Skip to content

Calibration: online Platt scaling via sequential Newton optimization

A model can be great at ranking outcomes but still report the wrong confidence numbers—stating it is 99% sure when it is only right 80% of the time. This tool corrects those probabilities in real time, sample by sample, so that a robot's control policy can rely on probabilities that match how often the model is right.

A classifier can rank its predictions well and still attach the wrong numbers to them. A decision tree that has 80 % accuracy on some region will happily output 0.99 on every sample of that region — the ranking is fine, the numbers are lies. On a robot, the control policy reads those numbers: a 0.99 that means "80 %" makes the policy act far too confidently.

Calibration fixes the numbers without touching the ranking. OnlinePlattScaling wraps any classifier from an existing online-learning library and corrects its probabilities one sample at a time, using the map sigmoid(a · logit(p) + b) learned by Online Newton Step.

1. The idea, in one line

The base classifier's probability p is transformed into σ(a · logit(p) + b), with a and b learned online. If the base model is over-confident (probabilities pushed toward 0 and 1), a < 1 shrinks them toward 0.5. If it is under-confident, a > 1 stretches them out. If it is systematically biased toward one side, b ≠ 0 shifts them. Both numbers adapt as new samples arrive.

2. The formula, symbol by symbol

Let p be the base model's probability for a sample, and y ∈ {0, 1} its true label.

  • logit(p) = ln(p / (1 − p)) — the inverse of the sigmoid, from (0, 1) to ℝ.
  • σ(z) = 1 / (1 + e^{−z}) — the sigmoid, from ℝ back to (0, 1).
  • The corrected probability is σ(a · logit(p) + b).
  • Start: (a, b) = (1, 0) — the base model's probabilities pass through unchanged.

After each label y, the parameters update by Online Newton Step:

u = (logit(p), 1)
g = (σ(θ · u) − y) · u
A ← A + g gᵀ                 (start A = ρ I, ρ = 100)
θ ← Proj(θ − A⁻¹ g / γ)      (γ = 0.1, projection onto ‖θ‖ ≤ 100)

The clip on p to [0.01, 0.99] is a numerical guard: logit(0) and logit(1) are infinite, and the paper's regret bound (Theorem 2.1 of Gupta–Ramdas 2023, arXiv:2305.00070) assumes the base model never outputs exactly 0 or 1.

3. Hand case

Base model says p = 0.8 on a sample whose true label is y = 0. Start (a, b) = (1, 0), so θ = (1, 0).

u = (logit(0.8), 1) = (1.3863, 1)
σ(θ · u) = σ(1.3863) = 0.8
g = (0.8 − 0) · (1.3863, 1) = (1.109, 0.8)

Assume A = 100 · I (start of the run, A has not accumulated much yet). Then:

A⁻¹ g = (0.01109, 0.008)
θ − A⁻¹ g / γ = (1, 0) − (0.111, 0.08) = (0.889, −0.08)

New corrected probability on the same input:

σ(0.889 · 1.3863 + (−0.08)) = σ(1.152) = 0.760

The base model was wrong and confident; after one sample of feedback, the calibrated model says 0.760. It lowered its confidence after being wrong — exactly what calibration is supposed to do.

4. The result

import numpy as np
from dense_armor.utility.learn.online_classifiers import OnlineGaussianNB
from dense_armor.utility.learn.calibration import OnlinePlattScaling
from dense_armor.utility.metrics import LogLoss

rng = np.random.default_rng(0)
base = OnlineGaussianNB()
cal = OnlinePlattScaling(OnlineGaussianNB())
base_loss, cal_loss = LogLoss(), LogLoss()
for _ in range(2000):
    x = float(rng.uniform(-1.0, 1.0))
    y = int(x > 0.0)
    p_base = base.predict_proba_one({"x": x}).get(1, 0.5)
    p_cal = cal.predict_proba_one({"x": x}).get(1, 0.5)
    base_loss.update(y, p_base)
    cal_loss.update(y, p_cal)
    base.learn_one({"x": x}, y)
    cal.learn_one({"x": x}, y)
print(round(base_loss.get(), 4), round(cal_loss.get(), 4))
0.1016 0.036

The calibrated model's log-loss drops from 0.1016 to 0.036. The wrapper passes the check_estimator of an existing online-learning library.

5. The parameters

Element Paper (Algorithm 1) Parameter
Start (a, b) = (1, 0): base probabilities pass through unchanged —
Newton step scaled by 1/gamma, gamma = 0.1 gamma
Initial Hessian rho · I, rho = 100 rho
Projection ‖(a, b)‖₂ ≤ 100 radius
Clipping f(x) in [0.01, 0.99] clip

API reference

calibration

Online Platt scaling (Gupta and Ramdas, ICML 2023, arXiv:2305.00070) as a classifier wrapper for an existing online-learning library. Needs the optional dependency: the streaming extra

OnlinePlattScaling

OnlinePlattScaling(
    classifier: Classifier,
    gamma: float = 0.1,
    rho: float = 100.0,
    radius: float = 100.0,
    clip: float = 0.01,
)

Bases: ModelWrapper, Classifier

Online Platt scaling.

Recalibrates the probabilities of a classifier. The update happens at every new example, so the mapping adapts even when the data distribution changes over time. Given the probability p of the base classifier, the calibrated one is sigmoid(a · logit(p) + b), with a and b updated online via Online Newton Step. If the base is already calibrated, the mapping tends to the identity and the probabilities remain almost unchanged.

While the base classifier has no opinion yet (an empty predict_proba_one, as a tree returns before it has seen a class), the wrapper returns an empty dict too, and learn_one passes the sample to the base classifier without updating a and b: the mapping is never fitted on a probability the base did not give. A base that knows only the negative class gives p = 1 - p(False).

When the base classifies well but gives wrong numbers: the base classifier produces miscalibrated probabilities, typically because it is overconfident in its own predictions. This is the classic case of decision trees and random forests, which tend to output probabilities close to 0 or 1 even when they have no reason to. The accuracy or the ability to rank predictions of the base remain acceptable: the problem is not the classification, it is only the number attached to the prediction.

The paper proves that the total log-loss of OPS over T examples exceeds that of the best fixed Platt mapping chosen in hindsight by an amount that grows at most as log T. In practice: online, without knowing what comes next, OPS does almost as well as the best calibration that could have been chosen knowing the future. The guarantee is on the log-loss, so the accuracy may vary slightly. The theorem holds under two conditions (with the base probabilities clipped to [0.01, 0.99], as the clip parameter does, and comparing against mappings with a² + b² ≤ B²). OPS does not guarantee that the probabilities improve, nor that the log-loss decreases: both depend on the base and on the data. OPS guarantees that its log-loss stays close to the best fixed Platt mapping in hindsight, which is already a strong result for an online method.

Parameters

classifier The base classifier whose probabilities are to be recalibrated. It must expose predict_proba_one and learn_one. gamma Parameter of the Online Newton Step: the Newton step is scaled by 1/gamma. The paper uses 0.1. rho Regularization of the ONS Hessian: it starts from rho · I. The paper uses 100. radius Radius of the l2 ball onto which (a, b) is projected after each step. The paper uses 100. clip The base probabilities are clipped between clip and 1 - clip before the logit. The paper assumes f(x) in [0.01, 0.99].

Examples

import numpy as np from dense_armor.utility.learn.calibration import OnlinePlattScaling from dense_armor.utility.learn.online_classifiers import OnlineGaussianNB from dense_armor.utility.metrics import LogLoss

rng = np.random.default_rng(0) base = OnlineGaussianNB() cal = OnlinePlattScaling(OnlineGaussianNB()) base_loss, cal_loss = LogLoss(), LogLoss() for _ in range(1500): ... x = float(rng.uniform(-1.0, 1.0)) ... y = int(x > 0.0) ... p_base = base.predict_proba_one({"x": x}).get(1, 0.5) ... p_cal = cal.predict_proba_one({"x": x}).get(1, 0.5) ... _ = base_loss.update(y, p_base) ... _ = cal_loss.update(y, p_cal) ... _ = base.learn_one({"x": x}, y) ... _ = cal.learn_one({"x": x}, y) round(base_loss.get(), 4), round(cal_loss.get(), 4) (0.1146, 0.0417)

References

[^1]: Gupta, C. and Ramdas, A., 2023. Online Platt Scaling with Calibeating. In International Conference on Machine Learning.

Source code in dense_armor/utility/learn/calibration.py
def __init__(self, classifier: Classifier, gamma: float = 0.1, rho: float = 100.0,
             radius: float = 100.0, clip: float = 0.01):
    self.classifier = classifier
    self.gamma = gamma
    self.rho = rho
    self.radius = radius
    self.clip = clip
    self._theta = np.array([1.0, 0.0])
    self._A = rho * np.eye(2)

Details

The comparison against the paper's tracking variant (TOPS) and a JSD/ensemble variant on seven streams is in Dense-Evolution-Discovery, the corresponding online Platt scaling page of that library.

This is the same Online Newton Step as the paper's Algorithm 1 with the same hyperparameters; no tuning was done on Phishing or on any of the other streams. The 0.3499 is what the paper's algorithm gives on this dataset, not a number that was fished for.

While the base model has no opinion yet (a tree returns an empty probability dict before it has seen a class), the calibration is not updated: the sample goes to the base model only, so (a, b) is never fitted on a probability the base model did not give.