The four names are not arbitrary. Each one is two words, and knowing which word means what makes them impossible to mix up.
# second word: what the model PREDICTED # first word: whether it was RIGHT # # true positive -> predicted positive, and correct # false positive -> predicted positive, and wrong # false negative -> predicted negative, and wrong # true negative -> predicted negative, and correct actual = [1, 1, 0, 0] predicted = [1, 0, 1, 0] # tp fn fp tn # A false negative is the fraud you let through. # A false positive is the customer you blocked for nothing.
- The second word is the prediction, the first is the verdict
'False positive' means the model said positive and was false about it. Once that reading is fixed, none of the four can be confused — and the mistake people make is reading 'false negative' as 'the thing was negative', which inverts two of the four counts and every metric built on them.
- The two errors are not equally expensive
A false negative in cancer screening is a missed diagnosis; a false positive is a frightening letter and another test. In spam filtering it is the other way round — a missed spam is an annoyance, a blocked invoice is a disaster. Which of the two you tune against is a product decision, and it is why the four counts are reported separately rather than averaged.
- Everything not the positive label is negative
Comparing against one named label rather than hard-coding 1 and 0 means the same function works for labels of 'spam', True, or 'fraud' — and it makes the rule for everything else explicit: not equal to positive means negative. That is only sound for a binary problem, which is why this function is honest about being binary.
One pass over the pairs, four counters. For each pair, work out whether the actual value is positive and whether the prediction is, and the two answers together pick which counter goes up.
zip(actual, predicted) walks them together — but check the lengths first, because zip truncates silently. Two booleans per row, four combinations, one increment each.
Start the dict with all four keys at 0 so they are always present, then increment by name. A nested if/else on the two booleans is clearer here than anything clever — the names are the documentation.
def confusion(actual, predicted, positive=1): if len(actual) != len(predicted): raise ____('actual and predicted must be the same length') counts = { 'true_positive': 0, 'false_positive': 0, 'false_negative': 0, 'true_negative': 0, } for truth, guess in ____(actual, predicted): was = truth == positive said = guess == ____ if said and was: counts['true_positive'] += 1 elif said: counts['____'] += 1 elif was: counts['false_negative'] += 1 else: counts['____'] += 1 return counts
def confusion(actual, predicted, positive=1):
if len(actual) != len(predicted):
raise ValueError(
f"actual has {len(actual)} labels, predicted has {len(predicted)}"
)
# All four keys from the start, so a caller never has to check.
counts = {
"true_positive": 0,
"false_positive": 0,
"false_negative": 0,
"true_negative": 0,
}
for truth, guess in zip(actual, predicted):
was = truth == positive
said = guess == positive
if said and was:
counts["true_positive"] += 1
elif said:
counts["false_positive"] += 1
elif was:
counts["false_negative"] += 1
else:
counts["true_negative"] += 1
return counts
`was` and `said` turn four combinations into four readable conditions, and the branch order means each one only has to mention what the ones above it ruled out. Compare the version that writes `truth == positive and guess == positive` four times: the same logic, four opportunities to type one of them backwards.
Without it, a perfect classifier returns a dict with no false_positive key and every metric downstream needs a .get. The shape of the result should not depend on the data — the same reasoning as diff-dicts always returning its three categories.
zip stops at the shorter list without a word, so mismatched inputs give four counts that quietly do not cover the whole test set — and the total looks plausible. On Python 3.10 and later, zip(..., strict=True) raises for you; the explicit check works everywhere and names both lengths.
sklearn.metrics.confusion_matrix handles any number of classes and returns a proper matrix, which is what you want the moment there are more than two labels — squeezing a multi-class problem into positive-and-everything-else throws away exactly the information you needed. Note its output is ordered by label, not by the names used here, and the famous mistake is reading its four values in the wrong order: ravel() gives tn, fp, fn, tp. Write this once so that the ordering of somebody else’s matrix is something you can reason about rather than look up every time.
- Add accuracy, and then look at the 'predicts nothing positive' case again — the number is the argument for this whole exercise.
- Generalise it to any number of labels, returning a dict of (actual, predicted) pairs to counts, and see what happens to the four familiar names.
- Add specificity and the false positive rate, and work out which of the four counts each one needs.