Skip to content
Expedify
Decision Trees

Module · Decision Trees

Ranking and lift — turning a tree into a list of names

Lesson 8 of 8 · 14 min

Perfect on what it has seen left a puzzle. The tree at depth four scores below what you get for saying “stays” to everybody. It was still called the first model in this path worth having.

This lesson is why both of those are true.

Nothing new is fitted here. Same file, same split, same tree. The whole subject is what you do with a model you already have.

A leaf gives a risk, from 1% to 100%

Ask the tree to predict and it says “stays” or “leaves”. That is less than it knows.

Every leaf holds a mixture of people. The share of leavers in a leaf is a risk. Across the 15 leaves, those risks run from 1% to 100%.

The six riskiest leaves, scored on the 525 people the tree has never seen.

100%

People in it
Who actually left

100%

People in it
Who actually left

100%

People in it
Who actually left

100%

People in it
Who actually left

59%

People in it
Who actually left

46%

People in it
Who actually left

Calling everything under 50% “stays” throws all of that away. That convention is why the accurate tree in the last lesson flagged nobody. Nothing it knew crossed 50%.

Sort people by risk and start at the top

Once a leaf gives a risk, the useful move is different from what the library does by default. Sort everybody by risk and work down the list.

525 people the tree has never met, worked through in the order it ranks them.decision-trees/a-list-of-names.ipynb
A lift of 3.1x, and 30% of everyone who left.

taken off the top of the tree's list

Leavers reached
21

with fifty people picked at random

Leavers reached
6.8

Same model. Same data. Three times the leavers. And it came from talking to fewer than one person in ten.

The budget picks the number, not the model

The question has changed. You are no longer asking who will leave, which nobody can answer.

You are asking: if I can have fifty conversations this quarter, whose? The tree can answer that. The fifty is a business decision, and no model makes it for you.

Four leaves claim 100% and hold 8 people between them

4 of the leaves say 100%. Between them they hold 8 people, of whom 2 left.

That is lesson 6's overfitting, arriving in the list of names. A leaf built on three training rows will claim certainty, and sorting by risk puts it first.

The leaf worth acting on holds 33 people, 16 of whom left

That leaf is at 59% risk and it is big enough to trust. And it is more than a bucket of high-risk people. It is the end of a path of questions.

  • works overtime
  • job satisfaction under 3
  • age under 51
  • monthly income under 13,174

Those four lines are the reason, and the reason is what you say out loud. Somebody who works overtime, is unhappy in the job, is not near the end of their career, and is not among the best paid.

A score tells you who. A model you can read tells you why.

You do not open with our model flagged you. You open with you have been carrying a lot lately, how is that going?

That is what eight lessons of readable models were for. The why is what the conversation is made of.

The first question survives 30 refits. The list of names does not.

Build the tree again on 95% of the training rows, picked at random, thirty times over. Then compare what survives.

Thirty refits, each missing a random 5% of the training rows.decision-trees/a-list-of-names.ipynb

Most refits keep between two thirds and nine tenths of the original names. None of them keep all fifty.

Drop 5% of the training rows and rebuild.

the first question

works overtime, 30 of 30 times

of the top fifty names, how many stay

78% on average, 64% at worst

The finding is solid. The names are not. One tree is one sequence of choices, and each choice moves everything below it. You cannot tune this away. The fix is to stop relying on one tree, and that is a later path.

What you can now do

  • Build a tree by hand. Score each question by the impurity it removes, take the best, repeat on each side.
  • Read every decision it makes. Which question, on which pile, what each leaf holds, and which columns it stands on.
  • Say when it has memorised. Hold data back and watch the gap. A leak leaves no gap, so reading the model is not optional.
  • Turn it into something to act on. Rank rather than classify, let the budget decide, and carry the reason with you.

The last of those generalises furthest. Every model in this Academy produces a number, and none of them produce a decision. Turning one into the other is a step a person takes. Not everything is a learning problem is where this track first said so.

The leaves, the ranking, and how much the list moves

content/notebooks/decision-trees/a-list-of-names.ipynb

Price it. Say a conversation costs an hour and keeping somebody is worth twenty, then find the budget where the list stops paying. Worth trying: drop every leaf built on fewer than ten training rows and see whether the top of the list gets better or worse.

Show the code6 cells
The honest tree from lesson 6 — no employee number, nothing invented
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)
X = pd.get_dummies(df.drop(columns=["Attrition", "EmployeeID"]), 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].values

tree = DecisionTreeClassifier(max_depth=4, random_state=7).fit(X.iloc[tr], ytr)
risk = tree.predict_proba(X.iloc[te])[:, 1]
leaf = tree.apply(X.iloc[te])

record("n_test", len(te))
record("n_leavers", int(yte.sum()))
record("base_rate", f"{yte.mean():.1%}")
print(f"{len(te)} people the tree has never seen, {int(yte.sum())} of whom left "
      f"({yte.mean():.1%})")
What each leaf of the tree actually says about the people who land in it
rows = []
for lf in sorted(set(leaf), key=lambda l: -risk[leaf == l][0]):
    m = leaf == lf
    rows.append({"leaf": int(lf), "risk": round(float(risk[m][0]), 2),
                 "people": int(m.sum()), "left": int(yte[m].sum())})

print(f"{'the leaf says':>14} {'people in it':>13} {'who actually left':>19}")
for r in rows:
    print(f"{r['risk']:>13.0%} {r['people']:>13} {r['left']:>19}")

record("leaf_rows", rows)
big = max((r for r in rows if r["people"] >= 20), key=lambda r: r["risk"])
record("big_leaf_risk", f"{big['risk']:.0%}")
record("big_leaf_people", big["people"])
record("big_leaf_left", big["left"])
record("n_leaves_shown", len(rows))
Work down the ranked list and count how many leavers you reach
order = np.argsort(-risk, kind="stable")
found = np.cumsum(yte[order])

budgets = [10, 25, 50, 75, 100, 150, 200]
print(f"{'conversations':>14} {'leavers reached':>17} {'if chosen at random':>21} {'lift':>7}")
table = []
for k in budgets:
    at_random = k * float(yte.mean())
    table.append({"budget": k, "found": int(found[k - 1]),
                  "random": round(at_random, 1), "lift": round(found[k - 1] / at_random, 1)})
    print(f"{k:>14} {int(found[k-1]):>17} {at_random:>21.1f} {found[k-1]/at_random:>6.1f}x")

record("budget_rows", table)
FIFTY = next(r for r in table if r["budget"] == 50)
record("fifty_found", FIFTY["found"])
record("fifty_random", FIFTY["random"])
record("fifty_lift", f"{FIFTY['lift']}x")
record("share_of_leavers_at_fifty", f"{FIFTY['found'] / yte.sum():.0%}")

def plot(ax):
    n = len(order)
    ax.plot(range(1, n + 1), found, color="#e2574c", label="working down the tree's ranking")
    ax.plot([0, n], [0, yte.sum()], ls="--", lw=1.2, color="#9aa0aa", label="picking people at random")
    ax.plot([0, yte.sum(), n], [0, yte.sum(), yte.sum()], ls=":", lw=1.2, color="#3b6fd4",
            label="a perfect list")
    ax.axvline(50, lw=1, color="#c9c9d1")
    ax.annotate(f"50 conversations\n{int(found[49])} leavers reached", (50, found[49]),
                textcoords="offset points", xytext=(14, -6), fontsize=9, color="#77777f")
    ax.set_xlabel("Conversations you are willing to have")
    ax.set_ylabel("Leavers you reach")
    ax.legend(frameon=False, fontsize=9, loc="lower right")

save_fig("working-down-the-list", plot, figsize=(7, 4.0))
The certain little leaves, against the one worth acting on
tiny = [r for r in rows if r["risk"] >= 0.99]
print(f"leaves claiming certainty: {len(tiny)}, holding {sum(r['people'] for r in tiny)} people "
      f"between them, of whom {sum(r['left'] for r in tiny)} left")
print(f"the big leaf:              {big['people']} people, {big['left']} left "
      f"({big['left'] / big['people']:.0%})")

record("n_certain_leaves", len(tiny))
record("certain_people", sum(r["people"] for r in tiny))
record("certain_left", sum(r["left"] for r in tiny))
The path to the biggest high-risk leaf, read back as a sentence
LABEL = {"OverTime_Yes": "works overtime", "JobSatisfaction": "job satisfaction",
         "MonthlyIncome": "monthly income", "Age": "age", "YearsAtCompany": "years at company",
         "TotalWorkingYears": "total working years", "StockOptionLevel": "stock option level",
         "DistanceFromHome": "distance from home", "EnvironmentSatisfaction": "environment score",
         "NumCompaniesWorked": "employers before this", "Education": "education level",
         "YearsSinceLastPromotion": "years since promotion", "JobLevel": "job level",
         "WorkLifeBalance": "work-life balance", "YearsWithCurrManager": "years with manager",
         "YearsInCurrentRole": "years in role", "TrainingTimesLastYear": "trainings last year",
         "PercentSalaryHike": "last salary hike %", "RelationshipSatisfaction": "relationship score",
         "PerformanceRating": "performance rating", "Gender_Male": "is male",
         "Department_R&D": "is in R and D", "Department_Sales": "is in Sales"}

# The leaf id travels in `rows`, so this is a lookup rather than a float
# comparison against a rounded number — which is how the first version of this
# cell died.
target = big["leaf"]

def path_to(clf, names, want, node=0, acc=None):
    acc = acc or []
    t = clf.tree_
    if t.children_left[node] == -1:
        return acc if node == want else None
    col, thr = names[t.feature[node]], t.threshold[node]
    binary = set(np.unique(X[col])) <= {0, 1, True, False}
    whole = bool(pd.Series(X[col]).dropna().mod(1).eq(0).all())
    step = int(np.ceil(thr)) if whole else round(float(thr), 1)
    lo = f"does not {LABEL.get(col, col)}" if binary else f"{LABEL.get(col, col)} under {step:,}"
    hi = f"{LABEL.get(col, col)}" if binary else f"{LABEL.get(col, col)} {step:,} or more"
    return (path_to(clf, names, want, t.children_left[node], acc + [lo])
            or path_to(clf, names, want, t.children_right[node], acc + [hi]))

steps = path_to(tree, list(X.columns), target)
record("big_leaf_path", steps)
for s in steps:
    print(" ", s)
print(f"-> {big['people']} such people in the held-out set, {big['left']} of them left")
Drop 5% of the training rows, thirty times, and see what survives
import collections
rng = np.random.default_rng(0)
firsts = collections.Counter()
overlap = []
top50 = set(order[:50])

for _ in range(30):
    keep = rng.choice(len(tr), int(len(tr) * 0.95), replace=False)
    idx = [tr[j] for j in keep]
    t2 = DecisionTreeClassifier(max_depth=4, random_state=7).fit(X.iloc[idx], y.iloc[idx])
    firsts[X.columns[t2.tree_.feature[0]]] += 1
    r2 = t2.predict_proba(X.iloc[te])[:, 1]
    overlap.append(len(top50 & set(np.argsort(-r2, kind="stable")[:50])) / 50)

print("first question, over 30 refits:", dict(firsts))
print(f"top-fifty list overlap: mean {np.mean(overlap):.0%}, worst {np.min(overlap):.0%}")

record("first_question_stability", f"{max(firsts.values())} of 30")
record("first_question", LABEL.get(max(firsts, key=firsts.get), max(firsts, key=firsts.get)))
record("list_overlap_mean", f"{np.mean(overlap):.0%}")
record("list_overlap_worst", f"{np.min(overlap):.0%}")
record("list_churn", f"{1 - np.mean(overlap):.0%}")

def plot2(ax):
    ax.hist(overlap, bins=8, color="#e2574c")
    ax.axvline(float(np.mean(overlap)), ls="--", lw=1.2, color="#3b6fd4")
    ax.text(float(np.mean(overlap)), 7.4, f" mean {np.mean(overlap):.0%}", fontsize=9, color="#3b6fd4")
    ax.set_xlabel("Share of the original fifty names still on the list")
    ax.set_ylabel("Refits")
    ax.grid(axis="x", visible=False)

save_fig("the-list-moves", plot2, figsize=(7, 3.2))