Bayesian Physics informed Neural Network ODEs Solvers

Bayesian inference for PINNs provides an approach to ODE solution finding and parameter estimation with quantified uncertainty.

The Lotka-Volterra Model

The Lotka–Volterra equations, also known as the predator–prey equations, are a pair of first-order nonlinear differential equations. These differential equations are frequently used to describe the dynamics of biological systems in which two species interact, one as a predator and the other as prey. The populations change through time according to the pair of equations:

\[\begin{aligned} \frac{\mathrm{d}x}{\mathrm{d}t} &= (\alpha - \beta y(t))x(t), \\ \frac{\mathrm{d}y}{\mathrm{d}t} &= (\delta x(t) - \gamma)y(t) \end{aligned}\]

where $x(t)$ and $y(t)$ denote the populations of prey and predator at time $t$, respectively, and $\alpha, \beta, \gamma, \delta$ are positive parameters.

We implement the Lotka-Volterra model and simulate it with ideal parameters $\alpha = 1.5$, $\beta = 1$, $\gamma = 3$, and $\delta = 1$ and initial conditions $x(0) = y(0) = 1$.

We then solve the equations and estimate the parameters of the model with priors for $\alpha$, $\beta$, $\gamma$ and $\delta$ as Normal(1,2), Normal(2,2), Normal(2,2) and Normal(0,2) using a neural network.

using NeuralPDE, Lux, Plots, OrdinaryDiffEq, Distributions, Random

function lotka_volterra(u, p, t)
    # Model parameters.
    α, β, γ, δ = p
    # Current state.
    x, y = u

    # Evaluate differential equations.
    dx = (α - β * y) * x # prey
    dy = (δ * x - γ) * y # predator

    return [dx, dy]
end

# initial-value problem.
u0 = [1.0, 1.0]
p = [1.5, 1.0, 3.0, 1.0]
tspan = (0.0, 4.0)
prob = ODEProblem(lotka_volterra, u0, tspan, p)
ODEProblem with uType Vector{Float64} and tType Float64. In-place: false
Non-trivial mass matrix: false
timespan: (0.0, 4.0)
u0: 2-element Vector{Float64}:
 1.0
 1.0

With the saveat argument, we can specify that the solution is stored only at saveat time units.

# Solve using OrdinaryDiffEq.jl solver
dt = 0.01
solution = solve(prob, Tsit5(); saveat = dt)
retcode: Success
Interpolation: 1st order linear
t: 401-element Vector{Float64}:
 0.0
 0.01
 0.02
 0.03
 0.04
 0.05
 0.06
 0.07
 0.08
 0.09
 ⋮
 3.92
 3.93
 3.94
 3.95
 3.96
 3.97
 3.98
 3.99
 4.0
u: 401-element Vector{Vector{Float64}}:
 [1.0, 1.0]
 [1.0051122697054304, 0.9802235489841001]
 [1.0104482482084793, 0.9608884029133249]
 [1.0160067852516195, 0.9419859539931972]
 [1.0217868581271055, 0.9235077034160883]
 [1.0277875716769742, 0.9054452613612181]
 [1.0340081582930438, 0.887790346994655]
 [1.040447977916915, 0.870534788469315]
 [1.0471065141055134, 0.8536705240234226]
 [1.0539833005834183, 0.837189627503347]
 ⋮
 [1.7188602790655703, 0.35603128520071003]
 [1.7386754231380195, 0.35153232204585927]
 [1.7587969344457686, 0.3471593575148184]
 [1.779227949981121, 0.34291022060855736]
 [1.7999716537968578, 0.3387828301940905]
 [1.821031277006269, 0.3347751950044705]
 [1.8424100977831226, 0.3308854136387941]
 [1.8641114413617017, 0.3271116745621956]
 [1.886138680036769, 0.32345225610585315]

We generate noisy observations to use for the parameter estimation task in this tutorial. To make the example more realistic we add random normally distributed noise to the simulation.

# Dataset creation for parameter estimation (30% noise)
time = solution.t
u = hcat(solution.u...)
x = u[1, :] + (u[1, :]) .* (0.3 .* randn(length(u[1, :])))
y = u[2, :] + (u[2, :]) .* (0.3 .* randn(length(u[2, :])))
dataset = [x, y, time]

# Plotting the data which will be used
plot(time, x, label = "noisy x")
plot!(time, y, label = "noisy y")
plot!(solution, labels = ["x" "y"])
Example block output

Let's define a PINN.

# Neural Networks must have 2 outputs as u -> [dx,dy] in function lotka_volterra()
chain = Chain(Dense(1, 6, tanh), Dense(6, 6, tanh), Dense(6, 2))
Chain(
    layer_1 = Dense(1 => 6, tanh),      # 12 parameters
    layer_2 = Dense(6 => 6, tanh),      # 42 parameters
    layer_3 = Dense(6 => 2),            # 14 parameters
)         # Total: 68 parameters,
          #        plus 0 states.

The dataset we generated can be passed for doing parameter estimation using provided priors in param keyword argument for BNNODE.

alg = BNNODE(chain;
    dataset = dataset,
    draw_samples = 1000,
    l2std = [0.1, 0.1],
    phystd = [0.1, 0.1],
    priorsNNw = (0.0, 3.0),
    param = [
        Normal(1, 2),
        Normal(2, 2),
        Normal(2, 2),
        Normal(0, 2)], progress = false)

sol_pestim = solve(prob, alg; saveat = dt)

The solution for the ODE is returned as a nested vector sol_flux_pestim.ensemblesol. Here, [$x$ , $y$] would be returned.

# plotting solution for x,y for chain
plot(time, sol_pestim.ensemblesol[1], label = "estimated x")
plot!(time, sol_pestim.ensemblesol[2], label = "estimated y")

# comparing it with the original solution
plot!(solution, labels = ["true x" "true y"])
Example block output

We can see the estimated ODE parameters by -

sol_pestim.estimated_de_params
4-element Vector{MonteCarloMeasurements.Particles{Float64, 334}}:
 1.49 ± 0.0087
 0.986 ± 0.0057
 3.03 ± 0.036
 1.01 ± 0.0097

We can see it is close to the true values of the parameters.