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 27: SHAP as a Feature Engineering Signal

Try the decision first

When two columns carry the same information, does the one with low attribution deserve to be dropped, and what does dropping both cost?

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

When two columns carry the same information, does the one with low attribution deserve to be dropped, and what does dropping both cost?

Mean absolute SHAP is a common feature-selection screen, and copies of a signal are common in real tables. Measuring the attribution next to the removal tells you when a low value reflects redundancy rather than noise.

Constructed data; the sizes of these effects are properties of this generator, not a competition result.

The experiment

Eight constructed datasets of 1,200 training rows with six columns. Columns A and B are two noisy readings of one hidden signal and correlate at the control value (1.0 is an identical copy); four more columns are pure noise. Only the signal drives the label. A histogram gradient boosting classifier is fitted, and exact Shapley values (by enumerating all 64 column subsets against 30 background rows) give mean absolute attribution on 100 validation rows. The copy ranked lower is then removed, then both copies, with a refit and the change in log loss measured on 6,000 fresh rows. Dropping one noise column is the control.

Calculation code
import itertools
import math

import numpy as np
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.metrics import log_loss

from kaggle_companion.activities._common import clean

SEED = 27
REPLICATES = 8             # independent constructed datasets; every estimate is their mean
P = 6                      # columns: copy A, copy B and four pure-noise columns
N_TRAIN, N_VALID, N_FRESH = 1200, 1200, 6000
EXPLAIN_ROWS, BACKGROUND_ROWS = 100, 30


def generate(n, copy_corr, rng):
    """Columns 0 and 1 are two noisy readings of one hidden signal z; they correlate at `copy_corr` (1 = identical).
    Only z drives the label, so either reading alone carries what the model needs."""
    z = rng.normal(size=n)
    s = math.sqrt(1.0 / copy_corr - 1.0)                       # reading noise that gives the requested correlation
    scale = math.sqrt(1.0 + s * s)                             # keep each reading at unit variance
    readings = [(z + s * rng.normal(size=n)) / scale for _ in range(2)]
    X = np.column_stack(readings + [rng.normal(size=n) for _ in range(P - 2)])
    y = (rng.random(n) < 1 / (1 + np.exp(-1.8 * z))).astype(int)
    return X, y


def fit(X, y, seed):
    return HistGradientBoostingClassifier(max_iter=60, max_leaf_nodes=8, min_samples_leaf=20,
                                          random_state=seed).fit(X, y)


SUBSETS = [S for k in range(P + 1) for S in itertools.combinations(range(P), k)]


def mean_abs_shap(model, rows, background):
    """Exact interventional Shapley values for P columns, in predicted-probability units.
    v(S) = mean prediction when columns in S come from the explained row and the others from background rows."""
    n_rows, n_bg = len(rows), len(background)
    value = {}
    for S in SUBSETS:
        hybrid = np.tile(background, (n_rows, 1))
        if S:
            hybrid[:, list(S)] = np.repeat(rows, n_bg, axis=0)[:, list(S)]
        value[S] = model.predict_proba(hybrid)[:, 1].reshape(n_rows, n_bg).mean(axis=1)
    phi = np.zeros((n_rows, P))
    for j in range(P):
        for S in SUBSETS:
            if j not in S:
                weight = math.factorial(len(S)) * math.factorial(P - len(S) - 1) / math.factorial(P)
                phi[:, j] += weight * (value[tuple(sorted(S + (j,)))] - value[S])
    return np.abs(phi).mean(axis=0)


def one_replicate(copy_corr, seed):
    rng = np.random.default_rng(seed)
    X, y = generate(N_TRAIN, copy_corr, rng)
    Xv, yv = generate(N_VALID, copy_corr, rng)
    Xf, yf = generate(N_FRESH, copy_corr, rng)                  # fresh rows from the same generator: the "truth"
    model = fit(X, y, seed)
    attribution = mean_abs_shap(model, Xv[:EXPLAIN_ROWS], X[:BACKGROUND_ROWS])
    low = int(np.argmin(attribution[:2]))                       # the copy the attribution ranks lower
    high = 1 - low

    def loss(cols):                                             # refit on the kept columns, score on both row sets
        m = fit(X[:, cols], y, seed)
        return (log_loss(yv, m.predict_proba(Xv[:, cols])[:, 1]), log_loss(yf, m.predict_proba(Xf[:, cols])[:, 1]))

    keep_all = list(range(P))
    full = loss(keep_all)
    removals = {"drop_noise": [c for c in keep_all if c != 2],  # control: one column that carries nothing
                "drop_low_copy": [c for c in keep_all if c != low],
                "drop_both": [c for c in keep_all if c > 1]}
    out = {name: [a - b for a, b in zip(loss(cols), full)] for name, cols in removals.items()}   # [validation, fresh]
    return {"attr_low": attribution[low], "attr_high": attribution[high], "attr_noise": float(attribution[2:].mean()),
            "low_is_column_a": low == 0, "full_fresh_loss": full[1], **out}


def run(copy_corr):
    reps = [one_replicate(copy_corr, SEED * 100 + i) for i in range(REPLICATES)]
    mean = lambda f: float(np.mean([f(x) for x in reps]))
    return clean({
        "copy_corr": copy_corr,
        "attr_low": mean(lambda x: x["attr_low"]), "attr_high": mean(lambda x: x["attr_high"]),
        "attr_noise": mean(lambda x: x["attr_noise"]),
        "low_is_column_a": int(sum(x["low_is_column_a"] for x in reps)),
        "full_fresh_loss": mean(lambda x: x["full_fresh_loss"]),
        **{f"{k}_fresh": mean(lambda x, k=k: x[k][1]) for k in ("drop_noise", "drop_low_copy", "drop_both")},
        **{f"{k}_valid": mean(lambda x, k=k: x[k][0]) for k in ("drop_noise", "drop_low_copy", "drop_both")},
        "per_replicate": {"attr_low": [x["attr_low"] for x in reps], "attr_high": [x["attr_high"] for x in reps],
                          **{f"{k}_fresh": [x[k][1] for x in reps] for k in ("drop_noise", "drop_low_copy", "drop_both")}},
        "replicates": REPLICATES, "train_rows": N_TRAIN, "fresh_rows": N_FRESH,
    })

Measure it across the control

The control is correlation between the two copies (1.0 = identical columns). Each value below runs the whole experiment from the generator up.

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

VALUES = [0.5, 0.9, 0.99, 1.0]
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.5     0.9    0.99     1.0
Higher copy |attribution|    0.128   0.159   0.170   0.265
Lower copy |attribution|     0.112   0.106   0.102   0.000
Noise column |attribution|   0.022   0.020   0.021   0.020
Drop low copy               +0.037  +0.012  +0.001   0.000
Drop both copies            +0.127  +0.193  +0.204  +0.206
Drop a noise column         -0.001  -0.001  -0.001   0.000

Look at it

The figure shows the default setting, correlation between the two copies (1.0 = identical columns) = 1.0.

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

DEFAULT = 1.0
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

At copy correlation 1.0: the higher-ranked copy has mean |attribution| 0.265 and the lower-ranked copy 0.000, against 0.020 for a noise column. The low copy ranks below a pure-noise column, so a screen would call it noise. Removing the low copy changes fresh-row log loss by 0.000; removing both copies changes it by +0.206, and 0.206 - 0.000 = 0.206 is the extra cost of dropping the pair. The lower-ranked copy was column A in 0 of 8 datasets.

Apply this to your competition

Book location: Chapter 27, A Removal Contrast.

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.

Inspect instance and global explanations for clinical plausibility

Conditions

  • a clinical classifier needs interpretable behavior

Action

  • Proposed conditional application: Compute SHAP explanations at both instance and global levels to inspect feature contributions.

Check

  • Proposed check: Inspect explanations with domain experts and correlate suspicious contributions with data audits; test sensitivity to background choice and correlated features.

Limits

  • SHAP explains model outputs rather than causal effects; clinical plausibility is not a substitute for outcome validation.

Find source links and value grades in the lesson atlas