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
1 change: 1 addition & 0 deletions Project.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ version = "0.6.0"
[deps]
AbstractMCMC = "80f14c24-f653-4e6a-9b94-39d6b0f70001"
Distributions = "31c24e10-a181-5473-b8eb-7969acd0382f"
LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e"
Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c"
Requires = "ae029012-a4dd-5104-9daa-d747884805df"

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

import Random

Expand All @@ -19,7 +20,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 +123,6 @@ end
include("proposal.jl")
include("mh-core.jl")
include("emcee.jl")
include("adaptivemetropolis.jl")

end # module AdvancedMH
111 changes: 111 additions & 0 deletions src/adaptivemetropolis.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
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 <: Proposal{MvNormal}
epsilon::Symmetric
scalefactor::Float64
proposal::MvNormal
samplemean::AbstractArray
samplesqmean::AbstractMatrix
N::Int
end
Copy link
Member

Choose a reason for hiding this comment

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

It would be good to add type parameters such that the fields are concretely typed: https://docs.julialang.org/en/v1/manual/performance-tips/index.html#Avoid-fields-with-abstract-type

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This should be fixed now.


function AMProposal(epsilon::AbstractMatrix, scalefactor=2.38^2 / size(epsilon, 1))
Copy link
Member

Choose a reason for hiding this comment

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

What's the motivation for this specific scale factor? Is there a reference for it?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

sym = Symmetric(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 = copy(trans.params)
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)

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

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