This chapter scores chapter 2’s test members with the register’s final incumbent, reads the score against no skill, and writes the justification, for a model-world result: a test on observed winters would have to come before any briefing line. It is not a blind test: during development (2026-10-20; the neural network also on 2026-10-29) these same 20 test members were scored with the Niño-sign rule, with logistic and boosting at the base-rate threshold, and, in the companion course, with a small neural network. Boosting at the base-rate threshold was registered as this cookbook’s final model on 2026-10-29, after those scores had been seen, so the test members took part in choosing the model. Read the edge below as provisional.
The development scorings
Logistic at the base-rate threshold (precision − base rate +0.005, 95% interval over test members [-0.020, +0.028]: not distinguishable from no skill) and boosting at the base-rate threshold, the result below; the Niño-sign rule (its line under the test block); and a small neural network in the companion course (2026-10-20 and 2026-10-29). A fresh test would need data no choice was made on; chapter 7 uses the same members’ SSP3-7.0 winters.
Overview¶
Rebuild chapter 5’s register and take its final incumbent.
Run the held-out test once.
Read it against no skill and count false alarms against hits.
Justify the decision and state the limits.
Shared code lives in lensml.py beside the chapters.
Prerequisites¶
| Concepts | Importance | Notes |
|---|---|---|
| Chapter 5 | Helpful | the register and its final incumbent |
| Chapter 4 | Helpful | precision, recall and AP against the base rate |
| Bootstrap intervals | Helpful | resampling with replacement |
Time to learn: about 15 minutes; rebuilding the register takes under a minute.
System requirements: the
environment.ymlat the repository root; no account, no local data beyond the committed cache.
Imports¶
import textwrap
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from lensml import (
FEATURES,
GOLD_CMAP,
RNG_SEED,
RUNG0,
cite,
credit_own,
decile_edges,
ladder,
rebuild_register,
setup,
show_fig,
wet_by_bin,
)
from sklearn.metrics import (
ConfusionMatrixDisplay,
accuracy_score,
average_precision_score,
classification_report,
confusion_matrix,
precision_score,
recall_score,
)
pd.set_option("display.width", 220)
pd.set_option("display.max_columns", 30)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
The final incumbent¶
The register is rebuilt exactly as chapter 5 left it (same trials, order and seeds); copy any YOUR_TRIALS you added there. Its
incumbent was chosen after its validation score was seen, and these test members had already scored it (above).
YOUR_TRIALS = [] # the same list as chapter 5's Your-trial cell
reg = rebuild_register(S, YOUR_TRIALS)
print(f"incumbent after {len(reg.rows)} trials: {reg.incumbent_name}")
reg.bonferroni_line()
cols = [
"trial",
"candidate",
"incumbent",
"recall_gap",
"called_wet_cand",
"called_wet_inc",
"recall_p",
"precision_gap",
"precision_p",
"verdict",
]
reg.table()[cols]first incumbent: nino-sign fold draws per trial: 10 × 5 folds = 50 paired scores per metric
incumbent after 5 trials: boosting@base-rate
Bonferroni line after 5 tested row(s) = 10 looks: p < 0.0050 (at the cap of 6 rows: p < 0.0042). A row promoted on a p above this line is promoted under the uncorrected rule only.
The test¶
The final model is fit once on all training members and scored on the test members. Change anything after reading the score and the
test members become a validation set, to be reported as such; set FINAL = False while adding trials. The 95% intervals resample whole
test members, so they cover only which members were drawn, not the choice of model or chapter 5’s selection.
The switch, the counter, and what else the cell prints
FINAL ships True, so every build reruns and shows this test of the shipped register; re-running unchanged re-reads the same score.
The run counter cannot see a restart; the discipline is yours. The cell also prints the wet-call share beside every recall, the false
alarms and hits, and the three rung-0 lines on the same members; precision and AP are read against the base rate of the same test
winters. The interval does not cover the training members either. A printed bound of ±0.000 reads NOT distinguishable.
FINAL = True # the shipped register is final; set False while adding trialsif not FINAL: # nothing reads the test labels
print(
f"test not run: set FINAL = True after your last trial (register so far: "
f"{len(reg.rows)} tested rows, incumbent {reg.incumbent_name})"
)
else:
TEST_RUNS = (
globals().get("TEST_RUNS", 0) + 1
) # a second run in one session is a second test
X_te, y_te = test[FEATURES].values, test["wet"].values
final = reg.incumbent_make(RNG_SEED).fit(X_tr, y_tr)
yhat_te = final.predict(X_te)
cm_te = confusion_matrix(y_te, yhat_te, labels=[0, 1])
score_te = final.predict_proba(X_te)[:, 1]
tn, fp, fn, tp = (int(v) for v in cm_te.ravel())
print(
f"test run #{TEST_RUNS}"
+ ("" if TEST_RUNS == 1 else " — NOT a test: report run #1")
)
print(
f"final model: {reg.incumbent_name} test members: {test.member_id.nunique()} "
f"test winters: {len(y_te)} base rate: {y_te.mean():.3f}"
)
acc_final = accuracy_score(y_te, yhat_te)
print(
f"precision (wet) = {precision_score(y_te, yhat_te, zero_division=0):.3f} "
f"recall (wet) = {recall_score(y_te, yhat_te, zero_division=0):.3f} "
f"wet-call share = {yhat_te.mean():.3f} accuracy = {acc_final:.3f} "
f"AP = {average_precision_score(y_te, score_te):.3f}"
)
print(
classification_report(
y_te, yhat_te, target_names=["dry", "wet"], zero_division=0
)
)
print(
"test confusion matrix (rows = true, columns = predicted); the figure below "
"draws the same matrix:"
)
print(
pd.DataFrame(
cm_te,
index=["true dry", "true wet"],
columns=["pred dry", "pred wet"],
)
)
print(
f"false alarms (true dry, called wet) {fp} · hits (true wet, called wet) {tp} · "
f"false alarms per hit {fp / tp:.2f} · misses {fn} · correct rejections {tn}"
)
print(
f"break-even: saying 'wetter' on these calls pays only if a false alarm "
f"costs less than hits / false alarms = {tp / fp:.2f} of a hit's value"
)
base = y_te.mean()
print(
f"always-wet's break-even on the same winters, for comparison: "
f"base rate / (1 − base rate) = {base / (1 - base):.2f}"
)
acc = {}
# the two bars and the physical prior, on the same test members
for name in RUNG0:
yb = ladder(RNG_SEED)[name].fit(X_tr, y_tr).predict(X_te)
acc[name] = accuracy_score(y_te, yb)
print(
f"{name:11s} precision {precision_score(y_te, yb, zero_division=0):.3f} "
f"recall {recall_score(y_te, yb, zero_division=0):.3f} "
f"wet-call share {yb.mean():.3f} accuracy {acc[name]:.3f}"
)
print(
f"accuracy, final minus always-dry: {acc_final - acc['always-dry']:+.3f} · "
f"final minus nino-sign: {acc_final - acc['nino-sign']:+.3f}"
)
N_BOOT = 1000
boot_rng = np.random.RandomState(RNG_SEED)
member_rows = {
m: np.flatnonzero(test["member_id"].values == m)
for m in test["member_id"].unique()
}
names = list(member_rows)
point = {
"precision − base rate": precision_score(
y_te, yhat_te, zero_division=0
)
- y_te.mean(),
"AP − base rate": average_precision_score(y_te, score_te)
- y_te.mean(),
}
draws = {k: [] for k in point}
for _ in range(N_BOOT):
picked = boot_rng.choice(names, size=len(names), replace=True)
pick = np.concatenate([member_rows[m] for m in picked])
yb, hb, sb = y_te[pick], yhat_te[pick], score_te[pick]
draws["precision − base rate"].append(
precision_score(yb, hb, zero_division=0) - yb.mean()
)
draws["AP − base rate"].append(
average_precision_score(yb, sb) - yb.mean()
)
for k, v in draws.items():
lo, hi = (
round(float(b), 3) for b in np.percentile(v, [2.5, 97.5])
) # as printed
reading = (
"above no skill"
if lo > 0
else (
"below no skill"
if hi < 0
else "NOT distinguishable from no skill"
)
)
print(
f"{reg.incumbent_name}: {k:22s} {point[k]:+.3f} 95% interval over test "
f"members [{lo:+.3f}, {hi:+.3f}] → {reading}"
)
fig, ax = plt.subplots(figsize=(3.6, 3.4))
ConfusionMatrixDisplay(cm_te, display_labels=["dry", "wet"]).plot(
ax=ax, colorbar=False, cmap=GOLD_CMAP
)
ax.set_xlim(-0.85, 1.85)
ax.set_ylim(1.85, -0.85)
ax.set_title(
f"Test: {reg.incumbent_name} on {test.member_id.nunique()} held-out members",
fontsize=9,
)
cite(fig, S.access_date)
plt.tight_layout()
show_fig(
fig,
(
"The final model's confusion matrix on the held-out test members:"
" true dry and wet rows against predicted dry and wet columns, "
"counts in each cell."
),
)test run #1
final model: boosting@base-rate test members: 20 test winters: 2300 base rate: 0.335
precision (wet) = 0.353 recall (wet) = 0.662 wet-call share = 0.629 accuracy = 0.480 AP = 0.357
precision recall f1-score support
dry 0.70 0.39 0.50 1530
wet 0.35 0.66 0.46 770
accuracy 0.48 2300
macro avg 0.52 0.53 0.48 2300
weighted avg 0.58 0.48 0.49 2300
test confusion matrix (rows = true, columns = predicted); the figure below draws the same matrix:
pred dry pred wet
true dry 594 936
true wet 260 510
false alarms (true dry, called wet) 936 · hits (true wet, called wet) 510 · false alarms per hit 1.84 · misses 260 · correct rejections 594
break-even: saying 'wetter' on these calls pays only if a false alarm costs less than hits / false alarms = 0.54 of a hit's value
always-wet's break-even on the same winters, for comparison: base rate / (1 − base rate) = 0.50
always-dry precision 0.000 recall 0.000 wet-call share 0.000 accuracy 0.665
always-wet precision 0.335 recall 1.000 wet-call share 1.000 accuracy 0.335
nino-sign precision 0.327 recall 0.484 wet-call share 0.496 accuracy 0.494
accuracy, final minus always-dry: -0.185 · final minus nino-sign: -0.014
boosting@base-rate: precision − base rate +0.018 95% interval over test members [+0.006, +0.031] → above no skill
boosting@base-rate: AP − base rate +0.022 95% interval over test members [-0.004, +0.052] → NOT distinguishable from no skill

if FINAL: # reads the test labels, so it sits behind the same switch
edges = decile_edges(train) # the TRAINING members' edges, as in chapter 2
test_bins, chi2_te, p_te, dof_te = wet_by_bin(test, edges)
print(
"wet rate by TRAINING decile of the autumn Niño 3.4 anomaly, test "
f"members (base rate {test['wet'].mean():.3f}):"
)
print(test_bins.round(3).to_string())
print(
f"chi-square test of wet against decile, test members: "
f"chi2 = {chi2_te:.1f}, dof = {dof_te}, p = {p_te:.2g}"
)
# Post hoc, chosen after the decile table above was seen: the two extreme
# bins pooled against the eight middle bins, on the test members.
x = test["nino34_anom"]
extreme = (x <= edges[1]) | (x > edges[-2])
parts = {"two extreme bins": extreme, "eight middle bins": ~extreme}
rates = {}
for label, mask in parts.items():
w = test.loc[mask, "wet"]
rates[label] = (w.mean(), np.sqrt(w.mean() * (1 - w.mean()) / len(w)))
print(
f"test members, {label}: wet rate {rates[label][0]:.3f} "
f"± {rates[label][1]:.3f} (binomial SE, n = {len(w)})"
)
(r_e, se_e), (r_m, se_m) = rates.values()
print(
f"extremes minus middle: {r_e - r_m:+.3f} ± {np.hypot(se_e, se_m):.3f} "
"(binomial SEs treat winters as independent: untested for clustering "
"within members)"
)wet rate by TRAINING decile of the autumn Niño 3.4 anomaly, test members (base rate 0.335):
wet_rate n binomial_se (rate - base) / se
nino34_anom bin (K)
(-inf, -1.774] 0.296 216 0.031 -1.239
(-1.774, -1.137] 0.363 251 0.030 0.915
(-1.137, -0.726] 0.349 229 0.032 0.462
(-0.726, -0.366] 0.359 209 0.033 0.725
(-0.366, -0.00268] 0.339 254 0.030 0.128
(-0.00268, 0.364] 0.363 245 0.031 0.927
(0.364, 0.738] 0.366 235 0.031 0.992
(0.738, 1.167] 0.306 222 0.031 -0.920
(1.167, 1.743] 0.307 192 0.033 -0.826
(1.743, inf] 0.291 247 0.029 -1.497
chi-square test of wet against decile, test members: chi2 = 8.5, dof = 9, p = 0.48
test members, two extreme bins: wet rate 0.294 ± 0.021 (binomial SE, n = 463)
test members, eight middle bins: wet rate 0.345 ± 0.011 (binomial SE, n = 1837)
extremes minus middle: -0.051 ± 0.024 (binomial SEs treat winters as independent: untested for clustering within members)
The shape, on the test members. Compare the table (training edges) with chapter 2’s; a chi-square p well
above 0.05 means the test bins alone are not distinguishable from chance. The last three lines pool the two extreme bins against the eight
middle ones: a post hoc contrast, chosen after the table was seen, with binomial errors untested for clustering within members.
Justify the decision¶
Unidata’s closing instruction: “No matter your final decision, the most important aspect is your ability to justify it using data and a robust evaluation.”
The costlier error (chapter 4’s assumption) is a false alarm: a statistically wetter line in a briefing before a dry winter. Per the break-even line,
saying “wetter” pays only if a false alarm costs less than the printed fraction of a hit; the always-wet's break-even line gives the
same for calling every winter wet, and the gap is all the model adds. In this model world, an office that weighs a false
alarm as heavily as a hit would gain nothing over CPC’s “equal chances”. The precision edge (the interval line) is small and
provisional, and chapter 7’s test does not replicate it.
Why precision alone sets the false alarms per hit
False alarms per hit = (1 − precision) / precision, so any model with precision below 0.5 makes more false alarms than hits, however
rarely it calls wet; the wet-call share only scales the two counts. The false alarms per hit line prints it.
The justification, in three labelled parts:
Does it deliver? The interval, false-alarm and break-even lines.
If not, say so plainly. The answer is the base rate; the briefing stays at “equal chances”.
The next change, and its cost. A new season or box: a parameter edit and a rebuild (chapter 1). A new input: a new box mean in
derive_member_wintersand a rebuild (chapter 2). A snowfall target (PRECSC + PRECSL): one more derivation.
The result’s shape¶
The straight-line relation is indistinguishable from zero (chapter 2’s standard errors line). Training members’ wet rate varies
between bins, extremes drier than the middle (chapter 2’s decile table). On the test members the chi-square line does not confirm
it; the extremes minus middle line shows extremes drier again, a post hoc description, not a test.
In chapter 7’s future winters only the coldest bin is beyond 2 SE, one of ten (its decile table, a protocol secondary outcome; omnibus
p = 0.32; read post hoc).
The NWS sign, read in two halves
NWS Boulder states El Niño’s wet signal east of the Divide “particularly during the late winter and spring of moderate to strong El Niños”, and La Niña’s dry signal east of the Divide “particularly during the fall and spring of La Niña episodes” and, for southwest and eastern Colorado, in “the entire cold season of moderate to strong La Niñas” (its mountain sign is limit 7). Here, the coldest decile is below the base rate in the training members, the test members and chapter 7’s future winters (beyond 2 standard errors in the training and future winters only), as the La Niña-dry sign would suggest. The three warmest deciles also sit below the base rate in the training and test members (each within 2 standard errors; mixed in chapter 7): the reverse of the El Niño-wet sign NWS Boulder gives east of the Divide (for late winter and spring), but the sign Stagge et al.'s Fig. 1b gives Colorado’s central mountains (drier under strong El Niño). The box holds both halves (limit 7), so neither prior is tested alone. Both readings are post hoc, from the decile tables.
The limits¶
A model world. CESM2 scored against CESM2, never observations.
CESM2’s own ENSO and teleconnection, biases included.
A ~1° grid, smoothing the orography Stagge et al. (2023) find amplifies the ENSO signal on western slopes.
The season. DJF holds only February of the late winter and spring NWS Boulder stresses for El Niño east of the Divide; its La Niña-dry statement for southwest and eastern Colorado covers the entire cold season.
Total precipitation (PRECC + PRECL), not snowfall, and a wet-only target: the coldest decile’s dry signal (above) is one a classifier of wet winters cannot state as a “drier” call; a dry-tercile target is the next change to try.
One forced-response curve for both forcing halves, so a half-specific forced path is absorbed, not tested. Rodgers et al. (2021, §2.3) report that the halves’ different biomass-burning forcing has “a discernible impact on large-scale climate”, including “tropical Pacific warming”, around 1990–2010; this cookbook does not test whether that shifts the Niño 3.4 input in those years.
A box that crosses the Divide: about half mountain, half plains. NWS Boulder (the north-central mountains, wetter under La Niña) and Stagge et al.'s Fig. 1b (the central mountains) give part of the west column the reverse of NWS Boulder’s east-of-Divide sign.
Not a blind test (the top of this chapter).
The limits in detail
Limit 3: Stagge et al. (2023) find the amplification on the Intermountain West’s western slopes. Limit 6: the cmip6 and smbb halves share one curve and one threshold; their per-half base rates agree in chapter 2. Limit 7: the box’s west column (centre 106.25°W, edges 106.875–105.625°W) straddles the Continental Divide: its northern cell lies mostly west of it, while its southern cell holds South Park and the upper Arkansas valley, east of it; the middle column holds the foothills and the eastern high peaks (Pikes Peak, 105.04°W 38.84°N, is inside it).
Summary¶
The spine, end to end: framing, data, the forced response removed, a member split, a ladder, corrected paired trials, and a provisional held-out test read against the cost of a false alarm. Chapter 7’s future test (protocol fixed before the run, self-attested) does not replicate the edge. The figure maps each step to its chapter.
SPINE_WALKED = [
(
"framing",
"chapter 1",
"a target that does not answer the decision anyone will make",
),
(
"data",
"chapters 1 · 2",
"inputs whose origin and quality were never recorded",
),
(
"split",
"chapter 3",
"training and scoring sets that share ensemble members, so the "
'"unseen" score is inflated',
),
(
"train",
"chapter 4",
"a fit that cannot be rerun because its settings and seed were "
"not recorded",
),
(
"validate",
"chapter 4",
"tuning with no held-out score to show whether the model is "
"memorising",
),
("refine", "chapters 4 · 5", "stopping at the first configuration tried"),
(
"test",
"chapter 6",
"a final number that was tuned toward through repeated use of the "
"same held-out cases",
),
(
"justify",
"chapter 6",
"a model chosen for its sophistication rather than its measured "
"performance and cost",
),
]
fig, ax = plt.subplots(figsize=(12, 3.2))
ax.set(xlim=(0, len(SPINE_WALKED)), ylim=(0.22, 1))
ax.axis("off")
for k, (step, where, failure) in enumerate(SPINE_WALKED):
ax.add_patch(
plt.Rectangle(
(k + 0.06, 0.6),
0.88,
0.32,
facecolor="#E7CD82",
edgecolor="#8F7022",
lw=1.2,
)
)
ax.text(
k + 0.5,
0.82,
step,
ha="center",
va="center",
fontsize=10.5,
fontweight="bold",
)
ax.add_patch(
plt.Rectangle(
(k + 0.12, 0.62),
0.76,
0.12,
facecolor="#F5F3ED",
edgecolor="#E4E0D6",
lw=0.8,
)
)
ax.text(k + 0.5, 0.68, where, ha="center", va="center", fontsize=8)
ax.text(
k + 0.5,
0.54,
textwrap.fill("guards against " + failure, 21),
ha="center",
va="top",
fontsize=6.6,
linespacing=1.25,
)
if k < len(SPINE_WALKED) - 1:
ax.annotate(
"",
xy=(k + 1.06, 0.76),
xytext=(k + 0.94, 0.76),
arrowprops=dict(arrowstyle="->", color="#8F7022", lw=1.3),
)
ax.set_title(
"The supervised-learning spine as this cookbook walked it — each step, the "
"chapter that took it, and the failure it guards against",
fontsize=9,
)
credit_own(
fig,
"a diagram of the analysis spine (shape: NSF Unidata, Apache-2.0); "
"no CESM2 LENS data drawn",
)
plt.tight_layout()
show_fig(
fig,
(
"A row of eight gold boxes joined by arrows, reading framing, "
"data, split, train, validate, refine, test, justify; under each "
"step's name is the chapter that took it (chapter 1; chapters 1 "
"and 2; chapter 3; chapter 4 for train and validate; chapters 4 "
"and 5; chapter 6 for test and justify) and below the box the "
"failure the step guards against."
),
)
What’s next?¶
Chapter 7 runs the test this chapter cannot: the same members’ SSP3-7.0 winters, under a protocol fixed before the scored run (self-attested; no external timestamp). Further ML in Pythia: the scikit-learn Argo,
xbatcher ML and Landsat ML cookbooks.
Optional: the ncar
Resources and references¶
| Resource | Where it bears | Licence |
|---|---|---|
| Grover, M., & Banihirwe, A. (2021). NCAR CESM2 Large Ensemble Cloud-Optimized Dataset. NSF NCAR CISL. doi:10.26024/y48t-q717 | the dataset (required citation) | UCAR Data Terms of Use |
| Rodgers, K. B. et al. (2021). Earth System Dynamics 12, 1393–1411. doi:10.5194/esd-12-1393-2021 | the ensemble paper (required citation); §2.3 in limit 6 | CC BY 4.0 |
| Stagge, J. H. et al. (2023). Orographic amplification of El Niño teleconnections on winter precipitation across the Intermountain West of North America. Nature Water 1, 1016–1026. doi:10.1038/s44221-023-00163-9 | limit 3; the dipole and its two poles | CC BY 4.0 |
| NWS Pueblo (2025). Winter of 2025-2026 Outlook. https:// | CPC’s “equal chances” | US Government work |
| NWS Boulder (2020). La Niña and the Upcoming 2020-2021 Winter Season (contains 2010-era slides, e.g. “perhaps through the spring of 2011”). https:// | the sign and its season; limit 7; limit 4 | US Government |
| NSF Unidata, Supervised Machine Learning Readiness (NSF award #2319979). https:// | the spine; the closing instruction | Apache-2.0 |
| Project Pythia notebook template. https:// | the shape of each chapter | CC BY 4.0 |
| scikit-learn User Guide, §12 Common pitfalls. https:// | the seed; transforms fit inside the split | BSD-3-Clause |
Results based on the CESM2 Large Ensemble acknowledge the CESM2 Large Ensemble Community Project and supercomputing resources provided by the IBS Center for Climate Physics in South Korea. This cookbook was self-funded and has not been reviewed or sponsored by NSF NCAR or NSF Unidata.