Nearest neighbours: predict what similar past cases did

ml basics

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. Next
  11. 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 for every 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}
CaseWorkingResult
Distance from (1, 2) to (4, 6)√(3² + 4²)5; the Manhattan distance, 3 + 4, is 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 is distance weighting: closer neighbours count for more. It helps a little on smooth problems and does nothing on this one (8.55 against 8.44 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.

kkWhat it predictsTraining errorBias / varianceVerdict
1the nearest point’s own value0low / highestmemorises
mediuma local averagesomebalancedchoose on validation
NNthe global mean, for every queryhighesthighest / zerounderfits

{"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)","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,3685.0],"y":[0.0,7.23526,8.48013,9.50531,10.6127,11.4848,12.3188,13.0232,13.8513,15.0196,16.0039,16.9886,17.7055],"sx":[74.15,111.05,132.64,159.85,196.75,233.66,282.45,319.36,356.27,405.06,441.97,478.88,511.42],"sy":[189.05,126.82,116.12,107.3,97.78,90.27,83.1,77.04,69.92,59.87,51.41,42.94,36.77]},{"panel":0,"label":"test months (unseen)","color":"var(--chart-maroon, #b6262c)","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,3685.0],"y":[14.9208,12.7593,11.6228,10.4292,9.22051,8.43863,7.66303,7.50328,7.50384,7.89625,8.53005,9.24307,9.83918],"sx":[74.15,111.05,132.64,159.85,196.75,233.66,282.45,319.36,356.27,405.06,441.97,478.88,511.42],"sy":[60.72,79.31,89.09,99.35,109.75,116.47,123.14,124.52,124.51,121.14,115.69,109.56,104.43]},{"panel":0,"label":"baseline: always predict the training mean","color":"var(--chart-grey, #a3a19c)","x":[0.0,1.0],"y":[9.83918,9.83918],"sx":[-122533.92,74.15],"sy":[104.43,104.43]},{"panel":1,"label":"20 nearest on raw features (volatility decides everything)","color":"var(--chart-blue, #3b6ea5)","x":[1.2899,1.35758,1.75012,1.77973,2.02548,2.22762,2.65671,2.78953,2.99459,3.00502,3.06114,3.27659,3.95068,4.22196,4.59141,5.22417,5.79423,7.00726,7.23557,7.91904],"y":[27.6371,20.5078,27.5932,28.1764,16.7578,21.6271,28.3639,40.0518,23.6581,23.8278,28.753,19.9866,27.7839,17.1156,28.2137,21.93,21.8743,73.2794,23.7585,53.5246],"sx":[337.18,337.84,341.65,341.94,344.32,346.29,350.45,351.74,353.73,353.83,354.38,356.47,363.01,365.65,369.23,375.38,380.91,392.69,394.91,401.54],"sy":[475.71,483.95,475.76,475.08,488.29,482.66,474.87,461.35,480.31,480.11,474.42,484.55,475.54,487.87,475.04,482.31,482.37,422.92,480.19,445.77]},{"panel":1,"label":"20 nearest on standardised features","color":"var(--chart-maroon, #b6262c)","x":[3.69398,4.52495,4.59141,4.92307,4.9351,5.01841,5.18837,5.33635,6.38008,6.89169,7.05998,7.12577,7.23557,7.53376,7.59666,8.6138,8.66462,9.21808,9.564,11.0113],"y":[36.1099,37.1584,28.2137,34.4002,28.5654,33.6946,39.1296,36.9841,39.7747,40.0799,35.9052,40.1055,23.7585,39.1983,32.8112,34.5539,37.1722,45.9356,38.9896,39.4869],"sx":[360.52,368.59,369.23,372.45,372.57,373.38,375.03,376.47,386.6,391.57,393.2,393.84,394.91,397.8,398.41,408.29,408.78,414.15,417.51,431.56],"sy":[465.91,464.69,475.04,467.88,474.63,468.7,462.41,464.9,461.67,461.32,466.14,461.29,480.19,462.34,469.72,467.71,464.68,454.54,462.58,462.0]},{"panel":1,"label":"the month being predicted","color":"var(--chart-amber, #c0781a)","x":[9.28147],"y":[33.653],"sx":[414.77],"sy":[468.75]},{"panel":2,"label":"k = 20 neighbours, standardised","color":"var(--chart-maroon, #b6262c)","x":[0.0,5.0,10.0,20.0,50.0],"y":[8.43863,8.34874,8.57937,8.68882,9.10272],"sx":[74.15,117.87,161.6,249.06,511.42],"sy":[726.14,734.69,712.75,702.34,662.98]},{"panel":2,"label":"linear regression","color":"var(--chart-blue, #3b6ea5)","x":[0.0,5.0,10.0,20.0,50.0],"y":[7.50142,7.51626,7.57246,7.56533,7.67589],"sx":[74.15,117.87,161.6,249.06,511.42],"sy":[815.26,813.85,808.51,809.19,798.67]}]} 2026-10-15T23:51:15.009651 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 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 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 7.6 7.8 8.0 8.2 8.4 8.6 8.8 9.0 Test RMSE (volatility points) 3. Useless features hurt a neighbour model far more than a linear one k = 20 neighbours, standardised linear regression

Real run. Next month’s volatility, 3,685 training months, 2,405 test months, features standardised, kk from 1 to all of them:

kkTraining RMSETest RMSEReading
10.00\key{0.00}14.92\risk{14.92}memorised: worse than guessing the mean (9.84)
59.5110.43still worse than the mean
2011.488.44
10013.027.50\key{7.50}the bottom; the same score as a linear model
50015.027.90
3,685, all of them17.719.84the global mean, by construction

The curve is the first panel of the chart, and it is the bias and variance trade-off drawn with the x-axis reversed. At k=1k = 1 the model reproduces the training months perfectly and does worse on new months than saying “22” every time. At k=100k = 100 it averages a hundred similar months and matches the linear model. The effective number of parameters is about N/kN / k: 3,685 at k=1k = 1, 37 at k=100k = 100.

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 of 0.079 and the Bollinger position a spread of 0.64; the raw distance is nearly all Bollinger position. The second panel of the chart draws one test month and its twenty nearest training months, first on raw features and then on standardised ones (each feature rescaled to spread 1). The two sets of “nearest” months are different months. Standardising takes the test RMSE at k=20k = 20 from 9.01 to 8.44\key{8.44}.

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

ItemFitPredict 2,405 rows
Nearest neighbours, k=20k = 200.9 ms17.6\risk{17.6} ms
Linear regression0.6 ms0.1 ms

Training is nothing more than storing the rows. 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 180 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:

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. Add random noise columns to the seven real features:

Useless features addedNearest neighbours, test RMSELinear regression, test RMSE
08.447.50
108.587.57
208.697.57
509.10\risk{9.10}7.68

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. 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, keep the linear one: it is cheaper to run and comes with coefficients you can read.

Traps

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

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

for k in (1, 5, 20, 100, 500):
    model = make_pipeline(StandardScaler(), KNeighborsRegressor(n_neighbors=k))
    model.fit(X_train, y_train)                           # stores the rows; the scaler is fitted here
    rmse = np.sqrt(np.mean((y_test - model.predict(X_test)) ** 2))

weighted = make_pipeline(StandardScaler(), KNeighborsRegressor(20, weights="distance"))

Summary

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
Capacityabout N/kN / k parametersk=100k = 100 on 3,685 rows is 37
Scalingstandardise, fitted on training rowsdistance uses raw numbers
Costfit free, predict N×pN \times pthe bill comes at query time
Cursebox side =q1/p= q^{1/p}neighbourhoods explode; drop junk features

Next

Bias and variance: where the error curve above comes from, and why every model has a dial like kk.

References