4343 "ConformalRiskPredictor" ,
4444 "Coverage" ,
4545 "SetSize" ,
46- "compute_set_size" ,
4746 "compute_coverage" ,
47+ "compute_set_size" ,
4848]
4949
5050tqdm , 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 ]:
0 commit comments