ml-models / Classical ML / Nearest neighbors intro

Nearest neighbors

The simplest learner there is: keep every training point, and answer a question about a new point by looking at the training points closest to it. This page covers the k-NN vote and its weights, the choice of distance and of k, why distances stop meaning much in high dimensions, and how KD trees and ball trees find neighbors without measuring against every point.

On this page
  1. Problem
  2. Intuition
  3. Mechanics
  4. Distance
  5. Vote and weights
  6. Finding the neighbors
  7. Worked example
  8. Implementation
  9. Tradeoffs
  10. Connections
  11. Read first
  12. Read next
  13. References

Problem

Most models compress the training set into parameters: a line, a tree, a network. That compression is an assumption about the shape of the answer. Sometimes we would rather not assume a shape at all and just say "points that look alike should get alike labels". Nearest neighbors does exactly that. It needs a way to say what "look alike" means (a distance), a rule to turn nearby labels into a prediction, and a way to find nearby points fast once the training set has millions of rows.

Intuition

To label a new point, find the k training points closest to it and let them vote. With k = 1 the prediction copies the single closest point, so the decision boundary bends around every training example, noise included. Larger k averages over a wider neighborhood, giving smoother boundaries but blurring small regions of the other class. This is the bias-variance tradeoff with one knob. Training is free (store the data); all the work happens at prediction time.

The diagram shows 16 training points in two classes and a query at (4.8, 5.0). Each dashed circle encloses the k nearest points for one value of k, and the vote changes as the circle grows.

The k-NN vote at one query for growing k Sixteen points: class 0 drawn as circles on the lower left, class 1 as squares on the upper right. A query point at 4.8, 5 sits between them. Dashed circles around it enclose the 1, 3, 5 and 7 nearest points. The table on the right lists the votes: k equal to 1 and 3 predict class 0, k equal to 5 predicts class 1, k equal to 7 and 9 predict class 0. k=1 k=3 k=5 k=7 x from 0 to 10, y from 0 to 10 class 0 class 1 query k votes 0 : 1 prediction 1 1 : 0 class 0 3 2 : 1 class 0 5 2 : 3 class 1 7 4 : 3 class 0 9 5 : 4 class 0 The answer flips twice as k grows: the query sits on the class boundary.

Mechanics

Distance

Almost everything uses the Minkowski distance with exponent p:

dp(a, b) = (Σj |aj − bj|p)1/p

p = 2 is Euclidean distance (scikit-learn's default, metric="minkowski", p=2), p = 1 is Manhattan distance, and p → ∞ is Chebyshev distance, the largest single-coordinate gap. For p ≥ 1 these are true metrics (the triangle inequality holds), which is what lets trees prune. Each coordinate contributes on its own scale, so a feature measured in thousands swamps one measured in fractions. That is why k-NN almost always sits behind standardization. For text and embeddings, cosine distance on normalized vectors is more common. It ranks neighbors the same way as Euclidean distance on unit vectors (see vectors and dot products).

Vote and weights

Given the k nearest training points with labels yi and distances di, a classifier returns the label with the largest total weight, and a regressor returns the weighted mean:

ŷ = argmaxc Σi wi·[yi = c]     ŷ = Σi wi yi / Σi wi

With weights="uniform", every wi = 1. With weights="distance", scikit-learn's _get_weights sets wi = 1/di. If the query lands exactly on one or more training points, those points get weight 1 and the rest get 0, which avoids dividing by zero. predict_proba returns each class's share of the total weight. When two labels tie on uniform votes, predict takes the mode, which picks the class that sorts first. The docstring also warns that when neighbors k and k+1 are equally far away but have different labels, the result depends on the order of the training data.

Brute force measures the query against all n training points: O(n·d) per query, and trivially parallel. Trees spend O(n log n) once, at fit time, so that most queries can skip most of the data.

  • A KD tree splits the node's points at the median of the dimension with the largest spread (find_node_split_dim, then partition_node_indices), and stores each node's bounding box. Nodes live in a flat array, and node i's children are 2i+1 and 2i+2.
  • A ball tree uses the same splitting, but each node stores a centroid and a radius, the largest distance from the centroid to any of the node's points. Its lower bound, max(0, d(q, centroid) − radius), works for any true metric, not just axis-aligned ones.

Before building, the tree fixes its depth: n_levels = int(log2(max(1, (n − 1) / leaf_size)) + 1) and n_nodes = 2**n_levels − 1. Splitting at the median then leaves every leaf with between leaf_size and 2·leaf_size points. The query is depth-first with a max-heap of the best k distances so far. A node whose lower bound already exceeds the k-th best distance is trimmed with all its points. A leaf is scanned point by point. Otherwise the query visits the nearer child first, so the heap tightens early and prunes more. Internally the KD tree compares "reduced" distances (squared Euclidean, which keeps the same order) to skip square roots.

A KD tree query that scans one leaf and trims the other three Left: the 16 points are cut first at x equals 5, then the left half at y equals 4 and the right half at y equals 7, giving four leaf boxes of four points each. The query sits in the leaf box covering x 1 to 5 and y 4 to 6, which is highlighted. The other three leaf boxes are dashed. Right: the tree has root node 0, children 1 and 2, and leaves 3 to 6. Nodes 0, 1 and leaf 4 are visited. Leaf 3, node 2's leaves 5 and 6 are trimmed because their boxes are farther from the query than the nearest point found, at distance 1.02. Only 4 of 16 distances are computed. leaf 3 leaf 4 leaf 5 leaf 6 x = 5 node 0 x at 5 node 1 y at 4 node 2 y at 7 leaf 3 trimmed leaf 4 scanned 4 leaf 5 trimmed leaf 6 trimmed k = 1: 4 of 16 distances computed; nearest point at distance 1.02.

The left half splits on y at 4 and the right half at 7, so each leaf box shown here is the tight box around its own four points, which is what init_node stores and min_rdist measures against.

ComponentShape/ParamsMeaningSource
KNeighborsClassifier.__init__ n_neighbors=5, weights="uniform", algorithm="auto", leaf_size=30, p=2, metric="minkowski" The defaults: five neighbors, equal votes, Euclidean distance, the search method picked at fit time. _classification.py 198-219
fit / predict X (n, d) → labels (m,) Fit stores the data (and builds a tree). Predict calls kneighbors, then takes the mode or the weighted mode of the neighbor labels. _classification.py 225-312
predict_proba (m, n_classes) Each class's share of the (weighted) votes among the k neighbors. _classification.py 314-417
KNeighborsRegressor.predict (m,) or (m, n_outputs) Mean of the neighbor targets, or their weighted sum divided by the sum of weights. _regression.py 229-272
_get_weights dist (m, k) "uniform" gives no weights; "distance" gives 1/d, with exact matches getting weight 1 and the others 0. _base.py 74-117
algorithm="auto" metric, d, k, n Brute force if the metric is precomputed, d > 15, or k ≥ n/2. Otherwise a KD tree when the metric allows it, else a ball tree. _base.py 615-648
kneighbors → dist, ind (m, k) The shared neighbor search behind every k-NN estimator and NearestNeighbors. _base.py 756-958
NearestNeighbors unsupervised The same search with no labels: return neighbors, for retrieval or as a building block. _unsupervised.py 10-179
Tree size n_levels, n_nodes = 2**n_levels − 1 The depth is fixed before building, so leaves end up with between leaf_size and 2·leaf_size points. _binary_tree.pxi.tp 870-877
find_node_split_dim → j The dimension with the largest spread (max − min) among the node's points. _binary_tree.pxi.tp 595-645
_recursive_build node i → 2i+1, 2i+2 Stop at a leaf; otherwise partition at the median n_mid = n_points / 2 and recurse. _binary_tree.pxi.tp 1033-1083
_query_single_depthfirst heap of k Trim a node whose bound exceeds the heap's largest, scan a leaf, or visit the nearer child first. _binary_tree.pxi.tp 1603-1656
KD init_node / min_rdist bounds (2, n_nodes, d) Per-node lower and upper corners, and the reduced distance from a point to that box. _kd_tree.pyx.tp 73-147
Ball init_node / min_dist centroid (d,), radius Centroid is the mean of the node's points, radius the farthest one. The bound is max(0, d(q, c) − r). _ball_tree.pyx.tp 86-156

Worked example

One pair, four distances. From (0, 0) to (3, 4), the Minkowski distance shrinks as p grows:

MetricpDistance
manhattan17.000
euclidean25.000
minkowski34.498
chebyshev∞4.000

The neighbor order can change with p, so the metric is a modelling choice, not a detail.

The vote at (4.8, 5.0). The seven nearest training points, closest first:

RankPointClassDistance1/d
1(5.0, 4.0)01.0200.981
2(6.0, 4.5)11.3000.769
3(3.0, 6.0)02.0590.486
4(7.0, 5.0)12.2000.455
5(6.0, 7.0)12.3320.429
6(3.0, 3.0)02.6910.372
7(2.0, 4.0)02.9730.336

With k = 5 and uniform weights the vote is 2 to 3 for class 1. With distance weights it is 1.466 to 1.653, still class 1, so predict_proba gives 0.530 for class 1 instead of 0.600. At k = 7 two more class-0 points join and the uniform vote becomes 4 to 3 for class 0.

Matching scikit-learn's docstrings. With training inputs [0], [1], [2], [3] and labels 0, 0, 1, 1:

  • A classifier with k = 3 predicts class 0 at 1.1. The neighbors are 1, 2 and 0, with labels 0, 1, 0.
  • At 0.9 the neighbors are 1, 0 and 2, so predict_proba is [0.667, 0.333].
  • A regressor with k = 2 predicts 0.5 at 1.5, the mean of the targets 0 and 1.

These are the outputs printed in the KNeighborsClassifier and KNeighborsRegressor docstrings.

Choosing k. Leave-one-out error on the 16 points: predict each point from the other 15.

kErrorsError rate
12 / 160.125
30 / 160.000
50 / 160.000
71 / 160.062
91 / 160.062
1516 / 161.000

k = 1 overfits: two points near the boundary copy a neighbor from the other class. Errors are lowest at 3 and 5. At k = 15 every point is wrong. With one point held out, its own class has 7 left and the other class has 8, so the vote always goes to the other class. A large k turns k-NN into "predict the majority of the rest". In practice, pick k with cross-validation, and prefer odd k for two classes to avoid ties.

The curse of dimensionality. Draw 200 uniform points in the unit cube [0, 1]d and one more as a query (fixed seed). Then compare the nearest and farthest distances:

dNearestFarthestNearest / farthest
10.0010.8430.001
20.0451.1010.041
100.6281.5880.395
1003.2404.5490.712
100012.39613.5780.913

In one dimension the nearest point is over a thousand times closer than the farthest. In 1000 dimensions it is only 1.10 times closer, so "nearest" barely means anything. Here is a second view. A sub-cube that holds 1% of uniform data needs edge 0.011/d: 0.01 for d = 1, 0.10 for d = 2, 0.63 for d = 10, 0.95 for d = 100. In 100 dimensions the "local" neighborhood spans 95% of every axis. k-NN works in high dimensions only when the data actually lies near a low-dimensional structure, which is what learned embeddings try to produce.

The tree at work.

  • Size: with 16 points and leaf_size=2, n_levels = 3 and n_nodes = 7. That is four leaves of four points, within [2, 4]. With 100,000 points and the default leaf_size=30, it is 12 levels and 4,095 nodes.
  • Build: the root's points spread 8 along both axes, so the first dimension wins the tie. The root splits at x = 5.
  • Query with k = 1: the search goes to node 1, then leaf 4, and finds point (5.0, 4.0) at distance 1.020. Leaves 3, 5, 6 are trimmed because their boxes are farther away than that. It computes 4 of 16 distances.
  • Query with k = 5: the fifth-best distance stays large, so no box can be ruled out and the tree scans all 16 points. That is why "auto" uses brute force once k ≥ n/2.
  • Ball bound: the class-0 points have centroid (2.625, 3.375) and radius 2.652. That gives the query a lower bound of 0.063, below the true nearest distance 1.020. It is a valid bound, but a loose one.

Implementation

from sklearn.model_selection import GridSearchCV
from sklearn.neighbors import KNeighborsClassifier, KDTree
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

# Scale first: every feature contributes to the distance on its own scale.
model = make_pipeline(StandardScaler(), KNeighborsClassifier())
search = GridSearchCV(
    model,
    {"kneighborsclassifier__n_neighbors": [1, 3, 5, 7, 9, 15],
     "kneighborsclassifier__weights": ["uniform", "distance"]},
    cv=5,
)
search.fit(X_train, y_train)
proba = search.predict_proba(X_test)        # (m, n_classes), vote shares

# The tree on its own, for retrieval.
tree = KDTree(X_train, leaf_size=30)
dist, ind = tree.query(X_test[:3], k=5)     # each (3, 5), nearest first

Fitting the scaler inside the pipeline keeps the test folds out of the means and variances (see data leakage). The tree is pure Cython with no learned parameters. Its only choices are the metric and leaf_size, which trades tree depth against per-leaf brute force. Larger leaf_size means a shallower tree and more scanning, smaller means more pruning overhead. Results are identical either way.

Tradeoffs

  • No training, slow prediction. Fit is cheap, but the model is the data: memory grows with n, and each query costs anywhere from O(log n) (a tree in low dimension) to O(n·d) (brute force).
  • Flexible boundaries. k-NN can draw any shape of class region and adapts as data is added, but small k memorizes noise (overfitting) and large k washes out minority regions.
  • Everything rides on the distance. Unscaled or irrelevant features distort neighborhoods. k-NN has no built-in feature selection, so one noisy column hurts every prediction.
  • Exact trees fade with dimension. As distances concentrate, box and ball bounds stop ruling nodes out, which is why "auto" switches to brute force above 15 features. Embedding search at hundreds of dimensions and millions of vectors uses approximate indexes instead. They trade a little recall for orders of magnitude in speed.
  • Calibration. predict_proba can only take values j/k with uniform weights, so with k = 5 every probability is a multiple of 0.2.
Why does k-NN need feature scaling while a decision tree does not?

A tree compares one feature at a time against a threshold, so rescaling a feature just moves the threshold. k-NN adds all features into one distance, so a feature with a larger numeric range dominates that sum.

Why can a KD tree prune when k = 1 but not when k = 5 in the worked example?

A node is trimmed only when its box is farther away than the current k-th best distance. With k = 1 that distance is 1.02, and three leaf boxes lie beyond it. With k = 5 it is 2.33, which reaches into every box, so every leaf must be scanned.

What does weights="distance" predict for a query that exactly matches a training point?

The matching point (or points) gets weight 1 and every other neighbor gets weight 0, so the prediction is that point's label or target, not an average.

Connections

References

  • Cover and Hart. Nearest Neighbor Pattern Classification. IEEE Transactions on Information Theory, 1967.
  • Bentley. Multidimensional Binary Search Trees Used for Associative Searching. Communications of the ACM, 1975 (the KD tree).
  • Friedman, Bentley and Finkel. An Algorithm for Finding Best Matches in Logarithmic Expected Time. ACM Transactions on Mathematical Software, 1977.
  • Omohundro. Five Balltree Construction Algorithms. ICSI Technical Report, 1989.
  • Beyer, Goldstein, Ramakrishnan and Shaft. When Is "Nearest Neighbor" Meaningful? ICDT, 1999.
  • Hastie, Tibshirani and Friedman. The Elements of Statistical Learning, 2nd ed., Springer, 2009: section 2.5 (local methods in high dimensions) and chapter 13.
  • scikit-learn 1.9.1: KNeighborsClassifier, KNeighborsRegressor, NearestNeighbors, _binary_tree.pxi.tp.