Download model-3/src/blocking.py from Jyo-K/PoPTard5: direct link, hf CLI and curl.
- Browser
- Download file 9.24 kB
-
https://huggingface.co/Jyo-K/PoPTard5/resolve/main/model-3/src/blocking.py
- Command line
-
hf download hf://Jyo-K/PoPTard5/model-3/src/blocking.py
-
curl -L -o blocking.py https://huggingface.co/Jyo-K/PoPTard5/resolve/main/model-3/src/blocking.py
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 | |