Spaces:
Running
Running
File size: 6,877 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 | """ITM cross-encoder rerank — the discarded head, wired for live queries.
InternVideo2's checkpoint ships an `itm_head` that every load discarded
("Some weights ... were not used"). It is the cross-encoder half of the
model's own retrieval recipe (BLIP-2 arXiv 2301.12597): text tokens
cross-attend to video tokens, a 2-class head reads the fused CLS. It
out-ranks everything else in this store — measured at k=1.5xsupport,
ITM alone 0.38/0.25 against the cosine ensemble's 0.32/0.21 and the
shipped RRF path's 0.29/0.23.
Two measurements decide the shape of this module:
NO POOLING. Vision tokens are 1,025 x 1,408 per episode = 3.24 GB
corpus-wide, four times the raw source, so storing them in the store
is out. Pooling them is worse than out: 16x16 -> 8x8 per frame drops
Spearman against the full-token score to 0.18, and to NEGATIVE on two
of three probe queries. Cross-attention does not tolerate a reduced
key set. So the tokens are a disposable CACHE outside the store's
byte budget, never a table.
CASCADE, NOT SCAN. Reranking the top-N of the cheap fused ranking
reaches the full-scan number exactly: N=500 gives 0.40/0.27, N=150
already gives 0.38/0.25, and every query with support under ~200 is
saturated at N=150. So N scales with the ceiling k rather than the
corpus, which is L7 ("ITM is evidence inside a candidate set, not
authority over the corpus") arrived at from the cost side.
ITM IS A STAGE, NOT A CHANNEL — and that distinction was measured, not
chosen. Making it a weighted RRF voter and refitting cost 0.38 -> 0.27
for two compounding reasons: RRF converts every channel to RANKS, which
throws away the logit margin's scale, and that scale is where the
cross-encoder's separation lives (the 0.40 offline number came from
adding z-scores, not ranks); and the k-ladder refit then optimized the
ladder mean away from the 1.5x-support operating point the product
metric uses. So ITM stays a rerank STAGE alongside NMS and the
confidence cut - stages the fitter switches on and off rather than
weights - and the fit models it as such.
The cache is content-addressed by (episode, model) and capped; deleting
it costs time, never correctness.
"""
from __future__ import annotations
import hashlib
from pathlib import Path
import numpy as np
_S: dict = {}
# 3.24 GB holds the whole 1,122-episode corpus. This is a DISPOSABLE
# cache under the store's _cache/, not a table: it never enters the
# store's byte ledger, and deleting it costs recompute time, never
# correctness. Override with ELIDEDB_ITM_CACHE_GB.
CACHE_CAP_GB = float(__import__("os").environ.get(
"ELIDEDB_ITM_CACHE_GB", "4.0"))
NF = 4
def _head():
"""The checkpoint's own 2-class matcher — AutoModel drops it because
the vendored class never declares the attribute."""
if "head" in _S:
return _S["head"], _S["m"], _S["dev"]
import torch
from .iv2 import MDIR, load_model
sd = torch.load(f"{MDIR}/pytorch_model.bin", map_location="cpu",
weights_only=True, mmap=True)
m, dev = load_model()
h = torch.nn.Linear(sd["itm_head.weight"].shape[1], 2)
h.load_state_dict({"weight": sd["itm_head.weight"],
"bias": sd["itm_head.bias"]})
h = h.to(dev, m.dtype).eval()
_S["head"], _S["m"], _S["dev"] = h, m, dev
return h, m, dev
def _cache_dir(store):
d = Path(store.dir) / "_cache" / "itm_tokens"
d.mkdir(parents=True, exist_ok=True)
return d
def _cache_path(store, stream, ts):
key = hashlib.sha1(f"{stream}|{ts}|iv2-1b|{NF}".encode()).hexdigest()
return _cache_dir(store) / f"{key}.npy"
def _evict(store):
"""Keep the disposable cache under CACHE_CAP_GB, oldest first."""
d = _cache_dir(store)
files = sorted(d.glob("*.npy"), key=lambda p: p.stat().st_mtime)
total = sum(p.stat().st_size for p in files)
cap = CACHE_CAP_GB * 1e9
while total > cap and files:
p = files.pop(0)
total -= p.stat().st_size
try:
p.unlink()
except OSError:
pass
def _vision_tokens(store, frames_tbl, key):
"""Cached (1, T, C) vision tokens for one episode."""
import cv2
import torch
from .iv2 import V_MEAN, V_STD
from .video import FrameSet
s, a, b = key
p = _cache_path(store, s, a)
_, m, dev = _head()
if p.exists():
arr = np.load(p)
return torch.from_numpy(arr).to(dev, m.dtype)
import pyarrow.compute as pc
sel = frames_tbl.filter(pc.and_(
pc.equal(frames_tbl.column("stream"), s),
pc.and_(pc.greater_equal(frames_tbl.column("ts"), a),
pc.less_equal(frames_tbl.column("ts"), b))))
if len(sel) < 2:
return None
pi = np.linspace(0, len(sel) - 1, min(NF, len(sel))).round().astype(int)
try:
dec = FrameSet(store, "frames", sel.take(pi)).decode(width=224)
except Exception:
return None
fr = [f for _, f in sorted(dec)]
if len(fr) < 2:
return None
fs = [cv2.resize(f, (224, 224)) for f in fr]
x = (np.stack(fs).astype(np.float32) / 255.0 - V_MEAN) / V_STD
px = torch.from_numpy(x).permute(0, 3, 1, 2)[None].to(dev, m.dtype)
with torch.no_grad():
vis, _ = m.encode_vision(px, test=True)
np.save(p, vis.cpu().numpy())
_evict(store)
return vis
def itm_scores(store, text, keys):
"""Logit margin P(match) - P(no match) for each candidate episode.
Returns np.array aligned with `keys`; NaN where frames are
undecodable. The vision pass dominates and is query-independent, so
a repeated query over the same candidates is nearly free."""
import torch
head, m, dev = _head()
frames_tbl = store.table("frames").scan()
tok = m.tokenizer(text, padding="max_length", truncation=True,
max_length=m._config.max_txt_l,
return_tensors="pt").to(dev)
out = np.full(len(keys), np.nan, np.float32)
for i, k in enumerate(keys):
vis = _vision_tokens(store, frames_tbl, k)
if vis is None:
continue
with torch.no_grad():
vam = torch.ones(vis.shape[:2], dtype=torch.long, device=dev)
o = m.get_text_encoder()(
tok.input_ids, attention_mask=tok.attention_mask,
encoder_hidden_states=vis, encoder_attention_mask=vam,
return_dict=True, mode="multi_modal")
lg = head(o.last_hidden_state[:, 0]).float()[0]
out[i] = float(lg[1] - lg[0])
return out
def rerank_depth(k_max, n_total):
"""Candidates to rerank. Measured: N=150 saturates every query with
support under ~200; only the 247-support query needed 500. Scaling
with the ceiling rather than the corpus keeps the cost proportional
to what the caller actually asked for."""
return int(min(max(150, 2 * k_max), n_total))
|