Download model-3/src/run_test.py from Jyo-K/PoPTard5: direct link, hf CLI and curl.
- Browser
- Download file 14.1 kB
-
https://huggingface.co/Jyo-K/PoPTard5/resolve/main/model-3/src/run_test.py
- Command line
-
hf download hf://Jyo-K/PoPTard5/model-3/src/run_test.py
-
curl -L -o run_test.py https://huggingface.co/Jyo-K/PoPTard5/resolve/main/model-3/src/run_test.py
14.1 kB
| """Test-set inference -> output/matching_results.tsv + output/candidate_pairs.tsv. | |
| python src/run_test.py --gpu 1 --artifacts work/results_A | |
| Requires (produced earlier in the queue): | |
| * work/emb/<model>/test_pool/ and work/emb/<model>/test_s1/ (src/embed.py) | |
| * an artifacts dir from run_train_eval.py holding abbrev_map.json, the LightGBM | |
| model, the isotonic calibrator, the tuned decision parameter and miss_mass. | |
| Output contract (see the organisers' README + utils/validate_submission.py): | |
| * exactly one row per test Source-1 entity, INCLUDING singletons (empty list) | |
| * ids may only be S2-/S3- ids that exist in the test set | |
| * no duplicate ids inside a list, no duplicate source1_entity_id rows | |
| * matching_results must be a SUBSET of candidate_pairs | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import json | |
| import os | |
| import sys | |
| import time | |
| import numpy as np | |
| import pandas as pd | |
| sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) | |
| import blocking as B # noqa: E402 | |
| import data as D # noqa: E402 | |
| import decide as DC # noqa: E402 | |
| import text_utils as T # noqa: E402 | |
| from config import CFG, EMB, PATHS, emb_dir # noqa: E402 | |
| from encoder import load_matrix, read_ids # noqa: E402 | |
| from features import compute_features # noqa: E402 | |
| T0 = time.time() | |
| def log(m): | |
| print(f"[{time.time() - T0:7.0f}s] {m}", flush=True) | |
| def write_tsv(path, rows, col): | |
| """rows: list of (s1_id, [ids]). Writes the exact organiser format.""" | |
| os.makedirs(os.path.dirname(path), exist_ok=True) | |
| tmp = path + ".tmp" | |
| with open(tmp, "w") as f: | |
| f.write(f"source1_entity_id\t{col}\n") | |
| for s1, ids in rows: | |
| f.write(f"{s1}\t{','.join(ids)}\n") | |
| os.replace(tmp, path) | |
| log(f"wrote {path} ({len(rows):,} rows)") | |
| def main(): | |
| ap = argparse.ArgumentParser() | |
| ap.add_argument("--gpu", default=os.environ.get("M3_GPU")) | |
| ap.add_argument("--artifacts", required=True, help="dir written by run_train_eval.py") | |
| ap.add_argument("--out", default=PATHS["output_dir"]) | |
| ap.add_argument("--chunk", type=int, default=200_000, help="test S1 rows per batch") | |
| a = ap.parse_args() | |
| if a.gpu is None: | |
| sys.exit("pass --gpu 0 / --gpu 1 / --gpu cpu (or set M3_GPU)") | |
| device = "cpu" if str(a.gpu) == "cpu" else f"cuda:{int(a.gpu)}" | |
| art = a.artifacts | |
| # ---------------------------------------------------------------- artifacts | |
| import joblib | |
| amap = json.load(open(os.path.join(art, "abbrev_map.json"))) | |
| bundle = joblib.load(os.path.join(art, "model_bundle.joblib")) | |
| lgbm, iso = bundle["lgbm"], bundle["calibrator"] | |
| feat_cols, method = bundle["feat_cols"], bundle["method"] | |
| param, miss_mass = bundle["param"], bundle["miss_mass"] | |
| country_filter = bundle["country_filter"] | |
| use_ce = bundle.get("use_cross_encoder", False) | |
| ce_dir = bundle.get("ce_dir") | |
| log(f"artifacts: {len(amap)} abbrev rules | {len(feat_cols)} features | " | |
| f"{method}({param}) | country_filter={country_filter} | ce={use_ce}") | |
| # ---------------------------------------------------------------- data | |
| log("loading test data") | |
| s1, s23, _ = D.load_data(PATHS["test"], with_gt=False) | |
| log(f"test S1 {len(s1):,} test S2+S3 {len(s23):,}") | |
| 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) | |
| # ---------------------------------------------------------------- embeddings | |
| pool_dir, q_dir = emb_dir("test_pool"), emb_dir("test_s1") | |
| c_emb, cmeta = load_matrix(pool_dir) | |
| q_emb, qmeta = load_matrix(q_dir) | |
| pool_ids, q_ids = read_ids(os.path.join(pool_dir, "ids.txt")), read_ids(os.path.join(q_dir, "ids.txt")) | |
| # row order of the cached matrices MUST match the dataframes we just loaded | |
| assert list(s23["entity_id"]) == pool_ids, "test pool embedding row order != S2+S3 order" | |
| assert list(s1["entity_id"]) == q_ids, "test S1 embedding row order != S1 order" | |
| log(f"embeddings ok: pool {c_emb.shape} queries {q_emb.shape} (model {cmeta['model']})") | |
| # ---------------------------------------------------------------- lexical index | |
| log("building lexical index over the test pool") | |
| lidx = B.LexicalIndex([B.lexical_tokens(n, ad) for n, ad in | |
| zip(s23v["name_core_abbr"], s23v["address_abbrev_norm"])], CFG) | |
| # ---------------------------------------------------------------- per-chunk inference | |
| # Each chunk is scored and persisted atomically, so a disconnect, an OOM from | |
| # another user, or a node reboot costs at most one chunk instead of the whole | |
| # multi-hour run. Re-running skips finished chunks. | |
| # | |
| # NOTE: conflict resolution is deliberately NOT applied here any more. Every | |
| # S2/S3 record has exactly one true owner (verified on the training labels: | |
| # 0 multi-owner ids across all 7,638,365 links), but two S1 entities sitting in | |
| # DIFFERENT chunks can each claim the same record. Resolving per chunk left | |
| # those duplicate claims in place, which costs precision on a precision-weighted | |
| # metric. It is now done once, globally, after every chunk exists. | |
| emb2_on = bool(bundle.get("emb2_enabled", CFG.get("emb2_enabled"))) | |
| if emb2_on: | |
| import emb_source | |
| c2_emb, q2_emb = emb_source.load_emb2("test", len(s1), len(s23)) | |
| log(f"second dense channel: pool {c2_emb.shape} (e5-large, pre-computed)") | |
| # cascade pre-ranker (reranker-free); absent file = cascade off | |
| cascade_m = int(CFG.get("cascade_m", 0) or 0) | |
| preranker = None | |
| if cascade_m: | |
| pr_path = os.path.join(art, "preranker.joblib") | |
| if os.path.exists(pr_path): | |
| preranker = joblib.load(pr_path) | |
| log(f"cascade ON: reranking top-{cascade_m}/entity using a " | |
| f"{len(preranker['cols'])}-feature pre-ranker " | |
| f"(validated delta F0.5 +0.000000)") | |
| else: | |
| log(f"cascade requested (M={cascade_m}) but {pr_path} is missing - " | |
| f"reranking ALL candidates instead") | |
| ck_dir = os.path.join(art, "test_chunks") | |
| os.makedirs(ck_dir, exist_ok=True) | |
| n = len(s1) | |
| chunk_los_all = list(range(0, n, a.chunk)) | |
| _lo = int(os.environ.get("M3_CHUNK_LO", "0")) | |
| _hi = int(os.environ.get("M3_CHUNK_HI", str(len(chunk_los_all)))) | |
| chunk_los = chunk_los_all[_lo:_hi] | |
| skip_decide = os.environ.get("M3_SKIP_DECIDE", "0") == "1" | |
| if (_lo, _hi) != (0, len(chunk_los_all)) or skip_decide: | |
| log(f"worker slice: chunks [{_lo}:{_hi}] of {len(chunk_los_all)}" | |
| f"{' (skipping global decision)' if skip_decide else ''}") | |
| for lo in chunk_los: | |
| hi = min(lo + a.chunk, n) | |
| cpath = os.path.join(ck_dir, f"chunk_{lo:09d}.parquet") | |
| if os.path.exists(cpath): | |
| log(f"chunk {lo:,}-{hi:,}: already complete, skipping") | |
| continue | |
| q_rows = np.arange(lo, hi) | |
| log(f"chunk {lo:,}-{hi:,}: dense search") | |
| emb_s, emb_i = B.dense_topk(np.asarray(q_emb[lo:hi]), c_emb, CFG, device) | |
| log(f"chunk {lo:,}-{hi:,}: 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"]) | |
| emb2_s = emb2_i = None | |
| if emb2_on: | |
| cfg2 = dict(CFG); cfg2["emb_k"] = CFG["emb2_k"] | |
| emb2_s, emb2_i = B.dense_topk(np.asarray(q2_emb[lo:hi]), c2_emb, cfg2, device) | |
| cand = B.build_candidates(q_rows, emb_s, emb_i, lex, s1v["country_norm"], | |
| s23v["country_norm"], 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[[lo + qpos[r] for r in rows]], c_emb) | |
| log(f"chunk {lo:,}-{hi:,}: {len(cand):,} candidates; features") | |
| feats = compute_features(cand, s1v, s23v, CFG["feat_n_jobs"]) | |
| cand = pd.concat([cand, feats], axis=1) | |
| del feats | |
| if preranker is not None: | |
| before = len(cand) | |
| sc = preranker["model"].predict(cand[preranker["cols"]]) | |
| rk = pd.Series(sc, index=cand.index).groupby(cand["q"].values) \ | |
| .rank(ascending=False, method="first") | |
| cand = cand[rk.values <= cascade_m].reset_index(drop=True) | |
| log(f"chunk {lo:,}-{hi:,}: cascade kept {len(cand):,}/{before:,} " | |
| f"({100*len(cand)/max(before,1):.1f}%) for reranking") | |
| if use_ce: | |
| from cross_encoder import pair_text, predict_cross_encoder | |
| log(f"chunk {lo:,}-{hi:,}: cross-encoder") | |
| ta = [pair_text(s1["business_name"].values[r], s1["business_address"].values[r], | |
| s1["country"].values[r]) for r in cand["q"].values] | |
| tb = [pair_text(s23["business_name"].values[c], s23["business_address"].values[c], | |
| s23["country"].values[c]) for c in cand["c"].values] | |
| # resumable inside the chunk too: scoring 2M+ pairs can itself take an hour | |
| cand["ce_prob"] = predict_cross_encoder( | |
| ta, tb, ce_dir, CFG, cache=os.path.join(ck_dir, f"ce_{lo:09d}")) | |
| del ta, tb | |
| cand["p"] = iso.predict(lgbm.predict(cand[feat_cols])) | |
| out = pd.DataFrame({"q": cand["q"].values.astype(np.int64), | |
| "c": cand["c"].values.astype(np.int64), | |
| "p": cand["p"].values.astype(np.float32)}) | |
| out.to_parquet(cpath + ".tmp", index=False) | |
| os.replace(cpath + ".tmp", cpath) | |
| for stale in (f"ce_{lo:09d}.part.npy", f"ce_{lo:09d}.done.json"): | |
| sp = os.path.join(ck_dir, stale) | |
| if os.path.exists(sp): | |
| os.remove(sp) | |
| log(f"chunk {lo:,}-{hi:,}: persisted {len(out):,} scored pairs -> {cpath}") | |
| del cand, out | |
| # ---------------------------------------------------------------- global decision | |
| if skip_decide: | |
| log(f"worker finished its {len(chunk_los)} chunk(s); global decision skipped " | |
| f"(run again without M3_SKIP_DECIDE once every chunk exists)") | |
| return | |
| # The decision MUST see every chunk, never just this worker's slice, or the | |
| # one-owner constraint would be resolved against a partial claim set and the | |
| # submission would be missing entities. | |
| missing = [lo for lo in chunk_los_all | |
| if not os.path.exists(os.path.join(ck_dir, f"chunk_{lo:09d}.parquet"))] | |
| if missing: | |
| raise SystemExit(f"cannot run the global decision: {len(missing)} chunk(s) " | |
| f"still missing (first at row {missing[0]:,}). Wait for the " | |
| f"other worker(s) to finish.") | |
| log("loading every chunk for GLOBAL conflict resolution + selection") | |
| allc = pd.concat([pd.read_parquet(os.path.join(ck_dir, f"chunk_{lo:09d}.parquet")) | |
| for lo in chunk_los_all], ignore_index=True) | |
| log(f"{len(allc):,} scored pairs over {n:,} entities " | |
| f"({len(allc)/max(n,1):.1f} candidates/entity)") | |
| n_claims = len(allc) | |
| n_owners = allc["c"].nunique() | |
| log(f"{n_owners:,} distinct pool records claimed by {n_claims:,} candidate edges") | |
| preds = DC.predict_sets(allc, "q", "c", "p", method, param, miss_mass, conflicts=True) | |
| log(f"selection done: {len(preds):,} entities received at least one match") | |
| pool_id_arr = s23v["entity_id"] | |
| s1_id_arr = s1v["entity_id"] | |
| by_q = allc.groupby("q")["c"].apply(list).to_dict() | |
| del allc | |
| match_rows, cand_rows = [], [] | |
| for r in range(n): | |
| sid = s1_id_arr[r] | |
| cand_rows.append((sid, [str(pool_id_arr[c]) for c in by_q.get(r, [])])) | |
| match_rows.append((sid, [str(pool_id_arr[c]) for c in preds.get(r, [])])) | |
| # ---------------------------------------------------------------- write + verify | |
| assert len(match_rows) == n, f"{len(match_rows)} rows for {n} test S1 entities" | |
| valid = set(s23v["entity_id"].astype(str)) | |
| seen_owner = {} | |
| for sid, ids in match_rows: | |
| assert len(ids) == len(set(ids)), f"duplicate ids for {sid}" | |
| assert all(i in valid for i in ids), f"unknown id in {sid}" | |
| for i in ids: # the constraint we just fixed - prove it | |
| assert i not in seen_owner, f"{i} claimed by both {seen_owner[i]} and {sid}" | |
| seen_owner[i] = sid | |
| cand_map = {s: set(i) for s, i in cand_rows} | |
| for sid, ids in match_rows: | |
| assert set(ids) <= cand_map[sid], f"{sid}: match not a subset of candidates" | |
| log(f"verified: {len(seen_owner):,} matched ids, each owned by exactly one S1 entity") | |
| write_tsv(os.path.join(a.out, "matching_results.tsv"), match_rows, "matched_entity_ids") | |
| write_tsv(os.path.join(a.out, "candidate_pairs.tsv"), cand_rows, "candidate_entity_ids") | |
| nz = sum(1 for _, i in match_rows if i) | |
| log(f"predicted matches for {nz:,}/{n:,} entities " | |
| f"({1 - nz / n:.4f} predicted-singleton rate; train singleton rate is 0.0558)") | |
| log(f"now run: python3 {PATHS['validator']} --matching {a.out}/matching_results.tsv " | |
| f"--candidate {a.out}/candidate_pairs.tsv --test-dir {os.path.dirname(PATHS['test']['source1'])}") | |
| if __name__ == "__main__": | |
| main() | |