File size: 8,227 Bytes
e79d917
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
"""Does reranking only the top-M candidates per entity cost any F0.5?

The test stage reranks all ~45 candidates/entity (80M pairs, ~12 h). But the answer
averages 3.46 matches and expected-F0.5 selection stops early on a precision-weighted
metric, so deep-rank candidates may never be selected. If so, reranking only the
top-M by a RERANKER-FREE ranker is free speed.

Method (paired - same entities, same models, only the reranking scope differs):
  full      : p = iso(lgbm_ce(features + ce_prob))            for every candidate
  cascade-M : rank by out-of-fold lgbm_noce; keep top-M/entity;
              those keep p; the rest are dropped (never rerankable, so never chosen)
  then identical calibration / conflict resolution / expected-F0.5 selection.

Paired on the same entities, so the comparison is far more sensitive than two
independent runs. Ship only at delta == 0.
"""
import os, sys, time, json
import numpy as np
import pandas as pd
import joblib
import lightgbm as lgb
from sklearn.isotonic import IsotonicRegression

sys.path.insert(0, "src")
import config as cfgmod
from config import CFG, EMB, PATHS, emb_dir
import blocking as B
import data as D
import decide as DC
import text_utils as T
import emb_source
from encoder import load_matrix, read_ids, Encoder
from features import compute_features
from evaluate import macro_f05

N_VAL = int(sys.argv[1]) if len(sys.argv) > 1 else 10000
GPU = sys.argv[2] if len(sys.argv) > 2 else "cuda:0"
if not str(GPU).startswith("cuda") and GPU != "cpu":
    GPU = f"cuda:{int(GPU)}"
MS = [4, 6, 8, 10, 12, 16, 20, 25]
ART = "work/results_A"
t0 = time.time()
log = lambda m: print(f"[{time.time()-t0:6.0f}s] {m}", flush=True)

bundle = joblib.load(os.path.join(ART, "model_bundle.joblib"))
method, param, miss_mass = bundle["method"], bundle["param"], bundle["miss_mass"]
feat_cols, lgbm_ce = bundle["feat_cols"], bundle["lgbm"]
amap = json.load(open(os.path.join(ART, "abbrev_map.json")))
ce_dir = bundle["ce_dir"]
log(f"bundle: {method}({param}) miss_mass={miss_mass:.4f} feats={len(feat_cols)}")

log("loading train data")
s1, s23, gt = D.load_data(PATHS["train"], with_gt=True)
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)
    v["nums"] = np.where(nums == "", df["business_address"].values.astype(object), nums)
    return v
s1v, s23v = vals(s1), vals(s23)

pool_pos = {e: i for i, e in enumerate(s23["entity_id"].values)}
s1 = D.augment_s1(s1, gt)            # gt is a DataFrame; this is how the pipeline does it
lists = s1["matched_entity_ids"].map(D.split_ids)
truth_rows = {}
for r, ms in enumerate(lists):
    if ms:
        truth_rows[r] = set(pool_pos[m] for m in ms if m in pool_pos)
_npairs = sum(len(v) for v in truth_rows.values())
log(f"ground truth: {len(truth_rows):,} entities with matches, {_npairs:,} true pairs")
assert _npairs > 5_000_000, f"ground truth looks wrong ({_npairs} pairs) - expected ~7.6M"

rng = np.random.RandomState(CFG["seed"])
ce_rows, st_rows, va_rows = D.make_split(s1, CFG, rng)
va_rows = np.sort(rng.choice(va_rows, min(N_VAL, len(va_rows)), replace=False))
log(f"using {len(va_rows):,} val entities (same split/seed as the real run)")

# ------------------------------------------------------------------ candidates
c_emb, cmeta = load_matrix(emb_dir("train_pool"))
log(f"pool emb {c_emb.shape}")
enc = Encoder(CFG["emb_model"], 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 va_rows],
                   CFG["emb_query_prefix"])
del enc
emb_s, emb_i = B.dense_topk(np.asarray(q_emb), c_emb, CFG, GPU)

emb2_s = emb2_i = None
if CFG.get("emb2_enabled"):
    c2, q2 = emb_source.load_emb2("train", len(s1), len(s23))
    cfg2 = dict(CFG); cfg2["emb_k"] = CFG["emb2_k"]
    emb2_s, emb2_i = B.dense_topk(np.asarray(q2[va_rows]), c2, cfg2, GPU)
    del c2, q2
log("dense channels done")

lidx = B.LexicalIndex([B.lexical_tokens(n, a) for n, a in
                       zip(s23v["name_core_abbr"], s23v["address_abbrev_norm"])], CFG)
lex = B.lexical_topk(lidx, [B.lexical_tokens(s1v["name_core_abbr"][i],
                                             s1v["address_abbrev_norm"][i])
                            for i in va_rows], CFG["lex_n_jobs"])
del lidx
cand = B.build_candidates(va_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(va_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.get(q, ()) for q, c in
                         zip(cand["q"].values, cand["c"].values)), np.int8, len(cand))
log(f"{len(cand):,} candidates ({len(cand)/len(va_rows):.1f}/entity)")

feats = compute_features(cand, s1v, s23v, CFG["feat_n_jobs"])
cand = pd.concat([cand, feats], axis=1); del feats
log("features done")

# ------------------------------------------------------------------ reranker
from cross_encoder import pair_text, predict_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]
cand["ce_prob"] = predict_cross_encoder(ta, tb, ce_dir, CFG)
del ta, tb
log("reranker scoring done")

# ------------------------------------------------------ CE-free pre-ranker (OOF)
noce_cols = [c for c in feat_cols if c != "ce_prob"]
oof = np.zeros(len(cand))
groups = cand["q"].values
uq = np.unique(groups)
fold_of = {q: i % 5 for i, q in enumerate(rng.permutation(uq))}
fold = np.array([fold_of[q] for q in groups])
for f in range(5):
    tr, te = fold != f, fold == f
    m = lgb.train({**CFG["lgbm_params"], "verbose": -1},
                  lgb.Dataset(cand.loc[tr, noce_cols], cand.loc[tr, "y"]),
                  num_boost_round=300)
    oof[te] = m.predict(cand.loc[te, noce_cols])
cand["noce"] = oof
log("out-of-fold CE-free pre-ranker built")

# ------------------------------------------------------------------ compare
cand["p"] = lgbm_ce.predict(cand[feat_cols])
cand["p"] = bundle["calibrator"].predict(cand["p"].values)   # the real calibrator

truths = {q: truth_rows.get(q, set()) for q in va_rows}
ids = list(va_rows)

full_preds = DC.predict_sets(cand, "q", "c", "p", method, param, miss_mass, True)
m_full, per_full = macro_f05(full_preds, truths, ids)
log(f"FULL      F0.5 {m_full['f05']:.6f}  P {m_full['precision']:.4f}  R {m_full['recall']:.4f}")
if not (0.93 <= m_full["f05"] <= 0.995):
    sys.exit(f"SANITY FAIL: full-pipeline F0.5 {m_full['f05']:.4f} is nowhere near the "
             f"0.9812 the real run reported - the harness is wrong, not the cascade")

cand["rk"] = cand.groupby("q")["noce"].rank(ascending=False, method="first")
print()
print(f"{'M':>4} {'F0.5':>10} {'delta':>11} {'P':>8} {'R':>8} {'rerank%':>9} {'selected outside top-M':>24}")
print(f"{'full':>4} {m_full['f05']:>10.6f} {0.0:>11.6f} {m_full['precision']:>8.4f} "
      f"{m_full['recall']:>8.4f} {100.0:>8.1f}% {0:>24}")
for M in MS:
    sub = cand[cand["rk"] <= M]
    preds = DC.predict_sets(sub, "q", "c", "p", method, param, miss_mass, True)
    mm, per = macro_f05(preds, truths, ids)
    lost = sum(1 for q, cs in full_preds.items() for c in cs
               if c not in set(preds.get(q, [])))
    print(f"{M:>4} {mm['f05']:>10.6f} {mm['f05']-m_full['f05']:>+11.6f} "
          f"{mm['precision']:>8.4f} {mm['recall']:>8.4f} "
          f"{100*len(sub)/len(cand):>8.1f}% {lost:>24}")
print()
print(f"paired on {len(ids):,} identical entities; ship only at delta == 0")