Skip to content

Commit 76a50a8

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

2 files changed

Lines changed: 146 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: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,80 @@ 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+
198+
Args:
199+
dtype: reduced-precision floating-point dtype to test.
200+
"""
201+
image = torch.linspace(0.0, 1.0, 64, dtype=dtype).reshape(1, 1, 8, 8).requires_grad_()
202+
target = torch.flip(image.detach(), dims=(-1,))
203+
loss = GlobalMutualInformationLoss(kernel_type="gaussian", num_bins=256).to(dtype=dtype)
204+
205+
weight, probability = loss.parzen_windowing_gaussian(image)
206+
result = loss(image, target)
207+
208+
self.assertTrue(torch.isfinite(weight).all())
209+
self.assertTrue(torch.isfinite(probability).all())
210+
self.assertTrue(torch.isfinite(result))
211+
result.backward()
212+
self.assertIsNotNone(image.grad)
213+
self.assertTrue(torch.isfinite(image.grad).all())
214+
215+
def test_float16_default_dtype_with_many_bins_remains_finite(self):
216+
"""Verify construction under a float16 default keeps Gaussian parameters finite."""
217+
original_dtype = torch.get_default_dtype()
218+
try:
219+
torch.set_default_dtype(torch.float16)
220+
image = torch.linspace(0.0, 1.0, 64).reshape(1, 1, 8, 8).requires_grad_()
221+
target = torch.flip(image.detach(), dims=(-1,))
222+
loss = GlobalMutualInformationLoss(kernel_type="gaussian", num_bins=256)
223+
224+
weight, probability = loss.parzen_windowing_gaussian(image)
225+
result = loss(image, target)
226+
227+
self.assertTrue(torch.isfinite(weight).all())
228+
self.assertTrue(torch.isfinite(probability).all())
229+
self.assertTrue(torch.isfinite(result))
230+
result.backward()
231+
self.assertIsNotNone(image.grad)
232+
self.assertTrue(torch.isfinite(image.grad).all())
233+
finally:
234+
torch.set_default_dtype(original_dtype)
235+
236+
@parameterized.expand([(torch.float16,), (torch.bfloat16,)])
237+
def test_half_precision_nonconstant_images_match_float32(self, dtype):
238+
"""Verify nonconstant reduced-precision loss tracks float32.
239+
240+
Args:
241+
dtype: reduced-precision floating-point dtype to test.
242+
"""
243+
pred_float = torch.linspace(0.0, 1.0, 64).reshape(1, 1, 8, 8)
244+
target_float = torch.flip(pred_float, dims=(-1,))
245+
loss = GlobalMutualInformationLoss(kernel_type="gaussian")
246+
expected = loss(pred_float, target_float)
247+
pred = pred_float.to(dtype=dtype).requires_grad_()
248+
target = target_float.to(dtype=dtype)
249+
250+
result = loss(pred, target)
251+
252+
self.assertTrue(torch.isfinite(result))
253+
self.assertEqual(result.dtype, dtype)
254+
torch.testing.assert_close(result.float(), expected, rtol=1e-2, atol=1e-2)
255+
result.backward()
256+
self.assertIsNotNone(pred.grad)
257+
self.assertTrue(torch.isfinite(pred.grad).all())
184258

185259
@parameterized.expand([(torch.float16,), (torch.bfloat16,)])
186260
def test_half_precision_large_constant_volume_is_finite(self, dtype):
@@ -200,6 +274,37 @@ def test_half_precision_large_constant_volume_is_finite(self, dtype):
200274
self.assertEqual(pred.grad.dtype, pred.dtype)
201275
self.assertEqual(pred.grad.device, pred.device)
202276

277+
def test_cpu_float16_autocast_nonconstant_images_match_float32(self):
278+
"""Verify nonconstant CPU autocast loss matches float32."""
279+
pred = torch.linspace(0.0, 1.0, 64).reshape(1, 1, 8, 8).requires_grad_()
280+
target = torch.flip(pred.detach(), dims=(-1,))
281+
loss = GlobalMutualInformationLoss(kernel_type="gaussian")
282+
expected = loss(pred, target).detach()
283+
284+
with torch.autocast(device_type="cpu", dtype=torch.float16):
285+
result = loss(pred, target)
286+
287+
self.assertTrue(torch.isfinite(result))
288+
self.assertEqual(result.dtype, pred.dtype)
289+
torch.testing.assert_close(result, expected)
290+
result.backward()
291+
self.assertIsNotNone(pred.grad)
292+
self.assertTrue(torch.isfinite(pred.grad).all())
293+
294+
def test_scripted_cpu_float16_autocast_large_volume_is_finite(self):
295+
"""Verify scripted loss avoids float16 histogram overflow under autocast."""
296+
pred = torch.zeros((1, 1, 257, 257), requires_grad=True)
297+
target = torch.zeros_like(pred)
298+
loss = torch.jit.script(GlobalMutualInformationLoss(kernel_type="gaussian"))
299+
300+
with torch.autocast(device_type="cpu", dtype=torch.float16):
301+
result = loss(pred, target)
302+
303+
self.assertTrue(torch.isfinite(result))
304+
result.backward()
305+
self.assertIsNotNone(pred.grad)
306+
self.assertTrue(torch.isfinite(pred.grad).all())
307+
203308
def test_cpu_float16_autocast_large_volume_is_finite(self):
204309
"""Verify CPU float16 autocast avoids histogram accumulation overflow."""
205310
pred = torch.zeros((1, 1, 48, 48, 48), requires_grad=True)
@@ -210,6 +315,7 @@ def test_cpu_float16_autocast_large_volume_is_finite(self):
210315
result = loss(pred, target)
211316

212317
self.assertTrue(torch.isfinite(result))
318+
self.assertEqual(result.dtype, pred.dtype)
213319
result.backward()
214320
self.assertIsNotNone(pred.grad)
215321
self.assertTrue(torch.isfinite(pred.grad).all())

0 commit comments

Comments
 (0)