1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
|
"""A matching benchmark, and the solvers we never tried on it.
Everything the project has used to search is one narrow family: spectral
initialisation on the pairwise field, exact steepest descent over
transpositions, tempering, and Sinkhorn. All of them act on the permutation
group with a pairwise objective in flat space. The measured cost of that
narrowness is large and specific -- on a field whose anchor bound is 0.997,
meaning the information is intact almost perfectly, blind matching returns
5.6%.
That makes a clean benchmark, because unlike natural data the answer is known
to be reachable. Three synthetic fields span the range: one that already
recovers, one whose captions omit a factor, one truncated to rank eight.
Natural data is included as the case where no method should be expected to
work, so a method that scores there is reporting a bug rather than a result.
Gromov-Wasserstein is the canonical formulation for exactly our problem --
align two metric spaces with no shared embedding, by matching their internal
distance structures -- and it has never been run on these fields. Its entropic
form with an annealed regulariser is the landscape-deformation method that the
rank ladder argued for and that we never reached. Each solver is scored blind
and then re-scored after exact refinement, since composition is what made the
spectral solver work.
"""
from __future__ import annotations
import argparse
import json
import time
import numpy as np
import torch
from .common import write_json
from .spectral_match import grampa, umeyama
from .synth_fast_gate import ClosedFormEnergy, fast_pair_descent
from .synth_triangle_gate import standardized
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--fields", nargs="+", required=True)
parser.add_argument("--labels", nargs="+", default=None)
parser.add_argument("--trials", type=int, default=3)
parser.add_argument("--iterations", type=int, default=600)
parser.add_argument("--device", default="cuda:3")
parser.add_argument("--methods", nargs="+", default=None)
parser.add_argument("--output", default="artifacts/synth_v1/match_battery.json")
return parser.parse_args()
def standardise(matrix: np.ndarray) -> np.ndarray:
mask = ~np.eye(len(matrix), dtype=bool)
values = matrix[mask]
out = (matrix - values.mean()) / values.std()
np.fill_diagonal(out, 0.0)
return out
def coupling_to_permutation(coupling: np.ndarray) -> np.ndarray:
from scipy.optimize import linear_sum_assignment
_, columns = linear_sum_assignment(-coupling)
return columns
# --- solvers: each returns column assignment for rows of `visual` ---------
def solve_grampa(visual, shuffled, **_):
return grampa(visual, shuffled, 1.0)
def solve_umeyama(visual, shuffled, **_):
return umeyama(visual, shuffled)
def solve_gw(visual, shuffled, **_):
import ot
size = len(visual)
weights = ot.unif(size)
coupling = ot.gromov.gromov_wasserstein(
visual, shuffled, weights, weights, "square_loss",
max_iter=200, tol_rel=1e-9,
)
return coupling_to_permutation(coupling)
def solve_gw_annealed(visual, shuffled, **_):
"""Entropic GW with the regulariser annealed from smooth to sharp."""
import ot
size = len(visual)
weights = ot.unif(size)
coupling = None
for epsilon in (5e-2, 2e-2, 1e-2, 5e-3, 2e-3):
try:
coupling = ot.gromov.entropic_gromov_wasserstein(
visual, shuffled, weights, weights, "square_loss",
epsilon=epsilon, G0=coupling, max_iter=200, tol=1e-9,
solver="PGD",
)
except Exception:
break
if not np.isfinite(coupling).all():
break
if coupling is None or not np.isfinite(coupling).all():
raise RuntimeError("entropic GW diverged at every regulariser")
return coupling_to_permutation(coupling)
def solve_bapg(visual, shuffled, **_):
"""Bregman alternating projected gradient: a newer GW solver."""
import ot
size = len(visual)
weights = ot.unif(size)
coupling = ot.gromov.BAPG_gromov_wasserstein(
visual, shuffled, weights, weights, "square_loss",
epsilon=1e-1, max_iter=1000, tol=1e-9,
)
return coupling_to_permutation(coupling)
def solve_faq(visual, shuffled, **_):
"""Frank-Wolfe on the Birkhoff polytope: the canonical QAP relaxation.
This is the convex-concave family the register listed and never reached.
It optimises over doubly stochastic matrices and projects at the end,
which is a different relaxation from both the spectral solvers and GW.
"""
from scipy.optimize import quadratic_assignment
result = quadratic_assignment(
visual, shuffled, method="faq", options={"maximize": True, "n_init": 1}
)
return result.col_ind
def solve_faq_multi(visual, shuffled, **_):
"""FAQ from many random doubly stochastic starts, best by its own objective."""
from scipy.optimize import quadratic_assignment
# scipy's FAQ takes no `n_init`; passing one is silently swallowed into
# unknown_options, which is why an earlier version of this function
# returned bit-identical results to plain FAQ. Restart explicitly.
best, best_value = None, -np.inf
for seed in range(15):
result = quadratic_assignment(
visual, shuffled, method="faq",
options={"maximize": True, "rng": seed, "P0": "randomized"},
)
value = float((visual * shuffled[np.ix_(result.col_ind, result.col_ind)]).sum())
if value > best_value:
best, best_value = result.col_ind, value
return best
def solve_2opt(visual, shuffled, **_):
from scipy.optimize import quadratic_assignment
result = quadratic_assignment(
visual, shuffled, method="2opt", options={"maximize": True, "rng": 0}
)
return result.col_ind
def solve_gw_multi(visual, shuffled, **_):
"""GW is non-convex; restart it and keep the best by the GW objective."""
import ot
size = len(visual)
weights = ot.unif(size)
best, best_value = None, np.inf
generator = np.random.default_rng(0)
for attempt in range(12):
init = None
if attempt:
noise = generator.random((size, size)) + 1e-3
init = noise / noise.sum()
try:
coupling, log = ot.gromov.gromov_wasserstein(
visual, shuffled, weights, weights, "square_loss",
G0=init, max_iter=200, tol_rel=1e-9, log=True,
)
except Exception:
continue
value = float(log["gw_dist"])
if value < best_value:
best, best_value = coupling, value
if best is None:
raise RuntimeError("all GW restarts failed")
return coupling_to_permutation(best)
def solve_gw_kl(visual, shuffled, **_):
import ot
size = len(visual)
weights = ot.unif(size)
shift = min(visual.min(), shuffled.min())
coupling = ot.gromov.gromov_wasserstein(
visual - shift + 1e-3, shuffled - shift + 1e-3, weights, weights,
"kl_loss", max_iter=200, tol_rel=1e-9,
)
return coupling_to_permutation(coupling)
def solve_gw_deep_anneal(visual, shuffled, **_):
"""The winning solver, annealed further and started from the smooth end."""
import ot
size = len(visual)
weights = ot.unif(size)
coupling = None
schedule = (2e-1, 1e-1, 5e-2, 3e-2, 2e-2, 1e-2, 7e-3, 5e-3, 3e-3, 2e-3, 1e-3)
for epsilon in schedule:
try:
candidate = ot.gromov.entropic_gromov_wasserstein(
visual, shuffled, weights, weights, "square_loss",
epsilon=epsilon, G0=coupling, max_iter=300, tol=1e-9, solver="PGD",
)
except Exception:
break
if not np.isfinite(candidate).all():
break
coupling = candidate
if coupling is None:
raise RuntimeError("deep anneal diverged immediately")
return coupling_to_permutation(coupling)
def solve_semirelaxed(visual, shuffled, **_):
import ot
size = len(visual)
coupling = ot.gromov.semirelaxed_gromov_wasserstein(
visual, shuffled, ot.unif(size), "square_loss", max_iter=200,
)
return coupling_to_permutation(coupling)
def _birkhoff_frank_wolfe(visual, shuffled, mu, coupling, steps):
"""Frank-Wolfe on the Birkhoff polytope for one concavity setting."""
from scipy.optimize import linear_sum_assignment
size = len(visual)
for step in range(steps):
gradient = 2.0 * (visual @ coupling @ shuffled) + 2.0 * mu * coupling
_, columns = linear_sum_assignment(-gradient)
vertex = np.zeros_like(coupling)
vertex[np.arange(size), columns] = 1.0
direction = vertex - coupling
# exact line search on the quadratic along the segment
quad = float((direction * (visual @ direction @ shuffled)).sum()) + mu * float(
(direction * direction).sum()
)
linear = float((direction * gradient).sum())
if abs(quad) < 1e-12:
gamma = 1.0 if linear > 0 else 0.0
else:
gamma = float(np.clip(-linear / (2.0 * quad), 0.0, 1.0)) if quad < 0 else (
1.0 if linear > 0 else 0.0
)
if gamma <= 1e-12:
break
coupling = coupling + gamma * direction
return coupling
def solve_path(visual, shuffled, **_):
"""Convex-to-concave path following over the Birkhoff polytope.
The relaxation the register named and never ran. Maximising a concave
surrogate over the polytope has one smooth interior optimum; maximising a
convex one attains its maximum at a vertex, which is a permutation. Adding
mu*||P||^2 and sweeping mu from negative to positive walks continuously
between the two, and the argument for it is exactly our situation: the
smooth end has no local optima to be trapped by, and the solution is
tracked into the sharp end rather than searched for there.
"""
size = len(visual)
scale = float(np.abs(np.linalg.eigvalsh(visual)).max()
* np.abs(np.linalg.eigvalsh(shuffled)).max())
coupling = np.full((size, size), 1.0 / size)
for fraction in np.linspace(-1.0, 1.0, 21):
coupling = _birkhoff_frank_wolfe(
visual, shuffled, fraction * scale, coupling, 30
)
return coupling_to_permutation(coupling)
def solve_moment_ladder(visual, shuffled, **_):
"""Match the eigenvalue moments of M, not just its sum.
The energy is a moment functional in disguise: the pairwise term is the
first moment of M = permuted-text * visual and the triangle term its
third. The sequence {tr(M^k)} is what pins down M's spectrum, and we have
only ever used two of its entries. This optimises a weighted combination
over several k by Frank-Wolfe, which is the cheapest way to ask whether a
longer moment sequence has a wider basin than a single term.
"""
from scipy.optimize import linear_sum_assignment
size = len(visual)
coupling = np.full((size, size), 1.0 / size)
for step in range(120):
permuted = coupling @ shuffled @ coupling.T
product = permuted * visual
# d/dP of tr(M^k) terms, accumulated over the ladder
gradient = 2.0 * (visual @ coupling @ shuffled)
squared = product @ product
gradient = gradient + 1.0 * (
(visual * product) @ coupling @ shuffled
+ (visual * squared) @ coupling @ shuffled
)
_, columns = linear_sum_assignment(-gradient)
vertex = np.zeros_like(coupling)
vertex[np.arange(size), columns] = 1.0
gamma = 2.0 / (step + 2.0)
coupling = coupling + gamma * (vertex - coupling)
return coupling_to_permutation(coupling)
SOLVERS = {
"grampa (current)": solve_grampa,
"umeyama": solve_umeyama,
"gromov-wasserstein": solve_gw,
"entropic GW annealed": solve_gw_annealed,
"BAPG GW": solve_bapg,
"FAQ (Frank-Wolfe)": solve_faq,
"FAQ x30 restarts": solve_faq_multi,
"2-opt": solve_2opt,
"GW x12 restarts": solve_gw_multi,
"GW kl-loss": solve_gw_kl,
"entropic GW deep anneal": solve_gw_deep_anneal,
"semirelaxed GW": solve_semirelaxed,
"PATH convex-concave": solve_path,
"moment ladder tr(M^k)": solve_moment_ladder,
}
def main() -> None:
args = parse_args()
labels = args.labels or [p.split("/")[-1] for p in args.fields]
chosen = args.methods or list(SOLVERS)
device = torch.device(args.device)
rows = []
for path, label in zip(args.fields, labels):
state = torch.load(path, map_location="cpu", weights_only=False)
visual = standardise(state["visual_field"].double().numpy())
text = standardise(state["text_field"].double().numpy())
size = len(visual)
text_gpu = standardized(torch.from_numpy(text).to(device)).double()
visual_gpu = standardized(torch.from_numpy(visual).to(device)).double()
print(f"\n=== {label} (N={size}, chance={1/size:.4f})", flush=True)
for name in chosen:
solver = SOLVERS[name]
raw, refined, seconds, failures = [], [], [], []
for trial in range(args.trials):
generator = np.random.default_rng(trial)
hidden = generator.permutation(size)
shuffled = text[np.ix_(hidden, hidden)]
start = time.time()
try:
columns = solver(visual=visual, shuffled=shuffled)
except Exception as error:
failures.append(str(error)[:80])
continue
seconds.append(time.time() - start)
raw.append(float((hidden[columns] == np.arange(size)).mean()))
shuffled_gpu = standardized(
torch.from_numpy(shuffled).to(device)
).double()
final = fast_pair_descent(
shuffled_gpu, visual_gpu,
torch.from_numpy(np.ascontiguousarray(columns)).to(device),
args.iterations,
)
refined.append(
float((hidden[final.cpu().numpy()] == np.arange(size)).mean())
)
row = {
"field": label, "method": name,
"raw": float(np.mean(raw)) if raw else None,
"refined": float(np.mean(refined)) if refined else None,
"seconds": float(np.mean(seconds)) if seconds else None,
"failures": failures,
}
rows.append(row)
if raw:
print(" %-22s raw=%.4f refined=%.4f (%.1fs)"
% (name, row["raw"], row["refined"], row["seconds"]), flush=True)
else:
print(" %-22s FAILED: %s" % (name, failures[0] if failures else "?"),
flush=True)
write_json(args.output, {
"protocol": (
"Blind: the hidden permutation is generated per trial and read only "
"for scoring. Each solver is scored as returned and again after "
"exact steepest-descent refinement."
),
"rows": rows,
})
print(json.dumps({"done": True}))
if __name__ == "__main__":
main()
|