Three rules that fit nothing, then three models of rising flexibility, read against the base rate: accuracy alone would crown the rule that never calls a wet winter.
Overview¶
Rung 0: three rules that set the bars.
Rungs 1–3: logistic regression, a decision tree and gradient boosting, built from scikit-learn.
Precision, recall and average precision against the base rate.
The decision threshold as a trial.
The glossary defines ladder, rung, incumbent, register, base rate and wet-call share.
Prerequisites¶
| Concepts | Importance | Notes |
|---|---|---|
| Pythia Foundations: Matplotlib Basics | Helpful | the confusion-matrix figures |
| Chapter 3 | Helpful | the member-grouped folds |
| scikit-learn User Guide, §3.4 Metrics | Helpful | precision, recall |
| Logistic regression, decision trees | Helpful | the idea, not the maths |
Time to learn: about 15 minutes; every cell runs in seconds.
System requirements: the
environment.ymlat the repository root; no account, no local data beyond the committed cache.
Imports¶
import matplotlib.pyplot as plt
import pandas as pd
from lensml import (
FEATURES,
GOLD_CMAP,
RNG_SEED,
WET_QUANTILE,
SignRule,
cite,
ladder,
pooled,
run_folds,
setup,
show,
show_fig,
)
from sklearn.base import clone
from sklearn.dummy import DummyClassifier
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import ConfusionMatrixDisplay
from sklearn.model_selection import FixedThresholdClassifier
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.tree import DecisionTreeClassifierS = 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 ladder¶
Unidata compares two algorithms; this cookbook climbs a ladder of three above rules.
| Rung | Estimator | What you can read from it |
|---|---|---|
| 0 | always-dry · always-wet · Niño-sign rule | the bars and the physical prior |
| 1 | LogisticRegression | one coefficient, in log-odds |
| 2 | DecisionTreeClassifier | the rules themselves |
| 3 | GradientBoostingClassifier | little directly |
Always-dry bars accuracy, always-wet bars precision (at the base rate), and the sign rule is the prior every rung must beat.
Why these three rungs
Rung 1 because one log-odds coefficient is the first thing a scientist wants to read. Rung 2 because it can bend where a line cannot — does the signal live only in strong events, as NWS Boulder’s “moderate to strong”
suggests? — under a depth cap. Rung 3 as the most flexible model
a one-input table can justify; the table is small, so the guide’s own note points at GradientBoostingClassifier rather than the
histogram variant. The costs: rung 1 assumes a linear boundary; a tree “can be unstable” and overfits without a depth or leaf limit;
boosting, “an excellent model … for tabular data”, costs run time, readability and more settings to record.
Seeds and Pipelines
Every estimator with randomness takes random_state=seed from the declared RNG_SEED, so a fit can be rerun, and scaling sits inside a Pipeline,
so a transform never sees the scoring side of a fold.
The cell builds all six; SignRule, the one custom estimator, prints its source. The asserts check the six against lensml.ladder,
which chapters 5–7 reuse.
show(SignRule) # rung 0c: wet if the autumn Niño 3.4 anomaly is positive
seed = RNG_SEED
models = {
"always-dry": DummyClassifier(strategy="most_frequent"),
"always-wet": DummyClassifier(strategy="constant", constant=1),
"nino-sign": Pipeline([("rule", SignRule())]),
"logistic": Pipeline(
[
("scale", StandardScaler()),
("clf", LogisticRegression(random_state=seed)),
]
),
"tree": DecisionTreeClassifier(
max_depth=3, min_samples_leaf=20, random_state=seed
),
"boosting": GradientBoostingClassifier(
n_estimators=100, max_depth=2, learning_rate=0.05, random_state=seed
),
}
module = ladder(seed) # the same six, as chapters 5-7 build them
assert list(models) == list(module)
assert all(
repr(m.get_params()) == repr(module[k].get_params())
for k, m in models.items()
)Train¶
Each rung is scored on the member-grouped folds; rung 1 is also fit once on all training members to print its one coefficient.
results = {
name: run_folds(m, train, seed, S.n_folds) for name, m in models.items()
}
print("trained:", ", ".join(models))
coef = clone(models["logistic"]).fit(X_tr, y_tr)[-1].coef_[0, 0]
print(
f"logistic coefficient on the scaled Niño 3.4 anomaly (all training members): "
f"{coef:+.3f} log-odds per SD"
)trained: always-dry, always-wet, nino-sign, logistic, tree, boosting
logistic coefficient on the scaled Niño 3.4 anomaly (all training members): -0.013 log-odds per SD
The logistic coefficient is tiny, as chapter 2’s standard errors line predicted: its sign is evidence for neither rule.
Validate: confusion matrix, precision, recall¶
Every number below comes from members the fit never saw. Rows are true, columns predicted; precision (for wet) reads down
“pred wet”, recall along “true wet”. called_wet is the wet-call share; accuracy, F1 and average precision
(AP) sit beside them.
Two estimators of precision and recall
precision_fold / recall_fold are the mean (± SD) of per-fold values, a fold with no wet call contributing 0.
precision_pooled / recall_pooled are one division over the summed matrix, which is what the figure draws.
F1 and AP are there because precision or recall alone can be gamed by predicting one class; a constant score has AP equal to the base
rate, and a random one about the base rate.
summary = pd.DataFrame(
{
name: dict(
precision_fold=s["precision"].mean(),
precision_sd=s["precision"].std(ddof=1),
recall_fold=s["recall"].mean(),
recall_sd=s["recall"].std(ddof=1),
**pooled(cm),
called_wet=cm[:, 1].sum() / cm.sum(),
accuracy=s["accuracy"].mean(),
f1=s["f1"].mean(),
ap=s["ap"].mean(),
)
for name, (s, cm) in results.items()
}
).T.round(3)
summary["base_rate"] = round(float(y_tr.mean()), 3)
print(
"nino-sign summed confusion matrix (rows = true, columns = predicted); the "
"figure below draws the same matrix:"
)
print(
pd.DataFrame(
results["nino-sign"][1],
index=["true dry", "true wet"],
columns=["pred dry", "pred wet"],
)
)
summarynino-sign summed confusion matrix (rows = true, columns = predicted); the figure below draws the same matrix:
pred dry pred wet
true dry 3019 3117
true wet 1583 1481
fig, axes = plt.subplots(1, len(results), figsize=(3.0 * len(results), 3.4))
for ax, (name, (s, cm)) in zip(axes, results.items()):
ConfusionMatrixDisplay(cm, 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) # a canvas frame around the matrix
pr = pooled(cm)
ax.set_title(
f"{name}\npooled P={pr['precision_pooled']:.2f} "
f"R={pr['recall_pooled']:.2f}",
fontsize=9,
)
fig.suptitle(
f"Summed confusion matrices over {S.n_folds} member-grouped folds (rows = "
"true, columns = predicted); titles = pooled precision/recall",
fontsize=9,
)
cite(fig, S.access_date)
plt.tight_layout()
show_fig(
fig,
(
"Six summed confusion matrices over member-grouped folds, one per"
" rung — always-dry, always-wet, nino-sign, logistic regression, "
"decision tree, gradient boosting — rows true, columns predicted,"
" with pooled precision and recall in each title."
),
)
Why accuracy misleads¶
Find the highest accuracy, then read that rung’s called_wet: with a base rate near one third, never calling wet is right two
times in three. A weak-signal model rarely clears the default 0.5 threshold, so its near-zero recall is an operating point, not a
verdict; ap, which reads the ranking at every threshold, says whether a rung separates wet from dry at all.
The same table in MET
MET, the Developmental Testbed Center’s verification package, prints the same four counts with rows = forecast and columns = observation, “Yes” first: read every cell off the axis labels, not off a remembered corner.
The decision threshold is a trial¶
The decision is a statistical statement in a briefing, and the two errors cost differently. A missed wet winter (recall falls) leaves the briefing at CPC’s “equal chances” — a cheap miss. A false alarm (precision falls) puts a statistically wetter line in a briefing before a dry winter — the costlier error, this cookbook assumes, for an office whose briefing is read as guidance.
Lowering the threshold from 0.5 to the designed base rate (1 - WET_QUANTILE) trades precision for recall along the same ranking,
adding no skill: the threshold table below prints the same ap at both. The cell only shows this; a threshold you keep is a
chapter 5 trial (FixedThresholdClassifier), and once this table has been read, that trial is no longer blind.
# sklearn's default, and the designed base rate
thresholds = [0.5, 1 - WET_QUANTILE]
rows = []
for thr in thresholds:
m = FixedThresholdClassifier(
clone(models["logistic"]),
threshold=thr,
response_method="predict_proba",
)
s, cm = run_folds(m, train, RNG_SEED, S.n_folds)
rows.append(
dict(
model="logistic",
threshold=round(thr, 3),
**pooled(cm),
f1=s["f1"].mean(),
ap=s["ap"].mean(),
predicted_wet=int(cm[:, 1].sum()),
of=int(cm.sum()),
)
)
pd.DataFrame(rows).round(3)Read the two rows: how many wet calls does each threshold make, what happens to recall, and does AP change?
Summary¶
Rung 0 sets two bars and a prior; three scikit-learn models climb above it. Accuracy rewards never calling wet, so every rung is read on precision, recall and AP against the base rate. A threshold moves the operating point, not the ranking: a trial to record, not a default to change.
What’s next?¶
Chapter 5 climbs the ladder for real: each rung and threshold trial against the incumbent, with a paired test.
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 |
| scikit-learn User Guide: §1.1 Linear models, §1.10 Decision trees, §1.11 Ensembles. https:// | the three rungs | BSD-3-Clause |
| scikit-learn User Guide, §3.4 Metrics and scoring. https:// | confusion matrix, precision, recall, AP | BSD-3-Clause |
| MET User’s Guide, Appendix C, Verification Measures (Table 35.2). https:// | hits, false alarms, POD, FAR | Apache-2.0 |
| 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:// | “moderate to strong” | US Government work |
| NWS Pueblo (2025). Winter of 2025-2026 Outlook. https:// | CPC’s “equal chances” | US Government work |
| NSF Unidata, Supervised Machine Learning Readiness. https:// | choose, train, validate | Apache-2.0 |