PoPTard5 / model-3 /src /blocking.py
Jyo-K's picture
Upload folder using huggingface_hub
e79d917 verified
Raw History Blame Contribute Delete
9.24 kB
"""Candidate generation: dense nearest neighbours ∪ lexical inverted index.
Both channels are kept deliberately. Measured on 3,000 real S1 queries vs 756k
hard negatives from the actual candidate set:
channel R@20 overall non-Latin name empty address
lexical / TF-IDF 0.927 0.727 0.574
dense (e5-small) 0.961 0.910 0.534
and on the 220,683-query dev run against the full 10.3M pool:
lexical only 0.9839 | dense only 0.9719 | UNION 0.9904
Dense alone is WORSE than lexical alone. Lexical wins on empty addresses, dense
wins hugely on non-Latin names. The union is what scores -- do not drop either.
"""
from __future__ import annotations
import multiprocessing as mp
import numpy as np
import pandas as pd
from text_utils import tokens
def record_text(name, addr, country):
return f"{name} | {addr} | {country}"
# --------------------------------------------------------------------------- dense
def dense_topk(q_emb, c_emb, cfg, device):
"""Exact top-k cosine over a (possibly memory-mapped) corpus matrix.
corpus_on_gpu=False streams each corpus shard to the GPU and frees it. That is
~15% slower than pinning the whole matrix but it is why this survives a shared
GPU: model-2 pinned 21 GB and died when another user's job filled the card.
"""
import torch
k = cfg["emb_k"]
dtype = torch.float16 if str(device).startswith("cuda") else torch.float32
shard = cfg["emb_shard"]
pin = cfg["emb_corpus_on_gpu"]
nq = q_emb.shape[0]
best_s = np.full((nq, k), -1.0, np.float32)
best_i = np.full((nq, k), -1, np.int64)
resident = []
if pin:
for s in range(0, c_emb.shape[0], shard):
resident.append((s, torch.from_numpy(np.ascontiguousarray(c_emb[s:s + shard]))
.to(dtype).to(device)))
for si, s in enumerate(range(0, c_emb.shape[0], shard)):
if pin:
off, X = resident[si]
else:
off = s
X = torch.from_numpy(np.ascontiguousarray(c_emb[s:s + shard])).to(dtype).to(device)
XT = X.T.contiguous()
for qs in range(0, nq, cfg["emb_query_batch"]):
q = torch.from_numpy(np.ascontiguousarray(q_emb[qs:qs + cfg["emb_query_batch"]])) \
.to(dtype).to(device)
sc = q @ XT
kk = min(k, sc.shape[1])
v, i = torch.topk(sc, kk, dim=1)
v = v.float().cpu().numpy()
i = i.cpu().numpy().astype(np.int64) + off
sl = slice(qs, qs + len(v))
cs = np.concatenate([best_s[sl], v], 1)
ci = np.concatenate([best_i[sl], i], 1)
take = np.argsort(-cs, axis=1)[:, :k]
best_s[sl] = np.take_along_axis(cs, take, 1)
best_i[sl] = np.take_along_axis(ci, take, 1)
del sc, q
if not pin:
del X, XT
if str(device).startswith("cuda"):
torch.cuda.empty_cache()
if pin:
del resident
if str(device).startswith("cuda"):
torch.cuda.empty_cache()
return best_s, best_i
# --------------------------------------------------------------------------- lexical
def lexical_tokens(name, addr):
out = []
for t in set(tokens(name)):
out.append("n:" + t)
if len(t) >= 5:
out.append("p:" + t[:4])
for t in set(tokens(addr)):
out.append("a:" + t)
return list(set(out))
class LexicalIndex:
def __init__(self, doc_tokens, cfg):
n = len(doc_tokens)
lens = np.fromiter((len(x) for x in doc_tokens), dtype=np.int64, count=n)
docs = np.repeat(np.arange(n, dtype=np.int32), lens)
toks = pd.Series([t for x in doc_tokens for t in x], dtype="object")
codes, vocab = pd.factorize(toks)
df = np.bincount(codes)
keep = df <= cfg["lex_max_df"]
mask = keep[codes]
codes, docs = codes[mask], docs[mask]
order = np.argsort(codes, kind="stable")
self.docs = docs[order]
counts = np.bincount(codes, minlength=len(vocab))
self.offs = np.concatenate([[0], np.cumsum(counts)])
self.vocab = {t: i for i, t in enumerate(vocab) if keep[i]}
idf = np.log(n / np.maximum(df, 1))
w = np.ones(len(vocab))
for t, i in self.vocab.items():
if t.startswith("a:"):
w[i] = cfg["lex_addr_weight"]
elif t.startswith("p:"):
w[i] = cfg["lex_prefix_weight"]
self.w = idf * w
self.k = cfg["lex_k"]
self.df = df
self.budget = cfg.get("lex_post_budget", 0)
def query(self, qtoks):
codes = [self.vocab[t] for t in qtoks if t in self.vocab]
if not codes:
return np.empty(0, np.int32), np.empty(0, np.float32)
codes = np.array(codes)
if self.budget:
# Spend the posting budget on the rarest (most informative) tokens
# first. Without this, raising lex_max_df to keep the address
# skeleton would make a query like "a:no" scan 1.9M postings.
order = np.argsort(self.df[codes], kind="stable")
codes = codes[order]
cum = np.cumsum(self.offs[codes + 1] - self.offs[codes])
keep = int(np.searchsorted(cum, self.budget, side="right"))
codes = codes[:max(keep, 1)]
starts, ends = self.offs[codes], self.offs[codes + 1]
lens = ends - starts
if lens.sum() == 0:
return np.empty(0, np.int32), np.empty(0, np.float32)
cand = np.concatenate([self.docs[s:e] for s, e in zip(starts, ends)])
ws = np.repeat(self.w[codes], lens)
uniq, inv = np.unique(cand, return_inverse=True)
sc = np.bincount(inv, weights=ws)
if len(uniq) > self.k:
top = np.argpartition(-sc, self.k)[:self.k]
uniq, sc = uniq[top], sc[top]
o = np.argsort(-sc)
return uniq[o], sc[o].astype(np.float32)
_IDX = None
def _lex_worker(chunk):
return [_IDX.query(q) for q in chunk]
def lexical_topk(index, q_tokens, n_jobs):
global _IDX
_IDX = index
chunks = [q_tokens[i:i + 2000] for i in range(0, len(q_tokens), 2000)]
if n_jobs > 1 and "fork" in mp.get_all_start_methods():
with mp.get_context("fork").Pool(n_jobs) as pool:
res = pool.map(_lex_worker, chunks)
else:
res = [_lex_worker(c) for c in chunks]
return [r for c in res for r in c]
# --------------------------------------------------------------------------- union
def build_candidates(q_rows, emb_s, emb_i, lex_res, q_country, c_country, country_filter,
emb2_s=None, emb2_i=None):
"""-> DataFrame [q, c, emb_score, emb_rank, lex_score, lex_rank, (emb2_*), n_keys]."""
nq, k = emb_i.shape
e = pd.DataFrame({
"qpos": np.repeat(np.arange(nq), k),
"c": emb_i.ravel().astype(np.int64),
"emb_score": emb_s.ravel(),
"emb_rank": np.tile(np.arange(k), nq),
})
e = e[e["c"] >= 0]
lq, lc, ls, lr = [], [], [], []
for i, (c, s) in enumerate(lex_res):
lq.append(np.full(len(c), i)); lc.append(c); ls.append(s); lr.append(np.arange(len(c)))
l = pd.DataFrame({
"qpos": np.concatenate(lq) if lq else np.empty(0, int),
"c": np.concatenate(lc).astype(np.int64) if lc else np.empty(0, np.int64),
"lex_score": np.concatenate(ls) if ls else np.empty(0),
"lex_rank": np.concatenate(lr) if lr else np.empty(0, int),
})
cand = e.merge(l, on=["qpos", "c"], how="outer")
n_keys = cand["emb_score"].notna().astype(int) + cand["lex_score"].notna().astype(int)
if emb2_i is not None:
nq2, k2 = emb2_i.shape
e2 = pd.DataFrame({
"qpos": np.repeat(np.arange(nq2), k2),
"c": emb2_i.ravel().astype(np.int64),
"emb2_score": emb2_s.ravel(),
"emb2_rank": np.tile(np.arange(k2), nq2),
})
e2 = e2[e2["c"] >= 0]
cand = cand.merge(e2, on=["qpos", "c"], how="outer")
n_keys = n_keys.reindex(cand.index).fillna(0).astype(int) \
+ cand["emb2_score"].notna().astype(int)
# left NaN on purpose where e5-large did not retrieve it: LightGBM treats
# that as missing, which is exactly the signal "the other encoder disagreed"
cand["emb2_rank"] = cand["emb2_rank"].fillna(99)
cand["n_keys"] = n_keys
cand["emb_rank"] = cand["emb_rank"].fillna(99)
cand["lex_rank"] = cand["lex_rank"].fillna(99)
cand["lex_score"] = cand["lex_score"].fillna(0.0)
cand["q"] = np.asarray(q_rows)[cand["qpos"].values]
if country_filter:
keep = q_country[cand["q"].values] == c_country[cand["c"].values]
cand = cand[keep]
return cand.drop(columns="qpos").reset_index(drop=True)
def fill_missing_emb(cand, q_emb_by_row, c_emb):
"""Cosine for candidates found only by the lexical channel."""
m = cand["emb_score"].isna().values
if m.any():
qe = q_emb_by_row(cand["q"].values[m]).astype(np.float32)
ce = np.asarray(c_emb[cand["c"].values[m]]).astype(np.float32)
cand.loc[m, "emb_score"] = (qe * ce).sum(1)
return cand