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 2: The Process Mindset

Try the decision first

How much of the winning version's public gain is still there on rows the choice never used, as the number of logged versions grows?

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 winning version's public gain is still there on rows the choice never used, as the number of logged versions grows?

A log of many versions invites the question of which one to keep. If the public score picks it, the winner's public gain contains a share of luck that grows with the number of versions, and the log has created no new independent labels.

Constructed data. Whether the pick helps on hidden rows depends on how many versions truly help; here 2 of 100 do, by design, and the sizes are properties of this generator.

The experiment

Sixteen constructed competitions. Each has 800 training rows, 2,000 test rows (400 public and 1,600 private, drawn at random ten times) and 20,000 fresh rows as the truth. A baseline logistic regression uses 6 columns; version k adds one of 100 candidate columns and refits, and only 2 of the 100 candidates truly help, by a small amount. The control is how many versions are logged (the baseline plus candidates). The winner is the version with the lowest public log loss. Gain is the baseline's loss minus the winner's loss, so a positive gain looks like improvement.

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

from kaggle_companion.activities._common import clean

SEED = 2
REPLICATES = 16                      # independent constructed competitions; every estimate is their mean
SPLITS = 10                          # random public/private splits of each competition's test rows
N_TRAIN, N_TEST, N_FRESH = 800, 2000, 20000
PUBLIC_SHARE = 0.20                  # public leaderboard rows: 400 of the 2,000 test rows
N_BASE, N_CANDIDATES, N_USEFUL, USEFUL_WEIGHT = 6, 100, 2, 0.25
LOGGED = [3, 10, 30, 100]            # numbers of logged versions the control can take


def generate(n, rng, w_base, w_cand):
    """Six base columns and 100 candidate columns. Only two candidates truly help, and only a little."""
    base = rng.normal(size=(n, N_BASE))
    cand = rng.normal(size=(n, N_CANDIDATES))
    p = 1 / (1 + np.exp(-(base @ w_base + cand @ w_cand)))
    return np.column_stack([base, cand]), (rng.random(n) < p).astype(int)


def row_loss(p, y):
    p = np.clip(p, 1e-6, 1 - 1e-6)
    return -(y * np.log(p) + (1 - y) * np.log(1 - p))


def one_competition(seed):
    """Version 0 is the baseline. Version k adds candidate column k and refits. Returns every version's
    per-row test loss and its mean loss on a large fresh sample (the truth)."""
    rng = np.random.default_rng(seed)
    w_base = rng.normal(0, 0.6, N_BASE)
    w_cand = np.zeros(N_CANDIDATES)
    w_cand[rng.choice(N_CANDIDATES, N_USEFUL, replace=False)] = USEFUL_WEIGHT
    X, y = generate(N_TRAIN, rng, w_base, w_cand)
    X_test, y_test = generate(N_TEST, rng, w_base, w_cand)
    X_fresh, y_fresh = generate(N_FRESH, rng, w_base, w_cand)
    test_loss = np.zeros((max(LOGGED), N_TEST))
    fresh_loss = np.zeros(max(LOGGED))
    for k in range(max(LOGGED)):
        cols = list(range(N_BASE)) + ([] if k == 0 else [N_BASE + k - 1])
        model = LogisticRegression(max_iter=200).fit(X[:, cols], y)
        test_loss[k] = row_loss(model.predict_proba(X_test[:, cols])[:, 1], y_test)
        fresh_loss[k] = row_loss(model.predict_proba(X_fresh[:, cols])[:, 1], y_fresh).mean()
    # Each split draws a public sample; the winner is the version with the lowest public loss among the first N.
    gains = {n: [] for n in LOGGED}
    for _ in range(SPLITS):
        public = np.zeros(N_TEST, bool)
        public[rng.choice(N_TEST, int(PUBLIC_SHARE * N_TEST), replace=False)] = True
        pub_loss, priv_loss = test_loss[:, public].mean(1), test_loss[:, ~public].mean(1)
        for n in LOGGED:
            k = int(np.argmin(pub_loss[:n]))
            # gain = baseline loss minus the winner's loss, so positive means the winner looks better
            gains[n].append([pub_loss[0] - pub_loss[k], priv_loss[0] - priv_loss[k], fresh_loss[0] - fresh_loss[k]])
    return {n: np.mean(g, axis=0) for n, g in gains.items()}, {n: np.mean(np.array(g)[:, 2] > 0) for n, g in gains.items()}


def run(versions):
    studies = [one_competition(SEED * 1000 + i) for i in range(REPLICATES)]
    curve = {}
    for n in LOGGED:
        g = np.array([s[0][n] for s in studies])           # replicates x (public, private, fresh)
        curve[n] = {"public": g[:, 0].mean(), "private": g[:, 1].mean(), "fresh": g[:, 2].mean(),
                    "public_sd": g[:, 0].std(), "private_sd": g[:, 1].std(),
                    "truly_better": float(np.mean([s[1][n] for s in studies])),
                    "per_study_public": g[:, 0], "per_study_private": g[:, 1]}
    return clean({"versions": versions, "selected": curve[versions],
                  "curve": [{"versions": n, **curve[n]} for n in LOGGED],
                  "replicates": REPLICATES, "splits": SPLITS, "public_rows": int(PUBLIC_SHARE * N_TEST),
                  "private_rows": N_TEST - int(PUBLIC_SHARE * N_TEST), "fresh_rows": N_FRESH})

Measure it across the control

The control is versions logged (including the baseline). Each value below runs the whole experiment from the generator up.

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

VALUES = [3, 10, 30, 100]
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())
                            3       10       30      100
Public gain            0.0007   0.0030   0.0045   0.0083
Private gain          -0.0003   0.0001   0.0008   0.0032
Fresh-row gain        -0.0004   0.0001   0.0010   0.0036
Public minus private  +0.0010  +0.0029  +0.0037  +0.0051
Picks truly better        13%      19%      34%      76%

Look at it

The figure shows the default setting, versions logged (including the baseline) = 30.

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

DEFAULT = 30
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 30 versions logged, the winner's public gain is 0.0045 and its private gain is 0.0008, so 0.0045 - 0.0008 = 0.0037 of the public gain is not there on private rows. On 20,000 fresh rows the winner gains +0.0010. Most of the public gain is the luck of picking the best of many comparisons. The winner truly beats the baseline in 34% of the picks.

Apply this to your competition

Book location: Chapter 2, Preserve the Comparison.

Technical corrections