Skip to content

Commit

Permalink
Extend the TSP implementation to work on any graph (#646)
Browse files Browse the repository at this point in the history
* Write tests for tsp in conjunction with non-geometric graph (#628)

* Modify tsp to support arbritrary graphs (#628)

* Add release note (#628)
  • Loading branch information
Dniskk authored Nov 25, 2024
1 parent c4487cc commit 53184ab
Show file tree
Hide file tree
Showing 3 changed files with 160 additions and 6 deletions.
23 changes: 18 additions & 5 deletions qiskit_optimization/applications/tsp.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# This code is part of a Qiskit project.
#
# (C) Copyright IBM 2018, 2023.
# (C) Copyright IBM 2018, 2024.
#
# This code is licensed under the Apache License, Version 2.0. You may
# obtain a copy of this license in the LICENSE.txt file in the root directory
Expand Down Expand Up @@ -46,18 +46,29 @@ def to_quadratic_program(self) -> QuadraticProgram:
mdl = Model(name="TSP")
n = self._graph.number_of_nodes()
x = {(i, k): mdl.binary_var(name=f"x_{i}_{k}") for i in range(n) for k in range(n)}

# Only sum over existing edges in the graph
tsp_func = mdl.sum(
self._graph.edges[i, j]["weight"] * x[(i, k)] * x[(j, (k + 1) % n)]
for i in range(n)
for j in range(n)
for i, j in self._graph.edges
for k in range(n)
)
# Add reverse edges since we have an undirected graph
tsp_func += mdl.sum(
self._graph.edges[i, j]["weight"] * x[(j, k)] * x[(i, (k + 1) % n)]
for i, j in self._graph.edges
for k in range(n)
if i != j
)

mdl.minimize(tsp_func)
for i in range(n):
mdl.add_constraint(mdl.sum(x[(i, k)] for k in range(n)) == 1)
for k in range(n):
mdl.add_constraint(mdl.sum(x[(i, k)] for i in range(n)) == 1)
for i, j in nx.non_edges(self._graph):
for k in range(n):
mdl.add_constraint(x[i, k] + x[j, (k + 1) % n] <= 1)
mdl.add_constraint(x[j, k] + x[i, (k + 1) % n] <= 1)
op = from_docplex_mp(mdl)
return op

Expand All @@ -73,7 +84,7 @@ def interpret(
A list of nodes whose indices correspond to its order in a prospective cycle.
"""
x = self._result_to_x(result)
n = int(np.sqrt(len(x)))
n = self._graph.number_of_nodes()
route = [] # type: List[Union[int, List[int]]]
for p__ in range(n):
p_step = []
Expand Down Expand Up @@ -141,6 +152,8 @@ def create_random_instance(n: int, low: int = 0, high: int = 100, seed: int = No
def parse_tsplib_format(filename: str) -> "Tsp":
"""Read a graph in TSPLIB format from file and return a Tsp instance.
Only the EUC_2D edge weight format is supported.
Args:
filename: the name of the file.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
features:
- |
Added support for non-complete graphs for the TSP problem.
139 changes: 138 additions & 1 deletion test/applications/test_tsp.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# This code is part of a Qiskit project.
#
# (C) Copyright IBM 2018, 2023.
# (C) Copyright IBM 2018, 2024.
#
# This code is licensed under the Apache License, Version 2.0. You may
# obtain a copy of this license in the LICENSE.txt file in the root directory
Expand Down Expand Up @@ -111,5 +111,142 @@ def test_parse_tsplib_format(self):
self.assertEqual(graph.number_of_edges(), 51 * 50 / 2) # fully connected graph


class TestTspCustomGraph(QiskitOptimizationTestCase):
"""Test Tsp class with a custom non-geometric graph"""

def setUp(self):
"""Set up test cases."""
super().setUp()
self.graph = nx.Graph()
self.edges_with_weights = [
(0, 1, 5),
(1, 2, 5),
(1, 3, 15),
(2, 3, 15),
(2, 4, 5),
(3, 4, 5),
(3, 0, 5),
]

self.graph.add_nodes_from(range(5))
for source, target, weight in self.edges_with_weights:
self.graph.add_edge(source, target, weight=weight)

op = QuadraticProgram()
for _ in range(25):
op.binary_var()

result_vector = np.zeros(25)
result_vector[0] = 1
result_vector[6] = 1
result_vector[12] = 1
result_vector[23] = 1
result_vector[19] = 1

self.optimal_path = [0, 1, 2, 4, 3]
self.optimal_edges = [(0, 1), (1, 2), (2, 4), (4, 3), (3, 0)]
self.optimal_cost = 25

self.result = OptimizationResult(
x=result_vector,
fval=self.optimal_cost,
variables=op.variables,
status=OptimizationResultStatus.SUCCESS,
)

def test_to_quadratic_program(self):
"""Test to_quadratic_program with custom graph"""
tsp = Tsp(self.graph)
quadratic_program = tsp.to_quadratic_program()

self.assertEqual(quadratic_program.name, "TSP")
self.assertEqual(quadratic_program.get_num_vars(), 25)

for variable in quadratic_program.variables:
self.assertEqual(variable.vartype, VarType.BINARY)

objective = quadratic_program.objective
self.assertEqual(objective.constant, 0)
self.assertEqual(objective.sense, QuadraticObjective.Sense.MINIMIZE)

# Test objective quadratic terms
quadratic_terms = objective.quadratic.to_dict()
for source, target, weight in self.edges_with_weights:
for position in range(5):
next_position = (position + 1) % 5
key = (
min(source * 5 + position, target * 5 + next_position),
max(source * 5 + position, target * 5 + next_position),
)
self.assertIn(key, quadratic_terms)
self.assertEqual(quadratic_terms[key], weight)

linear_constraints = quadratic_program.linear_constraints

# Test node constraints (each node appears once)
for node in range(5):
self.assertEqual(linear_constraints[node].sense, Constraint.Sense.EQ)
self.assertEqual(linear_constraints[node].rhs, 1)
self.assertEqual(
linear_constraints[node].linear.to_dict(),
{5 * node + pos: 1 for pos in range(5)},
)

# Test position constraints (each position filled once)
for position in range(5):
self.assertEqual(linear_constraints[5 + position].sense, Constraint.Sense.EQ)
self.assertEqual(linear_constraints[5 + position].rhs, 1)
self.assertEqual(
linear_constraints[5 + position].linear.to_dict(),
{5 * node + position: 1 for node in range(5)},
)

# Test non-edge constraints
non_edges = list(nx.non_edges(self.graph))
constraint_idx = 10 # Start after node and position constraints

for i, j in non_edges:
for k in range(5):
next_k = (k + 1) % 5

# Check forward constraint: x[i,k] + x[j,(k+1)%n] <= 1
constraint = linear_constraints[constraint_idx]
self.assertEqual(constraint.sense, Constraint.Sense.LE)
self.assertEqual(constraint.rhs, 1)
linear_dict = constraint.linear.to_dict()
self.assertEqual(len(linear_dict), 2)
self.assertEqual(linear_dict[i * 5 + k], 1)
self.assertEqual(linear_dict[j * 5 + next_k], 1)
constraint_idx += 1

# Check backward constraint: x[j,k] + x[i,(k+1)%n] <= 1
constraint = linear_constraints[constraint_idx]
self.assertEqual(constraint.sense, Constraint.Sense.LE)
self.assertEqual(constraint.rhs, 1)
linear_dict = constraint.linear.to_dict()
self.assertEqual(len(linear_dict), 2)
self.assertEqual(linear_dict[j * 5 + k], 1)
self.assertEqual(linear_dict[i * 5 + next_k], 1)
constraint_idx += 1

# Verify total number of constraints
expected_constraints = (
5 # node constraints
+ 5 # position constraints
+ len(non_edges) * 2 * 5 # non-edge constraints (2 per non-edge per position)
)
self.assertEqual(len(linear_constraints), expected_constraints)

def test_interpret(self):
"""Test interpret with custom graph"""
tsp = Tsp(self.graph)
self.assertEqual(tsp.interpret(self.result), self.optimal_path)

def test_edgelist(self):
"""Test _edgelist with custom graph"""
tsp = Tsp(self.graph)
self.assertEqual(tsp._edgelist(self.result), self.optimal_edges)


if __name__ == "__main__":
unittest.main()

0 comments on commit 53184ab

Please sign in to comment.