Decision trees
A decision tree asks a series of yes-or-no questions, each one chosen to tidy the data into cleaner piles, until every branch ends in an answer you can read aloud.
- 22 min read
- 3 reading levels
- Updated
Read these first
On this page 9
One lesson, three depths. Pick the one that fits you today — you can switch any time.
Beginner — No maths. Plain English.
A decision tree is a chain of yes-or-no questions, asked in order, until only one answer is left.
Think about a doctor at a busy clinic. They do not run every test at once. They ask how many days the fever has lasted, then whether there is a cough, then where it hurts. Each answer decides which question comes next.
You have played this game yourself. In twenty questions, you ask "is it alive?" before you ask "is it a parrot?". The first question cuts the possibilities roughly in half. A decision tree is that game, played by a computer, on your data.
Why it exists
Logistic regression hands you a set of weights. They work, and almost nobody outside the team can read them.
A decision tree hands you something different: a list of instructions in plain language. "If the message asks for an OTP, call it a scam. Otherwise, if it contains a link and the sender is unknown, call it a scam."
You can print that on paper. Hand it to a bank manager, a nurse, or a judge. They can check whether it is sensible. Some decisions must be explained to the person they affect. There, this matters more than a fraction of a percent of accuracy.
There is a second reason. A straight boundary cannot enclose a round patch, as the classification lesson showed. A tree builds its boundary out of many small cuts. So it can wrap around shapes a straight line can never reach.
How it picks the first question
Here is the only clever part, and it is not complicated.
Picture a basket of mixed laundry — whites and colours jumbled together. You want to sort it with as few decisions as you can.
A good first question leaves you with tidier piles. Say "is it a bedsheet?" gives one pile of pure whites and one of pure colours. That was a brilliant question. If both piles come out as mixed as the basket already was, the question was worthless.
The tree tries every question it could ask, at every value it could ask it. It keeps the one that leaves the tidiest piles. Then it repeats the whole process inside each pile.
That is the entire training procedure. Try everything, keep the tidiest split, repeat.
How it works
Does the message ask for an OTP?
/ \
no yes
/ \
Does it have a link? SCAM
/ \
no yes
/ \
SAFE Is the sender unknown?
/ \
no yes
/ \
SAFE SCAMEach junction is a node — a point where a question is asked. Each ending is a leaf — a point where an answer is given. The number of questions on the longest path is the depth.
Follow any single message down this tree and you pass through three questions at most. Every step is readable, and you can say exactly why any message got the label it did.
Knowing when to stop
Left alone, a tree keeps asking questions until every pile is pure.
That sounds ideal. It is a disaster. With enough questions, a tree can carve out one leaf per row of your data. That includes the rows recorded wrongly.
A tree like that has not learned anything. It has built a lookup table of your training data, mistakes included. Show it something new and it flounders. This is overfitting, and trees are unusually prone to it.
So you hold the tree back. You cap how many questions it may ask. Or you grow it fully, then cut back the branches that were not earning their place. The second approach is called pruning, and it usually works better.
Trees also predict numbers
Everything so far predicted a group. Trees can predict a number too.
The questions work the same way. The only change is at the leaves. Instead of a label, each leaf stores the average of the examples that landed there.
This has an odd consequence worth seeing early. The predictions come out as flat steps, not a smooth slope. Every flat landing in the same leaf gets the identical price. Then a question flips, and the price jumps.
Where you have already seen it
- Bank and insurance rules that must be shown to a regulator on request.
- Hospital triage charts, which are decision trees drawn by hand on a wall.
- Credit card fraud checks running in the milliseconds before a payment clears.
- A helpline menu — press one for this, press two for that — a tree you walk through with your thumb.
The honest part
One tree is rarely the most accurate choice. On most real tasks, a single tree loses to the methods in the next lessons. You use one when being able to read it matters more than the last few points of accuracy.
Trees are twitchy. Change a handful of rows and the tree can rearrange itself completely, even when its accuracy barely moves. You will watch this happen in the Developer section. It means you should never tell a story about "the first question the tree asked" without checking it survives on different samples.
Trees cannot draw a diagonal. Every cut is straight across or straight down, so a slanted boundary comes out as a staircase. It can be made very fine, but it is still a staircase.
Trees cannot see past their data. A tree trained on flats between 600 and 1300 square feet will predict the same price for a 1600 and a 3000 square foot flat. It has no concept of continuing a trend.
That last weakness is real, and it is exactly why the next lesson exists.
Remember this
- A tree is a chain of yes-or-no questions, chosen so that each one leaves the tidiest possible piles.
- Its great strength is that a person can read it and check it.
- Left unrestrained it memorises your data, so you must cap its depth or prune it back.
What to learn next
- Random forest — turning the twitchiness of one tree into the strength of many.
- Overfitting and underfitting — the depth sweep on this page, explained in full.
- Classification — where the staircase boundary came from.
Developer — Code and libraries.
Setup
pip install scikit-learn numpyA tree you can read aloud
The best feature of DecisionTreeClassifier is export_text, which prints the trained model as plain text. No plotting library, no image file.
The task: spotting scam SMS messages. We build all 16 combinations of four yes-or-no clues, and label them with a rule the model is not told about.
import numpy as np
from itertools import product
from sklearn.tree import DecisionTreeClassifier, export_text
# All 16 combinations of four yes/no clues about an SMS.
FEATURES = ["asks_for_otp", "has_link", "unknown_sender", "says_urgent"]
X = np.array(list(product([0, 1], repeat=4)), dtype=float)
# The rule we are hiding from the model:
# it is a scam if it asks for an OTP, or if a stranger sent a link.
# "says_urgent" is a decoy — it has nothing to do with the truth.
y = ((X[:, 0] == 1) | ((X[:, 1] == 1) & (X[:, 2] == 1))).astype(int)
tree = DecisionTreeClassifier(random_state=0).fit(X, y)
print("rows:", len(X), " scams:", int(y.sum()))
print("tree depth:", tree.get_depth(), " leaves:", tree.get_n_leaves())
print()
print(export_text(tree, feature_names=FEATURES, class_names=["safe", "scam"]))
print("importances:", dict(zip(FEATURES, tree.feature_importances_.round(3))))rows: 16 scams: 10
tree depth: 3 leaves: 4
|--- asks_for_otp <= 0.50
| |--- has_link <= 0.50
| | |--- class: safe
| |--- has_link > 0.50
| | |--- unknown_sender <= 0.50
| | | |--- class: safe
| | |--- unknown_sender > 0.50
| | | |--- class: scam
|--- asks_for_otp > 0.50
| |--- class: scam
importances: {'asks_for_otp': 0.6, 'has_link': 0.133, 'unknown_sender': 0.267, 'says_urgent': 0.0}Read the tree back into English and compare it with the rule in the comment.
Asks for an OTP, so it is a scam. Otherwise no link, so it is safe. Otherwise a link from a known sender, so it is safe. Otherwise a link from a stranger, so it is a scam.
That is the hidden rule, recovered exactly, from 16 rows and one fit call. No other model in this section produces an artefact you can check by reading it.
says_urgent scored exactly 0.0. The tree tried it at every node and never found it useful, because it truly is irrelevant. Getting a hard zero is a genuinely useful signal.
The thresholds are 0.50 on features that only take 0 and 1. scikit-learn always splits numerically, at the midpoint between two observed values. asks_for_otp <= 0.50 means "asks_for_otp is 0". This confuses people the first time; it is not a bug.
Trees are twitchy — see for yourself
Delete a single row and refit. Nothing else changes.
import numpy as np
from itertools import product
from sklearn.tree import DecisionTreeClassifier, export_text
FEATURES = ["asks_for_otp", "has_link", "unknown_sender", "says_urgent"]
X = np.array(list(product([0, 1], repeat=4)), dtype=float)
y = ((X[:, 0] == 1) | ((X[:, 1] == 1) & (X[:, 2] == 1))).astype(int)
# Drop exactly one of the sixteen rows
X2, y2 = np.delete(X, 3, axis=0), np.delete(y, 3)
tree2 = DecisionTreeClassifier(random_state=0).fit(X2, y2)
print("depth", tree2.get_depth(), " leaves", tree2.get_n_leaves())
print(export_text(tree2, feature_names=FEATURES, class_names=["safe", "scam"]))depth 3 leaves 4 |--- asks_for_otp <= 0.50 | |--- unknown_sender <= 0.50 | | |--- class: safe | |--- unknown_sender > 0.50 | | |--- has_link <= 0.50 | | | |--- class: safe | | |--- has_link > 0.50 | | | |--- class: scam |--- asks_for_otp > 0.50 | |--- class: scam
Same depth, same number of leaves, same logic overall. But unknown_sender and has_link have swapped places in the second question.
One row out of sixteen reordered the tree's explanation of itself.
Here the two orderings happen to be equivalent, so nothing is harmed. On real data with continuous features, this twitchiness changes which features appear at all, and the split thresholds move too. That is why "the tree told us OTP requests matter most" is a claim you must test across several samples before repeating it in a meeting.
This instability is not a flaw to be tolerated. It is the raw material that random forests turn into an advantage.
Watching a tree memorise
Now with continuous features, where a tree can carve arbitrarily fine boxes.
import numpy as np
from sklearn.tree import DecisionTreeClassifier
from sklearn.model_selection import train_test_split
# 300 loan applicants, two continuous clues each.
rng = np.random.default_rng(0)
X = rng.uniform(0, 10, size=(300, 2))
# The hidden truth is a diagonal line: a tree can only approach it in steps.
truth = (X[:, 0] + X[:, 1] > 10).astype(int)
# One label in ten was recorded wrongly, as happens in every real dataset.
noisy = np.where(rng.random(300) < 0.10, 1 - truth, truth)
X_train, X_test, y_train, y_test = train_test_split(
X, noisy, test_size=0.4, random_state=0, stratify=noisy)
print("depth leaves train test")
for d in (1, 2, 4, 8, None):
t = DecisionTreeClassifier(max_depth=d, random_state=0).fit(X_train, y_train)
print(f"{str(d):<6} {t.get_n_leaves():<8} "
f"{t.score(X_train, y_train):.3f} {t.score(X_test, y_test):.3f}")depth leaves train test 1 2 0.750 0.683 2 4 0.778 0.758 4 13 0.900 0.892 8 33 0.994 0.850 None 35 1.000 0.850
The whole story of tree tuning, in five rows.
Depth 1 asks one question. Both scores are low: it is underfitting.
Depth 4 is the sweet spot. Training 0.900, test 0.892, and a small honest gap between them.
Depth None reaches a perfect 1.000 on training data with 35 leaves. Its test score is 0.850 — worse than the depth-4 tree that scored lower on training. It memorised 180 rows, including the wrongly-labelled ones.
There is a ceiling here worth naming. One label in ten was deliberately corrupted, in the test half as well as the training half. So a flawless model would still score about 0.90 against those noisy labels. The depth-4 tree at 0.892 is essentially at the ceiling. Nothing you do to this model can beat it, because the remaining error is not in the model.
Pruning beats depth-capping
max_depth is a blunt tool — it forces every branch to stop at the same level, whether or not that branch was still learning something.
Cost-complexity pruning grows the tree fully, then removes branches that do not pay for themselves. ccp_alpha sets the price each branch must justify.
import numpy as np
from sklearn.tree import DecisionTreeClassifier
from sklearn.model_selection import train_test_split
rng = np.random.default_rng(0)
X = rng.uniform(0, 10, size=(300, 2))
truth = (X[:, 0] + X[:, 1] > 10).astype(int)
noisy = np.where(rng.random(300) < 0.10, 1 - truth, truth)
X_train, X_test, y_train, y_test = train_test_split(
X, noisy, test_size=0.4, random_state=0, stratify=noisy)
for a in (0.0, 0.005, 0.01, 0.02, 0.05):
t = DecisionTreeClassifier(random_state=0, ccp_alpha=a).fit(X_train, y_train)
print(f"ccp_alpha {a:<6} leaves {t.get_n_leaves():<4} "
f"train {t.score(X_train, y_train):.3f} test {t.score(X_test, y_test):.3f}")ccp_alpha 0.0 leaves 35 train 1.000 test 0.850 ccp_alpha 0.005 leaves 33 train 0.994 test 0.850 ccp_alpha 0.01 leaves 8 train 0.911 test 0.900 ccp_alpha 0.02 leaves 6 train 0.894 test 0.817 ccp_alpha 0.05 leaves 3 train 0.772 test 0.667
At ccp_alpha=0.01 the tree drops from 35 leaves to 8, and the test score rises to 0.900 — the best result anywhere on this page, and right at the noise ceiling.
Notice that pruning beat every max_depth setting from the previous file. It gets to choose which branches to keep, instead of chopping every branch at the same height.
Do not guess ccp_alpha. Use tree.cost_complexity_pruning_path(X_train, y_train) to get the values where the tree actually changes, then pick between them with cross-validation.
Trees that predict numbers, and where they give up
import numpy as np
from sklearn.tree import DecisionTreeRegressor
from sklearn.linear_model import LinearRegression
# Flat area in hundreds of square feet -> price in lakhs. A steady upward trend.
area = np.array([[6.0], [7.0], [8.0], [9.0], [10.0], [11.0], [12.0], [13.0]])
price = np.array([40.0, 46.0, 52.0, 58.0, 63.0, 70.0, 75.0, 82.0])
tree = DecisionTreeRegressor(max_depth=2).fit(area, price)
line = LinearRegression().fit(area, price)
outside = np.array([[16.0], [20.0], [30.0]])
print("OUTSIDE the training range of 6 to 13:")
print(" area :", outside.ravel())
print(" tree says :", tree.predict(outside).round(1))
print(" line says :", line.predict(outside).round(1))
print()
print("the tree's staircase, step by step:")
for a in np.arange(6.0, 14.1, 1.0):
print(f" area {a:5.1f} -> {tree.predict([[a]])[0]:6.2f}")OUTSIDE the training range of 6 to 13: area : [16. 20. 30.] tree says : [78.5 78.5 78.5] line says : [ 99.3 123. 182.3] the tree's staircase, step by step: area 6.0 -> 43.00 area 7.0 -> 43.00 area 8.0 -> 55.00 area 9.0 -> 55.00 area 10.0 -> 66.50 area 11.0 -> 66.50 area 12.0 -> 78.50 area 13.0 -> 78.50 area 14.0 -> 78.50
Two things to take away.
The staircase is real. Four leaves, four flat prices. A 600 and a 700 square foot flat get the identical 43.00, because they land in the same leaf. Every leaf reports the average of its training rows and nothing else.
Outside the data, the tree flatlines. A 1600, 2000 and 3000 square foot flat all get 78.50. The tree cannot continue a trend, because it has no concept of one.
The straight line is not the hero of this story either. It says a 3000 square foot flat costs 182.3 lakh, extrapolating a trend it has no evidence for. Both models are wrong outside the training range. They are wrong in opposite directions, and the tree at least fails quietly rather than confidently.
Common mistakes
Letting the tree grow unrestricted. The default max_depth=None grows until every leaf is pure. Always set max_depth, min_samples_leaf, or ccp_alpha.
Scaling the features first. Harmless but pointless. A tree only compares one feature against a threshold, so any order-preserving rescaling gives the identical tree. If a tutorial puts StandardScaler before a tree, it copied it from a linear-model tutorial.
Trusting feature_importances_. This is impurity-based importance, and it is biased towards features with many distinct values. A continuous feature or a high-cardinality ID column can outrank a genuinely useful binary one. Use sklearn.inspection.permutation_importance on held-out data instead.
Reading a single tree's structure as a discovery. Shown above: one row changed the ordering. Refit on several bootstrap samples and see what survives before you believe it.
Encoding categories as 0, 1, 2, 3. The tree will split on "code <= 1.5", which groups Delhi with Mumbai and separates Chennai for no reason. One-hot encode unordered categories, or use a library with native categorical support.
Expecting a tree to extrapolate. Shown above. For anything with a trend continuing beyond the observed range, a tree is the wrong model.
Assuming missing values need filling first. Since scikit-learn 1.3, DecisionTreeClassifier handles NaN natively by learning which way missing values should go at each split. Blindly filling with the mean can be worse, since missingness is often informative on its own.
Try it yourself
In scam_sms.py, make the decoy feature a partial giveaway instead of pure noise. Replace the label line so urgent-sounding messages are also scams:
y = ((X[:, 0] == 1) | ((X[:, 1] == 1) & (X[:, 2] == 1)) | (X[:, 3] == 1)).astype(int)Before running it, predict two things. Will says_urgent still score 0.0? Will the tree get deeper or shallower?
Then check both, and read the printed tree back into English. If it does not match the rule you typed, work out why before moving on — that gap is where the real understanding lives.
What to learn next
- Random forest — turning the twitchiness of one tree into the strength of many.
- Overfitting and underfitting — the depth sweep on this page, explained in full.
- Classification — where the staircase boundary came from.
Researcher — Mathematics and papers.
The learning problem
A decision tree partitions the feature space into axis-aligned rectangles R_1, ..., R_M and fits a constant in each:
f(x) = SUM_m c_m * 1[ x in R_m ]R_m— the region reached by one leaf, an intersection of half-spaces of the formx_j <= tc_m— the constant fitted in regionm: the mean ofyfor regression, the majority or class frequencies for classification1[.]— the indicator function
Finding the partition that minimises training error subject to a size limit is NP-complete (Hyafil & Rivest, 1976). Practical algorithms are therefore greedy: choose the locally best split, recurse, never reconsider.
Split criteria
At a node with class proportions p_1, ..., p_K:
Gini H(node) = 1 - SUM_k p_k^2 = SUM_k p_k (1 - p_k)
Entropy H(node) = - SUM_k p_k log2 p_k
Misclass. H(node) = 1 - max_k p_kp_k— proportion of node samples belonging to classkK— number of classes
A candidate split into left and right children is scored by the weighted impurity decrease:
Delta = H(parent) - ( n_L / n ) * H(L) - ( n_R / n ) * H(R)n,n_L,n_R— sample counts at the parent and the two children
Gini has an alternative reading: it is the expected error rate if you labelled each item by drawing a label from the node's own class distribution. It is also the training error of the node under squared loss on the one-hot encoding, which is why regression and classification trees share one implementation.
Misclassification error is not used for growing. It is insufficiently sensitive — it can score two candidate splits equally when one produces measurably purer children — though it is appropriate for pruning. Gini and entropy rarely disagree in practice; Raileanu & Stoffel (2004) find they differ on roughly 2% of splits.
A worked split, checkable by hand
Using the 16-row dataset from the Developer section: 10 scams, 6 safe.
Root Gini = 1 - (10/16)^2 - (6/16)^2 = 1 - 0.390625 - 0.140625 = 0.46875
Split on asks_for_otp:
right (otp=1): 8 rows, all scam -> Gini = 0
left (otp=0): 8 rows, 2 scam -> Gini = 1 - (2/8)^2 - (6/8)^2 = 0.375
Weighted child impurity = (8/16)(0) + (8/16)(0.375) = 0.1875
Delta = 0.46875 - 0.1875 = 0.28125scikit-learn's tree_.impurity array returns exactly 0.468750, 0.375000, 0.500000 for the root, the left child and its right grandchild. The arithmetic is fully reproducible.
Impurity-based importance (MDI) is the sum of (n_node / n_total) * Delta over nodes splitting on that feature, normalised to sum to one:
asks_for_otp : (16/16)(0.28125) = 0.28125 -> 0.28125 / 0.46875 = 0.600
has_link : ( 8/16)(0.125) = 0.0625 -> 0.0625 / 0.46875 = 0.133
unknown_sender : ( 4/16)(0.500) = 0.125 -> 0.125 / 0.46875 = 0.267
says_urgent : never used -> 0.000These match feature_importances_ to the printed precision.
Cost-complexity pruning
Breiman et al. (1984) define, for a subtree T:
R_alpha(T) = R(T) + alpha * |leaves(T)|R(T)— resubstitution error of the tree|leaves(T)|— number of terminal nodesalpha >= 0— complexity cost charged per leaf
The key structural result: as alpha increases from 0, the minimising subtrees form a finite nested sequence T_0 ⊃ T_1 ⊃ ... ⊃ {root}. So the entire one-dimensional model-selection problem is solved by a finite list of candidates, obtainable in one pass. cost_complexity_pruning_path returns exactly the alpha values at which the sequence changes.
This is why pruning dominates depth-capping. max_depth imposes a uniform constraint; alpha selects branches by their individual contribution.
Complexity
Build (balanced, presorted) O(d n log^2 n), or O(d n log n) with sorted feature caches
Build (depth h, worst case) O(d n h)
Prediction O(h), typically O(log n)
Memory O(number of nodes)Prediction is exceptionally cheap — a handful of comparisons, no arithmetic on the features. This, not accuracy, is why trees and their ensembles dominate latency-bound production ranking systems.
Known deficiencies
Axis-aligned splits. A boundary at 45 degrees requires a staircase of many splits, and the sample cost grows accordingly. Oblique trees split on w^T x <= t — OC1 (Murthy et al., 1994) uses randomised hill-climbing, since the optimal oblique split is itself intractable. Trees are also not rotation invariant, which Grinsztajn et al. (2022) argue is a feature on tabular data, where individual columns carry meaning.
High variance. Trees are unstable in the sense of Breiman (1996): small perturbations of the training set produce large changes in the predictor. Since the greedy choice at the root determines everything below it, an error near the root propagates. This is precisely the property that makes bagging effective — see random forest.
MDI bias. Strobl et al. (2007) demonstrate that impurity importance is biased towards continuous and high-cardinality features, because such features offer more candidate split points and therefore more chances to fit noise. The bias exists even when the feature is independent of the target. Prefer permutation importance on held-out data, or SHAP values (Lundberg et al., 2020), whose TreeSHAP algorithm computes exact Shapley values for tree ensembles in O(TLD^2).
No extrapolation. Predictions are constant outside the convex hull of the training data, as demonstrated above. For targets with trend, model the trend explicitly or use a hybrid such as a linear model on the residuals.
Missing data
CART's original answer is surrogate splits: at each node, store alternative variables ranked by how well they mimic the primary split, and use the best available one when the primary variable is missing. This is implemented in R's rpart, not in scikit-learn.
scikit-learn 1.3 introduced native NaN support for the best splitter. Missing values are sent to whichever child minimises impurity, evaluated during training. This is the same strategy LightGBM and XGBoost use, and it treats missingness as informative rather than as something to be repaired.
Optimal trees
Advances in mixed-integer optimisation have made globally optimal small trees tractable. Bertsimas & Dunn (2017) formulate optimal classification trees as an MIO problem, reporting absolute out-of-sample gains of 1–5% over CART at equal depth. Hu et al. (2019) and the GOSDT line of work use branch-and-bound with analytical bounds, reaching optimality in seconds for depth-limited trees on modest datasets.
Rudin (2019) argues from this that for high-stakes decisions the choice is not between an accurate black box and an inaccurate interpretable model — an optimal sparse tree is frequently as accurate, and post-hoc explanations of black boxes are unreliable by construction.
Key references
- Hyafil, L. & Rivest, R. (1976). Constructing Optimal Binary Decision Trees is NP-Complete. Information Processing Letters 5(1).
- Breiman, L., Friedman, J., Olshen, R. & Stone, C. (1984). Classification and Regression Trees. Wadsworth. The CART book.
- Quinlan, J. R. (1986). Induction of Decision Trees. Machine Learning 1(1). ID3.
- Quinlan, J. R. (1993). C4.5: Programs for Machine Learning. Morgan Kaufmann.
- Murthy, S., Kasif, S. & Salzberg, S. (1994). A System for Induction of Oblique Decision Trees. JAIR 2.
- Strobl, C. et al. (2007). Bias in Random Forest Variable Importance Measures. BMC Bioinformatics 8(25).
- Bertsimas, D. & Dunn, J. (2017). Optimal Classification Trees. Machine Learning 106(7).
- Rudin, C. (2019). Stop Explaining Black Box Machine Learning Models for High Stakes Decisions. Nature Machine Intelligence 1.
- Lundberg, S. et al. (2020). From Local Explanations to Global Understanding with Explainable AI for Trees. Nature Machine Intelligence 2.
Current state
Single trees are no longer competitive on accuracy and are not meant to be. Their two live roles are as the base learner inside ensembles, and as standalone models where an auditable decision rule is a hard requirement — credit adjudication, clinical protocols, and regulated underwriting.
Everything that made trees weak alone — instability, greedy myopia, high variance — is what the ensemble methods exploit. Bagging attacks the variance; boosting attacks the bias by fitting successive trees to residuals. Both start from the same greedy CART splitter described here.
What to learn next
- Random forest — turning the twitchiness of one tree into the strength of many.
- Overfitting and underfitting — the depth sweep on this page, explained in full.
- Classification — where the staircase boundary came from.