Skip to content

Commit f89b52e

Browse files
Expose jac_sparsity, keep_feasible, hess_update kwargs and add basic comments
Signed-off-by: AdityaPandeyCN <[email protected]>
1 parent 65318f3 commit f89b52e

File tree

2 files changed

+166
-35
lines changed

2 files changed

+166
-35
lines changed

lib/OptimizationSciPy/src/OptimizationSciPy.jl

Lines changed: 127 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,25 @@
1+
#This file lets you drive SciPy optimizers through SciML's Optimization.jl API.
12
module OptimizationSciPy
23

34
using Reexport
45
@reexport using Optimization
56
using Optimization.SciMLBase
67
using PythonCall
78

9+
# We keep a handle to the actual Python SciPy module here.
810
const scipy = PythonCall.pynew()
911

1012
function __init__()
1113
PythonCall.pycopy!(scipy, pyimport("scipy"))
1214
end
1315

16+
# Make sure whatever we got back is a plain Julia Vector{T}.
1417
function ensure_julia_array(x, ::Type{T}=Float64) where T
1518
x isa Vector{T} && return x
1619
return convert(Vector{T}, x isa Py ? pyconvert(Vector, x) : x)
1720
end
1821

22+
# Pull a human-readable message out of the SciPy result object.
1923
function safe_get_message(result)
2024
pyhasattr(result, "message") || return "Optimization completed"
2125
msg = result.message
@@ -28,6 +32,7 @@ function safe_get_message(result)
2832
return string(pytypeof(msg))
2933
end
3034

35+
# Squash any kind of numeric object down to a Julia Float64.
3136
function safe_to_float(x)
3237
x isa Float64 && return x
3338
x isa Number && return Float64(x)
@@ -44,6 +49,7 @@ function safe_to_float(x)
4449
error("Cannot convert object to Float64: $(typeof(x))")
4550
end
4651

52+
# Gather timing / iteration counts and wrap them in OptimizationStats.
4753
function extract_stats(result, time_elapsed)
4854
stats_dict = Dict{Symbol, Any}(
4955
:iterations => 0,
@@ -69,6 +75,7 @@ function extract_stats(result, time_elapsed)
6975
return Optimization.OptimizationStats(; stats_dict...)
7076
end
7177

78+
# Map SciPy status integers onto SciML ReturnCode symbols.
7279
function scipy_status_to_retcode(status::Int, success::Bool)
7380
if success
7481
return SciMLBase.ReturnCode.Success
@@ -90,6 +97,7 @@ function scipy_status_to_retcode(status::Int, success::Bool)
9097
end
9198
end
9299

100+
# Tiny structs that tag which SciPy algorithm the user picked.
93101
abstract type ScipyOptimizer end
94102

95103
struct ScipyMinimize <: ScipyOptimizer
@@ -400,19 +408,30 @@ function SciMLBase.__solve(cache::OptimizationCache{F,RC,LB,UB,LC,UC,S,O,D,P,C})
400408
end
401409
cons_jac = _cons_jac
402410
end
411+
# user-controlled NonlinearConstraint extras
412+
keep_feasible_flag = get(cache.solver_args, :keep_feasible, false)
413+
jac_sparsity = get(cache.solver_args, :jac_sparsity, nothing)
403414
nlc = scipy.optimize.NonlinearConstraint(
404-
_cons_func,
405-
lcons,
406-
ucons,
407-
jac = cons_jac,
408-
keep_feasible = false,
409-
finite_diff_rel_step = get(cache.solver_args, :cons_tol, 1e-8)
415+
_cons_func,
416+
lcons,
417+
ucons;
418+
jac = cons_jac,
419+
keep_feasible = keep_feasible_flag,
420+
finite_diff_rel_step = get(cache.solver_args, :cons_tol, 1e-8),
421+
finite_diff_jac_sparsity = jac_sparsity,
410422
)
411423
constraints = pylist([nlc])
412424
end
413425
elseif !isnothing(cache.f.cons)
414426
throw(ArgumentError("Method $(cache.opt.method) does not support constraints. Use SLSQP, trust-constr, COBYLA, or COBYQA instead."))
415427
end
428+
# allow users to specify a Hessian update strategy (e.g. "BFGS", "SR1")
429+
if cache.opt.method == "trust-constr"
430+
hess_update = get(cache.solver_args, :hess_update, nothing)
431+
if hess_update !== nothing
432+
hess = hess_update
433+
end
434+
end
416435
t0 = time()
417436
result = nothing
418437
try
@@ -444,8 +463,12 @@ function SciMLBase.__solve(cache::OptimizationCache{F,RC,LB,UB,LC,UC,S,O,D,P,C})
444463
throw(ErrorException("Optimization failed to return a result"))
445464
end
446465
t1 = time()
447-
minimizer = pyconvert(Vector{eltype(cache.u0)}, result.x)
448-
minimum = safe_to_float(result.fun)
466+
if pyis(result.x, pybuiltins.None)
467+
minimizer = fill(NaN, length(cache.u0))
468+
else
469+
minimizer = pyconvert(Vector{eltype(cache.u0)}, result.x)
470+
end
471+
minimum = pyis(result.fun, pybuiltins.None) ? NaN : safe_to_float(result.fun)
449472
py_success = pyconvert(Bool, pybool(result.success))
450473
py_message = safe_get_message(result)
451474
status = 0
@@ -519,8 +542,12 @@ function SciMLBase.__solve(cache::OptimizationCache{F,RC,LB,UB,LC,UC,S,O,D,P,C})
519542
throw(ErrorException("Optimization failed to return a result"))
520543
end
521544
t1 = time()
522-
minimizer = [safe_to_float(result.x)]
523-
minimum = safe_to_float(result.fun)
545+
if pyis(result.x, pybuiltins.None)
546+
minimizer = [NaN]
547+
else
548+
minimizer = [safe_to_float(result.x)]
549+
end
550+
minimum = pyis(result.fun, pybuiltins.None) ? NaN : safe_to_float(result.fun)
524551
py_success = pyconvert(Bool, pybool(result.success))
525552
if cache.sense === Optimization.MaxSense
526553
minimum = -minimum
@@ -591,7 +618,11 @@ function SciMLBase.__solve(cache::OptimizationCache{F,RC,LB,UB,LC,UC,S,O,D,P,C})
591618
throw(ErrorException("Optimization failed to return a result"))
592619
end
593620
t1 = time()
594-
minimizer = pyconvert(Vector{eltype(cache.u0)}, result.x)
621+
if pyis(result.x, pybuiltins.None)
622+
minimizer = fill(NaN, length(cache.u0))
623+
else
624+
minimizer = pyconvert(Vector{eltype(cache.u0)}, result.x)
625+
end
595626
minimum = safe_to_float(result.cost)
596627
py_success = pyconvert(Bool, pybool(result.success))
597628
py_message = safe_get_message(result)
@@ -693,9 +724,16 @@ function SciMLBase.__solve(cache::OptimizationCache{F,RC,LB,UB,LC,UC,S,O,D,P,C})
693724
throw(ErrorException("Root finding failed to return a result"))
694725
end
695726
t1 = time()
696-
minimizer = [safe_to_float(result.root)]
697-
root_julia = safe_to_float(result.root)
698-
minimum = abs(_func(root_julia))
727+
if pyis(result.root, pybuiltins.None)
728+
minimizer = [NaN]
729+
root_julia = NaN
730+
minimum = NaN
731+
else
732+
val = safe_to_float(result.root)
733+
minimizer = [val]
734+
root_julia = val
735+
minimum = abs(_func(root_julia))
736+
end
699737
converged = pyhasattr(result, "converged") ? pyconvert(Bool, pybool(result.converged)) : abs(_func(root_julia)) < 1e-10
700738
retcode = converged ? SciMLBase.ReturnCode.Success : SciMLBase.ReturnCode.Failure
701739
stats_dict = Dict{Symbol, Any}(:time => t1 - t0)
@@ -764,7 +802,11 @@ function SciMLBase.__solve(cache::OptimizationCache{F,RC,LB,UB,LC,UC,S,O,D,P,C})
764802
throw(ErrorException("Root finding failed to return a result"))
765803
end
766804
t1 = time()
767-
minimizer = pyconvert(Vector{eltype(cache.u0)}, result.x)
805+
if pyis(result.x, pybuiltins.None)
806+
minimizer = fill(NaN, length(cache.u0))
807+
else
808+
minimizer = pyconvert(Vector{eltype(cache.u0)}, result.x)
809+
end
768810
fun_val = pyconvert(Vector{Float64}, result.fun)
769811
minimum = sum(abs2, fun_val)
770812
py_success = pyconvert(Bool, pybool(result.success))
@@ -812,12 +854,16 @@ function SciMLBase.__solve(cache::OptimizationCache{F,RC,LB,UB,LC,UC,S,O,D,P,C})
812854
end
813855
bounds = pylist(bounds_list)
814856
end
815-
A_ub = nothing
816-
b_ub = nothing
817-
A_eq = nothing
818-
b_eq = nothing
819-
if !isnothing(cache.f.cons) && !isnothing(cache.lcons)
820-
@warn "Linear programming constraints implementation is simplified. For proper usage, provide constraints in standard LP form."
857+
# Allow users to pass constraint matrices via solver kwargs
858+
A_ub = get(cache.solver_args, :A_ub, nothing)
859+
b_ub = get(cache.solver_args, :b_ub, nothing)
860+
A_eq = get(cache.solver_args, :A_eq, nothing)
861+
b_eq = get(cache.solver_args, :b_eq, nothing)
862+
if !(isnothing(A_ub) == isnothing(b_ub))
863+
throw(ArgumentError("Both A_ub and b_ub must be provided together"))
864+
end
865+
if !(isnothing(A_eq) == isnothing(b_eq))
866+
throw(ArgumentError("Both A_eq and b_eq must be provided together"))
821867
end
822868
maxiters = Optimization._check_and_convert_maxiters(cache.solver_args.maxiters)
823869
options = nothing
@@ -849,8 +895,12 @@ function SciMLBase.__solve(cache::OptimizationCache{F,RC,LB,UB,LC,UC,S,O,D,P,C})
849895
throw(ErrorException("Linear programming failed to return a result"))
850896
end
851897
t1 = time()
852-
minimizer = pyconvert(Vector{eltype(cache.u0)}, result.x)
853-
minimum = safe_to_float(result.fun)
898+
if pyis(result.x, pybuiltins.None)
899+
minimizer = fill(NaN, length(cache.u0))
900+
else
901+
minimizer = pyconvert(Vector{eltype(cache.u0)}, result.x)
902+
end
903+
minimum = pyis(result.fun, pybuiltins.None) ? NaN : safe_to_float(result.fun)
854904
py_success = pyconvert(Bool, pybool(result.success))
855905
py_message = safe_get_message(result)
856906
if cache.sense === Optimization.MaxSense
@@ -897,10 +947,17 @@ function SciMLBase.__solve(cache::OptimizationCache{F,RC,LB,UB,LC,UC,S,O,D,P,C})
897947
ub = ub_new
898948
end
899949
bounds = scipy.optimize.Bounds(lb, ub)
900-
integrality = nothing
950+
integrality = get(cache.solver_args, :integrality, nothing)
951+
A = get(cache.solver_args, :A, nothing)
952+
lb_con = get(cache.solver_args, :lb_con, nothing)
953+
ub_con = get(cache.solver_args, :ub_con, nothing)
901954
constraints = nothing
902-
if !isnothing(cache.f.cons) && !isnothing(cache.lcons)
903-
@warn "MILP constraints implementation is simplified. For proper usage, provide constraints in standard MILP form."
955+
if !(isnothing(A) && isnothing(lb_con) && isnothing(ub_con))
956+
if any(isnothing.((A, lb_con, ub_con)))
957+
throw(ArgumentError("A, lb_con, and ub_con must all be provided for linear constraints"))
958+
end
959+
keep_feasible_flag = get(cache.solver_args, :keep_feasible, false)
960+
constraints = scipy.optimize.LinearConstraint(A, lb_con, ub_con, keep_feasible = keep_feasible_flag)
904961
end
905962
t0 = time()
906963
result = nothing
@@ -924,8 +981,12 @@ function SciMLBase.__solve(cache::OptimizationCache{F,RC,LB,UB,LC,UC,S,O,D,P,C})
924981
throw(ErrorException("MILP failed to return a result"))
925982
end
926983
t1 = time()
927-
minimizer = pyconvert(Vector{eltype(cache.u0)}, result.x)
928-
minimum = safe_to_float(result.fun)
984+
if pyis(result.x, pybuiltins.None)
985+
minimizer = fill(NaN, length(cache.u0))
986+
else
987+
minimizer = pyconvert(Vector{eltype(cache.u0)}, result.x)
988+
end
989+
minimum = pyis(result.fun, pybuiltins.None) ? NaN : safe_to_float(result.fun)
929990
py_success = pyconvert(Bool, pybool(result.success))
930991
py_message = safe_get_message(result)
931992
if cache.sense === Optimization.MaxSense
@@ -985,7 +1046,11 @@ function SciMLBase.__solve(cache::OptimizationCache{F,RC,LB,UB,LC,UC,S,O,D,P,C})
9851046
throw(ErrorException("Optimization failed to return a result"))
9861047
end
9871048
t1 = time()
988-
minimizer = pyconvert(Vector{eltype(cache.u0)}, result.x)
1049+
if pyis(result.x, pybuiltins.None)
1050+
minimizer = fill(NaN, length(cache.u0))
1051+
else
1052+
minimizer = pyconvert(Vector{eltype(cache.u0)}, result.x)
1053+
end
9891054
minimum = safe_to_float(result.fun)
9901055
py_success = pyconvert(Bool, pybool(result.success))
9911056
py_message = safe_get_message(result)
@@ -1039,7 +1104,11 @@ function SciMLBase.__solve(cache::OptimizationCache{F,RC,LB,UB,LC,UC,S,O,D,P,C})
10391104
throw(ErrorException("Optimization failed to return a result"))
10401105
end
10411106
t1 = time()
1042-
minimizer = pyconvert(Vector{eltype(cache.u0)}, result.x)
1107+
if pyis(result.x, pybuiltins.None)
1108+
minimizer = fill(NaN, length(cache.u0))
1109+
else
1110+
minimizer = pyconvert(Vector{eltype(cache.u0)}, result.x)
1111+
end
10431112
minimum = safe_to_float(result.fun)
10441113
lowest_result = result.lowest_optimization_result
10451114
py_success = pyconvert(Bool, pybool(lowest_result.success))
@@ -1100,7 +1169,11 @@ function SciMLBase.__solve(cache::OptimizationCache{F,RC,LB,UB,LC,UC,S,O,D,P,C})
11001169
throw(ErrorException("Optimization failed to return a result"))
11011170
end
11021171
t1 = time()
1103-
minimizer = pyconvert(Vector{eltype(cache.u0)}, result.x)
1172+
if pyis(result.x, pybuiltins.None)
1173+
minimizer = fill(NaN, length(cache.u0))
1174+
else
1175+
minimizer = pyconvert(Vector{eltype(cache.u0)}, result.x)
1176+
end
11041177
minimum = safe_to_float(result.fun)
11051178
py_success = pyconvert(Bool, pybool(result.success))
11061179
py_message = safe_get_message(result)
@@ -1190,7 +1263,11 @@ function SciMLBase.__solve(cache::OptimizationCache{F,RC,LB,UB,LC,UC,S,O,D,P,C})
11901263
throw(ErrorException("Optimization failed to return a result"))
11911264
end
11921265
t1 = time()
1193-
minimizer = pyconvert(Vector{eltype(cache.u0)}, result.x)
1266+
if pyis(result.x, pybuiltins.None)
1267+
minimizer = fill(NaN, length(cache.u0))
1268+
else
1269+
minimizer = pyconvert(Vector{eltype(cache.u0)}, result.x)
1270+
end
11941271
minimum = safe_to_float(result.fun)
11951272
py_success = pyconvert(Bool, pybool(result.success))
11961273
py_message = safe_get_message(result)
@@ -1246,7 +1323,11 @@ function SciMLBase.__solve(cache::OptimizationCache{F,RC,LB,UB,LC,UC,S,O,D,P,C})
12461323
throw(ErrorException("Optimization failed to return a result"))
12471324
end
12481325
t1 = time()
1249-
minimizer = pyconvert(Vector{eltype(cache.u0)}, result.x)
1326+
if pyis(result.x, pybuiltins.None)
1327+
minimizer = fill(NaN, length(cache.u0))
1328+
else
1329+
minimizer = pyconvert(Vector{eltype(cache.u0)}, result.x)
1330+
end
12501331
minimum = safe_to_float(result.fun)
12511332
py_success = pyconvert(Bool, pybool(result.success))
12521333
py_message = safe_get_message(result)
@@ -1300,7 +1381,11 @@ function SciMLBase.__solve(cache::OptimizationCache{F,RC,LB,UB,LC,UC,S,O,D,P,C})
13001381
throw(ErrorException("Optimization failed to return a result"))
13011382
end
13021383
t1 = time()
1303-
minimizer = pyconvert(Vector{eltype(cache.u0)}, result[0])
1384+
if pyis(result[0], pybuiltins.None)
1385+
minimizer = fill(NaN, length(cache.u0))
1386+
else
1387+
minimizer = pyconvert(Vector{eltype(cache.u0)}, result[0])
1388+
end
13041389
minimum = safe_to_float(result[1])
13051390
if cache.sense === Optimization.MaxSense
13061391
minimum = -minimum
@@ -1347,6 +1432,7 @@ export ScipyMinimize, ScipyNelderMead, ScipyPowell, ScipyCG, ScipyBFGS, ScipyNew
13471432
ScipyDifferentialEvolution, ScipyBasinhopping, ScipyDualAnnealing,
13481433
ScipyShgo, ScipyDirect, ScipyBrute
13491434

1435+
# Wrap the user's Julia objective so it matches what SciPy expects.
13501436
function _create_loss(cache; vector_output::Bool = false)
13511437
maxtime = get(cache.solver_args, :maxtime, nothing)
13521438
start_time = !isnothing(maxtime) ? time() : 0.0
@@ -1391,7 +1477,13 @@ function _create_loss(cache; vector_output::Bool = false)
13911477
end
13921478
end
13931479

1394-
const _DEFAULT_EXCLUDE = (:maxiters, :maxtime, :abstol, :reltol, :callback, :progress, :cons_tol)
1480+
# These solver-args are handled specially elsewhere, so we skip them here.
1481+
const _DEFAULT_EXCLUDE = (
1482+
:maxiters, :maxtime, :abstol, :reltol, :callback, :progress, :cons_tol,
1483+
:jac_sparsity, :keep_feasible, :hess_update
1484+
)
1485+
1486+
# Moving the remaining kwargs into a Dict that we pass straight to SciPy.
13951487
function _merge_solver_kwargs!(dest::AbstractDict, solver_args; exclude = _DEFAULT_EXCLUDE)
13961488
if isa(solver_args, NamedTuple)
13971489
for (k, v) in pairs(solver_args)

lib/OptimizationSciPy/test/runtests.jl

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,4 +483,43 @@ end
483483
@test sol.retcode == ReturnCode.Success
484484
@test eltype(sol.u) == Float32
485485
end
486+
487+
@testset "ScipyLinprog matrix constraints" begin
488+
# Minimize c^T x subject to A_ub * x <= b_ub and simple bounds
489+
c_vec(x, p) = [1.0, 1.0] # constant cost vector
490+
x0_lp = [0.0, 0.0]
491+
optf_lp = OptimizationFunction(c_vec)
492+
prob_lp = OptimizationProblem(optf_lp, x0_lp)
493+
494+
A_ub = [1.0 1.0] # x1 + x2 <= 5
495+
b_ub = [5.0]
496+
sol = solve(prob_lp, ScipyLinprog("highs"),
497+
A_ub = A_ub, b_ub = b_ub,
498+
lb = [0.0, 0.0], ub = [10.0, 10.0])
499+
@test sol.retcode == ReturnCode.Success
500+
@test sol.u[1] + sol.u[2] 5.0 + 1e-8
501+
end
502+
503+
@testset "ScipyMilp matrix constraints" begin
504+
# Mixed-integer LP: first variable binary, second continuous
505+
c_vec_milp(x, p) = [-1.0, -2.0] # maximize -> minimize negative
506+
x0_milp = [0.0, 0.0]
507+
optf_milp = OptimizationFunction(c_vec_milp)
508+
prob_milp = OptimizationProblem(optf_milp, x0_milp)
509+
510+
A = [1.0 1.0] # x1 + x2 >= 1 -> lb = 1, ub = Inf
511+
lb_con = [1.0]
512+
ub_con = [Inf]
513+
integrality = [1, 0] # binary, continuous
514+
515+
sol = solve(prob_milp, ScipyMilp();
516+
A = A, lb_con = lb_con, ub_con = ub_con,
517+
integrality = integrality,
518+
lb = [0.0, 0.0], ub = [1.0, 10.0])
519+
@test sol.retcode in (ReturnCode.Success, ReturnCode.Failure)
520+
if sol.retcode == ReturnCode.Success
521+
@test sol.u[1] in (0.0, 1.0)
522+
@test isapprox(sol.u[1] + sol.u[2], 1.0; atol = 1e-6) || sol.u[1] + sol.u[2] > 1.0
523+
end
524+
end
486525
end

0 commit comments

Comments
 (0)