Source code for physicsnemo.diffusion.samplers.edm_stochastic_heun

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

"""Second-order stochastic Heun sampler from the EDM paper."""

import math
from typing import Callable

import torch
from jaxtyping import Float
from torch import Tensor

from physicsnemo.diffusion.base import Denoiser

from .base import Solver


[docs] class EDMStochasticHeunSolver(Solver): r""" Second-order stochastic Heun sampler from the EDM paper. Implements stochastic sampling with configurable noise injection controlled by the "churn" parameters, using a second-order Heun correction step. .. important:: This is **not** a true SDE solver. It performs ad-hoc noise injection ("churn") at each step to improve sample diversity, but the underlying integration is still an ODE step. Therefore, the denoiser should return the right-hand side of the **ODE**, not the SDE. By default, noise injection is performed directly in time-step space. For linear-Gaussian noise schedules where diffusion time and noise level are not equal (e.g., VP schedule), provide ``sigma_fn`` and ``sigma_inv_fn`` to apply churn in noise-level space rather than time-step space. Optionally provide ``diffusion_fn`` to control the time-dependent magnitude of the injected noise. .. code-block:: python def sigma_fn( t: Tensor, # shape: (B,) or broadcastable ) -> Tensor: ... # noise level, same shape as t def sigma_inv_fn( sigma: Tensor, # shape: (B,) or broadcastable ) -> Tensor: ... # diffusion time, same shape as sigma def diffusion_fn( x: Tensor, # shape: (B, *dims) t: Tensor, # shape: (B,) ) -> Tensor: ... # g^2(x, t), broadcastable to shape of x Parameters ---------- denoiser : Denoiser A callable implementing the :class:`~physicsnemo.diffusion.Denoiser` interface. Should return the right-hand side of the **ODE** (not the SDE, since the stochastic noise injection is handled internally by this solver). Typically obtained via :meth:`~physicsnemo.diffusion.noise_schedulers.NoiseScheduler.get_denoiser` with ``denoising_type="ode"``. alpha : float, optional Interpolation parameter for the corrector step, must be in (0, 1]. ``alpha=1`` gives the standard Heun method (trapezoidal rule), ``alpha=0.5`` gives the midpoint method. By default 1. S_churn : float, optional Controls the amount of noise added at each step. Higher values add more stochasticity. By default 0 (deterministic), in which case this solver is equivalent to the deterministic :class:`HeunSolver`. S_min : float, optional Minimum diffusion time (or noise level if ``sigma_fn`` and ``sigma_inv_fn`` are provided) for applying churn. By default 0. S_max : float, optional Maximum diffusion time (or noise level if ``sigma_fn`` and ``sigma_inv_fn`` are provided) for applying churn. By default ``float("inf")``. S_noise : float, optional Noise scaling factor. Large values add more noise to the latent state. By default 1. num_steps : int, optional Total number of sampling steps, used to scale churn. By default 18. sigma_fn : Callable[[Tensor], Tensor] | None, optional Maps time to noise level :math:`\sigma(t)`. Useful for linear-Gaussian schedules where :math:`\sigma(t) \neq t`. Typically :meth:`~physicsnemo.diffusion.noise_schedulers.LinearGaussianNoiseScheduler.sigma`. If provided, ``sigma_inv_fn`` must also be provided. By default ``None`` (identity mapping). sigma_inv_fn : Callable[[Tensor], Tensor] | None, optional Maps noise level back to time. Typically :meth:`~physicsnemo.diffusion.noise_schedulers.LinearGaussianNoiseScheduler.sigma_inv`. If provided, ``sigma_fn`` must also be provided. By default ``None`` (identity mapping). diffusion_fn : Callable[[Tensor, Tensor], Tensor] | None, optional Controls the time-dependent magnitude of the injected noise, in addition of the ``S_noise`` scaling factor. Typically the squared diffusion coefficient :math:`g^2(\mathbf{x}, t)` from the reverse SDE, obtained from :meth:`~physicsnemo.diffusion.noise_schedulers.LinearGaussianNoiseScheduler.diffusion`. By default ``None`` (:math:`g^2 = 2t`), which corresponds to an EDM-like noise schedule. Note ---- Reference: `Elucidating the Design Space of Diffusion-Based Generative Models <https://arxiv.org/abs/2206.00364>`_ Examples -------- Basic usage with default parameters (noise injection in time-step space): >>> import torch >>> from physicsnemo.diffusion.samplers import ( ... EDMStochasticHeunSolver, ... ) >>> denoiser = lambda x, t: x / (1 + t.view(-1, 1, 1, 1)**2) # Toy denoiser >>> solver = EDMStochasticHeunSolver(denoiser, S_churn=40, num_steps=18) >>> x_t = torch.randn(1, 3, 8, 8) >>> t_cur = torch.tensor([1.0]) >>> t_next = torch.tensor([0.5]) >>> x_tm1 = solver.step(x_t, t_cur, t_next) >>> x_tm1.shape torch.Size([1, 3, 8, 8]) Using noise scheduler methods for linear-Gaussian schedules where :math:`\sigma(t) \neq t` (e.g., VP schedule). The callbacks map between time and noise level, allowing the churn to be applied in noise-level space before converting back to time-step space: >>> from physicsnemo.diffusion.noise_schedulers import VPNoiseScheduler >>> scheduler = VPNoiseScheduler() >>> num_steps = 10 >>> solver = EDMStochasticHeunSolver( ... denoiser, ... S_churn=40, ... num_steps=num_steps, ... sigma_fn=scheduler.sigma, ... sigma_inv_fn=scheduler.sigma_inv, ... diffusion_fn=scheduler.diffusion, ... ) >>> x_tm1 = solver.step(x_t, t_cur, t_next) >>> x_tm1.shape torch.Size([1, 3, 8, 8]) """ def __init__( self, denoiser: Denoiser, alpha: float = 1.0, S_churn: float = 0, S_min: float = 0, S_max: float = float("inf"), S_noise: float = 1, num_steps: int = 18, sigma_fn: Callable[[Float[Tensor, " *shape"]], Float[Tensor, " *shape"]] | None = None, sigma_inv_fn: Callable[[Float[Tensor, " *shape"]], Float[Tensor, " *shape"]] | None = None, diffusion_fn: Callable[ [Float[Tensor, " B *dims"], Float[Tensor, " B"]], Float[Tensor, " B *_"] ] | None = None, ) -> None: self.denoiser = denoiser if not 0 < alpha <= 1: raise ValueError(f"alpha must be in (0, 1], got {alpha}") self.alpha = alpha self.S_churn = S_churn self.S_min = S_min self.S_max = S_max self.S_noise = S_noise self.num_steps = num_steps # Validate sigma_fn and sigma_inv_fn if (sigma_fn is None) != (sigma_inv_fn is None): raise ValueError( "sigma_fn and sigma_inv_fn must both be provided or both None." ) if sigma_fn is None and sigma_inv_fn is None: self.sigma_fn = lambda t: t self.sigma_inv_fn = lambda sigma: sigma self._use_noise_level_space = False else: self.sigma_fn = sigma_fn self.sigma_inv_fn = sigma_inv_fn self._use_noise_level_space = True if diffusion_fn is None: self.diffusion_fn = lambda x, t: 2 * t.reshape(-1, *([1] * (x.ndim - 1))) else: self.diffusion_fn = diffusion_fn
[docs] def step( self, x: Float[Tensor, " B *dims"], t_cur: Float[Tensor, " B"], t_next: Float[Tensor, " B"], ) -> Float[Tensor, " B *dims"]: r""" Perform one stochastic Heun sampling step. Parameters ---------- x : Tensor Current noisy latent state :math:`\mathbf{x}_n` of shape :math:`(B, *)` where :math:`B` is the batch size. t_cur : Tensor Current diffusion time :math:`t_n` of shape :math:`(B,)`. t_next : Tensor Target diffusion time :math:`t_{n-1}` of shape :math:`(B,)`. Returns ------- Tensor Updated latent state :math:`\mathbf{x}_{n-1}` at time ``t_next``, same shape as ``x``. """ # Ensure contiguous strides so successive denoiser calls (across # sampling steps) present the same stride layout to torch.compile, # avoiding spurious recompilations / silently divergent traces. t_cur = t_cur.contiguous() t_next = t_next.contiguous() # Reshape t for broadcasting: (B,) -> (B, 1, ..., 1) expected_shape = (-1,) + (1,) * (x.ndim - 1) t_cur_bc = t_cur.reshape(expected_shape) t_next_bc = t_next.reshape(expected_shape) gamma_base = min(self.S_churn / self.num_steps, math.sqrt(2) - 1) # Compute perturbed time t_hat with increased noise # NOTE: sigma_fn and sigma_inv_fn are identity if not provided (stays # in time-step space). diffusion_fn defaults to g^2 = 2t (EDM-like # noise schedule). sigma_cur_bc = self.sigma_fn(t_cur_bc) # Mask: apply churn only where S_min <= sigma <= S_max churn_mask = (sigma_cur_bc >= self.S_min) & (sigma_cur_bc <= self.S_max) gamma_bc = torch.where(churn_mask, gamma_base, 0.0) sigma_hat_bc = sigma_cur_bc + gamma_bc * sigma_cur_bc t_hat_bc = self.sigma_inv_fn(sigma_hat_bc) # Noise scale: sqrt(sigma_hat^2 - sigma_cur^2) * S_noise * g(x,t) / sqrt(2*t) g_sq_bc = self.diffusion_fn(x, t_cur) safe_t_cur_bc = torch.where(t_cur_bc == 0, torch.ones_like(t_cur_bc), t_cur_bc) noise_scale_bc = ( (sigma_hat_bc**2 - sigma_cur_bc**2).clamp(min=0).sqrt() * self.S_noise * (g_sq_bc / (2 * safe_t_cur_bc)).sqrt() ) noise_scale_bc = torch.where( t_cur_bc == 0, torch.zeros_like(noise_scale_bc), noise_scale_bc ) # Perturb latent with noise x_hat = x + noise_scale_bc * torch.randn_like(x) # Euler step from t_hat to intermediate point (predictor) t_hat = t_hat_bc.reshape(x.shape[0]) h_bc = t_next_bc - t_hat_bc d_cur = self.denoiser(x_hat, t_hat) t_prime_bc = t_hat_bc + self.alpha * h_bc x_prime = x_hat + self.alpha * h_bc * d_cur # Mask for elements where t_next != 0 (need 2nd order correction) mask_bc = (t_next_bc != 0).float() # Second RHS evaluation (compute everywhere, masked later) t_prime = t_prime_bc.reshape(x.shape[0]) # Avoid issues by using t_hat where t_prime would be 0 t_prime_safe = torch.where(t_prime == 0, t_hat, t_prime) d_prime = self.denoiser(x_prime, t_prime_safe) # Apply 2nd order correction only where t_next != 0 w_cur = 1 - 1 / (2 * self.alpha) w_prime = 1 / (2 * self.alpha) x_euler = x_hat + h_bc * d_cur x_heun = x_hat + h_bc * (w_cur * d_cur + w_prime * d_prime) x_next = mask_bc * x_heun + (1 - mask_bc) * x_euler return x_next