summaryrefslogtreecommitdiff
path: root/worldalign/split_half.py
diff options
context:
space:
mode:
authorYurenHao0426 <Blackhao0426@gmail.com>2026-08-01 17:59:32 -0500
committerYurenHao0426 <Blackhao0426@gmail.com>2026-08-01 17:59:32 -0500
commit4f7ee05cc3b072478062e53645af016861c4b529 (patch)
treee7dc4e428f3dbe199b40a23be49019846430128e /worldalign/split_half.py
parentf29c41e78da10d3c40afdd2deeb43c4d73f5eb43 (diff)
The failure is the optimiser, not the information: a 257x faster descent and a benchmark
The gate settles which failure mode each field is in, and refutes the symmetry hypothesis I proposed. On the caption-omitted field the truth is a STRICT local minimum -- descent started at the truth does not move at all -- the anchor bound says the information is 99.7% intact, and our solver stops 0.44 above it at 4.9% accuracy. That is a pure optimiser failure. Natural data is the opposite: descent from the truth falls a further 0.167, so the truth is not even locally optimal, which is the information-deficit signature the 0.291 bound predicted. Steepest descent was brute-forcing all 32,640 candidate permutations through the full energy every step, including a batched cube trace with the triangle term active -- 203 seconds per descent, which is why the gates were hopeless. The pairwise term needs one matrix product for the whole table: swapping p,q changes the alignment sum by 2(C_pq + C_qp - C_pp - C_qq + 2 A_pq B_pq) with C = A @ B. Verified against brute force to 1e-9 before use, and the fast descent reaches the same optimum. 203s -> 0.79s. Adds a matching benchmark with known-reachable answers and the solver families never tried on these fields: Gromov-Wasserstein, entropic GW with an annealed regulariser, BAPG. Co-Authored-By: Claude <noreply@anthropic.com>
Diffstat (limited to 'worldalign/split_half.py')
-rw-r--r--worldalign/split_half.py190
1 files changed, 190 insertions, 0 deletions
diff --git a/worldalign/split_half.py b/worldalign/split_half.py
new file mode 100644
index 0000000..ee3f273
--- /dev/null
+++ b/worldalign/split_half.py
@@ -0,0 +1,190 @@
+"""The ceiling nobody measured: can a modality identify a scene against itself?
+
+Every deficit so far has been read as a cross-modal one, and that assumed each
+side describes its scenes well enough to identify them. Nothing tested the
+assumption. Split each scene's parts into two disjoint halves -- odd and even
+regions, odd and even segments -- build a relation field from each half, and
+match the halves against each other with the anchor bound. Both halves come
+from the same modality and the same scene, so any failure is that modality
+failing to pin down its own scenes.
+
+This upper-bounds anything cross-modal. If one half of an image's description
+cannot pick that image out from among the others given the other half, no
+vision encoder can be blamed for failing to, and no better cross-modal recipe
+exists to find. It costs one pass over data already on disk, and it should have
+been the first measurement on natural data rather than one of the last.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import re
+from pathlib import Path
+
+import numpy as np
+import torch
+import torch.nn.functional as F
+from scipy.optimize import linear_sum_assignment
+
+from .common import seed_everything, write_json
+from .natural_families import load_phrases
+from .natural_pipeline import content_directions, word_vectors
+from .synth_cc_battery import moment_field
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--vg-dir", default="artifacts/vg_5k")
+ parser.add_argument("--objects", default="artifacts/vg_5k/nobj_base_gpu.pt")
+ parser.add_argument("--samples", type=int, default=256)
+ parser.add_argument("--fit-scenes", type=int, default=1200)
+ parser.add_argument("--word-vectors", type=int, default=128)
+ parser.add_argument("--min-count", type=int, default=60)
+ parser.add_argument("--max-vocabulary", type=int, default=600)
+ parser.add_argument("--context-window", type=int, default=2)
+ parser.add_argument("--keep", type=int, default=64)
+ parser.add_argument("--shrinkage", type=float, default=0.05)
+ parser.add_argument("--weight-power", type=float, default=0.5)
+ parser.add_argument("--seed", type=int, default=0)
+ parser.add_argument("--output", default="artifacts/vg_5k/split_half.json")
+ return parser.parse_args()
+
+
+def standardise(matrix: np.ndarray) -> np.ndarray:
+ values = matrix[~np.eye(len(matrix), dtype=bool)]
+ out = (matrix - values.mean()) / values.std()
+ np.fill_diagonal(out, 0.0)
+ return out
+
+
+def anchor_bound(first: np.ndarray, second: np.ndarray, repeats: int = 5) -> float:
+ size = len(first)
+ scores = []
+ for repeat in range(repeats):
+ generator = np.random.default_rng(repeat)
+ order = generator.permutation(size)
+ half = size // 2
+ anchors, probe = order[:half], order[half:]
+
+ def profile(field):
+ block = field[np.ix_(probe, anchors)]
+ block = block - block.mean(1, keepdims=True)
+ return block / block.std(1, keepdims=True).clip(1e-9)
+
+ similarity = profile(first) @ profile(second).T / half
+ _, columns = linear_sum_assignment(-similarity)
+ scores.append(float((columns == np.arange(len(probe))).mean()))
+ return float(np.mean(scores))
+
+
+def build_field(sets, fitted, keep, power) -> torch.Tensor:
+ centre, basis, values = fitted
+ width = min(keep, basis.shape[1])
+ scale = values[:width] ** power if power else 1.0
+ projected = [
+ F.normalize(
+ torch.tensor((item - centre) @ basis[:, :width] * scale, dtype=torch.float32),
+ dim=-1,
+ )
+ for item in sets
+ ]
+ return moment_field(projected)
+
+
+def main() -> None:
+ args = parse_args()
+ seed_everything(args.seed)
+ vg_dir = Path(args.vg_dir)
+ truth = [
+ json.loads(line)
+ for line in (vg_dir / "ground_truth.private.jsonl").read_text(encoding="utf-8").splitlines()
+ if line.strip()
+ ]
+ records = {
+ json.loads(line)["node_id"]: json.loads(line)
+ for line in (vg_dir / "text_nodes.jsonl").read_text(encoding="utf-8").splitlines()
+ if line.strip()
+ }
+ state = torch.load(args.objects, map_location="cpu", weights_only=False)
+ index = {node: position for position, node in enumerate(state["node_ids"])}
+ vectors = word_vectors(load_phrases(vg_dir / "text_nodes.jsonl", "region_closed"), args)
+
+ def text_parts(node):
+ rows = []
+ for phrase in records[node]["region_closed"]:
+ hits = [vectors[t] for t in re.findall(r"[a-z]+", phrase.lower()) if t in vectors]
+ if hits:
+ rows.append(np.mean(hits, axis=0))
+ return rows
+
+ def vision_parts(node):
+ return [s["feature"].astype(np.float64) for s in state["segments"][index[node]]]
+
+ pairs = [p for p in truth if p["vision_node_id"] in index and p["text_node_id"] in records]
+
+ def collect(getter, key, rows, minimum):
+ out = []
+ for pair in rows:
+ parts = getter(pair[key])
+ out.append(np.stack(parts) if len(parts) >= minimum else None)
+ return out
+
+ results = {}
+ for name, getter, key in (
+ ("text", text_parts, "text_node_id"),
+ ("vision", vision_parts, "vision_node_id"),
+ ):
+ fit_rows = pairs[args.samples : args.samples + args.fit_scenes]
+ fit = [x for x in collect(getter, key, fit_rows, 2) if x is not None]
+ fitted = content_directions(fit, args.shrinkage)
+
+ evaluated, first_half, second_half = [], [], []
+ for pair in pairs[: args.samples]:
+ parts = getter(pair[key])
+ if len(parts) < 4:
+ continue
+ stacked = np.stack(parts)
+ evaluated.append(pair)
+ first_half.append(stacked[0::2])
+ second_half.append(stacked[1::2])
+
+ field_a = standardise(
+ build_field(first_half, fitted, args.keep, args.weight_power).double().numpy()
+ )
+ field_b = standardise(
+ build_field(second_half, fitted, args.keep, args.weight_power).double().numpy()
+ )
+ mask = ~np.eye(len(evaluated), dtype=bool)
+ results[name] = {
+ "scenes": len(evaluated),
+ "mean_parts": float(np.mean([len(getter(p[key])) for p in evaluated])),
+ "split_half_correlation": float(
+ np.corrcoef(field_a[mask], field_b[mask])[0, 1]
+ ),
+ "split_half_anchor_bound": anchor_bound(field_a, field_b),
+ }
+ print(
+ f"{name:<7} scenes={results[name]['scenes']:4d} "
+ f"parts={results[name]['mean_parts']:5.1f} "
+ f"rho={results[name]['split_half_correlation']:.3f} "
+ f"self-identification bound={results[name]['split_half_anchor_bound']:.3f}",
+ flush=True,
+ )
+
+ summary = {
+ "protocol": (
+ "Each scene's parts split into disjoint halves within one modality; "
+ "a relation field is built from each half and the two are matched by "
+ "the anchor bound. Both halves describe the same scenes, so this "
+ "upper-bounds any cross-modal result."
+ ),
+ "results": results,
+ "cross_modal_bound_for_reference": 0.291,
+ }
+ print(json.dumps(summary["results"]))
+ write_json(args.output, summary)
+
+
+if __name__ == "__main__":
+ main()