Unidata’s notebooks split at random. Here the unit of the split is the ensemble member, and the cost of getting it wrong is measured rather than assumed.
Overview¶
Build member-grouped validation folds with scikit-learn; verify no member sits on both sides of a fold.
Measure the dependence that would make a member-blind split leak: within members (lag-1) and between micro-siblings.
Score one model three ways — this cookbook’s pipeline, transforms fit before the split, a member-blind split — and read the gaps.
Loading and the transforms come from lensml.py; the splitter and scoring loop are written out here.
Prerequisites¶
| Concepts | Importance | Notes |
|---|---|---|
| Chapter 2 | Helpful | the test draw and the two transforms |
| scikit-learn User Guide, §3.1 Cross-validation | Helpful | folds, groups |
| Autocorrelation | Helpful | lag-1, in one line |
Time to learn: about 15 minutes; the contrast cell takes under a minute.
System requirements: the
environment.ymlat the repository root; no account, no local data beyond the committed cache.
Imports¶
import itertools
import re
import numpy as np
from lensml import (
ANOMALIES,
BOOSTING_SETTINGS,
FEATURES,
N_FOLDS_MAX,
N_REPEATS,
RNG_SEED,
WET_QUANTILE,
WINDOW,
apply_transforms,
fit_transforms,
member_folds,
score_splits,
setup,
)
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.metrics import (
accuracy_score,
average_precision_score,
confusion_matrix,
precision_score,
recall_score,
)
from sklearn.model_selection import (
FixedThresholdClassifier,
StratifiedGroupKFold,
StratifiedKFold,
)S = setup() # the cache, the test members, the transforms (chapters 1-2)
train, test = S.train, S.test
X_tr, y_tr = train[FEATURES].values, train["wet"].values
g_tr = train["member_id"].values
print(
f"train: {S.n_train_members} members / {len(train)} winters "
f"test: {test.member_id.nunique()} members / {len(test)} winters "
f"N_FOLDS = {S.n_folds}"
)cache header has no AUTUMN_MONTHS field (it predates the field): taken as AUTUMN_MONTHS=[9, 10, 11], the only autumn the derivation code had: the file's autumn is assumed, not read from the header. A rebuild records it.
train: 80 members / 9200 winters test: 20 members / 2300 winters N_FOLDS = 5
Why the member is the unit¶
scikit-learn’s guidance on grouped cross-validation: “all the samples in the validation fold come from groups that are not represented at all in the paired training fold.” Three reasons make the member the group:
The question is about members never seen. A member-blind split scores new years of members the model already knows.
The transforms. Fit before the split, held-out winters would help fit the forced curve and wet threshold that label them — the leak of a
StandardScalerfit on the whole table. Both are refit inside each fold.Dependence is measured, not assumed. After the fold check come the lag-1 table and a micro-sibling line that fails if members sharing a start year move together; that line licenses not grouping siblings.
Micro-siblings, and why the check is run on this window
Rodgers et al. 2021 §2.2 warn of initial-condition memory after 1850 and analyse internal variability “after 1960”; they give no
figure for 1900, so this cookbook’s 1900 start rests on the check below, not on Rodgers. Micro-siblings are members sharing a start year: the project page’s micro-perturbation members — the i1231-style token in
member_id. The check correlates them against unrelated members over the window.
Beside chapter 2’s test members, the training members are split into N_FOLDS = min(N_FOLDS_MAX, training members) folds by
scikit-learn’s StratifiedGroupKFold: the groups keep each member whole, and stratifying balances wet winters across folds.
Which labels the stratification reads
The folds are stratified on the all-training labels, which decide only which members share a fold; the labels scored inside a fold are refit on that fold’s training members.
N_FOLDS = S.n_folds
def grouped_folds(seed, n_folds):
# member-grouped, class-balanced folds over the training members
return StratifiedGroupKFold(
n_splits=n_folds, shuffle=True, random_state=seed
)
# the same folds as lensml.member_folds, which chapters 4-7 call
assert all(
np.array_equal(a1, a2) and np.array_equal(b1, b2)
for (a1, b1), (a2, b2) in zip(
grouped_folds(RNG_SEED, N_FOLDS).split(X_tr, y_tr, g_tr),
member_folds(RNG_SEED, N_FOLDS).split(X_tr, y_tr, g_tr),
strict=True,
)
)print(
f"base rate train {train.wet.mean():.3f} (the test members' base rate is "
"printed once, in chapter 6; nothing here reads their labels)"
)
print(
f"N_FOLDS = min(N_FOLDS_MAX={N_FOLDS_MAX}, {S.n_train_members} training "
f"members) = {N_FOLDS}"
)
for k, (a, b) in enumerate(
grouped_folds(RNG_SEED, N_FOLDS).split(X_tr, y_tr, g_tr)
):
assert not set(g_tr[a]) & set(g_tr[b]), f"fold {k} leaks members"
print(
f"{N_FOLDS} member-grouped folds verified: no member appears in both the "
"fitting and the scoring side of any fold"
)
if S.n_repeats_effective == 1:
print(
f"NOTE: leave-one-member-out; the trials use 1 fold draw, not {N_REPEATS}"
)
def lag1(s):
v = np.asarray(s, dtype=float)
return float(np.corrcoef(v[:-1], v[1:])[0, 1])
dependence = (
train.groupby("member_id")
.agg(lag1_nino34_anom=("nino34_anom", lag1), lag1_wet=("wet", lag1))
.round(3)
)
print(
"\nlag-1 autocorrelation within training members (what consecutive winters "
"of one member share):"
)
print(dependence.to_string())
print("mean:", dependence.mean().round(3).to_dict())
MICRO_STARTS = {"1231", "1251", "1281", "1301"}
start_year = {
m: re.search(r"i(\d{4})p", m).group(1) for m in train.member_id.unique()
}
print(
f"\ncorrelation between training members over {WINDOW[0]}–{WINDOW[1]} "
"(micro-siblings share a start year; the memory check):"
)
for anom in ANOMALIES:
wide = train.pivot(index="winter", columns="member_id", values=anom)
corr = wide.corr()
sib, unrel = [], []
for a, b in itertools.combinations(wide.columns, 2):
same = start_year[a] == start_year[b] and start_year[a] in MICRO_STARTS
(sib if same else unrel).append(corr.loc[a, b])
sib, unrel = np.array(sib), np.array(unrel)
if len(sib) < 2 or len(unrel) < 2:
print(f"{anom:12s} too few member pairs of one kind to compare")
continue
excess = sib.mean() - unrel.mean()
se = np.sqrt(sib.var(ddof=1) / len(sib) + unrel.var(ddof=1) / len(unrel))
if abs(excess) <= 2 * se:
reading = (
"PASS: no sibling excess detectable (|excess| ≤ 2 SE, SE a lower "
"bound) — the member is taken as the unit"
)
else:
reading = (
"FAIL: siblings still move together — group the folds by start "
"year, or start WINDOW later"
)
print(
f"{anom:12s} micro-siblings {sib.mean():+.3f} ({len(sib)} pairs, SD "
f"{sib.std(ddof=1):.3f}) · unrelated {unrel.mean():+.3f} ({len(unrel)} pairs, "
f"SD {unrel.std(ddof=1):.3f}) · siblings − unrelated {excess:+.3f} (±{se:.3f}, "
"a lower bound: the pairs share members, so they are not independent "
f"draws) → {reading}"
)base rate train 0.333 (the test members' base rate is printed once, in chapter 6; nothing here reads their labels)
N_FOLDS = min(N_FOLDS_MAX=5, 80 training members) = 5
5 member-grouped folds verified: no member appears in both the fitting and the scoring side of any fold
lag-1 autocorrelation within training members (what consecutive winters of one member share):
lag1_nino34_anom lag1_wet
member_id
r10i1181p1f1 -0.202 0.065
r10i1191p1f2 -0.113 -0.108
r10i1231p1f1 -0.235 0.036
r10i1281p1f1 -0.181 -0.024
r11i1231p1f2 -0.212 0.039
r11i1281p1f2 -0.191 0.121
r11i1301p1f2 -0.129 -0.040
r12i1231p1f2 -0.237 -0.162
r12i1251p1f2 -0.153 -0.091
r12i1281p1f2 -0.178 -0.022
r12i1301p1f2 -0.146 0.013
r13i1251p1f2 -0.079 -0.206
r13i1281p1f2 -0.189 0.143
r13i1301p1f2 -0.098 0.052
r14i1251p1f2 -0.202 -0.130
r14i1281p1f2 -0.173 0.040
r15i1231p1f2 -0.149 0.134
r15i1251p1f2 -0.205 -0.125
r15i1281p1f2 -0.090 -0.126
r15i1301p1f2 -0.143 0.134
r16i1231p1f2 -0.155 0.128
r16i1251p1f2 -0.180 -0.137
r16i1301p1f2 -0.214 -0.053
r17i1231p1f2 -0.028 0.081
r17i1251p1f2 -0.076 -0.113
r17i1281p1f2 -0.168 0.072
r18i1231p1f2 -0.204 0.078
r18i1281p1f2 -0.106 -0.073
r18i1301p1f2 -0.135 -0.057
r19i1251p1f2 -0.189 -0.107
r19i1281p1f2 -0.141 0.066
r19i1301p1f2 -0.063 0.053
r1i1001p1f1 -0.065 -0.013
r1i1231p1f1 -0.231 0.215
r1i1251p1f1 -0.188 -0.152
r1i1281p1f1 -0.255 0.038
r1i1301p1f1 -0.258 0.049
r20i1231p1f2 -0.194 0.078
r20i1251p1f2 -0.197 -0.086
r20i1281p1f2 -0.188 -0.145
r20i1301p1f2 -0.163 -0.004
r2i1031p1f2 -0.210 0.040
r2i1251p1f1 -0.237 0.171
r2i1281p1f1 -0.153 0.025
r2i1301p1f1 -0.093 0.161
r3i1041p1f1 -0.212 0.072
r3i1051p1f2 -0.162 -0.053
r3i1231p1f1 -0.115 0.081
r3i1251p1f1 -0.194 0.088
r3i1281p1f1 -0.090 -0.124
r3i1301p1f1 -0.051 0.036
r4i1061p1f1 -0.151 -0.000
r4i1071p1f2 -0.205 -0.039
r4i1231p1f1 -0.115 -0.056
r4i1281p1f1 -0.227 -0.015
r4i1301p1f1 -0.108 0.162
r5i1081p1f1 -0.196 0.013
r5i1231p1f1 -0.071 -0.078
r5i1301p1f1 -0.223 -0.060
r6i1101p1f1 -0.048 0.058
r6i1111p1f2 -0.126 -0.060
r6i1251p1f1 -0.185 0.053
r6i1281p1f1 -0.256 -0.119
r6i1301p1f1 -0.183 -0.080
r7i1121p1f1 -0.114 -0.078
r7i1131p1f2 -0.162 -0.002
r7i1231p1f1 -0.089 -0.027
r7i1251p1f1 -0.201 0.086
r7i1301p1f1 -0.075 0.053
r8i1141p1f1 -0.038 -0.053
r8i1151p1f2 -0.034 0.134
r8i1251p1f1 -0.236 -0.031
r8i1281p1f1 -0.183 -0.101
r8i1301p1f1 -0.108 0.001
r9i1161p1f1 -0.167 0.052
r9i1171p1f2 -0.177 0.191
r9i1231p1f1 -0.162 -0.013
r9i1251p1f1 -0.141 0.001
r9i1281p1f1 -0.171 -0.132
r9i1301p1f1 -0.105 -0.022
mean: {'lag1_nino34_anom': -0.156, 'lag1_wet': -0.0}
correlation between training members over 1900–2014 (micro-siblings share a start year; the memory check):
nino34_anom micro-siblings -0.001 (468 pairs, SD 0.100) · unrelated +0.001 (2692 pairs, SD 0.104) · siblings − unrelated -0.002 (±0.005, a lower bound: the pairs share members, so they are not independent draws) → PASS: no sibling excess detectable (|excess| ≤ 2 SE, SE a lower bound) — the member is taken as the unit
prect_anom micro-siblings -0.003 (468 pairs, SD 0.095) · unrelated -0.002 (2692 pairs, SD 0.095) · siblings − unrelated -0.001 (±0.005, a lower bound: the pairs share members, so they are not independent draws) → PASS: no sibling excess detectable (|excess| ≤ 2 SE, SE a lower bound) — the member is taken as the unit
The leak contrast: one model, three ways¶
One loop scores every validation run in this cookbook. Per fold: refit the transforms on the fold’s training rows (unless
refit_transforms=False), .fit the model, .predict the held-out rows, and score them for the wet class.
def score_folds(model, rows, splits, refit_transforms=True):
out = {m: [] for m in ['ap', 'precision', 'recall', 'accuracy']}
cm = np.zeros((2, 2), dtype=int) # rows = true, columns = predicted
for a, b in splits:
fit_rows, held_rows = rows.iloc[a], rows.iloc[b]
if refit_transforms: # curve and threshold from this fold only
tf = fit_transforms(fit_rows)
fit_rows = apply_transforms(fit_rows, tf)
held_rows = apply_transforms(held_rows, tf)
Xa, ya = fit_rows[FEATURES].values, fit_rows['wet'].values
Xb, yb = held_rows[FEATURES].values, held_rows['wet'].values
model.fit(Xa, ya)
yhat = model.predict(Xb)
p_wet = model.predict_proba(Xb)[:, 1]
out['precision'].append(precision_score(yb, yhat, zero_division=0))
out['recall'].append(recall_score(yb, yhat, zero_division=0))
out['accuracy'].append(accuracy_score(yb, yhat))
out['ap'].append(average_precision_score(yb, p_wet))
cm += confusion_matrix(yb, yhat, labels=[0, 1])
return {m: np.array(v) for m, v in out.items()}, cm| Arm | Split unit | Forced curve and wet threshold fit on | A held-out score measures |
|---|---|---|---|
| A — this cookbook’s pipeline | member | the fold’s training members | new members |
| B — transforms fit before the split | member | all training members | new members, with transforms they helped fit |
| C — member-blind split | winter (StratifiedKFold) | the fold’s training winters | new years of known members |
B differs from A only in where the transforms are fit; C only in the split unit. The model is gradient boosting (chapter 4) at the
base-rate threshold, so that it calls wet: the for reference line shows that at the default 0.5 it almost never does.
How to read the gaps, and what this cell costs chapter 5
A precision resting on a handful of calls makes any gap a non-test, so below one wet call per hundred winters the cell marks the gaps NON-TEST. The ± beside each gap is the SD of the per-fold gaps — a rough scale; chapter 5’s paired test is the measurement.
This cell is a validation score for boosting at the base-rate threshold, on the same seeds chapter 5 uses. Once it has been read, a trial of that model is no longer a blind one.
Reading this cell un-blinds chapter 5’s trial of this model (see the note).
CONTRAST_THRESHOLD = 1 - WET_QUANTILE # the designed base rate
MIN_WET_CALL_SHARE = (
0.01 # below this, precision and recall gaps are a NON-TEST
)
def contrast_model(seed):
return FixedThresholdClassifier(
GradientBoostingClassifier(**BOOSTING_SETTINGS, random_state=seed),
threshold=CONTRAST_THRESHOLD,
response_method="predict_proba",
)
arms = {
"A member split, transforms refit in each fold ": dict(
runs=[], cm=np.zeros((2, 2), dtype=int)
),
"B member split, transforms fit before the split ": dict(
runs=[], cm=np.zeros((2, 2), dtype=int)
),
"C member-blind split, transforms refit in fold ": dict(
runs=[], cm=np.zeros((2, 2), dtype=int)
),
}
_folds = list(grouped_folds(RNG_SEED, N_FOLDS).split(X_tr, y_tr, g_tr))
_s05, _cm05 = score_folds(
GradientBoostingClassifier(**BOOSTING_SETTINGS, random_state=RNG_SEED),
train,
_folds,
)
# the same scores as lensml.score_splits, which chapters 4-7 call
_s, _cm = score_folds(contrast_model(RNG_SEED), train, _folds)
_m, _mcm = score_splits(contrast_model(RNG_SEED), train, _folds)
assert all(np.allclose(_s[m], _m[m]) for m in _s)
assert np.array_equal(_cm, _mcm)
print(
f"for reference — the same model at sklearn's default threshold 0.5 (arm A's "
f"split, one fold draw): wet calls {int(_cm05[:, 1].sum())} of {int(_cm05.sum())} "
f"scored winters ({_cm05[:, 1].sum() / _cm05.sum():.2%}), recall "
f"{_s05['recall'].mean():.3f} — too few calls to read a gap in"
)
for rep in range(S.n_repeats_effective):
seed = RNG_SEED + rep
by_member = list(grouped_folds(seed, N_FOLDS).split(X_tr, y_tr, g_tr))
shuffled = StratifiedKFold(
n_splits=N_FOLDS, shuffle=True, random_state=seed
)
by_year = list(
shuffled.split(X_tr, y_tr)
) # member-blind: winters shuffled
plan = [(by_member, True), (by_member, False), (by_year, True)]
for (label, store), (splits, refit) in zip(arms.items(), plan):
s, cm = score_folds(
contrast_model(seed), train, splits, refit_transforms=refit
)
store["runs"].append(s)
store["cm"] += cm
METRICS = ["ap", "precision", "recall", "accuracy"]
pooled_arm = {
label: {m: np.concatenate([s[m] for s in st["runs"]]) for m in METRICS}
for label, st in arms.items()
}
ref = pooled_arm[next(iter(pooled_arm))]
def sd(v):
return v.std(ddof=1) if len(v) > 1 else 0.0
print(
f"boosting at threshold {CONTRAST_THRESHOLD:.3f} (the base rate; 0.5 is sklearn's "
f"default), {S.n_repeats_effective} fold draw(s) × {N_FOLDS} folds per arm. Per arm: "
"scores (mean ± per-fold SD) and wet calls; then gap = arm − A (mean ± SD of the "
"per-fold gaps)"
)
for label, s in pooled_arm.items():
cm = arms[label]["cm"]
wet_calls, n_scored = int(cm[:, 1].sum()), int(cm.sum())
share = wet_calls / n_scored
print(
f"{label} AP {s['ap'].mean():.3f} precision {s['precision'].mean():.3f} ± "
f"{sd(s['precision']):.3f} recall {s['recall'].mean():.3f} ± "
f"{sd(s['recall']):.3f} accuracy {s['accuracy'].mean():.3f} wet calls "
f"{wet_calls} of {n_scored} scored winters ({share:.1%})"
)
gaps = {m: s[m] - ref[m] for m in ["ap", "precision", "recall"]}
if share >= MIN_WET_CALL_SHARE:
reading = (
f"gaps readable (wet calls ≥ {MIN_WET_CALL_SHARE:.0%} of winters)"
)
else:
reading = "precision/recall gaps a NON-TEST: read AP only"
print(
f"{'':{len(label)}} gap vs A: AP {gaps['ap'].mean():+.4f} ± {sd(gaps['ap']):.4f}"
f" precision {gaps['precision'].mean():+.4f} ± {sd(gaps['precision']):.4f} "
f"recall {gaps['recall'].mean():+.4f} ± {sd(gaps['recall']):.4f} → {reading}"
)for reference — the same model at sklearn's default threshold 0.5 (arm A's split, one fold draw): wet calls 7 of 9200 scored winters (0.08%), recall 0.000 — too few calls to read a gap in
boosting at threshold 0.333 (the base rate; 0.5 is sklearn's default), 10 fold draw(s) × 5 folds per arm. Per arm: scores (mean ± per-fold SD) and wet calls; then gap = arm − A (mean ± SD of the per-fold gaps)
A member split, transforms refit in each fold AP 0.355 precision 0.353 ± 0.009 recall 0.634 ± 0.045 accuracy 0.491 wet calls 55064 of 92000 scored winters (59.9%)
gap vs A: AP +0.0000 ± 0.0000 precision +0.0000 ± 0.0000 recall +0.0000 ± 0.0000 → gaps readable (wet calls ≥ 1% of winters)
B member split, transforms fit before the split AP 0.354 precision 0.352 ± 0.009 recall 0.636 ± 0.045 accuracy 0.489 wet calls 55405 of 92000 scored winters (60.2%)
gap vs A: AP -0.0010 ± 0.0061 precision -0.0010 ± 0.0033 recall +0.0019 ± 0.0306 → gaps readable (wet calls ≥ 1% of winters)
C member-blind split, transforms refit in fold AP 0.355 precision 0.353 ± 0.008 recall 0.644 ± 0.036 accuracy 0.488 wet calls 55941 of 92000 scored winters (60.8%)
gap vs A: AP -0.0004 ± 0.0111 precision +0.0000 ± 0.0107 recall +0.0100 ± 0.0584 → gaps readable (wet calls ≥ 1% of winters)
Why the gaps are small here¶
Read each arm’s gap vs A line against the ± beside it: the leaks barely bite. The mean: line under the lag-1 table gives
lag1_wet near zero, so arm C has little to learn from a member’s other years. A small gap measures this design; it is not a licence.
Why the gaps are small, and when they would not be
No within-member persistence to memorise. With
lag1_wetnear zero, one wet winter says almost nothing about the next in the same member. The input’s lag-1 is negative, not positive — no run of similar autumns either.The input carries no member identity. With one detrended Niño 3.4 value per winter, nothing in a row tells the model which member it came from, so it cannot memorise a member even when it has seen one.
The transforms are smooth fits to many members. A cubic in year through the mean of many training members, and one pooled quantile, barely move when one fold’s members are left out — so fitting them before the split (arm B) leaks almost nothing.
With a member-specific input (a member’s own past winters, say) or transforms fit on fewer members, the same cell is where a member split earns its keep.
Summary¶
Validation folds are grouped by member with StratifiedGroupKFold and verified leak-free; the sibling check licenses the member as
the unit. Scoring one model three ways turned the two leaks into numbers — small on this design.
What’s next?¶
Chapter 4 scores the simple rules and a three-rung ladder on these folds.
Resources and references¶
| Resource | Where it bears | Licence |
|---|---|---|
| scikit-learn User Guide, §3.1 Cross-validation (grouped splits). https:// | member-grouped folds | BSD-3-Clause |
| scikit-learn User Guide, §12 Common pitfalls. https:// | transforms fit inside the split | BSD-3-Clause |
| Rodgers, K. B. et al. (2021). Earth System Dynamics 12, 1393–1411. doi:10.5194/esd-12-1393-2021 | §2.2: initial-condition memory | CC BY 4.0 |
| CESM2 Large Ensemble project page. https:// | micro-members and start years | UCAR website terms of use |
| NSF Unidata, Supervised Machine Learning Readiness. https:// | the random split this chapter changes | Apache-2.0 |