summaryrefslogtreecommitdiff
path: root/worldalign/shared_rank.py
diff options
context:
space:
mode:
Diffstat (limited to 'worldalign/shared_rank.py')
-rw-r--r--worldalign/shared_rank.py135
1 files changed, 135 insertions, 0 deletions
diff --git a/worldalign/shared_rank.py b/worldalign/shared_rank.py
new file mode 100644
index 0000000..629ae0b
--- /dev/null
+++ b/worldalign/shared_rank.py
@@ -0,0 +1,135 @@
+"""Which side is narrow? Per-modality rank against the shared rank.
+
+Truncating a recovering synthetic field to rank four leaves its correlation at
+0.90 and its recovery at 6%, so the width of the shared spectrum is a separate
+and binding constraint that the correlation does not express. The engineering
+question follows immediately: which modality is narrow. Upgrading the vision
+encoder is worth its compute only if vision is what limits the shared width,
+and if the text side is the narrow one no vision backbone will help.
+
+Three numbers per field pair. Each field's own effective rank, from the
+participation ratio of its spectrum, says how many directions the modality
+uses at all. The principal angles between the two leading eigenspaces say how
+many of those directions the two modalities share. And the per-direction
+correlation profile says where along the spectrum agreement dies.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+
+import numpy as np
+import torch
+
+from .common import write_json
+
+
+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("--width", type=int, default=32)
+ parser.add_argument("--output", default="artifacts/vg_5k/shared_rank.json")
+ return parser.parse_args()
+
+
+def spectrum(matrix: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
+ symmetric = (matrix + matrix.T) / 2.0
+ values, vectors = np.linalg.eigh(symmetric)
+ order = np.argsort(np.abs(values))[::-1]
+ return values[order], vectors[:, order]
+
+
+def effective_rank(values: np.ndarray) -> float:
+ weights = np.abs(values) / np.abs(values).sum()
+ weights = weights[weights > 1e-15]
+ return float(np.exp(-(weights * np.log(weights)).sum()))
+
+
+def analyse(path: str, label: str, width: int) -> dict:
+ state = torch.load(path, map_location="cpu", weights_only=False)
+ visual = state["visual_field"].double().numpy()
+ text = state["text_field"].double().numpy()
+ size = len(visual)
+ mask = ~np.eye(size, dtype=bool)
+
+ visual_values, visual_vectors = spectrum(visual)
+ text_values, text_vectors = spectrum(text)
+ width = min(width, size)
+
+ # principal angles between the two leading eigenspaces: cosines near one
+ # count as directions the two modalities agree on. The raw count inflates
+ # with width -- two random w-dimensional subspaces of R^N already overlap
+ # at cosines near sqrt(w/N) -- so a scene-shuffled null is subtracted and
+ # every comparison is made at matched width.
+ overlap = visual_vectors[:, :width].T @ text_vectors[:, :width]
+ cosines = np.clip(np.linalg.svd(overlap, compute_uv=False), 0.0, 1.0)
+ shared = int((cosines > 0.7).sum())
+
+ null_counts = []
+ for trial in range(5):
+ generator = np.random.default_rng(trial)
+ order = generator.permutation(size)
+ shuffled = text[np.ix_(order, order)]
+ _, shuffled_vectors = spectrum(shuffled)
+ null_overlap = visual_vectors[:, :width].T @ shuffled_vectors[:, :width]
+ null_cosines = np.clip(np.linalg.svd(null_overlap, compute_uv=False), 0.0, 1.0)
+ null_counts.append(int((null_cosines > 0.7).sum()))
+ null = float(np.mean(null_counts))
+
+ # where along the spectrum does agreement die
+ profile = []
+ for index in range(min(width, 16)):
+ visual_direction = visual_vectors[:, index]
+ best = float(np.abs(text_vectors[:, :width].T @ visual_direction).max())
+ profile.append({"index": index, "best_text_overlap": best})
+
+ return {
+ "label": label,
+ "size": size,
+ "correlation": float(np.corrcoef(visual[mask], text[mask])[0, 1]),
+ "visual_effective_rank": effective_rank(visual_values),
+ "text_effective_rank": effective_rank(text_values),
+ "shared_directions_cos_gt_0.7": shared,
+ "shuffled_null": null,
+ "shared_above_null": shared - null,
+ "principal_cosines_top8": [round(float(c), 3) for c in cosines[:8]],
+ "spectrum_profile": profile,
+ }
+
+
+def main() -> None:
+ args = parse_args()
+ labels = args.labels or [path.split("/")[-1] for path in args.fields]
+ rows = [analyse(path, label, args.width)
+ for path, label in zip(args.fields, labels)]
+ for row in rows:
+ print(
+ f"{row['label']:<26} rho={row['correlation']:.3f} "
+ f"eff_rank vision={row['visual_effective_rank']:6.1f} "
+ f"text={row['text_effective_rank']:6.1f} "
+ f"shared={row['shared_directions_cos_gt_0.7']:3d} "
+ f"(null {row['shuffled_null']:4.1f}, net {row['shared_above_null']:5.1f})",
+ flush=True,
+ )
+ summary = {
+ "protocol": (
+ "Effective rank from the participation ratio of each field's "
+ "spectrum; shared directions from principal angles between the "
+ "two leading eigenspaces. No pairing enters beyond the alignment "
+ "the fields already carry."
+ ),
+ "width": args.width,
+ "rows": rows,
+ "reading": (
+ "The narrow modality is the one to upgrade. A shared count far "
+ "below both effective ranks means each side is rich but they are "
+ "rich about different things."
+ ),
+ }
+ write_json(args.output, summary)
+
+
+if __name__ == "__main__":
+ main()