Only mask date-of-birth (not visit dates); readable redaction labels
Browse filesReported: a visit date ("reviewed on 07/01/26") was being redacted even though
it isn't a DOB, and "NMC number: 18D6896L" rendered as "[ORGANIZATION] number:
[NMC]".
- recognizers.py: dates are PII only in a date-of-birth context (_DOB_RE). Visit
/ encounter / admission dates are clinically useful and left intact.
- detect.py: drop DATE_TIME from PresidioDetector.KEEP so spaCy's generic date
detection no longer flags visit dates.
- transform.py: human-readable redaction placeholders ("[NMC number]",
"[NHS number]", "[name]", "[date of birth]" …) instead of raw entity codes.
- tests: update for new labels; date-shift test now uses DOB-context dates.
- docs: date-shift now applies to DOB only (visit-date interval preservation no
longer claimed).
(The "NMC"→ORGANIZATION issue was already fixed by the prior ORGANIZATION removal.)
- README.md +4 -3
- noteguard/detect.py +3 -1
- noteguard/recognizers.py +8 -5
- noteguard/transform.py +31 -6
- tests/test_transform.py +6 -4
|
@@ -24,9 +24,10 @@ layer** Presidio leaves to you:
|
|
| 24 |
2. **Domain adaptation to messy clinical text.** NHS-aware recognisers: checksum-validated NHS numbers
|
| 25 |
**plus** context-anchored detection for the dataset's 9-digit synthetic numbers Presidio's `UK_NHS`
|
| 26 |
misses, plus GMC/NMC clinician IDs, ODS org codes and record UUIDs.
|
| 27 |
-
3. **Patient-consistent
|
| 28 |
-
|
| 29 |
-
|
|
|
|
| 30 |
4. **Pluggable + degrades gracefully.** One `Detector` interface (Rule / Presidio); the pure-Python
|
| 31 |
rule layer + eval run even if spaCy/Presidio are unavailable.
|
| 32 |
5. **Governance wrapper.** Per-note audit of what was removed + the dataset-level leakage report,
|
|
|
|
| 24 |
2. **Domain adaptation to messy clinical text.** NHS-aware recognisers: checksum-validated NHS numbers
|
| 25 |
**plus** context-anchored detection for the dataset's 9-digit synthetic numbers Presidio's `UK_NHS`
|
| 26 |
misses, plus GMC/NMC clinician IDs, ODS org codes and record UUIDs.
|
| 27 |
+
3. **Patient-consistent de-identification.** Same patient → same surrogate across their whole
|
| 28 |
+
admission journey. Only date-of-birth is treated as PII (shifted by a consistent per-patient
|
| 29 |
+
offset); visit / admission dates are clinically useful and left intact. Realistic en_GB fakes
|
| 30 |
+
(or `[label]` redaction).
|
| 31 |
4. **Pluggable + degrades gracefully.** One `Detector` interface (Rule / Presidio); the pure-Python
|
| 32 |
rule layer + eval run even if spaCy/Presidio are unavailable.
|
| 33 |
5. **Governance wrapper.** Per-note audit of what was removed + the dataset-level leakage report,
|
|
@@ -39,8 +39,10 @@ class PresidioDetector:
|
|
| 39 |
# over-tags abbreviations/labels ("NHS", "DOB …", "GMC") as ORG, which both creates
|
| 40 |
# false positives and swallows precise rule spans (dates, NHS numbers). NHS site
|
| 41 |
# names are caught instead by the _SITE_RE LOCATION rule (incl. "… Trust").
|
|
|
|
|
|
|
| 42 |
KEEP = {
|
| 43 |
-
"PERSON", "
|
| 44 |
"LOCATION", "UK_NHS", "UK_NINO", "UK_PASSPORT",
|
| 45 |
"UK_VEHICLE_REGISTRATION", "IP_ADDRESS", "URL",
|
| 46 |
}
|
|
|
|
| 39 |
# over-tags abbreviations/labels ("NHS", "DOB …", "GMC") as ORG, which both creates
|
| 40 |
# false positives and swallows precise rule spans (dates, NHS numbers). NHS site
|
| 41 |
# names are caught instead by the _SITE_RE LOCATION rule (incl. "… Trust").
|
| 42 |
+
# DATE_TIME is also EXCLUDED: only date-of-birth dates are PII (caught by the _DOB_RE
|
| 43 |
+
# rule); visit / encounter / admission dates are clinically useful and left intact.
|
| 44 |
KEEP = {
|
| 45 |
+
"PERSON", "EMAIL_ADDRESS", "PHONE_NUMBER",
|
| 46 |
"LOCATION", "UK_NHS", "UK_NINO", "UK_PASSPORT",
|
| 47 |
"UK_VEHICLE_REGISTRATION", "IP_ADDRESS", "URL",
|
| 48 |
}
|
|
@@ -66,11 +66,14 @@ _EMAIL_RE = re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.-]+\b")
|
|
| 66 |
_PHONE_RE = re.compile(r"\b(?:\+?44\s?|0)(?:\d\s?){9,10}\b")
|
| 67 |
# UK postcode (simplified but standard) e.g. SW1A 1AA, M1 1AE
|
| 68 |
_POSTCODE_RE = re.compile(r"\b[A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2}\b", re.IGNORECASE)
|
| 69 |
-
|
| 70 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
r"|\d{4}-\d{2}-\d{2}"
|
| 72 |
-
r"|\d{1,2}\s+(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\.?\s+\d{2,4})
|
| 73 |
-
re.IGNORECASE,
|
| 74 |
)
|
| 75 |
# --- NHS staff / organisation identifiers (context-anchored to avoid noise) ---
|
| 76 |
_GMC_RE = re.compile(r"(?i)\bGMC(?:\s*(?:no|number|#))?[:\s#]*(\d{7})\b")
|
|
@@ -97,7 +100,7 @@ _PLAIN = [
|
|
| 97 |
(_EMAIL_RE, EMAIL, 0),
|
| 98 |
(_PHONE_RE, PHONE, 0),
|
| 99 |
(_POSTCODE_RE, POSTCODE, 0),
|
| 100 |
-
(
|
| 101 |
(_GMC_RE, GMC, 1),
|
| 102 |
(_NMC_RE, NMC, 1),
|
| 103 |
(_NMC_BARE_RE, NMC, 1),
|
|
|
|
| 66 |
_PHONE_RE = re.compile(r"\b(?:\+?44\s?|0)(?:\d\s?){9,10}\b")
|
| 67 |
# UK postcode (simplified but standard) e.g. SW1A 1AA, M1 1AE
|
| 68 |
_POSTCODE_RE = re.compile(r"\b[A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2}\b", re.IGNORECASE)
|
| 69 |
+
# Dates are PII only in a DATE-OF-BIRTH context. Visit / encounter / admission dates
|
| 70 |
+
# are clinically useful and NOT identifiers on their own, so they're left intact.
|
| 71 |
+
# Captures the date itself as group 1.
|
| 72 |
+
_DOB_RE = re.compile(
|
| 73 |
+
r"(?i)\b(?:DOB|D\.O\.B\.?|date\s+of\s+birth|born(?:\s+on)?)[\s:]*"
|
| 74 |
+
r"(\d{1,2}[/-]\d{1,2}[/-]\d{2,4}"
|
| 75 |
r"|\d{4}-\d{2}-\d{2}"
|
| 76 |
+
r"|\d{1,2}\s+(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\.?\s+\d{2,4})"
|
|
|
|
| 77 |
)
|
| 78 |
# --- NHS staff / organisation identifiers (context-anchored to avoid noise) ---
|
| 79 |
_GMC_RE = re.compile(r"(?i)\bGMC(?:\s*(?:no|number|#))?[:\s#]*(\d{7})\b")
|
|
|
|
| 100 |
(_EMAIL_RE, EMAIL, 0),
|
| 101 |
(_PHONE_RE, PHONE, 0),
|
| 102 |
(_POSTCODE_RE, POSTCODE, 0),
|
| 103 |
+
(_DOB_RE, DATE, 1),
|
| 104 |
(_GMC_RE, GMC, 1),
|
| 105 |
(_NMC_RE, NMC, 1),
|
| 106 |
(_NMC_BARE_RE, NMC, 1),
|
|
@@ -2,10 +2,10 @@
|
|
| 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
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
useful for downstream / federated training
|
| 9 |
|
| 10 |
Surrogates are realistic en_GB fakes (folded in from the Presidio branch's Faker
|
| 11 |
vault) so the output reads like a real note — better for training than `Patient_001`
|
|
@@ -26,6 +26,31 @@ from .recognizers import Span
|
|
| 26 |
REDACTION = "redaction"
|
| 27 |
PSEUDONYM = "pseudonym"
|
| 28 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
_DATE_FORMATS = ["%d/%m/%Y", "%d-%m-%Y", "%Y-%m-%d", "%d/%m/%y", "%d-%m-%y"]
|
| 30 |
_POSTCODE_INWARD = re.compile(r"\s*\d[A-Za-z]{2}\s*$")
|
| 31 |
|
|
@@ -172,11 +197,11 @@ def apply_transform(
|
|
| 172 |
for s in sorted(spans, key=lambda x: x.start, reverse=True):
|
| 173 |
original = text[s.start:s.end]
|
| 174 |
if method == REDACTION:
|
| 175 |
-
repl = f"[{s.entity_type}]"
|
| 176 |
else: # PSEUDONYM
|
| 177 |
if s.entity_type == "DATE_TIME":
|
| 178 |
shifted = _shift_date(original, offset)
|
| 179 |
-
repl = shifted if shifted else "[DATE_TIME]"
|
| 180 |
else:
|
| 181 |
repl = vault.token_for(s.entity_type, original)
|
| 182 |
out = out[:s.start] + repl + out[s.end:]
|
|
|
|
| 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. Only date-of-birth is treated as
|
| 6 |
+
PII; it is shifted by a single consistent per-patient offset (visit / admission
|
| 7 |
+
dates are clinically useful and left intact). That utility-preserving property is
|
| 8 |
+
what makes the cleaned data useful for downstream / federated training, not just safe.
|
| 9 |
|
| 10 |
Surrogates are realistic en_GB fakes (folded in from the Presidio branch's Faker
|
| 11 |
vault) so the output reads like a real note — better for training than `Patient_001`
|
|
|
|
| 26 |
REDACTION = "redaction"
|
| 27 |
PSEUDONYM = "pseudonym"
|
| 28 |
|
| 29 |
+
# Human-readable placeholders for redaction — clearer than raw entity codes
|
| 30 |
+
# (e.g. "NMC number: [NMC number]" instead of "[ORGANIZATION] number: [NMC]").
|
| 31 |
+
_REDACT_LABEL = {
|
| 32 |
+
"PERSON": "name",
|
| 33 |
+
"DATE_TIME": "date of birth",
|
| 34 |
+
"UK_NHS": "NHS number",
|
| 35 |
+
"UK_NINO": "NI number",
|
| 36 |
+
"UK_POSTCODE": "postcode",
|
| 37 |
+
"UK_PASSPORT": "passport number",
|
| 38 |
+
"UK_VEHICLE_REGISTRATION": "vehicle registration",
|
| 39 |
+
"GMC": "GMC number",
|
| 40 |
+
"NMC": "NMC number",
|
| 41 |
+
"NHS_ODS": "ODS code",
|
| 42 |
+
"RECORD_ID": "record ID",
|
| 43 |
+
"LOCATION": "location",
|
| 44 |
+
"EMAIL_ADDRESS": "email",
|
| 45 |
+
"PHONE_NUMBER": "phone",
|
| 46 |
+
"IP_ADDRESS": "IP address",
|
| 47 |
+
"URL": "URL",
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def redaction_label(entity_type: str) -> str:
|
| 52 |
+
return _REDACT_LABEL.get(entity_type, entity_type)
|
| 53 |
+
|
| 54 |
_DATE_FORMATS = ["%d/%m/%Y", "%d-%m-%Y", "%Y-%m-%d", "%d/%m/%y", "%d-%m-%y"]
|
| 55 |
_POSTCODE_INWARD = re.compile(r"\s*\d[A-Za-z]{2}\s*$")
|
| 56 |
|
|
|
|
| 197 |
for s in sorted(spans, key=lambda x: x.start, reverse=True):
|
| 198 |
original = text[s.start:s.end]
|
| 199 |
if method == REDACTION:
|
| 200 |
+
repl = f"[{redaction_label(s.entity_type)}]"
|
| 201 |
else: # PSEUDONYM
|
| 202 |
if s.entity_type == "DATE_TIME":
|
| 203 |
shifted = _shift_date(original, offset)
|
| 204 |
+
repl = shifted if shifted else f"[{redaction_label('DATE_TIME')}]"
|
| 205 |
else:
|
| 206 |
repl = vault.token_for(s.entity_type, original)
|
| 207 |
out = out[:s.start] + repl + out[s.end:]
|
|
@@ -11,7 +11,7 @@ from noteguard.transform import (
|
|
| 11 |
def test_redaction_removes_value_and_tags():
|
| 12 |
text = "NHS 943 476 5919"
|
| 13 |
out, _ = apply_transform(text, find_rule_spans(text), REDACTION)
|
| 14 |
-
assert "[
|
| 15 |
assert "943 476 5919" not in out
|
| 16 |
|
| 17 |
|
|
@@ -41,10 +41,12 @@ def test_vehicle_surrogate_format():
|
|
| 41 |
assert re.fullmatch(r"[A-Z]{2}\d{2} [A-Z]{3}", reg)
|
| 42 |
|
| 43 |
|
| 44 |
-
def
|
| 45 |
-
|
|
|
|
|
|
|
| 46 |
out, _ = apply_transform(text, find_rule_spans(text), PSEUDONYM, PseudonymVault(), person_id="p1")
|
| 47 |
assert "12/03/1981" not in out and "20/03/1981" not in out
|
| 48 |
dates = [datetime.strptime(d, "%d/%m/%Y") for d in re.findall(r"\d{2}/\d{2}/\d{4}", out)]
|
| 49 |
assert len(dates) == 2
|
| 50 |
-
assert (dates[1] - dates[0]).days == 8 #
|
|
|
|
| 11 |
def test_redaction_removes_value_and_tags():
|
| 12 |
text = "NHS 943 476 5919"
|
| 13 |
out, _ = apply_transform(text, find_rule_spans(text), REDACTION)
|
| 14 |
+
assert "[NHS number]" in out
|
| 15 |
assert "943 476 5919" not in out
|
| 16 |
|
| 17 |
|
|
|
|
| 41 |
assert re.fullmatch(r"[A-Z]{2}\d{2} [A-Z]{3}", reg)
|
| 42 |
|
| 43 |
|
| 44 |
+
def test_date_shift_is_consistent_per_patient():
|
| 45 |
+
# Only date-of-birth dates are treated as PII, so both need a DOB context. The
|
| 46 |
+
# per-patient offset is consistent, so the interval between them is preserved.
|
| 47 |
+
text = "DOB 12/03/1981 ... DOB 20/03/1981"
|
| 48 |
out, _ = apply_transform(text, find_rule_spans(text), PSEUDONYM, PseudonymVault(), person_id="p1")
|
| 49 |
assert "12/03/1981" not in out and "20/03/1981" not in out
|
| 50 |
dates = [datetime.strptime(d, "%d/%m/%Y") for d in re.findall(r"\d{2}/\d{2}/\d{4}", out)]
|
| 51 |
assert len(dates) == 2
|
| 52 |
+
assert (dates[1] - dates[0]).days == 8 # consistent offset preserves the interval
|