Source code for physicsnemo.diffusion.multi_diffusion.losses

# SPDX-FileCopyrightText: Copyright (c) 2023 - 2026 NVIDIA CORPORATION & AFFILIATES.
# SPDX-FileCopyrightText: All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Multi-diffusion denoising score matching losses for patch-based training."""

from functools import lru_cache
from typing import Any, Callable, Literal, Tuple

import torch
from jaxtyping import Float
from tensordict import TensorDict
from torch import Tensor

from physicsnemo.diffusion.base import PredictorType
from physicsnemo.diffusion.multi_diffusion.models import MultiDiffusionModel2D
from physicsnemo.diffusion.noise_schedulers import NoiseScheduler
from physicsnemo.diffusion.utils.utils import _unwrap_module, apply_loss_weight


class _CompiledPatchX:
    """Cached ``torch.compile``-d wrapper around
    :meth:`~MultiDiffusionModel2D.patch_x`.

    A separate compiled graph is cached per unique tensor signature
    (shape, dtype, device) so that calls with different shapes do not
    trigger recompilation.
    """

    def __init__(self, model: MultiDiffusionModel2D, *, maxsize: int = 8) -> None:
        self._model = model
        self._cache = lru_cache(maxsize=maxsize)(self._compile_for_sig)

    @staticmethod
    def _sig(t: Tensor) -> Tuple:
        return (tuple(t.shape), t.dtype, t.device)

    @staticmethod
    def _patch_x(model: MultiDiffusionModel2D, x: Tensor) -> Tensor:
        return model.patch_x(x)

    def _compile_for_sig(self, sig: Tuple) -> Callable:
        return torch.compile(self._patch_x)

    def __call__(self, x: Tensor) -> Tensor:
        fn = self._cache(self._sig(x))
        return fn(self._model, x)


[docs] class MultiDiffusionMSEDSMLoss: r"""Patch-based MSE denoising score matching loss for multi-diffusion training. This is the multi-diffusion counterpart of :class:`~physicsnemo.diffusion.metrics.losses.MSEDSMLoss`. It operates on a :class:`~physicsnemo.diffusion.multi_diffusion.MultiDiffusionModel2D` wrapper and computes the denoising score matching objective independently on each patch. A separate diffusion time is sampled per patch, giving :math:`P \times B` independent noise levels per training step. All training functionality is centered around a **noise scheduler** that must implement the :class:`~physicsnemo.diffusion.noise_schedulers.NoiseScheduler` protocol. At each training step the noise scheduler provides: - **Time sampling** via :meth:`~physicsnemo.diffusion.noise_schedulers.NoiseScheduler.sample_time`: draws random diffusion times :math:`t` — one per patch. - **Noise injection** via :meth:`~physicsnemo.diffusion.noise_schedulers.NoiseScheduler.add_noise`: produces the noisy state :math:`\mathbf{x}_t` from clean data :math:`\mathbf{x}_0`. - **Loss weighting** via :meth:`~physicsnemo.diffusion.noise_schedulers.NoiseScheduler.loss_weight`: returns the per-sample weight :math:`w(t)`. Weights may be scalar :math:`(N,)` or per-channel :math:`(N, C)` when the scheduler uses per-channel ``sigma_data`` (see :class:`~physicsnemo.diffusion.noise_schedulers.EDMNoiseScheduler`). The model **must** have a random patching strategy configured via :meth:`~MultiDiffusionModel2D.set_random_patching` before using this loss. .. note:: By default, each call to the loss **re-draws random patch positions** via :meth:`~MultiDiffusionModel2D.reset_patch_indices`. This ensures that every training step uses a fresh set of patches. Pass ``reset_patch_indices=False`` to the call to disable this behaviour (e.g., when patch positions are managed externally). .. note:: If the model has positional embeddings configured, the wrapped model must accept a ``TensorDict`` condition containing a ``"positional_embedding"`` key. For details on prediction types and ``score_to_x0_fn``, see :class:`~physicsnemo.diffusion.metrics.losses.MSEDSMLoss`. Parameters ---------- model : MultiDiffusionModel2D Multi-diffusion model wrapper with random patching configured. noise_scheduler : NoiseScheduler Noise scheduler implementing the :class:`~physicsnemo.diffusion.noise_schedulers.NoiseScheduler` protocol, providing the methods: :meth:`~physicsnemo.diffusion.noise_schedulers.NoiseScheduler.sample_time`, :meth:`~physicsnemo.diffusion.noise_schedulers.NoiseScheduler.add_noise`, and :meth:`~physicsnemo.diffusion.noise_schedulers.NoiseScheduler.loss_weight`. prediction_type : PredictorType, default="x0" Type of prediction the model outputs. score_to_x0_fn : Callable[[Tensor, Tensor, Tensor], Tensor], optional Callback to convert a score prediction to an :math:`\hat{\mathbf{x}}_0` estimate. Required when ``prediction_type="score"``. epsilon_to_x0_fn : Callable[[Tensor, Tensor, Tensor], Tensor], optional Callback to convert an epsilon (noise) prediction to an :math:`\hat{\mathbf{x}}_0` estimate. Required when ``prediction_type="epsilon"``. reduction : Literal["none", "mean", "sum"], default="mean" Reduction applied to the output. Examples -------- **Example 1:** Unconditional model with EDM schedule: >>> import torch >>> from physicsnemo.core import Module >>> from physicsnemo.diffusion.noise_schedulers import EDMNoiseScheduler >>> from physicsnemo.diffusion.multi_diffusion import ( ... MultiDiffusionModel2D, ... MultiDiffusionMSEDSMLoss, ... ) >>> >>> class UnconditionalModel(Module): ... def __init__(self): ... super().__init__() ... self.net = torch.nn.Conv2d(3, 3, 1) ... def forward(self, x, t, condition=None): ... return self.net(x) >>> >>> md_model = MultiDiffusionModel2D( ... model=UnconditionalModel(), ... global_spatial_shape=(16, 16), ... ) >>> md_model.set_random_patching(patch_shape=(8, 8), patch_num=4) >>> loss_fn = MultiDiffusionMSEDSMLoss(md_model, EDMNoiseScheduler()) >>> x0 = torch.randn(2, 3, 16, 16) >>> loss = loss_fn(x0) >>> loss.shape torch.Size([]) **Example 2:** Conditional model with score prediction and no reduction: >>> from tensordict import TensorDict >>> >>> class ConditionalModel(Module): ... def __init__(self): ... super().__init__() ... self.net = torch.nn.Conv2d(6, 3, 1) ... def forward(self, x, t, condition=None): ... return self.net(torch.cat([x, condition["image"]], dim=1)) >>> >>> cond_md_model = MultiDiffusionModel2D( ... model=ConditionalModel(), ... global_spatial_shape=(16, 16), ... condition_patch={"image": True}, ... ) >>> cond_md_model.set_random_patching(patch_shape=(8, 8), patch_num=4) >>> scheduler = EDMNoiseScheduler() >>> loss_fn = MultiDiffusionMSEDSMLoss( ... cond_md_model, scheduler, ... prediction_type="score", ... score_to_x0_fn=scheduler.score_to_x0, ... reduction="none", ... ) >>> cond = TensorDict({"image": torch.randn(2, 3, 16, 16)}, batch_size=[2]) >>> loss = loss_fn(x0, condition=cond) >>> loss.shape torch.Size([8, 3, 8, 8]) **Example 3:** Conditional model with learnable positional embeddings: >>> class PosEmbdModel(Module): ... def __init__(self): ... super().__init__() ... # 12 input channels: 3 (state) + 3 (image) + 6 (positional embedding) ... self.net = torch.nn.Conv2d(12, 3, 1) ... def forward(self, x, t, condition=None): ... img = condition["image"] ... # The wrapped model is designed to extract the positional embeddings ... # from the condition TensorDict ... pe = condition["positional_embedding"] ... return self.net(torch.cat([x, img, pe], dim=1)) >>> >>> pe_md_model = MultiDiffusionModel2D( ... model=PosEmbdModel(), ... global_spatial_shape=(16, 16), ... positional_embedding="learnable", ... channels_positional_embedding=6, ... condition_patch={"image": True}, ... ) >>> pe_md_model.set_random_patching(patch_shape=(8, 8), patch_num=4) >>> loss_fn = MultiDiffusionMSEDSMLoss(pe_md_model, EDMNoiseScheduler()) >>> cond = TensorDict({"image": torch.randn(2, 3, 16, 16)}, batch_size=[2]) >>> loss = loss_fn(x0, condition=cond) >>> loss.shape torch.Size([]) See Also -------- :class:`~physicsnemo.diffusion.metrics.losses.MSEDSMLoss` : Non-patched version of this loss. :class:`MultiDiffusionWeightedMSEDSMLoss` : Weighted variant that supports per-element masking. """ def __init__( self, model: MultiDiffusionModel2D, noise_scheduler: NoiseScheduler, prediction_type: PredictorType = "x0", score_to_x0_fn: Callable[ [torch.Tensor, torch.Tensor, torch.Tensor], torch.Tensor ] | None = None, epsilon_to_x0_fn: Callable[ [torch.Tensor, torch.Tensor, torch.Tensor], torch.Tensor ] | None = None, reduction: Literal["none", "mean", "sum"] = "mean", ) -> None: self.model = model self._md_model = _unwrap_module(model, MultiDiffusionModel2D) self.noise_scheduler = noise_scheduler self._compiled_patch_x = _CompiledPatchX(self._md_model) match prediction_type: case "x0": self._to_x0 = lambda prediction, x_t, t: prediction case "score": if score_to_x0_fn is None: raise ValueError( "score_to_x0_fn must be provided when prediction_type='score'." ) self._to_x0 = score_to_x0_fn case "epsilon": if epsilon_to_x0_fn is None: raise ValueError( "epsilon_to_x0_fn must be provided when prediction_type='epsilon'." ) self._to_x0 = epsilon_to_x0_fn case _: raise ValueError( f"prediction_type must be 'x0', 'score', or 'epsilon', " f"got '{prediction_type}'." ) _reductions = { "none": lambda x: x, "mean": lambda x: x.mean(), "sum": lambda x: x.sum(), } if reduction not in _reductions: raise ValueError( f"reduction must be 'none', 'mean', or 'sum', got '{reduction}'." ) self._reduce = _reductions[reduction] def __call__( self, x0: Float[Tensor, "B C H W"], condition: Float[Tensor, " B *cond_dims"] | TensorDict | None = None, reset_patch_indices: bool = True, t: Float[Tensor, " PB"] | None = None, **model_kwargs: Any, ) -> Float[Tensor, "P_times_B C Hp Wp"] | Float[Tensor, ""]: r"""Compute the multi-diffusion denoising score matching loss. Parameters ---------- x0 : Tensor Clean data of shape :math:`(B, C, H, W)` at global resolution. condition : Tensor, TensorDict, or None, optional, default=None Conditioning information at global resolution (batch size :math:`B`). reset_patch_indices : bool, default=True If ``True``, re-draw random patch positions before computing the loss. Set to ``False`` when patch positions are managed externally. t : Tensor or None, optional, default=None Pre-sampled diffusion time values of shape :math:`(P \times B,)` — one value per patch. When ``None`` (the default), times are sampled internally via :meth:`~physicsnemo.diffusion.noise_schedulers.NoiseScheduler.sample_time`. Passing explicit times is useful when the caller needs access to the sampled values for diagnostics (e.g., per-sigma-bin loss tracking). **model_kwargs : Any Additional keyword arguments forwarded to the model. Returns ------- Tensor If ``reduction="none"``, the per-element weighted loss of shape :math:`(P \times B, C, H_p, W_p)`. Otherwise a scalar tensor. """ if reset_patch_indices: self._md_model.reset_patch_indices() # Patch x0 and sample per-patch noise x0_patched = self._compiled_patch_x(x0) # (P*B, C, Hp, Wp) PB = x0_patched.shape[0] if t is None: t = self.noise_scheduler.sample_time(PB, device=x0.device, dtype=x0.dtype) x_t = self.noise_scheduler.add_noise(x0_patched, t) # Forward with pre-patched x and t prediction = self.model( x_t, t, condition=condition, x_is_patched=True, t_is_patched=True, **model_kwargs, ) x0_pred = self._to_x0(prediction, x_t, t) loss = (x0_pred - x0_patched) ** 2 w = self.noise_scheduler.loss_weight(t) loss = apply_loss_weight(w, x0_patched.ndim) * loss return self._reduce(loss)
[docs] class MultiDiffusionWeightedMSEDSMLoss: r"""Weighted patch-based MSE denoising score matching loss. Identical to :class:`MultiDiffusionMSEDSMLoss` but accepts an additional ``weight`` tensor that multiplies the per-element squared error. This is the multi-diffusion counterpart of :class:`~physicsnemo.diffusion.metrics.losses.WeightedMSEDSMLoss`. The ``weight`` tensor is provided at global resolution and is automatically patched alongside :math:`\mathbf{x}_0`. .. math:: \mathcal{L} = \mathbb{E}_{t, \boldsymbol{\epsilon}} \left[ w(t) \left\| \mathbf{m} \odot \left(\hat{\mathbf{x}}_0(\mathbf{x}_t, t) - \mathbf{x}_0\right) \right\|^2 \right] The noise scheduler's :meth:`~physicsnemo.diffusion.noise_schedulers.NoiseScheduler.loss_weight` may return scalar :math:`(N,)` or per-channel :math:`(N, C)` weights when the scheduler uses per-channel ``sigma_data`` (see :class:`~physicsnemo.diffusion.noise_schedulers.EDMNoiseScheduler`). The model **must** have a random patching strategy configured via :meth:`~MultiDiffusionModel2D.set_random_patching` before using this loss. .. note:: By default, each call to the loss **re-draws random patch positions** via :meth:`~MultiDiffusionModel2D.reset_patch_indices`. Pass ``reset_patch_indices=False`` to the call to disable this. .. note:: If the model has positional embeddings configured, the wrapped model must accept a ``TensorDict`` condition containing a ``"positional_embedding"`` key. For additional details, see :class:`MultiDiffusionMSEDSMLoss` and :class:`~physicsnemo.diffusion.metrics.losses.WeightedMSEDSMLoss`. Parameters ---------- model : MultiDiffusionModel2D Multi-diffusion model wrapper with random patching configured. noise_scheduler : NoiseScheduler Noise scheduler implementing the :class:`~physicsnemo.diffusion.noise_schedulers.NoiseScheduler` protocol, providing the methods: :meth:`~physicsnemo.diffusion.noise_schedulers.NoiseScheduler.sample_time`, :meth:`~physicsnemo.diffusion.noise_schedulers.NoiseScheduler.add_noise`, and :meth:`~physicsnemo.diffusion.noise_schedulers.NoiseScheduler.loss_weight`. prediction_type : PredictorType, default="x0" Type of prediction the model outputs. score_to_x0_fn : callable, optional Callback to convert a score prediction to an :math:`\hat{\mathbf{x}}_0` estimate. Required when ``prediction_type="score"``. epsilon_to_x0_fn : callable, optional Callback to convert an epsilon (noise) prediction to an :math:`\hat{\mathbf{x}}_0` estimate. Required when ``prediction_type="epsilon"``. reduction : {"none", "mean", "sum"}, default="mean" Reduction applied to the output. Examples -------- **Example 1:** Unconditional model with a spatial mask: >>> import torch >>> from physicsnemo.core import Module >>> from physicsnemo.diffusion.noise_schedulers import EDMNoiseScheduler >>> from physicsnemo.diffusion.multi_diffusion import ( ... MultiDiffusionModel2D, ... MultiDiffusionWeightedMSEDSMLoss, ... ) >>> >>> class UnconditionalModel(Module): ... def __init__(self): ... super().__init__() ... self.net = torch.nn.Conv2d(3, 3, 1) ... def forward(self, x, t, condition=None): ... return self.net(x) >>> >>> md_model = MultiDiffusionModel2D( ... model=UnconditionalModel(), ... global_spatial_shape=(16, 16), ... ) >>> md_model.set_random_patching(patch_shape=(8, 8), patch_num=4) >>> loss_fn = MultiDiffusionWeightedMSEDSMLoss( ... md_model, EDMNoiseScheduler() ... ) >>> x0 = torch.randn(2, 3, 16, 16) >>> # Weight/mask at global resolution — patched internally by the loss >>> mask = torch.ones(2, 3, 16, 16) >>> mask[:, :, :, :8] = 0.0 >>> loss = loss_fn(x0, weight=mask) >>> loss.shape torch.Size([]) **Example 2:** Conditional model with learnable positional embeddings and a spatial mask: >>> from tensordict import TensorDict >>> >>> class PosEmbdModel(Module): ... def __init__(self): ... super().__init__() ... # 12 input channels: 3 (state) + 3 (image) + 6 (positional embedding) ... self.net = torch.nn.Conv2d(12, 3, 1) ... def forward(self, x, t, condition=None): ... img = condition["image"] ... # The wrapped model is designed to extract the positional embeddings ... # from the condition TensorDict ... pe = condition["positional_embedding"] ... return self.net(torch.cat([x, img, pe], dim=1)) >>> >>> pe_md_model = MultiDiffusionModel2D( ... model=PosEmbdModel(), ... global_spatial_shape=(16, 16), ... positional_embedding="learnable", ... channels_positional_embedding=6, ... condition_patch={"image": True}, ... ) >>> pe_md_model.set_random_patching(patch_shape=(8, 8), patch_num=4) >>> loss_fn = MultiDiffusionWeightedMSEDSMLoss( ... pe_md_model, EDMNoiseScheduler() ... ) >>> cond = TensorDict({"image": torch.randn(2, 3, 16, 16)}, batch_size=[2]) >>> # Weight/mask at global resolution — patched internally by the loss >>> mask = torch.ones(2, 3, 16, 16) >>> mask[:, :, :, :8] = 0.0 >>> loss = loss_fn(x0, weight=mask, condition=cond) >>> loss.shape torch.Size([]) See Also -------- :class:`~physicsnemo.diffusion.metrics.losses.WeightedMSEDSMLoss` : Non-patched weighted loss. :class:`MultiDiffusionMSEDSMLoss` : Unweighted variant. """ def __init__( self, model: MultiDiffusionModel2D, noise_scheduler: NoiseScheduler, prediction_type: PredictorType = "x0", score_to_x0_fn: Callable[ [torch.Tensor, torch.Tensor, torch.Tensor], torch.Tensor ] | None = None, epsilon_to_x0_fn: Callable[ [torch.Tensor, torch.Tensor, torch.Tensor], torch.Tensor ] | None = None, reduction: Literal["none", "mean", "sum"] = "mean", ) -> None: self.model = model self._md_model = _unwrap_module(model, MultiDiffusionModel2D) self.noise_scheduler = noise_scheduler self._compiled_patch_x = _CompiledPatchX(self._md_model) match prediction_type: case "x0": self._to_x0 = lambda prediction, x_t, t: prediction case "score": if score_to_x0_fn is None: raise ValueError( "score_to_x0_fn must be provided when prediction_type='score'." ) self._to_x0 = score_to_x0_fn case "epsilon": if epsilon_to_x0_fn is None: raise ValueError( "epsilon_to_x0_fn must be provided when prediction_type='epsilon'." ) self._to_x0 = epsilon_to_x0_fn case _: raise ValueError( f"prediction_type must be 'x0', 'score', or 'epsilon', " f"got '{prediction_type}'." ) _reductions = { "none": lambda x: x, "mean": lambda x: x.mean(), "sum": lambda x: x.sum(), } if reduction not in _reductions: raise ValueError( f"reduction must be 'none', 'mean', or 'sum', got '{reduction}'." ) self._reduce = _reductions[reduction] def __call__( self, x0: Float[Tensor, "B C H W"], weight: Float[Tensor, "B C H W"], condition: Float[Tensor, " B *cond_dims"] | TensorDict | None = None, reset_patch_indices: bool = True, t: Float[Tensor, " PB"] | None = None, **model_kwargs: Any, ) -> Float[Tensor, "P_times_B C Hp Wp"] | Float[Tensor, ""]: r"""Compute the weighted multi-diffusion DSM loss. Parameters ---------- x0 : Tensor Clean data of shape :math:`(B, C, H, W)` at global resolution. weight : Tensor Per-element weight of shape :math:`(B, C, H, W)`, same shape as ``x0``. Patched automatically alongside :math:`\mathbf{x}_0`. condition : Tensor, TensorDict, or None, optional, default=None Conditioning information at global resolution. reset_patch_indices : bool, default=True If ``True``, re-draw random patch positions before computing the loss. Set to ``False`` when patch positions are managed externally. t : Tensor or None, optional, default=None Pre-sampled diffusion time values of shape :math:`(P \times B,)` — one value per patch. When ``None`` (the default), times are sampled internally via :meth:`~physicsnemo.diffusion.noise_schedulers.NoiseScheduler.sample_time`. Passing explicit times is useful when the caller needs access to the sampled values for diagnostics (e.g., per-sigma-bin loss tracking). **model_kwargs : Any Additional keyword arguments forwarded to the model. Returns ------- Tensor If ``reduction="none"``, the per-element weighted loss of shape :math:`(P \times B, C, H_p, W_p)`. Otherwise a scalar tensor. """ if not torch.compiler.is_compiling(): if weight.shape != x0.shape: raise ValueError( f"weight shape {tuple(weight.shape)} must match " f"x0 shape {tuple(x0.shape)}." ) if reset_patch_indices: self._md_model.reset_patch_indices() # Patch x0 and weight, then sample per-patch noise x0_patched = self._compiled_patch_x(x0) # (P*B, C, Hp, Wp) weight_patched = self._compiled_patch_x(weight) # (P*B, C, Hp, Wp) PB = x0_patched.shape[0] if t is None: t = self.noise_scheduler.sample_time(PB, device=x0.device, dtype=x0.dtype) x_t = self.noise_scheduler.add_noise(x0_patched, t) # Forward with pre-patched x and t prediction = self.model( x_t, t, condition=condition, x_is_patched=True, t_is_patched=True, **model_kwargs, ) x0_pred = self._to_x0(prediction, x_t, t) loss = weight_patched * (x0_pred - x0_patched) ** 2 w = self.noise_scheduler.loss_weight(t) loss = apply_loss_weight(w, x0_patched.ndim) * loss return self._reduce(loss)