Skip to content

Commit dd13006

Browse files
committed
tests(metrics): cover confusion-matrix Dice fast path and fallbacks
Add equivalence tests for the multi-class label-map fast path against the per-channel reference across include_background and ignore_empty, plus an out-of-range label case that must fall back to the per-channel loop. Also keep the mixed single/multi-channel cases for the loop path. Signed-off-by: Soumya Snigdha Kundu <soumya_snigdha.kundu@kcl.ac.uk>
1 parent 32b3fd8 commit dd13006

1 file changed

Lines changed: 79 additions & 2 deletions

File tree

tests/metrics/test_compute_meandice.py

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -291,15 +291,66 @@
291291
]
292292

293293

294+
# single-channel y (class indices) with multi-channel y_pred (one-hot)
295+
TEST_CASE_MIXED_1 = [
296+
{
297+
"y_pred": torch.tensor(
298+
[[[[0.0, 1.0], [0.0, 0.0]], [[0.0, 0.0], [0.0, 1.0]], [[1.0, 0.0], [1.0, 0.0]]]]
299+
), # (1, 3, 2, 2) one-hot
300+
"y": torch.tensor([[[[0.0, 1.0], [2.0, 1.0]]]]), # (1, 1, 2, 2) class indices
301+
"include_background": True,
302+
},
303+
# class 0: y_gt=[[1,0],[0,0]], y_pred=[[0,1],[0,0]] -> dice=0.0
304+
# class 1: y_gt=[[0,1],[0,1]], y_pred=[[0,0],[0,1]] -> dice=2/3
305+
# class 2: y_gt=[[0,0],[1,0]], y_pred=[[1,0],[1,0]] -> dice=2/3
306+
[[0.0000, 0.6667, 0.6667]],
307+
]
308+
309+
# single-channel y_pred (argmaxed, with num_classes) with multi-channel y (one-hot)
310+
TEST_CASE_MIXED_2 = [
311+
{
312+
"y_pred": torch.tensor([[[[2.0, 2.0], [2.0, 2.0]]]]), # (1, 1, 2, 2) all class 2
313+
"y": torch.tensor(
314+
[[[[1.0, 1.0], [1.0, 1.0]], [[0.0, 0.0], [0.0, 0.0]], [[0.0, 0.0], [0.0, 0.0]]]]
315+
), # (1, 3, 2, 2) one-hot, all background
316+
"include_background": True,
317+
"num_classes": 3,
318+
},
319+
# class 0: y_gt=[1,1,1,1](4), y_pred=[0,0,0,0](0) -> dice=0.0
320+
# class 1: y_gt=[0,0,0,0](0), y_pred=[0,0,0,0](0) -> dice=nan (ignore_empty default)
321+
# class 2: y_gt=[0,0,0,0](0), y_pred=[1,1,1,1](4) -> dice=nan (ignore_empty default)
322+
[[False, True, True]], # False=not-nan, True=nan
323+
]
324+
325+
# single-channel y (class indices) with multi-channel y_pred, exclude background
326+
TEST_CASE_MIXED_3 = [
327+
{
328+
"y_pred": torch.tensor(
329+
[
330+
[[[0.0, 1.0], [0.0, 0.0]], [[0.0, 0.0], [1.0, 1.0]], [[1.0, 0.0], [0.0, 0.0]]],
331+
[[[0.0, 0.0], [0.0, 1.0]], [[1.0, 0.0], [0.0, 0.0]], [[0.0, 1.0], [1.0, 0.0]]],
332+
]
333+
), # (2, 3, 2, 2) one-hot
334+
"y": torch.tensor([[[[0.0, 0.0], [0.0, 1.0]]], [[[0.0, 0.0], [0.0, 1.0]]]]), # (2, 1, 2, 2) class indices
335+
"include_background": False,
336+
},
337+
# batch 0: class 1 y_gt=[[0,0],[0,1]], y_pred=[[0,0],[1,1]] -> dice=2/3
338+
# class 2 y_gt=[[0,0],[0,0]], y_pred=[[1,0],[0,0]] -> dice=nan
339+
# batch 1: class 1 y_gt=[[0,0],[0,1]], y_pred=[[1,0],[0,0]] -> dice=0.0
340+
# class 2 y_gt=[[0,0],[0,0]], y_pred=[[0,1],[1,0]] -> dice=nan
341+
[[False, True], [False, True]], # nan pattern
342+
]
343+
344+
294345
class TestComputeMeanDice(unittest.TestCase):
295346

296-
@parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_9, TEST_CASE_11, TEST_CASE_12])
347+
@parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_9, TEST_CASE_11, TEST_CASE_12, TEST_CASE_MIXED_1])
297348
def test_value(self, input_data, expected_value):
298349
result = compute_dice(**input_data)
299350
np.testing.assert_allclose(result.cpu().numpy(), expected_value, atol=1e-4)
300351
np.testing.assert_equal(result.device, input_data["y_pred"].device)
301352

302-
@parameterized.expand([TEST_CASE_3])
353+
@parameterized.expand([TEST_CASE_3, TEST_CASE_MIXED_2, TEST_CASE_MIXED_3])
303354
def test_nans(self, input_data, expected_value):
304355
result = compute_dice(**input_data)
305356
self.assertTrue(np.allclose(np.isnan(result.cpu().numpy()), expected_value))
@@ -359,6 +410,32 @@ def test_channel_dimensions(self):
359410
with self.assertRaises(ValueError):
360411
DiceMetric(per_component=True)(torch.ones([3, 3, 144, 144]), torch.ones([3, 3, 144, 144]))
361412

413+
def test_label_map_fast_path(self):
414+
torch.manual_seed(0)
415+
for n in (3, 8):
416+
for include_background in (True, False):
417+
for ignore_empty in (True, False):
418+
kwargs = dict(
419+
num_classes=n,
420+
include_background=include_background,
421+
ignore_empty=ignore_empty,
422+
get_not_nans=False,
423+
)
424+
y_pred = torch.randint(0, n, (2, 1, 8, 8, 8))
425+
y = torch.randint(0, n, (2, 1, 8, 8, 8))
426+
fast = DiceHelper(**kwargs)(y_pred, y)
427+
ref = DiceHelper(**kwargs)(torch.nn.functional.one_hot(y_pred[:, 0], n).movedim(-1, 1), y)
428+
np.testing.assert_array_equal(torch.isnan(fast).cpu().numpy(), torch.isnan(ref).cpu().numpy())
429+
np.testing.assert_allclose(
430+
torch.nan_to_num(fast).cpu().numpy(), torch.nan_to_num(ref).cpu().numpy(), atol=1e-4
431+
)
432+
433+
def test_label_map_out_of_range_fallback(self):
434+
y_pred = torch.tensor([[[[0, 1, 2, 3]]]])
435+
y = torch.tensor([[[[0, 1, 2, 0]]]])
436+
result = DiceHelper(num_classes=3, get_not_nans=False)(y_pred, y)
437+
np.testing.assert_allclose(result.cpu().numpy(), [1.0, 1.0], atol=1e-4)
438+
362439

363440
if __name__ == "__main__":
364441
unittest.main()

0 commit comments

Comments
 (0)