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.
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.
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
| Role | IDs |
|---|---|
| Development | a1, a2, b1, b2, c1, c2, d1, d2, e1, e2 |
| Untouched test | f1, f2 |
| Fold 1 | Train: b1, b2, c1, c2, d1, d2 Validation: a1, a2, e1, e2 |
| Fold 2 | Train: a1, a2, c1, c2, e1, e2 Validation: b1, b2, d1, d2 |
| Fold 3 | Train: a1, a2, b1, b2, d1, d2, e1, e2 Validation: c1, c2 |
- 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
| ID | y | p | p ≥ threshold | (p−y)² |
|---|---|---|---|---|
| m1 | 0 | 0.1 | negative | 0.01 |
| m2 | 0 | 0.2 | negative | 0.04 |
| m3 | 0 | 0.3 | negative | 0.09 |
| m4 | 0 | 0.4 | negative | 0.16 |
| m5 | 0 | 0.6 | positive | 0.36 |
| m6 | 0 | 0.8 | positive | 0.64 |
| m7 | 1 | 0.5 | positive | 0.25 |
| m8 | 1 | 0.9 | positive | 0.01 |
| Threshold | TP / FP / TN / FN | Precision | Recall |
|---|---|---|---|
| 0 | 2 / 6 / 0 / 0 | 0.25 | 1 |
| 0.1 | 2 / 6 / 0 / 0 | 0.25 | 1 |
| 0.2 | 2 / 5 / 1 / 0 | 0.285714 | 1 |
| 0.3 | 2 / 4 / 2 / 0 | 0.333333 | 1 |
| 0.4 | 2 / 3 / 3 / 0 | 0.4 | 1 |
| 0.5 | 2 / 2 / 4 / 0 | 0.5 | 1 |
| 0.6 | 1 / 2 / 4 / 1 | 0.333333 | 0.5 |
| 0.8 | 1 / 1 / 5 / 1 | 0.5 | 0.5 |
| 0.9 | 1 / 0 / 6 / 1 | 1 | 0.5 |
| 1 | 0 / 0 / 6 / 2 | Undefined: zero denominator / empty bin | 0 |
| Bin | IDs / count | Mean p | Positive fraction |
|---|---|---|---|
| [0,0.2) | m1 / 1 | 0.1 | 0 |
| [0.2,0.4) | m2, m3 / 2 | 0.25 | 0 |
| [0.4,0.6) | m4, m7 / 2 | 0.45 | 0.5 |
| [0.6,0.8) | m5 / 1 | 0.6 | 0 |
| [0.8,1] | m6, m8 / 2 | 0.85 | 0.5 |
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.
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:
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
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.
"""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
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.