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
deidkit/engine.py
ADDED
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
"""The orchestrator: apply a :class:`~deidkit.config.Policy` to tabular data.
|
|
2
|
+
|
|
3
|
+
One :class:`Deidentifier` instance carries the shared state that guarantees
|
|
4
|
+
**referential integrity across the whole dataset**:
|
|
5
|
+
|
|
6
|
+
* one :class:`~deidkit.mapping.Mapping` (same real value -> same surrogate in
|
|
7
|
+
every column and every table),
|
|
8
|
+
* one :class:`~deidkit.dates.DateShifter` (same patient shifted by the same
|
|
9
|
+
offset in every table, so intervals line up across tables),
|
|
10
|
+
* one :class:`~deidkit.generators.NameGenerator`.
|
|
11
|
+
|
|
12
|
+
Process a single DataFrame with :meth:`run_table`, or a whole set of tables with
|
|
13
|
+
:meth:`run_dataset`. Every change is recorded for the before/after audit
|
|
14
|
+
(:mod:`deidkit.report`).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import math
|
|
20
|
+
import re
|
|
21
|
+
import unicodedata
|
|
22
|
+
from collections import defaultdict
|
|
23
|
+
from typing import Any, Dict, List, Optional
|
|
24
|
+
|
|
25
|
+
import pandas as pd
|
|
26
|
+
|
|
27
|
+
from . import config, schema
|
|
28
|
+
from .config import (
|
|
29
|
+
AGE_BAND,
|
|
30
|
+
DATE_SHIFT,
|
|
31
|
+
DROP,
|
|
32
|
+
FREETEXT,
|
|
33
|
+
GENERALIZE_YEAR,
|
|
34
|
+
IDENTIFIER,
|
|
35
|
+
PASSTHROUGH,
|
|
36
|
+
Policy,
|
|
37
|
+
REDACT,
|
|
38
|
+
SYNTHETIC_NAME,
|
|
39
|
+
)
|
|
40
|
+
from .dates import DateShifter
|
|
41
|
+
from .detect import Detector
|
|
42
|
+
from .detect.types import DATE as DATE_ENTITY
|
|
43
|
+
from .detect.types import NATIONAL_ID, PERSON, Detection
|
|
44
|
+
from .generators import NameGenerator, SurrogateGenerator
|
|
45
|
+
from .mapping import Mapping
|
|
46
|
+
from .secret import resolve_secret
|
|
47
|
+
from .textnorm import norm
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Deidentifier:
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
policy: Policy,
|
|
54
|
+
secret: Optional[str] = None,
|
|
55
|
+
secret_file: Optional[str] = None,
|
|
56
|
+
mapping_path: Optional[str] = None,
|
|
57
|
+
dict_index: Optional[Dict[str, Dict[str, Any]]] = None,
|
|
58
|
+
max_records: int = 200_000,
|
|
59
|
+
max_structured_samples: int = 2_000,
|
|
60
|
+
):
|
|
61
|
+
self.policy = policy
|
|
62
|
+
self.secret_str = secret
|
|
63
|
+
try:
|
|
64
|
+
self.secret = resolve_secret(secret, secret_file)
|
|
65
|
+
except ValueError:
|
|
66
|
+
# Friendly for notebooks: generate an ephemeral secret rather than
|
|
67
|
+
# erroring. Persist ``deid.secret_str`` to reproduce/extend the map.
|
|
68
|
+
import warnings
|
|
69
|
+
|
|
70
|
+
from .secret import generate_secret
|
|
71
|
+
|
|
72
|
+
self.secret_str = generate_secret()
|
|
73
|
+
self.secret = self.secret_str.encode("utf-8")
|
|
74
|
+
warnings.warn(
|
|
75
|
+
"deidkit: no secret provided — generated an ephemeral one. "
|
|
76
|
+
"Save `deid.secret_str` (or pass secret=/secret_file=) to keep "
|
|
77
|
+
"the mapping reproducible across runs.",
|
|
78
|
+
stacklevel=2,
|
|
79
|
+
)
|
|
80
|
+
self.mapping_path = mapping_path
|
|
81
|
+
self.mapping = Mapping.load_or_new(mapping_path)
|
|
82
|
+
self.dict_index = dict_index or {}
|
|
83
|
+
|
|
84
|
+
self.dates = DateShifter(self.secret, policy.date_max_days)
|
|
85
|
+
self.names = NameGenerator(self.secret, policy.lang, policy.extra_name_files)
|
|
86
|
+
self.surrogates = SurrogateGenerator(self.secret)
|
|
87
|
+
self.detector = Detector(
|
|
88
|
+
policy.lang,
|
|
89
|
+
policy.mode,
|
|
90
|
+
policy.spacy_model,
|
|
91
|
+
policy.extra_name_files,
|
|
92
|
+
policy.extra_stoplist_files,
|
|
93
|
+
policy.id_checksums,
|
|
94
|
+
policy.use_medical_vocab,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# audit state
|
|
98
|
+
self.changes: List[Dict[str, Any]] = [] # detailed before/after rows
|
|
99
|
+
self.review: List[Dict[str, Any]] = [] # detections not auto-replaced
|
|
100
|
+
self.plans: Dict[str, Dict[str, schema.PlanItem]] = {}
|
|
101
|
+
self.stats: Dict[str, int] = defaultdict(int)
|
|
102
|
+
self._structured_counts: Dict[tuple, int] = defaultdict(int)
|
|
103
|
+
self.max_records = max_records
|
|
104
|
+
self.max_structured_samples = max_structured_samples
|
|
105
|
+
self.truncated = False
|
|
106
|
+
|
|
107
|
+
# ------------------------------------------------------------------ #
|
|
108
|
+
def plan_for(self, table: str, columns: List[str], dtypes: Dict[str, str]) -> Dict[str, schema.PlanItem]:
|
|
109
|
+
plan = schema.plan_table(self.policy, table, columns, dtypes, self.dict_index)
|
|
110
|
+
self.plans[table] = plan
|
|
111
|
+
return plan
|
|
112
|
+
|
|
113
|
+
def plan_dataframe(self, df: pd.DataFrame, table: str) -> Dict[str, schema.PlanItem]:
|
|
114
|
+
"""Plan every column, then content-sniff undetermined object columns for
|
|
115
|
+
dates (catches ``encounter_start`` etc. that name heuristics miss)."""
|
|
116
|
+
dtypes = dict(zip(df.columns, (str(t) for t in df.dtypes)))
|
|
117
|
+
plan = schema.plan_table(self.policy, table, list(df.columns), dtypes, self.dict_index)
|
|
118
|
+
for col, item in plan.items():
|
|
119
|
+
if item.source == "default" and dtypes.get(col) in ("object", "string"):
|
|
120
|
+
col_data = df[col]
|
|
121
|
+
if isinstance(col_data, pd.DataFrame): # duplicate column name
|
|
122
|
+
continue
|
|
123
|
+
if not self.policy.is_ignored(col) and self._looks_like_dates(col_data):
|
|
124
|
+
plan[col] = schema.PlanItem(col, DATE_SHIFT, "content:date")
|
|
125
|
+
self.plans[table] = plan
|
|
126
|
+
return plan
|
|
127
|
+
|
|
128
|
+
_YEAR_ONLY = r"\d{4}"
|
|
129
|
+
_TIME_ONLY = r"\d{1,2}:\d{2}(:\d{2})?(\s?[APap]\.?[Mm]\.?)?"
|
|
130
|
+
_DATE_SHAPE = (
|
|
131
|
+
r"\d{1,4}[-/.]\d{1,2}[-/.]\d{1,4}" # 2021-06-15 / 15/06/2021
|
|
132
|
+
r"|\d{1,2}[-/ ][A-Za-z]{3,}[-/ ]\d{2,4}" # 05-ABR-1988
|
|
133
|
+
r"|[A-Za-z]{3,}\.?\s+\d{1,2},?\s+\d{4}" # March 5, 2021
|
|
134
|
+
r"|\bde\s+[A-Za-zÁÉÍÓÚáéíóúñ]+\s+(?:de\s+)?\d{4}" # 5 de marzo de 2021
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
@staticmethod
|
|
138
|
+
def _looks_like_dates(series: pd.Series) -> bool:
|
|
139
|
+
sample = series.dropna().astype(str).str.strip()
|
|
140
|
+
sample = sample[sample != ""].head(200)
|
|
141
|
+
if len(sample) < 1:
|
|
142
|
+
return False
|
|
143
|
+
# NOT a date column: year-only ints (birth_year) or time-only (dose_time)
|
|
144
|
+
if sample.str.fullmatch(Deidentifier._YEAR_ONLY).mean() > 0.8:
|
|
145
|
+
return False
|
|
146
|
+
if sample.str.fullmatch(Deidentifier._TIME_ONLY).mean() > 0.8:
|
|
147
|
+
return False
|
|
148
|
+
# must actually look like calendar dates (not just contain a separator)
|
|
149
|
+
if sample.str.contains(Deidentifier._DATE_SHAPE, regex=True).mean() < 0.7:
|
|
150
|
+
return False
|
|
151
|
+
parsed = DateShifter._parse_datetime(sample)
|
|
152
|
+
return bool(parsed.notna().mean() >= 0.6)
|
|
153
|
+
|
|
154
|
+
# ------------------------------------------------------------------ #
|
|
155
|
+
def run_table(self, df: pd.DataFrame, table: str = "table") -> pd.DataFrame:
|
|
156
|
+
"""Return a de-identified copy of ``df`` and record all changes."""
|
|
157
|
+
dups = df.columns[df.columns.duplicated()].unique().tolist()
|
|
158
|
+
if dups:
|
|
159
|
+
raise ValueError(
|
|
160
|
+
f"table {table!r} has duplicate column names {dups}; "
|
|
161
|
+
f"rename them before de-identifying (they are ambiguous)."
|
|
162
|
+
)
|
|
163
|
+
plan = self.plan_dataframe(df, table)
|
|
164
|
+
|
|
165
|
+
# Work on a positionally-indexed copy so a duplicated DataFrame index
|
|
166
|
+
# (e.g. from pd.concat) can't make `.at[label]` return a Series and crash.
|
|
167
|
+
orig_index = df.index
|
|
168
|
+
out = df.copy()
|
|
169
|
+
out.index = pd.RangeIndex(len(out))
|
|
170
|
+
entity_key = self.policy.entity_key
|
|
171
|
+
if entity_key in out.columns:
|
|
172
|
+
entity_ids = out[entity_key]
|
|
173
|
+
else:
|
|
174
|
+
entity_ids = pd.Series(out.index, index=out.index)
|
|
175
|
+
|
|
176
|
+
# Known-PHI propagation: snapshot the ORIGINAL known-name/id columns
|
|
177
|
+
# (positionally aligned to `out`) BEFORE they get pseudonymized.
|
|
178
|
+
kn_cols = [c for c in self.policy.known_name_columns if c in df.columns]
|
|
179
|
+
ki_cols = [c for c in self.policy.known_id_columns if c in df.columns]
|
|
180
|
+
known_names_df = df[kn_cols].reset_index(drop=True) if kn_cols else None
|
|
181
|
+
known_ids_df = df[ki_cols].reset_index(drop=True) if ki_cols else None
|
|
182
|
+
|
|
183
|
+
drop_cols: List[str] = []
|
|
184
|
+
for col, item in plan.items():
|
|
185
|
+
strat = item.strategy
|
|
186
|
+
if strat in (PASSTHROUGH,):
|
|
187
|
+
continue
|
|
188
|
+
self.stats[f"columns:{strat}"] += 1
|
|
189
|
+
if strat == DROP:
|
|
190
|
+
drop_cols.append(col)
|
|
191
|
+
continue
|
|
192
|
+
if strat == DATE_SHIFT:
|
|
193
|
+
out[col] = self._apply_date_shift(out[col], entity_ids, table, col)
|
|
194
|
+
elif strat == SYNTHETIC_NAME:
|
|
195
|
+
out[col] = self._apply_name(out[col], table, col)
|
|
196
|
+
elif strat == IDENTIFIER:
|
|
197
|
+
out[col] = self._apply_identifier(out[col], table, col)
|
|
198
|
+
elif strat == FREETEXT:
|
|
199
|
+
out[col] = self._apply_freetext(
|
|
200
|
+
out[col], entity_ids, table, col, known_names_df, known_ids_df
|
|
201
|
+
)
|
|
202
|
+
elif strat == REDACT:
|
|
203
|
+
out[col] = self._apply_redact(out[col], table, col)
|
|
204
|
+
elif strat == GENERALIZE_YEAR:
|
|
205
|
+
out[col] = self._apply_generalize_year(out[col], table, col)
|
|
206
|
+
elif strat == AGE_BAND:
|
|
207
|
+
out[col] = self._apply_age_band(out[col], item.options, table, col)
|
|
208
|
+
|
|
209
|
+
if drop_cols:
|
|
210
|
+
out = out.drop(columns=drop_cols)
|
|
211
|
+
out.index = orig_index # restore the caller's original index
|
|
212
|
+
self.stats[f"rows:{table}"] += len(df)
|
|
213
|
+
return out
|
|
214
|
+
|
|
215
|
+
def run_dataset(self, tables: Dict[str, pd.DataFrame]) -> Dict[str, pd.DataFrame]:
|
|
216
|
+
return {name: self.run_table(df, name) for name, df in tables.items()}
|
|
217
|
+
|
|
218
|
+
# ------------------------------------------------------------------ #
|
|
219
|
+
# strategy implementations
|
|
220
|
+
# ------------------------------------------------------------------ #
|
|
221
|
+
def _record(self, table, column, row_id, strategy, entity_type, before, after,
|
|
222
|
+
methods="", confidence=""):
|
|
223
|
+
self.stats["cells_changed"] += 1
|
|
224
|
+
self.stats[f"entity:{entity_type}"] += 1
|
|
225
|
+
if len(self.changes) >= self.max_records:
|
|
226
|
+
self.truncated = True
|
|
227
|
+
return
|
|
228
|
+
self.changes.append(
|
|
229
|
+
{
|
|
230
|
+
"table": table,
|
|
231
|
+
"column": column,
|
|
232
|
+
"row_id": row_id,
|
|
233
|
+
"strategy": strategy,
|
|
234
|
+
"entity_type": entity_type,
|
|
235
|
+
"before": before,
|
|
236
|
+
"after": after,
|
|
237
|
+
"methods": methods,
|
|
238
|
+
"confidence": confidence,
|
|
239
|
+
}
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
def _log_structured(self, table, column) -> bool:
|
|
243
|
+
"""Rate-limit structured before/after samples per column (report size)."""
|
|
244
|
+
key = (table, column)
|
|
245
|
+
self._structured_counts[key] += 1
|
|
246
|
+
return self._structured_counts[key] <= self.max_structured_samples
|
|
247
|
+
|
|
248
|
+
@staticmethod
|
|
249
|
+
def _isna(v) -> bool:
|
|
250
|
+
if v is None:
|
|
251
|
+
return True
|
|
252
|
+
try:
|
|
253
|
+
return bool(pd.isna(v))
|
|
254
|
+
except (TypeError, ValueError):
|
|
255
|
+
return False
|
|
256
|
+
|
|
257
|
+
# --- date shift --------------------------------------------------- #
|
|
258
|
+
def _apply_date_shift(self, series, entity_ids, table, col):
|
|
259
|
+
# Year-only (birth_year) and time-only (dose_time) columns must NOT be
|
|
260
|
+
# day-shifted — that fabricates/corrupts. If one reached here via a name
|
|
261
|
+
# heuristic, leave it untouched.
|
|
262
|
+
if not pd.api.types.is_datetime64_any_dtype(series):
|
|
263
|
+
s = series.dropna().astype(str).str.strip()
|
|
264
|
+
s = s[s != ""].head(200)
|
|
265
|
+
if len(s) and (
|
|
266
|
+
s.str.fullmatch(self._YEAR_ONLY).mean() > 0.8
|
|
267
|
+
or s.str.fullmatch(self._TIME_ONLY).mean() > 0.8
|
|
268
|
+
):
|
|
269
|
+
return series
|
|
270
|
+
|
|
271
|
+
new = self.dates.shift_series(series, entity_ids)
|
|
272
|
+
out = []
|
|
273
|
+
for o, n, e in zip(series.tolist(), new.tolist(), entity_ids.tolist()):
|
|
274
|
+
if self._isna(o):
|
|
275
|
+
out.append(o)
|
|
276
|
+
continue
|
|
277
|
+
o_s = str(o)
|
|
278
|
+
if not self._isna(n) and o_s != str(n):
|
|
279
|
+
out.append(n)
|
|
280
|
+
self.stats["cells_changed"] += 1
|
|
281
|
+
self.stats["entity:DATE"] += 1
|
|
282
|
+
if self._log_structured(table, col) and len(self.changes) < self.max_records:
|
|
283
|
+
self.changes.append({
|
|
284
|
+
"table": table, "column": col, "row_id": e,
|
|
285
|
+
"strategy": DATE_SHIFT, "entity_type": "DATE",
|
|
286
|
+
"before": o_s, "after": str(n), "methods": "date-offset", "confidence": "",
|
|
287
|
+
})
|
|
288
|
+
else:
|
|
289
|
+
# non-empty value in a DATE column we could not parse -> REDACT.
|
|
290
|
+
# Passing it through could leak a real date in an odd format.
|
|
291
|
+
out.append("[DATE]")
|
|
292
|
+
self.stats["cells_changed"] += 1
|
|
293
|
+
self.stats["entity:DATE_REDACTED"] += 1
|
|
294
|
+
if self._log_structured(table, col) and len(self.changes) < self.max_records:
|
|
295
|
+
self.changes.append({
|
|
296
|
+
"table": table, "column": col, "row_id": e,
|
|
297
|
+
"strategy": DATE_SHIFT, "entity_type": "DATE",
|
|
298
|
+
"before": o_s, "after": "[DATE]", "methods": "redact-unparsed", "confidence": "",
|
|
299
|
+
})
|
|
300
|
+
return pd.Series(out, index=series.index)
|
|
301
|
+
|
|
302
|
+
# --- person surrogate (shared by structured names AND free text) --- #
|
|
303
|
+
def _person_surrogate(self, source: str, gender: str = "u") -> str:
|
|
304
|
+
"""Return the pseudonym for a person, consistent across every surface.
|
|
305
|
+
|
|
306
|
+
One namespace (``PERSON``) is used for structured name columns and for
|
|
307
|
+
names found in free text, so the same real person gets the SAME synthetic
|
|
308
|
+
identity whether they appear in a name field or inside a note
|
|
309
|
+
(referential integrity). The visible rendering follows ``name_style``.
|
|
310
|
+
"""
|
|
311
|
+
from .config import NAME_PLACEHOLDER, NAME_REALISTIC, NAME_TAGGED, NAME_TOKEN, TAG_MARKERS
|
|
312
|
+
from .secret import digest_int, short_token
|
|
313
|
+
|
|
314
|
+
lang = self.policy.primary_lang
|
|
315
|
+
style = self.policy.name_style
|
|
316
|
+
key = norm(source)
|
|
317
|
+
|
|
318
|
+
# placeholder / token are allocated THROUGH the injective mapping (attempt
|
|
319
|
+
# perturbation breaks any hash collision) so two different people can
|
|
320
|
+
# never share a surrogate.
|
|
321
|
+
if style == NAME_TOKEN:
|
|
322
|
+
return self.mapping.get_or_create(
|
|
323
|
+
"PERSON_TOKEN", key,
|
|
324
|
+
lambda nv, att: f"[NOMBRE_{short_token(self.secret, 'TOK|', nv, '|', str(att), length=6)}]",
|
|
325
|
+
)
|
|
326
|
+
if style == NAME_PLACEHOLDER:
|
|
327
|
+
role = {"es": "Persona", "en": "Person"}.get(lang, "Persona")
|
|
328
|
+
return self.mapping.get_or_create(
|
|
329
|
+
"PERSON_PLACEHOLDER", key,
|
|
330
|
+
lambda nv, att: f"{role} {digest_int(self.secret, 'CODE|', nv, '|', str(att)) % 9000000 + 1000000}",
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
# tagged / realistic: a stable, injective realistic base name identity.
|
|
334
|
+
base = self.mapping.get_or_create(
|
|
335
|
+
"PERSON", key, lambda nv, att: self.names.generate_base(nv, gender, att)
|
|
336
|
+
)
|
|
337
|
+
display = NameGenerator.case_like(source, base)
|
|
338
|
+
if style == NAME_TAGGED:
|
|
339
|
+
return f"{display} {TAG_MARKERS.get(lang, '[SINTÉTICO]')}"
|
|
340
|
+
return display # NAME_REALISTIC
|
|
341
|
+
|
|
342
|
+
def _apply_name(self, series, table, col):
|
|
343
|
+
out = []
|
|
344
|
+
for idx, v in series.items():
|
|
345
|
+
if self._isna(v) or str(v).strip() == "":
|
|
346
|
+
out.append(v)
|
|
347
|
+
continue
|
|
348
|
+
src = str(v)
|
|
349
|
+
new_v = self._person_surrogate(src, "u")
|
|
350
|
+
out.append(new_v)
|
|
351
|
+
if new_v != src:
|
|
352
|
+
self._record(table, col, idx, SYNTHETIC_NAME, "PERSON", src, new_v)
|
|
353
|
+
return pd.Series(out, index=series.index)
|
|
354
|
+
|
|
355
|
+
# --- identifier column ------------------------------------------- #
|
|
356
|
+
@staticmethod
|
|
357
|
+
def _mostly_numeric(src: str) -> bool:
|
|
358
|
+
s = src.strip()
|
|
359
|
+
digits = sum(c.isdigit() for c in s)
|
|
360
|
+
return digits >= 4 and digits >= len(s) - 2
|
|
361
|
+
|
|
362
|
+
def _apply_identifier(self, series, table, col):
|
|
363
|
+
out = []
|
|
364
|
+
for idx, v in series.items():
|
|
365
|
+
if self._isna(v) or str(v).strip() == "":
|
|
366
|
+
out.append(v)
|
|
367
|
+
continue
|
|
368
|
+
src = str(v)
|
|
369
|
+
if self._mostly_numeric(src):
|
|
370
|
+
# unify with the free-text NATIONAL_ID namespace + digit key, so
|
|
371
|
+
# a cédula maps to the SAME surrogate in `cedula`, `documento`,
|
|
372
|
+
# dotted form, and inside a note (joins survive).
|
|
373
|
+
digits = "".join(c for c in src if c.isdigit())
|
|
374
|
+
new_v = self.mapping.get_or_create(
|
|
375
|
+
"NATIONAL_ID", digits, lambda nv, att: self.surrogates.national_id(nv, att)
|
|
376
|
+
)
|
|
377
|
+
etype = "NATIONAL_ID"
|
|
378
|
+
else:
|
|
379
|
+
new_v = self.mapping.get_or_create(
|
|
380
|
+
"ID_GENERIC", src, lambda nv, att: self.surrogates.generic_id(nv, col, att)
|
|
381
|
+
)
|
|
382
|
+
etype = "ID"
|
|
383
|
+
out.append(new_v)
|
|
384
|
+
if new_v != src:
|
|
385
|
+
self._record(table, col, idx, IDENTIFIER, etype, src, new_v)
|
|
386
|
+
return pd.Series(out, index=series.index)
|
|
387
|
+
|
|
388
|
+
# --- free text (the multi-stage detector + known-PHI) ------------- #
|
|
389
|
+
def _apply_freetext(self, series, entity_ids, table, col,
|
|
390
|
+
known_names_df=None, known_ids_df=None):
|
|
391
|
+
extra_names = self.policy.extra_known_names
|
|
392
|
+
out = []
|
|
393
|
+
for idx, v in series.items():
|
|
394
|
+
if self._isna(v) or str(v).strip() == "":
|
|
395
|
+
out.append(v)
|
|
396
|
+
continue
|
|
397
|
+
known_names = list(extra_names)
|
|
398
|
+
if known_names_df is not None:
|
|
399
|
+
known_names += [str(x) for x in known_names_df.iloc[idx]
|
|
400
|
+
if not self._isna(x) and str(x).strip()]
|
|
401
|
+
known_ids = []
|
|
402
|
+
if known_ids_df is not None:
|
|
403
|
+
known_ids += [str(x) for x in known_ids_df.iloc[idx]
|
|
404
|
+
if not self._isna(x) and str(x).strip()]
|
|
405
|
+
out.append(self._scrub_text(
|
|
406
|
+
str(v), entity_ids.at[idx], table, col, idx, known_names, known_ids))
|
|
407
|
+
return pd.Series(out, index=series.index)
|
|
408
|
+
|
|
409
|
+
def _known_spans(self, text, known_names, known_ids) -> List[Detection]:
|
|
410
|
+
"""Deterministic spans for values we already KNOW belong to this row
|
|
411
|
+
(from structured columns / a registry) — near-100% recall, no guessing."""
|
|
412
|
+
spans: List[Detection] = []
|
|
413
|
+
stop = self.detector.gaz.stoplist
|
|
414
|
+
for raw in known_names:
|
|
415
|
+
name = raw.strip()
|
|
416
|
+
if len(name) < 2:
|
|
417
|
+
continue
|
|
418
|
+
toks = name.split()
|
|
419
|
+
# full name (flexible whitespace, word boundaries, case-insensitive)
|
|
420
|
+
full = r"(?<!\w)" + r"\s+".join(re.escape(t) for t in toks) + r"(?!\w)"
|
|
421
|
+
for m in re.finditer(full, text, re.IGNORECASE):
|
|
422
|
+
spans.append(Detection(m.start(), m.end(), m.group(0), PERSON,
|
|
423
|
+
{"known-pii"}, confidence=1.0, accepted=True))
|
|
424
|
+
# lone tokens: only long, non-stopword ones (avoid over-redacting
|
|
425
|
+
# short/common surnames like "Cruz"/"León"). Each lone token maps to
|
|
426
|
+
# the CORRESPONDING token of this person's full surrogate, so partial
|
|
427
|
+
# mentions stay consistent and keep the right given/surname shape.
|
|
428
|
+
full_sur = self._person_surrogate(name)
|
|
429
|
+
surr_toks = full_sur.split()
|
|
430
|
+
token_level = self.policy.name_style in ("realistic", "tagged")
|
|
431
|
+
for i, tok in enumerate(toks):
|
|
432
|
+
if len(tok) < self.policy.known_token_min_len or norm(tok) in stop:
|
|
433
|
+
continue
|
|
434
|
+
if token_level and i < len(toks):
|
|
435
|
+
repl = surr_toks[i] if i < len(surr_toks) else (surr_toks[-1] if surr_toks else tok)
|
|
436
|
+
else: # placeholder / token: a lone mention -> the whole identity
|
|
437
|
+
repl = full_sur
|
|
438
|
+
for m in re.finditer(r"(?<!\w)" + re.escape(tok) + r"(?!\w)", text, re.IGNORECASE):
|
|
439
|
+
spans.append(Detection(m.start(), m.end(), m.group(0), PERSON,
|
|
440
|
+
{"known-pii"}, confidence=1.0, accepted=True,
|
|
441
|
+
meta={"surrogate": repl}))
|
|
442
|
+
for raw in known_ids:
|
|
443
|
+
digits = "".join(c for c in raw if c.isdigit())
|
|
444
|
+
if len(digits) < 4:
|
|
445
|
+
continue
|
|
446
|
+
variants = {raw.strip(), digits}
|
|
447
|
+
grouped = _group_thousands(digits) # 12345678 -> 12.345.678
|
|
448
|
+
if grouped:
|
|
449
|
+
variants.add(grouped)
|
|
450
|
+
for v in variants:
|
|
451
|
+
for m in re.finditer(r"(?<!\d)" + re.escape(v) + r"(?!\d)", text):
|
|
452
|
+
spans.append(Detection(m.start(), m.end(), m.group(0), NATIONAL_ID,
|
|
453
|
+
{"known-pii"}, confidence=1.0, accepted=True))
|
|
454
|
+
return spans
|
|
455
|
+
|
|
456
|
+
def _scrub_text(self, text, entity_id, table, col, row_id, known_names=(), known_ids=()):
|
|
457
|
+
# Match the detector's NFC normalization so replacement offsets align and
|
|
458
|
+
# decomposed (NFD) accents cannot leave a leaked fragment.
|
|
459
|
+
text = unicodedata.normalize("NFC", text)
|
|
460
|
+
dets = self.detector.detect(text)
|
|
461
|
+
accepted, review = [], []
|
|
462
|
+
for d in dets:
|
|
463
|
+
if d.entity_type == DATE_ENTITY and not self.policy.shift_dates_in_text:
|
|
464
|
+
continue
|
|
465
|
+
(accepted if d.accepted else review).append(d)
|
|
466
|
+
|
|
467
|
+
for d in review:
|
|
468
|
+
self.stats["review_detections"] += 1
|
|
469
|
+
if len(self.review) < self.max_records:
|
|
470
|
+
self.review.append({
|
|
471
|
+
"table": table, "column": col, "row_id": row_id,
|
|
472
|
+
"entity_type": d.entity_type, "text": d.text,
|
|
473
|
+
"confidence": round(d.confidence, 2),
|
|
474
|
+
"methods": "+".join(sorted(d.methods)),
|
|
475
|
+
"context": _snippet(text, d.start, d.end),
|
|
476
|
+
})
|
|
477
|
+
|
|
478
|
+
accepted += self._known_spans(text, known_names, known_ids)
|
|
479
|
+
if not accepted:
|
|
480
|
+
return text
|
|
481
|
+
|
|
482
|
+
# resolve overlaps: earliest start, longest span, and known-PHI wins ties
|
|
483
|
+
# (it is authoritative and its surrogate stays consistent with the full name).
|
|
484
|
+
accepted.sort(key=lambda d: (d.start, -(d.end - d.start),
|
|
485
|
+
0 if "known-pii" in d.methods else 1))
|
|
486
|
+
kept, occupied = [], -1
|
|
487
|
+
for d in accepted:
|
|
488
|
+
if d.start >= occupied:
|
|
489
|
+
kept.append(d)
|
|
490
|
+
occupied = d.end
|
|
491
|
+
|
|
492
|
+
new_text = text
|
|
493
|
+
for d in sorted(kept, key=lambda x: x.start, reverse=True):
|
|
494
|
+
surrogate = self._surrogate_for_text(d, entity_id)
|
|
495
|
+
if surrogate is None:
|
|
496
|
+
# A DATE we detected but could not shift (odd format) must never be
|
|
497
|
+
# left in the clear -> redact it. Other unresolved spans are left.
|
|
498
|
+
if d.entity_type == DATE_ENTITY:
|
|
499
|
+
surrogate = "[DATE]"
|
|
500
|
+
else:
|
|
501
|
+
continue
|
|
502
|
+
new_text = new_text[: d.start] + surrogate + new_text[d.end :]
|
|
503
|
+
self._record(
|
|
504
|
+
table, col, row_id, FREETEXT, d.entity_type, d.text, surrogate,
|
|
505
|
+
methods="+".join(sorted(d.methods)), confidence=round(d.confidence, 2),
|
|
506
|
+
)
|
|
507
|
+
return new_text
|
|
508
|
+
|
|
509
|
+
def _surrogate_for_text(self, d, entity_id):
|
|
510
|
+
et = d.entity_type
|
|
511
|
+
surr = self.surrogates
|
|
512
|
+
if d.meta.get("surrogate") is not None: # precomputed (known-PHI lone token)
|
|
513
|
+
return d.meta["surrogate"]
|
|
514
|
+
if et == "PERSON":
|
|
515
|
+
return self._person_surrogate(d.text, d.meta.get("gender", "u"))
|
|
516
|
+
if et == "EMAIL":
|
|
517
|
+
return self.mapping.get_or_create("EMAIL", d.text.lower(), surr.email)
|
|
518
|
+
if et == "PHONE":
|
|
519
|
+
return self.mapping.get_or_create("PHONE", _digits(d.text), surr.phone)
|
|
520
|
+
if et == "NATIONAL_ID":
|
|
521
|
+
return self.mapping.get_or_create("NATIONAL_ID", _digits(d.text), surr.national_id)
|
|
522
|
+
if et == "MRN":
|
|
523
|
+
return self.mapping.get_or_create("MRN", _digits(d.text), surr.mrn)
|
|
524
|
+
if et == "URL":
|
|
525
|
+
return self.mapping.get_or_create("URL", d.text, surr.url)
|
|
526
|
+
if et == "IP":
|
|
527
|
+
return self.mapping.get_or_create("IP", d.text, surr.ip)
|
|
528
|
+
if et == "DATE":
|
|
529
|
+
return self.dates.shift_fragment(d.text, entity_id) # None if unparseable
|
|
530
|
+
return None
|
|
531
|
+
|
|
532
|
+
# --- simple transforms ------------------------------------------- #
|
|
533
|
+
def _apply_redact(self, series, table, col):
|
|
534
|
+
out = []
|
|
535
|
+
for idx, v in series.items():
|
|
536
|
+
if self._isna(v):
|
|
537
|
+
out.append(v)
|
|
538
|
+
continue
|
|
539
|
+
out.append("[REDACTED]")
|
|
540
|
+
if self._log_structured(table, col):
|
|
541
|
+
self._record(table, col, idx, REDACT, "REDACT", str(v), "[REDACTED]")
|
|
542
|
+
return pd.Series(out, index=series.index)
|
|
543
|
+
|
|
544
|
+
def _apply_generalize_year(self, series, table, col):
|
|
545
|
+
s = pd.to_datetime(series, errors="coerce")
|
|
546
|
+
out = []
|
|
547
|
+
for idx, (orig, dt) in enumerate(zip(series, s)):
|
|
548
|
+
if pd.isna(dt):
|
|
549
|
+
out.append(orig)
|
|
550
|
+
continue
|
|
551
|
+
year = str(int(dt.year)) # string: mixed int/str breaks Parquet writes
|
|
552
|
+
out.append(year)
|
|
553
|
+
if self._log_structured(table, col):
|
|
554
|
+
self._record(table, col, series.index[idx], GENERALIZE_YEAR, "DATE", str(orig), year)
|
|
555
|
+
return pd.Series(out, index=series.index)
|
|
556
|
+
|
|
557
|
+
def _apply_age_band(self, series, options, table, col):
|
|
558
|
+
cap = int(options.get("cap", 90))
|
|
559
|
+
width = int(options.get("width", 10))
|
|
560
|
+
out = []
|
|
561
|
+
for idx, v in series.items():
|
|
562
|
+
if self._isna(v):
|
|
563
|
+
out.append(v)
|
|
564
|
+
continue
|
|
565
|
+
try:
|
|
566
|
+
fv = float(v)
|
|
567
|
+
if not math.isfinite(fv): # 'inf'/'nan'/'1e400' -> not an age
|
|
568
|
+
out.append(v)
|
|
569
|
+
continue
|
|
570
|
+
age = int(fv)
|
|
571
|
+
except (TypeError, ValueError, OverflowError):
|
|
572
|
+
out.append(v)
|
|
573
|
+
continue
|
|
574
|
+
if age >= cap:
|
|
575
|
+
band = f"{cap}+"
|
|
576
|
+
else:
|
|
577
|
+
lo = (age // width) * width
|
|
578
|
+
band = f"{lo}-{lo + width - 1}"
|
|
579
|
+
out.append(band)
|
|
580
|
+
if self._log_structured(table, col):
|
|
581
|
+
self._record(table, col, idx, AGE_BAND, "AGE", str(v), band)
|
|
582
|
+
return pd.Series(out, index=series.index)
|
|
583
|
+
|
|
584
|
+
# ------------------------------------------------------------------ #
|
|
585
|
+
def save_mapping(self, path: Optional[str] = None) -> None:
|
|
586
|
+
target = path or self.mapping_path
|
|
587
|
+
if target:
|
|
588
|
+
self.mapping.save(target)
|
|
589
|
+
|
|
590
|
+
def summary(self) -> Dict[str, Any]:
|
|
591
|
+
return {
|
|
592
|
+
"cells_changed": self.stats.get("cells_changed", 0),
|
|
593
|
+
"review_detections": self.stats.get("review_detections", 0),
|
|
594
|
+
"mapping_entries": self.mapping.size(),
|
|
595
|
+
"mapping_counts": self.mapping.counts(),
|
|
596
|
+
"entity_counts": {
|
|
597
|
+
k.split(":", 1)[1]: v for k, v in self.stats.items() if k.startswith("entity:")
|
|
598
|
+
},
|
|
599
|
+
"detailed_records_truncated": self.truncated,
|
|
600
|
+
"ner_status": self.detector.ner_status,
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
def write_report(self, path: str) -> None:
|
|
604
|
+
from . import report
|
|
605
|
+
|
|
606
|
+
report.write_report(path, self)
|
|
607
|
+
|
|
608
|
+
def write_review(self, path: str) -> int:
|
|
609
|
+
"""Write the review queue as an editable CSV. A human sets the first
|
|
610
|
+
column to ``y`` (real PII -> learn as a name) or ``n`` (false positive ->
|
|
611
|
+
learn as a stopword); ``deidkit learn`` then folds those decisions back
|
|
612
|
+
in. Returns the number of rows written."""
|
|
613
|
+
import csv
|
|
614
|
+
|
|
615
|
+
with open(path, "w", newline="", encoding="utf-8") as fh:
|
|
616
|
+
w = csv.writer(fh)
|
|
617
|
+
w.writerow(["decision(y/n)", "entity_type", "candidate", "context",
|
|
618
|
+
"table", "column", "row_id", "confidence", "detectors"])
|
|
619
|
+
for r in self.review:
|
|
620
|
+
w.writerow(["", r["entity_type"], r["text"], r["context"], r["table"],
|
|
621
|
+
r["column"], r["row_id"], r["confidence"], r["methods"]])
|
|
622
|
+
return len(self.review)
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _digits(s: str) -> str:
|
|
626
|
+
return "".join(c for c in str(s) if c.isdigit()) or str(s)
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _group_thousands(digits: str) -> Optional[str]:
|
|
630
|
+
"""12345678 -> '12.345.678' (the dotted cédula form), else None."""
|
|
631
|
+
if len(digits) <= 3:
|
|
632
|
+
return None
|
|
633
|
+
out = []
|
|
634
|
+
for i, ch in enumerate(reversed(digits)):
|
|
635
|
+
if i and i % 3 == 0:
|
|
636
|
+
out.append(".")
|
|
637
|
+
out.append(ch)
|
|
638
|
+
return "".join(reversed(out))
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _snippet(text: str, start: int, end: int, pad: int = 25) -> str:
|
|
642
|
+
a = max(0, start - pad)
|
|
643
|
+
b = min(len(text), end + pad)
|
|
644
|
+
return ("…" if a > 0 else "") + text[a:b].replace("\n", " ") + ("…" if b < len(text) else "")
|