Metrics - R-Squared and Adjusted R-Squared
What you’ll learn
- the decomposition , and where comes from
- why is a comparison against the mean baseline, not a percentage of correctness
- how can be negative, and what that tells you
- adjusted : the correction for feature count, and its exact formula
- why alone is never enough — Anscombe’s quartet, in one figure
- which metric to report in which situation
Intuition
RMSE tells you the size of a typical error, but it does not tell you whether that error is impressive. An RMSE of 50 is excellent if the target swings by thousands and useless if it swings by 60.
answers the second question by comparing your model against the laziest possible predictor: always guess the mean. If your errors are much smaller than the mean-predictor’s errors, is close to 1. If they are the same size, is 0. And if your model is somehow worse than guessing the mean, goes below zero — which is not a bug, it is the metric working.
flowchart TD Y["True values y"] --> M["Mean baseline ȳ"] Y --> F["Fitted model ŷ"] M --> SST["SST = sum of (y - ȳ)²
total variation"] F --> SSE["SSE = sum of (y - ŷ)²
variation still unexplained"] SST --> R2["R² = 1 - SSE / SST"] SSE --> R2
The math
Three sums of squares
The identity holds only when the model contains an intercept fitted by least squares — that is precisely what makes the residual orthogonal to the fitted values, killing the cross term. Drop the intercept and the decomposition, and every interpretation built on it, quietly stops being true.
What the value means
| Reading | |
|---|---|
| 1.0 | Every residual is zero. Suspect leakage before celebrating. |
| 0.7 | The model removes 70% of the variance the mean baseline leaves. |
| 0.0 | Exactly as good as always predicting the mean. |
| < 0 | Worse than predicting the mean. Possible on test data, impossible on training data with an intercept. |
That last row surprises people. On the training set, least squares with an intercept can always fall back to the mean, so is guaranteed there. On held-out data no such guarantee exists, and a badly overfitted model routinely goes deeply negative.
Adjusted
Adding any feature — including a column of random noise — can only reduce SSE, so can only rise. That makes it useless for comparing models with different feature counts. Adjusted charges rent for each feature:
with samples and features. The fraction exceeds 1 and grows with , so a new feature must improve by more than its cost to raise the adjusted value. It can go down, it can go negative, and it is always at most .
Worked example by hand
The Phase 3 five-point dataset with and :
| 1 | 2 | 2.8 | 4 | 1.44 | 0.64 |
| 2 | 4 | 3.4 | 0 | 0.36 | 0.36 |
| 3 | 5 | 4.0 | 1 | 0.00 | 1.00 |
| 4 | 4 | 4.6 | 0 | 0.36 | 0.36 |
| 5 | 5 | 5.2 | 1 | 1.44 | 0.04 |
| SST = 6.0 | SSR = 3.6 | SSE = 2.4 |
Step 1 — check the decomposition. . It balances exactly, as it must.
Step 2 — compute .
Step 3 — adjust for the one feature used. With and :
The drop from 0.60 to 0.467 is the price of one feature on only five samples. On 5,000 samples the same feature would cost almost nothing — which is exactly the behaviour you want.
See it move
The animation sweeps from a flat mean-only baseline to the least-squares fit. Red stems are the baseline’s errors, green are the model’s — and tracks the ratio between them.
From scratch
import numpy as np
from sklearn.metrics import r2_score
def r_squared(y, pred):
sse = float(((y - pred) ** 2).sum())
sst = float(((y - y.mean()) ** 2).sum())
return 1 - sse / sst
def adjusted_r_squared(y, pred, n_features):
m = len(y)
r2 = r_squared(y, pred)
return 1 - (1 - r2) * (m - 1) / (m - n_features - 1)
x = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
y = np.array([2.0, 4.0, 5.0, 4.0, 5.0])
pred = 0.6 * x + 2.2
sst = ((y - y.mean()) ** 2).sum()
ssr = ((pred - y.mean()) ** 2).sum()
sse = ((y - pred) ** 2).sum()
print(f"SST = {sst:.1f}") # SST = 6.0
print(f"SSR = {ssr:.1f}") # SSR = 3.6
print(f"SSE = {sse:.1f}") # SSE = 2.4
print(f"SSR + SSE = {ssr + sse:.1f}") # SSR + SSE = 6.0
print(f"R^2 = {r_squared(y, pred):.4f}") # R^2 = 0.6000
print(f"adjusted = {adjusted_r_squared(y, pred, 1):.4f}") # adjusted = 0.4667
print(f"sklearn = {r2_score(y, pred):.4f}") # sklearn = 0.6000import numpy as np
from sklearn.metrics import r2_score
def r_squared(y, pred):
sse = float(((y - pred) ** 2).sum())
sst = float(((y - y.mean()) ** 2).sum())
return 1 - sse / sst
def adjusted_r_squared(y, pred, n_features):
m = len(y)
r2 = r_squared(y, pred)
return 1 - (1 - r2) * (m - 1) / (m - n_features - 1)
x = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
y = np.array([2.0, 4.0, 5.0, 4.0, 5.0])
pred = 0.6 * x + 2.2
sst = ((y - y.mean()) ** 2).sum()
ssr = ((pred - y.mean()) ** 2).sum()
sse = ((y - pred) ** 2).sum()
print(f"SST = {sst:.1f}") # SST = 6.0
print(f"SSR = {ssr:.1f}") # SSR = 3.6
print(f"SSE = {sse:.1f}") # SSE = 2.4
print(f"SSR + SSE = {ssr + sse:.1f}") # SSR + SSE = 6.0
print(f"R^2 = {r_squared(y, pred):.4f}") # R^2 = 0.6000
print(f"adjusted = {adjusted_r_squared(y, pred, 1):.4f}") # adjusted = 0.4667
print(f"sklearn = {r2_score(y, pred):.4f}") # sklearn = 0.6000Why alone is never enough
It rewards useless features
The mechanism is not subtle: with samples and features, a linear model can interpolate the training data exactly and score having learned nothing. Any model comparison across different feature counts must use adjusted , cross-validation, or an information criterion.
It cannot see the shape of the data
Reading the plots
- I is the only honest one — a genuine linear relationship with scatter.
- II is a perfect parabola. The linear model is systematically wrong everywhere, and cannot tell.
- III is a perfect straight line ruined by a single outlier, which drags the fit off the true relationship.
- IV has no relationship at all. Ten points share one value; a single leverage point at determines the entire slope.
Constructed in 1973 to make exactly this argument, and the argument has not aged: plot your residuals. A metric is a summary, and summaries lose things.
Negative on real data
from sklearn.datasets import load_diabetes
from sklearn.linear_model import LinearRegression
from sklearn.model_selection import train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import PolynomialFeatures
X, y = load_diabetes(return_X_y=True)
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.3, random_state=42
)
overfit = make_pipeline(
PolynomialFeatures(4, include_bias=False), # 10 features -> 1,000+
LinearRegression(),
).fit(X_train, y_train)
print(f"train R^2 = {overfit.score(X_train, y_train):.4f}") # train R^2 = 1.0000
print(f"test R^2 = {overfit.score(X_test, y_test):.4f}") # test R^2 = -36.6836from sklearn.datasets import load_diabetes
from sklearn.linear_model import LinearRegression
from sklearn.model_selection import train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import PolynomialFeatures
X, y = load_diabetes(return_X_y=True)
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.3, random_state=42
)
overfit = make_pipeline(
PolynomialFeatures(4, include_bias=False), # 10 features -> 1,000+
LinearRegression(),
).fit(X_train, y_train)
print(f"train R^2 = {overfit.score(X_train, y_train):.4f}") # train R^2 = 1.0000
print(f"test R^2 = {overfit.score(X_test, y_test):.4f}") # test R^2 = -36.6836A perfect 1.0 on training and −36.68 on test. The model is thirty-seven times worse than a constant predictor on data it has not seen. Report only the training score and this looks like the best model ever built.
Which metric to report
| Metric | Units | Scale-free | Good for | Weakness |
|---|---|---|---|---|
| RMSE | Target’s | No | The headline error figure | Dominated by large errors |
| MAE | Target’s | No | Typical error, robust | Ignores how bad the worst case is |
| None | Yes | Comparing against a baseline | Rises with any added feature | |
| Adjusted | None | Yes | Comparing models of different sizes | Only meaningful for linear models |
| MAPE | Percent | Yes | Business reporting | Explodes when the target nears zero |
| Median AE | Target’s | No | Heavily skewed targets | Insensitive to the tails |
Reasonable default: report RMSE and together, both on held-out data. RMSE gives the magnitude, gives the context, and neither is enough alone.
Pitfalls
What does an R-squared of 0 mean?
R-squared is measured against the mean baseline. Zero means SSE equals SST — the model has bought you nothing over a constant prediction.
Show answer
B — The model performs exactly as well as always predicting the mean of the target — R-squared is measured against the mean baseline. Zero means SSE equals SST — the model has bought you nothing over a constant prediction.
You add ten columns of pure random noise. What happens to training R-squared?
Least squares can always assign a coefficient of zero to a useless column, so training error can never increase. In practice it fits noise and R-squared rises — which is exactly why adjusted R-squared exists.
Show answer
C — It rises, because extra columns can only reduce the training residuals — Least squares can always assign a coefficient of zero to a useless column, so training error can never increase. In practice it fits noise and R-squared rises — which is exactly why adjusted R-squared exists.
A test-set R-squared of -36 tells you what?
Negative values are impossible on the training set of an intercept model but perfectly possible on held-out data. It is the signature of severe overfitting.
Show answer
B — The model is far worse on unseen data than simply predicting the training mean — Negative values are impossible on the training set of an intercept model but perfectly possible on held-out data. It is the signature of severe overfitting.
All four Anscombe datasets score R-squared 0.67. What is the lesson?
R-squared is useful but lossy. The quartet was built to show that identical statistics can hide completely different structures, and only a plot reveals them.
Show answer
B — A single summary statistic cannot distinguish a good fit from a curve, an outlier, or a leverage point — always plot the residuals — R-squared is useful but lossy. The quartet was built to show that identical statistics can hide completely different structures, and only a plot reveals them.
🧪 Try It Yourself
Exercise 1 – Compute R-squared from two sums
Exercise 2 – Verify the decomposition
Exercise 3 – Adjust for the feature count
Exercise 4 – Watch noise inflate R-squared
Exercise 5 – Produce a negative R-squared
Recap
- holds whenever the model has a least-squares intercept; .
- On the five-point set: SST 6.0, SSR 3.6, SSE 2.4, so and adjusted .
- compares against the mean baseline. Zero means no better than a constant; negative means worse, which happens routinely on held-out data.
- rises with every added feature, even pure noise. Use adjusted or cross-validation when feature counts differ.
- Anscombe’s quartet: four datasets, one fitted line, one , four different truths.
- Report RMSE and together, always on held-out data, and always look at the residual plot.
Exercise 6 – Buy with pure noise
Next
Phase 3 ends here. Continue to Phase 4 - Supervised Learning - Classification — the same machinery pointed at a target that takes labels instead of numbers, where a whole new family of metrics becomes necessary.
If this helped you, consider buying me a coffee ☕
Buy me a coffeeWas this page helpful?
Let us know how we did
