Evaluation metrics
A classifier that claims 95% accuracy can still be useless — if 95% of emails aren't spam, a model that says "never spam" scores 95% and catches nothing. To see the real story you need the confusion matrix and the metrics built from it.
For a binary classifier (label 'pos' / 'neg'), every prediction lands in one of four boxes:
- TP (true positive) — predicted
pos, actuallypos✓ - FP (false positive) — predicted
pos, actuallyneg✗ (a false alarm) - FN (false negative) — predicted
neg, actuallypos✗ (a miss) - TN (true negative) — predicted
neg, actuallyneg✓
From those four counts:
- Accuracy =
(TP + TN) / total— fraction of all predictions that were right. - Precision =
TP / (TP + FP)— of everything you *called* positive, how much really was? (Punishes false alarms.) - Recall =
TP / (TP + FN)— of all the *actual* positives, how many did you catch? (Punishes misses.)
Both ratios can divide by zero (no positive predictions → precision; no actual positives → recall) — guard each with a ... if denom > 0 else 0.0.
*In production this is sklearn.metrics.classification_report. The arithmetic below is exactly what it computes.*
Syntax
# Confusion counts from two aligned label lists
for pred, actual in zip(predictions, actuals):
if pred == 'pos' and actual == 'pos': tp += 1
elif pred == 'pos' and actual == 'neg': fp += 1
elif pred == 'neg' and actual == 'pos': fn += 1
else: tn += 1
accuracy = (tp + tn) / total
precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0
recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0Worked examples
Confusion counts + all three metrics
def confusion_counts(predictions, actuals):
tp = fp = fn = tn = 0
for pred, actual in zip(predictions, actuals):
if pred == "pos" and actual == "pos":
tp += 1
elif pred == "pos" and actual == "neg":
fp += 1
elif pred == "neg" and actual == "pos":
fn += 1
else:
tn += 1
return {"TP": tp, "FP": fp, "FN": fn, "TN": tn}
def accuracy(predictions, actuals):
c = confusion_counts(predictions, actuals)
return round((c["TP"] + c["TN"]) / len(predictions), 4)
def precision_recall(predictions, actuals):
c = confusion_counts(predictions, actuals)
tp, fp, fn = c["TP"], c["FP"], c["FN"]
precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0
recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0
return round(precision, 4), round(recall, 4)
preds = ["pos","pos","neg","pos","neg","neg","pos","neg","pos","neg"]
acts = ["pos","neg","pos","pos","neg","pos","pos","neg","neg","neg"]
print(confusion_counts(preds, acts))
print(accuracy(preds, acts))
print(precision_recall(preds, acts))
# Output:
# {'TP': 3, 'FP': 2, 'FN': 2, 'TN': 3}
# 0.6
# (0.6, 0.6)Worked example — a spam filter, step by step
# 6 emails. pred = filter's call, actual = the truth.
preds = ["pos", "pos", "pos", "neg", "neg", "neg"]
acts = ["pos", "pos", "neg", "pos", "neg", "neg"]
# Pair them up:
# pos/pos -> TP pos/pos -> TP pos/neg -> FP
# neg/pos -> FN neg/neg -> TN neg/neg -> TN
# So: TP=2, FP=1, FN=1, TN=2 (total 6)
#
# accuracy = (TP+TN)/total = (2+2)/6 = 0.6667
# precision = TP/(TP+FP) = 2/(2+1) = 0.6667 (1 false alarm)
# recall = TP/(TP+FN) = 2/(2+1) = 0.6667 (1 spam missed)
tp, fp, fn, tn = 2, 1, 1, 2
print(round((tp + tn) / 6, 4)) # accuracy
print(round(tp / (tp + fp), 4)) # precision
print(round(tp / (tp + fn), 4)) # recall
# Output:
# 0.6667
# 0.6667
# 0.6667Zero-denominator guard (no positive predictions)
def precision_recall(predictions, actuals):
tp = sum(1 for p, a in zip(predictions, actuals) if p == "pos" and a == "pos")
fp = sum(1 for p, a in zip(predictions, actuals) if p == "pos" and a == "neg")
fn = sum(1 for p, a in zip(predictions, actuals) if p == "neg" and a == "pos")
precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0
recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0
return round(precision, 4), round(recall, 4)
# Model never predicts pos → TP+FP == 0 → precision guarded to 0.0
print(precision_recall(["neg", "neg"], ["pos", "neg"]))
# Output:
# (0.0, 0.0)Common mistakes & gotchas
Accuracy lies on imbalanced data
When one class dominates (99% 'neg'), a model that always predicts 'neg' scores 99% accuracy while catching zero positives — recall 0. Always look at precision AND recall, not accuracy alone, when the classes aren't roughly balanced.
Precision and recall trade off
Lower your threshold to catch more positives (recall up) and you also catch more false alarms (precision down) — and vice versa. You rarely max both. Pick which error costs more: missing a fraud (favour recall) or wrongly flagging a good customer (favour precision).
Dividing by zero on empty classes
TP/(TP+FP) blows up when the model predicts no positives; TP/(TP+FN) blows up when there are no actual positives. Both happen on small or skewed batches. Guard each ratio with if denom > 0 else 0.0 — a metric of 0.0 is meaningful, a crash isn't.
Why it matters
"Is the model any good?" has no single-number answer. Knowing precision from recall — and when accuracy is hiding the truth — is what separates someone who can ship a model from someone who just trained one. It's the vocabulary every ML conversation runs on.