# 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.
"""Abstract base class for linear-Gaussian noise schedules."""
from abc import ABC, abstractmethod
from typing import Any, Literal, Tuple
import torch
from jaxtyping import Float
from torch import Tensor
from physicsnemo.diffusion.base import Denoiser, Predictor
from .base import NoiseScheduler
[docs]
class LinearGaussianNoiseScheduler(ABC, NoiseScheduler):
r"""
Abstract base class for linear-Gaussian noise schedules.
It implements the :class:`NoiseScheduler` interface and it can be
subclassed to define custom linear-Gaussian noise schedules of the form:
.. math::
\mathbf{x}(t) = \alpha(t) \mathbf{x}_0
+ \sigma(t) \boldsymbol{\epsilon}
where :math:`\boldsymbol{\epsilon} \sim \mathcal{N}(0, \mathbf{I})` is
standard Gaussian noise, :math:`\alpha(t)` is the signal coefficient, and
:math:`\sigma(t)` is the noise level.
**Training:**
The :meth:`add_noise` method implements the forward diffusion process using
the formula above. The :meth:`sample_time` method samples diffusion times.
**Sampling:**
For ODE-based sampling, the reverse process follows the probability flow
ODE:
.. math::
\frac{d\mathbf{x}}{dt} = f(\mathbf{x}, t)
- \frac{1}{2} g^2(\mathbf{x}, t) \nabla_{\mathbf{x}} \log p(\mathbf{x})
For SDE-based sampling:
.. math::
d\mathbf{x} = \left[ f(\mathbf{x}, t)
- g^2(\mathbf{x}, t) \nabla_{\mathbf{x}} \log p(\mathbf{x}) \right] dt
+ g(\mathbf{x}, t) d\mathbf{W}
The :meth:`get_denoiser` factory converts a predictor (either a
score-predictor or an x0-predictor) into the appropriate ODE/SDE
right-hand side.
**Abstract methods (must be implemented by subclasses):**
- :meth:`sigma`: Map time to noise level :math:`\sigma(t)`
- :meth:`sigma_inv`: Map noise level back to time
- :meth:`sigma_dot`: Time derivative :math:`\dot{\sigma}(t)`
- :meth:`alpha`: Compute the signal coefficient :math:`\alpha(t)`
- :meth:`alpha_dot`: Time derivative :math:`\dot{\alpha}(t)`
- :meth:`timesteps`: Generate discrete time-steps for sampling
- :meth:`sample_time`: Sample diffusion times for training
- :meth:`loss_weight`: Compute loss weight for training
**Concrete methods (have default implementations, but can be overridden for
custom behavior):**
- :meth:`drift`: Drift term :math:`f(\mathbf{x}, t)` for ODE/SDE
- :meth:`diffusion`: Squared diffusion term :math:`g^2(\mathbf{x}, t)`
- :meth:`x0_to_score`: Convert x0-prediction to score
- :meth:`score_to_x0`: Convert score to x0-prediction
- :meth:`add_noise`: Add noise to clean data (training)
- :meth:`init_latents`: Initialize latent state (sampling)
- :meth:`get_denoiser`: Get ODE/SDE RHS (sampling)
Examples
--------
**Example 1:** A minimal EDM-like noise schedule. Only the abstract methods
need to be implemented since defaults work for EDM:
>>> import torch
>>> from physicsnemo.diffusion.noise_schedulers import (
... LinearGaussianNoiseScheduler,
... )
>>>
>>> class SimpleEDMScheduler(LinearGaussianNoiseScheduler):
... def __init__(self, sigma_min=0.002, sigma_max=80.0, rho=7.0):
... self.sigma_min = sigma_min
... self.sigma_max = sigma_max
... self.rho = rho
...
... def sigma(self, t): return t
... def sigma_inv(self, sigma): return sigma
... def sigma_dot(self, t): return torch.ones_like(t)
... def alpha(self, t): return torch.ones_like(t)
... def alpha_dot(self, t): return torch.zeros_like(t)
...
... def timesteps(self, num_steps, *, device=None, dtype=None):
... i = torch.arange(num_steps, device=device, dtype=dtype)
... smax_rho = self.sigma_max**(1/self.rho)
... smin_rho = self.sigma_min**(1/self.rho)
... frac = i/(num_steps-1)
... t = (smax_rho + frac * (smin_rho - smax_rho))**self.rho
... return torch.cat([t, torch.zeros(1, device=device)])
...
... def sample_time(self, N, *, device=None, dtype=None):
... u = torch.rand(N, device=device, dtype=dtype)
... return self.sigma_min * (self.sigma_max/self.sigma_min)**u
... def loss_weight(self, t):
... return 1 / t**2
...
>>> scheduler = SimpleEDMScheduler()
>>> t_steps = scheduler.timesteps(10)
>>> t_steps.shape
torch.Size([11])
**Example 2:** Customizing behavior by overriding concrete methods. This
shows how to override the drift term for a custom diffusion process:
>>> class CustomDriftScheduler(SimpleEDMScheduler):
... def drift(self, x, t):
... # Custom drift: f(x, t) = -0.5 * x (Ornstein-Uhlenbeck style)
... return -0.5 * x
...
>>> custom = CustomDriftScheduler()
>>>
>>> # The custom drift is used internally by get_denoiser
>>> score_pred = lambda x, t: -x / (1 + t.view(-1, 1)**2) # Toy score predictor
>>> denoiser = custom.get_denoiser(score_predictor=score_pred)
>>> x = torch.randn(2, 4)
>>> t = torch.tensor([1.0, 1.0])
>>> out = denoiser(x, t) # Uses custom drift in ODE RHS computation
>>> out.shape
torch.Size([2, 4])
"""
[docs]
@abstractmethod
def sigma(
self,
t: Float[Tensor, " *shape"],
) -> Float[Tensor, " *shape"]:
r"""
Map diffusion time to noise level :math:`\sigma(t)`.
Used in both training and sampling.
Parameters
----------
t : Tensor
Diffusion time tensor of any shape.
Returns
-------
Tensor
Noise coefficient :math:`\sigma(t)` with same shape as ``t``.
"""
...
[docs]
@abstractmethod
def sigma_inv(
self,
sigma: Float[Tensor, " *shape"],
) -> Float[Tensor, " *shape"]:
r"""
Map noise level back to diffusion time.
Used in both training and sampling.
Parameters
----------
sigma : Tensor
Noise level tensor of any shape.
Returns
-------
Tensor
Diffusion time with same shape as ``sigma``.
"""
...
[docs]
@abstractmethod
def sigma_dot(
self,
t: Float[Tensor, " *shape"],
) -> Float[Tensor, " *shape"]:
r"""
Compute time derivative of noise level :math:`\dot{\sigma}(t)`.
Used in sampling.
Parameters
----------
t : Tensor
Diffusion time tensor of any shape.
Returns
-------
Tensor
Time derivative :math:`\dot{\sigma}(t)` with same shape as ``t``.
"""
...
[docs]
@abstractmethod
def alpha(
self,
t: Float[Tensor, " *shape"],
) -> Float[Tensor, " *shape"]:
r"""
Compute the signal coefficient :math:`\alpha(t)`.
Used in both training and sampling.
Parameters
----------
t : Tensor
Diffusion time tensor of any shape.
Returns
-------
Tensor
Signal coefficient :math:`\alpha(t)` with same shape as ``t``.
"""
...
[docs]
@abstractmethod
def alpha_dot(
self,
t: Float[Tensor, " *shape"],
) -> Float[Tensor, " *shape"]:
r"""
Compute time derivative of signal coefficient :math:`\dot{\alpha}(t)`.
Used in sampling.
Parameters
----------
t : Tensor
Diffusion time tensor of any shape.
Returns
-------
Tensor
Time derivative :math:`\dot{\alpha}(t)` with same shape as ``t``.
"""
...
[docs]
@abstractmethod
def timesteps(
self,
num_steps: int,
*,
device: torch.device | None = None,
dtype: torch.dtype | None = None,
) -> Float[Tensor, " N+1"]:
r"""
Generate discrete time-steps for sampling.
Used in sampling to produce the sequence of diffusion times. Returns
a tensor of shape :math:`(N + 1,)` in decreasing order, with the last
element being 0.
Parameters
----------
num_steps : int
Number of sampling steps.
device : torch.device, optional
Device to place the tensor on.
dtype : torch.dtype, optional
Data type of the tensor.
Returns
-------
Tensor
Time-steps tensor of shape :math:`(N + 1,)`.
"""
...
[docs]
@abstractmethod
def sample_time(
self,
N: int,
*,
device: torch.device | None = None,
dtype: torch.dtype | None = None,
) -> Float[Tensor, " N"]:
r"""
Sample N diffusion time values for training.
Used in training to sample random diffusion times for the denoising
score matching loss.
Parameters
----------
N : int
Number of time values to sample.
device : torch.device, optional
Device to place the tensor on.
dtype : torch.dtype, optional
Data type of the tensor.
Returns
-------
Tensor
Sampled diffusion times of shape :math:`(N,)`.
"""
...
[docs]
@abstractmethod
def loss_weight(
self,
t: Float[Tensor, " N"],
) -> Float[Tensor, " N"] | Float[Tensor, " N C"]:
r"""
Compute loss weight for denoising score matching training.
Used in training to weight the per-sample loss in
:class:`~physicsnemo.diffusion.metrics.losses.MSEDSMLoss`. The loss
weight is designed for training an x0-predictor (clean data
predictor).
For training a score-predictor, additionally provide a
``score_to_x0_fn`` callback to
:class:`~physicsnemo.diffusion.metrics.losses.MSEDSMLoss`.
Parameters
----------
t : Tensor
Diffusion time values of shape :math:`(N,)`.
Returns
-------
Tensor
Loss weight with leading dimension :math:`N`. Shape is
:math:`(N,)` for scalar ``sigma_data``, or :math:`(N, C)`
when the scheduler uses per-channel ``sigma_data`` (see
:class:`EDMNoiseScheduler`).
"""
...
[docs]
def drift(
self,
x: Float[Tensor, " B *dims"],
t: Float[Tensor, " B"],
) -> Float[Tensor, " B *dims"]:
r"""
Compute drift term :math:`f(\mathbf{x}, t)` for ODE/SDE sampling.
Used by :meth:`get_denoiser` to build the ODE/SDE right-hand side.
By default: :math:`f(\mathbf{x}, t) = \frac{\dot{\alpha}(t)}{\alpha(t)}
\mathbf{x}`.
This method can be overridden to implement different drift terms.
Parameters
----------
x : Tensor
Latent state of shape :math:`(B, *)`.
t : Tensor
Diffusion time of shape :math:`(B,)`.
Returns
-------
Tensor
Drift term with same shape as ``x``.
"""
expected_shape = (-1,) + (1,) * (x.ndim - 1)
t_bc = t.reshape(expected_shape)
alpha_t_bc = self.alpha(t_bc)
alpha_dot_t_bc = self.alpha_dot(t_bc)
return (alpha_dot_t_bc / alpha_t_bc) * x
[docs]
def diffusion(
self,
x: Float[Tensor, " B *dims"],
t: Float[Tensor, " B"],
) -> Float[Tensor, " B *_"]:
r"""
Compute squared diffusion term :math:`g^2(\mathbf{x}, t)`.
Used by :meth:`get_denoiser` to build the ODE/SDE right-hand side.
By default: :math:`g^2 = 2 \dot{\sigma} \sigma - 2 \frac{\dot{\alpha}}
{\alpha} \sigma^2`.
This method can be overridden to implement different diffusion terms.
Parameters
----------
x : Tensor
Latent state of shape :math:`(B, *)`.
t : Tensor
Diffusion time of shape :math:`(B,)`.
Returns
-------
Tensor
Squared diffusion term, broadcastable to shape of ``x``.
"""
expected_shape = (-1,) + (1,) * (x.ndim - 1)
t_bc = t.reshape(expected_shape)
sigma_t_bc = self.sigma(t_bc)
sigma_dot_t_bc = self.sigma_dot(t_bc)
alpha_t_bc = self.alpha(t_bc)
alpha_dot_t_bc = self.alpha_dot(t_bc)
g_sq_bc = (
2 * sigma_dot_t_bc * sigma_t_bc
- 2 * (alpha_dot_t_bc / alpha_t_bc) * sigma_t_bc**2
)
return g_sq_bc
[docs]
def x0_to_score(
self,
x0: Float[Tensor, " B *dims"],
x_t: Float[Tensor, " B *dims"],
t: Float[Tensor, " B"],
) -> Float[Tensor, " B *dims"]:
r"""
Convert x0-predictor output to score.
This conversion is done automatically by :meth:`get_denoiser` when
``x0_predictor`` is provided, but can also be called manually.
The score is: :math:`\nabla_{\mathbf{x}_t} \log p(\mathbf{x}_t)
= \frac{\alpha(t) \hat{\mathbf{x}}_0 - \mathbf{x}_t}{\sigma^2(t)}`.
This is a helper method that usually does not need to be overridden in
subclasses.
Parameters
----------
x0 : Tensor
Predicted clean data :math:`\hat{\mathbf{x}}_0` of shape
:math:`(B, *)`.
x_t : Tensor
Current noisy state :math:`\mathbf{x}_t` of shape :math:`(B, *)`.
t : Tensor
Diffusion time of shape :math:`(B,)`.
Returns
-------
Tensor
Score with same shape as ``x0``.
Examples
--------
>>> from physicsnemo.diffusion.noise_schedulers import EDMNoiseScheduler
>>> scheduler = EDMNoiseScheduler()
>>> # If you have an x0-predictor, wrap it for manual conversion
>>> # (done automatically by get_denoiser):
>>> def x0_predictor(x, t):
... t_bc = t.view(-1, *([1] * (x.ndim - 1)))
... return x / (1 + t_bc**2)
>>> def score_predictor(x, t):
... x0_pred = x0_predictor(x, t)
... return scheduler.x0_to_score(x0_pred, x, t)
>>> # Or simply: scheduler.get_denoiser(x0_predictor=x0_predictor)
"""
expected_shape = (-1,) + (1,) * (x0.ndim - 1)
t_bc = t.reshape(expected_shape)
alpha_t_bc = self.alpha(t_bc)
sigma_t_bc = self.sigma(t_bc)
return (alpha_t_bc * x0 - x_t) / (sigma_t_bc**2)
[docs]
def score_to_x0(
self,
score: Float[Tensor, " B *dims"],
x_t: Float[Tensor, " B *dims"],
t: Float[Tensor, " B"],
) -> Float[Tensor, " B *dims"]:
r"""
Convert score to x0-prediction.
This is the inverse of :meth:`x0_to_score`. Given a score
prediction :math:`s(\mathbf{x}_t, t)` and
the noisy state :math:`\mathbf{x}_t`, recover the corresponding
:math:`\hat{\mathbf{x}}_0` estimate:
.. math::
\hat{\mathbf{x}}_0 = \frac{\mathbf{x}_t
+ \sigma^2(t) \nabla_{\mathbf{x}_t} \log p(\mathbf{x}_t)}
{\alpha(t)}
A common use case is with
:class:`~physicsnemo.diffusion.metrics.losses.MSEDSMLoss` to train a
score-predictor instead of an x0-predictor: pass this method as the
``score_to_x0_fn`` argument with ``prediction_type="score"``.
This is a helper method that usually does not need to be overridden
in subclasses.
Parameters
----------
score : Tensor
Predicted score :math:`s(\mathbf{x}_t, t)` of shape :math:`(B, *)`.
x_t : Tensor
Current noisy state :math:`\mathbf{x}_t` with same shape as
``score``.
t : Tensor
Diffusion time with shape :math:`(B,)`.
Returns
-------
Tensor
Estimated clean data :math:`\hat{\mathbf{x}}_0` with same shape
as ``score``.
Examples
--------
>>> from physicsnemo.diffusion.noise_schedulers import EDMNoiseScheduler
>>> scheduler = EDMNoiseScheduler()
>>> # If you have a score-predictor, convert to x0 for DSM loss:
>>> def score_predictor(x, t):
... return -x / (1 + t.view(-1, *([1] * (x.ndim - 1)))**2)
>>> x_t = torch.randn(2, 4)
>>> t = torch.tensor([1.0, 1.0])
>>> score = score_predictor(x_t, t)
>>> x0_est = scheduler.score_to_x0(score, x_t, t)
>>> x0_est.shape
torch.Size([2, 4])
"""
expected_shape = (-1,) + (1,) * (score.ndim - 1)
t_bc = t.reshape(expected_shape)
alpha_t_bc = self.alpha(t_bc)
sigma_t_bc = self.sigma(t_bc)
return (x_t + sigma_t_bc**2 * score) / alpha_t_bc
[docs]
def epsilon_to_score(
self,
epsilon: Float[Tensor, " B *dims"],
t: Float[Tensor, " B"],
) -> Float[Tensor, " B *dims"]:
r"""
Convert epsilon (noise) prediction to score.
For the linear-Gaussian forward process
:math:`\mathbf{x}_t = \alpha(t)\mathbf{x}_0
+ \sigma(t)\boldsymbol{\epsilon}`, the score is related to epsilon by:
.. math::
\nabla_{\mathbf{x}_t} \log p(\mathbf{x}_t)
= -\frac{\boldsymbol{\epsilon}}{\sigma(t)}
Parameters
----------
epsilon : Tensor
Predicted noise :math:`\hat{\boldsymbol{\epsilon}}` of shape
:math:`(B, *)`.
t : Tensor
Diffusion time of shape :math:`(B,)`.
Returns
-------
Tensor
Score with same shape as ``epsilon``.
Examples
--------
>>> from physicsnemo.diffusion.noise_schedulers import EDMNoiseScheduler
>>> scheduler = EDMNoiseScheduler()
>>> eps = torch.randn(2, 4)
>>> t = torch.tensor([1.0, 1.0])
>>> score = scheduler.epsilon_to_score(eps, t)
>>> score.shape
torch.Size([2, 4])
"""
expected_shape = (-1,) + (1,) * (epsilon.ndim - 1)
t_bc = t.reshape(expected_shape)
sigma_t_bc = self.sigma(t_bc)
return -epsilon / sigma_t_bc
[docs]
def score_to_epsilon(
self,
score: Float[Tensor, " B *dims"],
t: Float[Tensor, " B"],
) -> Float[Tensor, " B *dims"]:
r"""
Convert score to epsilon (noise) prediction.
Inverse of :meth:`epsilon_to_score`:
.. math::
\boldsymbol{\epsilon}
= -\sigma(t) \nabla_{\mathbf{x}_t} \log p(\mathbf{x}_t)
Parameters
----------
score : Tensor
Score :math:`\nabla_{\mathbf{x}_t} \log p(\mathbf{x}_t)` of
shape :math:`(B, *)`.
t : Tensor
Diffusion time of shape :math:`(B,)`.
Returns
-------
Tensor
Epsilon with same shape as ``score``.
Examples
--------
>>> from physicsnemo.diffusion.noise_schedulers import EDMNoiseScheduler
>>> scheduler = EDMNoiseScheduler()
>>> score = torch.randn(2, 4)
>>> t = torch.tensor([1.0, 1.0])
>>> eps = scheduler.score_to_epsilon(score, t)
>>> eps.shape
torch.Size([2, 4])
"""
expected_shape = (-1,) + (1,) * (score.ndim - 1)
t_bc = t.reshape(expected_shape)
sigma_t_bc = self.sigma(t_bc)
return -sigma_t_bc * score
[docs]
def epsilon_to_x0(
self,
epsilon: Float[Tensor, " B *dims"],
x_t: Float[Tensor, " B *dims"],
t: Float[Tensor, " B"],
) -> Float[Tensor, " B *dims"]:
r"""
Convert epsilon (noise) prediction to x0-prediction.
Given :math:`\mathbf{x}_t = \alpha(t)\mathbf{x}_0
+ \sigma(t)\boldsymbol{\epsilon}`:
.. math::
\hat{\mathbf{x}}_0 = \frac{\mathbf{x}_t
- \sigma(t)\hat{\boldsymbol{\epsilon}}}{\alpha(t)}
Parameters
----------
epsilon : Tensor
Predicted noise :math:`\hat{\boldsymbol{\epsilon}}` of shape
:math:`(B, *)`.
x_t : Tensor
Current noisy state :math:`\mathbf{x}_t` with same shape as
``epsilon``.
t : Tensor
Diffusion time of shape :math:`(B,)`.
Returns
-------
Tensor
Estimated clean data :math:`\hat{\mathbf{x}}_0` with same shape
as ``epsilon``.
Examples
--------
>>> from physicsnemo.diffusion.noise_schedulers import EDMNoiseScheduler
>>> scheduler = EDMNoiseScheduler()
>>> eps = torch.randn(2, 4)
>>> x_t = torch.randn(2, 4)
>>> t = torch.tensor([1.0, 1.0])
>>> x0_est = scheduler.epsilon_to_x0(eps, x_t, t)
>>> x0_est.shape
torch.Size([2, 4])
"""
expected_shape = (-1,) + (1,) * (epsilon.ndim - 1)
t_bc = t.reshape(expected_shape)
alpha_t_bc = self.alpha(t_bc)
sigma_t_bc = self.sigma(t_bc)
return (x_t - sigma_t_bc * epsilon) / alpha_t_bc
[docs]
def x0_to_epsilon(
self,
x0: Float[Tensor, " B *dims"],
x_t: Float[Tensor, " B *dims"],
t: Float[Tensor, " B"],
) -> Float[Tensor, " B *dims"]:
r"""
Convert x0-prediction to epsilon (noise) prediction.
Inverse of :meth:`epsilon_to_x0`:
.. math::
\hat{\boldsymbol{\epsilon}} = \frac{\mathbf{x}_t
- \alpha(t)\hat{\mathbf{x}}_0}{\sigma(t)}
Parameters
----------
x0 : Tensor
Predicted clean data :math:`\hat{\mathbf{x}}_0` of shape
:math:`(B, *)`.
x_t : Tensor
Current noisy state :math:`\mathbf{x}_t` with same shape as
``x0``.
t : Tensor
Diffusion time of shape :math:`(B,)`.
Returns
-------
Tensor
Epsilon with same shape as ``x0``.
Examples
--------
>>> from physicsnemo.diffusion.noise_schedulers import EDMNoiseScheduler
>>> scheduler = EDMNoiseScheduler()
>>> x0 = torch.randn(2, 4)
>>> x_t = torch.randn(2, 4)
>>> t = torch.tensor([1.0, 1.0])
>>> eps = scheduler.x0_to_epsilon(x0, x_t, t)
>>> eps.shape
torch.Size([2, 4])
"""
expected_shape = (-1,) + (1,) * (x0.ndim - 1)
t_bc = t.reshape(expected_shape)
alpha_t_bc = self.alpha(t_bc)
sigma_t_bc = self.sigma(t_bc)
return (x_t - alpha_t_bc * x0) / sigma_t_bc
[docs]
def get_denoiser(
self,
*,
score_predictor: Predictor | None = None,
x0_predictor: Predictor | None = None,
epsilon_predictor: Predictor | None = None,
denoising_type: Literal["ode", "sde"] = "ode",
**kwargs: Any,
) -> Denoiser:
r"""
Factory that converts a predictor to a denoiser for sampling.
Accepts exactly one of **score-predictor**, **x0-predictor**, or
**epsilon-predictor**. The returned denoiser computes the right-hand
side of the reverse ODE or SDE.
For ODE (``denoising_type="ode"``):
.. math::
\frac{d\mathbf{x}}{dt} = f(\mathbf{x}, t) - \frac{1}{2} g^2(t)
s(\mathbf{x}, t)
For SDE (``denoising_type="sde"``):
.. math::
d\mathbf{x} = \left[ f(\mathbf{x}, t) - g^2(t) s(\mathbf{x}, t)
\right] dt + g(t) d\mathbf{W}
where :math:`s(\mathbf{x}, t)` is the score. When an x0-predictor is
provided, the score is computed internally via :meth:`x0_to_score`.
When an epsilon-predictor is provided, the score is computed internally
via :meth:`epsilon_to_score`. When a score-predictor is provided, it
is used directly. *Note:* As usually done in SDE integration, the
stochastic term :math:`g(t) d\mathbf{W}` is handled by the solver,
not returned by the denoiser itself.
Parameters
----------
score_predictor : Predictor, optional
A score-predictor that takes ``(x_t, t)`` and returns a score
(e.g. :math:`\nabla_{\mathbf{x}} \log p(\mathbf{x}_t)`). Can be
unconditional, conditional, guidance-augmented, etc. Mutually
exclusive with ``x0_predictor`` and ``epsilon_predictor``.
x0_predictor : Predictor, optional
An x0-predictor that takes ``(x_t, t)`` and returns an estimate
of clean data :math:`\hat{\mathbf{x}}_0`. The score is computed
internally via :meth:`x0_to_score`. Mutually exclusive with
``score_predictor`` and ``epsilon_predictor``.
epsilon_predictor : Predictor, optional
An epsilon-predictor that takes ``(x_t, t)`` and returns an
estimate of the noise
:math:`\hat{\boldsymbol{\epsilon}}`. The score is computed
internally via :meth:`epsilon_to_score`. Mutually exclusive with
``score_predictor`` and ``x0_predictor``.
denoising_type : {"ode", "sde"}, default="ode"
Type of reverse process. Use ``"ode"`` for deterministic sampling,
``"sde"`` for stochastic sampling.
**kwargs : Any
Ignored.
Returns
-------
Denoiser
A denoiser computing the RHS of the reverse ODE/SDE. Implements
the :class:`~physicsnemo.diffusion.Denoiser` interface.
Raises
------
ValueError
If not exactly one of ``score_predictor``, ``x0_predictor``, or
``epsilon_predictor`` is provided.
Examples
--------
Generate ODE RHS from a score-predictor:
>>> import torch
>>> from physicsnemo.diffusion.noise_schedulers import EDMNoiseScheduler
>>> scheduler = EDMNoiseScheduler()
>>> score_pred = lambda x, t: -x / t.view(-1, 1, 1, 1)**2 # Toy score-predictor
>>> denoiser = scheduler.get_denoiser(
... score_predictor=score_pred, denoising_type="ode")
>>> x = torch.randn(2, 3, 8, 8)
>>> t = torch.tensor([1.0, 1.0])
>>> dx_dt = denoiser(x, t) # Returns ODE RHS for sampling
>>> dx_dt.shape
torch.Size([2, 3, 8, 8])
Generate ODE RHS from an x0-predictor (score conversion is done internally):
>>> x0_pred = lambda x, t: x / (1 + t.view(-1, 1, 1, 1)**2) # Toy x0-predictor
>>> denoiser = scheduler.get_denoiser(
... x0_predictor=x0_pred, denoising_type="ode")
>>> dx_dt = denoiser(x, t) # Returns ODE RHS for sampling
>>> dx_dt.shape
torch.Size([2, 3, 8, 8])
Generate ODE RHS from an epsilon-predictor:
>>> eps_pred = lambda x, t: x * 0.1 # Toy epsilon-predictor
>>> denoiser = scheduler.get_denoiser(
... epsilon_predictor=eps_pred, denoising_type="ode")
>>> dx_dt = denoiser(x, t) # Returns ODE RHS for sampling
>>> dx_dt.shape
torch.Size([2, 3, 8, 8])
"""
# Validate: exactly one predictor must be provided
provided = sum(
p is not None for p in (score_predictor, x0_predictor, epsilon_predictor)
)
if provided != 1:
raise ValueError(
"Exactly one of 'score_predictor', 'x0_predictor', or "
"'epsilon_predictor' must be provided."
)
# Capture methods as local variables to avoid referencing self
drift = self.drift
diffusion = self.diffusion
# Build the score function
if x0_predictor is not None:
x0_to_score = self.x0_to_score
def _score(
x: Float[Tensor, " B *dims"],
t: Float[Tensor, " B"],
) -> Float[Tensor, " B *dims"]:
x0 = x0_predictor(x, t)
return x0_to_score(x0, x, t)
score_fn = _score
elif epsilon_predictor is not None:
eps_to_score = self.epsilon_to_score
def _score_from_eps(
x: Float[Tensor, " B *dims"],
t: Float[Tensor, " B"],
) -> Float[Tensor, " B *dims"]:
eps = epsilon_predictor(x, t)
return eps_to_score(eps, t)
score_fn = _score_from_eps
else:
score_fn = score_predictor
if denoising_type == "ode":
def ode_denoiser(
x: Float[Tensor, " B *dims"],
t: Float[Tensor, " B"],
) -> Float[Tensor, " B *dims"]:
score = score_fn(x, t)
f = drift(x, t)
g_sq_bc = diffusion(x, t)
dx_dt = f - 0.5 * g_sq_bc * score
return dx_dt
return ode_denoiser
elif denoising_type == "sde":
def sde_denoiser(
x: Float[Tensor, " B *dims"],
t: Float[Tensor, " B"],
) -> Float[Tensor, " B *dims"]:
score = score_fn(x, t)
f = drift(x, t)
g_sq_bc = diffusion(x, t)
# Deterministic part of the SDE drift
# Note: stochastic term g(t)*dW is handled by the solver
dx_dt = f - g_sq_bc * score
return dx_dt
return sde_denoiser
else:
raise ValueError(
f"denoising_type must be 'ode' or 'sde', got '{denoising_type}'"
)
[docs]
def add_noise(
self,
x0: Float[Tensor, " B *dims"],
time: Float[Tensor, " B"],
) -> Float[Tensor, " B *dims"]:
r"""
Add noise to clean data at the given diffusion times.
Used in training to create noisy samples from clean data. Implements:
.. math::
\mathbf{x}(t) = \alpha(t) \mathbf{x}_0
+ \sigma(t) \boldsymbol{\epsilon}
Usually does not need to be overridden in subclasses: overriding the
:meth:`alpha` and :meth:`sigma` methods is sufficient for most use
cases.
Parameters
----------
x0 : Tensor
Clean latent state of shape :math:`(B, *)`.
time : Tensor
Diffusion time values of shape :math:`(B,)`.
Returns
-------
Tensor
Noisy latent state of shape :math:`(B, *)`.
"""
expected_shape = (-1,) + (1,) * (x0.ndim - 1)
t_bc = time.reshape(expected_shape)
alpha_t_bc = self.alpha(t_bc)
sigma_t_bc = self.sigma(t_bc)
noise = torch.randn_like(x0)
return alpha_t_bc * x0 + sigma_t_bc * noise
[docs]
def init_latents(
self,
spatial_shape: Tuple[int, ...],
tN: Float[Tensor, " B"],
*,
device: torch.device | None = None,
dtype: torch.dtype | None = None,
) -> Float[Tensor, " B *spatial_shape"]:
r"""
Initialize the noisy latent state :math:`\mathbf{x}_N` for sampling.
Generates:
.. math::
\mathbf{x}_N = \sigma(t_N) \cdot \boldsymbol{\epsilon}
where :math:`\boldsymbol{\epsilon} \sim \mathcal{N}(0, \mathbf{I})`.
Parameters
----------
spatial_shape : Tuple[int, ...]
Spatial shape of the latent state, e.g., ``(C, H, W)``.
tN : Tensor
Initial diffusion time of shape :math:`(B,)`.
device : torch.device, optional
Device to place the tensor on.
dtype : torch.dtype, optional
Data type of the tensor.
Returns
-------
Tensor
Initial noisy latent of shape :math:`(B, *spatial\_shape)`.
"""
B = tN.shape[0]
noise = torch.randn(B, *spatial_shape, device=device, dtype=dtype)
expected_shape = (-1,) + (1,) * len(spatial_shape)
tN_bc = tN.reshape(expected_shape)
sigma_tN_bc = self.sigma(tN_bc)
return sigma_tN_bc * noise