Skip to content

Commit 7fdeae6

Browse files
committed
Feat: add ignore_index support to DiceLoss
Signed-off-by: qwepablo12 <grazava6@gmail.com>
1 parent d6713fd commit 7fdeae6

2 files changed

Lines changed: 76 additions & 63 deletions

File tree

monai/losses/dice.py

Lines changed: 24 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -67,45 +67,11 @@ def __init__(
6767
batch: bool = False,
6868
weight: Sequence[float] | float | int | torch.Tensor | None = None,
6969
soft_label: bool = False,
70+
ignore_index: int | None = None,
7071
) -> None:
7172
"""
72-
Args:
73-
include_background: if False, channel index 0 (background category) is excluded from the calculation.
74-
if the non-background segmentations are small compared to the total image size they can get overwhelmed
75-
by the signal from the background so excluding it in such cases helps convergence.
76-
to_onehot_y: whether to convert the ``target`` into the one-hot format,
77-
using the number of classes inferred from `input` (``input.shape[1]``). Defaults to False.
78-
sigmoid: if True, apply a sigmoid function to the prediction.
79-
softmax: if True, apply a softmax function to the prediction.
80-
other_act: callable function to execute other activation layers, Defaults to ``None``. for example:
81-
``other_act = torch.tanh``.
82-
squared_pred: use squared versions of targets and predictions in the denominator or not.
83-
jaccard: compute Jaccard Index (soft IoU) instead of dice or not.
84-
reduction: {``"none"``, ``"mean"``, ``"sum"``}
85-
Specifies the reduction to apply to the output. Defaults to ``"mean"``.
86-
87-
- ``"none"``: no reduction will be applied.
88-
- ``"mean"``: the sum of the output will be divided by the number of elements in the output.
89-
- ``"sum"``: the output will be summed.
90-
91-
smooth_nr: a small constant added to the numerator to avoid zero.
92-
smooth_dr: a small constant added to the denominator to avoid nan.
93-
batch: whether to sum the intersection and union areas over the batch dimension before the dividing.
94-
Defaults to False, a Dice loss value is computed independently from each item in the batch
95-
before any `reduction`.
96-
weight: weights to apply to the voxels of each class. If None no weights are applied.
97-
The input can be a single value (same weight for all classes), a sequence of values (the length
98-
of the sequence should be the same as the number of classes. If not ``include_background``,
99-
the number of classes should not include the background category class 0).
100-
The value/values should be no less than 0. Defaults to None.
101-
soft_label: whether the target contains non-binary values (soft labels) or not.
102-
If True a soft label formulation of the loss will be used.
103-
104-
Raises:
105-
TypeError: When ``other_act`` is not an ``Optional[Callable]``.
106-
ValueError: When more than 1 of [``sigmoid=True``, ``softmax=True``, ``other_act is not None``].
107-
Incompatible values.
108-
73+
Args follow standard MONAI DiceLoss with the addition of:
74+
ignore_index: Specifies a target value that is ignored and does not contribute to the input gradient.
10975
"""
11076
super().__init__(reduction=LossReduction(reduction).value)
11177
if other_act is not None and not callable(other_act):
@@ -126,29 +92,13 @@ def __init__(
12692
self.register_buffer("class_weight", weight)
12793
self.class_weight: None | torch.Tensor
12894
self.soft_label = soft_label
95+
self.ignore_index = ignore_index
12996

13097
def forward(self, input: torch.Tensor, target: torch.Tensor) -> torch.Tensor:
13198
"""
13299
Args:
133100
input: the shape should be BNH[WD], where N is the number of classes.
134101
target: the shape should be BNH[WD] or B1H[WD], where N is the number of classes.
135-
136-
Raises:
137-
AssertionError: When input and target (after one hot transform if set)
138-
have different shapes.
139-
ValueError: When ``self.reduction`` is not one of ["mean", "sum", "none"].
140-
141-
Example:
142-
>>> from monai.losses.dice import * # NOQA
143-
>>> import torch
144-
>>> from monai.losses.dice import DiceLoss
145-
>>> B, C, H, W = 7, 5, 3, 2
146-
>>> input = torch.rand(B, C, H, W)
147-
>>> target_idx = torch.randint(low=0, high=C - 1, size=(B, H, W)).long()
148-
>>> target = one_hot(target_idx[:, None, ...], num_classes=C)
149-
>>> self = DiceLoss(reduction='none')
150-
>>> loss = self(input, target)
151-
>>> assert np.broadcast_shapes(loss.shape, input.shape) == input.shape
152102
"""
153103
if self.sigmoid:
154104
input = torch.sigmoid(input)
@@ -163,6 +113,17 @@ def forward(self, input: torch.Tensor, target: torch.Tensor) -> torch.Tensor:
163113
if self.other_act is not None:
164114
input = self.other_act(input)
165115

116+
# --- UPDATED MASKING LOGIC ---
117+
valid_mask = None
118+
if self.ignore_index is not None:
119+
if target.shape[1] == 1:
120+
# Target is already in index format (B1HW)
121+
valid_mask = (target != self.ignore_index).to(input.dtype)
122+
else:
123+
# Target is in one-hot format (BNHW), extract indices using argmax
124+
target_idx = target.argmax(dim=1, keepdim=True)
125+
valid_mask = (target_idx != self.ignore_index).to(input.dtype)
126+
166127
if self.to_onehot_y:
167128
if n_pred_ch == 1:
168129
warnings.warn("single channel prediction, `to_onehot_y=True` ignored.", stacklevel=2)
@@ -173,17 +134,21 @@ def forward(self, input: torch.Tensor, target: torch.Tensor) -> torch.Tensor:
173134
if n_pred_ch == 1:
174135
warnings.warn("single channel prediction, `include_background=False` ignored.", stacklevel=2)
175136
else:
176-
# if skipping background, removing first channel
177137
target = target[:, 1:]
178138
input = input[:, 1:]
139+
# Background exclusion does not affect our spatial valid_mask (B1HW)
179140

180141
if target.shape != input.shape:
181142
raise AssertionError(f"ground truth has different shape ({target.shape}) from input ({input.shape})")
182143

183-
# reducing only spatial dimensions (not batch nor channels)
144+
# Apply spatial mask across all channels using broadcasting
145+
if valid_mask is not None:
146+
input = input * valid_mask
147+
target = target * valid_mask
148+
# ------------------------------
149+
184150
reduce_axis: list[int] = torch.arange(2, len(input.shape)).tolist()
185151
if self.batch:
186-
# reducing spatial dimensions and batch
187152
reduce_axis = [0] + reduce_axis
188153

189154
ord = 2 if self.squared_pred else 1
@@ -198,7 +163,6 @@ def forward(self, input: torch.Tensor, target: torch.Tensor) -> torch.Tensor:
198163

199164
num_of_classes = target.shape[1]
200165
if self.class_weight is not None and num_of_classes != 1:
201-
# make sure the lengths of weights are equal to the number of classes
202166
if self.class_weight.ndim == 0:
203167
self.class_weight = torch.as_tensor([self.class_weight] * num_of_classes)
204168
else:
@@ -209,16 +173,13 @@ def forward(self, input: torch.Tensor, target: torch.Tensor) -> torch.Tensor:
209173
)
210174
if self.class_weight.min() < 0:
211175
raise ValueError("the value/values of the `weight` should be no less than 0.")
212-
# apply class_weight to loss
213176
f = f * self.class_weight.to(f)
214177

215178
if self.reduction == LossReduction.MEAN.value:
216-
f = torch.mean(f) # the batch and channel average
179+
f = torch.mean(f)
217180
elif self.reduction == LossReduction.SUM.value:
218-
f = torch.sum(f) # sum over the batch and channel dims
181+
f = torch.sum(f)
219182
elif self.reduction == LossReduction.NONE.value:
220-
# If we are not computing voxelwise loss components at least
221-
# make sure a none reduction maintains a broadcastable shape
222183
broadcast_shape = list(f.shape[0:2]) + [1] * (len(input.shape) - 2)
223184
f = f.view(broadcast_shape)
224185
else:

tests/losses/test_dice_loss.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,5 +218,57 @@ def test_script(self):
218218
test_script_save(loss, test_input, test_input)
219219

220220

221+
class TestDiceLossIgnoreIndex(unittest.TestCase):
222+
def test_ignore_index_index_target(self):
223+
# B=1, C=3, H=2, W=2
224+
input_tensor = torch.log(torch.tensor([[[[0.8, 0.1], [0.1, 0.1]],
225+
[[0.1, 0.8], [0.1, 0.1]],
226+
[[0.1, 0.1], [0.8, 0.8]]]])) # Shape: (1, 3, 2, 2)
227+
228+
# Target with ignore_index=2 at the bottom row
229+
target_idx = torch.tensor([[[0, 1],
230+
[2, 2]]]) # Shape: (1, 1, 2, 2)
231+
232+
# Loss with ignore_index=2
233+
loss_fn = DiceLoss(to_onehot_y=True, softmax=True, ignore_index=2, reduction="mean")
234+
loss = loss_fn(input_tensor, target_idx)
235+
236+
# Target without ignored regions (only top row evaluated)
237+
input_crop = input_tensor[:, :, :1, :]
238+
target_crop = target_idx[:, :, :1, :]
239+
loss_fn_crop = DiceLoss(to_onehot_y=True, softmax=True, reduction="mean")
240+
expected_loss = loss_fn_crop(input_crop, target_crop)
241+
242+
self.assertTrue(torch.allclose(loss, expected_loss))
243+
244+
def test_ignore_index_one_hot_target(self):
245+
input_tensor = torch.randn(2, 4, 8, 8)
246+
target_idx = torch.randint(0, 4, (2, 1, 8, 8))
247+
target_onehot = one_hot(target_idx, num_classes=4)
248+
249+
loss_fn = DiceLoss(softmax=True, ignore_index=1)
250+
loss_from_onehot = loss_fn(input_tensor, target_onehot)
251+
252+
loss_fn_idx = DiceLoss(to_onehot_y=True, softmax=True, ignore_index=1)
253+
loss_from_idx = loss_fn_idx(input_tensor, target_idx)
254+
255+
self.assertTrue(torch.allclose(loss_from_onehot, loss_from_idx))
256+
257+
def test_gradients_ignored(self):
258+
input_tensor = torch.randn(1, 3, 4, 4, requires_grad=True)
259+
# Set all targets in the bottom half to ignore_index=2
260+
target_idx = torch.tensor([[[0, 1, 0, 1],
261+
[1, 0, 1, 0],
262+
[2, 2, 2, 2],
263+
[2, 2, 2, 2]]])
264+
265+
loss_fn = DiceLoss(to_onehot_y=True, softmax=True, ignore_index=2)
266+
loss = loss_fn(input_tensor, target_idx)
267+
loss.backward()
268+
269+
# Gradients for the pixels with ignore_index=2 must be exactly 0
270+
ignored_grads = input_tensor.grad[:, :, 2:, :]
271+
self.assertTrue(torch.all(ignored_grads == 0.0))
272+
221273
if __name__ == "__main__":
222274
unittest.main()

0 commit comments

Comments
 (0)