From b84bc661919d4092ede6334630c9bbce3f19e3d0 Mon Sep 17 00:00:00 2001 From: znanan Date: Wed, 28 Oct 2026 23:41:26 +0800 Subject: [PATCH 1/2] perf: speed up FROC curve computation Signed-off-by: znanan --- monai/metrics/froc.py | 19 ++++++++------- tests/metrics/test_compute_froc.py | 37 +++++++++++++++++++++++++++--- 2 files changed, 45 insertions(+), 11 deletions(-) diff --git a/monai/metrics/froc.py b/monai/metrics/froc.py index 3faef84917..e6e7a291cf 100644 --- a/monai/metrics/froc.py +++ b/monai/metrics/froc.py @@ -145,15 +145,18 @@ def compute_froc_curve_data( if isinstance(tp_probs, torch.Tensor): tp_probs = tp_probs.detach().cpu().numpy() - total_fps, total_tps = [], [] + # Keep the threshold definition (unique scores from both arrays, excluding the + # smallest score) while avoiding a full scan of both arrays at every threshold. all_probs = sorted(set(list(fp_probs) + list(tp_probs))) - for thresh in all_probs[1:]: - total_fps.append((fp_probs >= thresh).sum()) - total_tps.append((tp_probs >= thresh).sum()) - total_fps.append(0) - total_tps.append(0) - fps_per_image = np.asarray(total_fps) / float(num_images) - total_sensitivity = np.asarray(total_tps) / float(num_targets) + thresholds = all_probs[1:] + sorted_fp_probs = np.sort(fp_probs) + sorted_tp_probs = np.sort(tp_probs) + total_fps = len(sorted_fp_probs) - np.searchsorted(sorted_fp_probs, thresholds, side="left") + total_tps = len(sorted_tp_probs) - np.searchsorted(sorted_tp_probs, thresholds, side="left") + total_fps = np.append(total_fps, 0) + total_tps = np.append(total_tps, 0) + fps_per_image = total_fps / float(num_images) + total_sensitivity = total_tps / float(num_targets) return fps_per_image, total_sensitivity diff --git a/tests/metrics/test_compute_froc.py b/tests/metrics/test_compute_froc.py index aa889ddb07..6195a8a7d4 100644 --- a/tests/metrics/test_compute_froc.py +++ b/tests/metrics/test_compute_froc.py @@ -139,7 +139,6 @@ class TestComputeFpTp(unittest.TestCase): - @parameterized.expand( [TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_EXCLUDE_ABSENT, TEST_CASE_EXCLUDE_DUPLICATE] ) @@ -151,7 +150,6 @@ def test_value(self, input_data, expected_fp, expected_tp, expected_num): class TestComputeFpTpNd(unittest.TestCase): - @parameterized.expand([TEST_CASE_ND_1, TEST_CASE_ND_2]) def test_value(self, input_data, expected_fp, expected_tp, expected_num): fp_probs, tp_probs, num_tumors = compute_fp_tp_probs_nd(**input_data) @@ -161,7 +159,6 @@ def test_value(self, input_data, expected_fp, expected_tp, expected_num): class TestComputeFrocScore(unittest.TestCase): - @parameterized.expand([TEST_CASE_4, TEST_CASE_5]) def test_value(self, input_data, thresholds, expected_score): fps_per_image, total_sensitivity = compute_froc_curve_data(**input_data) @@ -169,5 +166,39 @@ def test_value(self, input_data, thresholds, expected_score): np.testing.assert_allclose(score, expected_score, rtol=1e-5) +def _reference_compute_froc_curve_data(fp_probs, tp_probs, num_targets, num_images): + """Original threshold-by-threshold implementation, retained for regression comparisons.""" + total_fps, total_tps = [], [] + all_probs = sorted(set(list(fp_probs) + list(tp_probs))) + for thresh in all_probs[1:]: + total_fps.append((fp_probs >= thresh).sum()) + total_tps.append((tp_probs >= thresh).sum()) + total_fps.append(0) + total_tps.append(0) + return np.asarray(total_fps) / float(num_images), np.asarray(total_tps) / float(num_targets) + + +class TestComputeFrocCurveData(unittest.TestCase): + @parameterized.expand( + [ + ("duplicates", [0.2, 0.5, 0.5, 0.9], [0.1, 0.5, 0.5, 0.8]), + ("mostly_unique", [0.12, 0.34, 0.56, 0.78], [0.23, 0.45, 0.67, 0.89]), + ("all_equal", [0.4, 0.4], [0.4, 0.4, 0.4]), + ("empty", [], []), + ("empty_fp", [], [0.2, 0.6, 0.6]), + ("empty_tp", [0.2, 0.6, 0.6], []), + ("threshold_equality", [0.2, 0.5, 0.8], [0.5, 0.5, 0.9]), + ] + ) + def test_matches_reference(self, _, fp_probs, tp_probs): + fp_probs = np.asarray(fp_probs) + tp_probs = np.asarray(tp_probs) + actual = compute_froc_curve_data(fp_probs, tp_probs, num_targets=3, num_images=2) + expected = _reference_compute_froc_curve_data(fp_probs, tp_probs, num_targets=3, num_images=2) + for actual_array, expected_array in zip(actual, expected): + np.testing.assert_array_equal(actual_array, expected_array) + self.assertEqual(actual_array.dtype, expected_array.dtype) + + if __name__ == "__main__": unittest.main() From 6401db2a788b8a8cc138db4a435293e2f2fba5f4 Mon Sep 17 00:00:00 2001 From: znanan Date: Wed, 28 Oct 2026 23:53:00 +0800 Subject: [PATCH 2/2] fix: exclude NaN scores from FROC counts Signed-off-by: znanan --- monai/metrics/froc.py | 4 ++-- tests/metrics/test_compute_froc.py | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/monai/metrics/froc.py b/monai/metrics/froc.py index e6e7a291cf..aca2c48ec4 100644 --- a/monai/metrics/froc.py +++ b/monai/metrics/froc.py @@ -149,8 +149,8 @@ def compute_froc_curve_data( # smallest score) while avoiding a full scan of both arrays at every threshold. all_probs = sorted(set(list(fp_probs) + list(tp_probs))) thresholds = all_probs[1:] - sorted_fp_probs = np.sort(fp_probs) - sorted_tp_probs = np.sort(tp_probs) + sorted_fp_probs = np.sort(fp_probs[~np.isnan(fp_probs)]) + sorted_tp_probs = np.sort(tp_probs[~np.isnan(tp_probs)]) total_fps = len(sorted_fp_probs) - np.searchsorted(sorted_fp_probs, thresholds, side="left") total_tps = len(sorted_tp_probs) - np.searchsorted(sorted_tp_probs, thresholds, side="left") total_fps = np.append(total_fps, 0) diff --git a/tests/metrics/test_compute_froc.py b/tests/metrics/test_compute_froc.py index 6195a8a7d4..e1f0343e2d 100644 --- a/tests/metrics/test_compute_froc.py +++ b/tests/metrics/test_compute_froc.py @@ -199,6 +199,12 @@ def test_matches_reference(self, _, fp_probs, tp_probs): np.testing.assert_array_equal(actual_array, expected_array) self.assertEqual(actual_array.dtype, expected_array.dtype) + def test_nan_fp_probs_are_not_counted(self): + fp_probs = np.asarray([np.nan]) + tp_probs = np.asarray([0.5, 0.8]) + fps_per_image, _ = compute_froc_curve_data(fp_probs, tp_probs, num_targets=2, num_images=1) + np.testing.assert_array_equal(fps_per_image, np.zeros_like(fps_per_image)) + if __name__ == "__main__": unittest.main()