Skip to content

Commit 503e979

Browse files
asifuddin01claude
andcommitted
fix(metrics): score a missed prediction in Hausdorff percentile, don't drop it
`HausdorffDistanceMetric(percentile=...)` returns `nan` when one of the two masks is empty, where `percentile=None` returns `inf` for the same input. `get_surface_distance` reports an infinite distance for every boundary voxel when a mask is empty, so an all-infinite tensor reaches `_compute_percentile_hausdorff_distance`. `torch.quantile` interpolates linearly between the order statistics straddling the requested rank, and that interpolation is `inf + (inf - inf) * frac`, which is `nan`. The maximum and minimum paths escape it because they do not interpolate. The quantile of a constant sequence is that constant, so the `nan` is an artefact rather than a property of the distances. It also collides with the meaning this metric already gives `nan`: both-masks-empty returns it to say "not applicable", and `do_metric_reduction` excludes it from the average. A prediction that missed the structure entirely is therefore removed from a dataset score rather than counted as the worst case, and the reported HD95 improves as the model finds fewer structures. `get_not_nans=True` reveals the shrinking denominator but is off by default. Return the infinity directly when every distance is infinite, so the percentile path agrees with the maximum path. The guard is exact: `get_surface_distance` returns either all-finite or all-infinite distances, never a mixture, and each direction of the symmetric distance is reduced separately. Adds regression tests over both entry points at `percentile` None, 0, 50, 95, 99 and 100. Without the source change eight fail and four pass, the four being the non-interpolating None and 0 paths. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: asifuddin01 <md.asif.uddin@g.bracu.ac.bd>
1 parent c0d1ec1 commit 503e979

2 files changed

Lines changed: 52 additions & 2 deletions

File tree

monai/metrics/hausdorff_distance.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,9 @@ class HausdorffDistanceMetric(CumulativeIterationMetric):
4444
the metric used to compute surface distance. Defaults to ``"euclidean"``.
4545
percentile: an optional float number between 0 and 100. If specified, the corresponding
4646
percentile of the Hausdorff Distance rather than the maximum result will be achieved.
47-
Defaults to ``None``.
47+
Defaults to ``None``. If one of the two masks is empty the distance is ``inf`` for
48+
every percentile, matching the maximum-distance result; ``nan`` is returned only
49+
when both masks are empty, and is excluded from the reduction.
4850
directed: whether to calculate directed Hausdorff distance. Defaults to ``False``.
4951
reduction: define mode of reduction to the metrics, will only apply reduction on `not-nan` values,
5052
available reduction modes: {``"none"``, ``"mean"``, ``"sum"``, ``"mean_batch"``, ``"sum_batch"``,
@@ -153,7 +155,9 @@ def compute_hausdorff_distance(
153155
the metric used to compute surface distance. Defaults to ``"euclidean"``.
154156
percentile: an optional float number between 0 and 100. If specified, the corresponding
155157
percentile of the Hausdorff Distance rather than the maximum result will be achieved.
156-
Defaults to ``None``.
158+
Defaults to ``None``. If one of the two masks is empty the distance is ``inf`` for
159+
every percentile, matching the maximum-distance result; ``nan`` is returned only
160+
when both masks are empty, and is excluded from the reduction.
157161
directed: whether to calculate directed Hausdorff distance. Defaults to ``False``.
158162
spacing: spacing of pixel (or voxel). This parameter is relevant only if ``distance_metric`` is set to ``"euclidean"``.
159163
If a single number, isotropic spacing with that value is used for all images in the batch. If a sequence of numbers,
@@ -208,5 +212,19 @@ def _compute_percentile_hausdorff_distance(
208212
return surface_distance.max()
209213

210214
if 0 <= percentile <= 100:
215+
# `get_surface_distance` reports an infinite distance for every voxel when one of
216+
# the two masks is empty, so a prediction that missed the structure entirely
217+
# arrives here as an all-infinite tensor. `torch.quantile` interpolates linearly
218+
# between the two order statistics that straddle the requested rank, and that
219+
# interpolation is NaN when both of them are infinite -- inf + (inf - inf) * frac.
220+
#
221+
# NaN is this metric's "not applicable" sentinel: it is what an empty prediction
222+
# *and* an empty ground truth returns above, and `do_metric_reduction` drops it
223+
# from the average. Returning it for a total miss would quietly remove the worst
224+
# cases from a dataset score instead of counting them. The maximum-distance path
225+
# already answers `inf` for exactly this input, and the quantile of a constant
226+
# sequence is that constant, so answer `inf` here too.
227+
if torch.isinf(surface_distance).all():
228+
return torch.tensor(np.inf, dtype=torch.float, device=surface_distance.device)
211229
return torch.quantile(surface_distance, percentile / 100)
212230
raise ValueError(f"percentile should be a value between 0 and 100, get {percentile}.")

tests/metrics/test_hausdorff_distance.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from parameterized import parameterized
2020

2121
from monai.metrics import HausdorffDistanceMetric
22+
from monai.metrics.hausdorff_distance import _compute_percentile_hausdorff_distance
2223

2324
_devices = ["cpu"]
2425
if torch.cuda.is_available():
@@ -153,6 +154,11 @@ def create_spherical_seg_3d(
153154
],
154155
]
155156

157+
# An empty prediction against a non-empty ground truth: every surface distance is
158+
# infinite, and the reported distance must stay infinite whatever percentile is asked
159+
# for. NaN is reserved for the case where there is no structure on either side.
160+
TEST_CASES_EMPTY_PREDICTION = [[None], [0], [50], [95], [99], [100]]
161+
156162
TEST_CASES_EXPANDED = []
157163
for test_case in TEST_CASES:
158164
test_output: list[float | int]
@@ -204,6 +210,32 @@ def test_nans(self, input_data):
204210
np.testing.assert_allclose(0, result, rtol=1e-7)
205211
np.testing.assert_allclose(0, not_nans, rtol=1e-7)
206212

213+
@parameterized.expand(TEST_CASES_EMPTY_PREDICTION)
214+
def test_empty_prediction_is_infinite(self, percentile):
215+
"""A prediction that misses the structure entirely scores `inf`, not NaN.
216+
217+
NaN is dropped by `do_metric_reduction`, so returning it here would take the
218+
model's worst cases out of a dataset average rather than scoring them.
219+
"""
220+
seg_gt = torch.tensor(create_spherical_seg_3d(radius=20, centre=(20, 20, 20)))
221+
seg_pred = torch.zeros_like(seg_gt)
222+
hd_metric = HausdorffDistanceMetric(include_background=True, percentile=percentile, get_not_nans=True)
223+
hd_metric(seg_pred.unsqueeze(0).unsqueeze(0), seg_gt.unsqueeze(0).unsqueeze(0))
224+
result, not_nans = hd_metric.aggregate()
225+
self.assertTrue(torch.isinf(result).all(), f"expected inf, got {result}")
226+
np.testing.assert_allclose(1, not_nans, rtol=1e-7)
227+
228+
@parameterized.expand(TEST_CASES_EMPTY_PREDICTION)
229+
def test_all_infinite_surface_distance(self, percentile):
230+
"""The quantile of an all-infinite tensor is infinite, at every percentile.
231+
232+
`torch.quantile` interpolates between order statistics and returns NaN when the
233+
two it interpolates between are both infinite.
234+
"""
235+
surface_distance = torch.full((7,), float("inf"))
236+
result = _compute_percentile_hausdorff_distance(surface_distance, percentile)
237+
self.assertTrue(torch.isinf(result), f"expected inf, got {result}")
238+
207239

208240
if __name__ == "__main__":
209241
unittest.main()

0 commit comments

Comments
 (0)