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

ml basics

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

Before any model is fitted, four decisions have been made about the data, usually without noticing. This post makes each one on purpose and measures what it does, on the series’ problem of predicting next month’s volatility from this month’s prices.

a chosen way of scoring or fillingerrorthe outlier

The split decides what the score means

The same random forest, scored three ways:

How it was scoredRMSEWhat the number means
Fit on 2019 to 2021, score on 2022 to 20239.80one honest number for one period
Five shuffled folds, averaged9.93each fold tests on months whose neighbours were in training
Five forward folds, averaged13.53\risk{13.53}each fold trains on the past and tests on what came next
Predict the training mean9.84the baseline

The forward folds look worst, and they are the ones telling the truth. Their five scores were 29.66, 11.25, 9.30, 9.98 and 7.45. The first fold trained on 2019 alone and was then asked about 2020, the crash year, and missed by thirty points. The single time split never saw that, because its training set already contained 2020. The shuffled folds never saw it either, because every fold had crash months on both sides. One averaged number hides a regime the model had never met.

{"panels":[{"x0":58.76,"x1":565.2,"y0":29.16,"y1":202.55,"title":"1. Three ways to score the same model: only the forward folds meet the crash year","xlabel":"How the model was scored","ylabel":"RMSE (volatility points)","xlog":false},{"x0":58.76,"x1":565.2,"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":565.2,"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)","x":["2019-01-31","2019-11-18"],"y":[0.0,0.0],"sx":[81.78,157.73],"sy":[483.39,483.39]},{"panel":1,"label":"test rows","color":"var(--chart-maroon, #b6262c)","x":["2019-11-19","2020-09-09"],"y":[0.0,0.0],"sx":[157.99,234.99],"sy":[483.39,483.39]},{"panel":2,"label":"squared loss, with the outlier: slope 0.83","color":"var(--chart-maroon, #b6262c)","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.78,93.59,105.39,117.2,129.0,140.81,152.61,164.42,176.22,188.03,199.83,211.64,223.44,235.25,247.05,258.86,270.66,282.47,294.27,306.08,317.88,329.69,341.49,353.3,365.1,376.91,388.71,400.52,412.32,424.13,435.93,447.74,459.54,471.35,483.15,494.96,506.76,518.57,530.38,542.18],"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)","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.78,93.59,105.39,117.2,129.0,140.81,152.61,164.42,176.22,188.03,199.83,211.64,223.44,235.25,247.05,258.86,270.66,282.47,294.27,306.08,317.88,329.69,341.49,353.3,365.1,376.91,388.71,400.52,412.32,424.13,435.93,447.74,459.54,471.35,483.15,494.96,506.76,518.57,530.38,542.18],"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)","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.78,93.59,105.39,117.2,129.0,140.81,152.61,164.42,176.22,188.03,199.83,211.64,223.44,235.25,247.05,258.86,270.66,282.47,294.27,306.08,317.88,329.69,341.49,353.3,365.1,376.91,388.71,400.52,412.32,424.13,435.93,447.74,459.54,471.35,483.15,494.96,506.76,518.57,530.38,542.18],"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)","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.78,93.59,105.39,117.2,129.0,140.81,152.61,164.42,176.22,188.03,199.83,211.64,223.44,235.25,247.05,258.86,270.66,282.47,294.27,306.08,317.88,329.69,341.49,353.3,365.1,376.91,388.71,400.52,412.32,424.13,435.93,447.74,459.54,471.35,483.15,494.96,506.76,518.57,530.38,542.18],"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)","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.78,93.59,105.39,117.2,129.0,140.81,152.61,164.42,176.22,188.03,199.83,211.64,223.44,235.25,247.05,258.86,270.66,282.47,294.27,306.08,317.88,329.69,341.49,353.3,365.1,376.91,388.71,400.52,412.32,424.13,435.93,447.74,459.54,471.35,483.15,494.96,506.76,518.57,530.38,542.18],"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-16T00:09:36.290761 image/svg+xml Matplotlib v3.11.2, https://matplotlib.org/ time split (fit 2019-21, score 2022-23) 5 shuffled folds (leaks) 5 forward folds baseline: predict the mean How the model was scored 0 2 4 6 8 10 12 14 RMSE (volatility points) 9.80 9.93 13.53 9.84 1. Three ways to score the same model: only the forward folds meet the crash year 2019 2020 2021 2022 2023 2024 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

For a time series, the test must be later than the training. Consecutive days share most of a month, so a random split puts near-twins of every test month into the training set and the model gets credit for recognising them. Walk-forward folds (train on everything before a point, test on the block after it, slide the point) are the version of cross-validation that respects time; the second panel of the chart draws the five used here. The scikit-learn post showed the same effect on a classifier: shuffled folds gave an AUC of 0.912 and forward folds 0.734.

A second rule follows. Every step that learns from the data is part of training: the scaler, the imputer, the feature selection, the choice of settings. Fit them inside the training part of each fold, never on the whole table.

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

Three kinds of missing, with three different right answers:

KindPlain meaningMarket exampleDelete the rows?
Missing completely at randompure accidentan API glitchyes; only the sample shrinks
Missing at randomexplained by other columnssmall companies report less oftenwith care, conditioning on those columns
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 is the signal

Real run. I removed the 20-day volatility from 11.5% of the training rows, with the removal more likely when the volatility was high, the way a halted or illiquid stock goes missing in real data. Then three repairs:

RepairTest RMSE
Drop the rows with a gap10.67
Fill the gap with the column’s mean10.31
Fill with the mean and add a “was missing” column10.25\key{10.25}
Nothing missing, for reference9.80

Dropping was worst, because it threw away the most volatile months, exactly the ones the model needed to learn from. Mean-filling put a calm number into a wild month. The flag let the model learn that “missing” itself means “probably volatile”. For prices the standing rule is different and simpler: fill forward, so a halt repeats the last known price, and fill backward only where nothing came before.

Outliers: the loss function decides how far they pull

A fit minimises a loss, and the loss decides how much one far-out point matters. Three residuals of 1, 1 and 10:

LossTotalThe outlier’s share
Squared, used by ordinary least squares1 + 1 + 100 = 10298%
Absolute1 + 1 + 10 = 1283%

Squaring is why one big miss dominates. Real run. Forty points on a line of slope 0.5, then one point moved 25 units off the line:

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 the chart draws it. A crash day in a return series does the same to any model fitted by squared error, which is most of them. 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

TrapTruth
A bigger sample fixes a biased sampleBias is systematic, not random
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

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

rf = RandomForestRegressor(300, min_samples_leaf=5, random_state=0)
cross_val_score(rf, X, y, cv=KFold(5, shuffle=True), scoring="neg_root_mean_squared_error")   # leaks
cross_val_score(rf, X, y, cv=TimeSeriesSplit(5), scoring="neg_root_mean_squared_error")      # honest

import numpy as np
missing = np.isnan(X[:, 4])
X[missing, 4] = np.nanmean(X[:, 4])                      # fill with the training mean
X = np.hstack([X, missing[:, None].astype(float)])       # and keep the fact that it was missing

LinearRegression().fit(x, y)                             # squared loss
QuantileRegressor(quantile=0.5, alpha=0).fit(x, y)       # absolute loss: the median line
HuberRegressor().fit(x, y)                               # squared when close, absolute when far

Summary

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

Next

Regression and classification: the two questions most models answer, and how the question decides the model and the metric.

References