Nested sampling with ultranest
In this example, we show how to use the Python package ultranest
to perform inference on a simple model with nested sampling. We also show how to use the ultranest
[1] package with MPI to parallelise the sampling on multiple processes.
Installation
Install the Python environment
It is recommended to use a virtual environment to install the required Python packages. To create a new virtual environment, run the following command in your terminal. If you are using conda
, you can create a new environment with the following command:
conda create -n julia_ultranest python=3.10
Then, activate the environment:
conda activate julia_ultranest
You can then install ultranest
with the following command:
conda install -c conda-forge ultranest
Install the Julia environment
To use ultranest
with Julia, you need to install the PyCall
package. In the Julia REPL, set the PYTHON
environment variable to the path of the Python executable in the virtual environment you created earlier. For example:
ENV["PYTHON"] = "/opt/miniconda3/envs/julia_ultranest/bin/python"
You can do this by running the following commands in the Julia REPL:
using Pkg; Pkg.add("PyCall")
Pkg.build("PyCall")
We can check that the Python environment has been set correctly by running the following commands in the Julia REPL:
using PyCall
PyCall.python
Finally, ultranest
can be loaded in Julia with the following commands:
ultranest = pyimport("ultranest")
Install MPI.jl (optional)
If you want to parallelise the sampling with MPI, you first have to install MPI on your system. On Ubuntu, you can install the openmpi
package with the following command:
sudo apt-get install openmpi-bin libopenmpi-dev
You can then install MPI.jl
and MPIPreferences.jl
in Julia with the following commands:
using Pkg; Pkg.add("MPI")
Pkg.add("MPIPreferences")
As detailed in the official documentation of MPI.jl
[2], the system MPI binary can be linked to Julia with the following command:
julia --project -e 'using MPIPreferences; MPIPreferences.use_system_binary()'
More information on how to configure MPI on can be found in the official documentation of MPI.jl
.
The following lines of code can be used to initialise MPI in a Julia script:
using MPI
MPI.Init()
Modelling with ultranest
We assume the reader is familiar with nested sampling and the ultranest
package.
Priors
Priors are defined using the quantile
function and probability distributions from the Distributions
package. The prior_transform
function is then used to transform the unit cube to the prior space as shown in the following example:
using Distributions
function prior_transform(cube)
α₁ = quantile(Uniform(0.0, 1.25), cube[1])
f₁ = quantile(LogUniform(1e-3, 1e3), cube[2])
α₂ = quantile(Uniform(α₁, 4.0), cube[3])
variance = quantile(LogNormal(-2., 1.2), cube[4])
ν = quantile(Gamma(2, 0.5), cube[5])
μ = quantile(Normal(0., 2.), cube[6])
return [α₁, f₁, α₂, variance, ν, μ]
end
Model and likelihood
The loglikelihood
function is then defined to compute the likelihood of the model given the data and the parameters. The various instructions in the function are detailed in previous sections or examples.
using Pioran # hide
function loglikelihood(y, t, σ, params)
α₁, f₁, α₂, variance, ν, μ = params
σ² = ν .* σ .^ 2
𝓟 = model(α₁, f₁, α₂)
𝓡 = approx(𝓟, f0, fM, 20, variance)
f = ScalableGP(μ, 𝓡)
return logpdf(f(t, σ²), y)
end
paramnames = ["α₁", "f₁", "α₂", "variance", "ν", "μ"]
logl(params) = loglikelihood(y, t, yerr, params)
We can then initialise the ultranest
sampler with the following command:
sampler = ultranest.ReactiveNestedSampler(paramnames, logl, resume=true, transform=prior_transform, log_dir="inference_dir", vectorized=false)
The inference can be started with the following command:
results = sampler.run()
Finally, the results can be printed and plotted with the following commands:
sampler.print_results()
sampler.plot()
Parallel sampling with MPI
If you want to parallelise the sampling with MPI to speed-up the computation, follow the steps presented before and run the script with the following command:
mpiexec -n 4 julia script.jl
where 4
is the number of processes to use.
Full example
using MPI
MPI.Init()
comm = MPI.COMM_WORLD
using Pioran
using Distributions
using Statistics
using Random
using DelimitedFiles
using PyCall
# load the python package ultranest
ultranest = pyimport("ultranest")
# define the random number generator
rng = MersenneTwister(123)
# get the filename from the command line
filename = ARGS[1]
fname = replace(split(filename, "/")[end], ".txt" => "_single")
dir = "inference/" * fname
plot_path = dir * "/plots/"
# Load the data and extract a subset for the analysis
A = readdlm(filename, comments=true, comment_char='#')
t_all, y_all, yerr_all = A[:, 1], A[:, 2], A[:, 3]
t, y, yerr, x̄, va = extract_subset(rng, fname, t_all, y_all, yerr_all);
# Frequency range for the approx and the prior
f_min, f_max = 1 / (t[end] - t[1]), 1 / minimum(diff(t)) / 2
f0, fM = f_min / 20.0, f_max * 20.0
min_f_b, max_f_b = f0 * 4.0, fM / 4.0
# F var^2 is distributed as a log-normal
μᵥ, σᵥ = -1.5, 1.0;
μₙ, σₙ² = 2μᵥ, 2(σᵥ)^2;
σₙ = sqrt(σₙ²)
# options for the approximation
basis_function = "SHO"
n_components = 20
model = SingleBendingPowerLaw
posterior_checks = true
prior_checks = true
function loglikelihood(y, t, σ, params)
α₁, f₁, α₂, variance, ν, μ, c = params
# Rescale the measurement variance
σ² = ν .* σ .^ 2 ./ (y .- c) .^ 2
# Make the flux Gaussian
yn = log.(y .- c)
# Define power spectral density function
𝓟 = model(α₁, f₁, α₂)
# Approximation of the PSD to form a covariance function
𝓡 = approx(𝓟, f0, fM, n_components, variance, basis_function=basis_function)
# Build the GP
f = ScalableGP(μ, 𝓡)
# sample the conditioned distribution
return logpdf(f(t, σ²), yn)
end
logl(pars) = loglikelihood(y, t, yerr, pars)
# Priors
function prior_transform(cube)
α₁ = quantile(Uniform(0.0, 1.25), cube[1])
f₁ = quantile(LogUniform(min_f_b, max_f_b), cube[2])
α₂ = quantile(Uniform(α₁, 4.0), cube[3])
variance = quantile(LogNormal(μₙ, σₙ), cube[4])
ν = quantile(Gamma(2, 0.5), cube[5])
μ = quantile(Normal(x̄, 5 * sqrt(va)), cube[6])
c = quantile(LogUniform(1e-6, minimum(y) * 0.99), cube[7])
return [α₁, f₁, α₂, variance, ν, μ, c]
end
paramnames = ["α₁", "f₁", "α₂", "variance", "ν", "μ", "c"]
if MPI.Comm_rank(comm) == 0 && prior_checks
unif = rand(rng, 7, 3000) # uniform samples from the unit hypercube
priors = mapreduce(permutedims, hcat, [prior_transform(unif[:, i]) for i in 1:3000]')# transform the uniform samples to the prior
run_diagnostics(priors[1:3, :], priors[4, :], f0, fM, model, f_min, f_max, path=plot_path, basis_function=basis_function, n_components=n_components)
end
println("Hello world, I am $(MPI.Comm_rank(comm)) of $(MPI.Comm_size(comm))")
println("Running sampler...")
sampler = ultranest.ReactiveNestedSampler(paramnames, logl, resume=true, transform=prior_transform, log_dir=dir, vectorized=false)
results = sampler.run()
sampler.print_results()
sampler.plot()
if MPI.Comm_rank(comm) == 0 && posterior_checks
samples = readdlm(dir * "/chains/equal_weighted_post.txt", skipstart=1)
run_posterior_predict_checks(samples, paramnames, t, y, yerr, f0, fM, model, true; path=plot_path, basis_function=basis_function, n_components=n_components)
end