Skip to content

Commit a6b672a

Browse files
aymuos15ericspod
andauthored
Fix #8239: Enhance SoftclDiceLoss and SoftDiceclDiceLoss with DiceLoss-compatible API (#8703)
## Summary - Add `include_background`, `to_onehot_y`, `sigmoid`, `softmax`, `other_act`, and `reduction` parameters to `SoftclDiceLoss` and `SoftDiceclDiceLoss` - Fix argument order in `forward()` to match MONAI convention (`input`, `target` instead of `y_true`, `y_pred`) - Add proper input validation and comprehensive docstrings consistent with `DiceLoss` Fixes #8239 ## Changes These changes make the clDice losses consistent with the `DiceLoss` API, addressing the issues reported in #8239 where users encountered zero loss due to missing preprocessing options. ### Checklist - [x] Followed coding style (ran `./runtests.sh --codeformat`) - [x] Unit tests added - [x] Signed-off-by line in commit - [ ] This PR does not introduce breaking changes (argument order changed from `y_true, y_pred` to `input, target`) ## Test plan - [x] Parameterized tests with verified numerical outputs - [x] Tests for all new parameters (sigmoid, softmax, to_onehot_y, include_background, reduction modes) - [x] Error case tests (invalid shapes, invalid activation combinations) - [x] CUDA tests --------- Signed-off-by: Soumya Snigdha Kundu <soumya_snigdha.kundu@kcl.ac.uk> Signed-off-by: Soumya Snigdha Kundu <soumyawork15@gmail.com> Co-authored-by: Eric Kerfoot <17726042+ericspod@users.noreply.github.com>
1 parent 4f09ed6 commit a6b672a

2 files changed

Lines changed: 369 additions & 76 deletions

File tree

monai/losses/cldice.py

Lines changed: 247 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,18 @@
1111

1212
from __future__ import annotations
1313

14+
import warnings
15+
from collections.abc import Callable
16+
1417
import torch
1518
import torch.nn.functional as F
1619
from torch.nn.modules.loss import _Loss
1720

21+
from monai.losses.dice import DiceLoss
22+
from monai.networks import one_hot
23+
from monai.utils import LossReduction
24+
from monai.utils.deprecate_utils import deprecated_arg
25+
1826

1927
def soft_erode(img: torch.Tensor) -> torch.Tensor: # type: ignore
2028
"""
@@ -92,26 +100,6 @@ def soft_skel(img: torch.Tensor, iter_: int) -> torch.Tensor:
92100
return skel
93101

94102

95-
def soft_dice(y_true: torch.Tensor, y_pred: torch.Tensor, smooth: float = 1.0) -> torch.Tensor:
96-
"""
97-
Function to compute soft dice loss
98-
99-
Adapted from:
100-
https://github.com/jocpae/clDice/blob/master/cldice_loss/pytorch/cldice.py#L22
101-
102-
Args:
103-
y_true: the shape should be BCH(WD)
104-
y_pred: the shape should be BCH(WD)
105-
106-
Returns:
107-
dice loss
108-
"""
109-
intersection = torch.sum((y_true * y_pred)[:, 1:, ...])
110-
coeff = (2.0 * intersection + smooth) / (torch.sum(y_true[:, 1:, ...]) + torch.sum(y_pred[:, 1:, ...]) + smooth)
111-
soft_dice: torch.Tensor = 1.0 - coeff
112-
return soft_dice
113-
114-
115103
class SoftclDiceLoss(_Loss):
116104
"""
117105
Compute the Soft clDice loss defined in:
@@ -121,64 +109,269 @@ class SoftclDiceLoss(_Loss):
121109
122110
Adapted from:
123111
https://github.com/jocpae/clDice/blob/master/cldice_loss/pytorch/cldice.py#L7
112+
113+
The data `input` (BNHW[D] where N is number of classes) is compared with ground truth `target` (BNHW[D]).
114+
Note that axis N of `input` is expected to be logits or probabilities for each class, if passing logits as input,
115+
must set `sigmoid=True` or `softmax=True`, or specifying `other_act`. And the same axis of `target`
116+
can be 1 or N (one-hot format).
117+
124118
"""
125119

126-
def __init__(self, iter_: int = 3, smooth: float = 1.0) -> None:
120+
def __init__(
121+
self,
122+
iter_: int = 3,
123+
smooth_nr: float = 1.0,
124+
smooth_dr: float = 1.0,
125+
smooth: float = 1e-4,
126+
include_background: bool = True,
127+
to_onehot_y: bool = False,
128+
sigmoid: bool = False,
129+
softmax: bool = False,
130+
other_act: Callable | None = None,
131+
reduction: LossReduction | str = LossReduction.MEAN,
132+
) -> None:
127133
"""
128134
Args:
129-
iter_: Number of iterations for skeletonization. Defaults to 3.
130-
smooth: Smoothing parameter. Defaults to 1.0.
135+
iter_: Number of iterations for skeletonization. Must be a non-negative integer. Defaults to 3.
136+
smooth_nr: a small constant added to the numerator to avoid zero. Defaults to 1.0.
137+
smooth_dr: a small constant added to the denominator to avoid nan. Defaults to 1.0.
138+
smooth: a small constant added to the denominator of the harmonic mean to avoid nan. Defaults to 1e-4.
139+
include_background: if False, channel index 0 (background category) is excluded from the calculation.
140+
if the non-background segmentations are small compared to the total image size they can get overwhelmed
141+
by the signal from the background so excluding it in such cases helps convergence.
142+
to_onehot_y: whether to convert the ``target`` into the one-hot format,
143+
using the number of classes inferred from `input` (``input.shape[1]``). Defaults to False.
144+
sigmoid: if True, apply a sigmoid function to the prediction.
145+
softmax: if True, apply a softmax function to the prediction.
146+
other_act: callable function to execute other activation layers, Defaults to ``None``. for example:
147+
``other_act = torch.tanh``.
148+
reduction: {``"none"``, ``"mean"``, ``"sum"``}
149+
Specifies the reduction to apply to the output. Defaults to ``"mean"``.
150+
151+
- ``"none"``: no reduction will be applied.
152+
- ``"mean"``: the sum of the output will be divided by the number of elements in the output.
153+
- ``"sum"``: the output will be summed.
154+
155+
Raises:
156+
TypeError: When ``other_act`` is not an ``Optional[Callable]``.
157+
TypeError: When ``iter_`` is not an ``int``.
158+
ValueError: When ``iter_`` is a negative integer.
159+
ValueError: When ``smooth`` is not a positive value.
160+
ValueError: When more than 1 of [``sigmoid=True``, ``softmax=True``, ``other_act is not None``].
161+
Incompatible values.
162+
131163
"""
132-
super().__init__()
164+
super().__init__(reduction=LossReduction(reduction).value)
165+
if other_act is not None and not callable(other_act):
166+
raise TypeError(f"other_act must be None or callable but is {type(other_act).__name__}.")
167+
if int(sigmoid) + int(softmax) + int(other_act is not None) > 1:
168+
raise ValueError("Incompatible values: more than 1 of [sigmoid=True, softmax=True, other_act is not None].")
169+
if not isinstance(iter_, int):
170+
raise TypeError(f"iter_ must be an integer but got {type(iter_).__name__}.")
171+
if iter_ < 0:
172+
raise ValueError(f"iter_ must be a non-negative integer but got {iter_}.")
173+
if smooth <= 0:
174+
raise ValueError(f"smooth must be a positive value but got {smooth}.")
133175
self.iter = iter_
134-
self.smooth = smooth
176+
self.smooth_nr = float(smooth_nr)
177+
self.smooth_dr = float(smooth_dr)
178+
self.smooth = float(smooth)
179+
self.include_background = include_background
180+
self.to_onehot_y = to_onehot_y
181+
self.sigmoid = sigmoid
182+
self.softmax = softmax
183+
self.other_act = other_act
184+
185+
@deprecated_arg("y_pred", since="1.5", removed="1.8", new_name="input", msg_suffix="please use `input` instead.")
186+
@deprecated_arg("y_true", since="1.5", removed="1.8", new_name="target", msg_suffix="please use `target` instead.")
187+
def forward(self, input: torch.Tensor, target: torch.Tensor) -> torch.Tensor:
188+
"""
189+
Args:
190+
input: the shape should be BNH[WD], where N is the number of classes.
191+
target: the shape should be BNH[WD] or B1H[WD], where N is the number of classes.
192+
193+
Raises:
194+
AssertionError: When input and target (after one hot transform if set)
195+
have different shapes.
196+
197+
"""
198+
n_pred_ch = input.shape[1]
199+
200+
if self.sigmoid:
201+
input = torch.sigmoid(input)
202+
203+
if self.softmax:
204+
if n_pred_ch == 1:
205+
warnings.warn("single channel prediction, `softmax=True` ignored.", stacklevel=2)
206+
else:
207+
input = torch.softmax(input, dim=1)
135208

136-
def forward(self, y_true: torch.Tensor, y_pred: torch.Tensor) -> torch.Tensor:
137-
skel_pred = soft_skel(y_pred, self.iter)
138-
skel_true = soft_skel(y_true, self.iter)
139-
tprec = (torch.sum(torch.multiply(skel_pred, y_true)[:, 1:, ...]) + self.smooth) / (
140-
torch.sum(skel_pred[:, 1:, ...]) + self.smooth
209+
if self.other_act is not None:
210+
input = self.other_act(input)
211+
212+
if self.to_onehot_y:
213+
if n_pred_ch == 1:
214+
warnings.warn("single channel prediction, `to_onehot_y=True` ignored.", stacklevel=2)
215+
else:
216+
target = one_hot(target, num_classes=n_pred_ch)
217+
218+
if not self.include_background:
219+
if n_pred_ch == 1:
220+
warnings.warn("single channel prediction, `include_background=False` ignored.", stacklevel=2)
221+
else:
222+
target = target[:, 1:]
223+
input = input[:, 1:]
224+
225+
if target.shape != input.shape:
226+
raise AssertionError(f"ground truth has different shape ({target.shape}) from input ({input.shape})")
227+
228+
skel_pred = soft_skel(input, self.iter)
229+
skel_true = soft_skel(target, self.iter)
230+
231+
# Compute per-batch clDice by reducing over channel and spatial dimensions
232+
# reduce_axis includes all dimensions except batch (dim 0)
233+
reduce_axis: list[int] = list(range(1, len(input.shape)))
234+
235+
tprec = (torch.sum(torch.multiply(skel_pred, target), dim=reduce_axis) + self.smooth_nr) / (
236+
torch.sum(skel_pred, dim=reduce_axis) + self.smooth_dr
141237
)
142-
tsens = (torch.sum(torch.multiply(skel_true, y_pred)[:, 1:, ...]) + self.smooth) / (
143-
torch.sum(skel_true[:, 1:, ...]) + self.smooth
238+
tsens = (torch.sum(torch.multiply(skel_true, input), dim=reduce_axis) + self.smooth_nr) / (
239+
torch.sum(skel_true, dim=reduce_axis) + self.smooth_dr
144240
)
145-
cl_dice: torch.Tensor = 1.0 - 2.0 * (tprec * tsens) / (tprec + tsens)
241+
# Add small epsilon for numerical stability in harmonic mean
242+
cl_dice: torch.Tensor = 1.0 - 2.0 * (tprec * tsens) / (tprec + tsens + self.smooth)
243+
244+
# Apply reduction
245+
if self.reduction == LossReduction.MEAN.value:
246+
cl_dice = torch.mean(cl_dice)
247+
elif self.reduction == LossReduction.SUM.value:
248+
cl_dice = torch.sum(cl_dice)
249+
elif self.reduction == LossReduction.NONE.value:
250+
pass # keep per-batch values
251+
else:
252+
raise ValueError(f'Unsupported reduction: {self.reduction}, available options are ["mean", "sum", "none"].')
253+
146254
return cl_dice
147255

148256

149257
class SoftDiceclDiceLoss(_Loss):
150258
"""
151-
Compute the Soft clDice loss defined in:
259+
Compute both Dice loss and clDice loss, and return the weighted sum of these two losses.
260+
The details of Dice loss is shown in ``monai.losses.DiceLoss``.
261+
The details of clDice loss is shown in ``monai.losses.SoftclDiceLoss``.
152262
263+
Adapted from:
153264
Shit et al. (2021) clDice -- A Novel Topology-Preserving Loss Function
154265
for Tubular Structure Segmentation. (https://arxiv.org/abs/2003.07311)
155266
156-
Adapted from:
157-
https://github.com/jocpae/clDice/blob/master/cldice_loss/pytorch/cldice.py#L38
158267
"""
159268

160-
def __init__(self, iter_: int = 3, alpha: float = 0.5, smooth: float = 1.0) -> None:
269+
def __init__(
270+
self,
271+
iter_: int = 3,
272+
alpha: float = 0.5,
273+
smooth_nr: float = 1.0,
274+
smooth_dr: float = 1.0,
275+
smooth: float = 1e-4,
276+
include_background: bool = True,
277+
to_onehot_y: bool = False,
278+
sigmoid: bool = False,
279+
softmax: bool = False,
280+
other_act: Callable | None = None,
281+
reduction: LossReduction | str = LossReduction.MEAN,
282+
) -> None:
161283
"""
162284
Args:
163-
iter_: Number of iterations for skeletonization. Defaults to 3.
164-
alpha: Weighing factor for cldice. Defaults to 0.5.
165-
smooth: Smoothing parameter. Defaults to 1.0.
285+
iter_: Number of iterations for skeletonization, used by clDice. Must be a non-negative integer. Defaults to 3.
286+
alpha: Weighing factor for cldice component. Total loss = (1 - alpha) * dice + alpha * cldice.
287+
Defaults to 0.5.
288+
smooth_nr: a small constant added to the numerator to avoid zero, used by both Dice and clDice. Defaults to 1.0.
289+
smooth_dr: a small constant added to the denominator to avoid nan, used by both Dice and clDice. Defaults to 1.0.
290+
smooth: a small constant added to the denominator of the harmonic mean in clDice to avoid nan.
291+
Defaults to 1e-4. Note: This differs from standalone DiceLoss defaults (1e-5) to follow clDice convention.
292+
include_background: if False, channel index 0 (background category) is excluded from the calculation.
293+
if the non-background segmentations are small compared to the total image size they can get overwhelmed
294+
by the signal from the background so excluding it in such cases helps convergence.
295+
to_onehot_y: whether to convert the ``target`` into the one-hot format,
296+
using the number of classes inferred from `input` (``input.shape[1]``). Defaults to False.
297+
sigmoid: if True, apply a sigmoid function to the prediction.
298+
softmax: if True, apply a softmax function to the prediction.
299+
other_act: callable function to execute other activation layers, Defaults to ``None``. for example:
300+
``other_act = torch.tanh``.
301+
reduction: {``"none"``, ``"mean"``, ``"sum"``}
302+
Specifies the reduction to apply to the output. Defaults to ``"mean"``.
303+
304+
- ``"none"``: no reduction will be applied.
305+
- ``"mean"``: the sum of the output will be divided by the number of elements in the output.
306+
- ``"sum"``: the output will be summed.
307+
308+
Raises:
309+
TypeError: When ``other_act`` is not an ``Optional[Callable]``.
310+
ValueError: When ``alpha`` is not in ``[0, 1]``.
311+
ValueError: When more than 1 of [``sigmoid=True``, ``softmax=True``, ``other_act is not None``].
312+
Incompatible values.
313+
166314
"""
167315
super().__init__()
168-
self.iter = iter_
169-
self.smooth = smooth
170-
self.alpha = alpha
171-
172-
def forward(self, y_true: torch.Tensor, y_pred: torch.Tensor) -> torch.Tensor:
173-
dice = soft_dice(y_true, y_pred, self.smooth)
174-
skel_pred = soft_skel(y_pred, self.iter)
175-
skel_true = soft_skel(y_true, self.iter)
176-
tprec = (torch.sum(torch.multiply(skel_pred, y_true)[:, 1:, ...]) + self.smooth) / (
177-
torch.sum(skel_pred[:, 1:, ...]) + self.smooth
316+
if not 0.0 <= alpha <= 1.0:
317+
raise ValueError(f"alpha must be in [0, 1] but got {alpha}.")
318+
self.dice = DiceLoss(
319+
include_background=include_background,
320+
to_onehot_y=False,
321+
sigmoid=sigmoid,
322+
softmax=softmax,
323+
other_act=other_act,
324+
reduction=reduction,
325+
smooth_nr=smooth_nr,
326+
smooth_dr=smooth_dr,
178327
)
179-
tsens = (torch.sum(torch.multiply(skel_true, y_pred)[:, 1:, ...]) + self.smooth) / (
180-
torch.sum(skel_true[:, 1:, ...]) + self.smooth
328+
self.cldice = SoftclDiceLoss(
329+
iter_=iter_,
330+
smooth_nr=smooth_nr,
331+
smooth_dr=smooth_dr,
332+
smooth=smooth,
333+
include_background=include_background,
334+
to_onehot_y=False,
335+
sigmoid=sigmoid,
336+
softmax=softmax,
337+
other_act=other_act,
338+
reduction=reduction,
181339
)
182-
cl_dice = 1.0 - 2.0 * (tprec * tsens) / (tprec + tsens)
183-
total_loss: torch.Tensor = (1.0 - self.alpha) * dice + self.alpha * cl_dice
340+
self.alpha = alpha
341+
self.to_onehot_y = to_onehot_y
342+
343+
@deprecated_arg("y_pred", since="1.5", removed="1.8", new_name="input", msg_suffix="please use `input` instead.")
344+
@deprecated_arg("y_true", since="1.5", removed="1.8", new_name="target", msg_suffix="please use `target` instead.")
345+
def forward(self, input: torch.Tensor, target: torch.Tensor) -> torch.Tensor:
346+
"""
347+
Args:
348+
input: the shape should be BNH[WD], where N is the number of classes.
349+
target: the shape should be BNH[WD] or B1H[WD], where N is the number of classes.
350+
351+
Raises:
352+
ValueError: When number of dimensions for input and target are different.
353+
ValueError: When number of channels for target is neither 1 nor the same as input.
354+
355+
"""
356+
if input.dim() != target.dim():
357+
raise ValueError(
358+
f"the number of dimensions for input and target should be the same, got shape {input.shape} and {target.shape}."
359+
)
360+
361+
if target.shape[1] != 1 and target.shape[1] != input.shape[1]:
362+
raise ValueError(
363+
f"number of channels for target is neither 1 nor the same as input, got shape {input.shape} and {target.shape}."
364+
)
365+
366+
if self.to_onehot_y:
367+
n_pred_ch = input.shape[1]
368+
if n_pred_ch == 1:
369+
warnings.warn("single channel prediction, `to_onehot_y=True` ignored.", stacklevel=2)
370+
else:
371+
target = one_hot(target, num_classes=n_pred_ch)
372+
373+
dice_loss = self.dice(input, target)
374+
cldice_loss = self.cldice(input, target)
375+
total_loss: torch.Tensor = (1.0 - self.alpha) * dice_loss + self.alpha * cldice_loss
376+
184377
return total_loss

0 commit comments

Comments
 (0)