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.6: a model span that stops inside a number grows over the whole number
Browse files- CHANGELOG.md +18 -0
- README.md +10 -9
- hybrid.json +1 -1
- nergal.py +21 -6
- test_nergal.py +26 -1
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.5
|
| 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 more role nouns, and a number right after one, 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.6
|
| 12 |
+
|
| 13 |
+
Same NERGAL weights, threshold, union, rules (SHA `4d054723…`), names layer and API. One change in `nergal.py` `model_keep`: a kept model span that starts or ends inside a run of digits grows over the whole run, before the phone cut. Rule spans and person spans are unchanged.
|
| 14 |
+
|
| 15 |
+
- **A model span no longer stops mid-number.** XLM-R scores every digit as its own unit, so a kept span could start or end inside a number and leave the rest visible: `NIP [PII]7890`. 2.0.6 grows it to both ends of the run (`NIP [PII]`). No gold span cuts a digit run (0 of the bench's 790 `pii`/`phone` spans, 0 of name tests v1/v2's 73), so the policy is the whole run at every length and for both labels.
|
| 16 |
+
- **It never creates a span.** It only lengthens a span the model kept at ≥ 0.95, and only over digits. Groups split by a space (`28 1020 5558`) are separate runs and are not joined. A number the model wrongly flagged is now masked whole instead of in part.
|
| 17 |
+
- **Phone policy v3 applies to the grown span.** A phone span holding one full number and the head of a second (`601 234 567, 6012…`) stayed one span; grown, both numbers are full and it becomes one span per number, so the separator between them is unmasked. A cut phone under 7 digits was dropped; grown to 7+ digits it is kept.
|
| 18 |
+
- **Measured on stored spans.** 2.0.5 left a cut edge in 131 of 34,715 UZP rulings, 4 of 2,237 bench rows, 2 of 1,000 Dynaword documents and no name-test passage. 2.0.5 → 2.0.6: UZP 92 rulings change output and 408 characters gain a mask (10–13-digit `pii` runs completed: 51, 37 of them after `nr`); FineWeb miss hunt 47 of 41,204 passages, 166 characters; Dynaword 1 document, 1 phone digit.
|
| 19 |
+
|
| 20 |
+
Still missed: the space-separated groups of a number the model did not flag; a number the model never flagged. The UZP runs completed after `nr` have no gold; most are 12 digits, and whether they are identifiers or notice and case references is unmeasured. If they are references, 2.0.5 already masked them in part, so 2.0.6 adds false characters there, not false passages.
|
| 21 |
+
|
| 22 |
+
841-dev, names off: unchanged from 2.0.5. Release gates (`artifacts/nergal_2_0_6_digit_tail/`, frozen before scoring, stored spans, no new inference): rule spans identical, no two 2.0.6 spans overlap, and the 2.0.5 side rebuilds the stored Dynaword spans. Two gates failed as frozen: 7 characters lose a mask and 5 non-digit characters gain one, on four documents (UZP 1, miss hunt 3). All are the phone policy above (the separator between two numbers; a short phone's `+`, `-` and spaces), and no digit loses its mask; the gates assumed growth moves digits only. `test_nergal.py` gained the two-number case after the score. Label pool v3 `bench`, names off: 4 of 22 cohorts move, all up, with fp and false-redaction passages unchanged everywhere. `human_test_v2/bare_id` character recall 0.528 → 0.717 (fn 25 → 15); `human_test_v2/nip_regon` union exact precision 0.911 → 0.932; `human_test_v2/email` union exact precision 0.851 → 0.859; `mc4_negatives_v1/email` whole 99 → 100. `--names` and name tests v1/v2: no change. Role bench: as 2.0.5. `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.5, names off | 303 | 9 | 24 | 80 | 98.59% | 97.83% | Names layer leaves more role nouns, and a number right after one, out |
|
| 27 |
+
| 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 |
|
| 28 |
+
|
| 29 |
## 2.0.5
|
| 30 |
|
| 31 |
Same NERGAL weights, threshold, union, rules (SHA `4d054723…`) and API. One change in `nergal.py` `person_spans`: the names layer (`names=True`) leaves more role nouns, and a number right after one, 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 |
|
|
@@ -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 |
|
|
@@ -83,7 +83,8 @@ Unlabelled phones and identifiers (the rules take a bare phone only in the group
|
|
| 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 |
|
| 88 |
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`.
|
| 89 |
|
|
@@ -100,7 +101,7 @@ Unlabelled phones and identifiers (the rules take a bare phone only in the group
|
|
| 100 |
| Random | 300 | 614 | 586 (95.44%) | 98/116 (76.6–90.5%) | 1,021 | 34 (11.3%) |
|
| 101 |
| Targeted | 150 | 292 | 286 (97.95%) | 94/99 (88.6–98.3%) | 976 | 42 (28.0%) |
|
| 102 |
|
| 103 |
-
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
|
| 104 |
|
| 105 |
**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.
|
| 106 |
|
|
@@ -112,7 +113,7 @@ Names off hides no name. A component is the independent unit, covered when every
|
|
| 112 |
| Skarżący | 50 | 3 | 3 | 0 | **0** |
|
| 113 |
| Wykonawca, Prezes, Inwestor | 14,547 | 6 | 6 | 6 | **0** |
|
| 114 |
|
| 115 |
-
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`). The texts hold real names and are not released.
|
| 116 |
|
| 117 |
**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`.
|
| 118 |
|
|
@@ -126,7 +127,7 @@ The other nine roles tracked (wnioskodawca, uczestnik, powód, pozwany, przewodn
|
|
| 126 |
|
| 127 |
## Compared with other systems
|
| 128 |
|
| 129 |
-
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.
|
| 130 |
|
| 131 |
| System | Mode | Whole /315 | Residual | False chars | Char P | Char R |
|
| 132 |
|---|---|---:|---:|---:|---:|---:|
|
|
@@ -136,9 +137,9 @@ Same split and gold. Every model runs through the NERGAL recipe with names off:
|
|
| 136 |
| Fine-tuned GLiNER (previous incumbent) | naked | 263 | 28 | 15 | 99.70% | 88.80% |
|
| 137 |
| Fine-tuned GLiNER (previous incumbent) | ∪ regex | 299 | 12 | 39 | 99.30% | 97.18% |
|
| 138 |
| XLM-R epoch 5 | naked | 275 | 28 | 67 | 98.72% | 90.25% |
|
| 139 |
-
| **NERGAL 2.0.
|
| 140 |
|
| 141 |
-
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.
|
| 142 |
|
| 143 |
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).
|
| 144 |
|
|
|
|
| 16 |
- hybrid
|
| 17 |
---
|
| 18 |
|
| 19 |
+
# NERGAL 2.0.6
|
| 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.6
|
| 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.6 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`). 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 |
|
|
|
|
| 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 |
+
| 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 |
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`.
|
| 90 |
|
|
|
|
| 101 |
| Random | 300 | 614 | 586 (95.44%) | 98/116 (76.6–90.5%) | 1,021 | 34 (11.3%) |
|
| 102 |
| Targeted | 150 | 292 | 286 (97.95%) | 94/99 (88.6–98.3%) | 976 | 42 (28.0%) |
|
| 103 |
|
| 104 |
+
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 and 2.0.6 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.
|
| 105 |
|
| 106 |
**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.
|
| 107 |
|
|
|
|
| 113 |
| Skarżący | 50 | 3 | 3 | 0 | **0** |
|
| 114 |
| Wykonawca, Prezes, Inwestor | 14,547 | 6 | 6 | 6 | **0** |
|
| 115 |
|
| 116 |
+
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 texts hold real names and are not released.
|
| 117 |
|
| 118 |
**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`.
|
| 119 |
|
|
|
|
| 127 |
|
| 128 |
## Compared with other systems
|
| 129 |
|
| 130 |
+
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.6 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.
|
| 131 |
|
| 132 |
| System | Mode | Whole /315 | Residual | False chars | Char P | Char R |
|
| 133 |
|---|---|---:|---:|---:|---:|---:|
|
|
|
|
| 137 |
| Fine-tuned GLiNER (previous incumbent) | naked | 263 | 28 | 15 | 99.70% | 88.80% |
|
| 138 |
| Fine-tuned GLiNER (previous incumbent) | ∪ regex | 299 | 12 | 39 | 99.30% | 97.18% |
|
| 139 |
| XLM-R epoch 5 | naked | 275 | 28 | 67 | 98.72% | 90.25% |
|
| 140 |
+
| **NERGAL 2.0.6, names off** | **∪ regex** | **303** | **9** | **80** | **98.59%** | **97.83%** |
|
| 141 |
|
| 142 |
+
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.6 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%.
|
| 143 |
|
| 144 |
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).
|
| 145 |
|
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.
|
| 5 |
"mode": "rules_union",
|
| 6 |
"epoch": 5,
|
| 7 |
"seed": 202609160,
|
|
|
|
| 1 |
{
|
| 2 |
"full_name": "Named Entity Recognition with Grounded Additive Labels",
|
| 3 |
"hub_id": "SlayerLab/NERGAL",
|
| 4 |
+
"version": "2.0.6",
|
| 5 |
"mode": "rules_union",
|
| 6 |
"epoch": 5,
|
| 7 |
"seed": 202609160,
|
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.
|
| 21 |
GAPS = ['[PII_SPACE]', '[PII_BREAK]']
|
| 22 |
GAP_IDS = [250002, 250003]
|
| 23 |
BIO_LABELS = ['O', 'B-phone', 'I-phone', 'B-pii', 'I-pii']
|
|
@@ -212,6 +212,18 @@ def expand(text, start, end):
|
|
| 212 |
return start, end
|
| 213 |
|
| 214 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
def person_spans(text, ranges):
|
| 216 |
"""Person spans from FastPDN person-token ranges: whole words, joined across spaces, tabs and no-break spaces
|
| 217 |
(not punctuation or line breaks); words touching a placeholder, role nouns (ROLE_NOUNS) and a number
|
|
@@ -272,11 +284,14 @@ def apply_union(text, spans, firm=()):
|
|
| 272 |
|
| 273 |
|
| 274 |
def model_keep(text, model_spans, *, threshold=THRESHOLD, module=scrub_pii):
|
| 275 |
-
"""Decoded model spans
|
| 276 |
-
and emergency numbers alone are dropped.
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
|
|
|
|
|
|
|
|
|
| 280 |
|
| 281 |
|
| 282 |
def scrub_spans(text, rule_spans, model_spans, *, threshold=THRESHOLD, module=scrub_pii, name_spans=()):
|
|
|
|
| 17 |
from scrub_pii import LEGACY_PHONE_TAG, PERSON_TAG, PHONE_TAG, PII_TAG
|
| 18 |
|
| 19 |
HUB_ID = 'SlayerLab/NERGAL'
|
| 20 |
+
VERSION = '2.0.6'
|
| 21 |
GAPS = ['[PII_SPACE]', '[PII_BREAK]']
|
| 22 |
GAP_IDS = [250002, 250003]
|
| 23 |
BIO_LABELS = ['O', 'B-phone', 'I-phone', 'B-pii', 'I-pii']
|
|
|
|
| 212 |
return start, end
|
| 213 |
|
| 214 |
|
| 215 |
+
def whole_digits(text, start, end):
|
| 216 |
+
"""Grow a span that starts or ends inside a run of digits over the whole run (2.0.6). No gold span cuts a digit
|
| 217 |
+
run, so a model span stopping mid-number leaves the rest of an identifier open."""
|
| 218 |
+
if text[start].isdecimal():
|
| 219 |
+
while start > 0 and text[start - 1].isdecimal():
|
| 220 |
+
start -= 1
|
| 221 |
+
if text[end - 1].isdecimal():
|
| 222 |
+
while end < len(text) and text[end].isdecimal():
|
| 223 |
+
end += 1
|
| 224 |
+
return start, end
|
| 225 |
+
|
| 226 |
+
|
| 227 |
def person_spans(text, ranges):
|
| 228 |
"""Person spans from FastPDN person-token ranges: whole words, joined across spaces, tabs and no-break spaces
|
| 229 |
(not punctuation or line breaks); words touching a placeholder, role nouns (ROLE_NOUNS) and a number
|
|
|
|
| 284 |
|
| 285 |
|
| 286 |
def model_keep(text, model_spans, *, threshold=THRESHOLD, module=scrub_pii):
|
| 287 |
+
"""Decoded model spans grown over whole runs of digits (whole_digits), then each phone span cut by the rules'
|
| 288 |
+
phone policy: one span per number of 7+ digits; short and emergency numbers alone are dropped."""
|
| 289 |
+
keep = []
|
| 290 |
+
for s in decode(model_spans, threshold):
|
| 291 |
+
start, end = whole_digits(text, s['start'], s['end'])
|
| 292 |
+
parts = module._phone_spans(text, start, end) if s['label'] == 'phone' else [(start, end)]
|
| 293 |
+
keep += [dict(s, start=a, end=b) for a, b in parts]
|
| 294 |
+
return keep
|
| 295 |
|
| 296 |
|
| 297 |
def scrub_spans(text, rule_spans, model_spans, *, threshold=THRESHOLD, module=scrub_pii, name_spans=()):
|
test_nergal.py
CHANGED
|
@@ -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)
|
|
@@ -349,6 +349,31 @@ class NergalTests(unittest.TestCase):
|
|
| 349 |
self.assertEqual(scrub_spans(text, [], [span(text, '112')])[0], text)
|
| 350 |
self.assertEqual(model_keep(text, [span(text, '112', score=0.9)], threshold=0.95), [])
|
| 351 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 352 |
def test_published_weights_label_contact_lines(self):
|
| 353 |
from nergal import Nergal
|
| 354 |
nergal = Nergal(HERE, device='cpu')
|
|
|
|
| 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.6')
|
| 47 |
self.assertEqual(card['version'], VERSION)
|
| 48 |
self.assertEqual(card['eval']['union_fp'], 80)
|
| 49 |
self.assertEqual(card['eval']['rules_fp'], 24)
|
|
|
|
| 349 |
self.assertEqual(scrub_spans(text, [], [span(text, '112')])[0], text)
|
| 350 |
self.assertEqual(model_keep(text, [span(text, '112', score=0.9)], threshold=0.95), [])
|
| 351 |
|
| 352 |
+
def test_model_spans_cover_whole_digit_runs(self):
|
| 353 |
+
from nergal import model_keep, scrub_spans, whole_digits
|
| 354 |
+
span = lambda text, start, end, label='pii', score=0.99: dict(start=start, end=end, label=label, score=score)
|
| 355 |
+
kept = lambda text, *spans: [text[s['start']:s['end']] for s in model_keep(text, list(spans))]
|
| 356 |
+
text = 'NIP 1234567890, tel. 601234567'
|
| 357 |
+
self.assertEqual(kept(text, span(text, 4, 10)), ['1234567890']) # the tail was left
|
| 358 |
+
self.assertEqual(kept(text, span(text, 8, 14)), ['1234567890']) # the head was left
|
| 359 |
+
self.assertEqual(kept(text, span(text, 0, 7)), ['NIP 1234567890']) # a word before stays in
|
| 360 |
+
self.assertEqual(kept(text, span(text, 4, 14)), ['1234567890']) # whole already
|
| 361 |
+
self.assertEqual(kept(text, span(text, 21, 28, 'phone')), ['601234567']) # phone, then the phone policy
|
| 362 |
+
self.assertEqual(kept(text, span(text, 21, 25, 'phone')), ['601234567']) # 4 digits alone would drop
|
| 363 |
+
two = 'tel. 601 234 567, 601234567.'
|
| 364 |
+
# 2.0.5 kept '601 234 567, 6012' whole (one full number); grown, the second is full and the policy splits them
|
| 365 |
+
self.assertEqual(kept(two, span(two, 5, 22, 'phone')), ['601 234 567', '601234567'])
|
| 366 |
+
self.assertEqual(kept(text, span(text, 0, 3)), ['NIP']) # no digit at an edge
|
| 367 |
+
text = 'nip:1234567890abc 28 1020 5558'
|
| 368 |
+
self.assertEqual(kept(text, span(text, 4, 9)), ['1234567890']) # stops at the glued letters
|
| 369 |
+
self.assertEqual(kept(text, span(text, 18, 23)), ['28 1020']) # one run, not the next group
|
| 370 |
+
self.assertEqual(whole_digits('12 34', 0, 5), (0, 5))
|
| 371 |
+
self.assertEqual(whole_digits('a1b', 1, 2), (1, 2))
|
| 372 |
+
text = 'NIP 1234567890.'
|
| 373 |
+
self.assertEqual(scrub_spans(text, [], [span(text, 4, 10)])[0], 'NIP [PII].')
|
| 374 |
+
self.assertEqual(scrub_spans(text, [], [span(text, 4, 9), span(text, 9, 14, score=0.98)])[0], 'NIP [PII].')
|
| 375 |
+
self.assertEqual(scrub_spans(text, [], [span(text, 4, 10, score=0.9)])[0], text) # under threshold: nothing
|
| 376 |
+
|
| 377 |
def test_published_weights_label_contact_lines(self):
|
| 378 |
from nergal import Nergal
|
| 379 |
nergal = Nergal(HERE, device='cpu')
|