Skip to content

Factorized sampler: a composable Step-based inference spine#389

Open
stephengreen wants to merge 66 commits into
hackathon-1from
sampler-revamp
Open

Factorized sampler: a composable Step-based inference spine#389
stephengreen wants to merge 66 commits into
hackathon-1from
sampler-revamp

Conversation

@stephengreen

@stephengreen stephengreen commented Jul 1, 2026

Copy link
Copy Markdown
Member

Summary

This PR replaces the monolithic Sampler/GWSampler/GWSamplerGNPE classes with a factorized inference spine: the posterior is sampled by an explicit chain of steps — factors (networks, pins, tables), reparametrizations, and corrections — composed by a ChainComposer over a per-event GWSamplerContext. Every existing workflow (NPE, multi-iteration GNPE with density recovery, importance sampling with synthetic phase) runs on the spine bit-exactly, and the spine's first new physics capability — DINGO-BNS single-step chirp-mass GNPE (Dax et al., Nature 639, 49 (2025)) — runs end-to-end through dingo_pipe on real GW170817 data.

Architecture

  • dingo/core/factors.py — the domain-agnostic spine: Factor / Reparametrization / TargetCorrection step kinds (FlowFactor, DeltaFactor, SampleTableFactor, ProxyOffsetReparam, GibbsBlock), Stage/ChainComposer (fan-out, point-mass and 1:1 multiplicity rules, kind-aware reverse-fold log_prob), and the ComposedSampler base.
  • dingo/gw/inference/ — split by responsibility: context.py (GWSamplerContext: event data with its network-input, prior, and likelihood views; importance-sampling representations via immutable derive()), steps.py (GNPE factors, SyntheticPhaseFactor, the RAToEventFrame/RAToTrainingFrame pair, SpinConventionReparam), sampler.py (GWComposedSampler + chain builders), scan.py (the chirp-mass scan).
  • Result carries no construction knowledge: prior, domain, and likelihood delegate to the context (reconstructed from metadata for from-file results); synthetic phase runs as a chain rooted in the proposal sample table.
  • Results record structured provenance: settings["sampler"] holds the serialized chain description, model paths, and (where applicable) density-recovery and scan recipes.

Binary neutron stars

  • Heterodyning transforms (HeterodynePhase, batch-native factor_fiducial_waveform) ported from Add heterodyning transforms for BNS chirp mass GNPE #355; d_inner_h phase decomposition (return_rho2opt) ported from Core GNPE extensions for BNS support #354; matched-filter SNR reporting (return_aux_snr) added for the scan.
  • Fixed-proxy chain [DeltaFactor(pins) → RAToTrainingFrame → FlowFactor → ProxyOffsetReparam → RAToEventFrame]: single-pass sampling with preserved density (no Gibbs, no density recovery). The pinned values have one owner (the chain), and the heterodyne consumes the proxy through prepared_data(conditioning=...) — one method with a uniform row-aligned contract serving pins and sweeps alike.
  • Chirp-mass scan (--chirp-mass-scan): sweeps the proxy over the training prior on the ordinary chain machinery (grid table root, per-row batched heterodyne, one network pass per block), selects the maximum-likelihood draw, and records the trigger + SNR in provenance. --fixed-context-parameters accepts the published demo-INI form.
  • A pinned sky position is rotated into the network's training frame at input and back at output; exactly zero for networks whose reference time is the trigger (all current ones).

Verification

  • Legacy-vs-composed bit-exactness through both pipe stages (NPE and GNPE with density recovery), IS evidence identical to recorded digits; the legacy samplers are deleted (~1200 lines) with the last containing commit tagged legacy-samplers-final.
  • BNS sampling bit-exact to float32 against the bns_add_dingo_pipe_max research branch on frozen event data; IS side verified live ≡ from-file bit-exactly.
  • Real GW170817 (cleaned LOSC frames + GWTC-1 PSDs) through the full pipe: with the demo pins, efficiency ~10–11%, log BF +510, chirp mass 1.19754 ± 0.00007; with only the sky pinned, the scan recovers the trigger (1.1976, SNR 33.0) and the evidence agrees with the pinned run within Monte Carlo error — the prior-conditioning invariance working in production.
  • GPU campaign (A100 server): full test suite green on Linux/CUDA; NPE sampling with the reverse-fold log_prob re-evaluated on-device (float32-level agreement); real GW170817 BNS pin and scan runs reproduce the reference evidences within Monte Carlo error (scan trigger 1.19756, snr 33.0, efficiency ~11%); GNPE with density-recovery training and prior-dict-updates exercised end-to-end on CUDA. Two device leaks in the previously CPU-only table-rooted chain path were found and fixed.
  • A standing regression battery (full suite + pinned BNS/NPE/scan harnesses) ran after every change in this arc.

Documentation

  • New pages: Sampling chains -- the factorized-sampler concepts page (the product-of-conditionals model, step types, the sampler context, multiplicity rules, the reverse density fold, provenance, and chain building, with figures) -- and Binary neutron stars -- the DINGO-BNS workflow (phase heterodyning, prior conditioning, the chirp-mass scan, and the dingo_pipe configuration).
  • Rewritten: Inference (the Python-API workflow page) and the injection example, which was executed end to end against a trained GNPE pair as part of the update.
  • Updated: GNPE (live inference routes replacing the removed dingo_analyze_event; a new single-step section) and dingo_pipe (the BNS sampler options; the base-domain likelihood default).
  • build_model_from_kwargs is re-exported from dingo.core.posterior_models so the documented import works. Build locally with uv run --group docs sphinx-build -b html docs/source docs/build.

Compatibility and deferred work

  • Pre-v0.9.0 models need compatibility/remove_domain_window_factor.py (unchanged story).
  • Deferred, tracked for follow-ups: Max's decimated/heterodyned fast likelihood (and the JAX layer from the paper), the synthetic-phase compute_likelihood fast path, embed-once on FlowWrapper, calibration-as-a-factor, per-event time scans for pre-merger networks, CUDA/server realism runs.

🤖 Generated with Claude Code

stephengreen and others added 17 commits June 30, 2026 10:44
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_<i, d)).

dingo/core/factors.py (domain-agnostic):
- Factor (ABC): physical-in/physical-out sample_and_log_prob / log_prob
- ChainComposer: autoregressive composition, topological-order validation,
  per-factor log-prob summation
- Standardization: per-factor mean/std adapter (both directions + Jacobian)
- ComposedSampler: thin run_sampler facade (batching -> 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
- 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) <noreply@anthropic.com>
…e-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) <noreply@anthropic.com>
- 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
… 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) <noreply@anthropic.com>
@stephengreen stephengreen requested a review from max-dax July 1, 2026 13:11
@stephengreen

Copy link
Copy Markdown
Member Author

@max-dax — this is the factorized-sampler spine we've been coordinating on sampler-revamp, now at a good point to look at: Increments 1a–4 are done, _post_process is fully eliminated, and it's bit-exact vs the old samplers throughout. Opened as a draft for design feedback before any dingo_pipe wiring. Would value your eyes on the Step structure and the two factors.py modules whenever you get a chance — it supersedes the per-factor batching and folds in the GNPE kernel/flow split we worked through.

stephengreen and others added 11 commits July 1, 2026 15:35
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…ep 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 <noreply@anthropic.com>
…texts

- 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…witch

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 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
stephengreen and others added 2 commits July 7, 2026 14:59
The .pt branch referenced a nonexistent args.checkpoint (the parser
defines file_name), so it could never have run, and both branches missed
a window_factor nested in a multibanded domain's base_domain -- which is
exactly where pre-fix models carry it. Both file types now strip the key
at either level.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ProxyOffsetReparam reconstructs a physical parameter from a
proxy-conditioned network's offset output, X = delta_X + X_proxy (the
DINGO-BNS chirp-mass pattern): a pure shift at fixed proxy (log_det = 0)
that consumes the offset column while keeping the proxy recorded with
the samples, which is precisely what makes the chain output
self-sufficient for inversion.

Inverting it needs the proxy, so Reparametrization.inverse gains a
"given" argument carrying the non-consumed conditioning still present in
the chain -- the third bijection to need its parameterization at
inversion (RA needs none: its rotation is a per-event constant from the
context; the spin-convention change needed the invariant conditioning
and had to raise). The reverse fold supplies it, and
SpinConventionReparam.inverse is now implemented through it
(physical -> network, roundtrip-tested), closing the documented
protocol gap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
stephengreen and others added 12 commits July 7, 2026 15:46
GWSamplerContext.from_model_metadata accepts fixed_context_parameters:
for a chirp-GNPE model, the raw strain is heterodyned once with the
fixed proxy (HeterodynePhase at the head of the data preparation, before
decimation, on the base domain); only proxy entries parameterize the
data preparation, so a pinned sky position is conditioning-only. Without
pins the network-input view fails loudly while the likelihood and prior
views keep working (from-file results reconstruct their context without
pins).

GWComposedSampler.from_model becomes the general single-network builder:
a model with context_parameters (the DINGO-BNS chirp-mass prior
conditioning with a fixed sky position) requires pins for exactly those
parameters, and the chain becomes [DeltaFactor(pins), conditioned
FlowFactor, one ProxyOffsetReparam per inferred offset with a pinned
proxy]. Plain-NPE assembly is unchanged.

ProxyOffsetReparam moves to dingo.core.factors: it is domain-agnostic (a
name-keyed shift), meeting the core criterion alongside DeltaFactor.

Verified on the DINGO-BNS GW170817 model (window-fixed copy): the chain
samples in a single pass with finite log_prob, chirp_mass minus the
proxy fills the +/-0.005 training kernel on noise-only data, the
re-plug through the offset inverse reproduces the stored density at
float32 roundoff, and to_result exports the tidal prior intact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The chirp-mass heterodyne now runs in parameters mode: prepared_data
receives the chain's conditioning from the conditioned factors, verifies
the required columns are constant, injects the values into the transform
chain under their physical names (extracted as Python floats, preserving
the float64 heterodyne path), and keys its cache on them -- a later call
with different values fails rather than silently serving stale data. The
pin values therefore have a single owner, the chain's root DeltaFactor,
consumed by both the network conditioning and the data preparation, so
the two cannot disagree. The context records only the conditioning names
its preparation is a function of.

The composer gains the point-mass rule: DeltaFactor declares
point_mass = True, and the requested sample count lands on the first
stage that carries multiplicity -- a root prefix of pins emits a single
row each, and the flow draws N in one conditioned call (one embedding),
with the prefix rows expanded to match. Chains with stochastic roots
(NPE, GNPE, sample tables) are unchanged -- verified bit-exact by the
pipe regressions -- and the BNS chain produces the same draws to
float32 kernel-order noise (measured ~1e-5).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add the --fixed-context-parameters option (the demo-INI dict form) and
pass it through SamplingInput to the composed single-network builder, so
context-conditioned models (single-step chirp-mass GNPE, e.g. binary
neutron stars) run through the pipe. Combining it with model-init raises.
Verified end-to-end on real GW170817 data (demo INI adapted to the exact
base-domain likelihood; efficiency 11.4%, pins and full chain provenance
in the result) and bit-exact on the no-pins NPE pipe regression.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sweep the chirp-mass proxy over the training prior on the ordinary chain
machinery -- a SampleTableFactor grid root and a conditioned FlowFactor
drawing per grid row -- and take the maximum-likelihood draw as the
trigger value (Dax et al., Nature 639, 49 (2025)). The scan runs the
grid in blocks and selects with a phase-marginalized likelihood that now
optionally reports the matched-filter SNR (return_aux_snr).

To support this, prepared_data gains a uniform contract: with
conditioning, the result is row-aligned -- one data row per conditioning
row -- with a pinned value prepared once behind a value-keyed memo and
varying values prepared in a single batch-native pass. Columns the
preparation does not consume condition the network alone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add the --chirp-mass-scan option ('true' for model-derived defaults, a
dictionary for overrides; mutually exclusive with a pinned
chirp_mass_proxy and with model-init). The sampling stage runs the scan
once before building the sampler, fills the winning trigger into the
fixed context parameters, and records the scan settings and winner
alongside the chain provenance. Verified end-to-end on GW170817 with
only the sky position pinned: the scanned trigger matches the posterior
median, and the evidence agrees with the externally-pinned run within
Monte Carlo error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
dingo/gw/inference/factors.py (1673 lines) becomes three modules with
one concern each, moved verbatim:

  context.py  GWSamplerContext and the frequency-range update helper
  steps.py    the GNPE factors, SyntheticPhaseFactor, and the
              coordinate reparametrizations
  sampler.py  GWComposedSampler and its chain builders

All imports are retargeted repo-wide (pipe, result, importance
sampling, scan, tests, docs); no compatibility shim is kept, since the
module was introduced on this branch and has no external consumers.
Pure relocation -- no code changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A parameter that is frame-corrected at the output must be inversely
corrected at the input: a sky position pinned at the event time now
enters the network as ra@t_ref via RAToTrainingFrame, and the trailing
RAToEventFrame (formerly RAReparam; renamed as a directional pair)
restores the event-frame value in the samples, so likelihood and
provenance stay physical. The rotation is exactly zero when the event
time equals the training reference time, as for all current networks.

Supporting changes: FlowFactor applies its alias map to conditioning
names as well as outputs; the composer validator models consumed
columns; and the chain fold defers the base count past 1:1 steps
(reparametrizations, target corrections) to the first sampling stage,
with a mock-level regression test for that chain shape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New advanced-guide page documenting the factorized sampler: the
product-of-conditionals model, step types, the sampler context,
multiplicity rules, the reverse density fold, provenance, and chain
building, with mermaid figures for the chain, the context, and the
combined system. Also fixes two unterminated inline-code spans in the
ChainComposer docstring that produced docutils warnings under autodoc.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Refine the density presentation on the sampling-chains page: the
product of conditionals is owned by the factors, which may be
stochastic or point masses (conditioned on, not integrated over), while
a reparametrization is a bijective change of variables that removes its
inputs from the table and contributes a Jacobian term rather than a new
factor. The Dirac-delta reading of deterministic steps is dropped
except for point masses, where it is exact. Also states the composer's
consistency check in the intro and introduces each step type by what it
does rather than what it is not.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New advanced-guide page for DINGO-BNS inference: phase heterodyning as
single-step chirp-mass GNPE, prior conditioning with pinned context
parameters, the fixed-proxy chain, the chirp-mass scan, and the
dingo_pipe options (fixed-context-parameters, chirp-mass-scan) with a
GW170817 configuration example. Adds the Nature 2025 reference and
links the page from the sampling-chains examples.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
inference.md becomes a proper workflow page: the two inference routes,
the event_data contract, the sampler builders, running and output, and
the refreshed Injection section. The injection example is updated to
the current API and was executed end to end against a trained GNPE
model pair; its ASD line now restricts the dataset to the model's
detectors. The GNPE inference section replaces the removed
dingo_analyze_event script with the dingo_pipe and Python routes.
build_model_from_kwargs is re-exported from dingo.core.posterior_models
so the documented import works.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
gnpe.md gains a single-step section covering the two density-preserving
uses (density-recovery re-sampling and fixed BNS proxies). dingo_pipe.md
points single-network context-parameter models at
fixed-context-parameters / chirp-mass-scan on the BNS page and notes
the base-domain likelihood default for multibanded models.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@stephengreen

stephengreen commented Jul 13, 2026

Copy link
Copy Markdown
Member Author

@max-dax For reviewing, I would recommend starting by reading the updated documentation, mainly the sections on "Sampling chains" and "Binary neutron stars".

stephengreen and others added 9 commits July 14, 2026 08:21
Conflict resolutions: result.py takes the upstream comment fix (the
numpy>=2 bilby prior workaround merged cleanly); test_result.py keeps
both new test sections (upstream statistical properties + the
sampler-context lifecycle test); test_likelihood.py combines the two
independently added suites (upstream inner-product/marginalization
tests + the decomposition/d_inner_h tests).

Known follow-up: #387 also added tests for the legacy samplers, which
this branch removed; those tests are triaged in the next commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The merge from hackathon-1 brought in #387's tests for the removed
legacy samplers. Of their 32 tests, 17 are adapted to the new API
(composed-sampler counting/batching, FlowFactor log_prob round-trips
including the data-conditional branch, the frequency validators, the
sidereal shift magnitude, delta-prior filling, and Result export round
trips), 7 are dropped as duplicates of existing coverage, and 8 are
dropped as obsolete along with the removed Sampler machinery. All other
#387 test files pass unchanged and are kept.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GPU testing of the chirp-mass scan exposed two device leaks: the
SampleTableFactor emitted its fixed table on the CPU, feeding CPU
conditioning into a CUDA network, and the consumed conditioning columns
reached the numpy data-preparation world as CUDA tensors. The table now
joins the chain on context.device (the same policy as DeltaFactor), the
conditioning columns are normalized to the host alongside their float64
dtype, and the scan moves chain outputs to the CPU before numpy. Adds a
CUDA-gated regression test. Validated on an A100: the GW170817 scan
recovers the trigger (1.19758, snr 32.9) and matches the pinned run's
evidence within Monte Carlo error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Covers Result.update_prior directly: the string-form metadata round
trip, reweighting before and after importance sampling (hand-computable
PowerLaw-vs-Uniform tilt on identical support), and a from-file reload
rebuilding the evolved prior rather than the training prior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
bilby's PriorDict instantiates its input dict in place, replacing the
string values with Prior objects. The stored metadata was already
protected by a copy; the caller's dict was not, a latent behavior
dating back to the introduction of prior updates. Instantiate from a
copy and assert the caller's view in the tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replaces the hand-written entry with the INSPIRE export (same texkey
and fields, verified against the published paper; adds the LIGO report
number).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Removes the four mermaid blocks and their captions (100 diff lines),
bringing the PR to 9,988 changed lines against a suspected 10,000-line
limit. To be reverted after the review runs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
stephengreen and others added 2 commits July 14, 2026 18:00
The auxiliary SNR in the phase-marginalized likelihood reused the
marginalized-likelihood term ln_i0(|kappa2C|) in place of the
phase-maximized matched-filter statistic |kappa2C|, biasing every
reported scan SNR low by ~0.5*ln(2*pi*|kappa2C|)/sqrt(rho2opt). Trigger
selection was unaffected (it maximizes the log-likelihood, which was
correct). Found by the cloud review; adds a regression test pinning the
statistic against an independent computation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
misc_scripts/evaluation.py still imported the removed GWSampler; port
it to GWComposedSampler.from_model. The --log-probs help text
concatenated adjacent string literals without a space.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread dingo/gw/likelihood.py
# Phase-maximized matched-filter SNR: |kappa2C| / sqrt(rho2opt).
# (ln_i0(|kappa2C|) is the phase-marginalized likelihood term
# above, not a matched-filter statistic.)
snr = np.abs(kappa2C) / rho2opt**0.5

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@max-dax flagging this line for a look — it feeds the trigger SNR that the chirp-mass scan logs and stores in provenance.

The deep review caught that this previously reused the phase-marginalized likelihood term ln_i0(|kappa2C|) as the SNR numerator. The intended statistic is the phase-maximized matched-filter SNR: under the (2,2) approximation, <d, h(phi)> = |kappa2C| cos(2 phi - arg kappa2C), so maximizing over phase gives |kappa2C| / sqrt(rho2opt) (with rho2opt phase-invariant). By contrast, ln_i0(|kappa2C|) ~ |kappa2C| - 0.5 ln(2 pi |kappa2C|) is the marginalized (integrated) weight — the maximized value minus the phase-volume Occam penalty — so the old value was biased low (GW170817 scan: 32.89 → 32.99 after the fix). Trigger selection was never affected, since the scan argmaxes the log-likelihood, which was always correct.

A regression test pins the statistic now (test_phase_marginalized_aux_snr_is_phase_maximized). Note return_aux_snr is new in this branch — it did not come from #354; the PR description's earlier attribution was wrong and has been corrected.

@stephengreen stephengreen marked this pull request as ready for review July 14, 2026 17:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants