Hi,
I’m reviving the topic because I was solving the same problem (how to share complex calculation between NLopt’s objective and constrainct function), but with an additional twist: using ForwardDiff to compute gradient automatically.
I’ve placed my detail example in a Gist. Here is the summary. Please let me know what you think of this approach.
Also, while writing this post, I came across the open discussion NLopt.jl #128 Easier auto-differentiation support?. Since it seems this was not finalized, maybe my example can be used by other people who wish to use NLopt+ForwardDiff for their constrained blackbox optimization problem.
(Final word: can a forum moderator fix the typo in the thread title?)
Example of NLopt+ForwardDiff to only call the “simulator” once
(simulator: the expensive function which jointly computes the objective and the constraints)
Objective and constraints of this example come from a toy problem “Rosen-Suzuki” I found by chance in SAS IML documentation (and I discovered this Matlab-like environment at the same time) which itself cites (Hock & Schittkowski 1981 “Test Examples for Nonlinear Programming Codes”). Optimum is -44 at [0,1,2,-1].
using NLopt, ForwardDiff
f(x) = x[1]^2 + x[2]^2 + 2x[3]^2 + x[4]^2 - 5x[1] - 5x[2] - 21x[3] + 7x[4]
g1(x) = -8 + x[1]^2 + x[2]^2 + x[3]^2 + x[4]^2 + x[1] - x[2] + x[3] - x[4]
g2(x) = -10 + x[1]^2 + 2x[2]^2 + x[3]^2 + 2x[4]^2 - x[1] - x[4]
g3(x) = -5 + 2x[1]^2 + x[2]^2 + x[3]^2 + 2x[1] - x[2] - x[4]
Now let’s pretend these are all computed in one master function simulate_vec(x)
:
simcount = 0 # Global counter of simulator calls:
function simulate_vec(x)
global simcount += 1
return [f(x), g1(x), g2(x), 3(x)]
end
Now the generator function for the objective and constraint functions which share a common cache:
function cached_objcons_single()
x_prev = zeros(4)*NaN
val_prev = zeros(4)*NaN
Jac_prev = zeros(4,4)*NaN
result = DiffResults.JacobianResult(zeros(4), zeros(4)) # (output, input)
function runsim(x)
x_prev[:] = x
result = ForwardDiff.jacobian!(result, simulate_vec, x);
val_prev[:] = DiffResults.value(result)
Jac_prev[:] = DiffResults.jacobian(result)
end
function myf(x::Vector, grad::Vector)
if x != x_prev
# update cache
runsim(x)
end
if length(grad) > 0
grad[:] = Jac_prev[1,:]
end
return val_prev[1]
end
function mygvec(result::Vector, x::Vector, grad::Matrix)
if x != x_prev
# update cache
runsim(x)
end
if length(grad) > 0
# Watch out the transpose due to convention mismatch between NLopt and ForwardDiff.
# Without it, we get unfortunately no error,
# but the convergence is, of course, poor
grad[:] = transpose(Jac_prev[2:4,:])
end
result[:] = val_prev[2:4]
end
return myf, mygvec
end
Then create the cached objective and constraints functions:
myf_scache, mygvec_scache = cached_objcons_single();
And finally build the NLopt Opt structure and solve:
# Solver choice: SLSQP, MMA
opt = Opt(:LD_SLSQP, 4)
#opt = Opt(:LD_MMA, 4)
opt.xtol_rel = 1e-6
# Set objective and constraints
opt.min_objective = myf_scache
inequality_constraint!(opt, mygvec_scache, [0., 0., 0.])
# Starting point
x0 = [2, 1., 1., 1.] # âš SLSQP can get stuck when starting at [1,1,1,1]
# Solve and display
simcount = 0 # reset simulation call counter
(minf,minx,ret) = optimize(opt, x0)
numevals = opt.numevals # the number of function evaluations
println("f* = $minf after $numevals iterations (returned $ret)")
println("xopt = $minx")
println("simulator calls: $simcount ($(simcount/numevals) per iteration)")
As a result, it’s interesting to note that on this example, there are 0.8 simulator calls per iteration for SLSQP (3.1 without caching) or 0.87 simulator calls per iteration for MMA (4.0 without caching). This is slightly below the results I was expecting (1 call per iteration) and I suppose this relates to the way iterations are defined. Is it possible that some algorithms call the objective or the constraints several times at same point?