Skip to content
ML to LLMs

Module 5: Instance-based and probabilistic models 35 min8 min quick readClassical ML

K-nearest neighbors

Predict from the most similar stored examples, compute distances by hand, and see why feature scaling can flip a prediction.

Before this: Vectors and matrices, Workflow, splits, and leakage

Read on GitHub

Quick read: the intuition, interactive visualization, summary, and interview questions. The math, implementation, engineering, and knowledge-check sections are hidden.

Mental model. To label something new, find the K most similar examples you have already seen and let them vote. There is no equation to learn: the training data is the model.

You will learn to

  • Compute Euclidean and Manhattan distances and find the K nearest neighbors by hand.
  • Explain how K controls the trade-off between noisy and over-smoothed predictions.
  • Show, with numbers, why features must be scaled before using distances.
  • Implement KNN from scratch and with scikit-learn, and choose K with cross-validation.
  • Explain the curse of dimensionality and why KNN is slow at prediction time.

Why it matters. KNN is the simplest model that can draw any decision boundary, which makes it a great baseline and a clear lens on two ideas you will meet everywhere: distance in feature space and the bias-variance trade-off. The same nearest-neighbor search, done on embeddings with approximate indexes, powers semantic search and retrieval-augmented generation.

1. Intuition

You move to a new city and want to guess whether a neighborhood is expensive. You look at the five closest houses that sold recently. If four of them were expensive, you guess "expensive." That is KNN with K = 5.

Three choices define the model:

  1. What "close" means. You need a distance between two examples. With numeric features, the usual choice is straight-line (Euclidean) distance.
  2. How many neighbors (K). One neighbor trusts a single, possibly unusual, example. Fifty neighbors average over a wide area and may blur real local differences.
  3. How neighbors combine. Classification uses a majority vote; regression averages the neighbors' targets. Closer neighbors can be given more weight.

KNN does no work at training time; it just stores the data. All the work happens at prediction time, when it compares the new example against every stored one. That makes it a lazy or instance-based learner.

2. Visualization

K-nearest neighbors lab

Synthetic customers: age vs income. Click or drag anywhere to move the query point.

KNN neighborhood around a query point20406050100150age (years)income (thousand $)
did not buy (circles)bought (squares)neighborhood (k-th distance)
In raw units, income spans about 150 while age spans about 50, so distance is dominated by income: the neighborhood is a thin horizontal band of similar incomes, regardless of age. Turn on scaling and compare the leave-one-out accuracy (now 76.7%).

Try it (predict first, then check):

  1. Leave scaling off and drag the query left and right. Predict whether the prediction changes, then check.
  2. Compare leave-one-out accuracy at K = 1 and K = 25 with scaling on. Which generalizes better here?

What to notice:

  • K = 1 draws a region around every single point, including mislabeled or unusual ones. It fits the training data perfectly and generalizes worse (high variance).
  • K = 15 produces a smooth boundary close to the true rule (buy if older than about 45).
  • Raw units silently turn KNN into "find people with similar income." Nothing errors; the model is just wrong.

3. The math

Symbols

SymbolMeaningShape
xix_istored training example ii[p][p]
yiy_iits label (class) or target valuescalar
qqthe query point we want to predict[p][p]
d(q,xi)d(q, x_i)distance between query and example iiscalar
NK(q)N_K(q)the set of the KK training examples closest to qqset of size KK
μj,σj\mu_j, \sigma_jmean and standard deviation of feature jj on the training setscalars

Distances

dEuclidean(q,x)=∑j=1p(qj−xj)2dManhattan(q,x)=∑j=1p∣qj−xj∣d_{\text{Euclidean}}(q, x) = \sqrt{\sum_{j=1}^{p} (q_j - x_j)^2} \qquad d_{\text{Manhattan}}(q, x) = \sum_{j=1}^{p} |q_j - x_j|

Euclidean is the straight line; Manhattan is the city-block path. Manhattan is less dominated by a single large difference because it does not square.

Prediction

y^class=mode⁡{ yi:xi∈NK(q) }y^regression=1K∑xi∈NK(q)yi\hat{y}_{\text{class}} = \operatorname{mode}\{\, y_i : x_i \in N_K(q) \,\} \qquad \hat{y}_{\text{regression}} = \frac{1}{K} \sum_{x_i \in N_K(q)} y_i

Standardization

zj=xj−μjσjz_j = \frac{x_j - \mu_j}{\sigma_j}

After standardizing, every feature has mean 0 and standard deviation 1 on the training set, so one unit means "one standard deviation" for every feature.

Worked example

Five past customers (age in years, income in thousand dollars) and whether they bought. Predict for query q=(45,56)q = (45, 56) with K=3K = 3.

CustomerAgeIncomeBought
A22540
B26600
C44901
D47301
E60571

Step 1: raw differences and Euclidean distances.

Δ\DeltaageΔ\DeltaincomeSquared sumDistance
A22−45=−2322-45=-2354−56=−254-56=-2529+4=533529+4=533533=23.09\sqrt{533}=23.09
B−19-19+4+4361+16=377361+16=37719.4219.42
C−1-1+34+341+1156=11571+1156=115734.0134.01
D+2+2−26-264+676=6804+676=68026.0826.08
E+15+15+1+1225+1=226225+1=22615.0315.03

Step 2: vote. The three nearest are E (15.03, bought), B (19.42, did not), A (23.09, did not). Votes: 2 to 1 for did not buy.

(Manhattan distances are A 25, B 23, C 35, D 28, E 16; same three neighbors, same answer.)

Step 3: standardize. On these five rows, age has μ=39.8\mu = 39.8, σ=14.03\sigma = 14.03 and income has μ=58.2\mu = 58.2, σ=19.12\sigma = 19.12 (population standard deviation). The query becomes

qz=(45−39.814.03,  56−58.219.12)=(0.371,  −0.115)q_z = \left(\frac{45 - 39.8}{14.03},\; \frac{56 - 58.2}{19.12}\right) = (0.371,\; -0.115)

and customer D becomes (47−39.814.03,30−58.219.12)=(0.513,−1.475)\left(\frac{47-39.8}{14.03}, \frac{30-58.2}{19.12}\right) = (0.513, -1.475), so its distance is (0.513−0.371)2+(−1.475+0.115)2=0.020+1.850=1.367\sqrt{(0.513-0.371)^2 + (-1.475+0.115)^2} = \sqrt{0.020 + 1.850} = 1.367. Doing the same for everyone:

ABCDE
Standardized distance1.6421.3701.7791.3671.070

Step 4: vote again. The three nearest are now E (1.070, bought), D (1.367, bought), B (1.370, did not). Votes: 2 to 1 for bought.

Same data, same K, opposite prediction. In raw units, income differences (up to 34) outweighed age differences; D was "far" only because of income. After scaling, D's nearly identical age counts for what it should.

Key distinction

Fit μ\mu and σ\sigma on the training split only, then apply them to validation, test, and production queries. Computing them on all data leaks information from the test set.

4. Implementation

Build it from scratch

The full script is code/05-instance-and-probabilistic/knn_scratch.py. It uses only NumPy for the model and scikit-learn only to check the answer.

Distances. Everything in KNN rests on one function: the distance from a query to every stored row. Broadcasting X - q subtracts the query from all nn rows at once, giving an [n,p][n, p] array; summing over axis 1 gives one distance per row. Cosine distance is included because it is the default for text embeddings: it compares direction and ignores length.

python
def distances(X, q, metric="euclidean"):
    diff = X - q                                    # [n, p] by broadcasting
    if metric == "euclidean":
        return np.sqrt((diff**2).sum(axis=1))       # [n]
    if metric == "manhattan":
        return np.abs(diff).sum(axis=1)
    if metric == "cosine":                          # 1 - cos(angle); ignores length
        norms = np.linalg.norm(X, axis=1) * np.linalg.norm(q)
        return 1 - (X @ q) / np.maximum(norms, 1e-12)
    raise ValueError(metric)

Predict: two vectors point the same way but one is twice as long. What is their cosine distance, and what is their Euclidean distance?

Scaling. Section 3 showed that units can flip the prediction. The scaler is split into fit (learn μ\mu, σ\sigma from training rows) and apply (reuse them on anything), so queries never leak into the statistics. The 1e-12 floor stops a constant feature from dividing by zero.

python
def fit_scaler(X_train):
    mu = X_train.mean(axis=0)
    sigma = np.maximum(X_train.std(axis=0), 1e-12)  # guard: constant feature
    return mu, sigma

def apply_scaler(X, mu, sigma):
    return (X - mu) / sigma                         # same mu, sigma for every query

Predict: if you called fit_scaler on the query alone, what would the standardized query be?

Find K nearest and vote. argsort orders the distances; the first k indices are the neighbors. A Counter tallies their labels. With weighted=True each neighbor's vote is 1/d1/d, so closer neighbors count more (the floor handles d=0d = 0, an exact duplicate).

python
def knn_predict(X_train, y_train, q, k=3, metric="euclidean", weighted=False):
    d = distances(X_train, q, metric)
    nearest = np.argsort(d, kind="stable")[:k]      # indices of the k smallest
    votes = Counter()
    for i in nearest:
        votes[y_train[i]] += 1 / max(d[i], 1e-12) if weighted else 1
    return votes.most_common(1)[0][0], nearest

Predict: with KK equal to the number of training rows and weighted=False, what does this function return for every query?

The curse of dimensionality, measured. Draw 1,000 random points in a unit cube and one random query, then compare the nearest distance to the farthest. In 2 dimensions the nearest point is far closer than the farthest; as dimensions grow, the ratio climbs toward 1 and "nearest" stops meaning much.

python
rng = np.random.default_rng(0)
print("\ndims  nearest/farthest distance from a random query (uniform cube, 1000 points)")
for p in (2, 10, 100, 1000):
    pts = rng.uniform(size=(1000, p))
    d = distances(pts, rng.uniform(size=p))
    print(f"{p:5d}  {d.min() / d.max():.3f}")

Predict: roughly what ratio do you expect at 1,000 dimensions: 0.1, 0.5, or 0.9?

Use the library

scikit-learn's KNeighborsClassifier does the same search (with a KD-tree or ball tree when that is faster than brute force). Put the scaler in a pipeline so every cross-validation fold computes μ\mu and σ\sigma from its own training portion:

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

pipe = make_pipeline(StandardScaler(), KNeighborsClassifier())
search = GridSearchCV(pipe, {"kneighborsclassifier__n_neighbors": [1, 3, 5, 9, 15, 25]}, cv=5)
search.fit(X_train, y_train)          # scaler is refit inside every CV fold: no leakage
print(search.best_params_, search.best_score_)
Scratchscikit-learn
fit_scaler / apply_scalerStandardScaler().fit / .transform
distances(..., metric=)metric= ("euclidean", "manhattan", "cosine")
knn_predict(..., k=)KNeighborsClassifier(n_neighbors=)
weighted=Trueweights="distance"
full scan with argsortalgorithm="brute"; "kd_tree" / "ball_tree" index the data

The script trains both versions on the same synthetic split (K = 15, standardized) and checks that they agree.

Run it

From guide/:

bash
uv run code/05-instance-and-probabilistic/knn_scratch.py

Key output:

text
raw units:     nearest ['E', 'B', 'A'] -> predict 0
standardized:  nearest ['E', 'D', 'B'] -> predict 1

dims  nearest/farthest distance from a random query (uniform cube, 1000 points)
    2  0.007
   10  0.223
  100  0.720
 1000  0.898

scratch vs scikit-learn on 60 test rows: agree on 100%, test accuracy 0.817

The first two lines reproduce the worked example: same data, same K, opposite prediction after scaling. The separate script code/05-instance-and-probabilistic/knn.py prints the worked example's distances step by step, the cross-validated raw versus standardized comparison, and draws the figure in section 2.

5. Engineering

Good use cases. Small to medium datasets with a meaningful distance; quick baselines; recommendation ("users like you"); anomaly scoring (distance to the k-th neighbor); and, at large scale, semantic search over embeddings.

Choosing K. Use cross-validation. Small K means low bias and high variance; large K means the reverse. With two classes, odd K avoids ties. weights="distance" lets closer neighbors count more.

Preprocessing. Standardize numeric features (or use a metric that suits the data, such as cosine for text embeddings). One-hot categorical features and think about how much each should count. Impute missing values; distances cannot use NaN.

Cost. Training is O(1)O(1): store the data. A brute-force prediction costs O(np)O(np) per query, since it measures the distance to all nn examples in pp dimensions. Memory holds the whole training set. KD-trees and ball trees speed this up in low dimensions. For millions of high-dimensional vectors, production systems use approximate nearest neighbor indexes (HNSW, IVF, product quantization) that trade a little recall for orders of magnitude in speed.

The curse of dimensionality. As dimensions grow, the distances between random points concentrate: the nearest and farthest neighbors end up almost equally far away, so "nearest" stops meaning "similar." KNN on hundreds of raw features usually needs feature selection or a learned lower-dimensional representation (PCA, embeddings).

Watch out

Failure modes. Unscaled features (the model quietly ignores small-range features); irrelevant features adding noise to every distance; class imbalance (the majority class wins votes near the boundary); slow, memory-heavy inference at scale; and data drift, because stored examples go stale.

Common mistakes

  • Fitting the scaler on the full dataset before splitting.
  • Choosing K by looking at test-set accuracy.
  • Using K = 1 and reporting its perfect training accuracy.
  • Using Euclidean distance on one-hot or text-count features without thinking about the metric.
  • Forgetting that every prediction scans the training set, then being surprised by latency.

6. Knowledge check

Quiz: K-nearest neighbors

Question 1 of 8
Calculationeasy

What is the Euclidean distance between q=(1,2)q = (1, 2) and x=(4,6)x = (4, 6)?

Practice exercise. Training points: P1 = (1, 1) label 0, P2 = (2, 3) label 1, P3 = (4, 2) label 1, P4 = (0, 3) label 0. Query (2, 2). Compute Euclidean distances and predict with K = 1 and K = 3.

Solution

P1: 1+1=1.414\sqrt{1+1} = 1.414. P2: 0+1=1\sqrt{0+1} = 1. P3: 4+0=2\sqrt{4+0} = 2. P4: 4+1=2.236\sqrt{4+1} = 2.236. K = 1: nearest is P2, predict 1. K = 3: P2 (1), P1 (0), P3 (1), so votes 2 to 1, predict 1.

Implementation challenge. Extend knn_predict to regression (average the neighbors' targets) and to distance weighting (weight 1/d1/d, handling d=0d = 0). Compare uniform and distance-weighted KNN regression on a synthetic y=sin⁡(x)+noisey = \sin(x) + \text{noise} dataset with cross-validation.

Summary

  • KNN predicts from the K closest stored examples: majority vote for classes, average for numbers.
  • K sets the bias-variance trade-off: small K is flexible and noisy, large K is smooth and can underfit.
  • Distances are only meaningful if features are on comparable scales; standardize using training statistics.
  • Training is free but prediction is O(np)O(np) per query, and distances degrade in high dimensions.
  • Nearest-neighbor search on embeddings is the core of modern semantic retrieval.

Next: Naive Bayes

Related: K-means · Word embeddings · Model card: KNN

Parts of this lesson are adapted from AI Engineering from Scratch by Rohit Ghumare (MIT license, notice): Norms and Distances, K-Nearest Neighbors and Distances.

Interview angle

Why must you scale features before using KNN?

Distance sums squared differences across features, so the feature with the largest numeric range dominates. In this lesson's example, raw income differences up to 34 outweighed age differences, and with K=3K = 3 the query was predicted "did not buy." After standardizing each feature to mean 0 and standard deviation 1, customer D, with almost the same age, became a neighbor and the prediction flipped to "bought." Same data, same KK, opposite answer, purely because of units. Standardization makes one unit mean one standard deviation for every feature, giving each an equal say by default. Details: fit μ\mu and σ\sigma on the training split only and apply them to every query; consider robust scaling when outliers are heavy; and remember equal say isn't always right, since irrelevant features still add noise to every distance, so feature selection or weighting matters too.

How do you choose K in KNN? What happens at K = 1 and at K = n?

At K=1K = 1, training error is zero (each point is its own nearest neighbor) and the boundary is jagged, following every noisy point: low bias, high variance. At K=nK = n, every query gets the training set's majority class or global mean: maximal bias, almost no variance. Choose KK in between by cross-validation: plot validation error against KK and take the minimum, or the largest KK within one standard error of it for a simpler model. Practical details: odd KK avoids ties in binary problems; weights="distance" makes results less sensitive to the exact choice; with class imbalance, large KK drifts toward the majority class, so tune against a balanced metric. A starting heuristic is K≈nK \approx \sqrt{n}, but treat it only as a starting point. Never choose KK on the test set, and never report K=1K = 1's training accuracy.

You need nearest neighbors over 100 million 768-dimensional embeddings with a 20 ms latency budget. How do you build it?

Brute force costs O(np)O(np) per query: 108×768≈7.7×101010^8 \times 768 \approx 7.7 \times 10^{10} multiply-adds, and the float32 vectors alone take about 307 GB. That's too slow on CPU and too large for one machine's RAM, so use approximate nearest neighbor search. HNSW builds a navigable multi-layer graph with high recall and low latency, but it's memory-hungry (vectors plus graph links). IVF clusters vectors with K-means and searches only the few nearest lists; combined with product quantization (IVF-PQ), each vector compresses to tens of bytes, so 100M vectors fit in a few GB, at some recall cost. Recover precision by re-ranking the top few hundred candidates with exact distances, and shard across machines if needed. Measure recall@k against brute force on a sample, and tune efSearch or nprobe to trade recall for latency.

What is the curse of dimensionality, and why does it hurt KNN in particular?

In high dimensions, distances concentrate: for random points, the ratio of nearest to farthest neighbor distance approaches 1, so "nearest" carries little information. Each irrelevant dimension adds its own random squared difference to every distance, and those noise terms swamp the few dimensions that carry signal. Volume grows exponentially too: to capture 10% of uniformly distributed data in a sub-cube in 10 dimensions, each side must cover 0.11/10≈79%0.1^{1/10} \approx 79\% of that feature's range, so "local" neighborhoods aren't local anymore. For KNN this means you need exponentially more data to keep neighbors close, and accuracy degrades on raw high-dimensional features. Remedies: feature selection, PCA, or learned embeddings, where useful structure lives on a low-dimensional manifold and distances become meaningful. That's why KNN over learned embeddings works and KNN over raw pixels doesn't.