Validity and Specificity#
The previous page asked whether a model still works somewhere else. This page asks a question that is prior to it, and more uncomfortable: is the model measuring what you think it is measuring?
A model can cross-validate well, survive external validation, and still be useless — or worse, misleading — because the signal it exploits is not the one you meant to study. The classic version of this is the horse Clever Hans, who appeared to do arithmetic and was in fact reading his questioner’s posture. Clever Hans generalized: he got the right answer with many different questioners. He just was not doing arithmetic.
The machine learning literature calls this shortcut learning, and biomedical data is unusually rich in shortcuts: scanner differences, head motion, medication, the time of day patients are scanned, the tape marking a wound in a dermatology photograph.
Confounding is different here#
You already know what a confounder is from classical statistics: a variable associated with both the predictor and the outcome, which can generate a spurious association between them. In predictive modelling the problem changes shape, and the change is easy to get wrong.
The object of interest is no longer a feature–target association but the model’s output \(\hat{y}\). And here is the trap: if the confounder \(c\) is genuinely associated with the target \(y\) — and confounders usually are, that is what makes them confounders — then a model that is completely blind to \(c\) will still produce predictions that correlate with \(c\), simply because the predictions correlate with \(y\) and \(y\) correlates with \(c\) [16].
So the obvious check is worthless. Correlating your predictions with the confounder tells you almost nothing, because you have no idea how large that correlation should be under a clean model. Some association is expected. The question is whether there is more association than the \(c\)–\(y\) relationship can account for — that is, whether a path from \(c\) to \(\hat{y}\) exists that does not go through \(y\).
Stated properly, the null hypothesis of “my model is not confounded” is a conditional independence:
The partial confounder test#
Testing conditional independence properly is the job of the mlconfound package
[16]. The idea is a conditional permutation test
[17].
An ordinary permutation test would shuffle \(c\) freely, which destroys all of its dependencies — including its legitimate relationship with \(y\). That builds the wrong null: “a confounder unrelated to anything”. Instead, the conditional permutation test draws permutations of \(c\) that preserve the estimated conditional distribution \(Q(c \mid y)\). Each permuted copy is therefore a variable with the same relationship to the target as the real confounder, but with no direct path to the predictions — which is exactly the null we want. The test statistic is \(R^2(\hat{y}, c)\), and the p-value is the proportion of permuted copies achieving an \(R^2\) at least as extreme as the real one.
The conditional distribution \(Q(c \mid y)\) is modelled with a generalized additive model [18], so the mean structure can be non-linear. The crucial design decision is what is not modelled: no assumption whatsoever is made about the distribution of \(\hat{y}\), or about how \(\hat{y}\) depends on \(y\) and \(c\). That is what makes the test usable on machine learning output, which is routinely non-normal, heteroscedastic and strangely shaped.
Note
Assumptions cannot be avoided entirely — conditional independence testing is provably impossible without some [19]. The contribution is where they are placed: on the relationship between two observed, well-understood variables (\(y\) and \(c\)), rather than on the output of a black box.
Is our brain-age model measuring ageing?#
Let us put our own model on the stand. Throughout this book we have predicted age from 68 regional cortical volumes. An obvious candidate confounder is total cortical volume — overall brain size. People differ in head size for reasons that have nothing to do with ageing, and the cortex also shrinks globally with age. If our model is largely reading off “how much cortex is there in total”, it is a global atrophy detector wearing a regional-pattern costume.
import numpy as np
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt
from sklearn.linear_model import Ridge
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
# mlconfound pins an old version of pygam, which calls a SciPy sparse-matrix alias that has since
# been removed; restoring it keeps the recommended GAM-based test working on modern SciPy
import scipy.sparse
for _matrix_class in (scipy.sparse.csr_matrix, scipy.sparse.csc_matrix, scipy.sparse.coo_matrix):
if not hasattr(_matrix_class, 'A'):
_matrix_class.A = property(lambda self: self.toarray())
from mlconfound.stats import partial_confound_test, full_confound_test
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')]
model = Pipeline([('standardize', StandardScaler()), ('regression', Ridge(alpha=100))])
predictions = cross_val_predict(model, X=ixi[FEATURES], y=ixi[TARGET], cv=KFold(5))
age = ixi[TARGET].values
total_volume = ixi[FEATURES].sum(axis=1).values # the candidate confounder
print(f'cross-validated MAE : {mean_absolute_error(age, predictions):.2f} years')
print(f'correlation predicted vs. true : {np.corrcoef(age, predictions)[0, 1]:.3f}')
print(f'correlation confounder vs. true: {np.corrcoef(age, total_volume)[0, 1]:.3f}')
print(f'correlation confounder vs. pred: {np.corrcoef(predictions, total_volume)[0, 1]:.3f}')
cross-validated MAE : 9.76 years
correlation predicted vs. true : 0.713
correlation confounder vs. true: -0.556
correlation confounder vs. pred: -0.777
The predictions correlate −0.78 with total cortical volume — higher, in absolute value, than their correlation with age itself (0.71). That looks alarming. But remember the trap: age and total volume are themselves correlated at −0.56, so some association between predictions and total volume is exactly what an honest model should produce. Is −0.78 more than that?
This is the question the partial confounder test answers, and it is not one you can answer by staring at correlation coefficients.
partial = partial_confound_test(y=age, yhat=predictions, c=total_volume,
num_perms=1000, cond_dist_method='gam',
random_state=42, progress=False, n_jobs=1)
print(f'observed R2(yhat, c) : {partial.r2_yhat_c:.3f}')
print(f'expected under H0 : {np.round(partial.expected_r2_yhat_c, 3)} (5%, 50%, 95% quantiles)')
print(f'p-value : {partial.p:.4f} 95% CI {np.round(partial.p_ci, 4)}')
observed R2(yhat, c) : 0.603
expected under H0 : [0.123 0.161 0.2 ] (5%, 50%, 95% quantiles)
p-value : 0.0000 95% CI [0. 0.0037]
Under the null hypothesis of an unconfounded model, the expected \(R^2\) between predictions and total cortical volume is about 0.16, and under that null it would very rarely exceed 0.20. What we observe is 0.60 — close to four times the expected value — and not one permutation out of a thousand came near it. The model’s dependence on total cortical volume cannot be explained away by the fact that total volume declines with age.
Our brain-age model, in other words, is substantially driven by global brain size. That is a real finding about the running example of this entire book, and it took three lines of code.
“Confounder” is a scientific judgement, not a statistical one#
Before we treat this as a disaster, an important qualification. The test tells us that the model’s output depends on total cortical volume beyond what the age–volume relationship implies. It does not tell us whether that is a problem. That depends entirely on what the model is for:
As a measure of brain ageing, global atrophy is not a nuisance — it is arguably the single best-established biological correlate of ageing in the brain. A model that uses it is using real signal.
As a claim that specific regional patterns carry information about ageing beyond global size, the result is damaging: most of what looks like regional specificity may be global size in disguise.
As a clinical biomarker applied across scanners, it is a warning: head size interacts with acquisition and segmentation in ways that differ between sites, so a model leaning this heavily on it will be fragile.
Statistics can tell you that a dependence exists and how strong it is. Whether the variable is a confounder, a mechanism, or the thing you meant to measure all along is a question about your scientific claim, and only you can answer it.
Exercise 6.4
Test the claim directly: build a model that predicts age from total cortical volume alone, and compare its cross-validated error with the 68-feature model.
Solution
from sklearn.linear_model import LinearRegression
single = cross_val_predict(LinearRegression(), X=total_volume.reshape(-1, 1), y=age, cv=KFold(5))
print(mean_absolute_error(age, single))
One number per participant gets you a surprising distance towards the full model’s performance. Whatever the 68 regions add, it is less than the headline error suggests — and the partial confounder test told you to expect that before you ran the comparison.
The full confounder test
There is a second question, and it is not the negation of the first: is the model driven exclusively by the confounder? The full confounder test has the complementary null hypothesis
— “the predictions carry nothing about the target that the confounder does not already carry”. Here, unusually, rejecting the null is the good outcome. A small p-value means your model knows something beyond the confounder. Mixing up the direction of these two tests is the single easiest mistake to make with this tool.
full = full_confound_test(y=age, yhat=predictions, c=total_volume,
num_perms=1000, cond_dist_method='gam',
random_state=42, progress=False, n_jobs=1)
print(f'observed R2(y, yhat): {full.r2_y_yhat:.3f}')
print(f'expected under H0 : {np.round(full.expected_r2_y_yhat, 3)}')
print(f'p-value : {full.p:.4f} (small p = NOT fully explained by the confounder)')
observed R2(y, yhat): 0.508
expected under H0 : [0.152 0.19 0.229]
p-value : 0.0000 (small p = NOT fully explained by the confounder)
So the honest summary of our model is: significantly biased by total cortical volume, but not reducible to it. Both tests were needed to say that, and neither alone would have.
Turning the test around: positive validators#
The same machinery answers a question with the opposite sign. Suppose there is a variable your
model should be related to if it is measuring what you claim — a “positive validator”. In the
mlconfound package this is generalization_test, which is the partial confounder test with the
intent inverted: a small p-value now means the predictions carry information about the validator
directly, not merely through the target.
This is how you would test a claim like “our brain-age model is picking up something about cognitive decline, not just chronological age” — if you had the cognitive measure. Our dataset has no such variable, which is itself a lesson: the confounders and validators you can test are the ones you thought to measure. Deciding what to record is a study-design decision that no analysis can undo afterwards.
Mitigation is not verification#
The standard responses to a confounder are to regress it out of the features, or to harmonize across sites with a method such as ComBat [20, 21]. Both are reasonable, both are widely used — and both are frequently assumed to have worked rather than checked.
Spisak [16] applies the partial confounder test after mitigation on two large neuroimaging datasets, and finds that state-of-the-art approaches can fail: in the ABIDE data [22], imaging-centre bias survived ComBat harmonization (p = 0.009) even though the naive correlation looked much improved. In another case, regressing the confounder out of the features reduced the model’s actual predictive performance — the mitigation cost real signal.
The workflow this implies is simple and worth adopting: mitigate, then test. Not because mitigation is unsound, but because “we applied ComBat” is a description of what you did, not evidence of what it achieved.
Specificity: what does the model not respond to?#
Confounder tests ask whether some known nuisance is driving the model. Specificity asks the broader question: does the model respond to the construct you care about, and not to related things you do not?
The best-developed example in neuroimaging is the Neurologic Pain Signature (NPS) [23], a brain pattern trained to predict the intensity of experimentally induced pain, which we look at in detail in chapter 9. Its authors did something unusual: having shown it predicted pain in independent samples, they went looking for things it should not respond to.
In the original paper, participants who had recently been through an unwanted breakup viewed photographs of the ex-partner. The signature distinguished physical pain from this social pain well — and could not distinguish looking at the ex-partner from looking at a friend at better than chance.
Trained pattern classifiers for physical pain and for social rejection turned out to be uncorrelated and each performed at chance on the other’s task, even within the very regions that had been claimed to represent both [24].
The signature does not track observed pain in someone else [25], nor picture-induced negative emotion [26].
That is a serious discriminant-validity programme, of a kind almost no predictive model in the biomedical literature receives. And then:
Warning
Harrison et al. [27] tested the NPS against two conditions nobody had tried: breathlessness, and a finger-opposition motor task. Breathlessness activated the signature (d = 0.90), and the non-aversive motor task activated it more strongly still (d = 1.44) — though the study included no painful condition, so no direct comparison against pain was possible. The authors — including the signature’s first author — conclude that significant global NPS activity alone is not specific to pain.
Read that carefully, because it generalizes far beyond pain. Every specificity test before 2021 had compared pain against other aversive or emotional states. Within that set, the specificity was real. Nobody had asked whether a person simply moving their fingers would set it off.
Discriminant validity is only ever as good as the set of alternatives you actually tested against. A model is not “specific”; it is specific relative to the comparisons you ran. When you report specificity, report the list — and when you read someone else’s specificity claim, ask what is missing from theirs.
Exercise 6.5
Our model is biased by total cortical volume. Try to fix it: regress total volume out of every feature first (fit the regression inside the cross-validation, not outside it), then re-run the partial confounder test. Does the bias disappear? What happens to the predictive performance?
Solution
The dependence drops sharply — and so does the model’s accuracy, because a large part of what it was using to predict age was global atrophy, which is real signal about ageing. This is the “difficult compromise” at the heart of confound mitigation: removing a confounder removes everything it shares with the target, including the parts you wanted. There is no setting of this dial that is correct for all purposes; there is only a choice that you should make explicitly and report.
Exercise 6.6
Write down, for a model you are working on, every variable you would want to run a confounder test against. Now mark the ones you actually have recorded. The gap is the honest limit of what you can claim.
Validity asks whether the model measures the construct. The next page asks who it works for — and what happens when the construct itself was chosen badly.