diff --git a/Makefile b/Makefile index 1341104..9a6c54a 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ PYTHON := python3 PYTEST := pytest PIP := $(PYTHON) -m pip MYPY := $(PYTHON) -m mypy -PYTEST_ARGS := -W ignore::DeprecationWarning -vv -x --log-level=DEBUG +PYTEST_ARGS := -W ignore::DeprecationWarning -vv --log-level=DEBUG VERSION := 0.2 all: docs test diff --git a/miplearn/components/primal.py b/miplearn/components/primal.py index 50c7991..c08d5cb 100644 --- a/miplearn/components/primal.py +++ b/miplearn/components/primal.py @@ -95,7 +95,7 @@ class PrimalSolutionComponent(Component): ) def sample_predict(self, sample: Sample) -> Solution: - var_names = sample.get_vector("static_var_names") + var_names = sample.get_array("static_var_names") var_categories = sample.get_vector("static_var_categories") assert var_names is not None assert var_categories is not None @@ -145,7 +145,7 @@ class PrimalSolutionComponent(Component): instance_features = sample.get_vector("static_instance_features") mip_var_values = sample.get_vector("mip_var_values") var_features = sample.get_vector_list("lp_var_features") - var_names = sample.get_vector("static_var_names") + var_names = sample.get_array("static_var_names") var_categories = sample.get_vector("static_var_categories") if var_features is None: var_features = sample.get_vector_list("static_var_features") @@ -187,8 +187,8 @@ class PrimalSolutionComponent(Component): _: Optional[Instance], sample: Sample, ) -> Dict[str, Dict[str, float]]: - mip_var_values = sample.get_vector("mip_var_values") - var_names = sample.get_vector("static_var_names") + mip_var_values = sample.get_array("mip_var_values") + var_names = sample.get_array("static_var_names") assert mip_var_values is not None assert var_names is not None diff --git a/miplearn/features/extractor.py b/miplearn/features/extractor.py index 8848e80..cddf462 100644 --- a/miplearn/features/extractor.py +++ b/miplearn/features/extractor.py @@ -5,7 +5,7 @@ import collections import numbers from math import log, isfinite -from typing import TYPE_CHECKING, Dict, Optional, List, Any, Tuple +from typing import TYPE_CHECKING, Dict, Optional, List, Any, Tuple, KeysView, cast import numpy as np @@ -34,7 +34,7 @@ class FeaturesExtractor: variables = solver.get_variables(with_static=True) constraints = solver.get_constraints(with_static=True, with_lhs=self.with_lhs) sample.put_array("static_var_lower_bounds", variables.lower_bounds) - sample.put_vector("static_var_names", variables.names) + sample.put_array("static_var_names", variables.names) sample.put_array("static_var_obj_coeffs", variables.obj_coeffs) sample.put_vector("static_var_types", variables.types) sample.put_array("static_var_upper_bounds", variables.upper_bounds) @@ -139,12 +139,29 @@ class FeaturesExtractor: instance: "Instance", sample: Sample, ) -> Tuple[List, List]: - categories: List[Optional[str]] = [] - user_features: List[Optional[List[float]]] = [] - var_features_dict = instance.get_variable_features() - var_categories_dict = instance.get_variable_categories() - var_names = sample.get_vector("static_var_names") + # Query variable names + var_names = sample.get_array("static_var_names") assert var_names is not None + + # Query variable features and categories + var_features_dict = { + v.encode(): f for (v, f) in instance.get_variable_features().items() + } + var_categories_dict = { + v.encode(): f for (v, f) in instance.get_variable_categories().items() + } + + # Assert that variables in user-provided dicts actually exist + var_names_set = set(var_names) + for keys in [var_features_dict.keys(), var_categories_dict.keys()]: + for vn in cast(KeysView, keys): + assert ( + vn in var_names_set + ), f"Variable {vn!r} not found in the problem; {var_names_set}" + + # Assemble into compact lists + user_features: List[Optional[List[float]]] = [] + categories: List[Optional[str]] = [] for (i, var_name) in enumerate(var_names): if var_name not in var_categories_dict: user_features.append(None) diff --git a/miplearn/solvers/gurobi.py b/miplearn/solvers/gurobi.py index 724560f..8573717 100644 --- a/miplearn/solvers/gurobi.py +++ b/miplearn/solvers/gurobi.py @@ -73,11 +73,11 @@ class GurobiSolver(InternalSolver): self._has_lp_solution = False self._has_mip_solution = False - self._varname_to_var: Dict[str, "gurobipy.Var"] = {} + self._varname_to_var: Dict[bytes, "gurobipy.Var"] = {} self._cname_to_constr: Dict[str, "gurobipy.Constr"] = {} self._gp_vars: List["gurobipy.Var"] = [] self._gp_constrs: List["gurobipy.Constr"] = [] - self._var_names: List[str] = [] + self._var_names: np.ndarray = np.empty(0) self._constr_names: List[str] = [] self._var_types: List[str] = [] self._var_lbs: np.ndarray = np.empty(0) @@ -263,11 +263,13 @@ class GurobiSolver(InternalSolver): if self.cb_where is not None: if self.cb_where == self.gp.GRB.Callback.MIPNODE: return { - v.varName: self.model.cbGetNodeRel(v) for v in self.model.getVars() + v.varName.encode(): self.model.cbGetNodeRel(v) + for v in self.model.getVars() } elif self.cb_where == self.gp.GRB.Callback.MIPSOL: return { - v.varName: self.model.cbGetSolution(v) for v in self.model.getVars() + v.varName.encode(): self.model.cbGetSolution(v) + for v in self.model.getVars() } else: raise Exception( @@ -276,7 +278,7 @@ class GurobiSolver(InternalSolver): ) if self.model.solCount == 0: return None - return {v.varName: v.x for v in self.model.getVars()} + return {v.varName.encode(): v.x for v in self.model.getVars()} @overrides def get_variable_attrs(self) -> List[str]: @@ -584,7 +586,10 @@ class GurobiSolver(InternalSolver): assert self.model is not None gp_vars: List["gurobipy.Var"] = self.model.getVars() gp_constrs: List["gurobipy.Constr"] = self.model.getConstrs() - var_names: List[str] = self.model.getAttr("varName", gp_vars) + var_names: np.ndarray = np.array( + self.model.getAttr("varName", gp_vars), + dtype="S", + ) var_types: List[str] = self.model.getAttr("vtype", gp_vars) var_ubs: np.ndarray = np.array( self.model.getAttr("ub", gp_vars), @@ -599,7 +604,7 @@ class GurobiSolver(InternalSolver): dtype=float, ) constr_names: List[str] = self.model.getAttr("constrName", gp_constrs) - varname_to_var: Dict = {} + varname_to_var: Dict[bytes, "gurobipy.Var"] = {} cname_to_constr: Dict = {} for (i, gp_var) in enumerate(gp_vars): assert var_names[i] not in varname_to_var, ( diff --git a/miplearn/solvers/internal.py b/miplearn/solvers/internal.py index 5754aa9..0fa5ba8 100644 --- a/miplearn/solvers/internal.py +++ b/miplearn/solvers/internal.py @@ -50,7 +50,7 @@ class MIPSolveStats: @dataclass class Variables: - names: Optional[List[str]] = None + names: Optional[np.ndarray] = None basis_status: Optional[List[str]] = None lower_bounds: Optional[np.ndarray] = None obj_coeffs: Optional[np.ndarray] = None @@ -71,7 +71,7 @@ class Constraints: basis_status: Optional[List[str]] = None dual_values: Optional[np.ndarray] = None lazy: Optional[List[bool]] = None - lhs: Optional[List[List[Tuple[str, float]]]] = None + lhs: Optional[List[List[Tuple[bytes, float]]]] = None names: Optional[List[str]] = None rhs: Optional[np.ndarray] = None sa_rhs_down: Optional[np.ndarray] = None diff --git a/miplearn/solvers/pyomo/base.py b/miplearn/solvers/pyomo/base.py index b3640ec..ad2d541 100644 --- a/miplearn/solvers/pyomo/base.py +++ b/miplearn/solvers/pyomo/base.py @@ -34,8 +34,6 @@ from miplearn.types import ( SolverParams, UserCutCallback, Solution, - VariableName, - Category, ) logger = logging.getLogger(__name__) @@ -59,7 +57,7 @@ class BasePyomoSolver(InternalSolver): self._is_warm_start_available: bool = False self._pyomo_solver: SolverFactory = solver_factory self._obj_sense: str = "min" - self._varname_to_var: Dict[str, pe.Var] = {} + self._varname_to_var: Dict[bytes, pe.Var] = {} self._cname_to_constr: Dict[str, pe.Constraint] = {} self._termination_condition: str = "" self._has_lp_solution = False @@ -166,7 +164,7 @@ class BasePyomoSolver(InternalSolver): names: List[str] = [] rhs: List[float] = [] - lhs: List[List[Tuple[str, float]]] = [] + lhs: List[List[Tuple[bytes, float]]] = [] senses: List[str] = [] dual_values: List[float] = [] slacks: List[float] = [] @@ -199,18 +197,18 @@ class BasePyomoSolver(InternalSolver): if isinstance(term, MonomialTermExpression): lhsc.append( ( - term._args_[1].name, + term._args_[1].name.encode(), float(term._args_[0]), ) ) elif isinstance(term, _GeneralVarData): - lhsc.append((term.name, 1.0)) + lhsc.append((term.name.encode(), 1.0)) else: raise Exception( f"Unknown term type: {term.__class__.__name__}" ) elif isinstance(expr, _GeneralVarData): - lhsc.append((expr.name, 1.0)) + lhsc.append((expr.name.encode(), 1.0)) else: raise Exception( f"Unknown expression type: {expr.__class__.__name__}" @@ -264,7 +262,7 @@ class BasePyomoSolver(InternalSolver): for index in var: if var[index].fixed: continue - solution[f"{var}[{index}]"] = var[index].value + solution[f"{var}[{index}]".encode()] = var[index].value return solution @overrides @@ -328,7 +326,7 @@ class BasePyomoSolver(InternalSolver): values.append(v.value) return Variables( - names=_none_if_empty(names), + names=_none_if_empty(np.array(names, dtype="S")), types=_none_if_empty(types), upper_bounds=_none_if_empty(np.array(upper_bounds, dtype=float)), lower_bounds=_none_if_empty(np.array(lower_bounds, dtype=float)), @@ -558,9 +556,9 @@ class BasePyomoSolver(InternalSolver): self._varname_to_var = {} for var in self.model.component_objects(Var): for idx in var: - varname = f"{var.name}[{idx}]" + varname = f"{var.name}[{idx}]".encode() if idx is None: - varname = var.name + varname = var.name.encode() self._varname_to_var[varname] = var[idx] self._all_vars += [var[idx]] if var[idx].domain == pyomo.core.base.set_types.Binary: diff --git a/miplearn/solvers/tests/__init__.py b/miplearn/solvers/tests/__init__.py index c92b07b..5bb070d 100644 --- a/miplearn/solvers/tests/__init__.py +++ b/miplearn/solvers/tests/__init__.py @@ -10,6 +10,7 @@ from miplearn.solvers.internal import InternalSolver, Variables, Constraints inf = float("inf") + # NOTE: # This file is in the main source folder, so that it can be called from Julia. @@ -40,7 +41,7 @@ def run_basic_usage_tests(solver: InternalSolver) -> None: assert_equals( solver.get_variables(), Variables( - names=["x[0]", "x[1]", "x[2]", "x[3]", "z"], + names=np.array(["x[0]", "x[1]", "x[2]", "x[3]", "z"], dtype="S"), lower_bounds=np.array([0.0, 0.0, 0.0, 0.0, 0.0]), upper_bounds=np.array([1.0, 1.0, 1.0, 1.0, 67.0]), types=["B", "B", "B", "B", "C"], @@ -56,11 +57,11 @@ def run_basic_usage_tests(solver: InternalSolver) -> None: rhs=np.array([0.0]), lhs=[ [ - ("x[0]", 23.0), - ("x[1]", 26.0), - ("x[2]", 20.0), - ("x[3]", 18.0), - ("z", -1.0), + (b"x[0]", 23.0), + (b"x[1]", 26.0), + (b"x[2]", 20.0), + (b"x[3]", 18.0), + (b"z", -1.0), ], ], senses=["="], @@ -83,7 +84,7 @@ def run_basic_usage_tests(solver: InternalSolver) -> None: _filter_attrs( solver.get_variable_attrs(), Variables( - names=["x[0]", "x[1]", "x[2]", "x[3]", "z"], + names=np.array(["x[0]", "x[1]", "x[2]", "x[3]", "z"], dtype="S"), basis_status=["U", "B", "U", "L", "U"], reduced_costs=np.array( [193.615385, 0.0, 187.230769, -23.692308, 13.538462] @@ -140,7 +141,7 @@ def run_basic_usage_tests(solver: InternalSolver) -> None: _filter_attrs( solver.get_variable_attrs(), Variables( - names=["x[0]", "x[1]", "x[2]", "x[3]", "z"], + names=np.array(["x[0]", "x[1]", "x[2]", "x[3]", "z"], dtype="S"), values=np.array([1.0, 0.0, 1.0, 1.0, 61.0]), ), ), @@ -161,7 +162,7 @@ def run_basic_usage_tests(solver: InternalSolver) -> None: # Build new constraint and verify that it is violated cf = Constraints( names=["cut"], - lhs=[[("x[0]", 1.0)]], + lhs=[[(b"x[0]", 1.0)]], rhs=np.array([0.0]), senses=["<"], ) @@ -178,14 +179,14 @@ def run_basic_usage_tests(solver: InternalSolver) -> None: rhs=np.array([0.0, 0.0]), lhs=[ [ - ("x[0]", 23.0), - ("x[1]", 26.0), - ("x[2]", 20.0), - ("x[3]", 18.0), - ("z", -1.0), + (b"x[0]", 23.0), + (b"x[1]", 26.0), + (b"x[2]", 20.0), + (b"x[3]", 18.0), + (b"z", -1.0), ], [ - ("x[0]", 1.0), + (b"x[0]", 1.0), ], ], senses=["=", "<"], @@ -208,16 +209,16 @@ def run_warm_start_tests(solver: InternalSolver) -> None: instance = solver.build_test_instance_knapsack() model = instance.to_model() solver.set_instance(instance, model) - solver.set_warm_start({"x[0]": 1.0, "x[1]": 0.0, "x[2]": 0.0, "x[3]": 1.0}) + solver.set_warm_start({b"x[0]": 1.0, b"x[1]": 0.0, b"x[2]": 0.0, b"x[3]": 1.0}) stats = solver.solve(tee=True) if stats.mip_warm_start_value is not None: assert_equals(stats.mip_warm_start_value, 725.0) - solver.set_warm_start({"x[0]": 1.0, "x[1]": 1.0, "x[2]": 1.0, "x[3]": 1.0}) + solver.set_warm_start({b"x[0]": 1.0, b"x[1]": 1.0, b"x[2]": 1.0, b"x[3]": 1.0}) stats = solver.solve(tee=True) assert stats.mip_warm_start_value is None - solver.fix({"x[0]": 1.0, "x[1]": 0.0, "x[2]": 0.0, "x[3]": 1.0}) + solver.fix({b"x[0]": 1.0, b"x[1]": 0.0, b"x[2]": 0.0, b"x[3]": 1.0}) stats = solver.solve(tee=True) assert_equals(stats.mip_lower_bound, 725.0) assert_equals(stats.mip_upper_bound, 725.0) @@ -257,15 +258,15 @@ def run_lazy_cb_tests(solver: InternalSolver) -> None: def lazy_cb(cb_solver: InternalSolver, cb_model: Any) -> None: relsol = cb_solver.get_solution() assert relsol is not None - assert relsol["x[0]"] is not None - if relsol["x[0]"] > 0: + assert relsol[b"x[0]"] is not None + if relsol[b"x[0]"] > 0: instance.enforce_lazy_constraint(cb_solver, cb_model, "cut") solver.set_instance(instance, model) solver.solve(lazy_cb=lazy_cb) solution = solver.get_solution() assert solution is not None - assert_equals(solution["x[0]"], 0.0) + assert_equals(solution[b"x[0]"], 0.0) def _equals_preprocess(obj: Any) -> Any: @@ -274,7 +275,7 @@ def _equals_preprocess(obj: Any) -> Any: return np.round(obj, decimals=6).tolist() else: return obj.tolist() - elif isinstance(obj, (int, str, bool, np.bool_)): + elif isinstance(obj, (int, str, bool, np.bool_, np.bytes_, bytes)): return obj elif isinstance(obj, float): return round(obj, 6) diff --git a/miplearn/types.py b/miplearn/types.py index 5239c4c..5d94163 100644 --- a/miplearn/types.py +++ b/miplearn/types.py @@ -15,8 +15,7 @@ IterationCallback = Callable[[], bool] LazyCallback = Callable[[Any, Any], None] SolverParams = Dict[str, Any] UserCutCallback = Callable[["InternalSolver", Any], None] -VariableName = str -Solution = Dict[VariableName, Optional[float]] +Solution = Dict[bytes, Optional[float]] LearningSolveStats = TypedDict( "LearningSolveStats", diff --git a/tests/components/test_primal.py b/tests/components/test_primal.py index 7672011..0f56d7b 100644 --- a/tests/components/test_primal.py +++ b/tests/components/test_primal.py @@ -22,7 +22,7 @@ from miplearn.solvers.tests import assert_equals def sample() -> Sample: sample = MemorySample( { - "static_var_names": ["x[0]", "x[1]", "x[2]", "x[3]"], + "static_var_names": np.array(["x[0]", "x[1]", "x[2]", "x[3]"], dtype="S"), "static_var_categories": ["default", None, "default", "default"], "mip_var_values": np.array([0.0, 1.0, 1.0, 0.0]), "static_instance_features": [5.0], @@ -112,10 +112,10 @@ def test_usage() -> None: def test_evaluate(sample: Sample) -> None: comp = PrimalSolutionComponent() comp.sample_predict = lambda _: { # type: ignore - "x[0]": 1.0, - "x[1]": 1.0, - "x[2]": 0.0, - "x[3]": None, + b"x[0]": 1.0, + b"x[1]": 1.0, + b"x[2]": 0.0, + b"x[3]": None, } ev = comp.sample_evaluate(None, sample) assert_equals( @@ -150,8 +150,8 @@ def test_predict(sample: Sample) -> None: assert_array_equal(x["default"], clf.predict_proba.call_args[0][0]) assert_array_equal(x["default"], thr.predict.call_args[0][0]) assert pred == { - "x[0]": 0.0, - "x[1]": None, - "x[2]": None, - "x[3]": 1.0, + b"x[0]": 0.0, + b"x[1]": None, + b"x[2]": None, + b"x[3]": 1.0, } diff --git a/tests/features/test_extractor.py b/tests/features/test_extractor.py index 2fc1633..bfda285 100644 --- a/tests/features/test_extractor.py +++ b/tests/features/test_extractor.py @@ -33,7 +33,7 @@ def test_knapsack() -> None: extractor.extract_after_load_features(instance, solver, sample) assert_equals( sample.get_vector("static_var_names"), - ["x[0]", "x[1]", "x[2]", "x[3]", "z"], + np.array(["x[0]", "x[1]", "x[2]", "x[3]", "z"], dtype="S"), ) assert_equals( sample.get_vector("static_var_lower_bounds"), [0.0, 0.0, 0.0, 0.0, 0.0] @@ -126,16 +126,16 @@ def test_constraint_getindex() -> None: senses=["=", "<", ">"], lhs=[ [ - ("x1", 1.0), - ("x2", 1.0), + (b"x1", 1.0), + (b"x2", 1.0), ], [ - ("x2", 2.0), - ("x3", 2.0), + (b"x2", 2.0), + (b"x3", 2.0), ], [ - ("x3", 3.0), - ("x4", 3.0), + (b"x3", 3.0), + (b"x4", 3.0), ], ], ) @@ -147,12 +147,12 @@ def test_constraint_getindex() -> None: senses=["=", ">"], lhs=[ [ - ("x1", 1.0), - ("x2", 1.0), + (b"x1", 1.0), + (b"x2", 1.0), ], [ - ("x3", 3.0), - ("x4", 3.0), + (b"x3", 3.0), + (b"x4", 3.0), ], ], ),