from typing import Any
from math import pi
import keras
from keras import ops
from bayesflow.masking import MaskHandler
from bayesflow.types import Tensor
from bayesflow.utils import (
expand_right_as,
expand_right_to,
find_network,
jvp,
logging,
resolve_seed,
weighted_mean,
)
from bayesflow.utils.serialization import serializable, serialize
from ...inference import InferenceNetwork
from ...defaults import CONSISTENCY_MODEL_DEFAULTS, DIFFUSION_TRANSFORMER_DEFAULTS, WEIGHT_MLP_DEFAULTS
[docs]
@serializable("bayesflow.networks")
class StableConsistencyModel(InferenceNetwork):
"""Stable consistency model (sCM) for simulation-based inference.
Implements the simple, stable, and scalable Consistency Model with
continuous-time Consistency Training (CT) as described in [1]. The sampling
procedure is taken from [2].
Note: With the PyTorch backend on CPU, ``subnet="diffusion_transformer"`` is not supported because PyTorch CPU
scaled-dot-product attention lacks forward-mode AD support.
Parameters
----------
subnet : str, type, or keras.Layer
The neural network architecture used for the consistency model. If a
string is provided, it should be a registered name (e.g., ``"time_mlp"``).
If a type or ``keras.Layer`` is provided, it will be directly instantiated
with the given *subnet_kwargs*. Any subnet must accept a tuple of tensors
``(target, time, conditions)``. Default is ``"time_mlp"``.
sigma : float
Standard deviation of the target distribution for the consistency loss.
Controls the scale of the noise injected during training. Default is 1.0.
noise_dist_mean : float
Mean of the log-normal proposal distribution over noise levels used to
sample training times (``P_mean`` in [1]). Default is ``-1.1``.
noise_dist_std : float
Standard deviation of that proposal distribution (``P_std`` in [1]).
Default is ``2.0``.
tangent_norm_eps : float
Small constant added to the tangent norm when normalizing the training
target for stability (``c`` in [1]). Default is ``0.1``.
r_scale : float
Scale of the second stable tangent term in the continuous-time
consistency target. Default is ``1.0``.
steps : int
Default number of steps used by the multistep sampler. Can be overridden
per call by passing ``steps=`` to sampling. Default is ``15``.
rho : float
Exponent controlling the curvature of the time discretization schedule
used at sampling time. Can be overridden per call by passing ``rho=`` to
sampling. Default is ``3.5``.
subnet_kwargs : dict[str, Any], optional
Keyword arguments passed to the constructor of the chosen *subnet*
(e.g., number of hidden units, activation functions, or dropout settings).
weight_mlp_kwargs : dict[str, Any], optional
Keyword arguments for an auxiliary MLP used to generate weights within the
consistency model (e.g., depth, hidden sizes, non-linearity choices).
mask_handler : ~bayesflow.masking.MaskHandler, optional
Handler used to generate and route masks. If ``None``, a default mask handler is created.
**kwargs
Additional keyword arguments passed to the base ``InferenceNetwork``
(e.g., ``name``, ``dtype``, or ``trainable``).
References
----------
[1] Lu, C., & Song, Y. (2024). Simplifying, Stabilizing and Scaling
Continuous-Time Consistency Models. arXiv:2410.11081.
[2] Song, Y., Dhariwal, P., Chen, M. & Sutskever, I. (2023). Consistency
Models. arXiv:2303.01469.
"""
EPS_WARN = 0.1
def __init__(
self,
subnet: str | type | keras.Layer = "time_mlp",
sigma: float = 1.0,
noise_dist_mean: float = -1.1,
noise_dist_std: float = 2.0,
tangent_norm_eps: float = 0.1,
r_scale: float = 1.0,
steps: int = 15,
rho: float = 3.5,
subnet_kwargs: dict[str, Any] = None,
weight_mlp_kwargs: dict[str, Any] = None,
mask_handler: MaskHandler | None = None,
**kwargs,
):
super().__init__(base_distribution="normal", mask_handler=mask_handler, **kwargs)
subnet_defaults = {
"time_mlp": CONSISTENCY_MODEL_DEFAULTS,
"diffusion_transformer": DIFFUSION_TRANSFORMER_DEFAULTS,
}
self.subnet = find_network(subnet, **(subnet_defaults.get(subnet, {}) | (subnet_kwargs or {})))
self.subnet_projector = None
self.weight_mlp_kwargs = WEIGHT_MLP_DEFAULTS | (weight_mlp_kwargs or {})
self.weight_fn = find_network("mlp", **self.weight_mlp_kwargs)
self.weight_fn_projector = keras.layers.Dense(
units=1, bias_initializer="zeros", kernel_initializer="zeros", name="weight_fn_projector"
)
self.sigma = sigma
self.noise_dist_mean = noise_dist_mean
self.noise_dist_std = noise_dist_std
self.tangent_norm_eps = tangent_norm_eps
self.r_scale = r_scale
self.steps = steps
self.rho = rho
[docs]
def get_config(self):
base_config = super().get_config()
config = {
"subnet": self.subnet,
"sigma": self.sigma,
"noise_dist_mean": self.noise_dist_mean,
"noise_dist_std": self.noise_dist_std,
"tangent_norm_eps": self.tangent_norm_eps,
"r_scale": self.r_scale,
"steps": self.steps,
"rho": self.rho,
"weight_mlp_kwargs": self.weight_mlp_kwargs,
}
return base_config | serialize(config)
@staticmethod
def _discretize_time(num_steps: int, rho: float):
t = keras.ops.linspace(0.0, pi / 2, num_steps + 1)[1:]
times = keras.ops.exp((t - pi / 2) * rho) * pi / 2
# if rho is set too low, bad schedules can occur
if times[0] > StableConsistencyModel.EPS_WARN:
logging.warning("Warning: The last time step is large.")
logging.warning(f"Increasing rho (was {rho}) or n_steps (was {num_steps}) might improve results.")
return times
[docs]
def build(self, xz_shape, conditions_shape=None):
if self.built:
# building when the network is already built can cause issues with serialization
# see https://github.com/keras-team/keras/issues/21147
return
self.base_distribution.build(xz_shape)
self.subnet_projector = self._build_subnet_and_projector(xz_shape, conditions_shape)
# input shape for weight function and its projector
input_shape = (xz_shape[0], 1)
self.weight_fn.build(input_shape)
input_shape = self.weight_fn.compute_output_shape(input_shape)
self.weight_fn_projector.build(input_shape)
def _forward(self, x: Tensor, conditions: Tensor = None, **kwargs) -> Tensor:
# Consistency Models only learn the direction from noise distribution
# to target distribution, so we cannot implement this function.
raise NotImplementedError("Consistency Models are not invertible")
def _inverse(self, z: Tensor, conditions: Tensor = None, **kwargs) -> Tensor:
"""Generate random draws from the approximate target distribution
using the multistep sampling algorithm from [2], Algorithm 1.
Parameters
----------
z : Tensor
Samples from a standard normal distribution
conditions : Tensor, optional, default: None
Conditions for an approximate conditional distribution
**kwargs : dict, optional, default: {}
Additional keyword arguments. Pass `steps` and `rho` to override the
constructor defaults for the number of sampling steps and the time
discretization. Subnet-related kwargs (e.g., masks) are passed to the subnet.
Returns
-------
x : Tensor
The approximate samples
"""
seed = resolve_seed(kwargs.pop("seed", None), self.seed_generator)
masks = self.mask_handler.resolve(
kwargs,
targets=z,
conditions=conditions,
subnet=self.subnet,
)
subnet_kwargs = masks.subnet_kwargs
steps = kwargs.get("steps", self.steps)
rho = kwargs.get("rho", self.rho)
# noise distribution has variance sigma
x = keras.ops.copy(z) * self.sigma
discretized_time = keras.ops.flip(self._discretize_time(steps, rho=rho), axis=-1)
t = keras.ops.full((*keras.ops.shape(x)[:-1], 1), discretized_time[0], dtype=x.dtype)
x = self.mask_handler.maybe_apply(x, masks.fixed_target_mask, masks.fixed_target_value)
# apply consistency function at t_1
x = self._consistency_function(
x,
t,
conditions=conditions,
training=False,
subnet_kwargs=subnet_kwargs,
)
x = self.mask_handler.maybe_apply(x, masks.fixed_target_mask, masks.fixed_target_value)
for n in range(1, steps):
noise = keras.random.normal(keras.ops.shape(x), dtype=keras.ops.dtype(x), seed=seed) * self.sigma
t = keras.ops.full_like(t, discretized_time[n])
x_n = ops.cos(t) * x + ops.sin(t) * noise
x_n = self.mask_handler.maybe_apply(x_n, masks.fixed_target_mask, masks.fixed_target_value)
x = self._consistency_function(
x_n,
t,
conditions=conditions,
training=False,
subnet_kwargs=subnet_kwargs,
)
x = self.mask_handler.maybe_apply(x, masks.fixed_target_mask, masks.fixed_target_value)
return x
[docs]
def consistency_function(
self, x: Tensor, t: Tensor, conditions: Tensor = None, training: bool = False, **kwargs
) -> Tensor:
"""Compute consistency function at time t.
Parameters
----------
x : Tensor
Input vector
t : Tensor
Vector of time samples in [0, pi/2]
conditions : Tensor
The conditioning vector
training : bool
Flag to control whether the inner network operates in training or test mode
**kwargs : dict, optional
Additional keyword arguments to pass to the subnet.
"""
masks = self.mask_handler.resolve(
kwargs,
targets=x,
conditions=conditions,
subnet=self.subnet,
)
replacement = masks.fixed_target_value if masks.fixed_target_value is not None else x
x = self.mask_handler.maybe_apply(x, masks.fixed_target_mask, replacement)
out = self._consistency_function(
x,
t,
conditions=conditions,
training=training,
subnet_kwargs=masks.subnet_kwargs,
)
return self.mask_handler.maybe_apply(out, masks.fixed_target_mask, replacement)
def _consistency_function(
self,
x: Tensor,
t: Tensor,
conditions: Tensor,
training: bool,
subnet_kwargs: dict,
) -> Tensor:
subnet_out = self.subnet((x / self.sigma, t, conditions), training=training, **subnet_kwargs)
f = self.subnet_projector(subnet_out)
out = ops.cos(t) * x - ops.sin(t) * self.sigma * f
return out
[docs]
def compute_metrics(
self, x: Tensor, conditions: Tensor = None, stage: str = "training", sample_weight: Tensor = None, **kwargs
) -> dict[str, Tensor]:
training = stage == "training"
# generate noise vector
z = keras.random.normal(keras.ops.shape(x), dtype=keras.ops.dtype(x), seed=self.seed_generator) * self.sigma
# sample time
tau = (
keras.random.normal(keras.ops.shape(x)[:1], dtype=keras.ops.dtype(x), seed=self.seed_generator)
* self.noise_dist_std
+ self.noise_dist_mean
)
t_ = ops.arctan(ops.exp(tau) / self.sigma)
t = expand_right_as(t_, x)
# generate noisy sample
xt = ops.cos(t) * x + ops.sin(t) * z
masks = self.mask_handler.resolve(
kwargs,
targets=x,
conditions=conditions,
stage=stage,
subnet=self.subnet,
)
subnet_kwargs = masks.subnet_kwargs
xt = self.mask_handler.maybe_apply(xt, masks.fixed_target_mask, x)
# calculate estimator for dx_t/dt
dxtdt = ops.cos(t) * z - ops.sin(t) * x
dxtdt = self.mask_handler.maybe_apply(dxtdt, masks.fixed_target_mask)
def f_teacher(x, t):
o = self.subnet((x, t, conditions), training=training, **subnet_kwargs)
return self.subnet_projector(o)
primals = (xt / self.sigma, t)
tangents = (
ops.cos(t) * ops.sin(t) * dxtdt,
ops.cos(t) * ops.sin(t) * self.sigma,
)
teacher_output, cos_sin_dFdt = jvp(f_teacher, primals, tangents, return_output=True)
teacher_output = ops.stop_gradient(teacher_output)
cos_sin_dFdt = ops.stop_gradient(cos_sin_dFdt)
# calculate output of the network
subnet_out = self.subnet((xt / self.sigma, t, conditions), training=training, **subnet_kwargs)
student_out = self.subnet_projector(subnet_out)
# calculate the tangent
g = -(ops.cos(t) ** 2) * (self.sigma * teacher_output - dxtdt) - self.r_scale * (
ops.cos(t) * ops.sin(t) * xt + cos_sin_dFdt
)
# Fixed-coordinate tangents must not change the normalization of the
# active-coordinate training target.
g = self.mask_handler.maybe_apply(g, masks.fixed_target_mask)
# apply normalization to stabilize training
g = g / (ops.norm(g, axis=-1, keepdims=True) + self.tangent_norm_eps)
# compute adaptive weights and calculate loss
w = ops.squeeze(self.weight_fn_projector(self.weight_fn(expand_right_to(t_, 2))), axis=-1)
# the mean over dimensions is the ||.||^2 / D of [1]
loss = ops.mean(
ops.reshape(
self.mask_handler.maybe_apply((student_out - teacher_output - g) ** 2, masks.fixed_target_mask),
(ops.shape(teacher_output)[0], -1),
),
axis=-1,
)
loss = ops.exp(w) * loss - w
if masks.fixed_target_mask is not None:
active = ops.any(
ops.cast(ops.reshape(masks.fixed_target_mask, (ops.shape(x)[0], -1)), "bool"),
axis=-1,
)
# an all-fixed example contributes neither residual nor ``-w`` term
loss = loss * ops.cast(active, ops.dtype(loss))
loss = weighted_mean(loss, sample_weight)
return {"loss": loss}