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 17: Stacking and Hill-Climbing

Try the decision first

As the candidate library fills with near-duplicate models, how much of the hill-climbing improvement shows up on assessment 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

As the candidate library fills with near-duplicate models, how much of the hill-climbing improvement shows up on assessment rows?

The chapter warns that greedy search can overfit repeated development comparisons, and that an old score does not show the benefit. Measuring development against assessment gain shows what extra candidates are worth.

Constructed data with deliberate near-duplicates. The assessment loss did not rise with library size, so the extra candidates were harmless here but also nearly useless; the sizes are properties of this generator.

The experiment

A constructed binary task with 20 features, six of them informative. Four logistic-regression families each read five features and are fitted on 400 rows; the library then grows by bootstrap refits of those same four, so the extras are near-duplicates. The chapter's greedy_brier_weights runs on 200 development rows and is scored by Brier loss on those rows and on 5,000 assessment rows, against the same search on the four original models. Twenty-five independent datasets are averaged.

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

from kaggle_companion.activities._common import clean

SEED = 17
REPLICATES = 25                    # independent constructed datasets; every estimate is their mean
FEATURES, FAMILIES = 20, 4         # 20 features, 6 of them informative; 4 genuinely different model families
N_TRAIN, N_DEV, N_ASSESS = 400, 200, 5000


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


def greedy_brier_weights(y_development, candidate_predictions, step=0.1, max_iterations=30):
    """The chapter's hill-climbing function: start from the best candidate, then repeatedly move `step` of the weight
    to whichever candidate lowers the development Brier loss most, and stop when nothing helps."""
    y = np.asarray(y_development, dtype=float)
    p = np.asarray(candidate_predictions, dtype=float)
    if p.ndim != 2 or p.shape[0] != len(y) or p.shape[1] == 0:
        raise ValueError('rows by candidate matrix required')
    if not np.isfinite(p).all() or ((p < 0) | (p > 1)).any():
        raise ValueError('finite binary probabilities required')
    if not np.isin(y, [0, 1]).all() or not 0 < step <= 1:
        raise ValueError('binary labels and step in (0,1] required')
    loss = lambda q: np.mean((q - y) ** 2)
    start = int(np.argmin(np.mean((p - y[:, None]) ** 2, axis=0)))
    weights = np.eye(p.shape[1])[start]
    current = p @ weights
    for _ in range(max_iterations):
        choices = (1 - step) * current[:, None] + step * p
        losses = np.mean((choices - y[:, None]) ** 2, axis=0)
        winner = int(np.argmin(losses))
        if losses[winner] >= loss(current) - 1e-12:
            break
        weights *= 1 - step
        weights[winner] += step
        current = p @ weights
    return weights


def brier(y, p):
    return float(np.mean((p - y) ** 2))


def run(library_size):
    rows = []
    for rep in range(REPLICATES):
        rng = np.random.default_rng(SEED * 100 + rep)
        coef = np.zeros(FEATURES)
        coef[:6] = rng.normal(0, 0.8, 6)
        (X, y), (Xd, yd), (Xa, ya) = [generate(rng, n, coef) for n in (N_TRAIN, N_DEV, N_ASSESS)]
        columns = [rng.choice(FEATURES, 5, replace=False) for _ in range(FAMILIES)]   # each family reads five features
        Pd, Pa = np.zeros((N_DEV, library_size)), np.zeros((N_ASSESS, library_size))
        for j in range(library_size):
            cols = columns[j % FAMILIES]
            rows_used = np.arange(N_TRAIN) if j < FAMILIES else rng.integers(0, N_TRAIN, N_TRAIN)  # extras: bootstrap refits
            model = LogisticRegression(max_iter=300).fit(X[rows_used][:, cols], y[rows_used])
            Pd[:, j], Pa[:, j] = model.predict_proba(Xd[:, cols])[:, 1], model.predict_proba(Xa[:, cols])[:, 1]
        best = int(np.argmin([brier(yd, Pd[:, j]) for j in range(library_size)]))   # best single candidate, chosen on development rows
        w_all = greedy_brier_weights(yd, Pd)
        w_start = greedy_brier_weights(yd, Pd[:, :FAMILIES])                        # the same search on the four original models
        rows.append({
            "best": (brier(yd, Pd[:, best]), brier(ya, Pa[:, best])),
            "equal": (brier(yd, Pd.mean(axis=1)), brier(ya, Pa.mean(axis=1))),
            "greedy": (brier(yd, Pd @ w_all), brier(ya, Pa @ w_all)),
            "greedy_start": (brier(yd, Pd[:, :FAMILIES] @ w_start), brier(ya, Pa[:, :FAMILIES] @ w_start)),
            "kept": int((w_all > 0).sum()),
        })
    pick = lambda name, i: np.array([r[name][i] for r in rows])
    gain_dev = pick("greedy_start", 0) - pick("greedy", 0)        # what the extra candidates add on the rows the search used
    gain_assess = pick("greedy_start", 1) - pick("greedy", 1)     # what they add on assessment rows
    return clean({
        "library_size": library_size, "candidates_kept": float(np.mean([r["kept"] for r in rows])),
        "development": {k: float(pick(k, 0).mean()) for k in ("best", "equal", "greedy", "greedy_start")},
        "assessment": {k: float(pick(k, 1).mean()) for k in ("best", "equal", "greedy", "greedy_start")},
        "added_gain_development": float(gain_dev.mean()), "added_gain_assessment": float(gain_assess.mean()),
        "per_replicate": {"development": gain_dev, "assessment": gain_assess},
        "replicates": REPLICATES, "development_rows": N_DEV, "assessment_rows": N_ASSESS,
    })

Measure it across the control

The control is candidates in the library. Each value below runs the whole experiment from the generator up.

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

VALUES = [4, 12, 40, 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())
                                 4      12      40     100
Candidates                       4      12      40     100
Hill-climbing, development   0.203   0.201   0.198   0.196
Hill-climbing, assessment    0.207   0.207   0.206   0.205
Added gain, development     0.0000  0.0022  0.0052  0.0069
Added gain, assessment      0.0000  0.0003  0.0012  0.0019

Look at it

The figure shows the default setting, candidates in the library = 100.

Calculation code
import matplotlib.pyplot as plt
from kaggle_companion.activities.ch17 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()
Saved figure from this chapter calculation

What the result says

With 100 candidates the search kept 4.4 on average. Its development Brier loss is 0.196; on assessment rows it is 0.205, so 0.205 - 0.196 = 0.009 of optimism (the equal average, which chooses nothing on the development rows, differs by 0.001). The 96 extra candidates lowered the development loss by 0.0069 and the assessment loss by 0.0019, 28% of it. The best single candidate scores 0.215 on assessment rows and the equal average 0.219.

Apply this to your competition

Book location: Chapter 17, Hill-Climbing Ensemble.

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.

Treat training-loss-optimized blend weights as a separate overfitting risk

Conditions

  • blend weights are tuned from in-sample fit statistics

Action

  • Proposed adaptation of source observation: Compare manually constrained or held-out blend weights with unconstrained training-loss optimization.

Check

  • Proposed verification: Evaluate weights on distinct forecast origins and measure whether R-squared predicts out-of-sample reliability.

Limits

  • The sentence about lower leaderboard MASE is internally ambiguous; only the discarded optimizer and possible overfitting are clear.

Find source links and value grades in the lesson atlas

Keep stack fitting separate from stack evaluation

Conditions

  • a meta-classifier fits base predictions on a held-out set

Action

  • Proposed: evaluate the fitted stack on independent rows or nested folds.

Check

  • Proposed: create independent meta-validation or nested OOF predictions; separate base checkpoint selection, weight search and final assessment.

Limits

  • Independent evaluation is proposed correction. Base models also selected checkpoints on the same valid_df, and weighted-average trials optimize this same holdout.

Find source links and value grades in the lesson atlas

Align folds across backbone families before soft voting

Conditions

  • language-specialist models share a classification task

Action

  • Proposed conditional application: Freeze the same fold assignment for every backbone so OOF matrices are aligned before ensemble comparisons.

Check

  • Proposed check: Save fold IDs and assert row/class order and held-out provenance match across OOF matrices.

Limits

  • Aligned folds alone do not guarantee leakage-free preprocessing or meta-validation.

Find source links and value grades in the lesson atlas

Stack per-class astronomical probabilities using OOF inputs

Conditions

  • multiclass astronomical objects have compact photometric predictors

Action

  • Proposed adaptation of source observation: Generate class-specific OOF probabilities and compare a second-stage classifier against simple averaging.

Check

  • Proposed verification: Audit fold provenance and jointly normalize or calibrate class outputs as a proposed check.

Limits

  • The packet does not document split design or final class decision rule.

Find source links and value grades in the lesson atlas