Skip to content

Commit 519cbf2

Browse files
committed
Address CodeRabbit review: validate threshold, restore train state, drop invalid labels
- set_threshold: reject non-scalar qhat (prevents silent broadcasting bugs) - calibrate: save and restore network.training state (avoid side effect of eval()) - accumulate: drop invalid labels (negative or >= C) instead of clamping them (clamping silently remapped -1 -> 0 and 255 -> C-1, corrupting LAC scores) - accumulate: move scores to CPU after detach (avoid GPU OOM on large 3D cal sets) - tests: add coverage for scalar validation, train-state restore, invalid-label handling; fix assertFalse(.all()) -> .any() for excluded-class assertions - docstrings: add Returns/Raises sections to _quantile_threshold, accumulate, calibrate, set_threshold, __call__ Signed-off-by: Colin Son <txmed82@users.noreply.github.com>
1 parent ec9bb2b commit 519cbf2

2 files changed

Lines changed: 115 additions & 7 deletions

File tree

monai/inferers/conformal_predictor.py

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,16 @@ def _quantile_threshold(scores: torch.Tensor, alpha: float) -> torch.Tensor:
3232
(Vovk et al. 2005; Angelopoulos & Bates 2021). ``scores`` is a 1-D tensor of
3333
non-conformity scores from the held-out calibration split; the returned scalar
3434
lives on the same device/dtype as ``scores``.
35+
36+
Args:
37+
scores: 1-D tensor of non-conformity scores from the calibration split.
38+
alpha: mis-coverage level in ``(0, 1)``.
39+
40+
Returns:
41+
Scalar tensor ``qhat`` on the same device/dtype as ``scores``.
42+
43+
Raises:
44+
ValueError: if ``scores`` is empty or ``alpha`` is not in ``(0, 1)``.
3545
"""
3646
n = scores.numel()
3747
if n <= 0:
@@ -90,11 +100,15 @@ def accumulate(self, probs: torch.Tensor, labels: torch.Tensor) -> None:
90100
probs: softmax probabilities ``(B, C, spatial...)`` in ``[0, 1]`` summing to 1 over C.
91101
labels: integer class indices ``(B, 1, spatial...)`` or ``(B, spatial...)`` with values
92102
in ``[0, C)``. Shape must broadcast against ``probs`` spatial dims.
103+
104+
Raises:
105+
ValueError: if ``probs`` has fewer than 2 dimensions.
93106
"""
94107
if probs.ndim < 2:
95108
raise ValueError(f"probs must be (B, C, spatial...), got shape {tuple(probs.shape)}.")
109+
c = probs.shape[1]
96110
# flatten to (N, C)
97-
probs_flat = probs.reshape(probs.shape[0], probs.shape[1], -1).movedim(1, -1).reshape(-1, probs.shape[1])
111+
probs_flat = probs.reshape(probs.shape[0], c, -1).movedim(1, -1).reshape(-1, c)
98112
labels_flat = labels.reshape(-1).long()
99113
if not self.include_background:
100114
# drop background-labeled voxels (class 0) from calibration so the threshold isn't
@@ -103,12 +117,26 @@ def accumulate(self, probs: torch.Tensor, labels: torch.Tensor) -> None:
103117
keep = labels_flat != 0
104118
probs_flat = probs_flat[keep]
105119
labels_flat = labels_flat[keep]
106-
labels_flat = labels_flat.clamp(min=0, max=probs_flat.shape[1] - 1)
120+
# reject invalid labels (negative or >= C) outright rather than silently clamping them,
121+
# which would corrupt the non-conformity scores.
122+
valid = (labels_flat >= 0) & (labels_flat < c)
123+
probs_flat = probs_flat[valid]
124+
labels_flat = labels_flat[valid]
125+
if labels_flat.numel() == 0:
126+
return # nothing to accumulate this batch (all labels invalid or all bg-excluded)
107127
true_p = probs_flat.gather(1, labels_flat.unsqueeze(1)).squeeze(1)
108-
self._scores.append((1.0 - true_p).detach())
128+
# move to CPU to avoid GPU OOM on large per-voxel calibration sets
129+
self._scores.append((1.0 - true_p).detach().cpu())
109130

110131
def calibrate(self) -> torch.Tensor:
111-
"""Return the split-conformal threshold ``qhat`` from all accumulated scores."""
132+
"""Return the split-conformal threshold ``qhat`` from all accumulated scores.
133+
134+
Returns:
135+
Scalar tensor ``qhat`` on CPU.
136+
137+
Raises:
138+
RuntimeError: if no calibration scores have been accumulated.
139+
"""
112140
if not self._scores:
113141
raise RuntimeError("No calibration scores accumulated; call accumulate(probs, labels) first.")
114142
all_scores = torch.cat(self._scores)
@@ -174,9 +202,19 @@ def __init__(
174202
self.set_threshold(qhat)
175203

176204
def set_threshold(self, qhat: torch.Tensor) -> None:
177-
"""Set (or update) the calibrated threshold. Lets you keep one inferer and re-calibrate."""
205+
"""Set (or update) the calibrated threshold. Lets you keep one inferer and re-calibrate.
206+
207+
Args:
208+
qhat: scalar ``torch.Tensor`` threshold.
209+
210+
Raises:
211+
TypeError: if ``qhat`` is not a ``torch.Tensor``.
212+
ValueError: if ``qhat`` is not a scalar (has more than one element).
213+
"""
178214
if not isinstance(qhat, torch.Tensor):
179215
raise TypeError(f"qhat must be a torch.Tensor, got {type(qhat)}.")
216+
if qhat.numel() != 1:
217+
raise ValueError(f"qhat must be a scalar tensor, got {qhat.numel()} elements.")
180218
self.qhat = qhat.detach().clone()
181219

182220
def calibrate(
@@ -193,10 +231,14 @@ def calibrate(
193231
194232
Returns:
195233
The calibrated ``qhat`` (also stored and used by subsequent ``__call__`` invocations).
234+
235+
Raises:
236+
TypeError: if the network returns a non-Tensor.
196237
"""
197238
if device is None:
198239
param = next(network.parameters(), None)
199240
device = param.device if param is not None else torch.device("cpu")
241+
was_training = getattr(network, "training", False)
200242
network.eval()
201243
cal = ConformalCalibrator(alpha=self.alpha, score=self.score, include_background=self.include_background)
202244
iterator = cal_loader
@@ -215,6 +257,8 @@ def calibrate(
215257
cal.accumulate(probs.to(device), batch["label"].to(device))
216258
qhat = cal.calibrate()
217259
self.set_threshold(qhat)
260+
if was_training:
261+
network.train()
218262
return qhat
219263

220264
def __call__(
@@ -230,6 +274,10 @@ def __call__(
230274
Returns:
231275
sets: bool tensor ``(B, C, spatial...)``, ``True`` where class ``c`` is in the set.
232276
For the underlying softmax, call ``network(inputs).softmax(1)``.
277+
278+
Raises:
279+
RuntimeError: if no threshold has been set.
280+
TypeError: if the network returns a non-Tensor.
233281
"""
234282
if self.qhat is None:
235283
raise RuntimeError("No threshold set; call set_threshold(qhat) or calibrate(...) first.")

tests/inferers/test_conformal_predictor.py

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,25 @@ def test_calibrate_empty_raises(self):
109109
with self.assertRaises(RuntimeError):
110110
ConformalCalibrator().calibrate()
111111

112+
def test_invalid_labels_are_dropped_not_clamped(self):
113+
# labels -1 and 99 (out of range for C=3) must be dropped, not clamped to 0/2.
114+
# If clamped, score for "label 99" would be 1 - softmax[2] = 0.9, corrupting qhat.
115+
probs = torch.tensor([[[0.8, 0.8], [0.1, 0.1], [0.1, 0.1]]]) # (1, 3, 2)
116+
labels = torch.tensor([[[-1, 99]]]) # both invalid
117+
cal = ConformalCalibrator(alpha=0.1)
118+
cal.accumulate(probs, labels)
119+
with self.assertRaises(RuntimeError):
120+
# all labels dropped -> no scores -> calibrate raises
121+
cal.calibrate()
122+
123+
def test_mixed_valid_invalid_labels_keeps_valid(self):
124+
# one valid label (class 0, score 0.2) + one invalid (class 99, dropped).
125+
# qhat should reflect only the valid score.
126+
probs = torch.tensor([[[0.8, 0.8], [0.1, 0.1], [0.1, 0.1]]]) # (1, 3, 2)
127+
labels = torch.tensor([[[0, 99]]]) # voxel0 valid, voxel1 invalid
128+
qhat = self._cal_batch(probs, labels)
129+
assert_allclose(qhat, torch.tensor(0.2), atol=1e-6)
130+
112131

113132
class TestConformalPredictor(unittest.TestCase):
114133
def test_set_and_predict(self):
@@ -128,8 +147,8 @@ def net(x):
128147
self.assertEqual(sets.shape, (1, 3, 2, 2))
129148
self.assertEqual(sets.dtype, torch.bool)
130149
self.assertTrue(sets[:, 0].all())
131-
self.assertFalse(sets[:, 1].all())
132-
self.assertFalse(sets[:, 2].all())
150+
self.assertFalse(sets[:, 1].any())
151+
self.assertFalse(sets[:, 2].any())
133152

134153
def test_no_threshold_raises(self):
135154
inferer = ConformalPredictor() # qhat None
@@ -169,6 +188,47 @@ def net(x):
169188
with self.assertRaises(TypeError):
170189
inferer(torch.zeros(1, 1), net)
171190

191+
def test_set_threshold_rejects_non_scalar(self):
192+
inferer = ConformalPredictor()
193+
with self.assertRaises(ValueError):
194+
inferer.set_threshold(torch.tensor([0.1, 0.2]))
195+
196+
def test_set_threshold_rejects_non_tensor(self):
197+
inferer = ConformalPredictor()
198+
with self.assertRaises(TypeError):
199+
inferer.set_threshold(0.5) # type: ignore[arg-type]
200+
201+
def test_calibrate_restores_training_state(self):
202+
class Net(torch.nn.Module):
203+
def forward(self, x):
204+
logits = torch.zeros(x.shape[0], 2)
205+
logits[:, 0] = 2.1972
206+
return logits
207+
208+
net = Net()
209+
net.train()
210+
self.assertTrue(net.training)
211+
cal_loader = [{"image": torch.zeros(2, 1), "label": torch.zeros(2, 1, dtype=torch.long)} for _ in range(5)]
212+
inferer = ConformalPredictor(alpha=0.1)
213+
inferer.calibrate(net, cal_loader, device=torch.device("cpu"))
214+
# training state must be restored to True
215+
self.assertTrue(net.training)
216+
217+
def test_calibrate_preserves_eval_state(self):
218+
class Net(torch.nn.Module):
219+
def forward(self, x):
220+
logits = torch.zeros(x.shape[0], 2)
221+
logits[:, 0] = 2.1972
222+
return logits
223+
224+
net = Net()
225+
net.eval()
226+
self.assertFalse(net.training)
227+
cal_loader = [{"image": torch.zeros(2, 1), "label": torch.zeros(2, 1, dtype=torch.long)} for _ in range(5)]
228+
inferer = ConformalPredictor(alpha=0.1)
229+
inferer.calibrate(net, cal_loader, device=torch.device("cpu"))
230+
self.assertFalse(net.training)
231+
172232

173233
if __name__ == "__main__":
174234
unittest.main()

0 commit comments

Comments
 (0)