summaryrefslogtreecommitdiff
path: root/worldalign
diff options
context:
space:
mode:
authorYurenHao0426 <Blackhao0426@gmail.com>2026-08-01 18:53:29 -0500
committerYurenHao0426 <Blackhao0426@gmail.com>2026-08-01 18:53:29 -0500
commitde827a42e10ede662f4bd2893c4f8b6d54be45dc (patch)
treed7c2c30f5e36102572cacc70e4296d80459d992f /worldalign
parent4f7ee05cc3b072478062e53645af016861c4b529 (diff)
Amplification beats the instance that defeated fifteen solvers
The caption-omitted field returned under 5% from every solver tried: Umeyama, GRAMPA, five Gromov-Wasserstein variants, FAQ with and without restarts, PATH convex-concave, a moment ladder, semirelaxed GW. The measurements said the answer was not a sixteenth solver. Descent on that field amplifies: a start 10% correct comes out 42%, one 20% correct comes out 78%. What no initialiser could do was clear the entry price, since all of them land in the same wrong region. So run a diverse pool of cheap descents, let them vote, round the vote matrix to a permutation by Hungarian assignment rather than argmax, descend from that, and rebuild the pool around the result. Each round feeds the amplifier a better start. 0.044 -> 0.72 on the best run, 0.54 mean over two. Competitive elsewhere: 0.977 on the easy field against 0.961 for the best GW variant. Also adds the diagnostics that led here: the basin-width probe (k=16 transpositions still returns to the exact truth 100% of the time) and the capture-threshold curve that measures amplification directly. Co-Authored-By: Claude <noreply@anthropic.com>
Diffstat (limited to 'worldalign')
-rw-r--r--worldalign/amplify.py168
-rw-r--r--worldalign/consensus_seed.py180
-rw-r--r--worldalign/match_battery.py201
3 files changed, 549 insertions, 0 deletions
diff --git a/worldalign/amplify.py b/worldalign/amplify.py
new file mode 100644
index 0000000..6310b25
--- /dev/null
+++ b/worldalign/amplify.py
@@ -0,0 +1,168 @@
+"""Amplification: the instance that beat fifteen solvers, solved by not solving it.
+
+Fifteen methods returned under 5% on the caption-omitted field -- spectral,
+Birkhoff, five Gromov-Wasserstein variants, convex-concave path following. The
+measurements said why, and the answer was not a sixteenth solver.
+
+Three facts fit together. The information is intact (anchor bound 0.997). The
+truth is a strict local minimum whose basin is wide. And exact descent
+**amplifies**: a start that is 10% correct comes out 42% correct, one that is
+20% correct comes out 78%. What no initialiser could do was produce a start
+correct enough to enter that regime, because every one of them lands in the
+same systematically wrong region.
+
+So the move is to stop asking any single initialiser to be right. Run a diverse
+pool of cheap descents, let them vote on pairings, round the vote matrix to a
+permutation with Hungarian assignment -- rounding, not taking the argmax, which
+is worth several points on its own -- descend from that, and rebuild the pool by
+perturbing the result. Each round feeds the amplifier a better start than the
+last. Nothing here is a better optimiser; it is a ladder built out of the one
+the field already had.
+
+Ladders vary: on the hard field one run plateaus near 0.36 while another climbs
+to 0.72. Several are run and the best is chosen **by energy**, never by
+accuracy, so the selection stays blind.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+
+import numpy as np
+import torch
+from scipy.optimize import linear_sum_assignment
+
+from .common import write_json
+from .spectral_match import grampa
+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("--pool", type=int, default=60)
+ parser.add_argument("--rounds", type=int, default=8)
+ parser.add_argument("--ladders", type=int, default=3)
+ parser.add_argument("--trials", type=int, default=3)
+ parser.add_argument("--iterations", type=int, default=2000)
+ parser.add_argument("--fresh", type=float, default=0.25,
+ help="Fraction of each rebuilt pool that stays fully random.")
+ parser.add_argument("--device", default="cuda:3")
+ parser.add_argument("--output", default="artifacts/synth_v1/amplify.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 ladder(text_gpu, visual_gpu, visual, shuffled, energy, args, generator, device):
+ """One amplification ladder. Returns (permutation, energy, per-round trace)."""
+ size = len(visual)
+ pool = [grampa(visual, shuffled, eta) for eta in (0.2, 0.5, 1.0, 2.0, 5.0)]
+ pool += [generator.permutation(size) for _ in range(args.pool - 5)]
+
+ best, best_energy, trace = None, np.inf, []
+ for round_index in range(args.rounds):
+ votes = np.zeros((size, size))
+ for start in pool:
+ final = fast_pair_descent(
+ text_gpu, visual_gpu,
+ torch.from_numpy(np.ascontiguousarray(start)).to(device),
+ args.iterations,
+ ).cpu().numpy()
+ votes[np.arange(size), final] += 1.0
+
+ _, columns = linear_sum_assignment(-votes)
+ descended = fast_pair_descent(
+ text_gpu, visual_gpu,
+ torch.from_numpy(np.ascontiguousarray(columns)).to(device),
+ args.iterations,
+ )
+ value = float(energy.energy(descended[None])[0])
+ current = descended.cpu().numpy()
+ if value < best_energy:
+ best, best_energy = current, value
+ trace.append(value)
+
+ # rebuild the pool around the current answer, keeping some of it random
+ # so a plateaued ladder still has a way out
+ fresh = int(args.fresh * args.pool)
+ pool = [current.copy()]
+ for _ in range(args.pool - fresh - 1):
+ perturbed = current.copy()
+ for _ in range(int(generator.integers(10, 120))):
+ i, j = generator.integers(0, size, 2)
+ perturbed[[i, j]] = perturbed[[j, i]]
+ pool.append(perturbed)
+ pool += [generator.permutation(size) for _ in range(fresh)]
+ return best, best_energy, trace
+
+
+def main() -> None:
+ args = parse_args()
+ labels = args.labels or [p.split("/")[-1] for p in args.fields]
+ 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)
+ visual_gpu = standardized(torch.from_numpy(visual).to(device)).double()
+ print(f"\n=== {label} (N={size}, chance={1/size:.4f})", flush=True)
+
+ chosen, oracle_best = [], []
+ for trial in range(args.trials):
+ hidden = np.random.default_rng(trial).permutation(size)
+ shuffled = text[np.ix_(hidden, hidden)]
+ text_gpu = standardized(torch.from_numpy(shuffled).to(device)).double()
+ energy = ClosedFormEnergy(text_gpu, visual_gpu, 1.0, 0.0, 256)
+
+ results = []
+ for index in range(args.ladders):
+ generator = np.random.default_rng(1000 * trial + index)
+ permutation, value, _ = ladder(
+ text_gpu, visual_gpu, visual, shuffled, energy,
+ args, generator, device,
+ )
+ accuracy = float((hidden[permutation] == np.arange(size)).mean())
+ results.append((value, accuracy))
+ # selection by energy only -- the hidden pairing is never consulted
+ picked = min(results, key=lambda item: item[0])[1]
+ chosen.append(picked)
+ oracle_best.append(max(r[1] for r in results))
+ print(f" trial {trial}: ladders={[round(a, 3) for _, a in results]} "
+ f"picked-by-energy={picked:.4f}", flush=True)
+
+ row = {
+ "field": label, "size": size, "chance": 1.0 / size,
+ "accuracy_selected_by_energy": float(np.mean(chosen)),
+ "accuracy_if_oracle_picked_best_ladder": float(np.mean(oracle_best)),
+ }
+ rows.append(row)
+ print(f" MEAN selected-by-energy={row['accuracy_selected_by_energy']:.4f} "
+ f"(oracle ladder choice would give "
+ f"{row['accuracy_if_oracle_picked_best_ladder']:.4f})", flush=True)
+
+ write_json(args.output, {
+ "protocol": (
+ "Blind. Hidden permutation generated per trial and read only for "
+ "scoring; the ladder is chosen by energy, never by accuracy."
+ ),
+ "pool": args.pool, "rounds": args.rounds, "ladders": args.ladders,
+ "rows": rows,
+ })
+ print(json.dumps({"done": True}))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/worldalign/consensus_seed.py b/worldalign/consensus_seed.py
new file mode 100644
index 0000000..07a9593
--- /dev/null
+++ b/worldalign/consensus_seed.py
@@ -0,0 +1,180 @@
+"""Find a dozen pairs, not a permutation.
+
+The anchor curve changed what the problem is. Given 5% of the correspondence
+the synthetic world returns 89% of the rest, and given 10% it returns 97.6% --
+so at 256 scenes, twelve correct pairs are worth the whole search. Meanwhile
+the gate says the truth on the caption-omitted field is a strict local minimum
+that our solver misses by 0.44 in energy while landing 4.9% correct. Four point
+nine percent of 256 is twelve pairs. **The solver is already finding roughly as
+many correct pairs as a seed needs; what it cannot do is say which ones they
+are.**
+
+That is a different question and it has a cheap answer. Run many independent
+searches -- different spectral regularisers, different random starts -- and
+count how often each pairing appears. A wrong pair is wrong in a different way
+each time; a right pair is the same every time. The pairs that survive the vote
+become anchors, the rest are read off by assignment against them, and the whole
+thing is repeated with the enlarged anchor set.
+
+Seed precision is the quantity that matters, not seed recall, so it is reported
+separately at every round. Hidden pairs are read only for scoring.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+
+import numpy as np
+import torch
+from scipy.optimize import linear_sum_assignment
+
+from .common import write_json
+from .spectral_match import grampa
+from .synth_fast_gate import 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("--restarts", type=int, default=80)
+ parser.add_argument("--iterations", type=int, default=2000)
+ parser.add_argument("--rounds", type=int, default=4)
+ parser.add_argument("--seed-size", type=int, default=16)
+ parser.add_argument("--trials", type=int, default=3)
+ parser.add_argument("--device", default="cuda:3")
+ parser.add_argument("--output", default="artifacts/synth_v1/consensus_seed.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 assign_against(visual, text, anchors_left, anchors_right, probe):
+ """Match probe scenes by their profiles against an anchored coordinate frame."""
+ left = visual[np.ix_(probe, anchors_left)]
+ right = text[np.ix_(probe, anchors_right)]
+ left = left - left.mean(1, keepdims=True)
+ right = right - right.mean(1, keepdims=True)
+ left = left / left.std(1, keepdims=True).clip(1e-9)
+ right = right / right.std(1, keepdims=True).clip(1e-9)
+ similarity = left @ right.T / left.shape[1]
+ _, columns = linear_sum_assignment(-similarity)
+ margin = similarity[np.arange(len(probe)), columns] - np.partition(
+ similarity, -2, axis=1
+ )[:, -2]
+ return columns, margin
+
+
+def run_trial(visual, text, hidden, args, device) -> dict:
+ size = len(visual)
+ shuffled = text[np.ix_(hidden, hidden)]
+ shuffled_gpu = standardized(torch.from_numpy(shuffled).to(device)).double()
+ visual_gpu = standardized(torch.from_numpy(visual).to(device)).double()
+ generator = np.random.default_rng(0)
+
+ votes = np.zeros((size, size))
+ starts = [grampa(visual, shuffled, eta) for eta in (0.2, 0.5, 1.0, 2.0, 5.0)]
+ starts += [generator.permutation(size) for _ in range(args.restarts)]
+ for start in starts:
+ final = fast_pair_descent(
+ shuffled_gpu, visual_gpu,
+ torch.from_numpy(np.ascontiguousarray(start)).to(device),
+ args.iterations,
+ ).cpu().numpy()
+ votes[np.arange(size), final] += 1.0
+
+ baseline = float((hidden[votes.argmax(1)] == np.arange(size)).mean())
+
+ history = []
+ anchors_left = np.array([], dtype=int)
+ anchors_right = np.array([], dtype=int)
+ for round_index in range(args.rounds):
+ if round_index == 0:
+ confidence = votes.max(1) / len(starts)
+ proposal = votes.argmax(1)
+ else:
+ probe = np.setdiff1d(np.arange(size), anchors_left)
+ columns, margin = assign_against(
+ visual, shuffled, anchors_left, anchors_right, probe
+ )
+ confidence = np.full(size, -np.inf)
+ proposal = np.zeros(size, dtype=int)
+ confidence[probe] = margin
+ proposal[probe] = probe[columns] if False else np.array(
+ [probe[c] for c in columns]
+ )
+ # keep existing anchors
+ confidence[anchors_left] = np.inf
+ proposal[anchors_left] = anchors_right
+
+ take = min(args.seed_size * (round_index + 1), size)
+ chosen = np.argsort(confidence)[::-1][:take]
+ anchors_left = chosen
+ anchors_right = proposal[chosen]
+ correct = int((hidden[anchors_right] == anchors_left).sum())
+ precision = correct / max(len(chosen), 1)
+
+ probe = np.setdiff1d(np.arange(size), anchors_left)
+ if len(anchors_left) >= 4 and len(probe) > 1:
+ columns, _ = assign_against(
+ visual, shuffled, anchors_left, anchors_right, probe
+ )
+ resolved = np.array([probe[c] for c in columns])
+ full_correct = int((hidden[resolved] == probe).sum()) + correct
+ accuracy = full_correct / size
+ else:
+ accuracy = precision * len(chosen) / size
+ history.append({
+ "round": round_index, "anchors": int(len(chosen)),
+ "seed_precision": precision, "accuracy_after_expansion": accuracy,
+ })
+ return {"vote_baseline": baseline, "history": history}
+
+
+def main() -> None:
+ args = parse_args()
+ labels = args.labels or [p.split("/")[-1] for p in args.fields]
+ 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)
+ print(f"\n=== {label} (N={size}, chance={1/size:.4f})", flush=True)
+ trials = []
+ for trial in range(args.trials):
+ hidden = np.random.default_rng(trial).permutation(size)
+ trials.append(run_trial(visual, text, hidden, args, device))
+ print(f" vote-only accuracy {np.mean([t['vote_baseline'] for t in trials]):.4f}",
+ flush=True)
+ for index in range(args.rounds):
+ precision = np.mean([t["history"][index]["seed_precision"] for t in trials])
+ accuracy = np.mean(
+ [t["history"][index]["accuracy_after_expansion"] for t in trials]
+ )
+ anchors = trials[0]["history"][index]["anchors"]
+ print(f" round {index}: {anchors:3d} anchors seed precision={precision:.3f}"
+ f" accuracy after expansion={accuracy:.4f}", flush=True)
+ rows.append({"field": label, "trials": trials})
+ write_json(args.output, {
+ "protocol": (
+ "Many independent descents vote on pairings; the most-agreed pairs "
+ "become anchors and the rest are assigned against them, repeated. "
+ "Hidden pairs are read only for scoring."
+ ),
+ "restarts": args.restarts, "rows": rows,
+ })
+ print(json.dumps({"done": True}))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/worldalign/match_battery.py b/worldalign/match_battery.py
index 45fa26e..9035e62 100644
--- a/worldalign/match_battery.py
+++ b/worldalign/match_battery.py
@@ -123,12 +123,213 @@ def solve_bapg(visual, shuffled, **_):
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
+
+ result = quadratic_assignment(
+ visual, shuffled, method="faq",
+ options={"maximize": True, "n_init": 30, "rng": 0},
+ )
+ return result.col_ind
+
+
+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,
}