Winning Kaggle the Reproducible Way

Interactive chapter

This page shows the chapter notebook with its saved results. Constructed examples do not measure competition gains.

Chapter 20: Horse Health: Small Data, Big Discipline

Try the decision first

How much of the accuracy a tuned class factor adds on its own OOF rows survives on new rows?

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.

Open the chapter activity

Worked notebook and saved results

How much of the accuracy a tuned class factor adds on its own OOF rows survives on new rows?

With 3 classes and micro-F1 (which equals accuracy for single-label prediction), a per-class factor looks like free accuracy on the rows that produced it. The chapter says an OOF base matrix does not make the fitted factors' same-row score independent; this measures by how much.

Constructed data. Whether factors pay depends on the probabilities, the metric and the number of rows; the contrast, not the size, is the lesson.

The experiment

Fourteen constructed three-class datasets per development size (about 45%, 33% and 22% of rows), eight numeric features. A small boosted-tree classifier gives 5-fold OOF probabilities. A grid of 169 factor triples is searched for the highest OOF accuracy (divide each class probability by its factor, take argmax). Same-row gain is that accuracy minus plain argmax accuracy on the OOF rows. Cross-half gain tunes on one half of the OOF rows and scores the other half. New-row gain applies the tuned factors to the model refitted on all development rows and scores 4,000 fresh rows. Two kinds of probabilities are compared: the model's own, and the same probabilities divided by training class frequency and renormalized, which distorts them toward the rare classes as class-weighted training does.

Calculation code
import numpy as np
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.model_selection import StratifiedKFold

from kaggle_companion.activities._common import clean

SEED = 20
REPLICATES = 14      # independent constructed datasets per development size
N_NEW = 4000         # fresh rows from the same generator, never used for fitting or tuning
GRID = np.exp(np.linspace(np.log(0.5), np.log(2.0), 13))
FACTORS = np.array([(1.0, a, b) for a in GRID for b in GRID])   # divide class 0, 1, 2 probabilities by these, then take argmax


def generate(n, rng, weights, bias):
    """Three-class task with moderate imbalance (about 45% / 33% / 22%) and a few nonlinear terms."""
    X = rng.normal(size=(n, 8))
    z = X @ weights + bias + 0.5 * np.column_stack([np.sin(2 * X[:, 0]), X[:, 1] * X[:, 2], np.zeros(n)])
    p = np.exp(z - z.max(1, keepdims=True))
    p /= p.sum(1, keepdims=True)
    return X, np.minimum((rng.random(n)[:, None] > p.cumsum(1)).sum(1), 2)


def model():
    return HistGradientBoostingClassifier(learning_rate=0.1, max_leaf_nodes=6, max_iter=40, early_stopping=False, random_state=0)


def tune(proba, y):
    """Grid-search the factors that maximize accuracy on these rows; return them and that accuracy."""
    accuracy = (np.argmax(proba[:, None, :] / FACTORS[None], 2) == y[:, None]).mean(0)
    best = int(np.argmax(accuracy))
    return FACTORS[best], accuracy[best]


def gain(proba, y, factors):
    """Accuracy of factor-adjusted argmax minus accuracy of plain argmax, on these rows."""
    return float((np.argmax(proba / factors, 1) == y).mean() - (proba.argmax(1) == y).mean())


def one_dataset(n, seed):
    rng = np.random.default_rng(seed)
    weights, bias = rng.normal(0, 0.55, (8, 3)), np.array([0.5, 0.0, -0.5])
    X, y = generate(n, rng, weights, bias)
    X_new, y_new = generate(N_NEW, rng, weights, bias)
    oof = np.zeros((n, 3))
    for fit, out in StratifiedKFold(5, shuffle=True, random_state=1).split(X, y):
        oof[out] = model().fit(X[fit], y[fit]).predict_proba(X[out])
    new = model().fit(X, y).predict_proba(X_new)         # the deployed model, refitted on every development row
    prior = np.bincount(y, minlength=3) / n
    half = rng.permutation(n)
    halves = (half[: n // 2], half[n // 2:])
    out = {}
    # "plain": the model's own probabilities. "rebalanced": divided by training class frequency and renormalized,
    # which distorts them toward the rare classes in the way class-weighted training does.
    for name, (p_oof, p_new) in {"plain": (oof, new), "rebalanced": (oof / prior, new / prior)}.items():
        p_oof, p_new = p_oof / p_oof.sum(1, keepdims=True), p_new / p_new.sum(1, keepdims=True)
        factors, tuned_accuracy = tune(p_oof, y)
        # Same rows: tuned on the OOF rows and scored on those same rows. Cross-half: tuned on one half, scored on the other.
        same_rows = tuned_accuracy - (p_oof.argmax(1) == y).mean()
        cross = np.mean([gain(p_oof[b], y[b], tune(p_oof[a], y[a])[0]) for a, b in (halves, halves[::-1])])
        out[name] = (same_rows, cross, gain(p_new, y_new, factors), (p_oof.argmax(1) == y).mean())
    return out


def run(development_rows):
    sets = [one_dataset(development_rows, SEED * 1000 + i) for i in range(REPLICATES)]
    models = {}
    for name in ("plain", "rebalanced"):
        v = np.array([s[name] for s in sets])
        models[name] = {"same_rows": float(v[:, 0].mean()), "cross_half": float(v[:, 1].mean()), "new_rows": float(v[:, 2].mean()),
                        "new_rows_se": float(v[:, 2].std() / np.sqrt(REPLICATES)), "argmax_accuracy": float(v[:, 3].mean())}
    return clean({"development_rows": development_rows, "new_rows": N_NEW, "replicates": REPLICATES, "models": models})

Measure it across the control

The control is development rows. Each value below runs the whole experiment from the generator up.

Calculation code
import pandas as pd
from kaggle_companion.activities.ch20 import explain

VALUES = [250, 500, 988, 3000]
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())
                                      250    500    988   3000
Plain, same-row gain (points)        2.97   1.89   1.03   0.31
Plain, new-row gain (points)        -0.56  -0.15  -0.52  -0.03
Rebalanced, same-row gain (points)   3.14   2.96   2.06   1.95
Rebalanced, new-row gain (points)   +0.41  +1.03  +0.72  +1.45
Plain argmax accuracy on OOF rows   0.573  0.616  0.629  0.654

Look at it

The figure shows the default setting, development rows = 988.

Calculation code
import matplotlib.pyplot as plt
from kaggle_companion.activities.ch20 import draw, NCOLS, HEIGHT

DEFAULT = 988
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()
Saved figure from this chapter calculation

What the result says

With 988 development rows the model's own probabilities gain 1.03 accuracy points from tuned factors on the rows that tuned them and -0.52 on 4,000 new rows, so 1.03 - (-0.52) = 1.55 points is overstatement. For the rebalanced probabilities the same-row gain is 2.06 and the new-row gain +0.72 points: the factors pay there. The same-row gain is positive in both cases and cannot tell them apart. The cross-half gains are -0.94 and +0.79, which does separate them.

Apply this to your competition

Book location: Chapter 20, Per-Class Threshold Optimization on OOF.

Technical corrections