Training and Test Sets#

We ended chapter 2 with a problem: the error a model makes on the data it was fitted to can be driven to zero by adding predictors, whether or not those predictors mean anything. That number cannot be used to judge a model.

The way out is almost embarrassingly simple. If we want to know how well a model predicts data it has never seen, we should show it data it has never seen.

Setting up#

We take 160 participants and split them into two halves of 80: one for training, one for testing. Because mean age drifts substantially across the file (see chapter 1), the shuffle is what makes these two halves comparable — without it we would be testing on a systematically older group and calling the difference “overfitting”.

import numpy as np
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt
from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_absolute_error

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')]

train = ixi.iloc[:80]
test = ixi.iloc[80:160]

print(f'train: n={len(train)}, mean age {train[TARGET].mean():.1f}')
print(f'test : n={len(test)}, mean age {test[TARGET].mean():.1f}')
train: n=80, mean age 47.6
test : n=80, mean age 48.2

Note

Scikit-learn is the standard package for “classical” machine learning in python, with excellent documentation and a very active community. From here on it does most of the work. Its LinearRegression is a minimal implementation, tuned for use inside larger pipelines — it does not even give you p-values. We will not miss them.

Everything is measured against the same baseline as before: predicting the mean age of the training set for everybody.

baseline_prediction = train[TARGET].mean()

baseline_train = mean_absolute_error(train[TARGET], np.full(len(train), baseline_prediction))
baseline_test = mean_absolute_error(test[TARGET], np.full(len(test), baseline_prediction))

print(f'baseline MAE on train: {baseline_train:.2f} years')
print(f'baseline MAE on test : {baseline_test:.2f} years')
baseline MAE on train: 13.55 years
baseline MAE on test : 16.86 years

Three models#

Model 1 uses a single predictor, the volume of the right superior frontal cortex. Note that it is fitted on the training set only.

one_feature = ['rh_superiorfrontal_volume']

model_1 = LinearRegression().fit(X=train[one_feature], y=train[TARGET])

mae_1_train = mean_absolute_error(train[TARGET], model_1.predict(train[one_feature]))
print(f'model 1, MAE on train: {mae_1_train:.2f} years')
model 1, MAE on train: 10.55 years

Model 2 uses all 68 regions — the model that looked so impressive in chapter 2.

model_2 = LinearRegression().fit(X=train[FEATURES], y=train[TARGET])

mae_2_train = mean_absolute_error(train[TARGET], model_2.predict(train[FEATURES]))
print(f'model 2, MAE on train: {mae_2_train:.2f} years')
model 2, MAE on train: 2.48 years

Model 3 is our control: 68 random numbers per participant, guaranteed to know nothing about anybody’s age. We generate them for all 160 participants at once and split them the same way, so that the model can later be tested on random features it has not seen either.

rng = np.random.default_rng(seed=42)
noise = pd.DataFrame(rng.normal(size=(160, len(FEATURES))),
                     columns=[f'noise_{i}' for i in range(len(FEATURES))])
noise_train = noise.iloc[:80]
noise_test = noise.iloc[80:160]

model_3 = LinearRegression().fit(X=noise_train, y=train[TARGET])

mae_3_train = mean_absolute_error(train[TARGET], model_3.predict(noise_train))
print(f'model 3, MAE on train: {mae_3_train:.2f} years')
model 3, MAE on train: 4.97 years

The familiar picture: on the training data, the two complex models crush the simple one, and pure noise does about as well as real anatomy. Now for the moment of truth.

Testing on unseen data#

mae_1_test = mean_absolute_error(test[TARGET], model_1.predict(test[one_feature]))
mae_2_test = mean_absolute_error(test[TARGET], model_2.predict(test[FEATURES]))
mae_3_test = mean_absolute_error(test[TARGET], model_3.predict(noise_test))

results = pd.DataFrame({
    'model': ['1: one region', '1: one region',
              '2: all 68 regions', '2: all 68 regions',
              '3: 68 random numbers', '3: 68 random numbers'],
    'sample': ['train', 'test'] * 3,
    'MAE': [mae_1_train, mae_1_test, mae_2_train, mae_2_test, mae_3_train, mae_3_test],
})
results.round(2)
model sample MAE
0 1: one region train 10.55
1 1: one region test 12.09
2 2: all 68 regions train 2.48
3 2: all 68 regions test 30.77
4 3: 68 random numbers train 4.97
5 3: 68 random numbers test 46.14
ax = sns.barplot(x='model', y='MAE', hue='sample', data=results)
ax.axhline(baseline_test, ls='--', color='0.4')
ax.text(-0.45, baseline_test + 0.6, 'baseline: predict the mean age', color='0.4', fontsize=9)
plt.ylabel('mean absolute error (years)')
plt.xlabel('')
plt.xticks(rotation=15)
plt.show()
../_images/10c6c68cfd0cb3c9d4cad9cec4e285fef967b6883e6280d90fa708623fb7c5b5.png

Read that figure slowly, because it contains most of the message of this book.

  • Model 1, the humble one-predictor model, does almost as well on the test set as on the training set. It learned something small and real, and that something transfers.

  • Model 2 was more than four times better than model 1 on the training data — and on the test data it is worse than predicting the mean age of everybody. All of its apparent superiority was memorization. Not merely useless: actively harmful.

  • Model 3, which by construction knows nothing, is catastrophic on the test set, missing by decades. Its training error was never a measure of knowledge at all.

The gap between the light and dark bars is the amount of overfitting. It is not a small correction; for model 2 it is the entire result.

The complexity curve#

Let’s watch this happen continuously rather than in three snapshots, by increasing the number of predictors one at a time and tracking the error on both samples.

# 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)
train_curve, test_curve = [], []

for k in n_features:
    columns = ORDERED_FEATURES[:k]
    model = LinearRegression().fit(X=train[columns], y=train[TARGET])
    train_curve.append(mean_absolute_error(train[TARGET], model.predict(train[columns])))
    test_curve.append(mean_absolute_error(test[TARGET], model.predict(test[columns])))

best_k = int(np.argmin(test_curve)) + 1

plt.figure(figsize=(7, 5))
sns.lineplot(x=list(n_features), y=train_curve, label='training set (data the model saw)')
sns.lineplot(x=list(n_features), y=test_curve, label='test set (unseen data)')
plt.axhline(baseline_test, ls='--', color='0.6')
plt.axvline(best_k, ls=':', color='0.4')
plt.text(best_k + 1, max(test_curve) * 0.8, f'best on test: {best_k} predictors', color='0.4')
plt.xlabel('number of predictors (model complexity)')
plt.ylabel('mean absolute error (years)')
plt.legend()
plt.show()

print(f'best test MAE {min(test_curve):.2f} years with {best_k} predictors '
      f'(training MAE there: {train_curve[best_k - 1]:.2f})')
../_images/379d7d1bbd632c68acdda7e71776a4763e0d1777c18132bcaa2c467d81b2c698.png
best test MAE 11.23 years with 29 predictors (training MAE there: 7.25)

This picture is worth committing to memory, because every method in the rest of the book is an attempt to find the bottom of that valley.

The training error (blue) falls essentially without limit: more complexity buys a better fit to the data at hand. The test error (orange) does something quite different. It hardly moves for the first handful of predictors — those regions are so correlated with the one we started from that they add almost nothing — then drifts down into a broad, bumpy valley around 25 to 30 predictors, and finally climbs steeply as the model starts fitting noise instead of anatomy. Beyond about 57 predictors it crosses the baseline for good, and the model is worse than useless.

Notice how flat and noisy the bottom of the valley is. The exact position of the minimum is not a meaningful quantity here: with 80 test participants, the difference between 20 and 30 predictors is well within the noise of the estimate. What the curve tells us reliably is the shape — a range of sensible complexities, with disaster on the right.

The two regimes have names. To the left of the minimum the model is underfitting: too rigid to use the information available. To the right it is overfitting: flexible enough to memorize. The minimum is the sweet spot, and its location depends on how much data you have and how much signal is in it — it cannot be known in advance, it has to be found empirically.

Note

This curve is the practical face of the bias-variance trade-off. A model that is too simple is wrong in a systematic way no matter how much data it sees (bias); a model that is too flexible gives wildly different answers depending on which particular sample it was trained on (variance). Prediction error contains both, and they move in opposite directions as complexity changes.

Doing the split properly

Splitting by position, as we did, is fine on shuffled data. In practice you would normally let scikit-learn do it, which also makes the randomness explicit and reproducible:

from sklearn.model_selection import train_test_split

train_auto, test_auto = train_test_split(ixi.iloc[:160], test_size=0.5, random_state=42)
print(f'train: n={len(train_auto)},  test: n={len(test_auto)}')
train: n=80,  test: n=80

See also

For a classification problem, use stratify= so that both halves contain a comparable proportion of each class. For data with group structure — several scans per person, several sites — a plain random split will put related observations on both sides and quietly inflate your performance; scikit-learn’s GroupShuffleSplit handles that case.

The cost of a test set#

Holding out data for testing is the most direct way to get an honest performance estimate, and when the held-out data is genuinely new — a different hospital, a later cohort — it is the strongest evidence a predictive model can have.

But it is expensive. We just spent 80 participants to learn one number, and those 80 participants were unavailable for training the model that number describes. In biomedical research, where each participant costs real money and real effort, this stings.

The next page shows how cross-validation lets us have it both ways.