A refinement is a trial: one change, scored against the incumbent on the same folds, and promoted only if the gain beats the run-to-run spread. This chapter keeps the register of every trial, and says which were chosen after their scores had been seen.
Overview¶
The corrected paired t-test, written out.
The promotion rule, the Bonferroni line and the trial budget.
The register: three rungs, then two threshold trials chosen after the fact.
One paired test by hand.
Shared code lives in lensml.py; show() prints a function’s source. Terms are in the glossary.
Prerequisites¶
| Concepts | Importance | Notes |
|---|---|---|
| Chapter 4 | Helpful | the ladder and the threshold table |
| A paired t-test | Helpful | a mean difference over its standard error |
| scikit-learn: statistical comparison of models | Helpful | the source of the test |
Time to learn: about 15 minutes; the trials take under a minute.
System requirements: the
environment.ymlat the repository root; no account, no local data beyond the committed cache.
Imports¶
import lensml
import numpy as np
import pandas as pd
from lensml import (
FEATURES,
MAX_TESTED_TRIALS,
TRIALS,
Register,
at_base_rate,
recall_vs_calls,
setup,
show,
show_p,
verdict,
)
from scipy.stats import t as t_dist
pd.set_option("display.width", 220)
pd.set_option("display.max_columns", 30)
pd.set_option("display.max_colwidth", 70)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 test¶
Unidata’s instruction is to run trials “until you are satisfied with the evaluation metrics, or they no longer improve with new
trials”. Each trial repeats the member-grouped folds
N_REPEATS times on the same draws for both models, so every fold gives one paired difference. The test is the Nadeau–Bengio
corrected paired t-test, written out below from scikit-learn’s example Statistical comparison of models using grid search
(BSD-3-Clause). It is one-sided: a candidate must beat the incumbent, so a loss prints p near 1.
Why the standard error is corrected
Fold scores come from overlapping training sets, so they are not independent and a plain paired t-test under-estimates the variance.
A plain test divides the variance of the k per-fold differences by k; corrected_std multiplies it by 1/k + n_test/n_train
instead. “Corrected” means the standard error, not multiplicity: it does nothing about running many tests. Two changes from the example: it took np.abs(t_stat) under a right-tailed label, so a loss would have printed a small p; and
a zero spread (identical scores in every fold) now returns t = 0, p = 1 instead of dividing by zero. The register prints _sd, the
plain per-fold SD of the differences, for scale; _t divides the gap by the corrected error, and _t is the test.
# Adapted from scikit-learn's example "Statistical comparison of models
# using grid search" (examples/model_selection/plot_grid_search_stats.py).
# Copyright (c) 2007-2026 The scikit-learn developers. BSD-3-Clause; full
# text in LICENSES/scikit-learn-BSD-3-Clause.txt.
def corrected_std(differences, n_train, n_test):
"""Corrects standard deviation using Nadeau and Bengio's approach."""
kr = len(differences)
corrected_var = np.var(differences, ddof=1) * (1 / kr + n_test / n_train)
return np.sqrt(corrected_var)
def compute_corrected_ttest(differences, df, n_train, n_test):
"""Computes right-tailed paired t-test with corrected variance."""
mean = np.mean(differences)
std = corrected_std(differences, n_train, n_test)
if std == 0: # added: identical scores in every fold (guards 0/0)
return 0.0, 1.0
t_stat = mean / std
# changed from the example: t_stat, not np.abs(t_stat), so the test is
# right-tailed as the docstring says and a LOSS prints p near 1
p_val = t_dist.sf(t_stat, df)
return t_stat, p_valThe rule, the Bonferroni line, the budget¶
Recall alone rewards calling wet always; precision alone, almost never. So a candidate is promoted only if one of the two improves
measurably — p < 0.05 with a positive gap — while the other does not fall on average (mean gap ≥ 0). Each register row carries
both tests and the wet-call share (called_wet_cand, called_wet_inc).
The example warns that “[I]f multiple models are statistically compared, a multiple comparisons correction is needed when using the
frequentist approach”. Each p here is uncorrected, so the register bounds the looks at MAX_TESTED_TRIALS rows, two metrics each,
and prints the Bonferroni line (0.05 ÷ the looks so far). A p between that line and 0.05 is a promotion under the uncorrected
rule only. The line counts only this register’s looks.
What the rule cannot see
It compares a candidate with the incumbent only, and the first incumbent is the sign rule. If the sign rule’s precision sits below the always-wet bar (chapter 4), a candidate with no skill at all — precision merely at the base rate — can beat it. Read such a promotion as a promotion over the sign rule, not as skill; chapter 6 reads the tested model against the base rate of the same winters.
show(verdict, Register.record)The ladder climb¶
The three rungs, each against the incumbent, fixed in chapter 4 (chapter 3 had already scored one of them at the default threshold).
reg = Register(S)
for name, make_model, what_changed in TRIALS[:3]: # rungs 1-3
reg.record(name, make_model, what_changed)
print(
f"incumbent after the ladder ({len(reg.rows)} trials): {reg.incumbent_name}"
)
reg.bonferroni_line()
reg.table()first incumbent: nino-sign fold draws per trial: 10 × 5 folds = 50 paired scores per metric
incumbent after the ladder (3 trials): nino-sign
Bonferroni line after 3 tested row(s) = 6 looks: p < 0.0083 (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.
Two threshold trials, chosen after the fact¶
At the default 0.5 the probabilistic rungs almost never call wet, so the next two trials lower the threshold to the base rate.
Neither was blind: logistic at the base rate is in chapter 4’s threshold table, and boosting at the base rate is chapter 3’s contrast
model, registered because its score there had been seen. The Bonferroni line does not count those looks, so their p is optimistic. Trial 4’s
promotion is over the sign rule, whose precision is below the always-wet bar (chapter 4), and its p is above the Bonferroni line:
read it as a promotion over a rule with no skill, not as skill.
Chapters 6 and 7 are the remaining checks; chapter 6’s test members were scored during development, so it is provisional.
show(at_base_rate)
reg.record(*TRIALS[3]) # logistic@base-rate
print(f"incumbent after {len(reg.rows)} trials: {reg.incumbent_name}")
reg.bonferroni_line()
reg.table().tail(1)incumbent after 4 trials: logistic@base-rate
Bonferroni line after 4 tested row(s) = 8 looks: p < 0.0063 (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.
reg.record(*TRIALS[4]) # boosting@base-rate
print(f"incumbent after {len(reg.rows)} trials: {reg.incumbent_name}")
reg.bonferroni_line()
recall_vs_calls(reg, "boosting@base-rate")
reg.table().tail(1)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.
boosting@base-rate vs logistic@base-rate: wet-call share 0.599 vs 0.481 (change +0.118); recall 0.634 vs 0.487 (gain +0.147)
calling wet more often, with no skill, accounts for +0.118 of the +0.147 recall gain (80%); the rest, +0.030, is the ranking
With no skill, recall equals the wet-call share, so the accounts for line splits the recall gain into share and ranking. Precision
is the half that speaks to skill.
Your trial¶
One tested row is left in the budget. Write it down before you score it: one change, why, and which metric should move (a tree
threshold or depth, a leaf size, the boosting learning rate). Add it to YOUR_TRIALS, run the cell, read the row against the spread
and the Bonferroni line, then copy YOUR_TRIALS into chapter 6, which rebuilds this register.
YOUR_TRIALS = [
# ('tree@base-rate', at_base_rate('tree'), 'tree, threshold lowered to the base rate'),
]
for t in YOUR_TRIALS:
reg.record(*t) # a name already recorded is skipped
left = MAX_TESTED_TRIALS - len(reg.rows)
print(
f"incumbent after {len(reg.rows)} trials: {reg.incumbent_name} "
f"(tested rows left: {left})"
)
reg.bonferroni_line()
reg.table()incumbent after 5 trials: boosting@base-rate (tested rows left: 1)
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.
One paired test, by hand¶
The cell below redoes the test as arithmetic on one register pair (by default the last promotion, on its metric): per-fold differences,
corrected standard error, t, and the one-sided p from scipy. A loss prints p near 1, so it also reads the left tail. Change
PAIR_A, PAIR_B or HAND_METRIC, and say whether the lead is a measured difference.
promoted = [r for r in reg.rows if r["verdict"].startswith("promoted")]
last = promoted[-1] if promoted else reg.rows[-1]
PAIR_A, PAIR_B = (
last["incumbent"],
last["candidate"],
) # any two names in the register
promoted_on = (
last["verdict"].split(":")[1].split()[0]
if last["verdict"].startswith("promoted")
else None
)
HAND_METRIC = promoted_on or "precision"
print(
f"default pair: register row {last['trial']} ({last['candidate']} vs "
f"{last['incumbent']}, verdict '{last['verdict']}') → metric {HAND_METRIC}"
+ ("" if promoted_on else " (nothing promoted: precision by default)")
)
n_test = len(train) // S.n_folds
n_train = len(train) - n_test # exactly as compare() sets them
a, b = reg.scores[PAIR_A][HAND_METRIC], reg.scores[PAIR_B][HAND_METRIC]
diff = b - a # one paired difference per fold: candidate minus incumbent
k = len(diff)
df_hand = k - 1
sd_hand = diff.std(ddof=1) # the plain per-fold SD
se_hand = np.sqrt(sd_hand**2 * (1 / k + n_test / n_train)) # corrected SE
if se_hand == 0: # identical scores in every fold
t_hand, p_hand = 0.0, 1.0
else:
t_hand = diff.mean() / se_hand
p_hand = t_dist.sf(t_hand, df_hand) # one-sided: the RIGHT tail
p_loss = t_dist.cdf(t_hand, df_hand) # the LEFT tail: small when PAIR_B lost
# the hand arithmetic == the functions above == lensml's, on this pair
assert np.isclose(se_hand, corrected_std(diff, n_train, n_test))
ref = lensml.compare(
reg.scores[PAIR_A], reg.scores[PAIR_B], len(train), S.n_folds, HAND_METRIC
)
assert np.allclose(
[t_hand, p_hand],
compute_corrected_ttest(diff, df_hand, n_train, n_test),
)
assert np.allclose(
[t_hand, p_hand], [ref[f"{HAND_METRIC}_t"], ref[f"{HAND_METRIC}_p"]]
)
for r in reg.rows: # ... and on every register row, so the two cannot drift
inc, cand = reg.scores[r["incumbent"]], reg.scores[r["candidate"]]
for m in ("recall", "precision"):
d = cand[m] - inc[m]
ref = lensml.compare(inc, cand, len(train), S.n_folds, m)
assert np.allclose(
compute_corrected_ttest(d, len(d) - 1, n_train, n_test),
[ref[f"{m}_t"], ref[f"{m}_p"]],
)
print(
f"{PAIR_B} − {PAIR_A} on {HAND_METRIC}: {len(diff)} paired fold scores "
f"n_train = {n_train} n_test = {n_test}"
)
print(
f"means {b.mean():.3f} vs {a.mean():.3f} gap {diff.mean():+.4f} per-fold SD of "
f"the gap {sd_hand:.4f} corrected standard error "
f"{se_hand:.4f} (the spread the test uses)"
)
if p_hand < 0.05:
reading = (
f"p < 0.05: a measured GAIN for {PAIR_B} under the uncorrected rule; "
"read it against the Bonferroni line below"
)
elif p_loss < 0.05:
reading = (
f"a measured LOSS for {PAIR_B}: {abs(t_hand):.1f} corrected standard errors "
f"(left-tail p = {show_p(p_loss)})"
)
else:
reading = "NOT a measured difference (neither tail under 0.05)"
print(f"t = {t_hand:.2f} p = {show_p(p_hand)} → {reading}")
reg.bonferroni_line()default pair: register row 5 (boosting@base-rate vs logistic@base-rate, verdict 'promoted: recall up, no precision loss') → metric recall
boosting@base-rate − logistic@base-rate on recall: 50 paired fold scores n_train = 7360 n_test = 1840
means 0.634 vs 0.487 gap +0.1472 per-fold SD of the gap 0.0609 corrected standard error 0.0316 (the spread the test uses)
t = 4.65 p = 1.25e-05 → p < 0.05: a measured GAIN for boosting@base-rate under the uncorrected rule; read it against the Bonferroni line below
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.
Summary¶
Every refinement is a recorded trial with a one-sided, fold-corrected paired test. Promotion needs a measured gain on one decision metric and no average loss on the other, read beside the Bonferroni line. The two threshold trials were chosen after their scores were seen, so their promotions are leads to test, not findings. The last promotion is the model chapter 6 tests.
What’s next?¶
Chapter 6 runs the held-out test once on the final incumbent.
Resources and references¶
| Resource | Where it bears | Licence |
|---|---|---|
| scikit-learn example, Statistical comparison of models using grid search. https:// | the corrected paired t-test; the multiple-comparisons warning | BSD-3-Clause |
| NSF Unidata, Supervised Machine Learning Readiness. https:// | refine until trials no longer improve | Apache-2.0 |