ai/accuracy-why-a-spam-filter-that-is-99-right-can-still-catch-nothing.md

Accuracy: why a spam filter that is 99% right can still catch nothing

AI · 6 min read ·

A spam filter that never flags anything can score 99% accuracy when only 1 email in 100 is spam. So what should you measure instead?

This short read builds the confusion matrix (the four ways a yes/no decision can be right or wrong), then defines precision and recall with Python you can run. A live grid of 200 emails lets you drag the threshold and watch caught spam, missed spam and false alarms trade places.

Grid of 60 squares, three orange spam squares, none flagged, under the headline 99% accurate, 0 spam caught

The filter that is 99% right and catches nothing

Imagine you build a spam filter and test it on 1,000 emails. It gets 990 of them right. That is 99% accuracy, which sounds like an A+. Then you read the details: the filter has never flagged a single email. It simply says "not spam" to everything.

How can doing nothing score 99%? Because only 10 of the 1,000 emails were really spam. Saying "not spam" every time is right for the other 990. Here is that "filter" as Python code:

actual = ["spam"] * 10 + ["ham"] * 990
guess  = ["ham"] * 1000            # a "filter" that never flags anything

correct = sum(a == g for a, g in zip(actual, guess))
print("accuracy:", correct / len(actual))
print("spam caught:", sum(a == g == "spam" for a, g in zip(actual, guess)))

Running it prints:

accuracy: 0.99
spam caught: 0

("Ham" is the traditional name for good email, the opposite of spam.) The lesson reaches far beyond spam. Whenever one outcome is rare, such as fraud, a disease, or a broken part on a factory line, a single accuracy number can hide a model that is useless. We need to count the different ways of being right and wrong separately.

Four boxes: the confusion matrix

For any yes/no decision, each email falls into one of four boxes, depending on what is true and what the filter said:

Filter says spamFilter says not spam
Really spamTrue positive (TP): caught itFalse negative (FN): missed it
Really good mailFalse positive (FP): good mail wrongly flaggedTrue negative (TN): correctly left alone

This grid of four counts is called a confusion matrix. "Positive" just means "the filter said yes, this is the thing we are looking for". It does not mean "good". Accuracy is the share of emails in the two correct boxes: (TP + TN) divided by the total. Because TN is usually huge, it swamps everything else.

Two better questions: precision and recall

Two other numbers look at the boxes that matter:

Precision asks: of the emails the filter flagged, how many were really spam? It is TP / (TP + FP). Low precision means good mail is landing in the spam folder.

Recall asks: of all the real spam, how much did the filter catch? It is TP / (TP + FN). Low recall means spam keeps reaching your inbox.

Here is a filter that behaves more like a real one, written as a small function. It flags 8 emails: 6 really are spam, 2 are good mail. It also misses 4 spam emails.

def scores(actual, flagged):
    tp = sum(a == "spam" and f for a, f in zip(actual, flagged))
    fp = sum(a == "ham" and f for a, f in zip(actual, flagged))
    fn = sum(a == "spam" and not f for a, f in zip(actual, flagged))
    tn = sum(a == "ham" and not f for a, f in zip(actual, flagged))
    accuracy = (tp + tn) / len(actual)
    precision = tp / (tp + fp) if tp + fp else 0.0
    recall = tp / (tp + fn) if tp + fn else 0.0
    return tp, fp, fn, tn, accuracy, precision, recall

actual = ["spam"] * 10 + ["ham"] * 990
flagged = [True] * 6 + [False] * 4 + [True] * 2 + [False] * 988

Printing the results gives:

caught spam (TP): 6
good mail flagged (FP): 2
spam missed (FN): 4
good mail passed (TN): 988
accuracy: 0.994
precision: 0.75
recall: 0.6

Accuracy rose to 99.4%, yet the filter misses 4 of 10 spam emails and one in four of its flags is a mistake. Precision (0.75) and recall (0.6) tell that story. Accuracy does not.

Try it: move the threshold

Most spam filters do not say yes or no directly. They give each email a score from 0 to 100, and you choose a threshold: flag everything scoring at or above it. The grid below holds 200 emails sorted by score, lowest first, so the cut always falls between two neighbours. Orange squares are really spam and grey squares are really good mail. Squares with a green outline are the ones your filter flags. The data is made up for this demo, and 10 of the 200 emails are spam.

The trade-off: you cannot max out both

Slide the threshold down and the filter catches more spam, so recall goes up. But it also starts flagging more good mail, so precision drops. Slide it up and the opposite happens. Precision and recall pull against each other, and moving the threshold trades one for the other. No threshold is "correct" on its own.

The right setting depends on what each mistake costs:

For a personal inbox, a false positive (your landlord's email in the spam folder) usually hurts more than a false negative (one extra ad), so you favour precision. For a medical screening test, a false negative (a missed illness) is far worse than a false alarm that leads to a second test, so you favour recall. Neither answer is universal. It is a decision about people, and the maths just makes the trade visible.

What to do in your own code

Whenever you evaluate a yes/no model, whether it detects spam, fraud or defects in photos, do three things:

First, check how rare the "yes" cases are. If they are only a few percent of the data, accuracy alone will flatter you, so compare against the lazy "always no" baseline, as in the first code sample. Second, report the four counts and compute precision and recall. If you use Python's scikit-learn library, precision_score and recall_score in sklearn.metrics do the arithmetic for you. On the example above they return the same 0.75 and 0.6 as the hand-written function. Third, decide in plain words which mistake is more costly, then pick your threshold on purpose instead of leaving it at the default.

A high accuracy number can be true and still be uninformative. Ask what the model gets wrong, and who pays for it.

$ ls ai/

see all →
ai/the-context-window-why-a-chatbot-forgets-the-start-of-a-long-chat.md The context window: why a chatbot forgets the start of a long chat A chatbot does not remember your conversation. Each time you press send, the app hands the model the whole chat again, and the model can… 5 min read · 28 Oct ai/tokens-how-a-chatbot-cuts-your-text-into-pieces-before-it-reads-a-word.md Tokens: how a chatbot cuts your text into pieces before it reads a word A chatbot never sees your letters. Before it reads anything, a small program called a tokenizer cuts the text into pieces and gives each… 5 min read · 25 Oct ai/gradient-descent-how-a-computer-learns-a-number-by-rolling-downhill.md Gradient descent: how a computer learns a number by rolling downhill How does a computer learn a number? It can't solve for it in one go. It tries a guess, measures how wrong it is, and nudges the guess… 5 min read · 21 Oct ai/embeddings-how-a-computer-knows-a-pony-is-closer-to-a-horse-than-a-tru.md Embeddings: how a computer knows a pony is closer to a horse than a truck A computer sees the letters in "pony" and nothing else, yet search boxes can still tell that a pony is closer to a horse than to a truck… 5 min read · 18 Oct ai/next-word-prediction-how-a-chatbot-s-core-trick-is-just-counting.md Next-word prediction: how a chatbot's core trick is just counting A chatbot's core job is to guess the next word, and the simplest version of that is just counting. In this article you read a few… 5 min read · 15 Oct ai/temperature-why-a-chatbot-answers-differently-each-time.md Temperature: why a chatbot answers differently each time Ask a chatbot the same question twice and the answers can differ. That is not a bug: the model chooses each next word using chance, and a… 5 min read · 12 Oct