From 6529070e5f901dab6b3001b76c122accbaaa434c Mon Sep 17 00:00:00 2001 From: Stephen Green Date: Tue, 30 Jun 2026 10:44:40 +0200 Subject: [PATCH 01/65] Add factorized sampler core with plain-NPE parity Introduce the factorized-sampler spine from the hackathon design (vault/Hackathon/Factorized_Sampler_Design.md): the posterior as an ordered product of conditional factors, q(theta|d) = prod_i q_i(theta_i | f_i(theta_ DataFrame) - SamplerContext protocol dingo/gw/inference/factors.py (GW plain-NPE path): - GWSamplerContext.from_model: domain + one-time data prep, cached prepared_data - FlowFactor: wraps a posterior model, encapsulates standardization - GWComposedSampler: adds GW _post_process (fixed-param injection + RA correction) - FixedFactor / SyntheticPhaseFactor stubs for later steps Plain-NPE parity verified bit-exact against the current GWSampler on a real GW200129 NPE model, both at the core (_run_sampler) and end-to-end (run_sampler, batched, with post-processing) levels. Co-Authored-By: Claude Opus 4.8 (1M context) --- dingo/core/factors.py | 237 ++++++++++++++++++++++++ dingo/gw/inference/factors.py | 339 ++++++++++++++++++++++++++++++++++ 2 files changed, 576 insertions(+) create mode 100644 dingo/core/factors.py create mode 100644 dingo/gw/inference/factors.py diff --git a/dingo/core/factors.py b/dingo/core/factors.py new file mode 100644 index 000000000..4bfb86802 --- /dev/null +++ b/dingo/core/factors.py @@ -0,0 +1,237 @@ +""" +Factorized sampler core. + +This module implements the domain-agnostic spine of the factorized-sampler design +(see vault/Hackathon/Factorized_Sampler_Design.md): the posterior is represented as +an ordered product of conditional factors + + q(theta_1, ..., theta_n | d) = prod_i q_i(theta_i | f_i(theta_ torch.Tensor: + """Map physical ``values`` (a dict of named tensors) to a standardized tensor + with columns in ``names`` order.""" + cols = [(values[n] - self.mean[n]) / self.std[n] for n in names] + return torch.stack(cols, dim=-1) + + def destandardize( + self, z: torch.Tensor, names: list[str] + ) -> dict[str, torch.Tensor]: + """Map a standardized tensor (columns in ``names`` order) back to a dict of + named physical tensors.""" + return {n: z[..., i] * self.std[n] + self.mean[n] for i, n in enumerate(names)} + + def log_det(self, names: list[str]) -> float: + """The term to *add* to a network log-prob to express it in physical space: + ``log p_theta = log p_z - sum log std``. Same correction in both directions.""" + return -sum(math.log(self.std[n]) for n in names) + + +@runtime_checkable +class SamplerContext(Protocol): + """ + Per-event shared state referenced by every factor: the data ``d`` and everything + derived from it (the one-time prepared data representation, the likelihood, event + metadata). Concrete implementations are domain-specific (see + ``dingo.gw.inference.factors.GWSamplerContext``). Serialized as the transport state + between pipe stages. + """ + + event_metadata: Optional[dict] + + def prepared_data(self) -> torch.Tensor: + """The one-time data representation (whiten/decimate/repackage/...), computed + once and cached. The conditioning input shared by data-conditioned factors.""" + ... + + def likelihood(self): + """The likelihood, for likelihood-based factors (synthetic phase) and IS.""" + ... + + +@dataclass +class Conditioning: + """ + What the composer hands a factor: the shared ``context`` (-> prepared data, + likelihood, domain) plus the physical values of the earlier-block parameters this + factor conditions on. + """ + + context: SamplerContext + given: dict[str, torch.Tensor] = field(default_factory=dict) + + +class Factor(ABC): + """ + A conditional distribution ``q_i(theta_i | f_i(theta_ tuple[dict[str, torch.Tensor], torch.Tensor]: + """Draw ``theta_i ~ q_i``; return ``(physical samples, physical-space log q_i)``.""" + + @abstractmethod + def log_prob( + self, theta_i: dict[str, torch.Tensor], cond: Conditioning + ) -> torch.Tensor: + """Evaluate ``log q_i(theta_i | f_i)`` at given physical ``theta_i`` (for IS / + re-plug).""" + + +class ChainComposer: + """ + Autoregressive composer: sample (or evaluate) the factors in declared order, which + must be a topological order of the dependency DAG, and sum their log-probs. Exact + and importance-sampling-friendly. Covers plain NPE, single-step GNPE, prior + conditioning, synthetic phase, and intrinsic/extrinsic splits. + + Multi-iteration (cyclic) GNPE is *not* expressible here and uses a separate Gibbs + composer. + """ + + def __init__(self, factors: list[Factor]): + self.factors = factors + self._validate() + + def _validate(self): + """Check the declared order is a valid topological order: every conditioning + name is produced by an earlier factor.""" + produced: set[str] = set() + for factor in self.factors: + missing = [c for c in factor.conditioning if c not in produced] + if missing: + raise ValueError( + f"Factor producing {factor.parameters} conditions on {missing}, " + f"which no earlier factor produces. Check chain order." + ) + produced.update(factor.parameters) + + def sample_and_log_prob( + self, num_samples: int, context: SamplerContext + ) -> tuple[dict[str, torch.Tensor], torch.Tensor]: + samples: dict[str, torch.Tensor] = {} + total: torch.Tensor | float = 0.0 + for factor in self.factors: + cond = Conditioning(context, {k: samples[k] for k in factor.conditioning}) + block, lp = factor.sample_and_log_prob(num_samples, cond) + samples.update(block) + total = total + lp + return samples, total + + def log_prob( + self, samples: dict[str, torch.Tensor], context: SamplerContext + ) -> torch.Tensor: + total: torch.Tensor | float = 0.0 + for factor in self.factors: + cond = Conditioning(context, {k: samples[k] for k in factor.conditioning}) + theta_i = {k: samples[k] for k in factor.parameters} + total = total + factor.log_prob(theta_i, cond) + return total + + +class ComposedSampler: + """ + Thin user-facing façade over a ``ChainComposer`` and a ``SamplerContext``: runs the + chain with optional batching, applies domain-specific post-processing, and returns + samples as a DataFrame. The per-factor compute lives in the composer; this class only + handles batching, consolidation, and post-processing -- the role the monolithic + ``Sampler`` plays today. + """ + + def __init__(self, composer: ChainComposer, context: SamplerContext): + self.composer = composer + self.context = context + self.samples: Optional[pd.DataFrame] = None + + def _post_process(self, samples: dict, inverse: bool = False): + """Hook for domain-specific post-processing (e.g. GW fixed-parameter injection + and reference-time correction). No-op by default.""" + pass + + def _run_batch(self, num_samples: int) -> dict[str, torch.Tensor]: + samples, log_prob = self.composer.sample_and_log_prob(num_samples, self.context) + out = dict(samples) + out["log_prob"] = log_prob + return out + + def run_sampler( + self, num_samples: int, batch_size: Optional[int] = None + ) -> pd.DataFrame: + if batch_size is None: + batch_size = num_samples + full_batches, remainder = divmod(num_samples, batch_size) + batches = [self._run_batch(batch_size) for _ in range(full_batches)] + if remainder > 0: + batches.append(self._run_batch(remainder)) + merged = {k: torch.cat([b[k] for b in batches]) for k in batches[0].keys()} + merged = {k: v.cpu().numpy() for k, v in merged.items()} + self._post_process(merged) + self.samples = pd.DataFrame(merged) + return self.samples diff --git a/dingo/gw/inference/factors.py b/dingo/gw/inference/factors.py new file mode 100644 index 000000000..11124a216 --- /dev/null +++ b/dingo/gw/inference/factors.py @@ -0,0 +1,339 @@ +""" +Gravitational-wave factors for the factorized sampler. + +Concrete ``Factor`` / ``SamplerContext`` implementations for GW inference (see +vault/Hackathon/Factorized_Sampler_Design.md). This first increment covers the plain-NPE +path: a single ``FlowFactor`` whose conditioning map ``f_i`` is the one-time data +preprocessing (decimate / whiten / repackage), run on a ``GWSamplerContext``. The +fixed, synthetic-phase, and GNPE-proxy factors are stubbed for later steps. +""" + +from __future__ import annotations + +from typing import Optional, Union + +import numpy as np +import pandas as pd +import torch +from astropy.time import Time +from bilby.core.prior import DeltaFunction +from torchvision.transforms import Compose + +from dingo.core.factors import ( + ChainComposer, + ComposedSampler, + Conditioning, + Factor, + Standardization, +) +from dingo.core.posterior_models import BasePosteriorModel +from dingo.core.transforms import GetItem +from dingo.gw.domains import build_domain, MultibandedFrequencyDomain +from dingo.gw.gwutils import get_extrinsic_prior_dict +from dingo.gw.prior import build_prior_with_defaults +from dingo.gw.transforms import ( + DecimateWaveformsAndASDS, + RepackageStrainsAndASDS, + ToTorch, + WhitenAndScaleStrain, +) + + +def _base_model_metadata(model: BasePosteriorModel) -> dict: + """The training metadata describing the data domain / standardization. For an + unconditional (density-recovery) model this lives under ``metadata["base"]``.""" + metadata = model.metadata + if metadata["train_settings"]["data"].get("unconditional", False): + return metadata["base"] + return metadata + + +class GWSamplerContext: + """ + Per-event shared GW state: the data ``d`` and everything derived from it. Referenced + by every factor in a chain, and serialized as the transport state between pipe + stages. + + This implements the ``dingo.core.factors.SamplerContext`` protocol. It owns the + one-time data preprocessing (``prepared_data``) and -- once wired in a later step -- + the likelihood used by likelihood-based factors and importance sampling. + + Event metadata lives here (not on individual factors): it is a property of the data, + and it drives frequency cropping, the t_ref/RA correction, and the likelihood. + """ + + def __init__( + self, + domain, + detectors: list[str], + t_ref: float, + data_prep: Compose, + raw_context: dict, + event_metadata: Optional[dict] = None, + ): + self.domain = domain + self.detectors = detectors + self.t_ref = t_ref + self._data_prep = data_prep + self.raw_context = raw_context + self.event_metadata = event_metadata + self._prepared: Optional[torch.Tensor] = None + + @classmethod + def from_model( + cls, + model: BasePosteriorModel, + raw_context: dict, + event_metadata: Optional[dict] = None, + ) -> "GWSamplerContext": + """Build the context (domain + one-time data-prep chain) from a model's + metadata. The data-prep chain reproduces ``GWSampler`` preprocessing for the + plain-NPE case (parameter-dependent transforms are handled per-factor).""" + meta = _base_model_metadata(model) + data_settings = meta["train_settings"]["data"] + + domain = build_domain(meta["dataset_settings"]["domain"]) + if "domain_update" in data_settings: + domain.update(data_settings["domain_update"]) + detectors = data_settings["detectors"] + + transforms = [] + # Decimate from the base domain when using a multibanded frequency domain. + if isinstance(domain, MultibandedFrequencyDomain): + transforms.append( + DecimateWaveformsAndASDS(domain, decimation_mode="whitened") + ) + # Whiten and scale (the network expects standardized data). + transforms.append(WhitenAndScaleStrain(domain.noise_std)) + # Repackage strains/ASDs into an array, move to torch, extract the waveform. + # TODO: frequency-range cropping (MaskDataForFrequencyRangeUpdate) -- follow-up. + transforms += [ + RepackageStrainsAndASDS(ifos=detectors, first_index=domain.min_idx), + ToTorch(device=model.device), + GetItem("waveform"), + ] + + return cls( + domain=domain, + detectors=detectors, + t_ref=data_settings["ref_time"], + data_prep=Compose(transforms), + raw_context=raw_context, + event_metadata=event_metadata, + ) + + def prepared_data(self) -> torch.Tensor: + """One-time data preprocessing, computed once and cached.""" + if self._prepared is None: + self._prepared = self._data_prep(self.raw_context) + return self._prepared + + def likelihood(self): + raise NotImplementedError( + "Likelihood construction moves onto the context in a later step " + "(synthetic-phase / importance-sampling factors)." + ) + + +class FlowFactor(Factor): + """ + A factor wrapping a posterior model (NPE flow, FMPE, ...). Its conditioning map + ``f_i`` applies the cheap parameter-dependent data transform (none for plain NPE; + time-shift / heterodyne for GNPE -- follow-up), standardizes, then runs the network. + + Encapsulates the network's own standardization: the public interface is physical-in + / physical-out. Standardized values never leave the factor. + """ + + def __init__( + self, + model: BasePosteriorModel, + parameters: list[str], + conditioning: Optional[list[str]] = None, + context_parameters: Optional[list[str]] = None, + ): + self.model = model + self.parameters = parameters + self.conditioning = conditioning or [] + # Network conditioning inputs (GNPE proxies). Empty for plain NPE. + self.context_parameters = context_parameters or [] + std = _base_model_metadata(model)["train_settings"]["data"]["standardization"] + self.standardization = Standardization(std["mean"], std["std"]) + + @classmethod + def from_model(cls, model: BasePosteriorModel) -> "FlowFactor": + """Build a plain-NPE factor from a model: ``parameters`` and ``context_parameters`` + come from the model's training metadata (first-class conditioning).""" + data_settings = _base_model_metadata(model)["train_settings"]["data"] + context_parameters = data_settings.get("context_parameters") or [] + return cls( + model=model, + parameters=data_settings["inference_parameters"], + conditioning=list(context_parameters), + context_parameters=list(context_parameters), + ) + + def _network_context(self, cond: Conditioning) -> tuple[torch.Tensor, ...]: + """Assemble the model's positional context: the prepared data, plus standardized + context parameters when the network conditions on proxies.""" + data = cond.context.prepared_data() + if not self.context_parameters: + return (data.unsqueeze(0),) + # Standardize the (physical) conditioning values the network was trained on. + ctx = self.standardization.standardize(cond.given, self.context_parameters) + return data.unsqueeze(0), ctx.unsqueeze(0) + + def sample_and_log_prob(self, num_samples, cond): + net_context = self._network_context(cond) + self.model.network.eval() + with torch.no_grad(): + z, log_prob = self.model.sample_and_log_prob( + *net_context, num_samples=num_samples + ) + # Squeeze the batch dimension added for the (single) context. + z = z.squeeze(0) + log_prob = log_prob.squeeze(0) + theta = self.standardization.destandardize(z, self.parameters) + log_prob = log_prob + self.standardization.log_det(self.parameters) + return theta, log_prob + + def log_prob(self, theta_i, cond): + num_samples = next(iter(theta_i.values())).shape[0] + z = self.standardization.standardize(theta_i, self.parameters) + data = cond.context.prepared_data() + data = data.expand(num_samples, *data.shape) + net_context: tuple[torch.Tensor, ...] + if not self.context_parameters: + net_context = (data,) + else: + ctx = self.standardization.standardize(cond.given, self.context_parameters) + net_context = (data, ctx) + self.model.network.eval() + with torch.no_grad(): + log_prob = self.model.log_prob(z, *net_context) + return log_prob + self.standardization.log_det(self.parameters) + + +# --- Stubs for later steps ------------------------------------------------------------- + + +class FixedFactor(Factor): + """``q_i = delta(theta_i - c)``: pins parameters to fixed values (prior-conditioning + / known proxies). Subsumes ``FixedInitSampler``. TODO: implement.""" + + def __init__(self, values: dict[str, float]): + self.values = values + self.parameters = list(values) + self.conditioning = [] + + def sample_and_log_prob(self, num_samples, cond): + samples = { + p: torch.full((num_samples,), float(v)) for p, v in self.values.items() + } + return samples, torch.zeros(num_samples) + + def log_prob(self, theta_i, cond): + raise NotImplementedError("FixedFactor.log_prob -- later step.") + + +class SyntheticPhaseFactor(Factor): + """``q(phase | theta_rest, d)`` from the likelihood on a phase grid; the final, + non-NN factor, run in the CPU post-processing stage. TODO: port from + ``Result.sample_synthetic_phase``.""" + + def __init__(self): + self.parameters = ["phase"] + self.conditioning = [] # conditions on all preceding params via the likelihood + + def sample_and_log_prob(self, num_samples, cond): + raise NotImplementedError("SyntheticPhaseFactor -- later step.") + + def log_prob(self, theta_i, cond): + raise NotImplementedError("SyntheticPhaseFactor -- later step.") + + +class GWComposedSampler(ComposedSampler): + """ + GW façade: a ``ComposedSampler`` that adds the gravitational-wave post-processing -- + injecting fixed (delta-prior) parameters and correcting the sky position for the + model's fixed reference time. Ported from ``GWSamplerMixin._post_process``; in the + longer run the reference-time correction becomes a deterministic reparametrization + factor in the chain (see the design doc, Q#4/Q#7). + """ + + def __init__( + self, + composer: ChainComposer, + context: GWSamplerContext, + metadata: dict, + inference_parameters: list[str], + ): + super().__init__(composer, context) + self.metadata = metadata + self.inference_parameters = inference_parameters + + @classmethod + def from_model( + cls, + model: BasePosteriorModel, + raw_context: dict, + event_metadata: Optional[dict] = None, + ) -> "GWComposedSampler": + """Build a single-factor (plain-NPE) GW sampler from a model and event data.""" + context = GWSamplerContext.from_model(model, raw_context, event_metadata) + factor = FlowFactor.from_model(model) + return cls( + composer=ChainComposer([factor]), + context=context, + metadata=_base_model_metadata(model), + inference_parameters=factor.parameters, + ) + + def _correct_reference_time( + self, samples: Union[dict, pd.DataFrame], inverse: bool = False + ): + """Correct the right ascension for the difference between the event time and the + model's training reference time (fixed detector positions).""" + event_metadata = self.context.event_metadata + if event_metadata is None: + return + t_event = event_metadata.get("time_event") + t_ref = self.context.t_ref + if t_event is None or t_event == t_ref or "ra" not in samples: + return + ra = samples["ra"] + longitude_event = Time(t_event, format="gps", scale="utc").sidereal_time( + "apparent", "greenwich" + ) + longitude_reference = Time(t_ref, format="gps", scale="utc").sidereal_time( + "apparent", "greenwich" + ) + ra_correction = (longitude_event - longitude_reference).rad + if not inverse: + samples["ra"] = (ra + ra_correction) % (2 * np.pi) + else: + samples["ra"] = (ra - ra_correction) % (2 * np.pi) + + def _post_process(self, samples: Union[dict, pd.DataFrame], inverse: bool = False): + intrinsic_prior = self.metadata["dataset_settings"]["intrinsic_prior"] + extrinsic_prior = get_extrinsic_prior_dict( + self.metadata["train_settings"]["data"]["extrinsic_prior"] + ) + prior = build_prior_with_defaults({**intrinsic_prior, **extrinsic_prior}) + + if not inverse: + # Add fixed (delta-prior) parameters not produced by the chain. + num_samples = len(samples[list(samples.keys())[0]]) + for k, p in prior.items(): + if isinstance(p, DeltaFunction) and k not in samples: + print(f"Adding fixed parameter {k} = {p.peak} from prior.") + samples[k] = p.peak * np.ones(num_samples) + else: + drop = [k for k in samples.keys() if k not in self.inference_parameters] + if isinstance(samples, pd.DataFrame): + samples.drop(columns=drop, inplace=True, errors="ignore") + else: + for k in drop: + samples.pop(k, None) + + self._correct_reference_time(samples, inverse) From 2675f68f3abc96026a1354569772828f9a855c2e Mon Sep 17 00:00:00 2001 From: Stephen Green Date: Tue, 30 Jun 2026 13:35:55 +0200 Subject: [PATCH 02/65] Move FlowFactor and _base_model_metadata into core FlowFactor is domain-agnostic -- it reaches data only through the SamplerContext.prepared_data() protocol -- so it belongs in core alongside the Factor ABC rather than in the GW module. The GW layer keeps only the GW-specific pieces: GWSamplerContext (which builds the data-prep conditioning map f_i) and GWComposedSampler (post-processing). Document GWSamplerContext.raw_context (= EventDataset.data; retained for the not-yet-wired likelihood and the serialized transport state). dingo.gw.inference.factors re-exports FlowFactor, so existing imports keep working. Plain-NPE parity with GWSampler remains bit-exact. Co-Authored-By: Claude Opus 4.8 (1M context) --- dingo/core/factors.py | 94 +++++++++++++++++++++++++++++ dingo/gw/inference/factors.py | 107 ++++------------------------------ 2 files changed, 106 insertions(+), 95 deletions(-) diff --git a/dingo/core/factors.py b/dingo/core/factors.py index 4bfb86802..16d33cae6 100644 --- a/dingo/core/factors.py +++ b/dingo/core/factors.py @@ -29,6 +29,8 @@ import pandas as pd import torch +from dingo.core.posterior_models import BasePosteriorModel + class Standardization: """ @@ -145,6 +147,98 @@ def log_prob( re-plug).""" +def _base_model_metadata(model: BasePosteriorModel) -> dict: + """The training metadata describing the parameter standardization / data settings. + For an unconditional (density-recovery) model this lives under ``metadata["base"]``. + """ + metadata = model.metadata + if metadata["train_settings"]["data"].get("unconditional", False): + return metadata["base"] + return metadata + + +class FlowFactor(Factor): + """ + A factor wrapping a posterior model (NPE flow, FMPE, ...). Domain-agnostic: it reaches + data only through the ``SamplerContext.prepared_data()`` protocol, so the GW-specific + conditioning map ``f_i`` (the data preprocessing) lives entirely on the context. + + Encapsulates the network's own standardization: the public interface is physical-in / + physical-out -- standardized values never leave the factor. + + The cheap *parameter-dependent* part of ``f_i`` (time-shift / heterodyne for GNPE) is + a no-op here; a domain subclass overrides ``_apply_param_transforms`` to add it. + """ + + def __init__( + self, + model: BasePosteriorModel, + parameters: list[str], + conditioning: Optional[list[str]] = None, + context_parameters: Optional[list[str]] = None, + ): + self.model = model + self.parameters = parameters + self.conditioning = conditioning or [] + # Network conditioning inputs (GNPE proxies). Empty for plain NPE. + self.context_parameters = context_parameters or [] + std = _base_model_metadata(model)["train_settings"]["data"]["standardization"] + self.standardization = Standardization(std["mean"], std["std"]) + + @classmethod + def from_model(cls, model: BasePosteriorModel) -> "FlowFactor": + """Build a factor from a model: ``parameters`` and ``context_parameters`` come from + the model's training metadata (first-class conditioning).""" + data_settings = _base_model_metadata(model)["train_settings"]["data"] + context_parameters = data_settings.get("context_parameters") or [] + return cls( + model=model, + parameters=data_settings["inference_parameters"], + conditioning=list(context_parameters), + context_parameters=list(context_parameters), + ) + + def _network_context(self, cond: Conditioning) -> tuple[torch.Tensor, ...]: + """Assemble the model's positional context: the prepared data, plus standardized + context parameters when the network conditions on proxies.""" + data = cond.context.prepared_data() + if not self.context_parameters: + return (data.unsqueeze(0),) + # Standardize the (physical) conditioning values the network was trained on. + ctx = self.standardization.standardize(cond.given, self.context_parameters) + return data.unsqueeze(0), ctx.unsqueeze(0) + + def sample_and_log_prob(self, num_samples, cond): + net_context = self._network_context(cond) + self.model.network.eval() + with torch.no_grad(): + z, log_prob = self.model.sample_and_log_prob( + *net_context, num_samples=num_samples + ) + # Squeeze the batch dimension added for the (single) context. + z = z.squeeze(0) + log_prob = log_prob.squeeze(0) + theta = self.standardization.destandardize(z, self.parameters) + log_prob = log_prob + self.standardization.log_det(self.parameters) + return theta, log_prob + + def log_prob(self, theta_i, cond): + num_samples = next(iter(theta_i.values())).shape[0] + z = self.standardization.standardize(theta_i, self.parameters) + data = cond.context.prepared_data() + data = data.expand(num_samples, *data.shape) + net_context: tuple[torch.Tensor, ...] + if not self.context_parameters: + net_context = (data,) + else: + ctx = self.standardization.standardize(cond.given, self.context_parameters) + net_context = (data, ctx) + self.model.network.eval() + with torch.no_grad(): + log_prob = self.model.log_prob(z, *net_context) + return log_prob + self.standardization.log_det(self.parameters) + + class ChainComposer: """ Autoregressive composer: sample (or evaluate) the factors in declared order, which diff --git a/dingo/gw/inference/factors.py b/dingo/gw/inference/factors.py index 11124a216..79dfce5aa 100644 --- a/dingo/gw/inference/factors.py +++ b/dingo/gw/inference/factors.py @@ -1,11 +1,12 @@ """ Gravitational-wave factors for the factorized sampler. -Concrete ``Factor`` / ``SamplerContext`` implementations for GW inference (see -vault/Hackathon/Factorized_Sampler_Design.md). This first increment covers the plain-NPE -path: a single ``FlowFactor`` whose conditioning map ``f_i`` is the one-time data -preprocessing (decimate / whiten / repackage), run on a ``GWSamplerContext``. The -fixed, synthetic-phase, and GNPE-proxy factors are stubbed for later steps. +GW-specific pieces of the factorized-sampler design (see +vault/Hackathon/Factorized_Sampler_Design.md). The generic ``FlowFactor`` is +domain-agnostic and lives in ``dingo.core.factors``; what is GW-specific is the +``GWSamplerContext`` (which builds the data-preprocessing conditioning map ``f_i``) and +the post-processing in ``GWComposedSampler``. This first increment covers the plain-NPE +path; the fixed, synthetic-phase, and GNPE-proxy factors are stubbed for later steps. """ from __future__ import annotations @@ -22,9 +23,9 @@ from dingo.core.factors import ( ChainComposer, ComposedSampler, - Conditioning, Factor, - Standardization, + FlowFactor, + _base_model_metadata, ) from dingo.core.posterior_models import BasePosteriorModel from dingo.core.transforms import GetItem @@ -39,15 +40,6 @@ ) -def _base_model_metadata(model: BasePosteriorModel) -> dict: - """The training metadata describing the data domain / standardization. For an - unconditional (density-recovery) model this lives under ``metadata["base"]``.""" - metadata = model.metadata - if metadata["train_settings"]["data"].get("unconditional", False): - return metadata["base"] - return metadata - - class GWSamplerContext: """ Per-event shared GW state: the data ``d`` and everything derived from it. Referenced @@ -75,6 +67,10 @@ def __init__( self.detectors = detectors self.t_ref = t_ref self._data_prep = data_prep + # The raw event data `d` (strain + ASDs per detector), i.e. EventDataset.data -- + # the same object set as GWSampler.context today. Consumed lazily by + # prepared_data(); also retained for the likelihood (synthetic-phase / IS factors) + # and as part of the serialized transport state, neither of which is wired yet. self.raw_context = raw_context self.event_metadata = event_metadata self._prepared: Optional[torch.Tensor] = None @@ -135,85 +131,6 @@ def likelihood(self): ) -class FlowFactor(Factor): - """ - A factor wrapping a posterior model (NPE flow, FMPE, ...). Its conditioning map - ``f_i`` applies the cheap parameter-dependent data transform (none for plain NPE; - time-shift / heterodyne for GNPE -- follow-up), standardizes, then runs the network. - - Encapsulates the network's own standardization: the public interface is physical-in - / physical-out. Standardized values never leave the factor. - """ - - def __init__( - self, - model: BasePosteriorModel, - parameters: list[str], - conditioning: Optional[list[str]] = None, - context_parameters: Optional[list[str]] = None, - ): - self.model = model - self.parameters = parameters - self.conditioning = conditioning or [] - # Network conditioning inputs (GNPE proxies). Empty for plain NPE. - self.context_parameters = context_parameters or [] - std = _base_model_metadata(model)["train_settings"]["data"]["standardization"] - self.standardization = Standardization(std["mean"], std["std"]) - - @classmethod - def from_model(cls, model: BasePosteriorModel) -> "FlowFactor": - """Build a plain-NPE factor from a model: ``parameters`` and ``context_parameters`` - come from the model's training metadata (first-class conditioning).""" - data_settings = _base_model_metadata(model)["train_settings"]["data"] - context_parameters = data_settings.get("context_parameters") or [] - return cls( - model=model, - parameters=data_settings["inference_parameters"], - conditioning=list(context_parameters), - context_parameters=list(context_parameters), - ) - - def _network_context(self, cond: Conditioning) -> tuple[torch.Tensor, ...]: - """Assemble the model's positional context: the prepared data, plus standardized - context parameters when the network conditions on proxies.""" - data = cond.context.prepared_data() - if not self.context_parameters: - return (data.unsqueeze(0),) - # Standardize the (physical) conditioning values the network was trained on. - ctx = self.standardization.standardize(cond.given, self.context_parameters) - return data.unsqueeze(0), ctx.unsqueeze(0) - - def sample_and_log_prob(self, num_samples, cond): - net_context = self._network_context(cond) - self.model.network.eval() - with torch.no_grad(): - z, log_prob = self.model.sample_and_log_prob( - *net_context, num_samples=num_samples - ) - # Squeeze the batch dimension added for the (single) context. - z = z.squeeze(0) - log_prob = log_prob.squeeze(0) - theta = self.standardization.destandardize(z, self.parameters) - log_prob = log_prob + self.standardization.log_det(self.parameters) - return theta, log_prob - - def log_prob(self, theta_i, cond): - num_samples = next(iter(theta_i.values())).shape[0] - z = self.standardization.standardize(theta_i, self.parameters) - data = cond.context.prepared_data() - data = data.expand(num_samples, *data.shape) - net_context: tuple[torch.Tensor, ...] - if not self.context_parameters: - net_context = (data,) - else: - ctx = self.standardization.standardize(cond.given, self.context_parameters) - net_context = (data, ctx) - self.model.network.eval() - with torch.no_grad(): - log_prob = self.model.log_prob(z, *net_context) - return log_prob + self.standardization.log_det(self.parameters) - - # --- Stubs for later steps ------------------------------------------------------------- From 1a6bc918bce08b4601e6ea42c5a194ae96df88da Mon Sep 17 00:00:00 2001 From: Stephen Green Date: Tue, 30 Jun 2026 14:02:28 +0200 Subject: [PATCH 03/65] Add GNPE Gibbs composer with bit-exact parity Add multi-iteration time-GNPE to the factorized sampler: - core: GibbsComposer (seed from an init factor, iterate a GNPE step to a fixed point, return samples without log_prob); ChainComposer.sample; unify ComposedSampler._run_batch so the facade drives either composer. - gw: GNPEFlowFactor (one GNPE iteration -- blur proxies, time-shift strain, standardize context, sample, recompute detector times; replicates GWSamplerGNPE's transforms) and GWComposedSampler.from_gnpe_models. Multi-iteration GNPE parity is bit-exact vs GWSamplerGNPE over 4 Gibbs iterations on the GW200129 init/main pair; plain-NPE parity unchanged. Single-step GNPE (num_iterations=1, log_prob-preserving / BNS) is the chain composer path and is not yet implemented. Co-Authored-By: Claude Opus 4.8 (1M context) --- dingo/core/factors.py | 48 ++++++++++- dingo/gw/inference/factors.py | 153 +++++++++++++++++++++++++++++++++- 2 files changed, 195 insertions(+), 6 deletions(-) diff --git a/dingo/core/factors.py b/dingo/core/factors.py index 16d33cae6..ff299a6b4 100644 --- a/dingo/core/factors.py +++ b/dingo/core/factors.py @@ -289,6 +289,47 @@ def log_prob( total = total + factor.log_prob(theta_i, cond) return total + def sample( + self, num_samples: int, context: SamplerContext + ) -> dict[str, torch.Tensor]: + """Full per-sample dict (parameters + ``log_prob``), for the sampler façade.""" + samples, log_prob = self.sample_and_log_prob(num_samples, context) + return {**samples, "log_prob": log_prob} + + +class GibbsComposer: + """ + Multi-iteration GNPE composition. Seeds the Gibbs chain from an ``init_factor`` (e.g. + a network predicting detector coalescence times), then iterates a GNPE step + (``gnpe_factor.gibbs_step``) to a fixed point. The dependency is cyclic + (theta <-> proxies), so this breaks density access: ``sample`` returns parameters + WITHOUT a ``log_prob``. (Single-step GNPE that *does* preserve log_prob is a + two-factor ``ChainComposer`` instead.) + """ + + def __init__(self, init_factor: Factor, gnpe_factor, num_iterations: int): + self.init_factor = init_factor + self.gnpe_factor = gnpe_factor + self.num_iterations = num_iterations + + def sample( + self, num_samples: int, context: SamplerContext + ) -> dict[str, torch.Tensor]: + # Seed the Gibbs chain with the init factor's parameters (e.g. detector times). + seed, _ = self.init_factor.sample_and_log_prob( + num_samples, Conditioning(context) + ) + extrinsic = dict(seed) + parameters: dict[str, torch.Tensor] = {} + for _ in range(self.num_iterations): + parameters, extrinsic = self.gnpe_factor.gibbs_step( + num_samples, context, extrinsic + ) + proxies = { + p: extrinsic[p] for p in self.gnpe_factor.proxy_parameters if p in extrinsic + } + return {**parameters, **proxies} + class ComposedSampler: """ @@ -310,10 +351,9 @@ def _post_process(self, samples: dict, inverse: bool = False): pass def _run_batch(self, num_samples: int) -> dict[str, torch.Tensor]: - samples, log_prob = self.composer.sample_and_log_prob(num_samples, self.context) - out = dict(samples) - out["log_prob"] = log_prob - return out + # Uniform across composers: ChainComposer.sample adds log_prob; GibbsComposer + # (multi-iteration GNPE) returns parameters + proxies with no log_prob. + return self.composer.sample(num_samples, self.context) def run_sampler( self, num_samples: int, batch_size: Optional[int] = None diff --git a/dingo/gw/inference/factors.py b/dingo/gw/inference/factors.py index 79dfce5aa..1c2522689 100644 --- a/dingo/gw/inference/factors.py +++ b/dingo/gw/inference/factors.py @@ -18,6 +18,7 @@ import torch from astropy.time import Time from bilby.core.prior import DeltaFunction +from bilby.gw.detector import InterferometerList from torchvision.transforms import Compose from dingo.core.factors import ( @@ -25,16 +26,24 @@ ComposedSampler, Factor, FlowFactor, + GibbsComposer, _base_model_metadata, ) from dingo.core.posterior_models import BasePosteriorModel -from dingo.core.transforms import GetItem +from dingo.core.transforms import GetItem, RenameKey from dingo.gw.domains import build_domain, MultibandedFrequencyDomain from dingo.gw.gwutils import get_extrinsic_prior_dict from dingo.gw.prior import build_prior_with_defaults from dingo.gw.transforms import ( + CopyToExtrinsicParameters, DecimateWaveformsAndASDS, + GetDetectorTimes, + GNPEBase, + GNPECoalescenceTimes, + PostCorrectGeocentTime, RepackageStrainsAndASDS, + SelectStandardizeRepackageParameters, + TimeShiftStrain, ToTorch, WhitenAndScaleStrain, ) @@ -169,6 +178,122 @@ def log_prob(self, theta_i, cond): raise NotImplementedError("SyntheticPhaseFactor -- later step.") +class GNPEFlowFactor: + """ + One iteration of time-shift GNPE for the Gibbs composer. Given the current detector- + time estimates, it blurs them into proxies, shifts the strain, standardizes the + proxies as network context, samples the main network, then recomputes detector times + for the next iteration. Wraps the main model and the per-iteration transforms (the + same ones ``GWSamplerGNPE`` builds). + + Not a ``Factor``: the ABC's ``sample_and_log_prob`` does not fit a Gibbs step. It + exposes ``gibbs_step(num_samples, context, extrinsic) -> (parameters, extrinsic)``. + """ + + def __init__( + self, + model: BasePosteriorModel, + transform_pre: Compose, + transform_post: Compose, + gnpe_parameters: list[str], + parameters: list[str], + ): + self.model = model + self.transform_pre = transform_pre + self.transform_post = transform_post + self.gnpe_parameters = gnpe_parameters + self.proxy_parameters = [p + "_proxy" for p in gnpe_parameters] + self.parameters = parameters + + @classmethod + def from_model(cls, model: BasePosteriorModel) -> "GNPEFlowFactor": + """Build the GNPE per-iteration transforms from the main model's metadata, + replicating ``GWSamplerGNPE._initialize_transforms`` (time-shift GNPE).""" + meta = _base_model_metadata(model) + data_settings = meta["train_settings"]["data"] + ifo_list = InterferometerList(data_settings["detectors"]) + domain = build_domain(meta["dataset_settings"]["domain"]) + if "domain_update" in data_settings: + domain.update(data_settings["domain_update"]) + + gnpe_time_settings = data_settings.get("gnpe_time_shifts") + if not gnpe_time_settings: + raise NotImplementedError( + "Only time-shift GNPE is supported here so far " + "(no gnpe_chirp / gnpe_phase)." + ) + + transform_pre = [ + RenameKey("data", "waveform"), + GNPECoalescenceTimes( + ifo_list, + gnpe_time_settings["kernel"], + gnpe_time_settings["exact_equiv"], + inference=True, + ), + TimeShiftStrain(ifo_list, domain), + SelectStandardizeRepackageParameters( + {"context_parameters": data_settings["context_parameters"]}, + data_settings["standardization"], + device=model.device, + ), + RenameKey("waveform", "data"), + ] + gnpe_parameters = [] + for transform in transform_pre: + if isinstance(transform, GNPEBase): + gnpe_parameters += transform.input_parameter_names + + inference_parameters = data_settings["inference_parameters"] + transform_post = [ + SelectStandardizeRepackageParameters( + {"inference_parameters": inference_parameters}, + data_settings["standardization"], + inverse=True, + as_type="dict", + ), + PostCorrectGeocentTime(), + CopyToExtrinsicParameters( + "ra", "dec", "geocent_time", "chirp_mass", "mass_ratio", "phase" + ), + GetDetectorTimes(ifo_list, data_settings["ref_time"]), + ] + return cls( + model, + Compose(transform_pre), + Compose(transform_post), + gnpe_parameters, + inference_parameters, + ) + + def gibbs_step( + self, num_samples: int, context, extrinsic: dict + ) -> tuple[dict, dict]: + """One GNPE Gibbs iteration: returns (sampled parameters, updated extrinsic state + -- recomputed detector times + proxies for the next iteration).""" + x = { + "extrinsic_parameters": {k: extrinsic[k] for k in self.gnpe_parameters}, + "parameters": {}, + } + d = context.prepared_data().clone() + x["data"] = d.expand(num_samples, *d.shape) + x = self.transform_pre(x) + self.model.network.eval() + with torch.no_grad(): + if "context_parameters" in x: + y, log_prob = self.model.sample_and_log_prob( + x["data"], x["context_parameters"] + ) + else: + y, log_prob = self.model.sample_and_log_prob(x["data"]) + # sample_and_log_prob(num_samples=1) adds a singleton dim; the batch is the + # num_samples Gibbs walkers. + x["parameters"] = y.squeeze(1) + x["log_prob"] = log_prob.squeeze(1) + x = self.transform_post(x) + return x["parameters"], x["extrinsic_parameters"] + + class GWComposedSampler(ComposedSampler): """ GW façade: a ``ComposedSampler`` that adds the gravitational-wave post-processing -- @@ -180,7 +305,7 @@ class GWComposedSampler(ComposedSampler): def __init__( self, - composer: ChainComposer, + composer: Union[ChainComposer, GibbsComposer], context: GWSamplerContext, metadata: dict, inference_parameters: list[str], @@ -206,6 +331,30 @@ def from_model( inference_parameters=factor.parameters, ) + @classmethod + def from_gnpe_models( + cls, + init_model: BasePosteriorModel, + main_model: BasePosteriorModel, + raw_context: dict, + event_metadata: Optional[dict] = None, + num_iterations: int = 30, + ) -> "GWComposedSampler": + """Build a multi-iteration time-GNPE sampler from an init + main model pair: the + init model's data preprocessing, an init ``FlowFactor`` to seed, and a + ``GNPEFlowFactor`` driven by a ``GibbsComposer``. Returns samples without a + log_prob (Gibbs breaks density access).""" + context = GWSamplerContext.from_model(init_model, raw_context, event_metadata) + init_factor = FlowFactor.from_model(init_model) + gnpe_factor = GNPEFlowFactor.from_model(main_model) + composer = GibbsComposer(init_factor, gnpe_factor, num_iterations) + return cls( + composer, + context, + _base_model_metadata(main_model), + gnpe_factor.parameters, + ) + def _correct_reference_time( self, samples: Union[dict, pd.DataFrame], inverse: bool = False ): From 365daf101d50336ed5c9f4e35a8c1503cd36deed Mon Sep 17 00:00:00 2001 From: Stephen Green Date: Tue, 30 Jun 2026 14:41:14 +0200 Subject: [PATCH 04/65] ComposedSampler: accept GibbsComposer as well as ChainComposer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The façade already drives either composer at runtime (it needs only sample(num_samples, context) -> dict); broaden the type hint and docstring to match (Union[ChainComposer, GibbsComposer]). Co-Authored-By: Claude Opus 4.8 (1M context) --- dingo/core/factors.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/dingo/core/factors.py b/dingo/core/factors.py index ff299a6b4..e0df3baf8 100644 --- a/dingo/core/factors.py +++ b/dingo/core/factors.py @@ -24,7 +24,7 @@ import math from abc import ABC, abstractmethod from dataclasses import dataclass, field -from typing import Optional, Protocol, runtime_checkable +from typing import Optional, Protocol, Union, runtime_checkable import pandas as pd import torch @@ -333,14 +333,17 @@ def sample( class ComposedSampler: """ - Thin user-facing façade over a ``ChainComposer`` and a ``SamplerContext``: runs the - chain with optional batching, applies domain-specific post-processing, and returns - samples as a DataFrame. The per-factor compute lives in the composer; this class only - handles batching, consolidation, and post-processing -- the role the monolithic - ``Sampler`` plays today. + Thin user-facing façade over a composer (``ChainComposer`` or ``GibbsComposer``) and + a ``SamplerContext``: runs the composer with optional batching, applies domain-specific + post-processing, and returns samples as a DataFrame. The per-factor compute lives in + the composer (which need only expose ``sample(num_samples, context) -> dict``); this + class only handles batching, consolidation, and post-processing -- the role the + monolithic ``Sampler`` plays today. """ - def __init__(self, composer: ChainComposer, context: SamplerContext): + def __init__( + self, composer: Union[ChainComposer, GibbsComposer], context: SamplerContext + ): self.composer = composer self.context = context self.samples: Optional[pd.DataFrame] = None From 4e43778a10c7d752d6ed7dfa84319f0c332738b1 Mon Sep 17 00:00:00 2001 From: Maximilian Dax Date: Tue, 30 Jun 2026 14:55:24 +0200 Subject: [PATCH 05/65] Add per-factor batching to the factorized sampler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move batching from the ComposedSampler façade's single whole-chain loop down to each Factor: a per-factor `batch_size` plus base-class batching wrappers (`sample_and_log_prob`/`log_prob`) that chunk `num_samples`, slice the conditioning to match, call the `_sample_and_log_prob`/`_log_prob` hooks, and concatenate. The ChainComposer already materializes the full sample set between factors (cheap parameter vectors), so each factor batches independently at the size that fits its own memory footprint; the peak is the single most expensive factor, not the whole chain at one global size. GNPEFlowFactor.gibbs_step gets the same treatment, chunking the (independent) Gibbs walkers. The façade keeps `run_sampler(batch_size=...)` as a convenience that broadcasts a default across factors without their own. `batch_size=None` short-circuits to the original single-pass path, so default behavior is unchanged and plain-NPE/GNPE parity is preserved. Co-Authored-By: Claude Opus 4.8 (1M context) --- dingo/core/factors.py | 114 +++++++++++++++++++++++++++++----- dingo/gw/inference/factors.py | 37 ++++++++--- 2 files changed, 130 insertions(+), 21 deletions(-) diff --git a/dingo/core/factors.py b/dingo/core/factors.py index e0df3baf8..227afda5b 100644 --- a/dingo/core/factors.py +++ b/dingo/core/factors.py @@ -114,6 +114,28 @@ class Conditioning: given: dict[str, torch.Tensor] = field(default_factory=dict) +def _cat_dict( + batches: list[dict[str, torch.Tensor]], +) -> dict[str, torch.Tensor]: + """Concatenate a list of ``name -> tensor`` dicts along dim 0 (re-joining batches).""" + return {k: torch.cat([b[k] for b in batches]) for k in batches[0]} + + +def _slice_given(cond: Conditioning, start: int, stop: int) -> Conditioning: + """A view of ``cond`` whose conditioning values are restricted to rows + ``[start:stop]``, so a factor's batch sees the conditioning aligned with the samples + it produces. The shared context is unchanged (the prepared data is one event).""" + return Conditioning( + cond.context, {k: v[start:stop] for k, v in cond.given.items()} + ) + + +def _batch_bounds(num_samples: int, batch_size: int): + """Yield ``(start, stop)`` chunks covering ``range(num_samples)``.""" + for start in range(0, num_samples, batch_size): + yield start, min(start + batch_size, num_samples) + + class Factor(ABC): """ A conditional distribution ``q_i(theta_i | f_i(theta_ no + internal batching. """ parameters: list[str] conditioning: list[str] + batch_size: Optional[int] = None - @abstractmethod def sample_and_log_prob( self, num_samples: int, cond: Conditioning ) -> tuple[dict[str, torch.Tensor], torch.Tensor]: - """Draw ``theta_i ~ q_i``; return ``(physical samples, physical-space log q_i)``.""" + """Draw ``theta_i ~ q_i`` (internally batched by ``batch_size``); return + ``(physical samples, physical-space log q_i)``.""" + if not self.batch_size or self.batch_size >= num_samples: + return self._sample_and_log_prob(num_samples, cond) + sample_batches, lp_batches = [], [] + for start, stop in _batch_bounds(num_samples, self.batch_size): + block, lp = self._sample_and_log_prob( + stop - start, _slice_given(cond, start, stop) + ) + sample_batches.append(block) + lp_batches.append(lp) + return _cat_dict(sample_batches), torch.cat(lp_batches) - @abstractmethod def log_prob( self, theta_i: dict[str, torch.Tensor], cond: Conditioning ) -> torch.Tensor: """Evaluate ``log q_i(theta_i | f_i)`` at given physical ``theta_i`` (for IS / - re-plug).""" + re-plug), internally batched by ``batch_size``.""" + num_samples = next(iter(theta_i.values())).shape[0] + if not self.batch_size or self.batch_size >= num_samples: + return self._log_prob(theta_i, cond) + lp_batches = [] + for start, stop in _batch_bounds(num_samples, self.batch_size): + chunk = {k: v[start:stop] for k, v in theta_i.items()} + lp_batches.append(self._log_prob(chunk, _slice_given(cond, start, stop))) + return torch.cat(lp_batches) + + @abstractmethod + def _sample_and_log_prob( + self, num_samples: int, cond: Conditioning + ) -> tuple[dict[str, torch.Tensor], torch.Tensor]: + """Single-pass draw of ``num_samples`` from ``q_i`` (no internal batching).""" + + @abstractmethod + def _log_prob( + self, theta_i: dict[str, torch.Tensor], cond: Conditioning + ) -> torch.Tensor: + """Single-pass evaluation of ``log q_i`` at given physical ``theta_i``.""" def _base_model_metadata(model: BasePosteriorModel) -> dict: @@ -176,17 +240,21 @@ def __init__( parameters: list[str], conditioning: Optional[list[str]] = None, context_parameters: Optional[list[str]] = None, + batch_size: Optional[int] = None, ): self.model = model self.parameters = parameters self.conditioning = conditioning or [] # Network conditioning inputs (GNPE proxies). Empty for plain NPE. self.context_parameters = context_parameters or [] + self.batch_size = batch_size std = _base_model_metadata(model)["train_settings"]["data"]["standardization"] self.standardization = Standardization(std["mean"], std["std"]) @classmethod - def from_model(cls, model: BasePosteriorModel) -> "FlowFactor": + def from_model( + cls, model: BasePosteriorModel, batch_size: Optional[int] = None + ) -> "FlowFactor": """Build a factor from a model: ``parameters`` and ``context_parameters`` come from the model's training metadata (first-class conditioning).""" data_settings = _base_model_metadata(model)["train_settings"]["data"] @@ -196,6 +264,7 @@ def from_model(cls, model: BasePosteriorModel) -> "FlowFactor": parameters=data_settings["inference_parameters"], conditioning=list(context_parameters), context_parameters=list(context_parameters), + batch_size=batch_size, ) def _network_context(self, cond: Conditioning) -> tuple[torch.Tensor, ...]: @@ -208,7 +277,7 @@ def _network_context(self, cond: Conditioning) -> tuple[torch.Tensor, ...]: ctx = self.standardization.standardize(cond.given, self.context_parameters) return data.unsqueeze(0), ctx.unsqueeze(0) - def sample_and_log_prob(self, num_samples, cond): + def _sample_and_log_prob(self, num_samples, cond): net_context = self._network_context(cond) self.model.network.eval() with torch.no_grad(): @@ -222,7 +291,7 @@ def sample_and_log_prob(self, num_samples, cond): log_prob = log_prob + self.standardization.log_det(self.parameters) return theta, log_prob - def log_prob(self, theta_i, cond): + def _log_prob(self, theta_i, cond): num_samples = next(iter(theta_i.values())).shape[0] z = self.standardization.standardize(theta_i, self.parameters) data = cond.context.prepared_data() @@ -296,6 +365,14 @@ def sample( samples, log_prob = self.sample_and_log_prob(num_samples, context) return {**samples, "log_prob": log_prob} + def set_default_batch_size(self, batch_size: Optional[int]): + """Apply ``batch_size`` as the default to every factor that has not set its own + (per-factor ``batch_size`` takes precedence). The façade calls this to honour a + single ``run_sampler(batch_size=...)`` request across the whole chain.""" + for factor in self.factors: + if factor.batch_size is None: + factor.batch_size = batch_size + class GibbsComposer: """ @@ -330,6 +407,13 @@ def sample( } return {**parameters, **proxies} + def set_default_batch_size(self, batch_size: Optional[int]): + """Apply ``batch_size`` as the default to the init and GNPE factors that have not + set their own (per-factor ``batch_size`` takes precedence).""" + for factor in (self.init_factor, self.gnpe_factor): + if getattr(factor, "batch_size", None) is None: + factor.batch_size = batch_size + class ComposedSampler: """ @@ -361,13 +445,15 @@ def _run_batch(self, num_samples: int) -> dict[str, torch.Tensor]: def run_sampler( self, num_samples: int, batch_size: Optional[int] = None ) -> pd.DataFrame: - if batch_size is None: - batch_size = num_samples - full_batches, remainder = divmod(num_samples, batch_size) - batches = [self._run_batch(batch_size) for _ in range(full_batches)] - if remainder > 0: - batches.append(self._run_batch(remainder)) - merged = {k: torch.cat([b[k] for b in batches]) for k in batches[0].keys()} + """Run the chain and return samples as a DataFrame. + + Batching is per-factor (each factor splits its own ``num_samples`` by its + ``batch_size``); ``batch_size`` here is a convenience that broadcasts a single + default across every factor without its own, reproducing one global batch size. + ``None`` -> no batching.""" + if batch_size is not None: + self.composer.set_default_batch_size(batch_size) + merged = self._run_batch(num_samples) merged = {k: v.cpu().numpy() for k, v in merged.items()} self._post_process(merged) self.samples = pd.DataFrame(merged) diff --git a/dingo/gw/inference/factors.py b/dingo/gw/inference/factors.py index 1c2522689..735d6439e 100644 --- a/dingo/gw/inference/factors.py +++ b/dingo/gw/inference/factors.py @@ -28,6 +28,7 @@ FlowFactor, GibbsComposer, _base_model_metadata, + _cat_dict, ) from dingo.core.posterior_models import BasePosteriorModel from dingo.core.transforms import GetItem, RenameKey @@ -152,13 +153,13 @@ def __init__(self, values: dict[str, float]): self.parameters = list(values) self.conditioning = [] - def sample_and_log_prob(self, num_samples, cond): + def _sample_and_log_prob(self, num_samples, cond): samples = { p: torch.full((num_samples,), float(v)) for p, v in self.values.items() } return samples, torch.zeros(num_samples) - def log_prob(self, theta_i, cond): + def _log_prob(self, theta_i, cond): raise NotImplementedError("FixedFactor.log_prob -- later step.") @@ -171,10 +172,10 @@ def __init__(self): self.parameters = ["phase"] self.conditioning = [] # conditions on all preceding params via the likelihood - def sample_and_log_prob(self, num_samples, cond): + def _sample_and_log_prob(self, num_samples, cond): raise NotImplementedError("SyntheticPhaseFactor -- later step.") - def log_prob(self, theta_i, cond): + def _log_prob(self, theta_i, cond): raise NotImplementedError("SyntheticPhaseFactor -- later step.") @@ -197,6 +198,7 @@ def __init__( transform_post: Compose, gnpe_parameters: list[str], parameters: list[str], + batch_size: Optional[int] = None, ): self.model = model self.transform_pre = transform_pre @@ -204,9 +206,12 @@ def __init__( self.gnpe_parameters = gnpe_parameters self.proxy_parameters = [p + "_proxy" for p in gnpe_parameters] self.parameters = parameters + self.batch_size = batch_size @classmethod - def from_model(cls, model: BasePosteriorModel) -> "GNPEFlowFactor": + def from_model( + cls, model: BasePosteriorModel, batch_size: Optional[int] = None + ) -> "GNPEFlowFactor": """Build the GNPE per-iteration transforms from the main model's metadata, replicating ``GWSamplerGNPE._initialize_transforms`` (time-shift GNPE).""" meta = _base_model_metadata(model) @@ -264,13 +269,31 @@ def from_model(cls, model: BasePosteriorModel) -> "GNPEFlowFactor": Compose(transform_post), gnpe_parameters, inference_parameters, + batch_size=batch_size, ) def gibbs_step( self, num_samples: int, context, extrinsic: dict ) -> tuple[dict, dict]: - """One GNPE Gibbs iteration: returns (sampled parameters, updated extrinsic state - -- recomputed detector times + proxies for the next iteration).""" + """One GNPE Gibbs iteration, internally batched by ``batch_size`` over the Gibbs + walkers (independent across the batch dimension), to bound the per-pass memory. + ``batch_size = None`` runs all walkers in one pass.""" + if not self.batch_size or self.batch_size >= num_samples: + return self._gibbs_step(num_samples, context, extrinsic) + param_batches, extrinsic_batches = [], [] + for start in range(0, num_samples, self.batch_size): + stop = min(start + self.batch_size, num_samples) + chunk = {k: v[start:stop] for k, v in extrinsic.items()} + params, extr = self._gibbs_step(stop - start, context, chunk) + param_batches.append(params) + extrinsic_batches.append(extr) + return _cat_dict(param_batches), _cat_dict(extrinsic_batches) + + def _gibbs_step( + self, num_samples: int, context, extrinsic: dict + ) -> tuple[dict, dict]: + """One GNPE Gibbs iteration (single pass): returns (sampled parameters, updated + extrinsic state -- recomputed detector times + proxies for the next iteration).""" x = { "extrinsic_parameters": {k: extrinsic[k] for k in self.gnpe_parameters}, "parameters": {}, From f045821063306522b89e66ef415cbbce983603fc Mon Sep 17 00:00:00 2001 From: Stephen Green Date: Tue, 30 Jun 2026 14:57:24 +0200 Subject: [PATCH 06/65] Add GWComposedSampler.to_result() Export the factorized sampler's output to a gw Result so the existing post-processing pipeline -- synthetic phase, importance sampling, evidence, plotting -- runs on it unchanged. `context` is the raw event data (GWSamplerContext.raw_context), which Result needs to rebuild the likelihood. Validated end-to-end: NPE -> to_result() -> importance_sample reproduces the current GWSampler pipeline bit-exactly (log_prob, log_likelihood, log_prior, weights, log_evidence all max|delta|=0); GNPE -> to_result() builds a valid Result. Co-Authored-By: Claude Opus 4.8 (1M context) --- dingo/gw/inference/factors.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/dingo/gw/inference/factors.py b/dingo/gw/inference/factors.py index 735d6439e..6529d4407 100644 --- a/dingo/gw/inference/factors.py +++ b/dingo/gw/inference/factors.py @@ -11,6 +11,7 @@ from __future__ import annotations +import copy from typing import Optional, Union import numpy as np @@ -426,3 +427,24 @@ def _post_process(self, samples: Union[dict, pd.DataFrame], inverse: bool = Fals samples.pop(k, None) self._correct_reference_time(samples, inverse) + + def to_result(self): + """Export to a gw ``Result`` (samples + raw event data + metadata), so the + existing post-processing pipeline -- synthetic phase, importance sampling, + evidence, plotting -- runs on the factorized sampler's output unchanged. + + ``context`` is the *raw* event-data dict (``GWSamplerContext.raw_context``, what + ``Result`` needs to rebuild the likelihood), not the ``SamplerContext`` object. + """ + from dingo.gw.result import Result + + data_dict = { + "samples": self.samples, + "context": self.context.raw_context, + "event_metadata": self.context.event_metadata, + "importance_sampling_metadata": None, + "log_evidence": None, + "log_noise_evidence": None, + "settings": copy.deepcopy(self.metadata), + } + return Result(dictionary=data_dict) From cd0c502d88ae034f1180b1f292b43684f27a7846 Mon Sep 17 00:00:00 2001 From: Stephen Green Date: Tue, 30 Jun 2026 15:02:18 +0200 Subject: [PATCH 07/65] Apply black formatting Two cosmetic fixups in the merged factor files (docstring close + a one-line call) so the branch is black-clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- dingo/core/factors.py | 4 +--- dingo/gw/inference/factors.py | 3 ++- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/dingo/core/factors.py b/dingo/core/factors.py index 227afda5b..e3503ea60 100644 --- a/dingo/core/factors.py +++ b/dingo/core/factors.py @@ -125,9 +125,7 @@ def _slice_given(cond: Conditioning, start: int, stop: int) -> Conditioning: """A view of ``cond`` whose conditioning values are restricted to rows ``[start:stop]``, so a factor's batch sees the conditioning aligned with the samples it produces. The shared context is unchanged (the prepared data is one event).""" - return Conditioning( - cond.context, {k: v[start:stop] for k, v in cond.given.items()} - ) + return Conditioning(cond.context, {k: v[start:stop] for k, v in cond.given.items()}) def _batch_bounds(num_samples: int, batch_size: int): diff --git a/dingo/gw/inference/factors.py b/dingo/gw/inference/factors.py index 6529d4407..e6e94c1f2 100644 --- a/dingo/gw/inference/factors.py +++ b/dingo/gw/inference/factors.py @@ -294,7 +294,8 @@ def _gibbs_step( self, num_samples: int, context, extrinsic: dict ) -> tuple[dict, dict]: """One GNPE Gibbs iteration (single pass): returns (sampled parameters, updated - extrinsic state -- recomputed detector times + proxies for the next iteration).""" + extrinsic state -- recomputed detector times + proxies for the next iteration). + """ x = { "extrinsic_parameters": {k: extrinsic[k] for k in self.gnpe_parameters}, "parameters": {}, From 79108d2f4daf7df73194c9e7966507334afade3b Mon Sep 17 00:00:00 2001 From: Stephen Green Date: Tue, 30 Jun 2026 22:59:58 +0200 Subject: [PATCH 08/65] Replace per-factor batching with a vertical chain executor Pull batching out of the factors into a single vertical (depth-first) executor on ChainComposer, driven by a shared chunk_and_concat helper (also used by GibbsComposer). Add Stage(factor, fan_out) so a chain can expand num_samples per conditioning row (M intrinsic x K extrinsic); num_samples is the base/root count, total rows = num_samples * expansion. Factors become single-pass: drop batch_size, the batching wrapper methods, and _slice_given / _batch_bounds / set_default_batch_size / _gibbs_step (keep _cat_dict); inline ComposedSampler._run_batch into run_sampler. Fix FlowFactor's conditioned path to draw num_samples per conditioning row (N-row context + expanded data), matching what GNPEFlowFactor already does. Rationale: only the vertical executor bounds fan-out memory (you cannot materialize M*K intermediates for 1000-extrinsic-per-intrinsic), and chunking the whole chain / Gibbs loop per base-chunk reproduces the old sampler's batching order, so parity is exact. See the hackathon design doc section 14. Bit-exact vs GWSampler (NPE) and GWSamplerGNPE (multi-iteration GNPE), batched and unbatched, including post-processing. Add tests/core/test_factors.py (mock-factor unit tests for fan-out expansion + alignment, log-prob summation, topological validation, and the batching primitive). Co-Authored-By: Claude Opus 4.8 (1M context) --- dingo/core/factors.py | 298 +++++++++++++++++++--------------- dingo/gw/inference/factors.py | 34 +--- tests/core/test_factors.py | 148 +++++++++++++++++ 3 files changed, 321 insertions(+), 159 deletions(-) create mode 100644 tests/core/test_factors.py diff --git a/dingo/core/factors.py b/dingo/core/factors.py index e3503ea60..0e36e4872 100644 --- a/dingo/core/factors.py +++ b/dingo/core/factors.py @@ -24,7 +24,7 @@ import math from abc import ABC, abstractmethod from dataclasses import dataclass, field -from typing import Optional, Protocol, Union, runtime_checkable +from typing import Callable, Optional, Protocol, Union, runtime_checkable import pandas as pd import torch @@ -121,17 +121,33 @@ def _cat_dict( return {k: torch.cat([b[k] for b in batches]) for k in batches[0]} -def _slice_given(cond: Conditioning, start: int, stop: int) -> Conditioning: - """A view of ``cond`` whose conditioning values are restricted to rows - ``[start:stop]``, so a factor's batch sees the conditioning aligned with the samples - it produces. The shared context is unchanged (the prepared data is one event).""" - return Conditioning(cond.context, {k: v[start:stop] for k, v in cond.given.items()}) - - -def _batch_bounds(num_samples: int, batch_size: int): - """Yield ``(start, stop)`` chunks covering ``range(num_samples)``.""" - for start in range(0, num_samples, batch_size): - yield start, min(start + batch_size, num_samples) +def chunk_and_concat( + total: int, + batch_size: Optional[int], + run_once: Callable[[int], tuple[dict[str, torch.Tensor], Optional[torch.Tensor]]], +) -> tuple[dict[str, torch.Tensor], Optional[torch.Tensor]]: + """The vertical (depth-first) batching primitive, shared by the chain and Gibbs + composers. + + Split ``total`` base samples into chunks of ``batch_size``, run ``run_once(n)`` -- a + full pass through the *whole* pipeline (the entire chain, or the entire Gibbs loop) -- + per chunk, and concatenate. Working memory is bounded to one chunk carried through the + pipeline; only the output buffer grows to ``total`` (a real run streams it to CPU per + chunk). This is the order the old ``Sampler.run_sampler`` batches in, so parity is + exact when ``batch_size`` matches. ``batch_size=None`` runs everything in one pass. + + ``run_once`` returns ``(samples, log_prob)``; ``log_prob`` may be ``None`` (the Gibbs + path has no tractable density).""" + bs = batch_size or total + sample_parts: list[dict[str, torch.Tensor]] = [] + lp_parts: list[Optional[torch.Tensor]] = [] + for start in range(0, total, bs): + block, lp = run_once(min(bs, total - start)) + sample_parts.append(block) + lp_parts.append(lp) + samples = _cat_dict(sample_parts) + log_prob = None if lp_parts[0] is None else torch.cat(lp_parts) + return samples, log_prob class Factor(ABC): @@ -141,14 +157,14 @@ class Factor(ABC): standardization Jacobian already applied), so the chain product ``sum_i log q_i`` is directly the physical posterior log-density. - Batching is a per-factor concern: ``sample_and_log_prob`` / ``log_prob`` are - batching wrappers that split ``num_samples`` into chunks of ``batch_size`` (slicing - the conditioning to match), call the subclass hooks ``_sample_and_log_prob`` / - ``_log_prob`` per chunk, and concatenate. Because the chain composer already - materializes the full sample set between factors, each factor batches independently - at the size that fits *its own* memory footprint -- the peak is the single most - expensive factor at its own ``batch_size``, not the whole chain at one size. - ``batch_size = None`` runs the whole request in one pass (identical to no batching). + *Physical in, physical out*, and a **single forward pass per call** -- batching is not + a factor concern; the composer drives it (``chunk_and_concat``). + + Contract the composer relies on (this is what makes fan-out work): a call draws + ``num_samples`` samples **per conditioning row** and returns ``n_rows * num_samples`` + rows, flattened row-major, where ``n_rows`` is the number of rows in ``cond.given`` + (or 1 if unconditioned). This mirrors the network's own + ``sample_and_log_prob(*context, num_samples=n) -> (B, n, dim)`` with ``B = n_rows``. Attributes ---------- @@ -157,56 +173,25 @@ class Factor(ABC): conditioning : list[str] Earlier-block parameter names this factor conditions on. Data dependence is implicit via the shared context. - batch_size : int, optional - Max number of samples processed in a single forward pass. ``None`` -> no - internal batching. """ parameters: list[str] conditioning: list[str] - batch_size: Optional[int] = None + @abstractmethod def sample_and_log_prob( self, num_samples: int, cond: Conditioning ) -> tuple[dict[str, torch.Tensor], torch.Tensor]: - """Draw ``theta_i ~ q_i`` (internally batched by ``batch_size``); return - ``(physical samples, physical-space log q_i)``.""" - if not self.batch_size or self.batch_size >= num_samples: - return self._sample_and_log_prob(num_samples, cond) - sample_batches, lp_batches = [], [] - for start, stop in _batch_bounds(num_samples, self.batch_size): - block, lp = self._sample_and_log_prob( - stop - start, _slice_given(cond, start, stop) - ) - sample_batches.append(block) - lp_batches.append(lp) - return _cat_dict(sample_batches), torch.cat(lp_batches) + """Draw ``num_samples`` per conditioning row from ``q_i``; return ``(physical + samples, physical-space log q_i)``. The returned dict may carry extra named + side-channel columns (e.g. an IS kernel correction) alongside ``parameters``.""" + @abstractmethod def log_prob( self, theta_i: dict[str, torch.Tensor], cond: Conditioning ) -> torch.Tensor: """Evaluate ``log q_i(theta_i | f_i)`` at given physical ``theta_i`` (for IS / - re-plug), internally batched by ``batch_size``.""" - num_samples = next(iter(theta_i.values())).shape[0] - if not self.batch_size or self.batch_size >= num_samples: - return self._log_prob(theta_i, cond) - lp_batches = [] - for start, stop in _batch_bounds(num_samples, self.batch_size): - chunk = {k: v[start:stop] for k, v in theta_i.items()} - lp_batches.append(self._log_prob(chunk, _slice_given(cond, start, stop))) - return torch.cat(lp_batches) - - @abstractmethod - def _sample_and_log_prob( - self, num_samples: int, cond: Conditioning - ) -> tuple[dict[str, torch.Tensor], torch.Tensor]: - """Single-pass draw of ``num_samples`` from ``q_i`` (no internal batching).""" - - @abstractmethod - def _log_prob( - self, theta_i: dict[str, torch.Tensor], cond: Conditioning - ) -> torch.Tensor: - """Single-pass evaluation of ``log q_i`` at given physical ``theta_i``.""" + re-plug). One conditioning row per ``theta_i`` row (no fan-out).""" def _base_model_metadata(model: BasePosteriorModel) -> dict: @@ -238,21 +223,17 @@ def __init__( parameters: list[str], conditioning: Optional[list[str]] = None, context_parameters: Optional[list[str]] = None, - batch_size: Optional[int] = None, ): self.model = model self.parameters = parameters self.conditioning = conditioning or [] # Network conditioning inputs (GNPE proxies). Empty for plain NPE. self.context_parameters = context_parameters or [] - self.batch_size = batch_size std = _base_model_metadata(model)["train_settings"]["data"]["standardization"] self.standardization = Standardization(std["mean"], std["std"]) @classmethod - def from_model( - cls, model: BasePosteriorModel, batch_size: Optional[int] = None - ) -> "FlowFactor": + def from_model(cls, model: BasePosteriorModel) -> "FlowFactor": """Build a factor from a model: ``parameters`` and ``context_parameters`` come from the model's training metadata (first-class conditioning).""" data_settings = _base_model_metadata(model)["train_settings"]["data"] @@ -262,34 +243,41 @@ def from_model( parameters=data_settings["inference_parameters"], conditioning=list(context_parameters), context_parameters=list(context_parameters), - batch_size=batch_size, ) - def _network_context(self, cond: Conditioning) -> tuple[torch.Tensor, ...]: - """Assemble the model's positional context: the prepared data, plus standardized - context parameters when the network conditions on proxies.""" + def sample_and_log_prob(self, num_samples, cond): + """Draw ``num_samples`` per conditioning row. Unconditioned (plain NPE): one + shared data context, ``num_samples`` draws. Conditioned: ``N`` context rows in + (one embedding pass each), ``num_samples`` draws per row -> ``N * num_samples`` + rows, flattened row-major to align with the composer's ``repeat_interleave``.""" data = cond.context.prepared_data() - if not self.context_parameters: - return (data.unsqueeze(0),) - # Standardize the (physical) conditioning values the network was trained on. - ctx = self.standardization.standardize(cond.given, self.context_parameters) - return data.unsqueeze(0), ctx.unsqueeze(0) - - def _sample_and_log_prob(self, num_samples, cond): - net_context = self._network_context(cond) self.model.network.eval() - with torch.no_grad(): - z, log_prob = self.model.sample_and_log_prob( - *net_context, num_samples=num_samples - ) - # Squeeze the batch dimension added for the (single) context. - z = z.squeeze(0) - log_prob = log_prob.squeeze(0) + if not self.context_parameters: + with torch.no_grad(): + z, log_prob = self.model.sample_and_log_prob( + data.unsqueeze(0), num_samples=num_samples + ) + # Squeeze the batch dimension added for the single shared context. + z = z.squeeze(0) + log_prob = log_prob.squeeze(0) + else: + # Standardize the (physical) conditioning the network was trained on, giving + # B = N context rows; expand the shared data to match. The network draws + # num_samples per row -> (N, num_samples, dim). + ctx = self.standardization.standardize(cond.given, self.context_parameters) + n_rows = ctx.shape[0] + data = data.expand(n_rows, *data.shape) + with torch.no_grad(): + z, log_prob = self.model.sample_and_log_prob( + data, ctx, num_samples=num_samples + ) + z = z.reshape(n_rows * num_samples, z.shape[-1]) + log_prob = log_prob.reshape(n_rows * num_samples) theta = self.standardization.destandardize(z, self.parameters) log_prob = log_prob + self.standardization.log_det(self.parameters) return theta, log_prob - def _log_prob(self, theta_i, cond): + def log_prob(self, theta_i, cond): num_samples = next(iter(theta_i.values())).shape[0] z = self.standardization.standardize(theta_i, self.parameters) data = cond.context.prepared_data() @@ -306,19 +294,37 @@ def _log_prob(self, theta_i, cond): return log_prob + self.standardization.log_det(self.parameters) +@dataclass +class Stage: + """One factor in the chain plus its **fan-out**: how many samples it draws per + incoming conditioning row. The root (first) stage ignores ``fan_out`` -- it draws the + base count directly; a 1:1 chain link uses ``fan_out=1``; an intrinsic/extrinsic + expansion uses ``fan_out=K`` (``K`` extrinsic draws per intrinsic sample).""" + + factor: Factor + fan_out: int = 1 + + class ChainComposer: """ - Autoregressive composer: sample (or evaluate) the factors in declared order, which - must be a topological order of the dependency DAG, and sum their log-probs. Exact - and importance-sampling-friendly. Covers plain NPE, single-step GNPE, prior - conditioning, synthetic phase, and intrinsic/extrinsic splits. - - Multi-iteration (cyclic) GNPE is *not* expressible here and uses a separate Gibbs - composer. + Autoregressive composer **and vertical (depth-first) executor**: sample the stages in + declared order (a topological order of the dependency DAG), expanding by each stage's + fan-out, and sum their log-probs. Exact and importance-sampling-friendly. Covers plain + NPE, single-step GNPE, prior conditioning, synthetic phase, and intrinsic/extrinsic + splits. + + Vertical execution carries a chunk of base samples all the way down the chain, emits + it, and discards -- bounding working memory to one chunk (the only way to draw ``M`` + intrinsic x ``K`` extrinsic without materializing ``M*K`` intermediates) and + reproducing the old sampler's batching order (bit-exact when ``batch_size`` matches). + Batching lives in ``chunk_and_concat``; the factors are single-pass. + + Multi-iteration (cyclic) GNPE is *not* expressible here and uses ``GibbsComposer``. + Accepts bare factors (wrapped as ``Stage(factor, fan_out=1)``) or explicit ``Stage``s. """ - def __init__(self, factors: list[Factor]): - self.factors = factors + def __init__(self, stages: list[Union["Stage", Factor]]): + self.stages = [s if isinstance(s, Stage) else Stage(s) for s in stages] self._validate() def _validate(self): @@ -334,14 +340,51 @@ def _validate(self): ) produced.update(factor.parameters) + @property + def factors(self) -> list[Factor]: + """The factors in order (without fan-out), for callers that only need them.""" + return [stage.factor for stage in self.stages] + + @property + def expansion(self) -> int: + """Product of the non-root fan-outs; total rows returned = num_samples * + expansion (e.g. M intrinsic x K extrinsic for an extrinsic fan-out of K).""" + return math.prod(stage.fan_out for stage in self.stages[1:]) + def sample_and_log_prob( - self, num_samples: int, context: SamplerContext + self, + num_samples: int, + context: SamplerContext, + batch_size: Optional[int] = None, + ) -> tuple[dict[str, torch.Tensor], torch.Tensor]: + """``num_samples`` is the **base (root) count** -- e.g. ``M`` intrinsic samples. + Each fan-out stage multiplies it, so the result has ``num_samples * expansion`` + rows (``M * K`` for an extrinsic ``fan_out=K``); for a plain chain (no fan-out) + that is just ``num_samples``. ``batch_size`` chunks the base count, in the same + (root) unit as ``num_samples``.""" + return chunk_and_concat( + num_samples, batch_size, lambda n: self._run_chain_once(n, context) + ) + + def _run_chain_once( + self, base: int, context: SamplerContext ) -> tuple[dict[str, torch.Tensor], torch.Tensor]: + """One depth-first pass of the whole chain for ``base`` root samples.""" samples: dict[str, torch.Tensor] = {} total: torch.Tensor | float = 0.0 - for factor in self.factors: + for i, stage in enumerate(self.stages): + factor = stage.factor + n = base if i == 0 else stage.fan_out cond = Conditioning(context, {k: samples[k] for k in factor.conditioning}) - block, lp = factor.sample_and_log_prob(num_samples, cond) + block, lp = factor.sample_and_log_prob(n, cond) + # The factor returned (rows_so_far * n) rows. Expand the carried columns to + # match -- each upstream row repeated n times (the block is flattened in the + # same row-major order). The root has no carried rows, and fan_out=1 stages + # are a no-op, so 1:1 chains are untouched. + if i > 0 and n > 1: + samples = {k: v.repeat_interleave(n, 0) for k, v in samples.items()} + if torch.is_tensor(total): + total = total.repeat_interleave(n, 0) samples.update(block) total = total + lp return samples, total @@ -357,20 +400,15 @@ def log_prob( return total def sample( - self, num_samples: int, context: SamplerContext + self, + num_samples: int, + context: SamplerContext, + batch_size: Optional[int] = None, ) -> dict[str, torch.Tensor]: """Full per-sample dict (parameters + ``log_prob``), for the sampler façade.""" - samples, log_prob = self.sample_and_log_prob(num_samples, context) + samples, log_prob = self.sample_and_log_prob(num_samples, context, batch_size) return {**samples, "log_prob": log_prob} - def set_default_batch_size(self, batch_size: Optional[int]): - """Apply ``batch_size`` as the default to every factor that has not set its own - (per-factor ``batch_size`` takes precedence). The façade calls this to honour a - single ``run_sampler(batch_size=...)`` request across the whole chain.""" - for factor in self.factors: - if factor.batch_size is None: - factor.batch_size = batch_size - class GibbsComposer: """ @@ -379,7 +417,10 @@ class GibbsComposer: (``gnpe_factor.gibbs_step``) to a fixed point. The dependency is cyclic (theta <-> proxies), so this breaks density access: ``sample`` returns parameters WITHOUT a ``log_prob``. (Single-step GNPE that *does* preserve log_prob is a - two-factor ``ChainComposer`` instead.) + two-stage ``ChainComposer`` instead.) + + Batching is vertical, via ``chunk_and_concat``: chunk the Gibbs walkers and run the + whole loop per chunk -- the old ``GWSamplerGNPE.run_sampler`` order. """ def __init__(self, init_factor: Factor, gnpe_factor, num_iterations: int): @@ -388,6 +429,17 @@ def __init__(self, init_factor: Factor, gnpe_factor, num_iterations: int): self.num_iterations = num_iterations def sample( + self, + num_samples: int, + context: SamplerContext, + batch_size: Optional[int] = None, + ) -> dict[str, torch.Tensor]: + samples, _ = chunk_and_concat( + num_samples, batch_size, lambda n: (self._run_once(n, context), None) + ) + return samples + + def _run_once( self, num_samples: int, context: SamplerContext ) -> dict[str, torch.Tensor]: # Seed the Gibbs chain with the init factor's parameters (e.g. detector times). @@ -405,22 +457,15 @@ def sample( } return {**parameters, **proxies} - def set_default_batch_size(self, batch_size: Optional[int]): - """Apply ``batch_size`` as the default to the init and GNPE factors that have not - set their own (per-factor ``batch_size`` takes precedence).""" - for factor in (self.init_factor, self.gnpe_factor): - if getattr(factor, "batch_size", None) is None: - factor.batch_size = batch_size - class ComposedSampler: """ Thin user-facing façade over a composer (``ChainComposer`` or ``GibbsComposer``) and - a ``SamplerContext``: runs the composer with optional batching, applies domain-specific - post-processing, and returns samples as a DataFrame. The per-factor compute lives in - the composer (which need only expose ``sample(num_samples, context) -> dict``); this - class only handles batching, consolidation, and post-processing -- the role the - monolithic ``Sampler`` plays today. + a ``SamplerContext``: runs the composer (which bounds memory via vertical batching), + applies domain-specific post-processing, and returns samples as a DataFrame. The + per-factor compute lives in the composer (which need only expose + ``sample(num_samples, context, batch_size) -> dict``); this class only handles + consolidation and post-processing -- the role the monolithic ``Sampler`` plays today. """ def __init__( @@ -435,23 +480,14 @@ def _post_process(self, samples: dict, inverse: bool = False): and reference-time correction). No-op by default.""" pass - def _run_batch(self, num_samples: int) -> dict[str, torch.Tensor]: - # Uniform across composers: ChainComposer.sample adds log_prob; GibbsComposer - # (multi-iteration GNPE) returns parameters + proxies with no log_prob. - return self.composer.sample(num_samples, self.context) - def run_sampler( self, num_samples: int, batch_size: Optional[int] = None ) -> pd.DataFrame: - """Run the chain and return samples as a DataFrame. - - Batching is per-factor (each factor splits its own ``num_samples`` by its - ``batch_size``); ``batch_size`` here is a convenience that broadcasts a single - default across every factor without its own, reproducing one global batch size. - ``None`` -> no batching.""" - if batch_size is not None: - self.composer.set_default_batch_size(batch_size) - merged = self._run_batch(num_samples) + """Run the composer (vertically batched by ``batch_size``) and return samples as a + DataFrame. ``ChainComposer.sample`` adds ``log_prob``; ``GibbsComposer`` + (multi-iteration GNPE) returns parameters + proxies with no ``log_prob``. + ``batch_size=None`` runs in a single pass.""" + merged = self.composer.sample(num_samples, self.context, batch_size) merged = {k: v.cpu().numpy() for k, v in merged.items()} self._post_process(merged) self.samples = pd.DataFrame(merged) diff --git a/dingo/gw/inference/factors.py b/dingo/gw/inference/factors.py index e6e94c1f2..78c22fc03 100644 --- a/dingo/gw/inference/factors.py +++ b/dingo/gw/inference/factors.py @@ -29,7 +29,6 @@ FlowFactor, GibbsComposer, _base_model_metadata, - _cat_dict, ) from dingo.core.posterior_models import BasePosteriorModel from dingo.core.transforms import GetItem, RenameKey @@ -154,13 +153,13 @@ def __init__(self, values: dict[str, float]): self.parameters = list(values) self.conditioning = [] - def _sample_and_log_prob(self, num_samples, cond): + def sample_and_log_prob(self, num_samples, cond): samples = { p: torch.full((num_samples,), float(v)) for p, v in self.values.items() } return samples, torch.zeros(num_samples) - def _log_prob(self, theta_i, cond): + def log_prob(self, theta_i, cond): raise NotImplementedError("FixedFactor.log_prob -- later step.") @@ -173,10 +172,10 @@ def __init__(self): self.parameters = ["phase"] self.conditioning = [] # conditions on all preceding params via the likelihood - def _sample_and_log_prob(self, num_samples, cond): + def sample_and_log_prob(self, num_samples, cond): raise NotImplementedError("SyntheticPhaseFactor -- later step.") - def _log_prob(self, theta_i, cond): + def log_prob(self, theta_i, cond): raise NotImplementedError("SyntheticPhaseFactor -- later step.") @@ -199,7 +198,6 @@ def __init__( transform_post: Compose, gnpe_parameters: list[str], parameters: list[str], - batch_size: Optional[int] = None, ): self.model = model self.transform_pre = transform_pre @@ -207,12 +205,9 @@ def __init__( self.gnpe_parameters = gnpe_parameters self.proxy_parameters = [p + "_proxy" for p in gnpe_parameters] self.parameters = parameters - self.batch_size = batch_size @classmethod - def from_model( - cls, model: BasePosteriorModel, batch_size: Optional[int] = None - ) -> "GNPEFlowFactor": + def from_model(cls, model: BasePosteriorModel) -> "GNPEFlowFactor": """Build the GNPE per-iteration transforms from the main model's metadata, replicating ``GWSamplerGNPE._initialize_transforms`` (time-shift GNPE).""" meta = _base_model_metadata(model) @@ -270,31 +265,14 @@ def from_model( Compose(transform_post), gnpe_parameters, inference_parameters, - batch_size=batch_size, ) def gibbs_step( self, num_samples: int, context, extrinsic: dict - ) -> tuple[dict, dict]: - """One GNPE Gibbs iteration, internally batched by ``batch_size`` over the Gibbs - walkers (independent across the batch dimension), to bound the per-pass memory. - ``batch_size = None`` runs all walkers in one pass.""" - if not self.batch_size or self.batch_size >= num_samples: - return self._gibbs_step(num_samples, context, extrinsic) - param_batches, extrinsic_batches = [], [] - for start in range(0, num_samples, self.batch_size): - stop = min(start + self.batch_size, num_samples) - chunk = {k: v[start:stop] for k, v in extrinsic.items()} - params, extr = self._gibbs_step(stop - start, context, chunk) - param_batches.append(params) - extrinsic_batches.append(extr) - return _cat_dict(param_batches), _cat_dict(extrinsic_batches) - - def _gibbs_step( - self, num_samples: int, context, extrinsic: dict ) -> tuple[dict, dict]: """One GNPE Gibbs iteration (single pass): returns (sampled parameters, updated extrinsic state -- recomputed detector times + proxies for the next iteration). + Batching over the Gibbs walkers is the composer's job (``chunk_and_concat``). """ x = { "extrinsic_parameters": {k: extrinsic[k] for k in self.gnpe_parameters}, diff --git a/tests/core/test_factors.py b/tests/core/test_factors.py new file mode 100644 index 000000000..58d7bc3d6 --- /dev/null +++ b/tests/core/test_factors.py @@ -0,0 +1,148 @@ +""" +Unit tests for the factorized-sampler core executor (``dingo.core.factors``). + +These exercise the vertical ``ChainComposer`` and ``chunk_and_concat`` with deterministic +mock factors (no networks), so they are portable and fast: fan-out expansion + conditioning +alignment, log-prob summation, topological validation, and the batching plumbing. Bit-exact +parity against the legacy samplers is a model-based check that lives with the GW models, not +here. +""" + +import pytest +import torch + +from dingo.core.factors import ( + ChainComposer, + Conditioning, + Factor, + Stage, + chunk_and_concat, +) + + +class _ConstFactor(Factor): + """Deterministic factor (no RNG) honoring the executor contract: draws ``n`` samples + per conditioning row. Unconditioned, it returns ``arange(n)``; conditioned, it returns + ``sum(given) + within_row_index/1000`` so fan-out alignment is checkable by eye. Each + factor contributes a constant ``0.5`` to the log-prob.""" + + def __init__(self, name, conditioning=()): + self.parameters = [name] + self.conditioning = list(conditioning) + self._name = name + + def sample_and_log_prob(self, n, cond): + if self.conditioning: + base = sum(cond.given[k] for k in self.conditioning) # (N,) + n_rows = base.shape[0] + # Integer-valued within-row offset: exact in float32 (no cancellation when + # the test recovers it as b - a), and distinct per fan-out draw. + within = torch.arange(n, dtype=base.dtype) + vals = (base.unsqueeze(1) + within).reshape(-1) # (N*n,), row-major + lp = torch.full((n_rows * n,), 0.5) + else: + vals = torch.arange(n, dtype=torch.float32) + lp = torch.full((n,), 0.5) + return {self._name: vals}, lp + + def log_prob(self, theta_i, cond): + return torch.zeros(next(iter(theta_i.values())).shape[0]) + + +def test_root_only_draws_num_samples(): + comp = ChainComposer([_ConstFactor("a")]) + assert comp.expansion == 1 + out = comp.sample(5, context=None) + assert torch.equal(out["a"], torch.arange(5, dtype=torch.float32)) + assert torch.allclose(out["log_prob"], torch.full((5,), 0.5)) + + +def test_bare_factor_is_wrapped_as_stage(): + comp = ChainComposer([_ConstFactor("a")]) + assert isinstance(comp.stages[0], Stage) + assert comp.stages[0].fan_out == 1 + assert comp.factors[0].parameters == ["a"] + + +def test_fan_out_expansion_alignment_and_logprob(): + m, k = 4, 3 + comp = ChainComposer( + [ + Stage(_ConstFactor("a")), # root -> base m + Stage(_ConstFactor("b", conditioning=["a"]), fan_out=k), + ] + ) + assert comp.expansion == k + out = comp.sample(m, context=None) # num_samples is the base (M intrinsic) + assert out["a"].shape == (m * k,) and out["b"].shape == (m * k,) + + # 'a' is each root value repeated k times (repeat_interleave layout). + a = out["a"].reshape(m, k) + assert torch.all(a == a[:, :1]) + assert torch.equal(a[:, 0], torch.arange(m, dtype=torch.float32)) + + # 'b' conditions on the matching 'a': (b - a) recovers the within-row fan-out index, + # which is distinct per draw (0..k-1) and identical across rows. + b = out["b"].reshape(m, k) + expected_within = torch.arange(k, dtype=torch.float32).repeat(m, 1) + assert torch.equal(b - a, expected_within) + + # log_prob is the sum over both factors (0.5 + 0.5). + assert torch.allclose(out["log_prob"], torch.full((m * k,), 1.0)) + + +def test_chained_fan_out_product(): + comp = ChainComposer( + [ + Stage(_ConstFactor("a")), + Stage(_ConstFactor("b", conditioning=["a"]), fan_out=2), + Stage(_ConstFactor("c", conditioning=["b"]), fan_out=5), + ] + ) + assert comp.expansion == 10 + out = comp.sample(3, context=None) # base 3 -> 3 * 10 = 30 rows + assert all(out[p].shape == (30,) for p in ("a", "b", "c")) + + +def test_num_samples_is_the_base_count(): + # num_samples is the base (root) count, not the total: total rows = num_samples * + # expansion, with no divisibility constraint (here 7 is not a multiple of 4). + m, k = 7, 4 + comp = ChainComposer( + [ + Stage(_ConstFactor("a")), + Stage(_ConstFactor("b", conditioning=["a"]), fan_out=k), + ] + ) + out = comp.sample(m, context=None) + assert out["a"].shape == (m * k,) and out["b"].shape == (m * k,) + + +def test_topological_validation(): + with pytest.raises(ValueError, match="conditions on"): + ChainComposer([_ConstFactor("b", conditioning=["a"])]) # 'a' never produced + + +def test_chunk_and_concat_plumbing(): + # Position-independent run_once -> batched concat is identical to a single pass. + def run_once(n): + return {"x": torch.full((n,), 7.0)}, torch.ones(n) + + s_full, lp_full = chunk_and_concat(10, None, run_once) + s_chunk, lp_chunk = chunk_and_concat(10, 3, run_once) # 3+3+3+1 + assert s_full["x"].shape == (10,) + assert torch.equal(s_full["x"], s_chunk["x"]) + assert torch.equal(lp_full, lp_chunk) + + +def test_chunk_and_concat_allows_none_log_prob(): + # The Gibbs path carries no density. + samples, lp = chunk_and_concat(6, 2, lambda n: ({"x": torch.zeros(n)}, None)) + assert lp is None + assert samples["x"].shape == (6,) + + +def test_conditioning_dataclass_defaults(): + cond = Conditioning(context="ctx") + assert cond.context == "ctx" + assert cond.given == {} From 0a2f9f364c59098aab36a7ee2b320f37f3615ad6 Mon Sep 17 00:00:00 2001 From: Stephen Green Date: Tue, 30 Jun 2026 23:26:23 +0200 Subject: [PATCH 09/65] Rename GibbsComposer to GNPEGibbsComposer; add Composer protocol Gibbs sampling in Dingo is used only for GNPE, and the class's members (gnpe_factor, proxy_parameters, gibbs_step) are all GNPE-specific, so the general name was misleading. Rename to GNPEGibbsComposer and keep it in dingo.gw.inference.factors alongside GNPEFlowFactor. Core keeps the domain-agnostic pieces -- ChainComposer, chunk_and_concat -- and gains a Composer protocol that ComposedSampler depends on, so core no longer references the GW-specific composer by name. GNPE sampling remains bit-exact vs GWSamplerGNPE. Co-Authored-By: Claude Opus 4.8 (1M context) --- dingo/core/factors.py | 67 +++++++++-------------------------- dingo/gw/inference/factors.py | 66 +++++++++++++++++++++++++++++++--- 2 files changed, 79 insertions(+), 54 deletions(-) diff --git a/dingo/core/factors.py b/dingo/core/factors.py index 0e36e4872..96b3ac10d 100644 --- a/dingo/core/factors.py +++ b/dingo/core/factors.py @@ -319,8 +319,9 @@ class ChainComposer: reproducing the old sampler's batching order (bit-exact when ``batch_size`` matches). Batching lives in ``chunk_and_concat``; the factors are single-pass. - Multi-iteration (cyclic) GNPE is *not* expressible here and uses ``GibbsComposer``. - Accepts bare factors (wrapped as ``Stage(factor, fan_out=1)``) or explicit ``Stage``s. + Multi-iteration (cyclic) GNPE is *not* expressible here and uses the GW + ``GNPEGibbsComposer``. Accepts bare factors (wrapped as ``Stage(factor, fan_out=1)``) or + explicit ``Stage``s. """ def __init__(self, stages: list[Union["Stage", Factor]]): @@ -410,67 +411,33 @@ def sample( return {**samples, "log_prob": log_prob} -class GibbsComposer: - """ - Multi-iteration GNPE composition. Seeds the Gibbs chain from an ``init_factor`` (e.g. - a network predicting detector coalescence times), then iterates a GNPE step - (``gnpe_factor.gibbs_step``) to a fixed point. The dependency is cyclic - (theta <-> proxies), so this breaks density access: ``sample`` returns parameters - WITHOUT a ``log_prob``. (Single-step GNPE that *does* preserve log_prob is a - two-stage ``ChainComposer`` instead.) - - Batching is vertical, via ``chunk_and_concat``: chunk the Gibbs walkers and run the - whole loop per chunk -- the old ``GWSamplerGNPE.run_sampler`` order. - """ - - def __init__(self, init_factor: Factor, gnpe_factor, num_iterations: int): - self.init_factor = init_factor - self.gnpe_factor = gnpe_factor - self.num_iterations = num_iterations +@runtime_checkable +class Composer(Protocol): + """The minimal interface ``ComposedSampler`` drives: draw a per-sample dict + (parameters, plus ``log_prob`` for density-preserving composers). ``ChainComposer`` + (here, domain-agnostic) satisfies it, as does the GW ``GNPEGibbsComposer`` (multi-iteration + GNPE) -- which lives in ``dingo.gw.inference.factors`` because it is built entirely + around the GNPE step (its proxies, detector times, time-shift), not in core.""" def sample( self, num_samples: int, context: SamplerContext, batch_size: Optional[int] = None, - ) -> dict[str, torch.Tensor]: - samples, _ = chunk_and_concat( - num_samples, batch_size, lambda n: (self._run_once(n, context), None) - ) - return samples - - def _run_once( - self, num_samples: int, context: SamplerContext - ) -> dict[str, torch.Tensor]: - # Seed the Gibbs chain with the init factor's parameters (e.g. detector times). - seed, _ = self.init_factor.sample_and_log_prob( - num_samples, Conditioning(context) - ) - extrinsic = dict(seed) - parameters: dict[str, torch.Tensor] = {} - for _ in range(self.num_iterations): - parameters, extrinsic = self.gnpe_factor.gibbs_step( - num_samples, context, extrinsic - ) - proxies = { - p: extrinsic[p] for p in self.gnpe_factor.proxy_parameters if p in extrinsic - } - return {**parameters, **proxies} + ) -> dict[str, torch.Tensor]: ... class ComposedSampler: """ - Thin user-facing façade over a composer (``ChainComposer`` or ``GibbsComposer``) and - a ``SamplerContext``: runs the composer (which bounds memory via vertical batching), - applies domain-specific post-processing, and returns samples as a DataFrame. The - per-factor compute lives in the composer (which need only expose + Thin user-facing façade over a ``Composer`` (e.g. ``ChainComposer``, or the GW + ``GNPEGibbsComposer``) and a ``SamplerContext``: runs the composer (which bounds memory via + vertical batching), applies domain-specific post-processing, and returns samples as a + DataFrame. The per-factor compute lives in the composer (which need only expose ``sample(num_samples, context, batch_size) -> dict``); this class only handles consolidation and post-processing -- the role the monolithic ``Sampler`` plays today. """ - def __init__( - self, composer: Union[ChainComposer, GibbsComposer], context: SamplerContext - ): + def __init__(self, composer: Composer, context: SamplerContext): self.composer = composer self.context = context self.samples: Optional[pd.DataFrame] = None @@ -484,7 +451,7 @@ def run_sampler( self, num_samples: int, batch_size: Optional[int] = None ) -> pd.DataFrame: """Run the composer (vertically batched by ``batch_size``) and return samples as a - DataFrame. ``ChainComposer.sample`` adds ``log_prob``; ``GibbsComposer`` + DataFrame. ``ChainComposer.sample`` adds ``log_prob``; ``GNPEGibbsComposer`` (multi-iteration GNPE) returns parameters + proxies with no ``log_prob``. ``batch_size=None`` runs in a single pass.""" merged = self.composer.sample(num_samples, self.context, batch_size) diff --git a/dingo/gw/inference/factors.py b/dingo/gw/inference/factors.py index 78c22fc03..074d9aa6d 100644 --- a/dingo/gw/inference/factors.py +++ b/dingo/gw/inference/factors.py @@ -25,10 +25,12 @@ from dingo.core.factors import ( ChainComposer, ComposedSampler, + Conditioning, Factor, FlowFactor, - GibbsComposer, + SamplerContext, _base_model_metadata, + chunk_and_concat, ) from dingo.core.posterior_models import BasePosteriorModel from dingo.core.transforms import GetItem, RenameKey @@ -297,6 +299,62 @@ def gibbs_step( return x["parameters"], x["extrinsic_parameters"] +class GNPEGibbsComposer: + """ + The Gibbs sampler for multi-iteration GNPE -- GNPE-specific by name and by guts (it + drives the GNPE step: proxies, detector times, time-shift). Gibbs sampling in general + is broader than this, but Dingo only ever uses it for GNPE, so the class owns that + specificity rather than posing as a general Gibbs facility. The genuinely generic + pieces live in core: ``ChainComposer``, the ``chunk_and_concat`` batching primitive, + and the ``Composer`` protocol this satisfies. + + Each iteration is a 2-block Gibbs sweep on the joint q(theta, theta_hat | d): blur the + detector times into proxies (theta_hat ~ kernel(.|theta)), then sample the network + (theta ~ q(.|theta_hat, d)). Both blocks are bundled in ``gnpe_factor.gibbs_step``; + the ``init_factor`` seeds the chain and ``num_iterations`` is burn-in. The dependency + is cyclic, so this breaks density access: ``sample`` returns parameters WITHOUT a + ``log_prob``. (Single-step GNPE that *does* preserve log_prob is a two-stage + ``ChainComposer`` instead.) + + Batching is vertical, via ``chunk_and_concat``: chunk the Gibbs walkers and run the + whole loop per chunk -- the old ``GWSamplerGNPE.run_sampler`` order. + """ + + def __init__(self, init_factor: Factor, gnpe_factor, num_iterations: int): + self.init_factor = init_factor + self.gnpe_factor = gnpe_factor + self.num_iterations = num_iterations + + def sample( + self, + num_samples: int, + context: SamplerContext, + batch_size: Optional[int] = None, + ) -> dict[str, torch.Tensor]: + samples, _ = chunk_and_concat( + num_samples, batch_size, lambda n: (self._run_once(n, context), None) + ) + return samples + + def _run_once( + self, num_samples: int, context: SamplerContext + ) -> dict[str, torch.Tensor]: + # Seed the Gibbs chain with the init factor's parameters (e.g. detector times). + seed, _ = self.init_factor.sample_and_log_prob( + num_samples, Conditioning(context) + ) + extrinsic = dict(seed) + parameters: dict[str, torch.Tensor] = {} + for _ in range(self.num_iterations): + parameters, extrinsic = self.gnpe_factor.gibbs_step( + num_samples, context, extrinsic + ) + proxies = { + p: extrinsic[p] for p in self.gnpe_factor.proxy_parameters if p in extrinsic + } + return {**parameters, **proxies} + + class GWComposedSampler(ComposedSampler): """ GW façade: a ``ComposedSampler`` that adds the gravitational-wave post-processing -- @@ -308,7 +366,7 @@ class GWComposedSampler(ComposedSampler): def __init__( self, - composer: Union[ChainComposer, GibbsComposer], + composer: Union[ChainComposer, GNPEGibbsComposer], context: GWSamplerContext, metadata: dict, inference_parameters: list[str], @@ -345,12 +403,12 @@ def from_gnpe_models( ) -> "GWComposedSampler": """Build a multi-iteration time-GNPE sampler from an init + main model pair: the init model's data preprocessing, an init ``FlowFactor`` to seed, and a - ``GNPEFlowFactor`` driven by a ``GibbsComposer``. Returns samples without a + ``GNPEFlowFactor`` driven by a ``GNPEGibbsComposer``. Returns samples without a log_prob (Gibbs breaks density access).""" context = GWSamplerContext.from_model(init_model, raw_context, event_metadata) init_factor = FlowFactor.from_model(init_model) gnpe_factor = GNPEFlowFactor.from_model(main_model) - composer = GibbsComposer(init_factor, gnpe_factor, num_iterations) + composer = GNPEGibbsComposer(init_factor, gnpe_factor, num_iterations) return cls( composer, context, From 51b969cff3b1329005350a8583ece45ae2c73bcf Mon Sep 17 00:00:00 2001 From: Stephen Green Date: Tue, 30 Jun 2026 23:32:40 +0200 Subject: [PATCH 10/65] Tighten core factor docstrings Rewrite the dingo.core.factors docstrings as reference documentation: state what each class and function is and does, with parameters and returns. Drop design-justification, codebase-history narration, comparisons to the old samplers, and conversational asides -- that rationale lives in the design doc. Co-Authored-By: Claude Opus 4.8 (1M context) --- dingo/core/factors.py | 212 ++++++++++++++++-------------------------- 1 file changed, 78 insertions(+), 134 deletions(-) diff --git a/dingo/core/factors.py b/dingo/core/factors.py index 96b3ac10d..40b84fa15 100644 --- a/dingo/core/factors.py +++ b/dingo/core/factors.py @@ -1,22 +1,16 @@ """ -Factorized sampler core. +Factorized sampler core: the domain-agnostic spine of the factorized-sampler design +(see vault/Hackathon/Factorized_Sampler_Design.md). -This module implements the domain-agnostic spine of the factorized-sampler design -(see vault/Hackathon/Factorized_Sampler_Design.md): the posterior is represented as -an ordered product of conditional factors +The posterior is an ordered product of conditional factors, q(theta_1, ..., theta_n | d) = prod_i q_i(theta_i | f_i(theta_ float: @runtime_checkable class SamplerContext(Protocol): """ - Per-event shared state referenced by every factor: the data ``d`` and everything - derived from it (the one-time prepared data representation, the likelihood, event - metadata). Concrete implementations are domain-specific (see - ``dingo.gw.inference.factors.GWSamplerContext``). Serialized as the transport state - between pipe stages. + Per-event shared state referenced by every factor: the data ``d`` and quantities + derived from it (the prepared-data representation, the likelihood, event metadata). + Concrete implementations are domain-specific; see + ``dingo.gw.inference.factors.GWSamplerContext``. """ event_metadata: Optional[dict] def prepared_data(self) -> torch.Tensor: - """The one-time data representation (whiten/decimate/repackage/...), computed - once and cached. The conditioning input shared by data-conditioned factors.""" + """The data representation the factors condition on + (whiten/decimate/repackage/...), computed once and cached.""" ... def likelihood(self): @@ -105,9 +86,8 @@ def likelihood(self): @dataclass class Conditioning: """ - What the composer hands a factor: the shared ``context`` (-> prepared data, - likelihood, domain) plus the physical values of the earlier-block parameters this - factor conditions on. + The conditioning passed to a factor: the shared ``context``, and ``given``, the + physical values of the earlier-block parameters the factor conditions on. """ context: SamplerContext @@ -126,18 +106,12 @@ def chunk_and_concat( batch_size: Optional[int], run_once: Callable[[int], tuple[dict[str, torch.Tensor], Optional[torch.Tensor]]], ) -> tuple[dict[str, torch.Tensor], Optional[torch.Tensor]]: - """The vertical (depth-first) batching primitive, shared by the chain and Gibbs - composers. - - Split ``total`` base samples into chunks of ``batch_size``, run ``run_once(n)`` -- a - full pass through the *whole* pipeline (the entire chain, or the entire Gibbs loop) -- - per chunk, and concatenate. Working memory is bounded to one chunk carried through the - pipeline; only the output buffer grows to ``total`` (a real run streams it to CPU per - chunk). This is the order the old ``Sampler.run_sampler`` batches in, so parity is - exact when ``batch_size`` matches. ``batch_size=None`` runs everything in one pass. - - ``run_once`` returns ``(samples, log_prob)``; ``log_prob`` may be ``None`` (the Gibbs - path has no tractable density).""" + """Run ``run_once`` over batches of ``total`` and concatenate the results. + + Splits ``total`` into chunks of ``batch_size``, calls ``run_once(n) -> (samples, + log_prob)`` per chunk, and concatenates the sample dicts and log-probs. Caps peak + memory at one chunk. ``batch_size=None`` runs ``total`` in a single call. ``log_prob`` + may be ``None`` (for a composer without a tractable density, i.e. Gibbs).""" bs = batch_size or total sample_parts: list[dict[str, torch.Tensor]] = [] lp_parts: list[Optional[torch.Tensor]] = [] @@ -152,27 +126,20 @@ def chunk_and_concat( class Factor(ABC): """ - A conditional distribution ``q_i(theta_i | f_i(theta_ (B, n, dim)`` with ``B = n_rows``. + A call draws ``num_samples`` samples per conditioning row and returns ``n_rows * + num_samples`` rows in row-major order, where ``n_rows`` is the number of rows in + ``cond.given`` (1 if unconditioned). This mirrors the network's + ``sample_and_log_prob(*context, num_samples=n) -> (n_rows, n, dim)``. Attributes ---------- parameters : list[str] - The block ``theta_i`` this factor produces. + The parameter block this factor produces. conditioning : list[str] - Earlier-block parameter names this factor conditions on. Data dependence is - implicit via the shared context. + Earlier-block parameters it conditions on (data dependence is via the context). """ parameters: list[str] @@ -182,16 +149,16 @@ class Factor(ABC): def sample_and_log_prob( self, num_samples: int, cond: Conditioning ) -> tuple[dict[str, torch.Tensor], torch.Tensor]: - """Draw ``num_samples`` per conditioning row from ``q_i``; return ``(physical - samples, physical-space log q_i)``. The returned dict may carry extra named - side-channel columns (e.g. an IS kernel correction) alongside ``parameters``.""" + """Draw ``num_samples`` samples per conditioning row; return ``(samples, + log_prob)`` in physical space. The samples dict may include named columns beyond + ``parameters``.""" @abstractmethod def log_prob( self, theta_i: dict[str, torch.Tensor], cond: Conditioning ) -> torch.Tensor: - """Evaluate ``log q_i(theta_i | f_i)`` at given physical ``theta_i`` (for IS / - re-plug). One conditioning row per ``theta_i`` row (no fan-out).""" + """Evaluate ``log q_i`` at given physical ``theta_i`` (one conditioning row per + row).""" def _base_model_metadata(model: BasePosteriorModel) -> dict: @@ -206,15 +173,12 @@ def _base_model_metadata(model: BasePosteriorModel) -> dict: class FlowFactor(Factor): """ - A factor wrapping a posterior model (NPE flow, FMPE, ...). Domain-agnostic: it reaches - data only through the ``SamplerContext.prepared_data()`` protocol, so the GW-specific - conditioning map ``f_i`` (the data preprocessing) lives entirely on the context. + Factor wrapping a posterior model (NPE flow, FMPE, ...). Reaches data only through + ``SamplerContext.prepared_data()`` and encapsulates the network's standardization, so + its interface is in physical parameter space. - Encapsulates the network's own standardization: the public interface is physical-in / - physical-out -- standardized values never leave the factor. - - The cheap *parameter-dependent* part of ``f_i`` (time-shift / heterodyne for GNPE) is - a no-op here; a domain subclass overrides ``_apply_param_transforms`` to add it. + An unconditioned model draws from the shared data context; a model with + ``context_parameters`` conditions on the values in ``cond.given``. """ def __init__( @@ -234,8 +198,8 @@ def __init__( @classmethod def from_model(cls, model: BasePosteriorModel) -> "FlowFactor": - """Build a factor from a model: ``parameters`` and ``context_parameters`` come from - the model's training metadata (first-class conditioning).""" + """Build a factor from a model, reading ``parameters`` and ``context_parameters`` + from its training metadata.""" data_settings = _base_model_metadata(model)["train_settings"]["data"] context_parameters = data_settings.get("context_parameters") or [] return cls( @@ -246,10 +210,9 @@ def from_model(cls, model: BasePosteriorModel) -> "FlowFactor": ) def sample_and_log_prob(self, num_samples, cond): - """Draw ``num_samples`` per conditioning row. Unconditioned (plain NPE): one - shared data context, ``num_samples`` draws. Conditioned: ``N`` context rows in - (one embedding pass each), ``num_samples`` draws per row -> ``N * num_samples`` - rows, flattened row-major to align with the composer's ``repeat_interleave``.""" + """Draw ``num_samples`` samples per conditioning row. Unconditioned: ``num_samples`` + draws from the shared data context. Conditioned: ``num_samples`` per context row, + returning ``n_rows * num_samples`` rows in row-major order.""" data = cond.context.prepared_data() self.model.network.eval() if not self.context_parameters: @@ -296,10 +259,8 @@ def log_prob(self, theta_i, cond): @dataclass class Stage: - """One factor in the chain plus its **fan-out**: how many samples it draws per - incoming conditioning row. The root (first) stage ignores ``fan_out`` -- it draws the - base count directly; a 1:1 chain link uses ``fan_out=1``; an intrinsic/extrinsic - expansion uses ``fan_out=K`` (``K`` extrinsic draws per intrinsic sample).""" + """A chain factor and its fan-out: the number of samples drawn per incoming + conditioning row. The root stage draws the base count and ignores ``fan_out``.""" factor: Factor fan_out: int = 1 @@ -307,21 +268,14 @@ class Stage: class ChainComposer: """ - Autoregressive composer **and vertical (depth-first) executor**: sample the stages in - declared order (a topological order of the dependency DAG), expanding by each stage's - fan-out, and sum their log-probs. Exact and importance-sampling-friendly. Covers plain - NPE, single-step GNPE, prior conditioning, synthetic phase, and intrinsic/extrinsic - splits. - - Vertical execution carries a chunk of base samples all the way down the chain, emits - it, and discards -- bounding working memory to one chunk (the only way to draw ``M`` - intrinsic x ``K`` extrinsic without materializing ``M*K`` intermediates) and - reproducing the old sampler's batching order (bit-exact when ``batch_size`` matches). - Batching lives in ``chunk_and_concat``; the factors are single-pass. - - Multi-iteration (cyclic) GNPE is *not* expressible here and uses the GW - ``GNPEGibbsComposer``. Accepts bare factors (wrapped as ``Stage(factor, fan_out=1)``) or - explicit ``Stage``s. + Autoregressive composer over an ordered list of ``Stage``s. + + Draws the stages in declared order -- a topological order of the conditioning DAG -- + expanding each stage by its fan-out and summing the log-probs. Covers plain NPE, + single-step GNPE, prior conditioning, synthetic phase, and intrinsic/extrinsic splits. + Multi-iteration (cyclic) GNPE uses the GW ``GNPEGibbsComposer`` instead. + + Accepts bare factors (wrapped as ``Stage(factor, fan_out=1)``) or explicit ``Stage``s. """ def __init__(self, stages: list[Union["Stage", Factor]]): @@ -343,13 +297,13 @@ def _validate(self): @property def factors(self) -> list[Factor]: - """The factors in order (without fan-out), for callers that only need them.""" + """The stage factors, in order.""" return [stage.factor for stage in self.stages] @property def expansion(self) -> int: - """Product of the non-root fan-outs; total rows returned = num_samples * - expansion (e.g. M intrinsic x K extrinsic for an extrinsic fan-out of K).""" + """Product of the non-root fan-outs; ``sample`` returns ``num_samples * + expansion`` rows.""" return math.prod(stage.fan_out for stage in self.stages[1:]) def sample_and_log_prob( @@ -358,11 +312,9 @@ def sample_and_log_prob( context: SamplerContext, batch_size: Optional[int] = None, ) -> tuple[dict[str, torch.Tensor], torch.Tensor]: - """``num_samples`` is the **base (root) count** -- e.g. ``M`` intrinsic samples. - Each fan-out stage multiplies it, so the result has ``num_samples * expansion`` - rows (``M * K`` for an extrinsic ``fan_out=K``); for a plain chain (no fan-out) - that is just ``num_samples``. ``batch_size`` chunks the base count, in the same - (root) unit as ``num_samples``.""" + """Draw samples. ``num_samples`` is the base (root) count; the result has + ``num_samples * expansion`` rows. ``batch_size`` chunks the base count (``None`` + draws in one pass).""" return chunk_and_concat( num_samples, batch_size, lambda n: self._run_chain_once(n, context) ) @@ -370,7 +322,7 @@ def sample_and_log_prob( def _run_chain_once( self, base: int, context: SamplerContext ) -> tuple[dict[str, torch.Tensor], torch.Tensor]: - """One depth-first pass of the whole chain for ``base`` root samples.""" + """One pass of the whole chain for ``base`` root samples.""" samples: dict[str, torch.Tensor] = {} total: torch.Tensor | float = 0.0 for i, stage in enumerate(self.stages): @@ -406,18 +358,16 @@ def sample( context: SamplerContext, batch_size: Optional[int] = None, ) -> dict[str, torch.Tensor]: - """Full per-sample dict (parameters + ``log_prob``), for the sampler façade.""" + """Per-sample dict of parameters plus ``log_prob``.""" samples, log_prob = self.sample_and_log_prob(num_samples, context, batch_size) return {**samples, "log_prob": log_prob} @runtime_checkable class Composer(Protocol): - """The minimal interface ``ComposedSampler`` drives: draw a per-sample dict - (parameters, plus ``log_prob`` for density-preserving composers). ``ChainComposer`` - (here, domain-agnostic) satisfies it, as does the GW ``GNPEGibbsComposer`` (multi-iteration - GNPE) -- which lives in ``dingo.gw.inference.factors`` because it is built entirely - around the GNPE step (its proxies, detector times, time-shift), not in core.""" + """Interface required by ``ComposedSampler``: ``sample`` returns a per-sample dict of + parameters (plus ``log_prob`` for density-preserving composers). Satisfied by + ``ChainComposer`` and the GW ``GNPEGibbsComposer``.""" def sample( self, @@ -429,12 +379,8 @@ def sample( class ComposedSampler: """ - Thin user-facing façade over a ``Composer`` (e.g. ``ChainComposer``, or the GW - ``GNPEGibbsComposer``) and a ``SamplerContext``: runs the composer (which bounds memory via - vertical batching), applies domain-specific post-processing, and returns samples as a - DataFrame. The per-factor compute lives in the composer (which need only expose - ``sample(num_samples, context, batch_size) -> dict``); this class only handles - consolidation and post-processing -- the role the monolithic ``Sampler`` plays today. + Façade over a ``Composer`` and a ``SamplerContext``. Runs the composer, applies + domain-specific post-processing, and returns the samples as a DataFrame. """ def __init__(self, composer: Composer, context: SamplerContext): @@ -443,17 +389,15 @@ def __init__(self, composer: Composer, context: SamplerContext): self.samples: Optional[pd.DataFrame] = None def _post_process(self, samples: dict, inverse: bool = False): - """Hook for domain-specific post-processing (e.g. GW fixed-parameter injection - and reference-time correction). No-op by default.""" + """Hook for domain-specific post-processing; no-op by default.""" pass def run_sampler( self, num_samples: int, batch_size: Optional[int] = None ) -> pd.DataFrame: - """Run the composer (vertically batched by ``batch_size``) and return samples as a - DataFrame. ``ChainComposer.sample`` adds ``log_prob``; ``GNPEGibbsComposer`` - (multi-iteration GNPE) returns parameters + proxies with no ``log_prob``. - ``batch_size=None`` runs in a single pass.""" + """Draw ``num_samples`` samples (chunked by ``batch_size``), post-process, and + return them as a DataFrame. ``ChainComposer`` includes ``log_prob``; + ``GNPEGibbsComposer`` does not.""" merged = self.composer.sample(num_samples, self.context, batch_size) merged = {k: v.cpu().numpy() for k, v in merged.items()} self._post_process(merged) From 9a7152e3b4796608757e49776baae89536c82201 Mon Sep 17 00:00:00 2001 From: Stephen Green Date: Wed, 1 Jul 2026 00:51:07 +0200 Subject: [PATCH 11/65] Split GNPE main factor into kernel + flow; generalize Gibbs composer Replace the bundled GNPEFlowFactor (a non-Factor Gibbs step) and the single-step GNPEChainFactor with two conditional Factors: - GNPEKernelFactor: the perturbation kernel p(theta_hat | theta). Blurs detector times into proxies; its log_prob is the delta_log_prob_target importance-sampling correction. - GNPEFlowFactor: the main network q(theta | theta_hat, d), now a real Factor, shared by both composers. GNPEGibbsComposer becomes the generic GibbsComposer in core.factors (cycles a factor list, no GNPE specifics). Multi-iteration GNPE is GibbsComposer([kernel, flow]); single-step GNPE is ChainComposer([proxy_source, flow]) with the kernel correction applied out of the proposal sum. ComposedSampler keeps the Composer protocol, now used consistently by GWComposedSampler too (dropped the dead @runtime_checkable). Bit-exact vs the old GWSampler / GWSamplerGNPE for NPE, multi-iteration GNPE, and single-step GNPE across both verification harnesses. Co-Authored-By: Claude Opus 4.8 (1M context) --- dingo/core/factors.py | 58 +++++- dingo/gw/inference/factors.py | 334 +++++++++++++++++++--------------- 2 files changed, 240 insertions(+), 152 deletions(-) diff --git a/dingo/core/factors.py b/dingo/core/factors.py index 40b84fa15..c545fc160 100644 --- a/dingo/core/factors.py +++ b/dingo/core/factors.py @@ -273,7 +273,7 @@ class ChainComposer: Draws the stages in declared order -- a topological order of the conditioning DAG -- expanding each stage by its fan-out and summing the log-probs. Covers plain NPE, single-step GNPE, prior conditioning, synthetic phase, and intrinsic/extrinsic splits. - Multi-iteration (cyclic) GNPE uses the GW ``GNPEGibbsComposer`` instead. + Multi-iteration (cyclic) GNPE uses ``GibbsComposer`` instead. Accepts bare factors (wrapped as ``Stage(factor, fan_out=1)``) or explicit ``Stage``s. """ @@ -363,11 +363,61 @@ def sample( return {**samples, "log_prob": log_prob} -@runtime_checkable +class GibbsComposer: + """ + Blocked Gibbs composer over a cyclic conditioning dependency. + + Seeds the chain with an init factor, then sweeps the factor list in order for + ``num_iterations`` iterations; each factor conditions on the current state and + overwrites its own block. The cyclic dependency has no tractable marginal, so + ``sample`` returns parameters without a ``log_prob`` (recoverable by fitting an + unconditional density to the samples and taking one ``ChainComposer`` step). Dingo uses + this only for multi-iteration GNPE (the GNPE factors in ``dingo.gw.inference.factors``), + but the loop is generic. + + Batching is vertical, via ``chunk_and_concat``: chunk the walkers and run the whole + loop per chunk. + """ + + def __init__(self, init_factor: Factor, factors: list[Factor], num_iterations: int): + self.init_factor = init_factor + self.factors = list(factors) + self.num_iterations = num_iterations + + def sample( + self, + num_samples: int, + context: SamplerContext, + batch_size: Optional[int] = None, + ) -> dict[str, torch.Tensor]: + samples, _ = chunk_and_concat( + num_samples, batch_size, lambda n: (self._run_once(n, context), None) + ) + return samples + + def _run_once( + self, num_samples: int, context: SamplerContext + ) -> dict[str, torch.Tensor]: + # Seed the chain (e.g. an init network's detector times); the walkers are the rows. + seed, _ = self.init_factor.sample_and_log_prob( + num_samples, Conditioning(context) + ) + state = dict(seed) + for _ in range(self.num_iterations): + for factor in self.factors: + cond = Conditioning(context, {k: state[k] for k in factor.conditioning}) + # One sample per walker (Gibbs is 1:1); walkers are the conditioning rows. + block, _ = factor.sample_and_log_prob(1, cond) + state.update(block) + # Each factor's parameter block (e.g. proxies + inference parameters), dropping + # side-channel columns such as the recomputed detector times. + return {p: state[p] for factor in self.factors for p in factor.parameters} + + class Composer(Protocol): """Interface required by ``ComposedSampler``: ``sample`` returns a per-sample dict of parameters (plus ``log_prob`` for density-preserving composers). Satisfied by - ``ChainComposer`` and the GW ``GNPEGibbsComposer``.""" + ``ChainComposer`` and ``GibbsComposer``.""" def sample( self, @@ -397,7 +447,7 @@ def run_sampler( ) -> pd.DataFrame: """Draw ``num_samples`` samples (chunked by ``batch_size``), post-process, and return them as a DataFrame. ``ChainComposer`` includes ``log_prob``; - ``GNPEGibbsComposer`` does not.""" + ``GibbsComposer`` does not.""" merged = self.composer.sample(num_samples, self.context, batch_size) merged = {k: v.cpu().numpy() for k, v in merged.items()} self._post_process(merged) diff --git a/dingo/gw/inference/factors.py b/dingo/gw/inference/factors.py index 074d9aa6d..d085a6972 100644 --- a/dingo/gw/inference/factors.py +++ b/dingo/gw/inference/factors.py @@ -5,8 +5,10 @@ vault/Hackathon/Factorized_Sampler_Design.md). The generic ``FlowFactor`` is domain-agnostic and lives in ``dingo.core.factors``; what is GW-specific is the ``GWSamplerContext`` (which builds the data-preprocessing conditioning map ``f_i``) and -the post-processing in ``GWComposedSampler``. This first increment covers the plain-NPE -path; the fixed, synthetic-phase, and GNPE-proxy factors are stubbed for later steps. +the post-processing in ``GWComposedSampler``. Covers plain NPE (``FlowFactor`` via a +``ChainComposer``) and multi-iteration time-shift GNPE (``GNPEKernelFactor`` + +``GNPEFlowFactor`` cycled by a ``GibbsComposer``); the synthetic-phase factor and +single-step GNPE importance sampling remain for later steps. """ from __future__ import annotations @@ -18,19 +20,18 @@ import pandas as pd import torch from astropy.time import Time -from bilby.core.prior import DeltaFunction +from bilby.core.prior import DeltaFunction, PriorDict from bilby.gw.detector import InterferometerList from torchvision.transforms import Compose from dingo.core.factors import ( ChainComposer, ComposedSampler, - Conditioning, + Composer, Factor, FlowFactor, - SamplerContext, + GibbsComposer, _base_model_metadata, - chunk_and_concat, ) from dingo.core.posterior_models import BasePosteriorModel from dingo.core.transforms import GetItem, RenameKey @@ -181,16 +182,151 @@ def log_prob(self, theta_i, cond): raise NotImplementedError("SyntheticPhaseFactor -- later step.") -class GNPEFlowFactor: +def _build_gnpe_transforms(model: BasePosteriorModel): + """Build the time-shift GNPE per-step transforms from a model's metadata (the same + chains as ``GWSamplerGNPE._initialize_transforms``). + + Returns + ------- + transform_pre, transform_post : Compose + gnpe_parameters : list[str] + The GNPE input parameters (detector times). + inference_parameters : list[str] + kernel : PriorDict + The proxy perturbation kernel. + gnpe_transform : GNPECoalescenceTimes + The blur transform itself, shared so the kernel factor can call ``sample_proxies``. """ - One iteration of time-shift GNPE for the Gibbs composer. Given the current detector- - time estimates, it blurs them into proxies, shifts the strain, standardizes the - proxies as network context, samples the main network, then recomputes detector times - for the next iteration. Wraps the main model and the per-iteration transforms (the - same ones ``GWSamplerGNPE`` builds). - - Not a ``Factor``: the ABC's ``sample_and_log_prob`` does not fit a Gibbs step. It - exposes ``gibbs_step(num_samples, context, extrinsic) -> (parameters, extrinsic)``. + meta = _base_model_metadata(model) + data_settings = meta["train_settings"]["data"] + ifo_list = InterferometerList(data_settings["detectors"]) + domain = build_domain(meta["dataset_settings"]["domain"]) + if "domain_update" in data_settings: + domain.update(data_settings["domain_update"]) + + gnpe_time_settings = data_settings.get("gnpe_time_shifts") + if not gnpe_time_settings: + raise NotImplementedError( + "Only time-shift GNPE is supported here so far (no gnpe_chirp / gnpe_phase)." + ) + + gnpe_transform = GNPECoalescenceTimes( + ifo_list, + gnpe_time_settings["kernel"], + gnpe_time_settings["exact_equiv"], + inference=True, + ) + transform_pre = [ + RenameKey("data", "waveform"), + gnpe_transform, + TimeShiftStrain(ifo_list, domain), + SelectStandardizeRepackageParameters( + {"context_parameters": data_settings["context_parameters"]}, + data_settings["standardization"], + device=model.device, + ), + RenameKey("waveform", "data"), + ] + gnpe_parameters: list[str] = [] + kernel = PriorDict() + for transform in transform_pre: + if isinstance(transform, GNPEBase): + gnpe_parameters += transform.input_parameter_names + for k, v in transform.kernel.items(): + kernel[k] = v + + inference_parameters = data_settings["inference_parameters"] + transform_post = [ + SelectStandardizeRepackageParameters( + {"inference_parameters": inference_parameters}, + data_settings["standardization"], + inverse=True, + as_type="dict", + ), + PostCorrectGeocentTime(), + CopyToExtrinsicParameters( + "ra", "dec", "geocent_time", "chirp_mass", "mass_ratio", "phase" + ), + GetDetectorTimes(ifo_list, data_settings["ref_time"]), + ] + return ( + Compose(transform_pre), + Compose(transform_post), + gnpe_parameters, + inference_parameters, + kernel, + gnpe_transform, + ) + + +class GNPEKernelFactor(Factor): + """ + The GNPE perturbation kernel p(theta_hat | theta) as a factor (non-NN). + + GNPE (arXiv:2111.13139) conditions the main network on "proxy" parameters theta_hat + drawn from a fixed kernel p(theta_hat | theta). Here theta are the detector coalescence + times and the kernel adds a bounded perturbation to each. Conditioning on the proxies + frees the network input to be simplified by them -- ``GNPEFlowFactor`` shifts each + detector's strain to its proxy time -- at the price of inferring theta and theta_hat + jointly. + + The parameter block is the proxies; the conditioning is the detector times. + ``sample_and_log_prob`` blurs the times into proxies (the proxy update of a Gibbs + sweep). ``log_prob`` returns the kernel density log p(theta_hat | theta); for + single-step GNPE this is the ``delta_log_prob_target`` importance-sampling correction, + evaluated at the proxies and the detector times recomputed from theta. One proxy per + detector-time row. + """ + + def __init__(self, gnpe_transform: GNPEBase, gnpe_parameters: list[str]): + self.gnpe = gnpe_transform + self.gnpe_parameters = gnpe_parameters + self.parameters = [p + "_proxy" for p in gnpe_parameters] + self.conditioning = list(gnpe_parameters) + self.kernel = gnpe_transform.kernel + + @classmethod + def from_model(cls, model: BasePosteriorModel) -> "GNPEKernelFactor": + """Build from the main model's metadata (the kernel / blur transform).""" + _, _, gnpe_parameters, _, _, gnpe_transform = _build_gnpe_transforms(model) + return cls(gnpe_transform, gnpe_parameters) + + def sample_and_log_prob(self, num_samples, cond): + """Blur the conditioning detector times into proxies; ``num_samples`` must be 1 + (GNPE is 1:1). Returns the proxies and their kernel log-prob.""" + if num_samples != 1: + raise ValueError("GNPE proxy is 1:1; use fan_out=1.") + times = {k: cond.given[k] for k in self.gnpe_parameters} + proxies = self.gnpe.sample_proxies(times) + return proxies, self.log_prob(proxies, cond) + + def log_prob(self, theta_i, cond): + """``log p(theta_hat | theta)`` from the kernel, at the proxies (``theta_i``) and + the detector times (``cond.given``).""" + diffs = {} + for k in self.kernel.keys(): + diff = cond.given[k] - theta_i[f"{k}_proxy"] + if torch.is_tensor(diff): + diff = diff.detach().cpu().numpy() + diffs[k] = np.asarray(diff) + return torch.as_tensor(self.kernel.ln_prob(diffs, axis=0), dtype=torch.float32) + + +class GNPEFlowFactor(Factor): + """ + The GNPE main network q(theta | theta_hat, d) as a factor. + + Conditions on the detector-time proxies from ``GNPEKernelFactor``: it shifts each + detector's strain by the corresponding proxy time (standardizing the network input), + samples the network, and recomputes the detector times from the sampled sky position + and geocent time. The proxies are supplied, so no blurring happens here. Wraps the main + model and the per-iteration transforms built for ``GWSamplerGNPE``. + + The single network factor in either GNPE mode: cycled by ``GibbsComposer`` for + multi-iteration GNPE, or a ``ChainComposer`` factor for single-step GNPE. The recomputed + detector times are returned as extra columns: the next Gibbs iteration blurs them into + fresh proxies, and single-step GNPE evaluates the kernel correction at them. One sample + per proxy row. """ def __init__( @@ -207,81 +343,26 @@ def __init__( self.gnpe_parameters = gnpe_parameters self.proxy_parameters = [p + "_proxy" for p in gnpe_parameters] self.parameters = parameters + self.conditioning = list(self.proxy_parameters) @classmethod def from_model(cls, model: BasePosteriorModel) -> "GNPEFlowFactor": - """Build the GNPE per-iteration transforms from the main model's metadata, - replicating ``GWSamplerGNPE._initialize_transforms`` (time-shift GNPE).""" - meta = _base_model_metadata(model) - data_settings = meta["train_settings"]["data"] - ifo_list = InterferometerList(data_settings["detectors"]) - domain = build_domain(meta["dataset_settings"]["domain"]) - if "domain_update" in data_settings: - domain.update(data_settings["domain_update"]) - - gnpe_time_settings = data_settings.get("gnpe_time_shifts") - if not gnpe_time_settings: - raise NotImplementedError( - "Only time-shift GNPE is supported here so far " - "(no gnpe_chirp / gnpe_phase)." - ) - - transform_pre = [ - RenameKey("data", "waveform"), - GNPECoalescenceTimes( - ifo_list, - gnpe_time_settings["kernel"], - gnpe_time_settings["exact_equiv"], - inference=True, - ), - TimeShiftStrain(ifo_list, domain), - SelectStandardizeRepackageParameters( - {"context_parameters": data_settings["context_parameters"]}, - data_settings["standardization"], - device=model.device, - ), - RenameKey("waveform", "data"), - ] - gnpe_parameters = [] - for transform in transform_pre: - if isinstance(transform, GNPEBase): - gnpe_parameters += transform.input_parameter_names - - inference_parameters = data_settings["inference_parameters"] - transform_post = [ - SelectStandardizeRepackageParameters( - {"inference_parameters": inference_parameters}, - data_settings["standardization"], - inverse=True, - as_type="dict", - ), - PostCorrectGeocentTime(), - CopyToExtrinsicParameters( - "ra", "dec", "geocent_time", "chirp_mass", "mass_ratio", "phase" - ), - GetDetectorTimes(ifo_list, data_settings["ref_time"]), - ] - return cls( - model, - Compose(transform_pre), - Compose(transform_post), - gnpe_parameters, - inference_parameters, + """Build the GNPE per-iteration transforms from the main model's metadata.""" + pre, post, gnpe_parameters, inference_parameters, _, _ = _build_gnpe_transforms( + model ) + return cls(model, pre, post, gnpe_parameters, inference_parameters) - def gibbs_step( - self, num_samples: int, context, extrinsic: dict - ) -> tuple[dict, dict]: - """One GNPE Gibbs iteration (single pass): returns (sampled parameters, updated - extrinsic state -- recomputed detector times + proxies for the next iteration). - Batching over the Gibbs walkers is the composer's job (``chunk_and_concat``). - """ - x = { - "extrinsic_parameters": {k: extrinsic[k] for k in self.gnpe_parameters}, - "parameters": {}, - } - d = context.prepared_data().clone() - x["data"] = d.expand(num_samples, *d.shape) + def sample_and_log_prob(self, num_samples, cond): + """Sample one parameter set per proxy row; ``num_samples`` must be 1 (GNPE is 1:1). + Returns theta plus the recomputed detector times, and the network log-prob.""" + if num_samples != 1: + raise ValueError("GNPE is 1:1; draw one sample per proxy (fan_out=1).") + proxies = {p: cond.given[p] for p in self.proxy_parameters} + n_rows = next(iter(proxies.values())).shape[0] + x = {"extrinsic_parameters": dict(proxies), "parameters": {}} + d = cond.context.prepared_data().clone() + x["data"] = d.expand(n_rows, *d.shape) x = self.transform_pre(x) self.model.network.eval() with torch.no_grad(): @@ -291,68 +372,22 @@ def gibbs_step( ) else: y, log_prob = self.model.sample_and_log_prob(x["data"]) - # sample_and_log_prob(num_samples=1) adds a singleton dim; the batch is the - # num_samples Gibbs walkers. + # sample_and_log_prob(num_samples=1) adds a singleton dim; the batch is the proxy + # rows. x["parameters"] = y.squeeze(1) x["log_prob"] = log_prob.squeeze(1) x = self.transform_post(x) - return x["parameters"], x["extrinsic_parameters"] - - -class GNPEGibbsComposer: - """ - The Gibbs sampler for multi-iteration GNPE -- GNPE-specific by name and by guts (it - drives the GNPE step: proxies, detector times, time-shift). Gibbs sampling in general - is broader than this, but Dingo only ever uses it for GNPE, so the class owns that - specificity rather than posing as a general Gibbs facility. The genuinely generic - pieces live in core: ``ChainComposer``, the ``chunk_and_concat`` batching primitive, - and the ``Composer`` protocol this satisfies. - - Each iteration is a 2-block Gibbs sweep on the joint q(theta, theta_hat | d): blur the - detector times into proxies (theta_hat ~ kernel(.|theta)), then sample the network - (theta ~ q(.|theta_hat, d)). Both blocks are bundled in ``gnpe_factor.gibbs_step``; - the ``init_factor`` seeds the chain and ``num_iterations`` is burn-in. The dependency - is cyclic, so this breaks density access: ``sample`` returns parameters WITHOUT a - ``log_prob``. (Single-step GNPE that *does* preserve log_prob is a two-stage - ``ChainComposer`` instead.) - - Batching is vertical, via ``chunk_and_concat``: chunk the Gibbs walkers and run the - whole loop per chunk -- the old ``GWSamplerGNPE.run_sampler`` order. - """ - - def __init__(self, init_factor: Factor, gnpe_factor, num_iterations: int): - self.init_factor = init_factor - self.gnpe_factor = gnpe_factor - self.num_iterations = num_iterations + params = dict(x["parameters"]) + # Surface the recomputed detector times: the next Gibbs iteration's input, and the + # evaluation point for GNPEKernelFactor's importance-sampling correction. + for k in self.gnpe_parameters: + params[k] = x["extrinsic_parameters"][k] + return params, x["log_prob"] - def sample( - self, - num_samples: int, - context: SamplerContext, - batch_size: Optional[int] = None, - ) -> dict[str, torch.Tensor]: - samples, _ = chunk_and_concat( - num_samples, batch_size, lambda n: (self._run_once(n, context), None) - ) - return samples - - def _run_once( - self, num_samples: int, context: SamplerContext - ) -> dict[str, torch.Tensor]: - # Seed the Gibbs chain with the init factor's parameters (e.g. detector times). - seed, _ = self.init_factor.sample_and_log_prob( - num_samples, Conditioning(context) + def log_prob(self, theta_i, cond): + raise NotImplementedError( + "GNPEFlowFactor.log_prob -- later step (single-step GNPE importance sampling)." ) - extrinsic = dict(seed) - parameters: dict[str, torch.Tensor] = {} - for _ in range(self.num_iterations): - parameters, extrinsic = self.gnpe_factor.gibbs_step( - num_samples, context, extrinsic - ) - proxies = { - p: extrinsic[p] for p in self.gnpe_factor.proxy_parameters if p in extrinsic - } - return {**parameters, **proxies} class GWComposedSampler(ComposedSampler): @@ -366,7 +401,7 @@ class GWComposedSampler(ComposedSampler): def __init__( self, - composer: Union[ChainComposer, GNPEGibbsComposer], + composer: Composer, context: GWSamplerContext, metadata: dict, inference_parameters: list[str], @@ -403,17 +438,20 @@ def from_gnpe_models( ) -> "GWComposedSampler": """Build a multi-iteration time-GNPE sampler from an init + main model pair: the init model's data preprocessing, an init ``FlowFactor`` to seed, and a - ``GNPEFlowFactor`` driven by a ``GNPEGibbsComposer``. Returns samples without a - log_prob (Gibbs breaks density access).""" + ``GibbsComposer`` cycling the GNPE kernel and main-network factors. Returns samples + without a log_prob (Gibbs breaks density access).""" context = GWSamplerContext.from_model(init_model, raw_context, event_metadata) init_factor = FlowFactor.from_model(init_model) - gnpe_factor = GNPEFlowFactor.from_model(main_model) - composer = GNPEGibbsComposer(init_factor, gnpe_factor, num_iterations) + kernel_factor = GNPEKernelFactor.from_model(main_model) + flow_factor = GNPEFlowFactor.from_model(main_model) + composer = GibbsComposer( + init_factor, [kernel_factor, flow_factor], num_iterations + ) return cls( composer, context, _base_model_metadata(main_model), - gnpe_factor.parameters, + flow_factor.parameters, ) def _correct_reference_time( From 700b7d064dd0e920530d522ca5cd4dac71de7599 Mon Sep 17 00:00:00 2001 From: Stephen Green Date: Wed, 1 Jul 2026 09:43:31 +0200 Subject: [PATCH 12/65] Add single-step GNPE sampler on the factorized spine GWComposedSampler.from_singlestep_gnpe builds a ChainComposer of [proxy_source, GNPEFlowFactor] for density-preserving single-step GNPE. proxy_source supplies the detector-time proxies: a FixedFactor for prior conditioning (BNS), or an unconditional NDE for density recovery (which supersedes prepare_log_prob's single-step half). The kernel correction delta_log_prob_target = log p(theta_hat | theta) is evaluated in post-processing (via a held GNPEKernelFactor) at the proxies and the detector times recomputed from theta, then the intermediate detector times are dropped. It is kept out of the proposal log_prob; the column-driven importance-sampling layer applies it to the joint target. Bit-exact vs GWSamplerGNPE(num_iterations=1) end to end through run_sampler + to_result (fixed-proxy case). Co-Authored-By: Claude Opus 4.8 (1M context) --- dingo/gw/inference/factors.py | 49 +++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/dingo/gw/inference/factors.py b/dingo/gw/inference/factors.py index d085a6972..6d60bb2ca 100644 --- a/dingo/gw/inference/factors.py +++ b/dingo/gw/inference/factors.py @@ -28,6 +28,7 @@ ChainComposer, ComposedSampler, Composer, + Conditioning, Factor, FlowFactor, GibbsComposer, @@ -405,10 +406,15 @@ def __init__( context: GWSamplerContext, metadata: dict, inference_parameters: list[str], + kernel_factor: Optional[GNPEKernelFactor] = None, ): super().__init__(composer, context) self.metadata = metadata self.inference_parameters = inference_parameters + # Set for single-step GNPE. Supplies the delta_log_prob_target kernel correction + # (evaluated in post-processing, out of the proposal sum) that importance sampling + # applies to the joint target q(theta, theta_hat | d). + self.kernel_factor = kernel_factor @classmethod def from_model( @@ -454,6 +460,33 @@ def from_gnpe_models( flow_factor.parameters, ) + @classmethod + def from_singlestep_gnpe( + cls, + main_model: BasePosteriorModel, + proxy_source: Factor, + raw_context: dict, + event_metadata: Optional[dict] = None, + ) -> "GWComposedSampler": + """Build a single-step (density-preserving) time-GNPE sampler: a ``ChainComposer`` + of ``[proxy_source, GNPEFlowFactor]``. ``proxy_source`` supplies the detector-time + proxies -- a ``FixedFactor`` for prior conditioning (BNS), or an unconditional NDE + for density recovery. Unlike multi-iteration GNPE the chain is autoregressive, so + log_prob is preserved; the ``GNPEKernelFactor`` supplies the + ``delta_log_prob_target`` correction that importance sampling adds to the target. + """ + context = GWSamplerContext.from_model(main_model, raw_context, event_metadata) + flow_factor = GNPEFlowFactor.from_model(main_model) + kernel_factor = GNPEKernelFactor.from_model(main_model) + composer = ChainComposer([proxy_source, flow_factor]) + return cls( + composer, + context, + _base_model_metadata(main_model), + flow_factor.parameters, + kernel_factor=kernel_factor, + ) + def _correct_reference_time( self, samples: Union[dict, pd.DataFrame], inverse: bool = False ): @@ -479,7 +512,23 @@ def _correct_reference_time( else: samples["ra"] = (ra - ra_correction) % (2 * np.pi) + def _add_kernel_correction(self, samples: dict): + """Single-step GNPE: add ``delta_log_prob_target = log p(theta_hat | theta)``, + evaluated at the proxies and the detector times recomputed from theta, then drop + those intermediate detector times. This is the joint-target correction importance + sampling applies; it is deliberately kept out of the proposal ``log_prob``.""" + kf = self.kernel_factor + proxies = {p: samples[p] for p in kf.parameters} + times = {k: samples[k] for k in kf.gnpe_parameters} + correction = kf.log_prob(proxies, Conditioning(self.context, times)) + samples["delta_log_prob_target"] = np.asarray(correction) + for k in kf.gnpe_parameters: + samples.pop(k, None) + def _post_process(self, samples: Union[dict, pd.DataFrame], inverse: bool = False): + if self.kernel_factor is not None and not inverse: + self._add_kernel_correction(samples) + intrinsic_prior = self.metadata["dataset_settings"]["intrinsic_prior"] extrinsic_prior = get_extrinsic_prior_dict( self.metadata["train_settings"]["data"]["extrinsic_prior"] From f0220b52b607de2a004d543734aab2f84f1a7270 Mon Sep 17 00:00:00 2001 From: Stephen Green Date: Wed, 1 Jul 2026 11:36:37 +0200 Subject: [PATCH 13/65] Generalize ChainComposer to a Step list; drop the Composer protocol - Add a `Step` protocol (parameters / conditioning / sample_and_log_prob -> (dict, Optional log_prob)); Stage.factor -> Stage.step, .factors -> .steps. - ChainComposer's fold is None-aware: a single density-free step nulls the chain's log_prob, and sample() then omits it. - Replace GibbsComposer with GibbsBlock, a density-free Step; multi-iteration GNPE is now ChainComposer([GibbsBlock(...)]). The Gibbs loop is unchanged, so parity stays bit-exact. - Drop the now one-implementer Composer protocol. Parity: NPE, multi-iteration GNPE, single-step, and packaged to_result all bit-exact (max|delta|=0); tests/core/test_factors.py 12/12. Co-Authored-By: Claude Opus 4.8 (1M context) --- dingo/core/factors.py | 171 +++++++++++++++++++--------------- dingo/gw/inference/factors.py | 22 ++--- tests/core/test_factors.py | 59 +++++++++++- 3 files changed, 162 insertions(+), 90 deletions(-) diff --git a/dingo/core/factors.py b/dingo/core/factors.py index c545fc160..6b6f94e14 100644 --- a/dingo/core/factors.py +++ b/dingo/core/factors.py @@ -257,12 +257,30 @@ def log_prob(self, theta_i, cond): return log_prob + self.standardization.log_det(self.parameters) +class Step(Protocol): + """ + One entry a ``ChainComposer`` folds over: it emits a parameter block and an optional + contribution to the proposal ``log_prob``. + + Density-contributing steps (``Factor``) return a tensor; density-free sampling blocks + (``GibbsBlock``) return ``None``. ``parameters`` names the block produced, ``conditioning`` + the earlier-block parameters read from the chain (data is implicit via the context). + """ + + parameters: list[str] + conditioning: list[str] + + def sample_and_log_prob( + self, num_samples: int, cond: "Conditioning" + ) -> tuple[dict[str, torch.Tensor], Optional[torch.Tensor]]: ... + + @dataclass class Stage: - """A chain factor and its fan-out: the number of samples drawn per incoming + """A chain ``Step`` and its fan-out: the number of samples drawn per incoming conditioning row. The root stage draws the base count and ignores ``fan_out``.""" - factor: Factor + step: Step fan_out: int = 1 @@ -270,35 +288,38 @@ class ChainComposer: """ Autoregressive composer over an ordered list of ``Stage``s. - Draws the stages in declared order -- a topological order of the conditioning DAG -- - expanding each stage by its fan-out and summing the log-probs. Covers plain NPE, - single-step GNPE, prior conditioning, synthetic phase, and intrinsic/extrinsic splits. - Multi-iteration (cyclic) GNPE uses ``GibbsComposer`` instead. + Folds the steps in declared order -- a topological order of the conditioning DAG -- + expanding each by its fan-out and summing the proposal log-probs. A step is a + ``Factor`` (contributes ``log q_i``) or a density-free sampling block (``GibbsBlock``, + contributes ``None``); if any step is density-free the chain has no tractable density + and ``sample`` omits ``log_prob``. Covers plain NPE, single-step GNPE, prior + conditioning, synthetic phase, intrinsic/extrinsic splits, and -- via ``GibbsBlock`` -- + multi-iteration GNPE. - Accepts bare factors (wrapped as ``Stage(factor, fan_out=1)``) or explicit ``Stage``s. + Accepts bare steps (wrapped as ``Stage(step, fan_out=1)``) or explicit ``Stage``s. """ - def __init__(self, stages: list[Union["Stage", Factor]]): + def __init__(self, stages: list[Union["Stage", Step]]): self.stages = [s if isinstance(s, Stage) else Stage(s) for s in stages] self._validate() def _validate(self): """Check the declared order is a valid topological order: every conditioning - name is produced by an earlier factor.""" + name is produced by an earlier step.""" produced: set[str] = set() - for factor in self.factors: - missing = [c for c in factor.conditioning if c not in produced] + for step in self.steps: + missing = [c for c in step.conditioning if c not in produced] if missing: raise ValueError( - f"Factor producing {factor.parameters} conditions on {missing}, " - f"which no earlier factor produces. Check chain order." + f"A step producing {step.parameters} conditions on {missing}, " + f"which no earlier step produces. Check chain order." ) - produced.update(factor.parameters) + produced.update(step.parameters) @property - def factors(self) -> list[Factor]: - """The stage factors, in order.""" - return [stage.factor for stage in self.stages] + def steps(self) -> list[Step]: + """The stage steps, in order.""" + return [stage.step for stage in self.stages] @property def expansion(self) -> int: @@ -311,45 +332,53 @@ def sample_and_log_prob( num_samples: int, context: SamplerContext, batch_size: Optional[int] = None, - ) -> tuple[dict[str, torch.Tensor], torch.Tensor]: + ) -> tuple[dict[str, torch.Tensor], Optional[torch.Tensor]]: """Draw samples. ``num_samples`` is the base (root) count; the result has ``num_samples * expansion`` rows. ``batch_size`` chunks the base count (``None`` - draws in one pass).""" + draws in one pass). The log-prob is ``None`` if any step is density-free.""" return chunk_and_concat( num_samples, batch_size, lambda n: self._run_chain_once(n, context) ) def _run_chain_once( self, base: int, context: SamplerContext - ) -> tuple[dict[str, torch.Tensor], torch.Tensor]: - """One pass of the whole chain for ``base`` root samples.""" + ) -> tuple[dict[str, torch.Tensor], Optional[torch.Tensor]]: + """One pass of the whole chain for ``base`` root samples. Returns the samples and + the summed proposal log-prob, or ``None`` if any step is density-free.""" samples: dict[str, torch.Tensor] = {} total: torch.Tensor | float = 0.0 + has_density = True for i, stage in enumerate(self.stages): - factor = stage.factor + step = stage.step n = base if i == 0 else stage.fan_out - cond = Conditioning(context, {k: samples[k] for k in factor.conditioning}) - block, lp = factor.sample_and_log_prob(n, cond) - # The factor returned (rows_so_far * n) rows. Expand the carried columns to - # match -- each upstream row repeated n times (the block is flattened in the - # same row-major order). The root has no carried rows, and fan_out=1 stages - # are a no-op, so 1:1 chains are untouched. + cond = Conditioning(context, {k: samples[k] for k in step.conditioning}) + block, lp = step.sample_and_log_prob(n, cond) + # The step returned (rows_so_far * n) rows. Expand the carried columns to match + # -- each upstream row repeated n times (the block is flattened in the same + # row-major order). The root has no carried rows, and fan_out=1 stages are a + # no-op, so 1:1 chains are untouched. if i > 0 and n > 1: samples = {k: v.repeat_interleave(n, 0) for k, v in samples.items()} if torch.is_tensor(total): total = total.repeat_interleave(n, 0) samples.update(block) - total = total + lp - return samples, total + # A single density-free step (Gibbs) makes the whole chain density-free. + if lp is None: + has_density = False + elif has_density: + total = total + lp + return samples, (total if has_density else None) def log_prob( self, samples: dict[str, torch.Tensor], context: SamplerContext ) -> torch.Tensor: + """Sum each step's ``log_prob`` at the given samples. Valid only for an all-density + chain (every step a ``Factor``).""" total: torch.Tensor | float = 0.0 - for factor in self.factors: - cond = Conditioning(context, {k: samples[k] for k in factor.conditioning}) - theta_i = {k: samples[k] for k in factor.parameters} - total = total + factor.log_prob(theta_i, cond) + for step in self.steps: + cond = Conditioning(context, {k: samples[k] for k in step.conditioning}) + theta_i = {k: samples[k] for k in step.parameters} + total = total + step.log_prob(theta_i, cond) return total def sample( @@ -358,42 +387,45 @@ def sample( context: SamplerContext, batch_size: Optional[int] = None, ) -> dict[str, torch.Tensor]: - """Per-sample dict of parameters plus ``log_prob``.""" + """Per-sample dict of parameters, plus ``log_prob`` for an all-density chain.""" samples, log_prob = self.sample_and_log_prob(num_samples, context, batch_size) + if log_prob is None: + return dict(samples) return {**samples, "log_prob": log_prob} -class GibbsComposer: +class GibbsBlock: """ - Blocked Gibbs composer over a cyclic conditioning dependency. + A density-free sampling-block ``Step``: runs blocked Gibbs internally and yields no + proposal log-prob. Seeds the chain with an init factor, then sweeps the factor list in order for ``num_iterations`` iterations; each factor conditions on the current state and - overwrites its own block. The cyclic dependency has no tractable marginal, so - ``sample`` returns parameters without a ``log_prob`` (recoverable by fitting an - unconditional density to the samples and taking one ``ChainComposer`` step). Dingo uses - this only for multi-iteration GNPE (the GNPE factors in ``dingo.gw.inference.factors``), - but the loop is generic. - - Batching is vertical, via ``chunk_and_concat``: chunk the walkers and run the whole - loop per chunk. + overwrites its own block. As a chain ``Step`` it produces the swept parameter blocks + and returns ``None`` for the log-prob -- the cyclic dependency has no tractable marginal + (recoverable by fitting an unconditional density to the samples and taking one + ``ChainComposer`` step). Dingo uses this only for multi-iteration GNPE (the GNPE factors + in ``dingo.gw.inference.factors``), but the loop is generic. + + Batching is handled by the enclosing ``ChainComposer``: it chunks the walkers and runs + the whole loop per chunk (``chunk_and_concat``). """ def __init__(self, init_factor: Factor, factors: list[Factor], num_iterations: int): self.init_factor = init_factor self.factors = list(factors) self.num_iterations = num_iterations + # The blocks this step produces (proxies + inference parameters), dropping + # side-channel columns such as the recomputed detector times. + self.parameters = [p for factor in self.factors for p in factor.parameters] + self.conditioning: list[str] = [] - def sample( - self, - num_samples: int, - context: SamplerContext, - batch_size: Optional[int] = None, - ) -> dict[str, torch.Tensor]: - samples, _ = chunk_and_concat( - num_samples, batch_size, lambda n: (self._run_once(n, context), None) - ) - return samples + def sample_and_log_prob( + self, num_samples: int, cond: Conditioning + ) -> tuple[dict[str, torch.Tensor], None]: + """Run the Gibbs loop for ``num_samples`` walkers; return ``(samples, None)``. + ``num_samples`` is the walker (root) count -- Gibbs does not fan out.""" + return self._run_once(num_samples, cond.context), None def _run_once( self, num_samples: int, context: SamplerContext @@ -405,35 +437,20 @@ def _run_once( state = dict(seed) for _ in range(self.num_iterations): for factor in self.factors: - cond = Conditioning(context, {k: state[k] for k in factor.conditioning}) + c = Conditioning(context, {k: state[k] for k in factor.conditioning}) # One sample per walker (Gibbs is 1:1); walkers are the conditioning rows. - block, _ = factor.sample_and_log_prob(1, cond) + block, _ = factor.sample_and_log_prob(1, c) state.update(block) - # Each factor's parameter block (e.g. proxies + inference parameters), dropping - # side-channel columns such as the recomputed detector times. - return {p: state[p] for factor in self.factors for p in factor.parameters} - - -class Composer(Protocol): - """Interface required by ``ComposedSampler``: ``sample`` returns a per-sample dict of - parameters (plus ``log_prob`` for density-preserving composers). Satisfied by - ``ChainComposer`` and ``GibbsComposer``.""" - - def sample( - self, - num_samples: int, - context: SamplerContext, - batch_size: Optional[int] = None, - ) -> dict[str, torch.Tensor]: ... + return {p: state[p] for p in self.parameters} class ComposedSampler: """ - Façade over a ``Composer`` and a ``SamplerContext``. Runs the composer, applies + Façade over a ``ChainComposer`` and a ``SamplerContext``. Runs the composer, applies domain-specific post-processing, and returns the samples as a DataFrame. """ - def __init__(self, composer: Composer, context: SamplerContext): + def __init__(self, composer: ChainComposer, context: SamplerContext): self.composer = composer self.context = context self.samples: Optional[pd.DataFrame] = None @@ -446,8 +463,8 @@ def run_sampler( self, num_samples: int, batch_size: Optional[int] = None ) -> pd.DataFrame: """Draw ``num_samples`` samples (chunked by ``batch_size``), post-process, and - return them as a DataFrame. ``ChainComposer`` includes ``log_prob``; - ``GibbsComposer`` does not.""" + return them as a DataFrame. An all-density chain includes ``log_prob``; a chain + with a ``GibbsBlock`` step does not.""" merged = self.composer.sample(num_samples, self.context, batch_size) merged = {k: v.cpu().numpy() for k, v in merged.items()} self._post_process(merged) diff --git a/dingo/gw/inference/factors.py b/dingo/gw/inference/factors.py index 6d60bb2ca..36f84f674 100644 --- a/dingo/gw/inference/factors.py +++ b/dingo/gw/inference/factors.py @@ -7,7 +7,7 @@ ``GWSamplerContext`` (which builds the data-preprocessing conditioning map ``f_i``) and the post-processing in ``GWComposedSampler``. Covers plain NPE (``FlowFactor`` via a ``ChainComposer``) and multi-iteration time-shift GNPE (``GNPEKernelFactor`` + -``GNPEFlowFactor`` cycled by a ``GibbsComposer``); the synthetic-phase factor and +``GNPEFlowFactor`` cycled by a ``GibbsBlock``); the synthetic-phase factor and single-step GNPE importance sampling remain for later steps. """ @@ -27,11 +27,10 @@ from dingo.core.factors import ( ChainComposer, ComposedSampler, - Composer, Conditioning, Factor, FlowFactor, - GibbsComposer, + GibbsBlock, _base_model_metadata, ) from dingo.core.posterior_models import BasePosteriorModel @@ -323,7 +322,7 @@ class GNPEFlowFactor(Factor): and geocent time. The proxies are supplied, so no blurring happens here. Wraps the main model and the per-iteration transforms built for ``GWSamplerGNPE``. - The single network factor in either GNPE mode: cycled by ``GibbsComposer`` for + The single network factor in either GNPE mode: cycled by a ``GibbsBlock`` for multi-iteration GNPE, or a ``ChainComposer`` factor for single-step GNPE. The recomputed detector times are returned as extra columns: the next Gibbs iteration blurs them into fresh proxies, and single-step GNPE evaluates the kernel correction at them. One sample @@ -402,7 +401,7 @@ class GWComposedSampler(ComposedSampler): def __init__( self, - composer: Composer, + composer: ChainComposer, context: GWSamplerContext, metadata: dict, inference_parameters: list[str], @@ -443,18 +442,17 @@ def from_gnpe_models( num_iterations: int = 30, ) -> "GWComposedSampler": """Build a multi-iteration time-GNPE sampler from an init + main model pair: the - init model's data preprocessing, an init ``FlowFactor`` to seed, and a - ``GibbsComposer`` cycling the GNPE kernel and main-network factors. Returns samples - without a log_prob (Gibbs breaks density access).""" + init model's data preprocessing, an init ``FlowFactor`` to seed, and a single + ``GibbsBlock`` step -- cycling the GNPE kernel and main-network factors -- in a + ``ChainComposer``. Returns samples without a log_prob (Gibbs breaks density + access).""" context = GWSamplerContext.from_model(init_model, raw_context, event_metadata) init_factor = FlowFactor.from_model(init_model) kernel_factor = GNPEKernelFactor.from_model(main_model) flow_factor = GNPEFlowFactor.from_model(main_model) - composer = GibbsComposer( - init_factor, [kernel_factor, flow_factor], num_iterations - ) + gibbs = GibbsBlock(init_factor, [kernel_factor, flow_factor], num_iterations) return cls( - composer, + ChainComposer([gibbs]), context, _base_model_metadata(main_model), flow_factor.parameters, diff --git a/tests/core/test_factors.py b/tests/core/test_factors.py index 58d7bc3d6..8042bbbf2 100644 --- a/tests/core/test_factors.py +++ b/tests/core/test_factors.py @@ -15,6 +15,7 @@ ChainComposer, Conditioning, Factor, + GibbsBlock, Stage, chunk_and_concat, ) @@ -61,7 +62,7 @@ def test_bare_factor_is_wrapped_as_stage(): comp = ChainComposer([_ConstFactor("a")]) assert isinstance(comp.stages[0], Stage) assert comp.stages[0].fan_out == 1 - assert comp.factors[0].parameters == ["a"] + assert comp.steps[0].parameters == ["a"] def test_fan_out_expansion_alignment_and_logprob(): @@ -146,3 +147,59 @@ def test_conditioning_dataclass_defaults(): cond = Conditioning(context="ctx") assert cond.context == "ctx" assert cond.given == {} + + +class _NoDensityStep: + """A density-free step (like ``GibbsBlock``): emits a block but returns ``None`` for the + log-prob. Honors the per-row fan-out contract so it composes like any step.""" + + def __init__(self, name, conditioning=()): + self.parameters = [name] + self.conditioning = list(conditioning) + self._name = name + + def sample_and_log_prob(self, n, cond): + if self.conditioning: + base = sum(cond.given[k] for k in self.conditioning) + within = torch.arange(n, dtype=base.dtype) + vals = (base.unsqueeze(1) + within).reshape(-1) + else: + vals = torch.arange(n, dtype=torch.float32) + return {self._name: vals}, None + + +def test_density_free_step_omits_log_prob(): + # A density-free root step (Gibbs) yields no proposal density. + comp = ChainComposer([_NoDensityStep("a")]) + _, lp = comp.sample_and_log_prob(5, context=None) + assert lp is None + out = comp.sample(5, context=None) + assert "log_prob" not in out + assert torch.equal(out["a"], torch.arange(5, dtype=torch.float32)) + + +def test_one_density_free_step_nulls_the_whole_chain(): + # A single None step makes the chain density-free even alongside a density factor. + comp = ChainComposer( + [_ConstFactor("a"), Stage(_NoDensityStep("b", conditioning=["a"]))] + ) + _, lp = comp.sample_and_log_prob(4, context=None) + assert lp is None + assert "log_prob" not in comp.sample(4, context=None) + + +def test_gibbs_block_runs_as_a_density_free_step(): + # GibbsBlock seeds, sweeps its factors num_iterations times, and yields no density; + # seed-only params (not reproduced by a factor) are dropped, matching the old composer. + block = GibbsBlock( + init_factor=_ConstFactor("proxy"), + factors=[_ConstFactor("theta", conditioning=["proxy"])], + num_iterations=3, + ) + assert block.parameters == ["theta"] and block.conditioning == [] + comp = ChainComposer([block]) + out = comp.sample(6, context=None) + assert "log_prob" not in out + assert "proxy" not in out # seed-only, dropped + # theta | proxy with Gibbs' one-per-walker draw: theta == proxy == arange(6). + assert torch.equal(out["theta"], torch.arange(6, dtype=torch.float32)) From be8a74ca45383ba37c12a9addeda85a0afad1050 Mon Sep 17 00:00:00 2001 From: Stephen Green Date: Wed, 1 Jul 2026 14:33:24 +0200 Subject: [PATCH 14/65] Move RA frame and fixed params into chain steps; drop _post_process re-plug Reparametrizations (Increment 1b): - Add Reparametrization, a core bijector Step (forward/inverse/log_det, contributing -log|det J|, 1:1). RAReparam ports GWSamplerMixin's t_ref/RA sky rotation into the chain, via a ra@t_ref -> ra factor-boundary alias, and deletes _correct_reference_time. ra is stored float32 (a bounded angle; the old float64 was an incidental numpy promotion). The correction is computed in float64 (absolute GPS times); harnesses compare ra at float32. - FlowFactor / GNPEFlowFactor gain an `aliases` map exposing trained names under canonical names at the factor boundary. - The composer makes a Reparametrization consume its input (in-place bijection), so the network-frame intermediate never reaches post-processing. Fixed parameters (Increment 2): - Delta-prior parameters move from _post_process into a FixedFactor chain step (_fixed_prior_steps, resolved once at build time). - The composer supports an unconditioned non-root step (a filler): it draws one value per current row rather than fanning out. FixedFactor also remains the chain root for prior-conditioning / known proxies. Drop the dead _post_process inverse branch: its job was to strip the log_prob column so old Sampler.log_prob's in-band `+=` would not double-count. The factorized log_prob sums per-factor densities explicitly, so both the `+=` and the strip are obsolete. _post_process is now just the single-step-GNPE kernel correction; the `inverse` parameter is gone. Parity: NPE, multi-iteration GNPE, single-step, and packaged all bit-exact (ra at float32). tests/core/test_factors.py (reparam consume + -log_det + round-trip; unconditioned filler incl. as root) and tests/gw/test_ra_reparam.py (forward-inverse round trip + no-op) green; 21 tests total. Co-Authored-By: Claude Opus 4.8 (1M context) --- dingo/core/factors.py | 116 +++++++++++++--- dingo/gw/inference/factors.py | 244 +++++++++++++++++++++++----------- tests/core/test_factors.py | 103 ++++++++++++++ tests/gw/test_ra_reparam.py | 44 ++++++ 4 files changed, 410 insertions(+), 97 deletions(-) create mode 100644 tests/gw/test_ra_reparam.py diff --git a/dingo/core/factors.py b/dingo/core/factors.py index 6b6f94e14..77e788db7 100644 --- a/dingo/core/factors.py +++ b/dingo/core/factors.py @@ -187,9 +187,15 @@ def __init__( parameters: list[str], conditioning: Optional[list[str]] = None, context_parameters: Optional[list[str]] = None, + aliases: Optional[dict[str, str]] = None, ): self.model = model - self.parameters = parameters + # The network's trained parameter names -- standardization is keyed by these. The + # factor exposes them under canonical aliases (e.g. ra -> ra@t_ref), so a downstream + # reparametrization can convert frames by name without retraining (design Q#7). + self._net_parameters = parameters + self.aliases = aliases or {} + self.parameters = [self.aliases.get(p, p) for p in parameters] self.conditioning = conditioning or [] # Network conditioning inputs (GNPE proxies). Empty for plain NPE. self.context_parameters = context_parameters or [] @@ -197,9 +203,12 @@ def __init__( self.standardization = Standardization(std["mean"], std["std"]) @classmethod - def from_model(cls, model: BasePosteriorModel) -> "FlowFactor": + def from_model( + cls, model: BasePosteriorModel, aliases: Optional[dict[str, str]] = None + ) -> "FlowFactor": """Build a factor from a model, reading ``parameters`` and ``context_parameters`` - from its training metadata.""" + from its training metadata. ``aliases`` maps trained names to exposed canonical + names (e.g. ``{"ra": "ra@t_ref"}``) at the factor boundary.""" data_settings = _base_model_metadata(model)["train_settings"]["data"] context_parameters = data_settings.get("context_parameters") or [] return cls( @@ -207,6 +216,7 @@ def from_model(cls, model: BasePosteriorModel) -> "FlowFactor": parameters=data_settings["inference_parameters"], conditioning=list(context_parameters), context_parameters=list(context_parameters), + aliases=aliases, ) def sample_and_log_prob(self, num_samples, cond): @@ -236,13 +246,19 @@ def sample_and_log_prob(self, num_samples, cond): ) z = z.reshape(n_rows * num_samples, z.shape[-1]) log_prob = log_prob.reshape(n_rows * num_samples) - theta = self.standardization.destandardize(z, self.parameters) - log_prob = log_prob + self.standardization.log_det(self.parameters) + theta = self.standardization.destandardize(z, self._net_parameters) + log_prob = log_prob + self.standardization.log_det(self._net_parameters) + # Expose trained names under their canonical aliases at the factor boundary. + theta = {self.aliases.get(k, k): v for k, v in theta.items()} return theta, log_prob def log_prob(self, theta_i, cond): - num_samples = next(iter(theta_i.values())).shape[0] - z = self.standardization.standardize(theta_i, self.parameters) + # theta_i uses exposed (aliased) names; map back to the network's trained names. + theta_net = { + net: theta_i[self.aliases.get(net, net)] for net in self._net_parameters + } + num_samples = next(iter(theta_net.values())).shape[0] + z = self.standardization.standardize(theta_net, self._net_parameters) data = cond.context.prepared_data() data = data.expand(num_samples, *data.shape) net_context: tuple[torch.Tensor, ...] @@ -254,7 +270,60 @@ def log_prob(self, theta_i, cond): self.model.network.eval() with torch.no_grad(): log_prob = self.model.log_prob(z, *net_context) - return log_prob + self.standardization.log_det(self.parameters) + return log_prob + self.standardization.log_det(self._net_parameters) + + +class Reparametrization(ABC): + """ + A deterministic bijection ``Step``: it transforms existing parameters (no sampling) + and contributes ``-log|det J|`` to the proposal density. + + Unlike a ``Factor`` it is 1:1 and invertible -- ``forward`` maps the conditioning block + to the ``parameters`` block, ``inverse`` maps back (for re-plug / importance sampling), + and its density contribution is a Jacobian, not a sampled log-prob. Used to relate a + network's coordinates to physical ones (e.g. right ascension from the training reference + frame to the event frame). Subclasses implement ``forward`` / ``inverse`` and, where the + map is not measure-preserving, ``log_det``. + """ + + parameters: list[str] + conditioning: list[str] + + @abstractmethod + def forward( + self, given: dict[str, torch.Tensor], context: "SamplerContext" + ) -> dict[str, torch.Tensor]: + """Map the conditioning block to the ``parameters`` block.""" + + @abstractmethod + def inverse( + self, params: dict[str, torch.Tensor], context: "SamplerContext" + ) -> dict[str, torch.Tensor]: + """Map the ``parameters`` block back to the conditioning block.""" + + def log_det( + self, given: dict[str, torch.Tensor], context: "SamplerContext" + ) -> torch.Tensor: + """``log|det J|`` of ``forward``, per row. Default 0 (measure-preserving).""" + n = next(iter(given.values())).shape[0] + return torch.zeros(n) + + def sample_and_log_prob( + self, num_samples: int, cond: "Conditioning" + ) -> tuple[dict[str, torch.Tensor], torch.Tensor]: + """Apply ``forward`` to the conditioning; contribute ``-log|det J|``. ``num_samples`` + must be 1 (a reparametrization is 1:1).""" + if num_samples != 1: + raise ValueError("A reparametrization is 1:1; use fan_out=1.") + out = self.forward(cond.given, cond.context) + return out, -self.log_det(cond.given, cond.context) + + def log_prob( + self, theta_i: dict[str, torch.Tensor], cond: "Conditioning" + ) -> torch.Tensor: + raise NotImplementedError( + "Reparametrization.log_prob (re-plug through the inverse map) -- later step." + ) class Step(Protocol): @@ -350,18 +419,31 @@ def _run_chain_once( has_density = True for i, stage in enumerate(self.stages): step = stage.step - n = base if i == 0 else stage.fan_out + if i == 0: + n = base # root: draw the base count + elif step.conditioning: + n = stage.fan_out # fan_out samples per conditioning row + else: + # Unconditioned non-root step (e.g. a fixed/delta factor): draw one value + # per current row -- it fills the batch rather than fanning out. + n = len(next(iter(samples.values()))) cond = Conditioning(context, {k: samples[k] for k in step.conditioning}) block, lp = step.sample_and_log_prob(n, cond) - # The step returned (rows_so_far * n) rows. Expand the carried columns to match - # -- each upstream row repeated n times (the block is flattened in the same - # row-major order). The root has no carried rows, and fan_out=1 stages are a - # no-op, so 1:1 chains are untouched. - if i > 0 and n > 1: - samples = {k: v.repeat_interleave(n, 0) for k, v in samples.items()} + # A conditioned fan-out (fan_out > 1) expands the batch: repeat each carried row + # to align with the step's fan_out sub-rows (the block is flattened row-major). + # 1:1 stages and unconditioned fillers leave the batch untouched. + if i > 0 and step.conditioning and stage.fan_out > 1: + fan = stage.fan_out + samples = {k: v.repeat_interleave(fan, 0) for k, v in samples.items()} if torch.is_tensor(total): - total = total.repeat_interleave(n, 0) + total = total.repeat_interleave(fan, 0) samples.update(block) + # A reparametrization is an in-place bijection: it replaces the columns it + # consumed with its outputs, so drop the consumed (non-reproduced) inputs. + if isinstance(step, Reparametrization): + for k in step.conditioning: + if k not in step.parameters: + samples.pop(k, None) # A single density-free step (Gibbs) makes the whole chain density-free. if lp is None: has_density = False @@ -455,7 +537,7 @@ def __init__(self, composer: ChainComposer, context: SamplerContext): self.context = context self.samples: Optional[pd.DataFrame] = None - def _post_process(self, samples: dict, inverse: bool = False): + def _post_process(self, samples: dict): """Hook for domain-specific post-processing; no-op by default.""" pass diff --git a/dingo/gw/inference/factors.py b/dingo/gw/inference/factors.py index 36f84f674..575cdc1f9 100644 --- a/dingo/gw/inference/factors.py +++ b/dingo/gw/inference/factors.py @@ -14,10 +14,9 @@ from __future__ import annotations import copy -from typing import Optional, Union +from typing import Optional import numpy as np -import pandas as pd import torch from astropy.time import Time from bilby.core.prior import DeltaFunction, PriorDict @@ -31,6 +30,7 @@ Factor, FlowFactor, GibbsBlock, + Reparametrization, _base_model_metadata, ) from dingo.core.posterior_models import BasePosteriorModel @@ -144,12 +144,15 @@ def likelihood(self): ) -# --- Stubs for later steps ------------------------------------------------------------- - - class FixedFactor(Factor): - """``q_i = delta(theta_i - c)``: pins parameters to fixed values (prior-conditioning - / known proxies). Subsumes ``FixedInitSampler``. TODO: implement.""" + """``q_i = delta(theta_i - c)``: pins parameters to fixed values, contributing 0 to the + proposal log-prob. + + Serves two roles in a chain: the **root**, for prior-conditioning / known proxies + (single-step GNPE, where later factors condition on the pinned values -- subsumes + ``FixedInitSampler``), and a non-root **filler** for delta-prior parameters the network + does not infer (one constant per current row). ``log_prob`` (re-plug) is a later step. + """ def __init__(self, values: dict[str, float]): self.values = values @@ -166,6 +169,9 @@ def log_prob(self, theta_i, cond): raise NotImplementedError("FixedFactor.log_prob -- later step.") +# --- Stubs for later steps ------------------------------------------------------------- + + class SyntheticPhaseFactor(Factor): """``q(phase | theta_rest, d)`` from the likelihood on a phase grid; the final, non-NN factor, run in the CPU post-processing stage. TODO: port from @@ -336,22 +342,31 @@ def __init__( transform_post: Compose, gnpe_parameters: list[str], parameters: list[str], + aliases: Optional[dict[str, str]] = None, ): self.model = model self.transform_pre = transform_pre self.transform_post = transform_post self.gnpe_parameters = gnpe_parameters self.proxy_parameters = [p + "_proxy" for p in gnpe_parameters] - self.parameters = parameters + self.aliases = aliases or {} + self._net_parameters = parameters + self.parameters = [self.aliases.get(p, p) for p in parameters] self.conditioning = list(self.proxy_parameters) @classmethod - def from_model(cls, model: BasePosteriorModel) -> "GNPEFlowFactor": - """Build the GNPE per-iteration transforms from the main model's metadata.""" + def from_model( + cls, model: BasePosteriorModel, aliases: Optional[dict[str, str]] = None + ) -> "GNPEFlowFactor": + """Build the GNPE per-iteration transforms from the main model's metadata. + ``aliases`` maps trained names to canonical names (e.g. ``{"ra": "ra@t_ref"}``). + """ pre, post, gnpe_parameters, inference_parameters, _, _ = _build_gnpe_transforms( model ) - return cls(model, pre, post, gnpe_parameters, inference_parameters) + return cls( + model, pre, post, gnpe_parameters, inference_parameters, aliases=aliases + ) def sample_and_log_prob(self, num_samples, cond): """Sample one parameter set per proxy row; ``num_samples`` must be 1 (GNPE is 1:1). @@ -378,6 +393,8 @@ def sample_and_log_prob(self, num_samples, cond): x["log_prob"] = log_prob.squeeze(1) x = self.transform_post(x) params = dict(x["parameters"]) + # Expose trained names under their canonical aliases (e.g. ra -> ra@t_ref). + params = {self.aliases.get(k, k): v for k, v in params.items()} # Surface the recomputed detector times: the next Gibbs iteration's input, and the # evaluation point for GNPEKernelFactor's importance-sampling correction. for k in self.gnpe_parameters: @@ -390,6 +407,86 @@ def log_prob(self, theta_i, cond): ) +class RAReparam(Reparametrization): + """ + Rotate right ascension from the network's training reference frame (``ra@t_ref``) to the + event frame (``ra``). + + The network is trained at a fixed reference time; an event at a different GPS time needs + the sky rotated by the sidereal-time difference. This is a measure-preserving shift + modulo 2*pi (``log_det = 0``), so it contributes nothing to the density. Ported from + ``GWSamplerMixin._correct_reference_time``: ``forward`` produces the event-frame ``ra`` + for downstream, ``inverse`` recovers ``ra@t_ref`` for re-plug / importance sampling + (design Q#7). The sidereal correction is read from the shared context (``t_ref`` and the + event time). + """ + + def __init__(self): + self.conditioning = ["ra@t_ref"] + self.parameters = ["ra"] + + @staticmethod + def _correction(context) -> float: + """Sidereal-time difference (event minus reference) in radians; 0 when the event + time is unset or equal to the reference time.""" + event_metadata = context.event_metadata + t_event = None if event_metadata is None else event_metadata.get("time_event") + t_ref = context.t_ref + if t_event is None or t_event == t_ref: + return 0.0 + longitude_event = Time(t_event, format="gps", scale="utc").sidereal_time( + "apparent", "greenwich" + ) + longitude_reference = Time(t_ref, format="gps", scale="utc").sidereal_time( + "apparent", "greenwich" + ) + return (longitude_event - longitude_reference).rad + + def forward(self, given, context): + correction = self._correction(context) + if correction == 0.0: + return {"ra": given["ra@t_ref"]} + # ra is a bounded angle -> float32 is plenty. The correction is a difference of + # absolute GPS times, so compute it in float64, but store the wrapped angle float32. + ra = (given["ra@t_ref"].double() + correction) % (2 * np.pi) + return {"ra": ra.float()} + + def inverse(self, params, context): + correction = self._correction(context) + if correction == 0.0: + return {"ra@t_ref": params["ra"]} + ra_tref = (params["ra"].double() - correction) % (2 * np.pi) + return {"ra@t_ref": ra_tref.float()} + + +def _ra_aliases(inference_parameters: list[str]) -> dict[str, str]: + """The RA frame alias (``ra`` -> ``ra@t_ref``), applied only when the model infers + ``ra``; paired with an ``RAReparam`` step that maps it back to the event frame.""" + return {"ra": "ra@t_ref"} if "ra" in inference_parameters else {} + + +def _ra_reparam_steps(inference_parameters: list[str]) -> list: + """The ``RAReparam`` step, appended to a chain only when the model infers ``ra``.""" + return [RAReparam()] if "ra" in inference_parameters else [] + + +def _fixed_prior_steps(metadata: dict, inference_parameters: list[str]) -> list: + """Delta-prior parameters the chain does not produce, as a single ``FixedFactor`` step + (or none). These are pinned constants (e.g. an aligned-spin component fixed to 0); the + old sampler injected them in ``_post_process``.""" + intrinsic_prior = metadata["dataset_settings"]["intrinsic_prior"] + extrinsic_prior = get_extrinsic_prior_dict( + metadata["train_settings"]["data"]["extrinsic_prior"] + ) + prior = build_prior_with_defaults({**intrinsic_prior, **extrinsic_prior}) + fixed = { + k: p.peak + for k, p in prior.items() + if isinstance(p, DeltaFunction) and k not in inference_parameters + } + return [FixedFactor(fixed)] if fixed else [] + + class GWComposedSampler(ComposedSampler): """ GW façade: a ``ComposedSampler`` that adds the gravitational-wave post-processing -- @@ -422,14 +519,24 @@ def from_model( raw_context: dict, event_metadata: Optional[dict] = None, ) -> "GWComposedSampler": - """Build a single-factor (plain-NPE) GW sampler from a model and event data.""" + """Build a plain-NPE GW sampler from a model and event data: the flow exposes + ``ra`` as ``ra@t_ref``, followed by an ``RAReparam`` to the event frame.""" context = GWSamplerContext.from_model(model, raw_context, event_metadata) - factor = FlowFactor.from_model(model) + metadata = _base_model_metadata(model) + inference_parameters = metadata["train_settings"]["data"][ + "inference_parameters" + ] + factor = FlowFactor.from_model(model, aliases=_ra_aliases(inference_parameters)) + steps = ( + [factor] + + _ra_reparam_steps(inference_parameters) + + _fixed_prior_steps(metadata, inference_parameters) + ) return cls( - composer=ChainComposer([factor]), + composer=ChainComposer(steps), context=context, - metadata=_base_model_metadata(model), - inference_parameters=factor.parameters, + metadata=metadata, + inference_parameters=inference_parameters, ) @classmethod @@ -444,18 +551,29 @@ def from_gnpe_models( """Build a multi-iteration time-GNPE sampler from an init + main model pair: the init model's data preprocessing, an init ``FlowFactor`` to seed, and a single ``GibbsBlock`` step -- cycling the GNPE kernel and main-network factors -- in a - ``ChainComposer``. Returns samples without a log_prob (Gibbs breaks density - access).""" + ``ChainComposer``, then an ``RAReparam`` to the event frame. Returns samples without + a log_prob (Gibbs breaks density access).""" context = GWSamplerContext.from_model(init_model, raw_context, event_metadata) + metadata = _base_model_metadata(main_model) + inference_parameters = metadata["train_settings"]["data"][ + "inference_parameters" + ] init_factor = FlowFactor.from_model(init_model) kernel_factor = GNPEKernelFactor.from_model(main_model) - flow_factor = GNPEFlowFactor.from_model(main_model) + flow_factor = GNPEFlowFactor.from_model( + main_model, aliases=_ra_aliases(inference_parameters) + ) gibbs = GibbsBlock(init_factor, [kernel_factor, flow_factor], num_iterations) + steps = ( + [gibbs] + + _ra_reparam_steps(inference_parameters) + + _fixed_prior_steps(metadata, inference_parameters) + ) return cls( - ChainComposer([gibbs]), + ChainComposer(steps), context, - _base_model_metadata(main_model), - flow_factor.parameters, + metadata, + inference_parameters, ) @classmethod @@ -467,49 +585,35 @@ def from_singlestep_gnpe( event_metadata: Optional[dict] = None, ) -> "GWComposedSampler": """Build a single-step (density-preserving) time-GNPE sampler: a ``ChainComposer`` - of ``[proxy_source, GNPEFlowFactor]``. ``proxy_source`` supplies the detector-time - proxies -- a ``FixedFactor`` for prior conditioning (BNS), or an unconditional NDE - for density recovery. Unlike multi-iteration GNPE the chain is autoregressive, so - log_prob is preserved; the ``GNPEKernelFactor`` supplies the + of ``[proxy_source, GNPEFlowFactor, RAReparam]``. ``proxy_source`` supplies the + detector-time proxies -- a ``FixedFactor`` for prior conditioning (BNS), or an + unconditional NDE for density recovery. Unlike multi-iteration GNPE the chain is + autoregressive, so log_prob is preserved; the ``GNPEKernelFactor`` supplies the ``delta_log_prob_target`` correction that importance sampling adds to the target. """ context = GWSamplerContext.from_model(main_model, raw_context, event_metadata) - flow_factor = GNPEFlowFactor.from_model(main_model) + metadata = _base_model_metadata(main_model) + inference_parameters = metadata["train_settings"]["data"][ + "inference_parameters" + ] + flow_factor = GNPEFlowFactor.from_model( + main_model, aliases=_ra_aliases(inference_parameters) + ) kernel_factor = GNPEKernelFactor.from_model(main_model) - composer = ChainComposer([proxy_source, flow_factor]) + steps = ( + [proxy_source, flow_factor] + + _ra_reparam_steps(inference_parameters) + + _fixed_prior_steps(metadata, inference_parameters) + ) + composer = ChainComposer(steps) return cls( composer, context, - _base_model_metadata(main_model), - flow_factor.parameters, + metadata, + inference_parameters, kernel_factor=kernel_factor, ) - def _correct_reference_time( - self, samples: Union[dict, pd.DataFrame], inverse: bool = False - ): - """Correct the right ascension for the difference between the event time and the - model's training reference time (fixed detector positions).""" - event_metadata = self.context.event_metadata - if event_metadata is None: - return - t_event = event_metadata.get("time_event") - t_ref = self.context.t_ref - if t_event is None or t_event == t_ref or "ra" not in samples: - return - ra = samples["ra"] - longitude_event = Time(t_event, format="gps", scale="utc").sidereal_time( - "apparent", "greenwich" - ) - longitude_reference = Time(t_ref, format="gps", scale="utc").sidereal_time( - "apparent", "greenwich" - ) - ra_correction = (longitude_event - longitude_reference).rad - if not inverse: - samples["ra"] = (ra + ra_correction) % (2 * np.pi) - else: - samples["ra"] = (ra - ra_correction) % (2 * np.pi) - def _add_kernel_correction(self, samples: dict): """Single-step GNPE: add ``delta_log_prob_target = log p(theta_hat | theta)``, evaluated at the proxies and the detector times recomputed from theta, then drop @@ -523,33 +627,13 @@ def _add_kernel_correction(self, samples: dict): for k in kf.gnpe_parameters: samples.pop(k, None) - def _post_process(self, samples: Union[dict, pd.DataFrame], inverse: bool = False): - if self.kernel_factor is not None and not inverse: + def _post_process(self, samples: dict): + # Fixed (delta-prior) parameters are a FixedFactor in the chain and the RA frame + # rotation is a chain reparametrization; only the single-step-GNPE kernel correction + # remains here (moves to a chain step next). + if self.kernel_factor is not None: self._add_kernel_correction(samples) - intrinsic_prior = self.metadata["dataset_settings"]["intrinsic_prior"] - extrinsic_prior = get_extrinsic_prior_dict( - self.metadata["train_settings"]["data"]["extrinsic_prior"] - ) - prior = build_prior_with_defaults({**intrinsic_prior, **extrinsic_prior}) - - if not inverse: - # Add fixed (delta-prior) parameters not produced by the chain. - num_samples = len(samples[list(samples.keys())[0]]) - for k, p in prior.items(): - if isinstance(p, DeltaFunction) and k not in samples: - print(f"Adding fixed parameter {k} = {p.peak} from prior.") - samples[k] = p.peak * np.ones(num_samples) - else: - drop = [k for k in samples.keys() if k not in self.inference_parameters] - if isinstance(samples, pd.DataFrame): - samples.drop(columns=drop, inplace=True, errors="ignore") - else: - for k in drop: - samples.pop(k, None) - - self._correct_reference_time(samples, inverse) - def to_result(self): """Export to a gw ``Result`` (samples + raw event data + metadata), so the existing post-processing pipeline -- synthetic phase, importance sampling, diff --git a/tests/core/test_factors.py b/tests/core/test_factors.py index 8042bbbf2..7e01cfee0 100644 --- a/tests/core/test_factors.py +++ b/tests/core/test_factors.py @@ -16,6 +16,7 @@ Conditioning, Factor, GibbsBlock, + Reparametrization, Stage, chunk_and_concat, ) @@ -203,3 +204,105 @@ def test_gibbs_block_runs_as_a_density_free_step(): assert "proxy" not in out # seed-only, dropped # theta | proxy with Gibbs' one-per-walker draw: theta == proxy == arange(6). assert torch.equal(out["theta"], torch.arange(6, dtype=torch.float32)) + + +class _MockReparam(Reparametrization): + """A mock bijection ``u -> v = u + shift`` with a constant nonzero ``log|det J|`` so the + density contribution (``-log_det``) is checkable.""" + + def __init__(self, shift=10.0, log_det_val=0.5): + self.conditioning = ["u"] + self.parameters = ["v"] + self._shift = shift + self._ld = log_det_val + + def forward(self, given, context): + return {"v": given["u"] + self._shift} + + def inverse(self, params, context): + return {"u": params["v"] - self._shift} + + def log_det(self, given, context): + n = next(iter(given.values())).shape[0] + return torch.full((n,), self._ld) + + +def test_reparam_step_consumes_input_and_contributes_neg_logdet(): + # A reparam is an in-place bijection: it replaces its input column and contributes + # -log_det to the chain density. + comp = ChainComposer([_ConstFactor("u"), _MockReparam(shift=10.0, log_det_val=0.5)]) + out = comp.sample(4, context=None) + assert "u" not in out # consumed + assert torch.equal(out["v"], torch.arange(4, dtype=torch.float32) + 10.0) + # chain log_prob = factor(+0.5) + reparam(-0.5) = 0. + assert torch.allclose(out["log_prob"], torch.zeros(4)) + + +def test_reparam_rejects_fan_out(): + rp = _MockReparam() + with pytest.raises(ValueError, match="1:1"): + rp.sample_and_log_prob( + 2, Conditioning(context=None, given={"u": torch.zeros(3)}) + ) + + +def test_reparam_forward_inverse_round_trip(): + rp = _MockReparam(shift=10.0) + u = torch.arange(5, dtype=torch.float32) + v = rp.forward({"u": u}, context=None)["v"] + u2 = rp.inverse({"v": v}, context=None)["u"] + assert torch.equal(u, u2) + + +class _ConstFillFactor(Factor): + """An unconditioned factor emitting a constant (like a ``FixedFactor``). As the root it + draws the base count; as a non-root step it fills one value per current row.""" + + def __init__(self, name, value=3.0): + self.parameters = [name] + self.conditioning = [] + self._name, self._v = name, value + + def sample_and_log_prob(self, n, cond): + return {self._name: torch.full((n,), self._v)}, torch.zeros(n) + + def log_prob(self, theta_i, cond): + return torch.zeros(next(iter(theta_i.values())).shape[0]) + + +def test_unconditioned_filler_fills_the_current_batch(): + # A non-root unconditioned factor (fixed/delta filler) emits one value per current row, + # not a single row, and contributes 0 to the density. + comp = ChainComposer([_ConstFactor("a"), _ConstFillFactor("c", 3.0)]) + out = comp.sample(5, context=None) + assert out["a"].shape == (5,) and out["c"].shape == (5,) + assert torch.equal(out["c"], torch.full((5,), 3.0)) + assert torch.allclose( + out["log_prob"], torch.full((5,), 0.5) + ) # factor(0.5) + fill(0) + + +def test_unconditioned_filler_fills_after_fan_out(): + # The filler matches the expanded batch, not the base count. + comp = ChainComposer( + [ + _ConstFactor("a"), + Stage(_ConstFactor("b", conditioning=["a"]), fan_out=3), + _ConstFillFactor("c", 7.0), + ] + ) + out = comp.sample(4, context=None) # 4 * 3 = 12 rows + assert out["c"].shape == (12,) + assert torch.equal(out["c"], torch.full((12,), 7.0)) + + +def test_unconditioned_factor_as_root_draws_base_count(): + # As the chain root (prior-conditioning / FixedFactor proxy), an unconditioned factor + # draws the base count; a downstream factor conditions on the pinned values. + comp = ChainComposer( + [_ConstFillFactor("c", 2.0), _ConstFactor("d", conditioning=["c"])] + ) + out = comp.sample(6, context=None) + assert out["c"].shape == (6,) and out["d"].shape == (6,) + assert torch.equal(out["c"], torch.full((6,), 2.0)) + assert torch.equal(out["d"], torch.full((6,), 2.0)) # d | c: sum(c) + 0 == c diff --git a/tests/gw/test_ra_reparam.py b/tests/gw/test_ra_reparam.py new file mode 100644 index 000000000..83b4c8b70 --- /dev/null +++ b/tests/gw/test_ra_reparam.py @@ -0,0 +1,44 @@ +"""RAReparam: the right-ascension frame reparametrization (network training frame +``ra@t_ref`` <-> event frame ``ra``). Covers the forward/inverse round trip -- the inverse +direction the model-based parity harnesses do not exercise -- and the no-op case (event +time equal to, or absent from, the reference time).""" + +import types + +import numpy as np +import torch + +from dingo.gw.inference.factors import RAReparam + + +def _context(t_ref, t_event): + """A minimal stand-in for the SamplerContext fields RAReparam reads.""" + event_metadata = None if t_event is None else {"time_event": t_event} + return types.SimpleNamespace(t_ref=t_ref, event_metadata=event_metadata) + + +def test_ra_reparam_round_trip(): + rp = RAReparam() + ctx = _context(t_ref=1126259462.4, t_event=1264316116.4) # differ -> real rotation + ra_tref = torch.rand(1000, dtype=torch.float32) * (2 * np.pi) + + ra = rp.forward({"ra@t_ref": ra_tref}, ctx)["ra"] + back = rp.inverse({"ra": ra}, ctx)["ra@t_ref"] + + assert ra.dtype == torch.float32 # a bounded angle; float32 is sufficient + assert not torch.allclose(ra, ra_tref) # the sidereal rotation actually moved it + assert torch.allclose(back, ra_tref, atol=1e-6) # forward then inverse recovers it + + +def test_ra_reparam_is_noop_without_a_distinct_event_time(): + rp = RAReparam() + ra_tref = torch.rand(100, dtype=torch.float32) * (2 * np.pi) + for ctx in (_context(1126259462.4, 1126259462.4), _context(1126259462.4, None)): + out = rp.forward({"ra@t_ref": ra_tref}, ctx)["ra"] + assert torch.equal(out, ra_tref) # untouched: no cast, no modulo + + +def test_ra_reparam_declared_names(): + rp = RAReparam() + assert rp.conditioning == ["ra@t_ref"] + assert rp.parameters == ["ra"] From b0bc6b5f7484ad635699dffb41e9942b47c85ad3 Mon Sep 17 00:00:00 2001 From: Stephen Green Date: Wed, 1 Jul 2026 14:56:41 +0200 Subject: [PATCH 15/65] Move the GNPE kernel correction into a chain step; remove _post_process - Add TargetCorrection, a core kind-3 Step: it emits an importance-sampling target-side column and contributes 0 to the proposal density (1:1). GNPEKernelCorrection emits delta_log_prob_target = log p(theta_hat | theta) and consumes the intermediate detector times. - Generalize the composer's consume to a `consumes` attribute (used by both reparametrizations and target corrections), and add `produces` so a step's side-channel columns (e.g. GNPEFlowFactor's recomputed detector times) satisfy the topological check. - from_singlestep_gnpe adds GNPEKernelCorrection to the chain; drop the kernel_factor __init__ argument and _add_kernel_correction. - GWComposedSampler no longer overrides _post_process (all GW-specific processing is now chain steps); the base ComposedSampler hook stays as a no-op. - Tighten docstrings and NotImplementedError messages. Parity: NPE, multi-iteration GNPE, single-step, and packaged all bit-exact; tests/core/test_factors.py + tests/gw/test_ra_reparam.py green (24 tests). Co-Authored-By: Claude Opus 4.8 (1M context) --- dingo/core/factors.py | 59 +++++++++--- dingo/gw/inference/factors.py | 171 +++++++++++++++------------------- tests/core/test_factors.py | 67 +++++++++++++ 3 files changed, 190 insertions(+), 107 deletions(-) diff --git a/dingo/core/factors.py b/dingo/core/factors.py index 77e788db7..5443352bc 100644 --- a/dingo/core/factors.py +++ b/dingo/core/factors.py @@ -318,12 +318,50 @@ def sample_and_log_prob( out = self.forward(cond.given, cond.context) return out, -self.log_det(cond.given, cond.context) + @property + def consumes(self) -> list[str]: + """Conditioning the bijection replaces with its outputs (dropped after the step).""" + return [c for c in self.conditioning if c not in self.parameters] + def log_prob( self, theta_i: dict[str, torch.Tensor], cond: "Conditioning" ) -> torch.Tensor: - raise NotImplementedError( - "Reparametrization.log_prob (re-plug through the inverse map) -- later step." - ) + raise NotImplementedError("Reparametrization.log_prob") + + +class TargetCorrection(ABC): + """ + A ``Step`` that emits an importance-sampling target correction as a side-channel column + and contributes nothing to the proposal density. + + Its value belongs to the IS target, not the proposal: it reads earlier blocks, emits a + named column, optionally consumes intermediates (``consumes``), and adds 0 to the + proposal log-prob. 1:1. + """ + + parameters: list[str] + conditioning: list[str] + consumes: list[str] + + @abstractmethod + def correction( + self, given: dict[str, torch.Tensor], context: "SamplerContext" + ) -> dict[str, torch.Tensor]: + """The side-channel column(s), one value per conditioning row.""" + + def sample_and_log_prob( + self, num_samples: int, cond: "Conditioning" + ) -> tuple[dict[str, torch.Tensor], torch.Tensor]: + if num_samples != 1: + raise ValueError("A target correction is 1:1; use fan_out=1.") + out = self.correction(cond.given, cond.context) + n = next(iter(out.values())).shape[0] + return out, torch.zeros(n) + + def log_prob( + self, theta_i: dict[str, torch.Tensor], cond: "Conditioning" + ) -> torch.Tensor: + raise NotImplementedError("TargetCorrection.log_prob") class Step(Protocol): @@ -374,7 +412,8 @@ def __init__(self, stages: list[Union["Stage", Step]]): def _validate(self): """Check the declared order is a valid topological order: every conditioning - name is produced by an earlier step.""" + name is produced by an earlier step. A step's emitted columns default to its + ``parameters``, but a step may emit side-channel columns too (``produces``).""" produced: set[str] = set() for step in self.steps: missing = [c for c in step.conditioning if c not in produced] @@ -383,7 +422,7 @@ def _validate(self): f"A step producing {step.parameters} conditions on {missing}, " f"which no earlier step produces. Check chain order." ) - produced.update(step.parameters) + produced.update(getattr(step, "produces", step.parameters)) @property def steps(self) -> list[Step]: @@ -438,12 +477,10 @@ def _run_chain_once( if torch.is_tensor(total): total = total.repeat_interleave(fan, 0) samples.update(block) - # A reparametrization is an in-place bijection: it replaces the columns it - # consumed with its outputs, so drop the consumed (non-reproduced) inputs. - if isinstance(step, Reparametrization): - for k in step.conditioning: - if k not in step.parameters: - samples.pop(k, None) + # Drop any intermediates the step consumed: a reparametrization replaces its + # inputs (in-place bijection); a target correction drops the columns it read. + for k in getattr(step, "consumes", ()): + samples.pop(k, None) # A single density-free step (Gibbs) makes the whole chain density-free. if lp is None: has_density = False diff --git a/dingo/gw/inference/factors.py b/dingo/gw/inference/factors.py index 575cdc1f9..ef32b5450 100644 --- a/dingo/gw/inference/factors.py +++ b/dingo/gw/inference/factors.py @@ -1,14 +1,12 @@ """ -Gravitational-wave factors for the factorized sampler. - -GW-specific pieces of the factorized-sampler design (see -vault/Hackathon/Factorized_Sampler_Design.md). The generic ``FlowFactor`` is -domain-agnostic and lives in ``dingo.core.factors``; what is GW-specific is the -``GWSamplerContext`` (which builds the data-preprocessing conditioning map ``f_i``) and -the post-processing in ``GWComposedSampler``. Covers plain NPE (``FlowFactor`` via a -``ChainComposer``) and multi-iteration time-shift GNPE (``GNPEKernelFactor`` + -``GNPEFlowFactor`` cycled by a ``GibbsBlock``); the synthetic-phase factor and -single-step GNPE importance sampling remain for later steps. +Gravitational-wave factors, steps, and samplers for the factorized sampler. + +The domain-agnostic pieces (``Factor``, ``FlowFactor``, ``Reparametrization``, +``TargetCorrection``, and the composers) live in ``dingo.core.factors``. This module adds +the GW-specific ones: ``GWSamplerContext`` (the data-preprocessing conditioning map), the +GNPE factors and steps (``GNPEKernelFactor``, ``GNPEFlowFactor``, ``GNPEKernelCorrection``), +the ``RAReparam`` sky-frame reparametrization, and ``GWComposedSampler``, which assembles +the chain for plain NPE, multi-iteration GNPE, or single-step GNPE. """ from __future__ import annotations @@ -31,6 +29,7 @@ FlowFactor, GibbsBlock, Reparametrization, + TargetCorrection, _base_model_metadata, ) from dingo.core.posterior_models import BasePosteriorModel @@ -138,20 +137,16 @@ def prepared_data(self) -> torch.Tensor: return self._prepared def likelihood(self): - raise NotImplementedError( - "Likelihood construction moves onto the context in a later step " - "(synthetic-phase / importance-sampling factors)." - ) + raise NotImplementedError("GWSamplerContext.likelihood") class FixedFactor(Factor): """``q_i = delta(theta_i - c)``: pins parameters to fixed values, contributing 0 to the proposal log-prob. - Serves two roles in a chain: the **root**, for prior-conditioning / known proxies - (single-step GNPE, where later factors condition on the pinned values -- subsumes - ``FixedInitSampler``), and a non-root **filler** for delta-prior parameters the network - does not infer (one constant per current row). ``log_prob`` (re-plug) is a later step. + Used as the chain root for prior-conditioning or known proxies (single-step GNPE, where + later factors condition on the pinned values), and as a non-root filler for delta-prior + parameters the network does not infer (one constant per current row). """ def __init__(self, values: dict[str, float]): @@ -166,26 +161,25 @@ def sample_and_log_prob(self, num_samples, cond): return samples, torch.zeros(num_samples) def log_prob(self, theta_i, cond): - raise NotImplementedError("FixedFactor.log_prob -- later step.") + raise NotImplementedError("FixedFactor.log_prob") # --- Stubs for later steps ------------------------------------------------------------- class SyntheticPhaseFactor(Factor): - """``q(phase | theta_rest, d)`` from the likelihood on a phase grid; the final, - non-NN factor, run in the CPU post-processing stage. TODO: port from - ``Result.sample_synthetic_phase``.""" + """``q(phase | theta_rest, d)`` from the likelihood on a phase grid (a non-network + factor, the final step of the chain). Not yet implemented.""" def __init__(self): self.parameters = ["phase"] self.conditioning = [] # conditions on all preceding params via the likelihood def sample_and_log_prob(self, num_samples, cond): - raise NotImplementedError("SyntheticPhaseFactor -- later step.") + raise NotImplementedError("SyntheticPhaseFactor") def log_prob(self, theta_i, cond): - raise NotImplementedError("SyntheticPhaseFactor -- later step.") + raise NotImplementedError("SyntheticPhaseFactor") def _build_gnpe_transforms(model: BasePosteriorModel): @@ -267,21 +261,14 @@ def _build_gnpe_transforms(model: BasePosteriorModel): class GNPEKernelFactor(Factor): """ - The GNPE perturbation kernel p(theta_hat | theta) as a factor (non-NN). - - GNPE (arXiv:2111.13139) conditions the main network on "proxy" parameters theta_hat - drawn from a fixed kernel p(theta_hat | theta). Here theta are the detector coalescence - times and the kernel adds a bounded perturbation to each. Conditioning on the proxies - frees the network input to be simplified by them -- ``GNPEFlowFactor`` shifts each - detector's strain to its proxy time -- at the price of inferring theta and theta_hat - jointly. + The GNPE perturbation kernel ``p(theta_hat | theta)`` as a non-network factor. - The parameter block is the proxies; the conditioning is the detector times. + ``theta`` are the detector coalescence times; the kernel adds a bounded perturbation to + each, giving the proxies ``theta_hat`` the main network conditions on. The parameter + block is the proxies, the conditioning is the detector times. ``sample_and_log_prob`` blurs the times into proxies (the proxy update of a Gibbs - sweep). ``log_prob`` returns the kernel density log p(theta_hat | theta); for - single-step GNPE this is the ``delta_log_prob_target`` importance-sampling correction, - evaluated at the proxies and the detector times recomputed from theta. One proxy per - detector-time row. + sweep); ``log_prob`` returns the kernel density ``log p(theta_hat | theta)`` at the + proxies and the detector times. One proxy per detector-time row. """ def __init__(self, gnpe_transform: GNPEBase, gnpe_parameters: list[str]): @@ -320,19 +307,18 @@ def log_prob(self, theta_i, cond): class GNPEFlowFactor(Factor): """ - The GNPE main network q(theta | theta_hat, d) as a factor. + The GNPE main network ``q(theta | theta_hat, d)`` as a factor. Conditions on the detector-time proxies from ``GNPEKernelFactor``: it shifts each detector's strain by the corresponding proxy time (standardizing the network input), samples the network, and recomputes the detector times from the sampled sky position - and geocent time. The proxies are supplied, so no blurring happens here. Wraps the main - model and the per-iteration transforms built for ``GWSamplerGNPE``. + and geocent time. The proxies are supplied, so no blurring happens here. The single network factor in either GNPE mode: cycled by a ``GibbsBlock`` for multi-iteration GNPE, or a ``ChainComposer`` factor for single-step GNPE. The recomputed - detector times are returned as extra columns: the next Gibbs iteration blurs them into - fresh proxies, and single-step GNPE evaluates the kernel correction at them. One sample - per proxy row. + detector times are emitted as extra columns (``produces``): the next Gibbs iteration + blurs them into fresh proxies, and single-step GNPE evaluates the kernel correction at + them. One sample per proxy row. """ def __init__( @@ -354,6 +340,11 @@ def __init__( self.parameters = [self.aliases.get(p, p) for p in parameters] self.conditioning = list(self.proxy_parameters) + @property + def produces(self) -> list[str]: + """Emitted columns: the inference block plus the recomputed detector times.""" + return self.parameters + self.gnpe_parameters + @classmethod def from_model( cls, model: BasePosteriorModel, aliases: Optional[dict[str, str]] = None @@ -402,9 +393,7 @@ def sample_and_log_prob(self, num_samples, cond): return params, x["log_prob"] def log_prob(self, theta_i, cond): - raise NotImplementedError( - "GNPEFlowFactor.log_prob -- later step (single-step GNPE importance sampling)." - ) + raise NotImplementedError("GNPEFlowFactor.log_prob") class RAReparam(Reparametrization): @@ -414,11 +403,9 @@ class RAReparam(Reparametrization): The network is trained at a fixed reference time; an event at a different GPS time needs the sky rotated by the sidereal-time difference. This is a measure-preserving shift - modulo 2*pi (``log_det = 0``), so it contributes nothing to the density. Ported from - ``GWSamplerMixin._correct_reference_time``: ``forward`` produces the event-frame ``ra`` - for downstream, ``inverse`` recovers ``ra@t_ref`` for re-plug / importance sampling - (design Q#7). The sidereal correction is read from the shared context (``t_ref`` and the - event time). + modulo 2*pi (``log_det = 0``), so it contributes nothing to the density. ``forward`` + produces the event-frame ``ra``, ``inverse`` recovers ``ra@t_ref``. The sidereal + correction is read from the shared context (``t_ref`` and the event time). """ def __init__(self): @@ -459,6 +446,31 @@ def inverse(self, params, context): return {"ra@t_ref": ra_tref.float()} +class GNPEKernelCorrection(TargetCorrection): + """ + The single-step GNPE kernel correction as a target-side chain step. + + Emits ``delta_log_prob_target = log p(theta_hat | theta)`` -- the GNPE kernel evaluated + at the proxies and the detector times recomputed from theta -- for importance sampling + on the joint proposal ``q(theta, theta_hat | d)``. Contributes 0 to the proposal + density and consumes the intermediate detector times. + """ + + def __init__(self, kernel_factor: GNPEKernelFactor): + self.kernel_factor = kernel_factor + self.parameters = ["delta_log_prob_target"] + self.conditioning = list(kernel_factor.parameters) + list( + kernel_factor.gnpe_parameters + ) + self.consumes = list(kernel_factor.gnpe_parameters) + + def correction(self, given, context): + proxies = {p: given[p] for p in self.kernel_factor.parameters} + times = {k: given[k] for k in self.kernel_factor.gnpe_parameters} + correction = self.kernel_factor.log_prob(proxies, Conditioning(context, times)) + return {"delta_log_prob_target": correction} + + def _ra_aliases(inference_parameters: list[str]) -> dict[str, str]: """The RA frame alias (``ra`` -> ``ra@t_ref``), applied only when the model infers ``ra``; paired with an ``RAReparam`` step that maps it back to the event frame.""" @@ -472,8 +484,8 @@ def _ra_reparam_steps(inference_parameters: list[str]) -> list: def _fixed_prior_steps(metadata: dict, inference_parameters: list[str]) -> list: """Delta-prior parameters the chain does not produce, as a single ``FixedFactor`` step - (or none). These are pinned constants (e.g. an aligned-spin component fixed to 0); the - old sampler injected them in ``_post_process``.""" + (or none). These are pinned constants (e.g. an aligned-spin component fixed to 0). + """ intrinsic_prior = metadata["dataset_settings"]["intrinsic_prior"] extrinsic_prior = get_extrinsic_prior_dict( metadata["train_settings"]["data"]["extrinsic_prior"] @@ -489,11 +501,10 @@ def _fixed_prior_steps(metadata: dict, inference_parameters: list[str]) -> list: class GWComposedSampler(ComposedSampler): """ - GW façade: a ``ComposedSampler`` that adds the gravitational-wave post-processing -- - injecting fixed (delta-prior) parameters and correcting the sky position for the - model's fixed reference time. Ported from ``GWSamplerMixin._post_process``; in the - longer run the reference-time correction becomes a deterministic reparametrization - factor in the chain (see the design doc, Q#4/Q#7). + GW façade over a ``ChainComposer``. Builds the chain for plain NPE, multi-iteration + GNPE, or single-step GNPE from model metadata, and exports the samples to a gw + ``Result``. All GW-specific processing (RA frame, fixed parameters, kernel correction) + is expressed as chain steps, so there is no post-processing step. """ def __init__( @@ -502,15 +513,10 @@ def __init__( context: GWSamplerContext, metadata: dict, inference_parameters: list[str], - kernel_factor: Optional[GNPEKernelFactor] = None, ): super().__init__(composer, context) self.metadata = metadata self.inference_parameters = inference_parameters - # Set for single-step GNPE. Supplies the delta_log_prob_target kernel correction - # (evaluated in post-processing, out of the proposal sum) that importance sampling - # applies to the joint target q(theta, theta_hat | d). - self.kernel_factor = kernel_factor @classmethod def from_model( @@ -585,10 +591,10 @@ def from_singlestep_gnpe( event_metadata: Optional[dict] = None, ) -> "GWComposedSampler": """Build a single-step (density-preserving) time-GNPE sampler: a ``ChainComposer`` - of ``[proxy_source, GNPEFlowFactor, RAReparam]``. ``proxy_source`` supplies the - detector-time proxies -- a ``FixedFactor`` for prior conditioning (BNS), or an - unconditional NDE for density recovery. Unlike multi-iteration GNPE the chain is - autoregressive, so log_prob is preserved; the ``GNPEKernelFactor`` supplies the + of ``[proxy_source, GNPEFlowFactor, GNPEKernelCorrection, RAReparam]``. + ``proxy_source`` supplies the detector-time proxies -- a ``FixedFactor`` for prior + conditioning (BNS), or an unconditional NDE for density recovery. The chain is + autoregressive, so log_prob is preserved, and ``GNPEKernelCorrection`` emits the ``delta_log_prob_target`` correction that importance sampling adds to the target. """ context = GWSamplerContext.from_model(main_model, raw_context, event_metadata) @@ -601,38 +607,11 @@ def from_singlestep_gnpe( ) kernel_factor = GNPEKernelFactor.from_model(main_model) steps = ( - [proxy_source, flow_factor] + [proxy_source, flow_factor, GNPEKernelCorrection(kernel_factor)] + _ra_reparam_steps(inference_parameters) + _fixed_prior_steps(metadata, inference_parameters) ) - composer = ChainComposer(steps) - return cls( - composer, - context, - metadata, - inference_parameters, - kernel_factor=kernel_factor, - ) - - def _add_kernel_correction(self, samples: dict): - """Single-step GNPE: add ``delta_log_prob_target = log p(theta_hat | theta)``, - evaluated at the proxies and the detector times recomputed from theta, then drop - those intermediate detector times. This is the joint-target correction importance - sampling applies; it is deliberately kept out of the proposal ``log_prob``.""" - kf = self.kernel_factor - proxies = {p: samples[p] for p in kf.parameters} - times = {k: samples[k] for k in kf.gnpe_parameters} - correction = kf.log_prob(proxies, Conditioning(self.context, times)) - samples["delta_log_prob_target"] = np.asarray(correction) - for k in kf.gnpe_parameters: - samples.pop(k, None) - - def _post_process(self, samples: dict): - # Fixed (delta-prior) parameters are a FixedFactor in the chain and the RA frame - # rotation is a chain reparametrization; only the single-step-GNPE kernel correction - # remains here (moves to a chain step next). - if self.kernel_factor is not None: - self._add_kernel_correction(samples) + return cls(ChainComposer(steps), context, metadata, inference_parameters) def to_result(self): """Export to a gw ``Result`` (samples + raw event data + metadata), so the diff --git a/tests/core/test_factors.py b/tests/core/test_factors.py index 7e01cfee0..942910f3d 100644 --- a/tests/core/test_factors.py +++ b/tests/core/test_factors.py @@ -18,6 +18,7 @@ GibbsBlock, Reparametrization, Stage, + TargetCorrection, chunk_and_concat, ) @@ -306,3 +307,69 @@ def test_unconditioned_factor_as_root_draws_base_count(): assert out["c"].shape == (6,) and out["d"].shape == (6,) assert torch.equal(out["c"], torch.full((6,), 2.0)) assert torch.equal(out["d"], torch.full((6,), 2.0)) # d | c: sum(c) + 0 == c + + +class _MockTargetCorrection(TargetCorrection): + """A mock kind-3 correction: emits ``corr = 2 * x``, contributes 0 to the proposal, + and consumes its input.""" + + def __init__(self, reads="x", emits="corr"): + self.conditioning = [reads] + self.parameters = [emits] + self.consumes = [reads] + self._reads, self._emits = reads, emits + + def correction(self, given, context): + return {self._emits: given[self._reads] * 2} + + +def test_target_correction_emits_side_channel_and_contributes_zero(): + comp = ChainComposer([_ConstFactor("x"), _MockTargetCorrection(reads="x")]) + out = comp.sample(5, context=None) + assert "x" not in out # consumed + assert torch.equal(out["corr"], 2 * torch.arange(5, dtype=torch.float32)) + # A correction adds 0 to the proposal density: log_prob = factor(0.5) + corr(0). + assert torch.allclose(out["log_prob"], torch.full((5,), 0.5)) + + +def test_target_correction_rejects_fan_out(): + tc = _MockTargetCorrection() + with pytest.raises(ValueError, match="1:1"): + tc.sample_and_log_prob( + 2, Conditioning(context=None, given={"x": torch.zeros(3)}) + ) + + +class _SideChannelFactor(Factor): + """A factor emitting an extra side-channel column beyond ``parameters``, declared via + ``produces`` so the topological check sees it (like GNPEFlowFactor's detector times). + """ + + def __init__(self, name, side): + self.parameters = [name] + self.conditioning = [] + self._name, self._side = name, side + + @property + def produces(self): + return self.parameters + [self._side] + + def sample_and_log_prob(self, n, cond): + block = { + self._name: torch.arange(n, dtype=torch.float32), + self._side: torch.zeros(n), + } + return block, torch.zeros(n) + + def log_prob(self, theta_i, cond): + return torch.zeros(next(iter(theta_i.values())).shape[0]) + + +def test_side_channel_produces_satisfies_topological_check(): + # A later step may condition on a side-channel column declared via `produces`; without + # `produces` the topological check would reject the chain. + comp = ChainComposer( + [_SideChannelFactor("a", side="s"), _ConstFactor("b", conditioning=["s"])] + ) + out = comp.sample(4, context=None) + assert out["b"].shape == (4,) and "s" in out From 73912279220bf7bbce691d42d5510593d818b580 Mon Sep 17 00:00:00 2001 From: Stephen Green Date: Wed, 1 Jul 2026 15:00:34 +0200 Subject: [PATCH 16/65] Rename FixedFactor to DeltaFactor The factor is a point mass q = delta(theta - c). DeltaFactor names the math -- matching bilby's DeltaFunction, the prior these pinned parameters come from -- and frees "fixed" for a future unconditional / data-independent flow factor. Also renames the _fixed_prior_steps helper to _delta_prior_steps. Co-Authored-By: Claude Opus 4.8 (1M context) --- dingo/gw/inference/factors.py | 22 +++++++++++----------- tests/core/test_factors.py | 4 ++-- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/dingo/gw/inference/factors.py b/dingo/gw/inference/factors.py index ef32b5450..0cfcaa72a 100644 --- a/dingo/gw/inference/factors.py +++ b/dingo/gw/inference/factors.py @@ -140,9 +140,9 @@ def likelihood(self): raise NotImplementedError("GWSamplerContext.likelihood") -class FixedFactor(Factor): - """``q_i = delta(theta_i - c)``: pins parameters to fixed values, contributing 0 to the - proposal log-prob. +class DeltaFactor(Factor): + """``q_i = delta(theta_i - c)``: a point mass pinning parameters to fixed values, + contributing 0 to the proposal log-prob. Used as the chain root for prior-conditioning or known proxies (single-step GNPE, where later factors condition on the pinned values), and as a non-root filler for delta-prior @@ -161,7 +161,7 @@ def sample_and_log_prob(self, num_samples, cond): return samples, torch.zeros(num_samples) def log_prob(self, theta_i, cond): - raise NotImplementedError("FixedFactor.log_prob") + raise NotImplementedError("DeltaFactor.log_prob") # --- Stubs for later steps ------------------------------------------------------------- @@ -482,8 +482,8 @@ def _ra_reparam_steps(inference_parameters: list[str]) -> list: return [RAReparam()] if "ra" in inference_parameters else [] -def _fixed_prior_steps(metadata: dict, inference_parameters: list[str]) -> list: - """Delta-prior parameters the chain does not produce, as a single ``FixedFactor`` step +def _delta_prior_steps(metadata: dict, inference_parameters: list[str]) -> list: + """Delta-prior parameters the chain does not produce, as a single ``DeltaFactor`` step (or none). These are pinned constants (e.g. an aligned-spin component fixed to 0). """ intrinsic_prior = metadata["dataset_settings"]["intrinsic_prior"] @@ -496,7 +496,7 @@ def _fixed_prior_steps(metadata: dict, inference_parameters: list[str]) -> list: for k, p in prior.items() if isinstance(p, DeltaFunction) and k not in inference_parameters } - return [FixedFactor(fixed)] if fixed else [] + return [DeltaFactor(fixed)] if fixed else [] class GWComposedSampler(ComposedSampler): @@ -536,7 +536,7 @@ def from_model( steps = ( [factor] + _ra_reparam_steps(inference_parameters) - + _fixed_prior_steps(metadata, inference_parameters) + + _delta_prior_steps(metadata, inference_parameters) ) return cls( composer=ChainComposer(steps), @@ -573,7 +573,7 @@ def from_gnpe_models( steps = ( [gibbs] + _ra_reparam_steps(inference_parameters) - + _fixed_prior_steps(metadata, inference_parameters) + + _delta_prior_steps(metadata, inference_parameters) ) return cls( ChainComposer(steps), @@ -592,7 +592,7 @@ def from_singlestep_gnpe( ) -> "GWComposedSampler": """Build a single-step (density-preserving) time-GNPE sampler: a ``ChainComposer`` of ``[proxy_source, GNPEFlowFactor, GNPEKernelCorrection, RAReparam]``. - ``proxy_source`` supplies the detector-time proxies -- a ``FixedFactor`` for prior + ``proxy_source`` supplies the detector-time proxies -- a ``DeltaFactor`` for prior conditioning (BNS), or an unconditional NDE for density recovery. The chain is autoregressive, so log_prob is preserved, and ``GNPEKernelCorrection`` emits the ``delta_log_prob_target`` correction that importance sampling adds to the target. @@ -609,7 +609,7 @@ def from_singlestep_gnpe( steps = ( [proxy_source, flow_factor, GNPEKernelCorrection(kernel_factor)] + _ra_reparam_steps(inference_parameters) - + _fixed_prior_steps(metadata, inference_parameters) + + _delta_prior_steps(metadata, inference_parameters) ) return cls(ChainComposer(steps), context, metadata, inference_parameters) diff --git a/tests/core/test_factors.py b/tests/core/test_factors.py index 942910f3d..74f22ac11 100644 --- a/tests/core/test_factors.py +++ b/tests/core/test_factors.py @@ -256,7 +256,7 @@ def test_reparam_forward_inverse_round_trip(): class _ConstFillFactor(Factor): - """An unconditioned factor emitting a constant (like a ``FixedFactor``). As the root it + """An unconditioned factor emitting a constant (like a ``DeltaFactor``). As the root it draws the base count; as a non-root step it fills one value per current row.""" def __init__(self, name, value=3.0): @@ -298,7 +298,7 @@ def test_unconditioned_filler_fills_after_fan_out(): def test_unconditioned_factor_as_root_draws_base_count(): - # As the chain root (prior-conditioning / FixedFactor proxy), an unconditioned factor + # As the chain root (prior-conditioning / DeltaFactor proxy), an unconditioned factor # draws the base count; a downstream factor conditions on the pinned values. comp = ChainComposer( [_ConstFillFactor("c", 2.0), _ConstFactor("d", conditioning=["c"])] From b36c5c6f345b4313504c0cca56531371a188df9c Mon Sep 17 00:00:00 2001 From: Stephen Green Date: Wed, 1 Jul 2026 15:07:09 +0200 Subject: [PATCH 17/65] Remove the vestigial _post_process hook; ComposedSampler is a generic runner With all GW-specific processing expressed as chain steps (RA reparametrization, delta factors, kernel correction), the ComposedSampler._post_process hook is a dead no-op. Remove it and its run_sampler call: ComposedSampler is now a domain-agnostic runner, and GWComposedSampler is a GW builder (the from_* constructors) and exporter (to_result) on top of it. Parity: NPE, multi-iteration GNPE, single-step, and packaged all bit-exact; tests/core/test_factors.py + tests/gw/test_ra_reparam.py green (24 tests). Co-Authored-By: Claude Opus 4.8 (1M context) --- dingo/core/factors.py | 16 ++++++---------- dingo/gw/inference/factors.py | 9 +++++---- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/dingo/core/factors.py b/dingo/core/factors.py index 5443352bc..3926d170a 100644 --- a/dingo/core/factors.py +++ b/dingo/core/factors.py @@ -565,8 +565,9 @@ def _run_once( class ComposedSampler: """ - Façade over a ``ChainComposer`` and a ``SamplerContext``. Runs the composer, applies - domain-specific post-processing, and returns the samples as a DataFrame. + Generic runner over a ``ChainComposer`` and a ``SamplerContext``: draws samples and + returns them as a DataFrame. Domain-specific processing lives in the chain's steps, so + the runner is domain-agnostic. """ def __init__(self, composer: ChainComposer, context: SamplerContext): @@ -574,18 +575,13 @@ def __init__(self, composer: ChainComposer, context: SamplerContext): self.context = context self.samples: Optional[pd.DataFrame] = None - def _post_process(self, samples: dict): - """Hook for domain-specific post-processing; no-op by default.""" - pass - def run_sampler( self, num_samples: int, batch_size: Optional[int] = None ) -> pd.DataFrame: - """Draw ``num_samples`` samples (chunked by ``batch_size``), post-process, and - return them as a DataFrame. An all-density chain includes ``log_prob``; a chain - with a ``GibbsBlock`` step does not.""" + """Draw ``num_samples`` samples (chunked by ``batch_size``) and return them as a + DataFrame. An all-density chain includes ``log_prob``; a chain with a ``GibbsBlock`` + step does not.""" merged = self.composer.sample(num_samples, self.context, batch_size) merged = {k: v.cpu().numpy() for k, v in merged.items()} - self._post_process(merged) self.samples = pd.DataFrame(merged) return self.samples diff --git a/dingo/gw/inference/factors.py b/dingo/gw/inference/factors.py index 0cfcaa72a..608da45f6 100644 --- a/dingo/gw/inference/factors.py +++ b/dingo/gw/inference/factors.py @@ -501,10 +501,11 @@ def _delta_prior_steps(metadata: dict, inference_parameters: list[str]) -> list: class GWComposedSampler(ComposedSampler): """ - GW façade over a ``ChainComposer``. Builds the chain for plain NPE, multi-iteration - GNPE, or single-step GNPE from model metadata, and exports the samples to a gw - ``Result``. All GW-specific processing (RA frame, fixed parameters, kernel correction) - is expressed as chain steps, so there is no post-processing step. + GW builder and exporter over the generic ``ComposedSampler`` runner. The ``from_*`` + constructors assemble the chain for plain NPE, multi-iteration GNPE, or single-step + GNPE from model metadata; ``to_result`` exports the samples to a gw ``Result``. All + GW-specific processing (RA frame, fixed parameters, kernel correction) is expressed as + chain steps, so there is no post-processing. """ def __init__( From 17f49a6096d05625dd30f8dd23bb002c96457290 Mon Sep 17 00:00:00 2001 From: Stephen Green Date: Wed, 1 Jul 2026 15:35:23 +0200 Subject: [PATCH 18/65] Drop the Conditioning wrapper for explicit (context, given) args Conditioning was a 2-field dataclass (context, given) with no behaviour. Steps now take sample_and_log_prob(n, context, given) / log_prob(theta_i, context, given) directly, and the composer passes the two. Any parameter-dependent transform lives inside the factor (as GNPE shows), so the conditioning never needed to carry more than these two fields. Parity: NPE, multi-iteration GNPE, single-step, and packaged all bit-exact; tests/core/test_factors.py + tests/gw/test_ra_reparam.py green (23 tests). Co-Authored-By: Claude Opus 4.8 (1M context) --- dingo/core/factors.py | 95 +++++++++++++++++++---------------- dingo/gw/inference/factors.py | 31 ++++++------ tests/core/test_factors.py | 33 ++++-------- 3 files changed, 79 insertions(+), 80 deletions(-) diff --git a/dingo/core/factors.py b/dingo/core/factors.py index 3926d170a..038ea33f7 100644 --- a/dingo/core/factors.py +++ b/dingo/core/factors.py @@ -17,7 +17,7 @@ import math from abc import ABC, abstractmethod -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import Callable, Optional, Protocol, Union, runtime_checkable import pandas as pd @@ -83,17 +83,6 @@ def likelihood(self): ... -@dataclass -class Conditioning: - """ - The conditioning passed to a factor: the shared ``context``, and ``given``, the - physical values of the earlier-block parameters the factor conditions on. - """ - - context: SamplerContext - given: dict[str, torch.Tensor] = field(default_factory=dict) - - def _cat_dict( batches: list[dict[str, torch.Tensor]], ) -> dict[str, torch.Tensor]: @@ -131,7 +120,7 @@ class Factor(ABC): A call draws ``num_samples`` samples per conditioning row and returns ``n_rows * num_samples`` rows in row-major order, where ``n_rows`` is the number of rows in - ``cond.given`` (1 if unconditioned). This mirrors the network's + ``given`` (1 if unconditioned). This mirrors the network's ``sample_and_log_prob(*context, num_samples=n) -> (n_rows, n, dim)``. Attributes @@ -147,7 +136,10 @@ class Factor(ABC): @abstractmethod def sample_and_log_prob( - self, num_samples: int, cond: Conditioning + self, + num_samples: int, + context: SamplerContext, + given: Optional[dict[str, torch.Tensor]] = None, ) -> tuple[dict[str, torch.Tensor], torch.Tensor]: """Draw ``num_samples`` samples per conditioning row; return ``(samples, log_prob)`` in physical space. The samples dict may include named columns beyond @@ -155,7 +147,10 @@ def sample_and_log_prob( @abstractmethod def log_prob( - self, theta_i: dict[str, torch.Tensor], cond: Conditioning + self, + theta_i: dict[str, torch.Tensor], + context: SamplerContext, + given: Optional[dict[str, torch.Tensor]] = None, ) -> torch.Tensor: """Evaluate ``log q_i`` at given physical ``theta_i`` (one conditioning row per row).""" @@ -178,7 +173,7 @@ class FlowFactor(Factor): its interface is in physical parameter space. An unconditioned model draws from the shared data context; a model with - ``context_parameters`` conditions on the values in ``cond.given``. + ``context_parameters`` conditions on the values in ``given``. """ def __init__( @@ -219,11 +214,11 @@ def from_model( aliases=aliases, ) - def sample_and_log_prob(self, num_samples, cond): + def sample_and_log_prob(self, num_samples, context, given=None): """Draw ``num_samples`` samples per conditioning row. Unconditioned: ``num_samples`` draws from the shared data context. Conditioned: ``num_samples`` per context row, returning ``n_rows * num_samples`` rows in row-major order.""" - data = cond.context.prepared_data() + data = context.prepared_data() self.model.network.eval() if not self.context_parameters: with torch.no_grad(): @@ -237,7 +232,7 @@ def sample_and_log_prob(self, num_samples, cond): # Standardize the (physical) conditioning the network was trained on, giving # B = N context rows; expand the shared data to match. The network draws # num_samples per row -> (N, num_samples, dim). - ctx = self.standardization.standardize(cond.given, self.context_parameters) + ctx = self.standardization.standardize(given, self.context_parameters) n_rows = ctx.shape[0] data = data.expand(n_rows, *data.shape) with torch.no_grad(): @@ -252,20 +247,20 @@ def sample_and_log_prob(self, num_samples, cond): theta = {self.aliases.get(k, k): v for k, v in theta.items()} return theta, log_prob - def log_prob(self, theta_i, cond): + def log_prob(self, theta_i, context, given=None): # theta_i uses exposed (aliased) names; map back to the network's trained names. theta_net = { net: theta_i[self.aliases.get(net, net)] for net in self._net_parameters } num_samples = next(iter(theta_net.values())).shape[0] z = self.standardization.standardize(theta_net, self._net_parameters) - data = cond.context.prepared_data() + data = context.prepared_data() data = data.expand(num_samples, *data.shape) net_context: tuple[torch.Tensor, ...] if not self.context_parameters: net_context = (data,) else: - ctx = self.standardization.standardize(cond.given, self.context_parameters) + ctx = self.standardization.standardize(given, self.context_parameters) net_context = (data, ctx) self.model.network.eval() with torch.no_grad(): @@ -309,22 +304,28 @@ def log_det( return torch.zeros(n) def sample_and_log_prob( - self, num_samples: int, cond: "Conditioning" + self, + num_samples: int, + context: SamplerContext, + given: Optional[dict[str, torch.Tensor]] = None, ) -> tuple[dict[str, torch.Tensor], torch.Tensor]: """Apply ``forward`` to the conditioning; contribute ``-log|det J|``. ``num_samples`` must be 1 (a reparametrization is 1:1).""" if num_samples != 1: raise ValueError("A reparametrization is 1:1; use fan_out=1.") - out = self.forward(cond.given, cond.context) - return out, -self.log_det(cond.given, cond.context) + out = self.forward(given, context) + return out, -self.log_det(given, context) @property def consumes(self) -> list[str]: - """Conditioning the bijection replaces with its outputs (dropped after the step).""" + """Inputs the bijection replaces with its outputs (dropped after the step).""" return [c for c in self.conditioning if c not in self.parameters] def log_prob( - self, theta_i: dict[str, torch.Tensor], cond: "Conditioning" + self, + theta_i: dict[str, torch.Tensor], + context: SamplerContext, + given: Optional[dict[str, torch.Tensor]] = None, ) -> torch.Tensor: raise NotImplementedError("Reparametrization.log_prob") @@ -350,16 +351,22 @@ def correction( """The side-channel column(s), one value per conditioning row.""" def sample_and_log_prob( - self, num_samples: int, cond: "Conditioning" + self, + num_samples: int, + context: SamplerContext, + given: Optional[dict[str, torch.Tensor]] = None, ) -> tuple[dict[str, torch.Tensor], torch.Tensor]: if num_samples != 1: raise ValueError("A target correction is 1:1; use fan_out=1.") - out = self.correction(cond.given, cond.context) + out = self.correction(given, context) n = next(iter(out.values())).shape[0] return out, torch.zeros(n) def log_prob( - self, theta_i: dict[str, torch.Tensor], cond: "Conditioning" + self, + theta_i: dict[str, torch.Tensor], + context: SamplerContext, + given: Optional[dict[str, torch.Tensor]] = None, ) -> torch.Tensor: raise NotImplementedError("TargetCorrection.log_prob") @@ -378,7 +385,10 @@ class Step(Protocol): conditioning: list[str] def sample_and_log_prob( - self, num_samples: int, cond: "Conditioning" + self, + num_samples: int, + context: SamplerContext, + given: Optional[dict[str, torch.Tensor]] = None, ) -> tuple[dict[str, torch.Tensor], Optional[torch.Tensor]]: ... @@ -466,8 +476,8 @@ def _run_chain_once( # Unconditioned non-root step (e.g. a fixed/delta factor): draw one value # per current row -- it fills the batch rather than fanning out. n = len(next(iter(samples.values()))) - cond = Conditioning(context, {k: samples[k] for k in step.conditioning}) - block, lp = step.sample_and_log_prob(n, cond) + given = {k: samples[k] for k in step.conditioning} + block, lp = step.sample_and_log_prob(n, context, given) # A conditioned fan-out (fan_out > 1) expands the batch: repeat each carried row # to align with the step's fan_out sub-rows (the block is flattened row-major). # 1:1 stages and unconditioned fillers leave the batch untouched. @@ -495,9 +505,9 @@ def log_prob( chain (every step a ``Factor``).""" total: torch.Tensor | float = 0.0 for step in self.steps: - cond = Conditioning(context, {k: samples[k] for k in step.conditioning}) + given = {k: samples[k] for k in step.conditioning} theta_i = {k: samples[k] for k in step.parameters} - total = total + step.log_prob(theta_i, cond) + total = total + step.log_prob(theta_i, context, given) return total def sample( @@ -540,25 +550,26 @@ def __init__(self, init_factor: Factor, factors: list[Factor], num_iterations: i self.conditioning: list[str] = [] def sample_and_log_prob( - self, num_samples: int, cond: Conditioning + self, + num_samples: int, + context: SamplerContext, + given: Optional[dict[str, torch.Tensor]] = None, ) -> tuple[dict[str, torch.Tensor], None]: """Run the Gibbs loop for ``num_samples`` walkers; return ``(samples, None)``. ``num_samples`` is the walker (root) count -- Gibbs does not fan out.""" - return self._run_once(num_samples, cond.context), None + return self._run_once(num_samples, context), None def _run_once( self, num_samples: int, context: SamplerContext ) -> dict[str, torch.Tensor]: # Seed the chain (e.g. an init network's detector times); the walkers are the rows. - seed, _ = self.init_factor.sample_and_log_prob( - num_samples, Conditioning(context) - ) + seed, _ = self.init_factor.sample_and_log_prob(num_samples, context) state = dict(seed) for _ in range(self.num_iterations): for factor in self.factors: - c = Conditioning(context, {k: state[k] for k in factor.conditioning}) + given = {k: state[k] for k in factor.conditioning} # One sample per walker (Gibbs is 1:1); walkers are the conditioning rows. - block, _ = factor.sample_and_log_prob(1, c) + block, _ = factor.sample_and_log_prob(1, context, given) state.update(block) return {p: state[p] for p in self.parameters} diff --git a/dingo/gw/inference/factors.py b/dingo/gw/inference/factors.py index 608da45f6..624eaec05 100644 --- a/dingo/gw/inference/factors.py +++ b/dingo/gw/inference/factors.py @@ -24,7 +24,6 @@ from dingo.core.factors import ( ChainComposer, ComposedSampler, - Conditioning, Factor, FlowFactor, GibbsBlock, @@ -154,13 +153,13 @@ def __init__(self, values: dict[str, float]): self.parameters = list(values) self.conditioning = [] - def sample_and_log_prob(self, num_samples, cond): + def sample_and_log_prob(self, num_samples, context, given=None): samples = { p: torch.full((num_samples,), float(v)) for p, v in self.values.items() } return samples, torch.zeros(num_samples) - def log_prob(self, theta_i, cond): + def log_prob(self, theta_i, context, given=None): raise NotImplementedError("DeltaFactor.log_prob") @@ -175,10 +174,10 @@ def __init__(self): self.parameters = ["phase"] self.conditioning = [] # conditions on all preceding params via the likelihood - def sample_and_log_prob(self, num_samples, cond): + def sample_and_log_prob(self, num_samples, context, given=None): raise NotImplementedError("SyntheticPhaseFactor") - def log_prob(self, theta_i, cond): + def log_prob(self, theta_i, context, given=None): raise NotImplementedError("SyntheticPhaseFactor") @@ -284,21 +283,21 @@ def from_model(cls, model: BasePosteriorModel) -> "GNPEKernelFactor": _, _, gnpe_parameters, _, _, gnpe_transform = _build_gnpe_transforms(model) return cls(gnpe_transform, gnpe_parameters) - def sample_and_log_prob(self, num_samples, cond): + def sample_and_log_prob(self, num_samples, context, given=None): """Blur the conditioning detector times into proxies; ``num_samples`` must be 1 (GNPE is 1:1). Returns the proxies and their kernel log-prob.""" if num_samples != 1: raise ValueError("GNPE proxy is 1:1; use fan_out=1.") - times = {k: cond.given[k] for k in self.gnpe_parameters} + times = {k: given[k] for k in self.gnpe_parameters} proxies = self.gnpe.sample_proxies(times) - return proxies, self.log_prob(proxies, cond) + return proxies, self.log_prob(proxies, context, given) - def log_prob(self, theta_i, cond): + def log_prob(self, theta_i, context, given=None): """``log p(theta_hat | theta)`` from the kernel, at the proxies (``theta_i``) and - the detector times (``cond.given``).""" + the detector times (``given``).""" diffs = {} for k in self.kernel.keys(): - diff = cond.given[k] - theta_i[f"{k}_proxy"] + diff = given[k] - theta_i[f"{k}_proxy"] if torch.is_tensor(diff): diff = diff.detach().cpu().numpy() diffs[k] = np.asarray(diff) @@ -359,15 +358,15 @@ def from_model( model, pre, post, gnpe_parameters, inference_parameters, aliases=aliases ) - def sample_and_log_prob(self, num_samples, cond): + def sample_and_log_prob(self, num_samples, context, given=None): """Sample one parameter set per proxy row; ``num_samples`` must be 1 (GNPE is 1:1). Returns theta plus the recomputed detector times, and the network log-prob.""" if num_samples != 1: raise ValueError("GNPE is 1:1; draw one sample per proxy (fan_out=1).") - proxies = {p: cond.given[p] for p in self.proxy_parameters} + proxies = {p: given[p] for p in self.proxy_parameters} n_rows = next(iter(proxies.values())).shape[0] x = {"extrinsic_parameters": dict(proxies), "parameters": {}} - d = cond.context.prepared_data().clone() + d = context.prepared_data().clone() x["data"] = d.expand(n_rows, *d.shape) x = self.transform_pre(x) self.model.network.eval() @@ -392,7 +391,7 @@ def sample_and_log_prob(self, num_samples, cond): params[k] = x["extrinsic_parameters"][k] return params, x["log_prob"] - def log_prob(self, theta_i, cond): + def log_prob(self, theta_i, context, given=None): raise NotImplementedError("GNPEFlowFactor.log_prob") @@ -467,7 +466,7 @@ def __init__(self, kernel_factor: GNPEKernelFactor): def correction(self, given, context): proxies = {p: given[p] for p in self.kernel_factor.parameters} times = {k: given[k] for k in self.kernel_factor.gnpe_parameters} - correction = self.kernel_factor.log_prob(proxies, Conditioning(context, times)) + correction = self.kernel_factor.log_prob(proxies, context, times) return {"delta_log_prob_target": correction} diff --git a/tests/core/test_factors.py b/tests/core/test_factors.py index 74f22ac11..8cfe748c3 100644 --- a/tests/core/test_factors.py +++ b/tests/core/test_factors.py @@ -13,7 +13,6 @@ from dingo.core.factors import ( ChainComposer, - Conditioning, Factor, GibbsBlock, Reparametrization, @@ -34,9 +33,9 @@ def __init__(self, name, conditioning=()): self.conditioning = list(conditioning) self._name = name - def sample_and_log_prob(self, n, cond): + def sample_and_log_prob(self, n, context, given=None): if self.conditioning: - base = sum(cond.given[k] for k in self.conditioning) # (N,) + base = sum(given[k] for k in self.conditioning) # (N,) n_rows = base.shape[0] # Integer-valued within-row offset: exact in float32 (no cancellation when # the test recovers it as b - a), and distinct per fan-out draw. @@ -48,7 +47,7 @@ def sample_and_log_prob(self, n, cond): lp = torch.full((n,), 0.5) return {self._name: vals}, lp - def log_prob(self, theta_i, cond): + def log_prob(self, theta_i, context, given=None): return torch.zeros(next(iter(theta_i.values())).shape[0]) @@ -145,12 +144,6 @@ def test_chunk_and_concat_allows_none_log_prob(): assert samples["x"].shape == (6,) -def test_conditioning_dataclass_defaults(): - cond = Conditioning(context="ctx") - assert cond.context == "ctx" - assert cond.given == {} - - class _NoDensityStep: """A density-free step (like ``GibbsBlock``): emits a block but returns ``None`` for the log-prob. Honors the per-row fan-out contract so it composes like any step.""" @@ -160,9 +153,9 @@ def __init__(self, name, conditioning=()): self.conditioning = list(conditioning) self._name = name - def sample_and_log_prob(self, n, cond): + def sample_and_log_prob(self, n, context, given=None): if self.conditioning: - base = sum(cond.given[k] for k in self.conditioning) + base = sum(given[k] for k in self.conditioning) within = torch.arange(n, dtype=base.dtype) vals = (base.unsqueeze(1) + within).reshape(-1) else: @@ -242,9 +235,7 @@ def test_reparam_step_consumes_input_and_contributes_neg_logdet(): def test_reparam_rejects_fan_out(): rp = _MockReparam() with pytest.raises(ValueError, match="1:1"): - rp.sample_and_log_prob( - 2, Conditioning(context=None, given={"u": torch.zeros(3)}) - ) + rp.sample_and_log_prob(2, None, {"u": torch.zeros(3)}) def test_reparam_forward_inverse_round_trip(): @@ -264,10 +255,10 @@ def __init__(self, name, value=3.0): self.conditioning = [] self._name, self._v = name, value - def sample_and_log_prob(self, n, cond): + def sample_and_log_prob(self, n, context, given=None): return {self._name: torch.full((n,), self._v)}, torch.zeros(n) - def log_prob(self, theta_i, cond): + def log_prob(self, theta_i, context, given=None): return torch.zeros(next(iter(theta_i.values())).shape[0]) @@ -335,9 +326,7 @@ def test_target_correction_emits_side_channel_and_contributes_zero(): def test_target_correction_rejects_fan_out(): tc = _MockTargetCorrection() with pytest.raises(ValueError, match="1:1"): - tc.sample_and_log_prob( - 2, Conditioning(context=None, given={"x": torch.zeros(3)}) - ) + tc.sample_and_log_prob(2, None, {"x": torch.zeros(3)}) class _SideChannelFactor(Factor): @@ -354,14 +343,14 @@ def __init__(self, name, side): def produces(self): return self.parameters + [self._side] - def sample_and_log_prob(self, n, cond): + def sample_and_log_prob(self, n, context, given=None): block = { self._name: torch.arange(n, dtype=torch.float32), self._side: torch.zeros(n), } return block, torch.zeros(n) - def log_prob(self, theta_i, cond): + def log_prob(self, theta_i, context, given=None): return torch.zeros(next(iter(theta_i.values())).shape[0]) From 4420dcb110ecb24521b7b129f103704bec64f1b1 Mon Sep 17 00:00:00 2001 From: Stephen Green Date: Thu, 2 Jul 2026 13:58:54 +0200 Subject: [PATCH 19/65] Add likelihood-in-context and the synthetic-phase factor Give GWSamplerContext a likelihood() that builds the exact GW likelihood on the event's raw data (the network's decimated/whitened view stays in prepared_data()), and add SyntheticPhaseFactor, a terminal chain factor that reconstructs the coalescence phase for a phase-marginalized network from that likelihood on a phase grid, returning phase and its proposal log-prob. - GWSamplerContext gains base_metadata and likelihood(...), porting Result._build_likelihood. The likelihood reference time is the event time (the training-frame RA correction is already applied to the samples). - SyntheticPhaseFactor supports the (2, 2) approximation and the exact mode-decomposed grid, with a uniform floor for mass coverage. Verified bit-exact against Result._build_likelihood and Result.sample_synthetic_phase via model-based harnesses; 7 mock-based CI unit tests added in tests/gw/test_synthetic_phase.py. Co-Authored-By: Claude Opus 4.8 (1M context) --- dingo/gw/inference/factors.py | 255 +++++++++++++++++++++++++++++-- tests/gw/test_synthetic_phase.py | 129 ++++++++++++++++ 2 files changed, 372 insertions(+), 12 deletions(-) create mode 100644 tests/gw/test_synthetic_phase.py diff --git a/dingo/gw/inference/factors.py b/dingo/gw/inference/factors.py index 624eaec05..4c930ea53 100644 --- a/dingo/gw/inference/factors.py +++ b/dingo/gw/inference/factors.py @@ -12,15 +12,22 @@ from __future__ import annotations import copy +import logging +import time from typing import Optional import numpy as np +import pandas as pd import torch from astropy.time import Time -from bilby.core.prior import DeltaFunction, PriorDict +from bilby.core.prior import DeltaFunction, PriorDict, Uniform from bilby.gw.detector import InterferometerList from torchvision.transforms import Compose +from dingo.core.density import ( + interpolated_log_prob_multi, + interpolated_sample_and_log_prob_multi, +) from dingo.core.factors import ( ChainComposer, ComposedSampler, @@ -31,10 +38,12 @@ TargetCorrection, _base_model_metadata, ) +from dingo.core.multiprocessing import apply_func_with_multiprocessing from dingo.core.posterior_models import BasePosteriorModel from dingo.core.transforms import GetItem, RenameKey from dingo.gw.domains import build_domain, MultibandedFrequencyDomain from dingo.gw.gwutils import get_extrinsic_prior_dict +from dingo.gw.likelihood import StationaryGaussianGWLikelihood from dingo.gw.prior import build_prior_with_defaults from dingo.gw.transforms import ( CopyToExtrinsicParameters, @@ -50,6 +59,8 @@ WhitenAndScaleStrain, ) +logger = logging.getLogger(__name__) + class GWSamplerContext: """ @@ -58,8 +69,9 @@ class GWSamplerContext: stages. This implements the ``dingo.core.factors.SamplerContext`` protocol. It owns the - one-time data preprocessing (``prepared_data``) and -- once wired in a later step -- - the likelihood used by likelihood-based factors and importance sampling. + one-time data preprocessing (``prepared_data``) and builds the exact likelihood + (``likelihood``) used by likelihood-based factors (synthetic phase) and importance + sampling. Event metadata lives here (not on individual factors): it is a property of the data, and it drives frequency cropping, the t_ref/RA correction, and the likelihood. @@ -73,11 +85,14 @@ def __init__( data_prep: Compose, raw_context: dict, event_metadata: Optional[dict] = None, + base_metadata: Optional[dict] = None, ): self.domain = domain self.detectors = detectors self.t_ref = t_ref self._data_prep = data_prep + # The base (parameter/waveform/domain) metadata, used to build the likelihood. + self.base_metadata = base_metadata # The raw event data `d` (strain + ASDs per detector), i.e. EventDataset.data -- # the same object set as GWSampler.context today. Consumed lazily by # prepared_data(); also retained for the likelihood (synthetic-phase / IS factors) @@ -127,6 +142,7 @@ def from_model( data_prep=Compose(transforms), raw_context=raw_context, event_metadata=event_metadata, + base_metadata=meta, ) def prepared_data(self) -> torch.Tensor: @@ -135,8 +151,125 @@ def prepared_data(self) -> torch.Tensor: self._prepared = self._data_prep(self.raw_context) return self._prepared - def likelihood(self): - raise NotImplementedError("GWSamplerContext.likelihood") + def likelihood( + self, + time_marginalization_kwargs: Optional[dict] = None, + phase_marginalization_kwargs: Optional[dict] = None, + calibration_marginalization_kwargs: Optional[dict] = None, + phase_grid: Optional[np.ndarray] = None, + use_base_domain: bool = False, + ) -> StationaryGaussianGWLikelihood: + """ + Build the exact GW likelihood on this event's data, in physical parameter space. + + The network's standardized, decimated view of the data is `prepared_data()`; the + likelihood instead takes the raw event data (`raw_context`) and builds its own + representation -- decimating to the multibanded domain unless `use_base_domain`, + and masking the ASDs to the event's frequency range. Its reference time is the + event time (the training-frame right-ascension correction is already applied to the + samples), or the training reference time when no event time is set. + + Importance-sampling domain rebuilds (an updated `T` / `delta_f` or frequency range) + are not wired here yet: the likelihood uses this context's domain. + + Parameters + ---------- + time_marginalization_kwargs : dict, optional + Analytically marginalize over `geocent_time`; `t_lower` / `t_upper` are filled + from the network's (uniform) time prior. Requires a time-marginalized network. + phase_marginalization_kwargs : dict, optional + Analytically marginalize over `phase`. Requires a uniform [0, 2 pi) phase prior. + calibration_marginalization_kwargs : dict, optional + Marginalize over detector calibration uncertainty. + phase_grid : np.ndarray, optional + Phase grid for the exact phase-marginalized / synthetic-phase likelihood. + use_base_domain : bool, default False + For a multibanded domain, evaluate on the base (undecimated) frequency domain + rather than the decimated one. + + Returns + ------- + StationaryGaussianGWLikelihood + """ + # Marginalizing over a parameter needs the (uniform) prior the network + # marginalized over; use it to parameterize the requested marginalization. + if time_marginalization_kwargs is not None: + time_prior = self._marginalized_prior("geocent_time") + if time_prior is None: + raise NotImplementedError( + "Time marginalization is not compatible with a non-marginalized " + "network." + ) + if type(time_prior) != Uniform: + raise NotImplementedError( + "Only uniform time prior is supported for time marginalization." + ) + time_marginalization_kwargs = { + **time_marginalization_kwargs, + "t_lower": time_prior.minimum, + "t_upper": time_prior.maximum, + } + if phase_marginalization_kwargs is not None: + phase_prior = self._marginalized_prior("phase") + if not ( + isinstance(phase_prior, Uniform) + and (phase_prior._minimum, phase_prior._maximum) == (0, 2 * np.pi) + ): + raise ValueError( + f"Phase prior should be uniform [0, 2pi) for phase marginalization, " + f"but is {phase_prior}." + ) + + dataset_settings = self.base_metadata["dataset_settings"] + # WaveformGenerator domain -- generally the dataset domain (it may be wider than + # the data domain for generation). delta_f / T importance-sampling updates: TODO. + wfg_domain = build_domain(dataset_settings["domain"]) + + # Likelihood reference time: the event time (the training-frame RA correction has + # already been applied to the samples), falling back to the training reference. + if self.event_metadata is not None and "time_event" in self.event_metadata: + t_ref = self.event_metadata["time_event"] + else: + t_ref = self.t_ref + + return StationaryGaussianGWLikelihood( + wfg_kwargs=dataset_settings["waveform_generator"], + wfg_domain=wfg_domain, + data_domain=self.domain, + event_data=self.raw_context, + t_ref=t_ref, + time_marginalization_kwargs=time_marginalization_kwargs, + phase_marginalization_kwargs=phase_marginalization_kwargs, + calibration_marginalization_kwargs=calibration_marginalization_kwargs, + phase_grid=phase_grid, + use_base_domain=use_base_domain, + frequency_update=dict( + minimum_frequency=self._frequency( + "minimum_frequency", self.domain.f_min + ), + maximum_frequency=self._frequency( + "maximum_frequency", self.domain.f_max + ), + ), + ) + + def _frequency(self, key: str, default: float): + """The event's frequency-range override for `key` (min/max), else `default`.""" + if self.event_metadata is None: + return default + return self.event_metadata.get(key, default) + + def _marginalized_prior(self, name: str): + """The prior over `name` if the network marginalized it (i.e. `name` is not an + inference parameter), else `None` -- used to parameterize likelihood + marginalization over time / phase.""" + data_settings = self.base_metadata["train_settings"]["data"] + if name in data_settings["inference_parameters"]: + return None + intrinsic_prior = self.base_metadata["dataset_settings"]["intrinsic_prior"] + extrinsic_prior = get_extrinsic_prior_dict(data_settings["extrinsic_prior"]) + prior = build_prior_with_defaults({**intrinsic_prior, **extrinsic_prior}) + return prior.get(name) class DeltaFactor(Factor): @@ -163,22 +296,120 @@ def log_prob(self, theta_i, context, given=None): raise NotImplementedError("DeltaFactor.log_prob") -# --- Stubs for later steps ------------------------------------------------------------- +def _to_numpy(v) -> np.ndarray: + """Detach a torch tensor (or coerce anything) to a numpy array.""" + if torch.is_tensor(v): + return v.detach().cpu().numpy() + return np.asarray(v) class SyntheticPhaseFactor(Factor): - """``q(phase | theta_rest, d)`` from the likelihood on a phase grid (a non-network - factor, the final step of the chain). Not yet implemented.""" + """ + Reconstruct the coalescence phase for a phase-marginalized network: + `q(phase | theta_rest, d)` from the likelihood on a phase grid. The terminal factor of + the chain. + + For each incoming `theta_rest` it builds the phase-full likelihood + (`context.likelihood()`) and evaluates `log L` on a grid over `[0, 2 pi)`, exploiting + that the waveform modes computed once at `phase = 0` each transform as `exp(-i m phase)` + -- so the whole grid follows from a single waveform evaluation. The grid is + exponentiated into a conditional phase distribution, a uniform floor (weight + `uniform_weight`) is added to keep it mass-covering, and one phase is drawn per sample + from the interpolated distribution. The returned proposal log-prob + `log q(phase | theta_rest, d)` joins the chain's proposal density; importance sampling + then targets the phase-full posterior (with the phase prior re-added). + + Two grid modes: `approximation_22_mode=True` assumes a (2, 2)-dominated signal (the + whole waveform transforms as `exp(2i phase)`), giving `log L` from the complex overlap + `Re[(d | h(phase=0)) exp(2i phase)]`; `False` (the production default) sums the modes + exactly and requires the waveform generator's `spin_conversion_phase = 0`. + """ - def __init__(self): + def __init__( + self, + conditioning: list[str], + n_grid: int = 5001, + approximation_22_mode: bool = False, + uniform_weight: float = 0.01, + num_processes: int = 1, + ): + """ + Parameters + ---------- + conditioning : list[str] + The physical parameters the likelihood needs to generate the waveform + (everything the chain has produced except `phase`). + n_grid : int, default 5001 + Number of phase grid points on `[0, 2 pi)`. + approximation_22_mode : bool, default False + Use the (2, 2)-mode approximation instead of the exact mode sum. + uniform_weight : float, default 0.01 + Weight of the uniform floor added to the phase distribution for mass coverage. + num_processes : int, default 1 + Parallel processes for the per-sample likelihood evaluation and phase sampling. + """ self.parameters = ["phase"] - self.conditioning = [] # conditions on all preceding params via the likelihood + self.conditioning = list(conditioning) + self.n_grid = n_grid + self.approximation_22_mode = approximation_22_mode + self.uniform_weight = uniform_weight + self.num_processes = num_processes def sample_and_log_prob(self, num_samples, context, given=None): - raise NotImplementedError("SyntheticPhaseFactor") + """Draw one phase per `theta_rest` row (`num_samples` must be 1); return the phases + and their proposal log-prob `log q(phase | theta_rest, d)`.""" + if num_samples != 1: + raise ValueError( + "Synthetic phase is 1:1; draw one phase per sample (fan_out=1)." + ) + n = len(next(iter(given.values()))) + logger.info(f"Estimating synthetic phase for {n} samples.") + t0 = time.time() + phases, phase_posterior = self._phase_profile(given, context) + new_phase, log_prob = interpolated_sample_and_log_prob_multi( + phases, phase_posterior, self.num_processes + ) + logger.info(f"Done. This took {time.time() - t0:.2f} s.") + return {"phase": torch.as_tensor(new_phase)}, torch.as_tensor(log_prob) def log_prob(self, theta_i, context, given=None): - raise NotImplementedError("SyntheticPhaseFactor") + """Evaluate `log q(phase | theta_rest, d)` at the given phases (re-plug / IS).""" + phases, phase_posterior = self._phase_profile(given, context) + log_prob = interpolated_log_prob_multi( + phases, phase_posterior, _to_numpy(theta_i["phase"]), self.num_processes + ) + return torch.as_tensor(log_prob) + + def _phase_profile(self, given, context): + """The phase grid and the mass-covered (un-normalized) phase distribution, one row + per sample: evaluate `log L` on the grid, exponentiate (shifted by the per-row + max), and add the uniform floor.""" + theta = pd.DataFrame({k: _to_numpy(v) for k, v in given.items()}) + likelihood = context.likelihood() + phases = np.linspace(0, 2 * np.pi, self.n_grid) + if self.approximation_22_mode: + # Assume the waveform is (2, 2)-dominated (transforms as exp(2i phase)), so the + # phase-dependent log-posterior is Re[(d | h(phase=0)) exp(2i phase)]. + theta = theta.copy() + theta["phase"] = 0.0 + d_inner_h = likelihood.d_inner_h_complex_multi(theta, self.num_processes) + phase_log_posterior = np.outer(d_inner_h, np.exp(2j * phases)).real + else: + # Exact: each mode m contributes exp(-i m phase); needs spin_conversion_phase=0. + likelihood.phase_grid = phases + phase_log_posterior = apply_func_with_multiprocessing( + likelihood.log_likelihood_phase_grid, + theta, + num_processes=self.num_processes, + ) + phase_posterior = np.exp( + phase_log_posterior - np.amax(phase_log_posterior, axis=1, keepdims=True) + ) + # Uniform floor: keep q(phase) > 0 everywhere so importance sampling stays finite. + phase_posterior += ( + phase_posterior.mean(axis=-1, keepdims=True) * self.uniform_weight + ) + return phases, phase_posterior def _build_gnpe_transforms(model: BasePosteriorModel): diff --git a/tests/gw/test_synthetic_phase.py b/tests/gw/test_synthetic_phase.py new file mode 100644 index 000000000..cc45224aa --- /dev/null +++ b/tests/gw/test_synthetic_phase.py @@ -0,0 +1,129 @@ +"""CI unit tests for SyntheticPhaseFactor (and a GWSamplerContext helper), using a mock +context / likelihood so no waveform models or LAL calls are needed. End-to-end parity +against Result.sample_synthetic_phase is covered by the model-based harness.""" + +import numpy as np +import pytest +import torch +from bilby.core.utils import random as bilby_random + +from dingo.gw.inference.factors import GWSamplerContext, SyntheticPhaseFactor + + +class _MockLikelihood: + """Exposes the two methods the factor uses, deterministic in `chirp_mass`.""" + + def __init__(self): + self.phase_grid = None + + def d_inner_h_complex_multi(self, theta, num_processes=1): + # (2, 2)-approx path: one complex overlap (d | h) per row. + return np.array([complex(cm, 0.5) for cm in theta["chirp_mass"].to_numpy()]) + + def log_likelihood_phase_grid(self, theta): + # exact path: a (n_grid,) log-likelihood per row (theta is a row dict). + return np.cos(self.phase_grid) * float(theta["chirp_mass"]) + + +class _MockContext: + def likelihood(self, **kwargs): + return _MockLikelihood() + + +def _given(n=5): + return {"chirp_mass": torch.linspace(20.0, 40.0, n, dtype=torch.float64)} + + +def _seed(s=0): + np.random.seed(s) + bilby_random.seed(s) + + +def test_parameters_and_conditioning(): + factor = SyntheticPhaseFactor(conditioning=["chirp_mass", "theta_jn"]) + assert factor.parameters == ["phase"] + assert factor.conditioning == ["chirp_mass", "theta_jn"] + + +def test_synthetic_phase_is_one_to_one(): + factor = SyntheticPhaseFactor(conditioning=["chirp_mass"]) + with pytest.raises(ValueError): + factor.sample_and_log_prob(2, _MockContext(), _given()) + + +def test_profile_approx_matches_formula(): + n, n_grid, weight = 5, 257, 0.01 + factor = SyntheticPhaseFactor( + conditioning=["chirp_mass"], + n_grid=n_grid, + approximation_22_mode=True, + uniform_weight=weight, + ) + given = _given(n) + phases, profile = factor._phase_profile(given, _MockContext()) + + kappa = np.array([complex(cm, 0.5) for cm in given["chirp_mass"].numpy()]) + log_posterior = np.outer(kappa, np.exp(2j * phases)).real + expected = np.exp(log_posterior - log_posterior.max(axis=1, keepdims=True)) + expected += expected.mean(axis=1, keepdims=True) * weight + + assert phases.shape == (n_grid,) + assert profile.shape == (n, n_grid) + assert np.allclose(profile, expected) + assert (profile > 0).all() # uniform floor keeps it mass-covering + + +def test_profile_exact_mode_runs(): + n, n_grid = 4, 129 + factor = SyntheticPhaseFactor( + conditioning=["chirp_mass"], n_grid=n_grid, approximation_22_mode=False + ) + phases, profile = factor._phase_profile(_given(n), _MockContext()) + assert phases.shape == (n_grid,) + assert profile.shape == (n, n_grid) + assert (profile > 0).all() + + +def test_sample_shapes_and_range(): + n = 6 + factor = SyntheticPhaseFactor( + conditioning=["chirp_mass"], approximation_22_mode=True + ) + _seed(0) + block, log_prob = factor.sample_and_log_prob(1, _MockContext(), _given(n)) + phase = block["phase"].numpy() + assert set(block) == {"phase"} + assert phase.shape == (n,) and log_prob.shape == (n,) + assert (phase >= 0).all() and (phase <= 2 * np.pi).all() + assert np.isfinite(log_prob.numpy()).all() + + +def test_log_prob_replug_matches_sample(): + # factor.log_prob at the drawn phase equals the sampled log q (same deterministic + # profile, no re-draw). + n = 6 + factor = SyntheticPhaseFactor( + conditioning=["chirp_mass"], approximation_22_mode=True + ) + context, given = _MockContext(), _given(n) + _seed(1) + block, log_prob = factor.sample_and_log_prob(1, context, given) + log_prob_replug = factor.log_prob({"phase": block["phase"]}, context, given) + assert np.allclose(log_prob.numpy(), log_prob_replug.numpy()) + + +def test_context_frequency_override(): + def context(event_metadata): + return GWSamplerContext( + domain=None, + detectors=[], + t_ref=0.0, + data_prep=None, + raw_context={}, + event_metadata=event_metadata, + ) + + with_override = context({"minimum_frequency": 25.0}) + assert with_override._frequency("minimum_frequency", 20.0) == 25.0 + assert with_override._frequency("maximum_frequency", 1024.0) == 1024.0 + assert context(None)._frequency("minimum_frequency", 20.0) == 20.0 From b5fc80810370c20e77e3530b29acf3e281b14c8d Mon Sep 17 00:00:00 2001 From: Stephen Green Date: Thu, 2 Jul 2026 14:21:36 +0200 Subject: [PATCH 20/65] Convert factor docstrings to single backticks and numpy style For the quartodoc (markdown) docs migration: replace RST double-backticks with markdown single backticks across dingo/core/factors.py and dingo/gw/inference/factors.py, and add numpy-style Parameters/Returns blocks to the public constructors and builders. Docstrings only -- no code changes. Co-Authored-By: Claude Opus 4.8 (1M context) --- dingo/core/factors.py | 222 ++++++++++++++++++------------ dingo/gw/inference/factors.py | 249 +++++++++++++++++++++++++--------- 2 files changed, 325 insertions(+), 146 deletions(-) diff --git a/dingo/core/factors.py b/dingo/core/factors.py index 038ea33f7..68a806714 100644 --- a/dingo/core/factors.py +++ b/dingo/core/factors.py @@ -6,11 +6,11 @@ q(theta_1, ..., theta_n | d) = prod_i q_i(theta_i | f_i(theta_ torch.Tensor: - """Map physical ``values`` (a dict of named tensors) to a standardized tensor - with columns in ``names`` order.""" + """Map physical `values` (a dict of named tensors) to a standardized tensor + with columns in `names` order.""" cols = [(values[n] - self.mean[n]) / self.std[n] for n in names] return torch.stack(cols, dim=-1) def destandardize( self, z: torch.Tensor, names: list[str] ) -> dict[str, torch.Tensor]: - """Map a standardized tensor (columns in ``names`` order) back to a dict of + """Map a standardized tensor (columns in `names` order) back to a dict of named physical tensors.""" return {n: z[..., i] * self.std[n] + self.mean[n] for i, n in enumerate(names)} def log_det(self, names: list[str]) -> float: """The term to *add* to a network log-prob to express it in physical space: - ``log p_theta = log p_z - sum log std``. Same correction in both directions.""" + `log p_theta = log p_z - sum log std`. Same correction in both directions.""" return -sum(math.log(self.std[n]) for n in names) @runtime_checkable class SamplerContext(Protocol): """ - Per-event shared state referenced by every factor: the data ``d`` and quantities + Per-event shared state referenced by every factor: the data `d` and quantities derived from it (the prepared-data representation, the likelihood, event metadata). Concrete implementations are domain-specific; see - ``dingo.gw.inference.factors.GWSamplerContext``. + `dingo.gw.inference.factors.GWSamplerContext`. """ event_metadata: Optional[dict] @@ -86,7 +86,7 @@ def likelihood(self): def _cat_dict( batches: list[dict[str, torch.Tensor]], ) -> dict[str, torch.Tensor]: - """Concatenate a list of ``name -> tensor`` dicts along dim 0 (re-joining batches).""" + """Concatenate a list of `name -> tensor` dicts along dim 0 (re-joining batches).""" return {k: torch.cat([b[k] for b in batches]) for k in batches[0]} @@ -95,12 +95,27 @@ def chunk_and_concat( batch_size: Optional[int], run_once: Callable[[int], tuple[dict[str, torch.Tensor], Optional[torch.Tensor]]], ) -> tuple[dict[str, torch.Tensor], Optional[torch.Tensor]]: - """Run ``run_once`` over batches of ``total`` and concatenate the results. + """Run `run_once` over batches of `total` and concatenate the results. - Splits ``total`` into chunks of ``batch_size``, calls ``run_once(n) -> (samples, - log_prob)`` per chunk, and concatenates the sample dicts and log-probs. Caps peak - memory at one chunk. ``batch_size=None`` runs ``total`` in a single call. ``log_prob`` - may be ``None`` (for a composer without a tractable density, i.e. Gibbs).""" + Caps peak memory at one chunk. `log_prob` may be `None` (for a composer without a + tractable density, i.e. Gibbs). + + Parameters + ---------- + total : int + Total number of samples to produce. + batch_size : int, optional + Chunk size; `None` runs `total` in a single call. + run_once : callable + `run_once(n) -> (samples, log_prob)` for one chunk of `n` samples. + + Returns + ------- + samples : dict[str, torch.Tensor] + The concatenated per-name sample tensors. + log_prob : torch.Tensor or None + The concatenated log-probs, or `None` when `run_once` returns `None`. + """ bs = batch_size or total sample_parts: list[dict[str, torch.Tensor]] = [] lp_parts: list[Optional[torch.Tensor]] = [] @@ -115,13 +130,13 @@ def chunk_and_concat( class Factor(ABC): """ - A conditional distribution ``q_i(theta_i | f_i(theta_ (n_rows, n, dim)``. + A call draws `num_samples` samples per conditioning row and returns `n_rows * + num_samples` rows in row-major order, where `n_rows` is the number of rows in + `given` (1 if unconditioned). This mirrors the network's + `sample_and_log_prob(*context, num_samples=n) -> (n_rows, n, dim)`. Attributes ---------- @@ -141,9 +156,9 @@ def sample_and_log_prob( context: SamplerContext, given: Optional[dict[str, torch.Tensor]] = None, ) -> tuple[dict[str, torch.Tensor], torch.Tensor]: - """Draw ``num_samples`` samples per conditioning row; return ``(samples, - log_prob)`` in physical space. The samples dict may include named columns beyond - ``parameters``.""" + """Draw `num_samples` samples per conditioning row; return `(samples, + log_prob)` in physical space. The samples dict may include named columns beyond + `parameters`.""" @abstractmethod def log_prob( @@ -152,13 +167,13 @@ def log_prob( context: SamplerContext, given: Optional[dict[str, torch.Tensor]] = None, ) -> torch.Tensor: - """Evaluate ``log q_i`` at given physical ``theta_i`` (one conditioning row per + """Evaluate `log q_i` at given physical `theta_i` (one conditioning row per row).""" def _base_model_metadata(model: BasePosteriorModel) -> dict: """The training metadata describing the parameter standardization / data settings. - For an unconditional (density-recovery) model this lives under ``metadata["base"]``. + For an unconditional (density-recovery) model this lives under `metadata["base"]`. """ metadata = model.metadata if metadata["train_settings"]["data"].get("unconditional", False): @@ -169,11 +184,11 @@ def _base_model_metadata(model: BasePosteriorModel) -> dict: class FlowFactor(Factor): """ Factor wrapping a posterior model (NPE flow, FMPE, ...). Reaches data only through - ``SamplerContext.prepared_data()`` and encapsulates the network's standardization, so + `SamplerContext.prepared_data()` and encapsulates the network's standardization, so its interface is in physical parameter space. An unconditioned model draws from the shared data context; a model with - ``context_parameters`` conditions on the values in ``given``. + `context_parameters` conditions on the values in `given`. """ def __init__( @@ -184,6 +199,22 @@ def __init__( context_parameters: Optional[list[str]] = None, aliases: Optional[dict[str, str]] = None, ): + """ + Parameters + ---------- + model : BasePosteriorModel + The posterior model (NPE flow, FMPE, ...) wrapped by this factor. + parameters : list[str] + The network's trained parameter names (standardization is keyed by these). + conditioning : list[str], optional + Earlier-block parameters this factor conditions on. + context_parameters : list[str], optional + Network conditioning inputs (GNPE proxies); empty for plain NPE. + aliases : dict[str, str], optional + Trained-name to exposed-name map at the factor boundary (e.g. + `{"ra": "ra@t_ref"}`), so a downstream reparametrization can convert frames + by name without retraining. + """ self.model = model # The network's trained parameter names -- standardization is keyed by these. The # factor exposes them under canonical aliases (e.g. ra -> ra@t_ref), so a downstream @@ -201,9 +232,21 @@ def __init__( def from_model( cls, model: BasePosteriorModel, aliases: Optional[dict[str, str]] = None ) -> "FlowFactor": - """Build a factor from a model, reading ``parameters`` and ``context_parameters`` - from its training metadata. ``aliases`` maps trained names to exposed canonical - names (e.g. ``{"ra": "ra@t_ref"}``) at the factor boundary.""" + """Build a factor from a model, reading `parameters` and `context_parameters` + from its training metadata. + + Parameters + ---------- + model : BasePosteriorModel + The posterior model to wrap. + aliases : dict[str, str], optional + Trained-name to exposed canonical-name map (e.g. `{"ra": "ra@t_ref"}`) at + the factor boundary. + + Returns + ------- + FlowFactor + """ data_settings = _base_model_metadata(model)["train_settings"]["data"] context_parameters = data_settings.get("context_parameters") or [] return cls( @@ -215,9 +258,9 @@ def from_model( ) def sample_and_log_prob(self, num_samples, context, given=None): - """Draw ``num_samples`` samples per conditioning row. Unconditioned: ``num_samples`` - draws from the shared data context. Conditioned: ``num_samples`` per context row, - returning ``n_rows * num_samples`` rows in row-major order.""" + """Draw `num_samples` samples per conditioning row. Unconditioned: `num_samples` + draws from the shared data context. Conditioned: `num_samples` per context row, + returning `n_rows * num_samples` rows in row-major order.""" data = context.prepared_data() self.model.network.eval() if not self.context_parameters: @@ -270,15 +313,15 @@ def log_prob(self, theta_i, context, given=None): class Reparametrization(ABC): """ - A deterministic bijection ``Step``: it transforms existing parameters (no sampling) - and contributes ``-log|det J|`` to the proposal density. + A deterministic bijection `Step`: it transforms existing parameters (no sampling) + and contributes `-log|det J|` to the proposal density. - Unlike a ``Factor`` it is 1:1 and invertible -- ``forward`` maps the conditioning block - to the ``parameters`` block, ``inverse`` maps back (for re-plug / importance sampling), + Unlike a `Factor` it is 1:1 and invertible -- `forward` maps the conditioning block + to the `parameters` block, `inverse` maps back (for re-plug / importance sampling), and its density contribution is a Jacobian, not a sampled log-prob. Used to relate a network's coordinates to physical ones (e.g. right ascension from the training reference - frame to the event frame). Subclasses implement ``forward`` / ``inverse`` and, where the - map is not measure-preserving, ``log_det``. + frame to the event frame). Subclasses implement `forward` / `inverse` and, where the + map is not measure-preserving, `log_det`. """ parameters: list[str] @@ -288,18 +331,18 @@ class Reparametrization(ABC): def forward( self, given: dict[str, torch.Tensor], context: "SamplerContext" ) -> dict[str, torch.Tensor]: - """Map the conditioning block to the ``parameters`` block.""" + """Map the conditioning block to the `parameters` block.""" @abstractmethod def inverse( self, params: dict[str, torch.Tensor], context: "SamplerContext" ) -> dict[str, torch.Tensor]: - """Map the ``parameters`` block back to the conditioning block.""" + """Map the `parameters` block back to the conditioning block.""" def log_det( self, given: dict[str, torch.Tensor], context: "SamplerContext" ) -> torch.Tensor: - """``log|det J|`` of ``forward``, per row. Default 0 (measure-preserving).""" + """`log|det J|` of `forward`, per row. Default 0 (measure-preserving).""" n = next(iter(given.values())).shape[0] return torch.zeros(n) @@ -309,7 +352,7 @@ def sample_and_log_prob( context: SamplerContext, given: Optional[dict[str, torch.Tensor]] = None, ) -> tuple[dict[str, torch.Tensor], torch.Tensor]: - """Apply ``forward`` to the conditioning; contribute ``-log|det J|``. ``num_samples`` + """Apply `forward` to the conditioning; contribute `-log|det J|`. `num_samples` must be 1 (a reparametrization is 1:1).""" if num_samples != 1: raise ValueError("A reparametrization is 1:1; use fan_out=1.") @@ -332,11 +375,11 @@ def log_prob( class TargetCorrection(ABC): """ - A ``Step`` that emits an importance-sampling target correction as a side-channel column + A `Step` that emits an importance-sampling target correction as a side-channel column and contributes nothing to the proposal density. Its value belongs to the IS target, not the proposal: it reads earlier blocks, emits a - named column, optionally consumes intermediates (``consumes``), and adds 0 to the + named column, optionally consumes intermediates (`consumes`), and adds 0 to the proposal log-prob. 1:1. """ @@ -373,11 +416,11 @@ def log_prob( class Step(Protocol): """ - One entry a ``ChainComposer`` folds over: it emits a parameter block and an optional - contribution to the proposal ``log_prob``. + One entry a `ChainComposer` folds over: it emits a parameter block and an optional + contribution to the proposal `log_prob`. - Density-contributing steps (``Factor``) return a tensor; density-free sampling blocks - (``GibbsBlock``) return ``None``. ``parameters`` names the block produced, ``conditioning`` + Density-contributing steps (`Factor`) return a tensor; density-free sampling blocks + (`GibbsBlock`) return `None`. `parameters` names the block produced, `conditioning` the earlier-block parameters read from the chain (data is implicit via the context). """ @@ -394,8 +437,8 @@ def sample_and_log_prob( @dataclass class Stage: - """A chain ``Step`` and its fan-out: the number of samples drawn per incoming - conditioning row. The root stage draws the base count and ignores ``fan_out``.""" + """A chain `Step` and its fan-out: the number of samples drawn per incoming + conditioning row. The root stage draws the base count and ignores `fan_out`.""" step: Step fan_out: int = 1 @@ -403,17 +446,17 @@ class Stage: class ChainComposer: """ - Autoregressive composer over an ordered list of ``Stage``s. + Autoregressive composer over an ordered list of `Stage`s. Folds the steps in declared order -- a topological order of the conditioning DAG -- expanding each by its fan-out and summing the proposal log-probs. A step is a - ``Factor`` (contributes ``log q_i``) or a density-free sampling block (``GibbsBlock``, - contributes ``None``); if any step is density-free the chain has no tractable density - and ``sample`` omits ``log_prob``. Covers plain NPE, single-step GNPE, prior - conditioning, synthetic phase, intrinsic/extrinsic splits, and -- via ``GibbsBlock`` -- + `Factor` (contributes `log q_i`) or a density-free sampling block (`GibbsBlock`, + contributes `None`); if any step is density-free the chain has no tractable density + and `sample` omits `log_prob`. Covers plain NPE, single-step GNPE, prior + conditioning, synthetic phase, intrinsic/extrinsic splits, and -- via `GibbsBlock` -- multi-iteration GNPE. - Accepts bare steps (wrapped as ``Stage(step, fan_out=1)``) or explicit ``Stage``s. + Accepts bare steps (wrapped as `Stage(step, fan_out=1)`) or explicit `Stage`s. """ def __init__(self, stages: list[Union["Stage", Step]]): @@ -423,7 +466,7 @@ def __init__(self, stages: list[Union["Stage", Step]]): def _validate(self): """Check the declared order is a valid topological order: every conditioning name is produced by an earlier step. A step's emitted columns default to its - ``parameters``, but a step may emit side-channel columns too (``produces``).""" + `parameters`, but a step may emit side-channel columns too (`produces`).""" produced: set[str] = set() for step in self.steps: missing = [c for c in step.conditioning if c not in produced] @@ -441,8 +484,8 @@ def steps(self) -> list[Step]: @property def expansion(self) -> int: - """Product of the non-root fan-outs; ``sample`` returns ``num_samples * - expansion`` rows.""" + """Product of the non-root fan-outs; `sample` returns `num_samples * + expansion` rows.""" return math.prod(stage.fan_out for stage in self.stages[1:]) def sample_and_log_prob( @@ -451,9 +494,9 @@ def sample_and_log_prob( context: SamplerContext, batch_size: Optional[int] = None, ) -> tuple[dict[str, torch.Tensor], Optional[torch.Tensor]]: - """Draw samples. ``num_samples`` is the base (root) count; the result has - ``num_samples * expansion`` rows. ``batch_size`` chunks the base count (``None`` - draws in one pass). The log-prob is ``None`` if any step is density-free.""" + """Draw samples. `num_samples` is the base (root) count; the result has + `num_samples * expansion` rows. `batch_size` chunks the base count (`None` + draws in one pass). The log-prob is `None` if any step is density-free.""" return chunk_and_concat( num_samples, batch_size, lambda n: self._run_chain_once(n, context) ) @@ -461,8 +504,8 @@ def sample_and_log_prob( def _run_chain_once( self, base: int, context: SamplerContext ) -> tuple[dict[str, torch.Tensor], Optional[torch.Tensor]]: - """One pass of the whole chain for ``base`` root samples. Returns the samples and - the summed proposal log-prob, or ``None`` if any step is density-free.""" + """One pass of the whole chain for `base` root samples. Returns the samples and + the summed proposal log-prob, or `None` if any step is density-free.""" samples: dict[str, torch.Tensor] = {} total: torch.Tensor | float = 0.0 has_density = True @@ -501,8 +544,8 @@ def _run_chain_once( def log_prob( self, samples: dict[str, torch.Tensor], context: SamplerContext ) -> torch.Tensor: - """Sum each step's ``log_prob`` at the given samples. Valid only for an all-density - chain (every step a ``Factor``).""" + """Sum each step's `log_prob` at the given samples. Valid only for an all-density + chain (every step a `Factor`).""" total: torch.Tensor | float = 0.0 for step in self.steps: given = {k: samples[k] for k in step.conditioning} @@ -516,7 +559,7 @@ def sample( context: SamplerContext, batch_size: Optional[int] = None, ) -> dict[str, torch.Tensor]: - """Per-sample dict of parameters, plus ``log_prob`` for an all-density chain.""" + """Per-sample dict of parameters, plus `log_prob` for an all-density chain.""" samples, log_prob = self.sample_and_log_prob(num_samples, context, batch_size) if log_prob is None: return dict(samples) @@ -525,22 +568,33 @@ def sample( class GibbsBlock: """ - A density-free sampling-block ``Step``: runs blocked Gibbs internally and yields no + A density-free sampling-block `Step`: runs blocked Gibbs internally and yields no proposal log-prob. Seeds the chain with an init factor, then sweeps the factor list in order for - ``num_iterations`` iterations; each factor conditions on the current state and - overwrites its own block. As a chain ``Step`` it produces the swept parameter blocks - and returns ``None`` for the log-prob -- the cyclic dependency has no tractable marginal + `num_iterations` iterations; each factor conditions on the current state and + overwrites its own block. As a chain `Step` it produces the swept parameter blocks + and returns `None` for the log-prob -- the cyclic dependency has no tractable marginal (recoverable by fitting an unconditional density to the samples and taking one - ``ChainComposer`` step). Dingo uses this only for multi-iteration GNPE (the GNPE factors - in ``dingo.gw.inference.factors``), but the loop is generic. + `ChainComposer` step). Dingo uses this only for multi-iteration GNPE (the GNPE factors + in `dingo.gw.inference.factors`), but the loop is generic. - Batching is handled by the enclosing ``ChainComposer``: it chunks the walkers and runs - the whole loop per chunk (``chunk_and_concat``). + Batching is handled by the enclosing `ChainComposer`: it chunks the walkers and runs + the whole loop per chunk (`chunk_and_concat`). """ def __init__(self, init_factor: Factor, factors: list[Factor], num_iterations: int): + """ + Parameters + ---------- + init_factor : Factor + Seeds the chain (e.g. an init network's detector times). + factors : list[Factor] + The factors swept in order each iteration; each conditions on the current + state and overwrites its own block. + num_iterations : int + Number of Gibbs sweeps. + """ self.init_factor = init_factor self.factors = list(factors) self.num_iterations = num_iterations @@ -555,8 +609,8 @@ def sample_and_log_prob( context: SamplerContext, given: Optional[dict[str, torch.Tensor]] = None, ) -> tuple[dict[str, torch.Tensor], None]: - """Run the Gibbs loop for ``num_samples`` walkers; return ``(samples, None)``. - ``num_samples`` is the walker (root) count -- Gibbs does not fan out.""" + """Run the Gibbs loop for `num_samples` walkers; return `(samples, None)`. + `num_samples` is the walker (root) count -- Gibbs does not fan out.""" return self._run_once(num_samples, context), None def _run_once( @@ -576,7 +630,7 @@ def _run_once( class ComposedSampler: """ - Generic runner over a ``ChainComposer`` and a ``SamplerContext``: draws samples and + Generic runner over a `ChainComposer` and a `SamplerContext`: draws samples and returns them as a DataFrame. Domain-specific processing lives in the chain's steps, so the runner is domain-agnostic. """ @@ -589,8 +643,8 @@ def __init__(self, composer: ChainComposer, context: SamplerContext): def run_sampler( self, num_samples: int, batch_size: Optional[int] = None ) -> pd.DataFrame: - """Draw ``num_samples`` samples (chunked by ``batch_size``) and return them as a - DataFrame. An all-density chain includes ``log_prob``; a chain with a ``GibbsBlock`` + """Draw `num_samples` samples (chunked by `batch_size`) and return them as a + DataFrame. An all-density chain includes `log_prob`; a chain with a `GibbsBlock` step does not.""" merged = self.composer.sample(num_samples, self.context, batch_size) merged = {k: v.cpu().numpy() for k, v in merged.items()} diff --git a/dingo/gw/inference/factors.py b/dingo/gw/inference/factors.py index 4c930ea53..2dd76195f 100644 --- a/dingo/gw/inference/factors.py +++ b/dingo/gw/inference/factors.py @@ -1,11 +1,11 @@ """ Gravitational-wave factors, steps, and samplers for the factorized sampler. -The domain-agnostic pieces (``Factor``, ``FlowFactor``, ``Reparametrization``, -``TargetCorrection``, and the composers) live in ``dingo.core.factors``. This module adds -the GW-specific ones: ``GWSamplerContext`` (the data-preprocessing conditioning map), the -GNPE factors and steps (``GNPEKernelFactor``, ``GNPEFlowFactor``, ``GNPEKernelCorrection``), -the ``RAReparam`` sky-frame reparametrization, and ``GWComposedSampler``, which assembles +The domain-agnostic pieces (`Factor`, `FlowFactor`, `Reparametrization`, +`TargetCorrection`, and the composers) live in `dingo.core.factors`. This module adds +the GW-specific ones: `GWSamplerContext` (the data-preprocessing conditioning map), the +GNPE factors and steps (`GNPEKernelFactor`, `GNPEFlowFactor`, `GNPEKernelCorrection`), +the `RAReparam` sky-frame reparametrization, and `GWComposedSampler`, which assembles the chain for plain NPE, multi-iteration GNPE, or single-step GNPE. """ @@ -64,13 +64,13 @@ class GWSamplerContext: """ - Per-event shared GW state: the data ``d`` and everything derived from it. Referenced + Per-event shared GW state: the data `d` and everything derived from it. Referenced by every factor in a chain, and serialized as the transport state between pipe stages. - This implements the ``dingo.core.factors.SamplerContext`` protocol. It owns the - one-time data preprocessing (``prepared_data``) and builds the exact likelihood - (``likelihood``) used by likelihood-based factors (synthetic phase) and importance + This implements the `dingo.core.factors.SamplerContext` protocol. It owns the + one-time data preprocessing (`prepared_data`) and builds the exact likelihood + (`likelihood`) used by likelihood-based factors (synthetic phase) and importance sampling. Event metadata lives here (not on individual factors): it is a property of the data, @@ -87,16 +87,33 @@ def __init__( event_metadata: Optional[dict] = None, base_metadata: Optional[dict] = None, ): + """ + Parameters + ---------- + domain : Domain + The data frequency domain. + detectors : list[str] + Detector names. + t_ref : float + Training reference GPS time. + data_prep : Compose + The one-time data-preprocessing transform chain (whiten / decimate / + repackage). + raw_context : dict + The raw event data `d` (strain + ASDs per detector), i.e. `EventDataset.data`. + Consumed lazily by `prepared_data()` and reused for the likelihood. + event_metadata : dict, optional + Per-event metadata; drives frequency cropping, the RA correction, and the + likelihood reference time. + base_metadata : dict, optional + The base (parameter / waveform / domain) metadata, used to build the + likelihood. + """ self.domain = domain self.detectors = detectors self.t_ref = t_ref self._data_prep = data_prep - # The base (parameter/waveform/domain) metadata, used to build the likelihood. self.base_metadata = base_metadata - # The raw event data `d` (strain + ASDs per detector), i.e. EventDataset.data -- - # the same object set as GWSampler.context today. Consumed lazily by - # prepared_data(); also retained for the likelihood (synthetic-phase / IS factors) - # and as part of the serialized transport state, neither of which is wired yet. self.raw_context = raw_context self.event_metadata = event_metadata self._prepared: Optional[torch.Tensor] = None @@ -108,9 +125,23 @@ def from_model( raw_context: dict, event_metadata: Optional[dict] = None, ) -> "GWSamplerContext": - """Build the context (domain + one-time data-prep chain) from a model's - metadata. The data-prep chain reproduces ``GWSampler`` preprocessing for the - plain-NPE case (parameter-dependent transforms are handled per-factor).""" + """Build the context (domain + one-time data-prep chain) from a model's metadata. + The data-prep chain reproduces `GWSampler` preprocessing for the plain-NPE case + (parameter-dependent transforms are handled per-factor). + + Parameters + ---------- + model : BasePosteriorModel + The model whose metadata defines the domain and preprocessing. + raw_context : dict + The raw event data (strain + ASDs). + event_metadata : dict, optional + Per-event metadata. + + Returns + ------- + GWSamplerContext + """ meta = _base_model_metadata(model) data_settings = meta["train_settings"]["data"] @@ -273,7 +304,7 @@ def _marginalized_prior(self, name: str): class DeltaFactor(Factor): - """``q_i = delta(theta_i - c)``: a point mass pinning parameters to fixed values, + """`q_i = delta(theta_i - c)`: a point mass pinning parameters to fixed values, contributing 0 to the proposal log-prob. Used as the chain root for prior-conditioning or known proxies (single-step GNPE, where @@ -414,7 +445,7 @@ def _phase_profile(self, given, context): def _build_gnpe_transforms(model: BasePosteriorModel): """Build the time-shift GNPE per-step transforms from a model's metadata (the same - chains as ``GWSamplerGNPE._initialize_transforms``). + chains as `GWSamplerGNPE._initialize_transforms`). Returns ------- @@ -425,7 +456,7 @@ def _build_gnpe_transforms(model: BasePosteriorModel): kernel : PriorDict The proxy perturbation kernel. gnpe_transform : GNPECoalescenceTimes - The blur transform itself, shared so the kernel factor can call ``sample_proxies``. + The blur transform itself, shared so the kernel factor can call `sample_proxies`. """ meta = _base_model_metadata(model) data_settings = meta["train_settings"]["data"] @@ -491,17 +522,25 @@ def _build_gnpe_transforms(model: BasePosteriorModel): class GNPEKernelFactor(Factor): """ - The GNPE perturbation kernel ``p(theta_hat | theta)`` as a non-network factor. + The GNPE perturbation kernel `p(theta_hat | theta)` as a non-network factor. - ``theta`` are the detector coalescence times; the kernel adds a bounded perturbation to - each, giving the proxies ``theta_hat`` the main network conditions on. The parameter + `theta` are the detector coalescence times; the kernel adds a bounded perturbation to + each, giving the proxies `theta_hat` the main network conditions on. The parameter block is the proxies, the conditioning is the detector times. - ``sample_and_log_prob`` blurs the times into proxies (the proxy update of a Gibbs - sweep); ``log_prob`` returns the kernel density ``log p(theta_hat | theta)`` at the + `sample_and_log_prob` blurs the times into proxies (the proxy update of a Gibbs + sweep); `log_prob` returns the kernel density `log p(theta_hat | theta)` at the proxies and the detector times. One proxy per detector-time row. """ def __init__(self, gnpe_transform: GNPEBase, gnpe_parameters: list[str]): + """ + Parameters + ---------- + gnpe_transform : GNPEBase + The blur transform supplying the kernel and `sample_proxies`. + gnpe_parameters : list[str] + The detector-time parameters perturbed into proxies. + """ self.gnpe = gnpe_transform self.gnpe_parameters = gnpe_parameters self.parameters = [p + "_proxy" for p in gnpe_parameters] @@ -515,7 +554,7 @@ def from_model(cls, model: BasePosteriorModel) -> "GNPEKernelFactor": return cls(gnpe_transform, gnpe_parameters) def sample_and_log_prob(self, num_samples, context, given=None): - """Blur the conditioning detector times into proxies; ``num_samples`` must be 1 + """Blur the conditioning detector times into proxies; `num_samples` must be 1 (GNPE is 1:1). Returns the proxies and their kernel log-prob.""" if num_samples != 1: raise ValueError("GNPE proxy is 1:1; use fan_out=1.") @@ -524,8 +563,8 @@ def sample_and_log_prob(self, num_samples, context, given=None): return proxies, self.log_prob(proxies, context, given) def log_prob(self, theta_i, context, given=None): - """``log p(theta_hat | theta)`` from the kernel, at the proxies (``theta_i``) and - the detector times (``given``).""" + """`log p(theta_hat | theta)` from the kernel, at the proxies (`theta_i`) and + the detector times (`given`).""" diffs = {} for k in self.kernel.keys(): diff = given[k] - theta_i[f"{k}_proxy"] @@ -537,16 +576,16 @@ def log_prob(self, theta_i, context, given=None): class GNPEFlowFactor(Factor): """ - The GNPE main network ``q(theta | theta_hat, d)`` as a factor. + The GNPE main network `q(theta | theta_hat, d)` as a factor. - Conditions on the detector-time proxies from ``GNPEKernelFactor``: it shifts each + Conditions on the detector-time proxies from `GNPEKernelFactor`: it shifts each detector's strain by the corresponding proxy time (standardizing the network input), samples the network, and recomputes the detector times from the sampled sky position and geocent time. The proxies are supplied, so no blurring happens here. - The single network factor in either GNPE mode: cycled by a ``GibbsBlock`` for - multi-iteration GNPE, or a ``ChainComposer`` factor for single-step GNPE. The recomputed - detector times are emitted as extra columns (``produces``): the next Gibbs iteration + The single network factor in either GNPE mode: cycled by a `GibbsBlock` for + multi-iteration GNPE, or a `ChainComposer` factor for single-step GNPE. The recomputed + detector times are emitted as extra columns (`produces`): the next Gibbs iteration blurs them into fresh proxies, and single-step GNPE evaluates the kernel correction at them. One sample per proxy row. """ @@ -560,6 +599,23 @@ def __init__( parameters: list[str], aliases: Optional[dict[str, str]] = None, ): + """ + Parameters + ---------- + model : BasePosteriorModel + The GNPE main network. + transform_pre : Compose + Per-iteration pre-network transforms (proxy bookkeeping, time shift, + standardization). + transform_post : Compose + Post-network transforms (de-standardize, recompute detector times). + gnpe_parameters : list[str] + The detector-time parameters. + parameters : list[str] + The network's trained inference parameters. + aliases : dict[str, str], optional + Trained-name to exposed-name map (e.g. `{"ra": "ra@t_ref"}`). + """ self.model = model self.transform_pre = transform_pre self.transform_post = transform_post @@ -580,7 +636,17 @@ def from_model( cls, model: BasePosteriorModel, aliases: Optional[dict[str, str]] = None ) -> "GNPEFlowFactor": """Build the GNPE per-iteration transforms from the main model's metadata. - ``aliases`` maps trained names to canonical names (e.g. ``{"ra": "ra@t_ref"}``). + + Parameters + ---------- + model : BasePosteriorModel + The GNPE main model. + aliases : dict[str, str], optional + Trained-name to canonical-name map (e.g. `{"ra": "ra@t_ref"}`). + + Returns + ------- + GNPEFlowFactor """ pre, post, gnpe_parameters, inference_parameters, _, _ = _build_gnpe_transforms( model @@ -590,7 +656,7 @@ def from_model( ) def sample_and_log_prob(self, num_samples, context, given=None): - """Sample one parameter set per proxy row; ``num_samples`` must be 1 (GNPE is 1:1). + """Sample one parameter set per proxy row; `num_samples` must be 1 (GNPE is 1:1). Returns theta plus the recomputed detector times, and the network log-prob.""" if num_samples != 1: raise ValueError("GNPE is 1:1; draw one sample per proxy (fan_out=1).") @@ -628,14 +694,14 @@ def log_prob(self, theta_i, context, given=None): class RAReparam(Reparametrization): """ - Rotate right ascension from the network's training reference frame (``ra@t_ref``) to the - event frame (``ra``). + Rotate right ascension from the network's training reference frame (`ra@t_ref`) to the + event frame (`ra`). The network is trained at a fixed reference time; an event at a different GPS time needs the sky rotated by the sidereal-time difference. This is a measure-preserving shift - modulo 2*pi (``log_det = 0``), so it contributes nothing to the density. ``forward`` - produces the event-frame ``ra``, ``inverse`` recovers ``ra@t_ref``. The sidereal - correction is read from the shared context (``t_ref`` and the event time). + modulo 2*pi (`log_det = 0`), so it contributes nothing to the density. `forward` + produces the event-frame `ra`, `inverse` recovers `ra@t_ref`. The sidereal + correction is read from the shared context (`t_ref` and the event time). """ def __init__(self): @@ -680,9 +746,9 @@ class GNPEKernelCorrection(TargetCorrection): """ The single-step GNPE kernel correction as a target-side chain step. - Emits ``delta_log_prob_target = log p(theta_hat | theta)`` -- the GNPE kernel evaluated + Emits `delta_log_prob_target = log p(theta_hat | theta)` -- the GNPE kernel evaluated at the proxies and the detector times recomputed from theta -- for importance sampling - on the joint proposal ``q(theta, theta_hat | d)``. Contributes 0 to the proposal + on the joint proposal `q(theta, theta_hat | d)`. Contributes 0 to the proposal density and consumes the intermediate detector times. """ @@ -702,18 +768,18 @@ def correction(self, given, context): def _ra_aliases(inference_parameters: list[str]) -> dict[str, str]: - """The RA frame alias (``ra`` -> ``ra@t_ref``), applied only when the model infers - ``ra``; paired with an ``RAReparam`` step that maps it back to the event frame.""" + """The RA frame alias (`ra` -> `ra@t_ref`), applied only when the model infers + `ra`; paired with an `RAReparam` step that maps it back to the event frame.""" return {"ra": "ra@t_ref"} if "ra" in inference_parameters else {} def _ra_reparam_steps(inference_parameters: list[str]) -> list: - """The ``RAReparam`` step, appended to a chain only when the model infers ``ra``.""" + """The `RAReparam` step, appended to a chain only when the model infers `ra`.""" return [RAReparam()] if "ra" in inference_parameters else [] def _delta_prior_steps(metadata: dict, inference_parameters: list[str]) -> list: - """Delta-prior parameters the chain does not produce, as a single ``DeltaFactor`` step + """Delta-prior parameters the chain does not produce, as a single `DeltaFactor` step (or none). These are pinned constants (e.g. an aligned-spin component fixed to 0). """ intrinsic_prior = metadata["dataset_settings"]["intrinsic_prior"] @@ -731,9 +797,9 @@ def _delta_prior_steps(metadata: dict, inference_parameters: list[str]) -> list: class GWComposedSampler(ComposedSampler): """ - GW builder and exporter over the generic ``ComposedSampler`` runner. The ``from_*`` + GW builder and exporter over the generic `ComposedSampler` runner. The `from_*` constructors assemble the chain for plain NPE, multi-iteration GNPE, or single-step - GNPE from model metadata; ``to_result`` exports the samples to a gw ``Result``. All + GNPE from model metadata; `to_result` exports the samples to a gw `Result`. All GW-specific processing (RA frame, fixed parameters, kernel correction) is expressed as chain steps, so there is no post-processing. """ @@ -745,6 +811,18 @@ def __init__( metadata: dict, inference_parameters: list[str], ): + """ + Parameters + ---------- + composer : ChainComposer + The assembled chain of steps. + context : GWSamplerContext + Per-event shared state. + metadata : dict + Model metadata, carried through to the exported `Result`. + inference_parameters : list[str] + The inferred parameter names. + """ super().__init__(composer, context) self.metadata = metadata self.inference_parameters = inference_parameters @@ -757,7 +835,21 @@ def from_model( event_metadata: Optional[dict] = None, ) -> "GWComposedSampler": """Build a plain-NPE GW sampler from a model and event data: the flow exposes - ``ra`` as ``ra@t_ref``, followed by an ``RAReparam`` to the event frame.""" + `ra` as `ra@t_ref`, followed by an `RAReparam` to the event frame. + + Parameters + ---------- + model : BasePosteriorModel + The NPE model. + raw_context : dict + The raw event data (strain + ASDs). + event_metadata : dict, optional + Per-event metadata. + + Returns + ------- + GWComposedSampler + """ context = GWSamplerContext.from_model(model, raw_context, event_metadata) metadata = _base_model_metadata(model) inference_parameters = metadata["train_settings"]["data"][ @@ -786,10 +878,29 @@ def from_gnpe_models( num_iterations: int = 30, ) -> "GWComposedSampler": """Build a multi-iteration time-GNPE sampler from an init + main model pair: the - init model's data preprocessing, an init ``FlowFactor`` to seed, and a single - ``GibbsBlock`` step -- cycling the GNPE kernel and main-network factors -- in a - ``ChainComposer``, then an ``RAReparam`` to the event frame. Returns samples without - a log_prob (Gibbs breaks density access).""" + init model's data preprocessing, an init `FlowFactor` to seed, and a single + `GibbsBlock` step -- cycling the GNPE kernel and main-network factors -- in a + `ChainComposer`, then an `RAReparam` to the event frame. Returns samples without + a log_prob (Gibbs breaks density access). + + Parameters + ---------- + init_model : BasePosteriorModel + The init network (detector times); seeds the Gibbs loop and defines the data + preprocessing. + main_model : BasePosteriorModel + The GNPE main network. + raw_context : dict + The raw event data (strain + ASDs). + event_metadata : dict, optional + Per-event metadata. + num_iterations : int, default 30 + Number of Gibbs sweeps. + + Returns + ------- + GWComposedSampler + """ context = GWSamplerContext.from_model(init_model, raw_context, event_metadata) metadata = _base_model_metadata(main_model) inference_parameters = metadata["train_settings"]["data"][ @@ -821,12 +932,26 @@ def from_singlestep_gnpe( raw_context: dict, event_metadata: Optional[dict] = None, ) -> "GWComposedSampler": - """Build a single-step (density-preserving) time-GNPE sampler: a ``ChainComposer`` - of ``[proxy_source, GNPEFlowFactor, GNPEKernelCorrection, RAReparam]``. - ``proxy_source`` supplies the detector-time proxies -- a ``DeltaFactor`` for prior - conditioning (BNS), or an unconditional NDE for density recovery. The chain is - autoregressive, so log_prob is preserved, and ``GNPEKernelCorrection`` emits the - ``delta_log_prob_target`` correction that importance sampling adds to the target. + """Build a single-step (density-preserving) time-GNPE sampler: a `ChainComposer` + of `[proxy_source, GNPEFlowFactor, GNPEKernelCorrection, RAReparam]`. The chain is + autoregressive, so log_prob is preserved, and `GNPEKernelCorrection` emits the + `delta_log_prob_target` correction that importance sampling adds to the target. + + Parameters + ---------- + main_model : BasePosteriorModel + The GNPE main network. + proxy_source : Factor + Supplies the detector-time proxies -- a `DeltaFactor` for prior conditioning + (BNS), or an unconditional NDE for density recovery. + raw_context : dict + The raw event data (strain + ASDs). + event_metadata : dict, optional + Per-event metadata. + + Returns + ------- + GWComposedSampler """ context = GWSamplerContext.from_model(main_model, raw_context, event_metadata) metadata = _base_model_metadata(main_model) @@ -845,12 +970,12 @@ def from_singlestep_gnpe( return cls(ChainComposer(steps), context, metadata, inference_parameters) def to_result(self): - """Export to a gw ``Result`` (samples + raw event data + metadata), so the + """Export to a gw `Result` (samples + raw event data + metadata), so the existing post-processing pipeline -- synthetic phase, importance sampling, evidence, plotting -- runs on the factorized sampler's output unchanged. - ``context`` is the *raw* event-data dict (``GWSamplerContext.raw_context``, what - ``Result`` needs to rebuild the likelihood), not the ``SamplerContext`` object. + `context` is the *raw* event-data dict (`GWSamplerContext.raw_context`, what + `Result` needs to rebuild the likelihood), not the `SamplerContext` object. """ from dingo.gw.result import Result From 211c390c50c121281f9b70f09e08f63fd969398e Mon Sep 17 00:00:00 2001 From: Stephen Green Date: Fri, 3 Jul 2026 17:15:08 +0200 Subject: [PATCH 21/65] Add GWSamplerContext.prior as the static prior owner Introduce a cached prior property on GWSamplerContext that builds the static intrinsic+extrinsic prior from model metadata, and route _marginalized_prior through it. First step of the context / Result-diet consolidation: the static prior gets a single home on the per-event context. Defer the _delta_prior_steps and Result._build_prior call sites to the Result-diet step; in the GNPE builder the context is built from the init model while those read the main model metadata, so unifying them needs that resolved first. Bit-exact: 30/30 factor CI, plus verify_likelihood_context and verify_integrated (NPE+GNPE) at max|delta|=0. Co-Authored-By: Claude Opus 4.8 (1M context) --- dingo/gw/inference/factors.py | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/dingo/gw/inference/factors.py b/dingo/gw/inference/factors.py index 2dd76195f..ecf0314e6 100644 --- a/dingo/gw/inference/factors.py +++ b/dingo/gw/inference/factors.py @@ -117,6 +117,7 @@ def __init__( self.raw_context = raw_context self.event_metadata = event_metadata self._prepared: Optional[torch.Tensor] = None + self._prior: Optional[PriorDict] = None @classmethod def from_model( @@ -182,6 +183,24 @@ def prepared_data(self) -> torch.Tensor: self._prepared = self._data_prep(self.raw_context) return self._prepared + @property + def prior(self) -> PriorDict: + """The static prior over all parameters, built once from the model metadata + (intrinsic + extrinsic priors with Dingo defaults). + + This is the event-independent prior fixed at training time. Importance-sampling + prior-bound updates and the time / phase split-off for marginalized networks are + applied downstream (they depend on the evolving analysis state), not here. + """ + if self._prior is None: + data_settings = self.base_metadata["train_settings"]["data"] + intrinsic_prior = self.base_metadata["dataset_settings"]["intrinsic_prior"] + extrinsic_prior = get_extrinsic_prior_dict(data_settings["extrinsic_prior"]) + self._prior = build_prior_with_defaults( + {**intrinsic_prior, **extrinsic_prior} + ) + return self._prior + def likelihood( self, time_marginalization_kwargs: Optional[dict] = None, @@ -294,13 +313,9 @@ def _marginalized_prior(self, name: str): """The prior over `name` if the network marginalized it (i.e. `name` is not an inference parameter), else `None` -- used to parameterize likelihood marginalization over time / phase.""" - data_settings = self.base_metadata["train_settings"]["data"] - if name in data_settings["inference_parameters"]: + if name in self.base_metadata["train_settings"]["data"]["inference_parameters"]: return None - intrinsic_prior = self.base_metadata["dataset_settings"]["intrinsic_prior"] - extrinsic_prior = get_extrinsic_prior_dict(data_settings["extrinsic_prior"]) - prior = build_prior_with_defaults({**intrinsic_prior, **extrinsic_prior}) - return prior.get(name) + return self.prior.get(name) class DeltaFactor(Factor): From 028108c4962b4ff724d27fa11a4630c7d7f55956 Mon Sep 17 00:00:00 2001 From: Stephen Green Date: Fri, 3 Jul 2026 18:51:10 +0200 Subject: [PATCH 22/65] Route the prior through GWSamplerContext as the single owner Complete the prior consolidation started in 2b (context.prior), part of the Result diet (2c): - Build the multi-iteration GNPE context from the main model (it owns the analysis), guarded by a check that the init and main models agree on the shared data-prep view (domain, detectors, reference time). - _delta_prior_steps and Result._build_prior now source the static prior from context.prior. to_result passes the live GWSamplerContext as a new sampler_context arg; Result deepcopies it (the marginalization split-off mutates the prior) and still self-builds when loaded from file (no context). Bit-exact / pass: verify_integrated (NPE+GNPE), verify_singlestep_packaged, verify_prior_delegation, 30/30 factor CI. Co-Authored-By: Claude Opus 4.8 (1M context) --- dingo/core/result.py | 9 ++-- dingo/gw/inference/factors.py | 81 +++++++++++++++++++++++++++++------ dingo/gw/result.py | 40 +++++++++++------ 3 files changed, 101 insertions(+), 29 deletions(-) diff --git a/dingo/core/result.py b/dingo/core/result.py index 67017271a..8f9696052 100644 --- a/dingo/core/result.py +++ b/dingo/core/result.py @@ -82,11 +82,16 @@ class Result(DingoDataset): dataset_type = "core_result" - def __init__(self, file_name=None, dictionary=None): + def __init__(self, file_name=None, dictionary=None, sampler_context=None): self.event_metadata = None self.context = None self.samples = None self.log_noise_evidence = None + # Live sampler context (a GWSamplerContext), set when a sampler builds the + # Result rather than loading it from file. When present, _build_prior (and, + # later, _build_likelihood) delegate to it; it is not serialized, so a Result + # loaded from file has sampler_context=None and self-builds as before. + self.sampler_context = sampler_context super().__init__( file_name=file_name, dictionary=dictionary, @@ -1090,5 +1095,3 @@ def freeze(d): elif isinstance(d, list): return tuple(freeze(value) for value in d) return d - - diff --git a/dingo/gw/inference/factors.py b/dingo/gw/inference/factors.py index ecf0314e6..18757cf5a 100644 --- a/dingo/gw/inference/factors.py +++ b/dingo/gw/inference/factors.py @@ -793,15 +793,18 @@ def _ra_reparam_steps(inference_parameters: list[str]) -> list: return [RAReparam()] if "ra" in inference_parameters else [] -def _delta_prior_steps(metadata: dict, inference_parameters: list[str]) -> list: +def _delta_prior_steps(prior, inference_parameters: list[str]) -> list: """Delta-prior parameters the chain does not produce, as a single `DeltaFactor` step (or none). These are pinned constants (e.g. an aligned-spin component fixed to 0). + + Parameters + ---------- + prior : PriorDict + The static prior (`GWSamplerContext.prior`); its delta-function entries that are + not inference parameters become the pinned constants. + inference_parameters : list of str + The inferred parameter names. """ - intrinsic_prior = metadata["dataset_settings"]["intrinsic_prior"] - extrinsic_prior = get_extrinsic_prior_dict( - metadata["train_settings"]["data"]["extrinsic_prior"] - ) - prior = build_prior_with_defaults({**intrinsic_prior, **extrinsic_prior}) fixed = { k: p.peak for k, p in prior.items() @@ -810,6 +813,52 @@ def _delta_prior_steps(metadata: dict, inference_parameters: list[str]) -> list: return [DeltaFactor(fixed)] if fixed else [] +def _assert_consistent_gnpe_data_prep(init_model, main_model): + """Assert the init and main GNPE models agree on the data-preprocessing view. + + Multi-iteration GNPE shares one `GWSamplerContext` (built from the main model) + between the init and main factors, so both read the same `prepared_data()` and + reference time. That is only valid when the two models agree on everything that + determines those: the domain, the detectors, and the reference time. Raises + `ValueError` on any mismatch. + + Parameters + ---------- + init_model, main_model : BasePosteriorModel + The GNPE init and main networks. + """ + init = _base_model_metadata(init_model) + main = _base_model_metadata(main_model) + fields = { + "domain": ( + init["dataset_settings"]["domain"], + main["dataset_settings"]["domain"], + ), + "domain_update": ( + init["train_settings"]["data"].get("domain_update"), + main["train_settings"]["data"].get("domain_update"), + ), + "detectors": ( + init["train_settings"]["data"]["detectors"], + main["train_settings"]["data"]["detectors"], + ), + "ref_time": ( + init["train_settings"]["data"]["ref_time"], + main["train_settings"]["data"]["ref_time"], + ), + } + mismatched = {k: (i, m) for k, (i, m) in fields.items() if i != m} + if mismatched: + details = "; ".join( + f"{k}: init={i!r} vs main={m!r}" for k, (i, m) in mismatched.items() + ) + raise ValueError( + f"GNPE init and main models disagree on the data-preprocessing view " + f"({details}). They share one context, so they must agree on the domain, " + f"detectors, and reference time." + ) + + class GWComposedSampler(ComposedSampler): """ GW builder and exporter over the generic `ComposedSampler` runner. The `from_*` @@ -874,7 +923,7 @@ def from_model( steps = ( [factor] + _ra_reparam_steps(inference_parameters) - + _delta_prior_steps(metadata, inference_parameters) + + _delta_prior_steps(context.prior, inference_parameters) ) return cls( composer=ChainComposer(steps), @@ -916,7 +965,11 @@ def from_gnpe_models( ------- GWComposedSampler """ - context = GWSamplerContext.from_model(init_model, raw_context, event_metadata) + _assert_consistent_gnpe_data_prep(init_model, main_model) + # Build the context from the main model: it owns the analysis (likelihood, + # prior, inference parameters). The init model shares the data domain and + # preprocessing (asserted above), so prepared_data() is identical either way. + context = GWSamplerContext.from_model(main_model, raw_context, event_metadata) metadata = _base_model_metadata(main_model) inference_parameters = metadata["train_settings"]["data"][ "inference_parameters" @@ -930,7 +983,7 @@ def from_gnpe_models( steps = ( [gibbs] + _ra_reparam_steps(inference_parameters) - + _delta_prior_steps(metadata, inference_parameters) + + _delta_prior_steps(context.prior, inference_parameters) ) return cls( ChainComposer(steps), @@ -980,7 +1033,7 @@ def from_singlestep_gnpe( steps = ( [proxy_source, flow_factor, GNPEKernelCorrection(kernel_factor)] + _ra_reparam_steps(inference_parameters) - + _delta_prior_steps(metadata, inference_parameters) + + _delta_prior_steps(context.prior, inference_parameters) ) return cls(ChainComposer(steps), context, metadata, inference_parameters) @@ -989,8 +1042,10 @@ def to_result(self): existing post-processing pipeline -- synthetic phase, importance sampling, evidence, plotting -- runs on the factorized sampler's output unchanged. - `context` is the *raw* event-data dict (`GWSamplerContext.raw_context`, what - `Result` needs to rebuild the likelihood), not the `SamplerContext` object. + The raw event-data dict (`GWSamplerContext.raw_context`) is stored as the + `Result` context (serialized), and the live `GWSamplerContext` is passed as + `sampler_context` so `Result` can pull the prior (and, later, the likelihood) + from it rather than rebuilding them from metadata. """ from dingo.gw.result import Result @@ -1003,4 +1058,4 @@ def to_result(self): "log_noise_evidence": None, "settings": copy.deepcopy(self.metadata), } - return Result(dictionary=data_dict) + return Result(dictionary=data_dict, sampler_context=self.context) diff --git a/dingo/gw/result.py b/dingo/gw/result.py index 51616032d..6e3b3bf0f 100644 --- a/dingo/gw/result.py +++ b/dingo/gw/result.py @@ -214,11 +214,18 @@ def _rebuild_domain(self, verbose=False): def _build_prior(self): """Build the prior based on model metadata. Called by __init__().""" - intrinsic_prior = self.base_metadata["dataset_settings"]["intrinsic_prior"] - extrinsic_prior = get_extrinsic_prior_dict( - self.base_metadata["train_settings"]["data"]["extrinsic_prior"] - ) - self.prior = build_prior_with_defaults({**intrinsic_prior, **extrinsic_prior}) + if self.sampler_context is not None: + # Delegate the static prior to the sampler context (the single owner). + # Deepcopy because the marginalization split-off below mutates it. + self.prior = copy.deepcopy(self.sampler_context.prior) + else: + intrinsic_prior = self.base_metadata["dataset_settings"]["intrinsic_prior"] + extrinsic_prior = get_extrinsic_prior_dict( + self.base_metadata["train_settings"]["data"]["extrinsic_prior"] + ) + self.prior = build_prior_with_defaults( + {**intrinsic_prior, **extrinsic_prior} + ) prior_update = self.importance_sampling_metadata.get("prior_update") if prior_update is not None: @@ -415,13 +422,18 @@ def sample_calibration_parameters(self, calibration_sampling_kwargs: dict): self.calibration_sampling_kwargs = calibration_sampling_kwargs # Handle correction_type defaults - correction_type = self.calibration_sampling_kwargs.get("correction_type", "data") + correction_type = self.calibration_sampling_kwargs.get( + "correction_type", "data" + ) if correction_type is None: correction_type_dict = { - ifo: CALIBRATION_CORRECTION_TYPE_LOOKUP[ifo] for ifo in self.interferometers + ifo: CALIBRATION_CORRECTION_TYPE_LOOKUP[ifo] + for ifo in self.interferometers } elif correction_type == "data" or correction_type == "template": - correction_type_dict = {ifo: correction_type for ifo in self.interferometers} + correction_type_dict = { + ifo: correction_type for ifo in self.interferometers + } elif isinstance(correction_type, dict): correction_type_dict = correction_type else: @@ -440,7 +452,7 @@ def sample_calibration_parameters(self, calibration_sampling_kwargs: dict): ) # Removing the delta function priors on the frequency nodes, amplitude and phase. - # Usually the frequency nodes are set to delta functions, but we also remove the + # Usually the frequency nodes are set to delta functions, but we also remove the # the amplitude and phase delta functions if present. # This avoids large log probs and log priors, since the density of a delta function # at the sampled point is infinite. The delta functions do not affect the sampling, @@ -457,9 +469,9 @@ def sample_calibration_parameters(self, calibration_sampling_kwargs: dict): delta_log_prob = np.zeros(num_samples) # Here we will sample the calibration parameters from the prior. - # We treat the *prior as the proposal* distribution and - # therefore add the log_prob of the sampled calibration parameters - # to the existing log_prob. We also will update the prior + # We treat the *prior as the proposal* distribution and + # therefore add the log_prob of the sampled calibration parameters + # to the existing log_prob. We also will update the prior # to include the calibration priors using the importance_sampling_metadata prior_update = self.importance_sampling_metadata.get("prior_update", {}) for ifo, prior in calibration_priors.items(): @@ -690,7 +702,9 @@ def get_samples_bilby_phase(self, num_processes=1): num_processes=num_processes, ) - def get_pesummary_samples(self, num_processes=1, resampling_method="clip+rejection"): + def get_pesummary_samples( + self, num_processes=1, resampling_method="clip+rejection" + ): """Samples in a form suitable for PESummary. These samples are adjusted to undo certain conventions used internally by From 292f557c1af7f42a07e66e74811c450511db85ca Mon Sep 17 00:00:00 2001 From: Stephen Green Date: Sun, 5 Jul 2026 18:34:39 +0100 Subject: [PATCH 23/65] Make ChainComposer.log_prob a kind-aware reverse fold; add missing step log_probs ChainComposer.log_prob previously called step.log_prob uniformly, which raised for every real chain (reparams and corrections had raising stubs, and consumed columns like ra@t_ref were absent from the samples). Now the steps fold in exact reverse chain order, restoring the column state each step saw during sampling: a Reparametrization rebuilds its inputs via inverse and contributes -log|det J|, a Factor contributes its log_prob, a TargetCorrection contributes nothing, and a density-free chain (GibbsBlock) raises. - _validate now rejects a step overwriting an existing column, except a Reparametrization replacing its own inputs (invertible, so log_prob can restore the state); this is the invariant the reverse fold relies on. - GNPEFlowFactor.log_prob: run the proxies-present data prep, undo the post-network geocent_time proxy shift via PostCorrectGeocentTime(inverse=True), standardize, and score the network; carries its own Standardization now. - DeltaFactor moves to core (nothing GW about a point mass) and gains log_prob = 0 per row on the evaluated block's device; the gw module re-exports it for the builders. - Drop the dead raising log_prob stubs on Reparametrization/TargetCorrection; document that a correction must only consume side-channel intermediates. - RAReparam: document the principal-branch semantics of the modulo for samples drawn outside [0, 2pi). - Tests: analytic-density re-plug through reparam + correction chains (incl. an in-place same-name reparam), overwrite validation, density-free raise. Model-based re-plug harness (NPE + single-step GNPE) agrees to float roundoff on in-range rows (max ~2e-4, median ~4e-6); sampling parity stays bit-exact. Co-Authored-By: Claude Fable 5 --- dingo/core/factors.py | 121 ++++++++++++++++++++++++++-------- dingo/gw/inference/factors.py | 67 ++++++++++++------- tests/core/test_factors.py | 119 ++++++++++++++++++++++++++++++++- 3 files changed, 253 insertions(+), 54 deletions(-) diff --git a/dingo/core/factors.py b/dingo/core/factors.py index 68a806714..fd8a36b04 100644 --- a/dingo/core/factors.py +++ b/dingo/core/factors.py @@ -311,6 +311,36 @@ def log_prob(self, theta_i, context, given=None): return log_prob + self.standardization.log_det(self._net_parameters) +class DeltaFactor(Factor): + """`q_i = delta(theta_i - c)`: a point mass pinning parameters to fixed values, + contributing 0 to the proposal log-prob. + + Used as the chain root for prior-conditioning or known proxies (where later factors + condition on the pinned values), and as a non-root filler for delta-prior parameters + a model does not infer (one constant per current row). + """ + + def __init__(self, values: dict[str, float]): + self.values = values + self.parameters = list(values) + self.conditioning = [] + + def sample_and_log_prob(self, num_samples, context, given=None): + samples = { + p: torch.full((num_samples,), float(v)) for p, v in self.values.items() + } + return samples, torch.zeros(num_samples) + + def log_prob(self, theta_i, context, given=None): + """0 per row, matching sample time: the point mass is evaluated on its own + support (the chain only re-plugs its own samples), so the pinned block is + conditioned on, not integrated over. Off-support densities are not + represented.""" + # One zero per row, on the same device/dtype as the evaluated block. + reference_column = next(iter(theta_i.values())) + return torch.zeros_like(reference_column) + + class Reparametrization(ABC): """ A deterministic bijection `Step`: it transforms existing parameters (no sampling) @@ -361,17 +391,10 @@ def sample_and_log_prob( @property def consumes(self) -> list[str]: - """Inputs the bijection replaces with its outputs (dropped after the step).""" + """Inputs the bijection replaces with its outputs (dropped after the step). + `ChainComposer.log_prob` rebuilds them via `inverse`.""" return [c for c in self.conditioning if c not in self.parameters] - def log_prob( - self, - theta_i: dict[str, torch.Tensor], - context: SamplerContext, - given: Optional[dict[str, torch.Tensor]] = None, - ) -> torch.Tensor: - raise NotImplementedError("Reparametrization.log_prob") - class TargetCorrection(ABC): """ @@ -381,6 +404,10 @@ class TargetCorrection(ABC): Its value belongs to the IS target, not the proposal: it reads earlier blocks, emits a named column, optionally consumes intermediates (`consumes`), and adds 0 to the proposal log-prob. 1:1. + + `consumes` must name only side-channel intermediates (e.g. recomputed detector + times), never a sampled parameter: unlike a `Reparametrization`, a correction has no + inverse, so anything it consumes cannot be rebuilt by `ChainComposer.log_prob`. """ parameters: list[str] @@ -405,14 +432,6 @@ def sample_and_log_prob( n = next(iter(out.values())).shape[0] return out, torch.zeros(n) - def log_prob( - self, - theta_i: dict[str, torch.Tensor], - context: SamplerContext, - given: Optional[dict[str, torch.Tensor]] = None, - ) -> torch.Tensor: - raise NotImplementedError("TargetCorrection.log_prob") - class Step(Protocol): """ @@ -465,8 +484,11 @@ def __init__(self, stages: list[Union["Stage", Step]]): def _validate(self): """Check the declared order is a valid topological order: every conditioning - name is produced by an earlier step. A step's emitted columns default to its - `parameters`, but a step may emit side-channel columns too (`produces`).""" + name is produced by an earlier step, and no step overwrites an existing + column -- except a `Reparametrization` replacing its own inputs, which is + invertible, so `log_prob` can restore the overwritten state. A step's + emitted columns default to its `parameters`, but a step may emit + side-channel columns too (`produces`).""" produced: set[str] = set() for step in self.steps: missing = [c for c in step.conditioning if c not in produced] @@ -475,7 +497,18 @@ def _validate(self): f"A step producing {step.parameters} conditions on {missing}, " f"which no earlier step produces. Check chain order." ) - produced.update(getattr(step, "produces", step.parameters)) + emitted = set(getattr(step, "produces", step.parameters)) + replaceable = ( + set(step.conditioning) if isinstance(step, Reparametrization) else set() + ) + clobbered = (emitted & produced) - replaceable + if clobbered: + raise ValueError( + f"A step producing {step.parameters} would overwrite existing " + f"column(s) {sorted(clobbered)}. Only a Reparametrization may " + f"replace columns (its inverse can rebuild them for log_prob)." + ) + produced.update(emitted) @property def steps(self) -> list[Step]: @@ -544,13 +577,49 @@ def _run_chain_once( def log_prob( self, samples: dict[str, torch.Tensor], context: SamplerContext ) -> torch.Tensor: - """Sum each step's `log_prob` at the given samples. Valid only for an all-density - chain (every step a `Factor`).""" + """Evaluate the chain's proposal log-density at given physical samples + (re-plug / importance sampling). + + The steps are folded in exact reverse chain order, so the columns are + restored to the state each step saw during sampling: a `Reparametrization` + rebuilds its inputs via `inverse` (e.g. `ra@t_ref` from the event-frame + `ra`) and contributes `-log|det J|`, a `Factor` contributes its `log_prob`, + and a `TargetCorrection` contributes nothing (target-side only). Raises for + a density-free chain (one containing a `GibbsBlock`). + + Parameters + ---------- + samples : dict[str, torch.Tensor] + The chain's emitted columns (one value per row). Consumed intermediates + are rebuilt via the reparametrization inverses and need not be present. + context : SamplerContext + Per-event shared state. + + Returns + ------- + torch.Tensor + The proposal log-density per row. + """ + if any(isinstance(step, GibbsBlock) for step in self.steps): + raise ValueError( + "The chain contains a density-free step (GibbsBlock), so its " + "log_prob is unavailable; recover a density first (fit an " + "unconditional model and take a chain step)." + ) + values = dict(samples) total: torch.Tensor | float = 0.0 - for step in self.steps: - given = {k: samples[k] for k in step.conditioning} - theta_i = {k: samples[k] for k in step.parameters} - total = total + step.log_prob(theta_i, context, given) + for step in reversed(self.steps): + if isinstance(step, TargetCorrection): + continue + if isinstance(step, Reparametrization): + params = {k: values[k] for k in step.parameters} + values.update(step.inverse(params, context)) + given = {k: values[k] for k in step.conditioning} + total = total - step.log_det(given, context) + else: + given = {k: values[k] for k in step.conditioning} + theta_i = {k: values[k] for k in step.parameters} + total = total + step.log_prob(theta_i, context, given) return total def sample( diff --git a/dingo/gw/inference/factors.py b/dingo/gw/inference/factors.py index 18757cf5a..97f6bfdfd 100644 --- a/dingo/gw/inference/factors.py +++ b/dingo/gw/inference/factors.py @@ -31,10 +31,12 @@ from dingo.core.factors import ( ChainComposer, ComposedSampler, + DeltaFactor, Factor, FlowFactor, GibbsBlock, Reparametrization, + Standardization, TargetCorrection, _base_model_metadata, ) @@ -318,30 +320,6 @@ def _marginalized_prior(self, name: str): return self.prior.get(name) -class DeltaFactor(Factor): - """`q_i = delta(theta_i - c)`: a point mass pinning parameters to fixed values, - contributing 0 to the proposal log-prob. - - Used as the chain root for prior-conditioning or known proxies (single-step GNPE, where - later factors condition on the pinned values), and as a non-root filler for delta-prior - parameters the network does not infer (one constant per current row). - """ - - def __init__(self, values: dict[str, float]): - self.values = values - self.parameters = list(values) - self.conditioning = [] - - def sample_and_log_prob(self, num_samples, context, given=None): - samples = { - p: torch.full((num_samples,), float(v)) for p, v in self.values.items() - } - return samples, torch.zeros(num_samples) - - def log_prob(self, theta_i, context, given=None): - raise NotImplementedError("DeltaFactor.log_prob") - - def _to_numpy(v) -> np.ndarray: """Detach a torch tensor (or coerce anything) to a numpy array.""" if torch.is_tensor(v): @@ -640,6 +618,10 @@ def __init__( self._net_parameters = parameters self.parameters = [self.aliases.get(p, p) for p in parameters] self.conditioning = list(self.proxy_parameters) + # For log_prob: the sampling path de-standardizes (and corrects the log-prob) + # inside transform_post, but evaluating at a point needs the forward map too. + std = _base_model_metadata(model)["train_settings"]["data"]["standardization"] + self.standardization = Standardization(std["mean"], std["std"]) @property def produces(self) -> list[str]: @@ -704,7 +686,37 @@ def sample_and_log_prob(self, num_samples, context, given=None): return params, x["log_prob"] def log_prob(self, theta_i, context, given=None): - raise NotImplementedError("GNPEFlowFactor.log_prob") + """Evaluate the network density `log q(theta | theta_hat, d)` in physical space + at given `theta_i` (exposed / aliased names), one row per proxy row in `given`. + Runs the same proxies-present data preparation as sampling (time-shift by the + proxies, standardize the conditioning), then scores the standardized parameters + under the network.""" + proxies = {p: given[p] for p in self.proxy_parameters} + n_rows = next(iter(proxies.values())).shape[0] + x = {"extrinsic_parameters": dict(proxies), "parameters": {}} + d = context.prepared_data().clone() + x["data"] = d.expand(n_rows, *d.shape) + x = self.transform_pre(x) + theta_net = { + net: theta_i[self.aliases.get(net, net)] for net in self._net_parameters + } + # Mirror transform_post: sampling shifts geocent_time by the preferred proxy + # after the network (PostCorrectGeocentTime, using the extrinsic geocent_time + # set up by the GNPE transform), so score the network in its own output frame + # by applying the inverse correction first. + y = { + "parameters": dict(theta_net), + "extrinsic_parameters": dict(x["extrinsic_parameters"]), + } + theta_net = PostCorrectGeocentTime(inverse=True)(y)["parameters"] + z = self.standardization.standardize(theta_net, self._net_parameters) + self.model.network.eval() + with torch.no_grad(): + if "context_parameters" in x: + log_prob = self.model.log_prob(z, x["data"], x["context_parameters"]) + else: + log_prob = self.model.log_prob(z, x["data"]) + return log_prob + self.standardization.log_det(self._net_parameters) class RAReparam(Reparametrization): @@ -717,6 +729,11 @@ class RAReparam(Reparametrization): modulo 2*pi (`log_det = 0`), so it contributes nothing to the density. `forward` produces the event-frame `ra`, `inverse` recovers `ra@t_ref`. The sidereal correction is read from the shared context (`t_ref` and the event time). + + The modulo makes the map a bijection on the circle, while the flow's density lives + on the real line: a sample drawn outside `[0, 2 pi)` is wrapped, so `inverse` + recovers its principal-branch representative and a re-evaluated `log_prob` refers + to that branch. Only tail samples outside the bounded `ra` prior are affected. """ def __init__(self): diff --git a/tests/core/test_factors.py b/tests/core/test_factors.py index 8cfe748c3..1a9d65101 100644 --- a/tests/core/test_factors.py +++ b/tests/core/test_factors.py @@ -8,6 +8,8 @@ here. """ +import math + import pytest import torch @@ -302,12 +304,12 @@ def test_unconditioned_factor_as_root_draws_base_count(): class _MockTargetCorrection(TargetCorrection): """A mock kind-3 correction: emits ``corr = 2 * x``, contributes 0 to the proposal, - and consumes its input.""" + and (by default) consumes its input.""" - def __init__(self, reads="x", emits="corr"): + def __init__(self, reads="x", emits="corr", consume=True): self.conditioning = [reads] self.parameters = [emits] - self.consumes = [reads] + self.consumes = [reads] if consume else [] self._reads, self._emits = reads, emits def correction(self, given, context): @@ -362,3 +364,114 @@ def test_side_channel_produces_satisfies_topological_check(): ) out = comp.sample(4, context=None) assert out["b"].shape == (4,) and "s" in out + + +class _GaussFactor(Factor): + """A factor with a real analytic density -- standard normal around the sum of its + conditioning (0 if unconditioned) -- so log_prob re-plug is checkable against the + log-prob stored at sample time.""" + + def __init__(self, name, conditioning=()): + self.parameters = [name] + self.conditioning = list(conditioning) + self._name = name + + def _mean(self, given, n_rows): + if self.conditioning: + return sum(given[k] for k in self.conditioning) + return torch.zeros(n_rows) + + def sample_and_log_prob(self, n, context, given=None): + n_rows = 1 if not self.conditioning else next(iter(given.values())).shape[0] + mean = self._mean(given, n_rows).repeat_interleave(n) + vals = mean + torch.randn(n_rows * n) + return {self._name: vals}, self.log_prob({self._name: vals}, context, given) + + def log_prob(self, theta_i, context, given=None): + v = theta_i[self._name] + mean = self._mean(given, v.shape[0]) + return -0.5 * (v - mean) ** 2 - 0.5 * math.log(2 * math.pi) + + +def test_log_prob_replug_kind_aware(): + # Re-plug: the kind-aware log_prob rebuilds consumed columns via the reparam + # inverses, sums factor densities and Jacobians, and skips target corrections. + torch.manual_seed(0) + comp = ChainComposer( + [ + _GaussFactor("u"), + _MockReparam(shift=10.0, log_det_val=0.5), + _MockTargetCorrection(reads="v", emits="corr", consume=False), + _GaussFactor("w", conditioning=["v"]), + ] + ) + out = comp.sample(64, context=None) + assert "u" not in out # consumed by the reparam + samples = {k: v for k, v in out.items() if k != "log_prob"} + lp = comp.log_prob(samples, context=None) + # u is rebuilt as v - shift; the float round trip is not bit-exact, so compare + # with a tolerance. + assert torch.allclose(lp, out["log_prob"], atol=1e-5) + # And against a hand-built sum: gauss(u) + gauss(w | v) - log_det. + manual = ( + comp.steps[0].log_prob({"u": samples["v"] - 10.0}, None) + + comp.steps[3].log_prob({"w": samples["w"]}, None, {"v": samples["v"]}) + - 0.5 + ) + assert torch.allclose(lp, manual, atol=1e-6) + + +def test_log_prob_raises_for_density_free_chain(): + block = GibbsBlock( + init_factor=_ConstFactor("proxy"), + factors=[_ConstFactor("theta", conditioning=["proxy"])], + num_iterations=2, + ) + comp = ChainComposer([block]) + out = comp.sample(4, context=None) + with pytest.raises(ValueError, match="density-free"): + comp.log_prob(dict(out), context=None) + + +class _InPlaceReparam(Reparametrization): + """A bijection that replaces its input under the same name (``x -> x + shift``), + so evaluation must restore the column state per step position.""" + + def __init__(self, shift=5.0): + self.conditioning = ["x"] + self.parameters = ["x"] + self._shift = shift + + def forward(self, given, context): + return {"x": given["x"] + self._shift} + + def inverse(self, params, context): + return {"x": params["x"] - self._shift} + + +def test_log_prob_replug_in_place_reparam(): + # An in-place reparam overwrites its own column; the reverse fold restores the + # pre-transform value before the factor is scored, and a downstream consumer + # (the correction) is evaluated against the post-transform value it saw. + torch.manual_seed(0) + comp = ChainComposer( + [ + _GaussFactor("x"), + _InPlaceReparam(shift=5.0), + _MockTargetCorrection(reads="x", emits="corr", consume=False), + ] + ) + out = comp.sample(64, context=None) + assert torch.equal(out["corr"], 2 * out["x"]) # correction saw the shifted x + samples = {k: v for k, v in out.items() if k != "log_prob"} + lp = comp.log_prob(samples, context=None) + assert torch.allclose(lp, out["log_prob"], atol=1e-5) + + +def test_validation_rejects_column_overwrite(): + # Only a Reparametrization may replace an existing column (it is invertible); + # a factor re-producing a name would destroy state log_prob cannot rebuild. + with pytest.raises(ValueError, match="overwrite"): + ChainComposer([_ConstFactor("a"), _ConstFactor("a")]) + # The in-place reparam is the allowed exception. + ChainComposer([_ConstFactor("x"), _InPlaceReparam()]) From 8a2547e2c4c6d5bdebf9b51e25fd9a0fb2b45c20 Mon Sep 17 00:00:00 2001 From: Stephen Green Date: Sun, 5 Jul 2026 19:20:00 +0100 Subject: [PATCH 24/65] Add a chain device policy, cache the likelihood, invalidate stale contexts - SamplerContext/GWSamplerContext gain device (the model device): steps that create fresh tensors (DeltaFactor) create them there; steps that transform existing rows (the Reparametrization.log_det default, TargetCorrection, GNPEKernelFactor.log_prob) follow their inputs, so a chain stays on one device end to end (the GPU pipe case). The kernel density converts each side to numpy before subtracting (times and proxies may live on different devices; the kernel is a bilby PriorDict -- the same object that samples the blur). SyntheticPhaseFactor returns its phase and log-prob on the chain device. - GWSamplerContext.likelihood() keeps the most recently built likelihood with its arguments and rebuilds only when they change: the synthetic-phase factor requests one per chain chunk, and importance sampling will request its own configuration once. The arguments are captured via locals() so the comparison tracks the signature automatically. - Remove the never-used phase_grid constructor argument from StationaryGaussianGWLikelihood, Result._build_likelihood, and GWSamplerContext.likelihood(); the grid is assigned as an attribute by its consumers (synthetic phase) or set internally by phase marginalization. - Result.reset_event() drops the live sampler_context: after resetting to (possibly regenerated) event data it no longer describes self.context. - Tests: device placement (meta unit test + an MPS end-to-end mixed chain), likelihood cache semantics, reset_event invalidation. Co-Authored-By: Claude Fable 5 --- dingo/core/factors.py | 26 ++++++--- dingo/core/result.py | 5 ++ dingo/gw/inference/factors.py | 71 ++++++++++++++++++------ dingo/gw/likelihood.py | 6 +-- dingo/gw/result.py | 2 - tests/core/test_factors.py | 49 +++++++++++++++++ tests/core/test_result.py | 26 +++++++-- tests/gw/test_gw_sampler_context.py | 83 +++++++++++++++++++++++++++++ 8 files changed, 236 insertions(+), 32 deletions(-) create mode 100644 tests/gw/test_gw_sampler_context.py diff --git a/dingo/core/factors.py b/dingo/core/factors.py index fd8a36b04..eb3269bc8 100644 --- a/dingo/core/factors.py +++ b/dingo/core/factors.py @@ -69,9 +69,14 @@ class SamplerContext(Protocol): derived from it (the prepared-data representation, the likelihood, event metadata). Concrete implementations are domain-specific; see `dingo.gw.inference.factors.GWSamplerContext`. + + `device` is the torch device the chain runs on: steps that create fresh tensors + (rather than transforming existing ones) create them here, so their outputs can + join a chain whose network factors run on a GPU. """ event_metadata: Optional[dict] + device: Union[torch.device, str] def prepared_data(self) -> torch.Tensor: """The data representation the factors condition on @@ -326,10 +331,15 @@ def __init__(self, values: dict[str, float]): self.conditioning = [] def sample_and_log_prob(self, num_samples, context, given=None): + # A delta factor creates fresh tensors, so it places them on the chain's + # device (unlike steps that transform existing rows, which follow their + # inputs). + device = getattr(context, "device", None) samples = { - p: torch.full((num_samples,), float(v)) for p, v in self.values.items() + p: torch.full((num_samples,), float(v), device=device) + for p, v in self.values.items() } - return samples, torch.zeros(num_samples) + return samples, torch.zeros(num_samples, device=device) def log_prob(self, theta_i, context, given=None): """0 per row, matching sample time: the point mass is evaluated on its own @@ -372,9 +382,10 @@ def inverse( def log_det( self, given: dict[str, torch.Tensor], context: "SamplerContext" ) -> torch.Tensor: - """`log|det J|` of `forward`, per row. Default 0 (measure-preserving).""" - n = next(iter(given.values())).shape[0] - return torch.zeros(n) + """`log|det J|` of `forward`, per row. Default 0 (measure-preserving), on + the device of the transformed rows.""" + reference_column = next(iter(given.values())) + return torch.zeros_like(reference_column) def sample_and_log_prob( self, @@ -429,8 +440,9 @@ def sample_and_log_prob( if num_samples != 1: raise ValueError("A target correction is 1:1; use fan_out=1.") out = self.correction(given, context) - n = next(iter(out.values())).shape[0] - return out, torch.zeros(n) + # 0 proposal contribution per row, on the device of the emitted column. + reference_column = next(iter(out.values())) + return out, torch.zeros_like(reference_column) class Step(Protocol): diff --git a/dingo/core/result.py b/dingo/core/result.py index 8f9696052..1490c63b0 100644 --- a/dingo/core/result.py +++ b/dingo/core/result.py @@ -164,6 +164,11 @@ def reset_event(self, event_dataset): event_dataset: EventDataset New event to be used for importance sampling. """ + # The live sampler context describes the data the samples were drawn from; + # after resetting to (possibly regenerated) event data it no longer describes + # self.context, so drop it and fall back to self-building. + self.sampler_context = None + context = event_dataset.data event_metadata = event_dataset.settings diff --git a/dingo/gw/inference/factors.py b/dingo/gw/inference/factors.py index 97f6bfdfd..295b63bda 100644 --- a/dingo/gw/inference/factors.py +++ b/dingo/gw/inference/factors.py @@ -14,7 +14,7 @@ import copy import logging import time -from typing import Optional +from typing import Optional, Union import numpy as np import pandas as pd @@ -88,6 +88,7 @@ def __init__( raw_context: dict, event_metadata: Optional[dict] = None, base_metadata: Optional[dict] = None, + device: Union[torch.device, str] = "cpu", ): """ Parameters @@ -110,6 +111,9 @@ def __init__( base_metadata : dict, optional The base (parameter / waveform / domain) metadata, used to build the likelihood. + device : torch.device or str, default "cpu" + The torch device the chain runs on (the model device); steps that create + fresh tensors (e.g. `DeltaFactor`) create them here. """ self.domain = domain self.detectors = detectors @@ -118,8 +122,11 @@ def __init__( self.base_metadata = base_metadata self.raw_context = raw_context self.event_metadata = event_metadata + self.device = device self._prepared: Optional[torch.Tensor] = None self._prior: Optional[PriorDict] = None + self._likelihood: Optional[StationaryGaussianGWLikelihood] = None + self._likelihood_settings: Optional[dict] = None @classmethod def from_model( @@ -177,6 +184,7 @@ def from_model( raw_context=raw_context, event_metadata=event_metadata, base_metadata=meta, + device=model.device, ) def prepared_data(self) -> torch.Tensor: @@ -208,7 +216,6 @@ def likelihood( time_marginalization_kwargs: Optional[dict] = None, phase_marginalization_kwargs: Optional[dict] = None, calibration_marginalization_kwargs: Optional[dict] = None, - phase_grid: Optional[np.ndarray] = None, use_base_domain: bool = False, ) -> StationaryGaussianGWLikelihood: """ @@ -221,6 +228,13 @@ def likelihood( event time (the training-frame right-ascension correction is already applied to the samples), or the training reference time when no event time is set. + The most recently built likelihood is cached with its arguments: a repeated + call with the same arguments returns the shared instance (the synthetic-phase + factor requests one per chain chunk), and a call with different arguments + builds a replacement. In the exact synthetic-phase mode the consumer assigns + a `phase_grid` attribute on the shared instance, as + `Result.sample_synthetic_phase` does. + Importance-sampling domain rebuilds (an updated `T` / `delta_f` or frequency range) are not wired here yet: the likelihood uses this context's domain. @@ -233,8 +247,6 @@ def likelihood( Analytically marginalize over `phase`. Requires a uniform [0, 2 pi) phase prior. calibration_marginalization_kwargs : dict, optional Marginalize over detector calibration uncertainty. - phase_grid : np.ndarray, optional - Phase grid for the exact phase-marginalized / synthetic-phase likelihood. use_base_domain : bool, default False For a multibanded domain, evaluate on the base (undecimated) frequency domain rather than the decimated one. @@ -243,6 +255,15 @@ def likelihood( ------- StationaryGaussianGWLikelihood """ + # Capture the call's arguments for the cache comparison; this must stay the + # first statement so locals() holds exactly the arguments. The comparison + # happens before the marginalization bounds are filled in below (which is + # deterministic given the same arguments). + settings = dict(locals()) + settings.pop("self") + if settings == self._likelihood_settings: + return self._likelihood + # Marginalizing over a parameter needs the (uniform) prior the network # marginalized over; use it to parameterize the requested marginalization. if time_marginalization_kwargs is not None: @@ -284,7 +305,7 @@ def likelihood( else: t_ref = self.t_ref - return StationaryGaussianGWLikelihood( + likelihood = StationaryGaussianGWLikelihood( wfg_kwargs=dataset_settings["waveform_generator"], wfg_domain=wfg_domain, data_domain=self.domain, @@ -293,7 +314,6 @@ def likelihood( time_marginalization_kwargs=time_marginalization_kwargs, phase_marginalization_kwargs=phase_marginalization_kwargs, calibration_marginalization_kwargs=calibration_marginalization_kwargs, - phase_grid=phase_grid, use_base_domain=use_base_domain, frequency_update=dict( minimum_frequency=self._frequency( @@ -304,6 +324,9 @@ def likelihood( ), ), ) + self._likelihood = likelihood + self._likelihood_settings = settings + return likelihood def _frequency(self, key: str, default: float): """The event's frequency-range override for `key` (min/max), else `default`.""" @@ -386,7 +409,9 @@ def sample_and_log_prob(self, num_samples, context, given=None): raise ValueError( "Synthetic phase is 1:1; draw one phase per sample (fan_out=1)." ) - n = len(next(iter(given.values()))) + reference = next(iter(given.values())) + device = reference.device if torch.is_tensor(reference) else None + n = len(reference) logger.info(f"Estimating synthetic phase for {n} samples.") t0 = time.time() phases, phase_posterior = self._phase_profile(given, context) @@ -394,15 +419,20 @@ def sample_and_log_prob(self, num_samples, context, given=None): phases, phase_posterior, self.num_processes ) logger.info(f"Done. This took {time.time() - t0:.2f} s.") - return {"phase": torch.as_tensor(new_phase)}, torch.as_tensor(log_prob) + return ( + {"phase": torch.as_tensor(new_phase, device=device)}, + torch.as_tensor(log_prob, device=device), + ) def log_prob(self, theta_i, context, given=None): """Evaluate `log q(phase | theta_rest, d)` at the given phases (re-plug / IS).""" + reference = next(iter(given.values())) + device = reference.device if torch.is_tensor(reference) else None phases, phase_posterior = self._phase_profile(given, context) log_prob = interpolated_log_prob_multi( phases, phase_posterior, _to_numpy(theta_i["phase"]), self.num_processes ) - return torch.as_tensor(log_prob) + return torch.as_tensor(log_prob, device=device) def _phase_profile(self, given, context): """The phase grid and the mass-covered (un-normalized) phase distribution, one row @@ -557,14 +587,21 @@ def sample_and_log_prob(self, num_samples, context, given=None): def log_prob(self, theta_i, context, given=None): """`log p(theta_hat | theta)` from the kernel, at the proxies (`theta_i`) and - the detector times (`given`).""" - diffs = {} - for k in self.kernel.keys(): - diff = given[k] - theta_i[f"{k}_proxy"] - if torch.is_tensor(diff): - diff = diff.detach().cpu().numpy() - diffs[k] = np.asarray(diff) - return torch.as_tensor(self.kernel.ln_prob(diffs, axis=0), dtype=torch.float32) + the detector times (`given`). + + The kernel is a bilby `PriorDict` -- the same object that samples the blur -- + so the density is evaluated in numpy (converting each side first: the times + and proxies may live on different devices) and returned on the detector + times' device.""" + reference = next(iter(given.values())) + device = reference.device if torch.is_tensor(reference) else None + diffs = { + k: _to_numpy(given[k]) - _to_numpy(theta_i[f"{k}_proxy"]) + for k in self.kernel.keys() + } + return torch.as_tensor( + self.kernel.ln_prob(diffs, axis=0), dtype=torch.float32, device=device + ) class GNPEFlowFactor(Factor): diff --git a/dingo/gw/likelihood.py b/dingo/gw/likelihood.py index 49b29941f..43df8f80f 100644 --- a/dingo/gw/likelihood.py +++ b/dingo/gw/likelihood.py @@ -39,13 +39,11 @@ def __init__( time_marginalization_kwargs: Optional[dict] = None, phase_marginalization_kwargs: Optional[dict] = None, calibration_marginalization_kwargs: Optional[dict] = None, - phase_grid=None, use_base_domain: bool = False, frequency_update: Optional[ dict[str, float | dict[str, float | list[float]]] ] = None, ): - # TODO: Does the phase_grid argument ever get used? """ Parameters ---------- @@ -152,7 +150,9 @@ def __init__( ] ) self.whiten = True - self.phase_grid = phase_grid + # Assigned by phase-grid consumers (synthetic phase) or set internally by + # phase marginalization; never a construction input. + self.phase_grid = None # optionally initialize time marginalization self.time_marginalization = False diff --git a/dingo/gw/result.py b/dingo/gw/result.py index 6e3b3bf0f..0e36ba86f 100644 --- a/dingo/gw/result.py +++ b/dingo/gw/result.py @@ -303,7 +303,6 @@ def _build_likelihood( time_marginalization_kwargs: Optional[dict] = None, phase_marginalization_kwargs: Optional[dict] = None, calibration_marginalization_kwargs: Optional[dict] = None, - phase_grid: Optional[np.ndarray] = None, ): """ Build the likelihood function based on model metadata. This is called at the @@ -381,7 +380,6 @@ def _build_likelihood( time_marginalization_kwargs=time_marginalization_kwargs, phase_marginalization_kwargs=phase_marginalization_kwargs, calibration_marginalization_kwargs=calibration_marginalization_kwargs, - phase_grid=phase_grid, use_base_domain=self.use_base_domain, frequency_update=dict( minimum_frequency=self.minimum_frequency, diff --git a/tests/core/test_factors.py b/tests/core/test_factors.py index 1a9d65101..99f02d072 100644 --- a/tests/core/test_factors.py +++ b/tests/core/test_factors.py @@ -15,6 +15,7 @@ from dingo.core.factors import ( ChainComposer, + DeltaFactor, Factor, GibbsBlock, Reparametrization, @@ -475,3 +476,51 @@ def test_validation_rejects_column_overwrite(): ChainComposer([_ConstFactor("a"), _ConstFactor("a")]) # The in-place reparam is the allowed exception. ChainComposer([_ConstFactor("x"), _InPlaceReparam()]) + + +class _DeviceContext: + """Bare context stub carrying only a device (these steps ignore the rest).""" + + def __init__(self, device): + self.device = device + self.event_metadata = None + + +def test_delta_factor_creates_on_context_device(): + # A DeltaFactor creates fresh tensors, so they land on the chain's device; + # the 'meta' device stands in for a GPU without needing one. + delta = DeltaFactor({"chi_1": 0.0, "chi_2": 0.3}) + samples, lp = delta.sample_and_log_prob(4, _DeviceContext(torch.device("meta"))) + assert all(v.device.type == "meta" for v in samples.values()) + assert lp.device.type == "meta" + samples_cpu, lp_cpu = delta.sample_and_log_prob(4, context=None) + assert lp_cpu.device.type == "cpu" # no context device -> torch default + + +_HAS_MPS = getattr(torch.backends, "mps", None) and torch.backends.mps.is_available() + + +@pytest.mark.skipif(not _HAS_MPS, reason="needs an accelerator (MPS) for mixed devices") +def test_chain_device_consistency_on_accelerator(): + # A chain mixing a device-emitting factor, an (in-place) reparam, a correction, + # and a DeltaFactor filler must stay on one device end to end -- the GPU pipe + # case: constants follow the context device, transforms follow their inputs. + device = torch.device("mps") + + class _DeviceGauss(_GaussFactor): + def sample_and_log_prob(self, n, context, given=None): + block, lp = super().sample_and_log_prob(n, context, given) + return {k: v.to(device) for k, v in block.items()}, lp.to(device) + + comp = ChainComposer( + [ + _DeviceGauss("x"), + _InPlaceReparam(shift=5.0), + _MockTargetCorrection(reads="x", emits="corr", consume=False), + DeltaFactor({"c": 1.5}), + ] + ) + out = comp.sample(8, context=_DeviceContext(device)) + assert out["log_prob"].device.type == "mps" + assert out["c"].device.type == "mps" + assert out["x"].device.type == "mps" diff --git a/tests/core/test_result.py b/tests/core/test_result.py index 752fd0227..e9a796de7 100644 --- a/tests/core/test_result.py +++ b/tests/core/test_result.py @@ -150,9 +150,9 @@ def test_higher_max_increases_output(): ] # Expected output grows monotonically with k (stochastically, but very reliably). for i in range(len(counts) - 1): - assert counts[i] <= counts[i + 1], ( - f"k={i+1} gave {counts[i]} samples but k={i+2} gave {counts[i+1]}" - ) + assert ( + counts[i] <= counts[i + 1] + ), f"k={i+1} gave {counts[i]} samples but k={i+2} gave {counts[i+1]}" # --------------------------------------------------------------------------- @@ -233,3 +233,23 @@ def test_clip_weights_reproducible(): out1 = result.rejection_sample(clip_weights=True, random_state=42) out2 = result.rejection_sample(clip_weights=True, random_state=42) pd.testing.assert_frame_equal(out1, out2) + + +# --------------------------------------------------------------------------- +# sampler_context lifecycle +# --------------------------------------------------------------------------- + + +def test_reset_event_invalidates_sampler_context(): + """A live sampler context describes the data the samples were drawn from; + resetting to new event data must drop it so builders fall back to + self-building rather than reading the stale context.""" + from types import SimpleNamespace + + samples = pd.DataFrame({"x": [1.0, 2.0]}) + result = Result(dictionary={"samples": samples}, sampler_context=object()) + assert result.sampler_context is not None + event = SimpleNamespace(data={"waveform": np.zeros(3)}, settings={"f_min": 20.0}) + result.reset_event(event) + assert result.sampler_context is None + assert result.event_metadata == {"f_min": 20.0} diff --git a/tests/gw/test_gw_sampler_context.py b/tests/gw/test_gw_sampler_context.py new file mode 100644 index 000000000..2f97a1cfe --- /dev/null +++ b/tests/gw/test_gw_sampler_context.py @@ -0,0 +1,83 @@ +""" +Unit tests for ``GWSamplerContext`` plumbing: the default-likelihood cache and the +device attribute. The likelihood itself is stubbed (no waveform generation); the +model-based construction parity lives in the local harnesses. +""" + +import pytest + +import dingo.gw.inference.factors as factors_module +from dingo.gw.domains import build_domain +from dingo.gw.inference.factors import GWSamplerContext + +_DOMAIN_SETTINGS = { + "type": "FrequencyDomain", + "f_min": 20.0, + "f_max": 1024.0, + "delta_f": 0.25, +} + +_BASE_METADATA = { + "dataset_settings": { + "domain": _DOMAIN_SETTINGS, + "waveform_generator": {"approximant": "IMRPhenomD", "f_ref": 20.0}, + }, + "train_settings": {"data": {"inference_parameters": ["chirp_mass"]}}, +} + + +class _StubLikelihood: + """Records constructions; stands in for StationaryGaussianGWLikelihood.""" + + constructions = 0 + + def __init__(self, **kwargs): + type(self).constructions += 1 + self.kwargs = kwargs + + +@pytest.fixture +def context(monkeypatch): + monkeypatch.setattr( + factors_module, "StationaryGaussianGWLikelihood", _StubLikelihood + ) + _StubLikelihood.constructions = 0 + return GWSamplerContext( + domain=build_domain(_DOMAIN_SETTINGS), + detectors=["H1"], + t_ref=1126259462.4, + data_prep=None, + raw_context={}, + base_metadata=_BASE_METADATA, + ) + + +def test_likelihood_rebuilt_only_when_settings_change(context): + # Repeated calls with unchanged arguments share the last-built instance (the + # synthetic-phase factor requests one per chain chunk); a settings change + # builds a replacement. + default = context.likelihood() + assert context.likelihood() is default + assert _StubLikelihood.constructions == 1 + + base = context.likelihood(use_base_domain=True) + assert base is not default + assert base.kwargs["use_base_domain"] is True + assert context.likelihood(use_base_domain=True) is base + assert _StubLikelihood.constructions == 2 + + +def test_context_device_default_and_explicit(): + ctx = GWSamplerContext( + domain=None, + detectors=["H1"], + t_ref=0.0, + data_prep=None, + raw_context={}, + device="meta", + ) + assert ctx.device == "meta" + ctx_default = GWSamplerContext( + domain=None, detectors=["H1"], t_ref=0.0, data_prep=None, raw_context={} + ) + assert ctx_default.device == "cpu" From 78f3e7327d0b4eadc45761818119eb61629ad4a2 Mon Sep 17 00:00:00 2001 From: Stephen Green Date: Sun, 5 Jul 2026 19:26:08 +0100 Subject: [PATCH 25/65] Rename GWSamplerContext.raw_context to event_data The word "context" meant four things (the sampler context, the network's conditioning parameters, the Result storage key, and the raw strain/ASD dict). The new API now reserves it for the sampler context: the data dict is event_data, matching StationaryGaussianGWLikelihood(event_data=...) and EventDataset.data. The serialized Result key ("context") and the training metadata name (context_parameters) are frozen by back-compat and unchanged. Co-Authored-By: Claude Fable 5 --- dingo/gw/inference/factors.py | 40 ++++++++++++++--------------- tests/gw/test_gw_sampler_context.py | 6 ++--- tests/gw/test_synthetic_phase.py | 2 +- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/dingo/gw/inference/factors.py b/dingo/gw/inference/factors.py index 295b63bda..75aa9589f 100644 --- a/dingo/gw/inference/factors.py +++ b/dingo/gw/inference/factors.py @@ -85,7 +85,7 @@ def __init__( detectors: list[str], t_ref: float, data_prep: Compose, - raw_context: dict, + event_data: dict, event_metadata: Optional[dict] = None, base_metadata: Optional[dict] = None, device: Union[torch.device, str] = "cpu", @@ -102,7 +102,7 @@ def __init__( data_prep : Compose The one-time data-preprocessing transform chain (whiten / decimate / repackage). - raw_context : dict + event_data : dict The raw event data `d` (strain + ASDs per detector), i.e. `EventDataset.data`. Consumed lazily by `prepared_data()` and reused for the likelihood. event_metadata : dict, optional @@ -120,7 +120,7 @@ def __init__( self.t_ref = t_ref self._data_prep = data_prep self.base_metadata = base_metadata - self.raw_context = raw_context + self.event_data = event_data self.event_metadata = event_metadata self.device = device self._prepared: Optional[torch.Tensor] = None @@ -132,7 +132,7 @@ def __init__( def from_model( cls, model: BasePosteriorModel, - raw_context: dict, + event_data: dict, event_metadata: Optional[dict] = None, ) -> "GWSamplerContext": """Build the context (domain + one-time data-prep chain) from a model's metadata. @@ -143,7 +143,7 @@ def from_model( ---------- model : BasePosteriorModel The model whose metadata defines the domain and preprocessing. - raw_context : dict + event_data : dict The raw event data (strain + ASDs). event_metadata : dict, optional Per-event metadata. @@ -181,7 +181,7 @@ def from_model( detectors=detectors, t_ref=data_settings["ref_time"], data_prep=Compose(transforms), - raw_context=raw_context, + event_data=event_data, event_metadata=event_metadata, base_metadata=meta, device=model.device, @@ -190,7 +190,7 @@ def from_model( def prepared_data(self) -> torch.Tensor: """One-time data preprocessing, computed once and cached.""" if self._prepared is None: - self._prepared = self._data_prep(self.raw_context) + self._prepared = self._data_prep(self.event_data) return self._prepared @property @@ -222,7 +222,7 @@ def likelihood( Build the exact GW likelihood on this event's data, in physical parameter space. The network's standardized, decimated view of the data is `prepared_data()`; the - likelihood instead takes the raw event data (`raw_context`) and builds its own + likelihood instead takes the raw event data (`event_data`) and builds its own representation -- decimating to the multibanded domain unless `use_base_domain`, and masking the ASDs to the event's frequency range. Its reference time is the event time (the training-frame right-ascension correction is already applied to the @@ -309,7 +309,7 @@ def likelihood( wfg_kwargs=dataset_settings["waveform_generator"], wfg_domain=wfg_domain, data_domain=self.domain, - event_data=self.raw_context, + event_data=self.event_data, t_ref=t_ref, time_marginalization_kwargs=time_marginalization_kwargs, phase_marginalization_kwargs=phase_marginalization_kwargs, @@ -949,7 +949,7 @@ def __init__( def from_model( cls, model: BasePosteriorModel, - raw_context: dict, + event_data: dict, event_metadata: Optional[dict] = None, ) -> "GWComposedSampler": """Build a plain-NPE GW sampler from a model and event data: the flow exposes @@ -959,7 +959,7 @@ def from_model( ---------- model : BasePosteriorModel The NPE model. - raw_context : dict + event_data : dict The raw event data (strain + ASDs). event_metadata : dict, optional Per-event metadata. @@ -968,7 +968,7 @@ def from_model( ------- GWComposedSampler """ - context = GWSamplerContext.from_model(model, raw_context, event_metadata) + context = GWSamplerContext.from_model(model, event_data, event_metadata) metadata = _base_model_metadata(model) inference_parameters = metadata["train_settings"]["data"][ "inference_parameters" @@ -991,7 +991,7 @@ def from_gnpe_models( cls, init_model: BasePosteriorModel, main_model: BasePosteriorModel, - raw_context: dict, + event_data: dict, event_metadata: Optional[dict] = None, num_iterations: int = 30, ) -> "GWComposedSampler": @@ -1008,7 +1008,7 @@ def from_gnpe_models( preprocessing. main_model : BasePosteriorModel The GNPE main network. - raw_context : dict + event_data : dict The raw event data (strain + ASDs). event_metadata : dict, optional Per-event metadata. @@ -1023,7 +1023,7 @@ def from_gnpe_models( # Build the context from the main model: it owns the analysis (likelihood, # prior, inference parameters). The init model shares the data domain and # preprocessing (asserted above), so prepared_data() is identical either way. - context = GWSamplerContext.from_model(main_model, raw_context, event_metadata) + context = GWSamplerContext.from_model(main_model, event_data, event_metadata) metadata = _base_model_metadata(main_model) inference_parameters = metadata["train_settings"]["data"][ "inference_parameters" @@ -1051,7 +1051,7 @@ def from_singlestep_gnpe( cls, main_model: BasePosteriorModel, proxy_source: Factor, - raw_context: dict, + event_data: dict, event_metadata: Optional[dict] = None, ) -> "GWComposedSampler": """Build a single-step (density-preserving) time-GNPE sampler: a `ChainComposer` @@ -1066,7 +1066,7 @@ def from_singlestep_gnpe( proxy_source : Factor Supplies the detector-time proxies -- a `DeltaFactor` for prior conditioning (BNS), or an unconditional NDE for density recovery. - raw_context : dict + event_data : dict The raw event data (strain + ASDs). event_metadata : dict, optional Per-event metadata. @@ -1075,7 +1075,7 @@ def from_singlestep_gnpe( ------- GWComposedSampler """ - context = GWSamplerContext.from_model(main_model, raw_context, event_metadata) + context = GWSamplerContext.from_model(main_model, event_data, event_metadata) metadata = _base_model_metadata(main_model) inference_parameters = metadata["train_settings"]["data"][ "inference_parameters" @@ -1096,7 +1096,7 @@ def to_result(self): existing post-processing pipeline -- synthetic phase, importance sampling, evidence, plotting -- runs on the factorized sampler's output unchanged. - The raw event-data dict (`GWSamplerContext.raw_context`) is stored as the + The raw event-data dict (`GWSamplerContext.event_data`) is stored as the `Result` context (serialized), and the live `GWSamplerContext` is passed as `sampler_context` so `Result` can pull the prior (and, later, the likelihood) from it rather than rebuilding them from metadata. @@ -1105,7 +1105,7 @@ def to_result(self): data_dict = { "samples": self.samples, - "context": self.context.raw_context, + "context": self.context.event_data, "event_metadata": self.context.event_metadata, "importance_sampling_metadata": None, "log_evidence": None, diff --git a/tests/gw/test_gw_sampler_context.py b/tests/gw/test_gw_sampler_context.py index 2f97a1cfe..dc2f51e01 100644 --- a/tests/gw/test_gw_sampler_context.py +++ b/tests/gw/test_gw_sampler_context.py @@ -47,7 +47,7 @@ def context(monkeypatch): detectors=["H1"], t_ref=1126259462.4, data_prep=None, - raw_context={}, + event_data={}, base_metadata=_BASE_METADATA, ) @@ -73,11 +73,11 @@ def test_context_device_default_and_explicit(): detectors=["H1"], t_ref=0.0, data_prep=None, - raw_context={}, + event_data={}, device="meta", ) assert ctx.device == "meta" ctx_default = GWSamplerContext( - domain=None, detectors=["H1"], t_ref=0.0, data_prep=None, raw_context={} + domain=None, detectors=["H1"], t_ref=0.0, data_prep=None, event_data={} ) assert ctx_default.device == "cpu" diff --git a/tests/gw/test_synthetic_phase.py b/tests/gw/test_synthetic_phase.py index cc45224aa..71bec0623 100644 --- a/tests/gw/test_synthetic_phase.py +++ b/tests/gw/test_synthetic_phase.py @@ -119,7 +119,7 @@ def context(event_metadata): detectors=[], t_ref=0.0, data_prep=None, - raw_context={}, + event_data={}, event_metadata=event_metadata, ) From 602e5e64fa9915902f2040dae66af0a815591449 Mon Sep 17 00:00:00 2001 From: Stephen Green Date: Sun, 5 Jul 2026 21:28:50 +0100 Subject: [PATCH 26/65] Support unconditional (density-recovery) models in FlowFactor An unconditional NDE (unconditional: True in its training metadata) takes no network input: FlowFactor now draws it with no arguments and never touches the context, mirroring the old Sampler._run_sampler branch. This is the proxy source for density recovery on the spine: prepare_log_prob's re-sample half becomes from_singlestep_gnpe(main, FlowFactor.from_model(nde)). Metadata transparency, restoring the old two-handle split: the network-bound settings (standardization, inference_parameters, context_parameters) are always the model's OWN -- an unconditional NDE carries its own proxy standardization, distinct from the base model's under metadata["base"]. FlowFactor and GNPEFlowFactor now read model.metadata directly (previously _base_model_metadata, which for an NDE would have silently applied the base model's standardization); _base_model_metadata keeps the analysis-side role (domain / dataset / detector settings) and its docstring now states the rule. - Mock CI test with a decoy base standardization (using it fails loudly). - Parity harness (verify_unconditional_flowfactor.py, local models): replays prepare_log_prob end to end -- Gibbs run, train a small NDE on the proxies, then (A) FlowFactor(nde) vs GWSampler(nde)._run_sampler bit-exact, and (B) from_singlestep_gnpe(main, FlowFactor(nde)) vs the old GWSamplerGNPE(num_iterations=1, init_sampler=GWSampler(nde)) -- all 18 shared columns bit-exact. (The old full run_sampler() path is itself broken for unconditional models -- _post_process reads metadata[dataset_settings]; production only ever calls _run_sampler, which is the reference used.) - Note the embed-once gap for row-identical data with row-varying conditioning (intrinsic/extrinsic split) as a TODO at the conditioned path. Co-Authored-By: Claude Fable 5 --- dingo/core/factors.py | 67 ++++++++++++++++++++++------------ dingo/gw/inference/factors.py | 3 +- tests/core/test_factors.py | 68 +++++++++++++++++++++++++++++++++++ 3 files changed, 114 insertions(+), 24 deletions(-) diff --git a/dingo/core/factors.py b/dingo/core/factors.py index eb3269bc8..42da66612 100644 --- a/dingo/core/factors.py +++ b/dingo/core/factors.py @@ -177,9 +177,11 @@ def log_prob( def _base_model_metadata(model: BasePosteriorModel) -> dict: - """The training metadata describing the parameter standardization / data settings. - For an unconditional (density-recovery) model this lives under `metadata["base"]`. - """ + """The base *analysis* metadata (dataset / domain / detector / data settings): for + an unconditional (density-recovery) model this lives under `metadata["base"]`, the + metadata of the model whose samples it was trained on. The network-bound settings + (`standardization`, `inference_parameters`) are always the model's own and are read + from `model.metadata` directly.""" metadata = model.metadata if metadata["train_settings"]["data"].get("unconditional", False): return metadata["base"] @@ -188,12 +190,14 @@ def _base_model_metadata(model: BasePosteriorModel) -> dict: class FlowFactor(Factor): """ - Factor wrapping a posterior model (NPE flow, FMPE, ...). Reaches data only through - `SamplerContext.prepared_data()` and encapsulates the network's standardization, so - its interface is in physical parameter space. - - An unconditioned model draws from the shared data context; a model with - `context_parameters` conditions on the values in `given`. + Factor wrapping a posterior model (NPE flow, FMPE, ...). Encapsulates the network's + standardization, so its interface is in physical parameter space. + + Three conditioning shapes: a data-conditional model draws from the shared + `SamplerContext.prepared_data()`; a model with `context_parameters` (GNPE proxies) + additionally conditions on the values in `given`; and an *unconditional* model + (`unconditional` in its training metadata -- a density-recovery NDE or a + model-as-prior) takes no input at all and never touches the context. """ def __init__( @@ -230,7 +234,11 @@ def __init__( self.conditioning = conditioning or [] # Network conditioning inputs (GNPE proxies). Empty for plain NPE. self.context_parameters = context_parameters or [] - std = _base_model_metadata(model)["train_settings"]["data"]["standardization"] + data_settings = model.metadata["train_settings"]["data"] + self.unconditional = data_settings.get("unconditional", False) + # The model's *own* standardization: an unconditional (density-recovery) NDE + # carries its own, distinct from the base model's under metadata["base"]. + std = data_settings["standardization"] self.standardization = Standardization(std["mean"], std["std"]) @classmethod @@ -238,7 +246,8 @@ def from_model( cls, model: BasePosteriorModel, aliases: Optional[dict[str, str]] = None ) -> "FlowFactor": """Build a factor from a model, reading `parameters` and `context_parameters` - from its training metadata. + from its own training metadata (for an unconditional NDE these are its own, + e.g. the GNPE proxies it was trained on). Parameters ---------- @@ -252,7 +261,7 @@ def from_model( ------- FlowFactor """ - data_settings = _base_model_metadata(model)["train_settings"]["data"] + data_settings = model.metadata["train_settings"]["data"] context_parameters = data_settings.get("context_parameters") or [] return cls( model=model, @@ -263,12 +272,16 @@ def from_model( ) def sample_and_log_prob(self, num_samples, context, given=None): - """Draw `num_samples` samples per conditioning row. Unconditioned: `num_samples` - draws from the shared data context. Conditioned: `num_samples` per context row, - returning `n_rows * num_samples` rows in row-major order.""" - data = context.prepared_data() + """Draw `num_samples` samples per conditioning row. Unconditional: draws with + no input (the context is not touched). Data-conditional: `num_samples` draws + from the shared data context. Parameter-conditioned: `num_samples` per context + row, returning `n_rows * num_samples` rows in row-major order.""" self.model.network.eval() - if not self.context_parameters: + if self.unconditional: + with torch.no_grad(): + z, log_prob = self.model.sample_and_log_prob(num_samples=num_samples) + elif not self.context_parameters: + data = context.prepared_data() with torch.no_grad(): z, log_prob = self.model.sample_and_log_prob( data.unsqueeze(0), num_samples=num_samples @@ -277,9 +290,14 @@ def sample_and_log_prob(self, num_samples, context, given=None): z = z.squeeze(0) log_prob = log_prob.squeeze(0) else: + data = context.prepared_data() # Standardize the (physical) conditioning the network was trained on, giving # B = N context rows; expand the shared data to match. The network draws # num_samples per row -> (N, num_samples, dim). + # TODO: the embedding runs once per row, so row-identical data with + # row-varying conditioning (the intrinsic/extrinsic split) recomputes N + # identical data embeddings. Fixing this needs an embed/fuse split on the + # model (FlowWrapper) so the cached embedding can be reused across rows. ctx = self.standardization.standardize(given, self.context_parameters) n_rows = ctx.shape[0] data = data.expand(n_rows, *data.shape) @@ -302,14 +320,17 @@ def log_prob(self, theta_i, context, given=None): } num_samples = next(iter(theta_net.values())).shape[0] z = self.standardization.standardize(theta_net, self._net_parameters) - data = context.prepared_data() - data = data.expand(num_samples, *data.shape) net_context: tuple[torch.Tensor, ...] - if not self.context_parameters: - net_context = (data,) + if self.unconditional: + net_context = () else: - ctx = self.standardization.standardize(given, self.context_parameters) - net_context = (data, ctx) + data = context.prepared_data() + data = data.expand(num_samples, *data.shape) + if not self.context_parameters: + net_context = (data,) + else: + ctx = self.standardization.standardize(given, self.context_parameters) + net_context = (data, ctx) self.model.network.eval() with torch.no_grad(): log_prob = self.model.log_prob(z, *net_context) diff --git a/dingo/gw/inference/factors.py b/dingo/gw/inference/factors.py index 75aa9589f..53ed63fb4 100644 --- a/dingo/gw/inference/factors.py +++ b/dingo/gw/inference/factors.py @@ -657,7 +657,8 @@ def __init__( self.conditioning = list(self.proxy_parameters) # For log_prob: the sampling path de-standardizes (and corrects the log-prob) # inside transform_post, but evaluating at a point needs the forward map too. - std = _base_model_metadata(model)["train_settings"]["data"]["standardization"] + # The model's own standardization (network-bound, like FlowFactor's). + std = model.metadata["train_settings"]["data"]["standardization"] self.standardization = Standardization(std["mean"], std["std"]) @property diff --git a/tests/core/test_factors.py b/tests/core/test_factors.py index 99f02d072..8e926a72b 100644 --- a/tests/core/test_factors.py +++ b/tests/core/test_factors.py @@ -17,6 +17,7 @@ ChainComposer, DeltaFactor, Factor, + FlowFactor, GibbsBlock, Reparametrization, Stage, @@ -524,3 +525,70 @@ def sample_and_log_prob(self, n, context, given=None): assert out["log_prob"].device.type == "mps" assert out["c"].device.type == "mps" assert out["x"].device.type == "mps" + + +class _FakeUncondModel: + """A fake unconditional (density-recovery) model: a deterministic 'flow' whose + draws are arange-based, with its OWN standardization distinct from the decoy + base-model standardization under metadata["base"] -- the factor must read the + model's own metadata.""" + + def __init__(self): + from types import SimpleNamespace + + self.metadata = { + "train_settings": { + "data": { + "unconditional": True, + "inference_parameters": ["H1_time_proxy"], + "standardization": { + "mean": {"H1_time_proxy": 2.0}, + "std": {"H1_time_proxy": 0.5}, + }, + } + }, + # Decoy: using the base model's settings here would be a bug. + "base": { + "train_settings": { + "data": { + "inference_parameters": ["x"], + "standardization": {"mean": {"x": 0.0}, "std": {"x": 1.0}}, + } + } + }, + } + self.network = SimpleNamespace(eval=lambda: None) + + def sample_and_log_prob(self, num_samples=None): + z = torch.arange(num_samples, dtype=torch.float32).unsqueeze(-1) + lp = torch.full((num_samples,), -1.0) + return z, lp + + def log_prob(self, z): + return torch.full((z.shape[0],), -1.0) + + +def test_unconditional_flow_factor(): + # A density-recovery NDE takes no input: the factor never touches the context + # (context=None), reads the model's own standardization and parameters, and + # serves as a chain root (e.g. the single-step GNPE proxy source). + factor = FlowFactor.from_model(_FakeUncondModel()) + assert factor.unconditional + assert factor.parameters == ["H1_time_proxy"] + assert factor.conditioning == [] + + samples, lp = factor.sample_and_log_prob(4, context=None) + # z = arange(4), destandardized with the NDE's own mean/std: z * 0.5 + 2.0. + expected = torch.arange(4, dtype=torch.float32) * 0.5 + 2.0 + assert torch.equal(samples["H1_time_proxy"], expected) + # Physical-space density: base lp plus the NDE's own -sum(log std). + assert torch.allclose(lp, torch.full((4,), -1.0 - math.log(0.5))) + + # Re-plug through the no-context path. + lp2 = factor.log_prob(samples, context=None) + assert torch.allclose(lp2, lp) + + # Composes as a chain root with a conditioned factor downstream. + comp = ChainComposer([factor, _ConstFactor("y", conditioning=["H1_time_proxy"])]) + out = comp.sample(3, context=None) + assert out["y"].shape == (3,) From 334fae1183d7672a4f257fedb5e81c2e9ab4e849 Mon Sep 17 00:00:00 2001 From: Stephen Green Date: Sun, 5 Jul 2026 23:52:11 +0100 Subject: [PATCH 27/65] Wire composed NPE sampling into dingo_pipe behind an implementation switch Add --sampler-implementation {legacy, composed} (default legacy) to the sampler options. The sampling stage builds a GWComposedSampler for plain NPE when composed is selected (GNPE not yet supported on this path), and GWComposedSampler gains the to_hdf5 shim mirroring Sampler.to_hdf5. A/B parity vs legacy on GW200129: sampling-stage Result HDF5 bit-exact (all columns, settings, context; ra stored float32), and the unmodified importance-sampling stage consumes the composed file with identical output up to ra-float32 precision (~1e-8 in log evidence). Co-Authored-By: Claude Fable 5 --- dingo/gw/inference/factors.py | 7 ++++++ dingo/pipe/parser.py | 27 +++++++++++++++++++---- dingo/pipe/sampling.py | 14 ++++++++++++ tests/pipe/test_sampler_implementation.py | 27 +++++++++++++++++++++++ 4 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 tests/pipe/test_sampler_implementation.py diff --git a/dingo/gw/inference/factors.py b/dingo/gw/inference/factors.py index 53ed63fb4..5ebee3240 100644 --- a/dingo/gw/inference/factors.py +++ b/dingo/gw/inference/factors.py @@ -14,6 +14,7 @@ import copy import logging import time +from pathlib import Path from typing import Optional, Union import numpy as np @@ -1114,3 +1115,9 @@ def to_result(self): "settings": copy.deepcopy(self.metadata), } return Result(dictionary=data_dict, sampler_context=self.context) + + def to_hdf5(self, label="result", outdir="."): + """Export via `to_result` and save to `/