III-14 · Decision Thresholds and Confusion Matrix
Make every label flip accountable
Move one decision threshold over fixed labeled scores, inspect each label flip, and count TP, FP, TN and FN from those exact rows.
Probability and threshold laboratory
One score column → one threshold → four counts
Hand-authored labeled score fixture, shared by the threshold and metric chapters; no seed. These are illustrative scores, not a calibration claim.
- s1
≈ 0.9 → 1 - s2
≈ 0.8 → 1 - s3
≈ 0.8 → 1 - s4
≈ 0.4 → 0 - s5
≈ 0.1 → 0
Positive if score ≥ 0.5. Equality joins the positive class.
- Scores and labelsThe same score array supplies every count, metric and ROC point.
- 5 × 1
- Decision
- p ≥ threshold
- Score provenanceScores are fixed unless explicitly edited; moving a threshold never refits a model.
- Hand-authored
Live state
| ID | Score | Actual | Predicted | Cell |
|---|---|---|---|---|
| s1 | 0.9 | 1 | 1 | TP |
| s2 | 0.8 | 0 | 1 | FP |
| s3 | 0.8 | 1 | 1 | TP |
| s4 | 0.4 | 0 | 0 | TN |
| s5 | 0.1 | 1 | 0 | FN |
Move a policy over fixed scores
Move the threshold slider from to , then just above . Two examples share score and flip together. One is an actual positive and the other an actual negative, so the flip changes both true-positive and false-positive counts. A changed count must be traceable to a changed row.
Use Replay threshold sweep to step through all distinct scores from above every score down to the minimum. The above-all state is visibly , with finite threshold controls hidden. Use finite threshold 0.5 returns to a numeric policy. A score of one remains negative in the above-all state; it becomes positive at finite threshold one. Reset restores all original scores and labels.
A decision rule is an explicit comparison
Let be the saved score for example , and its actual label. This chapter applies
The equality convention matters at tied scores and at zero/one endpoints. Lowering can only add positive predictions when scores stay fixed. It cannot remove them. Changing the threshold does not change the scores, actual labels or any fitting objective.
The illustrative scores were hand-authored; they are not claimed to be calibrated probabilities from a validated model. The exact same score and label columns are used in III-15. They have shape , with sample IDs independent of score values. No random seed or measured dataset is claimed.
Derive four counts from intersections
Partition examples by actual and predicted label:
Every row belongs to exactly one cell. Therefore , is the fixed number of actual positives, and is the fixed number of actual negatives. These invariants are useful checks while the threshold moves.
“Positive” names the selected class, not a value judgment. A false positive means a negative example was declared positive; a false negative means a positive example was missed. Which error matters more depends on the application, prevalence and cost, not on the matrix’s visual layout.
Work the exact five-row table
At , use the hand-authored score fixture:
| ID | Score | Actual | Predicted | Cell |
|---|---|---|---|---|
| s1 | 0.9 | 1 | 1 | TP |
| s2 | 0.8 | 0 | 1 | FP |
| s3 | 0.8 | 1 | 1 | TP |
| s4 | 0.4 | 0 | 0 | TN |
| s5 | 0.1 | 1 | 0 | FN |
Thus . At , equality retains s2 and s3 as positives, so the same counts remain. At , only s1 is positive: . Lowering to adds s4 as a false positive: . At all five are positive: .
A score-1 observation shows why “above all” cannot be represented by numeric threshold one under . The explicit sentinel predicts no positives at any valid score, including one, and is stored as JSON null / Python None. It is a named decision state, not a missing value silently converted to one.
Inspect one example before counting
Select s2 and change its actual label from zero to one. At threshold , its positive prediction is unchanged, but its cell changes from FP to TP. Now select s3 and edit its score below the threshold. Its actual positive label is unchanged, but it moves from TP to FN.
These are separate kinds of intervention: changing a score, changing a truth label, or changing a policy. The data provenance identifies this as an editable toy experiment. A real evaluation should not rewrite actual outcomes to improve a matrix.
Replay, accessibility and limits
Replay contains detached immutable score/label/threshold states. First, Previous and Next select the active snapshot; editing its score, label or threshold begins from that frame and exits replay. Numeric fields and native sliders give keyboard and touch paths. Visible per-row cells, counts and a spoken summary use the same derived result, so color is never the only signal.
Scores must be finite in , actual labels exactly zero or one, and finite thresholds in . The pure model accepts 1–64 examples. An invalid score or label gives a reason and preserves the current valid state. Ties are handled by exact comparisons of stored binary64 scores; a displayed rounded probability is not the comparison input. This fixture displays score inputs without rounding.
Mistakes and connections
Do not swap FP and FN when transposing a matrix layout. Do not multiply scores by a threshold or retrain the model while evaluating a decision policy. Do not infer error costs from counts alone. The experiment reports the counts before deriving ratios, and III-15 makes their denominators explicit.
Canonical Python confusion and the TypeScript kernel classify each row. An independent oracle intersects sets of actual-positive and predicted-positive IDs to count the four cells. You should be able to trace every cell to an example, account for equality/ties, and separate a label flip from a probability change.
Execute the actual Python and NumPy
The imported source below is the runnable file used in canonical comparisons. Read the named mechanism from this chapter; the other functions coordinate the same Part experiment. From the book root:
.venv/bin/python -B code/python/p02/classification_reference.py
"""III-13–16: stable logit losses, exact thresholds/ROC and softmax gradients.
Run: .venv/bin/python -B code/python/p02/classification_reference.py
Samples occupy rows; binary W is d×1, multiclass W is d×k.
"""
import json
import math
import numpy as np
from fit_reference import column, design, finite
SCORE_FIXTURE = {"scores": [.9, .8, .8, .4, .1], "labels": [1, 0, 1, 0, 1]}
def class_labels(y, n, k=2):
column(y, n, "Labels")
if any(int(v) != v or not 0 <= v < k for v in y):
raise ValueError(f"Labels must be class indices 0–{k-1}")
def sigmoid(z):
if isinstance(z, bool) or not isinstance(z, (int, float)) or not math.isfinite(z):
raise ValueError("Logit must be finite")
return 1 / (1 + math.exp(-z)) if z >= 0 else math.exp(z) / (1 + math.exp(z))
def bce(z, y):
sigmoid(z)
if isinstance(y, bool) or y not in (0, 1):
raise ValueError("Binary label must be 0 or 1")
return float(finite(max(z, 0) - z * y + math.log1p(math.exp(-abs(z))), "BCE"))
def learning_rate(rate):
if isinstance(rate, bool) or not isinstance(rate, (int, float)) or not math.isfinite(rate) or not 0 < rate <= 1:
raise ValueError("Learning rate must be in (0,1]")
def logistic(X, y, weights, rate):
A, _ = design(X, y)
W = column(weights, A.shape[1], "Weights")
class_labels(y, len(A))
learning_rate(rate)
logits = finite(A @ W, "Logits")[:, 0].tolist()
scores = [sigmoid(z) for z in logits]
losses = [bce(z, label) for z, label in zip(logits, y)]
loss = float(finite(sum(losses) / len(A), "Mean BCE"))
gradient = finite(A.T @ (np.array(scores) - np.array(y)).reshape(-1, 1) / len(A), "Gradient")
next_weights = finite(W - rate * gradient, "Updated weights")
n, d = A.shape
return {"logits": logits, "scores": scores, "losses": losses, "loss": loss,
"gradient": gradient[:, 0].tolist(), "next": next_weights[:, 0].tolist(),
"shapes": {"X": [n, d], "W": [d, 1], "y": [n, 1], "gradient": [d, 1]}}
def ratio(top, bottom, reason):
return {"value": top / bottom, "reason": None} if bottom else {"value": None, "reason": reason}
def confusion(scores, y, threshold):
column(scores, len(scores), "Scores")
if not 1 <= len(scores) <= 64 or any(not 0 <= p <= 1 for p in scores):
raise ValueError("Scores require 1–64 probabilities in [0,1]")
class_labels(y, len(scores))
if threshold is not None and (isinstance(threshold, bool) or not isinstance(threshold, (int, float)) or not math.isfinite(threshold) or not 0 <= threshold <= 1):
raise ValueError("Threshold must be in [0,1] or the explicit above-all None sentinel")
rows = []
cells = dict(TP=0, FP=0, TN=0, FN=0)
for p, label in zip(scores, y):
prediction = int(threshold is not None and p >= threshold)
cell = ("TP" if label else "FP") if prediction else ("FN" if label else "TN")
cells[cell] += 1
rows.append(dict(score=p, label=label, prediction=prediction, cell=cell))
tp, fp, tn, fn = (cells[k] for k in ("TP", "FP", "TN", "FN"))
return {"rows": rows, "tp": tp, "fp": fp, "tn": tn, "fn": fn,
"precision": ratio(tp, tp + fp, "No predicted positives (TP+FP=0)."),
"recall": ratio(tp, tp + fn, "No actual positives (TP+FN=0)."),
"f1": ratio(2 * tp, 2 * tp + fp + fn, "No actual or predicted positives (2TP+FP+FN=0)."),
"accuracy": (tp + tn) / len(scores)}
def roc(scores, y):
confusion(scores, y, .5)
positives, negatives = y.count(1), y.count(0)
points = []
for threshold in [None] + sorted(set(scores), reverse=True):
c = confusion(scores, y, threshold)
points.append({"threshold": threshold, **{k: c[k] for k in ("tp", "fp", "tn", "fn")},
"tpr": ratio(c["tp"], positives, "ROC TPR needs at least one positive."),
"fpr": ratio(c["fp"], negatives, "ROC FPR needs at least one negative.")})
reason = None if positives and negatives else "ROC/AUC requires both actual classes."
area = None if reason else sum((b["fpr"]["value"] - a["fpr"]["value"]) * (a["tpr"]["value"] + b["tpr"]["value"]) / 2 for a, b in zip(points, points[1:]))
return {"points": points, "auc": {"value": area, "reason": reason}, "positives": positives, "negatives": negatives}
def softmax(logits):
if not isinstance(logits, (list, tuple)) or not 2 <= len(logits) <= 6:
raise ValueError("Softmax requires 2–6 logits")
column(logits, len(logits), "Logits")
maximum = max(logits)
shifted = finite(np.array(logits) - maximum, "Shifted logits")
exp = np.exp(shifted)
total = float(np.sum(exp))
return {"maximum": maximum, "shifted": shifted.tolist(), "probabilities": (exp / total).tolist(), "logSumExpShifted": math.log(total)}
def multiclass(X, y, W, rate, shift=0):
A, _ = design(X, y)
weights, _ = design(W, [0.] * len(W))
n, d = A.shape
k = weights.shape[1]
if weights.shape[0] != d or not 2 <= k <= 6:
raise ValueError("W must be d×k with 2–6 classes")
class_labels(y, n, k)
learning_rate(rate)
if isinstance(shift, bool) or not isinstance(shift, (int, float)) or not math.isfinite(shift):
raise ValueError("Common shift must be finite")
logits = finite(A @ weights + shift, "Logits")
normalized = [softmax(row) for row in logits.tolist()]
P = np.array([r["probabilities"] for r in normalized])
Y = np.eye(k)[np.array(y, dtype=int)]
losses = [r["logSumExpShifted"] - r["shifted"][int(y[i])] for i, r in enumerate(normalized)]
loss = float(finite(sum(losses) / n, "Mean cross entropy"))
gradient = finite(A.T @ (P - Y) / n, "Gradient")
next_weights = finite(weights - rate * gradient, "Updated weights")
return {"logits": logits.tolist(), "normalized": normalized, "probabilities": P.tolist(), "losses": losses, "loss": loss,
"gradient": gradient.tolist(), "next": next_weights.tolist(), "predicted": np.argmax(P, axis=1).tolist(), "oneHot": Y.tolist(),
"shapes": {"X": [n, d], "W": [d, k], "logits": [n, k], "Y": [n, k], "gradient": [d, k], "labels": [n, 1]}}
def demonstration():
X, y, w = [[1, -2], [1, -1], [1, 0], [1, 1], [1, 2]], [0, 0, 1, 0, 1], [0, 1]
trace = []
for i in range(4):
result = logistic(X, y, w, .2)
trace.append({"index": i, "weights": w, "derived": result})
w = result["next"]
return {"logisticTrace": trace, "threshold": confusion(SCORE_FIXTURE["scores"], SCORE_FIXTURE["labels"], .5),
"roc": roc(SCORE_FIXTURE["scores"], SCORE_FIXTURE["labels"]),
"softmax": multiclass([[1, -1], [1, 0], [1, 1]], [2, 1, 0], [[0, 0, 0], [1, 0, -1]], .2),
"extremeBCE": [[z, label, bce(z, label)] for z in (-1000, 1000) for label in (0, 1)]}
if __name__ == "__main__":
print(json.dumps(demonstration(), indent=2, allow_nan=False))