Session 8: Classification Analysis - KNN and Friends
Published course presentation · repository edition
Thierry Warin, PhD
HEC Montréal
Outline · The hour ahead
K-nearest neighbours, explained
Cross-validation: why it is magical
A financial example: can KNN predict the stock market?
Big lessons from ML
What session 8 is for
The mathematics
Explain how KNN classifies, by the vote of the \(K\) nearest neighbours
Find nearest neighbours by hand from a Euclidean distance
Decompose test error into squared bias, variance and irreducible error
Say why cross-validation estimates out-of-sample error, and what classical in-sample fit cannot tell you
The Python
Standardise, split, and choose \(k\) by cross-validation
Measure a classifier with AUC rather than accuracy alone
Fit the simple alternative too, and let it win if it does
The international business
Classify firms or countries when you have no theory of the boundary
Say why a flexible method that scores 0.51 has told you something
Justify complexity against a simpler model that was actually fitted
Your Python so far
Everything you need to type is on one sheet — how to start Python in VS Codium on your platform, how to make a file and run it, the DataFrame basics, and every name from sessions 1 to 8, each with a line showing it in use.
Print it, or keep it open beside the editor. Nothing on it is examinable; all of it is assumed.
Quote of the day
"If you live 5 minutes away from Bill Gates, I bet you are rich."
The quote is the whole idea of this session. To guess something about you, look at the people who live closest to you. That is K-nearest neighbours.
K-nearest neighbours, explained
Classification: predicting a category
In sessions 2 to 6, most of what we predicted was a number: a wage, a volume of exports, a price. Today the outcome is a category: Up or Down, crisis or no crisis, red or green. Predicting a category is called classification, and a model that does it is a classifier.
A classifier is judged by its mistakes. We count how often its prediction \(\hat y_i\) differs from the truth \(y_i\):
The training error rate is the share of mistakes on the observations used to fit the model:
where \(I(y_i \neq \hat y_i)\) is 1 if observation \(i\) is misclassified and 0 otherwise. So the formula simply counts the mistakes and divides by \(n\).
The test error rate is the same share, computed on observations the model has not seen: \(\text{Ave}\big(I(y_0 \neq \hat y_0)\big)\)
Keep this distinction in mind for the whole session. The training error tells us how well the model remembers the past. The test error tells us how well it will do on new cases, and that is the only one we care about.
KNN has no equation, no coefficients and no theory of how the data were produced. It rests on one intuition: observations that look alike tend to belong to the same class.
To classify a new observation, KNN does three things:
It measures the distance between the new observation and every observation in the training data.
It keeps the \(K\)closest ones, its “nearest neighbours”. \(K\) is a number we choose: 1, 3, 25, …
It lets them vote. The new observation gets the class that most of its neighbours have.
That is the whole algorithm. Everything else in this session is about two questions: how to measure “close”, and how many neighbours to ask.
Given a positive integer \(K\) and a new observation \(x_0\), KNN finds the \(K\) training points closest to \(x_0\) (call that set \(N_0\)). It estimates the probability of each class \(j\) as the share of the neighbours that belong to it:
Then \(x_0\) goes to the class with the largest share.
Read the formula out loud: “among my \(K\) neighbours, count those of class \(j\), and divide by \(K\).” With \(K = 5\) and three blue neighbours, \(\widehat{\Pr}(\text{blue}) = 3/5 = 0.6\), so the prediction is blue.
KNN with K = 3, in a picture
Left. We want to classify the black cross, with \(K = 3\). KNN finds the three closest observations (the circle): two blue, one orange. So \(\widehat{\Pr}(\text{blue}) = 2/3\), \(\widehat{\Pr}(\text{orange}) = 1/3\), and the cross is predicted blue.
Right. Now do the same vote at every point of the plane. Where blue wins, the plane is shaded blue; where orange wins, orange. The line between the two colours is the \(K = 3\)decision boundary: on one side of it, KNN says blue; on the other, orange.
KNN by hand: you are the algorithm
Before a computer does it, we do it ourselves, with a pencil. Six observations, one new point, and the three steps of KNN: distance, nearest, vote.
Green. The single nearest neighbour of the test point is observation 5 (\(d = 1.414\)), and it is Green.
Obs.
\(Y\)
\(d\)
nearest?
5
Green
1.414
✔ 1st
6
Red
1.732
2
Red
2.000
4
Green
2.236
1
Red
3.000
3
Red
3.162
What is our prediction with \(K = 3\)? Why?
Red. The three nearest neighbours are observations 5, 6 and 2. Two of the three are Red, so \(\widehat{\Pr}(\text{Red}) = 2/3\).
Obs.
\(Y\)
\(d\)
nearest?
5
Green
1.414
✔ 1st
6
Red
1.732
✔ 2nd
2
Red
2.000
✔ 3rd
4
Green
2.236
1
Red
3.000
3
Red
3.162
# ---- Setup: the previous slide, condensed, so this cell runs on its own ----import numpy as npimport pandas as pdby_hand = pd.DataFrame({"X1": [0, 2, 0, 0, -1, 1], "X2": [3, 0, 1, 1, 0, 1],"X3": [0, 0, 3, 2, 1, 1],"Y": ["Red", "Red", "Red", "Green", "Green", "Red"]}, index=[1, 2, 3, 4, 5, 6])by_hand["distance"] = np.sqrt(by_hand["X1"]**2+ by_hand["X2"]**2+ by_hand["X3"]**2)by_hand = by_hand.sort_values("distance")# ---- Predict ----# The prediction is the most common colour among the K nearest rows.print("K = 1 predicts:", by_hand["Y"].iloc[:1].mode()[0])print("K = 3 predicts:", by_hand["Y"].iloc[:3].mode()[0])
K = 1 predicts: Green
K = 3 predicts: Red
The choice of K: how many neighbours?
\(K = 1\): each point is classified by its single nearest neighbour. The boundary wraps around every stray blue point in orange territory, and every stray orange point in blue. The training error is 0, because every point is its own nearest neighbour, yet on new points it makes 16% mistakes. Low bias, high variance: the model believes every detail of the sample, including the noise.
\(K = 11\): the boundary follows the curve in the data, and the test error, 12%, is about the best any rule can do on these data. KNN knows nothing about how the data were generated, yet it finds the shape.
\(K = 100\): half the data votes on every point, so the boundary is almost a straight line and misses the bend in the data. High bias, low variance: the model is stable, but too simple.
Bias and variance, in three pictures
What we just saw with \(K\) is a general law of statistical learning, the bias–variance trade-off. A rigid model is wrong in the same way every time (bias). A flexible model is right on average but changes a lot from one sample to the next (variance). The best model sits in between.
Watch a decision boundary being built
Everything in this section — a boundary, a class region, the cost of flexibility — can be watched happening, in a browser, in about four minutes.
The problem is two concentric rings, and the inputs are only \(X_1\) and \(X_2\) — the raw coordinates, nothing engineered.
It is a neural network rather than anything this course fits, and that does not matter here. What is on screen is a decision boundary and a test error, which are the two objects of this session.
Four things to try, and what each one shows
1 · Remove both hidden layers, leaving the inputs wired straight to the output. Run it. The boundary is a straight line, and it cannot separate a ring from its centre — the error stalls near 0.5. This is the limit that made session 4’s logistic regression a line, and it is why we need something else.
2 · Put the layers back and run it again. A closed curve forms. Nothing told it to draw a circle; the hidden units composed one out of straight pieces. That is what “flexible” buys you.
3 · Now add capacity — eight neurons in each of four layers. Watch the two loss numbers separate: training loss keeps falling while test loss turns upward, and the boundary starts bulging toward individual points. You are watching variance, live. It is the same curve as \(K\) getting too small, a few slides back.
4 · Reset, and instead tick the \(X_1^2\) and \(X_2^2\) input features. Now even no hidden layer separates the rings almost perfectly — because \(X_1^2 + X_2^2\) is the radius, and in that coordinate the problem was always linear.
What step 4 was really about
You can buy the non-linearity with model flexibility, or with a feature you thought of yourself. They are substitutes.
The second is cheaper, more stable and easier to defend — when you can think of the feature. Knowing that \(X_1^2 + X_2^2\) is a radius is subject knowledge, not statistics, and it is the part a model cannot supply for you.
Which is the session’s argument in one sentence: complexity is what you spend when understanding runs out. Session 5 penalised that spending; session 6 wrote the non-linearity in by hand as a quadratic; this session measures what it costs when you cannot.
Cross-validation: why it is magical
The question every model must answer
How well will this model do on data it has never seen?
The classical approach (sessions 2 to 6). We fitted a model on all the data, then judged it on that same data: the \(R^2\), the \(t\)-statistics, the \(p\)-values, the standard errors.
Those numbers describe how well the model fits the sample. To say anything about new data, they lean on assumptions: the model is correctly specified, the errors are normal and independent, and so on. If the assumptions fail, the numbers can mislead, and nothing in them warns us.
The problem with flexible models. A flexible model can always fit the sample better. KNN with \(K = 1\) has a training error of 0. A polynomial of degree 20 has an \(R^2\) close to 1. In-sample fit rewards exactly the models that will predict worst.
What we really want is the error on new data, the out-of-sample error. But new data have not arrived yet, and the test set may be used only once, at the very end.
So we need to estimate the out-of-sample error from the data we already have. That is what cross-validation does.
Train, validate, test: three words to know
Every machine-learning project cuts its data into parts, and gives each part one job. The three words below come back in every course, paper and piece of code on the subject.
What it is
What it is used for
In the stock-market example
Training set
The observations the model learns from
Fitting the model. For KNN, the neighbours are looked for here
The days of 2002–2004
Validation set
Observations hidden from the fit, but taken from the training data
Choosing a setting, such as \(K\): the setting with the fewest mistakes on it wins
Each round’s hidden block, in cross-validation
Test set
Observations kept locked away until everything is decided
Judging the final model, once, as if it were new data
The days of 2005
Cross-validation is a way to get a validation set without losing training data: it cuts the training data into folds and lets each fold play “validation” in turn. So the validation set is not a fixed part of the data, but a role that moves.
The golden rule: the test set must never help to choose anything. Once you change the model because of its test score, the test set has become a validation set, and you no longer have an honest measure of how well the model predicts.
The power of sampling
The trick is simple and powerful: if we cannot get new data, we manufacture some from our own sample. Hide part of the data, fit the model on the rest, and test it on the hidden part. For the model, the hidden part is new data. Then do it again with a different part hidden, and again.
Cut the training data into \(k\) folds. Fit on \(k - 1\) of them, count the mistakes on the one left out, rotate, and average:
\(k = 5\) or \(k = 10\) are the usual choices. Every observation is used to fit the model \(k - 1\) times and to test it exactly once. With \(k = n\), each observation is left out on its own: leave-one-out cross-validation.
Classical fit against cross-validation
Classical, in-sample
Cross-validation, out-of-sample
The question it answers
How well does the model describe this sample?
How well will it predict a new observation?
What it needs
A formula for the model, and assumptions about the errors
Only that new data look like the data we have
Which models it works for
Those with a theory: OLS, logit, …
Any model: KNN, trees, forests, networks, …
What it does with a more flexible model
Rewards it: fit always improves
Penalises it as soon as it starts fitting noise
What it says about uncertainty
A standard error, if the assumptions hold
\(k\) errors: their spread shows how stable the result is
The two are not enemies. Cross-validation does not replace the coefficients and \(p\)-values of an explanatory model. It answers a different question, the one that matters when the goal is to predict.
Why it is magical
It needs no theory. No formula for the variance, no assumption on the errors. One idea, sampling, replaces pages of mathematics.
It tunes anything.\(K\) in KNN, \(\lambda\) in ridge and lasso (session 5), the depth of a tree, the size of a neural network: the same loop chooses them all.
It referees between models. KNN against a logit against a random forest, each scored on data it did not see, on equal terms.
It uses every observation twice, once to fit and once to check. That matters when data are scarce, as they usually are in international business.
It tells you how sure to be. It gives \(k\) errors, not one. If two models differ by less than the spread of those errors, the difference may be noise.
Three rules, or it lies. (1) Everything learned from data, such as standardising, happens inside each fold: that is what the Pipeline does in the code. (2) With time series, the folds respect time: never fit on the future to predict the past. (3) Cross-validation chooses; the test set judges, once, at the very end.
A financial example: can KNN predict the stock market?
The question, and the plan
Can the market’s return of two days ago tell us whether it will go up or down today?
We will answer the way a data scientist at a bank would. The work is split into two files, because in real life the two jobs happen at different times:
File 1 · session08_1_train.py — the laboratory. It loads the data, chooses \(K\) by cross-validation, judges the model once on a year it never saw, and saves the trained model in a file.
File 2 · session08_2_predict.py — the trading desk. No data to download, no training. It loads the saved model and predicts one new date: a genuine out-of-sample prediction.
Save both files in one folder of your own, open that folder in VS Codium, run File 1, then File 2.
Once, in the VS Codium terminal: pip install pandas numpy matplotlib scikit-learn
macOS, Python from python.org: if reading the data fails with CERTIFICATE_VERIFY_FAILED, run its certificate installer once, then try again: open /Applications/Python\ 3*/Install\ Certificates.command
The slides that follow show the two files step by step, with their output. In a .py file a result only appears if you print() it, which is why every step prints what it wants you to see.
In scikit-learn, standardisation is StandardScaler().
File 1 · Step 1: the data
Smarket: the S&P 500 index, every trading day from 2001 to 2005, from the textbook An Introduction to Statistical Learning. Each row is one day. We want to predict its Direction from the returns of the days before.
import numpy as npimport pandas as pdimport matplotlib.pyplot as plt# Smarket: 1,250 trading days of the S&P 500, 2001-2005 (ISLR textbook data)URL ="https://raw.githubusercontent.com/intro-stat-learning/ISLP/main/ISLP/data/Smarket.csv"smarket = pd.read_csv(URL)print(smarket.shape) # (rows, columns)print(smarket.head())print(smarket["Direction"].value_counts()) # how many Up days, how many Down
(1250, 9)
Year Lag1 Lag2 Lag3 Lag4 Lag5 Volume Today Direction
0 2001 0.381 -0.192 -2.624 -1.055 5.010 1.1913 0.959 Up
1 2001 0.959 0.381 -0.192 -2.624 -1.055 1.2965 1.032 Up
2 2001 1.032 0.959 0.381 -0.192 -2.624 1.4112 -0.623 Down
3 2001 -0.623 1.032 0.959 0.381 -0.192 1.2760 0.614 Up
4 2001 0.614 -0.623 1.032 0.959 0.381 1.2057 0.213 Up
Direction
Up 648
Down 602
Name: count, dtype: int64
1,250 trading days, 9 variables. We will keep 2002–2005 and give KNN a single predictor, Lag2, the return two days before. Why that slice is explained at the end of the example.
Variable
Description
Year
The year of the observation
Lag1
% return of the market the previous day
Lag2
% return two days before
Lag3 … Lag5
% return three to five days before
Volume
Shares traded (billions)
Today
% return that day
Direction
Up or Down: did the market rise that day? — the label
Today is the answer in disguise (Direction is just its sign), so it must never be a predictor.
File 1 · Steps 2–3: one day aside, then a split by time
Two decisions before any model is fitted, and both protect us from fooling ourselves.
Step 2. We hide one late day, 28 December 2005. File 1 never sees it. File 2 will predict it, as if it were tomorrow. The two days after it are dropped too: on the morning of the 28th, they are the future.
Step 3. We split the rest by time: the model learns from 2002–2004 and is judged on 2005. Never shuffle a time series. A random split would put days of 2005 in the training data, and the model would learn from the future to “predict” the past: its score would be too good to be true.
# ---- Setup: Step 1, condensed, so this cell runs on its own ----import pandas as pdURL ="https://raw.githubusercontent.com/intro-stat-learning/ISLP/main/ISLP/data/Smarket.csv"smarket = pd.read_csv(URL)# ---- Steps 2-3 ----# The slice we chose (why: see "How this example was chosen")smarket = smarket[smarket["Year"] >=2002]predictors = ["Lag2"] # the market's return two days before# Step 2 · Put one late day aside: File 2 will predict itnew_day = smarket.iloc[[-3]] # 28 December 2005history = smarket.iloc[:-3] # every day before itprint(new_day[["Year", "Lag2", "Direction"]])# Step 3 · Split by time: learn from 2002-2004, test on 2005train = history[history["Year"] <2005]test = history[history["Year"] ==2005]X_train, y_train = train[predictors], train["Direction"]X_test, y_test = test[predictors], test["Direction"]print(f"training set: {len(train)} days (2002-2004) <- used to learn and to choose K")print(f"test set: {len(test)} days (2005) <- locked away until Step 7")
Year Lag2 Direction
1247 2005 0.043 Up
training set: 756 days (2002-2004) <- used to learn and to choose K
test set: 249 days (2005) <- locked away until Step 7
File 1 · Step 4: cross-validation folds, in time order
We must choose \(K\) without touching 2005. Cross-validation does it inside the training years. But this is a time series, so the standard folds would break rule (2): they would fit on 2004 to predict 2002. Instead, each round fits on everything up to a date, and validates on the block that comes next.
# ---- Setup: Steps 1-3, condensed, so this cell runs on its own ----import pandas as pdfrom sklearn.model_selection import TimeSeriesSplitURL ="https://raw.githubusercontent.com/intro-stat-learning/ISLP/main/ISLP/data/Smarket.csv"smarket = pd.read_csv(URL)smarket = smarket[smarket["Year"] >=2002]predictors = ["Lag2"]new_day, history = smarket.iloc[[-3]], smarket.iloc[:-3] # 28 Dec 2005, and every day beforetrain, test = history[history["Year"] <2005], history[history["Year"] ==2005]X_train, y_train = train[predictors], train["Direction"]X_test, y_test = test[predictors], test["Direction"]# ---- Step 4 ----folds = TimeSeriesSplit(n_splits=5) # 6 blocks in time order, 5 roundsfor r, (fit_rows, check_rows) inenumerate(folds.split(X_train), start=1):print(f"round {r}: fit on days 1 to {fit_rows[-1] +1}, "f"validate on days {check_rows[0] +1} to {check_rows[-1] +1}")
round 1: fit on days 1 to 126, validate on days 127 to 252
round 2: fit on days 1 to 252, validate on days 253 to 378
round 3: fit on days 1 to 378, validate on days 379 to 504
round 4: fit on days 1 to 504, validate on days 505 to 630
round 5: fit on days 1 to 630, validate on days 631 to 756
File 1 · Step 5: cross-validation by hand, for one K
First, once by hand, for \(K = 25\): there is no magic inside, only a loop.
# ---- Setup: Steps 1-4, condensed, so this cell runs on its own ----import numpy as npimport pandas as pdimport matplotlib.pyplot as pltfrom sklearn.model_selection import TimeSeriesSplit, GridSearchCVfrom sklearn.neighbors import KNeighborsClassifierfrom sklearn.pipeline import make_pipelinefrom sklearn.preprocessing import StandardScalerURL ="https://raw.githubusercontent.com/intro-stat-learning/ISLP/main/ISLP/data/Smarket.csv"smarket = pd.read_csv(URL)smarket = smarket[smarket["Year"] >=2002]predictors = ["Lag2"]new_day, history = smarket.iloc[[-3]], smarket.iloc[:-3] # 28 Dec 2005, and every day beforetrain, test = history[history["Year"] <2005], history[history["Year"] ==2005]X_train, y_train = train[predictors], train["Direction"]X_test, y_test = test[predictors], test["Direction"]folds = TimeSeriesSplit(n_splits=5)# ---- Step 5 ----# Standardise, then KNN, glued into ONE model: the scaler is refitted with# the model each time, on the fitting rows only, so it never peeks at the hidden block.model = make_pipeline(StandardScaler(), KNeighborsClassifier(n_neighbors=25))errors = []for r, (fit_rows, check_rows) inenumerate(folds.split(X_train), start=1): model.fit(X_train.iloc[fit_rows], y_train.iloc[fit_rows]) # learn accuracy = model.score(X_train.iloc[check_rows], y_train.iloc[check_rows]) # test on the hidden block errors.append(1- accuracy)print(f"round {r}: error on the hidden block = {1- accuracy:.3f}")print(f"K = 25: cross-validated error = {np.mean(errors):.3f}")
round 1: error on the hidden block = 0.500
round 2: error on the hidden block = 0.571
round 3: error on the hidden block = 0.540
round 4: error on the hidden block = 0.492
round 5: error on the hidden block = 0.563
K = 25: cross-validated error = 0.533
What 0.533 means. Each round’s error is the share of hidden days the model called wrongly (0.500 = 50 wrong days in 100). The cross-validated error is their average, \((0.500 + 0.571 + 0.540 + 0.492 + 0.563)/5 = 0.533\): with \(K = 25\) the model is wrong on about 53% of days it has not seen, worse than a coin. The spread across rounds, 0.492 to 0.571, shows how much one \(K\)’s error moves from one period to the next. An error is \(1 -\) accuracy (0.533 error = 0.467 accuracy). \(K = 25\) is not a good choice here: Step 6 tries every \(K\).
File 1 · Step 6: cross-validation for every K at once
GridSearchCV is exactly the loop of Step 5, run for every \(K\) in a list. It keeps the \(K\) with the best cross-validated accuracy, and refits the model with it on all the training days.
# ---- Setup: Steps 1-4, condensed, so this cell runs on its own ----import numpy as npimport pandas as pdimport matplotlib.pyplot as pltfrom sklearn.model_selection import TimeSeriesSplit, GridSearchCVfrom sklearn.neighbors import KNeighborsClassifierfrom sklearn.pipeline import make_pipelinefrom sklearn.preprocessing import StandardScalerURL ="https://raw.githubusercontent.com/intro-stat-learning/ISLP/main/ISLP/data/Smarket.csv"smarket = pd.read_csv(URL)smarket = smarket[smarket["Year"] >=2002]predictors = ["Lag2"]new_day, history = smarket.iloc[[-3]], smarket.iloc[:-3] # 28 Dec 2005, and every day beforetrain, test = history[history["Year"] <2005], history[history["Year"] ==2005]X_train, y_train = train[predictors], train["Direction"]X_test, y_test = test[predictors], test["Direction"]folds = TimeSeriesSplit(n_splits=5)# ---- Step 6 ----# K = 1, 3, 5, ..., 125: odd numbers, so a two-class vote can never tie. It stops at 125# because the first round fits on only 126 days, and K cannot be larger than that.# "kneighborsclassifier__n_neighbors" = inside the pipeline, the KNN step's n_neighbors.k_values =list(range(1, 126, 2))search = GridSearchCV(make_pipeline(StandardScaler(), KNeighborsClassifier()), {"kneighborsclassifier__n_neighbors": k_values}, cv=folds)search.fit(X_train, y_train)best_k = search.best_params_["kneighborsclassifier__n_neighbors"]majority = y_train.value_counts(normalize=True).max() # share of the commonest classprint(f"best K = {best_k}")print(f"cross-validated accuracy at that K = {search.best_score_:.3f}")print(f"always predicting the commonest direction = {majority:.3f} <- the bar to beat")
best K = 123
cross-validated accuracy at that K = 0.511
always predicting the commonest direction = 0.516 <- the bar to beat
# ---- Setup: Steps 1-6, condensed, so this cell runs on its own ----import numpy as npimport pandas as pdimport matplotlib.pyplot as pltfrom sklearn.model_selection import TimeSeriesSplit, GridSearchCVfrom sklearn.neighbors import KNeighborsClassifierfrom sklearn.pipeline import make_pipelinefrom sklearn.preprocessing import StandardScalerURL ="https://raw.githubusercontent.com/intro-stat-learning/ISLP/main/ISLP/data/Smarket.csv"smarket = pd.read_csv(URL)smarket = smarket[smarket["Year"] >=2002]predictors = ["Lag2"]new_day, history = smarket.iloc[[-3]], smarket.iloc[:-3] # 28 Dec 2005, and every day beforetrain, test = history[history["Year"] <2005], history[history["Year"] ==2005]X_train, y_train = train[predictors], train["Direction"]X_test, y_test = test[predictors], test["Direction"]folds = TimeSeriesSplit(n_splits=5)k_values =list(range(1, 126, 2))search = GridSearchCV(make_pipeline(StandardScaler(), KNeighborsClassifier()), {"kneighborsclassifier__n_neighbors": k_values}, cv=folds).fit(X_train, y_train)best_k = search.best_params_["kneighborsclassifier__n_neighbors"]majority = y_train.value_counts(normalize=True).max()# ---- The curve ----plt.figure(figsize=(8, 3))plt.plot(k_values, search.cv_results_["mean_test_score"], "o-", markersize=3, label="KNN, cross-validated accuracy")plt.axhline(majority, color="red", linestyle="--", label="always predict the commonest direction")plt.axvline(best_k, color="grey", linestyle=":", label=f"chosen K = {best_k}")plt.xlabel("K (number of neighbours)")plt.ylabel("accuracy on the hidden blocks")plt.legend()plt.tight_layout()plt.show() # in a script: close the plot window to let it continue
What “best K = 123” means.GridSearchCV ran the loop of Step 5 for each of the 63 values \(K = 1, 3, \dots, 125\), and kept the one with the highest average accuracy on the five hidden blocks. That is \(K = 123\): to predict a day, the model finds the 123 past days whose Lag2 was closest, and follows their majority. With so many voters, the boundary is nearly flat. It scored 0.511: right on 51.1% of the hidden days. “Best” means best of the \(K\) values tried, not good: it is still just below the 0.516 of always predicting the commonest direction.
Small \(K\) (left): flexible, low bias, high variance. The boundary follows single days, and the accuracy jumps around.
Large \(K\) (right): rigid, high bias, low variance. The prediction flattens towards “the market usually goes up”.
The chosen \(K\) is the best compromise on hidden blocks, not on the data used to fit. Notice that even the best \(K\) does not clear the red line: cross-validation is already telling us that Lag2 says little about today.
File 1 · Step 7: open the test year, once
\(K\) is chosen. Only now do we look at 2005, and we do it once. If we went back and changed \(K\) after seeing this score, 2005 would have become training data, and the score would no longer be honest.
# ---- Setup: Steps 1-6, condensed, so this cell runs on its own ----import numpy as npimport pandas as pdimport matplotlib.pyplot as pltfrom sklearn.model_selection import TimeSeriesSplit, GridSearchCVfrom sklearn.neighbors import KNeighborsClassifierfrom sklearn.pipeline import make_pipelinefrom sklearn.preprocessing import StandardScalerURL ="https://raw.githubusercontent.com/intro-stat-learning/ISLP/main/ISLP/data/Smarket.csv"smarket = pd.read_csv(URL)smarket = smarket[smarket["Year"] >=2002]predictors = ["Lag2"]new_day, history = smarket.iloc[[-3]], smarket.iloc[:-3] # 28 Dec 2005, and every day beforetrain, test = history[history["Year"] <2005], history[history["Year"] ==2005]X_train, y_train = train[predictors], train["Direction"]X_test, y_test = test[predictors], test["Direction"]folds = TimeSeriesSplit(n_splits=5)k_values =list(range(1, 126, 2))search = GridSearchCV(make_pipeline(StandardScaler(), KNeighborsClassifier()), {"kneighborsclassifier__n_neighbors": k_values}, cv=folds).fit(X_train, y_train)best_k = search.best_params_["kneighborsclassifier__n_neighbors"]majority = y_train.value_counts(normalize=True).max()# ---- Step 7 ----from sklearn.metrics import confusion_matrixy_pred = search.predict(X_test)# Rows = what really happened, columns = what the model saidtable = pd.DataFrame(confusion_matrix(y_test, y_pred, labels=["Down", "Up"]), index=["actual Down", "actual Up"], columns=["predicted Down", "predicted Up"])print(table)accuracy = (y_pred == y_test).mean()baseline = y_test.value_counts(normalize=True).max()print(f"KNN accuracy on 2005: {accuracy:.3f}")print(f"always predicting the commonest direction: {baseline:.3f}")
predicted Down predicted Up
actual Down 35 74
actual Up 31 109
KNN accuracy on 2005: 0.578
always predicting the commonest direction: 0.562
Reading the confusion matrix. Rows are what the market did, columns what the model said. The diagonal holds the right calls, 35 Down days and 109 Up days: accuracy \(= (35 + 109)/249 =\)0.578, better than a coin and a little better than “always Up” (0.562). Read by row, it catches 78% of the Up days (\(109/140\)) but only 32% of the Down days (\(35/109\)). It says “Up” on 183 of 249 days, so much of its accuracy comes from the market rising more often than not. Hold the applause until “How this example was chosen”, after File 2.
File 1 · Step 8: refit on all the history, and save
The test year has done its job: it gave us an honest score. Before using the model, we let it learn from everything we know, 2 January 2002 to 27 December 2005, with the \(K\) that cross-validation chose. Then we save it in a file, so that File 2 can use it without training again.
# ---- Setup: Steps 1-6, condensed, so this cell runs on its own ----import numpy as npimport pandas as pdimport matplotlib.pyplot as pltfrom sklearn.model_selection import TimeSeriesSplit, GridSearchCVfrom sklearn.neighbors import KNeighborsClassifierfrom sklearn.pipeline import make_pipelinefrom sklearn.preprocessing import StandardScalerURL ="https://raw.githubusercontent.com/intro-stat-learning/ISLP/main/ISLP/data/Smarket.csv"smarket = pd.read_csv(URL)smarket = smarket[smarket["Year"] >=2002]predictors = ["Lag2"]new_day, history = smarket.iloc[[-3]], smarket.iloc[:-3] # 28 Dec 2005, and every day beforetrain, test = history[history["Year"] <2005], history[history["Year"] ==2005]X_train, y_train = train[predictors], train["Direction"]X_test, y_test = test[predictors], test["Direction"]folds = TimeSeriesSplit(n_splits=5)k_values =list(range(1, 126, 2))search = GridSearchCV(make_pipeline(StandardScaler(), KNeighborsClassifier()), {"kneighborsclassifier__n_neighbors": k_values}, cv=folds).fit(X_train, y_train)best_k = search.best_params_["kneighborsclassifier__n_neighbors"]majority = y_train.value_counts(normalize=True).max()# ---- Step 8 ----import joblib # installed together with scikit-learn: saves a model to a filefinal_model = make_pipeline(StandardScaler(), KNeighborsClassifier(n_neighbors=best_k))final_model.fit(history[predictors], history["Direction"])joblib.dump(final_model, "knn_smarket.joblib")print(f"Saved: knn_smarket.joblib (KNN with K = {best_k}, learned from {len(history)} days)")
Saved: knn_smarket.joblib (KNN with K = 123, learned from 1005 days)
The file holds the whole pipeline: the scaler with the means and standard deviations it learned, the chosen \(K\), and the days among which KNN will look for neighbours.
File 2: predict one new date
A new morning: 28 December 2005. The model has never seen this day. All it needs is what the market did two trading days before: it rose 0.043% on 23 December (26 December was a holiday).
# File 2: a new script. It loads the model File 1 saved in this folder.import pandas as pdimport joblibfrom pathlib import Path# ---- Setup: only if File 1 has not been run in this folder, rebuild its model ----ifnot Path("knn_smarket.joblib").exists():import numpy as npimport pandas as pdimport matplotlib.pyplot as pltfrom sklearn.model_selection import TimeSeriesSplit, GridSearchCVfrom sklearn.neighbors import KNeighborsClassifierfrom sklearn.pipeline import make_pipelinefrom sklearn.preprocessing import StandardScaler URL ="https://raw.githubusercontent.com/intro-stat-learning/ISLP/main/ISLP/data/Smarket.csv" smarket = pd.read_csv(URL) smarket = smarket[smarket["Year"] >=2002] predictors = ["Lag2"] new_day, history = smarket.iloc[[-3]], smarket.iloc[:-3] # 28 Dec 2005, and every day before train, test = history[history["Year"] <2005], history[history["Year"] ==2005] X_train, y_train = train[predictors], train["Direction"] X_test, y_test = test[predictors], test["Direction"] folds = TimeSeriesSplit(n_splits=5) k_values =list(range(1, 126, 2)) search = GridSearchCV(make_pipeline(StandardScaler(), KNeighborsClassifier()), {"kneighborsclassifier__n_neighbors": k_values}, cv=folds).fit(X_train, y_train) best_k = search.best_params_["kneighborsclassifier__n_neighbors"] majority = y_train.value_counts(normalize=True).max() final = make_pipeline(StandardScaler(), KNeighborsClassifier(n_neighbors=best_k)) joblib.dump(final.fit(history[predictors], history["Direction"]), "knn_smarket.joblib")# ---- File 2 ----model = joblib.load("knn_smarket.joblib") # the model File 1 saved, in the same folderk = model.named_steps["kneighborsclassifier"].n_neighbors# The new date: the returns of the two days before (in %)new_day = pd.DataFrame({"Lag2": [0.043]})prediction = model.predict(new_day)[0]probabilities = model.predict_proba(new_day)[0]print(f"Prediction for 28 December 2005: {prediction}")for label, prob inzip(model.classes_, probabilities):print(f" Pr({label}) = {prob:.3f} "f"-> about {round(prob * k)} of the {k} nearest past days went {label}")
Prediction for 28 December 2005: Up
Pr(Down) = 0.423 -> about 52 of the 123 nearest past days went Down
Pr(Up) = 0.577 -> about 71 of the 123 nearest past days went Up
The probabilities are the votes of the \(K\) nearest past days, the \(\frac{1}{K}\sum_{i \in N_0} I(y_i = j)\) of the KNN rule. On 28 December 2005 the S&P 500 in fact rose 0.130%, so this prediction was right. One date proves nothing either way. File 1’s test year is how the model is judged; File 2 is how it is used.
Your turn: change the number (for example Lag2 = 1.5, then Lag2 = -2) and run File 2 again. Does a big move two days ago change the vote?
How this example was chosen, and why it matters
The example you just ran was built to work. The natural first try (both lags, all five years, the very last day as the new date) gave 50.6% on 2005, below the 56.2% of “always Up”, and File 2 got its date wrong. So we tried 54 slices of the data (predictors × training years × test year) and kept one that wins: Lag2 only, 2002–2004, and 28 December as the new date, because the model gets 30 December wrong.
Luck alone produces a winner. On 249 test days, accuracy wobbles by about ±3 points from chance alone (\(\sqrt{0.25/249} \approx 0.032\)). With 54 tries, a winner that beats the baseline by 1.6 points is easy to find even if there is no signal at all.
Every look at 2005 made 2005 training data. Step 7 says: open the test year once. We opened it 54 times, so 57.8% is no longer an honest test score.
Cross-validation had already warned us. On the training years, the best \(K\) scored 0.511, below the 0.516 of “always Up” (Step 6).
This is how most “strategies that beat the market” are found: try many slices, publish the one that works. The steps of Files 1 and 2 are right. The evidence is not. An honest report says how many things were tried.
What the financial example taught us
Cross-validation chose \(K\) without touching the test year. It is the only honest way to tune a model, and it took five lines of code.
With time series, the folds follow time. The past predicts the block that comes next, never the reverse.
A test year only judges if it is opened once. Our slice beat “always Up”, but only after 54 looks: a winner found that way is luck until proven otherwise. A flexible method cannot create information that is not in the data.
Training and predicting are two different jobs. File 1 is done once, carefully. File 2 can then run every morning, on each new date, in a fraction of a second.
Swap the stock market for your own question: crisis or no crisis, a firm that exports or not, a country falling behind or not. The two files stay the same; only the data and the predictors change. That is what the practice asks you to do.
You survived your second session on machine learning
Wait! I am not an expert! I will never be!!!
I only know the K-Nearest-Neighbor classification technique!!!
OK, let's step back first...
Big lessons from ML
Big lessons
Machine Learning is about:
regressions
classifications
Machine Learning is about:
a process: the 7 principles of ML (see the previous session)
You can run the regressions of sessions 2–6 — smf.ols, smf.logit, panel models — in the context of ML: k-fold cross-validation, training/testing, predictions
ML aims at:
reducing the model and data biases in sampling with regards to the populations
as such, it is a GIGANTIC improvement of current quantitative studies
# Data with a real signal: three iris species from four flower measurements.# The data ships with scikit-learn, so there is nothing to download.from sklearn.datasets import load_irisfrom sklearn.neighbors import KNeighborsClassifierfrom sklearn.model_selection import GridSearchCVfrom sklearn.pipeline import make_pipelinefrom sklearn.preprocessing import StandardScaleriris = load_iris(as_frame=True)iris_search = GridSearchCV(make_pipeline(StandardScaler(), KNeighborsClassifier()), {"kneighborsclassifier__n_neighbors": range(1, 16)}, cv=10)iris_search.fit(iris.data, iris.target)print("best K:", iris_search.best_params_["kneighborsclassifier__n_neighbors"])print(f"CV accuracy: {iris_search.best_score_:.3f}")# Predict a new flower. GridSearchCV has already refitted the best K on all 150 flowers.import pandas as pdnew_flower = pd.DataFrame({"sepal length (cm)": [6.0], "sepal width (cm)": [2.9],"petal length (cm)": [4.5], "petal width (cm)": [1.5]})species = iris.target_names[iris_search.predict(new_flower)[0]]print("new flower:", species)for name, prob inzip(iris.target_names, iris_search.predict_proba(new_flower)[0]):print(f" Pr({name}) = {prob:.2f}")
best K: 8
CV accuracy: 0.967
new flower: versicolor
Pr(setosa) = 0.00
Pr(versicolor) = 1.00
Pr(virginica) = 0.00
Reading the output. Cross-validation tried \(K = 1\) to \(15\) and kept \(K = 8\). Its CV accuracy, 0.967, means that on flowers held out of the fit, it named the right species 96.7% of the time: about 145 of the 150. Compare with the 51% of the stock market: here the measurements really do carry the answer. The new flower is called versicolor with \(\Pr = 1.00\): all 8 of its nearest neighbours are versicolor, so the vote is unanimous.
# KNN can predict a number too: the average of the K nearest houses' prices.import pandas as pdfrom sklearn.neighbors import KNeighborsRegressorfrom sklearn.linear_model import LinearRegressionfrom sklearn.model_selection import cross_val_scorefrom sklearn.pipeline import make_pipelinefrom sklearn.preprocessing import StandardScalerURL ="https://www.warin.ca/datalake/courses_data/qmibr/session5/Real_Estate_Sample.csv"houses = pd.read_csv(URL, index_col=0).dropna()X = houses[["Living.area", "bedrooms", "bathrooms", "year", "garage"]]y = houses["Price"]models = {"KNN, K = 5": make_pipeline(StandardScaler(), KNeighborsRegressor(n_neighbors=5)),"KNN, K = 25": make_pipeline(StandardScaler(), KNeighborsRegressor(n_neighbors=25)),"linear regression": LinearRegression(),}for name, model in models.items():# scikit-learn returns NEGATIVE errors (it always maximises); the minus undoes it rmse =-cross_val_score(model, X, y, cv=5, scoring="neg_root_mean_squared_error").mean()print(f"{name:18} RMSE = {rmse:10,.0f}")print(f"price standard deviation = {y.std():,.0f} <- the cost of predicting the average")# Predict a new house with the model of lowest RMSE above, refitted on all the houses.best = models["KNN, K = 25"].fit(X, y)new_house = pd.DataFrame({"Living.area": [2400], "bedrooms": [4], "bathrooms": [2.5],"year": [1970], "garage": [2]})print(f"new house (2,400 sq ft, 4 bedrooms, 2.5 bathrooms, 1970, 2-car garage): "f"{best.predict(new_house)[0]:,.0f}")
KNN, K = 5 RMSE = 273,663
KNN, K = 25 RMSE = 259,959
linear regression RMSE = 263,558
price standard deviation = 288,540 <- the cost of predicting the average
new house (2,400 sq ft, 4 bedrooms, 2.5 bathrooms, 1970, 2-car garage): 334,652
Reading the output. RMSE is the typical size of a prediction error, in dollars, on houses held out of the fit. The bar is the standard deviation, 288,540: the error you make by predicting the average price for every house. All three models beat it. \(K = 25\) does best (259,959), and \(K = 5\) does worst: with only five neighbours each prediction follows a few houses, noise included (high variance). The gain is modest, about 10%, so these five features leave much of a price unexplained. The new house is predicted at 334,652: the average price of the 25 most similar houses in the data. Given an RMSE of about 260,000, read it as a rough centre, not a quote.
# ---- Setup: File 1, Steps 1-6, condensed, so this cell runs on its own ----import numpy as npimport pandas as pdimport matplotlib.pyplot as pltfrom sklearn.model_selection import TimeSeriesSplit, GridSearchCVfrom sklearn.neighbors import KNeighborsClassifierfrom sklearn.pipeline import make_pipelinefrom sklearn.preprocessing import StandardScalerURL ="https://raw.githubusercontent.com/intro-stat-learning/ISLP/main/ISLP/data/Smarket.csv"smarket = pd.read_csv(URL)smarket = smarket[smarket["Year"] >=2002]predictors = ["Lag2"]new_day, history = smarket.iloc[[-3]], smarket.iloc[:-3] # 28 Dec 2005, and every day beforetrain, test = history[history["Year"] <2005], history[history["Year"] ==2005]X_train, y_train = train[predictors], train["Direction"]X_test, y_test = test[predictors], test["Direction"]folds = TimeSeriesSplit(n_splits=5)k_values =list(range(1, 126, 2))search = GridSearchCV(make_pipeline(StandardScaler(), KNeighborsClassifier()), {"kneighborsclassifier__n_neighbors": k_values}, cv=folds).fit(X_train, y_train)best_k = search.best_params_["kneighborsclassifier__n_neighbors"]majority = y_train.value_counts(normalize=True).max()# ---- The forest ----from sklearn.ensemble import RandomForestClassifierfrom sklearn.model_selection import cross_val_score# 500 trees, on every core of the machine (n_jobs=-1)forest = RandomForestClassifier(n_estimators=500, n_jobs=-1, random_state=0)# cv=folds: the same time-ordered folds as KNN, so the past always predicts the futureforest_accuracy = cross_val_score(forest, X_train, y_train, cv=folds).mean()print(f"random forest CV accuracy = {forest_accuracy:.3f}")print(f"KNN CV accuracy = {search.best_score_:.3f}")print(f"commonest class = {majority:.3f}")# Predict File 2's date, 28 December 2005, with the forest fitted on the training yearsforest.fit(X_train, y_train)new_day = pd.DataFrame({"Lag2": [0.043]})print("forest's prediction for 28 December 2005:", forest.predict(new_day)[0],f"(Pr(Up) = {forest.predict_proba(new_day)[0][1]:.2f}); what happened: Up")
random forest CV accuracy = 0.490
KNN CV accuracy = 0.511
commonest class = 0.516
forest's prediction for 28 December 2005: Up (Pr(Up) = 0.67); what happened: Up
Reading the output. On the same time-ordered folds, the forest is right on 49.0% of the hidden days, KNN on 51.1%, and always predicting the commonest direction on 51.6%. Five hundred trees, every core busy, and neither method beats the constant rule. The lesson is not about the method: this lag may carry no signal, and no amount of compute manufactures one. The forest still calls 28 December Up, with \(\Pr(\text{Up}) = 0.67\) (the trees’ average vote), and the market did rise. One right call is not evidence: a method that loses to “always Up” on hidden days will still be right on many single days.
Before the next session
Required: the next session’s article, its Dataverse package, and the self-check. That is the pre-session deck.
Optional: the QMIB Lab for this session — a knowledge check on the hour you have just sat through: warin.ca/qmib-labs
Never a prerequisite, never marked for correctness. A completed report counts toward participation because attempting it is the engagement being measured.