Skip to content

Confusion matrix, precision, recall, F1

Chapter 8 12 code blocks 3071 words

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

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.

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

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

python10 lines
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"},
]))
CellCountModel saidRealityFor the business
TN126goes slowwent slowCorrect. No ad spend, no loss
FP52goes FASTwent slowAd money BURNED on an empty listing
FN28goes slowwent FASTOpportunity MISSED, we ignored it
TP154goes FASTwent fastCorrect. 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.

python8 lines
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"},
]))
MetricFormulaIts questionWho asks
PrecisionTP / (TP + FP)When the model says 'yes', how often is it right?The person spending the money
RecallTP / (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:

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

python12 lines
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))
ModelPrecisionRecallPlain meanF1 (harmonic)
Balanced model0.750.80.7750.774
Lazy model (only 1 'yes')10.010.5050.02
Generous model ('yes' to all)0.5110.7550.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'.

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

python5 lines
from sklearn.metrics import classification_report

print(classification_report(y_te, pred,
                            target_names=["went slow", "went fast"],
                            digits=4))
Output
              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 fast row 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:

python14 lines
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)"},
]))
SituationPrioritiseWhy
Ad budget is limitedPrecisionDon't spend on empty listings (FP is expensive)
Every opportunity is valuableRecallDon't miss a flat that will go fast (FN is expensive)
Disease detectionRecallSending a sick person home as healthy is the worst mistake
Spam filterPrecisionPutting an important email in spam is the worst mistake
Both matterF1Balance; if one is low, F1 is low
Classes very imbalancedmacro F1 / PR-AUCBoth 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.

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

python9 lines
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?"},
]))
ConceptIn one sentence
Confusion matrixFour cells: TN, FP, FN, TP. The whole story is there
FP and FNTwo kinds of mistake, two prices; accuracy doesn't separate them
PrecisionWhen the model says 'yes', how often it is right: TP/(TP+FP)
RecallOf all real 'yes' cases, how many it found: TP/(TP+FN)
F1Harmonic mean; if either one is low, F1 is low
macro vs weightedOn imbalanced data read macro; weighted hides things
Choosing a metricA business decision: is FP or FN more expensive?

The final passport of our model:

python8 lines
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"},
]))
MetricValueHow to read it
Accuracy0.7778Overall correctness
Precision0.747675% of its 'yes' answers are right
Recall0.8462Found 85% of the opportunities
F10.7938The balance score
FP (burned ads)52This many empty listings got ad money
FN (missed)28This 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.

Write on Telegram Contents