@@ -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." )
0 commit comments