Reducing Complexity in Action#

Scikit-learn implements all the methods from the theory page behind one consistent interface. Here we apply them to the IXI data and see what controlling complexity actually buys.

Throughout this page we use the same 100 participants and the same 5-fold cross-validation, so the numbers are comparable with each other. They are not directly comparable with chapter 3, which used a different 80 participants and therefore a different baseline — which is why every table below carries its own.

import numpy as np
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt

from sklearn.linear_model import LinearRegression, Ridge, Lasso
from sklearn.feature_selection import SelectKBest, f_regression
from sklearn.decomposition import PCA
from sklearn.cross_decomposition import PLSRegression
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline
from sklearn.model_selection import cross_val_predict, KFold
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')]

df_cv = ixi.iloc[:100]           # the participants we cross-validate on
df_ext = ixi.iloc[100:200]       # a separate, non-overlapping set used once, further below

cv = KFold(n_splits=5)

print(f'cross-validation set: n={len(df_cv)}   external set: n={len(df_ext)}   overlap: '
      f'{len(set(df_cv.index) & set(df_ext.index))}')
cross-validation set: n=100   external set: n=100   overlap: 0

Warning

That last check is not decoration. df.iloc[:100] and df.iloc[100:200] are disjoint, but the very similar-looking df.loc[:100] and df.loc[100:200] are not: label-based slicing includes both endpoints, so participant 100 would appear in both sets. When two datasets are supposed to be independent, verify it rather than assume it.

Two reference points#

Every result below is judged against two things: the trivial baseline, and an unregularized linear model using all 68 features — the disaster from chapter 3.

baseline_mae = mean_absolute_error(df_cv[TARGET], np.full(len(df_cv), df_cv[TARGET].mean()))

ols_predictions = cross_val_predict(LinearRegression(), X=df_cv[FEATURES], y=df_cv[TARGET], cv=cv)
ols_mae = mean_absolute_error(df_cv[TARGET], ols_predictions)

print(f'baseline (predict the mean age)      : {baseline_mae:.2f} years')
print(f'ordinary least squares, all features : {ols_mae:.2f} years')
baseline (predict the mean age)      : 14.67 years
ordinary least squares, all features : 23.45 years

As expected, the unregularized model is far worse than predicting nothing at all. Let’s fix that.

Selecting the k best features#

The simplest reduction: keep only the features most strongly associated with age.

Because this step looks at the target, doing it on the data we then cross-validate would be cheating (the next page shows exactly how much). For now we sidestep the problem by selecting the features on the separate 100 participants in df_ext, and only then applying that choice to df_cv.

selector = SelectKBest(score_func=f_regression, k=5).fit(X=df_ext[FEATURES], y=df_ext[TARGET])
selected = [f for f, keep in zip(FEATURES, selector.get_support()) if keep]

print('features selected on the external set:')
for f in selected:
    print('   ', f)

predictions = cross_val_predict(LinearRegression(), X=df_cv[selected], y=df_cv[TARGET], cv=cv)
kbest_mae = mean_absolute_error(df_cv[TARGET], predictions)

print(f'\nSelectKBest(5) + linear regression: {kbest_mae:.2f} years')
features selected on the external set:
    lh_parsorbitalis_volume
    rh_parsopercularis_volume
    rh_parstriangularis_volume
    rh_rostralmiddlefrontal_volume
    rh_superiorfrontal_volume

SelectKBest(5) + linear regression: 11.37 years

From catastrophic to useful, by throwing away 63 of the 68 features. That is the whole idea of this chapter in one number.

Note

Note the fit / transform split that scikit-learn objects offer: fit learns something from one dataset — here, which features to keep — and transform then applies what was learned to another (we use get_support() above only to print the names). Nearly every scikit-learn object works this way, and keeping the two apart is what makes an honest separation of training and test data possible. fit_transform does both at once on the same data — useful, and a frequent source of leakage.

Still, discarding 63 features that each carry a little information feels wasteful. What if we could compress them instead?

PCA and PLS#

PCA condenses correlated features into a smaller number of components. It never looks at the target, so — unlike feature selection — it is safe to fit inside a simple pipeline without any external dataset. We ask for 5 components, to keep the comparison with SelectKBest(5) fair.

pca_model = Pipeline([
    ('standardize', StandardScaler()),
    ('reduce', PCA(n_components=5)),
    ('regression', LinearRegression()),
])

predictions = cross_val_predict(pca_model, X=df_cv[FEATURES], y=df_cv[TARGET], cv=cv)
pca_mae = mean_absolute_error(df_cv[TARGET], predictions)

print(f'PCA(5) + linear regression: {pca_mae:.2f} years')
PCA(5) + linear regression: 11.04 years

A Pipeline chains steps together into a single estimator. This is not a stylistic preference: when the pipeline is cross-validated, scikit-learn refits every step on each training fold, so the scaling and the PCA rotation are learned without ever seeing the held-out participants. We will lean on this heavily from chapter 5 onwards.

PLS does the same kind of compression, but aims its components at the target:

pls_model = Pipeline([
    ('standardize', StandardScaler()),
    ('regression', PLSRegression(n_components=5)),
])

predictions = cross_val_predict(pls_model, X=df_cv[FEATURES], y=df_cv[TARGET], cv=cv).ravel()
pls_mae = mean_absolute_error(df_cv[TARGET], predictions)

print(f'PLS(5): {pls_mae:.2f} years')
PLS(5): 10.84 years

Ridge and LASSO#

Finally, the regularized models, which keep all 68 features but make large coefficients expensive. Both go into a pipeline behind a StandardScaler, for the reasons given in the theory section: a penalty on coefficients is meaningless if the features are on different scales.

ridge_model = Pipeline([('standardize', StandardScaler()), ('regression', Ridge(alpha=100))])
lasso_model = Pipeline([('standardize', StandardScaler()), ('regression', Lasso(alpha=1.0))])

ridge_mae = mean_absolute_error(
    df_cv[TARGET], cross_val_predict(ridge_model, X=df_cv[FEATURES], y=df_cv[TARGET], cv=cv))
lasso_mae = mean_absolute_error(
    df_cv[TARGET], cross_val_predict(lasso_model, X=df_cv[FEATURES], y=df_cv[TARGET], cv=cv))

print(f'Ridge(alpha=100): {ridge_mae:.2f} years')
print(f'LASSO(alpha=1)  : {lasso_mae:.2f} years')
Ridge(alpha=100): 10.47 years
LASSO(alpha=1)  : 10.31 years

Important

Those two alpha values were picked by hand, and we have no principled reason for them. Worse, if we now tried a dozen values each and reported the best, the resulting number would be optimistic for reasons we are about to make painfully concrete on the next page. Tuning hyperparameters honestly is the subject of chapter 5. Until then, take every alpha on this page as arbitrary.

Comparison#

results = pd.DataFrame({
    'model': ['baseline (mean age)', 'OLS, all 68 features', 'SelectKBest(5)', 'PCA(5)',
              'PLS(5)', 'Ridge(alpha=100)', 'LASSO(alpha=1)'],
    'MAE': [baseline_mae, ols_mae, kbest_mae, pca_mae, pls_mae, ridge_mae, lasso_mae],
})

ax = sns.barplot(y='model', x='MAE', data=results, color='steelblue')
ax.axvline(baseline_mae, ls='--', color='0.4')
plt.xlabel('cross-validated mean absolute error (years)')
plt.ylabel('')
plt.show()

results.round(2)
../_images/1031834722a5b77ef0d68f5730268729be026b6c2a9e94d45a32f1cfc13f2688.png
model MAE
0 baseline (mean age) 14.67
1 OLS, all 68 features 23.45
2 SelectKBest(5) 11.37
3 PCA(5) 11.04
4 PLS(5) 10.84
5 Ridge(alpha=100) 10.47
6 LASSO(alpha=1) 10.31

Every complexity-reducing method beats both reference points, and they land close to one another. This is a common and slightly deflating finding: once complexity is under control, how you control it usually matters far less than that you control it. The 68-feature model went from useless to competitive without a single new measurement — just by being prevented from memorizing.

The differences between the methods are small enough here to be within the noise of a 100-participant cross-validation, so resist the temptation to declare a winner.

Which regions drive the prediction?#

Ridge and LASSO are still linear models, so their coefficients can be read as a kind of feature importance: how much the prediction moves per standard deviation of each region.

To look at coefficients we need a single model, but cross-validation produced five. So we finalize the model, as discussed in chapter 3, by refitting the identical pipeline — the same alpha, the same scaler — on all 100 participants.

final_model = Pipeline([('standardize', StandardScaler()), ('regression', Ridge(alpha=100))])
final_model.fit(X=df_cv[FEATURES], y=df_cv[TARGET])

coefficients = pd.Series(final_model.named_steps['regression'].coef_, index=FEATURES)
coefficients.sort_values().head(5)
rh_frontalpole_volume            -1.956764
rh_superiorfrontal_volume        -1.311424
lh_parsorbitalis_volume          -1.068230
lh_rostralmiddlefrontal_volume   -1.026133
rh_precuneus_volume              -1.013041
dtype: float64

Warning

The finalized model must use exactly the settings that were cross-validated. Refitting with a different alpha would give you a model whose performance you have never estimated — the reported error would belong to a different model from the one you publish.

Painted onto the cortex:

def to_ggseg_label(column_name):
    hemisphere, region, _ = column_name.split('_')
    return region + ('_left' if hemisphere == 'lh' else '_right')


importance = {to_ggseg_label(c): v for c, v in coefficients.items()}
limit = np.abs(coefficients).max()

# ggseg 0.1 predates matplotlib 3.9 and calls matplotlib.cm.get_cmap, which some matplotlib
# versions no longer provide; restore it so the brain plot keeps working either way
import matplotlib.cm

if not hasattr(matplotlib.cm, 'get_cmap'):
    matplotlib.cm.get_cmap = matplotlib.colormaps.get_cmap

try:
    import ggseg
    ggseg.plot_dk(importance, cmap='bwr', vminmax=[-limit, limit], figsize=(10, 5),
                  background='w', edgecolor='gray', bordercolor='gray',
                  ylabel='Ridge coefficient (years per SD)',
                  title='Morphological predictors of age')
except Exception:
    # ggseg missing or incompatible with the installed matplotlib: plain bar plot instead
    ordered = coefficients.sort_values()
    plt.figure(figsize=(6, 12))
    sns.barplot(x=ordered.values, y=ordered.index, color='steelblue')
    plt.xlabel('Ridge coefficient (years per SD)')
    plt.tight_layout()
    plt.show()
../_images/4f88906fc77a203884d55794cf40825afdc13ad055d7cb730b7785cd32a3e481.png

Caution

Resist reading this map as “these regions are where ageing happens”. Ridge distributes weight across correlated features, so a region can receive a large coefficient because of what it shares with its neighbours rather than anything specific to it — and with 68 collinear features, the particular split of weight between them is unstable. The map describes this fitted model, not the brain. Chapter 7 takes this question seriously.

We now have several ways to control complexity, each with a dial — k, n_components, alpha — and no honest way to set them. The next page explains why the obvious approach does not work, and introduces the second villain of this book.