Skip to content

Commit fc68304

Browse files
week_5: Module C (The Librarian) — C.3 confidence calibration (temperature scaling)
1 parent 0dd3823 commit fc68304

5 files changed

Lines changed: 499 additions & 20 deletions

File tree

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
"""Tests for C.3 temperature-scaling calibration (Week 5).
2+
3+
Hermetic and deterministic: synthetic candidate-logit shortlists + 0/1 labels
4+
only — no cross-encoder, DB, or embedding key. Covers the softmax-over-shortlist
5+
confidence, temperature flatten/sharpen behaviour, the NLL fit recovering a known
6+
T and reducing NLL, ECE on perfectly- and mis-calibrated data (hand-checked), and
7+
every guard.
8+
"""
9+
10+
import math
11+
import unittest
12+
13+
import numpy as np
14+
from scipy.special import softmax
15+
16+
from application.utils.librarian.calibration.temperature import (
17+
CALIBRATOR_NAME,
18+
CalibrationError,
19+
DegenerateLabelsError,
20+
TemperatureScaler,
21+
expected_calibration_error,
22+
fit_temperature,
23+
negative_log_likelihood,
24+
)
25+
26+
27+
class TemperatureScalerTest(unittest.TestCase):
28+
def test_confidence_is_softmax_top_mass(self) -> None:
29+
logits = [2.0, 1.0, 0.0]
30+
expected = float(softmax(np.array(logits)).max())
31+
self.assertAlmostEqual(TemperatureScaler(1.0).confidence(logits), expected, 9)
32+
33+
def test_probabilities_sum_to_one_and_peak_at_top(self) -> None:
34+
p = TemperatureScaler(1.0).probabilities([3.0, 1.0, -2.0])
35+
self.assertAlmostEqual(float(p.sum()), 1.0, places=9)
36+
self.assertEqual(int(np.argmax(p)), 0) # highest logit -> highest prob
37+
38+
def test_high_temperature_flattens_toward_uniform(self) -> None:
39+
logits = [3.0, 1.0, 0.0]
40+
hot = TemperatureScaler(1000.0).confidence(logits)
41+
base = TemperatureScaler(1.0).confidence(logits)
42+
self.assertLess(hot, base)
43+
self.assertAlmostEqual(hot, 1.0 / 3.0, places=2) # ~uniform over 3
44+
45+
def test_low_temperature_sharpens_toward_one(self) -> None:
46+
logits = [3.0, 1.0, 0.0]
47+
cold = TemperatureScaler(0.1).confidence(logits)
48+
base = TemperatureScaler(1.0).confidence(logits)
49+
self.assertGreater(cold, base)
50+
self.assertGreater(cold, 0.99)
51+
52+
def test_single_candidate_confidence_is_one(self) -> None:
53+
# softmax over one element is always 1.0, at any temperature.
54+
self.assertAlmostEqual(TemperatureScaler(3.0).confidence([0.42]), 1.0, 9)
55+
56+
def test_empty_shortlist_rejected(self) -> None:
57+
with self.assertRaises(CalibrationError):
58+
TemperatureScaler(1.0).confidence([])
59+
60+
def test_non_positive_or_nonfinite_temperature_rejected(self) -> None:
61+
for bad in (0.0, -1.0, float("inf"), float("nan")):
62+
with self.assertRaises(CalibrationError):
63+
TemperatureScaler(bad)
64+
65+
66+
class NegativeLogLikelihoodTest(unittest.TestCase):
67+
def test_matches_hand_computed_single_pair(self) -> None:
68+
# one shortlist, top-1 correct: loss = -log(softmax([2,0]).max())
69+
conf = float(softmax(np.array([2.0, 0.0])).max())
70+
self.assertAlmostEqual(
71+
negative_log_likelihood([[2.0, 0.0]], [1.0], 1.0), -math.log(conf), 6
72+
)
73+
74+
def test_length_mismatch_rejected(self) -> None:
75+
with self.assertRaises(CalibrationError):
76+
negative_log_likelihood([[1.0, 0.0], [2.0, 0.0]], [1.0], 1.0)
77+
78+
def test_non_positive_temperature_rejected(self) -> None:
79+
with self.assertRaises(CalibrationError):
80+
negative_log_likelihood([[1.0, 0.0]], [1.0], 0.0)
81+
82+
83+
class FitTemperatureTest(unittest.TestCase):
84+
def _synthetic(self, true_t: float, n: int = 4000, k: int = 5):
85+
"""Shortlists whose top-1 correctness is drawn from softmax(logits/true_t).
86+
87+
Data generated with true_t flattens the softmax, so the raw (T=1)
88+
confidence is over-confident and the fit should recover T ~ true_t.
89+
Seeded -> stable.
90+
"""
91+
rng = np.random.default_rng(0)
92+
sets, labels = [], []
93+
for _ in range(n):
94+
z = rng.normal(0.0, 3.0, size=k)
95+
z[::-1].sort() # descending so index 0 is the top-1 (argmax)
96+
p_correct = softmax(z / true_t).max()
97+
sets.append(z.tolist())
98+
labels.append(1.0 if rng.random() < p_correct else 0.0)
99+
return sets, labels
100+
101+
def test_recovers_known_temperature(self) -> None:
102+
sets, labels = self._synthetic(true_t=2.0)
103+
scaler = fit_temperature(sets, labels)
104+
self.assertAlmostEqual(scaler.temperature, 2.0, delta=0.5)
105+
106+
def test_fit_reduces_nll_versus_T1(self) -> None:
107+
sets, labels = self._synthetic(true_t=2.0)
108+
scaler = fit_temperature(sets, labels)
109+
self.assertLess(
110+
negative_log_likelihood(sets, labels, scaler.temperature),
111+
negative_log_likelihood(sets, labels, 1.0),
112+
)
113+
114+
def test_single_class_labels_raise_degenerate(self) -> None:
115+
with self.assertRaises(DegenerateLabelsError):
116+
fit_temperature([[2.0, 0.0], [3.0, 1.0]], [1.0, 1.0])
117+
118+
def test_non_binary_labels_rejected(self) -> None:
119+
with self.assertRaises(CalibrationError):
120+
fit_temperature([[2.0, 0.0], [3.0, 1.0]], [0.0, 2.0])
121+
122+
123+
class ExpectedCalibrationErrorTest(unittest.TestCase):
124+
def test_perfectly_calibrated_is_near_zero(self) -> None:
125+
# bin [0.5,0.6): five correct, five wrong -> acc 0.5 == conf 0.5.
126+
self.assertAlmostEqual(
127+
expected_calibration_error([0.5] * 10, [1.0, 0.0] * 5), 0.0, places=6
128+
)
129+
130+
def test_confidently_wrong_is_large(self) -> None:
131+
self.assertAlmostEqual(
132+
expected_calibration_error([0.9] * 10, [0.0] * 10), 0.9, places=6
133+
)
134+
135+
def test_hand_checked_two_bin_value(self) -> None:
136+
# [0.2,0.3): conf .2 acc 0 gap .2 ; [0.8,0.9): conf .8 acc 1 gap .2
137+
# ECE = 0.5*0.2 + 0.5*0.2 = 0.20
138+
self.assertAlmostEqual(
139+
expected_calibration_error([0.2, 0.2, 0.8, 0.8], [0.0, 0.0, 1.0, 1.0]),
140+
0.20,
141+
places=6,
142+
)
143+
144+
def test_length_mismatch_and_empty_rejected(self) -> None:
145+
with self.assertRaises(CalibrationError):
146+
expected_calibration_error([0.5, 0.6], [1.0])
147+
with self.assertRaises(CalibrationError):
148+
expected_calibration_error([], [])
149+
with self.assertRaises(CalibrationError):
150+
expected_calibration_error([0.5], [1.0], n_bins=0)
151+
152+
153+
class EndToEndTest(unittest.TestCase):
154+
def test_flat_shortlists_are_low_confidence(self) -> None:
155+
# A near-tie shortlist (bad match) -> low top-1 confidence.
156+
self.assertLess(TemperatureScaler(1.0).confidence([0.1, 0.0, -0.1]), 0.45)
157+
158+
def test_peaked_shortlist_is_high_confidence(self) -> None:
159+
# A clear winner -> high top-1 confidence.
160+
self.assertGreater(TemperatureScaler(1.0).confidence([6.0, -1.0, -3.0]), 0.9)
161+
162+
163+
class MetadataTest(unittest.TestCase):
164+
def test_calibrator_name_is_versioned(self) -> None:
165+
self.assertEqual(CALIBRATOR_NAME, "temperature-scaling/0.2.0")
166+
167+
168+
if __name__ == "__main__":
169+
unittest.main()

application/utils/librarian/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@
1616
explicit-link resolution).
1717
W3 (C.1): candidate retriever (in-memory + pgvector) + pipeline switch.
1818
W4 (C.2): cross-encoder reranker — re-sorts the C.1 shortlist, fills reranked[].
19-
Calibration + decision routing (C.3-C.4, W5-W6) onward is not built yet.
19+
W5 (C.3): confidence calibration — temperature scaling maps a rerank logit to
20+
an honest probability (fit by NLL on the golden set, gated ECE < 0.10).
21+
Decision routing (C.4, W6) onward is not built yet.
2022
2123
Vendored RFC JSON schemas live under ``_rfc_schemas/``. They are pinned to
2224
upstream/owasp-graph @ 2b1437987768d5ed20fe9ee721ab9a898c4b84af (PR #734).
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"""Module C.3 — confidence calibration (Week 5).
2+
3+
C.2 (the cross-encoder, W4) emits a raw ranking logit per candidate — great for
4+
ordering, meaningless as confidence (a +1.5 is not "82% sure"). C.3 turns that
5+
logit into an honest probability via **temperature scaling**: ``p = sigmoid(z/T)``
6+
with a single scalar ``T`` fit by negative-log-likelihood on the golden set, and
7+
proves the result honest with **ECE < 0.10**.
8+
9+
The W6 decision engine thresholds that probability (auto-link vs. human review),
10+
so calibration is what makes the threshold trustworthy. Kept dependency-light
11+
(numpy + scipy) and model-free so it stays hermetically testable — mirrors the
12+
C.1/C.2 seams.
13+
"""

0 commit comments

Comments
 (0)