Machine Learning · Module 12 of 26 · 18 concepts · about 28 min

K-Nearest Neighbours (kNN)

Classify something by looking at the most similar examples you already have and letting them vote — worked through on Blinkit's stores, by hand, from scratch in NumPy and with scikit-learn.

At work: Classifying something by its most similar known examples: store traffic classes at Blinkit, “customers like you”, similar-product search.

The business problem: staffing Blinkit's stores

What it is

Blinkit wants each dark store to have the right number of delivery partners. It sorts stores into three classes by how much they deliver: Class 1, High traffic (many partners), Class 2, Moderate and Class 3, Low (very few). Each store is described by its Region (Tier-1, 2 or 3 city) and its delivery counts for Fresh, Milk, Grocery, Frozen, Detergents_Paper and Delicassen. When a new store opens, which class does it belong in?

Why it matters

Three facts about this data decide which model can work. It is multi-class (three answers, not two). It is imbalanced (far fewer High-traffic stores than the others). And when you plot it, the classes curl around each other, so it is non-linear — no straight line separates them. Keep these three in mind; the rest of the module is about a model that handles all three.

In the real world: A quick-commerce company decides how many delivery partners each dark store needs by classifying stores as high, moderate or low traffic.

Visual example

value_counts()

The stores per class. Class 1 has half as many examples as the other two — the data is multi-class and imbalanced.

df["class"].value_counts()
classmeaningstores
1High traffic87
2Moderate traffic180
3Low traffic173

440 stores in all: 87 + 180 + 173.

import pandas as pd

df = pd.read_csv("multiclass.csv")
df.head()
# Region  Fresh  Milk  Grocery  Frozen  Detergents_Paper  Delicassen  class

df["class"].value_counts().sort_index()
# 1     87      <- High traffic: the smallest class
# 2    180
# 3    173

Reading the class numbers 1, 2, 3 as amounts. They are labels, not quantities: Class 3 is not "three times" Class 1, and a model must never average them. Treat them as categories.

Why logistic regression struggles here

What it is

Logistic regression draws a straight boundary between two classes. To bend the boundary you add polynomial features (f1², f1·f2, …) and search for the right degree. To handle three classes it trains one model per class (one-vs-rest). So on Blinkit's data you would be searching polynomial degrees for three separate models.

Why it matters

Now imagine a million stores and 50 store types instead of 3: fifty models, each with its own search for the right polynomial features. That is slow, expensive and fragile. What we want is a model with no assumed shape for the boundary and one model for any number of classes — which is exactly what kNN is.

In the real world: When store types grow from 3 to 50, training and tuning 50 separate one-vs-rest models becomes slow and costly for the data team.

from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import PolynomialFeatures, StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.multiclass import OneVsRestClassifier

# a curved boundary for 3 classes: 3 models, and a degree to search
logreg = make_pipeline(
    PolynomialFeatures(degree=3),          # 7 columns become 120
    StandardScaler(),
    OneVsRestClassifier(LogisticRegression(max_iter=2000)),
)

Adding polynomial features until the training score looks good. Degree 3 on just 7 columns already makes 120 columns; the model starts fitting noise, and every new class adds another model to tune.

Geometric intuition: a point takes the class of its neighbours

What it is

Plot the stores as points. If a new store lands in the middle of a cluster of High-traffic stores, you would call it High traffic without doing any maths — its neighbours tell you. kNN turns that instinct into a rule: find the k stored points nearest the new one, and let them vote.

Why it matters

There is no equation to fit and no shape to assume, so the boundary can be as curved as the data needs, and three classes are no harder than two. This is why kNN handles exactly the problems logistic regression struggled with: non-linear, multi-class data.

In the real world: A new store in a busy residential area is judged by the stores most like it, the way a manager would compare it with similar branches.

Visual example

nearest neighbours

Two new stores, q1 and q2. Each is surrounded by one class, so its neighbours decide its class.

Class 1 (High)Class 2 (Moderate)Class 3 (Low)
02468100246810feature 1feature 2q1: surrounded by Class 1q2: surrounded by Class 3

Asking "nearest" without asking "in what units". Distance mixes all the columns together, so a column measured in thousands (Fresh deliveries) will swamp one measured in single digits (Region). Scale first — see the scaling concept below.

The kNN algorithm, step by step

What it is

To classify a query point: 1. Distance — measure how far it is from every training point (usually the Euclidean distance, the straight-line distance). 2. Sort — order the training points from nearest to farthest. 3. Pick k — keep the first k. 4. Vote — the class that appears most among those k is the prediction.

Why it matters

That is the whole model. Nothing is learned in advance — the training data *is* the model, and all the work happens when a question arrives. Working it by hand once makes every later idea (the effect of k, scaling, speed) obvious.

In the real world: Classifying a newly opened store: measure how similar it is to every existing store, take the closest few, and staff it like most of them.

Visual example

k = 3

Step 1 works out every distance; step 2 sorts by it. The top three rows are the 3 nearest neighbours, and two of them are class 2.

Step 1: distance to x_q = [2, 5]
storef1f2classdistance
x13611.41
x26414.12
x38236.71
x47535
x51421.41
x62223
sort by distance
Step 2: sorted, nearest first
storef1f2classdistance
x13611.41
x51421.41
x62223
x26414.12
x47535
x38236.71

Steps 3 and 4: pick k = 3, then vote

nearest 3: x1 (class 1), x5 (class 2), x6 (class 2)

votes: class 2 → 2, class 1 → 1

prediction for x_q: class 2

  1. 1DistanceFrom x_q to every stored point.
  2. 2SortNearest first.
  3. 3Pick kKeep the first k rows.
  4. 4VoteThe most common class wins.

Query x_q = [2, 5], six stored stores, k = 3. Distance: d = √((f1 − 2)² + (f2 − 5)²).

  • x1 = [3, 6]: √(1 + 1) = 1.41
  • x2 = [6, 4]: √(16 + 1) = 4.12
  • x3 = [8, 2]: √(36 + 9) = 6.71
  • x4 = [7, 5]: √(25 + 0) = 5.00
  • x5 = [1, 4]: √(1 + 1) = 1.41
  • x6 = [2, 2]: √(0 + 9) = 3.00

Nearest three: x1 (class 1, 1.41), x5 (class 2, 1.41), x6 (class 2, 3.00). Votes: class 2 twice, class 1 once → predict class 2.

Believing kNN learns something when you call fit. It only stores the rows. That is why training is instant and prediction is slow — the opposite of almost every other model.

Choosing k: odd values and ties

What it is

With k = 4 in the example, the four nearest are x1 (class 1), x5 (class 2), x6 (class 2), x2 (class 1): two votes for class 1 and two for class 2 — a tie, and no clear answer. With two classes, an odd k can never tie. With three or more classes it still can (k = 5 might give 2, 2 and 1 votes).

Why it matters

A tie has to be broken somehow, and silently. scikit-learn picks whichever tied class comes first in sorted order; other implementations pick at random. Either way the prediction for that point is arbitrary, so it is better to choose k to avoid ties, or to weight votes by distance so the nearer neighbours win.

In the real world: A tie between “high” and “moderate” traffic would leave staffing undecided; choosing k and distance weighting avoids arbitrary calls.

from sklearn.neighbors import KNeighborsClassifier

# closer neighbours count for more: a tie at equal counts is broken by distance
knn = KNeighborsClassifier(n_neighbors=4, weights="distance")

Assuming an odd k removes ties for good. It only does so for two classes. Blinkit has three, so ties are still possible — weights="distance" is the real fix.

kNN from scratch in NumPy

What it is

The four steps are a few lines of NumPy: compute all distances at once, sort, take the first k labels, count them. Writing it yourself once is the fastest way to be sure you understand what scikit-learn does for you.

Why it matters

It shows there is no hidden training: the function takes the stored data and the query every time. It also shows where the time goes — the distance line touches every stored row for every query.

In the real world: Writing the search once in NumPy lets a team check a library's results, or run a quick similarity search without a full ML stack.

import numpy as np

def knn_predict(X, y, query, k):
    # 1. distance from the query to every stored row
    d = np.sqrt(((X - query) ** 2).sum(axis=1))
    # 2 + 3. the k nearest rows
    nearest = np.argsort(d)[:k]
    # 4. majority vote among their labels
    labels, counts = np.unique(y[nearest], return_counts=True)
    return labels[counts.argmax()], d[nearest]

X = np.array([[3, 6], [6, 4], [8, 2], [7, 5], [1, 4], [2, 2]])
y = np.array([1, 1, 3, 3, 2, 2])
knn_predict(X, y, np.array([2, 5]), k=3)
# (2, array([1.41, 1.41, 3.  ]))

Writing the distance as a Python loop over rows. It gives the same answer and is a hundred times slower. Let NumPy compute every distance in one line, as above.

Practise it: Precision, Recall and F1 by Hand

kNN with scikit-learn

What it is

In practice: split the data into train, validation and test; balance the classes in the training set only (the lecture uses SMOTE); fit a StandardScaler on the training data; then KNeighborsClassifier. fit just stores the scaled training rows; predict does the search.

Why it matters

On Blinkit's stores this reaches about 0.90 accuracy on the validation set with k = 5, and the confusion matrix shows most mistakes are on Class 1 — the smallest class. It confirms kNN handles multi-class, non-linear data with no feature engineering at all.

In the real world: A few lines give a working store-traffic classifier the operations team can test against last month's real staffing.

from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.neighbors import KNeighborsClassifier
from sklearn.metrics import classification_report
from imblearn.over_sampling import SMOTE

X_tv, X_test, y_tv, y_test = train_test_split(X, y, test_size=0.25, stratify=y, random_state=1)
X_train, X_val, y_train, y_val = train_test_split(
    X_tv, y_tv, test_size=0.25, stratify=y_tv, random_state=1
)

X_train, y_train = SMOTE(random_state=1).fit_resample(X_train, y_train)  # training set only

scaler = StandardScaler().fit(X_train)                 # learned from training data only
knn = KNeighborsClassifier(n_neighbors=5, metric="euclidean")
knn.fit(scaler.transform(X_train), y_train)            # just stores the rows

knn.score(scaler.transform(X_val), y_val)              # ~0.90
print(classification_report(y_val, knn.predict(scaler.transform(X_val))))

Balancing or scaling before the split. SMOTE's synthetic rows and the scaler's averages would then be built partly from validation and test rows, and the scores would be too good to be true. Split first, then fit everything on the training set only.

The assumption: a homogeneous neighbourhood

What it is

kNN assumes that points close together share the same class — that each neighbourhood is homogeneous. The prediction for a new point is simply whatever its neighbours are.

Why it matters

When that holds, kNN is excellent. When the data is noisy — mislabelled stores, outliers scattered inside other classes — the neighbourhoods are mixed and the votes become unreliable, so kNN makes mistakes exactly where the data is messy.

In the real world: Mislabelled stores from a data-entry error can make whole neighbourhoods unreliable, and staffing predictions wrong in those areas.

Visual example

homogeneous?

Left: clean neighbourhoods, and the query's neighbours agree. Right: a noisy region where the neighbours disagree.

Class 1Class 2
02468100246810feature 1feature 2clean: all neighbours agreenoisy: a mixed vote

Blaming k when the real problem is noise. If validation accuracy stays poor for every k, look at the data: mislabelled rows and outliers sitting inside other classes break kNN's one assumption.

Bias and variance: what k does

What it is

With k = 1, each prediction copies the single nearest point — even when that point is an outlier. The model follows every quirk of the training data: low bias, high variance, it overfits. With k = all the data (say 72 of 72 points), every query gets the overall majority class, whatever its neighbourhood: high bias, low variance, it underfits.

Why it matters

k is one number that slides kNN from overfitting to underfitting, so it is the clearest picture of the bias-variance trade-off in the course. Training accuracy falls as k grows; validation accuracy rises, peaks, then falls. The best k sits at that peak.

In the real world: Too small a k staffs a store like one odd neighbour; too large a k staffs every store like the average store.

Visual example

n_neighbors

Training accuracy is 1.0 at k = 1 by construction and falls from there. Validation accuracy rises, peaks, then falls as the model underfits.

Training accuracyValidation accuracy
overfitsunderfits10203040500.80.850.90.951k (number of neighbours)Accuracybest k

Being pleased that training accuracy is 1.0 at k = 1. Every point's nearest neighbour is itself, so k = 1 scores perfectly on the training set for any data, even pure noise. It tells you nothing.

Practise it: A Pipeline That Does Not Leak, A Split That Does Not Leak Across Groups

Finding the best k on validation data

What it is

k is a hyperparameter — you choose it; the model does not learn it. Try a range of values, score each on the validation set (never the test set), and keep the best. On Blinkit's stores the lecture tries k = 1 to 50; validation accuracy is highest at small k (about 0.915 at k = 3) and falls steadily after about k = 17.

Why it matters

Choosing k on the training set would always pick k = 1, and choosing it on the test set would leave you no honest final check. The validation set exists for exactly this decision. Cross-validation (GridSearchCV) does the same job more reliably on small data.

In the real world: Choosing k on held-out stores gives an honest estimate of how well staffing predictions will work for next month's new stores.

import matplotlib.pyplot as plt

accuracy = []
for k in range(1, 51):
    model = KNeighborsClassifier(n_neighbors=k).fit(X_train_scaled, y_train)
    accuracy.append(model.score(X_val_scaled, y_val))

best_k = 1 + int(np.argmax(accuracy))       # 3 on this data
plt.plot(range(1, 51), accuracy, marker="o")
plt.xlabel("k"); plt.ylabel("validation accuracy")

Picking a lone spike. If k = 3 scores 0.915 but k = 2 and k = 4 score far less, that peak may be luck on a small validation set. Prefer a k in a steady high region, or confirm with cross-validation.

The decision boundary kNN draws

What it is

Colour every spot on the plane by the class kNN would predict there, and you see its decision boundary. It bends wherever the data bends — it is non-linear, with no formula behind it — and a small k makes it jagged, a large k makes it smooth.

Why it matters

It shows directly why kNN succeeded where a straight-line model struggled: the boundary between Blinkit's store classes follows the curved shape of the data, and three classes each get their own region.

In the real world: Showing the business which regions of store behaviour map to each staffing level, in a picture a manager can read.

from sklearn.decomposition import PCA
from sklearn.inspection import DecisionBoundaryDisplay

X2 = PCA(n_components=2).fit_transform(X_train_scaled)     # 2 columns, to draw it
clf = KNeighborsClassifier(n_neighbors=5).fit(X2, y_train)

disp = DecisionBoundaryDisplay.from_estimator(clf, X2, response_method="predict", alpha=0.5)
disp.ax_.scatter(X2[:, 0], X2[:, 1], c=y_train, edgecolor="k")

Reading the 2-D picture as the real model. The boundary is drawn on two PCA components so it fits on a screen; the model you deploy uses all seven columns, and its real boundary cannot be drawn.

Time and space cost

What it is

Training: O(1) — it only stores the data. Space: O(n × d) — it keeps all n rows of d columns. Prediction: O(n × d + n log n) per query — a distance to every row, then a sort (O(n × d + n log k) with a partial sort). Picking and voting over k rows is tiny next to that.

Why it matters

This is kNN's biggest practical cost, and the reverse of most models: instant to train, slow to answer. With a million stores and thousands of queries a second, searching every row for every query is too slow — which is why real systems use fast approximate search (see LSH below).

In the real world: Scoring every new order against millions of stored examples is too slow at checkout, which is why production similarity search uses indexes.

Visual example

Big-O

kNN puts all its work at prediction time.

kNN costs, n rows, d columns
phasecostwhy
trainO(1)only stores the rows
spaceO(n × d)keeps every row
predictO(n × d + n log n)distance to every row, then sort

Judging kNN by how fast fit runs. fit is instant; the bill arrives at prediction time, on every request, and grows with the size of the training data.

Scaling before kNN

What it is

Distance adds up the differences in every column. If Fresh deliveries run into the tens of thousands and Region is 1, 2 or 3, a difference of 5,000 in Fresh drowns out everything else — the "nearest" stores are just the ones with similar Fresh counts. StandardScaler puts every column on the same scale so each one counts.

Why it matters

Without scaling, kNN quietly becomes a model of your largest column. Scaling is not an optional tidy-up for kNN; it changes which points are neighbours, and so every prediction.

In the real world: Without scaling, stores are matched on raw fresh-food counts alone, and the city tier (which really matters for staffing) is ignored.

Visual example

StandardScaler

Before scaling, the distance is almost entirely the Fresh column. After scaling, both columns count.

raw: difference between two stores
columnstore Astore Bdifference²
Region134
Fresh12669705731494544
scale each column
scaled: same two stores
columnstore Astore Bdifference²
Region-1.60.64.84
Fresh0.05-0.390.19
from sklearn.pipeline import make_pipeline

# the scaler is learned on training rows only, and applied the same way at prediction time
model = make_pipeline(StandardScaler(), KNeighborsClassifier(n_neighbors=5))
model.fit(X_train, y_train)

Fitting the scaler on all the data before splitting. The validation and test rows then shape the averages used to scale the training rows, and the scores are slightly too optimistic. Fit it on training data only — a pipeline does that for you.

Categorical features: one-hot or target encoding

What it is

Distance needs numbers, so text categories must be converted. Take a diabetes dataset with Gender (M, F), Age, Blood group (8 groups), Glucose and Blood pressure. One-hot encoding gives each category its own 0/1 column: Gender becomes 2 columns and Blood group 8, so 5 features become 13 columns. Target encoding replaces each category with a single number learned from the target (such as the diabetes rate for that blood group), keeping one column per feature.

Why it matters

One-hot is simple but multiplies the columns, and kNN gets worse as columns multiply (see the next concept). For categories with many values, target encoding keeps the data compact — as long as it is learned on training data only.

In the real world: Turning city tier, blood group or product category into numbers so similar customers or patients can be found.

import pandas as pd

pd.get_dummies(df, columns=["gender", "blood_group"]).shape[1]   # 13 columns from 5

# target encoding: one column, learned from the training rows only
rate = train.groupby("blood_group")["diabetic"].mean()
train["blood_group_te"] = train["blood_group"].map(rate)
test["blood_group_te"] = test["blood_group"].map(rate)

Target-encoding with averages computed over all the data, or over the same rows being encoded. The encoded column then carries the answer, and the model looks far better than it is. Learn the averages on training rows (ideally out-of-fold).

The curse of dimensionality

What it is

As the number of columns grows, the distances between points bunch together: the nearest point and the farthest point end up almost the same distance away. In the lecture's picture, two points far apart in 2 columns look nearly as close as any other pair once there are 1,000 columns.

Why it matters

kNN depends entirely on ranking points by distance. When all distances are nearly equal, "nearest" means almost nothing and predictions become close to random — which is why one-hot encoding a lot of categories can quietly ruin a kNN model.

In the real world: With thousands of one-hot product columns, every customer looks equally similar and “customers like you” recommendations stop working.

Visual example

dimensions

The gap between the nearest and farthest neighbour shrinks as columns are added, so the ranking kNN relies on fades away.

2004006008001,000102030405060number of columnsfarthest ÷ nearest distanceall equally far

Adding more columns in the hope of more signal. For kNN, every weak or irrelevant column makes all points look more alike. Fewer, better features (or PCA first) usually beats more.

Distance metrics: Euclidean, Manhattan, Minkowski, cosine

What it is

Euclidean (L2) is the straight-line distance: √(Σ(aⱼ − bⱼ)²). Manhattan (L1) is the distance walking along the grid: Σ|aⱼ − bⱼ| — for A = (1, 1, 3) and B = (1, 3, 5) it is 0 + 2 + 2 = 4. Minkowski generalises both: (Σ|aⱼ − bⱼ|ᵖ)^(1/p), with p = 1 giving Manhattan and p = 2 Euclidean. Cosine similarity, a·b ÷ (‖a‖‖b‖), compares directions and ranges from −1 (opposite) to 1 (same direction).

Why it matters

The metric decides who counts as a neighbour, so it is a hyperparameter like k. Use Euclidean for a few dense numeric columns, Manhattan for grid-like or city-block data, and cosine for high-dimensional sparse data such as text, where it ignores the many columns that are zero in both rows.

In the real world: Cosine similarity powers document and product search; Manhattan suits city-grid delivery distances; Euclidean suits a few numeric features.

KNeighborsClassifier(n_neighbors=5, metric="euclidean")
KNeighborsClassifier(n_neighbors=5, metric="manhattan")
KNeighborsClassifier(n_neighbors=5, metric="minkowski", p=3)
KNeighborsClassifier(n_neighbors=5, metric="cosine")   # uses 1 - cosine similarity

Sorting by cosine similarity in the wrong direction. A higher similarity means *closer*, so neighbours are the largest values. scikit-learn avoids the confusion by using cosine *distance* (1 − similarity), where smaller is closer as usual.

Practise it: k-Nearest Neighbours, With a Tie-Break That Never Changes

Making kNN fast: Locality Sensitive Hashing

What it is

Instead of comparing a query with every stored point, Locality Sensitive Hashing (LSH) first puts points into buckets so that similar points tend to land in the same bucket. A simple version: pick a few random directions and record, for each point, which side of each direction it falls on — e.g. [0, 1, 0]. Points with the same code share a bucket. At query time, compute the query's code and run kNN only on that bucket.

Why it matters

It turns an impossible search over billions of items into a quick search over a few thousand. This family of approximate nearest-neighbour methods is what makes image search, product recommendations and semantic search fast (Google's ScaNN, Facebook's FAISS).

In the real world: Image search and product recommendations find similar items among billions in milliseconds by searching only the right bucket.

import numpy as np

rng = np.random.default_rng(0)
planes = rng.normal(size=(3, X.shape[1]))          # 3 random directions

def code(x):
    return tuple((planes @ x > 0).astype(int))        # e.g. (0, 1, 0)

buckets = {}
for i, x in enumerate(X):
    buckets.setdefault(code(x), []).append(i)

candidates = buckets.get(code(query), [])            # search only these

Assuming LSH finds the exact nearest neighbours. It is approximate: a true neighbour can land in a neighbouring bucket and be missed. Systems use several hash tables to trade a little accuracy for a lot of speed.

kNN imputation: filling missing values

What it is

When a row is missing a value, you can fill it with the column's mean or median — or with kNN: find the k rows most similar to it on the columns it *does* have, and use the average of their values for the missing one.

Why it matters

A store's missing Frozen count is better guessed from stores with similar Fresh, Milk and Grocery counts than from the average of all stores. kNN imputation keeps the relationships between columns that a plain mean ignores.

In the real world: Estimating a store's missing frozen-food count from stores with similar sales, instead of using one company-wide average.

from sklearn.impute import KNNImputer

imputer = KNNImputer(n_neighbors=5)
X_train_filled = imputer.fit_transform(X_train)     # learns from training rows
X_val_filled = imputer.transform(X_val)             # fills using training neighbours

Imputing before scaling. The imputer measures distance too, so unscaled columns decide which rows count as similar. Scale first (or put both in one pipeline), and fit on training data only.

Practise it: A Pipeline That Does Not Leak