| age | year | education | wage | |
|---|---|---|---|---|
| 0 | 18 | 2006 | 1. < HS Grad | 75.043154 |
| 1 | 24 | 2004 | 4. College Grad | 70.476020 |
| 2 | 45 | 2003 | 3. Some College | 130.982177 |
| 3 | 43 | 2003 | 4. College Grad | 154.685293 |
| 4 | 50 | 2005 | 2. HS Grad | 75.043154 |
Introduction to Statistical Learning, 2nd ed.
Last modified: 2026-09-29
Linear regression is interpretable but rigid. Whenever the true relationship between \(Y\) and \(X\) has curvature or thresholds, a straight line trades bias for nothing in return. This chapter keeps the linear- model machinery (OLS, GLM, standard errors) but enriches the model class.
WageA US labour-force survey with wage against age, year, education. Used throughout to make every method concrete.
Fit a degree-\(d\) polynomial in one predictor by treating powers of \(X\) as additional columns of the design matrix:
\[Y = \beta_0 + \beta_1 X + \beta_2 X^2 + \cdots + \beta_d X^d + \varepsilon\]
Still linear in the parameters, so OLS theory applies unchanged. The fitted curve is a smooth polynomial of degree \(d\); standard errors at any \(x_0\) come from the usual variance–covariance matrix of \(\hat{\boldsymbol\beta}\).
anova_lm).| coef | std err | t | P>|t| | [0.025 | 0.975] | |
|---|---|---|---|---|---|---|
| intercept | 111.7036 | 0.729 | 153.283 | 0.000 | 110.275 | 113.132 |
| poly(age, degree=4)[0] | 447.0679 | 39.915 | 11.201 | 0.000 | 368.805 | 525.331 |
| poly(age, degree=4)[1] | -478.3158 | 39.915 | -11.983 | 0.000 | -556.579 | -400.053 |
| poly(age, degree=4)[2] | 125.5217 | 39.915 | 3.145 | 0.002 | 47.259 | 203.785 |
| poly(age, degree=4)[3] | -77.9112 | 39.915 | -1.952 | 0.051 | -156.174 | 0.352 |
In both languages poly(..., 4) produces a 4-column design matrix using orthogonal polynomials, so individual t-statistics are independent — convenient for choosing the degree.
From the Ch07 handout:
Wage data with load_data('Wage'); store the response as y and the predictor as age.poly() inside MS() to build a 4th-degree polynomial in age, fit OLS, print the coefficient summary.age_grid of 100 evenly spaced values spanning observed age; wrap in a one-column DataFrame age_df.plot_wage_fit(age_df, basis, title) that fits OLS on basis, evaluates it on age_df, and plots data + curve + 95 % bands.age; pick a degree with anova_lm.education term alongside the polynomial in age.wage > 250 on the 4th-degree polynomial basis; plot \(P(Y=1)\) vs. age with a rug plot.Cut \(X\) into \(K+1\) intervals at chosen breakpoints \(c_1 < c_2 < \cdots < c_K\) and fit a separate constant in each interval:
\[Y = \beta_0 + \beta_1 \mathbf 1(c_1 \le X < c_2) + \cdots + \beta_K \mathbf 1(c_K \le X) + \varepsilon\]
The model is piecewise constant — no smoothness across cutpoints. Useful for predictors with a known threshold (e.g. tax brackets) or as a baseline against fancier smoothers.
pd.qcut(x, 4) splits at the 25 / 50 / 75 % quantiles, giving four roughly equal-sized bins. R’s cut(x, 4) instead divides the range of x into four equal-width intervals (which usually leaves the extreme bins much sparser); so the R panel above passes quantile-based breaks explicitly to match Python’s behaviour. In the Python fit each coefficient is the average wage in its bin (no intercept; cell-means parameterisation). The R fit uses the default treatment coding instead — the intercept is the mean of the first bin and the other three coefficients are differences from it.
From the Ch07 handout:
age into 4 quantile-based bins with pd.qcut(), build dummy columns with pd.get_dummies(), and fit OLS.Polynomial regression and step functions both replace the original \(X\) with a fixed family of basis functions \(b_1(X), b_2(X), \ldots, b_K(X)\) and fit OLS in the new design matrix:
\[Y = \beta_0 + \beta_1 b_1(X) + \cdots + \beta_K b_K(X) + \varepsilon\]
Regression splines use a smarter family — piecewise polynomials joined smoothly at knots — which makes a flexible, local fit possible without exploding the parameter count.
A cubic regression spline is piecewise cubic between knots, with the function value, first derivative, and second derivative continuous at each knot. The total number of free parameters is
\[\underbrace{(K + 3)}_{\text{degrees of freedom}} = (\text{knots}) + (\text{degree}) + 1,\]
equivalently the dimension of the cubic B-spline basis with \(K\) interior knots (no intercept).
A natural cubic spline adds boundary constraints (linear beyond the extreme knots), which trims four parameters off a cubic spline and prevents the wild tail behaviour cubic splines display at the edges of the data. Almost always preferred to plain cubic splines when knots extend to the boundary of \(X\).
df = 4); the basis constructor picks knot positions for you.intercept 56.313841
bs(age)[0] 27.824002
bs(age)[1] 54.062546
bs(age)[2] 65.828391
bs(age)[3] 55.812734
bs(age)[4] 72.131473
bs(age)[5] 14.750876
dtype: float64
For a natural spline replace bs(...) with ns(...) in either language — the rest of the workflow is identical.
From the Ch07 handout:
BSpline() with internal_knots=[25, 40, 60] and intercept=True; check the shape of the design matrix.bs() inside MS(); pass name='bs(age)' for cleaner column labels.BSpline for df=6; inspect internal_knots_ to see where knots ended up.df=3; compare its coefficients to the earlier qcut step function.df=5 via ns(); plot the fit with plot_wage_fit().Regression splines need you to pick knots and degrees of freedom. A smoothing spline puts a knot at every observation and controls flexibility through a penalty on roughness:
\[\hat g = \arg\min_{g}\;\sum_{i=1}^n \bigl(y_i - g(x_i)\bigr)^2 \;+\; \lambda \int g''(t)^2\,\mathrm dt\]
The fit \(\hat{\mathbf y} = \mathbf S_\lambda \mathbf y\) is linear in \(\mathbf y\) via a smoother matrix \(\mathbf S_\lambda\). Its trace gives the effective degrees of freedom:
\[\text{df}_\lambda = \operatorname{tr}(\mathbf S_\lambda).\]
A smoothing spline is often easier to think about by specifying \(\text{df}\) (e.g., “give me an age fit with df \(\approx 4\)”) and letting the software solve for \(\lambda\).
smooth.spline in R finds \(\lambda\) for the requested df directly. In pygam you set \(\lambda\) (lam) explicitly or use ISLP.pygam.approx_lam to translate a desired df into a \(\lambda\).
From the Ch07 handout:
LinearGAM(s_gam(0, lam=0.6)) on age → wage (reshape age to a 2-D feature matrix).lam over np.logspace(-2, 6, 5) and plot each resulting fit..gridsearch(X_age, y) on top.approx_lam() to find a lam giving ≈ 4 effective degrees of freedom; verify with degrees_of_freedom().df ∈ {1, 3, 4, 8, 15} (passing df + 1 to approx_lam for the intercept), refit, plot.For each query point \(x_0\):
Repeat across \(x_0\) to get a smooth curve.
Choose by visual inspection or cross-validation. The fitted span has a similar role to \(\lambda\) in smoothing splines.
statsmodels.lowess and R’s loess use slightly different defaults (degree, weighting, robustness) but produce qualitatively similar fits.
From the Ch07 handout:
sm.nonparametric.lowess to fit local linear regression models with spans of 0.2 and 0.5; plot them together. Note pygam does not support local-regression terms inside a GAM.Replace the linear sum with a sum of smooth functions, one per predictor:
\[Y = \alpha + f_1(X_1) + f_2(X_2) + \cdots + f_p(X_p) + \varepsilon\]
Strengths
Limits
LinearGAM(callbacks=[Deviance(), Diffs()], fit_intercept=True,
max_iter=100, scale=None, terms=s(0) + s(1) + f(2) + intercept,
tol=0.0001, verbose=False)
s(...) in both languages is a smoothing-spline term; f_gam in pygam (or a plain factor in R) handles categorical predictors. The mgcv::gam package fits a different smoothness selection algorithm than gam::gam — both are common choices in R.
Visualise the contribution of one predictor while the others are held at their mean (numeric) or summed out (categorical):
The shape of the partial dependence — flat / linear / hump-shaped / threshold — is what makes GAMs interpretable.
GAMs can be compared by nested ANOVA, just like OLS:
from pygam import l as l_gam
from ISLP.pygam import anova as anova_gam
# gam_no_year: drop year from the feature matrix entirely so the term
# index in the model spec lines up with the column index in X_no_yr.
X_no_yr = np.column_stack([Wage['age'], Wage['education'].cat.codes])
gam_no_year = LinearGAM(s_gam(0) + f_gam(1, lam=0)).fit(X_no_yr, Wage['wage'])
gam_lin_yr = LinearGAM(s_gam(0) + l_gam(1, lam=0) + f_gam(2, lam=0)).fit(X, Wage['wage'])
anova_gam(gam_no_year, gam_lin_yr, gam)| deviance | df | deviance_diff | df_diff | F | pvalue | |
|---|---|---|---|---|---|---|
| 0 | 105067.669012 | 2980.795583 | NaN | NaN | NaN | NaN |
| 1 | 104791.403410 | 2979.796561 | 276.265602 | 0.999023 | 7.863609 | 0.009898 |
| 2 | 104663.147348 | 2976.214249 | 128.256061 | 3.582312 | 1.018087 | 0.412203 |
For a binary outcome (\(Y \in \{0, 1\}\)) extend the additive model to a logistic link:
\[\log\frac{\Pr(Y = 1 \mid X)}{\Pr(Y = 0 \mid X)} = \alpha + \sum_{j=1}^p f_j(X_j)\]
LogisticGAM(callbacks=[Deviance(), Diffs(), Accuracy()],
fit_intercept=True, max_iter=100,
terms=s(0) + s(1) + f(2) + intercept, tol=0.0001, verbose=False)
From the Ch07 handout:
NaturalSpline(df=4) for age, df=5 for year, dummies for education; stack and fit OLS.age from the by-hand GAM.year.pygam: LinearGAM(s_gam(0) + s_gam(1, n_splines=7) + f_gam(2, lam=0)); plot the age partial dependence.gam_full so age and year each have ≈ 4 df via approx_lam(..., df=4+1); re-plot.education (categorical term).year: excluded vs. linear vs. smooth. What do you conclude?age.gam_full.summary() and call gam_full.predict() on the training set.high_earn (i.e. wage > 250); plot the education partial dependence.high_earn against education — what do you see?education, year, age.| Method | Smoothness | Knot choice | Parametric? | Interactions |
|---|---|---|---|---|
| Polynomial | High order ⇒ wiggly tails | None | Yes | Add product terms |
| Step | None at cutpoints | Manual | Yes | Cross-tabulate factors |
| Regression spline | \(C^2\) at knots | Manual or quantiles | Yes | Tensor product / bs interaction |
| Smoothing spline | Penalty-controlled | All points | Effectively no | Add tensor smooths |
| Local regression | Kernel-controlled | None | No | Multivariate kernel |
| GAM | Per-feature smooth | Per-feature | Hybrid | Add f(X_j, X_k) smooth |