summaryrefslogtreecommitdiff
path: root/worldalign/field_geometry.py
blob: 9360427eac68d0187ff98d2b9613afd2c4848009 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
"""The space and the operators, which we never varied.

Everything measured so far fixed one geometry without saying so. Scene states
are compared by Euclidean inner product, the relation field holds those raw
values, and two fields are compared by Pearson correlation -- three
commitments to flatness made by default rather than by argument. The
measured deficit is that the two modalities disagree about which scenes are
similar, and "similar" is exactly what those three choices define.

The mismatch that costs the most is likely the cheapest to remove. Two
independently trained encoders have no reason to put their similarities on
the same scale: if vision similarity is any monotone but nonlinear function
of text similarity, the two fields describe identical relational structure
and Pearson correlation still reports disagreement. A rank transform is
invariant to every monotone reparametrisation and costs one sort.

Density is the second. A scene sitting in a crowded region of one modality
and a sparse region of the other has systematically shifted similarities to
everything, which local scaling and the diffusion kernel both remove -- the
latter by replacing similarity with how probability spreads, a quantity
defined by the graph rather than by whatever units the encoder happened to
emit.

Each transform is applied to each modality independently, so nothing here
uses the pairing or crosses the gauge.
"""

from __future__ import annotations

import argparse
import json

import numpy as np
import torch
from scipy.optimize import linear_sum_assignment
from scipy.stats import rankdata

from .common import write_json


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser()
    parser.add_argument("--fields", default="artifacts/vg_5k/np_n256.pt")
    parser.add_argument("--label", default="natural-best")
    parser.add_argument("--repeats", type=int, default=5)
    parser.add_argument("--output", default="artifacts/vg_5k/field_geometry.json")
    return parser.parse_args()


def offdiagonal_mask(size: int) -> np.ndarray:
    return ~np.eye(size, dtype=bool)


def standardise(matrix: np.ndarray) -> np.ndarray:
    mask = offdiagonal_mask(len(matrix))
    values = matrix[mask]
    out = (matrix - values.mean()) / values.std()
    np.fill_diagonal(out, 0.0)
    return out


# --- geometries, each applied within one modality -------------------------

def identity(field: np.ndarray) -> np.ndarray:
    return standardise(field)


def global_rank(field: np.ndarray) -> np.ndarray:
    """Copula transform: keep the ordering of similarities, discard the scale."""
    size = len(field)
    mask = offdiagonal_mask(size)
    out = np.zeros_like(field)
    out[mask] = rankdata(field[mask]) / mask.sum()
    return standardise(out)


def row_rank(field: np.ndarray) -> np.ndarray:
    """Rank within each scene's own neighbourhood, removing per-scene scale."""
    size = len(field)
    out = np.zeros_like(field)
    for row in range(size):
        others = np.delete(np.arange(size), row)
        out[row, others] = rankdata(field[row, others]) / len(others)
    out = (out + out.T) / 2.0
    return standardise(out)


def local_scaling(field: np.ndarray, neighbours: int = 10) -> np.ndarray:
    """Self-tuning: divide by each scene's own neighbourhood scale (CSLS-like)."""
    size = len(field)
    work = field.copy()
    np.fill_diagonal(work, -np.inf)
    order = np.sort(work, axis=1)[:, ::-1]
    scale = order[:, :neighbours].mean(1)
    out = field - 0.5 * (scale[:, None] + scale[None, :])
    np.fill_diagonal(out, 0.0)
    return standardise(out)


def diffusion(field: np.ndarray, steps: int = 3) -> np.ndarray:
    """Replace similarity by how probability spreads on the similarity graph."""
    size = len(field)
    affinity = field - field.min()
    np.fill_diagonal(affinity, 0.0)
    degree = affinity.sum(1).clip(1e-9)
    walk = affinity / degree[:, None]
    powered = np.linalg.matrix_power(walk, steps)
    out = powered @ powered.T
    np.fill_diagonal(out, 0.0)
    return standardise(out)


def heat_kernel(field: np.ndarray, time: float = 1.0) -> np.ndarray:
    """Heat flow on the normalised Laplacian: a metric the graph defines itself."""
    size = len(field)
    affinity = field - field.min()
    np.fill_diagonal(affinity, 0.0)
    degree = affinity.sum(1).clip(1e-9)
    normalised = affinity / np.sqrt(np.outer(degree, degree))
    values, vectors = np.linalg.eigh((normalised + normalised.T) / 2.0)
    out = (vectors * np.exp(time * (values - values.max()))) @ vectors.T
    np.fill_diagonal(out, 0.0)
    return standardise(out)


def hyperbolic(field: np.ndarray, dimension: int = 16) -> np.ndarray:
    """Embed each field in hyperbolic space and re-derive distances there.

    If scene similarity is hierarchical -- broad categories containing finer
    ones -- a flat inner product cannot hold it without distortion, while
    negative curvature can. The field is embedded by eigenmap and read back
    as Lorentzian distance.
    """
    size = len(field)
    values, vectors = np.linalg.eigh((field + field.T) / 2.0)
    order = np.argsort(values)[::-1][:dimension]
    coordinates = vectors[:, order] * np.sqrt(np.abs(values[order]))
    scale = np.abs(coordinates).max().clip(1e-9)
    spatial = coordinates / scale * 0.9
    time = np.sqrt(1.0 + (spatial ** 2).sum(1))
    product = np.outer(time, time) - spatial @ spatial.T
    out = -np.arccosh(np.clip(product, 1.0, None))
    np.fill_diagonal(out, 0.0)
    return standardise(out)


GEOMETRIES = {
    "euclidean (current)": identity,
    "global rank (copula)": global_rank,
    "row rank": row_rank,
    "local scaling": local_scaling,
    "diffusion t=3": diffusion,
    "heat kernel": heat_kernel,
    "hyperbolic d=16": hyperbolic,
}


# --- comparison operators -------------------------------------------------

def anchor_bound(first: np.ndarray, second: np.ndarray, repeats: int,
                 spearman: bool = False) -> 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)]
            if spearman:
                block = np.apply_along_axis(rankdata, 1, block)
            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 main() -> None:
    args = parse_args()
    state = torch.load(args.fields, map_location="cpu", weights_only=False)
    visual = state["visual_field"].double().numpy()
    text = state["text_field"].double().numpy()
    mask = offdiagonal_mask(len(visual))

    rows = []
    print("%-24s %8s %10s %12s" % ("geometry", "rho", "bound", "bound+rank-op"))
    for name, transform in GEOMETRIES.items():
        try:
            first, second = transform(visual), transform(text)
        except Exception as error:  # a geometry that cannot be formed is a result
            print("%-24s  failed: %s" % (name, error))
            continue
        rho = float(np.corrcoef(first[mask], second[mask])[0, 1])
        plain = anchor_bound(first, second, args.repeats)
        ranked = anchor_bound(first, second, args.repeats, spearman=True)
        rows.append({"geometry": name, "correlation": rho,
                     "anchor_bound": plain, "anchor_bound_rank_operator": ranked})
        print("%-24s %8.3f %10.4f %12.4f" % (name, rho, plain, ranked))

    best = max(rows, key=lambda row: max(row["anchor_bound"],
                                         row["anchor_bound_rank_operator"]))
    write_json(args.output, {
        "protocol": (
            "Each geometry is applied to each modality independently, so no "
            "transform crosses the gauge or uses the pairing. The bound is the "
            "anchor upper bound; the last column re-computes it with a rank "
            "comparison operator instead of a linear one."
        ),
        "label": args.label,
        "rows": rows,
        "best": best,
    })
    print(json.dumps(best))


if __name__ == "__main__":
    main()