Skip to content
Open
31 changes: 31 additions & 0 deletions doc/src/extensions.rst
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ command-line flags:
- ``--interrupt-W-oscillations <file>`` -- activates W-oscillation
interruption (slamming; implies detection; see :ref:`w_oscillation`)
- ``--mipgaps-json <file>`` -- activates the mipgapper extension
- ``--timed-mipgap`` -- activates the timed MIP gap extension
- ``--timed-mipgap-options <curve>`` -- sets the timed MIP gap curve as
``gap:time`` pairs (e.g., ``"0.02:100 0.05:200"``)
- ``--user-defined-extensions <module>`` -- loads a custom extension
- ``--grad-rho`` -- activates gradient-based rho (see :ref:`rho_setting`)
- ``--use-norm-rho-updater`` -- activates the norm rho updater
Expand Down Expand Up @@ -95,6 +98,34 @@ as computed by the cylinders.

This extension can also be used with the Lagrangian and subgradient spokes.

timed_mipgap.py
^^^^^^^^^^^^^^^^

This extension installs a solver termination callback that stops a persistent
MIP solve once a user-specified run time has been reached *and* the current
relative gap is already below a target threshold. The option is given as a
string of ordered ``gap:time`` pairs in
``options["timed_mipgap"]["timecurve"]``. For example,
``"0.02:100 0.05:200"`` means: after 100 seconds, stop if the relative gap is
below 2%; after 200 seconds, stop if it is below 5%.

This is a soft, time-dependent stopping rule: it does not force termination at
the specified times unless the incumbent and bound are already close enough.
It is useful when early PH iterations do not need tight subproblem solves, but
later iterations may still benefit from stronger solves when the solver is
making progress.

When using ``generic_cylinders.py``, enable it with:

- ``--timed-mipgap``
- ``--timed-mipgap-options "0.02:100 0.05:200"``

The extension currently requires a persistent solver with supported termination
callbacks; at present this includes CPLEX, Gurobi, and Xpress persistent
interfaces. The ``gap:time`` pairs are validated in the order provided and
must be strictly increasing in both gap and time; duplicate gap entries are
rejected.

fixer.py
^^^^^^^^

Expand Down
2 changes: 2 additions & 0 deletions doc/src/generic_cylinders.rst
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,8 @@ Some extensions can be activated directly from the command line:

- ``--fixer`` -- Fix variables that have converged
- ``--mipgaps-json <file>`` -- MIP gap schedule from a JSON file
- ``--timed-mipgap`` -- Enable time-dependent MIP gap termination
- ``--timed-mipgap-options <curve>`` -- Timed gap curve as ``gap:time`` pairs
- ``--user-defined-extensions <module>`` -- Load a custom extension module
- ``--wtracker`` -- Track W (Lagrange-multiplier) values per iteration
and write a convergence report at the end of the run
Expand Down
142 changes: 84 additions & 58 deletions mpisppy/extensions/timed_mipgap.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,96 +7,122 @@
# full copyright and license information.
###############################################################################
'''
This class is implemented as an extension to be used in mpi-sppy to add a callback to a persistent
solver to implement a time-dependent target MIP gap.
For now, the only solver supported is GurobiPersistent.
This class is implemented as an extension to be used in mpi-sppy to add a termination callback to a
persistent solver and implement a time-dependent target MIP gap.
'''

import math

import mpisppy.extensions.extension
import mpisppy.utils.sputils as sputils
from pyomo.solvers.plugins.solvers.gurobi_persistent import GurobiPersistent

from gurobipy import GRB
from mpisppy.utils.callbacks.termination import (
set_termination_callback,
supports_termination_callback,
)

class TimedMIPGapCB(mpisppy.extensions.extension.Extension):
'''
This extension adds a solver callback function that implements a time-dependent target MIP gap.
The curve is defined by a sequence of (time,gap) pairs, monotonically increasing in both dimensions.
For each (t,g) pair, when the solver reaches time t, it relaxes the target MIP gap to g.
This extension adds a solver termination callback that implements a time-dependent target MIP gap.
The curve is defined by an ordered mapping of gap:time pairs, monotonically increasing in both
dimensions. For each (t,g) pair, when the solver reaches time t, it terminates if the relative gap
has already improved below g.

This class requires the following options:
'timed_mipgap':
'timecurve': string of gap:time pairs separated by spaces. Example: "0.02:100 0.05:200"

Attributes
----------
timecurve: dict of {gap:time} with sequence of (time,gap) pairs.

Reference
---------
Based on post found at:
https://support.gurobi.com/hc/en-us/articles/360047717291-How-do-I-use-callbacks-to-terminate-the-solver-
(Oct 2023)
timecurve: ordered dict of {gap:time} pairs.
'''

def __init__(self, ph):

super().__init__(ph)
self.ph = ph

self._set_options()

def _set_options(self):
ph = self.ph
if 'timed_mipgap' not in ph.options:
raise RuntimeError('Did not find "timed_mipgap" options')
timecurve_str = ph.options['timed_mipgap']['timecurve']
Comment thread
bknueven marked this conversation as resolved.
self.timecurve = {float(ent.split(':')[0]):float(ent.split(':')[1]) for ent in timecurve_str.split(' ')}
self.timecurve = dict()
prev_gap = None
prev_time = None
for entry in timecurve_str.split():
try:
gap_str, time_str = entry.split(':', 1)
gap = float(gap_str)
solve_time = float(time_str)
except ValueError as exc:
raise RuntimeError(
'Timed MIP gap option "timecurve" entries must have format "gap:time"'
) from exc

if not math.isfinite(gap) or not math.isfinite(solve_time):
raise RuntimeError(
'Timed MIP gap option "timecurve" entries must use finite gap and time values'
)
if gap in self.timecurve:
raise RuntimeError(
f'Timed MIP gap option "timecurve" has duplicate gap entry {gap}'
)
if prev_gap is not None and (gap <= prev_gap or solve_time <= prev_time):
raise RuntimeError(
'Timed MIP gap option "timecurve" must be strictly increasing in both gap and time'
)

self.timecurve[gap] = solve_time
prev_gap = gap
prev_time = solve_time

if not self.timecurve:
raise RuntimeError('Timed MIP gap option "timecurve" must not be empty')

@staticmethod
def _compute_relative_gap(best_obj, best_bound):
'''Return relative gap or None if a bound is unavailable.'''
if best_obj is None or best_bound is None:
return None
if not math.isfinite(best_obj) or not math.isfinite(best_bound):
return None

return abs(best_obj - best_bound) / max(
1e-10,
abs(best_obj),
abs(best_bound),
)

def _should_terminate(self, runtime, best_obj, best_bound):
'''Return True when the timecurve says to terminate.'''
if runtime is None or not math.isfinite(runtime):
return False

gap = self._compute_relative_gap(best_obj, best_bound)
if gap is None:
return False

for tc_gap, tc_t in self.timecurve.items():
if runtime > tc_t and gap < tc_gap:
return True

return False

def iter0_post_solver_creation(self):
ph = self.ph
for sname, s in ph.local_scenarios.items():
for s in ph.local_scenarios.values():
if not hasattr(s, '_solver_plugin'):
raise RuntimeError('Solver must be created before calling callback extension')
if not (sputils.is_persistent(s._solver_plugin)):
raise RuntimeError('Solvers must be persistent for callback definition')
if not s._solver_plugin.has_instance():
raise RuntimeError('Solver must be instantiated before calling callback extension')
if not isinstance(s._solver_plugin,GurobiPersistent):
raise RuntimeError('Currently, only GurobiPersistent solver is supported.')

def cb_fun(cb_m,cb_opt,cb_wh):
'''
callback function
'''
return self._timecurve_cb(cb_m,cb_opt,cb_wh,self.timecurve)
s._solver_plugin.set_callback(cb_fun)
if not supports_termination_callback(s._solver_plugin):
raise RuntimeError(
'Timed MIP gap requires a persistent solver with supported termination callbacks'
)

@staticmethod
def _timecurve_cb(cb_model, cb_opt, cb_where,
timecurve_dict):
'''
Inputs
------
cb_model: Pyomo ConcreteModel
cb_opt: SolverFactory model of gurobi_persistent type
cb_where: argument that indicates where in the algorith the callback is being called from

timecurve_dict: dict of {gap:time} with:
time: time in sec
gap: target MIP gap (in p.u.)
Note that (time,gap) only define a meaningful timecurve if there is monotonicity, so monotonicity
is assumed. However, it is not verified.

'''

if cb_where == GRB.Callback.MIP:
grb_m = cb_opt._solver_model # gurobipy model
runtime = grb_m.cbGet(GRB.Callback.RUNTIME)
objbst = grb_m.cbGet(GRB.Callback.MIP_OBJBST)
objbnd = grb_m.cbGet(GRB.Callback.MIP_OBJBND)
gap = abs((objbst - objbnd) / objbst)

for tc_gap,tc_t in timecurve_dict.items():
if runtime > tc_t and gap < tc_gap:
grb_m.terminate()

def cb_fun(runtime, best_obj, best_bound):
return self._should_terminate(runtime, best_obj, best_bound)

set_termination_callback(s._solver_plugin, cb_fun)
Loading
Loading