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.

40 sections scikit-learn 1.9.0 Retail examples 14 interview drills Reading time ~4 hours

[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.

Loss function shapes Left: squared, absolute and Huber loss against residual. Squared error grows fastest so a single outlier dominates. Right: log loss against the probability assigned to the true class, rising without bound as that probability approaches zero. What each loss is afraid of residual r 0 squared (MSE) absolute (MAE) Huber loss one outlier at r=3 costs MSE 9 but MAE only 3 predicted prob. for the TRUE class 0 1 log loss = -log(p) confident AND wrong: p=0.02 costs 3.9 a coin flip costs 0.69 cost is unbounded: it punishes confident mistakes hardest
Figure 21.1 — The shape of a loss is the modelling assumption. Squared error fears outliers; absolute error shrugs at them; log loss fears confident mistakes above all else.

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.

Squared error chases the outlier; absolute error barely flinchespython
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

LossUse whenFits toward
Squared (L2)Large errors are disproportionately costly; data is cleanthe mean
Absolute (L1)Errors cost in proportion; outliers are data-quality noisethe median
HuberYou want L2's smoothness but distrust the tailsbetween the two
QuantileYou need a range, not a point ("90% of the time demand is below X")a chosen quantile
Log-coshLike Huber but smooth everywhere, no delta to tunebetween 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.

Least squares, solved directlypython
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

AssumptionIf violatedMatters 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.

Multicollinearity breaks interpretation, not predictionpython
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

MetricPlain readingWatch 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.

  1. 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.
  2. 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.
  3. 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.

Why a logistic coefficient is an odds ratiopython
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.

Ridge shrinks; lasso selectspython
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:

ModelCV R²Features kept
Plain least squares0.41980
Ridge (alpha tuned to 8.89)0.63280
Lasso (alpha tuned to 2.49)0.84435
Elastic net0.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:

ModelTraining R²Cross-validated R²
Least squares1.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.

What force is left at the originpython
# 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.

The closed forms, checked against brute forcepython
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:

SolutionOperationCan 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.

Why a kink is required, not merely helpfulpython
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.

L1 diamond versus L2 circle Least-squares contours expanding until they meet each constraint region. The diamond is first touched at a corner on the axis, so one coefficient is exactly zero. The circle is touched off-axis, so both coefficients remain nonzero. Why the corner matters: same data, two constraint shapes Lasso: the L1 diamond b₁ b₂ least squares corner: b₂ = 0 touches ON the axis → feature removed Ridge: the L2 circle b₁ b₂ least squares b₂ = 0.26 touches OFF the axis → small, still there Measured: projecting random points onto the L1 ball gives a zero 100% of the time at p=20. The L2 ball did not do so once in 60,000 trials.
Figure 21.5 — The same least-squares solution, constrained two ways. The contact point is computed by projection, not drawn by eye: the L1 diamond is met at a corner (b₂ exactly 0), the L2 circle at a smooth point (b₂ small but nonzero). A circle has no corners to be caught on.

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.

The corner argument, made quantitativepython
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.
DimensionsL1 ball gives a zeroL2 ball gives a zero
p = 251.8%0%
p = 599.8%0%
p = 20100%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

SituationUseBecause
Most features probably matter a littleRidgeShrinks all, discards none
Few features probably matter a lotLassoZeroes the rest, giving a readable model
Sparse and correlated groupsElastic netSelects, but keeps groups intact
More columns than rowsLasso or elastic netRidge alone may not be enough — see above
You need every coefficient interpretableLassoA 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.

Why you must standardise before any penaltypython
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

FamilyHow it worksCostMain 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

ReasonTestAction
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.

What a split is actually choosingpython
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

MeasureUsed forRange (2 classes)
GiniClassification. The default nearly everywhere.0 (pure) to 0.5
EntropyClassification. 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.

Tree depth, overfitting, and what averaging fixes Left: training error falls monotonically with depth while test error is U-shaped, with a minimum at moderate depth. Right: measured AUC. One deep tree scores 1.0 on training data but only 0.866 held out, while a random forest scores 0.962 held out. Why one deep tree loses to a forest a single decision tree tree depth → error training error test error best single tree measured, held-out AUC one deep tree train 1.000 test 0.866 random forest train 1.000 test 0.962 depth 19, 335 leaves: perfect on train, mediocre on test.
Figure 21.4 — A fully grown tree drives training error to zero and pays for it on unseen data. Averaging many decorrelated trees keeps the low bias and cancels much of the variance.

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

ParameterWhat it doesWorth tuning?
max_depthHard ceiling on questions askedYes — the highest-leverage knob
min_samples_leafRefuses leaves built on too few rowsYes — directly limits memorising
min_samples_splitRefuses to split a small node at allSometimes
max_featuresConsiders a random subset per splitFor forests, yes (21.26)
ccp_alphaCost-complexity pruning: grow fully, then cut backYes, 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

ModelAUCNote
Decision stump (depth 1)0.709One question. Badly underfit.
Tree, unrestricted0.864Memorises
Tree, depth 50.915Beats the unrestricted tree
Bagging, 100 trees0.961Averaging alone
Random forest, 300 trees0.964Averaging + decorrelation
Extra trees, 3000.967More randomness still (21.27)
Gradient boosting0.957A 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 forestBoosting
Trees are builtIn parallel, independentlyIn sequence, each fixing the last
Base treeDeep, low bias, high varianceShallow, high bias, low variance
Chiefly reducesVarianceBias
More treesCannot overfit; just slowerCan overfit — needs early stopping
ParallelisableTriviallyNot across trees
Hyperparameter careForgivingSensitive, 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

LibraryIts ideaReach 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.

Impurity importance is biased; held-out permutation is notpython
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

  1. Held-out permutation importance — directly answers "does the model's performance depend on this?"
  2. SHAP values on held-out data — per-row attributions, useful for explaining an individual decision; more expensive, and still needs unseen data.
  3. Drop-column importance — retrain without the feature. The most honest and the most expensive, since it costs a full refit per feature.
  4. 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.

The four numbers everything else is built frompython
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.

95% accurate and completely uselesspython
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.

F-beta, and why it is a harmonic meanpython
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:

BetaThresholdPrecisionRecallReading
0.252.8969.8%9.8%Precision at almost any cost
0.52.1245.7%29.2%Precision matters twice as much
1.01.7938.5%43.0%Balanced (plain F1)
2.01.1323.2%67.3%Recall matters twice as much
4.00.6715.6%82.1%Catch nearly everything
Effect of beta on the chosen operating point A precision-recall scatter with five measured points, one per beta value. As beta increases from 0.25 to 4, the selected point moves down and to the right: recall rises from 9.8 to 82.1 per cent while precision falls from 69.8 to 14.6 per cent. Beta slides the operating point along the same curve recall precision 0 0 0.5 0.5 1.0 1.0 β=0.25 β=0.5 β=1.0 β=2.0 β=4.0 β threshold precision recall 0.25 +2.89 69.8% 9.8% 0.5 +2.12 45.7% 29.2% 1.0 +1.79 38.5% 43.0% 2.0 +1.13 21.2% 67.3% 4.0 +0.67 14.6% 82.1% β<1 buys precision. β>1 buys recall. The model never changed - only the threshold did.
Figure 21.3 — Measured on 20,000 rows at 5% prevalence. Choosing β is choosing where on this curve you are willing to stand.

[+] 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:FPBetaCost-optimal thresholdF-beta's threshold
1:11.02.851.79
4:12.01.81.13
16:14.00.870.67
1:40.53.232.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.

ROC-AUC versus PR-AUC under class imbalance Two panels showing the same predictions. The ROC curve bows high with AUC 0.903. The precision-recall curve for the identical predictions has area 0.508 against a random baseline of 0.0089. Same model, same predictions, two very different impressions ROC curve false positive rate true positive rate AUC 0.903 looks excellent random = prevalence = 0.0089 PR curve recall precision PR-AUC 0.508 89 positives in 10000 rows. ROC's x-axis is diluted by 9911 negatives; PR's is not.
Figure 21.2 — Measured on the same 10,000 rows. The ROC curve is flattered because the false-positive rate divides by a huge negative count; precision does not.

[!] 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

UseWhenBecause
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

MetricWhat it doesReach 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

ModelTypicallyNote
Logistic regressionWell calibratedLog loss is a proper scoring rule; calibration is what it optimises
Random forestPulled toward the middleAveraging votes rarely yields 0 or 1
Gradient boostingOften over-confidentPushed toward the extremes
SVMNot probabilities at allDistances 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.

Pick the threshold with money, not with a metricpython
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:FPFormula saysGrid search finds
1:10.50.429
5:10.1670.199
10:10.0910.118
1:50.8330.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?

IfStart withWhy
You must explain every coefficient to a regulatorLinear / logisticNothing else is as defensible
Tabular data, mixed types, you want accuracyGradient boostingStill the strongest default on tables
Wide data, few rows, sparse truthLasso / elastic netTrees struggle when p >> n
You need a fast, forgiving baselineRandom forestWorks untuned; hard to break
Relationships are smooth and roughly linearRegularised linearTrees approximate slopes with staircases
Strong interactions, thresholds, non-monotonic effectsTreesInteractions are free; linear models need them hand-built
The probability itself is the productLogistic, or boosting + calibration21.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 answeringMetric
How well does this rank, in general?ROC-AUC
Rare positives, and I act on the alertsPR-AUC
One number, imbalanced, no tuning knobMCC
I know the relative cost of the two errorsF-beta to compare models, cost to set the threshold
Fixed review capacityPrecision@k
The probability feeds a financial calculationBrier score, log loss, calibration plot
Regression, stakeholder-facingMAE with RMSE beside it, versus a naive baseline
Regression, intermittent or zero-heavyMASE (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

  1. 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.
  2. Linear regression's assumptions are not equally important. Independence and linearity bite hard; normality of residuals almost never does.
  3. Multicollinearity breaks interpretation, not prediction. The sum of correlated coefficients stays right; the split between them is noise.
  4. Logistic regression is linear in the log-odds. A coefficient is an odds ratio, and "5% higher odds" is not "5% more likely".
  5. Standardise before any penalty — and note that both large-unit and small-unit features are damaged, in opposite directions.
  6. 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.
  7. A constrained tree can beat an unconstrained one — 0.915 against 0.864 here. Capacity spent on noise is worse than no capacity.
  8. Bagging fights variance, boosting fights bias. More trees can never hurt a forest; more trees can absolutely hurt a boosted model.
  9. 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.
  10. Accuracy is meaningless under imbalance. Predicting "no" forever scored 99.11%.
  11. ROC-AUC flatters rare-positive problems. 0.903 against a PR-AUC of 0.508 on identical predictions.
  12. 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.