Skip to content

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 negativepredicted positive
actual negativeTN — true negativeFP — false positive (Type I)
actual positiveFN — false negative (Type II)TP — true positive

The math

Every metric, as a ratio of cells:

accuracy=TP+TNTP+TN+FP+FNprecision=TPTP+FP\text{accuracy} = \frac{TP + TN}{TP + TN + FP + FN} \qquad \text{precision} = \frac{TP}{TP + FP}
recall (sensitivity, TPR)=TPTP+FNspecificity (TNR)=TNTN+FP\text{recall (sensitivity, TPR)} = \frac{TP}{TP + FN} \qquad \text{specificity (TNR)} = \frac{TN}{TN + FP}
FPR=FPFP+TN=1specificityF1=2TP2TP+FP+FN\text{FPR} = \frac{FP}{FP + TN} = 1 - \text{specificity} \qquad F_1 = \frac{2 \cdot TP}{2 \cdot TP + FP + FN}

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

balanced accuracy=recall+specificity2\text{balanced accuracy} = \frac{\text{recall} + \text{specificity}}{2}

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:

#truescorepredictedcell
1spam0.95spamTP
2spam0.88spamTP
3spam0.76spamTP
4not spam0.70spamFP
5not spam0.62spamFP
6spam0.58spamTP
7spam0.31not spamFN
8not spam0.24not spamTN
9not spam0.13not spamTN
10not spam0.05not spamTN

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 spampredicted spam
actually not spamTN = 3FP = 2
actually spamFN = 1TP = 4

Step 3 — every metric, from those four numbers.

accuracy=4+310=0.700precision=44+2=0.667\text{accuracy} = \frac{4 + 3}{10} = 0.700 \qquad \text{precision} = \frac{4}{4 + 2} = 0.667
recall=44+1=0.800specificity=33+2=0.600\text{recall} = \frac{4}{4 + 1} = 0.800 \qquad \text{specificity} = \frac{3}{3 + 2} = 0.600
F1=2(4)2(4)+2+1=811=0.727balanced accuracy=0.800+0.6002=0.700F_1 = \frac{2(4)}{2(4) + 2 + 1} = \frac{8}{11} = 0.727 \qquad \text{balanced accuracy} = \frac{0.800 + 0.600}{2} = 0.700

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:

TP=3,  FP=1,  FN=2,  TN=4    precision=0.750,    recall=0.600TP = 3,\; FP = 1,\; FN = 2,\; TN = 4 \;\Longrightarrow\; \text{precision} = 0.750,\;\; \text{recall} = 0.600

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.

sketch One threshold, four cells, six metrics p5.js
The ten ranked scores as a column with a draggable threshold line. Rows above the line are predicted spam; the two-by-two matrix and the six metrics below it update as rows cross. At threshold 0.5 the matrix reads TP 4, FP 2, FN 1, TN 3.

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 0/00/0: 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

figureBinary and ten-class matrices from real modelsmatplotlib
Left: a two-by-two confusion matrix with cells labelled TN 103, FP 4, FN 4, TP 60. Right: a ten-by-ten confusion matrix for handwritten digits with a strong diagonal and a scattering of small off-diagonal counts.Left: a two-by-two confusion matrix with cells labelled TN 103, FP 4, FN 4, TP 60. Right: a ten-by-ten confusion matrix for handwritten digits with a strong diagonal and a scattering of small off-diagonal counts.
Left: logistic regression on breast cancer, 171 held-out patients. Right: the same estimator on handwritten digits, 97.2% accurate — with the remaining 2.8% concentrated in a few specific confusions.

Reading the plot

The binary matrix (TN 103, FP 4, FN 4, TP 60):

  • Accuracy is (103+60)/171=0.953(103+60)/171 = 0.953.
  • Precision is 60/64=0.93860/64 = 0.938 and recall is 60/64=0.93860/64 = 0.938 — 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.
confusion_matrix.py
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"]))
confusion_matrix.py
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 KK classes the matrix is K×KK \times K: entry (i,j)(i, j) counts samples of true class ii predicted as class jj. There is no single TP or FP any more — each class gets its own, computed one-versus-rest:

  • TPcTP_c is the diagonal entry for class cc
  • FNcFN_c is the rest of row cc (true cc, predicted something else)
  • FPcFP_c is the rest of column cc (predicted cc, actually something else)
  • TNcTN_c is everything else

Per-class metrics are then averaged, and the averaging choice matters:

AverageMethodUse when
macromacroUnweighted mean of per-class scoresEvery class matters equally, including rare ones
weightedweightedMean weighted by class supportYou want a figure that tracks overall accuracy
micromicroPool all TP, FP, FN, then compute onceEquivalent 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.

multiclass_matrix.py
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 1
multiclass_matrix.py
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 1

Two 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:

diagram Diagram mermaid

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 t=CFP/(CFP+CFN)t^* = C_{FP} / (C_{FP} + C_{FN}) without any tuning. That derivation is on Cost-Sensitive Learning and Decision Thresholds.

DomainPositive classFP costsFN costsOptimise
Cancer screeningMalignantA follow-up scanA missed cancerRecall
Spam filteringSpamA lost real emailAn annoying emailPrecision
Fraud detectionFraudulentAn angry customerMoney goneDepends on amounts
Criminal justice riskHigh riskWrongful detentionReoffenceRecall, with a fairness audit
Ad targetingWill clickA wasted impressionA missed saleWhichever is worth more
Predictive maintenanceWill failAn unnecessary serviceAn unplanned outageRecall

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

quizCheck yourself
  1. A model predicts 'positive' for a sample that is actually negative. What is that called?

    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.

  2. Precision and recall divide by different things. Which is which?

    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.

  3. cm.ravel() on a binary confusion matrix returns four numbers. In what order?

    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.

  4. Your ten-class model is 97% accurate. What does the off-diagonal of its confusion matrix add?

    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, F1F_1 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 coffee

Was this page helpful?

Let us know how we did