using CairoMakie
CairoMakie.activate!()Neural Energy-Based Model (NeuralPotts)
Hand-crafting penalty functions requires biological intuition and careful parameter fitting. NeuralPotts offers an alternative: replace (or augment) the explicit energy terms with a neural network trained to reproduce observed cell configurations. The network is an energy-based model (EBM) — it assigns a scalar energy to every lattice configuration, and training minimises the energy of observed data while maximising it for MCMC-generated "fantasy" configurations (Persistent Contrastive Divergence).
A critical architectural constraint: MCMC sampling must run outside the loss function so that Zygote's AD does not trace through the stochastic lattice updates. The PottsTrainingCache struct owns the persistent Markov chains and is mutated in-place during the training loop.
Packages
using PottsToolkit # re-exports CorePotts
using NeuralPotts # extends CorePotts with neural penalty types + training utilities
using MakiePotts
using Lux
using Optimisers
using Statistics
using Random
rng = Random.default_rng()Random.TaskLocalRNG()Step 1: Define a Lux MLP
The neural penalty receives a local patch descriptor — a flattened vector of cell-type labels in a small neighbourhood around each candidate copy event. Here we use a 3×3 patch (9 pixels × n_types one-hot) = 9·3 = 27 input features for three cell types.
n_types = 3 # A, B, Medium
patch_size = 9 # 3×3 neighbourhood, flattened
model = Chain(
Dense(9 => 64, tanh),
Dense(64 => 32, tanh),
Dense(32 => 1) # scalar energy contribution
)
ps, st = Lux.setup(rng, model)((layer_1 = (weight = Float32[-0.19793598 0.4877117 … 0.66770947 -0.6640026; 0.38272616 -0.019913973 … -0.5668389 -0.20634703; … ; 0.8593354 0.73248047 … 0.26938546 0.12114885; 0.31280714 0.69593424 … 0.8853232 0.15248568], bias = Float32[0.1456908, 0.0049444437, 0.23160541, 0.14824207, -0.33006325, -0.012679656, -0.1870656, -0.20254476, 0.052476447, 0.2248884 … -0.017994523, -0.29738352, -0.2597109, 0.023540258, -0.06528139, 0.2070756, 0.12697403, 0.20459759, -0.20467138, 0.014179468]), layer_2 = (weight = Float32[0.17419991 0.30758277 … 0.12487013 -0.016603982; -0.3274888 -0.18545035 … 0.1138879 -0.3225003; … ; 0.12247396 0.07548885 … -0.32331136 -0.0039880513; 0.13147832 -0.16636993 … -0.35286778 0.26826966], bias = Float32[0.02560182, -0.1157179, 0.09626223, -0.11802806, -0.010237157, -0.08994557, -0.056977913, 0.057037905, 0.119446516, 0.036542207 … -0.11324939, 0.05781795, -0.0018962175, 0.057431653, 0.00797753, -0.07881138, -0.079010785, 0.08001548, 0.031337216, -0.09830031]), layer_3 = (weight = Float32[-0.22342251 -0.23041041 … -0.111102104 -0.030094048], bias = Float32[0.15791944])), (layer_1 = NamedTuple(), layer_2 = NamedTuple(), layer_3 = NamedTuple()))Step 2: Wrap in LocalNeuralPenalty
LocalNeuralPenalty is defined in NeuralPotts. It implements the CorePotts.AbstractPenalty interface so it can be dropped into any PottsSystem component list alongside standard components. The radius controls the neighbourhood patch fed to the network.
neural_penalty = LocalNeuralPenalty(model, ps, st)Step 3: Build the Potts system and problem
The neural penalty is listed alongside standard Potts components. The standard components provide a stable prior that keeps cells well-behaved; the neural penalty learns residual structure from data.
A = CellType(:A)
B = CellType(:B)
Medium = CellType(:Medium, is_background = true)
sys = PottsSystem(
cell_types = [Medium, A, B],
penalties = [
VolumeComponent(
A => (λ = 3.0f0, target = 400),
B => (λ = 3.0f0, target = 400)
),
AdhesionComponent(
(A, Medium) => 10.0f0,
(B, Medium) => 10.0f0,
(A, A) => 2.0f0,
(B, B) => 2.0f0,
(A, B) => 8.0f0
),
neural_penalty # learned energy term
]
)
prob = PottsProblem(
sys,
Dict(A => 15, B => 15),
(150, 150);
tspan = (0, 400),
topology = MooreTopology{2}()
)
alg = CheckerboardMetropolis(T = 1.5f0, sweeps_per_step = 10)Step 4: Create a training cache
PottsTrainingCache owns K persistent Markov chains — independent Potts integrators initialised from random states. During training their states are updated (MCMC steps) before the loss is evaluated, so Zygote never needs to differentiate through the stochastic update kernel.
n_chains = 4
cache = PottsTrainingCache(prob, n_chains, alg)
opt_state = Optimisers.setup(Optimisers.Adam(1e-3), ps)
n_data = 8
data_sols = [solve(prob, alg; saveat = 400) for _ in 1:n_data]
observed_lattices = [sol.u[end] for sol in data_sols]
n_epochs = 50
for epoch in 1:n_epochs
advance_chains!(cache, alg; n_steps = 20)
loss_val, grads = Lux.withgradient(ps) do params
NeuralPotts.potts_loss(
neural_penalty,
params, st,
observed_lattices,
cache;
alpha = 1.0f0,
)
end
opt_state, ps = Optimisers.update(opt_state, ps, grads[1])
neural_penalty = LocalNeuralPenalty(model, ps, st)
epoch % 10 == 0 && println("Epoch $epoch — loss: $(round(loss_val; digits=4))")
endStep 6: Run the trained model and visualise
Rebuild the system with the trained parameters, solve, and record.
trained_sys = PottsSystem(
cell_types = [Medium, A, B],
penalties = [
VolumeComponent(
A => (λ = 3.0f0, target = 400),
B => (λ = 3.0f0, target = 400),
),
AdhesionComponent(
(A, Medium) => 10.0f0,
(B, Medium) => 10.0f0,
(A, A) => 2.0f0,
(B, B) => 2.0f0,
(A, B) => 8.0f0,
),
neural_penalty,
],
)
trained_prob = PottsProblem(
trained_sys,
Dict(A => 15, B => 15),
(150, 150);
tspan = (0, 600),
topology = MooreTopology{2}(),
)
trained_sol = solve(trained_prob, alg; saveat = 20)
record_potts(
"neural_ebm_trained.mp4", trained_sol;
metrics = [
"Mean Volume" => u -> begin
n = u.N_cells[]
n == 0 ? 0.0 : mean(Array(u.cell_data.volumes)[1:n])
end,
],
framerate = 20,
resolution = (1000, 800),
)This page was generated using Literate.jl.