Biochemical Oxygen Demand (BOD) Example
This example demonstrates Bayesian parameter estimation for a biochemical oxygen demand model using transport maps. The problem comes from environmental engineering and was originally presented in [9] and later used as a benchmark in transport map applications [1].
The model describes the evolution of biochemical oxygen demand (BOD) in a river system using an exponential growth model with two uncertain parameters controlling growth and decay rates.
using TransportMaps
using Plots
using Distributions
using SpecialFunctions
The Forward Model
The BOD model is given by:
\[\mathcal{B}(t) = A(1-\exp(-Bt))+\mathcal{E}\]
where the parameters A and B are functions of the uncertain inputs θ₁ and θ₂:
\[\begin{aligned} A &= 0.4 + 0.4\left(1 + \text{erf}\left(\frac{\theta_1}{\sqrt{2}} \right)\right) \\ B &= 0.01 + 0.15\left(1 + \text{erf}\left(\frac{\theta_2}{\sqrt{2}} \right)\right) \end{aligned}\]
and ℰ ~ 𝒩(0, 10⁻³) represents measurement noise.
function forward_model(t, x)
A = 0.4 + 0.4 * (1 + erf(x[1] / sqrt(2)))
B = 0.01 + 0.15 * (1 + erf(x[2] / sqrt(2)))
return A * (1 - exp(-B * t))
end
Experimental Data
We have BOD measurements at five time points:
t = [1, 2, 3, 4, 5]
D = [0.18, 0.32, 0.42, 0.49, 0.54]
σ = sqrt(1e-3)
Let's visualize the data along with model predictions for different parameter values:
s = scatter(t, D, label="Data", xlabel="Time (t)", ylabel="Biochemical Oxygen Demand (D)",
size=(600, 400), legend=:topleft)
# Plot model output for some parameter values
t_values = range(0, 5, length=100)
for x₁ in [-0.5, 0, 0.5]
for x₂ in [-0.5, 0, 0.5]
plot!(t_values, [forward_model(ti, [x₁, x₂]) for ti in t_values],
label="(x₁ = $x₁, x₂ = $x₂)", linestyle=:dash)
end
end
Bayesian Inference Setup
We define the posterior distribution using a standard normal prior on both parameters and a Gaussian likelihood for the observations:
\[\pi(\mathbf{y}|\boldsymbol{\theta}) = \prod_{t=1}^{5} \frac{1}{\sqrt{2\pi\sigma^2}}\exp\left(-\frac{1}{2\sigma^2}(y_t - \mathcal{B}(t))^2\right)\]
function posterior(x)
# Calculate the likelihood
likelihood = prod([pdf(Normal(forward_model(t[k], x), σ), D[k]) for k in 1:5])
# Calculate the prior
prior = pdf(Normal(0,1), x[1]) * pdf(Normal(0,1), x[2])
return prior * likelihood
end
target = MapTargetDensity(posterior, :auto_diff)
MapTargetDensity(density=posterior, gradient_type=auto_diff)
Creating and Optimizing the Transport Map
We use a 2-dimensional polynomial transport map with degree 3 and Softplus rectifier:
M = PolynomialMap(2, 3, :normal, Softplus())
PolynomialMap:
Dimensions: 2
Total coefficients: 14
Reference density: Distributions.Normal{Float64}(μ=0.0, σ=1.0)
Maximum degree: 3
Basis: Hermite
Rectifier: Softplus
Components:
Component 1: 4 basis functions
Component 2: 10 basis functions
Coefficients: min=6.9267715158288e-310, max=6.9268182210756e-310, mean=6.9268024908456e-310
Set up Gauss-Hermite quadrature for optimization:
quadrature = GaussHermiteWeights(10, 2)
GaussHermiteWeights:
Number of points: 100
Dimensions: 2
Quadrature type: Tensor product Gauss-Hermite
Reference measure: Standard Gaussian
Weight range: [1.858172610271843e-11, 0.11877833902739371]
Weight sum: 0.9999999999999994
Points (first 5):
[-4.859462828332312, -4.859462828332312] → weight: 1.858172610271843e-11
[-3.581823483551927, -4.859462828332312] → weight: 3.2677804672640156e-9
[-2.484325841638953, -4.859462828332312] → weight: 8.238338476283024e-8
[-1.4659890943911627, -4.859462828332312] → weight: 5.840231806713457e-7
[-0.48493570751550125, -4.859462828332312] → weight: 1.4856333877315967e-6
... and 95 more
Optimize the map coefficients:
@time res = optimize!(M, target, quadrature)
println("Optimization result: ", res)
12.506762 seconds (168.44 M allocations: 9.810 GiB, 5.30% gc time, 3.59% compilation time)
Optimization result: * Status: success
* Candidate solution
Final objective value: -6.229888e+00
* Found with
Algorithm: L-BFGS
* Convergence measures
|x - x'| = 2.18e-09 ≰ 0.0e+00
|x - x'|/|x'| = 1.35e-09 ≰ 0.0e+00
|f(x) - f(x')| = 1.12e-13 ≰ 0.0e+00
|f(x) - f(x')|/|f(x')| = 1.80e-14 ≰ 0.0e+00
|g(x)| = 6.68e-09 ≤ 1.0e-08
* Work counters
Seconds run: 12 (vs limit Inf)
Iterations: 123
f(x) calls: 368
∇f(x) calls: 368
Generating Posterior Samples
Generate samples from the standard normal distribution and map them to the posterior:
samples_z = randn(1000, 2)
1000×2 Matrix{Float64}:
-1.81761 1.539
-1.2094 0.412999
0.0280115 0.0477759
-0.0261609 -0.945836
1.16377 -0.454578
1.14597 -0.0249904
-0.364864 0.633504
0.583589 -0.97581
-0.46293 0.589189
-1.72181 -0.162177
⋮
-1.01781 0.768779
-0.21267 0.762467
0.177849 -0.783171
-0.200454 -0.31037
-0.290224 0.310542
0.762085 -0.329262
-0.64664 -1.45032
0.0235214 0.49285
-1.74864 -0.918771
Map the samples through our transport map:
mapped_samples = evaluate(M, samples_z)
1000×2 Matrix{Float64}:
-0.392813 2.84286
-0.313125 1.71808
-0.0600533 0.869273
-0.0752472 0.734279
0.425555 0.218161
0.414593 0.263077
-0.15969 1.24027
0.130869 0.43516
-0.181176 1.29395
-0.381391 1.74869
⋮
-0.283681 1.73501
-0.123849 1.1698
-0.0152137 0.657153
-0.120831 0.93175
-0.142511 1.11309
0.209077 0.419551
-0.218421 0.932792
-0.0613323 0.955441
-0.384626 1.46972
Quality Assessment
Compute the variance diagnostic to assess the quality of our approximation:
var_diag = variance_diagnostic(M, target, samples_z)
println("Variance Diagnostic: ", var_diag)
Variance Diagnostic: 0.011423391411532185
Visualization
Plot the mapped samples along with contours of the true posterior density:
x₁ = range(-0.5, 1.5, length=100)
x₂ = range(-0.5, 3, length=100)
posterior_values = [posterior([x₁, x₂]) for x₂ in x₂, x₁ in x₁]
scatter(mapped_samples[:, 1], mapped_samples[:, 2],
label="Mapped Samples", alpha=0.5, color=1,
xlabel="x₁", ylabel="x₂", title="Posterior Density and Mapped Samples")
contour!(x₁, x₂, posterior_values, colormap=:viridis, label="Posterior Density")
Finally, we can compute the pullback density to visualize how well our transport map approximates the posterior:
posterior_pullback = [pullback(M, [x₁, x₂]) for x₂ in x₂, x₁ in x₁]
contour(x₁, x₂, posterior_values./maximum(posterior_values);
levels = 5, colormap = :viridis, colorbar = false,
label="Target", xlabel="x₁", ylabel="x₂")
contour!(x₁, x₂, posterior_pullback./maximum(posterior_pullback);
levels = 5, colormap = :viridis, linestyle=:dash,
label="Pullback")
Model Parameter Interpretation
The posterior samples provide uncertainty quantification for the BOD model parameters:
- x₁: Controls the maximum BOD level (parameter A)
- x₂: Controls the rate of BOD development (parameter B)
The correlation structure in the posterior reflects the interdependence between these parameters in explaining the observed data.