Spaces:
Sleeping
Sleeping
| """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 ──────────────────────────────────────────────────── | |
| 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))) | |
| 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)) | |
| 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) | |