This page shows the chapter notebook with its saved results. Constructed examples do not measure competition gains.
Chapter 59: Threshold Optimization
Try the decision first
How far does the development F1 of a tuned threshold overstate its F1 on fresh labels, and how does that depend on the size of the development set?
Write your expectation before opening the worked results. Use the chapter activity to compare supported inputs. To change the calculation itself, run the notebook on GitHub.
Your answers stay on this page until you download them. They are not sent to a server.
Worked notebook and saved results
How far does the development F1 of a tuned threshold overstate its F1 on fresh labels, and how does that depend on the size of the development set?
A threshold is a fitted parameter chosen to maximize the metric on rows whose labels it can exploit. The best search score is a development result, optimistic by an amount that depends on how many positives the search saw.
Constructed data with a stable relationship. The gain from averaging fold thresholds is small here (about 0.01 at 100 rows); sizes depend on the model's strength and the metric.
The experiment
A constructed binary task with 5% positives and five noisy features. A logistic regression is fitted once on 5,000 rows. For each development-set size, 300 independent development sets are drawn; on each, the threshold that maximizes F1 is found by scanning every cut of the sorted scores, then frozen and scored on 50,000 fresh rows with the same prevalence. Also reported: the default 0.5, the best possible threshold (tuned on the 50,000 audit rows, a ceiling no real search sees), the average of five thresholds each tuned on the development set with one fifth held back, and the frozen threshold on audits with 1% and 20% positives.
Calculation code
import numpy as np
from sklearn.linear_model import LogisticRegression
from kaggle_companion.activities._common import clean
SEED = 59
PREVALENCE = 0.05 # share of positive rows when the model and the threshold are fitted
DRAWS = 300 # independent development sets per size; results are means over draws
AUDIT_ROWS = 50000 # fresh rows used only for scoring
SHIFTS = [0.01, 0.05, 0.20] # audit prevalences used to test the frozen threshold
def generate(n, prevalence, rng):
"""Five noisy features; each shifts a little for positive rows."""
y = (rng.random(n) < prevalence).astype(int)
X = rng.normal(size=(n, 5)) + y[:, None] * np.array([1.0, 0.8, 0.6, 0.4, 0.2])
return X, y
class Scored:
"""Rows sorted by score, so F1 at any threshold costs one search instead of one pass."""
def __init__(self, p, y):
order = np.argsort(p)
self.p, self.y = p[order], y[order]
self.positives_from = np.r_[np.cumsum(self.y[::-1])[::-1], 0] # positives among rows at or above each position
self.total = int(y.sum())
def f1(self, threshold):
"""F1 when every row with score >= threshold is called positive."""
i = np.searchsorted(self.p, threshold, side="left")
called, hits = len(self.p) - i, self.positives_from[i]
return 2 * hits / (called + self.total) if called + self.total else 0.0
def best_threshold(self):
"""The threshold that maximizes F1 on these rows: a midpoint between two neighbouring scores."""
k = np.arange(len(self.p), 0, -1) # rows called positive when cutting at each position
f1 = 2 * self.positives_from[:-1] / (k + self.total)
i = int(np.argmax(f1))
return (self.p[i - 1] + self.p[i]) / 2 if i > 0 else self.p[0] - 1e-9, float(f1[i])
def averaged_fold_threshold(p, y, rng, folds=5):
"""A variant of the chapter's fold aggregation: tune on each fold's training part (four fifths of the rows), then average."""
order = rng.permutation(len(p))
found = []
for k in range(folds):
keep = np.setdiff1d(order, order[k::folds])
if y[keep].sum() > 0:
found.append(Scored(p[keep], y[keep]).best_threshold()[0])
return float(np.mean(found))
def run(development_rows):
rng = np.random.default_rng(SEED)
X, y = generate(5000, PREVALENCE, rng)
model = LogisticRegression(max_iter=500).fit(X, y) # fitted once; never sees development or audit rows
audits = {}
for prevalence in SHIFTS:
Xa, ya = generate(AUDIT_ROWS, prevalence, rng)
audits[prevalence] = Scored(model.predict_proba(Xa)[:, 1], ya)
audit = audits[PREVALENCE]
tuned_dev, tuned_audit, fold_audit, thresholds = [], [], [], []
shifted = {s: [] for s in SHIFTS}
for _ in range(DRAWS):
Xd, yd = generate(development_rows, PREVALENCE, rng)
if yd.sum() == 0: # a draw with no positive row cannot be tuned: give it one
yd[rng.integers(development_rows)] = 1
dev = Scored(model.predict_proba(Xd)[:, 1], yd)
threshold, dev_f1 = dev.best_threshold() # fitted on the development rows' own labels
tuned_dev.append(dev_f1)
tuned_audit.append(audit.f1(threshold)) # frozen, then scored on fresh labels
thresholds.append(threshold)
fold_audit.append(audit.f1(averaged_fold_threshold(dev.p, dev.y, rng)))
for s in SHIFTS:
shifted[s].append(audits[s].f1(threshold))
return clean({
"development_rows": development_rows,
"development_f1": float(np.mean(tuned_dev)), "development_f1_sd": float(np.std(tuned_dev)),
"audit_f1": float(np.mean(tuned_audit)), "audit_f1_sd": float(np.std(tuned_audit)),
"fold_average_audit_f1": float(np.mean(fold_audit)),
"default_audit_f1": audit.f1(0.5), "best_possible_audit_f1": audit.best_threshold()[1],
"best_possible_threshold": audit.best_threshold()[0],
"threshold_mean": float(np.mean(thresholds)), "threshold_sd": float(np.std(thresholds)),
"tuned_beats_default": float(np.mean(np.array(tuned_audit) > audit.f1(0.5))),
"shift": {str(s): {"frozen": float(np.mean(shifted[s])), "default": audits[s].f1(0.5),
"best_possible": audits[s].best_threshold()[1]} for s in SHIFTS},
"draws": DRAWS, "audit_rows": AUDIT_ROWS,
})
Measure it across the control
The control is rows in the development set used to tune the threshold. Each value below runs the whole experiment from the generator up.
Calculation code
import pandas as pd
from kaggle_companion.activities.ch59 import explain
VALUES = [100, 300, 1000, 5000]
results = {value: run(value) for value in VALUES}
table = pd.DataFrame({str(v): explain(results[v], v)['metrics'] for v in VALUES})
print(table.to_string())100 300 1000 5000 Development F1 (tuned) 0.502 0.443 0.402 0.380 Fresh-rows F1 (same threshold) 0.315 0.330 0.343 0.352 Optimism 0.187 0.113 0.059 0.028 Default 0.5 F1 0.215 0.215 0.215 0.215 Tuned beats default 93% of draws 97% of draws 100% of draws 100% of draws
Look at it
The figure shows the default setting, rows in the development set used to tune the threshold = 100.
Calculation code
import matplotlib.pyplot as plt
from kaggle_companion.activities.ch59 import draw, NCOLS, HEIGHT
DEFAULT = 100
fig, axes = plt.subplots(1, NCOLS, figsize=(6.6 if NCOLS == 1 else 10.4, HEIGHT), layout='constrained')
draw(axes, results[DEFAULT], DEFAULT)
plt.show()What the result says
With 100 development rows, the tuned threshold reaches an F1 of 0.502 on those rows but 0.315 on 50,000 fresh rows, so 0.502 - 0.315 = 0.187 is the optimism. The default 0.5 scores 0.215 on the fresh rows, so tuning gains 0.315 - 0.215 = 0.100 and beats the default in 93% of 300 draws. The best possible threshold scores 0.361. The tuned threshold itself varies by 0.132 (one standard deviation) around 0.225. Frozen at 5% positives and scored where only 1% are positive it gives 0.139 (default 0.5: 0.192), and at 20% positive 0.481 (best cut for that audit: 0.595).
- Optimism: 0.502 - 0.315 = +0.187 (development minus fresh rows).
- Gain over the default 0.5: 0.315 - 0.215 = +0.100.
- Shortfall against the best possible threshold: 0.361 - 0.315 = +0.046.
- Fold-averaged threshold minus single tuned threshold on fresh rows: 0.324 - 0.315 = +0.009.
Apply this to your competition
- Tune the threshold on development predictions only, freeze it, and report its score on labels the search never saw.
- Expect the development score to overstate the frozen score, most of all when the development set holds few positives, and count positives, not rows.
- Compare the tuned threshold with the default and with a simple aggregate such as the average of fold thresholds before keeping a complicated recipe.
- Check the frozen threshold at the prevalence you expect in the test data; a threshold carries the class balance it was tuned on.
Book location: Chapter 59, When Threshold Optimization Overfits.
Apply a lesson from this chapter
Select a lesson whose conditions match your project. State how you would test it and what would make you reject it.
Compare ordinal decoding choices under repeated validation
Conditions
- postprocessing strongly affects an ordinal metric
Action
- Proposed adaptation of source observation: Benchmark several decoders under repeated folds before changing the underlying model.
Check
- Proposed verification: Report fold dispersion and recalibrate using only each training fold.
Limits
- The large result is source-specific and does not establish prevalence robustness.
Decode ordinal regression by fold-local class frequencies
Conditions
- ordinal regression predicts classes with a skewed distribution
Action
- Proposed adaptation of source observation: Estimate class CDF on training folds and allocate sorted predictions by those cumulative proportions.
Check
- Proposed verification: Compare QWK with rounding and ceiling on identical folds and stress-test prevalence shift.
Limits
- Uses a batch-distribution assumption; frequencies must not be read from validation labels.
Align classifier thresholds with the requested metric
Conditions
- different classification tasks reward different decision tradeoffs
Action
- Proposed adaptation of source observation: For the binary F2 task, select a probability threshold against F2 rather than assuming 0.5; compare with other metrics separately.
Check
- Proposed verification: Use OOF probabilities and an untouched set to evaluate selected thresholds.
Limits
- The packet is a repository overview with no threshold or quantitative comparison; steps are proposed from the stated approach.
Choose the class with lowest expected error cost
Conditions
- classification uses an asymmetric misclassification cost matrix
- classification errors have unequal explicit penalties
Action
- Proposed: multiply predicted class probabilities by the cost matrix and select the minimum expected-cost class.
- Proposed adaptation of source observation: Compute expected loss for every decision from predicted class probabilities and the cost matrix, then choose minimum loss.
Check
- Proposed: verify class ordering and matrix orientation; evaluate mean realized cost against argmax predictions and inspect probability calibration.
- Proposed verification: Compare mean realized cost with argmax classification and assess probability calibration.
Limits
- Expected-cost decisions depend on calibrated probabilities and the correct mapping between cost rows and class probabilities. The exploratory script has commented train/test assignments and no numeric result.
- Outlier removal should be checked per fold; the source’s claim that XGBoost handles imbalance internally is not a guarantee.
Expose class probabilities for downstream confidence decisions
Conditions
- downstream workflows need confidence-aware text decisions
Action
- Proposed adaptation of source observation: Save identified class probabilities alongside hard labels.
Check
- Proposed verification: Check row/class alignment and calibration before routing by confidence.
Limits
- Softmax probabilities are not automatically calibrated and no calibration evaluation is supplied.