Linear Models in Action#

Here we put linear models to work on the IXI data, and meet the first villain of predictive modelling: overfitting.

We start, as every notebook in this book does, by loading the data, shuffling it with a fixed seed (see chapter 1 for why), and naming the target and the features.

import numpy as np
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt
import statsmodels.api as sm
import statsmodels.formula.api as smf

DATA_URL = "https://raw.githubusercontent.com/pni-lab/predmod_lecture/master/ex_data/IXI/ixi.csv"

ixi = pd.read_csv(DATA_URL).sample(frac=1, random_state=42).reset_index(drop=True)
TARGET = 'Age'
FEATURES = [c for c in ixi.columns if c.endswith('_volume')]

# for this chapter we deliberately work with a small study: 70 participants
df = ixi.iloc[:70].copy()

print(f'{len(df)} participants, {len(FEATURES)} candidate features')
70 participants, 68 candidate features

A baseline: the simplest possible prediction#

Before building anything, it is worth asking what a trivial prediction achieves. The simplest possible guess for someone’s age, if we know nothing about them, is the average age of the group:

df.hist('Age', bins=15)
plt.xlabel('age (years)')
plt.show()

mean_age = df[TARGET].mean()
print(f'mean age: {mean_age:.1f} years')
../_images/276e7ca9e61efde2407d1343cf2df8b9b3b602319f4d154ca333a66d6f978daf.png
mean age: 48.2 years
baseline_predictions = np.full(len(df), mean_age)

def mae(y_true, y_pred):
    return np.mean(np.abs(np.asarray(y_true) - np.asarray(y_pred)))

def rmse(y_true, y_pred):
    return np.sqrt(np.mean((np.asarray(y_true) - np.asarray(y_pred)) ** 2))

print(f'baseline MAE : {mae(df[TARGET], baseline_predictions):.2f} years')
print(f'baseline RMSE: {rmse(df[TARGET], baseline_predictions):.2f} years')
baseline MAE : 13.67 years
baseline RMSE: 15.70 years

Roughly fourteen years off, on average. That is our yardstick: any model worth the name has to beat it, and “our model achieves an MAE of 10 years” means nothing until you know what the trivial guess achieves on the same data.

Note

Baselines like this are standard practice outside biomedicine. In weather forecasting, the benchmark that early models struggled to beat was persistence: predicting that tomorrow will be like today. Many published prediction models in the clinical literature are never compared to any baseline at all, which makes their performance impossible to judge.

One predictor

We can surely do better by using something we know about each person. From chapter 1 we know that cortical volume declines with age more or less everywhere. Let’s use one region:

df.plot.scatter(x='rh_superiorfrontal_volume', y='Age', alpha=0.7)
plt.show()
../_images/ac3aef86ef0f2861be0cc7b15a243bc662e370b791e4907c31e5429878249616.png

We fit a linear model with ols (ordinary least squares) from statsmodels. The syntax mirrors R: a formula, then the data.

fitted_model = smf.ols('Age ~ rh_superiorfrontal_volume', data=df).fit()
fitted_model.summary()
OLS Regression Results
Dep. Variable: Age R-squared: 0.305
Model: OLS Adj. R-squared: 0.294
Method: Least Squares F-statistic: 29.79
Date: Thu, 24 Sep 2026 Prob (F-statistic): 7.33e-07
Time: 12:14:24 Log-Likelihood: -279.35
No. Observations: 70 AIC: 562.7
Df Residuals: 68 BIC: 567.2
Df Model: 1
Covariance Type: nonrobust
coef std err t P>|t| [0.025 0.975]
Intercept 106.5395 10.807 9.859 0.000 84.975 128.104
rh_superiorfrontal_volume -0.0027 0.000 -5.458 0.000 -0.004 -0.002
Omnibus: 1.207 Durbin-Watson: 2.333
Prob(Omnibus): 0.547 Jarque-Bera (JB): 1.270
Skew: -0.276 Prob(JB): 0.530
Kurtosis: 2.639 Cond. No. 1.50e+05


Notes:
[1] Standard Errors assume that the covariance matrix of the errors is correctly specified.
[2] The condition number is large, 1.5e+05. This might indicate that there are
strong multicollinearity or other numerical problems.

This is the output a “classical” analysis would stop at: a significant negative association, with an \(R^2\) telling us how much of the variance in age is explained. Let’s instead pull out the coefficients and use them to predict.

beta = fitted_model.params
beta
Intercept                    106.53953
rh_superiorfrontal_volume     -0.00267
dtype: float64

The coefficients are the \(\beta_0\) and \(\beta_1\) of the equation \(y_{i} = \beta_{0} + \beta_{1}x_{i} + \varepsilon_{i}\), so we can predict the age of a hypothetical new participant whose right superior frontal cortex has a volume of 25,000 mm³ with nothing more than arithmetic:

x_new = 25000
y_hat = beta.iloc[0] + beta.iloc[1] * x_new

print(f'predicted age: {y_hat:.1f} years')
predicted age: 39.8 years

Do that for a whole range of volumes and you have drawn the regression line:

x_grid = np.arange(14000, 30000, 500)
y_grid = beta.iloc[0] + beta.iloc[1] * x_grid

ax = df.plot.scatter(x='rh_superiorfrontal_volume', y='Age', alpha=0.7)
sns.lineplot(x=x_grid, y=y_grid, color='red', ax=ax)
plt.show()
../_images/74b264525a3e32da91563f930791b38c9794aee7a3d64b90d3a597539f6db538.png

Seaborn draws the same thing in one line, with a confidence band:

sns.regplot(x='rh_superiorfrontal_volume', y='Age', data=df, line_kws={'color': 'red'})
plt.show()
../_images/3ec4a7a4d8015bb12c734f677823a6eb17bc7a7833b1239e9bd3cd665dec39a7.png

Note

You may have noticed that the predictors are not normally distributed. Normality and the other assumptions of linear regression matter a great deal for inference — for p-values and confidence intervals to mean what they claim. They are not preconditions for a model to predict well. A properly validated predictive model is judged by its errors on unseen data, and that judgement does not rest on distributional assumptions. This is one of the genuine liberations of the predictive framework, and we will make use of it.

How much did one brain region buy us?

y_hat_all = fitted_model.predict(df)

print(f'baseline (mean age)  MAE: {mae(df[TARGET], baseline_predictions):5.2f} years')
print(f'one-region model     MAE: {mae(df[TARGET], y_hat_all):5.2f} years')
print(f'one-region model    RMSE: {rmse(df[TARGET], y_hat_all):5.2f} years')
baseline (mean age)  MAE: 13.67 years
one-region model     MAE: 10.77 years
one-region model    RMSE: 13.09 years

A few years better than the trivial guess. Modest, but real.

More predictors#

We have 67 more regions sitting unused. The obvious next move is to add them.

five_regions = ['rh_superiorfrontal_volume', 'lh_bankssts_volume',
                'lh_caudalanteriorcingulate_volume', 'lh_caudalmiddlefrontal_volume',
                'lh_cuneus_volume']

model_5 = smf.ols('Age ~ ' + ' + '.join(five_regions), data=df).fit()
y_hat_5 = model_5.predict(df)

print(f'five-region model    MAE: {mae(df[TARGET], y_hat_5):5.2f} years')
five-region model    MAE: 10.41 years

Better again. So let’s be systematic: add regions one at a time, and watch the error fall.

def fit_and_score(feature_columns, data=df):
    """Fit OLS on the given columns and return the MAE on the same data."""
    X = sm.add_constant(data[list(feature_columns)].values)
    model = sm.OLS(data[TARGET].values, X).fit()
    return mae(data[TARGET], model.predict(X))


# a fixed, nested sequence of feature sets: the first k features, for k = 1 ... 68
# we keep looking at the same region first, so that the curve below starts from the model we have
# just fitted, and then adds the other 67 in a fixed order
ORDERED_FEATURES = ['rh_superiorfrontal_volume'] + [f for f in FEATURES if f != 'rh_superiorfrontal_volume']

n_features = range(1, len(FEATURES) + 1)
mae_real = [fit_and_score(ORDERED_FEATURES[:k]) for k in n_features]

sns.lineplot(x=list(n_features), y=mae_real)
plt.xlabel('number of predictors in the model')
plt.ylabel('MAE on the data used for fitting (years)')
plt.axhline(mae(df[TARGET], baseline_predictions), ls='--', color='0.6')
plt.text(2, mae(df[TARGET], baseline_predictions) - 0.7, 'baseline', color='0.5')
plt.show()

print(f'lowest MAE reached: {min(mae_real):.2f} years')
../_images/9a51daabe4abf683e933d138abc920fbf51163178b44fd6ee5b78f0156abc8d4.png
lowest MAE reached: 0.00 years

The error falls steadily, and with all 68 regions in the model it has collapsed to essentially zero. Age predicted to within a small fraction of a year from brain structure alone!

This should worry you. The best published brain-age models — deep networks trained on tens of thousands of scans — report mean absolute errors in the region of 3 to 5 years, with MAE = 2.71 ± 2.10 among the strongest results [7]. We have just beaten the state of the art with 70 participants and a linear model.

The same experiment, with nonsense#

To see what is actually happening, let’s repeat the experiment with predictors that are guaranteed to carry no information about age at all: random numbers.

rng = np.random.default_rng(seed=42)
random_features = pd.DataFrame(
    rng.normal(size=(len(df), len(FEATURES))),
    columns=[f'noise_{i}' for i in range(len(FEATURES))],
)
random_features[TARGET] = df[TARGET].values

mae_random = [fit_and_score(random_features.columns[:k], data=random_features)
              for k in n_features]

sns.lineplot(x=list(n_features), y=mae_real, label='68 cortical volumes')
sns.lineplot(x=list(n_features), y=mae_random, label='68 random numbers')
plt.xlabel('number of predictors in the model')
plt.ylabel('MAE on the data used for fitting (years)')
plt.legend()
plt.show()

print(f'lowest MAE with real features  : {min(mae_real):.2f} years')
print(f'lowest MAE with random features: {min(mae_random):.2f} years')
../_images/1e94012ff8b0a06dfc322ca198d6e85ac9e0c0e714a6504b61b0a526deb50bd5.png
lowest MAE with real features  : 0.00 years
lowest MAE with random features: 2.51 years

Look at what the random curve does. Predictors that cannot possibly know anything about age still drive the error down from the 13.7-year baseline to under three years, simply by being added to the model. Nothing was learned; the model merely acquired enough free parameters to bend itself through the 70 points in front of it.

The real features do stay below the noise features throughout, and that vertical gap is the one part of the picture that reflects genuine information about ageing. But the gap is a small fraction of the total drop. Most of what looks like improvement in the left-hand curve is the same memorization that produced the right-hand one — and from the fitting error alone, you cannot tell the two apart.

How far does this go?#

We stopped at 68 random predictors only because that is how many brain regions we have. Nothing stops us generating more. So let’s draw a fresh, wider set of noise — 69 columns this time — and watch what happens as the number of free parameters approaches the number of participants.

# a fresh, wider draw of noise: one column short of one per participant
more_noise = pd.DataFrame(
    rng.normal(size=(len(df), len(df) - 1)),
    columns=[f'noise_{i}' for i in range(len(df) - 1)],
)
more_noise[TARGET] = df[TARGET].values

n_noise = range(1, len(df))
mae_more = [fit_and_score(more_noise.columns[:k], data=more_noise) for k in n_noise]

sns.lineplot(x=list(n_noise), y=mae_more, color='#d62728')
plt.axvline(len(df) - 1, ls='--', color='0.4')
plt.text(3, max(mae_more) * 0.18,
         f'{len(df) - 1} predictors + intercept\n= {len(df)} parameters\n= {len(df)} participants',
         color='0.4', fontsize=9)
plt.xlabel('number of random predictors in the model')
plt.ylabel('MAE on the data used for fitting (years)')
plt.show()
../_images/f4f6c05a779ec162d6dd3d1d916c0436058c03562059f394b0c47548ea261ae6.png
def fit_summary(k):
    """MAE and R-squared of an OLS fit on the first k random predictors."""
    X = sm.add_constant(more_noise[more_noise.columns[:k]].values)
    fitted = sm.OLS(df[TARGET].values, X).fit()
    predictions = fitted.predict(X)
    return {'random predictors': k,
            'free parameters': k + 1,
            'MAE (years)': f'{mae(df[TARGET], predictions):.6f}',
            'R-squared': f'{fitted.rsquared:.6f}'}


pd.DataFrame([fit_summary(k) for k in [20, 40, 60, 67, 68, 69]])
random predictors free parameters MAE (years) R-squared
0 20 21 11.892333 0.180820
1 40 41 8.596495 0.547342
2 60 61 5.438402 0.823156
3 67 68 2.773354 0.948718
4 68 69 2.770694 0.948719
5 69 70 0.000000 1.000000

At 69 random predictors — 70 free parameters, for 70 participants — the error does not merely get small. It becomes zero, and \(R^2\) becomes 1. The model reproduces every participant’s age to the last decimal place, from numbers drawn out of a random number generator. (The printed values are zero and one to six decimal places; the residuals are around \(10^{-13}\), which is floating-point arithmetic’s way of writing zero.)

This is not a numerical curiosity, it is a fact about linear algebra, and it is worth stating precisely. The fitted values \(\boldsymbol{X}\boldsymbol{\beta}\) can be any vector in the column space of \(\boldsymbol{X}\) — the set of all weighted sums of its columns. With \(n\) observations, \(\boldsymbol{y}\) lives in an \(n\)-dimensional space. Once \(\boldsymbol{X}\) has \(n\) linearly independent columns, that column space is the whole space, so it contains \(\boldsymbol{y}\) — and a \(\boldsymbol{\beta}\) reproducing \(\boldsymbol{y}\) exactly is guaranteed to exist.

Note what that argument never mentions: the data. It holds for any target vector and any predictors. Age, shoe size, lottery numbers; cortical volumes, random noise, the digits of π. A model with as many parameters as observations fits everything perfectly, and therefore its perfect fit tells you nothing whatsoever.

We stopped the curve at 69 predictors, because past that point the perfect fit stops being a single answer: with more columns than observations there are infinitely many coefficient vectors that reproduce the data exactly, and which one you get depends on the numerical method rather than on anything in the data. Software will usually warn you about a rank-deficient design matrix here. That regime — far more parameters than observations — is the everyday situation in neuroimaging and genomics, and chapter 4 is about how to work in it without pretending the problem away.

The extreme case is clarifying, but it is not a special case. Every model in the region approaching it is doing a milder version of the same thing: spending degrees of freedom to bend through the particular points in front of it. The fit gets better, smoothly, all the way up — and at no point along that curve does the improvement mean the model has learned anything.

Overfitting#

Overfitting is what we are looking at: the model is using random variation in the predictors to reproduce the particular quirks of the participants in front of it, rather than any relationship that holds in general. The more predictors a model has — the more complex it is — the more capacity it has to do this, and the smaller the dataset, the less it has to work with except quirks.

Note

A helpful, if imperfect, metaphor: the model is memorizing the data rather than learning the relationship. A student who has memorized last year’s exam paper will score brilliantly on last year’s exam paper.

The more predictors a model has, the more capacity it has to do this — and as we have just seen, that capacity reaches its absolute limit when the number of parameters reaches the number of observations. What makes overfitting dangerous is not that limit, which is obvious once you have seen it, but everything on the way there, which is not.

Note

One traditional escape from having many predictors is to avoid multivariate modelling entirely: fit a separate model per predictor, with target and predictor swapped. This mass univariate approach is ubiquitous in, for example, task fMRI. It trades the overfitting problem for a multiple comparisons problem, and it gives up on combining weak predictors into a strong one — which is precisely what we are trying to do.

Where this leaves us#

We cannot judge a model by the error it makes on the data it was fitted to. That number can always be driven to zero, and it tells us nothing about whether the model has learned anything.

On the next page we look at overfitting in some other disguises, so that you recognize it in the wild. Then, in chapter 3, we fix the measurement problem: how to estimate predictive performance in a way that overfitting cannot inflate. Chapter 4 and chapter 5 then tackle the disease itself, by tuning model complexity to the sweet spot between oversimplification and memorization — which is, in one sentence, what machine learning is.