Quickstart

This page introduces basic Pathfinder usage with examples.

A 5-dimensional multivariate normal

For a simple example, we'll run Pathfinder on a multivariate normal distribution with a dense covariance matrix. Pathfinder expects an object that implements the LogDensityProblems interface and has a gradient implemented. We can use automatic differentiation to compute the gradient using LogDensityProblemsAD.

using ForwardDiff, LinearAlgebra, LogDensityProblems, LogDensityProblemsAD,
      Pathfinder, Printf, StatsPlots, Random
Random.seed!(42)

ForwardDiff, LogDensityProblems, LogDensityProblemsAD
struct MvNormalProblem{T,S}
    μ::T  # mean
    P::S  # precision matrix
end
function LogDensityProblems.capabilities(::Type{<:MvNormalProblem})
    return LogDensityProblems.LogDensityOrder{0}()
end
LogDensityProblems.dimension(ℓ::MvNormalProblem) = length(ℓ.μ)
function LogDensityProblems.logdensity(ℓ::MvNormalProblem, x)
    z = x - μ
    return -dot(z, P, z) / 2
end

Σ = [
    2.71   0.5    0.19   0.07   1.04
    0.5    1.11  -0.08  -0.17  -0.08
    0.19  -0.08   0.26   0.07  -0.7
    0.07  -0.17   0.07   0.11  -0.21
    1.04  -0.08  -0.7   -0.21   8.65
]
μ = [-0.55, 0.49, -0.76, 0.25, 0.94]
P = inv(Symmetric(Σ))
prob_mvnormal = ADgradient(:ForwardDiff, MvNormalProblem(μ, P))

Now we run pathfinder.

result = pathfinder(prob_mvnormal; init_scale=4)
Single-path Pathfinder result
  tries: 1
  draws: 5
  fit iteration: 5 (total: 5)
  fit ELBO: 3.84 ± 0.0
  fit distribution: Distributions.MvNormal{Float64, Pathfinder.WoodburyPDMat{Float64, LinearAlgebra.Diagonal{Float64, Vector{Float64}}, Matrix{Float64}, Matrix{Float64}, Pathfinder.WoodburyPDFactorization{Float64, LinearAlgebra.Diagonal{Float64, Vector{Float64}}, LinearAlgebra.QRCompactWYQ{Float64, Matrix{Float64}, Matrix{Float64}}, LinearAlgebra.UpperTriangular{Float64, Matrix{Float64}}}}, Vector{Float64}}(
dim: 5
μ: [-0.55, 0.49, -0.76, 0.25, 0.94]
Σ: [2.709999999999999 0.49999999999999933 … 0.07000000000000005 1.04; 0.49999999999999933 1.1100000000000012 … -0.1700000000000001 -0.07999999999999952; … ; 0.07000000000000005 -0.1700000000000001 … 0.10999999999999943 -0.20999999999999996; 1.04 -0.07999999999999952 … -0.20999999999999996 8.649999999999997]
)

result is a PathfinderResult. See its docstring for a description of its fields.

The L-BFGS optimizer constructs an approximation to the inverse Hessian of the negative log density using the limited history of previous points and gradients. For each iteration, Pathfinder uses this estimate as an approximation to the covariance matrix of a multivariate normal that approximates the target distribution. The distribution that maximizes the evidence lower bound (ELBO) is stored in result.fit_distribution. Its mean and covariance are quite close to our target distribution's.

result.fit_distribution.μ
5-element Vector{Float64}:
 -0.55
  0.49
 -0.76
  0.25
  0.94
result.fit_distribution.Σ
5×5 Pathfinder.WoodburyPDMat{Float64, LinearAlgebra.Diagonal{Float64, Vector{Float64}}, Matrix{Float64}, Matrix{Float64}, Pathfinder.WoodburyPDFactorization{Float64, LinearAlgebra.Diagonal{Float64, Vector{Float64}}, LinearAlgebra.QRCompactWYQ{Float64, Matrix{Float64}, Matrix{Float64}}, LinearAlgebra.UpperTriangular{Float64, Matrix{Float64}}}}:
 2.71   0.5    0.19   0.07   1.04
 0.5    1.11  -0.08  -0.17  -0.08
 0.19  -0.08   0.26   0.07  -0.7
 0.07  -0.17   0.07   0.11  -0.21
 1.04  -0.08  -0.7   -0.21   8.65

result.draws is a Matrix whose columns are the requested draws from result.fit_distribution:

result.draws
5×5 Matrix{Float64}:
  0.267403   -2.88998    0.445173   0.191851   1.77112
  2.27576     1.91601    1.20595   -0.5814     0.674725
 -0.0127976  -1.39141   -0.241852  -0.769595   0.0793332
  0.301801   -0.264527   0.392102   0.253725   0.480984
 -6.46494    -2.1255     0.180423   5.98467   -2.50585

We can visualize Pathfinder's sequence of multivariate-normal approximations with the following function:

function plot_pathfinder_trace(
    result, logp_marginal, xrange, yrange, maxiters;
    show_elbo=false, flipxy=false, kwargs...,
)
    iterations = min(length(result.optim_trace) - 1, maxiters)
    trace_points = result.optim_trace.points
    trace_dists = result.fit_distributions
    anim = @animate for i in 1:iterations
        contour(xrange, yrange, exp ∘ logp_marginal ∘ Base.vect; label="")
        trace = trace_points[1:(i + 1)]
        dist = trace_dists[i + 1]
        plot!(
            first.(trace), getindex.(trace, 2);
            seriestype=:scatterpath, color=:black, msw=0, label="trace",
        )
        covellipse!(
            dist.μ[1:2], dist.Σ[1:2, 1:2];
            n_std=2.45, alpha=0.7, color=1, linecolor=1, label="MvNormal 95% ellipsoid",
        )
        title = "Iteration $i"
        if show_elbo
            est = result.elbo_estimates[i]
            title *= "  ELBO estimate: " * @sprintf("%.1f", est.value)
        end
        plot!(; xlims=extrema(xrange), ylims=extrema(yrange), title, kwargs...)
    end
    return anim
end;
xrange = -5:0.1:5
yrange = -5:0.1:5

μ_marginal = μ[1:2]
P_marginal = inv(Σ[1:2,1:2])
logp_mvnormal_marginal(x) = -dot(x - μ_marginal, P_marginal, x - μ_marginal) / 2

anim = plot_pathfinder_trace(
    result, logp_mvnormal_marginal, xrange, yrange, 20;
    xlabel="x₁", ylabel="x₂",
)
gif(anim; fps=5)
Example block output

A banana-shaped distribution

Now we will run Pathfinder on the following banana-shaped distribution with density

\[\pi(x_1, x_2) = e^{-x_1^2 / 2} e^{-5 (x_2 - x_1^2)^2 / 2}.\]

First we define the log density problem:

Random.seed!(23)

struct BananaProblem end
function LogDensityProblems.capabilities(::Type{<:BananaProblem})
    return LogDensityProblems.LogDensityOrder{0}()
end
LogDensityProblems.dimension(ℓ::BananaProblem) = 2
function LogDensityProblems.logdensity(ℓ::BananaProblem, x)
    return -(x[1]^2 + 5(x[2] - x[1]^2)^2) / 2
end

prob_banana = ADgradient(:ForwardDiff, BananaProblem())

and then visualise it:

xrange = -3.5:0.05:3.5
yrange = -3:0.05:7
logp_banana(x) = LogDensityProblems.logdensity(prob_banana, x)
contour(xrange, yrange, exp ∘ logp_banana ∘ Base.vect; xlabel="x₁", ylabel="x₂")
Example block output

Now we run pathfinder.

result = pathfinder(prob_banana; init_scale=10)
Single-path Pathfinder result
  tries: 1
  draws: 5
  fit iteration: 15 (total: 17)
  fit ELBO: 0.45 ± 1.11
  fit distribution: Distributions.MvNormal{Float64, Pathfinder.WoodburyPDMat{Float64, LinearAlgebra.Diagonal{Float64, Vector{Float64}}, Matrix{Float64}, Matrix{Float64}, Pathfinder.WoodburyPDFactorization{Float64, LinearAlgebra.Diagonal{Float64, Vector{Float64}}, LinearAlgebra.QRCompactWYQ{Float64, Matrix{Float64}, Matrix{Float64}}, LinearAlgebra.UpperTriangular{Float64, Matrix{Float64}}}}, Vector{Float64}}(
dim: 2
μ: [1.985932870049922e-5, -7.784639475122661e-7]
Σ: [0.9718409129896838 0.0018087389291679393; 0.0018087389291679393 0.19999894866190412]
)

As before we can visualise each iteration of the algorithm.

anim = plot_pathfinder_trace(
    result, logp_banana, xrange, yrange, 20;
    xlabel="x₁", ylabel="x₂",
)
gif(anim; fps=5)
Example block output

Since the distribution is far from normal, Pathfinder is unable to fit the distribution well. Especially for such complicated target distributions, it's always a good idea to run multipathfinder, which runs single-path Pathfinder multiple times.

ndraws = 1_000
result = multipathfinder(prob_banana, ndraws; nruns=20, init_scale=10)
Multi-path Pathfinder result
  runs: 20
  draws: 1000
  Pareto shape diagnostic: 0.79 (bad)

result is a MultiPathfinderResult. See its docstring for a description of its fields.

result.fit_distribution is a uniformly-weighted Distributions.MixtureModel. Each component is the result of a single Pathfinder run. It's possible that some runs fit the target distribution much better than others, so instead of just drawing samples from result.fit_distribution, multipathfinder draws many samples from each component and then uses Pareto-smoothed importance resampling (using PSIS.jl) from these draws to better target prob_banana.

The Pareto shape diagnostic informs us on the quality of these draws. Here the Pareto shape $k$ diagnostic is bad ($k > 0.7$), which tells us that these draws are unsuitable for computing posterior estimates, so we should definitely run MCMC to get better draws. Still, visualizing the draws can still be useful.

x₁_approx = result.draws[1, :]
x₂_approx = result.draws[2, :]

contour(xrange, yrange, exp ∘ logp_banana ∘ Base.vect)
scatter!(x₁_approx, x₂_approx; msw=0, ms=2, alpha=0.5, color=1)
plot!(xlims=extrema(xrange), ylims=extrema(yrange), xlabel="x₁", ylabel="x₂", legend=false)
Example block output

While the draws do a poor job of covering the tails of the distribution, they are still useful for identifying the nonlinear correlation between these two parameters.

A 100-dimensional funnel

As we have seen above, running multi-path Pathfinder is much more useful for target distributions that are far from normal. One particularly difficult distribution to sample is Neal's funnel:

\[\begin{aligned} \tau &\sim \mathrm{Normal}(\mu=0, \sigma=3)\\ \beta_i &\sim \mathrm{Normal}(\mu=0, \sigma=e^{\tau/2}) \end{aligned}\]

Such funnel geometries appear in other models (e.g. many hierarchical models) and typically frustrate MCMC sampling. Multi-path Pathfinder can't sample the funnel well, but it can quickly give us draws that can help us diagnose that we have a funnel.

In this example, we draw from a 100-dimensional funnel and visualize 2 dimensions.

Random.seed!(68)

function logp_funnel(x)
    n = length(x)
    τ = x[1]
    β = view(x, 2:n)
    return ((τ / 3)^2 + (n - 1) * τ + sum(b -> abs2(b * exp(-τ / 2)), β)) / -2
end

struct FunnelProblem
    dim::Int
end
function LogDensityProblems.capabilities(::Type{<:FunnelProblem})
    return LogDensityProblems.LogDensityOrder{0}()
end
LogDensityProblems.dimension(ℓ::FunnelProblem) = ℓ.dim
LogDensityProblems.logdensity(::FunnelProblem, x) = logp_funnel(x)

prob_funnel = ADgradient(:ForwardDiff, FunnelProblem(100))

First, let's fit this posterior with single-path Pathfinder.

result_single = pathfinder(prob_funnel; init_scale=10)
Single-path Pathfinder result
  tries: 1
  draws: 5
  fit iteration: 6 (total: 42)
  fit ELBO: 65.88 ± 4.75
  fit distribution: Distributions.MvNormal{Float64, Pathfinder.WoodburyPDMat{Float64, LinearAlgebra.Diagonal{Float64, Vector{Float64}}, Matrix{Float64}, Matrix{Float64}, Pathfinder.WoodburyPDFactorization{Float64, LinearAlgebra.Diagonal{Float64, Vector{Float64}}, LinearAlgebra.QRCompactWYQ{Float64, Matrix{Float64}, Matrix{Float64}}, LinearAlgebra.UpperTriangular{Float64, Matrix{Float64}}}}, Vector{Float64}}(
dim: 100
μ: [1.524776947095019, 0.5262994351398967, 2.4681239077832173, 1.236280120607803, -2.5897642824974123, 2.0996894512677375, 0.40043134486832255, 2.4891789959503874, 0.9165111740698059, 1.167665360159217  …  -0.7469868651148984, -0.17585475651496033, -0.7786781761909585, -1.1084382011508385, 2.369725400661965, -0.304156897053075, 0.025604990609404854, 0.7776225569561186, -0.8363938701799696, -0.2526762788665785]
Σ: [0.016752936153538234 -0.010678988340475952 … 0.016970961918694853 0.005126990174010591; -0.010678988340475952 4.032373442084272 … -0.009932168395121326 -0.0030440459540503205; … ; 0.016970961918694853 -0.009932168395121326 … 4.07611096317594 0.004791631616614744; 0.005126990174010591 -0.0030440459540503205 … 0.004791631616614744 4.010147062485438]
)

Let's visualize this sequence of multivariate normals for the first two dimensions.

β₁_range = -5:0.01:5
τ_range = -15:0.01:5

anim = plot_pathfinder_trace(
    result_single, logp_funnel, τ_range, β₁_range, 15;
    show_elbo=true, xlabel="τ", ylabel="β₁",
)
gif(anim; fps=2)
Example block output

For this challenging posterior, we can again see that most of the approximations are not great, because this distribution is not normal. Also, this distribution has a pole instead of a mode, so there is no MAP estimate, and no Laplace approximation exists. As optimization proceeds, the approximation goes from very bad to less bad and finally extremely bad. The ELBO-maximizing distribution is at the neck of the funnel, which would be a good location to initialize MCMC.

Now we run multipathfinder.

ndraws = 1_000
result = multipathfinder(prob_funnel, ndraws; nruns=20, init_scale=10)
Multi-path Pathfinder result
  runs: 20
  draws: 1000
  Pareto shape diagnostic: 2.08 (very bad)

Again, the poor Pareto shape diagnostic indicates we should run MCMC to get draws suitable for computing posterior estimates.

We can see that the bulk of Pathfinder's draws come from the neck of the funnel, where the fit from the single path we examined was located.

τ_approx = result.draws[1, :]
β₁_approx = result.draws[2, :]

contour(τ_range, β₁_range, exp ∘ logp_funnel ∘ Base.vect)
scatter!(τ_approx, β₁_approx; msw=0, ms=2, alpha=0.5, color=1)
plot!(; xlims=extrema(τ_range), ylims=extrema(β₁_range), xlabel="τ", ylabel="β₁", legend=false)
Example block output