Evaluation Metrics - Confusion Matrix
What you’ll learn
- the four cells — TP, FP, FN, TN — and how to stop confusing the middle two
- every common metric written as a ratio of those cells
- the ten-row example computed by hand, cell by cell
- scikit-learn’s row/column convention, and why people read the matrix backwards
- how to read a multiclass matrix, where the off-diagonal is the interesting part
- why the matrix is the only summary that loses nothing
Intuition
A single accuracy number compresses every prediction into one figure and throws away which kind of mistake was made. For most real problems the kinds are not interchangeable: telling a healthy patient they may be ill costs an anxious week, and telling an ill patient they are healthy can cost much more.
The confusion matrix keeps them separate. For a binary problem it is four numbers, and every metric in this phase is a ratio of some of them. It is the last point in the evaluation pipeline where no information has been discarded.
| predicted negative | predicted positive | |
|---|---|---|
| actual negative | TN — true negative | FP — false positive (Type I) |
| actual positive | FN — false negative (Type II) | TP — true positive |
The math
Every metric, as a ratio of cells:
Note which cell each denominator uses:
- Precision divides by everything the model called positive — a column of the matrix.
- Recall divides by everything that actually is positive — a row.
That single distinction is the source of most confusion between them, and it is worth memorising as “precision is a column, recall is a row”.
Balanced accuracy
The average of per-class recall. On the 2%-positive problem from Introduction to Classification, a constant “always negative” model scores 0.98 accuracy and exactly 0.50 balanced accuracy — which is the number you actually wanted.
Worked example by hand
The ten-row example from the introduction, at threshold 0.5:
| # | true | score | predicted | cell |
|---|---|---|---|---|
| 1 | spam | 0.95 | spam | TP |
| 2 | spam | 0.88 | spam | TP |
| 3 | spam | 0.76 | spam | TP |
| 4 | not spam | 0.70 | spam | FP |
| 5 | not spam | 0.62 | spam | FP |
| 6 | spam | 0.58 | spam | TP |
| 7 | spam | 0.31 | not spam | FN |
| 8 | not spam | 0.24 | not spam | TN |
| 9 | not spam | 0.13 | not spam | TN |
| 10 | not spam | 0.05 | not spam | TN |
Step 1 — tally. TP = 4, FP = 2, FN = 1, TN = 3. They sum to 10, as they must.
Step 2 — assemble the matrix.
| predicted not spam | predicted spam | |
|---|---|---|
| actually not spam | TN = 3 | FP = 2 |
| actually spam | FN = 1 | TP = 4 |
Step 3 — every metric, from those four numbers.
Six numbers describing one set of ten predictions, ranging from 0.60 to 0.80. Choosing which to report is a decision about what the model is for, not a technicality.
Step 4 — move the threshold to 0.65 and recount. Now rows 1–4 are predicted spam:
The model has not changed. One number in a comparison changed, and precision went up while recall went down. That trade-off is the subject of the next page.
See it move
Steps 1 to 4 by hand, for every threshold at once. Drag the threshold line through the ten scores and watch rows cross it: each crossing moves exactly one row from one cell of the matrix to another, and the six metrics recompute from the four counts.
Two things the static table cannot show. Only one row moves at a time. Sliding from 0.5 to 0.65
crosses row 6 alone, which is why exactly one cell pair changed in step 4 — the matrix moves in
integer steps, and with ten rows every metric is quantised to tenths and sixths. Above 0.95 nothing
is predicted spam, so precision is : undefined, and scikit-learn will emit
UndefinedMetricWarningUndefinedMetricWarning and substitute 0.0 unless you pass zero_divisionzero_division. A metric that can be
undefined is a metric you should not average over folds without checking.
Reading a real matrix
Reading the plot
The binary matrix (TN 103, FP 4, FN 4, TP 60):
- Accuracy is .
- Precision is and recall is — equal only because FP happens to equal FN here. That is a coincidence, not a property.
- The four false negatives are the cells that matter clinically: four malignant tumours the model called benign.
The ten-class matrix:
- The diagonal is the correct predictions and dominates, as it should at 97% accuracy.
- The off-diagonal is the interesting part. Row 8 shows three eights predicted as ones — a specific, repeatable confusion, not random error.
- Reading a row tells you what a class gets mistaken for; reading a column tells you what gets mistaken for that class. Both are actionable in ways an accuracy score is not.
from sklearn.datasets import load_breast_cancer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import classification_report, confusion_matrix
from sklearn.model_selection import train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
X, y = load_breast_cancer(return_X_y=True)
y = 1 - y # 1 = malignant
X_tr, X_te, y_tr, y_te = train_test_split(
X, y, test_size=0.3, random_state=0, stratify=y
)
model = make_pipeline(StandardScaler(), LogisticRegression(max_iter=5000))
model.fit(X_tr, y_tr)
pred = model.predict(X_te)
cm = confusion_matrix(y_te, pred)
print(cm)
# [[103 4]
# [ 4 60]]
tn, fp, fn, tp = cm.ravel() # the standard unpacking idiom
print(f"TN={tn} FP={fp} FN={fn} TP={tp}") # TN=103 FP=4 FN=4 TP=60
print(classification_report(y_te, pred, target_names=["benign", "malignant"]))from sklearn.datasets import load_breast_cancer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import classification_report, confusion_matrix
from sklearn.model_selection import train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
X, y = load_breast_cancer(return_X_y=True)
y = 1 - y # 1 = malignant
X_tr, X_te, y_tr, y_te = train_test_split(
X, y, test_size=0.3, random_state=0, stratify=y
)
model = make_pipeline(StandardScaler(), LogisticRegression(max_iter=5000))
model.fit(X_tr, y_tr)
pred = model.predict(X_te)
cm = confusion_matrix(y_te, pred)
print(cm)
# [[103 4]
# [ 4 60]]
tn, fp, fn, tp = cm.ravel() # the standard unpacking idiom
print(f"TN={tn} FP={fp} FN={fn} TP={tp}") # TN=103 FP=4 FN=4 TP=60
print(classification_report(y_te, pred, target_names=["benign", "malignant"]))Multiclass
For classes the matrix is : entry counts samples of true class predicted as class . There is no single TP or FP any more — each class gets its own, computed one-versus-rest:
- is the diagonal entry for class
- is the rest of row (true , predicted something else)
- is the rest of column (predicted , actually something else)
- is everything else
Per-class metrics are then averaged, and the averaging choice matters:
| Average | Method | Use when |
|---|---|---|
macromacro | Unweighted mean of per-class scores | Every class matters equally, including rare ones |
weightedweighted | Mean weighted by class support | You want a figure that tracks overall accuracy |
micromicro | Pool all TP, FP, FN, then compute once | Equivalent to accuracy for single-label multiclass |
On an imbalanced problem macromacro and weightedweighted can differ enormously. A model that is excellent on
the common classes and useless on the rare ones scores well on weightedweighted and badly on macromacro —
and macromacro is usually the honest one.
import numpy as np
from sklearn.datasets import load_digits
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import confusion_matrix, f1_score
from sklearn.model_selection import train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
digits = load_digits()
X_tr, X_te, y_tr, y_te = train_test_split(
digits.data, digits.target, test_size=0.3, random_state=0, stratify=digits.target
)
model = make_pipeline(StandardScaler(), LogisticRegression(max_iter=5000))
model.fit(X_tr, y_tr)
pred = model.predict(X_te)
cm = confusion_matrix(y_te, pred)
print(f"accuracy {model.score(X_te, y_te):.4f}") # 0.9722
print(f"macro F1 {f1_score(y_te, pred, average='macro'):.4f}")
print(f"weighted F1 {f1_score(y_te, pred, average='weighted'):.4f}")
# Where does the model actually go wrong?
errors = cm.copy()
np.fill_diagonal(errors, 0)
for true_c, pred_c in zip(*np.where(errors >= 2)):
print(f"{errors[true_c, pred_c]} samples: true {true_c} -> predicted {pred_c}")
# 3 samples: true 8 -> predicted 1import numpy as np
from sklearn.datasets import load_digits
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import confusion_matrix, f1_score
from sklearn.model_selection import train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
digits = load_digits()
X_tr, X_te, y_tr, y_te = train_test_split(
digits.data, digits.target, test_size=0.3, random_state=0, stratify=digits.target
)
model = make_pipeline(StandardScaler(), LogisticRegression(max_iter=5000))
model.fit(X_tr, y_tr)
pred = model.predict(X_te)
cm = confusion_matrix(y_te, pred)
print(f"accuracy {model.score(X_te, y_te):.4f}") # 0.9722
print(f"macro F1 {f1_score(y_te, pred, average='macro'):.4f}")
print(f"weighted F1 {f1_score(y_te, pred, average='weighted'):.4f}")
# Where does the model actually go wrong?
errors = cm.copy()
np.fill_diagonal(errors, 0)
for true_c, pred_c in zip(*np.where(errors >= 2)):
print(f"{errors[true_c, pred_c]} samples: true {true_c} -> predicted {pred_c}")
# 3 samples: true 8 -> predicted 1Two lines of NumPy turn a wall of numbers into one actionable sentence: this model confuses eights with ones, and nothing else systematically.
Which cell should you be optimising?
No metric knows your costs, so this is a routing question you answer before choosing one:
flowchart TD
A["Which mistake hurts more?"] --> B{"A missed positive
-- FN"}
A --> C{"A false alarm
-- FP"}
A --> H{"Both, and you can
price them"}
A --> J{"Genuinely unsure,
classes balanced"}
B -->|"cancer screening,
fraud, safety"| D["Maximise recall.
Accept low precision."]
D --> E["Lower the threshold.
Report recall at a
fixed false-positive budget."]
C -->|"spam, auto-blocking,
customer contact"| F["Maximise precision.
Accept low recall."]
F --> G["Raise the threshold.
Report precision at a
fixed recall floor."]
H -->|"each error has
a price in currency"| I["Skip ratio metrics.
Minimise expected cost --
it names the threshold directly."]
J --> K["F1 or balanced accuracy
as a placeholder,
declared as a placeholder."]
The branch most teams skip is the third. If a false positive costs €4 of wasted review and a false negative costs €300 of loss, no ratio of counts is the right objective — expected cost is, and it implies a threshold of without any tuning. That derivation is on Cost-Sensitive Learning and Decision Thresholds.
| Domain | Positive class | FP costs | FN costs | Optimise |
|---|---|---|---|---|
| Cancer screening | Malignant | A follow-up scan | A missed cancer | Recall |
| Spam filtering | Spam | A lost real email | An annoying email | Precision |
| Fraud detection | Fraudulent | An angry customer | Money gone | Depends on amounts |
| Criminal justice risk | High risk | Wrongful detention | Reoffence | Recall, with a fairness audit |
| Ad targeting | Will click | A wasted impression | A missed sale | Whichever is worth more |
| Predictive maintenance | Will fail | An unnecessary service | An unplanned outage | Recall |
Fill this row in for your own problem before you choose a metric. If you cannot say which error is worse, you do not yet know what the model is for.
Pitfalls
A model predicts 'positive' for a sample that is actually negative. What is that called?
Read the second word first: the model said positive. The first word says it was wrong. A false alarm.
Show answer
B — False positive (Type I error) — Read the second word first: the model said positive. The first word says it was wrong. A false alarm.
Precision and recall divide by different things. Which is which?
Precision is TP/(TP+FP) — a column of the matrix. Recall is TP/(TP+FN) — a row. 'Precision is a column, recall is a row' is worth memorising.
Show answer
B — Precision divides by all predicted positives (a column); recall divides by all actual positives (a row) — Precision is TP/(TP+FP) — a column of the matrix. Recall is TP/(TP+FN) — a row. 'Precision is a column, recall is a row' is worth memorising.
cm.ravel() on a binary confusion matrix returns four numbers. In what order?
scikit-learn puts actual on rows and predicted on columns with labels sorted, so row 0 is [TN, FP] and row 1 is [FN, TP]. Flattened, that is TN, FP, FN, TP.
Show answer
B — TN, FP, FN, TP — scikit-learn puts actual on rows and predicted on columns with labels sorted, so row 0 is [TN, FP] and row 1 is [FN, TP]. Flattened, that is TN, FP, FN, TP.
Your ten-class model is 97% accurate. What does the off-diagonal of its confusion matrix add?
'97% accurate' and 'it confuses eights with ones three times' lead to completely different next steps. The off-diagonal is where the debugging information lives.
Show answer
B — It shows which specific classes get confused with which, turning a score into an actionable diagnosis — '97% accurate' and 'it confuses eights with ones three times' lead to completely different next steps. The off-diagonal is where the debugging information lives.
🧪 Try It Yourself
Exercise 1 – Count the four cells
Exercise 2 – Derive six metrics from four numbers
Exercise 3 – Unpack a scikit-learn matrix
Exercise 4 – Watch the cells move with the threshold
Exercise 5 – Find the systematic multiclass confusion
Recap
- Four cells: TP, FP, FN, TN. Read the second word first — a false positive is a false alarm, a false negative is a miss.
- Precision divides by a column (everything predicted positive); recall divides by a row (everything actually positive).
- The ten-row example: TN 3, FP 2, FN 1, TP 4 → accuracy 0.700, precision 0.667, recall 0.800, specificity 0.600, 0.727.
- Moving the threshold to 0.65 gives precision 0.750 and recall 0.600, with no retraining.
- scikit-learn puts actual on rows and predicted on columns;
cm.ravel()cm.ravel()is TN, FP, FN, TP. - In multiclass the off-diagonal is the diagnosis, and the averaging mode is part of the claim.
Exercise 6 – Tabulate every threshold at once
Next
Continue to Precision, Recall, and F1-Score — which of these four cells you are willing to trade, and how to pick a threshold that reflects the answer.
If this helped you, consider buying me a coffee ☕
Buy me a coffeeWas this page helpful?
Let us know how we did
