diff options
Diffstat (limited to 'worldalign/split_half.py')
| -rw-r--r-- | worldalign/split_half.py | 190 |
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() |
