ppuzio commited on
Commit
b21609b
·
verified ·
1 Parent(s): ee88d62

2.0.4: the names layer leaves procedural role nouns out

Browse files
Files changed (5) hide show
  1. CHANGELOG.md +17 -0
  2. README.md +11 -10
  3. hybrid.json +7 -7
  4. nergal.py +10 -3
  5. test_nergal.py +37 -1
CHANGELOG.md CHANGED
@@ -8,6 +8,23 @@ 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.3
12
 
13
  Same NERGAL weights, threshold, union and API. Rules SHA `4d054723…`: input that already holds a `[PII]`, `[PHONE]` or `[Telefon]` placeholder.
 
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.4
12
+
13
+ Same NERGAL weights, threshold, union, rules (SHA `4d054723…`) and API. One change in `nergal.py` `person_spans`: the names layer (`names=True`) leaves procedural role nouns out. Names off is unchanged.
14
+
15
+ - **Procedural role nouns are not persons.** FastPDN-PII tags the capitalised party roles of rulings as persons, so 2.0.3 masked `Odwołujący`, `Przystępującemu` or `Protestującej` as `[PERSON]`. A model word that is one of five role participles (odwołujący, przystępujący, zamawiający, protestujący, skarżący) in any case, number or gender (`-y`, `-ego`, `-emu`, `-ym`, `-ych`, `-ymi`, `-a`, `-ej`, `-ą`, `-e`), any letter case, with only punctuation around it, is dropped before words are merged into a span. The names policy already keeps roles outside a person span.
16
+ - **A name next to a role keeps its mask.** `Odwołujący Jan Nowak` masks `Jan Nowak`; a word that only starts with a role stem (`Odwołującyński`) is still masked.
17
+ - **The list is measured.** On the 240 KIO rulings of the UZP G1 check (slayerlabs/polish-dynaword-v2#112), after the full G1 pass, 2.0.3 left 4,619 of 7,297 capitalised `Odwołujący` forms (36.7% lost) and 1,272 of 1,971 `Przystępujący` (35.5% lost); 2.0.4 loses 18 (0.25%) and 11 (0.56%), and with names off G1's own rules already drop 16 and 9. `Zamawiający` loses 64 → 21 of 9,446, `Protestujący` 36 → 0 of 82, `Skarżący` 12 → 0 of 68. `[PERSON]` placeholders in those rulings fall 6,917 → 3,531. `Wykonawca` is left as it is: 2 capitalised forms inside a person span. Wnioskodawca, Uczestnik, Powód, Pozwany, Przewodniczący, Protokolant, Pełnomocnik, Prezes, Inwestor and Oferent are never inside a person span in the surveyed samples of six Dynaword sources (uzp, ted_pl, samorzad_gov_pl, govpl, eurlex, dziennik_ustaw).
18
+
19
+ Still missed: the number after a role (`Odwołujący 1`) stays `[PERSON]`, because a name can be letterless (the name test gold holds 4 letterless person words); role nouns outside the five; a role glued to another word in damaged text.
20
+
21
+ 841-dev, names off: unchanged from 2.0.3. Release gates (`artifacts/nergal_2_0_4_role_nouns/`, frozen before scoring, stored spans, no new inference): the 1,837 bench rows, the 1,000-document Dynaword runtime sample (names off and on) and 41,204 FineWeb miss-hunt passages, rule spans and output identical; the G1 delta rebuilds the stored 2.0.3 output of G1's NERGAL stage for all 240 rulings. Name test v1 and v2 (1,050 passages): a live CPU float32 run of the 2.0.4 `Names` equals the stored spans refiltered word by word, 0 mismatches; one passage changes in each set. `hybrid.json` `eval_names` is now scored on those refiltered spans: random panel whole names 586/614 and false-mask passages 34 unchanged, person false characters 1,051 → 1,021; targeted panel unchanged. Label pool v3 `bench`: names off, every cohort as 2.0.2; `--names`, names v1 random person false characters 1,051 → 1,021, names v2 random 573 → 561 (false-mask passages 34 → 33), whole names and recall unchanged on every cohort.
22
+
23
+ | Version | Whole /315 | Residual | Rules FP | Union FP | Char P | Char R | What changed |
24
+ |---|---:|---:|---:|---:|---:|---:|---|
25
+ | 2.0.3, names off | 303 | 9 | 24 | 80 | 98.59% | 97.83% | Values cut or preceded by a placeholder |
26
+ | 2.0.4, names off | 303 | 9 | 24 | 80 | 98.59% | 97.83% | Names layer leaves procedural role nouns out |
27
+
28
  ## 2.0.3
29
 
30
  Same NERGAL weights, threshold, union and API. Rules SHA `4d054723…`: input that already holds a `[PII]`, `[PHONE]` or `[Telefon]` placeholder.
README.md CHANGED
@@ -16,7 +16,7 @@ tags:
16
  - hybrid
17
  ---
18
 
19
- # NERGAL 2.0.3
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.3
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.3 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. Input that already holds a `[PII]` / `[PHONE]` / `[Telefon]` placeholder: a NIP or REGON label reaches past one placeholder to a checksum-valid value, and a value cut by a placeholder is masked digit group by digit group only after a `PESEL` / `NIP` / `REGON` label (`[PII]`) or a phone cue (`[PHONE]`). Without either (`8503[PII]1212343`, `601 [PII] 200 300`), with fewer than 5 digits, or when the run starts with a placeholder (`tel. [PHONE] 200 300`), the fragments stay. Names-on gaps are under [Names](#names).
72
 
73
  ## Versions
74
 
@@ -81,13 +81,14 @@ Unlabelled phones and identifiers (the rules take a bare phone only in the group
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
  | 2.0.2, names off | 303 | 9 | 24 | 80 | 98.59% | 97.83% | A rule's label wins on its characters |
84
- | **2.0.3, names off** | **303** | **9** | **24** | **80** | **98.59%** | **97.83%** | Values cut or preceded by a placeholder |
 
85
 
86
  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`.
87
 
88
  ## Names
89
 
90
- `names=True` (CLI `--names`) adds a second model, [FastPDN NER — Polish PII](https://huggingface.co/ArkadiuszPawlak/fastpdn-ner-polish-pii) by ArkadiuszPawlak, revision `636c57fe…`, fine-tuned from [clarin-pl/FastPDN](https://huggingface.co/clarin-pl/FastPDN) on filtered KPWr plus LLM-synthetic data. `names/` holds it converted from ONNX to safetensors with no value changed. **`names/` is licensed [CC-BY-4.0](https://creativecommons.org/licenses/by/4.0/), not MIT**; `names/NOTICE.md` has the attribution, the changes and the file hashes. NERGAL uses only its person tags (`PERSON`, `PERSON_F`, `PERSON_L`), masks whole words, and always runs it in float32.
91
 
92
  **Scope with names on.** The name test labels every person: private individuals, public officials, politicians and historical figures alike, plus fictional characters, nicknames, partial names and initials. Titles and roles stay outside the span. Names inside a street, patronage, organisation or work title, eponyms, usernames, and names inside emails or URLs are not person spans. Masking public figures removes who said, signed or wrote what, which is why names are opt-in. This is the labelling target; the model does not meet it everywhere.
93
 
@@ -95,10 +96,10 @@ Unlabelled phones and identifiers (the rules take a bare phone only in the group
95
 
96
  | Panel | Passages | Names | Whole | Components covered (95% CI) | Person false chars | Passages with one |
97
  |---|---:|---:|---:|---:|---:|---:|
98
- | Random | 300 | 614 | 586 (95.44%) | 98/116 (76.6–90.5%) | 1,051 | 34 (11.3%) |
99
  | Targeted | 150 | 292 | 286 (97.95%) | 94/99 (88.6–98.3%) | 976 | 42 (28.0%) |
100
 
101
- Names off hides no name. A component is the independent unit, covered when every name in it is whole: on the random panel 18 of 116 keep part of a name. The second pass is a same-day blind self-review of 100 passages by the same reviewer (11 disagreed), not independent agreement. `hybrid.json` `eval_names` has the full numbers, including float16 and INT8 scored for the record; neither ships.
102
 
103
  **Cost:** on one RTX 4090 with 3 processes, names took 2.0.0 from 74,520 to 66,492 chars/s (−11%) and peak device memory from 9,006 to 15,416 MiB; with one process, −9%. Local measurements are in `CHANGELOG.md`.
104
 
@@ -112,7 +113,7 @@ Names off hides no name. A component is the independent unit, covered when every
112
 
113
  ## Compared with other systems
114
 
115
- Same split and gold. Every model runs through the NERGAL recipe with names off: its spans at 0.95, phone spans cut by phone policy v3, unioned with the rules. The rows were scored with the 1.2.0 rules; 2.0.0 through 2.0.3 give the same rule spans on this split. **Naked** is the model alone. **Residual** is passages with any gold character left unmasked. Character scores are gold vs masked characters.
116
 
117
  | System | Mode | Whole /315 | Residual | False chars | Char P | Char R |
118
  |---|---|---:|---:|---:|---:|---:|
@@ -122,9 +123,9 @@ Same split and gold. Every model runs through the NERGAL recipe with names off:
122
  | Fine-tuned GLiNER (previous incumbent) | naked | 263 | 28 | 15 | 99.70% | 88.80% |
123
  | Fine-tuned GLiNER (previous incumbent) | ∪ regex | 299 | 12 | 39 | 99.30% | 97.18% |
124
  | XLM-R epoch 5 | naked | 275 | 28 | 67 | 98.72% | 90.25% |
125
- | **NERGAL 2.0.3, names off** | **∪ regex** | **303** | **9** | **80** | **98.59%** | **97.83%** |
126
 
127
- Zero-shot [GLiNER 2.5](https://huggingface.co/fastino/gliner2.5-multi-v1) is not competitive, especially on non-phone PII (7/185 whole, naked). The fine-tuned GLiNER that NERGAL replaced is the more precise union (39 false characters vs 80) and the stronger phone model (128/130 whole, naked, vs 115), but it masks 4 fewer values whole and leaves 12 residual passages vs 9; XLM-R is the stronger model on other PII (160/185 vs 135). NERGAL 2.0.3 wholly masks 124/130 phones and 179/185 other PII, each under its own placeholder (`[PHONE]` / `[PII]`); exact-span precision 91.05%, recall 93.65%, F1 92.33%.
128
 
129
  XLM-R and epoch 5 were chosen in September 2026, on the earlier gold and before the phone-policy cut. Fine-tuned GLiNER, HerBERT-large and XLM-R-large were trained on the same split; only XLM-R passed the content-preservation gate, and epoch 5 at 0.95 (seed `202609160`) was the only one of 133 epoch/threshold points that covered more than the fine-tuned GLiNER union of that time while adding no false-mask characters it did not already make. Under the 1.2.0 rules, gold and phone cut, that no longer holds: the GLiNER union makes fewer false masks (table above).
130
 
 
16
  - hybrid
17
  ---
18
 
19
+ # NERGAL 2.0.4
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.4
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.4 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. Input that already holds a `[PII]` / `[PHONE]` / `[Telefon]` placeholder: a NIP or REGON label reaches past one placeholder to a checksum-valid value, and a value cut by a placeholder is masked digit group by digit group only after a `PESEL` / `NIP` / `REGON` label (`[PII]`) or a phone cue (`[PHONE]`). Without either (`8503[PII]1212343`, `601 [PII] 200 300`), with fewer than 5 digits, or when the run starts with a placeholder (`tel. [PHONE] 200 300`), the fragments stay. Names-on gaps are under [Names](#names).
72
 
73
  ## Versions
74
 
 
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
  | 2.0.2, names off | 303 | 9 | 24 | 80 | 98.59% | 97.83% | A rule's label wins on its characters |
84
+ | 2.0.3, names off | 303 | 9 | 24 | 80 | 98.59% | 97.83% | Values cut or preceded by a placeholder |
85
+ | **2.0.4, names off** | **303** | **9** | **24** | **80** | **98.59%** | **97.83%** | Names layer leaves procedural role nouns out |
86
 
87
  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`.
88
 
89
  ## Names
90
 
91
+ `names=True` (CLI `--names`) adds a second model, [FastPDN NER — Polish PII](https://huggingface.co/ArkadiuszPawlak/fastpdn-ner-polish-pii) by ArkadiuszPawlak, revision `636c57fe…`, fine-tuned from [clarin-pl/FastPDN](https://huggingface.co/clarin-pl/FastPDN) on filtered KPWr plus LLM-synthetic data. `names/` holds it converted from ONNX to safetensors with no value changed. **`names/` is licensed [CC-BY-4.0](https://creativecommons.org/licenses/by/4.0/), not MIT**; `names/NOTICE.md` has the attribution, the changes and the file hashes. NERGAL uses only its person tags (`PERSON`, `PERSON_F`, `PERSON_L`), masks whole words, and always runs it in float32. Since 2.0.4 it leaves procedural role nouns out of a person span: odwołujący, przystępujący, zamawiający, protestujący and skarżący in any inflection (`Odwołujący Jan Nowak` → `Odwołujący [PERSON]`). The number after a role (`Odwołujący 1`) and role nouns outside these five can still be masked.
92
 
93
  **Scope with names on.** The name test labels every person: private individuals, public officials, politicians and historical figures alike, plus fictional characters, nicknames, partial names and initials. Titles and roles stay outside the span. Names inside a street, patronage, organisation or work title, eponyms, usernames, and names inside emails or URLs are not person spans. Masking public figures removes who said, signed or wrote what, which is why names are opt-in. This is the labelling target; the model does not meet it everywhere.
94
 
 
96
 
97
  | Panel | Passages | Names | Whole | Components covered (95% CI) | Person false chars | Passages with one |
98
  |---|---:|---:|---:|---:|---:|---:|
99
+ | Random | 300 | 614 | 586 (95.44%) | 98/116 (76.6–90.5%) | 1,021 | 34 (11.3%) |
100
  | Targeted | 150 | 292 | 286 (97.95%) | 94/99 (88.6–98.3%) | 976 | 42 (28.0%) |
101
 
102
+ Names off hides no name. A component is the independent unit, covered when every name in it is whole: on the random panel 18 of 116 keep part of a name. The second pass is a same-day blind self-review of 100 passages by the same reviewer (11 disagreed), not independent agreement. Since 2.0.4 the scores are the frozen 2.0.0 predictions with person spans refiltered through 2.0.4's role-noun rule (2.0.3: 1,051 random false characters). `hybrid.json` `eval_names` has the full numbers, including float16 and INT8 scored for the record; neither ships.
103
 
104
  **Cost:** on one RTX 4090 with 3 processes, names took 2.0.0 from 74,520 to 66,492 chars/s (−11%) and peak device memory from 9,006 to 15,416 MiB; with one process, −9%. Local measurements are in `CHANGELOG.md`.
105
 
 
113
 
114
  ## Compared with other systems
115
 
116
+ Same split and gold. Every model runs through the NERGAL recipe with names off: its spans at 0.95, phone spans cut by phone policy v3, unioned with the rules. The rows were scored with the 1.2.0 rules; 2.0.0 through 2.0.4 give the same rule spans on this split. **Naked** is the model alone. **Residual** is passages with any gold character left unmasked. Character scores are gold vs masked characters.
117
 
118
  | System | Mode | Whole /315 | Residual | False chars | Char P | Char R |
119
  |---|---|---:|---:|---:|---:|---:|
 
123
  | Fine-tuned GLiNER (previous incumbent) | naked | 263 | 28 | 15 | 99.70% | 88.80% |
124
  | Fine-tuned GLiNER (previous incumbent) | ∪ regex | 299 | 12 | 39 | 99.30% | 97.18% |
125
  | XLM-R epoch 5 | naked | 275 | 28 | 67 | 98.72% | 90.25% |
126
+ | **NERGAL 2.0.4, names off** | **∪ regex** | **303** | **9** | **80** | **98.59%** | **97.83%** |
127
 
128
+ Zero-shot [GLiNER 2.5](https://huggingface.co/fastino/gliner2.5-multi-v1) is not competitive, especially on non-phone PII (7/185 whole, naked). The fine-tuned GLiNER that NERGAL replaced is the more precise union (39 false characters vs 80) and the stronger phone model (128/130 whole, naked, vs 115), but it masks 4 fewer values whole and leaves 12 residual passages vs 9; XLM-R is the stronger model on other PII (160/185 vs 135). NERGAL 2.0.4 wholly masks 124/130 phones and 179/185 other PII, each under its own placeholder (`[PHONE]` / `[PII]`); exact-span precision 91.05%, recall 93.65%, F1 92.33%.
129
 
130
  XLM-R and epoch 5 were chosen in September 2026, on the earlier gold and before the phone-policy cut. Fine-tuned GLiNER, HerBERT-large and XLM-R-large were trained on the same split; only XLM-R passed the content-preservation gate, and epoch 5 at 0.95 (seed `202609160`) was the only one of 133 epoch/threshold points that covered more than the fine-tuned GLiNER union of that time while adding no false-mask characters it did not already make. Under the 1.2.0 rules, gold and phone cut, that no longer holds: the GLiNER union makes fewer false masks (table above).
131
 
hybrid.json CHANGED
@@ -1,7 +1,7 @@
1
  {
2
  "full_name": "Named Entity Recognition with Grounded Additive Labels",
3
  "hub_id": "SlayerLab/NERGAL",
4
- "version": "2.0.3",
5
  "mode": "rules_union",
6
  "epoch": 5,
7
  "seed": 202609160,
@@ -54,7 +54,7 @@
54
  "max_length": 512,
55
  "stride": 128,
56
  "dtype": "float32",
57
- "decode": "argmax, union over windows, expanded to whole words",
58
  "converted_from": {
59
  "model.onnx": "11924bfd20eb478ee614e5e1184536825db4747753d341af7c04b940efa84645",
60
  "convert.py": "a698bc993b253fc70d960aa14af37a5088afb79c77dae70557c7056ce394333e"
@@ -82,7 +82,7 @@
82
  "fp16": "mps",
83
  "int8": "cpu"
84
  },
85
- "score_report_sha256": "9dec1517dae8fefb4857acab975d83d82b217373b1e7efdee56a685eaf9dc6b2",
86
  "panels": {
87
  "random": {
88
  "passages": 300,
@@ -98,13 +98,13 @@
98
  0.7659,
99
  0.9054
100
  ],
101
- "person_false_chars": 1051,
102
  "person_false_passages": 34,
103
  "person_false_ci95": [
104
  0.0798,
105
  0.1548
106
  ],
107
- "false_chars": 1052,
108
  "false_mask_passages": 35,
109
  "phone_gold": 2,
110
  "phone_whole": 2,
@@ -122,13 +122,13 @@
122
  0.7659,
123
  0.9054
124
  ],
125
- "person_false_chars": 1051,
126
  "person_false_passages": 34,
127
  "person_false_ci95": [
128
  0.0798,
129
  0.1548
130
  ],
131
- "false_chars": 1052,
132
  "false_mask_passages": 35,
133
  "phone_gold": 2,
134
  "phone_whole": 2,
 
1
  {
2
  "full_name": "Named Entity Recognition with Grounded Additive Labels",
3
  "hub_id": "SlayerLab/NERGAL",
4
+ "version": "2.0.4",
5
  "mode": "rules_union",
6
  "epoch": 5,
7
  "seed": 202609160,
 
54
  "max_length": 512,
55
  "stride": 128,
56
  "dtype": "float32",
57
+ "decode": "argmax, union over windows, expanded to whole words, procedural role nouns left out",
58
  "converted_from": {
59
  "model.onnx": "11924bfd20eb478ee614e5e1184536825db4747753d341af7c04b940efa84645",
60
  "convert.py": "a698bc993b253fc70d960aa14af37a5088afb79c77dae70557c7056ce394333e"
 
82
  "fp16": "mps",
83
  "int8": "cpu"
84
  },
85
+ "score_report_sha256": "161c10a872efebbea44a57af0f87004b6c443c2490240b3e41cc93ffeabd1ff8",
86
  "panels": {
87
  "random": {
88
  "passages": 300,
 
98
  0.7659,
99
  0.9054
100
  ],
101
+ "person_false_chars": 1021,
102
  "person_false_passages": 34,
103
  "person_false_ci95": [
104
  0.0798,
105
  0.1548
106
  ],
107
+ "false_chars": 1022,
108
  "false_mask_passages": 35,
109
  "phone_gold": 2,
110
  "phone_whole": 2,
 
122
  0.7659,
123
  0.9054
124
  ],
125
+ "person_false_chars": 1021,
126
  "person_false_passages": 34,
127
  "person_false_ci95": [
128
  0.0798,
129
  0.1548
130
  ],
131
+ "false_chars": 1022,
132
  "false_mask_passages": 35,
133
  "phone_gold": 2,
134
  "phone_whole": 2,
nergal.py CHANGED
@@ -17,7 +17,7 @@ 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.3'
21
  GAPS = ['[PII_SPACE]', '[PII_BREAK]']
22
  GAP_IDS = [250002, 250003]
23
  BIO_LABELS = ['O', 'B-phone', 'I-phone', 'B-pii', 'I-pii']
@@ -31,6 +31,12 @@ RANK = {label: i for i, label in enumerate(TAGS)}
31
  PERSON_LABELS = ('PERSON', 'PERSON_F', 'PERSON_L')
32
  _WORD = re.compile(r"[\w'’-]")
33
  _PLACEHOLDER = re.compile('|'.join(map(re.escape, MARKERS)))
 
 
 
 
 
 
34
 
35
 
36
  def sha(path):
@@ -198,11 +204,12 @@ def expand(text, start, end):
198
 
199
  def person_spans(text, ranges):
200
  """Person spans from FastPDN person-token ranges: whole words, joined across spaces, tabs and no-break spaces
201
- (not punctuation or line breaks); words touching a placeholder are skipped."""
 
202
  blocked = [m.span() for m in _PLACEHOLDER.finditer(text)]
203
  merged = []
204
  for start, end in sorted(expand(text, a, b) for a, b in ranges):
205
- if any(start < b and a < end for a, b in blocked):
206
  continue
207
  if merged and not text[merged[-1][1]:start].strip(' \t\u00a0'):
208
  merged[-1][1] = max(merged[-1][1], end)
 
17
  from scrub_pii import LEGACY_PHONE_TAG, PERSON_TAG, PHONE_TAG, PII_TAG
18
 
19
  HUB_ID = 'SlayerLab/NERGAL'
20
+ VERSION = '2.0.4'
21
  GAPS = ['[PII_SPACE]', '[PII_BREAK]']
22
  GAP_IDS = [250002, 250003]
23
  BIO_LABELS = ['O', 'B-phone', 'I-phone', 'B-pii', 'I-pii']
 
31
  PERSON_LABELS = ('PERSON', 'PERSON_F', 'PERSON_L')
32
  _WORD = re.compile(r"[\w'’-]")
33
  _PLACEHOLDER = re.compile('|'.join(map(re.escape, MARKERS)))
34
+ # Procedural party roles the names model tags as persons (artifacts/nergal_2_0_4_role_nouns): the noun in any
35
+ # inflection. Names policy keeps roles outside a person span.
36
+ _PARTICIPLE = '(?:y|ego|emu|ym|ych|ymi|a|ej|ą|e)'
37
+ ROLE_NOUNS = tuple(stem + _PARTICIPLE for stem in ('odwołując', 'przystępując', 'zamawiając', 'protestując',
38
+ 'skarżąc'))
39
+ _ROLE_NOUN = re.compile(r'\W*(?:%s)\W*' % '|'.join(ROLE_NOUNS), re.IGNORECASE)
40
 
41
 
42
  def sha(path):
 
204
 
205
  def person_spans(text, ranges):
206
  """Person spans from FastPDN person-token ranges: whole words, joined across spaces, tabs and no-break spaces
207
+ (not punctuation or line breaks); words touching a placeholder and procedural role nouns (ROLE_NOUNS) are
208
+ skipped."""
209
  blocked = [m.span() for m in _PLACEHOLDER.finditer(text)]
210
  merged = []
211
  for start, end in sorted(expand(text, a, b) for a, b in ranges):
212
+ if any(start < b and a < end for a, b in blocked) or _ROLE_NOUN.fullmatch(text, start, end):
213
  continue
214
  if merged and not text[merged[-1][1]:start].strip(' \t\u00a0'):
215
  merged[-1][1] = max(merged[-1][1], end)
test_nergal.py CHANGED
@@ -1,6 +1,7 @@
1
  """Synthetic NERGAL tests. Invented strings only; no corpus text or real identifiers."""
2
  import hashlib
3
  import json
 
4
  import shutil
5
  import tempfile
6
  import unittest
@@ -8,6 +9,11 @@ from pathlib import Path
8
 
9
  HERE = Path(__file__).resolve().parent
10
  RULES_SHA = '4d0547238131908ca498a23607a7877512b2fb6022c484171334af1e604fc95a'
 
 
 
 
 
11
 
12
 
13
  class NergalTests(unittest.TestCase):
@@ -15,7 +21,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.3')
19
  self.assertEqual(card['version'], VERSION)
20
  self.assertEqual(card['eval']['union_fp'], 80)
21
  self.assertEqual(card['eval']['rules_fp'], 24)
@@ -245,6 +251,23 @@ class NergalTests(unittest.TestCase):
245
  self.assertEqual(person_spans('Nowak', [(0, 2), (1, 5)]), [{'start': 0, 'end': 5, 'label': 'person', 'score': 1.0}])
246
  self.assertEqual(person_spans('x', []), [])
247
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
248
  def test_model_phone_spans_follow_the_phone_policy(self):
249
  from nergal import model_keep, scrub_spans
250
  span = lambda text, part, label='phone', score=0.99: dict(
@@ -322,6 +345,19 @@ class NergalTests(unittest.TestCase):
322
  text = 'Ala ma kota.' # a first name alone is a person (names policy)
323
  self.assertEqual([text[s['start']:s['end']] for s in names.spans(text)], ['Ala'])
324
 
 
 
 
 
 
 
 
 
 
 
 
 
 
325
  def test_names_find_a_person_past_the_first_window_and_are_idempotent(self):
326
  from nergal import apply_union
327
  names = self._names()
 
1
  """Synthetic NERGAL tests. Invented strings only; no corpus text or real identifiers."""
2
  import hashlib
3
  import json
4
+ import re
5
  import shutil
6
  import tempfile
7
  import unittest
 
9
 
10
  HERE = Path(__file__).resolve().parent
11
  RULES_SHA = '4d0547238131908ca498a23607a7877512b2fb6022c484171334af1e604fc95a'
12
+ # Procedural party-role nouns in every inflection the names layer leaves out, built here, not from ROLE_NOUNS
13
+ PARTICIPLE_ENDINGS = ('y', 'ego', 'emu', 'ym', 'ych', 'ymi', 'a', 'ej', 'ą', 'e')
14
+ ROLE_NOUN_FORMS = tuple(stem + e for stem in ('Odwołując', 'Przystępując', 'Zamawiając', 'Protestując', 'Skarżąc')
15
+ for e in PARTICIPLE_ENDINGS)
16
+ STEM_NAMES = ('Odwołującyński', 'Zamawiającek', 'Skarżącki')
17
 
18
 
19
  class NergalTests(unittest.TestCase):
 
21
  from nergal import GAP_IDS, GAPS, HUB_ID, RULES_SHA as PINNED, THRESHOLD, VERSION
22
  card = json.loads((HERE / 'hybrid.json').read_text())
23
  self.assertEqual(HUB_ID, 'SlayerLab/NERGAL')
24
+ self.assertEqual(VERSION, '2.0.4')
25
  self.assertEqual(card['version'], VERSION)
26
  self.assertEqual(card['eval']['union_fp'], 80)
27
  self.assertEqual(card['eval']['rules_fp'], 24)
 
251
  self.assertEqual(person_spans('Nowak', [(0, 2), (1, 5)]), [{'start': 0, 'end': 5, 'label': 'person', 'score': 1.0}])
252
  self.assertEqual(person_spans('x', []), [])
253
 
254
+ def test_person_spans_leave_procedural_role_nouns_out(self):
255
+ from nergal import person_spans
256
+
257
+ def persons(text): # every word tagged, as the names model tags a role noun next to a name
258
+ return [text[s['start']:s['end']] for s in person_spans(text, [m.span() for m in re.finditer(r'\S+', text)])]
259
+ for noun in ROLE_NOUN_FORMS:
260
+ self.assertEqual(persons(noun), [], noun)
261
+ self.assertEqual(persons(noun.upper()), [], noun)
262
+ self.assertEqual(persons('Odwołujący Jan Nowak'), ['Jan Nowak']) # the name stays
263
+ self.assertEqual(persons('Jan Nowak Przystępujący Anna Kowal'), ['Jan Nowak', 'Anna Kowal']) # the noun splits
264
+ text = 'od Przystępującego, na rzecz'
265
+ comma = text.index(',')
266
+ self.assertEqual(person_spans(text, [(comma, comma + 1)]), []) # a tagged comma grows to the noun
267
+ self.assertEqual(persons('(Zamawiającemu)'), [])
268
+ for name in STEM_NAMES: # invented surnames that start like a role noun
269
+ self.assertEqual(persons(name), [name])
270
+
271
  def test_model_phone_spans_follow_the_phone_policy(self):
272
  from nergal import model_keep, scrub_spans
273
  span = lambda text, part, label='phone', score=0.99: dict(
 
345
  text = 'Ala ma kota.' # a first name alone is a person (names policy)
346
  self.assertEqual([text[s['start']:s['end']] for s in names.spans(text)], ['Ala'])
347
 
348
+ def test_names_leave_role_nouns_out_and_keep_the_names(self):
349
+ names = self._names()
350
+ texts = ['W toku rozprawy Przystępujący podtrzymał swoje stanowisko. Odwołujący wniósł o uwzględnienie odwołania, '
351
+ 'a Zamawiający o jego oddalenie.',
352
+ 'Izba oddaliła odwołanie. Odwołujący nie wykazał, że Przystępującemu należało odrzucić ofertę.',
353
+ 'Odwołujący Jan Wrzos wniósł odwołanie. Przystępujący Tomasz Kmieć zgłosił przystąpienie po stronie '
354
+ 'Zamawiającego.',
355
+ 'Protestująca wniosła protest. Zamawiający oddalił protest Protestującej, a Protestująca wniosła odwołanie.',
356
+ 'Skarżąca wniosła skargę na decyzję. Organ wniósł o oddalenie skargi Skarżącej.']
357
+ found = [[t[s['start']:s['end']] for s in spans] for t, spans in zip(texts, names.spans_many(texts))]
358
+ # 2.0.3: 'Przystępujący', 'Przystępującemu', 'Protestującej', 'Protestująca', 'Skarżącej'
359
+ self.assertEqual(found, [[], [], ['Jan Wrzos', 'Tomasz Kmieć'], [], []])
360
+
361
  def test_names_find_a_person_past_the_first_window_and_are_idempotent(self):
362
  from nergal import apply_union
363
  names = self._names()