Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
One access-change request breaks the obvious rule. R3 has an ambiguity score of only 2.0, yet its label says it needed human review. Several higher-ambiguity requests also needed review, but R8 at 3.2 didn't. How can a model catch exceptions without sending every request to a human?
The previous lesson fitted a smooth review score over ambiguity and policy support, with a straight decision boundary. A decision tree asks yes/no questions instead. Each answer leads to another question or a leaf, which stores a prediction. Fit one split, then investigate whether extra branches or multiple trees handle R3 more reliably.
Keep the same fictional error costs: $120 for a missed required review and $18 for an unnecessary review. These aren't measured incident costs. Authorization rules still apply independently of the classifier. A tree's feature threshold chooses a branch; a separate operating threshold turns its final score into a routing action.

The same eight requests, now as rectangles
Keep the same two evidence scores from the logistic example. ambiguity rises when request text and retrieved evidence conflict. auto_policy_support rises when policy P-7 backs automatic handling. needs_review is 1 when a human had to approve the permission change.
In this small synthetic dataset, each row is one request. Which single feature cut would leave the fewest mixed labels?
| Request | ambiguity | auto_policy_support | needs_review |
|---|---|---|---|
| R1 | 1.0 | 1 | 0 |
| R2 | 1.5 | 2 | 0 |
| R3 | 2.0 | 3 | 1 |
| R4 | 3.5 | 6 | 1 |
| R5 | 4.0 | 7 | 1 |
| R6 | 4.5 | 5 | 1 |
| R7 | 2.8 | 4 | 0 |
| R8 | 3.2 | 8 | 0 |
Four requests needed review and four didn't. Ambiguity looks promising because R4, R5, and R6 are high, but R3 and R8 are counterexamples. Keep eight separate constructed requests frozen for validation, using the same train / validation split as logistic regression. They represent a later batch in the story; their results aren't evidence about real traffic.
A decision tree splits on one feature at a time using an orthogonal, axis-aligned cut (). In our two-feature plane, each split draws a strictly horizontal or vertical line, partitioning the feature space into hyper-rectangles.
This geometry has a huge advantage and a subtle trap:
- Ordering rather than distance: An exact numeric split searches gaps between distinct values. Strictly increasing transformations preserve those candidate training partitions, so ordinary feature standardization isn't required. They don't necessarily preserve predictions inside unseen gaps. Ties, quantization, finite precision, and approximate split-search implementations also need checking.
- Diagonal boundary failure: If the true policy boundary is diagonal (say, ), a single linear model draws it with one equation. An axis-aligned tree must approximate that diagonal using a jagged staircase of many orthogonal cuts, requiring deeper trees and fragmenting training data into tiny leaves.
Impurity scores how mixed a leaf is
Four of the eight requests needed review. Before choosing a cut, we need a mathematical score that measures how mixed or pure a leaf is: all automatic, all review, or at least less mixed than the parent.
For a leaf with classes where class has proportion , Gini impurity measures the probability of misclassifying a randomly chosen sample if it were labeled randomly according to the leaf's class distribution:
For binary review routing with review proportion , this simplifies to:
A pure leaf ( or ) has . A leaf split evenly between classes () has . Gini is a fast, bounded measure of label disagreement.
An alternative criterion from information theory is Shannon entropy[1]:
For our binary case with base-two logarithms, entropy measures uncertainty in bits:
Treat a zero-probability term as zero using . An evenly mixed binary leaf has bit; a pure leaf has . Both criteria prefer purer children, but their weighted rankings can differ. Entropy includes logarithms; actual fitting speed also depends on the implementation and data.
What if the target is continuous (such as handling duration in minutes) rather than binary? In regression trees, CART minimizes squared error through variance reduction:
where each leaf predicts the regional mean . Minimizing child variance is mathematically equivalent to maximizing the reduction in Mean Squared Error (MSE).
For classification, a split's impurity decrease is parent impurity minus weighted child impurity. When entropy is the metric, that decrease is information gain. When Gini is the metric, it's Gini gain. We'll use Gini gain because the arithmetic stays clean and fast.
At our training root, four of eight rows need review:
Now test the tempting cut ambiguity <= 3.35. Predict the outcome before checking the arithmetic: the three high-ambiguity rows (R4, R5, R6) are all reviews, so only the low side remains mixed.
- Left labels
[0, 0, 1, 0, 0], so and . - Right labels
[1, 1, 1], so and . - Weighted child impurity: .
- Gini gain: .
The cut removes most label disagreement, but not all of it. R3 remains a required-review request inside the mostly automatic leaf.
1from math import log2
2
3TRAIN_X = [
4 (1.0, 1.0),
5 (1.5, 2.0),
6 (2.0, 3.0),
7 (3.5, 6.0),
8 (4.0, 7.0),
9 (4.5, 5.0),
10 (2.8, 4.0),
11 (3.2, 8.0),
12]
13TRAIN_Y = [0, 0, 1, 1, 1, 1, 0, 0]
14VALID_X = [
15 (1.2, 2.0),
16 (2.1, 4.0),
17 (2.4, 3.0),
18 (3.0, 5.0),
19 (3.6, 4.0),
20 (3.2, 7.0),
21 (4.2, 5.0),
22 (2.8, 6.0),
23]
24VALID_Y = [0, 0, 1, 0, 1, 0, 1, 1]
25NAMES = ["ambiguity", "auto_policy_support"]
26
27def gini(labels):
28 n = len(labels)
29 if n == 0:
30 return 0.0
31 p = sum(labels) / n
32 return 1.0 - p * p - (1.0 - p) * (1.0 - p)
33
34def entropy(labels):
35 n = len(labels)
36 if n == 0:
37 return 0.0
38 p = sum(labels) / n
39 terms = []
40 for q in (p, 1.0 - p):
41 if q > 0:
42 terms.append(-q * log2(q))
43 return sum(terms)
44
45def midpoints(values):
46 unique = sorted(set(values))
47 return [(a + b) / 2 for a, b in zip(unique, unique[1:])]
48
49root = TRAIN_Y
50left = [y for x, y in zip(TRAIN_X, TRAIN_Y) if x[0] <= 3.35]
51right = [y for x, y in zip(TRAIN_X, TRAIN_Y) if x[0] > 3.35]
52weighted = (len(left) * gini(left) + len(right) * gini(right)) / len(root)
53
54print(f"root gini={gini(root):.3f} entropy={entropy(root):.3f}")
55print(f"left gini={gini(left):.3f} right gini={gini(right):.3f}")
56print(f"weighted={weighted:.3f} gain={gini(root) - weighted:.3f}")1root gini=0.500 entropy=1.000
2left gini=0.320 right gini=0.000
3weighted=0.200 gain=0.300What are the Gini impurities of a pure binary leaf and a leaf split 50/50 between classes?
Answer
The pure leaf has G = 0. The evenly mixed leaf has G = 0.5. A useful split lowers the weighted child impurity relative to the parent.
Two criteria can prefer different cuts
Use a separate 24-row counterexample with twelve labels of each class. Candidate A isolates four negatives; candidate B produces two equally sized, moderately pure leaves. Which kind of purity does each criterion reward more?
1parent = [0] * 12 + [1] * 12
2partitions = {
3 "A": ([0] * 4, [0] * 8 + [1] * 12),
4 "B": ([0] * 3 + [1] * 9, [0] * 9 + [1] * 3),
5}
6
7for name, children in partitions.items():
8 gains = []
9 for criterion in (gini, entropy):
10 weighted = sum(len(child) * criterion(child) for child in children) / len(parent)
11 gains.append(criterion(parent) - weighted)
12 print(f"{name}: Gini gain={gains[0]:.4f}; entropy gain={gains[1]:.4f}")1A: Gini gain=0.1000; entropy gain=0.1909
2B: Gini gain=0.1250; entropy gain=0.1887Gini prefers B; entropy prefers A. Neither result declares a better model on unseen requests. Return to our eight-row table and search its actual feature gaps.
A stump tests midpoints
A decision stump is a tree with one split. To find its rule, sort one feature, place a candidate between each pair of adjacent values, and score each partition by impurity decrease. The winner is the feature-threshold pair that leaves the children most uniform.
For ambiguity, 3.35 sits between 3.2 and 3.5, separating R4-R6 from the lower-ambiguity rows. With a <= rule, any threshold from 3.2 inclusive to 3.5 exclusive makes the same training partition. A midpoint is a convenient representative, not a requirement for correctness. Only gaps between distinct values matter: equal values can't be separated by this feature.
1def split_gain(values, labels, threshold):
2 left = [y for x, y in zip(values, labels) if x <= threshold]
3 right = [y for x, y in zip(values, labels) if x > threshold]
4 weighted = (len(left) * gini(left) + len(right) * gini(right)) / len(labels)
5 return gini(labels) - weighted
6
7for column, name in enumerate(NAMES):
8 values = [row[column] for row in TRAIN_X]
9 scored = [(threshold, split_gain(values, TRAIN_Y, threshold)) for threshold in midpoints(values)]
10 best_threshold, best_gain = max(scored, key=lambda item: item[1])
11 print(f"{name:20} best threshold={best_threshold:.2f} gain={best_gain:.3f}")1ambiguity best threshold=3.35 gain=0.300
2auto_policy_support best threshold=2.50 gain=0.167The search selects ambiguity <= 3.35. Its left leaf reports the observed review rate, ; its right leaf reports . The number in each leaf comes from labels that reached that leaf, not from a smooth curve.

A score of 0.20 isn't automatically "safe." The routing threshold still has to price a missed review against an extra queue item, as it did for logistic regression.
Why does a numeric stump test thresholds between adjacent observed values rather than directly on every value?
Answer
A midpoint represents a distinct training partition. With a <= comparison, using the lower observed value also produces that partition. Midpoints avoid choosing an observed endpoint, but don't confer extra predictive power.
Fit the stump
Read the next cell as the stump's complete loop: scan every feature, try every midpoint, keep the largest gain, and store the two leaf rates. Then use that stored rule to score the training rows.
1def fit_stump(rows, labels):
2 root_impurity = gini(labels)
3 best = None
4 n_features = len(rows[0])
5 for feature in range(n_features):
6 values = [row[feature] for row in rows]
7 for threshold in midpoints(values):
8 left = [y for row, y in zip(rows, labels) if row[feature] <= threshold]
9 right = [y for row, y in zip(rows, labels) if row[feature] > threshold]
10 weighted = (len(left) * gini(left) + len(right) * gini(right)) / len(labels)
11 gain = root_impurity - weighted
12 if best is None or gain > best["gain"]:
13 best = {
14 "feature": feature,
15 "threshold": threshold,
16 "gain": gain,
17 "left_probability": sum(left) / len(left),
18 "right_probability": sum(right) / len(right),
19 }
20 return best
21
22def predict_proba(stump, rows):
23 feature = stump["feature"]
24 threshold = stump["threshold"]
25 scores = []
26 for row in rows:
27 if row[feature] <= threshold:
28 scores.append(stump["left_probability"])
29 else:
30 scores.append(stump["right_probability"])
31 return scores
32
33stump = fit_stump(TRAIN_X, TRAIN_Y)
34scores = predict_proba(stump, TRAIN_X)
35predictions = [int(score >= 0.50) for score in scores]
36correct = sum(pred == label for pred, label in zip(predictions, TRAIN_Y))
37
38print(
39 f"rule: {NAMES[stump['feature']]} <= {stump['threshold']:.2f}; "
40 f"gain={stump['gain']:.3f}"
41)
42print(
43 f"leaf probabilities: left={stump['left_probability']:.2f}, "
44 f"right={stump['right_probability']:.2f}"
45)
46print("training predictions:", predictions)
47print(f"training accuracy={correct / len(TRAIN_Y):.3f}")1rule: ambiguity <= 3.35; gain=0.300
2leaf probabilities: left=0.20, right=1.00
3training predictions: [0, 0, 0, 1, 1, 1, 0, 0]
4training accuracy=0.875The returned scores make the tradeoff visible. At routing threshold 0.50, the stump misses R3. A deeper tree could repair this training row, but that repair is a hypothesis about later requests, not evidence that the branch will transfer. This minimal implementation assumes nonempty numeric data with at least one varying feature; a general estimator must also handle constant features, missing values, and invalid inputs.
The simple search rescans labels for every threshold. With features and up to cuts per feature, that costs roughly plus sorting. Efficient exact implementations sort and update cumulative class counts; histogram methods search a smaller set of bins. Don't extrapolate this eight-row trainer's performance to large datasets.
Multiplying ambiguity by ten preserves this stump's partitions and rescales its midpoint. A nonlinear transform gives a subtler result. Take two training values, 1 and 4. The original midpoint is 2.5; after squaring, it's 8.5. Which side receives an unseen value of 2.8?
1training = [1.0, 4.0]
2original_cut = sum(training) / 2
3transformed_cut = sum(value**2 for value in training) / 2
4unseen = 2.8
5
6print("original training sides:", [value <= original_cut for value in training])
7print("squared training sides:", [value**2 <= transformed_cut for value in training])
8print("unseen 2.8 goes left originally:", unseen <= original_cut)
9print("unseen 2.8 goes left after squaring:", unseen**2 <= transformed_cut)1original training sides: [True, False]
2squared training sides: [True, False]
3unseen 2.8 goes left originally: False
4unseen 2.8 goes left after squaring: TrueThe training partition is identical; the unseen route changes. This counterexample concerns the chosen midpoint, not a new learned relationship. Scikit-learn also converts tree inputs to float32 internally, so extreme scaling can collapse distinct representable values.[2]
Held-out cost still picks the threshold
Freeze the fitted stump before looking at the later labels. These eight requests didn't choose its split and therefore give the first check on transfer:
| Request | ambiguity | auto_policy_support | needs_review |
|---|---|---|---|
| V1 | 1.2 | 2 | 0 |
| V2 | 2.1 | 4 | 0 |
| V3 | 2.4 | 3 | 1 |
| V4 | 3.0 | 5 | 0 |
| V5 | 3.6 | 4 | 1 |
| V6 | 3.2 | 7 | 0 |
| V7 | 4.2 | 5 | 1 |
| V8 | 2.8 | 6 | 1 |
Use the same operational costs as logistic regression:
| Error | Routing mistake | Cost |
|---|---|---|
| False negative | Automatically approve an access change that needed human review. | $120 |
| False positive | Review a request whose label says review wasn't required. | $18 |
A 0.50 threshold doesn't minimize cost by definition. Before running the cell, predict which threshold wins when a missed review costs almost seven times an unnecessary review. Compare candidates on validation, then confirm the selected policy on a fresh test period before shipping.
1def metrics(y_true, y_pred):
2 tp = sum(yt == 1 and yp == 1 for yt, yp in zip(y_true, y_pred))
3 fp = sum(yt == 0 and yp == 1 for yt, yp in zip(y_true, y_pred))
4 fn = sum(yt == 1 and yp == 0 for yt, yp in zip(y_true, y_pred))
5 precision = tp / (tp + fp) if tp + fp else 0.0
6 recall = tp / (tp + fn) if tp + fn else 0.0
7 f1 = 2 * precision * recall / (precision + recall) if precision + recall else 0.0
8 cost = 18 * fp + 120 * fn
9 return precision, recall, f1, cost, fp, fn
10
11probabilities = predict_proba(stump, VALID_X)
12for threshold in (0.20, 0.50):
13 predicted = [int(score >= threshold) for score in probabilities]
14 precision, recall, f1, cost, fp, fn = metrics(VALID_Y, predicted)
15 print(
16 f"threshold={threshold:.2f} precision={precision:.3f} "
17 f"recall={recall:.3f} f1={f1:.3f} fp={fp} fn={fn} cost=${cost}"
18 )1threshold=0.20 precision=0.500 recall=1.000 f1=0.667 fp=4 fn=0 cost=$72
2threshold=0.50 precision=1.000 recall=0.500 f1=0.667 fp=0 fn=2 cost=$240Both thresholds tie on F1 here, but they make different mistakes. At 0.20, four false positives cost $72; at 0.50, two false negatives cost $240. The lower threshold buys recall with queue work, which is cheaper on this batch.
Extra depth can memorize R3
A one-split tree is easy to audit and can underfit. An unconstrained tree can isolate each of these distinct training inputs; conflicting labels at identical inputs couldn't be separated. Extra depth becomes overfitting when branches capture quirks that don't transfer. Before growing it, predict the two scores: training accuracy should rise, but V2 may inherit R3's exception leaf.
Grow the same Gini tree until every leaf is pure:
1def fit_tree(rows, labels, max_depth, min_leaf=1, rng=None, mtry=None, depth=0):
2 n = len(labels)
3 p = sum(labels) / n if n else 0.0
4 n_features = len(rows[0]) if rows else 0
5 if depth >= max_depth or n < 2 * min_leaf or len(set(labels)) == 1 or n_features == 0:
6 return {"leaf": True, "p": p, "n": n}
7
8 features = list(range(n_features))
9 if rng is not None and mtry is not None:
10 rng.shuffle(features)
11 features = features[:mtry]
12
13 best = None
14 for feature in features:
15 values = [row[feature] for row in rows]
16 for threshold in midpoints(values):
17 left_idx = [i for i, row in enumerate(rows) if row[feature] <= threshold]
18 left_set = set(left_idx)
19 right_idx = [i for i in range(n) if i not in left_set]
20 if len(left_idx) < min_leaf or len(right_idx) < min_leaf:
21 continue
22 left_y = [labels[i] for i in left_idx]
23 right_y = [labels[i] for i in right_idx]
24 gain = gini(labels) - (
25 len(left_y) * gini(left_y) + len(right_y) * gini(right_y)
26 ) / n
27 if best is None or gain > best["gain"]:
28 best = {
29 "feat": feature,
30 "threshold": threshold,
31 "gain": gain,
32 "left_idx": left_idx,
33 "right_idx": right_idx,
34 }
35 if best is None:
36 return {"leaf": True, "p": p, "n": n}
37
38 left_rows = [rows[i] for i in best["left_idx"]]
39 right_rows = [rows[i] for i in best["right_idx"]]
40 left_y = [labels[i] for i in best["left_idx"]]
41 right_y = [labels[i] for i in best["right_idx"]]
42 return {
43 "leaf": False,
44 "feat": best["feat"],
45 "threshold": best["threshold"],
46 "gain": best["gain"],
47 "left": fit_tree(left_rows, left_y, max_depth, min_leaf, rng, mtry, depth + 1),
48 "right": fit_tree(right_rows, right_y, max_depth, min_leaf, rng, mtry, depth + 1),
49 }
50
51def predict_one(tree, row):
52 node = tree
53 while not node["leaf"]:
54 if row[node["feat"]] <= node["threshold"]:
55 node = node["left"]
56 else:
57 node = node["right"]
58 return node["p"]
59
60def report(name, tree):
61 train_scores = [predict_one(tree, row) for row in TRAIN_X]
62 valid_scores = [predict_one(tree, row) for row in VALID_X]
63 train_pred = [int(score >= 0.50) for score in train_scores]
64 valid_pred = [int(score >= 0.50) for score in valid_scores]
65 train_acc = sum(pred == y for pred, y in zip(train_pred, TRAIN_Y)) / len(TRAIN_Y)
66 _, _, f1, cost, fp, fn = metrics(VALID_Y, valid_pred)
67 print(
68 f"{name}: train_accuracy={train_acc:.3f} val_f1={f1:.3f} "
69 f"fp={fp} fn={fn} cost=${cost}"
70 )
71 print(f"{name} val scores: {[round(score, 2) for score in valid_scores]}")
72
73stump_tree = fit_tree(TRAIN_X, TRAIN_Y, max_depth=1)
74deep_tree = fit_tree(TRAIN_X, TRAIN_Y, max_depth=8)
75report("stump", stump_tree)
76report("deep tree", deep_tree)1stump: train_accuracy=0.875 val_f1=0.667 fp=0 fn=2 cost=$240
2stump val scores: [0.2, 0.2, 0.2, 0.2, 1.0, 0.2, 1.0, 0.2]
3deep tree: train_accuracy=1.000 val_f1=0.750 fp=1 fn=1 cost=$138
4deep tree val scores: [0.0, 1.0, 1.0, 0.0, 1.0, 0.0, 1.0, 0.0]
The extra splits isolate R3 between ambiguity 1.75 and 2.40. That fits one training exception; whether the interval captures a reusable rule requires more evidence.
V2 has ambiguity 2.1 and label 0, so it inherits the review leaf and incurs an $18 false-positive cost. V8 has ambiguity 2.8 and label 1, so it falls into the R7/R8 automatic leaf and incurs a $120 false-negative cost.
Training accuracy reaches 1.000. At the same operating threshold 0.50, validation cost actually falls from the stump's $240 to $138; perfect training fit doesn't automatically imply worse held-out performance. The deep tree still costs more than the stump's $72 at threshold 0.20. Greedy, hierarchical splits can be unstable under changes to training data. Tune depth, minimum leaf size, and pruning with representative validation or cross-validation, then verify the selected policy independently.[3][4]
Forests average perturbed trees
Bagging (bootstrap aggregating) attacks that high variance by fitting many trees independently in parallel. Each tree receives a bootstrap sample: draw rows from our -row table with replacement. Some requests repeat; others are left out entirely.
At a fixed input , consider identically distributed tree scores across training-data and tree-building randomness. Assume each has variance and every distinct pair has correlation . Summing their variances and covariances gives:
As grows, the second term shrinks; the shared variation remains for this assumed building procedure. If , averaging doesn't reduce variance. This floor isn't irreducible outcome noise or a formula for classification error. Bootstrap randomizations can be independent conditional on one fixed training set while the fitted scores remain correlated across shared random training sets.
Random forests perturb both rows and features to encourage useful disagreement between members.[5]
- Bootstrap sampling: Each tree sees a different perturbed slice of data.
- Feature subsampling: At each split, restrict the candidate features. This can reduce dependence on a dominant predictor, but may also weaken individual trees; tune the tradeoff. In scikit-learn 1.9.1, RandomForestClassifier defaults to
max_features="sqrt", while RandomForestRegressor defaults to1.0, considering all features. The older regression rule is a heuristic, not that library's current default.[6]
Bootstrap sampling also provides a built-in validation mechanism: Out-of-Bag (OOB) validation. For a dataset of size , the probability that a specific row isn't selected in a bootstrap draw of size is:
The 36.8% value is a large- limit; for our eight rows the expected fraction is about 34.4%. Score each row using only trees whose bootstrap omitted it. OOB evaluation can estimate same-distribution performance without a separate fitting holdout, but isn't automatically unbiased: each row uses fewer trees, and a small forest may leave some rows without any OOB prediction. Breiman explicitly discusses the finite-forest effect.[5] Ordinary row bootstrapping also doesn't isolate a future period or related requests. Tuning against OOB scores still requires independent verification of the selected policy.
Here we average leaf class-frequency scores, as scikit-learn's random-forest classifier does.[7] Breiman's original classifier used hard majority voting. Averages preserve more score detail than hard votes; neither makes the probabilities calibrated automatically.
With two features, use one random feature per split (mtry = 1) to make that disagreement obvious. Predict R3's forest score before running the cell: if two trees never draw R3 and one tree over-samples it, the mean should land around 0.33, rather than becoming a hard majority vote of 0.

1import random
2from statistics import mean
3
4rng = random.Random(0)
5forest = []
6bootstrap_members = []
7for tree_index in range(3):
8 sample_idx = [rng.randrange(len(TRAIN_X)) for _ in range(len(TRAIN_X))]
9 sampled_rows = [TRAIN_X[i] for i in sample_idx]
10 sampled_labels = [TRAIN_Y[i] for i in sample_idx]
11 tree = fit_tree(sampled_rows, sampled_labels, max_depth=2, rng=rng, mtry=1)
12 forest.append(tree)
13 bootstrap_members.append(set(sample_idx))
14 r3_score = predict_one(tree, TRAIN_X[2])
15 feat = NAMES[tree["feat"]] if not tree["leaf"] else "leaf"
16 threshold = tree.get("threshold", float("nan"))
17 print(
18 f"tree {tree_index + 1}: {feat} <= {threshold:.2f}; "
19 f"bootstrap={sample_idx}; R3 p={r3_score:.2f}"
20 )
21
22r3_mean = mean(predict_one(tree, TRAIN_X[2]) for tree in forest)
23valid_scores = [mean(predict_one(tree, row) for tree in forest) for row in VALID_X]
24print(f"R3 forest mean={r3_mean:.2f}")
25print("validation probabilities:", [round(score, 3) for score in valid_scores])
26for threshold in (0.20, 0.50):
27 predicted = [int(score >= threshold) for score in valid_scores]
28 _, _, f1, cost, fp, fn = metrics(VALID_Y, predicted)
29 print(f"threshold={threshold:.2f} f1={f1:.3f} fp={fp} fn={fn} cost=${cost}")
30
31r3_oob = [
32 predict_one(tree, TRAIN_X[2])
33 for tree, members in zip(forest, bootstrap_members)
34 if 2 not in members
35]
36print("R3 OOB trees:", len(r3_oob), "mean score:", round(mean(r3_oob), 2))
37print("expected OOB fraction at N=8:", round((1 - 1 / 8)**8, 4))1tree 1: ambiguity <= 3.60; bootstrap=[6, 6, 0, 4, 7, 6, 4, 7]; R3 p=0.00
2tree 2: auto_policy_support <= 2.50; bootstrap=[3, 2, 4, 2, 1, 4, 2, 4]; R3 p=1.00
3tree 3: ambiguity <= 3.35; bootstrap=[1, 5, 7, 1, 5, 6, 5, 3]; R3 p=0.00
4R3 forest mean=0.33
5validation probabilities: [0.0, 0.333, 0.333, 0.333, 0.667, 0.333, 1.0, 0.333]
6threshold=0.20 f1=0.727 fp=3 fn=0 cost=$54
7threshold=0.50 f1=0.667 fp=0 fn=2 cost=$240
8R3 OOB trees: 2 mean score: 0.0
9expected OOB fraction at N=8: 0.3436Tree 1 never draws R3, so its low-ambiguity leaf is pure automatic and scores R3 as 0.00. Tree 2 over-samples R3 and splits on policy support, scoring it as 1.00. Tree 3 also drops R3.
The mean 0.33 is the forest's review score for that exception: two trees never saw it, and one tree treated it as a review case. It's an average of tree scores, not a new label.
Our toy considers exactly the sampled features at each node and stops if none yields a valid partition. Library split search can inspect additional features to find a valid partition. This demonstrates row/feature perturbation and averaging; it isn't a full clone of RandomForestClassifier.
Validation check: On validation, threshold
0.20costs$54. V1's score is0.00, a true negative, while the other low-ambiguity rows go to review. That is a real improvement on this slice, not a production bake-off. Eight rows plus three trees demonstrate the mechanism.
R3's OOB score is 0.00, using only trees 1 and 3; tree 2 must be excluded because its bootstrap contains R3. The full-forest mean 0.33 isn't an OOB score. With eight rows and three trees, neither supports a release estimate.
More trees stabilize the average of this tree-building procedure; they don't repair a bad feature set or a biased dataset. Individual forest trees often fit training data closely, and their average can still generalize well. Select depth and leaf size using validation rather than assuming that deeper members necessarily worsen the ensemble.
The forest emits more varied scores than the stump. Its threshold still belongs to the routing policy and must be evaluated with the same costs.
Boosting fits the leftover error
Gradient boosting also combines trees, but its trees take turns sequentially. Instead of fitting independent trees on perturbed data in parallel, each new tree learns to correct the leftover mistakes of the existing ensemble.
In standard gradient descent, we update model parameter weights in the direction of negative parameter gradients: . In gradient boosting, we perform gradient descent in function space[8]. Our "parameters" are the predicted outputs at each training example.
To minimize an arbitrary differentiable loss function , the steepest descent step is the negative gradient of the loss with respect to the current predictions:
We call a pseudo-residual.
For squared-error regression , the negative gradient is simply the familiar arithmetic error:
The target for each new tree is literally the residual error .
Before returning to binary review routing, predict one correction step on continuous handling time. For ambiguity values [1, 2, 5] and handling times [10, 20, 60], the initial prediction is their mean, 30. The first two rows need downward corrections, while the last needs a large upward correction.
1from statistics import mean
2
3ambiguity = [1.0, 2.0, 5.0]
4minutes = [10.0, 20.0, 60.0]
5prediction = [mean(minutes)] * len(minutes)
6residual = [target - current for target, current in zip(minutes, prediction)]
7
8threshold = 3.5
9left = [value for value, flag in zip(residual, ambiguity) if flag <= threshold]
10right = [value for value, flag in zip(residual, ambiguity) if flag > threshold]
11correction = [mean(left) if value <= threshold else mean(right) for value in ambiguity]
12learning_rate = 0.10
13updated = [current + learning_rate * delta for current, delta in zip(prediction, correction)]
14
15print("base prediction:", [round(value, 1) for value in prediction])
16print("residual targets:", [round(value, 1) for value in residual])
17print("stump correction:", [round(value, 1) for value in correction])
18print("updated prediction:", [round(value, 1) for value in updated])1base prediction: [30.0, 30.0, 30.0]
2residual targets: [-20.0, -10.0, 30.0]
3stump correction: [-15.0, -15.0, 30.0]
4updated prediction: [28.5, 28.5, 33.0]
Why scale the update by learning rate (also known as shrinkage) ?
At , this step gives [15, 15, 60]: it fits the last row exactly but still misses the first two. Shrinkage scales each added correction and changes the learning path; it isn't the explicit leaf penalty introduced below. Smaller rates often need more rounds, and don't guarantee diverse trees or prevent noise fitting. Tune the rate together with tree complexity and the stopping round on appropriate validation data.[6]
The next cell repeats that update four times. Watch whether each residual stump keeps the same threshold and whether training MSE falls:
1from statistics import mean
2
3def residual_stump(values, residual):
4 unique = sorted(set(values))
5 candidates = [(a + b) / 2 for a, b in zip(unique, unique[1:])]
6 best = None
7 for threshold in candidates:
8 left = [res for value, res in zip(values, residual) if value <= threshold]
9 right = [res for value, res in zip(values, residual) if value > threshold]
10 prediction = [mean(left) if value <= threshold else mean(right) for value in values]
11 mse = mean((res - pred) ** 2 for res, pred in zip(residual, prediction))
12 if best is None or mse < best["error"]:
13 best = {"threshold": threshold, "prediction": prediction, "error": mse}
14 return best
15
16x = [1.0, 2.0, 5.0]
17y = [10.0, 20.0, 60.0]
18prediction = [mean(y)] * len(y)
19learning_rate = 0.10
20
21print(
22 f"round=0 predictions={[round(value, 2) for value in prediction]} "
23 f"mse={mean((target - pred) ** 2 for target, pred in zip(y, prediction)):.2f}"
24)
25for round_number in range(1, 5):
26 residual = [target - pred for target, pred in zip(y, prediction)]
27 stump = residual_stump(x, residual)
28 prediction = [
29 current + learning_rate * delta
30 for current, delta in zip(prediction, stump["prediction"])
31 ]
32 mse = mean((target - pred) ** 2 for target, pred in zip(y, prediction))
33 print(
34 f"round={round_number} threshold={stump['threshold']:.2f} "
35 f"predictions={[round(value, 2) for value in prediction]} mse={mse:.2f}"
36 )1round=0 predictions=[30.0, 30.0, 30.0] mse=466.67
2round=1 threshold=3.50 predictions=[28.5, 28.5, 33.0] mse=381.17
3round=2 threshold=3.50 predictions=[27.15, 27.15, 35.7] mse=311.91
4round=3 threshold=3.50 predictions=[25.93, 25.93, 38.13] mse=255.82
5round=4 threshold=3.50 predictions=[24.84, 24.84, 40.32] mse=210.38What is the structural difference between a random forest and gradient boosting?
Answer
A forest fits perturbed trees independently and averages their scores. Boosting fits trees sequentially, with each new tree correcting errors left by the current ensemble.
Classification boosting uses y minus p
For access routing, we need probabilities rather than handling minutes. Let be the current additive logit score, and let be the review probability.
For a single request with true label , the binary cross-entropy loss is:
Differentiating with respect to the logit using the chain rule ():
The pseudo-residual (the negative gradient) is therefore:
When and the current probability is low (like ), the residual is positive (), asking for a higher logit. When and , the residual is negative (), asking for a lower logit.
For an approximate Newton leaf update, also compute the second derivative:
Our gradient-boosting fixture fits split regions by squared error on , then takes one Newton step in each fixed leaf:[8]
Start at , the best constant unpenalized logit for these balanced training labels. In general, a constant baseline uses the training positive fraction through when . Here and every curvature term is ; the residual stump splits at ambiguity <= 3.35.
- Left leaf ( rows: R1, R2, R3, R7, R8): One review () and four automatic (). The residual sum is .
- Right leaf ( rows: R4, R5, R6): Three reviews (). The residual sum is .
With learning rate , the updated logits become on the left and on the right. The corresponding probabilities shift to and . Notice the contrast with an isolated decision stump: boosting doesn't overwrite predictions with extreme 0.20 and 1.00 probabilities. It nudges the ensemble in the direction of the gradient.
The Newton values aren't exact minimizers of the original log loss. Starting at zero, the left leaf's exact constant optimum would be , not . The all-positive right leaf has no finite unpenalized optimum, despite its finite first Newton step of . A quadratic approximation, a step-size choice, and an exact optimum are different objects.
XGBoost integrates gradient and curvature statistics directly into its split score, rather than selecting the tree solely by residual squared error.[9][10] For this binary log-loss objective, its second-order approximation is:
where is the gradient, is the Hessian, is the number of leaves, and is an leaf regularizer.
Minimizing that quadratic approximation for a fixed tree gives:
With and positive summed curvature, this is the same Newton step. Positive shrinks its magnitude for a fixed numerator and curvature; it doesn't guarantee calibration or exclude extreme predictions. Define as summed gradients and as summed Hessians. The proposed split's improvement, including the extra-leaf charge, is:
For our first stump with , the improvement before the charge is 2.4; a charge greater than 2.4 makes this proposal unfavorable. Once the charge has been subtracted, compare gain with zero, not with the charge again. Actual growth also obeys estimator constraints such as depth and minimum child weight.[10]
Predict the validation consequence before running the four-round boosting code. The probability loss should fall, but if V3 and V8 stay on the low-ambiguity side, threshold 0.50 will still miss them.
1from math import exp, log1p
2from statistics import mean
3
4def sigmoid(logit):
5 if logit >= 0:
6 return 1.0 / (1.0 + exp(-logit))
7 exponential = exp(logit)
8 return exponential / (1.0 + exponential)
9
10def log_loss_from_logits(labels, logits):
11 terms = []
12 for label, logit in zip(labels, logits):
13 signed_error = (1 - 2 * label) * logit
14 terms.append(max(signed_error, 0.0) + log1p(exp(-abs(signed_error))))
15 return mean(terms)
16
17def fit_residual_stump(rows, residual):
18 best = None
19 n_features = len(rows[0])
20 for feature in range(n_features):
21 values = [row[feature] for row in rows]
22 for threshold in midpoints(values):
23 left_idx = [i for i, row in enumerate(rows) if row[feature] <= threshold]
24 left_set = set(left_idx)
25 right_idx = [i for i in range(len(rows)) if i not in left_set]
26 left_mean = mean(residual[i] for i in left_idx)
27 right_mean = mean(residual[i] for i in right_idx)
28 prediction = [
29 left_mean if row[feature] <= threshold else right_mean for row in rows
30 ]
31 mse = mean((res - pred) ** 2 for res, pred in zip(residual, prediction))
32 if best is None or mse < best["mse"]:
33 best = {
34 "feature": feature,
35 "threshold": threshold,
36 "left_idx": left_idx,
37 "right_idx": right_idx,
38 "mse": mse,
39 }
40 return best
41
42def newton_value(idxs, residual, hessian, leaf_l2=0.0):
43 if leaf_l2 < 0:
44 raise ValueError("leaf_l2 must be nonnegative")
45 numerator = sum(residual[i] for i in idxs)
46 denominator = sum(hessian[i] for i in idxs) + leaf_l2
47 if denominator <= 0:
48 raise ValueError("nonpositive leaf curvature")
49 return numerator / denominator
50
51logits = [0.0] * len(TRAIN_Y)
52stumps = []
53learning_rate = 0.10
54
55def boosted_logit(row):
56 return sum(
57 learning_rate * (
58 fitted["left"] if row[fitted["feature"]] <= fitted["threshold"] else fitted["right"]
59 )
60 for fitted in stumps
61 )
62
63def boosted_probability(row):
64 return sigmoid(boosted_logit(row))
65
66print(f"round=0 val_logloss={log_loss_from_logits(VALID_Y, [0.0] * len(VALID_Y)):.3f}")
67for round_number in range(1, 5):
68 probabilities = [sigmoid(logit) for logit in logits]
69 residual = [label - probability for label, probability in zip(TRAIN_Y, probabilities)]
70 hessian = [probability * (1.0 - probability) for probability in probabilities]
71 candidate = fit_residual_stump(TRAIN_X, residual)
72 stump = {
73 "feature": candidate["feature"],
74 "threshold": candidate["threshold"],
75 "left": newton_value(candidate["left_idx"], residual, hessian),
76 "right": newton_value(candidate["right_idx"], residual, hessian),
77 }
78 stumps.append(stump)
79 logits = [
80 logit
81 + learning_rate
82 * (stump["left"] if row[stump["feature"]] <= stump["threshold"] else stump["right"])
83 for logit, row in zip(logits, TRAIN_X)
84 ]
85
86 valid_logits = [boosted_logit(row) for row in VALID_X]
87 print(
88 f"round={round_number} {NAMES[stump['feature']]} <= {stump['threshold']:.2f} "
89 f"left={stump['left']:.3f} right={stump['right']:.3f} "
90 f"val_logloss={log_loss_from_logits(VALID_Y, valid_logits):.3f}"
91 )
92
93valid_scores = [boosted_probability(row) for row in VALID_X]
94print("validation probabilities:", [round(score, 3) for score in valid_scores])
95for threshold in (0.20, 0.50):
96 predicted = [int(score >= threshold) for score in valid_scores]
97 _, _, f1, cost, fp, fn = metrics(VALID_Y, predicted)
98 print(f"threshold={threshold:.2f} f1={f1:.3f} fp={fp} fn={fn} cost=${cost}")
99
100print("stable wrong-logit loss:", round(log_loss_from_logits([0], [1000.0]), 1))
101try:
102 newton_value([0], [-1.0], [0.0])
103except ValueError as error:
104 print("unpenalized saturated leaf:", error)
105print("same leaf with L2=1:", newton_value([0], [-1.0], [0.0], leaf_l2=1.0))1round=0 val_logloss=0.693
2round=1 ambiguity <= 3.35 left=-1.200 right=2.000 val_logloss=0.656
3round=2 ambiguity <= 3.35 left=-1.084 right=1.819 val_logloss=0.626
4round=3 ambiguity <= 3.35 left=-0.985 right=1.683 val_logloss=0.603
5round=4 ambiguity <= 3.35 left=-0.900 right=1.577 val_logloss=0.584
6validation probabilities: [0.397, 0.397, 0.397, 0.397, 0.67, 0.397, 0.67, 0.397]
7threshold=0.20 f1=0.667 fp=4 fn=0 cost=$72
8threshold=0.50 f1=0.667 fp=0 fn=2 cost=$240
9stable wrong-logit loss: 1000.0
10unpenalized saturated leaf: nonpositive leaf curvature
11same leaf with L2=1: -1.0Validation log loss falls from 0.693 to 0.584; all four rounds retain the original split. Cost at 0.50 stays $240 because V3 and V8 remain on the low-ambiguity side. A better probability loss needn't improve a selected policy. Choose rounds and thresholds on validation, freeze both, then evaluate a locked test period.
The stress check computes loss from finite logits, preserving a confidently wrong loss of 1000 even when sigmoid rounds to 1. Rounded probabilities can also make curvature zero; the unpenalized helper reports that condition instead of silently returning a zero update. An L2 penalty can make this leaf's quadratic problem well defined, but changes the fitted objective. This small trainer assumes valid 0/1 labels, finite features, and a varying feature; it's a demonstration, not a general production estimator.
The default unweighted Gini and log-loss criteria don't price a false negative at $120 while fitting. When false negatives cost far more than false positives, per-row sample weights can make high-harm rows influence splits. Sweep the decision threshold on validation anyway: weights change the fit, while the threshold turns scores into actions.
Leaf frequencies and forest averages are ranking scores, not automatically honest probabilities. The logistic chapter measured expected calibration error on these same eight validation rows. Treat ensemble predict_proba as a score until a reliability audit on a reserved split says otherwise.
A tree prediction is constant outside the training support of each split. If production ambiguity climbs past every training value, the leaf still returns the training rate for that region. Monitor feature ranges and don't expect the smooth extrapolation a linear model might give.
What bias and variance can tell us
The familiar decomposition is for squared prediction error, not every classification metric. At a fixed input , let , with zero-mean noise independent of the training data and variance . Across random training sets and fitting seeds :
Perfect training accuracy doesn't measure statistical bias. A singleton tree can fit all eight labels while producing systematically wrong scores elsewhere. A stump can also be unstable if small data changes switch its best feature or threshold.
Averaging identically distributed member predictions preserves their common expectation and can reduce variance through imperfect correlation. Changing bootstrap, feature, depth, or leaf-size rules changes the member distribution too, so a forest isn't guaranteed to have the same bias as an arbitrary deep tree.
Boosting expands an additive function to improve a chosen loss. It can reduce approximation error, but its fitted variance can also change. More rounds can overfit; eventual memorization isn't guaranteed, especially with restricted learners or contradictory labels at identical inputs. Depth, learning rate, sampling, regularization, and stopping interact. For binary review routing, compare the resulting validation costs and calibration directly.[6]
| Dimension | Bagging / random forests | Gradient boosting |
|---|---|---|
| Members | Often deep trees; depth and leaf size remain choices | Often bounded-depth trees; stumps are one option |
| Fitting target | Original labels on perturbed samples | Pseudo-residuals or gradient/curvature statistics, depending on the algorithm |
| Parallel work | Separate trees can fit in parallel | Rounds depend on prior scores; work within a round can be parallelized |
| Combination | Average scores; a forest also samples split features | Add corrections scaled by a learning rate |
| Useful question | Does averaging reduce member instability? | Do added corrections improve the chosen held-out objective? |
| More members | Stabilize this procedure's average; don't guarantee monotone error improvement | Change the fitted function; choose the stopping round on validation |
| Main controls | Number of trees, feature sampling, depth, leaf size | Rate, rounds, depth, sampling, and estimator-specific penalties |
MDI can crown a useless ID
Tree libraries often report mean decrease in impurity (MDI) as default feature importance. Each split contributes its impurity decrease weighted by the fraction of training samples reaching that node. Contributions are grouped by feature, normalized, and averaged across trees. It's fast, but high-cardinality features offer many candidate thresholds and more chances to fit accidental patterns. A numeric request ID can receive large importance without predicting later labels.[11]
To isolate that failure, use a separate synthetic experiment. A binary feature agrees with the label 70% of the time; a continuous feature is independent uniform noise. Both training and validation come from the same process. Scikit-learn supplies the forest and importance calculations here, so we don't need a second implementation of the tree builder. Forty training rows leave plenty of opportunity to overfit; 2,000 held-out rows give a less noisy comparison.
1import numpy as np
2from sklearn.ensemble import RandomForestClassifier
3from sklearn.inspection import permutation_importance
4from sklearn.metrics import f1_score
5
6rng = np.random.default_rng(1)
7
8def make_rows(n):
9 labels = rng.integers(0, 2, size=n)
10 binary = np.where(rng.random(n) < 0.70, labels, 1 - labels)
11 noise = rng.random(n)
12 return np.column_stack([binary, noise]), labels
13
14train_x, train_y = make_rows(40)
15valid_x, valid_y = make_rows(2000)
16model = RandomForestClassifier(
17 n_estimators=20, max_depth=5, max_features=1, random_state=0
18).fit(train_x, train_y)
19importance = permutation_importance(
20 model, valid_x, valid_y, scoring="f1", n_repeats=15, random_state=0
21)
22
23print(f"held-out F1={f1_score(valid_y, model.predict(valid_x)):.3f}")
24for index, name in enumerate(["weak binary", "continuous noise"]):
25 print(
26 f"{name}: MDI={model.feature_importances_[index]:.3f}; "
27 f"permutation F1 drop={importance.importances_mean[index]:.3f} "
28 f"(shuffle SD={importance.importances_std[index]:.3f})"
29 )1held-out F1=0.575
2weak binary: MDI=0.080; permutation F1 drop=0.097 (shuffle SD=0.007)
3continuous noise: MDI=0.920; permutation F1 drop=0.003 (shuffle SD=0.007)Permutation importance asks how this fitted model's selected metric changes when one feature is shuffled across held-out rows. Marginal shuffling breaks that feature's relationship with labels and other features, potentially creating unusual combinations. It doesn't measure intrinsic feature quality or establish a causal effect.[11]
In this seeded experiment, MDI assigns 0.920 to continuous noise, while its held-out permutation F1 drop is only 0.003. The binary predictor's drop is 0.097. This exposes a misleading training-importance ranking. The reported 0.007 shuffle SD describes repeated permutations on the fixed model and data; it isn't a confidence interval over new datasets, refits, or production traffic.
With strongly correlated features, another feature may retain redundant information, making individual permutation effects small.[12] This isn't universal: if a fitted tree relies entirely on one copy, shuffling that copy can still hurt. Consider a joint permutation of a feature group or a justified conditional method, and state which question it answers.
An importance score doesn't certify model quality. For access routing, include permutation effects on the declared cost at the frozen threshold, alongside recall and queue capacity. F1 alone can't express the two error prices.
A local explanation isn't a verdict
Global importances tell you which features matter across an entire dataset. They don't explain why the model made a specific decision on request R3.
SHAP (SHapley Additive exPlanations) solves this by adapting Shapley values from cooperative game theory to machine learning models[13]. It decomposes an individual prediction into an additive sum of a baseline expectation plus individual feature contributions:
For our chosen background game, . The original SHAP paper identifies three properties: local accuracy, missingness in the simplified feature-presence representation, and consistency. Consistency requires the feature's marginal contribution not to decrease for any coalition when the model changes; then its attribution must not decrease. The reconstruction above expresses local accuracy. These properties concern an attribution game; they don't validate labels or establish causal effects.[13]
The baseline and contributions depend on the background data and how absent features are replaced. Here we explain the stump's review probability using an interventional background: hold selected features at the request's values and fill the others from each training row, without conditioning on the held features.[14]
With two features, a Shapley contribution averages the change from adding that feature first and adding it second. Our stump only uses ambiguity, so revealing policy support never changes its output under this replacement rule. That gives a clean, exact check:
- Baseline review score across the training background: .
- Left-leaf score: , so ambiguity contributes .
- Right-leaf score: , so ambiguity contributes .
auto_policy_supportcontributes0.00under the stated interventional calculation.
1from statistics import mean
2
3attribution_stump = fit_stump(TRAIN_X, TRAIN_Y)
4
5def background_score(row, known_features):
6 completed = [
7 [row[j] if j in known_features else background[j] for j in range(2)]
8 for background in TRAIN_X
9 ]
10 return mean(predict_proba(attribution_stump, completed))
11
12for name, row in [("R5 high ambiguity", TRAIN_X[4]), ("R3 missed exception", TRAIN_X[2])]:
13 base_score = background_score(row, set())
14 only_ambiguity = background_score(row, {0})
15 only_policy = background_score(row, {1})
16 prediction = background_score(row, {0, 1})
17 ambiguity_contribution = 0.5 * (
18 only_ambiguity - base_score + prediction - only_policy
19 )
20 policy_contribution = 0.5 * (
21 only_policy - base_score + prediction - only_ambiguity
22 )
23 rebuilt = base_score + ambiguity_contribution + policy_contribution
24 assert abs(rebuilt - prediction) < 1e-12
25 print(
26 f"{name}: base={base_score:.2f} ambiguity={ambiguity_contribution:+.2f} "
27 f"policy={policy_contribution:+.2f} prediction={rebuilt:.2f}"
28 )1R5 high ambiguity: base=0.50 ambiguity=+0.50 policy=+0.00 prediction=1.00
2R3 missed exception: base=0.50 ambiguity=-0.30 policy=+0.00 prediction=0.20R3 exposes the divide between explanation and decision correctness. The attribution faithfully reports why the stump scored the request low: its ambiguity of 2.0 placed it in the low-ambiguity leaf, contributing . But that doesn't prove automatic processing was safe! R3's true label is (it required human review). Local explanations audit what a model computed; they don't validate real-world ground truth.
If instead the explainer conditions on correlated features, revealing policy support can convey information about ambiguity even though the tree never splits on support. Attribution then answers a different question. Always document whether your SHAP values use interventional or conditional expectations rather than assuming one attribution is uniquely defined.
With TreeExplainer, also record model_output and feature_perturbation. Its default raw output depends on the estimator; for binary XGBoost, raw attributions reconstruct log odds, not a percentage. Current auto chooses interventional replacement when a background is supplied and tree-path-dependent replacement otherwise. Tree-path-dependent replacement isn't a general conditional-expectation calculation.[14]
Missing values are a library contract
"Trees handle missing data automatically" is a half-truth that breaks pipelines. Before moving an estimator, check which component owns the missing-value route. The behavior depends on library, estimator, and version:
The following contracts were checked in scikit-learn 1.9.1, XGBoost 3.4.2, and LightGBM 4.7.0 documentation on September 21, 2026. Our hand-written trainers don't implement native missing-value routing.
| Library | Missing-value behavior |
|---|---|
| scikit-learn decision trees | Native NaN support with splitter='best' and gini / entropy / log_loss. The grower learns whether missing rows go left or right. If a feature had no missing values in training, prediction sends NaNs to the child with more samples.[2] |
| scikit-learn random forests | RandomForestClassifier supports NaNs and learns a left/right default at each split, matching the tree contract.[7] |
| XGBoost tree boosters | Learn a default direction for missing values. With the default missing=NaN, an absent sparse entry is missing, while an explicitly stored 0 is a value. Densifying a sparse matrix can fill absent entries with zeros and change predictions.[15] |
| LightGBM | Handles NaNs by default (use_missing=true). With default zero_as_missing=false, absent sparse entries are zeros; with zero_as_missing=true, zeros and absent entries count as missing.[16] |
The trap is assuming the behavior is uniform. Moving a pipeline between estimators or versions can turn native routing into an input error.
The quieter trap changes data without raising one. zero_as_missing and sparse-matrix conventions mean a literal 0 and a missing value can be treated identically or differently depending on the tool, which changes splits. Decide the missing-value contract explicitly: impute, encode a missingness flag, or rely on documented native behavior. A tree won't infer that contract for you.
Pick a model the same way you picked logistic
Use the same later-request contract for every candidate. Compare a regularized tree or ensemble with logistic regression under identical splits and costs, then inspect whether added flexibility repairs meaningful failures. Read the table below as a set of decisions, not a leaderboard: the cheapest row still rests on eight validation requests.
| Model | Threshold | Validation F1 | Validation cost | What to notice |
|---|---|---|---|---|
| Previous lesson's logistic model | 0.20 | 0.800 | $36 | Two unnecessary reviews, no missed reviews. |
| Stump | 0.20 | 0.667 | $72 | Sends the mixed leaf to review; catches V3 and V8. |
| Stump | 0.50 | 0.667 | $240 | Same F1, two expensive misses. |
| Deep tree | 0.50 | 0.750 | $138 | Perfect train fit; V2 inherits R3's singleton leaf. |
| Three-tree forest | 0.20 | 0.727 | $54 | Best of these tree fixtures; logistic still costs less. |
| Four-round boosting | 0.50 | 0.667 | $240 | Scores move, the first stump stays in charge. |
The forest improves on our stump, but it doesn't beat the logistic baseline on this batch. Greater flexibility isn't a reason to replace a working model. We used these rows to inspect and select policies, so even the logistic result needs a fresh verification set before making a performance claim.
| Model symptom | What to inspect | Practical response |
|---|---|---|
| Stump misses costly review exceptions | False negatives and affected request slices | Add evidence features or evaluate a constrained ensemble. |
| Deep tree reaches perfect training accuracy | Validation cost and leaf sample counts | Compare depth, leaf-size, and pruning constraints; perfect fit alone doesn't establish overfitting. |
| Forest scores improve but the threshold fails | Cost curve and calibration on later requests | Select the threshold on validation evidence, then retest on a later period. |
| Boosting degrades after new policy wording | Feature distributions and error slices | Retrain or revise features after confirming policy drift. |
| Importance changes sharply across batches | Permutation uncertainty and correlations | Treat explanations as diagnostics, not causal conclusions. |
Trees expose conditional splits. Forests reduce single-tree instability by averaging perturbed members. Boosting accumulates targeted corrections. Each mechanism changes a different failure mode, and none replaces held-out evaluation.
Change the example, then check your reasoning
Change R8's label from 0 to 1 and rerun the split search. Which feature now wins?
Answer
Ambiguity still wins, but its threshold moves to 3.00 with gain 0.281. Policy support at 2.50 gains about 0.260. Changing one label moves the preferred boundary, showing why eight rows don't establish a durable rule.
Reduce the missed-review cost to $40. Among thresholds 0.20 and 0.50, does the preferred stump policy change on the eight validation rows?
Answer
No. Threshold 0.20 still costs $72 for four false positives. Threshold 0.50 now costs $80 for two false negatives, so the gap narrows to $8. For this comparison, the switch occurs below a missed-review cost of $36; equality gives a tie.
Keep four regression-boosting rounds but change the learning rate from 0.10 to 0.05. Should lower training MSE be guaranteed?
Answer
No. Each update is smaller, and four rounds now reach about [27.22, 27.22, 35.56] with MSE 315.21, versus 210.38 at rate 0.10. A smaller learning rate often needs more rounds. Whether it generalizes better is a validation question, not a consequence of shrinkage alone.