Implement TravelingSalesmanPerturber

This commit is contained in:
2025-12-08 15:10:24 -06:00
parent 4137378bb8
commit 1d44980a7b
23 changed files with 128 additions and 90 deletions

View File

@@ -1108,15 +1108,18 @@
"source": [
"### Random instance generator\n",
"\n",
"The class [TravelingSalesmanGenerator][TravelingSalesmanGenerator] can be used to generate random instances of this problem. Initially, the class samples the user-provided probability distribution `n` to decide how many cities to generate. Then, for each city $i$, the class generates its geographical location $(x_i, y_i)$ by sampling the provided distributions `x` and `y`. The distance $d_{ij}$ between cities $i$ and $j$ is then set to\n",
"The class [TravelingSalesmanGenerator][TravelingSalesmanGenerator] can be used to generate random instances of this problem. The class samples the user-provided probability distribution `n` to decide how many cities to generate. Then, for each city $i$, the class generates its geographical location $(x_i, y_i)$ by sampling the provided distributions `x` and `y`. The distance $d_{ij}$ between cities $i$ and $j$ is then set to\n",
"$$\n",
"\\gamma_{ij} \\sqrt{(x_i - x_j)^2 + (y_i - y_j)^2},\n",
"$$\n",
"where $\\gamma$ is a random scaling factor sampled from the provided probability distribution `gamma`.\n",
"\n",
"If `fix_cities=True`, then the list of cities is kept the same for all generated instances. The $\\gamma$ values, however, and therefore also the distances, are still different. By default, all distances $d_{ij}$ are rounded to the nearest integer. If `round=False` is provided, this rounding will be disabled.\n",
"By default, all distances $d_{ij}$ are rounded to the nearest integer. If `round=False` is provided, this rounding will be disabled.\n",
"\n",
"[TravelingSalesmanGenerator]: ../../api/problems/#miplearn.problems.tsp.TravelingSalesmanGenerator"
"To create multiple instances with the same cities but slightly different distances, you can use [TravelingSalesmanPerturber][TravelingSalesmanPerturber]. This class takes an existing TravelingSalesmanData instance and generates new instances by applying fresh randomization factors to the distances computed from the original cities while keeping the city locations fixed.\n",
"\n",
"[TravelingSalesmanGenerator]: ../../api/problems/#miplearn.problems.tsp.TravelingSalesmanGenerator\n",
"[TravelingSalesmanPerturber]: ../../api/problems/#miplearn.problems.tsp.TravelingSalesmanPerturber"
]
},
{
@@ -1129,7 +1132,7 @@
},
{
"cell_type": "code",
"execution_count": 7,
"execution_count": 11,
"id": "9d0c56c6",
"metadata": {
"ExecuteTime": {
@@ -1146,6 +1149,17 @@
"name": "stdout",
"output_type": "stream",
"text": [
"cities\n",
" [[374.54011885 950.71430641]\n",
" [731.99394181 598.6584842 ]\n",
" [156.01864044 155.99452034]\n",
" [ 58.08361217 866.17614577]\n",
" [601.11501174 708.0725778 ]\n",
" [ 20.5844943 969.90985216]\n",
" [832.4426408 212.33911068]\n",
" [181.82496721 183.40450985]\n",
" [304.24224296 524.75643163]\n",
" [431.94501864 291.2291402 ]]\n",
"distances[0]\n",
" [[ 0. 513. 762. 358. 325. 374. 932. 731. 391. 634.]\n",
" [ 513. 0. 726. 765. 163. 754. 409. 719. 446. 400.]\n",
@@ -1221,6 +1235,7 @@
"from scipy.stats import uniform, randint\n",
"from miplearn.problems.tsp import (\n",
" TravelingSalesmanGenerator,\n",
" TravelingSalesmanPerturber,\n",
" build_tsp_model_gurobipy,\n",
")\n",
"\n",
@@ -1228,18 +1243,25 @@
"random.seed(42)\n",
"np.random.seed(42)\n",
"\n",
"# Generate random instances with a fixed ten cities in the 1000x1000 box\n",
"# and random distance scaling factors in the [0.90, 1.10] interval.\n",
"data = TravelingSalesmanGenerator(\n",
"# Generate a reference instance with ten cities in the 1000x1000 box\n",
"generator = TravelingSalesmanGenerator(\n",
" n=randint(low=10, high=11),\n",
" x=uniform(loc=0.0, scale=1000.0),\n",
" y=uniform(loc=0.0, scale=1000.0),\n",
" gamma=uniform(loc=0.90, scale=0.20),\n",
" fix_cities=True,\n",
" gamma=uniform(loc=1.0, scale=0.0),\n",
" round=True,\n",
").generate(10)\n",
")\n",
"reference_instance = generator.generate(1)[0]\n",
"\n",
"# Generate perturbed instances with the same cities but different distance scaling factors\n",
"perturber = TravelingSalesmanPerturber(\n",
" gamma=uniform(loc=0.90, scale=0.20),\n",
" round=True,\n",
")\n",
"data = perturber.perturb(reference_instance, 10)\n",
"\n",
"# Print distance matrices for the first two instances\n",
"print(\"cities\\n\", data[0].cities)\n",
"print(\"distances[0]\\n\", data[0].distances)\n",
"print(\"distances[1]\\n\", data[1].distances)\n",
"print()\n",

View File

@@ -27,10 +27,21 @@ logger = logging.getLogger(__name__)
class TravelingSalesmanData:
n_cities: int
distances: np.ndarray
cities: np.ndarray
class TravelingSalesmanGenerator:
"""Random generator for the Traveling Salesman Problem."""
"""Random instance generator for the Traveling Salesman Problem.
Generates instances by creating n cities (x_1,y_1),...,(x_n,y_n) where n,
x_i and y_i are sampled independently from the provided probability
distributions `n`, `x` and `y`. For each (unordered) pair of cities (i,j),
the distance d[i,j] between them is set to:
d[i,j] = gamma[i,j] \\sqrt{(x_i - x_j)^2 + (y_i - y_j)^2}
where gamma is sampled from the provided probability distribution `gamma`.
"""
def __init__(
self,
@@ -38,27 +49,10 @@ class TravelingSalesmanGenerator:
y: rv_frozen = uniform(loc=0.0, scale=1000.0),
n: rv_frozen = randint(low=100, high=101),
gamma: rv_frozen = uniform(loc=1.0, scale=0.0),
fix_cities: bool = True,
round: bool = True,
) -> None:
"""Initializes the problem generator.
Initially, the generator creates n cities (x_1,y_1),...,(x_n,y_n) where n,
x_i and y_i are sampled independently from the provided probability
distributions `n`, `x` and `y`. For each (unordered) pair of cities (i,j),
the distance d[i,j] between them is set to:
d[i,j] = gamma[i,j] \\sqrt{(x_i - x_j)^2 + (y_i - y_j)^2}
where gamma is sampled from the provided probability distribution `gamma`.
If fix_cities=True, the list of cities is kept the same for all generated
instances. The gamma values, and therefore also the distances, are still
different.
By default, all distances d[i,j] are rounded to the nearest integer. If
`round=False` is provided, this rounding will be disabled.
Arguments
---------
x: rv_continuous
@@ -67,9 +61,8 @@ class TravelingSalesmanGenerator:
Probability distribution for the y-coordinate of each city.
n: rv_discrete
Probability distribution for the number of cities.
fix_cities: bool
If False, cities will be resampled for every generated instance. Otherwise, list
of cities will be computed once, during the constructor.
gamma: rv_continuous
Probability distribution for distance perturbation factors.
round: bool
If True, distances are rounded to the nearest integer.
"""
@@ -86,26 +79,11 @@ class TravelingSalesmanGenerator:
self.gamma = gamma
self.round = round
if fix_cities:
self.fixed_n: Optional[int]
self.fixed_cities: Optional[np.ndarray]
self.fixed_n, self.fixed_cities = self._generate_cities()
else:
self.fixed_n = None
self.fixed_cities = None
def generate(self, n_samples: int) -> List[TravelingSalesmanData]:
def _sample() -> TravelingSalesmanData:
if self.fixed_cities is not None:
assert self.fixed_n 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 TravelingSalesmanData(n, distances)
n, cities = self._generate_cities()
distances = self._compute_distances(cities, self.gamma, self.round)
return TravelingSalesmanData(n, distances, cities)
return [_sample() for _ in range(n_samples)]
@@ -114,6 +92,62 @@ class TravelingSalesmanGenerator:
cities = np.array([(self.x.rvs(), self.y.rvs()) for _ in range(n)])
return n, cities
@staticmethod
def _compute_distances(
cities: np.ndarray, gamma: rv_frozen, round: bool
) -> np.ndarray:
n = len(cities)
distances = squareform(pdist(cities)) * gamma.rvs(size=(n, n))
distances = np.tril(distances) + np.triu(distances.T, 1)
if round:
distances = distances.round()
return distances
class TravelingSalesmanPerturber:
"""Perturbation generator for existing Traveling Salesman Problem instances.
Takes an existing TravelingSalesmanData instance and generates new instances
by applying randomization factors to the distances computed from the original cities.
"""
def __init__(
self,
gamma: rv_frozen = uniform(loc=1.0, scale=0.0),
round: bool = True,
) -> None:
"""Initialize the perturbation generator.
Parameters
----------
gamma: rv_continuous
Probability distribution for randomization factors applied to distances.
round: bool
If True, perturbed distances are rounded to the nearest integer.
"""
assert isinstance(
gamma, rv_frozen
), "gamma should be a SciPy probability distribution"
self.gamma = gamma
self.round = round
def perturb(
self,
instance: TravelingSalesmanData,
n_samples: int,
) -> List[TravelingSalesmanData]:
def _sample() -> TravelingSalesmanData:
new_distances = TravelingSalesmanGenerator._compute_distances(
instance.cities,
self.gamma,
self.round,
)
return TravelingSalesmanData(
instance.n_cities, new_distances, instance.cities
)
return [_sample() for _ in range(n_samples)]
def build_tsp_model_gurobipy(
data: Union[str, TravelingSalesmanData],

View File

@@ -22,7 +22,7 @@ def test_mem_component(
for h5 in [tsp_gp_h5, tsp_pyo_h5]:
clf = Mock(wraps=DummyClassifier())
comp = MemorizingLazyComponent(clf=clf, extractor=default_extractor)
comp.fit(tsp_gp_h5)
comp.fit(h5)
# Should call fit method with correct arguments
clf.fit.assert_called()
@@ -43,7 +43,7 @@ def test_mem_component(
# Call before-mip
stats: Dict[str, Any] = {}
model = Mock()
comp.before_mip(tsp_gp_h5[0], model, stats)
comp.before_mip(h5[0], model, stats)
# Should call predict with correct args
clf.predict.assert_called()

View File

@@ -7,6 +7,7 @@ from miplearn.collectors.basic import BasicCollector
from miplearn.io import write_pkl_gz
from miplearn.problems.tsp import (
TravelingSalesmanGenerator,
TravelingSalesmanPerturber,
build_tsp_model_gurobipy,
build_tsp_model_pyomo,
)
@@ -16,12 +17,19 @@ gen = TravelingSalesmanGenerator(
x=uniform(loc=0.0, scale=1000.0),
y=uniform(loc=0.0, scale=1000.0),
n=randint(low=20, high=21),
gamma=uniform(loc=1.0, scale=0.25),
fix_cities=True,
gamma=uniform(loc=1.0, scale=0.0),
round=True,
)
data = gen.generate(3)
# Generate a reference instance with fixed cities
reference_instance = gen.generate(1)[0]
# Generate perturbed instances with same cities but different distance scaling
perturber = TravelingSalesmanPerturber(
gamma=uniform(loc=1.0, scale=0.25),
round=True,
)
data = perturber.perturb(reference_instance, 3)
params = {"seed": 42, "threads": 1}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -17,56 +17,30 @@ def test_tsp_generator() -> None:
gen = TravelingSalesmanGenerator(
x=uniform(loc=0.0, scale=1000.0),
y=uniform(loc=0.0, scale=1000.0),
n=randint(low=3, high=4),
n=randint(low=5, high=6),
gamma=uniform(loc=1.0, scale=0.25),
fix_cities=True,
round=True,
)
data = gen.generate(2)
data = gen.generate(1)
assert data[0].distances.tolist() == [
[0.0, 591.0, 996.0],
[591.0, 0.0, 765.0],
[996.0, 765.0, 0.0],
]
assert data[1].distances.tolist() == [
[0.0, 556.0, 853.0],
[556.0, 0.0, 779.0],
[853.0, 779.0, 0.0],
[0.0, 525.0, 950.0, 392.0, 382.0],
[525.0, 0.0, 752.0, 761.0, 178.0],
[950.0, 752.0, 0.0, 809.0, 721.0],
[392.0, 761.0, 809.0, 0.0, 700.0],
[382.0, 178.0, 721.0, 700.0, 0.0],
]
def test_tsp() -> None:
data = TravelingSalesmanData(
n_cities=6,
distances=squareform(
pdist(
[
[0.0, 0.0],
[1.0, 0.0],
[2.0, 0.0],
[3.0, 0.0],
[0.0, 1.0],
[3.0, 1.0],
]
)
),
)
model = build_tsp_model_gurobipy(data)
model = build_tsp_model_gurobipy(data[0])
model.optimize()
assert model.inner.getAttr("x", model.inner.getVars()) == [
1.0,
0.0,
0.0,
1.0,
0.0,
1.0,
0.0,
0.0,
0.0,
1.0,
0.0,
0.0,
0.0,
1.0,
1.0,
0.0,
0.0,
]