Inverse: nonlinear reaction coefficient (closed-form)
Reaction-coefficient inverse problem with a fixed-target posterior.
Recover the scalar k in λ u''(x) + k tanh(u(x)) = f(x) from noisy
observations of the forcing f. Because the analytical solution
u_exact = sin(πx)/π² is known, we plug it directly into the PDE
residual instead of training a surrogate. NUTS samples a fixed-target
posterior over k with calibrated, hyperparameter-stable credible
intervals — matching Yang et al. 2021's textbook B-PINN HMC result
(k ≈ 0.705 ± 0.006 for the small-noise case, our short chain hits
k ≈ 0.703 ± 0.043).
If u were unknown (a real PDE solve), the two-stage surrogate
pattern via substeps=[([surrogate_constraints], n_train), ([pde], 1)]
with .stop_gradient on the surrogate in the PDE residual is the
recommended alternative. Tutorial 06 (Inverse FEM
Diffusivity) shows the more general
case where the forward model is fully numerical (FEM).
Reference
Yang, L., Meng, X., & Karniadakis, G. E. (2021). B-PINNs: Bayesian physics-informed neural networks for forward and inverse PDE problems with noisy data. Journal of Computational Physics, 425, 109913.
Script
"""03 — Bayesian inverse: recover nonlinear reaction coefficient k"""
from pathlib import Path
import blackjax
import jax
import jax.numpy as jnp
import jno
π = jno.np.pi
# ── Physical setup ────────────────────────────────────────────────────────────
λ = 0.01
k_true = 0.7
sigma_obs = 0.005 # noise on observed f(x)
# ── Domain ────────────────────────────────────────────────────────────────────
domain = jno.domain.line(x_range=(-0.7, 0.7), mesh_size=0.05)
x, _ = domain.variable("interior")
# ── Analytical solution + noisy forcing observations under k_true ────────────
u = jno.np.sin(π * x) / π**2 # exact solution
u_xx = -jno.np.sin(π * x) # analytical 2nd derivative (no FD noise)
# Synthetic noisy forcing observations. jno.noise.gaussian is redrawn
# each step from the solver's PRNG key — with a fixed global seed the
# realisation is consistent across the whole chain.
f_obs = λ * u_xx + k_true * jno.np.tanh(u) + jno.noise.gaussian(std=sigma_obs)
# ── Bayesian reaction coefficient — NUTS with closed-form forward ────────────
k = jno.np.parameter((1,), key=jax.random.PRNGKey(0), name="k")
k.bayesian(
blackjax.nuts,
step_size=1e-2, # initial guess; window adaptation tunes it
warmup=200,
keep=400,
# adapt=True default — pure Bayesian inference, adaptation is well-defined.
)
# ── Likelihood: PDE-residual scaled by σ ──────────────────────────────────────
residual = (λ * u_xx + k * jno.np.tanh(u) - f_obs) / sigma_obs
# ── Solve — pure Bayesian, no surrogate, no substeps needed ──────────────────
crux = jno.core([residual.mse])
crux.solve(600)
# ── Posterior summary ────────────────────────────────────────────────────────
k_chain = k.posterior_samples
k_mean = float(jnp.mean(k_chain))
k_std = float(jnp.std(k_chain))
k_lo, k_hi = (float(v) for v in jnp.quantile(k_chain, jnp.array([0.05, 0.95])))
print(f"k = {k_mean:.4f} ± {k_std:.4f}")
print(f" 90% CI = [{k_lo:.4f}, {k_hi:.4f}] truth = {k_true}")
rel_k = abs(k_mean - k_true) / abs(k_true)
results_file = Path(__file__).parent.parent.parent / "tutorial_results.txt"
with open(results_file, "a") as f:
f.write(
f"10_bayesian_pinns/03_inverse_reaction_coefficient.py | epochs=600 | "
f"rel_k={rel_k:.4f} | CI_width={k_hi - k_lo:.4f}\n"
)
assert rel_k < 0.1, f"posterior-mean k off by {rel_k:.2%}"