Download model-5/src/features.py from Jyo-K/PoPTard5: direct link, hf CLI and curl.
- Browser
- Download file 15.5 kB
-
https://huggingface.co/Jyo-K/PoPTard5/resolve/main/model-5/src/features.py
- Command line
-
hf download hf://Jyo-K/PoPTard5/model-5/src/features.py
-
curl -L -o features.py https://huggingface.co/Jyo-K/PoPTard5/resolve/main/model-5/src/features.py
15.5 kB
| """CPU stage: country-agnostic pair features on rule-free text views, plus country-invariant versions of the | |
| scale-dependent ones (README F1).""" | |
| import math | |
| import sys | |
| import time | |
| from multiprocessing import Pool | |
| import numpy as np | |
| import pandas as pd | |
| from rapidfuzz import fuzz, process | |
| from rapidfuzz.distance import JaroWinkler, Levenshtein | |
| import text_utils as tu | |
| from common import Progress, get_logger, load_json, n_workers, save_parquet, shard_path, claim, release | |
| from config import EMB_MODELS, WORK | |
| from prep import load_prep | |
| from retrieve_dense import block_rows, n_shards | |
| log = get_logger("feat") | |
| PCOLS = ["name_full", "name_core", "legal", "name_ns", "nonlatin", "addr_norm", "street", "street_ns", "loc", | |
| "house", "nums", "postal", "alnum", "addr_empty", "src", "country"] | |
| PY_FEATS = ["n_soft_ab", "n_soft_ba", "n_jacc", "n_extra_a", "n_extra_b", "n_contain", "n_acro", "n_first_eq", | |
| "legal_state", "st_soft_ab", "st_soft_ba", "loc_cov_a", "loc_cov_b", "a_jacc", | |
| "house_state", "house_diff", "num_jacc", "num_state", "postal_state", "alnum_state", | |
| "n_tok_a", "n_tok_b", "a_tok_a", "a_tok_b"] | |
| FUZZ = [("n_ratio", "name_core", fuzz.ratio), ("n_jw", "name_core", JaroWinkler.normalized_similarity), | |
| ("n_lev", "name_core", Levenshtein.normalized_similarity), ("n_tsort", "name_core", fuzz.token_sort_ratio), | |
| ("n_tset", "name_core", fuzz.token_set_ratio), ("n_partial", "name_core", fuzz.partial_ratio), | |
| ("n_full_ratio", "name_full", fuzz.ratio), ("ns_ratio", "name_ns", fuzz.ratio), | |
| ("ns_partial", "name_ns", fuzz.partial_ratio), ("a_ratio", "addr_norm", fuzz.ratio), | |
| ("a_tset", "addr_norm", fuzz.token_set_ratio), ("a_partial", "addr_norm", fuzz.partial_ratio), | |
| ("st_ratio", "street", fuzz.ratio), ("st_ns_ratio", "street_ns", fuzz.ratio)] | |
| # features whose scale depends on a country's vocabulary / pool / language (README F1). The F1 selection stage | |
| # decides under leave-one-country-out whether to drop them; their invariant replacements are always present. | |
| def raw_scale_features(cols): | |
| raw = {f"{r}_{x}" for r in ("e5", "bge", "e5s", "lex", "hop") for x in ("s", "gap")} | |
| raw |= {"e5_cos", "bge_cos", "e5s_cos", "q_best_e5", "q_best_bge", "q_best_e5s", "tri_max_cos", | |
| "n_tok_a", "n_tok_b", "a_tok_a", "a_tok_b"} | |
| return sorted(c for c in cols if c in raw) | |
| def _jacc(a, b): | |
| if not a and not b: | |
| return math.nan | |
| return len(a & b) / len(a | b) | |
| def _py_chunk(args): | |
| A, B = args # dicts of column -> list for the query side and candidate side | |
| out = np.empty((len(A["name_core"]), len(PY_FEATS)), dtype=np.float32) | |
| for i in range(len(out)): | |
| na, nb = A["name_core"][i].split(), B["name_core"][i].split() | |
| sa, sb = set(na), set(nb) | |
| fa, fb = A["name_full"][i].split(), B["name_full"][i].split() | |
| n_ab, n_ba = tu.soft_cov(na, nb), tu.soft_cov(nb, na) | |
| extra_a = sum(1 for x in na if x not in sb and not any(tu.tok_sim(x, y) > 0 for y in nb)) | |
| extra_b = sum(1 for x in nb if x not in sa and not any(tu.tok_sim(x, y) > 0 for y in na)) | |
| contain = 1.0 if tu.contains_seq(na, fb) else (2.0 if tu.contains_seq(nb, fa) else 0.0) | |
| acro = 1.0 if (tu.acronym_match(na, nb) or tu.acronym_match(nb, na)) else 0.0 | |
| first = 1.0 if na and nb and na[0] == nb[0] else 0.0 | |
| leg = tu.legal_state(A["legal"][i], B["legal"][i]) | |
| sta, stb = A["street"][i].split(), B["street"][i].split() | |
| st_ab, st_ba = tu.soft_cov(sta, stb), tu.soft_cov(stb, sta) | |
| ta, tb = A["addr_norm"][i].split(), B["addr_norm"][i].split() | |
| la = [t for seg in A["loc"][i].split("|") for t in seg.split() if not t.isdigit()] | |
| lb = [t for seg in B["loc"][i].split("|") for t in seg.split() if not t.isdigit()] | |
| loc_a = tu.soft_cov(la, tb) # does the query's city/region appear anywhere in the candidate | |
| loc_b = tu.soft_cov(lb, ta) | |
| hs, hd = tu.house_compare(A["house"][i], B["house"][i]) | |
| ma, mb = set(A["nums"][i].split()), set(B["nums"][i].split()) | |
| pa, pb = set(A["postal"][i].split()), set(B["postal"][i].split()) | |
| xa, xb = set(A["alnum"][i].split()), set(B["alnum"][i].split()) | |
| out[i] = (n_ab, n_ba, _jacc(sa, sb), extra_a, extra_b, contain, acro, first, leg, st_ab, st_ba, | |
| loc_a, loc_b, _jacc(set(ta), set(tb)), hs, hd, _jacc(ma, mb), tu.set_state(ma, mb), | |
| tu.set_state(pa, pb), tu.set_state(xa, xb), len(na), len(nb), len(ta), len(tb)) | |
| return out | |
| def _gather(tab, idx, cols): | |
| return {c: tab[c][idx].tolist() for c in cols} | |
| def pair_features(pool, s1tab, pooltab, df, nthreads): | |
| q, c = df.q.values, df.c.values | |
| feats = {} | |
| for name, col, scorer in FUZZ: | |
| a, b = s1tab[col][q].tolist(), pooltab[col][c].tolist() # lists: ~10x faster than object arrays | |
| v = process.cpdist(a, b, scorer=scorer, workers=nthreads).astype(np.float32) | |
| empty = (pd.Series(a).str.len().values == 0) | (pd.Series(b).str.len().values == 0) | |
| v[empty] = np.nan | |
| feats[name] = v | |
| str_cols = ["name_core", "name_full", "legal", "street", "addr_norm", "loc", "house", "nums", "postal", "alnum"] | |
| step = 10_000 | |
| args = [(_gather(s1tab, q[i:i + step], str_cols), _gather(pooltab, c[i:i + step], str_cols)) | |
| for i in range(0, len(df), step)] | |
| py = np.concatenate(list(pool.imap(_py_chunk, args))) if args else np.empty((0, len(PY_FEATS)), np.float32) | |
| for j, name in enumerate(PY_FEATS): | |
| feats[name] = py[:, j] | |
| feats["nonlatin_a"] = s1tab["nonlatin"][q].astype(np.float32) | |
| feats["nonlatin_b"] = pooltab["nonlatin"][c].astype(np.float32) | |
| feats["addr_empty_a"] = s1tab["addr_empty"][q].astype(np.float32) | |
| feats["addr_empty_b"] = pooltab["addr_empty"][c].astype(np.float32) | |
| feats["cand_src"] = pooltab["src"][c].astype(np.float32) | |
| return pd.DataFrame(feats) | |
| def query_context(df, retr, stats, s1_country): | |
| """Within-query ranks / gaps / z-scores, and per-country standardised cosines (label-free statistics).""" | |
| g = df.groupby("q") | |
| out = pd.DataFrame(index=df.index) | |
| out["q_ncand"] = g.c.transform("size").astype(np.float32) | |
| ctry = s1_country[df.q.values] | |
| for m in EMB_MODELS: | |
| col = f"{m}_cos" | |
| best = g[col].transform("max") | |
| mu_q, sd_q = g[col].transform("mean"), g[col].transform("std").fillna(0) | |
| out[f"{m}_cos_gap"] = (best - df[col]).astype(np.float32) | |
| out[f"q_best_{m}"] = best.astype(np.float32) | |
| out[f"{m}_cos_rank"] = g[col].rank(ascending=False, method="first").astype(np.float32) | |
| out[f"{m}_cos_qz"] = ((df[col] - mu_q) / (sd_q + 1e-3)).astype(np.float32) | |
| st = stats[m] | |
| mu = np.array([st.get(c, {"mu": np.nan})["mu"] for c in ctry], dtype=np.float32) | |
| sd = np.array([st.get(c, {"sd": np.nan})["sd"] for c in ctry], dtype=np.float32) | |
| out[f"{m}_cos_cz"] = ((df[col].values - mu) / sd).astype(np.float32) | |
| out[f"q_best_{m}_cz"] = ((best.values - mu) / sd).astype(np.float32) | |
| if "lex_s" in df: | |
| mx = g.lex_s.transform("max") | |
| out["lex_rel"] = (df.lex_s / mx.where(mx > 0)).astype(np.float32) | |
| out["q_best_n_tset"] = g.n_tset.transform("max").astype(np.float32) | |
| out["n_tset_gap"] = (out.q_best_n_tset - df.n_tset).astype(np.float32) | |
| out["tok_ratio_n"] = (df.n_tok_a / df.n_tok_b.clip(lower=1)).astype(np.float32) | |
| out["tok_ratio_a"] = (df.a_tok_a / df.a_tok_b.clip(lower=1)).astype(np.float32) | |
| return out | |
| def run_features(split): | |
| ns = n_shards(split) | |
| paths = [shard_path("feat", split, i) for i in range(ns)] | |
| if all(p.exists() for p in paths): | |
| return | |
| from config import RETR # noqa: F401 (the kept retrievers are already in the block shards) | |
| stats = {m: load_json(WORK / "reports" / f"cos_stats_{m}_{split}.json") for m in EMB_MODELS} | |
| nw = n_workers() | |
| with Pool(nw) as pool: # fork before big tables are loaded | |
| s1 = load_prep(split, "s1", [c for c in PCOLS if c != "src"]) | |
| pl = load_prep(split, "pool", PCOLS) | |
| s1tab = {c: s1[c].values for c in s1.columns} | |
| pooltab = {c: pl[c].values for c in pl.columns} | |
| s1_country = s1.country.values | |
| del s1, pl | |
| prog = Progress(log, sum(not p.exists() for p in paths), f"features {split} shards") | |
| # several processes may drain this queue (python features.py <split> starts a helper); keep going until | |
| # every shard exists: shards claimed by a live helper are waited for, stale claims are re-taken | |
| while True: | |
| for i, p in enumerate(paths): | |
| if not claim(p): | |
| continue | |
| try: | |
| df = block_rows(split, i) | |
| for m in EMB_MODELS: | |
| z = np.load(shard_path("cos", split, i, "npz", m)) | |
| for k in z.files: | |
| df[k] = z[k] | |
| f = pair_features(pool, s1tab, pooltab, df, nw) | |
| df = pd.concat([df, f], axis=1) | |
| df = pd.concat([df, query_context(df, None, stats, s1_country)], axis=1) | |
| save_parquet(df, p) | |
| finally: | |
| release(p) | |
| prog.step() | |
| missing = [p for p in paths if not p.exists()] | |
| if not missing: | |
| break | |
| log.info(f"features {split}: waiting for {len(missing)} shard(s) held by other workers") | |
| time.sleep(30) | |
| def feature_columns(df): | |
| return [c for c in df.columns if c not in ("q", "c", "y")] | |
| # ----------------------------------------------------------------------------------------------- union with ename (v2) | |
| CONTEXT_COLS = (["q_ncand", "lex_rel", "q_best_n_tset", "n_tset_gap", "tok_ratio_n", "tok_ratio_a"] + | |
| [f"{x}{m}{y}" for m in EMB_MODELS for x, y in (("", "_cos_gap"), ("q_best_", ""), ("", "_cos_rank"), | |
| ("", "_cos_qz"), ("", "_cos_cz"), ("q_best_", "_cz"))]) | |
| ENAME_K = 5 | |
| def run_featx(split): | |
| """README v2 §2.2/§3.2: the fused top-K rows plus the ename candidates as a guaranteed union. New (q, c) pairs get | |
| exact e5/bge/e5s cosines and the full pair features; within-query context (ranks, gaps, z-scores, counts) is | |
| recomputed over the union; flags from_ename / ename_only and ename_s / ename_r are added. Feature shards from | |
| run_features are read, never modified.""" | |
| from config import CFG, RETR # noqa: F401 | |
| from decide import in_sorted | |
| from embeddings import load_emb | |
| from prep import load_splits | |
| from blocking import chosen_retr | |
| from retrieve_dense import queries | |
| ns = n_shards(split) | |
| paths = [shard_path("featx", split, i) for i in range(ns)] | |
| if all(p.exists() for p in paths): | |
| return | |
| retr = chosen_retr() | |
| Kc = load_json(WORK / "reports" / "k_choice.json")["K"] | |
| Q = queries(split) | |
| C = CFG["query_chunk"] | |
| stats = {m: load_json(WORK / "reports" / f"cos_stats_{m}_{split}.json") for m in EMB_MODELS} | |
| gt = None | |
| if split == "train": | |
| sp = load_splits() | |
| gt = np.sort(sp["gt_q"] * np.int64(1 << 32) + sp["gt_c"]) | |
| nw = n_workers() | |
| with Pool(nw) as pool: | |
| s1 = load_prep(split, "s1", [c for c in PCOLS if c != "src"]) | |
| pl = load_prep(split, "pool", PCOLS) | |
| s1tab = {c: s1[c].values for c in s1.columns} | |
| pooltab = {c: pl[c].values for c in pl.columns} | |
| s1_country = s1.country.values | |
| del s1, pl | |
| embs = {m: (load_emb(m, split, "s1"), load_emb(m, split, "pool")) for m in EMB_MODELS} | |
| prog = Progress(log, sum(not p.exists() for p in paths), f"featx {split} shards") | |
| stat = {"new_pairs": 0, "ename_pairs": 0, "rows": 0} | |
| while True: | |
| for i, p in enumerate(paths): | |
| if not claim(p): | |
| continue | |
| try: | |
| base = pd.read_parquet(shard_path("feat", split, i)) | |
| z = np.load(shard_path("dense", split, i, "npz", "ename")) | |
| q = np.repeat(Q[i * C:(i + 1) * C], z["cand"].shape[1]) | |
| en = pd.DataFrame({"q": q.astype(np.int32), "c": z["cand"].ravel(), | |
| "ename_s": z["score"].astype(np.float32).ravel(), | |
| "ename_r": np.tile(np.arange(z["cand"].shape[1]), len(z["cand"])).astype(np.float32)}) | |
| en = en[en.c >= 0].reset_index(drop=True) | |
| key = lambda d: d.q.values.astype(np.int64) * (1 << 32) + d.c.values | |
| bkey = np.sort(key(base)) | |
| new = en[~in_sorted(key(en), bkey)][["q", "c"]].reset_index(drop=True) | |
| if len(new): | |
| new["rrf"] = np.float32(0) | |
| new["rrf_rank"] = np.int16(Kc) # sentinel: outside the fused top-K | |
| new["support"] = np.int8(0) | |
| new["y"] = (in_sorted(key(new), gt).astype(np.int8) if gt is not None else np.int8(-1)) | |
| for r in retr: | |
| new[f"{r}_r"] = np.int16(CFG["dense_k"]) | |
| new[f"{r}_s"] = np.float32(np.nan) | |
| new[f"{r}_gap"] = np.float32(np.nan) | |
| for m, (es, ep) in embs.items(): | |
| a = es.take(new.q.values).astype(np.float32) | |
| b = ep.take(new.c.values).astype(np.float32) | |
| new[f"{m}_cos"] = (a * b).sum(1) | |
| new["tri_max_cos"] = np.float32(np.nan) | |
| new["tri_n_close"] = np.float32(0) | |
| f = pair_features(pool, s1tab, pooltab, new, nw) | |
| new = pd.concat([new, f], axis=1) | |
| df = pd.concat([base.drop(columns=[c for c in CONTEXT_COLS if c in base.columns]), | |
| new[[c for c in base.columns if c not in CONTEXT_COLS]] if len(new) else None], | |
| ignore_index=True) | |
| df["ename_only"] = np.r_[np.zeros(len(base), np.int8), np.ones(len(new), np.int8)] | |
| df = df.merge(en, on=["q", "c"], how="left") | |
| df["from_ename"] = df.ename_s.notna().astype(np.int8) | |
| df["ename_r"] = df.ename_r.fillna(ENAME_K).astype(np.float32) | |
| df = df.sort_values(["q", "ename_only", "rrf_rank"], kind="stable").reset_index(drop=True) | |
| df = pd.concat([df, query_context(df, None, stats, s1_country)], axis=1) | |
| save_parquet(df, p) | |
| stat["new_pairs"] += len(new); stat["ename_pairs"] += len(en); stat["rows"] += len(df) | |
| finally: | |
| release(p) | |
| prog.step() | |
| missing = [p for p in paths if not p.exists()] | |
| if not missing: | |
| break | |
| log.info(f"featx {split}: waiting for {len(missing)} shard(s) held by other workers") | |
| time.sleep(30) | |
| log.info(f"featx {split} (this process): {stat}") | |
| if __name__ == "__main__": # helper: python features.py <feat|featx> <split> — joins that stage's shard queue | |
| (run_featx if sys.argv[1] == "featx" else run_features)(sys.argv[-1]) | |