III-37 · Class Imbalance, Calibration and Metric Choice

A correct label is not a calibrated probability

Move a decision threshold, count its mistakes and compare forecast probabilities with observed frequencies in fixed bins.

Chapters in Part VIII
  1. III-35 · Cross-Validation and Hyperparameter Tuning
  2. III-36 · Learning Curves and Model Selection
  3. III-37 · Class Imbalance, Calibration and Metric Choice
  4. III-38 · Preprocessing, Feature Engineering and Pipelines
  5. III-39 · Reproducibility and Numerical Reliability

Part VIII · experimental workflow sheet

Count decisions; examine probability errors

Hand-authored synthetic rows; seed controls group assignment only, never data generation. Data version p08-hand-v1. Edits are recorded in the displayed state. Probability rows are a separate hand-authored forecast example.

Split and workflow
Forecast and decision editor

Workflow phase 1 / 5. Replay preserves immutable states; edits fork the displayed frame. Revealing the test is the final step: do not tune after seeing it.

Raw X / engineered X / yObservations are mathematical columns; batch stores their transpose in rows. Missing raw values use null.
12 × 1 / n × 1 / n × 1
ParametersSelected on equal-weight CV MSE; intercept unpenalized; exact ties use first grid candidate.
power=2, λ=0
Test groupGroup allocation uses only IDs/groups and seed. Test values never fit preprocessing, select a model or enter a curve.
f
Data and RNGHand-authored rows. Seed controls only group shuffle. Full displayed JSON records edited values.
p08-hand-v1
LCG draws (u32)1664525·state + 1013904223 mod 2³²; Fisher–Yates draws descending.
1025555898, 3923423697, 2630631676, 3981355051, 211918734
Numerical limitsOverflow and underflow to zero reject the edit. Constant transformed features use scale 1 and zero slope when λ=0.
binary64; finite arithmetic required
Experimental workflow, active phase 1 of five12345
1 Split → 2 Fold fits → 3 Select → 4 Development refit → 5 Final test. Active phase 1; replay exposes each step.
Locked group split; no stratification is claimed
RoleIDs
Developmenta1, a2, b1, b2, c1, c2, d1, d2, e1, e2
Untouched testf1, f2
Fold 1Train: b1, b2, c1, c2, d1, d2
Validation: a1, a2, e1, e2
Fold 2Train: a1, a2, c1, c2, e1, e2
Validation: b1, b2, d1, d2
Fold 3Train: a1, a2, b1, b2, d1, d2, e1, e2
Validation: c1, c2
y^i=1[pi≥τ],Brier=1n∑i(pi−yi)2\hat y_i=\mathbf1[p_i\ge\tau],\quad \mathrm{Brier}=\frac1n\sum_i(p_i-y_i)^2
TP
2
FP
2
TN
4
FN
0
Accuracy
0.75
Balanced accuracy
0.833333
Precision
0.5
Recall
1
F1
0.666667
Brier probability MSE
0.195
Mean natural-log loss
0.56503
Hand-authored forecasts; equality is positive
IDypp ≥ threshold(p−y)²
m100.1negative0.01
m200.2negative0.04
m300.3negative0.09
m400.4negative0.16
m500.6positive0.36
m600.8positive0.64
m710.5positive0.25
m810.9positive0.01
Every observed probability cutoff, plus 0 and 1. Undefined precision is not plotted as zero.
ThresholdTP / FP / TN / FNPrecisionRecall
02 / 6 / 0 / 00.251
0.12 / 6 / 0 / 00.251
0.22 / 5 / 1 / 00.2857141
0.32 / 4 / 2 / 00.3333331
0.42 / 3 / 3 / 00.41
0.52 / 2 / 4 / 00.51
0.61 / 2 / 4 / 10.3333330.5
0.81 / 1 / 5 / 10.50.5
0.91 / 0 / 6 / 110.5
10 / 0 / 6 / 2Undefined: zero denominator / empty bin0
Reliability bins: mean forecast versus observed positive fraction01
Horizontal: mean probability; vertical: positive fraction. The dashed diagonal indicates empirical agreement, with little evidence in tiny bins.
Five fixed bins [0,.2), [.2,.4), [.4,.6), [.6,.8), [.8,1]. Empty bins undefined.
BinIDs / countMean pPositive fraction
[0,0.2)m1 / 10.10
[0.2,0.4)m2, m3 / 20.250
[0.4,0.6)m4, m7 / 20.450.5
[0.6,0.8)m5 / 10.60
[0.8,1]m6, m8 / 20.850.5

Equality is positive: p ≥ threshold. Counts, PR cutoffs, Brier error and reliability bins derive from the displayed hand-authored forecasts.

Change a decision without changing a probability

Raise the threshold from 0.5 to 1. No displayed probability reaches 1, so the model predicts no positives. Precision becomes undefined because its denominator is zero; recall becomes zero because there are two actual positives. Brier score stays fixed: changing the decision rule did not change a forecast.

Reset. Select row m7 and reduce its probability. Now you changed a forecast itself, so both the threshold decision and probability error can change. Try Use all-negative data. Recall and balanced accuracy become undefined because there are no positive examples; the interface reports the missing denominator rather than treating an absent class as perfectly recalled. These eight rows are a hand-authored metric counterexample, separate from the regression workflow dataset.

Imbalance makes the question matter

With 99 negatives and one positive, predicting every row negative gives 99% accuracy and zero positive recall. That can be unusable when missing the positive is costly. Accuracy asks the fraction of all decisions that are correct; it does not express the cost of each error or ensure that both classes are served. Class prevalence changes its meaning.

Define positive by p≥τ. True positives TP have y=1 and a positive decision; false positives FP have y=0 and a positive decision; true negatives TN have y=0 and a negative decision; false negatives FN have y=1 and a negative decision. These are counts of observations, not probabilities.

accuracy=TP+TNn,precision=TPTP+FP,recall=TPTP+FN,\mathrm{accuracy}=\frac{TP+TN}{n},\quad \mathrm{precision}=\frac{TP}{TP+FP},\quad \mathrm{recall}=\frac{TP}{TP+FN}, balanced accuracy=12(TPTP+FN+TNTN+FP),F1=2TP2TP+FP+FN.\mathrm{balanced\ accuracy}=\frac12\left(\frac{TP}{TP+FN}+\frac{TN}{TN+FP}\right),\quad F_1=\frac{2TP}{2TP+FP+FN}.

A zero denominator means undefined. F1’s count formula remains defined as zero when there are actual positives but no predicted positives. If TP=FP=FN=0, its denominator is zero and F1 is undefined. Balanced accuracy needs both classes present. Declaring these conventions prevents silent disagreements between libraries with different zero-division options.

Count the original forecasts by hand

The six negative forecasts are 0.1,0.2,0.3,0.4,0.6,0.8; the two positive forecasts are 0.5,0.9. At τ=0.5, TP=2, FP=2, TN=4, FN=0. Accuracy is 6/8=3/4, precision is 2/4=1/2, recall is 1 and F1 is 4/6=2/3. Negative recall is 4/6=2/3, so balanced accuracy is (1+2/3)/2=5/6.

At τ=0.8, equality keeps the negative forecast 0.8 positive. TP=1, FP=1, TN=5, FN=1; precision and recall are both 1/2. The board lists all distinct observed cutoffs plus 0 and 1. Between neighboring cutoffs, the confusion counts stay fixed. Increasing a threshold removes predicted positives and cannot increase recall on this fixed dataset, though precision need not change monotonically.

A PR tradeoff is relevant when positives are scarce: it explicitly compares positive recovery with the fraction of positive alerts that are correct. Choose a threshold using development data and operational costs, then lock it before the final test. The metric board is descriptive; it does not silently tune thresholds on the regression test group.

Calibration asks a probability question

For a calibrated forecaster, among observations assigned probability near q, the positive fraction should be near q. Good ranking and threshold accuracy do not establish that property. A forecast of 0.99 and a forecast of 0.51 produce the same positive decision at τ=0.5, yet claim very different confidence.

The Brier score measures probability squared error:

B=1n∑i(pi−yi)2.B=\frac1n\sum_i(p_i-y_i)^2.

For the original rows, the squared errors are .01,.04,.09,.16,.36,.64,.25,.01. Their sum is 1.56=39/25 and B=39/200=0.195. It is a probability error score, influenced by more than calibration alone, and it does not depend on τ. A lower Brier score can reflect improved discrimination as well as calibration; the reliability diagram provides a different descriptive view.

Natural-log loss is

L=−1n∑i[yilog⁡pi+(1−yi)log⁡(1−pi)].L=-\frac1n\sum_i[y_i\log p_i+(1-y_i)\log(1-p_i)].

The code evaluates only the probability of the true class, avoiding 0·log 0. A true-class probability of zero gives unbounded log loss. It is represented as unavailable with an explicit reason, never clipped to a convenient finite number.

Fixed bins, explicit membership

The five reliability bins are [0,.2), [.2,.4), [.4,.6), [.6,.8), [.8,1]. Boundary values enter the bin on their right; 1 enters the last bin. For each nonempty bin, plot its mean forecast against its empirical positive fraction. Original counts are 1,2,2,1,2. The [.4,.6) bin contains .4 with label 0 and .5 with label 1: mean forecast .45, frequency .5. The final bin contains .8 with label 0 and .9 with label 1: mean .85, frequency .5.

Tiny bins are noisy. Empty bins have no defined mean or frequency and are omitted from the plot. Fixed bins keep comparisons interpretable; changing bins after inspecting results can hide discrepancies. These eight artificial forecasts cannot certify calibration on a deployment population.

Actual canonical code and explorer

The Python reference validates binary labels, real probabilities in [0,1], unique IDs and the threshold. Confusion counts use integers; independent oracle expectations use rational arithmetic. In the browser, the count table, scalar metrics and plot project one derived result. No seed is claimed for these literal forecasts.

code/python/p08/evaluation.py
"""Canonical bounded evaluation lab. X: n×1 rows; y: n×1; one slope + intercept.

Hand data, LCG-seeded group allocation. No claim of stratification. Owner runs tests.
"""
import json
import math
import numbers
import sys
from pathlib import Path
import numpy as np

ROOT = Path(__file__).resolve().parents[3]
DATA = json.loads((ROOT / "data/p08/experiments.json").read_text())
DEFAULT = dict(rows=DATA["rows"], probabilities=DATA["probabilities"], seed=7,
               threshold=.5, folds=3, phase=0, t=1e8, delta=1e-8)
GRID = [(power, penalty) for power in (1, 2) for penalty in (0, 1, 4)]


def number(v, name="number"):
    if isinstance(v, (bool, np.bool_)) or not isinstance(v, numbers.Real):
        raise ValueError(name + " must be a real number, not Boolean")
    try:
        v = float(v)
    except (OverflowError, ValueError):
        raise ValueError(name + " is not binary64 representable") from None
    if not math.isfinite(v):
        raise ValueError(name + " must be finite")
    return v


def finite(v):
    if not math.isfinite(v):
        raise ValueError("Numerical result unavailable: binary64 overflow")
    return v


def mul(a, b):
    v = finite(a * b)
    if a != 0 and b != 0 and v == 0:
        raise ValueError("Numerical result unavailable: product underflow")
    return v


def div(a, b):
    if b == 0:
        raise ValueError("Numerical result unavailable: zero divisor")
    v = finite(a / b)
    if a != 0 and v == 0:
        raise ValueError("Numerical result unavailable: quotient underflow")
    return v


def mean(values):
    if not values:
        raise ValueError("Mean needs observations")
    if all(v == values[0] for v in values):
        return values[0]
    total = 0.
    for v in values:
        total = finite(total + div(v, len(values)))
    return total


def integer(v, low, high, name):
    v = number(v, name)
    if not v.is_integer() or not low <= v <= high:
        raise ValueError(name + " must be an integer in bounds")
    return int(v)


def identifiers(rows):
    ids = set()
    for r in rows:
        if not isinstance(r, dict) or not isinstance(r.get("id"), str) or not r["id"] or r["id"] in ids:
            raise ValueError("Unique nonempty row IDs required")
        ids.add(r["id"])


def validate(s):
    if not isinstance(s, dict):
        raise ValueError("Experiment must be an object")
    rows, probs = s.get("rows"), s.get("probabilities")
    if not isinstance(rows, list) or not 8 <= len(rows) <= 64:
        raise ValueError("8–64 dense regression rows required")
    if not isinstance(probs, list) or not 1 <= len(probs) <= 64:
        raise ValueError("1–64 dense probability rows required")
    identifiers(rows)
    identifiers(probs)
    for r in rows:
        if not isinstance(r.get("group"), str) or not r["group"]:
            raise ValueError("Nonempty group required")
        if not isinstance(r.get("x"), list) or len(r["x"]) != 1:
            raise ValueError("X must contain dense one-feature rows")
        if r["x"][0] is not None:
            number(r["x"][0], "feature")
        number(r.get("y"), "target")
    for r in probs:
        if number(r.get("y"), "binary label") not in (0, 1) or not 0 <= number(r.get("p"), "probability") <= 1:
            raise ValueError("Binary label and probability in [0,1] required")
    integer(s.get("seed"), 0, 2**32-1, "seed")
    integer(s.get("folds"), 2, 4, "folds")
    integer(s.get("phase"), 0, 4, "phase")
    if not 0 <= number(s.get("threshold"), "threshold") <= 1:
        raise ValueError("Threshold in [0,1] required")
    if not 1 <= number(s.get("t"), "t") <= 1e12 or not 1e-12 <= number(s.get("delta"), "delta") <= .1:
        raise ValueError("Numerical counterexample parameters out of bounds")


def split(rows, seed, folds):
    if not isinstance(rows, list) or not rows:
        raise ValueError("Dense regression rows required")
    identifiers(rows)
    seed = integer(seed, 0, 2**32-1, "seed")
    folds = integer(folds, 2, 4, "folds")
    for r in rows:
        if not isinstance(r.get("group"), str) or not r["group"]:
            raise ValueError("Nonempty group required")
    groups = sorted(set(r["group"] for r in rows), key=lambda g: g.encode("utf-16-be", "surrogatepass"))
    if len(groups) < folds + 2:
        raise ValueError("Need at least folds+2 groups")
    state = int(seed)
    draws = []
    for i in range(len(groups)-1, 0, -1):
        state = (1664525 * state + 1013904223) % 2**32
        draws.append(state)
        j = state * (i+1) // 2**32
        groups[i], groups[j] = groups[j], groups[i]
    test_group, dev_groups = groups[0], groups[1:]
    test = [r["id"] for r in rows if r["group"] == test_group]
    dev = [r["id"] for r in rows if r["group"] != test_group]
    boards = []
    for k in range(folds):
        held = dev_groups[k::folds]
        boards.append(dict(train=[r["id"] for r in rows if r["group"] in dev_groups and r["group"] not in held],
                           validation=[r["id"] for r in rows if r["group"] in held]))
    return dict(groups=groups, draws=draws, development=dev, test=test, folds=boards)


def take(rows, ids):
    if not isinstance(rows, list) or not 1 <= len(rows) <= 64 or not isinstance(ids, list):
        raise ValueError("Dense rows and membership arrays required")
    identifiers(rows)
    lookup = {r["id"]: r for r in rows}
    if not ids or len(set(ids)) != len(ids) or any(i not in lookup for i in ids):
        raise ValueError("Membership requires unique known IDs")
    return [lookup[i] for i in ids]


def transform_fit(rows, ids):
    selected = take(rows, ids)
    for r in selected:
        if not isinstance(r.get("x"), list) or len(r["x"]) != 1:
            raise ValueError("Dense one-feature row required")
    observed = [number(r["x"][0]) for r in selected if r["x"][0] is not None]
    impute = mean(observed)
    filled = [impute if r["x"][0] is None else number(r["x"][0]) for r in selected]
    center = mean(filled)
    variance = mean([mul(finite(v-center), finite(v-center)) for v in filled])
    scale = math.sqrt(variance) if variance > 0 else 1.
    return dict(fitIds=list(ids), impute=impute, center=center, scale=scale, constant=variance == 0)


def transform(row, state, power):
    if not isinstance(row, dict) or not isinstance(row.get("x"), list) or len(row["x"]) != 1:
        raise ValueError("Dense one-feature row required")
    integer(power, 1, 2, "power")
    number(state["impute"]); number(state["center"])
    if number(state["scale"]) <= 0:
        raise ValueError("Positive fitted scale required")
    filled = state["impute"] if row["x"][0] is None else number(row["x"][0])
    z = div(finite(filled-state["center"]), state["scale"])
    feature = z if power == 1 else mul(z, z)
    return dict(id=row["id"], filled=filled, z=z, feature=feature)


def fit(rows, train_ids, power, penalty, fit_ids=None):
    power = integer(power, 1, 2, "power")
    penalty = number(penalty, "penalty")
    if penalty < 0:
        raise ValueError("Nonnegative ridge penalty required")
    pre = transform_fit(rows, train_ids if fit_ids is None else fit_ids)
    selected = take(rows, train_ids)
    features = [transform(r, pre, power)["feature"] for r in selected]
    targets = [number(r["y"]) for r in selected]
    # NumPy holds the declared row batches; arithmetic order matches the browser contract.
    X, y = np.asarray(features, dtype=np.float64).reshape(-1, 1), np.asarray(targets, dtype=np.float64).reshape(-1, 1)
    fm, ym = mean(X[:, 0].tolist()), mean(y[:, 0].tolist())
    dx, dy = [finite(v-fm) for v in features], [finite(v-ym) for v in targets]
    variance = mean([mul(v, v) for v in dx])
    covariance = mean([mul(a, b) for a, b in zip(dx, dy)])
    denominator = finite(variance + penalty)
    slope = div(covariance, denominator) if denominator else 0.
    intercept = finite(ym - mul(slope, fm))
    model = dict(trainIds=list(train_ids), preprocessing=pre, power=power, penalty=penalty,
                 featureMean=fm, targetMean=ym, slope=slope, intercept=intercept,
                 convention="unpenalized intercept; mean squared error + lambda*slope^2; zero slope if unidentifiable")
    training = evaluate(rows, train_ids, model)
    model.update(trainingMse=training["mse"], objective=finite(training["mse"] + mul(penalty, mul(slope, slope))))
    return model


def evaluate(rows, ids, model):
    evaluated = []
    for r in take(rows, ids):
        transformed = transform(r, model["preprocessing"], model["power"])
        prediction = finite(model["intercept"] + mul(model["slope"], transformed["feature"]))
        residual = finite(prediction-number(r["y"]))
        evaluated.append(dict(transformed, target=number(r["y"]), prediction=prediction,
                              residual=residual, squaredError=mul(residual, residual)))
    return dict(ids=list(ids), rows=evaluated, mse=mean([r["squaredError"] for r in evaluated]))


def metrics(rows, threshold):
    if not isinstance(rows, list) or not 1 <= len(rows) <= 64:
        raise ValueError("1–64 dense probability rows required")
    identifiers(rows)
    threshold = number(threshold)
    if not rows or not 0 <= threshold <= 1:
        raise ValueError("Nonempty probability rows and threshold in [0,1] required")
    tp = fp = tn = fn = 0
    errors, logs = [], []
    decisions = []
    bins = [dict(low=i/5, high=(i+1)/5, ids=[], count=0, meanProbability=None, frequency=None) for i in range(5)]
    for r in rows:
        y, p = number(r.get("y")), number(r.get("p"))
        if y not in (0, 1) or not 0 <= p <= 1:
            raise ValueError("Binary label and probability required")
        positive = p >= threshold
        tp += int(positive and y == 1); fp += int(positive and y == 0)
        tn += int(not positive and y == 0); fn += int(not positive and y == 1)
        errors.append(mul(p-y, p-y))
        decisions.append(dict(id=r["id"], y=y, p=p, positive=positive, squaredError=errors[-1]))
        q = p if y else 1-p
        if q == 0:
            logs.append(None)
        else:
            logs.append(finite(-math.log(q)))
        b = next(b for b in bins if p >= b["low"] and (p < b["high"] or b["high"] == 1))
        b["ids"].append(r["id"])
    for b in bins:
        selected = [r for r in rows if r["id"] in b["ids"]]
        b["count"] = len(selected)
        if selected:
            b["meanProbability"] = mean([number(r["p"]) for r in selected])
            b["frequency"] = mean([number(r["y"]) for r in selected])
    ratio = lambda a, b: a/b if b else None
    recall, specificity = ratio(tp, tp+fn), ratio(tn, tn+fp)
    return dict(rows=decisions, tp=tp, fp=fp, tn=tn, fn=fn, accuracy=(tp+tn)/len(rows),
                balancedAccuracy=(recall+specificity)/2 if recall is not None and specificity is not None else None,
                precision=ratio(tp, tp+fp), recall=recall, f1=ratio(2*tp, 2*tp+fp+fn),
                brier=mean(errors), logLoss=mean(logs) if None not in logs else None,
                logReason="Unbounded log loss: a true class has probability zero" if None in logs else None, bins=bins)


def numerics(t, delta):
    t, delta = number(t), number(delta)
    if not 1 <= t <= 1e12 or not 1e-12 <= delta <= .1:
        raise ValueError("Counterexample parameters out of bounds")
    root = math.sqrt(finite(mul(t, t)+1))
    rounded = finite(1 + delta)
    return dict(left=(1e16 + -1e16) + 1, right=1e16 + (-1e16 + 1),
                fragile=finite(root-t), stable=div(1, finite(root+t)),
                difference=finite(rounded-1), reciprocal=div(1, finite(rounded-1)),
                nominalReciprocal=div(1, delta), condition=div(abs(rounded)+1, abs(rounded-1)))


def experiment(s):
    validate(s)
    rows = s["rows"]
    board = split(rows, s["seed"], s["folds"])
    candidates = []
    for power, penalty in GRID:
        scores = []
        for fold in board["folds"]:
            model = fit(rows, fold["train"], power, penalty)
            validation = evaluate(rows, fold["validation"], model)
            scores.append(dict(model=model, validation=validation))
        candidates.append(dict(power=power, penalty=penalty, folds=scores,
                               meanValidation=mean([f["validation"]["mse"] for f in scores])))
    # Strict less-than: an exact tie retains the first (power ascending, penalty ascending).
    chosen = candidates[0]
    for c in candidates[1:]:
        if c["meanValidation"] < chosen["meanValidation"]:
            chosen = c
    final = fit(rows, board["development"], chosen["power"], chosen["penalty"])
    test = evaluate(rows, board["test"], final)
    fold = board["folds"][0]
    train_groups = [g for g in board["groups"][1:] if any(r["group"] == g and r["id"] in fold["train"] for r in rows)]
    curves = []
    for k in range(1, len(train_groups)+1):
        ids = [r["id"] for r in rows if r["group"] in train_groups[:k]]
        model = fit(rows, ids, chosen["power"], chosen["penalty"])
        curves.append(dict(n=len(ids), ids=ids, model=model, validation=evaluate(rows, fold["validation"], model)))
    proper = fit(rows, fold["train"], 1, 1)
    leaked = fit(rows, fold["train"], 1, 1, fold["train"]+fold["validation"])
    proper_score, leaked_score = evaluate(rows, fold["validation"], proper), evaluate(rows, fold["validation"], leaked)
    m = metrics(s["probabilities"], s["threshold"])
    tradeoff = [dict(threshold=t, **{k: v for k, v in metrics(s["probabilities"], t).items() if k in ("precision", "recall", "tp", "fp", "tn", "fn")})
                for t in sorted(set([0., 1.] + [number(r["p"]) for r in s["probabilities"]]))]
    return dict(board=board, candidates=candidates, selected=dict(power=chosen["power"], penalty=chosen["penalty"]),
                final=final, test=test, curves=curves, leakage=dict(proper=proper, leaked=leaked,
                properScore=proper_score, leakedScore=leaked_score, difference=finite(leaked_score["mse"]-proper_score["mse"])),
                metrics=m, tradeoff=tradeoff, numerics=numerics(s["t"], s["delta"]),
                provenance=dict(dataVersion=DATA["version"], data=DATA["provenance"], seed=s["seed"], folds=s["folds"],
                threshold=s["threshold"], model="scalar engineered-feature ridge with intercept", grid=[list(g) for g in GRID],
                split="UTF-16 sorted groups; Fisher–Yates descending; u32 LCG; first group test; remaining round-robin CV",
                selection="equal-weight mean fold MSE; first grid candidate on exact ties", arithmetic="constant-preserving binary64 sequential mean of divided terms"))


if __name__ == "__main__":
    try:
        request = json.load(sys.stdin) if not sys.stdin.isatty() else DEFAULT
        print(json.dumps(experiment(request), allow_nan=False))
    except (ValueError, TypeError, KeyError) as error:
        print(str(error), file=sys.stderr)
        sys.exit(1)

Try m7 at p=0 and y=1 to expose unbounded log loss. Set τ=0 to predict all positives, then τ=1 to inspect undefined precision. Move .6 to .59 to inspect the documented bin boundary independently of the threshold. Numeric inputs reject malformed or out-of-range values and preserve the previous state; reset restores the original forecasts.

Connections and limits

III-14 introduced the decision boundary; III-15 explored ranking and threshold tradeoffs. This chapter adds calibration and class-aware reporting. Calibration methods themselves are fitted procedures and need development-only fitting. The bounded lab implements no resampling trainer, learned calibrator or production risk guarantee.

Five graded decisions

At p=0.5 and threshold 0.5, what is the decision?
No positives are predicted, but actual positives exist. What is precision?
What is the original Brier score?
Where does a probability of 0.6 enter the fixed bins?
Does raising a threshold without changing forecasts improve Brier score?

What you can now explain

You can compute class-aware counts, identify undefined denominators, inspect PR cutoffs and distinguish decisions from probabilities. You can calculate Brier exactly and interpret reliability bins cautiously. Next, III-38 audits what fitted preprocessing is allowed to know.