Analysis
A biased age head, a circular correction, and two intervals
Simulate a head that predicts toward a training mean of 40, and score it in a population like its training studies' and in a seminar room aged 22 to 30, against the training mean and the group's own mean. Correct its gap the usual way, and score the corrected predictions. Then compute a mean AUC over 20 studies that differ in how well the head separates the groups, with intervals from resampling windows within studies and from resampling whole studies.
import numpy as np
from scipy.stats import rankdata
rng = np.random.default_rng(8)
# 1. A head that regresses toward its training mean, as a weak least-squares fit does.
def predict(age, train_mean=40.0, slope=0.4, noise=7.0):
return train_mean + slope * (age - train_mean) + rng.normal(0, noise, len(age))
wide = rng.uniform(18, 80, 3000) # people like the training studies'
room = rng.uniform(22, 30, 30) # a seminar room
for name, ages in (("wide population", wide), ("seminar room", room)):
p = predict(ages)
print(f"{name}: head {np.mean(np.abs(p - ages)):.1f} years, "
f"training mean {np.mean(np.abs(40.0 - ages)):.1f}, "
f"the group's own mean {np.mean(np.abs(ages.mean() - ages)):.1f}")
p = predict(wide)
gap = p - wide # the "brain age gap"
print(f"gap against age: r = {np.corrcoef(gap, wide)[0, 1]:.2f}")
b, a = np.polyfit(wide, gap, 1) # the usual correction, fitted on age itself
corrected = p - (a + b * wide)
r2 = lambda q: 1 - np.sum((q - wide) ** 2) / np.sum((wide - wide.mean()) ** 2)
print(f"R^2 of the predictions {r2(p):.2f}, of the corrected ones {r2(corrected):.2f}")
# 2. An AUC per study, averaged over studies, with its interval from resampling the windows
# or the studies. Studies differ in how well the head separates the groups.
studies, per = 20, 200
study = np.repeat(np.arange(studies), per)
effect = np.repeat(rng.normal(0.35, 0.4, studies), per) # the separation, study by study
group = (rng.random(studies * per) < 0.5).astype(int)
score = effect * group + rng.normal(0, 1, studies * per)
def auc(s, g):
r = rankdata(s)
n1 = g.sum()
return (r[g == 1].sum() - n1 * (n1 + 1) / 2) / (n1 * (len(g) - n1))
def mean_auc(pick):
"""The mean over the studies in `pick`, repeats allowed, of each one's AUC."""
return np.mean([auc(score[w], group[w]) for w in pick])
each = [np.flatnonzero(study == s) for s in range(studies)]
by_window = [mean_auc([rng.choice(w, len(w)) for w in each]) for _ in range(1000)]
by_study = [mean_auc([each[s] for s in rng.integers(0, studies, studies)]) for _ in range(1000)]
print(f"mean AUC over studies {mean_auc(each):.3f}")
for name, vals in (("windows within studies", by_window), ("whole studies", by_study)):
print(f"95% interval, resampling {name}: {np.round(np.percentile(vals, [2.5, 97.5]), 3)}")
You should seeIn the wide population, the head misses by 11.2 years against 16.3 for the training mean. In the room, by 10.8 against 14.4, while the room's own mean misses by 2.1. The gap falls with age, r = −0.84, and correcting it lifts R² from 0.37 to 0.85. The mean AUC is 0.611, with an interval of 0.593 to 0.629 from windows and 0.575 to 0.645 from studies.
Then report: which baseline each claim needs, and why the corrected R² says nothing about the head.