Skip to content

Neural models

Flax NNX modules. The generative families share one interface — construct with nnx.Rngs, fit to train, as_dist() for a frozen distribution — so they are interchangeable. See Density estimation.

This page covers the user-facing surface. probjax.nn also exports the building blocks those models are assembled from (attention masks, block sizes, individual bijector configs); read the source for those.

Normalizing flows

probjax.nn.maf module-attribute

maf = AffineAutoregressiveFlow

probjax.nn.nsf module-attribute

nsf = SplineAutoregressiveFlow

probjax.nn.naf module-attribute

naf = NeuralAutoregressiveFlow

probjax.nn.unaf module-attribute

unaf = UnconstrainedNeuralAutoregressiveFlow

probjax.nn.sospf module-attribute

sospf = SumOfSquaresPolynomialFlow

probjax.nn.bpf module-attribute

bpf = BernsteinPolynomialFlow

probjax.nn.gf module-attribute

gf = GaussianizationFlow

probjax.nn.NormalizingFlow

Bases: StandardizingMixin, GenerativeModel

Source code in probjax/nn/generative/nflows/models.py
class NormalizingFlow(StandardizingMixin, GenerativeModel):
    def __init__(
        self,
        base_dist,
        transformation: Callable[..., Any],
        name: Optional[str] = None,
        *,
        standardize: bool = True,
    ):
        self.base_dist = base_dist
        self.transformation = transformation
        self.name = name
        # Standardisation is a per-dimension affine map over a flat event, so
        # it only applies to a base with a single flat event axis. A structured
        # (pytree) base has no such shape; leave those models untouched.
        event = getattr(base_dist, "event_shape", None)
        flat = event is not None and len(tuple(event)) == 1
        self._init_standardization(
            int(tuple(event)[0]) if flat else 1, standardize and bool(flat)
        )
        super().__init__()

    def _flow_distribution(self):
        return transformed(base_dist=self.base_dist, bijector=self.transformation)

    def _conditional_flow_distribution(self, context):
        def _bijector(x):
            return self.transformation(x, context)

        return transformed(base_dist=self.base_dist, bijector=_bijector)

    def _flow_distribution_for_context(self, context=None):
        if context is None:
            return self._flow_distribution()
        return self._conditional_flow_distribution(context)

    def transform(self, x, context=None, *, rng: jax.Array | None = None):
        """Push a base sample through to the data space.

        Data space, not standardised space: this has to agree with ``sample``
        and ``logpdf``. ``self.transformation`` is the inner map and stays in
        standardised coordinates, which is what ``_logpdf`` feeds it.
        """
        if context is None:
            out = self.transformation(x, rng=rng)
        else:
            out = self.transformation(x, context, rng=rng)
        return self._unstandardize(out) if self.standardize else out

    def __call__(self, x, context=None, *, rng: jax.Array | None = None):
        return self.transform(x, context=context, rng=rng)

    def _distribution_sampler(
        self,
        event_spec=None,
        *,
        dtype=jnp.float32,
        context_spec=None,
    ) -> _ExportedSampler:
        """Build and cache a shape-polymorphic flow sampler.

        ``event_spec`` defaults to the base distribution's intrinsic event
        shape, but may be a pytree spec for structured base distributions.
        ``context_spec`` may be a plain shape tuple or a pytree of shapes /
        ``jax.ShapeDtypeStruct``.
        """
        if event_spec is None:
            event_spec = tuple(int(size) for size in self.base_dist.event_shape)
        make_sample_fn = partial(make_map_sample_fn, transform=_flow_transform)

        return self._build_exported_sampler(
            ("normalizing-flow-sampler",),
            event_spec,
            make_sample_fn,
            dtype=dtype,
            context_spec=context_spec,
        )

    def _sample_base(self, rng, sample_shape, spec):
        del spec
        if hasattr(self.base_dist, "dist") and hasattr(self.base_dist, "_call_kwds"):
            return self.base_dist.dist._rvs_impl(
                rng, shape=sample_shape, **self.base_dist._call_kwds
            )
        return self.base_dist.rvs(rng, shape=sample_shape)

    def _distribution_logpdf(
        self,
        event_spec=None,
        *,
        dtype=jnp.float32,
        context_spec=None,
    ):
        """Build and cache a shape-polymorphic flow log-density evaluator."""
        if event_spec is None:
            event_spec = tuple(int(size) for size in self.base_dist.event_shape)

        def make_logpdf_fn(graphdef):
            if context_spec is None:

                def logpdf_fn(current_state, value):
                    model = nnx.merge(graphdef, current_state)
                    return jax.vmap(model._logpdf)(value)

            else:

                def logpdf_fn(current_state, value, context):
                    model = nnx.merge(graphdef, current_state)
                    return jax.vmap(
                        lambda item, condition: model._logpdf(item, context=condition)
                    )(value, context)

            return logpdf_fn

        return self._build_exported_logpdf(
            ("normalizing-flow-logpdf",),
            event_spec,
            make_logpdf_fn,
            dtype=dtype,
            context_spec=context_spec,
        )

    def fit(self, rng, data, **kwargs):
        """Fit the standardising transform once, then train as usual."""
        if self.standardize:
            self.fit_standardization(data)
        # `loss` goes through `_logpdf`, which standardises internally -- do not
        # pre-transform the data here or it would be applied twice.
        return super().fit(rng, data, **kwargs)

    def _default_fit_kwargs(self) -> dict:
        """Flows train better on a warm-started, decaying rate than a flat one.

        Measured over the 2-D benchmark sweep: at equal step count this reaches
        3.501 nats on the checkerboard against 3.520 for constant-rate Adam at
        1e-3, and it was the only setting in the sweep that never diverged.
        """
        return {"schedule": "warmup_cosine", "learning_rate": 3e-3}

    def _logpdf(self, value, context=None):
        # Density of the *original* variable, so the standardising Jacobian has
        # to come along; without it this is the density of z, not of x.
        if not self.standardize:
            return self._flow_distribution_for_context(context).logpdf(value)
        z = self._standardize(value)
        inner = self._flow_distribution_for_context(context).logpdf(z)
        return inner - self._log_scale_correction()

    def loss(self, rng, data, *args, context=None, weights=None, **kwargs):
        """Negative mean log-likelihood training loss.

        With ``context``, each data row is scored against its own context row
        (per-pair conditional likelihood).
        """
        del rng, args, kwargs
        if context is None:
            logpdf = self._logpdf(data)
        else:
            pair_logpdf = jax.vmap(lambda x, c: self._logpdf(x, context=c))
            logpdf = pair_logpdf(data, context)
        if weights is None:
            return -jnp.mean(logpdf)
        weights = jnp.asarray(weights)
        return -jnp.sum(weights * logpdf) / jnp.sum(weights)

    def as_dist(
        self,
        event_spec=None,
        *,
        context_spec=None,
        context=None,
        **kwargs,
    ):
        """Create a lazy compiled sampling and log-density view of this flow."""
        if event_spec is None:
            event_spec = tuple(int(size) for size in self.base_dist.event_shape)
        return super().as_dist(
            event_spec,
            context_spec=context_spec,
            context=context,
            **kwargs,
        )

    # -- Helper for building the standard normal base distribution --

    @staticmethod
    def _standard_normal_base(input_dim: int):
        """Create an independent standard normal base distribution."""
        mu0 = jnp.zeros((input_dim,))
        std0 = jnp.ones((input_dim,))
        return indep(norm(mu0, std0))

transform

transform(x, context=None, *, rng=None)

Push a base sample through to the data space.

Data space, not standardised space: this has to agree with sample and logpdf. self.transformation is the inner map and stays in standardised coordinates, which is what _logpdf feeds it.

Source code in probjax/nn/generative/nflows/models.py
def transform(self, x, context=None, *, rng: jax.Array | None = None):
    """Push a base sample through to the data space.

    Data space, not standardised space: this has to agree with ``sample``
    and ``logpdf``. ``self.transformation`` is the inner map and stays in
    standardised coordinates, which is what ``_logpdf`` feeds it.
    """
    if context is None:
        out = self.transformation(x, rng=rng)
    else:
        out = self.transformation(x, context, rng=rng)
    return self._unstandardize(out) if self.standardize else out

fit

fit(rng, data, **kwargs)

Fit the standardising transform once, then train as usual.

Source code in probjax/nn/generative/nflows/models.py
def fit(self, rng, data, **kwargs):
    """Fit the standardising transform once, then train as usual."""
    if self.standardize:
        self.fit_standardization(data)
    # `loss` goes through `_logpdf`, which standardises internally -- do not
    # pre-transform the data here or it would be applied twice.
    return super().fit(rng, data, **kwargs)

loss

loss(rng, data, *args, context=None, weights=None, **kwargs)

Negative mean log-likelihood training loss.

With context, each data row is scored against its own context row (per-pair conditional likelihood).

Source code in probjax/nn/generative/nflows/models.py
def loss(self, rng, data, *args, context=None, weights=None, **kwargs):
    """Negative mean log-likelihood training loss.

    With ``context``, each data row is scored against its own context row
    (per-pair conditional likelihood).
    """
    del rng, args, kwargs
    if context is None:
        logpdf = self._logpdf(data)
    else:
        pair_logpdf = jax.vmap(lambda x, c: self._logpdf(x, context=c))
        logpdf = pair_logpdf(data, context)
    if weights is None:
        return -jnp.mean(logpdf)
    weights = jnp.asarray(weights)
    return -jnp.sum(weights * logpdf) / jnp.sum(weights)

as_dist

as_dist(event_spec=None, *, context_spec=None, context=None, **kwargs)

Create a lazy compiled sampling and log-density view of this flow.

Source code in probjax/nn/generative/nflows/models.py
def as_dist(
    self,
    event_spec=None,
    *,
    context_spec=None,
    context=None,
    **kwargs,
):
    """Create a lazy compiled sampling and log-density view of this flow."""
    if event_spec is None:
        event_spec = tuple(int(size) for size in self.base_dist.event_shape)
    return super().as_dist(
        event_spec,
        context_spec=context_spec,
        context=context,
        **kwargs,
    )

probjax.nn.NFlowConfig dataclass

Shape of a normalizing flow plus its three configuration axes.

Source code in probjax/nn/generative/nflows/config.py
@dataclass
class NFlowConfig:
    """Shape of a normalizing flow plus its three configuration axes."""

    input_dim: int
    num_transforms: int = 8
    context_features: Optional[int] = None
    bijector: BijectorConfigProtocol = field(default_factory=AffineBijectorConfig)
    conditioner: ConditionerConfigProtocol = field(default_factory=MLPConditionerConfig)
    mixing: MixingConfigProtocol = field(default_factory=FlipMixingConfig)

    def __post_init__(self) -> None:
        if self.input_dim < 1:
            raise ValueError(f"input_dim must be positive; got {self.input_dim}.")
        if self.num_transforms < 1:
            raise ValueError(
                f"num_transforms must be positive; got {self.num_transforms}."
            )
        if self.context_features is not None and self.context_features < 1:
            raise ValueError("context_features must be positive when given.")

Autoregressive models

probjax.nn.MADE

Bases: Autoregressive

Gaussian conditionals over a masked MLP (Germain et al., 2015).

Source code in probjax/nn/generative/autoregressive/model.py
class MADE(Autoregressive):
    """Gaussian conditionals over a masked MLP (Germain et al., 2015)."""

    def __init__(self, input_dim, rngs, **kwargs):
        super().__init__(input_dim, ARFamily.normal(), rngs, **kwargs)

probjax.nn.MixtureAutoregressive

Bases: Autoregressive

Mixture-density conditionals: the KDE-like flexible head.

Source code in probjax/nn/generative/autoregressive/model.py
class MixtureAutoregressive(Autoregressive):
    """Mixture-density conditionals: the KDE-like flexible head."""

    def __init__(self, input_dim, rngs, *, num_components: int = 10, **kwargs):
        super().__init__(
            input_dim, ARFamily.mixture(num_components), rngs, **kwargs
        )

probjax.nn.SplineAutoregressive

Bases: Autoregressive

Spline-warped normal conditionals.

Distinct from SplineAutoregressiveFlow: that composes spline bijections, this predicts a spline-warped distribution per dimension.

Source code in probjax/nn/generative/autoregressive/model.py
class SplineAutoregressive(Autoregressive):
    """Spline-warped normal conditionals.

    Distinct from ``SplineAutoregressiveFlow``: that composes spline bijections,
    this predicts a spline-warped *distribution* per dimension.
    """

    def __init__(self, input_dim, rngs, *, num_bins: int = 8, bound: float = 5.0, **kwargs):
        super().__init__(
            input_dim, ARFamily.spline(num_bins, bound), rngs, **kwargs
        )

probjax.nn.HistogramAutoregressive

Bases: Autoregressive

Piecewise-constant conditionals with exponential tails.

Source code in probjax/nn/generative/autoregressive/model.py
class HistogramAutoregressive(Autoregressive):
    """Piecewise-constant conditionals with exponential tails."""

    def __init__(
        self,
        input_dim,
        rngs,
        *,
        num_bins: int = 32,
        low: float = -5.0,
        high: float = 5.0,
        tails: bool = True,
        **kwargs,
    ):
        super().__init__(
            input_dim, ARFamily.histogram(num_bins, low, high, tails), rngs, **kwargs
        )

probjax.nn.CategoricalAutoregressive

Bases: Autoregressive

Categorical conditionals over integer-valued data.

Source code in probjax/nn/generative/autoregressive/model.py
class CategoricalAutoregressive(Autoregressive):
    """Categorical conditionals over integer-valued data."""

    def __init__(self, input_dim, rngs, *, num_categories: int, **kwargs):
        super().__init__(
            input_dim, ARFamily.categorical(num_categories), rngs, **kwargs
        )

probjax.nn.Autoregressive

Bases: StandardizingMixin, GenerativeModel

Autoregressive density over input_dim variables.

Parameters

input_dim : Number of variables in the joint. family : The univariate conditional, as an :class:ARFamily. A bare probjax.stats generator is accepted and wrapped. conditioner : Config for the masked network. Defaults to a MADE-style masked MLP. context_features : Width of an optional conditioning vector.

Source code in probjax/nn/generative/autoregressive/model.py
class Autoregressive(StandardizingMixin, GenerativeModel):
    """Autoregressive density over ``input_dim`` variables.

    Parameters
    ----------
    input_dim :
        Number of variables in the joint.
    family :
        The univariate conditional, as an :class:`ARFamily`. A bare
        ``probjax.stats`` generator is accepted and wrapped.
    conditioner :
        Config for the masked network. Defaults to a MADE-style masked MLP.
    context_features :
        Width of an optional conditioning vector.
    """

    def __init__(
        self,
        input_dim: int,
        family: Any = None,
        rngs: nnx.Rngs = None,
        *,
        conditioner: Optional[ARConditionerConfig] = None,
        context_features: Optional[int] = None,
        name: Optional[str] = None,
        standardize: bool = True,
    ) -> None:
        if input_dim < 1:
            raise ValueError(f"input_dim must be positive; got {input_dim}.")
        if rngs is None:
            raise ValueError("rngs is required.")

        if family is None:
            family = ARFamily.normal()
        elif not isinstance(family, ARFamily):
            # A bare stats generator: wrap it with default packing.
            family = ARFamily(family)
        conditioner = conditioner or MLPARConditionerConfig()
        if not isinstance(conditioner, ARConditionerConfig):
            raise TypeError("conditioner must implement ARConditionerConfig")

        self.input_dim = input_dim
        self.family = family
        self.conditioner_config = conditioner
        self.context_features = context_features
        self.name = name

        params_dim = family.params_dim()
        # A discrete head one-hots its input, widening the network's first layer
        # without changing the number of autoregressive variables.
        in_features = input_dim
        if family.discrete:
            in_features = input_dim * family._natural_sizes[family._predicted[0]]
        self.conditioner = conditioner.build(
            input_dim,
            params_dim,
            in_features=in_features,
            context_features=context_features,
            rngs=rngs,
        )
        # A discrete family has no meaningful mean/std, and shifting integer
        # labels would destroy them.
        self._init_standardization(input_dim, standardize and not family.discrete)
        super().__init__()

    # -- parameters and density ---------------------------------------------

    def predict_params(self, x, context=None, *, rng=None):
        """Per-dimension parameter blocks, shape ``(..., input_dim, params_dim)``."""
        encoded = self.family.encode(x)
        if self.family.discrete:
            encoded = encoded.reshape(encoded.shape[:-2] + (-1,))
        flat = self.conditioner(encoded, context, rng=rng)
        return flat.reshape(flat.shape[:-1] + (self.input_dim, self.family.params_dim()))

    def conditional_logpdfs(self, x, context=None):
        """``log p(x_i | x_<i)`` for every ``i``, shape ``(..., input_dim)``."""
        params = self.predict_params(x, context)
        natural = self.family.unpack(params)
        return self.family.logpdf(jnp.asarray(x), natural)

    def _logpdf(self, value, context=None):
        # Density of the original variable: standardise, then carry the
        # Jacobian of that map so this stays a density of x, not of z.
        z = self._standardize(value) if self.standardize else value
        inner = jnp.sum(self.conditional_logpdfs(z, context), axis=-1)
        return inner - self._log_scale_correction()

    def __call__(self, x, context=None, *, rng=None):
        return self._logpdf(x, context)

    def _check_support(self, data) -> None:
        """Reject data the family cannot represent, instead of returning -inf."""
        bounds = self.family.bounded_support()
        if bounds is None:
            return
        low, high = bounds
        # The family sees standardised values, so that is what must be in range.
        data = self._standardize(data) if self.standardize else data
        lo, hi = float(jnp.min(data)), float(jnp.max(data))
        if lo < low or hi > high:
            raise ValueError(
                f"{self.family.dist.name!r} has bounded support [{low}, {high}] "
                f"but the data spans [{lo:.4g}, {hi:.4g}], so those points have "
                "zero density. Widen the bounds, or use a family with tails "
                "(e.g. ARFamily.histogram(..., tails=True))."
            )

    def loss(self, rng, data, *args, context=None, **kwargs):
        """Negative mean joint log-likelihood."""
        del rng, args, kwargs
        if not isinstance(jnp.asarray(data), jax.core.Tracer):
            self._check_support(jnp.asarray(data))
        if context is None:
            return -jnp.mean(self._logpdf(data))
        pair = jax.vmap(lambda x, c: self._logpdf(x, context=c))
        return -jnp.mean(pair(data, context))

    # -- sampling ------------------------------------------------------------

    def sample(
        self,
        rng,
        sample_shape=(),
        *,
        context=None,
        prefix=None,
        prefix_len=0,
        use_cache=True,
    ):
        """Ancestral sampling: one conditioner pass per dimension.

        The naive loop scans over the *event* dimension, which is static,
        so it survives export with a symbolic batch size.

        ``prefix`` conditions the draw on known leading values: with
        ``prefix`` of shape ``(..., input_dim)`` and ``prefix_len=k`` the
        first ``k`` positions are clamped to ``prefix`` and only the rest
        are sampled -- image completion from a top half, for example.
        ``prefix_len`` may be any value in ``[0, input_dim]``.

        With a transformer conditioner, ``use_cache=True`` (the default)
        decodes with the attention KV cache instead of re-reading the whole
        prefix at every step; ``False`` selects the naive loop, which is
        also what non-transformer conditioners always use. Both paths draw
        from the same per-dimension keys, so they agree up to the
        floating-point dust between the two compiled scans.

        The cached loop is compiled to a single program and does
        asymptotically less attention work (one growing prefix row per step
        instead of a full matrix). Time both paths with
        ``block_until_ready`` -- JAX dispatches asynchronously, so bare
        ``time.time`` differences only measure enqueueing.
        """
        sample_shape = tuple(sample_shape)
        if not 0 <= prefix_len <= self.input_dim:
            raise ValueError(
                f"prefix_len must lie in [0, {self.input_dim}]; got {prefix_len}."
            )
        if prefix is None:
            if prefix_len:
                raise ValueError("prefix_len requires prefix.")
            prefix_z = None
        else:
            prefix_z = (
                self._standardize(prefix) if self.standardize else jnp.asarray(prefix)
            )
            prefix_z = jnp.broadcast_to(prefix_z, sample_shape + (self.input_dim,))
        keys = jax.random.split(rng, self.input_dim)
        if use_cache and hasattr(self.conditioner, "predict_next_params"):
            return self._sample_cached(
                keys, sample_shape, context, prefix_z, prefix_len
            )
        return self._sample_naive(keys, sample_shape, context, prefix_z, prefix_len)

    def _sample_naive(self, keys, sample_shape, context, prefix_z, prefix_len):
        dtype = self.family.event_dtype
        x = jnp.zeros(sample_shape + (self.input_dim,), dtype=dtype)

        def step(carry, inputs):
            i, key = inputs
            params = self.predict_params(carry, context)
            params_i = jnp.take(params, i, axis=-2)
            natural = self.family.unpack(params_i)
            draw = self.family.rvs(key, natural).astype(dtype)
            new = draw[..., None]
            if prefix_z is not None:
                given = prefix_z[..., i][..., None]
                new = jnp.where(i < prefix_len, given, new)
            # Only dimension i is written; the mask guarantees the parameters
            # used here depended on x_<i alone.
            onehot = jnp.arange(self.input_dim) == i
            return jnp.where(onehot, new, carry), None

        x, _ = jax.lax.scan(step, x, (jnp.arange(self.input_dim), keys))
        return self._unstandardize(x) if self.standardize else x

    def _sample_cached(self, keys, sample_shape, context, prefix_z, prefix_len):
        """KV-cached ancestral sampling for transformer conditioners.

        Compiled to a single program with :func:`flax.nnx.scan`: the
        attention caches are threaded through as scan carry (the model is
        passed explicitly so its ``Cache`` state is lifted) while the
        parameters stay put. Cache updates apply to this model in place,
        so every call starts with a fresh :meth:`init_decode`.
        """
        dtype = self.family.event_dtype
        family = self.family
        feature_width = self.conditioner.feature_width
        input_dim = self.input_dim
        self.conditioner.init_decode(sample_shape, dtype=jnp.float32)
        carry0 = (
            jnp.zeros(sample_shape + (input_dim,), dtype=dtype),
            # Encoder input space is floating point (one-hot for discrete).
            jnp.zeros(sample_shape + (feature_width,), jnp.float32),
        )

        @nnx.scan(
            in_axes=(
                nnx.Carry,
                0,
                # Parameters are shared, but each step must see the caches
                # written by the preceding step.
                nnx.StateAxes({nnx.Cache: nnx.Carry, ...: None}),
            ),
            out_axes=(nnx.Carry, 0),
            length=input_dim,
        )
        def step(carry, i, model):
            x, prev_feat = carry
            params_i = model.conditioner.predict_next_params(
                prev_feat, i, context
            )
            natural = family.unpack(params_i)
            draw = family.rvs(keys[i], natural).astype(dtype)
            value = draw
            if prefix_z is not None:
                value = jnp.where(i < prefix_len, prefix_z[..., i], value)
            x = x.at[..., i].set(value)
            encoded = family.encode(value)
            prev_feat = encoded[..., None] if feature_width == 1 else encoded
            return (x, prev_feat), draw

        (x, _), _ = step(carry0, jnp.arange(input_dim), self)
        return self._unstandardize(x) if self.standardize else x

    def _sample_base(self, rng, sample_shape, spec):
        """Unused noise; the per-dimension draws carry the randomness."""
        del spec
        return jnp.zeros(tuple(sample_shape) + (self.input_dim,))

    def _distribution_sampler(
        self,
        event_spec=None,
        *,
        dtype=None,
        context_spec=None,
    ):
        if event_spec is None:
            event_spec = (self.input_dim,)
        if dtype is None:
            dtype = self.family.event_dtype

        if not self.conditioner_config.exportable:
            return _EagerARSampler(self, (self.input_dim,))

        def make_sample_fn(graphdef, with_context):
            def sample_fn(state, rng, eps, *rest):
                model = nnx.merge(graphdef, state)
                context = rest[0] if with_context else None
                batch = eps.shape[0]
                return model.sample(rng, (batch,), context=context)

            return sample_fn

        return _KeyNormalizingSampler(
            self._build_exported_sampler(
                ("autoregressive-sampler",),
                event_spec,
                make_sample_fn,
                dtype=dtype,
                stochastic=True,
                context_spec=context_spec,
            )
        )

    def _distribution_logpdf(
        self,
        event_spec=None,
        *,
        dtype=jnp.float32,
        context_spec=None,
    ):
        if event_spec is None:
            event_spec = (self.input_dim,)

        def make_logpdf_fn(graphdef):
            if context_spec is None:

                def logpdf_fn(current_state, value):
                    model = nnx.merge(graphdef, current_state)
                    return jax.vmap(model._logpdf)(value)

            else:

                def logpdf_fn(current_state, value, context):
                    model = nnx.merge(graphdef, current_state)
                    return jax.vmap(
                        lambda item, cond: model._logpdf(item, context=cond)
                    )(value, context)

            return logpdf_fn

        return self._build_exported_logpdf(
            ("autoregressive-logpdf",),
            event_spec,
            make_logpdf_fn,
            dtype=dtype,
            context_spec=context_spec,
        )

    def as_dist(self, event_spec=None, **kwargs):
        if event_spec is None:
            event_spec = jax.ShapeDtypeStruct(
                (self.input_dim,), self.family.event_dtype
            )
        return super().as_dist(event_spec, **kwargs)

    def fit(self, rng, data, **kwargs):
        """Fit the standardising transform once, then train as usual."""
        self.fit_standardization(data)
        # `loss` goes through `_logpdf`, which standardises internally.
        return super().fit(rng, data, **kwargs)

    def _default_fit_kwargs(self) -> dict:
        return {"schedule": "warmup_cosine"}

predict_params

predict_params(x, context=None, *, rng=None)

Per-dimension parameter blocks, shape (..., input_dim, params_dim).

Source code in probjax/nn/generative/autoregressive/model.py
def predict_params(self, x, context=None, *, rng=None):
    """Per-dimension parameter blocks, shape ``(..., input_dim, params_dim)``."""
    encoded = self.family.encode(x)
    if self.family.discrete:
        encoded = encoded.reshape(encoded.shape[:-2] + (-1,))
    flat = self.conditioner(encoded, context, rng=rng)
    return flat.reshape(flat.shape[:-1] + (self.input_dim, self.family.params_dim()))

conditional_logpdfs

conditional_logpdfs(x, context=None)

log p(x_i | x_<i) for every i, shape (..., input_dim).

Source code in probjax/nn/generative/autoregressive/model.py
def conditional_logpdfs(self, x, context=None):
    """``log p(x_i | x_<i)`` for every ``i``, shape ``(..., input_dim)``."""
    params = self.predict_params(x, context)
    natural = self.family.unpack(params)
    return self.family.logpdf(jnp.asarray(x), natural)

loss

loss(rng, data, *args, context=None, **kwargs)

Negative mean joint log-likelihood.

Source code in probjax/nn/generative/autoregressive/model.py
def loss(self, rng, data, *args, context=None, **kwargs):
    """Negative mean joint log-likelihood."""
    del rng, args, kwargs
    if not isinstance(jnp.asarray(data), jax.core.Tracer):
        self._check_support(jnp.asarray(data))
    if context is None:
        return -jnp.mean(self._logpdf(data))
    pair = jax.vmap(lambda x, c: self._logpdf(x, context=c))
    return -jnp.mean(pair(data, context))

sample

sample(rng, sample_shape=(), *, context=None, prefix=None, prefix_len=0, use_cache=True)

Ancestral sampling: one conditioner pass per dimension.

The naive loop scans over the event dimension, which is static, so it survives export with a symbolic batch size.

prefix conditions the draw on known leading values: with prefix of shape (..., input_dim) and prefix_len=k the first k positions are clamped to prefix and only the rest are sampled -- image completion from a top half, for example. prefix_len may be any value in [0, input_dim].

With a transformer conditioner, use_cache=True (the default) decodes with the attention KV cache instead of re-reading the whole prefix at every step; False selects the naive loop, which is also what non-transformer conditioners always use. Both paths draw from the same per-dimension keys, so they agree up to the floating-point dust between the two compiled scans.

The cached loop is compiled to a single program and does asymptotically less attention work (one growing prefix row per step instead of a full matrix). Time both paths with block_until_ready -- JAX dispatches asynchronously, so bare time.time differences only measure enqueueing.

Source code in probjax/nn/generative/autoregressive/model.py
def sample(
    self,
    rng,
    sample_shape=(),
    *,
    context=None,
    prefix=None,
    prefix_len=0,
    use_cache=True,
):
    """Ancestral sampling: one conditioner pass per dimension.

    The naive loop scans over the *event* dimension, which is static,
    so it survives export with a symbolic batch size.

    ``prefix`` conditions the draw on known leading values: with
    ``prefix`` of shape ``(..., input_dim)`` and ``prefix_len=k`` the
    first ``k`` positions are clamped to ``prefix`` and only the rest
    are sampled -- image completion from a top half, for example.
    ``prefix_len`` may be any value in ``[0, input_dim]``.

    With a transformer conditioner, ``use_cache=True`` (the default)
    decodes with the attention KV cache instead of re-reading the whole
    prefix at every step; ``False`` selects the naive loop, which is
    also what non-transformer conditioners always use. Both paths draw
    from the same per-dimension keys, so they agree up to the
    floating-point dust between the two compiled scans.

    The cached loop is compiled to a single program and does
    asymptotically less attention work (one growing prefix row per step
    instead of a full matrix). Time both paths with
    ``block_until_ready`` -- JAX dispatches asynchronously, so bare
    ``time.time`` differences only measure enqueueing.
    """
    sample_shape = tuple(sample_shape)
    if not 0 <= prefix_len <= self.input_dim:
        raise ValueError(
            f"prefix_len must lie in [0, {self.input_dim}]; got {prefix_len}."
        )
    if prefix is None:
        if prefix_len:
            raise ValueError("prefix_len requires prefix.")
        prefix_z = None
    else:
        prefix_z = (
            self._standardize(prefix) if self.standardize else jnp.asarray(prefix)
        )
        prefix_z = jnp.broadcast_to(prefix_z, sample_shape + (self.input_dim,))
    keys = jax.random.split(rng, self.input_dim)
    if use_cache and hasattr(self.conditioner, "predict_next_params"):
        return self._sample_cached(
            keys, sample_shape, context, prefix_z, prefix_len
        )
    return self._sample_naive(keys, sample_shape, context, prefix_z, prefix_len)

fit

fit(rng, data, **kwargs)

Fit the standardising transform once, then train as usual.

Source code in probjax/nn/generative/autoregressive/model.py
def fit(self, rng, data, **kwargs):
    """Fit the standardising transform once, then train as usual."""
    self.fit_standardization(data)
    # `loss` goes through `_logpdf`, which standardises internally.
    return super().fit(rng, data, **kwargs)

probjax.nn.ARFamily dataclass

A univariate probjax.stats family used as a conditional head.

Parameters

dist : The distribution generator, e.g. norm, laplace, histogram. hyper : Hyperparameters fixing the parameter vector lengths, forwarded to dist.param_sizes (e.g. num_components, num_bins). fixed : Parameters that are not predicted, supplied as constants instead. Integer-constrained parameters must go here. constrain : Per-parameter overrides of the default constrainer. discrete : Whether the data are integer-valued; drives the event dtype and the one-hot encoding of the conditioner's input.

Source code in probjax/nn/generative/autoregressive/config.py
@dataclass
class ARFamily:
    """A univariate ``probjax.stats`` family used as a conditional head.

    Parameters
    ----------
    dist :
        The distribution generator, e.g. ``norm``, ``laplace``, ``histogram``.
    hyper :
        Hyperparameters fixing the parameter vector lengths, forwarded to
        ``dist.param_sizes`` (e.g. ``num_components``, ``num_bins``).
    fixed :
        Parameters that are *not* predicted, supplied as constants instead.
        Integer-constrained parameters must go here.
    constrain :
        Per-parameter overrides of the default constrainer.
    discrete :
        Whether the data are integer-valued; drives the event dtype and the
        one-hot encoding of the conditioner's input.
    """

    dist: rv_generic
    hyper: Mapping[str, Any] = field(default_factory=dict)
    fixed: Mapping[str, Any] = field(default_factory=dict)
    constrain: Mapping[str, _Constrainer] = field(default_factory=dict)
    sizes: Mapping[str, int] = field(default_factory=dict)
    discrete: bool = False

    def __post_init__(self) -> None:
        unknown = set(self.fixed) - set(self.dist.parameters)
        if unknown:
            raise ValueError(
                f"fixed names {sorted(unknown)} are not parameters of "
                f"{self.dist.name!r}; expected some of "
                f"{list(self.dist.parameters)}."
            )
        if not self._predicted:
            raise ValueError(
                f"Every parameter of {self.dist.name!r} is fixed, leaving "
                "nothing for the conditioner to predict."
            )

    # -- parameter layout ---------------------------------------------------

    @property
    def _predicted(self) -> list[str]:
        """Parameter names the network must emit, in declaration order."""
        return [n for n in self.dist.parameters if n not in self.fixed]

    @property
    def _natural_sizes(self) -> dict[str, int]:
        """Natural (constrained) size of every parameter."""
        sizes_fn = getattr(self.dist, "param_sizes", None)
        derived = (
            {n: 1 for n in self.dist.parameters}
            if sizes_fn is None
            else dict(sizes_fn(**self.hyper))
        )
        # `sizes` covers families that predate the `param_sizes` convention but
        # still have vector parameters (categorical's `probs`, for instance).
        derived.update(self.sizes)
        return derived

    @property
    def _constrainers(self) -> dict[str, _Constrainer]:
        out = {}
        for name in self._predicted:
            if name in self.constrain:
                out[name] = self.constrain[name]
            else:
                out[name] = _default_constrainer(name, self.dist.parameters[name])
        return out

    def raw_sizes(self) -> dict[str, int]:
        """Unconstrained block length the conditioner must emit per parameter."""
        natural = self._natural_sizes
        return {
            name: c.raw_size(natural.get(name, 1))
            for name, c in self._constrainers.items()
        }

    def params_dim(self) -> int:
        """Total parameters the conditioner emits per data dimension."""
        return sum(self.raw_sizes().values())

    def unpack(self, params: Array) -> dict[str, Array]:
        """Split a ``(..., params_dim)`` vector into constrained parameters."""
        params = jnp.asarray(params)
        if params.shape[-1] != self.params_dim():
            raise ValueError(
                f"{self.dist.name!r} head expects params with trailing "
                f"dimension {self.params_dim()}, got {params.shape[-1]}."
            )
        sizes = self.raw_sizes()
        constrainers = self._constrainers

        natural: dict[str, Any] = {}
        start = 0
        for name in self._predicted:
            width = sizes[name]
            block = params[..., start : start + width]
            if width == 1 and self._natural_sizes.get(name, 1) == 1:
                block = block[..., 0]  # scalar parameter
            natural[name] = constrainers[name].fn(block)
            start += width
        natural.update(self.fixed)
        return natural

    def params_init(self) -> Initializer:
        """Zeros: every constrainer is neutral there."""
        return nnx.initializers.zeros

    # -- density and sampling, straight from the distribution ---------------

    def logpdf(self, x: Array, natural: Mapping[str, Array]) -> Array:
        return self.dist.logpdf(x, **natural)

    def rvs(self, rng, natural: Mapping[str, Array]) -> Array:
        """One draw per batch element, using the family's own sampler."""
        return self.dist._rvs_impl(rng, **natural, shape=())

    # -- data plumbing ------------------------------------------------------

    @property
    def event_dtype(self):
        return jnp.int32 if self.discrete else jnp.float32

    def encode(self, x: Array) -> Array:
        """Conditioner input encoding.

        Integer labels carry no usable metric, so a discrete family one-hots
        them; continuous data passes through.
        """
        if not self.discrete:
            return jnp.asarray(x)
        num_classes = self._natural_sizes[self._predicted[0]]
        return jax.nn.one_hot(jnp.asarray(x).astype(jnp.int32), num_classes)

    def bounded_support(self) -> Optional[tuple[float, float]]:
        """``(low, high)`` when the family's support is a bounded interval.

        Used to reject out-of-range training data loudly instead of silently
        returning ``-inf``.
        """
        try:
            support = self.dist.support(**self.fixed)
        except Exception:  # pragma: no cover - families with required params
            return None
        lower = getattr(support, "lower", None)
        upper = getattr(support, "upper", None)
        if lower is None or upper is None:
            return None
        if not (jnp.isfinite(jnp.asarray(lower)) and jnp.isfinite(jnp.asarray(upper))):
            return None
        return float(lower), float(upper)

    # -- convenience constructors -------------------------------------------

    @classmethod
    def normal(cls) -> "ARFamily":
        from probjax.stats import norm

        return cls(norm)

    @classmethod
    def mixture(
        cls,
        num_components: int = 10,
        kernel: str = "norm",
        spread: float = 2.0,
    ) -> "ARFamily":
        """Mixture head, with the component locations pulled apart at init.

        The offset is not cosmetic. A zero-initialised conditioner emits the
        same vector for every component, so all of them share a location, a
        scale *and* a gradient -- the mixture is exactly one kernel and stays
        that way, since nothing breaks the symmetry. Spreading the locations
        deterministically makes each component see a different gradient from
        the first step, at no cost to the neutral density being sensible.
        """
        from probjax.stats import logistic_mixture_kernel, mixture_kernel

        dist = mixture_kernel if kernel == "norm" else logistic_mixture_kernel
        offsets = jnp.linspace(-spread, spread, num_components)
        return cls(
            dist,
            hyper={"num_components": num_components},
            constrain={"locs": _Constrainer(lambda raw: raw + offsets)},
        )

    @classmethod
    def histogram(
        cls, num_bins: int = 32, low: float = -5.0, high: float = 5.0, tails: bool = True
    ) -> "ARFamily":
        """Piecewise-constant head; ``tails`` keeps the support unbounded."""
        from probjax.stats import histogram as _hist
        from probjax.stats import tailed_histogram as _thist

        dist = _thist if tails else _hist
        constrain = {}
        if tails:
            # A raw 0 would otherwise put sigmoid(0) = half the mass in the
            # tails; offset the logit so the neutral state is a near-flat
            # histogram with only ~5% tail mass.
            constrain["tail_logit"] = _Constrainer(lambda raw: raw - 3.0)
        return cls(
            dist,
            hyper={"num_bins": num_bins},
            fixed={"low": low, "high": high},
            constrain=constrain,
        )

    @classmethod
    def spline(
        cls, num_bins: int = 8, bound: float = 5.0, latent_bound: float = 5.0
    ) -> "ARFamily":
        """Spline-warped normal head; knots span the given ranges exactly.

        ``latent_bound`` defaults to ``bound`` so that zero parameters give
        matching knots with unit slopes -- an identity spline, i.e. exactly a
        standard normal.
        """
        from probjax.stats import spline_normal

        return cls(
            spline_normal,
            hyper={"num_bins": num_bins},
            constrain={
                "x_pos": _spline_knot_constrainer(-latent_bound, latent_bound),
                "y_pos": _spline_knot_constrainer(-bound, bound),
                "knot_slopes": _spline_slope_constrainer(),
            },
        )

    @classmethod
    def categorical(cls, num_categories: int) -> "ARFamily":
        from probjax.stats import categorical

        return cls(
            categorical,
            sizes={"probs": num_categories},
            discrete=True,
        )

raw_sizes

raw_sizes()

Unconstrained block length the conditioner must emit per parameter.

Source code in probjax/nn/generative/autoregressive/config.py
def raw_sizes(self) -> dict[str, int]:
    """Unconstrained block length the conditioner must emit per parameter."""
    natural = self._natural_sizes
    return {
        name: c.raw_size(natural.get(name, 1))
        for name, c in self._constrainers.items()
    }

params_dim

params_dim()

Total parameters the conditioner emits per data dimension.

Source code in probjax/nn/generative/autoregressive/config.py
def params_dim(self) -> int:
    """Total parameters the conditioner emits per data dimension."""
    return sum(self.raw_sizes().values())

unpack

unpack(params)

Split a (..., params_dim) vector into constrained parameters.

Source code in probjax/nn/generative/autoregressive/config.py
def unpack(self, params: Array) -> dict[str, Array]:
    """Split a ``(..., params_dim)`` vector into constrained parameters."""
    params = jnp.asarray(params)
    if params.shape[-1] != self.params_dim():
        raise ValueError(
            f"{self.dist.name!r} head expects params with trailing "
            f"dimension {self.params_dim()}, got {params.shape[-1]}."
        )
    sizes = self.raw_sizes()
    constrainers = self._constrainers

    natural: dict[str, Any] = {}
    start = 0
    for name in self._predicted:
        width = sizes[name]
        block = params[..., start : start + width]
        if width == 1 and self._natural_sizes.get(name, 1) == 1:
            block = block[..., 0]  # scalar parameter
        natural[name] = constrainers[name].fn(block)
        start += width
    natural.update(self.fixed)
    return natural

params_init

params_init()

Zeros: every constrainer is neutral there.

Source code in probjax/nn/generative/autoregressive/config.py
def params_init(self) -> Initializer:
    """Zeros: every constrainer is neutral there."""
    return nnx.initializers.zeros

rvs

rvs(rng, natural)

One draw per batch element, using the family's own sampler.

Source code in probjax/nn/generative/autoregressive/config.py
def rvs(self, rng, natural: Mapping[str, Array]) -> Array:
    """One draw per batch element, using the family's own sampler."""
    return self.dist._rvs_impl(rng, **natural, shape=())

encode

encode(x)

Conditioner input encoding.

Integer labels carry no usable metric, so a discrete family one-hots them; continuous data passes through.

Source code in probjax/nn/generative/autoregressive/config.py
def encode(self, x: Array) -> Array:
    """Conditioner input encoding.

    Integer labels carry no usable metric, so a discrete family one-hots
    them; continuous data passes through.
    """
    if not self.discrete:
        return jnp.asarray(x)
    num_classes = self._natural_sizes[self._predicted[0]]
    return jax.nn.one_hot(jnp.asarray(x).astype(jnp.int32), num_classes)

bounded_support

bounded_support()

(low, high) when the family's support is a bounded interval.

Used to reject out-of-range training data loudly instead of silently returning -inf.

Source code in probjax/nn/generative/autoregressive/config.py
def bounded_support(self) -> Optional[tuple[float, float]]:
    """``(low, high)`` when the family's support is a bounded interval.

    Used to reject out-of-range training data loudly instead of silently
    returning ``-inf``.
    """
    try:
        support = self.dist.support(**self.fixed)
    except Exception:  # pragma: no cover - families with required params
        return None
    lower = getattr(support, "lower", None)
    upper = getattr(support, "upper", None)
    if lower is None or upper is None:
        return None
    if not (jnp.isfinite(jnp.asarray(lower)) and jnp.isfinite(jnp.asarray(upper))):
        return None
    return float(lower), float(upper)

mixture classmethod

mixture(num_components=10, kernel='norm', spread=2.0)

Mixture head, with the component locations pulled apart at init.

The offset is not cosmetic. A zero-initialised conditioner emits the same vector for every component, so all of them share a location, a scale and a gradient -- the mixture is exactly one kernel and stays that way, since nothing breaks the symmetry. Spreading the locations deterministically makes each component see a different gradient from the first step, at no cost to the neutral density being sensible.

Source code in probjax/nn/generative/autoregressive/config.py
@classmethod
def mixture(
    cls,
    num_components: int = 10,
    kernel: str = "norm",
    spread: float = 2.0,
) -> "ARFamily":
    """Mixture head, with the component locations pulled apart at init.

    The offset is not cosmetic. A zero-initialised conditioner emits the
    same vector for every component, so all of them share a location, a
    scale *and* a gradient -- the mixture is exactly one kernel and stays
    that way, since nothing breaks the symmetry. Spreading the locations
    deterministically makes each component see a different gradient from
    the first step, at no cost to the neutral density being sensible.
    """
    from probjax.stats import logistic_mixture_kernel, mixture_kernel

    dist = mixture_kernel if kernel == "norm" else logistic_mixture_kernel
    offsets = jnp.linspace(-spread, spread, num_components)
    return cls(
        dist,
        hyper={"num_components": num_components},
        constrain={"locs": _Constrainer(lambda raw: raw + offsets)},
    )

histogram classmethod

histogram(num_bins=32, low=-5.0, high=5.0, tails=True)

Piecewise-constant head; tails keeps the support unbounded.

Source code in probjax/nn/generative/autoregressive/config.py
@classmethod
def histogram(
    cls, num_bins: int = 32, low: float = -5.0, high: float = 5.0, tails: bool = True
) -> "ARFamily":
    """Piecewise-constant head; ``tails`` keeps the support unbounded."""
    from probjax.stats import histogram as _hist
    from probjax.stats import tailed_histogram as _thist

    dist = _thist if tails else _hist
    constrain = {}
    if tails:
        # A raw 0 would otherwise put sigmoid(0) = half the mass in the
        # tails; offset the logit so the neutral state is a near-flat
        # histogram with only ~5% tail mass.
        constrain["tail_logit"] = _Constrainer(lambda raw: raw - 3.0)
    return cls(
        dist,
        hyper={"num_bins": num_bins},
        fixed={"low": low, "high": high},
        constrain=constrain,
    )

spline classmethod

spline(num_bins=8, bound=5.0, latent_bound=5.0)

Spline-warped normal head; knots span the given ranges exactly.

latent_bound defaults to bound so that zero parameters give matching knots with unit slopes -- an identity spline, i.e. exactly a standard normal.

Source code in probjax/nn/generative/autoregressive/config.py
@classmethod
def spline(
    cls, num_bins: int = 8, bound: float = 5.0, latent_bound: float = 5.0
) -> "ARFamily":
    """Spline-warped normal head; knots span the given ranges exactly.

    ``latent_bound`` defaults to ``bound`` so that zero parameters give
    matching knots with unit slopes -- an identity spline, i.e. exactly a
    standard normal.
    """
    from probjax.stats import spline_normal

    return cls(
        spline_normal,
        hyper={"num_bins": num_bins},
        constrain={
            "x_pos": _spline_knot_constrainer(-latent_bound, latent_bound),
            "y_pos": _spline_knot_constrainer(-bound, bound),
            "knot_slopes": _spline_slope_constrainer(),
        },
    )

Diffusion and flow matching

probjax.nn.EDM

Bases: DiffusionDenoiser

EDM-style model
  • EDMNoiseSchedule
  • EDMPreconditioning
  • EDMTrainingConfig (t == sigma)
  • EDMSolverConfig by default
Source code in probjax/nn/generative/diffusion/model.py
class EDM(DiffusionDenoiser):
    """
    EDM-style model:
      - EDMNoiseSchedule
      - EDMPreconditioning
      - EDMTrainingConfig (t == sigma)
      - EDMSolverConfig by default
    """

    def __init__(
        self,
        net: ModuleLike,
        *,
        std0: float = 1.0,
        lognoise_mean: float = -1.2,
        lognoise_scale: float = 1.2,
        t_min: float = 2e-4,
        t_max: float = 80.0,
        rho: float = 7.0,
        num_steps: int = 64,
        loss_type: str = "x0",
        loss_kwargs: Mapping[str, object] | None = None,
        last_layer: Callable[[Array], Array] | None = None,
        rngs: nnx.RngStream | None = None,
        solver: SolverConfigProtocol | None = None,
    ) -> None:
        schedule = EDMNoiseSchedule(t_min=t_min, t_max=t_max)
        precond = EDMPreconditioning()
        train_cfg = EDMTrainingConfig(
            loss_type=loss_type,
            loss_kwargs=dict(loss_kwargs or {}),
            lognoise_mean=lognoise_mean,
            lognoise_scale=lognoise_scale,
            t_min=t_min,
            t_max=t_max,
        )
        solver_cfg = solver or EDMSolverConfig(
            schedule=schedule,
            num_steps=num_steps,
            rho=rho,
        )
        super().__init__(
            net=net,
            schedule=schedule,
            precond=precond,
            train_cfg=train_cfg,
            solver_cfg=solver_cfg,
            std0=std0,
            last_layer=last_layer,
            rngs=rngs,
        )

probjax.nn.VP

Bases: DiffusionDenoiser

VP variant
  • VPNoiseSchedule(beta_min, beta_max)
  • EDMPreconditioning (σ_eff-based)
  • SigmaEffEDMTrainingConfig (log-normal in σ_eff)
  • BaseSolverConfig by default
Source code in probjax/nn/generative/diffusion/model.py
class VP(DiffusionDenoiser):
    """
    VP variant:
      - VPNoiseSchedule(beta_min, beta_max)
      - EDMPreconditioning (σ_eff-based)
      - SigmaEffEDMTrainingConfig (log-normal in σ_eff)
      - BaseSolverConfig by default
    """

    def __init__(
        self,
        net: ModuleLike,
        *,
        beta_min: float = 0.1,
        beta_max: float = 10.0,
        std0: float = 1.0,
        t_min: float = 0.0,
        t_max: float = 1.0,
        min_tau: float = 1e-5,
        num_steps: int = 100,
        logsigma_mean: float = -1.2,
        logsigma_std: float = 1.2,
        loss_type: str = "x0",
        loss_kwargs: Mapping[str, object] | None = None,
        last_layer: Callable[[Array], Array] | None = None,
        rngs: nnx.RngStream | None = None,
        solver: SolverConfigProtocol | None = None,
    ) -> None:
        schedule = VPNoiseSchedule(
            t_min=t_min,
            t_max=t_max,
            beta_min=beta_min,
            beta_max=beta_max,
            min_tau=min_tau,
        )
        precond = EDMPreconditioning()
        train_cfg = SigmaEffEDMTrainingConfig(
            schedule=schedule,
            loss_type=loss_type,
            loss_kwargs=dict(loss_kwargs or {}),
            logsigma_mean=logsigma_mean,
            logsigma_std=logsigma_std,
            t_min=t_min,
            t_max=t_max,
        )
        solver_cfg = solver or BaseSolverConfig(
            schedule=schedule,
            num_steps=num_steps,
        )
        super().__init__(
            net=net,
            schedule=schedule,
            precond=precond,
            train_cfg=train_cfg,
            solver_cfg=solver_cfg,
            std0=std0,
            last_layer=last_layer,
            rngs=rngs,
        )

probjax.nn.VE

Bases: DiffusionDenoiser

VE variant
  • VENoiseSchedule(sigma_min, sigma_max)
  • EDMPreconditioning
  • UniformTTrainingConfig
  • BaseSolverConfig by default
Source code in probjax/nn/generative/diffusion/model.py
class VE(DiffusionDenoiser):
    """
    VE variant:
      - VENoiseSchedule(sigma_min, sigma_max)
      - EDMPreconditioning
      - UniformTTrainingConfig
      - BaseSolverConfig by default
    """

    def __init__(
        self,
        net: ModuleLike,
        *,
        std0: float = 1.0,
        sigma_min: float = 1e-4,
        sigma_max: float = 80.0,
        t_min: float = 1e-3,
        t_max: float = 1.0,
        num_steps: int = 100,
        loss_type: str = "x0",
        loss_kwargs: Mapping[str, object] | None = None,
        last_layer: Callable[[Array], Array] | None = None,
        rngs: nnx.RngStream | None = None,
        solver: SolverConfigProtocol | None = None,
    ) -> None:
        schedule = VENoiseSchedule(
            t_min=t_min,
            t_max=t_max,
            sigma_min=sigma_min,
            sigma_max=sigma_max,
        )
        precond = EDMPreconditioning()
        train_cfg = UniformTTrainingConfig(
            loss_type=loss_type,
            loss_kwargs=dict(loss_kwargs or {}),
            t_min=t_min,
            t_max=t_max,
        )
        solver_cfg = solver or VSolverConfig(
            schedule=schedule,
            num_steps=num_steps,
        )
        super().__init__(
            net=net,
            schedule=schedule,
            precond=precond,
            train_cfg=train_cfg,
            solver_cfg=solver_cfg,
            std0=std0,
            last_layer=last_layer,
            rngs=rngs,
        )

probjax.nn.MultinomialDiffusion

Bases: GenerativeModel

Source code in probjax/nn/generative/discrete/model.py
class MultinomialDiffusion(GenerativeModel):
    """
    Discrete diffusion model with denoising_diffusion_model-like composition:
      - schedule
      - preconditioning
      - training config
    """

    def __init__(
        self,
        net: ModuleLike,
        schedule: CategoricalScheduleProtocol,
        *,
        preconditioning: Optional[CategoricalPreconditioningProtocol] = None,
        train_cfg: Optional[CategoricalTrainingConfigProtocol] = None,
        use_loss_weighting: bool = False,
        rao_blackwellize_xt: bool = False,
        rao_blackwellize_xt_num_samples: int = 4,
        rao_blackwellize_xt_num_features: Optional[int] = None,
        rngs: nnx.RngStream | None = None,
        eps: float = 1e-12,
    ):
        if not isinstance(schedule, CategoricalScheduleProtocol):
            raise TypeError("schedule must implement CategoricalScheduleProtocol")

        self.net = net
        self._net_accepts_rng = module_accepts_rng(self.net)
        self.schedule = schedule
        self.precond = preconditioning or CategoricalEDMPreconditioning()
        # Backward-compatible alias.
        self.preconditioning = self.precond
        self.train_cfg = train_cfg or UniformContinuousTimeTrainingConfig(
            num_steps=schedule.num_steps,
            t_min=schedule.t_min,
            t_max=schedule.t_max,
        )
        self.use_loss_weighting = bool(use_loss_weighting)
        self.rao_blackwellize_xt = bool(rao_blackwellize_xt)
        self.rao_blackwellize_xt_num_samples = int(rao_blackwellize_xt_num_samples)
        if self.rao_blackwellize_xt_num_samples < 1:
            raise ValueError("rao_blackwellize_xt_num_samples must be >= 1.")
        self.rao_blackwellize_xt_num_features = (
            None
            if rao_blackwellize_xt_num_features is None
            else int(rao_blackwellize_xt_num_features)
        )
        if (
            self.rao_blackwellize_xt_num_features is not None
            and self.rao_blackwellize_xt_num_features < 1
        ):
            raise ValueError("rao_blackwellize_xt_num_features must be >= 1.")
        self.eps = eps
        self.rngs = rngs

    @property
    def num_classes(self) -> int:
        return self.schedule.num_classes

    @property
    def num_steps(self) -> int:
        return self.schedule.num_steps

    def _to_continuous_time(self, t: ArrayLike) -> Array:
        t_array = _require_float_time(t)
        t_cont = t_array.astype(jnp.float32)
        return jnp.clip(t_cont, self.schedule.t_min, self.schedule.t_max)

    def c_in(self, t: ArrayLike) -> Array:
        t_cont = self._to_continuous_time(t)
        return self.precond.c_in(t_cont, self.schedule)

    def c_out(self, t: ArrayLike) -> Array:
        t_cont = self._to_continuous_time(t)
        return self.precond.c_out(t_cont, self.schedule)

    def c_skip(self, t: ArrayLike) -> Array:
        t_cont = self._to_continuous_time(t)
        return self.precond.c_skip(t_cont, self.schedule)

    def c_t(self, t: ArrayLike) -> Array:
        t_cont = self._to_continuous_time(t)
        return self.precond.c_t(t_cont, self.schedule)

    def weight_fn(self, t: ArrayLike) -> Array:
        t_cont = self._to_continuous_time(t)
        return self.precond.weight_ce(t_cont, self.schedule)

    def _as_onehot(self, x_t: Array) -> Array:
        return _one_hot(jnp.asarray(x_t, dtype=jnp.int32), self.num_classes)

    def _base_probs_for(self, x_onehot: Array) -> Array:
        return self.schedule.base_probs.reshape((1,) * (x_onehot.ndim - 1) + (-1,))

    def _net_input(self, x_t: Array) -> Array:
        x_onehot = self._as_onehot(x_t)
        pi = self._base_probs_for(x_onehot)
        return x_onehot - pi

    def _net_forward(
        self,
        t: ArrayLike,
        x_features: Array,
        *args,
        rng: jax.Array | None = None,
        **kwargs,
    ) -> Array:
        t_cont = self._to_continuous_time(t)
        t_embed = self.c_t(t_cont)
        x_embed = self.c_in(t_cont)[..., None] * x_features
        if self._net_accepts_rng:
            return self.net(t_embed, x_embed, *args, rng=rng, **kwargs)
        return self.net(t_embed, x_embed, *args, **kwargs)

    def __call__(
        self,
        t: ArrayLike,
        x_t: Array,
        *args,
        rng: jax.Array | None = None,
        **kwargs,
    ) -> Array:
        x_features = self._net_input(x_t)
        return self._net_forward(t, x_features, *args, rng=rng, **kwargs)

    def _predict_x0_logits_from_probs(
        self,
        t: ArrayLike,
        x_t_probs: Array,
        *args,
        **kwargs,
    ) -> Array:
        x_t_probs = jnp.asarray(x_t_probs, dtype=jnp.float32)
        pi = self._base_probs_for(x_t_probs)
        x_features = x_t_probs - pi

        net_out = self._net_forward(t, x_features, *args, **kwargs)

        c_skip = self.c_skip(t)[..., None]
        c_out = self.c_out(t)[..., None]
        skip_probs = c_skip * x_t_probs + (1.0 - c_skip) * pi
        skip_logits = jnp.log(jnp.maximum(skip_probs, self.eps))

        return skip_logits + c_out * net_out

    def predict_x0_logits(
        self,
        t: ArrayLike,
        x_t: Array,
        *args,
        **kwargs,
    ) -> Array:
        x_t_probs = self._as_onehot(x_t)
        return self._predict_x0_logits_from_probs(t, x_t_probs, *args, **kwargs)

    def denoise_logits(
        self,
        t: ArrayLike,
        x_t: Array,
        *args,
        **kwargs,
    ) -> Array:
        return self.predict_x0_logits(t, x_t, *args, **kwargs)

    def predict_x0_probs(
        self,
        t: ArrayLike,
        x_t: Array,
        *args,
        **kwargs,
    ) -> Array:
        logits = self.predict_x0_logits(t, x_t, *args, **kwargs)
        return jax.nn.softmax(logits, axis=-1)

    def denoise_probs(
        self,
        t: ArrayLike,
        x_t: Array,
        *args,
        **kwargs,
    ) -> Array:
        return self.predict_x0_probs(t, x_t, *args, **kwargs)

    def score_reparameterized(
        self,
        rng: RngKey,
        t: ArrayLike,
        x_t: Array,
        *args,
        temperature: float = 1.0,
        num_samples: int = 1,
        **kwargs,
    ) -> Array:
        """
        Relaxed discrete score: gradient wrt relaxed x_t simplex variable.

        Uses Gumbel-Softmax reparameterization and computes
          grad_{x_t_relaxed} E[ <x_t_relaxed, log p_theta(x0|x_t_relaxed,t)> ].
        """
        x_onehot = self._as_onehot(x_t)
        log_x = jnp.log(jnp.maximum(x_onehot, self.eps))

        def one_score(sample_key):
            u = jax.random.uniform(
                sample_key,
                x_onehot.shape,
                minval=self.eps,
                maxval=1.0 - self.eps,
            )
            g = -jnp.log(-jnp.log(u))
            x_relaxed = jax.nn.softmax(
                (log_x + g) / jnp.maximum(temperature, self.eps),
                axis=-1,
            )

            def objective(x_in):
                logits = self._predict_x0_logits_from_probs(t, x_in, *args, **kwargs)
                log_probs = jax.nn.log_softmax(logits, axis=-1)
                return jnp.sum(x_in * log_probs)

            return jax.grad(objective)(x_relaxed)

        keys = jax.random.split(rng, num_samples)
        grads = jax.vmap(one_score)(keys)
        return jnp.mean(grads, axis=0)

    def score(
        self,
        rng: RngKey,
        t: ArrayLike,
        x_t: Array,
        *args,
        temperature: float = 1.0,
        num_samples: int = 1,
        **kwargs,
    ) -> Array:
        return self.score_reparameterized(
            rng,
            t,
            x_t,
            *args,
            temperature=temperature,
            num_samples=num_samples,
            **kwargs,
        )

    def q_sample(self, rng: RngKey, x0: Array, t: ArrayLike) -> Array:
        return self.schedule.q_sample(rng, x0, self._to_continuous_time(t))

    def p_probs(
        self,
        t: ArrayLike,
        x_t: Array,
        *args,
        t_prev: Optional[ArrayLike] = None,
        **kwargs,
    ) -> Array:
        t_cont = self._to_continuous_time(t)
        if t_prev is None:
            raise ValueError(
                "p_probs requires t_prev for continuous-time reverse transitions."
            )
        t_prev_cont = self._to_continuous_time(t_prev)
        x0_probs = self.predict_x0_probs(t_cont, x_t, *args, **kwargs)
        return self.schedule.posterior_mixture_probs(
            x_t,
            x0_probs,
            t=t_cont,
            t_prev=t_prev_cont,
        )

    def p_sample(
        self,
        rng: RngKey,
        t: ArrayLike,
        x_t: Array,
        *args,
        t_prev: Optional[ArrayLike] = None,
        **kwargs,
    ) -> Array:
        probs = self.p_probs(t, x_t, *args, t_prev=t_prev, **kwargs)
        return _sample_categorical(rng, probs)

    def _build_loss_fn(self):
        return build_time_dependent_multinomial_diffusion_loss(
            self.denoise_logits,
            q_sample_fn=self.q_sample,
            sample_timesteps_fn=self.train_cfg.sample_times,
            q_xt_given_x0_probs_fn=self.schedule.q_xt_given_x0_probs,
            alpha_bar_fn=self.schedule.alpha_bar,
            base_probs=self.schedule.base_probs,
            rao_blackwellize_xt=self.rao_blackwellize_xt,
            rao_blackwellize_xt_num_samples=self.rao_blackwellize_xt_num_samples,
            rao_blackwellize_xt_num_features=self.rao_blackwellize_xt_num_features,
            num_classes=self.num_classes,
            weight_fn=self.weight_fn if self.use_loss_weighting else None,
        )

    def loss(
        self,
        rng: RngKey,
        x0: Array,
        *args,
        t: Optional[Array] = None,
        loss_mask: Optional[ArrayLike] = None,
        **kwargs,
    ) -> Array:
        loss_fn = self._build_loss_fn()
        model_kwargs = dict(self.train_cfg.loss_kwargs)
        model_kwargs.update(kwargs)
        return loss_fn(
            x0,
            *args,
            rng=rng,
            t=t,
            loss_mask=loss_mask,
            **model_kwargs,
        )

    def _sample_reverse_process(
        self,
        rng: RngKey,
        shape: tuple[int, ...],
        *args,
        num_sample_steps: Optional[int] = None,
        **kwargs,
    ) -> Array:
        rng_prior, rng_loop = jax.random.split(rng, 2)
        x_init = self.schedule.prior_sample(rng_prior, shape)

        steps = self.num_steps if num_sample_steps is None else int(num_sample_steps)
        if steps <= 0:
            raise ValueError("num_sample_steps must be a positive integer.")

        times = jnp.linspace(
            self.schedule.t_max,
            self.schedule.t_min,
            steps + 1,
            dtype=jnp.float32,
        )
        step_keys = jax.random.split(rng_loop, steps)

        def scan_body(x_curr, inp):
            step_key, t_curr, t_prev = inp
            x_prev = self.p_sample(
                step_key,
                t_curr,
                x_curr,
                *args,
                t_prev=t_prev,
                **kwargs,
            )
            return x_prev, None

        x_final, _ = jax.lax.scan(
            scan_body,
            x_init,
            (step_keys, times[:-1], times[1:]),
        )
        return x_final

    def _distribution_sampler(
        self,
        event_spec,
        *,
        num_sample_steps: Optional[int] = None,
        context_spec=None,
        **kwargs,
    ):
        del kwargs
        if context_spec is not None:
            raise ValueError("Multinomial diffusion does not accept context.")
        leaves = jax.tree.leaves(event_spec)
        if len(leaves) != 1:
            raise TypeError("Multinomial diffusion requires a single-array event spec.")
        return _DiscreteSampler(
            self,
            tuple(leaves[0].shape),
            num_sample_steps,
        )

score_reparameterized

score_reparameterized(rng, t, x_t, *args, temperature=1.0, num_samples=1, **kwargs)

Relaxed discrete score: gradient wrt relaxed x_t simplex variable.

Uses Gumbel-Softmax reparameterization and computes grad_{x_t_relaxed} E[ ].

Source code in probjax/nn/generative/discrete/model.py
def score_reparameterized(
    self,
    rng: RngKey,
    t: ArrayLike,
    x_t: Array,
    *args,
    temperature: float = 1.0,
    num_samples: int = 1,
    **kwargs,
) -> Array:
    """
    Relaxed discrete score: gradient wrt relaxed x_t simplex variable.

    Uses Gumbel-Softmax reparameterization and computes
      grad_{x_t_relaxed} E[ <x_t_relaxed, log p_theta(x0|x_t_relaxed,t)> ].
    """
    x_onehot = self._as_onehot(x_t)
    log_x = jnp.log(jnp.maximum(x_onehot, self.eps))

    def one_score(sample_key):
        u = jax.random.uniform(
            sample_key,
            x_onehot.shape,
            minval=self.eps,
            maxval=1.0 - self.eps,
        )
        g = -jnp.log(-jnp.log(u))
        x_relaxed = jax.nn.softmax(
            (log_x + g) / jnp.maximum(temperature, self.eps),
            axis=-1,
        )

        def objective(x_in):
            logits = self._predict_x0_logits_from_probs(t, x_in, *args, **kwargs)
            log_probs = jax.nn.log_softmax(logits, axis=-1)
            return jnp.sum(x_in * log_probs)

        return jax.grad(objective)(x_relaxed)

    keys = jax.random.split(rng, num_samples)
    grads = jax.vmap(one_score)(keys)
    return jnp.mean(grads, axis=0)

probjax.nn.DiffusionDenoiser

Bases: GenerativeModel

Composable diffusion denoiser:

  • schedule : NoiseScheduleProtocol (physical schedule)
  • precond : PreconditioningProtocol (defines c_in/out/etc)
  • train_cfg : TrainingConfigProtocol (defines t sampling)
  • solver_cfg : SolverConfigProtocol (defines solve ODE/SDE)
Source code in probjax/nn/generative/diffusion/model.py
class DiffusionDenoiser(GenerativeModel):
    """
    Composable diffusion denoiser:

      - schedule   : NoiseScheduleProtocol   (physical schedule)
      - precond    : PreconditioningProtocol (defines c_in/out/etc)
      - train_cfg  : TrainingConfigProtocol  (defines t sampling)
      - solver_cfg : SolverConfigProtocol    (defines solve ODE/SDE)
    """

    schedule: NoiseScheduleProtocol
    precond: PreconditioningProtocol
    train_cfg: TrainingConfigProtocol
    solver_cfg: SolverConfigProtocol | None

    def __init__(
        self,
        net: ModuleLike,
        schedule: NoiseScheduleProtocol,
        precond: PreconditioningProtocol,
        train_cfg: TrainingConfigProtocol,
        solver_cfg: SolverConfigProtocol | None = None,
        std0: ArrayLike = 1.0,
        last_layer: Callable[[Array], Array] | None = None,
        rngs: nnx.RngStream | None = None,
    ) -> None:
        if not isinstance(schedule, NoiseScheduleProtocol):
            raise TypeError("schedule must implement NoiseScheduleProtocol")
        if not isinstance(precond, PreconditioningProtocol):
            raise TypeError("precond must implement PreconditioningProtocol")
        if not isinstance(train_cfg, TrainingConfigProtocol):
            raise TypeError("train_cfg must implement TrainingConfigProtocol")
        if solver_cfg is not None and not isinstance(solver_cfg, SolverConfigProtocol):
            raise TypeError("solver_cfg must implement SolverConfigProtocol")

        self.rngs = rngs
        self.net: ModuleLike = net
        self._net_accepts_rng = module_accepts_rng(self.net)
        self.schedule = schedule
        self.precond = precond
        self.train_cfg = train_cfg
        self.solver_cfg = solver_cfg
        if self.solver_cfg is not None and hasattr(self.solver_cfg, "set_schedule"):
            self.solver_cfg.set_schedule(self.schedule)
        self.std0 = nnx.Variable(std0)
        self.last_layer = last_layer

    def set_solver_cfg(self, solver_cfg: SolverConfigProtocol) -> None:
        if not isinstance(solver_cfg, SolverConfigProtocol):
            raise TypeError("solver_cfg must implement SolverConfigProtocol")
        self.solver_cfg = solver_cfg
        if hasattr(self.solver_cfg, "set_schedule"):
            self.solver_cfg.set_schedule(self.schedule)
        self._clear_distribution_cache()

    # ---- physical schedule adapters ----

    def scale_fn(self, t: ArrayLike) -> Array:
        return self.schedule.scale(t)

    def std_fn(self, t: ArrayLike) -> Array:
        return self.schedule.std(t)

    def sigma_eff(self, t: ArrayLike) -> Array:
        return self.schedule.sigma_eff(t)

    def inv_sigma_eff(self, sigma_eff: ArrayLike) -> Array:
        return self.schedule.inv_sigma_eff(sigma_eff)

    # ---- preconditioning adapters ----

    def c_in(self, t: ArrayLike) -> Array:
        return self.precond.c_in(
            t,
            std0=self.std0.get_value(),
            scale_fn=self.scale_fn,
            std_fn=self.std_fn,
        )

    def c_out(self, t: ArrayLike) -> Array:
        return self.precond.c_out(
            t,
            std0=self.std0.get_value(),
            scale_fn=self.scale_fn,
            std_fn=self.std_fn,
        )

    def c_t(self, t: ArrayLike) -> Array:
        return self.precond.c_t(
            t,
            scale_fn=self.scale_fn,
            std_fn=self.std_fn,
        )

    def c_skip(self, t: ArrayLike) -> Array | None:
        return self.precond.c_skip(
            t,
            std0=self.std0.get_value(),
            scale_fn=self.scale_fn,
            std_fn=self.std_fn,
        )

    def weight_fn(self, t: ArrayLike) -> Array:
        return self.precond.weight_x0(
            t,
            std0=self.std0.get_value(),
            scale_fn=self.scale_fn,
            std_fn=self.std_fn,
        )

    def weight_fn_eps(self, t: ArrayLike) -> Array:
        return self.precond.weight_eps(
            t,
            std0=self.std0.get_value(),
            scale_fn=self.scale_fn,
            std_fn=self.std_fn,
        )

    def weight_fn_v(self, t: ArrayLike) -> Array:
        return self.precond.weight_v(
            t,
            std0=self.std0.get_value(),
            scale_fn=self.scale_fn,
            std_fn=self.std_fn,
        )

    # ---- loss ----

    def _build_loss_fn(self):
        loss_type = self.train_cfg.loss_type

        if loss_type == "x0":
            weight_fn = self.weight_fn
            pred_fn = self.denoise
        elif loss_type in ("eps", "epsilon"):
            weight_fn = self.weight_fn_eps
            pred_fn = self.epsilon
            loss_type = "eps"
        elif loss_type == "v":
            weight_fn = self.weight_fn_v
            pred_fn = self.v
        else:
            raise ValueError(f"Unsupported loss type: {loss_type}")

        return build_time_dependent_denoising_loss(
            pred_fn,
            scale_fn=self.scale_fn,
            std_fn=self.std_fn,
            weight_fn=weight_fn,
            prediction_target=loss_type,
            **dict(self.train_cfg.loss_kwargs),
        )

    @property
    def loss_type(self) -> str:
        return self.train_cfg.loss_type

    @loss_type.setter
    def loss_type(self, value: str) -> None:
        self.train_cfg.loss_type = value  # type: ignore[attr-defined]
        self._build_loss_fn()

    # ---- forward ----

    def __call__(
        self,
        t: ArrayLike,
        x_t: PyTree[Array],
        *args,
        rng: jax.Array | None = None,
        **kwargs,
    ) -> PyTree[Array]:
        noise_embed = self.c_t(t)
        x_embed = jax.tree_util.tree_map(lambda x: self.c_in(t) * x, x_t)
        if self._net_accepts_rng:
            out = self.net(noise_embed, x_embed, *args, rng=rng, **kwargs)
        else:
            out = self.net(noise_embed, x_embed, *args, **kwargs)
        if self.last_layer is not None:
            out = jax.tree_util.tree_map(self.last_layer, out)
        return out

    # ---- prediction heads ----

    def denoise(
        self,
        t: ArrayLike,
        x_t: PyTree[Array],
        *args,
        **kwargs,
    ) -> PyTree[Array]:
        model_output = self.__call__(t, x_t, *args, **kwargs)
        c_out = self.c_out(t)
        c_skip = self.c_skip(t)
        if c_skip is None:
            return jax.tree_util.tree_map(lambda o: c_out * o, model_output)
        return jax.tree_util.tree_map(
            lambda x, o: c_skip * jnp.nan_to_num(x) + c_out * o,
            x_t,
            model_output,
        )

    def epsilon(
        self,
        t: ArrayLike,
        x_t: PyTree[Array],
        *args,
        **kwargs,
    ) -> PyTree[Array]:
        sigma_t = self.std_fn(t)
        alpha_t = self.scale_fn(t)
        x0_pred = self.denoise(t, x_t, *args, **kwargs)
        return jax.tree_util.tree_map(
            lambda x, o: (jnp.nan_to_num(x) - alpha_t * o) / sigma_t,
            x_t,
            x0_pred,
        )

    def score(
        self,
        t: ArrayLike,
        x_t: PyTree[Array],
        *args,
        **kwargs,
    ) -> PyTree[Array]:
        eps = self.epsilon(t, x_t, *args, **kwargs)
        sigma_t = self.std_fn(t)
        return jax.tree_util.tree_map(
            lambda e: -jnp.nan_to_num(e) / sigma_t,
            eps,
        )

    def v(
        self,
        t: ArrayLike,
        x_t: PyTree[Array],
        *args,
        **kwargs,
    ) -> PyTree[Array]:
        """
        Normalized v:
          v = alpha_hat(t) * eps - sigma_hat(t) * x0
        based on normalized (alpha_hat, sigma_hat).
        """
        x0_pred = self.denoise(t, x_t, *args, **kwargs)
        alpha_t = self.scale_fn(t)
        sigma_t = self.std_fn(t)
        eps_pred = jax.tree_util.tree_map(
            lambda x, x0_: (jnp.nan_to_num(x) - alpha_t * x0_) / sigma_t,
            x_t,
            x0_pred,
        )
        total_var = jnp.sqrt(jnp.maximum(alpha_t**2 + sigma_t**2, 1e-12))
        alpha_hat = alpha_t / total_var
        sigma_hat = sigma_t / total_var
        return jax.tree_util.tree_map(
            lambda e, x0_: alpha_hat * e - sigma_hat * x0_,
            eps_pred,
            x0_pred,
        )

    # ---- SDE helpers ----

    def marginal_std(self, t: ArrayLike) -> Array:
        return self.schedule.marginal_std(t, self.std0.get_value())

    def _sample_base(self, rng, sample_shape, spec):
        """Draw the reverse process's starting noise, N(0, marginal_std(t_max)^2).

        Overrides the base class's fixed unit-variance default: the reverse
        ODE/SDE starts at ``t_max``, where the data has been diffused to
        ``marginal_std(t_max)`` (e.g. ``~sigma_max`` for EDM), not to unit
        variance.
        """
        scale = self.marginal_std(self.train_cfg.t_max)
        return sample_normal(rng, sample_shape, spec, scale=scale)

    def drift(
        self,
        t: ArrayLike,
        x: PyTree[Array],
        *args,
        **kwargs,
    ) -> PyTree[Array]:
        return self.schedule.drift(t, x)

    def diffusion(
        self,
        t: ArrayLike,
        x: PyTree[Array],
        *args,
        **kwargs,
    ) -> PyTree[Array]:
        return self.schedule.diffusion(t, x)

    # ---- training loss ----

    def loss(
        self,
        rng: RngKey,
        data: Array,
        *args,
        **kwargs,
    ) -> Array:
        loss_fn = self._build_loss_fn()
        rng_times, rng_loss = jax.random.split(rng, 2)

        ndims = data.ndim - 2
        time_shape = (data.shape[0],) + (1,) * ndims
        times = self.train_cfg.sample_times(rng_times, time_shape)

        if "axis" not in kwargs:
            kwargs["axis"] = tuple(range(1, data.ndim))
        return loss_fn(times, data, *args, rng=rng_loss, **kwargs)

    # ---- sampling (delegates to solver_cfg) ----

    def _build_ode_drift(self, context=None):
        kwargs = {} if context is None else {"context": context}
        return self.solver_cfg.build_ode_drift(self, **kwargs)

    def _build_sde_drift_and_diffusion(self, context=None):
        kwargs = {} if context is None else {"context": context}
        return self.solver_cfg.build_sde_drift_and_diffusion(self, **kwargs)

    def _distribution_sampler(
        self,
        event_spec,
        *,
        mode: str = "ode",
        num_steps: int | None = None,
        t_min: float | None = None,
        t_max: float | None = None,
        collect_trace: bool = False,
        dtype=jnp.float32,
        context_spec=None,
    ) -> _ExportedSampler:
        """Build and cache a shape-polymorphic diffusion sampler.

        ``event_spec`` may be a plain shape tuple or a pytree of shapes /
        ``jax.ShapeDtypeStruct`` for structured data. ``context_spec`` uses
        the same format and enables a required, batch-aligned context input.
        """
        if mode not in ("ode", "sde"):
            raise ValueError(f"mode must be 'ode' or 'sde', got {mode!r}")
        if self.solver_cfg is None:
            raise ValueError(
                "solver_cfg is not set. Provide one at init or via set_solver_cfg()."
            )
        steps = self.solver_cfg.num_steps if num_steps is None else int(num_steps)
        t_min = self.train_cfg.t_min if t_min is None else t_min
        t_max = self.train_cfg.t_max if t_max is None else t_max
        method = (
            self.solver_cfg.ode_method if mode == "ode" else self.solver_cfg.sde_method
        )

        ts = self.solver_cfg.solve_schedule(
            t_min=t_min,
            t_max=t_max,
            num_steps=steps,
        )
        if mode == "ode":
            make_sample_fn = partial(
                make_ode_sample_fn,
                ts=ts,
                prototype=self._build_ode_drift(),
                build_drift=_diffusion_ode_drift,
                method=method,
                collect_trace=collect_trace,
            )
        else:
            make_sample_fn = partial(
                make_sde_sample_fn,
                ts=ts,
                build_drift_and_diffusion=_diffusion_sde_terms,
                method=method,
                collect_trace=collect_trace,
            )

        return self._build_exported_sampler(
            ("diffusion", mode, steps, method, t_min, t_max, collect_trace),
            event_spec,
            make_sample_fn,
            dtype=dtype,
            stochastic=mode == "sde",
            context_spec=context_spec,
            trace=collect_trace,
        )

v

v(t, x_t, *args, **kwargs)
Normalized v

v = alpha_hat(t) * eps - sigma_hat(t) * x0

based on normalized (alpha_hat, sigma_hat).

Source code in probjax/nn/generative/diffusion/model.py
def v(
    self,
    t: ArrayLike,
    x_t: PyTree[Array],
    *args,
    **kwargs,
) -> PyTree[Array]:
    """
    Normalized v:
      v = alpha_hat(t) * eps - sigma_hat(t) * x0
    based on normalized (alpha_hat, sigma_hat).
    """
    x0_pred = self.denoise(t, x_t, *args, **kwargs)
    alpha_t = self.scale_fn(t)
    sigma_t = self.std_fn(t)
    eps_pred = jax.tree_util.tree_map(
        lambda x, x0_: (jnp.nan_to_num(x) - alpha_t * x0_) / sigma_t,
        x_t,
        x0_pred,
    )
    total_var = jnp.sqrt(jnp.maximum(alpha_t**2 + sigma_t**2, 1e-12))
    alpha_hat = alpha_t / total_var
    sigma_hat = sigma_t / total_var
    return jax.tree_util.tree_map(
        lambda e, x0_: alpha_hat * e - sigma_hat * x0_,
        eps_pred,
        x0_pred,
    )

probjax.nn.FlowMatcher

Bases: GenerativeModel

Composable flow matcher:

  • schedule : InterpolationScheduleProtocol
  • precond : FlowPreconditioningProtocol
  • train_cfg : FlowTrainingConfigProtocol
  • solver_cfg : FlowSolverConfigProtocol | None
Source code in probjax/nn/generative/flow_matching/model.py
class FlowMatcher(GenerativeModel):
    """
    Composable flow matcher:

      - schedule   : InterpolationScheduleProtocol
      - precond    : FlowPreconditioningProtocol
      - train_cfg  : FlowTrainingConfigProtocol
      - solver_cfg : FlowSolverConfigProtocol | None
    """

    schedule: InterpolationScheduleProtocol
    preconditioning: FlowPreconditioningProtocol
    train_cfg: FlowTrainingConfigProtocol
    solver_cfg: FlowSolverConfigProtocol | None

    def __init__(
        self,
        net: ModuleLike,
        schedule: InterpolationScheduleProtocol,
        preconditioning: FlowPreconditioningProtocol,
        train_cfg: FlowTrainingConfigProtocol,
        solver_cfg: FlowSolverConfigProtocol | None = None,
        mu0: ArrayLike = 0.0,
        std0: ArrayLike = 1.0,
        mu1: ArrayLike = 0.0,
        std1: ArrayLike = 1.0,
        loss_kwargs: Mapping[str, object] | None = None,
        rngs: nnx.RngStream | None = None,
    ):
        if not isinstance(schedule, InterpolationScheduleProtocol):
            raise TypeError("schedule must implement InterpolationScheduleProtocol")
        if not isinstance(preconditioning, FlowPreconditioningProtocol):
            raise TypeError(
                "preconditioning must implement FlowPreconditioningProtocol"
            )
        if not isinstance(train_cfg, FlowTrainingConfigProtocol):
            raise TypeError("train_cfg must implement FlowTrainingConfigProtocol")
        if solver_cfg is not None and not isinstance(
            solver_cfg, FlowSolverConfigProtocol
        ):
            raise TypeError("solver_cfg must implement FlowSolverConfigProtocol")

        self.rngs = rngs
        self.net: ModuleLike = net
        self.schedule = schedule
        self.preconditioning = preconditioning
        self.train_cfg = train_cfg
        self.solver_cfg = solver_cfg

        self.mu0 = nnx.Variable(mu0)
        self.std0 = nnx.Variable(std0)
        self.mu1 = nnx.Variable(mu1)
        self.std1 = nnx.Variable(std1)

        self._loss_kwargs: dict[str, object] = dict(loss_kwargs or {})

    def set_solver_cfg(self, solver_cfg: FlowSolverConfigProtocol) -> None:
        if not isinstance(solver_cfg, FlowSolverConfigProtocol):
            raise TypeError("solver_cfg must implement FlowSolverConfigProtocol")
        self.solver_cfg = solver_cfg
        self._clear_distribution_cache()

    def __call__(
        self,
        t: ArrayLike,
        x: PyTree[Array],
        *args,
        rng: jax.Array | None = None,
        **kwargs,
    ) -> PyTree[Array]:
        """Forward pass of the model - v-prediction.

        Uses a Gaussian closed-form preconditioning scheme. For endpoints
        p0(x) = N(x; mu0, std0**2) and p1(x) = N(x; mu1, std1**2), the
        marginal velocity field under the interpolation schedule is

        E[x1-x0|xt] = (mu1 - mu0) + s(t) * (xt - mu_t)

        where mu_t and sigma_t are the schedule's interpolated mean and
        standard deviation, and s(t) = d/dt log sigma(t). For the linear
        schedule this gives

        s(t) = (t * std1**2 - (1 - t) * std0**2)
               / ((1 - t)**2 * std0**2 + t**2 * std1**2).

        The network operates on the normalized input ``x_normed = (x - mu_t)
        / sigma_t`` and predicts a displacement correction ``v_out`` in the
        same normalized space. The preconditioner maps it back to data space
        as ``sigma_t * v_out`` and adds it inside the velocity-scaled term:

        v(t, x) = (mu1 - mu0) + s(t) * ((x - mu_t) + sigma_t * v_out).

        ``mu_t`` and ``sigma_t`` are computed analytically from the schedule;
        the network never predicts them.
        """
        # With preconditioning
        mu0 = self.mu0.get_value()
        std0 = self.std0.get_value()
        mu1 = self.mu1.get_value()
        std1 = self.std1.get_value()

        x_normed, approx_mut, approx_stdt = self.preconditioning.normalize(
            self.schedule, t, x, mu0, mu1, std0, std1
        )
        v_out = self.net(t, x_normed, *args, rng=rng, **kwargs)
        v_out_data = jax.tree_util.tree_map(lambda v: approx_stdt * v, v_out)
        return self.preconditioning.decode_velocity(
            self.schedule,
            t,
            x,
            mu0,
            mu1,
            std0,
            std1,
            v_out_data,
        )

    def score(self, t: ArrayLike, x: PyTree[Array], *args, **kwargs) -> PyTree[Array]:
        """Score function for the model."""
        raise NotImplementedError(
            "Implemented only for specific implementation of this base class"
        )

    def denoise(self, t: ArrayLike, x: PyTree[Array]) -> PyTree[Array]:
        """Denoise the input x at time t."""
        raise NotImplementedError(
            "Implemented only for specific implementation of this base class"
        )

    def _build_loss_fn(self):
        return build_flow_matching_loss(
            self,
            schedule=self.schedule,
            **self._loss_kwargs,
        )

    def loss(
        self,
        rng: RngKey,
        data: Array,
        *args,
        **kwargs,
    ) -> Array:
        loss_fn = self._build_loss_fn()

        rng_source, rng_times = jax.random.split(rng, 2)

        # Generate noise for x0
        x0 = (
            jax.random.normal(rng_source, shape=data.shape) * self.std0.get_value()
            + self.mu0.get_value()
        )

        # Get shape from data for time scheduling
        data_shape = data.shape
        ndims = data.ndim - 2
        times = self.train_cfg.sample_times(rng_times, (data_shape[0],) + (1,) * ndims)

        loss = loss_fn(times, x0, data, *args, **kwargs)
        return loss

    def solve_schedule(
        self,
        t_min: float = 0.0,
        t_max: float = 1.0,
        num_steps: int | None = None,
    ) -> Array:
        if self.solver_cfg is None:
            raise ValueError(
                "solver_cfg is not set. Provide one at init or via set_solver_cfg()."
            )
        return self.solver_cfg.solve_schedule(
            t_min=t_min, t_max=t_max, num_steps=num_steps
        )

    def _sample_base(self, rng, sample_shape, spec):
        return sample_normal(
            rng,
            sample_shape,
            spec,
            loc=self.mu0.get_value(),
            scale=self.std0.get_value(),
        )

    def _build_ode_drift(self, context=None):
        def drift(t, value):
            if context is None:
                return self(t, value)
            return self(t, value, context=context)

        return generic_drift(drift)

    def _distribution_sampler(
        self,
        event_spec,
        *,
        num_steps: int | None = None,
        method: str = "rk4",
        t_min: float = 0.0,
        t_max: float = 1.0,
        collect_trace: bool = False,
        dtype=jnp.float32,
        context_spec=None,
    ) -> _ExportedSampler:
        """Build and cache an ODE sampler with symbolic batch size.

        ``event_spec`` may be a plain shape tuple or a pytree of shapes /
        ``jax.ShapeDtypeStruct`` for structured data. ``context_spec`` uses
        the same format and enables a required, batch-aligned context input.
        """
        if self.solver_cfg is None:
            raise ValueError(
                "solver_cfg is not set. Provide one at init or via set_solver_cfg()."
            )
        steps = self.solver_cfg.num_steps if num_steps is None else int(num_steps)
        ts = self.solve_schedule(t_min=t_min, t_max=t_max, num_steps=steps)
        make_sample_fn = partial(
            make_ode_sample_fn,
            ts=ts,
            prototype=self._build_ode_drift(),
            build_drift=_flow_ode_drift,
            method=method,
            collect_trace=collect_trace,
        )

        return self._build_exported_sampler(
            ("flow", steps, method, t_min, t_max, collect_trace),
            event_spec,
            make_sample_fn,
            dtype=dtype,
            context_spec=context_spec,
            trace=collect_trace,
        )

score

score(t, x, *args, **kwargs)

Score function for the model.

Source code in probjax/nn/generative/flow_matching/model.py
def score(self, t: ArrayLike, x: PyTree[Array], *args, **kwargs) -> PyTree[Array]:
    """Score function for the model."""
    raise NotImplementedError(
        "Implemented only for specific implementation of this base class"
    )

denoise

denoise(t, x)

Denoise the input x at time t.

Source code in probjax/nn/generative/flow_matching/model.py
def denoise(self, t: ArrayLike, x: PyTree[Array]) -> PyTree[Array]:
    """Denoise the input x at time t."""
    raise NotImplementedError(
        "Implemented only for specific implementation of this base class"
    )

probjax.nn.MeanFlowMatcher

Bases: GenerativeModel

Source code in probjax/nn/generative/mean_flow/model.py
class MeanFlowMatcher(GenerativeModel):
    def __init__(
        self,
        net: ModuleLike,
        schedule: InterpolationScheduleProtocol,
        preconditioning: FlowPreconditioningProtocol,
        train_cfg: FlowPairTrainingConfigProtocol,
        solver_cfg: FlowSolverConfigProtocol | None = None,
        mu0: ArrayLike = 0,
        std0: ArrayLike = 1,
        mu1: ArrayLike = 0.0,
        std1: ArrayLike = 1.0,
        rngs: nnx.RngStream | None = None,
        loss_kwargs: Mapping[str, object] | None = None,
    ):
        self.net: ModuleLike = net
        self.mu0 = nnx.Variable(mu0)
        self.std0 = nnx.Variable(std0)
        self.mu1 = nnx.Variable(mu1)
        self.std1 = nnx.Variable(std1)
        self.rngs = rngs
        self._loss_kwargs: dict[str, object] = dict(loss_kwargs or {})

        if not isinstance(schedule, InterpolationScheduleProtocol):
            raise TypeError("schedule must implement InterpolationScheduleProtocol")
        if not isinstance(preconditioning, FlowPreconditioningProtocol):
            raise TypeError(
                "preconditioning must implement FlowPreconditioningProtocol"
            )
        if not isinstance(train_cfg, FlowPairTrainingConfigProtocol):
            raise TypeError("train_cfg must implement FlowPairTrainingConfigProtocol")
        if solver_cfg is not None and not isinstance(
            solver_cfg, FlowSolverConfigProtocol
        ):
            raise TypeError("solver_cfg must implement FlowSolverConfigProtocol")

        self.schedule = schedule
        self.preconditioning = preconditioning
        self.train_cfg = train_cfg
        self.solver_cfg = solver_cfg

    def set_solver_cfg(self, solver_cfg: FlowSolverConfigProtocol) -> None:
        if not isinstance(solver_cfg, FlowSolverConfigProtocol):
            raise TypeError("solver_cfg must implement FlowSolverConfigProtocol")
        self.solver_cfg = solver_cfg
        self._clear_distribution_cache()

    def __call__(
        self,
        t: ArrayLike,
        x: PyTree[Array],
        r: ArrayLike | None = None,
        *args,
        rng: jax.Array | None = None,
        **kwargs,
    ) -> PyTree[Array]:
        """
        Predicted velocity to time r.

        Uses a Gaussian closed-form preconditioning scheme. The marginal
        mean-velocity field is

        v(t, x) = (mu1 - mu0) + s(t) * (x - mu_t)

        where s(t) = d/dt log sigma(t). The network operates on the
        normalized input ``x_normed = (x - mu_t) / sigma_t`` and predicts a
        displacement correction ``v_out`` in the same normalized space. The
        preconditioner maps it back to data space as ``sigma_t * v_out`` and
        adds it inside the velocity-scaled term:

        v(t, x, r) = (mu1 - mu0) + s(t) * ((x - mu_t) + sigma_t * v_out).

        ``mu_t`` and ``sigma_t`` are computed analytically from the schedule;
        the network never predicts them.

        Note: this preconditioning uses the *instantaneous* Gaussian
        ``s(t) = d/dt log sigma(t)``. For ``std0 == std1``, ``s(0.5) = 0``,
        so at ``t = 0.5`` the output is exactly ``mu1 - mu0`` for every ``x``
        and the network correction is annihilated (the true average-velocity
        field to ``r = 1`` is still ``x``-dependent there). Fine for
        ``r -> t``; a known limitation for large-gap pairs straddling the
        midpoint.

        Args:
            t: Current time t.
            x: Data at time t.
            r: Time at which to predict the mean (r > t).

        Returns:
            Predicted velocity to time r.
        """
        mu0 = self.mu0.get_value()
        std0 = self.std0.get_value()
        mu1 = self.mu1.get_value()
        std1 = self.std1.get_value()

        # Safety floor/ceiling enforcing the t <= r <= 1 convention used by
        # _mean_flow_step. Written with jnp.where (primal-identical to
        # jnp.clip) so tangents stay one-sided: jnp.maximum's JVP tie rule
        # would otherwise halve r's own tangent (and blend in t's tangent)
        # at the r == t ties that make up most of the training batch.
        # stop_gradient on the t bound keeps the training-time JVP of this
        # __call__ (tangents dr=0, dt=1) from leaking t's tangent into r.
        t_bound = jax.lax.stop_gradient(t)
        if r is None:
            r: ArrayLike = t
        else:
            r = jnp.where(r < t_bound, t_bound, r)
            r = jnp.where(r > 1.0, 1.0, r)

        eps = getattr(self.preconditioning, "eps", 1e-8)
        approx_mu_t = self.schedule.path_mean(t, mu0, mu1)
        approx_std_t = jnp.maximum(self.schedule.path_std(t, std0, std1), eps)

        x_normed = jax.tree_util.tree_map(lambda x: (x - approx_mu_t) / approx_std_t, x)
        std_t = approx_std_t
        a_t = self.schedule.a_t(t)
        b_t = self.schedule.b_t(t)
        denom = (a_t**2) * std0**2 + (b_t**2) * std1**2
        scale = (b_t * std1**2 - a_t * std0**2) / jnp.maximum(denom, eps)

        v_out = self.net(t, x_normed, *args, r=r, rng=rng, **kwargs)
        v_out_data = jax.tree_util.tree_map(lambda v: std_t * v, v_out)

        return jax.tree.map(
            lambda value, update: mu1 - mu0 + scale * (value - approx_mu_t + update),
            x,
            v_out_data,
        )

    def loss(
        self,
        rng: RngKey,
        data: Array,
        *args,
        adaptive_weight_p: float = 0.3,
        adaptive_weight_eps: float = 1e-3,
        imf: bool | None = None,
        **kwargs,
    ) -> Array:
        """Mean flow matching loss (Improved MeanFlow v-loss by default).

        Args:
            imf: Use the Improved MeanFlow objective. Defaults to
                ``loss_kwargs["imf"]`` if set, else True. Pass False (or
                ``loss_kwargs={"imf": False}``) for the original objective.
        """
        if imf is None:
            imf = self._loss_kwargs.get("imf", True)
        build_kwargs = {k: v for k, v in self._loss_kwargs.items() if k != "imf"}
        loss_fn = build_mean_flow_matching_loss(
            self,
            schedule=self.schedule,
            weight_fn=None,
            imf=imf,
            **build_kwargs,
        )

        rng_source, rng_times = jax.random.split(rng, 2)
        ndims = data.ndim - 2
        times_t, times_r = self.train_cfg.sample_times_pair(
            rng_times, (data.shape[0],) + (1,) * ndims
        )

        x0 = (
            jax.random.normal(rng_source, shape=data.shape) * self.std0.get_value()
            + self.mu0.get_value()
        )
        loss = loss_fn(
            times_r,
            times_t,
            x0,
            data,
            *args,
            adaptive_weight_p=adaptive_weight_p,
            adaptive_weight_eps=adaptive_weight_eps,
            **kwargs,
        )
        return loss

    def solve_schedule(
        self,
        t_min: float = 0.0,
        t_max: float = 1.0,
        num_steps: int | None = None,
    ) -> Array:
        return self.solver_cfg.solve_schedule(
            t_min=t_min, t_max=t_max, num_steps=num_steps
        )

    def _sample_base(self, rng, sample_shape, spec):
        return sample_normal(
            rng,
            sample_shape,
            spec,
            loc=self.mu0.get_value(),
            scale=self.std0.get_value(),
        )

    def _distribution_sampler(
        self,
        event_spec,
        *,
        num_steps: int | None = None,
        dtype=jnp.float32,
        context_spec=None,
    ) -> _ExportedSampler:
        """Build and cache a mean-flow sampler with symbolic batch size.

        ``event_spec`` may be a plain shape tuple or a pytree of shapes /
        ``jax.ShapeDtypeStruct`` for structured data. ``context_spec`` uses
        the same format and enables a required, batch-aligned context input.
        """
        if self.solver_cfg is None:
            raise ValueError(
                "solver_cfg is not set. Provide one at init or via set_solver_cfg()."
            )
        steps = self.solver_cfg.num_steps if num_steps is None else int(num_steps)
        ts = self.solve_schedule(num_steps=steps)
        make_sample_fn = partial(
            make_scan_sample_fn,
            xs=(ts[:-1], ts[1:]),
            step=_mean_flow_step,
        )

        return self._build_exported_sampler(
            ("mean-flow", steps),
            event_spec,
            make_sample_fn,
            dtype=dtype,
            context_spec=context_spec,
        )

loss

loss(rng, data, *args, adaptive_weight_p=0.3, adaptive_weight_eps=0.001, imf=None, **kwargs)

Mean flow matching loss (Improved MeanFlow v-loss by default).

Parameters:

Name Type Description Default
imf bool | None

Use the Improved MeanFlow objective. Defaults to loss_kwargs["imf"] if set, else True. Pass False (or loss_kwargs={"imf": False}) for the original objective.

None
Source code in probjax/nn/generative/mean_flow/model.py
def loss(
    self,
    rng: RngKey,
    data: Array,
    *args,
    adaptive_weight_p: float = 0.3,
    adaptive_weight_eps: float = 1e-3,
    imf: bool | None = None,
    **kwargs,
) -> Array:
    """Mean flow matching loss (Improved MeanFlow v-loss by default).

    Args:
        imf: Use the Improved MeanFlow objective. Defaults to
            ``loss_kwargs["imf"]`` if set, else True. Pass False (or
            ``loss_kwargs={"imf": False}``) for the original objective.
    """
    if imf is None:
        imf = self._loss_kwargs.get("imf", True)
    build_kwargs = {k: v for k, v in self._loss_kwargs.items() if k != "imf"}
    loss_fn = build_mean_flow_matching_loss(
        self,
        schedule=self.schedule,
        weight_fn=None,
        imf=imf,
        **build_kwargs,
    )

    rng_source, rng_times = jax.random.split(rng, 2)
    ndims = data.ndim - 2
    times_t, times_r = self.train_cfg.sample_times_pair(
        rng_times, (data.shape[0],) + (1,) * ndims
    )

    x0 = (
        jax.random.normal(rng_source, shape=data.shape) * self.std0.get_value()
        + self.mu0.get_value()
    )
    loss = loss_fn(
        times_r,
        times_t,
        x0,
        data,
        *args,
        adaptive_weight_p=adaptive_weight_p,
        adaptive_weight_eps=adaptive_weight_eps,
        **kwargs,
    )
    return loss

probjax.nn.LinearFlow

Bases: FlowMatcher

Source code in probjax/nn/generative/flow_matching/model.py
class LinearFlow(FlowMatcher):
    def __init__(
        self,
        net: ModuleLike,
        mu0: ArrayLike = 0.0,
        std0: ArrayLike = 1.0,
        mu1: ArrayLike = 0.0,
        std1: ArrayLike = 1.0,
        rngs: nnx.RngStream | None = None,
        loss_kwargs: Mapping[str, object] | None = None,
        train_cfg: FlowTrainingConfigProtocol | None = None,
        solver_cfg: FlowSolverConfigProtocol | None = None,
        schedule: InterpolationScheduleProtocol | None = None,
        preconditioning: FlowPreconditioningProtocol | None = None,
    ):
        schedule = schedule or LinearInterpolationSchedule()
        preconditioning = preconditioning or GaussianFlowPreconditioning()
        train_cfg = train_cfg or LogitNormalFlowTrainingConfig(mu=0.7, scale=1.0)
        solver_cfg = solver_cfg or LinearFlowSolverConfig()
        super().__init__(
            net,
            schedule=schedule,
            preconditioning=preconditioning,
            train_cfg=train_cfg,
            solver_cfg=solver_cfg,
            mu0=mu0,
            std0=std0,
            mu1=mu1,
            std1=std1,
            rngs=rngs,
            loss_kwargs=loss_kwargs,
        )

    def denoise(self, t: ArrayLike, x: PyTree[Array]) -> PyTree[Array]:
        # x0 is noise
        # x1 is data
        # xt = (1 - t) * x0 + t * x1
        # x0 = (xt - t * x1) / (1 - t)
        # E[x1-x0|xt] = E[x1 - (xt - t * x1) / (1 - t)|xt]
        # = (xt - E[x1|xt])/(1 - t)
        # So we can recover the denoiser by
        # E[x1|xt] = xt - (1-t)*E[x1-x0|xt]
        v = self.__call__(t, x)

        def denoise_leaf(leaf_x, leaf_v):
            return leaf_x - (1 - t) * leaf_v

        return jax.tree_util.tree_map(denoise_leaf, x, v)

    def score(
        self, t: ArrayLike, x: PyTree[Array], max_t: float = 1 - 1e-3
    ) -> PyTree[Array]:
        # We can recover a "denoiser" so we can use it to get the score
        # using Tweedie's formula
        # Pertubration kernel is given by
        # p(xt|x1) = N(xt, t*x1, (1-t)*std0**2)
        # score = (t * E[x1|xt] + (1-t)*mu0 - xt) / (1-t)**2*std0**2
        # = (t * (xt - (1-t)*v) + (1-t)*mu0 - xt) / (1-t)**2*std0**2
        # = (-(1-t) * xt - t*(1-t)*v + (1-t)*mu0) / (1-t)**2*std0**2
        # = (- t*v + mu0 - xt) / (1-t)*std0**2

        mu0 = self.mu0.get_value()
        std0 = self.std0.get_value()
        t = jnp.clip(t, 0, max_t)
        v = self.__call__(t, x)

        def score_leaf(leaf_x, leaf_v):
            return (-t * leaf_v + mu0 - leaf_x) / ((1 - t) * std0**2)

        return jax.tree_util.tree_map(score_leaf, x, v)

    def noise_schedule(
        self, rng: RngKey, shape: Tuple[int, ...], mu: float = 0.0, scale: float = 1.0
    ) -> Array:
        return jax.nn.sigmoid(jax.random.normal(rng, shape=shape + (1,)) * scale + mu)

Architectures

probjax.nn.MLP

Bases: Module

Multi-layer perceptron (MLP) module with configurable layers and activation.

Source code in probjax/nn/nets/simple.py
class MLP(nnx.Module):
    """Multi-layer perceptron (MLP) module with configurable layers and activation."""

    def __init__(
        self,
        feature_dims: Sequence[int],
        *,
        activation=jax.nn.gelu,
        activate_final: bool = False,
        context_dim: Optional[int] = None,
        # Accept alias used elsewhere in the codebase
        context_features: Optional[int] = None,
        precision: PrecisionLike | None = None,
        dtype: DTypeLike | None = None,
        param_dtype: DTypeLike | None = None,
        preferred_element_type: DTypeLike | None = None,
        norm_cls: ModuleLikeType | None = None,
        linear_cls: ModuleLikeType | Sequence[ModuleLikeType] = nnx.Linear,
        context_fuse_cls: ModuleLikeType = AffineFuse,
        rngs: nnx.Rngs,
        **kwargs,
    ):
        """Initialize MLP module.

        Args:
            dims: Sequence of layer dimensions. Length must be >= 2.
            rngs: Random number generators.
            linear: Linear layer module to use. Defaults to nnx.Linear.
            norm: Optional normalization layer. If provided, applied after
                each hidden layer (not output layer).
            activation: Activation function. Defaults to GELU.
            activate_final: Whether to apply activation to final layer output.
                Defaults to False.
            precision: Computation precision.
            dtype: Computation dtype.
            param_dtype: Parameter dtype.
            preferred_element_dtype: Preferred element dtype.
            **kwargs: Additional arguments passed to linear layers.

        Raises:
            ValueError: If dims has fewer than 2 elements or contains
                non-positive values.
        """
        if len(feature_dims) < 2:
            raise ValueError(
                f"dims must have at least 2 elements, got {len(feature_dims)}"
            )
        if any(dim <= 0 for dim in feature_dims):
            raise ValueError(f"All dimensions must be positive, got {feature_dims}")

        self.feature_dims = feature_dims
        # Prefer explicit context_dim, fallback to alias if provided
        self.context_dim = context_dim if context_dim is not None else context_features

        precision_kwargs = get_active_precision_kwargs(
            dtype, precision, param_dtype, preferred_element_type
        )
        num_layers = len(feature_dims) - 1
        if isinstance(linear_cls, Sequence) and not isinstance(linear_cls, type):
            if len(linear_cls) != num_layers:
                raise ValueError(
                    f"linear_cls sequence must have length {num_layers}, got {len(linear_cls)}"
                )
            base_linears = [
                partial(
                    lcls,
                    rngs=rngs,
                    **filter_precision_kwargs(lcls, **precision_kwargs),
                    **kwargs,
                )
                for lcls in linear_cls
            ]
        else:
            filtered = filter_precision_kwargs(linear_cls, **precision_kwargs)
            base_ctor = partial(linear_cls, rngs=rngs, **filtered, **kwargs)
            base_linears = [base_ctor for _ in range(num_layers)]

        layers = []
        norm_layers = []
        context_fuses = []
        _ctx_dim = self.context_dim
        for i in range(num_layers):
            ctor = base_linears[i]
            # Megatron-style alternation: column-parallel then row-parallel.
            if i % 2 == 0:
                sharding_kwargs = dict(
                    kernel_metadata=param_metadata(EMBED, HIDDEN),
                    bias_metadata=param_metadata(HIDDEN),
                )
            else:
                sharding_kwargs = dict(
                    kernel_metadata=param_metadata(HIDDEN, EMBED),
                    bias_metadata=param_metadata(EMBED),
                )
            sharding_kwargs = {k: v for k, v in sharding_kwargs.items() if v}
            layers.append(
                ctor(
                    feature_dims[i],
                    feature_dims[i + 1],
                    **filter_supported_kwargs(ctor, **sharding_kwargs),
                )
            )

            if norm_cls is not None and i < num_layers - 1:
                norm_layers.append(norm_cls(feature_dims[i + 1], rngs=rngs))
            if _ctx_dim is not None:
                context_fuses.append(
                    context_fuse_cls(feature_dims[i + 1], _ctx_dim, rngs=rngs)
                )

        self.layers = nnx.List(layers)
        self._layer_accepts_rng = tuple(
            module_accepts_rng(layer) for layer in self.layers
        )
        self.norm_layers = nnx.List(norm_layers) if norm_cls is not None else None
        self.context_fuses = nnx.List(context_fuses) if _ctx_dim is not None else None
        self._context_fuse_accepts_rng = (
            tuple(module_accepts_rng(fuse) for fuse in self.context_fuses)
            if self.context_fuses is not None
            else None
        )
        self.activation = activation
        self.activate_final = activate_final

    def __call__(
        self, x: Array, context: Array | None = None, *, rng: Array | None = None
    ) -> Array:
        """Forward pass through the MLP.

        Args:
            x: Input array of shape [..., input_dim].

        Returns:
            Output array of shape [..., output_dim].
        """
        h = (
            self.layers[0](x, rng=rng)
            if self._layer_accepts_rng[0]
            else self.layers[0](x)
        )
        h = self.activation(h)
        for i in range(1, len(self.layers) - 1):
            h = (
                self.layers[i](h, rng=rng)
                if self._layer_accepts_rng[i]
                else self.layers[i](h)
            )
            if self.norm_layers is not None:
                h = self.norm_layers[i - 1](h)
            h = self.activation(h)
            if self.context_fuses is not None:
                if (
                    self._context_fuse_accepts_rng is not None
                    and self._context_fuse_accepts_rng[i - 1]
                ):
                    h = self.context_fuses[i - 1](h, context, rng=rng)
                else:
                    h = self.context_fuses[i - 1](h, context)

        if len(self.layers) > 1:
            out = (
                self.layers[-1](h, rng=rng)
                if self._layer_accepts_rng[-1]
                else self.layers[-1](h)
            )
        else:
            out = h

        if self.activate_final:
            out = self.activation(out)
        return out

probjax.nn.ResNet

Bases: Module

Residual neural network with optional context conditioning.

Source code in probjax/nn/nets/simple.py
class ResNet(nnx.Module):
    """Residual neural network with optional context conditioning."""

    def __init__(
        self,
        in_features: int,
        out_features: int,
        *,
        hidden_dim: int = 50,
        num_hidden_layers: int = 2,
        context_dim: Optional[int] = None,
        activation=jax.nn.gelu,
        activate_final: bool = False,
        precision: PrecisionLike | None = None,
        dtype: DTypeLike | None = None,
        param_dtype: DTypeLike | None = None,
        preferred_element_type: DTypeLike | None = None,
        context_fuse_cls: ModuleLikeType = AffineFuse,
        norm_cls: ModuleLikeType | None = None,
        linear_cls: ModuleLikeType = nnx.Linear,
        rngs: nnx.Rngs,
        **kwargs,
    ):
        """Initialize ResNet module.

        Args:
            in_features: Input dimension.
            out_features: Output dimension.
            hidden_dim: Hidden layer dimension. Defaults to 50.
            num_hidden_layers: Number of hidden layers. Defaults to 2.
            context_dim: Optional context dimension for conditioning.
            activation: Activation function. Defaults to GELU.
            activate_final: Whether to apply activation to final layer output.
                Defaults to False.
            precision: Computation precision.
            dtype: Computation dtype.
            param_dtype: Parameter dtype.
            preferred_element_dtype: Preferred element dtype.
            linear_cls: Linear layer module to use. Defaults to nnx.Linear.
            context_fuse_cls: Context fusion module. Defaults to AffineFuse.
            norm_cls: Optional normalization layer.
            rngs: Random number generators.
            **kwargs: Additional arguments passed to linear layers.

        Raises:
            ValueError: If input/output dimensions or hidden dimensions
                are not positive.
        """
        if in_features <= 0:
            raise ValueError(f"in_dim must be positive, got {in_features}")
        if out_features <= 0:
            raise ValueError(f"out_dim must be positive, got {out_features}")
        if hidden_dim <= 0:
            raise ValueError(f"hidden_dim must be positive, got {hidden_dim}")
        if num_hidden_layers < 0:
            raise ValueError(
                f"num_hidden_layers must be non-negative, got {num_hidden_layers}"
            )

        self.in_dim = in_features
        self.out_dim = out_features
        if context_dim is not None and context_dim <= 0:
            raise ValueError(f"context_dim must be positive, got {context_dim}")
        self.context_dim = context_dim
        num_layers = num_hidden_layers + 2

        precision_kwargs = get_active_precision_kwargs(
            dtype, precision, param_dtype, preferred_element_type
        )
        precision_kwargs = filter_precision_kwargs(linear_cls, **precision_kwargs)
        base_ctor = partial(linear_cls, rngs=rngs, **precision_kwargs, **kwargs)

        # Column-parallel in, row-parallel out; hidden kernels stay replicated
        # so the residual additions keep a consistent activation sharding.
        in_sharding = {
            k: v
            for k, v in dict(
                kernel_metadata=param_metadata(EMBED, HIDDEN),
                bias_metadata=param_metadata(HIDDEN),
            ).items()
            if v
        }
        out_sharding = {
            k: v
            for k, v in dict(
                kernel_metadata=param_metadata(HIDDEN, EMBED),
                bias_metadata=param_metadata(EMBED),
            ).items()
            if v
        }

        hidden_layers = []
        norm_layers = []
        context_layers = []
        for i in range(num_layers):
            if i == 0:
                self.in_layer = base_ctor(
                    in_features,
                    hidden_dim,
                    **filter_supported_kwargs(base_ctor, **in_sharding),
                )
            elif i == num_layers - 1:
                self.out_layer = base_ctor(
                    hidden_dim,
                    out_features,
                    **filter_supported_kwargs(base_ctor, **out_sharding),
                )
            else:
                hidden_layers.append(base_ctor(hidden_dim, hidden_dim))

                if norm_cls is not None:
                    norm_layers.append(norm_cls(hidden_dim, rngs=rngs))
                if context_dim is not None:
                    context_layers.append(
                        context_fuse_cls(hidden_dim, context_dim, rngs=rngs)
                    )

        self.hidden_layers = nnx.List(hidden_layers)
        self._in_layer_accepts_rng = module_accepts_rng(self.in_layer)
        self._hidden_layer_accepts_rng = tuple(
            module_accepts_rng(layer) for layer in self.hidden_layers
        )
        self._out_layer_accepts_rng = module_accepts_rng(self.out_layer)
        self.norm_layers = nnx.List(norm_layers) if norm_cls is not None else None
        self.activation = activation
        self.activate_final = activate_final

        self.context_layers = (
            nnx.List(context_layers) if context_dim is not None else None
        )
        self._context_layer_accepts_rng = (
            tuple(module_accepts_rng(layer) for layer in self.context_layers)
            if self.context_layers is not None
            else None
        )

    def __call__(
        self,
        x: ArrayLike,
        context: Optional[ArrayLike] = None,
        *,
        rng: Array | None = None,
    ) -> Array:
        """Forward pass through the ResNet.

        Args:
            x: Input array of shape [..., in_dim].
            context: Optional context array of shape [..., context_dim].

        Returns:
            Output array of shape [..., out_dim].

        Raises:
            ValueError: If context is expected but not provided, or vice versa.
        """
        if self.context_dim is not None and context is None:
            raise ValueError("context is required when context_dim is specified")
        if self.context_dim is None and context is not None:
            raise ValueError("context provided but context_dim is None")

        h = (
            self.in_layer(x, rng=rng)
            if self._in_layer_accepts_rng
            else self.in_layer(x)
        )
        h = self.activation(h)
        for i in range(len(self.hidden_layers)):
            h_old = h
            if self.norm_layers is not None:
                h = self.norm_layers[i](h)
            h = (
                self.hidden_layers[i](h, rng=rng)
                if self._hidden_layer_accepts_rng[i]
                else self.hidden_layers[i](h)
            )
            h = self.activation(h)
            if self.context_layers is not None:
                if (
                    self._context_layer_accepts_rng is not None
                    and self._context_layer_accepts_rng[i]
                ):
                    h = self.context_layers[i](h, context, rng=rng)
                else:
                    h = self.context_layers[i](h, context)

            h = h + h_old

        if len(self.hidden_layers) > 0:
            out = (
                self.out_layer(h, rng=rng)
                if self._out_layer_accepts_rng
                else self.out_layer(h)
            )
        else:
            out = h

        if self.activate_final:
            out = self.activation(out)
        return out

probjax.nn.Transformer

Bases: Module

A transformer stack.

Source code in probjax/nn/nets/transformer.py
class Transformer(nnx.Module):
    """A transformer stack."""

    model_dim: int  # Dimensionality of the embedding vectors.
    num_heads: int  # Number of attention heads.
    num_layers: int  # Number of transformer (attention + MLP) layers to stack.
    attn_size: int  # Size of the attention (key, query, value) vectors.
    dropout_rate: float  # Probability with which to apply dropout.
    drop_path_rates: Sequence[float]  # Drop-path rate(s) per layer.
    widening_factor: int = 4  # Factor by which the MLP hidden layer widens.

    def __init__(
        self,
        model_dim: int,
        num_heads: int,
        num_layers: int,
        attn_size: int,
        *,
        enable_cross_attention: bool = False,
        kv_in_features: Optional[int] = None,
        normalize_qk_attn: bool = False,
        normalize_qk_cross_attn: bool = False,
        context_dim: Optional[int] = None,
        dropout_rate: float = 0.0,
        dropout_rate_attn: float | None = None,
        drop_path_rate: float | Sequence[float] = 0.0,
        widening_factor: int = 4,
        num_hidden_layers: int = 1,
        act: Callable = jax.nn.gelu,
        attention_fn: Optional[Callable] = None,
        cross_attention_fn: Optional[Callable] = None,
        initializer: Optional[nnx.Initializer] = None,
        dtype: DTypeLike | None = None,
        param_dtype: DTypeLike | None = None,
        precision: PrecisionLike | None = None,
        preferred_element_type: DTypeLike | None = None,
        norm_cls: ModuleLikeType = nnx.LayerNorm,
        context_fusion_cls: ModuleLikeType = AffineFuse,
        attn_fuse_cls: ModuleLikeType = AdditiveBinaryFuse,
        mlp_fuse_cls: ModuleLikeType = AdditiveBinaryFuse,
        mlp_cls: ModuleLikeType = MLP,
        mha_cls: ModuleLikeType = MultiHeadAttention,
        rngs: nnx.Rngs,
    ):
        """Initialize a Transformer model.
        Args:
            model_dim (int): The dimension of the model's hidden states.
            num_heads (int): Number of attention heads.
            num_layers (int): Number of transformer layers.
            attn_size (int): Size of each attention head.
            rngs (nnx.Rngs): Random number generator state.
            context_dim (Optional[int], optional): Dimension of additional context to be
                concatenated with transformer output. If None, no context is used.
                Defaults to None.
            dropout_rate (float, optional): Dropout rate. If 0.0, dropout is disabled.
                Defaults to 0.0.
            dropout_rate_attn (float | None, optional): Dropout rate for attention if
                None, uses `dropout_rate`.
            drop_path_rate (float | Sequence[float], optional): Drop-path rate(s) per
                layer. Provide a single float to apply uniformly, or a sequence of
                length `num_layers`. Defaults to 0.0.
            widening_factor (int, optional): Factor by which to increase the dimension
                in the MLP. Defaults to 4.
            num_hidden_layers (int, optional): Number of hidden layers in the MLP block.
                Defaults to 1.
            act (Callable, optional): Activation function. Defaults to jax.nn.gelu.
            attention_fn (Optional[Callable], optional): Custom attention function.
                If None, uses dot product attention. Defaults to None.
            cross_attention_fn (Optional[Callable], optional): Custom cross attention
                function. Defaults to dot product attention.
            attn_fuse_cls: Binary fusion module for residual connections in the
                attention block. Use None to disable the residual path. Defaults to
                AdditiveBinaryFuse which reproduces a standard residual add.
            mlp_fuse_cls: Binary fusion module for residual connections in the MLP
                block. Use None to disable the residual path. Defaults to
                AdditiveBinaryFuse.
            initializer (Optional[nnx.initializers.Initializer], optional): Weight
                initializer. If None, uses truncated normal with variance scaling.
                Defaults to None.
        """
        super().__init__()
        self.model_dim = model_dim
        self.context_dim = context_dim
        self.num_heads = num_heads
        self.num_layers = num_layers
        self.attn_size = attn_size
        self.dropout_rate = dropout_rate
        self.dropout_rate_attn = (
            dropout_rate_attn if dropout_rate_attn is not None else dropout_rate
        )
        if isinstance(drop_path_rate, Sequence) and not isinstance(
            drop_path_rate, (str, bytes)
        ):
            if len(drop_path_rate) != num_layers:
                raise ValueError(
                    "drop_path_rate sequence length must match num_layers "
                    f"({num_layers}), got {len(drop_path_rate)}."
                )
            drop_path_rates = [float(x) for x in drop_path_rate]
        else:
            drop_path_rates = [float(drop_path_rate)] * num_layers

        self.drop_path_rates = drop_path_rates
        self.initializer = (
            nnx.initializers.variance_scaling(
                2 / self.num_layers, 'fan_in', 'truncated_normal'
            )
            if initializer is None
            else initializer
        )
        self.act = act
        self.enable_cross_attention = enable_cross_attention
        # Precision and dtype settings.
        precision_kwargs = get_active_precision_kwargs(
            dtype,
            precision,
            param_dtype,
            preferred_element_type,
        )

        # Norm layers.
        self.layer_norms_attn = nnx.List([
            norm_cls(model_dim, rngs=rngs) for _ in range(num_layers)
        ])
        self.layer_norms_dense = nnx.List([
            norm_cls(model_dim, rngs=rngs) for _ in range(num_layers)
        ])

        if self.enable_cross_attention:
            self.layer_norms_cross_attn = nnx.List([
                norm_cls(model_dim, rngs=rngs) for _ in range(num_layers)
            ])

        # Attention block.
        attention_fn = (
            attention_fn if attention_fn is not None else dot_product_attention
        )
        self.attention_blocks = nnx.List([
            mha_cls(
                num_heads=num_heads,
                in_features=model_dim,
                qkv_features=attn_size * num_heads,
                out_features=model_dim,
                rngs=rngs,
                kernel_init=self.initializer,
                dropout_rate=self.dropout_rate_attn,
                attention_fn=attention_fn,
                normalize_qk=normalize_qk_attn,
                **filter_precision_kwargs(mha_cls, **precision_kwargs),
            )
            for _ in range(num_layers)
        ])

        if self.enable_cross_attention:
            cross_attention_fn = (
                cross_attention_fn
                if cross_attention_fn is not None
                else dot_product_attention
            )
            self.cross_attention_blocks = nnx.List([
                mha_cls(
                    num_heads=num_heads,
                    in_features=model_dim,
                    qkv_features=attn_size * num_heads,
                    out_features=model_dim,
                    in_kv_features=kv_in_features,
                    rngs=rngs,
                    kernel_init=self.initializer,
                    dropout_rate=self.dropout_rate_attn,
                    attention_fn=cross_attention_fn,
                    normalize_qk=normalize_qk_cross_attn,
                    **filter_precision_kwargs(mha_cls, **precision_kwargs),
                )
                for _ in range(num_layers)
            ])

        # Context fusion if context is provided.
        if context_dim is not None:
            self.context_layers1 = nnx.List([
                context_fusion_cls(
                    model_dim,
                    context_dim,
                    rngs=rngs,
                )
                for _ in range(num_layers)
            ])
            self.context_layers2 = nnx.List([
                context_fusion_cls(
                    model_dim,
                    context_dim,
                    rngs=rngs,
                )
                for _ in range(num_layers)
            ])

        # Dense block.
        dims = (
            [model_dim]
            + [widening_factor * model_dim] * num_hidden_layers
            + [model_dim]
        )
        linear = partial(nnx.Linear, kernel_init=self.initializer)
        self.dense_blocks = nnx.List([
            mlp_cls(
                dims,
                rngs=rngs,
                linear_cls=linear,
                activation=act,
                # activate_final=True,
                **filter_precision_kwargs(mlp_cls, **precision_kwargs),
            )
            for _ in range(num_layers)
        ])
        if dropout_rate > 0.0:
            self.dropout_dense = nnx.List([
                nnx.Dropout(rate=dropout_rate, rngs=rngs) for _ in range(num_layers)
            ])
        else:
            self.dropout_dense = None

        # Skip connection fusers.
        self.attn_skip_fuse = nnx.List([])
        self.mlp_skip_fuse = nnx.List([])
        if enable_cross_attention:
            self.cross_skip_fuse = nnx.List([])
        for num_layer in range(num_layers):
            self.attn_skip_fuse.append(
                attn_fuse_cls(
                    model_dim,
                    context_dim,
                    drop_path_rate=drop_path_rates[num_layer],
                    rngs=rngs,
                    **filter_precision_kwargs(attn_fuse_cls, **precision_kwargs),
                )
            )
            self.mlp_skip_fuse.append(
                mlp_fuse_cls(
                    model_dim,
                    context_dim,
                    drop_path_rate=drop_path_rates[num_layer],
                    rngs=rngs,
                    **filter_precision_kwargs(mlp_fuse_cls, **precision_kwargs),
                )
            )
            if self.enable_cross_attention:
                self.cross_skip_fuse.append(
                    attn_fuse_cls(
                        model_dim,
                        context_dim,
                        rngs=rngs,
                        drop_path_rate=drop_path_rates[num_layer],
                        **filter_precision_kwargs(attn_fuse_cls, **precision_kwargs),
                    )
                )

    def __call__(
        self,
        q: Array,  # [B, T, D]
        k: Optional[Array] = None,  # [B, T', D]
        v: Optional[Array] = None,  # [B, T', D]
        context: Optional[Array] = None,  # [B, D_context]
        mask: AttentionMask | Array | None = None,
        mask_cross: AttentionMask | Array | None = None,
        bias: AttentionBias | Array | None = None,
        bias_cross: AttentionBias | Array | None = None,
        deterministic: bool | None = None,
        decode: bool = False,
        kv_len: int | Array | None = None,
        rng: jax.Array | None = None,
    ) -> Array:  # [B, T, D]
        """Transforms input embedding sequences to output embedding sequences."""

        # Normalize masks/bias to broadcastable shapes
        if isinstance(mask, jax.Array):
            mask = normalize_attn_mask(mask)
        if isinstance(mask_cross, jax.Array):
            mask_cross = normalize_attn_mask(mask_cross)
        if isinstance(bias, jax.Array):
            bias = normalize_attn_bias(bias)
        if isinstance(bias_cross, jax.Array):
            bias_cross = normalize_attn_bias(bias_cross)

        # Flatten to (B, T, D)
        q, q_shape = flatten_to_btd(q)
        k, _ = flatten_to_btd(k) if k is not None else (None, None)
        v, _ = flatten_to_btd(v) if v is not None else (None, None)

        q = constrain(q, BATCH)

        # Ensure context has shape [B, 1, Dc] when provided
        if context is not None:
            context = context.reshape(-1, 1, context.shape[-1])

        if k is not None and not self.enable_cross_attention:
            raise ValueError("Cross attention is disabled, but k is provided.")
        if v is not None and not self.enable_cross_attention:
            raise ValueError("Cross attention is disabled, but v is provided.")

        for i in range(self.num_layers):
            # First the attention block.
            q_res = q
            q = self.layer_norms_attn[i](q)
            if context is not None and self.context_dim is not None:
                q = self.context_layers1[i](q, context, rng=rng)
            q = self.attention_blocks[i](
                q,
                mask=mask,
                bias=bias,
                deterministic=deterministic,
                decode=decode,
                kv_len=kv_len,
                rng=rng,
            )
            q = constrain(q, BATCH, SEQ, EMBED)
            q = self.attn_skip_fuse[i](
                q_res, q, context=context, deterministic=deterministic, rng=rng
            )

            # Then cross attention if wanted
            if self.enable_cross_attention:
                q_res = q
                q = self.layer_norms_cross_attn[i](q)
                q = self.cross_attention_blocks[i](
                    q,
                    k,
                    v,
                    mask=mask_cross,
                    bias=bias_cross,
                    deterministic=deterministic,
                    decode=False,
                    rng=rng,
                )
                q = constrain(q, BATCH, SEQ, EMBED)
                q = self.cross_skip_fuse[i](
                    q_res, q, context=context, deterministic=deterministic, rng=rng
                )

            # Then the dense block and global context.
            q_res = q
            q = self.layer_norms_dense[i](q)
            if context is not None and self.context_dim is not None:
                q = self.context_layers2[i](q, context, rng=rng)

            q = self.dense_blocks[i](q, rng=rng)
            q = constrain(q, BATCH, SEQ, EMBED)
            if self.dropout_dense is not None:
                q = self.dropout_dense[i](q, deterministic=deterministic, rngs=rng)
            q = self.mlp_skip_fuse[i](
                q_res,
                q,
                context=context,
                deterministic=deterministic,
                rng=rng,
            )

        return restore_from_btd(q, q_shape)

probjax.nn.UNet

Bases: Module

Flexible U-Net with pluggable submodules and per-layer drop-path.

Pluggable builders: - ResNet blocks (default: ResnetBlock) - Downsampling convolutions (default: nnx.Conv) - Upsampling convolutions (default: nnx.ConvTranspose) - Spatial attention blocks (default: SpatialSelfAttention)

Required constructor signatures for swappable modules: - resnet_block_cls: Callable[..., nnx.Module] init(in_features: int, out_features: int, *, kernel_size, strides, context_features=None, dropout_rate=0.0, drop_path_rate=0.0, rngs: nnx.Rngs, ...) call(x, context=None, *, deterministic: bool = True) -> Array

  • conv_down_cls / conv_up_cls: Callable[..., nnx.Module] init(in_features: int, out_features: int, *, kernel_size, strides, rngs: nnx.Rngs, ...) call(x) -> Array

  • attn_cls: Callable[..., nnx.Module] init(features: int, *, dropout_rate=0.0, rngs: nnx.Rngs, ...) call(x, context=None, *, deterministic: bool = True) -> Array

  • conv_cls (1x1 projections): Callable[..., nnx.Module] init(in_features: int, out_features: int, *, kernel_size=1, use_bias: bool, rngs: nnx.Rngs, ...) must accept kernel_init as kwarg for final layer init.

All builders should accept standard precision/dtype kwargs as applicable: dtype, precision, param_dtype, preferred_element_type (some may be filtered by filter_precision_kwargs).

Drop-path configuration: - drop_path_rate: float applied uniformly to all ResNet blocks, or a sequence of length (2 * num_stages + 2). Layer indices are: [0..num_stages-1] Down path ResNet blocks [num_stages] Middle block 1 [num_stages+1] Middle block 2 [num_stages+2 .. end] Up path ResNet blocks (from bottom to top) If a sequence is provided, it overrides the uniform rate.

Source code in probjax/nn/nets/unets.py
class UNet(nnx.Module):
    """Flexible U-Net with pluggable submodules and per-layer drop-path.

    Pluggable builders:
    - ResNet blocks (default: ResnetBlock)
    - Downsampling convolutions (default: nnx.Conv)
    - Upsampling convolutions (default: nnx.ConvTranspose)
    - Spatial attention blocks (default: SpatialSelfAttention)

    Required constructor signatures for swappable modules:
    - resnet_block_cls: Callable[..., nnx.Module]
      __init__(in_features: int, out_features: int,
               *, kernel_size, strides,
               context_features=None, dropout_rate=0.0,
               drop_path_rate=0.0, rngs: nnx.Rngs, ...)
      __call__(x, context=None, *, deterministic: bool = True) -> Array

    - conv_down_cls / conv_up_cls: Callable[..., nnx.Module]
      __init__(in_features: int, out_features: int,
               *, kernel_size, strides, rngs: nnx.Rngs, ...)
      __call__(x) -> Array

    - attn_cls: Callable[..., nnx.Module]
      __init__(features: int, *, dropout_rate=0.0, rngs: nnx.Rngs, ...)
      __call__(x, context=None, *, deterministic: bool = True) -> Array

    - conv_cls (1x1 projections): Callable[..., nnx.Module]
      __init__(in_features: int, out_features: int,
               *, kernel_size=1, use_bias: bool, rngs: nnx.Rngs, ...)
      must accept `kernel_init` as kwarg for final layer init.

    All builders should accept standard precision/dtype kwargs as applicable:
    `dtype`, `precision`, `param_dtype`, `preferred_element_type` (some may be
    filtered by `filter_precision_kwargs`).

    Drop-path configuration:
    - drop_path_rate: float applied uniformly to all ResNet blocks, or a
      sequence of length (2 * num_stages + 2). Layer indices are:
        [0..num_stages-1]         Down path ResNet blocks
        [num_stages]              Middle block 1
        [num_stages+1]            Middle block 2
        [num_stages+2 .. end]     Up path ResNet blocks (from bottom to top)
    If a sequence is provided, it overrides the uniform rate.
    """

    def __init__(
        self,
        in_features: int,
        out_features: Sequence[int],
        *,
        # --- shape/behavior ---
        kernel_size: int | Sequence[int] = 4,
        strides: int | Sequence[int] = 2,
        kernel_size_resnet: int | Sequence[int] = 3,
        strides_resnet: int | Sequence[int] = 1,
        use_attention: bool | Sequence[bool] = False,
        context_features: int | None = None,
        resize_method: str = "bilinear",
        dropout_rate: float = 0.0,
        drop_path_rate: float | Sequence[float] = 0.0,
        # --- initialization/precision ---
        precision: PrecisionLike | None = None,
        dtype: jnp.dtype | None = None,
        param_dtype: jnp.dtype | None = None,
        preferred_element_type: jnp.dtype | None = None,
        # --- pluggable builders: classes ---
        resnet_block_cls: ModuleLikeType = ResnetBlock,
        conv_down_cls: ModuleLikeType | Sequence[ModuleLikeType] = nnx.Conv,
        conv_up_cls: ModuleLikeType | Sequence[ModuleLikeType] = nnx.ConvTranspose,
        attn_cls: ModuleLikeType = SpatialSelfAttention,
        conv_cls: ModuleLikeType = nnx.Conv,
        rngs: nnx.Rngs,
    ):
        assert len(out_features) >= 2, "Must have at least 2 output channels"

        self.in_features = in_features
        self.out_features = list(out_features)
        self.num_stages = len(out_features)
        self.resize_method = resize_method  # Triggered if user shapes do not mat
        self.preferred_element_type = preferred_element_type
        precision_kwargs = get_active_precision_kwargs(
            dtype,
            precision,
            param_dtype,
            preferred_element_type,
        )

        # Normalize/validate attention mask
        if isinstance(use_attention, Sequence):
            assert len(use_attention) == self.num_stages, (
                "`use_attention` list must match number of stages"
            )
            self.attn_mask = list(bool(x) for x in use_attention)
            self.use_attention = any(self.attn_mask)
        else:
            self.attn_mask = [bool(use_attention)] * self.num_stages
            self.use_attention = bool(use_attention)

        # ---------------------------------------------------------------------
        # Builder helpers
        # ---------------------------------------------------------------------
        # ResNet block builder
        _resnet_block = partial(
            resnet_block_cls,
            kernel_size=kernel_size_resnet,
            strides=strides_resnet,
            context_features=context_features,
            dropout_rate=dropout_rate,
            rngs=rngs,
            **filter_precision_kwargs(resnet_block_cls, **precision_kwargs),
        )

        # Build per-layer drop-path rate list
        total_blocks = 2 * self.num_stages + 2
        if isinstance(drop_path_rate, Sequence) and not isinstance(
            drop_path_rate, (str, bytes)
        ):
            if len(drop_path_rate) != total_blocks:
                raise ValueError(
                    f"drop_path_rate sequence length must be {total_blocks}, got {len(drop_path_rate)}"
                )
            dpr_list = [float(x) for x in drop_path_rate]
        else:
            dpr_list = [float(drop_path_rate)] * total_blocks

        _down_blocks = nnx.List()
        for i in range(self.num_stages - 1):
            if isinstance(conv_down_cls, Sequence):
                conv_down_cls_i = conv_down_cls[i]
            else:
                conv_down_cls_i = conv_down_cls

            _down_blocks.append(
                partial(
                    conv_down_cls_i,
                    kernel_size=kernel_size,
                    strides=strides,
                    rngs=rngs,
                    **filter_precision_kwargs(conv_down_cls_i, **precision_kwargs),
                )
            )

        _up_blocks = nnx.List()
        for i in range(self.num_stages - 1):
            if isinstance(conv_up_cls, Sequence):
                conv_up_cls_i = conv_up_cls[i]
            else:
                conv_up_cls_i = conv_up_cls
            _up_blocks.append(
                partial(
                    conv_up_cls_i,
                    kernel_size=kernel_size,
                    strides=strides,
                    rngs=rngs,
                    **filter_precision_kwargs(conv_up_cls_i, **precision_kwargs),
                )
            )

        # Initial/final projection builders
        _init_final = partial(
            conv_cls,
            kernel_size=1,
            use_bias=False,
            rngs=rngs,
            **filter_precision_kwargs(conv_cls, **precision_kwargs),
        )

        _attn_block = partial(
            attn_cls,
            dropout_rate=dropout_rate,
            rngs=rngs,
            **filter_precision_kwargs(attn_cls, **precision_kwargs),
        )

        # -----------------------------------------------------------------
        # Down path
        # -----------------------------------------------------------------
        self.resnet_blocks_down = nnx.List()
        self.downsampling_layers = nnx.List()
        self.att_layers_down = nnx.List()

        layer_idx = 0
        for i in range(self.num_stages):
            # ResNet in each stage works on out_features[i]
            self.resnet_blocks_down.append(
                _resnet_block(
                    self.out_features[i],
                    self.out_features[i],
                    drop_path_rate=dpr_list[layer_idx],
                )
            )
            layer_idx += 1
            # Optional attention for this stage
            if self.attn_mask[i]:
                self.att_layers_down.append(
                    _attn_block(
                        self.out_features[i], drop_path_rate=dpr_list[layer_idx]
                    )
                )
            else:
                self.att_layers_down.append(None)

            # Insert a downsample conv between stages (0->1, 1->2, ...)
            if i > 0:
                self.downsampling_layers.append(
                    _down_blocks[i - 1](self.out_features[i - 1], self.out_features[i])
                )

        # -----------------------------------------------------------------
        # Middle block
        # -----------------------------------------------------------------
        top_ch = self.out_features[-1]
        self.middle_block1 = _resnet_block(
            top_ch, top_ch, drop_path_rate=dpr_list[layer_idx]
        )
        layer_idx += 1
        self.middle_block2 = _resnet_block(
            top_ch, top_ch, drop_path_rate=dpr_list[layer_idx]
        )
        layer_idx += 1
        self.att_middle = (
            _attn_block(top_ch, drop_path_rate=dpr_list[layer_idx])
            if self.attn_mask[-1]
            else None
        )

        # -----------------------------------------------------------------
        # Up path (mirror of down)
        # -----------------------------------------------------------------
        self.resnet_blocks_up = nnx.List()
        self.att_layers_up = nnx.List()
        self.upsampling_layers = nnx.List()

        for i in reversed(range(self.num_stages)):
            ch = self.out_features[i]

            # ResNet block after concatenating skip connection
            self.resnet_blocks_up.append(
                _resnet_block(ch * 2, ch, drop_path_rate=dpr_list[layer_idx])
            )
            layer_idx += 1

            # Attention layer (mirroring down path)
            if self.attn_mask[self.num_stages - i - 1]:
                self.att_layers_up.append(
                    _attn_block(ch, drop_path_rate=dpr_list[layer_idx])
                )
            else:
                self.att_layers_up.append(None)

            # Upsample conv (skip for bottom-most stage)
            if i > 0:
                self.upsampling_layers.append(
                    _up_blocks[i - 1](ch, self.out_features[i - 1])
                )

        # Initial and final 1x1 projections
        self.conv_initial = _init_final(in_features, self.out_features[0])
        self.conv_final = _init_final(
            self.out_features[0] * 2,
            in_features,
            kernel_init=nnx.initializers.zeros,
        )
        self._conv_initial_accepts_rng = module_accepts_rng(self.conv_initial)
        self._conv_final_accepts_rng = module_accepts_rng(self.conv_final)
        self._downsampling_accepts_rng = tuple(
            module_accepts_rng(layer) for layer in self.downsampling_layers
        )
        self._upsampling_accepts_rng = tuple(
            module_accepts_rng(layer) for layer in self.upsampling_layers
        )

    def __call__(
        self,
        inputs: Array,
        context: Optional[Array] = None,
        verbose: bool = False,
        deterministic: bool = True,
        rng: jax.Array | None = None,
    ) -> Array:
        def _constrain(x: Array) -> Array:
            return constrain(x, BATCH)

        # 1) Initial projection
        x = (
            self.conv_initial(inputs, rng=rng)
            if self._conv_initial_accepts_rng
            else self.conv_initial(inputs)
        )
        x = _constrain(x)

        # Stash features before each downsample for skips
        pre_downsampling = [x]

        # 2) Down path
        for i in range(self.num_stages):
            x = self.resnet_blocks_down[i](
                x, context, deterministic=deterministic, rng=rng
            )
            if self.att_layers_down[i] is not None:
                x = self.att_layers_down[i](
                    x, context, deterministic=deterministic, rng=rng
                )
            x = _constrain(x)
            pre_downsampling.append(x)
            if i < self.num_stages - 1:
                if verbose:
                    print("Down:", x.shape)
                x = (
                    self.downsampling_layers[i](x, rng=rng)
                    if self._downsampling_accepts_rng[i]
                    else self.downsampling_layers[i](x)
                ).astype(self.preferred_element_type)
                x = _constrain(x)

        # 3) Middle
        x = self.middle_block1(x, context, deterministic=deterministic, rng=rng)
        if self.att_middle is not None:
            x = self.att_middle(x, context, deterministic=deterministic, rng=rng)
        x = self.middle_block2(x, context, deterministic=deterministic, rng=rng)
        x = _constrain(x)
        if verbose:
            print("Mid:", x.shape)

        # 4) Up path
        for idx in range(self.num_stages):
            down = pre_downsampling.pop()
            # Ensure spatial match (covers odd sizes / stride combos)
            if x.shape != down.shape:
                x = jax.image.resize(x, down.shape, method=self.resize_method)
            x = jnp.concatenate([down, x], axis=-1)

            x = self.resnet_blocks_up[idx](
                x, context, deterministic=deterministic, rng=rng
            )
            if self.att_layers_up[idx] is not None:
                x = self.att_layers_up[idx](
                    x, context, deterministic=deterministic, rng=rng
                )
            x = _constrain(x)

            if idx < self.num_stages - 1:
                x = (
                    self.upsampling_layers[idx](x, rng=rng)
                    if self._upsampling_accepts_rng[idx]
                    else self.upsampling_layers[idx](x)
                ).astype(self.preferred_element_type)
                x = _constrain(x)
                if verbose:
                    print("Up:", x.shape)

        # 5) Final projection (+ last skip from very beginning)
        pre_in = pre_downsampling.pop()
        x = jnp.concatenate([pre_in, x], axis=-1)
        x = (
            self.conv_final(x, rng=rng)
            if self._conv_final_accepts_rng
            else self.conv_final(x)
        )
        x = _constrain(x)

        return x

probjax.nn.DeepSet

Bases: Module

Deep Sets module for permutation-invariant functions.

Implements the Deep Sets architecture that processes sets of elements in a permutation-invariant manner using the formula: f(X) = ρ(Σ φ(x_i)) where X = {x_1, ..., x_n}

Source code in probjax/nn/nets/simple.py
class DeepSet(nnx.Module):
    """Deep Sets module for permutation-invariant functions.

    Implements the Deep Sets architecture that processes sets of elements
    in a permutation-invariant manner using the formula:
    f(X) = ρ(Σ φ(x_i)) where X = {x_1, ..., x_n}
    """

    def __init__(
        self,
        phi: ModuleLike,
        rho: ModuleLike,
        *,
        reduction: Callable = jnp.sum,
        axis: tuple[int] | int = -2,
        dropout_rate: float = 0.0,
        rngs: nnx.Rngs,
    ):
        """Initialize the DeepSets module.

        The only requirement is that both `phi` and `rho` accept the input
        arrays as their first argument.

        Args:
            phi: Neural network module or callable function that processes
                individual elements of the input set.
            rho: Neural network module or callable function that processes
                the aggregated output of phi.
            reduction: Reduction function to aggregate the outputs of phi.
                Defaults to jnp.sum.
            axis: Axis along which to apply the reduction. Defaults to -2.
            dropout_rate: Dropout rate applied after phi and before aggregation.
                Must be between 0.0 and 1.0. Defaults to 0.0 (no dropout).
            rngs: Random number generators.

        Raises:
            ValueError: If phi or rho are None, or if dropout_rate is invalid.
        """
        if not (0.0 <= dropout_rate <= 1.0):
            raise ValueError(
                f"dropout_rate must be between 0.0 and 1.0, got {dropout_rate}"
            )

        self.phi = phi
        self.rho = rho
        self.reduction = reduction
        self.axis = axis
        self.dropout_rate = dropout_rate

        if dropout_rate > 0.0:
            self.dropout = nnx.Dropout(rate=dropout_rate, rngs=rngs)
        else:
            self.dropout = None

    def __call__(
        self,
        x: PyTree[ArrayLike],
        *,
        deterministic: bool = True,
        rng: Array | None = None,
        phi_args: Optional[tuple] = None,
        rho_args: Optional[tuple] = None,
        phi_kwargs: Optional[dict] = None,
        rho_kwargs: Optional[dict] = None,
    ) -> Array:
        """Apply the Deep Sets computation.

        Args:
            x: Input array to be processed by phi.
            phi_kwargs: Additional keyword arguments to pass to the phi module.
                Defaults to None.
            rho_kwargs: Additional keyword arguments to pass to the rho module.
                Defaults to None.

        Returns:
            Output array after applying phi, dropout (if enabled), reduction, and rho.
        """
        phi_args = phi_args if phi_args is not None else ()
        rho_args = rho_args if rho_args is not None else ()
        phi_kwargs = phi_kwargs if phi_kwargs is not None else {}
        rho_kwargs = rho_kwargs if rho_kwargs is not None else {}
        if rng is not None:
            phi_kwargs.setdefault("rng", rng)
            rho_kwargs.setdefault("rng", rng)
        # Apply phi to each element
        phi_x = self.phi(x, *phi_args, **phi_kwargs)

        # Apply dropout if enabled
        if self.dropout is not None:
            phi_x = self.dropout(phi_x, deterministic=deterministic, rngs=rng)

        # Aggregate
        h = self.reduction(phi_x, axis=self.axis)

        # Apply rho
        return self.rho(h, **(rho_kwargs if rho_kwargs is not None else {}))