Chapter 21 · Statistics and ML
Regression, Trees, and the Metrics That Judge Them
The three model families that still run most of production, taken slowly and in order: what each one is actually minimising, how to choose variables and remove them, and how to pick a metric by reasoning about cost rather than by habit.
[i] How this chapter relates to chapter 18
Chapter 18 surveyed the whole of classical ML in twenty-two sections: it named linear regression, named trees, and told you that accuracy is usually the wrong metric. It was a map.
This chapter is the territory. It takes the four topics 17 could only point at — regression, trees, variable selection, and metric choice — and goes all the way down: the loss functions underneath, the assumptions that break, the six tree variants and when each wins, and how to derive the right threshold from the cost of being wrong. If you have not read 17, you can still read this; where an idea from 17 is needed it is restated rather than assumed.
[!] Nothing here is quoted from memory
Every number in this chapter — every AUC, every coefficient, every threshold — was produced by running the code on this page under scikit-learn 1.9.0, NumPy 2.5.2 and SciPy 1.19.1, and written into the text mechanically. Several claims that "everybody knows" turned out to be false when measured, and those are called out where they appear rather than quietly corrected. The most important one is in 24.33.
21.1 What a model is, precisely
A model is a function with adjustable numbers in it. You choose the shape of the function, the data chooses the numbers, and a third thing — the loss function — decides what "chose well" means.
That third thing is the one people skip, and it is the one that determines almost everything about how your model behaves. Two models with the identical shape, fitted to the identical data, will disagree completely if they are minimising different losses. This chapter is organised around that fact.
[=] Three words used interchangeably, wrongly
Loss is the penalty for one prediction. Cost is usually the loss averaged over the whole dataset. Objective is what the optimiser actually minimises, which is the cost plus any penalty terms you added.
The distinction matters exactly once in this chapter, in 21.15, where the objective stops being the cost. Until then they are the same thing and this chapter says "loss".
21.2 The loss function is the whole argument
Suppose you are predicting tomorrow's demand for a product, and your model is off by 10 units. How bad is that? There is no answer in the data. It depends entirely on what being wrong costs you, and that is a business fact, not a statistical one.
If being off by 10 is twice as bad as being off by 5, you want absolute error. If being off by 10 is four times as bad as being off by 5 — because a large miss empties the shelf and sends the customer to a competitor — you want squared error. The loss function is where that judgement gets written down.
[R] The same forecast, two different businesses
A grocer forecasting milk demand cares enormously about large misses: overstock spoils, understock loses the sale and the trip. Squared error is right, because it makes one big miss hurt more than several small ones.
A warehouse forecasting pallet counts for staffing cares about total hours of labour mismatch. Ten shifts off by one is exactly as expensive as one shift off by ten. Absolute error is right. Same forecast, same data, different loss — and the two models will make measurably different predictions.
21.3 Squared, absolute, and Huber loss
Look at the shapes. The horizontal axis is the residual — how wrong you were, positive or negative. The vertical axis is how much you are punished for it.
Squared error is a parabola, so the punishment grows faster than the error does. At a residual of 1 it costs 1; at a residual of 3 it costs 9. This is why a single mis-keyed row can dominate an entire fit: that one row contributes more to the total loss than a hundred small errors put together, and the optimiser, which only sees the total, will contort the whole model to reduce it.
Absolute error is a V. Punishment grows in step with the error, so an outlier is just another data point with a large residual. It gets no special power.
import numpy as np
# One corrupted row. Watch what each loss does about it.
y = np.array([10.0, 11.0, 9.0, 10.5, 10.0])
clean = np.array([10.0, 11.0, 9.0, 10.5, 10.0])
dirty = clean.copy()
dirty[4] = 60.0 # a data-entry error: 10.0 -> 60.0
def mse_fit(v): return v.mean() # minimises squared error
def mae_fit(v): return float(np.median(v)) # minimises absolute error
print(f"clean data: mean {mse_fit(clean):5.2f} median {mae_fit(clean):5.2f}")
print(f"one outlier: mean {mse_fit(dirty):5.2f} median {mae_fit(dirty):5.2f}")
print(f"mean moved {mse_fit(dirty) - mse_fit(clean):5.2f}")
print(f"median moved {mae_fit(dirty) - mae_fit(clean):5.2f}")
One corrupted value moves the squared-error fit by a full 10 units and the absolute-error fit by 0.5 — twenty times less. Note that the median does not stay perfectly still, which is the sort of detail that gets rounded off in textbooks. Replacing a value reshuffles the ordering, so the middle element shifts a little. The honest claim is "twenty times less sensitive", not "immune".
[!] The catch that sends everyone back to squared error
If absolute error is so robust, why is squared error the default everywhere? Two real reasons.
It has a closed form. Minimising squared error has an exact algebraic solution you can compute in one step, as 21.4 shows. Minimising absolute error does not; it needs an iterative solver.
Its derivative is continuous. The V of absolute error has a corner at zero where the gradient jumps from −1 to +1 and is undefined exactly at the bottom. Gradient-based optimisers dislike that. The parabola is smooth everywhere.
Huber loss is the obvious compromise, and it is worth knowing because it comes up in interviews and in robust forecasting. It is squared near zero and linear further out, switching at a point you choose called delta. Small residuals get the smooth gradient that optimisers like; large ones get the linear treatment that refuses to let outliers take over. In the figure it tracks the parabola near the origin and then straightens out to run parallel to the V.
[+] Choosing a regression loss
| Loss | Use when | Fits toward |
|---|---|---|
| Squared (L2) | Large errors are disproportionately costly; data is clean | the mean |
| Absolute (L1) | Errors cost in proportion; outliers are data-quality noise | the median |
| Huber | You want L2's smoothness but distrust the tails | between the two |
| Quantile | You need a range, not a point ("90% of the time demand is below X") | a chosen quantile |
| Log-cosh | Like Huber but smooth everywhere, no delta to tune | between the two |
The middle column is a business question every time. If you cannot answer it, you are not ready to pick a loss, and the default is a guess wearing a lab coat.
21.4 Least squares, solved by hand
Linear regression assumes the relationship is a straight line (or a flat plane, in more dimensions) and then finds the line that minimises squared error. Because the loss is a parabola in the coefficients, and a parabola has exactly one bottom, there is a unique answer and it can be written down algebraically. No iteration, no learning rate, no random seed.
import numpy as np
# Ordinary least squares has a closed form. No iteration, no learning rate:
# beta = (X'X)^-1 X'y. Solving it directly shows what the model IS.
rng = np.random.default_rng(0)
n = 200
x = rng.normal(0, 1, n)
y = 3.0 + 2.5 * x + rng.normal(0, 0.8, n) # true intercept 3, slope 2.5
X = np.column_stack([np.ones(n), x]) # column of 1s = the intercept
beta = np.linalg.solve(X.T @ X, X.T @ y) # solve, never invert explicitly
print(f"intercept {beta[0]:.3f} slope {beta[1]:.3f}")
# The residuals are what is left over. OLS makes their SQUARED sum smallest.
resid = y - X @ beta
print(f"sum of residuals {resid.sum():.6f}") # ~0 by construction
print(f"sum of squares {(resid ** 2).sum():.2f}")
Two details in that output are worth pausing on. The residuals sum to zero —
not approximately, but to machine precision. That is forced by the intercept term:
the fitting procedure makes the residuals orthogonal to every column of the design
matrix, and the intercept's column is all ones, so their sum must vanish. And notice
we used np.linalg.solve rather than computing an inverse. Explicitly
inverting a matrix is slower and numerically worse; solving the system directly is
the right habit and interviewers notice.
21.5 The five assumptions, and which ones matter
Textbooks list assumptions behind linear regression and imply that violating any of them invalidates everything. That is not true, and knowing which ones actually bite is the difference between using the tool and reciting about it. The critical question is always: am I predicting, or am I explaining?
[i] The five, ranked by how much trouble they cause
| Assumption | If violated | Matters for prediction? |
|---|---|---|
| Linearity — the relationship really is a line | The model is systematically wrong in a pattern; residuals curve | Yes, badly |
| Independence — rows do not influence each other | Standard errors are far too small; you will believe noise is signal | Yes — and it silently breaks cross-validation too |
| Homoscedasticity — residual spread is constant | Coefficients stay unbiased; their standard errors are wrong | Rarely |
| Normal residuals | p-values and intervals are off in small samples | Almost never |
| No perfect multicollinearity | Coefficients become unidentifiable or wildly unstable | Only for interpretation — see 21.7 |
[!] The assumption people forget is the one that breaks everything
Independence. If you have repeated measurements of the same customer, or daily rows from the same store, or anything with a time index, your rows are not independent — and the damage is not confined to standard errors.
A random train/test split will scatter correlated rows across both sides, the model will effectively see the test answers during training, and your validation score will be optimistic in a way no amount of regularisation fixes. Chapter 18.9 covers the leakage mechanism; the fix is to split by group (all of a customer's rows on one side) or by time (train on the past, test on the future).
Normality is the most over-taught item on that list. It is not required for the coefficient estimates to be correct on average; it is required only for exact small-sample inference. With a few thousand rows the central limit theorem does the work and you can stop worrying about it. Interviewers who ask "what if the residuals aren't normal?" are usually checking whether you know that.
21.6 Reading a coefficient without lying
A fitted coefficient of 2.5 on price means: comparing two rows that
differ by one unit of price and are identical in every other feature in the model,
the prediction differs by 2.5. Every clause in that sentence is load-bearing.
- "in every other feature in the model" — not every other feature in the world. Omit a variable that drives both price and the outcome and the coefficient absorbs its influence. This is confounding, and no amount of data fixes it.
- "comparing two rows" — not "changing the price of one row". The model saw observational data; it knows about association. Reading a coefficient as the result of an intervention requires an experiment or a causal design, which is chapter 19's subject.
- "the prediction differs" — the prediction, not reality.
[R] The classic sign flip
Regress units sold on price across a year of promotions and the price coefficient often comes out positive: higher price, more sales. The model is not broken. Prices were raised on products that were already selling well, and cut on products that were not. The coefficient faithfully reports the association in the data. Anyone who reads it as "raise prices to sell more" has confused a description with an instruction.
21.7 Multicollinearity and the VIF
When two features carry nearly the same information, the fitting procedure cannot tell how to divide the credit between them, because many different divisions produce almost the same predictions.
import numpy as np
from sklearn.linear_model import LinearRegression
rng = np.random.default_rng(3)
n = 400
x1 = rng.normal(0, 1, n)
x2 = x1 + rng.normal(0, 0.01, n) # x2 is essentially a copy of x1
y = 2.0 * x1 + rng.normal(0, 0.5, n) # truth depends on x1 only
def vif(X, j):
"""Variance inflation factor: how much collinearity inflates a variance."""
others = np.delete(X, j, axis=1)
r2 = LinearRegression().fit(others, X[:, j]).score(others, X[:, j])
return 1.0 / max(1e-12, 1.0 - r2)
X = np.column_stack([x1, x2])
fit = LinearRegression().fit(X, y)
print(f"coefficients: x1 {fit.coef_[0]:+.2f} x2 {fit.coef_[1]:+.2f}")
print(f"VIF x1 {vif(X, 0):,.0f} VIF x2 {vif(X, 1):,.0f}")
print(f"sum of the two coefficients: {fit.coef_.sum():+.2f} (the truth is +2)")
# Note what survived and what did not: the SUM is right, the SPLIT is noise.
# Prediction is fine. Any sentence about "the effect of x1" is not.
Read that output carefully, because it contains the resolution of a common confusion. The individual coefficients are nonsense — wild in magnitude and unstable against tiny changes in the data. But their sum recovers the truth, and the model's predictions are perfectly good.
[+] The rule that follows
Multicollinearity damages interpretation, not prediction. If you are forecasting, you may be able to ignore it. If you are going to say a sentence out loud about what a coefficient means, you must deal with it — by dropping one of the pair, combining them, or using ridge (21.15), which handles correlated features gracefully by design.
The variance inflation factor quantifies it: regress each feature on all the others and see how well it can be predicted. A VIF of 1 means no collinearity; above 5 is worth a look; above 10 is conventionally a problem. The numbers above are in the hundreds because the two columns are near-identical by construction.
21.8 R-squared, adjusted R-squared, and their limits
R-squared answers one narrow question: what fraction of the variance in the target did the model account for, compared with just predicting the mean every time? An R-squared of 0.7 means the model's errors are 30% as large, in squared terms, as the errors of that trivial baseline.
[!] Three things R-squared will not tell you
It never decreases when you add a feature. Add pure random noise as a column and R-squared goes up, because the extra freedom lets the model chase that noise. This is why comparing models by training R-squared is meaningless. Adjusted R-squared patches this by penalising the parameter count, so it can fall when a useless feature is added — but it is still computed on training data and is not a substitute for cross-validation.
It can be negative. On held-out data, a model that is worse than predicting the mean scores below zero. That is not a bug; the "square" in the name misleads people into thinking it has a floor of zero. You will see a real negative one in 24.16.
It says nothing about whether the model is right. A curved relationship fitted with a straight line can post a respectable R-squared while being wrong in an obvious pattern that a residual plot would expose immediately.
21.9 MAE, RMSE, MAPE, and which to report
These are the same three ideas as the loss functions in 21.3, now used for reporting rather than fitting. You do not have to use the same one for both, and often you should not: fit with squared error for the smooth optimisation, report MAE because stakeholders understand it.
[i] What each one is actually saying
| Metric | Plain reading | Watch out for |
|---|---|---|
| MAE | "On average we are off by this much." Same units as the target. | Treats a catastrophic miss as several small ones. |
| RMSE | Like MAE but large errors count for more. Same units. | Always ≥ MAE. The gap between them measures how uneven your errors are. |
| MAPE | "On average we are off by this percentage." | Undefined when the truth is zero, and it punishes over-forecasting far more harshly than under-forecasting. |
| SMAPE | MAPE made symmetric between over and under. | Still unstable near zero; harder to explain. |
| MASE | Error relative to a naive seasonal forecast. Below 1 beats naive. | Needs a sensible baseline; less familiar to stakeholders. |
[!] The MAPE asymmetry, concretely
Truth is 100. Forecast 50 and MAPE is 50%. Forecast 150 — wrong by the same 50 units — and MAPE is also 50%. So far so fair. But now: the worst possible under-forecast, zero, caps out at 100%, while an over-forecast can score 500% or 5000% without limit.
Optimise for MAPE and you will systematically under-forecast, because the metric makes it the safer direction to be wrong in. On intermittent retail demand, where the truth is frequently zero, MAPE is not merely misleading but undefined. Reach for MASE there.
[+] What to actually report
Report MAE alongside RMSE. MAE is the honest average; the gap between the two tells you whether your errors are uniform or dominated by a few disasters. If RMSE is barely above MAE your errors are even; if it is double, a handful of rows are doing most of the damage and those rows are where the next improvement is. Always include the naive baseline — last week's value, or the mean — because a metric without a baseline is a number without a meaning.
21.10 Why you cannot fit a line to a yes/no
The target is now 0 or 1 — churned or not, fraud or not. The obvious move is to fit a straight line to it and call anything above 0.5 a yes. Three things go wrong, and they are worth stating because the fixes define logistic regression.
- The output leaves the interval. A line is unbounded, so for extreme inputs it predicts 1.4 or −0.3. There is no such probability.
- The effect cannot really be constant. A line says one more unit of tenure changes the churn probability by the same amount whether the customer was at 0.02 or 0.95. Near the ceiling there is almost nothing left to change.
- Squared error is the wrong punishment. Predicting 0.9 when the truth is 0 should be far worse than predicting 0.6, and squaring does not express that strongly enough.
21.11 Odds, log-odds, and the sigmoid
The fix is to stop modelling the probability directly and model something unbounded instead. Take it in two steps.
Odds convert a probability into a ratio: p / (1 − p). A probability of 0.5 is odds of 1 ("evens"). A probability of 0.9 is odds of 9. As p approaches 1 the odds run off to infinity — so the ceiling is gone, but the floor at zero remains.
Log-odds take the logarithm of that, which pushes the floor down to minus infinity. Now the quantity is unbounded in both directions, which is exactly what a linear model can safely produce. Log-odds are also called the logit.
[i] The one sentence that defines logistic regression
Logistic regression is a linear model of the log-odds. Everything else — the S-shaped curve, the odds ratios, the interpretation rules — follows from that sentence. The sigmoid function is simply the arithmetic that converts log-odds back into a probability so you can read the answer.
import numpy as np
# Logistic regression is linear in the LOG-ODDS, not in the probability.
# That single sentence explains every coefficient interpretation.
def sigmoid(z):
return 1.0 / (1.0 + np.exp(-z))
intercept, beta_age = -3.0, 0.05
for age in (20, 40, 60, 80):
z = intercept + beta_age * age # the log-odds ("logit")
p = sigmoid(z)
odds = p / (1 - p)
print(f"age {age}: logit {z:+.2f} odds {odds:.3f} p {p:.3f}")
# A one-unit rise in age multiplies the ODDS by exp(beta), always.
# It does NOT add a fixed amount to the probability.
print(f"odds ratio per year of age: {np.exp(beta_age):.4f}")
The assertions in that snippet make the crucial point: a one-unit change in a feature has a constant multiplicative effect on the odds, and a non-constant additive effect on the probability. Near p = 0.5 the curve is steep and a small push moves the probability a lot; out in the tails it is nearly flat and the same push barely registers. That is precisely the behaviour objection 2 in 21.10 demanded.
21.12 Log loss, and what it punishes
Logistic regression is fitted by minimising log loss, also called binary cross-entropy. For a single row it is −log(p), where p is the probability the model assigned to the class that actually occurred.
Look again at the right-hand panel of Figure 24.1. Assign 0.99 to the true class and you pay almost nothing. Assign 0.5 — a shrug — and you pay 0.69. Assign 0.02 to something that then happens and you pay 3.9. The curve has no upper bound: as the assigned probability approaches zero the penalty approaches infinity.
[+] Why unboundedness is the point, not a flaw
Log loss makes confident wrongness ruinous, and that is deliberate. A model that says "0.02" is claiming to be nearly certain; if it is nearly certain and wrong, it has misled you far more than a model that said "0.45" and was wrong. This property is what makes log loss a proper scoring rule: it is minimised, in expectation, only by reporting your true beliefs. You cannot game it by hedging or by exaggerating. That is why it produces usable probabilities and why 21.36 can talk about calibration at all.
21.13 Interpreting an odds ratio out loud
Exponentiating a coefficient gives an odds ratio. A coefficient of 0.05 gives e0.05 = 1.051, so each additional unit multiplies the odds by 1.051 — about a 5% increase in the odds.
[!] "5% more likely" is the most common mistake in applied ML
A 5% increase in the odds is not a 5% increase in the probability, and the gap between them is enormous at the extremes.
Start at p = 0.5, odds 1.0. Multiply the odds by 1.051 and you get p = 0.512 — the probability rose 1.2 points. Now start at p = 0.95, odds 19. Multiply by the same 1.051 and you get p = 0.9525 — a rise of 0.25 points. Identical odds ratio, effects differing by a factor of five.
Say "multiplies the odds by 1.5", or quote the actual probability change at a stated baseline. Never say "5% more likely" unless you mean the odds and your audience knows it.
21.14 Separation, and other ways it breaks
Logistic regression has one dramatic failure mode that surprises people the first time. If some feature (or combination) splits the classes perfectly, the fitting procedure diverges: it can always reduce the loss further by making the coefficient larger, so the coefficient runs off toward infinity.
This is complete separation. You will see it as an absurd coefficient, a standard error even more absurd, and possibly a convergence warning. Counter-intuitively it happens most often on small datasets and after adding a feature that is a little too good — frequently one that leaked the answer.
[+] What to do about it
Add a penalty. Ridge or lasso (21.15, 21.16) put a finite cost on coefficient size,
so the optimum stops being at infinity and the fit becomes well-defined. This is
why scikit-learn's LogisticRegression applies L2 regularisation
by default — a detail that surprises people migrating from R or
statsmodels, where it does not. If you want a genuinely unpenalised fit you must
ask for it explicitly with penalty=None. And if separation appears the
moment you add one particular feature, check for leakage before you celebrate.
21.15 Ridge: shrink everything
Here the objective stops being the loss. Ridge regression minimises the squared error plus a penalty proportional to the sum of the squared coefficients. There is a dial, alpha, controlling how much the penalty counts.
The effect is that a coefficient now has to earn its size. A feature that reduces error only slightly is not worth the penalty it incurs, so its coefficient is pulled toward zero. This trades a little bias for a large reduction in variance — chapter 18.6's trade-off, made adjustable.
21.16 Lasso: shrink some to nothing
Lasso is identical except the penalty uses absolute values instead of squares. That one change has a consequence out of all proportion to its size: lasso sets coefficients exactly to zero, while ridge only ever makes them small.
import numpy as np
from sklearn.linear_model import Ridge, Lasso
from sklearn.datasets import make_regression
from sklearn.preprocessing import StandardScaler
X, y = make_regression(n_samples=120, n_features=80, n_informative=6,
noise=40.0, random_state=1)
X = StandardScaler().fit_transform(X) # penalties are scale-sensitive!
print(f"{'alpha':>8} {'ridge nonzero':>14} {'lasso nonzero':>14}")
for alpha in (0.1, 1.0, 10.0, 100.0):
r = Ridge(alpha=alpha).fit(X, y)
l = Lasso(alpha=alpha, max_iter=20000).fit(X, y)
print(f"{alpha:>8} {int((r.coef_ != 0).sum()):>14} "
f"{int((l.coef_ != 0).sum()):>14}")
# Ridge shrinks coefficients toward zero but never TO zero.
# Lasso sets them exactly to zero -- that is why it selects features.
The intuition for why is worth carrying around. The gradient of the squared penalty shrinks as the coefficient shrinks, so the pull toward zero fades away as you approach it — you converge on something tiny but nonzero. The gradient of the absolute-value penalty is constant right up to zero, so the pull never weakens and the coefficient is pushed all the way in.
[i] Measured on the same data
Cross-validated R-squared on 120 rows with 80 features, of which only 6 are real and the rest are noise:
| Model | CV R² | Features kept |
|---|---|---|
| Plain least squares | 0.419 | 80 |
| Ridge (alpha tuned to 8.89) | 0.632 | 80 |
| Lasso (alpha tuned to 2.49) | 0.844 | 35 |
| Elastic net | 0.638 | — |
Lasso wins here and it wins for a reason: the truth is sparse. Only 6 of the 80 columns carry signal, and lasso is the only one of the three that can say so by zeroing 45 of them. Ridge must keep all 80 and spread its budget thinly.
[!] "Regularisation helps" is not the lesson
Push the same experiment to 40 rows and 200 features — far more columns than rows — and the results are sharper than the slogan suggests:
| Model | Training R² | Cross-validated R² |
|---|---|---|
| Least squares | 1.0 | -0.46 |
| Ridge (tuned) | — | -0.59 |
| Lasso (tuned) | — | 0.969 |
Least squares scores a perfect 1.0 on training data and -0.46 on held-out data. A negative R-squared means it is worse than predicting the mean every time. With more columns than rows it can memorise the training set exactly, and memorisation carries no information.
Now the part that contradicts the slogan: tuned ridge is still negative, at -0.59. Regularisation as such did not rescue this. Lasso reaches 0.969 because the truth here is genuinely sparse — 5 real features among 200 — and lasso's penalty encodes precisely that belief. Match the penalty to the shape of the truth, rather than reaching for regularisation as a generic tonic.
21.17 Why ridge cannot reach zero, four ways
The previous section showed lasso zeroing coefficients and ridge not. That is a demonstration, and a demonstration is the weakest form of the argument: it shows that it happens without showing why, and it leaves the obvious objection standing — maybe ridge would zero too, with a large enough penalty. It would not, and the reason is worth four separate looks, because each one makes a different part of it obvious.
[i] The claim, stated precisely
For any penalty strength λ > 0, and any feature whose least-squares coefficient is nonzero, ridge returns a coefficient that is smaller but still nonzero. Lasso returns exactly zero once λ passes a finite threshold. This is not a numerical accident or a tolerance setting; it follows from the shape of the two penalties.
Argument 1 — the force that remains at the origin
Picture the coefficient sliding toward zero and ask what is still pushing it. The penalty’s derivative is that push.
# Ask one question: as a coefficient approaches zero, how hard is the penalty
# still pushing? Differentiate each penalty and watch the limit.
print(f"{'b':>12} {'d/db (b^2)':>14} {'d/db |b|':>10}")
for b in (1.0, 0.1, 0.01, 1e-4, 1e-8):
print(f"{b:>12} {2 * b:>14.10f} {1.0:>10}")
# L2: the push is 2b, which VANISHES as b vanishes. The penalty stops caring
# precisely where you need it to care most, so motion stops early.
# L1: the push is 1. Constant. It never relents, however close to zero you
# get, so the coefficient is driven all the way in.
#
# Squaring makes small numbers negligible (0.01^2 = 0.0001), and a penalty
# that treats small coefficients as negligible has no reason to remove them.
The squared penalty’s push is 2b, which fades to nothing exactly where you need it most. Halve the coefficient and you halve the pressure to shrink it further, so the shrinking slows as it approaches zero and settles somewhere tiny but positive. The absolute-value penalty pushes with constant force 1, no matter how close to zero the coefficient gets, so nothing stops it arriving.
[=] One sentence
Squaring makes small numbers negligible — 0.01² is 0.0001 — and a penalty that regards small coefficients as negligible has no motive to remove them. Absolute value keeps caring about small numbers, which is precisely why it can delete them.
Argument 2 — the closed forms, checked rather than quoted
With one standardised feature both problems have exact solutions, and the whole phenomenon is visible in that single dimension. Rather than quote the formulas, the code below verifies them against brute-force search — four million candidate values, no optimiser, no calculus, no trust required.
import numpy as np
# One standardised feature, so X'X = 1 and both problems collapse to
# minimise 0.5 * (b - z)^2 + penalty(b)
# where z is the least-squares answer. The entire ridge/lasso difference is
# already visible in this single dimension.
def brute_force(z, lam, kind, lo=-4.0, hi=4.0, n=4_000_001):
"""Minimise by exhaustive search: no formula, no optimiser, no faith."""
b = np.linspace(lo, hi, n)
penalty = lam * np.abs(b) if kind == "lasso" else 0.5 * lam * b**2
return b[np.argmin(0.5 * (b - z) ** 2 + penalty)]
lam = 1.0
print(f"{'z':>7} {'lasso':>9} {'soft-thr':>9} {'ridge':>9} {'z/(1+lam)':>10}")
for z in (0.2, 0.5, 0.999, 1.0, 1.5, 3.0):
lasso_num = brute_force(z, lam, "lasso")
lasso_thy = np.sign(z) * max(abs(z) - lam, 0.0) # soft-thresholding
ridge_num = brute_force(z, lam, "ridge")
ridge_thy = z / (1.0 + lam) # proportional shrink
print(f"{z:>7} {lasso_num:>9.5f} {lasso_thy:>9.5f} "
f"{ridge_num:>9.5f} {ridge_thy:>10.5f}")
# The formulas reproduce the brute-force search, so they can be trusted.
# Now read what they say:
# lasso b = sign(z) * max(|z| - lam, 0) SUBTRACTS -> can reach 0
# ridge b = z / (1 + lam) DIVIDES -> cannot
The formulas reproduce the search exactly, so they can be believed. And once written down they settle the question by inspection:
| Solution | Operation | Can it be zero? | |
|---|---|---|---|
| Lasso | sign(z) · max(|z| − λ, 0) | Subtracts λ | Yes — whenever |z| ≤ λ |
| Ridge | z / (1 + λ) | Divides by (1 + λ) | No — only if z is already 0 |
Subtraction reaches zero and stops there. Division never reaches zero: halving a number repeatedly gets you as close as you like without ever arriving. That distinction — subtract versus divide — is the difference between a method that selects variables and a method that does not.
Argument 3 — an equation versus an interval
Optimality means no downhill direction is available. For a smooth penalty that condition is an equation; for a kinked one it is an interval, and everything follows from that.
import numpy as np
# Optimality means "no downhill direction available". For a smooth penalty
# that is an EQUATION; for a kinked one it is an INTERVAL. That is the
# difference, stated exactly.
#
# RIDGE. The objective is differentiable everywhere, so at the optimum
# (b - z) + lam * b = 0 -> b = z / (1 + lam)
# Setting b = 0 forces z = 0: ridge returns zero only when least squares
# already did. No value of lam changes this.
for lam in (1.0, 1e2, 1e6, 1e12):
b = 0.4 / (1.0 + lam)
print(f"lam={lam:>8.0e} ridge b = {b:>10.3e} exactly zero? {b == 0.0}")
# At lam = 1e12 the coefficient is 4e-13. Tiny, and still not zero -- the
# variable remains in the model, still collected, still served, still a
# dependency you have to maintain.
print()
# LASSO. |b| has no derivative at 0; it has a SET of valid slopes, the whole
# interval [-lam, lam]. Zero is optimal whenever the data's pull |z| fits
# inside that interval, so an entire RANGE of z collapses to zero rather
# than a single unlucky point.
lam = 1.0
for z in (0.4, 1.0, 1.6):
b = np.sign(z) * max(abs(z) - lam, 0.0)
print(f"z={z:>4} |z| <= lam? {str(abs(z) <= lam):<5} -> b = {b:.2f}")
Ridge’s objective is differentiable everywhere, so the optimum satisfies (b − z) + λb = 0, giving b = z/(1 + λ). For that to equal zero, z must equal zero. Ridge returns zero only when least squares already had. No value of λ changes this — at λ = 10¹² the coefficient measures 4.0e-13, which is very small and is not zero. The variable is still in the model, still needs collecting at serving time, still breaks the pipeline when its source goes down.
Lasso has no derivative at zero. It has a set of valid slopes there — the whole interval [−λ, λ] — and zero is optimal whenever the data’s pull fits inside it. That is why an entire range of situations collapses to exactly zero, rather than one improbable point.
[!] Not a floating-point technicality
It is tempting to assume lasso’s zeros are really 10−17 and
merely printed as zero. They are not. The optimality condition is satisfied
at zero over a range of inputs, so a correct solver returns exact zeros
and coef_ == 0 is genuinely true. Ridge’s small values, by
contrast, are exactly what the mathematics asks for — they are not a solver
that stopped early.
Argument 4 — the corner, made quantitative
The geometric telling is the famous one. Both methods can be written as “minimise squared error, subject to keeping the coefficients inside a ball”. The L1 ball is a diamond with corners on the axes; the L2 ball is a sphere, smooth everywhere. Expand the least-squares contours until they first touch the ball, and that contact point is the answer.
A corner sitting on an axis is a coefficient of zero, and corners stick out, so they get touched first. That is the standard argument and it is correct — but it is a picture, and pictures are not evidence. So measure it: project random points onto each ball and count how often the result lands on an axis.
import numpy as np
# The standard picture: constrain the coefficients inside a ball, expand the
# least-squares contours until they touch it. The L1 ball (a diamond) has
# CORNERS on the axes; the L2 ball (a circle) is smooth everywhere. A corner
# sitting on an axis IS a zero coefficient.
#
# "Corners stick out" is a picture, not evidence. So measure it: project many
# random points onto each ball and count how often the answer lands on an axis.
rng = np.random.default_rng(1)
def project_l1(v, r): # onto the diamond {||b||_1 <= r}
if np.abs(v).sum() <= r:
return v
u = np.sort(np.abs(v))[::-1]
c = np.cumsum(u)
rho = np.nonzero(u * np.arange(1, len(v) + 1) > (c - r))[0][-1]
return np.sign(v) * np.maximum(np.abs(v) - (c[rho] - r) / (rho + 1.0), 0)
def project_l2(v, r): # onto the sphere {||b||_2 <= r}
n = np.linalg.norm(v)
return v if n <= r else v * r / n
TRIALS = 20_000
print(f"{'p':>4} {'L1 -> a zero':>14} {'L2 -> a zero':>14}")
for p in (2, 5, 20):
l1 = sum((project_l1(rng.normal(size=p) * 2, 1.0) == 0).any()
for _ in range(TRIALS))
l2 = sum((project_l2(rng.normal(size=p) * 2, 1.0) == 0).any()
for _ in range(TRIALS))
print(f"{p:>4} {100 * l1 / TRIALS:>13.1f}% {100 * l2 / TRIALS:>13.3f}%")
# The corner is not a rare special case. By 20 dimensions it is the rule,
# because the diamond is nearly all corner. The sphere produced a zero
# exactly zero times in 60,000 attempts: landing on an axis requires aiming
# at it perfectly, and nothing makes that happen.
| Dimensions | L1 ball gives a zero | L2 ball gives a zero |
|---|---|---|
| p = 2 | 51.8% | 0% |
| p = 5 | 99.8% | 0% |
| p = 20 | 100% | 0% |
The corner is not an edge case that textbook diagrams exaggerate. By twenty dimensions it is the rule, because a high-dimensional diamond is almost entirely corner — and real models have far more than twenty features. The sphere produced a zero not once in 60,000 attempts, because landing on an axis requires aiming at it perfectly and nothing in the geometry encourages that.
[i] The four arguments are one argument
Each view describes the same fact from a different angle, and it is worth being able to give whichever one your audience will accept:
- Calculus — the L2 gradient vanishes at the origin; the L1 gradient does not.
- Algebra — lasso subtracts and can hit zero; ridge divides and cannot.
- Optimisation — a kink gives an interval of valid slopes, so a whole range of inputs maps to zero; smoothness gives an equation, so only one input does.
- Geometry — the diamond has corners on the axes; the sphere has none.
Underneath, all four say: absolute value has a corner at zero and squaring does not. Every difference in behaviour is that corner.
[R] Why this matters at 3 a.m.
A demand model with 400 candidate features. Ridge gives all 400 a nonzero weight, including the ones worth 10−9. Every one is a live dependency: a column to compute, a join to maintain, a service that can fail at 3 a.m. and take the forecast with it. Lasso keeps 30 and returns exact zeros for the rest, so you can drop those columns from the pipeline entirely and know the model has not changed. “Nearly zero” and “zero” are the same number to a statistician and completely different to whoever is on call.
[!] None of this makes lasso better
It makes lasso different. Section 21.16 measured ridge losing badly when only 6 of 80 features mattered — but with genuinely correlated predictors, lasso picks one of a correlated group arbitrarily and zeroes its neighbours, which can be unstable across resamples and actively misleading if you intend to interpret the survivors. Ridge keeps the whole group and shares the weight. That is the trade-off elastic net exists to manage, which is the next section.
21.18 Elastic net, and choosing between the three
Elastic net uses both penalties at once, mixing them with a ratio you choose. It exists because lasso has a specific weakness: faced with a group of correlated features it tends to pick one arbitrarily and zero the rest, and which one it picks can flip with a small change in the data. Elastic net's ridge component keeps correlated features together, so the selection is more stable.
[+] Which penalty, given what you know
| Situation | Use | Because |
|---|---|---|
| Most features probably matter a little | Ridge | Shrinks all, discards none |
| Few features probably matter a lot | Lasso | Zeroes the rest, giving a readable model |
| Sparse and correlated groups | Elastic net | Selects, but keeps groups intact |
| More columns than rows | Lasso or elastic net | Ridge alone may not be enough — see above |
| You need every coefficient interpretable | Lasso | A zero is the clearest statement there is |
Tune alpha by cross-validation, never by eye. RidgeCV,
LassoCV and ElasticNetCV do it in one line, and the
numbers in this chapter all come from those.
21.19 Standardise first, always
Every penalty above sums up coefficient sizes. A coefficient's size depends on the units of its feature. Therefore the penalty depends on your units — which is absurd, since nothing about the problem changes when you record revenue in pounds instead of pence.
import numpy as np
from sklearn.linear_model import Ridge
from sklearn.datasets import make_regression
X, y = make_regression(n_samples=200, n_features=5, noise=10.0, random_state=0)
# Simulate a realistic mixed-unit table: one column in millions, one tiny.
X_mixed = X.copy()
X_mixed[:, 0] *= 1_000_000 # e.g. revenue in currency units
X_mixed[:, 1] /= 1_000 # e.g. a rate
a = Ridge(alpha=1.0).fit(X, y).coef_
b = Ridge(alpha=1.0).fit(X_mixed, y).coef_
print("same data, different units, SAME penalty:")
print(f" col 0 (x1,000,000): {a[0]:10.3f} -> {b[0]:.8f}")
print(f" col 1 (/1,000): {a[1]:10.3f} -> {b[1]:10.3f}")
# Both columns are damaged, in opposite ways, and neither is what you meant.
# The big-unit column needs only a minuscule coefficient to do its work, so
# the penalty barely touches it -- it is effectively exempt. The small-unit
# column needs a LARGE coefficient to have the same influence, and a large
# coefficient is exactly what the penalty punishes, so the model gives up on
# the feature instead.
[!] The damage runs in both directions, and one of them is counter-intuitive
The column multiplied by a million needs only a minuscule coefficient to exert its influence. Its coefficient drops from 77.5 to 0.00008433 — and a coefficient that small costs essentially nothing under the penalty, so that feature is effectively exempt from regularisation.
The column divided by a thousand is the interesting one. You might reason that since its values are tiny, its coefficient must become huge — and it would, without a penalty. But a huge coefficient is exactly what the penalty punishes, so the model does the other thing available to it: it gives up on the feature. Its coefficient falls, from 70.5 to 11.7. The feature is quietly suppressed, not amplified.
So: standardise before any penalised fit, and do it inside the
cross-validation loop with a Pipeline. Scaling on the full dataset
first leaks the test set's mean and standard deviation into training.
21.20 Stepwise selection, and why to distrust it
Stepwise selection adds the most helpful feature, then the next, and so on (forward), or starts with everything and drops the least useful (backward), stopping when some criterion stops improving. It is intuitive, it is widely taught, and it has a serious flaw.
[!] The p-values it produces are not real
The procedure looks at the data, picks the winners, and then reports significance as though those features had been specified in advance. They were not: they were chosen precisely because they looked good on this sample. Some of them look good by luck, and the reported p-values do not account for the search.
Run stepwise selection on a table of pure random noise with enough columns and it will hand you a model of "significant" predictors. This is the same multiple-comparisons problem as chapter 19's peeking, wearing different clothes.
There is a second, quieter problem: stepwise is greedy. Having taken the best single feature first, it can never find a pair that is powerful together but unimpressive individually. Lasso considers all coefficients simultaneously and does not have this blind spot, which is the main reason to prefer it.
21.21 Filter, wrapper, embedded
Every variable-selection method falls into one of three families. Knowing the family tells you the cost and the failure mode.
[i] The three families
| Family | How it works | Cost | Main weakness |
|---|---|---|---|
| Filter correlation, chi-square, mutual information, variance threshold |
Score each feature against the target on its own, before any model | Very cheap | Blind to interactions; keeps redundant twins |
| Wrapper stepwise, recursive feature elimination |
Repeatedly fit the model on subsets and compare | Expensive | Overfits the selection; greedy |
| Embedded lasso, elastic net, tree importances |
Selection happens during fitting | Free — you were fitting anyway | Tied to that model's assumptions |
[+] A defensible default
Use a filter pass to delete the obviously dead — zero variance, near duplicates, more than half missing. Then let an embedded method do the real work: lasso for a linear model, or a gradient-boosted model with held-out permutation importance (21.30). Reach for a wrapper only when features are genuinely expensive to collect and you must justify each one, and even then wrap the whole thing in an outer cross-validation loop so the selection itself is scored honestly.
21.22 When to remove a variable
Selection methods tell you what is predictive. They cannot tell you what is allowed, or what is stable, and those are often the decisions that matter more.
[i] Reasons to drop a feature, strongest first
| Reason | Test | Action |
|---|---|---|
| It leaks the future | Would this value exist, at this timestamp, at prediction time? | Remove. Not negotiable, however good it looks. |
| It is legally or ethically off-limits | Protected characteristic, or a close proxy for one | Remove, and check the proxies too. |
| It will not exist in production | Available in the training warehouse but not at serving time | Remove. Common and painful. |
| It is unstable over time | Distribution shifts between train and recent data | Remove or re-engineer. It will decay (18.18). |
| It is a near-duplicate | VIF above 10, correlation above 0.95 | Drop one, if you need interpretation (21.7). |
| It adds nothing measurable | Held-out score unchanged without it | Drop for simplicity, not for accuracy. |
Only the last row is about predictive power — and it is the weakest reason on the list. The first three will get a model pulled from production; a marginally lower AUC will not.
[R] The feature that was too good
A churn model reaches 0.98 AUC overnight after someone adds
days_since_last_login. It is measured as of today, and
customers who churned stopped logging in weeks ago. The feature is not predicting
churn; it is reporting it. In production, where "today" is before the churn rather
than after, the model collapses. The tell is that top row of the table: at
prediction time, that value does not yet exist.
21.23 One tree, and what a split optimises
A decision tree asks a sequence of yes/no questions about one feature at a time. Everything in the tree family — forests, boosting, the whole gradient-boosted industry — is built from this one object, so it pays to be exact about what it does.
To grow a tree, the algorithm tries every threshold on every feature, scores each candidate split by how much purer it makes the two resulting groups, and keeps the best one. Then it repeats on each group. That is the whole algorithm. The only remaining question is when to stop, which turns out to be the question that matters.
21.24 Gini, entropy, and variance reduction
"Purer" needs a definition. A node is pure if everything in it belongs to one class, and maximally impure if the classes are evenly mixed.
import numpy as np
def gini(labels):
if len(labels) == 0:
return 0.0
p = np.mean(labels)
return 2 * p * (1 - p) # for 2 classes: 1 - p^2 - (1-p)^2
def entropy(labels):
if len(labels) == 0:
return 0.0
p = np.mean(labels)
if p in (0.0, 1.0):
return 0.0
return -(p * np.log2(p) + (1 - p) * np.log2(1 - p))
parent = np.array([1, 1, 1, 1, 0, 0, 0, 0]) # perfectly mixed
left = np.array([1, 1, 1, 0])
right = np.array([1, 0, 0, 0])
def weighted(fn, l, r):
n = len(l) + len(r)
return (len(l) / n) * fn(l) + (len(r) / n) * fn(r)
print(f"parent gini {gini(parent):.3f} entropy {entropy(parent):.3f}")
print(f"split gini {weighted(gini, left, right):.3f} "
f"entropy {weighted(entropy, left, right):.3f}")
print(f"gain gini {gini(parent) - weighted(gini, left, right):.3f} "
f"entropy {entropy(parent) - weighted(entropy, left, right):.3f}")
# A tree tries every split on every feature and keeps the biggest gain.
# That is the entire algorithm. Everything else is when to stop.
[i] The three impurity measures
| Measure | Used for | Range (2 classes) |
|---|---|---|
| Gini | Classification. The default nearly everywhere. | 0 (pure) to 0.5 |
| Entropy | Classification. Information-theoretic reading. | 0 to 1 bit |
| Variance (MSE) | Regression. A leaf predicts the mean of its rows. | 0 upward |
Gini versus entropy almost never changes the tree. They agree on the ranking of splits nearly always, and entropy costs a logarithm per evaluation. Pick Gini and spend your attention on depth instead. If an interviewer asks which is better, the honest answer — that the choice is immaterial next to the stopping rules — is the one that shows experience.
21.25 Pruning, depth, and the stopping rules
Left alone, a tree keeps splitting until every leaf is pure. It can always achieve this, if necessary by isolating single rows. At that point it has memorised the training set.
The left panel is the shape to remember: training error falls forever as depth grows, while held-out error falls, bottoms out, and then climbs again. Everything to the right of that minimum is the model learning noise. The measured numbers on the right make it concrete — an unrestricted tree reached depth 19 with 335 leaves, scored a perfect 1.0 AUC on its training data, and managed only 0.866 on data it had not seen.
[+] The knobs, and which ones earn their keep
| Parameter | What it does | Worth tuning? |
|---|---|---|
max_depth | Hard ceiling on questions asked | Yes — the highest-leverage knob |
min_samples_leaf | Refuses leaves built on too few rows | Yes — directly limits memorising |
min_samples_split | Refuses to split a small node at all | Sometimes |
max_features | Considers a random subset per split | For forests, yes (21.26) |
ccp_alpha | Cost-complexity pruning: grow fully, then cut back | Yes, and principled |
Pre-pruning (stopping early) is cheap but short-sighted: it may reject a
mediocre split whose children would have been excellent. Post-pruning
(ccp_alpha) grows the tree out and then removes branches that do not
pay for themselves, which avoids that trap at the cost of more compute.
21.26 Bagging and random forests
A single deep tree has low bias and terrible variance: it can express almost any shape, but shift a few training rows and you get a visibly different tree. Averaging is the classic remedy for variance, and that is exactly what a forest is.
Bagging trains many trees on bootstrap samples — each drawn with replacement, so each tree sees a slightly different dataset — and averages their predictions. The errors are partly independent, so averaging cancels some of them.
Random forests add one more idea, and it is the important one. At every split, each tree may only consider a random subset of the features. This sounds like sabotage. It works because bagged trees are still highly correlated: if one feature is strongly predictive, every tree splits on it first and they all end up similar, so averaging buys little. Forcing trees to sometimes ignore the best feature decorrelates them, and averaging decorrelated errors cancels far more.
[i] Measured, 6000 rows, 5-fold CV AUC
| Model | AUC | Note |
|---|---|---|
| Decision stump (depth 1) | 0.709 | One question. Badly underfit. |
| Tree, unrestricted | 0.864 | Memorises |
| Tree, depth 5 | 0.915 | Beats the unrestricted tree |
| Bagging, 100 trees | 0.961 | Averaging alone |
| Random forest, 300 trees | 0.964 | Averaging + decorrelation |
| Extra trees, 300 | 0.967 | More randomness still (21.27) |
| Gradient boosting | 0.957 | A different idea entirely (21.28) |
The third row is the one to sit with: a depth-5 tree beats the unrestricted tree, 0.915 against 0.864. Constraining the model made it better, because the extra capacity was being spent on noise.
21.27 Extra trees, and why more randomness helps
Extremely randomised trees push the idea one step further. A random forest picks the best threshold among a random subset of features; extra trees pick a random threshold too, and keep the best of those.
Each individual tree is therefore worse. The ensemble often is not, because the trees are even less correlated and there is more variance to cancel. They are also noticeably faster to train, since scanning for the optimal split point is the expensive part and they skip it. Above, extra trees scored 0.967 against the forest's 0.964 — a real if modest edge on this data.
[=] Out-of-bag scoring, free of charge
Each bagged tree is trained on a bootstrap sample that omits roughly a third of the
rows. Those rows are "out of bag" for that tree, and can be predicted by it as
genuine held-out data. Averaging over the forest gives a validation estimate
without a separate validation set (oob_score=True). Useful when
data is scarce — though with grouped or time-ordered data it inherits all the
leakage problems of a random split, so treat it with the same suspicion.
21.28 Boosting: AdaBoost to gradient boosting
Bagging builds trees in parallel and averages them; every tree is an equal vote and none knows the others exist. Boosting builds trees in sequence, each one trained specifically to fix what the current ensemble is still getting wrong.
AdaBoost (1995) does this by reweighting rows: after each tree, examples that were misclassified get more weight, so the next tree pays them more attention. The final prediction is a weighted vote, with more accurate trees getting louder votes.
Gradient boosting reframes that idea in a way that generalises to any differentiable loss. Instead of reweighting rows, each new tree is fitted to the negative gradient of the loss with respect to the current predictions. For squared error the gradient is just the residual, so the picture is simple: each tree predicts the leftover error of everything before it, and you add a small fraction of it — the learning rate — to the running total.
[i] The two families, side by side
| Bagging / random forest | Boosting | |
|---|---|---|
| Trees are built | In parallel, independently | In sequence, each fixing the last |
| Base tree | Deep, low bias, high variance | Shallow, high bias, low variance |
| Chiefly reduces | Variance | Bias |
| More trees | Cannot overfit; just slower | Can overfit — needs early stopping |
| Parallelisable | Trivially | Not across trees |
| Hyperparameter care | Forgiving | Sensitive, especially learning rate |
The fourth row is the practical one and a favourite interview question. Adding trees to a forest is safe. Adding trees to a boosted model is not, because each new tree deliberately chases the remaining error — and eventually the only error left is noise.
[+] Learning rate and tree count are one decision
They trade off directly: halve the learning rate and you need roughly twice the trees for the same fit. Lower rates generalise better but cost more. The standard recipe is to fix a low-ish rate (0.05 or 0.1), set the tree count high, and let early stopping on a validation set decide when to quit — which turns two hyperparameters into one.
21.29 XGBoost, LightGBM, CatBoost
All three are gradient boosting. They differ in engineering, and the differences do change which one you should reach for.
[i] What each actually changed
| Library | Its idea | Reach for it when |
|---|---|---|
| XGBoost (2014) | Second-order (Newton) boosting, an explicit regularisation term in the objective, sparsity-aware splits, heavy systems optimisation | You want the dependable default with the largest community |
| LightGBM (2017) | Leaf-wise growth instead of level-wise, plus histogram binning of feature values | Large datasets, or training time matters. Usually the fastest. |
| CatBoost (2017) | Ordered target statistics for categoricals and ordered boosting, to remove the target-leakage bias in naive target encoding | Many high-cardinality categorical features |
[!] Leaf-wise growth: fast, and easier to overfit
Level-wise growth expands a whole depth layer at a time. LightGBM's leaf-wise
growth repeatedly splits whichever single leaf promises the largest gain,
producing deep, lopsided trees that reach a lower loss with fewer nodes. The catch:
a deep narrow branch can be built to serve a handful of rows. On small datasets
LightGBM overfits more readily than XGBoost at default settings, and
num_leaves is the parameter to control — not max_depth.
[+] The honest summary
Properly tuned, on tabular data of ordinary size, these three land within noise of each other. Choose on constraints — training speed, categorical handling, deployment story — rather than on an expected accuracy difference, and be sceptical of benchmark tables that show one comfortably ahead without reporting the tuning budget each received.
21.30 Feature importance, and how it misleads
Every tree library exposes feature_importances_, and it is one of the
most-quoted and least-questioned numbers in applied ML. It is computed by adding up
how much each feature reduced impurity across all splits. That has a bias built into
it.
import numpy as np
from sklearn.ensemble import RandomForestClassifier
from sklearn.inspection import permutation_importance
from sklearn.model_selection import train_test_split
rng = np.random.default_rng(7)
n = 3000
signal = rng.integers(0, 2, n) # genuinely predictive
noise_bin = rng.integers(0, 2, n) # pure noise, 2 levels
noise_id = rng.integers(0, 1000, n) # pure noise, 1000 levels
y = signal ^ (rng.random(n) < 0.15).astype(int)
X = np.column_stack([signal, noise_bin, noise_id]).astype(float)
Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.4, random_state=0)
rf = RandomForestClassifier(n_estimators=300, random_state=0,
n_jobs=-1).fit(Xtr, ytr)
names = ["signal", "noise (2 levels)", "noise (1000 levels)"]
print("impurity importance (the .feature_importances_ everyone uses):")
for nm, v in zip(names, rf.feature_importances_):
print(f" {nm:<22} {v:.3f}")
pi = permutation_importance(rf, Xte, yte, n_repeats=20,
random_state=0, scoring="roc_auc")
print("permutation importance, measured on HELD-OUT data:")
for nm, v in zip(names, pi.importances_mean):
print(f" {nm:<22} {v:+.3f}")
[!] A pure-noise column rated 48.0% important
The setup: one genuinely predictive binary feature, one binary noise column, and one noise column of random integers with 1000 distinct values. Nothing about the third column has any relationship to the target.
Impurity importance gives it 0.48 — nearly as much credit as the real signal's 0.517. The mechanism is simple once seen: a feature with many distinct values offers many candidate split points, so by chance some of them separate the training rows well. Impurity importance counts that as usefulness. The metric rewards cardinality, not predictiveness.
Held-out permutation importance — shuffle one column, see how much the held-out score drops — correctly reports 0.013 for the noise and 0.338 for the real feature.
[!] And permutation importance is not automatically the fix
Run the same permutation test on the training rows instead of held-out rows and the noise column scores 0.169 — back to looking important. Of course it does: the model memorised that column, so destroying it hurts training performance. The fix is not "use permutation importance", it is "measure importance on data the model has not seen". The same trap catches SHAP values computed on training rows.
[+] What to trust, in order
- Held-out permutation importance — directly answers "does the model's performance depend on this?"
- SHAP values on held-out data — per-row attributions, useful for explaining an individual decision; more expensive, and still needs unseen data.
- Drop-column importance — retrain without the feature. The most honest and the most expensive, since it costs a full refit per feature.
- Impurity importance — free, and fine for a rough glance, provided every feature has similar cardinality. Never put it in a slide for a decision-maker.
[!] All of them still split credit arbitrarily between correlated features
If two features carry the same information, permuting either one alone barely hurts — the model just leans on the other. Both will look unimportant, and you may drop both and watch performance collapse. This is 21.7's multicollinearity problem reappearing in a different guise. Permute correlated features as a group, or cluster them first.
21.31 The confusion matrix, slowly
Every classification metric in existence is built from four counts. Get these genuinely solid and the rest of this chapter is arithmetic; stay fuzzy on them and no amount of memorising formulas will help.
import numpy as np
# Everything in classification metrics is four numbers. Build them once,
# by hand, and every formula afterwards is obvious.
y_true = np.array([1, 1, 1, 1, 0, 0, 0, 0, 0, 0])
y_pred = np.array([1, 1, 0, 0, 1, 0, 0, 0, 0, 0])
TP = int(((y_true == 1) & (y_pred == 1)).sum()) # said yes, was yes
FP = int(((y_true == 0) & (y_pred == 1)).sum()) # said yes, was no (false alarm)
FN = int(((y_true == 1) & (y_pred == 0)).sum()) # said no, was yes (miss)
TN = int(((y_true == 0) & (y_pred == 0)).sum()) # said no, was no
print(f" predicted 1 predicted 0")
print(f"actual 1 {TP:>11} {FN:>11}")
print(f"actual 0 {FP:>11} {TN:>11}")
precision = TP / (TP + FP) # of those I FLAGGED, how many were right?
recall = TP / (TP + FN) # of those that EXIST, how many did I catch?
print(f"precision {precision:.2f} recall {recall:.2f}")
# Read the denominators. Precision divides by what the MODEL said.
# Recall divides by what REALITY contains. That is the whole difference.
[=] Reading "false positive" correctly
The second word is what the model said; the first word is whether it was wrong. A false positive is a positive prediction that was wrong — a false alarm. A false negative is a negative prediction that was wrong — a miss. People reverse these under pressure. Read the second word first.
[i] The two you must never confuse
Precision = TP / (TP + FP). Of everything I flagged, how much was right? The denominator is what the model said. It answers: can I trust an alert?
Recall = TP / (TP + FN). Of everything that was really there, how much did I catch? The denominator is what reality contains. It answers: how much am I missing?
Two more that appear constantly and are worth naming, because ROC curves are built from them. Sensitivity is another word for recall. Specificity is recall for the negative class: TN / (TN + FP), the fraction of genuine negatives correctly left alone. The false positive rate is 1 − specificity.
21.32 Precision, recall, and the threshold
Here is the thing that reframes everything. A classifier does not output a class. It outputs a score, and somebody — usually a library default nobody examined — chose to cut it at 0.5.
Change the cut and every one of those four counts changes with it. Lower the threshold and you flag more things: recall rises, precision falls. Raise it and the reverse. Precision and recall are not properties of a model. They are properties of a model at a chosen operating point, and quoting them without the threshold is meaningless.
[!] Accuracy at the default threshold, the most common bad report
On a dataset with 5% positives, a model that predicts "no" every single time scores 0.95 accuracy. It has no skill whatsoever. Balanced accuracy sees through it immediately (0.5 — exactly chance), as does MCC (0.0) and Cohen's kappa (0.0).
In the larger measured example in 21.34, 89 positives in 10000 rows, the do-nothing classifier scores 99.11% accuracy.
import numpy as np
from sklearn.metrics import (accuracy_score, balanced_accuracy_score,
matthews_corrcoef, f1_score)
# 5% of rows are positive. A model that predicts "no" for everything.
y = np.array([0] * 95 + [1] * 5)
lazy = np.zeros(100, dtype=int)
print(f"accuracy {accuracy_score(y, lazy):.3f} <- looks great")
print(f"balanced accuracy {balanced_accuracy_score(y, lazy):.3f} <- honest")
print(f"F1 {f1_score(y, lazy, zero_division=0):.3f}")
print(f"MCC {matthews_corrcoef(y, lazy):.3f} <- 0 = no skill")
# Accuracy rewards the majority class for existing. The other three do not.
21.33 F-beta, and choosing beta
You often need one number to compare models. F-beta combines precision and recall into one, weighting recall as beta times more important than precision.
def fbeta(precision, recall, beta):
"""F-beta is a weighted harmonic mean. beta = how many times more
you care about recall than precision."""
b2 = beta * beta
return (1 + b2) * precision * recall / (b2 * precision + recall)
p, r = 0.80, 0.40 # precise but misses a lot
for beta in (0.5, 1.0, 2.0):
print(f"beta {beta}: F = {fbeta(p, r, beta):.3f}")
# Sanity: beta=1 is the plain harmonic mean, sitting nearer the WORSE value.
print(f"arithmetic mean would flatter it: {(p + r) / 2:.3f}")
print(f"F1 refuses to: {fbeta(p, r, 1.0):.3f}")
It is a harmonic mean, not an arithmetic one, and that is deliberate: the harmonic mean is dragged toward the smaller of the two values. Precision 0.8 with recall 0.4 gives an arithmetic mean of 0.6 but an F1 of 0.53. You cannot rescue a bad recall with an excellent precision, which is exactly the behaviour you want from a summary score.
[i] What beta does, measured
Beta does not change the model. It changes where on the precision-recall curve you choose to stand. Same predictions, same data, five values of beta:
| Beta | Threshold | Precision | Recall | Reading |
|---|---|---|---|---|
| 0.25 | 2.89 | 69.8% | 9.8% | Precision at almost any cost |
| 0.5 | 2.12 | 45.7% | 29.2% | Precision matters twice as much |
| 1.0 | 1.79 | 38.5% | 43.0% | Balanced (plain F1) |
| 2.0 | 1.13 | 23.2% | 67.3% | Recall matters twice as much |
| 4.0 | 0.67 | 15.6% | 82.1% | Catch nearly everything |
[+] How to actually choose beta
Answer one question: how many false alarms would you accept to avoid one miss? That number is beta squared. Not beta — beta squared. If you would tolerate nine extra false alarms to catch one more real case, beta = 3.
Asked the other way for a precision-critical system: how many missed cases would you accept to avoid one false alarm? If four, then beta² = 1/4 and beta = 0.5.
[R] Two systems, two betas
Fraud screening that blocks a transaction. A false alarm blocks a real customer at checkout — expensive and infuriating. A miss costs the fraud amount. If a block costs more than a typical fraud, you want beta below 1, perhaps 0.5. Precision is the priority.
Screening for a safety recall. A false alarm means an unnecessary inspection. A miss means a defective product stays in homes. Nobody trades those evenly: beta of 3 or 4. Catch everything and pay for the inspections.
[!] The rule everyone repeats, and what it actually does
You will be told that beta = √(costFN / costFP). It is a reasonable heuristic for picking a beta, and the direction is right. But it is widely stated as though optimising F-beta then finds the cost-minimising threshold, and it does not. Measured on 20000 rows:
| Cost ratio FN:FP | Beta | Cost-optimal threshold | F-beta's threshold |
|---|---|---|---|
| 1:1 | 1.0 | 2.85 | 1.79 |
| 4:1 | 2.0 | 1.8 | 1.13 |
| 16:1 | 4.0 | 0.87 | 0.67 |
| 1:4 | 0.5 | 3.23 | 2.12 |
F-beta lands consistently lower than the cost optimum — it flags more than it should. The reason is structural: F-beta has no term for true negatives. Precision and recall both ignore TN entirely, so F-beta cannot perceive the benefit of correctly leaving the vast negative majority alone. Cost minimisation counts every one of them.
So use beta = √(cost ratio) to choose a beta for comparing models. Do not use F-beta to set your production threshold. For that, minimise cost directly — 24.37.
21.34 ROC-AUC versus PR-AUC
Both summarise a classifier across all thresholds at once, which is what makes them useful for comparing models before you have chosen an operating point. They differ in what they plot, and under imbalance they tell strikingly different stories.
The ROC curve plots recall against the false positive rate. ROC-AUC has a genuinely intuitive reading: take one random positive and one random negative; ROC-AUC is the probability the model scores the positive higher. 0.5 is a coin flip, 1.0 is perfect.
The PR curve plots precision against recall. Its baseline is not 0.5 — a random classifier scores the prevalence, so on 1% positives the floor is 0.01.
[!] Why ROC-AUC flatters an imbalanced problem
Same predictions, same 10000 rows, 89 of them positive: ROC-AUC 0.903 against PR-AUC 0.508.
Look at the denominators. The false positive rate divides by the number of true negatives — here 10000 minus 89. Add a hundred false alarms to a pool that size and the FPR moves by about one percentage point; the ROC curve barely twitches. But precision divides by the number of things you flagged, and a hundred false alarms against 89 possible true ones is a catastrophe. Precision collapses.
Neither number is wrong. They answer different questions. ROC asks "how well does this rank?"; PR asks "if I act on the alerts, how much of my work is wasted?"
[+] Which to use
| Use | When | Because |
|---|---|---|
| ROC-AUC | Roughly balanced classes; both errors comparable; comparing rankers | Insensitive to prevalence, so it is stable across datasets |
| PR-AUC | Rare positives; you act on the positives; false alarms cost real work | Reflects the experience of whoever handles the alerts |
PR-AUC's baseline moves with prevalence, so PR-AUC 0.5 is superb at 1% prevalence and unremarkable at 40%. Always report prevalence beside it. ROC-AUC's stability across prevalence is precisely why it is comparable across datasets and also why it can hide a practical disaster.
21.35 The metrics nobody teaches you
Beyond the standard set are several that solve specific problems well, and knowing one or two signals real experience.
[i] Worth having in your vocabulary
| Metric | What it does | Reach for it when |
|---|---|---|
| MCC Matthews correlation |
Correlation between prediction and truth, using all four cells. −1 to +1. | You want one honest number under imbalance. Arguably the best single summary there is. |
| Cohen's kappa | Agreement above what chance would give | Comparing against human labellers; inter-rater agreement |
| Balanced accuracy | Mean of recall on each class | You want accuracy's simplicity without its blindness to imbalance |
| Brier score | Mean squared error of the predicted probabilities | The probability itself is the product (21.36) |
| Log loss | Punishes confident errors without bound | Comparing probabilistic models; already your training loss |
| Lift / gain | How much better than random within the top k% | Marketing and outreach with a fixed contact budget |
| Precision@k | Precision within the top k scored items | Capacity is fixed: reviewers can only check 100 cases a day |
| Partial AUC | Area under only the usable region of the ROC curve | Only very low false-positive rates are operationally acceptable |
| G-mean | Geometric mean of sensitivity and specificity | Both classes matter and you refuse to trade one away |
[+] Two that deserve more use than they get
MCC is the one to remember. It uses all four cells of the confusion matrix — unlike F-beta, which ignores true negatives entirely — and it is high only when the model does well on both classes. The all-negative classifier from 21.32 scores exactly 0.0 on it.
Precision@k is the one that matches reality most often and appears in textbooks least. If your fraud team can review 200 cases a day, the model's AUC is close to irrelevant; what matters is how many of the top 200 are real. Optimising anything else is optimising for a situation you are not in.
21.36 Calibration, and why ranking is not enough
Every metric so far only cares about order. Push all your predicted probabilities through any monotonic squashing function and ROC-AUC, PR-AUC and precision@k are all completely unchanged.
That is fine if you only need a ranking. It is not fine the moment anyone multiplies your output by money.
[!] When the number itself is the product
Expected loss is probability times exposure. If the model says 0.10 and the true rate among such cases is 0.30, every downstream financial calculation is wrong by a factor of three — while the AUC stays perfect, because the ranking never changed. A model can be excellent at ordering and useless at pricing.
Calibration asks the direct question: of all the cases you scored 0.10, did about 10% actually happen? Plot predicted probability against observed frequency in bins; a calibrated model sits on the diagonal. Measure it with the Brier score (mean squared error of the probabilities — 0.0379 for the logistic model used in 21.37) or with log loss.
[i] Who is calibrated out of the box
| Model | Typically | Note |
|---|---|---|
| Logistic regression | Well calibrated | Log loss is a proper scoring rule; calibration is what it optimises |
| Random forest | Pulled toward the middle | Averaging votes rarely yields 0 or 1 |
| Gradient boosting | Often over-confident | Pushed toward the extremes |
| SVM | Not probabilities at all | Distances to a boundary; must be converted |
Fix with CalibratedClassifierCV: Platt scaling (fit a logistic
regression to the scores) for small data, isotonic regression (fit any
monotonic step function) when you have a few thousand rows to spare. Always
calibrate on held-out data — calibrating on training predictions
teaches the calibrator the model's memorisation.
21.37 Choosing a threshold with money
This is where the chapter has been heading. Metrics are proxies. The thing you actually want to minimise is cost, and if you can put numbers on the two kinds of mistake you can skip the proxy entirely.
import numpy as np
from sklearn.datasets import make_classification
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
X, y = make_classification(n_samples=20000, n_features=8, n_informative=5,
weights=[0.95, 0.05], random_state=0)
Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.5, random_state=0)
model = LogisticRegression(max_iter=2000).fit(Xtr, ytr)
p = model.predict_proba(Xte)[:, 1]
# Put money on the outcomes. THIS is the conversation to have with the
# business, instead of arguing about which metric is "best".
COST_FN = 50.0 # a missed fraud: we refund the customer
COST_FP = 4.0 # a false alarm: manual review by an analyst
def total_cost(t):
fn = int(((yte == 1) & (p < t)).sum())
fp = int(((yte == 0) & (p >= t)).sum())
return fn * COST_FN + fp * COST_FP
grid = np.linspace(0.01, 0.99, 99)
costs = [total_cost(t) for t in grid]
best = float(grid[int(np.argmin(costs))])
print(f"default threshold 0.50 costs {total_cost(0.5):>9,.0f}")
print(f"best threshold {best:.2f} costs {total_cost(best):>9,.0f}")
print(f"saving: {total_cost(0.5) - total_cost(best):>9,.0f}")
# For well-calibrated probabilities the optimum is analytic:
print(f"theory says: {COST_FP / (COST_FP + COST_FN):.3f}")
[+] The analytic shortcut
For calibrated probabilities you do not need the grid search. Act whenever the expected cost of acting is below the expected cost of not acting, and the algebra collapses to:
threshold = costFP / (costFP + costFN)
Misses ten times worse than false alarms? Threshold 1/11 = 0.09, not 0.5. Measured against a real logistic model:
| FN:FP | Formula says | Grid search finds |
|---|---|---|
| 1:1 | 0.5 | 0.429 |
| 5:1 | 0.167 | 0.199 |
| 10:1 | 0.091 | 0.118 |
| 1:5 | 0.833 | 0.814 |
Close, not exact — the residual gap is imperfect calibration, which is precisely why 21.36 comes first. The formula is only as good as your probabilities. Use it to get near the answer, then confirm on a validation set.
[R] The conversation this lets you have
Instead of "our F1 is 0.62", you can say: "at the current threshold we catch 71% of fraud and send 340 cases a week to review. Dropping the threshold to 0.15 catches 84% but sends 900. At £4 a review and £50 a missed case, the second option saves about £2,100 a week and needs two more reviewers." That is a decision a business can make. An F1 score is not.
21.38 A decision guide
Everything above, compressed into the questions actually worth asking.
[1] Which model family?
| If | Start with | Why |
|---|---|---|
| You must explain every coefficient to a regulator | Linear / logistic | Nothing else is as defensible |
| Tabular data, mixed types, you want accuracy | Gradient boosting | Still the strongest default on tables |
| Wide data, few rows, sparse truth | Lasso / elastic net | Trees struggle when p >> n |
| You need a fast, forgiving baseline | Random forest | Works untuned; hard to break |
| Relationships are smooth and roughly linear | Regularised linear | Trees approximate slopes with staircases |
| Strong interactions, thresholds, non-monotonic effects | Trees | Interactions are free; linear models need them hand-built |
| The probability itself is the product | Logistic, or boosting + calibration | 21.36 |
Always fit the linear model too, even when you expect to ship the boosted one. It costs a minute and it tells you how much the complexity is actually buying.
[2] Which metric?
| Question you are answering | Metric |
|---|---|
| How well does this rank, in general? | ROC-AUC |
| Rare positives, and I act on the alerts | PR-AUC |
| One number, imbalanced, no tuning knob | MCC |
| I know the relative cost of the two errors | F-beta to compare models, cost to set the threshold |
| Fixed review capacity | Precision@k |
| The probability feeds a financial calculation | Brier score, log loss, calibration plot |
| Regression, stakeholder-facing | MAE with RMSE beside it, versus a naive baseline |
| Regression, intermittent or zero-heavy | MASE (never MAPE) |
[3] Which variables?
Remove leakage, illegal features, and anything unavailable at serving time first — before you look at a single score. Then filter the dead columns. Then let lasso or held-out permutation importance rank what remains. Never let a selection method overrule the first step.
21.39 Key takeaways
[+] The dozen worth keeping
- The loss function is the modelling assumption. Squared error fears outliers, absolute error shrugs, log loss fears confident mistakes. Choosing one is a business decision.
- Linear regression's assumptions are not equally important. Independence and linearity bite hard; normality of residuals almost never does.
- Multicollinearity breaks interpretation, not prediction. The sum of correlated coefficients stays right; the split between them is noise.
- Logistic regression is linear in the log-odds. A coefficient is an odds ratio, and "5% higher odds" is not "5% more likely".
- Standardise before any penalty — and note that both large-unit and small-unit features are damaged, in opposite directions.
- Match the penalty to the truth. Ridge when everything matters a little, lasso when the truth is sparse. Regularisation is not a generic tonic: tuned ridge still scored -0.59 where lasso reached 0.969.
- A constrained tree can beat an unconstrained one — 0.915 against 0.864 here. Capacity spent on noise is worse than no capacity.
- Bagging fights variance, boosting fights bias. More trees can never hurt a forest; more trees can absolutely hurt a boosted model.
- Impurity importance rewards cardinality. A pure-noise column with 1000 levels scored 0.48. Use permutation importance on held-out data — in-sample it fails the same way.
- Accuracy is meaningless under imbalance. Predicting "no" forever scored 99.11%.
- ROC-AUC flatters rare-positive problems. 0.903 against a PR-AUC of 0.508 on identical predictions.
- Set thresholds with money, not metrics. beta = √(cost ratio) picks a beta; it does not find the cost-optimal threshold. For calibrated probabilities that is costFP / (costFP + costFN).
21.40 Interview drills
Fourteen questions in the order the chapter built them. Try to answer before opening each one — the answers include the traps interviewers are usually probing for.
1. Why is squared error the default when absolute error is more robust?
Two concrete reasons. It has a closed-form solution, so it can be computed exactly in one step rather than iteratively. And its derivative is continuous, while absolute error has an undefined gradient at zero, which gradient-based optimisers dislike. The trade is real: squared error lets one bad row dominate. The strong answer names Huber as the compromise and points out you can fit with one loss and report with another.
2. Your linear model's residuals are not normally distributed. How worried are you?
Barely, if you have a decent sample size. Normality is not required for coefficients to be unbiased; it matters for exact small-sample inference, and the central limit theorem covers you with a few thousand rows. Be far more worried about independence — correlated rows break both your standard errors and your cross-validation. The interviewer is usually checking whether you can rank the assumptions rather than recite them.
3. Two features have a correlation of 0.98. Does your model break?
Depends what you need. Predictions are fine — the sum of the two coefficients is well-determined even though the split between them is unstable. But any statement about "the effect of feature A" is unsupportable. Fixes: drop one, combine them, or use ridge, which handles correlated groups gracefully. Quantify with VIF; above 10 is the conventional alarm.
4. Your price coefficient is positive: higher prices, more sales. Explain.
Almost certainly confounding, not a pricing insight. Prices get raised on products already selling well and cut on products that are not, so the coefficient reports that association faithfully. It describes the data; it does not license an intervention. To answer the causal question you need an experiment or a causal design.
5. Interpret a logistic coefficient of 0.7.
e0.7 ≈ 2.1, so each one-unit increase doubles the odds, holding the model's other features fixed. Do not say "twice as likely": from p = 0.5 doubling the odds gives 0.67, but from p = 0.9 it gives 0.947. Same coefficient, very different probability changes. The strong answer volunteers a baseline probability.
6. When would you pick lasso over ridge?
When you believe the truth is sparse — a few features matter and the rest are noise — or when you need feature selection as an output. Ridge when you think most features contribute a little, or when features are correlated in groups. Elastic net when both. The measured example in 21.16 is a good one to cite: with more columns than rows, tuned ridge still scored -0.59 while lasso reached 0.969, because the truth was genuinely sparse.
7. Why must you standardise before ridge or lasso?
The penalty sums coefficient magnitudes, and magnitude depends on units — so without standardising, your results depend on whether you recorded revenue in pounds or pence. Both directions are damaged: a feature in millions needs a tiny coefficient and escapes the penalty almost entirely; a feature in thousandths needs a large one, which the penalty punishes, so the model abandons the feature. Do the scaling inside the CV loop via a Pipeline, or you leak test statistics into training.
8. What's wrong with stepwise selection?
The p-values are invalid: features were chosen by looking at the data, then reported as if specified in advance, with no correction for the search. On enough random columns it will produce "significant" predictors from pure noise. It is also greedy, so it cannot find pairs that only work together. Prefer lasso, which considers all coefficients at once, or wrap the whole selection in an outer CV loop.
9. Random forest versus gradient boosting — how do you choose?
Forests build deep trees in parallel to cut variance; boosting builds shallow trees in sequence to cut bias. More trees never hurts a forest; more trees can overfit a boosted model, so it needs early stopping. Forests are forgiving and near-untunable; boosting usually wins once tuned. Practical answer: forest as a baseline in five minutes, boosting when the accuracy justifies the tuning budget.
10. Your top feature by importance is a random ID column. What happened?
Classic impurity-importance bias. Many distinct values means many candidate split points, some of which separate training rows by luck; impurity importance counts that as usefulness. In the measured example a pure-noise column with 1000 levels scored 0.48. Use permutation importance on held-out data — and note that in-sample permutation fails the same way, scoring 0.169 for the same worthless column.
11. Model A has 0.92 ROC-AUC, model B has 0.89. Is A better?
Not necessarily, and the right response is questions. What is the prevalence? Under heavy imbalance ROC-AUC flatters everything — the chapter's example pairs ROC-AUC 0.903 with PR-AUC 0.508. What is the operating point? B may dominate in the region you actually run in. Is the difference within noise? Ask for confidence intervals across folds. Are the probabilities calibrated? If they feed a financial calculation, ranking is not enough.
12. How do you choose beta for F-beta?
Ask: how many false alarms would you accept to avoid one miss? That is beta², not beta. Nine false alarms per avoided miss gives beta = 3. The rule beta = √(costFN/costFP) is fine for picking a beta to compare models, but — the nuance that impresses — optimising F-beta does not find the cost-minimising threshold, because F-beta ignores true negatives entirely. For the threshold, minimise cost directly.
13. Your model has 0.94 AUC and the business says it's useless. What do you check?
In order: the threshold (0.5 by default is arbitrary and rarely right); precision at the operating point (great AUC, unusable precision is routine under imbalance); capacity (if they can review 100 a day, precision@100 is the only metric that matters); calibration (if the probability feeds a cost calculation); and leakage (0.94 that does not survive contact with production is often 0.94 borrowed from the future).
14. Set the threshold for a fraud model. Missing costs £50, a false alarm costs £4.
If the probabilities are calibrated, the analytic answer is costFP / (costFP + costFN) = 4/54 ≈ 0.074 — far below the default 0.5, because misses are 13.5× more expensive. Then verify empirically by sweeping the threshold on a validation set and plotting total cost, since the formula is only as good as the calibration. Finish by translating into operational terms: at that threshold, how many reviews per week, and is there staff to do them?
[i] Where this leaves you
You can now fit and defend the three model families that run most of production, argue for a metric from the cost of being wrong rather than from habit, and choose or remove a variable for reasons that survive review. That covers the modelling half of an ML interview and most of the modelling half of the job.
Chapter 18 places these models in the wider ML landscape and covers drift and feedback loops. Chapter 17 has the statistical machinery underneath the inference. Chapter 19 takes up the causal question this chapter kept deferring: how to establish that an intervention caused a change rather than merely accompanied it.