Decision Trees - Entropy and Gini Impurity
What you’ll learn
- how a tree turns a dataset into a list of if-statements you can read aloud
- Gini impurity and entropy — the formulas, and the real difference between them
- both computed by hand on the actual iris tree, matching scikit-learn to six decimals
- how CART searches for a split, and why the result is only locally optimal
- why an unpruned tree always reaches 100% training accuracy, and every control that prevents it
- why trees are unstable, and what that instability sets up for Phase 5
Intuition
A decision tree asks a sequence of yes/no questions about one feature at a time, and each answer narrows the possibilities until only one class remains plausible. It is the only model in this phase whose reasoning you can print and hand to a domain expert:
if petal width <= 0.80:
setosa
else:
if petal width <= 1.75:
versicolor
else:
virginicaif petal width <= 0.80:
setosa
else:
if petal width <= 1.75:
versicolor
else:
virginicaTwo questions, three answers, 96% accurate on the full iris dataset. No scaling, no distance metric, no kernel, no probability calibration — just thresholds.
The whole design problem is: which question, in which order? That is what impurity measures answer.
flowchart TD A["150 samples
50 / 50 / 50
gini 0.667"] -->|"petal width ≤ 0.8"| B["50 samples
all setosa
gini 0.000"] A -->|"petal width > 0.8"| C["100 samples
0 / 50 / 50
gini 0.500"] C -->|"petal width ≤ 1.75"| D["54 samples
0 / 49 / 5
gini 0.168"] C -->|"petal width > 1.75"| E["46 samples
0 / 1 / 45
gini 0.043"]
The math
Measuring impurity
A node is pure if every sample in it shares a label. Impurity measures how far from that a node is. With the proportion of class in the node:
Both are zero for a pure node and maximal for a uniform one. Their maxima differ: for classes, Gini tops out at while entropy tops out at . With three classes that is 0.667 against 1.585.
Reading the plot
- Gini and entropy agree almost everywhere. After rescaling, the two curves are close enough that they select the same split the overwhelming majority of the time. Gini is marginally faster because it avoids a logarithm; that is the entire practical difference.
- Entropy is slightly more aggressive at the extremes, so it can prefer a split that isolates a small pure group where Gini would not.
- Misclassification error is flat over wide ranges. Two candidate splits often score identically, so it gives the search nothing to descend. It is used for pruning, not for growing.
Choosing a split
CART evaluates every (feature, threshold) pair and picks the one minimising the weighted impurity of the two children:
The information gain is the parent’s impurity minus that weighted average. Split, then recurse on each child, stopping when a node is pure or a limit is reached.
Two properties follow directly:
- It is greedy. Each split is chosen to be locally best; nothing looks two moves ahead. Finding the globally optimal tree is NP-complete, so every practical tree is a good local solution.
- It is invariant to monotone transformations. Only the ordering of a feature’s values matters, never the spacing. Log-transform a feature, or standardise it, and the tree is unchanged. This is why trees never need scaling.
Worked example by hand
The iris tree above, computed from scratch.
Step 1 — root impurity. 150 samples, 50 of each class:
Step 2 — evaluate the split at petal width ≤ 0.80. The left child gets 50 setosa and nothing else; the right child gets 50 versicolor and 50 virginica:
No other threshold on either feature beats that, so CART takes it.
Step 3 — split the right child at petal width ≤ 1.75. The 100 remaining samples divide into 54 (49 versicolor, 5 virginica) and 46 (1 versicolor, 45 virginica):
Every one of these numbers is available from the fitted estimator as tree_.impuritytree_.impurity, and they
agree to six decimal places.
The same tree by entropy. , the right child is , and the two leaves are 0.4451 and 0.1511. Different scale, identical thresholds: 0.80 and 1.75.
See it move
From scratch
import numpy as np
def gini(labels):
if len(labels) == 0:
return 0.0
_, counts = np.unique(labels, return_counts=True)
p = counts / counts.sum()
return float(1 - (p**2).sum())
def entropy(labels):
if len(labels) == 0:
return 0.0
_, counts = np.unique(labels, return_counts=True)
p = counts / counts.sum()
return float(-(p * np.log2(p)).sum())
def best_split(X, y, impurity=gini):
"""Exhaustive search over every feature and every midpoint threshold."""
best = (None, None, impurity(y))
m = len(y)
for feature in range(X.shape[1]):
values = np.unique(X[:, feature])
thresholds = (values[:-1] + values[1:]) / 2 # midpoints
for t in thresholds:
mask = X[:, feature] <= t
if mask.sum() == 0 or (~mask).sum() == 0:
continue
weighted = (mask.sum() * impurity(y[mask])
+ (~mask).sum() * impurity(y[~mask])) / m
if weighted < best[2]:
best = (feature, float(t), weighted)
return best
from sklearn.datasets import load_iris
iris = load_iris()
X = iris.data[:, (2, 3)] # petal length, petal width
y = iris.target
print(f"root gini = {gini(y):.6f}") # root gini = 0.666667
feature, threshold, weighted = best_split(X, y)
print(f"split on feature {feature} at {threshold:.2f}") # feature 1 at 0.80
print(f"weighted impurity = {weighted:.6f}") # 0.333333
print(f"information gain = {gini(y) - weighted:.6f}") # 0.333333import numpy as np
def gini(labels):
if len(labels) == 0:
return 0.0
_, counts = np.unique(labels, return_counts=True)
p = counts / counts.sum()
return float(1 - (p**2).sum())
def entropy(labels):
if len(labels) == 0:
return 0.0
_, counts = np.unique(labels, return_counts=True)
p = counts / counts.sum()
return float(-(p * np.log2(p)).sum())
def best_split(X, y, impurity=gini):
"""Exhaustive search over every feature and every midpoint threshold."""
best = (None, None, impurity(y))
m = len(y)
for feature in range(X.shape[1]):
values = np.unique(X[:, feature])
thresholds = (values[:-1] + values[1:]) / 2 # midpoints
for t in thresholds:
mask = X[:, feature] <= t
if mask.sum() == 0 or (~mask).sum() == 0:
continue
weighted = (mask.sum() * impurity(y[mask])
+ (~mask).sum() * impurity(y[~mask])) / m
if weighted < best[2]:
best = (feature, float(t), weighted)
return best
from sklearn.datasets import load_iris
iris = load_iris()
X = iris.data[:, (2, 3)] # petal length, petal width
y = iris.target
print(f"root gini = {gini(y):.6f}") # root gini = 0.666667
feature, threshold, weighted = best_split(X, y)
print(f"split on feature {feature} at {threshold:.2f}") # feature 1 at 0.80
print(f"weighted impurity = {weighted:.6f}") # 0.333333
print(f"information gain = {gini(y) - weighted:.6f}") # 0.333333Forty lines reproduce scikit-learn’s first split exactly, threshold included.
Trees overfit, aggressively
An unpruned tree will always reach 100% training accuracy on data with no duplicated rows, because it can keep splitting until each leaf holds one sample. That is not a bug in the implementation; it is what “grow until pure” means. The controls that prevent it:
| Parameter | Effect | Typical value |
|---|---|---|
max_depthmax_depth | Hard cap on tree height | 3–10 |
min_samples_splitmin_samples_split | Refuse to split a node below this size | 10–50 |
min_samples_leafmin_samples_leaf | Every leaf must hold at least this many | 5–20 |
max_leaf_nodesmax_leaf_nodes | Cap total leaves; grows best-first instead of depth-first | 10–50 |
min_impurity_decreasemin_impurity_decrease | Refuse a split that gains less than this | 0.0–0.01 |
ccp_alphaccp_alpha | Cost-complexity pruning after growing | Tune via cost_complexity_pruning_pathcost_complexity_pruning_path |
min_samples_leafmin_samples_leaf is the most reliable single knob: it directly forbids the “one leaf per noisy
point” failure, whatever the depth.
from sklearn.datasets import load_iris
from sklearn.model_selection import cross_val_score
from sklearn.tree import DecisionTreeClassifier
iris = load_iris()
X, y = iris.data[:, (2, 3)], iris.target
for depth in (1, 2, 3, 5, None):
model = DecisionTreeClassifier(max_depth=depth, random_state=0)
train = model.fit(X, y).score(X, y)
cv = cross_val_score(model, X, y, cv=5).mean()
print(f"depth={str(depth):<5} leaves {model.get_n_leaves():2d}"
f" train {train:.4f} cv {cv:.4f}")
# depth=1 leaves 2 train 0.6667 cv 0.6667
# depth=2 leaves 3 train 0.9600 cv 0.9600
# depth=3 leaves 5 train 0.9733 cv 0.9600
# depth=5 leaves 8 train 0.9933 cv 0.9533
# depth=None leaves 8 train 0.9933 cv 0.9467from sklearn.datasets import load_iris
from sklearn.model_selection import cross_val_score
from sklearn.tree import DecisionTreeClassifier
iris = load_iris()
X, y = iris.data[:, (2, 3)], iris.target
for depth in (1, 2, 3, 5, None):
model = DecisionTreeClassifier(max_depth=depth, random_state=0)
train = model.fit(X, y).score(X, y)
cv = cross_val_score(model, X, y, cv=5).mean()
print(f"depth={str(depth):<5} leaves {model.get_n_leaves():2d}"
f" train {train:.4f} cv {cv:.4f}")
# depth=1 leaves 2 train 0.6667 cv 0.6667
# depth=2 leaves 3 train 0.9600 cv 0.9600
# depth=3 leaves 5 train 0.9733 cv 0.9600
# depth=5 leaves 8 train 0.9933 cv 0.9533
# depth=None leaves 8 train 0.9933 cv 0.9467Depth 2 already achieves the best cross-validated score. Everything past it buys training accuracy and loses generalisation — three extra splits to gain 0.033 on training data and lose 0.013 on held-out data.
Instability
Trees are high variance: resample the training data slightly and you can get a completely different tree. The cause is the greedy search — if two candidate splits score almost equally at the root, a handful of different rows flips which one wins, and every subsequent split changes.
This is a genuine weakness, and it is also the opening for Phase 5. If a model’s errors change substantially with the training sample, averaging many of them cancels the variance out. That single observation is what a random forest is.
APIsklearn.tree.DecisionTreeClassifier
Assumes
- The target can be approximated by axis-aligned rectangular regions
- Feature ordering is meaningful; spacing need not be
- Enough samples per leaf for the leaf proportions to mean something
Cost
- train
O(n · m log m)- predict
O(depth)- memory
O(nodes)
m = samples, n = features; prediction is logarithmic in a balanced tree
Hyperparameters that matter
max_depthdefault NoneNone means grow until pure, which guarantees overfitting. Set it.min_samples_leafdefault 1The most effective single guard against memorising individual points. Try 5 to 20.criteriondefault gini'entropy' is marginally more aggressive at the extremes and slightly slower. Rarely changes the result.ccp_alphadefault 0.0Cost-complexity pruning after growing. Use cost_complexity_pruning_path to get candidate values.class_weightdefault None'balanced' reweights impurity by inverse class frequency on imbalanced data.
Reach for it when
- You need a model a non-technical stakeholder can read
- Features are a mix of scales and types and you do not want to preprocess
- You want a base learner for a forest or a boosted ensemble
- Non-linear interactions matter and you want them found automatically
Look elsewhere when
- You need a smooth or diagonal boundary — a tree approximates one with a staircase
- The dataset is small and stability matters
- You need to extrapolate beyond the training range; a tree predicts a constant outside it
- A single model's accuracy is the priority — use an ensemble
Pitfalls
Compare
| Model | Needs scaling | Handles categoricals | Interpretable | Boundary | Stability |
|---|---|---|---|---|---|
| Decision Tree | No | Natively, once encoded | Very, when shallow | Axis-aligned steps | Low |
| Random Forest | No | Yes | Poor | Many steps averaged | High |
| Logistic Regression | Yes | Needs one-hot | High | Linear | High |
| SVM (RBF) | Critically | Needs one-hot | Low | Smooth | Moderate |
| KNN | Critically | Needs encoding | Low | Local | Moderate |
Why do decision trees not require feature scaling?
A threshold test asks whether x is above or below a value. Any monotone transformation — log, standardisation, squaring positives — preserves the ordering and therefore the tree.
Show answer
B — Because a split depends only on the ordering of a feature's values, not on their spacing — A threshold test asks whether x is above or below a value. Any monotone transformation — log, standardisation, squaring positives — preserves the ordering and therefore the tree.
A node contains 54 samples: 49 of class 1 and 5 of class 2. What is its Gini impurity?
1 - (49/54)² - (5/54)² = 1 - 0.8234 - 0.0086 = 0.1680. Mostly pure, so the impurity is low but not zero.
Show answer
B — 0.168 — 1 - (49/54)² - (5/54)² = 1 - 0.8234 - 0.0086 = 0.1680. Mostly pure, so the impurity is low but not zero.
Why does an unpruned decision tree reach 100% training accuracy?
'Grow until pure' means exactly that. With enough splits every leaf can hold a single training point, so the training score is guaranteed and meaningless.
Show answer
B — Because it keeps splitting until every leaf is pure, which it can always do if no two rows are identical with different labels — 'Grow until pure' means exactly that. With enough splits every leaf can hold a single training point, so the training score is guaranteed and meaningless.
Two decision trees fitted on slightly different samples of the same data look completely different. What does that tell you, and what does it set up?
A near-tie at the root flips under resampling and changes every subsequent split. High variance plus low bias is the ideal profile for bagging, which is what a random forest exploits.
Show answer
B — Trees are high variance because the greedy search amplifies small differences — which is exactly why averaging many of them works — A near-tie at the root flips under resampling and changes every subsequent split. High variance plus low bias is the ideal profile for bagging, which is what a random forest exploits.
🧪 Try It Yourself
Exercise 1 – Compute Gini impurity
Exercise 2 – Compute the information gain
Exercise 3 – Read the tree’s own numbers
Exercise 4 – Print the rules
Exercise 5 – Find where depth stops helping
Recap
- A tree is a sequence of single-feature threshold tests, readable as plain if-statements.
- Gini is , entropy is ; after rescaling they nearly coincide, and they almost always choose the same split.
- Hand-worked on iris: root Gini 0.6667, first split gains 0.3333, second gains 0.3897 — matching
tree_.impuritytree_.impurityexactly. - CART is greedy and only locally optimal; the globally best tree is NP-complete to find.
- Splits depend only on value ordering, so trees never need scaling.
- Unpruned trees always reach 100% training accuracy. On iris, depth 2 is the best cross-validated choice and depth 5 is already worse.
- Trees are unstable, and that instability is precisely what ensembles exploit.
Exercise 6 – Which hyperparameter actually matters?
Next
Continue to Naive Bayes Classifier — a probabilistic classifier that trains in one pass over the data and, despite an assumption that is essentially always false, remains hard to beat on text.
If this helped you, consider buying me a coffee ☕
Buy me a coffeeWas this page helpful?
Let us know how we did
