Skip to content

Metrics - R-Squared and Adjusted R-Squared

What you’ll learn

  • the decomposition SST=SSE+SSR\text{SST} = \text{SSE} + \text{SSR}, and where R2R^2 comes from
  • why R2R^2 is a comparison against the mean baseline, not a percentage of correctness
  • how R2R^2 can be negative, and what that tells you
  • adjusted R2R^2: the correction for feature count, and its exact formula
  • why R2R^2 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.

R2R^2 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, R2R^2 is close to 1. If they are the same size, R2R^2 is 0. And if your model is somehow worse than guessing the mean, R2R^2 goes below zero — which is not a bug, it is the metric working.

diagram Diagram mermaid

The math

Three sums of squares

i(yiyˉ)2SST, total=i(y^iyˉ)2SSR, explained+i(yiy^i)2SSE, residual\underbrace{\sum_i (y_i - \bar{y})^2}_{\text{SST, total}} = \underbrace{\sum_i (\hat{y}_i - \bar{y})^2}_{\text{SSR, explained}} + \underbrace{\sum_i (y_i - \hat{y}_i)^2}_{\text{SSE, residual}}

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.

R2=1SSESST=SSRSSTR^2 = 1 - \frac{\text{SSE}}{\text{SST}} = \frac{\text{SSR}}{\text{SST}}

What the value means

R2R^2Reading
1.0Every residual is zero. Suspect leakage before celebrating.
0.7The model removes 70% of the variance the mean baseline leaves.
0.0Exactly as good as always predicting the mean.
< 0Worse 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 R20R^2 \geq 0 is guaranteed there. On held-out data no such guarantee exists, and a badly overfitted model routinely goes deeply negative.

Adjusted R2R^2

Adding any feature — including a column of random noise — can only reduce SSE, so R2R^2 can only rise. That makes it useless for comparing models with different feature counts. Adjusted R2R^2 charges rent for each feature:

Radj2=1(1R2)m1mk1R^2_{\text{adj}} = 1 - \left(1 - R^2\right)\frac{m - 1}{m - k - 1}

with mm samples and kk features. The fraction m1mk1\frac{m-1}{m-k-1} exceeds 1 and grows with kk, so a new feature must improve R2R^2 by more than its cost to raise the adjusted value. It can go down, it can go negative, and it is always at most R2R^2.

Worked example by hand

The Phase 3 five-point dataset with y^=0.6x+2.2\hat{y} = 0.6x + 2.2 and yˉ=4\bar{y} = 4:

xix_iyiy_iy^i\hat{y}_i(yiyˉ)2(y_i - \bar{y})^2(y^iyˉ)2(\hat{y}_i - \bar{y})^2(yiy^i)2(y_i - \hat{y}_i)^2
122.841.440.64
243.400.360.36
354.010.001.00
444.600.360.36
555.211.440.04
SST = 6.0SSR = 3.6SSE = 2.4

Step 1 — check the decomposition. 3.6+2.4=6.03.6 + 2.4 = 6.0. It balances exactly, as it must.

Step 2 — compute R2R^2.

R2=12.46.0=0.60R^2 = 1 - \frac{2.4}{6.0} = 0.60

Step 3 — adjust for the one feature used. With m=5m = 5 and k=1k = 1:

Radj2=1(10.60)51511=10.4430.467R^2_{\text{adj}} = 1 - (1 - 0.60)\cdot\frac{5-1}{5-1-1} = 1 - 0.4\cdot\frac{4}{3} \approx 0.467

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.

figureThe two sums of squares, side by sidematplotlib
Two panels of the same five points. Left: red stems from each point to the horizontal mean line, labelled SST = 6.0. Right: green stems from each point to the fitted line, labelled SSE = 2.4.Two panels of the same five points. Left: red stems from each point to the horizontal mean line, labelled SST = 6.0. Right: green stems from each point to the fitted line, labelled SSE = 2.4.
R-squared is literally the ratio of the total green length to the total red length, subtracted from one. Shorter green stems mean a higher score.

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 R2R^2 tracks the ratio between them.

sketch R-squared as shrinking residuals p5.js
As the fitted line improves, the green residuals shrink relative to the red baseline distances — that ratio is exactly what R-squared measures.

From scratch

r2_from_scratch.py
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.6000
r2_from_scratch.py
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.6000

Why R2R^2 alone is never enough

It rewards useless features

figureAdding columns of pure random noise to a two-feature problemmatplotlib
Two curves against the number of features. R-squared rises monotonically toward 1 as pure noise columns are added; adjusted R-squared rises briefly then falls away.Two curves against the number of features. R-squared rises monotonically toward 1 as pure noise columns are added; adjusted R-squared rises briefly then falls away.
Only the first two features carry any signal. R-squared climbs regardless, all the way to 1 as the feature count approaches the sample count. Adjusted R-squared turns down almost immediately.

The mechanism is not subtle: with mm samples and m1m-1 features, a linear model can interpolate the training data exactly and score R2=1R^2 = 1 having learned nothing. Any model comparison across different feature counts must use adjusted R2R^2, cross-validation, or an information criterion.

It cannot see the shape of the data

figureAnscombe's quartet: four datasets, one set of statisticsmatplotlib
Four scatter plots with identical fitted lines. The first is a normal cloud, the second a clean parabola, the third a straight line with one outlier, the fourth a vertical stack with one distant point.Four scatter plots with identical fitted lines. The first is a normal cloud, the second a clean parabola, the third a straight line with one outlier, the fourth a vertical stack with one distant point.
Every panel has the same fitted line, y = 3 + 0.5x, and R-squared within 0.0005 of 0.667. Only one of the four is a dataset where linear regression is the right answer.

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 R2R^2 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 xx value; a single leverage point at x=19x = 19 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 R2R^2 on real data

negative_r2.py
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.6836
negative_r2.py
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.6836

A 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

MetricUnitsScale-freeGood forWeakness
RMSETarget’sNoThe headline error figureDominated by large errors
MAETarget’sNoTypical error, robustIgnores how bad the worst case is
R2R^2NoneYesComparing against a baselineRises with any added feature
Adjusted R2R^2NoneYesComparing models of different sizesOnly meaningful for linear models
MAPEPercentYesBusiness reportingExplodes when the target nears zero
Median AETarget’sNoHeavily skewed targetsInsensitive to the tails

Reasonable default: report RMSE and R2R^2 together, both on held-out data. RMSE gives the magnitude, R2R^2 gives the context, and neither is enough alone.

Pitfalls

quizCheck yourself
  1. What does an R-squared of 0 mean?

    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.

  2. You add ten columns of pure random noise. What happens to training R-squared?

    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.

  3. A test-set R-squared of -36 tells you what?

    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.

  4. All four Anscombe datasets score R-squared 0.67. What is the lesson?

    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

  • SST=SSR+SSE\text{SST} = \text{SSR} + \text{SSE} holds whenever the model has a least-squares intercept; R2=1SSE/SSTR^2 = 1 - \text{SSE}/\text{SST}.
  • On the five-point set: SST 6.0, SSR 3.6, SSE 2.4, so R2=0.60R^2 = 0.60 and adjusted R2=0.467R^2 = 0.467.
  • R2R^2 compares against the mean baseline. Zero means no better than a constant; negative means worse, which happens routinely on held-out data.
  • R2R^2 rises with every added feature, even pure noise. Use adjusted R2R^2 or cross-validation when feature counts differ.
  • Anscombe’s quartet: four datasets, one fitted line, one R2R^2, four different truths.
  • Report RMSE and R2R^2 together, always on held-out data, and always look at the residual plot.

Exercise 6 – Buy R2R^2 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 coffee

Was this page helpful?

Let us know how we did