Skip to content

KNN and Naive Bayes

Chapter 21 14 code blocks 3221 words

The code in this chapter uses these filesrun it on your own machine

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.

python28 lines
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]}")
Output
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
xonalarmaydon_m2qavatqavatlar_sonimetro_kmmebelanswerdistance
471.78101.27010.65
489.78112.44000.682
483.29122.94000.765
489.67102.44000.798
490.47102.5000.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.

python10 lines
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")
Output
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:

python8 lines
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}")
Output
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

python12 lines
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}")
Output
    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.

python7 lines
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).")
Output
Rule: make K an ODD number (so there are no tied votes in binary classification).
KModelRisk
small (1-3)Very flexibleOverfitting, sensitive to noise
medium (5-20)BalancedUsually the best
large (100+)Very smoothUnderfitting: the same answer everywhere

The distance metric

python13 lines
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")
Output
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.

python8 lines
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}")
Output
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.

python14 lines
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}")
Output
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.

python13 lines
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")
Output
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
python8 lines
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"},
]))
VariantKind of dataAssumptionExample
GaussianNBContinuous numbersEach feature is normally distributedHeight, weight, price
MultinomialNBCountsWord frequenciesSPAM FILTER: text classification
BernoulliNBBinary (0/1)A word is present/absentShort texts

Naive Bayes's real home: text

On numeric columns Naive Bayes is average. But in text classification it's still strong.

python23 lines
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'}")
Output
  "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.

python6 lines
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}")
Output
The effect of smoothing:
  alpha=1.0      spam probability = 0.500000
  alpha=1e-10    spam probability = 0.500000

Which one to choose

python11 lines
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"},
]))
ModelGood whenBad whenScaling
KNNSmall data, few columns, local patternsMany columns, big data, real timeREQUIRED
GaussianNBYou need a very fast baselineThe features are strongly relatedNot needed
MultinomialNBTEXT classification, spam, little dataContinuous numbersNot 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

python10 lines
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"},
]))
ConceptIn one sentence
The KNN ideaLearns nothing: it looks at the neighbours
The cost of KNNFast fit, slow predict; it keeps the whole training set
KNN and scalingWithout it, the large-scale column drowns out the rest
The role of KSmall K overfits, large K underfits
The curse of dimensionalityWith many columns, every point is equally far away
Naive BayesBayes' theorem + the false assumption that features are independent
NB's homeText classification: MultinomialNB
Smoothingalpha=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.

Write on Telegram Contents