using Lux, Zygote, Optimisers, Optimization, OptimizationOptimJL,
ComponentArrays, Random, Statistics, OrdinaryDiffEq, Plots
rng = Random.MersenneTwister(0)
const α, β_true, γ, δ = 1.5, 1.0, 3.0, 1.0 # β is the unknown we recover
# 1. Data — solve LV, sample 60×, add 2% multiplicative noise
lv!(du, u, p, t) = (du[1] = α*u[1] - β_true*u[1]*u[2];
du[2] = δ*u[1]*u[2] - γ*u[2])
u0, ts = [1.0, 1.0], range(0.0, 8.0; length = 60)
truth = Array(solve(ODEProblem(lv!, u0, (0.0, 8.0)), Tsit5(); saveat = ts))
data = truth .* (1 .+ 0.02 .* randn(rng, size(truth)))
# 2. Model — known prey term (with unknown β) + neural predator-growth closure
nn = Lux.Chain(Lux.Dense(2 => 8, tanh), Lux.Dense(8 => 1))
p0, stt = Lux.setup(rng, nn)
Nθ(u, p) = first(nn(u, p.net, stt))[1]
field(u, p) = [α*u[1] - p.β*u[1]*u[2], Nθ(u, p) - γ*u[2]] # +δxy → N_θ(x,y)
# pack an interpretable scalar β ALONGSIDE the network weights (grey-box)
ps = ComponentArray{Float64}((β = 0.6, net = ComponentArray{Float64}(p0)))
dt = 0.02; n = round(Int, 8/dt)
function rollout(p) # RK4; Zygote.Buffer ⇒ differentiable
buf = Zygote.Buffer(zeros(2, n+1)); buf[:, 1] = u0; u = u0
for k in 1:n
k1 = field(u, p); k2 = field(u .+ 0.5dt .* k1, p)
k3 = field(u .+ 0.5dt .* k2, p); k4 = field(u .+ dt .* k3, p)
u = u .+ (dt/6) .* (k1 .+ 2 .* k2 .+ 2 .* k3 .+ k4); buf[:, k+1] = u
end
copy(buf)
end
idx = round.(Int, range(1, n+1; length = 60))
predict(p) = rollout(p)[:, idx]
wx, wy = 1/std(data[1, :]), 1/std(data[2, :]) # put both states on equal footing
loss(p) = mean(abs2, (predict(p)[1, :] .- data[1, :]) .* wx) +
mean(abs2, (predict(p)[2, :] .- data[2, :]) .* wy)
# 3. Train — Adam to find the basin, then an L-BFGS polish (the §4.3 recipe)
function adam!(ps, lr, iters)
os = Optimisers.setup(Optimisers.Adam(lr), ps)
for _ in 1:iters
os, ps = Optimisers.update(os, ps, Zygote.gradient(loss, ps)[1])
end
ps
end
ps = adam!(ps, 0.01, 600)
optf = OptimizationFunction((p, _) -> loss(p), Optimization.AutoZygote())
ps = solve(OptimizationProblem(optf, ps), OptimizationOptimJL.LBFGS();
maxiters = 300).u
@show ps.β # ⇒ 1.004 (true 1.0)
# Read it off — learned N_θ(x,y) vs the true δ·x·y, with the orbit overlaid
xs = range(0, maximum(data[1, :])*1.08; length = 70)
ys = range(0, maximum(data[2, :])*1.08; length = 70)
diff = [Nθ([x, y], ps) - δ*x*y for y in ys, x in xs]
heatmap(xs, ys, diff; c = :balance, clims = (-3, 3),
xlabel = "x (prey)", ylabel = "y (predator)",
title = "Nθ(x,y) − δxy (orbit overlaid)")
plot!(truth[1, :], truth[2, :]; c = :black, lw = 2, label = "training orbit")