Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adaptive Metropolis algorithm #57

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
2 changes: 2 additions & 0 deletions Project.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ version = "0.6.0"
[deps]
AbstractMCMC = "80f14c24-f653-4e6a-9b94-39d6b0f70001"
Distributions = "31c24e10-a181-5473-b8eb-7969acd0382f"
LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e"
PDMats = "90014a1f-27ba-587c-ab20-58faa44d9150"
Copy link
Member

Choose a reason for hiding this comment

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

Can you add a [compat] entry for PDMats?

Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c"
Requires = "ae029012-a4dd-5104-9daa-d747884805df"

Expand Down
7 changes: 6 additions & 1 deletion src/AdvancedMH.jl
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ module AdvancedMH
using AbstractMCMC
using Distributions
using Requires
using LinearAlgebra
using PDMats

import Random

Expand All @@ -19,7 +21,9 @@ export
SymmetricRandomWalkProposal,
Ensemble,
StretchProposal,
MALA
MALA,
AMProposal,
AMSampler
kaandocal marked this conversation as resolved.
Show resolved Hide resolved

# Reexports
export sample, MCMCThreads, MCMCDistributed
Expand Down Expand Up @@ -120,5 +124,6 @@ end
include("proposal.jl")
include("mh-core.jl")
include("emcee.jl")
include("adaptivemetropolis.jl")

end # module AdvancedMH
113 changes: 113 additions & 0 deletions src/adaptivemetropolis.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
struct AMSampler{D} <: MHSampler
proposal::D
end

propose(spl::AMSampler, args...) = propose(Random.GLOBAL_RNG, spl, args...)

function propose(rng::Random.AbstractRNG, spl::AMSampler{<:Proposal}, model::DensityModel)
proposal = propose(rng, spl.proposal, model)
return Transition(model, proposal)
end

function propose(rng::Random.AbstractRNG, spl::AMSampler{<:Proposal}, model::DensityModel,
params_prev::Transition)
proposal = propose(rng, spl.proposal, model, params_prev.params)
return Transition(model, proposal)
end

# Same as for MetropolisHastings, but with a `trackstep` callback
function AbstractMCMC.step(rng::Random.AbstractRNG, model::DensityModel, spl::AMSampler;
init_params=nothing, kwargs...)
if init_params === nothing
transition = propose(rng, spl, model)
else
transition = transition(spl, model, init_params)
end

trackstep(spl.proposal, transition)
return transition, transition
end

# Same as for MetropolisHastings but with a `trackstep` callback
function AbstractMCMC.step(rng::Random.AbstractRNG, model::DensityModel, spl::AMSampler,
params_prev::AbstractTransition; kwargs...)
# Generate a new proposal.
params = propose(rng, spl, model, params_prev)

# Calculate the log acceptance probability.
logα = logdensity(model, params) - logdensity(model, params_prev) +
logratio_proposal_density(spl, params_prev, params)

# Decide whether to return the previous params or the new one.
if -Random.randexp(rng) < logα
trackstep(spl.proposal, params, Val(true))
return params, params
else
trackstep(spl.proposal, params_prev, Val(false))
return params_prev, params_prev
end
end

# Called when the first sample is drawn
trackstep(proposal::Proposal, params::Transition) = nothing

# Called when a new step is performed, the last argument determines if the step is an acceptance step
trackstep(proposal::Proposal, params::Transition,
::Union{Val{true}, Val{false}}) = nothing

function logratio_proposal_density(sampler::AMSampler, params_prev::Transition, params::Transition)
return logratio_proposal_density(sampler.proposal, params_prev.params, params.params)
end

# Simple Adaptive Metropolis Proposal
# The proposal at each step is equal to a scalar multiple
# of the empirical posterior covariance plus a fixed, small covariance
# matrix epsilon which is also used for initial exploration.
mutable struct AMProposal{FT <: Real, CT <: AbstractMatrix{FT},
MNT <: AbstractMvNormal} <: Proposal{MNT}
epsilon::CT
scalefactor::FT
proposal::MNT
samplemean::Vector{FT}
samplesqmean::Matrix{FT}
N::Int
end

function AMProposal(epsilon::AbstractMatrix{FT},
scalefactor=FT(2.38^2 / size(epsilon, 1))) where {FT <: Real}
kaandocal marked this conversation as resolved.
Show resolved Hide resolved
sym = PDMat(epsilon)
proposal = MvNormal(zeros(size(sym, 1)), sym)
AMProposal(sym, scalefactor, proposal, mean(proposal), zeros(size(sym)...), 0)
end

logratio_proposal_density(::AMProposal, params_prev, params) = 0

# When the proposal is initialised the empirical posterior covariance is zero
function trackstep(proposal::AMProposal, trans::Transition)
proposal.samplemean .= trans.params
proposal.samplesqmean .= trans.params * trans.params'
Copy link
Member

Choose a reason for hiding this comment

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

This still computes trans.params * trans.params' first and then copies it to proposal.samplesqmean. You can avoid this by writing

Suggested change
proposal.samplesqmean .= trans.params * trans.params'
mul!(proposal.samplesqmean, trans.params, trans.params')

proposal.proposal = MvNormal(zeros(size(proposal.samplemean)), proposal.epsilon)
proposal.N = 1
end

# Recompute the empirical posterior covariance matrix
function trackstep(proposal::AMProposal, trans::Transition, ::Union{Val{true}, Val{false}})
proposal.samplemean .= (proposal.samplemean .* proposal.N .+ trans.params) ./ (proposal.N + 1)
Copy link
Member

Choose a reason for hiding this comment

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

IIRC it is numerically more stable to write

Suggested change
proposal.samplemean .= (proposal.samplemean .* proposal.N .+ trans.params) ./ (proposal.N + 1)
proposal.N += 1
proposal.samplemean .+= (trans.params .- proposal.samplemean) ./ proposal.N


proposal.samplesqmean .= (proposal.samplesqmean .* proposal.N + trans.params * trans.params') ./
(proposal.N + 1)
Copy link
Member

Choose a reason for hiding this comment

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

This would fuse all operations (might require Compat.jl on older Julia versions) but is not as stable as the version for the mean above:

Suggested change
proposal.samplesqmean .= (proposal.samplesqmean .* proposal.N + trans.params * trans.params') ./
(proposal.N + 1)
mul!(proposal.samplesqmean, trans.params, trans.params', true, N)
ldiv!(N + 1, proposal.samplesqmean)


samplecov = proposal.samplesqmean .- proposal.samplemean * proposal.samplemean'
Copy link
Member

Choose a reason for hiding this comment

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

One should be aware that this is the "naive" algorithm for computing the variance which can lead to catastrophic cancellation (see e.g. https://en.wikipedia.org/wiki/Algorithms_for_calculating_variance#Na%C3%AFve_algorithm). Maybe it would be better to use Welford's algorithm (https://en.wikipedia.org/wiki/Algorithms_for_calculating_variance#Welford's_online_algorithm)?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I will have a look at this later. In combination with creating a new MvNormal distribution every round, which is not very efficient, I think we could make this whole step more efficient by using the rank-one Cholesky update algorithm, although I am not sure how best to do this given that MvNormals are immutable

Copy link
Member

Choose a reason for hiding this comment

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

I think the Welford implementation in AdvancedHMC is pretty good. I think it's an adaptation of the Stan version.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Added!

prop_cov = proposal.scalefactor .* samplecov
proposal.proposal = MvNormal(mean(proposal.proposal), prop_cov .+ proposal.epsilon)
proposal.N += 1
end

function propose(rng::Random.AbstractRNG, p::AMProposal, ::DensityModel)
return rand(rng, p.proposal)
end

function propose(rng::Random.AbstractRNG, p::AMProposal, ::DensityModel, t)
return t + rand(rng, p.proposal)
end

13 changes: 12 additions & 1 deletion test/runtests.jl
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,18 @@ include("util.jl")
# Construct a DensityModel.
model = DensityModel(density)

@testset "AdaptiveMH" begin
# Set up our sampler with initial parameters.
spl = AMSampler(AMProposal([0.01 0. ; 0. 0.01]))

# Sample from the posterior.
chain = sample(model, spl, 100000; chain_type=StructArray, param_names=["μ", "σ"])

# chn_mean ≈ dist_mean atol=atol_v
@test mean(chain.μ) ≈ 0.0 atol=0.1
@test mean(chain.σ) ≈ 1.0 atol=0.1
end

@testset "StaticMH" begin
# Set up our sampler with initial parameters.
spl1 = StaticMH([Normal(0,1), Normal(0, 1)])
Expand Down Expand Up @@ -207,5 +219,4 @@ include("util.jl")
end

@testset "EMCEE" begin include("emcee.jl") end

end