diff options
Diffstat (limited to 'worldalign/spectral_match.py')
| -rw-r--r-- | worldalign/spectral_match.py | 172 |
1 files changed, 172 insertions, 0 deletions
diff --git a/worldalign/spectral_match.py b/worldalign/spectral_match.py new file mode 100644 index 0000000..9e1a0fe --- /dev/null +++ b/worldalign/spectral_match.py @@ -0,0 +1,172 @@ +"""Spectral matching of relation fields: Umeyama and GRAMPA. + +Relation fields are N x N regardless of embedding dimension, so the two +modalities need no common representation dimension. What they do need is +comparable spectra: eigenvector matching degrades when effective ranks +differ or when eigenvalues cluster. GRAMPA is built for that regime -- it +weights every pair of eigenvectors by 1 / ((lambda_i - mu_j)^2 + eta^2) +instead of pairing them one to one -- so both solvers are provided along +with the spectral compatibility diagnostic that predicts whether either +can work. + +No local search: these are polynomial-time solvers that sidestep the +glassy landscape entirely. Hidden pairs score the output only. +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +import numpy as np +import torch +from scipy.optimize import linear_sum_assignment + +from .common import read_json, seed_everything, write_json + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--fields", help="Saved .pt with visual/text fields.") + parser.add_argument("--data-dir", default="artifacts/synth_v0") + parser.add_argument("--split", choices=["val", "test"], default="test") + parser.add_argument("--samples", type=int, default=256) + parser.add_argument("--merge-distance", type=float, default=30.0) + parser.add_argument("--vision-views", type=int, default=4) + parser.add_argument("--eta", default="0.05,0.1,0.2,0.5") + parser.add_argument("--seed", type=int, default=20260731) + parser.add_argument( + "--output", default="artifacts/synth_v0/spectral_match.json" + ) + parser.add_argument("--fields-output", default="") + return parser.parse_args() + + +def spectral_profile(matrix: np.ndarray) -> dict: + values = np.linalg.eigvalsh(matrix)[::-1] + magnitude = np.abs(values) + total = magnitude.sum() + cumulative = np.cumsum(magnitude) / max(total, 1e-12) + participation = (magnitude.sum() ** 2) / max((magnitude**2).sum(), 1e-12) + gaps = np.abs(np.diff(values)) + return { + "top_eigenvalues": values[:12].tolist(), + "effective_rank_participation": float(participation), + "rank_for_90_percent": int(np.searchsorted(cumulative, 0.90) + 1), + "rank_for_99_percent": int(np.searchsorted(cumulative, 0.99) + 1), + "median_relative_gap": float( + np.median(gaps) / max(magnitude.max(), 1e-12) + ), + "min_relative_gap_top20": float( + gaps[:20].min() / max(magnitude.max(), 1e-12) + ), + } + + +def umeyama(visual: np.ndarray, text: np.ndarray) -> np.ndarray: + """Classic eigenvector-magnitude matching, sign ambiguity absorbed.""" + _, u = np.linalg.eigh(visual) + _, v = np.linalg.eigh(text) + score = np.abs(u) @ np.abs(v).T + rows, cols = linear_sum_assignment(-score) + return cols + + +def grampa(visual: np.ndarray, text: np.ndarray, eta: float) -> np.ndarray: + """Pairwise eigen-alignment similarity, robust to clustered spectra.""" + lam, u = np.linalg.eigh(visual) + mu, v = np.linalg.eigh(text) + ones = np.ones(len(visual)) + left = u.T @ ones # [N] + right = v.T @ ones + weight = np.outer(left, right) / ((lam[:, None] - mu[None, :]) ** 2 + eta**2) + similarity = u @ weight @ v.T + rows, cols = linear_sum_assignment(-similarity) + return cols + + +def score_assignment( + assignment: np.ndarray, hidden: np.ndarray, size: int +) -> dict: + """assignment[i] is the shuffled-space index matched to visual row i. + + Shuffled index j denotes original node hidden[j], and visual row i + denotes original node i, so the match is correct when + hidden[assignment[i]] == i. + """ + return { + "accuracy": float((hidden[assignment] == np.arange(size)).mean()), + "chance": 1.0 / size, + } + + +def main() -> None: + args = parse_args() + seed_everything(args.seed) + if args.fields: + state = torch.load(args.fields, map_location="cpu", weights_only=False) + visual_field = state["visual_field"] + text_field = state["text_field"] + else: + from .synth_triangle_gate import build_fields + + manifest = read_json(Path(args.data_dir, "manifest.json")) + rows = manifest[args.split][: args.samples] + visual_field, text_field = build_fields(args, rows, manifest) + if args.fields_output: + torch.save( + {"visual_field": visual_field, "text_field": text_field}, + args.fields_output, + ) + + size = len(visual_field) + visual = visual_field.double().numpy() + text = text_field.double().numpy() + # Center and scale: spectral methods compare shapes, not offsets. + mask = ~np.eye(size, dtype=bool) + for matrix in (visual, text): + values = matrix[mask] + matrix -= values.mean() + matrix /= values.std() + np.fill_diagonal(matrix, 0.0) + + generator = np.random.default_rng(args.seed) + hidden = generator.permutation(size) + text_shuffled = text[np.ix_(hidden, hidden)] + + report = { + "protocol": ( + "Polynomial-time spectral solvers on N x N relation fields; " + "embedding dimensions are irrelevant by construction. The " + "hidden shuffle is applied to the text field and used only to " + "score the returned assignment." + ), + "samples": size, + "spectra": { + "visual": spectral_profile(visual), + "text": spectral_profile(text_shuffled), + }, + "solvers": {}, + } + # Both fields are built over the same row list, so they are aligned at + # the truth without any permutation. + correlation = float(np.corrcoef(visual[mask], text[mask])[0, 1]) + report["field_correlation_at_truth"] = correlation + + assignment = umeyama(visual, text_shuffled) + report["solvers"]["umeyama"] = score_assignment(assignment, hidden, size) + print(json.dumps({"umeyama": report["solvers"]["umeyama"]})) + + for eta in (float(value) for value in args.eta.split(",")): + assignment = grampa(visual, text_shuffled, eta) + result = score_assignment(assignment, hidden, size) + report["solvers"][f"grampa_eta{eta}"] = result + print(json.dumps({f"grampa_eta{eta}": result})) + + write_json(args.output, report) + print(f"Wrote {args.output}") + + +if __name__ == "__main__": + main() |
