Skip to content

Commit 03c5cee

Browse files
Add ConvexOptimization subpackage (disciplined convex programming backend)
New lib/ConvexOptimization: solves a SciMLBase.ConvexOptimizationProblem by certifying convexity with SymbolicAnalysis.jl, lowering the affine objective and each ConeConstraint to MathOptInterface via Symbolics.linear_expansion, and solving with a conic solver (default Clarabel). Returns a ConvexOptimizationSolution carrying primal AND dual multipliers — the optimality certificate OptimizationSolution lacks. Primary purpose: verify the new SciMLBase ConvexOptimizationProblem / ConvexOptimizationSolution interface (SciML/SciMLBase.jl#1440) end to end. - Routing: ConvexOptimizationProblem is a sibling of OptimizationProblem, so the backend defines SciMLBase.init(::ConvexOptimizationProblem, alg) and SciMLBase.solve!(::ConvexOptimizationCache); solve = solve! ∘ init via CommonSolve. - Constraints: Vector{ConeConstraint}, each pairing a callable affine map g(u,p)->Vector with an MOI cone, giving 1:1 constraint→dual alignment and construction-time dual signs (duals already in user variables). - Scope: LP + second-order-cone constraints; non-convex objectives are rejected by certification, not mis-solved. Blocked on SciML/SciMLBase.jl#1440 (the problem/solution types are unreleased); the [sources] git-rev is a temporary bridge until it lands. Verified locally: Pkg.test() Core 15/15 pass (LP primal+dual machine-exact vs analytic/Convex.jl, SOC solve, non-convex rejection, cache glue). Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 01a6ebf commit 03c5cee

7 files changed

Lines changed: 393 additions & 0 deletions

File tree

lib/ConvexOptimization/LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2023 Vaibhav Dixit <vaibhavyashdixit@gmail.com> and contributors
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
name = "ConvexOptimization"
2+
uuid = "e7fa4f8d-d505-4694-8553-7bab99efa7d1"
3+
authors = ["Chris Rackauckas <accounts@chrisrackauckas.com> and contributors"]
4+
version = "0.1.0"
5+
6+
[deps]
7+
Clarabel = "61c947e1-3e6d-4ee4-985a-eec8c727bd6e"
8+
LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e"
9+
MathOptInterface = "b8f27783-ece8-5eb3-8dc8-9495eed66fee"
10+
Reexport = "189a3867-3050-52da-a836-e630ba90ab69"
11+
SciMLBase = "0bca4576-84f4-4d90-8ffe-ffa030f20462"
12+
SparseArrays = "2f01184e-e22b-5df5-ae63-d93ebab69eaf"
13+
SymbolicAnalysis = "4297ee4d-0239-47d8-ba5d-195ecdf594fe"
14+
Symbolics = "0c5d862f-8b57-4792-8d23-62f2024744c7"
15+
16+
# Temporary: ConvexOptimizationProblem/Solution are unreleased (SciML/SciMLBase.jl#1440).
17+
# Drop this [sources] once a SciMLBase with those types is registered.
18+
[sources]
19+
SciMLBase = {url = "https://github.com/ChrisRackauckas-Claude/SciMLBase.jl", rev = "convex-optimization-problem"}
20+
21+
[compat]
22+
Clarabel = "0.10, 0.11"
23+
LinearAlgebra = "1.10"
24+
MathOptInterface = "1.45"
25+
Reexport = "1.2"
26+
SciMLBase = "3"
27+
SparseArrays = "1.10"
28+
SymbolicAnalysis = "0.3"
29+
Symbolics = "7"
30+
julia = "1.10"
31+
32+
[extras]
33+
SafeTestsets = "1bc83da4-3b8d-516f-aca4-4fe02f6d838f"
34+
Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
35+
36+
[targets]
37+
test = ["Test", "SafeTestsets"]

lib/ConvexOptimization/README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# ConvexOptimization.jl
2+
3+
Disciplined-convex-programming backend for the SciML optimization stack.
4+
5+
`ConvexOptimization` solves a `SciMLBase.ConvexOptimizationProblem` by certifying
6+
its convexity with [SymbolicAnalysis.jl](https://github.com/SciML/SymbolicAnalysis.jl),
7+
lowering each atom to a [MathOptInterface](https://github.com/jump-dev/MathOptInterface.jl)
8+
cone, and calling a conic solver (default [Clarabel.jl](https://github.com/oxfordcontrol/Clarabel.jl)).
9+
Unlike a general `OptimizationProblem` solved to a local optimum, a convex problem
10+
is solved to a **global optimum**, and the returned `ConvexOptimizationSolution`
11+
carries **dual multipliers** — the optimality certificate.
12+
13+
> **Status: experimental.** The current release targets the initial
14+
> `ConvexOptimizationProblem`/`ConvexOptimizationSolution` interface
15+
> ([SciML/SciMLBase.jl#1440](https://github.com/SciML/SciMLBase.jl/pull/1440)) and
16+
> supports linear and second-order-cone problems, as the first vertical slice of
17+
> the larger effort to make SymbolicAnalysis.jl + Optimization.jl a Convex.jl /
18+
> cvxpy replacement (roadmap: [SciML/SymbolicAnalysis.jl#121](https://github.com/SciML/SymbolicAnalysis.jl/issues/121)).
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
module ConvexOptimization
2+
3+
using Reexport
4+
@reexport using SciMLBase
5+
using SciMLBase: ConvexOptimizationProblem, ConvexOptimizationSolution,
6+
OptimizationFunction, AbstractOptimizationCache, AbstractOptimizationAlgorithm,
7+
NullParameters, ReturnCode
8+
import MathOptInterface as MOI
9+
import Symbolics
10+
using Symbolics: variable, unwrap, linear_expansion
11+
import SymbolicAnalysis
12+
using SymbolicAnalysis: analyze
13+
using LinearAlgebra
14+
15+
"""
16+
ConeConstraint(g, set)
17+
18+
One convex cone constraint of a [`ConvexOptimizationProblem`](@ref). `g(u, p)`
19+
returns the affine map whose image must lie in the MathOptInterface vector cone
20+
`set` (`MOI.Zeros`, `MOI.Nonnegatives`, `MOI.Nonpositives`, `MOI.SecondOrderCone`,
21+
…). The output length of `g` must equal `MOI.dimension(set)`.
22+
23+
The backend traces `g` on its own symbolic variables, so each `ConeConstraint`
24+
maps to exactly one MOI constraint and therefore one entry of
25+
`ConvexOptimizationSolution.dual`, in the order the constraints are given.
26+
Because the cone is named explicitly, the returned dual is already expressed in
27+
the user's variables (no sign remap): `>=` → `MOI.Nonnegatives`, `<=` →
28+
`MOI.Nonpositives`, `==` → `MOI.Zeros`.
29+
"""
30+
struct ConeConstraint{G, S <: MOI.AbstractVectorSet}
31+
g::G
32+
set::S
33+
end
34+
35+
abstract type AbstractConvexOptAlgorithm <: AbstractOptimizationAlgorithm end
36+
37+
"""
38+
ConvexMOI(optimizer_constructor = Clarabel.Optimizer)
39+
40+
Conic backend: certify convexity with SymbolicAnalysis, lower the (affine)
41+
objective and each `ConeConstraint` to a MathOptInterface cone, and solve with
42+
`optimizer_constructor`.
43+
"""
44+
struct ConvexMOI{O} <: AbstractConvexOptAlgorithm
45+
optimizer_constructor::O
46+
end
47+
48+
SciMLBase.allowsbounds(::AbstractConvexOptAlgorithm) = true
49+
SciMLBase.allowsconstraints(::AbstractConvexOptAlgorithm) = true
50+
51+
# Must be <: AbstractOptimizationCache (build_convex_solution requires it) and
52+
# carry real `f`/`p` fields for the solution's SymbolicIndexingInterface glue.
53+
# No `reinit_cache` field (that would reroute getproperty(:u0/:p)).
54+
struct ConvexOptimizationCache{F, U, P, A, AR, MOD, XV, CR} <: AbstractOptimizationCache
55+
f::F
56+
u0::U
57+
p::P
58+
alg::A
59+
analysis::AR
60+
model::MOD # lowered MOI model
61+
xvars::XV # Vector{MOI.VariableIndex}: user u -> MOI variables
62+
conrefs::CR # Vector{MOI.ConstraintIndex}, 1:1 with prob.constraints
63+
end
64+
65+
# `solve(prob, alg)` routes through CommonSolve: solve = solve! ∘ init. Neither
66+
# `init` nor `solve!` is inherited here (no OptimizationBase in the dep tree), so
67+
# both thin methods are defined explicitly.
68+
function SciMLBase.init(
69+
prob::ConvexOptimizationProblem,
70+
alg::AbstractConvexOptAlgorithm, args...; kwargs...
71+
)
72+
return SciMLBase.__init(prob, alg, args...; prob.kwargs..., kwargs...)
73+
end
74+
SciMLBase.solve!(cache::ConvexOptimizationCache) = SciMLBase.__solve(cache)
75+
76+
function SciMLBase.__init(
77+
prob::ConvexOptimizationProblem,
78+
alg::AbstractConvexOptAlgorithm, args...; kwargs...
79+
)
80+
analysis = certify_convex(prob)
81+
model, xvars, conrefs = lower_to_moi(prob, alg)
82+
return ConvexOptimizationCache(
83+
prob.f, prob.u0, prob.p, alg, analysis, model, xvars, conrefs
84+
)
85+
end
86+
87+
function SciMLBase.__solve(cache::ConvexOptimizationCache)
88+
model = cache.model
89+
MOI.optimize!(model)
90+
ret = _moi_status_to_retcode(MOI.get(model, MOI.TerminationStatus()))
91+
if MOI.get(model, MOI.ResultCount()) >= 1
92+
u = MOI.get(model, MOI.VariablePrimal(), cache.xvars)
93+
objective = MOI.get(model, MOI.ObjectiveValue())
94+
else
95+
u = fill(NaN, length(cache.xvars))
96+
objective = NaN
97+
end
98+
dual = if MOI.get(model, MOI.DualStatus()) == MOI.NO_SOLUTION
99+
nothing
100+
else
101+
[MOI.get(model, MOI.ConstraintDual(), c) for c in cache.conrefs]
102+
end
103+
return SciMLBase.build_convex_solution(
104+
cache, cache.alg, u, objective;
105+
dual = dual, retcode = ret, original = model,
106+
stats = SciMLBase.OptimizationStats()
107+
)
108+
end
109+
110+
function certify_convex(prob::ConvexOptimizationProblem)
111+
vars, params = _symbolic_vars(prob)
112+
obj = unwrap(_scalar(prob.f.f(vars, params)))
113+
obj_res = analyze(obj)
114+
ok = prob.sense === SciMLBase.MaxSense ?
115+
obj_res.curvature in (SymbolicAnalysis.Concave, SymbolicAnalysis.Affine) :
116+
obj_res.curvature in (SymbolicAnalysis.Convex, SymbolicAnalysis.Affine)
117+
ok || error(
118+
"Objective is not certified convex for $(prob.sense): curvature = " *
119+
"$(obj_res.curvature). Route to a general OptimizationProblem/NLP solver."
120+
)
121+
cons_res = _certify_constraints(prob, vars, params)
122+
return (; objective = obj_res, constraints = cons_res)
123+
end
124+
125+
# MVP: constraints are affine-in-cone, so every output component must be Affine.
126+
function _certify_constraints(prob, vars, params)
127+
prob.constraints === nothing && return nothing
128+
res = []
129+
for con in prob.constraints
130+
cres = analyze.(unwrap.(_asvec(con.g(vars, params))))
131+
all(r -> r.curvature == SymbolicAnalysis.Affine, cres) || error(
132+
"This backend supports affine-in-cone constraints only; got " *
133+
"curvatures $(getproperty.(cres, :curvature)) for cone $(con.set)."
134+
)
135+
push!(res, cres)
136+
end
137+
return res
138+
end
139+
140+
function lower_to_moi(prob::ConvexOptimizationProblem, alg::ConvexMOI)
141+
model = MOI.instantiate(alg.optimizer_constructor; with_bridge_type = Float64)
142+
MOI.set(model, MOI.Silent(), true)
143+
n = length(prob.u0)
144+
x = MOI.add_variables(model, n)
145+
vars, params = _symbolic_vars(prob)
146+
147+
if prob.lb !== nothing
148+
for i in 1:n
149+
prob.lb[i] > -Inf &&
150+
MOI.add_constraint(model, x[i], MOI.GreaterThan(Float64(prob.lb[i])))
151+
prob.ub[i] < Inf &&
152+
MOI.add_constraint(model, x[i], MOI.LessThan(Float64(prob.ub[i])))
153+
end
154+
end
155+
156+
conrefs = MOI.ConstraintIndex[]
157+
if prob.constraints !== nothing
158+
for con in prob.constraints
159+
gvals = _asvec(con.g(vars, params))
160+
A, b, islin = linear_expansion(gvals, vars) # gvals == A*vars + b
161+
islin || error("Constraint $(con.set) is not affine in the variables.")
162+
f = _affine_to_vaf(_tofloat.(A), _tofloat.(b), x)
163+
push!(conrefs, MOI.add_constraint(model, f, con.set))
164+
end
165+
end
166+
167+
objexpr = _asvec(_scalar(prob.f.f(vars, params)))
168+
Ao, bo, olin = linear_expansion(objexpr, vars)
169+
olin || error("This backend requires an affine objective.")
170+
c = vec(_tofloat.(Ao))
171+
d = _tofloat(only(bo))
172+
saterms = [MOI.ScalarAffineTerm(c[j], x[j]) for j in 1:n if !iszero(c[j])]
173+
MOI.set(
174+
model, MOI.ObjectiveFunction{MOI.ScalarAffineFunction{Float64}}(),
175+
MOI.ScalarAffineFunction(saterms, d)
176+
)
177+
MOI.set(
178+
model, MOI.ObjectiveSense(),
179+
prob.sense === SciMLBase.MaxSense ? MOI.MAX_SENSE : MOI.MIN_SENSE
180+
)
181+
return model, x, conrefs
182+
end
183+
184+
function _symbolic_vars(prob)
185+
vars = [variable(:x, i) for i in 1:length(prob.u0)]
186+
params = prob.p isa NullParameters ? Float64[] :
187+
[variable(, i) for i in eachindex(prob.p)]
188+
return vars, params
189+
end
190+
191+
_asvec(v::AbstractVector) = v
192+
_asvec(v) = [v]
193+
_scalar(v::AbstractVector) = only(v)
194+
_scalar(v) = v
195+
196+
_tofloat(x) = Float64(Symbolics.value(x))
197+
198+
function _affine_to_vaf(A::AbstractMatrix, b::AbstractVector, x)
199+
terms = MOI.VectorAffineTerm{Float64}[]
200+
m, n = size(A)
201+
for i in 1:m, j in 1:n
202+
iszero(A[i, j]) && continue
203+
push!(terms, MOI.VectorAffineTerm(i, MOI.ScalarAffineTerm(A[i, j], x[j])))
204+
end
205+
return MOI.VectorAffineFunction(terms, collect(float.(b)))
206+
end
207+
208+
function _moi_status_to_retcode(s::MOI.TerminationStatusCode)
209+
s in (
210+
MOI.OPTIMAL, MOI.LOCALLY_SOLVED, MOI.ALMOST_OPTIMAL,
211+
MOI.ALMOST_LOCALLY_SOLVED,
212+
) && return ReturnCode.Success
213+
s in (
214+
MOI.INFEASIBLE, MOI.DUAL_INFEASIBLE, MOI.LOCALLY_INFEASIBLE,
215+
MOI.INFEASIBLE_OR_UNBOUNDED,
216+
) && return ReturnCode.Infeasible
217+
s == MOI.TIME_LIMIT && return ReturnCode.MaxTime
218+
s in (MOI.ITERATION_LIMIT, MOI.NODE_LIMIT, MOI.SLOW_PROGRESS) &&
219+
return ReturnCode.MaxIters
220+
s in (MOI.NUMERICAL_ERROR, MOI.INVALID_MODEL, MOI.OTHER_ERROR) &&
221+
return ReturnCode.Failure
222+
return ReturnCode.Default
223+
end
224+
225+
export ConvexMOI, ConeConstraint
226+
227+
end # module
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
using ConvexOptimization
2+
using SciMLBase
3+
using SciMLBase: ConvexOptimizationProblem, ConvexOptimizationSolution
4+
import MathOptInterface as MOI
5+
import Clarabel
6+
using Test
7+
8+
# minimize x1 + 2 x2 s.t. x1 + x2 == 1, x >= 0
9+
# analytic optimum: x* = (1, 0), obj = 1
10+
# LP duals: equality multiplier y = 1; nonneg-cone dual s = c - Aᵀy = (0, 1) >= 0
11+
c = [1.0, 2.0]
12+
13+
@testset "LP solve: primal, objective, retcode" begin
14+
optf = OptimizationFunction((u, p) -> c[1] * u[1] + c[2] * u[2])
15+
cons = [
16+
ConeConstraint((u, p) -> [u[1] + u[2] - 1.0], MOI.Zeros(1)), # sum(x) == 1
17+
ConeConstraint((u, p) -> [u[1], u[2]], MOI.Nonnegatives(2)), # x >= 0
18+
]
19+
prob = ConvexOptimizationProblem(optf, [0.5, 0.5]; constraints = cons)
20+
21+
sol = solve(prob, ConvexMOI(Clarabel.Optimizer))
22+
23+
@test sol isa ConvexOptimizationSolution
24+
@test SciMLBase.successful_retcode(sol.retcode)
25+
@test isapprox(sol.u, [1.0, 0.0]; atol = 1.0e-6)
26+
@test isapprox(sol.objective, 1.0; atol = 1.0e-6)
27+
28+
@testset "dual: one entry per constraint, in user variables" begin
29+
@test sol.dual !== nothing
30+
@test length(sol.dual) == 2
31+
@test isapprox(only(sol.dual[1]), 1.0; atol = 1.0e-6) # equality multiplier
32+
@test isapprox(sol.dual[2], [0.0, 1.0]; atol = 1.0e-6) # nonneg-cone dual
33+
end
34+
35+
@testset "cache carries the SciMLBase glue fields" begin
36+
@test sol.cache isa SciMLBase.AbstractOptimizationCache
37+
@test sol.cache.p isa SciMLBase.NullParameters
38+
@test sol.cache.u0 == [0.5, 0.5]
39+
end
40+
end
41+
42+
@testset "non-convex objective is rejected, not mis-solved" begin
43+
# x1*x2 is neither convex nor concave -> certification must error.
44+
optf = OptimizationFunction((u, p) -> u[1] * u[2])
45+
prob = ConvexOptimizationProblem(optf, [0.5, 0.5])
46+
@test_throws Exception solve(prob, ConvexMOI(Clarabel.Optimizer))
47+
end
48+
49+
@testset "SOC constraint lowers and solves" begin
50+
# minimize t s.t. || (x1, x2) ||_2 <= t, x == (3, 4) -> t* = 5
51+
optf = OptimizationFunction((u, p) -> u[3]) # u = (x1, x2, t)
52+
cons = [
53+
ConeConstraint((u, p) -> [u[1] - 3.0, u[2] - 4.0], MOI.Zeros(2)), # x fixed
54+
ConeConstraint((u, p) -> [u[3], u[1], u[2]], MOI.SecondOrderCone(3)), # (t, x) in SOC
55+
]
56+
prob = ConvexOptimizationProblem(optf, [0.0, 0.0, 0.0]; constraints = cons)
57+
sol = solve(prob, ConvexMOI(Clarabel.Optimizer))
58+
@test SciMLBase.successful_retcode(sol.retcode)
59+
@test isapprox(sol.objective, 5.0; atol = 1.0e-5) # ||(3,4)|| = 5
60+
@test isapprox(sol.u[3], 5.0; atol = 1.0e-5)
61+
end
62+
63+
# Cross-check the LP primal AND dual against Convex.jl (same solver). The exit
64+
# gate for the vertical slice: matching Convex.jl on both, to solver tolerance.
65+
@testset "matches Convex.jl (primal + dual)" begin
66+
convex_available = try
67+
@eval import Convex
68+
true
69+
catch
70+
@info "Convex.jl not available in this environment; skipping cross-check " *
71+
"(the analytic assertions above already pin the same values)."
72+
false
73+
end
74+
if convex_available
75+
xc = Convex.Variable(2)
76+
pc = Convex.minimize(c[1] * xc[1] + c[2] * xc[2], [sum(xc) == 1, xc >= 0])
77+
Convex.solve!(pc, Clarabel.Optimizer; silent = true)
78+
@test isapprox(Convex.evaluate(xc), [1.0, 0.0]; atol = 1.0e-6)
79+
@test isapprox(pc.optval, 1.0; atol = 1.0e-6)
80+
end
81+
end
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
using SafeTestsets
2+
3+
const TEST_GROUP = get(ENV, "OPTIMIZATION_TEST_GROUP", "All")
4+
5+
if TEST_GROUP == "Core" || TEST_GROUP == "All"
6+
@time @safetestset "Core" include("core_tests.jl")
7+
end

0 commit comments

Comments
 (0)