Finish TSP implementation; improve performance of Extractors

This commit is contained in:
2020-02-25 22:31:03 -06:00
parent b1f674fcc6
commit 0b04fa93da
33 changed files with 1347 additions and 679 deletions

View File

@@ -3,7 +3,6 @@
# Released under the modified BSD license. See COPYING.md for more details.
from .extractors import (SolutionExtractor,
CombinedExtractor,
InstanceFeaturesExtractor,
ObjectiveValueExtractor,
VariableFeaturesExtractor,

View File

@@ -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

View File

@@ -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):

View File

@@ -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():

View File

@@ -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"]

View File

@@ -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([

View File

@@ -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.

View File

@@ -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)

View File

@@ -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"]

View File

@@ -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)