Reimplement particle MCMC (SMC and PG) natively, removing AdvancedPS - #2853
Reimplement particle MCMC (SMC and PG) natively, removing AdvancedPS#2853yebai wants to merge 58 commits into
Conversation
SMC/PG/CSMC are reimplemented directly on Libtask + DynamicPPL, dropping the AdvancedPS dependency. A particle is a suspended model execution advanced one observation at a time; the conditional-SMC reference trajectory is regenerated by replaying a per-particle TracedRNG (Random123 Philox), so no reference values need to be stored, and a child forked from the reference is simply reseeded to branch off. All particle state lives explicitly on the particle via Libtask's taped globals -- no task_local_storage -- and @addlogprob! reweights by producing inside the likelihood accumulator, so no type piracy is required. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Charles Knipp <32943413+charlesknipp@users.noreply.github.com>
- Inline traced_rng.jl into particle_mcmc.jl as a section (it must precede Particle/PGState, which name TracedRNG in their types). - Merge the container unit tests into test/mcmc/particle_mcmc.jl and remove the now-empty essential test group. - Drop the set_taped_globals! call in fork: deepcopy already preserves the task-particle back-reference (verified against the full particle suite). - Add WHY notes for the might_produce hints and the reference RNG rewind. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Charles Knipp <32943413+charlesknipp@users.noreply.github.com>
Stratified resampling satisfies the unbiased-offspring condition needed for the particle Gibbs invariance proof (Andrieu, Doucet & Holenstein, 2010) and is consistent as N grows. Systematic resampling shares the expected offspring counts but is order-dependent and not consistent in general (Gerber, Chopin & Whiteley, 2019), so it sits outside the invariance proof. Make stratified the default scheme for ESSResampler (hence for SMC and PG); systematic remains available explicitly. A note in the resampling section records the trade-off. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Charles Knipp <32943413+charlesknipp@users.noreply.github.com>
Chain metadata recomputes the log-prior and Jacobian terms from a particle's raw values, so accumulating them during the sweep was wasted work; particles now carry only the produce-aware likelihood accumulator and the raw values. Metadata stays correct (test_chain_logp_metadata passes for SMC and PG). Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Charles Knipp <32943413+charlesknipp@users.noreply.github.com>
resample_propagate! deepcopied every resampled slot; deepcopying a Libtask TapedTask is the dominant allocation in a sweep, so this made PG allocate ~40% more than the AdvancedPS implementation (59.9M vs 41.9M allocations; ~22% slower). Reuse each surviving parent's object for its first offspring, deepcopying only additional offspring and any descending from the retained reference (as AdvancedPS does). Both kinds of child are reseeded via the new `reseed!` helper. Allocations now match the old implementation and PG is slightly faster; CSMC reference reproduction is unchanged (0 mismatches over the reference-consistency check, full suite green). Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Charles Knipp <32943413+charlesknipp@users.noreply.github.com>
Open the file with a concise "Key design" overview (the observe-as-filtering-step idea, the RNG-replay reference mechanism, and the fresh-seed-per-step invariant it rests on) plus a section map. Move the threadsafe-eval guard into the model-evaluation section with a note on why particle samplers require it, and note the log-evidence telescoping in the sweep. Comments only; no behaviour change. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Charles Knipp <32943413+charlesknipp@users.noreply.github.com>
Add a HISTORY.md breaking-change entry for the native SMC/PG reimplementation (AdvancedPS removed, resamplers are now types, stratified is the new default), and add test_rng_respected coverage for SMC and PG so RNG determinism is locked in. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Charles Knipp <32943413+charlesknipp@users.noreply.github.com>
Bring across the valuable tests the ck/smc branch added, adapted to the native internals: - resampling schemes: stratified and multinomial both hit the analytic coinflip posterior, and the two schemes produce genuinely different draws; - CSMC reference consistency: over 30 conditional sweeps the reference particle regenerates the retained trajectory exactly and its traced-RNG keys stay aligned with the trajectory length (previously only checked in a scratch script); - @addlogprob! is also respected under MH, not just PG; - a particle advanced without resampling matches a direct init!! evaluation (values and log-likelihood) seeded identically. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Charles Knipp <32943413+charlesknipp@users.noreply.github.com>
SMC(Systematic()) and friends require Turing.Inference qualification: the scheme types are not exported, and a clean export is blocked by the Multinomial resampler colliding with the re-exported Distributions.Multinomial. Document this in the SMC docstring (whether to export a renamed subset is a separate API decision). This matches the previous AdvancedPS-qualified API, so it is not a regression. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Charles Knipp <32943413+charlesknipp@users.noreply.github.com>
inc_step! only ever advances by one and set_step! was only ever called as set_step!(_, 1). Remove the unused `n` parameters: inc_step! takes no count, and set_step! becomes rewind! (reset to the first step), which also reads clearer at the call site where the reference RNG is rewound for replay. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Charles Knipp <32943413+charlesknipp@users.noreply.github.com>
SMC's `sample` override already calls error_if_threadsafe_eval before delegating to mcmcsample, so the same check at the top of SMC's first `step` never fires on a distinct path. Remove it. PG keeps its check, being the only guard there (PG has no `sample` override). Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Charles Knipp <32943413+charlesknipp@users.noreply.github.com>
Use DynamicPPL.LogProbType for particle log-weights and the log-evidence rather than a hardcoded Float64, and make ESSResampler parametric on its threshold type so it keeps whatever Real the user passes. Also bound the previously-open type parameters (TracedRNG's key type as <:Unsigned, SMCState's fields as <:AbstractVector) so every parametric struct carries meaningful subtype bounds. Integer types were already platform-native `Int`, not `Int64`. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Charles Knipp <32943413+charlesknipp@users.noreply.github.com>
|
Turing.jl documentation for PR #2853 is available at: |
Random123 is only used by the TracedRNG in particle_mcmc.jl, so scope the import there rather than in Inference.jl. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Charles Knipp <32943413+charlesknipp@users.noreply.github.com>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #2853 +/- ##
==========================================
+ Coverage 85.09% 86.14% +1.05%
==========================================
Files 23 23
Lines 1516 1566 +50
==========================================
+ Hits 1290 1349 +59
+ Misses 226 217 -9 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
The native SMC/PG reimplementation changes the resampler API and default, so give it its own breaking release: move the changelog entry under a new 0.47.0 heading and bump the version. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Charles Knipp <32943413+charlesknipp@users.noreply.github.com>
`Random.seed!(trng::TracedRNG, key)` took an untyped `key`, making it ambiguous with `Random.seed!(::AbstractRNG, ::Nothing)` (Random stdlib) for the call `seed!(::TracedRNG, ::Nothing)`. The Aqua ambiguity check flags this on CI (the conflicting Random method isn't present in every local Random, so it didn't reproduce locally). The seed is always an integer, so constrain `key::Integer`, which removes the overlap with `::Nothing`. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Charles Knipp <32943413+charlesknipp@users.noreply.github.com>
`split_key` derived a fresh seed by re-seeding a stdlib `MersenneTwister`. That is fragile two ways: re-seeding to split a generator can yield correlated streams (Steele et al., OOPSLA 2014), and MersenneTwister streams are not reproducible across Julia versions, so SMC/PG results drifted between versions even under a StableRNG. Both affected the previous AdvancedPS implementation (#2781, AdvancedPS.jl#110). Derive the seed through Philox instead -- a counter-based generator with a fixed, portable algorithm and strong avalanche -- which is both well-decorrelated and version-stable. The full particle suite, including exact CSMC reference reproduction, stays green. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Charles Knipp <32943413+charlesknipp@users.noreply.github.com>
Add the foundational particle MCMC reference to the module's design note, where the short "(ADH 2010)" citations in the resampling notes point. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Charles Knipp <32943413+charlesknipp@users.noreply.github.com>
The native particle-MCMC RNG (Philox seed derivation) changes the exact draws, and `StableRNG(23)` now lands a ~2.7σ tail draw for E[s] that just exceeds atol=0.1 at 3_000 iterations. The estimator is unbiased -- E[s] scatters tightly around the true 2.042 across seeds -- so the fix is simply more headroom: CSMC mixes the variance slowly, and 10_000 draws bring the error comfortably inside atol on every Julia version and platform. Co-Authored-By: Charles Knipp <32943413+charlesknipp@users.noreply.github.com> Co-Authored-By: Claude Code <noreply@anthropic.com>
`SMC(; threaded=true)` and `PG(n; threaded=true)` now evaluate the particles across threads within each sweep. Only the per-particle model advances (the expensive part) run in parallel; resampling stays serial, and because every particle's RNG is seeded serially in `resample_propagate!` before the parallel region, the threaded run reproduces the serial draws bit for bit. The default (serial) path keeps its scalar tally and allocates nothing extra. `threaded` is a keyword-only field: an inner constructor suppresses the positional default constructor, so it cannot collide with the existing `(scheme, threshold::Real)` forms (`Bool <: Real`). HISTORY.md gains notes on this, on the cross-version RNG reproducibility, and on the weight-diagnostic columns, plus a mention that multiple chains run under `MCMCThreads()` / `MCMCDistributed()`. Co-Authored-By: Charles Knipp <32943413+charlesknipp@users.noreply.github.com> Co-Authored-By: Claude Code <noreply@anthropic.com>
There was a problem hiding this comment.
Overall, I think this is a very nice revision of #2848. Although, there are a few minor choices I intentionally made when drafting that should be reconsidered here.
- Passing
AbstractMCMCEnsemblethrough toreweight!(only for SMC) for consistency - Producing the log score upon
tilde_observe(oraccloglikelihood!!for@addlogprob!) to ensure log score is up to date with log likelihood accumulation.
Addresses Charles's review comment on internal consistency. Emit the per-step `produce` from `tilde_observe!!` and an `accloglikelihood!!` overload -- after the log-likelihood accumulator has been updated -- instead of from inside `acclogp` before it. This removes the one-step lag between a particle's produced weight and its accumulated log-likelihood, and makes `@addlogprob!` terms reach the accumulator (hence the reported log-likelihood), not only the weight. The produced score is the accumulator's increment, so the accumulator stays the single source of truth: `acclogp` now inherits the generic `LogProbAccumulator` method, and `ProduceLogLikelihoodAccumulator` is just a marker type flagging a particle's varinfo so the produce sites know to emit. Co-Authored-By: Charles Knipp <32943413+charlesknipp@users.noreply.github.com> Co-Authored-By: Claude Code <noreply@anthropic.com>
Per Charles's review: SMC is not an MCMC (nor PMCMC) algorithm, so `SMCContext` names the leaf context more accurately. Pure rename, internal to particle_mcmc.jl. Co-Authored-By: Charles Knipp <32943413+charlesknipp@users.noreply.github.com> Co-Authored-By: Claude Code <noreply@anthropic.com>
Per Charles's review: `Particle{RT<:TracedRNG,WT<:Real}`, so `logweight` tracks
`DynamicPPL.LogProbType` and follows it if that is ever changed. Behaviour is
unchanged -- taped-globals access stays type-unstable as before (the `::Particle`
typeassert still holds for the parametric type), so this only concretises the
stored particle's field types.
Co-Authored-By: Charles Knipp <32943413+charlesknipp@users.noreply.github.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
Answers Charles's review question ("is it always InitFromPrior?"). Verified that a
variable is never already present at `tilde_assume!!` across SMC, PG, CSMC and
PG-in-Gibbs: particle varinfos start empty, each variable is assumed exactly once,
and the CSMC reference reproduces its trajectory by replaying RNG seeds rather than
reusing stored values. So `InitFromPrior` is always correct, and a conditional
`InitFromParams` branch would be dead code unless the varinfo were pre-populated
(e.g. initial_params for particle samplers, which is not currently supported).
Documented rather than adding the inert branch.
Co-Authored-By: Charles Knipp <32943413+charlesknipp@users.noreply.github.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
Per Charles's review: the retained particle already carries the reference trajectory's varinfo and rng needed to resume the next conditional sweep, so particle Gibbs needs no separate state struct. `const PGState = Particle` keeps the semantic name at the PG call sites while adding no new type. `gibbs_update_state!!` now updates the reference varinfo in place, which is safe -- Gibbs replaces the state with the returned value and never reads the pre-update one again. Co-Authored-By: Charles Knipp <32943413+charlesknipp@users.noreply.github.com> Co-Authored-By: Claude Code <noreply@anthropic.com>
…ate the two levels Renames the within-sweep parallelism keyword `SMC`/`PG(; threaded=...)` to `multithreaded`, and documents (in HISTORY and a note at `reweight!`) that it is a distinct axis from AbstractMCMC's chain-level ensemble. Within-sweep threads a single sweep's particle evaluations; the ensemble (`MCMCThreads`/`MCMCDistributed`) runs whole chains independently; the two compose. Only threading is offered within a sweep -- particles resample every step (all-to-all) and are live Libtask tasks, so distributing one sweep across processes would be communication-bound rather than a speed-up. Addresses Charles's review that the parallelism description conflated the two levels, and follows the discussion that these are genuinely different axes. Co-Authored-By: Charles Knipp <32943413+charlesknipp@users.noreply.github.com> Co-Authored-By: Claude Code <noreply@anthropic.com>
The ensemble wrapper's injected `InitFromPrior` warned spuriously, and `callback` was dropped in silence. Co-Authored-By: Charles Knipp <32943413+charlesknipp@users.noreply.github.com> Co-Authored-By: Claude Code <noreply@anthropic.com>
The line above already derives `logZ` from it, so the two could silently diverge. Co-Authored-By: Charles Knipp <32943413+charlesknipp@users.noreply.github.com> Co-Authored-By: Claude Code <noreply@anthropic.com>
Values and addresses were separately defaulted, so passing only values type-checked and did nothing. Co-Authored-By: Charles Knipp <32943413+charlesknipp@users.noreply.github.com> Co-Authored-By: Claude Code <noreply@anthropic.com>
…t claimed Multinomial conditional sweeps took |err| on `s` from 0.024 to 0.061 against `atol = 0.1`. Co-Authored-By: Charles Knipp <32943413+charlesknipp@users.noreply.github.com> Co-Authored-By: Claude Code <noreply@anthropic.com>
This branch removed AdvancedPS and also creates AGENTS.md, so it contradicted itself on landing. Co-Authored-By: Charles Knipp <32943413+charlesknipp@users.noreply.github.com> Co-Authored-By: Claude Code <noreply@anthropic.com>
It shadows DynamicPPL's method for every `OnlyAccsVarInfo`; duplicating the body would let it drift from upstream unnoticed. Co-Authored-By: Charles Knipp <32943413+charlesknipp@users.noreply.github.com> Co-Authored-By: Claude Code <noreply@anthropic.com>
`[1,1,2,2,1,4]` are the labels, not the means; the 0.072 offset spent half the tolerance and made gibbs.jl flaky. Co-Authored-By: Charles Knipp <32943413+charlesknipp@users.noreply.github.com> Co-Authored-By: Claude Code <noreply@anthropic.com>
Drawing from the prior is only half of what `tilde_assume!!` does under it. Co-Authored-By: Charles Knipp <32943413+charlesknipp@users.noreply.github.com> Co-Authored-By: Claude Code <noreply@anthropic.com>
The same tree both passed and failed in CI; a lingering Task is OS-independent. Co-Authored-By: Charles Knipp <32943413+charlesknipp@users.noreply.github.com> Co-Authored-By: Claude Code <noreply@anthropic.com>
00574bc to
7b12f5f
Compare
…dead Pinning the CSMC reference to the retained trajectory by value made the seed-recording half of `TracedRNG` unreachable: every reference draw is now supplied by `InitFromParams`, so the reference never consults its own generator. Two checks confirm it. Scrambling the reference's seed before every step leaves the trajectory it regenerates untouched; and scrambling it throughout the particle suite breaks exactly one test, `"rng replay"`, which builds a reference with no retained particle -- a shape no sampler constructs. So `count`, `keys`, `save_state!`, `load_state!`, `inc_step!`, `rewind!` and `inner_key` go. That leaves `TracedRNG` with no state of its own, and since Random123's `seed!` already zeroes the counter the wrapper has nothing left to do either: particles now hold a plain `Random123.Philox2x` from `particle_rng`. Three `Random` forwarding methods go with it, including the one that needed an ambiguity fix. Being the reference then stops being a fact about a particle's slot and becomes one about the particle -- `expected_reference_varnames !== nothing`, exposed as `isreference`. `advance!` loses its `isref` argument, which lets `reweight!` lose `conditional`, which drops the index-versus-`n` arithmetic from the reweighting path entirely. The reference carries the retained generator forward instead of taking a fresh one, so the sweep draws nothing from the sampler rng on its behalf. Draws are therefore bit-identical to before across SMC, PG under two resampling schemes, and Gibbs(CSMC + HMC). Comments and section headers get a pass here too, since most of the long ones described the machinery being removed. The file banner becomes a level-1 header like every other section, the two longest sections gain subsection rules, and the hand-maintained "Sections below" index is gone -- the headers make it redundant, and it had already gone stale naming "traced RNG". Co-Authored-By: Claude Code <noreply@anthropic.com>
…er's name `Turing.Inference` had accumulated `ESS`, `ESSLikelihood`, `ESSPrior` and `TuringESSState`, all elliptical slice sampling, and then `ESSResampler`, which is about effective sample size. Three of the four `ESS*` names mean one thing and the fourth means another, so `SMC(ESSResampler(0.5))` reads ambiguously next to `Gibbs(:s => CSMC(15), :m => ESS())`. `ESSThresholdResampler` says what triggers the resampling and matches the `AdvancedPS.ResampleWithESSThreshold` it replaces, which also eases migration. Separately, `Turing.Inference.ess` and `Turing.ess` were two *different* functions: the former the weight degeneracy of one particle population, the latter `MCMCDiagnosticTools.ess` measuring a chain's autocorrelation. Renaming ours to `weight_ess` removes the collision, and its docstring now names the distinction so the next reader does not have to rediscover it. Both names are unexported, and 0.47.0 is unreleased, so nothing downstream depends on them yet. Co-Authored-By: Claude Code <noreply@anthropic.com>
`SMC` and `PG` both put `log_normalizing_constant` in the chain, under one name and one per-iteration shape, and HISTORY.md called it the marginal-likelihood estimate for both. Only `SMC`'s deserves that description. A conditional sweep retains the reference whatever its weight, and the reference is a draw from the posterior rather than from the proposal, so it carries far more likelihood than a fresh particle and inflates the mean weight at every step. Measured against the exact p(y) of a linear Gaussian SSM: SMC's `E[Ẑ/p(y)]` is 0.945 ± 0.040 at 16 particles and 1.029 ± 0.020 at 64, both consistent with 1, while PG's is 1.80 and 1.16 -- an 80% overestimate of the marginal likelihood at 16 particles. It decays like 1/n but stays large at any practical n. `mean(exp.(chain[:log_normalizing_constant]))` was therefore silently wrong under PG, in the direction that favours whichever model was fitted with fewer particles. The existing test could not have caught this. Its model pins `x = 1` through `x ~ Bernoulli(1)`, so both observes contribute exactly `log(1/2)` for every particle; with zero weight variance the estimate is exact for conditional sweeps too, which is also why the test could assert that all iterations agree. That degeneracy is now spelled out where it could otherwise read as evidence of unbiasedness, and a second testset pins the bias against a closed-form Beta-Bernoulli p(y): SMC's ratio averages to 1, PG's exceeds 1.05. Co-Authored-By: Claude Code <noreply@anthropic.com>
Until now nothing pinned SMC/PG to a known answer. These add a scalar linear Gaussian SSM and a discrete HMM, both with closed-form posteriors, and check each twice: `PG` alone against the exact smoothing marginals, then `Gibbs(theta => NUTS/HMC, states => CSMC)` with a static parameter unknown. The second case is the one worth having -- the states' distribution depends on the theta the *other* Gibbs component owns, so it only passes if the CSMC reference stays pinned to its retained trajectory as the model is re-conditioned between sweeps. The exact parameter posterior comes from quadrature against the closed-form likelihood rather than from a second sampler, and the theta-mixed state marginals follow from the laws of total expectation and variance over the same grid. Tolerances are batch-means standard errors, not hard-coded `atol`, so they stay meaningful as mixing changes instead of going vacuous or flaky; HMM states rarer than 0.02 are skipped, since a bursty 0/1 indicator makes that estimate unreliable. `ExactSSM` validates itself rather than asking to be trusted: the closed-form Gaussian smoother is cross-checked against an independent Kalman/RTS recursion, and forward-backward against enumeration of all K^T state paths, both to 1e-12, as part of the suite. The models are defined at module scope deliberately. Inside a testset they would share a local scope with the simulated `x`/`z`, which makes those captured locals that the model body then rebinds -- so every particle mutates one shared array and the posterior is silently wrong. That cost an afternoon; the simulated truth is now named `xtrue`/`ztrue` and both sites say why. GeneralisedFilters would have been the natural source of ground truth, but it cannot go in the test environment: v0.4.2 pins CUDA to 5.0-5.11 while Mooncake, which the AD tests need, requires CUDA 6.x. Two things found while trying, worth knowing if it is ever revisited: its prior sits at t = 0 with one transition applied before the first observation, so comparing it against a naturally-written Turing model is silently wrong unless the prior is stationary (1.5e-3 in log p(y) here, 4.2e-2 for the HMM); and `smooth` throws a convert MethodError on v0.4.2. Co-Authored-By: Claude Code <noreply@anthropic.com>
…iracy `@addlogprob!` bypasses `tilde_observe!!`, so its weight was emitted from a method on `DynamicPPL.accloglikelihood!!(::DynamicPPL.OnlyAccsVarInfo, ...)` -- piracy, since both the function and the type belong to DynamicPPL, and it shadowed upstream's method for *every* `OnlyAccsVarInfo` in the ecosystem while only particle sampling exercised it. Every other sampler used Turing's copy and none of them tested it. There was a Turing-owned dispatch point available all along, and it is where `main` had this before 75d3c0b moved it out: `acclogp` on the accumulator. Both routes to the likelihood -- an `observe` via `accumulate_observe!!`, and `@addlogprob!` via `accloglikelihood!!` -> `map_accumulator!!` -- pass through exactly one `acclogp` call, so producing there emits exactly one weight per term and reaches `@addlogprob!` (issue #1996) without touching anything DynamicPPL owns. It is also what 75d3c0b was after. That commit moved the produce to the call sites so the emitted weight would agree with the accumulated log-likelihood, and enforced it by diffing `getloglikelihood` either side of the update. Inside `acclogp` the increment *is* the argument, so the agreement is structural: `tilde_observe!!` loses the before/after diff, the second produce site goes, and `ProduceLogLikelihoodAccumulator` stops being a marker that other code consults and becomes the mechanism. Three things worth recording, since they are what make this safe rather than merely shorter. Accumulator merging cannot fire a spurious produce: `combine` sums the two `logp`s directly instead of going through `acclogp`, which is also why `main` needed a `might_produce` method on `Base.:+` and this does not. `produce` suspends before the caller stores the varinfo back on the particle, so a suspended particle's total lags one term -- nothing reads it in that state, as the sweep reweights from `logweight` and every varinfo read happens after the model finishes. And draws are bit-identical to before across SMC, PG under two resampling schemes, and Gibbs(CSMC + HMC): the produce fires from a different place, in the same order, with the same values. Co-Authored-By: Claude Code <noreply@anthropic.com>
A pass over the branch for duplication and derivable state. None of it changes behaviour: draws are
bit-identical to the previous commit across SMC, PG under two resampling schemes, and
Gibbs(CSMC + HMC), checked before and after each source edit.
In the sampler:
- Stratified and systematic resampling were the same walk up the cumulative weights, differing
only in where each stratum's offset comes from. They now share `inverse_cdf_indices`, and the
guard against `weights` summing to slightly under one is stated once instead of twice. The
offsets stay scalar `rand` draws inside the loop -- `rand(rng, n)` would read better but Julia
fills arrays through a SIMD path that yields a different stream, silently changing every result.
- `resample_propagate!` no longer takes `conditional`, nor `sweep!` a keyword to thread it: the
reference occupies the last slot, so `isreference(last(particles))` is the single source of
truth, which is what `isreference` exists for.
- `reference_values` and `expected_reference_varnames` had to be set and cleared together, with
only a comment enforcing it. They are now one `reference::Union{Nothing,@NamedTuple{…}}` field,
so a half-specified reference is unrepresentable and `reseed!` is a single assignment.
- `Particle` built its own varinfo at all 23 call sites, which could take only one value, via a
`deepcopy` of a freshly allocated object. It now calls `particle_varinfo()` itself.
- `sweep!` recomputed the entering total weight every step. Resampling zeroes every weight, so it
is then exactly `log(n)`; otherwise the weights are untouched and it is still last step's total.
- `ess_per_step` is now behind `ess=true`, which only `SMC` passes. `PG` discarded it on every
sweep -- a gather, a softmax, a `sum(abs2)` and a `push!` per observation, thrown away
thousands of times per chain.
In the tests, `coinflip` was defined five times, `test()` three, `normal()` twice and the threadsafe
model twice; those move to module scope with a shared observation vector, and eight copies of
`while advance!(p) !== nothing end` become `run_to_end!`. Two of these were not mechanical: the
reference-replay testset's `normal()` is centred at zero rather than four, so it became
`centred_normal()` rather than being folded into the others; and the CSMC-consistency test was
reimplementing `pg_transition_and_state`'s ancestor draw, so it would have kept passing if the real
selection rule drifted -- it now calls the sampler's own function.
Deliberately not done: hoisting the per-resample scratch buffers and the weight vector into
`sweep!`. Measured, `fork`'s `deepcopy` is about 2.3 MB per sweep step against 2.7 kB for all the
weight machinery combined, so that is a 0.1% change bought with persistent mutable state threaded
through two functions.
Co-Authored-By: Claude Code <noreply@anthropic.com>
…ierarchy Each state space model was written twice, once with its parameter sampled and once with it passed in, so the PG test and the Gibbs test could drift into testing different models. The fixed-parameter tests now `fix` the parameter on the single sampled model instead. `fix` and not `condition`, and the difference is not cosmetic: `fix` substitutes the value without adding a log-density term, whereas `condition` turns the assume into an observe and so adds a produce -- another filtering step in the sweep. Measured on the linear Gaussian model with a fixed seed, the explicitly-fixed model and `fix` give bit-identical draws while `condition` gives different ones (x[1] mean 0.5851 against 0.4925). The comment at the model records this. Separately, the section headers now follow the repo conventions properly. `particle_mcmc.jl` has two depths of structure -- major regions, and sections within them -- but rendered the inner depth as level-3 one-line rules directly beneath level-1 frames, skipping level 2; those seven are now `##` frames. The test file had one header in 780 lines, so its real region boundaries (shared models, SMC, PG, chain-level parallelism, particle mechanics, the SSM checks) now have level-1 headers. Two headers had prose butted against the closing frame, against the blank-line rule. The source change here is comments only, and draws stay bit-identical. Co-Authored-By: Claude Code <noreply@anthropic.com>
Orphaned when the test switched to calling pg_transition_and_state instead of reimplementing the transition itself; nothing in the file references it now. Co-Authored-By: Claude Code <noreply@anthropic.com>
particle_varinfo's comment claimed the prior and Jacobian terms are recomputed downstream from the raw values, so accumulating them per particle would be wasted work. Both halves are wrong: OnlyAccsVarInfo ships LogPrior and LogJacobian by default, so every particle does accumulate them, and ParamsWithStats reads them straight off this varinfo to fill a chain's logprior and logjoint columns. Because that read is guarded by hasacc, acting on the old comment and dropping the accumulators would not error -- it would silently omit those columns, and fail test_chain_logp_metadata. The PG step comment still described the reference as regenerating its trajectory by replaying state.rng from the first step. That is the seed-replay scheme removed in 6c8247d; the reference reuses the retained values, which the comment a dozen lines below it already explains. Co-Authored-By: Claude Code <noreply@anthropic.com>
The existing value-replay tests cover a trace that changed and must be rejected. Nothing covered one that is legitimately different on every execution and must simply work, which is the property particle samplers uniquely support: k[t] decides how many jumps step t has, so the reference must reuse k[t] before it can reuse a jump vector of the matching length. The target is exact and needs no reference implementation, because tilting k ~ Poisson(1) by c^k is exactly Poisson(c). The tilt is the only informative term and is a function of the trace's shape, so a reference that replayed or reweighted the varying-length part wrongly would move E[k[t]] off c. Measured: with the tilt, E[k[t]] is 2.00-2.07 across the four steps; dropping it lands on the prior mean 1.0, roughly eight standard errors outside the tolerance, so the assertion separates the two rather than passing either way. Costs about 28s in the PG block. Co-Authored-By: Claude Code <noreply@anthropic.com>
The tolerance was eight iid standard errors' worth of slack expressed as four, because sqrt(tilt/ndraws) assumes independent draws and a PG chain is not independent. Measured across eight seeds, the batch-means standard error of E[k[t]] is about 2.3x the iid figure, which left the old bound only ~1.75 real standard errors wide; two of the eight seeds came within 13% of failing. Widening costs nothing in sensitivity. The failure mode being guarded against -- dropping the weight that depends on the trace's shape -- puts E[k[t]] at the prior mean 1.0, still four times the widened tolerance away from the target. Co-Authored-By: Claude Code <noreply@anthropic.com>
HISTORY.md was failing the format check. With format_markdown = true, a list item
containing a second paragraph makes JuliaFormatter rewrite the list: it indents
the separating blank line to four spaces, adding trailing whitespace, and spaces
out the sibling items to match. `format(".")` therefore reported a diff on a
clean tree, which the Format workflow would flag.
Merging the caveat into the bullet it belongs to removes the multi-paragraph item
and leaves `format(".")` clean and idempotent. It also removes an ambiguity worth
fixing on its own: "For SMC this is an unbiased estimate" had ess_per_step as its
nearest antecedent, when what is unbiased is the normalizing constant, which the
sentence now names.
Co-Authored-By: Claude Code <noreply@anthropic.com>
|
Will merge this in 72 hours. |
charlesknipp
left a comment
There was a problem hiding this comment.
This largely resembles my initial redesign, so I don't hate it. As I point out, there are also some design changes which better motivate some of my earlier suggestions.
Reference Trajectories
My main criticism here is with respect to handling reference trajectories. This approach induces a ton of type unions and abstractions which are only necessary to accommodate a single reference trajectory.
In my PR, I introduce ReferencedContainer which assumes reference trajectories are the same type as other particles, so forking them during resampling ensures my particles are all type stable. To be fair, this is only possible with TracedRNG.
The PG inconsistency you pointed out was due to the missing DynamicPPL.InitFromParams in the tilde_assume pipeline, not the RNG tracing. Otherwise, the particle samplers in main are also incorrect.
AbstractMCMC Interface
My comment on AbstractMCMC.sample says it all. I think this is absolutely the right call, but it should be pushed further to adhere to the interface.
Passing PG(; multithreading=true) in conjunction with MCMCThreads() is not only nonsensical, but potentially dangerous. Playing devils advocate, I wonder if we could see some nice gains when pairing multithreaded sweep and parallel chains with MCMCDistributed().
Hooking into Libtask
While not mentioned in my comments, I have some concerns about the libtask usage here.
My original concern from the review was initially addressed, then subsequently replaced with the desynchronized accumulated varinfo. I understand this choice, but it still doesn't sit right with me.
I also don't like the self referential aspect of storing the entire particle in the taped globals. I think a much cleaner approach is outlined in my PR where we only need two things: the RNG and the varinfo; everything else is unnecessary storage. At the very least, I wonder if you could abuse Ref to avoid redundant copies.
Final Remarks
Compared to my implementation, this rewrite drops much of the elegance and modularity without provable gains to speed or efficiency. While I understand much of the design choices, the bloated containers and heavy indentation in functions like reweight and resample_propagate just leave a lot to be desired.
With all of that being said, if this is sufficient to pass the unit tests and guarantee correctness, it would be a waste not to merge.
| strategy = if reference === nothing | ||
| DynamicPPL.InitFromPrior() | ||
| else | ||
| vn in reference.varnames || error( | ||
| "the reference execution trace changed while replaying retained values " * | ||
| "(new address: $vn)", | ||
| ) | ||
| DynamicPPL.InitFromParams(reference.values, nothing) | ||
| end | ||
| ctx = DynamicPPL.InitContext(particle.rng, strategy, DynamicPPL.UnlinkAll()) |
There was a problem hiding this comment.
I left an earlier comment which questioned the exclusion of InitFromParams which is necessary for PG, and hence why you mentioned task based RNG wouldn't handle this alone.
There was a problem hiding this comment.
It sounds like that would require combining conditioning with InitFromParams for reference particles. I haven't explored that, but would be happy if there is a clean mechanism.
| reference::Union{ | ||
| Nothing, | ||
| @NamedTuple{values::DynamicPPL.VarNamedTuple, varnames::Set{DynamicPPL.VarName}} | ||
| } |
There was a problem hiding this comment.
Is this necessary on a per particle basis? I think this should be handled in the sampler code, like in my version.
There was a problem hiding this comment.
Instead of the named tuple, why not use a raw value accumulator?
| # Addresses assumed by this execution, in the same form as `reference.varnames`. Survives | ||
| # forking, so a particle that becomes the retained state hands the complete set to the next | ||
| # reference; a reference must finish having assumed exactly that set. | ||
| assumed_varnames::Set{DynamicPPL.VarName} |
There was a problem hiding this comment.
If I understand correctly, this is necessary for the reference varinfo type to match the rest of the particles. My original draft of the PR (which is now closed) needed this in order to do subsequent CSMC steps, but ultimately led me to reimplement the traced RNG approach.
| # AbstractMCMC's step loop (returning the population one particle at a time), we run the sweep | ||
| # and bundle the whole population into the chain in one shot. `discard_initial`/`thinning` | ||
| # therefore have nothing to apply to. | ||
| function AbstractMCMC.sample( |
There was a problem hiding this comment.
I like how you took the approach I suggested, dropping the AbstractMCMC.step paradigm. I think you should take this a step further.
I would argue that each particle trajectory is indeed Markov, which implies that a set of particle trajectories is the same structure as a set of parallel Markov chains. Therefore, I think including AbstractMCMCEnsemble here and dispatching it to sweep! would be the correct way to parallelize this algorithm that is still consistent with the AbstractMCMC interface.
This further strengthens my earlier suggestion to replace SMC(; multithreaded=true) by passing MCMCThreads() to a dispatched reweight. See my branch to understand how it is dispatched, since it works for both multiprocessing and multithreading.
| particles = [Particle(model, particle_rng(rng)) for _ in 1:(n - 1)] | ||
| push!(particles, reference) | ||
| logZ, _ = sweep!(rng, particles, sampler.resampler, sampler.multithreaded) |
There was a problem hiding this comment.
It seems like @code_warntype is going to have a field day here.
While I understand it is not entirely possible to have a fully parametric and type stable SMC sampler with DPPL, my version at least guarantees that particles are parametrically typed and that each element shares the SAME type. The only abstract type I had was the varinfo stored in the task globals. Everything else is parametric and consistent between elements.
When the rest of the cloud (besides the reference trajectory) is the same type, it seems wasteful to make the compiler work for it.
My suggestion is to run a profiler and check to see how much bloat is from abstract types. Maybe I'm off base here, but my prior is that it will have a whole lot of type inference.
| rng = StableRNG(91) | ||
| retained = Particle(branch_changes(true, 0.0), particle_rng(rng)) | ||
| run_to_end!(retained) | ||
| reference = Particle(branch_changes(false, 0.0), particle_rng(rng), retained) | ||
| @test_throws "reference execution trace changed" advance!(reference) |
There was a problem hiding this comment.
I'm a bit surprised that this passes given that we drop the traced RNG component; as long as forking a reference changes the per particle RNG I think this is golden.
|
We will do another iteration of the design, likely as a new PR. |
Opened for discussion.
Reimplements
SMCandPG/CSMCdirectly on Libtask + DynamicPPL and drops the AdvancedPSdependency. A rewrite based on #2848 (thanks @charlesknipp; commits co-authored).
Each
observeis one filtering step: underParticleMCMCContext, every likelihood termbecomes a
Libtask.produce, so a particle is a suspended model execution we advance, weight,and resample. The conditional-SMC reference is reproduced by replaying a per-particle
TracedRNG(no stored trajectory); a fork is reseeded to branch off. Particle state lives inthe task's taped globals (no
task_local_storage), and@addlogprob!reweights via thelikelihood accumulator.
Posteriors, log-evidence, and exact reference reproduction match the old implementation;
gibbs.jl, Aqua, and log-density-metadata tests pass; performance is on par.Fix #2781
Fix TuringLang/AdvancedPS.jl#110
Fix TuringLang/AdvancedPS.jl#39
Fix TuringLang/AdvancedPS.jl#6