Refer to variables by varname instead of (vname, index)

This commit is contained in:
2021-04-07 10:56:31 -05:00
parent 856b595d5e
commit 1cf6124757
22 changed files with 467 additions and 516 deletions

View File

@@ -6,7 +6,9 @@ import re
import sys
from io import StringIO
from random import randint
from typing import List, Any, Dict, Optional, cast, Tuple, Union
from typing import List, Any, Dict, Optional
from overrides import overrides
from miplearn.instance.base import Instance
from miplearn.solvers import _RedirectOutput
@@ -17,7 +19,12 @@ from miplearn.solvers.internal import (
LazyCallback,
MIPSolveStats,
)
from miplearn.types import VarIndex, SolverParams, Solution, UserCutCallback
from miplearn.types import (
SolverParams,
UserCutCallback,
Solution,
VariableName,
)
logger = logging.getLogger(__name__)
@@ -52,8 +59,8 @@ class GurobiSolver(InternalSolver):
self.instance: Optional[Instance] = None
self.model: Optional["gurobipy.Model"] = None
self.params: SolverParams = params
self._all_vars: Dict = {}
self._bin_vars: Optional[Dict[str, Dict[VarIndex, "gurobipy.Var"]]] = None
self.varname_to_var: Dict[str, "gurobipy.Var"] = {}
self.bin_vars: List["gurobipy.Var"] = []
self.cb_where: Optional[int] = None
assert lazy_cb_frequency in [1, 2]
@@ -65,6 +72,7 @@ class GurobiSolver(InternalSolver):
self.gp.GRB.Callback.MIPNODE,
]
@overrides
def set_instance(
self,
instance: Instance,
@@ -85,30 +93,20 @@ class GurobiSolver(InternalSolver):
def _update_vars(self) -> None:
assert self.model is not None
self._all_vars = {}
self._bin_vars = {}
idx: VarIndex
self.varname_to_var.clear()
self.bin_vars.clear()
for var in self.model.getVars():
m = re.search(r"([^[]*)\[(.*)]", var.varName)
if m is None:
name = var.varName
idx = (0,)
else:
name = m.group(1)
parts = m.group(2).split(",")
idx = cast(
Tuple[Union[str, int]],
tuple(int(k) if k.isdecimal() else str(k) for k in parts),
)
if len(idx) == 1:
idx = idx[0]
if name not in self._all_vars:
self._all_vars[name] = {}
self._all_vars[name][idx] = var
if var.vtype != "C":
if name not in self._bin_vars:
self._bin_vars[name] = {}
self._bin_vars[name][idx] = var
assert var.varName not in self.varname_to_var, (
f"Duplicated variable name detected: {var.varName}. "
f"Unique variable names are currently required."
)
self.varname_to_var[var.varName] = var
assert var.vtype in ["B", "C"], (
"Only binary and continuous variables are currently supported. "
"Variable {var.varName} has type {var.vtype}."
)
if var.vtype == "B":
self.bin_vars.append(var)
def _apply_params(self, streams: List[Any]) -> None:
assert self.model is not None
@@ -118,6 +116,7 @@ class GurobiSolver(InternalSolver):
if "seed" not in [k.lower() for k in self.params.keys()]:
self.model.setParam("Seed", randint(0, 1_000_000))
@overrides
def solve_lp(
self,
tee: bool = False,
@@ -128,17 +127,14 @@ class GurobiSolver(InternalSolver):
streams += [sys.stdout]
self._apply_params(streams)
assert self.model is not None
assert self._bin_vars is not None
for (varname, vardict) in self._bin_vars.items():
for (idx, var) in vardict.items():
var.vtype = self.gp.GRB.CONTINUOUS
var.lb = 0.0
var.ub = 1.0
for var in self.bin_vars:
var.vtype = self.gp.GRB.CONTINUOUS
var.lb = 0.0
var.ub = 1.0
with _RedirectOutput(streams):
self.model.optimize()
for (varname, vardict) in self._bin_vars.items():
for (idx, var) in vardict.items():
var.vtype = self.gp.GRB.BINARY
for var in self.bin_vars:
var.vtype = self.gp.GRB.BINARY
log = streams[0].getvalue()
opt_value = None
if not self.is_infeasible():
@@ -148,6 +144,7 @@ class GurobiSolver(InternalSolver):
"LP log": log,
}
@overrides
def solve(
self,
tee: bool = False,
@@ -218,33 +215,30 @@ class GurobiSolver(InternalSolver):
}
return stats
@overrides
def get_solution(self) -> Optional[Solution]:
self._raise_if_callback()
assert self.model is not None
if self.model.solCount == 0:
return None
solution: Solution = {}
for (varname, vardict) in self._all_vars.items():
solution[varname] = {}
for (idx, var) in vardict.items():
solution[varname][idx] = var.x
return solution
return {v.varName: v.x for v in self.model.getVars()}
@overrides
def get_variable_names(self) -> List[VariableName]:
self._raise_if_callback()
assert self.model is not None
return [v.varName for v in self.model.getVars()]
@overrides
def set_warm_start(self, solution: Solution) -> None:
self._raise_if_callback()
self._clear_warm_start()
count_fixed, count_total = 0, 0
for (varname, vardict) in solution.items():
for (idx, value) in vardict.items():
count_total += 1
if value is not None:
count_fixed += 1
self._all_vars[varname][idx].start = value
logger.info(
"Setting start values for %d variables (out of %d)"
% (count_fixed, count_total)
)
for (var_name, value) in solution.items():
var = self.varname_to_var[var_name]
if value is not None:
var.start = value
@overrides
def get_sense(self) -> str:
assert self.model is not None
if self.model.modelSense == 1:
@@ -252,18 +246,12 @@ class GurobiSolver(InternalSolver):
else:
return "max"
def get_value(
self,
var_name: str,
index: VarIndex,
) -> Optional[float]:
var = self._all_vars[var_name][index]
return self._get_value(var)
@overrides
def is_infeasible(self) -> bool:
assert self.model is not None
return self.model.status in [self.gp.GRB.INFEASIBLE, self.gp.GRB.INF_OR_UNBD]
@overrides
def get_dual(self, cid: str) -> float:
assert self.model is not None
c = self.model.getConstrByName(cid)
@@ -288,15 +276,7 @@ class GurobiSolver(InternalSolver):
"get_value cannot be called from cb_where=%s" % self.cb_where
)
def get_empty_solution(self) -> Solution:
self._raise_if_callback()
solution: Solution = {}
for (varname, vardict) in self._all_vars.items():
solution[varname] = {}
for (idx, var) in vardict.items():
solution[varname][idx] = None
return solution
@overrides
def add_constraint(
self,
constraint: Any,
@@ -321,36 +301,39 @@ class GurobiSolver(InternalSolver):
else:
self.model.addConstr(constraint, name=name)
@overrides
def add_cut(self, cobj: Any) -> None:
assert self.model is not None
assert self.cb_where == self.gp.GRB.Callback.MIPNODE
self.model.cbCut(cobj)
def _clear_warm_start(self) -> None:
for (varname, vardict) in self._all_vars.items():
for (idx, var) in vardict.items():
var.start = self.gp.GRB.UNDEFINED
for var in self.varname_to_var.values():
var.start = self.gp.GRB.UNDEFINED
@overrides
def fix(self, solution: Solution) -> None:
self._raise_if_callback()
for (varname, vardict) in solution.items():
for (idx, value) in vardict.items():
if value is None:
continue
var = self._all_vars[varname][idx]
var.vtype = self.gp.GRB.CONTINUOUS
var.lb = value
var.ub = value
for (varname, value) in solution.items():
if value is None:
continue
var = self.varname_to_var[varname]
var.vtype = self.gp.GRB.CONTINUOUS
var.lb = value
var.ub = value
@overrides
def get_constraint_ids(self):
self._raise_if_callback()
self.model.update()
return [c.ConstrName for c in self.model.getConstrs()]
@overrides
def get_constraint_rhs(self, cid: str) -> float:
assert self.model is not None
return self.model.getConstrByName(cid).rhs
@overrides
def get_constraint_lhs(self, cid: str) -> Dict[str, float]:
assert self.model is not None
constr = self.model.getConstrByName(cid)
@@ -360,6 +343,7 @@ class GurobiSolver(InternalSolver):
lhs[expr.getVar(i).varName] = expr.getCoeff(i)
return lhs
@overrides
def extract_constraint(self, cid):
self._raise_if_callback()
constr = self.model.getConstrByName(cid)
@@ -367,6 +351,7 @@ class GurobiSolver(InternalSolver):
self.model.remove(constr)
return cobj
@overrides
def is_constraint_satisfied(self, cobj, tol=1e-6):
lhs, sense, rhs, name = cobj
if self.cb_where is not None:
@@ -386,21 +371,25 @@ class GurobiSolver(InternalSolver):
else:
raise Exception("Unknown sense: %s" % sense)
@overrides
def get_inequality_slacks(self) -> Dict[str, float]:
assert self.model is not None
ineqs = [c for c in self.model.getConstrs() if c.sense != "="]
return {c.ConstrName: c.Slack for c in ineqs}
@overrides
def set_constraint_sense(self, cid: str, sense: str) -> None:
assert self.model is not None
c = self.model.getConstrByName(cid)
c.Sense = sense
@overrides
def get_constraint_sense(self, cid: str) -> str:
assert self.model is not None
c = self.model.getConstrByName(cid)
return c.Sense
@overrides
def relax(self) -> None:
assert self.model is not None
self.model.update()
@@ -438,6 +427,4 @@ class GurobiSolver(InternalSolver):
self.lazy_cb_where = state["lazy_cb_where"]
self.instance = None
self.model = None
self._all_vars = None
self._bin_vars = None
self.cb_where = None

View File

@@ -6,23 +6,25 @@ import logging
from abc import ABC, abstractmethod
from typing import Any, Dict, List, Optional
from overrides import EnforceOverrides
from miplearn.instance.base import Instance
from miplearn.types import (
LPSolveStats,
IterationCallback,
LazyCallback,
MIPSolveStats,
VarIndex,
Solution,
BranchPriorities,
Constraint,
UserCutCallback,
Solution,
VariableName,
)
logger = logging.getLogger(__name__)
class InternalSolver(ABC):
class InternalSolver(ABC, EnforceOverrides):
"""
Abstract class representing the MIP solver used internally by LearningSolver.
"""
@@ -90,9 +92,6 @@ class InternalSolver(ABC):
If called after `solve`, returns the best primal solution found during
the search. If called after `solve_lp`, returns the optimal solution
to the LP relaxation. If no primal solution is available, return None.
The solution is a dictionary `sol`, where the optimal value of `var[idx]`
is given by `sol[var][idx]`.
"""
pass
@@ -235,14 +234,6 @@ class InternalSolver(ABC):
"""
pass
@abstractmethod
def get_value(self, var_name: str, index: VarIndex) -> Optional[float]:
"""
Returns the value of a given variable in the current solution. If no
solution is available, returns None.
"""
pass
@abstractmethod
def relax(self) -> None:
"""
@@ -286,11 +277,10 @@ class InternalSolver(ABC):
pass
@abstractmethod
def get_empty_solution(self) -> Dict[str, Dict[VarIndex, Optional[float]]]:
def get_variable_names(self) -> List[VariableName]:
"""
Returns a dictionary with the same shape as the one produced by
`get_solution`, but with all values set to None. This method is
used by the ML components to query what variables are there in
the model before a solution is available.
Returns a list containing the names of all variables in the model. This
method is used by the ML components to query what variables are there in the
model before a solution is available.
"""
pass

View File

@@ -9,6 +9,7 @@ from io import StringIO
from typing import Any, List, Dict, Optional
import pyomo
from overrides import overrides
from pyomo import environ as pe
from pyomo.core import Var, Constraint
from pyomo.opt import TerminationCondition
@@ -23,7 +24,12 @@ from miplearn.solvers.internal import (
LazyCallback,
MIPSolveStats,
)
from miplearn.types import VarIndex, SolverParams, Solution, UserCutCallback
from miplearn.types import (
SolverParams,
UserCutCallback,
Solution,
VariableName,
)
logger = logging.getLogger(__name__)
@@ -52,6 +58,7 @@ class BasePyomoSolver(InternalSolver):
for (key, value) in params.items():
self._pyomo_solver.options[key] = value
@overrides
def solve_lp(
self,
tee: bool = False,
@@ -76,6 +83,7 @@ class BasePyomoSolver(InternalSolver):
var.domain = pyomo.core.base.set_types.Binary
self._pyomo_solver.update_var(var)
@overrides
def solve(
self,
tee: bool = False,
@@ -123,36 +131,44 @@ class BasePyomoSolver(InternalSolver):
}
return stats
@overrides
def get_solution(self) -> Optional[Solution]:
assert self.model is not None
if self.is_infeasible():
return None
solution: Solution = {}
for var in self.model.component_objects(Var):
solution[str(var)] = {}
for index in var:
if var[index].fixed:
continue
solution[str(var)][index] = var[index].value
solution[f"{var}[{index}]"] = var[index].value
return solution
@overrides
def get_variable_names(self) -> List[VariableName]:
assert self.model is not None
variables: List[VariableName] = []
for var in self.model.component_objects(Var):
for index in var:
if var[index].fixed:
continue
variables += [f"{var}[{index}]"]
return variables
@overrides
def set_warm_start(self, solution: Solution) -> None:
self._clear_warm_start()
count_total, count_fixed = 0, 0
for var_name in solution:
count_fixed = 0
for (var_name, value) in solution.items():
if value is None:
continue
var = self._varname_to_var[var_name]
for index in solution[var_name]:
count_total += 1
var[index].value = solution[var_name][index]
if solution[var_name][index] is not None:
count_fixed += 1
var.value = solution[var_name]
count_fixed += 1
if count_fixed > 0:
self._is_warm_start_available = True
logger.info(
"Setting start values for %d variables (out of %d)"
% (count_fixed, count_total)
)
@overrides
def set_instance(
self,
instance: Instance,
@@ -168,25 +184,6 @@ class BasePyomoSolver(InternalSolver):
self._update_vars()
self._update_constrs()
def get_value(self, var_name: str, index: VarIndex) -> Optional[float]:
if self.is_infeasible():
return None
else:
var = self._varname_to_var[var_name]
return var[index].value
def get_empty_solution(self) -> Solution:
assert self.model is not None
solution: Solution = {}
for var in self.model.component_objects(Var):
svar = str(var)
solution[svar] = {}
for index in var:
if var[index].fixed:
continue
solution[svar][index] = None
return solution
def _clear_warm_start(self) -> None:
for var in self._all_vars:
if not var.fixed:
@@ -204,8 +201,8 @@ class BasePyomoSolver(InternalSolver):
self._bin_vars = []
self._varname_to_var = {}
for var in self.model.component_objects(Var):
self._varname_to_var[var.name] = var
for idx in var:
self._varname_to_var[f"{var.name}[{idx}]"] = var[idx]
self._all_vars += [var[idx]]
if var[idx].domain == pyomo.core.base.set_types.Binary:
self._bin_vars += [var[idx]]
@@ -220,25 +217,16 @@ class BasePyomoSolver(InternalSolver):
else:
self._cname_to_constr[constr.name] = constr
def fix(self, solution):
count_total, count_fixed = 0, 0
for varname in solution:
for index in solution[varname]:
var = self._varname_to_var[varname]
count_total += 1
if solution[varname][index] is None:
continue
count_fixed += 1
var[index].fix(solution[varname][index])
self._pyomo_solver.update_var(var[index])
logger.info(
"Fixing values for %d variables (out of %d)"
% (
count_fixed,
count_total,
)
)
@overrides
def fix(self, solution: Solution) -> None:
for (varname, value) in solution.items():
if value is None:
continue
var = self._varname_to_var[varname]
var.fix(value)
self._pyomo_solver.update_var(var)
@overrides
def add_constraint(self, constraint):
self._pyomo_solver.add_constraint(constraint)
self._update_constrs()
@@ -271,6 +259,7 @@ class BasePyomoSolver(InternalSolver):
return None
return int(value)
@overrides
def get_constraint_ids(self):
return list(self._cname_to_constr.keys())
@@ -280,6 +269,7 @@ class BasePyomoSolver(InternalSolver):
def _get_node_count_regexp(self) -> Optional[str]:
return None
@overrides
def relax(self) -> None:
for var in self._bin_vars:
lb, ub = var.bounds
@@ -288,6 +278,7 @@ class BasePyomoSolver(InternalSolver):
var.domain = pyomo.core.base.set_types.Reals
self._pyomo_solver.update_var(var)
@overrides
def get_inequality_slacks(self) -> Dict[str, float]:
result: Dict[str, float] = {}
for (cname, cobj) in self._cname_to_constr.items():
@@ -296,6 +287,7 @@ class BasePyomoSolver(InternalSolver):
result[cname] = cobj.slack()
return result
@overrides
def get_constraint_sense(self, cid: str) -> str:
cobj = self._cname_to_constr[cid]
has_ub = cobj.has_ub()
@@ -310,6 +302,7 @@ class BasePyomoSolver(InternalSolver):
else:
return "="
@overrides
def get_constraint_rhs(self, cid: str) -> float:
cobj = self._cname_to_constr[cid]
if cobj.has_ub:
@@ -317,23 +310,30 @@ class BasePyomoSolver(InternalSolver):
else:
return cobj.lower()
@overrides
def get_constraint_lhs(self, cid: str) -> Dict[str, float]:
return {}
@overrides
def set_constraint_sense(self, cid: str, sense: str) -> None:
raise NotImplementedError()
@overrides
def extract_constraint(self, cid: str) -> Constraint:
raise NotImplementedError()
@overrides
def is_constraint_satisfied(self, cobj: Constraint, tol: float = 1e-6) -> bool:
raise NotImplementedError()
@overrides
def is_infeasible(self) -> bool:
return self._termination_condition == TerminationCondition.infeasible
@overrides
def get_dual(self, cid):
raise NotImplementedError()
@overrides
def get_sense(self) -> str:
return self._obj_sense

View File

@@ -3,6 +3,7 @@
# Released under the modified BSD license. See COPYING.md for more details.
from typing import Optional
from overrides import overrides
from pyomo import environ as pe
from scipy.stats import randint
@@ -36,8 +37,10 @@ class CplexPyomoSolver(BasePyomoSolver):
params=params,
)
@overrides
def _get_warm_start_regexp(self):
return "MIP start .* with objective ([0-9.e+-]*)\\."
@overrides
def _get_node_count_regexp(self):
return "^[ *] *([0-9]+)"

View File

@@ -5,6 +5,7 @@
import logging
from typing import Optional
from overrides import overrides
from pyomo import environ as pe
from scipy.stats import randint
@@ -38,22 +39,25 @@ class GurobiPyomoSolver(BasePyomoSolver):
params=params,
)
@overrides
def _extract_node_count(self, log: str) -> int:
return max(1, int(self._pyomo_solver._solver_model.getAttr("NodeCount")))
@overrides
def _get_warm_start_regexp(self) -> str:
return "MIP start with objective ([0-9.e+-]*)"
@overrides
def _get_node_count_regexp(self) -> Optional[str]:
return None
@overrides
def set_branching_priorities(self, priorities: BranchPriorities) -> None:
from gurobipy import GRB
for varname in priorities.keys():
for (varname, priority) in priorities.items():
if priority is None:
continue
var = self._varname_to_var[varname]
for (index, priority) in priorities[varname].items():
if priority is None:
continue
gvar = self._pyomo_solver._pyomo_var_to_solver_var_map[var[index]]
gvar.setAttr(GRB.Attr.BranchPriority, int(round(priority)))
gvar = self._pyomo_solver._pyomo_var_to_solver_var_map[var]
gvar.setAttr(GRB.Attr.BranchPriority, int(round(priority)))