Skip to content

K-Nearest Neighbors (KNN)

What you’ll learn

  • how a model with no training step still makes predictions
  • Euclidean, Manhattan and Minkowski distance, and when the choice matters
  • a full k=1,3,5k = 1, 3, 5 prediction worked by hand on six points — where each kk gives a different answer
  • why kk is a bias-variance dial, and how to pick it
  • why unscaled features destroy KNN more completely than any other model
  • the curse of dimensionality, measured rather than asserted

Intuition

Every other model in this phase compresses the training data into parameters and then discards the data. KNN does the opposite: it stores everything and does all its work at prediction time. Asked to classify a new point, it finds the kk closest training points and takes a vote.

That makes it a lazy learnerfit()fit() is essentially a memory copy — and it inverts the usual cost profile. Training is instant; prediction is expensive, and gets more expensive as the dataset grows.

diagram Diagram mermaid

The math

Distance

The default is Euclidean distance, the straight line between two points:

d(a,b)=j=1n(ajbj)2d(\mathbf{a}, \mathbf{b}) = \sqrt{\sum_{j=1}^{n}\left(a_j - b_j\right)^2}

It is one case of the Minkowski family, parameterised by pp:

dp(a,b)=(j=1najbjp)1/pd_p(\mathbf{a}, \mathbf{b}) = \left(\sum_{j=1}^{n}\left\lvert a_j - b_j\right\rvert^{p}\right)^{1/p}
ppNameBehaviour
1ManhattanSum of axis-wise distances; robust to one wild coordinate
2EuclideanStraight line; the default
\inftyChebyshevThe single largest coordinate difference

scikit-learn exposes this as metric="minkowski", p=2metric="minkowski", p=2. On high-dimensional data p=1p = 1 often works better, because Euclidean distances concentrate — see the measurement below.

The prediction rule

y^(q)=arg maxc  iNk(q)1 ⁣[y(i)=c]\hat{y}(\mathbf{q}) = \operatorname*{arg\,max}_{c}\;\sum_{i \in N_k(\mathbf{q})} \mathbb{1}\!\left[y^{(i)} = c\right]

where Nk(q)N_k(\mathbf{q}) is the set of indices of the kk closest training points. The class proportions inside that set are the predicted probabilities — which is why KNN probabilities come in steps of 1/k1/k and are coarse for small kk.

With weights="distance"weights="distance" each neighbour instead votes with weight 1/d1/d, so closer points count for more and exact ties become rare.

Worked example by hand

Six training points, two features, two classes. Classify the query q=(4,4)\mathbf{q} = (4, 4).

pointx1x_1x2x_2classΔx1\Delta x_1Δx2\Delta x_2d=Δx12+Δx22d = \sqrt{\Delta x_1^2 + \Delta x_2^2}
A110−3−318=4.243\sqrt{18} = 4.243
B220−2−28=2.828\sqrt{8} = 2.828
C330−1−12=1.414\sqrt{2} = 1.414
D4510+11=1.000\sqrt{1} = 1.000
E651+2+15=2.236\sqrt{5} = 2.236
F771+3+318=4.243\sqrt{18} = 4.243

Step 1 — rank by distance.

D(1.000)C(1.414)E(2.236)B(2.828)A=F(4.243)\text{D}\,(1.000) \prec \text{C}\,(1.414) \prec \text{E}\,(2.236) \prec \text{B}\,(2.828) \prec \text{A} = \text{F}\,(4.243)

Step 2 — vote, for three values of kk.

kkNeighboursVotesPredictionP(class 1)P(\text{class } 1)
1D1 → class 111.00
3D, C, E2 → class 1, 1 → class 010.67
5D, C, E, B, A2 → class 1, 3 → class 000.40

The same query, the same data, and kk flips the answer. With k=1k = 1 the nearest point decides everything; by k=5k = 5 the vote has reached back into the cluster of class-0 points and reversed the verdict. This is not a pathological example — it is ordinary behaviour near a boundary, and it is exactly why kk must be cross-validated rather than guessed.

Choosing k

figurek = 1, 15 and 101 on identical datamatplotlib
Three panels of the same two-moons dataset with KNN decision boundaries at k equals 1, 15 and 101. The k=1 boundary is jagged with isolated islands, k=15 is smooth and follows the crescents, and k=101 is nearly a straight line.Three panels of the same two-moons dataset with KNN decision boundaries at k equals 1, 15 and 101. The k=1 boundary is jagged with isolated islands, k=15 is smooth and follows the crescents, and k=101 is nearly a straight line.
k = 1 carves an island around every noisy point. k = 101 averages over so many neighbours that the crescent structure disappears entirely.
figureTraining accuracy lies about k = 1matplotlib
Training accuracy and cross-validated accuracy plotted against k. Training accuracy starts at 1.0 for k=1 and declines; CV accuracy rises to a peak then slowly falls.Training accuracy and cross-validated accuracy plotted against k. Training accuracy starts at 1.0 for k=1 and declines; CV accuracy rises to a peak then slowly falls.
At k = 1 every training point is its own nearest neighbour, so training accuracy is a guaranteed 1.00. Cross-validation tells the truth: 0.89 at k = 1, peaking at 0.93 around k = 11.

Reading the plot

  1. Training accuracy at k=1k = 1 is always exactly 1.0. Each point retrieves itself, at distance zero. This is the cleanest demonstration in the curriculum that a training score can be structurally meaningless.
  2. The CV curve peaks and then declines. Small kk is high variance, large kk is high bias, and the peak is the trade.
  3. The peak is broad. Anything from roughly 7 to 21 performs within noise of the best here. Choose from the middle of the plateau rather than the exact argmax.

Two rules of thumb, neither of which replaces cross-validation:

  • Use an odd kk for binary problems so a majority always exists.
  • kmk \approx \sqrt{m} is a reasonable starting point (30017\sqrt{300} \approx 17 here).

See it move

Move the mouse to place a query point. The sketch draws a circle out to its kk-th nearest neighbour, connects the voting neighbours, and tallies the vote — so you can see the prediction change as the circle swallows one more point. The background is the full decision boundary at the current kk, recomputed on a grid.

sketch The k-th neighbour circle and the vote it produces p5.js
A query point follows the mouse across a two-class scatter. Lines connect it to its k nearest neighbours and a circle marks the k-th distance; the tally shows the vote. Scroll the k value with a click and watch small k produce islands in the background boundary while large k erases the structure.

Three things become obvious by moving rather than reading. At k=1k = 1 the background is full of single-point islands — each one is a noisy label that owns its own territory. At k=61k = 61 the boundary is nearly straight and the crescents are gone, because a 61-neighbour vote over 90 points is averaging two thirds of the dataset into every prediction. And near the boundary at any kk, the vote margin drops to one or two, which is the honest signal that the model has no opinion there — a probability KNN can report as predict_probapredict_proba and that a threshold decision should respect.

Scaling is not optional

figureOne noise feature with a large range destroys the modelmatplotlib
Two panels. Left, raw features: the informative x1 axis is compressed to nothing beside a noise feature with standard deviation 300. Right, standardised: the classes separate cleanly along x1.Two panels. Left, raw features: the informative x1 axis is compressed to nothing beside a noise feature with standard deviation 300. Right, standardised: the classes separate cleanly along x1.
x1 carries the entire signal but has standard deviation 1. x2 is pure noise with standard deviation 300. Euclidean distance therefore measures almost nothing but x2.

The reason is arithmetic. In (Δx1)2+(Δx2)2\sqrt{(\Delta x_1)^2 + (\Delta x_2)^2}, a feature with a range of 300 contributes terms 90,000 times larger than one with a range of 1. The small feature is not down-weighted — it is erased.

Every other model can compensate by learning a larger coefficient. KNN has no coefficients, which makes it the model most sensitive to scaling in the entire curriculum.

knn_scaling.py
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import train_test_split
from sklearn.neighbors import KNeighborsClassifier
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
 
X, y = load_breast_cancer(return_X_y=True)
y = 1 - y
X_tr, X_te, y_tr, y_te = train_test_split(
    X, y, test_size=0.3, random_state=0, stratify=y
)
 
for k in (1, 5, 15):
    raw = KNeighborsClassifier(k).fit(X_tr, y_tr).score(X_te, y_te)
    scaled = make_pipeline(
        StandardScaler(), KNeighborsClassifier(k)
    ).fit(X_tr, y_tr).score(X_te, y_te)
    print(f"k={k:<3} raw {raw:.4f}   scaled {scaled:.4f}")
 
# k=1   raw 0.9123   scaled 0.9298
# k=5   raw 0.9357   scaled 0.9415
# k=15  raw 0.9357   scaled 0.9474
knn_scaling.py
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import train_test_split
from sklearn.neighbors import KNeighborsClassifier
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
 
X, y = load_breast_cancer(return_X_y=True)
y = 1 - y
X_tr, X_te, y_tr, y_te = train_test_split(
    X, y, test_size=0.3, random_state=0, stratify=y
)
 
for k in (1, 5, 15):
    raw = KNeighborsClassifier(k).fit(X_tr, y_tr).score(X_te, y_te)
    scaled = make_pipeline(
        StandardScaler(), KNeighborsClassifier(k)
    ).fit(X_tr, y_tr).score(X_te, y_te)
    print(f"k={k:<3} raw {raw:.4f}   scaled {scaled:.4f}")
 
# k=1   raw 0.9123   scaled 0.9298
# k=5   raw 0.9357   scaled 0.9415
# k=15  raw 0.9357   scaled 0.9474

Scaling gains one to two points at every kk here, because the breast-cancer features already sit within a couple of orders of magnitude of each other. With genuinely mismatched units — age in years beside income in currency — the gap is far larger.

From scratch

knn_from_scratch.py
import numpy as np
from collections import Counter
 
 
def knn_predict(X_train, y_train, query, k=3):
    """Distances, sort, vote. That is the entire algorithm."""
    distances = np.linalg.norm(X_train - query, axis=1)
    nearest = np.argsort(distances)[:k]
    votes = Counter(y_train[nearest])
    winner, count = votes.most_common(1)[0]
    return winner, count / k, distances[nearest]
 
 
X = np.array([[1.0, 1], [2, 2], [3, 3], [4, 5], [6, 5], [7, 7]])
y = np.array([0, 0, 0, 1, 1, 1])
q = np.array([4.0, 4])
 
for k in (1, 3, 5):
    label, confidence, dists = knn_predict(X, y, q, k)
    print(f"k={k}: class {label}  confidence {confidence:.2f}  "
          f"distances {dists.round(3)}")
 
# k=1: class 1  confidence 1.00  distances [1.]
# k=3: class 1  confidence 0.67  distances [1.    1.414 2.236]
# k=5: class 0  confidence 0.60  distances [1.    1.414 2.236 2.828 4.243]
knn_from_scratch.py
import numpy as np
from collections import Counter
 
 
def knn_predict(X_train, y_train, query, k=3):
    """Distances, sort, vote. That is the entire algorithm."""
    distances = np.linalg.norm(X_train - query, axis=1)
    nearest = np.argsort(distances)[:k]
    votes = Counter(y_train[nearest])
    winner, count = votes.most_common(1)[0]
    return winner, count / k, distances[nearest]
 
 
X = np.array([[1.0, 1], [2, 2], [3, 3], [4, 5], [6, 5], [7, 7]])
y = np.array([0, 0, 0, 1, 1, 1])
q = np.array([4.0, 4])
 
for k in (1, 3, 5):
    label, confidence, dists = knn_predict(X, y, q, k)
    print(f"k={k}: class {label}  confidence {confidence:.2f}  "
          f"distances {dists.round(3)}")
 
# k=1: class 1  confidence 1.00  distances [1.]
# k=3: class 1  confidence 0.67  distances [1.    1.414 2.236]
# k=5: class 0  confidence 0.60  distances [1.    1.414 2.236 2.828 4.243]

At k=5k = 5 the confidence 0.60 refers to the winning class 0, so the probability of class 1 is 0.40 — matching the hand-worked table.

The curse of dimensionality

KNN rests on one assumption: that “nearby” means “similar”. In high dimensions that assumption quietly fails.

Take 1,000 points spread uniformly through a unit hypercube and measure how far the nearest and the farthest are from a query point. As dimensions grow, those two numbers converge:

curse_of_dimensionality.py
import numpy as np
 
rng = np.random.default_rng(0)
 
print(f"{'dims':>5} {'nearest':>9} {'farthest':>9} {'ratio':>7}")
for n_dims in (2, 10, 100, 1000):
    X = rng.random((1000, n_dims))
    q = rng.random(n_dims)
    d = np.linalg.norm(X - q, axis=1)
    print(f"{n_dims:>5} {d.min():>9.3f} {d.max():>9.3f} {d.max() / d.min():>7.2f}")
 
#  dims   nearest  farthest   ratio
#     2     0.010     1.334  135.62
#    10     0.471     2.111    4.48
#   100     3.349     4.653    1.39
#  1000    12.166    13.553    1.11
curse_of_dimensionality.py
import numpy as np
 
rng = np.random.default_rng(0)
 
print(f"{'dims':>5} {'nearest':>9} {'farthest':>9} {'ratio':>7}")
for n_dims in (2, 10, 100, 1000):
    X = rng.random((1000, n_dims))
    q = rng.random(n_dims)
    d = np.linalg.norm(X - q, axis=1)
    print(f"{n_dims:>5} {d.min():>9.3f} {d.max():>9.3f} {d.max() / d.min():>7.2f}")
 
#  dims   nearest  farthest   ratio
#     2     0.010     1.334  135.62
#    10     0.471     2.111    4.48
#   100     3.349     4.653    1.39
#  1000    12.166    13.553    1.11

In two dimensions the farthest point is 136 times further away than the nearest. In a thousand dimensions it is 11% further — “nearest” has stopped carrying information, and a vote among the five closest points is a vote among five essentially arbitrary points. Reduce dimensionality with PCA before applying KNN to wide data, or use a model that learns which features matter.

algorithmK-Nearest NeighborsSupervised · Classification and Regression · Instance-based

APIsklearn.neighbors.KNeighborsClassifier

Assumes

  • Points close together in feature space tend to share a label
  • All features are on comparable scales
  • The feature count is low enough that distances still discriminate
  • The training set is dense enough to have genuine neighbours

Cost

train
O(1) — it stores the data
predict
O(m·n) brute force, O(n log m) with a KD-tree
memory
O(m·n) — the whole training set

m = training samples, n = features

Hyperparameters that matter

  • n_neighborsdefault 5The bias-variance dial. Small k gives jagged boundaries; large k smooths toward the majority class.
  • weightsdefault uniform'distance' weights each neighbour by 1/d, so closer points count more and ties become rare.
  • metric / pdefault minkowski, p=2p=2 is Euclidean, p=1 is Manhattan. Manhattan often holds up better in higher dimensions.
  • algorithmdefault auto'kd_tree' and 'ball_tree' speed up low-dimensional prediction; above roughly 20 features they degrade to brute force.

Reach for it when

  • You want a strong non-linear baseline with almost no assumptions
  • The decision boundary is irregular and local
  • The dataset is small to medium and prediction latency is not critical
  • You need a quick check on whether the features carry signal at all

Look elsewhere when

  • There are many features — distances stop discriminating
  • The training set is large and predictions must be fast
  • Features cannot be scaled sensibly
  • You need an interpretable model; KNN offers no coefficients and no rules

Pitfalls

Compare

ModelTraining costPrediction costNeeds scalingBoundary shape
KNNNoneHigh — O(mn)O(m \cdot n)CriticallyArbitrarily local
Logistic RegressionModerateO(n)O(n)Yes, for the solverLinear
SVM (RBF)HighO(support vectors)O(\text{support vectors})CriticallySmooth, non-linear
Decision TreeModerateO(depth)O(\text{depth})NoAxis-aligned steps
Naive BayesVery lowO(n)O(n)NoSmooth, from densities

KNN and decision trees make a useful first pair: both handle non-linear boundaries, and their failure modes are opposite. If both do badly, the features are probably the problem.

quizCheck yourself
  1. Why does KNN have a training accuracy of exactly 1.0 when k = 1?

    Show answer

    B — Because each training point is its own nearest neighbour, so it always retrieves its own label — With k = 1 the closest point to any training row is that row itself, at distance zero. The training score is structurally guaranteed and carries no information.

  2. Why does KNN suffer more from unscaled features than logistic regression does?

    Show answer

    B — Because it has no coefficients that could compensate — the distance metric is fixed, so a wide-ranging feature dominates it outright — Logistic regression can learn a small coefficient for a wide-ranging feature. KNN has nothing to learn: the squared difference of a feature spanning 300 units swamps one spanning 1.

  3. In the worked example, k = 1 and k = 3 predict class 1 but k = 5 predicts class 0. What does that tell you?

    Show answer

    B — The query sits near a decision boundary, where the answer depends on how far the vote reaches — Near a boundary the composition of the neighbourhood changes rapidly with k. This is precisely why k must be cross-validated rather than assumed.

  4. In 1,000 dimensions the farthest of 1,000 random points is only 11% further away than the nearest. Why does that break KNN?

    Show answer

    B — Because 'nearest' stops being meaningfully different from 'farthest', so the vote becomes effectively random — KNN assumes proximity implies similarity. Once all pairwise distances converge, the k nearest neighbours are no longer meaningfully closer than any other k points.

🧪 Try It Yourself

Exercise 1 – Compute the distances

Exercise 2 – Vote by majority

Exercise 3 – Scaling changes the score

Exercise 4 – Cross-validate k

Exercise 5 – Watch distances converge

Recap

  • KNN stores the training set and votes at prediction time — no parameters, no training step.
  • Distance is the model. Euclidean by default, Manhattan often better in higher dimensions.
  • Worked by hand: the query (4,4)(4,4) is class 1 at k=1k = 1 and k=3k = 3, and class 0 at k=5k = 5.
  • Small kk is high variance, large kk is high bias; training accuracy at k=1k = 1 is always 1.0 and always meaningless.
  • Scaling is mandatory — a feature with a 300-unit range erases one with a 1-unit range.
  • In 1,000 dimensions the nearest and farthest points differ by 11%, and the whole premise collapses.

Exercise 6 – One query point, four values of kk

Next

Continue to Support Vector Machines (SVM) — a model that keeps only the handful of points that actually define the boundary and ignores the rest.

If this helped you, consider buying me a coffee ☕

Buy me a coffee

Was this page helpful?

Let us know how we did