Classification Metrics and Decision Thresholds › Visualizing Classification Metrics and Thresholds

Michael BrenndoerferOctober 24, 202618 min read
Machine Learning from ScratchAll chapters
Part 4 of 15Model Evaluation and Selection
Chapter 32Classification Metrics and Decision Thresholds › Visualizing Classification Metrics and Thresholds
Reading section 5 of 10 · Visualizing Classification Metrics and Thresholds · 18 minSection 5 of 10 · 18 min

Section 5 of 10 · 18 min read

Classification Metrics and Decision Thresholds › Visualizing Classification Metrics and ThresholdsSection view · Sections in this chapter5 / 10 ↓
Reading tools Definitions, links & notes

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

Notes & comments

We now build a small alerting problem where every quantity is known by construction. Each account has two features, the linear predictor is zi=β0+β1xi1+β2xi2z_i = \beta_0 + \beta_1 x_{i1} + \beta_2 x_{i2}, the label is drawn as yi∼Bernoulli(pi)y_i \sim \text{Bernoulli}(p_i) with pi=1/(1+e−zi)p_i = 1/(1+e^{-z_i}), and the model's score is exactly pip_i, the probability you would threshold. Because the label was generated from the same probability the score reports, the score is calibrated by construction. That lets us compare a finite-sample empirical cost minimum with the population cutoff from the previous section. They need not agree exactly on a realized sample; an arbitrary score would not justify the analytic probability cutoff at all.

The scores arrive from a model that would, in a real pipeline, have been fitted on a separate training split. Since we start from scores, the roles you need here are selection (validation) and reporting (test). We look at the two samples side by side to confirm they are drawn from the same population.

PythonIn [3]
CollapseExpand
split_summary = pd.DataFrame(
    {
        "split": ["validation", "test"],
        "n": [len(y_val), len(y_test)],
        "positives": [int(y_val.sum()), int(y_test.sum())],
        "prevalence": [y_val.mean(), y_test.mean()],
        "mean score": [score_val.mean(), score_test.mean()],
    }
)
ConsoleOut [4]
CollapseExpand
     split    n  positives  prevalence  mean score
validation 4000        344       0.086      0.0856
      test 4000        352       0.088      0.0877

The two splits are drawn from the same generating process and land close on size, prevalence, and mean score. Small differences reflect sampling variation, so the validation and test sets can reasonably be treated as exchangeable draws from one population for the purposes of this example.

Next we write the sweep that does the work. These tutorial functions assume paired one-dimensional nonempty arrays with binary labels, both classes present and finite scores in [0, 1], as used in this chapter. They do not implement a general input-validation API. Everything below is a direct transcription of the formulas: counts at a single threshold, counts for every distinct threshold at once using cumulative sums over the score-sorted data, the two curves, and the cost minimization.

PythonIn [5]
CollapseExpand
def confusion_counts(y_true, y_score, threshold):
    """2x2 count matrix [[TN, FP], [FN, TP]] at a single threshold."""
    y = np.asarray(y_true)
    pred = (np.asarray(y_score) >= threshold).astype(int)
    return np.array(
        [
            [np.sum((pred == 0) & (y == 0)), np.sum((pred == 1) & (y == 0))],
            [np.sum((pred == 0) & (y == 1)), np.sum((pred == 1) & (y == 1))],
        ]
    )


def binary_metrics(y_true, y_score, threshold):
    """Precision, recall, false positive rate and F1 at one threshold."""
    tn, fp, fn, tp = confusion_counts(y_true, y_score, threshold).ravel()
    precision = tp / (tp + fp) if tp + fp > 0 else 1.0  # 0/0 convention
    recall = tp / (tp + fn) if tp + fn > 0 else 0.0
    fpr = fp / (fp + tn) if fp + tn > 0 else 0.0
    f1 = (
        2 * precision * recall / (precision + recall)
        if precision + recall > 0
        else 0.0
    )
    return {
        "TN": float(tn),
        "FP": float(fp),
        "FN": float(fn),
        "TP": float(tp),
        "precision": float(precision),
        "recall": float(recall),
        "fpr": float(fpr),
        "f1": float(f1),
    }


def evaluate(y_true, y_score, threshold, cost_fp=COST_FP, cost_fn=COST_FN):
    """Everything we report for one threshold, including expected cost."""
    m = binary_metrics(y_true, y_score, threshold)
    m["cost"] = (cost_fp * m["FP"] + cost_fn * m["FN"]) / len(y_true)
    return m


def threshold_sweep(y_true, y_score):
    """Counts for every distinct cut point, from 'alert nobody' to 'alert everybody'."""
    y = np.asarray(y_true)
    s = np.asarray(y_score, dtype=float)
    order = np.argsort(-s, kind="mergesort")
    y_sorted, s_sorted = y[order], s[order]
    tp = np.cumsum(y_sorted == 1).astype(float)
    fp = np.cumsum(y_sorted == 0).astype(float)
    keep = np.r_[
        s_sorted[1:] != s_sorted[:-1], True
    ]  # last index of each tie group
    tp, fp, s_kept = tp[keep], fp[keep], s_sorted[keep]
    tp, fp = np.r_[0.0, tp], np.r_[0.0, fp]
    n1, n0 = tp[-1], fp[-1]
    return {
        "threshold": np.r_[np.inf, s_kept],
        "tp": tp,
        "fp": fp,
        "fn": n1 - tp,
        "tn": n0 - fp,
        "n1": n1,
        "n0": n0,
    }


def curve_points(y_true, y_score):
    """ROC and PR coordinates on the shared threshold grid."""
    sw = threshold_sweep(y_true, y_score)
    denom = sw["tp"] + sw["fp"]
    precision = np.where(denom > 0, sw["tp"] / np.maximum(denom, 1e-12), 1.0)
    recall = sw["tp"] / sw["n1"]
    return {
        "threshold": sw["threshold"],
        "precision": precision,
        "recall": recall,
        "tpr": recall,
        "fpr": sw["fp"] / sw["n0"],
    }


def trapezoid_area(x, y):
    """Composite trapezoid rule on paired coordinate arrays."""
    return float(np.sum(np.diff(x) * (y[1:] + y[:-1]) / 2.0))


def roc_auc(y_true, y_score):
    pts = curve_points(y_true, y_score)
    return trapezoid_area(pts["fpr"], pts["tpr"])


def average_precision(y_true, y_score):
    pts = curve_points(y_true, y_score)
    return float(np.sum(np.diff(pts["recall"]) * pts["precision"][1:]))


def pr_trapezoid(y_true, y_score):
    pts = curve_points(y_true, y_score)
    return trapezoid_area(pts["recall"], pts["precision"])


def cost_curve(y_true, y_score, cost_fp=COST_FP, cost_fn=COST_FN):
    """Expected cost per example for every distinct threshold."""
    sw = threshold_sweep(y_true, y_score)
    n = sw["n1"] + sw["n0"]
    return sw["threshold"], (cost_fp * sw["fp"] + cost_fn * sw["fn"]) / n


def select_threshold(y_true, y_score, cost_fp=COST_FP, cost_fn=COST_FN):
    """Empirical cost minimiser with midpoint and endpoint representatives."""
    thresholds, costs = cost_curve(y_true, y_score, cost_fp, cost_fn)
    j = int(np.argmin(costs))
    upper = thresholds[j]
    lower = thresholds[j + 1] if j + 1 < thresholds.size else 0.0
    chosen = upper if np.isinf(upper) else 0.5 * (upper + lower)
    if chosen <= lower < upper:
        chosen = upper
    return chosen, float(costs[j])

We compute the curve arrays and operating metrics before the figures and reuse them in the plotting cells.

The first picture is the raw material: how the scores are distributed in each class. The vertical axis is logarithmic so that density values across a wide range remain visible. Each class histogram is normalized separately, so the height comparison describes class-conditional density rather than the ratio of class headcounts.

VisualizationOut [7]
CollapseExpand
Two step histograms. Negative scores pile up near zero with a long right tail; positive scores spread across the whole range. Vertical lines mark the default threshold at 0.5 and the cost-optimal threshold near 0.05.
Validation score distributions, normalized separately within each class, across the full probability range. The logarithmic density axis shows the long tails. The dotted line marks the default 0.50 threshold and the dashed line the validation-selected threshold near 0.047.

The two distributions overlap almost everywhere, which is the whole difficulty. The negative density is concentrated below 0.05, but its tail is long, so a threshold placed low enough to catch most positives drags in a large absolute number of negatives. The default cut of 0.50 sits far out in the positive tail. It would raise very few alerts and miss most fraud. The cost-optimal threshold sits far below it, inside the overlap, which is the quantitative version of "we would rather review more accounts than miss the fraud".

Plotting the metrics against the threshold itself makes the tradeoff explicit. The figure below is a useful diagnostic for a non-technical audience, because the horizontal axis is the one thing they control.

VisualizationOut [8]
CollapseExpand
Three curves against threshold. Recall and the false positive rate fall as the threshold rises; precision fluctuates. At the selected threshold near 0.047, recall is about 0.913, precision 0.203 and false positive rate 0.336, so the three curves do not cross there. A horizontal line marks the prevalence.
Recall, precision and false positive rate over the finite positive threshold grid. The dashed line marks the validation-selected threshold; the horizontal reference is prevalence. Recall is about 0.913, precision 0.203 and false positive rate 0.336 at that operating point.

Read the left edge first. When the threshold approaches zero every account is flagged, recall is 1, the false positive rate is 1, and precision collapses to the prevalence, which is the dotted line: if you alert on everything, the fraction of alerts that are real is exactly the base rate. Read the right edge next. When the threshold exceeds the largest score nothing is flagged and precision is 1 by convention. The interesting region is the middle, and the dashed line marks where the cost function prefers to sit. In this realization, the selected threshold lies below the recall–precision crossing. The crossing is determined by the scores and labels; the error costs choose where the selected marker sits relative to it.

The ROC and precision-recall curves are the same sweep with the threshold eliminated. The first figure adds the iso-cost line from the population cost formula; its slope encodes the ratio of false positive cost to false negative cost weighted by prevalence. The second figure shows the same classifier in precision-recall space, where the horizontal line is the prevalence. In the population, a score independent of the label has precision equal to prevalence at thresholds with positive alert probability. A finite empirical curve can fluctuate around that baseline, and its no-alert corner still uses the stated plotting convention.

::: {layout-ncol=2}

VisualizationOut [9]
CollapseExpand
ROC curve bowing toward the top-left corner, a diagonal chance line, a marked cost-optimal operating point, a square default operating point, and a dashed iso-cost line.
Validation ROC curve with the selected and default 0.50 operating points. The iso-cost line has positive slope and passes through the selected point. The default point is below and to the left, with both lower recall and lower false positive rate.
VisualizationOut [10]
CollapseExpand
Precision-recall curve falling from high precision at low recall to the prevalence at full recall, with a marked cost-optimal operating point and a square default operating point.
Validation precision-recall curve with the same two operating points. Raising the cut to 0.50 increases precision in this comparison while reducing recall to 0.25. The horizontal reference marks prevalence. Lines connect threshold states; the AP label uses a separate noninterpolated sum.

:::

The ROC curve shows a healthy ranking: the area is well above the diagonal. The operating point, however, sits at a false positive rate that will look alarming to you next to the default square marker. That is the correct reading. With a miss costing thirty times a false alarm, the optimum tolerates many false alarms, and the iso-cost line makes the arithmetic visible: in the (FPR, TPR) plane its positive slope is cFP(1−π)/(cFNπ)c_{FP}(1-\pi)/(c_{FN}\pi). The line passes through the minimum-cost empirical point and supports the available ROC points: a point above it would have smaller stated cost. A smooth tangency is not required for a discrete curve.

The precision-recall figure tells the same story in the units the review team cares about. Precision at the operating point is modest, but the alternative at the default threshold has recall of only 0.25 and therefore misses three quarters of the frauds, so its higher precision is largely irrelevant. Precision alone does not identify a useful rule, and it need not rise when the threshold rises. In the worked example below, increasing the threshold from 0.45 to 0.50 reduces precision from 4/7 to 1/2 as well as reducing recall from 1.000 to 0.750, because rank 7 (a true positive) drops out of the alert queue.

The final figure of this section plots the cost itself, and it is where the selection decision actually happens. The validation curve is what we minimize; the test curve evaluates fixed candidate rules on held-out observations and would stay unopened until after the validation choice was recorded. One realized test curve does not prove that the validation minimizer is the population optimum. The test curve's minimum and its value at the selected threshold are descriptive of this split, not an independent re-selection; the test set is used only to evaluate the fixed validation-selected rule.

VisualizationOut [12]
CollapseExpand
Two U-shaped cost curves against a logarithmic threshold axis, nearly overlapping. The minimum is marked with a point, and a dotted vertical line marks the analytic threshold near 0.03.
Observed cost per account over the threshold grid, shown as step functions for validation and test. The marked rule was selected on validation. The dotted line is the analytic cutoff near 0.0323; the test curve evaluates fixed rules without selecting a new threshold.

The empirical cost curve is a step function because predictions change only when a score is crossed. A plateau between adjacent distinct scores has width equal to their numerical gap. The cost jump at a score depends on the labels and number of observations tied there, as well as the stated error costs; neither fixes the shape of the minimum on its own. Inspect both the cost differences and threshold values; curve shape alone does not establish stability across new samples.

The two curves nearly coincide, which is the optimistic but expected outcome here: validation and test are drawn from the same distribution and both are large. The closed-form threshold for calibrated scores lands close to, but not exactly on, the empirical minimizer, because the empirical minimizer is a noisy estimate. Finite-sample differences are possible despite calibration. Convergence to one numeric threshold additionally requires conditions such as a unique population minimizer and suitable sampling; calibration alone does not establish those conditions.

The invariance claims of the previous section deserve a picture. The following reweights the validation sample so that the positive share becomes 20 percent and then 2 percent, holding the class-conditional score distributions fixed. The weights are constant within each class, and because the ROC coordinates normalize within each class, the ROC curve cannot change; precision, which mixes the classes, changes at mixed operating points with both true and false positives. Pure-positive prefixes and the plotting endpoint can stay unchanged.

::: {layout-ncol=2}

VisualizationOut [14]
CollapseExpand
Two ROC curves plotted on top of each other so they coincide, with AUC labels that agree to three decimals.
Validation ROC coordinates under class-constant weights targeting prevalences 0.20 and 0.02. The curves coincide to numerical precision because their within-class score distributions are unchanged.
VisualizationOut [15]
CollapseExpand
Two precision-recall curves, one for prevalence 0.20 with higher average precision and one for prevalence 0.02 with lower average precision, with horizontal reference lines at each prevalence.
Precision-recall coordinates for the same reweighting. Average precision is about 0.702 at prevalence 0.20 and 0.252 at 0.02. Horizontal references mark the two prevalences. Lines connect threshold states; AP is computed by the separate noninterpolated sum.

:::

The two ROC curves lie on top of each other and the two AUC values agree to three decimals, while the average precision values differ substantially. Both plots are honest; they answer different questions. The ROC answer is "how well does this score order the two classes", which does not depend on how the classes are mixed. The precision-recall answer is "how useful would this score be here", which very much does. If we tuned the alerting model on a sample with 20 percent positives and deployed it where positives are 2 percent, the ROC curve would have told us nothing was wrong.

Comments

Add to the conversation

A question, a correction, or an example from your own work.

No comments yet. Be the first to share your thoughts!

Newsletter

Stay up to date

Get new articles, book updates, and notes on AI and technology.

Unsubscribe at any time. Privacy policy