Skip to content

Commit 0fc51ea

Browse files
committed
Fix #8239: Enhance SoftclDiceLoss and SoftDiceclDiceLoss with additional parameters
- Add include_background, to_onehot_y, sigmoid, softmax, other_act, and reduction parameters - Fix argument order in forward() to match other losses (y_pred, y_true) - Add proper input validation and comprehensive docstrings - These changes make the losses consistent with DiceLoss API and fix zero loss issues Signed-off-by: Soumya Snigdha Kundu <soumya_snigdha.kundu@kcl.ac.uk>
1 parent 57fdd59 commit 0fc51ea

2 files changed

Lines changed: 325 additions & 74 deletions

File tree

monai/losses/cldice.py

Lines changed: 216 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,17 @@
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+
1825

1926
def soft_erode(img: torch.Tensor) -> torch.Tensor: # type: ignore
2027
"""
@@ -92,26 +99,6 @@ def soft_skel(img: torch.Tensor, iter_: int) -> torch.Tensor:
9299
return skel
93100

94101

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-
115102
class SoftclDiceLoss(_Loss):
116103
"""
117104
Compute the Soft clDice loss defined in:
@@ -121,64 +108,241 @@ class SoftclDiceLoss(_Loss):
121108
122109
Adapted from:
123110
https://github.com/jocpae/clDice/blob/master/cldice_loss/pytorch/cldice.py#L7
111+
112+
The data `input` (BNHW[D] where N is number of classes) is compared with ground truth `target` (BNHW[D]).
113+
Note that axis N of `input` is expected to be logits or probabilities for each class, if passing logits as input,
114+
must set `sigmoid=True` or `softmax=True`, or specifying `other_act`. And the same axis of `target`
115+
can be 1 or N (one-hot format).
116+
124117
"""
125118

126-
def __init__(self, iter_: int = 3, smooth: float = 1.0) -> None:
119+
def __init__(
120+
self,
121+
iter_: int = 3,
122+
smooth: float = 1.0,
123+
include_background: bool = True,
124+
to_onehot_y: bool = False,
125+
sigmoid: bool = False,
126+
softmax: bool = False,
127+
other_act: Callable | None = None,
128+
reduction: LossReduction | str = LossReduction.MEAN,
129+
) -> None:
127130
"""
128131
Args:
129-
iter_: Number of iterations for skeletonization
130-
smooth: Smoothing parameter
132+
iter_: Number of iterations for skeletonization.
133+
smooth: Smoothing parameter.
134+
include_background: if False, channel index 0 (background category) is excluded from the calculation.
135+
if the non-background segmentations are small compared to the total image size they can get overwhelmed
136+
by the signal from the background so excluding it in such cases helps convergence.
137+
to_onehot_y: whether to convert the ``target`` into the one-hot format,
138+
using the number of classes inferred from `input` (``input.shape[1]``). Defaults to False.
139+
sigmoid: if True, apply a sigmoid function to the prediction.
140+
softmax: if True, apply a softmax function to the prediction.
141+
other_act: callable function to execute other activation layers, Defaults to ``None``. for example:
142+
``other_act = torch.tanh``.
143+
reduction: {``"none"``, ``"mean"``, ``"sum"``}
144+
Specifies the reduction to apply to the output. Defaults to ``"mean"``.
145+
146+
- ``"none"``: no reduction will be applied.
147+
- ``"mean"``: the sum of the output will be divided by the number of elements in the output.
148+
- ``"sum"``: the output will be summed.
149+
150+
Raises:
151+
TypeError: When ``other_act`` is not an ``Optional[Callable]``.
152+
ValueError: When more than 1 of [``sigmoid=True``, ``softmax=True``, ``other_act is not None``].
153+
Incompatible values.
154+
131155
"""
132-
super().__init__()
156+
super().__init__(reduction=LossReduction(reduction).value)
157+
if other_act is not None and not callable(other_act):
158+
raise TypeError(f"other_act must be None or callable but is {type(other_act).__name__}.")
159+
if int(sigmoid) + int(softmax) + int(other_act is not None) > 1:
160+
raise ValueError("Incompatible values: more than 1 of [sigmoid=True, softmax=True, other_act is not None].")
133161
self.iter = iter_
134162
self.smooth = smooth
163+
self.include_background = include_background
164+
self.to_onehot_y = to_onehot_y
165+
self.sigmoid = sigmoid
166+
self.softmax = softmax
167+
self.other_act = other_act
168+
169+
def forward(self, input: torch.Tensor, target: torch.Tensor) -> torch.Tensor:
170+
"""
171+
Args:
172+
input: the shape should be BNH[WD], where N is the number of classes.
173+
target: the shape should be BNH[WD] or B1H[WD], where N is the number of classes.
174+
175+
Raises:
176+
AssertionError: When input and target (after one hot transform if set)
177+
have different shapes.
178+
179+
"""
180+
n_pred_ch = input.shape[1]
181+
182+
if self.sigmoid:
183+
input = torch.sigmoid(input)
184+
185+
if self.softmax:
186+
if n_pred_ch == 1:
187+
warnings.warn("single channel prediction, `softmax=True` ignored.")
188+
else:
189+
input = torch.softmax(input, dim=1)
135190

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
191+
if self.other_act is not None:
192+
input = self.other_act(input)
193+
194+
if self.to_onehot_y:
195+
if n_pred_ch == 1:
196+
warnings.warn("single channel prediction, `to_onehot_y=True` ignored.")
197+
else:
198+
target = one_hot(target, num_classes=n_pred_ch)
199+
200+
if not self.include_background:
201+
if n_pred_ch == 1:
202+
warnings.warn("single channel prediction, `include_background=False` ignored.")
203+
else:
204+
target = target[:, 1:]
205+
input = input[:, 1:]
206+
207+
if target.shape != input.shape:
208+
raise AssertionError(f"ground truth has different shape ({target.shape}) from input ({input.shape})")
209+
210+
skel_pred = soft_skel(input, self.iter)
211+
skel_true = soft_skel(target, self.iter)
212+
213+
# Compute per-batch clDice by reducing over channel and spatial dimensions
214+
# reduce_axis includes all dimensions except batch (dim 0)
215+
reduce_axis: list[int] = list(range(1, len(input.shape)))
216+
217+
tprec = (torch.sum(torch.multiply(skel_pred, target), dim=reduce_axis) + self.smooth) / (
218+
torch.sum(skel_pred, dim=reduce_axis) + self.smooth
141219
)
142-
tsens = (torch.sum(torch.multiply(skel_true, y_pred)[:, 1:, ...]) + self.smooth) / (
143-
torch.sum(skel_true[:, 1:, ...]) + self.smooth
220+
tsens = (torch.sum(torch.multiply(skel_true, input), dim=reduce_axis) + self.smooth) / (
221+
torch.sum(skel_true, dim=reduce_axis) + self.smooth
144222
)
145223
cl_dice: torch.Tensor = 1.0 - 2.0 * (tprec * tsens) / (tprec + tsens)
224+
225+
# Apply reduction
226+
if self.reduction == LossReduction.MEAN.value:
227+
cl_dice = torch.mean(cl_dice)
228+
elif self.reduction == LossReduction.SUM.value:
229+
cl_dice = torch.sum(cl_dice)
230+
elif self.reduction == LossReduction.NONE.value:
231+
pass # keep per-batch values
232+
else:
233+
raise ValueError(f'Unsupported reduction: {self.reduction}, available options are ["mean", "sum", "none"].')
234+
146235
return cl_dice
147236

148237

149238
class SoftDiceclDiceLoss(_Loss):
150239
"""
151-
Compute the Soft clDice loss defined in:
240+
Compute both Dice loss and clDice loss, and return the weighted sum of these two losses.
241+
The details of Dice loss is shown in ``monai.losses.DiceLoss``.
242+
The details of clDice loss is shown in ``monai.losses.SoftclDiceLoss``.
152243
244+
Adapted from:
153245
Shit et al. (2021) clDice -- A Novel Topology-Preserving Loss Function
154246
for Tubular Structure Segmentation. (https://arxiv.org/abs/2003.07311)
155247
156-
Adapted from:
157-
https://github.com/jocpae/clDice/blob/master/cldice_loss/pytorch/cldice.py#L38
158248
"""
159249

160-
def __init__(self, iter_: int = 3, alpha: float = 0.5, smooth: float = 1.0) -> None:
250+
def __init__(
251+
self,
252+
iter_: int = 3,
253+
alpha: float = 0.5,
254+
smooth: float = 1.0,
255+
include_background: bool = True,
256+
to_onehot_y: bool = False,
257+
sigmoid: bool = False,
258+
softmax: bool = False,
259+
other_act: Callable | None = None,
260+
reduction: LossReduction | str = LossReduction.MEAN,
261+
) -> None:
161262
"""
162263
Args:
163-
iter_: Number of iterations for skeletonization
164-
smooth: Smoothing parameter
165-
alpha: Weighing factor for cldice
264+
iter_: Number of iterations for skeletonization, used by clDice.
265+
alpha: Weighing factor for cldice component. Total loss = (1 - alpha) * dice + alpha * cldice.
266+
Defaults to 0.5.
267+
smooth: Smoothing parameter, used by both Dice and clDice.
268+
include_background: if False, channel index 0 (background category) is excluded from the calculation.
269+
if the non-background segmentations are small compared to the total image size they can get overwhelmed
270+
by the signal from the background so excluding it in such cases helps convergence.
271+
to_onehot_y: whether to convert the ``target`` into the one-hot format,
272+
using the number of classes inferred from `input` (``input.shape[1]``). Defaults to False.
273+
sigmoid: if True, apply a sigmoid function to the prediction.
274+
softmax: if True, apply a softmax function to the prediction.
275+
other_act: callable function to execute other activation layers, Defaults to ``None``. for example:
276+
``other_act = torch.tanh``.
277+
reduction: {``"none"``, ``"mean"``, ``"sum"``}
278+
Specifies the reduction to apply to the output. Defaults to ``"mean"``.
279+
280+
- ``"none"``: no reduction will be applied.
281+
- ``"mean"``: the sum of the output will be divided by the number of elements in the output.
282+
- ``"sum"``: the output will be summed.
283+
284+
Raises:
285+
TypeError: When ``other_act`` is not an ``Optional[Callable]``.
286+
ValueError: When more than 1 of [``sigmoid=True``, ``softmax=True``, ``other_act is not None``].
287+
Incompatible values.
288+
166289
"""
167290
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
291+
self.dice = DiceLoss(
292+
include_background=include_background,
293+
to_onehot_y=False,
294+
sigmoid=sigmoid,
295+
softmax=softmax,
296+
other_act=other_act,
297+
reduction=reduction,
298+
smooth_nr=smooth,
299+
smooth_dr=smooth,
178300
)
179-
tsens = (torch.sum(torch.multiply(skel_true, y_pred)[:, 1:, ...]) + self.smooth) / (
180-
torch.sum(skel_true[:, 1:, ...]) + self.smooth
301+
self.cldice = SoftclDiceLoss(
302+
iter_=iter_,
303+
smooth=smooth,
304+
include_background=include_background,
305+
to_onehot_y=False,
306+
sigmoid=sigmoid,
307+
softmax=softmax,
308+
other_act=other_act,
309+
reduction=reduction,
181310
)
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
311+
self.alpha = alpha
312+
self.to_onehot_y = to_onehot_y
313+
314+
def forward(self, input: torch.Tensor, target: torch.Tensor) -> torch.Tensor:
315+
"""
316+
Args:
317+
input: the shape should be BNH[WD], where N is the number of classes.
318+
target: the shape should be BNH[WD] or B1H[WD], where N is the number of classes.
319+
320+
Raises:
321+
ValueError: When number of dimensions for input and target are different.
322+
ValueError: When number of channels for target is neither 1 nor the same as input.
323+
324+
"""
325+
if input.dim() != target.dim():
326+
raise ValueError(
327+
"the number of dimensions for input and target should be the same, "
328+
f"got shape {input.shape} and {target.shape}."
329+
)
330+
331+
if target.shape[1] != 1 and target.shape[1] != input.shape[1]:
332+
raise ValueError(
333+
"number of channels for target is neither 1 nor the same as input, "
334+
f"got shape {input.shape} and {target.shape}."
335+
)
336+
337+
if self.to_onehot_y:
338+
n_pred_ch = input.shape[1]
339+
if n_pred_ch == 1:
340+
warnings.warn("single channel prediction, `to_onehot_y=True` ignored.")
341+
else:
342+
target = one_hot(target, num_classes=n_pred_ch)
343+
344+
dice_loss = self.dice(input, target)
345+
cldice_loss = self.cldice(input, target)
346+
total_loss: torch.Tensor = (1.0 - self.alpha) * dice_loss + self.alpha * cldice_loss
347+
184348
return total_loss

0 commit comments

Comments
 (0)