PoPTard5 / model-3 /scripts /corrob_gate.py
Jyo-K's picture
Upload folder using huggingface_hub
e79d917 verified
Raw History Blame Contribute Delete
7.76 kB
"""THE LAST UNTESTED LEVER: S2<->S3 corroboration.
Two independently-trained cross-encoders - bge-reranker-v2-m3 (XLM-R 568M) and Chinmay's
mDeBERTa-v3 (278M) - reject the SAME true pairs. Blending them gained +0.00032 held out, and of
the true pairs the first rejects the second promotes 7.71% while also promoting 0.0602% of the
negatives: a 0.52:1 trade against a ~3.76:1 break-even.
I concluded from that those pairs were irreducibly ambiguous. That was WRONG, and the public
leaderboard maximum of 0.990788 proves it. Both models read ONLY "name | addr | country" for two
records. What was shown is that the PAIRWISE signal is exhausted at that scale. Collective
evidence is structurally invisible to a pairwise scorer.
THE IDEA (Model_Harsh.md s10 / Model-5 s10, never implemented). True matches come in groups:
S1-q matches some S2-x AND some S3-y, and x and y describe the SAME business, so they resemble
EACH OTHER. A false candidate that merely resembles q is unlikely to also resemble q's confident
match from the other source.
corrob(q,c) = max cosine( emb[c], emb[a] ) over q's ACCEPTED records from the OTHER source
Label-free (anchors are our own predictions), feature-only (never invents a candidate), and
self-excluding (a record is never corroborated against itself - that circularity once made a true
match its own distractor and read recall@1 as 53.87% instead of 95.01%).
THE GATE: among pairs we currently REJECT, does corroboration buy true pairs at better than
~3.76 TP per FP? Below that it lowers macro F0.5 no matter how sensible it looks.
"""
import json
import sys
import time
import joblib
import numpy as np
import pandas as pd
sys.path.insert(0, "src")
import config as C
import decide as DC
from evaluate import macro_f05
N_S2 = 5_034_616
THR = 0.70
t0 = time.time()
def log(m):
print(f"[{time.time()-t0:6.0f}s] {m}", flush=True)
b = joblib.load("work/results_A/model_bundle.joblib")
MM = b["miss_mass"]
CACHE = "work/val_scored_20000.parquet"
cand = pd.read_parquet(CACHE)
truths = {int(k): set(v) for k, v in json.load(open(CACHE + ".truth.json")).items()}
ids = sorted(truths)
shipped = DC.predict_sets(cand, "q", "c", "p", b["method"], THR, MM, True)
m0, _ = macro_f05(shipped, truths, ids)
log(f"baseline F0.5 {m0['f05']:.5f} P {m0['precision']:.4f} R {m0['recall']:.4f}")
cand = cand.copy()
cand["y"] = [1 if c in truths.get(q, ()) else 0 for q, c in zip(cand["q"], cand["c"])]
sel = {(q, c) for q, cs in shipped.items() for c in cs}
cand["sel"] = [1 if (q, c) in sel else 0 for q, c in zip(cand["q"], cand["c"])]
need = np.unique(cand["c"].to_numpy())
log(f"distinct pool records needed: {len(need):,}")
# np.load, NOT np.memmap: these are .npy files with a 128-byte header, which memmap maps as
# data and which made a raw reshape fail by exactly 64 float16 elements. np.load parses the
# header and returns the true shape and dtype instead of us assuming them.
s2 = np.load(C.HYBRID["train_s2"], mmap_mode="r")
s3 = np.load(C.HYBRID["train_s3"], mmap_mode="r")
log(f"s2 {s2.shape} {s2.dtype} s3 {s3.shape} {s3.dtype}")
assert s2.ndim == 2 and s2.shape[1] == 1024, s2.shape
assert s2.shape[0] == N_S2, s2.shape
E = np.empty((len(need), 1024), np.float32)
CH = 50_000 # small blocks: this box is shared and heavily loaded
for i in range(0, len(need), CH):
nd = need[i:i + CH] # already sorted -> sequential-ish page access
blk = E[i:i + CH]
m2 = nd < N_S2
if m2.any():
blk[m2] = np.asarray(s2[nd[m2]], np.float32)
if (~m2).any():
blk[~m2] = np.asarray(s3[nd[~m2] - N_S2], np.float32)
if (i // CH) % 4 == 0:
log(f" loaded {min(i+CH, len(need)):,}/{len(need):,}")
E /= np.maximum(np.linalg.norm(E, axis=1, keepdims=True), 1e-9)
pos = {int(c): i for i, c in enumerate(need)}
log("embeddings loaded, unit-normalised")
corr = np.full(len(cand), np.nan, np.float32)
qv = cand["q"].to_numpy()
cv = cand["c"].to_numpy()
sv = cand["sel"].to_numpy()
pv = cand["p"].to_numpy()
order = np.argsort(qv, kind="stable")
uq = np.unique(qv)
bounds = np.r_[np.searchsorted(qv[order], uq), len(order)]
n_anchor = 0
for i in range(len(uq)):
idx = order[bounds[i]:bounds[i + 1]]
acc = idx[sv[idx] == 1]
if not len(acc):
continue
# confident anchors only, highest first: corroborating against a shaky acceptance
# propagates its error to everything it touches
acc = acc[np.argsort(-pv[acc])]
acc = acc[pv[acc] >= 0.90][:4]
if not len(acc):
continue
a2 = np.array([pos[int(c)] for c in cv[acc] if int(c) < N_S2], np.int64)
a3 = np.array([pos[int(c)] for c in cv[acc] if int(c) >= N_S2], np.int64)
if not len(a2) and not len(a3):
continue
n_anchor += 1
for j in idx:
A = a3 if cv[j] < N_S2 else a2 # the OTHER source's accepted records
if not len(A):
continue
me = pos[int(cv[j])]
A = A[A != me]
if not len(A):
continue
corr[j] = float((E[A] @ E[me]).max())
cand["corr"] = corr
log(f"entities with a usable cross-source anchor: {n_anchor:,}/{len(uq):,}")
un = cand[(cand.sel == 0) & cand["corr"].notna()]
rt, rn = un[un.y == 1], un[un.y == 0]
print(f"\nunselected pairs with a corroboration value: {len(un):,} "
f"({len(rt):,} true, {len(rn):,} false)")
print(f" rejected TRUE median corr {rt['corr'].median():.4f} "
f"rejected FALSE median corr {rn['corr'].median():.4f}")
print("\n=== THE GATE: exchange rate of accepting by corroboration ===")
print(f"{'corr>=':>8}{'TP':>8}{'FP':>9}{'ratio':>8} (needs > 3.76)")
for t in (0.80, 0.85, 0.88, 0.90, 0.92, 0.94, 0.95, 0.96, 0.97, 0.98, 0.99):
tp = int((rt["corr"] >= t).sum())
fp = int((rn["corr"] >= t).sum())
r = tp / fp if fp else float("inf")
flag = " <-- CLEARS BREAK-EVEN" if r > 3.76 else ""
print(f"{t:>8.2f}{tp:>8,}{fp:>9,}{r:>8.2f}{flag}")
print("\n=== combined with a pairwise-score floor ===")
print(f"{'p>=':>6}{'corr>=':>8}{'TP':>7}{'FP':>8}{'ratio':>7}")
for pt in (0.2, 0.3, 0.4, 0.5, 0.6):
for ct in (0.90, 0.94, 0.96, 0.97, 0.98):
tp = int(((rt["corr"] >= ct) & (rt.p >= pt)).sum())
fp = int(((rn["corr"] >= ct) & (rn.p >= pt)).sum())
if tp + fp == 0:
continue
r = tp / fp if fp else float("inf")
flag = " <-- CLEARS" if r > 3.76 else ""
print(f"{pt:>6.2f}{ct:>8.2f}{tp:>7,}{fp:>8,}{r:>7.2f}{flag}")
print("\n=== END TO END, held out on half the entities ===")
h = np.array([hash(("half", int(q))) % 2 for q in ids])
HA = set(np.array(ids)[h == 0].tolist())
HB = [q for q in ids if q not in HA]
HAl = sorted(HA)
def ev(pr, idl):
return macro_f05({q: pr.get(q, []) for q in idl}, truths, idl)[0]["f05"]
bA, bB = ev(shipped, HAl), ev(shipped, HB)
print(f"baseline half A {bA:.5f} half B {bB:.5f}")
best = (-1, None, None)
for pt in (0.2, 0.3, 0.4, 0.5, 0.6):
for ct in (0.90, 0.94, 0.96, 0.97, 0.98):
add = cand[(cand.sel == 0) & (cand["corr"] >= ct) & (cand.p >= pt)]
if not len(add):
continue
aug = DC.resolve_conflicts(pd.concat([cand[cand.sel == 1], add]), "p")
pr = {}
for q, c in zip(aug["q"], aug["c"]):
pr.setdefault(int(q), []).append(int(c))
a, bb = ev(pr, HAl), ev(pr, HB)
if a > best[0]:
best = (a, (pt, ct, len(add)), bb)
print(f" p>={pt:.2f} corr>={ct:.2f}: +{len(add):>6,} -> A {a:.5f} B {bb:.5f}")
if best[1]:
pt, ct, n = best[1]
print(f"\nbest on half A: p>={pt} corr>={ct} (+{n:,} pairs)")
print(f" half A (in-sample) {best[0]:.5f} ({best[0]-bA:+.5f})")
print(f" half B (HELD OUT) {best[2]:.5f} ({best[2]-bB:+.5f}) <-- the honest number")