Skip to content

Commit 9c32c55

Browse files
committed
fix(losses): strengthen reduced-precision MI contracts
Signed-off-by: kyinhub <kevinpyin@gmail.com>
1 parent 27eb62d commit 9c32c55

2 files changed

Lines changed: 138 additions & 16 deletions

File tree

monai/losses/image_dissimilarity.py

Lines changed: 40 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111

1212
from __future__ import annotations
1313

14+
import math
15+
1416
import torch
1517
from torch.nn import functional as F
1618
from torch.nn.modules.loss import _Loss
@@ -236,10 +238,18 @@ def __init__(
236238
# gaussian kernel, hence the ``Tensor`` annotation reflects the type at the use sites in that path.
237239
self.preterm: torch.Tensor | None
238240
self.bin_centers: torch.Tensor | None
241+
self._preterm_value: float | None = None
239242
self.register_buffer("preterm", None, persistent=False)
240243
self.register_buffer("bin_centers", None, persistent=False)
241244
if self.kernel_type == "gaussian":
242-
self.register_buffer("preterm", 1 / (2 * sigma**2), persistent=False)
245+
preterm = 1 / (2 * sigma**2)
246+
preterm_value = float(preterm)
247+
if not math.isfinite(preterm_value) and num_bins > 1 and sigma_ratio != 0.0:
248+
preterm_value = (num_bins - 1) ** 2 / (2.0 * sigma_ratio**2)
249+
if not bool(torch.isfinite(preterm)):
250+
preterm = torch.as_tensor(preterm_value, dtype=torch.float32)
251+
self._preterm_value = preterm_value
252+
self.register_buffer("preterm", preterm, persistent=False)
243253
self.register_buffer("bin_centers", bin_centers[None, None, ...], persistent=False)
244254
self.smooth_nr = float(smooth_nr)
245255
self.smooth_dr = float(smooth_dr)
@@ -325,14 +335,17 @@ def parzen_windowing_gaussian(self, img: torch.Tensor) -> tuple[torch.Tensor, to
325335
ValueError: if the Gaussian kernel buffers are unavailable.
326336
"""
327337
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)
338+
compute_dtype = torch.float32 if output_dtype in (torch.float16, torch.bfloat16) else output_dtype
339+
img = torch.clamp(img.to(dtype=compute_dtype), 0, 1)
332340
img = img.reshape(img.shape[0], -1, 1) # (batch, num_sample, 1)
333-
if self.bin_centers is None or self.preterm is None:
341+
if self.bin_centers is None or self.preterm is None or self._preterm_value is None:
334342
raise ValueError("bin_centers and preterm must be defined for gaussian parzen windowing.")
335343
preterm = self.preterm.to(device=img.device, dtype=compute_dtype)
344+
preterm = torch.where(
345+
torch.isfinite(preterm),
346+
preterm,
347+
torch.as_tensor(self._preterm_value, device=img.device, dtype=compute_dtype),
348+
)
336349
bin_centers = self.bin_centers.to(device=img.device, dtype=compute_dtype)
337350
weight = torch.exp(-preterm * (img - bin_centers) ** 2) # (batch, num_sample, num_bin)
338351
weight = weight / torch.sum(weight, dim=-1, keepdim=True) # (batch, num_sample, num_bin)
@@ -351,27 +364,38 @@ def forward(self, pred: torch.Tensor, target: torch.Tensor) -> torch.Tensor:
351364
Reduced negative mutual information loss.
352365
353366
Raises:
354-
ValueError: When ``self.reduction`` is not one of ["mean", "sum", "none"].
367+
ValueError: if ``pred`` and ``target`` have different shapes, or
368+
if ``self.reduction`` is not one of ``"mean"``, ``"sum"``,
369+
or ``"none"``.
355370
"""
356371
if target.shape != pred.shape:
357372
raise ValueError(f"ground truth has differing shape ({target.shape}) from pred ({pred.shape})")
358373
wa, pa, wb, pb = self.parzen_windowing(pred, target) # (batch, num_sample, num_bin), (batch, 1, num_bin)
359374

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.
375+
# A half-precision matrix product can overflow while accumulating the
376+
# unnormalized joint histogram. Eager execution disables autocast for
377+
# this operation. TorchScript cannot compile a dynamic autocast device,
378+
# so it computes the normalized histogram directly by scaling both
379+
# operands by sqrt(N).
363380
output_dtype = wa.dtype
364381
compute_dtype = torch.float32 if wa.dtype in (torch.float16, torch.bfloat16) else wa.dtype
365382
wa = wa.to(dtype=compute_dtype)
366383
wb = wb.to(wa)
367384
pa = pa.to(dtype=compute_dtype)
368385
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)
386+
if torch.jit.is_scripting():
387+
sample_scale = float(wa.shape[1]) ** 0.5
388+
pab = torch.bmm((wa / sample_scale).permute(0, 2, 1), wb / sample_scale).to(
389+
dtype=compute_dtype
390+
) # (batch, num_bins, num_bins)
391+
papb = torch.bmm(pa.permute(0, 2, 1), pb).to(dtype=compute_dtype) # (batch, num_bins, num_bins)
392+
else:
393+
with torch.autocast(device_type=wa.device.type, enabled=False):
394+
pab = torch.bmm(wa.permute(0, 2, 1), wb).div(wa.shape[1]) # (batch, num_bins, num_bins)
395+
papb = torch.bmm(pa.permute(0, 2, 1), pb) # (batch, num_bins, num_bins)
396+
mi = torch.sum(
397+
pab * torch.log((pab + self.smooth_nr) / (papb + self.smooth_dr) + self.smooth_dr), dim=(1, 2)
398+
) # (batch)
375399

376400
if self.reduction == LossReduction.SUM.value:
377401
loss = torch.sum(mi).neg() # sum over the batch and channel ndims

tests/losses/image_dissimilarity/test_global_mutual_information_loss.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,72 @@ def test_half_precision_gaussian_weights_with_many_bins_are_finite(self, dtype):
181181
self.assertEqual(probability.dtype, image.dtype)
182182
self.assertEqual(weight.device, image.device)
183183
self.assertEqual(probability.device, image.device)
184+
torch.testing.assert_close(
185+
weight.float().sum(dim=-1), torch.ones_like(weight[..., 0], dtype=torch.float32), rtol=0.0, atol=5e-3
186+
)
187+
torch.testing.assert_close(
188+
probability.float().sum(dim=-1),
189+
torch.ones_like(probability[..., 0], dtype=torch.float32),
190+
rtol=0.0,
191+
atol=5e-3,
192+
)
193+
194+
@parameterized.expand([(torch.float16,), (torch.bfloat16,)])
195+
def test_module_cast_with_many_bins_remains_finite(self, dtype):
196+
"""Verify module dtype conversion cannot overflow Gaussian parameters."""
197+
image = torch.linspace(0.0, 1.0, 64, dtype=dtype).reshape(1, 1, 8, 8).requires_grad_()
198+
target = torch.flip(image.detach(), dims=(-1,))
199+
loss = GlobalMutualInformationLoss(kernel_type="gaussian", num_bins=256).to(dtype=dtype)
200+
201+
weight, probability = loss.parzen_windowing_gaussian(image)
202+
result = loss(image, target)
203+
204+
self.assertTrue(torch.isfinite(weight).all())
205+
self.assertTrue(torch.isfinite(probability).all())
206+
self.assertTrue(torch.isfinite(result))
207+
result.backward()
208+
self.assertIsNotNone(image.grad)
209+
self.assertTrue(torch.isfinite(image.grad).all())
210+
211+
def test_float16_default_dtype_with_many_bins_remains_finite(self):
212+
"""Verify construction under a float16 default keeps Gaussian parameters finite."""
213+
original_dtype = torch.get_default_dtype()
214+
try:
215+
torch.set_default_dtype(torch.float16)
216+
image = torch.linspace(0.0, 1.0, 64).reshape(1, 1, 8, 8).requires_grad_()
217+
target = torch.flip(image.detach(), dims=(-1,))
218+
loss = GlobalMutualInformationLoss(kernel_type="gaussian", num_bins=256)
219+
220+
weight, probability = loss.parzen_windowing_gaussian(image)
221+
result = loss(image, target)
222+
223+
self.assertTrue(torch.isfinite(weight).all())
224+
self.assertTrue(torch.isfinite(probability).all())
225+
self.assertTrue(torch.isfinite(result))
226+
result.backward()
227+
self.assertIsNotNone(image.grad)
228+
self.assertTrue(torch.isfinite(image.grad).all())
229+
finally:
230+
torch.set_default_dtype(original_dtype)
231+
232+
@parameterized.expand([(torch.float16,), (torch.bfloat16,)])
233+
def test_half_precision_nonconstant_images_match_float32(self, dtype):
234+
"""Verify nonconstant reduced-precision loss tracks float32."""
235+
pred_float = torch.linspace(0.0, 1.0, 64).reshape(1, 1, 8, 8)
236+
target_float = torch.flip(pred_float, dims=(-1,))
237+
loss = GlobalMutualInformationLoss(kernel_type="gaussian")
238+
expected = loss(pred_float, target_float)
239+
pred = pred_float.to(dtype=dtype).requires_grad_()
240+
target = target_float.to(dtype=dtype)
241+
242+
result = loss(pred, target)
243+
244+
self.assertTrue(torch.isfinite(result))
245+
self.assertEqual(result.dtype, dtype)
246+
torch.testing.assert_close(result.float(), expected, rtol=1e-2, atol=1e-2)
247+
result.backward()
248+
self.assertIsNotNone(pred.grad)
249+
self.assertTrue(torch.isfinite(pred.grad).all())
184250

185251
@parameterized.expand([(torch.float16,), (torch.bfloat16,)])
186252
def test_half_precision_large_constant_volume_is_finite(self, dtype):
@@ -200,6 +266,37 @@ def test_half_precision_large_constant_volume_is_finite(self, dtype):
200266
self.assertEqual(pred.grad.dtype, pred.dtype)
201267
self.assertEqual(pred.grad.device, pred.device)
202268

269+
def test_cpu_float16_autocast_nonconstant_images_match_float32(self):
270+
"""Verify nonconstant CPU autocast loss matches float32."""
271+
pred = torch.linspace(0.0, 1.0, 64).reshape(1, 1, 8, 8).requires_grad_()
272+
target = torch.flip(pred.detach(), dims=(-1,))
273+
loss = GlobalMutualInformationLoss(kernel_type="gaussian")
274+
expected = loss(pred, target).detach()
275+
276+
with torch.autocast(device_type="cpu", dtype=torch.float16):
277+
result = loss(pred, target)
278+
279+
self.assertTrue(torch.isfinite(result))
280+
self.assertEqual(result.dtype, pred.dtype)
281+
torch.testing.assert_close(result, expected)
282+
result.backward()
283+
self.assertIsNotNone(pred.grad)
284+
self.assertTrue(torch.isfinite(pred.grad).all())
285+
286+
def test_scripted_cpu_float16_autocast_large_volume_is_finite(self):
287+
"""Verify scripted loss avoids float16 histogram overflow under autocast."""
288+
pred = torch.zeros((1, 1, 257, 257), requires_grad=True)
289+
target = torch.zeros_like(pred)
290+
loss = torch.jit.script(GlobalMutualInformationLoss(kernel_type="gaussian"))
291+
292+
with torch.autocast(device_type="cpu", dtype=torch.float16):
293+
result = loss(pred, target)
294+
295+
self.assertTrue(torch.isfinite(result))
296+
result.backward()
297+
self.assertIsNotNone(pred.grad)
298+
self.assertTrue(torch.isfinite(pred.grad).all())
299+
203300
def test_cpu_float16_autocast_large_volume_is_finite(self):
204301
"""Verify CPU float16 autocast avoids histogram accumulation overflow."""
205302
pred = torch.zeros((1, 1, 48, 48, 48), requires_grad=True)
@@ -210,6 +307,7 @@ def test_cpu_float16_autocast_large_volume_is_finite(self):
210307
result = loss(pred, target)
211308

212309
self.assertTrue(torch.isfinite(result))
310+
self.assertEqual(result.dtype, pred.dtype)
213311
result.backward()
214312
self.assertIsNotNone(pred.grad)
215313
self.assertTrue(torch.isfinite(pred.grad).all())

0 commit comments

Comments
 (0)