◈ quant roadmapPart 2 · Ch 21/45
Quantitative Finance — the Mathematics of Markets · chapter 21

21In-Sample vs Out-of-Sample

Chapter 20 closed with a confession, and this chapter is what that confession costs. Every number the machine printed was computed on the rows that chose the coefficients. The R², the t-statistics, the residual plots: all of them scored the fit on its own homework. There is a second question hiding behind that apparatus, and it is the only one that ever pays you. Not how well does this fit the data I have. How well does it predict data I have never seen. Those two questions have different answers. The gap between them is not bad luck, it is manufactured by the act of fitting, and you can compute its size before you run anything. The instrument that measures it is embarrassingly simple. Hold some rows back, fit on the rest, and score on the rows that had no vote. Everything else in this chapter is about protecting those rows from voting, because information leaks into them through channels you cannot see in the code. Fit is what you got. Predict is what you get paid for.

Look at what this page stands on, because nearly all of it is already yours. Chapter 20 gave us least squares as a projection, the fact that ŷ is the nearest point of the column space, and the confession that every diagnostic it built was computed on the fitting rows. It also gave us the dimension count behind n − k, and the warning that R² can only rise when you add a column. Chapter 19 gave us the winner's curse, the family of tests you never counted, and the handoff line that matters most here: selection luck belongs to the sample it was selected in. Chapter 16 gave us bias as a separate disease from noise. That is the whole toolkit.

Ch 20 printed a whole diagnostic kit. Press PREDICT and watch every number go dark — then tap one to hear why it cannot follow you into tomorrow.
THE PRINTOUT · EVERYTHING CH 20 HANDED YOU ▸ tap any diagnostic DIAGNOSTIC PRINTS SCORED ON β̂ coefficients 5 numbers in‑sample no reach 0.61 in‑sample no reach t‑statistics 6.2 max in‑sample no reach residual plot clean in‑sample no reach VIF 1.8 in‑sample no reach HAC std. errors ±0.04 in‑sample no reach THE ROWS OF THE DATA n = 240 rows these chose β̂ TODAY ? ? ? not written yet never seen WHAT THE WHOLE KIT ACTUALLY SAYS every number here was scored on the gold rows 6 of 6 answered here THE MAP SO FAR tap a chapter ▾ Ch 16 Ch 19 Ch 20 Ch 21 Ch 22 Ch 30 Ch 32 Ch 21 · you are here: the kit describes the fit, not the future.

Ch 20 printed all six. All six were computed on the rows that chose the coefficients.

PREDICT FIRST
Six diagnostics, all green, all printed by Ch 20 — and every one computed on the gold rows, the 240 rows that chose β̂. Before you press PREDICT: guess how many of the six can tell you what happens on the rows that do not exist yet.
6 of 6 lit · all in‑sample
What you’re looking at — a full diagnostic kit, and the one band of rows it never touched
the printout — Ch 20’s kit: β̂ (the fitted coefficients), (the share of variation explained), the t‑statistics, the residual plot, VIF (how tangled the columns are) and HAC standard errors.
the fitting rows — the 240 rows that chose β̂. Every number above was scored here, so every number above is a statement about here.
the rows that do not exist yet — tomorrow. Nothing in the kit is computed here, which is why nothing in the kit can speak about it.
cannot answer — tap any greyed diagnostic for the one-line reason. That gap is the entire reason this chapter exists.
Fig. 1. Ch 20’s whole diagnostic kit, and the confession at the door: every number in it was scored on the rows that chose the coefficients, so not one of them can answer the only question that pays — does it hold on rows the fit never saw?

So the seam is a single sentence. Chapter 20 built an apparatus and admitted it could not answer whether the fit holds on rows it has never seen. There is exactly one honest next move available, and it is to ask that question out loud and go and get the number.

01The question Chapter 20 could not ask

Here is a research note of a kind you will read a hundred times. A moving-average crossover on one equity index, tested over ten years of daily data. Buy when the fast average crosses above the slow one, sell when it crosses back. The backtest returns +18% a year.

The desk funds it. Twelve months later the strategy has returned −2%.

Before you read another line, commit to an answer, because the whole chapter is one long reply to whatever you pick. Is that gap bad luck, or is something about the +18% not what it appears to be?

Ten backtested years at +18% a year, then a first live year of −2% — seal your verdict before the reveal.
MA CROSSOVER · ONE INDEX · ANNUAL RETURN % THE CASE FILE BACKTEST · 10 years, simulated +18.0%/yr LIVE · year 1, real money −2.0% bad luck? or was the +18% never real? +20 +10 0 THE WALL −2.0% 10 BACKTESTED YEARS · simulated LIVE YR 1 YOUR SEALED VERDICT PRESS FIT OR PREDICT → nothing scored yet sealed until you commit 1,000 STRATEGIES · LIVE MINUS BACKTEST live WORSE 0 BETTER SO: WHO CHOSE IT? luck would centre this on zero. it sits at −7 pts. +18% was picked.
▼ press one — no skipping
1 · SEAL A VERDICT
nothing sealed yet
2 · SPLIT THE QUESTION
Commit before you learn
anything. A guess you own
is a guess you remember.
What you’re looking at — one rule, scored twice: on the rows that chose it, and on the one row that never met it.
IN-SAMPLE · FIT — the ten backtested years. The crossover’s two window lengths were picked by searching over these rows, so scoring it here measures how well it copies what it already saw: +18.0%/yr, Sharpe 2.0. A backtest is a simulated history of decisions replayed over past data — not a track record.
OUT-OF-SAMPLE · PREDICT — the single live year past the wall. Same rule, same market, same code; only the row-set changed, and the number changed with it: −2.0%, Sharpe −0.1. Fit and prediction are two different questions and the backtest only ever answered the first.
Your sealed verdict stays pinned above. The bottom strip settles it: live minus backtest across 1,000 published strategies is not centred on zero — 86% land left of it, median about −7 points. Luck is symmetric; a search is not. The honest question was never “is +18% high enough” but “who chose the rule, and on what?”
Fig. 2. Commit first, then look: ten years of backtest and one year of live money are not two samples of the same thing — the first set of rows chose the rule, and only the second one tested it.

Name the object properly first. A backtest is a simulated history of decisions — the strategy's rules replayed over past data, trade by trade, as if you had been there. It is not a track record, and nobody ever made or lost a penny inside one. It is a measurement, and like any measurement it can be biased by the way it was taken.

And there are two different questions tangled up inside that measurement. One is how well does this model fit the data I have. The other is how well does it predict data I have never seen. The rows the model was fitted on are called in-sample. Rows it never touched are called out-of-sample. Chapter 20 answered the first question in exhausting detail and could not touch the second.

Now here is the ugly empirical fact that makes this a chapter rather than a footnote. Across the whole industry, live results come in below their backtests, and they do it systematically. Not sometimes above and sometimes below. Below.

02This is not the noise you already know

Most readers have already filed that gap in the wrong drawer, and it is worth stopping to unfile it. Part 2 spent five chapters teaching you that a sample can be trusted once you attach the right error bar. So the natural reaction to +18% and −2% is: small sample, wide interval, nothing new here.

That reaction is wrong, and Chapter 16 already gave us the tool that shows why. It taught two distinct diseases, and you have been using only one of them.

The first is sampling noise, and it is scatter — your estimate lands around the truth, too high about as often as too low, with a spread that shrinks like σ/√n. The second is bias, defined as bias(θ̂) = E[θ̂] − θ, and it is a number that comes out wrong in the same direction every single time you run the experiment. Only one of those two is cured by collecting more rows.

So ask the diagnostic question directly of the backtest itself. If I re-ran this entire research process on a different five years of data, would the reported backtest return come out too high about as often as too low? The answer is no. And that single no is what separates this chapter from a footnote about error bars.

Two dartboards, one shared control. Before you drag: more darts fixes one of these boards and cannot touch the other — which one?
A · SCATTER only B · SCATTER + BIAS ±1 SE THROWS 40 drives both same darts same n bolt = 0 (centred) bolt = k/n = 0.048 avg off by 0.010 avg off by 0.050 CURED BY MORE DATA NOT CURED BY MORE DATA
THROWS 40◀ drag ▶
ROWS n 250SE 0.063
FREEDOM k 12k/n 0.048
A cured · B NOT cured by darts
Same darts, same n on both boards. Only B's aim is bolted off by k/n. Now drag THROWS right.
What you're looking at — one estimator thrown over and over, on a board that is centred (A) and on a board that is bolted off-centre (B)
each dart = one estimate from one sample; the dashed ring is ±1 SE = σ/√n, so more rows tighten the cloud — on both boards.
the gold cross is the average of all throws so far — on A it marches onto the bullseye, on B it converges just as beautifully onto the wrong point.
the bullseye is the truth — the out-of-sample number you actually want.
the bolt is bias: E[θ̂] − θ = k/n. Averaging never touches it, and rows only shrink it if you freeze k — press spend n on k to see what desks really do.
Fig. 3. Two ways to be wrong, and only one of them is cured by more data. Both boards get the identical darts — board B's aim is simply bolted off-centre by k/n, the freedom you spent divided by the rows you had. Throw more and both gold crosses settle down beautifully; A settles on the truth and B settles on a lie. Scatter is what five chapters of standard-error training protects you against. Bias is what a backtest gives you, and no amount of averaging — and no extra row, if you spend it on another parameter — will move it.

The dartboard is the picture to keep. Scatter is darts spread around the bullseye, and the cure is to throw more darts and take the average, which is exactly what a larger sample does for you. Bias is a board bolted to the wall six inches off-centre. Throw ten thousand darts, average them with beautiful precision, and you land six inches off.

Ground that back on the spot, because an analogy left floating is worth nothing. The backtest number is the average of the darts. The offset of the board is a quantity we will compute exactly, and it turns out to be k/n, the amount of freedom you had divided by the amount of data you had.

Which gives the sentence to carry through the rest of the chapter. More data cures variance. More data does not cure optimism. In practice a bigger dataset usually makes things worse, because it tempts you to spend the extra rows on extra freedom, and the ratio that governs the damage stays exactly where it was.

03The only instrument there is

You cannot detect a one-sided bias without a yardstick that does not share it. So the design of the instrument is forced rather than chosen. We need a score computed on rows the fit never touched.

The mechanics take one line: take the data, hold a block of it back, fit the model on what remains, and then score that fitted model on the block. Two numbers now exist where there was one. The in-sample score, computed on the fitting rows, and the out-of-sample score, computed on the held-back block. The difference between them has a name — the generalization gap — and measuring it honestly is what the rest of this chapter is about.

Before pointing that at anything real, point it at a world where we already know the answer. Chapter 19 built the noise factory: candidate strategies with genuinely zero edge, by construction, so that any result the machine reports is manufactured and there is nothing to argue about. Dress it in Chapter 20's clothes. Two hundred and fifty rows of coin-flip returns as the target, and fifty columns of pure random noise as the predictors, drawn from a generator that has never heard of the target.

The true relationship is exactly zero, so every coefficient in that regression should come back as nothing. Run it.

CodeRun — a world with nothing in it: 250 coin flips, 50 columns of noise, one least-squares fit. Step it, and watch what the machine reports.
python ↓  coinflip.py 1 import numpy as np 2 rng = np.random.default_rng(359) 3 n, k = 250, 50 4 # [1] target: a fair coin, zero edge 5 y = rng.choice([-1., 1.], size=n) 6 # [2] k noise cols; they never saw y 7 X = rng.standard_normal((n, k)) 8 # [3] least squares on THOSE SAME rows 9 A = np.column_stack([np.ones(n), X]) 10 b,*_ = np.linalg.lstsq(A, y, rcond=None) 11 rss = ((y - A @ b)**2).sum() 12 tss = ((y - y.mean())**2).sum() 13 print(1 - rss/tss, k/n) 14 # [4] 250 held-back rows: never scored stdout ↓ $ python coinflip n=250 k=50 y — the target: a fair coin, first 50 of the 250 rows 126 up  ·  124 down  ·  mean(y) = +0.0080 The designed edge is exactly 0. There is nothing here to predict.
the run▶ press below
a fair coin: zero edge, by design
k — columns fittedre-run
The target is a fair coin: +1 or −1, 50/50. Nothing to predict — any edge you find is luck.
What you’re looking at — a real run over a world built to contain nothing, scored on the rows it was fitted to
y is 250 coin flips, +1 or −1. The edge is 0 by construction; the sample edge is +0.0080, which is itself noise.
X is k standard-normal columns drawn after y, from a generator that never saw it. Every |r| lands under 0.14.
R²in = 0.2000 — and k/n = 50/250 = 0.2000. Two bars, identical length. Nobody has explained that yet.
R²out, the same weights on 250 unseen rows, is sealed. Write your guess down before the keystone opens it.
Fig. 4. The whole chapter, manufactured in fourteen lines and one number. The world is empty on purpose: y is 250 flips of a fair coin, +1 or −1, so its edge is exactly zero by construction — and the run confirms it, 126 up against 124 down, a sample edge of +0.0080 that is itself just noise. The predictors are 50 columns of standard-normal noise drawn after y from a generator that never saw it, and step 2 shows the receipts: all fifty correlations huddle inside ±0.14, the largest being 0.1304, exactly the sort of number Ch16 taught you to expect from 250 rows of nothing. Then least squares fits, and step 3 prints the sentence that should stop you: R²in = 0.2000. A fifth of the variation in a coin flip, “explained” by fifty columns of static. Beside it the script prints k/n = 50/250 = 0.2000, a number nobody has explained yet — and the two bars are the same length. Re-run at k = 10 and k = 25 and the pair moves together: 0.0251 beside 0.0400, then 0.0887 beside 0.1000. R² is not measuring signal here; there is no signal. It is measuring how many knobs you let the fit turn — which is Ch20’s law (R² never falls when you add a predictor) shown with the signal removed, so only the manufacturing remains. Step 4 applies those same fifty weights to 250 rows the fit has never seen and refuses to print the answer. That is deliberate: commit to a guess for R²out in writing before the keystone breaks the seal. Most readers guess a small positive number. It is not.

The in-sample R² comes back 0.2000. We built a world with nothing whatsoever in it, and the machine reports that a fifth of the variation is explained.

Two honest asides before we chase that number down. The first is the question everyone asks about the split, which is why 80/20 and not something else. There is no rule, and anyone who gives you one is inventing it. The split size is a trade between how well you can fit and how precisely you can measure, and we will improve on the whole arrangement later in the chapter. Right now the split is not a convention. It is the only way to get an untainted yardstick at all.

The second aside is a warning about a shrug. Chapter 20 already told us that R² rises when you add columns, so it is tempting to file 0.2000 under "yes, R² is inflated, use adjusted R² instead" and move on. Please do not. That shrug treats the disease as cosmetic and already patched. By the end of this chapter you will be able to predict that 0.2000 before running anything, from two integers, and you will see exactly what adjusted R² was doing all along.

04★★ Fitting is searching

Why does a fit to nothing report something? The answer is sitting in Chapter 20's own words, and almost everybody reads straight past it.

Chapter 20 said that ŷ is the nearest point of the column space. Read that literally. Nearest means best of every candidate point. Least squares does not find β̂ the way you find a key in a drawer. It holds a competition among every possible coefficient vector, judges them all on the same rows, and reports the champion. The closed form β̂ = (XᵀX)⁻¹Xᵀy is just the shortcut that skips the contest and hands you the winner.

The objection arrives immediately, and it is an honest one. I did not run a thousand tests. I ran one regression. That objection is exactly why this goes unnoticed. Chapter 19 built the winner's curse on a countable m, and here the family is a continuum with no m to count. Nobody sees a search because a formula arrives looking like an answer.

So let us draw the competition the formula hides. One predictor, one coefficient, twelve training rows and two hundred held-out rows. Sweep β across a range and plot the squared error twice: once on the training rows, once on the rows held back.

Sweep one coefficient β — and plot the same squared error twice: on the rows that shaped the fit, and on 200 rows it has never seen
squared error per row 1.0 2.0 3.0 0.0 0.5 1.0 1.5 β +26.0% ◄ drag ► ▲ train 1.0588 held-out 0.5031 ▲ you stand on the BLUE floor the real bill is +26.0% YOUR β 1.0588 train floor TRAIN — 12 rows MSE 0.7502 RSS = 12 × MSE = 9.00 HELD-OUT — 200 rows MSE 1.7000 THE PENALTY +26.0%   n = 12 rows — the two floors sit 0.5557 apart
Drag β and read both bills at once. The two buttons jump you to the bottom of each bowl — the blue floor is the one least squares actually reports.
wrong floor · held-out +26.0%
What you're looking at — one coefficient, one error, measured twice
The training bowl: squared error per row on the n rows the fit was built from. Its floor is β̂ — the single number least squares hands you.
The held-out bowl: the same error on 200 rows the fit has never seen. Its floor sits on the truth ( dashed, true β = 0.500).
The gap. Least squares does not look up β̂ — it runs a competition among every possible β and crowns the winner, so the winning score keeps whatever luck helped it win. With k coefficients you are finding the floor of a k-dimensional bowl: k directions to be lucky in. Push n up and the luck drains away.
Fig. 5. The same squared error, drawn twice on one pair of axes: blue on the 12 rows the fit was built from, green on 200 rows it never saw. The blue bowl bottoms out at β̂ = 1.0588 — more than twice the true 0.500 — and reports a flattering MSE of 0.7502. Stand on that same β in the green bowl and the honest bill is 1.7000 against a possible 1.3491: +26.0%, paid for luck the fit could not tell from signal. Least squares never looks up β̂; it runs a silent competition among every candidate β and reports the champion's score, so that score carries the champion's luck. Drag n from 12 towards 400 and watch the two floors slide together — more rows, less room to be lucky. With k coefficients you are locating the floor of a k-dimensional bowl, and every extra dimension is one more direction the search can get lucky in.

Two bowls, and they do not sit on top of each other — that offset is the entire disease, drawn at the smallest size it can exist. The training bowl's floor sits at 1.0588, which is by definition β̂, the answer the formula would hand you. The held-out bowl's floor sits at 0.5032, and the coefficient that actually generated the data was 0.500. We did not find the best β. We found the bottom of the wrong bowl, and standing there costs 25.9% extra error on the rows that were held back.

Now scale it up in one line. With k parameters you are locating the floor of a k-dimensional bowl, and every extra dimension is one more direction in which the search can get lucky on these particular rows.

Which brings us back to the noise regression, where in-sample R² is already showing 0.2000 on data built to contain nothing. Before anything runs, write down one number. What will the out-of-sample R² be? Nearly everyone writes zero. Some write slightly above zero. Commit, then look.

The keystone. Write one number down and SEAL it — then press RUN and watch R² fall straight through zero.
$ noise_only.py · y = 250 coin flips · X = 50 noise columns the fit uses those 250 rows · then 250 fresh rows it never saw R²_in = +0.2000 R²_out = ? ← same fit, unseen rows THE RAIL · where will the held-out score land? distance — your call: +0.00 · not sealed true: — −0.50 −0.25 0 +0.25 +0.50 THE MIRROR the mirror appears when you press RUN 0 in out +0.2000 −0.2424 in + out = −0.0424 0 −0.003 −0.012 −0.042 −0.259 k=10 k=25 k=50 k=100 why R² = cos²θ held-out: e ⊥ nothing 90°
BEAT 1 · COMMIT
you say: +0.00
BEAT 3 · THE MIRROR
ŷ·e in-sample
ŷ·e held-out
drag your number, then SEAL
What you're looking at — one fit on pure noise, scored twice. The fit never saw the second 250 rows, and that is the only difference.
gold — the flattering numbers: your sealed guess, and R²_in = +0.2000, the score computed on the very rows the fit minimised over.
red — the held-out truth R²_out = −0.2424, and the residual e. Below zero means worse than always guessing the held-out mean.
the 90° cornere ⊥ ŷ is the normal equations, true only on the fitted rows. That corner is what makes R² = cos²θ. Off those rows it opens (ŷ·e = −58.253), Pythagoras dies, and R² is free to go negative.
y — the hypotenuse. Press all k: at 10, 25, 50 and 100 noise columns the gain flips into a loss of the same size — and past k ≈ n/3 the loss runs away. That is Ch 19's winner's curse, paid back on one regression.
Fig. 6. Here is the whole chapter in one screen. The y column is 250 fair coin flips — there is nothing to predict, and we know it, because we made it. The X columns are 50 more columns of pure noise, each one as meaningless as the last. Least squares does not know any of that; it does the only thing it can do, which is find the combination of those 50 columns that lands closest to y on these rows — and it succeeds: R²_in = +0.2000. A fifth of the variation, apparently explained, by columns that mean nothing. Now write your number down. Drag the gold marker to what you think the same β will score on 250 fresh rows, and press SEAL. Most people put it at 0 — the fit learned nothing real, so it should score nothing. Press RUN: R²_out = −0.2424. Not zero. Negative. The model does worse than a flat line drawn at the held-out mean; you would have been better off deleting it. And the objection arrives immediately, as it should, because last chapter proved that R² = cos²θ — and a squared cosine cannot be negative. Both things are true, because R² is two different objects wearing one name. The proof ran through TSS = ESS + RSS, which is Pythagoras, which needs a right triangle, and the triangle is right-angled only because e ⊥ ŷ — and that is the normal equations, which hold only on the rows the fit minimised over. Read the corner in the figure: on the fitted rows ŷ·e = −2.8e−14, zero to the last bit of floating point. Carry the same β to rows it never saw and ŷ·e = −58.253. The corner opens, the triangle collapses, Pythagoras is gone, and 1 − RSS/TSS is free to fall straight through zero. Then look at the two numbers side by side, which is the point of the mirror: +0.2000 up, −0.2424 down. The amount it appeared to help is almost exactly the amount it hurts. Press all k and watch the reflection hold at 10, 25, 50 and 100 noise columns — more freedom, more apparent gain, and a matching-or-deeper loss every time. That is Ch 19's winner's curse arriving in a single regression: the fit searched 50 columns for whichever noise pattern happened to line up with this sample's coin flips, and it borrowed that alignment to win. On any other rows the borrowed alignment is just a wrong answer carried forward. Fitting is searching, so it costs what it appears to gain. Which is why a backtest that scores itself on the data that shaped it is not a result — it is a hypothesis with good manners.

It is −0.2424. Negative. The model did not merely fail to help. It did actively worse than a flat line drawn at the mean of the held-out rows.

And the right objection arrives instantly. Chapter 20 proved that R² = cos²θ, and a squared cosine cannot be negative. So which chapter is lying?

Neither. R² is two different objects wearing one name, and here is the single stroke that separates them. In-sample, TSS = ESS + RSS is Pythagoras on a right triangle. The triangle is right-angled only because the residual is perpendicular to the fit, and that perpendicularity is the normal equations, which hold only on the rows the fit minimised over. Carry β̂ to rows it has never seen and the residual is perpendicular to nothing. The triangle collapses, Pythagoras goes with it, and 1 − RSS/TSS is free to fall straight through zero.

Rotate the residual off perpendicular — and watch R² fall straight through zero
drag the gold dot e ⊥ the fit: normal equations that right angle IS the proof 90.0° y−ȳ ‖e‖ ŷ−ȳ e ȳ ŷ drag to rotate the residual y THE IDENTITY ✓ EQUAL ESS 179.83 RSS 71.46 ESS + RSS 251.29 TSS 251.29 R² — TWO WAYS cos²θ (0 to 1) 0.716 1 − RSS/TSS 0.716 ✓ one and the same SIDE LENGTHS 71.6% better √RSS 8.45 √TSS 15.85
The fit chose β̂ so the leftover e is perpendicular to what the model can build. Rotate that corner and ESS, RSS stay put — only the angle moves, and the whole identity goes with it.
TSS = ESS + RSS · R² = 0.716
What you're looking at — one triangle, and the right angle that only exists on the rows the fit was chosen for
ŷ−ȳ is how far the fitted values stray from the flat line ȳ (the mean of y). Its squared length is ESS, the explained sum of squares.
e = y−ŷ is the leftover the model could not reach. Squared length RSS. Least squares makes it perpendicular to everything the model can build — but only on the rows it saw.
y−ȳ closes the triangle: squared length TSS, and it is exactly the error you'd make predicting ȳ every time. At 90° Pythagoras gives TSS=ESS+RSS, so 1−RSS/TSS = cos²θ is caged in [0,1]. Open the angle and the cage is gone: the gold ‖e‖ reaches past the cyan side, and R² goes negative — on the real seed-359 run, RSS 309.63 against TSS 249.22.
Fig. 7. The same β̂, two sets of rows. On the fitting rows least squares forces the leftover e to sit at a perfect right angle to what the model can build — so the three vectors form a right triangle, TSS = ESS + RSS is nothing but Pythagoras, and R² = cos²θ is caged in [0,1]. Drag the corner open, or hit HELD-OUT ROWS, and that premise evaporates: the normal equations were a promise about those rows and no others. The identity snaps, RSS 309.63 overtakes TSS 249.22, and 1−RSS/TSS lands at −0.242 — 24.2% worse than predicting the mean every single time.

You can watch it in the arithmetic. On the fitting rows the dot product of the fit with the residual is −2.8 × 10⁻¹⁴, which is zero to machine precision. On the held-out rows the same dot product is −58.253. And the residual sum of squares out of sample is 309.63 against a total sum of squares of 249.22, so the model's errors are 24.2% larger than simply guessing the average every time.

Now put the two scores side by side, because the shape of the pair is the point. +0.2000 in, −0.2424 out. The amount it appeared to help is almost exactly the amount it hurts. That mirror is the winner's curse being paid back with interest. The search borrowed this sample's noise in order to win the contest, and on any other rows that borrowed noise is simply a wrong answer carried forward.

Which forces us to re-label two things you thought you understood. Overfitting is not a model being too wiggly, and that mislabel is why people believe a linear model cannot overfit. It is a property of a procedure that had too much freedom. And k, the number of fitted parameters, is not a modelling detail. It is the size of the search.

05The exact price of freedom

So put a number on the freedom. On pure noise the expected in-sample R² is exactly k/n, and the derivation is short enough to hold in your head. Two integers, and you can predict the optimism before you run anything.

Chapter 20 established that fitting spends dimensions. The residual is forced perpendicular to k independent directions, so it lives in the remaining n − k. Now add the one extra ingredient. If the target is pure noise, no direction is special, so by symmetry each of the n directions carries on average 1/n of the total variance. Hand the search k of them and it captures k/n. That is the whole argument.

Where k/n comes from — five beats, and adjusted R² turns out to be the bill
BEAT 1/5 beat n = 250 rows = 250 directions k/n = 50/250 = 0.2000 k = 50 spent n − k = 200 left the residual is perpendicular to all k fitted directions at once drag k · or press next ▶ BEAT 1 · Ch 20's dimension count       e ⊥ col₁ … colₖ k independent constraints dim(residual space) = n − k 250 − 50 = 200 Ch 20 called this the degrees of freedom. It is a ROOM COUNT. n − k is the residual's ROOM
Ch 20's count: fit k columns and the residual keeps n − k directions.
k = 50 spent · n−k = 200 left
What you're looking at — 250 little squares, one per direction the data has, and who gets to keep them
Gold = the k directions the search claimed. Fit a model with k columns and the residual is forced perpendicular to all k of them at once (Ch 20). If the target is pure noise, no direction is special, so each of the n carries 1/n of the total variance in expectation — hand the search k of them and it captures k/n. That is the whole derivation: E[R²in] = k/n. At k = 50, n = 250 it predicts 0.2000 and the simulation returns 0.2000.
Blue = the observed in-sample R², averaged over 400 repeats on data with nothing in it. Chase the prediction with the dial and blue never leaves gold: 0.0196 vs 0.020, 0.1019 vs 0.100, 0.4023 vs 0.400.
Red = the same count reflected onto fresh rows. E[R²out] ≈ −k/(n−k−1) = −0.2513 against an observed −0.2424 — so the in-sample gain and the out-of-sample loss are the same k/n with opposite signs, and the gap is roughly 2k/n. Push k towards n and red falls off the ruler entirely.
Cyan = the n − k the residual still owns — and it is also adjusted R², because that is the same number: R²adj = 1 − (RSS/(n−k))/(TSS/(n−1)), and σ̂² = RSS/(n−k), both dividing by rooms-left instead of rows. Hit LEFT n−k to see the same grid from the other side. On the noise ladder adjusted R² averages −0.0005, +0.0006, +0.0016, −0.0010, +0.0011 while raw R² climbs to 0.4023. It was charging you k/n all along.
Fig. 8. Everyone is taught that R² rises with every column and that adjusted R² “penalises” you for it — as if the penalty were an arbitrary fudge someone chose. Step the five beats and it stops being a fudge. Ch 20 said a k-column fit leaves the residual n − k directions to live in; if the target is pure noise, symmetry hands each of the n directions 1/n of the variance; give the search k of them and it walks off with k/n — predicted 0.2000, simulated 0.2000. Reflect that onto rows the model has never seen and you get the same number back with a minus sign, so honest and dishonest scores are about 2k/n apart. Then drag k from 0 to 200 and watch the punchline: raw R² sails to 0.80 on data containing nothing at all, and adjusted R² sits on zero, refusing to move — because dividing by n − k was always exactly this toll.

Check it against the number we already watched appear. Fifty columns, two hundred and fifty rows, k/n = 0.2000, and the observed in-sample R² was 0.2000. Averaged over four hundred repeats the match holds at every rung of the ladder: 0.0196 against 0.020 at k = 5, 0.1019 against 0.100 at k = 25, 0.4023 against 0.400 at k = 100.

Reflect the same argument onto fresh rows and the expected out-of-sample R² is about −k/n. The exact version is −k/(n−k−1), which for our regression predicts −0.2513 against an observed −0.2424. So the generalization gap on noise is roughly 2k/n, and every one of those quantities is a ratio. The parameter count on its own says nothing. Fifty columns is a catastrophe on two hundred rows and barely noticeable on fifty thousand.

Now collect the payment that has been sitting in Chapter 20 unclaimed. Adjusted R² was defined as 1 − (RSS/(n−k))/(TSS/(n−1)), and σ̂² = RSS/(n−k) divided the residual by its honest dimension count. Both were taught as bookkeeping. They are this exact toll, charged in advance and in miniature. On the pure-noise ladder, adjusted R² averages −0.0005, +0.0006, +0.0016, −0.0010, +0.0011 as k runs from 5 to 100. It reports zero at every level of freedom, which is the correct answer, while raw R² climbs to 0.40.

That is degrees of freedom finally cashed. Not an incantation you subtract to keep software happy. A literal count of the dimensions the residual is still allowed to occupy, and the price of the ones you took away.

One warning before we move on. It is tempting to file k/n as a fact about linear regression. It is not. It is a statement about search freedom, and it will come back untouched around trees, around neural networks, and around a hand-tuned trading rule with no parameters in it at all.

06The U, watched rather than asserted

Everything so far ran on data with nothing in it. So put something real in and run the identical sweep.

Here is the design, and it is deliberately shaped like real quant data. One hundred and twenty candidate factors, each carrying a genuine but weak relationship to the target, with the j-th coefficient set to 0.30/√j so that they fade as you go down the list. Two hundred training rows, and eight thousand fresh rows held back for scoring. The true R², the ceiling that even a model handed the exact answer could not beat, is 0.3258.

Now fit models of increasing complexity, taking the strongest k factors each time, and score each one twice.

A real run, seed 7 — 120 weak-but-real factors, 200 rows to train on, 8000 fresh rows to be judged by. Drag k and watch which curve turns.
one fit, scored twice · error against k 3 2 1 0 0 12 40 80 120 floor TRAIN · seen rows 1.0633 OUT · fresh rows 1.2606 blue can only fall: a wider span can’t miss by more the comb · fitted |b| upward, the true |β| downward column j = 1 (strongest true effect) → j = 120 (nearly nothing)
PREDICT FIRST
 
k = 6 of 120 · 114 unused
gap = out − train = 0.197
UNDERFITTING · gap 0.197
What you’re looking at — the same 200-row fit, marked by its own homework and then by an exam it has never seen.
train error, on the rows that shaped the fit. It falls at every one of the 120 steps, 1.2482 → 0.3317 — Ch 20’s geometry forbids it from rising, so it can never warn you.
out-of-sample error, on 8000 fresh rows. It bottoms out at 1.2182 near k = 12 (R²out = +0.1548; the flat basin’s true low is 1.2136 at k = 14) — then turns, reaching 2.8020 at k = 120, R²out = −0.9440: worse than guessing the average.
the true coefficients, βj = 0.30/√j — every one genuinely non-zero, all of them weak. Nothing here is fake; true R² is 0.3258.
a fitted tooth shouting 3× louder than the truth — noise, memorised. The turn is invisible from inside the fit, so the only curve carrying the news is the one you must hold rows back to draw.
Fig. 9. Sweep k from 0 to 120 and score the same fit twice. The training error falls at every single step and never turns; the out-of-sample error bottoms out near k = 12 and then climbs until the model is worse than useless. Lock the training curve and sweep again: it still never turns.

The in-sample error falls at every single step, from 1.2482 down to 0.3317, and it has no choice about it. Chapter 20's geometry guarantees that a bigger column space cannot be further from the target.

The out-of-sample error does something completely different. It falls from 1.4456 to a floor of 1.2182 at k = 12, then it turns and climbs all the way to 2.8020 by k = 120. Past that turn every extra column buys more fake fit than real signal, and the k/n toll quietly eats the difference.

Only now are the names worth handing over, because you have watched both failures happen with your own eyes. The left arm is underfitting — a model too rigid to hold the structure that is genuinely sitting there in the data. The right arm is overfitting. And the floor between them is not a property of the model family at all: it is the right complexity for this much data.

Which kills the over-correction you may already be forming. Everything since the noise regression makes simpler is always safer sound like wisdom, and the left arm of that curve is a picture of it losing money. At k = 1 the model captures 0.0538 of the available variation when 0.1548 was on the table.

The same k, at four sample sizes — and the axis change that collapses four right answers into one
same k = 45 → 4 verdicts below floor spread 100× n = 50 best k = 1 DISASTER +802% your k = 45 n = 150 best k = 12 PAST THE TURN +17% your k = 45 n = 600 best k = 45 AT THE FLOOR +0.0% your k = 45 n = 2400 best k = 100 STILL CLIMBING +2.5% your k = 45 0 30 60 90 120 x-axis: k = columns used drag the slider ▸ flip the axis
Same signal, same model, same k — only the row count differs. Sweep k.
4 verdicts · one k = 45
What you're looking at — one signal, one model family, four training-set sizes, and the same dial
Each blue curve is out-of-sample error — error measured on fresh rows the model never saw — against k, the number of columns the model is allowed to use. Every strip is the identical signal and the identical model family. The only difference is the number of training rows: 50, 150, 600, 2400. Height is cost relative to that strip's own best, so all four are graded on their own terms.
Green = the floor, the best k available at that sample size: 1, 12, 45, 100 (a real run, seed 7). The green stripe along each baseline is “within 1% of your own floor”. Click a green dot to snap the cursor onto it.
Cyan = one k, read across all four at once. At k = 45: DISASTER on 50 rows (+802%, the curve pinned to the red ceiling), PAST THE TURN on 150, AT THE FLOOR on 600, STILL CLIMBING on 2400. Same k. Same model. Four different verdicts.
Now flip the axis to k / n and watch the green band contract from 100× wide to : the four floors slide into one narrow strip at roughly 2–8% of the rows. FREEZE first and the punchline gets sharper — one gold line in this view becomes four in the other, because a single count is four different ratios. That is why “this model is too complex” is never a complete sentence: complexity was always a ratio, never a count.
Fig. 10. The same k, at four sample sizes. One signal, one model family, one dial — only the number of training rows changes. Sweep the cursor to k = 45 and read four verdicts at once; then flip the x-axis to k/n and watch the four floors collapse into a single band.

Run the same sweep at four sample sizes and the point sharpens. With 50 rows the best choice is k = 1. With 150 rows it is k = 12. With 600 rows it is k = 45, and with 2400 rows it is k = 100. Same signal, same model family, same everything. Only n changed, and the floor walked right.

Now the fact that makes the entire rest of this chapter necessary rather than procedural. You cannot locate that floor from in-sample numbers alone. There is no formula that reads a training fit and returns the right k. The minimum is only visible if you have an out-of-sample curve, which means the rest of the chapter is about how to get one honestly.

And one thing we are deliberately not doing yet. We have watched the U-curve, and we have not taken it apart. Splitting that error into bias squared, variance and irreducible noise is Chapter 22's entire job, and it is what turns this diagnosis into a dial you can turn.

07The freedom nobody counts

k counts what the optimiser searched. It does not count what you searched.

The 60-day lookback you settled on after trying 20 and 120. The entry threshold you nudged from 1.5 sigma to 2 sigma. The universe you narrowed to liquid names because the small caps looked messy. The three signals you dropped and the two you kept. The rerun after a disappointing result. Every one of those is a parameter fitted to the data by a human being, and by Chapter 19's accounting each is another member of the family.

None of them appears anywhere in the regression output. The information travelled through your head rather than through the code, and that is precisely why careful, honest people ship overfit strategies while believing they were rigorous. Nothing on screen changes when it happens. The code is identical, the diagnostics are identical, and the model has no idea.

The information-flow audit of a research week — draw an arrow for every “this influenced that”
THE FILE — three blocks YOUR RESEARCH WEEK · tap a row TRAIN arrows in: ∞ VALIDATION arrows in: 0 TUNED TEST arrows in: 0 SPENT MON lookback 60d, not 20 or 120 ×3 TUE threshold 1.5σ nudged to 2σ ×4 WED universe cut to liquid names ×3 THU dropped 3 of 12 signal sets ×12 FRI reran it after a bad Friday ×7 LOOKS m P(a false winner) E[best t of m] 1 0.050 0.00 m = 1 — the only honest way to read a test set: once.
Tap any row to place that decision. Choose first whether it reads validation or test.
the next tap reads…
m = 1 · P = 0.050 · best t 0.00
What you're looking at — out-of-sample is an information claim, not a date on a calendar
train — the fit reads it, so it is in-sample by construction
validation — the block your hand is allowed to read
one arrow into test and it is training data in disguise
m = looks you spent; P = 1 − 0.95m, the odds of a fake winner
Fig. 11. The audit is the whole lesson: draw the data on the left, your week on the right, and one arrow for every “this influenced that”. Tap the rows with test selected and the week you actually had — a lookback picked after trying 20 and 120, a threshold nudged from 1.5σ to 2σ, a universe narrowed, three signals dropped, a Friday rerun — lands on exactly 29 configurations plus the one honest look: m = 30, Ch 19's number, so P(at least one |t| > 1.96) = 1 − 0.9530 = 0.785 and E[best t] = 2.04 on data with no edge at all. Press re-split and watch the counter refuse to fall: re-cutting the same file cannot un-see it. Then press clean run — the identical week routed through validation, test block still green, m = 1. That is why careful people ship overfit strategies: every one of those hand-choices was a parameter fitted to the data, but it travelled through your head instead of the code, so it shows up in no printout and in no value of k. Out-of-sample means data that influenced no decision — information flow, not the calendar — and a test set is a consumable.

Which forces an upgrade to the definition we have been using, and it is the most important sentence in the chapter after the keystone. Out-of-sample does not mean later in the file. It means data that had no opportunity to influence any decision. Information flow, not chronology.

The corollary is brutal. The test set is a consumable. The first time you read its score and change anything in response, its information has entered your model through you, and no re-split of the same file can restore it.

People accept that as etiquette and then peek anyway, because one look feels harmless. So price it instead of moralising about it. Thirty variants tried against a holdout is m = 30, and Chapter 19 already told us what that costs. On data with no edge whatsoever, the chance that at least one of thirty tries clears a t of 1.96 is 78.5%, and the expected best t of the thirty is 2.04. That is the number that gets a strategy funded.

Give the whole phenomenon its name. Backtest overfitting is the winner's curse charged on a research process rather than on a single fit, and it is invisible in every printout the process produces.

08Three jobs, then rotation

If the test set can only be read once, and you still have choices to make, then the choosing has to happen somewhere else. So split by job rather than by name.

Train fits the parameters. Validation chooses among models and settings, which are the things you would otherwise have tuned against the test set. Test is read exactly once, at the end, and reported. Define them by the question each one answers rather than by its label, because that framing survives a colleague using the words backwards. A setting chosen by search rather than by the optimiser has a name too: it is a hyper-parameter.

Now improve the middle one, because a single validation block is a small sample, and Chapter 16 says a small sample gives a wide standard error. It also wastes rows you could have been fitting on.

Every row takes exactly one turn as the judge — and the judges are not independent
10 rows · 5 blocks · 1 held out role turns fold 1/5 · fit 160 · score 40 each row: 1 score, 4 train turns tap a row to jump the block there 300 datasets · 300 estimates one 40-row holdout sd 0.3079 k-fold average, k = 5 sd 0.1465 real ±0.1465 √k: ±0.1377 0.4 0.8 1.2 6% short of the √k promise folds share 75% of their rows real 2.10× tighter · √5: 2.24× raise k → tighter, but slowly
k = 5 blocks of 2 rows
the held-out block rotates
every row scored exactly once
k=5 · sd 0.1465 · 2.10× tighter
What you're looking at — the same ten rows, taking turns being the judge
Training rows — the model is fitted on these. Each row spends k−1 of the k turns here.
The held-out block — scored, never fitted. It rotates, so every row is judged exactly once. On the right, gold is the spread of the k-fold average over 300 fresh datasets (the dashed line is the truth, Sharpe 0.80).
The √k promise: where the spread would land if the k fold scores were k independent draws. The gold bracket never gets inside it.
Press three jobs: train picks the parameters, validation picks the model, test gives the one number you report — and is spent the moment you look.
Fig. 12. Ten rows, k blocks, one block held out and rotating: every row is scored exactly once and trained on k−1 times. Over 300 fresh datasets a single 40-row holdout scatters with sd 0.3079 while the 5-fold average scatters with sd 0.1465 — 2.10× tighter, better than halved, but short of the √5 = 2.24 the square-root law promises, because the five fold-models share three-quarters of their training rows and so are not five independent draws. Push k to 10 and the shortfall widens to 17%: leave-one-out fits ten models that are near-copies of each other.

The picture is the definition. Cut the training data into k blocks and go round k times, fitting on k−1 of them and scoring on the single block left out. Average the k scores. Every row gets exactly one turn as a scoring row and k−1 turns as a training row, so nothing is wasted. That is k-fold cross-validation, and you could have invented it yourself from the grid.

The reason to bother is variance, and it is worth measuring rather than asserting. Repeat the whole exercise on three hundred fresh datasets. A single 40-row holdout gives an estimate with a standard deviation of 0.3079. The five-fold average gives 0.1465. The wobble is more than halved.

Notice it is not divided by √5 = 2.24, which is what Chapter 13's law of large numbers would give for five independent estimates. The observed ratio is 2.10, because the five fold-models share training rows and are therefore not independent. Push that to the extreme and you get leave-one-out cross-validation, where k = n. Its folds differ by a single row each, so they are almost the same model, and averaging them settles the number far less than the count suggests.

Five fold-models and the one model you actually ship, drawn in one frame. Drag FOLDS k and watch the faint blue profiles close on the gold one.
COEFFICIENT PROFILE — 12 factors 0 +.4 −.4 factor 1 factor 12 FACTOR 1, UP CLOSE ship 0.2816 spread 0.1166 gap 0.0350 THIS CV NUMBER DESCRIBES fit the recipe on 160 rows THE MODEL YOU WILL DEPLOY was fitted on 200 rows
FOLDS kk = 5
drag → more folds
THE VIEWtap
spread = the k fold scores
 
recipe: 160 rows · ship: 200
What you’re looking at — a cross-validation score is a report card on a recipe, not on the model you ship.
the faint blue profiles — the k fold-models. Each was fitted on n(k−1)/k rows, and each is a different model.
the solid gold profile — the object you deploy, refitted on all 200 rows. Its factor-1 coefficient: 0.2816.
the dashed line — the truth that made the data (factor 1 = 0.30). Nobody in real life ever sees it.
k = 2: the recipe only ever sees 100 rows and the gap to the shipped model is 0.0941. At k = 200 it is 0.0037.
Fig. 13. Here is the rung almost every course skips, and it is the reason a carefully cross-validated model can still surprise the desk that deployed it. Two hundred rows sit behind this figure, twelve factors, a fixed seed; every line and every numeral you see is fitted live in your browser. Cross-validation cuts the rows into k blocks, holds one out, fits on the rest, scores on the held-out block, and rotates. At k = 5 that produces five different models — the five faint blue profiles — each fitted on 160 of the 200 rows, and their coefficients on factor 1 come back 0.2612, 0.2332, 0.2637, 0.3017, 0.3498. Their out-of-sample errors are 1.2295, 1.4794, 1.0591, 1.2075, 1.4110, and the average of those five numbers — 1.2773 — is what everyone calls “the CV score”. Now look at the solid gold profile. That is the model you will actually deploy: refitted on all 200 rows, factor 1 = 0.2816. Notice what just happened. None of the five models that produced the score is the model you are shipping. The score belongs to a procedure — “fit this recipe on 160 rows” — and you deployed an artefact built on 200. The two cards at the bottom say it in words, and the number in the left card moves while the right card never does. Drag the k dial and the mismatch becomes physical. At k = 2 each fold-model sees only 100 rows — half your data — and the blue profiles fly apart: factor 1 comes back 0.1729 and 0.3611, an average distance of 0.0941 from the shipped 0.2816. That CV number is an honest estimate of a model weaker than the one you built, so it is pessimistic: it under-sells your final fit. Push k up and the training slices fatten — 150, 160, 175, 180, 190, 195, and at k = n = 200 (leave-one-out) each fold-model trains on 199 of the 200 rows. The gap collapses: 0.0941 → 0.0350 → 0.0177 → 0.0037. The faint lines lie down on the gold one, because a model fitted on 199 rows and a model fitted on 200 are very nearly the same object. That is the whole trade in one dial: large k buys you a procedure that resembles what you will ship; small k buys you speed and a bias against yourself. Then press SPREAD, and the second half of the honesty lands. The five fold scores stop being a tidy average and become five dots strewn from 1.06 to 1.48 — each one itself an average over just 40 held-out rows, and therefore itself a noisy estimate (Ch. 16). A single CV number is the mean of a handful of numbers that wide; report it to four decimals and you are performing a precision you do not have. Roll k to 200 in this view and watch the failure mode at the other end: each “score” is now a single squared error, ranging from 0.02 to 7.19 — the average is stable, but no individual fold tells you anything at all. So the discipline: a CV score is a statement about a recipe evaluated on n(k−1)/k rows, carrying a standard error you should quote, and it is not a certificate for the object you are about to put in front of the market. (And a warning that this chapter has already made and that this figure deliberately does not fix: these folds are contiguous blocks of rows, but nothing here stops a fold-model from being fitted on rows that come after the block it is scored on. On a time series that is training on the future to predict the past, and it is why k-fold must give way to walk-forward validation — the CV number stays beautiful while the strategy dies. Shrinking these wobbling coefficients on purpose — ridge and lasso, and the bias–variance dial behind this whole picture: Ch. 22. Why adjacent financial rows are not independent, so the effective sample size is smaller than the row count: Ch. 32. Transaction costs, without which even an honest backtest is still a fiction: Part 5.)

And here is the caveat almost nobody states out loud. Those k fits are k different models. On our own run the coefficient on the leading factor comes back as 0.3611, 0.2688, 0.2339, 0.2607 and 0.2850 across the five folds, while the model you would actually deploy, refitted on all two hundred rows, has 0.2809.

So a cross-validation score estimates the out-of-sample performance of a procedure. Fit this recipe on this much data. It does not estimate the performance of the single model you are about to ship, which was fitted on all n rows rather than on n(k−1)/k of them. The mismatch is small when k is large and it is not small when k is 2, and nobody tells you.

09The assumption inside the shuffle

k-fold has an assumption inside it that nobody says out loud. Shuffling the rows before you cut them into blocks assumes the rows are exchangeable, meaning a row is a row and it does not matter which ones land in the scoring block.

Financial rows violate that assumption twice over. First, they carry an arrow of time, and a shuffled fold will cheerfully train on next year in order to predict last year. Second, and this is the one that does the real damage, they are dependent — today's row shares information with yesterday's, so a row is not a row.

Before any definitions, take a number. Here is a random walk, a running sum of independent coin flips. Its level is enormously persistent, and its increments are pure noise by construction, so there is provably nothing to predict. Set up a shuffled 5-fold that predicts today's level from yesterday's level. What R² do you expect?

Seal your call, then run it: a shuffled 5-fold scores 0.9865 on a series that provably contains nothing — and the same fitted model scores +0.0001 on the only part you could trade
truth = 0 full corr you: 0.05 R²=0 1 ACF ρ(h) = Corr(x_t, x_t+h) h = 1..8, in days
sealed at0.05
place your call, then run
What you're looking at — five confident folds on a series that provably contains nothing
the level xt — hugely persistent, ρ(1) = +0.9871
the steps et — iid by construction, ρ(1) = −0.0721: that is what zero looks like on 500 rows
a 20-day smoothed feature built from an unrelated noise source
a 20-day overlapping forward return as the label — the true relationship is exactly 0
Fig. 14. Two runs, one rail. Part 1 builds 500 rows of a random walk — xt = xt−1 + et, where the steps et are drawn independently, so there is provably nothing in them to predict. A shuffled 5-fold, asked to predict today's level from yesterday's step, reports R² of 0.9853, 0.9864, 0.9871, 0.9819, 0.9920 — five confident confirmations, mean 0.9865, of a model that knows nothing. Read its fitted line and the joke is written on it: x̂t = 0.9988 xt−1 + 0.0626, which is the machine saying tomorrow equals today. Score that identical fit on the only quantity you could ever trade — the day-to-day change — and it collapses to R² = +0.0001, with a predicted-vs-actual correlation of +0.0106. The instrument that explains the gap is the autocorrelation function: ρ(h) = Corr(xt, xt+h), the correlation of a series with itself h steps later. Here ρ(1) of the level is +0.9871 while ρ(1) of the steps is −0.0721 — and that second number is not a signal, it is simply what zero looks like when you measure it on 500 rows. Part 2 strips away the last excuse: a feature smoothed over 20 days out of an independent noise source, a 20-day overlapping forward return as the label, and a true relationship of exactly zero. Full-sample correlation lands at −0.4091; the shuffled 5-fold reports +0.1455 and is positive in all five folds; walk-forward reports −0.0801 with blocks swinging from −0.6429 to +0.2201. Five hundred rows, but only about 25 independent ones. That is the assumption hiding inside every shuffle: it assumes a row is a row. When neighbouring rows share information, the held-out row was never held out — its answer was already sitting in the training set, one day away.

The shuffled folds report 0.9865, and they agree with each other beautifully: 0.9853, 0.9864, 0.9871, 0.9819, 0.9920. Five independent-looking confirmations of a model that knows nothing.

Look at what it actually learned. The fitted slope is 0.9988 with an intercept of 0.0626, which is the model saying tomorrow equals today. Score the only quantity you could ever trade, the day-to-day change, and the R² is +0.0001. The correlation between predicted change and actual change is +0.0106.

Now define the thing that made it possible, because the definition lands properly once you have felt the phenomenon. The autocorrelation at lag h is ρ(h) = Corr(x_t, x_{t+h}), which is Chapter 18's correlation with the series shifted against itself. Read across all the lags and you have the ACF. For our walk, ρ(1) of the level is +0.9871 and ρ(1) of the increments is −0.0721, which is what zero looks like when you measure it on 500 rows.

When ρ(1) is high, every held-out row has a near-twin sitting in the training set, one day away. The model does not have to predict anything at all. It only has to recognise a neighbour it has already memorised, and interpolate between the two rows on either side.

The second half of that run shows the same leak in the form quant work actually meets it. Chapter 20 flagged the mechanism: a 20-day forward return computed every day makes neighbouring labels 95% the same data. So take a feature built from an independent noise source and smoothed over 20 days, and a 20-day overlapping forward return as the label, with a true relationship of exactly zero. The sample correlation over the whole file comes out at −0.4091. Shuffled 5-fold reports +0.1455, positive in all five folds. Walk it forward in time instead and the average is −0.0801.

And the blocks of that walk-forward run swing from −0.6429 to +0.2201, which is the honest signal here. Five hundred rows, but the effective sample size is about 500/20 = 25 independent observations. There was never enough information in that file for a stable number, and both the split and every error bar you compute from it are lying about how much you know.

One naming trap to close, because it catches good people. Chapter 20 met autocorrelation as a residual pathology, fixed by HAC standard errors. Here it is a property of the series that invalidates an entire resampling scheme. Same word, different job, and the HAC fix does not cover this one.

10Walk forward, and cut a hole

Two violations were named, so fix exactly two things.

For the arrow of time: fit on everything up to time t, predict the block that runs from t to t+h, then roll forward and repeat. That is walk-forward validation, also called rolling-origin cross-validation, and it is the only scheme in which every single prediction was made using information that already existed when it was made.

For the dependence: the rows on either side of the boundary are still near-twins, so remove them. Purge the training rows whose labels overlap the test window, and embargo a buffer of test rows immediately after the boundary, in case the feature construction reaches backwards as well.

Walk forward, then cut a hole — and the hole's width is a number you already measured
ZOOM · training rows just before the wall TEST BLOCK t each bar runs from the row’s date to the day its label is known FOLD 1/4 TEST day 0 100 200 300 hole width = 20 days = the label horizon PURGE OFF · 20 training rows hold test-block answers
Drag LABEL HORIZON. Each bar is one training row, reaching to the day its answer is known.
20 rows leak into the test
What you’re looking at — the hole is not a hack, it is a measurement
Blue = the test block. Fit on everything to its left, score on it, roll forward one block, repeat. Information never travels backwards.
Red = a training row whose label window crosses the wall. Its answer — the next h days of return — was partly written by the test period. Scoring on it is scoring on itself. (The zoom shows every 6th row.)
Gold = the hole. Its width is not a taste parameter: it is h, the label horizon you already measured. A 20-day forward return → a 20-day hole → exactly the 20 rows that were contaminated, and no more.
Violet = the embargo. The same h added after the test, because features reach backwards too: a 20-day moving average on the first post-test row is still made of test days.
Fig. 15. Walk-forward is the honest protocol: fit on everything up to day t, score the block from t to t+40, roll forward, repeat — the blue block marches right and information never travels backwards. But look at the zoom before you congratulate yourself. Each grey bar is one training row, drawn from its date to the day its label is finally known, and with a 20-day forward return as the target those bars are 20 days long. The last twenty of them reach across the wall: their answers were partly written by the very days you are about to score on, so the row and the test set share information and the score comes back flattering. Turn PURGE on and the fix is not a judgement call — the hole is exactly h days wide because h is the overlap you already measured, which removes precisely the contaminated rows and not one clean row more. Crank the dial to 60 and watch the price: the hole is three times wider and sixty training rows are gone. Then add the EMBARGO, the same h on the other side, because features reach backwards as surely as labels reach forwards.

The width of that hole is not a taste parameter, and this is where purging stops feeling like a hack bolted on to rescue a method. The purge width IS the label horizon. If your target is a 20-day forward return, the overlap you measured is 20 days, so the hole is 20 days wide. You are removing exactly the rows whose answers were partly written by the test period.

A training window is not a knob — it is a BET. EXPANDING bets the world never changes; ROLLING bets it does. Break the world and watch which bet pays.
① THE FOLD LADDER — train on the bar, test the next block k/n ② STITCHED OUT-OF-SAMPLE CURVE 1.50 1.00 warm-up early folds ends 1.72
① THE BETtap one
② WINDOW LENGTHdrag →
ALL history · n grows 100→380
③ THE WORLD
④ EARLY FOLDS
OOS total, counted+72.2%
incl. early folds+72.2%
mean k/n counted0.037
worst block+4.9%
predict: which bet survives?
What you’re looking at — 420 rows of history, one walk-forward run. Each row of the ladder trains on its bar and is scored on the gold block right after it; n = rows it trained on, k = 8 knobs it fits, and k/n is the overfitting toll it pays.
EXPANDING starts at row 0 every time. n grows, k/n shrinks — a bet that the relationship is stable.
ROLLING keeps only the last L rows. It forgets fast, but n never grows, so it pays that k/n toll forever.
the stitched curve — every gold block glued end to end. Nothing here was ever fitted; it is the closest thing to an honest track record.
the chips + the red stretch = k/n. The first folds saw almost nothing, so their scores measure your sample size, not your idea.
Fig. 16. Choosing a window is not tuning — it is stating a hypothesis about whether the world holds still, and each hypothesis wins exactly when it is true.

There are two flavours of the training window, and they are two different bets about the world. An expanding window keeps all history and bets that the relationship is stable. A rolling window keeps only the last N rows and bets that the world changes, paying for that adaptivity with a smaller n and therefore a larger k/n toll. Neither is correct in general. They are hypotheses, and you should be able to say which one you are making.

Two practical warnings. Stitch the fold predictions end to end and you get an out-of-sample equity curve, which is the closest thing to an honest simulated track record you will ever produce. But the earliest folds are fitted on very little data, so their scores are dominated by k/n rather than by the strategy, and averaging them in tells you about your sample size rather than about your idea.

Now the temptation, said out loud because it is the thing that actually happens. Walk-forward returns worse numbers and fewer of them, and there is a k-fold script sitting right there that gives lovelier results in one line. The answer is not discipline, it is arithmetic. We watched the prettier number report +0.1455 on a relationship that is exactly zero. It is not a better score. It is a different measurement.

The advanced version of this same idea has a name worth knowing: combinatorial purged cross-validation, which runs many purged train-test arrangements rather than one path, so you get a distribution of outcomes instead of a single number. Same two fixes, applied many times over.

11Information that did not exist yet

Everything so far assumed the data itself was honest. It usually is not, and one law covers all of the ways it fails. You may use only information that existed, in the form it existed, at the instant of the decision.

Most readers will now think of using future prices, decide they would obviously never do that, and read the rest of this section as a list of other people's mistakes. That is the exact inch where the section fails, so let me be blunt about it. Real leaks are indistinguishable from competent code, and every one of them is a line you have written without a second thought.

Four lines of a research script. The split is purged, embargoed, textbook-perfect — and the backtest is still a lie. Tap ①: is this code clean?
① FOUR LINES OF RESEARCH CODE is it clean? X = features(prices) X = (X - X.mean()) / X.std() tr, te = purged_walk_forward(X, embargo=5) fit(X[tr]); sharpe(X[te]) ← leak ③ WHERE THE INFORMATION GOES tap a box the test rows’ mean prices a transform split train test (later) the reported Sharpe tap ① and the mechanism appears here, in one line. ④ THE TELL t = 8.0 × √2.0 = 11.31 plausible the usual bar absurd 0 2 4 6 8 10 12 open t close t open t+1 filled 6.5 h before the signal existed · SR 2.40 drag the marker ↔
① PREDICTtap one
② THE LEAKtap a card
④ THE TELLt = 11.31
Sharpe claimed8.0
years of record2.0
is this script clean? tap ①
What you’re looking at — a split that is flawless, and a leak on the line above it. The scaler learned the test block’s mean and handed it to every training row, before the split ever ran.
the pipeline — every blue arrow points forward in time. Tap a box to trace what flows in and out of it.
the leak — one arrow carrying information from later than the decision, sometimes straight out of the sealed test block. Six cards, six mechanisms.
the split — purged, embargoed, innocent. It is not the thing that broke.
the information instant — the moment a number first existed — and the tell: t = SR·√T.
Fig. 17. The split in line 3 is purged and embargoed and completely correct — and it saves nothing, because line 2 already ran. Fitting the scaler on the whole sample means the mean and standard deviation were estimated from every row, the sealed test rows included, and that number was then subtracted from every training row: the training set knows the future’s centre. The rule the checklist misses is one line long — every transform that ESTIMATES a number from data (centre, scale, impute, winsorise, PCA) must be fitted inside the fold. Tap the other five cards for the same crime in other clothes: the overnight fill (drag the marker — entering at 09:30 on a signal that did not exist until 16:00, and watch the claimed edge collapse from 2.40 to 0.10 the moment you fill legally), survivorship, restated fundamentals dated to the period end rather than the publication date, the off-by-one label, and full-sample PCA. And the tell, from Ch 16: a track record’s t-statistic is t = SR·√T, so a Sharpe of 8 over two years is t = 11.3. Nothing in markets is that good. A spectacular result is not a discovery; it is a bug report.

Start with the one that catches good engineers, because it survives a perfect walk-forward split with a correctly sized purge. It happens on the line above it. Standardise your features with the full-sample mean and standard deviation, impute missing values with the full-sample median, winsorize on full-sample percentiles, or fit a PCA on everything, and the test set's information has already walked into the training pipeline before the split ever ran.

The rule that fixes it is short. Every transform that estimates a number from data must be fitted inside the fold, on training rows only, and then applied unchanged to the rows you held out. A scaler is a model. It has parameters — a mean and a standard deviation — and they have to be learned from training data like any others.

Then the catalogue, each with its mechanism. The overnight-to-open gap: you compute a signal from today's close and the backtest fills you at today's close, which is impossible, because the close is the last price of the day and you only learned it after trading stopped. The earliest you could act is tomorrow's open, and a large share of the move lives in that jump. Survivorship bias: the universe file you downloaded today contains only the names that survived, and the ones that vanished were the bad ones. Restatement: fundamentals get revised, so the value in the database now is not the value published then, and you need point-in-time data. The off-by-one label: align the forward return one bar early and you have built a money machine out of a typo.

None of these throws an error. None of them looks like cheating. So carry the tell instead. A spectacular result is a bug report. Chapter 16's arithmetic makes it concrete: t = SR·√T, so a Sharpe ratio of 8 measured over two years is a t-statistic of 11.3. Nothing real produces a t of 11 on two years of data. Go and find the leak.

12When the columns outnumber the rows

Now push the ratio to its limit, because that is where quant data actually lives. Five hundred candidate factors against twenty years of monthly returns is 500 columns and 240 rows. This is not an exotic corner case from genomics. It is the default shape of a modern factor research problem.

Chapter 6 and Chapter 20 answer immediately. When k reaches n the columns span all of ℝⁿ, the target lies inside the column space, the projection is the target itself, the residual is exactly zero, and R²_in = 1. Push past it and β̂ is not even unique: infinitely many coefficient vectors reproduce the data exactly.

So walk the ladder past the wall on data that contains nothing, and watch what the software does at each rung.

CodeRun — 240 monthly rows, k columns of pure noise, one lstsq. Walk k past the number of rows and predict what the software does.
python ↓  wall.py  ·  seed 11 1 rng = np.random.default_rng(11) 2 n = 240 # 240 monthly rows 3 y = rng.standard_normal(n) # noise 4 X = rng.standard_normal((n, 600)) 5 k = 100 # factors we fit 6 ones = np.ones((n, 1)) 7 A = np.hstack([ones, X[:, :k]]) 8 b, *_ = np.linalg.lstsq(A, y) 9 print(A.shape, rank(A), R2, R2_out) RANK METER — directions, not columns 501 240 0 the wall 261 free k=0 239 500 stdout ↓ $ python wall.py k = 100 cols(A) = 101 rank(A) = 101 free = 0 R2_in = 0.446354 max|e| = 2.15 R2_out = -0.76 beta-hat — is it unique? UNIQUE ✓ norm(b) = 0.7706 101 columns, 240 rows. Each column gets its own direction → one b.
① WALK THE LADDERdrag →
k=100 · 101 cols vs 240 rows
② NOW RUN k = 500tap one
predict: lstsq will…
R²in = 0.4464 from pure noise
Every column is pure noise, drawn after y. R²in still climbs — that is flexibility, not signal.
What you’re looking at — one real run (seed 11): 240 monthly rows of pure noise as the target, up to 600 noise columns as candidate factors, and least squares told to fit the first k of them.
rank(A) — the directions 240 rows can actually tell apart. It climbs with k, then stops dead at 240 and can never go higher.
columns climb forever. The wedge between them is the null space: at k=500, 261 directions β̂ can move along without changing one prediction.
R²in = 1.000000 at k=239, max|e| = 8.2e-14. That is machine zero — not understanding, geometry: 240 columns span the whole space.
the wall. Past it the software returns one answer out of infinitely many — the shortest — and prints it as confidently as ever. R²out = −302.58.
Fig. 18. This is the whole chapter’s worst news, executed. The target y is 240 monthly rows of pure noise; the candidate factors X are 600 more columns of pure noise, drawn afterwards by a generator that never saw y. There is nothing here to find. Yet drag k and watch R²in march: 0.0706 at k=20, 0.4464 at k=100, 0.7488 at k=180 — and the largest residual shrinking with it, 2.97 → 2.15 → 1.59. At k=238 the design matrix has 239 columns for 240 rows: exactly one row is left over to hold the entire error, and it shows — R²in = 0.999987, max|e| = 9.67e-03. Then k=239. Now the matrix is 240 columns for 240 rows, the columns span the whole space the data lives in, and the fit is exact by geometry: R²in = 1.000000, max|e| = 8.20e-14 — machine zero, the residual of a computer with nothing left to say. On a fresh, unseen year the same weights score R²out = −302.58: three hundred times worse than predicting the average. A perfect fit is not the best possible result; it is the loudest possible failure signal. Now the second half. Push to k=500 and predict first, in writing: does lstsq throw, warn, or answer? Almost everyone says it must complain — 501 columns, 240 rows, the problem is underdetermined. It answers. No exception, no warning, a full coefficient table. And it has to be hiding something, because rank(A) is stuck at 240 while the columns run to 501: 261 free directions along which β̂ can be moved without changing a single prediction. The figure prints two of those solutions side by side. They disagree by 22.8197 in the coefficient on x240 — one says −0.0095, the other says +22.8101 — their lengths are 0.8794 and 37.0104, and yet their predictions agree to 9.71e-14 and both leave a residual sum of squares of essentially zero. Both are exactly, equally correct. The one you were handed is the minimum-norm pseudo-inverse solution Ch20 named: of the infinitely many exact answers, numpy quietly returns the shortest. That is the sentence to carry out of Part 2 — past the wall the coefficients stop having meanings, but the printout looks exactly as confident as it did at k=20.

At k = 239 the design matrix has 240 columns for 240 rows, in-sample R² reads 1.000000, and the largest residual in the whole file is 8.2 × 10⁻¹⁴. A perfect fit, on pure noise. Out of sample the same model scores −302.58.

Past the wall it gets stranger and quieter. At k = 500 the rank is stuck at 240 with 261 free directions, so the answer is genuinely non-unique, and the software returns one anyway. Here are two solutions to that same problem. One has a coefficient vector of length 0.8794, the other of length 37.0104, and a single coefficient differs between them by 22.82. Their predictions agree to 9.7 × 10⁻¹⁴, and both have a residual sum of squares of essentially zero.

Nothing on the screen warns you that any of this happened. That is the minimum-norm pseudo-inverse Chapter 20 named, quietly picking one member of an infinite family and printing it with a full table of coefficients, standard errors and p-values beside it.

Which gives the sentence for the door. A perfect fit is the loudest possible failure signal. Every scrap of variance is explained, and not one bit of it is information.

There are exactly three exits, and they are the same move wearing different clothes. Constrain the fit so the search cannot use all k directions freely. Reduce the dimensions before fitting. Or select fewer features, honestly, inside the fold. All three are Chapter 22's door.

13The protocol, and what it cannot reach

Write the chapter down as something you could hand a colleague on their first day, with each rule carrying the failure that forced it.

Seven rules you could hand a colleague on day one. Tap one to switch it OFF and meet the failure that forced it.
THE PROTOCOL 7 of 7 on 1 state the question first 2 split by time, with a purge 3 transform inside the fold 4 tune on validation only 5 touch the test set once 6 tally tries, take haircut 7 spectacular = bug report tap a rule → switch it OFF THE PROTOCOL · 7 rules HAND THIS TO A COLLEAGUE every rule is a scar Each one exists because of one specific failure. Tap a rule: the card flips to that failure, the rule switches OFF, and the damage bar on the right widens. right: how wrong you could be seven rules your number the one you kept 3 uncounted train test purge = h μ and σ from all of it one fold held out, rotating TEST — sealed 1 look — logged twice = two tests haircut max of N t−1? honest SHIPPED the U-curve’s floor: Ch 22 bias² variance noise +0.30 how wrong you could be tap the floor
the floor: +0.30, not zero
Each rule is a scar. Switch one off and the bar shows how far your reported Sharpe could drift above the truth — and with all seven on, it still does not reach zero.
What you’re looking at — seven rules, each one a scar, and the damage they hold back
a rule ON. Tap it to switch it off — the card flips to the exact failure that rule exists to prevent, with a thumbnail of where you watched it happen.
a rule OFF, and its slice of the bar: how far your reported Sharpe could sit above the truth. Every slice is one-directional — optimism, never pessimism.
the floor that survives all seven. Tap it: choosing to deploy this strategy is itself a selection made after seeing everything, and there is no holdout left to intercept it.
the data in each thumbnail — train, folds, the split that leaked. The protocol does not make the backtest true; it converts an unbounded, invisible bias into a bounded, stated one.
Fig. 19. The protocol does not make the backtest true. It converts an unbounded, invisible, one-directional bias into a bounded and stated one — and the last arrow, the decision to ship this strategy after seeing every number, is the one no holdout can ever intercept.

State the question and the horizon before you look at anything. Split by time, with a purge sized to the label horizon. Build every transform inside the fold. Tune only on validation folds. Touch the test set once, and write down what you did to earn the right to touch it. Keep an honest tally of everything you tried, and expect Chapter 19's haircut on whatever survives. And treat a spectacular result as a bug report until you have found the bug or proved there is not one.

Now the limit, stated plainly, because a protocol that promises safety is the most dangerous object in this chapter.

This does not make you safe. The final decision, to deploy this strategy out of everything you looked at, is itself a selection made after seeing all the evidence. No amount of internal hygiene can make that decision out-of-sample, because there is no data left that it did not see. The only truly clean sample is the money.

Which reframes the industry fact we opened with. Live results decaying relative to backtests is not a sign that something went wrong. It is the expected behaviour of an honest process, and the right expectation for a well-built strategy is somewhat below its backtest, not at it.

So be precise about what this chapter buys. It does not buy immunity. It converts an unbounded, invisible, one-directional bias into a bounded and stated one. That is all it does, and that is everything.

And it leaves one door standing open. We watched the out-of-sample curve fall, turn and climb, and we never took the turn apart. Why does the left arm lose? Why does the right arm lose? The moment you decompose that error into bias squared, variance and irreducible noise, overfitting stops being a hazard you avoid and becomes a dial with a knob on it. That is Chapter 22, and the knob is called regularization.

iolinked.com
Written by Ajai Raj