yumi.h commited on
Commit
abfd704
·
1 Parent(s): 79df74d

Update files

Browse files
.gitignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ .DS_Store
5
+ # HF dataset cache pulled at runtime
6
+ synthetic_clinical_notes/
7
+ patients.csv
8
+ admissions.csv
NOTEGU~1.MD ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # NoteGuard — Project Brief & Handover
2
+
3
+ **NoteGuard**
4
+ *An automatic PII preprocessing tool that sanitises NHS clinical notes before any model training.*
5
+
6
+ **Hackathon:** FLock.io × UK Sovereign AI (48 hours)
7
+ **Team:** Chaeyoon + Yumi
8
+ **Last updated:** 19 June 2026
9
+
10
+ ---
11
+
12
+ ## TL;DR
13
+
14
+ NoteGuard is an automatic preprocessing tool that **discovers, inspects, redacts, and de-identifies PII in free-text NHS clinical notes** before the data is used to train any model. It runs **locally at each trust** ("sanitise at source"), so each institution cleans its own data before anything is shared or used in collaborative/federated training.
15
+
16
+ > One-liner: *NoteGuard — automatic PII sanitisation for NHS clinical notes. Clean data in, no identifiers out.*
17
+
18
+ **Tracks:** Trusted Data & AI Infrastructure + AI Governance, Transparency & Trust.
19
+
20
+ ---
21
+
22
+ ## The problem
23
+
24
+ Clinical notes are the most IG-sensitive data the NHS holds. To use them for any ML they must first be de-identified, but:
25
+
26
+ - Manual de-identification is slow, inconsistent, and doesn't scale.
27
+ - Centralised tools (e.g. Google Cloud Sensitive Data Protection / Cloud DLP) require sending data to a proprietary external cloud.
28
+ - Free-text notes hide PII *inline in the narrative*, and real notes are full of typos and abbreviations that defeat naive rules.
29
+
30
+ NoteGuard automates de-identification at the point data enters the pipeline, on-prem, with a **measurable** leakage rate.
31
+
32
+ ---
33
+
34
+ ## Sovereignty framing (clarified by the judges)
35
+
36
+ "Sovereignty" does **not** mean locking data to UK soil. It means **data-jurisdiction sovereignty**: any institution or region retains governance over its own data and can collaborate without surrendering it — whether that boundary is an NHS trust, a London ICB, a US state health department, or an EU region.
37
+
38
+ NoteGuard's contribution: it sanitises data **inside its own governance boundary, wherever that is**, so cross-institution collaboration (incl. federated training) becomes possible without any central party ever seeing raw PII. NoteGuard is the **privacy-preserving on-ramp** to collaborative/federated AI.
39
+
40
+ > Avoid "don't send it to a US hyperscaler" — it's about *control*, not location.
41
+
42
+ ---
43
+
44
+ ## Scope (narrowed — this is the whole build)
45
+
46
+ We are building **only the PII preprocessing tool**, not a federated model. Federated learning is the *context/motivation* (sanitise-at-source enables it); the *deliverable* is the sanitiser.
47
+
48
+ **Note on FLock:** the judges confirmed we do **not** have to use FLock's API. Build the tool standalone; reference FLock as the decentralised-coordination layer NoteGuard feeds into.
49
+
50
+ ---
51
+
52
+ ## The pipeline (the product)
53
+
54
+ ### 1. Ingest
55
+ Load clinical notes (NHSE synthetic dataset).
56
+
57
+ ### 2. Detect / Inspect PII (layered — no single method catches everything)
58
+ - **Rules + checksums:** NHS number (10-digit, **mod-11 checksum** — standout custom recogniser), UK postcodes, dates, phone, email.
59
+ - **Gazetteer:** match names / site names from the patient & admission tables against the note text.
60
+ - **NER model:** catch PII *inline in the narrative* (PERSON, DATE, LOCATION) that rules miss.
61
+
62
+ ### 3. Transform / De-identify (offer several; explain the utility trade-off)
63
+ - **Redaction** → `[PERSON]`, `[NHS_NUMBER]`
64
+ - **Surrogate replacement** → fake-but-realistic value (keeps text readable for downstream NLP)
65
+ - **Consistent pseudonymisation** → same patient → same token across all their notes (preserves the longitudinal journey) via a mapping vault
66
+ - **Date shifting** → shift each patient's dates by a consistent random offset (preserves intervals)
67
+
68
+ ### 4. Validate / Risk-score
69
+ Output a **clean, training-ready dataset** + an **audit report** of what was removed + a **k-anonymity** re-identification risk score.
70
+
71
+ ---
72
+
73
+ ## Recommended engine: Microsoft Presidio
74
+
75
+ Don't build detection from scratch. **Presidio** is open-source, purpose-built for PII detection + anonymisation, runs fully on-prem (sovereign), and is extensible with custom recognisers — the de-facto open-source equivalent of GCP Sensitive Data Protection.
76
+
77
+ Our 48h work = add NHS-specific recognisers (NHS-number checksum, NHS org/site lists) + wire up the transforms. **spaCy / medspaCy** underneath for the NER layer.
78
+
79
+ ---
80
+
81
+ ## Evaluation (our "reliable" pillar — wins on measurable results)
82
+
83
+ The synthetic dataset has **known** PII (names/NHS numbers in the structured tables), so we can build ground-truth labels and report real numbers:
84
+
85
+ - **Precision / recall / F1 of PII detection, per entity type.**
86
+ - **Residual PII leakage rate** after de-identification.
87
+ - **Robustness to noise:** the dataset's injected **typos + abbreviations** degrade detection — report F1 *with vs without* them. "PII detection under realistic messy text" is a strong, honest result.
88
+
89
+ ---
90
+
91
+ ## Dataset
92
+
93
+ **NHSEDataScience/synthetic_clinical_notes** (Hugging Face) — NHS England's own synthetic dataset.
94
+
95
+ - ~70 synthetic patients (50 adult, 20 paediatric), 20–50 notes each, full admission→discharge journeys.
96
+ - **Deliberately injected typos + medical abbreviations** — the messiness is the test.
97
+ - Names / NHS numbers in the patient & admission tables → ground truth for the de-id eval.
98
+ - No FDP, no real data, no IG barrier. "We deliberately used synthetic data so it's shareable and governance-safe" is a strength.
99
+
100
+ ---
101
+
102
+ ## NHS England framing (credibility edge)
103
+
104
+ Adopt NHSE Data Science's own ethics scaffolding rather than inventing one. Four characteristics: **fair, transparent, value-adding, reliable.**
105
+
106
+ **Lighter governance artefacts (no heavy ML-model paperwork now):**
107
+ 1. **Tool Card / datasheet** for NoteGuard
108
+ 2. **Data Hazards assessment** (datahazards.com — NHSE applies it to every project)
109
+ 3. **Five Safes mapping** (Safe Data: de-identify at source)
110
+
111
+ **NHSE siblings to cite hard** (NoteGuard is the productised, automated version): *Tool to Assess Privacy Risk of Text Data*; *Privacy of Unstructured Data*; *Emerging Privacy Enhancing Technologies (PETs)*; *SDE Service Data Wranglers — Reusable Data Validation Process* (literally the target role).
112
+
113
+ ---
114
+
115
+ ## 48-hour MVP
116
+
117
+ A small web UI:
118
+ **messy clinical note → highlighted detected PII (inspect) → choose a de-id transform → sanitised note out → detection F1 + leakage + k-anonymity score.**
119
+ Behind it: Presidio + NHS custom recognisers running against the synthetic dataset. The **detect → de-identify → score** loop is the demo that lands.
120
+
121
+ ---
122
+
123
+ ## Division of labour (suggested)
124
+
125
+ - **Person A:** Presidio engine + NHS custom recognisers (NHS-number checksum, gazetteers) + transforms (redaction/pseudonymisation/date-shift).
126
+ - **Person B:** Web UI demo + evaluation harness (precision/recall/F1, leakage rate, k-anonymity).
127
+ - **Shared / Chaeyoon (ethicist edge):** Tool Card, Data Hazards, Five Safes mapping, the pitch + architecture diagram.
128
+
129
+ ---
130
+
131
+ ## Why this wins (judging criteria check)
132
+
133
+ - ✅ Clear, relevant real-world use case (automated NHS de-identification preprocessing)
134
+ - ✅ Thoughtful use of privacy-preserving AI (sanitise-at-source; on-ramp to federated/collaborative AI)
135
+ - ✅ Strong technical implementation (layered detection, multiple transforms, robustness-to-noise eval)
136
+ - ✅ Attention to trust, governance, security (Five Safes, NHSE ethics, measurable leakage rate, audit log)
137
+ - ✅ Credible path to real-world adoption (open-source, on-prem, generalises to any jurisdiction; aligns to NHSE's own tooling)
README.md CHANGED
@@ -1 +1,100 @@
1
- # Automatic-PII-preprocessing-tool
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 🛡️ NoteGuard
2
+
3
+ **Automatic PII sanitisation for NHS clinical notes — clean data in, no identifiers out.**
4
+
5
+ NoteGuard discovers, inspects, redacts, and de-identifies PII in free-text NHS
6
+ clinical notes **before** the data is used to train any model. It runs **locally
7
+ at each institution** ("sanitise at source"), so every trust cleans its own data
8
+ inside its own governance boundary before anything is shared or used in
9
+ collaborative / federated training.
10
+
11
+ > Federated learning lets institutions train without moving data. NoteGuard is the
12
+ > **privacy-preserving on-ramp** that makes the data safe to train on in the first place.
13
+
14
+ Hackathon: **FLock.io × UK Sovereign AI** — track *Trusted Data & AI Infrastructure*.
15
+
16
+ ---
17
+
18
+ ## What makes this more than "just Presidio"
19
+
20
+ [Microsoft Presidio](https://microsoft.github.io/presidio/) is the detection
21
+ **engine** — we don't reinvent it. NoteGuard is the **clinical assurance layer**
22
+ Presidio leaves to you:
23
+
24
+ 1. **Measured residual leakage.** Presidio detects PII but never tells you *how
25
+ much still leaks on your data*. Because the NHSE dataset keeps PII in structured
26
+ tables, we join them back to each note to get ground truth for free and report a
27
+ real **re-identification risk** number.
28
+ 2. **Domain adaptation to messy clinical text.** Real notes are full of typos and
29
+ abbreviations. We measure detection with vs without that noise, and add NHS-aware
30
+ recognisers (checksum-validated NHS numbers **plus** context-anchored detection
31
+ for the dataset's 9-digit synthetic numbers that Presidio's `UK_NHS` misses).
32
+ 3. **Patient-consistent, longitudinal de-identification.** Presidio anonymises per
33
+ document. NoteGuard keeps the *same patient → same surrogate* across their whole
34
+ admission journey and shifts each patient's dates by one consistent offset, so
35
+ intervals (clinical timelines) survive — useful data, not just safe data.
36
+ 4. **Governance wrapper.** Per-note audit of what was removed, plus the dataset-level
37
+ leakage report — aligned to NHSE's *fair / transparent / value-adding / reliable*
38
+ ethics and the Five Safes (Safe Data: de-identify at source).
39
+
40
+ ## Results (500 NHSE synthetic notes)
41
+
42
+ | Detector | NHS number F1 | PERSON recall | **Residual leakage** |
43
+ |---|---|---|---|
44
+ | rules only | 0.97 | 0.00 | **73.3 %** |
45
+ | **presidio + rules** | **0.98** | **0.69** | **4.6 %** |
46
+
47
+ **Residual leakage** = known identifiers (joined from the structured tables) still
48
+ present in the note *after* sanitisation. The rules→engine drop from 73 % → 4.6 %
49
+ is the headline: it shows, with numbers, exactly what the NER engine buys you.
50
+
51
+ > Precision is reported against *structured* PII only, so it is a conservative lower
52
+ > bound — correctly removing a clinician's name (not in the tables) counts here as a
53
+ > false positive. Recall and leakage are the sound, headline metrics.
54
+
55
+ ## Architecture
56
+
57
+ ```
58
+ data.py load 3 CSVs, join on person_id/admission_id → per-note ground-truth PII
59
+ recognizers.py pure-Python rules: NHS checksum + NHS-context + postcode/date/phone/email
60
+ detect.py Detector interface: RuleDetector · PresidioDetector · Gazetteer · Composite
61
+ transform.py redaction · patient-consistent pseudonymisation (vault) · date-shift
62
+ evaluate.py ground-truth matching → per-entity P/R/F1 + residual leakage rate
63
+ pipeline.py single-note detect → de-identify → audit (used by UI + CLI)
64
+ run_eval.py dataset-level evaluation → results.json
65
+ app.py Gradio demo: note → highlighted PII → transform → sanitised + metrics
66
+ ```
67
+
68
+ The rule layer and evaluation harness are **pure Python** — they run even if
69
+ Presidio/spaCy are unavailable, so the core "measure the leakage" capability never
70
+ depends on the heavy NER stack.
71
+
72
+ ## Quickstart
73
+
74
+ ```bash
75
+ python3 -m venv .venv && source .venv/bin/activate
76
+ pip install -r requirements.txt
77
+ python -m spacy download en_core_web_sm
78
+
79
+ python run_eval.py --compare --limit 500 # reproduce the table above → results.json
80
+ python app.py # launch the demo UI (http://127.0.0.1:7860)
81
+ ```
82
+
83
+ The NHSE synthetic dataset
84
+ ([`NHSEDataScience/synthetic_clinical_notes`](https://huggingface.co/datasets/NHSEDataScience/synthetic_clinical_notes))
85
+ is pulled automatically on first run. To run fully offline, drop the three CSVs in a
86
+ folder and set `NOTEGUARD_DATA_DIR=/path/to/csvs`.
87
+
88
+ ## Data note (found by inspecting the data, not assuming)
89
+
90
+ - NHS numbers in this synthetic set are **9 digits** (real ones are 10 + mod-11
91
+ check). We catch both: checksum-validated 10-digit anywhere, **and**
92
+ context-anchored numbers after an "NHS …" label.
93
+ - The `ward` column is the literal word `ward`, and some fields are double-encoded
94
+ (`·`). Both are handled in `data.py` so they don't pollute the ground truth.
95
+
96
+ ## Why synthetic data is a strength
97
+
98
+ No real patient data, no IG barrier, fully shareable — and it ships **known** PII in
99
+ structured tables, which is exactly what lets us report an honest, measurable leakage
100
+ rate instead of a vibe.
work-process_yumi/app.py ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """NoteGuard demo UI.
2
+
3
+ The demo that lands: messy clinical note -> highlighted detected PII (inspect) ->
4
+ choose a de-identification transform -> sanitised note out -> audit + the
5
+ dataset-level residual-leakage numbers behind it.
6
+
7
+ python app.py
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+
14
+ import gradio as gr
15
+
16
+ from noteguard.data import load_notes
17
+ from noteguard.pipeline import Pipeline
18
+ from noteguard.transform import PSEUDONYM, REDACTION, PseudonymVault
19
+
20
+ # ---- load engine + a few sample notes once ---------------------------------
21
+ print("[noteguard] loading detection engine (Presidio + spaCy) ...")
22
+ PIPELINE = Pipeline()
23
+ print("[noteguard] loading sample notes ...")
24
+ try:
25
+ SAMPLES = load_notes(limit=40)
26
+ except Exception as e: # pragma: no cover
27
+ print(f"[noteguard] could not load dataset samples ({e}); paste-only mode.")
28
+ SAMPLES = []
29
+
30
+ SAMPLE_CHOICES = [
31
+ f"{i}: {(s.note_type or 'note')} — {s.text[:48].strip()}…"
32
+ for i, s in enumerate(SAMPLES) if s.text
33
+ ]
34
+
35
+
36
+ def _highlight(text: str, spans) -> dict:
37
+ return {
38
+ "text": text,
39
+ "entities": [
40
+ {"entity": s.entity_type, "start": s.start, "end": s.end} for s in spans
41
+ ],
42
+ }
43
+
44
+
45
+ def load_sample(choice: str) -> tuple[str, str]:
46
+ if not choice:
47
+ return "", ""
48
+ idx = int(choice.split(":", 1)[0])
49
+ rec = SAMPLES[idx]
50
+ return rec.text, rec.person_id
51
+
52
+
53
+ def run(text: str, method_label: str, person_id: str):
54
+ method = PSEUDONYM if method_label.startswith("Pseudonym") else REDACTION
55
+ # fresh vault per run so the demo is reproducible
56
+ PIPELINE.vault = PseudonymVault()
57
+ result = PIPELINE.sanitise(text or "", method, person_id or "demo")
58
+
59
+ highlighted = _highlight(text or "", result.spans)
60
+ by_type = result.audit.get("by_type", {})
61
+ audit_md = "\n".join(f"- **{k}**: {v}" for k, v in sorted(by_type.items())) or "_none detected_"
62
+ audit_md = (
63
+ f"**Detector:** `{result.audit['detector']}` \n"
64
+ f"**Transform:** `{method}` \n"
65
+ f"**Entities removed:** {result.audit['entities_removed']}\n\n"
66
+ f"{audit_md}"
67
+ )
68
+ return highlighted, result.sanitised, audit_md
69
+
70
+
71
+ _PII_LABEL = {"UK_NHS": "NHS number", "PERSON": "Name", "DATE_TIME": "Date of birth"}
72
+
73
+
74
+ def metrics_panel() -> str:
75
+ if not os.path.exists("results.json"):
76
+ return "_Run `python run_eval.py --limit 500` to populate metrics._"
77
+ with open("results.json") as f:
78
+ data = json.load(f)
79
+ # show the shipping detector (NER model + rules)
80
+ name = "presidio+rules" if "presidio+rules" in data else next(iter(data))
81
+ r = data[name]
82
+ notes = r["notes_evaluated"]
83
+ leak = r["leakage"]["leakage_rate_pct"]
84
+ removed = round(100 - leak, 2)
85
+ pe = r["detection"]["per_entity"]
86
+
87
+ rows = ["| PII type | Detected (recall) | occurrences |", "|---|---|---|"]
88
+ for et in ("UK_NHS", "PERSON", "DATE_TIME"):
89
+ m = pe.get(et)
90
+ if m and m["support"] > 0:
91
+ rows.append(f"| {_PII_LABEL.get(et, et)} | {m['recall']:.0%} | {m['support']} |")
92
+ return (
93
+ f"### 📊 Measured on {notes} NHSE synthetic notes\n\n"
94
+ f"## ✅ Removed **{removed}%** of known identifiers\n"
95
+ f"_residual re-identification risk: **{leak}%** leakage_\n\n"
96
+ + "\n".join(rows)
97
+ + "\n\n_Ground truth is joined from the dataset's structured patient / "
98
+ "admission tables, so the leakage rate is a real, measurable "
99
+ "re-identification risk — not an estimate._"
100
+ )
101
+
102
+
103
+ with gr.Blocks(title="NoteGuard") as demo:
104
+ gr.Markdown(
105
+ "# 🛡️ NoteGuard\n"
106
+ "**Automatic PII sanitisation for NHS clinical notes — clean data in, no "
107
+ "identifiers out.** Sanitise-at-source so institutions can collaborate "
108
+ "(incl. federated training) without ever sharing raw PII."
109
+ )
110
+ person_state = gr.State("")
111
+ with gr.Row():
112
+ with gr.Column(scale=1):
113
+ sample_dd = gr.Dropdown(SAMPLE_CHOICES, label="Load a sample NHSE note",
114
+ value=SAMPLE_CHOICES[0] if SAMPLE_CHOICES else None)
115
+ note_in = gr.Textbox(label="Clinical note (messy free-text)", lines=14,
116
+ placeholder="Paste a clinical note…")
117
+ method = gr.Radio(
118
+ ["Redaction → [PERSON]", "Pseudonymisation (patient-consistent + date-shift)"],
119
+ value="Redaction → [PERSON]", label="De-identification transform",
120
+ )
121
+ run_btn = gr.Button("Detect & sanitise", variant="primary")
122
+ with gr.Column(scale=1):
123
+ highlighted = gr.HighlightedText(label="1) Detected PII (inspect)")
124
+ sanitised = gr.Textbox(label="2) Sanitised note (training-ready)", lines=10)
125
+ audit = gr.Markdown(label="3) Audit")
126
+
127
+ gr.Markdown("---")
128
+ with gr.Row():
129
+ gr.Markdown("## 📊 Dataset-level metrics")
130
+ refresh_btn = gr.Button("🔄 Refresh from results.json", scale=0)
131
+ metrics_md = gr.Markdown(metrics_panel())
132
+
133
+ sample_dd.change(load_sample, sample_dd, [note_in, person_state])
134
+ run_btn.click(run, [note_in, method, person_state], [highlighted, sanitised, audit])
135
+ refresh_btn.click(metrics_panel, None, metrics_md)
136
+
137
+ # re-read results.json on every page load so the panel never shows a stale snapshot
138
+ demo.load(metrics_panel, None, metrics_md)
139
+ if SAMPLE_CHOICES:
140
+ demo.load(load_sample, sample_dd, [note_in, person_state])
141
+
142
+
143
+ if __name__ == "__main__":
144
+ demo.launch()
work-process_yumi/noteguard/__init__.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ """NoteGuard — automatic PII sanitisation for NHS clinical notes.
2
+
3
+ Clean data in, no identifiers out. Sanitise-at-source so institutions can
4
+ collaborate (incl. federated training) without surrendering raw PII.
5
+ """
6
+
7
+ __version__ = "0.1.0"
work-process_yumi/noteguard/data.py ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Load the NHSE synthetic clinical notes dataset and build per-note ground truth.
2
+
3
+ The dataset ships three CSVs that share keys:
4
+ patients.csv person_id, full_name, nhs_number, date_of_birth, ...
5
+ admissions.csv admission_id, patient_name/first_name/surname, site_name, ward, ...
6
+ notes.csv clinical_note_id, clean_note_text, person_id, admission_id, ...
7
+
8
+ Because the PII lives in the *structured* tables, we get ground-truth labels for
9
+ free: for each note we join back to the patient/admission rows and collect the
10
+ known PII strings that *should* be removed. That join is what makes a real,
11
+ measurable leakage rate possible — the thing Presidio alone never gives you.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ from dataclasses import dataclass, field
17
+ from functools import lru_cache
18
+
19
+ import pandas as pd
20
+
21
+ REPO_ID = "NHSEDataScience/synthetic_clinical_notes"
22
+
23
+
24
+ # --- entity types we align to Presidio's vocabulary -------------------------
25
+ PERSON = "PERSON"
26
+ UK_NHS = "UK_NHS"
27
+ DATE = "DATE_TIME"
28
+ LOCATION = "LOCATION"
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class GroundTruthPII:
33
+ """One known PII value that should not survive sanitisation."""
34
+ text: str
35
+ entity_type: str
36
+
37
+
38
+ @dataclass
39
+ class NoteRecord:
40
+ note_id: str
41
+ person_id: str
42
+ admission_id: str
43
+ text: str
44
+ note_type: str = ""
45
+ note_subject: str = ""
46
+ # known PII strings for THIS note, derived from the structured tables
47
+ ground_truth: list[GroundTruthPII] = field(default_factory=list)
48
+
49
+
50
+ def _fix_mojibake(s: str) -> str:
51
+ """Repair the known UTF-8-as-latin-1 decoding defect (e.g. '·' -> '·')."""
52
+ if not s or ("Â" not in s and "Ã" not in s):
53
+ return s
54
+ try:
55
+ return s.encode("latin-1").decode("utf-8")
56
+ except (UnicodeDecodeError, UnicodeEncodeError):
57
+ return s
58
+
59
+
60
+ def _first_col(df: pd.DataFrame, *candidates: str) -> str | None:
61
+ for c in candidates:
62
+ if c in df.columns:
63
+ return c
64
+ return None
65
+
66
+
67
+ def _download_csvs(local_dir: str | None = None) -> dict[str, str]:
68
+ """Discover and fetch the three CSVs from the HF dataset repo.
69
+
70
+ Returns a dict {"patients"|"admissions"|"notes": local_path}.
71
+ """
72
+ from huggingface_hub import hf_hub_download, list_repo_files
73
+
74
+ files = [f for f in list_repo_files(REPO_ID, repo_type="dataset") if f.endswith(".csv")]
75
+ picked: dict[str, str] = {}
76
+ for f in files:
77
+ name = f.lower()
78
+ if "patient" in name and "patients" not in picked:
79
+ picked["patients"] = f
80
+ elif "admission" in name:
81
+ picked["admissions"] = f
82
+ elif "note" in name:
83
+ picked["notes"] = f
84
+ missing = {"patients", "admissions", "notes"} - picked.keys()
85
+ if missing:
86
+ raise RuntimeError(
87
+ f"Could not locate {missing} CSVs in {REPO_ID}. Found: {files}"
88
+ )
89
+ out: dict[str, str] = {}
90
+ for key, repo_path in picked.items():
91
+ out[key] = hf_hub_download(
92
+ REPO_ID, repo_path, repo_type="dataset", local_dir=local_dir
93
+ )
94
+ return out
95
+
96
+
97
+ @lru_cache(maxsize=1)
98
+ def load_tables(local_dir: str | None = None) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
99
+ """Return (patients, admissions, notes) DataFrames.
100
+
101
+ Honours NOTEGUARD_DATA_DIR (a folder holding the three CSVs) so the demo can
102
+ run fully offline once the data is cached.
103
+ """
104
+ data_dir = local_dir or os.environ.get("NOTEGUARD_DATA_DIR")
105
+ if data_dir and os.path.isdir(data_dir):
106
+ def _read(*names):
107
+ for n in names:
108
+ p = os.path.join(data_dir, n)
109
+ if os.path.exists(p):
110
+ return pd.read_csv(p, dtype=str, keep_default_na=False)
111
+ raise FileNotFoundError(f"None of {names} in {data_dir}")
112
+ patients = _read("patients.csv")
113
+ admissions = _read("admissions.csv")
114
+ notes = _read("synthetic_clinical_notes.csv", "notes.csv")
115
+ else:
116
+ paths = _download_csvs(local_dir=local_dir)
117
+ patients = pd.read_csv(paths["patients"], dtype=str, keep_default_na=False)
118
+ admissions = pd.read_csv(paths["admissions"], dtype=str, keep_default_na=False)
119
+ notes = pd.read_csv(paths["notes"], dtype=str, keep_default_na=False)
120
+ return patients, admissions, notes
121
+
122
+
123
+ # generic values that are not identifying on their own — never treat as PII GT
124
+ _GENERIC = {
125
+ "ward", "bay", "bed", "unit", "unknown", "none", "n/a", "na",
126
+ "male", "female", "trust", "hospital", "patient", "nil",
127
+ }
128
+
129
+
130
+ def _gt_from_row(row: pd.Series, df: pd.DataFrame, mapping: dict[str, str]) -> list[GroundTruthPII]:
131
+ out: list[GroundTruthPII] = []
132
+ for col, etype in mapping.items():
133
+ actual = _first_col(df, col)
134
+ if actual is None:
135
+ continue
136
+ val = _fix_mojibake(str(row.get(actual, "")).strip())
137
+ if not val or val.lower() in _GENERIC or len(val) < 2:
138
+ continue
139
+ out.append(GroundTruthPII(val, etype))
140
+ return out
141
+
142
+
143
+ # which structured columns map to which entity type
144
+ PATIENT_PII = {
145
+ "full_name": PERSON,
146
+ "nhs_number": UK_NHS,
147
+ "date_of_birth": DATE,
148
+ }
149
+ ADMISSION_PII = {
150
+ "patient_name": PERSON,
151
+ "first_name": PERSON,
152
+ "surname": PERSON,
153
+ "full_name": PERSON,
154
+ "nhs_number": UK_NHS,
155
+ "date_of_birth": DATE,
156
+ "site_name": LOCATION,
157
+ "ward": LOCATION,
158
+ "bed_location": LOCATION,
159
+ }
160
+
161
+
162
+ def load_notes(limit: int | None = None, local_dir: str | None = None) -> list[NoteRecord]:
163
+ """Build NoteRecords with ground-truth PII joined from patient/admission tables."""
164
+ patients, admissions, notes = load_tables(local_dir=local_dir)
165
+
166
+ pid_col = _first_col(patients, "person_id")
167
+ patients_idx = patients.set_index(pid_col) if pid_col else None
168
+
169
+ aid_col = _first_col(admissions, "admission_id")
170
+ admissions_idx = admissions.set_index(aid_col) if aid_col else None
171
+
172
+ text_col = _first_col(notes, "clean_note_text", "note_text", "text")
173
+ n_pid = _first_col(notes, "person_id", "patient_id")
174
+ n_aid = _first_col(notes, "admission_id")
175
+ nid_col = _first_col(notes, "clinical_note_id", "note_id")
176
+ ntype = _first_col(notes, "note_type")
177
+ nsubj = _first_col(notes, "note_subject")
178
+
179
+ records: list[NoteRecord] = []
180
+ rows = notes if limit is None else notes.head(limit)
181
+ for _, r in rows.iterrows():
182
+ pid = str(r.get(n_pid, "")) if n_pid else ""
183
+ aid = str(r.get(n_aid, "")) if n_aid else ""
184
+ gt: list[GroundTruthPII] = []
185
+ if patients_idx is not None and pid in patients_idx.index:
186
+ prow = patients_idx.loc[pid]
187
+ if isinstance(prow, pd.DataFrame):
188
+ prow = prow.iloc[0]
189
+ gt += _gt_from_row(prow, patients, PATIENT_PII)
190
+ if admissions_idx is not None and aid in admissions_idx.index:
191
+ arow = admissions_idx.loc[aid]
192
+ if isinstance(arow, pd.DataFrame):
193
+ arow = arow.iloc[0]
194
+ gt += _gt_from_row(arow, admissions, ADMISSION_PII)
195
+
196
+ # dedupe on (text, type)
197
+ gt = list({(g.text, g.entity_type): g for g in gt}.values())
198
+
199
+ records.append(
200
+ NoteRecord(
201
+ note_id=str(r.get(nid_col, "")) if nid_col else "",
202
+ person_id=pid,
203
+ admission_id=aid,
204
+ text=_fix_mojibake(str(r.get(text_col, ""))) if text_col else "",
205
+ note_type=str(r.get(ntype, "")) if ntype else "",
206
+ note_subject=str(r.get(nsubj, "")) if nsubj else "",
207
+ ground_truth=gt,
208
+ )
209
+ )
210
+ return records
211
+
212
+
213
+ if __name__ == "__main__":
214
+ recs = load_notes(limit=5)
215
+ for rec in recs:
216
+ print(f"\n=== note {rec.note_id} (person {rec.person_id}) ===")
217
+ print(rec.text[:200].replace("\n", " "), "...")
218
+ print(" ground-truth PII:", [(g.text, g.entity_type) for g in rec.ground_truth])
work-process_yumi/noteguard/detect.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Detection layer.
2
+
3
+ NoteGuard does not reinvent detection — Presidio is the engine. Our job is to
4
+ (1) compose Presidio's NER with our transparent rule layer, (2) keep everything
5
+ behind one `Detector` interface so the pipeline and eval are engine-agnostic, and
6
+ (3) make detection degrade gracefully to pure-Python rules when spaCy/Presidio
7
+ are unavailable.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from typing import Iterable, Protocol
13
+
14
+ from .recognizers import Span, find_rule_spans
15
+
16
+
17
+ class Detector(Protocol):
18
+ def detect(self, text: str) -> list[Span]: ...
19
+
20
+
21
+ class RuleDetector:
22
+ """Pure-Python baseline. No external dependencies."""
23
+
24
+ name = "rules"
25
+
26
+ def detect(self, text: str) -> list[Span]:
27
+ return find_rule_spans(text)
28
+
29
+
30
+ class PresidioDetector:
31
+ """Presidio AnalyzerEngine (spaCy NER + recognisers), unioned with our rules.
32
+
33
+ The rule layer is kept in the union because our NHS-number recogniser is
34
+ checksum-validated and our outputs stay auditable.
35
+ """
36
+
37
+ name = "presidio+rules"
38
+
39
+ # Presidio entity types we keep (already aligned to our vocabulary)
40
+ KEEP = {
41
+ "PERSON", "DATE_TIME", "EMAIL_ADDRESS", "PHONE_NUMBER",
42
+ "LOCATION", "UK_NHS", "UK_NINO", "IP_ADDRESS", "URL",
43
+ }
44
+
45
+ def __init__(self, spacy_model: str = "en_core_web_sm", score_threshold: float = 0.4):
46
+ from presidio_analyzer import AnalyzerEngine
47
+ from presidio_analyzer.nlp_engine import NlpEngineProvider
48
+
49
+ provider = NlpEngineProvider(nlp_configuration={
50
+ "nlp_engine_name": "spacy",
51
+ "models": [{"lang_code": "en", "model_name": spacy_model}],
52
+ })
53
+ self.engine = AnalyzerEngine(nlp_engine=provider.create_engine())
54
+ self.score_threshold = score_threshold
55
+
56
+ def detect(self, text: str) -> list[Span]:
57
+ results = self.engine.analyze(text=text, language="en")
58
+ spans = [
59
+ Span(r.start, r.end, r.entity_type, text[r.start:r.end], r.score)
60
+ for r in results
61
+ if r.entity_type in self.KEEP and r.score >= self.score_threshold
62
+ ]
63
+ spans += find_rule_spans(text)
64
+ return _merge(spans)
65
+
66
+
67
+ class GazetteerDetector:
68
+ """Match a known list of names/sites (the roster a trust actually holds).
69
+
70
+ Catches identifiers the NER model misses (rare names, typo'd surnames) using
71
+ whole-word, case-insensitive matching. Used as an optional layer to show the
72
+ recall lift — not part of the headline eval, to avoid circularity.
73
+ """
74
+
75
+ name = "gazetteer"
76
+
77
+ def __init__(self, terms: Iterable[tuple[str, str]], min_len: int = 3):
78
+ self._patterns: list[tuple[re.Pattern, str]] = []
79
+ seen: set[str] = set()
80
+ for term, etype in terms:
81
+ term = (term or "").strip()
82
+ if len(term) < min_len or term.lower() in seen:
83
+ continue
84
+ seen.add(term.lower())
85
+ self._patterns.append(
86
+ (re.compile(rf"\b{re.escape(term)}\b", re.IGNORECASE), etype)
87
+ )
88
+
89
+ def detect(self, text: str) -> list[Span]:
90
+ spans: list[Span] = []
91
+ for pat, etype in self._patterns:
92
+ for m in pat.finditer(text):
93
+ spans.append(Span(m.start(), m.end(), etype, m.group(), 0.9))
94
+ return spans
95
+
96
+
97
+ class CompositeDetector:
98
+ def __init__(self, *detectors: Detector):
99
+ self.detectors = detectors
100
+ self.name = "+".join(getattr(d, "name", "?") for d in detectors)
101
+
102
+ def detect(self, text: str) -> list[Span]:
103
+ spans: list[Span] = []
104
+ for d in self.detectors:
105
+ spans += d.detect(text)
106
+ return _merge(spans)
107
+
108
+
109
+ def _merge(spans: list[Span]) -> list[Span]:
110
+ """Sort, then drop spans fully contained in a longer span (keep highest score)."""
111
+ spans = sorted(spans, key=lambda s: (s.start, -(s.end - s.start), -s.score))
112
+ kept: list[Span] = []
113
+ for s in spans:
114
+ if any(k.start <= s.start and s.end <= k.end for k in kept):
115
+ continue
116
+ kept.append(s)
117
+ return kept
118
+
119
+
120
+ def build_detector(use_presidio: bool = True) -> Detector:
121
+ """Best available detector; falls back to rules if Presidio import fails."""
122
+ if use_presidio:
123
+ try:
124
+ return PresidioDetector()
125
+ except Exception as e: # pragma: no cover - environment dependent
126
+ print(f"[noteguard] Presidio unavailable ({e}); falling back to rules.")
127
+ return RuleDetector()
work-process_yumi/noteguard/evaluate.py ADDED
@@ -0,0 +1,220 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Evaluation harness — NoteGuard's 'reliable' pillar.
2
+
3
+ Because the dataset's PII lives in structured tables, every note has ground-truth
4
+ identifiers. We measure two things Presidio alone never reports:
5
+
6
+ 1. Detection quality : per-entity precision / recall / F1 against known PII.
7
+ 2. Residual leakage : after sanitisation, how many KNOWN identifiers still
8
+ appear in the output text. This is the headline number —
9
+ an honest, measurable re-identification risk.
10
+
11
+ Caveat we state openly: precision is measured against *structured* PII only.
12
+ A note may contain PII not present in the tables (e.g. a clinician's name); a
13
+ correct detection of it counts here as a false positive, so reported precision is
14
+ a conservative lower bound. Recall and leakage are unaffected.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from collections import defaultdict
19
+ from dataclasses import dataclass, field
20
+ from datetime import datetime
21
+
22
+ from .data import NoteRecord
23
+ from .detect import Detector
24
+ from .recognizers import Span
25
+ from .transform import REDACTION, PseudonymVault, apply_transform
26
+
27
+ _DATE_FORMATS = ["%d/%m/%Y", "%d-%m-%Y", "%Y-%m-%d", "%d/%m/%y", "%d-%m-%y", "%d %b %Y", "%d %B %Y"]
28
+
29
+
30
+ def _date_variants(value: str) -> list[str]:
31
+ for fmt in _DATE_FORMATS:
32
+ try:
33
+ dt = datetime.strptime(value.strip(), fmt)
34
+ return list({dt.strftime(f) for f in _DATE_FORMATS})
35
+ except ValueError:
36
+ continue
37
+ return [value]
38
+
39
+
40
+ def value_variants(value: str, entity_type: str) -> list[str]:
41
+ """Surface forms of a known PII value as it might appear in free text."""
42
+ value = value.strip()
43
+ if not value:
44
+ return []
45
+ if entity_type == "PERSON":
46
+ parts = value.split()
47
+ out = [value]
48
+ if len(parts) > 1:
49
+ out.append(parts[-1]) # surname alone
50
+ out.append(parts[0]) # forename alone
51
+ return out
52
+ if entity_type == "UK_NHS":
53
+ digits = "".join(ch for ch in value if ch.isdigit())
54
+ out = {value, digits}
55
+ if len(digits) == 10:
56
+ out.add(f"{digits[:3]} {digits[3:6]} {digits[6:]}")
57
+ out.add(f"{digits[:3]}-{digits[3:6]}-{digits[6:]}")
58
+ return list(out)
59
+ if entity_type == "DATE_TIME":
60
+ return _date_variants(value)
61
+ return [value]
62
+
63
+
64
+ def _find_all(haystack: str, needle: str) -> list[tuple[int, int]]:
65
+ """Case-insensitive, word-boundary-aware occurrences of needle in haystack."""
66
+ if not needle:
67
+ return []
68
+ hl, nl = haystack.lower(), needle.lower()
69
+ spots: list[tuple[int, int]] = []
70
+ start = 0
71
+ while True:
72
+ i = hl.find(nl, start)
73
+ if i == -1:
74
+ break
75
+ left_ok = i == 0 or not (hl[i - 1].isalnum())
76
+ right_ok = i + len(nl) == len(hl) or not (hl[i + len(nl)].isalnum())
77
+ if left_ok and right_ok:
78
+ spots.append((i, i + len(nl)))
79
+ start = i + 1
80
+ return spots
81
+
82
+
83
+ def ground_truth_spans(record: NoteRecord) -> list[Span]:
84
+ """Locate each known PII value (and its surface variants) inside the note."""
85
+ occ: list[Span] = []
86
+ for gt in record.ground_truth:
87
+ for variant in value_variants(gt.text, gt.entity_type):
88
+ if len(variant) < 2:
89
+ continue
90
+ for s, e in _find_all(record.text, variant):
91
+ occ.append(Span(s, e, gt.entity_type, record.text[s:e]))
92
+ return _dedupe(occ)
93
+
94
+
95
+ def _dedupe(spans: list[Span]) -> list[Span]:
96
+ seen: set[tuple[int, int]] = set()
97
+ out: list[Span] = []
98
+ for s in sorted(spans, key=lambda x: (x.start, -(x.end - x.start))):
99
+ if any(s.start >= a and s.end <= b for (a, b) in seen):
100
+ continue
101
+ seen.add((s.start, s.end))
102
+ out.append(s)
103
+ return out
104
+
105
+
106
+ def _overlaps(a: Span, b: Span) -> bool:
107
+ return a.start < b.end and b.start < a.end
108
+
109
+
110
+ @dataclass
111
+ class Counter:
112
+ tp: int = 0
113
+ fp: int = 0
114
+ fn: int = 0
115
+
116
+ @property
117
+ def precision(self) -> float:
118
+ return self.tp / (self.tp + self.fp) if (self.tp + self.fp) else 0.0
119
+
120
+ @property
121
+ def recall(self) -> float:
122
+ return self.tp / (self.tp + self.fn) if (self.tp + self.fn) else 0.0
123
+
124
+ @property
125
+ def f1(self) -> float:
126
+ p, r = self.precision, self.recall
127
+ return 2 * p * r / (p + r) if (p + r) else 0.0
128
+
129
+
130
+ @dataclass
131
+ class EvalResult:
132
+ notes: int = 0
133
+ per_entity: dict[str, Counter] = field(default_factory=lambda: defaultdict(Counter))
134
+ overall: Counter = field(default_factory=Counter)
135
+ total_gt_occurrences: int = 0
136
+ residual_leaks: int = 0
137
+ transform_method: str = REDACTION
138
+ detector_name: str = ""
139
+
140
+ @property
141
+ def leakage_rate(self) -> float:
142
+ return self.residual_leaks / self.total_gt_occurrences if self.total_gt_occurrences else 0.0
143
+
144
+ def to_dict(self) -> dict:
145
+ return {
146
+ "detector": self.detector_name,
147
+ "transform": self.transform_method,
148
+ "notes_evaluated": self.notes,
149
+ "detection": {
150
+ "overall": {
151
+ "precision": round(self.overall.precision, 4),
152
+ "recall": round(self.overall.recall, 4),
153
+ "f1": round(self.overall.f1, 4),
154
+ "tp": self.overall.tp, "fp": self.overall.fp, "fn": self.overall.fn,
155
+ },
156
+ "per_entity": {
157
+ et: {
158
+ "precision": round(c.precision, 4),
159
+ "recall": round(c.recall, 4),
160
+ "f1": round(c.f1, 4),
161
+ "support": c.tp + c.fn,
162
+ }
163
+ for et, c in sorted(self.per_entity.items())
164
+ },
165
+ },
166
+ "leakage": {
167
+ "total_known_pii_occurrences": self.total_gt_occurrences,
168
+ "residual_leaks_after_sanitisation": self.residual_leaks,
169
+ "leakage_rate": round(self.leakage_rate, 4),
170
+ "leakage_rate_pct": round(100 * self.leakage_rate, 2),
171
+ },
172
+ }
173
+
174
+
175
+ def evaluate(
176
+ records: list[NoteRecord],
177
+ detector: Detector,
178
+ transform_method: str = REDACTION,
179
+ ) -> EvalResult:
180
+ res = EvalResult(transform_method=transform_method, detector_name=getattr(detector, "name", "?"))
181
+ for rec in records:
182
+ if not rec.text:
183
+ continue
184
+ res.notes += 1
185
+ gt = ground_truth_spans(rec)
186
+ detected = detector.detect(rec.text)
187
+
188
+ # ---- detection precision / recall (overlap-based) ----
189
+ matched_det: set[int] = set()
190
+ for g in gt:
191
+ hit = next((i for i, d in enumerate(detected)
192
+ if i not in matched_det and _overlaps(g, d)), None)
193
+ if hit is not None:
194
+ matched_det.add(hit)
195
+ res.per_entity[g.entity_type].tp += 1
196
+ res.overall.tp += 1
197
+ else:
198
+ res.per_entity[g.entity_type].fn += 1
199
+ res.overall.fn += 1
200
+ for i, d in enumerate(detected):
201
+ if i not in matched_det:
202
+ res.per_entity[d.entity_type].fp += 1
203
+ res.overall.fp += 1
204
+
205
+ # ---- residual leakage after sanitisation ----
206
+ vault = PseudonymVault()
207
+ sanitised, _ = apply_transform(
208
+ rec.text, detected, transform_method, vault, rec.person_id
209
+ )
210
+ res.total_gt_occurrences += len(gt)
211
+ # a known value leaks if any of its surface variants survives in output
212
+ for g in gt:
213
+ leaked = False
214
+ for variant in value_variants(g.text, g.entity_type):
215
+ if len(variant) >= 2 and _find_all(sanitised, variant):
216
+ leaked = True
217
+ break
218
+ if leaked:
219
+ res.residual_leaks += 1
220
+ return res
work-process_yumi/noteguard/pipeline.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """End-to-end single-note pipeline: detect -> de-identify -> audit.
2
+
3
+ This is the unit the demo UI and the CLI both call.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ from collections import Counter
8
+ from dataclasses import dataclass, field
9
+
10
+ from .detect import Detector, build_detector
11
+ from .recognizers import Span
12
+ from .transform import REDACTION, PseudonymVault, Replacement, apply_transform
13
+
14
+
15
+ @dataclass
16
+ class SanitiseResult:
17
+ original: str
18
+ sanitised: str
19
+ spans: list[Span]
20
+ replacements: list[Replacement]
21
+ method: str
22
+ audit: dict = field(default_factory=dict)
23
+
24
+
25
+ class Pipeline:
26
+ def __init__(self, detector: Detector | None = None, vault: PseudonymVault | None = None):
27
+ self.detector = detector or build_detector()
28
+ self.vault = vault or PseudonymVault()
29
+
30
+ def sanitise(self, text: str, method: str = REDACTION, person_id: str = "") -> SanitiseResult:
31
+ spans = self.detector.detect(text)
32
+ sanitised, repls = apply_transform(text, spans, method, self.vault, person_id)
33
+ by_type = Counter(s.entity_type for s in spans)
34
+ audit = {
35
+ "detector": getattr(self.detector, "name", "?"),
36
+ "method": method,
37
+ "entities_removed": sum(by_type.values()),
38
+ "by_type": dict(by_type),
39
+ }
40
+ return SanitiseResult(text, sanitised, spans, repls, method, audit)
work-process_yumi/noteguard/recognizers.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pure-Python rule recognisers — no spaCy / Presidio dependency.
2
+
3
+ These give NoteGuard a transparent, auditable baseline that runs anywhere, and
4
+ let the evaluation harness work even before the (heavier) NER engine is wired up.
5
+ The NHS-number recogniser validates the mod-11 check digit so random 10-digit
6
+ strings (dose volumes, IDs) aren't flagged as patient identifiers.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from dataclasses import dataclass
12
+
13
+ from .data import DATE, LOCATION, PERSON, UK_NHS # noqa: F401 (re-exported types)
14
+
15
+ EMAIL = "EMAIL_ADDRESS"
16
+ PHONE = "PHONE_NUMBER"
17
+ POSTCODE = "UK_POSTCODE"
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class Span:
22
+ start: int
23
+ end: int
24
+ entity_type: str
25
+ text: str
26
+ score: float = 1.0
27
+
28
+
29
+ def nhs_number_is_valid(digits: str) -> bool:
30
+ """Validate a 10-digit NHS number using the Modulus 11 check-digit algorithm."""
31
+ d = re.sub(r"\D", "", digits)
32
+ if len(d) != 10:
33
+ return False
34
+ total = sum(int(d[i]) * (10 - i) for i in range(9))
35
+ remainder = total % 11
36
+ check = 11 - remainder
37
+ if check == 11:
38
+ check = 0
39
+ if check == 10:
40
+ return False # never valid
41
+ return check == int(d[9])
42
+
43
+
44
+ # Real NHS numbers are 10 digits with a mod-11 check digit, optionally grouped.
45
+ _NHS_RE = re.compile(r"\b\d{3}[ -]?\d{3}[ -]?\d{4}\b")
46
+ # Context-anchored: an "NHS ..." label followed by a 9-10 digit number. Needed
47
+ # because this synthetic dataset uses 9-digit NHS numbers (no valid checksum),
48
+ # which neither the checksum rule nor Presidio's UK_NHS recogniser would catch.
49
+ _NHS_CTX_RE = re.compile(
50
+ r"NHS\s*(?:Number|No\.?|#)?\s*[:\-]?\s*(\d{3}[ -]?\d{3}[ -]?\d{2,4})",
51
+ re.IGNORECASE,
52
+ )
53
+ _EMAIL_RE = re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.-]+\b")
54
+ _PHONE_RE = re.compile(r"\b(?:\+?44\s?|0)(?:\d\s?){9,10}\b")
55
+ # UK postcode (simplified but standard) e.g. SW1A 1AA, M1 1AE
56
+ _POSTCODE_RE = re.compile(
57
+ r"\b[A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2}\b", re.IGNORECASE
58
+ )
59
+ _DATE_RE = re.compile(
60
+ r"\b(\d{1,2}[/-]\d{1,2}[/-]\d{2,4}"
61
+ r"|\d{4}-\d{2}-\d{2}"
62
+ r"|\d{1,2}\s+(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\.?\s+\d{2,4})\b",
63
+ re.IGNORECASE,
64
+ )
65
+
66
+
67
+ def find_rule_spans(text: str) -> list[Span]:
68
+ spans: list[Span] = []
69
+
70
+ for m in _NHS_RE.finditer(text):
71
+ if nhs_number_is_valid(m.group()):
72
+ spans.append(Span(m.start(), m.end(), UK_NHS, m.group()))
73
+ # context-anchored NHS numbers (catches the 9-digit synthetic ones)
74
+ for m in _NHS_CTX_RE.finditer(text):
75
+ spans.append(Span(m.start(1), m.end(1), UK_NHS, m.group(1)))
76
+
77
+ for regex, etype in (
78
+ (_EMAIL_RE, EMAIL),
79
+ (_PHONE_RE, PHONE),
80
+ (_POSTCODE_RE, POSTCODE),
81
+ (_DATE_RE, DATE),
82
+ ):
83
+ for m in regex.finditer(text):
84
+ spans.append(Span(m.start(), m.end(), etype, m.group()))
85
+
86
+ return _dedupe(spans)
87
+
88
+
89
+ def _dedupe(spans: list[Span]) -> list[Span]:
90
+ """Drop spans fully contained within another (keep the longer match)."""
91
+ spans = sorted(spans, key=lambda s: (s.start, -(s.end - s.start)))
92
+ kept: list[Span] = []
93
+ for s in spans:
94
+ if any(k.start <= s.start and s.end <= k.end for k in kept):
95
+ continue
96
+ kept.append(s)
97
+ return kept
98
+
99
+
100
+ if __name__ == "__main__":
101
+ # quick check: 9434765919 is a documented valid NHS test number
102
+ assert nhs_number_is_valid("943 476 5919"), "valid NHS number rejected"
103
+ assert not nhs_number_is_valid("943 476 5918"), "bad check digit accepted"
104
+ demo = "NHS no 943 476 5919, ring 07700 900123, dob 12/03/1981, SW1A 1AA."
105
+ for sp in find_rule_spans(demo):
106
+ print(sp)
work-process_yumi/noteguard/transform.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """De-identification transforms.
2
+
3
+ Presidio anonymises per-document; the value NoteGuard adds is *cross-note,
4
+ patient-consistent* de-identification — the same patient maps to the same
5
+ surrogate across their whole admission journey, and their dates are shifted by a
6
+ single consistent offset so intervals (and therefore clinical timelines) survive.
7
+ That utility-preserving longitudinal property is what makes the cleaned data
8
+ useful for downstream / federated training instead of just safe.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import re
14
+ from dataclasses import dataclass, field
15
+ from datetime import datetime, timedelta
16
+
17
+ from .recognizers import Span
18
+
19
+ REDACTION = "redaction"
20
+ PSEUDONYM = "pseudonym"
21
+
22
+ _DATE_FORMATS = ["%d/%m/%Y", "%d-%m-%Y", "%Y-%m-%d", "%d/%m/%y", "%d-%m-%y"]
23
+
24
+
25
+ @dataclass
26
+ class Replacement:
27
+ original: str
28
+ replacement: str
29
+ entity_type: str
30
+
31
+
32
+ @dataclass
33
+ class PseudonymVault:
34
+ """Stable original-value -> surrogate mapping (the 'mapping vault')."""
35
+ _map: dict[tuple[str, str], str] = field(default_factory=dict)
36
+ _counts: dict[str, int] = field(default_factory=dict)
37
+
38
+ def token_for(self, entity_type: str, value: str) -> str:
39
+ key = (entity_type, value.strip().lower())
40
+ if key not in self._map:
41
+ self._counts[entity_type] = self._counts.get(entity_type, 0) + 1
42
+ n = self._counts[entity_type]
43
+ if entity_type == "PERSON":
44
+ surrogate = f"Patient_{n:03d}"
45
+ elif entity_type == "UK_NHS":
46
+ surrogate = _fake_nhs_number(value)
47
+ else:
48
+ surrogate = f"{entity_type}_{n:03d}"
49
+ self._map[key] = surrogate
50
+ return self._map[key]
51
+
52
+ def export(self) -> dict[str, str]:
53
+ """Audit/export of the vault (keep this secret in production)."""
54
+ return {f"{etype}:{val}": tok for (etype, val), tok in self._map.items()}
55
+
56
+
57
+ def _patient_date_offset(person_id: str, max_days: int = 365) -> int:
58
+ """Deterministic per-patient shift in [-max_days, max_days], from person_id."""
59
+ h = int(hashlib.sha256(f"noteguard:{person_id}".encode()).hexdigest(), 16)
60
+ return (h % (2 * max_days + 1)) - max_days
61
+
62
+
63
+ def _fake_nhs_number(value: str) -> str:
64
+ """Deterministic, checksum-VALID fake NHS number (stable per original)."""
65
+ from .recognizers import nhs_number_is_valid
66
+
67
+ seed = int(hashlib.sha256(value.encode()).hexdigest(), 16)
68
+ for _ in range(1000):
69
+ nine = f"{seed % 1_000_000_000:09d}"
70
+ total = sum(int(nine[i]) * (10 - i) for i in range(9))
71
+ check = 11 - (total % 11)
72
+ check = 0 if check == 11 else check
73
+ if check != 10:
74
+ candidate = nine + str(check)
75
+ if nhs_number_is_valid(candidate):
76
+ return candidate
77
+ seed = (seed * 1103515245 + 12345) & ((1 << 64) - 1)
78
+ return "0000000000"
79
+
80
+
81
+ def _shift_date(value: str, offset_days: int) -> str | None:
82
+ for fmt in _DATE_FORMATS:
83
+ try:
84
+ dt = datetime.strptime(value.strip(), fmt)
85
+ return (dt + timedelta(days=offset_days)).strftime(fmt)
86
+ except ValueError:
87
+ continue
88
+ return None
89
+
90
+
91
+ def apply_transform(
92
+ text: str,
93
+ spans: list[Span],
94
+ method: str = REDACTION,
95
+ vault: PseudonymVault | None = None,
96
+ person_id: str = "",
97
+ ) -> tuple[str, list[Replacement]]:
98
+ """Return (sanitised_text, replacements). Spans applied right-to-left."""
99
+ vault = vault or PseudonymVault()
100
+ offset = _patient_date_offset(person_id) if person_id else 0
101
+ out = text
102
+ used: list[Replacement] = []
103
+ for s in sorted(spans, key=lambda x: x.start, reverse=True):
104
+ original = text[s.start:s.end]
105
+ if method == REDACTION:
106
+ repl = f"[{s.entity_type}]"
107
+ else: # PSEUDONYM
108
+ if s.entity_type == "DATE_TIME":
109
+ shifted = _shift_date(original, offset)
110
+ repl = shifted if shifted else "[DATE_TIME]"
111
+ else:
112
+ repl = vault.token_for(s.entity_type, original)
113
+ out = out[:s.start] + repl + out[s.end:]
114
+ used.append(Replacement(original, repl, s.entity_type))
115
+ used.reverse()
116
+ return out, used
117
+
118
+
119
+ if __name__ == "__main__":
120
+ from .recognizers import find_rule_spans
121
+
122
+ txt = "Pt John seen 12/03/1981, NHS 943 476 5919. Reviewed again 20/03/1981."
123
+ spans = find_rule_spans(txt)
124
+ for method in (REDACTION, PSEUDONYM):
125
+ v = PseudonymVault()
126
+ new, repls = apply_transform(txt, spans, method, v, person_id="p7")
127
+ print(f"\n[{method}] {new}")
128
+ for r in repls:
129
+ print(" ", r.original, "->", r.replacement, f"({r.entity_type})")
work-process_yumi/requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ pandas>=2.0
2
+ huggingface_hub>=0.23
3
+ presidio-analyzer>=2.2
4
+ presidio-anonymizer>=2.2
5
+ spacy>=3.7
6
+ gradio>=4.0
work-process_yumi/results.json ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "presidio+rules": {
3
+ "detector": "presidio+rules",
4
+ "transform": "redaction",
5
+ "notes_evaluated": 500,
6
+ "detection": {
7
+ "overall": {
8
+ "precision": 0.0678,
9
+ "recall": 0.7752,
10
+ "f1": 0.1247,
11
+ "tp": 238,
12
+ "fp": 3271,
13
+ "fn": 69
14
+ },
15
+ "per_entity": {
16
+ "DATE_TIME": {
17
+ "precision": 0.0171,
18
+ "recall": 1.0,
19
+ "f1": 0.0336,
20
+ "support": 25
21
+ },
22
+ "LOCATION": {
23
+ "precision": 0.0,
24
+ "recall": 0.0,
25
+ "f1": 0.0,
26
+ "support": 0
27
+ },
28
+ "PERSON": {
29
+ "precision": 0.088,
30
+ "recall": 0.6937,
31
+ "f1": 0.1562,
32
+ "support": 222
33
+ },
34
+ "PHONE_NUMBER": {
35
+ "precision": 0.0,
36
+ "recall": 0.0,
37
+ "f1": 0.0,
38
+ "support": 0
39
+ },
40
+ "UK_NHS": {
41
+ "precision": 0.9833,
42
+ "recall": 0.9833,
43
+ "f1": 0.9833,
44
+ "support": 60
45
+ },
46
+ "URL": {
47
+ "precision": 0.0,
48
+ "recall": 0.0,
49
+ "f1": 0.0,
50
+ "support": 0
51
+ }
52
+ }
53
+ },
54
+ "leakage": {
55
+ "total_known_pii_occurrences": 307,
56
+ "residual_leaks_after_sanitisation": 14,
57
+ "leakage_rate": 0.0456,
58
+ "leakage_rate_pct": 4.56
59
+ }
60
+ }
61
+ }
work-process_yumi/run_eval.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Run the NoteGuard evaluation over the NHSE synthetic dataset.
2
+
3
+ python run_eval.py --limit 300 # quick run
4
+ python run_eval.py --method pseudonym # leakage under pseudonymisation
5
+ python run_eval.py --compare # rules-only vs presidio+rules
6
+
7
+ Writes results.json (consumed by the demo's metrics panel) and prints a summary.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+
14
+ from noteguard.data import load_notes
15
+ from noteguard.detect import RuleDetector, build_detector
16
+ from noteguard.evaluate import EvalResult, evaluate
17
+ from noteguard.transform import REDACTION
18
+
19
+
20
+ def _print_summary(res: EvalResult) -> None:
21
+ d = res.to_dict()
22
+ print(f"\n detector : {d['detector']}")
23
+ print(f" transform: {d['transform']} notes: {d['notes_evaluated']}")
24
+ ov = d["detection"]["overall"]
25
+ print(f" detection P={ov['precision']:.3f} R={ov['recall']:.3f} F1={ov['f1']:.3f}")
26
+ print(" per-entity:")
27
+ for et, m in d["detection"]["per_entity"].items():
28
+ print(f" {et:<14} P={m['precision']:.3f} R={m['recall']:.3f} "
29
+ f"F1={m['f1']:.3f} (support={m['support']})")
30
+ lk = d["leakage"]
31
+ print(f" >> RESIDUAL LEAKAGE: {lk['residual_leaks_after_sanitisation']}"
32
+ f"/{lk['total_known_pii_occurrences']} = {lk['leakage_rate_pct']:.2f}%")
33
+
34
+
35
+ def main() -> None:
36
+ ap = argparse.ArgumentParser()
37
+ ap.add_argument("--limit", type=int, default=300, help="max notes (None=all)")
38
+ ap.add_argument("--method", default=REDACTION, choices=["redaction", "pseudonym"])
39
+ ap.add_argument("--no-presidio", action="store_true", help="rules only")
40
+ ap.add_argument("--compare", action="store_true", help="rules vs presidio+rules")
41
+ ap.add_argument("--out", default="results.json")
42
+ args = ap.parse_args()
43
+
44
+ print(f"[noteguard] loading notes (limit={args.limit}) ...")
45
+ records = load_notes(limit=args.limit)
46
+ print(f"[noteguard] {len(records)} notes; "
47
+ f"{sum(len(r.ground_truth) for r in records)} known PII values joined.")
48
+
49
+ runs: dict[str, EvalResult] = {}
50
+ if args.compare:
51
+ print("\n=== rules-only ===")
52
+ runs["rules"] = evaluate(records, RuleDetector(), args.method)
53
+ _print_summary(runs["rules"])
54
+ print("\n=== presidio+rules ===")
55
+ runs["presidio+rules"] = evaluate(records, build_detector(True), args.method)
56
+ _print_summary(runs["presidio+rules"])
57
+ else:
58
+ det = RuleDetector() if args.no_presidio else build_detector(True)
59
+ res = evaluate(records, det, args.method)
60
+ _print_summary(res)
61
+ runs[res.detector_name] = res
62
+
63
+ payload = {name: r.to_dict() for name, r in runs.items()}
64
+ with open(args.out, "w") as f:
65
+ json.dump(payload, f, indent=2)
66
+ print(f"\n[noteguard] wrote {args.out}")
67
+
68
+
69
+ if __name__ == "__main__":
70
+ main()