Multi-Objective Symbolic Regression with NSGA-II
Classical symbolic regression trades off fit quality against expression complexity via a bloat_penalty coefficient — a scalar compromise. NSGA-II computes the full Pareto front in one run, letting you inspect the accuracy-vs-parsimony tradeoff directly.
The data and operators
using Arborist
using DynamicExpressions: OperatorEnum
using Printf
xs = Float32.(range(-2, 2, length=30))
X = reshape(xs, 1, :)
y = xs .^ 2 .+ xs
operators = OperatorEnum(;
binary_operators = [+, -, *, /],
unary_operators = [sin, cos, abs],
)Wrap the evaluator for two objectives
ParsimonyEvaluator wraps any single-objective AbstractEvaluator into a two-objective (fitness, complexity) problem. complexity(g) counts nodes in the expression tree.
inner = TreeFitnessEvaluator(X, y, operators)
evaluator = ParsimonyEvaluator(inner)
println("Objectives: ", objective_names(evaluator))
# Output: ["fitness", "complexity"]Configure NSGA-II
problem = GPProblem(evaluator, TreeGenome{Float32}; seed=42)
algorithm = NSGAII(
pop_size = 200, # must be even and ≥ 4
generations = 100,
mutation_rate = 0.3,
crossover_rate = 0.3,
parallel = false,
)
result = solve(problem, algorithm; verbose=true)NSGA-II uses non-dominated sorting + crowding distance with (μ+λ) survivor selection. Selection pressure emerges from the sorting — no tournament / lexicase here — so NSGAII does not expose elitism or selection kwargs.
Read the Pareto front
# Deduplicate by (MSE rounded to 6 digits, complexity)
seen = Set{Tuple{Float64, Float64}}()
unique_indices = Int[]
for (i, fit) in enumerate(result.pareto_fitnesses)
key = (round(fit[1], digits=6), fit[2])
if key ∉ seen
push!(seen, key)
push!(unique_indices, i)
end
end
println(lpad("MSE", 12), " ", lpad("Nodes", 6), " ", "Expression")
println("-" ^ 60)
for i in unique_indices
fit = result.pareto_fitnesses[i]
expr = serialize(result.pareto_front[i])
mse_str = fit[1] < 0.001 ?
@sprintf("%.2e", fit[1]) :
@sprintf("%.6f", fit[1])
println(lpad(mse_str, 12), " ", lpad(Int(fit[2]), 6), " ", expr)
endA typical run produces a Pareto front with solutions ranging from (MSE large, complexity 1) at one extreme to (MSE tiny, complexity 15+) at the other. Somewhere around complexity 5 is the "right" answer (x^2 + x, or equivalently x*x + x).
Hypervolume and wall time
println("Wall time: ", round(result.wall_time, digits=2), " s")
println("Final hypervolume: ", round(result.hypervolume_history[end], digits=4))result.hypervolume_history is a per-generation scalar — useful for plotting (see Plotting) and for comparing runs across different algorithm configurations.
Next steps
- Supported genomes:
NSGAIIcurrently dispatches onExprGenomeandTreeGenome.GraphGenomeNSGA-II is supported via specialized benchmarks (test/benchmarks/xor_nsga2_neat.jl,two_spirals_nsga2_neat.jl,retina_nsga2_neat.jl) but not via the generalNSGAIIalgorithm yet. - Custom two-objective formulations (e.g. accuracy vs robustness) subtype
AbstractMultiObjectiveEvaluatorand implementevaluate_multi(e, g) -> Vector{Float64}plusobjective_names(e). - Plot the front:
plot(result)from the Plots.jl extension draws a 2D scatter of the Pareto front;plothypervolumetrajectory(result)shows the hypervolume history.