Neural 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

using TopOpt, Zygote, ChainRulesCore
using Flux
using NNlib: leakyrelu
using NonconvexIpopt

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))) - V

The 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: -1.6763840315903167e-10

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
end
Penalty 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.83364672700807
constr(res2.minimizer) = 0.49999999999999933
Penalty 1.0, iter 2 (μ=25.0):
extrema(tf(res2.minimizer)) = (1.0f0, 1.0f0)
obj(res2.minimizer) = 244.83364672700807
constr(res2.minimizer) = 0.49999999999999933
Penalty 1.0, iter 3 (μ=125.0):
extrema(tf(res2.minimizer)) = (1.0f0, 1.0f0)
obj(res2.minimizer) = 244.83364672700807
constr(res2.minimizer) = 0.49999999999999933
Penalty 1.0, iter 4 (μ=625.0):
extrema(tf(res2.minimizer)) = (1.0f0, 1.0f0)
obj(res2.minimizer) = 244.83364672700807
constr(res2.minimizer) = 0.49999999999999933
Penalty 1.0, iter 5 (μ=3125.0):
extrema(tf(res2.minimizer)) = (1.0f0, 1.0f0)
obj(res2.minimizer) = 244.83364672700807
constr(res2.minimizer) = 0.49999999999999933
Penalty 2.0, iter 1 (μ=5.0):
extrema(tf(res2.minimizer)) = (1.0f0, 1.0f0)
obj(res2.minimizer) = 244.83364672700807
constr(res2.minimizer) = 0.49999999999999933
Penalty 2.0, iter 2 (μ=25.0):
extrema(tf(res2.minimizer)) = (1.0f0, 1.0f0)
obj(res2.minimizer) = 244.83364672700807
constr(res2.minimizer) = 0.49999999999999933
Penalty 2.0, iter 3 (μ=125.0):
extrema(tf(res2.minimizer)) = (1.0f0, 1.0f0)
obj(res2.minimizer) = 244.83364672700807
constr(res2.minimizer) = 0.49999999999999933
Penalty 2.0, iter 4 (μ=625.0):
extrema(tf(res2.minimizer)) = (1.0f0, 1.0f0)
obj(res2.minimizer) = 244.83364672700807
constr(res2.minimizer) = 0.49999999999999933
Penalty 2.0, iter 5 (μ=3125.0):
extrema(tf(res2.minimizer)) = (1.0f0, 1.0f0)
obj(res2.minimizer) = 244.83364672700807
constr(res2.minimizer) = 0.49999999999999933
Penalty 3.0, iter 1 (μ=5.0):
extrema(tf(res2.minimizer)) = (1.0f0, 1.0f0)
obj(res2.minimizer) = 244.83364672700807
constr(res2.minimizer) = 0.49999999999999933
Penalty 3.0, iter 2 (μ=25.0):
extrema(tf(res2.minimizer)) = (1.0f0, 1.0f0)
obj(res2.minimizer) = 244.83364672700807
constr(res2.minimizer) = 0.49999999999999933
Penalty 3.0, iter 3 (μ=125.0):
extrema(tf(res2.minimizer)) = (1.0f0, 1.0f0)
obj(res2.minimizer) = 244.83364672700807
constr(res2.minimizer) = 0.49999999999999933
Penalty 3.0, iter 4 (μ=625.0):
extrema(tf(res2.minimizer)) = (1.0f0, 1.0f0)
obj(res2.minimizer) = 244.83364672700807
constr(res2.minimizer) = 0.49999999999999933
Penalty 3.0, iter 5 (μ=3125.0):
extrema(tf(res2.minimizer)) = (1.0f0, 1.0f0)
obj(res2.minimizer) = 244.83364672700807
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: 3349.0347988352137
Constraint gradient norm: 0.36532022031000777
All gradients finite: true

Visualization

using CairoMakie
topology = filter(tf(res2.minimizer))
fig = visualize(problem; topology=topology)
Precompiling packages...
   6947.4 msQuartoNotebookWorkerMakieExt (serial)
  1 dependency successfully precompiled in 7 seconds
Precompiling packages...
   4634.7 msQuartoNotebookWorkerCairoMakieExt (serial)
  1 dependency successfully precompiled in 5 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
Figure 1: Neural network-parametrized design (4-layer MLP, 20 neurons/layer)

The result shows a smooth design parametrized by the neural network — the implicit representation naturally filters high-frequency variations.