ppuzio Cursor commited on
Commit
7ce967b
·
1 Parent(s): 856b119

Release 1.0.1: prefix-only glued-email trim.

Browse files

Same epoch-5 weights; 841-dev union FP 133→123 with no gold loss.

Co-authored-by: Cursor <cursoragent@cursor.com>

Files changed (6) hide show
  1. CHANGELOG.md +24 -0
  2. README.md +21 -9
  3. hybrid.json +19 -1
  4. nergal.py +2 -1
  5. scrub_pii.py +47 -2
  6. test_nergal.py +6 -2
CHANGELOG.md ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # NERGAL versions
2
+
3
+ Semver for this island:
4
+
5
+ - **MAJOR** — API, threshold, or weight recipe changes
6
+ - **MINOR** — new capability, same API
7
+ - **PATCH** — rules or card fix, same weights and API
8
+
9
+ Accuracy is 841-dev, union at 0.95, 354 gold spans. A version that changes those numbers must update `hybrid.json` `eval` and the tables below.
10
+
11
+ ## 1.0.1
12
+
13
+ Prefix-only glued-email trim. Cluster-gated title-case 5–11 letter prefixes are dropped from the redaction when the remainder is already a lowercase-local email. Same epoch-5 weights. Lost gold 0. New clean-passage damage 0.
14
+
15
+ | Version | Whole /354 | Residual | Rules FP | Union FP | Char P | Char R | What changed |
16
+ |---|---:|---:|---:|---:|---:|---:|---|
17
+ | 1.0.0 | 323 | 25 | 133 | 133 | 97.76% | 95.95% | First Hub snapshot |
18
+ | 1.0.1 | 323 | 25 | 98 | 123 | 97.93% | 95.95% | Prefix-only glued-email trim |
19
+
20
+ Rules SHA `4dcc441c…`. Seven emails trimmed, 35 prefix characters dropped; the model still covers 25 of those positions. Suffix glue is unchanged.
21
+
22
+ ## 1.0.0
23
+
24
+ First Hub snapshot. Seed `202609160`, epoch 5, rules `547c0428…`. Union FP 133 was the glued-email regex floor. This seed added no new false characters versus the historical incumbent.
README.md CHANGED
@@ -13,7 +13,7 @@ tags:
13
  - hybrid
14
  ---
15
 
16
- # NERGAL
17
 
18
  **Named Entity Recognition with Grounded Additive Labels**
19
 
@@ -23,10 +23,20 @@ SlayerLab hybrid PII cleaner for Polish. Not a chat model. Not a drop-in `pipeli
23
 
24
  Python rules do the identifiers they can prove. A transformer NER head adds phone and other PII spans the regex misses. The cleaner **unions** the two on the original text, then replaces hits with `[Telefon]` or `[PII]`.
25
 
26
- - **Ground:** frozen `scrub_pii` regex
 
27
  - **Additive labels:** XLM-RoBERTa-large token classifier, BIO tags `phone` / `pii`, threshold 0.95
28
  - **This snapshot:** seed `202609160`, **epoch 5** of a seven-epoch schedule
29
 
 
 
 
 
 
 
 
 
 
30
  ## 841-dev
31
 
32
  Tables use one development split: 841 passages, 215 with gold PII, **354 spans** (169 phone, 185 other PII). It is the `dev` side of a 4,500-passage labelled tranche (3,655 train / 841 dev). Sources match Dynaword (EUR-Lex, HPLT, Wikipedia, parliamentary and government text, plus smaller news/literary slices). Labels mix unchanged silver with human review.
@@ -57,19 +67,19 @@ Fresh XLM-R, seven epochs. 133 epoch/threshold combinations. Epoch 5 at 0.95 was
57
 
58
  ## Compared with other systems
59
 
60
- Same 841-dev split, threshold **0.95**. **Naked** is the transformer alone. **∪ regex** is that model unioned with the frozen rules (the NERGAL recipe). Character scores are gold vs masked characters.
61
 
62
  | System | Mode | Whole /354 | Residual | False chars | Char P | Char R |
63
  |---|---|---:|---:|---:|---:|---:|
64
- | Regex (`scrub_pii`) | rules | 245 | 73 | 133 | 97.36% | 81.01% |
65
  | GLiNER 2.5-multi zero-shot | naked | 81 | 188 | 970 | 56.98% | 21.22% |
66
  | GLiNER 2.5-multi zero-shot | ∪ regex | 262 | 63 | 1,103 | 82.36% | 85.02% |
67
  | Historical GLiNER email12 | naked | 249 | 70 | 10 | 99.81% | 85.39% |
68
  | Historical GLiNER email12 | ∪ regex | 289 | 51 | 143 | 97.54% | 93.48% |
69
  | XLM-R epoch 5 | naked | 298 | 42 | 57 | 98.96% | 89.38% |
70
- | **NERGAL (this snapshot)** | **∪ regex** | **323** | **25** | **133** | **97.76%** | **95.95%** |
71
 
72
- Zero-shot [GLiNER 2.5](https://huggingface.co/fastino/gliner2.5-multi-v1) is not competitive here, especially on non-phone PII (7/185 whole vs 160 naked / 179 union for this snapshot). Fine-tuned historical GLiNER is the precise naked baseline (10 false characters) and still trails XLM-R on coverage. NERGAL is XLM-R epoch 5 plus the regex: 144/169 phone, 179/185 other PII. Exact-span precision 87.50%, recall 88.98%, F1 88.24%. The 133 false characters are the glued-email regex error; this seed does not add to that floor.
73
 
74
  Trained GLiNER, HerBERT-large, and XLM-R-large were also compared on this split (plot above). GLiNER’s 0.50 coverage lead is the precision collapse in that figure; no GLiNER or HerBERT operating point passed the content-preservation gate.
75
 
@@ -77,13 +87,15 @@ Trained GLiNER, HerBERT-large, and XLM-R-large were also compared on this split
77
 
78
  | Seed | Whole /354 | False chars | New false vs historical union |
79
  |---|---:|---:|---:|
80
- | 202609160 (this repo) | 323 | 133 | 0 |
81
  | 202609161 | 322 | 134 | 1 |
82
  | 202609162 | 316 | 151 | 18 |
83
 
 
 
84
  ## Load
85
 
86
- This repo is the PII island: frozen `scrub_pii.py` plus `nergal.py`. `pipeline("token-classification")` will not match. Regex runs on the original text, the model adds spans at 0.95, then the two are unioned and replaced with `[Telefon]` / `[PII]`.
87
 
88
  ```python
89
  from pathlib import Path
@@ -98,6 +110,6 @@ nergal = Nergal.from_pretrained(root, local_files_only=True)
98
  masked, counts = nergal.scrub(text)
99
  ```
100
 
101
- `hybrid.json` records threshold 0.95 and gap ids `250002` / `250003`. `test_nergal.py` is synthetic (no corpus text). From this snapshot: `python -m unittest test_nergal`.
102
 
103
  Base weights: [`FacebookAI/xlm-roberta-large`](https://huggingface.co/FacebookAI/xlm-roberta-large) revision `c23d21b0620b635a76227c604d44e43a9f0ee389` (MIT).
 
13
  - hybrid
14
  ---
15
 
16
+ # NERGAL 1.0.1
17
 
18
  **Named Entity Recognition with Grounded Additive Labels**
19
 
 
23
 
24
  Python rules do the identifiers they can prove. A transformer NER head adds phone and other PII spans the regex misses. The cleaner **unions** the two on the original text, then replaces hits with `[Telefon]` or `[PII]`.
25
 
26
+ - **Version:** `1.0.1` (`hybrid.json`, `CHANGELOG.md`)
27
+ - **Ground:** `scrub_pii` regex (SHA256 `4dcc441c…`)
28
  - **Additive labels:** XLM-RoBERTa-large token classifier, BIO tags `phone` / `pii`, threshold 0.95
29
  - **This snapshot:** seed `202609160`, **epoch 5** of a seven-epoch schedule
30
 
31
+ ## Versions
32
+
33
+ 841-dev, union at 0.95, 354 gold spans. Same weights and API is a patch; new capability is minor; API, threshold, or weight recipe is major. A release that changes these numbers updates `hybrid.json` `eval` and this table.
34
+
35
+ | Version | Whole /354 | Residual | Rules FP | Union FP | Char P | Char R | What changed |
36
+ |---|---:|---:|---:|---:|---:|---:|---|
37
+ | 1.0.0 | 323 | 25 | 133 | 133 | 97.76% | 95.95% | First Hub snapshot |
38
+ | **1.0.1** | **323** | **25** | **98** | **123** | **97.93%** | **95.95%** | Prefix-only glued-email trim |
39
+
40
  ## 841-dev
41
 
42
  Tables use one development split: 841 passages, 215 with gold PII, **354 spans** (169 phone, 185 other PII). It is the `dev` side of a 4,500-passage labelled tranche (3,655 train / 841 dev). Sources match Dynaword (EUR-Lex, HPLT, Wikipedia, parliamentary and government text, plus smaller news/literary slices). Labels mix unchanged silver with human review.
 
67
 
68
  ## Compared with other systems
69
 
70
+ Same 841-dev split, threshold **0.95**. **Naked** is the transformer alone. **∪ regex** is that model unioned with the current rules. Character scores are gold vs masked characters.
71
 
72
  | System | Mode | Whole /354 | Residual | False chars | Char P | Char R |
73
  |---|---|---:|---:|---:|---:|---:|
74
+ | Regex (`scrub_pii`) | rules | 245 | 73 | 98 | 98.04% | 81.01% |
75
  | GLiNER 2.5-multi zero-shot | naked | 81 | 188 | 970 | 56.98% | 21.22% |
76
  | GLiNER 2.5-multi zero-shot | ∪ regex | 262 | 63 | 1,103 | 82.36% | 85.02% |
77
  | Historical GLiNER email12 | naked | 249 | 70 | 10 | 99.81% | 85.39% |
78
  | Historical GLiNER email12 | ∪ regex | 289 | 51 | 143 | 97.54% | 93.48% |
79
  | XLM-R epoch 5 | naked | 298 | 42 | 57 | 98.96% | 89.38% |
80
+ | **NERGAL 1.0.1** | **∪ regex** | **323** | **25** | **123** | **97.93%** | **95.95%** |
81
 
82
+ Zero-shot [GLiNER 2.5](https://huggingface.co/fastino/gliner2.5-multi-v1) is not competitive here, especially on non-phone PII (7/185 whole vs 160 naked / 179 union). Fine-tuned historical GLiNER is the precise naked baseline (10 false characters) and still trails XLM-R on coverage. NERGAL is XLM-R epoch 5 plus the regex: 144/169 phone, 179/185 other PII. Exact-span precision 87.50%, recall 88.98%, F1 88.24%.
83
 
84
  Trained GLiNER, HerBERT-large, and XLM-R-large were also compared on this split (plot above). GLiNER’s 0.50 coverage lead is the precision collapse in that figure; no GLiNER or HerBERT operating point passed the content-preservation gate.
85
 
 
87
 
88
  | Seed | Whole /354 | False chars | New false vs historical union |
89
  |---|---:|---:|---:|
90
+ | 202609160 (this snapshot) | 323 | 123 | 0 |
91
  | 202609161 | 322 | 134 | 1 |
92
  | 202609162 | 316 | 151 | 18 |
93
 
94
+ Seed 160 is the published weights. 161 and 162 were confirmation runs of the same recipe.
95
+
96
  ## Load
97
 
98
+ This repo is the PII island: `scrub_pii.py` plus `nergal.py`. `pipeline("token-classification")` will not match. Regex runs on the original text, the model adds spans at 0.95, then the two are unioned and replaced with `[Telefon]` / `[PII]`.
99
 
100
  ```python
101
  from pathlib import Path
 
110
  masked, counts = nergal.scrub(text)
111
  ```
112
 
113
+ `hybrid.json` records version `1.0.1`, threshold 0.95, gap ids `250002` / `250003`, and the 841-dev `eval` block. `test_nergal.py` is synthetic (no corpus text). From this snapshot: `python -m unittest test_nergal`.
114
 
115
  Base weights: [`FacebookAI/xlm-roberta-large`](https://huggingface.co/FacebookAI/xlm-roberta-large) revision `c23d21b0620b635a76227c604d44e43a9f0ee389` (MIT).
hybrid.json CHANGED
@@ -1,11 +1,29 @@
1
  {
2
  "full_name": "Named Entity Recognition with Grounded Additive Labels",
3
  "hub_id": "SlayerLab/NERGAL",
 
4
  "mode": "rules_union",
5
  "epoch": 5,
6
  "seed": 202609160,
7
  "threshold": 0.95,
8
- "rules_sha256": "547c0428b0799bf051566d6ac489987eff27f09e1bda36a5665452fe155b3966",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  "weights_sha256": "063ee5f9782c1b4d99e838be5a836328718e1372e213d5b87098b371b5c162af",
10
  "gaps": [
11
  "[PII_SPACE]",
 
1
  {
2
  "full_name": "Named Entity Recognition with Grounded Additive Labels",
3
  "hub_id": "SlayerLab/NERGAL",
4
+ "version": "1.0.1",
5
  "mode": "rules_union",
6
  "epoch": 5,
7
  "seed": 202609160,
8
  "threshold": 0.95,
9
+ "rules_sha256": "4dcc441c39b653b1e8360928c9e36f6299c1f17e81e7566dd2d252cc5dbbb03c",
10
+ "eval": {
11
+ "split": "841-dev",
12
+ "gold_entities": 354,
13
+ "whole_entities": 323,
14
+ "residual_passages": 25,
15
+ "union_fp": 123,
16
+ "rules_fp": 98,
17
+ "character_precision": 0.9793,
18
+ "character_recall": 0.9595,
19
+ "exact_precision": 0.875,
20
+ "exact_recall": 0.8898,
21
+ "exact_f1": 0.8824,
22
+ "phone_whole": 144,
23
+ "phone_gold": 169,
24
+ "pii_whole": 179,
25
+ "pii_gold": 185
26
+ },
27
  "weights_sha256": "063ee5f9782c1b4d99e838be5a836328718e1372e213d5b87098b371b5c162af",
28
  "gaps": [
29
  "[PII_SPACE]",
nergal.py CHANGED
@@ -17,12 +17,13 @@ import scrub_pii
17
  from scrub_pii import PHONE_TAG, PII_TAG
18
 
19
  HUB_ID = 'SlayerLab/NERGAL'
 
20
  GAPS = ['[PII_SPACE]', '[PII_BREAK]']
21
  GAP_IDS = [250002, 250003]
22
  BIO_LABELS = ['O', 'B-phone', 'I-phone', 'B-pii', 'I-pii']
23
  LABELS = ['phone', 'pii']
24
  THRESHOLD = 0.95
25
- RULES_SHA = '547c0428b0799bf051566d6ac489987eff27f09e1bda36a5665452fe155b3966'
26
 
27
 
28
  def sha(path):
 
17
  from scrub_pii import PHONE_TAG, PII_TAG
18
 
19
  HUB_ID = 'SlayerLab/NERGAL'
20
+ VERSION = '1.0.1'
21
  GAPS = ['[PII_SPACE]', '[PII_BREAK]']
22
  GAP_IDS = [250002, 250003]
23
  BIO_LABELS = ['O', 'B-phone', 'I-phone', 'B-pii', 'I-pii']
24
  LABELS = ['phone', 'pii']
25
  THRESHOLD = 0.95
26
+ RULES_SHA = '4dcc441c39b653b1e8360928c9e36f6299c1f17e81e7566dd2d252cc5dbbb03c'
27
 
28
 
29
  def sha(path):
scrub_pii.py CHANGED
@@ -11,7 +11,9 @@ valid check digit. Passport
11
  variants include one-letter and diplomatic IDs. Foreign IBANs use country
12
  lengths and MOD-97. ePUAP paths require a label; e-Doreczenia uses its AE:PL form.
13
  Wrapped email domains, local parts hyphenated across one line break and small
14
- extraction gaps around @/hyphens are supported.
 
 
15
  Explicit phone extensions and terminal suffix ranges are included; room numbers are not. Labelled numeric
16
  PINs (including URL pin= values) map to [PII] before phone detection.
17
  Contact/helpline headings cover consecutive descriptive phone-list entries;
@@ -509,9 +511,51 @@ def _phone_ok(raw: str, short: bool = False) -> bool:
509
  or (short and 9 <= len(d) <= 12 and d.startswith('0')))
510
 
511
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
512
  def _replace_checked(text: str, pattern: re.Pattern, tag: str, ok,
513
  label: re.Pattern | None = None, *, mask=_tag) -> tuple[str, int]:
514
  n = 0
 
515
 
516
  def _sub(m):
517
  nonlocal n
@@ -522,8 +566,9 @@ def _replace_checked(text: str, pattern: re.Pattern, tag: str, ok,
522
  if not ok(m.group(0)) or (pattern not in (_EMAIL_RE, _EMAIL_WRAP_RE, _EMAIL_FRAGMENT_RE)
523
  and _is_amount(text, m.start(), m.end())):
524
  return m.group(0)
 
525
  n += 1
526
- return mask(m.start(), m.end(), tag)
527
 
528
  return pattern.sub(_sub, text), n
529
 
 
11
  variants include one-letter and diplomatic IDs. Foreign IBANs use country
12
  lengths and MOD-97. ePUAP paths require a label; e-Doreczenia uses its AE:PL form.
13
  Wrapped email domains, local parts hyphenated across one line break and small
14
+ extraction gaps around @/hyphens are supported. A title-case alphabetic prefix
15
+ of 5–11 letters is dropped from the redaction when the remainder is already a
16
+ complete lowercase-local email and the same passage has at least two such glues.
17
  Explicit phone extensions and terminal suffix ranges are included; room numbers are not. Labelled numeric
18
  PINs (including URL pin= values) map to [PII] before phone detection.
19
  Contact/helpline headings cover consecutive descriptive phone-list entries;
 
511
  or (short and 9 <= len(d) <= 12 and d.startswith('0')))
512
 
513
 
514
+ def _trim_glued_email_prefix(text: str, start: int, end: int) -> int:
515
+ """Drop a title-case letter prefix when the remainder is already a complete email.
516
+
517
+ Prefix-only: do not touch dotted locals or suffix glue. Leftmost 5–11 letters.
518
+ Remainder local must be lowercase letters, length at least 5.
519
+ """
520
+ frag = text[start:end]
521
+ at = frag.find('@')
522
+ if at < 0:
523
+ return start
524
+ loc = frag[:at]
525
+ if '.' in loc or '-' in loc or sum(c.isupper() for c in loc) != 1:
526
+ return start
527
+ for i in range(5, min(12, at)):
528
+ prefix, rest = frag[:i], frag[i:]
529
+ if not prefix.isalpha() or not (prefix[0].isupper() and prefix[1:].islower()):
530
+ continue
531
+ r_at = rest.find('@')
532
+ rloc = rest[:r_at] if r_at >= 0 else ''
533
+ if r_at < 5 or not rloc.isalpha() or not rloc.islower():
534
+ continue
535
+ if _EMAIL_RE.fullmatch(rest):
536
+ return start + i
537
+ return start
538
+
539
+
540
+ def _glued_email_trim_starts(text: str) -> dict[int, int]:
541
+ """Map match starts to trimmed starts when a passage has two or more glues.
542
+
543
+ A lone title-case local is a real mailbox (Lostandfound@…). Two or more
544
+ CELEX-shaped prefix+mailbox tokens in one passage are column glue.
545
+ """
546
+ starts = {}
547
+ for pattern in (_EMAIL_WRAP_RE, _EMAIL_RE):
548
+ for m in pattern.finditer(text):
549
+ new = _trim_glued_email_prefix(text, m.start(), m.end())
550
+ if new != m.start():
551
+ starts.setdefault(m.start(), new)
552
+ return starts if len(starts) >= 2 else {}
553
+
554
+
555
  def _replace_checked(text: str, pattern: re.Pattern, tag: str, ok,
556
  label: re.Pattern | None = None, *, mask=_tag) -> tuple[str, int]:
557
  n = 0
558
+ trims = _glued_email_trim_starts(text) if pattern in (_EMAIL_RE, _EMAIL_WRAP_RE) else {}
559
 
560
  def _sub(m):
561
  nonlocal n
 
566
  if not ok(m.group(0)) or (pattern not in (_EMAIL_RE, _EMAIL_WRAP_RE, _EMAIL_FRAGMENT_RE)
567
  and _is_amount(text, m.start(), m.end())):
568
  return m.group(0)
569
+ start = trims.get(m.start(), m.start())
570
  n += 1
571
+ return text[m.start():start] + mask(start, m.end(), tag)
572
 
573
  return pattern.sub(_sub, text), n
574
 
test_nergal.py CHANGED
@@ -5,14 +5,18 @@ import unittest
5
  from pathlib import Path
6
 
7
  HERE = Path(__file__).resolve().parent
8
- RULES_SHA = '547c0428b0799bf051566d6ac489987eff27f09e1bda36a5665452fe155b3966'
9
 
10
 
11
  class NergalTests(unittest.TestCase):
12
  def test_card_and_rules_hash(self):
13
- from nergal import GAP_IDS, GAPS, HUB_ID, RULES_SHA as PINNED, THRESHOLD
14
  card = json.loads((HERE / 'hybrid.json').read_text())
15
  self.assertEqual(HUB_ID, 'SlayerLab/NERGAL')
 
 
 
 
16
  self.assertEqual(GAPS, card['gaps'])
17
  self.assertEqual(GAP_IDS, card['gap_ids'])
18
  self.assertEqual(THRESHOLD, card['threshold'])
 
5
  from pathlib import Path
6
 
7
  HERE = Path(__file__).resolve().parent
8
+ RULES_SHA = '4dcc441c39b653b1e8360928c9e36f6299c1f17e81e7566dd2d252cc5dbbb03c'
9
 
10
 
11
  class NergalTests(unittest.TestCase):
12
  def test_card_and_rules_hash(self):
13
+ from nergal import GAP_IDS, GAPS, HUB_ID, RULES_SHA as PINNED, THRESHOLD, VERSION
14
  card = json.loads((HERE / 'hybrid.json').read_text())
15
  self.assertEqual(HUB_ID, 'SlayerLab/NERGAL')
16
+ self.assertEqual(VERSION, '1.0.1')
17
+ self.assertEqual(card['version'], VERSION)
18
+ self.assertEqual(card['eval']['union_fp'], 123)
19
+ self.assertEqual(card['eval']['rules_fp'], 98)
20
  self.assertEqual(GAPS, card['gaps'])
21
  self.assertEqual(GAP_IDS, card['gap_ids'])
22
  self.assertEqual(THRESHOLD, card['threshold'])