Skip to content
intermediate Phase 8 · Decision Trees and Ensemble Methods

Tree Pruning and Hyperparameters

Control tree growth with pre-pruning stops or cost-complexity trimming.

50m
0 problems
Topic Progress 0%

Stopping Growth Before It Starts: The Pre-Pruning Knobs

From Decision Tree Learning you know the default tree memorizes: depth 31, 604 leaves, train accuracy 1.000. Pre-pruning answers that with rules that stop the recursion before it can carve a leaf for every row. Each knob is a different definition of "this split is not worth it":

Hyperparameter Default Stops when Typical start
max_depth None the path reaches the limit 4–12
min_samples_split 2 fewer rows than N remain to split 2–20
min_samples_leaf 1 a child would hold fewer than N rows 5–50
max_leaf_nodes None the tree already has N leaves 15–100
max_features None considered at each split (fraction) sqrt / 0.3 for speed

min_samples_leaf deserves special attention because it does more than cap size: requiring every leaf to hold a minimum number of rows forces the tree to average over data instead of memorizing it. A leaf with one row predicts that row; a leaf with 50 rows predicts their majority, so noise gets smoothed away. In practice min_samples_leaf and max_depth together kill most of the memorization signature — the toy from the previous chapter drops from depth 31 to something honest:

from sklearn.tree import DecisionTreeClassifier
from sklearn.model_selection import GridSearchCV, StratifiedKFold

base = DecisionTreeClassifier(random_state=42)
param_grid = {
    "max_depth": [4, 6, 8, 12, None],
    "min_samples_leaf": [1, 5, 20, 50],
    "min_samples_split": [2, 10, 40],
    "max_leaf_nodes": [15, 31, 63, None],
}
cv = StratifiedKFold(5, shuffle=True, random_state=42)
search = GridSearchCV(base, param_grid, cv=cv, scoring="f1", n_jobs=-1)
search.fit(X_tr, y_tr)
search.best_params_   # e.g. {'max_depth': 8, 'min_samples_leaf': 20, ...}

Before trusting any of those numbers, diagnose first: fit the unconstrained tree and inspect get_depth(), get_n_leaves(), and the train/validation gap. Depth 3 with a large gap means size is not the problem (look at features or label noise instead); depth 30 with a large gap is textbook overfitting, and the knobs above are the right lever. Size limits double as an interpretability tool — a depth-6 tree is at most 63 printable rules a stakeholder can audit, while depth 30 is a database nobody reviews.

Now the failure mode, which is the reason pre-pruning alone is not enough. Tree growth is greedy, so a split that looks useless today may be the doorway to an excellent split two levels down. A classic instance: parent 100 rows, 60/40; the best available split produces children 31/19 and 29/21 — pure 0.5 everywhere, information gain 0. The rule "stop when gain ≈ 0" halts here, yet one level deeper both children might split to near-purity. The famous artificial case is XOR: either single feature has zero gain against the label, so any myopic tree with a gain threshold refuses to split at all and stays at the majority guess, while the same tree two levels deep solves it perfectly. This is the horizon problem — early stopping optimizes the next step, not the final tree.

Two operational notes. First, max_features is not a size limit but a per-split feature subsampling rate; for classification its default in a single tree is all features (the sqrt heuristic belongs to random forests), and lowering it speeds training on wide datasets while injecting randomness. Second, tune pre-pruning limits inside cross-validation, never on the test set — with a grid this large and 5 folds you are fitting 300 trees, which is still fast, and the alternative is reporting a number you chose by staring at the test results. Pre-pruning is cheap and human-readable, but there is a second family of methods that lets the tree grow fully first and then removes what never earned its place — and that family has a theory-backed advantage you are about to compute by hand.

Cost-Complexity Pruning: Putting a Price on Every Leaf with Alpha

Post-pruning takes a different philosophy: grow the tree all the way down so every structure the greedy search could use exists, then delete the branches whose contribution does not justify their complexity. The deletion rule is a single regularized objective, the tree analogue of ridge and lasso:

R_alpha(T) = R(T) + alpha * |T~|

R(T)   = weighted impurity of the leaves (sum over leaves of n_t/n * impurity_t)
|T~|   = number of effective leaves (terminal nodes)
alpha  = the price you charge for each leaf

The objective is a direct trade: every subtree is worth keeping only if the impurity it removes exceeds alpha times the leaves it costs. Work the arithmetic on a subtree t with 3 leaves that currently reduces the training error of its would-be pruned replacement by 4 mistakes:

keep t:  error 6 + alpha * 3        # subtree contributes 3 leaves
prune t: error 10 + alpha * 1       # replaced by a single leaf
break-even: 6 + 3a = 10 + a  ->  2a = 4  ->  alpha* = 2

alpha < 2  -> keep the subtree (error savings outweigh the leaf tax)
alpha > 2  -> replace it with one leaf
alpha = 0  -> no pruning at all: the fully grown tree minimizes R_0

General rule: a subtree is cut when alpha exceeds (R(t) − R(T_t)) / (|T̃_t| − 1) — the error it saves divided by the extra leaves it needs. As alpha rises from 0, trees get cut in a fixed order — the weakest links first, which gives the algorithm its name, weakest-link cutting — so one growing sequence produces every candidate tree you might want, with alpha as the single dial.

scikit-learn exposes that sequence directly. ccp_alpha is its pruning parameter, and cost_complexity_pruning_path computes the whole ladder before you commit to anything:

import numpy as np
from sklearn.tree import DecisionTreeClassifier

tree = DecisionTreeClassifier(random_state=42)
tree.fit(X_tr, y_tr)
path = tree.cost_complexity_pruning_path(X_tr, y_tr)
alphas, impurities = path.ccp_alphas, path.impurities
print(alphas[:8])   # [0.  0.0012  0.0019  0.0044  ...] ascending, one per cut

# every candidate tree, cheap to evaluate:
forest = [DecisionTreeClassifier(ccp_alpha=a, random_state=42).fit(X_tr, y_tr)
          for a in alphas]
print([t.get_n_leaves() for t in forest])   # monotonically shrinking

The pruning path is computed bottom-up: each internal node gets its own break-even alpha, the node with the smallest alpha collapses first, and the tree is re-evaluated upward — a single pass that yields the complete nesting of subtrees. That is why the returned ccp_alphas array is ascending and each value corresponds to one more cut: index 0 is alpha = 0 (fully grown), and the last entry prunes everything to the root. You get a total ordering of candidate models for the price of one fit.

Two precision points interviewers use to separate readers of docs from users of trees. First, sklearn's R(T) is computed with leaf impurity (gini/entropy), not misclassification rate — the same formula shape, a more sensitive measure, and identical in spirit to the break-even math above. Second, alpha values are dataset-specific and scale with the impurity units of your labels, so the number 0.0044 means nothing portable — you always select it relative to the candidate list via cross-validation, which is exactly the next chapter's job. Also note pruning never adds structure: the pruned tree is always a subtree of the fully grown one, so if the greedy search never considered a useful split, no alpha will recover it. That limit is shared with pre-pruning and is the reason ensembles exist — averaging many diverse trees sidesteps any single tree's blind spots.

Selecting Alpha with Cross-Validation: Where the Test Set Stays Untouched

You now have two levers — pre-pruning limits and cost-complexity alpha — and one rule for both: select them by cross-validation on the training folds, freeze the choice, and evaluate the test set exactly once. The full workflow for alpha looks like this:

import numpy as np
from sklearn.tree import DecisionTreeClassifier
from sklearn.model_selection import GridSearchCV, StratifiedKFold, train_test_split
from sklearn.metrics import accuracy_score

X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.2,
                                           stratify=y, random_state=42)

path = DecisionTreeClassifier(random_state=42).fit(X_tr, y_tr) \
             .cost_complexity_pruning_path(X_tr, y_tr)
alphas = np.unique(path.ccp_alphas)[::5]          # subsample the ladder

grid = GridSearchCV(
    DecisionTreeClassifier(random_state=42),
    {"ccp_alpha": alphas},
    cv=StratifiedKFold(5, shuffle=True, random_state=42),
    scoring="accuracy", n_jobs=-1)
grid.fit(X_tr, y_tr)
print("best alpha:", grid.best_params_["ccp_alpha"])
print("test score:", accuracy_score(y_te, grid.predict(X_te)))  # ONCE

What the CV curve looks like as alpha varies tells you where you stand. At alpha = 0 the candidates are deep, memorized trees: training score is pinned at 1.0 while validation score sags — classic variance. At very large alpha the tree collapses to its root (one leaf predicting the majority class), and train and validation scores converge downward at the majority-class baseline — classic bias, the same lazy-model shape you met in Class Imbalance Remedies. The chosen alpha sits where the validation curve peaks, between the two. Watching those three regions form is the most efficient way to internalize what regularization does in trees.

A subtle and common mistake: selecting alpha by training score, or by the number of leaves you find aesthetically pleasing. Training score is monotonically best at alpha = 0 by construction, so it can never choose the cut; only held-out folds estimate generalization. The other common leak is tuning alpha after looking at test results — then the test set has quietly become a validation set and your final number is optimistic. Keep one held-out set pristine, or use nested CV if the dataset is small enough that you cannot spare it.

Read the chosen alpha for signal, too. If CV picks the smallest candidate, the data barely wanted regularization — suspect label noise or duplicated rows before celebrating. If it picks the largest (the tree collapses toward one leaf), the features carry almost no signal at that granularity, and no amount of pruning arithmetic will rescue them; the honest response is feature work or a different model class, not a tighter grid. A best alpha in the middle with a clear validation peak is the healthy case the technique exists for.

Pre-pruning versus post-pruning is a standard discussion question, so have a position ready. Pre-pruning is cheap (one tree, no extra passes), gives you interpretable size limits directly, and stops runaway depth early — but it is myopic: gain-zero halting can strand a tree before it reaches splits that would have paid off, and it interacts poorly with greedy search (the XOR trap). Post-pruning sees the full structure first, then trims by a principled cost that trades error against leaves, and empirically generalizes better — at the price of growing a possibly huge tree first and a CV loop over the alpha ladder. Many practitioners combine them: a generous max_depth/min_samples_leaf to bound the worst case, then ccp_alpha tuned by CV for the actual regularization.

Also fix these habits while the workflow is in front of you: set random_state everywhere so results reproduce; remember ccp_alpha interacts with class_weight on imbalanced data (weights change the impurity that pruning is pricing); and understand what happens next in the roadmap — a single pruned tree is a decent baseline, but the phase's remaining topics remove the single-tree ceiling. Random Forests starts there: average many decorrelated trees, and each tree's individual pruning errors cancel instead of compounding.

Quiz

1. A subtree with 3 leaves reduces training error from 10 to 6 relative to its pruned replacement (1 leaf). At what cost-complexity alpha does the subtree get cut?

Question 1 options

2. What does cost_complexity_pruning_path return, and how is ccp_alpha normally selected from it?

Question 2 options

3. Your CV curve for ccp_alpha shows training accuracy 1.000 and sagging validation accuracy at alpha = 0, and both scores converging downward at large alpha. What do the two ends indicate?

Question 3 options

4. Why is pre-pruning (max_depth, min_samples_leaf) not sufficient on its own?

Question 4 options

Flashcards

Question

List the main pre-pruning hyperparameters and say what each one stops.

Answer

max_depth caps path length; min_samples_split refuses to split nodes with fewer than N rows; min_samples_leaf forces every leaf to hold N rows (also smooths noise by averaging); max_leaf_nodes caps total leaves; max_features subsamples candidate features per split (speed/randomness). Defaults (None/2/1) mean unconstrained growth.

Question

State the cost-complexity objective and the break-even rule for pruning a subtree.

Answer

R_alpha(T) = R(T) + alpha·|T̃|, where R is weighted leaf impurity and |T̃| is the number of leaves. A subtree is cut when alpha exceeds (R(t) − R(T_t)) / (|T̃_t| − 1) — the error it saves divided by the extra leaves. alpha = 0 keeps the fully grown tree; alpha above the root cut leaves one leaf.

Question

Why does training score pick alpha = 0 every time, and where should alpha actually be chosen?

Answer

R_0 is minimized by the fully grown tree, so training accuracy is maximized at alpha = 0 by construction and carries zero information about generalization. Alpha must be chosen by cross-validation on training folds (GridSearchCV over cost_complexity_pruning_path's alphas), with the test set evaluated once after the choice is frozen.

Question

Compare pre-pruning and post-pruning: strengths, weaknesses, and the recommended combination.

Answer

Pre-pruning (depth/leaf limits) is cheap and gives readable size bounds but is myopic — zero-gain halting can strand the tree before profitable deeper splits (XOR). Post-pruning grows everything then trims by a principled error-vs-leaves cost and generalizes better, at the price of a full grow plus an alpha CV loop. Practice: generous max_depth/min_samples_leaf as a guardrail, then tune ccp_alpha by CV.

Question

What does ccp_alpha interact with in scikit-learn, and what limit does every pruning method share?

Answer

ccp_alpha prices the leaf impurity that class_weight and sample_weight change — so on imbalanced data, weights and pruning must be tuned together; alpha magnitudes are dataset-specific (impurity units), so never port a value between datasets. Shared limit: pruning only deletes structure — the result is a subtree of the fully grown tree, so a split the greedy search never considered cannot be recovered by any alpha.

Revision Notes

Key Takeaways

  • 1. Pre-pruning knobs (max_depth, min_samples_leaf, min_samples_split, max_leaf_nodes) bound growth cheaply — min_samples_leaf is the strongest single lever because averaging beats memorizing
  • 2. Stopping rules are greedy and myopic: a zero-gain split can hide a two-level payoff, so early halting alone can strand a tree at the XOR trap
  • 3. Cost-complexity pruning minimizes R(T) + alpha·|T~|; a subtree is cut when alpha exceeds the error it saves divided by its extra leaves (worked break-even: alpha* = 2)
  • 4. cost_complexity_pruning_path yields the whole weakest-link ladder; alpha is dataset-specific and must be chosen by CV on training folds with the test set evaluated once
  • 5. The CV curve has three regimes — memorized (alpha=0), sweet spot (validation peak), collapsed (large alpha) — and pruning can only delete structure the greedy search actually built

Interview Tips

  • • "Explain cost-complexity pruning" — write R_alpha(T) = R(T) + alpha|T~|, then do the break-even arithmetic on a 3-leaf subtree (saves 4 errors → cut above alpha = 2); calling it the tree's version of ridge/lasso frames it in language interviewers recognize
  • • "Pre-pruning or post-pruning?" — take a position: pre-pruning is cheap but myopic (give the XOR reason), post-pruning sees full structure then trims by cost; say you use a generous depth guardrail plus ccp_alpha from CV, which is what practitioners actually do
  • • "How do you pick ccp_alpha?" — path → GridSearchCV over alphas with stratified folds on training data → freeze → single test evaluation; explicitly refusing to read alpha off training score (maximized at 0) shows you understand the variance/bias curve
  • • "Why does my pruned tree still overfit?" — pruning only deletes; if greedy splits never captured the true structure, or class_weight/pruning are tuned out of sync on imbalanced data, no alpha recovers it — pivot to ensembles as the next line of defense

Cheat Sheet

Tree Pruning and Hyperparameters — Cheat Sheet

Pre-pruning (stop growth early)

Knob Effect Failure mode
max_depth caps path length too shallow → bias
min_samples_leaf every leaf holds N rows smooths noise; main anti-memorization lever
min_samples_split node needs N rows to split weak alone
max_leaf_nodes caps total leaves bounds size without fixing horizon
max_features per-split feature subsample speed/randomness only
Myopia trap: ~zero-gain splits can unlock deep payoffs (XOR) — early halting strands the tree.

Cost-complexity (grow then trim)

R_alpha(T) = R(T) + alpha·|T~|          # R = weighted leaf impurity, |T~| = leaves
cut subtree when alpha > (R(t) − R(T_t)) / (|T~_t| − 1)
worked: error 6 vs 10, 3 leaves vs 1  ->  alpha* = 4/2 = 2
alpha = 0 -> fully grown tree;  large alpha -> single majority leaf

Selection workflow

path = tree.cost_complexity_pruning_path(X_tr, y_tr)
GridSearchCV(tree, {"ccp_alpha": unique_alphas}, cv=StratifiedKFold(5, shuffle=True),
             scoring="f1").fit(X_tr, y_tr)   # test set touched ONCE at the end
  • CV curve: alpha=0 → train 1.0 / val sag (variance); large alpha → both fall to baseline (bias)
  • Training score can never select alpha (it is maximized at 0)
  • alpha units are dataset-specific (impurity-scaled); tune jointly with class_weight
  • Pruning only removes: result is a subtree of what the greedy search grew

Continue Learning

Before You Start

Next Topics