yumi.h commited on
Commit ·
abfd704
1
Parent(s): 79df74d
Update files
Browse files- .gitignore +8 -0
- NOTEGU~1.MD +137 -0
- README.md +100 -1
- work-process_yumi/app.py +144 -0
- work-process_yumi/noteguard/__init__.py +7 -0
- work-process_yumi/noteguard/data.py +218 -0
- work-process_yumi/noteguard/detect.py +127 -0
- work-process_yumi/noteguard/evaluate.py +220 -0
- work-process_yumi/noteguard/pipeline.py +40 -0
- work-process_yumi/noteguard/recognizers.py +106 -0
- work-process_yumi/noteguard/transform.py +129 -0
- work-process_yumi/requirements.txt +6 -0
- work-process_yumi/results.json +61 -0
- work-process_yumi/run_eval.py +70 -0
.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 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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()
|