diff --git a/miplearn/components/primal.py b/miplearn/components/primal.py index 00c676e..3237fdd 100644 --- a/miplearn/components/primal.py +++ b/miplearn/components/primal.py @@ -104,7 +104,7 @@ class PrimalSolutionComponent(Component): def sample_predict(self, sample: Sample) -> Solution: assert sample.after_load is not None - assert sample.after_load.variables is not None + assert sample.after_load.variables_old is not None # Compute y_pred x, _ = self.sample_xy(None, sample) @@ -125,9 +125,9 @@ class PrimalSolutionComponent(Component): ).T # Convert y_pred into solution - solution: Solution = {v: None for v in sample.after_load.variables.keys()} + solution: Solution = {v: None for v in sample.after_load.variables_old.keys()} category_offset: Dict[Hashable, int] = {cat: 0 for cat in x.keys()} - for (var_name, var_features) in sample.after_load.variables.items(): + for (var_name, var_features) in sample.after_load.variables_old.items(): category = var_features.category if category not in category_offset: continue @@ -150,8 +150,8 @@ class PrimalSolutionComponent(Component): y: Dict = {} assert sample.after_load is not None assert sample.after_load.instance is not None - assert sample.after_load.variables is not None - for (var_name, var) in sample.after_load.variables.items(): + assert sample.after_load.variables_old is not None + for (var_name, var) in sample.after_load.variables_old.items(): # Initialize categories category = var.category if category is None: @@ -162,17 +162,17 @@ class PrimalSolutionComponent(Component): # Features features = list(sample.after_load.instance.to_list()) - features.extend(sample.after_load.variables[var_name].to_list()) + features.extend(sample.after_load.variables_old[var_name].to_list()) if sample.after_lp is not None: - assert sample.after_lp.variables is not None - features.extend(sample.after_lp.variables[var_name].to_list()) + assert sample.after_lp.variables_old is not None + features.extend(sample.after_lp.variables_old[var_name].to_list()) x[category].append(features) # Labels if sample.after_mip is not None: - assert sample.after_mip.variables is not None - assert sample.after_mip.variables[var_name] is not None - opt_value = sample.after_mip.variables[var_name].value + assert sample.after_mip.variables_old is not None + assert sample.after_mip.variables_old[var_name] is not None + opt_value = sample.after_mip.variables_old[var_name].value assert opt_value is not None assert 0.0 - 1e-5 <= opt_value <= 1.0 + 1e-5, ( f"Variable {var_name} has non-binary value {opt_value} in the " @@ -190,9 +190,9 @@ class PrimalSolutionComponent(Component): sample: Sample, ) -> Dict[Hashable, Dict[str, float]]: assert sample.after_mip is not None - assert sample.after_mip.variables is not None + assert sample.after_mip.variables_old is not None - solution_actual = sample.after_mip.variables + solution_actual = sample.after_mip.variables_old solution_pred = self.sample_predict(sample) vars_all, vars_one, vars_zero = set(), set(), set() pred_one_positive, pred_zero_positive = set(), set() diff --git a/miplearn/features.py b/miplearn/features.py index aedc3ad..d6cded8 100644 --- a/miplearn/features.py +++ b/miplearn/features.py @@ -34,7 +34,7 @@ class InstanceFeatures: class VariableFeatures: names: Optional[Tuple[str, ...]] = None basis_status: Optional[Tuple[str, ...]] = None - categories: Optional[Tuple[Hashable, ...]] = None + categories: Optional[Tuple[Optional[Hashable], ...]] = None lower_bounds: Optional[Tuple[float, ...]] = None obj_coeffs: Optional[Tuple[float, ...]] = None reduced_costs: Optional[Tuple[float, ...]] = None @@ -46,7 +46,7 @@ class VariableFeatures: sa_ub_up: Optional[Tuple[float, ...]] = None types: Optional[Tuple[str, ...]] = None upper_bounds: Optional[Tuple[float, ...]] = None - user_features: Optional[Tuple[Tuple[float, ...]]] = None + user_features: Optional[Tuple[Optional[Tuple[float, ...]], ...]] = None values: Optional[Tuple[float, ...]] = None @@ -135,7 +135,8 @@ class Constraint: @dataclass class Features: instance: Optional[InstanceFeatures] = None - variables: Optional[Dict[str, Variable]] = None + variables: Optional[VariableFeatures] = None + variables_old: Optional[Dict[str, Variable]] = None constraints: Optional[Dict[str, Constraint]] = None lp_solve: Optional["LPSolveStats"] = None mip_solve: Optional["MIPSolveStats"] = None @@ -153,8 +154,10 @@ class FeaturesExtractor: def __init__( self, internal_solver: "InternalSolver", + with_sa: bool = True, ) -> None: self.solver = internal_solver + self.with_sa = with_sa def extract( self, @@ -162,7 +165,11 @@ class FeaturesExtractor: with_static: bool = True, ) -> Features: features = Features() - features.variables = self.solver.get_variables_old( + features.variables = self.solver.get_variables( + with_static=with_static, + with_sa=self.with_sa, + ) + features.variables_old = self.solver.get_variables_old( with_static=with_static, ) features.constraints = self.solver.get_constraints( @@ -170,18 +177,19 @@ class FeaturesExtractor: ) if with_static: self._extract_user_features_vars(instance, features) + self._extract_user_features_vars_old(instance, features) self._extract_user_features_constrs(instance, features) self._extract_user_features_instance(instance, features) self._extract_alvarez_2017(features) return features - def _extract_user_features_vars( + def _extract_user_features_vars_old( self, instance: "Instance", features: Features, ) -> None: - assert features.variables is not None - for (var_name, var) in features.variables.items(): + assert features.variables_old is not None + for (var_name, var) in features.variables_old.items(): user_features: Optional[List[float]] = None category: Category = instance.get_variable_category(var_name) if category is not None: @@ -206,6 +214,45 @@ class FeaturesExtractor: var.category = category var.user_features = user_features + def _extract_user_features_vars( + self, + instance: "Instance", + features: Features, + ) -> None: + assert features.variables is not None + assert features.variables.names is not None + categories: List[Hashable] = [] + user_features: List[Optional[Tuple[float, ...]]] = [] + for (i, var_name) in enumerate(features.variables.names): + category: Hashable = instance.get_variable_category(var_name) + user_features_i: Optional[List[float]] = None + if category is not None: + assert isinstance(category, collections.Hashable), ( + f"Variable category must be be hashable. " + f"Found {type(category).__name__} instead for var={var_name}." + ) + user_features_i = instance.get_variable_features(var_name) + if isinstance(user_features_i, np.ndarray): + user_features_i = user_features_i.tolist() + assert isinstance(user_features_i, list), ( + f"Variable features must be a list. " + f"Found {type(user_features_i).__name__} instead for " + f"var={var_name}." + ) + for v in user_features_i: + assert isinstance(v, numbers.Real), ( + f"Variable features must be a list of numbers. " + f"Found {type(v).__name__} instead " + f"for var={var_name}." + ) + categories.append(category) + if user_features_i is None: + user_features.append(None) + else: + user_features.append(tuple(user_features_i)) + features.variables.categories = tuple(categories) + features.variables.user_features = tuple(user_features) + def _extract_user_features_constrs( self, instance: "Instance", @@ -265,18 +312,18 @@ class FeaturesExtractor: ) def _extract_alvarez_2017(self, features: Features) -> None: - assert features.variables is not None + assert features.variables_old is not None pos_obj_coeff_sum = 0.0 neg_obj_coeff_sum = 0.0 - for (varname, var) in features.variables.items(): + for (varname, var) in features.variables_old.items(): if var.obj_coeff is not None: if var.obj_coeff > 0: pos_obj_coeff_sum += var.obj_coeff if var.obj_coeff < 0: neg_obj_coeff_sum += -var.obj_coeff - for (varname, var) in features.variables.items(): + for (varname, var) in features.variables_old.items(): assert isinstance(var, Variable) f: List[float] = [] if var.obj_coeff is not None: diff --git a/miplearn/solvers/gurobi.py b/miplearn/solvers/gurobi.py index 26bf1d0..ce2babf 100644 --- a/miplearn/solvers/gurobi.py +++ b/miplearn/solvers/gurobi.py @@ -393,13 +393,12 @@ class GurobiSolver(InternalSolver): else: raise Exception(f"unknown vbasis: {basis_status}") - names, upper_bounds, lower_bounds, types, values = None, None, None, None, None + upper_bounds, lower_bounds, types, values = None, None, None, None obj_coeffs, reduced_costs, basis_status = None, None, None sa_obj_up, sa_ub_up, sa_lb_up = None, None, None sa_obj_down, sa_ub_down, sa_lb_down = None, None, None if with_static: - names = self._var_names upper_bounds = self._var_ubs lower_bounds = self._var_lbs types = self._var_types @@ -426,7 +425,7 @@ class GurobiSolver(InternalSolver): values = tuple(model.getAttr("x", self._gp_vars)) return VariableFeatures( - names=names, + names=self._var_names, upper_bounds=upper_bounds, lower_bounds=lower_bounds, types=types, diff --git a/miplearn/solvers/pyomo/base.py b/miplearn/solvers/pyomo/base.py index 948dda4..c279bc4 100644 --- a/miplearn/solvers/pyomo/base.py +++ b/miplearn/solvers/pyomo/base.py @@ -210,13 +210,13 @@ class BasePyomoSolver(InternalSolver): for idx in var: v = var[idx] - if with_static: - # Variable name - if idx is None: - names.append(str(var)) - else: - names.append(f"{var}[{idx}]") + # Variable name + if idx is None: + names.append(str(var)) + else: + names.append(f"{var}[{idx}]") + if with_static: # Variable type if v.domain == pyomo.core.Binary: types.append("B") @@ -250,7 +250,6 @@ class BasePyomoSolver(InternalSolver): if self._has_lp_solution or self._has_mip_solution: values.append(v.value) - names_t: Optional[Tuple[str, ...]] = None types_t: Optional[Tuple[str, ...]] = None upper_bounds_t: Optional[Tuple[float, ...]] = None lower_bounds_t: Optional[Tuple[float, ...]] = None @@ -259,7 +258,6 @@ class BasePyomoSolver(InternalSolver): values_t: Optional[Tuple[float, ...]] = None if with_static: - names_t = tuple(names) types_t = tuple(types) upper_bounds_t = tuple(upper_bounds) lower_bounds_t = tuple(lower_bounds) @@ -272,7 +270,7 @@ class BasePyomoSolver(InternalSolver): values_t = tuple(values) return VariableFeatures( - names=names_t, + names=tuple(names), types=types_t, upper_bounds=upper_bounds_t, lower_bounds=lower_bounds_t, diff --git a/miplearn/solvers/tests/__init__.py b/miplearn/solvers/tests/__init__.py index d765b87..b286565 100644 --- a/miplearn/solvers/tests/__init__.py +++ b/miplearn/solvers/tests/__init__.py @@ -138,6 +138,7 @@ def run_basic_usage_tests(solver: InternalSolver) -> None: _filter_attrs( solver.get_variable_attrs(), VariableFeatures( + names=("x[0]", "x[1]", "x[2]", "x[3]", "z"), basis_status=("U", "B", "U", "L", "U"), reduced_costs=(193.615385, 0.0, 187.230769, -23.692308, 13.538462), sa_lb_down=(-inf, -inf, -inf, -0.111111, -inf), @@ -200,7 +201,10 @@ def run_basic_usage_tests(solver: InternalSolver) -> None: _round(solver.get_variables(with_static=False)), _filter_attrs( solver.get_variable_attrs(), - VariableFeatures(values=(1.0, 0.0, 1.0, 1.0, 61.0)), + VariableFeatures( + names=("x[0]", "x[1]", "x[2]", "x[3]", "z"), + values=(1.0, 0.0, 1.0, 1.0, 61.0), + ), ), ) diff --git a/tests/components/test_primal.py b/tests/components/test_primal.py index 46d4a52..2c98043 100644 --- a/tests/components/test_primal.py +++ b/tests/components/test_primal.py @@ -28,7 +28,7 @@ def sample() -> Sample: sample = Sample( after_load=Features( instance=InstanceFeatures(), - variables={ + variables_old={ "x[0]": Variable(category="default"), "x[1]": Variable(category=None), "x[2]": Variable(category="default"), @@ -36,7 +36,7 @@ def sample() -> Sample: }, ), after_lp=Features( - variables={ + variables_old={ "x[0]": Variable(), "x[1]": Variable(), "x[2]": Variable(), @@ -44,7 +44,7 @@ def sample() -> Sample: }, ), after_mip=Features( - variables={ + variables_old={ "x[0]": Variable(value=0.0), "x[1]": Variable(value=1.0), "x[2]": Variable(value=1.0), @@ -53,13 +53,13 @@ def sample() -> Sample: ), ) sample.after_load.instance.to_list = Mock(return_value=[5.0]) # type: ignore - sample.after_lp.variables["x[0]"].to_list = Mock( # type: ignore + sample.after_lp.variables_old["x[0]"].to_list = Mock( # type: ignore return_value=[0.0, 0.0] ) - sample.after_lp.variables["x[2]"].to_list = Mock( # type: ignore + sample.after_lp.variables_old["x[2]"].to_list = Mock( # type: ignore return_value=[1.0, 0.0] ) - sample.after_lp.variables["x[3]"].to_list = Mock( # type: ignore + sample.after_lp.variables_old["x[3]"].to_list = Mock( # type: ignore return_value=[1.0, 1.0] ) return sample diff --git a/tests/problems/test_tsp.py b/tests/problems/test_tsp.py index e49531c..4f18880 100644 --- a/tests/problems/test_tsp.py +++ b/tests/problems/test_tsp.py @@ -43,13 +43,13 @@ def test_instance() -> None: assert instance.samples[0].after_mip is not None features = instance.samples[0].after_mip assert features is not None - assert features.variables is not None - assert features.variables["x[(0, 1)]"].value == 1.0 - assert features.variables["x[(0, 2)]"].value == 0.0 - assert features.variables["x[(0, 3)]"].value == 1.0 - assert features.variables["x[(1, 2)]"].value == 1.0 - assert features.variables["x[(1, 3)]"].value == 0.0 - assert features.variables["x[(2, 3)]"].value == 1.0 + assert features.variables_old is not None + assert features.variables_old["x[(0, 1)]"].value == 1.0 + assert features.variables_old["x[(0, 2)]"].value == 0.0 + assert features.variables_old["x[(0, 3)]"].value == 1.0 + assert features.variables_old["x[(1, 2)]"].value == 1.0 + assert features.variables_old["x[(1, 3)]"].value == 0.0 + assert features.variables_old["x[(2, 3)]"].value == 1.0 assert features.mip_solve is not None assert features.mip_solve.mip_lower_bound == 4.0 assert features.mip_solve.mip_upper_bound == 4.0 @@ -79,12 +79,12 @@ def test_subtour() -> None: lazy_enforced = features.extra["lazy_enforced"] assert lazy_enforced is not None assert len(lazy_enforced) > 0 - assert features.variables is not None - assert features.variables["x[(0, 1)]"].value == 1.0 - assert features.variables["x[(0, 4)]"].value == 1.0 - assert features.variables["x[(1, 2)]"].value == 1.0 - assert features.variables["x[(2, 3)]"].value == 1.0 - assert features.variables["x[(3, 5)]"].value == 1.0 - assert features.variables["x[(4, 5)]"].value == 1.0 + assert features.variables_old is not None + assert features.variables_old["x[(0, 1)]"].value == 1.0 + assert features.variables_old["x[(0, 4)]"].value == 1.0 + assert features.variables_old["x[(1, 2)]"].value == 1.0 + assert features.variables_old["x[(2, 3)]"].value == 1.0 + assert features.variables_old["x[(3, 5)]"].value == 1.0 + assert features.variables_old["x[(4, 5)]"].value == 1.0 solver.fit([instance]) solver.solve(instance) diff --git a/tests/solvers/test_learning_solver.py b/tests/solvers/test_learning_solver.py index 3060871..dffe97b 100644 --- a/tests/solvers/test_learning_solver.py +++ b/tests/solvers/test_learning_solver.py @@ -39,12 +39,12 @@ def test_learning_solver( after_mip = sample.after_mip assert after_mip is not None - assert after_mip.variables is not None + assert after_mip.variables_old is not None assert after_mip.mip_solve is not None - assert after_mip.variables["x[0]"].value == 1.0 - assert after_mip.variables["x[1]"].value == 0.0 - assert after_mip.variables["x[2]"].value == 1.0 - assert after_mip.variables["x[3]"].value == 1.0 + assert after_mip.variables_old["x[0]"].value == 1.0 + assert after_mip.variables_old["x[1]"].value == 0.0 + assert after_mip.variables_old["x[2]"].value == 1.0 + assert after_mip.variables_old["x[3]"].value == 1.0 assert after_mip.mip_solve.mip_lower_bound == 1183.0 assert after_mip.mip_solve.mip_upper_bound == 1183.0 assert after_mip.mip_solve.mip_log is not None @@ -52,16 +52,16 @@ def test_learning_solver( after_lp = sample.after_lp assert after_lp is not None - assert after_lp.variables is not None + assert after_lp.variables_old is not None assert after_lp.lp_solve is not None - assert after_lp.variables["x[0]"].value is not None - assert after_lp.variables["x[1]"].value is not None - assert after_lp.variables["x[2]"].value is not None - assert after_lp.variables["x[3]"].value is not None - assert round(after_lp.variables["x[0]"].value, 3) == 1.000 - assert round(after_lp.variables["x[1]"].value, 3) == 0.923 - assert round(after_lp.variables["x[2]"].value, 3) == 1.000 - assert round(after_lp.variables["x[3]"].value, 3) == 0.000 + assert after_lp.variables_old["x[0]"].value is not None + assert after_lp.variables_old["x[1]"].value is not None + assert after_lp.variables_old["x[2]"].value is not None + assert after_lp.variables_old["x[3]"].value is not None + assert round(after_lp.variables_old["x[0]"].value, 3) == 1.000 + assert round(after_lp.variables_old["x[1]"].value, 3) == 0.923 + assert round(after_lp.variables_old["x[2]"].value, 3) == 1.000 + assert round(after_lp.variables_old["x[3]"].value, 3) == 0.000 assert after_lp.lp_solve.lp_value is not None assert round(after_lp.lp_solve.lp_value, 3) == 1287.923 assert after_lp.lp_solve.lp_log is not None diff --git a/tests/test_features.py b/tests/test_features.py index bb0d4f8..412a206 100644 --- a/tests/test_features.py +++ b/tests/test_features.py @@ -7,9 +7,15 @@ from miplearn.features import ( InstanceFeatures, Variable, Constraint, + VariableFeatures, ) from miplearn.solvers.gurobi import GurobiSolver -from miplearn.solvers.tests import assert_equals, _round_variables, _round_constraints +from miplearn.solvers.tests import ( + assert_equals, + _round_variables, + _round_constraints, + _round, +) inf = float("inf") @@ -22,113 +28,36 @@ def test_knapsack() -> None: solver.solve_lp() features = FeaturesExtractor(solver).extract(instance) - assert features.variables is not None + assert features.variables_old is not None assert features.constraints is not None assert features.instance is not None assert_equals( - _round_variables(features.variables), - { - "x[0]": Variable( - basis_status="U", - category="default", - lower_bound=0.0, - obj_coeff=505.0, - reduced_cost=193.615385, - sa_lb_down=-inf, - sa_lb_up=1.0, - sa_obj_down=311.384615, - sa_obj_up=inf, - sa_ub_down=0.913043, - sa_ub_up=2.043478, - type="B", - upper_bound=1.0, - user_features=[23.0, 505.0], - value=1.0, - alvarez_2017=[1.0, 0.32899, 0.0, 0.0, 1.0, 1.0, 5.265874, 46.051702], - ), - "x[1]": Variable( - basis_status="B", - category="default", - lower_bound=0.0, - obj_coeff=352.0, - reduced_cost=0.0, - sa_lb_down=-inf, - sa_lb_up=0.923077, - sa_obj_down=317.777778, - sa_obj_up=570.869565, - sa_ub_down=0.923077, - sa_ub_up=inf, - type="B", - upper_bound=1.0, - user_features=[26.0, 352.0], - value=0.923077, - alvarez_2017=[ - 1.0, - 0.229316, - 0.0, - 0.076923, - 1.0, - 1.0, - 3.532875, - 5.388476, - ], - ), - "x[2]": Variable( - basis_status="U", - category="default", - lower_bound=0.0, - obj_coeff=458.0, - reduced_cost=187.230769, - sa_lb_down=-inf, - sa_lb_up=1.0, - sa_obj_down=270.769231, - sa_obj_up=inf, - sa_ub_down=0.9, - sa_ub_up=2.2, - type="B", - upper_bound=1.0, - user_features=[20.0, 458.0], - value=1.0, - alvarez_2017=[1.0, 0.298371, 0.0, 0.0, 1.0, 1.0, 5.232342, 46.051702], + _round(features.variables), + VariableFeatures( + names=("x[0]", "x[1]", "x[2]", "x[3]", "z"), + basis_status=("U", "B", "U", "L", "U"), + categories=("default", "default", "default", "default", None), + lower_bounds=(0.0, 0.0, 0.0, 0.0, 0.0), + obj_coeffs=(505.0, 352.0, 458.0, 220.0, 0.0), + reduced_costs=(193.615385, 0.0, 187.230769, -23.692308, 13.538462), + sa_lb_down=(-inf, -inf, -inf, -0.111111, -inf), + sa_lb_up=(1.0, 0.923077, 1.0, 1.0, 67.0), + sa_obj_down=(311.384615, 317.777778, 270.769231, -inf, -13.538462), + sa_obj_up=(inf, 570.869565, inf, 243.692308, inf), + sa_ub_down=(0.913043, 0.923077, 0.9, 0.0, 43.0), + sa_ub_up=(2.043478, inf, 2.2, inf, 69.0), + types=("B", "B", "B", "B", "C"), + upper_bounds=(1.0, 1.0, 1.0, 1.0, 67.0), + user_features=( + (23.0, 505.0), + (26.0, 352.0), + (20.0, 458.0), + (18.0, 220.0), + None, ), - "x[3]": Variable( - basis_status="L", - category="default", - lower_bound=0.0, - obj_coeff=220.0, - reduced_cost=-23.692308, - sa_lb_down=-0.111111, - sa_lb_up=1.0, - sa_obj_down=-inf, - sa_obj_up=243.692308, - sa_ub_down=0.0, - sa_ub_up=inf, - type="B", - upper_bound=1.0, - user_features=[18.0, 220.0], - value=0.0, - alvarez_2017=[1.0, 0.143322, 0.0, 0.0, 1.0, -1.0, 46.051702, 3.16515], - ), - "z": Variable( - basis_status="U", - category=None, - lower_bound=0.0, - obj_coeff=0.0, - reduced_cost=13.538462, - sa_lb_down=-inf, - sa_lb_up=67.0, - sa_obj_down=-13.538462, - sa_obj_up=inf, - sa_ub_down=43.0, - sa_ub_up=69.0, - type="C", - upper_bound=67.0, - user_features=None, - value=67.0, - alvarez_2017=[0.0, 0.0, 0.0, 0.0, 1.0, -1.0, 0.0, 0.0], - ), - }, + values=(1.0, 0.923077, 1.0, 0.0, 67.0), + ), ) assert_equals( _round_constraints(features.constraints),