Search Tech Journey

Find topics, journeys and posts

6-month learning plan89 / 130
back to blog
mlintermediate 55m read

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.

🤖Machine LearningM11 · Classical ML· Session 089 of 130 90 min

🎯 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.

You will be able to
  • 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

A decision tree is 20 questions, played greedily
🌍 Real world

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.

💻 Code world

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

Decision tree in three sentences
  • 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

  1. 1963
    AID · Automatic Interaction Detection
    First tree algorithm. Sonquist & Morgan at U Michigan. Splits regression by categorical features.
  2. 1979
    ID3 · Ross Quinlan
    The classic classification tree. Uses information gain (entropy). Categorical features only.
  3. 1984
    CART · Breiman, Friedman, Olshen, Stone
    Classification And Regression Trees. Binary splits, Gini, handles both regression and classification. Sklearn's default.
  4. 1993
    C4.5 · Quinlan
    ID3 extended: handles numeric features, missing values, pruning. Reigning champion for a decade.
  5. 2001
    Random Forest · Breiman
    Bagging trees + random feature subsets → variance destroyer. Best out-of-the-box tabular model until XGBoost.
  6. 2014
    XGBoost · Chen
    Gradient 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

Gini impurity

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.
Entropy (info gain)

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.
Variance

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

11
At node t with data (X_t, y_t)

Compute impurity(t) = Gini(y_t).

22
For each feature j

Sort samples by x_j. Consider every possible split threshold between consecutive values.

33
For each candidate (j, threshold)

Split into left/right. Compute weighted_impurity = (n_L·imp_L + n_R·imp_R) / n.

44
Pick the (j*, t*) with lowest weighted_impurity

This is the greedy choice — no lookahead.

55
Recurse on left and right children

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)

Unlimited depth
Tree keeps splitting until every leaf has 1 sample. Perfect train fit; useless on new data.
problem
Cure 1 · max_depth
Cap the number of splits from root to leaf. Simple and effective. 5-10 typical for tabular data.
reg
Cure 2 · min_samples_leaf
Refuse to create leaves with fewer than N samples. Ensures each prediction is based on real support.
reg
Cure 3 · min_impurity_decrease
Refuse to split unless it improves impurity by at least δ. Kills splits that don't help.
reg
Cure 4 · Post-pruning (CCP)
Fit a huge tree, then greedily remove weakest subtrees. sklearn: ccp_alpha parameter.
reg
Cure 5 · Ensemble
Grow many overfit trees on different samples, average their predictions. Random forest = this.
ensemble

Common misconception
✗ What most people think

"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."

✓ What is actually true

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.

Why the myth is so sticky

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.

Prove it to yourself

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 mass
From first principles
Start with the question

Why 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.

  1. 1
    A 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
  2. 2
    Accuracy 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
  3. 3
    That 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
  4. 4
    Therefore 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
  5. 5
    Gini (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

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.

Mental modelTwenty questions with axis-aligned cuts

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.
🔔 Fires when you see

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.

The tradeoff

You need an interpretable model for a stakeholder who will act on it. A single decision tree, or a linear model with coefficients?

Single decision tree
+ you gain the explanation is a path of if-then conditions, which is how humans and business rules already think — a non-technical stakeholder can read a depth-4 tree and argue with it. Captures interactions and non-linearities automatically, handles mixed categorical and numeric data, needs no scaling, and tolerates outliers because a split only cares about ordering.
− you pay severe instability: change a few training rows and the root split can change, cascading into an entirely different tree with the same accuracy. That destroys the very trust the interpretability was meant to build, because the explanation you showed last month is now a different explanation. Also weak accuracy alone, and it cannot extrapolate.
pick when the decision logic is genuinely rule-shaped (eligibility, triage, routing), interactions matter, and the tree is shallow enough — roughly depth 4 or less — to be read in one screen
Linear / logistic regression
+ you gain stable coefficients across resamples, so the explanation is reproducible and defensible over time; each coefficient carries a magnitude and direction with confidence intervals; extrapolates sensibly; and it is trivially fast to serve
− you pay only expresses additive effects unless you hand-build interactions and basis expansions; "holding all else constant" is fictional when features are correlated, which quietly undermines the interpretation; requires scaling and is sensitive to outliers
pick when effects are roughly additive, you need stable and statistically defensible attribution, or a regulator will ask you to justify a specific coefficient
Ensemble plus post-hoc explanations
+ you gain far better accuracy than either, with per-prediction attributions available; the honest choice when the decision is high-volume and accuracy has real monetary value
− you pay the explanation is now a second model of the first, and attributions can be unstable or misleading under correlated features. You lose the ability to state the global rule, and in some regulated contexts a post-hoc approximation is not an acceptable justification.
pick when accuracy dominates and per-prediction explanations suffice — but never when you must state, in advance and in writing, the rule the system follows
What a senior engineer actually does

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

Gini + entropy
Both take a vector of labels and return a number in [0, 0.5] (Gini binary) or [0, log₂k] (entropy). +1e-12 in entropy prevents log(0).
impurity
best_split brute force
For each feature, try every midpoint between consecutive unique values. O(n·d) candidate splits, O(n) impurity per candidate = O(n²·d). Fine for small datasets; sklearn uses sorted-arrays trick to get O(n·d).
search
Recursive build
Stopping conditions checked at top. If we can split, recurse into left and right. Beautifully simple ~15 lines.
grow
Prediction
Traverse from root, follow feature < threshold left/right until leaf. Leaf's stored prediction is the answer.
predict
Depth sweep
depth=1 is a stump (1 split); depth=None is unbounded (memorises). Watch train accuracy hit 100 % as depth grows and test accuracy peak-then-plateau.
overfitting
sklearn comparison
Should match to a few decimals. Small differences due to tie-breaking rules and sklearn's presort optimisation.
validate
print_tree
The interpretability payoff. You can read a small tree as a series of nested if-else statements.
explain
Try itBreak the tree with irrelevant features

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.

💡 Hint · Trees are relatively robust to irrelevant features because splits on random noise show minimal gain. But an unbounded tree will still overfit spectacularly.

(d) Production reality · 15 min

War story Every ML interview · every yearuniversal
🔥 What broke

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.

🧯 The fix
A single tree has almost no bias but huge variance — it fits arbitrary functions and any noise. Bagging (random forest) grows many high-variance trees on bootstrap samples with random feature subsets, then averages them. The average has the same low bias but drastically lower variance because errors cancel.
🎓 Lesson to steal
You cannot understand random forest, gradient boosting, or XGBoost without first understanding why a single tree is a variance monster. The ensembles exist to fix that.
War story Kaggle · Titanic → tabular competitions · 2013-presentthousands of competitions
🔥 What broke

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.

🧯 The fix
For tabular data, tree ensembles remain the state-of-the-art baseline. Deep learning excels on unstructured data (images, text, audio). Recognise the terrain before picking a tool.
🎓 Lesson to steal
Decision trees + boosting handle tabular data's messy realities natively — mixed dtypes, missing values, non-linearity, feature interactions. Deep learning demands preprocessing that trees don't.
Post-mortem
War story Medical diagnosis models · ongoingregulatory
🔥 What broke

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.

🧯 The fix
Regulated industries (medical, finance, insurance) prefer trees or LogReg over deep models specifically for interpretability. Some jurisdictions REQUIRE explainable models by law (EU AI Act, GDPR ‘right to explanation’).
🎓 Lesson to steal
Model choice is a trade-off among accuracy, interpretability, latency, and regulatory compliance. Trees are pareto-optimal on the interpretability axis. Don't dismiss them because they're ‘not deep learning.’

Where this shows up in the rest of the plan

Decision trees are the atomic unit of the most powerful tabular models
S090 · Random Forest & Bagging
Directly built on single trees. Bagging + random feature subsets = variance destroyer.
S091 · Gradient Boosting
Sequentially adds trees that correct residuals. XGBoost, LightGBM, CatBoost.
S088 · Bias-variance
Single trees are the poster child for extreme variance. Everything makes sense once you see the learning curve.
S094 · Feature engineering
Trees don't need scaling; interactions are built-in. Different pipeline than linear models.
S096 · Model interpretability
Trees are inherently interpretable. SHAP + LIME apply naturally.
S123 · System design · fraud detection
Boosted trees are the industry standard for fraud/credit scoring at scale.

(e) Recall + stretch · 10 min

Recall — click each to reveal · click to reveal
★ = stretch question

Explain-out-loud test

If you can't teach these three without notes, redo the session:

  1. What is a decision tree, in one sentence? (sequence of greedy axis-aligned splits)
  2. How does it pick each split? (impurity reduction)
  3. 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.