Skip to content

Commit 0c84ac7

Browse files
authored
Allow both objective and solution sensitivities in reverse mode (#338)
* allow both obj and sol sens in reverse mode * format * reset param value after fd * update for objective `x -> 2x` * re-optimize as well * attr * update test * update attr * format * remove get before set test * try to fix the issue with julia 1.6 * support 1.6 https://docs.julialang.org/en/v1.6/stdlib/Test/#:~:text=Use%20%40test%5Flogs%20instead * test lts instead of 1.6 * remove begin
1 parent ae803ef commit 0c84ac7

File tree

6 files changed

+114
-45
lines changed

6 files changed

+114
-45
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ jobs:
1717
fail-fast: false
1818
matrix:
1919
include:
20-
- version: '1.6'
20+
- version: 'lts'
2121
os: ubuntu-latest
2222
arch: x64
2323
- version: '1'

src/NonLinearProgram/NonLinearProgram.jl

Lines changed: 34 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -552,45 +552,46 @@ function DiffOpt.reverse_differentiate!(model::Model; tol = 1e-6)
552552
# Compute Jacobian
553553
Δs, df_dp = _compute_sensitivity(model; tol = tol)
554554
Δp = if !iszero(model.input_cache.dobj)
555-
model.input_cache.dobj * df_dp
555+
df_dp'model.input_cache.dobj
556556
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
557+
zeros(length(cache.params))
558+
end
559+
num_primal = length(cache.primal_vars)
560+
# Fetch primal sensitivities
561+
Δx = zeros(num_primal)
562+
for (i, var_idx) in enumerate(cache.primal_vars)
563+
if haskey(model.input_cache.dx, var_idx)
564+
Δx[i] = model.input_cache.dx[var_idx]
564565
end
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
566+
end
567+
# Fetch dual sensitivities
568+
num_constraints = length(cache.cons)
569+
num_up = length(cache.has_up)
570+
num_low = length(cache.has_low)
571+
Δdual = zeros(num_constraints + num_up + num_low)
572+
for (i, ci) in enumerate(cache.cons)
573+
idx = form.nlp_index_2_constraint[ci]
574+
if haskey(model.input_cache.dy, idx)
575+
Δdual[i] = model.input_cache.dy[idx]
575576
end
576-
for (i, var_idx) in enumerate(cache.primal_vars[cache.has_low])
577-
idx = form.constraint_lower_bounds[var_idx.value]
578-
if haskey(model.input_cache.dy, idx)
579-
Δdual[num_constraints+i] = model.input_cache.dy[idx]
580-
end
577+
end
578+
for (i, var_idx) in enumerate(cache.primal_vars[cache.has_low])
579+
idx = form.constraint_lower_bounds[var_idx.value]
580+
if haskey(model.input_cache.dy, idx)
581+
Δdual[num_constraints+i] = model.input_cache.dy[idx]
581582
end
582-
for (i, var_idx) in enumerate(cache.primal_vars[cache.has_up])
583-
idx = form.constraint_upper_bounds[var_idx.value]
584-
if haskey(model.input_cache.dy, idx)
585-
Δdual[num_constraints+num_low+i] = model.input_cache.dy[idx]
586-
end
583+
end
584+
for (i, var_idx) in enumerate(cache.primal_vars[cache.has_up])
585+
idx = form.constraint_upper_bounds[var_idx.value]
586+
if haskey(model.input_cache.dy, idx)
587+
Δdual[num_constraints+num_low+i] = model.input_cache.dy[idx]
587588
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
593589
end
590+
# Extract Parameter sensitivities
591+
Δw = zeros(size(Δs, 1))
592+
Δw[1:num_primal] = Δx
593+
Δw[cache.index_duals] = Δdual
594+
Δp += Δs' * Δw
594595

595596
Δp_dict = Dict{MOI.ConstraintIndex,Float64}(
596597
form.var2ci[var_idx] => Δp[form.var2param[var_idx].value]

src/diff_opt.jl

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ Base.@kwdef mutable struct InputCache
3131
MOIDD.DoubleDict{MOI.VectorAffineFunction{Float64}}() # also includes G for QPs
3232
objective::Union{Nothing,MOI.AbstractScalarFunction} = nothing
3333
factorization::Union{Nothing,Function} = nothing
34+
allow_objective_and_solution_input::Bool = false
3435
end
3536

3637
function Base.empty!(cache::InputCache)
@@ -122,6 +123,8 @@ MOI.set(model, DiffOpt.NonLinearKKTJacobianFactorization(), factorization)
122123
"""
123124
struct NonLinearKKTJacobianFactorization <: MOI.AbstractModelAttribute end
124125

126+
struct AllowObjectiveAndSolutionInput <: MOI.AbstractModelAttribute end
127+
125128
"""
126129
ForwardConstraintFunction <: MOI.AbstractConstraintAttribute
127130
@@ -440,6 +443,15 @@ function MOI.set(
440443
return
441444
end
442445

446+
function MOI.set(
447+
model::AbstractModel,
448+
::AllowObjectiveAndSolutionInput,
449+
allow::Bool,
450+
)
451+
model.input_cache.allow_objective_and_solution_input = allow
452+
return
453+
end
454+
443455
function MOI.set(
444456
model::AbstractModel,
445457
::ReverseVariablePrimal,

src/jump_moi_overloads.jl

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,14 @@ function MOI.set(
2929
return MOI.set(JuMP.backend(model), attr, factorization)
3030
end
3131

32+
function MOI.set(
33+
model::JuMP.Model,
34+
attr::AllowObjectiveAndSolutionInput,
35+
allow::Bool,
36+
)
37+
return MOI.set(JuMP.backend(model), attr, allow)
38+
end
39+
3240
function MOI.set(
3341
model::JuMP.Model,
3442
attr::ForwardObjectiveFunction,

src/moi_wrapper.jl

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -557,16 +557,22 @@ function reverse_differentiate!(model::Optimizer)
557557
end
558558
if !iszero(model.input_cache.dobj) &&
559559
(!isempty(model.input_cache.dx) || !isempty(model.input_cache.dy))
560-
error(
561-
"Cannot compute the reverse differentiation with both solution sensitivities and objective sensitivities.",
562-
)
560+
if !MOI.get(model, AllowObjectiveAndSolutionInput())
561+
@warn "Computing reverse differentiation with both solution sensitivities and objective sensitivities. " *
562+
"Set `DiffOpt.AllowObjectiveAndSolutionInput()` to `true` to silence this warning."
563+
end
563564
end
564565
diff = _diff(model)
565566
MOI.set(
566567
diff,
567568
NonLinearKKTJacobianFactorization(),
568569
model.input_cache.factorization,
569570
)
571+
MOI.set(
572+
diff,
573+
AllowObjectiveAndSolutionInput(),
574+
model.input_cache.allow_objective_and_solution_input,
575+
)
570576
for (vi, value) in model.input_cache.dx
571577
MOI.set(diff, ReverseVariablePrimal(), model.index_map[vi], value)
572578
end
@@ -673,6 +679,11 @@ function forward_differentiate!(model::Optimizer)
673679
NonLinearKKTJacobianFactorization(),
674680
model.input_cache.factorization,
675681
)
682+
MOI.set(
683+
diff,
684+
AllowObjectiveAndSolutionInput(),
685+
model.input_cache.allow_objective_and_solution_input,
686+
)
676687
T = Float64
677688
list = MOI.get(
678689
model,
@@ -1125,6 +1136,10 @@ function MOI.supports(
11251136
return true
11261137
end
11271138

1139+
function MOI.supports(::Optimizer, ::AllowObjectiveAndSolutionInput, ::Bool)
1140+
return true
1141+
end
1142+
11281143
function MOI.set(
11291144
model::Optimizer,
11301145
::NonLinearKKTJacobianFactorization,
@@ -1134,10 +1149,19 @@ function MOI.set(
11341149
return
11351150
end
11361151

1152+
function MOI.set(model::Optimizer, ::AllowObjectiveAndSolutionInput, allow)
1153+
model.input_cache.allow_objective_and_solution_input = allow
1154+
return
1155+
end
1156+
11371157
function MOI.get(model::Optimizer, ::NonLinearKKTJacobianFactorization)
11381158
return model.input_cache.factorization
11391159
end
11401160

1161+
function MOI.get(model::Optimizer, ::AllowObjectiveAndSolutionInput)
1162+
return model.input_cache.allow_objective_and_solution_input
1163+
end
1164+
11411165
function MOI.set(model::Optimizer, attr::MOI.AbstractOptimizerAttribute, value)
11421166
return MOI.set(model.optimizer, attr, value)
11431167
end

test/nlp_program.jl

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -644,14 +644,15 @@ function test_ObjectiveSensitivity_model1()
644644
set_silent(model)
645645

646646
# Parameters
647-
@variable(model, p MOI.Parameter(1.5))
647+
p_val = 1.5
648+
@variable(model, p MOI.Parameter(p_val))
648649

649650
# Variables
650651
@variable(model, x)
651652

652653
# Constraints
653654
@constraint(model, x * sin(p) == 1)
654-
@objective(model, Min, sum(x))
655+
@objective(model, Min, 2 * x)
655656

656657
optimize!(model)
657658
@assert is_solved_and_feasible(model)
@@ -665,19 +666,42 @@ function test_ObjectiveSensitivity_model1()
665666

666667
# Test Objective Sensitivity wrt parameters
667668
df_dp = MOI.get(model, DiffOpt.ForwardObjectiveSensitivity())
668-
@test isapprox(df_dp, -0.0071092; atol = 1e-4)
669+
df = -2cos(p_val) / sin(p_val)^2
670+
@test isapprox(df_dp, df * Δp; atol = 1e-4)
669671

670672
# Clean up
671673
DiffOpt.empty_input_sensitivities!(model)
672674

673-
# Set Too Many Sensitivities
675+
# Test both obj and solution inputs
674676
Δf = 0.5
675677
MOI.set(model, DiffOpt.ReverseObjectiveSensitivity(), Δf)
678+
MOI.set(model, DiffOpt.ReverseVariablePrimal(), x, Δp)
676679

677-
MOI.set(model, DiffOpt.ReverseVariablePrimal(), x, 1.0)
680+
msg = "Computing reverse differentiation with both solution sensitivities and objective sensitivities. Set `DiffOpt.AllowObjectiveAndSolutionInput()` to `true` to silence this warning."
681+
@test_logs (:warn, msg) DiffOpt.reverse_differentiate!(model)
682+
MOI.set(model, DiffOpt.AllowObjectiveAndSolutionInput(), true)
683+
@test_nowarn DiffOpt.reverse_differentiate!(model)
678684

679-
# Compute derivatives
680-
@test_throws ErrorException DiffOpt.reverse_differentiate!(model)
685+
dp_combined =
686+
MOI.get(model, DiffOpt.ReverseConstraintSet(), ParameterRef(p)).value
687+
688+
ε = 1e-6
689+
df_dp_fdpos = begin
690+
set_parameter_value(p, p_val + ε)
691+
optimize!(model)
692+
Δf * objective_value(model) + Δp * value(x)
693+
end
694+
df_dp_fdneg = begin
695+
set_parameter_value(p, p_val - ε)
696+
optimize!(model)
697+
Δf * objective_value(model) + Δp * value(x)
698+
end
699+
df_dp_fd = (df_dp_fdpos - df_dp_fdneg) / (2ε)
700+
701+
@test isapprox(df_dp_fd, dp_combined)
702+
703+
set_parameter_value(p, p_val)
704+
optimize!(model)
681705

682706
DiffOpt.empty_input_sensitivities!(model)
683707

@@ -691,7 +715,7 @@ function test_ObjectiveSensitivity_model1()
691715
# Test Objective Sensitivity wrt parameters
692716
dp = MOI.get(model, DiffOpt.ReverseConstraintSet(), ParameterRef(p)).value
693717

694-
@test isapprox(dp, -0.0355464; atol = 1e-4)
718+
@test isapprox(dp, df * Δf; atol = 1e-4)
695719
end
696720

697721
function test_ObjectiveSensitivity_model2()

0 commit comments

Comments
 (0)