Machine learning for quantitative finance in ten ideas

On this page
  1. The target decides the problem
  2. The data is part of the model
  3. Every feature has a timestamp
  4. A linear model is a weighted sum
  5. Nearest neighbours store everything
  6. A tree asks one question at a time
  7. Averaging helps only when the errors differ
  8. A score needs a split, a metric and a baseline
  9. The gap between seen and unseen is the whole game
  10. Model choice is a systems decision
  11. The formula sheet
  12. The arithmetic to be fluent in
  13. The script

Ten ideas, in the order each one needs the one before it. Same shape every time: the idea in one sentence, the formula with worked numbers, a real run where one helps, the trap, and where the idea shows up again. The real runs use the blog’s shared problem: predict next month’s volatility of a ticker from seven features computed from its own past prices (recent returns, recent volatility, distance from the moving average), five tickers, 3,685 training rows from 2019 to 2021 and 2,405 test rows from 2022 to 2023, all with scikit-learn. The full script is at the end of the post.

the quantity being estimated or chosenerror, riska bad outcomethe actual observed value

The target decides the problem

Full post: What is machine learning?.

Every target is a learnable rule plus noise nobody can predict:

Y=f(X)+ε\good{Y} = f(X) + \risk{\varepsilon}

What happens equals the pattern you can learn plus the luck you cannot. In finance ff is small and ε\risk{\varepsilon} is big, so good models still look weak. The same rows can feed regression (the answer is a number), classification (the answer is a category) or clustering (no answer given); the target, not the rows, decides which. A classifier first estimates a probability, and the hard label is a second choice based on costs.

Expected error splits into three parts:

Error=Bias2+Variance+Noise\risk{\text{Error}} = \text{Bias}^2 + \text{Variance} + \text{Noise}

Bias is aiming at the wrong spot every time (the model is too simple). Variance is a shaky hand (the model changes with each training set). Noise is the floor; only new kinds of data lower it.

Misses 1, 1, 1, 5WorkingResult
MSE(1 + 1 + 1 + 25)/47
MAE8/42

One big miss dominates the squared error. Eager learners (linear, tree) do the work at training time and can throw the data away; lazy learners (nearest neighbours) do nothing at training time and all the work at prediction time.

Real run. Next month’s volatility on the test months averages 22.8 points with a spread of 9.7. Predicting the training mean for every row scores a test RMSE of 9.84\risk{9.84}. That number is the bar every model below has to clear, and some of them do not.

TrapTruth
More data fixes high biasMore data fixes variance
A train-test gap is biasThe gap is variance; both high is bias
Faster training means faster predictionIndependent
The lowest error is the best modelWeigh stability, latency, cost, interpretability

The data is part of the model

Full post: The data is part of the model.

Which rows you keep, fill, order or reuse changes results as much as the algorithm does.

The split here is in time: train on 2019 to 2021, test on 2022 to 2023, never shuffled. Consecutive days share most of a month, so a random split would put near-twins of every test row into the training set and flatter every model. For a time series the test must be later than the training, and a walk-forward loop (fit on a window, test on the next, slide) is the gold standard.

Why data goes missing matters: missing completely at random (an API glitch) only shrinks the sample; missing at random (small companies report less often) needs care; missing because of the value (no volume because nothing traded, no earnings because bankrupt) is the signal itself and must not be deleted or mean-filled. The price rule: fill forward, then fill backward only where nothing came before. Keep a missingness indicator column.

Residuals 1, 1, 10TotalThe outlier’s share
Squared loss1 + 1 + 100 = 10298%
Absolute loss1283%

Squared error chases outliers; absolute loss, Huber loss or a tree (only the side of a split matters) resist them. Every data-dependent step is training: scaling, filling, feature selection, choosing settings. Fit them on the training part only.

TrapTruth
A bigger sample fixes survivorshipIt is systematic, not random
Mean fill is neutralShrinks variance, hides the signal
Normalising with the full-sample mean is fineA look-ahead leak
Walk-forward leaks because it reuses the pastThe test is always later

Every feature has a timestamp

Full post: Every feature has a timestamp.

For every feature ask one question: when did this value become knowable, and could I compute it live? Leakage is a feature that secretly contains the answer, through time (it uses the future) or through pooling (a statistic computed over train and test together).

Real run, a leak on purpose. The seven honest features are all computed from prices up to the day of the forecast. I added an eighth: SPY’s volatility over the same month the model is trying to predict. It is not knowable in advance, and it would be an easy mistake to make when joining a market-wide table onto a per-ticker one.

FeaturesLinear model, test RMSE
Seven honest features7.50
Plus SPY’s same-month volatility6.81\bad{6.81}

The leak looks like a better model. Nothing in the score says otherwise. The first defence is to lag every input to the previous close; the second is to rerun with the last few days removed and see whether earlier predictions change.

Scaling changes influence, not information. Distance-based models need it; trees do not, because only order matters. Correlated twin features make linear coefficients swing (their sum stays stable) and split a forest’s importance between them.

ItemRidge (L2)Lasso (L1)
Penalty∑β2\sum \beta^2∑∣β∣\sum \lvert\beta\rvert
Effectshrinks all, never to zeropushes some exactly to zero
Twinsshares the weightpicks one arbitrarily
Use whenmany small effectsfew real effects

Rolling z-score, worked: window mean 100, σ 2, price 106 gives z=3z = 3; in a crash the window σ jumps to 10 and the same six-point gap reads z=0.6z = 0.6. The z-score hides the crash.

TrapTruth
Leakage is only about timePooling leaks too
Future returns as labels are leakageThat is the target; leakage is about inputs
Trees need scalingNo
Fewer features lowers biasRaises bias, lowers variance

A linear model is a weighted sum

Full post: Linear regression.

y^=β0+β1x1+β2x2+⋯\key{\hat{y}} = \key{\beta_0} + \key{\beta_1} x_1 + \key{\beta_2} x_2 + \cdots

Start from a base number and add a fixed amount per unit of each feature, like a taxi fare. Worked: y^=0.001+0.5×momentum−0.2×vol\hat{y} = 0.001 + 0.5 \times \text{momentum} - 0.2 \times \text{vol} with momentum 0.02 and vol 0.01 gives 0.001+0.010−0.002=0.0090.001 + 0.010 - 0.002 = 0.009. Each β\beta is the change in prediction per unit of its feature holding the others fixed; only standardised coefficients can be compared by size.

Fitting is least squares: append a column of ones so the solve finds an intercept, then np.linalg.lstsq, or LinearRegression() in scikit-learn, which does the same.

Real run, when a line wins. A made-up dataset: 1,000 rows, three features uniform on −1 to 1, y=50x1+30x2−20x3+small noisey = 50x_1 + 30x_2 - 20x_3 + \text{small noise}. Out of sample:

LearnerRMSE on the linear surface
Linear regression0.06\key{0.06}
Regression tree, fully grown7.79

A tree cannot draw a slope. It builds a staircase of constants and cannot extrapolate past the training range, so on a smooth linear surface it is a hundred times worse. Polynomial terms make curves but the model is still linear in β\beta. Logistic regression squashes the weighted sum into a probability; its boundary is still a straight line. Read the residuals: a U-shape means a missed curve, runs of the same sign over time mean serial correlation, a widening fan means uneven noise.

R2=1−RSS/TSSR^2 = 1 - \text{RSS}/\text{TSS} is “how much better than predicting the mean”; in finance a value well below 0.1 can be real. A coefficient’s t=β^/SEt = \hat\beta / \text{SE} with ∣t∣≳2|t| \gtrsim 2 is the usual bar.

TrapTruth
Bigger coefficient means more importantOnly after standardising
Normal errors are requiredOnly for small-sample inference
Polynomial regression is nonlinearLinear in the parameters
Regularisation adds flexibilityIt constrains

Nearest neighbours store everything

Full post: Nearest neighbours.

Predict what similar past cases did: keep all the data, and at query time find the kk closest rows and average them (regression) or vote (classification).

Itemk=1k = 1medium kkk=Nk = N
Predictionthe nearest point’s own valuea local averagethe global mean, for every query
Training error0somehighest
Bias / variancelow / highestbalancedhighest / zero
Verdictoverfitspick on validationunderfits

The dial runs backwards: raising kk makes the model simpler, the opposite of tree depth or polynomial degree. Effective capacity is about N/kN/k.

CaseWorkingResult
Euclidean distance (1, 2) to (4, 6)√(9 + 16)5; Manhattan 7
k=3k = 3, neighbours’ returns 1%, 2%, 3%2%
k=5k = 5 votes up, up, down, up, downP(up) = 0.6
Weights by 1/d1/d, distances 1 and 31 and 0.33, normalised0.75 and 0.25
Box side to capture 10% of points in pp dimensions0.11/p0.1^{1/p}p=1p = 1: 0.10; p=10p = 10: 0.79; p=100p = 100: 0.98

The last row is the curse of dimensionality: in many dimensions everything is far from everything, and a “neighbourhood” is almost the whole space. Scaling fixes units, not this; drop irrelevant features or weight each one. Cost: training is free, each prediction scans N×pN \times p numbers. Daily allocation on 7,500 rows is trivial and explainable (“today looks like these five dates”); a high-frequency book at 500,000 rows per tick is not.

TrapTruth
Larger kk overfits moreSmaller kk does
k=Nk = N predicts each point’s own labelThe global mean
KNN is fast because training is cheapPrediction is the cost

A tree asks one question at a time

Full post: Decision trees.

A tree is a chain of yes/no questions, one feature and one threshold each, ending in a leaf that predicts a constant. It grows greedily: at each node try every feature and threshold, keep the best split, never look back.

A regression tree needs three decisions, and the classic recipe makes them like this:

DecisionRuleWhat it means
Which feature to ask aboutthe one whose split most reduces squared error (a cheaper recipe: the one most correlated with the target)the question that explains the most
Where to cut itthe threshold with the lowest error on either side (a cheaper recipe: the median)half the rows go each way, so the tree stays balanced
When to stopwhen a node holds at most min_samples_leaf rows, or every target in it is identicalthe leaf then predicts the mean of its rows

Growing is recursion: cut, then grow a tree on each side. Prediction walks from the root, answering one question per level, until it reaches a leaf.

Real run, when a tree wins. The same made-up setup, but yy is a sum of three step functions:

y=8[x1>0]+5[x2>0]+3[x3>0]+0.5x3y = 8[x_1 > 0] + 5[x_2 > 0] + 3[x_3 > 0] + 0.5x_3

Out of sample:

LearnerRMSE on the three steps
Linear regression2.52
Regression tree, fully grown0.21\key{0.21}

Three thresholds are three questions. A line has to average across each step and misses on both sides of it. Trees capture thresholds and interactions without hand-made features, need no scaling, and are unstable: a small change in the data can flip the root split and everything below, which is the reason to bag them.

Impurity, for classification trees, measures how mixed a node is:

Node mixGini 1−∑p21 - \sum p^2Entropy −∑pln⁡p-\sum p \ln p
100 / 000
30 / 700.420.611 (0.881 bits)
50 / 500.50.693 (1 bit)

Worked regression split: targets {2, 4, 9, 11}. Cut A into {2, 4} and {9, 11}: means 3 and 10, squared error 2 + 2 = 4. Cut B into {2, 9} and {4, 11}: means 5.5 and 7.5, error 24.5 + 24.5 = 49. Cut A wins. Gini gain: a 50/50 parent (0.5) split into two 80/20 halves (0.32 each) gains 0.18.

SettingControlsWatch out
leaf sizethe minimum rows in a leaf, the main variance knobtoo large hides real patterns
max depthlevels of questions, at most 2d2^d leavesone feature can be split many times
min samples to splitthe smallest node that may try a splitalone, it still allows a 99/1 split
TrapTruth
A tree averages over all pathsOne path
Log-transforming a feature changes a treeSame splits
Trees extrapolate trendsA constant outside the training range
Deeper is more stableLess stable

Averaging helps only when the errors differ

Full post: Bagging.

Bagging trains many copies of a model, each on a bootstrap sample (draw NN rows with replacement), and averages them. Chance a row is never drawn: (1−1/N)N→1/e≈37%(1 - 1/N)^N \to 1/e \approx 37\%, so each copy sees about 63% of distinct rows and the rest are out-of-bag, a free validation set.

Var(average)=ρ σ2+(1−ρ) σ2B\text{Var}(\text{average}) = \key{\rho}\,\risk{\sigma^2} + \frac{(1 - \key{\rho})\,\risk{\sigma^2}}{B}

Averaging shrinks only the part of the errors that differs from copy to copy. The shared part ρσ2\key{\rho}\risk{\sigma^2} stays however many copies you add, like polling a hundred people who all read the same newspaper. Random forests attack ρ\key{\rho} by letting each split choose from a random subset of features; boosting trains copies one after another on the errors so far and cuts bias instead.

Caseσ²ρBVariance
Baseline10.51000.5 + 0.005 = 0.505
100 × more trees10.510,0000.50005
Decorrelate instead10.11000.1 + 0.009 = 0.109

Real run, bags of fully grown trees. BaggingRegressor around a tree with one row per leaf, test RMSE on the 2022 to 2023 months:

Trees averaged15102050100
Test RMSE15.8711.9311.2410.6110.1910.23

The first twenty trees take a third off the error; the next eighty take nothing. That levelling is the ρσ2\key{\rho}\risk{\sigma^2} floor. Twenty bagged trees also flatten the leaf-size curve: a single tree’s test error runs from 15.7 down to 8.4 across leaf sizes, the bagged version from 10.6 to 8.1.

Real run, random splits. splitter="random" picks the cut point at random instead of searching for the best one. On this noisy target it trained three to seven times faster and, for leaf sizes above a handful, scored better than the searching tree (10.24 against 12.01 at leaf 10, 9.22 against 10.25 at leaf 30). The search was finding cuts that fit the training noise. Twenty bagged random trees with one row per leaf scored 9.80, against 10.61 for the searching kind. A random tree is weaker in principle and cheaper in practice, and on noisy data the “weakness” is a form of regularisation; that is the trade random forests and extra-trees exploit.

{"panels":[{"x0":43.64,"x1":565.2,"y0":29.16,"y1":216.88,"title":"1. One regression tree: error on seen and unseen months as leaves grow","xlabel":"Leaf size (minimum rows in a leaf, log scale)","ylabel":"RMSE (volatility points)","xlog":true},{"x0":43.64,"x1":565.2,"y0":307.36,"y1":495.08,"title":"2. Twenty bagged trees: the test curve goes flat","xlabel":"Leaf size (minimum rows in a leaf, log scale)","ylabel":"RMSE (volatility points)","xlog":true},{"x0":43.64,"x1":565.2,"y0":585.56,"y1":773.28,"title":"3. Random splits against chosen splits: error on the test months","xlabel":"Leaf size (minimum rows in a leaf, log scale)","ylabel":"RMSE (volatility points)","xlog":true},{"x0":43.64,"x1":565.2,"y0":863.76,"y1":1051.48,"title":"4. Time to build the tree","xlabel":"Leaf size (minimum rows in a leaf, log scale)","ylabel":"Training time (milliseconds)","xlog":true}],"series":[{"panel":0,"label":"training months (seen)","color":"var(--chart-blue, #3b6ea5)","x":[1.0,2.0,3.0,4.0,5.0,6.0,7.0,8.0,9.0,10.0,11.0,12.0,13.0,14.0,15.0,16.0,17.0,18.0,19.0,20.0,21.0,22.0,23.0,24.0,25.0,26.0,27.0,28.0,29.0,30.0,40.0,50.0,75.0,100.0,150.0,200.0],"y":[0.0,3.87542,5.73586,6.91228,7.55064,8.08546,8.36332,8.7712,8.95996,9.22664,9.43976,9.62729,9.8243,9.94799,10.0333,10.1806,10.3034,10.3377,10.3259,10.3646,10.4687,10.5072,10.5362,10.6145,10.6156,10.6613,10.747,10.7731,10.7816,11.0485,11.271,11.4597,11.969,12.076,12.8777,12.9816],"sx":[67.35,129.38,165.66,191.41,211.38,227.69,241.49,253.44,263.98,273.41,281.94,289.72,296.89,303.52,309.69,315.47,320.89,326.01,330.85,335.44,339.8,343.97,347.94,351.75,355.4,358.91,362.29,365.55,368.69,371.72,397.47,417.43,453.72,479.46,515.75,541.49],"sy":[208.34,166.19,145.96,133.16,126.22,120.4,117.38,112.94,110.89,107.99,105.67,103.63,101.49,100.15,99.22,97.62,96.28,95.91,96.03,95.61,94.48,94.06,93.75,92.9,92.88,92.39,91.45,91.17,91.08,88.18,85.76,83.7,78.16,77.0,68.28,67.15]},{"panel":0,"label":"test months (unseen)","color":"var(--chart-maroon, #b6262c)","x":[1.0,2.0,3.0,4.0,5.0,6.0,7.0,8.0,9.0,10.0,11.0,12.0,13.0,14.0,15.0,16.0,17.0,18.0,19.0,20.0,21.0,22.0,23.0,24.0,25.0,26.0,27.0,28.0,29.0,30.0,40.0,50.0,75.0,100.0,150.0,200.0],"y":[15.6903,15.1177,14.5469,13.0927,12.5589,11.9172,11.7935,12.1931,11.9475,12.012,11.788,11.6633,11.3592,11.2791,11.2968,11.0894,10.3219,10.4222,10.3378,10.37,10.2519,10.1276,10.1068,9.96855,9.95412,9.87047,10.2648,10.2092,10.2592,10.2513,10.0368,9.5622,9.2253,9.01127,8.65669,8.41267],"sx":[67.35,129.38,165.66,191.41,211.38,227.69,241.49,253.44,263.98,273.41,281.94,289.72,296.89,303.52,309.69,315.47,320.89,326.01,330.85,335.44,339.8,343.97,347.94,351.75,355.4,358.91,362.29,365.55,368.69,371.72,397.47,417.43,453.72,479.46,515.75,541.49],"sy":[37.69,43.92,50.13,65.94,71.75,78.73,80.07,75.73,78.4,77.7,80.13,81.49,84.8,85.67,85.47,87.73,96.08,94.99,95.91,95.56,96.84,98.19,98.42,99.92,100.08,100.99,96.7,97.3,96.76,96.85,99.18,104.34,108.01,110.33,114.19,116.84]},{"panel":1,"label":"training months, bagged","color":"var(--chart-blue, #3b6ea5)","x":[1.0,2.0,3.0,4.0,5.0,6.0,7.0,8.0,9.0,10.0,11.0,12.0,13.0,14.0,15.0,16.0,17.0,18.0,19.0,20.0,21.0,22.0,23.0,24.0,25.0,26.0,27.0,28.0,29.0,30.0,40.0,50.0,75.0,100.0,150.0,200.0],"y":[4.33337,5.48198,6.4889,7.21297,7.80414,8.2113,8.55523,8.88724,9.14996,9.34036,9.49938,9.62766,9.7907,9.91331,10.0291,10.1237,10.2258,10.3077,10.4,10.4787,10.5455,10.6479,10.6863,10.7521,10.8137,10.8581,10.909,10.9647,10.9984,11.0288,11.2911,11.5245,12.1886,12.6633,13.0656,13.4131],"sx":[67.35,129.38,165.66,191.41,211.38,227.69,241.49,253.44,263.98,273.41,281.94,289.72,296.89,303.52,309.69,315.47,320.89,326.01,330.85,335.44,339.8,343.97,347.94,351.75,355.4,358.91,362.29,365.55,368.69,371.72,397.47,417.43,453.72,479.46,515.75,541.49],"sy":[486.54,469.28,454.15,443.27,434.39,428.27,423.1,418.11,414.17,411.31,408.92,406.99,404.54,402.7,400.96,399.53,398.0,396.77,395.38,394.2,393.2,391.66,391.08,390.09,389.17,388.5,387.74,386.9,386.39,385.94,381.99,378.49,368.51,361.38,355.33,350.11]},{"panel":1,"label":"test months, bagged","color":"var(--chart-maroon, #b6262c)","x":[1.0,2.0,3.0,4.0,5.0,6.0,7.0,8.0,9.0,10.0,11.0,12.0,13.0,14.0,15.0,16.0,17.0,18.0,19.0,20.0,21.0,22.0,23.0,24.0,25.0,26.0,27.0,28.0,29.0,30.0,40.0,50.0,75.0,100.0,150.0,200.0],"y":[10.6137,10.3796,10.2361,10.2309,10.108,9.80945,9.77861,9.75334,9.63883,9.55105,9.63955,9.53622,9.51977,9.45179,9.53363,9.52964,9.47424,9.52485,9.5543,9.59167,9.54486,9.38933,9.36553,9.33132,9.27517,9.20803,9.149,9.09077,9.07589,9.0314,8.79253,8.63461,8.33157,8.12615,8.19589,8.41663],"sx":[67.35,129.38,165.66,191.41,211.38,227.69,241.49,253.44,263.98,273.41,281.94,289.72,296.89,303.52,309.69,315.47,320.89,326.01,330.85,335.44,339.8,343.97,347.94,351.75,355.4,358.91,362.29,365.55,368.69,371.72,397.47,417.43,453.72,479.46,515.75,541.49],"sy":[392.17,395.69,397.85,397.92,399.77,404.26,404.72,405.1,406.82,408.14,406.81,408.36,408.61,409.63,408.4,408.46,409.29,408.53,408.09,407.53,408.23,410.57,410.93,411.44,412.29,413.29,414.18,415.06,415.28,415.95,419.54,421.91,426.46,429.55,428.5,425.19]},{"panel":1,"label":"test months, one tree (from panel 1)","color":"var(--chart-grey, #a3a19c)","x":[1.0,2.0,3.0,4.0,5.0,6.0,7.0,8.0,9.0,10.0,11.0,12.0,13.0,14.0,15.0,16.0,17.0,18.0,19.0,20.0,21.0,22.0,23.0,24.0,25.0,26.0,27.0,28.0,29.0,30.0,40.0,50.0,75.0,100.0,150.0,200.0],"y":[15.6903,15.1177,14.5469,13.0927,12.5589,11.9172,11.7935,12.1931,11.9475,12.012,11.788,11.6633,11.3592,11.2791,11.2968,11.0894,10.3219,10.4222,10.3378,10.37,10.2519,10.1276,10.1068,9.96855,9.95412,9.87047,10.2648,10.2092,10.2592,10.2513,10.0368,9.5622,9.2253,9.01127,8.65669,8.41267],"sx":[67.35,129.38,165.66,191.41,211.38,227.69,241.49,253.44,263.98,273.41,281.94,289.72,296.89,303.52,309.69,315.47,320.89,326.01,330.85,335.44,339.8,343.97,347.94,351.75,355.4,358.91,362.29,365.55,368.69,371.72,397.47,417.43,453.72,479.46,515.75,541.49],"sy":[315.89,324.49,333.07,354.92,362.94,372.59,374.44,368.44,372.13,371.16,374.53,376.4,380.97,382.17,381.91,385.02,396.56,395.05,396.32,395.83,397.61,399.48,399.79,401.87,402.08,403.34,397.41,398.25,397.5,397.62,400.84,407.97,413.04,416.25,421.58,425.25]},{"panel":2,"label":"tree that searches for the best split","color":"var(--chart-maroon, #b6262c)","x":[1.0,2.0,3.0,4.0,5.0,6.0,7.0,8.0,9.0,10.0,11.0,12.0,13.0,14.0,15.0,16.0,17.0,18.0,19.0,20.0,21.0,22.0,23.0,24.0,25.0,26.0,27.0,28.0,29.0,30.0,40.0,50.0,75.0,100.0,150.0,200.0],"y":[15.6903,15.1177,14.5469,13.0927,12.5589,11.9172,11.7935,12.1931,11.9475,12.012,11.788,11.6633,11.3592,11.2791,11.2968,11.0894,10.3219,10.4222,10.3378,10.37,10.2519,10.1276,10.1068,9.96855,9.95412,9.87047,10.2648,10.2092,10.2592,10.2513,10.0368,9.5622,9.2253,9.01127,8.65669,8.41267],"sx":[67.35,129.38,165.66,191.41,211.38,227.69,241.49,253.44,263.98,273.41,281.94,289.72,296.89,303.52,309.69,315.47,320.89,326.01,330.85,335.44,339.8,343.97,347.94,351.75,355.4,358.91,362.29,365.55,368.69,371.72,397.47,417.43,453.72,479.46,515.75,541.49],"sy":[607.67,620.02,632.34,663.73,675.25,689.1,691.77,683.15,688.45,687.06,691.89,694.58,701.15,702.87,702.49,706.97,723.53,721.37,723.19,722.5,725.05,727.73,728.18,731.16,731.47,733.28,724.77,725.97,724.89,725.06,729.69,739.93,747.2,751.82,759.48,764.74]},{"panel":2,"label":"tree that splits at random (mean of 10)","color":"var(--chart-blue, #3b6ea5)","x":[1.0,2.0,3.0,4.0,5.0,6.0,7.0,8.0,9.0,10.0,11.0,12.0,13.0,14.0,15.0,16.0,17.0,18.0,19.0,20.0,21.0,22.0,23.0,24.0,25.0,26.0,27.0,28.0,29.0,30.0,40.0,50.0,75.0,100.0,150.0,200.0],"y":[16.3192,14.2372,12.7107,12.114,11.7148,11.0965,10.9625,10.718,10.457,10.2432,10.3571,10.056,10.0107,10.0617,10.1581,9.85225,9.93139,9.7593,9.66481,9.42193,9.48545,9.45993,9.5703,9.57,9.46584,9.63228,9.41321,9.43816,9.33567,9.21541,9.2839,8.97205,9.1477,9.71739,9.68312,9.33488],"sx":[67.35,129.38,165.66,191.41,211.38,227.69,241.49,253.44,263.98,273.41,281.94,289.72,296.89,303.52,309.69,315.47,320.89,326.01,330.85,335.44,339.8,343.97,347.94,351.75,355.4,358.91,362.29,365.55,368.69,371.72,397.47,417.43,453.72,479.46,515.75,541.49],"sy":[594.09,639.03,671.97,684.85,693.47,706.82,709.71,714.99,720.62,725.23,722.77,729.27,730.25,729.15,727.07,733.67,731.96,735.68,737.72,742.96,741.59,742.14,739.76,739.76,742.01,738.42,743.15,742.61,744.82,747.42,745.94,752.67,748.88,736.58,737.32,744.84]},{"panel":3,"label":"tree that searches for the best split","color":"var(--chart-maroon, #b6262c)","x":[1.0,2.0,3.0,4.0,5.0,6.0,7.0,8.0,9.0,10.0,11.0,12.0,13.0,14.0,15.0,16.0,17.0,18.0,19.0,20.0,21.0,22.0,23.0,24.0,25.0,26.0,27.0,28.0,29.0,30.0,40.0,50.0,75.0,100.0,150.0,200.0],"y":[16.5193,13.4434,12.646,12.2862,11.937,11.7081,11.452,11.0696,10.7463,10.6531,10.4651,10.311,10.2357,10.0006,9.90034,9.8833,9.75031,9.7436,9.51258,9.37529,9.22775,8.98373,8.96323,8.96077,8.89274,8.74675,8.60781,8.5496,8.36674,8.22988,7.85622,7.59126,6.69163,6.19494,5.48822,5.02898],"sx":[67.35,129.38,165.66,191.41,211.38,227.69,241.49,253.44,263.98,273.41,281.94,289.72,296.89,303.52,309.69,315.47,320.89,326.01,330.85,335.44,339.8,343.97,347.94,351.75,355.4,358.91,362.29,365.55,368.69,371.72,397.47,417.43,453.72,479.46,515.75,541.49],"sy":[872.29,904.96,913.43,917.25,920.95,923.39,926.11,930.17,933.6,934.59,936.59,938.22,939.02,941.52,942.58,942.76,944.18,944.25,946.7,948.16,949.73,952.32,952.54,952.56,953.28,954.84,956.31,956.93,958.87,960.32,964.29,967.11,976.66,981.94,989.44,994.32]},{"panel":3,"label":"tree that splits at random (mean of 10)","color":"var(--chart-blue, #3b6ea5)","x":[1.0,2.0,3.0,4.0,5.0,6.0,7.0,8.0,9.0,10.0,11.0,12.0,13.0,14.0,15.0,16.0,17.0,18.0,19.0,20.0,21.0,22.0,23.0,24.0,25.0,26.0,27.0,28.0,29.0,30.0,40.0,50.0,75.0,100.0,150.0,200.0],"y":[5.92418,2.73221,2.26035,2.05675,1.90823,1.80969,1.71666,1.63334,1.59903,1.53858,1.48515,1.43821,1.41244,1.38132,1.35796,1.35452,1.30635,1.26507,1.26619,1.25515,1.22776,1.2128,1.20892,1.19473,1.19238,1.14249,1.12681,1.10718,1.09449,1.09143,0.995957,0.939473,0.730432,0.674014,0.518961,0.45042],"sx":[67.35,129.38,165.66,191.41,211.38,227.69,241.49,253.44,263.98,273.41,281.94,289.72,296.89,303.52,309.69,315.47,320.89,326.01,330.85,335.44,339.8,343.97,347.94,351.75,355.4,358.91,362.29,365.55,368.69,371.72,397.47,417.43,453.72,479.46,515.75,541.49],"sy":[984.81,1018.71,1023.72,1025.88,1027.46,1028.51,1029.5,1030.38,1030.74,1031.39,1031.95,1032.45,1032.73,1033.06,1033.3,1033.34,1033.85,1034.29,1034.28,1034.4,1034.69,1034.85,1034.89,1035.04,1035.06,1035.59,1035.76,1035.97,1036.1,1036.14,1037.15,1037.75,1039.97,1040.57,1042.21,1042.94]}]} 2026-10-15T23:19:12.564369 image/svg+xml Matplotlib v3.11.2, https://matplotlib.org/ 1 0 0 1 0 1 1 0 2 Leaf size (minimum rows in a leaf, log scale) 0 2 4 6 8 10 12 14 16 RMSE (volatility points) 1. One regression tree: error on seen and unseen months as leaves grow training months (seen) test months (unseen) 1 0 0 1 0 1 1 0 2 Leaf size (minimum rows in a leaf, log scale) 4 6 8 10 12 14 16 RMSE (volatility points) 2. Twenty bagged trees: the test curve goes flat training months, bagged test months, bagged test months, one tree (from panel 1) 1 0 0 1 0 1 1 0 2 Leaf size (minimum rows in a leaf, log scale) 9 10 11 12 13 14 15 16 RMSE (volatility points) 3. Random splits against chosen splits: error on the test months tree that searches for the best split tree that splits at random (mean of 10) 1 0 0 1 0 1 1 0 2 Leaf size (minimum rows in a leaf, log scale) 0 2 4 6 8 10 12 14 16 Training time (milliseconds) 4. Time to build the tree tree that searches for the best split tree that splits at random (mean of 10)

Bagging helps unstable learners. A bag of linear regressions returns almost exactly the single regression’s answer, because a straight line barely moves between resamples and there is nothing to average away.

TrapTruth
Bagging reduces biasVariance; boosting reduces bias
Bagging shallow trees works wellThe bias stays
A random forest picks features once per treePer split
Averaging cancels a shared errorIt cannot

A score needs a split, a metric and a baseline

Full post: Scoring a model.

SetUsed forTouch it
Trainingfit the parametersas often as you like
Validationchoose settings: depth, kk, λ, featuresmany times
Testone final, honest checkonce

For a time series the test must be later than the training. The three error metrics on misses 1, 1, 1, 5: MAE 2, MSE 7, RMSE 2.65. RMSE is at least MAE, equal only when every miss is the same size; a big gap means a few large misses.

RMSE=1n∑i(yi−y^i)2\risk{\text{RMSE}} = \sqrt{\frac{1}{n}\sum_i (\good{y_i} - \key{\hat{y}_i})^2}

Real run. The baseline from section 1, predicting the training mean, scores 9.84. A fully grown tree scores 15.69, worse than guessing the average. Twenty bagged trees score 10.61, still worse. A single tree with 200 rows per leaf scores 8.41 and the plain linear model 7.50. Every one of those numbers is meaningless until it sits next to 9.84, and the first two are the kind of result that gets reported as “the model works” when nobody checked the baseline. For classification the bar is the majority class: 58% accuracy when 60% of days are up is worse than doing nothing.

Classification metrics from a confusion matrix: sensitivity (recall) divides true positives by the actual positives; specificity divides true negatives by the actual negatives; precision divides true positives by the predicted positives. In a credit example with 10,000 people and 333 defaulters, total error 2.75% looked fine while 252 of 333 defaulters were missed. Moving the threshold from 0.5 to 0.2 cut the misses to 138 at the cost of more false alarms. ROC plots the true positive rate against the false positive rate across every threshold; AUC 0.5 is a coin flip.

TrapTruth
Tuning on the test set is fine when data is scarceNo
MAE punishes outliers more than RMSEThe reverse
Higher directional accuracy means more profitSizes and costs sit in between

The gap between seen and unseen is the whole game

Full post: Bias and variance.

Training error always falls as the model gets more flexible. Validation error falls, bottoms out, then rises. Stop at the bottom.

Real run. One regression tree, min_samples_leaf swept from 1 to 200:

Leaf sizeTraining RMSETest RMSEReading
10.00\key{0.00}15.69\risk{15.69}memorised: perfect in, worse than the baseline out
57.5512.56
2010.3610.37the two curves cross
5011.469.56now beating the baseline
10012.089.01
20012.988.41\key{8.41}the best of the sweep

At leaf size 1 the tree has one row per leaf and reproduces the training targets; its test error is 60% worse than guessing the mean. As leaves grow the tree averages more rows, training error rises and test error falls, and on this target the bottom is at 200 rows per leaf or beyond: next month’s volatility is so noisy that a tree needs to average a lot of months before it says anything true. (The classic U-shape, where test error turns back up, appears on less noisy targets; here the right end of the sweep is where it starts.) Diagnose from the two numbers:

Training errorValidation errorDiagnosisFix
highhigh, similarunderfit, biasmore capacity, better features
very lowmuch higheroverfit, varianceless capacity, regularise, more data
lowlow, similargoodtest once

More data helps variance only. Complexity dials: tree depth and leaf size, kk (reversed), polynomial degree, λ (reversed), boosting rounds. Cross-validation averages the error over KK folds; for a time series use forward folds, because random folds leak the future. Choose the simplest model within one standard error of the best.

TrapTruth
Both scores equally bad means a stable good modelUnderfit
Random k-fold is fine for a time seriesIt leaks
Stop at the lowest training errorAt the lowest validation error

Model choice is a systems decision

Full post: Choosing a model.

ItemLinearKNNTreeBag or forest
Shape it capturesstraight linesany local shapethresholds, interactionsthe same, smoother
Training costlownonelowhigh
Cost per predictionO(p)O(p)O(Np)O(Np)O(depth)O(\text{depth})O(trees×depth)O(\text{trees} \times \text{depth})
Keeps the datanoall of itnono
Needs scalingfor regularisationyesnono
Extrapolatesyesnonono
Interpretablehigh“like these past days”high if shallowlow
Stablehighmediumlowhigh

Real run, the lessons in numbers. The linear surface: linear 0.06, tree 7.79. The three steps: tree 0.21, linear 2.52. No method is best on every dataset. On the real volatility target the plain linear model (7.50) beat every tree and every bag, because the signal is weak and nearly linear, and the cheapest model had the least room to fit noise. A random-split tree trained three to seven times faster than a searching one. Twenty bagged trees cost twenty fits and twenty queries for a third off a fully grown tree’s error. A thousand-row, twenty-feature table costs a KNN 20,000 numbers per prediction and a linear model 20.

Interpretability can be a binding constraint, and accepting a little less accuracy is then correct. Tail latency (P99), not the mean, decides whether a deadline is met. More history helps only if it is relevant. A small observed lead can be noise, and reliability can beat a marginal gain.

TrapTruth
Pick the lowest-error modelWeigh every requirement
Interpretable means reliableNo
Mean latency within budget means safeTails break deadlines

The formula sheet

TopicFormulaRemember
Signal plus noiseY=f(X)+εY = f(X) + \varepsilonnoise is the floor
Bias-varianceBias2+Var+Noise\text{Bias}^2 + \text{Var} + \text{Noise}more data fixes variance only
MSE, RMSE, MAEmean of e2e^2; its root; mean of ∣e∣\lvert e \rvertRMSE ≥ MAE
R2R^21−RSS/TSS1 - \text{RSS}/\text{TSS}against predicting the mean
t-statisticβ^/SE\hat\beta / \text{SE}about 2 is the bar
Odds, logisticp/(1−p)p/(1 - p); log-odds linear in xxone unit of xx multiplies odds by eβe^\beta
Gini1−∑p21 - \sum p^20.5 at worst for two classes
Entropy−∑pln⁡p-\sum p \ln p (or log⁡2\log_2 for bits)ln 2 = 0.693, 1 bit
Information gainparent entropy − weighted child entropysplit on the largest
KNN capacityabout N/kN/kbigger kk, simpler model
Curse of dimensionalityside =q1/p= q^{1/p}neighbourhoods explode
Bootstrap(1−1/N)N→0.37(1 - 1/N)^N \to 0.3763% in the bag
Averaged varianceρσ2+(1−ρ)σ2/B\rho\sigma^2 + (1 - \rho)\sigma^2/Bdecorrelate
Sensitivity, specificityTP / actual positives; TN / actual negativesdivide by the true counts
PrecisionTP / predicted positivesdivide by the predicted count
VIF1/(1−Rj2)1/(1 - R_j^2)above 5 to 10, collinear
BayesP(A∣B)=P(B∣A)P(A)/P(B)P(A \mid B) = P(B \mid A)P(A)/P(B)use counts of 10,000; a rare prior drags it down

The arithmetic to be fluent in

  1. MAE, MSE and RMSE from a list of misses.
  2. Gini and entropy from class fractions; information gain in bits.
  3. The bootstrap 63/37 split and the ρσ2\rho\sigma^2 floor.
  4. The reversed dials: small kk is complex, big λ is simple.
  5. Bayes with counts: 1% base rate, 90% sensitivity, 3% false alarms gives 90/(90 + 297) = 23%.
  6. Sensitivity, specificity and precision from a confusion matrix.
  7. Read a train-test pair: 0.5 and 2.0 is overfit; 1.9 and 2.0 is underfit.

The script

Every number and chart above comes from one script, which runs in a few seconds with uv run (the dependencies are declared at the top of the file). It uses the blog’s shared price file and two small helpers for loading prices and drawing charts.

The full script, 132 lines
# /// script
# requires-python = ">=3.11"
# dependencies = ["numpy", "pandas", "matplotlib", "scikit-learn"]
# ///
"""Numbers and chart for the post "Machine learning for quantitative finance in ten ideas".

Everything runs on the blog's shared market data (examples/market_data.py: SPY, AAPL, JPM, XOM,
GLD, 2019-2023, seven price-derived features, target = next month's volatility) with scikit-learn,
plus two tiny made-up datasets that favour a line or a tree.
Run:  uv run examples/machine-learning-in-ten-ideas/ten_ideas.py
"""
import sys
import time
from pathlib import Path

import matplotlib.pyplot as plt
import numpy as np
from sklearn.ensemble import BaggingRegressor
from sklearn.linear_model import LinearRegression
from sklearn.tree import DecisionTreeRegressor

sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import chartstyle as cs  # noqa: E402
import market_data as md  # noqa: E402

OUT = Path(__file__).resolve().parents[2] / "public" / "posts" / "machine-learning-in-ten-ideas"


def rmse(y, yhat):
    return float(np.sqrt(np.mean((y - yhat) ** 2)))


def p(label, value):
    print(f"{label:<64} {value}")


df = md.features()
train, test = md.split(df)                     # 2019-2021 train, 2022-2023 test, in time order
Xtr, ytr = train[md.FEATURES].to_numpy(), (train["next_vol"] * md.ANNUALISE).to_numpy()
Xte, yte = test[md.FEATURES].to_numpy(), (test["next_vol"] * md.ANNUALISE).to_numpy()
print("\n== data ==")
p("training rows / test rows", f"{len(ytr)} / {len(yte)}")
p("target: next month's volatility, % a year; test mean / sd", f"{yte.mean():.1f} / {yte.std():.1f}")
p("baseline: predict the training mean, test RMSE", f"{rmse(yte, np.full_like(yte, ytr.mean())):.2f}")

print("\n== 9. one tree, leaf size swept ==")
leaves = list(range(1, 31)) + [40, 50, 75, 100, 150, 200]
tree_tr, tree_te, tree_t = [], [], []
for ls in leaves:
    m = DecisionTreeRegressor(min_samples_leaf=ls, random_state=0)
    t0 = time.perf_counter(); m.fit(Xtr, ytr); tree_t.append(time.perf_counter() - t0)
    tree_tr.append(rmse(ytr, m.predict(Xtr))); tree_te.append(rmse(yte, m.predict(Xte)))
for ls, a, b in zip(leaves, tree_tr, tree_te):
    if ls in (1, 5, 10, 20, 30, 50, 100, 200):
        p(f"leaf size {ls:3}: train / test RMSE", f"{a:.2f} / {b:.2f}")
best = int(np.argmin(tree_te))
p("best test RMSE", f"{tree_te[best]:.2f} at leaf size {leaves[best]}")

print("\n== 7. twenty bagged trees, same sweep ==")
bag_tr, bag_te = [], []
for ls in leaves:
    m = BaggingRegressor(DecisionTreeRegressor(min_samples_leaf=ls), n_estimators=20, random_state=0, n_jobs=-1)
    m.fit(Xtr, ytr)
    bag_tr.append(rmse(ytr, m.predict(Xtr))); bag_te.append(rmse(yte, m.predict(Xte)))
for ls, a, b in zip(leaves, bag_tr, bag_te):
    if ls in (1, 5, 10, 20, 30, 50, 100, 200):
        p(f"leaf size {ls:3}: bagged train / test RMSE", f"{a:.2f} / {b:.2f}")
p("bagged test RMSE range over all leaf sizes", f"{min(bag_te):.2f} to {max(bag_te):.2f}")
for b_ in [1, 5, 10, 20, 50, 100]:
    m = BaggingRegressor(DecisionTreeRegressor(min_samples_leaf=1), n_estimators=b_, random_state=0, n_jobs=-1).fit(Xtr, ytr)
    p(f"bags {b_:3} of fully grown trees: test RMSE", f"{rmse(yte, m.predict(Xte)):.2f}")

print("\n== 7. random splits against chosen splits ==")
rt_te, rt_t = [], []
for ls in leaves:
    tes, ts = [], []
    for seed in range(10):
        m = DecisionTreeRegressor(min_samples_leaf=ls, splitter="random", random_state=seed)
        t0 = time.perf_counter(); m.fit(Xtr, ytr); ts.append(time.perf_counter() - t0)
        tes.append(rmse(yte, m.predict(Xte)))
    rt_te.append(float(np.mean(tes))); rt_t.append(float(np.mean(ts)))
for ls in (1, 10, 30):
    i = leaves.index(ls)
    p(f"leaf size {ls:3}: chosen vs random split test RMSE", f"{tree_te[i]:.2f} vs {rt_te[i]:.2f}; fit {1000*tree_t[i]:.1f} vs {1000*rt_t[i]:.1f} ms")
m = BaggingRegressor(DecisionTreeRegressor(min_samples_leaf=1, splitter="random"), n_estimators=20, random_state=0, n_jobs=-1).fit(Xtr, ytr)
p("20 bagged random-split trees, leaf 1: test RMSE", f"{rmse(yte, m.predict(Xte)):.2f}")

print("\n== 4 and 6. a line or a tree, on data that favours each ==")
rng = np.random.default_rng(0)
X = rng.uniform(-1, 1, (1000, 3))
y_line = 50 * X[:, 0] + 30 * X[:, 1] - 20 * X[:, 2] + rng.uniform(-0.1, 0.1, 1000)
y_step = 8 * (X[:, 0] > 0) + 5 * (X[:, 1] > 0) + 3 * (X[:, 2] > 0) + 0.5 * X[:, 2]
idx = rng.permutation(1000); tr_i, te_i = idx[:600], idx[600:]
for name, y in [("linear surface", y_line), ("three steps", y_step)]:
    lin = LinearRegression().fit(X[tr_i], y[tr_i]); tr = DecisionTreeRegressor(min_samples_leaf=1, random_state=0).fit(X[tr_i], y[tr_i])
    p(f"{name}: linear / tree test RMSE", f"{rmse(y[te_i], lin.predict(X[te_i])):.2f} / {rmse(y[te_i], tr.predict(X[te_i])):.2f}")

print("\n== 3. the leak: a same-day feature ==")
# add SPY's same-day next-month volatility as a feature for the other tickers: not knowable in advance
spy = df.xs("SPY", level="ticker")["next_vol"] * md.ANNUALISE
leak = df.join(spy.rename("spy_same_month"), on="date")
ltr, lte = md.split(leak)
m = LinearRegression().fit(ltr[md.FEATURES], ltr["next_vol"] * md.ANNUALISE)
p("linear, honest features: test RMSE", f"{rmse(lte['next_vol'] * md.ANNUALISE, m.predict(lte[md.FEATURES])):.2f}")
m = LinearRegression().fit(ltr[md.FEATURES + ['spy_same_month']], ltr["next_vol"] * md.ANNUALISE)
p("linear, plus a same-month feature (leak): test RMSE", f"{rmse(lte['next_vol'] * md.ANNUALISE, m.predict(lte[md.FEATURES + ['spy_same_month']])):.2f}")

# ------------------------------------------------------------------ chart ----
cs.apply()
fig, (a1, a2, a3, a4) = plt.subplots(4, 1, figsize=(8, 3.8 * 4))
cs.title(a1, "One regression tree: error on seen and unseen months as leaves grow", 1)
a1.plot(leaves, tree_tr, color=cs.BLUE, marker="o", ms=3, label="training months (seen)")
a1.plot(leaves, tree_te, color=cs.MAROON, marker="o", ms=3, label="test months (unseen)")
a1.set_xscale("log"); a1.set_xlabel("Leaf size (minimum rows in a leaf, log scale)"); a1.set_ylabel("RMSE (volatility points)")
a1.legend()
cs.title(a2, "Twenty bagged trees: the test curve goes flat", 2)
a2.plot(leaves, bag_tr, color=cs.BLUE, marker="o", ms=3, label="training months, bagged")
a2.plot(leaves, bag_te, color=cs.MAROON, marker="o", ms=3, label="test months, bagged")
a2.plot(leaves, tree_te, color=cs.GREY, lw=1, label="test months, one tree (from panel 1)")
a2.set_xscale("log"); a2.set_xlabel("Leaf size (minimum rows in a leaf, log scale)"); a2.set_ylabel("RMSE (volatility points)")
a2.legend()
cs.title(a3, "Random splits against chosen splits: error on the test months", 3)
a3.plot(leaves, tree_te, color=cs.MAROON, marker="o", ms=3, label="tree that searches for the best split")
a3.plot(leaves, rt_te, color=cs.BLUE, marker="o", ms=3, label="tree that splits at random (mean of 10)")
a3.set_xscale("log"); a3.set_xlabel("Leaf size (minimum rows in a leaf, log scale)"); a3.set_ylabel("RMSE (volatility points)")
a3.legend()
cs.title(a4, "Time to build the tree", 4)
a4.plot(leaves, [1000 * t for t in tree_t], color=cs.MAROON, marker="o", ms=3, label="tree that searches for the best split")
a4.plot(leaves, [1000 * t for t in rt_t], color=cs.BLUE, marker="o", ms=3, label="tree that splits at random (mean of 10)")
a4.set_xscale("log"); a4.set_xlabel("Leaf size (minimum rows in a leaf, log scale)"); a4.set_ylabel("Training time (milliseconds)")
a4.legend()
cs.save(fig, OUT / "leaf-size-sweep.svg")