Skip to content

Commit 3fb8cb1

Browse files
committed
proposals for individual MCMC methods, shared asymmetric transition logic
1 parent 26d7455 commit 3fb8cb1

2 files changed

Lines changed: 227 additions & 46 deletions

File tree

src/MarkovChainMonteCarlo.jl

Lines changed: 139 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -84,13 +84,14 @@ abstract type ZygoteProtocol <: AutodiffProtocol end
8484
abstract type EnzymeProtocol <: AutodiffProtocol end
8585
=#
8686

87-
function _get_proposal(encoded_prior::ParameterDistribution, encoder_schedule::VV) where {VV <: AbstractVector}
88-
# We use the prior covariance to shape the proposal (in the encoded space),
89-
# as proposals are based on increments we do not need to shift the mean too
87+
function _get_cholesky_factor(encoded_prior::ParameterDistribution, encoder_schedule::VV) where {VV <: AbstractVector}
88+
# We use the prior covariance to shape the proposal (in the encoded space),
89+
# as proposals are based on increments we do not need to shift the mean too.
90+
# The Cholesky factor L (C = LL') is the single source of truth every sampler below
91+
# uses both to draw its noise and to evaluate its transition (log-)density, so the
92+
# two can never drift apart.
9093
C = cov(encoded_prior)
91-
Σ = cholesky(Symmetric(C))
92-
93-
return AdvancedMH.RandomWalkProposal.L * MvNormal(zeros(size(Σ, 1)), I))
94+
return cholesky(Symmetric(C)).L
9495
end
9596

9697

@@ -112,15 +113,10 @@ struct RWMHSampling{T <: AutodiffProtocol} <: MCMCProtocol end
112113

113114
RWMHSampling() = RWMHSampling{GradFreeProtocol}()
114115

115-
struct RWMetropolisHastings{PT, ADT <: AutodiffProtocol} <: AdvancedMH.MHSampler
116-
proposal::PT
116+
struct RWMetropolisHastings{LT, ADT <: AutodiffProtocol} <: AdvancedMH.MHSampler
117+
"Lower Cholesky factor `L` of the (encoded) prior covariance `C = LL'`, shaping the proposal noise."
118+
cholesky_L::LT
117119
end
118-
# Define method needed by AdvancedMH for new Sampler
119-
AdvancedMH.logratio_proposal_density(
120-
sampler::RWMetropolisHastings,
121-
transition_prev::AdvancedMH.AbstractTransition,
122-
candidate,
123-
) = AdvancedMH.logratio_proposal_density(sampler.proposal, transition_prev.params, candidate)
124120

125121
"""
126122
$(TYPEDSIGNATURES)
@@ -142,8 +138,8 @@ function MetropolisHastingsSampler(
142138
encoded_prior::ParameterDistribution,
143139
encoder_schedule::VV,
144140
) where {T <: AutodiffProtocol, VV <: AbstractVector}
145-
proposal = _get_proposal(encoded_prior, encoder_schedule)
146-
return RWMetropolisHastings{typeof(proposal), T}(proposal)
141+
L = _get_cholesky_factor(encoded_prior, encoder_schedule)
142+
return RWMetropolisHastings{typeof(L), T}(L)
147143
end
148144

149145
"""
@@ -157,22 +153,17 @@ on the covariance of `prior`.
157153
struct pCNMHSampling{T <: AutodiffProtocol} <: MCMCProtocol end
158154
pCNMHSampling() = pCNMHSampling{GradFreeProtocol}()
159155

160-
struct pCNMetropolisHastings{D, T <: AutodiffProtocol} <: AdvancedMH.MHSampler
161-
proposal::D
156+
struct pCNMetropolisHastings{LT, T <: AutodiffProtocol} <: AdvancedMH.MHSampler
157+
"Lower Cholesky factor `L` of the (encoded) prior covariance `C = LL'`, shaping the proposal noise."
158+
cholesky_L::LT
162159
end
163-
# Define method needed by AdvancedMH for new Sampler
164-
AdvancedMH.logratio_proposal_density(
165-
sampler::pCNMetropolisHastings,
166-
transition_prev::AdvancedMH.AbstractTransition,
167-
candidate,
168-
) = AdvancedMH.logratio_proposal_density(sampler.proposal, transition_prev.params, candidate)
169160
function MetropolisHastingsSampler(
170161
::pCNMHSampling{T},
171162
encoded_prior::ParameterDistribution,
172163
encoder_schedule::VV,
173164
) where {T <: AutodiffProtocol, VV <: AbstractVector}
174-
proposal = _get_proposal(encoded_prior, encoder_schedule)
175-
return pCNMetropolisHastings{typeof(proposal), T}(proposal)
165+
L = _get_cholesky_factor(encoded_prior, encoder_schedule)
166+
return pCNMetropolisHastings{typeof(L), T}(L)
176167
end
177168

178169
#------ The following are gradient-based samplers
@@ -186,23 +177,65 @@ new parameters according to the Barker proposal.
186177
struct BarkerSampling{T <: AutodiffProtocol} <: MCMCProtocol end
187178
BarkerSampling() = BarkerSampling{ForwardDiffProtocol}()
188179

189-
struct BarkerMetropolisHastings{D, T <: AutodiffProtocol} <: AdvancedMH.MHSampler
190-
proposal::D
180+
struct BarkerMetropolisHastings{LT, T <: AutodiffProtocol} <: AdvancedMH.MHSampler
181+
"Lower Cholesky factor `L` of the (encoded) prior covariance `C = LL'`, shaping the proposal noise."
182+
cholesky_L::LT
191183
end
192-
# Define method needed by AdvancedMH for new Sampler
193-
AdvancedMH.logratio_proposal_density(
194-
sampler::BarkerMetropolisHastings,
195-
transition_prev::AdvancedMH.AbstractTransition,
196-
candidate,
197-
) = AdvancedMH.logratio_proposal_density(sampler.proposal, transition_prev.params, candidate)
198184

199185
function MetropolisHastingsSampler(
200186
::BarkerSampling{T},
201187
encoded_prior::ParameterDistribution,
202188
encoder_schedule::VV,
203189
) where {T <: AutodiffProtocol, VV <: AbstractVector}
204-
proposal = _get_proposal(encoded_prior, encoder_schedule)
205-
return BarkerMetropolisHastings{typeof(proposal), T}(proposal)
190+
L = _get_cholesky_factor(encoded_prior, encoder_schedule)
191+
return BarkerMetropolisHastings{typeof(L), T}(L)
192+
end
193+
194+
# ------------------------------------------------------------------------------------------
195+
# Shared Metropolis-Hastings transition-density interface.
196+
#
197+
# Every sampler above is required to implement ONE function, log_transition_density(sampler,
198+
# model, θ_from, θ_to; stepsize) = log q(θ_to | θ_from) — the honest forward transition
199+
# log-density of whatever Markov kernel that sampler's `propose` actually draws from. There is
200+
# no default/fallback implementation: a sampler that doesn't implement it errors loudly instead
201+
# of silently inheriting a (possibly wrong) symmetric random-walk correction.
202+
#
203+
# AdvancedMH.logratio_proposal_density, which feeds directly into the MH acceptance ratio, is
204+
# then defined ONCE, generically over every AdvancedMH.MHSampler (so it applies automatically to
205+
# any sampler added to this module in future, not just the three below), as the textbook
206+
# Hastings correction log q(θ_from | θ_to) − log q(θ_to | θ_from) (reverse − forward), by
207+
# evaluating log_transition_density in both directions. A sampler can never update its `propose`
208+
# without also updating the quantity that determines its acceptance ratio, because there is only
209+
# one function (log_transition_density) doing double duty for both.
210+
function log_transition_density(
211+
sampler::MHS,
212+
model,
213+
θ_from,
214+
θ_to;
215+
stepsize::FT = 1.0,
216+
) where {MHS <: AdvancedMH.MHSampler, FT <: AbstractFloat}
217+
throw(
218+
ArgumentError(
219+
"log_transition_density not implemented for $(MHS). Every MHSampler in this module " *
220+
"must implement its own log_transition_density(sampler, model, θ_from, θ_to; stepsize) " *
221+
"= log q(θ_to | θ_from); there is no generic/symmetric fallback, since silently assuming " *
222+
"one caused the prior-double-counting bug this interface replaces.",
223+
),
224+
)
225+
end
226+
227+
function AdvancedMH.logratio_proposal_density(
228+
sampler::MHS,
229+
model::AdvancedMH.DensityModel,
230+
transition_prev::AdvancedMH.AbstractTransition,
231+
candidate;
232+
stepsize::FT = 1.0,
233+
) where {MHS <: AdvancedMH.MHSampler, FT <: AbstractFloat}
234+
θ_from = transition_prev.params
235+
θ_to = candidate
236+
log_q_forward = log_transition_density(sampler, model, θ_from, θ_to; stepsize = stepsize)
237+
log_q_reverse = log_transition_density(sampler, model, θ_to, θ_from; stepsize = stepsize)
238+
return log_q_reverse - log_q_forward
206239
end
207240

208241

@@ -336,7 +369,20 @@ function AdvancedMH.propose(
336369
current_state::MCMCState;
337370
stepsize::FT = 1.0,
338371
) where {FT <: AbstractFloat}
339-
return current_state.params + stepsize * rand(rng, sampler.proposal)
372+
L = sampler.cholesky_L
373+
return current_state.params .+ stepsize .* (L * randn(rng, size(L, 1)))
374+
end
375+
376+
# θ_to | θ_from ~ N(θ_from, stepsize² C), the symmetric kernel that propose() above draws from.
377+
function log_transition_density(
378+
sampler::RWMetropolisHastings,
379+
model::AdvancedMH.DensityModel,
380+
θ_from,
381+
θ_to;
382+
stepsize::FT = 1.0,
383+
) where {FT <: AbstractFloat}
384+
L = sampler.cholesky_L
385+
return logpdf(MvNormal(θ_from, Symmetric((stepsize^2) .* (L * L'))), θ_to)
340386
end
341387

342388
# method extending AdvancedMH.propose() for preconditioned Crank-Nicholson
@@ -347,10 +393,27 @@ function AdvancedMH.propose(
347393
current_state::MCMCState;
348394
stepsize::FT = 1.0,
349395
) where {FT <: AbstractFloat}
350-
# Use prescription in Beskos et al (2017) "Geometric MCMC for infinite-dimensional
396+
# Use prescription in Beskos et al (2017) "Geometric MCMC for infinite-dimensional
351397
# inverse problems." for relating ρ to Euler stepsize:
352398
ρ = (1 - stepsize / 4) / (1 + stepsize / 4)
353-
return ρ * current_state.params .+ sqrt(1 - ρ^2) * rand(rng, sampler.proposal)
399+
L = sampler.cholesky_L
400+
return ρ .* current_state.params .+ sqrt(1 - ρ^2) .* (L * randn(rng, size(L, 1)))
401+
end
402+
403+
# θ_to | θ_from ~ N(ρθ_from, (1-ρ²)C), the asymmetric kernel that propose() above draws from.
404+
# Evaluating this honestly in both directions (via the generic logratio_proposal_density) is
405+
# what recovers the pCN cancellation log q(θ_from|θ_to) - log q(θ_to|θ_from) = logprior(θ_from) -
406+
# logprior(θ_to), instead of the silently-symmetric 0 the previous implementation returned.
407+
function log_transition_density(
408+
sampler::pCNMetropolisHastings,
409+
model::AdvancedMH.DensityModel,
410+
θ_from,
411+
θ_to;
412+
stepsize::FT = 1.0,
413+
) where {FT <: AbstractFloat}
414+
ρ = (1 - stepsize / 4) / (1 + stepsize / 4)
415+
L = sampler.cholesky_L
416+
return logpdf(MvNormal.* θ_from, Symmetric((1 - ρ^2) .* (L * L'))), θ_to)
354417
end
355418

356419
# method extending AdvancedMH.propose() for the Barker proposal
@@ -361,12 +424,41 @@ function AdvancedMH.propose(
361424
current_state::MCMCState;
362425
stepsize::FT = 1.0,
363426
) where {FT <: AbstractFloat}
364-
# Livingstone and Zanella (2022)
365-
# Compute the gradient of the log-density at the current state
366-
n = length(current_state.params)
367-
log_gradient = autodiff_gradient(model, current_state.params, sampler)
368-
xi = rand(rng, sampler.proposal)
369-
return current_state.params .+ (stepsize .* ((rand(rng, n) .< 1 ./ (1 .+ exp.(-log_gradient .* xi))) .* xi))
427+
# Livingstone and Zanella (2022). The elementwise sigmoid selection that defines the Barker
428+
# proposal is only reversible for *independent* coordinates, so we apply it in the whitened
429+
# coordinate system u = L⁻¹θ (where the prior-covariance preconditioning L is decorrelated
430+
# to the identity) and map the increment back with L: the gradient is rotated into whitened
431+
# coordinates via L'∇log π(θ) (chain rule for θ = θ₀ + Lu). We also flip the sign of the
432+
# whitened noise η rather than zeroing it out — the standard Barker kernel moves by ±η, never
433+
# by exactly 0, so its transition density (below) is an ordinary, atom-free density; zeroing
434+
# out (keep-or-drop) instead creates a mixed discrete/continuous kernel with no closed form.
435+
θ = current_state.params
436+
n = length(θ)
437+
L = sampler.cholesky_L
438+
grad_white = L' * autodiff_gradient(model, θ, sampler)
439+
η = randn(rng, n)
440+
flip_prob = 1 ./ (1 .+ exp.(-grad_white .* η))
441+
sign = 2 .* (rand(rng, n) .< flip_prob) .- 1
442+
ζ = stepsize .* sign .* η
443+
return θ .+ L * ζ
444+
end
445+
446+
# For a symmetric noise density g (here standard normal) and whitened increment e = ζ/stepsize,
447+
# the marginal density of the ± flip-sign move is q(e | θ_from) = 2 g(e) σ(grad_white·e) (Barker,
448+
# 1965; Livingstone & Zanella, 2022): summing the probability that e itself was kept (w.p.
449+
# σ(grad_white·e)) with the probability that -e was drawn and flipped (w.p. σ(grad_white·e) too,
450+
# since g(-e) = g(e)). See propose() above for how θ_to is generated from θ_from.
451+
function log_transition_density(
452+
sampler::BarkerMetropolisHastings,
453+
model::AdvancedMH.DensityModel,
454+
θ_from,
455+
θ_to;
456+
stepsize::FT = 1.0,
457+
) where {FT <: AbstractFloat}
458+
L = sampler.cholesky_L
459+
grad_white = L' * autodiff_gradient(model, θ_from, sampler)
460+
e = (L \ (θ_to .- θ_from)) ./ stepsize
461+
return sum(log(2) .+ logpdf.(Normal(), e) .+ log.(1 ./ (1 .+ exp.(-grad_white .* e)))) - length(e) * log(stepsize)
370462
end
371463

372464
# Copy a MCMCState and set accepted = false
@@ -393,7 +485,8 @@ function AbstractMCMC.step(
393485
AdvancedMH.logdensity(model, current_state)
394486

395487
log_α =
396-
new_log_density - current_log_density + AdvancedMH.logratio_proposal_density(sampler, current_state, new_params)
488+
new_log_density - current_log_density +
489+
AdvancedMH.logratio_proposal_density(sampler, model, current_state, new_params; stepsize = stepsize)
397490

398491
# Decide whether to return the previous params or the new one.
399492
new_state = if -Random.randexp(rng) < log_α

test/MarkovChainMonteCarlo/runtests.jl

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ using LinearAlgebra
33
using Distributions
44
using GaussianProcesses
55
using Test
6+
using AdvancedMH
7+
using AbstractMCMC
68

79
using CalibrateEmulateSample.EnsembleKalmanProcesses
810
using CalibrateEmulateSample.MarkovChainMonteCarlo
@@ -16,6 +18,10 @@ using CalibrateEmulateSample.Utilities
1618
# range 0->2, with lengthscale of transition 0.5, and y=1 at x=2
1719
G(x) = 5 * (tanh.((x .- 2) ./ 0.5) .+ 1)
1820

21+
# A minimal MHSampler that deliberately does not implement log_transition_density, used to check
22+
# that the shared interface errors loudly instead of silently falling back to a wrong value.
23+
struct _DummyMHSampler <: AdvancedMH.MHSampler end
24+
1925
function test_data(prior; rng_seed = 41, n = 80, var_y = 0.05, rest...)
2026
# Seed for pseudo-random number generator
2127
rng = Random.MersenneTwister(rng_seed)
@@ -682,4 +688,86 @@ end
682688
end
683689
end
684690
end
691+
692+
@testset "Sampler transition-density interface (pCN Hastings-term fix)" begin
693+
# Regression tests for: pCN's (and Barker's) log_transition_density must reflect the
694+
# *actual* asymmetric proposal kernel, not the symmetric additive random-walk kernel.
695+
# Constructed directly at the sampler level (bypassing Emulator/MCMCWrapper) so the
696+
# underlying transition-density math can be checked against closed-form references.
697+
rng = Random.MersenneTwister(2026)
698+
n = 3
699+
# A genuinely correlated (non-diagonal) covariance, so the tests exercise the
700+
# whitening/off-diagonal structure, not just a diagonal special case.
701+
C = [2.0 0.5 0.1; 0.5 1.5 0.3; 0.1 0.3 1.0]
702+
L = cholesky(Symmetric(C)).L
703+
prior_dist = MvNormal(zeros(n), Symmetric(C))
704+
705+
a = randn(rng, n)
706+
b = randn(rng, n)
707+
dummy_model = AdvancedMH.DensityModel-> 0.0)
708+
709+
@testset "RW: transition density is symmetric" begin
710+
rw = MCMC.RWMetropolisHastings{typeof(L), MCMC.GradFreeProtocol}(L)
711+
for s in (0.1, 1.0, 2.5)
712+
@test isapprox(
713+
MCMC.log_transition_density(rw, dummy_model, a, b; stepsize = s),
714+
MCMC.log_transition_density(rw, dummy_model, b, a; stepsize = s),
715+
)
716+
end
717+
end
718+
719+
@testset "pCN: Hastings term exactly cancels the prior ratio" begin
720+
# This is the core bug: reverse - forward must equal logprior(a) - logprior(b)
721+
# for every stepsize (every ρ), not silently 0.
722+
pcn = MCMC.pCNMetropolisHastings{typeof(L), MCMC.GradFreeProtocol}(L)
723+
prev_state = MCMC.MCMCState(a, 0.0, true)
724+
for s in (0.05, 0.5, 1.5, 3.9)
725+
hastings = AdvancedMH.logratio_proposal_density(pcn, dummy_model, prev_state, b; stepsize = s)
726+
@test isapprox(hastings, logpdf(prior_dist, a) - logpdf(prior_dist, b); atol = 1e-8)
727+
end
728+
end
729+
730+
@testset "pCN: flat-likelihood chain always accepts" begin
731+
# If the "likelihood" is constant, the full posterior log-density is just the prior,
732+
# and the correct pCN acceptance log-ratio is then EXACTLY 0 for every proposal
733+
# (loglik cancels trivially, and the Hastings term cancels the prior ratio). Under
734+
# the pre-fix code (Hastings term silently 0), this would instead fluctuate with the
735+
# prior ratio and cause spurious rejections.
736+
pcn = MCMC.pCNMetropolisHastings{typeof(L), MCMC.GradFreeProtocol}(L)
737+
flat_model = AdvancedMH.DensityModel-> logpdf(prior_dist, θ))
738+
θ0 = zeros(n)
739+
state = MCMC.MCMCState(θ0, AdvancedMH.logdensity(flat_model, θ0), true)
740+
for i in 1:200
741+
state, _ = AbstractMCMC.step(rng, flat_model, pcn, state; stepsize = 0.6)
742+
@test state.accepted
743+
end
744+
end
745+
746+
@testset "Barker: transition density matches closed-form gradient formula" begin
747+
# Target chosen as the same Gaussian, so ∇log π(θ) = -C⁻¹θ is known in closed form,
748+
# letting us check the whitened, flip-sign transition density against hand-derived
749+
# algebra rather than trusting the autodiff call alone.
750+
target_logdensity(θ) = -0.5 * dot(θ, C \ θ)
751+
barker_model = AdvancedMH.DensityModel(target_logdensity)
752+
barker = MCMC.BarkerMetropolisHastings{typeof(L), MCMC.ForwardDiffProtocol}(L)
753+
prev_state = MCMC.MCMCState(a, 0.0, true)
754+
for s in (0.3, 1.0, 2.0)
755+
hastings = AdvancedMH.logratio_proposal_density(barker, barker_model, prev_state, b; stepsize = s)
756+
757+
gw_a = L' * (-(C \ a))
758+
gw_b = L' * (-(C \ b))
759+
e = (L \ (b .- a)) ./ s
760+
sigmoid(x) = 1 / (1 + exp(-x))
761+
manual = sum(log.(sigmoid.(-gw_b .* e)) .- log.(sigmoid.(gw_a .* e)))
762+
@test isapprox(hastings, manual; atol = 1e-8)
763+
end
764+
end
765+
766+
@testset "log_transition_density errors loudly for an unimplemented sampler" begin
767+
# A hypothetical new sampler that forgets to implement log_transition_density must
768+
# fail loudly (ArgumentError) rather than silently falling back to a symmetric,
769+
# possibly-wrong correction — this is what the shared interface guards against.
770+
@test_throws ArgumentError MCMC.log_transition_density(_DummyMHSampler(), dummy_model, a, b)
771+
end
772+
end
685773
end

0 commit comments

Comments
 (0)