Skip to content

Commit 42b883d

Browse files
TSPLIB benchmark by Anthropic Claude
1 parent a8fb675 commit 42b883d

1 file changed

Lines changed: 268 additions & 0 deletions

File tree

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
# TSPLIB benchmark: PyQrackIsing vs. mathematically-proven optimal tours.
2+
#
3+
# Data source: the canonical TSPLIB symmetric-TSP mirror at
4+
# https://github.com/mastqe/tsplib
5+
# which hosts the original .tsp instance files from Heidelberg's TSPLIB95
6+
# (http://comopt.ifi.uni-heidelberg.de/software/TSPLIB95/) along with a
7+
# `solutions` file of known-optimal tour lengths, all proven optimal via
8+
# Concorde (see TSPLIB FAQ).
9+
#
10+
# Supports the two coordinate-based TSPLIB edge-weight types that cover the large
11+
# majority of well-known instances:
12+
# - EUC_2D: standard Euclidean distance
13+
# - ATT: the "pseudo-Euclidean" distance used by att48/att532, per the TSPLIB spec
14+
# Instances using EDGE_WEIGHT_TYPE EXPLICIT (a pre-given distance matrix, no coordinates,
15+
# e.g. bayg29, gr17) are deliberately skipped rather than mishandled -- silently guessing
16+
# at those would risk exactly the kind of unflagged error this script exists to avoid.
17+
#
18+
# Script created by Anthropic Claude;
19+
# PyQrackIsing is by Daniel Strano, with LLM assistance where and as credited.
20+
21+
import math
22+
import os
23+
import time
24+
import urllib.request
25+
from concurrent.futures import ProcessPoolExecutor
26+
27+
import numpy as np
28+
import pandas as pd
29+
from pyqrackising import tsp_symmetric
30+
31+
TSPLIB_RAW_BASE = "https://raw.githubusercontent.com/mastqe/tsplib/master"
32+
OUTPUT_CSV = os.environ.get("TSP_OUTPUT_CSV", "pyqrackising_tsplib_results.csv")
33+
34+
# A representative spread of well-known, coordinate-based (non-EXPLICIT) instances,
35+
# small to large. Add/remove names freely -- run_instance() will skip anything that
36+
# turns out to be EXPLICIT-format or otherwise unsupported, and say so explicitly.
37+
DEFAULT_INSTANCES = [
38+
"burma14",
39+
"bayg29", # EXPLICIT-format instance, included on purpose to show the skip path working
40+
"berlin52", "att48", "eil51", "eil76", "st70", "eil101", "ch130", "ch150",
41+
"att532", "a280",
42+
]
43+
44+
45+
def fetch_text(url):
46+
with urllib.request.urlopen(url, timeout=30) as resp:
47+
return resp.read().decode("utf-8")
48+
49+
50+
def fetch_solutions():
51+
"""Parse the canonical 'solutions' file into {instance_name: known_optimal_length}."""
52+
raw = fetch_text(f"{TSPLIB_RAW_BASE}/solutions")
53+
solutions = {}
54+
for line in raw.splitlines():
55+
line = line.strip()
56+
if not line or ":" not in line:
57+
continue
58+
name, value = line.split(":", 1)
59+
name = name.strip()
60+
value = value.strip().split()[0] # drop trailing annotations like "(CEIL_2D)"
61+
try:
62+
solutions[name] = float(value)
63+
except ValueError:
64+
continue
65+
return solutions
66+
67+
68+
def parse_tsp_file(raw_text):
69+
"""
70+
Minimal TSPLIB .tsp parser for EUC_2D and ATT instances.
71+
Returns (edge_weight_type, {node_id: (x, y)}) or raises ValueError for
72+
unsupported formats (e.g. EXPLICIT) so callers can skip cleanly.
73+
"""
74+
lines = raw_text.splitlines()
75+
edge_weight_type = None
76+
coords = {}
77+
in_coord_section = False
78+
79+
for line in lines:
80+
stripped = line.strip()
81+
if stripped.startswith("EDGE_WEIGHT_TYPE"):
82+
edge_weight_type = stripped.split(":", 1)[1].strip()
83+
elif stripped.startswith("NODE_COORD_SECTION"):
84+
in_coord_section = True
85+
continue
86+
elif stripped.startswith("EOF"):
87+
break
88+
elif in_coord_section and stripped:
89+
parts = stripped.split()
90+
node_id = int(parts[0])
91+
x, y = float(parts[1]), float(parts[2])
92+
coords[node_id] = (x, y)
93+
94+
if edge_weight_type not in ("EUC_2D", "ATT"):
95+
raise ValueError(
96+
f"Unsupported EDGE_WEIGHT_TYPE={edge_weight_type!r}; "
97+
f"this script only handles EUC_2D and ATT (coordinate-based) instances."
98+
)
99+
if not coords:
100+
raise ValueError("No NODE_COORD_SECTION found or it was empty.")
101+
102+
return edge_weight_type, coords
103+
104+
105+
def att_distance(p1, p2):
106+
"""TSPLIB ATT pseudo-Euclidean distance (used by att48, att532, etc.)."""
107+
xd = p1[0] - p2[0]
108+
yd = p1[1] - p2[1]
109+
rij = math.sqrt((xd * xd + yd * yd) / 10.0)
110+
tij = round(rij)
111+
return tij + 1 if tij < rij else tij
112+
113+
114+
def build_distance_matrix(edge_weight_type, coords):
115+
node_ids = sorted(coords.keys())
116+
n = len(node_ids)
117+
pts = np.array([coords[i] for i in node_ids], dtype=np.float64)
118+
119+
if edge_weight_type == "EUC_2D":
120+
diff_x = pts[:, 0][:, None] - pts[:, 0][None, :]
121+
diff_y = pts[:, 1][:, None] - pts[:, 1][None, :]
122+
dist = np.sqrt(diff_x ** 2 + diff_y ** 2)
123+
elif edge_weight_type == "ATT":
124+
dist = np.zeros((n, n), dtype=np.float64)
125+
for i in range(n):
126+
for j in range(n):
127+
if i != j:
128+
dist[i, j] = att_distance(pts[i], pts[j])
129+
else:
130+
raise ValueError(f"build_distance_matrix: unhandled type {edge_weight_type!r}")
131+
132+
return dist
133+
134+
135+
def nearest_neighbor_tour(dist_matrix):
136+
"""Cheap, standard baseline: greedy nearest-neighbor heuristic, starting at city 0."""
137+
n = dist_matrix.shape[0]
138+
unvisited = set(range(1, n))
139+
tour = [0]
140+
current = 0
141+
while unvisited:
142+
nxt = min(unvisited, key=lambda j: dist_matrix[current, j])
143+
tour.append(nxt)
144+
unvisited.remove(nxt)
145+
current = nxt
146+
length = sum(dist_matrix[tour[i], tour[(i + 1) % n]] for i in range(n))
147+
return tour, length
148+
149+
150+
def _single_solve(dist_matrix):
151+
"""One independent top-level solve. Module-level so it's picklable for ProcessPoolExecutor."""
152+
return tsp_symmetric(dist_matrix, monte_carlo=True, is_cyclic=True)
153+
154+
155+
def best_of_n_solve(dist_matrix, n_runs=8, n_workers=None):
156+
"""
157+
Run tsp_symmetric n_runs independent times and keep the best result.
158+
This is just calling the function repeatedly at the top level and taking the
159+
minimum -- tsp_symmetric has real run-to-run stochasticity (confirmed: a few
160+
percent spread across repeated calls on the same input), so this is a genuine,
161+
non-placebo lever, not a no-op.
162+
163+
Runs in parallel across n_workers processes when more than one CPU is available
164+
(defaults to os.cpu_count()).
165+
"""
166+
if n_workers is None:
167+
n_workers = os.cpu_count() or 1
168+
n_workers = max(1, min(n_workers, n_runs))
169+
170+
best_path, best_weight = None, float("inf")
171+
172+
if n_workers == 1:
173+
for _ in range(n_runs):
174+
path, weight = _single_solve(dist_matrix)
175+
if weight < best_weight:
176+
best_path, best_weight = path, weight
177+
else:
178+
with ProcessPoolExecutor(max_workers=n_workers) as executor:
179+
futures = [
180+
executor.submit(_single_solve, dist_matrix)
181+
for _ in range(n_runs)
182+
]
183+
for f in futures:
184+
path, weight = f.result()
185+
if weight < best_weight:
186+
best_path, best_weight = path, weight
187+
188+
return best_path, best_weight
189+
190+
191+
def run_instance(name, known_solutions, n_runs=8, n_workers=None):
192+
raw = fetch_text(f"{TSPLIB_RAW_BASE}/{name}.tsp")
193+
edge_weight_type, coords = parse_tsp_file(raw) # raises ValueError -> caller skips
194+
n = len(coords)
195+
dist_matrix = build_distance_matrix(edge_weight_type, coords)
196+
197+
known_best = known_solutions.get(name)
198+
if known_best is None:
199+
raise ValueError(f"No known-optimal length found in solutions file for {name!r}.")
200+
201+
t0 = time.perf_counter()
202+
path, weight = best_of_n_solve(
203+
dist_matrix, n_runs=n_runs, n_workers=n_workers
204+
)
205+
elapsed = time.perf_counter() - t0
206+
207+
_, nn_length = nearest_neighbor_tour(dist_matrix)
208+
209+
ratio = weight / known_best if known_best > 0 else float("nan")
210+
nn_ratio = nn_length / known_best if known_best > 0 else float("nan")
211+
212+
return {
213+
"instance": name,
214+
"num_cities": n,
215+
"edge_weight_type": edge_weight_type,
216+
"known_best_length": known_best,
217+
"pyqrackising_length": weight,
218+
"ratio_to_known_best": ratio, # 1.0 = matched the proven optimum
219+
"nearest_neighbor_length": nn_length,
220+
"nearest_neighbor_ratio_to_known_best": nn_ratio, # cheap baseline, for context only
221+
"seconds": elapsed,
222+
"path": path,
223+
}
224+
225+
226+
def main(instance_names=None, n_runs=8, n_workers=None):
227+
if instance_names is None:
228+
instance_names = DEFAULT_INSTANCES
229+
230+
known_solutions = fetch_solutions()
231+
232+
results = []
233+
for name in instance_names:
234+
try:
235+
res = run_instance(
236+
name, known_solutions, n_runs=n_runs, n_workers=n_workers,
237+
)
238+
print(
239+
f"{res['instance']:>10s} n={res['num_cities']:<5d} "
240+
f"type={res['edge_weight_type']:<6s} "
241+
f"known={res['known_best_length']:.1f} "
242+
f"pyqrackising={res['pyqrackising_length']:.1f} (ratio={res['ratio_to_known_best']:.4f}) "
243+
f"nearest_neighbor={res['nearest_neighbor_length']:.1f} (ratio={res['nearest_neighbor_ratio_to_known_best']:.4f}) "
244+
f"time={res['seconds']:.2f}s"
245+
)
246+
results.append(res)
247+
except ValueError as e:
248+
print(f"{name:>10s} SKIPPED: {e}")
249+
except Exception as e:
250+
print(f"{name:>10s} FAILED: {e}")
251+
252+
out = pd.DataFrame(results)
253+
out.to_csv(OUTPUT_CSV, index=False)
254+
255+
if len(out):
256+
print("\n--- summary ---")
257+
print(f"instances run: {len(out)}")
258+
print(f"PyQrackIsing mean ratio: {out['ratio_to_known_best'].mean():.4f}")
259+
print(f"PyQrackIsing median ratio: {out['ratio_to_known_best'].median():.4f}")
260+
print(f"PyQrackIsing worst ratio: {out['ratio_to_known_best'].max():.4f}")
261+
print(f"Nearest-neighbor mean ratio: {out['nearest_neighbor_ratio_to_known_best'].mean():.4f} (cheap baseline, for context)")
262+
print(f"Total wall time (s): {out['seconds'].sum():.2f}")
263+
print(f"\nFull results written to {OUTPUT_CSV}")
264+
return out
265+
266+
267+
if __name__ == "__main__":
268+
main(n_runs=os.cpu_count())

0 commit comments

Comments
 (0)