Skip to content

Commit 8d24b0f

Browse files
authored
Add a callback to modify the subproblem on numerical difficulty (#790)
1 parent 2fd1e8d commit 8d24b0f

File tree

4 files changed

+118
-29
lines changed

4 files changed

+118
-29
lines changed

Diff for: docs/src/apireference.md

+1
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ SDDP.termination_status
3434
SDDP.write_cuts_to_file
3535
SDDP.read_cuts_from_file
3636
SDDP.write_log_to_csv
37+
SDDP.set_numerical_difficulty_callback
3738
```
3839

3940
### [Stopping rules](@id api_stopping_rules)

Diff for: src/algorithm.jl

+87-12
Original file line numberDiff line numberDiff line change
@@ -292,19 +292,77 @@ function _has_primal_solution(node::Node)
292292
return status in (JuMP.FEASIBLE_POINT, JuMP.NEARLY_FEASIBLE_POINT)
293293
end
294294

295-
function attempt_numerical_recovery(model::PolicyGraph, node::Node)
296-
if JuMP.mode(node.subproblem) == JuMP.DIRECT
297-
@warn(
298-
"Unable to recover in direct mode! Remove `direct = true` when " *
299-
"creating the policy graph."
300-
)
301-
else
302-
model.ext[:numerical_issue] = true
303-
MOI.Utilities.reset_optimizer(node.subproblem)
304-
optimize!(node.subproblem)
295+
function _has_dual_solution(node::Node)
296+
status = JuMP.dual_status(node.subproblem)
297+
return status in (JuMP.FEASIBLE_POINT, JuMP.NEARLY_FEASIBLE_POINT)
298+
end
299+
300+
"""
301+
set_numerical_difficulty_callback(
302+
model::PolicyGraph,
303+
callback::Function,
304+
)
305+
306+
Set a callback function `callback(::PolicyGraph, ::Node; require_dual::Bool)`
307+
that is run when the optimizer terminates without finding a primal solution (and
308+
dual solution if `require_dual` is `true`).
309+
310+
## Default callback
311+
312+
The default callback is a small variation of:
313+
```julia
314+
function callback(::PolicyGraph, node::Node; require_dual::Bool)
315+
MOI.Utilities.reset_optimizer(node.subproblem)
316+
optimize!(node.subproblem)
317+
return
318+
end
319+
```
320+
This callback is the default because a common issue is solvers declaring the
321+
infeasible because of numerical issues related to the large number of cutting
322+
planes. Resetting the subproblem---and therefore starting from a fresh problem
323+
instead of warm-starting from the previous solution---is often enough to fix the
324+
problem and allow more iterations.
325+
326+
## Other callbacks
327+
328+
In cases where the problem is truely infeasible (not because of numerical issues
329+
), it may be helpful to write out the irreducible infeasible subsystem (IIS) for
330+
debugging. For this use-case, use a callback as follows:
331+
```julia
332+
function callback(::PolicyGraph, node::Node; require_dual::Bool)
333+
JuMP.compute_conflict!(node.suprobblem)
334+
status = JuMP.get_attribute(node.subproblem, MOI.ConflictStatus())
335+
if status == MOI.CONFLICT_FOUND
336+
iis_model, _ = JuMP.copy_conflict(node.subproblem)
337+
print(iis_model)
305338
end
306-
if !_has_primal_solution(node)
307-
model.ext[:numerical_issue] = true
339+
return
340+
end
341+
SDDP.set_numerical_difficulty_callback(model, callback)
342+
```
343+
"""
344+
function set_numerical_difficulty_callback(
345+
model::PolicyGraph,
346+
callback::Function,
347+
)
348+
model.ext[:numerical_difficulty_callback] = callback
349+
return
350+
end
351+
352+
function attempt_numerical_recovery(
353+
model::PolicyGraph,
354+
node::Node;
355+
require_dual::Bool = false,
356+
)
357+
model.ext[:numerical_issue] = true
358+
callback = get(
359+
model.ext,
360+
:numerical_difficulty_callback,
361+
default_numerical_difficulty_callback,
362+
)
363+
callback(model, node; require_dual)
364+
missing_dual_solution = require_dual && !_has_dual_solution(node)
365+
if !_has_primal_solution(node) || missing_dual_solution
308366
# We use the `node.index` in the filename because two threads could both
309367
# try to write the cuts to file at the same time. If, after writing this
310368
# file, a second thread finds an infeasibility of the same node, it
@@ -321,6 +379,23 @@ function attempt_numerical_recovery(model::PolicyGraph, node::Node)
321379
return
322380
end
323381

382+
function default_numerical_difficulty_callback(
383+
model::PolicyGraph,
384+
node::Node;
385+
kwargs...,
386+
)
387+
if JuMP.mode(node.subproblem) == JuMP.DIRECT
388+
@warn(
389+
"Unable to recover in direct mode! Remove `direct = true` when " *
390+
"creating the policy graph."
391+
)
392+
return
393+
end
394+
MOI.Utilities.reset_optimizer(node.subproblem)
395+
optimize!(node.subproblem)
396+
return
397+
end
398+
324399
"""
325400
_initialize_solver(node::Node; throw_error::Bool)
326401

Diff for: src/plugins/duality_handlers.jl

+2-17
Original file line numberDiff line numberDiff line change
@@ -91,25 +91,10 @@ min Cᵢ(x̄, u, w) + θᵢ
9191
"""
9292
struct ContinuousConicDuality <: AbstractDualityHandler end
9393

94-
function _has_dual_solution(node::Node)
95-
status = JuMP.dual_status(node.subproblem)
96-
return status in (JuMP.FEASIBLE_POINT, JuMP.NEARLY_FEASIBLE_POINT)
97-
end
98-
9994
function get_dual_solution(node::Node, ::ContinuousConicDuality)
10095
if !_has_dual_solution(node)
101-
# Attempt to recover by resetting the optimizer and re-solving.
102-
if JuMP.mode(node.subproblem) != JuMP.DIRECT
103-
MOI.Utilities.reset_optimizer(node.subproblem)
104-
optimize!(node.subproblem)
105-
end
106-
end
107-
if !_has_dual_solution(node)
108-
write_subproblem_to_file(
109-
node,
110-
"subproblem.mof.json";
111-
throw_error = true,
112-
)
96+
model = node.subproblem.ext[:sddp_policy_graph]
97+
attempt_numerical_recovery(model, node; require_dual = true)
11398
end
11499
# Note: due to JuMP's dual convention, we need to flip the sign for
115100
# maximization problems.

Diff for: test/algorithm.jl

+28
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,34 @@ function test_log_frequency_argument_error()
359359
return
360360
end
361361

362+
function test_numerical_difficulty_callback()
363+
model = SDDP.LinearPolicyGraph(;
364+
stages = 2,
365+
lower_bound = 0.0,
366+
optimizer = HiGHS.Optimizer,
367+
) do node, stage
368+
@variable(node, x >= 0, SDDP.State, initial_value = 0.0)
369+
if stage == 2
370+
@constraint(node, c_infeasible, x.in >= 2)
371+
end
372+
@stageobjective(node, x.out)
373+
end
374+
callback_called_from = Int[]
375+
function my_callback(model, node; require_dual)
376+
push!(callback_called_from, node.index)
377+
JuMP.delete(node.subproblem, node.subproblem[:c_infeasible])
378+
JuMP.optimize!(node.subproblem)
379+
return
380+
end
381+
SDDP.set_numerical_difficulty_callback(model, my_callback)
382+
SDDP.train(model; iteration_limit = 3)
383+
@test callback_called_from == [2]
384+
@test model.most_recent_training_results.status == :iteration_limit
385+
log = model.most_recent_training_results.log
386+
@test map(l -> l.serious_numerical_issue, log) == [1, 0, 0]
387+
return
388+
end
389+
362390
end # module
363391

364392
TestAlgorithm.runtests()

0 commit comments

Comments
 (0)