File size: 7,397 Bytes
a1dd5ba
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""CHANNEL STUDENTS: an FDNN head per teacher, over vectors already computed.

The teachers - PE, SigLIP2 so400m, InternVideo2-1B, XCLIP, V-JEPA2 -
were built for accuracy and they are not deployable. Measured per
episode at write time: pe 1.17 s, sig2 0.70, iv2 1.14, xclip 0.49,
before V-JEPA2 runs twice more. That is the retrieval path costing an
order of magnitude more than the entire rest of the write, which makes
the teachers part of the product instead of part of the training.

A teacher's job is to be RIGHT. A student's job is to be THERE.

THE INPUT IS ALREADY PAID FOR. The write path streams every frame
through FDNN-V and stores a 1152-d vector per frame, at 0.020 s per
episode. Every channel output is a function of the same pixels, so
every channel can be a small head over those vectors instead of a
second pass over the video. The teacher is run ONCE, offline, to make
training pairs; after that it never touches an ingest again.

SKELETON, not a bag of layers. Each teacher pools a SPAN of frames into
one vector - a window for PE and SigLIP2, a whole clip for IV2, XCLIP
and V-JEPA2 - so the student mirrors that shape:

    attention pool over the span's frame vectors   (one learned query,
                                                    the teacher's own
                                                    aggregation step)
    pre-norm residual MLP -> the teacher's dimension
    L2 normalise                                   (every consumer
                                                    compares by cosine,
                                                    so the norm is not
                                                    part of the signal)

Mean pooling was the obvious first choice and it is wrong for the same
reason it is wrong in the teachers: a window's meaning is carried by a
few frames, and averaging buries them under the static ones.

Inference is deliberately NUMPY. The head is two small matmuls; running
them under a training framework costs more in dispatch than the
arithmetic, and the write path should not have to import one.
"""
from __future__ import annotations

import json
from pathlib import Path

import numpy as np

# teacher output dimensions, by channel
DIMS = {"pe": 1024, "sig2": 1152, "iv2": 512, "xclip": 768,
        "vjepa": 1024, "act": 174}
FDNNV_D = 1152


def _l2(x, axis=-1):
    return x / (np.linalg.norm(x, axis=axis, keepdims=True) + 1e-8)


class Head:
    """A distilled channel. Loads from .npz, runs in numpy."""

    def __init__(self, w: dict):
        self.q = w["q"]                 # (d_in,)      pooling query
        self.W1, self.b1 = w["W1"], w["b1"]
        self.W2, self.b2 = w["W2"], w["b2"]
        self.Wp, self.bp = w["Wp"], w["bp"]
        self.g, self.beta = w["g"], w["beta"]

    @staticmethod
    def load(path):
        z = np.load(path)
        return Head({k: z[k] for k in z.files})

    def pool(self, X):
        """Attention-pool (n_frames, d) -> (d,) with one learned query."""
        if len(X) == 0:
            return np.zeros(self.q.shape[0], np.float32)
        s = X @ self.q
        s -= s.max()
        a = np.exp(s)
        a /= a.sum() + 1e-8
        return (a[:, None] * X).sum(0)

    def __call__(self, X):
        """(n_frames, 1152) frame vectors of one span -> teacher vector."""
        h = self.pool(np.asarray(X, np.float32))
        # pre-norm: the pooled vector's scale varies with span length
        h = self.g * (h - h.mean()) / (h.std() + 1e-6) + self.beta
        z = np.maximum(h @ self.W1 + self.b1, 0)          # ReLU
        h = h + z @ self.W2 + self.b2                     # residual
        return _l2(h @ self.Wp + self.bp)

    def batch(self, spans):
        """Many spans at once - the write path's actual call shape."""
        return np.stack([self(X) for X in spans]) if spans else \
            np.zeros((0, self.Wp.shape[1]), np.float32)


def pairs(store, channel, frames_table="frame_vectors"):
    """(list of frame-vector spans, teacher matrix) for one channel.

    A teacher row carries ts..t1; the student sees exactly the frame
    vectors inside that span, which is the same evidence the teacher
    had and no more. Spans with no frames are dropped rather than
    zero-filled - a student taught to map emptiness to a real vector
    learns to hallucinate.
    """
    import pyarrow.compute as pc
    tv = store.table(f"{channel}_vectors").scan()
    fv = store.table(frames_table).scan()
    fts = np.asarray(fv.column("ts").to_pylist(), np.int64)
    fst = np.asarray([str(s) for s in fv.column("stream").to_pylist()])
    F = np.asarray(fv.column("vector").to_pylist(), np.float32)

    order = np.argsort(fts, kind="stable")
    fts, fst, F = fts[order], fst[order], F[order]

    t0 = np.asarray(tv.column("ts").to_pylist(), np.int64)
    t1 = np.asarray(tv.column("t1").to_pylist(), np.int64)
    tst = np.asarray([str(s) for s in tv.column("stream").to_pylist()])
    T = _l2(np.asarray(tv.column("vector").to_pylist(), np.float32))

    X, Y, keys = [], [], []
    for i in range(len(t0)):
        lo = np.searchsorted(fts, t0[i], "left")
        hi = np.searchsorted(fts, t1[i], "right")
        if hi <= lo:
            continue
        sel = slice(lo, hi)
        m = fst[sel] == tst[i]
        if not m.any():
            continue
        X.append(F[sel][m])
        Y.append(T[i])
        keys.append((tst[i], int(t0[i])))
    return X, (np.stack(Y) if Y else np.zeros((0, 1), np.float32)), keys


def init(d_in=FDNNV_D, d_out=1024, hidden=512, seed=0):
    r = np.random.default_rng(seed)
    def n(*s):
        return (r.normal(size=s) / np.sqrt(s[0])).astype(np.float32)
    return {"q": n(d_in), "W1": n(d_in, hidden), "b1": np.zeros(hidden, np.float32),
            "W2": n(hidden, d_in), "b2": np.zeros(d_in, np.float32),
            "Wp": n(d_in, d_out), "bp": np.zeros(d_out, np.float32),
            "g": np.ones(d_in, np.float32), "beta": np.zeros(d_in, np.float32)}


def fidelity(head, X, Y):
    """Cosine to the teacher, ITS TRIVIAL BASELINE, and rank agreement.

    Cosine alone is not weak, it is actively misleading here, and the
    first PE student proved it: 0.9168 test cosine, which reads like a
    working student until you compute what a CONSTANT prediction of the
    corpus mean scores - 0.8629. The teacher's space is anisotropic
    (mean pairwise cosine 0.885), so almost all of that 0.92 is the
    shared mean and almost none of it is the episode. Nearest-neighbour
    agreement was 0.005.

    So every report carries `mean_baseline` next to `cosine`, and the
    number that decides whether a student ships is rank agreement -
    ranking is the only thing a retrieval channel is ever used for.
    """
    P = head.batch(X)
    cos = float(np.mean(np.sum(P * Y, 1)))
    mu = _l2(Y.mean(0))
    base = float(np.mean(Y @ mu))
    n = min(len(P), 400)
    Sp, St = P[:n] @ P[:n].T, Y[:n] @ Y[:n].T
    np.fill_diagonal(Sp, -9); np.fill_diagonal(St, -9)
    top1 = float(np.mean(Sp.argmax(1) == St.argmax(1)))
    k = min(10, n - 1)
    rp = np.argsort(-Sp, 1)[:, :k]
    rt = np.argsort(-St, 1)[:, :k]
    rec = float(np.mean([len(set(a) & set(b)) / k for a, b in zip(rp, rt)]))
    return {"cosine": round(cos, 4), "mean_baseline": round(base, 4),
            "lift_over_mean": round(cos - base, 4),
            "nn_top1": round(top1, 4), "nn_recall@10": round(rec, 4)}