Most classifier reviews we do end the same way. The model is fine. The training pipeline is fine. What is broken is the last two lines of the serving code: p = model.predict(x) followed by if p > 0.5. Those two lines quietly decide what the product does, and almost nobody measures them.
Two separate problems hide there. Calibration asks whether the number the model emits means what it claims — of everything scored 0.80, do roughly 80% turn out positive? Thresholding asks where to cut that number to make a decision, given that a false positive and a false negative usually do not cost the same thing. AUC tells you about neither. A model can rank perfectly, score AUC 0.97, and still be badly calibrated and cut in the wrong place.
This post is the evaluation step we run between "training finished" and "ship it."
Why a sigmoid output is not a probability
A sigmoid or softmax squeezes a logit into [0, 1]. Nothing in binary_crossentropy guarantees the result is a frequency. In practice modern networks are overconfident: they pile predictions near 0 and 1, because a large model trained to low loss on training data keeps pushing logits outward long after ranking stops improving.
The usual amplifiers:
- Class imbalance plus resampling or class weights. If you rebalanced 2% positives to 50% during training, every score the model emits is calibrated to a world that does not exist at serving time.
- Label smoothing, mixup, heavy augmentation. These help generalization and shift the score distribution.
- Long training past the point where val loss bottoms out. Accuracy plateaus; confidence keeps climbing.
None of that hurts if you only ever rank. It hurts the moment a downstream system multiplies your score by a dollar amount, feeds it into an expected-value rule, or shows it to a human as "87% confident."
Measure it before you fix it
Use a held-out calibration split — not the training set, and ideally not the same test set you will report final numbers on. Carve three ways: train / calibration / test.
Two numbers and one picture:
Reliability diagram. Bin predictions by score, and for each bin plot mean predicted score against observed positive rate. A calibrated model sits on the diagonal. Overconfident models sag below it at the high end.
Expected calibration error (ECE). The weighted average gap between those two quantities across bins. One number to track across runs.
import numpy as np
def reliability(y_true, p, n_bins=15):
edges = np.linspace(0.0, 1.0, n_bins + 1)
idx = np.digitize(p, edges[1:-1])
rows, ece = [], 0.0
for b in range(n_bins):
m = idx == b
if not m.any():
continue
conf, acc, w = p[m].mean(), y_true[m].mean(), m.mean()
rows.append((b, int(m.sum()), float(conf), float(acc)))
ece += w * abs(acc - conf)
return rows, float(ece)
Brier score (mean squared error against the 0/1 label) is worth logging too: unlike ECE it is a proper scoring rule, so it penalizes a model that is calibrated on average but useless in the aggregate. Report both. ECE alone can be gamed by a model that predicts the base rate for everything.
Bin count matters more than people expect — 10 to 20 equal-width bins is conventional, but equal-mass bins give a steadier estimate when scores clump near the ends. Pick one, write it down, keep it fixed across runs, and report the sample count per bin so nobody reads a 4-sample bin as signal.
Fixing calibration: two methods, in order
Calibration is a post-processing step fit on the calibration split. You do not retrain the network.
Temperature scaling is the first thing to try. Divide the logits by a single learned scalar T before the sigmoid or softmax, fit T by minimizing NLL on the calibration split. One parameter, cannot change the ranking, so AUC is untouched by construction. It typically removes most of the overconfidence in a neural classifier.
import keras
from keras import ops
logits = logit_model.predict(cal_ds) # model WITHOUT the final activation
T = keras.Variable(1.0, trainable=True, dtype="float32")
opt = keras.optimizers.Adam(0.01)
for _ in range(300):
with tf.GradientTape() as tape:
loss = keras.losses.binary_crossentropy(
y_cal, ops.sigmoid(logits / T), from_logits=False
)
loss = ops.mean(loss)
opt.apply_gradients(zip(tape.gradient(loss, [T]), [T]))
Keep a logits-output version of the model around for this — build the network to emit logits and apply the activation outside, which also makes from_logits=True losses numerically safer during training. If you only have probabilities, recover logits with log(p / (1 - p)) and clip first.
Isotonic regression is the fallback when the miscalibration is not a simple confidence stretch — S-shaped or non-monotonic reliability curves. It fits an arbitrary monotonic map from score to probability, so it is far more flexible and far more willing to overfit. Below a few thousand calibration examples, prefer temperature scaling. Platt scaling (a logistic fit on the score) sits between the two and is a reasonable middle rung.
Whichever you pick, the calibrator is part of the artifact. Version it with the checkpoint, apply it inside the serving path or the export graph, and parity-test the composed thing rather than the bare network. A calibrator that lives in a notebook is a calibrator that will be missing in production.
Choosing the threshold from costs, not from 0.5
Once scores mean something, the threshold is an economics question, and it has an answer.
Write down the four cell costs — true positive, false positive, true negative, false negative — in whatever unit your business actually uses: dollars, review-minutes, missed defects. Then sweep the threshold on the calibration split and pick the point that minimizes expected cost:
thresholds = np.linspace(0.01, 0.99, 99)
costs = []
for t in thresholds:
yhat = p_cal >= t
fp = np.sum(yhat & (y_cal == 0))
fn = np.sum(~yhat & (y_cal == 1))
costs.append(C_FP * fp + C_FN * fn)
t_star = thresholds[int(np.argmin(costs))]
If nobody will give you costs, get a constraint instead: "reviewers can handle 200 alerts a day" fixes the threshold by volume; "recall must be at least 0.90" fixes it by requirement and you report the precision that follows. Any of these beats 0.5, which is only optimal when errors cost the same and the classes are balanced — a situation we have encountered approximately never on client work.
Three things to get right:
- Pick the threshold on the calibration split, report metrics on test. Tuning the cut on your test set and reporting the result is the same leakage we warn about with backtesting time-series models, in a smaller costume.
- Check the sensitivity, not just the optimum. Plot cost against threshold. If the curve is flat between 0.31 and 0.52, you have slack and should not be precious about the third decimal. If it is a sharp V, small drift will hurt, and you need a plan to re-fit it.
- Consider abstaining. Two thresholds instead of one — auto-accept above, auto-reject below, route the middle to a human — is often worth more than any modeling change, and calibrated scores are what make the band meaningful.
Multi-class, and the imbalance case
For multi-class, temperature scaling generalizes directly: one T over the softmax logits. Per-class thresholds are a different matter — fitting a separate cut per class on a few hundred examples per class overfits quickly. Start with one temperature and argmax, and only add per-class thresholds where a specific class carries a distinctly different error cost.
On heavily imbalanced problems, remember that any resampling or class weighting you used during training has shifted the score distribution away from the prior. Calibrate on a split with the true production base rate, not the rebalanced one. This is the single most common cause of a model that scores well offline and floods a queue on day one.
After deploy
Calibration decays. The score distribution drifts before labels arrive, and the relationship between score and outcome moves with the population. Add three things to the monitoring you already have for drift and retraining:
- Predicted positive rate per day against the threshold — fast, needs no labels, and catches most input drift.
- ECE and Brier on delayed labels as they land, tracked on the same bins and windows every time.
- Realized cost using the same four numbers that set the threshold. If it rises while the model metrics hold, the population changed, not the model — refit the calibrator before you retrain the network. It is hours instead of days.
Re-fitting a calibrator is cheap. Make it a scheduled job with its own held-out data, its own version number, and a record of which checkpoint it was fitted against.
The short version
Split three ways. Plot a reliability diagram and log ECE and Brier next to AUC. Fit temperature scaling; reach for isotonic only with enough data and a curve that needs it. Set the threshold from costs or an operating constraint on the calibration split, and report on test. Ship the calibrator and threshold as part of the versioned artifact, and monitor the predicted positive rate daily.
None of this is research. It is an afternoon of work that decides what your model does in production, and it is the part of the stack we most often find unattended when we are asked why a good model is producing bad decisions.