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.2: a rule's label wins on its characters
Browse files- CHANGELOG.md +15 -0
- README.md +9 -8
- hybrid.json +2 -2
- nergal.py +31 -18
- scrub_pii.py +3 -0
- test_nergal.py +73 -2
CHANGELOG.md
CHANGED
|
@@ -8,6 +8,21 @@ Semver for this island:
|
|
| 8 |
|
| 9 |
Accuracy is 841-dev, union at 0.95. Through 1.1.2 the gold has 354 spans; from 1.2.0 it is restated to phone policy v3 (315 spans), and the two are not comparable. A version that changes those numbers must update `hybrid.json` `eval` and the tables below.
|
| 10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
## 2.0.1
|
| 12 |
|
| 13 |
Same NERGAL weights, threshold and API. Rules SHA `c1b924a8…`: one fix for input that already holds `[PERSON]`.
|
|
|
|
| 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.2
|
| 12 |
+
|
| 13 |
+
Same NERGAL weights, threshold and API. Rules SHA `205a253b…`: an unlabelled PESEL span is marked `bare`; every rule span keeps its start, end and label. With one union change in `nergal.py`, a labelled identifier keeps `[PII]`.
|
| 14 |
+
|
| 15 |
+
- **A rule's label wins on its characters.** In 2.0.1 each character took the highest-ranked label of every span over it (phone > pii > person), so a model `phone` span over a rule `pii` span turned a labelled PESEL into `[PHONE]` (`Kontakt: tel. 601 222 333, PESEL 44051401359.` → `PESEL [PHONE]`). In 2.0.2, where a rule span covers a character, only rule labels compete for it; model and person spans fill the characters no rule covers. Which characters are masked does not change, only the placeholder.
|
| 16 |
+
- **An unlabelled PESEL still yields to a model phone.** The rules mask an 11-digit number with a valid PESEL checksum even without a label, and many phone numbers pass that checksum. Such a span is marked `bare` in `scrub_pii`'s spans and keeps 2.0.1's ranking, so `Kontakt: 44051401359` with a model phone stays `[PHONE]`. Every other rule span (labelled PESEL, NIP, REGON, ID card, passport, account, email, phone) is firm.
|
| 17 |
+
- **Rule phones are unaffected.** A rule `phone` span already outranked a model `pii` span; that is unchanged.
|
| 18 |
+
|
| 19 |
+
841-dev, names off: unchanged from 2.0.1, and 124/130 phones and 179/185 other PII are wholly masked with their own placeholder in both versions. Release gates (`artifacts/nergal_2_0_2_rule_label/`, frozen before scoring, stored spans): rule spans and masked characters identical on every set; the 1,837 bench rows (841-dev and the 16 pool cohorts) 0 relabelled; the 1,000-document Dynaword runtime sample, names off and on, 0 relabelled, each 2.0.1 rebuild matching the GPU gate's stored output; 41,204 FineWeb miss-hunt passages, 2 relabelled, 51 characters `[PHONE]` → `[PII]`, all `pii` per the teacher; every on-disk Dynaword source, full text, placeholder documents included, rule spans identical (32 sources, 3,378,438 documents, 997 with a placeholder; the on-disk corpus is smaller than at 2.0.1, which had 37 sources and 4,144,908 documents). Before the fix, the same miss-hunt overlap also held 22 characters the teacher calls phone, all under an unlabelled PESEL span; they keep `[PHONE]`. The benchmark replay (`bench`, and v3 `bench` with names off and on) gives 2.0.1's numbers on every cohort.
|
| 20 |
+
|
| 21 |
+
| Version | Whole /315 | Residual | Rules FP | Union FP | Char P | Char R | What changed |
|
| 22 |
+
|---|---:|---:|---:|---:|---:|---:|---|
|
| 23 |
+
| 2.0.1, names off | 303 | 9 | 24 | 80 | 98.59% | 97.83% | A `[PERSON]` placeholder ends an other-number label |
|
| 24 |
+
| 2.0.2, names off | 303 | 9 | 24 | 80 | 98.59% | 97.83% | A rule's label wins on its characters |
|
| 25 |
+
|
| 26 |
## 2.0.1
|
| 27 |
|
| 28 |
Same NERGAL weights, threshold and API. Rules SHA `c1b924a8…`: one fix for input that already holds `[PERSON]`.
|
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 |
|
|
@@ -79,7 +79,8 @@ Unlabelled phones and identifiers (the rules take a bare phone only in the group
|
|
| 79 |
| 1.1.2 | 303 | 9 | 83 | 333 | 94.37% | 97.83% | Email boundary rules |
|
| 80 |
| 1.2.0 | 303 | 9 | 24 | 80 | 98.59% | 97.83% | Phone policy v3 at mask time, rules and model spans |
|
| 81 |
| 2.0.0, names off | 303 | 9 | 24 | 80 | 98.59% | 97.83% | `[PHONE]` replaces `[Telefon]`; opt-in `[PERSON]` names |
|
| 82 |
-
|
|
|
|
|
| 83 |
|
| 84 |
1.0.0–1.1.2 were scored on the earlier 354-span gold (whole 323 → 326, union FP 133 → 80); per-version numbers are in `CHANGELOG.md`.
|
| 85 |
|
|
@@ -110,7 +111,7 @@ Names off hides no name. A component is the independent unit, covered when every
|
|
| 110 |
|
| 111 |
## Compared with other systems
|
| 112 |
|
| 113 |
-
Same split and gold. Every model runs through the
|
| 114 |
|
| 115 |
| System | Mode | Whole /315 | Residual | False chars | Char P | Char R |
|
| 116 |
|---|---|---:|---:|---:|---:|---:|
|
|
@@ -120,15 +121,15 @@ Same split and gold. Every model runs through the 1.2.0 recipe: its spans at 0.9
|
|
| 120 |
| Fine-tuned GLiNER (previous incumbent) | naked | 263 | 28 | 15 | 99.70% | 88.80% |
|
| 121 |
| Fine-tuned GLiNER (previous incumbent) | ∪ regex | 299 | 12 | 39 | 99.30% | 97.18% |
|
| 122 |
| XLM-R epoch 5 | naked | 275 | 28 | 67 | 98.72% | 90.25% |
|
| 123 |
-
| **NERGAL
|
| 124 |
|
| 125 |
-
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
|
| 126 |
|
| 127 |
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).
|
| 128 |
|
| 129 |
## Load
|
| 130 |
|
| 131 |
-
Regex runs on the original text, the model adds spans at 0.95, then the two are unioned and replaced with `[PHONE]` / `[PII]` (and `[PERSON]` with `names=True`). Each character takes its highest-ranked label, phone > pii > person.
|
| 132 |
|
| 133 |
```python
|
| 134 |
from pathlib import Path
|
|
|
|
| 16 |
- hybrid
|
| 17 |
---
|
| 18 |
|
| 19 |
+
# NERGAL 2.0.2
|
| 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.2
|
| 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.2 model. Do not rely on it to remove them consistently. An email with a lowercase word glued onto its domain (`jan@firma.plkontakt`) is masked together with that word, and text glued before an address can be masked with it. A phone written as short parts joined by two slashes (`12 / 345 / 678`) is left as text. An identifier with a `[PII]` / `[PHONE]` / `[Telefon]` placeholder inside it or right before it can be missed. Names-on gaps are under [Names](#names).
|
| 72 |
|
| 73 |
## Versions
|
| 74 |
|
|
|
|
| 79 |
| 1.1.2 | 303 | 9 | 83 | 333 | 94.37% | 97.83% | Email boundary rules |
|
| 80 |
| 1.2.0 | 303 | 9 | 24 | 80 | 98.59% | 97.83% | Phone policy v3 at mask time, rules and model spans |
|
| 81 |
| 2.0.0, names off | 303 | 9 | 24 | 80 | 98.59% | 97.83% | `[PHONE]` replaces `[Telefon]`; opt-in `[PERSON]` names |
|
| 82 |
+
| 2.0.1, names off | 303 | 9 | 24 | 80 | 98.59% | 97.83% | A `[PERSON]` placeholder ends an other-number label |
|
| 83 |
+
| **2.0.2, names off** | **303** | **9** | **24** | **80** | **98.59%** | **97.83%** | A rule's label wins on its characters |
|
| 84 |
|
| 85 |
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`.
|
| 86 |
|
|
|
|
| 111 |
|
| 112 |
## Compared with other systems
|
| 113 |
|
| 114 |
+
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, 2.0.1 and 2.0.2 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.
|
| 115 |
|
| 116 |
| System | Mode | Whole /315 | Residual | False chars | Char P | Char R |
|
| 117 |
|---|---|---:|---:|---:|---:|---:|
|
|
|
|
| 121 |
| Fine-tuned GLiNER (previous incumbent) | naked | 263 | 28 | 15 | 99.70% | 88.80% |
|
| 122 |
| Fine-tuned GLiNER (previous incumbent) | ∪ regex | 299 | 12 | 39 | 99.30% | 97.18% |
|
| 123 |
| XLM-R epoch 5 | naked | 275 | 28 | 67 | 98.72% | 90.25% |
|
| 124 |
+
| **NERGAL 2.0.2, names off** | **∪ regex** | **303** | **9** | **80** | **98.59%** | **97.83%** |
|
| 125 |
|
| 126 |
+
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.2 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%.
|
| 127 |
|
| 128 |
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).
|
| 129 |
|
| 130 |
## Load
|
| 131 |
|
| 132 |
+
Regex runs on the original text, the model adds spans at 0.95, then the two are unioned and replaced with `[PHONE]` / `[PII]` (and `[PERSON]` with `names=True`). Each character takes its highest-ranked label, phone > pii > person, except that a rule's label wins on the characters its span covers: a labelled PESEL, NIP, REGON or ID number stays `[PII]` when the model calls it a phone. An unlabelled 11-digit number the rules take only for its PESEL checksum still yields to a model phone.
|
| 133 |
|
| 134 |
```python
|
| 135 |
from pathlib import Path
|
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.2",
|
| 5 |
"mode": "rules_union",
|
| 6 |
"epoch": 5,
|
| 7 |
"seed": 202609160,
|
| 8 |
"threshold": 0.95,
|
| 9 |
+
"rules_sha256": "205a253b7f496b00a3a7644b9588b6c22d94d8ebb223d2d9d8a4d6326bdcccdb",
|
| 10 |
"eval": {
|
| 11 |
"split": "841-dev",
|
| 12 |
"gold_amendments": [
|
nergal.py
CHANGED
|
@@ -17,16 +17,16 @@ import scrub_pii
|
|
| 17 |
from scrub_pii import LEGACY_PHONE_TAG, PERSON_TAG, PHONE_TAG, PII_TAG
|
| 18 |
|
| 19 |
HUB_ID = 'SlayerLab/NERGAL'
|
| 20 |
-
VERSION = '2.0.
|
| 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
|
| 30 |
RANK = {label: i for i, label in enumerate(TAGS)}
|
| 31 |
PERSON_LABELS = ('PERSON', 'PERSON_F', 'PERSON_L')
|
| 32 |
_WORD = re.compile(r"[\w'’-]")
|
|
@@ -174,10 +174,11 @@ class Encoding:
|
|
| 174 |
|
| 175 |
|
| 176 |
def spans_from(module, text):
|
| 177 |
-
"""Rule spans on the original text. Existing [PHONE]/[PII]/[Telefon]
|
|
|
|
| 178 |
result = []
|
| 179 |
module.scrub_pii(text, spans=result)
|
| 180 |
-
return sorted(({k: s[k] for k in ('start', 'end', 'label')} | {'score': 1.0} for s in result),
|
| 181 |
key=lambda s: s['start'])
|
| 182 |
|
| 183 |
|
|
@@ -210,18 +211,27 @@ def person_spans(text, ranges):
|
|
| 210 |
return [{'start': a, 'end': b, 'label': 'person', 'score': 1.0} for a, b in merged]
|
| 211 |
|
| 212 |
|
| 213 |
-
def
|
| 214 |
-
"""Each character
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
out, chars, counts, i = [], 0, dict.fromkeys(TAGS, 0), 0
|
| 226 |
while i < len(text):
|
| 227 |
lab = labels[i]
|
|
@@ -252,7 +262,10 @@ def scrub_spans(text, rule_spans, model_spans, *, threshold=THRESHOLD, module=sc
|
|
| 252 |
keep = model_keep(text, model_spans, threshold=threshold, module=module)
|
| 253 |
extra = sum(1 for s in keep if (s['start'], s['end'], s['label']) not in rule_keys)
|
| 254 |
_, rules_chars, _ = apply_union(text, rule_spans)
|
| 255 |
-
|
|
|
|
|
|
|
|
|
|
| 256 |
return masked, {**counts, 'rules_placeholder_chars': rules_chars, 'union_placeholder_chars': union_chars,
|
| 257 |
'model_extra_spans': extra}
|
| 258 |
|
|
|
|
| 17 |
from scrub_pii import LEGACY_PHONE_TAG, PERSON_TAG, PHONE_TAG, PII_TAG
|
| 18 |
|
| 19 |
HUB_ID = 'SlayerLab/NERGAL'
|
| 20 |
+
VERSION = '2.0.2'
|
| 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 = '205a253b7f496b00a3a7644b9588b6c22d94d8ebb223d2d9d8a4d6326bdcccdb'
|
| 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
|
| 30 |
RANK = {label: i for i, label in enumerate(TAGS)}
|
| 31 |
PERSON_LABELS = ('PERSON', 'PERSON_F', 'PERSON_L')
|
| 32 |
_WORD = re.compile(r"[\w'’-]")
|
|
|
|
| 174 |
|
| 175 |
|
| 176 |
def spans_from(module, text):
|
| 177 |
+
"""Rule spans on the original text; an unlabelled PESEL carries `bare`. Existing [PHONE]/[PII]/[Telefon]
|
| 178 |
+
placeholders do not switch the rules off."""
|
| 179 |
result = []
|
| 180 |
module.scrub_pii(text, spans=result)
|
| 181 |
+
return sorted(({k: s[k] for k in ('start', 'end', 'label', 'bare') if k in s} | {'score': 1.0} for s in result),
|
| 182 |
key=lambda s: s['start'])
|
| 183 |
|
| 184 |
|
|
|
|
| 211 |
return [{'start': a, 'end': b, 'label': 'person', 'score': 1.0} for a, b in merged]
|
| 212 |
|
| 213 |
|
| 214 |
+
def char_labels(text, spans, firm=()):
|
| 215 |
+
"""Each character's highest-ranked label (phone > pii > person), or None; under a `firm` span only firm labels
|
| 216 |
+
compete."""
|
| 217 |
+
labels, best = [None] * len(text), [None] * len(text)
|
| 218 |
+
for tier, group in ((0, spans), (1, firm)):
|
| 219 |
+
for span in group:
|
| 220 |
+
start, end, label = span['start'], span['end'], span['label']
|
| 221 |
+
if label not in RANK:
|
| 222 |
+
raise ValueError(f'Unknown label {label!r}')
|
| 223 |
+
if not 0 <= start < end <= len(text):
|
| 224 |
+
raise ValueError('Span outside text')
|
| 225 |
+
key = (tier, RANK[label])
|
| 226 |
+
for i in range(start, end):
|
| 227 |
+
if best[i] is None or key > best[i]:
|
| 228 |
+
labels[i], best[i] = label, key
|
| 229 |
+
return labels
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
def apply_union(text, spans, firm=()):
|
| 233 |
+
"""Each run of one label (char_labels) becomes one placeholder."""
|
| 234 |
+
labels = char_labels(text, spans, firm)
|
| 235 |
out, chars, counts, i = [], 0, dict.fromkeys(TAGS, 0), 0
|
| 236 |
while i < len(text):
|
| 237 |
lab = labels[i]
|
|
|
|
| 262 |
keep = model_keep(text, model_spans, threshold=threshold, module=module)
|
| 263 |
extra = sum(1 for s in keep if (s['start'], s['end'], s['label']) not in rule_keys)
|
| 264 |
_, rules_chars, _ = apply_union(text, rule_spans)
|
| 265 |
+
# A rule's label wins on its characters (2.0.2: "PESEL 44051401359" in a contact line is [PII], not [PHONE]),
|
| 266 |
+
# except an unlabelled PESEL, which the rules mask last and a model phone may keep.
|
| 267 |
+
firm = [s for s in rule_spans if not s.get('bare')]
|
| 268 |
+
masked, union_chars, counts = apply_union(text, [*rule_spans, *keep, *name_spans], firm)
|
| 269 |
return masked, {**counts, 'rules_placeholder_chars': rules_chars, 'union_placeholder_chars': union_chars,
|
| 270 |
'model_extra_spans': extra}
|
| 271 |
|
scrub_pii.py
CHANGED
|
@@ -954,6 +954,9 @@ def scrub_pii(text: str, *, spans: list | None = None) -> tuple[str, dict[str, i
|
|
| 954 |
counts["phone"] = apply(_replace_phones)
|
| 955 |
# Contact context takes precedence over coincidental PESEL checksums in
|
| 956 |
# foreign phone numbers; explicitly labelled identifiers were handled first.
|
|
|
|
| 957 |
bare_pesel = apply(_replace_checked, _PESEL_RE, PII_TAG, _pesel_ok)
|
|
|
|
|
|
|
| 958 |
counts['pesel'] += bare_pesel
|
| 959 |
return text, counts
|
|
|
|
| 954 |
counts["phone"] = apply(_replace_phones)
|
| 955 |
# Contact context takes precedence over coincidental PESEL checksums in
|
| 956 |
# foreign phone numbers; explicitly labelled identifiers were handled first.
|
| 957 |
+
first = len(spans) if spans is not None else 0
|
| 958 |
bare_pesel = apply(_replace_checked, _PESEL_RE, PII_TAG, _pesel_ok)
|
| 959 |
+
for span in spans[first:] if spans is not None else ():
|
| 960 |
+
span['bare'] = True # unlabelled: NERGAL lets a model phone keep it (2.0.2)
|
| 961 |
counts['pesel'] += bare_pesel
|
| 962 |
return text, counts
|
test_nergal.py
CHANGED
|
@@ -7,7 +7,7 @@ import unittest
|
|
| 7 |
from pathlib import Path
|
| 8 |
|
| 9 |
HERE = Path(__file__).resolve().parent
|
| 10 |
-
RULES_SHA = '
|
| 11 |
|
| 12 |
|
| 13 |
class NergalTests(unittest.TestCase):
|
|
@@ -15,7 +15,7 @@ class NergalTests(unittest.TestCase):
|
|
| 15 |
from nergal import GAP_IDS, GAPS, HUB_ID, RULES_SHA as PINNED, THRESHOLD, VERSION
|
| 16 |
card = json.loads((HERE / 'hybrid.json').read_text())
|
| 17 |
self.assertEqual(HUB_ID, 'SlayerLab/NERGAL')
|
| 18 |
-
self.assertEqual(VERSION, '2.0.
|
| 19 |
self.assertEqual(card['version'], VERSION)
|
| 20 |
self.assertEqual(card['eval']['union_fp'], 80)
|
| 21 |
self.assertEqual(card['eval']['rules_fp'], 24)
|
|
@@ -151,6 +151,50 @@ class NergalTests(unittest.TestCase):
|
|
| 151 |
with self.assertRaises(ValueError):
|
| 152 |
apply_union(text, [{'start': 0, 'end': 1, 'label': 'org'}])
|
| 153 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
def test_person_spans_expand_merge_and_skip_markers(self):
|
| 155 |
from nergal import person_spans
|
| 156 |
text = 'Pani Nowakowskiej-Kowal, O’Brien. [PERSON] i [PHONE]'
|
|
@@ -186,6 +230,33 @@ class NergalTests(unittest.TestCase):
|
|
| 186 |
self.assertEqual(scrub_spans(text, [], [span(text, '112')])[0], text)
|
| 187 |
self.assertEqual(model_keep(text, [span(text, '112', score=0.9)], threshold=0.95), [])
|
| 188 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
def _names(self):
|
| 190 |
from nergal import Names
|
| 191 |
return Names(HERE, json.loads((HERE / 'hybrid.json').read_text())['names'])
|
|
|
|
| 7 |
from pathlib import Path
|
| 8 |
|
| 9 |
HERE = Path(__file__).resolve().parent
|
| 10 |
+
RULES_SHA = '205a253b7f496b00a3a7644b9588b6c22d94d8ebb223d2d9d8a4d6326bdcccdb'
|
| 11 |
|
| 12 |
|
| 13 |
class NergalTests(unittest.TestCase):
|
|
|
|
| 15 |
from nergal import GAP_IDS, GAPS, HUB_ID, RULES_SHA as PINNED, THRESHOLD, VERSION
|
| 16 |
card = json.loads((HERE / 'hybrid.json').read_text())
|
| 17 |
self.assertEqual(HUB_ID, 'SlayerLab/NERGAL')
|
| 18 |
+
self.assertEqual(VERSION, '2.0.2')
|
| 19 |
self.assertEqual(card['version'], VERSION)
|
| 20 |
self.assertEqual(card['eval']['union_fp'], 80)
|
| 21 |
self.assertEqual(card['eval']['rules_fp'], 24)
|
|
|
|
| 151 |
with self.assertRaises(ValueError):
|
| 152 |
apply_union(text, [{'start': 0, 'end': 1, 'label': 'org'}])
|
| 153 |
|
| 154 |
+
def test_a_rule_label_wins_on_its_characters(self):
|
| 155 |
+
from nergal import char_labels
|
| 156 |
+
spans = [{'start': 0, 'end': 4, 'label': 'phone'}, {'start': 2, 'end': 6, 'label': 'pii'},
|
| 157 |
+
{'start': 5, 'end': 8, 'label': 'phone'}]
|
| 158 |
+
self.assertEqual(char_labels('x' * 8, spans), ['phone'] * 4 + ['pii'] + ['phone'] * 3)
|
| 159 |
+
self.assertEqual(char_labels('x' * 8, spans, firm=spans[1:2]), ['phone'] * 2 + ['pii'] * 4 + ['phone'] * 2)
|
| 160 |
+
self.assertEqual(char_labels('x' * 8, spans, firm=spans), ['phone'] * 4 + ['pii'] + ['phone'] * 3) # firm vs firm: rank
|
| 161 |
+
self.assertEqual(char_labels('xy', []), [None, None])
|
| 162 |
+
|
| 163 |
+
def test_labelled_identifiers_keep_pii_under_a_model_phone(self):
|
| 164 |
+
from nergal import rules, scrub_spans
|
| 165 |
+
account = '28 1020 5558 1111 1119 0100 0000' # invented, checksum-valid
|
| 166 |
+
for value, label in (('44051401359', 'PESEL'), ('44051401359', 'PESEL:'), ('123456785', 'REGON'),
|
| 167 |
+
('12345678512347', 'REGON'), ('1234567802', 'NIP'), ('123-456-78-02', 'NIP'),
|
| 168 |
+
('ABA300000', 'dowód osobisty'), ('AB1234567', 'paszport nr'), (account, 'konto'),
|
| 169 |
+
(account.replace(' ', ''), 'nr rachunku')):
|
| 170 |
+
for lead in ('Kontakt: tel. 601 222 333, ', 'Dane: tel. 601 222 333, ', 'tel. 601 222 333, ', 'Kontakt: ', ''):
|
| 171 |
+
text = f'{lead}{label} {value}.'
|
| 172 |
+
at = text.index(value, len(lead))
|
| 173 |
+
phone = [dict(start=at, end=at + len(value), label='phone', score=0.99)]
|
| 174 |
+
with self.subTest(text=text):
|
| 175 |
+
found = rules(text)
|
| 176 |
+
self.assertFalse(any(s.get('bare') for s in found))
|
| 177 |
+
masked = scrub_spans(text, found, phone)[0]
|
| 178 |
+
self.assertTrue(masked.endswith(f'{label} [PII].'), masked)
|
| 179 |
+
self.assertEqual(masked.count('[PHONE]'), lead.count('601')) # the contact phone keeps [PHONE]
|
| 180 |
+
|
| 181 |
+
def test_an_unlabelled_pesel_yields_to_a_model_phone(self):
|
| 182 |
+
from nergal import rules, scrub_spans
|
| 183 |
+
for text in ('Kontakt: 44051401359', 'tel. 44051401359', 'PESEL? Nie, tel. 44051401359.'):
|
| 184 |
+
at = next(i for i, c in enumerate(text) if c.isdigit())
|
| 185 |
+
phone = [dict(start=at, end=at + 11, label='phone', score=0.99)]
|
| 186 |
+
with self.subTest(text=text):
|
| 187 |
+
found = rules(text)
|
| 188 |
+
self.assertTrue(found and all(s.get('bare') and s['label'] == 'pii' for s in found)) # rules alone: [PII]
|
| 189 |
+
self.assertEqual(scrub_spans(text, found, [])[0], text[:at] + '[PII]' + text[at + 11:])
|
| 190 |
+
self.assertEqual(scrub_spans(text, found, phone)[0], text[:at] + '[PHONE]' + text[at + 11:])
|
| 191 |
+
|
| 192 |
+
def test_a_rule_phone_keeps_phone_under_a_model_pii(self):
|
| 193 |
+
from nergal import rules, scrub_spans
|
| 194 |
+
text = 'Kontakt: tel. 601 222 333, kom. 602 333 444.'
|
| 195 |
+
model = [dict(start=text.index('602'), end=text.index('602') + 11, label='pii', score=0.99)]
|
| 196 |
+
self.assertEqual(scrub_spans(text, rules(text), model)[0], 'Kontakt: tel. [PHONE], kom. [PHONE].')
|
| 197 |
+
|
| 198 |
def test_person_spans_expand_merge_and_skip_markers(self):
|
| 199 |
from nergal import person_spans
|
| 200 |
text = 'Pani Nowakowskiej-Kowal, O’Brien. [PERSON] i [PHONE]'
|
|
|
|
| 230 |
self.assertEqual(scrub_spans(text, [], [span(text, '112')])[0], text)
|
| 231 |
self.assertEqual(model_keep(text, [span(text, '112', score=0.9)], threshold=0.95), [])
|
| 232 |
|
| 233 |
+
def test_published_weights_label_contact_lines(self):
|
| 234 |
+
from nergal import Nergal
|
| 235 |
+
nergal = Nergal(HERE, device='cpu')
|
| 236 |
+
account = '28 1020 5558 1111 1119 0100 0000' # invented, checksum-valid
|
| 237 |
+
cases = {
|
| 238 |
+
'Kontakt: tel. 601 222 333, PESEL 44051401359.': 'Kontakt: tel. [PHONE], PESEL [PII].',
|
| 239 |
+
'Kontakt: jan.kowalski@example.com, PESEL 44051401359.': 'Kontakt: [PII], PESEL [PII].',
|
| 240 |
+
'Kontakt: tel. 601 222 333, REGON 123456785.': 'Kontakt: tel. [PHONE], REGON [PII].',
|
| 241 |
+
'Kontakt: tel. 601 222 333, REGON 12345678512347.': 'Kontakt: tel. [PHONE], REGON [PII].',
|
| 242 |
+
'Kontakt: PESEL 44051401359.': 'Kontakt: PESEL [PII].',
|
| 243 |
+
'Dane: tel. 601 222 333, PESEL 44051401359.': 'Dane: tel. [PHONE], PESEL [PII].',
|
| 244 |
+
'tel. 601 222 333, PESEL 44051401359.': 'tel. [PHONE], PESEL [PII].',
|
| 245 |
+
'Telefon: 601 222 333, PESEL: 44051401359': 'Telefon: [PHONE], PESEL: [PII]',
|
| 246 |
+
'PESEL 44051401359.': 'PESEL [PII].',
|
| 247 |
+
'Kontakt: tel. 601 222 333, NIP 1234567802.': 'Kontakt: tel. [PHONE], NIP [PII].',
|
| 248 |
+
'Kontakt: tel. 601 222 333, NIP 123-456-78-02.': 'Kontakt: tel. [PHONE], NIP [PII].',
|
| 249 |
+
'Kontakt: tel. 601 222 333, dowód osobisty ABA300000.': 'Kontakt: tel. [PHONE], dowód osobisty [PII].',
|
| 250 |
+
'Kontakt: tel. 601 222 333, paszport nr AB1234567.': 'Kontakt: tel. [PHONE], paszport nr [PII].',
|
| 251 |
+
f'Kontakt: tel. 601 222 333, konto {account}.': 'Kontakt: tel. [PHONE], konto [PII].',
|
| 252 |
+
'Kontakt: tel. 601 222 333, kom. 602 333 444.': 'Kontakt: tel. [PHONE], kom. [PHONE].',
|
| 253 |
+
'Kontakt: +48 22 123 45 67, e-mail biuro@example.pl.': 'Kontakt: [PHONE], e-mail [PII].',
|
| 254 |
+
'Dane: tel. (22) 123 45 67, fax 22 123 45 68.': 'Dane: tel. [PHONE], fax [PHONE].',
|
| 255 |
+
'Kontakt: 44051401359': 'Kontakt: [PHONE]', # an unlabelled PESEL-valid number the model reads as a phone
|
| 256 |
+
'Zadzwoń: 48601222333.': 'Zadzwoń: [PHONE].',
|
| 257 |
+
}
|
| 258 |
+
self.assertEqual({t: m for t, (m, _) in zip(cases, nergal.scrub_many(list(cases)))}, cases)
|
| 259 |
+
|
| 260 |
def _names(self):
|
| 261 |
from nergal import Names
|
| 262 |
return Names(HERE, json.loads((HERE / 'hybrid.json').read_text())['names'])
|