File size: 13,738 Bytes
d4cc501
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
"""Word-aware span decoding for the schema encoder's sigmoid span head (PII long-doc fix, 26 Sep).

The v1 decoder (span_data.decode) thresholds every token x label independently and turns each above-threshold
run into a span. With sub-word tokens (the Vela tokenizer splits digits one by one) a single below-threshold
piece fragments one entity into several false positives ('ois', '0', 'example'), and every label is decoded
separately, so overlapping labels each cost a false positive.

decode_v2(probs, offs, names, text, thr, ...) -> [dict(start, end, label)] (code-point offsets)
  1. label resolution: each token takes its argmax label if that probability > thr, else O
     (overlapping labels resolved by max score; upstream Vela PII argmax-BIO likewise gives one label per token).
  2. word units from the text (GLiNER2 word splitter, see _UNIT): URL | run of letters/digits/@._-+ | any other
     single non-space char; every CJK / kana / hangul / full-width character is its own unit (character level).
     A token belongs to the unit that holds its first non-space character.
  3. within a unit, one label: the label with the largest summed probability over the unit's labelled tokens
     wins and the other labelled tokens of the unit are relabelled to it.
  4. fill: unlabelled tokens BETWEEN two tokens of the same label inside one unit take that label;
     extension (extend="unit", default): to the outermost letter/digit tokens of the unit, through inner
     punctuation ('ois' -> 'lois123@x.example.org'), never onto a bare edge '[' / ']' / '.';
     extend="alnum": only through directly adjacent letter/digit tokens; extend=False: off.
  5. contiguous tokens of one label form a span (never joins different labels: upstream decode_entities rule);
     whitespace is trimmed (upstream), and so are enclosing bracket / quote characters at the span edges.
  6. optional: bridge=True joins two same-label spans separated by one unit whose probability for that
     label is > thr * bridge_frac; min_chars={label: n} drops shorter spans.
Upstream semantics kept: exact code-point spans, whitespace trim, different types never joined. Not
reproducible from a sigmoid head: the B- tag that splits two adjacent same-type entities (it has no B/I).
"""
import re
import numpy as np

_CJK = ("⺀-⿿ -〿぀-ヿ㄀-ㇿ㐀-䶿一-鿿ꀀ-꓏"
        "가-힯豈-﫿︰-﹏＀-￯")
# Word units = GLiNER2's splitters (gliner2/processing/word_splitter.py, Apache-2.0): WhitespaceTokenSplitter's URL
# rule, CharLevelSplitter's [A-Za-z0-9@._\-+]+ runs (e-mails, IPs, phones, IDs stay one word), widened to Unicode
# letters/digits (Straße, título), and every CJK / kana / hangul / full-width character on its own (char level).
_UNIT = re.compile(rf"(?:https?://|www\.)[^\s\]\)\}}>「」『』\"'“”{_CJK}]+"
                   rf"|(?:(?![{_CJK}])[^\W_]|[@._\-+])+"
                   rf"|\S")
_ALNUM = re.compile(r"[^\W_]")
EDGE = set("[](){}<>「」『』【】《》〈〉\"'“”‘’«»‹›")


def units(text):
    """char index -> unit id (-1 for whitespace)."""
    u = np.full(len(text) + 1, -1, dtype=np.int64)
    for k, m in enumerate(_UNIT.finditer(text)):
        u[m.start():m.end()] = k
    return u


def _first_nonspace(text, a, b):
    while a < b and text[a].isspace():
        a += 1
    return a


def trim(text, s, e):
    while s < e and (text[s].isspace() or text[s] in EDGE):
        s += 1
    while e > s and (text[e - 1].isspace() or text[e - 1] in EDGE):
        e -= 1
    return s, e


def decode_v2(probs, offs, names, text, thr, fill=True, extend="unit", unit_vote=True, trim_edges=True,
              bridge=False, bridge_frac=0.5, min_chars=None, u=None):
    probs = np.asarray(probs, dtype=np.float32); offs = np.asarray(offs)
    T = len(offs)
    if T == 0:
        return []
    valid = offs[:, 1] > offs[:, 0]
    k = probs.argmax(1); pk = probs[np.arange(T), k]
    lab = np.where(valid & (pk > thr), k, -1)
    u = units(text) if u is None else u
    tu = np.full(T, -1, dtype=np.int64)
    alnum = np.zeros(T, dtype=bool)
    for t in range(T):
        if not valid[t]:
            continue
        a, b = int(offs[t, 0]), int(offs[t, 1])
        f = _first_nonspace(text, a, b)
        tu[t] = u[f] if f < b and f < len(text) else -1
        alnum[t] = bool(_ALNUM.search(text[a:b]))
    # group tokens by unit (tokens are in text order, units contiguous)
    if unit_vote or fill or extend:
        t = 0
        while t < T:
            if tu[t] < 0:
                t += 1; continue
            t2 = t
            while t2 + 1 < T and (tu[t2 + 1] == tu[t] or not valid[t2 + 1]):
                t2 += 1
            idx = [i for i in range(t, t2 + 1) if valid[i]]
            L = [i for i in idx if lab[i] >= 0]
            if L:
                if unit_vote and len({int(lab[i]) for i in L}) > 1:
                    sc = {}
                    for i in L:
                        sc[int(lab[i])] = sc.get(int(lab[i]), 0.0) + float(pk[i])
                    w = max(sc, key=sc.get)
                    for i in L:
                        lab[i] = w
                if fill:
                    for c in set(int(lab[i]) for i in L):
                        pos = [i for i in L if lab[i] == c]
                        for i in idx:
                            if pos[0] < i < pos[-1] and lab[i] < 0:
                                lab[i] = c
                if extend:
                    for c in set(int(lab[i]) for i in idx if lab[i] >= 0):
                        pos = [idx.index(i) for i in idx if lab[i] == c]
                        if extend == "unit":   # to the outermost letter/digit tokens of the unit, through inner punctuation
                            an = [j for j, i in enumerate(idx) if alnum[i]]
                            lo, hi = min([pos[0]] + an[:1]), max([pos[-1]] + an[-1:])
                            if all(lab[idx[j]] < 0 or lab[idx[j]] == c for j in range(lo, hi + 1)):
                                for j in range(lo, hi + 1):
                                    lab[idx[j]] = c
                        else:                  # "alnum": only through adjacent letter/digit tokens
                            j = pos[0] - 1
                            while j >= 0 and lab[idx[j]] < 0 and alnum[idx[j]]:
                                lab[idx[j]] = c; j -= 1
                            j = pos[-1] + 1
                            while j < len(idx) and lab[idx[j]] < 0 and alnum[idx[j]]:
                                lab[idx[j]] = c; j += 1
            t = t2 + 1
    # contiguous runs of one label (skipping zero-width tokens)
    spans = []
    cur = None
    for t in range(T):
        if not valid[t]:
            continue
        c = int(lab[t])
        if cur is not None and c == cur[0]:
            cur[2] = int(offs[t, 1]); cur[3].append(t); continue
        if cur is not None:
            spans.append(cur)
        cur = [c, int(offs[t, 0]), int(offs[t, 1]), [t]] if c >= 0 else None
    if cur is not None:
        spans.append(cur)
    if bridge and len(spans) > 1:
        out = [spans[0]]
        for sp in spans[1:]:
            pv = out[-1]
            if sp[0] == pv[0]:
                gap = [t for t in range(pv[3][-1] + 1, sp[3][0]) if valid[t]]
                gu = {int(tu[t]) for t in gap if tu[t] >= 0}
                if gap and len(gu) <= 1 and all(probs[t, sp[0]] > thr * bridge_frac for t in gap if tu[t] >= 0):
                    pv[2] = sp[2]; pv[3] = pv[3] + gap + sp[3]; continue
            out.append(sp)
        spans = out
    res = []
    for c, s, e, _ in spans:
        s, e = trim(text, s, e) if trim_edges else trim_ws(text, s, e)
        if e <= s:
            continue
        if min_chars and e - s < min_chars.get(names[c], 0):
            continue
        res.append(dict(start=s, end=e, label=names[c]))
    return res


def trim_ws(text, s, e):
    while s < e and text[s].isspace():
        s += 1
    while e > s and text[e - 1].isspace():
        e -= 1
    return s, e


def decode_v1_trimmed(probs, offs, names, text, thr):
    """The scorer's current rule (span_data.decode + whitespace trim), for before/after tables."""
    from span_data import decode
    out = []
    for s in decode(probs, offs, names, thr):
        a, b = trim_ws(text, s["start"], s["end"])
        if b > a:
            out.append(dict(start=a, end=b, label=s["label"]))
    return out


# ---------------------------------------------------------------------------------------------------------------
# row-level API (rows = dicts with text, spans [{start,end,label}], names, offs, probs; see dump_span_probs_v2.py)
# ---------------------------------------------------------------------------------------------------------------
GRID = [round(x, 2) for x in np.arange(0.05, 0.96, 0.05)]
DECODERS = {
    "v1": lambda r, thr: decode_v1_trimmed(r["probs"], r["offs"], r["names"], r["text"], thr),
    "v2": lambda r, thr: decode_v2(r["probs"], r["offs"], r["names"], r["text"], thr),
    "v2_noext": lambda r, thr: decode_v2(r["probs"], r["offs"], r["names"], r["text"], thr, extend=False),
    "v2_alnum": lambda r, thr: decode_v2(r["probs"], r["offs"], r["names"], r["text"], thr, extend="alnum"),
    "v2_notrim": lambda r, thr: decode_v2(r["probs"], r["offs"], r["names"], r["text"], thr, trim_edges=False),
    "v2_bridge": lambda r, thr: decode_v2(r["probs"], r["offs"], r["names"], r["text"], thr, bridge=True),
    "v2_argmax_only": lambda r, thr: decode_v2(r["probs"], r["offs"], r["names"], r["text"], thr, fill=False,
                                               extend=False, unit_vote=False, trim_edges=False),
}


def decode_rows(rows, thr, decoder="v2"):
    f = DECODERS[decoder] if isinstance(decoder, str) else decoder
    return [f(r, thr) for r in rows]


def _psd(span_data_path):
    """Load the upstream semantic-router span_data.py (src/training/model_classifier/pii_model_fine_tuning_lora/span_data.py)
    from span_data_path. Eval helper only (dev threshold picks); inference never calls it."""
    import importlib.util
    from pathlib import Path
    if span_data_path is None:
        raise ValueError("pii_metrics needs span_data_path: the path of the upstream span_data.py")
    spec = importlib.util.spec_from_file_location("pii_span_data_up", Path(span_data_path))
    m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m)
    return m


PII_TYPES = ["AGE", "CREDIT_CARD", "DATE_TIME", "DOMAIN_NAME", "EMAIL_ADDRESS", "GPE", "IBAN_CODE", "IP_ADDRESS", "NRP",
             "ORGANIZATION", "PERSON", "PHONE_NUMBER", "STREET_ADDRESS", "TITLE", "US_DRIVER_LICENSE", "US_SSN", "ZIP_CODE"]


def pii_metrics(rows, preds, span_data_path):
    """Upstream Vela PII exact-entity metric (span_data.entity_metrics, whitespace-trimmed gold); span_data_path: see _psd."""
    P = _psd(span_data_path)
    recs = [dict(full_text=r["text"], spans=[dict(start_position=s["start"], end_position=s["end"], entity_type=s["label"],
                 entity_value=r["text"][s["start"]:s["end"]]) for s in r["spans"]]) for r in rows]
    return P.entity_metrics(recs, [{(s["label"], s["start"], s["end"]) for s in p} for p in preds], PII_TYPES)


def toxic_score(rows, preds):
    from span_data import char_f1
    return float(np.mean([char_f1(p, r["spans"]) for r, p in zip(rows, preds)]))


def halu_metrics(rows, preds):
    from train_schema_span import ragtruth_metrics_spans
    return ragtruth_metrics_spans([dict(text=r["text"], spans=r["spans"]) for r in rows], preds)


def family_objective(family, span_data_path=None):
    """Dev objective per span label family (what the threshold is chosen to maximise); span_data_path: PII only (see _psd)."""
    if family == "pii":
        return lambda rows, preds: pii_metrics(rows, preds, span_data_path)["micro"]["f1"]
    if family == "toxic":
        return toxic_score
    if family == "halu":
        return lambda rows, preds: halu_metrics(rows, preds)["example_f1"]
    raise KeyError(family)


def pick_threshold(dev_sets, family, decoder="v2", grid=GRID, span_data_path=None):
    """Choose one threshold per label family on DEV rows only.
    dev_sets: list of row lists; the objective is the unweighted mean over the sets (for PII: dev short, dev 4096,
    dev 8192 -> upstream's length-macro selection, so the dense long rows cannot dominate). Returns (thr, curve)."""
    obj = family_objective(family, span_data_path)
    curve = {t: float(np.mean([obj(rows, decode_rows(rows, t, decoder)) for rows in dev_sets])) for t in grid}
    best = max(curve.values())
    thr = min(t for t, v in curve.items() if v >= best - 1e-12)  # ties -> lowest threshold
    return thr, curve


if __name__ == "__main__":  # self-test on the fragment cases seen in run C
    text = "Le formulaire indique [pavel_200015@summit15.example.org] dans adresse courriel."
    names = ["EMAIL_ADDRESS", "PERSON"]
    import unicodedata  # noqa
    # fake token offsets: split at every non-alnum boundary
    offs = [(m.start(), m.end()) for m in re.finditer(r"\s?\w+|\s?[^\w\s]", text)]
    p = np.zeros((len(offs), 2), dtype=np.float32)
    s0 = text.index("pavel"); e0 = text.index("]")
    for i, (a, b) in enumerate(offs):
        if a >= s0 - 1 and b <= e0 + 1:
            p[i, 0] = 0.9 if i % 3 else 0.2        # fragmented
    p[[i for i, (a, b) in enumerate(offs) if text[a:b].strip() == "["][0], 0] = 0.8  # bracket above threshold
    out = decode_v2(p, np.array(offs), names, text, 0.5)
    assert [(o["label"], text[o["start"]:o["end"]]) for o in out] == [("EMAIL_ADDRESS", "pavel_200015@summit15.example.org")], out
    t2 = "表格在日期一栏填写了「2015-06-06」。"
    print(units(t2)[:14], "ok")