noteguard-agent / src /deid.py
github-actions[bot]
Deploy 9aa839066bbf99a8ada733b41479a39770b3bb83 from main
eb83689
Raw
History Blame Contribute Delete
17.6 kB
"""NoteGuard de-identification core.
Dependency-free and runnable on its own (`python noteguard/deid.py`).
If `presidio-analyzer` + a spaCy model are installed the module upgrades
free-text PERSON/LOCATION detection automatically; the rule + vault layer
below always runs without them.
Design:
- NHS-aware recognisers (NHS number, GMC/NMC, postcode, DOB, email, phone)
- vault of known identifiers from patients.csv + admissions.csv so redaction
is exact AND measurable
- patient-consistent surrogates (same original → same token across notes)
- DOB date-shift (kept clinically plausible); other identifiers tokenised
- reidentify() restores originals for the CLINICIAN's eyes only
- assert_clean() is the hard guarantee that no identifier reaches the model
"""
from __future__ import annotations
import csv
import os
import re
from dataclasses import dataclass
from datetime import datetime, timedelta
NHS_NUMBER = re.compile(r"\b\d{3}[ -]?\d{3}[ -]?\d{4}\b")
NHS_CONTEXT = re.compile(r"(?i)\bNHS(?:\s*(?:no\.?|number|#))?[:\s]*([0-9][0-9 \-]{6,12}\d)")
UK_POSTCODE = re.compile(r"\b([A-Z]{1,2}\d[A-Z\d]? ?\d[A-Z]{2})\b")
DOB = re.compile(r"\b(\d{1,2}[/\-.]\d{1,2}[/\-.]\d{2,4})\b")
EMAIL = re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.-]+\b")
PHONE = re.compile(r"\b0\d{2,3}[ -]?\d{3,4}[ -]?\d{3,4}\b")
# GMC/NMC: optional connector word then optional colon/spaces before the ID
# Handles: "GMC No. 7654321", "NMC number: 18D6896L", "PIN 18D6896L", etc.
_CONN = r"(?:\s*(?:no\.?|number|reg(?:istration)?|pin|#))?[:\s]*"
GMC = re.compile(r"(?i)\bGMC" + _CONN + r"(\d{7})\b")
NMC = re.compile(r"(?i)\b(?:NMC|PIN)" + _CONN + r"(\d{2}[A-Z]\d{4}[A-Z])\b")
# Surrogate token pattern — catches any [LABEL_n] the model might invent
_SURROGATE_PAT = re.compile(r"\[[A-Z]+_\d+\]")
# Broader surrogate-shaped pattern: [LABEL_<anything>]. Catches both [LABEL_n] and
# stray template placeholders the model may echo from the system prompt (e.g.
# [DATE_X]), so none reach the clinician verbatim. Used by redact_unresolved().
_ORPHAN_PAT = re.compile(r"\[[A-Z]+_[A-Za-z0-9]+\]")
# Person-title prefixes that almost always precede a real name. Used by the
# trust-panel audit (scan_pii) to flag free-text names that slipped past the
# vault / NER passes — e.g. "Dr Ethel Joanne Duffy", "Nurse Jasmine Freda Murray".
# Requires >= 2 Title-Case tokens after the title so role words ("Nurse
# Practitioner", "Consultant Cardiologist") do not false-positive, and a correctly
# tokenised name ("Dr [PERSON_1]") never matches because "[" is not a letter.
_PERSON_TITLE = (
r"(?:Dr|Doctor|Mr|Mrs|Ms|Miss|Prof|Professor|Nurse|Sister|Matron|Midwife|Sir|Dame|Consultant|GP)"
)
_NAME_AFTER_TITLE = re.compile(r"\b" + _PERSON_TITLE + r"\b[.,:]?\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+){1,3})")
# Column names we look for in any CSV to extract person names
_NAME_COLS = frozenset(
{
"full_name",
"patient_name",
"first_name",
"surname",
"last_name",
"clinician_name",
"author_name",
"author",
"attending",
"attending_physician",
"nurse",
"consultant",
"doctor",
"provider",
}
)
# ── Optional NLP detector (Presidio + spaCy) ─────────────────────────────────
# Clinical terms/abbreviations that the small spaCy model (en_core_web_md) often
# mislabels as PERSON/LOCATION. They are never redacted as names even when the NER
# layer flags them — over-redaction like "Subcut" -> [PERSON_3] is wrong and noisy.
# Compared case-insensitively; keep to terms that are not plausible real names.
_NER_STOPWORDS = frozenset(
{
"subcut",
"subcutaneous",
"obs",
"sats",
"spo2",
"nad",
"nbm",
"nkda",
"prn",
"stat",
"mane",
"nocte",
"neuro",
"resp",
"cvs",
"abdo",
"perrla",
"gcs",
"afebrile",
"apyrexial",
"euvolaemic",
"normotensive",
"tachycardic",
"bradycardic",
"pyrexial",
"bibasal",
"pneumomediastinum",
}
)
class _Detector:
"""Stub detector — no-op when Presidio/spaCy is not installed."""
def detect_persons(self, text: str) -> list[str]:
return []
def _build_detector() -> _Detector:
"""Upgrade free-text PERSON/LOCATION detection to Presidio + spaCy when
available; otherwise return a no-op stub so the rule + vault layer still runs.
The spaCy model is configurable via ``NOTEGUARD_SPACY_MODEL`` (default
``en_core_web_md`` — small enough to ship in the Docker image, materially
better recall than ``_sm``; set ``_lg`` for best recall at ~14x the size).
Any import or engine-setup failure degrades gracefully to the stub: NER is a
recall boost over the rules + vault, never a hard dependency.
"""
try:
from presidio_analyzer import AnalyzerEngine # type: ignore
from presidio_analyzer.nlp_engine import NlpEngineProvider # type: ignore
model = os.getenv("NOTEGUARD_SPACY_MODEL", "en_core_web_md")
provider = NlpEngineProvider(
nlp_configuration={
"nlp_engine_name": "spacy",
"models": [{"lang_code": "en", "model_name": model}],
}
)
engine = AnalyzerEngine(nlp_engine=provider.create_engine(), supported_languages=["en"])
class _PresidioDetector(_Detector):
def detect_persons(self, text: str) -> list[str]:
results = engine.analyze(text, language="en", entities=["PERSON", "LOCATION"])
return [text[r.start : r.end] for r in results if r.end > r.start]
return _PresidioDetector()
except Exception:
return _Detector()
_DETECTOR: _Detector = _build_detector()
# ── Vault loader ──────────────────────────────────────────────────────────────
def load_known_from_csv(patients_csv: str, admissions_csv: str | None = None) -> dict:
"""Build the identifier vault from the synthetic dataset's structured tables.
Reads ``full_name`` and common name columns from *patients_csv*, plus NHS
numbers. If *admissions_csv* is supplied, any clinician/author name columns
found there are added to the PERSON set so clinician names are caught
deterministically even without Presidio.
"""
known: dict[str, set] = {"PERSON": set(), "NHS": set()}
def _pull_row(row: dict) -> None:
for col in _NAME_COLS:
v = (row.get(col) or "").strip()
if len(v) > 2:
known["PERSON"].add(v)
with open(patients_csv, newline="", encoding="utf-8-sig") as f:
for row in csv.DictReader(f):
_pull_row(row)
nhs = (row.get("nhs_number") or "").strip()
if nhs:
known["NHS"].add(nhs)
if admissions_csv:
try:
with open(admissions_csv, newline="", encoding="utf-8-sig") as f:
for row in csv.DictReader(f):
_pull_row(row)
except FileNotFoundError:
pass
return {k: sorted(v) for k, v in known.items()}
# ── Core de-identification ────────────────────────────────────────────────────
@dataclass
class DeidResult:
clean_text: str
forward: dict
reverse: dict
residual: list
class NoteGuard:
def __init__(self, known=None, dob_shift_days=37, forward=None, reverse=None):
self.known = {k: list(v) for k, v in (known or {}).items()}
self.dob_shift = dob_shift_days
self.forward = dict(forward or {})
self.reverse = dict(reverse or {})
self._counter: dict[str, int] = {}
for tok in self.reverse:
m = re.match(r"\[([A-Z]+)_(\d+)\]", tok)
if m:
self._counter[m.group(1)] = max(self._counter.get(m.group(1), 0), int(m.group(2)))
@staticmethod
def _fix_mojibake(s: str) -> str:
# Each pair: (UTF-8 bytes of the real char decoded as Windows-1252, real char)
# · = · → · (middle dot U+00B7)
# ’ = ’ → ' (right single quote U+2019)
# – = â€" → – (en-dash U+2013; 0x93 in Win-1252 = U+201C)
# é = é → é (e-acute U+00E9)
return s.replace("·", "·").replace("’", "’").replace("–", "–").replace("é", "é")
def _surrogate(self, label: str, original: str) -> str:
if original in self.forward:
return self.forward[original]
self._counter[label] = self._counter.get(label, 0) + 1
tok = f"[{label}_{self._counter[label]}]"
self.forward[original] = tok
self.reverse[tok] = original
return tok
def _shift_date(self, s: str) -> str:
if s in self.forward:
return self.forward[s]
for fmt in ("%d/%m/%Y", "%d/%m/%y", "%d-%m-%Y", "%Y-%m-%d", "%d.%m.%Y"):
try:
shifted = (datetime.strptime(s, fmt) + timedelta(days=self.dob_shift)).strftime(fmt)
self.forward[s] = shifted
self.reverse[shifted] = s
return shifted
except ValueError:
continue
return self._surrogate("DATE", s)
def _redact(self, pattern, label, text, group=0, transform=None):
def repl(m):
original = m.group(group)
surr = transform(original) if transform else self._surrogate(label, original)
return m.group(0).replace(original, surr)
return pattern.sub(repl, text)
def deidentify(self, text: str) -> DeidResult:
t = self._fix_mojibake(text)
# Vault pass — patient names + NHS numbers from structured tables
for label in ("PERSON", "NHS"):
terms = [x for x in self.known.get(label, []) if x]
if terms:
alternatives = "|".join(re.escape(x) for x in sorted(terms, key=len, reverse=True))
pat = re.compile(r"\b(" + alternatives + r")\b")
t = self._redact(pat, label, t, group=1)
# Rule-based recognisers
t = self._redact(NHS_CONTEXT, "NHS", t, group=1)
t = self._redact(NHS_NUMBER, "NHS", t, group=0)
t = self._redact(GMC, "GMC", t, group=1)
t = self._redact(NMC, "NMC", t, group=1)
t = self._redact(EMAIL, "EMAIL", t, group=0)
t = self._redact(PHONE, "PHONE", t, group=0)
t = self._redact(UK_POSTCODE, "POSTCODE", t, group=1)
t = self._redact(DOB, "DOB", t, group=1, transform=self._shift_date)
# Optional NLP pass — catches clinician names and locations not in vault
for name in self._detect_names(t):
if name not in self.forward:
t = t.replace(name, self._surrogate("PERSON", name))
return DeidResult(t, dict(self.forward), dict(self.reverse), self._residual_known(t))
@staticmethod
def _detect_names(text: str) -> list[str]:
"""NER-detected person/location names, minus clinical-term false positives.
Filters the raw detector output so abbreviations the small spaCy model
mislabels (e.g. "Subcut") are not treated as names.
"""
return [
n for n in _DETECTOR.detect_persons(text) if n and len(n) > 2 and n.lower() not in _NER_STOPWORDS
]
def _residual_known(self, text: str) -> list:
# Use word-boundary match, same as the deidentify vault pass, so that
# short names like "Dia" don't false-positive on "Diastolic"/"Diabetes".
return [
v
for vals in self.known.values()
for v in vals
if v and re.search(r"\b" + re.escape(v) + r"\b", text)
]
def residual_identifiers(self, text: str) -> list[str]:
"""Comprehensive leak check — used for the trust metric.
Covers:
- vault names that survived de-id
- regex patterns (NHS, email, GMC, NMC)
- orphaned surrogate tokens (invented by the model, no reverse mapping)
- NLP-detected persons/locations (when Presidio is installed)
"""
hits: list[str] = list(self._residual_known(text))
for pat in (NHS_CONTEXT, NHS_NUMBER, EMAIL, GMC, NMC):
if pat.search(text):
hits.append(f"pattern:{pat.pattern[:40]}")
# Orphaned surrogates: a [LABEL_n] in the text with no reverse mapping
# means the model invented a token we cannot restore — it's a leak.
for m in _SURROGATE_PAT.finditer(text):
tok = m.group(0)
if tok not in self.reverse:
hits.append(f"unmapped_token:{tok}")
# NLP pass (no-op when Presidio not installed)
for name in self._detect_names(text):
hits.append(f"PERSON:{name[:30]}")
return list(dict.fromkeys(hits)) # deduplicate, preserve order
def scan_pii(self, text: str) -> list[dict]:
"""Vault-independent audit of a *de-identified* text for residual PII.
``assert_clean``/``residual_identifiers`` only know the vault and
structured patterns, so a free-text name that was never in the vault
(with no Presidio installed) passes them silently — exactly the failure
where "Dr Ethel Joanne Duffy" reaches the model un-redacted. This adds a
high-precision person-title heuristic so the trust panel can report
whether de-identification actually succeeded, even on a pasted note with
no ground-truth vault.
Returns a de-duplicated list of ``{"type": str, "text": str}`` findings,
each a span of suspected un-redacted PII still visible to the model.
Surrogate tokens (``[PERSON_1]``) are never flagged. Shifted dates are
intentional, so dates are out of scope here.
"""
findings: list[dict] = []
spans: list[tuple[int, int]] = []
def _add(typ: str, start: int, end: int) -> None:
if start < 0 or any(start < pe and ps < end for ps, pe in spans):
return # invalid, or overlaps an earlier (higher-priority) finding
spans.append((start, end))
findings.append({"type": typ, "text": text[start:end]})
# 1. structured identifiers that should have been tokenised but survived
for typ, pat, grp in (
("NHS number", NHS_CONTEXT, 1),
("NHS number", NHS_NUMBER, 0),
("GMC", GMC, 1),
("NMC", NMC, 1),
("email", EMAIL, 0),
("phone", PHONE, 0),
("postcode", UK_POSTCODE, 1),
):
for m in pat.finditer(text):
_add(typ, m.start(grp), m.end(grp))
# 2. free-text person names the vault/NER passes missed (title heuristic)
for m in _NAME_AFTER_TITLE.finditer(text):
_add("name", m.start(1), m.end(1))
# 3. vault names that survived de-id (when a ground-truth vault is loaded)
for v in self._residual_known(text):
idx = text.find(v)
_add("name", idx, idx + len(v))
return findings
def assert_clean(self, text: str) -> None:
"""Hard guarantee: raises if any known identifier or regex pattern survives."""
hits = list(self._residual_known(text))
for pat in (NHS_CONTEXT, NHS_NUMBER, EMAIL, GMC, NMC):
if pat.search(text):
hits.append(pat.pattern[:40])
if hits:
raise ValueError(f"NoteGuard guarantee failed: identifiers reached the model boundary: {hits}")
def reidentify(self, text: str) -> str:
for tok, original in sorted(self.reverse.items(), key=lambda kv: len(kv[0]), reverse=True):
text = text.replace(tok, original)
return text
def redact_unresolved(self, text: str) -> tuple[str, list[str]]:
"""Redact any surrogate-shaped token still present after ``reidentify()``.
Catches both unrestored surrogates (a ``[PERSON_9]`` with no reverse entry)
and stray template placeholders the model echoes from the prompt (e.g.
``[DATE_X]``) — neither must ever reach the clinician verbatim. Returns the
cleaned text and the tokens that were redacted (a reversibility-leak signal).
"""
leaked: list[str] = []
def _r(m: re.Match) -> str:
leaked.append(m.group(0))
return "[redacted]"
return _ORPHAN_PAT.sub(_r, text), leaked
if __name__ == "__main__":
known = {"PERSON": ["Margaret Okafor"], "NHS": ["485 777 3456"]}
note = (
"02 Jan, Ward RJ1. Pt Margaret Okafor (NHS 485 777 3456, DOB 14/03/1934) "
"admitted post-fall. Nurse Chukwuebuka reviewed. "
"Contact a.okafor@example.com, 020 7946 0991. "
"GMC No. 7654321. NMC number: 18D6896L."
)
print("INPUT:\n", note, "\n")
ng = NoteGuard(known=known)
res = ng.deidentify(note)
print("DE-IDENTIFIED (what the model sees):\n", res.clean_text, "\n")
print("Residual identifiers:", res.residual)
ng.assert_clean(res.clean_text)
print("assert_clean: OK\n")
restored = NoteGuard(reverse=res.reverse).reidentify(res.clean_text)
print("RE-IDENTIFIED (clinician view only):\n", restored)