yumi.h commited on
Commit
b7c4eb2
·
1 Parent(s): 2da24e4

Only mask date-of-birth (not visit dates); readable redaction labels

Browse files

Reported: 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 CHANGED
@@ -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, longitudinal de-identification.** Same patient → same surrogate across their
28
- whole admission journey, with each patient's dates shifted by one consistent offset so clinical
29
- intervals survive — *useful* data, not just safe data. Realistic en_GB fakes (or `[TYPE]` redaction).
 
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,
noteguard/detect.py CHANGED
@@ -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", "DATE_TIME", "EMAIL_ADDRESS", "PHONE_NUMBER",
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
  }
noteguard/recognizers.py CHANGED
@@ -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
- _DATE_RE = re.compile(
70
- r"\b(\d{1,2}[/-]\d{1,2}[/-]\d{2,4}"
 
 
 
 
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})\b",
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
- (_DATE_RE, DATE, 0),
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),
noteguard/transform.py CHANGED
@@ -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, 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
  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:]
tests/test_transform.py CHANGED
@@ -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 "[UK_NHS]" in out
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 test_date_shift_preserves_interval():
45
- text = "seen 12/03/1981 and again 20/03/1981"
 
 
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 # 8-day interval survives the shift
 
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