Module · Decision Trees
Overfitting — perfect on what it has seen
Lesson 6 of 8 · 15 min
Growing it, and knowing when to stop grew a tree that got 23 of the 24 films right. But it was scored on the same 24 films it was built from.
That score cannot tell you much. A tree can store an answer for every row it was given. It will then be right about all of them and know nothing about anybody else.
There is a simple test. Keep some of the data back, build the tree without it, then ask the tree about it.
1,500 employees, and 202 of them left
Twenty-four films are too few for this test. Keep five back and the second score is mostly luck. So this lesson uses real data instead.
| People in the file | 1500 |
| Things known about each one | 22 |
| People who stayed | 1298 (86.5%) |
| People who left | 202 (13.5%) |
People in the file
- 1500
Things known about each one
- 22
People who stayed
- 1298 (86.5%)
People who left
- 202 (13.5%)
The tree learns from 975 of these people. It is tested on the other 525, which it never sees. That second group is called the held-out set. 71 of them left.
The same file the notebook reads.
One column is left out: the employee ID. It is a number the company assigned. It says nothing about the person. The column that gives the game away puts it back and shows what happens.
Guessing “stays” every time is 86.5% right
Before building anything, work out what you get for free.
Say this person stays about everybody. Look at no columns. Speak to nobody.
| Strategy | How often it is right | Leavers it finds |
|---|---|---|
| Say “stays” about everyone | 86.5% | 0 of 71 |
Say “stays” about everyone
- How often it is right
- 86.5%
- Leavers it finds
- 0 of 71
A model has to beat 86.5% before it has earned anything. That number is called the baseline. It is what you score by always guessing the most common answer.
How many questions is the answer worth? gave the reason. When one answer covers most of the data, there is very little to tell anybody. So there is very little for a model to earn.
Deeper trees get worse on people they have not seen
Depth is how many questions a tree may ask before it has to decide. A depth of 3 means at most three questions about any one person.
Build the tree at every depth from 1 to 20. Score each one twice. Once on the 975 people it learned from, once on the 525 it has never seen.
At depth 20 the tree is right about 100.0% of the people it learned from and 79.2% of the people it has not.
That tree has 129 leaves for 975 people. Many of its leaves hold one or two people each. It has stored answers for individuals rather than found a pattern.
The gap between the two scores is the memorising
Subtract the second score from the first. The difference is the part of the score that only exists on the training people.
At depth 20 the gap is 20.8%. That much of the score is not real. When a model does much better on its training rows than on new data, it has overfitted.
Look at where the held-out line ends up: 79.2%, below the baseline of 86.5%. A tree deep enough to be perfect on its training people does worse on new people than guessing “stays” about all of them.
A shallow tree scores 86.5% and flags nobody
So use a shallow tree instead. Of the twenty depths, 1 beats the baseline, and it beats it by two thousandths. Here is the tree two questions deep.
All four leaves say “stays”. This tree scores 86.5% and flags 0 people for a conversation.
Its first question is a good one. Works overtime? separates a group where nearly a quarter left from a group where fewer than one in ten did. That is a real finding, and a manager could act on it.
One leaf holds 107 people. 43 of them left. It says “stays”.
Look at the leaf on the far left of that tree.
40% of the people in that leaf walked out. The tree calls them stayers.
The tree is working correctly. Stayers still outnumber leavers in that leaf. Calling it stays gives the highest accuracy. Accuracy is the only thing we asked it for.
Count the leavers a tree finds, not the people it calls right
Score the same twenty trees a different way. Ask how many of the 71 leavers each one actually flags.
The two most accurate trees on this page find nobody at all.
Accuracy puts these three models in the wrong order
| Score on new people | People flagged | Leavers found | |
|---|---|---|---|
| Say “stays” about everyone | 86.5% | 0 | 0 of 71 |
| The two-question tree | 86.5% | 0 | 0 of 71 |
| The tree at depth 4 | 85.5% | 41 | 18 of 71 |
Say “stays” about everyone
- Score on new people
- 86.5%
- People flagged
- 0
- Leavers found
- 0 of 71
The two-question tree
- Score on new people
- 86.5%
- People flagged
- 0
- Leavers found
- 0 of 71
The tree at depth 4
- Score on new people
- 85.5%
- People flagged
- 41
- Leavers found
- 18 of 71
The tree with the worst score is the only one that finds anybody. It flags 41 people, and 18 of them left. You cannot act on a score. You act on a list of names.
Two rules to take away
- Test on data the model has not seen. Every score in this Academy from here on is measured that way. Without a held-out set you cannot tell a model that has learned from one that has memorised.
- A high score is not a good model. Accuracy put these three models in the wrong order, on real data, in a case a real company faces. Always ask what the model would score by guessing.
Choosing what to measure instead is a subject of its own. Not everything is a learning problem made a smaller version of the same point.
One column was left out of this lesson. The next lesson puts the employee ID back in and builds the same tree.
content/notebooks/decision-trees/perfect-on-what-it-has-seen.ipynb
Change the seed on the split and run it again. The two lines keep their shape and the best depth moves around, which is why you should not pick a depth from one split. Also worth trying: build the tree on only 200 people and watch which of the two lines moves further.
Show the code7 cells
from pathlib import Path
import pandas as pd
# Local checkout first, the published copy otherwise — so this cell works
# unchanged in Colab, where there is no repo.
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)
record("n_people", len(df))
record("n_columns", df.shape[1] - 2) # not the ID, not the answer
record("n_left", int((df.Attrition == "Yes").sum()))
record("share_left", round(float((df.Attrition == "Yes").mean()), 3))
record("share_left_pct", f"{(df.Attrition == 'Yes').mean():.1%}")
record("n_stayed", int((df.Attrition == "No").sum()))
print(f"{len(df)} people, {df.shape[1] - 2} things known about each, "
f"{(df.Attrition == 'Yes').sum()} of them left")
df.head()y = (df.Attrition == "Yes").astype(int)
X = pd.get_dummies(df.drop(columns=["Attrition", "EmployeeID"]), drop_first=True)
baseline = round(float(1 - y.mean()), 3)
record("baseline", baseline)
# Percentages for the prose. A share of 1.0 interpolates into a sentence as
# "1", which reads as one person rather than all of them.
record("baseline_pct", f"{baseline:.1%}")
print(f"say 'stays' to all {len(y)} people -> right {baseline:.1%} of the time")
print(f"leavers found by that strategy -> 0 of {int(y.sum())}")from sklearn.model_selection import train_test_split
Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.35, random_state=7, stratify=y)
record("n_train", len(Xtr))
record("n_test", len(Xte))
record("n_test_leavers", int(yte.sum()))
print(f"grown on {len(Xtr)} people, tested on {len(Xte)} it has never seen "
f"({int(yte.sum())} of whom left)")from sklearn.tree import DecisionTreeClassifier
DEPTHS = list(range(1, 21))
rows = []
for d in DEPTHS:
t = DecisionTreeClassifier(max_depth=d, criterion="gini", random_state=7).fit(Xtr, ytr)
pred = t.predict(Xte)
rows.append({
"depth": d,
"train": round(float(t.score(Xtr, ytr)), 3),
"test": round(float(t.score(Xte, yte)), 3),
"leaves": int(t.get_n_leaves()),
"flagged": int((pred == 1).sum()),
"caught": int(((pred == 1) & (yte == 1)).sum()),
})
print(f"{'depth':>5} {'leaves':>7} {'on what it saw':>16} {'on what it did not':>20}")
for r in rows:
print(f"{r['depth']:>5} {r['leaves']:>7} {r['train']:>16.3f} {r['test']:>20.3f}")
record("sweep", rows)
deepest = rows[-1]
record("deep_train", deepest["train"])
record("deep_test", deepest["test"])
record("deep_leaves", deepest["leaves"])
best = max(rows, key=lambda r: r["test"])
record("best_depth", best["depth"])
record("best_test", best["test"])
record("gap_at_deepest", round(deepest["train"] - deepest["test"], 3))
record("deep_train_pct", f"{deepest['train']:.1%}")
record("deep_test_pct", f"{deepest['test']:.1%}")
record("gap_pct", f"{deepest['train'] - deepest['test']:.1%}")
record("best_test_pct", f"{best['test']:.1%}")
def plot(ax):
ax.plot([r["depth"] for r in rows], [r["train"] for r in rows],
marker="o", ms=4, color="#9aa0aa", label="on the people it learned from")
ax.plot([r["depth"] for r in rows], [r["test"] for r in rows],
marker="o", ms=4, color="#e2574c", label="on the people it has never met")
ax.axhline(baseline, ls="--", lw=1.2, color="#3b6fd4")
ax.text(20, baseline + 0.006, 'saying "stays" to everybody', ha="right", fontsize=9, color="#3b6fd4")
ax.set_xlabel("How many questions deep the tree may go")
ax.set_ylabel("Share called right")
ax.set_xticks([1, 5, 10, 15, 20])
ax.set_ylim(0.75, 1.02)
ax.legend(frameon=False, fontsize=9, loc="center right")
save_fig("learned-and-memorised", plot, figsize=(7, 4.0))def plot2(ax):
gaps = [r["train"] - r["test"] for r in rows]
ax.bar([r["depth"] for r in rows], gaps, color="#e2574c", width=0.62)
ax.set_xlabel("How many questions deep the tree may go")
ax.set_ylabel("Score that exists only\non the training set")
ax.set_xticks([1, 5, 10, 15, 20])
ax.grid(axis="x", visible=False)
save_fig("the-gap", plot2, figsize=(7, 3.2))
below = [r for r in rows if r["test"] < baseline]
record("first_depth_below_baseline", below[0]["depth"] if below else None)
record("n_depths_beating_baseline", sum(1 for r in rows if r["test"] > baseline))
print(f"depths that beat 'stays for everybody': {sum(1 for r in rows if r['test'] > baseline)} of {len(rows)}")
print(f"best of them: depth {best['depth']} at {best['test']:.3f}, against a free {baseline:.3f}")import numpy as np
LABEL = {
"MonthlyIncome": "Monthly income", "Age": "Age", "DistanceFromHome": "Distance from home",
"YearsAtCompany": "Years at company", "TotalWorkingYears": "Total working years",
"YearsSinceLastPromotion": "Years since promotion", "YearsInCurrentRole": "Years in role",
"YearsWithCurrManager": "Years with manager", "StockOptionLevel": "Stock option level",
"JobLevel": "Job level", "EnvironmentSatisfaction": "Environment score",
"JobSatisfaction": "Job satisfaction", "WorkLifeBalance": "Work-life balance",
"TrainingTimesLastYear": "Trainings last year", "NumCompaniesWorked": "Employers before this",
"PercentSalaryHike": "Last salary hike %", "RelationshipSatisfaction": "Relationship score",
"Education": "Education level", "PerformanceRating": "Performance rating",
"OverTime_Yes": "Works overtime?", "Gender_Male": "Male?",
"Department_R&D": "In R and D?", "Department_Sales": "In Sales?",
}
def to_nodes(clf, names, node=0):
"""sklearn's arrays -> the {q, yes, no, note} shape the lesson draws.
sklearn always sends `feature <= threshold` LEFT. For a yes/no column that
means left is the NO side, so the branches are swapped and the question is
asked the way a person would ask it. Getting this backwards draws a tree
that is a mirror image of the one that was fitted, and nothing about the
picture would look wrong.
"""
t = clf.tree_
stayed, left_co = t.value[node][0]
n = int(t.n_node_samples[node])
share = left_co / (stayed + left_co)
note = f"{n} people - {round(share * n)} left"
if t.children_left[node] == -1:
return {"leaf": "leaves" if share >= 0.5 else "stays", "note": note}
col = names[t.feature[node]]
thr = t.threshold[node]
lo = to_nodes(clf, names, t.children_left[node])
hi = to_nodes(clf, names, t.children_right[node])
if col.endswith("_Yes") or col.startswith(("Gender_", "Department_")):
return {"q": LABEL.get(col, col), "note": note, "yes": hi, "no": lo}
# A threshold of 0.5 on a whole-number column reads as "under 0" if it is
# simply rounded, which is not what the tree does. Round UP: "under 1"
# means zero, which is the split that was actually made.
whole = bool(X[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}
shallow = DecisionTreeClassifier(max_depth=2, criterion="gini", random_state=7).fit(Xtr, ytr)
record("shallow_tree", to_nodes(shallow, list(X.columns)))
record("shallow_test", round(float(shallow.score(Xte, yte)), 3))
record("shallow_test_pct", f"{shallow.score(Xte, yte):.1%}")
record("shallow_flagged", int((shallow.predict(Xte) == 1).sum()))
# The leaf a manager should be most alarmed by, and the tree calls it "stays".
def worst_leaf(node):
if "leaf" in node:
n, left = int(node["note"].split()[0]), int(node["note"].split("- ")[1].split()[0])
return (left / n, n, left, node["leaf"])
return max(worst_leaf(node["yes"]), worst_leaf(node["no"]))
share, n_leaf, n_left_leaf, call = worst_leaf(record("shallow_tree", to_nodes(shallow, list(X.columns))))
record("worst_leaf_people", n_leaf)
record("worst_leaf_left", n_left_leaf)
record("worst_leaf_share", round(share, 2))
record("worst_leaf_pct", f"{share:.0%}")
record("worst_leaf_call", call)
print(f"depth 2 scores {shallow.score(Xte, yte):.3f} against a free {baseline:.3f}")
print(f"and flags {int((shallow.predict(Xte) == 1).sum())} people out of {len(yte)} for a conversation")print(f"{'depth':>5} {'test score':>11} {'people flagged':>15} {'leavers caught':>15}")
for r in rows[:8]:
print(f"{r['depth']:>5} {r['test']:>11.3f} {r['flagged']:>15} {r['caught']:>10} of {int(yte.sum())}")
useful = max(rows, key=lambda r: r["caught"])
record("useful_depth", useful["depth"])
record("useful_test", useful["test"])
record("useful_test_pct", f"{useful['test']:.1%}")
record("useful_flagged", useful["flagged"])
record("useful_caught", useful["caught"])
def plot3(ax):
ax.bar([r["depth"] for r in rows], [r["caught"] for r in rows], color="#e2574c", width=0.62)
ax.axhline(int(yte.sum()), ls="--", lw=1.2, color="#3b6fd4")
ax.text(20, int(yte.sum()) + 1.2, f"all {int(yte.sum())} leavers in the test set",
ha="right", fontsize=9, color="#3b6fd4")
ax.set_xlabel("How many questions deep the tree may go")
ax.set_ylabel("Leavers it actually found")
ax.set_xticks([1, 5, 10, 15, 20])
ax.set_ylim(0, int(yte.sum()) * 1.18)
ax.grid(axis="x", visible=False)
save_fig("what-it-catches", plot3, figsize=(7, 3.2))
