The Confusion Matrix

The table every classification metric is computed from - and the one place you can see which mistakes the model is actually making.

Overview

What the table is

A single accuracy number hides where the model goes wrong. The confusion matrix shows exactly where each mistake happens. It breaks the predictions down by class and by error type, so you can see whether the model confuses cats with dogs or misses a rare class entirely.

Reading a confusion matrix is a skill worth building early. Every other classification metric -- precision, recall, F1, specificity -- can be computed from the four cells of a binary confusion matrix. Once you can read the table, the formulas follow naturally.

The matrix also helps you decide what to fix. If false positives are high, the model is too aggressive. If false negatives are high, it is too cautious. Adjusting the threshold or rebalancing the classes targets one at the expense of the other. The confusion matrix is what tells you which direction to push.

This page covers the table layout, the naming convention, the multi-class extension and the normalised display that makes comparison easier.

The confusion mat cross-tabulates what was true against what was predicted. Rows are the actual classes, columns are the predicted ones. Each cell counts how many samples fell into that combination.

The main-line is where the two agree. Correct guesses. All off the main-line is a mistake. *which* off-main-line cell it lands in tells you what kind.

For two classes the rule is worth storing because ravel() returns it in this order: true minus, false plus, false minus, true plus. Reading it as tn, fp, fn, tp = cm.ravel() is the standard line.

The naming is easier than it looks once you read it as two words rather than one phrase. The second word is what the model *said*. The first word is whether it was *right*. So a false minus is a case the model called minus and was wrong about — a miss.

Worth knowing

Rows are the truth, columns are the prediction. The diagonal is correct; everything off it is a mistake.
For two classes, cm.ravel() unpacks to tn, fp, fn, tp in that order.
Precision, recall, accuracy and the rest are all arithmetic on these four counts - the table is the thing they summarise.
normalize="true" divides by row, showing what happened to each actual class regardless of its size.
Pass labels= to fix the order and keep a never-predicted class in the table.
Confusions are usually asymmetric: A mistaken for B is a different count from B mistaken for A.

The Confusion Matrix: A Practical Guide

each classification metric is a fraction found from four numbers. Looking at the four right tells you things no fraction can.

The four counts

Rows are what was true, columns are what was predicted. Everything else on this page is arithmetic on these four numbers.

example_01.pyscikit-learn
Output

Every metric, derived from the table

Precision, recall and accuracy are three different fractions of the same four counts.

example_02.pyscikit-learn
Output

With several classes it stops being 2x2

And the interesting part is which classes get mistaken for which - which no single metric can tell you.

example_03.pyscikit-learn
Output

Normalise by row to compare unequal classes

Raw counts are dominated by whichever class is largest. Row proportions are not.

example_04.pyscikit-learn
Output

labels= fixes the order and the missing classes

Without it, a class the model never predicted can vanish from the table entirely.

example_05.pyscikit-learn
Output

Read the rows the model got wrong

The matrix says how many. The rows themselves say why.

example_06.pyscikit-learn
Output

Why it beats any single number

A metric compresses the table into one figure. Compression loses just the info you need to improve the model.

Fit of 0.94 could be 88 true negatives and 6 true positives with 4 misses and 2 false alarms. Or it could be 94 true negatives, no true positives. 6 misses, with the model never predicting the positive class at all. Those are fully different situations and fit reports them the same way.

The mat sets apart them right away. It does so before you've decided which metric matters. That is the hands-on arg for printing it first. It tells you what the model is doing, and only then do you choose the number that summarises it.

It also tells you which direction to push. Too many false positives and too few false negatives means the threshold is too low. The reverse means it's too high. Both visible at a glance, neither visible in an fit score.

The multi-class version, and what it reveals

With k classes the mat is k by k. The off-main-line structure carries info that no averaged metric preserves.

The editor above fits iris on sepal readings only. on purpose too little. And the result is instructive. Setosa is classified just right, never confused with any of it. Versicolor and virginica are confused with each other again and again. not symmetrically. Versicolor is called virginica 8 times, while virginica is called versicolor 5 times.

That skew is a real finding and it's invisible in an F1 score. It tells you the boundary between those two classes is drawn slightly off centre. It tells you setosa is a solved problem while the other two need better features. A macro F1 of 0.78 tells you none of that.

The general habit: on a multi-class problem, find the largest off-main-line cell. It's almost always one exact pair being confused. It's almost always the most productive thing to work on.

Normalising, and which way

Raw counts are dominated by class size. On a 99-to-1 problem the true-minus cell is huge and the rest are visually invisible. This Makes the table hard to read though the numbers are correct.

norm="true" cuts each row by its total, giving the proportion of each actual class that went to each guess. The main-line then reads as per-class recall. A row that is mostly off the main-line shows a class the model handles badly even so of how few samples it has.

norm="pred" cuts by column instead, giving the proportion of each guess that was correct. The main-line reads as per-class precision.

norm="all" cuts by the grand total. This Is at times handy and often the least helpful of the three.

Row norming is the default worth reaching for. It answers "what does the model do with each kind of case". This Is the question that lives changes in class balance.

The labels argument

confusion_matrix infers the classes from the data it's given, sorted. Two consequences bite.

A class present in y_true but never predicted still appears. But a class absent from both, perhaps because the test split happened to hold none of it, disappears fully. The mat silently turns smaller than expected. Code that indexes into it by class spot then reads the wrong cell.

And the order is sorted. This For string labels is alphabetical: ["cat", "dog", "fish"]. given a different order and labelling the axes so makes a table that is confidently wrong.

Passing labels= fixes both. It ensures the size, ensures the order. Makes the code free of which classes happened to turn up in a given split. It's worth passing habitually rather than when a problem appears.

From the table to the rows

The mat says how many mistakes of each kind. The next question is always why. Answering it means looking at the samples themselves.

Selecting the misclassified rows takes one line. np.flatnonzero(pred != y_true). And reading a handful of them is always the most helpful ten minutes available. often they've a thing in common. Values near a boundary, a category the training data barely covered, missing fields that were imputed, or labels that are simply wrong in the source data.

Each of those has a different response. None of them is "try a different model". Mislabelled ground truth in given is far more common than people expect, and no amount of tuning fixes it. The model is being penalised for being right.

The editor above does just this for the iris confusion. The misclassified rows are all sepal readings in the shared middle of the two species. That isn't a model problem. It's the honest answer that these two features don't separate those two classes. The fix is petal readings rather than a fancier classifier.

The other convention, and why it causes trouble

scikit-learn puts truth on the rows and guesses on the columns. A good deal of the statistics literature does the opposite. Some textbooks put the positive class first rather than second.

That means a mat copied from a paper, a blog post or a lecture slide may be transposed scaled to what confusion_matrix makes. A transposed mat swaps precision and recall without changing any of it visible. The numbers all look plausible. They're simply answering the other question.

Two habits prevent it. Print the mat with labelled axes rather than as a bare array. This Takes three lines and drops the ambiguity permanently. And sanity-check against a metric you trust. Find recall with recall_score and confirm it matches the main-line cell cut by its row total. If it matches the column total instead, the orientation isn't what you assumed.

The same caution applies to the binary case. Here The positive class is whichever label sorts second. 1 before 0 isn't the order, 0 then 1 is. With string labels, "no" sorts before "yes". This Often happens to be right. With "minus" and "plus" it also happens to be right. With "benign" and "malignant" it's right for the wrong reason. With labels where it isn't, pos_label= is the arg that says so by name.

What it cannot tell you

Two limits worth stating, because the mat is or else so handy that it gets asked questions it can't answer.

It says nothing about trust. each cell counts hard guesses. A case predicted plus at 0.51 and one at 0.99 are the same entry. A model can have an great mat and terrible chances, or the reverse. Only the chance-based metrics set apart them.

And it shows one threshold. Change the threshold and each cell moves. That is the whole mechanism of the precision-recall trade. So a confusion mat is a snapshot of one operating point, not a description of the model. Checking two models by their mats means checking them at what threshold each happened to use.

Is there a plotting version? ConfusionMatrixDisplay.from_estimator(model, X, y) draws it with matplotlib. This Is worth using once the mat is bigger than about four by four and the numbers stop being readable as text.

Which class is "plus" in a binary problem? Whichever label sorts second. 0 then 1 puts 1 plus. Pass pos_label= when the sorted order isn't what you mean.

Can I get one mat per class for a multi-label problem? Yes - multilabel_confusion_matrix returns a stack of 2x2 tables, one per label. This Is the right shape when a sample can carry a few labels at once.

Putting a cost on each cell

The mat turns a choice tool rather than a diagnostic the moment you attach a number to each kind of mistake.

The four cells rarely cost the same. A false minus on a fraud check is the value of the fraud. A false plus is a few minutes of an analyst's time. A false minus on a medical screen is a missed diagnosis. A false plus is a second test. A false minus on a spam filter is one unwanted email. A false plus is a lost message the recipient never knew about.

Once those numbers exist, the expected cost of a model is the sum of each cell times'd by its price. Checking two models turns math rather than arg. It also settles the threshold question right. Sweep the threshold, find the total cost at each one. Pick the min. That is a better method than choosing a threshold to hit a round-numbered precision, and it takes about the same effort.

The exercise is worth doing even when the costs are rough. Guessing that a miss is roughly ten times worse than a false alarm is enough to rule out most of the range. It forces the conversation with whoever owns the problem. Who often has a much clearer view of the scaled costs than the person building the model, and who is rarely asked.

Where it goes wrong is treating the guessed costs as precise. They aren't. A threshold tuned to the third decimal place of an invented cost ratio is false precision. The handy output is a region rather than a point. Anywhere in this range is sensible, and outside it isn't.

Things to try with the Confusion Matrix

  1. Unpack the four counts. In the first editor, find precision and recall yourself from tn, fp, fn, tp before looking at what the library returns.
  2. Find the worst pair. In the third editor, locate the largest off-main-line cell and note that it isn't mirrored.
  3. Switch the norming. In the fourth editor, compare norm="true" with norm="pred" and work out which main-line is recall and which is precision.
  4. Read the mistakes. In the last editor, raise the slice to see all thirteen and look for what they've in common.

Questions people ask about the Confusion Matrix

How do I read a confusion matrix? Rows are the true classes and columns the predicted ones in scikit-learn. The diagonal holds correct predictions and everything else is a mistake.

Which cell is a false positive? For a binary problem, the cell where the true class is 0 and the prediction is 1, which is the top-right entry.

What does normalize do? It converts counts to rates. normalize='true' divides each row by its total, so each row shows recall per class.

Why does my matrix look transposed compared with a textbook? Some sources put predictions on rows. Scikit-learn uses true labels on rows, so check the convention before comparing.

Can I get TN, FP, FN and TP out directly? Yes. For binary problems, tn, fp, fn, tp = confusion_matrix(y, p).ravel() unpacks them.

Does it work for many classes? Yes, it becomes a square table with one row and column per class. Large off-diagonal cells show which classes get confused.

Where this leaves you

Four counts for two classes, k by k for more, rows as truth and columns as guess. Print it before choosing a metric, norm by row when the classes are uneven, pass labels= so the shape is ensured. Read the rows behind the largest off-main-line cell before changing any of it.

Check yourself

0 of 4

Answer without scrolling back up.

  1. In a scikit-learn confusion matrix, what do the rows represent?

  2. What does cm.ravel() return for a binary problem?

  3. Why pass labels= to confusion_matrix?

  4. What does normalize="true" give you on the diagonal?

Cheat sheet

The Confusion Matrix

Build a confusion matrix from predictions, name each of the four cells, and compute precision, recall and F1 from them by hand.

SCIKIT-LEARN · vizlearn.in/sklearn/confusion_matrix.html

About the author

Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.