deidkit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- deidkit/__init__.py +74 -0
- deidkit/__main__.py +6 -0
- deidkit/api.py +316 -0
- deidkit/cli.py +399 -0
- deidkit/config.py +285 -0
- deidkit/data/NOTICES.md +25 -0
- deidkit/data/context_triggers_en.txt +17 -0
- deidkit/data/context_triggers_es.txt +36 -0
- deidkit/data/en_given_names.txt +203 -0
- deidkit/data/en_surnames.txt +118 -0
- deidkit/data/es_given_names.txt +273 -0
- deidkit/data/es_surnames.txt +168 -0
- deidkit/data/honorifics_en.txt +22 -0
- deidkit/data/honorifics_es.txt +35 -0
- deidkit/data/medical_stoplist_en.txt +345 -0
- deidkit/data/medical_stoplist_es.txt +499 -0
- deidkit/data/medical_vocab.txt +21445 -0
- deidkit/dates.py +229 -0
- deidkit/detect/__init__.py +19 -0
- deidkit/detect/checksums.py +160 -0
- deidkit/detect/context.py +137 -0
- deidkit/detect/gazetteer.py +158 -0
- deidkit/detect/patterns.py +147 -0
- deidkit/detect/pipeline.py +234 -0
- deidkit/detect/spacy_ner.py +53 -0
- deidkit/detect/types.py +53 -0
- deidkit/engine.py +644 -0
- deidkit/generators/__init__.py +4 -0
- deidkit/generators/names.py +111 -0
- deidkit/generators/surrogates.py +76 -0
- deidkit/io.py +184 -0
- deidkit/learn.py +83 -0
- deidkit/mapping.py +108 -0
- deidkit/report.py +219 -0
- deidkit/resources.py +100 -0
- deidkit/schema.py +234 -0
- deidkit/secret.py +77 -0
- deidkit/textnorm.py +66 -0
- deidkit/version.py +3 -0
- deidkit-0.1.0.dist-info/METADATA +768 -0
- deidkit-0.1.0.dist-info/RECORD +45 -0
- deidkit-0.1.0.dist-info/WHEEL +5 -0
- deidkit-0.1.0.dist-info/entry_points.txt +2 -0
- deidkit-0.1.0.dist-info/licenses/LICENSE +21 -0
- deidkit-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Gazetteer stage: dictionary matching of person names.
|
|
2
|
+
|
|
3
|
+
Loads curated given-name (with gender) and surname lists, plus honorifics,
|
|
4
|
+
context triggers and a medical stoplist. Proposes PERSON spans from runs of
|
|
5
|
+
adjacent title-case tokens that contain at least one known name token. A run is
|
|
6
|
+
only a *proposal*; the pipeline's voting gate decides whether it is confident
|
|
7
|
+
enough to replace (e.g. a lone surname that is also a common word waits for
|
|
8
|
+
corroboration).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Dict, List, Set
|
|
14
|
+
|
|
15
|
+
from ..resources import load_given_names, load_set, load_surnames
|
|
16
|
+
from ..textnorm import Token, is_titlecase, norm
|
|
17
|
+
from .types import Detection, PERSON
|
|
18
|
+
|
|
19
|
+
# Connectors allowed *inside* one compound name span. Deliberately excludes "y"
|
|
20
|
+
# and "san"/"santa": "Juan Pérez y María Gómez" is two people, not one, and
|
|
21
|
+
# "San ..." is usually a place. da/do/dos/das cover Portuguese/Brazilian names.
|
|
22
|
+
CONNECTORS: Set[str] = {"de", "del", "la", "las", "los", "da", "das", "do", "dos"}
|
|
23
|
+
MAX_RUN = 6
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Gazetteer:
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
given: Dict[str, str],
|
|
30
|
+
surnames: Set[str],
|
|
31
|
+
honorifics: Set[str],
|
|
32
|
+
triggers: Set[str],
|
|
33
|
+
stoplist: Set[str],
|
|
34
|
+
):
|
|
35
|
+
self.given = given
|
|
36
|
+
self.surnames = surnames
|
|
37
|
+
self.honorifics = honorifics
|
|
38
|
+
self.triggers = triggers
|
|
39
|
+
self.stoplist = stoplist
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def load(cls, lang="es", extra_name_files=(), extra_stoplist_files=(),
|
|
43
|
+
use_medical_vocab=True):
|
|
44
|
+
from ..resources import load_lang_sets
|
|
45
|
+
|
|
46
|
+
given = load_given_names(lang, extra_name_files)
|
|
47
|
+
surnames = load_surnames(lang, extra_name_files)
|
|
48
|
+
honorifics = load_lang_sets("honorifics", lang)
|
|
49
|
+
triggers = load_lang_sets("context_triggers", lang)
|
|
50
|
+
stoplist = load_lang_sets("medical_stoplist", lang, extra_stoplist_files)
|
|
51
|
+
if not stoplist: # never run without a stoplist (precision guard)
|
|
52
|
+
stoplist = load_set("medical_stoplist_es.txt", extra_stoplist_files)
|
|
53
|
+
if use_medical_vocab: # bundled RxNorm-derived safe-vocabulary (Philter-style)
|
|
54
|
+
try:
|
|
55
|
+
stoplist |= load_set("medical_vocab.txt")
|
|
56
|
+
except (FileNotFoundError, OSError):
|
|
57
|
+
pass
|
|
58
|
+
return cls(given, surnames, honorifics, triggers, stoplist)
|
|
59
|
+
|
|
60
|
+
# --- token predicates -------------------------------------------------- #
|
|
61
|
+
def is_given(self, nrm: str) -> bool:
|
|
62
|
+
return nrm in self.given
|
|
63
|
+
|
|
64
|
+
def gender(self, nrm: str) -> str:
|
|
65
|
+
return self.given.get(nrm, "u")
|
|
66
|
+
|
|
67
|
+
def is_surname(self, nrm: str) -> bool:
|
|
68
|
+
return nrm in self.surnames
|
|
69
|
+
|
|
70
|
+
def is_name_token(self, nrm: str) -> bool:
|
|
71
|
+
return nrm in self.given or nrm in self.surnames
|
|
72
|
+
|
|
73
|
+
def is_stopword(self, nrm: str) -> bool:
|
|
74
|
+
return nrm in self.stoplist
|
|
75
|
+
|
|
76
|
+
def is_honorific(self, nrm: str) -> bool:
|
|
77
|
+
return nrm.rstrip(".") in self.honorifics
|
|
78
|
+
|
|
79
|
+
def is_trigger(self, nrm: str) -> bool:
|
|
80
|
+
return nrm in self.triggers
|
|
81
|
+
|
|
82
|
+
def is_boundary(self, nrm: str) -> bool:
|
|
83
|
+
"""A token that must not be part of a name span (stopword / anchor word).
|
|
84
|
+
|
|
85
|
+
Honorifics and role triggers *precede* names; absorbing them into the
|
|
86
|
+
span ("Acompañante María Gómez") is wrong, so they end a run.
|
|
87
|
+
"""
|
|
88
|
+
return self.is_stopword(nrm) or self.is_honorific(nrm) or self.is_trigger(nrm)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _trim_connectors(run: List[Token]) -> List[Token]:
|
|
92
|
+
while run and run[0].norm in CONNECTORS:
|
|
93
|
+
run = run[1:]
|
|
94
|
+
while run and run[-1].norm in CONNECTORS:
|
|
95
|
+
run = run[:-1]
|
|
96
|
+
return run
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def find(text: str, tokens: List[Token], gaz: Gazetteer) -> List[Detection]:
|
|
100
|
+
"""Propose PERSON spans from gazetteer-anchored title-case runs."""
|
|
101
|
+
dets: List[Detection] = []
|
|
102
|
+
n = len(tokens)
|
|
103
|
+
i = 0
|
|
104
|
+
while i < n:
|
|
105
|
+
t = tokens[i]
|
|
106
|
+
if not is_titlecase(t.text) or gaz.is_boundary(t.norm):
|
|
107
|
+
i += 1
|
|
108
|
+
continue
|
|
109
|
+
# Grow a run of title-case, non-boundary tokens (connectors allowed
|
|
110
|
+
# between name tokens for Spanish compound surnames).
|
|
111
|
+
run: List[Token] = []
|
|
112
|
+
j = i
|
|
113
|
+
while j < n and len(run) < MAX_RUN:
|
|
114
|
+
tj = tokens[j]
|
|
115
|
+
if is_titlecase(tj.text) and not gaz.is_boundary(tj.norm):
|
|
116
|
+
run.append(tj)
|
|
117
|
+
j += 1
|
|
118
|
+
elif (
|
|
119
|
+
tj.norm in CONNECTORS
|
|
120
|
+
and run
|
|
121
|
+
and j + 1 < n
|
|
122
|
+
and is_titlecase(tokens[j + 1].text)
|
|
123
|
+
and not gaz.is_boundary(tokens[j + 1].norm)
|
|
124
|
+
):
|
|
125
|
+
run.append(tj)
|
|
126
|
+
j += 1
|
|
127
|
+
else:
|
|
128
|
+
break
|
|
129
|
+
|
|
130
|
+
run = _trim_connectors(run)
|
|
131
|
+
if run:
|
|
132
|
+
known_given = sum(1 for r in run if gaz.is_given(r.norm))
|
|
133
|
+
known_surname = sum(1 for r in run if gaz.is_surname(r.norm))
|
|
134
|
+
name_tokens = [r for r in run if r.norm not in CONNECTORS]
|
|
135
|
+
if known_given + known_surname >= 1:
|
|
136
|
+
gender = "u"
|
|
137
|
+
for r in run:
|
|
138
|
+
if gaz.is_given(r.norm):
|
|
139
|
+
gender = gaz.gender(r.norm)
|
|
140
|
+
break
|
|
141
|
+
dets.append(
|
|
142
|
+
Detection(
|
|
143
|
+
start=run[0].start,
|
|
144
|
+
end=run[-1].end,
|
|
145
|
+
text=text[run[0].start : run[-1].end],
|
|
146
|
+
entity_type=PERSON,
|
|
147
|
+
methods={"gazetteer"},
|
|
148
|
+
meta={
|
|
149
|
+
"known_given": known_given,
|
|
150
|
+
"known_surname": known_surname,
|
|
151
|
+
"run_len": len(name_tokens),
|
|
152
|
+
"bigram": known_given >= 1 and known_surname >= 1,
|
|
153
|
+
"gender": gender,
|
|
154
|
+
},
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
i = max(j, i + 1)
|
|
158
|
+
return dets
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Regex stage: structured identifiers embedded in free text.
|
|
2
|
+
|
|
3
|
+
These patterns are deliberately precise. Bare numbers are *not* matched as IDs
|
|
4
|
+
(a 7-digit run could be a lab value or a code); a national ID is only taken when
|
|
5
|
+
a document cue (``CC``, ``cédula``, ``documento`` ...) or thousands-dotted shape
|
|
6
|
+
makes it unambiguous. Every hit here is high-precision and accepted directly by
|
|
7
|
+
the voting gate.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from typing import List
|
|
14
|
+
|
|
15
|
+
from .types import (
|
|
16
|
+
Detection,
|
|
17
|
+
DATE,
|
|
18
|
+
EMAIL,
|
|
19
|
+
IPADDR,
|
|
20
|
+
MRN,
|
|
21
|
+
NATIONAL_ID,
|
|
22
|
+
PHONE,
|
|
23
|
+
URL,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Unicode-aware: accented local parts (e.g. diego.gómez@...) must match fully.
|
|
27
|
+
EMAIL_RE = re.compile(
|
|
28
|
+
r"(?<![\w.%+\-])[\w.%+\-]+@[\w\-]+(?:\.[\w\-]+)*\.[A-Za-z]{2,}(?![\w])",
|
|
29
|
+
re.UNICODE,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# http(s):// URLs and bare www. hosts (which can carry names/IDs in the path).
|
|
33
|
+
URL_RE = re.compile(r"\b(?:https?://|www\.)[^\s<>\"')]+", re.IGNORECASE)
|
|
34
|
+
# Trailing characters that are sentence punctuation, not part of the URL.
|
|
35
|
+
_URL_TRAILING = ".,;:!?'\")]}>"
|
|
36
|
+
|
|
37
|
+
IP_RE = re.compile(
|
|
38
|
+
r"\b(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)\b"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# Colombian phones: optional +57 / (57), mobile (3xx, 10 digits) or landline
|
|
42
|
+
# with separators. Requires separators or a country/area cue so we don't grab
|
|
43
|
+
# plain long integers.
|
|
44
|
+
PHONE_RE = re.compile(
|
|
45
|
+
r"""(?<!\d)(?:
|
|
46
|
+
(?:\+?57[\s\-.]?)? # optional country code
|
|
47
|
+
(?:\(?\d{1,3}\)?[\s\-.])? # optional area code in parens or with sep
|
|
48
|
+
3\d{2}[\s\-.]?\d{3}[\s\-.]?\d{2,4} # mobile 3xx xxx xxxx
|
|
49
|
+
|
|
|
50
|
+
(?:\+?57[\s\-.]?)?\(?\d{1,3}\)?[\s\-.]\d{3}[\s\-.]?\d{4} # landline
|
|
51
|
+
)(?!\d)""",
|
|
52
|
+
re.VERBOSE,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# National ID (cédula / documento) with an explicit document cue.
|
|
56
|
+
CEDULA_CUED_RE = re.compile(
|
|
57
|
+
r"""(?ix)
|
|
58
|
+
\b(?:c\.?\s?c\.?|c[eé]dula(?:\s+de\s+ciudadan[ií]a)?|
|
|
59
|
+
documento|identificaci[oó]n|nuip|
|
|
60
|
+
t\.?\s?i\.?|r\.?\s?c\.?|c\.?\s?e\.?|no\.?\s?documento)
|
|
61
|
+
\s*[:#\-]?\s*
|
|
62
|
+
(\d{1,3}(?:[.\s]\d{3}){2,3}|\d{6,11})
|
|
63
|
+
"""
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# Thousands-dotted 8-11 digit number, which in ES text is almost always a cédula.
|
|
67
|
+
CEDULA_DOTTED_RE = re.compile(r"(?<!\d)\d{1,3}(?:\.\d{3}){2,3}(?!\d)")
|
|
68
|
+
|
|
69
|
+
# Medical-record / historia-clínica number with a cue.
|
|
70
|
+
MRN_RE = re.compile(
|
|
71
|
+
r"""(?ix)
|
|
72
|
+
\b(?:h\.?\s?c\.?|historia(?:\s+cl[ií]nica)?|
|
|
73
|
+
n[uú]mero\s+de\s+historia|expediente|folio|mrn)
|
|
74
|
+
\s*(?:n[o°º]\.?)?\s*[:#\-]?\s*
|
|
75
|
+
(\d{4,12})
|
|
76
|
+
"""
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# Dates: numeric (dd/mm/yyyy etc.) and Spanish long form.
|
|
80
|
+
DATE_NUMERIC_RE = re.compile(
|
|
81
|
+
r"(?<!\d)(?:\d{1,2}[/\-.]\d{1,2}[/\-.]\d{2,4}|\d{4}[/\-.]\d{1,2}[/\-.]\d{1,2})(?!\d)"
|
|
82
|
+
)
|
|
83
|
+
DATE_LONG_ES_RE = re.compile(
|
|
84
|
+
r"(?i)\b\d{1,2}\s+de\s+(?:enero|febrero|marzo|abril|mayo|junio|julio|agosto|"
|
|
85
|
+
r"septiembre|setiembre|octubre|noviembre|diciembre)\s+(?:de\s+)?\d{4}\b"
|
|
86
|
+
)
|
|
87
|
+
_EN_MONTH = (r"jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec|january|february|"
|
|
88
|
+
r"march|april|june|july|august|september|october|november|december")
|
|
89
|
+
DATE_LONG_EN_RE = re.compile(
|
|
90
|
+
rf"(?i)\b(?:{_EN_MONTH})\.?\s+\d{{1,2}}(?:st|nd|rd|th)?,?\s+\d{{4}}\b" # March 5, 2021
|
|
91
|
+
rf"|\b\d{{1,2}}(?:st|nd|rd|th)?\s+(?:{_EN_MONTH})\.?,?\s+\d{{4}}\b" # 5 March 2021
|
|
92
|
+
)
|
|
93
|
+
# 05-ABR-1988 / 5-MAR-88 (day-monthname-year, common in DB exports)
|
|
94
|
+
DATE_ALPHA_DMY_RE = re.compile(
|
|
95
|
+
r"(?i)\b\d{1,2}[-/](?:ene|feb|mar|abr|may|jun|jul|ago|sep|set|oct|nov|dic|"
|
|
96
|
+
rf"{_EN_MONTH})[-/]\d{{2,4}}\b"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _add(dets: List[Detection], m, etype: str, group: int = 0, meta=None):
|
|
101
|
+
start, end = m.span(group)
|
|
102
|
+
dets.append(
|
|
103
|
+
Detection(
|
|
104
|
+
start=start,
|
|
105
|
+
end=end,
|
|
106
|
+
text=m.string[start:end],
|
|
107
|
+
entity_type=etype,
|
|
108
|
+
methods={"regex"},
|
|
109
|
+
confidence=0.99,
|
|
110
|
+
meta=meta or {},
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def find(text: str) -> List[Detection]:
|
|
116
|
+
"""Return all identifier detections in ``text``."""
|
|
117
|
+
dets: List[Detection] = []
|
|
118
|
+
for m in EMAIL_RE.finditer(text):
|
|
119
|
+
_add(dets, m, EMAIL)
|
|
120
|
+
for m in URL_RE.finditer(text):
|
|
121
|
+
start, end = m.start(), m.end()
|
|
122
|
+
while end > start and text[end - 1] in _URL_TRAILING: # keep sentence punctuation
|
|
123
|
+
end -= 1
|
|
124
|
+
dets.append(
|
|
125
|
+
Detection(start=start, end=end, text=text[start:end], entity_type=URL,
|
|
126
|
+
methods={"regex"}, confidence=0.99)
|
|
127
|
+
)
|
|
128
|
+
for m in IP_RE.finditer(text):
|
|
129
|
+
_add(dets, m, IPADDR)
|
|
130
|
+
for m in CEDULA_CUED_RE.finditer(text):
|
|
131
|
+
_add(dets, m, NATIONAL_ID, group=1)
|
|
132
|
+
for m in CEDULA_DOTTED_RE.finditer(text):
|
|
133
|
+
_add(dets, m, NATIONAL_ID)
|
|
134
|
+
for m in MRN_RE.finditer(text):
|
|
135
|
+
_add(dets, m, MRN, group=1)
|
|
136
|
+
for m in PHONE_RE.finditer(text):
|
|
137
|
+
# Guard: skip if it is entirely inside an already-found email/url span.
|
|
138
|
+
_add(dets, m, PHONE)
|
|
139
|
+
for m in DATE_NUMERIC_RE.finditer(text):
|
|
140
|
+
_add(dets, m, DATE)
|
|
141
|
+
for m in DATE_LONG_ES_RE.finditer(text):
|
|
142
|
+
_add(dets, m, DATE)
|
|
143
|
+
for m in DATE_LONG_EN_RE.finditer(text):
|
|
144
|
+
_add(dets, m, DATE)
|
|
145
|
+
for m in DATE_ALPHA_DMY_RE.finditer(text):
|
|
146
|
+
_add(dets, m, DATE)
|
|
147
|
+
return dets
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Detection orchestrator + voting gate.
|
|
2
|
+
|
|
3
|
+
Runs every stage over a text value, merges overlapping spans, then scores each
|
|
4
|
+
person cluster from the combination of signals that fired. Whether a detection
|
|
5
|
+
is *accepted* (replaced) or sent to the *review queue* depends on the score and
|
|
6
|
+
the policy ``mode``:
|
|
7
|
+
|
|
8
|
+
============ ========= =============================================
|
|
9
|
+
mode threshold typical effect
|
|
10
|
+
============ ========= =============================================
|
|
11
|
+
conservative 0.80 only clear names (bigram / honorific / 2-detector)
|
|
12
|
+
balanced 0.65 also accept single-model NER hits
|
|
13
|
+
aggressive 0.35 also accept lone gazetteer tokens
|
|
14
|
+
============ ========= =============================================
|
|
15
|
+
|
|
16
|
+
This is how the layered design pursues high capture with low error: confident
|
|
17
|
+
signals are replaced automatically, and the ambiguous tail is surfaced for human
|
|
18
|
+
review in the audit workbook rather than silently changed or silently missed.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import re
|
|
24
|
+
import unicodedata
|
|
25
|
+
from typing import List, Optional
|
|
26
|
+
|
|
27
|
+
from ..textnorm import tokenize
|
|
28
|
+
from . import context, gazetteer, patterns
|
|
29
|
+
from .gazetteer import Gazetteer
|
|
30
|
+
from .spacy_ner import SpacyNER
|
|
31
|
+
from .types import Detection, PERSON
|
|
32
|
+
|
|
33
|
+
_IDENTIFIER_TYPES = {"EMAIL", "PHONE", "NATIONAL_ID", "MRN", "URL", "DATE", "IP"}
|
|
34
|
+
|
|
35
|
+
# Eponym context: a person-name token that is really a disease/sign/test name.
|
|
36
|
+
_EPONYM_BEFORE = re.compile(
|
|
37
|
+
r"(?i)(?:enfermedad|s[ií]ndrome|signo|prueba|maniobra|escala|fen[oó]meno|reflejo|"
|
|
38
|
+
r"cl[aá]sificaci[oó]n|[ií]ndice|test|disease|syndrome|sign|scale|reflex|maneuver|"
|
|
39
|
+
r"score|grade|stage|classification|lesi[oó]n|tumor)\s+de\s+$"
|
|
40
|
+
)
|
|
41
|
+
_EPONYM_AFTER = re.compile(
|
|
42
|
+
r"(?i)^['’]?s?\s*(?:disease|syndrome|sign|test|scale|reflex|maneuver|score|grade|"
|
|
43
|
+
r"stage|classification|cell|cells|body|bodies|node|nodes|method|stain|tumou?r)\b"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
_THRESHOLDS = {"conservative": 0.80, "balanced": 0.65, "aggressive": 0.35}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Detector:
|
|
50
|
+
"""Full multi-stage detector for one language."""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
lang: str = "es",
|
|
55
|
+
mode: str = "conservative",
|
|
56
|
+
spacy_model: Optional[str] = None,
|
|
57
|
+
extra_name_files=(),
|
|
58
|
+
extra_stoplist_files=(),
|
|
59
|
+
id_checksums=(),
|
|
60
|
+
use_medical_vocab=True,
|
|
61
|
+
):
|
|
62
|
+
self.lang = lang
|
|
63
|
+
self.mode = mode
|
|
64
|
+
self.id_checksums = list(id_checksums or [])
|
|
65
|
+
self.threshold = _THRESHOLDS.get(mode, 0.80)
|
|
66
|
+
self.gaz: Gazetteer = Gazetteer.load(
|
|
67
|
+
lang, extra_name_files, extra_stoplist_files, use_medical_vocab
|
|
68
|
+
)
|
|
69
|
+
self.ner: Optional[SpacyNER] = SpacyNER(spacy_model) if spacy_model else None
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def ner_status(self) -> str:
|
|
73
|
+
if self.ner is None:
|
|
74
|
+
return "disabled"
|
|
75
|
+
return "active" if self.ner.available else f"unavailable ({self.ner.error})"
|
|
76
|
+
|
|
77
|
+
# ------------------------------------------------------------------ #
|
|
78
|
+
def detect(self, text: str) -> List[Detection]:
|
|
79
|
+
"""Return all detections with ``.accepted`` and ``.confidence`` set.
|
|
80
|
+
|
|
81
|
+
``text`` is NFC-normalized first so combining-mark (NFD) input like a
|
|
82
|
+
decomposed ``Ramírez`` tokenizes correctly (otherwise the span ends early
|
|
83
|
+
and a fragment leaks). Callers that apply replacements MUST slice the same
|
|
84
|
+
NFC-normalized string (the engine does)."""
|
|
85
|
+
if not text or not str(text).strip():
|
|
86
|
+
return []
|
|
87
|
+
text = unicodedata.normalize("NFC", str(text))
|
|
88
|
+
tokens = tokenize(text)
|
|
89
|
+
|
|
90
|
+
raw: List[Detection] = []
|
|
91
|
+
raw += patterns.find(text)
|
|
92
|
+
if self.id_checksums:
|
|
93
|
+
from . import checksums
|
|
94
|
+
raw += checksums.find_valid_ids(text, self.id_checksums)
|
|
95
|
+
raw += context.find(text, tokens, self.gaz)
|
|
96
|
+
raw += gazetteer.find(text, tokens, self.gaz)
|
|
97
|
+
if self.ner is not None and self.ner.available:
|
|
98
|
+
raw += self.ner.find(text)
|
|
99
|
+
|
|
100
|
+
idents = [d for d in raw if d.entity_type in _IDENTIFIER_TYPES]
|
|
101
|
+
persons = [d for d in raw if d.entity_type == PERSON]
|
|
102
|
+
|
|
103
|
+
accepted_idents = self._resolve_identifiers(idents)
|
|
104
|
+
person_dets = self._resolve_persons(persons, accepted_idents)
|
|
105
|
+
|
|
106
|
+
final = accepted_idents + person_dets
|
|
107
|
+
final.sort(key=lambda d: (d.start, -(d.end - d.start)))
|
|
108
|
+
final = self._drop_accepted_overlaps(final)
|
|
109
|
+
self._suppress_eponyms(text, final)
|
|
110
|
+
return final
|
|
111
|
+
|
|
112
|
+
@staticmethod
|
|
113
|
+
def _suppress_eponyms(text: str, dets: List[Detection]) -> None:
|
|
114
|
+
"""Demote eponymous diagnoses (``enfermedad de Wilson``, ``Down syndrome``,
|
|
115
|
+
``Parkinson's disease``) from replace to review — they are disease names,
|
|
116
|
+
not the patient. Real names in other contexts are unaffected."""
|
|
117
|
+
for d in dets:
|
|
118
|
+
if not d.accepted or d.entity_type != PERSON:
|
|
119
|
+
continue
|
|
120
|
+
if _EPONYM_BEFORE.search(text[: d.start]) or _EPONYM_AFTER.match(text[d.end :]):
|
|
121
|
+
d.accepted = False
|
|
122
|
+
d.meta["suppressed"] = "eponym"
|
|
123
|
+
|
|
124
|
+
# ------------------------------------------------------------------ #
|
|
125
|
+
@staticmethod
|
|
126
|
+
def _resolve_identifiers(idents: List[Detection]) -> List[Detection]:
|
|
127
|
+
"""Greedy longest-span-wins over overlapping identifier hits."""
|
|
128
|
+
idents.sort(key=lambda d: (d.start, -(d.end - d.start)))
|
|
129
|
+
kept: List[Detection] = []
|
|
130
|
+
occupied_end = -1
|
|
131
|
+
for d in idents:
|
|
132
|
+
if d.start >= occupied_end:
|
|
133
|
+
d.accepted = True
|
|
134
|
+
d.confidence = max(d.confidence, 0.95)
|
|
135
|
+
kept.append(d)
|
|
136
|
+
occupied_end = d.end
|
|
137
|
+
elif d.end > occupied_end:
|
|
138
|
+
# partial overlap with a shorter tail; skip to avoid double-edit
|
|
139
|
+
continue
|
|
140
|
+
return kept
|
|
141
|
+
|
|
142
|
+
def _resolve_persons(
|
|
143
|
+
self, persons: List[Detection], idents: List[Detection]
|
|
144
|
+
) -> List[Detection]:
|
|
145
|
+
clusters = _merge_person_spans(persons)
|
|
146
|
+
out: List[Detection] = []
|
|
147
|
+
for c in clusters:
|
|
148
|
+
# suppress person spans overlapping an accepted identifier
|
|
149
|
+
if any(c.overlaps(i) for i in idents):
|
|
150
|
+
continue
|
|
151
|
+
c.confidence = _score_person(c)
|
|
152
|
+
c.accepted = c.confidence >= self.threshold
|
|
153
|
+
out.append(c)
|
|
154
|
+
return out
|
|
155
|
+
|
|
156
|
+
@staticmethod
|
|
157
|
+
def _drop_accepted_overlaps(final: List[Detection]) -> List[Detection]:
|
|
158
|
+
"""Ensure accepted spans never overlap (safe replacement)."""
|
|
159
|
+
result: List[Detection] = []
|
|
160
|
+
occupied_end = -1
|
|
161
|
+
for d in sorted(final, key=lambda d: (d.start, -(d.end - d.start))):
|
|
162
|
+
if not d.accepted:
|
|
163
|
+
result.append(d)
|
|
164
|
+
continue
|
|
165
|
+
if d.start >= occupied_end:
|
|
166
|
+
result.append(d)
|
|
167
|
+
occupied_end = d.end
|
|
168
|
+
# else: overlapping accepted span -> drop (already covered)
|
|
169
|
+
return result
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# --------------------------------------------------------------------------- #
|
|
173
|
+
def _merge_person_spans(persons: List[Detection]) -> List[Detection]:
|
|
174
|
+
"""Merge overlapping person detections into clusters (union span + meta)."""
|
|
175
|
+
if not persons:
|
|
176
|
+
return []
|
|
177
|
+
persons = sorted(persons, key=lambda d: (d.start, d.end))
|
|
178
|
+
clusters: List[Detection] = []
|
|
179
|
+
cur = _clone(persons[0])
|
|
180
|
+
for d in persons[1:]:
|
|
181
|
+
if d.start < cur.end: # overlap
|
|
182
|
+
cur.end = max(cur.end, d.end)
|
|
183
|
+
cur.text += "" # placeholder; text re-set below
|
|
184
|
+
_merge_meta(cur, d)
|
|
185
|
+
else:
|
|
186
|
+
clusters.append(cur)
|
|
187
|
+
cur = _clone(d)
|
|
188
|
+
clusters.append(cur)
|
|
189
|
+
return clusters
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _clone(d: Detection) -> Detection:
|
|
193
|
+
return Detection(
|
|
194
|
+
start=d.start,
|
|
195
|
+
end=d.end,
|
|
196
|
+
text=d.text,
|
|
197
|
+
entity_type=d.entity_type,
|
|
198
|
+
methods=set(d.methods),
|
|
199
|
+
meta=dict(d.meta),
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _merge_meta(cur: Detection, other: Detection) -> None:
|
|
204
|
+
cur.methods |= other.methods
|
|
205
|
+
for key in ("known_given", "known_surname", "run_len"):
|
|
206
|
+
cur.meta[key] = max(cur.meta.get(key, 0), other.meta.get(key, 0))
|
|
207
|
+
cur.meta["bigram"] = cur.meta.get("bigram") or other.meta.get("bigram")
|
|
208
|
+
if cur.meta.get("gender", "u") == "u":
|
|
209
|
+
cur.meta["gender"] = other.meta.get("gender", "u")
|
|
210
|
+
# keep the strongest context strength seen (strong > naming > verb)
|
|
211
|
+
rank = {"strong": 3, "naming": 2, "weak": 1, "verb": 1}
|
|
212
|
+
a, b = cur.meta.get("strength"), other.meta.get("strength")
|
|
213
|
+
cur.meta["strength"] = a if rank.get(a, 0) >= rank.get(b, 0) else b
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _score_person(d: Detection) -> float:
|
|
217
|
+
methods = d.methods
|
|
218
|
+
meta = d.meta
|
|
219
|
+
score = 0.30
|
|
220
|
+
if "context" in methods:
|
|
221
|
+
strength = meta.get("strength")
|
|
222
|
+
score += {"strong": 0.55, "naming": 0.45}.get(strength, 0.30)
|
|
223
|
+
if "ner" in methods:
|
|
224
|
+
score += 0.35
|
|
225
|
+
if meta.get("bigram"):
|
|
226
|
+
score += 0.45
|
|
227
|
+
elif meta.get("run_len", 1) >= 2:
|
|
228
|
+
score += 0.15
|
|
229
|
+
known = meta.get("known_given", 0) + meta.get("known_surname", 0)
|
|
230
|
+
if known >= 1 and "gazetteer" in methods:
|
|
231
|
+
score += 0.10
|
|
232
|
+
if len(methods) >= 2:
|
|
233
|
+
score += 0.20
|
|
234
|
+
return min(score, 0.99)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Optional statistical NER stage (spaCy).
|
|
2
|
+
|
|
3
|
+
Enabled only when ``Policy.spacy_model`` is set and spaCy + the model are
|
|
4
|
+
installed. It is used as an independent PERSON detector and to corroborate
|
|
5
|
+
gazetteer hits. Everything degrades gracefully: if the model is missing,
|
|
6
|
+
``available`` is ``False`` and :meth:`find` returns nothing, so the tool still
|
|
7
|
+
runs on the deterministic stages alone.
|
|
8
|
+
|
|
9
|
+
pip install "deidkit[ner]"
|
|
10
|
+
python -m spacy download es_core_news_lg
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import List
|
|
16
|
+
|
|
17
|
+
from .types import Detection, PERSON
|
|
18
|
+
|
|
19
|
+
_PERSON_LABELS = {"PER", "PERSON"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SpacyNER:
|
|
23
|
+
def __init__(self, model: str):
|
|
24
|
+
self.model = model
|
|
25
|
+
self._nlp = None
|
|
26
|
+
self.available = False
|
|
27
|
+
self.error = None
|
|
28
|
+
try:
|
|
29
|
+
import spacy
|
|
30
|
+
|
|
31
|
+
self._nlp = spacy.load(model, disable=["lemmatizer", "textcat"])
|
|
32
|
+
self.available = True
|
|
33
|
+
except Exception as exc: # ImportError or OSError (model not found)
|
|
34
|
+
self.error = str(exc)
|
|
35
|
+
|
|
36
|
+
def find(self, text: str) -> List[Detection]:
|
|
37
|
+
if not self.available or not text:
|
|
38
|
+
return []
|
|
39
|
+
doc = self._nlp(text)
|
|
40
|
+
out: List[Detection] = []
|
|
41
|
+
for ent in doc.ents:
|
|
42
|
+
if ent.label_ in _PERSON_LABELS:
|
|
43
|
+
out.append(
|
|
44
|
+
Detection(
|
|
45
|
+
start=ent.start_char,
|
|
46
|
+
end=ent.end_char,
|
|
47
|
+
text=ent.text,
|
|
48
|
+
entity_type=PERSON,
|
|
49
|
+
methods={"ner"},
|
|
50
|
+
meta={"gender": "u"},
|
|
51
|
+
)
|
|
52
|
+
)
|
|
53
|
+
return out
|
deidkit/detect/types.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Detection data model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any, Dict, Set
|
|
7
|
+
|
|
8
|
+
# --- entity types ---------------------------------------------------------- #
|
|
9
|
+
PERSON = "PERSON"
|
|
10
|
+
EMAIL = "EMAIL"
|
|
11
|
+
PHONE = "PHONE"
|
|
12
|
+
NATIONAL_ID = "NATIONAL_ID" # e.g. Colombian cédula
|
|
13
|
+
MRN = "MRN" # medical-record / historia-clínica number
|
|
14
|
+
URL = "URL"
|
|
15
|
+
DATE = "DATE" # date embedded in free text
|
|
16
|
+
IPADDR = "IP"
|
|
17
|
+
|
|
18
|
+
# Namespace helper so callers can write ``entity.PERSON`` if they prefer.
|
|
19
|
+
class entity: # noqa: N801 - used as a namespace
|
|
20
|
+
PERSON = PERSON
|
|
21
|
+
EMAIL = EMAIL
|
|
22
|
+
PHONE = PHONE
|
|
23
|
+
NATIONAL_ID = NATIONAL_ID
|
|
24
|
+
MRN = MRN
|
|
25
|
+
URL = URL
|
|
26
|
+
DATE = DATE
|
|
27
|
+
IP = IPADDR
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Detection:
|
|
32
|
+
"""A detected span of PII inside a text value.
|
|
33
|
+
|
|
34
|
+
Half-open span ``[start, end)`` over the *original* text. ``methods`` records
|
|
35
|
+
which detector stages fired (used by the voting gate). ``confidence`` is set
|
|
36
|
+
by the pipeline after voting. ``accepted`` marks whether it will be replaced
|
|
37
|
+
(vs. sent to the review queue).
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
start: int
|
|
41
|
+
end: int
|
|
42
|
+
text: str
|
|
43
|
+
entity_type: str
|
|
44
|
+
methods: Set[str] = field(default_factory=set)
|
|
45
|
+
confidence: float = 0.0
|
|
46
|
+
accepted: bool = False
|
|
47
|
+
meta: Dict[str, Any] = field(default_factory=dict)
|
|
48
|
+
|
|
49
|
+
def overlaps(self, other: "Detection") -> bool:
|
|
50
|
+
return self.start < other.end and other.start < self.end
|
|
51
|
+
|
|
52
|
+
def __len__(self) -> int:
|
|
53
|
+
return self.end - self.start
|