Skip to content

Commit 2a500d7

Browse files
committed
Address Code Rabbit review: validate labels, lam_grid, loss_fn; chunk lambda loop; sort __all__; scalar/range check set_threshold; zip(strict=True); docstrings
- conformal_risk.py: reject out-of-range labels instead of silent clamp (lines ~76, ~226) - conformal_risk.py: chunk the lambda grid in calibrate() to avoid materializing (n_lam, P_i, C) at once - conformal_risk.py: validate lam_grid is non-empty and sorted ascending (prevents IndexError and wrong infimum) - conformal_risk.py: validate loss_fn output shape and NaN after each call - conformal_risk.py: enforce set_threshold lam is scalar in [0, 1] - conformal_risk.py: zip(strict=True) over _scores/_labels - conformal_risk.py: alphabetical __all__ (RUF022), reset() docstring - test_conformal_risk.py: assert predictor returns input probs unchanged (RUF059) Signed-off-by: Colin Son <txmed82@users.noreply.github.com>
1 parent 2fd9a20 commit 2a500d7

2 files changed

Lines changed: 55 additions & 11 deletions

File tree

monai/metrics/conformal_risk.py

Lines changed: 51 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,8 @@
4343
"ConformalRiskPredictor",
4444
"Coverage",
4545
"SetSize",
46-
"compute_set_size",
4746
"compute_coverage",
47+
"compute_set_size",
4848
]
4949

5050
tqdm, has_tqdm = optional_import("tqdm", name="tqdm")
@@ -73,7 +73,10 @@ def _flatten_spatial(sets: torch.Tensor, labels: torch.Tensor) -> tuple[torch.Te
7373
c = sets.shape[1]
7474
sets_flat = sets.movedim(1, -1).reshape(-1, c)
7575
labels_flat = labels.reshape(-1).long()
76-
labels_flat = labels_flat.clamp(min=0, max=c - 1)
76+
if (labels_flat < 0).any() or (labels_flat >= c).any():
77+
raise ValueError(
78+
f"labels must lie in [0, {c - 1}], got min={int(labels_flat.min())}, max={int(labels_flat.max())}."
79+
)
7780
return sets_flat, labels_flat
7881

7982

@@ -192,8 +195,10 @@ def __init__(
192195
self.include_background = include_background
193196
if lam_grid is None:
194197
lam_grid = torch.linspace(0.0, 1.0, 101)
195-
if lam_grid.ndim != 1 or (lam_grid < 0).any() or (lam_grid > 1).any():
196-
raise ValueError("lam_grid must be a 1-D tensor with values in [0, 1].")
198+
if lam_grid.ndim != 1 or lam_grid.numel() == 0 or (lam_grid < 0).any() or (lam_grid > 1).any():
199+
raise ValueError("lam_grid must be a non-empty 1-D tensor with values in [0, 1].")
200+
if not bool((lam_grid[1:] >= lam_grid[:-1]).all()):
201+
raise ValueError("lam_grid must be sorted in ascending order for the infimum search.")
197202
self.lam_grid = lam_grid.float()
198203
# Per-image score/label tensors, stored one entry per calibration image so spatial
199204
# size may vary across images and across accumulate() calls (variable-size volumes).
@@ -223,7 +228,11 @@ def accumulate(self, probs: torch.Tensor, labels: torch.Tensor) -> None:
223228
# (B, per_image, C): move class to last then flatten spatial
224229
scores = (1.0 - probs).movedim(1, -1).reshape(b, per_image, c).detach()
225230
# labels (B, 1, spatial...) or (B, spatial...) -> (B, per_image)
226-
labels_flat = labels.reshape(b, per_image).long().clamp(min=0, max=c - 1).detach()
231+
labels_flat = labels.reshape(b, per_image).long().detach()
232+
if (labels_flat < 0).any() or (labels_flat >= c).any():
233+
raise ValueError(
234+
f"labels must lie in [0, {c - 1}], got min={int(labels_flat.min())}, max={int(labels_flat.max())}."
235+
)
227236
for i in range(b):
228237
self._scores.append(scores[i]) # (per_image, C)
229238
self._labels.append(labels_flat[i]) # (per_image,)
@@ -250,16 +259,32 @@ def calibrate(self) -> torch.Tensor:
250259
# Sum each image's per-lambda loss; images vary in size so we loop per image but
251260
# vectorize over the whole lambda grid (n_lam acts as the batch dim into loss_fn).
252261
risk_sum = torch.zeros(n_lam, device=device, dtype=torch.float32)
253-
for scores_i, labels_i in zip(self._scores, self._labels):
262+
# ponytail: chunk over the lambda grid to bound peak memory; the full
263+
# (n_lam, P_i, C) tensor would OOM on large 3D volumes. 1 << 12 lambdas
264+
# at a time keeps the working set modest while preserving the cumulative
265+
# sum; lower if calibration volumes are very large.
266+
lam_chunk = 1 << 12
267+
for scores_i, labels_i in zip(self._scores, self._labels, strict=True):
254268
if not self.include_background:
255269
keep = labels_i != 0
256270
if not bool(keep.any()):
257271
continue # all-background image: 0 loss, but still counted in n
258272
scores_i, labels_i = scores_i[keep], labels_i[keep]
259-
sets = scores_i.unsqueeze(0) <= lam_grid.view(-1, 1, 1) # (n_lam, P_i, C)
260-
sets_shaped = sets.movedim(-1, 1) # (n_lam, C, P_i)
261-
labels_rep = labels_i.view(1, 1, -1).expand(n_lam, 1, -1) # (n_lam, 1, P_i)
262-
risk_sum += self.loss_fn(sets_shaped, labels_rep).float()
273+
p_i = scores_i.shape[0]
274+
for start in range(0, n_lam, lam_chunk):
275+
end = min(start + lam_chunk, n_lam)
276+
lam_chunk_grid = lam_grid[start:end] # (n_chunk,)
277+
sets = scores_i.unsqueeze(0) <= lam_chunk_grid.view(-1, 1, 1) # (n_chunk, P_i, C)
278+
sets_shaped = sets.movedim(-1, 1) # (n_chunk, C, P_i)
279+
labels_rep = labels_i.view(1, 1, -1).expand(sets_shaped.shape[0], 1, p_i) # (n_chunk, 1, P_i)
280+
loss = self.loss_fn(sets_shaped, labels_rep).float()
281+
if loss.shape != (sets_shaped.shape[0],):
282+
raise ValueError(
283+
f"loss_fn must return per-image loss of shape (n_chunk,), got {tuple(loss.shape)}."
284+
)
285+
if bool(torch.isnan(loss).any()):
286+
raise ValueError("loss_fn returned NaN; check inputs or loss implementation.")
287+
risk_sum[start:end] += loss
263288
emp_risk = risk_sum / n
264289
# Finite-sample-corrected selection. B = 1 is the loss upper bound (losses are in
265290
# [0, 1]); losses are non-increasing in lambda, so the leftmost lambda clearing the
@@ -275,6 +300,11 @@ def calibrate(self) -> torch.Tensor:
275300
return lam_hat.to(dtype).to(device)
276301

277302
def reset(self) -> None:
303+
"""Reset internal calibration state.
304+
305+
Clears the per-image score/label buffers and the cached class count so
306+
the calibrator can be reused on a fresh calibration split.
307+
"""
278308
self._scores, self._labels = [], []
279309
self._num_classes = None
280310

@@ -317,9 +347,19 @@ def __init__(self, lam: torch.Tensor, include_background: bool = True) -> None:
317347
self.include_background = include_background
318348

319349
def set_threshold(self, lam: torch.Tensor) -> None:
320-
"""Set (or update) the calibrated threshold."""
350+
"""Set (or update) the calibrated threshold.
351+
352+
Args:
353+
lam: scalar tensor in ``[0, 1]``. A non-scalar would broadcast over
354+
spatial dims at inference and silently produce wrong sets.
355+
"""
321356
if not isinstance(lam, torch.Tensor):
322357
raise TypeError(f"lam must be a torch.Tensor, got {type(lam)}.")
358+
if lam.ndim != 0:
359+
raise ValueError(f"lam must be a scalar tensor, got shape {tuple(lam.shape)}.")
360+
lam_val = float(lam.detach().item())
361+
if not 0.0 <= lam_val <= 1.0:
362+
raise ValueError(f"lam must lie in [0, 1], got {lam_val}.")
323363
self.lam = lam.detach().clone()
324364

325365
def __call__(self, probs: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:

tests/metrics/test_conformal_risk.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,10 @@ def test_sets_and_mask(self):
188188
self.assertEqual(sets.shape, (1, 3, 3))
189189
self.assertEqual(mask.shape, (1, 1, 3))
190190
self.assertEqual(mask.dtype, torch.bool)
191+
# contract: predictor returns the input probs unchanged
192+
self.assertIsInstance(probs_out, torch.Tensor)
193+
self.assertEqual(probs_out.shape, (1, 3, 3))
194+
assert_allclose(probs_out, probs, atol=0.0)
191195
# voxel 0: only class 0 -> not ambiguous
192196
self.assertFalse(mask[0, 0, 0].item())
193197
# voxel 1: only class 0 -> not ambiguous

0 commit comments

Comments
 (0)