What it actually does
Ask a sequence of yes/no questions about one feature at a time, and after each answer ask the next question of whichever group you landed in. Each question is chosen greedily: try every feature and every threshold, and keep whichever split makes the two resulting groups most internally homogeneous. Recurse until the groups are small or pure, then predict the majority class or the mean of each. The result is a partition of the feature space into axis-aligned boxes with a constant prediction inside each — which explains both its strengths and its ceiling, since no amount of depth makes a box into a diagonal.
- The decision boundary is well approximated by axis-aligned splits — a diagonal boundary is representable only as a staircase, and needs depth to buy what one linear term would give
- The target is well approximated as piecewise constant, which is why the model cannot extrapolate beyond the range of the training targets
- Enough samples reach each leaf for its constant to mean something; a leaf of three points is a memorized triple
- Features are informative individually or in sequence — a tree finds interactions only by splitting on one feature after another, so a pure XOR needs depth to represent what it cannot see in one split
Loss function
Of every question you could ask about one feature, pick the one that leaves the two resulting groups as internally uniform as possible, weighted by how many points land in each. That is the entire training rule, applied recursively. Two things worth noticing: the objective is local, so nothing here optimizes the tree as a whole, and Gini and entropy almost always choose the same split — the choice between them is far less consequential than the depth limit applied afterwards.
- the feature and threshold being chosen — the split is always on one feature at a time, which is what makes it axis-aligned
- the two child subsets produced by the split
- impurity: Gini or entropy for classification, mean squared error for regression
- size weighting, which is what stops the search preferring a split that isolates a single point
How it is minimized
Greedy recursive partitioning, followed by cost-complexity pruning
- the pruning objective: total leaf impurity plus a price per leaf
- complexity cost. Zero grows the tree until the leaves are pure; large alpha prunes back to the root
- the number of leaves, standing in for model complexity
- one leaf, whose prediction is the majority class or the mean of the points that reach it
The split search is greedy because the alternative is intractable: finding the globally optimal tree is NP-hard, so every practical implementation takes the locally best split and never revisits it. That has a real consequence — a split that looks poor now can enable an excellent pair of splits below it, and greedy search will never find it. Pruning is the partial remedy. Grow the tree past the point of usefulness, then use alpha to walk back a nested sequence of subtrees and pick one by cross-validation. Growing-then-pruning beats stopping early, because a stopping rule has to judge a split before seeing what it enables, which is the same short-sightedness one level up.
max_depth
The blunt capacity limit and usually the most consequential setting. Beyond about depth 5 the interpretability argument for a single tree has already evaporated (3 to 10 for an interpretable tree; unbounded only inside an ensemble)
min_samples_leaf
Floor on leaf size. The most reliable single guard against overfitting, because it directly bounds how confidently a leaf can speak (1 to 5% of the training set)
ccp_alpha
Cost-complexity pruning strength, chosen by cross-validation over the nested subtree sequence
criterion
Gini, entropy, or log-loss for classification; squared or absolute error for regression. Gini and entropy agree on the split the overwhelming majority of the time
max_features
How many features to consider per split. Meaningless for a single tree and essential inside a random forest, where it is the decorrelating mechanism
class_weight
Rescales impurity contributions per class; the standard handling for imbalance, since a pure-majority leaf otherwise looks perfect
The recursion terminates — every split strictly reduces the node size — so there is no convergence question, only a question of what was built. The characteristic failure is instability: a decision tree is a high-variance estimator, and changing a handful of training rows can flip an early split and produce a structurally different tree with similar accuracy. That is not a tuning problem, it is the nature of a greedy recursive partition, and it is exactly why bagging exists. The second failure is overfitting by construction — an unpruned tree grows until every leaf is pure, which means zero training error and a memorized training set. The third is quieter: impurity-based feature importances are systematically biased toward continuous and high-cardinality features, because those offer more candidate thresholds and therefore more chances to reduce impurity by luck.
Training: O(n·d·log n) with pre-sorted features, or O(n·d) per level using histogram binning, times O(log n) levels for a balanced tree. Memory O(n·d) for the sorted indices or O(d·bins) for the histograms. Prediction: O(depth) — a handful of comparisons, with no arithmetic at all — which makes trees among the cheapest models to serve of anything in this section.
How, where, and why it is used
Time-Series Forecasting
AdaptedReshape the series into a supervised table of lags, rolling statistics, and calendar features, then fit a tree on it. The tree finds thresholds — "when the 7-day mean is above 400 and it is a weekday" — which is a genuinely different hypothesis class from a linear model on the same features, and it captures interactions and regime changes without being told they exist.
- Interpretable regime detection, where the fitted thresholds are themselves the finding
- Short-horizon forecasting on series with strong calendar and event effects that interact
- The single-tree baseline that motivates a forecasting ensemble, since its variance makes the case for bagging concretely
It handles interactions, mixed feature types, and missing values with no preprocessing, and it needs no scaling — which is a real advantage over every model authored so far. But the limitation is disqualifying for many forecasting problems and needs stating plainly: a tree predicts the mean of a leaf, so it is piecewise constant and cannot extrapolate. On a trending series, every future value falls outside the range of the training targets, and the forecast is pinned to the highest leaf mean the tree ever saw. Detrend first, or forecast the difference, or use a linear model — this is not something a deeper tree fixes.
- Detrend or difference before fitting, since the tree cannot predict a value outside the training target range
- Lags, rolling statistics, and calendar flags — no scaling required, which removes an entire class of leakage bug
- Encode seasonality as explicit features (day of week, week of year) rather than expecting the tree to discover periodicity, which it cannot represent
- Let the tree find interactions rather than pre-building them; that is the one thing it does that a linear model does not
Rolling-origin backtesting scored with MASE against seasonal-naive. Refit on several adjacent windows and compare the resulting trees — if the top splits change between windows, the structure is noise, and any interpretation of it is fiction.
- Trend, which produces a forecast that flattens at the highest leaf mean and stays there for ever
- Reading impurity-based feature importances as driver importance when continuous lags are structurally favoured over categorical flags
- Treating one fitted tree as a stable description of the process, when a different sample would have produced a different tree
Anomaly Detection
Not applicableA decision tree partitions by label, so with unlabelled data it has no objective at all, and with labelled data it just becomes a rare-class classifier. Splitting on isolation depth rather than on impurity is a different algorithm with a different criterion, which is what the isolation forest entry covers.
Optimization
Not applicableThe name is a false friend: a decision tree produces a prediction, not an allocation under constraints, and its own fitting is a greedy heuristic rather than a solver worth studying — finding the optimal tree is NP-hard and nothing here attempts it.
Risk & Fraud
ViableTwo distinct roles. As a rule generator: fit a shallow tree and read the root-to-leaf paths off as literal decision rules, which a policy team can implement in a rules engine without deploying a model at all. And as a segmentation step, splitting a portfolio into risk bands whose boundaries are explicit thresholds a committee can approve or override.
- Extracting candidate decline and review rules from historical outcomes
- Risk-band segmentation where the band boundaries must be stated, defended, and occasionally overridden by hand
- Root-cause exploration on a spike in losses, where the question is which combination of attributes the losses share
The path to a leaf is a conjunction of thresholds, which is exactly the form a policy rule takes — so unlike every other model here, the deliverable can be the rules rather than a scoring service. That matters in a domain where a human must be able to defend a decline. The reasons to stop there are equally clear: a single tree is unstable, so the extracted rules change on refit, and its accuracy is well below the boosted ensemble it is usually competing against. It is a tool for finding and stating rules, not for scoring at volume.
- No scaling and no encoding of ordinals — a genuine advantage on messy intake data with mixed types
- Constrain depth hard, since a rule nobody can read is not a rule
- Use permutation importance rather than impurity importance, which is biased toward high-cardinality identifiers exactly where those are least useful as policy
- Set class weights or a minimum leaf size, or the tree will find a pure-majority leaf and declare victory on an imbalanced problem
Precision and volume per extracted rule at the operating point, on an out-of-time split — a rule is judged by what it catches and what it costs, not by the tree’s aggregate accuracy. Test stability by refitting on bootstrap samples and measuring how often the same rules appear.
- Rules extracted from an unstable tree that do not reproduce on the next refit
- Splitting on a leaked or post-outcome field, which a tree will find immediately and exploit completely
- Very deep trees marketed as interpretable; past a few levels the path to a leaf is no longer an explanation anyone reads
What it costs to run
Cheap and predictable: O(n·d·log n) with sorted features, seconds on a million rows. The cost is dominated by the split search, which is why histogram binning — the single most important implementation choice — is what every production implementation uses.
The cheapest of anything in this section. A prediction is a walk down the tree: a handful of comparisons, no arithmetic, no matrix, and no scaling step. Trees export to SQL, to a switch statement, or to a rules engine, which is often the real deployment target.
Cheap enough to refit whenever the data changes. The constraint is not compute but stability: each refit can produce a structurally different tree, so anything keyed to the fitted rules must be re-approved rather than silently swapped.
- Track the top splits across refits — a changed root split means the model a stakeholder approved no longer exists, even if accuracy is unchanged
- Monitor the sample distribution across leaves; a leaf whose traffic share moves sharply is drift arriving at a specific, nameable place
- Watch for leaves receiving traffic they never saw in training, where the prediction is a constant fitted on a handful of historical rows
- Compare permutation importances between refits rather than impurity importances, which are biased and therefore uninformative about change
- The tree cannot extrapolate: any target outside the training range is predicted as the nearest leaf mean, and it will do this silently and for ever
- Missing-value handling is a model decision — surrogate splits or a default direction — and it must be identical at training and inference, or rows take different paths
- A new categorical level has no branch; the fallback direction must be explicit, not whatever the encoder produces by default
- Impurity-based feature importance is biased toward continuous and high-cardinality features and should not be exported as a driver analysis
- Prediction depends on strict-versus-non-strict comparison at the threshold, so a value exactly on a boundary can flip between implementations — this actually happens when a model is ported to SQL
Pros and cons, in context
Requires no scaling, no encoding, and tolerates mixed types and missing values
Genuinely the least demanding model here on data preparation, which removes a whole class of production bug — a persisted scaler that drifts. Worth less when the pipeline is already built for a linear model.
A shallow tree is readable as literal rules
The path to a leaf is a conjunction of thresholds, which is the form a policy rule already takes. This is real interpretability rather than an approximation of the model — and it evaporates entirely past a few levels of depth.
Finds interactions without being told they exist
Splitting on one feature and then another is an interaction, discovered rather than specified. That is exactly what a linear model cannot do, and it is why trees dominate tabular problems where interactions carry the signal.
Prediction is a handful of comparisons
No arithmetic, no preprocessing, trivially portable to SQL or a rules engine. Decisive at extreme volume or on constrained hardware; irrelevant when a millisecond of inference is free.
High variance — a small change in the data produces a different tree
The defining weakness, and the reason bagging and boosting exist. It also undercuts the interpretability advantage: a structure that changes with the sample is not a description of the world, and treating one fitted tree as a finding is the most common misuse.
Overfits by construction unless constrained
Grown unchecked it reaches zero training error by memorizing. Depth limits, leaf-size floors, and pruning are not refinements here; without them the model is a lookup table.
Cannot extrapolate, and boundaries are axis-aligned
A piecewise-constant model pinned to the range of its training targets, approximating a diagonal boundary with a staircase. Disqualifying for trending series and for smooth relationships a single linear term would capture exactly.
A single tree is beaten by its own ensembles, decisively
Random forests and gradient boosting are strictly better predictors built from this exact component. The single tree survives where the structure itself is the deliverable — rule extraction, segmentation, explanation — and essentially nowhere else.
Intuitive, idiomatic, optimized
The same algorithm in three languages, each progressing through Kent Beck's three stages. Every optimization names the lever it pulls and the trade-off it accepts — cost profiles are illustrative, not measured benchmarks.
The math, transcribed literally. No dependencies.
"""CART classification tree - the split criterion, transcribed.
For every feature and every candidate threshold, split, measure the weighted
impurity of the two children, and keep the best. That exhaustive scan is the
objective above, written out literally: nothing is cached, nothing is sorted,
and every candidate recomputes both children from scratch.
"""
def gini(labels):
"""H(S) = 1 - sum_c p_c^2"""
if not labels:
return 0.0
counts = {}
for label in labels:
counts[label] = counts.get(label, 0) + 1
impurity = 1.0
for count in counts.values():
proportion = count / len(labels)
impurity -= proportion * proportion
return impurity
def majority(labels):
counts = {}
for label in labels:
counts[label] = counts.get(label, 0) + 1
best_label = None
best_count = -1
for label, count in counts.items():
if count > best_count:
best_label = label
best_count = count
return best_label
def best_split(X, y):
"""Exhaustive search over every feature and every midpoint."""
n = len(X)
d = len(X[0])
best = (None, None, gini(y))
for feature in range(d):
values = sorted({X[i][feature] for i in range(n)})
# Candidate thresholds are midpoints between adjacent distinct values.
for position in range(len(values) - 1):
threshold = (values[position] + values[position + 1]) / 2.0
left_labels = []
right_labels = []
for i in range(n):
if X[i][feature] <= threshold:
left_labels.append(y[i])
else:
right_labels.append(y[i])
if not left_labels or not right_labels:
continue
# Weighted by child size, which is what stops the search from
# preferring a split that isolates a single point.
weighted = (
len(left_labels) / n * gini(left_labels)
+ len(right_labels) / n * gini(right_labels)
)
if weighted < best[2]:
best = (feature, threshold, weighted)
return best
def build(X, y, depth=0, max_depth=5, min_samples_leaf=1):
if depth >= max_depth or len(set(y)) == 1 or len(y) < 2 * min_samples_leaf:
return {"leaf": True, "prediction": majority(y)}
feature, threshold, _ = best_split(X, y)
if feature is None:
return {"leaf": True, "prediction": majority(y)}
left_X, left_y, right_X, right_y = [], [], [], []
for i in range(len(X)):
if X[i][feature] <= threshold:
left_X.append(X[i])
left_y.append(y[i])
else:
right_X.append(X[i])
right_y.append(y[i])
return {
"leaf": False,
"feature": feature,
"threshold": threshold,
"left": build(left_X, left_y, depth + 1, max_depth, min_samples_leaf),
"right": build(right_X, right_y, depth + 1, max_depth, min_samples_leaf),
}
def predict_one(node, x):
while not node["leaf"]:
node = node["left"] if x[node["feature"]] <= node["threshold"] else node["right"]
return node["prediction"]O(n^2 * d) per node — every candidate threshold rebuilds both child label lists and recounts them from scratch.
How this language actually wants it written.
"""CART - typed, presorted, incremental class counts."""
from dataclasses import dataclass
import numpy as np
from numpy.typing import NDArray
Vector = NDArray[np.float64]
Matrix = NDArray[np.float64]
@dataclass(frozen=True)
class Split:
feature: int
threshold: float
impurity: float
@dataclass(frozen=True)
class Node:
"""A leaf carries a prediction; an internal node carries a split.
Modelled as one frozen type with optional children rather than a dict,
so a malformed node is a construction error rather than a KeyError during
prediction.
"""
prediction: int
split: Split | None = None
left: "Node | None" = None
right: "Node | None" = None
@property
def is_leaf(self) -> bool:
return self.split is None
def _best_split(X: Matrix, y: NDArray, n_classes: int, min_samples_leaf: int) -> Split | None:
"""Scan each feature in sorted order, moving one point at a time.
The key change: sorting the feature once means the class counts for the two
children can be updated incrementally as the boundary sweeps right, instead
of being recomputed for every candidate threshold.
"""
n, d = X.shape
parent_counts = np.bincount(y, minlength=n_classes)
best: Split | None = None
best_impurity = 1.0 - float((parent_counts / n) ** 2 @ np.ones(n_classes))
for feature in range(d):
order = np.argsort(X[:, feature], kind="stable")
values = X[order, feature]
labels = y[order]
left_counts = np.zeros(n_classes, dtype=np.int64)
right_counts = parent_counts.copy()
for position in range(n - 1):
label = labels[position]
left_counts[label] += 1
right_counts[label] -= 1
# Skip boundaries inside a run of equal values: they are not
# separable, and splitting there produces two identical children.
if values[position] == values[position + 1]:
continue
n_left = position + 1
n_right = n - n_left
if n_left < min_samples_leaf or n_right < min_samples_leaf:
continue
gini_left = 1.0 - float(((left_counts / n_left) ** 2).sum())
gini_right = 1.0 - float(((right_counts / n_right) ** 2).sum())
weighted = (n_left * gini_left + n_right * gini_right) / n
if weighted < best_impurity:
best_impurity = weighted
best = Split(
feature=feature,
threshold=float((values[position] + values[position + 1]) / 2.0),
impurity=weighted,
)
return best
def build(
X: Matrix,
y: NDArray,
n_classes: int,
depth: int = 0,
max_depth: int = 5,
min_samples_leaf: int = 1,
) -> Node:
"""Grow a tree. Raises ValueError on malformed input."""
if X.ndim != 2:
raise ValueError(f"X must be 2-D, got shape {X.shape}")
if X.shape[0] != y.shape[0]:
raise ValueError(f"X has {X.shape[0]} rows but y has {y.shape[0]}")
if min_samples_leaf < 1:
raise ValueError(f"min_samples_leaf must be at least 1, got {min_samples_leaf}")
prediction = int(np.bincount(y, minlength=n_classes).argmax())
if depth >= max_depth or np.unique(y).size == 1 or y.size < 2 * min_samples_leaf:
return Node(prediction=prediction)
split = _best_split(X, y, n_classes, min_samples_leaf)
if split is None:
return Node(prediction=prediction)
mask = X[:, split.feature] <= split.threshold
return Node(
prediction=prediction,
split=split,
left=build(X[mask], y[mask], n_classes, depth + 1, max_depth, min_samples_leaf),
right=build(X[~mask], y[~mask], n_classes, depth + 1, max_depth, min_samples_leaf),
)
def predict(node: Node, X: Matrix) -> NDArray:
out = np.empty(X.shape[0], dtype=np.int64)
for index, row in enumerate(X):
current = node
while not current.is_leaf:
assert current.split is not None
current = (
current.left if row[current.split.feature] <= current.split.threshold
else current.right
)
if current is None:
raise ValueError("malformed tree: internal node with a missing child")
out[index] = current.prediction
return outWhat changed: The algorithmic change dominates: sorting each feature once and sweeping the split boundary left to right lets the child class counts be updated one point at a time instead of recomputed per candidate, which turns an O(n^2) scan per node into O(n log n). Two correctness details come with it — boundaries inside a run of equal values are skipped, because splitting there yields two children that are not actually separable, and the leaf-size floor is enforced during the scan rather than after. Structurally the dict-based node becomes a frozen dataclass whose leaf/internal distinction is a property rather than a key, so a malformed tree fails at construction instead of as a KeyError halfway through prediction.
O(n * d * log n) per node for the sorts, plus an O(n) incremental sweep per feature.
Faster, and the justification for each change.
"""CART - histogram binning, one pass per feature, preallocated buffers."""
import numpy as np
from numpy.typing import NDArray
Matrix = NDArray[np.float64]
class HistogramTreeBuilder:
"""Split finding by binned histograms - what every production tree does.
Features are quantized ONCE into at most 256 bins, before any tree is
grown. Finding a split then means accumulating a per-bin class histogram in
one linear pass and scanning 255 candidate boundaries, instead of sorting
and sweeping n values. The per-node cost stops depending on n except through
the single accumulation pass.
The trade is exact for what it is: splits are now chosen from bin edges
rather than from every midpoint, so the fitted thresholds are approximate.
On real data the accuracy cost is tiny and the speedup is large, which is
why this is the default - but on a feature whose signal lives in a narrow
range, the binning can quantize the split away entirely.
"""
N_BINS = 256
def __init__(self, X: Matrix, y: NDArray, n_classes: int) -> None:
self._n_classes = n_classes
self._y = np.ascontiguousarray(y, dtype=np.int64)
# Quantile edges, computed once for the whole tree - and reusable
# across every tree in an ensemble, which is where this really pays.
design = np.ascontiguousarray(X, dtype=np.float64)
self._edges = np.stack([
np.quantile(design[:, feature], np.linspace(0, 1, self.N_BINS + 1)[1:-1])
for feature in range(design.shape[1])
])
# uint8 binned matrix: 8x smaller than float64, so far more of it stays
# in cache during the accumulation pass.
self._binned = np.empty(design.shape, dtype=np.uint8, order="F")
for feature in range(design.shape[1]):
np.searchsorted(
self._edges[feature], design[:, feature], side="left",
).astype(np.uint8, copy=False, casting="unsafe")
self._binned[:, feature] = np.searchsorted(
self._edges[feature], design[:, feature], side="left"
)
# Histogram buffer, allocated once and reused at every node.
self._histogram = np.empty((self.N_BINS, n_classes), dtype=np.int64)
def best_split(self, indices: NDArray, min_samples_leaf: int) -> tuple[int, int, float]:
"""Returns (feature, bin, impurity) for the best split of this node."""
n = indices.size
labels = self._y[indices]
best = (-1, -1, np.inf)
for feature in range(self._binned.shape[1]):
# One pass over the node's rows builds the whole histogram: bincount
# over the flattened (bin, class) index, no Python-level loop.
bins = self._binned[indices, feature].astype(np.int64, copy=False)
flat = bins * self._n_classes + labels
counts = np.bincount(flat, minlength=self.N_BINS * self._n_classes)
self._histogram[:] = counts.reshape(self.N_BINS, self._n_classes)
# Prefix sums turn "counts per bin" into "counts left of each
# boundary" for all 255 candidates at once.
left = np.cumsum(self._histogram, axis=0)
total = left[-1]
right = total - left
n_left = left.sum(axis=1)
n_right = n - n_left
valid = (n_left >= min_samples_leaf) & (n_right >= min_samples_leaf)
if not valid.any():
continue
with np.errstate(invalid="ignore", divide="ignore"):
gini_left = 1.0 - ((left / n_left[:, None]) ** 2).sum(axis=1)
gini_right = 1.0 - ((right / n_right[:, None]) ** 2).sum(axis=1)
weighted = (n_left * gini_left + n_right * gini_right) / n
weighted[~valid] = np.inf
candidate = int(weighted.argmin())
if weighted[candidate] < best[2]:
best = (feature, candidate, float(weighted[candidate]))
return bestWhat changed: Split finding stops being a sort-and-sweep and becomes a histogram scan. Features are quantized once, before any tree exists, into at most 256 bins held as uint8 — eight times smaller than float64, so far more of the matrix stays in cache. Finding a split is then one accumulation pass over the node’s rows followed by a prefix sum that evaluates all 255 candidate boundaries simultaneously, so the per-node cost no longer depends on sorting. The honest cost is stated in the docstring: thresholds now come from bin edges rather than from every midpoint, which makes the fitted split approximate, and on a feature whose signal sits inside one bin the binning removes it entirely.
Eliminate Python-level loops over samples
The per-bin class histogram is one bincount over a flattened (bin, class) index, and the prefix sum evaluates every candidate boundary at once instead of one loop iteration per threshold.
Trade-off: Candidate thresholds are restricted to bin edges, so the chosen split is approximate — a real accuracy cost that is usually negligible and occasionally decisive on a narrow-range feature.
Ensure contiguous memory layout and a single dtype
The binned matrix is uint8 in Fortran order, so a feature column is contiguous during the accumulation pass and eight times more of it fits in cache than the float64 original.
Trade-off: Costs a full extra copy of the design matrix at construction, and the uint8 dtype hard-caps the design at 256 bins — raising the bin count means changing the dtype and the memory story with it.
Pre-allocate output arrays and use in-place operations
The histogram buffer is allocated once and rewritten at every node, so growing a deep tree does not allocate a fresh (bins, classes) array per node per feature.
Trade-off: The buffer is shared mutable state on the builder, which makes the object non-reentrant — two threads growing nodes from one builder would corrupt each other silently.
O(n) accumulation plus O(bins * classes) scan per feature per node. Illustrative, not a measured benchmark.