learn-classification-regression-with-phoebe / Session 5 of 10
Learn Classification & Regression with Phoebe · Session 5 of 10

k-nearest neighbors - just ask the neighbors

Every animal so far learned something: linear regression learned a slope, logistic learned weights, gradient descent showed you the learning itself. Today's animal learns nothing. kNN stores the training data and, when a new Lumen session arrives, finds the k most similar past sessions and lets them vote. That is the whole algorithm - and it hides two of the most instructive lessons in the zoo: k is a bias-variance dial you can feel with your hands, and distance-based models live or die by feature scaling. By the end you will have swept k from 1 (a coin flip) to 101 (kNN's ceiling here, ~0.64 AUC), proven the scaling effect with real numbers, and met the curse of dimensionality face to face.

🟡 Core zoo Builders · DA / DE / DS Python · scikit-learn 45 min
0-3 · Setup 3-18 · Neighbors, k, and distance 18-40 · Build: the k sweep 40-45 · Wrap
Part 0

The first instance-based animal

Session 1 drew the zoo map with two families: model-based animals compress the data into learned numbers, instance-based animals keep the data and look things up. kNN is the purest instance-based animal there is - there is no training step at all. .fit() just files the rows away. All the work happens at prediction time: measure the distance from the new point to every stored point, take the k closest, and vote (classification) or average (regression). No loss function, no descent, no coefficients to read - which means every ounce of its behavior comes from three choices you make: k, the distance metric, and how you scale the features.

Live - built in session Self-study - run after ★ The one big idea The case: Lumen Skincare
★ What you walk out with today You will choose k with your eyes open - having watched k=1 memorize its way to a coin flip (0.50 AUC) and k=101 smooth its way to kNN's ceiling here (~0.64) - and you will never ship an unscaled distance model again, because you will have measured exactly what skipping the scaler costs on Lumen: 0.64 down to 0.58. Plus kNN regression, the curse of dimensionality, and the honest reason kNN sits below logistic on our leaderboard.
Part 1 · the idea

The algorithm with no training step 7 min live

Here is the entire mechanism. Store every training row. When a new session arrives, compute its distance to all stored rows, keep the k nearest, and let them decide: majority vote for a class, average for a number. The "model" you deploy is literally the training set plus a lookup rule - which is why session 1 called kNN "non-generalizing": it never compresses the data into a generalization, it remembers.

One prediction, k=5: measure, rank, vote new session? dashed circle = distance to the 5th neighbor The vote (k=5) converted neighbors: 3 not-converted neighbors: 2 predict: converts Regression flavor: same 5 neighbors, average their order values instead. The model IS the training set - fit() files rows away, and every prediction is a fresh lookup among them.
🔍 Click to zoom - one kNN prediction from start to finish
LiveThe vote, and how to weight it2 min▶

Default kNN uses weights="uniform": each of the k neighbors gets exactly one vote, whether it sits right on top of the query or way out at the edge of the neighborhood. The alternative, weights="distance", weights each vote by 1/distance - closer neighbors count more, far ones fade.

Distance weighting softens the biggest annoyance of uniform voting: a neighborhood where 51 lukewarm far-away neighbors outvote 50 near-identical ones. It also makes predictions vary more smoothly as k grows, because adding one more distant neighbor barely moves the answer. You will try it in the homework at k=25 and see whether it buys anything on Lumen.

LiveWhat "distance" means - euclidean, manhattan, minkowski2 min▶

sklearn's default metric is minkowski with p=2, which is just euclidean distance - the straight-line ruler. Set p=1 and you get manhattan distance: sum of the absolute differences per feature, the "walk the city grid" ruler that is gentler on single-feature outliers. Any p in between (or beyond) is the general minkowski family.

On Lumen the choice barely moves the needle - the features are few and, after scaling, comparable. The metric matters more in high dimensions or on features with heavy tails. The knob to remember: KNeighborsClassifier(metric="minkowski", p=1) flips you to manhattan without changing anything else.

Self-studyThe tie warning in the docs1 min▶

A subtle honesty note straight from the sklearn neighbors guide: if the k-th and (k+1)-th neighbors sit at exactly the same distance but carry different labels, which one makes the cut depends on the ordering of the training data. Rare with continuous features, more common with one-hot grids where many points share coordinates - and worth knowing before you chase a "non-deterministic model" ghost in a debugging session.

Practical hygiene: prefer odd k for binary problems so the vote itself cannot tie.

Self-studyProduction memory - the bill for skipping training2 min▶

No training step sounds free until deployment day: kNN ships the training data as the model. Every prediction scans (or tree-searches) the stored rows, so prediction cost grows with training size - the exact opposite of logistic regression, which trains slowly-ish once and then predicts with a handful of multiplications forever.

This is session 1's fraud-team story coming home: the 40 GB kNN scorer next to the kilobyte logistic model. Same accuracy conversation, wildly different memory bill.

Rule of thumb: kNN is a superb prototyping and sanity-check model, and a defensible production model only when the training set is small, low-dimensional, and rarely refreshed.
Part 2 · the dial

k is the bias-variance dial 9 min live

Every algorithm has a flexibility knob - intro-ml drew the U-curve with tree depth, session 2 blew it up with polynomial degree. kNN's knob is k, and it runs backwards from the others: small k = maximum flexibility (each prediction trusts one loud neighbor), large k = maximum smoothing (each prediction polls a whole district). Here is the dial swept on Lumen, full features, scaled, test ROC-AUC:

kTest ROC-AUCWhat the dial is doing
10.504a coin flip - pure memorization of noise
50.533still trusting tiny, noisy crowds
250.592smoothing starts to pay
1010.640kNN's ceiling on this data
LivePlay: the k dial on the boundary explorer3 min▶

Drag k on the moons - watch the boundary wrap around every stray point at k=1 (memorization) and blur into an over-smoothed blob by k=75.

LiveWhy k=1 memorizes - and why that reads as a coin flip2 min▶

At k=1, every training point's nearest neighbor is itself, so training accuracy is essentially perfect - the model has memorized the data, noise and all. But a new session lands next to whichever old session happens to sit closest, and at Lumen's ~3% conversion rate in a crowded feature space, that single neighbor is nearly uninformative: test AUC 0.504, statistically a coin flip.

This is the sharpest overfitting demonstration in the whole course precisely because nothing was "trained": memorization is not a training pathology, it is a flexibility pathology. Any model flexible enough to store the data will happily do so.

LiveBig k smooths - the sklearn guidance2 min▶

The sklearn guide's advice on choosing k is refreshingly plain: a larger k suppresses the effects of noise, but makes the classification boundaries less distinct. On clean, balanced data you would tune k somewhere modest. On imbalanced data like Lumen - roughly 1 converter per 30 sessions - a converter's local neighborhood is mostly non-converters, so small k drowns the signal in noise and large k keeps helping: 0.504 → 0.533 → 0.592 → 0.640 across our sweep.

But the dial does not rise forever - crank k far enough and every neighborhood contains the same city-wide average, boundaries blur, and performance slides back down. You saw exactly that over-smoothing on the moons at k=75. Where the peak sits is an empirical question, which is homework.

Self-studykNN regression - the whole dial in one row2 min▶

Swap the vote for an average and kNN predicts numbers: KNeighborsRegressor on the 1,306 Lumen buyers, predicting order value. The k sweep tells the entire bias-variance story in four values of R²:

kTest R²Reading
1-0.10worse than predicting the mean!
50.40usable
250.46the peak
1010.39over-smoothed - averaging too broad a crowd

Note the two failure modes bracket the peak: k=1 memorizes (negative R² - you would score better predicting $73 for everyone), k=101 over-smooths (every prediction drifts toward the global mean). Same U-curve as tree depth and polynomial degree, third costume.

Part 3 · the fine print

Scaling and the curse 7 min live

Distance treats every unit as equal. On Lumen, prior_30d_spend runs 0-400 while pages_viewed runs 1-15 - so in raw units, a $50 spend difference dwarfs a 10-page browsing difference, and "nearest" quietly becomes "nearest in spend, pages be damned". Standardize both to mean 0 and SD 1 and the vote becomes fair. This is not a style preference; on Lumen it is worth six points of AUC: scaled k=101 hits 0.64, unscaled 0.58.

The same neighborhood, before and after StandardScaler Unscaled - spend drowns pages spend $0-400 pages 1-15 a 3-page gap looks huge, a $100 gap looks tiny Scaled - both features count spend, standardized pages, standardized round neighborhoods, honest distances Scale first or the biggest unit picks the neighbors. On Lumen the difference is measured: AUC 0.58 vs 0.64.
🔍 Click to zoom - why StandardScaler is mandatory for distance models
LiveThe scaling proof, in numbers3 min▶

Same pipeline, same k=101, one change - pass the numerics through raw instead of standardizing them - and test AUC drops from 0.64 to 0.58. Nothing else moved. In the unscaled run, prior_30d_spend's 0-400 range dominates every distance computation, so the neighbor sets are effectively chosen by spend alone; pages, hour, and most of the one-hot signal get shouted down.

The fix is one line in the ColumnTransformer, and the discipline you already own from intro-ml applies: the scaler lives inside the pipeline and is fit on train only, so no test-set statistics leak in.

Real worldA retention team once "upgraded" a churn model's spend feature from dollars to cents for consistency with the billing system - and their kNN scorer's recall quietly cratered. Multiplying one feature by 100 rewrote every neighborhood in the model. Nobody touched the algorithm; everybody trusted the units. Distance models make your unit choices load-bearing.
Self-studybrute, kd_tree, ball_tree - how sklearn finds neighbors fast2 min▶

Naively, each prediction compares the query to all N stored rows - algorithm="brute", exact and fine for small data. For bigger data sklearn can pre-organize the rows into a kd_tree (splits space along coordinate axes; shines in low dimensions) or a ball_tree (nests points inside hyperspheres; holds up better as dimensions climb). Both prune away whole regions that provably cannot contain a nearest neighbor.

The default algorithm="auto" chooses for you, and its rules are worth knowing: it falls back to brute force when the data is sparse or when dimensionality is high (above 15 or so, tree structures stop paying for themselves - our one-hot-heavy Lumen matrix is exactly that case). The leaf_size knob (default 30) tunes when the trees stop splitting and hand over to brute force at the leaves - it affects speed and memory, never the predictions.

LiveThe curse of dimensionality2 min▶

Distance-based reasoning degrades as dimensions pile up. In high-dimensional space, everything is far from everything - the volume grows so fast that your training points become lonely, and the gap between the nearest and farthest neighbor shrinks until "nearest" barely means anything. The vote is only as good as the neighbors' relevance, and in high dimensions relevance thins out.

Lumen after one-hot encoding lives in roughly 20 dimensions - modest, but enough to blunt the tool. That, plus the harsh class imbalance, is the honest reason kNN tops out at 0.64 here while logistic - which learns a global pattern instead of trusting local crowds - reaches 0.69. Not a broken model; a mismatched one. Session 9 turns this instinct into a checklist.

Build-along

The k sweep, scaled and unscaled, both tasks ★ 18 min · everyone builds

One script, three experiments: sweep k on the classifier and watch the dial climb from coin flip to ceiling, rerun the winner without scaling to price the mistake, then sweep the regressor on the 1,306 buyers and watch the same U-curve appear in R².

Build the pipeline. The same ColumnTransformer as session 1 - one-hot the categoricals, standardize the numerics - with KNeighborsClassifier at the end. The scaler inside the pipeline is the whole ballgame today.

Sweep the classifier. Loop k over 1, 5, 25, 101 and print test ROC-AUC. Watch memorization at k=1 become smoothing by k=101.

Price the unscaled sin. Swap the scaler for "passthrough", rerun k=101, and watch six points of AUC evaporate.

Sweep the regressor. Same dial on the buyers subset with KNeighborsRegressor - k=1 goes negative, k=25 peaks.

knn_sweep.py - the whole session in one scriptimport pandas as pd from sklearn.model_selection import train_test_split from sklearn.compose import ColumnTransformer from sklearn.preprocessing import OneHotEncoder, StandardScaler from sklearn.pipeline import Pipeline from sklearn.neighbors import KNeighborsClassifier, KNeighborsRegressor from sklearn.metrics import roc_auc_score, r2_score df = pd.read_parquet("lumen_sessions.parquet") cat = ["channel", "device", "new_vs_returning", "product_category"] num = ["pages_viewed", "session_hour", "prior_30d_spend"] X, y = df[cat + num], df["converted"] X_tr, X_te, y_tr, y_te = train_test_split( X, y, test_size=0.25, random_state=42, stratify=y) prep = ColumnTransformer([ ("cat", OneHotEncoder(handle_unknown="ignore"), cat), ("num", StandardScaler(), num)]) for k in [1, 5, 25, 101]: pipe = Pipeline([("prep", prep), ("knn", KNeighborsClassifier(n_neighbors=k))]).fit(X_tr, y_tr) proba = pipe.predict_proba(X_te)[:, 1] print(f"k={k:3d} ROC-AUC {roc_auc_score(y_te, proba):.3f}") # k= 1 ROC-AUC 0.504 <- memorization = coin flip # k= 5 ROC-AUC 0.533 # k= 25 ROC-AUC 0.592 # k=101 ROC-AUC 0.640 <- kNN's ceiling here # Experiment 2: same model, numerics passed through UNSCALED prep_raw = ColumnTransformer([ ("cat", OneHotEncoder(handle_unknown="ignore"), cat), ("num", "passthrough", num)]) pipe = Pipeline([("prep", prep_raw), ("knn", KNeighborsClassifier(n_neighbors=101))]).fit(X_tr, y_tr) print(f"unscaled k=101 ROC-AUC " f"{roc_auc_score(y_te, pipe.predict_proba(X_te)[:, 1]):.2f}") # unscaled k=101 ROC-AUC 0.58 <- the biggest unit picked the neighbors # Experiment 3: kNN regression on the buyers (n=1,306) buyers = df[df["converted"] == 1] bcat = ["product_category", "channel", "new_vs_returning"] Xb, yb = buyers[bcat + ["prior_30d_spend"]], buyers["order_value"] Xb_tr, Xb_te, yb_tr, yb_te = train_test_split( Xb, yb, test_size=0.25, random_state=42) prep_b = ColumnTransformer([ ("cat", OneHotEncoder(handle_unknown="ignore"), bcat), ("num", StandardScaler(), ["prior_30d_spend"])]) for k in [1, 5, 25, 101]: pipe = Pipeline([("prep", prep_b), ("knn", KNeighborsRegressor(n_neighbors=k))]).fit(Xb_tr, yb_tr) print(f"k={k:3d} R2 {r2_score(yb_te, pipe.predict(Xb_te)):.2f}") # k= 1 R2 -0.10 <- worse than predicting the mean! # k= 5 R2 0.40 # k= 25 R2 0.46 <- the peak # k=101 R2 0.39 <- over-smoothed
If your numbers differ in the 3rd decimal: normal library-version jitter. If the unscaled run does NOT drop below the scaled run, check that "passthrough" actually replaced the scaler - a scaler accidentally left upstream of the split is the classic false negative here.
Homework

Before session 6 ◐ 25 min

Check yourself

Three questions before you go 🎯 ◐ 90 seconds

1 · kNN with k=1 scores ~0.50 test AUC on Lumen - a coin flip. Why?

There is no training to go wrong - k=1 is a flexibility pathology. Train accuracy is essentially perfect (each point predicts itself), while a new session inherits the label of one nearly-random neighbor: 0.504 AUC, statistically a coin flip.

2 · Standardizing the numerics moved k=101 from 0.58 to 0.64 AUC. What was going wrong before?

Distance treats every unit as equal, so the feature with the biggest range picks the neighbors. Standardizing puts spend, pages, and hour on the same footing - worth six points of AUC on Lumen, measured, not asserted.

3 · In the regression sweep, k=101 (R² 0.39) fell below k=25 (R² 0.46). What happened?

The dial has two bad ends: k=1 memorizes (R² -0.10, worse than predicting the mean) and k=101 over-smooths. The peak in between - k=25, R² 0.46 - is the bias-variance sweet spot, found by validation, never by faith.

Source material

What this session covers

Session 5 covers the kNN material from the anchor sources - the StatQuest episode and the sklearn nearest-neighbors guide. Videos and full API references stay with the official providers.

✓StatQuest: K-Nearest Neighbors - the whole episodeParts 1-2
✓sklearn 1.6: KNeighborsClassifier and KNeighborsRegressorParts 1-2 + demo
✓sklearn 1.6: choice of k - larger k suppresses noise, blurs boundariesPart 2
✓sklearn 1.6: weights - uniform vs distancePart 1 + homework
◐sklearn 1.6: brute / kd_tree / ball_tree, auto rules, leaf_sizeone self-study card
✓Curse of dimensionality - why kNN thins out in high dimensionsPart 3
◐sklearn 1.6: tie warning - equal-distance neighbors depend on data orderone self-study card
✓Feature scaling for distance models - taught here with the 0.58 vs 0.64 proof, though the tip officially lives in sklearn's preprocessing docs, not the neighbors pagePart 3, honestly sourced
✗NCA, radius-based classifier, nearest-centroidnot covered by design

Session 5 cheat sheet · pin this

No training stepfit() stores the data; prediction = find k nearest, vote (classify) or average (regress). The model IS the training set - flexibility for memory and prediction speed.
The k dialSmall k = flexible = memorizes; big k = smooth = blurs. Lumen sweep: k=1 → 0.50 (coin flip), k=5 → 0.53, k=25 → 0.59, k=101 → 0.64 AUC.
Scale or dieDistance models require StandardScaler inside the pipeline: k=101 scores 0.64 scaled vs 0.58 unscaled. The biggest unit picks the neighbors otherwise.
Curse of dimensionalityIn high dimensions everything is far from everything and "nearest" loses meaning. Lumen's ~20 one-hot dims + imbalance = why kNN tops out at 0.64 vs logistic's 0.69.
kNN regressionAverage the neighbors: k=1 → R² -0.10 (worse than the mean!), k=25 → 0.46 (peak), k=101 → 0.39 (over-smoothed). Same U-curve, third costume.
Fast lookupalgorithm="auto" picks brute vs kd_tree vs ball_tree; brute wins when dims > ~15 or data is sparse; leaf_size tunes speed, never predictions.