Skip to main content

k-Nearest Neighbors

Every model so far has fit parameters during training and thrown the raw data away afterward. kNN does the opposite: it stores the entire training set verbatim and defers all the actual work to inference time, where it asks "which stored examples look most like this new one?"

Key idea

kNN stores the training set and defers all work to inference โ€” which makes it trivially flexible and hopeless at scale.

The algorithmโ€‹

To classify a new point xx: compute its distance to every training point, take the kk closest, and predict the majority class among them (or the average value, for regression).

Choosing kโ€‹

Three decision boundaries for k of 1, 15 and 75 on the same two-moons data, progressing from jagged to nearly linear
k is the biasโ€“variance dial made visible. At k = 1 every noisy point carves out its own island; at k = 75 the boundary is almost linear and the moons are lost. Nothing about the model changes but that one number.

Small kk (e.g. k=1k=1) fits the local structure tightly โ€” low bias, high variance, sensitive to noise (a single mislabelled neighbour flips the prediction). Large kk smooths the decision boundary โ€” higher bias, lower variance. This is the Bias-Variance Tradeoff made directly visible by a single hyperparameter.

Distance metricsโ€‹

  • Euclidean: โˆ‘i(xiโˆ’yi)2\sqrt{\sum_i (x_i - y_i)^2} โ€” the default, sensitive to feature scale.
  • Manhattan: โˆ‘iโˆฃxiโˆ’yiโˆฃ\sum_i |x_i - y_i| โ€” less sensitive to outlier dimensions.
  • Cosine: 1โˆ’xโ‹…yโˆฅxโˆฅโˆฅyโˆฅ1 - \frac{x \cdot y}{\|x\|\|y\|} โ€” measures angle, not magnitude; standard for text and embeddings.
  • Hamming: fraction of differing positions โ€” for categorical or binary features.

Feature scaling is not optionalโ€‹

Distance metrics are dominated by whichever feature has the largest numeric range โ€” a feature measured in thousands (income) will swamp one measured in single digits (age) unless both are standardised first (Data Preprocessing and Features).

Weighted votingโ€‹

Instead of an unweighted majority vote among the kk neighbours, weight each neighbour's vote by the inverse of its distance โ€” closer neighbours count more, which softens the effect of choosing kk slightly too large.

kNN for regressionโ€‹

The same algorithm, predicting the (weighted) average of the kk neighbours' target values instead of a majority vote.

Computational cost and approximate methodsโ€‹

A naive query is O(nd)O(nd) โ€” compare against every training point. KD-trees and ball trees organise the training set spatially to prune most comparisons, reducing average query cost to roughly O(logโกnโ‹…d)O(\log n \cdot d) in low dimensions โ€” but their advantage evaporates in high dimensions, where nearly every point ends up needing to be checked anyway (see below). For truly large-scale settings, approximate nearest neighbour methods (e.g. HNSW, used throughout vector-store retrieval) trade a small amount of accuracy for large speedups.

SymbolMeaning
kknumber of neighbours consulted
nnnumber of training points
ddnumber of features

The curse of dimensionality hits kNN hardestโ€‹

As shown in Curse of Dimensionality, pairwise distances converge toward a single value as dimension grows โ€” which means "the kk nearest points" stops being meaningfully different from "kk arbitrary points" once dimensionality is high. kNN is the algorithm most directly and severely damaged by this effect.

Code: kNN from scratch, boundary comparison, timing against sklearnโ€‹

knn_demo.py
import time
import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import make_moons
from sklearn.neighbors import KNeighborsClassifier

def knn_predict(X_train, y_train, X_query, k):
preds = np.zeros(len(X_query), dtype=int)
for i, q in enumerate(X_query):
dists = np.linalg.norm(X_train - q, axis=1) # vectorised Euclidean distance
nearest = np.argsort(dists)[:k]
preds[i] = np.bincount(y_train[nearest]).argmax()
return preds

X, y = make_moons(n_samples=300, noise=0.25, random_state=0)

xx, yy = np.meshgrid(np.linspace(X[:, 0].min()-0.5, X[:, 0].max()+0.5, 100),
np.linspace(X[:, 1].min()-0.5, X[:, 1].max()+0.5, 100))
grid = np.c_[xx.ravel(), yy.ravel()]

fig, axes = plt.subplots(1, 3, figsize=(15, 4))
for ax, k in zip(axes, [1, 5, 50]):
preds = knn_predict(X, y, grid, k).reshape(xx.shape)
ax.contourf(xx, yy, preds, alpha=0.4)
ax.scatter(X[:, 0], X[:, 1], c=y, edgecolors="k", s=15)
ax.set_title(f"k={k}")
plt.savefig("knn_boundaries.png")

# --- Timing: hand-written vs sklearn's tree-based backend ---
start = time.perf_counter()
knn_predict(X, y, grid[:500], k=5)
print(f"hand-written (brute force): {time.perf_counter() - start:.4f}s")

sk_knn = KNeighborsClassifier(n_neighbors=5).fit(X, y)
start = time.perf_counter()
sk_knn.predict(grid[:500])
print(f"sklearn (tree-based): {time.perf_counter() - start:.4f}s")

At k=1k=1 the boundary hugs every training point tightly (jagged, high variance); at k=50k=50 it smooths into a much simpler shape (higher bias) โ€” the same tradeoff described above, made visible.

When to reach for thisโ€‹

Data sizesmall-to-moderate โ€” no separate training cost, but query cost scales with nn
Feature countlow, ideally after dimensionality reduction
Interpretabilityhigh (can inspect the actual neighbours)
Training costeffectively zero (just stores the data)
Inference costhigh, O(nd)O(nd) per query without a spatial index

See alsoโ€‹