Source code for gensbi.recipes.flow_pipeline

"""NPE pipeline for discrete normalizing flows (parallel track).

The flow IS the density model: no ``ConditionalWrapper``, no ``GenerativeMethod``.
Trains ``q(obs | cond)`` by max-likelihood. NPE convention: ``obs = theta``,
``cond = x`` (mirrors ``ConditionalPipeline`` so the diagnostics run unchanged).
"""

import warnings

import jax
import jax.numpy as jnp
from tqdm.auto import tqdm

from gensbi.recipes.pipeline import AbstractPipeline, _chunked_draw, _validate_chunk_size
from gensbi.recipes.utils import _require_channel, _single_obs


[docs] def _warn_unused_kwargs(kwargs): """Warn that solver-style kwargs are ignored by the (solver-free) flow. The flow pipeline mirrors the flow-matching surface (which accepts ``**sampler_kwargs``), but a normalizing flow has no ODE/SDE solver, so arguments like ``step_size``/``nsteps``/``solver`` do not apply and are silently ignored apart from this warning. """ if kwargs: keys = ", ".join(sorted(kwargs)) warnings.warn( f"flow pipeline ignores unsupported keyword argument(s): {keys}. " "A normalizing flow has no solver, so these have no effect.", UserWarning, stacklevel=3, )
[docs] class ConditionalFlowPipeline(AbstractPipeline): """Max-likelihood NPE pipeline wrapping an ``MAFlow``. Parameters ---------- model : MAFlow A pre-built flow (e.g. ``MAFlow(MAFlowParams(rngs=rngs, dim=dim_obs, cond_dim=dim_cond))``). train_dataset, val_dataset : iterable Yield ``(obs, cond)`` batches. Shape is ``(B, dim, C)`` for each variable (``C = 1`` for the tabular path; see ``ch_obs``/``ch_cond``). dim_obs, dim_cond : int ch_obs, ch_cond : int, optional Channel count for the obs and cond variables. Default 1 (tabular SBI). Values > 1 enable the ``(B, dim, C)`` channel-passthrough path: the channel axis is preserved and forwarded to the flow unchanged (the flow must be built with matching ``channels``/``cond_channels`` in :class:`~gensbi.models.MAFlowParams`). structured_obs, structured_cond : bool, optional If ``True``, the modeled variable / condition keeps its native structured shape (the model owns it) instead of the tabular ``(B, dim, 1)`` layout. Default ``False``. Notes ----- Every single-observation method (:meth:`sample`, :meth:`log_prob`, :meth:`get_sampler`, :meth:`get_log_prob_fn`) expects ``x_o`` to carry a **leading batch axis** (size 1 for one observation) **and a channel axis**: shape ``(1, dim_cond, C)`` for tabular, or ``(1,) + per_obs_shape`` for structured. A bare ``(B, dim)`` tensor is rejected — add ``[..., None]`` for ``C = 1``. A batch axis > 1 raises ``ValueError`` — pass a batch to :meth:`sample_batched` instead. """ def __init__(self, model, train_dataset, val_dataset, dim_obs, dim_cond, ch_obs=1, ch_cond=1, params=None, training_config=None, structured_obs=False, structured_cond=False): super().__init__( model, train_dataset, val_dataset, dim_obs, dim_cond, ch_obs=ch_obs, ch_cond=ch_cond, params=params, training_config=training_config)
[docs] self._standardized = False
[docs] self.structured_obs = structured_obs
[docs] self.structured_cond = structured_cond
[docs] def _prep_obs(self, x): x = jnp.asarray(x) return x if self.structured_obs else _require_channel(x, "obs")
[docs] def _prep_cond(self, x): x = jnp.asarray(x) return x if self.structured_cond else _require_channel(x, "cond")
# --- abstract methods the flow pipeline does not use (mirror ConditionalPipeline) --- @classmethod
[docs] def init_pipeline_from_config(cls, *args, **kwargs): """Not implemented: the flow pipeline requires a pre-built model. Raises ------ NotImplementedError Always. Construct an ``MAFlow`` and pass it as ``model=`` to the pipeline constructor instead. """ raise NotImplementedError( "ConditionalFlowPipeline takes a pre-built flow; build a `MAFlow` " "and pass it as model=.")
[docs] def _make_model(self, params): raise NotImplementedError( "Pass a pre-built MAFlow as model=; the flow pipeline does not build " "models from params.")
@classmethod
[docs] def get_default_params(cls, *args, **kwargs): """Not implemented: the flow pipeline takes a pre-built ``MAFlow``. Raises ------ NotImplementedError Always. There are no default model params to return; construct an ``MAFlow`` directly and pass it as ``model=``. """ raise NotImplementedError( "ConditionalFlowPipeline takes a pre-built MAFlow; there are no model " "params to default.")
# --- the flow IS the model: no wrapper ---
[docs] def _wrap_model(self): self.model_wrapped = self.model self.ema_model_wrapped = self.ema_model
# --- methods implemented in later tasks (Tasks 3, 5, 6) ---
[docs] def get_loss_fn(self): """Return the max-likelihood loss function for training. Returns a closure ``loss_fn(model, batch, key) -> Array`` that computes the mean negative log-likelihood ``-mean(log q(obs | cond))``. ``batch = (obs, cond)`` with each element of shape ``(B, dim, 1)``. NPE convention: ``obs = theta``, ``cond = x``. The ``key`` argument is accepted for interface compatibility but is unused. Returns ------- loss_fn : Callable A function ``(model, batch, key) -> Array`` returning the scalar mean negative log-likelihood. """ def loss_fn(model, batch, key): obs, cond = batch obs = self._prep_obs(obs) cond = self._prep_cond(cond) # Loss is always computed in fp32 regardless of the model's # compute dtype (defense-in-depth on top of the models-emit-fp32 # contract). return jnp.asarray(-jnp.mean(model.log_prob(obs, cond)), jnp.float32) return loss_fn
[docs] def fit_standardization(self, obs_data, axis=0): """Fit the Standardize bijection buffers from training observations. Computes per-dimension mean and standard deviation of ``obs_data`` and stores them as buffers on both the live model and the EMA model. EMA only averages ``Param`` variables, so the non-Param buffers must be set explicitly here. Must be called before :meth:`train` when input standardization is desired. Parameters ---------- obs_data : Array Training observations of shape ``(N, dim_obs)`` or ``(N, dim_obs, 1)`` (the autoregressive target; e.g. theta for NPE). For multichannel flows (``ch_obs > 1``) the shape is ``(N, dim_obs, C)`` and ``axis=(0, 1)`` yields per-channel stats. axis : int or tuple of int, optional Reduction axis or axes for the mean/std computation. Default is ``0`` (per-dimension stats over the batch), which is the correct choice for the tabular (``C == 1``) path. Pass ``axis=(0, 1)`` for per-channel standardization when ``C > 1``. """ obs = jnp.asarray(obs_data) mean = jnp.mean(obs, axis=axis) std = jnp.std(obs, axis=axis) std = jnp.where(std < 1e-6, 1.0, std) # guard zero-variance dims self.model.set_standardization(mean, std) self.ema_model.set_standardization(mean, std) self._standardized = True
[docs] def train(self, rngs, nsteps=None, save_model=True): """Train the flow model, warning if standardization was skipped. Delegates to :meth:`AbstractPipeline.train` after checking that :meth:`fit_standardization` was called. Parameters ---------- rngs : nnx.Rngs Random number generators for training and validation steps. nsteps : int or None, optional Number of training steps. If ``None``, taken from ``training_config["nsteps"]``. Default is ``None``. save_model : bool, optional If ``True`` (default), serialise the model to disk after training. Returns ------- loss_array : list Per-step training losses. val_loss_array : list Validation losses recorded at each validation checkpoint. """ if not self._standardized: warnings.warn( "fit_standardization() was not called before train(); the " "Standardize bijection stays at identity. Call " "pipeline.fit_standardization(theta_train) first if you want " "input standardization.", UserWarning, stacklevel=2) return super().train(rngs, nsteps=nsteps, save_model=save_model)
[docs] def get_sampler(self, x_o, use_ema=True, **kwargs): """Return a sampler closure for a single conditioning observation. Parameters ---------- x_o : Array Single conditioning observation. Must carry a leading batch axis and a channel axis for tabular cond: shape ``(1, dim_cond, C)``. For structured cond: ``(1,) + per_observation_shape``. A leading batch axis > 1 raises ``ValueError`` (use :meth:`sample_batched` for many conditions). use_ema : bool, optional If ``True`` (default), use the EMA model; otherwise use the live model. Returns ------- sampler : Callable A function ``(key, nsamples) -> Array`` returning the model's native output shape ``(nsamples, dim_obs, C)`` (channel always carried). """ _warn_unused_kwargs(kwargs) flow = self.ema_model if use_ema else self.model mode = "none" if self.structured_cond else "require" cond = _single_obs(x_o, channel=mode)[0] # (cond_dim, C_cond) or structured per-obs shape def sampler(key, nsamples): cond_b = jnp.broadcast_to(cond, (nsamples,) + cond.shape) return flow.sample(key, cond=cond_b) # model owns (nsamples, dim, C) return sampler
[docs] def sample(self, key, x_o, nsamples=10_000, use_ema=True, chunk_size=None, show_progress_bars=True, **kwargs): """Draw posterior samples for a single conditioning observation. Parameters ---------- key : jax.random.PRNGKey Random key. x_o : Array Single conditioning observation carrying a leading batch axis of size 1 (see :meth:`get_sampler` for the shape convention). A leading batch axis > 1 raises ``ValueError``. nsamples : int, optional Number of posterior samples to draw. Default is 10 000. use_ema : bool, optional If ``True`` (default), use the EMA model. chunk_size : int, optional Maximum number of samples drawn per device call. ``None`` (default) draws everything in one call — identical to the historical behavior. Set it to bound memory when drawing many samples from a deep flow. show_progress_bars : bool, optional Show a progress bar over chunks (only when chunking is active). Default is True. Returns ------- samples : Array Posterior samples of shape ``(nsamples, dim_obs, 1)`` for the tabular default (``C = 1``), or ``(nsamples, dim_obs, C)`` for ``ch_obs = C`` — the channel axis is always carried for a vector-modeled variable regardless of ``structured_cond`` (a structured condition changes only ``x_o``'s expected shape, not the modeled variable's). When ``structured_obs=True``, samples instead have shape ``(nsamples,) + per_obs_shape``, the model's native structured output. """ sampler = self.get_sampler(x_o, use_ema=use_ema, **kwargs) return _chunked_draw( sampler, key, nsamples, chunk_size, show_progress_bars=show_progress_bars, )
[docs] def sample_batched(self, key, x_o, nsamples=10_000, *, use_ema=True, chunk_size=None, show_progress_bars=True, **kwargs): """Draw posterior samples for a batch of conditioning observations. Each condition is repeated ``nsamples`` times and concatenated into a single flattened ``(B * nsamples, ...)`` batch. Without ``chunk_size`` the whole batch runs in **one** autoregressive pass (memory scales with ``B * nsamples``); with ``chunk_size`` the flattened batch is sliced into pieces of at most ``chunk_size`` rows per ``flow.sample`` call — chunk boundaries may fall inside a condition, which is fine because every row is independent. Parameters ---------- key : jax.random.PRNGKey Random key for the batched sampling pass. x_o : Array Batch of observations. For tabular cond: shape ``(B, dim_cond, C)`` (a bare ``(B, dim_cond)`` raises ``ValueError`` — add a trailing channel axis). For structured cond: ``(B,) + per_obs_shape``. nsamples : int, optional Number of posterior samples per observation. Default is 10 000. use_ema : bool, optional If ``True`` (default), use the EMA model. chunk_size : int, optional Maximum number of rows of the flattened ``B * nsamples`` batch per device call. ``None`` (default) keeps the historical single-pass behavior. show_progress_bars : bool, optional Show a progress bar over chunks (only when chunking is active). Default is True. **kwargs : dict, optional Extra keyword arguments accepted for interface compatibility and ignored with a warning (e.g. solver arguments from :class:`~gensbi.recipes.pipeline.AbstractPipeline`). Returns ------- samples : Array Posterior samples of shape ``(nsamples, B, dim_obs, 1)`` for the tabular default (``C = 1``), or ``(nsamples, B, dim_obs, C)`` for ``ch_obs = C``. When ``structured_obs=True``, samples instead have shape ``(nsamples, B) + per_obs_shape``. In both cases ``out[:, i]`` is the samples for condition ``i``. """ _warn_unused_kwargs(kwargs) _validate_chunk_size(chunk_size) flow = self.ema_model if use_ema else self.model x_o = jnp.asarray(x_o) if not self.structured_cond: x_o = _require_channel(x_o, "x_o") B = x_o.shape[0] cond = jnp.repeat(x_o, nsamples, axis=0) # (B*nsamples, ...): c0 x nsamples, c1 x nsamples, ... total = B * nsamples if chunk_size is None or chunk_size >= total: samples = flow.sample(key, cond=cond) # ONE batched AR pass else: n_chunks = (total + chunk_size - 1) // chunk_size keys = jax.random.split(key, n_chunks) loop = range(n_chunks) if show_progress_bars: loop = tqdm(loop, desc="Sampling") chunks = [] for i in loop: sl = slice(i * chunk_size, min((i + 1) * chunk_size, total)) chunk = flow.sample(keys[i], cond=cond[sl]) chunks.append(jax.block_until_ready(chunk)) samples = jnp.concatenate(chunks, axis=0) samples = samples.reshape((B, nsamples) + samples.shape[1:]) return jnp.moveaxis(samples, 0, 1) # (nsamples, B, dim_obs, C)
[docs] def get_log_prob_fn(self, x_o, use_ema=True, **kwargs): """Return a log-probability closure for a single conditioning observation. Parameters ---------- x_o : Array Single conditioning observation carrying a leading batch axis of size 1 (see :meth:`get_sampler` for the shape convention). A leading batch axis > 1 raises ``ValueError``. use_ema : bool, optional If ``True`` (default), use the EMA model. Returns ------- log_prob_fn : Callable A function ``(x_1) -> Array`` of shape ``(B,)`` evaluating the conditional log-probability ``log q(x_1 | x_o)`` for a batch of ``B`` parameter vectors. ``x_1`` has shape ``(B, dim_obs)`` or ``(B, dim_obs, 1)`` on the tabular path, or ``(B, dim_obs, C)`` when ``ch_obs > 1`` (channel-passthrough). """ _warn_unused_kwargs(kwargs) flow = self.ema_model if use_ema else self.model mode = "none" if self.structured_cond else "require" cond = _single_obs(x_o, channel=mode)[0] # (cond_dim, C_cond) or structured per-obs shape def log_prob_fn(x_1): obs = self._prep_obs(x_1) cond_b = jnp.broadcast_to(cond, (obs.shape[0],) + cond.shape) return flow.log_prob(obs, cond_b) # (B,) return log_prob_fn
[docs] def log_prob(self, x_1, x_o, use_ema=True, **kwargs): """Evaluate the conditional log-probability for a batch of samples. Parameters ---------- x_1 : Array Batch of parameter vectors of shape ``(B, dim_obs)`` or ``(B, dim_obs, 1)``. x_o : Array Single conditioning observation carrying a leading batch axis of size 1 (see :meth:`get_sampler` for the shape convention). A leading batch axis > 1 raises ``ValueError``. use_ema : bool, optional If ``True`` (default), use the EMA model. Returns ------- log_prob : Array Log-probabilities of shape ``(B,)``. """ return self.get_log_prob_fn(x_o, use_ema=use_ema, **kwargs)(x_1)