diff --git a/requirements.txt b/requirements.txt index f24f9ae..53632ec 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ +networkx>=3.1 numpy>=1.22.4 diff --git a/src/Annealing/anneal.py b/src/Annealing/anneal.py index bfe79ed..904bbfc 100644 --- a/src/Annealing/anneal.py +++ b/src/Annealing/anneal.py @@ -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): diff --git a/src/Annealing/strategies.py b/src/Annealing/strategies.py index e115ae9..f5deb99 100644 --- a/src/Annealing/strategies.py +++ b/src/Annealing/strategies.py @@ -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 @@ -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. @@ -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 diff --git a/tests/unit/test_strategies.py b/tests/unit/test_strategies.py index f69d709..2c284cd 100644 --- a/tests/unit/test_strategies.py +++ b/tests/unit/test_strategies.py @@ -22,6 +22,7 @@ """unit test strategies.""" +import networkx as nx import numpy as np import pytest @@ -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( @@ -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."