""" End-to-end training + validation for Business Entity Resolution. python src/run_train_eval.py # Split A (random S1 entities) python src/run_train_eval.py --split B # train on one country, validate on the other python src/run_train_eval.py --no-ce # skip the cross-encoder (fast baseline) Outputs (in PATHS["work_dir"]): train_source1_augmented.parquet S1 + ground-truth column `matched_entity_ids` results_/metrics.json, report.md, errors.tsv, feature_importance.tsv """ import argparse import json import os import sys import time import numpy as np import pandas as pd import lightgbm as lgb from sklearn.metrics import roc_auc_score, average_precision_score from sklearn.model_selection import GroupKFold sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from config import PATHS, CFG, EMB, emb_dir # noqa: E402 from encoder import Encoder, load_matrix, read_ids # noqa: E402 import data as D # noqa: E402 import text_utils as T # noqa: E402 import blocking as B # noqa: E402 from features import compute_features # noqa: E402 from evaluate import macro_f05 # noqa: E402 import decide as DC # noqa: E402 T0 = time.time() def log(msg): print(f"[{time.time() - T0:7.0f}s] {msg}", flush=True) def lgb_oof_and_final(X, y, groups, cfg): """5-fold grouped OOF predictions on the stack part + a final model on all of it.""" oof = np.zeros(len(y)) iters = [] for tr, va in GroupKFold(n_splits=cfg["n_folds_stack"]).split(X, y, groups): m = lgb.train(cfg["lgbm_params"], lgb.Dataset(X.iloc[tr], y[tr]), cfg["lgbm_rounds"], valid_sets=[lgb.Dataset(X.iloc[va], y[va])], callbacks=[lgb.early_stopping(cfg["lgbm_early_stopping"], verbose=False)]) oof[va] = m.predict(X.iloc[va], num_iteration=m.best_iteration) iters.append(m.best_iteration) final = lgb.train(cfg["lgbm_params"], lgb.Dataset(X, y), int(np.mean(iters) * 1.1) + 1) return oof, final def main(): ap = argparse.ArgumentParser() ap.add_argument("--split", choices=["A", "B"], default=CFG["split"]) ap.add_argument("--train-country", default=CFG["split_b_train_country"]) ap.add_argument("--val-country", default=CFG["split_b_val_country"]) ap.add_argument("--no-ce", action="store_true") ap.add_argument("--gpu", default=os.environ.get("M3_GPU"), help="GPU index or 'cpu' (required; never guessed)") ap.add_argument("--n-ce", type=int); ap.add_argument("--n-stack", type=int) ap.add_argument("--n-val", type=int) args = ap.parse_args() if args.gpu is None: sys.exit("pass --gpu 0 / --gpu 1 / --gpu cpu (or set M3_GPU)") args.gpu = "cpu" if str(args.gpu) == "cpu" else f"cuda:{int(args.gpu)}" cfg = dict(CFG) cfg["split"] = args.split cfg["split_b_train_country"], cfg["split_b_val_country"] = args.train_country, args.val_country if args.no_ce: cfg["use_cross_encoder"] = False for k, a in [("n_s1_ce", args.n_ce), ("n_s1_stack", args.n_stack), ("n_s1_val", args.n_val)]: if a: cfg[k] = a rng = np.random.default_rng(cfg["seed"]) work = D.ensure_dir(PATHS["work_dir"]) tag = "A" if cfg["split"] == "A" else f"B_{cfg['split_b_train_country']}2{cfg['split_b_val_country']}" res_dir = D.ensure_dir(os.path.join(work, f"results_{tag}")) metrics = {"config": cfg} # ------------------------------------------------------------------ 1. data log("loading data") s1, s23, gt = D.load_data(PATHS["train"]) s1 = D.augment_s1(s1, gt, os.path.join(work, "train_source1_augmented.parquet")) del gt log(f"S1 {len(s1):,} S2+S3 {len(s23):,}") checks = D.sanity_checks(s1, s23, rng) metrics["sanity"] = checks log(f"sanity: {json.dumps(checks, default=str)}") if checks["gt_ids_missing_in_s23"] > 0: log("WARNING: some ground-truth IDs are missing from S2/S3 (incomplete files?)") if cfg["country_filter"] == "auto": rate = checks["true_pairs_same_country_rate"] or 0 cfg["country_filter"] = rate >= 0.999 log(f"country filter: {cfg['country_filter']}") # truths as row indices s23_index = pd.Index(s23["entity_id"]) lists = s1["matched_entity_ids"].map(D.split_ids) owner = np.repeat(np.arange(len(s1)), lists.map(len).values) pos = s23_index.get_indexer([x for l in lists for x in l]) ok = pos >= 0 truth_rows = {i: set() for i in range(len(s1))} for q, c in zip(owner[ok].tolist(), pos[ok].tolist()): truth_rows[q].add(c) del lists, owner, pos ce_rows, st_rows, va_rows = D.make_split(s1, cfg, rng) log(f"split {tag}: ce {len(ce_rows):,} stack {len(st_rows):,} val {len(va_rows):,}") # ------------------------------------------------------------------ 2. abbreviations log("mining abbreviations (ce part only)") pa, pb = [], [] for q in ce_rows: for c in truth_rows[q]: pa.append(s1["name_core"].values[q]); pb.append(s23["name_core"].values[c]) if len(pa) > cfg["abbrev_max_pos_pairs"]: sel = rng.choice(len(pa), cfg["abbrev_max_pos_pairs"], replace=False) pa, pb = [pa[i] for i in sel], [pb[i] for i in sel] nb = [pb[i] for i in rng.permutation(len(pb))] amap = T.mine_abbreviations(zip(pa, pb), zip(pa, nb), cfg["abbrev_min_support"], cfg["abbrev_min_precision"]) json.dump(amap, open(os.path.join(res_dir, "abbrev_map.json"), "w"), indent=1, ensure_ascii=False) metrics["abbrev_rules"] = len(amap) metrics["abbrev_examples"] = dict(list(sorted(amap.items()))[:40]) log(f"{len(amap)} abbreviation rules") s1["name_core_abbr"] = [T.apply_abbrev(x, amap) for x in s1["name_core"].values] s23["name_core_abbr"] = [T.apply_abbrev(x, amap) for x in s23["name_core"].values] def vals(df): v = {c: df[c].values.astype(object) for c in ["entity_id", "name_core_abbr", "name_norm", "address_abbrev_norm", "legal_suffix", "country_norm"]} nums = df["address_numbers"].values.astype(object) raw = df["business_address"].values.astype(object) v["nums"] = np.where(nums == "", raw, nums) return v s1v, s23v = vals(s1), vals(s23) # ------------------------------------------------------------------ 3. blocking q_rows = np.concatenate([ce_rows, st_rows, va_rows]) part = np.array(["ce"] * len(ce_rows) + ["stack"] * len(st_rows) + ["val"] * len(va_rows)) part_of = dict(zip(q_rows.tolist(), part.tolist())) log("loading cached S2+S3 corpus embeddings") pool_dir = emb_dir("train_pool") c_emb, cmeta = load_matrix(pool_dir) assert list(s23["entity_id"]) == read_ids(os.path.join(pool_dir, "ids.txt")), \ "train pool embedding row order != S2+S3 order (re-run src/embed.py)" log(f"pool embeddings {c_emb.shape} from {cmeta['model']}") # Only the sampled queries are ever searched, so encode just those (200k, not 2.21M). # This is the dead work that cost model-2 ~50 min per run. qcache = os.path.join(work, f"q_emb_{tag}_{EMB['model']}_{len(q_rows)}.npy") if os.path.exists(qcache): q_emb = np.load(qcache, mmap_mode="r") log(f"query embeddings from cache {q_emb.shape}") else: log(f"encoding {len(q_rows):,} sampled queries on {args.gpu}") enc = Encoder(cfg["emb_model"], args.gpu, EMB["max_len"], EMB["batch"]) q_emb = enc.encode([B.record_text(s1["business_name"].values[i], s1["business_address"].values[i], s1["country"].values[i]) for i in q_rows], cfg["emb_query_prefix"]) np.save(qcache + ".tmp.npy", q_emb); os.replace(qcache + ".tmp.npy", qcache) del enc log("dense search") emb_s, emb_i = B.dense_topk(np.asarray(q_emb), c_emb, cfg, args.gpu) # Second dense channel (pre-computed e5-large). Lifts the blocking ceiling from # 0.9744 to 0.9834 at k=20 because it fails on different records than e5-small. # Its S1 vectors are already on disk in row order, so nothing is encoded here. emb2_s = emb2_i = None if cfg.get("emb2_enabled"): import emb_source c2_emb, q2_emb = emb_source.load_emb2("train", len(s1), len(s23)) log(f"second dense channel: pool {c2_emb.shape} (e5-large, pre-computed)") cfg2 = dict(cfg); cfg2["emb_k"] = cfg["emb2_k"] emb2_s, emb2_i = B.dense_topk(np.asarray(q2_emb[q_rows]), c2_emb, cfg2, args.gpu) del c2_emb, q2_emb log("building lexical index") lidx = B.LexicalIndex([B.lexical_tokens(n, a) for n, a in zip(s23v["name_core_abbr"], s23v["address_abbrev_norm"])], cfg) log("lexical search") lex = B.lexical_topk(lidx, [B.lexical_tokens(s1v["name_core_abbr"][i], s1v["address_abbrev_norm"][i]) for i in q_rows], cfg["lex_n_jobs"]) del lidx cand = B.build_candidates(q_rows, emb_s, emb_i, lex, s1v["country_norm"], s23v["country_norm"], cfg["country_filter"], emb2_s=emb2_s, emb2_i=emb2_i) qpos = {r: i for i, r in enumerate(q_rows.tolist())} cand = B.fill_missing_emb(cand, lambda rows: q_emb[[qpos[r] for r in rows]], c_emb) cand["y"] = np.fromiter((c in truth_rows[q] for q, c in zip(cand["q"].values, cand["c"].values)), np.int8, len(cand)) cand["part"] = [part_of[q] for q in cand["q"].values] log(f"{len(cand):,} candidate pairs") blk = {} for p, rows in [("ce", ce_rows), ("stack", st_rows), ("val", va_rows)]: n_true = sum(len(truth_rows[q]) for q in rows) sub = cand[cand["part"] == p] blk[p] = {"recall_ceiling": float(sub["y"].sum() / max(n_true, 1)), "avg_candidates": float(len(sub) / len(rows)), "reduction_ratio": float(1 - len(sub) / (len(rows) * len(s23)))} sub = cand[cand["part"] == "val"].copy() for k in [1, 5, 10, 20]: m = (sub["emb_rank"] < k) | (sub["lex_rank"] < k) if cfg.get("emb2_enabled"): m = m | (sub["emb2_rank"] < k) blk.setdefault("val_recall_at_k", {})[k] = float(sub.loc[m, "y"].sum() / max(sum(len(truth_rows[q]) for q in va_rows), 1)) metrics["blocking"] = blk log(f"blocking: {json.dumps(blk)}") n_true_st = sum(len(truth_rows[q]) for q in st_rows) miss_mass = (n_true_st - cand.loc[cand["part"] == "stack", "y"].sum()) / len(st_rows) # ------------------------------------------------------------------ 4. features log("computing features") feats = compute_features(cand, s1v, s23v, cfg["feat_n_jobs"]) feat_cols = list(feats.columns) + ["emb_score", "emb_rank", "lex_score", "lex_rank", "n_keys"] if cfg.get("emb2_enabled"): feat_cols += ["emb2_score", "emb2_rank"] cand = pd.concat([cand, feats], axis=1) del feats # ------------------------------------------------------------------ 5. cross-encoder def texts(rows, df): from cross_encoder import pair_text n, a, c = df["business_name"].values, df["business_address"].values, df["country"].values return [pair_text(n[r], a[r], c[r]) for r in rows] if cfg["use_cross_encoder"]: from cross_encoder import train_cross_encoder, predict_cross_encoder log("building cross-encoder training pairs") ce_c = cand[cand["part"] == "ce"] tq, tc, ty = [], [], [] for q in ce_rows: # all true pairs (incl. missed by blocking) for c in truth_rows[q]: tq.append(q); tc.append(c); ty.append(1.0) neg = ce_c[ce_c["y"] == 0].copy() neg["hard"] = neg["n_keys"] * 10 - np.minimum(neg["emb_rank"], neg["lex_rank"]) neg = neg.sort_values(["q", "hard"], ascending=[True, False]) hard = neg.groupby("q").head(cfg["ce_hard_negs"]) rest = neg.drop(hard.index) rnd = rest.groupby("q").sample(frac=1.0, random_state=cfg["seed"]).groupby("q").head(cfg["ce_rand_negs"]) for d in (hard, rnd): tq += d["q"].tolist(); tc += d["c"].tolist(); ty += [0.0] * len(d) tq, tc, ty = np.array(tq), np.array(tc), np.array(ty, np.float32) if len(ty) > cfg["ce_max_train_pairs"]: sel = rng.choice(len(ty), cfg["ce_max_train_pairs"], replace=False) tq, tc, ty = tq[sel], tc[sel], ty[sel] log(f"cross-encoder train pairs: {len(ty):,} (pos rate {ty.mean():.3f})") ce_dir = os.path.join(work, f"ce_model_{tag}") train_cross_encoder(texts(tq, s1), texts(tc, s23), ty, cfg, ce_dir, cfg["seed"]) log("scoring stack + val pairs with cross-encoder") m = cand["part"].isin(["stack", "val"]).values cand["ce_prob"] = np.nan cand.loc[m, "ce_prob"] = predict_cross_encoder(texts(cand["q"].values[m], s1), texts(cand["c"].values[m], s23), ce_dir, cfg) # ------------------------------------------------------------------ 6. scorers st = cand[cand["part"] == "stack"].reset_index(drop=True) va = cand[cand["part"] == "val"].reset_index(drop=True) del cand y_st, y_va = st["y"].values, va["y"].values truths_st = {q: truth_rows[q] for q in st_rows} truths_va = {q: truth_rows[q] for q in va_rows} ids_st, ids_va = list(st_rows), list(va_rows) scores_st, scores_va, aucs = {}, {}, {} # rule baseline scores_st["rule"] = 0.7 * st["name_jw"].values + 0.3 * st["addr_tset"].values scores_va["rule"] = 0.7 * va["name_jw"].values + 0.3 * va["addr_tset"].values variants = [("lgbm_noce", feat_cols)] if cfg["use_cross_encoder"]: scores_st["ce_only"] = st["ce_prob"].values scores_va["ce_only"] = va["ce_prob"].values variants.append(("lgbm_ce", feat_cols + ["ce_prob"])) fi = None models = {} for name, cols in variants: log(f"training LightGBM {name}") oof, final = lgb_oof_and_final(st[cols], y_st, st["q"].values, cfg) models[name] = (final, cols) scores_st[name] = oof scores_va[name] = final.predict(va[cols]) fi = pd.DataFrame({"feature": cols, "gain": final.feature_importance("gain")}) \ .sort_values("gain", ascending=False) fi.to_csv(os.path.join(res_dir, "feature_importance.tsv"), sep="\t", index=False) for k in scores_va: aucs[k] = {"roc_auc": float(roc_auc_score(y_va, scores_va[k])) if 0 < y_va.sum() < len(y_va) else None, "pr_auc": float(average_precision_score(y_va, scores_va[k])) if y_va.sum() else None} metrics["pair_auc_val"] = aucs # ------------------------------------------------------------------ 7. decision + ablation thr_grid = np.round(np.arange(0.05, 0.96, 0.025), 3) floor_grid = [0.0, 0.05, 0.1, 0.15, 0.2, 0.3, 0.4] ablation = {} final_key = "lgbm_ce" if cfg["use_cross_encoder"] else "lgbm_noce" final_preds = None for k in scores_va: # calibrate on stack OOF (rule/ce scores are also mapped to probabilities) iso = DC.fit_calibrator(scores_st[k], y_st) st["p"], va["p"] = iso.predict(scores_st[k]), iso.predict(scores_va[k]) for method, grid in [("threshold", thr_grid), ("ef05", floor_grid)]: for conf in ([True, False] if k == final_key else [True]): v, f_st = DC.tune(st, truths_st, ids_st, "q", "c", "p", method, grid, miss_mass, conf, lambda P, Tr, I: macro_f05(P, Tr, I)) preds = DC.predict_sets(va, "q", "c", "p", method, v, miss_mass, conf) m, per = macro_f05(preds, truths_va, ids_va) key = f"{k}|{method}" + ("" if conf else "|no_conflict_resolution") ablation[key] = {**m, "tuned_param": float(v), "stack_oof_f05": float(f_st)} log(f"{key:45s} val F0.5 {m['f05']:.4f} P {m['precision']:.4f} R {m['recall']:.4f}") if k == final_key and method == "ef05" and conf: final_preds, final_per, final_m = preds, per, m va["p_final"] = va["p"] final_bundle = {"calibrator": iso, "method": method, "param": float(v)} # oracle: perfect matcher on top of our blocking (upper bound) hit = va[va["y"] == 1].groupby("q")["c"].apply(list).to_dict() oracle = {q: hit.get(q, []) for q in ids_va} ablation["oracle_on_candidates"] = macro_f05(oracle, truths_va, ids_va)[0] metrics["ablation_val"] = ablation # ------------------------------------------------------------------ 8. breakdowns per = pd.DataFrame({"q": ids_va, "f05": final_per}) per["singleton"] = [len(truths_va[q]) == 0 for q in ids_va] per["country"] = s1v["country_norm"][per["q"].values] metrics["final"] = { "variant": f"{final_key}|ef05", **final_m, "f05_singletons": float(per.loc[per["singleton"], "f05"].mean()) if per["singleton"].any() else None, "f05_non_singletons": float(per.loc[~per["singleton"], "f05"].mean()), "singleton_share": float(per["singleton"].mean()), "f05_by_country": per.groupby("country")["f05"].mean().to_dict(), } # error analysis pred_pairs = {(q, c) for q, cs in final_preds.items() for c in cs} va["pred"] = [(q, c) in pred_pairs for q, c in zip(va["q"].values, va["c"].values)] fp = va[va["pred"] & (va["y"] == 0)].nlargest(20, "p_final") fn = va[~va["pred"] & (va["y"] == 1)].nlargest(20, "p_final") rows = [] for kind, d in [("FP", fp), ("FN", fn)]: for _, r in d.iterrows(): rows.append({"type": kind, "prob": r["p_final"], "s1_id": s1v["entity_id"][r["q"]], "s1_name": s1["business_name"].values[r["q"]], "s1_addr": s1["business_address"].values[r["q"]], "cand_id": s23v["entity_id"][r["c"]], "cand_name": s23["business_name"].values[r["c"]], "cand_addr": s23["business_address"].values[r["c"]]}) pd.DataFrame(rows).to_csv(os.path.join(res_dir, "errors.tsv"), sep="\t", index=False) # ------------------------------------------------------------------ 9. write report # ---------------------------------------------------------------- persist for run_test.py import joblib _lgbm, _cols = models[final_key] joblib.dump({"emb2_enabled": bool(cfg.get("emb2_enabled")), "lgbm": _lgbm, "feat_cols": _cols, "calibrator": final_bundle["calibrator"], "method": final_bundle["method"], "param": final_bundle["param"], "miss_mass": float(miss_mass), "country_filter": bool(cfg["country_filter"]), "use_cross_encoder": bool(cfg["use_cross_encoder"]), "ce_dir": (ce_dir if cfg["use_cross_encoder"] else None), "emb_model": cfg["emb_model"], "val_f05": float(final_m["f05"])}, os.path.join(res_dir, "model_bundle.joblib")) log(f"saved model_bundle.joblib -> {res_dir}") metrics["runtime_sec"] = round(time.time() - T0) json.dump(metrics, open(os.path.join(res_dir, "metrics.json"), "w"), indent=2, default=str) L = [f"# Validation report — split {tag}", "", f"**Final ({metrics['final']['variant']}) macro F0.5: {final_m['f05']:.4f}** " f"(P {final_m['precision']:.4f}, R {final_m['recall']:.4f}, n={final_m['n']:,})", "", f"- singletons: {metrics['final']['f05_singletons']} | non-singletons: " f"{metrics['final']['f05_non_singletons']:.4f} | singleton share: {metrics['final']['singleton_share']:.3f}", f"- by country: {metrics['final']['f05_by_country']}", "", "## Blocking", "```", json.dumps(blk, indent=1), "```", "", "## Pair-level AUC (val)", "| scorer | ROC-AUC | PR-AUC |", "|---|---|---|"] L += [f"| {k} | {v['roc_auc']} | {v['pr_auc']} |" for k, v in aucs.items()] L += ["", "## Ablation (val macro F0.5)", "| variant | F0.5 | P | R | tuned param |", "|---|---|---|---|---|"] for k, v in ablation.items(): L.append(f"| {k} | {v['f05']:.4f} | {v['precision']:.4f} | {v['recall']:.4f} | {v.get('tuned_param', '')} |") L += ["", f"Abbreviation rules learned: {len(amap)} (see abbrev_map.json)", f"Country filter used: {cfg['country_filter']}", f"Runtime: {metrics['runtime_sec']}s"] open(os.path.join(res_dir, "report.md"), "w").write("\n".join(L)) log(f"done -> {res_dir}") if __name__ == "__main__": main()