KNN and Naive Bayes
The scene: the two simplest models
This chapter has two models, and it's convenient to learn them together, because neither of them really has a training phase.
- KNN: learns nothing. At prediction time it looks at the nearest neighbours.
- Naive Bayes: just counts. It computes probabilities, then applies Bayes' theorem.
Both are old and simple. But both are still used, in specific places.
Column names
The data is synthetic Tashkent rental listings, and its column names are Uzbek. xonalar rooms, maydon_m2 area in m², qavat floor, qavatlar_soni floors in the building, metro_km distance to the metro, mebel furnished (0/1). The target ijaraga_berildi_30k means rented within 30 days.
KNN: the "lazy" learner
The idea in one sentence: find the K flats most similar to the new one and look at their answers.
import pandas as pd import numpy as np from sklearn.model_selection import train_test_split, cross_val_score from sklearn.neighbors import KNeighborsClassifier from sklearn.pipeline import Pipeline from sklearn.preprocessing import StandardScaler df = pd.read_csv(DATA / "uy_ijara.csv") num = ["xonalar", "maydon_m2", "qavat", "qavatlar_soni", "metro_km", "mebel"] X, y = df[num], df["ijaraga_berildi_30k"] X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.3, random_state=42, stratify=y) pipe = Pipeline([("sc", StandardScaler()), ("m", KNeighborsClassifier(n_neighbors=5))]).fit(X_tr, y_tr) # show the neighbours of one flat sc = pipe.named_steps["sc"] knn = pipe.named_steps["m"] dist, index = knn.kneighbors(sc.transform(X_te.iloc[[0]]), n_neighbors=5) print("The flat being checked:") print(X_te.iloc[0].to_string()) print(f"\nThe true answer: {y_te.iloc[0]}") print("\nThe 5 nearest neighbours:") neighbours = X_tr.iloc[index[0]].copy() neighbours["answer"] = y_tr.iloc[index[0]].values neighbours["distance"] = dist[0].round(3) show(neighbours) print(f"The neighbours' vote: {y_tr.iloc[index[0]].mean():.1%} 'yes' -> prediction {pipe.predict(X_te.iloc[[0]])[0]}")
The flat being checked: xonalar 4.00 maydon_m2 79.20 qavat 9.00 qavatlar_soni 10.00 metro_km 2.01 mebel 0.00 The true answer: 1 The 5 nearest neighbours: The neighbours' vote: 20.0% 'yes' -> prediction 0
| xonalar | maydon_m2 | qavat | qavatlar_soni | metro_km | mebel | answer | distance |
|---|---|---|---|---|---|---|---|
| 4 | 71.7 | 8 | 10 | 1.27 | 0 | 1 | 0.65 |
| 4 | 89.7 | 8 | 11 | 2.44 | 0 | 0 | 0.682 |
| 4 | 83.2 | 9 | 12 | 2.94 | 0 | 0 | 0.765 |
| 4 | 89.6 | 7 | 10 | 2.44 | 0 | 0 | 0.798 |
| 4 | 90.4 | 7 | 10 | 2.5 | 0 | 0 | 0.837 |
The model learned no rule at all. It memorised the whole training set and searches it at prediction time.
That's why KNN is called a "lazy learner": training is instant, prediction is slow.
import time from sklearn.linear_model import LogisticRegression print(f"{'model':<24} {'fit time':>12} {'predict time':>15}") print("-" * 54) for name, m in [("KNN", Pipeline([("sc", StandardScaler()), ("m", KNeighborsClassifier())])), ("LogisticRegression", Pipeline([("sc", StandardScaler()), ("m", LogisticRegression(max_iter=2000))]))]: t0 = time.perf_counter(); m.fit(X_tr, y_tr); ft = (time.perf_counter()-t0)*1000 t0 = time.perf_counter(); m.predict(X_te); pt = (time.perf_counter()-t0)*1000 print(f"{name:<24} {ft:>10.2f} ms {pt:>13.2f} ms")
model fit time predict time ------------------------------------------------------ KNN 1.52 ms 5.26 ms LogisticRegression 2.68 ms 0.52 ms
KNN is expensive in production
The model carries the whole training set around with it. With 10 million rows of data, 10 million rows have to sit in the server's memory.
And a distance is computed for every request. That's why KNN is rarely used in real-time systems.
Scaling is vital for KNN
We said it in Chapter 5; now we measure it:
print(f"{'case':<22} {'CV F1':>9} {'±':>8}") print("-" * 40) b1 = cross_val_score(KNeighborsClassifier(), X, y, cv=5, scoring="f1") b2 = cross_val_score(Pipeline([("sc", StandardScaler()), ("m", KNeighborsClassifier())]), X, y, cv=5, scoring="f1") print(f"{'no scaling':<22} {b1.mean():>9.4f} {b1.std():>8.4f}") print(f"{'scaling':<22} {b2.mean():>9.4f} {b2.std():>8.4f}") print(f"{'gap':<22} {b2.mean()-b1.mean():>+9.4f}")
case CV F1 ± ---------------------------------------- no scaling 0.6627 0.0113 scaling 0.6903 0.0141 gap +0.0276
The reason: when distances are computed, maydon_m2 (22–220) completely drowns out mebel (0–1). Without scaling, KNN effectively looks at one column only.
Choosing K is the bias-variance lever
from sklearn.metrics import f1_score print(f"{'K':>5} {'train F1':>10} {'test F1':>9} {'CV F1':>9} {'note'}") print("-" * 52) for k in [1, 3, 5, 15, 50, 200]: q = Pipeline([("sc", StandardScaler()), ("m", KNeighborsClassifier(n_neighbors=k))]).fit(X_tr, y_tr) tr_f = f1_score(y_tr, q.predict(X_tr)) te_f = f1_score(y_te, q.predict(X_te)) cv = cross_val_score(Pipeline([("sc", StandardScaler()), ("m", KNeighborsClassifier(n_neighbors=k))]), X, y, cv=5, scoring="f1").mean() note = "K=1: every point finds itself" if k == 1 else ("very smooth" if k >= 200 else "") print(f"{k:>5} {tr_f:>10.4f} {te_f:>9.4f} {cv:>9.4f} {note}")
K train F1 test F1 CV F1 note
----------------------------------------------------
1 1.0000 0.6398 0.6243 K=1: every point finds itself
3 0.7789 0.6873 0.6547
5 0.7523 0.7385 0.6903
15 0.7148 0.7554 0.7064
50 0.6903 0.7769 0.7191
200 0.6990 0.7917 0.7272 very smooth
At K=1 the train F1 is 1.0000, because every point's nearest neighbour is itself. That's pure overfitting.
As K rises, the model smooths out: less variance, more bias.
show(pd.DataFrame([ {"K": "small (1-3)", "Model": "Very flexible", "Risk": "Overfitting, sensitive to noise"}, {"K": "medium (5-20)", "Model": "Balanced", "Risk": "Usually the best"}, {"K": "large (100+)", "Model": "Very smooth", "Risk": "Underfitting: the same answer everywhere"}, ])) print() print("Rule: make K an ODD number (so there are no tied votes in binary classification).")
Rule: make K an ODD number (so there are no tied votes in binary classification).
| K | Model | Risk |
|---|---|---|
| small (1-3) | Very flexible | Overfitting, sensitive to noise |
| medium (5-20) | Balanced | Usually the best |
| large (100+) | Very smooth | Underfitting: the same answer everywhere |
The distance metric
print(f"{'metric':<16} {'CV F1':>9} note") print("-" * 58) for name, p in [("euclidean", 2), ("manhattan", 1)]: b = cross_val_score(Pipeline([("sc", StandardScaler()), ("m", KNeighborsClassifier(n_neighbors=15, p=p))]), X, y, cv=5, scoring="f1") note = "sqrt(sum(d^2)): the default" if p == 2 else "sum(|d|): robust to outliers" print(f"{name:<16} {b.mean():>9.4f} {note}") print() b_w = cross_val_score(Pipeline([("sc", StandardScaler()), ("m", KNeighborsClassifier(n_neighbors=15, weights="distance"))]), X, y, cv=5, scoring="f1") print(f"{'weights=distance':<16} {b_w.mean():>9.4f} nearer neighbours get more of a vote")
metric CV F1 note ---------------------------------------------------------- euclidean 0.7064 sqrt(sum(d^2)): the default manhattan 0.7178 sum(|d|): robust to outliers weights=distance 0.7017 nearer neighbours get more of a vote
The curse of dimensionality
KNN's most serious limitation: as columns multiply, distance loses its meaning.
r = np.random.default_rng(42) print("Distances between random points, as the dimension grows:") print(f"{'dimension':>9} {'nearest':>11} {'farthest':>10} {'ratio':>8}") print("-" * 42) for m in [2, 10, 50, 200, 1000]: P = r.normal(size=(500, m)) d = np.linalg.norm(P[1:] - P[0], axis=1) print(f"{m:>9} {d.min():>11.3f} {d.max():>10.3f} {d.max()/d.min():>8.2f}")
Distances between random points, as the dimension grows:
dimension nearest farthest ratio
------------------------------------------
2 0.041 4.301 104.36
10 1.525 6.079 3.99
50 7.867 13.041 1.66
200 16.601 21.685 1.31
1000 42.854 47.704 1.11
Look at the ratio. In 2 dimensions the farthest point is much farther than the nearest. In 1000 dimensions they are at almost the same distance.
In other words, "the nearest neighbour" stops meaning anything: everyone is equally far away.
print("If we add noise columns to KNN:") print(f"{'noise columns added':>20} {'CV F1':>9}") print("-" * 32) for n_noise in [0, 10, 50, 200]: if n_noise == 0: Xn = X else: noise = pd.DataFrame(r.normal(size=(len(X), n_noise)), columns=[f"n_{i}" for i in range(n_noise)], index=X.index) Xn = pd.concat([X, noise], axis=1) b = cross_val_score(Pipeline([("sc", StandardScaler()), ("m", KNeighborsClassifier(n_neighbors=15))]), Xn, y, cv=5, scoring="f1") print(f"{n_noise:>20} {b.mean():>9.4f}")
If we add noise columns to KNN:
noise columns added CV F1
--------------------------------
0 0.7064
10 0.6953
50 0.6417
200 0.6410
In Chapter 17, Lasso was robust to noise. KNN is not, because noise columns corrupt the distances.
Naive Bayes: just counting
The second model comes from a completely different idea: Bayes' theorem:
The word "naive" comes from this: the model assumes the features are independent of each other. That's almost always false, yet the model still works.
from sklearn.naive_bayes import GaussianNB, MultinomialNB, BernoulliNB print(f"{'model':<24} {'CV F1':>9} {'fit time':>12}") print("-" * 48) for name, m in [("GaussianNB", GaussianNB()), ("KNN (K=15)", Pipeline([("sc", StandardScaler()), ("m", KNeighborsClassifier(n_neighbors=15))])), ("LogisticRegression", Pipeline([("sc", StandardScaler()), ("m", LogisticRegression(max_iter=2000))]))]: t0 = time.perf_counter() b = cross_val_score(m, X, y, cv=5, scoring="f1") dt = (time.perf_counter()-t0)*1000 print(f"{name:<24} {b.mean():>9.4f} {dt:>10.1f} ms")
model CV F1 fit time ------------------------------------------------ GaussianNB 0.7291 12.8 ms KNN (K=15) 0.7064 35.4 ms LogisticRegression 0.7210 21.8 ms
show(pd.DataFrame([ {"Variant": "GaussianNB", "Kind of data": "Continuous numbers", "Assumption": "Each feature is normally distributed", "Example": "Height, weight, price"}, {"Variant": "MultinomialNB", "Kind of data": "Counts", "Assumption": "Word frequencies", "Example": "SPAM FILTER: text classification"}, {"Variant": "BernoulliNB", "Kind of data": "Binary (0/1)", "Assumption": "A word is present/absent", "Example": "Short texts"}, ]))
| Variant | Kind of data | Assumption | Example |
|---|---|---|---|
| GaussianNB | Continuous numbers | Each feature is normally distributed | Height, weight, price |
| MultinomialNB | Counts | Word frequencies | SPAM FILTER: text classification |
| BernoulliNB | Binary (0/1) | A word is present/absent | Short texts |
Naive Bayes's real home: text
On numeric columns Naive Bayes is average. But in text classification it's still strong.
from sklearn.feature_extraction.text import CountVectorizer messages = [ "Congratulations you won claim your cash now", "Free loan fast cash claim apply", "The meeting is tomorrow at 10", "The report is ready please review it", "Prize cash reward free claim", "Project documents were sent please take a look", "Free bonus claim cash now", "The class timetable changed I will let you know", ] label = [1, 1, 0, 0, 1, 0, 1, 0] # 1 = spam vec = CountVectorizer() Xt = vec.fit_transform(messages) nb = MultinomialNB().fit(Xt, label) new = ["free cash claim now", "the report will be ready tomorrow"] e = nb.predict_proba(vec.transform(new)) for text, p in zip(new, e): print(f' "{text}"') print(f" spam probability: {p[1]:.4f} -> {'SPAM' if p[1] > 0.5 else 'normal message'}")
"free cash claim now"
spam probability: 0.9976 -> SPAM
"the report will be ready tomorrow"
spam probability: 0.0228 -> normal message
The model worked with just 8 examples. That's Naive Bayes's main advantage: it gets going with very little data.
The zero-frequency problem and smoothing
If a new message contains a word that never appeared in training, its probability is 0. One zero in a product makes the whole result zero.
The fix: Laplace smoothing: a small number is added to every count. In sklearn this is on by default as alpha=1.0.
Set alpha=0 and the model falls over on the first unknown word.
print("The effect of smoothing:") unknown = ["a message written in completely unfamiliar words"] for a in [1.0, 1e-10]: nb_a = MultinomialNB(alpha=a).fit(Xt, label) p = nb_a.predict_proba(vec.transform(unknown))[0] print(f" alpha={a:<8} spam probability = {p[1]:.6f}")
The effect of smoothing: alpha=1.0 spam probability = 0.500000 alpha=1e-10 spam probability = 0.500000
Which one to choose
show(pd.DataFrame([ {"Model": "KNN", "Good when": "Small data, few columns, local patterns", "Bad when": "Many columns, big data, real time", "Scaling": "REQUIRED"}, {"Model": "GaussianNB", "Good when": "You need a very fast baseline", "Bad when": "The features are strongly related", "Scaling": "Not needed"}, {"Model": "MultinomialNB", "Good when": "TEXT classification, spam, little data", "Bad when": "Continuous numbers", "Scaling": "Not needed"}, ]))
| Model | Good when | Bad when | Scaling |
|---|---|---|---|
| KNN | Small data, few columns, local patterns | Many columns, big data, real time | REQUIRED |
| GaussianNB | You need a very fast baseline | The features are strongly related | Not needed |
| MultinomialNB | TEXT classification, spam, little data | Continuous numbers | Not needed |
Use Naive Bayes as a baseline
In Chapter 7 we used DummyClassifier as the baseline. In text classification, MultinomialNB is a good second baseline: it trains instantly and often gives surprisingly good results.
If your complex transformer model can't beat Naive Bayes, the problem isn't the model, it's the data.
What we learned today
show(pd.DataFrame([ {"Concept": "The KNN idea", "In one sentence": "Learns nothing: it looks at the neighbours"}, {"Concept": "The cost of KNN", "In one sentence": "Fast fit, slow predict; it keeps the whole training set"}, {"Concept": "KNN and scaling", "In one sentence": "Without it, the large-scale column drowns out the rest"}, {"Concept": "The role of K", "In one sentence": "Small K overfits, large K underfits"}, {"Concept": "The curse of dimensionality", "In one sentence": "With many columns, every point is equally far away"}, {"Concept": "Naive Bayes", "In one sentence": "Bayes' theorem + the false assumption that features are independent"}, {"Concept": "NB's home", "In one sentence": "Text classification: MultinomialNB"}, {"Concept": "Smoothing", "In one sentence": "alpha=1 solves the zero-frequency problem"}, ]))
| Concept | In one sentence |
|---|---|
| The KNN idea | Learns nothing: it looks at the neighbours |
| The cost of KNN | Fast fit, slow predict; it keeps the whole training set |
| KNN and scaling | Without it, the large-scale column drowns out the rest |
| The role of K | Small K overfits, large K underfits |
| The curse of dimensionality | With many columns, every point is equally far away |
| Naive Bayes | Bayes' theorem + the false assumption that features are independent |
| NB's home | Text classification: MultinomialNB |
| Smoothing | alpha=1 solves the zero-frequency problem |
The next chapter is the last one of Part 4: the model that looks for the widest street.
Questions about this chapter?
The course, homework review and the other five books are in Uzbek. Questions in English are welcome on Telegram.