Skip to content
Expedify
14 min

Data leakage — the column that gives the game away

Put the employee number back in and the tree uses it. Then add a column that already knows the answer, and watch every safeguard from the last lesson report a success.

Perfect on what it has seen left out one column, the employee ID, and said the reason was worth waiting for.

Put it back. Nothing else changes: same people, same split, same tree.

Two things come out of it. The tree uses the ID. And there is a kind of broken model that a held-out set cannot catch at all.

The tree spends 5.3% of its decisions on the employee number

The same tree as the last lesson, with one extra column available.

3 questions deep

Share of its decisions resting on the employee number
5.3%

10 questions deep

Share of its decisions resting on the employee number
3.9%

Nobody made a mistake. Nobody wrote a bad line of code. A twentieth of this model's reasoning is a filing number, and it got there by default, from a file that had the column.

A column with more values gets more chances to look good

A yes-or-no column offers a tree one way to cut it. A column of 975 different numbers offers 974 ways.

The best of 974 lucky cuts looks better than the best of one, even when every one of them is meaningless.

Columns that mean nothing, scored. Thirty draws at each width.decision-trees/the-column-that-gives-it-away.ipynb

Pure noise with two values scores 0.0002. The same noise with 975 values scores 0.0013. Six times as much, from having more thresholds to try.

A real column can score what pure noise scores

Two real columns and two amounts of luck, on the same scale.

the real OverTime column

Best gain a single question can get
0.011

the real StockOptionLevel column

Best gain a single question can get
0.0023

meaningless noise with 50 values

Best gain a single question can get
0.0011

the real EmployeeID column

Best gain a single question can get
0.0007

A tree cannot tell these apart. It does not know which columns are facts about a person and which are filing conventions. It spends its splits on either. Everything it knows, it knows from the numbers, which is what From writing rules to learning them promised.

A column that already knows the answer takes the tree to 96%

An employee number is a nuisance. A column that already knows the answer is a disaster, and it is far more common.

This file does not contain one, so the notebook adds one on purpose, in a cell you can watch it build. It invents exit interview booked: a column any real HR system holds, true for almost everyone who left and almost nobody who stayed.

One column added. Nothing else changed.

without the invented column

On what it saw
88.4%
On strangers
85.7%
Leavers found
18 of 71

with it

On what it saw
96.0%
On strangers
96.0%
Leavers found
56 of 71

It is the best model in this whole path and it is worth nothing. You only know that column after the person has already resigned.

A leaking column leaves no gap between the two scores

Perfect on what it has seen gave you a test for a broken model: the gap. High on what it learned from, low on what it has never seen.

The gap that catches overfitting, and the leak it does not catch.decision-trees/the-column-that-gives-it-away.ipynb

The leaky model has a gap of 0.0%. It is equally good on both sides, and better than anything honest. Every safeguard from the last lesson is in place and reporting success. A held-out set cannot catch this.

One column holds up 88.5% of the tree

So read the model. Ask which columns it is standing on.

The leaky tree, asked what it is standing on.

ExitInterviewBooked

Share of the tree's decisions
88.5%

YearsAtCompany

Share of the tree's decisions
3.8%

OverTime_Yes

Share of the tree's decisions
2.8%

JobSatisfaction

Share of the tree's decisions
1.2%

TotalWorkingYears

Share of the tree's decisions
1.0%

One column is holding up 88.5% of it. And it is in the first box of the tree.

The first question is “Exit interview booked?”

The leaky tree, two questions deep
yesyesnonoyesnoExit interviewbooked?975 people - 131 leftWorks overtime?152 people - 119 leftleaves76 people - 69 leftleaves76 people - 50 leftDistance from homeunder 24?823 people - 12 leftstays784 people - 9 leftstays39 people - 3 left
The first box is the whole story.

Say that question out loud to anybody who works in HR. They will answer in one sentence: we only book an exit interview after somebody has resigned.

This is why the path started with a tree

That sentence is not in the data. No amount of cross-validation can find it. It is available to a person who can read the model.

Every lesson so far has been able to show its working: which question, on which pile, and what each leaf holds. That is the only defence against a model that is right for a reason nobody would accept.

One lesson left. The honest tree flags 18 real leavers out of 71. So who do you go and talk to, and what do you say?

The ID, the cardinality bias, and a leak built in front of you

content/notebooks/decision-trees/the-column-that-gives-it-away.ipynb

Weaken the invented column until it stops being obvious in the importances, then check whether it is still lifting the score. Worth trying: leak something subtler, such as a flag for anyone whose years-at-company is zero. Is that a leak? The answer is not in the data.

Show the code7 cells
The same people and the same split as the last lesson — with the ID left in this time
from pathlib import Path
import numpy as np
import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.tree import DecisionTreeClassifier

REL = 'public/datasets/decision-trees/hr-employee-attrition.csv'
here = Path.cwd()
LOCAL = next((p / REL for p in [here, *here.parents] if (p / REL).exists()), None)
CSV = LOCAL or 'https://raw.githubusercontent.com/Dr-Shashank-S-Sharma/expedify-ai-courses/main/datasets/decision-trees/hr-employee-attrition.csv'

df = pd.read_csv(CSV)
y = (df.Attrition == "Yes").astype(int)
WITHOUT_ID = pd.get_dummies(df.drop(columns=["Attrition", "EmployeeID"]), drop_first=True)
WITH_ID = pd.get_dummies(df.drop(columns=["Attrition"]), drop_first=True)

tr, te = train_test_split(range(len(df)), test_size=0.35, random_state=7, stratify=y)
ytr, yte = y.iloc[tr], y.iloc[te]

def fit(X, depth=4):
    return DecisionTreeClassifier(max_depth=depth, random_state=7).fit(X.iloc[tr], ytr)

def report(X, depth=4):
    t = fit(X, depth)
    pred = t.predict(X.iloc[te])
    return {
        "train": round(float(t.score(X.iloc[tr], ytr)), 3),
        "test": round(float(t.score(X.iloc[te], yte)), 3),
        "caught": int(((pred == 1) & (yte == 1)).sum()),
        "importances": dict(sorted(zip(X.columns, t.feature_importances_), key=lambda r: -r[1])),
    }

print(f"{len(tr)} people to learn from, {len(te)} held back, {int(yte.sum())} of whom left")
How much of the tree's decision making rests on the ID column
for d in (3, 4, 6, 10):
    imp = report(WITH_ID, d)["importances"].get("EmployeeID", 0.0)
    print(f"depth {d:>2}   share of the tree's decisions resting on EmployeeID: {imp:.1%}")

record("id_importance_3", f"{report(WITH_ID, 3)['importances'].get('EmployeeID', 0):.1%}")
record("id_importance_10", f"{report(WITH_ID, 10)['importances'].get('EmployeeID', 0):.1%}")
Pure noise, scored — the more distinct values it has, the better it looks
def gini(v):
    if len(v) == 0:
        return 0.0
    p = v.mean()
    return 1 - p * p - (1 - p) ** 2

def best_gain(col, target):
    """The best a single question about this column could possibly do."""
    order = np.argsort(col, kind="stable")
    c, t = np.asarray(col)[order], np.asarray(target)[order]
    n, g0, best = len(t), gini(np.asarray(target)), 0.0
    for i in range(1, n):
        if c[i] == c[i - 1]:
            continue
        best = max(best, g0 - (i * gini(t[:i]) + (n - i) * gini(t[i:])) / n)
    return best

rng = np.random.default_rng(11)
noise = []
for k in (2, 4, 10, 50, 200, 975):
    gains = [best_gain(rng.integers(0, k, len(tr)), ytr.values) for _ in range(30)]
    noise.append({"distinct": k, "mean": round(float(np.mean(gains)), 4)})
    print(f"a meaningless column with {k:>4} distinct values   best gain {np.mean(gains):.4f}")

record("noise_by_cardinality", noise)
record("noise_2", noise[0]["mean"])
record("noise_975", noise[-1]["mean"])

real = {
    "OverTime": best_gain((df.OverTime == "Yes").astype(int).iloc[tr].values, ytr.values),
    "StockOptionLevel": best_gain(df.StockOptionLevel.iloc[tr].values, ytr.values),
    "EmployeeID": best_gain(df.EmployeeID.iloc[tr].values, ytr.values),
}
for k, v in real.items():
    print(f"\nthe real {k:<18} best gain {v:.4f}")
record("gain_overtime", round(real["OverTime"], 4))
record("gain_stock", round(real["StockOptionLevel"], 4))
record("gain_id", round(real["EmployeeID"], 4))

def plot(ax):
    ax.plot([n["distinct"] for n in noise], [n["mean"] for n in noise],
            marker="o", ms=5, color="#9aa0aa", label="a column that is pure noise")
    ax.set_xscale("log")
    for label, v, c in [("the real OverTime column", real["OverTime"], "#e2574c"),
                        ("the real StockOptionLevel column", real["StockOptionLevel"], "#3b6fd4"),
                        ("the real EmployeeID column", real["EmployeeID"], "#c2871a")]:
        ax.axhline(v, ls="--", lw=1.1, color=c)
        ax.text(975, v + 0.0004, label, ha="right", fontsize=8.5, color=c)
    ax.set_xlabel("Distinct values the column has")
    ax.set_ylabel("Best gain a single question can get")
    ax.set_ylim(0, real["OverTime"] * 1.35)
    ax.legend(frameon=False, fontsize=9, loc="center left")

save_fig("lucky-columns", plot, figsize=(7, 4.0))
Real columns and noise on the same scale
record("noise_50", [n["mean"] for n in noise if n["distinct"] == 50][0])
print(f"real StockOptionLevel      {real['StockOptionLevel']:.4f}")
print(f"noise with 50 values       {[n['mean'] for n in noise if n['distinct'] == 50][0]:.4f}")
print(f"real EmployeeID            {real['EmployeeID']:.4f}")
print(f"noise with 4 values        {[n['mean'] for n in noise if n['distinct'] == 4][0]:.4f}")
An invented column that already knows the answer — added deliberately
# CONSTRUCTED, not in the file. True for 90% of leavers and 4% of stayers,
# which is roughly how an exit-interview flag behaves in a real HR system.
rng2 = np.random.default_rng(5)
leak = np.where(y == 1, rng2.random(len(y)) < 0.90, rng2.random(len(y)) < 0.04).astype(int)

WITH_LEAK = WITH_ID.copy()
WITH_LEAK["ExitInterviewBooked"] = leak

clean, leaky = report(WITH_ID), report(WITH_LEAK)
print(f"{'':34} {'on what it saw':>15} {'on strangers':>14} {'leavers found':>15}")
for label, r in (("without the invented column", clean), ("with it", leaky)):
    print(f"{label:<34} {r['train']:>15.3f} {r['test']:>14.3f} {r['caught']:>10} of {int(yte.sum())}")

record("clean_train", f"{clean['train']:.1%}")
record("clean_test", f"{clean['test']:.1%}")
record("clean_caught", clean["caught"])
record("clean_gap", f"{clean['train'] - clean['test']:.1%}")
record("leaky_train", f"{leaky['train']:.1%}")
record("leaky_test", f"{leaky['test']:.1%}")
record("leaky_caught", leaky["caught"])
record("leaky_gap", f"{abs(leaky['train'] - leaky['test']):.1%}")
record("leak_importance", f"{leaky['importances']['ExitInterviewBooked']:.1%}")
record("n_test_leavers", int(yte.sum()))
The gap that catches overfitting, and the leak it does not catch
def plot2(ax):
    labels = ["honest tree\n(no invented column)", "leaky tree\n(with it)"]
    trains = [clean["train"], leaky["train"]]
    tests = [clean["test"], leaky["test"]]
    xs = range(2)
    ax.bar([x - 0.19 for x in xs], trains, width=0.36, color="#9aa0aa", label="on what it saw")
    ax.bar([x + 0.19 for x in xs], tests, width=0.36, color="#e2574c", label="on strangers")
    for x, (a, b) in enumerate(zip(trains, tests)):
        ax.text(x - 0.19, a + 0.006, f"{a:.3f}", ha="center", fontsize=9)
        ax.text(x + 0.19, b + 0.006, f"{b:.3f}", ha="center", fontsize=9)
        ax.text(x, 0.60, f"gap {abs(a - b):.3f}", ha="center", fontsize=9.5, color="#77777f")
    ax.set_xticks(list(xs))
    ax.set_xticklabels(labels, fontsize=9)
    ax.set_ylim(0.55, 1.02)
    ax.set_ylabel("Share called right")
    ax.legend(frameon=False, fontsize=9, loc="upper left")
    ax.grid(axis="x", visible=False)

save_fig("the-leak-has-no-tell", plot2, figsize=(7, 3.8))
Which columns the leaky tree is leaning on
top = [(c, v) for c, v in leaky["importances"].items() if v > 0][:5]
for c, v in top:
    print(f"{c:<24} {v:>6.1%}  {'#' * round(v * 50)}")

record("leak_top_columns", [{"column": c, "share": f"{v:.1%}"} for c, v in top])

LABEL = {"ExitInterviewBooked": "Exit interview booked?", "OverTime_Yes": "Works overtime?",
         "JobSatisfaction": "Job satisfaction", "YearsAtCompany": "Years at company",
         "MonthlyIncome": "Monthly income", "Age": "Age", "EmployeeID": "Employee number",
         "StockOptionLevel": "Stock option level", "TotalWorkingYears": "Total working years",
         "DistanceFromHome": "Distance from home", "Education": "Education level"}

def to_nodes(clf, names, node=0):
    """sklearn's arrays -> the shape the lesson draws. sklearn sends
    `feature <= threshold` LEFT, so a yes/no column has its NO side on the
    left and the branches are swapped to ask the question the human way."""
    t = clf.tree_
    stayed, gone = t.value[node][0]
    n = int(t.n_node_samples[node])
    note = f"{n} people - {round(gone / (stayed + gone) * n)} left"
    if t.children_left[node] == -1:
        return {"leaf": "leaves" if gone >= stayed else "stays", "note": note}
    col, thr = names[t.feature[node]], t.threshold[node]
    lo = to_nodes(clf, names, t.children_left[node])
    hi = to_nodes(clf, names, t.children_right[node])
    if set(np.unique(WITH_LEAK[col])) <= {0, 1, True, False}:
        return {"q": LABEL.get(col, col), "note": note, "yes": hi, "no": lo}
    whole = bool(pd.Series(WITH_LEAK[col]).dropna().mod(1).eq(0).all())
    step = np.ceil(thr) if whole else thr
    return {"q": f"{LABEL.get(col, col)} under {step:,.0f}?", "note": note, "yes": lo, "no": hi}

record("leaky_tree", to_nodes(fit(WITH_LEAK, 2), list(WITH_LEAK.columns)))

Related lessons