Skip to content

Commit 2542e45

Browse files
andrewrosembergjoaquimgodowblegat
authored
Objective Sensitivity 2.0 (#303)
* implement dual of parameter anywhere * fix api and more tests * format * update docs * update for appropriate API * update documemtation * format * rm legacy function * rm unecessary functions * read necessary functions * Update DiffOpt.jl * fix bug indexing * format * initial sketch * rm method * add JuMP API and test it * add diff_model test * Fix conic error (#284) * fix conic error * use jump psd problem * [docs] update to Documenter@1 (#286) * [docs] update to Documenter@1 * Update * update * format * Pass attributes through Objective.FunctionConversionBridge (#287) * Pass attributes through Objective.FunctionConversionBridge * Fix * add test * fix tol * fix test * add reverse test --------- Co-authored-by: joaquimg <[email protected]> * bump POI * cleanup --------- Co-authored-by: Oscar Dowson <[email protected]> Co-authored-by: Benoît Legat <[email protected]> Co-authored-by: joaquimg <[email protected]> * fix parameters support * Update jump-api pr (#294) * prepare more tests * sketch * temp examples * remove printing * remove printing * remove duplicatie def * move definition * format * format * simplify conic model * force nlp on nlp tests * fix param tests * cleanup parameter usage * remove code * cleanup usage of parameters * format * add temp dep * format * add temp dep * fix PSDSquare * temp fix for tests * format * Improve name handling, cleanup parameter error messages, remove slack * re-enable constraint names * integrate new POI * remove test * rm adhoc pkg add * fix docs * add POI test and docs * rm test_solve_conflict * format * add parameter in cone test * add back name constraint vect * Update Project.toml * implement dual of parameter anywhere * update for appropriate API * format * fix * fix docs * add error for non implemented backends * fix error handeling * format * fix tests * fix tests * format * improve coverage * format * bump version * update docs * udpate docs * fix error dual objective sensitivity mention * format * fix typo * fix typo * Update docs/src/usage.md Co-authored-by: Benoît Legat <[email protected]> * Apply suggestion from @andrewrosemberg * pin MLDatasets * Apply suggestion from @joaquimg Co-authored-by: Joaquim <[email protected]> * Apply suggestion from @joaquimg Co-authored-by: Joaquim <[email protected]> * Apply suggestion from @joaquimg Co-authored-by: Joaquim <[email protected]> * Apply suggestion from @andrewrosemberg * Apply suggestion from @andrewrosemberg * Apply suggestion from @andrewrosemberg * Apply suggestion from @andrewrosemberg * rename objective_sensitivity_p * fix tests * update tests * format * rm dual and addition overloads --------- Co-authored-by: joaquimg <[email protected]> Co-authored-by: Oscar Dowson <[email protected]> Co-authored-by: Benoît Legat <[email protected]>
1 parent 23c27b6 commit 2542e45

File tree

12 files changed

+422
-50
lines changed

12 files changed

+422
-50
lines changed

Project.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
name = "DiffOpt"
22
uuid = "930fe3bc-9c6b-11ea-2d94-6184641e85e7"
33
authors = ["Akshay Sharma", "Mathieu Besançon", "Joaquim Dias Garcia", "Benoît Legat", "Oscar Dowson", "Andrew Rosemberg"]
4-
version = "0.5.1"
4+
version = "0.5.2"
55

66
[deps]
77
BlockDiagonals = "0a1fb500-61f7-11e9-3c65-f5ef3456f9f0"

docs/src/usage.md

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,4 +117,70 @@ DiffOpt.reverse_differentiate!(model)
117117
@show MOI.get(model, DiffOpt.ReverseConstraintSet(), ParameterRef(p)) == MOI.Parameter(direction_x * 3 / pc_val)
118118
@show abs(MOI.get(model, DiffOpt.ReverseConstraintSet(), ParameterRef(pc)).value -
119119
-direction_x * 3 * p_val / pc_val^2) < 1e-5
120-
```
120+
```
121+
122+
## Calculating objective sensitivity with respect to parameters (currently only supported for Nonlinear Programs)
123+
124+
Consider a differentiable model with parameters `p` and `pc` as in the previous example:
125+
126+
```julia
127+
using JuMP, DiffOpt, HiGHS
128+
129+
model = Model(() -> DiffOpt.diff_optimizer(Ipopt.Optimizer))
130+
set_silent(model)
131+
132+
p_val = 4.0
133+
pc_val = 2.0
134+
@variable(model, x)
135+
@variable(model, p in Parameter(p_val))
136+
@variable(model, pc in Parameter(pc_val))
137+
@constraint(model, cons, pc * x >= 3 * p)
138+
@objective(model, Min, x^4)
139+
optimize!(model)
140+
141+
direction_p = 3.0
142+
MOI.set(model, DiffOpt.ForwardConstraintSet(), ParameterRef(p), Parameter(direction_p))
143+
DiffOpt.forward_differentiate!(model)
144+
```
145+
146+
Using Lagrangian duality we can easily calculate the objective sensitivity with respect to parameters that appear as constants of the constraints (e.g, `cons` in this case for parameter `p`) - i.e. The objective sensitivity w.r.t. a constant parameter change is given by the optimal dual multiplier, under strong duality.
147+
148+
On the other hand, if the parameter appears as a coefficient of the constraints, one can calculate the objective sensitivity with respect to the parameter using the sensitivities of the variables with respect to the parameter, \( \frac{\partial x}{\partial p} \), and the gradient of the objective with respect to the variables \( \frac{\partial f}{\partial x} \):
149+
150+
```math
151+
\frac{\partial f}{\partial p} = \frac{\partial f}{\partial x} \frac{\partial x}{\partial p}
152+
```
153+
- A consequence of the chain-rule.
154+
155+
156+
In order to calculate the objective perturbation with respect to the parameter perturbation vector, we can use the following code:
157+
158+
```julia
159+
# Always a good practice to clear previously set sensitivities
160+
DiffOpt.empty_input_sensitivities!(model)
161+
162+
MOI.set(model, DiffOpt.ForwardConstraintSet(), ParameterRef(p), Parameter(3.0))
163+
MOI.set(model, DiffOpt.ForwardConstraintSet(), ParameterRef(p_c), Parameter(3.0))
164+
DiffOpt.forward_differentiate!(model)
165+
166+
MOI.get(model, DiffOpt.ForwardObjectiveSensitivity())
167+
```
168+
169+
In reverse mode, we can calculate the parameter perturbation with respect to the objective perturbation:
170+
171+
```julia
172+
# Always a good practice to clear previously set sensitivities
173+
DiffOpt.empty_input_sensitivities!(model)
174+
175+
MOI.set(
176+
model,
177+
DiffOpt.ReverseObjectiveSensitivity(),
178+
0.1,
179+
)
180+
181+
DiffOpt.reverse_differentiate!(model)
182+
183+
MOI.get(model, DiffOpt.ReverseConstraintSet(), ParameterRef(p))
184+
```
185+
186+
It is important to note that the (reverse) parameter perturbation given an objective perturbation is somewhat equivalent to the perturbation with respect to solution (since one can be calculated from the other). Therefore, one cannot set both the objective sensitivity (`DiffOpt.ReverseObjectiveSensitivity`) and the solution sensitivity (e.g. `DiffOpt.ReverseVariablePrimal`) at the same time - the code will throw an error if you try to do so.

src/ConicProgram/ConicProgram.jl

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,4 +450,16 @@ function MOI.get(
450450
return MOI.get(model.model, attr, ci)
451451
end
452452

453+
function MOI.get(::Model, ::DiffOpt.ForwardObjectiveSensitivity)
454+
return error(
455+
"ForwardObjectiveSensitivity is not implemented for the Conic Optimization backend",
456+
)
457+
end
458+
459+
function MOI.set(::Model, ::DiffOpt.ReverseObjectiveSensitivity, val)
460+
return error(
461+
"ReverseObjectiveSensitivity is not implemented for the Conic Optimization backend",
462+
)
463+
end
464+
453465
end

src/NonLinearProgram/NonLinearProgram.jl

Lines changed: 59 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,11 @@ end
2727
Base.@kwdef struct ForwCache
2828
primal_Δs::Dict{MOI.VariableIndex,Float64} # Sensitivity for primal variables (indexed by VariableIndex)
2929
dual_Δs::Vector{Float64} # Sensitivity for constraints and bounds (indexed by ConstraintIndex)
30+
objective_sensitivity_p::Float64 # Objective Sensitivity wrt parameters
3031
end
3132

3233
Base.@kwdef struct ReverseCache
33-
Δp::Vector{Float64} # Sensitivity for parameters
34+
Δp::Dict{MOI.ConstraintIndex,Float64} # Sensitivity for parameters
3435
end
3536

3637
# Define the form of the NLP
@@ -525,15 +526,19 @@ function DiffOpt.forward_differentiate!(model::Model; tol = 1e-6)
525526
end
526527

527528
# Compute Jacobian
528-
Δs = _compute_sensitivity(model; tol = tol)
529+
Δs, df_dp = _compute_sensitivity(model; tol = tol)
529530

530531
# Extract primal and dual sensitivities
531532
primal_Δs = Δs[1:length(model.cache.primal_vars), :] * Δp # Exclude slacks
532533
dual_Δs = Δs[cache.index_duals, :] * Δp # Includes constraints and bounds
533534

535+
# obj sensitivity wrt parameters
536+
objective_sensitivity_p = df_dp * Δp
537+
534538
model.forw_grad_cache = ForwCache(;
535539
primal_Δs = Dict(model.cache.primal_vars .=> primal_Δs),
536540
dual_Δs = dual_Δs,
541+
objective_sensitivity_p = objective_sensitivity_p,
537542
)
538543
end
539544
return nothing
@@ -545,50 +550,53 @@ function DiffOpt.reverse_differentiate!(model::Model; tol = 1e-6)
545550
form = model.model
546551

547552
# Compute Jacobian
548-
Δs = _compute_sensitivity(model; tol = tol)
549-
num_primal = length(cache.primal_vars)
550-
# Fetch primal sensitivities
551-
Δx = zeros(num_primal)
552-
for (i, var_idx) in enumerate(cache.primal_vars)
553-
if haskey(model.input_cache.dx, var_idx)
554-
Δx[i] = model.input_cache.dx[var_idx]
553+
Δs, df_dp = _compute_sensitivity(model; tol = tol)
554+
Δp = if !iszero(model.input_cache.dobj)
555+
model.input_cache.dobj * df_dp
556+
else
557+
num_primal = length(cache.primal_vars)
558+
# Fetch primal sensitivities
559+
Δx = zeros(num_primal)
560+
for (i, var_idx) in enumerate(cache.primal_vars)
561+
if haskey(model.input_cache.dx, var_idx)
562+
Δx[i] = model.input_cache.dx[var_idx]
563+
end
555564
end
556-
end
557-
# Fetch dual sensitivities
558-
num_constraints = length(cache.cons)
559-
num_up = length(cache.has_up)
560-
num_low = length(cache.has_low)
561-
Δdual = zeros(num_constraints + num_up + num_low)
562-
for (i, ci) in enumerate(cache.cons)
563-
idx = form.nlp_index_2_constraint[ci]
564-
if haskey(model.input_cache.dy, idx)
565-
Δdual[i] = model.input_cache.dy[idx]
565+
# Fetch dual sensitivities
566+
num_constraints = length(cache.cons)
567+
num_up = length(cache.has_up)
568+
num_low = length(cache.has_low)
569+
Δdual = zeros(num_constraints + num_up + num_low)
570+
for (i, ci) in enumerate(cache.cons)
571+
idx = form.nlp_index_2_constraint[ci]
572+
if haskey(model.input_cache.dy, idx)
573+
Δdual[i] = model.input_cache.dy[idx]
574+
end
566575
end
567-
end
568-
for (i, var_idx) in enumerate(cache.primal_vars[cache.has_low])
569-
idx = form.constraint_lower_bounds[var_idx.value].value
570-
if haskey(model.input_cache.dy, idx)
571-
Δdual[num_constraints+i] = model.input_cache.dy[idx]
576+
for (i, var_idx) in enumerate(cache.primal_vars[cache.has_low])
577+
idx = form.constraint_lower_bounds[var_idx.value].value
578+
if haskey(model.input_cache.dy, idx)
579+
Δdual[num_constraints+i] = model.input_cache.dy[idx]
580+
end
572581
end
573-
end
574-
for (i, var_idx) in enumerate(cache.primal_vars[cache.has_up])
575-
idx = form.constraint_upper_bounds[var_idx.value].value
576-
if haskey(model.input_cache.dy, idx)
577-
Δdual[num_constraints+num_low+i] = model.input_cache.dy[idx]
582+
for (i, var_idx) in enumerate(cache.primal_vars[cache.has_up])
583+
idx = form.constraint_upper_bounds[var_idx.value].value
584+
if haskey(model.input_cache.dy, idx)
585+
Δdual[num_constraints+num_low+i] = model.input_cache.dy[idx]
586+
end
578587
end
588+
# Extract Parameter sensitivities
589+
Δw = zeros(size(Δs, 1))
590+
Δw[1:num_primal] = Δx
591+
Δw[cache.index_duals] = Δdual
592+
Δp = Δs' * Δw
579593
end
580-
# Extract Parameter sensitivities
581-
Δw = zeros(size(Δs, 1))
582-
Δw[1:num_primal] = Δx
583-
Δw[cache.index_duals] = Δdual
584-
Δp = Δs' * Δw
585-
586-
# Order by ConstraintIndex
587-
varorder =
588-
sort(collect(keys(form.var2ci)); by = x -> form.var2ci[x].value)
589-
Δp = [Δp[form.var2param[var_idx].value] for var_idx in varorder]
590-
591-
model.back_grad_cache = ReverseCache(; Δp = Δp)
594+
595+
Δp_dict = Dict{MOI.ConstraintIndex,Float64}(
596+
form.var2ci[var_idx] => Δp[form.var2param[var_idx].value]
597+
for var_idx in keys(form.var2ci)
598+
)
599+
model.back_grad_cache = ReverseCache(; Δp = Δp_dict)
592600
end
593601
return nothing
594602
end
@@ -620,10 +628,16 @@ function MOI.get(
620628
::DiffOpt.ReverseConstraintSet,
621629
ci::MOI.ConstraintIndex{MOI.VariableIndex,MOI.Parameter{T}},
622630
) where {T}
623-
form = model.model
624-
var_idx = MOI.VariableIndex(ci.value)
625-
p_idx = form.var2param[var_idx].value
626-
return MOI.Parameter{T}(model.back_grad_cache.Δp[p_idx])
631+
return MOI.Parameter{T}(model.back_grad_cache.Δp[ci])
632+
end
633+
634+
function MOI.get(model::Model, ::DiffOpt.ForwardObjectiveSensitivity)
635+
return model.forw_grad_cache.objective_sensitivity_p
636+
end
637+
638+
function MOI.set(model::Model, ::DiffOpt.ReverseObjectiveSensitivity, val)
639+
model.input_cache.dobj = val
640+
return
627641
end
628642

629643
end # module NonLinearProgram

src/NonLinearProgram/nlp_utilities.jl

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,13 @@ function _fill_off_diagonal(H::SparseMatrixCSC)
2727
return ret
2828
end
2929

30+
function _compute_gradient(model::Model)
31+
evaluator = model.cache.evaluator
32+
grad = zeros(length(model.x))
33+
MOI.eval_objective_gradient(evaluator, grad, model.x)
34+
return grad
35+
end
36+
3037
"""
3138
_compute_optimal_hessian(evaluator::MOI.Nonlinear.Evaluator, rows::Vector{JuMP.ConstraintRef}, x::Vector{JuMP.VariableRef})
3239
@@ -104,7 +111,7 @@ function _create_evaluator(form::Form)
104111
backend,
105112
MOI.VariableIndex.(1:form.num_variables),
106113
)
107-
MOI.initialize(evaluator, [:Hess, :Jac])
114+
MOI.initialize(evaluator, [:Hess, :Jac, :Grad])
108115
return evaluator
109116
end
110117

@@ -480,6 +487,11 @@ function _compute_sensitivity(model::Model; tol = 1e-6)
480487
# Dual bounds lower
481488
∂s[(num_w+num_cons+1):(num_w+num_cons+num_lower), :] *= _sense_multiplier
482489
# Dual bounds upper
483-
∂s[(num_w+num_cons+num_lower+1):end, :] *= -_sense_multiplier
484-
return ∂s
490+
∂s[((num_w+num_cons+num_lower+1):end), :] *= -_sense_multiplier
491+
492+
# dual wrt parameter
493+
primal_idx = [i.value for i in model.cache.primal_vars]
494+
df_dx = _compute_gradient(model)[primal_idx]
495+
df_dp = df_dx'∂s[1:num_vars, :]
496+
return ∂s, df_dp
485497
end

src/QuadraticProgram/QuadraticProgram.jl

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -501,4 +501,16 @@ function MOI.set(model::Model, ::LinearAlgebraSolver, linear_solver)
501501
return model.linear_solver = linear_solver
502502
end
503503

504+
function MOI.get(::Model, ::DiffOpt.ForwardObjectiveSensitivity)
505+
return error(
506+
"ForwardObjectiveSensitivity is not implemented for the Quadratic Optimization backend",
507+
)
508+
end
509+
510+
function MOI.set(::Model, ::DiffOpt.ReverseObjectiveSensitivity, val)
511+
return error(
512+
"ReverseObjectiveSensitivity is not implemented for the Quadratic Optimization backend",
513+
)
514+
end
515+
504516
end

src/diff_opt.jl

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Base.@kwdef mutable struct InputCache
1515
dx::Dict{MOI.VariableIndex,Float64} = Dict{MOI.VariableIndex,Float64}()# dz for QP
1616
dy::Dict{MOI.ConstraintIndex,Float64} = Dict{MOI.ConstraintIndex,Float64}()
1717
# Dual sensitivity currently only works for NonLinearProgram
18+
dobj::Float64 = 0.0 # Objective input sensitivity for reverse differentiation
1819
# ds
1920
# dy #= [d\lambda, d\nu] for QP
2021
# FIXME Would it be possible to have a DoubleDict where the value depends
@@ -35,6 +36,7 @@ end
3536
function Base.empty!(cache::InputCache)
3637
empty!(cache.dx)
3738
empty!(cache.dy)
39+
cache.dobj = 0.0
3840
empty!(cache.parameter_constraints)
3941
empty!(cache.scalar_constraints)
4042
empty!(cache.vector_constraints)
@@ -191,6 +193,20 @@ MOI.set(model, DiffOpt.ReverseConstraintDual(), ci, value)
191193
"""
192194
struct ReverseConstraintDual <: MOI.AbstractConstraintAttribute end
193195

196+
"""
197+
ReverseObjectiveSensitivity <: MOI.AbstractModelAttribute
198+
199+
A `MOI.AbstractModelAttribute` to set input data for reverse differentiation.
200+
201+
For instance, to set the sensitivity of the parameter perturbation with respect to the
202+
objective function perturbation, do the following:
203+
204+
```julia
205+
MOI.set(model, DiffOpt.ReverseObjectiveSensitivity(), value)
206+
```
207+
"""
208+
struct ReverseObjectiveSensitivity <: MOI.AbstractModelAttribute end
209+
194210
"""
195211
ForwardConstraintDual <: MOI.AbstractConstraintAttribute
196212
@@ -206,6 +222,21 @@ struct ForwardConstraintDual <: MOI.AbstractConstraintAttribute end
206222

207223
MOI.is_set_by_optimize(::ForwardConstraintDual) = true
208224

225+
"""
226+
ForwardObjectiveSensitivity <: MOI.AbstractModelAttribute
227+
228+
A `MOI.AbstractModelAttribute` to get output objective sensitivity data from forward differentiation.
229+
230+
For instance, to get the sensitivity of the objective function with respect to the parameter perturbation, do the following:
231+
232+
```julia
233+
MOI.get(model, DiffOpt.ForwardObjectiveSensitivity())
234+
```
235+
"""
236+
struct ForwardObjectiveSensitivity <: MOI.AbstractModelAttribute end
237+
238+
MOI.is_set_by_optimize(::ForwardObjectiveSensitivity) = true
239+
209240
"""
210241
ReverseObjectiveFunction <: MOI.AbstractModelAttribute
211242

0 commit comments

Comments
 (0)