Example — Amortized inference (plume source attribution)¶
Simulation-based inference / neural posterior estimation. Train a
conditional density estimator on (source_params, simulated_obs)
pairs; at inference time, condition on a new observation and sample
posterior parameters. Maps to master plan §2.3 + §4.3.
import pipekit as pk
import pipekit_train as pt
import pipekit_cycle as pc
import pipekit_experiment as pe
# 1. The simulator — plume forward + observation operator.
from plumax.adapters.pipekit import PlumeForward, ColumnObs
plume_forward = PlumeForward()
obs_op = ColumnObs(instrument="TROPOMI")
# plume_forward satisfies pipekit_cycle.ForwardModel.
# obs_op satisfies pipekit_cycle.ObservationOperator.
# 2. Prior over source parameters.
class SourcePrior(pk.Operator):
"""Prior over (lat, lon, strength, height)."""
loc_bounds: tuple[float, float, float, float]
strength_loguniform: tuple[float, float]
height_uniform: tuple[float, float]
def __call__(self) -> jax.Array: ...
prior = SourcePrior(
loc_bounds=(-10, 10, 30, 40),
strength_loguniform=(1.0, 1e4),
height_uniform=(0.0, 500.0),
)
# 3. SimulationDataset for SBI — emit (params, observed_concentrations).
# Crucially the obs_op is applied so the network learns to invert
# *observed* concentrations, not the latent state.
sbi_ds = pt.SimulationDataset(
forward_model=plume_forward,
prior=prior,
n_samples=100_000,
obs_op=obs_op, # ← key difference vs emulator training
seed=42,
split="train",
)
val_ds = sbi_ds.with_split("val")
# 4. The conditional density estimator — from pyrox.
from pyrox.adapters.pipekit_train import ConditionalNormalizingFlow
posterior_model = ConditionalNormalizingFlow(
n_dim=4, # 4 source params
condition_dim=obs_op.output_dim, # observation embedding size
flow_type="masked_autoregressive",
n_layers=8,
hidden_dim=128,
)
# 5. Pipekit-experiment.
registry = pe.LocalModelRegistry(root="./models")
tracker = pe.adapters.mlflow.MLflowTracker(tracking_uri="http://mlflow:5000")
# 6. Train as a normalising-flow likelihood. NLL ⇒ the flow learns
# log p(params | obs).
loop = pt.TrainingLoop(
model_op=posterior_model,
dataset=pt.CachedDataset(sbi_ds, cache_dir="./cache/plume_sbi/"),
val_dataset=pt.CachedDataset(val_ds, cache_dir="./cache/plume_sbi/"),
loss=pt.NLL(), # flow's own log_prob
optimizer_config={"name": "adamw", "lr": 1e-3},
max_steps=200_000,
batch_size=128,
backend="equinox", # JAX-traceable
callbacks=(
pt.EarlyStopping(metric="val_loss", patience=10),
pt.Checkpoint(every_n_steps=5_000, save_dir="./ckpts/plume_sbi",
registry=registry),
pt.LogToExperiment(tracker=tracker, run_name="plume_npe"),
),
metric_writer=pt.JSONLWriter(path="./logs/plume_sbi.jsonl"),
seed=42,
)
posterior_op, artifact = loop.run()
# 7. Use the amortized posterior at inference.
# Given any new observation, sample parameters in milliseconds —
# no simulator calls at inference time.
new_obs = load_real_tropomi_pixel("2026-04-12T13:00:00Z")
posterior_samples = posterior_op.sample(condition=new_obs, n_samples=10_000)
# posterior_samples: array of shape (10_000, 4) — lat, lon, strength, height.
# 8. The posterior_op is a pipekit.Operator — drops into a full
# attribution pipeline.
attribution_pipeline = pk.Sequential([
obs_op_preprocess, # any obs-side preprocessing
posterior_op, # samples posterior
posterior_summary_op, # MAP / credible intervals / etc.
])
What this example demonstrates:
SimulationDataset(obs_op=...)— the dataset emits(params, observed_concentrations)pairs by composing the forward model with the observation operator. The same dataset shape as emulator training; only the loss and head differ.- Pyrox's
ConditionalNormalizingFlowplugged in as apipekit.Operatorvia thepyrox.adapters.pipekit_trainextra-gated adapter. Pipekit-train sees only anOperator; the algorithmic core lives in pyrox. NLLhere is computed by the flow itself (each batch is scored by its ownlog_prob). The adapter's auto-synthesised TrainTask handles this — the user does not write a customtrain_step.- The trained posterior is amortized: one (potentially expensive) training; many fast queries at inference. The attribution pipeline at the bottom is composable just like any inference pipeline.
Why pipekit-train matters here.
Without pipekit-train, this workflow is three different codebases
glued together — a simulator script, a custom SBI training loop, a
custom inference script. With pipekit-train, the simulator is a
SimulationDataset, the SBI loop is a TrainingLoop, the inference
is a Sequential of Operators. The artifact at the end is one
JSON file that reproduces all three byte-identically.