ppuzio Claude Opus 5.5 commited on
Commit
960c515
·
1 Parent(s): 8329d5b

2.0.1: a [PERSON] placeholder ends an other-number label

Browse files

Same weights, threshold and API. Rules SHA c1b924a8: a Kod/NIP/REGON/PESEL/KRS/ISBN
label no longer reaches a number across [PERSON], so re-scrubbing names-on output
masks a phone after a masked name. Phone cues still cross [PERSON]. A first scrub of
raw text gives 2.0.0's rule spans, names on or off.

Gates vs 2.0.0 rules: 841-dev + invented 0/845 changed; names-on re-scrub 0/841;
bench replay 12/12 cohorts equal; full Dynaword corpus 0/4,144,908 changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Files changed (6) hide show
  1. CHANGELOG.md +15 -0
  2. README.md +5 -4
  3. hybrid.json +2 -2
  4. nergal.py +3 -4
  5. scrub_pii.py +4 -1
  6. test_nergal.py +14 -2
CHANGELOG.md CHANGED
@@ -8,6 +8,21 @@ Semver for this island:
8
 
9
  Accuracy is 841-dev, union at 0.95. Through 1.1.2 the gold has 354 spans; from 1.2.0 it is restated to phone policy v3 (315 spans), and the two are not comparable. A version that changes those numbers must update `hybrid.json` `eval` and the tables below.
10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  ## 2.0.0
12
 
13
  Same NERGAL weights and threshold. Rules SHA `d1866243…`: the placeholder rename only; every rule span is unchanged. Major because the output placeholder changes.
 
8
 
9
  Accuracy is 841-dev, union at 0.95. Through 1.1.2 the gold has 354 spans; from 1.2.0 it is restated to phone policy v3 (315 spans), and the two are not comparable. A version that changes those numbers must update `hybrid.json` `eval` and the tables below.
10
 
11
+ ## 2.0.1
12
+
13
+ Same NERGAL weights, threshold and API. Rules SHA `c1b924a8…`: one fix for input that already holds `[PERSON]`.
14
+
15
+ - **A masked name ends an other-number label.** A `Kod`, `NIP`, `REGON`, `PESEL`, `KRS` or `ISBN` label up to 20 characters before a number keeps it from being a phone. In 2.0.0 that held across a `[PERSON]` placeholder, so scrubbing names-on output again could leave a phone that follows a masked name. In 2.0.1 a `[PERSON]` between the label and the number ends the label's reach.
16
+ - **Phone cues still cross `[PERSON]`.** A contact or phone label before a masked name still counts for the number after it. Unlike `[PHONE]` and `[PII]`, `[PERSON]` is not a rules boundary.
17
+ - **A first scrub is unchanged.** The rules read the raw text and name spans are joined after, so scrubbing raw text gives 2.0.0's rule spans, names on or off. The fix shows only when the input already holds `[PERSON]`, such as a second scrub.
18
+
19
+ 841-dev, names off: unchanged from 2.0.0. Release gates, 2.0.1 rules against 2.0.0's: 841-dev plus the invented placeholder cases, 0 of 845 passages changed, scrubbed once or twice; 841-dev scrubbed names-on and then again, 0 of 841 changed, so the fix is exercised only by the invented cases in `test_nergal.py`; the benchmark replay gives 2.0.0's numbers on all 12 cohorts; every on-disk Dynaword source (37 sources, 4,144,908 documents, full text, placeholder documents included), 0 changed.
20
+
21
+ | Version | Whole /315 | Residual | Rules FP | Union FP | Char P | Char R | What changed |
22
+ |---|---:|---:|---:|---:|---:|---:|---|
23
+ | 2.0.0, names off | 303 | 9 | 24 | 80 | 98.59% | 97.83% | `[PHONE]` replaces `[Telefon]`; opt-in `[PERSON]` names |
24
+ | 2.0.1, names off | 303 | 9 | 24 | 80 | 98.59% | 97.83% | A `[PERSON]` placeholder ends an other-number label |
25
+
26
  ## 2.0.0
27
 
28
  Same NERGAL weights and threshold. Rules SHA `d1866243…`: the placeholder rename only; every rule span is unchanged. Major because the output placeholder changes.
README.md CHANGED
@@ -16,7 +16,7 @@ tags:
16
  - hybrid
17
  ---
18
 
19
- # NERGAL 2.0.0
20
 
21
  **Named Entity Recognition with Grounded Additive Labels**
22
 
@@ -66,9 +66,9 @@ NERGAL is not designed to remove:
66
 
67
  These are intended exclusions; false positives can still mask some of this content.
68
 
69
- ### Known gaps in 2.0.0
70
 
71
- Unlabelled phones and identifiers (the rules take a bare phone only in the grouped national forms `601 234 567` and `22 123 45 67`; a plain `601234567` needs a label or the model), unusual formatting and damaged text can escape detection. **VINs and obfuscated emails** (such as `name (at) domain.pl`) are approved annotation targets, but that approval alone does not establish reliable support in the released 2.0.0 model. Do not rely on it to remove them consistently. An email with a lowercase word glued onto its domain (`jan@firma.plkontakt`) is masked together with that word, and text glued before an address can be masked with it. A phone written as short parts joined by two slashes (`12 / 345 / 678`) is left as text. An identifier with a `[PII]` / `[PHONE]` / `[Telefon]` placeholder inside it or right before it can be missed. Names-on gaps are under [Names](#names).
72
 
73
  ## Versions
74
 
@@ -78,7 +78,8 @@ Unlabelled phones and identifiers (the rules take a bare phone only in the group
78
  |---|---:|---:|---:|---:|---:|---:|---|
79
  | 1.1.2 | 303 | 9 | 83 | 333 | 94.37% | 97.83% | Email boundary rules |
80
  | 1.2.0 | 303 | 9 | 24 | 80 | 98.59% | 97.83% | Phone policy v3 at mask time, rules and model spans |
81
- | **2.0.0, names off** | **303** | **9** | **24** | **80** | **98.59%** | **97.83%** | `[PHONE]` replaces `[Telefon]`; opt-in `[PERSON]` names |
 
82
 
83
  1.0.0–1.1.2 were scored on the earlier 354-span gold (whole 323 → 326, union FP 133 → 80); per-version numbers are in `CHANGELOG.md`.
84
 
 
16
  - hybrid
17
  ---
18
 
19
+ # NERGAL 2.0.1
20
 
21
  **Named Entity Recognition with Grounded Additive Labels**
22
 
 
66
 
67
  These are intended exclusions; false positives can still mask some of this content.
68
 
69
+ ### Known gaps in 2.0.1
70
 
71
+ Unlabelled phones and identifiers (the rules take a bare phone only in the grouped national forms `601 234 567` and `22 123 45 67`; a plain `601234567` needs a label or the model), unusual formatting and damaged text can escape detection. **VINs and obfuscated emails** (such as `name (at) domain.pl`) are approved annotation targets, but that approval alone does not establish reliable support in the released 2.0.1 model. Do not rely on it to remove them consistently. An email with a lowercase word glued onto its domain (`jan@firma.plkontakt`) is masked together with that word, and text glued before an address can be masked with it. A phone written as short parts joined by two slashes (`12 / 345 / 678`) is left as text. An identifier with a `[PII]` / `[PHONE]` / `[Telefon]` placeholder inside it or right before it can be missed. Names-on gaps are under [Names](#names).
72
 
73
  ## Versions
74
 
 
78
  |---|---:|---:|---:|---:|---:|---:|---|
79
  | 1.1.2 | 303 | 9 | 83 | 333 | 94.37% | 97.83% | Email boundary rules |
80
  | 1.2.0 | 303 | 9 | 24 | 80 | 98.59% | 97.83% | Phone policy v3 at mask time, rules and model spans |
81
+ | 2.0.0, names off | 303 | 9 | 24 | 80 | 98.59% | 97.83% | `[PHONE]` replaces `[Telefon]`; opt-in `[PERSON]` names |
82
+ | **2.0.1, names off** | **303** | **9** | **24** | **80** | **98.59%** | **97.83%** | A `[PERSON]` placeholder ends an other-number label |
83
 
84
  1.0.0–1.1.2 were scored on the earlier 354-span gold (whole 323 → 326, union FP 133 → 80); per-version numbers are in `CHANGELOG.md`.
85
 
hybrid.json CHANGED
@@ -1,12 +1,12 @@
1
  {
2
  "full_name": "Named Entity Recognition with Grounded Additive Labels",
3
  "hub_id": "SlayerLab/NERGAL",
4
- "version": "2.0.0",
5
  "mode": "rules_union",
6
  "epoch": 5,
7
  "seed": 202609160,
8
  "threshold": 0.95,
9
- "rules_sha256": "d18662434b3bab69d74e7f63f76a646122cd9d7420de1d70914aaeb903ff2357",
10
  "eval": {
11
  "split": "841-dev",
12
  "gold_amendments": [
 
1
  {
2
  "full_name": "Named Entity Recognition with Grounded Additive Labels",
3
  "hub_id": "SlayerLab/NERGAL",
4
+ "version": "2.0.1",
5
  "mode": "rules_union",
6
  "epoch": 5,
7
  "seed": 202609160,
8
  "threshold": 0.95,
9
+ "rules_sha256": "c1b924a893ed01b739d6616fcc1e05ffc5c9c0df138ac1409ab3af427c6b0768",
10
  "eval": {
11
  "split": "841-dev",
12
  "gold_amendments": [
nergal.py CHANGED
@@ -14,18 +14,17 @@ from functools import lru_cache
14
  from pathlib import Path
15
 
16
  import scrub_pii
17
- from scrub_pii import LEGACY_PHONE_TAG, PHONE_TAG, PII_TAG
18
 
19
  HUB_ID = 'SlayerLab/NERGAL'
20
- VERSION = '2.0.0'
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 = 'd18662434b3bab69d74e7f63f76a646122cd9d7420de1d70914aaeb903ff2357'
27
  DTYPES = ('float32', 'float16')
28
- PERSON_TAG = '[PERSON]'
29
  MARKERS = (PHONE_TAG, PII_TAG, PERSON_TAG, LEGACY_PHONE_TAG)
30
  TAGS = {'person': PERSON_TAG, 'pii': PII_TAG, 'phone': PHONE_TAG} # ascending precedence
31
  RANK = {label: i for i, label in enumerate(TAGS)}
 
14
  from pathlib import Path
15
 
16
  import scrub_pii
17
+ from scrub_pii import LEGACY_PHONE_TAG, PERSON_TAG, PHONE_TAG, PII_TAG
18
 
19
  HUB_ID = 'SlayerLab/NERGAL'
20
+ VERSION = '2.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 = 'c1b924a893ed01b739d6616fcc1e05ffc5c9c0df138ac1409ab3af427c6b0768'
27
  DTYPES = ('float32', 'float16')
 
28
  MARKERS = (PHONE_TAG, PII_TAG, PERSON_TAG, LEGACY_PHONE_TAG)
29
  TAGS = {'person': PERSON_TAG, 'pii': PII_TAG, 'phone': PHONE_TAG} # ascending precedence
30
  RANK = {label: i for i, label in enumerate(TAGS)}
scrub_pii.py CHANGED
@@ -46,6 +46,7 @@ import re
46
  PHONE_TAG = "[PHONE]"
47
  PII_TAG = "[PII]"
48
  LEGACY_PHONE_TAG = "[Telefon]" # NERGAL 1.x output; still a boundary and a heading in text scrubbed before 2.0
 
49
  _BOUNDARY = "|".join(map(re.escape, (PHONE_TAG, PII_TAG, LEGACY_PHONE_TAG)))
50
  _BOUNDARY_WIDTH = max(map(len, (PHONE_TAG, PII_TAG, LEGACY_PHONE_TAG))) # 9: same window as 1.x
51
  COUNTS = ("email", "phone", "pesel", "nip", "regon", "account", "document", "krs", "electronic_address", "pin",
@@ -188,7 +189,9 @@ _PHONE_BREAK_RE = re.compile(
188
  r"|\d{1,2}[.)][ \t]+\d{1,2}[./]\d{1,2}[./](?:19|20)\d{2})")
189
  _SECTION_MARKER_RE = re.compile(r"\d{1,2}[.)](?!\d)")
190
  _PHONE_EXTENSION_RE =re.compile(r"(?:(?:[ \t]+,?[ \t]*|,[ \t]*)wew(?:n(?:ętrzny)?)?\.?[ \t]*\d{1,5}|[ \t]+do[ \t]+\d{1,3}(?=[ \t]*(?:[.;,](?!\d)|\r?\n|\Z)))(?!\w|[ \t]*\d)", re.I)
191
- _OTHER_NUMBER_LABEL_RE = re.compile(r"\b(?:NIP|REGON|PESEL|KRS|ISBN|kod)\b[^\d\n]{0,20}\Z", re.I)
 
 
192
  # Grouped national phones need no cue: web contact blocks write them bare. Plain 9-digit strings stay cue-gated,
193
  # since many unlabelled ones are not phones.
194
  _MOBILE_PREFIX = "45|50|51|53|57|60|66|69|72|73|78|79|88"
 
46
  PHONE_TAG = "[PHONE]"
47
  PII_TAG = "[PII]"
48
  LEGACY_PHONE_TAG = "[Telefon]" # NERGAL 1.x output; still a boundary and a heading in text scrubbed before 2.0
49
+ PERSON_TAG = "[PERSON]" # opt-in names (2.0); not a boundary: a phone cue before a name still counts
50
  _BOUNDARY = "|".join(map(re.escape, (PHONE_TAG, PII_TAG, LEGACY_PHONE_TAG)))
51
  _BOUNDARY_WIDTH = max(map(len, (PHONE_TAG, PII_TAG, LEGACY_PHONE_TAG))) # 9: same window as 1.x
52
  COUNTS = ("email", "phone", "pesel", "nip", "regon", "account", "document", "krs", "electronic_address", "pin",
 
189
  r"|\d{1,2}[.)][ \t]+\d{1,2}[./]\d{1,2}[./](?:19|20)\d{2})")
190
  _SECTION_MARKER_RE = re.compile(r"\d{1,2}[.)](?!\d)")
191
  _PHONE_EXTENSION_RE =re.compile(r"(?:(?:[ \t]+,?[ \t]*|,[ \t]*)wew(?:n(?:ętrzny)?)?\.?[ \t]*\d{1,5}|[ \t]+do[ \t]+\d{1,3}(?=[ \t]*(?:[.;,](?!\d)|\r?\n|\Z)))(?!\w|[ \t]*\d)", re.I)
192
+ # A name placeholder ends the label's reach (2.0.1): "Kod [PERSON] zadzwoń 601 200 300" is a phone.
193
+ _OTHER_NUMBER_LABEL_RE = re.compile(
194
+ r"\b(?:NIP|REGON|PESEL|KRS|ISBN|kod)\b(?:(?!" + re.escape(PERSON_TAG) + r")[^\d\n]){0,20}\Z", re.I)
195
  # Grouped national phones need no cue: web contact blocks write them bare. Plain 9-digit strings stay cue-gated,
196
  # since many unlabelled ones are not phones.
197
  _MOBILE_PREFIX = "45|50|51|53|57|60|66|69|72|73|78|79|88"
test_nergal.py CHANGED
@@ -7,7 +7,7 @@ import unittest
7
  from pathlib import Path
8
 
9
  HERE = Path(__file__).resolve().parent
10
- RULES_SHA = 'd18662434b3bab69d74e7f63f76a646122cd9d7420de1d70914aaeb903ff2357'
11
 
12
 
13
  class NergalTests(unittest.TestCase):
@@ -15,7 +15,7 @@ class NergalTests(unittest.TestCase):
15
  from nergal import GAP_IDS, GAPS, HUB_ID, RULES_SHA as PINNED, THRESHOLD, VERSION
16
  card = json.loads((HERE / 'hybrid.json').read_text())
17
  self.assertEqual(HUB_ID, 'SlayerLab/NERGAL')
18
- self.assertEqual(VERSION, '2.0.0')
19
  self.assertEqual(card['version'], VERSION)
20
  self.assertEqual(card['eval']['union_fp'], 80)
21
  self.assertEqual(card['eval']['rules_fp'], 24)
@@ -83,6 +83,18 @@ class NergalTests(unittest.TestCase):
83
  with self.subTest(text=text):
84
  self.assertEqual(found(text.format('[Telefon]')), found(text.format('[PHONE]')))
85
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  def test_grouped_national_phones_mask_without_a_cue(self):
87
  from nergal import rules
88
  for text, masked in (('Sklep Ala, 601 234 567, czynne 9-17', ['601 234 567']),
 
7
  from pathlib import Path
8
 
9
  HERE = Path(__file__).resolve().parent
10
+ RULES_SHA = 'c1b924a893ed01b739d6616fcc1e05ffc5c9c0df138ac1409ab3af427c6b0768'
11
 
12
 
13
  class NergalTests(unittest.TestCase):
 
15
  from nergal import GAP_IDS, GAPS, HUB_ID, RULES_SHA as PINNED, THRESHOLD, VERSION
16
  card = json.loads((HERE / 'hybrid.json').read_text())
17
  self.assertEqual(HUB_ID, 'SlayerLab/NERGAL')
18
+ self.assertEqual(VERSION, '2.0.1')
19
  self.assertEqual(card['version'], VERSION)
20
  self.assertEqual(card['eval']['union_fp'], 80)
21
  self.assertEqual(card['eval']['rules_fp'], 24)
 
83
  with self.subTest(text=text):
84
  self.assertEqual(found(text.format('[Telefon]')), found(text.format('[PHONE]')))
85
 
86
+ def test_a_name_placeholder_ends_an_other_number_label_but_never_a_phone_cue(self):
87
+ from nergal import rules
88
+ for text, masked in (('Kod [PERSON] zadzwoń 601 200 300', ['601 200 300']),
89
+ ('Kod Jan zadzwoń 601 200 300', []), # a raw name is text to the rules
90
+ ('[PERSON] NIP: 601 234 567', []), # a label after the name still counts
91
+ ('Telefon do [PERSON]: 601234567', ['601234567']), # plain 9 digits: cue-gated
92
+ ('Kontakt: [PERSON], 601234567', ['601234567']),
93
+ ('Informacje u [PERSON] pod numerem 601234567', ['601234567']),
94
+ ('tel. [PERSON] 601234567', ['601234567'])): # invented
95
+ with self.subTest(text=text):
96
+ self.assertEqual([text[s['start']:s['end']] for s in rules(text) if s['label'] == 'phone'], masked)
97
+
98
  def test_grouped_national_phones_mask_without_a_cue(self):
99
  from nergal import rules
100
  for text, masked in (('Sklep Ala, 601 234 567, czynne 9-17', ['601 234 567']),