Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
networkx>=3.1
numpy>=1.22.4
7 changes: 4 additions & 3 deletions src/Annealing/anneal.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,11 +161,12 @@ def simulate(self, k: int | None = None, nswaps: int = 3) -> np.ndarray:
# Reset Tm
self.tm = self.chill.tm_max
data: np.ndarray = self.data if k is None else self.nucleate(k)
if not (2 <= nswaps <= len(data)):
nswaps = max(2, min(nswaps, len(data)))
length: int = len(data)
if not (2 <= nswaps <= length):
nswaps = max(2, min(nswaps, length))
self.log.info("Setting nswaps argument to: %d", nswaps)

index: np.ndarray = np.arange(len(data))
index: np.ndarray = np.arange(length)
best_index: np.ndarray = np.copy(index)
best: float = self.fitness(data)
for j in range(self.steps):
Expand Down
70 changes: 68 additions & 2 deletions src/Annealing/strategies.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from abc import ABC, abstractmethod
from collections.abc import Callable, Iterable

import networkx as nx # type: ignore[import-untyped]
import numpy as np


Expand Down Expand Up @@ -62,8 +63,36 @@ def build_2d_distance_matrix(
return matrix


# TODO: Think how we can integrate a strategy into a simulated annealing approach.
# Think of it as a co-strategy to escape local minima to reach better convergence.
def build_graph(
coordinates: np.ndarray,
distance: Callable[[np.ndarray, np.ndarray], float] = _euclidean,
) -> nx.Graph:
"""Build a complete weighted undirected eulerian graph from coordinates."""
n: int = len(coordinates)
graph = nx.Graph()
for node in range(n):
graph.add_node(node, coordinate=coordinates[node])

for j in range(n):
for k in range(j + 1, n):
graph.add_edge(j, k, weight=distance(coordinates[j], coordinates[k]))

return graph


def build_graph_from_2d_distance_matrix(matrix: np.ndarray) -> nx.Graph:
"""Construct a weighted undirected eulerian graph from 2d distance matrix."""
n: int = len(matrix)
graph = nx.Graph()
for node in range(n):
graph.add_node(node)
for j in range(n):
for k in range(j + 1, n):
graph.add_edge(j, k, weight=matrix[j, k])

return graph


class Strategy(ABC):
"""Abstract minimization strategy.

Expand Down Expand Up @@ -395,3 +424,40 @@ def minimize(self, iterations: int = 10_000) -> tuple[list[int], float]:
min_cost = self.calculate_tour_cost(self.tour)

return self.tour, min_cost


class ChristofidesStrategy(TSPStrategy):
"""Christofides strategy to minimize TSP problem."""

graph: nx.Graph

def __init__(self, distance_matrix: np.ndarray) -> None:
super().__init__(distance_matrix)
self.graph = build_graph_from_2d_distance_matrix(distance_matrix)

def christofides_tsp(self) -> list[int]:
"""Perform christofides strategy to improving tour length."""
mst: nx.Graph = nx.minimum_spanning_tree(self.graph, algorithm="prim")
odd_degree = [v for v, d in mst.degree() if d % 2 == 1]

# Find Minimum Weight Perfect Matching among odd degree nodes
subgraph: nx.Graph = self.graph.subgraph(odd_degree)
matching: set = nx.algorithms.matching.min_weight_matching(subgraph)
eulerian_graph = nx.MultiGraph(mst)
eulerian_graph.add_edges_from(matching)

# Shortcut Eulerian circuit to form the final TSP tour
tour: list[int] = []
visited: set[int] = set()
for u, _ in nx.eulerian_circuit(eulerian_graph):
if u not in visited:
tour.append(u)
visited.add(u)

return tour

def minimize(self) -> tuple[list[int], float]:
self.tour = self.christofides_tsp()
cost = self.calculate_tour_cost(self.tour)

return self.tour, cost
73 changes: 71 additions & 2 deletions tests/unit/test_strategies.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

"""unit test strategies."""

import networkx as nx
import numpy as np
import pytest

Expand Down Expand Up @@ -202,12 +203,80 @@ def tour_3opt() -> list[int]:
]


@pytest.fixture
def tour_cfs() -> list[int]:
"""christofides tour."""
return [
0,
28,
13,
37,
3,
19,
44,
34,
45,
2,
30,
49,
26,
5,
46,
12,
9,
36,
1,
32,
47,
39,
22,
16,
4,
43,
23,
17,
35,
15,
33,
42,
10,
48,
40,
7,
14,
20,
27,
24,
38,
25,
41,
8,
29,
18,
31,
11,
21,
6,
]


def test_matrix_to_graph(dm: np.ndarray, coord: np.ndarray) -> None:
"""Confirm graphs constructed are equal."""
graph_a = strategies.build_graph_from_2d_distance_matrix(dm)
graph_b = strategies.build_graph(coord)

assert nx.utils.edges_equal(graph_a.edges, graph_b.edges), "Unequal Edges"
assert nx.utils.nodes_equal(graph_a.nodes, graph_b.nodes), "Unequal Nodes"
assert nx.algorithms.is_isomorphic(graph_a, graph_b), "Graphs are not isomorphic"


@pytest.mark.parametrize(
["tsp_strategy", "expected_tour", "expected_cost"],
[
(strategies.NearestNeighborStrategy, "tour_nn", 1149.5188),
(strategies.TwoOptStrategy, "tour_2opt", 1119.0572),
(strategies.ThreeOptStrategy, "tour_3opt", 1200.9199),
(strategies.ChristofidesStrategy, "tour_cfs", 1178.9486),
],
)
def test_tsp_strategies(
Expand All @@ -216,15 +285,15 @@ def test_tsp_strategies(
expected_tour: list[int],
expected_cost: float,
request: pytest.FixtureRequest,
):
) -> None:
"""Test various tsp strategies produce expected tours and costs."""
strategy = tsp_strategy(dm)
tour, val = strategy.minimize()
expected = request.getfixturevalue(expected_tour)

assert isinstance(tour, list), "Expected a list result."
assert tour == expected, "Expected predictable tour."
assert len(tour) == len(dm), "Expected same number of data points."
assert tour == expected, "Expected predictable tour."

assert isinstance(val, float), "Expected a float result."
assert np.isclose(val, expected_cost), "Unexpected tour length calculation."
Loading