A practical introduction to approximate Bayesian computation

By: julia | Victor Boussange

Re-posted from: https://victorboussange.com/post/abc_inference/

Introduction

In this tutorial, we’ll learn the basics of approximate Bayesian computation (ABC).
ABC is a very flexible inference method that is quite intuitive, and that you can apply to almost any model type. As such, it is quite handy to have it in one’s toolbox! To build up an intuition of the method, we’ll perform ABC on a very simple ecosystem model, using Julia, and will visualize graphically the inference results.

Package loading

We first need to load a few packages.

cd(@__DIR__)
using Pkg; Pkg.activate(".")
using Random
using LinearAlgebra
import Optimisers:destructure # required to simplify parameter indexing
using UnPack # for parameter indexing
using ApproxBayes # ABC toolbox
using Distributions # useful to define the priors
using OrdinaryDiffEq # ODE solver library
using ParametricModels # convenience package for ODE models. /!\ package not registered

It is always important to make your work reproducible, so we’ll set manually a seed to the random number generator.

# Set a seed for reproducibility.
Random.seed!(11);

Model definition and data generation

What we’ll do is to implement an Ordinary Differential Equation model, which will be used to generate synthetic data and further perform the inference. The idea here is to consider this synthetic data as our empirical data, and pretend that we do not know which parameters generated this data. Our goal is then to recover the generating parameters.

We’ll consider a predator-prey ecological model, the Lotka-Volterra equations. This model has a total of four different parameters $\alpha, \beta, \gamma, \delta$, that describe the interactions between the two species. For the sake of simplicity, we’ll assume that we know perfectly the parameters $\gamma, \delta$, and seek to infer $\alpha$ and $\beta$. Assuming to know $\gamma, \delta$ is of course a very unrealistic assumption… but a handy assumption to develop an intuition about parameter inference.

We use ParametricModels.jl to define our model. ParametricModels.jl is a simple wrapper around OrdinaryDiffEq.jl, that allows to play around with the model without bothering further about the details of the numerical solve. This makes it easier to simulate repeatedly an ODE model. For readability and maintenance, we’ll encapsulate the parameters in a NamedTuple.

# Declaration of the model
ParametricModels.@model LotkaVolterra
# Definition of the model
function (lv::LotkaVolterra)(du, u, p, t)
    # Model parameters
    @unpack α, β, = p
    # Current state
    x, y = u
    # fixed parameters
    γ = 3.0
    δ = 1.0

    # Evaluate differential equations
    du[1] = (α[] - β[] * y) * x # prey
    du[2] = (δ * x - γ) * y # predator

    return nothing
end
# Define initial-value problem.
u0 = [1.0, 1.0]
p = (α=[1.5], β=[1.0])
tspan = (0.0, 10.0)

model = LotkaVolterra(ModelParams(;u0, p, tspan, alg=Tsit5(), saveat=0.1))
sol = simulate(model);

And here we go, we have our model defined!

As an exercise, you could try to write the same model with OrdinaryDiffEq.jl.

Let’s now plot the model output.
We first need to import some plotting utilities.
For plotting, I tend to use PyPlot.jl, which is a wrapper around the Python library matplotlib. I find it much more convenient than the Julia package Plots.jl, in the sense that it contains more utility functions. It is always a good idea to load in parallel PyCall.jl, which allows to import other utility funtions from Python.

using PyPlot # to plot 3d landscape
using PyCall

Here is a plot of the model output without noise:

fig = PyPlot.figure()
PyPlot.plot(sol')
display(fig)

We now use sol to generate synthetic data containing some noise (in mathematical terms, we call this a Gaussian white noise, which follows $\mathcal{N}(0,\sigma)$ where $\sigma = 0.8$.

σ = 0.8
odedata = Array(sol) + σ * randn(size(Array(sol)))
fig = figure()
PyPlot.plot(odedata')
display(fig)

The model and the synthetic data are ready, let’s get started with ABC!

Approximate Bayesian computation

For ABC, one needs to define a function $\rho$ that measures the distance between the model output $\hat \mu$ (in the picture below, $\mu_i$) and the empirical data $\mu$.

source: Wikipedia

In our case, the empirical data available (odedata) allows us to explicitly define the likelihood of the model given the data. As a distance function, we therefore can first use the negative log of the likelihood. For additive Gaussian noise, the likelihood of the model is given by

$$\begin{split}
p(y_{1:K} | \theta, \mathcal{M}) &= \prod_{i=1}^K p(y_{i} | \theta, \mathcal{M})\\
&= \prod_{k=1}^K \frac{1}{\sqrt{(2\pi)^d|\Sigma_y|}} \exp \left(-\frac{1}{2} \epsilon_k^{T} \Sigma_y^{-1} \epsilon_k \right)
\end{split}$$

where $\epsilon_k \equiv \epsilon(t_k) = y(t_k) – h\left(\mathcal{M}(t_k, \theta)\right)$ and $\Sigma_y = \sigma^2 I$ in our case.

So let’s translate all this in Julia code.

ApproxBayes.jl requires as input a function simfunc(params, constants, data), which plays the role of the $\rho$ function. It should return the distance value, as well as a second value that may be used for logging. To make things simple, we’ll return nothing for this second value.

Because ApproxBayes.jl requires that params is an array (params <: AbstractArray), we further use a special trick relying on the function Optimiser.destructure. This utility function allows to generate a function re that can recover a NamedTuple from a vector. Very useful in combination with the utility macro @unpack!

_, re = destructure(p)
function log_likelihood(p; odedata=odedata)
    p_tuple = re(p)
    pred = simulate(model, p=p_tuple)
    return sum([loglikelihood(MvNormal(zeros(2), LinearAlgebra.I * σ^2), odedata[:,i] - pred[:,i]) for i in 1:size(odedata,2)])
end
function simfunc(params, constants, data)
    ρ = - log_likelihood(params, odedata=data)
    return ρ, nothing
end

# testing function
simfunc([1.0, 1.2], nothing, odedata)
(1403.0830673958446, nothing)

Now that the distance function is implemented, we define the priors on the parameters $\alpha, \beta$

priors = [truncated(Normal(1.1, 1.), 0.5, 2.5), # for α and β
            truncated(Normal(1.1, 1.), 0., 2.)]
num_samples = 500
max_iterations = 1e5
ϵ = 2.0
abcsetup = ABCRejection(simfunc,
                        length(priors),
                        ϵ,
                        ApproxBayes.Prior(priors);
                        nparticles=num_samples,
                        maxiterations=max_iterations)

abcresult = runabc(abcsetup, odedata, progress=true, parallel=true)
Preparing to run in parallel on 5 processors
Number of simulations: 1.00e+05
Acceptance ratio: 5.00e-03

Median (95% intervals):
Parameter 1: 1.51 (1.48,1.53)
Parameter 2: 1.00 (0.86,1.16)

And here we go: abcresult informs us that the ABC inference could indeed recover the parameters that generated the data!

Visualizing the inference process

To better understand what has happened, let’s plot the parameters that have been accepted by the sampler, together with the real likelihood landscape. As a first step, let’s visualise the real likelihoood landscape.


αs = range(1.45, 1.55, length=100)
βs = range(0.8, 1.3, length=100)
likelihoods = Float64[]
for α in αs
    for β in βs
        p = [α, β]
        push!(likelihoods, log_likelihood(p))
    end
end

likelihoods = exp.(likelihoods); # exponentiating, to make it visually nicer

We now define an Axes3D, as you would do it in Python:

# Plotting 3d landscape
fig = plt.figure()
ax = Axes3D(fig, computed_zorder=false);

We’ll import numpy into Julia, to easily construct the meshgrid required by the matplotlib function plot_surface.

np = pyimport("numpy") # used for plotting
X, Y = np.meshgrid(αs, βs)


# fig.savefig("perturbed_p.png", dpi = 300, bbox_inches="tight")
ax.plot_surface(X .|> Float64,
    Y .|> Float64,
    reshape(likelihoods, (length(αs), length(βs))) .|> Float64,
    edgecolor="0.7",
    linewidth=0.2,
    cmap="Blues", zorder=-1)
ax.set_xlabel(L"p_1")
ax.set_ylabel(L"p_2")
ax.set_zlabel("Likelihood", x=2)
ax.set_xticks([])
ax.set_yticks([])
ax.set_zticks([])

# make the panes transparent
ax.xaxis.set_pane_color((1.0, 1.0, 1.0, 0.0))
ax.yaxis.set_pane_color((1.0, 1.0, 1.0, 0.0))
ax.zaxis.set_pane_color((1.0, 1.0, 1.0, 0.0))
# make the grid lines transparent
ax.xaxis._axinfo["grid"]["color"] = (1.0, 1.0, 1.0, 0.0)
ax.yaxis._axinfo["grid"]["color"] = (1, 1, 1, 0)
ax.zaxis._axinfo["grid"]["color"] = (1, 1, 1, 0)

fig.tight_layout()
fig.set_facecolor("None")
ax.set_facecolor("None")

for p in eachrow(abcresult.parameters)
    ax.scatter(p[1], p[2], exp.(log_likelihood(p)), c="tab:red", zorder=100, s = 0.5)
end
p = abcresult.parameters[1,:]
ax.scatter(p[1], p[2], exp(log_likelihood(p)), c="tab:red", zorder=100, s = 2., label="Accepted simulation")
ax.legend()
display(fig)

Cool, right?

Summary statistics

It may be useful, or necessary, to use summary statistics of the data instead of the full likelihood, to be provided to the distance function. Summary statistics are values calculated from the data to represent the maximum amount of information in the simplest possible form. Using summary statistics allows to reduce the dimensionality of the data, which increases the probability of accepting the model output. In our case, the dimensionality correponds to the number of time steps multiplied by the number of state variables). Using summary statistics may prove useful to improve the convergence of the inference process, were the inference not converge using the likelihood function. But because it reduces the amount of information provided to the inference method, it may also hamper a precise characterisation of the model parameter values.

Mean and variance

As summary statistics, we can here compute the mean and covariance matrix of the state variables, and use those to define the distance between the model output and the data.

compute_summary_stats(data) = [mean(data,dims=2); cov(data, dims=2)[:]]
ss_data = compute_summary_stats(odedata)
6×1 Matrix{Float64}:
 2.966416873264894
 1.5414956648089986
 4.784360022666909
 0.07385819785251807
 0.07385819785251807
 2.680060599643699

Let’s now proceed similarly to above

function simfunc(params, constants, ss_data)
    p_tuple = re(params)
    pred = simulate(model, p=p_tuple)
    ss_pred = compute_summary_stats(pred)
    ρ = sum((ss_pred .- ss_data).^2) # taking euclidean distance between
    return ρ, nothing
end

simfunc([1.0, 1.2], nothing, ss_data)

priors = [truncated(Normal(1.1, 1.), 0.5, 2.5), # for α and β
            truncated(Normal(1.1, 1.), 0., 2.)]
num_samples = 500
max_iterations = 1e5
ϵ = 0.15
abcsetup = ABCRejection(simfunc,
                        length(priors),
                        ϵ,
                        ApproxBayes.Prior(priors);
                        nparticles=num_samples,
                        maxiterations=max_iterations)

abcresult = runabc(abcsetup, ss_data, progress=true, parallel=true)
Preparing to run in parallel on 5 processors
Number of simulations: 1.00e+05
Acceptance ratio: 5.00e-03

Median (95% intervals):
Parameter 1: 2.10 (1.98,2.30)
Parameter 2: 1.11 (1.01,1.23)

As you can see, the results of the inference or much less accurate.

Which other summary statistics of the time series could you think of? You can find below tests with other summary statistics.

And that’s it for today! Hopefully this tutorial has given you some basics and intuition on ABC, and how to use in Julia.

Further references

Appendix

You can find the corresponding tutorial as a .jmd file at https://github.com/vboussange/MyTutorials. To use ParametricModels.jl, follow the procedure indicated on the github repo.
Please contact me, if you have found a mistake, or if you have any comment or suggestion on how to improve this tutorial.

Summary statistics with Fourier transforms

using FFTW
# Fourier Transform of the data
F = fft(odedata .- mean(odedata,dims=2)) |> fftshift
# freqs = fftfreq(size(odedata, 2), 1.0/Ts) |> fftshift
fig = figure()
plot(1:size(odedata,2), abs.(F)')
display(fig)

Let’s select the frequency which frequency is predominent, and consider it for our summary statistics

freq_x = sortperm(abs.(F[1,:]))[end-1:end] # selecting the two most important frequencies
freq_y = sortperm(abs.(F[1,:]))[end-1:end] # selecting the two most important frequencies
@assert freq_x == freq_y # both prey and predator data peak at the same frequencies

function compute_summary_stats(data)
    F = fft(Array(data)) |> fftshift
    return abs.(F[:,freq_x])
end
ss_data = compute_summary_stats(Array(odedata))
simfunc([1.0, 1.2], nothing, ss_data)
(29583.477061802652, nothing)

Let’s now proceed similarly to above

priors = [truncated(Normal(1.1, 1.), 0.5, 2.5), # for α and β
            truncated(Normal(1.1, 1.), 0., 2.)]
num_samples = 500
max_iterations = 1e5
ϵ = 0.15
abcsetup = ABCRejection(simfunc,
                        length(priors),
                        ϵ,
                        ApproxBayes.Prior(priors);
                        nparticles=num_samples,
                        maxiterations=max_iterations)

abcresult = runabc(abcsetup, ss_data, progress=true, parallel=true)
Preparing to run in parallel on 5 processors
Number of simulations: 1.00e+05
Acceptance ratio: 5.00e-03

Median (95% intervals):
Parameter 1: 1.44 (1.05,2.38)
Parameter 2: 0.92 (0.26,0.99)

Although we have quite a large uncertainty on the parameters, the Fourier transform-based summary statistics seem to contain more relevant information, and this is reflected on the more accurate value of the inferred parameters.

Summary statistics with auto-correlation

Here we use the autocorrelation function to build our summary statistics. The default lag value of the autocor in Julia reduces the dimension of the data to a matrix of size 21x2.

using StatsBase
autocor_ss = autocor(odedata') 
fig = figure()
plot(1:size(autocor_ss,1), autocor_ss)
display(fig)

If we were to compute the lag from $t = 0$ to $t = t_{\text{max}}$, we would obtain a sinusoidal curve, similar to the time series raw data.

compute_summary_stats(data) = autocor(Array(data)')
ss_data = compute_summary_stats(odedata)
simfunc([1.0, 1.2], nothing, ss_data)
(1.4530748281677037, nothing)

Let’s now proceed similarly to above

priors = [truncated(Normal(1.1, 1.), 0.5, 2.5), # for α and β
            truncated(Normal(1.1, 1.), 0., 2.)]
num_samples = 500
max_iterations = 1e5
ϵ = 0.15
abcsetup = ABCRejection(simfunc,
                        length(priors),
                        ϵ,
                        ApproxBayes.Prior(priors);
                        nparticles=num_samples,
                        maxiterations=max_iterations)

abcresult = runabc(abcsetup, ss_data, progress=true, parallel=true)
Preparing to run in parallel on 5 processors
Number of simulations: 1.00e+05
Acceptance ratio: 5.00e-03

Median (95% intervals):
Parameter 1: 1.53 (1.45,1.61)
Parameter 2: 0.35 (0.29,0.41)

A practical introduction to approximate Bayesian computation

By: julia | Victor Boussange

Re-posted from: https://vboussange.github.io/post/abc_inference/

Introduction

In this tutorial, we’ll learn the basics of approximate Bayesian computation (ABC).
ABC is a very flexible inference method that is quite intuitive, and that you can apply to almost any model type. As such, it is quite handy to have it in one’s toolbox! To build up an intuition of the method, we’ll perform ABC on a very simple ecosystem model, using Julia, and will visualize graphically the inference results.

Package loading

We first need to load a few packages.

cd(@__DIR__)
using Pkg; Pkg.activate(".")
using Random
using LinearAlgebra
import Optimisers:destructure # required to simplify parameter indexing
using UnPack # for parameter indexing
using ApproxBayes # ABC toolbox
using Distributions # useful to define the priors
using OrdinaryDiffEq # ODE solver library
using ParametricModels # convenience package for ODE models. /!\ package not registered

It is always important to make your work reproducible, so we’ll set manually a seed to the random number generator.

# Set a seed for reproducibility.
Random.seed!(11);

Model definition and data generation

What we’ll do is to implement an Ordinary Differential Equation model, which will be used to generate synthetic data and further perform the inference. The idea here is to consider this synthetic data as our empirical data, and pretend that we do not know which parameters generated this data. Our goal is then to recover the generating parameters.

We’ll consider a predator-prey ecological model, the Lotka-Volterra equations. This model has a total of four different parameters $\alpha, \beta, \gamma, \delta$, that describe the interactions between the two species. For the sake of simplicity, we’ll assume that we know perfectly the parameters $\gamma, \delta$, and seek to infer $\alpha$ and $\beta$. Assuming to know $\gamma, \delta$ is of course a very unrealistic assumption… but a handy assumption to develop an intuition about parameter inference.

We use ParametricModels.jl to define our model. ParametricModels.jl is a simple wrapper around OrdinaryDiffEq.jl, that allows to play around with the model without bothering further about the details of the numerical solve. This makes it easier to simulate repeatedly an ODE model. For readability and maintenance, we’ll encapsulate the parameters in a NamedTuple.

# Declaration of the model
ParametricModels.@model LotkaVolterra
# Definition of the model
function (lv::LotkaVolterra)(du, u, p, t)
    # Model parameters
    @unpack α, β, = p
    # Current state
    x, y = u
    # fixed parameters
    γ = 3.0
    δ = 1.0

    # Evaluate differential equations
    du[1] = (α[] - β[] * y) * x # prey
    du[2] = (δ * x - γ) * y # predator

    return nothing
end
# Define initial-value problem.
u0 = [1.0, 1.0]
p = (α=[1.5], β=[1.0])
tspan = (0.0, 10.0)

model = LotkaVolterra(ModelParams(;u0, p, tspan, alg=Tsit5(), saveat=0.1))
sol = simulate(model);

And here we go, we have our model defined!

As an exercise, you could try to write the same model with OrdinaryDiffEq.jl.

Let’s now plot the model output.
We first need to import some plotting utilities.
For plotting, I tend to use PyPlot.jl, which is a wrapper around the Python library matplotlib. I find it much more convenient than the Julia package Plots.jl, in the sense that it contains more utility functions. It is always a good idea to load in parallel PyCall.jl, which allows to import other utility funtions from Python.

using PyPlot # to plot 3d landscape
using PyCall

Here is a plot of the model output without noise:

fig = PyPlot.figure()
PyPlot.plot(sol')
display(fig)

We now use sol to generate synthetic data containing some noise (in mathematical terms, we call this a Gaussian white noise, which follows $\mathcal{N}(0,\sigma)$ where $\sigma = 0.8$.

σ = 0.8
odedata = Array(sol) + σ * randn(size(Array(sol)))
fig = figure()
PyPlot.plot(odedata')
display(fig)

The model and the synthetic data are ready, let’s get started with ABC!

Approximate Bayesian computation

For ABC, one needs to define a function $\rho$ that measures the distance between the model output $\hat \mu$ (in the picture below, $\mu_i$) and the empirical data $\mu$.

source: Wikipedia

In our case, the empirical data available (odedata) allows us to explicitly define the likelihood of the model given the data. As a distance function, we therefore can first use the negative log of the likelihood. For additive Gaussian noise, the likelihood of the model is given by

$$\begin{split}
p(y_{1:K} | \theta, \mathcal{M}) &= \prod_{i=1}^K p(y_{i} | \theta, \mathcal{M})\\
&= \prod_{k=1}^K \frac{1}{\sqrt{(2\pi)^d|\Sigma_y|}} \exp \left(-\frac{1}{2} \epsilon_k^{T} \Sigma_y^{-1} \epsilon_k \right)
\end{split}$$

where $\epsilon_k \equiv \epsilon(t_k) = y(t_k) – h\left(\mathcal{M}(t_k, \theta)\right)$ and $\Sigma_y = \sigma^2 I$ in our case.

So let’s translate all this in Julia code.

ApproxBayes.jl requires as input a function simfunc(params, constants, data), which plays the role of the $\rho$ function. It should return the distance value, as well as a second value that may be used for logging. To make things simple, we’ll return nothing for this second value.

Because ApproxBayes.jl requires that params is an array (params <: AbstractArray), we further use a special trick relying on the function Optimiser.destructure. This utility function allows to generate a function re that can recover a NamedTuple from a vector. Very useful in combination with the utility macro @unpack!

_, re = destructure(p)
function log_likelihood(p; odedata=odedata)
    p_tuple = re(p)
    pred = simulate(model, p=p_tuple)
    return sum([loglikelihood(MvNormal(zeros(2), LinearAlgebra.I * σ^2), odedata[:,i] - pred[:,i]) for i in 1:size(odedata,2)])
end
function simfunc(params, constants, data)
    ρ = - log_likelihood(params, odedata=data)
    return ρ, nothing
end

# testing function
simfunc([1.0, 1.2], nothing, odedata)
(1403.0830673958446, nothing)

Now that the distance function is implemented, we define the priors on the parameters $\alpha, \beta$

priors = [truncated(Normal(1.1, 1.), 0.5, 2.5), # for α and β
            truncated(Normal(1.1, 1.), 0., 2.)]
num_samples = 500
max_iterations = 1e5
ϵ = 2.0
abcsetup = ABCRejection(simfunc,
                        length(priors),
                        ϵ,
                        ApproxBayes.Prior(priors);
                        nparticles=num_samples,
                        maxiterations=max_iterations)

abcresult = runabc(abcsetup, odedata, progress=true, parallel=true)
Preparing to run in parallel on 5 processors
Number of simulations: 1.00e+05
Acceptance ratio: 5.00e-03

Median (95% intervals):
Parameter 1: 1.51 (1.48,1.53)
Parameter 2: 1.00 (0.86,1.16)

And here we go: abcresult informs us that the ABC inference could indeed recover the parameters that generated the data!

Visualizing the inference process

To better understand what has happened, let’s plot the parameters that have been accepted by the sampler, together with the real likelihood landscape. As a first step, let’s visualise the real likelihoood landscape.


αs = range(1.45, 1.55, length=100)
βs = range(0.8, 1.3, length=100)
likelihoods = Float64[]
for α in αs
    for β in βs
        p = [α, β]
        push!(likelihoods, log_likelihood(p))
    end
end

likelihoods = exp.(likelihoods); # exponentiating, to make it visually nicer

We now define an Axes3D, as you would do it in Python:

# Plotting 3d landscape
fig = plt.figure()
ax = Axes3D(fig, computed_zorder=false);

We’ll import numpy into Julia, to easily construct the meshgrid required by the matplotlib function plot_surface.

np = pyimport("numpy") # used for plotting
X, Y = np.meshgrid(αs, βs)


# fig.savefig("perturbed_p.png", dpi = 300, bbox_inches="tight")
ax.plot_surface(X .|> Float64,
    Y .|> Float64,
    reshape(likelihoods, (length(αs), length(βs))) .|> Float64,
    edgecolor="0.7",
    linewidth=0.2,
    cmap="Blues", zorder=-1)
ax.set_xlabel(L"p_1")
ax.set_ylabel(L"p_2")
ax.set_zlabel("Likelihood", x=2)
ax.set_xticks([])
ax.set_yticks([])
ax.set_zticks([])

# make the panes transparent
ax.xaxis.set_pane_color((1.0, 1.0, 1.0, 0.0))
ax.yaxis.set_pane_color((1.0, 1.0, 1.0, 0.0))
ax.zaxis.set_pane_color((1.0, 1.0, 1.0, 0.0))
# make the grid lines transparent
ax.xaxis._axinfo["grid"]["color"] = (1.0, 1.0, 1.0, 0.0)
ax.yaxis._axinfo["grid"]["color"] = (1, 1, 1, 0)
ax.zaxis._axinfo["grid"]["color"] = (1, 1, 1, 0)

fig.tight_layout()
fig.set_facecolor("None")
ax.set_facecolor("None")

for p in eachrow(abcresult.parameters)
    ax.scatter(p[1], p[2], exp.(log_likelihood(p)), c="tab:red", zorder=100, s = 0.5)
end
p = abcresult.parameters[1,:]
ax.scatter(p[1], p[2], exp(log_likelihood(p)), c="tab:red", zorder=100, s = 2., label="Accepted simulation")
ax.legend()
display(fig)

Cool, right?

Summary statistics

It may be useful, or necessary, to use summary statistics of the data instead of the full likelihood, to be provided to the distance function. Summary statistics are values calculated from the data to represent the maximum amount of information in the simplest possible form. Using summary statistics allows to reduce the dimensionality of the data, which increases the probability of accepting the model output. In our case, the dimensionality correponds to the number of time steps multiplied by the number of state variables). Using summary statistics may prove useful to improve the convergence of the inference process, were the inference not converge using the likelihood function. But because it reduces the amount of information provided to the inference method, it may also hamper a precise characterisation of the model parameter values.

Mean and variance

As summary statistics, we can here compute the mean and covariance matrix of the state variables, and use those to define the distance between the model output and the data.

compute_summary_stats(data) = [mean(data,dims=2); cov(data, dims=2)[:]]
ss_data = compute_summary_stats(odedata)
6×1 Matrix{Float64}:
 2.966416873264894
 1.5414956648089986
 4.784360022666909
 0.07385819785251807
 0.07385819785251807
 2.680060599643699

Let’s now proceed similarly to above

function simfunc(params, constants, ss_data)
    p_tuple = re(params)
    pred = simulate(model, p=p_tuple)
    ss_pred = compute_summary_stats(pred)
    ρ = sum((ss_pred .- ss_data).^2) # taking euclidean distance between
    return ρ, nothing
end

simfunc([1.0, 1.2], nothing, ss_data)

priors = [truncated(Normal(1.1, 1.), 0.5, 2.5), # for α and β
            truncated(Normal(1.1, 1.), 0., 2.)]
num_samples = 500
max_iterations = 1e5
ϵ = 0.15
abcsetup = ABCRejection(simfunc,
                        length(priors),
                        ϵ,
                        ApproxBayes.Prior(priors);
                        nparticles=num_samples,
                        maxiterations=max_iterations)

abcresult = runabc(abcsetup, ss_data, progress=true, parallel=true)
Preparing to run in parallel on 5 processors
Number of simulations: 1.00e+05
Acceptance ratio: 5.00e-03

Median (95% intervals):
Parameter 1: 2.10 (1.98,2.30)
Parameter 2: 1.11 (1.01,1.23)

As you can see, the results of the inference or much less accurate.

Which other summary statistics of the time series could you think of? You can find below tests with other summary statistics.

And that’s it for today! Hopefully this tutorial has given you some basics and intuition on ABC, and how to use in Julia.

Further references

Appendix

You can find the corresponding tutorial as a .jmd file at https://github.com/vboussange/MyTutorials. To use ParametricModels.jl, follow the procedure indicated on the github repo.
Please contact me, if you have found a mistake, or if you have any comment or suggestion on how to improve this tutorial.

Summary statistics with Fourier transforms

using FFTW
# Fourier Transform of the data
F = fft(odedata .- mean(odedata,dims=2)) |> fftshift
# freqs = fftfreq(size(odedata, 2), 1.0/Ts) |> fftshift
fig = figure()
plot(1:size(odedata,2), abs.(F)')
display(fig)

Let’s select the frequency which frequency is predominent, and consider it for our summary statistics

freq_x = sortperm(abs.(F[1,:]))[end-1:end] # selecting the two most important frequencies
freq_y = sortperm(abs.(F[1,:]))[end-1:end] # selecting the two most important frequencies
@assert freq_x == freq_y # both prey and predator data peak at the same frequencies

function compute_summary_stats(data)
    F = fft(Array(data)) |> fftshift
    return abs.(F[:,freq_x])
end
ss_data = compute_summary_stats(Array(odedata))
simfunc([1.0, 1.2], nothing, ss_data)
(29583.477061802652, nothing)

Let’s now proceed similarly to above

priors = [truncated(Normal(1.1, 1.), 0.5, 2.5), # for α and β
            truncated(Normal(1.1, 1.), 0., 2.)]
num_samples = 500
max_iterations = 1e5
ϵ = 0.15
abcsetup = ABCRejection(simfunc,
                        length(priors),
                        ϵ,
                        ApproxBayes.Prior(priors);
                        nparticles=num_samples,
                        maxiterations=max_iterations)

abcresult = runabc(abcsetup, ss_data, progress=true, parallel=true)
Preparing to run in parallel on 5 processors
Number of simulations: 1.00e+05
Acceptance ratio: 5.00e-03

Median (95% intervals):
Parameter 1: 1.44 (1.05,2.38)
Parameter 2: 0.92 (0.26,0.99)

Although we have quite a large uncertainty on the parameters, the Fourier transform-based summary statistics seem to contain more relevant information, and this is reflected on the more accurate value of the inferred parameters.

Summary statistics with auto-correlation

Here we use the autocorrelation function to build our summary statistics. The default lag value of the autocor in Julia reduces the dimension of the data to a matrix of size 21x2.

using StatsBase
autocor_ss = autocor(odedata') 
fig = figure()
plot(1:size(autocor_ss,1), autocor_ss)
display(fig)

If we were to compute the lag from $t = 0$ to $t = t_{\text{max}}$, we would obtain a sinusoidal curve, similar to the time series raw data.

compute_summary_stats(data) = autocor(Array(data)')
ss_data = compute_summary_stats(odedata)
simfunc([1.0, 1.2], nothing, ss_data)
(1.4530748281677037, nothing)

Let’s now proceed similarly to above

priors = [truncated(Normal(1.1, 1.), 0.5, 2.5), # for α and β
            truncated(Normal(1.1, 1.), 0., 2.)]
num_samples = 500
max_iterations = 1e5
ϵ = 0.15
abcsetup = ABCRejection(simfunc,
                        length(priors),
                        ϵ,
                        ApproxBayes.Prior(priors);
                        nparticles=num_samples,
                        maxiterations=max_iterations)

abcresult = runabc(abcsetup, ss_data, progress=true, parallel=true)
Preparing to run in parallel on 5 processors
Number of simulations: 1.00e+05
Acceptance ratio: 5.00e-03

Median (95% intervals):
Parameter 1: 1.53 (1.45,1.61)
Parameter 2: 0.35 (0.29,0.41)

The mighty trio: Project Euler, Simpson’s paradox, and DataFrames.jl

By: Blog by Bogumił Kamiński

Re-posted from: https://bkamins.github.io/julialang/2022/11/25/paradox.html

Introduction

Recently, following users’ feedback, I started solving more puzzles in my blog.
To choose a problem for today I went to Project Euler page, which
provides hundreds of very nice puzzles. I wanted to avoid solving some very
easy problem, so I decided to pick the first puzzle from the list that has
less than 1000 people who solved it. This lead me to problem 236.

To my surprise the problem is an example of the Simpson’s paradox,
so hopefully if you are a data scientist, you will find it interesting.

In general Project Euler does not encourage posting solutions to the problems
given in that page. For this reason, on purpose, I have not optimized my code
and the solution is brute force (to encourage you to find an elegant solution;
the presented solution takes several minutes to produce the result).
Also, I decided to show the code, but not the value of the final solution
(to encourage you to try solving it).

So, why I think it is interesting to read this post? I solved the puzzle
mostly using DataFrames.jl, so I hope you can find some useful data
wrangling patterns when reading it.

The presented codes were run under Julia 1.8.2, DataFrames.jl 1.4.3, and
DataFramesMeta.jl 0.12.0. The solution requires 32GB of RAM (this is, again, on
purpose; if you have less RAM I encourage you to optimize my code as a part of
the Project Euler challenge).

Simpson’s paradox

Simpson’s paradox is a phenomenon in which data analyzed in groups
presents a different direction of relationships than if you analyze the data
after aggregating it.

To show you the paradox in practice let me introduce the
Project Euler problem 236. The description is copied from Project Euler
page:


Suppliers ‘A’ and ‘B’ provided the following numbers of products for the luxury
hamper market:

Product             'A'     'B'
Beluga Caviar       5248     640
Christmas Cake      1312    1888
Gammon Joint        2624    3776
Vintage Port        5760    3776
Champagne Truffles  3936    5664

Although the suppliers try very hard to ship their goods in perfect condition,
there is inevitably some spoilage – i.e. products gone bad.

The suppliers compare their performance using two types of statistic:

  • The five per-product spoilage rates for each supplier are equal to the number
    of products gone bad divided by the number of products supplied, for each of
    the five products in turn.
  • The overall spoilage rate for each supplier is equal to the total number of
    products gone bad divided by the total number of products provided by that
    supplier.

To their surprise, the suppliers found that each of the five per-product
spoilage rates was worse (higher) for ‘B’ than for ‘A’ by the same factor
(ratio of spoilage rates), m>1; and yet, paradoxically, the overall spoilage
rate was worse for ‘A’ than for ‘B’, also by a factor of m.

What’s the largest possible value of m?


This situation is an example of Simpson’s paradox. Within-groups supplier
‘B’ has more spoilage than supplier ‘A’, however in aggregate the situation is
reversed. Before we go and try solving the puzzle let us check if such a
situation is possible.

Assume that the amount of spoilage per product and supplier is as follows:

Product             'A'     'B'
Beluga Caviar       2478    630
Christmas Cake        16     48
Gammon Joint          33     99
Vintage Port         180    246
Champagne Truffles    23     69

We can check that the spoilage percentages are approximately:

Product             'A'     'B'
Beluga Caviar       47.22%  98.44%
Christmas Cake       1.22%   2.54%
Gammon Joint         1.26%   2.62%
Vintage Port         3.13%   6.51%
Champagne Truffles   0.58%   1.22%

As you can see supplier ‘B’ has over 2 times higher spoilage percentage on
every product (you can check that the spoilage percentage ratio is the same for
all products and is approximately equal to 2.085).

Now let us check aggregates. Supplier ‘A’ delivered 18880 products of which
2730 were spoiled. Supplier ‘B’ delivered 15744 products of which 1092 were
spoiled. So the aggregate spoilage percentage for supplier ‘A’ is approximately
14.46%, and for supplier ‘B’ 6.94%. Interestingly, supplier ‘A’ has over two
times higher spoilage percentage than supplier ‘B’ (astonishingly the ratio
of spoilage percentage is the same as above, but in reverse direction).

Solving the puzzle

Let us now solve the puzzle and find the largest possible ratio m of spoilage
percentages between suppliers ‘A’ and ‘B’ that meets the conditions of the
puzzle.

Start by loading the required DataFramesMeta.jl package and the data:

julia> using DataFramesMeta

julia> const pv = [(a=5248, b=640),
                   (a=1312, b=1888),
                   (a=2624, b=3776),
                   (a=5760, b=3776),
                   (a=3936, b=5664)]
5-element Vector{NamedTuple{(:a, :b), Tuple{Int64, Int64}}}:
 (a = 5248, b = 640)
 (a = 1312, b = 1888)
 (a = 2624, b = 3776)
 (a = 5760, b = 3776)
 (a = 3936, b = 5664)

julia> const pt = (a=sum(x -> x.a, pv), b=sum(x -> x.b, pv))
(a = 18880, b = 15744)

The pv vector holds the amounts of products delivered by suppliers ‘A’ and
‘B’ and the pt named tuple stores their totals.

We know that for each product-supplier combination spoilage is at least 1.
Using the allcombinations function we create data frames containing all
possible compositions of spoilage amounts for each product and for total, and
store them in dfs vector:

julia> dfs = [allcombinations(DataFrame, a=1:p.a, b=1:p.b) for p in pv];

julia> push!(dfs, allcombinations(DataFrame, a=1:pt.a, b=1:pt.b));

julia> nrow.(dfs)
6-element Vector{Int64}:
   3358720
   2477056
   9908224
  21749760
  22293504
 297246720

We can see that the number of options is large, but seems within range what
current computers can handle.

Before we proceed let us peek at the contents of one of the data frames to
be sure that its structure is as expected:

julia> dfs[1]
3358720×2 DataFrame
     Row │ a      b
         │ Int64  Int64
─────────┼──────────────
       1 │     1      1
       2 │     2      1
       3 │     3      1
    ⋮    │   ⋮      ⋮
 3358718 │  5246    640
 3358719 │  5247    640
 3358720 │  5248    640
    3358714 rows omitted

All looks good.

Now for each combination we need to compute the m ratio, remembering that for
totals it should be reversed. Additionally, we want to collect columns :a and
:b into one column holding a tuple of values stored in them, as it will be
easier to work with such data later and only keep options for which m is
greater than 1. We can easily do both tasks using macros provided by
DataFramesMeta.jl:

julia> ms = Function[(x, y) -> (y * p.a) // (x * p.b) for p in pv];

julia> push!(ms, (x, y) -> (x * pt.b) // (y * pt.a));

julia> foreach(dfs, ms) do df, m
           @rselect!(df, :m = m(:a, :b), :ab = (:a, :b))
           @rsubset!(df, :m > 1)
       end

Note that for each data frame I defined its own function computing the m
ratio and stored it in ms vector.

Again, let us check how the data frames look after the transformation:

julia> nrow.(dfs)
6-element Vector{Int64}:
   1681600
   1238224
   4953504
  10875840
  11145840
 148621760

julia> dfs[1]
1681600×2 DataFrame
     Row │ m           ab
         │ Rational…   Tuple…
─────────┼─────────────────────────
       1 │      41//5  (1, 1)
       2 │     41//10  (2, 1)
       3 │     41//15  (3, 1)
    ⋮    │     ⋮            ⋮
 1681598 │ 5248//5245  (5245, 640)
 1681599 │ 2624//2623  (5246, 640)
 1681600 │ 5248//5247  (5247, 640)
               1681594 rows omitted

We notice that we dropped around 50% of rows (this is expected). Observe that
using the // operator in the functions calculating m we get rational number
as a result, so our computations are exact, without any rounding.

Let us now group the data frames by the m ratio value (remember we want
a solution where this ratio is the same for all products and for total):

julia> gdfs = groupby.(dfs, :m);

julia> length.(gdfs)
6-element Vector{Int64}:
  1021210
   753076
  3012264
  6612288
  6777256
 90353608

We see that we have quite a lot groups. However we are interested only in cases
where m matches in all the tables, so let us now find such candidate m
values:

julia> key_tuples = [@combine(gdf, :mt = (first(:m),)).mt for gdf in gdfs];

julia> match_keys = intersect(key_tuples...);

julia> sort!(match_keys, rev=true)
7040-element Vector{Tuple{Rational{Int64}}}:
 (1230//1,)
 (738//1,)
 (615//1,)
 ⋮
 (1230//1229,)
 (1476//1475,)
 (3895//3894,)

Fortunately we see that only 7040 possible values of m are available.
I have sorted the matching keys in a descending order to have highest m
values first.

You might wonder why I used @combine to convert the ratios into tuples
holding one-element with this ratio. The reason is that GroupedDataFrame
supports a fast group lookup by the value of the grouping variable and one of
the options of such lookup is to pass a tuple storing it. Therefore we can
create groups vector that stores 6-element vectors of data frames
representing the five products and the total that have matching m:

julia> groups = [[gdfs[i][key] for i in 1:6] for key in match_keys];

Note that this operation in DataFrames.jl is memory efficient. Indexing into
a GroupedDataFrame to get a single group returns a SubDataFrame, which
is a view of the source data frame.

As before, let us peek at the result:

julia> groups[1]
6-element Vector{SubDataFrame{DataFrame, DataFrames.Index, Vector{Int64}}}:
 4×2 SubDataFrame
 Row │ m          ab
     │ Rational…  Tuple…
─────┼─────────────────────
   1 │   1230//1  (1, 150)
   2 │   1230//1  (2, 300)
   3 │   1230//1  (3, 450)
   4 │   1230//1  (4, 600)
 1×2 SubDataFrame
 Row │ m          ab
     │ Rational…  Tuple…
─────┼──────────────────────
   1 │   1230//1  (1, 1770)
 2×2 SubDataFrame
 Row │ m          ab
     │ Rational…  Tuple…
─────┼──────────────────────
   1 │   1230//1  (1, 1770)
   2 │   1230//1  (2, 3540)
 1×2 SubDataFrame
 Row │ m          ab
     │ Rational…  Tuple…
─────┼──────────────────────
   1 │   1230//1  (3, 2419)
 3×2 SubDataFrame
 Row │ m          ab
     │ Rational…  Tuple…
─────┼──────────────────────
   1 │   1230//1  (1, 1770)
   2 │   1230//1  (2, 3540)
   3 │   1230//1  (3, 5310)
 12×2 SubDataFrame
 Row │ m          ab
     │ Rational…  Tuple…
─────┼────────────────────────
   1 │   1230//1  (1475, 1)
   2 │   1230//1  (2950, 2)
   3 │   1230//1  (4425, 3)
   4 │   1230//1  (5900, 4)
   5 │   1230//1  (7375, 5)
   6 │   1230//1  (8850, 6)
   7 │   1230//1  (10325, 7)
   8 │   1230//1  (11800, 8)
   9 │   1230//1  (13275, 9)
  10 │   1230//1  (14750, 10)
  11 │   1230//1  (16225, 11)
  12 │   1230//1  (17700, 12)

So the highest potentially possible m ratio is 1230. But is it valid? Note
that we have not checked one condition yet. The total spoilage (one of the
options in the last data frame displayed above) must be a sum of five
per-product spoilages.

So we need to drop these m ratios for which this is not possible. To achieve
this we define the test_options helper function:

julia> function test_options(inv, outs)
           v = copy(inv[1])
           for i in 2:4
               s = Set{Tuple{Int32, Int32}}()
               for a in v, b in inv[i]
                   push!(s, a .+ b)
               end
               v = collect(s)
           end
           for a in v, b in inv[5]
               a .+ b in outs && return true
           end
           return false
       end
test_options (generic function with 1 method)

This function takes two arguments inv which is a vector of :ab spoilages
of the five products and outs which is a Set of total spoilages.

The function works as follows. We check what possible total sums of five
product spoilages we can get (we do it iteratively, starting from product one,
then adding options for consecutive products). Then we check if the totals of
spoilages for both suppliers can be found in the outs collection (and that is
the reason this is a Set, as sets support fast lookup). If this is the case
we return true. If it is not possible we return false.

Note that the trick in this function, to make it feasible computationally
(again good enough – the solution is not optimal, this is a challenge for you),
is the incremental computation of possible (a, b) product total sums and
reduction of possible options after each iteration in a Set. From the Julia
programming perspective two lines of code are relevant. The first is
v = copy(inv[1]) and the second is v = collect(s). They make the function
type-stable, as they ensure that the v variable over which we iterate a lot
within a function is always bound to the object that has
Vector{Tuple{Int, Int}} type that can be inferred by the compiler.

Let use use the test_options function to filter out the m values that
meet the condition of our puzzle:

julia> answer = [g[1].m[1] for g in groups
                 if test_options([g[i].ab for i in 1:5], Set(g[6].ab))];

julia> first(answer);

Since we have sorted m in descending order above first(answer) is a solution
to our puzzle. I have put ; at the end of all expressions to hide it.

Though, let me show you the data that was disclosed by the puzzle authors on
the Project Euler problem 236 page:

julia> length(answer)
35

julia> last(answer)
1476//1475

Indeed we see that there are 35 unique values of m that are feasible
and that the smallest of them is 1476/1575, so it is quite likely that the
code works correctly.

Conclusions

I hope you enjoyed the puzzle, the Simpson’s paradox example, and the solution
using DataFrames.jl. If you would like to check your programming skills I
encourage you to improve over my solution (an efficient code solving the puzzle
runs in under 1 second).