← All Posts
Machine Learning · Decision Trees · Handwritten Notes · Part 4

CART, Complexity & Pruning

The scan connects three ideas: a leaf’s prediction, an efficient search over thresholds, and the cost of keeping extra leaves. This companion works through each one with small, explicit datasets. The numeric examples are teaching additions; the original scan remains below.

Why a regression leaf predicts an average

For targets 2, 4, and 9, the mean is 5. Predicting 5 gives squared errors 9, 1, and 16, totaling 26. Why choose 5? For any constant prediction c, the error decomposes into a fixed part plus a nonnegative penalty:

$$\sum_{i=1}^{3}(y_i-c)^2=26+3(c-5)^2.$$

The minimum is therefore at c = 5. More generally, for rows S with target mean $\bar y_S$, the minimized total squared error is

$$L(S)=\sum_{i\in S}(y_i-\bar y_S)^2.$$

This statement assumes equal observation weights and squared-error loss. A different loss or weighting rule can change the optimal leaf prediction. If the split score uses mean squared error, weight each child by its row fraction. If it uses total squared error, add the child totals directly.

Compare two actual regression splits

Assign the three targets to ordered feature values: (x, y) = (1, 2), (2, 4), (3, 9). Test the rule x < t. Any threshold between consecutive feature values makes the same partition of these training rows; use midpoints 1.5 and 2.5.

Same three rows, different leaf predictions

No split

Targets: [2, 4, 9]

Predict 5 for every row.

SSE = 9 + 1 + 16 = 26

x < 1.5

Left [2] → predict 2

Right [4, 9] → predict 6.5

SSE = 0 + 6.25 + 6.25 = 12.5

x < 2.5

Left [2, 4] → predict 3

Right [9] → predict 9

SSE = 1 + 1 + 0 = 2

The best of these two candidate splits reduces training SSE by 24. It does not establish test performance. A minimum leaf size of two would make both candidates invalid, because each creates a one-row child.

Avoid rescanning every candidate’s rows

Now switch to classification to make the running counts visible. Let N be this node’s row count, D its feature count, and K the number of classes. For each feature, at most N − 1 boundaries can separate nonempty children. Recounting N labels at every boundary costs O(N²); scanning K class proportions for each score adds O(NK). A naive search over D features therefore costs O(D(N² + NK)), apart from smaller sorting work in the usual comparison-sort setting.

Sort a feature once in O(N log N). Start with every row on the right. Moving one row of class A increments left[A] and decrements right[A]; no other class counter changes. Those two counter updates take constant time. Scoring each candidate from all K counts costs O(K), giving O(D(N log N + NK)) for the node’s full search. This is a per-node calculation, not the cost of growing an entire tree.

Trace the nine legal boundaries

Use x = 1, 2, …, 10 and labels [A, A, B, A, B, B, A, B, B, B]. The parent has four A and six B rows, so its Gini impurity is 1 − 0.4² − 0.6² = 0.48. These values supply a reproducible companion example; they are not transcribed training data from the scan.

After moving k rows left, test t = k + 0.5 for k = 1 through 9. A child with class proportions pₐ and pᵦ has Gini 1 − pₐ² − pᵦ². Combine the child impurities with their row fractions:

$$G_{\rm split}=\frac{k}{10}G_L+\frac{10-k}{10}G_R,\qquad \Delta G=0.48-G_{\rm split}.$$

At k = 2, the left counts are (2, 0), so GL = 0. The right counts are (2, 6), so GR = 1 − (2/8)² − (6/8)² = 0.375. The weighted score is (2/10) × 0 + (8/10) × 0.375 = 0.3, giving a reduction of 0.18. Exhausting the nine candidates confirms that t = 2.5 is best.

Move one row; update one counter on each side

Rows are sorted by x. Read the cards left to right, then continue on the next line. “L” and “R” identify the current child.

x=1AL
x=2AL
x=3BR
x=4AR
x=5BR
x=6BR
x=7AR
x=8BR
x=9BR
x=10BR

Worked candidate: x < 2.5. The second A row has moved: left[A] +1, right[A] −1.

Left · 2 rows

A2
B0

Gini: 0.000000

Right · 8 rows

A2
B6

Gini: 0.375000

Count bars share the scale 0–6 rows. Green is class A; orange is class B. Bar length shows a count, not a class probability.

Weighted Gini: (2/10) × 0.000000 + (8/10) × 0.375000 = 0.300000
Reduction: 0.480000 − 0.300000 = 0.180000

Best after all nine candidates: x < 2.5, weighted Gini 0.300000.

Compare every legal candidate
Threshold tLeft A / BRight A / BWeighted GiniReduction
1.51 / 03 / 60.4000000.080000
2.52 / 02 / 60.3000000.180000
3.52 / 12 / 50.4190480.060952
4.53 / 11 / 50.3166670.163333
5.53 / 21 / 40.4000000.080000
6.53 / 31 / 30.4500000.030000
7.54 / 30 / 30.3428570.137143
8.54 / 40 / 20.4000000.080000
9.54 / 50 / 10.4444440.035556
State zero initializes the counters: no valid split exists because the left child is empty. It is not scored as a candidate. A full sweep stops after row 9 so the right child also stays nonempty. Without JavaScript, the complete best candidate and all nine scores remain visible.

For equal feature values, move the entire tied group before testing its boundary. Otherwise a proposed cut would pretend to separate identical values. The numeric midpoint must also be representable and reproduce the intended comparison; the threshold-search implementation note covers those edge cases and missing-value policy.

When K is fixed, sorting dominates the count-based node search. With many classes, Gini can avoid a full K-count scan by maintaining the sum of squared counts. Incrementing count c changes c² by 2c + 1; decrementing it changes c² by −2c + 1. This is an additional optimization, distinct from merely updating the counts.

For regression, the analogous running quantities are count n, target sum S, and squared-target sum Q. A nonempty child’s SSE is Q − S²/n. Moving one target updates all three quantities on each side. For targets [2, 4], n = 2, S = 6, Q = 20 gives SSE = 20 − 36/2 = 2. Direct subtraction can lose precision for very large, nearly equal targets; use suitable centering or stable numerical updates in a production implementation.

Give extra leaves a reason to stay

Return to the three-row regression example. Suppose the current tree is the two-leaf tree at t = 2.5. Compare keeping it with replacing its root by one leaf. Use the explicitly unnormalized cost R(T) + α times the number of leaves, where R is total training SSE and α ≥ 0:

$$C_{\rm keep}=2+2\alpha,\qquad C_{\rm prune}=26+\alpha.$$

Keeping the split is cheaper when α < 24. At α = 24 the costs tie; above 24, pruning is cheaper. For example, α = 10 gives 22 versus 36, while α = 30 gives 62 versus 56. This compares these two trees only; it does not claim to enumerate every possible larger tree.

Normalization changes the numerical cutoff. Dividing the training error by all three rows gives 2/3 + 2α versus 26/3 + α, with a tie at α = 8. Do not transfer “24” into a library’s pruning parameter without checking its impurity and sample-weight convention. scikit-learn’s pruning documentation defines its weighted leaf-impurity objective.

Depth limits and minimum leaf sizes restrict growth before a split is made. Post-pruning removes branches from an already grown tree. Choose these controls using validation data; a better penalized training score alone does not prove better generalization. The full pruning chapter develops the sequence of candidate subtrees.

Read the scan’s depth sketch as a tendency

A shallow tree may miss a useful boundary. A deep tree can represent finer patterns but may change substantially when its training sample changes. The scan’s training/test-error sketch illustrates that tradeoff; it is not an empirical curve for either toy dataset here. A deep tree need not overfit every dataset, and pruning cannot identify “noise” without an evaluation criterion.

Handwritten page 4: incremental left/right class counts, CART regression with squared error, candidate feature values, and a schematic training/test-error sketch.
Original handwritten page 4, preserved unchanged. Open the full-resolution scan. The scan switches between sums and an averaging convention; the examples above state which convention they use.
Reproduce all classification split scores in Python
labels = list("AABABBABBB")
left = {"A": 0, "B": 0}
right = {"A": labels.count("A"), "B": labels.count("B")}
def gini(counts):
    n = sum(counts.values())
    return 1 - sum((c/n)**2 for c in counts.values())

for k, label in enumerate(labels[:-1], start=1):
    left[label] += 1
    right[label] -= 1
    weighted = (k*gini(left) + (10-k)*gini(right)) / 10
    print(k + 0.5, left, right, round(weighted, 6),
          round(0.48 - weighted, 6))
# Minimum weighted Gini: 0.3 at threshold 2.5.

Each child is nonempty in this loop, so the impurity function never divides by zero. At initialization, display an empty-child state instead of treating it as a candidate.

Next, bagging combines predictions from separately fitted trees.

Sources and next steps