Skip to content

Commit 32b3fd8

Browse files
committed
perf(metrics): single-pass confusion-matrix Dice for multi-class label maps
DiceHelper.__call__ computed Dice with a Python loop over channels, so its cost grew linearly with the number of classes. For the common case where both inputs are single-channel integer label maps with three or more classes, accumulate one batched confusion matrix with torch.bincount in a single pass and read per-class true-positive, prediction and ground-truth counts off its diagonal and margins. This is numerically identical to the per-channel path and its peak memory is independent of the class count. Binary (fewer than three classes), one-hot or soft inputs, and out-of-range label maps fall through to the unchanged per-channel loop, which already handles those faster or is the only correct option, so there is no regression anywhere. Signed-off-by: Soumya Snigdha Kundu <soumya_snigdha.kundu@kcl.ac.uk>
1 parent 91b1ad7 commit 32b3fd8

1 file changed

Lines changed: 69 additions & 4 deletions

File tree

monai/metrics/meandice.py

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,53 @@ def compute_channel(self, y_pred: torch.Tensor, y: torch.Tensor) -> torch.Tensor
374374
return torch.tensor(1.0, device=y_o.device)
375375
return torch.tensor(0.0, device=y_o.device)
376376

377+
def compute_confusion_dice(self, y_pred: torch.Tensor, y: torch.Tensor, n_pred_ch: int) -> torch.Tensor:
378+
"""
379+
Fast Dice for multi-class label maps. Both ``y_pred`` and ``y`` are single-channel integer class-index maps
380+
with values in ``[0, n_pred_ch)``. A single batched confusion matrix is accumulated with ``torch.bincount``
381+
(one pass over the voxels, independent of the number of classes), and per-class true-positive, prediction and
382+
ground-truth counts are read off its diagonal and margins. This matches :meth:`compute_channel` exactly while
383+
avoiding both the per-channel Python loop and the materialization of one-hot tensors.
384+
385+
Args:
386+
y_pred: predicted class indices with shape (batch_size, 1, spatial_dims...).
387+
y: ground-truth class indices with shape (batch_size, 1, spatial_dims...).
388+
n_pred_ch: number of classes (background included).
389+
390+
Returns:
391+
Per-(batch, class) Dice with shape (batch_size, num_classes_selected), background excluded when
392+
``include_background`` is False, following the same ``ignore_empty`` convention as the per-channel path.
393+
"""
394+
batch_size = y_pred.shape[0]
395+
device = y_pred.device
396+
y_pred_flat = y_pred.reshape(batch_size, -1).long()
397+
y_flat = y.reshape(batch_size, -1).long()
398+
batch_offset = torch.arange(batch_size, device=device).unsqueeze(1) * (n_pred_ch * n_pred_ch)
399+
indices = (batch_offset + y_pred_flat * n_pred_ch + y_flat).reshape(-1)
400+
confusion = torch.bincount(indices, minlength=batch_size * n_pred_ch * n_pred_ch)
401+
confusion = confusion.reshape(batch_size, n_pred_ch, n_pred_ch).to(torch.float32)
402+
403+
tp = torch.diagonal(confusion, dim1=1, dim2=2)
404+
pred_sum = confusion.sum(dim=2)
405+
y_sum = confusion.sum(dim=1)
406+
dice = (2.0 * tp) / (pred_sum + y_sum)
407+
408+
if self.ignore_empty:
409+
dice = torch.where(y_sum > 0, dice, torch.tensor(float("nan"), device=device, dtype=dice.dtype))
410+
else:
411+
dice = torch.where(
412+
y_sum == 0,
413+
torch.where(
414+
pred_sum == 0,
415+
torch.tensor(1.0, device=device, dtype=dice.dtype),
416+
torch.tensor(0.0, device=device, dtype=dice.dtype),
417+
),
418+
dice,
419+
)
420+
421+
first_ch = 0 if self.include_background else 1
422+
return dice[:, first_ch:]
423+
377424
def __call__(self, y_pred: torch.Tensor, y: torch.Tensor) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]:
378425
"""
379426
Compute the metric for the given prediction and ground truth.
@@ -383,6 +430,9 @@ def __call__(self, y_pred: torch.Tensor, y: torch.Tensor) -> torch.Tensor | tupl
383430
the number of channels is inferred from ``y_pred.shape[1]`` when ``num_classes is None``.
384431
y: ground truth with shape (batch_size, num_classes or 1, spatial_dims...).
385432
433+
Returns:
434+
Per-(batch, class) Dice scores, or ``(scores, not_nans)`` when ``get_not_nans`` is True.
435+
386436
Raises:
387437
ValueError: when the shapes of `y_pred` and `y` are not compatible for the per-component computation.
388438
"""
@@ -413,13 +463,28 @@ def __call__(self, y_pred: torch.Tensor, y: torch.Tensor) -> torch.Tensor | tupl
413463
"(B, 2, H, W) or (B, 2, D, H, W). "
414464
f"Got y_pred={tuple(y_pred.shape)}, y={tuple(y.shape)}."
415465
)
466+
data = torch.stack(
467+
[
468+
self.compute_cc_dice(y_pred[b].unsqueeze(0), y[b].unsqueeze(0)).reshape(-1)
469+
for b in range(y_pred.shape[0])
470+
],
471+
dim=0,
472+
).contiguous()
473+
f, not_nans = do_metric_reduction(data, self.reduction) # type: ignore
474+
return (f, not_nans) if self.get_not_nans else f
475+
476+
# multi-class label maps use the single-pass confusion matrix; all else uses the per-channel loop
477+
label_maps = (
478+
y_pred.shape[1] == 1 and y.shape[1] == 1 and not y_pred.is_floating_point() and not y.is_floating_point()
479+
)
480+
if label_maps and n_pred_ch >= 3 and int(torch.max(y_pred.max(), y.max())) < n_pred_ch:
481+
data = self.compute_confusion_dice(y_pred, y, n_pred_ch)
482+
f, not_nans = do_metric_reduction(data, self.reduction) # type: ignore
483+
return (f, not_nans) if self.get_not_nans else f
416484

417-
first_ch = 0 if self.include_background and not self.per_component else 1
485+
first_ch = 0 if self.include_background else 1
418486
data = []
419487
for b in range(y_pred.shape[0]):
420-
if self.per_component:
421-
data.append(self.compute_cc_dice(y_pred=y_pred[b].unsqueeze(0), y=y[b].unsqueeze(0)).reshape(-1))
422-
continue
423488
c_list = []
424489
for c in range(first_ch, n_pred_ch) if n_pred_ch > 1 else [1]:
425490
x_pred = (y_pred[b, 0] == c) if (y_pred.shape[1] == 1) else y_pred[b, c].bool()

0 commit comments

Comments
 (0)