Skip to content

Decision tree (CART)

Chapter 18 14 code blocks 3226 words

The code in this chapter uses these filesrun it on your own machine

The scene: separating the ring

In Chapter 16 the linear model failed on the ring-shaped data: accuracy 0.5350, the level of a coin toss. The tree gave 0.9883.

Why? Because a tree works with a completely different idea.

A linear model has coefficients and gradient descent. A tree has neither. It only has questions:

"Is the area bigger than 60 m2? Yes -> go right. No -> go left."

Asking those questions one after another, it arrives at the answer.

Column names

The data is synthetic Tashkent rental listings, and its column names are Uzbek. tuman district, xonalar rooms, maydon_m2 area in m², qavat floor, qavatlar_soni floors in the building, metro_km distance to the metro, mebel furnished (0/1). The target ijaraga_berildi_30k means rented within 30 days.

The first tree

python12 lines
import pandas as pd
import numpy as np
from sklearn.model_selection import train_test_split, cross_val_score
from sklearn.tree import DecisionTreeClassifier, export_text

df = pd.read_csv(DATA / "uy_ijara.csv")
num = ["xonalar", "maydon_m2", "qavat", "qavatlar_soni", "metro_km", "mebel"]
X, y = df[num], df["ijaraga_berildi_30k"]
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.3, random_state=42, stratify=y)

tree = DecisionTreeClassifier(max_depth=3, random_state=42).fit(X_tr, y_tr)
print(export_text(tree, feature_names=num, decimals=1))
Output
|--- maydon_m2 <= 58.8
|   |--- maydon_m2 <= 36.0
|   |   |--- maydon_m2 <= 28.7
|   |   |   |--- class: 1
|   |   |--- maydon_m2 >  28.7
|   |   |   |--- class: 1
|   |--- maydon_m2 >  36.0
|   |   |--- qavat <= 13.5
|   |   |   |--- class: 1
|   |   |--- qavat >  13.5
|   |   |   |--- class: 0
|--- maydon_m2 >  58.8
|   |--- maydon_m2 <= 77.2
|   |   |--- metro_km <= 0.1
|   |   |   |--- class: 0
|   |   |--- metro_km >  0.1
|   |   |   |--- class: 0
|   |--- maydon_m2 >  77.2
|   |   |--- metro_km <= 0.4
|   |   |   |--- class: 0
|   |   |--- metro_km >  0.4
|   |   |   |--- class: 0

The text above is the whole model. There's nothing else: no coefficients, no weights.

You can draw this tree on paper and predict by hand. No other model is this transparent.

Why this is valuable in practice

When a bank refuses a loan, the customer asks "why?". With a tree the answer is precise: "your income is below X and your debt is above Y".

With logistic regression the answer is "the weighted sum of the coefficients is below the threshold", which the customer can't understand and which is legally problematic.

How it splits: Gini and entropy

At every step the tree looks for the best question. What does "best" mean?

The answer: after the split, the groups should be as pure as possible.

python20 lines
def gini(y):
    if len(y) == 0: return 0.0
    p = np.bincount(y, minlength=2) / len(y)
    return float(1 - np.sum(p ** 2))

def entropy(y):
    if len(y) == 0: return 0.0
    p = np.bincount(y, minlength=2) / len(y)
    p = p[p > 0]
    return float(-np.sum(p * np.log2(p)))

print(f"{'case':<34} {'Gini':>8} {'Entropy':>11}")
print("-" * 55)
for name, arr in [
    ("Pure: all 1 (10 of them)", np.ones(10, int)),
    ("Pure: all 0 (10 of them)", np.zeros(10, int)),
    ("Almost pure: 9 ones, 1 zero", np.r_[np.ones(9, int), np.zeros(1, int)]),
    ("Half and half: 5 ones, 5 zeros", np.r_[np.ones(5, int), np.zeros(5, int)]),
]:
    print(f"{name:<34} {gini(arr):>8.4f} {entropy(arr):>11.4f}")
Output
case                                   Gini     Entropy
-------------------------------------------------------
Pure: all 1 (10 of them)             0.0000     -0.0000
Pure: all 0 (10 of them)             0.0000     -0.0000
Almost pure: 9 ones, 1 zero          0.1800      0.4690
Half and half: 5 ones, 5 zeros       0.5000      1.0000

Both measure impurity: 0 = a pure group, the maximum = an even mix.

The tree tries every possible question and picks the one that reduces impurity the most.

python22 lines
def best_split(X, y, col):
    values = np.unique(X[col])
    cuts = (values[:-1] + values[1:]) / 2
    best, best_cut = -1, None
    start = gini(y.to_numpy())
    for c in cuts:
        left = y[X[col] <= c].to_numpy()
        right = y[X[col] > c].to_numpy()
        if len(left) == 0 or len(right) == 0: continue
        weighted = (len(left) * gini(left) + len(right) * gini(right)) / len(y)
        gain = start - weighted
        if gain > best:
            best, best_cut = gain, c
    return best_cut, best

print("The BEST question for each column (by Gini gain):")
result = []
for c in num:
    cut, gain = best_split(X_tr, y_tr, c)
    result.append({"column": c, "best threshold": round(float(cut), 2),
                   "Gini gain": round(gain, 5)})
show(pd.DataFrame(result).sort_values("Gini gain", ascending=False))
Output
The BEST question for each column (by Gini gain):
columnbest thresholdGini gain
maydon_m258.750.076
xonalar2.50.074
metro_km7.220.002
qavatlar_soni13.50.002
qavat4.50.002
mebel0.50

The column with the biggest gain becomes the tree's first question. Compare with the export_text output above: they agree.

python6 lines
show(pd.DataFrame([
    {"Criterion": "gini", "Formula": "1 - sum(p^2)", "Speed": "Faster",
     "In sklearn": "THE DEFAULT", "In practice": "Almost always this one"},
    {"Criterion": "entropy", "Formula": "-sum(p * log2(p))", "Speed": "Slower (computes logs)",
     "In sklearn": "criterion='entropy'", "In practice": "Almost the same result"},
]))
CriterionFormulaSpeedIn sklearnIn practice
gini1 - sum(p^2)FasterTHE DEFAULTAlmost always this one
entropy-sum(p * log2(p))Slower (computes logs)criterion='entropy'Almost the same result
python7 lines
print("The two criteria, measured and compared:")
for crit in ["gini", "entropy"]:
    b = cross_val_score(DecisionTreeClassifier(criterion=crit, max_depth=6, random_state=42),
                        X, y, cv=5, scoring="f1")
    print(f"  criterion={crit:<8} F1 = {b.mean():.4f} ±{b.std():.4f}")
print()
print("The difference is usually negligible, so you leave the default alone.")
Output
The two criteria, measured and compared:
  criterion=gini     F1 = 0.6794 ±0.0179
  criterion=entropy  F1 = 0.7021 ±0.0191

The difference is usually negligible, so you leave the default alone.

Overfitting: the tree's main disease

If a tree isn't limited, it memorises every row.

python9 lines
print(f"{'max_depth':>10} {'train F1':>10} {'test F1':>9} {'gap':>8} {'leaves':>9}")
print("-" * 52)
from sklearn.metrics import f1_score
for d in [2, 3, 5, 8, 15, None]:
    m = DecisionTreeClassifier(max_depth=d, random_state=42).fit(X_tr, y_tr)
    tr_f = f1_score(y_tr, m.predict(X_tr))
    te_f = f1_score(y_te, m.predict(X_te))
    flag = "  <-- MEMORISED" if tr_f - te_f > 0.2 else ""
    print(f"{str(d):>10} {tr_f:>10.4f} {te_f:>9.4f} {tr_f-te_f:>8.4f} {m.get_n_leaves():>9}{flag}")
Output
 max_depth   train F1   test F1      gap    leaves
----------------------------------------------------
         2     0.7098    0.7784  -0.0686         4
         3     0.7138    0.7644  -0.0506         8
         5     0.7275    0.7405  -0.0131        24
         8     0.8049    0.6997   0.1051        65
        15     0.9518    0.6114   0.3404       187  <-- MEMORISED
      None     1.0000    0.6264   0.3736       222  <-- MEMORISED

An unlimited tree is almost perfect on train and collapses on test. And it has hundreds of leaves: each leaf has "memorised" one or two rows.

Limiting the tree: four levers

python10 lines
show(pd.DataFrame([
    {"Parameter": "max_depth", "What it does": "A limit on the tree's depth",
     "Practical value": "3-10"},
    {"Parameter": "min_samples_leaf", "What it does": "Every leaf must have at least this many samples",
     "Practical value": "5-50"},
    {"Parameter": "min_samples_split", "What it does": "A split needs at least this many samples",
     "Practical value": "10-100"},
    {"Parameter": "ccp_alpha", "What it does": "Pruning: cutting back after growing",
     "Practical value": "0.0-0.02"},
]))
ParameterWhat it doesPractical value
max_depthA limit on the tree's depth3-10
min_samples_leafEvery leaf must have at least this many samples5-50
min_samples_splitA split needs at least this many samples10-100
ccp_alphaPruning: cutting back after growing0.0-0.02
python8 lines
print("min_samples_leaf is the most useful lever:")
print(f"{'min_samples_leaf':>18} {'CV F1':>9} {'±':>8} {'leaves':>9}")
print("-" * 48)
for msl in [1, 5, 20, 50, 100]:
    m = DecisionTreeClassifier(min_samples_leaf=msl, random_state=42)
    b = cross_val_score(m, X, y, cv=5, scoring="f1")
    m.fit(X_tr, y_tr)
    print(f"{msl:>18} {b.mean():>9.4f} {b.std():>8.4f} {m.get_n_leaves():>9}")
Output
min_samples_leaf is the most useful lever:
  min_samples_leaf     CV F1        ±    leaves
------------------------------------------------
                 1    0.6504   0.0318       222
                 5    0.6203   0.0248       106
                20    0.6703   0.0184        33
                50    0.7058   0.0129        13
               100    0.7296   0.0108         6

Tune min_samples_leaf, not max_depth

Students usually change only max_depth. But min_samples_leaf often works better, because it lets the tree be deep where it needs to and shallow where it doesn't.

max_depth cuts every branch the same.

Feature importance: a free gift

python5 lines
m = DecisionTreeClassifier(max_depth=6, min_samples_leaf=20, random_state=42).fit(X_tr, y_tr)
importance = pd.DataFrame({"feature": num, "importance": m.feature_importances_.round(4)})
show(importance.sort_values("importance", ascending=False))
print()
print(f"Sum: {m.feature_importances_.sum():.4f}  (always 1.0)")
Output
Sum: 1.0000  (always 1.0)
featureimportance
maydon_m20.819
metro_km0.142
qavat0.03
qavatlar_soni0.009
xonalar0
mebel0

feature_importances_ misleads you in two ways

  1. It favours columns with many values. If you kept elon_id (Chapter 1), it would come out as the most "important", because it separates every row.
  2. It picks at random between related columns. xonalar and maydon_m2 correlate at 0.94: the tree picks one, and the other's importance drops to 0. That does not mean "the other one is useless".

More reliable methods: permutation importance or SHAP (Chapter 26).

python8 lines
from sklearn.inspection import permutation_importance
pi = permutation_importance(m, X_te, y_te, n_repeats=20, random_state=42, scoring="f1")
compare = pd.DataFrame({
    "feature": num,
    "from the tree": m.feature_importances_.round(4),
    "permutation": pi.importances_mean.round(4),
}).sort_values("permutation", ascending=False)
show(compare)
featurefrom the treepermutation
maydon_m20.8190.205
xonalar00
qavat0.030
qavatlar_soni0.0090
mebel00
metro_km0.142-0.014

Compare the two columns: the order can differ. Permutation importance is more reliable, because it actually tests the model: it shuffles a column and measures how much the result gets worse.

The tree's strengths and weaknesses

python14 lines
show(pd.DataFrame([
    {"Property": "Needs scaling?", "Tree": "NO: it only asks 'bigger or smaller?'",
     "Linear model": "Yes"},
    {"Property": "Non-linear patterns", "Tree": "Handles them (the ring: 0.9883)",
     "Linear model": "Can't (0.5350)"},
    {"Property": "Explanation", "Tree": "The easiest: you can draw it",
     "Linear model": "Through coefficients"},
    {"Property": "Text columns", "Tree": "NEED ENCODING in sklearn",
     "Linear model": "Need encoding"},
    {"Property": "Overfitting", "Tree": "VERY prone: you must limit it",
     "Linear model": "Less prone"},
    {"Property": "Stability", "Tree": "Low: change the data a little and the tree is completely different",
     "Linear model": "Higher"},
]))
PropertyTreeLinear model
Needs scaling?NO: it only asks 'bigger or smaller?'Yes
Non-linear patternsHandles them (the ring: 0.9883)Can't (0.5350)
ExplanationThe easiest: you can draw itThrough coefficients
Text columnsNEED ENCODING in sklearnNeed encoding
OverfittingVERY prone: you must limit itLess prone
StabilityLow: change the data a little and the tree is completely differentHigher

The last row is the tree's biggest weakness. Let me measure it:

python7 lines
print("One tree, the first question on different train splits:")
for rs in [0, 1, 7, 42, 99]:
    Xa, _, ya, _ = train_test_split(X, y, test_size=0.3, random_state=rs, stratify=y)
    d1 = DecisionTreeClassifier(max_depth=2, random_state=42).fit(Xa, ya)
    root = num[d1.tree_.feature[0]]
    cut = d1.tree_.threshold[0]
    print(f"  random_state={rs:<4} first question: {root} <= {cut:.2f}")
Output
One tree, the first question on different train splits:
  random_state=0    first question: xonalar <= 2.50
  random_state=1    first question: xonalar <= 2.50
  random_state=7    first question: xonalar <= 2.50
  random_state=42   first question: maydon_m2 <= 58.75
  random_state=99   first question: xonalar <= 2.50

When the data changes a little, even the tree's root question can change. In other words, a single tree is an unstable model.

This instability is the reason for Chapter 19

If one tree is unstable, what do we do? We grow many trees and let them vote.

That's exactly what a Random Forest is. Chapter 19 starts from this idea.

Text columns: an sklearn limitation

python21 lines
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import OneHotEncoder

try:
    DecisionTreeClassifier().fit(df[["tuman", "maydon_m2"]], y)
except ValueError as e:
    print("If we pass the text column directly:")
    print(f"  ValueError: {str(e)[:78]}")
print()
print("So a tree needs encoding too:")
pipe = Pipeline([
    ("t", ColumnTransformer([("cat", OneHotEncoder(handle_unknown="ignore"), ["tuman"])],
                            remainder="passthrough")),
    ("m", DecisionTreeClassifier(max_depth=6, min_samples_leaf=20, random_state=42)),
])
b = cross_val_score(pipe, df[num + ["tuman"]], y, cv=5, scoring="f1")
print(f"  with tuman    F1 = {b.mean():.4f} ±{b.std():.4f}")
b2 = cross_val_score(DecisionTreeClassifier(max_depth=6, min_samples_leaf=20, random_state=42),
                     X, y, cv=5, scoring="f1")
print(f"  without tuman F1 = {b2.mean():.4f} ±{b2.std():.4f}")
Output
If we pass the text column directly:
  ValueError: could not convert string to float: 'Chilonzor'

So a tree needs encoding too:
  with tuman    F1 = 0.6715 ±0.0258
  without tuman F1 = 0.6715 ±0.0246

This corrects a claim from the old version of this book: "a tree handles mixed numeric and categorical data" is not true in sklearn. In theory CART can handle categories, but sklearn's implementation only accepts numbers.

What we learned today

python11 lines
show(pd.DataFrame([
    {"Concept": "The idea", "In one sentence": "Not coefficients: a sequence of questions"},
    {"Concept": "The split criterion", "In one sentence": "Gini or entropy: reduce impurity"},
    {"Concept": "gini vs entropy", "In one sentence": "The difference is negligible; leave the default"},
    {"Concept": "Overfitting", "In one sentence": "An unlimited tree memorises"},
    {"Concept": "The most useful lever", "In one sentence": "min_samples_leaf, rather than max_depth"},
    {"Concept": "feature_importances_", "In one sentence": "Free but misleading; permutation is more reliable"},
    {"Concept": "Scaling", "In one sentence": "NOT NEEDED"},
    {"Concept": "Text columns", "In one sentence": "Encoding is REQUIRED in sklearn: it raises ValueError"},
    {"Concept": "The biggest weakness", "In one sentence": "Unstable: the root question changes from split to split"},
]))
ConceptIn one sentence
The ideaNot coefficients: a sequence of questions
The split criterionGini or entropy: reduce impurity
gini vs entropyThe difference is negligible; leave the default
OverfittingAn unlimited tree memorises
The most useful levermin_samples_leaf, rather than max_depth
feature_importances_Free but misleading; permutation is more reliable
ScalingNOT NEEDED
Text columnsEncoding is REQUIRED in sklearn: it raises ValueError
The biggest weaknessUnstable: the root question changes from split to split

The next chapter solves that instability: instead of one tree, we grow a hundred.

Questions about this chapter?

The course, homework review and the other five books are in Uzbek. Questions in English are welcome on Telegram.

Write on Telegram Contents