ml-models / Classical ML / Decision trees intro

Decision trees

A decision tree predicts by asking a short series of yes/no questions about the inputs, such as "is x₁ at most 5.5?", and reading the answer off the leaf you land in. This page covers how the questions are chosen (Gini and entropy), how regression trees differ, how to stop a tree from memorizing its training data, and why impurity-based feature importance can mislead.

On this page
  1. Problem
  2. Intuition
  3. Mechanics
  4. Impurity: how mixed is a node?
  5. Choosing a split
  6. When to stop
  7. Regression trees
  8. Pruning: cost-complexity
  9. Feature importance, and its trap
  10. Worked example
  11. The root node
  12. Scoring every threshold on x₁
  13. Growing to purity
  14. A regression split
  15. The pruning path
  16. Depth against held-out accuracy
  17. A noise feature earns importance
  18. Implementation
  19. Tradeoffs
  20. Check yourself
  21. Connections
  22. Read first
  23. Read next
  24. References

Problem

A linear model draws one straight boundary and a nearest-neighbor model needs a distance that makes sense for every feature. Real tabular data is often neither: a rule such as "approve if income is high, or if debt is low and the account is old" is a handful of thresholds on different columns, with no obvious distance between a salary and an age. A decision tree learns rules of exactly that shape directly from labelled rows, and the finished model can be read aloud.

The difficulty is that the number of possible trees is astronomically large, and a tree allowed to grow without limit can always carve the training rows into single-row leaves. On the 12 rows used below it reaches 100% training accuracy with 6 leaves, which says almost nothing about new data. So there are two questions: how do you pick each question, and when do you stop asking?

Intuition

Play twenty questions. Each question should split the remaining possibilities into groups that are as pure as you can manage, ideally all one answer, so that the next question has less to do. A tree is built the same way: look at every question of the form "feature ≤ threshold", pick the one that leaves the two groups purest, and then repeat inside each group. This is greedy: it never looks ahead, so it can miss a pair of questions that are only good together.

Because every question looks at one feature at a time, the tree cuts the input space into boxes with sides parallel to the axes, and predicts a constant (the majority class, or the mean) in each box. A diagonal boundary has to be approximated by a staircase of boxes. More boxes fit the training data better, but past a point they fit its noise.

The toy rows and the first two splits A scatter of 12 points on a plane with x₁ across and x₂ up, drawn as circles for class 0 and squares for class 1. A vertical line at x₁ = 5.5 separates 7 points on the left from 5 on the right, and all 5 on the right are class 1. A horizontal line at x₂ = 2.5 cuts the left region into a lower part and an upper part. 0 0 2 2 4 4 6 6 8 8 10 10 x₁ x₂ x₁ ≤ 5.5 x₂ ≤ 2.5 class 0 class 1

Each split is one axis-parallel line. The right region is already pure, so the tree stops there; the left region gets a second question.

Mechanics

Impurity: how mixed is a node?

For a node whose rows have class proportions pk, two measures of mixing are in common use. Both are zero for a pure node and largest when the classes are evenly mixed.

Gini: G = 1 − Σk pk²     Entropy: H = − Σk pk log₂ pk

For two classes at p = ½, G = 0.5 and H = 1 bit. Gini is the chance of mislabelling a random row if you label it by drawing from the node's own class mix. Entropy measures the information in the label. In practice they pick the same split on most data: here the best root split is x1 ≤ 5.5 under Gini and x1 ≤ 5.5 under entropy. In scikit-learn the entropy criterion takes its logarithm through a helper that divides by ln 2, so it is in bits, and "log_loss" is the same criterion under another name.

Gini and entropy against the class mix Two curves over the proportion of class 1 from 0 to 1 in a two-class node. Entropy in bits rises from 0 to 1 at one half and falls back to 0. Gini follows the same arch but peaks at 0.5. Both are zero at a pure node. 0 0.5 1 proportion of class 1 0 1 solid: entropy (bits) dashed: Gini at p = 0.5: entropy 1.000, Gini 0.500

Gini is about half the height of entropy at the middle but has the same shape, so they rank splits almost identically.

Choosing a split

For each feature, sort the distinct values and consider a threshold halfway between each adjacent pair. Send rows with the feature at or below the threshold left, the rest right. Score the split by how much it reduces impurity, weighting each child by its share of the rows:

Δ = I(parent) − (nL/n) I(left) − (nR/n) I(right)

The best (feature, threshold) pair is the one with the largest Δ. Then the same procedure runs on each child, recursively. This is the CART algorithm (Breiman et al., 1984). scikit-learn's search sorts the node's rows by one feature, walks the sorted order and updates running class counts, so a feature costs O(n log n) at a node. Features are visited in a random order drawn from random_state, so exact ties between features can resolve differently from seed to seed.

When to stop

A node becomes a leaf when any of these holds: it is pure; its depth has reached max_depth; it has fewer than min_samples_split rows, or fewer than 2 × min_samples_leaf; or the best split improves impurity by less than min_impurity_decrease. Candidate splits that would leave fewer than min_samples_leaf rows on a side are skipped outright. With the defaults (max_depth=None, min_samples_split=2, min_samples_leaf=1) only purity stops the tree, which is why a default tree fits its training set perfectly unless two identical rows carry different labels. Setting max_leaf_nodes switches to best-first growth, which always splits the leaf with the largest improvement next.

Regression trees

The tree is unchanged except for the impurity and the leaf value. A leaf predicts the mean of its targets, and a node's impurity is the variance of its targets, so a split's score is the reduction in the weighted variance, equivalently the drop in the total sum of squared errors:

I(node) = (1/n) Σ (yi − ȳ)²     prediction = ȳ     best split minimizes SSEleft + SSEright

absolute_error uses the median and poisson a deviance, for count targets. Predictions are a staircase of constants, so a regression tree cannot extrapolate beyond the target range it saw.

Pruning: cost-complexity

Instead of stopping early, grow the full tree and cut back. Cost-complexity pruning trades training error against size. With R(T) the sum over leaves of (nt/N) · I(t), it minimizes

Rα(T) = R(T) + α · |leaves(T)|     αeff(t) = ( R(t) − R(Tt) ) / ( |leaves(Tt)| − 1 )

The effective alpha of an internal node t is the price per leaf saved if you collapse its subtree Tt into one leaf. Repeatedly collapsing the node with the smallest effective alpha (the weakest link) gives a nested sequence of trees, one for each alpha. Set ccp_alpha to keep the largest tree whose effective alpha is at most that value; cost_complexity_pruning_path returns the whole sequence so you can choose alpha by cross-validation.

Feature importance, and its trap

A feature's impurity-based importance is the total impurity reduction over all the nodes that split on it, each weighted by its share of the training rows, then normalized to sum to 1 across features:

imp(f) ∝ Σt splits on f [ nt I(t) − nL I(L) − nR I(R) ] / n

It is computed on the training data only and it rewards features that offer many candidate thresholds: a continuous column with a unique value per row can always carve out a pure-looking sliver by chance. Permutation importance on held-out data (sklearn.inspection.permutation_importance) measures what the fitted model actually relies on and is the usual alternative. Correlated features also split the credit between them, so a small importance does not mean a feature is uninformative.

The depth-2 tree for the toy rows A tree with a root asking x₁ ≤ 5.5. Its right branch is a leaf of 5 rows, all class 1. Its left branch asks x₂ ≤ 2.5 and ends in two leaves of 1 and 6 rows. Each node shows its row count, class counts and Gini impurity. yes no x₁ ≤ 5.5 n = 12, counts [5, 7] Gini 0.486 x₂ ≤ 2.5 n = 7, counts [5, 2] Gini 0.408 leaf: class 1 n = 5, counts [0, 5] Gini 0.000 leaf: class 1 n = 1, counts [0, 1] leaf: class 0 n = 6, counts [5, 1]

The same tree as the lines in the scatter above. A leaf's class counts are its prediction: predict_proba returns their fractions.

ComponentShape/ParamsMeaningSource
DecisionTreeClassifier criterion="gini", max_depth=None, min_samples_split=2, min_samples_leaf=1, max_features=None, ccp_alpha=0.0 The classifier and its defaults: a fully grown tree considering every feature at each split. _classes.py
DecisionTreeRegressor criterion="squared_error", same stopping parameters The regressor: mean-valued leaves, variance impurity. _classes.py
Builder choice max_leaf_nodes unset (−1) or set Depth-first growth by default; best-first when max_leaf_nodes is given. _classes.py
Gini class counts per node 1 − Σ pₖ², from running per-class counts. _criterion.pyx
Entropy class counts per node −Σ pₖ log pₖ, with the log in base 2. _criterion.pyx, _utils.pyx
MSE sum and sum of squares of y per node Variance impurity for regression, with a proxy score that skips constant terms. _criterion.pyx
impurity_improvement parent, left and right impurity (Nt/N) · (I − nR/nt · IR − nL/nt · IL), the quantity compared with min_impurity_decrease. _criterion.pyx
node_split_best sorted values of each drawn feature Scans every cut point, skips those violating min_samples_leaf, sets the threshold to the midpoint of the neighbouring values. _splitter.pyx
Leaf conditions depth, n_node_samples, impurity The checks that turn a node into a leaf in the depth-first builder. _tree.pyx
predict_proba (n_samples, n_classes) The class fractions of the leaf each row lands in. _classes.py
_cost_complexity_prune per-node r_node, r_branch, n_leaves Repeatedly prunes the node with the smallest effective alpha. _tree.pyx
cost_complexity_pruning_path returns ccp_alphas, impurities The nested sequence of pruned trees to choose alpha from. _classes.py
feature_importances_ (n_features,) summing to 1 Normalized total impurity decrease per feature. _classes.py, _tree.pyx

Worked example

The root node

The 12 toy rows have 5 of class 0 and 7 of class 1, so p = (5/12, 7/12). Gini = 1 − (5/12)² − (7/12)² = 0.4861, and entropy = 0.9799 bits.

Scoring every threshold on x₁

The distinct values of x₁ give 7 candidate thresholds. Each row below is one candidate, scored by Gini.

ThresholdLeft rowsRight rowsLeft GiniRight GiniWeighted GiniGain Δ
x₁ ≤ 2.5111 0.0000.4630.4240.062
x₁ ≤ 3.539 0.0000.3460.2590.227
x₁ ≤ 4.557 0.3200.2450.2760.210
x₁ ≤ 5.575 0.4080.0000.2380.248
x₁ ≤ 6.593 0.4940.0000.3700.116
x₁ ≤ 7.5102 0.5000.0000.4170.069
x₁ ≤ 8.5111 0.4960.0000.4550.032

The largest gain is 0.2480 at x1 ≤ 5.5: the left child has 7 rows with Gini 0.408, the right child 5 pure rows, so the weighted impurity falls from 0.486 to 0.238. The best threshold on x₂ gains only 0.0694, so x₁ wins the root. The tree then repeats the search inside each child.

Growing to purity

Without limits the recursion ends with 6 leaves and depth 5, with 1.000 training accuracy. Its deepest splits isolate a single row each. Impurity-based importance for the two features is x₁ = 0.624 and x₂ = 0.376. Capping the depth at 2 leaves the three-leaf tree in the diagram above, whose leaf sizes are 1, 6, 5.

A regression split

Eight points with x = 1 … 8 and y = 1.0, 1.4, 1.1, 1.3, 3.8, 4.2, 4.0, 4.4 have total sum of squared errors 17.120 around their mean. Each threshold below splits them and predicts each side's mean.

ThresholdLeft meanRight meanSSE after split
x ≤ 1.51.0002.88614.009
x ≤ 2.51.2003.13311.513
x ≤ 3.51.1673.5406.559
x ≤ 4.51.2004.1000.300
x ≤ 5.51.7204.2005.588
x ≤ 6.52.1334.20010.713
x ≤ 7.52.4004.40013.620

The best cut is x ≤ 4.5, with leaf predictions 1.200 and 4.100 and SSE 0.300, down from 17.120.

The pruning path

The full toy tree has 6 leaves. Each row is the tree left after collapsing the weakest link; R is the sum over its leaves of (n/N) · Gini.

ccp_alpha at which it appearsLeavesR(T)
0.000060.0000
0.046330.1389
0.099220.2381
0.248010.4861

A ccp_alpha of 0.05 keeps the 3-leaf tree, 0.1 keeps 2 leaves, and anything above 0.2480 leaves a single node. The alphas and R values match what scikit-learn's cost_complexity_pruning_path returns for this data.

Depth against held-out accuracy

Now a larger noisy problem: 60 training and 400 test rows, with x₁ and x₂ uniform on 0 to 10, class 1 when x₁ + x₂ > 10, and 15% of labels flipped. The diagonal boundary needs a staircase.

max_depthLeavesTrain accuracyTest accuracy
120.7500.682
240.7830.690
370.8830.760
490.9000.762
6120.9500.693
None151.0000.705

Training accuracy climbs to 1.000 as the tree grows, while test accuracy peaks at 0.762 and ends at 0.705. The extra leaves are fitting the flipped labels. One draw is noisy, so read the shape rather than the exact numbers.

A noise feature earns importance

Take 80 rows whose label depends only on a signal feature (class 1 when it exceeds 5, with 20% of labels flipped), and add two features of pure noise: a continuous one and a 0/1 coin flip. Grow a full tree on each of 20 such datasets and average the impurity importances: signal 0.715, continuous noise 0.274, binary noise 0.011. The continuous noise column collects about a quarter of the credit although it carries no information, because it offers a fresh threshold at every row.

Implementation

from sklearn.tree import (
    DecisionTreeClassifier, DecisionTreeRegressor, export_text,
)
from sklearn.inspection import permutation_importance
from sklearn.model_selection import cross_val_score

clf = DecisionTreeClassifier(max_depth=3, min_samples_leaf=5, random_state=0)
clf.fit(X_train, y_train)
clf.predict_proba(X_test)                  # (n, n_classes): leaf class fractions
print(export_text(clf, feature_names=names))

# choose the pruning strength by cross-validation
path = DecisionTreeClassifier(random_state=0).cost_complexity_pruning_path(X_train, y_train)
scores = {a: cross_val_score(DecisionTreeClassifier(ccp_alpha=a, random_state=0),
                             X_train, y_train, cv=5).mean()
          for a in path.ccp_alphas}
best_alpha = max(scores, key=scores.get)

clf.feature_importances_                    # impurity-based, training data only
permutation_importance(clf, X_test, y_test, n_repeats=10, random_state=0)

reg = DecisionTreeRegressor(max_depth=4, min_samples_leaf=3).fit(X_train, y_reg)

fit grows the tree, _prune_tree applies ccp_alpha afterwards, and the fitted structure lives in the Cython Tree object as parallel arrays (feature, threshold, children, impurity, counts per node). Rows are never normalized or scaled: a tree only compares a feature with a threshold, so monotonic rescaling changes nothing. Recent scikit-learn versions can also route missing values (NaN) at each split, with the default splitter.

Tradeoffs

  • Why use one. No feature scaling, mixed numeric and encoded categorical inputs, interactions found automatically, and a model a person can read, audit and explain. Fast to train and to predict.
  • High variance. A small change in the data can change the root split and everything below it. Single trees are rarely the most accurate model; they are the building block of random forests and gradient boosting, which average or sum many of them.
  • Overfitting by default. The default tree memorizes its training set. Always constrain it (max_depth, min_samples_leaf) or prune it (ccp_alpha), and choose the setting on validation data, not training accuracy.
  • Axis-parallel boundaries. A diagonal or smooth boundary costs many splits. Rotating the features, or a different model family, can do much better.
  • No extrapolation. Predictions are piecewise constant, so outside the training range a tree repeats its edge value; a linear trend is approximated by steps.
  • Greedy search. Each split is chosen alone. Data like XOR, where neither feature helps by itself, can mislead the first split even though two splits together would solve it.
  • Imbalanced classes. Majority-dominated leaves predict the majority; use class_weight, a sensible decision threshold on predict_proba, and a metric that is not accuracy (see classification metrics).
  • Importances. Impurity-based importance favours high-cardinality columns and is measured on training data; confirm with permutation importance on held-out rows.
  • Alternatives compared. Against logistic regression: trees capture interactions without engineering but lose smooth, extrapolating, well-calibrated scores. Against forests and boosting: a single tree is cheaper and readable, an ensemble is almost always more accurate.

Check yourself

A node holds 6 rows of class A and 2 of class B. What are its Gini impurity and the class probabilities a leaf here would give?

p = (0.75, 0.25), so Gini = 1 − 0.5625 − 0.0625 = 0.375. The leaf predicts class A, with predict_proba of 0.75 and 0.25.

Why is the threshold for a split on the values 3 and 5 equal to 4, and not 3 or 5?

Any threshold between the two values sends the same rows each way, so the choice does not change the training split. Using the midpoint puts the boundary as far as possible from both neighbours, which tends to generalize a little better than sitting on a training value.

Training accuracy is 100% and validation accuracy is 71%. Name two changes that may help.

Limit the tree: lower max_depth, raise min_samples_leaf, or prune with ccp_alpha chosen by cross-validation. Or replace the single tree with an ensemble such as a random forest.

Connections

Read first

Read next

References

  • Breiman, Friedman, Olshen and Stone. Classification and Regression Trees. Wadsworth, 1984.
  • Quinlan. Induction of Decision Trees. Machine Learning 1(1), 1986. doi:10.1007/BF00116251.
  • Hastie, Tibshirani and Friedman. The Elements of Statistical Learning, chapter 9.2: tree-based methods, including cost-complexity pruning.
  • Strobl, Boulesteix, Zeileis and Hothorn. Bias in random forest variable importance measures. BMC Bioinformatics, 2007. doi:10.1186/1471-2105-8-25.
  • scikit-learn 1.9.1: DecisionTreeClassifier, Gini, _cost_complexity_prune.