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 19: March Mania: Prediction at Scale

Try the decision first

If the team history quietly includes later seasons, would the development score tell you, and would the label-flip test?

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

If the team history quietly includes later seasons, would the development score tell you, and would the label-flip test?

A leave-one-season-out feature build is an easy mistake and rarely inflates the score by much. The chapter's test, reversing the later labels and checking that nothing earlier moves, detects it exactly. Measuring both shows why the chapter relies on the test.

Constructed data. The leak here is small and the activity says so: the chapter's claim is that the boundary can be tested exactly, not that leaked history ruins a score.

The experiment

Forty constructed fixtures from the chapter's generator (12 teams, 8 seasons of round-robin games, drifting strengths), plus the chapter's own seed 42. Each pipeline fits logistic regression and a small boosted tree on seasons 1 to 4, selects among them and a constant 0.5 by Brier loss on seasons 5 and 6, refits on seasons 1 to 6 and forecasts seasons 7 and 8 using only legal history. The control adds that many later seasons to every team's history when the features are built, as a leave-one-season-out build would. At 0 the pipeline is the chapter's. The label-flip test reverses every season 7 and 8 outcome and measures how much the selected recipe's development Brier moves.

Calculation code
import numpy as np
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import brier_score_loss

from kaggle_companion.activities._common import clean

SEED = 19
FIXTURES = 40       # independent constructed seasons-of-games; every estimate is their mean
CHAPTER_SEED = 42   # the chapter's own fixture, reported separately
TEAMS = 12


def fixture(seed):
    """Eight seasons of round-robin games between 12 teams whose strengths drift (the chapter's generator)."""
    rng = np.random.default_rng(seed)
    strength = rng.normal(size=TEAMS)
    season, a, b, y, p = [], [], [], [], []
    for s in range(1, 9):
        strength = 0.85 * strength + rng.normal(0, 0.25, size=TEAMS)
        for i in range(TEAMS):
            for j in range(i + 1, TEAMS):
                prob = 1 / (1 + np.exp(-(strength[i] - strength[j])))
                season.append(s); a.append(i); b.append(j); p.append(prob); y.append(int(rng.random() < prob))
    return {"season": np.array(season), "a": np.array(a), "b": np.array(b), "y": np.array(y), "p": np.array(p)}


def rate_difference(fx, leak):
    """Smoothed win rate of team A minus team B, from the history visible at the start of each game's season.

    Legal history is every earlier season. `leak` adds that many LATER seasons, as a leave-one-season-out
    feature build would. leak = 0 reproduces the chapter's pipeline."""
    season, a, b, y = fx["season"], fx["a"], fx["b"], fx["y"]
    out = np.zeros(len(y))
    for s in range(1, 9):
        use = (season < s) | ((season > s) & (season <= s + leak))
        wins, games = np.zeros(TEAMS), np.zeros(TEAMS)
        np.add.at(wins, a[use], y[use]); np.add.at(wins, b[use], 1 - y[use])
        np.add.at(games, a[use], 1); np.add.at(games, b[use], 1)
        rate = (wins + 1) / (games + 2)                 # Beta(1, 1) smoothing, as in the chapter
        here = season == s
        out[here] = rate[a[here]] - rate[b[here]]
    return out


def candidates():
    """The chapter's two fixed candidates; a constant 0.5 is the third."""
    return {"logistic": LogisticRegression(C=1.0, random_state=42, max_iter=1000),
            "small_tree": HistGradientBoostingClassifier(max_iter=100, max_leaf_nodes=7, l2_regularization=1.0,
                                                         early_stopping=False, random_state=42)}


def pipeline(fx, leak):
    """Fit on seasons 1-4, select on 5-6 by Brier, refit on 1-6, then forecast 7-8 with legal history only."""
    x = rate_difference(fx, leak)[:, None]
    season, y = fx["season"], fx["y"]
    train, dev, fit, test = season <= 4, (season == 5) | (season == 6), season <= 6, season >= 7
    scores = {"constant": brier_score_loss(y[dev], np.full(dev.sum(), 0.5))}
    for name, model in candidates().items():
        scores[name] = brier_score_loss(y[dev], model.fit(x[train], y[train]).predict_proba(x[dev])[:, 1])
    order = ["constant", "logistic", "small_tree"]
    selected = min(order, key=lambda n: (scores[n], order.index(n)))
    if selected == "constant":
        p = np.full(test.sum(), 0.5)
    else:   # at forecast time only legal history exists, whatever history built the training rows
        p = candidates()[selected].fit(x[fit], y[fit]).predict_proba(rate_difference(fx, 0)[test][:, None])[:, 1]
    return scores, selected, brier_score_loss(y[test], p)


def flip_assessment_labels(fx):
    """The chapter's test: reverse every season 7 and 8 outcome and see whether anything earlier moves."""
    return {**fx, "y": np.where(fx["season"] >= 7, 1 - fx["y"], fx["y"])}


def run(leaked_seasons):
    fixtures = [fixture(SEED * 1000 + i) for i in range(FIXTURES)]
    estimate, delivered, change, reselected, shift = [], [], [], [], []
    for fx in fixtures:
        scores, selected, outcome = pipeline(fx, leaked_seasons)
        legal_scores, legal_selected, _ = pipeline(fx, 0)
        flipped_scores, flipped_selected, _ = pipeline(flip_assessment_labels(fx), leaked_seasons)
        estimate.append(scores[selected])
        delivered.append(outcome)
        change.append(abs(flipped_scores[selected] - scores[selected]))   # selected recipe's development Brier
        reselected.append(flipped_selected != selected)
        shift.append(scores[selected] - legal_scores[legal_selected])
    ref = fixture(CHAPTER_SEED)
    ref_scores, ref_selected, ref_outcome = pipeline(ref, leaked_seasons)
    ref_flip = pipeline(flip_assessment_labels(ref), leaked_seasons)[0]
    dev, test = np.isin(fixtures[0]["season"], [5, 6]), fixtures[0]["season"] >= 7
    best = lambda m: float(np.mean([(f["p"][m] * (1 - f["p"][m])).mean() for f in fixtures]))   # Brier of the true probabilities
    return clean({
        "leaked_seasons": leaked_seasons, "fixtures": FIXTURES,
        "estimate": float(np.mean(estimate)), "delivered": float(np.mean(delivered)),
        "best_possible_development": best(dev), "best_possible_assessment": best(test),
        "estimate_shift": float(np.mean(shift)), "estimate_shift_se": float(np.std(shift) / np.sqrt(FIXTURES)),
        "flip_change": float(np.mean(change)), "flip_change_per_fixture": change,
        "recipe_changed": float(np.mean(reselected)),
        "chapter_fixture": {"scores": ref_scores, "selected": ref_selected, "delivered": ref_outcome,
                            "flip_change": abs(ref_flip[ref_selected] - ref_scores[ref_selected])},
    })

Measure it across the control

The control is later seasons included in each game's team history. Each value below runs the whole experiment from the generator up.

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

VALUES = [0, 1, 2, 3]
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())
                                            0         1         2         3
Development estimate                   0.2282    0.2257    0.2241    0.2248
Forecast of seasons 7-8                0.2377    0.2359    0.2365    0.2377
Label-flip change                      0.0000    0.0038    0.0110    0.0133
Recipe changed by flip                  0.000     0.075     0.175     0.200
Chapter fixture, logistic dev Brier  0.217400  0.210300  0.212700  0.215200

Look at it

The figure shows the default setting, later seasons included in each game's team history = 1.

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

DEFAULT = 1
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 1 later season(s) in the history, the development estimate averages 0.2257 and the forecast of seasons 7 and 8 scores 0.2359, so 0.2359 - 0.2257 = 0.0102. Against the legal pipeline the estimate moves -0.0026 (standard error 0.0004). The label-flip test fails: reversing the season 7 and 8 labels moves the development Brier by 0.0038 on average and changes the selected recipe in 0.075 of fixtures. The chapter's own fixture moves by 0.0034.

Apply this to your competition

Book location: Chapter 19, Run the Complete Constructed Forecast.

Technical corrections