Skip to content

feat(losses/metrics): implement ignore_index support across dice and …#8757

Open
Rusheel86 wants to merge 18 commits intoProject-MONAI:devfrom
Rusheel86:feat-ignore-index-support
Open

feat(losses/metrics): implement ignore_index support across dice and …#8757
Rusheel86 wants to merge 18 commits intoProject-MONAI:devfrom
Rusheel86:feat-ignore-index-support

Conversation

@Rusheel86
Copy link

@Rusheel86 Rusheel86 commented Feb 25, 2026

Fixes #8734

Description

This PR introduces comprehensive native support for ignore_index in core MONAI losses and metrics, as requested in issue #8734. The ignore_index parameter allows specific label values (e.g., padding, unlabeled regions, or boundary artifacts) to be excluded from loss and metric calculations which is a critical feature for medical imaging workflows.

Implementation Summary

Losses with ignore_index support:

  • DiceLoss - masks ignored voxels before dice computation
  • FocalLoss - applies masking after one-hot conversion
  • TverskyLoss - consistent masking approach with DiceLoss
  • UnifiedFocalLoss - handles ignore_index in binary target conversion

Metrics with ignore_index support:

  • MeanDice & GeneralizedDiceScore - spatial masking before computation
  • MeanIoU - excludes ignored classes from IoU calculation
  • ConfusionMatrixMetric - filters ignored indices from confusion matrix
  • HausdorffDistanceMetric - masks boundary computation
  • SurfaceDiceMetric & SurfaceDistanceMetric - handles ignore_index in surface calculations

Key Fixes:

  • Added support for ignore_index
  • Added comprehensive test coverage in test_ignore_index_losses.py and test_ignore_index_metrics.py

Types of changes

  • Non-breaking change (fix or new feature that would not break existing functionality)
  • Breaking change (fix or new feature that would cause existing functionality to change)
  • New tests added to cover the changes (test_ignore_index_losses.py, test_ignore_index_metrics.py)
  • Integration tests passed locally by running ./runtests.sh -f -u --net --coverage
  • Quick tests passed locally by running ./runtests.sh --quick --unittests --disttests
  • In-line docstrings updated
  • Documentation updated, tested make html command in the docs/ folder

Related Issues

Could merge with or addresses similar requirements as #8667 (Ignore Class)

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 25, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds an optional ignore_index: int | None parameter to many loss and metric classes (DiceLoss, FocalLoss, TverskyLoss, unified focal losses, DiceMetric, MeanIoU, GeneralizedDiceScore, ConfusionMatrixMetric, Hausdorff/SurfaceDistance/SurfaceDice) and related helpers. Each constructor stores self.ignore_index and forward/compute paths build per-batch/per-voxel masks (channel-aware or sentinel-aware) to mask input/target before reductions. Utilities in metrics/utils (ignore_index_mask, mask-aware edge/distance routines) were introduced/extended. Tests for losses and metrics validating ignore_index behavior were added.

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 74.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed Title clearly summarizes the main feature: implementing ignore_index support across losses and metrics. Concise and directly reflects the changeset.
Description check ✅ Passed Description covers objectives, implementation summary, affected losses/metrics, key fixes, test coverage, and types of changes. Well-structured against template requirements.
Linked Issues check ✅ Passed PR addresses issue #8734 objectives: adds ignore_index parameter to losses and metrics, masks ignored voxels from loss/metric calculations, supports binary and multi-class segmentation, maintains reduction modes and utilities.
Out of Scope Changes check ✅ Passed All changes focus on implementing ignore_index support: parameter additions to loss/metric constructors, masking logic in forward/compute methods, helper utilities, and comprehensive tests. No unrelated modifications detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Tip

You can get early access to new features in CodeRabbit.

Enable the early_access setting to enable early access features such as new models, tools, and more.

@Rusheel86 Rusheel86 force-pushed the feat-ignore-index-support branch 6 times, most recently from 7177b8d to b74ef93 Compare March 1, 2026 18:08
…ated unit tests

Signed-off-by: Rusheel Sharma <rusheelhere@gmail.com>
Signed-off-by: Rusheel Sharma <rusheelhere@gmail.com>
Signed-off-by: Rusheel Sharma <rusheelhere@gmail.com>
@Rusheel86 Rusheel86 force-pushed the feat-ignore-index-support branch from b74ef93 to f2caaf8 Compare March 9, 2026 14:43
Signed-off-by: Rusheel Sharma <rusheelhere@gmail.com>
I, Rusheel Sharma <rusheelhere@gmail.com>, hereby add my Signed-off-by to this commit: d075009

Signed-off-by: Rusheel Sharma <rusheelhere@gmail.com>
Signed-off-by: Rusheel Sharma <rusheelhere@gmail.com>
Signed-off-by: Rusheel Sharma <rusheelhere@gmail.com>
Signed-off-by: Rusheel Sharma <rusheelhere@gmail.com>
@Rusheel86 Rusheel86 marked this pull request as ready for review March 10, 2026 17:29
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 14

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (5)
monai/losses/focal_loss.py (1)

150-154: ⚠️ Potential issue | 🔴 Critical

ignore_index is derived from the wrong target representation.

The comparison happens after any one_hot conversion, so it only works for sentinel-filled tensors. For documented one-hot targets, class IDs are gone and ignore_index never identifies the ignored voxels. Keep the pre-conversion labels for masking, and fall back to the ignored class channel when the target is already one-hot.

Also applies to: 167-170

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@monai/losses/focal_loss.py` around lines 150 - 154, The ignore_index mask is
being computed from the post-conversion target (after one_hot), so when users
pass one-hot targets the class IDs are lost and ignored voxels aren’t detected;
fix by saving the original target (e.g., orig_target = target) before calling
one_hot in the method where self.to_onehot_y is handled, compute the ignore mask
from orig_target when it contains integer class labels, and if the supplied
target is already one-hot derive the mask from the ignore_index channel (e.g.,
orig_target[..., ignore_index] or equivalent); apply the same change to the
second occurrence of the logic (lines matching the 167-170 block) so both paths
correctly detect ignored voxels regardless of whether target was converted or
already one-hot.
monai/losses/dice.py (1)

169-177: ⚠️ Potential issue | 🔴 Critical

Build the ignore mask from a sanitized label view.

At Line 171 the mask is based on tensor values, so it only works for single-channel sentinel maps. For documented one-hot targets, ignore_index never matches class membership, and with include_background=False the C-channel mask no longer broadcasts after Lines 183-185 slice input and target. Also sanitize ignored labels before Line 177’s one_hot.

Also applies to: 179-192

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@monai/losses/dice.py` around lines 169 - 177, Build the ignore mask from a
sanitized label view before converting to one-hot: compute a boolean label_mask
= (target != self.ignore_index) (or all-True if ignore_index is None), then if
self.to_onehot_y is True sanitize target for one_hot by copying target and
setting ignored positions to a valid class (e.g., 0) before calling one_hot;
after one_hot, expand/reshape label_mask into the channel dimension to match
target/prediction (e.g., mask =
label_mask.unsqueeze(1).expand_as(target).float()) so the mask works for both
single-channel sentinel maps and multi-channel one-hot targets and still
broadcasts correctly when include_background slicing happens (apply same mask
generation in the later block covering lines ~179-192).
monai/metrics/utils.py (3)

316-326: ⚠️ Potential issue | 🟡 Minor

ignore_index parameter unused in get_edge_surface_distance.

Parameter at line 325 is accepted but not forwarded to get_mask_edges at line 355 or used elsewhere in the function.

🛠️ Forward ignore_index to get_mask_edges
-    edge_results = get_mask_edges(y_pred, y, crop=True, spacing=edges_spacing, always_return_as_numpy=False)
+    edge_results = get_mask_edges(y_pred, y, crop=True, spacing=edges_spacing, always_return_as_numpy=False, ignore_index=ignore_index)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@monai/metrics/utils.py` around lines 316 - 326, The get_edge_surface_distance
function accepts ignore_index but never forwards it to get_mask_edges or uses
it; update get_edge_surface_distance to pass the ignore_index argument through
when calling get_mask_edges (the call site around get_mask_edges in
get_edge_surface_distance) so masked edge computation respects ignore_index, and
ensure any signatures (get_mask_edges invocation) and downstream uses handle a
None value appropriately.

265-271: ⚠️ Potential issue | 🟡 Minor

mask parameter added but unused.

The mask parameter at line 270 is not used in get_surface_distance. The function computes distances based on edges but doesn't apply the mask. Remove or implement.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@monai/metrics/utils.py` around lines 265 - 271, get_surface_distance
currently accepts a mask parameter but never uses it; either remove the
parameter or apply it to limit where surface distances are computed. To fix,
update get_surface_distance so that before computing edges (from seg_pred and
seg_gt) you validate and cast mask to a boolean array matching seg_pred/seg_gt
shape and then apply it to zero-out/ignore voxels outside the mask (e.g., mask
the inputs seg_pred and seg_gt or filter edge point sets) so that subsequent
edge extraction and distance calculations only consider masked regions; keep the
existing logic for distance_metric and spacing unchanged and ensure mask
handling is documented in the function signature and tests.

161-169: ⚠️ Potential issue | 🟡 Minor

ignore_index parameter is unused and undocumented.

The parameter at line 168 is never referenced in the function body and missing from the docstring's Args section. Either implement the functionality and document it, or remove the parameter to keep the API honest.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@monai/metrics/utils.py` around lines 161 - 169, The ignore_index parameter in
get_mask_edges is never used and not documented; remove it from the function
signature and any related type hints, delete its mention in the docstring Args
section, and update any call sites/tests that pass ignore_index to stop
supplying it; alternatively if you prefer keeping behavior, implement logic
inside get_mask_edges to mask out label==ignore_index from seg_gt/seg_pred
before processing and document the parameter in the docstring—choose one
approach and make matching changes to callers and tests for consistency.
🧹 Nitpick comments (2)
monai/metrics/hausdorff_distance.py (1)

199-207: Redundant masking when both ignore_index and mask are used.

At line 105-107, y_pred and y are already masked. Then at lines 200-202, yp and yt are masked again with valid_mask. The mask[b, 0] is also passed to get_edge_surface_distance at line 215. This triple application may be intentional for robustness but adds overhead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@monai/metrics/hausdorff_distance.py` around lines 199 - 207, The code
redundantly reapplies masking: y_pred/y are already masked earlier (mask[b, 0])
yet yp/yt are masked again with valid_mask before computing distances and the
same mask is later passed into get_edge_surface_distance; remove the duplicated
masking to avoid overhead by eliminating the yp = yp * valid_mask and yt = yt *
valid_mask lines (or skip the earlier mask and only apply a single, consistent
mask) and ensure the mask passed to get_edge_surface_distance is the same single
mask used to prepare yp/yt; update references in the loop that set hd[b, c] and
the early all-ignored check to use that single mask (valid_mask or mask[b, 0])
so masking occurs exactly once.
monai/metrics/generalized_dice.py (1)

198-204: Per-batch loop for inf replacement is functional but could be vectorized.

Works correctly but iterating per batch may be slower for large batches.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@monai/metrics/generalized_dice.py` around lines 198 - 204, Loop over batches
replacing infs in w_full is correct but can be vectorized: compute inf_mask =
torch.isinf(w_full), create temp = w_full.masked_fill(inf_mask, 0), compute
per-batch max_w = temp.view(w_full.shape[0], -1).max(dim=1).values, build
per-batch replacement values = torch.where(max_w > 0, max_w, torch.tensor(1.0,
device=w_full.device)), expand/broadcast those replacements to the shape of
w_full and assign them into w_full at inf_mask; update the code replacing the
per-batch for-loop (variables: w_full, batch_w, infs) with this vectorized
sequence so behavior remains identical.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@monai/losses/focal_loss.py`:
- Around line 167-171: The current implementation applies the ignore mask to
logits/targets (input/target) which still yields a nonzero BCE/focal term for
masked entries; instead, compute the elementwise focal/BCE loss normally (in the
sigmoid and other paths) and then multiply that per-element loss by mask to zero
out ignored voxels, and when reducing use sum(loss * mask) / mask.sum() (or
clamp minimal denom) so ignored elements don't contribute to numerator or
denominator; update the forward method in FocalLoss (and the other masked blocks
referenced around the sigmoid branch and the regions you noted at 183-185 and
207-218) to apply mask to the computed loss tensor and perform masked reduction
rather than masking input/target or using .mean() over masked values.

In `@monai/losses/tversky.py`:
- Around line 135-136: original_target is preserved but one_hot is called on the
raw target which can contain out-of-range sentinel values (e.g., 255) causing
one_hot to fail; before calling one_hot(target, num_classes=n_pred_ch) replace
ignored/sentinel labels in target with a valid class id (e.g., 0 or another safe
class index) then call one_hot using that sanitized target, and continue to
derive the ignore mask from original_target (not the sanitized target) when
constructing the mask and applying it in the subsequent loss logic (refer to
original_target, target, one_hot, n_pred_ch, and the mask construction used in
the block around lines 138-148).

In `@monai/losses/unified_focal_loss.py`:
- Around line 167-172: The mean-reduction branch incorrectly divides loss.sum()
by spatial_mask.sum(), undercounting channels (loss has shape [B,2,H,W]); update
the normalization to account for both channel elements by either applying the
spatial mask to loss (e.g., expand spatial_mask to match loss with
spatial_mask.unsqueeze(1).expand_as(loss)), summing the masked loss and dividing
by the masked-element count, or divide loss.sum() by (spatial_mask.sum() *
loss.size(1)). Make the change in the block handling self.reduction ==
LossReduction.MEAN.value and self.ignore_index is not None (affecting variables
loss, spatial_mask, back_ce, fore_ce).
- Around line 71-77: The current mask uses (torch.sum(y_true, dim=1) > 0) which
does not respect ignore_index; update the ignore handling to build the mask from
the original label indices or from the explicit ignored-class channel instead of
summing one-hot channels. Concretely: when ignore_index is set, if you have
access to the original label tensor (e.g., labels / y_true_indices) create
valid_mask = (labels != self.ignore_index).unsqueeze(1) and expand it to y_true
shape; otherwise (when y_true is one-hot) build the mask from the ignored class
channel as valid_mask = 1 - y_true[:, self.ignore_index:self.ignore_index+1,
...] and use that to mask y_pred and other computations (replace spatial_mask
usage in this file and in AsymmetricFocalTverskyLoss code paths).
- Around line 157-160: The ignore_index handling currently builds spatial_mask
from torch.sum(y_true, dim=1) which fails when self.to_onehot_y is False; update
the block in AsymmetricFocalLoss (unified_focal_loss.py) to branch on
self.to_onehot_y: when to_onehot_y is False compute spatial_mask = (y_true !=
self.ignore_index).unsqueeze(1).float() (so label tensors drop ignored voxels),
otherwise keep the existing one-hot style mask (e.g., spatial_mask =
(torch.sum(y_true, dim=1, keepdim=True) > 0).float()); then apply that
spatial_mask to cross_entropy as before.

In `@monai/metrics/generalized_dice.py`:
- Around line 157-161: The current ignore_index mask in
monai/metrics/generalized_dice.py compares a one-hot y to an integer and always
yields True; change the masking to detect one-hot class vectors using n_class:
if ignore_index >= n_class set mask = (y.sum(dim=1, keepdim=True) > 0).float(),
else set mask = (1.0 - y[:, ignore_index:ignore_index+1]).float(); then apply
that mask to y_pred and y as before (y_pred = y_pred * mask; y = y * mask). Use
the existing variables y, y_pred, ignore_index and n_class to implement this
branching so one-hot inputs are masked correctly.

In `@monai/metrics/hausdorff_distance.py`:
- Around line 151-152: The docstring for hausdorff_distance is missing
documentation for the mask parameter; update the function's docstring to
describe mask: its type (torch.Tensor | None), expected shape (same spatial
shape as y_pred/y), semantics (binary or boolean tensor where True/non-zero
marks voxels to include in the metric; if None, no spatial masking is applied),
how it interacts with ignore_index (e.g., masked-out voxels are excluded before
applying ignore_index), and any dtype/format expectations (boolean or 0/1
integer). Mention that mask applies to both prediction and ground-truth when
computing distances and that mask and ignore_index can be used together (specify
precedence if relevant).

In `@monai/metrics/meaniou.py`:
- Around line 146-151: compute_iou's ignore_index handling assumes label-encoded
y but must support one-hot y; change the mask logic in compute_iou to detect
one-hot (y.dim()==y_pred.dim() and y.shape[1]==y_pred.shape[1] or otherwise by
checking channel count) and, when one-hot, build a [B,1,...] mask from y[:,
ignore_index:ignore_index+1] (e.g., mask = 1 - y[:,
ignore_index:ignore_index+1]) then expand_as(y_pred) and apply it to y_pred; for
label-encoded y keep the existing mask = (y != ignore_index).float() and replace
y values with 0 via torch.where(y == ignore_index, torch.tensor(0,
device=y.device), y) only in that branch so one-hot y is not altered
incorrectly.

In `@monai/metrics/surface_dice.py`:
- Around line 223-227: The current one-hot mask logic using (y !=
ignore_index).all(dim=1, keepdim=True) is incorrect for one-hot encoded labels;
replace it by constructing a class-based mask from the one-hot channel: build
mask = (1 - y[:, ignore_index:ignore_index+1]).float() (preserving a [B,1,...]
shape) and then multiply y_pred and y by that mask; update the block that checks
ignore_index and applies masking to y_pred and y (referencing y, y_pred, and
ignore_index) so ignored-class voxels are zeroed correctly.

In `@monai/metrics/surface_distance.py`:
- Around line 174-176: The current conditional skips calling ignore_background
when include_background is False and ignore_index == 0, causing background
removal to be bypassed unintentionally; update the logic in surface_distance.py
so that when include_background is False you always call
ignore_background(y_pred=y_pred, y=y) (remove or change the ignore_index check),
or alternatively make the behavior explicit by documenting that ignore_index==0
preserves background; reference the include_background flag, ignore_index
variable, and the ignore_background(y_pred=y_pred, y=y) call when making the
change.

In `@monai/metrics/utils.py`:
- Around line 358-364: The mask-slicing branch currently only runs when
edge_results[2] is a tuple, so in subvoxel mode (when spacing is provided and
edge_results[2] is an area tensor) the mask is never applied; update the code
around edge_results/ mask handling so it handles both cases: if edge_results[2]
is a tuple extract slices and do mask = mask[slices], otherwise skip slicing and
just move/cast the mask to the edges_pred device (mask =
mask.to(edges_pred.device).bool()), then apply edges_pred = edges_pred & mask
and edges_gt = edges_gt & mask. Ensure you reference edge_results, mask,
edges_pred and edges_gt when making the change.

In `@tests/losses/test_ignore_index_losses.py`:
- Around line 22-27: The current TEST_CASES only checks invariance with ignored
indices but not that ignored voxels are truly excluded; update the tests in
tests/losses/test_ignore_index_losses.py to compute each loss twice—(1) the
existing full-volume loss with ignore_index set and (2) a reference loss
computed only on the non-ignored region (mask out ignored voxels from y_true and
y_pred and/or slice to the non-ignored voxels) and assert those values match—so
constant penalties from ignored voxels would fail; extend TEST_CASES to include
both to_onehot_y=True and an already-one-hot label case for each Loss class
(DiceLoss, FocalLoss, TverskyLoss, AsymmetricUnifiedFocalLoss) and ensure the
test covers both softmax/sigmoid variants referenced in the tuple entries.

In `@tests/metrics/test_ignore_index_metrics.py`:
- Around line 48-50: The class-level `@unittest.skipUnless`(has_scipy, ...) is
incorrectly skipping metrics that don't require SciPy; update the test split so
non-SciPy metrics run regardless. Remove or change the decorator on
TestIgnoreIndexMetrics and have it use `@parameterized.expand`(TEST_METRICS) only,
then create a separate test class (e.g., TestIgnoreIndexSurfaceMetrics)
decorated with `@unittest.skipUnless`(has_scipy, ...) that uses
`@parameterized.expand`(SCIPY_METRICS) so only the surface-distance tests are
gated on has_scipy.
- Around line 63-66: The test currently fills all channels with 255 which never
triggers the class-based one-hot path; change the bottom-half sentinel
assignment to a proper one-hot vector for the ignored class so the masking logic
is exercised—specifically, update the tensor y (created in the test) so that for
the ignored class channel (referenced as the channel index used by the
metric/ignored_class constant) you set y[:, ignored_channel, 2:4, :] = 1 and
ensure the other channels in that region remain 0; keep the top-half valid
labels (y[:, 1, 0:2, 0:2] = 1.0) intact so both valid and ignored-class regions
are present and the class-based one-hot branch in the metric (the one handling
one-hot label tensors) is covered.

---

Outside diff comments:
In `@monai/losses/dice.py`:
- Around line 169-177: Build the ignore mask from a sanitized label view before
converting to one-hot: compute a boolean label_mask = (target !=
self.ignore_index) (or all-True if ignore_index is None), then if
self.to_onehot_y is True sanitize target for one_hot by copying target and
setting ignored positions to a valid class (e.g., 0) before calling one_hot;
after one_hot, expand/reshape label_mask into the channel dimension to match
target/prediction (e.g., mask =
label_mask.unsqueeze(1).expand_as(target).float()) so the mask works for both
single-channel sentinel maps and multi-channel one-hot targets and still
broadcasts correctly when include_background slicing happens (apply same mask
generation in the later block covering lines ~179-192).

In `@monai/losses/focal_loss.py`:
- Around line 150-154: The ignore_index mask is being computed from the
post-conversion target (after one_hot), so when users pass one-hot targets the
class IDs are lost and ignored voxels aren’t detected; fix by saving the
original target (e.g., orig_target = target) before calling one_hot in the
method where self.to_onehot_y is handled, compute the ignore mask from
orig_target when it contains integer class labels, and if the supplied target is
already one-hot derive the mask from the ignore_index channel (e.g.,
orig_target[..., ignore_index] or equivalent); apply the same change to the
second occurrence of the logic (lines matching the 167-170 block) so both paths
correctly detect ignored voxels regardless of whether target was converted or
already one-hot.

In `@monai/metrics/utils.py`:
- Around line 316-326: The get_edge_surface_distance function accepts
ignore_index but never forwards it to get_mask_edges or uses it; update
get_edge_surface_distance to pass the ignore_index argument through when calling
get_mask_edges (the call site around get_mask_edges in
get_edge_surface_distance) so masked edge computation respects ignore_index, and
ensure any signatures (get_mask_edges invocation) and downstream uses handle a
None value appropriately.
- Around line 265-271: get_surface_distance currently accepts a mask parameter
but never uses it; either remove the parameter or apply it to limit where
surface distances are computed. To fix, update get_surface_distance so that
before computing edges (from seg_pred and seg_gt) you validate and cast mask to
a boolean array matching seg_pred/seg_gt shape and then apply it to
zero-out/ignore voxels outside the mask (e.g., mask the inputs seg_pred and
seg_gt or filter edge point sets) so that subsequent edge extraction and
distance calculations only consider masked regions; keep the existing logic for
distance_metric and spacing unchanged and ensure mask handling is documented in
the function signature and tests.
- Around line 161-169: The ignore_index parameter in get_mask_edges is never
used and not documented; remove it from the function signature and any related
type hints, delete its mention in the docstring Args section, and update any
call sites/tests that pass ignore_index to stop supplying it; alternatively if
you prefer keeping behavior, implement logic inside get_mask_edges to mask out
label==ignore_index from seg_gt/seg_pred before processing and document the
parameter in the docstring—choose one approach and make matching changes to
callers and tests for consistency.

---

Nitpick comments:
In `@monai/metrics/generalized_dice.py`:
- Around line 198-204: Loop over batches replacing infs in w_full is correct but
can be vectorized: compute inf_mask = torch.isinf(w_full), create temp =
w_full.masked_fill(inf_mask, 0), compute per-batch max_w =
temp.view(w_full.shape[0], -1).max(dim=1).values, build per-batch replacement
values = torch.where(max_w > 0, max_w, torch.tensor(1.0, device=w_full.device)),
expand/broadcast those replacements to the shape of w_full and assign them into
w_full at inf_mask; update the code replacing the per-batch for-loop (variables:
w_full, batch_w, infs) with this vectorized sequence so behavior remains
identical.

In `@monai/metrics/hausdorff_distance.py`:
- Around line 199-207: The code redundantly reapplies masking: y_pred/y are
already masked earlier (mask[b, 0]) yet yp/yt are masked again with valid_mask
before computing distances and the same mask is later passed into
get_edge_surface_distance; remove the duplicated masking to avoid overhead by
eliminating the yp = yp * valid_mask and yt = yt * valid_mask lines (or skip the
earlier mask and only apply a single, consistent mask) and ensure the mask
passed to get_edge_surface_distance is the same single mask used to prepare
yp/yt; update references in the loop that set hd[b, c] and the early all-ignored
check to use that single mask (valid_mask or mask[b, 0]) so masking occurs
exactly once.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: de4d36bb-f303-47c9-bc69-6339e0ccdc88

📥 Commits

Reviewing files that changed from the base of the PR and between daaedaa and af83422.

📒 Files selected for processing (14)
  • monai/losses/dice.py
  • monai/losses/focal_loss.py
  • monai/losses/tversky.py
  • monai/losses/unified_focal_loss.py
  • monai/metrics/confusion_matrix.py
  • monai/metrics/generalized_dice.py
  • monai/metrics/hausdorff_distance.py
  • monai/metrics/meandice.py
  • monai/metrics/meaniou.py
  • monai/metrics/surface_dice.py
  • monai/metrics/surface_distance.py
  • monai/metrics/utils.py
  • tests/losses/test_ignore_index_losses.py
  • tests/metrics/test_ignore_index_metrics.py

@Rusheel86 Rusheel86 marked this pull request as draft March 11, 2026 03:24
Signed-off-by: Rusheel Sharma <rusheelhere@gmail.com>
@Rusheel86 Rusheel86 force-pushed the feat-ignore-index-support branch from d93f058 to 187da14 Compare March 11, 2026 12:45
Signed-off-by: Rusheel Sharma <rusheelhere@gmail.com>
Signed-off-by: Rusheel Sharma <rusheelhere@gmail.com>
Signed-off-by: Rusheel Sharma <rusheelhere@gmail.com>
Signed-off-by: Rusheel Sharma <rusheelhere@gmail.com>
@Rusheel86 Rusheel86 marked this pull request as ready for review March 12, 2026 17:08
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
monai/metrics/meandice.py (1)

256-287: ⚠️ Potential issue | 🟡 Minor

DiceHelper docstring missing ignore_index parameter.

Add ignore_index to the Args section.

📝 Suggested addition
         num_classes: number of input channels (always including the background). When this is ``None``,
             ``y_pred.shape[1]`` will be used. This option is useful when both ``y_pred`` and ``y`` are
             single-channel class indices and the number of classes is not automatically inferred from data.
+        ignore_index: class index to ignore during calculation. Defaults to ``None``.
     """
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@monai/metrics/meandice.py` around lines 256 - 287, The DiceHelper docstring
is missing the ignore_index parameter description; update the DiceHelper class
docstring (the Args section) to include a brief entry for ignore_index
explaining it is an optional int specifying a label to ignore during Dice
computation (matching the __init__ signature where ignore_index: int | None =
None), and ensure the phrasing and type match other Args entries (e.g.,
include_background, threshold, apply_argmax) so docs and the __init__ parameters
remain consistent.
♻️ Duplicate comments (3)
tests/metrics/test_ignore_index_metrics.py (2)

63-70: ⚠️ Potential issue | 🟠 Major

Test only exercises sentinel-value masking, not class-based one-hot path.

With ignore_index=255 and 2 channels, the condition ignore_index < y.shape[1] is always false. Add a test case with ignore_index=1 (a valid class index) where the ignored channel is set to 1 to exercise the class-based masking branch.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/metrics/test_ignore_index_metrics.py` around lines 63 - 70, The test
currently only covers sentinel-style masking (ignore_index=255) by leaving the
bottom region as all zeros; add an additional test case that uses ignore_index=1
to exercise the class-based one-hot masking path: create a new y tensor (same
shape as current y) and set the ignored region pixels to the one-hot class
channel (e.g., set y[:, 1, 2:4, 0:4] = 1.0 for the bottom half) so the branch
guarded by the condition ignore_index < y.shape[1] runs and is asserted against;
keep the existing sentinel test and add assertions for both behaviors.

48-50: ⚠️ Potential issue | 🟠 Major

Don't gate non-SciPy metrics behind SciPy.

DiceMetric, MeanIoU, GeneralizedDiceScore, and ConfusionMatrixMetric don't require SciPy. Split into two test classes: one for TEST_METRICS without the skip decorator, another for SCIPY_METRICS with it.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/metrics/test_ignore_index_metrics.py` around lines 48 - 50, The test
class TestIgnoreIndexMetrics is incorrectly gating all metrics behind SciPy;
split it into two classes: keep one TestIgnoreIndexMetrics (or
TestIgnoreIndexMetricsBasic) that uses `@parameterized.expand`(TEST_METRICS)
without the `@unittest.skipUnless`(has_scipy, ...) decorator to cover DiceMetric,
MeanIoU, GeneralizedDiceScore, ConfusionMatrixMetric, etc., and create a second
class (e.g., TestIgnoreIndexMetricsScipy) decorated with
`@unittest.skipUnless`(has_scipy, "Scipy required for surface metrics") that uses
`@parameterized.expand`(SCIPY_METRICS) to run SciPy-dependent metrics; update
class names and the parameterized.expand calls accordingly so non-SciPy tests
run even when SciPy is absent.
monai/metrics/utils.py (1)

363-369: ⚠️ Potential issue | 🟠 Major

Mask slicing condition may not execute in subvoxel mode.

When use_subvoxels=True, edge_results[2] is an area tensor, not a tuple containing slices. The condition isinstance(edge_results[2], tuple) will be False, skipping mask application.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@monai/metrics/utils.py` around lines 363 - 369, The mask-slicing branch in
the block using edge_results[2] only handles the case where edge_results[2] is a
tuple of slices, so when use_subvoxels=True and edge_results[2] is an area
tensor the mask is never applied; update the condition to handle both shapes: if
edge_results[2] is a tuple treat it as slices (as now), else if it's a
torch.Tensor treat it as an area mask—convert it to the target device and dtype
(torch.bool) and then apply it to edges_pred and edges_gt (e.g., mask = mask &
area_tensor); adjust the code around edge_results, mask, edges_pred, and
edges_gt to ensure both paths produce a boolean mask on the same device before
combining.
🧹 Nitpick comments (3)
monai/metrics/utils.py (1)

72-90: ignore_index_mask docstring incomplete.

Missing Args, Returns sections per Google-style docstring guidelines.

📝 Suggested docstring
 def ignore_index_mask(
     y_pred: torch.Tensor, y: torch.Tensor, ignore_index: int | None = None
 ) -> tuple[torch.Tensor, torch.Tensor]:
     """
     Masks out the specified ignore_index from both predictions and ground truth.
-    This is a helper for `#8667` to allow 'Ignore Class' functionality in metrics.
+
+    Args:
+        y_pred: Prediction tensor.
+        y: Ground truth tensor.
+        ignore_index: Class index to ignore. If None, inputs returned unchanged.
+
+    Returns:
+        Tuple of masked (y_pred, y) tensors.
     """
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@monai/metrics/utils.py` around lines 72 - 90, The docstring for
ignore_index_mask is missing Google-style Args and Returns sections; update the
docstring of ignore_index_mask to include an Args section documenting y_pred:
torch.Tensor (predictions tensor), y: torch.Tensor (ground-truth tensor), and
ignore_index: int | None (index to mask out, None means no masking), and a
Returns section describing the tuple[torch.Tensor, torch.Tensor] (masked y_pred
and masked y); keep the short description and mention that the mask zeros out
positions where y == ignore_index and that when ignore_index is None the inputs
are returned unchanged.
monai/metrics/surface_dice.py (1)

284-298: Defensive handling of areas tuple is reasonable.

The code handles edge cases where areas might be a single tensor or varying tuple lengths. Consider adding a comment explaining why this defensive check is needed.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@monai/metrics/surface_dice.py` around lines 284 - 298, Add a short inline
comment above the defensive block that handles the variable areas in
surface_dice.py explaining why the check is necessary: note that the upstream
area computation can return either a single tensor (applies to both pred and
gt), a tuple/list of two tensors (areas_pred, areas_gt), or occasionally other
lengths (handled as empty tensors), and that the guard ensures subsequent
indexing by edges_gt/edges_pred is safe; reference the variables areas,
areas_pred, and areas_gt so future readers know exactly which values and shapes
are being normalized here.
tests/metrics/test_ignore_index_metrics.py (1)

51-87: Add docstrings for class and test method.

As per coding guidelines, docstrings should describe the test purpose and parameters.

📝 Suggested docstring
 class TestIgnoreIndexMetrics(unittest.TestCase):
+    """Tests that metrics produce identical results when predictions differ only in ignored regions."""
+
     `@parameterized.expand`(TEST_METRICS + SCIPY_METRICS)
     def test_metric_ignore_consistency(self, metric_class, kwargs):
+        """
+        Verify ignore_index excludes specified regions from metric computation.
+
+        Args:
+            metric_class: The metric class to test.
+            kwargs: Initialization arguments for the metric.
+        """
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/metrics/test_ignore_index_metrics.py` around lines 51 - 87, Add clear
docstrings for the test class (the class that contains
test_metric_ignore_consistency) and for the test method
test_metric_ignore_consistency: the class docstring should state the purpose of
the test suite (testing ignore_index behavior for metrics) and any shared
fixtures/parameters, and the method docstring should describe the specific
scenario (two predictions differing only in ignored regions, ignore_index=255
sentinel, expected identical aggregated results) plus the method inputs
(metric_class, kwargs) and expected outcome; place the docstrings immediately
above the class definition and above the test_metric_ignore_consistency function
respectively.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@monai/metrics/generalized_dice.py`:
- Around line 140-141: The docstring for the generalized_dice function has a
parameter name mismatch: it documents sum_over_labels but the actual parameter
is sum_over_classes; update the docstring to use sum_over_classes (or rename the
function parameter to sum_over_labels if you prefer the alternate API) so the
parameter names match; edit the docstring in generalized_dice (and any related
references in the same file) to reflect sum_over_classes for the described
behavior.

In `@monai/metrics/utils.py`:
- Line 330: The parameter ignore_index in get_edge_surface_distance is declared
but never used; remove it from the function signature, update any internal
references (docstring, type hints) and update all callers to stop passing
ignore_index, ensuring function behavior and tests remain unchanged; if the
ignore behavior was intended, instead implement masking using ignore_index
inside get_edge_surface_distance where label arrays are processed (e.g., filter
out voxels/labels equal to ignore_index) rather than leaving the parameter
unused.
- Line 168: The parameter ignore_index is declared but unused in the function in
monai/metrics/utils.py; remove the unused parameter named ignore_index from the
function signature, delete any mention of it in the function docstring/type
hints, and update any internal type annotations or default values that
referenced it; also search for and update any call sites or wrappers that pass
ignore_index so they no longer supply this argument (or adjust them to use the
updated signature) and run tests to ensure no callers remain.

---

Outside diff comments:
In `@monai/metrics/meandice.py`:
- Around line 256-287: The DiceHelper docstring is missing the ignore_index
parameter description; update the DiceHelper class docstring (the Args section)
to include a brief entry for ignore_index explaining it is an optional int
specifying a label to ignore during Dice computation (matching the __init__
signature where ignore_index: int | None = None), and ensure the phrasing and
type match other Args entries (e.g., include_background, threshold,
apply_argmax) so docs and the __init__ parameters remain consistent.

---

Duplicate comments:
In `@monai/metrics/utils.py`:
- Around line 363-369: The mask-slicing branch in the block using
edge_results[2] only handles the case where edge_results[2] is a tuple of
slices, so when use_subvoxels=True and edge_results[2] is an area tensor the
mask is never applied; update the condition to handle both shapes: if
edge_results[2] is a tuple treat it as slices (as now), else if it's a
torch.Tensor treat it as an area mask—convert it to the target device and dtype
(torch.bool) and then apply it to edges_pred and edges_gt (e.g., mask = mask &
area_tensor); adjust the code around edge_results, mask, edges_pred, and
edges_gt to ensure both paths produce a boolean mask on the same device before
combining.

In `@tests/metrics/test_ignore_index_metrics.py`:
- Around line 63-70: The test currently only covers sentinel-style masking
(ignore_index=255) by leaving the bottom region as all zeros; add an additional
test case that uses ignore_index=1 to exercise the class-based one-hot masking
path: create a new y tensor (same shape as current y) and set the ignored region
pixels to the one-hot class channel (e.g., set y[:, 1, 2:4, 0:4] = 1.0 for the
bottom half) so the branch guarded by the condition ignore_index < y.shape[1]
runs and is asserted against; keep the existing sentinel test and add assertions
for both behaviors.
- Around line 48-50: The test class TestIgnoreIndexMetrics is incorrectly gating
all metrics behind SciPy; split it into two classes: keep one
TestIgnoreIndexMetrics (or TestIgnoreIndexMetricsBasic) that uses
`@parameterized.expand`(TEST_METRICS) without the `@unittest.skipUnless`(has_scipy,
...) decorator to cover DiceMetric, MeanIoU, GeneralizedDiceScore,
ConfusionMatrixMetric, etc., and create a second class (e.g.,
TestIgnoreIndexMetricsScipy) decorated with `@unittest.skipUnless`(has_scipy,
"Scipy required for surface metrics") that uses
`@parameterized.expand`(SCIPY_METRICS) to run SciPy-dependent metrics; update
class names and the parameterized.expand calls accordingly so non-SciPy tests
run even when SciPy is absent.

---

Nitpick comments:
In `@monai/metrics/surface_dice.py`:
- Around line 284-298: Add a short inline comment above the defensive block that
handles the variable areas in surface_dice.py explaining why the check is
necessary: note that the upstream area computation can return either a single
tensor (applies to both pred and gt), a tuple/list of two tensors (areas_pred,
areas_gt), or occasionally other lengths (handled as empty tensors), and that
the guard ensures subsequent indexing by edges_gt/edges_pred is safe; reference
the variables areas, areas_pred, and areas_gt so future readers know exactly
which values and shapes are being normalized here.

In `@monai/metrics/utils.py`:
- Around line 72-90: The docstring for ignore_index_mask is missing Google-style
Args and Returns sections; update the docstring of ignore_index_mask to include
an Args section documenting y_pred: torch.Tensor (predictions tensor), y:
torch.Tensor (ground-truth tensor), and ignore_index: int | None (index to mask
out, None means no masking), and a Returns section describing the
tuple[torch.Tensor, torch.Tensor] (masked y_pred and masked y); keep the short
description and mention that the mask zeros out positions where y ==
ignore_index and that when ignore_index is None the inputs are returned
unchanged.

In `@tests/metrics/test_ignore_index_metrics.py`:
- Around line 51-87: Add clear docstrings for the test class (the class that
contains test_metric_ignore_consistency) and for the test method
test_metric_ignore_consistency: the class docstring should state the purpose of
the test suite (testing ignore_index behavior for metrics) and any shared
fixtures/parameters, and the method docstring should describe the specific
scenario (two predictions differing only in ignored regions, ignore_index=255
sentinel, expected identical aggregated results) plus the method inputs
(metric_class, kwargs) and expected outcome; place the docstrings immediately
above the class definition and above the test_metric_ignore_consistency function
respectively.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: f028bef2-c8c7-47da-9559-bdce60c79ab7

📥 Commits

Reviewing files that changed from the base of the PR and between af83422 and 9863a93.

📒 Files selected for processing (7)
  • monai/losses/unified_focal_loss.py
  • monai/metrics/generalized_dice.py
  • monai/metrics/meandice.py
  • monai/metrics/meaniou.py
  • monai/metrics/surface_dice.py
  • monai/metrics/utils.py
  • tests/metrics/test_ignore_index_metrics.py
✅ Files skipped from review due to trivial changes (1)
  • monai/metrics/meaniou.py

Signed-off-by: Rusheel Sharma <rusheelhere@gmail.com>
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
monai/metrics/generalized_dice.py (1)

73-78: ⚠️ Potential issue | 🟡 Minor

Remove ignore_index from _compute_tensor’s Args block.

The method does not accept that parameter, so the docstring is wrong.

As per coding guidelines, "Docstrings should be present for all definition which describe each variable, return value, and raised exception in the appropriate section of the Google-style of docstrings."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@monai/metrics/generalized_dice.py` around lines 73 - 78, The docstring for
_compute_tensor incorrectly lists an ignore_index argument that the method does
not accept; update the Args section of the _compute_tensor docstring in
generalized_dice.py by removing the ignore_index entry and ensuring only actual
parameters (e.g., y_pred, y) are documented in Google-style format to match the
method signature and coding guidelines.
♻️ Duplicate comments (1)
monai/metrics/utils.py (1)

360-366: ⚠️ Potential issue | 🔴 Critical

Mask is never applied in any mode.

The condition len(edge_results) > 2 and isinstance(edge_results[2], tuple) cannot be satisfied:

  • Non-subvoxel mode: get_mask_edges returns 2 elements → len(edge_results) > 2 is False
  • Subvoxel mode: Returns 4 elements, but edge_results[2] is areas_pred (a tensor), never a tuple

The mask parameter is accepted but silently ignored.

🐛 Proposed fix
     edge_results = get_mask_edges(y_pred, y, crop=True, spacing=edges_spacing, always_return_as_numpy=False)
     edges_pred, edges_gt = edge_results[0], edge_results[1]

     if mask is not None:
-        if len(edge_results) > 2 and isinstance(edge_results[2], tuple):
-            slices = edge_results[2]
-            mask = mask[slices]
-            mask = torch.as_tensor(mask, device=edges_pred.device, dtype=torch.bool)
-            edges_pred = edges_pred & mask
-            edges_gt = edges_gt & mask
+        mask = torch.as_tensor(mask, device=edges_pred.device, dtype=torch.bool)
+        # Resize mask if cropping changed spatial dimensions
+        if mask.shape != edges_pred.shape:
+            # Crop mask to match edges; fallback to no masking if incompatible
+            try:
+                slices = tuple(slice(0, s) for s in edges_pred.shape)
+                mask = mask[slices]
+            except (IndexError, RuntimeError):
+                pass  # Keep original mask shape
+        if mask.shape == edges_pred.shape:
+            edges_pred = edges_pred & mask
+            edges_gt = edges_gt & mask
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@monai/metrics/utils.py` around lines 360 - 366, The mask parameter is
accepted but never applied because the code only applies it when edge_results[2]
is a tuple; instead, when mask is not None directly apply the provided mask to
edges_pred and edges_gt. Replace the conditional that checks edge_results[2]
with logic that (1) converts the incoming mask to a torch.bool tensor on
edges_pred.device (torch.as_tensor(mask, device=edges_pred.device,
dtype=torch.bool)) and (2) masks edges_pred and edges_gt via bitwise-and
(edges_pred = edges_pred & mask; edges_gt = edges_gt & mask). Keep references to
edge_results, edges_pred, edges_gt and the mask parameter so the mask is
actually used regardless of get_mask_edges return shape.
🧹 Nitpick comments (1)
monai/metrics/utils.py (1)

384-396: Defensive code for unreachable cases.

Lines 386-390: get_mask_edges returns either 2 or 4 elements, never 3. The len(areas) == 1 branch is unreachable.

Lines 394-396: convert_to_tensor does not return None for the given input. This null check is dead code.

Consider removing or documenting why these guards exist.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@monai/metrics/utils.py` around lines 384 - 396, Remove the unreachable
defensive branches around areas and the unnecessary None-check on
convert_to_tensor: since get_mask_edges never returns a single-area case, delete
the len(areas) == 1 branch in the use_subvoxels handling and replace the
unexpected-length fallback with an explicit assertion or ValueError (referencing
the areas variable and use_subvoxels flag) so failures are visible; similarly,
assume convert_to_tensor(...) (called as out = convert_to_tensor(((edges_pred,
edges_gt), distances, tuple(areas)), device=y_pred.device)) never returns None
and remove the if out is None check, letting any unexpected behavior surface as
an exception instead.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@monai/metrics/generalized_dice.py`:
- Around line 158-177: The ignore_index handling is treating negative sentinel
values as class-channel indices causing empty slices and broadcast errors;
modify the guard to only treat ignore_index as a class channel when it satisfies
0 <= ignore_index < y.shape[1] (use this same condition in the initial mask
branch and in the channels_to_use.remove(...) logic), otherwise treat it as a
sentinel (keep the existing sentinel branch). Update the logic around variables
ignore_index, y, y_pred, mask and channels_to_use (in the generalized_dice /
generalized_dice implementation) accordingly and add a regression test that
passes a negative ignore_index (e.g., -1 or -100) to verify no channel slicing
occurs and broadcasting remains valid.
- Around line 184-211: The current fallback weight logic computes w_full from
y_o_full (all channels) so the inf->max_finite substitution can be influenced by
excluded channels; change it to compute and sanitize weights only on the scored
channels: derive w from y_o (already sliced by channels_to_use), convert to
float, compute reciprocal/square reciprocal according to Weight in the same way
you did for w_full, then perform the per-batch inf detection/replacement on that
per-score-channel tensor (variables: y_o, channels_to_use, w_full, w) so w
contains the sanitized weights for scored channels only rather than selecting
channels after sanitizing.

In `@monai/metrics/utils.py`:
- Around line 72-91: The docstring for ignore_index_mask is incomplete and the
function only handles label-encoded tensors; update the Google-style docstring
to include Args (y_pred: torch.Tensor, y: torch.Tensor, ignore_index: int | None
— describe shapes, dtype and that mask is spatial with 1 for kept voxels and 0
for ignored) and Returns (tuple[torch.Tensor, torch.Tensor] — masked y_pred and
y with same shapes), and mention no exceptions raised; also either extend the
function to support one-hot/channel-first inputs (detect if y has channel
dimension like in compute_generalized_dice and apply channel-wise masking by
creating mask from y.argmax or from a channel containing ignore_index) or
explicitly document that only label-encoded targets are supported and reference
compute_generalized_dice for the multi-channel approach; implement the chosen
path in ignore_index_mask so callers get correctly masked tensors.
- Line 287: Remove the orphaned docstring entry that documents a non-existent
"mask" parameter in monai/metrics/utils.py: locate the function docstring that
contains the line "mask: optional boolean mask. Pixels where mask is False will
be ignored in the distance computation." and delete that line (or the
corresponding docstring bullet) so the docstring matches the actual function
signature; do not add a new parameter unless you intentionally modify the
function signature to accept and handle a mask.
- Around line 310-316: The current branch in the function using variables
seg_pred and dis performs an unnecessary None check and can return mismatched
types; update the torch.Tensor branch (where seg_pred is a torch.Tensor) to use
the boolean mask seg_pred.bool() and return a torch.empty(0, dtype=dis.dtype,
device=dis.device) when no elements match (rather than np.empty), and update the
NumPy branch to use seg_pred.astype(bool) and return a numpy.empty((0,),
dtype=dis.dtype) when empty; remove the redundant out is not None checks so both
branches always return the same container type as dis (torch.Tensor or
numpy.ndarray) and preserve dtype/device.

---

Outside diff comments:
In `@monai/metrics/generalized_dice.py`:
- Around line 73-78: The docstring for _compute_tensor incorrectly lists an
ignore_index argument that the method does not accept; update the Args section
of the _compute_tensor docstring in generalized_dice.py by removing the
ignore_index entry and ensuring only actual parameters (e.g., y_pred, y) are
documented in Google-style format to match the method signature and coding
guidelines.

---

Duplicate comments:
In `@monai/metrics/utils.py`:
- Around line 360-366: The mask parameter is accepted but never applied because
the code only applies it when edge_results[2] is a tuple; instead, when mask is
not None directly apply the provided mask to edges_pred and edges_gt. Replace
the conditional that checks edge_results[2] with logic that (1) converts the
incoming mask to a torch.bool tensor on edges_pred.device (torch.as_tensor(mask,
device=edges_pred.device, dtype=torch.bool)) and (2) masks edges_pred and
edges_gt via bitwise-and (edges_pred = edges_pred & mask; edges_gt = edges_gt &
mask). Keep references to edge_results, edges_pred, edges_gt and the mask
parameter so the mask is actually used regardless of get_mask_edges return
shape.

---

Nitpick comments:
In `@monai/metrics/utils.py`:
- Around line 384-396: Remove the unreachable defensive branches around areas
and the unnecessary None-check on convert_to_tensor: since get_mask_edges never
returns a single-area case, delete the len(areas) == 1 branch in the
use_subvoxels handling and replace the unexpected-length fallback with an
explicit assertion or ValueError (referencing the areas variable and
use_subvoxels flag) so failures are visible; similarly, assume
convert_to_tensor(...) (called as out = convert_to_tensor(((edges_pred,
edges_gt), distances, tuple(areas)), device=y_pred.device)) never returns None
and remove the if out is None check, letting any unexpected behavior surface as
an exception instead.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: dcfb7f36-019d-44f4-a4d6-f43e54f47616

📥 Commits

Reviewing files that changed from the base of the PR and between 9863a93 and 170f34a.

📒 Files selected for processing (2)
  • monai/metrics/generalized_dice.py
  • monai/metrics/utils.py

Signed-off-by: Rusheel Sharma <rusheelhere@gmail.com>
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
monai/metrics/generalized_dice.py (1)

69-92: ⚠️ Potential issue | 🟡 Minor

Docstring lists non-existent parameter.

Line 77 documents ignore_index as an argument to _compute_tensor, but the function signature only accepts y_pred and y. The value comes from self.ignore_index.

📝 Fix
         Args:
             y_pred (torch.Tensor): Binarized segmentation model output. It must be in one-hot format and in the NCHW[D] format,
                 where N is the batch dimension, C is the channel dimension, and the remaining are the spatial dimensions.
             y (torch.Tensor): Binarized ground-truth. It must be in one-hot format and have the same shape as `y_pred`.
-            ignore_index: class index to ignore from the metric computation.

         Returns:

As per coding guidelines: "Docstrings should be present for all definition which describe each variable, return value, and raised exception in the appropriate section of the Google-style of docstrings."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@monai/metrics/generalized_dice.py` around lines 69 - 92, The docstring for
_compute_tensor incorrectly lists an `ignore_index` parameter even though the
method signature only has y_pred and y and uses self.ignore_index; update the
docstring to remove the `ignore_index` parameter entry (or change it to document
that ignore_index is taken from self.ignore_index) so the arguments section
matches the function signature and mention that ignore_index is read from
self.ignore_index; ensure the Args section only documents y_pred, y and any
class-sourced options like self.ignore_index under a brief note.
♻️ Duplicate comments (1)
monai/metrics/utils.py (1)

368-374: ⚠️ Potential issue | 🟠 Major

Mask not applied in subvoxel mode.

edge_results[2] is an area tensor when spacing is provided, not a tuple of slices. The isinstance(edge_results[2], tuple) check fails, so lines 370-374 never execute in subvoxel mode.

🔧 Suggested fix
     if mask is not None:
-        if len(edge_results) > 2 and isinstance(edge_results[2], tuple):
-            slices = edge_results[2]
-            mask = mask[slices]
-            mask = torch.as_tensor(mask, device=edges_pred.device, dtype=torch.bool)
-            edges_pred = edges_pred & mask
-            edges_gt = edges_gt & mask
+        mask = torch.as_tensor(mask, device=edges_pred.device, dtype=torch.bool)
+        # Crop mask to match edges shape if needed
+        if mask.shape != edges_pred.shape:
+            # Resize/crop mask to match edge dimensions
+            slices = tuple(slice(0, min(m, e)) for m, e in zip(mask.shape, edges_pred.shape))
+            mask = mask[slices]
+        edges_pred = edges_pred & mask
+        edges_gt = edges_gt & mask
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@monai/metrics/utils.py` around lines 368 - 374, The mask application branch
currently only runs when edge_results[2] is a tuple of slices, so in subvoxel
mode (where edge_results[2] is an area tensor) the mask never gets applied;
update the code in the block handling mask, edge_results and edges_pred/edges_gt
so it handles both cases: if edge_results[2] is a tuple treat it as slices
(slices = edge_results[2] and mask = mask[slices]), otherwise treat
edge_results[2] as an area tensor (derive a boolean mask from that tensor, e.g.
area_tensor > 0 or nonzero as appropriate), then cast the mask to torch.bool on
edges_pred.device and apply edges_pred = edges_pred & mask and edges_gt =
edges_gt & mask; refer to variables edge_results, mask, slices, edges_pred and
edges_gt to locate and modify the logic.
🧹 Nitpick comments (1)
monai/metrics/utils.py (1)

385-398: Minor: Unnecessary None guard.

get_surface_distance returns a tensor (possibly empty or inf-filled), never None. The d if d is not None else ... check on line 385 is defensive but unreachable.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@monai/metrics/utils.py` around lines 385 - 398, The None check in building
distances_list is unnecessary because get_surface_distance never returns None;
update the construction around distances_list/distances_raw to remove the d is
not None guard and directly use the tensors (e.g., replace the comprehension
creating distances_list with one that uses d from distances_raw directly,
keeping the fallback of edges_pred.new_empty((0,)) only if you still need a safe
empty tensor), ensuring downstream variables distances and distances_list remain
the same shape/typing used by the rest of the function (references:
distances_list, distances_raw, distances, edges_pred, get_surface_distance).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@monai/metrics/generalized_dice.py`:
- Around line 69-92: The docstring for _compute_tensor incorrectly lists an
`ignore_index` parameter even though the method signature only has y_pred and y
and uses self.ignore_index; update the docstring to remove the `ignore_index`
parameter entry (or change it to document that ignore_index is taken from
self.ignore_index) so the arguments section matches the function signature and
mention that ignore_index is read from self.ignore_index; ensure the Args
section only documents y_pred, y and any class-sourced options like
self.ignore_index under a brief note.

---

Duplicate comments:
In `@monai/metrics/utils.py`:
- Around line 368-374: The mask application branch currently only runs when
edge_results[2] is a tuple of slices, so in subvoxel mode (where edge_results[2]
is an area tensor) the mask never gets applied; update the code in the block
handling mask, edge_results and edges_pred/edges_gt so it handles both cases: if
edge_results[2] is a tuple treat it as slices (slices = edge_results[2] and mask
= mask[slices]), otherwise treat edge_results[2] as an area tensor (derive a
boolean mask from that tensor, e.g. area_tensor > 0 or nonzero as appropriate),
then cast the mask to torch.bool on edges_pred.device and apply edges_pred =
edges_pred & mask and edges_gt = edges_gt & mask; refer to variables
edge_results, mask, slices, edges_pred and edges_gt to locate and modify the
logic.

---

Nitpick comments:
In `@monai/metrics/utils.py`:
- Around line 385-398: The None check in building distances_list is unnecessary
because get_surface_distance never returns None; update the construction around
distances_list/distances_raw to remove the d is not None guard and directly use
the tensors (e.g., replace the comprehension creating distances_list with one
that uses d from distances_raw directly, keeping the fallback of
edges_pred.new_empty((0,)) only if you still need a safe empty tensor), ensuring
downstream variables distances and distances_list remain the same shape/typing
used by the rest of the function (references: distances_list, distances_raw,
distances, edges_pred, get_surface_distance).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: ce651529-39eb-4da6-aba7-5c7fa61a68de

📥 Commits

Reviewing files that changed from the base of the PR and between 170f34a and c80eeeb.

📒 Files selected for processing (2)
  • monai/metrics/generalized_dice.py
  • monai/metrics/utils.py

Signed-off-by: Rusheel Sharma <rusheelhere@gmail.com>
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
monai/metrics/utils.py (1)

344-359: ⚠️ Potential issue | 🟡 Minor

Docstring is missing the new mask argument.

Please document mask in Args and describe how it affects edge/distance computation.

As per coding guidelines, "Docstrings should be present for all definition which describe each variable, return value, and raised exception in the appropriate section of the Google-style of docstrings."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@monai/metrics/utils.py` around lines 344 - 359, Add a new Args entry
documenting the mask parameter: state its name and type (optional
array-like/bool mask with same spatial shape as y_pred/y, default None), state
that when provided voxels where mask is False are ignored (excluded) from edge
detection and distance calculations so edges and distances are computed only
inside the masked region, and clarify how this affects outputs
(edges_pred/edges_gt and distances correspond only to unmasked voxels); update
the Args section near y_pred/y and the Returns description for (edges_pred,
edges_gt) to mention masked outputs. Ensure the description references the mask
behavior consistently with get_surface_distance and the existing parameters
(use_subvoxels, spacing, symmetric, class_index).
♻️ Duplicate comments (1)
monai/metrics/utils.py (1)

335-396: ⚠️ Potential issue | 🟠 Major

mask is never applied in get_edge_surface_distance.

mask is accepted but unused, so ignored regions still affect surface distances. This can silently break ignore-index behavior in surface/hausdorff metrics.

Suggested fix
 def get_edge_surface_distance(
@@
-    edge_results = get_mask_edges(y_pred, y, crop=True, spacing=edges_spacing, always_return_as_numpy=False)
+    # keep spatial alignment when an external mask is provided
+    edge_results = get_mask_edges(
+        y_pred, y, crop=(mask is None), spacing=edges_spacing, always_return_as_numpy=False
+    )
     edges_pred, edges_gt = edge_results[0], edge_results[1]
+
+    if mask is not None:
+        mask = mask.to(edges_pred.device).bool()
+        edges_pred = edges_pred & mask
+        edges_gt = edges_gt & mask
@@
-    out = convert_to_tensor(((edges_pred, edges_gt), distances, tuple(areas)), device=y_pred.device)  # type: ignore[no-any-return]
+    out = convert_to_tensor(((edges_pred, edges_gt), distances, tuple(areas)), device=y_pred.device)  # type: ignore[no-any-return]
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@monai/metrics/utils.py` around lines 335 - 396, The function
get_edge_surface_distance accepts a mask parameter but never applies it; update
get_edge_surface_distance to respect mask by applying it to the edge maps before
computing distances: after obtaining edges_pred and edges_gt from
get_mask_edges, if mask is not None (and matches the cropped shape returned by
get_mask_edges) set edge voxels outside the mask to zero (e.g.,
edges_pred[~mask]=0 and edges_gt[~mask]=0) or alternatively pass the mask into
get_mask_edges if that helper supports it; ensure the same device/dtype
alignment and handle None mask as currently done so subsequent calls to
get_surface_distance use masked edges only.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@monai/metrics/utils.py`:
- Around line 344-359: Add a new Args entry documenting the mask parameter:
state its name and type (optional array-like/bool mask with same spatial shape
as y_pred/y, default None), state that when provided voxels where mask is False
are ignored (excluded) from edge detection and distance calculations so edges
and distances are computed only inside the masked region, and clarify how this
affects outputs (edges_pred/edges_gt and distances correspond only to unmasked
voxels); update the Args section near y_pred/y and the Returns description for
(edges_pred, edges_gt) to mention masked outputs. Ensure the description
references the mask behavior consistently with get_surface_distance and the
existing parameters (use_subvoxels, spacing, symmetric, class_index).

---

Duplicate comments:
In `@monai/metrics/utils.py`:
- Around line 335-396: The function get_edge_surface_distance accepts a mask
parameter but never applies it; update get_edge_surface_distance to respect mask
by applying it to the edge maps before computing distances: after obtaining
edges_pred and edges_gt from get_mask_edges, if mask is not None (and matches
the cropped shape returned by get_mask_edges) set edge voxels outside the mask
to zero (e.g., edges_pred[~mask]=0 and edges_gt[~mask]=0) or alternatively pass
the mask into get_mask_edges if that helper supports it; ensure the same
device/dtype alignment and handle None mask as currently done so subsequent
calls to get_surface_distance use masked edges only.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 8e861711-6956-4e26-9b9d-3ef53d9c9c3c

📥 Commits

Reviewing files that changed from the base of the PR and between c80eeeb and 1114907.

📒 Files selected for processing (2)
  • monai/metrics/generalized_dice.py
  • monai/metrics/utils.py

Signed-off-by: Rusheel Sharma <rusheelhere@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature Request: Support ignore_index / ignore_class in Losses and Metrics

1 participant