
Deidentify
- 44 installs
- 236 repo stars
- Updated August 3, 2026
- aperivue/medsci-skills
Deidentify is a Claude Code skill wrapping a standalone Python CLI that detects and removes PHI from clinical research data locally, with no LLM or network access to raw data.
About
Deidentify de-identifies clinical research data before LLM-assisted analysis using a standalone Python CLI that runs locally without network or AI calls. A medical researcher runs it to strip PHI from CSV, TSV, or Excel datasets using country-specific locale pattern packs. The LLM only guides and verifies; it never sees the raw patient data.
- Standalone local Python CLI detects PHI via regex plus heuristics; no LLM touches raw data
- 10 country locale packs (kr, us, jp, cn, de, uk, fr, ca, au, in) for PHI patterns
- Interactive three-pass terminal review and HIPAA Safe Harbor audit logging
Deidentify by the numbers
- 44 all-time installs (skills.sh)
- Ranked #973 of 2,064 Data Science & ML skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
deidentify capabilities & compatibility
- Capabilities
- generate codebook · define variables
- Use cases
- security audit · data analysis
- Platforms
- macOS · Linux · Windows
- Pricing
- Free
What deidentify says it does
De-identify clinical research data before LLM-assisted analysis.
No LLM touches raw data — the script runs locally without any network or AI calls.
NEVER ask the user to paste, show, or upload raw data containing PHI.
npx skills add https://github.com/aperivue/medsci-skills --skill deidentifyAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 44 |
|---|---|
| repo stars | ★ 236 |
| Last updated | August 3, 2026 |
| Repository | aperivue/medsci-skills ↗ |
What it does
De-identify clinical datasets locally by detecting and anonymizing PHI before any LLM-assisted analysis.
Who is it for?
Researchers who must strip PHI from CSV or Excel clinical data before LLM-assisted analysis, with IRB-ready documentation.
Skip if: Analyzing or transforming data content beyond PHI removal.
When should I use this skill?
Clinical data containing patient identifiers must be anonymized before sharing or analysis.
What you get
A de-identified dataset, a hash-only audit log, and a methods paragraph for the manuscript or IRB.
- de-identified dataset
- hash-only audit log
- IRB methods paragraph
By the numbers
- 10 country locale packs
- HIPAA 18-identifier checklist
- five-phase workflow
Files
De-identification Skill
You are guiding a medical researcher through data de-identification. The actual de-identification is performed by a standalone Python script that runs WITHOUT any LLM. Your role is to explain, guide, and verify — not to see or process raw PHI data.
Critical Safety Rules
1. NEVER ask the user to paste, show, or upload raw data containing PHI. The script processes data locally. You never need to see patient-level data. 2. NEVER read or display the mapping file contents. It contains original PHI values. 3. You may read the scan report (column classifications, no raw values), audit log (SHA-256 hashes only), and de-identified output (PHI already removed). 4. Always communicate in the user's preferred language about the process, but use English for technical terms (PHI, HIPAA, Safe Harbor, etc.).
Reference Files
${CLAUDE_SKILL_DIR}/references/hipaa_18_identifiers.md— HIPAA Safe Harbor checklist${CLAUDE_SKILL_DIR}/references/korean_phi_patterns.md— Korean-specific regex patterns${CLAUDE_SKILL_DIR}/references/date_shift_guide.md— Date shifting best practices
Read relevant references before advising the researcher.
Prerequisites
- Python 3.10+
openpyxl(for .xlsx files):pip install openpyxl- Supported formats: CSV, TSV, Excel (.xlsx)
Five-Phase Workflow
Phase 1: Assessment
Ask the researcher: 1. What file format is the data? (CSV, Excel, etc.) 2. What PHI do you expect in the data? (names, dates, IDs, etc.) 3. Does your IRB require specific de-identification documentation? 4. Do you need to re-identify later? (affects mapping file choice)
Based on answers, recommend the appropriate command:
- Full pipeline (most common):
python deidentify.py full <file> --locale <code> - Step-by-step (cautious):
python deidentify.py scan <file> --locale <code>first
Available locale codes: kr (Korea), us (USA), jp (Japan), cn (China), de (Germany), uk (United Kingdom), fr (France), ca (Canada), au (Australia), in (India). If --locale is omitted, the script shows an interactive country selection menu. Users can provide a custom locale file via --locale-file custom.json.
Phase 2: Script Execution
Guide the researcher to run the script. The script is located at:
${CLAUDE_SKILL_DIR}/deidentify.pyFull pipeline (recommended for most users):
python ${CLAUDE_SKILL_DIR}/deidentify.py full data.xlsx \
--locale kr \
--output-dir ./deidentified/ \
--auto-accept-safeStep-by-step (for careful review):
# Step 1: Scan
python ${CLAUDE_SKILL_DIR}/deidentify.py scan data.xlsx --locale kr --output-dir ./deidentified/
# Step 2: Review (interactive)
python ${CLAUDE_SKILL_DIR}/deidentify.py review ./deidentified/scan_report.json
# Step 3: Apply
python ${CLAUDE_SKILL_DIR}/deidentify.py apply ./deidentified/reviewed_report.jsonOptions:
--locale CODE: Country locale for PHI patterns (kr, us, jp, cn, de, uk, fr, ca, au, in)--locale-file PATH: Custom locale JSON file (copylocales/_template.jsonto create one)--auto-accept-safe: Skip confirmation for columns classified as SAFE (faster for large datasets)--hash-mapping: Store SHA-256 hashes instead of original values in mapping file (one-way, more secure)--output-dir: Where to save de-identified file, mapping, and audit log-v/--verbose: Enable debug logging
Phase 3: Interactive Review Guidance
The script's terminal review has three passes:
1. Pass 1 — Column Classification: Each column is shown as PHI / REVIEW_NEEDED / SAFE. The researcher confirms or overrides each classification. 2. Pass 2 — Undecided Items: Columns that weren't resolved in Pass 1 get a second look with more sample values displayed. 3. Pass 3 — Final Summary: A table of all planned actions. The researcher can edit individual decisions before confirming.
Coach the researcher. Deliver these prompts in the researcher's preferred language:
- "Columns classified as PHI are anonymized by default. Press 'k' to keep the original value."
- "REVIEW_NEEDED are columns the script could not classify. Check the sample values and decide."
- "SAFE means no PHI detected. Press 'r' to request re-review if any column looks suspicious."
Phase 4: Verify and Document
After the script completes, help the researcher verify:
1. Read the audit log (safe — contains only hashes):
cat ./deidentified/audit_log.csv | head -20Verify the number of changes, affected columns, and PHI types.
2. Spot-check the de-identified file (safe — PHI already removed): Read a few rows to confirm pseudonyms (P0001, etc.), date shifts, and [REDACTED] markers appear where expected.
3. Check that sensitive columns are actually removed: Verify no original names, phone numbers, or RRN values remain.
4. Mapping file security:
- Remind the researcher: "mapping.json contains original patient identifiers — treat it as restricted."
- Recommend storing it separately from the de-identified data
- File permissions are automatically set to 0600 (owner-only)
Phase 5: Documentation
Generate a de-identification methods paragraph for the manuscript or IRB:
Template:
Protected health information was removed from the dataset prior to analysis using
a rule-based de-identification tool (deidentify.py, medsci-skills) with the [COUNTRY]
locale pattern pack. The tool scanned column names and cell values using regex patterns
for country-specific identifiers (e.g., national ID numbers, phone numbers), email
addresses, dates, and addresses. Each column classification was reviewed by the
researcher in an interactive terminal session. Names were replaced with pseudonyms
(P0001, P0002, ...), dates were shifted by a random per-patient offset (±365 days)
preserving relative temporal intervals, and direct identifiers (phone numbers, email
addresses, national ID numbers) were suppressed. A total of [N] cells across [M]
columns were de-identified. The de-identification mapping file was stored separately
under restricted access (file permissions 0600).
Customize based on the actual audit log statistics.
Cross-Skill Integration
- deidentify sits BEFORE
clean-datain the research pipeline - After de-identification, hand off to
/clean-datafor data quality profiling /analyze-statscan safely process the de-identified output/write-paperMethods section should reference the de-identification process/write-protocolcan use the HIPAA/PIPA reference files for protocol documentation
Output Files
| File | Contains PHI? | Safe for Claude? | Purpose |
|---|---|---|---|
*_deidentified.xlsx/csv | No | Yes | De-identified data for analysis |
mapping.json | YES | No | Original ↔ pseudonym mapping |
audit_log.csv | No (hashes only) | Yes | What was changed and where |
scan_report.json | No | Yes | Column classification results |
reviewed_report.json | No | Yes | Researcher-reviewed classifications |
Scope and Limitations
Supported (v1):
- Structured tabular data: CSV, TSV, Excel (.xlsx)
- 10 country locales with country-specific PHI patterns:
- Korea (kr): RRN (주민번호), phone, email, address, Hangul names, dates
- USA (us): SSN, US phone, US address, zip codes
- Japan (jp): マイナンバー, Japanese phone, 都道府県 address, Kanji names
- China (cn): 身份证号, Chinese phone, 省市区 address, Chinese names
- Germany (de): Steuer-ID, German phone, Straße address
- UK (uk): NHS Number, NI Number, UK phone, postcodes
- France (fr): NIR/INSEE, French phone, Rue address
- Canada (ca): SIN, Canadian phone, postal codes
- Australia (au): TFN, Medicare number, AU phone
- India (in): Aadhaar, PAN, Indian phone, pin codes
- Universal patterns (all locales): email, ISO dates, high-cardinality numeric IDs (MRN)
- English column names recognized across all locales
- Custom locale support via
--locale-filewith template - Pseudonymization, date shifting, ID replacement, suppression
NOT supported (planned for v2):
- DICOM image metadata (PS3.15 Annex E) — requires pydicom
- Clinical free-text NER (clinical notes, radiology reports)
- Automated k-anonymity / l-diversity assessment
- SPSS (.sav), SAS (.sas7bdat), or other statistical formats
Anti-Hallucination
- Never fabricate file paths, URLs, DOIs, or package names. Verify existence before recommending.
- Never invent journal metadata, impact factors, or submission policies without verification at the journal's website.
- If a tool, package, or resource does not exist or you are unsure, say so explicitly rather than guessing.
#!/usr/bin/env python3
"""
Clinical research data de-identification (LLM-free).
Scans Excel/CSV files for Protected Health Information (PHI) using regex
and column-name heuristics, walks the researcher through an interactive
terminal review, then produces a de-identified copy with mapping and
audit trail.
Supports 10 country locales (kr, us, jp, cn, de, uk, fr, ca, au, in)
with country-specific PHI patterns. Custom locales via --locale-file.
Usage:
python deidentify.py scan input.xlsx [--locale kr]
python deidentify.py review scan_report.json
python deidentify.py apply reviewed_report.json [--hash-mapping]
python deidentify.py full input.xlsx [--locale kr] [--auto-accept-safe]
"""
import argparse
import csv
import hashlib
import json
import logging
import os
import random
import re
import stat
import sys
from datetime import datetime, timedelta
from pathlib import Path
log = logging.getLogger("deidentify")
REPORT_VERSION = 1
# ================================================================
# Section 1: Constants + Locale Loading
# ================================================================
LOCALES_DIR = Path(__file__).parent / "locales"
# Universal column names (English — common across all research locales).
UNIVERSAL_COLUMN_NAMES: dict[str, str] = {
"patient_name": "name", "patientname": "name", "pt_name": "name",
"name": "name", "first_name": "name", "last_name": "name",
"ssn": "rrn", "social_security": "rrn",
"dob": "date", "date_of_birth": "date", "birth_date": "date",
"birthdate": "date",
"phone": "phone", "telephone": "phone", "mobile": "phone",
"phone_number": "phone", "cell": "phone",
"address": "address", "home_address": "address", "street": "address",
"zip": "address", "zipcode": "address", "zip_code": "address",
"email": "email", "email_address": "email",
"mrn": "id", "medical_record": "id", "chart_no": "id",
"patient_id": "id", "patientid": "id", "chart_number": "id",
"record_number": "id", "hospital_id": "id",
"insurance_no": "insurance", "insurance_number": "insurance",
}
# Universal value patterns (always active regardless of locale).
UNIVERSAL_VALUE_PATTERNS: list[tuple[re.Pattern, str]] = [
# Email
(re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b"), "email"),
# ISO date YYYY-MM-DD or YYYY.MM.DD or YYYY/MM/DD
(re.compile(r"\b(19|20)\d{2}[-/.](0[1-9]|1[0-2])[-/.](0[1-9]|[12]\d|3[01])\b"), "date"),
# YYMMDD (6 digits that look like a birthdate, standalone)
(re.compile(r"\b([5-9]\d|0[0-4])(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\b"), "date"),
]
def list_locales() -> list[dict]:
"""List available locales from the locales/ directory."""
locales = []
if not LOCALES_DIR.is_dir():
return locales
for f in sorted(LOCALES_DIR.glob("*.json")):
if f.name.startswith("_"):
continue
try:
data = json.loads(f.read_text(encoding="utf-8"))
locales.append({
"code": data.get("code", f.stem),
"name": data.get("name", f.stem),
"native_name": data.get("native_name", ""),
"path": str(f),
})
except (json.JSONDecodeError, OSError):
continue
return locales
def load_locale(code: str) -> dict:
"""Load locale by country code (e.g., 'kr', 'us')."""
path = LOCALES_DIR / f"{code}.json"
if not path.exists():
sys.exit(f"Locale not found: {code}\n"
f"Available: {', '.join(l['code'] for l in list_locales())}\n"
f"Or use --locale-file for a custom locale.")
return json.loads(path.read_text(encoding="utf-8"))
def load_locale_file(path: str) -> dict:
"""Load a custom locale from an arbitrary JSON file."""
p = Path(path)
if not p.exists():
sys.exit(f"Locale file not found: {path}")
return json.loads(p.read_text(encoding="utf-8"))
def select_locale_interactive() -> dict:
"""Interactive country selection prompt."""
locales = list_locales()
if not locales:
sys.exit("No locale files found in locales/ directory.")
print(f"\n{_bold('Select country / 국가 선택:')}")
for i, loc in enumerate(locales, 1):
native = f" ({loc['native_name']})" if loc['native_name'] != loc['name'] else ""
print(f" {i:2d}. {loc['name']}{native}")
print(f" 0. Other (provide custom locale file)")
while True:
choice = input(f"\n> ").strip()
if choice == "0":
custom_path = input(" Path to custom locale JSON: ").strip()
locale = load_locale_file(custom_path)
print(f" Loaded custom locale: {locale.get('name', 'Custom')}")
return locale
try:
idx = int(choice)
if 1 <= idx <= len(locales):
locale = load_locale(locales[idx - 1]["code"])
print(f" Loading {locale['name']} patterns...")
return locale
except ValueError:
# Try as code
for loc in locales:
if choice.lower() == loc["code"]:
locale = load_locale(choice.lower())
print(f" Loading {locale['name']} patterns...")
return locale
print(f" Invalid choice. Enter 1-{len(locales)} or a country code.")
def build_locale_patterns(locale: dict) -> tuple[
dict[str, str],
list[tuple[re.Pattern, str]],
re.Pattern | None,
re.Pattern | None,
float,
list[str],
]:
"""Build scanning patterns from a locale dict.
Returns:
(column_names, value_patterns, address_re, name_re, name_min_ratio, name_columns)
"""
# Column names: universal + locale-specific
column_names = dict(UNIVERSAL_COLUMN_NAMES)
column_names.update(locale.get("column_names", {}))
# Value patterns: universal + locale-specific
value_patterns = list(UNIVERSAL_VALUE_PATTERNS)
# National ID
nid = locale.get("national_id", {})
nid_type = nid.get("phi_type", "national_id")
for pat in nid.get("patterns", []):
value_patterns.append((re.compile(pat), nid_type))
# Phone
for phone in locale.get("phone", []):
value_patterns.append((re.compile(phone["pattern"]), "phone"))
# Extra date formats
for df in locale.get("date_formats", []):
value_patterns.append((re.compile(df["pattern"]), "date"))
# Address pattern
addr_cfg = locale.get("address", {})
address_re = None
if addr_cfg.get("type") == "suffix_regex" and addr_cfg.get("pattern"):
address_re = re.compile(addr_cfg["pattern"])
elif addr_cfg.get("type") == "keywords" and addr_cfg.get("keywords"):
# Build a regex from keywords (case-insensitive word boundary match)
escaped = [re.escape(kw) for kw in addr_cfg["keywords"]]
address_re = re.compile(r"(?:" + "|".join(escaped) + r")", re.IGNORECASE)
# Postcode pattern (if available, add to value_patterns as address type)
if addr_cfg.get("postcode_pattern"):
value_patterns.append((re.compile(addr_cfg["postcode_pattern"]), "address"))
# Name heuristic
name_cfg = locale.get("name_heuristic", {})
name_re = None
if name_cfg.get("type") == "regex" and name_cfg.get("pattern"):
name_re = re.compile(name_cfg["pattern"])
name_min_ratio = name_cfg.get("min_ratio", 0.3)
# Name columns (for restricting name heuristic)
name_columns = [k for k, v in column_names.items() if v == "name"]
return column_names, value_patterns, address_re, name_re, name_min_ratio, name_columns
# Confidence thresholds
CONF_HIGH = "high"
CONF_MEDIUM = "medium"
CONF_LOW = "low"
# ANSI helpers (respect NO_COLOR)
_NO_COLOR = bool(os.environ.get("NO_COLOR"))
def _c(code: str, text: str) -> str:
if _NO_COLOR:
return text
return f"\033[{code}m{text}\033[0m"
def _red(t: str) -> str: return _c("31", t)
def _green(t: str) -> str: return _c("32", t)
def _yellow(t: str) -> str: return _c("33", t)
def _bold(t: str) -> str: return _c("1", t)
def _dim(t: str) -> str: return _c("2", t)
# ================================================================
# Section 2: File I/O
# ================================================================
def detect_format(path: Path) -> str:
"""Return 'csv', 'tsv', or 'xlsx' based on extension."""
ext = path.suffix.lower()
if ext == ".xlsx":
return "xlsx"
if ext == ".tsv":
return "tsv"
if ext in (".csv", ".txt", ""):
return "csv"
sys.exit(f"Unsupported file format: {ext}")
def detect_encoding(path: Path) -> str:
"""Detect encoding: try UTF-8, fall back to EUC-KR."""
raw = path.read_bytes()
# UTF-8 BOM
if raw[:3] == b"\xef\xbb\xbf":
return "utf-8-sig"
try:
raw.decode("utf-8")
return "utf-8"
except UnicodeDecodeError:
pass
try:
raw.decode("euc-kr")
return "euc-kr"
except UnicodeDecodeError:
pass
return "utf-8" # best effort
def load_tabular(path: Path) -> tuple[list[dict], dict]:
"""Load CSV/TSV/XLSX into list of row-dicts + metadata dict."""
fmt = detect_format(path)
meta = {"format": fmt, "path": str(path), "sheets_skipped": []}
if fmt == "xlsx":
try:
import openpyxl
except ImportError:
sys.exit("openpyxl is required for .xlsx files. Install: pip install openpyxl")
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
if len(wb.sheetnames) > 1:
meta["sheets_skipped"] = wb.sheetnames[1:]
log.warning("Multiple sheets found. Processing '%s' only. Skipped: %s",
wb.sheetnames[0], ", ".join(wb.sheetnames[1:]))
ws = wb[wb.sheetnames[0]]
rows_iter = ws.iter_rows(values_only=True)
headers = [str(h) if h is not None else f"col_{i}" for i, h in enumerate(next(rows_iter))]
data = []
for row in rows_iter:
data.append({h: (str(v) if v is not None else "") for h, v in zip(headers, row)})
wb.close()
else:
delimiter = "\t" if fmt == "tsv" else ","
enc = detect_encoding(path)
with open(path, newline="", encoding=enc) as f:
reader = csv.DictReader(f, delimiter=delimiter)
headers = reader.fieldnames or []
data = list(reader)
meta["rows"] = len(data)
meta["columns"] = len(headers) if data else 0
meta["headers"] = headers
return data, meta
def save_tabular(data: list[dict], path: Path, fmt: str) -> None:
"""Write de-identified data back to CSV/TSV/XLSX."""
if not data:
log.warning("No data to write.")
return
headers = list(data[0].keys())
if fmt == "xlsx":
try:
import openpyxl
except ImportError:
fmt = "csv"
path = path.with_suffix(".csv")
log.warning("openpyxl not available; writing CSV instead: %s", path)
if fmt == "xlsx":
import openpyxl
wb = openpyxl.Workbook()
ws = wb.active
ws.append(headers)
for row in data:
ws.append([row.get(h, "") for h in headers])
wb.save(path)
else:
delimiter = "\t" if fmt == "tsv" else ","
with open(path, "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=headers, delimiter=delimiter)
writer.writeheader()
writer.writerows(data)
# ================================================================
# Section 3: PHI Scanner
# ================================================================
def _normalize_col(name: str) -> str:
"""Normalize column name for matching: lowercase, strip, collapse whitespace."""
return re.sub(r"[\s_\-]+", "_", name.strip().lower())
def _col_name_matches(norm_col: str, pattern: str) -> bool:
"""Check if a normalized column name matches a PHI pattern.
Rules:
- Short column names (<=10 chars): exact match or pattern equals norm
- Long column names (>10 chars): only match if pattern IS the full norm
(prevents 'cell' matching inside 'atypical cell carcinoma')
- Korean patterns: use substring match only for dedicated Korean PHI words
"""
# Exact match
if norm_col == pattern:
return True
# Short column name that equals or is contained in pattern
if len(norm_col) <= 10 and norm_col in pattern:
return True
# Short column name: check if pattern matches as a whole word
if len(norm_col) <= 10 and pattern in norm_col:
return True
# Long column name: only match if the normalized name STARTS with the pattern
# (e.g., "전화번호_집" matches "전화번호", but "bronchial...cell" doesn't match "cell")
if norm_col.startswith(pattern):
return True
return False
def scan_column_names(headers: list[str],
column_names: dict[str, str] | None = None) -> dict[str, dict]:
"""Match column names against PHI dictionary.
Returns {col: {"phi_type": str, "confidence": str, "source": "column_name"}}.
"""
if column_names is None:
column_names = UNIVERSAL_COLUMN_NAMES
results: dict[str, dict] = {}
for col in headers:
norm = _normalize_col(col)
for pattern, phi_type in column_names.items():
if _col_name_matches(norm, pattern):
results[col] = {
"phi_type": phi_type,
"confidence": CONF_HIGH,
"source": "column_name",
}
break
return results
def _sample_values(values: list[str], n: int = 500) -> list[str]:
"""Return up to n non-empty values for scanning."""
non_empty = [v for v in values if v and v.strip()]
if len(non_empty) <= n:
return non_empty
return random.sample(non_empty, n)
def scan_column_values(col: str, values: list[str],
col_phi_hint: str | None = None,
value_patterns: list[tuple[re.Pattern, str]] | None = None,
address_re: re.Pattern | None = None,
name_re: re.Pattern | None = None,
name_min_ratio: float = 0.3,
name_columns: list[str] | None = None) -> dict | None:
"""Scan cell values in a column for PHI patterns.
Returns a detection dict or None.
"""
if value_patterns is None:
value_patterns = UNIVERSAL_VALUE_PATTERNS
if name_columns is None:
name_columns = [k for k, v in UNIVERSAL_COLUMN_NAMES.items() if v == "name"]
sample = _sample_values(values)
if not sample:
return None
# Count matches per PHI type
type_counts: dict[str, int] = {}
for val in sample:
for regex, phi_type in value_patterns:
if regex.search(val):
type_counts[phi_type] = type_counts.get(phi_type, 0) + 1
break # one match per value is enough
# Name heuristic check (only if column name hints at a name)
if name_re is not None:
if col_phi_hint == "name" or _normalize_col(col) in name_columns:
name_count = sum(1 for v in sample if name_re.match(v.strip()))
if name_count > len(sample) * name_min_ratio:
type_counts["name"] = name_count
# Address check
if address_re is not None:
addr_count = sum(1 for v in sample if address_re.search(v))
if addr_count > len(sample) * 0.3:
type_counts["address"] = addr_count
if not type_counts:
return None
# Pick the most frequent type
best_type = max(type_counts, key=lambda k: type_counts[k])
ratio = type_counts[best_type] / len(sample)
confidence = CONF_HIGH if ratio > 0.5 else CONF_MEDIUM if ratio > 0.2 else CONF_LOW
return {
"phi_type": best_type,
"confidence": confidence,
"source": "value_pattern",
"match_ratio": round(ratio, 3),
"sample_size": len(sample),
}
def is_high_cardinality_numeric(values: list[str], threshold: float = 0.9) -> bool:
"""Detect columns that look like MRN/chart numbers:
high-cardinality pure-numeric values."""
non_empty = [v.strip() for v in values if v and v.strip()]
if len(non_empty) < 10:
return False
numeric_count = sum(1 for v in non_empty if v.isdigit() and len(v) >= 5)
if numeric_count / len(non_empty) < threshold:
return False
unique_ratio = len(set(non_empty)) / len(non_empty)
return unique_ratio > 0.8
def classify_columns(data: list[dict], headers: list[str],
locale: dict | None = None) -> list[dict]:
"""Classify every column as PHI, SAFE, or REVIEW_NEEDED.
Returns a list of classification dicts (one per column).
"""
# Build patterns from locale (or use universal defaults)
if locale is not None:
col_names, val_patterns, addr_re, name_re, name_ratio, name_cols = \
build_locale_patterns(locale)
else:
col_names = UNIVERSAL_COLUMN_NAMES
val_patterns = UNIVERSAL_VALUE_PATTERNS
addr_re = None
name_re = None
name_ratio = 0.3
name_cols = [k for k, v in UNIVERSAL_COLUMN_NAMES.items() if v == "name"]
# Pass 1: column name matching
name_hits = scan_column_names(headers, col_names)
classifications = []
for col in headers:
values = [row.get(col, "") for row in data]
# Already matched by name?
if col in name_hits:
entry = {
"column": col,
"classification": "PHI",
**name_hits[col],
}
# Refine with value scan
val_hit = scan_column_values(
col, values, name_hits[col]["phi_type"],
val_patterns, addr_re, name_re, name_ratio, name_cols)
if val_hit:
entry["value_scan"] = val_hit
classifications.append(entry)
continue
# Pass 2: value pattern scan
val_hit = scan_column_values(
col, values, None,
val_patterns, addr_re, name_re, name_ratio, name_cols)
if val_hit:
classifications.append({
"column": col,
"classification": "PHI" if val_hit["confidence"] == CONF_HIGH else "REVIEW_NEEDED",
**val_hit,
})
continue
# Pass 3: high-cardinality numeric (possible MRN)
if is_high_cardinality_numeric(values):
# Show sample for user review
unique_sample = sorted(set(v.strip() for v in values if v.strip()))[:5]
classifications.append({
"column": col,
"classification": "REVIEW_NEEDED",
"phi_type": "id",
"confidence": CONF_LOW,
"source": "high_cardinality_numeric",
"sample_values": unique_sample,
})
continue
# Pass 4: free-text detection (long strings, mixed content)
non_empty = [v for v in values if v and v.strip()]
if non_empty:
avg_len = sum(len(v) for v in non_empty) / len(non_empty)
if avg_len > 50:
# Scan for embedded PHI in free text
embedded_phi = False
for val in _sample_values(non_empty, 100):
for regex, _ in val_patterns:
if regex.search(val):
embedded_phi = True
break
if embedded_phi:
break
if embedded_phi:
classifications.append({
"column": col,
"classification": "REVIEW_NEEDED",
"phi_type": "free_text",
"confidence": CONF_MEDIUM,
"source": "free_text_with_phi",
})
continue
# Default: SAFE
classifications.append({
"column": col,
"classification": "SAFE",
"phi_type": None,
"confidence": CONF_HIGH,
"source": "no_match",
})
return classifications
def build_scan_report(input_path: Path, data: list[dict],
meta: dict, classifications: list[dict],
locale: dict | None = None) -> dict:
"""Build the full scan report JSON."""
report = {
"version": REPORT_VERSION,
"timestamp": datetime.now().isoformat(),
"input_file": str(input_path),
"meta": meta,
"classifications": classifications,
}
if locale is not None:
report["locale"] = {
"code": locale.get("code", "custom"),
"name": locale.get("name", "Custom"),
}
return report
# ================================================================
# Section 4: Interactive Reviewer
# ================================================================
def _format_classification(c: dict) -> str:
cls = c["classification"]
phi = c.get("phi_type", "")
conf = c.get("confidence", "")
if cls == "PHI":
return f"{_red('PHI')} ({phi}, {conf})"
if cls == "REVIEW_NEEDED":
return f"{_yellow('REVIEW_NEEDED')} ({phi}, {conf})"
return _green("SAFE")
def _show_sample_values(col: str, data: list[dict], n: int = 10) -> None:
"""Print up to n unique sample values for a column."""
values = list(set(row.get(col, "") for row in data if row.get(col, "").strip()))
sample = values[:n]
if sample:
print(f" Sample values: {', '.join(repr(v) for v in sample)}")
if len(values) > n:
print(f" ... and {len(values) - n} more unique values")
def review_scan_report(report: dict, data: list[dict],
auto_accept_safe: bool = False) -> dict:
"""Interactive three-pass review. Mutates and returns the report."""
classifications = report["classifications"]
total = len(classifications)
# ---- Pass 1: Column-level review ----
print(f"\n{_bold('=== Pass 1: Column Classification Review ===')}")
print(f"Total columns: {total}\n")
phi_count = sum(1 for c in classifications if c["classification"] == "PHI")
review_count = sum(1 for c in classifications if c["classification"] == "REVIEW_NEEDED")
safe_count = sum(1 for c in classifications if c["classification"] == "SAFE")
print(f" {_red(f'PHI: {phi_count}')} | "
f"{_yellow(f'REVIEW_NEEDED: {review_count}')} | "
f"{_green(f'SAFE: {safe_count}')}\n")
for i, c in enumerate(classifications):
col = c["column"]
cls = c["classification"]
if cls == "SAFE" and auto_accept_safe:
c["approved_action"] = "keep"
continue
print(f"[{i + 1}/{total}] {_bold(col)}: {_format_classification(c)}")
if "sample_values" in c:
print(f" Flagged samples: {c['sample_values']}")
if cls != "SAFE":
_show_sample_values(col, data)
if cls == "SAFE":
choice = input(" Action [K]eep / (r)eview_needed? ").strip().lower()
if choice == "r":
c["classification"] = "REVIEW_NEEDED"
c["approved_action"] = None
else:
c["approved_action"] = "keep"
elif cls == "PHI":
choice = input(" Action [A]nonymize / (k)eep / (r)eview? ").strip().lower()
if choice == "k":
c["approved_action"] = "keep"
elif choice == "r":
c["classification"] = "REVIEW_NEEDED"
c["approved_action"] = None
else:
c["approved_action"] = "anonymize"
else: # REVIEW_NEEDED
_show_sample_values(col, data)
choice = input(" Action (a)nonymize / [K]eep / (f)lag_free_text? ").strip().lower()
if choice == "a":
c["approved_action"] = "anonymize"
c["classification"] = "PHI"
elif choice == "f":
c["approved_action"] = "flag"
else:
c["approved_action"] = "keep"
# ---- Pass 2: Re-examine REVIEW_NEEDED items without decisions ----
undecided = [c for c in classifications if c.get("approved_action") is None]
if undecided:
print(f"\n{_bold('=== Pass 2: Undecided Items ===')}")
for c in undecided:
col = c["column"]
print(f"\n {_bold(col)}: {_format_classification(c)}")
_show_sample_values(col, data, n=15)
choice = input(" Action (a)nonymize / [K]eep? ").strip().lower()
c["approved_action"] = "anonymize" if choice == "a" else "keep"
# ---- Pass 3: Final summary ----
print(f"\n{_bold('=== Pass 3: Final Summary ===')}")
to_anonymize = [c for c in classifications if c.get("approved_action") == "anonymize"]
to_keep = [c for c in classifications if c.get("approved_action") == "keep"]
to_flag = [c for c in classifications if c.get("approved_action") == "flag"]
print(f"\n Anonymize ({len(to_anonymize)}): "
+ ", ".join(c["column"] for c in to_anonymize) if to_anonymize else " Anonymize: none")
print(f" Keep ({len(to_keep)}): "
+ ", ".join(c["column"] for c in to_keep) if to_keep else " Keep: none")
if to_flag:
print(f" {_yellow(f'Flagged ({len(to_flag)})')}: "
+ ", ".join(c["column"] for c in to_flag))
print()
confirm = input("Proceed with these actions? [Y]es / (e)dit / (q)uit: ").strip().lower()
if confirm == "q":
sys.exit("Aborted by user.")
if confirm == "e":
# Allow editing individual items
while True:
col_name = input(" Column name to change (or 'done'): ").strip()
if col_name.lower() == "done":
break
match = [c for c in classifications if c["column"] == col_name]
if not match:
print(f" Column '{col_name}' not found.")
continue
c = match[0]
choice = input(f" New action for {col_name} — (a)nonymize / (k)eep / (f)lag: ").strip().lower()
if choice == "a":
c["approved_action"] = "anonymize"
elif choice == "f":
c["approved_action"] = "flag"
else:
c["approved_action"] = "keep"
report["reviewed"] = True
report["review_timestamp"] = datetime.now().isoformat()
return report
# ================================================================
# Section 5: Anonymizers
# ================================================================
class PseudonymGenerator:
"""Maps original values to consistent pseudonyms (P001, P002, ...)."""
def __init__(self, prefix: str = "P"):
self._map: dict[str, str] = {}
self._counter = 0
self._prefix = prefix
def get(self, original: str) -> str:
if original not in self._map:
self._counter += 1
self._map[original] = f"{self._prefix}{self._counter:04d}"
return self._map[original]
@property
def mapping(self) -> dict[str, str]:
return dict(self._map)
class DateShifter:
"""Shifts dates by a consistent per-entity offset.
The same entity (identified by entity_id) always gets the same offset,
preserving relative time intervals between events for the same entity.
"""
def __init__(self, seed: int, max_days: int = 365):
self._rng = random.Random(seed)
self._max_days = max_days
self._offsets: dict[str, int] = {}
self.seed = seed
def _get_offset(self, entity_id: str) -> int:
if entity_id not in self._offsets:
self._offsets[entity_id] = self._rng.randint(-self._max_days, self._max_days)
return self._offsets[entity_id]
def shift(self, date_str: str, entity_id: str = "__default__") -> str:
"""Attempt to parse, shift, and re-format a date string."""
offset = self._get_offset(entity_id)
delta = timedelta(days=offset)
# Try common formats
for fmt_in, fmt_out in [
("%Y-%m-%d", "%Y-%m-%d"),
("%Y.%m.%d", "%Y.%m.%d"),
("%Y/%m/%d", "%Y/%m/%d"),
("%Y%m%d", "%Y%m%d"),
]:
try:
dt = datetime.strptime(date_str.strip(), fmt_in)
return (dt + delta).strftime(fmt_out)
except ValueError:
continue
# Korean format
m = re.match(r"(\d{4})년\s*(\d{1,2})월\s*(\d{1,2})일", date_str)
if m:
try:
dt = datetime(int(m.group(1)), int(m.group(2)), int(m.group(3)))
shifted = dt + delta
return f"{shifted.year}년 {shifted.month}월 {shifted.day}일"
except ValueError:
pass
# Cannot parse — return suppressed
return "[DATE_SHIFTED]"
@property
def offsets(self) -> dict[str, int]:
return dict(self._offsets)
class IDReplacer:
"""Replaces identifiers with sequential IDs (ID001, ID002, ...)."""
def __init__(self, prefix: str = "ID"):
self._map: dict[str, str] = {}
self._counter = 0
self._prefix = prefix
def get(self, original: str) -> str:
if original not in self._map:
self._counter += 1
self._map[original] = f"{self._prefix}{self._counter:04d}"
return self._map[original]
@property
def mapping(self) -> dict[str, str]:
return dict(self._map)
def _suppress(val: str) -> str:
return "[REDACTED]"
def _sha256(val: str) -> str:
return hashlib.sha256(val.encode("utf-8")).hexdigest()
def apply_anonymization(data: list[dict], report: dict,
date_shift_seed: int | None = None) -> tuple[list[dict], dict, list[dict]]:
"""Apply approved anonymization actions.
Returns (de-identified data, mapping dict, audit entries).
"""
classifications = report["classifications"]
to_anonymize = {c["column"]: c for c in classifications
if c.get("approved_action") == "anonymize"}
if not to_anonymize:
log.info("No columns marked for anonymization.")
return data, {}, []
# Detect the entity/patient ID column for date shifting
id_columns = [col for col, c in to_anonymize.items() if c.get("phi_type") == "id"]
# Also check non-anonymized columns that look like IDs
all_id_cols = id_columns + [
c["column"] for c in classifications
if c.get("phi_type") == "id" and c.get("approved_action") == "keep"
]
# Initialize anonymizers
name_gen = PseudonymGenerator(prefix="P")
id_gen = IDReplacer(prefix="ID")
seed = date_shift_seed if date_shift_seed is not None else random.randint(1, 999999)
date_shifter = DateShifter(seed=seed)
mapping: dict[str, dict] = {
"_meta": {
"date_shift_seed": seed,
"timestamp": datetime.now().isoformat(),
"version": REPORT_VERSION,
}
}
audit: list[dict] = []
# Process each row
clean_data = []
for row_idx, row in enumerate(data):
new_row = dict(row)
# Determine entity ID for this row (for date shifting)
entity_id = "__default__"
for id_col in all_id_cols:
if row.get(id_col, "").strip():
entity_id = row[id_col].strip()
break
for col, spec in to_anonymize.items():
original = row.get(col, "")
if not original or not original.strip():
continue
phi_type = spec.get("phi_type", "unknown")
original_stripped = original.strip()
if phi_type == "name":
replacement = name_gen.get(original_stripped)
elif phi_type == "id":
replacement = id_gen.get(original_stripped)
elif phi_type == "date":
replacement = date_shifter.shift(original_stripped, entity_id)
elif phi_type in ("phone", "rrn", "email", "insurance"):
replacement = _suppress(original_stripped)
elif phi_type == "address":
replacement = _suppress(original_stripped)
elif phi_type == "free_text":
# Redact known patterns within the text
replaced = original_stripped
for regex, _ in PHI_VALUE_PATTERNS:
replaced = regex.sub("[REDACTED]", replaced)
replacement = replaced
else:
replacement = _suppress(original_stripped)
new_row[col] = replacement
audit.append({
"row": row_idx,
"column": col,
"phi_type": phi_type,
"action": "anonymize",
"before_hash": _sha256(original_stripped),
"after_value": replacement,
})
clean_data.append(new_row)
# Build mapping
mapping["names"] = name_gen.mapping
mapping["ids"] = id_gen.mapping
mapping["date_offsets"] = date_shifter.offsets
return clean_data, mapping, audit
# ================================================================
# Section 6: Output
# ================================================================
def write_deidentified_file(data: list[dict], input_path: Path,
output_dir: Path) -> Path:
"""Write de-identified data to output_dir/{stem}_deidentified.{ext}."""
fmt = detect_format(input_path)
out_name = f"{input_path.stem}_deidentified{input_path.suffix}"
out_path = output_dir / out_name
save_tabular(data, out_path, fmt)
log.info("De-identified data written to: %s", out_path)
return out_path
def write_mapping(mapping: dict, path: Path, hash_mode: bool = False) -> Path:
"""Write mapping file. In hash mode, original values are SHA-256 hashed."""
if hash_mode:
hashed = {"_meta": mapping.get("_meta", {})}
for section in ("names", "ids"):
if section in mapping:
hashed[section] = {_sha256(k): v for k, v in mapping[section].items()}
if "date_offsets" in mapping:
hashed["date_offsets"] = {_sha256(k): v for k, v in mapping["date_offsets"].items()}
out_data = hashed
else:
out_data = mapping
path.write_text(json.dumps(out_data, ensure_ascii=False, indent=2), encoding="utf-8")
# Set restrictive permissions (owner-only read/write)
try:
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR) # 0600
except OSError:
log.warning("Could not set restrictive permissions on mapping file: %s", path)
log.info("Mapping file written to: %s (permissions: 0600)", path)
return path
def write_audit_log(audit: list[dict], path: Path) -> Path:
"""Write audit log CSV. before_hash is SHA-256 of original value."""
if not audit:
log.info("No changes made; audit log is empty.")
return path
fieldnames = ["row", "column", "phi_type", "action", "before_hash", "after_value"]
with open(path, "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(audit)
log.info("Audit log written to: %s (%d entries)", path, len(audit))
return path
# ================================================================
# Section 7: Main + CLI
# ================================================================
def _resolve_locale(args: argparse.Namespace) -> dict | None:
"""Resolve locale from CLI args or interactive selection."""
if getattr(args, "locale_file", None):
locale = load_locale_file(args.locale_file)
log.info("Using custom locale: %s", locale.get("name", "Custom"))
return locale
if getattr(args, "locale", None):
locale = load_locale(args.locale)
log.info("Using locale: %s (%s)", locale["name"], locale["code"])
return locale
# Interactive selection
return select_locale_interactive()
def cmd_scan(args: argparse.Namespace) -> None:
"""Scan command: profile and classify columns."""
input_path = Path(args.input_file)
if not input_path.exists():
sys.exit(f"File not found: {input_path}")
output_dir = Path(args.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
locale = _resolve_locale(args)
log.info("Loading %s ...", input_path)
data, meta = load_tabular(input_path)
log.info("Loaded %d rows, %d columns", meta["rows"], meta["columns"])
log.info("Scanning for PHI ...")
classifications = classify_columns(data, meta["headers"], locale)
report = build_scan_report(input_path, data, meta, classifications, locale)
report_path = output_dir / "scan_report.json"
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
# Print summary
phi = sum(1 for c in classifications if c["classification"] == "PHI")
review = sum(1 for c in classifications if c["classification"] == "REVIEW_NEEDED")
safe = sum(1 for c in classifications if c["classification"] == "SAFE")
print(f"\n{_bold('Scan Results')}:")
print(f" {_red(f'PHI: {phi}')} | {_yellow(f'REVIEW_NEEDED: {review}')} | {_green(f'SAFE: {safe}')}")
print(f"\nReport saved: {report_path}")
print(f"Next step: python deidentify.py review {report_path}")
def cmd_review(args: argparse.Namespace) -> None:
"""Review command: interactive terminal review of scan report."""
report_path = Path(args.report_file)
if not report_path.exists():
sys.exit(f"Report not found: {report_path}")
report = json.loads(report_path.read_text(encoding="utf-8"))
if report.get("version", 0) != REPORT_VERSION:
log.warning("Report version mismatch (expected %d, got %d)",
REPORT_VERSION, report.get("version", 0))
# Reload original data for sample display
input_path = Path(report["input_file"])
if not input_path.exists():
sys.exit(f"Original file not found: {input_path}")
data, _ = load_tabular(input_path)
reviewed = review_scan_report(report, data,
auto_accept_safe=getattr(args, "auto_accept_safe", False))
out_path = report_path.parent / "reviewed_report.json"
out_path.write_text(json.dumps(reviewed, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"\nReviewed report saved: {out_path}")
print(f"Next step: python deidentify.py apply {out_path}")
def cmd_apply(args: argparse.Namespace) -> None:
"""Apply command: anonymize based on reviewed report."""
report_path = Path(args.report_file)
if not report_path.exists():
sys.exit(f"Report not found: {report_path}")
report = json.loads(report_path.read_text(encoding="utf-8"))
if not report.get("reviewed"):
log.warning("Report has not been reviewed. Run 'review' first.")
input_path = Path(report["input_file"])
if not input_path.exists():
sys.exit(f"Original file not found: {input_path}")
output_dir = report_path.parent
data, _ = load_tabular(input_path)
log.info("Applying anonymization ...")
clean_data, mapping, audit = apply_anonymization(data, report)
# Write outputs
deid_path = write_deidentified_file(clean_data, input_path, output_dir)
mapping_path = write_mapping(mapping, output_dir / "mapping.json",
hash_mode=getattr(args, "hash_mapping", False))
audit_path = write_audit_log(audit, output_dir / "audit_log.csv")
# Warn if mapping is in same dir as de-identified data
if mapping_path.parent == deid_path.parent:
print(f"\n{_yellow('WARNING')}: mapping.json is in the same directory as the "
"de-identified data. For security, store mapping.json separately.")
# Summary
changes = len(audit)
cols_changed = len(set(a["column"] for a in audit))
print(f"\n{_bold('De-identification Complete')}:")
print(f" Changes: {changes} cells across {cols_changed} columns")
print(f" Output: {deid_path}")
print(f" Mapping: {mapping_path}")
print(f" Audit: {audit_path}")
def cmd_full(args: argparse.Namespace) -> None:
"""Full pipeline: scan -> review -> apply in one go."""
input_path = Path(args.input_file)
if not input_path.exists():
sys.exit(f"File not found: {input_path}")
output_dir = Path(args.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
locale = _resolve_locale(args)
# Scan
log.info("Loading %s ...", input_path)
data, meta = load_tabular(input_path)
log.info("Loaded %d rows, %d columns", meta["rows"], meta["columns"])
log.info("Scanning for PHI ...")
classifications = classify_columns(data, meta["headers"], locale)
report = build_scan_report(input_path, data, meta, classifications, locale)
# Quick summary before review
phi = sum(1 for c in classifications if c["classification"] == "PHI")
review_n = sum(1 for c in classifications if c["classification"] == "REVIEW_NEEDED")
safe = sum(1 for c in classifications if c["classification"] == "SAFE")
print(f"\n{_bold('Scan Results')}:")
print(f" {_red(f'PHI: {phi}')} | {_yellow(f'REVIEW_NEEDED: {review_n}')} | {_green(f'SAFE: {safe}')}")
if phi == 0 and review_n == 0:
print(f"\n{_green('No PHI detected.')} Your data appears clean.")
confirm = input("Proceed anyway? (y/n) ").strip().lower()
if confirm != "y":
return
# Review
reviewed = review_scan_report(report, data,
auto_accept_safe=args.auto_accept_safe)
# Save report
report_path = output_dir / "reviewed_report.json"
report_path.write_text(json.dumps(reviewed, ensure_ascii=False, indent=2), encoding="utf-8")
# Apply
log.info("Applying anonymization ...")
clean_data, mapping, audit = apply_anonymization(data, reviewed)
# Write outputs
deid_path = write_deidentified_file(clean_data, input_path, output_dir)
mapping_path = write_mapping(mapping, output_dir / "mapping.json",
hash_mode=args.hash_mapping)
audit_path = write_audit_log(audit, output_dir / "audit_log.csv")
if mapping_path.parent == deid_path.parent:
print(f"\n{_yellow('WARNING')}: mapping.json is in the same directory as the "
"de-identified data. For security, store mapping.json separately.")
changes = len(audit)
cols_changed = len(set(a["column"] for a in audit))
print(f"\n{_bold('De-identification Complete')}:")
print(f" Changes: {changes} cells across {cols_changed} columns")
print(f" Output: {deid_path}")
print(f" Mapping: {mapping_path}")
print(f" Audit: {audit_path}")
def main() -> None:
parser = argparse.ArgumentParser(
prog="deidentify",
description="Clinical research data de-identification (LLM-free).",
)
parser.add_argument("-v", "--verbose", action="store_true",
help="Enable verbose logging")
sub = parser.add_subparsers(dest="command", required=True)
# Locale options (shared by scan and full)
def _add_locale_args(p: argparse.ArgumentParser) -> None:
g = p.add_mutually_exclusive_group()
g.add_argument("--locale", type=str, metavar="CODE",
help="Country code (kr, us, jp, cn, de, uk, fr, ca, au, in). "
"If omitted, interactive selection is shown.")
g.add_argument("--locale-file", type=str, metavar="PATH",
help="Path to a custom locale JSON file")
# scan
p_scan = sub.add_parser("scan", help="Scan a file for PHI")
p_scan.add_argument("input_file", help="Path to CSV/TSV/XLSX file")
p_scan.add_argument("-o", "--output-dir", default=".", help="Output directory (default: .)")
_add_locale_args(p_scan)
# review
p_review = sub.add_parser("review", help="Interactive review of scan report")
p_review.add_argument("report_file", help="Path to scan_report.json")
p_review.add_argument("--auto-accept-safe", action="store_true",
help="Automatically accept SAFE columns without prompting")
# apply
p_apply = sub.add_parser("apply", help="Apply anonymization from reviewed report")
p_apply.add_argument("report_file", help="Path to reviewed_report.json")
p_apply.add_argument("--hash-mapping", action="store_true",
help="Hash original values in mapping file (one-way)")
# full
p_full = sub.add_parser("full", help="Full pipeline: scan + review + apply")
p_full.add_argument("input_file", help="Path to CSV/TSV/XLSX file")
p_full.add_argument("-o", "--output-dir", default=".", help="Output directory (default: .)")
_add_locale_args(p_full)
p_full.add_argument("--auto-accept-safe", action="store_true",
help="Automatically accept SAFE columns without prompting")
p_full.add_argument("--hash-mapping", action="store_true",
help="Hash original values in mapping file (one-way)")
args = parser.parse_args()
logging.basicConfig(
level=logging.DEBUG if args.verbose else logging.INFO,
format="%(levelname)s: %(message)s",
)
if args.command == "scan":
cmd_scan(args)
elif args.command == "review":
cmd_review(args)
elif args.command == "apply":
cmd_apply(args)
elif args.command == "full":
cmd_full(args)
if __name__ == "__main__":
main()
{
"_comment": "Template for adding a new country locale to deidentify.py",
"_instructions": "Copy this file, rename to {country_code}.json, fill in patterns.",
"name": "Country Name",
"native_name": "Native Name",
"code": "xx",
"national_id": {
"label": "National ID Name",
"phi_type": "national_id",
"patterns": [
"REGEX_PATTERN_HERE"
]
},
"phone": [
{"label": "Mobile", "pattern": "REGEX_PATTERN_HERE"},
{"label": "Landline", "pattern": "REGEX_PATTERN_HERE"}
],
"address": {
"type": "keywords",
"keywords": ["Street", "Avenue"],
"min_ratio": 0.3
},
"name_heuristic": {
"type": "none",
"pattern": "",
"min_ratio": 0.3,
"only_in_name_columns": true
},
"date_formats": [
{"pattern": "REGEX_PATTERN_HERE", "label": "Local date format"}
],
"column_names": {
"patient_name": "name",
"ssn": "national_id",
"phone": "phone",
"address": "address"
}
}
{
"name": "Australia",
"native_name": "Australia",
"code": "au",
"national_id": {
"label": "Tax File Number (TFN) / Medicare Number",
"phi_type": "national_id",
"patterns": [
"\\b\\d{3}\\s?\\d{3}\\s?\\d{3}\\b",
"\\b\\d{4}\\s?\\d{5}\\s?\\d{1}\\b"
]
},
"phone": [
{"label": "AU Mobile", "pattern": "\\b04\\d{2}\\s?\\d{3}\\s?\\d{3}\\b"},
{"label": "AU Landline", "pattern": "\\b0[2378]\\s?\\d{4}\\s?\\d{4}\\b"},
{"label": "AU +61", "pattern": "\\b\\+61\\s?4\\d{2}\\s?\\d{3}\\s?\\d{3}\\b"}
],
"address": {
"type": "keywords",
"keywords": ["Street", "St", "Road", "Rd", "Avenue", "Ave", "Drive", "Dr", "Crescent", "Cres", "Court", "Ct", "Place", "Pl", "Lane", "Ln"],
"min_ratio": 0.3
},
"name_heuristic": {
"type": "none",
"pattern": "",
"min_ratio": 0.3,
"only_in_name_columns": true
},
"date_formats": [
{"pattern": "\\b(0?[1-9]|[12]\\d|3[01])/(0?[1-9]|1[0-2])/(19|20)\\d{2}\\b", "label": "AU date (DD/MM/YYYY)"}
],
"column_names": {
"tfn": "national_id", "tax_file_number": "national_id",
"medicare_number": "national_id", "medicare_no": "national_id",
"postcode": "address"
}
}
{
"name": "Canada",
"native_name": "Canada",
"code": "ca",
"national_id": {
"label": "Social Insurance Number (SIN)",
"phi_type": "national_id",
"patterns": [
"\\b\\d{3}[- ]?\\d{3}[- ]?\\d{3}\\b"
]
},
"phone": [
{"label": "Canadian phone", "pattern": "\\b\\(?\\d{3}\\)?[-.\\s]?\\d{3}[-.\\s]?\\d{4}\\b"},
{"label": "Canadian phone +1", "pattern": "\\b\\+?1[-.\\s]?\\(?\\d{3}\\)?[-.\\s]?\\d{3}[-.\\s]?\\d{4}\\b"}
],
"address": {
"type": "keywords",
"keywords": ["Street", "St.", "Avenue", "Ave.", "Boulevard", "Blvd.", "Drive", "Dr.", "Road", "Rd.", "Rue", "Chemin"],
"postcode_pattern": "\\b[A-Z]\\d[A-Z]\\s?\\d[A-Z]\\d\\b",
"min_ratio": 0.3
},
"name_heuristic": {
"type": "none",
"pattern": "",
"min_ratio": 0.3,
"only_in_name_columns": true
},
"date_formats": [
{"pattern": "\\b(0?[1-9]|[12]\\d|3[01])/(0?[1-9]|1[0-2])/(19|20)\\d{2}\\b", "label": "DD/MM/YYYY"},
{"pattern": "\\b(0?[1-9]|1[0-2])/(0?[1-9]|[12]\\d|3[01])/(19|20)\\d{2}\\b", "label": "MM/DD/YYYY"}
],
"column_names": {
"sin": "national_id", "social_insurance": "national_id",
"health_card": "national_id", "ohip": "national_id",
"postal_code": "address",
"numéro_assurance_sociale": "national_id"
}
}
{
"name": "China",
"native_name": "中国",
"code": "cn",
"national_id": {
"label": "身份证号 (Citizen ID)",
"phi_type": "national_id",
"patterns": [
"\\b\\d{17}[\\dXx]\\b",
"\\b\\d{15}\\b"
]
},
"phone": [
{"label": "Mobile", "pattern": "\\b1[3-9]\\d{9}\\b"},
{"label": "Landline", "pattern": "\\b0\\d{2,3}-?\\d{7,8}\\b"}
],
"address": {
"type": "suffix_regex",
"pattern": "(省|市|区|县|镇|乡|村|街道|路|号|弄|室)",
"min_ratio": 0.3
},
"name_heuristic": {
"type": "regex",
"pattern": "^[\\u4E00-\\u9FFF]{2,4}$",
"min_ratio": 0.3,
"only_in_name_columns": true
},
"date_formats": [
{"pattern": "\\b(19|20)\\d{2}年\\s?(0?[1-9]|1[0-2])月\\s?(0?[1-9]|[12]\\d|3[01])日\\b", "label": "Chinese date (YYYY年MM月DD日)"}
],
"column_names": {
"患者姓名": "name", "姓名": "name", "名字": "name",
"身份证号": "national_id", "身份证": "national_id", "证件号": "national_id",
"出生日期": "date", "生日": "date",
"电话": "phone", "手机": "phone", "联系电话": "phone", "手机号": "phone",
"地址": "address", "住址": "address", "家庭住址": "address",
"邮箱": "email", "电子邮件": "email",
"病历号": "id", "住院号": "id", "门诊号": "id", "患者编号": "id",
"医保号": "insurance", "社保号": "insurance"
}
}
{
"name": "Germany",
"native_name": "Deutschland",
"code": "de",
"national_id": {
"label": "Steuerliche Identifikationsnummer (Tax ID)",
"phi_type": "national_id",
"patterns": [
"\\b\\d{2}\\s?\\d{3}\\s?\\d{3}\\s?\\d{3}\\b"
]
},
"phone": [
{"label": "German phone", "pattern": "\\b0\\d{2,5}[/-]?\\d{3,9}\\b"},
{"label": "German phone +49", "pattern": "\\b\\+49\\s?\\d{2,5}[/-]?\\d{3,9}\\b"}
],
"address": {
"type": "keywords",
"keywords": ["Straße", "Strasse", "Str.", "Weg", "Platz", "Allee", "Gasse", "Ring", "Damm"],
"min_ratio": 0.3
},
"name_heuristic": {
"type": "none",
"pattern": "",
"min_ratio": 0.3,
"only_in_name_columns": true
},
"date_formats": [
{"pattern": "\\b(0?[1-9]|[12]\\d|3[01])\\.(0?[1-9]|1[0-2])\\.(19|20)\\d{2}\\b", "label": "German date (DD.MM.YYYY)"}
],
"column_names": {
"patientenname": "name", "vorname": "name", "nachname": "name",
"name": "name", "familienname": "name",
"steuer_id": "national_id", "steuernummer": "national_id",
"geburtsdatum": "date", "geb_datum": "date",
"telefon": "phone", "telefonnummer": "phone", "handy": "phone", "mobilnummer": "phone",
"adresse": "address", "anschrift": "address", "wohnort": "address", "plz": "address",
"postleitzahl": "address",
"e_mail": "email",
"patientennummer": "id", "fallnummer": "id", "krankenhaus_id": "id",
"versichertennummer": "insurance", "krankenversicherung": "insurance"
}
}
{
"name": "France",
"native_name": "France",
"code": "fr",
"national_id": {
"label": "Numéro de sécurité sociale (NIR/INSEE)",
"phi_type": "national_id",
"patterns": [
"\\b[12]\\s?\\d{2}\\s?\\d{2}\\s?\\d{2}\\s?\\d{3}\\s?\\d{3}\\s?\\d{2}\\b"
]
},
"phone": [
{"label": "French phone", "pattern": "\\b0[1-9]\\s?\\d{2}\\s?\\d{2}\\s?\\d{2}\\s?\\d{2}\\b"},
{"label": "French phone +33", "pattern": "\\b\\+33\\s?[1-9]\\s?\\d{2}\\s?\\d{2}\\s?\\d{2}\\s?\\d{2}\\b"}
],
"address": {
"type": "keywords",
"keywords": ["Rue", "Avenue", "Boulevard", "Place", "Allée", "Impasse", "Chemin", "Cours", "Passage"],
"min_ratio": 0.3
},
"name_heuristic": {
"type": "none",
"pattern": "",
"min_ratio": 0.3,
"only_in_name_columns": true
},
"date_formats": [
{"pattern": "\\b(0?[1-9]|[12]\\d|3[01])/(0?[1-9]|1[0-2])/(19|20)\\d{2}\\b", "label": "French date (DD/MM/YYYY)"}
],
"column_names": {
"nom_patient": "name", "nom": "name", "prénom": "name", "prenom": "name",
"nom_de_famille": "name",
"numéro_sécu": "national_id", "numero_secu": "national_id",
"nir": "national_id", "insee": "national_id",
"date_de_naissance": "date", "date_naissance": "date",
"téléphone": "phone", "telephone": "phone", "portable": "phone", "mobile": "phone",
"adresse": "address", "code_postal": "address",
"courriel": "email",
"numéro_patient": "id", "numero_patient": "id", "ipp": "id",
"numéro_assurance": "insurance", "numero_assurance": "insurance"
}
}
{
"name": "India",
"native_name": "भारत",
"code": "in",
"national_id": {
"label": "Aadhaar / PAN",
"phi_type": "national_id",
"patterns": [
"\\b\\d{4}\\s?\\d{4}\\s?\\d{4}\\b",
"\\b[A-Z]{5}\\d{4}[A-Z]\\b"
]
},
"phone": [
{"label": "Indian Mobile", "pattern": "\\b[6-9]\\d{9}\\b"},
{"label": "Indian Mobile +91", "pattern": "\\b\\+91[-.\\s]?[6-9]\\d{9}\\b"},
{"label": "Indian Landline", "pattern": "\\b0\\d{2,4}[-.\\s]?\\d{6,8}\\b"}
],
"address": {
"type": "keywords",
"keywords": ["Road", "Rd.", "Nagar", "Colony", "Street", "Marg", "Path", "Lane", "Gali", "Mohalla", "Chowk", "District", "Dist."],
"postcode_pattern": "\\b\\d{6}\\b",
"min_ratio": 0.3
},
"name_heuristic": {
"type": "none",
"pattern": "",
"min_ratio": 0.3,
"only_in_name_columns": true
},
"date_formats": [
{"pattern": "\\b(0?[1-9]|[12]\\d|3[01])/(0?[1-9]|1[0-2])/(19|20)\\d{2}\\b", "label": "Indian date (DD/MM/YYYY)"},
{"pattern": "\\b(0?[1-9]|[12]\\d|3[01])-(0?[1-9]|1[0-2])-(19|20)\\d{2}\\b", "label": "Indian date (DD-MM-YYYY)"}
],
"column_names": {
"aadhaar": "national_id", "aadhaar_number": "national_id",
"aadhar": "national_id", "aadhar_number": "national_id",
"pan": "national_id", "pan_number": "national_id",
"pin_code": "address", "pincode": "address",
"uhid": "id", "hospital_registration": "id",
"abha_number": "id", "health_id": "id"
}
}
{
"name": "Japan",
"native_name": "日本",
"code": "jp",
"national_id": {
"label": "マイナンバー (My Number)",
"phi_type": "national_id",
"patterns": [
"\\b\\d{4}\\s?\\d{4}\\s?\\d{4}\\b"
]
},
"phone": [
{"label": "Mobile (090/080/070)", "pattern": "\\b0[789]0-?\\d{4}-?\\d{4}\\b"},
{"label": "Landline", "pattern": "\\b0\\d{1,4}-?\\d{1,4}-?\\d{4}\\b"}
],
"address": {
"type": "suffix_regex",
"pattern": "(都|道|府|県|市|区|町|村|丁目|番地|号)",
"min_ratio": 0.3
},
"name_heuristic": {
"type": "regex",
"pattern": "^[\\u4E00-\\u9FFF\\u3040-\\u309F\\u30A0-\\u30FF]{2,6}$",
"min_ratio": 0.3,
"only_in_name_columns": true
},
"date_formats": [
{"pattern": "\\b(令和|平成|昭和)\\s?\\d{1,2}年\\s?\\d{1,2}月\\s?\\d{1,2}日\\b", "label": "Japanese era date"},
{"pattern": "\\b(19|20)\\d{2}年\\s?\\d{1,2}月\\s?\\d{1,2}日\\b", "label": "Japanese date (YYYY年MM月DD日)"}
],
"column_names": {
"患者名": "name", "氏名": "name", "姓名": "name", "名前": "name",
"姓": "name", "名": "name",
"マイナンバー": "national_id", "個人番号": "national_id",
"生年月日": "date", "誕生日": "date",
"電話番号": "phone", "携帯番号": "phone", "連絡先": "phone",
"住所": "address", "自宅住所": "address", "居住地": "address",
"メール": "email", "メールアドレス": "email",
"患者番号": "id", "カルテ番号": "id", "診察券番号": "id",
"保険番号": "insurance", "被保険者番号": "insurance"
}
}
{
"name": "Korea",
"native_name": "한국",
"code": "kr",
"national_id": {
"label": "주민등록번호 (RRN)",
"phi_type": "rrn",
"patterns": [
"\\b\\d{6}-[1-4]\\d{6}\\b"
]
},
"phone": [
{"label": "Mobile", "pattern": "\\b01[016789]-?\\d{3,4}-?\\d{4}\\b"},
{"label": "Landline", "pattern": "\\b0[2-6][0-9]{0,2}-?\\d{3,4}-?\\d{4}\\b"}
],
"address": {
"type": "suffix_regex",
"pattern": "(특별시|광역시|특별자치시|특별자치도|도\\s|시\\s|군\\s|구\\s|읍\\s|면\\s|동\\s|리\\s|로\\s|길\\s)",
"min_ratio": 0.3
},
"name_heuristic": {
"type": "regex",
"pattern": "^[\\uAC00-\\uD7AF]{2,4}$",
"min_ratio": 0.3,
"only_in_name_columns": true
},
"date_formats": [
{"pattern": "\\b(19|20)\\d{2}년\\s*(0?[1-9]|1[0-2])월\\s*(0?[1-9]|[12]\\d|3[01])일\\b", "label": "Korean date (YYYY년 MM월 DD일)"}
],
"column_names": {
"환자명": "name", "성명": "name", "이름": "name", "성함": "name",
"주민번호": "rrn", "주민등록번호": "rrn",
"생년월일": "date", "생년": "date", "출생일": "date",
"전화번호": "phone", "연락처": "phone", "핸드폰": "phone",
"휴대폰": "phone", "휴대전화": "phone", "자택전화": "phone",
"주소": "address", "자택주소": "address", "거주지": "address",
"이메일": "email",
"차트번호": "id", "등록번호": "id", "환자번호": "id",
"의무기록번호": "id", "원무번호": "id",
"보험번호": "insurance"
}
}
{
"name": "United Kingdom",
"native_name": "United Kingdom",
"code": "uk",
"national_id": {
"label": "NHS Number / National Insurance Number",
"phi_type": "national_id",
"patterns": [
"\\b\\d{3}\\s?\\d{3}\\s?\\d{4}\\b",
"\\b[A-CEGHJ-PR-TW-Z]{2}\\d{6}[A-D]\\b"
]
},
"phone": [
{"label": "UK Mobile", "pattern": "\\b07\\d{3}\\s?\\d{6}\\b"},
{"label": "UK Landline", "pattern": "\\b0\\d{4}\\s?\\d{6}\\b"},
{"label": "UK +44", "pattern": "\\b\\+44\\s?\\d{4}\\s?\\d{6}\\b"}
],
"address": {
"type": "keywords",
"keywords": ["Street", "St.", "Road", "Rd.", "Avenue", "Ave.", "Lane", "Ln.", "Close", "Crescent", "Terrace", "Drive"],
"postcode_pattern": "\\b[A-Z]{1,2}\\d[A-Z\\d]?\\s?\\d[A-Z]{2}\\b",
"min_ratio": 0.3
},
"name_heuristic": {
"type": "none",
"pattern": "",
"min_ratio": 0.3,
"only_in_name_columns": true
},
"date_formats": [
{"pattern": "\\b(0?[1-9]|[12]\\d|3[01])/(0?[1-9]|1[0-2])/(19|20)\\d{2}\\b", "label": "UK date (DD/MM/YYYY)"},
{"pattern": "\\b(0?[1-9]|[12]\\d|3[01])-(0?[1-9]|1[0-2])-(19|20)\\d{2}\\b", "label": "UK date (DD-MM-YYYY)"}
],
"column_names": {
"nhs_number": "national_id", "nhs_no": "national_id",
"ni_number": "national_id", "national_insurance": "national_id",
"postcode": "address", "post_code": "address"
}
}
{
"name": "United States",
"native_name": "United States",
"code": "us",
"national_id": {
"label": "Social Security Number (SSN)",
"phi_type": "rrn",
"patterns": [
"\\b\\d{3}-\\d{2}-\\d{4}\\b"
]
},
"phone": [
{"label": "US Phone", "pattern": "\\b\\(?\\d{3}\\)?[-.\\s]?\\d{3}[-.\\s]?\\d{4}\\b"},
{"label": "US Phone +1", "pattern": "\\b\\+?1[-.\\s]?\\(?\\d{3}\\)?[-.\\s]?\\d{3}[-.\\s]?\\d{4}\\b"}
],
"address": {
"type": "keywords",
"keywords": ["Street", "St.", "Avenue", "Ave.", "Boulevard", "Blvd.", "Drive", "Dr.", "Lane", "Ln.", "Road", "Rd.", "Court", "Ct.", "Suite", "Ste.", "Apt.", "P.O. Box"],
"min_ratio": 0.3
},
"name_heuristic": {
"type": "none",
"pattern": "",
"min_ratio": 0.3,
"only_in_name_columns": true
},
"date_formats": [
{"pattern": "\\b(0?[1-9]|1[0-2])/(0?[1-9]|[12]\\d|3[01])/(19|20)\\d{2}\\b", "label": "US date (MM/DD/YYYY)"},
{"pattern": "\\b(0?[1-9]|1[0-2])-(0?[1-9]|[12]\\d|3[01])-(19|20)\\d{2}\\b", "label": "US date (MM-DD-YYYY)"}
],
"column_names": {
"social_security_number": "rrn", "social_security": "rrn",
"ssn": "rrn", "ss_number": "rrn",
"zip": "address", "zipcode": "address", "zip_code": "address",
"state": "address"
}
}
Date Shifting Guide
What is Date Shifting?
Date shifting replaces actual dates with shifted dates, preserving the relative time intervals between events for the same patient. This maintains temporal relationships (e.g., "surgery was 3 days after admission") while removing the absolute dates that could identify individuals.
SANT Method (Shift and Truncate)
The recommended approach, used by PCORI Clinical Data Research Networks:
1. Generate offset: For each patient, draw a random integer from [-365, +365] days 2. Apply uniformly: Shift ALL dates for that patient by the same offset 3. Truncate edges: Remove records near dataset boundaries where shifting would push dates outside the study period
Why per-patient offset?
- Preserves relative intervals between events for the same patient
- "Admission → Surgery → Discharge" intervals remain exact
- Different patients get different offsets (prevents cross-patient inference)
Implementation Details
Seed Management
- A random seed is generated once per run and stored in
mapping.json - This makes the shifting deterministic and reproducible
- Re-running with the same seed on the same data produces identical results
- The seed should be kept as securely as the mapping file itself
Entity Identification
The script looks for a patient/entity ID column to group dates: 1. Columns classified as id type (차트번호, MRN, patient_id, etc.) 2. If no ID column exists, all dates are shifted by the same global offset
Supported Date Formats
| Format | Example | Preserved |
|---|---|---|
| ISO | 2024-03-15 | Yes |
| Dot-separated | 2024.03.15 | Yes |
| Slash-separated | 2024/03/15 | Yes |
| Compact | 20240315 | Yes |
| Korean | 2024년 3월 15일 | Yes |
Unparseable Dates
If a date string cannot be parsed by any known format, it is replaced with [DATE_SHIFTED] rather than left unchanged (fail-safe).
When NOT to Date Shift
- Study-level dates that are already public (e.g., "data collected 2020-2023")
- Seasonal analyses where month or season is a study variable
- Age at event: Calculate age before shifting, then shift the dates
Edge Cases
- Impossible dates after shift: Feb 30 → script uses Python datetime which
handles month-end overflow (raises ValueError → caught and adjusted)
- Leap years: Feb 29 shifted to a non-leap year → becomes Feb 28
- Cross-year boundaries: Handled correctly by timedelta arithmetic
- Negative ages: If birth date is shifted forward past an event date,
the relative interval (age at event) is still preserved
Re-identification Risk
Even with date shifting, be aware:
- Rare diseases + approximate date range can still narrow candidates
- If attacker knows the shift range (±365), they have a 730-day window
- For very small populations, consider larger shift ranges or year-only dates
References
- Hripcsak G, et al. "Bias Associated with Mining Electronic Health Records."
J Biomed Inform. 2011;44(6):1120-1126. (Temporal relationship preservation)
- Meystre SM, et al. "Automatic De-identification of Textual Documents in the
Electronic Health Record." BMC Med Res Methodol. 2010;10:70.
HIPAA Safe Harbor: 18 Identifiers
Reference for the deidentify skill. Based on HIPAA Privacy Rule 164.514(b)(2).
Identifier List
| # | Identifier | Script action | Manual review needed |
|---|---|---|---|
| 1 | Names | Pseudonymize (P001, P002...) | No |
| 2 | Geographic data (< state) | Suppress | Yes — ZIP first 3 digits may be kept if population >= 20,000 |
| 3 | Dates (except year) | Date shift (per-patient offset) | No |
| 4 | Ages > 89 | Generalize to "90+" | No |
| 5 | Telephone numbers | Suppress | No |
| 6 | Fax numbers | Suppress | No |
| 7 | Email addresses | Suppress | No |
| 8 | Social Security numbers | Suppress | No |
| 9 | Medical record numbers | Replace (ID001, ID002...) | No |
| 10 | Health plan beneficiary numbers | Suppress | No |
| 11 | Account numbers | Suppress | Yes — rare in research data |
| 12 | Certificate/license numbers | Suppress | Yes — rare in research data |
| 13 | Vehicle identifiers | Suppress | Yes — only in trauma data |
| 14 | Device identifiers | Suppress | Yes — only in device studies |
| 15 | Web URLs | Suppress | No |
| 16 | IP addresses | Suppress | No |
| 17 | Biometric identifiers | N/A (not in tabular data) | N/A |
| 18 | Full-face photographs | N/A (not in tabular data) | N/A |
Korean Equivalents
| HIPAA identifier | Korean equivalent | 한국 개인정보보호법 분류 |
|---|---|---|
| Names | 성명 | 고유식별정보 |
| SSN | 주민등록번호 | 고유식별정보 |
| MRN | 차트번호/의무기록번호 | 개인정보 |
| Phone | 전화번호/연락처 | 개인정보 |
| Address | 주소 | 개인정보 |
| DOB | 생년월일 | 개인정보 |
| 이메일 | 개인정보 | |
| Insurance no. | 건강보험증 번호 | 고유식별정보 |
Notes
- Items 17-18 (biometrics, photos) are outside the scope of this tool (tabular data only).
- Items 11-14 (accounts, certificates, vehicles, devices) are rare in clinical research datasets
but should be flagged if column names suggest their presence.
- For Korean data, 주민등록번호 is the most critical direct identifier (combines birthdate + gender).
- The script detects items 1-10 and 15-16 via column names and regex patterns.
Items 11-14 rely on column-name heuristics only.
Korean PHI Patterns
Locale: Korean. This reference intentionally contains Korean — it documents the Korean PHI-detection patterns (thekrlocale feature of/deidentify). Seedocs/locale_inventory.md.
Regex patterns and column-name dictionaries used by deidentify.py. This file serves as documentation; the actual patterns are in the Python script.
Value-Level Regex Patterns
주민등록번호 (Resident Registration Number)
\d{6}-[1-4]\d{6}- Format:
YYMMDD-GNNNNNN - First 6 digits: birthdate (YYMMDD)
- 7th digit (G): gender + birth century (1=1900s male, 2=1900s female, 3=2000s male, 4=2000s female)
- Remaining 6: region code + serial + check digit
- Example:
850315-1234567
외국인등록번호 (Alien Registration Number)
\d{6}-[5-8]\d{6}- Same format as 주민번호 but 7th digit is 5-8
- Covered by the same regex when expanded to
[1-8]
전화번호 — 휴대전화 (Mobile Phone)
01[016789]-?\d{3,4}-?\d{4}- Prefixes: 010, 011, 016, 017, 018, 019
- Dashes optional
- Examples:
010-1234-5678,01012345678,011-234-5678
전화번호 — 유선전화 (Landline)
0[2-6][0-9]{0,2}-?\d{3,4}-?\d{4}- Area codes: 02 (Seoul), 031-033 (Gyeonggi), 041-044 (Chungcheong), 051-055 (Gyeongsang), 061-064 (Jeolla/Jeju)
- Examples:
02-555-1234,031-765-4321,051-234-5678
이메일 (Email)
[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}- Standard email pattern
- Common Korean domains: naver.com, daum.net, hanmail.net, kakao.com
날짜 (Date)
ISO format:
(19|20)\d{2}[-/.](0[1-9]|1[0-2])[-/.](0[1-9]|[12]\d|3[01])Korean format:
(19|20)\d{2}년\s*(0?[1-9]|1[0-2])월\s*(0?[1-9]|[12]\d|3[01])일Short (YYMMDD):
([5-9]\d|0[0-4])(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])주소 (Address)
Korean address suffix pattern:
(특별시|광역시|특별자치시|특별자치도|도\s|시\s|군\s|구\s|읍\s|면\s|동\s|리\s|로\s|길\s)- Matches administrative divisions that appear in Korean addresses
- Detection threshold: >30% of values in a column contain these patterns
한국인 이름 (Korean Name)
[\uAC00-\uD7AF]{2,4}- 2 to 4 Hangul syllable characters
- CRITICAL: Only applied to columns identified as name-type by column name
- Common Korean words (정상, 양성, 음성, etc.) also match this pattern
- False positive mitigation: match against column name hints only
Column Name Dictionary
Name-type columns
환자명, 성명, 이름, 성함, patient_name, patientname, pt_name, name, first_name, last_name
RRN-type columns
주민번호, 주민등록번호, ssn, social_security
Date-type columns
생년월일, 생년, 출생일, dob, date_of_birth, birth_date, birthdate
Phone-type columns
전화번호, 연락처, 핸드폰, 휴대폰, 휴대전화, 자택전화, phone, telephone, mobile, phone_number, cell
Address-type columns
주소, 자택주소, 거주지, address, home_address, street, zip, zipcode, zip_code
Email-type columns
이메일, email, email_address
ID-type columns
차트번호, 등록번호, 환자번호, 의무기록번호, 원무번호, mrn, medical_record, chart_no, patient_id, patientid, chart_number, record_number, hospital_id
Insurance-type columns
보험번호, insurance_no, insurance_number
High-Cardinality Numeric Detection
Columns not matching any name pattern but containing:
- >90% pure numeric values (digits only)
- Length >= 5 digits
- >80% unique values
These are flagged as potential MRN/chart numbers for researcher review.
schema_version: 2
name: deidentify
layer: A
owner_domain: data_preparation
maturity: official
when_to_use: "De-identify clinical CSV/TSV/Excel data locally before any LLM-assisted analysis."
when_NOT_to_use: "General data cleaning or type fixing (use clean-data). Never to have the agent itself read, paste, or process raw PHI."
inputs:
- path: "raw clinical data file (CSV / TSV / XLSX)"
schema: csv
required: true
outputs:
- path: "de-identified data file"
- path: "scan report (column classifications, no raw values)"
- path: "audit log (SHA-256 hashes only)"
deterministic_scripts:
- deidentify.py
side_effects:
- writes_deidentified_data
- runs_locally_no_network
downstream_consumers:
- clean-data
- analyze-stats
forbidden_actions:
- read_raw_phi
- display_mapping_file
- send_phi_to_llm
# v2.1 quality card
purpose: "Detect and remove PHI from clinical tabular data with a local-only CLI, so downstream LLM analysis never touches raw identifiers."
safety_boundaries:
- "The agent never sees raw PHI; de-identification runs in a standalone local script with no network or AI calls."
- "Never reads or displays the re-identification mapping file (it holds original PHI values)."
- "Only the scan report (no raw values), the hash-only audit log, and the de-identified output may be read."
known_limitations:
- "Regex and heuristic detection across 10 country locale packs is not a substitute for expert disclosure review or an IRB determination."
- "PHI coverage is limited to the bundled locale packs (kr, us, jp, cn, de, uk, fr, ca, au, in)."
validation_commands:
- "python deidentify.py scan <file> --locale <code> # review column classifications before stripping"
- "inspect the SHA-256 audit log after a full run"
evidence_surface: bundled_script
Deidentify Test Fixtures
All CSV files in this directory contain synthetic test data only. No real patient or person is represented.
- Names (
김철수,이영희,박민수, etc.) are common Korean placeholder
names equivalent to "John Doe" / "Jane Doe" in English. They were chosen precisely because they are generic enough to be unattributable to any real individual.
- RRN (주민번호) values follow the public format specification but the
digits are arbitrary and do not validate against the official checksum algorithm used by the Korean civil registry.
- Phone numbers, addresses, emails, chart numbers, and
diagnoses are all constructed for the purpose of exercising the PHI detector regexes shipped with /deidentify.
These fixtures exist to verify that the de-identifier: 1. Detects the PHI patterns the skill claims to detect. 2. Leaves non-PHI fields (clinical measurements, dates of routine nature) untouched. 3. Handles edge cases (mixed date formats, half-width vs full-width digits, comma vs newline separators, missing fields).
If you need to add a new fixture, follow the same rule: every value must be either a published format example or a constructed synthetic string. Never copy real EMR data into this directory, even for one-off debugging.
sample_id,measurement_a,measurement_b,category,score,group,result
S001,45.2,12.3,A,78,control,positive
S002,38.7,15.6,B,82,treatment,negative
S003,52.1,9.8,A,91,control,positive
S004,41.5,11.2,C,67,treatment,negative
S005,36.9,14.7,B,73,control,positive
S006,48.3,10.5,A,85,treatment,positive
S007,39.6,13.1,C,69,control,negative
S008,55.0,8.9,B,94,treatment,positive
S009,42.8,12.0,A,76,control,negative
S010,37.4,16.2,C,88,treatment,positive
S011,50.5,11.8,A,71,control,negative
S012,44.1,13.5,B,83,treatment,positive
S013,46.7,10.1,C,79,control,positive
S014,40.2,14.0,A,86,treatment,negative
S015,53.8,9.3,B,92,control,positive
#!/usr/bin/env bash
# Regression test for the PHI scanner (deidentify.py scan).
# Asserts the exact classification contract on three committed CSV fixtures so a
# silent break in PHI detection -- which would leak patient data -- fails CI.
# CSV scan path is stdlib-only (openpyxl is a lazy import for .xlsx only),
# so this test needs no third-party deps and makes no network calls.
#
# This shell file contains NO Hangul (keeps clear of the locale-inventory gate).
# Column-specific assertions read the fixture header at runtime and address
# columns positionally; the Korean data itself lives only in the fixture CSVs.
set -u
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SKILL="$HERE/.."
SCRIPT="$SKILL/deidentify.py"
OUTDIR="$(mktemp -d -t deid_XXXX)"
trap 'rm -rf "$OUTDIR"' EXIT
fail=0
check() { local label="$1"; shift
if "$@" >/dev/null 2>&1; then printf ' PASS %s\n' "$label"
else printf ' FAIL %s\n' "$label"; fail=$((fail+1)); fi
}
[[ -f "$SCRIPT" ]] || { echo "ENV-ERR: deidentify.py missing" >&2; exit 2; }
for fx in test_phi_korean test_clean test_edge_cases; do
[[ -f "$HERE/$fx.csv" ]] || { echo "ENV-ERR: fixture $fx.csv missing" >&2; exit 2; }
done
# Counts a classification class in a scan_report.json.
# usage: count <report.json> <PHI|REVIEW_NEEDED|SAFE>
COUNTER='
import json,sys
d=json.load(open(sys.argv[1]))
from collections import Counter
c=Counter(x["classification"] for x in d["classifications"])
print(c.get(sys.argv[2],0))'
count() { python3 -c "$COUNTER" "$1" "$2"; }
# --- Fixture 1: Korean PHI (10 columns) -> PHI=7, REVIEW_NEEDED=0, SAFE=3 ---
python3 "$SCRIPT" scan "$HERE/test_phi_korean.csv" --locale kr -o "$OUTDIR" >/dev/null 2>&1
PHI_REPORT="$OUTDIR/scan_report.json"
check "phi fixture: report written" test -s "$PHI_REPORT"
check "phi fixture: PHI == 7" test "$(count "$PHI_REPORT" PHI)" -eq 7
check "phi fixture: REVIEW_NEEDED == 0" test "$(count "$PHI_REPORT" REVIEW_NEEDED)" -eq 0
check "phi fixture: SAFE == 3" test "$(count "$PHI_REPORT" SAFE)" -eq 3
# Exactly one column has phi_type 'rrn' (resident-registration-number) -> PHI.
check "phi fixture: rrn column is PHI/rrn" python3 -c "
import json
d=json.load(open('$PHI_REPORT'))
rrn=[x for x in d['classifications'] if x.get('phi_type')=='rrn']
assert len(rrn)==1, rrn
assert rrn[0]['classification']=='PHI', rrn[0]"
# Header positions 7 (diagnosis) and 8 (measurement) must be SAFE.
# Read the header from the fixture so this file stays Hangul-free.
check "phi fixture: diagnosis + measurement (cols 7,8) are SAFE" python3 -c "
import json,csv
d=json.load(open('$PHI_REPORT'))
with open('$HERE/test_phi_korean.csv', encoding='utf-8') as f:
hdr=next(csv.reader(f))
cl={x['column']: x['classification'] for x in d['classifications']}
for i in (7, 8):
assert cl.get(hdr[i])=='SAFE', (i, hdr[i], cl.get(hdr[i]))"
# --- Fixture 2: clean (no PHI) -> PHI == 0 (false-positive guard) ---
python3 "$SCRIPT" scan "$HERE/test_clean.csv" --locale kr -o "$OUTDIR" >/dev/null 2>&1
CLEAN_REPORT="$OUTDIR/scan_report.json"
check "clean fixture: PHI == 0" test "$(count "$CLEAN_REPORT" PHI)" -eq 0
# --- Fixture 3: edge cases -> REVIEW_NEEDED=1, SAFE=5 (no crash, fixed contract) ---
python3 "$SCRIPT" scan "$HERE/test_edge_cases.csv" --locale kr -o "$OUTDIR" >/dev/null 2>&1
check "edge fixture: exit 0 (no crash)" test "$?" -eq 0
EDGE_REPORT="$OUTDIR/scan_report.json"
check "edge fixture: REVIEW_NEEDED == 1" test "$(count "$EDGE_REPORT" REVIEW_NEEDED)" -eq 1
check "edge fixture: SAFE == 5" test "$(count "$EDGE_REPORT" SAFE)" -eq 5
echo "fail=$fail"; [[ "$fail" -eq 0 ]] && echo "ALL PASS" || echo "FAILURES: $fail"
exit "$fail"
col_a,col_b,비고,숫자값,빈컬럼,긴한글단어
값1,123,,45.6,,대한민국
,456,일반 메모,78.9,,
값3,,환자 김철수 연락처 010-1234-5678,12.3,,서울특별시강남구테헤란로
값4,789,"콤마, 포함 값",0,,
,,,,,
값6,012,정상 소견,99.9,,가나다라마바사아자차카타파하
값7,345,2024년 3월 15일 검사,55.5,,
값8,678,,33.3,,AB
값9,901,"따옴표 ""포함"" 값",77.7,,정상
값10,234,특이사항 없음,44.4,,대한외과학회
환자명,주민번호,전화번호,생년월일,주소,이메일,차트번호,진단,측정값,비고
김철수,850315-1234567,010-1234-5678,1985-03-15,서울특별시 강남구 테헤란로 123,chulsu@example.com,12345678,위암,45.2,정기검진
이영희,901220-2345678,010-9876-5432,1990-12-20,부산광역시 해운대구 우동 456,yhlee@test.kr,23456789,유방암,38.7,
박민수,780505-1567890,02-555-1234,1978-05-05,대구광역시 수성구 범어동 789-12,minsoo.park@hospital.org,34567890,폐결절,12.3,재검 필요
정미영,880101-2678901,010-5555-9999,1988년 1월 1일,인천광역시 남동구 구월동 321,miyoung@naver.com,45678901,정상,67.8,
최준호,950730-1789012,051-234-5678,1995.07.30,광주광역시 서구 화정동 654,junho.choi@gmail.com,56789012,고혈압,120.5,약물 복용 중
홍길동,700101-1890123,010-1111-2222,1970-01-01,경기도 성남시 분당구 판교로 100,gildong@daum.net,67890123,당뇨,200.3,합병증 주의
강수현,920415-2901234,010-3333-4444,1992.04.15,세종특별자치시 아름동 555,suhyun.kang@outlook.com,78901234,갑상선결절,2.1,
윤대한,830620-1012345,031-765-4321,1983-06-20,충청남도 천안시 동남구 신방동 888,daehan@company.co.kr,89012345,정상,55.0,건강
임소연,960808-2123456,010-7777-8888,1996년 8월 8일,전라북도 전주시 완산구 효자동 222,soyeon.lim@yonsei.ac.kr,90123456,빈혈,8.5,추적검사
한지민,750225-2234567,042-333-9876,1975-02-25,제주특별자치도 제주시 연동 777,jimin.han@korea.ac.kr,01234567,대장용종,15.0,내시경 추적
Related skills
FAQ
Does the LLM see the raw patient data?
No. The de-identification is performed by a standalone Python script that runs locally without any LLM or network calls; the model only guides and verifies.
Which countries are supported?
Ten locale packs: Korea, USA, Japan, China, Germany, UK, France, Canada, Australia, and India.