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.0: ranked union, person spans
Browse filesPHONE > PII > PERSON on overlap; person token ranges expand to whole words
and merge into spans; scrub_spans counts person. Names off: output unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- nergal.py +46 -21
- test_nergal.py +27 -1
nergal.py
CHANGED
|
@@ -14,7 +14,7 @@ from functools import lru_cache
|
|
| 14 |
from pathlib import Path
|
| 15 |
|
| 16 |
import scrub_pii
|
| 17 |
-
from scrub_pii import PHONE_TAG, PII_TAG
|
| 18 |
|
| 19 |
HUB_ID = 'SlayerLab/NERGAL'
|
| 20 |
VERSION = '1.2.0'
|
|
@@ -25,6 +25,13 @@ LABELS = ['phone', 'pii']
|
|
| 25 |
THRESHOLD = 0.95
|
| 26 |
RULES_SHA = 'd18662434b3bab69d74e7f63f76a646122cd9d7420de1d70914aaeb903ff2357'
|
| 27 |
DTYPES = ('float32', 'float16')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
|
| 30 |
def sha(path):
|
|
@@ -180,16 +187,43 @@ def rules(text):
|
|
| 180 |
return spans_from(scrub_pii, text)
|
| 181 |
|
| 182 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
def apply_union(text, spans):
|
|
|
|
| 184 |
labels = [None] * len(text)
|
| 185 |
for span in spans:
|
| 186 |
start, end, label = span['start'], span['end'], span['label']
|
|
|
|
|
|
|
| 187 |
if not 0 <= start < end <= len(text):
|
| 188 |
raise ValueError('Span outside text')
|
| 189 |
for i in range(start, end):
|
| 190 |
-
if labels[i] is None or label
|
| 191 |
labels[i] = label
|
| 192 |
-
out, chars,
|
| 193 |
while i < len(text):
|
| 194 |
lab = labels[i]
|
| 195 |
if lab is None:
|
|
@@ -199,15 +233,11 @@ def apply_union(text, spans):
|
|
| 199 |
j = i + 1
|
| 200 |
while j < len(text) and labels[j] == lab:
|
| 201 |
j += 1
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
if lab == 'phone':
|
| 206 |
-
n_phone += 1
|
| 207 |
-
else:
|
| 208 |
-
n_pii += 1
|
| 209 |
i = j
|
| 210 |
-
return ''.join(out), chars,
|
| 211 |
|
| 212 |
|
| 213 |
def model_keep(text, model_spans, *, threshold=THRESHOLD, module=scrub_pii):
|
|
@@ -218,19 +248,14 @@ def model_keep(text, model_spans, *, threshold=THRESHOLD, module=scrub_pii):
|
|
| 218 |
else [(s['start'], s['end'])])]
|
| 219 |
|
| 220 |
|
| 221 |
-
def scrub_spans(text, rule_spans, model_spans, *, threshold=THRESHOLD, module=scrub_pii):
|
| 222 |
rule_keys = {(s['start'], s['end'], s['label']) for s in rule_spans}
|
| 223 |
keep = model_keep(text, model_spans, threshold=threshold, module=module)
|
| 224 |
extra = sum(1 for s in keep if (s['start'], s['end'], s['label']) not in rule_keys)
|
| 225 |
-
_, rules_chars, _
|
| 226 |
-
masked, union_chars,
|
| 227 |
-
return masked, {
|
| 228 |
-
|
| 229 |
-
'pii': n_pii,
|
| 230 |
-
'rules_placeholder_chars': rules_chars,
|
| 231 |
-
'union_placeholder_chars': union_chars,
|
| 232 |
-
'model_extra_spans': extra,
|
| 233 |
-
}
|
| 234 |
|
| 235 |
|
| 236 |
def _resolve(source, *, local_files_only):
|
|
|
|
| 14 |
from pathlib import Path
|
| 15 |
|
| 16 |
import scrub_pii
|
| 17 |
+
from scrub_pii import LEGACY_PHONE_TAG, PHONE_TAG, PII_TAG
|
| 18 |
|
| 19 |
HUB_ID = 'SlayerLab/NERGAL'
|
| 20 |
VERSION = '1.2.0'
|
|
|
|
| 25 |
THRESHOLD = 0.95
|
| 26 |
RULES_SHA = 'd18662434b3bab69d74e7f63f76a646122cd9d7420de1d70914aaeb903ff2357'
|
| 27 |
DTYPES = ('float32', 'float16')
|
| 28 |
+
PERSON_TAG = '[PERSON]'
|
| 29 |
+
MARKERS = (PHONE_TAG, PII_TAG, PERSON_TAG, LEGACY_PHONE_TAG)
|
| 30 |
+
TAGS = {'person': PERSON_TAG, 'pii': PII_TAG, 'phone': PHONE_TAG} # ascending precedence
|
| 31 |
+
RANK = {label: i for i, label in enumerate(TAGS)}
|
| 32 |
+
PERSON_LABELS = ('PERSON', 'PERSON_F', 'PERSON_L')
|
| 33 |
+
_WORD = re.compile(r"[\w'’-]")
|
| 34 |
+
_PLACEHOLDER = re.compile('|'.join(map(re.escape, MARKERS)))
|
| 35 |
|
| 36 |
|
| 37 |
def sha(path):
|
|
|
|
| 187 |
return spans_from(scrub_pii, text)
|
| 188 |
|
| 189 |
|
| 190 |
+
def expand(text, start, end):
|
| 191 |
+
"""Grow a token range to the whole word: letters, digits, apostrophes and hyphens."""
|
| 192 |
+
while start > 0 and _WORD.match(text, start - 1):
|
| 193 |
+
start -= 1
|
| 194 |
+
while end < len(text) and _WORD.match(text, end):
|
| 195 |
+
end += 1
|
| 196 |
+
return start, end
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def person_spans(text, ranges):
|
| 200 |
+
"""Person spans from FastPDN person-token ranges: whole words, joined across spaces, tabs and no-break spaces
|
| 201 |
+
(not punctuation or line breaks); words touching a placeholder are skipped."""
|
| 202 |
+
blocked = [m.span() for m in _PLACEHOLDER.finditer(text)]
|
| 203 |
+
merged = []
|
| 204 |
+
for start, end in sorted(expand(text, a, b) for a, b in ranges):
|
| 205 |
+
if any(start < b and a < end for a, b in blocked):
|
| 206 |
+
continue
|
| 207 |
+
if merged and not text[merged[-1][1]:start].strip(' \t\u00a0'):
|
| 208 |
+
merged[-1][1] = max(merged[-1][1], end)
|
| 209 |
+
else:
|
| 210 |
+
merged.append([start, end])
|
| 211 |
+
return [{'start': a, 'end': b, 'label': 'person', 'score': 1.0} for a, b in merged]
|
| 212 |
+
|
| 213 |
+
|
| 214 |
def apply_union(text, spans):
|
| 215 |
+
"""Each character takes its highest-ranked label (phone > pii > person); each run becomes one placeholder."""
|
| 216 |
labels = [None] * len(text)
|
| 217 |
for span in spans:
|
| 218 |
start, end, label = span['start'], span['end'], span['label']
|
| 219 |
+
if label not in RANK:
|
| 220 |
+
raise ValueError(f'Unknown label {label!r}')
|
| 221 |
if not 0 <= start < end <= len(text):
|
| 222 |
raise ValueError('Span outside text')
|
| 223 |
for i in range(start, end):
|
| 224 |
+
if labels[i] is None or RANK[label] > RANK[labels[i]]:
|
| 225 |
labels[i] = label
|
| 226 |
+
out, chars, counts, i = [], 0, dict.fromkeys(TAGS, 0), 0
|
| 227 |
while i < len(text):
|
| 228 |
lab = labels[i]
|
| 229 |
if lab is None:
|
|
|
|
| 233 |
j = i + 1
|
| 234 |
while j < len(text) and labels[j] == lab:
|
| 235 |
j += 1
|
| 236 |
+
out.append(TAGS[lab])
|
| 237 |
+
chars += len(TAGS[lab])
|
| 238 |
+
counts[lab] += 1
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
i = j
|
| 240 |
+
return ''.join(out), chars, counts
|
| 241 |
|
| 242 |
|
| 243 |
def model_keep(text, model_spans, *, threshold=THRESHOLD, module=scrub_pii):
|
|
|
|
| 248 |
else [(s['start'], s['end'])])]
|
| 249 |
|
| 250 |
|
| 251 |
+
def scrub_spans(text, rule_spans, model_spans, *, threshold=THRESHOLD, module=scrub_pii, name_spans=()):
|
| 252 |
rule_keys = {(s['start'], s['end'], s['label']) for s in rule_spans}
|
| 253 |
keep = model_keep(text, model_spans, threshold=threshold, module=module)
|
| 254 |
extra = sum(1 for s in keep if (s['start'], s['end'], s['label']) not in rule_keys)
|
| 255 |
+
_, rules_chars, _ = apply_union(text, rule_spans)
|
| 256 |
+
masked, union_chars, counts = apply_union(text, [*rule_spans, *keep, *name_spans])
|
| 257 |
+
return masked, {**counts, 'rules_placeholder_chars': rules_chars, 'union_placeholder_chars': union_chars,
|
| 258 |
+
'model_extra_spans': extra}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
|
| 260 |
|
| 261 |
def _resolve(source, *, local_files_only):
|
test_nergal.py
CHANGED
|
@@ -119,11 +119,37 @@ class NergalTests(unittest.TestCase):
|
|
| 119 |
self.assertIn('[PII]', masked)
|
| 120 |
self.assertGreater(counts['union_placeholder_chars'], counts['rules_placeholder_chars'])
|
| 121 |
self.assertEqual(counts['model_extra_spans'], 1)
|
| 122 |
-
_, rules_chars, _
|
| 123 |
self.assertEqual(counts['rules_placeholder_chars'], rules_chars)
|
|
|
|
| 124 |
self.assertNotIn('000000000', masked)
|
| 125 |
self.assertNotIn('extra', masked)
|
| 126 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
def test_model_phone_spans_follow_the_phone_policy(self):
|
| 128 |
from nergal import model_keep, scrub_spans
|
| 129 |
span = lambda text, part, label='phone', score=0.99: dict(
|
|
|
|
| 119 |
self.assertIn('[PII]', masked)
|
| 120 |
self.assertGreater(counts['union_placeholder_chars'], counts['rules_placeholder_chars'])
|
| 121 |
self.assertEqual(counts['model_extra_spans'], 1)
|
| 122 |
+
_, rules_chars, _ = apply_union(text, rules)
|
| 123 |
self.assertEqual(counts['rules_placeholder_chars'], rules_chars)
|
| 124 |
+
self.assertEqual(counts['person'], 0)
|
| 125 |
self.assertNotIn('000000000', masked)
|
| 126 |
self.assertNotIn('extra', masked)
|
| 127 |
|
| 128 |
+
def test_union_ranks_phone_over_pii_over_person(self):
|
| 129 |
+
from nergal import apply_union
|
| 130 |
+
text = 'Jan Kowal 601 234 567'
|
| 131 |
+
spans = [{'start': 0, 'end': 21, 'label': 'person'}, {'start': 4, 'end': 9, 'label': 'pii'},
|
| 132 |
+
{'start': 10, 'end': 21, 'label': 'phone'}]
|
| 133 |
+
masked, chars, counts = apply_union(text, spans)
|
| 134 |
+
self.assertEqual(masked, '[PERSON][PII][PERSON][PHONE]') # 0–4 and the space at 9 stay person
|
| 135 |
+
self.assertEqual(chars, len(masked))
|
| 136 |
+
self.assertEqual(counts, {'person': 2, 'pii': 1, 'phone': 1})
|
| 137 |
+
with self.assertRaises(ValueError):
|
| 138 |
+
apply_union(text, [{'start': 0, 'end': 1, 'label': 'org'}])
|
| 139 |
+
|
| 140 |
+
def test_person_spans_expand_merge_and_skip_markers(self):
|
| 141 |
+
from nergal import person_spans
|
| 142 |
+
text = 'Pani Nowakowskiej-Kowal, O’Brien. [PERSON] i [PHONE]'
|
| 143 |
+
a = text.index('Nowak'); b = text.index('O’B')
|
| 144 |
+
spans = person_spans(text, [(a + 2, a + 6), (b, b + 2), (text.index('[PERSON]') + 1, text.index('[PERSON]') + 3)])
|
| 145 |
+
self.assertEqual([text[s['start']:s['end']] for s in spans], ['Nowakowskiej-Kowal', 'O’Brien'])
|
| 146 |
+
self.assertEqual(person_spans('Jan Nowak', [(0, 3), (4, 9)]), [{'start': 0, 'end': 9, 'label': 'person', 'score': 1.0}])
|
| 147 |
+
self.assertEqual(person_spans('Jan Nowak', [(0, 3), (4, 9)])[0]['end'], 9) # no-break space joins
|
| 148 |
+
self.assertEqual(person_spans('Jan, Nowak', [(0, 3), (5, 10)])[1]['start'], 5) # punctuation keeps them apart
|
| 149 |
+
self.assertEqual(len(person_spans('Jan\nNowak', [(0, 3), (4, 9)])), 2) # a line break keeps them apart
|
| 150 |
+
self.assertEqual(person_spans('Nowak', [(0, 2), (1, 5)]), [{'start': 0, 'end': 5, 'label': 'person', 'score': 1.0}])
|
| 151 |
+
self.assertEqual(person_spans('x', []), [])
|
| 152 |
+
|
| 153 |
def test_model_phone_spans_follow_the_phone_policy(self):
|
| 154 |
from nergal import model_keep, scrub_spans
|
| 155 |
span = lambda text, part, label='phone', score=0.99: dict(
|