Describe the bug
HausdorffDistanceMetric(percentile=...) returns nan when one of the two masks is empty, where percentile=None returns inf for the same input.
nan is not a quieter way of saying inf here. It is this metric's "not applicable" sentinel — it is what both-masks-empty returns — 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.
To Reproduce
import torch
from monai.metrics import HausdorffDistanceMetric
gt = torch.zeros(1, 1, 64, 64)
gt[0, 0, 20:28, 20:28] = 1.0
pred = torch.zeros(1, 1, 64, 64) # the model found nothing
for percentile in (None, 50, 95, 99, 100):
m = HausdorffDistanceMetric(include_background=True, percentile=percentile)
m(y_pred=pred, y=gt)
print(percentile, m.get_buffer().flatten().tolist())
# None -> [inf]
# 50 -> [nan]
# 95 -> [nan]
# 99 -> [nan]
# 100 -> [nan]
The effect over a dataset. 100 images, every non-empty prediction identically 4px off, so the only variable is how many images the model misses:
| images missed |
reported HD95 |
cases averaged (get_not_nans) |
| 0 |
4.000 |
100 |
| 50 |
4.000 |
50 |
| 90 |
4.000 |
10 |
| 99 |
4.000 |
1 |
A model that finds nothing in 99 images out of 100 reports the same HD95 as one that finds the structure every time. A runnable script that prints the whole table is at https://github.com/asifuddin01/A-PR.
Expected behavior
inf, matching percentile=None, so the case is scored rather than discarded. nan stays reserved for both masks empty, where there is genuinely nothing to measure.
Cause
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 two order statistics straddling the requested rank:
inf + (inf - inf) * 0.95 -> nan
The quantile of a constant sequence is that constant, so the nan is an artefact of the interpolation rather than a property of the distances. percentile=None escapes it because .max() does not interpolate.
Environment
MONAI version: 1.6.0
Numpy version: 2.5.2
Pytorch version: 2.14.0
MONAI flags: HAS_EXT = False, USE_COMPILED = False, USE_META_DICT = False
MONAI rev id: eccefc57550b111ed781d82249dfe77872a0e918
Also reproduced on current dev at c0d1ec1.
Additional context
Only the percentile path is affected. SurfaceDistanceMetric returns inf for the same input and SurfaceDiceMetric returns 0.0; both are already honest about a miss.
Separately, #9033 fixes percentile=0 being treated as unset by the if not percentile: guard. It does not cover this: it adds a .min() branch above the torch.quantile call and leaves the interpolation as it is. The two are independent.
I have a fix and regression tests ready and will open a PR against this issue.
Describe the bug
HausdorffDistanceMetric(percentile=...)returnsnanwhen one of the two masks is empty, wherepercentile=Nonereturnsinffor the same input.nanis not a quieter way of sayinginfhere. It is this metric's "not applicable" sentinel — it is what both-masks-empty returns — anddo_metric_reductionexcludes 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.To Reproduce
The effect over a dataset. 100 images, every non-empty prediction identically 4px off, so the only variable is how many images the model misses:
get_not_nans)A model that finds nothing in 99 images out of 100 reports the same HD95 as one that finds the structure every time. A runnable script that prints the whole table is at https://github.com/asifuddin01/A-PR.
Expected behavior
inf, matchingpercentile=None, so the case is scored rather than discarded.nanstays reserved for both masks empty, where there is genuinely nothing to measure.Cause
get_surface_distancereports an infinite distance for every boundary voxel when a mask is empty, so an all-infinite tensor reaches_compute_percentile_hausdorff_distance.torch.quantileinterpolates linearly between the two order statistics straddling the requested rank:The quantile of a constant sequence is that constant, so the
nanis an artefact of the interpolation rather than a property of the distances.percentile=Noneescapes it because.max()does not interpolate.Environment
Also reproduced on current
devat c0d1ec1.Additional context
Only the percentile path is affected.
SurfaceDistanceMetricreturnsinffor the same input andSurfaceDiceMetricreturns0.0; both are already honest about a miss.Separately, #9033 fixes
percentile=0being treated as unset by theif not percentile:guard. It does not cover this: it adds a.min()branch above thetorch.quantilecall and leaves the interpolation as it is. The two are independent.I have a fix and regression tests ready and will open a PR against this issue.