S089 · Decision Trees — Gini, Entropy, Splits
The non-linear model you can draw on a napkin — greedy axis-aligned splits, Gini vs entropy, and why unpruned trees are the poster child for overfitting.
🎯 Build a decision tree by hand — understand Gini vs entropy, how greedy splits work, and why every tree in isolation overfits.
Why this session exists
Decision trees are the ML model your grandmother could read. They're a sequence of if-else statements you can draw as a flowchart, they handle mixed data types natively, and they're the atomic building block of every tabular-data leaderboard winner (random forest, XGBoost, LightGBM). Before you can wield those ensembles, you need to build a single tree, split it correctly, and see it overfit spectacularly. This session does that.
- Explain how a decision tree splits a feature — Gini impurity vs entropy vs variance reduction.
- Implement a greedy tree from scratch in ~120 lines of Python.
- Visualise a fitted tree and manually trace a prediction.
- Diagnose overfitting via depth + leaf-size hyperparameters.
- Compare CART (sklearn), ID3, and C4.5 approaches.
Prerequisites
- S084 · ML mental model — X/y/split/loss/metric scaffold.
- S088 · Bias-variance — you'll see extreme variance live.
- S033 · Pandas fundamentals — feature slicing / filtering.
(a) Intuition · 5 min
Playing 20 questions, you always ask the question that best splits the remaining possibilities in half. ‘Is it alive?’ eliminates half the world in one shot. ‘Is it Sean Connery?’ almost never does. You're being greedy — picking the locally best question without lookahead.
A decision tree is the same. At each node, it tries every feature and every possible split, picks the split that reduces impurity the most, and recurses on each side.
Formally: at each node, for each feature j and threshold t, split data into left (x_j < t) and right (x_j ≥ t). Score the split by the weighted impurity of the two children. Pick (j*, t*) that minimises impurity. Recurse until a stopping criterion (max depth, min samples, all same class).
Impurity for classification: Gini or entropy. For regression: variance (MSE within each child).
The three ideas to own before we code
- Greedy recursive splits — at each node, pick the (feature, threshold) that most reduces impurity in the children.
- Impurity: Gini = 1 - Σpₖ², Entropy = -Σpₖ log pₖ, Variance = Var(y). All measure ‘how mixed is this node.’
- Stopping: max depth OR min samples per leaf OR pure node OR no split improves impurity. Without stopping, trees always overfit.
A brief history
- 1963AID · Automatic Interaction DetectionFirst tree algorithm. Sonquist & Morgan at U Michigan. Splits regression by categorical features.
- 1979ID3 · Ross QuinlanThe classic classification tree. Uses information gain (entropy). Categorical features only.
- 1984CART · Breiman, Friedman, Olshen, StoneClassification And Regression Trees. Binary splits, Gini, handles both regression and classification. Sklearn's default.
- 1993C4.5 · QuinlanID3 extended: handles numeric features, missing values, pruning. Reigning champion for a decade.
- 2001Random Forest · BreimanBagging trees + random feature subsets → variance destroyer. Best out-of-the-box tabular model until XGBoost.
- 2014XGBoost · ChenGradient boosting done right. Dominates Kaggle. Trees remain the leaf; the ensemble is the innovation.
(b) Visual walkthrough · 15 min
One tree's anatomy
Impurity — three flavours
sklearn default · classification
- Gini(t) = 1 - Σpₖ² (for k classes at node t)
- 0 = perfectly pure (one class). 0.5 = maximum for binary (50/50).
- Fast to compute (no logs).
- Slightly biased toward multi-value features.
- In practice, Gini vs entropy rarely changes anything.
ID3, C4.5
- H(t) = -Σpₖ log₂(pₖ)
- 0 for pure. 1.0 for balanced binary. log₂(k) for balanced k-way.
- Slower than Gini (logarithms).
- Same intuition — more balanced = higher.
- Information gain = parent entropy - weighted child entropy.
regression trees
- Var(t) = mean((y - ȳ)²) at node t.
- 0 for constant y. Grows with spread.
- Split reduction = parent var - weighted child var.
- Prediction at a leaf = mean of y values there.
- sklearn's DecisionTreeRegressor default.
How a split is picked — the greedy loop
Compute impurity(t) = Gini(y_t).
Sort samples by x_j. Consider every possible split threshold between consecutive values.
Split into left/right. Compute weighted_impurity = (n_L·imp_L + n_R·imp_R) / n.
This is the greedy choice — no lookahead.
Stop if: max_depth reached OR min_samples_leaf hit OR no split improves impurity OR node is pure.
Why single trees overfit
The pathology (and its cures)
"A decision tree finds the best set of splits for the data — that's what training does. And since it picks the most informative features at the top, feature importance tells me which variables actually matter."
Tree training is greedy, not optimal. At every node it picks the split that looks best right now, with no lookahead and no backtracking — finding the globally optimal tree is NP-hard, so nobody does it. And impurity-based feature importance is systematically biased toward high-cardinality and continuous features, because those offer more candidate split points and therefore more chances to reduce impurity by luck.
The myth is sticky because greedy really does produce good trees most of the time, and the failure is invisible: you never see the better tree you didn't find. The importance belief is stickier still — the numbers are right there in .feature_importances_, they sum to 1, and they usually rank plausible features highly, so nothing signals that a random unique ID column would score high too. The bias only shows when you deliberately test it, which almost nobody does.
Two experiments, ten lines. First, watch greedy miss an XOR that a depth-2 tree can represent perfectly. Second, watch a pure-noise high-cardinality column outrank a real feature:
import numpy as np
from sklearn.tree import DecisionTreeClassifier
# 1. neither feature alone reduces impurity at the root
X = np.random.randint(0, 2, (2000, 2))
y = X[:, 0] ^ X[:, 1]
print(DecisionTreeClassifier(max_depth=1).fit(X, y).score(X, y)) # ~0.5
print(DecisionTreeClassifier(max_depth=2).fit(X, y).score(X, y)) # ~1.0
# 2. pure noise, but continuous and high-cardinality
X2 = np.c_[np.random.rand(500), np.random.randint(0, 2, 500)]
y2 = np.random.randint(0, 2, 500)
print(DecisionTreeClassifier().fit(X2, y2).feature_importances_)
# the useless continuous column takes most of the massWhy do trees split on impurity reduction — Gini or entropy — instead of simply the split that maximises classification accuracy at that node? Accuracy is what you are ultimately judged on. Optimising it directly is worse, and the reason is structural.
- 1A node's split is not a final decision. It is one step in a recursive procedure, so its job is to leave the children in a better state for further splitting, not to classify correctly right now.forced by · everything except the leaves will be split again; only leaves make predictions
- 2Accuracy at a node is a step function of the class proportions — it only changes when the majority class flips. So a split that moves a node from 80/20 to 95/5 shows zero accuracy gain, since the majority class was, and remains, the same.forced by · accuracy depends only on which class is largest, discarding how dominant it is
- 3That makes accuracy blind to exactly the progress that matters. Many genuinely excellent splits improve purity substantially without flipping any majority, and a greedy accuracy criterion scores them all identically at zero — so it cannot choose among them, and frequently stops early.forced by · a flat objective provides no gradient for the greedy search to follow
- 4Therefore the criterion must be strictly concave in the class proportions: maximal at 50/50, zero at pure, and strictly decreasing in between. Concavity guarantees that any split producing children more homogeneous than the parent scores a positive gain.forced by · by Jensen's inequality, a concave function evaluated at the parent exceeds the weighted average over the children whenever the split separates anything at all
- 5Gini (1 − Σpᵢ²) and entropy (−Σpᵢ log pᵢ) are both strictly concave and both satisfy this. Gini is cheaper — no logarithms — which matters because the criterion is evaluated once per candidate threshold per feature per node, and that is the inner loop of the entire algorithm.forced by · the criterion runs O(n · p) times per node, so its constant factor dominates training time
Therefore impurity measures are used because they are sensitive to partial progress in a way accuracy structurally cannot be. They are surrogate objectives that make greedy search tractable.
And note what this predicts: because the criterion is greedy and each split is evaluated in isolation, a tree must fail on problems where no single feature reduces impurity at the root even though a combination does. That is precisely XOR, and precisely the diagonal boundary case — a tree approximates a 45° line with a staircase of axis-aligned steps, needing many splits for something linear regression captures with two coefficients. Both failures are predictions of the derivation, not surprises.
Picture feature space as a rectangle being repeatedly sliced by cuts that are always perpendicular to an axis — never diagonal, never curved. Each internal node is one cut; each leaf is one surviving rectangle that predicts a constant (majority class, or mean value).
Everything about trees follows from that picture. They are invariant to any monotone transform of a feature, because the cut just moves to the transformed threshold — so scaling and log transforms are pointless. They cannot extrapolate, because outside the training range there are no rectangles, only the nearest edge's constant. And a fully grown tree memorises, because it will keep cutting until each rectangle holds one point.
- Splits are axis-aligned, so diagonal boundaries need staircases. If your signal is a linear combination, a linear model wins with a fraction of the parameters.
- Monotone-transform invariant: no scaling, no normalisation, no log transforms needed. This is a genuine and underrated advantage over linear models.
- An unpruned tree drives training error to zero and generalises poorly. Control it with
max_depth,min_samples_leaf, or cost-complexity pruning — the last is the principled one. - Predictions are piecewise constant. A tree can never output a value outside the range of its training targets, which makes it structurally unfit for extrapolating trends.
Fire this the moment you see: feature_importances_ quoted as evidence that a variable matters · a single deep tree in production · someone scaling features before fitting a tree · a tree used to forecast a trending time series · a tree whose structure changes completely when a few rows are added · an ID-like column in the feature set.
You need an interpretable model for a stakeholder who will act on it. A single decision tree, or a linear model with coefficients?
A single tree is the right answer far less often than its reputation suggests. Its interpretability is real but fragile, and the fragility is not a tuning problem — it is inherent in a greedy procedure where the root split is chosen by a narrow margin over a close runner-up. If you ship one, constrain depth hard and check stability across bootstrap resamples before promising anyone that this is "the" rule.
The durable value of learning trees properly is not deploying them solo — it is that everything strong on tabular data is built from them. Random forests exist to cancel exactly the instability described above by averaging; boosting exists to fix the greedy bias by fitting residuals sequentially. You cannot reason about either without owning the single tree first.
(c) Hands-on · 25 min
We're going to build a classification tree from scratch, then compare against sklearn, then explore what happens when we don't regularize.
# tree_scratch.py — decision tree from scratch + sklearn comparison, ~140 lines.
import numpy as np
from sklearn.datasets import load_iris
from sklearn.model_selection import train_test_split
from sklearn.tree import DecisionTreeClassifier
from sklearn.metrics import accuracy_score
np.random.seed(42)
# --- Data ---
X, y = load_iris(return_X_y=True)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.25, stratify=y, random_state=42)
# =====================================================
# 1. Impurity functions
# =====================================================
def gini(y):
if len(y) == 0:
return 0.0
_, counts = np.unique(y, return_counts=True)
p = counts / len(y)
return 1.0 - (p ** 2).sum()
def entropy(y):
if len(y) == 0:
return 0.0
_, counts = np.unique(y, return_counts=True)
p = counts / len(y)
return -(p * np.log2(p + 1e-12)).sum()
# =====================================================
# 2. Best split — brute force
# =====================================================
def best_split(X, y, impurity=gini):
"""Return (feature_idx, threshold, impurity_gain) for the best split, or None."""
n, d = X.shape
parent_imp = impurity(y)
best = None
for j in range(d):
vals = np.unique(X[:, j])
for t in (vals[:-1] + vals[1:]) / 2: # midpoints between consecutive values
left = X[:, j] < t
right = ~left
n_l, n_r = left.sum(), right.sum()
if n_l == 0 or n_r == 0:
continue
weighted_imp = (n_l * impurity(y[left]) + n_r * impurity(y[right])) / n
gain = parent_imp - weighted_imp
if best is None or gain > best[2]:
best = (j, t, gain)
return best
# =====================================================
# 3. Tree node
# =====================================================
class Node:
def __init__(self):
self.feature = None # split feature index
self.threshold = None # split threshold
self.left = None
self.right = None
self.prediction = None # for leaves: majority class
def build(X, y, depth=0, max_depth=5, min_samples=2, impurity=gini):
node = Node()
# Stopping criteria
if depth >= max_depth or len(y) < min_samples or len(np.unique(y)) == 1:
vals, counts = np.unique(y, return_counts=True)
node.prediction = vals[counts.argmax()]
return node
split = best_split(X, y, impurity)
if split is None or split[2] <= 0:
vals, counts = np.unique(y, return_counts=True)
node.prediction = vals[counts.argmax()]
return node
j, t, _ = split
node.feature, node.threshold = j, t
mask = X[:, j] < t
node.left = build(X[mask], y[mask], depth + 1, max_depth, min_samples, impurity)
node.right = build(X[~mask], y[~mask], depth + 1, max_depth, min_samples, impurity)
return node
def predict_one(node, x):
if node.prediction is not None:
return node.prediction
if x[node.feature] < node.threshold:
return predict_one(node.left, x)
return predict_one(node.right, x)
def predict(root, X):
return np.array([predict_one(root, x) for x in X])
# =====================================================
# 4. Fit + evaluate
# =====================================================
for max_depth in [1, 2, 3, 5, 10, None]:
md = max_depth if max_depth else 20
root = build(X_train, y_train, max_depth=md)
acc_tr = accuracy_score(y_train, predict(root, X_train))
acc_te = accuracy_score(y_test, predict(root, X_test))
print(f"[scratch · depth={str(max_depth):>4}] train={acc_tr:.3f} test={acc_te:.3f}")
# =====================================================
# 5. sklearn sanity check
# =====================================================
print()
for max_depth in [1, 2, 3, 5, 10, None]:
sk = DecisionTreeClassifier(max_depth=max_depth, random_state=42).fit(X_train, y_train)
acc_tr = accuracy_score(y_train, sk.predict(X_train))
acc_te = accuracy_score(y_test, sk.predict(X_test))
print(f"[sklearn · depth={str(max_depth):>4}] train={acc_tr:.3f} test={acc_te:.3f}")
# =====================================================
# 6. Print the fitted tree (small enough to read)
# =====================================================
def print_tree(node, feature_names, class_names, indent=""):
if node.prediction is not None:
print(f"{indent}→ predict {class_names[node.prediction]}")
return
print(f"{indent}if {feature_names[node.feature]} < {node.threshold:.2f}:")
print_tree(node.left, feature_names, class_names, indent + " ")
print(f"{indent}else:")
print_tree(node.right, feature_names, class_names, indent + " ")
print("\n=== Learned tree (max_depth=3) ===")
root3 = build(X_train, y_train, max_depth=3)
print_tree(root3, ["sepal_len", "sepal_wid", "petal_len", "petal_wid"], ["setosa", "versicolor", "virginica"])What each block does
Anatomy of the script
Add 50 columns of pure random noise (np.random.normal) to X_train. Refit at max_depth=3 and depth=None. Compare test accuracy.
- At depth=3, test accuracy barely changes — the tree ignores irrelevant features.
- At depth=None, test accuracy plummets — the tree happily overfits noise.
Lesson: depth limits (or ensembling) are what save you when data has junk features.
(d) Production reality · 15 min
Candidate answers ‘what model would you try first?’ with ‘I'd start with a random forest.’ Interviewer: ‘Why not a single decision tree?’ Candidate: ‘Because... it overfits?’ Interviewer: ‘Why does it overfit and a forest doesn't?’ Awkward silence.
Kaggle competitions on tabular data consistently see teams building deep neural networks lose to teams using CART-based ensembles (Random Forest, XGBoost, LightGBM). The gap has narrowed over the years but hasn't closed for tabular.
Hospitals want ML models to help diagnose but need clinicians to trust and audit them. A black-box neural network that outputs ‘cancer probability = 0.87’ is untrustworthy. A decision tree that outputs ‘IF tumor_size > 3 AND age > 60 THEN 87 % likelihood’ is auditable.
Where this shows up in the rest of the plan
(e) Recall + stretch · 10 min
Explain-out-loud test
If you can't teach these three without notes, redo the session:
- What is a decision tree, in one sentence? (sequence of greedy axis-aligned splits)
- How does it pick each split? (impurity reduction)
- Why does it overfit, and what are the three cures? (unlimited memorisation; regularize / prune / ensemble)
What comes next
Hub: The 6-Month Learning Plan
Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.