The data is part of the model: splits, gaps and outliers

On this page
  1. Two tools this post uses
  2. The split decides what the score means
  3. Gaps: why a value is missing matters more than that it is
  4. Outliers: the loss function decides how far they pull
  5. The rules
  6. Traps
  7. The code
  8. Summary
  9. References

Before a model learns anything, someone has already made three choices about its data, usually without noticing: which rows it trains on and which it is tested on, what to do when a value is missing, and how far one extreme value is allowed to pull the result. Each of these choices can move the score as much as swapping in a cleverer model. This post makes each one on purpose and measures what it changes, on the series’ problem: predict next month’s volatility (how much a price will swing) from this month’s prices.

First, two tools the rest of the post leans on. Both get their own posts later in the series; this is all you need for now.

a chosen way of scoring or fillingerrorthe outlier

Two tools this post uses

A fitted line

Put each month on a chart as a dot: this month’s volatility along the bottom, next month’s up the side. Wild months tend to be followed by wild months, so the dots drift upwards. A linear regression draws the one straight line that fits them best, and you read a forecast off it: find this month’s volatility on the bottom, go up to the line, read across. How “best” is decided is the subject of the linear regression post; for now Figure 1 is enough.

{"panels":[{"x0":43.64,"x1":565.2,"y0":29.16,"y1":222.74,"title":"1. A fitted line, and how far each month is from it","xlabel":"This month's volatility (% a year)","ylabel":"Next month's volatility (% a year)","xlog":false},{"x0":43.64,"x1":565.2,"y0":311.42,"y1":505.0,"title":"2. Squaring hands the biggest miss most of the total","xlabel":"Month","ylabel":"Share of the total miss (%)","xlog":false}],"series":[{"panel":0,"label":"the fitted line","color":"var(--chart-blue, #3b6ea5)","kind":"line","x":[11.0,11.3061,11.6122,11.9184,12.2245,12.5306,12.8367,13.1429,13.449,13.7551,14.0612,14.3673,14.6735,14.9796,15.2857,15.5918,15.898,16.2041,16.5102,16.8163,17.1224,17.4286,17.7347,18.0408,18.3469,18.6531,18.9592,19.2653,19.5714,19.8776,20.1837,20.4898,20.7959,21.102,21.4082,21.7143,22.0204,22.3265,22.6327,22.9388,23.2449,23.551,23.8571,24.1633,24.4694,24.7755,25.0816,25.3878,25.6939,26.0],"y":[5.2,5.69854,6.19708,6.69563,7.19417,7.69271,8.19125,8.6898,9.18834,9.68688,10.1854,10.684,11.1825,11.681,12.1796,12.6781,13.1767,13.6752,14.1738,14.6723,15.1708,15.6694,16.1679,16.6665,17.165,17.6636,18.1621,18.6606,19.1592,19.6577,20.1563,20.6548,21.1534,21.6519,22.1504,22.649,23.1475,23.6461,24.1446,24.6431,25.1417,25.6402,26.1388,26.6373,27.1359,27.6344,28.1329,28.6315,29.13,29.6286],"sx":[43.64,53.62,63.6,73.58,83.56,93.54,103.52,113.49,123.47,133.45,143.43,153.41,163.39,173.37,183.35,193.32,203.3,213.28,223.26,233.24,243.22,253.2,263.18,273.15,283.13,293.11,303.09,313.07,323.05,333.03,343.01,352.98,362.96,372.94,382.92,392.9,402.88,412.86,422.84,432.81,442.79,452.77,462.75,472.73,482.71,492.69,502.67,512.65,522.62,532.6],"sy":[213.94,210.4,206.86,203.32,199.79,196.25,192.71,189.17,185.64,182.1,178.56,175.02,171.49,167.95,164.41,160.87,157.33,153.8,150.26,146.72,143.18,139.65,136.11,132.57,129.03,125.5,121.96,118.42,114.88,111.35,107.81,104.27,100.73,97.19,93.66,90.12,86.58,83.04,79.51,75.97,72.43,68.89,65.36,61.82,58.28,54.74,51.21,47.67,44.13,40.59]},{"panel":0,"label":"five SPY months","color":"var(--chart-maroon, #b6262c)","kind":"scatter","x":[13.0,16.0,17.0,20.0,24.0],"y":[8.0,8.0,16.0,30.0,21.0],"sx":[108.84,206.63,239.23,337.02,467.41],"sy":[194.07,194.07,137.3,37.96,101.82]}]} 2026-10-31T01:55:14.892481 image/svg+xml Matplotlib v3.11.2, https://matplotlib.org/ 12 14 16 18 20 22 24 26 This month's volatility (% a year) 5 10 15 20 25 30 Next month's volatility (% a year) Jun 2020 miss +10.1 Aug 2020 miss -0.5 Oct 2020 miss -5.4 Dec 2020 miss -5.3 Feb 2021 miss +1.0 1. A fitted line, and how far each month is from it the fitted line a miss: actual minus predicted five SPY months Jun 2020 Aug 2020 Oct 2020 Dec 2020 Feb 2021 Month 0 10 20 30 40 50 60 Share of the total miss (%) 2. Squaring hands the biggest miss most of the total misses counted as they are (MAE) misses squared first (MSE, RMSE)
Figure 1. Five SPY months with the fitted line, and each miss counted plain and squared

The top panel uses five real SPY months. No straight line passes through every dot. The gap between a dot and the line, actual minus predicted, is the miss; statisticians call it the residual. June 2020 came in 10.1 points above the line, August 2020 only half a point below it.

One number for many misses

To compare two models you need one number that sums up all their misses. Three are common, worked in Table 1 on the five misses above (+10.1, −0.5, −5.4, −5.3 and +1.0):

Table 1. Three error scores on the five SPY months
ScoreHow it is computedThe five months
MAE, mean absolute errordrop the signs, average the misses4.47
MSE, mean squared errorsquare each miss, then average32.3
RMSE, root mean squared errorthe square root of the MSE, which brings it back to volatility points5.68

(Computed from the unrounded misses; the rounded ones shown give 4.46, 32.1 and 5.67.)

Read RMSE as “the typical size of a miss”, with one twist: the misses are squared before they are averaged, so big misses count extra. The bottom panel shows how much extra. Counted as they are, June 2020’s miss is 45% of the total; squared, it is 64%, and August’s half point almost disappears. For all three scores, lower is better. The rest of this post scores models by RMSE, and the squaring comes back in the section on outliers.

The split decides what the score means

To score a model you hide some of the data from it, train on the rest, and check its guesses on the hidden part. Doing that once gives one score from one slice of the data. Cross-validation repeats it: cut the data into five parts, called folds, let each part take one turn as the hidden test set while the model trains on the other four, and average the five scores. How you cut the parts matters:

  • Shuffled folds: the months are mixed up at random before cutting, so each part is a random handful of months from all six years.
  • Forward folds: the months stay in time order. Each round trains only on the past and tests on the block that comes next, the way the model would be used for real. This is also called walk-forward testing.

The score is RMSE from the section above: the typical size of a miss, in points of volatility. The same random forest (a model that averages many decision trees, explained later in the series), scored three ways, next to the baseline (Table 2):

Table 2. One random forest scored three ways, with the baseline
How it was scoredRMSEWhat the number means
Train on 2020 to 2023, test once on 2024 to 202512.75one honest number for one period
Cross-validation, shuffled folds12.05each test month had its neighbouring months in training
Cross-validation, forward folds12.63\risk{12.63}each round trains on the past and tests on what came next
Always guess the average of the training months17.69the baseline: a model must beat this

The three numbers are not scored on the same months, so they are not three opinions about one question. The shuffled folds score best, and that is the leak: every test month had its neighbouring months in training, so the forest gets credit for recognising them. They are also the only scoring that tests on 2020, the crash year. The forward folds train on 2020 and never test on it: their first test block starts in January 2021. Their five scores were 13.30, 13.37, 11.98, 10.43 and 14.08. The first fold, trained on 2020 alone, the crash year, was then asked about 2021, a calmer one, and missed by thirteen points; the fourth, testing on late 2023 and 2024, missed by ten; the last, testing on 2025, by fourteen. The single time split, trained on all four years, gives one number for 2024 to 2025. One averaged number hides how much the miss moves from one kind of market to the next.

{"panels":[{"x0":58.76,"x1":556.34,"y0":29.16,"y1":202.55,"title":"1. Three ways to score the same model: the shuffled folds are the only ones tested on the crash year","xlabel":"How the model was scored","ylabel":"RMSE (volatility points)","xlog":false},{"x0":58.76,"x1":556.34,"y0":317.88,"y1":491.28,"title":"2. Forward folds: each fold trains on everything before it and tests on what comes next","xlabel":"Date","ylabel":"Fold","xlog":false},{"x0":58.76,"x1":556.34,"y0":606.61,"y1":780.0,"title":"3. One outlier: the squared-loss line tilts toward it, the absolute-loss line barely moves","xlabel":"x","ylabel":"y","xlog":false}],"series":[{"panel":1,"label":"training rows","color":"var(--chart-blue, #3b6ea5)","kind":"line","x":["2020-01-31","2021-01-08"],"y":[0.0,0.0],"sx":[81.38,156.26],"sy":[483.39,483.39]},{"panel":1,"label":"test rows","color":"var(--chart-maroon, #b6262c)","kind":"line","x":["2021-01-11","2021-12-17"],"y":[0.0,0.0],"sx":[156.92,231.14],"sy":[483.39,483.39]},{"panel":2,"label":"squared loss, with the outlier: slope 0.83","color":"var(--chart-maroon, #b6262c)","kind":"line","x":[0.0,0.25641,0.512821,0.769231,1.02564,1.28205,1.53846,1.79487,2.05128,2.30769,2.5641,2.82051,3.07692,3.33333,3.58974,3.84615,4.10256,4.35897,4.61538,4.87179,5.12821,5.38462,5.64103,5.89744,6.15385,6.41026,6.66667,6.92308,7.17949,7.4359,7.69231,7.94872,8.20513,8.46154,8.71795,8.97436,9.23077,9.48718,9.74359,10.0],"y":[0.918555,1.13207,1.34558,1.5591,1.77261,1.98613,2.19964,2.41316,2.62667,2.84019,3.0537,3.26722,3.48073,3.69425,3.90776,4.12128,4.33479,4.54831,4.76182,4.97534,5.18885,5.40237,5.61588,5.82939,6.04291,6.25642,6.46994,6.68345,6.89697,7.11048,7.324,7.53751,7.75103,7.96454,8.17806,8.39157,8.60509,8.8186,9.03212,9.24563],"sx":[81.38,92.98,104.58,116.18,127.77,139.37,150.97,162.57,174.17,185.77,197.37,208.96,220.56,232.16,243.76,255.36,266.96,278.56,290.16,301.75,313.35,324.95,336.55,348.15,359.75,371.35,382.94,394.54,406.14,417.74,429.34,440.94,452.54,464.13,475.73,487.33,498.93,510.53,522.13,533.73],"sy":[766.73,763.64,760.56,757.47,754.39,751.3,748.22,745.13,742.05,738.96,735.88,732.79,729.71,726.62,723.54,720.45,717.36,714.28,711.19,708.11,705.02,701.94,698.85,695.77,692.68,689.6,686.51,683.43,680.34,677.26,674.17,671.09,668.0,664.92,661.83,658.75,655.66,652.58,649.49,646.41]},{"panel":2,"label":"absolute loss, with the outlier: slope 0.56","color":"var(--chart-blue, #3b6ea5)","kind":"line","x":[0.0,0.25641,0.512821,0.769231,1.02564,1.28205,1.53846,1.79487,2.05128,2.30769,2.5641,2.82051,3.07692,3.33333,3.58974,3.84615,4.10256,4.35897,4.61538,4.87179,5.12821,5.38462,5.64103,5.89744,6.15385,6.41026,6.66667,6.92308,7.17949,7.4359,7.69231,7.94872,8.20513,8.46154,8.71795,8.97436,9.23077,9.48718,9.74359,10.0],"y":[1.76183,1.90585,2.04988,2.1939,2.33793,2.48195,2.62597,2.77,2.91402,3.05804,3.20207,3.34609,3.49011,3.63414,3.77816,3.92218,4.06621,4.21023,4.35425,4.49828,4.6423,4.78633,4.93035,5.07437,5.2184,5.36242,5.50644,5.65047,5.79449,5.93851,6.08254,6.22656,6.37058,6.51461,6.65863,6.80265,6.94668,7.0907,7.23472,7.37875],"sx":[81.38,92.98,104.58,116.18,127.77,139.37,150.97,162.57,174.17,185.77,197.37,208.96,220.56,232.16,243.76,255.36,266.96,278.56,290.16,301.75,313.35,324.95,336.55,348.15,359.75,371.35,382.94,394.54,406.14,417.74,429.34,440.94,452.54,464.13,475.73,487.33,498.93,510.53,522.13,533.73],"sy":[754.54,752.46,750.38,748.3,746.22,744.14,742.06,739.98,737.89,735.81,733.73,731.65,729.57,727.49,725.41,723.33,721.25,719.16,717.08,715.0,712.92,710.84,708.76,706.68,704.6,702.52,700.43,698.35,696.27,694.19,692.11,690.03,687.95,685.87,683.79,681.71,679.62,677.54,675.46,673.38]},{"panel":2,"label":"Huber loss, with the outlier: slope 0.53","color":"var(--chart-amber, #c0781a)","kind":"line","x":[0.0,0.25641,0.512821,0.769231,1.02564,1.28205,1.53846,1.79487,2.05128,2.30769,2.5641,2.82051,3.07692,3.33333,3.58974,3.84615,4.10256,4.35897,4.61538,4.87179,5.12821,5.38462,5.64103,5.89744,6.15385,6.41026,6.66667,6.92308,7.17949,7.4359,7.69231,7.94872,8.20513,8.46154,8.71795,8.97436,9.23077,9.48718,9.74359,10.0],"y":[1.93468,2.0696,2.20451,2.33943,2.47435,2.60927,2.74418,2.8791,3.01402,3.14894,3.28385,3.41877,3.55369,3.68861,3.82352,3.95844,4.09336,4.22828,4.36319,4.49811,4.63303,4.76795,4.90286,5.03778,5.1727,5.30762,5.44254,5.57745,5.71237,5.84729,5.98221,6.11712,6.25204,6.38696,6.52188,6.65679,6.79171,6.92663,7.06155,7.19646],"sx":[81.38,92.98,104.58,116.18,127.77,139.37,150.97,162.57,174.17,185.77,197.37,208.96,220.56,232.16,243.76,255.36,266.96,278.56,290.16,301.75,313.35,324.95,336.55,348.15,359.75,371.35,382.94,394.54,406.14,417.74,429.34,440.94,452.54,464.13,475.73,487.33,498.93,510.53,522.13,533.73],"sy":[752.04,750.1,748.15,746.2,744.25,742.3,740.35,738.4,736.45,734.5,732.55,730.6,728.65,726.7,724.75,722.8,720.85,718.9,716.95,715.0,713.06,711.11,709.16,707.21,705.26,703.31,701.36,699.41,697.46,695.51,693.56,691.61,689.66,687.71,685.76,683.81,681.86,679.91,677.96,676.02]},{"panel":2,"label":"the fit without the outlier: slope 0.50","color":"var(--chart-grey, #a3a19c)","kind":"line","x":[0.0,0.25641,0.512821,0.769231,1.02564,1.28205,1.53846,1.79487,2.05128,2.30769,2.5641,2.82051,3.07692,3.33333,3.58974,3.84615,4.10256,4.35897,4.61538,4.87179,5.12821,5.38462,5.64103,5.89744,6.15385,6.41026,6.66667,6.92308,7.17949,7.4359,7.69231,7.94872,8.20513,8.46154,8.71795,8.97436,9.23077,9.48718,9.74359,10.0],"y":[1.99418,2.12278,2.25138,2.37997,2.50857,2.63717,2.76576,2.89436,3.02296,3.15155,3.28015,3.40875,3.53734,3.66594,3.79454,3.92313,4.05173,4.18033,4.30892,4.43752,4.56612,4.69471,4.82331,4.95191,5.0805,5.2091,5.3377,5.4663,5.59489,5.72349,5.85209,5.98068,6.10928,6.23788,6.36647,6.49507,6.62367,6.75226,6.88086,7.00946],"sx":[81.38,92.98,104.58,116.18,127.77,139.37,150.97,162.57,174.17,185.77,197.37,208.96,220.56,232.16,243.76,255.36,266.96,278.56,290.16,301.75,313.35,324.95,336.55,348.15,359.75,371.35,382.94,394.54,406.14,417.74,429.34,440.94,452.54,464.13,475.73,487.33,498.93,510.53,522.13,533.73],"sy":[751.19,749.33,747.47,745.61,743.75,741.89,740.04,738.18,736.32,734.46,732.6,730.75,728.89,727.03,725.17,723.31,721.45,719.6,717.74,715.88,714.02,712.16,710.31,708.45,706.59,704.73,702.87,701.02,699.16,697.3,695.44,693.58,691.72,689.87,688.01,686.15,684.29,682.43,680.58,678.72]},{"panel":2,"label":"40 points, one of them wrong by 25","color":"var(--chart-grey, #a3a19c)","kind":"scatter","x":[0.0,0.25641,0.512821,0.769231,1.02564,1.28205,1.53846,1.79487,2.05128,2.30769,2.5641,2.82051,3.07692,3.33333,3.58974,3.84615,4.10256,4.35897,4.61538,4.87179,5.12821,5.38462,5.64103,5.89744,6.15385,6.41026,6.66667,6.92308,7.17949,7.4359,7.69231,7.94872,8.20513,8.46154,8.71795,8.97436,9.23077,9.48718,9.74359,10.0],"y":[2.09453,1.86683,2.04988,1.16388,3.41267,3.21311,2.60652,3.28434,3.16625,2.87693,3.77084,3.25498,3.37405,3.27059,4.02235,3.87348,4.32393,3.87589,4.37111,3.98976,4.98484,4.78633,4.9858,5.15397,4.57154,5.59672,6.36168,4.64232,4.72504,4.96553,6.26688,6.03872,6.64174,6.59198,6.46426,6.6292,6.5305,7.17782,6.30694,30.0],"sx":[81.38,92.98,104.58,116.18,127.77,139.37,150.97,162.57,174.17,185.77,197.37,208.96,220.56,232.16,243.76,255.36,266.96,278.56,290.16,301.75,313.35,324.95,336.55,348.15,359.75,371.35,382.94,394.54,406.14,417.74,429.34,440.94,452.54,464.13,475.73,487.33,498.93,510.53,522.13,533.73],"sy":[749.74,753.03,750.38,763.18,730.69,733.57,742.34,732.54,734.25,738.43,725.51,732.97,731.25,732.74,721.88,724.03,717.52,724.0,716.84,722.35,707.97,710.84,707.96,705.53,713.94,699.13,688.08,712.92,711.73,708.25,689.45,692.74,684.03,684.75,686.6,684.21,685.64,676.28,688.87,346.52]}]} 2026-10-31T01:55:14.006048 image/svg+xml Matplotlib v3.11.2, https://matplotlib.org/ time split (fit 2020-23, score 2024-25) 5 shuffled folds (leaks) 5 forward folds baseline: predict the mean How the model was scored 0.0 2.5 5.0 7.5 10.0 12.5 15.0 17.5 RMSE (volatility points) 12.75 12.05 12.63 17.69 1. Three ways to score the same model: the shuffled folds are the only ones tested on the crash year 2020 2021 2022 2023 2024 2025 2026 Date fold 1 fold 2 fold 3 fold 4 fold 5 Fold 2. Forward folds: each fold trains on everything before it and tests on what comes next training rows test rows 0 2 4 6 8 10 x 0 2 4 6 8 10 12 y 3. One outlier: the squared-loss line tilts toward it, the absolute-loss line barely moves 40 points, one of them wrong by 25 squared loss, with the outlier: slope 0.83 absolute loss, with the outlier: slope 0.56 Huber loss, with the outlier: slope 0.53 the fit without the outlier: slope 0.50
Figure 2. Scores by split, the five forward folds, and one outlier under three losses

For a time series, the test must be later than the training. Each row’s volatility is measured over a window of about a month, so two rows a day apart are built from almost the same days: near-twins. A random split puts a near-twin of every test row into the training set, and the model gets credit for recognising it, which it could never do live. Forward folds are the version of cross-validation that respects time; the second panel of Figure 2 draws the five used here. The scikit-learn post showed the same effect on a yes/no model, scored by AUC (0.5 is a coin flip, 1 is perfect): shuffled folds promised 0.929, forward folds 0.899.

A second rule follows. Every step that learns from the data is part of training: the scaler (which puts features on the same scale, using their average and spread), the imputer (which fills gaps, often with an average), the choice of which features to keep, and the choice of settings. Each of them learns numbers from the rows it sees. Let them see only the training part of each fold, never the whole table, or the test rows leak into training.

Gaps: why a value is missing matters more than that it is

A value can be missing for three kinds of reason, and the reason decides what you may do (Table 3):

Table 3. Three kinds of missing value and whether the reason can be ignored
KindPlain meaningMarket exampleCan you ignore why it is missing?
Missing completely at randompure accidenta data feed glitchyes: the gap tells you nothing
Missing at randomthe reason is visible in another columnsmall companies report less oftenyes, if the model also sees the column that explains it (company size)
Missing because of the valuethe gap is caused by what the value would have beenno volume because nothing traded; no earnings because bankruptno: the gap itself carries information, so give it to the model

Real run, the third kind. I removed the 20-day volatility from 12.6% of the training rows, with the removal more likely when the volatility was high, the way a stock goes missing in real data when trading in it is stopped or thin. Then three repairs, scored in Table 4:

Table 4. Test RMSE after three repairs for gaps in 12.6% of the training rows
RepairTest RMSE
Drop the rows with a gap12.62
Fill the gap with the column’s mean12.86
Fill with the mean and add a “was missing” column12.83\key{12.83}
Nothing missing, for reference12.75

The three repairs land within a quarter of a point of each other, and dropping the rows even came in 0.13 below the run with nothing missing. Differences that small do not rank the repairs: on this window the gaps cost nothing the score can see, and the flag bought 0.03 over plain filling. The flag is still the right habit, because it costs nothing and is the only repair that keeps the fact of the gap. For prices the standing rule is different and simpler: fill forward, so a day with no trading repeats the last known price, and fill backward only where nothing came before.

Outliers: the loss function decides how far they pull

The line in Figure 1 is the one whose misses add up to the smallest total. The loss is the rule for counting each miss in that total. Ordinary linear regression squares them, exactly like the MSE, and that choice decides how hard one far-out point can pull. Three misses of 1, 1 and 10 (Table 5):

Table 5. Share of the total loss taken by one miss of 10, squared and absolute
LossTotalThe outlier’s share
Squared, used by ordinary least squares1 + 1 + 100 = 10298%
Absolute1 + 1 + 10 = 1283%

Squaring is why one big miss dominates, the same effect as June 2020 in Figure 1. Real run. Forty points around a line of slope 0.5, then the last point moved about 23 units above the line (Table 6):

Table 6. Slope of three fits before and after one outlier
FitSlope, cleanSlope, with the outlier
Squared loss (ordinary least squares)0.500.83\bad{0.83}
Absolute loss (median regression)0.510.56
Huber loss (squared when small, absolute when large)0.510.53

One point in forty moved the least-squares slope by two thirds. The third panel of Figure 2 draws it. Where the point sits matters: this one is at the edge of the range (x = 10), where it can tilt the line. The same miss in the middle (x = 5) leaves the slope at 0.51 and only lifts the line. A point at the edge like this is called a high-leverage point. A crash day can be exactly that, an extreme value at the edge of everything the model has seen, and squared-error models, which are most of them, feel it most. The choices are to use a robust loss, to use a model where only the side of a split matters (a tree), or to keep the squared loss and know what it is doing. Deleting the point is not on the list: a crash is real data, and a model that has never seen one will meet one live.

The rules

  1. Split by time; test later than training; walk forward for cross-validation.
  2. Fit scalers, imputers and feature selection inside the training part of each fold.
  3. Ask why each value is missing before deciding what to do about it; keep a missingness flag.
  4. Know your loss; squared error chases outliers.
  5. Never delete a crash.

Traps

Things people say about splits, gaps and outliers, and what is true (Table 7):

Table 7. Traps about splits, gaps and outliers
TrapTruth
A bigger sample fixes a biased sampleMore rows of the wrong kind are still wrong in the same direction
Mean-filling is neutralIt shrinks the spread and hides the signal
Trees suffer most from outliersLeast squares does; a tree only sees which side
Normalising with the full table’s mean is harmlessIt leaks the test set into training
Walk-forward is unfair because it reuses the pastThe test is always later; that is the point
Shuffled folds are fine if the model is simpleThey leak regardless of the model

The code

All three ideas on made-up data, so it runs as pasted (pip install numpy scikit-learn). Part 1 builds a daily series whose volatility drifts slowly, like real volatility, and scores one random forest with shuffled and with forward folds. X holds the one feature, last 20 days’ volatility, one row per day; y holds the answer for each row, the next 20 days’ volatility.

import numpy as np
from sklearn.ensemble import RandomForestRegressor
from sklearn.linear_model import HuberRegressor, LinearRegression, QuantileRegressor
from sklearn.model_selection import KFold, TimeSeriesSplit, cross_val_score

# 1. Shuffled folds against forward folds --------------------------------------
# Made-up daily returns whose volatility drifts slowly, as real volatility does.
rng = np.random.default_rng(0)
n = 1500                                                  # about six years of trading days
vol = np.exp(np.cumsum(rng.normal(0, 0.05, n)))           # daily volatility, wandering over time
returns = rng.normal(0, vol)                              # one return per day
past_20d = np.array([returns[t - 20:t].std() for t in range(20, n - 20)])
next_20d = np.array([returns[t:t + 20].std() for t in range(20, n - 20)])

X = past_20d.reshape(-1, 1)   # one feature: volatility over the last 20 days
y = next_20d                  # the answer: volatility over the next 20 days
# Rows a day apart share 19 of their 20 days, so neighbouring rows are near-twins.

rf = RandomForestRegressor(200, min_samples_leaf=5, random_state=0)
for name, cv in [("shuffled folds", KFold(5, shuffle=True, random_state=0)),
                 ("forward folds ", TimeSeriesSplit(5))]:
    scores = -cross_val_score(rf, X, y, cv=cv, scoring="neg_root_mean_squared_error")
    print(f"{name}: RMSE per fold {np.round(scores, 3)}, average {scores.mean():.3f}")

# 2. A missing value: fill it, and keep the fact that it was missing ------------
x = np.array([12.0, np.nan, 15.0, np.nan, 11.0])          # a feature with two gaps
was_missing = np.isnan(x)                                 # [False True False True False]
x_filled = np.where(was_missing, np.nanmean(x), x)        # nanmean: the average, ignoring gaps
print(np.column_stack([x_filled, was_missing]))           # two columns go to the model

# 3. One outlier against three losses --------------------------------------------
rng = np.random.default_rng(2)
xs = np.linspace(0, 10, 40)
ys = 2 + 0.5 * xs + rng.normal(0, 0.5, 40)                # 40 points around a line of slope 0.5
ys_out = ys.copy()
ys_out[-1] = 30.0                                         # move the last point far above the line
for name, model in [("squared (least squares)", LinearRegression()),
                    ("absolute (median line) ", QuantileRegressor(quantile=0.5, alpha=0, solver="highs")),
                    ("Huber                  ", HuberRegressor())]:
    clean = model.fit(xs.reshape(-1, 1), ys).coef_[0]
    dirty = model.fit(xs.reshape(-1, 1), ys_out).coef_[0]
    print(f"{name}: slope {clean:.2f} clean, {dirty:.2f} with the outlier")

It prints, in order:

  • Folds: shuffled folds average about 0.14, forward folds about 0.20, with one forward round much worse than the rest. That is the same pattern as Table 2, on different numbers.
  • Missing value: the filled column (the gaps became the average, 12.67) next to the “was missing” column (1 where there was a gap).
  • Outlier: the slopes from Table 6 exactly: 0.50 and 0.83, 0.51 and 0.56, 0.51 and 0.53.
The script behind the real numbers

The tables above come from examples/data-is-part-of-the-model/sampling.py, which runs the same steps on the blog’s five tickers; the two-tools chart comes from examples/data-is-part-of-the-model/two_tools.py, which needs only the five months typed into it. To run it yourself, clone the examples repo: its README has a three-command Docker setup, or run uv run on it.

Summary

Table 8 is the post in five rows.

Table 8. The post’s five ideas, each with its rule
IdeaRuleRemember it as
Time splittest later than trainingno near-twins across the line
Walk-forwardtrain before, test after, slidefive numbers, not one
Preprocessingfit inside the training foldthe scaler is a model too
Missing valuesask why; keep a flagthe gap can be the signal
Loss and outlierssquared error chases them98% of the loss from one point

References

Tagged machine-learning, finance, volatility, statistics← Home