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.
Files changed (45) hide show
  1. deidkit/__init__.py +74 -0
  2. deidkit/__main__.py +6 -0
  3. deidkit/api.py +316 -0
  4. deidkit/cli.py +399 -0
  5. deidkit/config.py +285 -0
  6. deidkit/data/NOTICES.md +25 -0
  7. deidkit/data/context_triggers_en.txt +17 -0
  8. deidkit/data/context_triggers_es.txt +36 -0
  9. deidkit/data/en_given_names.txt +203 -0
  10. deidkit/data/en_surnames.txt +118 -0
  11. deidkit/data/es_given_names.txt +273 -0
  12. deidkit/data/es_surnames.txt +168 -0
  13. deidkit/data/honorifics_en.txt +22 -0
  14. deidkit/data/honorifics_es.txt +35 -0
  15. deidkit/data/medical_stoplist_en.txt +345 -0
  16. deidkit/data/medical_stoplist_es.txt +499 -0
  17. deidkit/data/medical_vocab.txt +21445 -0
  18. deidkit/dates.py +229 -0
  19. deidkit/detect/__init__.py +19 -0
  20. deidkit/detect/checksums.py +160 -0
  21. deidkit/detect/context.py +137 -0
  22. deidkit/detect/gazetteer.py +158 -0
  23. deidkit/detect/patterns.py +147 -0
  24. deidkit/detect/pipeline.py +234 -0
  25. deidkit/detect/spacy_ner.py +53 -0
  26. deidkit/detect/types.py +53 -0
  27. deidkit/engine.py +644 -0
  28. deidkit/generators/__init__.py +4 -0
  29. deidkit/generators/names.py +111 -0
  30. deidkit/generators/surrogates.py +76 -0
  31. deidkit/io.py +184 -0
  32. deidkit/learn.py +83 -0
  33. deidkit/mapping.py +108 -0
  34. deidkit/report.py +219 -0
  35. deidkit/resources.py +100 -0
  36. deidkit/schema.py +234 -0
  37. deidkit/secret.py +77 -0
  38. deidkit/textnorm.py +66 -0
  39. deidkit/version.py +3 -0
  40. deidkit-0.1.0.dist-info/METADATA +768 -0
  41. deidkit-0.1.0.dist-info/RECORD +45 -0
  42. deidkit-0.1.0.dist-info/WHEEL +5 -0
  43. deidkit-0.1.0.dist-info/entry_points.txt +2 -0
  44. deidkit-0.1.0.dist-info/licenses/LICENSE +21 -0
  45. 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 "")
@@ -0,0 +1,4 @@
1
+ """Surrogate value generators (deterministic, keyed by the secret)."""
2
+
3
+ from .names import NameGenerator # noqa: F401
4
+ from .surrogates import SurrogateGenerator # noqa: F401