From 735f9c7fd202d0eaed9183094d84d365e0e5404d Mon Sep 17 00:00:00 2001 From: YurenHao0426 Date: Sat, 1 Aug 2026 16:20:35 -0500 Subject: Test the new instruments; fix an O(1/n) bias in the degree decomposition Four tests around today's additions. Two failed on first run and both were worth having. The degree decomposition left an O(1/n) residual on a field that is purely additive: excluding the diagonal makes the two-way design unbalanced, so one pass of row and column means does not remove a pure degree effect. Swept to convergence instead. At N=256 the correction moves the reported variance shares by under 0.001, so the refutation of the hubness hypothesis stands unchanged -- but the instrument that produced it now does what it claims. The other failure was the test's own scale: two random 16-dimensional subspaces of R^64 overlap above 0.7 by chance, which is why the real measurements are made at N=256 where the null sits at 1.0. Co-Authored-By: Claude --- worldalign/field_anatomy.py | 29 +++++++-- worldalign/natural_pipeline.py | 42 ++++++++++-- worldalign/rho_at_full_width.py | 137 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 195 insertions(+), 13 deletions(-) create mode 100644 worldalign/rho_at_full_width.py (limited to 'worldalign') diff --git a/worldalign/field_anatomy.py b/worldalign/field_anatomy.py index 6156018..9e04ec6 100644 --- a/worldalign/field_anatomy.py +++ b/worldalign/field_anatomy.py @@ -49,23 +49,38 @@ def correlate(first: np.ndarray, second: np.ndarray) -> float: return float(np.corrcoef(offdiagonal(first), offdiagonal(second))[0, 1]) -def degree_part(matrix: np.ndarray) -> tuple[np.ndarray, np.ndarray]: +def degree_part( + matrix: np.ndarray, sweeps: int = 200 +) -> tuple[np.ndarray, np.ndarray]: """Split into the additive row/column-mean model and its residual. The two-way additive fit m + r_i + c_j is what a pure busyness effect produces: every entry explained by how prominent each of its two scenes is, with nothing said about the pair. + + The fit is swept to convergence rather than taken in one pass. With the + diagonal excluded the design is unbalanced, so a single pass of row and + column means leaves an O(1/n) piece of a pure degree effect behind -- + small, but it is exactly the quantity this function exists to remove. + Alternating the two sweeps converges to the correct fit. """ size = len(matrix) mask = ~np.eye(size, dtype=bool) - work = matrix.copy().astype(np.float64) - np.fill_diagonal(work, np.nan) + work = np.where(mask, matrix.astype(np.float64), np.nan) grand = np.nanmean(work) - rows = np.nanmean(work, axis=1, keepdims=True) - grand - cols = np.nanmean(work, axis=0, keepdims=True) - grand + rows = np.zeros((size, 1)) + cols = np.zeros((1, size)) + for _ in range(sweeps): + residual = work - (grand + rows + cols) + step = np.nanmean(residual, axis=1, keepdims=True) + rows = rows + step + residual = work - (grand + rows + cols) + step_columns = np.nanmean(residual, axis=0, keepdims=True) + cols = cols + step_columns + if max(np.abs(step).max(), np.abs(step_columns).max()) < 1e-12: + break fitted = grand + rows + cols - residual = np.where(mask, work - fitted, 0.0) - return np.where(mask, fitted, 0.0), residual + return np.where(mask, fitted, 0.0), np.where(mask, work - fitted, 0.0) def spectral_strip(matrix: np.ndarray, components: int) -> np.ndarray: diff --git a/worldalign/natural_pipeline.py b/worldalign/natural_pipeline.py index 1a4f63e..1844a6d 100644 --- a/worldalign/natural_pipeline.py +++ b/worldalign/natural_pipeline.py @@ -53,6 +53,9 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--views", type=int, default=1) parser.add_argument("--moment-degree", type=int, default=2, choices=[2, 3]) parser.add_argument("--third-width", type=int, default=12) + parser.add_argument( + "--phrase-encoding", default="mean", choices=["mean", "structured"] + ) parser.add_argument("--seed", type=int, default=0) parser.add_argument("--output", default="artifacts/vg_5k/natural_pipeline.pt") return parser.parse_args() @@ -160,13 +163,39 @@ def main() -> None: vectors = word_vectors(load_phrases(vg_dir / "text_nodes.jsonl", "region_closed"), args) + width = args.word_vectors + + def encode_phrase(phrase: str) -> np.ndarray | None: + """One vector per region description. + + Averaging every word of a phrase collapses "red bus" and "bus red" + and, worse, mixes the thing named with what is said about it. The + synthetic world never had to face this because its captions were + encoded into separate factor blocks. Splitting the head from its + modifiers recovers part of that structure from English word order + alone -- the last token of an English noun phrase is its head -- which + is a fact about one language, not a claim about the other modality, + so it stays inside Tier 0. + """ + tokens = [t for t in re.findall(r"[a-z]+", phrase.lower()) if t in vectors] + if not tokens: + return None + if args.phrase_encoding == "mean": + return np.mean([vectors[t] for t in tokens], axis=0) + head = vectors[tokens[-1]] + modifiers = ( + np.mean([vectors[t] for t in tokens[:-1]], axis=0) + if len(tokens) > 1 + else np.zeros(width) + ) + return np.concatenate([head, modifiers]) + def language_state(node: str) -> np.ndarray | None: - rows = [] - for phrase in records[node]["region_closed"]: - hits = [vectors[token] for token in re.findall(r"[a-z]+", phrase.lower()) - if token in vectors] - if hits: - rows.append(np.mean(hits, axis=0)) + rows = [ + row + for row in (encode_phrase(p) for p in records[node]["region_closed"]) + if row is not None + ] return np.stack(rows) if rows else None def vision_state(node: str, view: int) -> np.ndarray | None: @@ -245,6 +274,7 @@ def main() -> None: "kept_directions": keep, "weight_power": args.weight_power, "moment_degree": args.moment_degree, + "phrase_encoding": args.phrase_encoding, "field_correlation_at_truth": correlation, "recovery_threshold": 0.9, } diff --git a/worldalign/rho_at_full_width.py b/worldalign/rho_at_full_width.py new file mode 100644 index 0000000..c30c5cc --- /dev/null +++ b/worldalign/rho_at_full_width.py @@ -0,0 +1,137 @@ +"""The other axis: recovery against correlation with the shared width intact. + +The truncation ladder varied the width at nearly fixed correlation. This +varies the correlation at full width, by adding independent noise to both +fields, and the two together give the picture the single statistic could not. + +The control matters because it guards against overcorrecting. Showing that +width is a separate necessary condition does not show that correlation stopped +mattering, and natural data is short on both -- correlation 0.725 against 0.93, +width 15 against 26. If full-width fields still recover at a correlation near +0.72, then width is what natural data lacks; if they do not, both gaps are real +and the correlation remains a target rather than a discredited one. +""" + +from __future__ import annotations + +import argparse +import json + +import numpy as np +import torch + +from .common import write_json +from .shared_rank import spectrum +from .spectral_match import grampa +from .synth_fast_gate import ClosedFormEnergy, all_swaps, steepest_descent +from .synth_triangle_gate import standardized + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--fields", default="artifacts/synth_v1/fields_tier0_ws_256.pt") + parser.add_argument("--trials", type=int, default=3) + parser.add_argument("--device", default="cuda:3") + parser.add_argument( + "--noise", type=float, nargs="+", + default=[0.0, 0.2, 0.35, 0.5, 0.7, 0.9, 1.2], + ) + parser.add_argument("--output", default="artifacts/synth_v1/rho_at_full_width.json") + return parser.parse_args() + + +def offdiagonal(matrix: np.ndarray) -> np.ndarray: + return matrix[~np.eye(len(matrix), dtype=bool)] + + +def normalise(matrix: np.ndarray) -> np.ndarray: + values = offdiagonal(matrix) + out = (matrix - values.mean()) / values.std() + np.fill_diagonal(out, 0.0) + return out + + +def symmetric_noise(size: int, generator) -> np.ndarray: + raw = generator.normal(size=(size, size)) + out = (raw + raw.T) / np.sqrt(2.0) + np.fill_diagonal(out, 0.0) + return out + + +def shared_count(visual: np.ndarray, text: np.ndarray, width: int = 32) -> int: + _, visual_vectors = spectrum(visual) + _, text_vectors = spectrum(text) + cosines = np.clip( + np.linalg.svd( + visual_vectors[:, :width].T @ text_vectors[:, :width], compute_uv=False + ), 0.0, 1.0, + ) + return int((cosines > 0.7).sum()) + + +def main() -> None: + args = parse_args() + state = torch.load(args.fields, map_location="cpu", weights_only=False) + visual_clean = normalise(state["visual_field"].double().numpy()) + text_clean = normalise(state["text_field"].double().numpy()) + size = len(visual_clean) + device = torch.device(args.device) + swaps = all_swaps(size, device) + + rows = [] + for level in args.noise: + correlations, accuracies, widths = [], [], [] + for trial in range(args.trials): + generator = np.random.default_rng(1000 + trial) + visual = normalise( + visual_clean + level * symmetric_noise(size, generator) + ) + text = normalise(text_clean + level * symmetric_noise(size, generator)) + mask = ~np.eye(size, dtype=bool) + correlations.append(float(np.corrcoef(visual[mask], text[mask])[0, 1])) + widths.append(shared_count(visual, text)) + + hidden = generator.permutation(size) + shuffled = text[np.ix_(hidden, hidden)] + columns = grampa(visual, shuffled, 1.0) + energy = ClosedFormEnergy( + standardized(torch.from_numpy(shuffled).to(device)).float(), + standardized(torch.from_numpy(visual).to(device)).float(), + 1.0, 1.0, 256, + ) + final, _ = steepest_descent( + energy, torch.from_numpy(columns.copy()).to(device), swaps, 4000 + ) + accuracies.append( + float((hidden[final.cpu().numpy()] == np.arange(size)).mean()) + ) + row = { + "noise": level, + "correlation": float(np.mean(correlations)), + "shared_directions": float(np.mean(widths)), + "recovery": float(np.mean(accuracies)), + } + rows.append(row) + print( + f"noise={level:<5} rho={row['correlation']:.3f} " + f"shared={row['shared_directions']:5.1f} " + f"recovery={row['recovery']:.3f}", + flush=True, + ) + + summary = { + "protocol": ( + "Independent symmetric noise added to both fields, so the shared " + "spectrum stays wide while the correlation falls. Hidden pairing " + "generated per trial and read only for scoring." + ), + "size": size, + "chance": 1.0 / size, + "rows": rows, + } + print(json.dumps({"chance": 1.0 / size})) + write_json(args.output, summary) + + +if __name__ == "__main__": + main() -- cgit v1.2.3