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 4: Tracking the CV-LB Gap

Try the decision first

With no shift and no leak, how far does the CV-LB gap move from sampling alone, and can a public sample of a given size reveal a candidate that is truly better by a small amount?

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

With no shift and no leak, how far does the CV-LB gap move from sampling alone, and can a public sample of a given size reveal a candidate that is truly better by a small amount?

A competitor sees a gap move and a public score go up or down after a change. Before blaming the feature, you need to know how much movement sampling alone produces and which comparison can separate a real gain from it.

Constructed data with no shift, leak or selection; a real gap also contains those. The unpaired rule is a naive baseline for contrast, not a recommended practice.

The experiment

A constructed binary task with six features, 3,000 training rows and a hidden pool of 60,000 rows from the same generator, so there is no shift. The baseline logistic model uses five features; the candidate adds a sixth that carries a small real signal. The gap is public log loss minus local 5-fold log loss. Public samples of m rows are drawn from the pool, 300 per competition, over 20 independent competitions. The candidate's gain is judged two ways: paired (the standard error of the per-row loss difference) and unpaired (treating the two public scores as independent, each with its own standard error). Detection means the gain exceeds 1.96 standard errors.

Calculation code
import numpy as np
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import KFold

from kaggle_companion.activities._common import clean

SEED = 4
REPLICATES = 20          # independent constructed competitions (training set, local CV and large pool of "hidden" rows)
DRAWS = 300              # public samples drawn from the hidden pool in each replicate
N_TRAIN, POOL = 3000, 60000
WEIGHTS = np.array([0.9, -0.7, 0.5, 0.4, -0.3, 0.15])   # the sixth feature carries a small true signal
Z95 = 1.96


def generate(rng, n):
    X = rng.normal(size=(n, 6))
    y = (rng.random(n) < 1 / (1 + np.exp(-(X @ WEIGHTS - 0.2)))).astype(int)
    return X, y


def row_loss(y, p):
    """Log loss of each row; the leaderboard score is the mean of these."""
    p = np.clip(p, 1e-6, 1 - 1e-6)
    return -(y * np.log(p) + (1 - y) * np.log(1 - p))


def one_competition(public_rows, seed):
    rng = np.random.default_rng(seed)
    X, y = generate(rng, N_TRAIN)
    X_hidden, y_hidden = generate(rng, POOL)
    base, cand = slice(0, 5), slice(0, 6)          # baseline: five features; candidate: all six

    # Local CV of the unchanged baseline: 5-fold out-of-fold loss on the training rows.
    oof = np.zeros(N_TRAIN)
    for a, b in KFold(5, shuffle=True, random_state=0).split(X):
        oof[b] = LogisticRegression().fit(X[a][:, base], y[a]).predict_proba(X[b][:, base])[:, 1]
    cv_base = row_loss(y, oof).mean()

    # Per-row loss of both final models on the hidden pool. The same rows are scored by both.
    loss_b = row_loss(y_hidden, LogisticRegression().fit(X[:, base], y).predict_proba(X_hidden[:, base])[:, 1])
    loss_c = row_loss(y_hidden, LogisticRegression().fit(X[:, cand], y).predict_proba(X_hidden[:, cand])[:, 1])

    # Many public samples of `public_rows` hidden rows each.
    idx = rng.integers(0, POOL, (DRAWS, public_rows))
    B, C = loss_b[idx], loss_c[idx]
    D = B - C                                           # per-row paired difference, positive when the candidate is better
    gain = D.mean(axis=1)
    se_paired = D.std(axis=1, ddof=1) / np.sqrt(public_rows)
    se_unpaired = np.sqrt((B.var(axis=1, ddof=1) + C.var(axis=1, ddof=1)) / public_rows)   # two independent scores
    gap = B.mean(axis=1) - cv_base                      # chapter's gap: public loss minus local loss
    return {"pool_base": loss_b.mean(), "pool_cand": loss_c.mean(), "gap": gap, "gain": gain,
            "worse": (gain < 0).mean(), "paired_hit": (gain / se_paired > Z95).mean(),
            "unpaired_hit": (gain / se_unpaired > Z95).mean(),
            "se_paired": se_paired.mean(), "se_unpaired": se_unpaired.mean()}


def run(public_rows):
    runs = [one_competition(public_rows, SEED * 1000 + i) for i in range(REPLICATES)]
    mean = lambda key: float(np.mean([r[key] for r in runs]))
    pool_base, pool_cand = round(mean("pool_base"), 4), round(mean("pool_cand"), 4)
    return clean({
        "public_rows": public_rows,
        "pool_base": pool_base, "pool_cand": pool_cand, "true_gain": round(pool_base - pool_cand, 4),
        # spread of the gap of an UNCHANGED model: public sampling within a competition, and the development side across them
        "gap_public_sd": float(np.mean([r["gap"].std() for r in runs])),
        "gap_dev_sd": float(np.std([r["gap"].mean() for r in runs])),
        "looks_worse": mean("worse"), "paired_detects": mean("paired_hit"), "unpaired_detects": mean("unpaired_hit"),
        "se_paired": mean("se_paired"), "se_unpaired": mean("se_unpaired"),
        "public_gains": np.concatenate([r["gain"][:25] for r in runs]).tolist(),
        "replicates": REPLICATES, "draws": DRAWS, "training_rows": N_TRAIN, "hidden_rows": POOL,
    })

Measure it across the control

The control is rows in the public sample. Each value below runs the whole experiment from the generator up.

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

VALUES = [250, 1000, 4000, 16000]
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    1000    4000   16000
Gap spread (public sampling)    0.028   0.014   0.007   0.004
Gap spread (development side)   0.010   0.009   0.009   0.009
True gain                      0.0021  0.0021  0.0021  0.0021
Better candidate looks worse      32%     16%      3%      0%
Paired detects                     8%     19%     55%     94%
Unpaired detects                   0%      0%      0%      0%

Look at it

The figure shows the default setting, rows in the public sample = 1000.

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

DEFAULT = 1000
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,000 public rows an unchanged model's gap wanders by a standard deviation of 0.014 from public sampling alone, and by 0.009 from the development side, against a true gain of 0.5496 - 0.5475 = 0.0021. The truly better candidate looks worse on 16% of public samples. Read as two independent scores the gain is found in 0% of samples (standard error 0.020); read as a paired difference it is found in 19% (standard error 0.002).

Apply this to your competition

Book location: Chapter 4, The Three Gap Patterns.

Technical corrections

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.

Investigate a forecast holdout that stops predicting leaderboard changes

Conditions

  • a single forecast holdout no longer ranks candidate changes consistently

Action

  • Proposed adaptation of source observation: Add rolling-origin and period-specific checks when the original holdout stops tracking expected evaluation behavior.

Check

  • Proposed verification: Compare candidate rankings across multiple historical periods before retuning the blend.

Limits

  • Recession effects are speculation, and repeated public submissions can themselves overfit; no causal shift diagnosis is established.

Find source links and value grades in the lesson atlas

Distinguish algorithm quality from shared test-era trends

Conditions

  • many forecast series may share the same test dates

Action

  • Proposed conditional application: Assess performance over staggered historical cutoff periods before claiming one method generally better.

Check

  • Proposed check: Evaluate rolling and staggered origins across several eras and compare method rankings, horizon error and sensitivity to shared shocks.

Limits

  • Source did not perform the suggested staggered evaluation; common test-era confounding is a hypothesis.

Find source links and value grades in the lesson atlas

Audit extreme labels when local and public scores conflict

Conditions

  • rare high labels dominate error and may shift across years

Action

  • Proposed conditional application: Investigate extreme-target distribution shift before adding a special tail class or removing suspect rows.

Check

  • Proposed check: Audit extremes by year/gauge and compare retaining/reweighting/removal on time-held-out tail loss.

Limits

  • Genuine extreme weather must not be deleted solely for leaderboard improvement; 2014 labels unavailable to author.

Find source links and value grades in the lesson atlas