Chapter 4 — Statistical Predictive Models

Learning Statistics with Python

Prof. Xuhu Wan

Chapter 4 · Learning Statistics with Python

Statistical Predictive Models

Credit-risk case (default, EAD, LGD): feature engineering, multiple regression, model selection, inference, trees and boosting, logistic classification, and why prediction is not causation.

Prof. Xuhu Wan

ISOM, HKUST Business School · 2026 Edition

What This Chapter Builds

  • One real case, start to finish: 8 000 LendingClub loans, 20 % of them charged off. You will build the three numbers a lender lives by — PD, EAD, LGD — from raw ledger columns.
  • Feature engineering that turns strings, dates and missing values into regressors, then multiple regression with statsmodels.
  • Model selection you can defend: adjusted R², interactions, best-subset search, t-tests, VIF — and the difference between predicting and inferring.
  • Nonlinear models: regression trees, random forests, gradient boosting; logistic regression with ROC/AUC and a cutoff chosen by gain and loss.
  • Causal analysis: why a superb predictor can be a useless policy lever, and three ways to recover a causal effect.
  • Cross-sectional attention: one transformer layer in numpy over the stock cross-section, used as feature engineering (attention-weighted peers) inside a walk-forward test with rank IC.

Why this matters

Every lending, pricing or marketing model ends in a decision. This chapter gives you the machinery to build the prediction and the discipline to know when the prediction answers the question actually being asked.

Roadmap

Section Concept Tool
4.1 Credit-risk data default, EAD, LGD; feature engineering; OLS for EAD and LGD map, get_dummies, sm.OLS, R², adjusted R², RMSE
4.2 Model selection adjusted R², interactions, best subset, residuals, t-tests, VIF itertools.combinations, .tvalues, variance_inflation_factor
4.3 Nonlinear models trees, bagging, random forests, boosting, walk-forward stock prediction, logistic classification DecisionTreeRegressor, RandomForestRegressor, GradientBoosting*, TimeSeriesSplit, sm.Logit, roc_curve
4.4 Causal analysis confounders, regression adjustment, DiD, instrumental variables simulation, sm.OLS, 2SLS by hand
4.5 Cross-sectional attention per-month standardisation, attention as a data-driven peer group, context and deviation features, walk-forward rank IC numpy softmax attention, HistGradientBoostingRegressor, spearmanr

Credit Risk Data: Default, EAD, LGD, Feature Engineering, Multiple Regression

A lender’s loss on a loan is the product of three things — whether it defaults, how much is still owed when it does, and how little comes back. You will build all three from raw columns, engineer 25 features, and discover how much (and how little) a linear model explains.

How does a lending platform make money?

LendingClub matches borrowers (debt consolidation, big purchases, medical bills) with funding. The platform underwrites each application with a credit-risk model, sets the rate and term, and either keeps the loan or sells it to investors.

Which statement best describes the platform’s economics?

  • It earns only when a borrower defaults
  • It earns a flat fee per application, so risk is irrelevant
  • It earns fees and net interest, so the whole business rests on pricing risk correctly
  • It earns nothing until the loan is fully repaid

Expected loss has three factors

\[\text{EL} = \underbrace{\text{PD}}_{\text{probability of default}} \times \underbrace{\text{EAD}}_{\text{exposure at default}} \times \underbrace{\text{LGD}}_{\text{loss given default}}\]

Which factor calls for a classification model rather than a regression?

  • EAD — it is the exposure in dollars
  • PD — it is the probability of a 0/1 outcome
  • LGD — it is a loss fraction
  • All three are regressions

This section builds EAD and LGD by regression. PD waits for §4.3.

Load 8 000 loans

The catalog says 20 % of loans are charged off. What does the second line print for Charged Off?

print(debt["loan_status"].value_counts(normalize=True).round(3))

0.2

(8000, 29) — a random sample of the 150 000-loan original. Exactly 80 % Fully Paid, 20 % Charged Off. Every loan here has finished its life, so we know the outcome.

Define default, then EAD and LGD from the ledger

Three ledger columns carry the story: funded_amnt (what was lent), total_rec_prncp (principal repaid), recoveries (cash clawed back after charge-off).

For a Fully Paid loan, what is EAD = (funded_amnt − total_rec_prncp) / funded_amnt?

  • 0 — all principal came back
  • 1 — the full amount was exposed
  • Undefined
  • Equal to the interest rate

Build the three targets

Among the 1 604 charged-off loans the mean EAD is 0.698 — on average 70 % of the principal was still outstanding — and mean LGD is 0.892: recoveries claw back barely a tenth. The 1e-4 guards the one loan with EADamount = 0. Six loans recovered more than the outstanding principal (late fees, collection interest), which is why the minimum LGD is −0.177 — real ledgers do this, and we keep the raw number.

Feature Engineering

Raw columns are strings, dates and NaNs. A regression needs numbers with a meaning. You will convert term and employment length, build a credit age, a FICO midpoint, two “time since trouble” clocks, and dummies for three categoricals.

Term and employment length are strings

emp_length holds values like "10+ years", "< 1 year", "3 years" and NaN. What does str.extract(r"(\d+)") return for "10+ years" and for "< 1 year"?

pd.Series(["10+ years", "< 1 year"]).str.extract(r"(\d+)")[0].tolist()

['10', '1']

"< 1 year" would extract as 1, so we override it to 0. The 484 loans with no employment record get employment = 0 and a flag employed = 0 — the flag lets the model treat “unknown” differently from “under a year”.

Credit age, FICO midpoint, instalment burden

earliest_cr_line is like "Jan-1986". Days from 1 Jan 1986 to 29 Dec 2018 (the data’s end date)?

(pd.Timestamp("2018-12-29") - pd.Timestamp("1986-01-01")).days

12050

format="%b-%Y" parses Jan-1986 directly — no guessing, no warnings. Credit histories run from 1 277 to 20 757 days (3.5 to 57 years). install is the monthly instalment as a share of annual income — a burden ratio, not a dollar amount. Three applicants report zero income: dividing by NaN rather than by 0 keeps their ratio missing instead of infinite.

Months since last delinquency — what does NaN mean?

mths_since_last_delinq is NaN for half the loans. What should NaN become?

  • 0 — missing means no information, use the smallest value
  • The column mean
  • A large number such as 360 — NaN means “never delinquent”
  • Drop those rows

balance (instalment-loan balance over income) is NaN when the borrower has no instalment loans — there the honest fill is 0.

Dummies for grade, home ownership, verification

Grade has 7 levels A–G. Why keep only 6 dummy columns in a regression with an intercept?

  • Grade G is too rare to matter
  • All 7 would sum to the intercept column — perfect collinearity
  • statsmodels allows at most 6 dummies
  • To make the coefficients smaller

Home ownership also has ANY and OTHER (one loan each). We keep OWN, RENT, MORTGAGE as the notebook does — and in §4.2 the VIF will show why that was a trap.

Assemble, shuffle, split

Why permute the rows before taking the first 70 % as training data?

  • To make the regression converge faster
  • Because statsmodels requires it
  • The file may be sorted, so an unshuffled split would not be representative
  • To remove duplicates

25 features. Six rows are dropped for a missing dti or revol_util — the three zero-income applicants among them. 5 595 / 2 399 loans train/test; of those, 1 146 / 458 defaulted — the EAD and LGD regressions see only these.

OLS for EAD on the defaulted loans

R² = 0.192. Longer term (t = 7.5) and higher int_rate (t = 3.5) mean more principal is still outstanding when the loan fails; a heavier instalment install (t = −2.7) means the borrower had paid more down before failing. fico and dti add nothing once the rest are in.

Now predict: how well does OLS explain LGD?

Same 25 features, target LGD. The R² will be roughly:

  • Under 0.05 — recoveries are nearly unpredictable from origination data
  • About 0.2, like EAD
  • About 0.5
  • Above 0.8 — LGD is mechanical

R² = 0.039, adjusted 0.018. The 150 000-loan notebook found 0.012. LGD is, for practical purposes, a constant near 0.89 — and a model that knows it is a constant is more useful than one that pretends otherwise.

Judge on data the model never saw

\[R^2_{\text{adj}} = 1 - (1 - R^2)\,\frac{n-1}{n-k-1}, \qquad \text{RMSE} = \sqrt{\tfrac{1}{n}\sum (y_i - \hat y_i)^2}\]

EAD: train adjusted R² 0.174 → test 0.107. LGD: test adjusted R² is negative (−0.080) — worse than predicting the mean. Test RMSE 0.190 for EAD says a typical exposure forecast is off by 19 percentage points of principal.

Your turn: expected loss per dollar lent

You have all three factors on the training data. Put them together.

EL = PD × mean EAD × mean LGD, using the default rate in train and the mean EAD and LGD of the defaulted loans train_d. Fix the EL line.

About 12.7 cents per dollar lent — which is why the average interest rate in this book is 13 %.

What you discovered

  • A lender’s loss is PD × EAD × LGD; PD is a classification, EAD and LGD are regressions on the charged-off subset (1 604 of 8 000 loans).
  • Feature engineering is a sequence of judgements: "< 1 year" → 0, NaN delinquency → 360 (never), missing balance → 0, one dummy dropped per categorical.
  • OLS explains 19 % of EAD on training data, 16 % on test — and 4 % of LGD, which is effectively a constant near 0.89.
  • Adjusted R² and RMSE on the test set are the numbers you report; a negative test adjusted R² means “predict the mean instead”.

Next: §4.2 — can interactions and best-subset search raise that 0.107?

Model Selection: Adjusted R², Interactions, Best Subset, Inference, Multicollinearity

More variables always raise R². You will see why adjusted R² is the honest score, create interaction terms that reveal hidden structure, search all 255 subsets of 8 candidates, and then read t-tests and VIF to decide which coefficients mean anything.

Rebuild the §4.1 features (run this first)

Dummies and the same split

Same seed 5650, same 1 146 / 458 defaulted rows, same adjusted R² 0.174 as §4.1.

Does a useless variable raise R²?

We append 10 columns of pure noise (seed 1) to the 25 features and refit.

After adding 10 noise columns, R² and adjusted R² will:

  • Both fall
  • Both rise
  • R² rises slightly, adjusted R² falls
  • Neither changes — noise has zero coefficients

0.1920 → 0.1959; adjusted R² 0.1740 → 0.1706. Ten columns that cannot possibly matter “explained” 0.4 % more variance. Never select on raw R².

Marginally uncorrelated, jointly correlated

y = x1 * x2 + noise with independent standard-normal x1, x2. What are the three correlations corr(y,x1), corr(y,x2), corr(y,x1·x2)?

  • All three near 0.9
  • All three near 0
  • High, high, low
  • Near 0, near 0, near 0.9

−0.027, −0.081, 0.887. A term-by-rate interaction in the loan data could hide the same way — so we add products to the candidate pool.

Expand the pool with interactions

Eight base features and all 28 pairwise products: 36 candidates. (Squares of a two-valued term would duplicate term, so we skip squares.)

The notebook does this for all 25 features plus ratios and reaches 650 columns. Best-subset over 650 columns means \(2^{650}\) models — the reason the next step is a screen, not a search.

The full 36-column model: predict train vs test

Compared with the plain 8-feature model, the 36-column model will have:

  • Higher train and higher test adjusted R²
  • Higher train, lower test adjusted R²
  • Lower train, higher test
  • Identical numbers — products add no information

36 columns: train 0.195, test 0.079. 8 columns: train 0.178, test 0.141. The interactions bought 1.7 points on training data and cost 6 on test.

Best subset: how many models?

Best-subset selection fits every non-empty subset of \(k\) candidates. For \(k = 8\), how many fits?

2**8 - 1

255

For 36 candidates it would be \(2^{36} - 1 \approx 6.9 \times 10^{10}\). So we first screen the pool by absolute correlation with EAD and keep the top 8 — the notebook keeps the top 15 of 650.

Run all 255 subsets

Adjusted R² climbs 0.143 → 0.152 → 0.158 → 0.161 at \(k=4\) and then falls as more candidates are added. The winner: int_rate, term*fico, int_rate*creditdays, term*revol_util.

Adjusted R² against model size

Every grey dot is one of the 255 fits. The red envelope peaks at \(k = 4\): beyond that, every extra column is charged more than it earns.

The chosen model on test data

Best-4 on test: 0.130 — far above the 36-column model (0.079), with a tenth of the columns.

But the plain 8 base features score 0.141. The correlation screen kept five variants of int_rate and term and dropped install and balance — weak marginally, useful jointly (the lesson of the x1*x2 slide, in reverse). Screening is itself a modelling choice.

Inference: Residuals, t-tests, VIF

Prediction asks “how close?”. Inference asks “is this coefficient real?” — and that question needs assumptions the prediction never did.

Look at the residuals before trusting any t-test

Two hard diagonal edges: EAD lives in \([0, 1]\), so residual \(= y - \hat y\) cannot exceed \(1 - \hat y\) or fall below \(-\hat y\). The spread is not constant and the errors are not normal.

Assumptions matter for inference, not for prediction

R², adjusted R², RMSE and the predictions are computed from \(y\) and \(\hat y\) alone — no distributional assumption. t-tests, p-values and confidence intervals need independent, constant-variance errors (and normality or a large \(n\)): violate them and the numbers still print, but their coverage is wrong. Validate assumptions when you are about to call a coefficient significant; skip it when you only need a forecast.

Which coefficients are redundant?

A coefficient has |t| = 0.8. The right reading is:

  • The variable has no relationship with EAD
  • Given the other 24 features, the data cannot tell this coefficient from zero
  • The variable is collinear with the intercept
  • The model is misspecified

19 of 25 coefficients have |t| < 2. term (7.5), balance (4.9) and int_rate (3.5) carry the model — exactly the variables the best-subset search kept reaching for. Small |t| or \(p > 0.05\) → redundant in this model; large |t| with a bad residual plot → trust the direction, not the exact p-value.

Multicollinearity: predict the VIF of the home-ownership dummies

\[\text{VIF}_j = \frac{1}{1 - R^2_j}, \quad R^2_j = R^2 \text{ of regressing } x_j \text{ on the other predictors}\]

We kept all three of home_OWN, home_RENT, home_MORTGAGE. Their VIFs will be:

  • About 1 — dummies are independent
  • Between 2 and 5
  • Exactly 3
  • In the hundreds — they almost sum to the intercept

Drop the collinear dummies and refit

home_RENT 290.7, home_MORTGAGE 286.3, home_OWN 97.0 — the dummy trap from §4.1. Grades B–D sit at 19–31 because int_rate is set from the grade. Follow the notebook: drop the three home dummies and grades A–D.

Max VIF falls from 291 to 10.8 (dti and install share the income denominator). Seven fewer columns, and test adjusted R² rises from 0.107 to 0.121. Collinearity hurts inference badly and prediction a little.

Your turn: keep only the coefficients that survive the t-test

Build keep = the features of m_ead with |t| ≥ 2, refit, and print how many survived and the adjusted R².

Six features (int_rate, term, revol_util, install, balance, creditdays), adjusted R² 0.178 — above the 25-feature model’s 0.174. Nineteen columns were pure cost.

What you discovered

  • R² never falls when you add a column — ten noise columns raised it from 0.192 to 0.196. Adjusted R² fell, as it should.
  • Two variables can be individually uncorrelated with \(y\) and jointly explain 79 % of it (x1*x2); interactions belong in the candidate pool.
  • All 36 candidates: test adjusted R² 0.079. Best subset of 8 screened candidates: 0.130 with 4 columns. Screening by marginal correlation is a choice with consequences.
  • 19 of 25 coefficients had |t| < 2; dropping them raised adjusted R² (0.174 → 0.178). VIF exposed the dummy trap (290) and the grade–rate overlap (31).
  • Assumption checks protect inference; prediction scores need none of them.

Next: §4.3 — let a tree find the interactions for you.

Nonlinear Models: Trees, Random Forests, Gradient Boosting, Logistic Classification

A regression tree finds thresholds and interactions on its own — and memorises noise just as readily. You will watch one split, one overfit, then tame it by averaging (random forest) and by correcting residuals (boosting), and turn boosting on ten years of the S&P 500 with a walk-forward split. Then the missing factor: PD by logistic regression, judged by ROC and a cutoff that reflects gain and loss.

Rebuild the data (run this first)

Eight numeric features, the same seed and split as before: 1 146 / 458 defaulted loans.

Why trees?

Which pattern can a linear regression not capture without you engineering a feature?

  • A steady increase of EAD with interest rate
  • A constant shift for 60-month loans
  • “IF term = 60 AND balance > 0.01 THEN EAD is high” — a threshold interaction
  • A negative slope on revolving utilisation

A regression tree partitions the feature space into boxes and predicts the mean of \(y\) in each box — a piecewise-constant approximation with thresholds and interactions built in.

How does a tree choose its first split?

It tries every cut-point of every feature and keeps the one that most reduces the sum of squared errors: \(\text{SSE}_{\text{left}} + \text{SSE}_{\text{right}}\) versus the parent’s SSE.

On the 1 146 training loans, EAD has standard deviation ≈ 0.227. Roughly what is the parent SSE, \(\sum (y - \bar y)^2\)?

round(1146 * 0.227**2, 1)

59.1

Cutting int_rate at 17.93 % drops SSE from 59.11 to 54.44. The tree does this for every feature and takes the best; then repeats inside each child.

Let sklearn grow two levels

The root splits on term (36 vs 60 months), then each side splits on balance. Four leaves, four means from 0.57 to 0.86 — the tree found the term×balance interaction that §4.2 had to construct by hand, and its test RMSE (0.191) already matches OLS.

Grow it to the bottom: predict the training error

DecisionTreeRegressor() with default settings on 1 146 rows. Training RMSE will be:

  • Exactly 0 — one leaf per loan
  • About the same as OLS (0.20)
  • About half of OLS
  • Undefined

1 145 leaves for 1 146 loans, train 0.0, test 0.279 — 46 % worse than the two-level tree’s 0.191. This is the overfitting gap in its purest form.

The hyper-parameters that stop memorising

Parameter Effect Typical
max_depth caps the number of successive splits 2–7
min_samples_leaf no leaf smaller than this 5–50
min_samples_split no split attempted on a smaller node 10–20
max_leaf_nodes direct cap on complexity 4–32

Test RMSE bottoms out around depth 2–3 and climbs from depth 5 as training RMSE keeps falling. Simple tree = high bias, low variance; deep tree = the reverse. Pick the depth on validation data, never on training.

Your turn: tame the deep tree with a leaf size

Change min_samples_leaf so the fully grown tree’s test RMSE falls below 0.196.

A leaf size of 50 gives 0.195 with 19 leaves; 20 (0.197, 44 leaves) is not enough. The leaf size is a variance dial.

Bagging → random forest: what does averaging fix?

Fit 100 deep trees on 100 bootstrap samples and average them. What does that reduce?

  • Bias — each tree learns a different part of the truth
  • Variance — noisy trees average out while the signal survives
  • The irreducible noise
  • Training time

Forest test RMSE 0.191 — identical to OLS on these 8 features, with a lower training error (0.185 vs 0.205). With 1 146 rows the nonlinearity buys little; on the 150 000-loan notebook the forest lifted test adjusted R² from 0.163 to 0.216.

Which features does the forest use?

term, balance, int_rate dominate — the same three the t-tests and the best-subset search flagged in §4.2.

What “feature importance” actually measures

Read it as a description of the forest, not of the world

  • Definition (sklearn default): for every split on feature \(j\) in every tree, record the impurity (SSE) reduction; sum, and average across trees. That is the whole number.
  • Bias toward many-valued features. A continuous variable offers hundreds of cut-points; a 0/1 dummy offers one. More chances to split = more importance, whatever the truth.
  • Correlated features share the credit unstably. Two near-copies split the importance between them at random.
  • Marginally weak ≠ useless. A feature may matter only inside an interaction, and still score low.

Importance ranks the forest’s habits. For “does this variable matter?”, use permutation importance on test data, or a t-test in a model you can interpret.

Boosting: what does the next tree fit?

In gradient boosting with squared-error loss, tree \(m\) is fitted to:

  • A fresh bootstrap sample of \(y\)
  • \(y\) itself, with different features
  • The residuals \(y - f_{m-1}(x)\) of the ensemble so far
  • The predictions of tree \(m-1\)

Five one-split stumps, learning rate 0.5: test RMSE 0.200 → 0.189 — already at the forest’s level. \(f_M(x) = \bar y + \nu \sum_m T_m(x)\); each stump corrects what the previous ones left behind.

GradientBoostingRegressor with 100 small trees

Boosting test 0.192, forest 0.191, OLS 0.191 — three models within a thousandth on a weak signal. The knobs: n_estimators (more trees = more capacity), learning_rate (smaller steps need more trees, generalise better), max_depth 1–3 (weak learners), subsample < 1 (row sampling per tree, adds variance reduction). Tune them on validation data.

Predicting the stock market: why can you not shuffle?

The notebook’s last example turns boosting on prices. The target is “does the S&P 500 rise tomorrow?”, the features are yesterday’s information — and the split must respect time.

Why is train_test_split(shuffle=True) wrong for a price-direction model?

  • Returns are not normally distributed
  • Training rows would come from after the test rows — leakage from the future
  • Boosting needs sorted data
  • It is fine; the rows are independent

2 495 trading days, 2015–2024; 53.8 % are up-days. Seven features, all lagged by at least one day. TimeSeriesSplit gives an expanding window: each fold trains on everything before its test block.

Walk-forward boosting on ten years of the S&P 500

Train AUC vs test AUC across the four folds will look like:

  • Both about 0.7 — momentum is real
  • Both about 0.5 — nothing to learn, nothing memorised
  • Train about 0.7, test about 0.5 — memorised noise, no forecast
  • Test above train

Test AUC 0.52 on average (0.49–0.56) against train AUC of 0.71–0.83 — highest on the smallest training block, where memorising is easiest. The notebook’s 140-feature LightGBM on 5-minute bars lands at 0.538 ± 0.031 — same picture. Daily direction is nearly unforecastable from its own past.

Is an AUC of 0.52 worth anything?

On the 821 out-of-sample days the model was confident, the S&P averaged 0.089 % a day against 0.054 % overall; on its 296 bearish days, −0.027 %. The notebook’s version: 0.077 % vs 0.023 %. A tiny edge, before costs — and the importance ranking (lag4 first) is the unstable kind §4.3 warned about.

In Colab: LightGBM for large data

In Colab

Pyodide has no lightgbm; the notebook runs the walk-forward above on 125 MB of 5-minute bars (140 features, weekly folds) with it. Same gradient-boosting idea, engineered for speed and regularisation:

from lightgbm import LGBMClassifier, early_stopping
model = LGBMClassifier(n_estimators=5000, learning_rate=0.02, num_leaves=63,
                       subsample=0.8, colsample_bytree=0.8,
                       reg_alpha=1.0, reg_lambda=2.0, random_state=42, verbose=-1)
model.fit(X_train, y_train, eval_set=[(X_valid, y_valid)], eval_metric="auc",
          callbacks=[early_stopping(200, verbose=False)])
  • num_leaves = 63 ≈ depth 6 per tree; subsample / colsample_bytree = row and feature sampling (the forest’s tricks inside boosting); reg_alpha / reg_lambda = L1 / L2 penalties on leaf values; early_stopping picks n_estimators on the validation fold.
  • For time series, split with TimeSeriesSplit — train on the past, test on the future, never shuffle.

Logistic Classification for PD

The third factor. Default is 0/1, so the model must output a probability — and a probability is not a decision until you choose a cutoff.

Why not just run OLS on the 0/1 default column?

What goes wrong with OLS on a binary target?

  • Nothing — it is the linear probability model and always fine
  • Fitted “probabilities” can fall outside [0, 1] and the errors are heteroskedastic
  • OLS cannot handle integer columns
  • It always predicts 0.5

\[\log\frac{p}{1-p} = \beta_0 + \beta^\top x \quad\Longleftrightarrow\quad p = \frac{1}{1 + e^{-(\beta_0 + \beta^\top x)}}\]

Fitted on all 5 595 training loans, not just the defaulted ones. int_rate (z = 9.8) and term (5.7) raise the log-odds; fico (−4.8) lowers them. Mean predicted PD equals the default rate, 0.205 — logistic regression is calibrated on average by construction.

From a probability to a decision: the confusion table

Predicted PDs average 0.205 and rarely exceed 0.5. With cutoff 0.5, roughly what share of the 458 real defaulters in the test set are flagged (the TPR)?

# TPR = flagged defaulters / all defaulters

about 0.07

Cutoff 0.5 catches 7 % of defaulters and wrongly flags 1.5 % of good loans. Cutoff 0.2 catches 63 % but flags 35 % of good loans. TPR = P(flag | default), FPR = P(flag | repaid): every cutoff is a trade between them.

The ROC curve is every cutoff at once

AUC = 0.702 on test (0.692 on train — no overfitting in a 9-parameter logit): a random defaulter is ranked riskier than a random good loan 70 % of the time. The notebook’s 150 000-loan logit reaches a similar 0.70 — origination data simply do not separate the classes more than this.

Choose the cutoff by gain and loss, not by 0.5

Approve a loan when it is not flagged. Per applicant, expected profit is \[\Pi(c) = g\,(1 - \text{FPR}(c)) - \ell\,(1 - \text{TPR}(c)),\] with \(g\) = gain from a good loan × P(good), \(\ell\) = loss on a bad loan × P(bad). Setting \(d\Pi/dc = 0\): \[\frac{d\,\text{TPR}}{d\,\text{FPR}} = \frac{g}{\ell} \quad\text{— the ROC slope at the optimum equals gain over loss.}\]

If the loss per bad loan rises relative to the gain per good loan, the optimal cutoff:

  • Falls — flag more applicants, accept more false alarms
  • Rises — flag fewer applicants
  • Stays at 0.5
  • Moves to wherever accuracy is highest

Compute the optimal cutoff from the ROC

Gain on a good loan ≈ 10 % interest; loss on a bad one ≈ EAD × LGD ≈ 0.62 of principal (§4.1). Weight by the base rates.

The slope target is 0.68, so the optimum sits where the ROC has flattened: cutoff ≈ 0.17, flagging 78 % of defaulters at the price of 50 % of good applicants. Approving everyone loses 3.8 cents per applicant; at the optimum the book earns 1.4 cents — and 0.5 was never a candidate.

Does boosting beat the logit at ranking?

Boosting: train 0.745, test 0.703; logit: 0.692 / 0.702. The flexible model gains 5 points on training data and one tenth of a point on test. When the signal is weak, a flexible model mostly finds more noise.

What you discovered

  • A tree splits where SSE falls most (int_rate at 17.93 %), and finds interactions (term then balance) that OLS needs hand-built.
  • Unlimited depth: train RMSE 0, test 0.292. Depth, leaf size and leaf count are variance dials; set them on validation data.
  • Forests average many deep trees (variance ↓); boosting adds many shallow trees fitted to residuals (bias ↓). Both landed at test RMSE ≈ 0.19 here — as OLS did.
  • Impurity importance describes the forest, not the world: biased toward many-valued and uncorrelated features.
  • Prices: walk-forward TimeSeriesSplit, never a shuffle. Boosting on ten years of the S&P 500: train AUC 0.7, test 0.52 — memorised noise, a sliver of edge.
  • Logistic PD: AUC 0.70. The cutoff is a business decision — ROC slope = gain / loss put it at 0.17, not 0.5.

Next: §4.4 — a model that predicts well can still be the wrong lever to pull.

Causal Analysis: Prediction vs Causation, Confounders, Difference-in-Differences, Instrumental Variables

Everything so far answered “given \(x\), what is \(y\)?”. A manager asks “if I change \(x\), what happens to \(y\)?”. You will build a case where the best predictor is a useless lever, fix it with a control, and then meet two designs for when the control is not observed.

Two questions that look alike

“Borrowers who use our budgeting tool repay 20 % more.” Which action needs a causal answer?

  • Flag tool users as low-risk when pricing their next loan
  • Use tool usage as a feature in the PD model
  • Push every borrower to install the tool, expecting 20 % better repayment
  • Report the correlation in the annual review

Potential outcomes: each borrower has \(Y(1)\) with the tool and \(Y(0)\) without; we see one. The observed gap is \(\underbrace{E[Y(1)-Y(0)\mid T=1]}_{\text{causal}} + \underbrace{E[Y(0)\mid T=1] - E[Y(0)\mid T=0]}_{\text{selection bias}}\).

A great predictor that is a useless lever

Simulate a hidden trait \(U\) (financial discipline) that drives both tool adoption \(T\) and repayment \(Y\). By construction the tool has zero effect.

Coefficient 2.17 with t = 24 and R² = 0.23. As a predictor of repayment, tool usage is excellent. As a policy, pushing the tool would move repayment by exactly 0.

Regression adjustment: control for the confounder

Add \(U\) as a second regressor. The coefficient on \(T\) will:

  • Stay near 2.17 — \(T\) is still strongly correlated with \(Y\)
  • Collapse toward 0 — the true effect
  • Flip to about −2
  • Become undefined because \(T\) and \(U\) are correlated

Adjusted: 0.066 (truth 0). With a real effect of 0.5, naive says 2.67, adjusted says 0.45. This works only because \(U\) was measured. Draw the graph \(T \leftarrow U \rightarrow Y\) first; include confounders, never mediators or colliders.

When the confounder is not in your data

        U  (unobserved)
       / \
      v   v
      T -> Y
  • Regression adjustment cannot close a backdoor through a variable you do not have. No amount of data on \(T\) and \(Y\) alone recovers the effect.
  • Randomise if you can: an A/B test makes \(T\) independent of \(U\) and the difference of means is causal.
  • If you cannot, two observational designs replace the missing control with a structure:
    • Difference-in-differences — a control group that shares the same time trend.
    • Instrumental variables — a source of variation in \(T\) that has nothing to do with \(U\).

Difference-in-differences: the gap between gaps

Treated group gets a policy between period 0 and 1; the control group does not. \[\hat\tau_{\text{DiD}} = (\bar Y^{\text{tr}}_{1} - \bar Y^{\text{tr}}_{0}) - (\bar Y^{\text{ctl}}_{1} - \bar Y^{\text{ctl}}_{0})\]

The identifying assumption of DiD is:

  • The two groups have the same level of \(Y\) before treatment
  • Treatment was randomly assigned
  • Without treatment, both groups would have followed parallel trends
  • The control group is larger than the treated group

Simulate a two-group, two-period panel

Treated group starts 1.0 higher, everyone drifts up 0.5, and the policy adds 0.8 to the treated group in period 1.

Both naive numbers are wrong: the post gap (1.85) includes the pre-existing level difference; the before-after (1.31) includes the common drift.

Your turn: compute the DiD by hand

did currently holds the treated group’s before-after change. Subtract the control group’s change so that did is the difference-in-differences.

The regression \(y = \alpha + \beta\,\text{group} + \gamma\,\text{period} + \delta\,(\text{group} \times \text{period})\) gives \(\hat\delta\) = 0.861 (se 0.051) — identical to the hand calculation, now with a standard error. Parallel trends cannot be tested after treatment; with more pre-periods, check that the two groups moved in parallel before it.

Instrumental variables: borrow a lottery

An instrument \(Z\) moves \(T\) but touches \(Y\) only through \(T\). Three conditions: relevance (\(Z \to T\)), exclusion (no \(Z \to Y\) path except via \(T\)), independence (\(Z \perp U\)).

Which condition can never be verified from the data alone?

  • Relevance — \(Z\) predicts \(T\)
  • Exclusion — \(Z\) affects \(Y\) only through \(T\)
  • That \(n\) is large enough
  • That \(T\) is binary

Classic instruments: distance to college for schooling (Card 1995), draft-lottery number for military service (Angrist 1990), rainfall for agricultural income. Two-stage least squares: regress \(T\) on \(Z\), then \(Y\) on \(\hat T\).

2SLS by hand with numpy

Hidden \(U\) drives \(T\) and \(Y\); true effect of \(T\) on \(Y\) is 1.0; \(Z\) is a clean shock to \(T\).

OLS says 2.013 — twice the truth, because \(U\) pushes \(T\) and \(Y\) the same way. 2SLS uses only the part of \(T\) that \(Z\) explains and lands on 1.027. The one-instrument case reduces to the ratio \(\text{cov}(Z,Y)/\text{cov}(Z,T)\).

Weak instruments: when the lottery barely moves T

Strong instrument: F = 637. Weak one: F = 7.0 and the 2SLS estimate drifts to 0.72 with a huge standard error. Rule of thumb: first-stage F above 10, or do not trust the second stage.

Design Replaces the missing control with Fails when
Regression adjustment the measured confounder a confounder is unmeasured
Difference-in-differences a control group’s time trend trends are not parallel
Instrumental variables an exogenous shock to \(T\) \(Z\) is weak or has its own path to \(Y\)

What you discovered

  • A predictor with t = 24 and R² = 0.23 had a causal effect of zero; the correlation came entirely from the hidden trait behind both \(T\) and \(Y\).
  • Regression adjustment recovers the effect (0.066 ≈ 0; 0.45 ≈ 0.5) — but only for confounders you measured.
  • DiD nets out level differences and common trends: 0.861 for a true 0.8. Its price is the parallel-trends assumption.
  • 2SLS turned an OLS estimate of 2.01 into 1.03 using a clean instrument; with F = 7 the instrument was too weak to trust.
  • Never report a causal number without naming the assumption it rests on.

Cross-Sectional Attention Features: Transformers for the Stock Cross-Section

Every month a fund sees the same object: a matrix of stocks by characteristics, and next month’s returns as the target. You will standardise it without peeking at the future, replace the sector as “peer group” with one attention layer written in numpy, and put both through the walk-forward protocol of §4.3 — and discover that the protocol, not the architecture, is the lesson.

One month, one matrix

At month-end \(t\), \(X_t \in \mathbb{R}^{N \times K}\) holds \(N\) stocks × \(K\) characteristics and \(y_t \in \mathbb{R}^N\) holds next month’s returns. The prediction problem is cross-sectional: rank the \(N\) stocks, month by month.

25 335 rows = 119 months × 213 stocks in 11 sectors, 2015-01 … 2024-11. Five price-based characteristics: last-month return, 12-1 momentum, 21-day realised volatility, log dollar volume, price / 252-day high. ret_next at month \(t\) is exactly ret_1_0 at month \(t+1\) (0.100776 = 0.100776) — the target is the next row’s return.

Whose 213 stocks are these?

The universe is today’s large caps, tracked back to 2015. Nothing that was large in 2015 and has since failed, shrunk or been delisted is in the file.

What does this survivorship bias do to a backtest run on the file?

  • Nothing — every price is the real price on that day
  • Flatters it: firms that failed are absent, so returns are biased upward and losers are under-represented
  • Hurts it: today’s winners were expensive in 2015
  • Only changes the sector mix

Production rule: build the universe as of each month from a point-in-time index membership file. This file is fine for learning the mechanics — never quote its Sharpe ratio as evidence.

Standardise per snapshot, never across time

Why is (rvol_21d − rvol_21d.mean()) / rvol_21d.std() over the whole 2015–2024 file a look-ahead?

  • Volatility is not normally distributed
  • The mean is dominated by 2020
  • The 2015 z-score depends on 2024’s mean and std, which a live model in 2015 could not have seen
  • It is not: the transform is monotone

Median and std are computed within each month (and, for the _s columns, within each month × sector — the production invariant: a stock is judged against the peers it is compared with on the day). Sector medians are exactly 0 afterwards; values are clipped at ±3 so one meme month cannot dominate a tree split.

Why is a sector median a crude peer group?

“Sector” is a label assigned by an index provider. In October 2022 NVDA’s behavioural peers — high volume, high volatility, far below the 52-week high — included AMD, but also Netflix, Alibaba and Tesla, none of them “Technology”.

Attention builds the peer group from the data. With queries \(Q = XW_q\), keys \(K = XW_k\), values \(V = XW_v\):

\[A = \text{softmax}_{\text{rows}}\!\left(\frac{QK^\top}{\sqrt{d}\,\tau}\right), \qquad C = AV\]

With \(W_q = W_k = I\), the weight stock \(i\) gives stock \(j\) is proportional to

  • \(1/N\) — every stock equally
  • \(\exp(x_i \cdot x_j / \sqrt{K})\) — a similarity kernel in characteristic space
  • the sector indicator \(\mathbb{1}[s_i = s_j]\)
  • the Euclidean distance \(\lVert x_i - x_j \rVert\)

One attention layer in twelve lines of numpy

NVDA’s attention row in October 2022: AMD 0.353, itself 0.263, NFLX 0.154, BABA 0.077, TSLA 0.055, AMZN 0.028 — six stocks carry more than 1 %, from three sectors. The context \(C\) is the peer-weighted characteristic vector: 12-1 momentum −1.49 for NVDA against −1.26 for its peers, dollar volume 3.0 against 2.63.

Predict: what happens as the temperature grows?

With identity weights and \(\tau \to \infty\) every score goes to 0. What does NVDA’s context vector collapse to?

A, C = attention(X, I, I, I, tau=1e6)
print(C[i].round(2))

the cross-sectional mean of X — every weight is 1/N

Temperature is the dial between two crude peer groups: \(\tau \to 0\) attends only to the most similar stock (at \(\tau = 0.05\) AMD carries 0.997 — NVDA is not even its own nearest point), \(\tau \to \infty\) gives the cross-sectional mean (0.02, 0.10, 0.19, 0.00, −0.14), which plain per-month standardisation already removes. \(\tau = 1\) sits in between: a handful of behavioural peers.

What Kelly, Kuznetsov, Malamud and Xu actually train

“Artificial Intelligence Asset Pricing Models” (KKMX, 2025) puts a transformer over the stock cross-section, not over words:

  • Each layer: linear attention \(A = XW_qW_k^\top X^\top\) — no softmax — then a feed-forward block and a residual connection, \(X^{(\ell+1)} = X^{(\ell)} + \text{FFN}(A X^{(\ell)} W_v)\), stacked \(L\) times.
  • The final layer maps each stock’s row to a portfolio weight; all \(W\)’s are trained end-to-end to maximise the Sharpe ratio of that portfolio, not to minimise a squared error.
  • Attention lets a stock’s weight depend on the whole cross-section — the “peer group” is learned.

What we do instead

Weights stay fixed (\(W = I\); a seeded random \(W\) works the same way and is the random-features view). The attention output is used only as feature engineering: for every stock, the context \(C\) (peer-weighted characteristics) and the deviation \(X - C\) (“how I differ from my attention-weighted peers”). The learner on top is the boosting machine from §4.3.

From attention to features: \(C\) and \(X - C\)

Fifteen columns per stock-month: 5 market-relative characteristics, 5 peer contexts, 5 deviations. NVDA in October 2022: 12-1 momentum −1.49 against −1.38 for its peers, price / 52-week high −2.67 against −2.59 — deviations of −0.10 and −0.08. With a self-weight of 0.26 and AMD at 0.35, NVDA largely is its peer group that month. Only month-\(t\) rows enter month \(t\)’s attention: no look-ahead.

The walk-forward protocol

The row dated 2018-12-31 has ret_next = January 2019’s return. May it sit in the training set when we predict the 2019-01-31 row?

  • No — its target overlaps the first test month
  • Yes — its target is realised on 31 Jan 2019, exactly when the 2019-01-31 features are observed
  • Only if we drop one month between train and test
  • Yes — targets can never leak

Expanding window from 2019-01: 71 test months, 6 refits (every 12 months, predicting the next 12 — a monthly refit would be 71 fits, same protocol, ten times the runtime). Two scores per month: rank IC (Spearman between prediction and realised return across the 213 stocks) and the top-20 minus bottom-20 equal-weight spread.

Three feature sets, one protocol

Which feature set will have the highest mean rank IC — and will the difference be significant?

  • Raw standardised \(X\): fewer features, less over-fitting
  • \(X\) + sector-relative: the production invariant wins
  • \(X\) + attention context and deviations: data-driven peers win
  • No set is distinguishable from zero, let alone from the others

Mean rank IC: −0.015 (t = −1.04) raw, −0.014 (t = −0.99) with sector features, −0.010 (t = −0.67) with attention. All three are zero within noise (standard error ≈ 0.015). The long-short spread is positive (+7.7 %, +9.3 %, +2.8 % a year; Sharpe 0.47, 0.62, 0.17, before costs) — the extremes rank slightly better than the middle, but 71 months cannot separate a Sharpe of 0.6 from 0. Attention features did not help here; the protocol that tells you so is the deliverable.

Is a Sharpe of 0.5 evidence?

The t-statistic of a spread with Sharpe \(S\) over \(T\) months is \(S\sqrt{T/12}\): with \(T = 71\), the best set’s Sharpe of 0.62 gives t = 1.51; the raw set’s 0.47 gives 1.15; attention’s 0.17 gives 0.40. To reach t = 2 the sector set would need 124 months of the same performance (10 years), the raw set 18 years. Every gain in the plot comes with an equally plausible zero — and the file is survivorship-biased in the spread’s favour.

Your turn: change the peer group

Change the temperature (tau_new = 3.0) or build the keys from a subset of characteristics (keys_new = ["ret_12_1_z", "rvol_21d_z"]) so that ic_new differs from the baseline attention IC. Does the peer-group definition move the IC by more than its standard error (about 0.015)?

\(\tau = 3\): IC −0.010 (unchanged); keys from momentum and volatility only: −0.025 (t = −1.64); \(\tau = 0.3\): −0.041 (t = −2.60); \(\tau = 10\): −0.008. The sign of the change flips with the setting, and the one “significant” number appears after four tries — that is multiple testing, not a signal. In KKMX the peer group is learned on a Sharpe objective with thousands of stocks and six decades; here it is a fixed kernel on 213 survivors.

What you discovered

  • The cross-sectional frame: one matrix \(X_t\) per month, standardised per month (median / std, within sector in production) so that no row uses a number from its own future.
  • One attention layer is twelve lines of numpy; with \(W = I\) its weights are a similarity kernel, and the temperature interpolates between “nearest stock” (\(\tau \to 0\)) and “cross-sectional mean” (\(\tau \to \infty\)).
  • NVDA’s data-driven peers in October 2022 were AMD, Netflix, Alibaba and Tesla — three sectors; a sector median would have seen none of that.
  • KKMX learn \(W\) end-to-end on a Sharpe objective; we froze it and used \(C\) and \(X - C\) as features for the §4.3 boosting machine.
  • Walk-forward, 71 months, 6 refits: mean rank IC −0.015 / −0.014 / −0.010 for raw, sector and attention features — none distinguishable from zero. Spread Sharpe 0.17–0.62 needs a decade or more to become evidence.
  • Next: the Mistakes Library — what happens when a good predictor becomes an action.

Mistakes Library: Zillow Offers (2021)

Warning

Zillow Offers used the company’s Zestimate models to buy homes directly, renovate and resell them. The models were good predictors of sale prices on the market as observed. But once Zillow acted on them — bidding at the model’s price — the data-generating process changed: sellers accepted the offers the model had overpriced and walked away from the ones it had underpriced (adverse selection), and a 2021 shift in the housing market moved prices faster than the model updated.

In Q3 2021 Zillow wrote down its home inventory by $304 million, disclosed roughly 7 000 homes still to be sold, and on 2 November 2021 shut the business and cut about 25 % of its workforce.

Lesson for this chapter: a model tuned on test RMSE answers “how close is \(\hat y\) to \(y\) under the world that produced the training data?”. The moment your predictions become actions — offers, prices, approvals — you are asking a causal question, and the selection that follows your action is a confounder the test set never contained.

Decision Memo — Should we tighten the approval cutoff?

To: Head of Credit, consumer lending From: <Your name>, credit analytics Subject: Move the PD approval cutoff from 0.50 to 0.17 Date: 2026-09-15

Recommendation: approve applicants with predicted PD below 0.17; refer the rest for manual review.

Evidence: - Logistic PD model, 8 origination features, test AUC 0.70 (8 000-loan sample, 30 % held out). - Expected loss per bad loan ≈ EAD × LGD = 0.70 × 0.89 = 0.62 of principal; expected gain per good loan ≈ 0.10. - Profit-maximising cutoff (ROC slope = gain/loss) is 0.17: catches 78 % of defaulters at the cost of 50 % of good applicants, turning −3.8 cents per applicant into +1.4. At 0.50 the model flags only 7 % of defaulters.

Caveats: - LGD is effectively unpredictable (test adjusted R² < 0); the 0.89 mean is the model. - The cutoff is a prediction, not a causal estimate: tightening approvals changes the applicant pool (Zillow lesson). Pilot on a random 10 % of applications for one quarter.

Next step: A/B pilot; re-estimate AUC and the profit curve on the pilot’s realised outcomes.

Working with an AI Copilot

  1. “Explain why my R² went up when I added these five features.” The copilot will say “more features fit more variance” — correct and useless. Ask instead for adjusted R² and the test-set score, and paste both. If it reports only training numbers, it has not answered.
  2. “This coefficient is significant, so the variable causes the outcome.” A copilot will happily write that sentence. Before you accept it, ask it to list the confounders that could drive both \(x\) and \(y\), and whether any is in your data. If the list is non-empty and unmeasured, the sentence is false.
  3. “Give me the best hyper-parameters for GradientBoosting on this data.” It cannot see your validation split. Ask for a grid (learning rate × trees × depth) and a cross-validation loop, then run it yourself — and report the validation RMSE, not the training one it might quote from an example.

Chapter Summary

Concept Tool
Default, EAD, LGD map, ledger arithmetic on funded_amnt, total_rec_prncp, recoveries
Feature engineering str.extract, pd.to_datetime(format=), fillna(360), pd.get_dummies(...).iloc[:, :-1]
Multiple regression sm.OLS(y, sm.add_constant(X)).fit(), .rsquared_adj, test RMSE
Interactions, best subset itertools.combinations, adjusted R² envelope over \(k\)
Inference .tvalues (
Trees and ensembles DecisionTreeRegressor(max_depth, min_samples_leaf), RandomForestRegressor, GradientBoostingRegressor
Time-series prediction lagged features, TimeSeriesSplit walk-forward, per-fold AUC
Classification sm.Logit, confusion table, roc_curve, roc_auc_score, cutoff at ROC slope = gain/loss
Causal analysis confounder adjustment, DiD interaction term, 2SLS via np.linalg.lstsq
Cross-sectional attention per-month (sector) standardisation, Q @ K.T / sqrt(d) + row softmax in numpy, context \(C\) and \(X - C\) features, expanding-window rank IC and top − bottom spread

Next: Chapter 5 — Rethinking Statistics with Bayesian Methods.

Discussion Questions

  1. The EAD model explains 19 % of variance on training data and LGD is essentially a constant. A colleague proposes a deep neural network to “unlock” both. What evidence from §4.3 (forest and boosting vs OLS) would you show, and what would change your mind?
  2. Best-subset selection on 8 screened candidates chose 4 columns with test adjusted R² 0.130, while the 8 unscreened base features scored 0.141. Design a screening rule that would not have dropped balance and install, and state what it would cost.
  3. Your PD model has AUC 0.70. Regulators require you to explain every rejection. Which of logistic regression, random forest and gradient boosting can you defend, and what does the impurity-based importance plot not tell the regulator?
  4. Management wants to raise repayment by nudging borrowers into the budgeting tool. Sketch (a) the confounder that makes the naive 20 % estimate suspect, (b) a DiD design using two regions with a staggered rollout, and (c) an instrument — and name the assumption each design cannot test.