ppuzio Claude Opus 5.5 commited on
Commit
7ab6996
·
1 Parent(s): 75130f0

2.0.0: ranked union, person spans

Browse files

PHONE > 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>

Files changed (2) hide show
  1. nergal.py +46 -21
  2. 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 == 'phone':
191
  labels[i] = label
192
- out, chars, n_phone, n_pii, i = [], 0, 0, 0, 0
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
- tag = PHONE_TAG if lab == 'phone' else PII_TAG
203
- out.append(tag)
204
- chars += len(tag)
205
- if lab == 'phone':
206
- n_phone += 1
207
- else:
208
- n_pii += 1
209
  i = j
210
- return ''.join(out), chars, n_phone, n_pii
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, _, _ = apply_union(text, rule_spans)
226
- masked, union_chars, n_phone, n_pii = apply_union(text, list(rule_spans) + keep)
227
- return masked, {
228
- 'phone': n_phone,
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, _, _ = apply_union(text, rules)
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(