Python & Data Science
Machine Learning Under review

The Confusion Matrix: Seeing Where Your Model Gets It Wrong

The Problem: Why Accuracy Is a Big Fat Liar

Say you’re a doctor running a screening test for a rare disease. Only one person in a hundred actually has it. You build a machine-learning model to predict who’s sick.

Here’s the thing: you could build a model that does absolutely nothing. It just predicts “Nobody has the disease” for every patient. Your model would be 99% accurate. Right ninety-nine times out of a hundred.

But it would be useless. It would never catch a single sick person.

This is the Accuracy Paradox, and it’s one of the biggest traps in machine learning. A high accuracy score can hide a model that’s failing at its job. Accuracy only tells you the total percentage of correct guesses — it doesn’t tell you where you’re going wrong.

Let’s see what this looks like in code. We’ll make a pretend dataset where only 1% of cases are positive (sick), then build that “lazy” model that just guesses negative every time.

import numpy as np
from sklearn.metrics import confusion_matrix, accuracy_score
from sklearn.dummy import DummyClassifier

# Create imbalanced data: 1% positive cases, 99% negative
np.random.seed(42)
X = np.random.randn(1000, 5)  # 1000 samples, 5 features
y = np.array([1 if i < 10 else 0 for i in range(1000)])  # Only 10 positive cases

# Give the 10 positive cases a real signal on one feature — otherwise the
# features carry no information about the label, and no model, however
# "realistic," has anything to learn from
X[:10, 0] += 3.4

print(f"Total samples: {len(y)}")
print(f"Positive cases: {sum(y)}")
print(f"Negative cases: {len(y) - sum(y)}")
print(f"Percentage positive: {100 * sum(y) / len(y):.1f}%")

# Build the lazy model: always predict 0 (negative)
lazy_model = DummyClassifier(strategy='constant', constant=0)
lazy_model.fit(X, y)
y_pred_lazy = lazy_model.predict(X)

# Check accuracy
accuracy = accuracy_score(y, y_pred_lazy)
print(f"\nLazy model accuracy: {100 * accuracy:.1f}%")

# But how many sick people did it actually catch?
correct_positives = sum((y == 1) & (y_pred_lazy == 1))
print(f"Sick people correctly identified: {correct_positives} out of {sum(y)}")

When you run this, the lazy model is 99% accurate — but it catches zero sick people. The accuracy score is a lie. It hides the fact that the model is a complete failure at the one thing that matters.

This is why we need the confusion matrix. It’s a way to see the flavor of your mistakes, not just count them. It shows you exactly where your model is going wrong.

Meet the Matrix: The Four-Room House

Picture the confusion matrix as a house with four rooms. Each one tells a different part of your model’s story.

One side of the house is Reality — what actually happened. The other is Prediction — what your model said would happen. The four rooms:

  • True Positive (TP): Your model said “Yes” and it was “Yes.” Correct call.
  • True Negative (TN): Your model said “No” and it was “No.” Right again.
  • False Positive (FP): Your model said “Yes” but it was “No.” A false alarm.
  • False Negative (FN): Your model said “No” but it was “Yes.” You missed something.

What trips people up at first: “Positive” and “Negative” don’t mean “good” and “bad.” They’re just labels for the two possible outcomes. In a disease test, “Positive” means “sick.” In a spam filter, “Positive” means “spam.” In a loan approval model, “Positive” might mean “approved.” The label depends on your problem.

The real challenge is keeping track of which axis is Reality and which is Prediction. Let’s build one and see.

from sklearn.metrics import confusion_matrix

# Let's use a more realistic model that actually tries to predict
from sklearn.ensemble import RandomForestClassifier

# Split data into train and test
from sklearn.model_selection import train_test_split
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.3, random_state=42
)

# Train a real model
real_model = RandomForestClassifier(n_estimators=100, random_state=42)
real_model.fit(X_train, y_train)
y_pred = real_model.predict(X_test)

# Generate confusion matrix
cm = confusion_matrix(y_test, y_pred)
print("Confusion Matrix:")
print(cm)
print()
print("Layout: [[True Negatives, False Positives],")
print("         [False Negatives, True Positives]]")
print()

# Extract each value
TN = cm[0, 0]  # True Negatives (top-left)
FP = cm[0, 1]  # False Positives (top-right)
FN = cm[1, 0]  # False Negatives (bottom-left)
TP = cm[1, 1]  # True Positives (bottom-right)

print(f"True Negatives (TN): {TN}")
print(f"False Positives (FP): {FP}")
print(f"False Negatives (FN): {FN}")
print(f"True Positives (TP): {TP}")

When you look at this matrix, you’re seeing the complete record of what your model did. The diagonal — top-left to bottom-right — is the “diagonal of truth,” where the model was right. The off-diagonal corners are where it went wrong.

The False Positive: The Boy Who Cried Wolf

A False Positive is when your model raises an alarm it shouldn’t have. It says “Yes” when the answer is actually “No.”

Take a spam filter. A False Positive happens when a real email from your mom gets marked as spam and you never see it. The filter cried wolf. It wasted your time and broke your trust.

The model said “Yes,” but the truth was “No.”

False Positives are usually annoyance errors — frustrating, but not catastrophic. You tend to find out quickly. Your mom calls asking why you didn’t reply. You check the spam folder, find the email, and move on.

The cost of a False Positive depends on your problem. In ad targeting, you show an ad to someone who won’t click it — wasted money. In airport security, an innocent person gets pulled aside for extra screening. Annoying, but not dangerous. Medical screening is where it stings more: a healthy person gets told they might be sick and needs further testing. Anxiety, extra cost, but at least they get checked out.

Let’s calculate the False Positive rate from our model:

# False Positives: model said YES, but reality was NO
# These are in the top-right of the matrix
FP = cm[0, 1]

# Total actual negatives (people who don't have the disease)
total_actual_negatives = TN + FP

# False Positive Rate: what percentage of actual negatives did we wrongly flag?
FP_rate = FP / total_actual_negatives if total_actual_negatives > 0 else 0

print(f"False Positives: {FP}")
print(f"Total actual negatives: {total_actual_negatives}")
print(f"False Positive Rate: {100 * FP_rate:.1f}%")
print()
print(f"Of all the people who don't have the disease,")
print(f"we incorrectly flagged {100 * FP_rate:.1f}% of them.")

This number tells you: out of everyone who’s actually healthy, how many did we wrongly tell to worry? A high False Positive rate means you’re crying wolf a lot. People stop trusting your model.

In this run the rate comes out to 0% — this particular model didn’t raise any false alarms on the test set. That won’t always be true. Watch what happens to False Positives later in this article, once we start moving the classification threshold.

The False Negative: The Silent Danger

Here’s the error that tends to worry practitioners most.

A False Negative occurs when your model predicts “No” but the truth is “Yes.” It’s the opposite of a False Positive.

Imagine a fire alarm that stays quiet while your kitchen burns. That’s a False Negative. The alarm had one job, and it failed silently.

The model said “No,” but the truth was “Yes.”

False Negatives are the “hidden danger” kind of error. They’re often worse than False Positives. You don’t find out about them right away; you discover them later, when the damage is already done.

In disease screening, a sick person gets sent home untreated. That’s serious. In fraud detection, a bad actor gets approved and steals money. For a fire alarm, the building burns down.

Building real-world models is hard. False Negatives are often the most expensive mistake, and the hardest to catch, because they don’t announce themselves.

Here’s the False Negative rate for our model:

# False Negatives: model said NO, but reality was YES
# These are in the bottom-left of the matrix
FN = cm[1, 0]

# Total actual positives (people who DO have the disease)
total_actual_positives = TP + FN

# False Negative Rate: what percentage of actual positives did we miss?
FN_rate = FN / total_actual_positives if total_actual_positives > 0 else 0

print(f"False Negatives: {FN}")
print(f"Total actual positives: {total_actual_positives}")
print(f"False Negative Rate: {100 * FN_rate:.1f}%")
print()
print(f"Of all the people who DO have the disease,")
print(f"we missed {100 * FN_rate:.1f}% of them.")
print(f"These people went home thinking they were healthy.")

This number tells you: out of everyone who’s actually sick, how many did we send home healthy? A high False Negative rate means your model is unreliable. The harm comes from what you missed.

Visualizing the Truth with ConfusionMatrixDisplay

Raw numbers are hard to read. Your brain doesn’t process “TN=265, FP=12, FN=8, TP=15” as easily as it processes a picture.

Turn the confusion matrix into a heatmap. Colors make the story jump out at you. A perfect model shows a bright diagonal where it got things right, and dark corners where it messed up. Dark corners in the wrong places? Your model is systematically biased.

from sklearn.metrics import ConfusionMatrixDisplay
import matplotlib.pyplot as plt

# Create a confusion matrix display
disp = ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=['Negative', 'Positive'])
disp.plot(cmap='Blues', values_format='d')
plt.title('Confusion Matrix: Our Model\'s Performance')
plt.ylabel('Actual')
plt.xlabel('Predicted')
plt.tight_layout()
plt.show()

print("Look at the heatmap:")
print("- Bright blue on the diagonal = model got it right")
print("- Bright blue in the corners = model made mistakes")
print("- If the top-right is bright, you have lots of False Positives")
print("- If the bottom-left is bright, you have lots of False Negatives")

Look at this heatmap and you instantly see the shape of your model’s mistakes. A bright top-right corner means it’s saying “Yes” too often. A bright bottom-left corner means it’s saying “No” too often.

This visual tells you at a glance whether your model is just guessing the majority class (like our lazy model did) or actually trying to learn the pattern.

So What? How to Use This to Fix Your Model

Now that you can see the mistakes, what do you do about them?

You usually can’t fix both False Positives and False Negatives at once. Push one down and the other climbs. That trade-off sits at the heart of practical machine learning.

Most models don’t output a hard “Yes” or “No” — they output a probability. Behind the scenes, your model is saying something like “I’m 73% confident this is a positive case.” You set a threshold (usually 50%) and convert that probability into a decision: above 50%, predict Yes. Otherwise, No.

Lower the threshold to 30% and the model predicts “Yes” more often. You’ll catch more True Positives, but you’ll also collect more False Positives. Raise it to 70% and you get the opposite — fewer False Positives, more missed True Positives.

So you have to decide which mistake hurts more. In disease screening, you’d rather catch every sick person and tolerate some false alarms. In spam filtering, false alarms are worse than letting the occasional spam through.

Let’s watch the trade-off play out:

from sklearn.metrics import confusion_matrix, accuracy_score

# Get probability predictions instead of hard predictions
y_pred_proba = real_model.predict_proba(X_test)[:, 1]

print("Threshold trade-off:")
print()

for threshold in [0.3, 0.5, 0.7]:
    # Apply the threshold
    y_pred_threshold = (y_pred_proba >= threshold).astype(int)
    
    # Calculate confusion matrix
    cm_threshold = confusion_matrix(y_test, y_pred_threshold)
    TN_t = cm_threshold[0, 0]
    FP_t = cm_threshold[0, 1]
    FN_t = cm_threshold[1, 0]
    TP_t = cm_threshold[1, 1]
    
    accuracy_t = accuracy_score(y_test, y_pred_threshold)
    
    print(f"Threshold: {threshold}")
    print(f"  Accuracy: {100 * accuracy_t:.1f}%")
    print(f"  True Positives: {TP_t} (caught {TP_t} sick people)")
    print(f"  False Positives: {FP_t} (false alarms)")
    print(f"  False Negatives: {FN_t} (missed {FN_t} sick people)")
    print()

Run this and you’ll see the see-saw move: at threshold 0.3, the model catches 2 of the 5 sick people in the test set but also raises 2 false alarms; push the threshold up to 0.5 and it catches just 1 with no false alarms; push it to 0.7 and it catches none. As the threshold drops, False Negatives fall (you catch more sick people) but False Positives rise (more false alarms). Raise the threshold and the pattern reverses.

Accuracy is the “what” — it tells you the total percentage of correct guesses. The confusion matrix is the “why” and “where” — it shows exactly what kind of mistakes your model makes and where it breaks down.

Now you have the tools to look at your model honestly. You can tell whether it’s learning or just guessing. You can see which mistakes it makes. And you can make an informed choice about whether those mistakes are acceptable for your problem.

The next step is turning those four boxes into finer-grained scores like Precision and Recall — that lets you compare models and optimize for the specific mistakes that matter most to you. We’ll come back to that later.

For now, when someone shows you an accuracy score, ask to see the confusion matrix. That’s where the real story lives.

Check Your Understanding

Questions below move from simple recall up to open-ended design, roughly following Bloom’s Taxonomy.

Remember What is the “Accuracy Paradox,” and how did the article’s lazy model demonstrate it?

Understand In your own words, explain why False Positives and False Negatives are described as being on a “see-saw” — why does lowering the classification threshold reduce one while increasing the other?

Apply Using the article’s threshold-tradeoff table structure, if you were building a disease-screening model where missing a sick patient is far worse than a false alarm, would you move the threshold toward 0.3 or 0.7? Justify using the article’s own definitions of what each direction does to FP and FN.

Analyze The article says “the words ‘Positive’ and ‘Negative’ don’t mean ‘good’ and ‘bad’” — they just label the two outcomes, and what counts as “Positive” depends on the problem. Walk through why mixing up which class is labeled “Positive” in a fraud-detection model (fraud = positive vs. legitimate = positive) would completely flip the practical meaning of your False Positive and False Negative rates, even though the underlying model and predictions haven’t changed.

Evaluate The article’s lazy model achieves 99% accuracy while catching zero sick people, and the article treats this as an obvious example of the Accuracy Paradox. Critique a stakeholder who sees this and concludes “we should never use accuracy as a metric” — is there a legitimate use case (even with imbalanced classes) where accuracy is still a reasonable metric to report?

Create Design a threshold-selection process for a new scenario: a content-moderation model deciding whether to auto-remove a post as harassment, where False Positives (wrongly removing legitimate posts) damage user trust and False Negatives (missing real harassment) cause user harm. Describe how you’d decide where to set the threshold, referencing the article’s “which mistake matters more for your problem” framing, and what data you’d want before making that call.


Apply What You Learned is for Supporter and Insider subscribers.

Subscribe to unlock the exercises on this post.

See plans

Looking for something else?

Search every article by title, summary or topic.