Lesson 07
Measuring Classification
Go beyond accuracy with the confusion matrix, precision, recall, and F1, and see why a model that never says yes can still score high accuracy.
Accuracy counts correct answers, but it hides which mistakes were made. Saying a struggling learner is fine is a different mistake from worrying about one who is doing well. The confusion matrix separates the four outcomes, and precision and recall turn them into two numbers that answer two different questions.
When accuracy lies
Only a few learners score 75 or more. A dummy model that always answers no gets most rows right without finding a single top learner. Accuracy alone cannot tell you that.
import numpy as np
import pandas as pd
rng = np.random.default_rng(27)
learners = pd.DataFrame({
"hours": rng.uniform(1, 12, 200).round(1),
"practice": rng.integers(0, 41, 200),
"video_minutes": rng.integers(0, 3001, 200),
"track": rng.choice(["Python", "Data", "Web"], 200),
})
bonus = learners["track"].map({"Python": 0, "Data": 6, "Web": -5})
noise = rng.normal(0, 6, 200)
learners["score"] = (
22 + learners["hours"] * 3.4 + learners["practice"] * 0.55 + bonus + noise
).clip(0, 100).round()
learners["passed"] = (learners["score"] >= 60).astype(int)
from sklearn.dummy import DummyClassifier
from sklearn.metrics import accuracy_score
top = (learners["score"] >= 75).astype(int)
print("share of top learners:", top.mean())
always_no = DummyClassifier(strategy="most_frequent").fit(learners[["hours"]], top)
predicted = always_no.predict(learners[["hours"]])
print("accuracy:", accuracy_score(top, predicted))
print("top learners found:", int(predicted.sum()))Output:
share of top learners: 0.11
accuracy: 0.89
top learners found: 0The confusion matrix
Rows are the actual classes and columns are the predictions, both in the order 0 then 1. ravel unpacks the four cells: true negatives, false positives, false negatives, and true positives.
import numpy as np
import pandas as pd
rng = np.random.default_rng(27)
learners = pd.DataFrame({
"hours": rng.uniform(1, 12, 200).round(1),
"practice": rng.integers(0, 41, 200),
"video_minutes": rng.integers(0, 3001, 200),
"track": rng.choice(["Python", "Data", "Web"], 200),
})
bonus = learners["track"].map({"Python": 0, "Data": 6, "Web": -5})
noise = rng.normal(0, 6, 200)
learners["score"] = (
22 + learners["hours"] * 3.4 + learners["practice"] * 0.55 + bonus + noise
).clip(0, 100).round()
learners["passed"] = (learners["score"] >= 60).astype(int)
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import confusion_matrix
from sklearn.model_selection import train_test_split
X = learners[["hours", "practice"]]
y = learners["passed"]
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42, stratify=y
)
predicted = LogisticRegression().fit(X_train, y_train).predict(X_test)
matrix = confusion_matrix(y_test, predicted)
print(matrix)
tn, fp, fn, tp = matrix.ravel()
print("tn", tn, "fp", fp, "fn", fn, "tp", tp)Output:
[[17 4]
[ 3 16]]
tn 17 fp 4 fn 3 tp 16Precision and recall
Precision asks: of the learners predicted to pass, how many did? Recall asks: of the learners who passed, how many did the model find? The manual formulas give the same numbers.
import numpy as np
import pandas as pd
rng = np.random.default_rng(27)
learners = pd.DataFrame({
"hours": rng.uniform(1, 12, 200).round(1),
"practice": rng.integers(0, 41, 200),
"video_minutes": rng.integers(0, 3001, 200),
"track": rng.choice(["Python", "Data", "Web"], 200),
})
bonus = learners["track"].map({"Python": 0, "Data": 6, "Web": -5})
noise = rng.normal(0, 6, 200)
learners["score"] = (
22 + learners["hours"] * 3.4 + learners["practice"] * 0.55 + bonus + noise
).clip(0, 100).round()
learners["passed"] = (learners["score"] >= 60).astype(int)
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import confusion_matrix, precision_score, recall_score
from sklearn.model_selection import train_test_split
X = learners[["hours", "practice"]]
y = learners["passed"]
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42, stratify=y
)
predicted = LogisticRegression().fit(X_train, y_train).predict(X_test)
tn, fp, fn, tp = confusion_matrix(y_test, predicted).ravel()
print("precision:", round(precision_score(y_test, predicted), 3), round(tp / (tp + fp), 3))
print("recall: ", round(recall_score(y_test, predicted), 3), round(tp / (tp + fn), 3))Output:
precision: 0.8 0.8
recall: 0.842 0.842F1 combines the two
F1 is the harmonic mean of precision and recall. It is only high when both are high, so it suits classes of very different sizes. The dummy model from the first section scores zero.
import numpy as np
import pandas as pd
rng = np.random.default_rng(27)
learners = pd.DataFrame({
"hours": rng.uniform(1, 12, 200).round(1),
"practice": rng.integers(0, 41, 200),
"video_minutes": rng.integers(0, 3001, 200),
"track": rng.choice(["Python", "Data", "Web"], 200),
})
bonus = learners["track"].map({"Python": 0, "Data": 6, "Web": -5})
noise = rng.normal(0, 6, 200)
learners["score"] = (
22 + learners["hours"] * 3.4 + learners["practice"] * 0.55 + bonus + noise
).clip(0, 100).round()
learners["passed"] = (learners["score"] >= 60).astype(int)
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import f1_score
from sklearn.model_selection import train_test_split
X = learners[["hours", "practice"]]
y = learners["passed"]
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42, stratify=y
)
predicted = LogisticRegression().fit(X_train, y_train).predict(X_test)
print("F1:", round(f1_score(y_test, predicted), 3))
top = (learners["score"] >= 75).astype(int)
print("always no, F1:", f1_score(top, np.zeros(len(top), dtype=int), zero_division=0))Output:
F1: 0.821
always no, F1: 0.0Everything at once
classification_report prints precision, recall, and F1 for every class, plus support: how many test rows belong to that class.
import numpy as np
import pandas as pd
rng = np.random.default_rng(27)
learners = pd.DataFrame({
"hours": rng.uniform(1, 12, 200).round(1),
"practice": rng.integers(0, 41, 200),
"video_minutes": rng.integers(0, 3001, 200),
"track": rng.choice(["Python", "Data", "Web"], 200),
})
bonus = learners["track"].map({"Python": 0, "Data": 6, "Web": -5})
noise = rng.normal(0, 6, 200)
learners["score"] = (
22 + learners["hours"] * 3.4 + learners["practice"] * 0.55 + bonus + noise
).clip(0, 100).round()
learners["passed"] = (learners["score"] >= 60).astype(int)
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import classification_report
from sklearn.model_selection import train_test_split
X = learners[["hours", "practice"]]
y = learners["passed"]
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42, stratify=y
)
predicted = LogisticRegression().fit(X_train, y_train).predict(X_test)
print(classification_report(y_test, predicted, target_names=["not passed", "passed"]))Output:
precision recall f1-score support
not passed 0.85 0.81 0.83 21
passed 0.80 0.84 0.82 19
accuracy 0.82 40
macro avg 0.82 0.83 0.82 40
weighted avg 0.83 0.82 0.83 40Key takeaways
-
Accuracy hides which mistakes were made and misleads on unbalanced classes
-
The confusion matrix has actual classes as rows and predictions as columns
-
Precision: of the predicted yes, how many were right
-
Recall: of the real yes, how many were found
-
F1 is high only when precision and recall are both high
-
Next: K-Nearest Neighbours
-
Previous: Logistic Regression
Loading playground…
Check your understanding
Measuring Classification Quiz
Why can accuracy mislead?
1 / 10