Source code for physicsnemo.diffusion.multi_diffusion.dps_guidance

# 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.

"""Patch-local DPS guidance for multi-diffusion sampling."""

from typing import Callable, Protocol, Sequence, runtime_checkable

import torch
from jaxtyping import Bool, Float
from torch import Tensor

from physicsnemo.diffusion.base import Predictor
from physicsnemo.diffusion.multi_diffusion.predictor import MultiDiffusionPredictor
from physicsnemo.diffusion.utils.utils import _as_broadcastable


def _lp_loss_fn(
    p: int,
) -> Callable[
    [Float[Tensor, " *shape"], Float[Tensor, " *shape"]], Float[Tensor, " *shape"]
]:
    """
    Return a pointwise (not reduced) Lp residual function with exponent ``p``.
    """

    def _loss(y_pred: Tensor, y_true: Tensor) -> Tensor:
        return (y_pred - y_true).abs().pow(p)

    return _loss


def _prepatch_param(
    value: float | Float[Tensor, " *#shape"],
    predictor: MultiDiffusionPredictor,
    y: Float[Tensor, "B C H W"],
) -> Float[Tensor, "P_times_B C Hp Wp"]:
    """Pre-patch a ``value`` tensor to the patch layout.

    ``value`` is validated to be a scalar or a tensor broadcastable to the
    global observation ``y``, then broadcast to the global resolution and
    patched. The result lines up with the :math:`(P \\times B, \\dots)` layout
    of the pre-patched observations, so a chunk slice ``[s : s + K]`` selects
    the matching patches.
    """
    value = _as_broadcastable(value, y)
    return predictor.patch_fn(torch.broadcast_to(value, y.shape).contiguous())


[docs] @runtime_checkable class MultiDiffusionDPSGuidance(Protocol): r"""Protocol for **patch-local** DPS guidance compatible with :class:`MultiDiffusionDPSScorePredictor`. A guidance is **patch-local** when its computation decomposes along the multi-diffusion patch grid: the guidance value at each patch depends only on the data of that patch. This protocol is **not applicable** to globally-coupled guidances (e.g. ones that mix information across patches), use :class:`~physicsnemo.diffusion.guidance.DPSGuidance` for those. Identical to the standard :class:`~physicsnemo.diffusion.guidance.DPSGuidance` protocol, plus an optional ``slice_start`` argument that enables chunked evaluation: - **Full batch mode** (``slice_start=None``, the default): the call processes the full :math:`P \times B` batch of patches at once. Inputs match the size of the pre-patched data stored on the guidance. The implementation may optionally fuse the result back to the global resolution. - **Chunked batch mode** (``slice_start=s``): the call processes a single chunk of :math:`K \leq \text{chunk\_size}` patches starting at row ``s``. The implementation slices its pre-patched data with ``[s : s + K]`` and returns a chunk-sized guidance term (no fusing). Chunked batch mode is the key memory-efficiency knob, the per-chunk activations are released between iterations, so peak GPU memory stays proportional to ``chunk_size`` rather than to the full :math:`P \times B`. Use it for large global domains where the full-batch counterpart from :class:`~physicsnemo.diffusion.guidance.DPSGuidance` would OOM. A guidance satisfying this protocol also satisfies :class:`~physicsnemo.diffusion.guidance.DPSGuidance` because the extra argument is optional. Examples -------- Implementing a simple patch-local guidance from scratch. The mask and observations are pre-patched once at construction time and sliced per chunk based on ``slice_start``: >>> import torch >>> from physicsnemo.diffusion.multi_diffusion import ( ... MultiDiffusionDPSGuidance, ... ) >>> >>> class InpaintGuidance: ... def __init__(self, mask_patched, y_patched, gamma=1.0): ... self.mask = mask_patched ... self.y = y_patched ... self.gamma = gamma ... ... def __call__(self, x, t, x_0, slice_start=None): ... if slice_start is None: ... mask, y = self.mask, self.y ... else: ... K = x.shape[0] ... mask = self.mask[slice_start : slice_start + K] ... y = self.y[slice_start : slice_start + K] ... return -self.gamma * mask * (x_0 - y) ... >>> mask = torch.ones(8, 3, 8, 8) # (P*B, C, Hp, Wp) >>> y = torch.randn(8, 3, 8, 8) >>> guidance = InpaintGuidance(mask, y) >>> isinstance(guidance, MultiDiffusionDPSGuidance) True >>> >>> # Full batch mode: process all P*B = 8 patches at once >>> x = torch.randn(8, 3, 8, 8) >>> t = torch.full((8,), 1.0) >>> x_0 = x * 0.9 >>> guidance(x, t, x_0).shape torch.Size([8, 3, 8, 8]) >>> >>> # Chunked batch mode: process a chunk of 2 patches starting at row 0 >>> guidance(x[:2], t[:2], x_0[:2], slice_start=0).shape torch.Size([2, 3, 8, 8]) """ def __call__( self, x: Float[Tensor, "K C Hp Wp"], t: Float[Tensor, " K"], x_0: Float[Tensor, "K C Hp Wp"], slice_start: int | None = None, ) -> Float[Tensor, "K C Hp Wp"]: ...
[docs] class MultiDiffusionDPSScorePredictor(Predictor): r"""Score predictor that combines a :class:`~physicsnemo.diffusion.multi_diffusion.MultiDiffusionPredictor` with one or more patch-local DPS guidances for guided sampling on large multi-diffusion domains. Implements the same :class:`~physicsnemo.diffusion.Predictor` interface as :class:`~physicsnemo.diffusion.guidance.DPSScorePredictor` and slots into the standard sampling stack: pass it to :meth:`~physicsnemo.diffusion.noise_schedulers.NoiseScheduler.get_denoiser` to obtain a :class:`~physicsnemo.diffusion.Denoiser` that can be used with :func:`~physicsnemo.diffusion.samplers.sample` or any sampling utility that consumes a denoiser. Use this class instead of :class:`~physicsnemo.diffusion.guidance.DPSScorePredictor` when every guidance is patch-local (see :class:`MultiDiffusionDPSGuidance`) and the global domain is too large for the full :math:`(P \times B, \dots)` activation tensor to fit in memory. The predictor streams score and guidance contributions chunk by chunk in patch space and fuses once at the end: .. math:: \nabla_{\mathbf{x}} \log p(\mathbf{x}) + \sum_i g_i(\mathbf{x}, t, \hat{\mathbf{x}}_0) \;=\; \mathrm{Fuse}\!\left[\, s^k + \sum_i g_i^k\, \right]_{k=1..P} where the superscript :math:`k` denotes the :math:`k`-th patch chunk and :math:`\mathrm{Fuse}` is the multi-diffusion fusing operator. The full :math:`(P \times B, \dots)` activation tensor is never materialized. .. important:: Use :class:`~physicsnemo.diffusion.guidance.DPSScorePredictor` for guidances that do **not** decompose patch-locally. Passing a globally-coupled guidance to this class produces incorrect results. Each guidance must implement the :class:`MultiDiffusionDPSGuidance` protocol: .. code-block:: python def guidance( x: Tensor, # shape: (K, C, Hp, Wp) t: Tensor, # shape: (K,) x_0: Tensor, # shape: (K, C, Hp, Wp) slice_start: int | None, # row index of the chunk in (P*B); # None means full-batch mode ) -> Tensor: ... # shape: (K, C, Hp, Wp) where :math:`K` is the number of patches in the current chunk (:math:`K = P \times B` in full batch mode, :math:`K \leq \text{chunk\_size}` in chunked batch mode). The predictor forwards each chunk's ``slice_start`` from :meth:`MultiDiffusionPredictor.chunks` directly to every guidance, so each guidance reads the corresponding slice of its own pre-patched observations without any internal state. The ``x0_to_score_fn`` callback must be an elementwise conversion with the signature: .. code-block:: python def x0_to_score_fn( x_0: Tensor, # shape: (K, C, Hp, Wp) x_t: Tensor, # shape: (K, C, Hp, Wp) t: Tensor, # shape: (K,) ) -> Tensor: ... # shape: (K, C, Hp, Wp) Parameters ---------- x0_predictor : MultiDiffusionPredictor A trained predictor with ``chunk_size`` set, returning x0 estimates. x0_to_score_fn : callable Elementwise conversion ``(x_0, x_t, t) -> score`` (see the signature above). Typically obtained from a noise scheduler, e.g. :meth:`~physicsnemo.diffusion.noise_schedulers.LinearGaussianNoiseScheduler.x0_to_score`. guidances : MultiDiffusionDPSGuidance or sequence of MultiDiffusionDPSGuidance One or more patch-local guidance objects implementing the :class:`MultiDiffusionDPSGuidance` protocol. See Also -------- :class:`MultiDiffusionDPSGuidance` : Protocol that guidances must satisfy. :class:`MultiDiffusionDataConsistencyDPSGuidance` : Patch-local guidance for masked observations. :class:`MultiDiffusionModelConsistencyDPSGuidance` : Patch-local guidance for generic patch-local observation operators. :class:`~physicsnemo.diffusion.guidance.DPSScorePredictor` : Use for non-patch-local guidances. Notes ----- **Recommended call pattern:** wrap inference-only sample loops in ``with torch.no_grad():``. The predictor re-enables autograd locally for the guidance's ``autograd.grad`` call, so functionality is preserved, and the returned score does not carry a graph back to the per-chunk state. Without ``no_grad``, the score's autograd graph accumulates across solver steps and can dominate memory at large global domains. Do NOT use ``torch.inference_mode()`` — it disables autograd entirely and breaks the guidance's internal gradient computation. Examples -------- **Example 1:** Basic usage with a single inpainting guidance: >>> import torch >>> from physicsnemo.core import Module >>> from physicsnemo.diffusion.multi_diffusion import ( ... MultiDiffusionModel2D, MultiDiffusionPredictor, ... MultiDiffusionDPSScorePredictor, ... ) >>> >>> class Backbone(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 = MultiDiffusionModel2D(Backbone(), global_spatial_shape=(16, 16)) >>> md.set_random_patching(patch_shape=(8, 8), patch_num=4) >>> _ = md.eval() >>> predictor = MultiDiffusionPredictor(md, chunk_size=2) >>> predictor.set_patching(overlap_pix=0, boundary_pix=0) >>> >>> # x0-to-score for EDM: score = (x_0 - x) / t^2 >>> def x0_to_score_fn(x_0, x, t): ... t_bc = t.reshape((-1,) + (1,) * (x.ndim - 1)) ... return (x_0 - x) / (t_bc ** 2) >>> >>> # Inline inpainting guidance; mask and observations are pre-patched >>> # by the user via predictor.patch_fn so all patching uses the same >>> # grid as the predictor. >>> class InpaintGuidance: ... def __init__(self, mask_patched, y_patched, gamma=0.1): ... self.mask = mask_patched ... self.y = y_patched ... self.gamma = gamma ... def __call__(self, x, t, x_0, slice_start=None): ... if slice_start is None: ... mask, y = self.mask, self.y ... else: ... K = x.shape[0] ... mask = self.mask[slice_start : slice_start + K] ... y = self.y[slice_start : slice_start + K] ... return -self.gamma * mask * (x_0 - y) >>> >>> mask_patched = predictor.patch_fn(torch.ones(2, 3, 16, 16)) >>> y_patched = predictor.patch_fn(torch.randn(2, 3, 16, 16)) >>> guidance = InpaintGuidance(mask_patched, y_patched) >>> >>> dps = MultiDiffusionDPSScorePredictor( ... x0_predictor=predictor, ... x0_to_score_fn=x0_to_score_fn, ... guidances=guidance, ... ) >>> x = torch.randn(2, 3, 16, 16) >>> t = torch.tensor([1.0, 1.0]) >>> dps(x, t).shape torch.Size([2, 3, 16, 16]) **Example 2:** Multiple guidances for multi-constraint problems. The predictor returned by this class is a drop-in score predictor that plugs into any sampling utility (here :func:`~physicsnemo.diffusion.samplers.sample`): >>> from physicsnemo.diffusion.multi_diffusion import ( ... MultiDiffusionDataConsistencyDPSGuidance, ... MultiDiffusionModelConsistencyDPSGuidance, ... ) >>> from physicsnemo.diffusion.noise_schedulers import EDMNoiseScheduler >>> from physicsnemo.diffusion.samplers import sample >>> >>> scheduler = EDMNoiseScheduler() >>> >>> # First guidance: masked observations (inpainting) >>> mask = torch.zeros(2, 3, 16, 16, dtype=torch.bool) >>> mask[:, :, 4:, :] = True >>> y_obs1 = torch.randn(2, 3, 16, 16) >>> g1 = MultiDiffusionDataConsistencyDPSGuidance( ... predictor=predictor, mask=mask, y=y_obs1, std_y=0.1, ... retain_graph=True, # required: not the last autograd guidance ... ) >>> >>> # Second guidance: nonlinear patch-local channel response >>> A = lambda x_0: torch.sigmoid(x_0[:, :1]) >>> y_obs2 = torch.rand(2, 1, 16, 16) >>> g2 = MultiDiffusionModelConsistencyDPSGuidance( ... predictor=predictor, observation_operator=A, ... y=y_obs2, std_y=0.1, ... ) >>> >>> dps = MultiDiffusionDPSScorePredictor( ... x0_predictor=predictor, ... x0_to_score_fn=scheduler.x0_to_score, ... guidances=[g1, g2], ... ) >>> denoiser = scheduler.get_denoiser(score_predictor=dps) >>> xN = torch.randn(2, 3, 16, 16) >>> x0 = sample(denoiser, xN, scheduler, num_steps=4) >>> x0.shape torch.Size([2, 3, 16, 16]) """ def __init__( self, x0_predictor: MultiDiffusionPredictor, x0_to_score_fn: Callable[ [ Float[Tensor, "K C Hp Wp"], Float[Tensor, "K C Hp Wp"], Float[Tensor, " K"], ], Float[Tensor, "K C Hp Wp"], ], guidances: MultiDiffusionDPSGuidance | Sequence[MultiDiffusionDPSGuidance], ) -> None: if not isinstance(x0_predictor, MultiDiffusionPredictor): raise TypeError( f"x0_predictor must be a MultiDiffusionPredictor, " f"got {type(x0_predictor).__name__}." ) if x0_predictor._chunk_size is None: raise ValueError( "x0_predictor must have chunk_size set. " "Pass chunk_size=<int> to MultiDiffusionPredictor.__init__." ) self.x0_predictor = x0_predictor self.x0_to_score_fn = x0_to_score_fn if isinstance(guidances, Sequence) and not isinstance(guidances, str): self.guidances: list[MultiDiffusionDPSGuidance] = list(guidances) else: self.guidances = [guidances] # type: ignore[list-item] def __call__( self, x: Float[Tensor, "B C H W"], t: Float[Tensor, " B"], ) -> Float[Tensor, "B C H W"]: r"""Compute the guided score at the global resolution. Parameters ---------- x : Tensor Noisy latent at global resolution, shape :math:`(B, C, H, W)`. t : Tensor Diffusion time, shape :math:`(B,)`. Returns ------- Tensor Guided score at global resolution, shape :math:`(B, C, H, W)`. """ if not torch.compiler.is_compiling() and torch.is_inference_mode_enabled(): raise RuntimeError( "MultiDiffusionDPSScorePredictor requires autograd but torch " "inference mode is enabled. Wrap the calling code with " "'with torch.inference_mode(False):' or 'with torch.no_grad():' " "instead." ) # Capture the caller's grad mode so the score-conversion and chunk-add # ops below honor it. Under ``torch.no_grad()`` this makes the appended # tensors leaves, so the cat/fuse return value does not carry a # graph-tail through ``x_patched`` and friends. The guidance call still # runs under ``enable_grad`` since it needs ``autograd.grad`` internally. outer_grad_enabled = torch.is_grad_enabled() x = x.detach().requires_grad_(True) combined_list: list[Tensor] = [] with torch.enable_grad(): for s, x0_chunk, x_chunk, t_chunk in self.x0_predictor.chunks(x, t): g_chunk = torch.zeros_like(x0_chunk) for g in self.guidances: g_chunk = g_chunk + g(x_chunk, t_chunk, x0_chunk, slice_start=s) with torch.set_grad_enabled(outer_grad_enabled): score_chunk = self.x0_to_score_fn(x0_chunk, x_chunk, t_chunk) combined_list.append(score_chunk + g_chunk) combined_patched = torch.cat(combined_list, dim=0) # (P*B, C, Hp, Wp) return self.x0_predictor.fuse_fn(combined_patched)
[docs] class MultiDiffusionModelConsistencyDPSGuidance(MultiDiffusionDPSGuidance): r"""Patch-local DPS guidance for generic observation operators with Gaussian noise. Multi-diffusion counterpart of :class:`~physicsnemo.diffusion.guidance.ModelConsistencyDPSGuidance`, intended for cases where the observation operator :math:`A` decomposes along the multi-diffusion patch grid. Implements the :class:`MultiDiffusionDPSGuidance` protocol, see it for the two-mode (``slice_start``) semantics and the :math:`K` chunk-size convention. Computes the likelihood score assuming Gaussian measurement noise with standard deviation :math:`\sigma_y`. For the current patch chunk :math:`k`: .. math:: \nabla_{\mathbf{x}} \log p(\mathbf{y}^k | \mathbf{x}_t^k) = -\nabla_{\mathbf{x}^k} \sum_i \frac{\big( A(\hat{\mathbf{x}}_0^k) - \mathbf{y}^k \big)_i^2} {2 \left( \sigma_{y,i}^2 + \Gamma_i\, \sigma(t)^2 / \alpha(t)^2 \right)} where :math:`i` indexes the (patch-local) observation components. See the global :class:`~physicsnemo.diffusion.guidance.ModelConsistencyDPSGuidance` for :math:`\sigma_{y,i}` (``std_y``), :math:`\Gamma_i` (``gamma``), and the SDA scaling. Observations ``y`` are pre-patched once at construction; calling the guidance many times during sampling never re-patches them. .. important:: ``y`` must be **patcheable** in the same way as the latent state :math:`\mathbf{x}`, so its spatial dimensions must equal the global resolution :math:`(H, W)`. This is a stronger requirement than the global counterpart :class:`~physicsnemo.diffusion.guidance.ModelConsistencyDPSGuidance`, which allows arbitrary observation shapes. The operator :math:`A` must therefore produce observations matching the input spatial resolution (e.g. channel-selection, pointwise nonlinearities, local convolutions within an overlap region). Tensor-valued ``std_y`` / ``gamma`` follow the same rule: a scalar, or a 4D tensor broadcastable to the global observation :math:`(B, C_{obs}, H, W)`, pre-patched at construction like ``y``. The ``observation_operator`` must be a differentiable callable with the following signature: .. code-block:: python def observation_operator( x_0: Tensor, # shape: (K, C, Hp, Wp) ) -> Tensor: ... # shape: (K, C_obs, Hp, Wp) When ``norm`` is a callable, it must be an elementwise loss with the signature: .. code-block:: python def norm( y_pred: Tensor, # shape: (K, C_obs, Hp, Wp) y_true: Tensor, # shape: (K, C_obs, Hp, Wp) ) -> Tensor: ... # elementwise loss, shape: (K, C_obs, Hp, Wp) Parameters ---------- predictor : MultiDiffusionPredictor Predictor used to pre-patch ``y`` and (optionally) fuse the guidance. Stored on ``self.predictor`` for later access. observation_operator : callable Differentiable patch-local observation operator :math:`A`. See the signature above. y : Tensor Global observations of shape :math:`(B, C_{obs}, H, W)` matching the latent's global spatial shape. std_y : float or Tensor Standard deviation of the measurement noise :math:`\boldsymbol{\sigma}_y`. A ``float`` applies a single standard deviation to every observation component. A ``Tensor`` must be 4D and broadcastable to the global observation :math:`(B, C_{obs}, H, W)`; it is pre-patched like ``y``. norm : int or callable, default=2 Residual loss. An ``int`` selects the corresponding :math:`L^p` norm; a callable must return an elementwise loss with the same shape as its inputs (see the signature above). gamma : float or Tensor, default=0.0 SDA covariance scaling factor :math:`\boldsymbol{\Gamma}`. Set to ``0`` for classical DPS without SDA scaling. Like ``std_y``, may be a ``float`` or a ``Tensor`` broadcastable to the global observation. sigma_fn : callable or None, default=None Function mapping diffusion time to noise level :math:`\sigma(t)`. Required when ``gamma > 0``. Typically obtained from a noise scheduler, e.g. :meth:`~physicsnemo.diffusion.noise_schedulers.LinearGaussianNoiseScheduler.sigma`. alpha_fn : callable or None, default=None Function mapping diffusion time to signal coefficient :math:`\alpha(t)`. Defaults to :math:`\alpha(t) = 1`. Typically obtained from a noise scheduler, e.g. :meth:`~physicsnemo.diffusion.noise_schedulers.LinearGaussianNoiseScheduler.alpha`. fuse : bool, default=False Whether :meth:`__call__` fuses the guidance term to the global resolution in full batch mode (``slice_start=None``). Ignored in chunked batch mode. retain_graph : bool, default=False Retain the computation graph after the gradient call. Required on all but the last guidance when combining multiple autograd-based guidances in a single :class:`MultiDiffusionDPSScorePredictor`. create_graph : bool, default=False Allow higher-order derivatives. Note ---- References: - DPS: `Diffusion Posterior Sampling for General Noisy Inverse Problems <https://arxiv.org/abs/2209.14687>`_ - SDA: `Score-based Data Assimilation <https://arxiv.org/abs/2306.10574>`_ See Also -------- :class:`~physicsnemo.diffusion.guidance.ModelConsistencyDPSGuidance` : Global counterpart for non-patch-local operators. :class:`MultiDiffusionDPSScorePredictor` : Score predictor that consumes this guidance. Examples -------- **Example 1:** Patch-local channel selection. The operator selects the first channel of each patch, clearly patch-local. Inputs are chunk-sized patched tensors: >>> import torch >>> from physicsnemo.core import Module >>> from physicsnemo.diffusion.multi_diffusion import ( ... MultiDiffusionModel2D, MultiDiffusionPredictor, ... MultiDiffusionModelConsistencyDPSGuidance, ... ) >>> >>> class Backbone(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 = MultiDiffusionModel2D(Backbone(), global_spatial_shape=(16, 16)) >>> md.set_random_patching(patch_shape=(8, 8), patch_num=4) >>> _ = md.eval() >>> predictor = MultiDiffusionPredictor(md, chunk_size=2) >>> predictor.set_patching(overlap_pix=0, boundary_pix=0) >>> >>> A = lambda x: x[:, :1] >>> y_obs = torch.randn(2, 1, 16, 16) >>> >>> guidance = MultiDiffusionModelConsistencyDPSGuidance( ... predictor=predictor, observation_operator=A, y=y_obs, std_y=0.1, ... ) >>> x_chunk = torch.randn(2, 3, 8, 8, requires_grad=True) >>> t_chunk = torch.tensor([1.0, 1.0]) >>> x0_chunk = x_chunk * 0.9 >>> guidance(x_chunk, t_chunk, x0_chunk, slice_start=0).shape torch.Size([2, 3, 8, 8]) **Example 2:** SDA-scaled guidance with a nonlinear patch-local operator (here a sigmoid response on the first two channels) and **tensor-valued** ``std_y`` / ``gamma``, plugged into the full sampling stack; ``std_y`` is per-channel (spatially constant :math:`(B, C_{obs}, 1, 1)`) while ``gamma`` is pointwise at the global resolution :math:`(B, C_{obs}, H, W)`; both are pre-patched like ``y``: >>> from physicsnemo.diffusion.multi_diffusion import ( ... MultiDiffusionDPSScorePredictor, ... ) >>> from physicsnemo.diffusion.noise_schedulers import EDMNoiseScheduler >>> from physicsnemo.diffusion.samplers import sample >>> >>> scheduler = EDMNoiseScheduler() >>> A_nl = lambda x_0: torch.sigmoid(x_0[:, :2]) # 2 observed channels >>> y_obs_nl = torch.rand(2, 2, 16, 16) >>> >>> std_y = torch.tensor([0.05, 0.1]).reshape(1, 2, 1, 1) # per-channel >>> gamma = 0.05 * torch.rand(2, 2, 16, 16) # pointwise (B, C, H, W) >>> guidance_sda = MultiDiffusionModelConsistencyDPSGuidance( ... predictor=predictor, ... observation_operator=A_nl, ... y=y_obs_nl, ... std_y=std_y, ... gamma=gamma, # enable SDA scaling ... sigma_fn=scheduler.sigma, ... alpha_fn=scheduler.alpha, ... ) >>> dps = MultiDiffusionDPSScorePredictor( ... x0_predictor=predictor, ... x0_to_score_fn=scheduler.x0_to_score, ... guidances=guidance_sda, ... ) >>> denoiser = scheduler.get_denoiser(score_predictor=dps) >>> xN = torch.randn(2, 3, 16, 16) >>> x0 = sample(denoiser, xN, scheduler, num_steps=4) >>> x0.shape torch.Size([2, 3, 16, 16]) """ def __init__( self, predictor: MultiDiffusionPredictor, observation_operator: Callable[ [Float[Tensor, "K C Hp Wp"]], Float[Tensor, "K C_obs Hp Wp"] ], y: Float[Tensor, "B C_obs H W"], std_y: float | Float[Tensor, "#B #C_obs #H #W"], norm: int | Callable[ [Float[Tensor, "K C_obs Hp Wp"], Float[Tensor, "K C_obs Hp Wp"]], Float[Tensor, "K C_obs Hp Wp"], ] = 2, gamma: float | Float[Tensor, "#B #C_obs #H #W"] = 0.0, sigma_fn: Callable[[Float[Tensor, " *shape"]], Float[Tensor, " *shape"]] | None = None, alpha_fn: Callable[[Float[Tensor, " *shape"]], Float[Tensor, " *shape"]] | None = None, fuse: bool = False, retain_graph: bool = False, create_graph: bool = False, ) -> None: self.predictor = predictor # Pre-patch observations once via the predictor's patch_fn. self._y_patched: Tensor = predictor.patch_fn(y) self.observation_operator = observation_operator # Pre-patch std_y / gamma once like ``y`` (a scalar broadcasts to # everything), so they slice per chunk alongside the observations. self._std_y_patched = _prepatch_param(std_y, predictor, y) self._gamma_patched = _prepatch_param(gamma, predictor, y) if sigma_fn is None and (self._gamma_patched > 0).any(): raise ValueError("sigma_fn must be provided when gamma > 0") # Resolve the loss callable at construction so __call__ has no branch. if isinstance(norm, int): self._loss_fn: Callable[[Tensor, Tensor], Tensor] = _lp_loss_fn(norm) else: self._loss_fn = norm self.sigma_fn = ( sigma_fn if sigma_fn is not None else lambda t: torch.zeros_like(t) ) self.alpha_fn = ( alpha_fn if alpha_fn is not None else lambda t: torch.ones_like(t) ) self.fuse = fuse self.retain_graph = retain_graph self.create_graph = create_graph def __call__( self, x: Float[Tensor, "K C Hp Wp"], t: Float[Tensor, " K"], x_0: Float[Tensor, "K C Hp Wp"], slice_start: int | None = None, ) -> Float[Tensor, "K C Hp Wp"] | Float[Tensor, "B C H W"]: r"""Compute the patch-local likelihood score guidance term. See :class:`MultiDiffusionDPSGuidance` for the meaning of ``slice_start`` (full vs chunked batch mode) and the :math:`K` chunk-size convention. Parameters ---------- x : Tensor Noisy patched latent slice :math:`\mathbf{x}_t^k`, of shape :math:`(K, C, H_p, W_p)`. Must have ``requires_grad=True`` and be part of a computational graph connecting to ``x_0``. Its ``dtype`` and ``device`` determine those of all internal computations. t : Tensor Patched diffusion time slice, shape :math:`(K,)`. x_0 : Tensor Estimate of the patched clean state :math:`\hat{\mathbf{x}}_0^k(\mathbf{x}_t^k, t)`, of shape :math:`(K, C, H_p, W_p)`. Must be computed from ``x`` so gradients can backpropagate. slice_start : int or None, default=None Chunk offset along the :math:`(P \times B)` dimension. See class docstring. Returns ------- Tensor Patch-local guidance term of shape :math:`(K, C, H_p, W_p)`. Fused to the global resolution :math:`(B, C, H, W)` when ``slice_start=None`` and ``fuse=True`` was passed at construction. """ if not torch.compiler.is_compiling() and torch.is_inference_mode_enabled(): raise RuntimeError( "MultiDiffusionModelConsistencyDPSGuidance requires autograd " "but torch inference mode is enabled." ) # Slice the pre-patched observation / strengths to the current chunk. K = x.shape[0] sl = slice(None) if slice_start is None else slice(slice_start, slice_start + K) y_chunk = self._y_patched[sl].to(dtype=x.dtype, device=x.device) std_y_chunk = self._std_y_patched[sl].to(dtype=x.dtype, device=x.device) gamma_chunk = self._gamma_patched[sl].to(dtype=x.dtype, device=x.device) with torch.enable_grad(): y_pred = self.observation_operator(x_0) loss = self._loss_fn(y_pred, y_chunk) # Guidance strength rho(t), broadcast over the observation. bc_shape = (-1,) + (1,) * (loss.ndim - 1) t_bc = t.reshape(bc_shape) sigma_t = self.sigma_fn(t_bc) alpha_t = self.alpha_fn(t_bc) rho = 1.0 / ( 2.0 * (std_y_chunk**2 + gamma_chunk * (sigma_t**2) / (alpha_t**2)) ) grad_x = torch.autograd.grad( outputs=(rho * loss).sum(), inputs=x, retain_graph=self.retain_graph, create_graph=self.create_graph, )[0] g = -grad_x if slice_start is None and self.fuse: return self.predictor.fuse_fn(g) return g
[docs] class MultiDiffusionDataConsistencyDPSGuidance(MultiDiffusionDPSGuidance): r"""Patch-local DPS guidance for masked observations with Gaussian noise. Multi-diffusion counterpart of :class:`~physicsnemo.diffusion.guidance.DataConsistencyDPSGuidance`, intended for masked observations whose mask decomposes along the multi-diffusion patch grid. Use cases: inpainting, sparse pointwise data assimilation on large domains. Implements the :class:`MultiDiffusionDPSGuidance` protocol, see it for the two-mode (``slice_start``) semantics and the :math:`K` chunk-size convention. Computes the likelihood score assuming Gaussian measurement noise with standard deviation :math:`\sigma_y`. For the current patch chunk :math:`k`: .. math:: \nabla_{\mathbf{x}} \log p(\mathbf{y}^k | \mathbf{x}_t^k) = -\nabla_{\mathbf{x}^k} \sum_i \frac{\big( \mathbf{M}^k \odot (\hat{\mathbf{x}}_0^k - \mathbf{y}^k) \big)_i^2} {2 \left( \sigma_{y,i}^2 + \Gamma_i\, \sigma(t)^2 / \alpha(t)^2 \right)} where :math:`\mathbf{M}` is a binary mask (1 = observed, 0 = missing) and :math:`\odot` element-wise multiplication. See the global :class:`~physicsnemo.diffusion.guidance.DataConsistencyDPSGuidance` for :math:`\sigma_{y,i}` (``std_y``), :math:`\Gamma_i` (``gamma``), and the SDA scaling. Both ``mask`` and ``y`` are pre-patched once at construction; calling the guidance many times during sampling never re-patches them. .. important:: ``mask`` and ``y`` must be **patcheable** in the same way as the latent state :math:`\mathbf{x}`, so their spatial dimensions must equal the global resolution :math:`(H, W)`. The mask defines per-pixel observability within the global spatial domain. Tensor-valued ``std_y`` / ``gamma`` follow the same rule: a scalar, or a 4D tensor broadcastable to the global resolution :math:`(B, C, H, W)`, pre-patched at construction. When ``norm`` is a callable, it must be an elementwise loss with the signature: .. code-block:: python def norm( y_pred: Tensor, # shape: (K, C, Hp, Wp) y_true: Tensor, # shape: (K, C, Hp, Wp) ) -> Tensor: ... # elementwise loss, shape: (K, C, Hp, Wp) Parameters ---------- predictor : MultiDiffusionPredictor Predictor used to pre-patch ``mask`` and ``y`` and (optionally) fuse the guidance. Stored on ``self.predictor`` for later access. mask : Tensor Boolean mask of shape :math:`(B, C, H, W)`. ``True`` marks observed locations, ``False`` marks missing. y : Tensor Observed values of shape :math:`(B, C, H, W)`. Values at unobserved locations are ignored. std_y : float or Tensor Standard deviation of the measurement noise :math:`\boldsymbol{\sigma}_y`. A ``float`` applies a single standard deviation everywhere. A ``Tensor`` must be 4D and broadcastable to the global resolution :math:`(B, C, H, W)`; it is pre-patched like ``mask`` and ``y``. norm : int or callable, default=2 Residual loss. An ``int`` selects the corresponding :math:`L^p` norm; a callable receives ``(x_0, y)`` and must return an elementwise loss with the same shape as its inputs (the mask is applied to the result); see the signature above. gamma : float or Tensor, default=0.0 SDA covariance scaling factor :math:`\boldsymbol{\Gamma}`. Set to ``0`` for classical DPS without SDA scaling. Like ``std_y``, may be a ``float`` or a ``Tensor`` broadcastable to the global observation. sigma_fn : callable or None, default=None Function mapping diffusion time to noise level :math:`\sigma(t)`. Required when ``gamma > 0``. Typically obtained from a noise scheduler, e.g. :meth:`~physicsnemo.diffusion.noise_schedulers.LinearGaussianNoiseScheduler.sigma`. alpha_fn : callable or None, default=None Function mapping diffusion time to signal coefficient :math:`\alpha(t)`. Defaults to :math:`\alpha(t) = 1`. Typically obtained from a noise scheduler, e.g. :meth:`~physicsnemo.diffusion.noise_schedulers.LinearGaussianNoiseScheduler.alpha`. fuse : bool, default=False Whether :meth:`__call__` fuses the guidance term to the global resolution in full batch mode (``slice_start=None``). Ignored in chunked batch mode. retain_graph : bool, default=False Retain the computation graph after the gradient call. Required on all but the last guidance when combining multiple autograd-based guidances in a single :class:`MultiDiffusionDPSScorePredictor`. create_graph : bool, default=False Allow higher-order derivatives. Note ---- References: - DPS: `Diffusion Posterior Sampling for General Noisy Inverse Problems <https://arxiv.org/abs/2209.14687>`_ - SDA: `Score-based Data Assimilation <https://arxiv.org/abs/2306.10574>`_ See Also -------- :class:`~physicsnemo.diffusion.guidance.DataConsistencyDPSGuidance` : Global counterpart for non-patch-local masks. :class:`MultiDiffusionDPSScorePredictor` : Score predictor that consumes this guidance. Examples -------- **Example 1:** Inpainting on a large domain. The mask is a spatial pattern, so it decomposes along the patch grid: >>> import torch >>> from physicsnemo.core import Module >>> from physicsnemo.diffusion.multi_diffusion import ( ... MultiDiffusionModel2D, MultiDiffusionPredictor, ... MultiDiffusionDataConsistencyDPSGuidance, ... ) >>> >>> class Backbone(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 = MultiDiffusionModel2D(Backbone(), global_spatial_shape=(16, 16)) >>> md.set_random_patching(patch_shape=(8, 8), patch_num=4) >>> _ = md.eval() >>> predictor = MultiDiffusionPredictor(md, chunk_size=2) >>> predictor.set_patching(overlap_pix=0, boundary_pix=0) >>> >>> mask = torch.zeros(2, 3, 16, 16, dtype=torch.bool) >>> mask[:, :, 4:, :] = True >>> y_obs = torch.randn(2, 3, 16, 16) >>> >>> guidance = MultiDiffusionDataConsistencyDPSGuidance( ... predictor=predictor, mask=mask, y=y_obs, std_y=0.1, ... ) >>> x_chunk = torch.randn(2, 3, 8, 8, requires_grad=True) >>> t_chunk = torch.tensor([1.0, 1.0]) >>> x0_chunk = x_chunk * 0.9 >>> guidance(x_chunk, t_chunk, x0_chunk, slice_start=0).shape torch.Size([2, 3, 8, 8]) **Example 2:** Sparse probe observations with a **per-probe** measurement noise and a custom elementwise (Huber) loss, plugged into the full sampling stack; ``std_y`` is a pointwise tensor at the global resolution that sets an independent noise standard deviation at each probe (only its entries at observed locations matter), while ``gamma`` stays a scalar: >>> import torch.nn.functional as F >>> from physicsnemo.diffusion.multi_diffusion import ( ... MultiDiffusionDPSScorePredictor, ... ) >>> from physicsnemo.diffusion.noise_schedulers import EDMNoiseScheduler >>> from physicsnemo.diffusion.samplers import sample >>> >>> scheduler = EDMNoiseScheduler() >>> >>> # Elementwise Huber loss (no reduction) >>> def huber_loss(y_pred, y_true): ... return F.huber_loss(y_pred, y_true, reduction="none") ... >>> # Sparse probe locations on the global domain >>> mask = torch.zeros(2, 3, 16, 16, dtype=torch.bool) >>> mask[:, :, 2, 3] = True >>> mask[:, :, 5, 6] = True >>> mask[:, :, 11, 4] = True >>> y_obs = torch.randn(2, 3, 16, 16) >>> >>> # Per-probe measurement noise (pointwise tensor at the global resolution); >>> # entries away from the probes are unused but must stay positive >>> std_y = torch.ones(2, 3, 16, 16) >>> std_y[:, :, 2, 3] = 0.05 >>> std_y[:, :, 5, 6] = 0.1 >>> std_y[:, :, 11, 4] = 0.3 >>> guidance = MultiDiffusionDataConsistencyDPSGuidance( ... predictor=predictor, ... mask=mask, ... y=y_obs, ... std_y=std_y, # per-probe measurement noise ... norm=huber_loss, # custom elementwise loss ... gamma=1.0, # scalar SDA scaling ... sigma_fn=scheduler.sigma, ... alpha_fn=scheduler.alpha, ... ) >>> dps = MultiDiffusionDPSScorePredictor( ... x0_predictor=predictor, ... x0_to_score_fn=scheduler.x0_to_score, ... guidances=guidance, ... ) >>> denoiser = scheduler.get_denoiser(score_predictor=dps) >>> xN = torch.randn(2, 3, 16, 16) >>> x0 = sample(denoiser, xN, scheduler, num_steps=4) >>> x0.shape torch.Size([2, 3, 16, 16]) """ def __init__( self, predictor: MultiDiffusionPredictor, mask: Bool[Tensor, "B C H W"], y: Float[Tensor, "B C H W"], std_y: float | Float[Tensor, "#B #C #H #W"], norm: int | Callable[ [Float[Tensor, "K C Hp Wp"], Float[Tensor, "K C Hp Wp"]], Float[Tensor, "K C Hp Wp"], ] = 2, gamma: float | Float[Tensor, "#B #C #H #W"] = 0.0, sigma_fn: Callable[[Float[Tensor, " *shape"]], Float[Tensor, " *shape"]] | None = None, alpha_fn: Callable[[Float[Tensor, " *shape"]], Float[Tensor, " *shape"]] | None = None, fuse: bool = False, retain_graph: bool = False, create_graph: bool = False, ) -> None: self.predictor = predictor # Pre-patch mask and observations once via the predictor's patch_fn. patch = predictor.patch_fn self._mask_patched: Tensor = patch(mask.float()) self._y_patched: Tensor = patch(y) # Pre-patch std_y / gamma once like ``mask`` / ``y`` (a scalar # broadcasts to everything), so they slice per chunk alongside them. self._std_y_patched = _prepatch_param(std_y, predictor, y) self._gamma_patched = _prepatch_param(gamma, predictor, y) if sigma_fn is None and (self._gamma_patched > 0).any(): raise ValueError("sigma_fn must be provided when gamma > 0") # Resolve the loss callable at construction so __call__ has no branch. if isinstance(norm, int): self._loss_fn: Callable[[Tensor, Tensor], Tensor] = _lp_loss_fn(norm) else: self._loss_fn = norm self.sigma_fn = ( sigma_fn if sigma_fn is not None else lambda t: torch.zeros_like(t) ) self.alpha_fn = ( alpha_fn if alpha_fn is not None else lambda t: torch.ones_like(t) ) self.fuse = fuse self.retain_graph = retain_graph self.create_graph = create_graph def __call__( self, x: Float[Tensor, "K C Hp Wp"], t: Float[Tensor, " K"], x_0: Float[Tensor, "K C Hp Wp"], slice_start: int | None = None, ) -> Float[Tensor, "K C Hp Wp"] | Float[Tensor, "B C H W"]: r"""Compute the patch-local likelihood score guidance term. See :class:`MultiDiffusionDPSGuidance` for the meaning of ``slice_start`` (full vs chunked batch mode) and the :math:`K` chunk-size convention. Parameters ---------- x : Tensor Noisy patched latent slice :math:`\mathbf{x}_t^k`, of shape :math:`(K, C, H_p, W_p)`. Must have ``requires_grad=True`` and be part of a computational graph connecting to ``x_0``. Its ``dtype`` and ``device`` determine those of all internal computations. t : Tensor Patched diffusion time slice, shape :math:`(K,)`. x_0 : Tensor Estimate of the patched clean state :math:`\hat{\mathbf{x}}_0^k(\mathbf{x}_t^k, t)`, of shape :math:`(K, C, H_p, W_p)`. Must be computed from ``x`` so gradients can backpropagate. slice_start : int or None, default=None Chunk offset along the :math:`(P \times B)` dimension. See class docstring. Returns ------- Tensor Patch-local guidance term of shape :math:`(K, C, H_p, W_p)`. Fused to the global resolution :math:`(B, C, H, W)` when ``slice_start=None`` and ``fuse=True`` was passed at construction. """ if not torch.compiler.is_compiling() and torch.is_inference_mode_enabled(): raise RuntimeError( "MultiDiffusionDataConsistencyDPSGuidance requires autograd " "but torch inference mode is enabled." ) # Slice the pre-patched mask / observation / strengths to the chunk. K = x.shape[0] sl = slice(None) if slice_start is None else slice(slice_start, slice_start + K) mask_chunk = self._mask_patched[sl].to(dtype=x.dtype, device=x.device) y_chunk = self._y_patched[sl].to(dtype=x.dtype, device=x.device) std_y_chunk = self._std_y_patched[sl].to(dtype=x.dtype, device=x.device) gamma_chunk = self._gamma_patched[sl].to(dtype=x.dtype, device=x.device) with torch.enable_grad(): # Elementwise loss on the full state, then keep observed locations. loss = mask_chunk * self._loss_fn(x_0, y_chunk) # Guidance strength rho(t), broadcast over the state. bc_shape = (-1,) + (1,) * (loss.ndim - 1) t_bc = t.reshape(bc_shape) sigma_t = self.sigma_fn(t_bc) alpha_t = self.alpha_fn(t_bc) rho = 1.0 / ( 2.0 * (std_y_chunk**2 + gamma_chunk * (sigma_t**2) / (alpha_t**2)) ) grad_x = torch.autograd.grad( outputs=(rho * loss).sum(), inputs=x, retain_graph=self.retain_graph, create_graph=self.create_graph, )[0] g = -grad_x if slice_start is None and self.fuse: return self.predictor.fuse_fn(g) return g