Nearest neighbours: predict what similar past cases did

On this page
  1. The rule
  2. The k dial runs backwards
  3. Scaling: distance uses raw numbers
  4. Cost: free to train, expensive to ask
  5. The curse of dimensionality
  6. When to use it
  7. Traps
  8. The code
  9. Summary
  10. References

The simplest learning rule there is: to predict a new case, look up the most similar cases you have seen and copy what they did. Nothing is fitted. The data is the model. This post runs it on the series’ problem, predicting next month’s volatility from seven features of this month, and uses it to show three things that matter well beyond this model: a dial that runs backwards, why scaling matters, and what happens as features pile up.

The rule

Store every training row. For a new row, measure its distance to every stored row, keep the kk closest, and predict their average (for a number) or their majority (for a category). Distance is the ordinary straight-line kind, across all the features at once:

d(a,b)=∑j(aj−bj)2d(a, b) = \sqrt{\sum_j (a_j - b_j)^2}
Table 1. Worked examples of the rule: distance, average, vote and weights
CaseWorkingResult
Distance from (1, 2) to (4, 6)32+42\sqrt{3^2 + 4^2}5 (the “city-block” or Manhattan distance, walking along one feature at a time, is 3 + 4 = 7)
k=3k = 3, neighbours’ returns 1%, 2%, 3%average2%
k=5k = 5 votes up, up, down, up, downmajorityup, with P(up) = 0.6
Weights by 1/distance, distances 1 and 31 and 0.33, rescaled to sum to 10.75 and 0.25

The last row of Table 1 is distance weighting: closer neighbours count for more. It helps a little on smooth problems and on this one it is slightly worse (test RMSE 12.43 weighted against 12.39 unweighted at k=20k = 20).

The k dial runs backwards

Every model has a dial that trades memorising against generalising. For a tree it is depth, for a polynomial it is degree, and in both cases turning it up makes the model more flexible. For nearest neighbours the dial is kk, and turning it up makes the model simpler: more neighbours are averaged, the prediction is smoother, and at k=Nk = N every query gets the global mean (Table 2).

Table 2. What the model predicts at small, medium and largest k
kkWhat it predictsTraining errorSteady on new data?Verdict
1the nearest point’s own value0no: one odd neighbour decidesmemorises
mediuma local averagesomein betweenchoose on validation
NN, all rowsthe global mean, for every queryhighestcompletely, but it ignores the featurestoo simple

{"panels":[{"x0":52.28,"x1":533.28,"y0":29.16,"y1":196.67,"title":"1. The k dial runs backwards: small k memorises, huge k predicts the average","xlabel":"k, the number of neighbours averaged (log scale)","ylabel":"RMSE (volatility points)","xlog":true},{"x0":52.28,"x1":533.28,"y0":342.26,"y1":509.77,"title":"2. Two features, one query: the twenty nearest months, before and after scaling","xlabel":"5-day return (%)","ylabel":"20-day volatility (% a year)","xlog":false},{"x0":52.28,"x1":533.28,"y0":655.37,"y1":822.88,"title":"3. Useless features hurt a neighbour model far more than a linear one","xlabel":"Number of random noise features added to the seven real ones","ylabel":"Test RMSE (volatility points)","xlog":false}],"series":[{"panel":0,"label":"training months (seen)","color":"var(--chart-blue, #3b6ea5)","kind":"line","x":[1.0,2.0,3.0,5.0,10.0,20.0,50.0,100.0,200.0,500.0,1000.0,2000.0,4930.0],"y":[0.0,8.48772,10.0172,11.1597,12.0457,12.5396,13.1944,13.5996,14.1194,15.1287,16.2054,17.6825,19.5897],"sx":[74.15,109.79,130.64,156.91,192.56,228.2,275.32,310.97,346.61,393.73,429.38,465.02,511.42],"sy":[189.05,123.07,111.18,102.3,95.42,91.58,86.49,83.34,79.3,71.45,63.08,51.6,36.77]},{"panel":0,"label":"test months (unseen)","color":"var(--chart-maroon, #b6262c)","kind":"line","x":[1.0,2.0,3.0,5.0,10.0,20.0,50.0,100.0,200.0,500.0,1000.0,2000.0,4930.0],"y":[17.0535,14.94,14.0245,13.3234,12.6947,12.386,12.1882,12.2699,12.4069,12.8605,13.5636,14.7814,17.6934],"sx":[74.15,109.79,130.64,156.91,192.56,228.2,275.32,310.97,346.61,393.73,429.38,465.02,511.42],"sy":[56.49,72.92,80.03,85.48,90.37,92.77,94.31,93.67,92.61,89.08,83.62,74.15,51.51]},{"panel":0,"label":"baseline: always predict the training mean","color":"var(--chart-grey, #a3a19c)","kind":"line","x":[0.0,1.0],"y":[17.6934,17.6934],"sx":[-118336.94,74.15],"sy":[51.51,51.51]},{"panel":1,"label":"20 nearest on raw features (volatility decides everything)","color":"var(--chart-blue, #3b6ea5)","kind":"scatter","x":[-0.777732,-0.0161048,0.0650152,0.0654385,0.190359,0.221858,0.31934,0.64099,0.69843,0.732381,0.955029,1.00617,1.0732,1.42662,2.07539,2.13934,2.30945,2.45916,2.74572,2.80158],"y":[11.3016,17.4911,11.2009,13.6073,26.313,27.1919,13.0556,15.2264,18.2124,11.96,8.86848,15.2268,22.4917,11.6776,18.0522,17.9205,27.1841,11.9245,13.1337,20.0577],"sx":[269.44,275.38,276.01,276.02,276.99,277.24,278.0,280.51,280.96,281.22,282.96,283.36,283.88,286.64,291.7,292.2,293.53,294.7,296.93,297.37],"sy":[496.59,489.43,496.71,493.93,479.23,478.21,494.57,492.05,488.6,495.83,499.41,492.05,483.65,496.16,488.79,488.94,478.22,495.87,494.48,486.47]},{"panel":1,"label":"20 nearest on standardised features","color":"var(--chart-maroon, #b6262c)","kind":"scatter","x":[0.0650152,0.0654385,0.411072,0.549112,0.67158,0.839992,0.955029,0.974703,1.00617,1.02218,1.14456,1.16251,1.28191,1.31961,1.4037,1.42662,1.52728,1.52934,1.5844,2.31742],"y":[11.2009,13.6073,12.3067,7.98675,10.8282,8.38137,8.86848,7.32967,15.2268,14.824,10.8425,10.9987,7.32567,6.92068,10.9436,11.6776,12.8154,14.4077,10.3692,10.4269],"sx":[276.01,276.02,278.71,279.79,280.75,282.06,282.96,283.11,283.36,283.48,284.44,284.58,285.51,285.8,286.46,286.64,287.42,287.44,287.87,293.59],"sy":[496.71,493.93,495.43,500.43,497.14,499.97,499.41,501.19,492.05,492.52,497.13,496.94,501.19,501.66,497.01,496.16,494.84,493.0,497.67,497.61]},{"panel":1,"label":"the month being predicted","color":"var(--chart-amber, #c0781a)","kind":"scatter","x":[1.09979],"y":[9.52556],"sx":[284.09],"sy":[498.65]},{"panel":2,"label":"k = 20 neighbours, standardised","color":"var(--chart-maroon, #b6262c)","kind":"line","x":[0.0,5.0,10.0,20.0,50.0],"y":[12.386,12.8717,13.2929,13.8719,14.5954],"sx":[74.15,117.87,161.6,249.06,511.42],"sy":[815.26,781.79,752.76,712.85,662.98]},{"panel":2,"label":"linear regression","color":"var(--chart-blue, #3b6ea5)","kind":"line","x":[0.0,5.0,10.0,20.0,50.0],"y":[12.7845,12.7911,12.8284,12.8536,12.8133],"sx":[74.15,117.87,161.6,249.06,511.42],"sy":[787.8,787.34,784.77,783.04,785.81]}]} 2026-10-31T01:55:44.293153 image/svg+xml Matplotlib v3.11.2, https://matplotlib.org/ 1 0 0 1 0 1 1 0 2 1 0 3 k, the number of neighbours averaged (log scale) 0.0 2.5 5.0 7.5 10.0 12.5 15.0 17.5 20.0 RMSE (volatility points) 1. The k dial runs backwards: small k memorises, huge k predicts the average training months (seen) test months (unseen) baseline: always predict the training mean −20 −10 0 10 20 30 5-day return (%) 0 20 40 60 80 100 120 140 20-day volatility (% a year) 2. Two features, one query: the twenty nearest months, before and after scaling training months 20 nearest on raw features (volatility decides everything) 20 nearest on standardised features the month being predicted 0 10 20 30 40 50 Number of random noise features added to the seven real ones 12.5 13.0 13.5 14.0 14.5 Test RMSE (volatility points) 3. Useless features hurt a neighbour model far more than a linear one k = 20 neighbours, standardised linear regression
Figure 1. The k curve, the effect of scaling, and useless features added

Real run. Next month’s volatility, 4,930 training rows and 2,205 test rows (one per ticker per trading day), features standardised. Table 3 sweeps kk from 1 to all of them; the score is RMSE, the typical size of a miss in points of volatility, and lower is better:

Table 3. Training and test RMSE by k, next month’s volatility
kkTraining RMSETest RMSEReading
10.00\key{0.00}17.05\risk{17.05}memorised: barely better than guessing the mean (17.69)
511.1613.32clearly better than the mean
2012.5412.39
10013.6012.27\key{12.27}the bottom, give or take (k=50k = 50 scores 12.19); better than a linear model (12.78)
50015.1312.86
4,930, all of them19.5917.69the global mean, by construction

The curve is the first panel of Figure 1, and it is the bias and variance trade-off drawn with the x-axis reversed. At k=1k = 1 the model reproduces the training rows perfectly and on new rows is barely better than guessing the training average every time, 17.05 against 17.69. At k=100k = 100 it averages a hundred similar rows and beats the linear model, 12.27 against 12.78. A model like this has no fixed set of parameters: it keeps the training data, so its size grows with the data (Murphy calls such models nonparametric).

Scaling: distance uses raw numbers

Distance adds squared differences across features, so a feature measured in bigger units dominates. In the training rows, the 20-day return has a spread (standard deviation) of 0.100 and the Bollinger position a spread of 0.65; between those two, the raw distance is nearly all Bollinger position. The second panel of Figure 1 draws one test row and its twenty nearest training rows, first on raw features and then on standardised ones (each feature rescaled to spread 1). The two sets of “nearest” rows are different rows. Standardising takes the test RMSE at k=20k = 20 from 13.58 to 12.39\key{12.39}.

Fit the scaler on the training rows and apply it to the test rows; fitting it on everything leaks the test set’s mean into the model. Trees do not need any of this, because a split only asks whether a value is above or below a threshold.

Cost: free to train, expensive to ask

Table 4. Time to fit and to predict 2,205 rows, neighbours against a line
ItemFitPredict 2,205 rows
Nearest neighbours, k=20k = 201.1 ms19.2\risk{19.2} ms
Linear regression0.6 ms0.1 ms

Training is nothing more than storing the rows, the 1.1 ms in Table 4. Every prediction then scans all NN rows across all pp features, so the cost per query is N×pN \times p and it grows with the data. A linear model costs pp per query. The gap is about 190 times here and grows with every row added. Daily allocation over a few thousand rows is trivial and comes with an explanation (“this month looks like these twenty”); a high-frequency book with half a million rows per tick is not a job for this model. Tree indexes speed up the search in low dimensions and fall back to brute force above about twenty features.

The curse of dimensionality

In many dimensions, everything is far from everything. Spread points evenly in a unit box and ask how wide a sub-box must be to contain 10% of them (Table 5):

Table 5. Side of a box holding 10% of evenly spread points, by dimension
Dimensions ppSide of the box, 0.11/p0.1^{1/p}Meaning
10.10a small local window
20.32
100.7979% of every feature’s range
1000.98the “neighbourhood” is almost everything

With a hundred features, the ten percent of points nearest you span nearly the whole range of every feature; they are not neighbours in any useful sense. Scaling fixes units; it does not fix this. Real run. Table 6 adds random noise columns to the seven real features:

Table 6. Test RMSE as noise features are added, neighbours against a line
Useless features addedNearest neighbours, test RMSELinear regression, test RMSE
012.3912.78
512.8712.79
1013.2912.83
2013.8712.85
5014.60\risk{14.60}12.81

Noise features push distances around at random, so the neighbours chosen are less and less the right ones; the linear model simply gives the noise columns weights near zero. Five noise columns are enough to hand the lead back to the linear model. The fixes are to drop irrelevant features, or to give each feature its own weight chosen on validation data, which a tree does on its own by ignoring features it never splits on.

When to use it

Nearest neighbours wins when the true relationship is complicated overall but simple locally, there are few features, and there are many rows per feature. A linear model wins when the truth is nearly linear, when rows are scarce, or when a feature list is long. If the neighbour model is only slightly better, as it is here (12.27 against 12.78), keep the linear one: it is cheaper to run and comes with coefficients you can read.

Traps

Table 7 lists the traps.

Table 7. Traps in nearest neighbours
TrapTruth
Larger kk overfits moreSmaller kk does; the dial runs backwards
k=Nk = N predicts each point’s own labelIt predicts the global mean for everything
Larger kk lowers training errorIt raises it
Scaling cures high dimensionsIt cures units, not the curse
More features always helpIrrelevant ones distort every distance
It is fast because training is cheapPrediction is the cost, and it grows with the data
Appending new rows handles driftStale rows stay in the pool

The code

Made-up data, so it runs as pasted (pip install numpy scikit-learn): two useful features on very different scales, the way a 20-day return (numbers like 0.05) and a Bollinger position (numbers like 1) differ. X holds the features, one row per case; y holds the answer for each row. The first 1,500 rows train, the last 500 test.

import numpy as np
from sklearn.metrics import root_mean_squared_error
from sklearn.neighbors import KNeighborsRegressor
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

# Made-up data: two useful features on very different scales
rng = np.random.default_rng(0)
n = 2000
small = rng.normal(0, 0.05, n)      # like a 20-day return: numbers around ±0.05
big = rng.normal(0, 1.0, n)         # like a Bollinger position: numbers around ±1
y = 20 + 80 * small + 4 * big + rng.normal(0, 3, n)   # the answer depends on both, plus noise
X = np.c_[small, big]               # one row per case, one column per feature
X_train, X_test, y_train, y_test = X[:1500], X[1500:], y[:1500], y[1500:]

def test_rmse(model, Xtr=X_train, Xte=X_test):
    model.fit(Xtr, y_train)                         # "fit" only stores the rows (and fits the scaler)
    return round(root_mean_squared_error(y_test, model.predict(Xte)), 2)

# 1. The k dial runs backwards: small k memorises, k = all rows predicts the average
for k in (1, 20, 100, 1500):
    print(f"k = {k:4}: test RMSE", test_rmse(make_pipeline(StandardScaler(), KNeighborsRegressor(k))))

# 2. Scaling: without it, distance is nearly all the big-number feature
print("k = 20, raw features:   ", test_rmse(KNeighborsRegressor(20)))
print("k = 20, scaled features:", test_rmse(make_pipeline(StandardScaler(), KNeighborsRegressor(20))))

# 3. Useless features: add 50 columns of pure noise and every distance gets blurred
noise_tr, noise_te = rng.normal(size=(1500, 50)), rng.normal(size=(500, 50))
print("k = 20, plus 50 noise columns:",
      test_rmse(make_pipeline(StandardScaler(), KNeighborsRegressor(20)),
                np.c_[X_train, noise_tr], np.c_[X_test, noise_te]))

It prints:

  • The dial: test RMSE 4.06 at k=1k = 1, the best 3.04 at k=20k = 20, 3.19 at k=100k = 100, and 6.41 with every row averaged, which is just the overall mean.
  • Scaling: 3.56 on raw features, 3.04 once scaled.
  • Useless features: 5.3 with fifty noise columns added.

The noise added to y has a spread of 3, so 3 is the floor no model can beat here.

The script behind the real numbers

The tables above come from examples/nearest-neighbours/knn.py, which runs on the blog’s five tickers. To run it yourself, clone the examples repo: its README has a three-command Docker setup, or run uv run nearest-neighbours/knn.py.

Summary

Table 8 is the post in six lines.

Table 8. Nearest neighbours in six rules
IdeaRuleRemember it as
Predictionaverage of the kk nearest stored rowsthe data is the model
The kk dialsmall kk complex, big kk simplebackwards from depth and degree
Sizegrows with the datait keeps every training row
Scalingstandardise, fitted on training rowsdistance uses raw numbers
Costfit free, predict N×pN \times pthe bill comes at query time
Cursebox side =0.11/p= 0.1^{1/p} to hold 10% of the pointsneighbourhoods explode; drop junk features

References

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