datarekha

Plots every ML engineer ships

The five charts that go on every model card — confusion matrix, ROC, precision-recall, learning curve, feature importance. Copy-paste ready.

8 min read Intermediate Storytelling with Visualisation Lesson 5 of 12

What you'll learn

  • Annotated confusion matrices that a non-ML stakeholder can read
  • ROC and precision-recall curves with AUC in the legend
  • Learning curves that diagnose overfitting at a glance
  • Feature importance bar charts ranked correctly

Before you start

The dashboard from the last lesson was deliberately generic. This one is the opposite — the specific, non-negotiable charts a machine-learning model is judged by. A model is only as trustworthy as the plots that come with it. Every serious model card — internal review doc, customer-facing report, production runbook — needs roughly the same five charts. Learn them once, paste them everywhere.

The five are: confusion matrix, ROC + AUC, precision-recall, learning curve, and feature importance. Together they answer: where does the model get things wrong, how good is it at ranking, how does it behave at imbalanced thresholds, is it overfitting, and which features actually matter.

Confusion matrix — annotated, not just colored

A confusion matrix without numbers in the cells is useless. Annotate it.

import numpy as np
import matplotlib.pyplot as plt

# Hand-rolled predictions vs labels — 4-class problem
classes = ["spam", "promo", "social", "primary"]
cm = np.array([
    [142,   8,   2,   3],
    [ 11, 118,   6,   9],
    [  4,   5, 167,  12],
    [  2,   7,  14, 190],
])

fig, ax = plt.subplots(figsize=(6, 5))
im = ax.imshow(cm, cmap="Blues")

# Tick labels
ax.set_xticks(range(len(classes)))
ax.set_yticks(range(len(classes)))
ax.set_xticklabels(classes)
ax.set_yticklabels(classes)
ax.set_xlabel("Predicted")
ax.set_ylabel("True")
ax.set_title("Confusion matrix — email classifier")

# Annotate each cell with the count.
# Flip text color on dark cells for legibility.
thresh = cm.max() / 2
for i in range(cm.shape[0]):
    for j in range(cm.shape[1]):
        ax.text(j, i, cm[i, j],
                ha="center", va="center",
                color="white" if cm[i, j] > thresh else "black")

fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04)
fig.tight_layout()
plt.show()
A 4x4 confusion matrix for an email classifier (spam, promo, social, primary) shaded in blue, with the count annotated in every cell and a colorbar. The large numbers run down the diagonal (correct predictions).

Rows = true label, columns = predicted. The bright diagonal is correct predictions; off-diagonal cells are the errors.

The layout convention: rows = true label, columns = predicted label. That means the diagonal (top-left to bottom-right) is correct predictions; everything else is an error.

Two non-negotiables: counts in every cell, and text colour that flips on the dark cells so the numbers stay readable. The imshow

  • text loop is the whole pattern — sklearn’s ConfusionMatrixDisplay does the same thing under the hood.

ROC curve with AUC

ROC (Receiver Operating Characteristic) plots true positive rate (fraction of real positives the model catches) vs false positive rate (fraction of negatives it wrongly flags) as you sweep the decision threshold. The AUC (Area Under the Curve) collapses this to one number you can quote in a Slack message — higher is better, 0.5 is random, 1.0 is perfect.

import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import make_classification
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
from sklearn.metrics import roc_curve, auc

X, y = make_classification(n_samples=2000, n_features=20, n_informative=8,
                           weights=[0.7, 0.3], random_state=0)
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.3, random_state=0)

model = LogisticRegression(max_iter=1000).fit(X_tr, y_tr)
scores = model.predict_proba(X_te)[:, 1]

fpr, tpr, _ = roc_curve(y_te, scores)
roc_auc = auc(fpr, tpr)              # 0.951 on this split

fig, ax = plt.subplots(figsize=(6, 5))
ax.plot(fpr, tpr, color="steelblue", lw=2,
        label=f"Logistic — AUC = {roc_auc:.3f}")
ax.plot([0, 1], [0, 1], color="grey", lw=1, linestyle="--",
        label="Random")
ax.set_xlim(0, 1)
ax.set_ylim(0, 1.02)
ax.set_xlabel("False positive rate")
ax.set_ylabel("True positive rate")
ax.set_title("ROC curve")
ax.legend(loc="lower right", frameon=False)
ax.grid(True, alpha=0.3)
fig.tight_layout()
plt.show()
ROC curve bowing toward the top-left corner with AUC = 0.951 in the legend, plotted against a dashed diagonal random-classifier baseline.

AUC = 0.951 — the curve bows hard toward the top-left, far above the dashed random baseline.

The dashed diagonal is the random-classifier baseline. The AUC goes right into the legend label — anyone scanning the plot sees the headline number without reading the title.

Precision-recall — the imbalanced-class workhorse

Precision = of all predicted positives, how many are actually positive. Recall = of all actual positives, how many did the model find. There is a trade-off: a stricter threshold raises precision but lowers recall.

When positives are rare (fraud, churn, click-through), ROC can look flatteringly good because the FPR stays tiny. Precision-recall is honest about how many of your positive predictions are correct.

import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import make_classification
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
from sklearn.metrics import precision_recall_curve, average_precision_score

X, y = make_classification(n_samples=3000, n_features=15, n_informative=6,
                           weights=[0.95, 0.05], random_state=1)
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.3, random_state=0)

model = LogisticRegression(max_iter=1000).fit(X_tr, y_tr)
scores = model.predict_proba(X_te)[:, 1]

precision, recall, _ = precision_recall_curve(y_te, scores)
ap = average_precision_score(y_te, scores)   # 0.671 on this split
baseline = y_te.mean()    # precision of a random classifier = positive rate (0.053)

fig, ax = plt.subplots(figsize=(6, 5))
ax.plot(recall, precision, color="crimson", lw=2,
        label=f"Logistic — AP = {ap:.3f}")
ax.axhline(baseline, color="grey", lw=1, linestyle="--",
           label=f"Baseline = {baseline:.3f}")
ax.set_xlim(0, 1)
ax.set_ylim(0, 1.02)
ax.set_xlabel("Recall")
ax.set_ylabel("Precision")
ax.set_title("Precision-recall curve — 5% positive rate")
ax.legend(loc="upper right", frameon=False)
ax.grid(True, alpha=0.3)
fig.tight_layout()
plt.show()
Precision-recall curve in crimson (AP = 0.671) starting high on the left and falling as recall rises, far above a dashed horizontal baseline at 0.053 (the 5% positive rate).

AP = 0.671, and the dashed baseline sits at just 0.053 (the 5% positive rate) — the gap is the lift the model actually earns.

The baseline for PR is not the diagonal — it’s a horizontal line at the positive rate. With 5% positives, random precision is 0.05, and a real model has to clear that. Always draw the baseline so reviewers can see how much lift you’ve actually got.

The confusion matrix and these two curves all move together as you shift the decision threshold. Drag it below to see exactly how that works with real sample counts:

TryThreshold & confusion

One model score, infinite classifiers — move the threshold

Each dot is one sample. Positives (top row) and negatives (bottom row) are sorted by the model's probability score. The threshold line decides what counts as a positive prediction — drag it left and you catch more real positives (recall ↑, precision ↓); drag it right and you flag fewer false alarms (precision ↑, recall ↓).

Model scorespositives (actual 1)negatives (actual 0)
posneg0.000.250.500.751.00model score →
threshold0.50

Predict positive when score ≥ 0.50

Confusion matrix @ 0.50
pred +
pred −
actual +
TP40
FN10
actual −
FP7
TN41
Precision85.1%
Recall80.0%
F182.5%
Accuracy82.7%
The threshold is not "0.5 by default" — it is a tunable parameter. Shift it toward 0 to maximise recall (catch everything, accept false alarms); shift toward 1 to maximise precision (only flag what you're sure about, miss some real cases). Which trade-off is right depends on the problem — not on the model.

Learning curve — train vs val loss

Two lines, x = epoch, y = loss. The shape tells you everything.

import numpy as np
import matplotlib.pyplot as plt

# Simulated training history — train loss keeps falling,
# val loss bottoms out then rises (classic overfit).
np.random.seed(0)
epochs = np.arange(1, 51)
train_loss = 1.6 * np.exp(-epochs / 12) + 0.05 + np.random.normal(0, 0.01, 50)
val_loss   = 1.6 * np.exp(-epochs / 12) + 0.15 + 0.004 * (epochs - 20).clip(0) ** 1.5

best_epoch = int(np.argmin(val_loss)) + 1   # 26

fig, ax = plt.subplots(figsize=(8, 4))
ax.plot(epochs, train_loss, label="Train loss", color="steelblue", lw=2)
ax.plot(epochs, val_loss,   label="Val loss",   color="crimson",   lw=2)

# Mark the best epoch
ax.axvline(best_epoch, color="grey", linestyle="--", lw=1)
ax.annotate(f"Best epoch = {best_epoch}",
            xy=(best_epoch, val_loss.min()),
            xytext=(best_epoch + 3, val_loss.min() + 0.15),
            arrowprops=dict(arrowstyle="->", color="black"))

ax.set_xlabel("Epoch")
ax.set_ylabel("Loss")
ax.set_title("Learning curve — train vs validation")
ax.legend(loc="upper right", frameon=False)
ax.grid(True, alpha=0.3)
fig.tight_layout()
plt.show()
Learning curve: train loss (blue) keeps falling toward 0.1, while validation loss (crimson) bottoms out around epoch 26 then climbs again — the classic overfit signature. A dashed vertical line and annotation mark 'Best epoch = 26'.

Val loss bottoms at epoch 26 then rises while train loss keeps falling — the textbook overfit fork. Early-stop at the dashed line.

The validation minimum lands at epoch 26 — after that, train loss keeps dropping while val loss climbs, the unmistakable fork of overfitting. Three patterns to recognize at a glance:

  • Both curves still falling at the end → undertrained, train longer.
  • Train falls, val flattens → fine, you’ve reached capacity.
  • Train falls, val starts rising → overfit. Early-stop at the validation minimum (the vertical line above).

You can read all of these off the shape alone — no need for the underlying numbers. Flip between the canonical diagnoses below and drag the early-stop line to see where you’d halt each run.

TryLoss curves · diagnose the run

Read the diagnosis off the shape — no data required

110203040500.00.51.01.5epoch
val rising while train falls → overfitting; regularize or early-stop
trainval
epoch18
train0.261
val0.411
gap+0.151
Drag the line to the val minimum (or snap) — that's where you'd early-stop this overfitting run.
The shapes are the diagnosis: train and val together = healthy or underfit, a widening gap = overfit, jagged or crawling = the learning rate. Drag the line to read any epoch; the green mark is the val minimum — where early stopping belongs.

Feature importance — sorted, descending, with values

The bar chart that ends every model report. Two rules: sort the bars and show the magnitude in the label or at the bar end.

import numpy as np
import matplotlib.pyplot as plt

features = np.array(["account_age_days", "tx_count_30d", "avg_tx_amt",
                     "country_risk_score", "device_age_days", "ip_velocity",
                     "merchant_category_risk", "hour_of_day", "is_weekend",
                     "card_bin_risk"])
importance = np.array([0.21, 0.18, 0.14, 0.11, 0.08, 0.07, 0.06, 0.05, 0.05, 0.05])

# argsort is ascending; barh then draws the largest bar at the top
order = np.argsort(importance)
features_sorted   = features[order]
importance_sorted = importance[order]

fig, ax = plt.subplots(figsize=(8, 5))
bars = ax.barh(features_sorted, importance_sorted, color="steelblue")

# Value labels at the end of each bar
for bar, val in zip(bars, importance_sorted):
    ax.text(val + 0.003, bar.get_y() + bar.get_height() / 2,
            f"{val:.2f}", va="center", fontsize=9)

ax.set_xlabel("Importance")
ax.set_title("Feature importance — fraud model v3.1")
ax.set_xlim(0, importance.max() * 1.15)
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
fig.tight_layout()
plt.show()
Horizontal bar chart of feature importance for a fraud model, sorted descending with the largest bar (account_age_days, 0.21) at the top and each bar labelled with its value.

Sorted descending, horizontal bars, value at the end of each — account_age_days (0.21) leads.

Horizontal bars (barh) are nearly always better than vertical when feature names are more than one short word — the labels stay horizontal and readable. Sorted, with values printed, no gridlines needed.

In one breath

Five charts ship with every model. A confusion matrix (rows = true, columns = predicted; diagonal = correct) needs counts in every cell and text colour that flips on dark cells. ROC + AUC sweeps the threshold to plot true-positive vs false-positive rate and collapses it to one number (here 0.951; 0.5 is random) — but on rare positives it flatters, so reach for precision-recall instead, whose baseline is a horizontal line at the positive rate (0.053 here), not a diagonal. A learning curve diagnoses fit from shape alone: both falling = undertrained, train-falls-val-flat = done, train-falls-val-rises = overfit (early-stop at the val minimum, epoch 26). And feature importance is a horizontal bar chart, sorted descending with values printed. Learn the five, paste them everywhere.

Practice

Quick check

0/3
Q1Your fraud model has 1% positive rate. ROC-AUC is 0.94 but the PR curve looks weak. Which metric should you trust for production?
Q2On a learning curve, train loss keeps falling but val loss has been rising for the last 10 epochs. What does this mean?
Q3Why is the baseline on a precision-recall curve a horizontal line, not a diagonal?

A question to carry forward

Look back at the confusion matrix code — the double for loop to write counts in cells, the manual text-colour flip, the colorbar fiddling. Even our “simple” plots took a dozen lines of styling each, and every one started from a bare-bones default. That’s matplotlib’s bargain: total control, but you build everything by hand.

So the question to carry forward is: what if a single line gave you a statistically aware plot — a histogram with a smooth density curve already overlaid, sensible colours, a legend — with no manual loops at all? That’s the promise of the next chapter, Seaborn, a library built on top of matplotlib for fast statistical graphics. Its first lesson, distributions, shows how histplot, kdeplot, and displot turn the “how is this variable spread out?” question into one readable call.

Sign in to track your progress

Completed lessons, your XP, level, and streak save to your account — it's free and takes a few seconds.

Practice this in an interview

All questions
What goes in a model card, and how do you provide explainability for production decisions?

A model card documents a model's intended use, training data, evaluation results broken down by relevant subgroups, known limitations, and ethical considerations, so stakeholders can judge whether and where it should be used. Explainability is provided through methods like SHAP or LIME for feature attributions, plus logging the inputs and reasons behind each decision so it can be audited or contested. Together they support transparency, oversight, and regulatory requirements for high-risk systems.

What is a confusion matrix and what four quantities does it report?

A confusion matrix tallies predictions against ground truth in a 2x2 table: true positives, true negatives, false positives, and false negatives. From those four cells every classification metric — accuracy, precision, recall, F1, specificity — can be derived. It exposes *which kind* of error a model makes, not just how often it errs.

How does CI/CD for ML differ from standard software CI/CD, and what stages should an ML pipeline include?

ML CI/CD must validate not just code correctness but also model quality — automated retraining triggers, data validation, model evaluation gates, and canary deployment checks that standard software pipelines have no equivalent for. A regression in model AUC is as much a deployment failure as a 500 error.

What are the key regression metrics — MAE, RMSE, MAPE, R² — and what are their failure modes?

MAE, RMSE, MAPE, and R² each measure a different aspect of regression quality and each has a regime where it misleads. RMSE is dominated by outliers; MAE is robust but hides large-error tails; MAPE is undefined at zero and asymmetrically penalises under-prediction; R² can appear high even when absolute errors are large, and can be negative, yet is still commonly misread as a percentage-correct. Choosing the right metric requires knowing the cost structure of the prediction task.

Related lessons

Explore further

Skip to content