Implement TSP generator and LazyConstraintsComponent

pull/1/head
Alinson S. Xavier 6 years ago committed by Alinson S Xavier
parent f713a399a8
commit 7a01d9cbcf
No known key found for this signature in database
GPG Key ID: A796166E4E218E02

@ -10,6 +10,7 @@ from .extractors import (SolutionExtractor,
) )
from .components.component import Component from .components.component import Component
from .components.objective import ObjectiveValueComponent from .components.objective import ObjectiveValueComponent
from .components.lazy import LazyConstraintsComponent
from .components.primal import (PrimalSolutionComponent, from .components.primal import (PrimalSolutionComponent,
AdaptivePredictor, AdaptivePredictor,
) )

@ -118,29 +118,3 @@ class BranchPriorityComponent(Component):
instance_features = instance.get_instance_features() instance_features = instance.get_instance_features()
var_features = instance.get_variable_features(var, index) var_features = instance.get_variable_features(var, index)
return np.hstack([instance_features, var_features]) return np.hstack([instance_features, var_features])
def merge(self, other_components):
keys = set(self.x_train.keys())
for comp in other_components:
self.pending_instances += comp.pending_instances
keys = keys.union(set(comp.x_train.keys()))
# Merge x_train and y_train
for key in keys:
x_train_submatrices = [comp.x_train[key]
for comp in other_components
if key in comp.x_train.keys()]
y_train_submatrices = [comp.y_train[key]
for comp in other_components
if key in comp.y_train.keys()]
if key in self.x_train.keys():
x_train_submatrices += [self.x_train[key]]
y_train_submatrices += [self.y_train[key]]
self.x_train[key] = np.vstack(x_train_submatrices)
self.y_train[key] = np.vstack(y_train_submatrices)
# Merge trained ML predictors
for comp in other_components:
for key in comp.predictors.keys():
if key not in self.predictors.keys():
self.predictors[key] = comp.predictors[key]

@ -18,10 +18,6 @@ class Component(ABC):
def after_solve(self, solver, instance, model): def after_solve(self, solver, instance, model):
pass pass
@abstractmethod
def merge(self, other):
pass
@abstractmethod @abstractmethod
def fit(self, training_instances): def fit(self, training_instances):
pass pass

@ -0,0 +1,48 @@
# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization
# Copyright (C) 2020, UChicago Argonne, LLC. All rights reserved.
# Released under the modified BSD license. See COPYING.md for more details.
from .component import Component
from ..extractors import *
from abc import ABC, abstractmethod
from copy import deepcopy
import numpy as np
from sklearn.pipeline import make_pipeline
from sklearn.linear_model import LogisticRegression
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import cross_val_score
from sklearn.metrics import roc_curve
from sklearn.neighbors import KNeighborsClassifier
from tqdm.auto import tqdm
import pyomo.environ as pe
import logging
logger = logging.getLogger(__name__)
class LazyConstraintsComponent(Component):
"""
A component that predicts which lazy constraints to enforce.
"""
def __init__(self):
self.violations = set()
def before_solve(self, solver, instance, model):
logger.info("Enforcing %d lazy constraints" % len(self.violations))
for v in self.violations:
cut = instance.build_lazy_constraint(model, v)
solver.internal_solver.add_constraint(cut)
def after_solve(self, solver, instance, model):
pass
def fit(self, training_instances):
for instance in training_instances:
if not hasattr(instance, "found_violations"):
continue
for v in instance.found_violations:
self.violations.add(v)
def predict(self, instance, model=None):
return self.violations

@ -30,9 +30,6 @@ class ObjectiveValueComponent(Component):
def after_solve(self, solver, instance, model): def after_solve(self, solver, instance, model):
pass pass
def merge(self, other):
pass
def fit(self, training_instances): def fit(self, training_instances):
features = InstanceFeaturesExtractor().extract(training_instances) features = InstanceFeaturesExtractor().extract(training_instances)
ub = ObjectiveValueExtractor(kind="upper bound").extract(training_instances) ub = ObjectiveValueExtractor(kind="upper bound").extract(training_instances)

@ -200,6 +200,3 @@ class PrimalSolutionComponent(Component):
if ws[i, 1] >= self.thresholds[category, label]: if ws[i, 1] >= self.thresholds[category, label]:
solution[var][index] = label solution[var][index] = label
return solution return solution
def merge(self, other_components):
pass

@ -0,0 +1,68 @@
# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization
# Copyright (C) 2020, UChicago Argonne, LLC. All rights reserved.
# Released under the modified BSD license. See COPYING.md for more details.
from miplearn import LearningSolver
from miplearn.problems.tsp import TravelingSalesmanGenerator, TravelingSalesmanInstance
import numpy as np
from numpy.linalg import norm
from scipy.spatial.distance import pdist, squareform
from scipy.stats import uniform, randint
def test_generator():
instances = TravelingSalesmanGenerator(x=uniform(loc=0.0, scale=1000.0),
y=uniform(loc=0.0, scale=1000.0),
n=randint(low=100, high=101),
gamma=uniform(loc=0.95, scale=0.1),
fix_cities=True).generate(100)
assert len(instances) == 100
assert instances[0].n_cities == 100
assert norm(instances[0].distances - instances[0].distances.T) < 1e-6
d = [instance.distances[0,1] for instance in instances]
assert np.std(d) > 0
def test_instance():
n_cities = 4
distances = np.array([
[0., 1., 2., 1.],
[1., 0., 1., 2.],
[2., 1., 0., 1.],
[1., 2., 1., 0.],
])
instance = TravelingSalesmanInstance(n_cities, distances)
solver = LearningSolver()
solver.solve(instance)
x = instance.solution["x"]
assert x[0,1] == 1.0
assert x[0,2] == 0.0
assert x[0,3] == 1.0
assert x[1,2] == 1.0
assert x[1,3] == 0.0
assert x[2,3] == 1.0
assert instance.lower_bound == 4.0
assert instance.upper_bound == 4.0
def test_subtour():
n_cities = 6
cities = np.array([
[0., 0.],
[1., 0.],
[2., 0.],
[3., 0.],
[0., 1.],
[3., 1.],
])
distances = squareform(pdist(cities))
instance = TravelingSalesmanInstance(n_cities, distances)
solver = LearningSolver()
solver.solve(instance)
x = instance.solution["x"]
assert x[0,1] == 1.0
assert x[0,4] == 1.0
assert x[1,2] == 1.0
assert x[2,3] == 1.0
assert x[3,5] == 1.0
assert x[4,5] == 1.0

@ -5,71 +5,109 @@
import numpy as np import numpy as np
import pyomo.environ as pe import pyomo.environ as pe
from miplearn import Instance from miplearn import Instance
from scipy.stats import uniform, randint
from scipy.spatial.distance import pdist, squareform
from scipy.stats.distributions import rv_frozen
import networkx as nx
import random import random
class TravelingSalesmanChallengeA: class ChallengeA:
"""Fixed set of cities, small perturbation to travel speed.""" def __init__(self,
def __init__(): seed=42,
self.generator = TravelingSalesmanGenerator(speed=uniform(loc=0.9, scale=0.2), n_training_instances=500,
x=uniform(loc=0.0, loc=1000.0), n_test_instances=50,
y=uniform(loc=0.0, loc=1000.0), ):
pn=0.0,
n=randint(low=100, high=100), np.random.seed(seed)
fix_cities=True) self.generator = TravelingSalesmanGenerator(x=uniform(loc=0.0, scale=1000.0),
y=uniform(loc=0.0, scale=1000.0),
n=randint(low=350, high=351),
gamma=uniform(loc=0.95, scale=0.1),
fix_cities=True,
)
def get_training_instances(): np.random.seed(seed + 1)
return self.generator.generate(500) self.training_instances = self.generator.generate(n_training_instances)
def get_test_instances(): np.random.seed(seed + 2)
return self.generator.generate(100) self.test_instances = self.generator.generate(n_test_instances)
class TravelingSalesmanGenerator: class TravelingSalesmanGenerator:
"""Random generator for the Traveling Salesman Problem. """Random generator for the Traveling Salesman Problem."""
The generator starts by randomly selecing n points with coordinates (x_i, y_i), where n, x_i def __init__(self,
and y_i are random variables. The time required to travel from a pair of cities is calculated x=uniform(loc=0.0, scale=1000.0),
by: (i) computing the euclidean distance between the cities, (ii) sampling a random variable y=uniform(loc=0.0, scale=1000.0),
speed_i, (iii) dividing the two numbers. n=randint(low=100, high=101),
gamma=uniform(loc=1.0, scale=0.0),
fix_cities=True,
round=True,
):
"""Initializes the problem generator.
If fix_cities is True, the cities and travel times will be calculated only once, during the Initially, the generator creates n cities (x_1,y_1),...,(x_n,y_n) where n, x_i and y_i are
constructor. Each time an instance is generated, however, each city will have probability pv sampled independently from the provided probability distributions `n`, `x` and `y`. For each
of being removed from the list. If fix_cities if False, then the cities and travel times will (unordered) pair of cities (i,j), the distance d[i,j] between them is set to:
be resampled each time an instance is generated. The probability pn is not used in this case.
All random variables are independent. d[i,j] = gamma[i,j] \sqrt{(x_i - x_j)^2 + (y_i - y_j)^2}
"""
def __init__(self, where gamma is sampled from the provided probability distribution `gamma`.
speed=uniform(loc=0.75, scale=0.5),
x=uniform(loc=0.0, loc=1000.0), If fix_cities=True, the list of cities is kept the same for all generated instances. The
y=uniform(loc=0.0, loc=1000.0), gamma values, and therefore also the distances, are still different.
pn=0.0,
n=randint(low=100, high=100), By default, all distances d[i,j] are rounded to the nearest integer. If `round=False`
fix_cities=True): is provided, this rounding will be disabled.
"""Initializes the problem generator.
Arguments Arguments
--------- ---------
speed: rv_continuous
Probability distribution for travel speed.
x: rv_continuous x: rv_continuous
Probability distribution for the x-coordinate of each city. Probability distribution for the x-coordinate of each city.
y: rv_continuous y: rv_continuous
Probability distribution for the y-coordinate of each city. Probability distribution for the y-coordinate of each city.
pn: float
Probability of a city being removed from the list. Only used if fix_cities=True.
n: rv_discrete n: rv_discrete
Probability distribution for the number of cities. Probability distribution for the number of cities.
fix_cities: bool fix_cities: bool
If true, cities will be resampled for every generated instance. Otherwise, list of If False, cities will be resampled for every generated instance. Otherwise, list of
cities will be computed once, during the constructor. cities will be computed once, during the constructor.
round: bool
If True, distances are rounded to the nearest integer.
""" """
pass assert isinstance(x, rv_frozen), "x should be a SciPy probability distribution"
assert isinstance(y, rv_frozen), "y should be a SciPy probability distribution"
assert isinstance(n, rv_frozen), "n should be a SciPy probability distribution"
assert isinstance(gamma, rv_frozen), "gamma should be a SciPy probability distribution"
self.x = x
self.y = y
self.n = n
self.gamma = gamma
self.round = round
if fix_cities:
self.fixed_n, self.fixed_cities = self._generate_cities()
else:
self.fixed_n = None
self.fixed_cities = None
def generate(self, n_samples): def generate(self, n_samples):
pass def _sample():
if self.fixed_cities is not None:
n, cities = self.fixed_n, self.fixed_cities
else:
n, cities = self._generate_cities()
distances = squareform(pdist(cities)) * self.gamma.rvs(size=(n, n))
distances = np.tril(distances) + np.triu(distances.T, 1)
if self.round:
distances = distances.round()
return TravelingSalesmanInstance(n, distances)
return [_sample() for _ in range(n_samples)]
def _generate_cities(self):
n = self.n.rvs()
cities = np.array([(self.x.rvs(), self.y.rvs()) for _ in range(n)])
return n, cities
class TravelingSalesmanInstance(Instance): class TravelingSalesmanInstance(Instance):
@ -82,9 +120,49 @@ class TravelingSalesmanInstance(Instance):
""" """
def __init__(self, n_cities, distances): def __init__(self, n_cities, distances):
assert isinstance(distances, np.array) assert isinstance(distances, np.ndarray)
assert distances.shape == (n_cities, n_cities) assert distances.shape == (n_cities, n_cities)
self.n_cities = n_cities self.n_cities = n_cities
self.distances = distances self.distances = distances
pass
def to_model(self):
self.model = model = pe.ConcreteModel()
self.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),
sense=pe.minimize)
model.eq_degree = pe.ConstraintList()
model.eq_subtour = pe.ConstraintList()
for i in range(self.n_cities):
model.eq_degree.add(sum(model.x[min(i,j), max(i,j)]
for j in range(self.n_cities) if i != j) == 2)
return model
def get_instance_features(self):
return np.array([1])
def get_variable_features(self, var, index):
return np.array([1])
def get_variable_category(self, var, index):
return index
def find_violations(self, model):
selected_edges = [e for e in self.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))]
violations = []
for c in components:
if len(c) < self.n_cities:
violations += [c]
return violations
def build_lazy_constraint(self, model, component):
cut_edges = [e for e in self.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)

@ -2,7 +2,7 @@
# Copyright (C) 2020, UChicago Argonne, LLC. All rights reserved. # Copyright (C) 2020, UChicago Argonne, LLC. All rights reserved.
# Released under the modified BSD license. See COPYING.md for more details. # Released under the modified BSD license. See COPYING.md for more details.
from . import ObjectiveValueComponent, PrimalSolutionComponent from . import ObjectiveValueComponent, PrimalSolutionComponent, LazyConstraintsComponent
import pyomo.environ as pe import pyomo.environ as pe
from pyomo.core import Var from pyomo.core import Var
from copy import deepcopy from copy import deepcopy
@ -89,6 +89,9 @@ class InternalSolver:
logger.info("Fixing values for %d variables (out of %d)" % logger.info("Fixing values for %d variables (out of %d)" %
(count_fixed, count_total)) (count_fixed, count_total))
def add_constraint(self, cut):
self.solver.add_constraint(cut)
class GurobiSolver(InternalSolver): class GurobiSolver(InternalSolver):
def __init__(self): def __init__(self):
@ -198,6 +201,7 @@ class LearningSolver:
self.components = { self.components = {
"ObjectiveValue": ObjectiveValueComponent(), "ObjectiveValue": ObjectiveValueComponent(),
"PrimalSolution": PrimalSolutionComponent(), "PrimalSolution": PrimalSolutionComponent(),
"LazyConstraints": LazyConstraintsComponent(),
} }
assert self.mode in ["exact", "heuristic"] assert self.mode in ["exact", "heuristic"]
@ -231,27 +235,44 @@ class LearningSolver:
self.internal_solver = self._create_internal_solver() self.internal_solver = self._create_internal_solver()
self.internal_solver.set_model(model) self.internal_solver.set_model(model)
# Solve LP relaxation logger.debug("Solving LP relaxation...")
results = self.internal_solver.solve_lp(tee=tee) results = self.internal_solver.solve_lp(tee=tee)
instance.lp_solution = self.internal_solver.get_solution() instance.lp_solution = self.internal_solver.get_solution()
instance.lp_value = results["Optimal value"] instance.lp_value = results["Optimal value"]
# Invoke before_solve callbacks logger.debug("Running before_solve callbacks...")
for component in self.components.values(): for component in self.components.values():
component.before_solve(self, instance, model) component.before_solve(self, instance, model)
if relaxation_only: if relaxation_only:
return results return results
# Solver original MIP total_wallclock_time = 0
results = self.internal_solver.solve(tee=tee) instance.found_violations = []
while True:
logger.debug("Solving MIP...")
results = self.internal_solver.solve(tee=tee)
logger.debug(" %.2f s" % results["Wallclock time"])
total_wallclock_time += results["Wallclock time"]
if not hasattr(instance, "find_violations"):
break
logger.debug("Finding violated constraints...")
violations = instance.find_violations(model)
if len(violations) == 0:
break
instance.found_violations += violations
logger.debug(" %d violations found" % len(violations))
for v in violations:
cut = instance.build_lazy_constraint(model, v)
self.internal_solver.add_constraint(cut)
results["Wallclock time"] = total_wallclock_time
# Read MIP solution and bounds # Read MIP solution and bounds
instance.lower_bound = results["Lower bound"] instance.lower_bound = results["Lower bound"]
instance.upper_bound = results["Upper bound"] instance.upper_bound = results["Upper bound"]
instance.solution = self.internal_solver.get_solution() instance.solution = self.internal_solver.get_solution()
# Invoke after_solve callbacks logger.debug("Calling after_solve callbacks...")
for component in self.components.values(): for component in self.components.values():
component.after_solve(self, instance, model) component.after_solve(self, instance, model)
@ -282,6 +303,7 @@ class LearningSolver:
"LP value": instance.lp_value, "LP value": instance.lp_value,
"Upper bound": instance.upper_bound, "Upper bound": instance.upper_bound,
"Lower bound": instance.lower_bound, "Lower bound": instance.lower_bound,
"Violations": instance.found_violations,
} }
p_map_results = p_map(_process, instances, num_cpus=n_jobs, desc=label) p_map_results = p_map(_process, instances, num_cpus=n_jobs, desc=label)
@ -294,12 +316,7 @@ class LearningSolver:
instances[idx].lp_value = r["LP value"] instances[idx].lp_value = r["LP value"]
instances[idx].lower_bound = r["Lower bound"] instances[idx].lower_bound = r["Lower bound"]
instances[idx].upper_bound = r["Upper bound"] instances[idx].upper_bound = r["Upper bound"]
instances[idx].found_violations = r["Violations"]
for (name, component) in self.components.items():
subcomponents = [subsolver.components[name]
for subsolver in subsolvers
if name in subsolver.components.keys()]
self.components[name].merge(subcomponents)
return results return results

Loading…
Cancel
Save