mirror of
https://github.com/ANL-CEEESA/MIPLearn.git
synced 2025-12-06 01:18:52 -06:00
Finish TSP implementation; improve performance of Extractors
This commit is contained in:
@@ -3,7 +3,6 @@
|
||||
# Released under the modified BSD license. See COPYING.md for more details.
|
||||
|
||||
from .extractors import (SolutionExtractor,
|
||||
CombinedExtractor,
|
||||
InstanceFeaturesExtractor,
|
||||
ObjectiveValueExtractor,
|
||||
VariableFeaturesExtractor,
|
||||
|
||||
@@ -27,10 +27,14 @@ class LazyConstraintsComponent(Component):
|
||||
|
||||
def __init__(self):
|
||||
self.violations = set()
|
||||
self.count = {}
|
||||
self.n_samples = 0
|
||||
|
||||
def before_solve(self, solver, instance, model):
|
||||
logger.info("Enforcing %d lazy constraints" % len(self.violations))
|
||||
for v in self.violations:
|
||||
if self.count[v] < self.n_samples * 0.05:
|
||||
continue
|
||||
cut = instance.build_lazy_constraint(model, v)
|
||||
solver.internal_solver.add_constraint(cut)
|
||||
|
||||
@@ -38,11 +42,16 @@ class LazyConstraintsComponent(Component):
|
||||
pass
|
||||
|
||||
def fit(self, training_instances):
|
||||
logger.debug("Fitting...")
|
||||
self.n_samples = len(training_instances)
|
||||
for instance in training_instances:
|
||||
if not hasattr(instance, "found_violations"):
|
||||
continue
|
||||
for v in instance.found_violations:
|
||||
self.violations.add(v)
|
||||
if v not in self.count.keys():
|
||||
self.count[v] = 0
|
||||
self.count[v] += 1
|
||||
|
||||
def predict(self, instance, model=None):
|
||||
return self.violations
|
||||
|
||||
@@ -31,12 +31,15 @@ class ObjectiveValueComponent(Component):
|
||||
pass
|
||||
|
||||
def fit(self, training_instances):
|
||||
logger.debug("Extracting features...")
|
||||
features = InstanceFeaturesExtractor().extract(training_instances)
|
||||
ub = ObjectiveValueExtractor(kind="upper bound").extract(training_instances)
|
||||
lb = ObjectiveValueExtractor(kind="lower bound").extract(training_instances)
|
||||
self.ub_regressor = deepcopy(self.regressor_prototype)
|
||||
self.lb_regressor = deepcopy(self.regressor_prototype)
|
||||
logger.debug("Fitting ub_regressor...")
|
||||
self.ub_regressor.fit(features, ub)
|
||||
logger.debug("Fitting ub_regressor...")
|
||||
self.lb_regressor.fit(features, lb)
|
||||
|
||||
def predict(self, instances):
|
||||
|
||||
@@ -129,7 +129,7 @@ class PrimalSolutionComponent(Component):
|
||||
self.dynamic_thresholds = dynamic_thresholds
|
||||
|
||||
def before_solve(self, solver, instance, model):
|
||||
solution = self.predict(instance, model)
|
||||
solution = self.predict(instance)
|
||||
if self.mode == "heuristic":
|
||||
solver.internal_solver.fix(solution)
|
||||
else:
|
||||
@@ -139,6 +139,7 @@ class PrimalSolutionComponent(Component):
|
||||
pass
|
||||
|
||||
def fit(self, training_instances):
|
||||
logger.debug("Extracting features...")
|
||||
features = VariableFeaturesExtractor().extract(training_instances)
|
||||
solutions = SolutionExtractor().extract(training_instances)
|
||||
|
||||
@@ -180,12 +181,10 @@ class PrimalSolutionComponent(Component):
|
||||
self.thresholds[category, label] = thresholds[k]
|
||||
|
||||
|
||||
def predict(self, instance, model=None):
|
||||
if model is None:
|
||||
model = instance.to_model()
|
||||
x_test = VariableFeaturesExtractor().extract([instance], [model])
|
||||
def predict(self, instance):
|
||||
x_test = VariableFeaturesExtractor().extract([instance])
|
||||
solution = {}
|
||||
var_split = Extractor.split_variables(instance, model)
|
||||
var_split = Extractor.split_variables(instance)
|
||||
for category in var_split.keys():
|
||||
for (i, (var, index)) in enumerate(var_split[category]):
|
||||
if var not in solution.keys():
|
||||
|
||||
@@ -27,29 +27,7 @@ def test_predict():
|
||||
instances, models = _get_instances()
|
||||
comp = PrimalSolutionComponent()
|
||||
comp.fit(instances)
|
||||
solution = comp.predict(instances[0], models[0])
|
||||
assert models[0].x in solution.keys()
|
||||
solution = comp.predict(instances[0])
|
||||
assert "x" in solution
|
||||
for idx in range(4):
|
||||
assert idx in solution[models[0].x].keys()
|
||||
|
||||
# def test_warm_start_save_load():
|
||||
# state_file = tempfile.NamedTemporaryFile(mode="r")
|
||||
# solver = LearningSolver(components={"warm-start": WarmStartComponent()})
|
||||
# solver.parallel_solve(_get_instances(), n_jobs=2)
|
||||
# solver.fit()
|
||||
# comp = solver.components["warm-start"]
|
||||
# assert comp.x_train["default"].shape == (8, 6)
|
||||
# assert comp.y_train["default"].shape == (8, 2)
|
||||
# assert ("default", 0) in comp.predictors.keys()
|
||||
# assert ("default", 1) in comp.predictors.keys()
|
||||
# solver.save_state(state_file.name)
|
||||
|
||||
# solver.solve(_get_instances()[0])
|
||||
|
||||
# solver = LearningSolver(components={"warm-start": WarmStartComponent()})
|
||||
# solver.load_state(state_file.name)
|
||||
# comp = solver.components["warm-start"]
|
||||
# assert comp.x_train["default"].shape == (8, 6)
|
||||
# assert comp.y_train["default"].shape == (8, 2)
|
||||
# assert ("default", 0) in comp.predictors.keys()
|
||||
# assert ("default", 1) in comp.predictors.keys()
|
||||
assert idx in solution["x"]
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
import numpy as np
|
||||
from abc import ABC, abstractmethod
|
||||
from pyomo.core import Var
|
||||
from tqdm.auto import tqdm, trange
|
||||
from p_tqdm import p_map
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Extractor(ABC):
|
||||
@@ -13,59 +17,39 @@ class Extractor(ABC):
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def split_variables(instance, model):
|
||||
def split_variables(instance):
|
||||
assert hasattr(instance, "lp_solution")
|
||||
result = {}
|
||||
for var in model.component_objects(Var):
|
||||
for index in var:
|
||||
category = instance.get_variable_category(var, index)
|
||||
for var_name in instance.lp_solution:
|
||||
for index in instance.lp_solution[var_name]:
|
||||
category = instance.get_variable_category(var_name, index)
|
||||
if category is None:
|
||||
continue
|
||||
if category not in result.keys():
|
||||
if category not in result:
|
||||
result[category] = []
|
||||
result[category] += [(var, index)]
|
||||
result[category] += [(var_name, index)]
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def merge(partial_results, vertical=False):
|
||||
results = {}
|
||||
all_categories = set()
|
||||
for pr in partial_results:
|
||||
all_categories |= pr.keys()
|
||||
for category in all_categories:
|
||||
results[category] = []
|
||||
for pr in partial_results:
|
||||
if category in pr.keys():
|
||||
results[category] += [pr[category]]
|
||||
if vertical:
|
||||
results[category] = np.vstack(results[category])
|
||||
else:
|
||||
results[category] = np.hstack(results[category])
|
||||
return results
|
||||
|
||||
|
||||
class VariableFeaturesExtractor(Extractor):
|
||||
def extract(self,
|
||||
instances,
|
||||
models=None,
|
||||
):
|
||||
def extract(self, instances):
|
||||
result = {}
|
||||
if models is None:
|
||||
models = [instance.to_model() for instance in instances]
|
||||
for (index, instance) in enumerate(instances):
|
||||
model = models[index]
|
||||
for instance in tqdm(instances,
|
||||
desc="Extract var features",
|
||||
disable=len(instances) < 5):
|
||||
instance_features = instance.get_instance_features()
|
||||
var_split = self.split_variables(instance, model)
|
||||
var_split = self.split_variables(instance)
|
||||
for (category, var_index_pairs) in var_split.items():
|
||||
if category not in result.keys():
|
||||
if category not in result:
|
||||
result[category] = []
|
||||
for (var, index) in var_index_pairs:
|
||||
result[category] += [np.hstack([
|
||||
instance_features,
|
||||
instance.get_variable_features(var, index),
|
||||
instance.lp_solution[str(var)][index],
|
||||
])]
|
||||
for category in result.keys():
|
||||
result[category] = np.vstack(result[category])
|
||||
for (var_name, index) in var_index_pairs:
|
||||
result[category] += [
|
||||
instance_features.tolist() + \
|
||||
instance.get_variable_features(var_name, index).tolist() + \
|
||||
[instance.lp_solution[var_name][index]]
|
||||
]
|
||||
for category in result:
|
||||
result[category] = np.array(result[category])
|
||||
return result
|
||||
|
||||
|
||||
@@ -73,39 +57,29 @@ class SolutionExtractor(Extractor):
|
||||
def __init__(self, relaxation=False):
|
||||
self.relaxation = relaxation
|
||||
|
||||
def extract(self, instances, models=None):
|
||||
def extract(self, instances):
|
||||
result = {}
|
||||
if models is None:
|
||||
models = [instance.to_model() for instance in instances]
|
||||
for (index, instance) in enumerate(instances):
|
||||
model = models[index]
|
||||
var_split = self.split_variables(instance, model)
|
||||
for instance in tqdm(instances,
|
||||
desc="Extract solution",
|
||||
disable=len(instances) < 5):
|
||||
var_split = self.split_variables(instance)
|
||||
for (category, var_index_pairs) in var_split.items():
|
||||
if category not in result.keys():
|
||||
if category not in result:
|
||||
result[category] = []
|
||||
for (var, index) in var_index_pairs:
|
||||
for (var_name, index) in var_index_pairs:
|
||||
if self.relaxation:
|
||||
v = instance.lp_solution[str(var)][index]
|
||||
v = instance.lp_solution[var_name][index]
|
||||
else:
|
||||
v = instance.solution[str(var)][index]
|
||||
v = instance.solution[var_name][index]
|
||||
if v is None:
|
||||
result[category] += [[0, 0]]
|
||||
else:
|
||||
result[category] += [[1 - v, v]]
|
||||
for category in result.keys():
|
||||
result[category] = np.vstack(result[category])
|
||||
for category in result:
|
||||
result[category] = np.array(result[category])
|
||||
return result
|
||||
|
||||
|
||||
class CombinedExtractor(Extractor):
|
||||
def __init__(self, extractors):
|
||||
self.extractors = extractors
|
||||
|
||||
def extract(self, instances, models):
|
||||
return self.merge([ex.extract(instances, models)
|
||||
for ex in self.extractors])
|
||||
|
||||
|
||||
class InstanceFeaturesExtractor(Extractor):
|
||||
def extract(self, instances, models=None):
|
||||
return np.vstack([
|
||||
|
||||
@@ -65,11 +65,12 @@ class Instance(ABC):
|
||||
|
||||
def get_variable_category(self, var, index):
|
||||
"""
|
||||
Returns the category (a string, an integer or any hashable type) for each decision variable.
|
||||
Returns the category (a string, an integer or any hashable type) for each decision
|
||||
variable.
|
||||
|
||||
If two variables have the same category, LearningSolver will use the same internal ML model
|
||||
to predict the values of both variables. By default, all variables belong to the "default"
|
||||
category, and therefore only one ML model is used for all variables.
|
||||
If two variables have the same category, LearningSolver will use the same internal ML
|
||||
model to predict the values of both variables. By default, all variables belong to the
|
||||
"default" category, and therefore only one ML model is used for all variables.
|
||||
|
||||
If the returned category is None, ML models will ignore the variable.
|
||||
"""
|
||||
@@ -99,10 +100,11 @@ class Instance(ABC):
|
||||
Returns a Pyomo constraint which fixes a given violation.
|
||||
|
||||
This method is typically called immediately after find_violations. The violation object
|
||||
provided by this method is exactly the same object returned earlier by find_violations.
|
||||
provided to this method is exactly the same object returned earlier by find_violations.
|
||||
After some training, LearningSolver may decide to proactively build some lazy constraints
|
||||
at the beginning of the optimization process, before a solution is even available. In this
|
||||
case, build_lazy_constraints will be called without a corresponding call to find_violations.
|
||||
case, build_lazy_constraints will be called without a corresponding call to
|
||||
find_violations.
|
||||
|
||||
The implementation should not directly add the constraint to the model. The constraint
|
||||
will be added by LearningSolver after the method returns.
|
||||
|
||||
@@ -25,6 +25,7 @@ class ChallengeA:
|
||||
n=randint(low=350, high=351),
|
||||
gamma=uniform(loc=0.95, scale=0.1),
|
||||
fix_cities=True,
|
||||
round=True,
|
||||
)
|
||||
|
||||
np.random.seed(seed + 1)
|
||||
@@ -126,13 +127,13 @@ class TravelingSalesmanInstance(Instance):
|
||||
self.distances = distances
|
||||
|
||||
def to_model(self):
|
||||
self.model = model = pe.ConcreteModel()
|
||||
self.edges = edges = [(i,j)
|
||||
model = pe.ConcreteModel()
|
||||
model.edges = edges = [(i,j)
|
||||
for i in range(self.n_cities)
|
||||
for j in range(i+1, self.n_cities)]
|
||||
model.x = pe.Var(edges, domain=pe.Binary)
|
||||
model.obj = pe.Objective(rule=lambda m : sum(m.x[i,j] * self.distances[i,j]
|
||||
for (i,j) in edges),
|
||||
model.obj = pe.Objective(expr=sum(model.x[i,j] * self.distances[i,j]
|
||||
for (i,j) in edges),
|
||||
sense=pe.minimize)
|
||||
model.eq_degree = pe.ConstraintList()
|
||||
model.eq_subtour = pe.ConstraintList()
|
||||
@@ -144,14 +145,14 @@ class TravelingSalesmanInstance(Instance):
|
||||
def get_instance_features(self):
|
||||
return np.array([1])
|
||||
|
||||
def get_variable_features(self, var, index):
|
||||
def get_variable_features(self, var_name, index):
|
||||
return np.array([1])
|
||||
|
||||
def get_variable_category(self, var, index):
|
||||
def get_variable_category(self, var_name, index):
|
||||
return index
|
||||
|
||||
def find_violations(self, model):
|
||||
selected_edges = [e for e in self.edges if model.x[e].value > 0.5]
|
||||
selected_edges = [e for e in model.edges if model.x[e].value > 0.5]
|
||||
graph = nx.Graph()
|
||||
graph.add_edges_from(selected_edges)
|
||||
components = [frozenset(c) for c in list(nx.connected_components(graph))]
|
||||
@@ -162,7 +163,7 @@ class TravelingSalesmanInstance(Instance):
|
||||
return violations
|
||||
|
||||
def build_lazy_constraint(self, model, component):
|
||||
cut_edges = [e for e in self.edges
|
||||
cut_edges = [e for e in model.edges
|
||||
if (e[0] in component and e[1] not in component) or
|
||||
(e[0] not in component and e[1] in component)]
|
||||
return model.eq_subtour.add(sum(model.x[e] for e in cut_edges) >= 2)
|
||||
|
||||
@@ -9,15 +9,36 @@ from copy import deepcopy
|
||||
import pickle
|
||||
from scipy.stats import randint
|
||||
from p_tqdm import p_map
|
||||
import numpy as np
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Global memory for multiprocessing
|
||||
SOLVER = [None]
|
||||
INSTANCES = [None]
|
||||
|
||||
|
||||
def _parallel_solve(instance_idx):
|
||||
solver = deepcopy(SOLVER[0])
|
||||
instance = INSTANCES[0][instance_idx]
|
||||
results = solver.solve(instance)
|
||||
return {
|
||||
"Results": results,
|
||||
"Solution": instance.solution,
|
||||
"LP solution": instance.lp_solution,
|
||||
"LP value": instance.lp_value,
|
||||
"Upper bound": instance.upper_bound,
|
||||
"Lower bound": instance.lower_bound,
|
||||
"Violations": instance.found_violations,
|
||||
}
|
||||
|
||||
|
||||
class InternalSolver:
|
||||
def __init__(self):
|
||||
self.is_warm_start_available = False
|
||||
self.model = None
|
||||
pass
|
||||
self.var_name_to_var = {}
|
||||
|
||||
def solve_lp(self, tee=False):
|
||||
self.solver.set_instance(self.model)
|
||||
@@ -58,33 +79,37 @@ class InternalSolver:
|
||||
solution[str(var)][index] = var[index].value
|
||||
return solution
|
||||
|
||||
def set_warm_start(self, ws):
|
||||
def set_warm_start(self, solution):
|
||||
self.is_warm_start_available = True
|
||||
self.clear_values()
|
||||
count_total, count_fixed = 0, 0
|
||||
for var in ws.keys():
|
||||
for index in var:
|
||||
for var_name in solution:
|
||||
var = self.var_name_to_var[var_name]
|
||||
for index in solution[var_name]:
|
||||
count_total += 1
|
||||
var[index].value = ws[var][index]
|
||||
if ws[var][index] is not None:
|
||||
var[index].value = solution[var_name][index]
|
||||
if solution[var_name][index] is not None:
|
||||
count_fixed += 1
|
||||
logger.info("Setting start values for %d variables (out of %d)" %
|
||||
(count_fixed, count_total))
|
||||
|
||||
|
||||
def set_model(self, model):
|
||||
self.model = model
|
||||
self.solver.set_instance(model)
|
||||
self.var_name_to_var = {}
|
||||
for var in model.component_objects(Var):
|
||||
self.var_name_to_var[var.name] = var
|
||||
|
||||
def fix(self, ws):
|
||||
def fix(self, solution):
|
||||
count_total, count_fixed = 0, 0
|
||||
for var in ws.keys():
|
||||
for index in var:
|
||||
for var_name in solution:
|
||||
for index in solution[var_name]:
|
||||
var = self.var_name_to_var[var_name]
|
||||
count_total += 1
|
||||
if ws[var][index] is None:
|
||||
if solution[var_name][index] is None:
|
||||
continue
|
||||
count_fixed += 1
|
||||
var[index].fix(ws[var][index])
|
||||
var[index].fix(solution[var_name][index])
|
||||
self.solver.update_var(var[index])
|
||||
logger.info("Fixing values for %d variables (out of %d)" %
|
||||
(count_fixed, count_total))
|
||||
@@ -287,29 +312,16 @@ class LearningSolver:
|
||||
label="Solve",
|
||||
collect_training_data=True,
|
||||
):
|
||||
|
||||
self.internal_solver = None
|
||||
|
||||
def _process(instance):
|
||||
solver = deepcopy(self)
|
||||
results = solver.solve(instance)
|
||||
solver.internal_solver = None
|
||||
if not collect_training_data:
|
||||
solver.components = {}
|
||||
return {
|
||||
"Solver": solver,
|
||||
"Results": results,
|
||||
"Solution": instance.solution,
|
||||
"LP solution": instance.lp_solution,
|
||||
"LP value": instance.lp_value,
|
||||
"Upper bound": instance.upper_bound,
|
||||
"Lower bound": instance.lower_bound,
|
||||
"Violations": instance.found_violations,
|
||||
}
|
||||
SOLVER[0] = self
|
||||
INSTANCES[0] = instances
|
||||
p_map_results = p_map(_parallel_solve,
|
||||
list(range(len(instances))),
|
||||
num_cpus=n_jobs,
|
||||
desc=label)
|
||||
|
||||
p_map_results = p_map(_process, instances, num_cpus=n_jobs, desc=label)
|
||||
subsolvers = [p["Solver"] for p in p_map_results]
|
||||
results = [p["Results"] for p in p_map_results]
|
||||
|
||||
for (idx, r) in enumerate(p_map_results):
|
||||
instances[idx].solution = r["Solution"]
|
||||
instances[idx].lp_solution = r["LP solution"]
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
from miplearn.problems.knapsack import KnapsackInstance
|
||||
from miplearn import (LearningSolver,
|
||||
SolutionExtractor,
|
||||
CombinedExtractor,
|
||||
InstanceFeaturesExtractor,
|
||||
VariableFeaturesExtractor,
|
||||
)
|
||||
@@ -33,7 +32,7 @@ def _get_instances():
|
||||
|
||||
def test_solution_extractor():
|
||||
instances, models = _get_instances()
|
||||
features = SolutionExtractor().extract(instances, models)
|
||||
features = SolutionExtractor().extract(instances)
|
||||
assert isinstance(features, dict)
|
||||
assert "default" in features.keys()
|
||||
assert isinstance(features["default"], np.ndarray)
|
||||
@@ -48,17 +47,6 @@ def test_solution_extractor():
|
||||
]
|
||||
|
||||
|
||||
def test_combined_extractor():
|
||||
instances, models = _get_instances()
|
||||
extractor = CombinedExtractor(extractors=[VariableFeaturesExtractor(),
|
||||
SolutionExtractor()])
|
||||
features = extractor.extract(instances, models)
|
||||
assert isinstance(features, dict)
|
||||
assert "default" in features.keys()
|
||||
assert isinstance(features["default"], np.ndarray)
|
||||
assert features["default"].shape == (6, 7)
|
||||
|
||||
|
||||
def test_instance_features_extractor():
|
||||
instances, models = _get_instances()
|
||||
features = InstanceFeaturesExtractor().extract(instances)
|
||||
|
||||
Reference in New Issue
Block a user