Instructions to use SlayerLab/NERGAL with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use SlayerLab/NERGAL with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("token-classification", model="SlayerLab/NERGAL")# pip install -U transformers accelerate # Load model directly from transformers import AutoTokenizer, AutoModelForTokenClassification tokenizer = AutoTokenizer.from_pretrained("SlayerLab/NERGAL") model = AutoModelForTokenClassification.from_pretrained("SlayerLab/NERGAL", device_map="auto") - Notebooks
- Google Colab
- Kaggle
2.0.8: an ID-card series and number named apart, and a KW number read out in words
Browse files- CHANGELOG.md +18 -0
- README.md +11 -10
- hybrid.json +2 -2
- nergal.py +2 -2
- scrub_pii.py +69 -3
- test_nergal.py +22 -2
CHANGELOG.md
CHANGED
|
@@ -8,6 +8,24 @@ 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.7
|
| 12 |
|
| 13 |
Same NERGAL weights, threshold, union, rules (SHA `4d054723…`) and API. One change in `nergal.py` `person_spans`: the names layer (`names=True`) also leaves a role noun damaged in the text out of a person span. Names off is unchanged.
|
|
|
|
| 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.8
|
| 12 |
+
|
| 13 |
+
Same NERGAL weights, threshold, union, names layer and API. Rules SHA `4d054723…` → `7db3f739…`, two forms seen in notarial deeds:
|
| 14 |
+
|
| 15 |
+
- **ID card, series and number named apart.** `dowodem osobistym serii ABA numer 300000`: after a dowód osobisty label, a 3-letter series, then `numer` or `nr` (a comma and a dot allowed), then 6 digits. The series and the number are masked apart, `[PII] numer [PII]`; the words between stay. 2.0.7's rules masked neither.
|
| 16 |
+
- **KW number read out in words.** `GD jeden G łamane przez zero zero jeden dwa trzy cztery pięć łamane przez zero`: a court code (its digit as a digit or a word), the number in digits or number words (digit by digit, as a cardinal, or either after spoken zeros), and the check digit, joined by `łamane przez`. Masked as one `[PII]` under the digit form's gate: a land-register label within 64 characters before, or, unlabelled, a number of at most 8 digits (zero-filled) with a valid check digit.
|
| 17 |
+
- **Measured on the full corpus.** Frozen (`artifacts/nergal_2_0_8_deed_ids/freeze.json`), then run once over 3,469,752 documents (every on-disk Dynaword source and the UZP rulings): 1 document changes (parliamentary), 2 spans and 9 characters gain a mask, none loses or changes one. A census of every dowód osobisty label followed by a 3-letter series and a 6-digit number: 21, of which 2.0.7 masks 10 and 2.0.8 11. The corpus holds one `łamane przez … łamane przez` pair, with no court code before it and no land-register label near it; neither version masks it.
|
| 18 |
+
- **Human check.** Both added spans, in their one passage, reviewed against a rule fixed beforehand (`human_check_prereg.json`): an ID-card series and number, accepted as they are.
|
| 19 |
+
|
| 20 |
+
Still missed: an ID card whose series stands 1 to 6 words after the label, words the label pattern does not take (the other 10 census hits: eurlex 3, parliamentary 3, UZP 3, Wikipedia 1); a KW number in words with no label and a wrong check digit, or with more than 8 digits.
|
| 21 |
+
|
| 22 |
+
841-dev, names off: unchanged from 2.0.7. Dev delta (`dev_delta.json`, stored spans, no new inference): rule spans change in none of the 1,837 scored bench rows, the 1,000 Dynaword documents (names off and on) or the 41,204 FineWeb passages, and no label pool v3 bench cohort moves, names off or on. Its gates, all 0: masked characters lost, unions not rebuilt from their char labels, the 2.0.7 side differing from the published bench files. Full-corpus gates (`full_corpus/gates.json`), both 0: masked characters lost, relabelled. Rule spans are also equal to 2.0.7's on name tests v1 (450 texts) and v2 (600) and the role bench (4,615). `hybrid.json` `eval` and `eval_names` are unchanged.
|
| 23 |
+
|
| 24 |
+
| Version | Whole /315 | Residual | Rules FP | Union FP | Char P | Char R | What changed |
|
| 25 |
+
|---|---:|---:|---:|---:|---:|---:|---|
|
| 26 |
+
| 2.0.7, names off | 303 | 9 | 24 | 80 | 98.59% | 97.83% | Names layer leaves role nouns cut short or glued to a word out |
|
| 27 |
+
| 2.0.8, names off | 303 | 9 | 24 | 80 | 98.59% | 97.83% | ID-card series and number named apart; KW number read out in words |
|
| 28 |
+
|
| 29 |
## 2.0.7
|
| 30 |
|
| 31 |
Same NERGAL weights, threshold, union, rules (SHA `4d054723…`) and API. One change in `nergal.py` `person_spans`: the names layer (`names=True`) also leaves a role noun damaged in the text out of a person span. Names off is unchanged.
|
README.md
CHANGED
|
@@ -16,7 +16,7 @@ tags:
|
|
| 16 |
- hybrid
|
| 17 |
---
|
| 18 |
|
| 19 |
-
# NERGAL 2.0.
|
| 20 |
|
| 21 |
**Named Entity Recognition with Grounded Additive Labels**
|
| 22 |
|
|
@@ -47,7 +47,7 @@ These are target categories, not a guarantee that every occurrence or format is
|
|
| 47 |
| Personal identifiers | PESEL, passport and identity-document numbers | `[PII]` |
|
| 48 |
| Organization identifiers | NIP/VAT, REGON, KRS, GEMI, LEI and equivalent foreign company registration numbers; labelled DUNS, BDO and RPWDL register-book numbers | `[PII]` |
|
| 49 |
| Financial identifiers | Bank/account numbers, including Polish accounts and foreign IBANs | `[PII]` |
|
| 50 |
-
| Property identifiers | Land-register (księga wieczysta, KW) numbers | `[PII]` |
|
| 51 |
| Electronic contacts and access | e-Doręczenia and ePUAP addresses, explicitly labelled numeric access PINs (including `pin=` URL values), GG account IDs | `[PII]` |
|
| 52 |
| Person names, only with `names=True` | Names of people, public officials and historical figures included ([Names](#names)) | `[PERSON]` |
|
| 53 |
|
|
@@ -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.
|
| 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.
|
| 72 |
|
| 73 |
## Versions
|
| 74 |
|
|
@@ -85,7 +85,8 @@ Unlabelled phones and identifiers (the rules take a bare phone only in the group
|
|
| 85 |
| 2.0.4, names off | 303 | 9 | 24 | 80 | 98.59% | 97.83% | Names layer leaves procedural role nouns out |
|
| 86 |
| 2.0.5, names off | 303 | 9 | 24 | 80 | 98.59% | 97.83% | Names layer leaves more role nouns, and a number right after one, out |
|
| 87 |
| 2.0.6, names off | 303 | 9 | 24 | 80 | 98.59% | 97.83% | A model span that stops inside a number grows over the whole number |
|
| 88 |
-
|
|
|
|
|
| 89 |
|
| 90 |
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`.
|
| 91 |
|
|
@@ -102,7 +103,7 @@ Unlabelled phones and identifiers (the rules take a bare phone only in the group
|
|
| 102 |
| Random | 300 | 614 | 586 (95.44%) | 98/116 (76.6–90.5%) | 1,021 | 34 (11.3%) |
|
| 103 |
| Targeted | 150 | 292 | 286 (97.95%) | 94/99 (88.6–98.3%) | 976 | 42 (28.0%) |
|
| 104 |
|
| 105 |
-
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 the release's role-noun rule (2.0.3: 1,051 random false characters); 2.0.5
|
| 106 |
|
| 107 |
**Role-noun bench** (`nergal_role_bench_v1`, 2026-10-19): how often a capitalised procedural role is masked as a person. A role noun is never a name, so every one masked is a false mask and no gold is needed. 4,615 Dynaword legal documents, about 5M characters each from UZP appeal rulings, TED notices, local-government and gov.pl pages, EUR-Lex and Dziennik Ustaw, excluding the documents the 2.0.4 roles were chosen on; the 2.0.5 roles were chosen on the UZP rulings outside the bench. Every column runs the same FastPDN weights, so the change is NERGAL's span filter, not the model.
|
| 108 |
|
|
@@ -114,7 +115,7 @@ Names off hides no name. A component is the independent unit, covered when every
|
|
| 114 |
| Skarżący | 50 | 3 | 3 | 0 | **0** |
|
| 115 |
| Wykonawca, Prezes, Inwestor | 14,547 | 6 | 6 | 6 | **0** |
|
| 116 |
|
| 117 |
-
The other nine roles tracked (wnioskodawca, uczestnik, powód, pozwany, przewodniczący, protokolant, pełnomocnik, oferent, protestujący) have no mask in any version. The one 2.0.4 `Zamawiający` is a truncated `Zamawiając`; it was known from this table before 2.0.5 added the bare stem, so its 1 → 0 is not a held-out result. Every version masks all 69,834 other words FastPDN tags, so the filter removes only role nouns; name recall is the name test above. The bench holds no tagged number after a role, so the 2.0.5 number rule is measured on UZP rulings (`CHANGELOG.md`). 2.0.6 changes only model `phone`/`pii` spans; its run equals 2.0.5 on every role and the guard. The bench holds no damaged role noun, so 2.0.7 equals 2.0.5 there too; its words were checked by hand on UZP rulings (`CHANGELOG.md`). The texts hold real names and are not released.
|
| 118 |
|
| 119 |
**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`.
|
| 120 |
|
|
@@ -128,7 +129,7 @@ The other nine roles tracked (wnioskodawca, uczestnik, powód, pozwany, przewodn
|
|
| 128 |
|
| 129 |
## Compared with other systems
|
| 130 |
|
| 131 |
-
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.
|
| 132 |
|
| 133 |
| System | Mode | Whole /315 | Residual | False chars | Char P | Char R |
|
| 134 |
|---|---|---:|---:|---:|---:|---:|
|
|
@@ -138,9 +139,9 @@ Same split and gold. Every model runs through the NERGAL recipe with names off:
|
|
| 138 |
| Fine-tuned GLiNER (previous incumbent) | naked | 263 | 28 | 15 | 99.70% | 88.80% |
|
| 139 |
| Fine-tuned GLiNER (previous incumbent) | ∪ regex | 299 | 12 | 39 | 99.30% | 97.18% |
|
| 140 |
| XLM-R epoch 5 | naked | 275 | 28 | 67 | 98.72% | 90.25% |
|
| 141 |
-
| **NERGAL 2.0.
|
| 142 |
|
| 143 |
-
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.
|
| 144 |
|
| 145 |
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).
|
| 146 |
|
|
|
|
| 16 |
- hybrid
|
| 17 |
---
|
| 18 |
|
| 19 |
+
# NERGAL 2.0.8
|
| 20 |
|
| 21 |
**Named Entity Recognition with Grounded Additive Labels**
|
| 22 |
|
|
|
|
| 47 |
| Personal identifiers | PESEL, passport and identity-document numbers | `[PII]` |
|
| 48 |
| Organization identifiers | NIP/VAT, REGON, KRS, GEMI, LEI and equivalent foreign company registration numbers; labelled DUNS, BDO and RPWDL register-book numbers | `[PII]` |
|
| 49 |
| Financial identifiers | Bank/account numbers, including Polish accounts and foreign IBANs | `[PII]` |
|
| 50 |
+
| Property identifiers | Land-register (księga wieczysta, KW) numbers, also read out in words (`GD jeden G łamane przez …`) | `[PII]` |
|
| 51 |
| Electronic contacts and access | e-Doręczenia and ePUAP addresses, explicitly labelled numeric access PINs (including `pin=` URL values), GG account IDs | `[PII]` |
|
| 52 |
| Person names, only with `names=True` | Names of people, public officials and historical figures included ([Names](#names)) | `[PERSON]` |
|
| 53 |
|
|
|
|
| 66 |
|
| 67 |
These are intended exclusions; false positives can still mask some of this content.
|
| 68 |
|
| 69 |
+
### Known gaps in 2.0.8
|
| 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.8 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. A model span is grown over the whole run of digits it stops in, but not across a space, so a spaced number can keep a group the model did not flag (`28 1020 5558`). A number neither the rules nor the model flag is left as text: on the label-pool benchmark, 46 of 344 gold phones and 27 of 160 gold identifiers holding a digit have no character masked. Some 10–13-digit runs after `nr` in procurement rulings are masked as identifiers with no gold to say whether they are identifiers or notice and case references. 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. An ID card whose series stands one or more words after its label, other than `serii`, `seria`, `numer` or `nr`, is left to the model; a KW number read out in words with no label nearby is masked only with a valid check digit. Names-on gaps are under [Names](#names).
|
| 72 |
|
| 73 |
## Versions
|
| 74 |
|
|
|
|
| 85 |
| 2.0.4, names off | 303 | 9 | 24 | 80 | 98.59% | 97.83% | Names layer leaves procedural role nouns out |
|
| 86 |
| 2.0.5, names off | 303 | 9 | 24 | 80 | 98.59% | 97.83% | Names layer leaves more role nouns, and a number right after one, out |
|
| 87 |
| 2.0.6, names off | 303 | 9 | 24 | 80 | 98.59% | 97.83% | A model span that stops inside a number grows over the whole number |
|
| 88 |
+
| 2.0.7, names off | 303 | 9 | 24 | 80 | 98.59% | 97.83% | Names layer leaves role nouns cut short or glued to a word out |
|
| 89 |
+
| **2.0.8, names off** | **303** | **9** | **24** | **80** | **98.59%** | **97.83%** | ID-card series and number named apart; KW number read out in words |
|
| 90 |
|
| 91 |
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`.
|
| 92 |
|
|
|
|
| 103 |
| Random | 300 | 614 | 586 (95.44%) | 98/116 (76.6–90.5%) | 1,021 | 34 (11.3%) |
|
| 104 |
| Targeted | 150 | 292 | 286 (97.95%) | 94/99 (88.6–98.3%) | 976 | 42 (28.0%) |
|
| 105 |
|
| 106 |
+
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 the release's role-noun rule (2.0.3: 1,051 random false characters); 2.0.5 through 2.0.8 give the same spans as 2.0.4 on both name tests. `hybrid.json` `eval_names` has the full numbers, including float16 and INT8 scored for the record; neither ships.
|
| 107 |
|
| 108 |
**Role-noun bench** (`nergal_role_bench_v1`, 2026-10-19): how often a capitalised procedural role is masked as a person. A role noun is never a name, so every one masked is a false mask and no gold is needed. 4,615 Dynaword legal documents, about 5M characters each from UZP appeal rulings, TED notices, local-government and gov.pl pages, EUR-Lex and Dziennik Ustaw, excluding the documents the 2.0.4 roles were chosen on; the 2.0.5 roles were chosen on the UZP rulings outside the bench. Every column runs the same FastPDN weights, so the change is NERGAL's span filter, not the model.
|
| 109 |
|
|
|
|
| 115 |
| Skarżący | 50 | 3 | 3 | 0 | **0** |
|
| 116 |
| Wykonawca, Prezes, Inwestor | 14,547 | 6 | 6 | 6 | **0** |
|
| 117 |
|
| 118 |
+
The other nine roles tracked (wnioskodawca, uczestnik, powód, pozwany, przewodniczący, protokolant, pełnomocnik, oferent, protestujący) have no mask in any version. The one 2.0.4 `Zamawiający` is a truncated `Zamawiając`; it was known from this table before 2.0.5 added the bare stem, so its 1 → 0 is not a held-out result. Every version masks all 69,834 other words FastPDN tags, so the filter removes only role nouns; name recall is the name test above. The bench holds no tagged number after a role, so the 2.0.5 number rule is measured on UZP rulings (`CHANGELOG.md`). 2.0.6 changes only model `phone`/`pii` spans; its run equals 2.0.5 on every role and the guard. The bench holds no damaged role noun, so 2.0.7 equals 2.0.5 there too; its words were checked by hand on UZP rulings (`CHANGELOG.md`). 2.0.8 changes only rules; its names layer is 2.0.7's. The texts hold real names and are not released.
|
| 119 |
|
| 120 |
**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`.
|
| 121 |
|
|
|
|
| 129 |
|
| 130 |
## Compared with other systems
|
| 131 |
|
| 132 |
+
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.8 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.
|
| 133 |
|
| 134 |
| System | Mode | Whole /315 | Residual | False chars | Char P | Char R |
|
| 135 |
|---|---|---:|---:|---:|---:|---:|
|
|
|
|
| 139 |
| Fine-tuned GLiNER (previous incumbent) | naked | 263 | 28 | 15 | 99.70% | 88.80% |
|
| 140 |
| Fine-tuned GLiNER (previous incumbent) | ∪ regex | 299 | 12 | 39 | 99.30% | 97.18% |
|
| 141 |
| XLM-R epoch 5 | naked | 275 | 28 | 67 | 98.72% | 90.25% |
|
| 142 |
+
| **NERGAL 2.0.8, names off** | **∪ regex** | **303** | **9** | **80** | **98.59%** | **97.83%** |
|
| 143 |
|
| 144 |
+
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.8 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%.
|
| 145 |
|
| 146 |
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).
|
| 147 |
|
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.
|
| 5 |
"mode": "rules_union",
|
| 6 |
"epoch": 5,
|
| 7 |
"seed": 202609160,
|
| 8 |
"threshold": 0.95,
|
| 9 |
-
"rules_sha256": "
|
| 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.8",
|
| 5 |
"mode": "rules_union",
|
| 6 |
"epoch": 5,
|
| 7 |
"seed": 202609160,
|
| 8 |
"threshold": 0.95,
|
| 9 |
+
"rules_sha256": "7db3f7392cc16e22934d9d418be54074bf6c87fb3ef996bfe91ea232fb1d978d",
|
| 10 |
"eval": {
|
| 11 |
"split": "841-dev",
|
| 12 |
"gold_amendments": [
|
nergal.py
CHANGED
|
@@ -17,13 +17,13 @@ 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.
|
| 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 = '
|
| 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 rank; a non-bare rule span outranks every model and person span
|
|
|
|
| 17 |
from scrub_pii import LEGACY_PHONE_TAG, PERSON_TAG, PHONE_TAG, PII_TAG
|
| 18 |
|
| 19 |
HUB_ID = 'SlayerLab/NERGAL'
|
| 20 |
+
VERSION = '2.0.8'
|
| 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 = '7db3f7392cc16e22934d9d418be54074bf6c87fb3ef996bfe91ea232fb1d978d'
|
| 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 rank; a non-bare rule span outranks every model and person span
|
scrub_pii.py
CHANGED
|
@@ -7,7 +7,8 @@ to [PII]. Bare PESEL requires checksum and date validation; explicit identifier
|
|
| 7 |
labels also redact damaged numbers (including common OCR O/I/l substitutions).
|
| 8 |
NIP/REGON/KRS and identity documents require explicit nearby labels. KW numbers
|
| 9 |
need a nearby KW/księga wieczysta label, or the full XX0X/00000000/0 form with a
|
| 10 |
-
valid check digit
|
|
|
|
| 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
|
|
@@ -119,7 +120,9 @@ _DOCUMENT_RE = re.compile(
|
|
| 119 |
r"(?:nr|numer)[ \t]+dowodu|paszport(?:u|em)?(?:[ \t]+dyplomatyczn(?:y|ego|ym))?)"
|
| 120 |
r"[ \t]*(?:(?:seria[ \t]+i[ \t]+numer|serii|seria|numer|nr)\.?[ \t]*)?"
|
| 121 |
r"[:=-]?[ \t]*)"
|
| 122 |
-
|
|
|
|
|
|
|
| 123 |
)
|
| 124 |
_LABELLED_ID_LABEL = (
|
| 125 |
r"\b(?P<kind>PESEL|NIP|REGON)\b"
|
|
@@ -137,6 +140,23 @@ _LAND_REGISTER_LABEL_RE = re.compile(
|
|
| 137 |
r"(?:(?-i:\bKW\b)|\bks(?:\.|i[eęą]\w*)[ \t]+wieczyst\w*)[^\n]{0,30}\Z", re.I)
|
| 138 |
# Check-digit values; court codes skip Q and V.
|
| 139 |
_LAND_REGISTER_VALUES = {c: i for i, c in enumerate("0123456789XABCDEFGHIJKLMNOPRSTUWYZ")}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
_REGON9_RE = re.compile(r"\b\d{9}\b")
|
| 141 |
_ID_LABEL_END = r"[ \t]{0,8}(?:(?:nr\.?|numer)[ \t]{0,8})?[:=.\-]?[ \t]{0,8}(?:\r?\n[ \t]{0,8})?\Z"
|
| 142 |
# Form labels may carry footnote asterisks and one short digit-free gloss: "NIP (Wykonawcy)**:".
|
|
@@ -463,7 +483,8 @@ def _replace_documents(text: str, *, mask=_tag) -> tuple[str, int]:
|
|
| 463 |
|
| 464 |
def replace(m):
|
| 465 |
nonlocal n
|
| 466 |
-
number =
|
|
|
|
| 467 |
passport = m["label"].lower().startswith("paszport")
|
| 468 |
if passport:
|
| 469 |
short_diplomatic = ('dyplomatyczn' in m['label'].lower()
|
|
@@ -474,6 +495,9 @@ def _replace_documents(text: str, *, mask=_tag) -> tuple[str, int]:
|
|
| 474 |
elif not re.fullmatch(r"[A-Z]{3}[0-9OIL]{6}", number):
|
| 475 |
return m[0]
|
| 476 |
n += 1
|
|
|
|
|
|
|
|
|
|
| 477 |
return m["label"] + mask(m.start('number'), m.end('number'), PII_TAG)
|
| 478 |
|
| 479 |
# Passport detection is label/format based; no MRZ check digit is present here.
|
|
@@ -497,6 +521,47 @@ def _replace_land_registers(text: str, *, mask=_tag) -> tuple[str, int]:
|
|
| 497 |
return _LAND_REGISTER_RE.sub(replace, text), n
|
| 498 |
|
| 499 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 500 |
def _head(raw: str) -> str:
|
| 501 |
# The greedy match may run on over a newline or a slash; the break and pair logic in _replace_phones cut it there.
|
| 502 |
return re.split(r"[ \t]*[\n/]", raw, maxsplit=1)[0]
|
|
@@ -967,6 +1032,7 @@ def scrub_pii(text: str, *, spans: list | None = None) -> tuple[str, dict[str, i
|
|
| 967 |
for number, label in _REGISTRY)
|
| 968 |
counts["document"] = apply(_replace_documents)
|
| 969 |
counts["land_register"] = apply(_replace_land_registers)
|
|
|
|
| 970 |
def labelled_ids(value, *, mask):
|
| 971 |
def replace(m):
|
| 972 |
if len(_digits(m['number'])) < 7 or _is_amount(value, m.start('number'), m.end()):
|
|
|
|
| 7 |
labels also redact damaged numbers (including common OCR O/I/l substitutions).
|
| 8 |
NIP/REGON/KRS and identity documents require explicit nearby labels. KW numbers
|
| 9 |
need a nearby KW/księga wieczysta label, or the full XX0X/00000000/0 form with a
|
| 10 |
+
valid check digit; the same holds for a KW number read out in words ("łamane przez").
|
| 11 |
+
An ID-card series and number may be named apart ("serii ABA numer 300000"). Passport
|
| 12 |
variants include one-letter and diplomatic IDs. Foreign IBANs use country
|
| 13 |
lengths and MOD-97. ePUAP paths require a label; e-Doreczenia uses its AE:PL form.
|
| 14 |
Wrapped email domains, local parts hyphenated across one line break and small
|
|
|
|
| 120 |
r"(?:nr|numer)[ \t]+dowodu|paszport(?:u|em)?(?:[ \t]+dyplomatyczn(?:y|ego|ym))?)"
|
| 121 |
r"[ \t]*(?:(?:seria[ \t]+i[ \t]+numer|serii|seria|numer|nr)\.?[ \t]*)?"
|
| 122 |
r"[:=-]?[ \t]*)"
|
| 123 |
+
# An ID-card series and number may be named apart: "serii ABA numer 300000".
|
| 124 |
+
r"(?P<number>[A-Z]{3}(?:(?P<gap>[ \t]*,?[ \t]*(?:numer|nr)\.?[ \t]*)|[ \t-]?)[0-9OIl]{6}|"
|
| 125 |
+
r"[A-Z]{2}[ \t-]?[0-9OIl]{7}|[A-Z][ \t-]?[0-9]{6,9}|[0-9]{4,12})\b", re.I,
|
| 126 |
)
|
| 127 |
_LABELLED_ID_LABEL = (
|
| 128 |
r"\b(?P<kind>PESEL|NIP|REGON)\b"
|
|
|
|
| 140 |
r"(?:(?-i:\bKW\b)|\bks(?:\.|i[eęą]\w*)[ \t]+wieczyst\w*)[^\n]{0,30}\Z", re.I)
|
| 141 |
# Check-digit values; court codes skip Q and V.
|
| 142 |
_LAND_REGISTER_VALUES = {c: i for i, c in enumerate("0123456789XABCDEFGHIJKLMNOPRSTUWYZ")}
|
| 143 |
+
# Deeds repeat a KW number in words: "GD jeden G łamane przez zero zero trzysta ... łamane przez osiem".
|
| 144 |
+
_NUMBER_WORDS = {
|
| 145 |
+
"zero": 0, "jeden": 1, "dwa": 2, "trzy": 3, "cztery": 4, "pięć": 5, "sześć": 6, "siedem": 7, "osiem": 8,
|
| 146 |
+
"dziewięć": 9, "dziesięć": 10, "jedenaście": 11, "dwanaście": 12, "trzynaście": 13, "czternaście": 14,
|
| 147 |
+
"piętnaście": 15, "szesnaście": 16, "siedemnaście": 17, "osiemnaście": 18, "dziewiętnaście": 19,
|
| 148 |
+
"dwadzieścia": 20, "trzydzieści": 30, "czterdzieści": 40, "pięćdziesiąt": 50, "sześćdziesiąt": 60,
|
| 149 |
+
"siedemdziesiąt": 70, "osiemdziesiąt": 80, "dziewięćdziesiąt": 90, "sto": 100, "dwieście": 200,
|
| 150 |
+
"trzysta": 300, "czterysta": 400, "pięćset": 500, "sześćset": 600, "siedemset": 700, "osiemset": 800,
|
| 151 |
+
"dziewięćset": 900, "tysiąc": 1000, "tysiące": 1000, "tysięcy": 1000,
|
| 152 |
+
"milion": 10**6, "miliony": 10**6, "milionów": 10**6,
|
| 153 |
+
}
|
| 154 |
+
_NUMBER_WORD = "|".join(sorted(_NUMBER_WORDS, key=len, reverse=True))
|
| 155 |
+
_DIGIT_WORD = "|".join(w for w, v in _NUMBER_WORDS.items() if v < 10)
|
| 156 |
+
_SLASH_WORDS = r"\s+łamane\s+przez\s+"
|
| 157 |
+
_LAND_REGISTER_WORDS_RE = re.compile(
|
| 158 |
+
rf"\b(?P<court>[A-Z]{{2}}\s*(?:\d|{_DIGIT_WORD})\s*[A-Z]){_SLASH_WORDS}"
|
| 159 |
+
rf"(?P<number>\d{{1,8}}|(?:(?:{_NUMBER_WORD})\s+)*(?:{_NUMBER_WORD})){_SLASH_WORDS}(?P<check>\d|{_DIGIT_WORD})\b")
|
| 160 |
_REGON9_RE = re.compile(r"\b\d{9}\b")
|
| 161 |
_ID_LABEL_END = r"[ \t]{0,8}(?:(?:nr\.?|numer)[ \t]{0,8})?[:=.\-]?[ \t]{0,8}(?:\r?\n[ \t]{0,8})?\Z"
|
| 162 |
# Form labels may carry footnote asterisks and one short digit-free gloss: "NIP (Wykonawcy)**:".
|
|
|
|
| 483 |
|
| 484 |
def replace(m):
|
| 485 |
nonlocal n
|
| 486 |
+
number = m["number"].replace(m["gap"], "") if m["gap"] else m["number"]
|
| 487 |
+
number = re.sub(r"[ \t-]", "", number).upper()
|
| 488 |
passport = m["label"].lower().startswith("paszport")
|
| 489 |
if passport:
|
| 490 |
short_diplomatic = ('dyplomatyczn' in m['label'].lower()
|
|
|
|
| 495 |
elif not re.fullmatch(r"[A-Z]{3}[0-9OIL]{6}", number):
|
| 496 |
return m[0]
|
| 497 |
n += 1
|
| 498 |
+
if m["gap"]: # mask the series and the number, keep the words between
|
| 499 |
+
return (m["label"] + mask(m.start('number'), m.start('gap'), PII_TAG) + m["gap"]
|
| 500 |
+
+ mask(m.end('gap'), m.end('number'), PII_TAG))
|
| 501 |
return m["label"] + mask(m.start('number'), m.end('number'), PII_TAG)
|
| 502 |
|
| 503 |
# Passport detection is label/format based; no MRZ check digit is present here.
|
|
|
|
| 521 |
return _LAND_REGISTER_RE.sub(replace, text), n
|
| 522 |
|
| 523 |
|
| 524 |
+
def _digits_from_words(words: str) -> str:
|
| 525 |
+
"""Digits of a number read out in Polish: digit by digit ("zero jeden dwa"), as a cardinal
|
| 526 |
+
("dwanaście tysięcy trzysta"), or either after spoken leading zeros."""
|
| 527 |
+
tokens = words.split()
|
| 528 |
+
rest = tokens
|
| 529 |
+
while rest and rest[0] == "zero":
|
| 530 |
+
rest = rest[1:]
|
| 531 |
+
lead = "0" * (len(tokens) - len(rest))
|
| 532 |
+
if not rest:
|
| 533 |
+
return lead
|
| 534 |
+
if len(rest) > 1 and all(_NUMBER_WORDS[t] < 10 for t in rest):
|
| 535 |
+
return lead + "".join(str(_NUMBER_WORDS[t]) for t in rest)
|
| 536 |
+
total = group = 0
|
| 537 |
+
for t in rest:
|
| 538 |
+
if _NUMBER_WORDS[t] >= 1000:
|
| 539 |
+
total += (group or 1) * _NUMBER_WORDS[t]
|
| 540 |
+
group = 0
|
| 541 |
+
else:
|
| 542 |
+
group += _NUMBER_WORDS[t]
|
| 543 |
+
return lead + str(total + group)
|
| 544 |
+
|
| 545 |
+
|
| 546 |
+
def _replace_land_register_words(text: str, *, mask=_tag) -> tuple[str, int]:
|
| 547 |
+
n = 0
|
| 548 |
+
|
| 549 |
+
def replace(m):
|
| 550 |
+
nonlocal n
|
| 551 |
+
# Same gate as the digit form; leading zeros are often left unspoken, so the number is zero-filled.
|
| 552 |
+
court = re.sub(r"\s", "", m["court"])
|
| 553 |
+
court = court[:2] + str(_NUMBER_WORDS.get(court[2:-1], court[2:-1])) + court[-1]
|
| 554 |
+
number = m["number"] if m["number"].isdigit() else _digits_from_words(m["number"])
|
| 555 |
+
check = str(_NUMBER_WORDS.get(m["check"], m["check"]))
|
| 556 |
+
labelled = _LAND_REGISTER_LABEL_RE.search(text, max(0, m.start()-64), m.start())
|
| 557 |
+
if not labelled and not (len(number) <= 8 and _land_register_ok(court, number, check)):
|
| 558 |
+
return m[0]
|
| 559 |
+
n += 1
|
| 560 |
+
return mask(m.start(), m.end(), PII_TAG)
|
| 561 |
+
|
| 562 |
+
return _LAND_REGISTER_WORDS_RE.sub(replace, text), n
|
| 563 |
+
|
| 564 |
+
|
| 565 |
def _head(raw: str) -> str:
|
| 566 |
# The greedy match may run on over a newline or a slash; the break and pair logic in _replace_phones cut it there.
|
| 567 |
return re.split(r"[ \t]*[\n/]", raw, maxsplit=1)[0]
|
|
|
|
| 1032 |
for number, label in _REGISTRY)
|
| 1033 |
counts["document"] = apply(_replace_documents)
|
| 1034 |
counts["land_register"] = apply(_replace_land_registers)
|
| 1035 |
+
counts["land_register"] += apply(_replace_land_register_words)
|
| 1036 |
def labelled_ids(value, *, mask):
|
| 1037 |
def replace(m):
|
| 1038 |
if len(_digits(m['number'])) < 7 or _is_amount(value, m.start('number'), m.end()):
|
test_nergal.py
CHANGED
|
@@ -8,7 +8,7 @@ import unittest
|
|
| 8 |
from pathlib import Path
|
| 9 |
|
| 10 |
HERE = Path(__file__).resolve().parent
|
| 11 |
-
RULES_SHA = '
|
| 12 |
# Party and office role nouns in every inflection the names layer leaves out, built here, not from ROLE_NOUNS; the
|
| 13 |
# empty participle ending is the bare stem (2.0.5)
|
| 14 |
PARTICIPLE_ENDINGS = ('', 'y', 'ego', 'emu', 'ym', 'ych', 'ymi', 'a', 'ej', 'ą', 'e')
|
|
@@ -43,7 +43,7 @@ class NergalTests(unittest.TestCase):
|
|
| 43 |
from nergal import GAP_IDS, GAPS, HUB_ID, RULES_SHA as PINNED, THRESHOLD, VERSION
|
| 44 |
card = json.loads((HERE / 'hybrid.json').read_text())
|
| 45 |
self.assertEqual(HUB_ID, 'SlayerLab/NERGAL')
|
| 46 |
-
self.assertEqual(VERSION, '2.0.
|
| 47 |
self.assertEqual(card['version'], VERSION)
|
| 48 |
self.assertEqual(card['eval']['union_fp'], 80)
|
| 49 |
self.assertEqual(card['eval']['rules_fp'], 24)
|
|
@@ -444,6 +444,26 @@ class NergalTests(unittest.TestCase):
|
|
| 444 |
}
|
| 445 |
self.assertEqual({t: m for t, (m, _) in zip(cases, nergal.scrub_many(list(cases)))}, cases)
|
| 446 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 447 |
def _names(self):
|
| 448 |
from nergal import Names
|
| 449 |
return Names(HERE, json.loads((HERE / 'hybrid.json').read_text())['names'])
|
|
|
|
| 8 |
from pathlib import Path
|
| 9 |
|
| 10 |
HERE = Path(__file__).resolve().parent
|
| 11 |
+
RULES_SHA = '7db3f7392cc16e22934d9d418be54074bf6c87fb3ef996bfe91ea232fb1d978d'
|
| 12 |
# Party and office role nouns in every inflection the names layer leaves out, built here, not from ROLE_NOUNS; the
|
| 13 |
# empty participle ending is the bare stem (2.0.5)
|
| 14 |
PARTICIPLE_ENDINGS = ('', 'y', 'ego', 'emu', 'ym', 'ych', 'ymi', 'a', 'ej', 'ą', 'e')
|
|
|
|
| 43 |
from nergal import GAP_IDS, GAPS, HUB_ID, RULES_SHA as PINNED, THRESHOLD, VERSION
|
| 44 |
card = json.loads((HERE / 'hybrid.json').read_text())
|
| 45 |
self.assertEqual(HUB_ID, 'SlayerLab/NERGAL')
|
| 46 |
+
self.assertEqual(VERSION, '2.0.8')
|
| 47 |
self.assertEqual(card['version'], VERSION)
|
| 48 |
self.assertEqual(card['eval']['union_fp'], 80)
|
| 49 |
self.assertEqual(card['eval']['rules_fp'], 24)
|
|
|
|
| 444 |
}
|
| 445 |
self.assertEqual({t: m for t, (m, _) in zip(cases, nergal.scrub_many(list(cases)))}, cases)
|
| 446 |
|
| 447 |
+
def test_published_weights_mask_deed_ids_named_apart_or_in_words(self):
|
| 448 |
+
from nergal import Nergal
|
| 449 |
+
nergal = Nergal(HERE, device='cpu')
|
| 450 |
+
kw = 'GD jeden G łamane przez zero zero dwanaście tysięcy trzysta czterdzieści pięć łamane przez ' # GD1G/00012345/0
|
| 451 |
+
cases = { # invented; 2.0.8: an ID-card series and number split by "numer"/"nr" mask apart, the words between kept
|
| 452 |
+
'legitymujący się dowodem osobistym serii ABA numer 300000 ważnym do':
|
| 453 |
+
'legitymujący się dowodem osobistym serii [PII] numer [PII] ważnym do',
|
| 454 |
+
'dowód osobisty seria ABA numer 300000,': 'dowód osobisty seria [PII] numer [PII],',
|
| 455 |
+
'dowód osobisty ABA, nr. 300000.': 'dowód osobisty [PII], nr. [PII].',
|
| 456 |
+
# a KW number read out in words: after a KW label, or alone with a valid check digit
|
| 457 |
+
f'KW nr GD1G/00012345/0 ({kw}zero).': 'KW nr [PII] ([PII]).',
|
| 458 |
+
f'{kw}zero.': '[PII].',
|
| 459 |
+
f'KW {kw}jeden.': 'KW [PII].',
|
| 460 |
+
'GD jeden G łamane przez zero zero jeden dwa trzy cztery pięć łamane przez zero.': '[PII].',
|
| 461 |
+
# unchanged: no ID-card label, an unlabelled KW with a wrong check digit, other numbers read with slashes
|
| 462 |
+
**{t: t for t in ('serii ABA numer 300000 bez etykiety.', f'{kw}jeden.',
|
| 463 |
+
'druk nr 12 łamane przez 3 oraz 5 łamane przez 6')},
|
| 464 |
+
}
|
| 465 |
+
self.assertEqual({t: m for t, (m, _) in zip(cases, nergal.scrub_many(list(cases)))}, cases)
|
| 466 |
+
|
| 467 |
def _names(self):
|
| 468 |
from nergal import Names
|
| 469 |
return Names(HERE, json.loads((HERE / 'hybrid.json').read_text())['names'])
|