Decision tree (CART)
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
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))
|--- 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.
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}")
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.
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))
The BEST question for each column (by Gini gain):
| column | best threshold | Gini gain |
|---|---|---|
| maydon_m2 | 58.75 | 0.076 |
| xonalar | 2.5 | 0.074 |
| metro_km | 7.22 | 0.002 |
| qavatlar_soni | 13.5 | 0.002 |
| qavat | 4.5 | 0.002 |
| mebel | 0.5 | 0 |
The column with the biggest gain becomes the tree's first question. Compare with the export_text output above: they agree.
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"}, ]))
| Criterion | Formula | Speed | In sklearn | In practice |
|---|---|---|---|---|
| gini | 1 - sum(p^2) | Faster | THE DEFAULT | Almost always this one |
| entropy | -sum(p * log2(p)) | Slower (computes logs) | criterion='entropy' | Almost the same result |
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.")
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.
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}")
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
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"}, ]))
| Parameter | What it does | Practical value |
|---|---|---|
| max_depth | A limit on the tree's depth | 3-10 |
| min_samples_leaf | Every leaf must have at least this many samples | 5-50 |
| min_samples_split | A split needs at least this many samples | 10-100 |
| ccp_alpha | Pruning: cutting back after growing | 0.0-0.02 |
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}")
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
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)")
Sum: 1.0000 (always 1.0)
| feature | importance |
|---|---|
| maydon_m2 | 0.819 |
| metro_km | 0.142 |
| qavat | 0.03 |
| qavatlar_soni | 0.009 |
| xonalar | 0 |
| mebel | 0 |
feature_importances_ misleads you in two ways
- 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. - It picks at random between related columns.
xonalarandmaydon_m2correlate 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).
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)
| feature | from the tree | permutation |
|---|---|---|
| maydon_m2 | 0.819 | 0.205 |
| xonalar | 0 | 0 |
| qavat | 0.03 | 0 |
| qavatlar_soni | 0.009 | 0 |
| mebel | 0 | 0 |
| metro_km | 0.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
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"}, ]))
| Property | Tree | Linear model |
|---|---|---|
| Needs scaling? | NO: it only asks 'bigger or smaller?' | Yes |
| Non-linear patterns | Handles them (the ring: 0.9883) | Can't (0.5350) |
| Explanation | The easiest: you can draw it | Through coefficients |
| Text columns | NEED ENCODING in sklearn | Need encoding |
| Overfitting | VERY prone: you must limit it | Less prone |
| Stability | Low: change the data a little and the tree is completely different | Higher |
The last row is the tree's biggest weakness. Let me measure it:
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}")
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
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}")
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
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"}, ]))
| Concept | In one sentence |
|---|---|
| The idea | Not coefficients: a sequence of questions |
| The split criterion | Gini or entropy: reduce impurity |
| gini vs entropy | The difference is negligible; leave the default |
| Overfitting | An unlimited tree memorises |
| The most useful lever | min_samples_leaf, rather than max_depth |
| feature_importances_ | Free but misleading; permutation is more reliable |
| Scaling | NOT NEEDED |
| Text columns | Encoding is REQUIRED in sklearn: it raises ValueError |
| The biggest weakness | Unstable: 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.