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/cli.py
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
"""Command-line interface.
|
|
2
|
+
|
|
3
|
+
deidkit init-secret --out secret.key
|
|
4
|
+
deidkit plan --input data/ --schema dict.json
|
|
5
|
+
deidkit run --input data/ --out out/ --schema dict.json \
|
|
6
|
+
--secret-file secret.key --report audit.xlsx --ignore blood_type
|
|
7
|
+
deidkit scan --text "Paciente Juan Pérez, CC 1.234.567.890"
|
|
8
|
+
|
|
9
|
+
See ``deidkit <command> --help`` for all options.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
from typing import List, Optional
|
|
18
|
+
|
|
19
|
+
from . import io
|
|
20
|
+
from .config import (
|
|
21
|
+
ALL_MODES,
|
|
22
|
+
FieldRule,
|
|
23
|
+
Policy,
|
|
24
|
+
SYNTHETIC_NAME,
|
|
25
|
+
normalize_only_ignore,
|
|
26
|
+
)
|
|
27
|
+
from .engine import Deidentifier
|
|
28
|
+
from .schema import load_dictionary
|
|
29
|
+
from .secret import ENV_VAR, generate_secret
|
|
30
|
+
from .version import __version__
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _split_csv(value: Optional[str]) -> Optional[List[str]]:
|
|
34
|
+
if not value:
|
|
35
|
+
return None
|
|
36
|
+
return [v.strip() for v in value.split(",") if v.strip()]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_policy(args) -> Policy:
|
|
40
|
+
policy = Policy.from_yaml(args.policy) if getattr(args, "policy", None) else Policy()
|
|
41
|
+
if getattr(args, "lang", None):
|
|
42
|
+
policy.lang = args.lang
|
|
43
|
+
if getattr(args, "mode", None):
|
|
44
|
+
policy.mode = args.mode
|
|
45
|
+
if getattr(args, "name_style", None):
|
|
46
|
+
policy.name_style = args.name_style
|
|
47
|
+
if getattr(args, "entity_key", None):
|
|
48
|
+
policy.entity_key = args.entity_key
|
|
49
|
+
if getattr(args, "date_max_days", None) is not None:
|
|
50
|
+
policy.date_max_days = args.date_max_days
|
|
51
|
+
if getattr(args, "spacy_model", None):
|
|
52
|
+
policy.spacy_model = args.spacy_model
|
|
53
|
+
if getattr(args, "names_token", False):
|
|
54
|
+
policy.names_synthetic = False
|
|
55
|
+
if getattr(args, "no_date_text", False):
|
|
56
|
+
policy.shift_dates_in_text = False
|
|
57
|
+
if getattr(args, "no_medical_vocab", False):
|
|
58
|
+
policy.use_medical_vocab = False
|
|
59
|
+
frag = normalize_only_ignore(_split_csv(getattr(args, "only", None)),
|
|
60
|
+
_split_csv(getattr(args, "ignore", None)))
|
|
61
|
+
if "only" in frag:
|
|
62
|
+
policy.only = frag["only"]
|
|
63
|
+
if "ignore" in frag:
|
|
64
|
+
policy.ignore |= frag["ignore"]
|
|
65
|
+
# --name-column NAME can be repeated to force synthetic-name treatment
|
|
66
|
+
for col in getattr(args, "name_column", None) or []:
|
|
67
|
+
policy.rules.insert(0, FieldRule(column=col, strategy=SYNTHETIC_NAME))
|
|
68
|
+
policy.known_name_columns += getattr(args, "known_name_column", None) or []
|
|
69
|
+
policy.known_id_columns += getattr(args, "known_id_column", None) or []
|
|
70
|
+
policy.extra_known_names += getattr(args, "known_name", None) or []
|
|
71
|
+
policy.id_checksums += _split_csv(getattr(args, "id_checksums", None)) or []
|
|
72
|
+
policy.extra_name_files += getattr(args, "extra_name_file", None) or []
|
|
73
|
+
policy.extra_stoplist_files += getattr(args, "extra_stoplist_file", None) or []
|
|
74
|
+
learn_dir = getattr(args, "learn_dir", None)
|
|
75
|
+
if learn_dir:
|
|
76
|
+
from .learn import learned_files
|
|
77
|
+
|
|
78
|
+
name_files, stop_files = learned_files(learn_dir)
|
|
79
|
+
policy.extra_name_files += name_files
|
|
80
|
+
policy.extra_stoplist_files += stop_files
|
|
81
|
+
policy.__post_init__()
|
|
82
|
+
return policy
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# --------------------------------------------------------------------------- #
|
|
86
|
+
def cmd_init_secret(args) -> int:
|
|
87
|
+
secret = generate_secret()
|
|
88
|
+
if args.out:
|
|
89
|
+
with open(args.out, "w", encoding="utf-8") as fh:
|
|
90
|
+
fh.write(secret + "\n")
|
|
91
|
+
os.chmod(args.out, 0o600)
|
|
92
|
+
print(f"Wrote a new secret to {args.out} (chmod 600).")
|
|
93
|
+
print("Keep it private and stored separately from the delivered data.")
|
|
94
|
+
else:
|
|
95
|
+
print(secret)
|
|
96
|
+
return 0
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def cmd_plan(args) -> int:
|
|
100
|
+
policy = build_policy(args)
|
|
101
|
+
dict_index = load_dictionary(args.schema) if args.schema else {}
|
|
102
|
+
deid = Deidentifier(policy, secret="plan-only-not-used", dict_index=dict_index)
|
|
103
|
+
input_path = args.input or args.input_flag
|
|
104
|
+
if not input_path:
|
|
105
|
+
print("error: give an input path, e.g. `deidkit plan data/`", file=sys.stderr)
|
|
106
|
+
return 2
|
|
107
|
+
tables = io.read_tables(input_path)
|
|
108
|
+
print(f"{'table':<16} {'column':<32} {'strategy':<16} decided_by")
|
|
109
|
+
print("-" * 88)
|
|
110
|
+
for name, df in tables.items():
|
|
111
|
+
plan = deid.plan_dataframe(df, name)
|
|
112
|
+
for col, item in plan.items():
|
|
113
|
+
flag = "" if item.strategy == "passthrough" else " *"
|
|
114
|
+
print(f"{name:<16} {col:<32} {item.strategy:<16} {item.source}{flag}")
|
|
115
|
+
return 0
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def cmd_scan(args) -> int:
|
|
119
|
+
policy = build_policy(args)
|
|
120
|
+
deid = Deidentifier(policy, secret="scan-only")
|
|
121
|
+
if args.text:
|
|
122
|
+
text = args.text
|
|
123
|
+
elif args.file:
|
|
124
|
+
with open(args.file, encoding="utf-8") as fh:
|
|
125
|
+
text = fh.read()
|
|
126
|
+
else:
|
|
127
|
+
text = sys.stdin.read()
|
|
128
|
+
dets = deid.detector.detect(text)
|
|
129
|
+
print(f"NER: {deid.detector.ner_status}")
|
|
130
|
+
print(f"{'accepted':<9} {'type':<12} {'conf':<5} {'detectors':<22} text")
|
|
131
|
+
print("-" * 80)
|
|
132
|
+
for d in sorted(dets, key=lambda x: x.start):
|
|
133
|
+
mark = "REPLACE" if d.accepted else "review"
|
|
134
|
+
print(f"{mark:<9} {d.entity_type:<12} {d.confidence:<5.2f} "
|
|
135
|
+
f"{'+'.join(sorted(d.methods)):<22} {d.text!r}")
|
|
136
|
+
return 0
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def cmd_map(args) -> int:
|
|
140
|
+
"""Surface the private value->surrogate mapping (the re-identification key)."""
|
|
141
|
+
from .mapping import Mapping
|
|
142
|
+
|
|
143
|
+
if not os.path.exists(args.mapping):
|
|
144
|
+
print(f"error: mapping file not found: {args.mapping}", file=sys.stderr)
|
|
145
|
+
return 2
|
|
146
|
+
try:
|
|
147
|
+
mp = Mapping.load(args.mapping)
|
|
148
|
+
except (ValueError, OSError) as exc:
|
|
149
|
+
print(f"error: could not read mapping {args.mapping!r}: {exc}", file=sys.stderr)
|
|
150
|
+
return 2
|
|
151
|
+
rows = []
|
|
152
|
+
for etype in mp.entity_types():
|
|
153
|
+
for original, surrogate in mp.items(etype):
|
|
154
|
+
rows.append((etype, original, surrogate))
|
|
155
|
+
if args.out:
|
|
156
|
+
import csv
|
|
157
|
+
|
|
158
|
+
with open(args.out, "w", newline="", encoding="utf-8") as fh:
|
|
159
|
+
w = csv.writer(fh)
|
|
160
|
+
w.writerow(["entity_type", "original", "surrogate"])
|
|
161
|
+
w.writerows(rows)
|
|
162
|
+
print(f"PRIVATE mapping ({len(rows)} entries) -> {args.out}")
|
|
163
|
+
print("This is the re-identification key. Keep it out of any deliverable.")
|
|
164
|
+
else:
|
|
165
|
+
print("# PRIVATE re-identification key — do not share with the data")
|
|
166
|
+
print(f"{'entity_type':<16}{'original':<32}surrogate")
|
|
167
|
+
for etype, original, surrogate in rows:
|
|
168
|
+
print(f"{etype:<16}{str(original):<32}{surrogate}")
|
|
169
|
+
print(f"\n{len(rows)} entries")
|
|
170
|
+
return 0
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _default_out(input_path: str) -> str:
|
|
174
|
+
p = input_path.rstrip("/\\")
|
|
175
|
+
if os.path.isdir(p):
|
|
176
|
+
return p + "_deid"
|
|
177
|
+
if any(ch in p for ch in "*?["):
|
|
178
|
+
return "deid-output"
|
|
179
|
+
return os.path.splitext(p)[0] + "_deid"
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def cmd_learn(args) -> int:
|
|
183
|
+
"""Fold a human-edited review.csv into the learned dictionaries."""
|
|
184
|
+
from .learn import learn_from_review
|
|
185
|
+
|
|
186
|
+
if not os.path.exists(args.review):
|
|
187
|
+
print(f"error: review file not found: {args.review}", file=sys.stderr)
|
|
188
|
+
return 2
|
|
189
|
+
n_names, n_stop = learn_from_review(args.review, args.learn_dir)
|
|
190
|
+
print(f"learned {n_names} new name(s) and {n_stop} new stopword(s) -> {args.learn_dir}/")
|
|
191
|
+
print("Re-run with `--learn-dir " + args.learn_dir + "` to apply them.")
|
|
192
|
+
return 0
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def cmd_run_db(args) -> int:
|
|
196
|
+
from .api import deidentify_database
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
result = deidentify_database(
|
|
200
|
+
args.source, args.out_db,
|
|
201
|
+
tables=_split_csv(args.tables),
|
|
202
|
+
schema=args.schema,
|
|
203
|
+
out_schema=args.out_schema,
|
|
204
|
+
if_exists=args.if_exists,
|
|
205
|
+
policy=build_policy(args),
|
|
206
|
+
secret=args.secret,
|
|
207
|
+
secret_file=args.secret_file,
|
|
208
|
+
mapping=args.mapping,
|
|
209
|
+
private_dir=args.private_dir,
|
|
210
|
+
)
|
|
211
|
+
except (ValueError, TypeError, ImportError) as exc:
|
|
212
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
213
|
+
return 2
|
|
214
|
+
|
|
215
|
+
summ = result.summary
|
|
216
|
+
for name, df in result.tables.items():
|
|
217
|
+
print(f" {name:<20} {len(df):,} rows -> output DB")
|
|
218
|
+
print(f"\n OUTPUT DB -> {args.out_db} (de-identified, same structure)")
|
|
219
|
+
print(f" KEEP PRIVATE-> {result.private_dir}/ (secret + mapping + audit — NEVER share)")
|
|
220
|
+
print(f" cells changed : {summ['cells_changed']:,} | to review: {summ['review_detections']:,}")
|
|
221
|
+
return 0
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def cmd_run(args) -> int:
|
|
225
|
+
from .api import deidentify
|
|
226
|
+
|
|
227
|
+
input_path = args.input or args.input_flag
|
|
228
|
+
if not input_path:
|
|
229
|
+
print("error: give an input path, e.g. `deidkit run data/`", file=sys.stderr)
|
|
230
|
+
return 2
|
|
231
|
+
out = args.out or args.out_flag or _default_out(input_path)
|
|
232
|
+
|
|
233
|
+
try:
|
|
234
|
+
result = deidentify(
|
|
235
|
+
input_path,
|
|
236
|
+
out=out,
|
|
237
|
+
policy=build_policy(args),
|
|
238
|
+
schema=args.schema,
|
|
239
|
+
secret=args.secret,
|
|
240
|
+
secret_file=args.secret_file,
|
|
241
|
+
mapping=args.mapping,
|
|
242
|
+
report=args.report,
|
|
243
|
+
fmt=args.format,
|
|
244
|
+
quiet=True,
|
|
245
|
+
)
|
|
246
|
+
except (FileNotFoundError, ValueError, TypeError, ImportError) as exc:
|
|
247
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
248
|
+
return 2
|
|
249
|
+
|
|
250
|
+
summ = result.summary
|
|
251
|
+
for name, df in result.tables.items():
|
|
252
|
+
print(f" {name:<16} {len(df):,} rows")
|
|
253
|
+
print(f"\n SHIP THIS -> {result.out}/ (de-identified data only)")
|
|
254
|
+
print(f" KEEP PRIVATE-> {result.private_dir}/ (secret + mapping + audit — NEVER share)")
|
|
255
|
+
print(f" audit : {os.path.basename(result.report_path or '')}")
|
|
256
|
+
print(f" mapping : {os.path.basename(result.mapping_path or '')} (re-identification key)")
|
|
257
|
+
print(f" review : review.csv (mark y/n, then `deidkit learn`)")
|
|
258
|
+
if result.secret:
|
|
259
|
+
print(f" secret : deidkit-secret.key (KEEP — needed to re-run consistently)")
|
|
260
|
+
print(f"\n cells changed : {summ['cells_changed']:,}")
|
|
261
|
+
print(f" to review : {summ['review_detections']:,}")
|
|
262
|
+
print(f" detection : {summ['ner_status']} (deterministic rules; no AI by default)")
|
|
263
|
+
for etype, cnt in sorted(summ["entity_counts"].items(), key=lambda x: -x[1]):
|
|
264
|
+
print(f" {etype:<14} {cnt:,}")
|
|
265
|
+
return 0
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# --------------------------------------------------------------------------- #
|
|
269
|
+
def _add_policy_args(p):
|
|
270
|
+
p.add_argument("--policy", help="YAML policy file (CLI flags override it).")
|
|
271
|
+
p.add_argument("--lang", choices=["es", "en", "multi"],
|
|
272
|
+
help="Language(s) for names/detection ('multi' = ES+EN).")
|
|
273
|
+
p.add_argument("--mode", choices=sorted(ALL_MODES),
|
|
274
|
+
help="Free-text detection aggressiveness (default conservative).")
|
|
275
|
+
p.add_argument("--name-style", choices=["tagged", "placeholder", "token", "realistic"],
|
|
276
|
+
help="How fake names look (default tagged = obviously synthetic).")
|
|
277
|
+
p.add_argument("--only", help="Comma list: transform ONLY these columns.")
|
|
278
|
+
p.add_argument("--ignore", help="Comma list: never transform these columns.")
|
|
279
|
+
p.add_argument("--name-column", action="append",
|
|
280
|
+
help="Force a column to synthetic-name treatment (repeatable).")
|
|
281
|
+
p.add_argument("--entity-key", help="Column that groups date shifts (default patient_id).")
|
|
282
|
+
p.add_argument("--date-max-days", type=int, help="Date shift window in days (default 365).")
|
|
283
|
+
p.add_argument("--names-token", action="store_true",
|
|
284
|
+
help="Use [PERSON_x] tokens instead of realistic synthetic names.")
|
|
285
|
+
p.add_argument("--no-date-text", action="store_true",
|
|
286
|
+
help="Do not shift date strings found inside free text.")
|
|
287
|
+
p.add_argument("--spacy-model",
|
|
288
|
+
help="Enable spaCy NER stage with this model (e.g. es_core_news_lg).")
|
|
289
|
+
p.add_argument("--known-name-column", action="append",
|
|
290
|
+
help="Column whose value is a known person name to force-scrub "
|
|
291
|
+
"from that row's free text (repeatable).")
|
|
292
|
+
p.add_argument("--known-id-column", action="append",
|
|
293
|
+
help="Column whose value is a known identifier to force-scrub "
|
|
294
|
+
"from that row's free text (repeatable).")
|
|
295
|
+
p.add_argument("--known-name", action="append",
|
|
296
|
+
help="A known name (e.g. clinic staff) to scrub from ALL free text (repeatable).")
|
|
297
|
+
p.add_argument("--id-checksums",
|
|
298
|
+
help="Comma list of checksum-validated ID types to detect (bare, no cue): "
|
|
299
|
+
"CPF,CNPJ,CNS (Brazil), DNI,NIE (Spain), NHS (UK), CURP,RFC (Mexico), SSN, or ALL.")
|
|
300
|
+
p.add_argument("--extra-name-file", action="append",
|
|
301
|
+
help="Extra gazetteer file (one name per line) to merge (repeatable).")
|
|
302
|
+
p.add_argument("--extra-stoplist-file", action="append",
|
|
303
|
+
help="Extra stoplist file (words never treated as names) (repeatable).")
|
|
304
|
+
p.add_argument("--learn-dir",
|
|
305
|
+
help="Directory of learned dictionaries from `deidkit learn`; "
|
|
306
|
+
"auto-loads learned_names.txt + learned_stoplist.txt if present.")
|
|
307
|
+
p.add_argument("--no-medical-vocab", action="store_true",
|
|
308
|
+
help="Do not load the bundled medical safe-vocabulary (drug/terminology terms).")
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
312
|
+
parser = argparse.ArgumentParser(
|
|
313
|
+
prog="deidkit",
|
|
314
|
+
description="Schema-driven pseudonymization for clinical / tabular datasets.",
|
|
315
|
+
)
|
|
316
|
+
parser.add_argument("--version", action="version", version=f"deidkit {__version__}")
|
|
317
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
318
|
+
|
|
319
|
+
ps = sub.add_parser("init-secret", help="Generate a secret key.")
|
|
320
|
+
ps.add_argument("--out", help="Write the secret to this file (else print it).")
|
|
321
|
+
ps.set_defaults(func=cmd_init_secret)
|
|
322
|
+
|
|
323
|
+
pp = sub.add_parser("plan", help="Show the resolved field plan without transforming.")
|
|
324
|
+
pp.add_argument("input", nargs="?", help="File, glob, or directory of tables.")
|
|
325
|
+
pp.add_argument("--input", dest="input_flag", help=argparse.SUPPRESS)
|
|
326
|
+
pp.add_argument("--schema", help="Data-dictionary JSON.")
|
|
327
|
+
_add_policy_args(pp)
|
|
328
|
+
pp.set_defaults(func=cmd_plan)
|
|
329
|
+
|
|
330
|
+
pr = sub.add_parser(
|
|
331
|
+
"run", help="De-identify a dataset.",
|
|
332
|
+
description="De-identify a dataset. Simplest form: `deidkit run data/` "
|
|
333
|
+
"(auto output dir, auto secret, auto audit report).",
|
|
334
|
+
)
|
|
335
|
+
pr.add_argument("input", nargs="?", help="File, glob, or directory of tables.")
|
|
336
|
+
pr.add_argument("out", nargs="?", help="Output directory (default: <input>_deid).")
|
|
337
|
+
pr.add_argument("--input", dest="input_flag", help=argparse.SUPPRESS)
|
|
338
|
+
pr.add_argument("--out", dest="out_flag", help=argparse.SUPPRESS)
|
|
339
|
+
pr.add_argument("--schema", help="Data-dictionary JSON (auto-detected if omitted).")
|
|
340
|
+
pr.add_argument("--secret", help="Secret string (else --secret-file, $%s, or auto)." % ENV_VAR)
|
|
341
|
+
pr.add_argument("--secret-file", help=f"File holding the secret (or set {ENV_VAR}).")
|
|
342
|
+
pr.add_argument("--mapping", help="Private mapping JSON (default <out>/mapping.private.json).")
|
|
343
|
+
pr.add_argument("--report", help="Audit workbook path (default <out>/deid_audit.xlsx).")
|
|
344
|
+
pr.add_argument("--format", choices=["csv", "parquet", "tsv", "json"],
|
|
345
|
+
help="Output format (default csv).")
|
|
346
|
+
_add_policy_args(pr)
|
|
347
|
+
pr.set_defaults(func=cmd_run)
|
|
348
|
+
|
|
349
|
+
pl = sub.add_parser("learn", help="Fold a reviewed review.csv into learned dictionaries.")
|
|
350
|
+
pl.add_argument("--review", required=True, help="The human-edited review.csv (y/n column).")
|
|
351
|
+
pl.add_argument("--learn-dir", required=True, help="Directory to write/append learned dicts.")
|
|
352
|
+
pl.set_defaults(func=cmd_learn)
|
|
353
|
+
|
|
354
|
+
pm = sub.add_parser("map", help="Dump the private value->surrogate mapping.")
|
|
355
|
+
pm.add_argument("--mapping", required=True, help="Mapping JSON produced by `run`.")
|
|
356
|
+
pm.add_argument("--out", help="Write to this CSV (else print).")
|
|
357
|
+
pm.set_defaults(func=cmd_map)
|
|
358
|
+
|
|
359
|
+
pd_ = sub.add_parser(
|
|
360
|
+
"run-db", help="De-identify a SQL database into a SEPARATE output DB.",
|
|
361
|
+
description="Reads the source DB (read-only) and writes the de-identified result "
|
|
362
|
+
"to a SEPARATE output DB that mirrors the source structure. The output "
|
|
363
|
+
"database must be provisioned by YOU (an empty DB you create) and passed "
|
|
364
|
+
"as --out-db; the run fails with instructions if it is missing or "
|
|
365
|
+
"unreachable. deidkit never edits the source in place.")
|
|
366
|
+
pd_.add_argument("--source", required=True, help="Source DB SQLAlchemy URL (read-only).")
|
|
367
|
+
pd_.add_argument("--out-db", required=True,
|
|
368
|
+
help="Output DB SQLAlchemy URL you created (must differ from source). "
|
|
369
|
+
"REQUIRED — the run fails without it.")
|
|
370
|
+
pd_.add_argument("--tables", help="Comma list of tables (default: all).")
|
|
371
|
+
pd_.add_argument("--schema", help="Data-dictionary JSON (drives the field plan).")
|
|
372
|
+
pd_.add_argument("--out-schema", help="Schema/namespace in the output DB.")
|
|
373
|
+
pd_.add_argument("--if-exists", choices=["fail", "replace", "append"], default="fail",
|
|
374
|
+
help="Behaviour if an output table already exists (default fail).")
|
|
375
|
+
pd_.add_argument("--secret", help="Secret string (else --secret-file, env, or auto).")
|
|
376
|
+
pd_.add_argument("--secret-file", help=f"File holding the secret (or set {ENV_VAR}).")
|
|
377
|
+
pd_.add_argument("--mapping", help="Private mapping JSON (default <private>/mapping.private.json).")
|
|
378
|
+
pd_.add_argument("--private-dir", help="Dir for secret+mapping+audit (default ./deidkit-db-PRIVATE).")
|
|
379
|
+
_add_policy_args(pd_)
|
|
380
|
+
pd_.set_defaults(func=cmd_run_db)
|
|
381
|
+
|
|
382
|
+
px = sub.add_parser("scan", help="Show detections for a text (tuning/debug).")
|
|
383
|
+
g = px.add_mutually_exclusive_group()
|
|
384
|
+
g.add_argument("--text", help="Inline text to scan.")
|
|
385
|
+
g.add_argument("--file", help="Read text from a file.")
|
|
386
|
+
_add_policy_args(px)
|
|
387
|
+
px.set_defaults(func=cmd_scan)
|
|
388
|
+
|
|
389
|
+
return parser
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def main(argv=None) -> int:
|
|
393
|
+
parser = build_parser()
|
|
394
|
+
args = parser.parse_args(argv)
|
|
395
|
+
return args.func(args)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
if __name__ == "__main__":
|
|
399
|
+
raise SystemExit(main())
|
deidkit/config.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""Policy configuration: how each field is transformed.
|
|
2
|
+
|
|
3
|
+
A :class:`Policy` is the single object that describes what the tool should do.
|
|
4
|
+
It combines:
|
|
5
|
+
|
|
6
|
+
* global knobs (language, date-shift window, detection mode),
|
|
7
|
+
* an allow / ignore list of columns,
|
|
8
|
+
* explicit per-column :class:`FieldRule` overrides.
|
|
9
|
+
|
|
10
|
+
Everything else (which strategy a column gets when there is no explicit rule)
|
|
11
|
+
is derived automatically from the schema / data dictionary in :mod:`deidkit.schema`.
|
|
12
|
+
Policies can be built in code, loaded from YAML, or seeded from a data-dictionary
|
|
13
|
+
JSON file.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import fnmatch
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from typing import Any, Dict, List, Optional, Sequence
|
|
21
|
+
|
|
22
|
+
# --------------------------------------------------------------------------- #
|
|
23
|
+
# Strategy vocabulary
|
|
24
|
+
# --------------------------------------------------------------------------- #
|
|
25
|
+
# Each value column in a table is handled by exactly one strategy.
|
|
26
|
+
|
|
27
|
+
PASSTHROUGH = "passthrough" # leave value untouched (safe default)
|
|
28
|
+
DATE_SHIFT = "date_shift" # shift by a per-entity offset; preserves intervals
|
|
29
|
+
SYNTHETIC_NAME = "synthetic_name" # replace a structured name value with a fake one
|
|
30
|
+
IDENTIFIER = "identifier" # replace an ID-like value with a stable surrogate
|
|
31
|
+
FREETEXT = "freetext" # run the multi-stage detector over free text
|
|
32
|
+
GENERALIZE_YEAR = "generalize_year" # reduce a date to its year only
|
|
33
|
+
AGE_BAND = "age_band" # bucket an age into a band (e.g. 10-year bands, 90+)
|
|
34
|
+
REDACT = "redact" # blank the value out
|
|
35
|
+
DROP = "drop" # drop the whole column from the output
|
|
36
|
+
|
|
37
|
+
ALL_STRATEGIES = frozenset(
|
|
38
|
+
{
|
|
39
|
+
PASSTHROUGH,
|
|
40
|
+
DATE_SHIFT,
|
|
41
|
+
SYNTHETIC_NAME,
|
|
42
|
+
IDENTIFIER,
|
|
43
|
+
FREETEXT,
|
|
44
|
+
GENERALIZE_YEAR,
|
|
45
|
+
AGE_BAND,
|
|
46
|
+
REDACT,
|
|
47
|
+
DROP,
|
|
48
|
+
}
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# Detection aggressiveness for the free-text stage.
|
|
52
|
+
MODE_CONSERVATIVE = "conservative" # only replace when clearly a name (default)
|
|
53
|
+
MODE_BALANCED = "balanced" # also accept strong single-signal name hits
|
|
54
|
+
MODE_AGGRESSIVE = "aggressive" # accept any plausible person span (higher recall)
|
|
55
|
+
ALL_MODES = frozenset({MODE_CONSERVATIVE, MODE_BALANCED, MODE_AGGRESSIVE})
|
|
56
|
+
|
|
57
|
+
# How replacement person names look. All are OBVIOUSLY synthetic except
|
|
58
|
+
# "realistic" (which reads like a real name and should be used with care).
|
|
59
|
+
NAME_TAGGED = "tagged" # realistic name + visible marker: "Juan Gómez [SINTÉTICO]"
|
|
60
|
+
NAME_PLACEHOLDER = "placeholder" # role + stable code: "Persona 481502" / "Person 481502"
|
|
61
|
+
NAME_TOKEN = "token" # machine token: "[NOMBRE_9F5287]"
|
|
62
|
+
NAME_REALISTIC = "realistic" # realistic, UNMARKED fake name (looks real)
|
|
63
|
+
ALL_NAME_STYLES = frozenset({NAME_TAGGED, NAME_PLACEHOLDER, NAME_TOKEN, NAME_REALISTIC})
|
|
64
|
+
|
|
65
|
+
# Visible markers for the "tagged" style, by primary language.
|
|
66
|
+
TAG_MARKERS = {"es": "[SINTÉTICO]", "en": "[SYNTHETIC]"}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class FieldRule:
|
|
71
|
+
"""An explicit override for one or more columns.
|
|
72
|
+
|
|
73
|
+
``column`` and ``table`` support ``fnmatch`` globs (``*`` etc.). ``table``
|
|
74
|
+
of ``None`` matches every table. The first matching rule wins, so put more
|
|
75
|
+
specific rules first.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
column: str
|
|
79
|
+
strategy: str
|
|
80
|
+
table: Optional[str] = None
|
|
81
|
+
options: Dict[str, Any] = field(default_factory=dict)
|
|
82
|
+
|
|
83
|
+
def matches(self, table: Optional[str], column: str) -> bool:
|
|
84
|
+
if self.table is not None and table is not None:
|
|
85
|
+
if not fnmatch.fnmatch(table, self.table):
|
|
86
|
+
return False
|
|
87
|
+
return fnmatch.fnmatch(column, self.column)
|
|
88
|
+
|
|
89
|
+
def __post_init__(self) -> None:
|
|
90
|
+
if self.strategy not in ALL_STRATEGIES:
|
|
91
|
+
raise ValueError(
|
|
92
|
+
f"Unknown strategy {self.strategy!r}. "
|
|
93
|
+
f"Valid: {sorted(ALL_STRATEGIES)}"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass
|
|
98
|
+
class Policy:
|
|
99
|
+
"""The full transformation policy.
|
|
100
|
+
|
|
101
|
+
Attributes
|
|
102
|
+
----------
|
|
103
|
+
entity_key:
|
|
104
|
+
Column used to group date shifts and to seed per-patient offsets.
|
|
105
|
+
Every date column of a given patient is shifted by the *same* number of
|
|
106
|
+
days, so intervals (length of stay, time-to-result, age) are preserved.
|
|
107
|
+
date_max_days:
|
|
108
|
+
Date offsets are drawn deterministically from ``[-date_max_days,
|
|
109
|
+
+date_max_days]``. The magnitude does not affect interval fidelity; it
|
|
110
|
+
only controls how far the absolute calendar date moves.
|
|
111
|
+
lang:
|
|
112
|
+
Default language for name generation / detection (``"es"`` or ``"en"``).
|
|
113
|
+
mode:
|
|
114
|
+
Free-text detection aggressiveness (see ``MODE_*``).
|
|
115
|
+
name_style:
|
|
116
|
+
How replacement names look (see ``NAME_*``). Default ``"tagged"`` yields
|
|
117
|
+
OBVIOUSLY-synthetic names ("Juan Gómez [SINTÉTICO]"). ``"placeholder"``
|
|
118
|
+
-> "Persona 481502"; ``"token"`` -> "[NOMBRE_9F5287]"; ``"realistic"`` ->
|
|
119
|
+
an unmarked realistic fake (looks real — use with care).
|
|
120
|
+
names_synthetic:
|
|
121
|
+
Legacy switch. ``False`` forces ``name_style="token"``.
|
|
122
|
+
shift_dates_in_text:
|
|
123
|
+
Shift date-like strings found inside free text by the row's offset.
|
|
124
|
+
only:
|
|
125
|
+
If set, ONLY these columns are transformed; everything else passes
|
|
126
|
+
through. (Applied by exact column name.)
|
|
127
|
+
ignore:
|
|
128
|
+
These columns are never transformed. Takes precedence over everything.
|
|
129
|
+
rules:
|
|
130
|
+
Explicit per-column overrides evaluated before the schema auto-plan.
|
|
131
|
+
spacy_model:
|
|
132
|
+
If set (e.g. ``"es_core_news_lg"``) the optional statistical NER stage
|
|
133
|
+
is enabled as a corroborating detector.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
entity_key: str = "patient_id"
|
|
137
|
+
date_max_days: int = 365
|
|
138
|
+
lang: str = "es"
|
|
139
|
+
mode: str = MODE_CONSERVATIVE
|
|
140
|
+
name_style: str = NAME_TAGGED
|
|
141
|
+
names_synthetic: bool = True
|
|
142
|
+
shift_dates_in_text: bool = True
|
|
143
|
+
only: Optional[set] = None
|
|
144
|
+
ignore: set = field(default_factory=set)
|
|
145
|
+
rules: List[FieldRule] = field(default_factory=list)
|
|
146
|
+
spacy_model: Optional[str] = None
|
|
147
|
+
# Advanced: extra gazetteer/stoplist files to merge on top of the bundled ones.
|
|
148
|
+
extra_name_files: List[str] = field(default_factory=list)
|
|
149
|
+
extra_stoplist_files: List[str] = field(default_factory=list)
|
|
150
|
+
# Known-PHI propagation: values in these per-row columns are KNOWN PII and are
|
|
151
|
+
# force-scrubbed from that row's free text (near-100% recall, no guessing).
|
|
152
|
+
known_name_columns: List[str] = field(default_factory=list)
|
|
153
|
+
known_id_columns: List[str] = field(default_factory=list)
|
|
154
|
+
# Enable checksum-validated detection of bare national IDs, per region, e.g.
|
|
155
|
+
# ["CPF","CNPJ","CNS"] (Brazil), ["DNI","NIE"] (Spain), ["NHS"] (UK),
|
|
156
|
+
# ["CURP","RFC"] (Mexico), or ["ALL"]. Off by default (see detect/checksums).
|
|
157
|
+
id_checksums: List[str] = field(default_factory=list)
|
|
158
|
+
# Load the bundled medical safe-vocabulary (drug/terminology tokens) so
|
|
159
|
+
# capitalized medical terms are never flagged as person names (Philter-style).
|
|
160
|
+
use_medical_vocab: bool = True
|
|
161
|
+
# Global registry of known names (e.g. clinic staff) scrubbed from all free text.
|
|
162
|
+
extra_known_names: List[str] = field(default_factory=list)
|
|
163
|
+
# Only force-scrub a LONE known name token if it is this long (guards against
|
|
164
|
+
# over-redacting short/common surname words like "Cruz"/"León").
|
|
165
|
+
known_token_min_len: int = 5
|
|
166
|
+
|
|
167
|
+
def __post_init__(self) -> None:
|
|
168
|
+
if self.mode not in ALL_MODES:
|
|
169
|
+
raise ValueError(f"mode must be one of {sorted(ALL_MODES)}")
|
|
170
|
+
if not self.names_synthetic: # legacy switch -> tokens
|
|
171
|
+
self.name_style = NAME_TOKEN
|
|
172
|
+
if self.name_style not in ALL_NAME_STYLES:
|
|
173
|
+
raise ValueError(f"name_style must be one of {sorted(ALL_NAME_STYLES)}")
|
|
174
|
+
if self.only is not None:
|
|
175
|
+
self.only = set(self.only)
|
|
176
|
+
self.ignore = set(self.ignore)
|
|
177
|
+
|
|
178
|
+
@property
|
|
179
|
+
def primary_lang(self) -> str:
|
|
180
|
+
"""First language of a (possibly multilingual) spec, for name rendering."""
|
|
181
|
+
lg = self.lang
|
|
182
|
+
if isinstance(lg, (list, tuple)):
|
|
183
|
+
return lg[0] if lg else "es"
|
|
184
|
+
if lg in ("multi", "es+en", "en+es", "both"):
|
|
185
|
+
return "es"
|
|
186
|
+
return lg
|
|
187
|
+
|
|
188
|
+
# --- rule resolution --------------------------------------------------- #
|
|
189
|
+
def explicit_rule(self, table: Optional[str], column: str) -> Optional[FieldRule]:
|
|
190
|
+
"""Return the first explicit rule matching ``table``/``column``."""
|
|
191
|
+
for rule in self.rules:
|
|
192
|
+
if rule.matches(table, column):
|
|
193
|
+
return rule
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
def is_ignored(self, column: str) -> bool:
|
|
197
|
+
if column in self.ignore:
|
|
198
|
+
return True
|
|
199
|
+
if self.only is not None and column not in self.only:
|
|
200
|
+
return True
|
|
201
|
+
return False
|
|
202
|
+
|
|
203
|
+
# --- serialization ----------------------------------------------------- #
|
|
204
|
+
@classmethod
|
|
205
|
+
def from_dict(cls, data: Dict[str, Any]) -> "Policy":
|
|
206
|
+
data = dict(data)
|
|
207
|
+
rules = [
|
|
208
|
+
FieldRule(
|
|
209
|
+
column=r["column"],
|
|
210
|
+
strategy=r["strategy"],
|
|
211
|
+
table=r.get("table"),
|
|
212
|
+
options=r.get("options", {}) or {},
|
|
213
|
+
)
|
|
214
|
+
for r in data.pop("rules", []) or []
|
|
215
|
+
]
|
|
216
|
+
only = data.pop("only", None)
|
|
217
|
+
ignore = data.pop("ignore", []) or []
|
|
218
|
+
known = {
|
|
219
|
+
"entity_key",
|
|
220
|
+
"date_max_days",
|
|
221
|
+
"lang",
|
|
222
|
+
"mode",
|
|
223
|
+
"name_style",
|
|
224
|
+
"names_synthetic",
|
|
225
|
+
"shift_dates_in_text",
|
|
226
|
+
"spacy_model",
|
|
227
|
+
"extra_name_files",
|
|
228
|
+
"extra_stoplist_files",
|
|
229
|
+
"known_name_columns",
|
|
230
|
+
"known_id_columns",
|
|
231
|
+
"extra_known_names",
|
|
232
|
+
"known_token_min_len",
|
|
233
|
+
"id_checksums",
|
|
234
|
+
"use_medical_vocab",
|
|
235
|
+
}
|
|
236
|
+
kwargs = {k: v for k, v in data.items() if k in known}
|
|
237
|
+
return cls(
|
|
238
|
+
rules=rules,
|
|
239
|
+
only=set(only) if only else None,
|
|
240
|
+
ignore=set(ignore),
|
|
241
|
+
**kwargs,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
@classmethod
|
|
245
|
+
def from_yaml(cls, path: str) -> "Policy":
|
|
246
|
+
import yaml
|
|
247
|
+
|
|
248
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
249
|
+
data = yaml.safe_load(fh) or {}
|
|
250
|
+
return cls.from_dict(data)
|
|
251
|
+
|
|
252
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
253
|
+
return {
|
|
254
|
+
"entity_key": self.entity_key,
|
|
255
|
+
"date_max_days": self.date_max_days,
|
|
256
|
+
"lang": self.lang,
|
|
257
|
+
"mode": self.mode,
|
|
258
|
+
"name_style": self.name_style,
|
|
259
|
+
"names_synthetic": self.names_synthetic,
|
|
260
|
+
"shift_dates_in_text": self.shift_dates_in_text,
|
|
261
|
+
"spacy_model": self.spacy_model,
|
|
262
|
+
"only": sorted(self.only) if self.only else None,
|
|
263
|
+
"ignore": sorted(self.ignore),
|
|
264
|
+
"rules": [
|
|
265
|
+
{
|
|
266
|
+
"column": r.column,
|
|
267
|
+
"strategy": r.strategy,
|
|
268
|
+
"table": r.table,
|
|
269
|
+
"options": r.options,
|
|
270
|
+
}
|
|
271
|
+
for r in self.rules
|
|
272
|
+
],
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def normalize_only_ignore(
|
|
277
|
+
only: Optional[Sequence[str]], ignore: Optional[Sequence[str]]
|
|
278
|
+
) -> Dict[str, Any]:
|
|
279
|
+
"""Helper for the CLI: turn comma-lists into a dict fragment for a Policy."""
|
|
280
|
+
frag: Dict[str, Any] = {}
|
|
281
|
+
if only:
|
|
282
|
+
frag["only"] = set(only)
|
|
283
|
+
if ignore:
|
|
284
|
+
frag["ignore"] = set(ignore)
|
|
285
|
+
return frag
|