Confusion matrix, precision, recall, F1
The question: what is 0.78 hiding
In Chapter 1 we built a model and got accuracy = 0.7778. That is 0.27 above the baseline, so the model learned something.
But the manager at the agency asks you:
"Of the flats the model says will 'rent fast', how many actually went fast? Because those are the listings I spend ad money on."
accuracy = 0.7778 does not answer that question.
Accuracy is one number, and it throws two different mistakes into the same bag:
- The model said "goes fast", but the flat sat empty for three months → ad money burned
- The model said "goes slow", but the flat went in a week → opportunity missed
The first mistake loses money. The second one lets profit slip away. They are not the same thing, and accuracy does not separate them.
This chapter gives you the tools that do.
Where this sits in the protocol
This is step 10 of the protocol from Chapter 1: "choose the metric". In step 9 you built the model; now you decide how to judge it.
Column names
The data is the Tashkent rental listings, and its column names are Uzbek. tuman district, 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), qurilgan_yil year built. The target ijaraga_berildi_30k means rented within 30 days.
Rebuilding the model
A chapter should stand on its own, so we rebuild the Chapter 1 model right here.
import pandas as pd from sklearn.model_selection import train_test_split from sklearn.pipeline import Pipeline from sklearn.compose import ColumnTransformer from sklearn.preprocessing import StandardScaler, OneHotEncoder from sklearn.impute import SimpleImputer from sklearn.linear_model import LogisticRegression df = pd.read_csv(DATA / "uy_ijara.csv") num = ["xonalar", "maydon_m2", "qavat", "qavatlar_soni", "metro_km", "mebel", "qurilgan_yil"] cat = ["tuman"] X = df[num + cat] y = 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([ ("prep", ColumnTransformer([ ("num", Pipeline([("impute", SimpleImputer(strategy="median")), ("scale", StandardScaler())]), num), ("cat", OneHotEncoder(handle_unknown="ignore"), cat), ])), ("model", LogisticRegression(max_iter=2000)), ]).fit(X_tr, y_tr) pred = pipe.predict(X_te) print(f"test set : {len(y_te)} flats") print(f"accuracy : {(pred == y_te).mean():.4f}")
test set : 360 flats accuracy : 0.7778
Confusion matrix: four cells
Accuracy gives you one number. The confusion matrix gives you four, and the whole story is in those four.
from sklearn.metrics import confusion_matrix cm = confusion_matrix(y_te, pred) print("Confusion Matrix:") print(cm) print() tn, fp, fn, tp = cm.ravel() print(f"TN={tn} FP={fp} FN={fn} TP={tp}")
Confusion Matrix: [[126 52] [ 28 154]] TN=126 FP=52 FN=28 TP=154
sklearn always returns them in this order: top left TN, top right FP, bottom left FN, bottom right TP. Memorise it. Mix them up and every calculation after that is wrong.
Now let's read those four numbers in the language of our business:
show(pd.DataFrame([ {"Cell": "TN", "Count": tn, "Model said": "goes slow", "Reality": "went slow", "For the business": "Correct. No ad spend, no loss"}, {"Cell": "FP", "Count": fp, "Model said": "goes FAST", "Reality": "went slow", "For the business": "Ad money BURNED on an empty listing"}, {"Cell": "FN", "Count": fn, "Model said": "goes slow", "Reality": "went FAST", "For the business": "Opportunity MISSED, we ignored it"}, {"Cell": "TP", "Count": tp, "Model said": "goes FAST", "Reality": "went fast", "For the business": "Correct. The ad landed where it should"}, ]))
| Cell | Count | Model said | Reality | For the business |
|---|---|---|---|---|
| TN | 126 | goes slow | went slow | Correct. No ad spend, no loss |
| FP | 52 | goes FAST | went slow | Ad money BURNED on an empty listing |
| FN | 28 | goes slow | went FAST | Opportunity MISSED, we ignored it |
| TP | 154 | goes FAST | went fast | Correct. The ad landed where it should |
Now we can answer the manager.
The model called 206 flats "goes fast" (154 + 52). Of those, 154 really did go fast. The ad money on the other 52 was wasted.
That ratio has a name: precision.
Precision and recall
Both come straight out of the matrix. Before you memorise the formulas, memorise the question each one asks.
show(pd.DataFrame([ {"Metric": "Precision", "Formula": "TP / (TP + FP)", "Its question": "When the model says 'yes', how often is it right?", "Who asks": "The person spending the money"}, {"Metric": "Recall", "Formula": "TP / (TP + FN)", "Its question": "Of all the real 'yes' cases, how many did it find?", "Who asks": "The person who cannot afford to miss an opportunity"}, ]))
| Metric | Formula | Its question | Who asks |
|---|---|---|---|
| Precision | TP / (TP + FP) | When the model says 'yes', how often is it right? | The person spending the money |
| Recall | TP / (TP + FN) | Of all the real 'yes' cases, how many did it find? | The person who cannot afford to miss an opportunity |
Now we compute them, and show where each number comes from in the matrix:
from sklearn.metrics import precision_score, recall_score, f1_score, accuracy_score p = precision_score(y_te, pred) r = recall_score(y_te, pred) f1 = f1_score(y_te, pred) print(f"Accuracy : {accuracy_score(y_te, pred):.4f} = (TP+TN)/total = ({tp}+{tn})/{len(y_te)}") print(f"Precision: {p:.4f} = TP/(TP+FP) = {tp}/({tp}+{fp})") print(f"Recall : {r:.4f} = TP/(TP+FN) = {tp}/({tp}+{fn})") print(f"F1 : {f1:.4f}")
Accuracy : 0.7778 = (TP+TN)/total = (154+126)/360 Precision: 0.7476 = TP/(TP+FP) = 154/(154+52) Recall : 0.8462 = TP/(TP+FN) = 154/(154+28) F1 : 0.7938
Notice: recall (0.8462) is higher than precision (0.7476).
What does that say about our model? It is generous: it leans towards calling more flats "goes fast". So it finds most of the real opportunities (high recall), but it also says "yes" to many listings that sit empty (lower precision).
If the manager wants to save ad budget, this model does not suit them. In Chapter 9 we change the threshold to make the model stricter.
F1: why not a plain average, but a "harmonic mean"
Precision and recall are two numbers. What if you need one? Add them and divide by two?
No. Let me show you why not.
Imagine a lazy model that calls just one flat "goes fast" and happens to be right. Precision = 1.0 (perfect!). But it missed the other 180 opportunities: recall = 0.01.
cases = [ ("Balanced model", 0.75, 0.80), ("Lazy model (only 1 'yes')", 1.00, 0.01), ("Generous model ('yes' to all)", 0.51, 1.00), ] rows = [] for name, pr, rc in cases: mean = (pr + rc) / 2 harmonic = 2 * pr * rc / (pr + rc) rows.append({"Model": name, "Precision": pr, "Recall": rc, "Plain mean": round(mean, 3), "F1 (harmonic)": round(harmonic, 3)}) show(pd.DataFrame(rows))
| Model | Precision | Recall | Plain mean | F1 (harmonic) |
|---|---|---|---|---|
| Balanced model | 0.75 | 0.8 | 0.775 | 0.774 |
| Lazy model (only 1 'yes') | 1 | 0.01 | 0.505 | 0.02 |
| Generous model ('yes' to all) | 0.51 | 1 | 0.755 | 0.675 |
Look. For the lazy model the plain mean is 0.505, so it looks like an "average model". But it missed 179 of 180 opportunities. It is useless.
F1 gives 0.02. It tells the truth.
The reason: the harmonic mean sides with the smaller number. If either one is low, F1 is low too. So read F1 as a demand that "both must be good".
Don't celebrate a precision of 1.0
Precision = 1.0000 usually means the model said 'yes' very rarely. Make one prediction, get it right, and precision is perfect.
So never read precision alone. Read recall next to it, or look at how many times the model said 'yes':
pred.sum() is how many times the model said 'yes'.
print(f"Times the model said 'goes fast' : {pred.sum()}") print(f"Flats in the test set that went fast: {y_te.sum()}") print() print("Is the model generous or strict:", "GENEROUS" if pred.sum() > y_te.sum() else "STRICT")
Times the model said 'goes fast' : 206 Flats in the test set that went fast: 182 Is the model generous or strict: GENEROUS
classification_report: everything in one table
We learned to compute it by hand. In practice sklearn gives you everything at once.
from sklearn.metrics import classification_report print(classification_report(y_te, pred, target_names=["went slow", "went fast"], digits=4))
precision recall f1-score support
went slow 0.8182 0.7079 0.7590 178
went fast 0.7476 0.8462 0.7938 182
accuracy 0.7778 360
macro avg 0.7829 0.7770 0.7764 360
weighted avg 0.7825 0.7778 0.7766 360
How to read this table:
- One row per class. The
went fastrow is the class we care about. - support is how many real examples that class has.
- macro avg is the plain average of the two classes. It counts every class equally.
- weighted avg is averaged by class size. The large class dominates.
macro vs weighted matters a lot on imbalanced data
Our classes are nearly even (50.7% / 49.3%), so macro and weighted are close to each other.
But if the classes were 95% / 5%, weighted avg would show the large class's good result and hide the small class's bad one. macro avg does not hide it.
The rule: on imbalanced data, read macro. It shows you a number you won't like, but it is the right number. Chapter 9 is entirely about this.
Which metric do you choose
This is an engineering and business decision, not a mathematical one. A table:
show(pd.DataFrame([ {"Situation": "Ad budget is limited", "Prioritise": "Precision", "Why": "Don't spend on empty listings (FP is expensive)"}, {"Situation": "Every opportunity is valuable", "Prioritise": "Recall", "Why": "Don't miss a flat that will go fast (FN is expensive)"}, {"Situation": "Disease detection", "Prioritise": "Recall", "Why": "Sending a sick person home as healthy is the worst mistake"}, {"Situation": "Spam filter", "Prioritise": "Precision", "Why": "Putting an important email in spam is the worst mistake"}, {"Situation": "Both matter", "Prioritise": "F1", "Why": "Balance; if one is low, F1 is low"}, {"Situation": "Classes very imbalanced", "Prioritise": "macro F1 / PR-AUC", "Why": "Both accuracy and weighted lie (Chapter 9)"}, ]))
| Situation | Prioritise | Why |
|---|---|---|
| Ad budget is limited | Precision | Don't spend on empty listings (FP is expensive) |
| Every opportunity is valuable | Recall | Don't miss a flat that will go fast (FN is expensive) |
| Disease detection | Recall | Sending a sick person home as healthy is the worst mistake |
| Spam filter | Precision | Putting an important email in spam is the worst mistake |
| Both matter | F1 | Balance; if one is low, F1 is low |
| Classes very imbalanced | macro F1 / PR-AUC | Both accuracy and weighted lie (Chapter 9) |
A question to ask in class
Ask your students: "At our agency, is FP more expensive, or FN?"
There is no right answer; it depends on the company's strategy. If ads are cheap and flats are plentiful, FN is expensive (recall matters). If ads are expensive, FP is expensive (precision matters).
The key point: the model does not answer this question. You do. Then you choose the metric.
When accuracy lies: a short warning
Our classes are balanced, so accuracy behaved fairly honestly. But let me build an imbalanced case on purpose; it leads into Chapter 9.
import numpy as np # Build an imbalanced case: cut the share of "went fast" to ~5% np.random.seed(42) fast = y_te[y_te == 1].index slow = y_te[y_te == 0].index sample = list(slow) + list(np.random.choice(fast, size=int(len(slow) * 0.05), replace=False)) y_imb = y_te.loc[sample] print(f"New set: {len(y_imb)} rows, share of 'went fast' {y_imb.mean():.1%}") print() lazy = np.zeros(len(y_imb), dtype=int) # says "goes slow" to everyone print(f"LAZY model ('slow' for everyone):") print(f" Accuracy : {accuracy_score(y_imb, lazy):.4f} <-- beautiful!") print(f" Recall : {recall_score(y_imb, lazy, zero_division=0):.4f} <-- did not find A SINGLE one") print(f" F1 : {f1_score(y_imb, lazy, zero_division=0):.4f}")
New set: 186 rows, share of 'went fast' 4.3%
LAZY model ('slow' for everyone):
Accuracy : 0.9570 <-- beautiful!
Recall : 0.0000 <-- did not find A SINGLE one
F1 : 0.0000
Accuracy 0.9570. Put that in a report and it looks great.
And this model found not a single flat that went fast. It is completely useless.
If you report accuracy alone, the report is a lie.
What we learned today
show(pd.DataFrame([ {"Concept": "Confusion matrix", "In one sentence": "Four cells: TN, FP, FN, TP. The whole story is there"}, {"Concept": "FP and FN", "In one sentence": "Two kinds of mistake, two prices; accuracy doesn't separate them"}, {"Concept": "Precision", "In one sentence": "When the model says 'yes', how often it is right: TP/(TP+FP)"}, {"Concept": "Recall", "In one sentence": "Of all real 'yes' cases, how many it found: TP/(TP+FN)"}, {"Concept": "F1", "In one sentence": "Harmonic mean; if either one is low, F1 is low"}, {"Concept": "macro vs weighted", "In one sentence": "On imbalanced data read macro; weighted hides things"}, {"Concept": "Choosing a metric", "In one sentence": "A business decision: is FP or FN more expensive?"}, ]))
| Concept | In one sentence |
|---|---|
| Confusion matrix | Four cells: TN, FP, FN, TP. The whole story is there |
| FP and FN | Two kinds of mistake, two prices; accuracy doesn't separate them |
| Precision | When the model says 'yes', how often it is right: TP/(TP+FP) |
| Recall | Of all real 'yes' cases, how many it found: TP/(TP+FN) |
| F1 | Harmonic mean; if either one is low, F1 is low |
| macro vs weighted | On imbalanced data read macro; weighted hides things |
| Choosing a metric | A business decision: is FP or FN more expensive? |
The final passport of our model:
show(pd.DataFrame([ {"Metric": "Accuracy", "Value": f"{accuracy_score(y_te, pred):.4f}", "How to read it": "Overall correctness"}, {"Metric": "Precision", "Value": f"{p:.4f}", "How to read it": f"{p:.0%} of its 'yes' answers are right"}, {"Metric": "Recall", "Value": f"{r:.4f}", "How to read it": f"Found {r:.0%} of the opportunities"}, {"Metric": "F1", "Value": f"{f1:.4f}", "How to read it": "The balance score"}, {"Metric": "FP (burned ads)", "Value": str(fp), "How to read it": "This many empty listings got ad money"}, {"Metric": "FN (missed)", "Value": str(fn), "How to read it": "This many opportunities went unnoticed"}, ]))
| Metric | Value | How to read it |
|---|---|---|
| Accuracy | 0.7778 | Overall correctness |
| Precision | 0.7476 | 75% of its 'yes' answers are right |
| Recall | 0.8462 | Found 85% of the opportunities |
| F1 | 0.7938 | The balance score |
| FP (burned ads) | 52 | This many empty listings got ad money |
| FN (missed) | 28 | This many opportunities went unnoticed |
Next chapter
In this chapter we measured the model at one threshold (0.5).
In Chapter 9 we change that threshold, and see how to trade precision against recall without retraining the model. We also learn why the ROC curve lies on imbalanced data and why the PR curve shows the truth.
Questions about this chapter?
The course, homework review and the other five books are in Uzbek. Questions in English are welcome on Telegram.