Polynomial Regression
What you’ll learn
- why adding to the feature matrix lets a linear model fit a curve
- how to solve a quadratic fit by hand on five points
- how
PolynomialFeaturesPolynomialFeaturesexplodes combinatorially, and how fast - how to choose the degree with cross-validation and the one-standard-error rule
- how to read a learning curve to tell underfitting from overfitting
- why polynomial pipelines must scale their features
Intuition
A straight line through curved data is wrong in a specific, visible way: it misses low, then high, then low again. The residual plot shows a smile or a frown instead of a shapeless band.
The fix is disarmingly cheap. Do not change the algorithm — change the columns. Hand the model alongside , and least squares will find the best parabola exactly the way it found the best line, because the model is still a weighted sum of whatever columns you supply. Linear regression is linear in the parameters, and never had an opinion about the features.
flowchart LR X["Raw feature x"] --> PF["PolynomialFeatures(degree=2)"] PF --> C["Columns: x, x²"] C --> LR["The same LinearRegression"] LR --> Y["Curved prediction ŷ"]
The math
The model
A degree- polynomial in one feature:
Rename and it reads — an ordinary multiple linear regression. The Normal Equation applies unchanged:
That matrix has a name — the Vandermonde matrix — and a reputation: it becomes badly conditioned fast as grows, which is one reason high-degree fits misbehave numerically as well as statistically.
How many terms?
With input features and degree , PolynomialFeaturesPolynomialFeatures generates every product of total
degree at most :
| features | degree 2 | degree 3 | degree 5 |
|---|---|---|---|
| 1 | 3 | 4 | 6 |
| 3 | 10 | 20 | 56 |
| 10 | 66 | 286 | 3,003 |
| 100 | 5,151 | 176,851 | ≈ 96 million |
Two features at degree 3 already give you interaction terms like — which is genuinely useful, and also how a tidy 10-column dataset becomes 286 columns without anyone deciding to do that.
Worked example by hand
Five points, chosen symmetric about zero so that and . That kills half the terms in and makes the algebra tractable.
| 1 | −2 | 9 | 4 | −18 | 36 |
| 2 | −1 | 4 | 1 | −4 | 4 |
| 3 | 0 | 1 | 0 | 0 | 0 |
| 4 | 1 | 2 | 1 | 2 | 2 |
| 5 | 2 | 9 | 4 | 18 | 36 |
| 0 | 25 | 10 | −2 | 78 |
Step 1 — assemble the normal equations. With , , and :
Step 2 — the middle row is already solved. , so .
Step 3 — solve the remaining 2×2 system.
Substitute into :
The fitted curve is .
Step 4 — check.
| residual | squared | |||
|---|---|---|---|---|
| −2 | 9 | 9.4 | −0.4 | 0.16 |
| −1 | 4 | 3.2 | +0.8 | 0.64 |
| 0 | 1 | 1.0 | 0.0 | 0.00 |
| 1 | 2 | 2.8 | −0.8 | 0.64 |
| 2 | 9 | 8.6 | +0.4 | 0.16 |
| 0.0 | 1.60 |
Nothing here is new machinery. It is the Normal Equation from the previous page, applied to a design matrix that happens to contain a squared column.
See it move
Three capacities cycling over the same curved dataset — watch the degree-12 curve chase individual points instead of the trend.
From scratch
Build the Vandermonde matrix explicitly and reuse the Normal Equation:
import numpy as np
def vandermonde(x, degree):
"""Columns [1, x, x^2, ..., x^degree]."""
x = np.asarray(x, dtype=float)
return np.vstack([x**k for k in range(degree + 1)]).T
x = np.array([-2.0, -1.0, 0.0, 1.0, 2.0])
y = np.array([9.0, 4.0, 1.0, 2.0, 9.0])
X = vandermonde(x, 2)
print(X)
# [[ 1. -2. 4.]
# [ 1. -1. 1.]
# [ 1. 0. 0.]
# [ 1. 1. 1.]
# [ 1. 2. 4.]]
theta = np.linalg.solve(X.T @ X, X.T @ y)
print(theta.round(4)) # [ 1. -0.2 2. ]
resid = y - X @ theta
print(resid.round(4)) # [-0.4 0.8 0. -0.8 0.4]
print(round((resid ** 2).sum(), 4)) # 1.6import numpy as np
def vandermonde(x, degree):
"""Columns [1, x, x^2, ..., x^degree]."""
x = np.asarray(x, dtype=float)
return np.vstack([x**k for k in range(degree + 1)]).T
x = np.array([-2.0, -1.0, 0.0, 1.0, 2.0])
y = np.array([9.0, 4.0, 1.0, 2.0, 9.0])
X = vandermonde(x, 2)
print(X)
# [[ 1. -2. 4.]
# [ 1. -1. 1.]
# [ 1. 0. 0.]
# [ 1. 1. 1.]
# [ 1. 2. 4.]]
theta = np.linalg.solve(X.T @ X, X.T @ y)
print(theta.round(4)) # [ 1. -0.2 2. ]
resid = y - X @ theta
print(resid.round(4)) # [-0.4 0.8 0. -0.8 0.4]
print(round((resid ** 2).sum(), 4)) # 1.6Same numbers as the hand calculation, including the residuals.
With scikit-learn
Never fit PolynomialFeaturesPolynomialFeatures outside a pipeline — the expansion has to be re-applied identically
at prediction time, and the scaler after it has to be fitted on training data only:
import numpy as np
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import PolynomialFeatures, StandardScaler
from sklearn.linear_model import LinearRegression
rng = np.random.default_rng(7)
x = np.sort(rng.uniform(-3, 3, 120)).reshape(-1, 1)
y = 0.5 * x.ravel() ** 2 + x.ravel() + 2 + rng.normal(0, 1.1, 120)
model = make_pipeline(
PolynomialFeatures(degree=2, include_bias=False),
StandardScaler(), # essential: x^5 dwarfs x, and scale wrecks conditioning
LinearRegression(),
)
model.fit(x, y)
print(model.score(x, y).round(4)) # 0.8196
print(model[0].get_feature_names_out()) # ['x0' 'x0^2']
print(model[-1].coef_.round(3)) # [1.78 1.248]import numpy as np
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import PolynomialFeatures, StandardScaler
from sklearn.linear_model import LinearRegression
rng = np.random.default_rng(7)
x = np.sort(rng.uniform(-3, 3, 120)).reshape(-1, 1)
y = 0.5 * x.ravel() ** 2 + x.ravel() + 2 + rng.normal(0, 1.1, 120)
model = make_pipeline(
PolynomialFeatures(degree=2, include_bias=False),
StandardScaler(), # essential: x^5 dwarfs x, and scale wrecks conditioning
LinearRegression(),
)
model.fit(x, y)
print(model.score(x, y).round(4)) # 0.8196
print(model[0].get_feature_names_out()) # ['x0' 'x0^2']
print(model[-1].coef_.round(3)) # [1.78 1.248]include_bias=Falseinclude_bias=False matters: LinearRegressionLinearRegression already fits an intercept, so leaving the
constant column in gives you two intercepts and a rank-deficient design.
Choosing the degree
Training error falls forever as you add degrees. It has to — each extra term is a strictly larger family of curves. The only honest signal comes from data the model has not seen:
Reading the plot
- The cliff at degree 2 is the real signal. CV error drops from 2.79 to 1.06 and never improves meaningfully again. The data was generated by a quadratic, and the curve says so.
- Degrees 2 through 10 are a tie. The shaded band is one standard error across folds; every point in that range sits inside it. Picking the exact minimum here would be reading noise.
- The one-standard-error rule. Find the best CV score, add one standard error, and take the simplest model still under that threshold. Here it selects degree 2 — the right answer, and the one that generalises.
- The gap after degree 10 is overfitting made visible. Training error keeps falling while CV error climbs.
Learning curves
The degree curve varies capacity at fixed data. A learning curve does the opposite: fixed capacity, growing data. The two failure modes look completely different.
| Symptom | Diagnosis | What actually helps |
|---|---|---|
| Both curves plateau high, close together | Underfitting (high bias) | More capacity: higher degree, more features |
| Training low, validation high, gap persists | Overfitting (high variance) | More data, lower degree, regularisation |
| Both low and converged | Good fit | Ship it |
APIsklearn.preprocessing.PolynomialFeatures + sklearn.linear_model.LinearRegression
Assumes
- The relationship is smooth and can be approximated by a polynomial on the observed range
- Observations are independent
- Residual variance is constant
Cost
- train
O(m·p² + p³)- predict
O(p)- memory
O(p)
m = samples, p = expanded term count = binomial(n + d, d)
Hyperparameters that matter
degreedefault 2The one that matters. Choose by cross-validation, then take the simplest degree within one standard error of the best.include_biasdefault TrueSet False inside a pipeline whose final estimator already fits an intercept.interaction_onlydefault FalseKeep cross terms like x0·x1 but drop pure powers — useful when you want interactions without curvature.
Reach for it when
- A scatter plot or residual plot shows smooth curvature
- You have one to a handful of features and want to keep interpretability
- A physical relationship is genuinely quadratic or cubic
Look elsewhere when
- You have many features — the term count explodes combinatorially
- You need to predict outside the training range; polynomials diverge violently
- The curve has sharp kinks or steps — use splines or trees instead
Pitfalls
Compare
| Approach | Shape it fits | Extrapolates | Cost with many features | Interpretability |
|---|---|---|---|---|
| Polynomial regression | Smooth global curve | Very badly | Explodes | Good at low degree |
| Splines | Smooth, piecewise, local | Poorly | Linear | Moderate |
| Decision tree | Steps | Flat (constant) | Linear | Moderate |
| Random forest | Smooth steps | Flat | Linear | Poor |
| Kernel ridge (RBF) | Smooth, local | Reverts to mean | Quadratic in samples | Poor |
| Log/sqrt transform | One fixed curve shape | Reasonably | Free | Very good |
If the curvature is monotone, try transforming or with a log before reaching for a polynomial. One column, no degree to tune, and it extrapolates sensibly.
Why is polynomial regression still called a linear model?
Linearity refers to how the parameters enter the model. Treat x squared as a new feature and ordinary least squares applies with no modification.
Show answer
B — Because it is linear in the parameters — x squared is just another column — Linearity refers to how the parameters enter the model. Treat x squared as a new feature and ordinary least squares applies with no modification.
Training MSE keeps falling as you raise the degree while CV MSE starts rising after degree 10. What is happening?
Training error must fall with capacity. When held-out error turns upward, the extra flexibility is being spent memorising noise.
Show answer
B — Overfitting: the extra capacity is fitting noise that does not generalise — Training error must fall with capacity. When held-out error turns upward, the extra flexibility is being spent memorising noise.
You have 10 features and set degree=3. Roughly how many columns does PolynomialFeatures produce?
The count is binomial(n + d, d) = binomial(13, 3) = 286, including the bias term. The growth is combinatorial, not linear.
Show answer
C — 286 — The count is binomial(n + d, d) = binomial(13, 3) = 286, including the bias term. The growth is combinatorial, not linear.
Your CV curve is flat between degrees 2 and 10. Which degree should you ship?
Differences inside one standard error are noise. The one-standard-error rule takes the simplest model that is statistically indistinguishable from the best, which is more likely to hold up on new data.
Show answer
B — Degree 2 — the simplest model within one standard error of the best — Differences inside one standard error are noise. The one-standard-error rule takes the simplest model that is statistically indistinguishable from the best, which is more likely to hold up on new data.
🧪 Try It Yourself
Exercise 1 – Expand features by hand
Exercise 2 – Solve the quadratic fit
Exercise 3 – Build a polynomial pipeline
Exercise 4 – Watch overfitting appear
Exercise 5 – Extrapolate and watch it explode
Recap
- Adding as columns lets ordinary least squares fit curves — the model stays linear in its parameters.
- The hand-worked quadratic on five points gives with .
PolynomialFeaturesPolynomialFeaturesproduces terms; 10 features at degree 3 is 286 columns.- Choose the degree with cross-validation, then apply the one-standard-error rule to prefer the simplest model in the tie.
- Learning curves distinguish the two failure modes: converged-and-high is bias, wide-gap is variance.
- Scale after expanding, and never extrapolate.
Exercise 6 – Apply the one-standard-error rule
Next
Continue to Cost Functions - Mean Squared Error (MSE) — a closer look at the loss every fit on this page was silently minimising, and what changes when you pick a different one.
If this helped you, consider buying me a coffee ☕
Buy me a coffeeWas this page helpful?
Let us know how we did
