File size: 8,320 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
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
"""Can NAME DISTINCTIVENESS rescue the empty-address pairs?

THE FINDING THIS FOLLOWS UP. Of our 1,763 retrieved-but-rejected true pairs, 76.57% have an EMPTY
ADDRESS (against a 3.34% pool base rate - a 23x enrichment). Of the 1,211 never-retrieved, 42.36%.
Together 1,863 pairs = 63% of all 2,974 misses, concentrated in the 344,883 pool records (3.34%)
that carry no address.

That explains every previous negative result at once: both cross-encoders reject them because
`addr: ` is blank and there is nothing to compare, not because they lack capacity; they are shorter
(58 vs 70 tokens) because the field is empty; and corroboration failed because an address-less
record resembles everything.

THE QUESTION. With no address only the name can decide. Name-only matching is precision-poor in
general, but not uniformly: 'Northwind' -> 'Northwind' is obvious, 'Ruby Academy' is not. Does name
DISTINCTIVENESS (rare-token IDF) separate true from false among empty-address candidates well
enough to clear the ~3.76:1 break-even?

Yes -> a feature/decision change: cheap, no GPU, no retraining.
No  -> the empty-address pairs are genuinely unrecoverable, and 0.9821 plus the retrieval ceiling
       is close to this data's real limit.
"""
import collections
import json
import math
import os
import re
import sys
import unicodedata

import joblib
import numpy as np
import pandas as pd

sys.path.insert(0, "src")
import config as C
import decide as DC
from evaluate import macro_f05

RAW = os.environ.get("RAW_DIR", "/scratch/user4/dai/dataset_unstop/dataset/train")
_WS = re.compile(r"\s+")
_PUN = re.compile(r"[^\w\s]", re.UNICODE)
LEGAL = set("inc incorporated llc ltd limited corp corporation co company lp llp pllc pc pa plc "
            "trust holdings pvt private pte sa sarl sas sasu sci eurl snc".split())


def norm(s):
    s = "".join(c for c in unicodedata.normalize("NFKD", str(s)) if not unicodedata.combining(c))
    return _WS.sub(" ", _PUN.sub(" ", s.casefold())).strip()


def toks(s):
    return [t for t in norm(s).split() if t not in LEGAL]


b = joblib.load("work/results_A/model_bundle.joblib")
MM, THR = b["miss_mass"], b["param"]
CACHE = "work/val_scored_20000.parquet"
cand = pd.read_parquet(CACHE)
truths = {int(k): set(v) for k, v in json.load(open(CACHE + ".truth.json")).items()}
ids = sorted(truths)
shipped = DC.predict_sets(cand, "q", "c", "p", b["method"], THR, MM, True)
m0, _ = macro_f05(shipped, truths, ids)
print(f"baseline F0.5 {m0['f05']:.5f}  P {m0['precision']:.4f}  R {m0['recall']:.4f}")

p = C.PATHS["train"]
s2 = pd.read_parquet(p["source2"], columns=["entity_id"])
s3 = pd.read_parquet(p["source3"], columns=["entity_id"])
pool_ids = np.concatenate([s2["entity_id"].values, s3["entity_id"].values])
s1_ids = pd.read_parquet(p["source1"], columns=["entity_id"])["entity_id"].values

praw = {}
df_counts = collections.Counter()
for src in (2, 3):
    d = pd.read_csv(os.path.join(RAW, f"train_source{src}.tsv"), sep="\t", dtype=str,
                    keep_default_na=False, na_filter=False)
    for e, n, a in zip(d["entity_id"], d["business_name"], d["business_address"]):
        praw[e] = (n, a)
        df_counts.update(set(toks(n)))
N_POOL = len(praw)
idf = {t: math.log(N_POOL / (1 + c)) for t, c in df_counts.items()}
print(f"pool records {N_POOL:,}   distinct name tokens {len(idf):,}")

s1raw = pd.read_csv(os.path.join(RAW, "train_source1.tsv"), sep="\t", dtype=str,
                    keep_default_na=False, na_filter=False)
s1map = dict(zip(s1raw["entity_id"], s1raw["business_name"]))

qv = cand["q"].to_numpy()
cv = cand["c"].to_numpy()
pv = cand["p"].to_numpy()
y = np.array([1 if c in truths.get(q, ()) else 0 for q, c in zip(qv, cv)], np.int8)
sel_pairs = {(q, c) for q, cs in shipped.items() for c in cs}
sel = np.array([1 if (q, c) in sel_pairs else 0 for q, c in zip(qv, cv)], np.int8)

emptyaddr = np.array([not praw[pool_ids[c]][1].strip() for c in cv], bool)
print(f"\nval candidate pairs {len(cand):,}   with empty-address candidate "
      f"{int(emptyaddr.sum()):,} ({emptyaddr.mean():.2%})")

# name features on the empty-address subset
rows = np.flatnonzero(emptyaddr)
feat = np.zeros((len(rows), 5), np.float32)
for i, r in enumerate(rows):
    qn = s1map[s1_ids[qv[r]]]
    cn = praw[pool_ids[cv[r]]][0]
    tq, tc = set(toks(qn)), set(toks(cn))
    inter = tq & tc
    feat[i, 0] = 1.0 if norm(qn) == norm(cn) else 0.0                     # exact after norm
    feat[i, 1] = len(inter) / max(len(tq | tc), 1)                        # token jaccard
    feat[i, 2] = sum(idf.get(t, 0.0) for t in inter)                      # shared IDF mass
    feat[i, 3] = max((idf.get(t, 0.0) for t in inter), default=0.0)       # rarest shared token
    feat[i, 4] = len(inter)
F = pd.DataFrame(feat, columns=["exact", "jac", "idf_sum", "idf_max", "n_shared"])
F["y"] = y[rows]
F["sel"] = sel[rows]
F["p"] = pv[rows]
F["q"] = qv[rows]
F["c"] = cv[rows]

print("\n" + "=" * 74)
print("EMPTY-ADDRESS SUBSET: name-feature separation, TRUE vs FALSE")
print("=" * 74)
t_, f_ = F[F.y == 1], F[F.y == 0]
print(f"  true pairs {len(t_):,}   false pairs {len(f_):,}   "
      f"base rate {len(t_)/max(len(F),1):.4%}")
print(f"{'feature':<12}{'true median':>14}{'false median':>14}{'true p90':>11}{'false p90':>11}")
for c in ("exact", "jac", "idf_sum", "idf_max", "n_shared"):
    print(f"  {c:<10}{t_[c].median():>14.4f}{f_[c].median():>14.4f}"
          f"{np.percentile(t_[c],90):>11.4f}{np.percentile(f_[c],90):>11.4f}")

print("\n" + "=" * 74)
print("EXCHANGE RATE of accepting REJECTED empty-address pairs by a name rule")
print("=" * 74)
un = F[F.sel == 0]
print(f"  currently rejected empty-address pairs: {len(un):,} "
      f"({int((un.y == 1).sum()):,} true, {int((un.y == 0).sum()):,} false)")
print(f"{'rule':<44}{'TP':>7}{'FP':>8}{'ratio':>8}")
rules = [
    ("exact normalised name match", un.exact == 1),
    ("exact name AND idf_max>=8", (un.exact == 1) & (un.idf_max >= 8)),
    ("exact name AND idf_max>=10", (un.exact == 1) & (un.idf_max >= 10)),
    ("exact name AND idf_max>=12", (un.exact == 1) & (un.idf_max >= 12)),
    ("jac>=0.8 AND idf_max>=10", (un.jac >= 0.8) & (un.idf_max >= 10)),
    ("jac>=0.6 AND idf_max>=12", (un.jac >= 0.6) & (un.idf_max >= 12)),
    ("idf_sum>=20", un.idf_sum >= 20),
    ("idf_sum>=25 AND jac>=0.5", (un.idf_sum >= 25) & (un.jac >= 0.5)),
    ("exact name AND p>=0.2", (un.exact == 1) & (un.p >= 0.2)),
    ("exact name AND idf_max>=10 AND p>=0.1", (un.exact == 1) & (un.idf_max >= 10) & (un.p >= 0.1)),
]
best = None
for name, mask in rules:
    tp = int(((mask) & (un.y == 1)).sum())
    fp = int(((mask) & (un.y == 0)).sum())
    if tp + fp == 0:
        print(f"  {name:<42}{0:>7}{0:>8}{'-':>8}")
        continue
    r = tp / fp if fp else float("inf")
    flag = "  <== CLEARS 3.76" if r > 3.76 else ""
    print(f"  {name:<42}{tp:>7,}{fp:>8,}{r:>8.2f}{flag}")
    if r > 3.76 and (best is None or tp > best[1]):
        best = (name, tp, fp, mask)

print("\n" + "=" * 74)
print("END TO END, held out on half the entities")
print("=" * 74)
h = np.array([hash(("half", int(q))) % 2 for q in ids])
HA = set(np.array(ids)[h == 0].tolist())
HAl = sorted(HA)
HB = [q for q in ids if q not in HA]


def ev(pr, idl):
    return macro_f05({q: pr.get(q, []) for q in idl}, truths, idl)[0]["f05"]


bA, bB = ev(shipped, HAl), ev(shipped, HB)
print(f"baseline  half A {bA:.5f}   half B {bB:.5f}")
if best is None:
    print("\nNo rule clears the break-even. The empty-address pairs are not recoverable by")
    print("name distinctiveness alone, which means the 1,763 rejections really are close to")
    print("this data's floor - the address is simply absent.")
else:
    name, tp, fp, mask = best
    add = un[mask]
    newpred = {q: set(v) for q, v in shipped.items()}
    for q, c in zip(add["q"], add["c"]):
        newpred[int(q)].add(int(c))
    pr = {q: list(v) for q, v in newpred.items()}
    a, bb = ev(pr, HAl), ev(pr, HB)
    print(f"\nbest rule: {name}  (+{len(add):,} pairs, {tp} TP / {fp} FP)")
    print(f"  half A {a:.5f}  ({a-bA:+.5f})")
    print(f"  half B {bb:.5f}  ({bb-bB:+.5f})   <-- held out")
    dup = collections.Counter()
    for q, cs in newpred.items():
        for c in cs:
            dup[c] += 1
    print(f"  one-owner violations: {sum(1 for v in dup.values() if v > 1):,}")