From f33b73d93d75d8b55df579b2c241056cfa3c7df8 Mon Sep 17 00:00:00 2001 From: JoaoMarcos44 <87440198+JoaoMarcos44@users.noreply.github.com> Date: Tue, 20 Oct 2026 04:10:35 -0300 Subject: [PATCH 1/7] test(metrics): cover spatial exclusion in confusion matrix scores Add seven regression cases for ignored-class pixels leaking into other classes' precision and negative counts, plus two sentinel-mask controls. Before the implementation change, the targeted suite runs 77 tests with seven expected failures and nine optional SciPy cases skipped in the minimal dependency environment. Assisted-by: OpenAI Codex Signed-off-by: JoaoMarcos44 <87440198+JoaoMarcos44@users.noreply.github.com> --- tests/metrics/test_ignore_index_metrics.py | 44 ++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/metrics/test_ignore_index_metrics.py b/tests/metrics/test_ignore_index_metrics.py index 5353ab6618a..3a3fe1de6e1 100644 --- a/tests/metrics/test_ignore_index_metrics.py +++ b/tests/metrics/test_ignore_index_metrics.py @@ -26,6 +26,7 @@ SurfaceDistanceMetric, compute_dice, compute_iou, + get_confusion_matrix, ) from monai.utils import optional_import @@ -158,6 +159,49 @@ def test_ignored_voxels_excluded_from_other_classes(self): self.assertEqual(iou[0, 0].item(), 1.0) torch.testing.assert_close(iou, dice, equal_nan=True) + @parameterized.expand([(background, sample) for background in (True, False) for sample in (True, False)]) + def test_confusion_matrix_excludes_ignored_voxels(self, include_background, compute_sample): + y = torch.tensor([[[1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]]]) + # The only error predicts class 2 at a voxel belonging to ignored class 1. + y_pred = torch.tensor([[[1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 0.0, 0.0], [0.0, 1.0, 1.0, 0.0]]]) + metric = ConfusionMatrixMetric( + include_background=include_background, + metric_name="precision", + compute_sample=compute_sample, + reduction="none", + ignore_index=1, + ) + metric(y_pred=y_pred, y=y) + class_index = 2 if include_background else 1 + self.assertEqual(metric.aggregate()[0][0, class_index].item(), 1.0) + counts = get_confusion_matrix(y_pred, y, include_background=include_background, ignore_index=1) + self.assertEqual(counts[0, class_index].tolist(), [1.0, 0.0, 2.0, 0.0]) + + @parameterized.expand([(True,), (False,)]) + def test_confusion_matrix_excludes_ignored_background_from_negatives(self, include_background): + y = torch.tensor([[[1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]]]) + y_pred = y.clone() + y_pred[0, 0, 0] = 0.0 + y_pred[0, 1, 0] = 1.0 + counts = get_confusion_matrix(y_pred, y, include_background=include_background, ignore_index=0) + class_index = 2 if include_background else 1 + self.assertEqual(counts[0, class_index].tolist(), [1.0, 0.0, 1.0, 0.0]) + + def test_confusion_matrix_keeps_errors_on_valid_voxels(self): + y = torch.tensor([[[1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]]]) + y_pred = torch.tensor([[[0.0, 0.0, 0.0, 1.0], [1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 1.0, 0.0]]]) + counts = get_confusion_matrix(y_pred, y, include_background=False, ignore_index=0) + self.assertEqual(counts[0, 0].tolist(), [0.0, 0.0, 1.0, 1.0]) + self.assertEqual(counts[0, 1].tolist(), [1.0, 1.0, 0.0, 0.0]) + + @parameterized.expand([(True,), (False,)]) + def test_confusion_matrix_sentinel_mask(self, include_background): + y = torch.tensor([[[1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]]]) + y_pred = torch.tensor([[[1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 0.0, 0.0], [0.0, 1.0, 1.0, 0.0]]]) + counts = get_confusion_matrix(y_pred, y, include_background=include_background, ignore_index=255) + class_index = 2 if include_background else 1 + self.assertEqual(counts[0, class_index].tolist(), [1.0, 0.0, 2.0, 0.0]) + def test_ignored_voxels_excluded_with_include_background_false(self): """The ignore_index mask must line up with the ignore_background channel strip.""" # 4 one-hot classes: 0=background, 1, 2=ignored, 3 From e42d7c4cec5574bca811e8423167dca1ccf34379 Mon Sep 17 00:00:00 2001 From: JoaoMarcos44 <87440198+JoaoMarcos44@users.noreply.github.com> Date: Tue, 20 Oct 2026 04:13:07 -0300 Subject: [PATCH 2/7] fix(metrics): exclude ignored voxels from confusion matrix scores Build the spatial ignore mask from the original labels for valid class indices as well as sentinel values. Preserve zeroing of the ignored channel while excluding its voxels from all positive and negative counts. The targeted suite now runs 77 cases successfully (68 passed, nine optional SciPy cases skipped). All eight standalone reproduction/control checks pass. Ruff, Black, and isort checks pass for both changed files. Assisted-by: OpenAI Codex Signed-off-by: JoaoMarcos44 <87440198+JoaoMarcos44@users.noreply.github.com> --- monai/metrics/confusion_matrix.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/monai/metrics/confusion_matrix.py b/monai/metrics/confusion_matrix.py index c471200069b..b038c3b8a80 100644 --- a/monai/metrics/confusion_matrix.py +++ b/monai/metrics/confusion_matrix.py @@ -156,8 +156,9 @@ def get_confusion_matrix( include_background: whether to include metric computation on the first channel of the predicted output. Defaults to True. ignore_index: single integer class index (or sentinel value) to ignore from the metric computation. - If ignore_index refers to a valid class channel, that channel is excluded. If it is outside the - class range, ignored regions are inferred from spatial locations where all label channels are zero. + If ignore_index refers to a valid class channel, that channel is excluded and voxels with that + ground-truth label are excluded from all classes' counts. If it is outside the class range, + ignored regions are inferred from spatial locations where all label channels are zero. Raises: ValueError: when `y_pred` and `y` have different shapes. @@ -173,7 +174,7 @@ class range, ignored regions are inferred from spatial locations where all label # get confusion matrix related metric batch_size, n_class = y_pred.shape[:2] - # Create spatial mask if ignore_index is provided + # Preserve exclusion of the ignored channel as well as its spatial locations. if ignore_index is not None and 0 <= ignore_index < (y_pred.shape[1] + (0 if include_background else 1)): ignore_channel = ignore_index if include_background else ignore_index - 1 if 0 <= ignore_channel < y_pred.shape[1]: @@ -181,9 +182,7 @@ class range, ignored regions are inferred from spatial locations where all label y = y.clone() y_pred[:, ignore_channel] = 0 y[:, ignore_channel] = 0 - mask = None - else: - mask = create_ignore_mask(original_y if ignore_index is not None else y, ignore_index) + mask = create_ignore_mask(original_y, ignore_index) # convert to [BNS], where S is the number of pixels for one sample. y_pred = y_pred.reshape(batch_size, n_class, -1) From 0323321d652ffa8b749312927ba24a0363b2431e Mon Sep 17 00:00:00 2001 From: JoaoMarcos44 <87440198+JoaoMarcos44@users.noreply.github.com> Date: Tue, 20 Oct 2026 04:34:24 -0300 Subject: [PATCH 3/7] test(metrics): reproduce the single-channel ignore mask regression Cover binary membership targets across float, integer, and boolean dtypes, background handling, mask extremes, specificity, and sentinel/no-mask controls. Document the regression tests added by this PR. At e42d7c4c, the targeted suite runs 92 tests with 11 expected failures and nine optional SciPy cases skipped in the minimal runtime. Assisted-by: OpenAI Codex Signed-off-by: JoaoMarcos44 <87440198+JoaoMarcos44@users.noreply.github.com> --- tests/metrics/test_ignore_index_metrics.py | 52 ++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/tests/metrics/test_ignore_index_metrics.py b/tests/metrics/test_ignore_index_metrics.py index 3a3fe1de6e1..cf8f34c328c 100644 --- a/tests/metrics/test_ignore_index_metrics.py +++ b/tests/metrics/test_ignore_index_metrics.py @@ -161,6 +161,7 @@ def test_ignored_voxels_excluded_from_other_classes(self): @parameterized.expand([(background, sample) for background in (True, False) for sample in (True, False)]) def test_confusion_matrix_excludes_ignored_voxels(self, include_background, compute_sample): + """Exclude ignored-class voxels from counts and precision in both aggregation modes.""" y = torch.tensor([[[1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]]]) # The only error predicts class 2 at a voxel belonging to ignored class 1. y_pred = torch.tensor([[[1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 0.0, 0.0], [0.0, 1.0, 1.0, 0.0]]]) @@ -179,6 +180,7 @@ def test_confusion_matrix_excludes_ignored_voxels(self, include_background, comp @parameterized.expand([(True,), (False,)]) def test_confusion_matrix_excludes_ignored_background_from_negatives(self, include_background): + """Exclude ignored background voxels from every channel's negative counts.""" y = torch.tensor([[[1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]]]) y_pred = y.clone() y_pred[0, 0, 0] = 0.0 @@ -188,6 +190,7 @@ def test_confusion_matrix_excludes_ignored_background_from_negatives(self, inclu self.assertEqual(counts[0, class_index].tolist(), [1.0, 0.0, 1.0, 0.0]) def test_confusion_matrix_keeps_errors_on_valid_voxels(self): + """Preserve false positives and false negatives at valid voxels.""" y = torch.tensor([[[1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]]]) y_pred = torch.tensor([[[0.0, 0.0, 0.0, 1.0], [1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 1.0, 0.0]]]) counts = get_confusion_matrix(y_pred, y, include_background=False, ignore_index=0) @@ -196,12 +199,61 @@ def test_confusion_matrix_keeps_errors_on_valid_voxels(self): @parameterized.expand([(True,), (False,)]) def test_confusion_matrix_sentinel_mask(self, include_background): + """Preserve sentinel masking with and without the background channel.""" y = torch.tensor([[[1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]]]) y_pred = torch.tensor([[[1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 0.0, 0.0], [0.0, 1.0, 1.0, 0.0]]]) counts = get_confusion_matrix(y_pred, y, include_background=include_background, ignore_index=255) class_index = 2 if include_background else 1 self.assertEqual(counts[0, class_index].tolist(), [1.0, 0.0, 2.0, 0.0]) + @parameterized.expand( + [(background, dtype) for background in (True, False) for dtype in (torch.float32, torch.int64, torch.bool)] + ) + def test_confusion_matrix_single_channel_ignore_class(self, include_background, dtype): + """Interpret a single channel as binary membership when ignoring class zero.""" + y = torch.tensor([[[1, 1, 0]]], dtype=dtype) + y_pred = torch.ones_like(y) + counts = get_confusion_matrix(y_pred, y, include_background=include_background, ignore_index=0) + expected = [0.0, 0.0, 1.0, 0.0] if include_background else [0.0, 1.0, 0.0, 0.0] + self.assertEqual(counts[0, 0].tolist(), expected) + + def test_confusion_matrix_single_channel_specificity(self): + """Retain a false positive at the only valid single-channel voxel.""" + y = torch.tensor([[[1.0, 1.0, 0.0]]]) + metric = ConfusionMatrixMetric( + include_background=False, metric_name="specificity", reduction="none", ignore_index=0 + ) + metric(y_pred=torch.ones_like(y), y=y) + self.assertEqual(metric.aggregate()[0].item(), 0.0) + + @parameterized.expand( + [ + (True, [1.0, 1.0, 1.0], [0.0, 0.0, 0.0, 0.0]), + (False, [1.0, 1.0, 1.0], [0.0, 0.0, 0.0, 0.0]), + (True, [0.0, 0.0, 0.0], [0.0, 0.0, 3.0, 0.0]), + (False, [0.0, 0.0, 0.0], [0.0, 3.0, 0.0, 0.0]), + ] + ) + def test_confusion_matrix_single_channel_mask_extremes(self, include_background, labels, expected): + """Handle all-ignored and entirely valid single-channel spatial regions.""" + y = torch.tensor([[labels]]) + counts = get_confusion_matrix(torch.ones_like(y), y, include_background=include_background, ignore_index=0) + self.assertEqual(counts[0, 0].tolist(), expected) + + @parameterized.expand([(True,), (False,)]) + def test_confusion_matrix_single_channel_sentinel(self, include_background): + """Keep the existing out-of-range comparison for single-channel targets.""" + y = torch.tensor([[[1.0, 1.0, 0.0]]]) + counts = get_confusion_matrix(torch.ones_like(y), y, include_background=include_background, ignore_index=255) + self.assertEqual(counts[0, 0].tolist(), [2.0, 1.0, 0.0, 0.0]) + + @parameterized.expand([(True,), (False,)]) + def test_confusion_matrix_single_channel_without_ignore(self, include_background): + """Leave the unmasked single-channel counts unchanged.""" + y = torch.tensor([[[1.0, 1.0, 0.0]]]) + counts = get_confusion_matrix(torch.ones_like(y), y, include_background=include_background) + self.assertEqual(counts[0, 0].tolist(), [2.0, 1.0, 0.0, 0.0]) + def test_ignored_voxels_excluded_with_include_background_false(self): """The ignore_index mask must line up with the ignore_background channel strip.""" # 4 one-hot classes: 0=background, 1, 2=ignored, 3 From 74d17d292f3fe87e7e9eab2c4d504213b46e3610 Mon Sep 17 00:00:00 2001 From: JoaoMarcos44 <87440198+JoaoMarcos44@users.noreply.github.com> Date: Tue, 20 Oct 2026 04:37:04 -0300 Subject: [PATCH 4/7] fix(metrics): handle one-channel binary ignore masks explicitly For a single-channel binary target, ignore_index=0 excludes ones rather than zeros. Handle that representation locally instead of using the shared helper's label-encoded convention. Keep multi-channel, sentinel, and unmasked paths unchanged; support bool without subtraction. Clarify the public input contract and retain the existing background and ignored-channel handling. Addresses review comment r4322421661. Targeted validation: 92 tests, 83 passed and nine optional SciPy skips; all eight standalone reproduction/control checks pass. Ruff, Black, and isort pass for both changed files. Eleven new regression failures were observed before this implementation change. Assisted-by: OpenAI Codex Signed-off-by: JoaoMarcos44 <87440198+JoaoMarcos44@users.noreply.github.com> --- monai/metrics/confusion_matrix.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/monai/metrics/confusion_matrix.py b/monai/metrics/confusion_matrix.py index b038c3b8a80..56e13b1bac5 100644 --- a/monai/metrics/confusion_matrix.py +++ b/monai/metrics/confusion_matrix.py @@ -63,6 +63,7 @@ class ConfusionMatrixMetric(CumulativeIterationMetric): Voxels with this label are excluded from the score, which is useful for padding, unlabeled regions, or boundary artifacts. For federated or aggregated settings, ensure all clients use the same ignore_index to keep score values comparable. + For a single-channel binary target, ignoring channel 0 excludes locations where the target is 1. """ @@ -153,12 +154,14 @@ def get_confusion_matrix( The values should be binarized. y: ground truth to compute the metric. It must be one-hot format and first dim is batch. The values should be binarized. + A single channel is a binary membership indicator, not a map of class IDs. include_background: whether to include metric computation on the first channel of the predicted output. Defaults to True. ignore_index: single integer class index (or sentinel value) to ignore from the metric computation. - If ignore_index refers to a valid class channel, that channel is excluded and voxels with that - ground-truth label are excluded from all classes' counts. If it is outside the class range, - ignored regions are inferred from spatial locations where all label channels are zero. + For a valid class index, voxels belonging to that channel are excluded from all classes' counts. + The existing channel-exclusion behavior is retained. With a single-channel binary target, + ignore_index=0 excludes locations where the target is 1. Out-of-range values use all-zero + locations for multi-channel targets and direct value comparison for single-channel targets. Raises: ValueError: when `y_pred` and `y` have different shapes. @@ -182,7 +185,11 @@ def get_confusion_matrix( y = y.clone() y_pred[:, ignore_channel] = 0 y[:, ignore_channel] = 0 - mask = create_ignore_mask(original_y, ignore_index) + if original_y.shape[1] == 1 and ignore_index == 0: + # This API receives binary membership, while the shared helper assumes class IDs for one channel. + mask = (original_y == 0).float() + else: + mask = create_ignore_mask(original_y, ignore_index) # convert to [BNS], where S is the number of pixels for one sample. y_pred = y_pred.reshape(batch_size, n_class, -1) From b151c8a3115977602cfd38d3b04d1822fb05c9f9 Mon Sep 17 00:00:00 2001 From: JoaoMarcos44 <87440198+JoaoMarcos44@users.noreply.github.com> Date: Tue, 20 Oct 2026 04:43:53 -0300 Subject: [PATCH 5/7] docs(metrics): describe confusion matrix returns and test parameters Complete Google-style Returns and Args sections requested in review. Preserve the binary membership explanation and document only the test cases introduced by this PR. Targeted validation: 92 cases, 83 passed and nine optional SciPy skips; all eight standalone controls pass. Ruff, Black, and isort pass. These changes affect docstrings only. Assisted-by: OpenAI Codex Signed-off-by: JoaoMarcos44 <87440198+JoaoMarcos44@users.noreply.github.com> --- monai/metrics/confusion_matrix.py | 4 ++ tests/metrics/test_ignore_index_metrics.py | 46 ++++++++++++++++++---- 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/monai/metrics/confusion_matrix.py b/monai/metrics/confusion_matrix.py index 56e13b1bac5..f76950174db 100644 --- a/monai/metrics/confusion_matrix.py +++ b/monai/metrics/confusion_matrix.py @@ -163,6 +163,10 @@ def get_confusion_matrix( ignore_index=0 excludes locations where the target is 1. Out-of-range values use all-zero locations for multi-channel targets and direct value comparison for single-channel targets. + Returns: + Tensor of shape [B, C, 4], containing true positive, false positive, true negative, and false negative + counts, in that order, for each sample and evaluated channel. + Raises: ValueError: when `y_pred` and `y` have different shapes. """ diff --git a/tests/metrics/test_ignore_index_metrics.py b/tests/metrics/test_ignore_index_metrics.py index cf8f34c328c..57bba507643 100644 --- a/tests/metrics/test_ignore_index_metrics.py +++ b/tests/metrics/test_ignore_index_metrics.py @@ -161,7 +161,12 @@ def test_ignored_voxels_excluded_from_other_classes(self): @parameterized.expand([(background, sample) for background in (True, False) for sample in (True, False)]) def test_confusion_matrix_excludes_ignored_voxels(self, include_background, compute_sample): - """Exclude ignored-class voxels from counts and precision in both aggregation modes.""" + """Exclude ignored-class voxels from counts and precision in both aggregation modes. + + Args: + include_background: whether to include the background channel in the output. + compute_sample: whether to compute precision per sample before aggregation. + """ y = torch.tensor([[[1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]]]) # The only error predicts class 2 at a voxel belonging to ignored class 1. y_pred = torch.tensor([[[1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 0.0, 0.0], [0.0, 1.0, 1.0, 0.0]]]) @@ -180,7 +185,11 @@ def test_confusion_matrix_excludes_ignored_voxels(self, include_background, comp @parameterized.expand([(True,), (False,)]) def test_confusion_matrix_excludes_ignored_background_from_negatives(self, include_background): - """Exclude ignored background voxels from every channel's negative counts.""" + """Exclude ignored background voxels from every channel's negative counts. + + Args: + include_background: whether to include the background channel in the output. + """ y = torch.tensor([[[1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]]]) y_pred = y.clone() y_pred[0, 0, 0] = 0.0 @@ -199,7 +208,11 @@ def test_confusion_matrix_keeps_errors_on_valid_voxels(self): @parameterized.expand([(True,), (False,)]) def test_confusion_matrix_sentinel_mask(self, include_background): - """Preserve sentinel masking with and without the background channel.""" + """Preserve sentinel masking with and without the background channel. + + Args: + include_background: whether to include the background channel in the output. + """ y = torch.tensor([[[1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]]]) y_pred = torch.tensor([[[1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 0.0, 0.0], [0.0, 1.0, 1.0, 0.0]]]) counts = get_confusion_matrix(y_pred, y, include_background=include_background, ignore_index=255) @@ -210,7 +223,12 @@ def test_confusion_matrix_sentinel_mask(self, include_background): [(background, dtype) for background in (True, False) for dtype in (torch.float32, torch.int64, torch.bool)] ) def test_confusion_matrix_single_channel_ignore_class(self, include_background, dtype): - """Interpret a single channel as binary membership when ignoring class zero.""" + """Interpret a single channel as binary membership when ignoring class zero. + + Args: + include_background: whether to request inclusion of the background channel. + dtype: data type of the binary target and prediction tensors. + """ y = torch.tensor([[[1, 1, 0]]], dtype=dtype) y_pred = torch.ones_like(y) counts = get_confusion_matrix(y_pred, y, include_background=include_background, ignore_index=0) @@ -235,21 +253,35 @@ def test_confusion_matrix_single_channel_specificity(self): ] ) def test_confusion_matrix_single_channel_mask_extremes(self, include_background, labels, expected): - """Handle all-ignored and entirely valid single-channel spatial regions.""" + """Handle all-ignored and entirely valid single-channel spatial regions. + + Args: + include_background: whether to request inclusion of the background channel. + labels: binary target values for the spatial locations. + expected: expected counts in true positive, false positive, true negative, false negative order. + """ y = torch.tensor([[labels]]) counts = get_confusion_matrix(torch.ones_like(y), y, include_background=include_background, ignore_index=0) self.assertEqual(counts[0, 0].tolist(), expected) @parameterized.expand([(True,), (False,)]) def test_confusion_matrix_single_channel_sentinel(self, include_background): - """Keep the existing out-of-range comparison for single-channel targets.""" + """Keep the existing out-of-range comparison for single-channel targets. + + Args: + include_background: whether to request inclusion of the background channel. + """ y = torch.tensor([[[1.0, 1.0, 0.0]]]) counts = get_confusion_matrix(torch.ones_like(y), y, include_background=include_background, ignore_index=255) self.assertEqual(counts[0, 0].tolist(), [2.0, 1.0, 0.0, 0.0]) @parameterized.expand([(True,), (False,)]) def test_confusion_matrix_single_channel_without_ignore(self, include_background): - """Leave the unmasked single-channel counts unchanged.""" + """Leave the unmasked single-channel counts unchanged. + + Args: + include_background: whether to request inclusion of the background channel. + """ y = torch.tensor([[[1.0, 1.0, 0.0]]]) counts = get_confusion_matrix(torch.ones_like(y), y, include_background=include_background) self.assertEqual(counts[0, 0].tolist(), [2.0, 1.0, 0.0, 0.0]) From 3d74777de87d69a2cbcbd1f9f0196ad905f6a73f Mon Sep 17 00:00:00 2001 From: joaomarcos Date: Tue, 20 Oct 2026 15:43:04 -0300 Subject: [PATCH 6/7] fix(metrics): support boolean targets in spatial ignore masks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build validity masks from binary channel membership without subtracting boolean tensors. Cover exact counts and input immutability across ignored classes, background modes, and prediction dtypes. 👾 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta Code Signed-off-by: joaomarcos --- monai/metrics/confusion_matrix.py | 6 ++-- tests/metrics/test_ignore_index_metrics.py | 36 ++++++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/monai/metrics/confusion_matrix.py b/monai/metrics/confusion_matrix.py index 4b70e89963d..68330a513d0 100644 --- a/monai/metrics/confusion_matrix.py +++ b/monai/metrics/confusion_matrix.py @@ -195,9 +195,9 @@ def get_confusion_matrix( y = y.clone() y_pred[:, ignore_channel] = 0 y[:, ignore_channel] = 0 - if original_y.shape[1] == 1 and ignore_index == 0: - # This API receives binary membership, while the shared helper assumes class IDs for one channel. - mask = (original_y == 0).float() + if ignore_index is not None and 0 <= ignore_index < original_y.shape[1]: + # Every channel represents binary membership; comparison also supports boolean targets. + mask = (original_y[:, ignore_index : ignore_index + 1] == 0).float() else: mask = create_ignore_mask(original_y, ignore_index) diff --git a/tests/metrics/test_ignore_index_metrics.py b/tests/metrics/test_ignore_index_metrics.py index 57bba507643..6facb8159db 100644 --- a/tests/metrics/test_ignore_index_metrics.py +++ b/tests/metrics/test_ignore_index_metrics.py @@ -219,6 +219,42 @@ def test_confusion_matrix_sentinel_mask(self, include_background): class_index = 2 if include_background else 1 self.assertEqual(counts[0, class_index].tolist(), [1.0, 0.0, 2.0, 0.0]) + @parameterized.expand( + [ + (background, ignore_index, dtype) + for background in (True, False) + for ignore_index in (0, 1, 2) + for dtype in (torch.float32, torch.int64, torch.bool) + ] + ) + def test_confusion_matrix_multichannel_boolean(self, include_background, ignore_index, prediction_dtype): + """Exclude ignored voxels from boolean one-hot targets without mutating inputs. + + Args: + include_background: whether to retain the background channel in the returned counts. + ignore_index: original class channel whose spatial locations must be excluded. + prediction_dtype: data type of the binarized prediction tensor. + """ + y = torch.tensor([[[1, 0, 0, 1], [0, 1, 0, 0], [0, 0, 1, 0]]], dtype=torch.bool) + y_pred = torch.tensor([[[1, 1, 0, 1], [0, 0, 0, 0], [0, 0, 1, 0]]], dtype=prediction_dtype) + original_y, original_pred = y.clone(), y_pred.clone() + expected = torch.tensor( + [ + [[0, 0, 2, 0], [0, 0, 1, 1], [1, 0, 1, 0]], + [[2, 0, 1, 0], [0, 0, 3, 0], [1, 0, 2, 0]], + [[2, 1, 0, 0], [0, 0, 2, 1], [0, 0, 3, 0]], + ], + dtype=torch.float32, + )[ignore_index].unsqueeze(0) + if not include_background: + expected = expected[:, 1:] + + counts = get_confusion_matrix(y_pred, y, include_background=include_background, ignore_index=ignore_index) + + torch.testing.assert_close(counts, expected) + torch.testing.assert_close(y, original_y) + torch.testing.assert_close(y_pred, original_pred) + @parameterized.expand( [(background, dtype) for background in (True, False) for dtype in (torch.float32, torch.int64, torch.bool)] ) From fe6736c6ea563421d3595cae2402d6f035b624d0 Mon Sep 17 00:00:00 2001 From: joaomarcos Date: Tue, 20 Oct 2026 16:01:52 -0300 Subject: [PATCH 7/7] fix(metrics): retain valid single-channel false positives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restrict ignored-channel zeroing to multi-channel targets so background inclusion cannot hide errors at valid single-channel locations. Cover both background modes in specificity and count regressions, and document the invariant. 👾 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta Code Signed-off-by: joaomarcos --- monai/metrics/confusion_matrix.py | 13 +++++++++---- tests/metrics/test_ignore_index_metrics.py | 16 ++++++++++------ 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/monai/metrics/confusion_matrix.py b/monai/metrics/confusion_matrix.py index 68330a513d0..67eff3451e1 100644 --- a/monai/metrics/confusion_matrix.py +++ b/monai/metrics/confusion_matrix.py @@ -165,8 +165,9 @@ def get_confusion_matrix( the predicted output. Defaults to True. ignore_index: single integer class index (or sentinel value) to ignore from the metric computation. For a valid class index, voxels belonging to that channel are excluded from all classes' counts. - The existing channel-exclusion behavior is retained. With a single-channel binary target, - ignore_index=0 excludes locations where the target is 1. Out-of-range values use all-zero + The existing channel-exclusion behavior is retained for multi-channel targets. With a single-channel + binary target, ignore_index=0 excludes locations where the target is 1 without zeroing the channel. + Counts at valid single-channel locations do not depend on include_background. Out-of-range values use all-zero locations for multi-channel targets and direct value comparison for single-channel targets. Returns: @@ -187,8 +188,12 @@ def get_confusion_matrix( # get confusion matrix related metric batch_size, n_class = y_pred.shape[:2] - # Preserve exclusion of the ignored channel as well as its spatial locations. - if ignore_index is not None and 0 <= ignore_index < (y_pred.shape[1] + (0 if include_background else 1)): + # Single-channel targets need spatial exclusion only, preserving errors at valid locations. + if ( + ignore_index is not None + and original_y.shape[1] > 1 + and 0 <= ignore_index < (y_pred.shape[1] + (0 if include_background else 1)) + ): ignore_channel = ignore_index if include_background else ignore_index - 1 if 0 <= ignore_channel < y_pred.shape[1]: y_pred = y_pred.clone() diff --git a/tests/metrics/test_ignore_index_metrics.py b/tests/metrics/test_ignore_index_metrics.py index 6facb8159db..674d763125d 100644 --- a/tests/metrics/test_ignore_index_metrics.py +++ b/tests/metrics/test_ignore_index_metrics.py @@ -268,14 +268,18 @@ def test_confusion_matrix_single_channel_ignore_class(self, include_background, y = torch.tensor([[[1, 1, 0]]], dtype=dtype) y_pred = torch.ones_like(y) counts = get_confusion_matrix(y_pred, y, include_background=include_background, ignore_index=0) - expected = [0.0, 0.0, 1.0, 0.0] if include_background else [0.0, 1.0, 0.0, 0.0] - self.assertEqual(counts[0, 0].tolist(), expected) + self.assertEqual(counts[0, 0].tolist(), [0.0, 1.0, 0.0, 0.0]) + + @parameterized.expand([(True,), (False,)]) + def test_confusion_matrix_single_channel_specificity(self, include_background): + """Retain a false positive at the only valid single-channel voxel. - def test_confusion_matrix_single_channel_specificity(self): - """Retain a false positive at the only valid single-channel voxel.""" + Args: + include_background: whether to request inclusion of the background channel. + """ y = torch.tensor([[[1.0, 1.0, 0.0]]]) metric = ConfusionMatrixMetric( - include_background=False, metric_name="specificity", reduction="none", ignore_index=0 + include_background=include_background, metric_name="specificity", reduction="none", ignore_index=0 ) metric(y_pred=torch.ones_like(y), y=y) self.assertEqual(metric.aggregate()[0].item(), 0.0) @@ -284,7 +288,7 @@ def test_confusion_matrix_single_channel_specificity(self): [ (True, [1.0, 1.0, 1.0], [0.0, 0.0, 0.0, 0.0]), (False, [1.0, 1.0, 1.0], [0.0, 0.0, 0.0, 0.0]), - (True, [0.0, 0.0, 0.0], [0.0, 0.0, 3.0, 0.0]), + (True, [0.0, 0.0, 0.0], [0.0, 3.0, 0.0, 0.0]), (False, [0.0, 0.0, 0.0], [0.0, 3.0, 0.0, 0.0]), ] )