Machine learning for quantitative finance in ten ideas
On this page
- The target decides the problem
- The data is part of the model
- Every feature has a timestamp
- A linear model is a weighted sum
- Nearest neighbours store everything
- A tree asks one question at a time
- Averaging helps only when the errors differ
- A score needs a split, a metric and a baseline
- The gap between seen and unseen is the whole game
- Model choice is a systems decision
- The formula sheet
- The arithmetic to be fluent in
- 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:
What happens equals the pattern you can learn plus the luck you cannot. In finance is small and 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:
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, 5 | Working | Result |
|---|---|---|
| MSE | (1 + 1 + 1 + 25)/4 | 7 |
| MAE | 8/4 | 2 |
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 . That number is the bar every model below has to clear, and some of them do not.
| Trap | Truth |
|---|---|
| More data fixes high bias | More data fixes variance |
| A train-test gap is bias | The gap is variance; both high is bias |
| Faster training means faster prediction | Independent |
| The lowest error is the best model | Weigh 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, 10 | Total | The outlier’s share |
|---|---|---|
| Squared loss | 1 + 1 + 100 = 102 | 98% |
| Absolute loss | 12 | 83% |
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.
| Trap | Truth |
|---|---|
| A bigger sample fixes survivorship | It is systematic, not random |
| Mean fill is neutral | Shrinks variance, hides the signal |
| Normalising with the full-sample mean is fine | A look-ahead leak |
| Walk-forward leaks because it reuses the past | The 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.
| Features | Linear model, test RMSE |
|---|---|
| Seven honest features | 7.50 |
| Plus SPY’s same-month volatility |
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.
| Item | Ridge (L2) | Lasso (L1) |
|---|---|---|
| Penalty | ||
| Effect | shrinks all, never to zero | pushes some exactly to zero |
| Twins | shares the weight | picks one arbitrarily |
| Use when | many small effects | few real effects |
Rolling z-score, worked: window mean 100, σ 2, price 106 gives ; in a crash the window σ jumps to 10 and the same six-point gap reads . The z-score hides the crash.
| Trap | Truth |
|---|---|
| Leakage is only about time | Pooling leaks too |
| Future returns as labels are leakage | That is the target; leakage is about inputs |
| Trees need scaling | No |
| Fewer features lowers bias | Raises bias, lowers variance |
A linear model is a weighted sum
Full post: Linear regression.
Start from a base number and add a fixed amount per unit of each feature, like a taxi fare. Worked: with momentum 0.02 and vol 0.01 gives . Each 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, . Out of sample:
| Learner | RMSE on the linear surface |
|---|---|
| Linear regression | |
| Regression tree, fully grown | 7.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 . 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.
is “how much better than predicting the mean”; in finance a value well below 0.1 can be real. A coefficient’s with is the usual bar.
| Trap | Truth |
|---|---|
| Bigger coefficient means more important | Only after standardising |
| Normal errors are required | Only for small-sample inference |
| Polynomial regression is nonlinear | Linear in the parameters |
| Regularisation adds flexibility | It 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 closest rows and average them (regression) or vote (classification).
| Item | medium | ||
|---|---|---|---|
| Prediction | the nearest point’s own value | a local average | the global mean, for every query |
| Training error | 0 | some | highest |
| Bias / variance | low / highest | balanced | highest / zero |
| Verdict | overfits | pick on validation | underfits |
The dial runs backwards: raising makes the model simpler, the opposite of tree depth or polynomial degree. Effective capacity is about .
| Case | Working | Result |
|---|---|---|
| Euclidean distance (1, 2) to (4, 6) | √(9 + 16) | 5; Manhattan 7 |
| , neighbours’ returns 1%, 2%, 3% | 2% | |
| votes up, up, down, up, down | P(up) = 0.6 | |
| Weights by , distances 1 and 3 | 1 and 0.33, normalised | 0.75 and 0.25 |
| Box side to capture 10% of points in dimensions | : 0.10; : 0.79; : 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 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.
| Trap | Truth |
|---|---|
| Larger overfits more | Smaller does |
| predicts each point’s own label | The global mean |
| KNN is fast because training is cheap | Prediction 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:
| Decision | Rule | What it means |
|---|---|---|
| Which feature to ask about | the 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 it | the 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 stop | when a node holds at most min_samples_leaf rows, or every target in it is identical | the 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 is a sum of three step functions:
Out of sample:
| Learner | RMSE on the three steps |
|---|---|
| Linear regression | 2.52 |
| Regression tree, fully grown |
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 mix | Gini | Entropy |
|---|---|---|
| 100 / 0 | 0 | 0 |
| 30 / 70 | 0.42 | 0.611 (0.881 bits) |
| 50 / 50 | 0.5 | 0.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.
| Setting | Controls | Watch out |
|---|---|---|
| leaf size | the minimum rows in a leaf, the main variance knob | too large hides real patterns |
| max depth | levels of questions, at most leaves | one feature can be split many times |
| min samples to split | the smallest node that may try a split | alone, it still allows a 99/1 split |
| Trap | Truth |
|---|---|
| A tree averages over all paths | One path |
| Log-transforming a feature changes a tree | Same splits |
| Trees extrapolate trends | A constant outside the training range |
| Deeper is more stable | Less stable |
Averaging helps only when the errors differ
Full post: Bagging.
Bagging trains many copies of a model, each on a bootstrap sample (draw rows with replacement), and averages them. Chance a row is never drawn: , so each copy sees about 63% of distinct rows and the rest are out-of-bag, a free validation set.
Averaging shrinks only the part of the errors that differs from copy to copy. The shared part stays however many copies you add, like polling a hundred people who all read the same newspaper. Random forests attack 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 | σ² | ρ | B | Variance |
|---|---|---|---|---|
| Baseline | 1 | 0.5 | 100 | 0.5 + 0.005 = 0.505 |
| 100 × more trees | 1 | 0.5 | 10,000 | 0.50005 |
| Decorrelate instead | 1 | 0.1 | 100 | 0.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 averaged | 1 | 5 | 10 | 20 | 50 | 100 |
|---|---|---|---|---|---|---|
| Test RMSE | 15.87 | 11.93 | 11.24 | 10.61 | 10.19 | 10.23 |
The first twenty trees take a third off the error; the next eighty take nothing. That levelling is the 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.
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.
| Trap | Truth |
|---|---|
| Bagging reduces bias | Variance; boosting reduces bias |
| Bagging shallow trees works well | The bias stays |
| A random forest picks features once per tree | Per split |
| Averaging cancels a shared error | It cannot |
A score needs a split, a metric and a baseline
Full post: Scoring a model.
| Set | Used for | Touch it |
|---|---|---|
| Training | fit the parameters | as often as you like |
| Validation | choose settings: depth, , λ, features | many times |
| Test | one final, honest check | once |
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.
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.
| Trap | Truth |
|---|---|
| Tuning on the test set is fine when data is scarce | No |
| MAE punishes outliers more than RMSE | The reverse |
| Higher directional accuracy means more profit | Sizes 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 size | Training RMSE | Test RMSE | Reading |
|---|---|---|---|
| 1 | memorised: perfect in, worse than the baseline out | ||
| 5 | 7.55 | 12.56 | |
| 20 | 10.36 | 10.37 | the two curves cross |
| 50 | 11.46 | 9.56 | now beating the baseline |
| 100 | 12.08 | 9.01 | |
| 200 | 12.98 | 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 error | Validation error | Diagnosis | Fix |
|---|---|---|---|
| high | high, similar | underfit, bias | more capacity, better features |
| very low | much higher | overfit, variance | less capacity, regularise, more data |
| low | low, similar | good | test once |
More data helps variance only. Complexity dials: tree depth and leaf size, (reversed), polynomial degree, λ (reversed), boosting rounds. Cross-validation averages the error over 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.
| Trap | Truth |
|---|---|
| Both scores equally bad means a stable good model | Underfit |
| Random k-fold is fine for a time series | It leaks |
| Stop at the lowest training error | At the lowest validation error |
Model choice is a systems decision
Full post: Choosing a model.
| Item | Linear | KNN | Tree | Bag or forest |
|---|---|---|---|---|
| Shape it captures | straight lines | any local shape | thresholds, interactions | the same, smoother |
| Training cost | low | none | low | high |
| Cost per prediction | ||||
| Keeps the data | no | all of it | no | no |
| Needs scaling | for regularisation | yes | no | no |
| Extrapolates | yes | no | no | no |
| Interpretable | high | “like these past days” | high if shallow | low |
| Stable | high | medium | low | high |
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.
| Trap | Truth |
|---|---|
| Pick the lowest-error model | Weigh every requirement |
| Interpretable means reliable | No |
| Mean latency within budget means safe | Tails break deadlines |
The formula sheet
| Topic | Formula | Remember |
|---|---|---|
| Signal plus noise | noise is the floor | |
| Bias-variance | more data fixes variance only | |
| MSE, RMSE, MAE | mean of ; its root; mean of | RMSE ≥ MAE |
| against predicting the mean | ||
| t-statistic | about 2 is the bar | |
| Odds, logistic | ; log-odds linear in | one unit of multiplies odds by |
| Gini | 0.5 at worst for two classes | |
| Entropy | (or for bits) | ln 2 = 0.693, 1 bit |
| Information gain | parent entropy − weighted child entropy | split on the largest |
| KNN capacity | about | bigger , simpler model |
| Curse of dimensionality | side | neighbourhoods explode |
| Bootstrap | 63% in the bag | |
| Averaged variance | decorrelate | |
| Sensitivity, specificity | TP / actual positives; TN / actual negatives | divide by the true counts |
| Precision | TP / predicted positives | divide by the predicted count |
| VIF | above 5 to 10, collinear | |
| Bayes | use counts of 10,000; a rare prior drags it down |
The arithmetic to be fluent in
- MAE, MSE and RMSE from a list of misses.
- Gini and entropy from class fractions; information gain in bits.
- The bootstrap 63/37 split and the floor.
- The reversed dials: small is complex, big λ is simple.
- Bayes with counts: 1% base rate, 90% sensitivity, 3% false alarms gives 90/(90 + 297) = 23%.
- Sensitivity, specificity and precision from a confusion matrix.
- 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")