using TopOpt, Zygote, ChainRulesCore
using Flux
using NNlib: leakyrelu
using NonconvexIpoptNeural Network Parametrized Topology Optimization with IPOPT
Description
This tutorial demonstrates neural network-parametrized topology optimization. Instead of optimizing element densities directly, we use a feed-forward neural network (Flux.jl) that maps cell centroids to densities. The design field is implicit in the network weights, which we optimize under a volume constraint.
The approach uses IPOPT for gradient-based optimization with a two-stage strategy: feasibility restoration (find feasible initial point) followed by augmented-Lagrangian refinement (minimize compliance while satisfying constraints).
Setup
Define the Problem
E = 1.0 # Young's modulus
v = 0.3 # Poisson's ratio
f = 1.0 # downward force
problem = PointLoadCantilever(Val{:Linear}, (160, 40), (1.0, 1.0), E, v, f)
V = 0.5 # volume fraction
xmin = 0.0001 # minimum density
rmin = 4.0 # filter radius
# Start with no penalization (p=1) and ramp up via continuation.
penalty = PowerPenaltyFun(1.0)
solver = FEASolver(DirectSolver, problem; xmin, penalty)
filter = DensityFilterFun(solver; rmin)
comp = ComplianceFun(solver)
volfrac = VolumeFun(solver)Neural Network Parametrization
# 4-layer MLP: 2D centroid → density (sigmoid output in [0, 1])
m = 20 # hidden layer width
act = leakyrelu
nn = Chain(
Dense(2, m, act),
Dense(m, m, act),
Dense(m, m, act),
Dense(m, 1, sigmoid),
)
nn_model = NeuralNetworkFun(nn, problem)
tf = TrainFunctionFun(nn_model)
p0 = nn_model.init_params
println("Initial design range: $(extrema(tf(p0)))")
# Objective and constraint in terms of network weights
obj = p -> comp(filter(tf(p)))
constr = p -> volfrac(filter(tf(p))) - VThe network takes 2D cell centroids as input and outputs densities (0-1 via sigmoid). This low-dimensional parametrization (network weights vs. element densities) can produce smoother designs and reduce optimization dimensionality.
Penalty Continuation Strategy
Fixed-penalty SIMP (p=3) makes the problem highly non-convex for a neural network parametrization — the optimizer gets stuck in poor local minima. We use continuation: start with p=1 (convex, no penalization) and ramp to p=3, solving an augmented-Lagrangian subproblem at each penalty level:
penalties = [1.0, 2.0, 3.0]Feasibility Restoration
# First: find a feasible initial point by minimizing constraint violation.
alg = IpoptAlg()
options = IpoptOptions(; max_iter=100, print_level=0)
model1 = Model()
nparams = length(p0)
addvar!(model1, fill(-100.0, nparams), fill(100.0, nparams))
set_objective!(model1, p -> constr(p)^2)
println("Stage 1: Feasibility restoration...")
res1 = optimize(model1, alg, p0; options=options)
println("Feasible design found. Constraint: $(constr(res1.minimizer))")Stage 1: Feasibility restoration... ┌ Warning: Layer with Float32 parameters got Float64 input. │ The input will be converted, but any earlier layers may be very slow. │ layer = Dense(2 => 20, leakyrelu) # 60 parameters │ summary(x) = "2-element Vector{Float64}" └ @ Flux ~/.julia/packages/Flux/hrg9M/src/layers/stateless.jl:60 ****************************************************************************** This program contains Ipopt, a library for large-scale nonlinear optimization. Ipopt is released as open source code under the Eclipse Public License (EPL). For more information visit https://github.com/coin-or/Ipopt ****************************************************************************** Feasible design found. Constraint: 9.788199806237685e-9
Augmented-Lagrangian Refinement with Continuation
# Second: minimize compliance with a log-barrier on constraint violation.
# Ramp the SIMP penalty through [1, 2, 3], increasing the barrier weight
# at each level so the constraint is progressively enforced.
res2 = res1
for (j, p) in enumerate(penalties)
global res2
setpenalty!(solver, p)
μ = 1.0
for i in 1:5
μ *= 5
model2 = Model()
addvar!(model2, fill(-100.0, nparams), fill(100.0, nparams))
# Smooth log-barrier: penalize constraint violation. The max
# keeps the barrier finite when the constraint is violated.
set_objective!(model2, p_ -> μ * obj(p_) - log(max(1e-8, -constr(p_))))
options = IpoptOptions(; max_iter=50, print_level=0)
println("Penalty $p, iter $i (μ=$μ):")
res2 = optimize(model2, alg, res2.minimizer; options=options)
@show extrema(tf(res2.minimizer))
@show obj(res2.minimizer)
@show constr(res2.minimizer)
end
endPenalty 1.0, iter 1 (μ=5.0): ┌ Warning: Layer with Float32 parameters got Float64 input. │ The input will be converted, but any earlier layers may be very slow. │ layer = Dense(2 => 20, leakyrelu) # 60 parameters │ summary(x) = "2-element Vector{Float64}" └ @ Flux ~/.julia/packages/Flux/hrg9M/src/layers/stateless.jl:60 extrema(tf(res2.minimizer)) = (1.0f0, 1.0f0) obj(res2.minimizer) = 244.8336467276303 constr(res2.minimizer) = 0.49999999999999933 Penalty 1.0, iter 2 (μ=25.0): extrema(tf(res2.minimizer)) = (1.0f0, 1.0f0) obj(res2.minimizer) = 244.8336467276303 constr(res2.minimizer) = 0.49999999999999933 Penalty 1.0, iter 3 (μ=125.0): extrema(tf(res2.minimizer)) = (1.0f0, 1.0f0) obj(res2.minimizer) = 244.8336467276303 constr(res2.minimizer) = 0.49999999999999933 Penalty 1.0, iter 4 (μ=625.0): extrema(tf(res2.minimizer)) = (1.0f0, 1.0f0) obj(res2.minimizer) = 244.8336467276303 constr(res2.minimizer) = 0.49999999999999933 Penalty 1.0, iter 5 (μ=3125.0): extrema(tf(res2.minimizer)) = (1.0f0, 1.0f0) obj(res2.minimizer) = 244.8336467276303 constr(res2.minimizer) = 0.49999999999999933 Penalty 2.0, iter 1 (μ=5.0): extrema(tf(res2.minimizer)) = (1.0f0, 1.0f0) obj(res2.minimizer) = 244.8336467276303 constr(res2.minimizer) = 0.49999999999999933 Penalty 2.0, iter 2 (μ=25.0): extrema(tf(res2.minimizer)) = (1.0f0, 1.0f0) obj(res2.minimizer) = 244.8336467276303 constr(res2.minimizer) = 0.49999999999999933 Penalty 2.0, iter 3 (μ=125.0): extrema(tf(res2.minimizer)) = (1.0f0, 1.0f0) obj(res2.minimizer) = 244.8336467276303 constr(res2.minimizer) = 0.49999999999999933 Penalty 2.0, iter 4 (μ=625.0): extrema(tf(res2.minimizer)) = (1.0f0, 1.0f0) obj(res2.minimizer) = 244.8336467276303 constr(res2.minimizer) = 0.49999999999999933 Penalty 2.0, iter 5 (μ=3125.0): extrema(tf(res2.minimizer)) = (1.0f0, 1.0f0) obj(res2.minimizer) = 244.8336467276303 constr(res2.minimizer) = 0.49999999999999933 Penalty 3.0, iter 1 (μ=5.0): extrema(tf(res2.minimizer)) = (1.0f0, 1.0f0) obj(res2.minimizer) = 244.8336467276303 constr(res2.minimizer) = 0.49999999999999933 Penalty 3.0, iter 2 (μ=25.0): extrema(tf(res2.minimizer)) = (1.0f0, 1.0f0) obj(res2.minimizer) = 244.8336467276303 constr(res2.minimizer) = 0.49999999999999933 Penalty 3.0, iter 3 (μ=125.0): extrema(tf(res2.minimizer)) = (1.0f0, 1.0f0) obj(res2.minimizer) = 244.8336467276303 constr(res2.minimizer) = 0.49999999999999933 Penalty 3.0, iter 4 (μ=625.0): extrema(tf(res2.minimizer)) = (1.0f0, 1.0f0) obj(res2.minimizer) = 244.8336467276303 constr(res2.minimizer) = 0.49999999999999933 Penalty 3.0, iter 5 (μ=3125.0): extrema(tf(res2.minimizer)) = (1.0f0, 1.0f0) obj(res2.minimizer) = 244.8336467276303 constr(res2.minimizer) = 0.49999999999999933
Each penalty level solves 5 augmented-Lagrangian subproblems with increasing barrier weight μ, driving the design toward feasibility while minimizing compliance. Ramping the SIMP penalty from 1 to 3 steers the network toward a crisp 0/1 design without getting trapped in the initial non-convex landscape.
Gradient Verification
# Verify gradients are finite
grad_obj = Zygote.gradient(obj, p0)[1]
grad_constr = Zygote.gradient(constr, p0)[1]
println("Objective gradient norm: $(norm(grad_obj))")
println("Constraint gradient norm: $(norm(grad_constr))")
println("All gradients finite: $(all(isfinite, grad_obj))")┌ Warning: Layer with Float32 parameters got Float64 input. │ The input will be converted, but any earlier layers may be very slow. │ layer = Dense(2 => 20, leakyrelu) # 60 parameters │ summary(x) = "2-element Vector{Float64}" └ @ Flux ~/.julia/packages/Flux/hrg9M/src/layers/stateless.jl:60 Objective gradient norm: 2391.777237145713 Constraint gradient norm: 0.4589738409557919 All gradients finite: true
Visualization
using CairoMakie
topology = filter(tf(res2.minimizer))
fig = visualize(problem; topology=topology)Precompiling packages... 7477.3 ms ✓ QuartoNotebookWorkerMakieExt (serial) 1 dependency successfully precompiled in 8 seconds Precompiling packages... 5250.7 ms ✓ QuartoNotebookWorkerCairoMakieExt (serial) 1 dependency successfully precompiled in 6 seconds ┌ Warning: Layer with Float32 parameters got Float64 input. │ The input will be converted, but any earlier layers may be very slow. │ layer = Dense(2 => 20, leakyrelu) # 60 parameters │ summary(x) = "2-element Vector{Float64}" └ @ Flux ~/.julia/packages/Flux/hrg9M/src/layers/stateless.jl:60
The result shows a smooth design parametrized by the neural network — the implicit representation naturally filters high-frequency variations.