Skip to content

Commit 27eb62d

Browse files
committed
fix(losses): avoid half precision mutual information overflow
Signed-off-by: kyinhub <kevinpyin@gmail.com>
1 parent 3ee058b commit 27eb62d

2 files changed

Lines changed: 97 additions & 15 deletions

File tree

monai/losses/image_dissimilarity.py

Lines changed: 44 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -316,40 +316,69 @@ def parzen_windowing_gaussian(self, img: torch.Tensor) -> tuple[torch.Tensor, to
316316
Note: the input is expected to range between 0 and 1
317317
Args:
318318
img: the shape should be B[NDHW].
319+
320+
Returns:
321+
A tuple containing per-sample Gaussian bin weights and the
322+
corresponding marginal probability.
323+
324+
Raises:
325+
ValueError: if the Gaussian kernel buffers are unavailable.
319326
"""
320-
img = torch.clamp(img, 0, 1)
327+
output_dtype = img.dtype
328+
if output_dtype == torch.float16:
329+
img = img.float()
330+
compute_dtype = torch.float32 if img.dtype in (torch.float16, torch.bfloat16) else img.dtype
331+
img = torch.clamp(img, 0, 1).to(dtype=compute_dtype)
321332
img = img.reshape(img.shape[0], -1, 1) # (batch, num_sample, 1)
322333
if self.bin_centers is None or self.preterm is None:
323334
raise ValueError("bin_centers and preterm must be defined for gaussian parzen windowing.")
324-
weight = torch.exp(
325-
-self.preterm.to(img) * (img - self.bin_centers.to(img)) ** 2
326-
) # (batch, num_sample, num_bin)
335+
preterm = self.preterm.to(device=img.device, dtype=compute_dtype)
336+
bin_centers = self.bin_centers.to(device=img.device, dtype=compute_dtype)
337+
weight = torch.exp(-preterm * (img - bin_centers) ** 2) # (batch, num_sample, num_bin)
327338
weight = weight / torch.sum(weight, dim=-1, keepdim=True) # (batch, num_sample, num_bin)
328339
probability = torch.mean(weight, dim=-2, keepdim=True) # (batch, 1, num_bin)
340+
if output_dtype in (torch.float16, torch.bfloat16):
341+
weight = weight.to(dtype=output_dtype)
342+
probability = probability.to(dtype=output_dtype)
329343
return weight, probability
330344

331345
def forward(self, pred: torch.Tensor, target: torch.Tensor) -> torch.Tensor:
332346
"""
333347
Args:
334348
pred: the shape should be B[NDHW].
335349
target: the shape should be same as the pred shape.
350+
Returns:
351+
Reduced negative mutual information loss.
352+
336353
Raises:
337354
ValueError: When ``self.reduction`` is not one of ["mean", "sum", "none"].
338355
"""
339356
if target.shape != pred.shape:
340357
raise ValueError(f"ground truth has differing shape ({target.shape}) from pred ({pred.shape})")
341358
wa, pa, wb, pb = self.parzen_windowing(pred, target) # (batch, num_sample, num_bin), (batch, 1, num_bin)
342359

343-
pab = torch.bmm(wa.permute(0, 2, 1), wb.to(wa)).div(wa.shape[1]) # (batch, num_bins, num_bins)
344-
papb = torch.bmm(pa.permute(0, 2, 1), pb.to(pa)) # (batch, num_bins, num_bins)
345-
mi = torch.sum(
346-
pab * torch.log((pab + self.smooth_nr) / (papb + self.smooth_dr) + self.smooth_dr), dim=(1, 2)
347-
) # (batch)
360+
# Half-precision matrix multiplication can overflow before the joint
361+
# histogram is divided by the number of samples. Accumulate histogram
362+
# products in float32 while preserving float64 inputs.
363+
output_dtype = wa.dtype
364+
compute_dtype = torch.float32 if wa.dtype in (torch.float16, torch.bfloat16) else wa.dtype
365+
wa = wa.to(dtype=compute_dtype)
366+
wb = wb.to(wa)
367+
pa = pa.to(dtype=compute_dtype)
368+
pb = pb.to(pa)
369+
with torch.autocast(device_type=wa.device.type, enabled=False):
370+
pab = torch.bmm(wa.permute(0, 2, 1), wb).div(wa.shape[1]) # (batch, num_bins, num_bins)
371+
papb = torch.bmm(pa.permute(0, 2, 1), pb) # (batch, num_bins, num_bins)
372+
mi = torch.sum(
373+
pab * torch.log((pab + self.smooth_nr) / (papb + self.smooth_dr) + self.smooth_dr), dim=(1, 2)
374+
) # (batch)
348375

349376
if self.reduction == LossReduction.SUM.value:
350-
return torch.sum(mi).neg() # sum over the batch and channel ndims
351-
if self.reduction == LossReduction.NONE.value:
352-
return mi.neg()
353-
if self.reduction == LossReduction.MEAN.value:
354-
return torch.mean(mi).neg() # average over the batch and channel ndims
355-
raise ValueError(f'Unsupported reduction: {self.reduction}, available options are ["mean", "sum", "none"].')
377+
loss = torch.sum(mi).neg() # sum over the batch and channel ndims
378+
elif self.reduction == LossReduction.NONE.value:
379+
loss = mi.neg()
380+
elif self.reduction == LossReduction.MEAN.value:
381+
loss = torch.mean(mi).neg() # average over the batch and channel ndims
382+
else:
383+
raise ValueError(f'Unsupported reduction: {self.reduction}, available options are ["mean", "sum", "none"].')
384+
return loss.to(dtype=output_dtype)

tests/losses/image_dissimilarity/test_global_mutual_information_loss.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,59 @@ def test_ill_opts(self, num_bins, reduction, expected_exception, expected_messag
164164
GlobalMutualInformationLoss(num_bins=num_bins, reduction=reduction)(pred, target)
165165

166166

167+
class TestGlobalMutualInformationLossHalfPrecision(unittest.TestCase):
168+
"""Test stable Gaussian mutual information in reduced-precision modes."""
169+
170+
@parameterized.expand([(torch.float16,), (torch.bfloat16,)])
171+
def test_half_precision_gaussian_weights_with_many_bins_are_finite(self, dtype):
172+
"""Verify many-bin Parzen outputs remain finite and preserve metadata."""
173+
image = torch.zeros((1, 1, 2), dtype=dtype)
174+
loss = GlobalMutualInformationLoss(kernel_type="gaussian", num_bins=256)
175+
176+
weight, probability = loss.parzen_windowing_gaussian(image)
177+
178+
self.assertTrue(torch.isfinite(weight).all())
179+
self.assertTrue(torch.isfinite(probability).all())
180+
self.assertEqual(weight.dtype, image.dtype)
181+
self.assertEqual(probability.dtype, image.dtype)
182+
self.assertEqual(weight.device, image.device)
183+
self.assertEqual(probability.device, image.device)
184+
185+
@parameterized.expand([(torch.float16,), (torch.bfloat16,)])
186+
def test_half_precision_large_constant_volume_is_finite(self, dtype):
187+
"""Verify reduced-precision loss and gradients remain finite."""
188+
pred = torch.zeros((1, 1, 48, 48, 48), dtype=dtype, requires_grad=True)
189+
target = torch.zeros_like(pred)
190+
loss = GlobalMutualInformationLoss(kernel_type="gaussian")
191+
192+
result = loss(pred, target)
193+
194+
self.assertTrue(torch.isfinite(result))
195+
self.assertEqual(result.dtype, pred.dtype)
196+
self.assertEqual(result.device, pred.device)
197+
result.backward()
198+
self.assertIsNotNone(pred.grad)
199+
self.assertTrue(torch.isfinite(pred.grad).all())
200+
self.assertEqual(pred.grad.dtype, pred.dtype)
201+
self.assertEqual(pred.grad.device, pred.device)
202+
203+
def test_cpu_float16_autocast_large_volume_is_finite(self):
204+
"""Verify CPU float16 autocast avoids histogram accumulation overflow."""
205+
pred = torch.zeros((1, 1, 48, 48, 48), requires_grad=True)
206+
target = torch.zeros_like(pred)
207+
loss = GlobalMutualInformationLoss(kernel_type="gaussian")
208+
209+
with torch.autocast(device_type="cpu", dtype=torch.float16):
210+
result = loss(pred, target)
211+
212+
self.assertTrue(torch.isfinite(result))
213+
result.backward()
214+
self.assertIsNotNone(pred.grad)
215+
self.assertTrue(torch.isfinite(pred.grad).all())
216+
self.assertEqual(pred.grad.dtype, pred.dtype)
217+
self.assertEqual(pred.grad.device, pred.device)
218+
219+
167220
class TestGlobalMutualInformationLossBuffers(unittest.TestCase):
168221
def test_gaussian_kernel_registers_buffers(self):
169222
"""Verify gaussian kernel registers preterm and bin_centers as non-trainable, non-persistent buffers."""

0 commit comments

Comments
 (0)