De-identifying text

Mask personal identifiers and measure what leaks

entity enrichment
de-identification
workforce research
Learn how simple masking rules find invented personal identifiers and why de-identification is judged by leaks.

Sharing a sample applicant note raises a privacy question for the coordinator: can names, a phone number, an email address, a birth date, and a street address be hidden before the note enters a teaching packet?

Text de-identification removes or masks details that identify a person. This lesson uses only invented values, then measures what the rules failed to hide.

Note

The applicant note and Riverton setting are invented; no real person is described.

Important

Every personal value in this lesson is invented for teaching. The phone number uses the reserved 555-0100 through 555-0199 fiction range, and the email address uses the reserved example.com domain.

TipWhat you will learn

By the end of this lesson, you will be able to:

  • name common personal identifier categories;
  • write simple masking rules for an invented note;
  • compare found, missed, and wrongly masked identifiers;
  • use spaCy PERSON entities as a second detector; and
  • explain why rule-based redaction is not safe for real personal data.

Mark every identifier first

The next chunk loads dplyr and tibble for tables, purrr for applying the masking patterns, stringr for matching text, and spacyr for the second detector. The author-marked table is the reference for this lesson. It includes a surname, Green, that also appears as a common word.

library(dplyr)
library(tibble)
library(purrr)
library(stringr)
library(spacyr)

applicant_note <- paste(
  "Invented applicant note: Maya Green asked the Riverton Workforce Lab to call 555-0104.",
  "Her email is maya.green@example.com and her date of birth is 2001-04-12.",
  "She listed 123 Example Street as her address.",
  "Taylor Reed can confirm the class seat.",
  "Green prefers text messages after evening shifts.",
  sep = " "
)

identifiers <- tibble(
  identifier_id = paste0("id", 1:7),
  category = c(
    "person",
    "phone",
    "email",
    "date_of_birth",
    "street_address",
    "person",
    "person"
  ),
  surface = c(
    "Maya Green",
    "555-0104",
    "maya.green@example.com",
    "2001-04-12",
    "123 Example Street",
    "Taylor Reed",
    "Green"
  )
)

masking_rules <- tibble(
  category = c("email", "phone", "date_of_birth", "street_address", "person"),
  pattern = c(
    "\\b[A-Za-z0-9._%+-]+@example\\.com\\b",
    "\\b555-01\\d{2}\\b",
    "\\b\\d{4}-\\d{2}-\\d{2}\\b",
    "\\b\\d{1,4}\\s+Example\\s+(?:Street|Avenue|Road)\\b",
    "\\b(?:Maya Green|Taylor Reed)\\b"
  ),
  replacement = c("[EMAIL]", "[PHONE]", "[DATE_OF_BIRTH]", "[STREET_ADDRESS]", "[PERSON]")
)

knitr::kable(
  identifiers,
  col.names = c("Identifier ID", "Category", "Surface text"),
  caption = "Author-marked invented identifiers",
  row.names = FALSE
)
Author-marked invented identifiers
Identifier ID Category Surface text
id1 person Maya Green
id2 phone 555-0104
id3 email maya.green@example.com
id4 date_of_birth 2001-04-12
id5 street_address 123 Example Street
id6 person Taylor Reed
id7 person Green

The categories are a small teaching subset of the identifiers listed in HIPAA’s Safe Harbor guidance. The note contains seven marked identifiers. Three are person names or name fragments.

Run the masking rules

The rule set masks email, phone, date of birth, street address, and two full names. The evaluation counts exact category-and-text matches.

rule_hits <- pmap(
  masking_rules,
  function(category, pattern, replacement) {
    tibble(
      category = category,
      surface = str_extract_all(applicant_note, pattern)[[1]]
    )
  }
) |>
  list_rbind() |>
  filter(!is.na(surface), surface != "")

found_identifiers <- identifiers |>
  semi_join(rule_hits, by = join_by(category, surface))
missed_identifiers <- identifiers |>
  anti_join(rule_hits, by = join_by(category, surface))
wrongly_masked <- rule_hits |>
  anti_join(identifiers, by = join_by(category, surface))

replacement_map <- setNames(masking_rules$replacement, masking_rules$pattern)
masked_note <- str_replace_all(applicant_note, replacement_map)
leaked_identifiers <- identifiers |>
  filter(str_detect(masked_note, fixed(surface)))

found_missed_accounting <- tibble(
  measure = c("found", "missed"),
  identifiers = c(
    nrow(found_identifiers),
    nrow(missed_identifiers)
  )
)

mask_accounting <- tibble(
  measure = c("wrongly masked", "leaked after masking"),
  identifiers = c(
    nrow(wrongly_masked),
    nrow(leaked_identifiers)
  )
)

knitr::kable(
  found_missed_accounting,
  col.names = c("Measure", "Identifiers"),
  caption = "Expected identifiers found and missed by the rules",
  row.names = FALSE
)
Expected identifiers found and missed by the rules
Measure Identifiers
found 6
missed 1
knitr::kable(
  mask_accounting,
  col.names = c("Measure", "Identifiers"),
  caption = "Extra masks and leaks after replacement",
  row.names = FALSE
)
Extra masks and leaks after replacement
Measure Identifiers
wrongly masked 0
leaked after masking 1
knitr::kable(
  tibble(masked_note = masked_note),
  col.names = "Masked note",
  caption = "Invented note after rule-based masking",
  row.names = FALSE
)
Invented note after rule-based masking
Masked note
Invented applicant note: [PERSON] asked the Riverton Workforce Lab to call [PHONE]. Her email is [EMAIL] and her date of birth is [DATE_OF_BIRTH]. She listed [STREET_ADDRESS] as her address. [PERSON] can confirm the class seat. Green prefers text messages after evening shifts.

The rules and expected identifiers share an author, so the counts do not estimate recall on real notes. The name rule is just a list of the two full names in this note. It cannot find a third name, and the leaked value is Green, a surname that the rule did not treat as a name when it appeared alone.

Compare spaCy PERSON labels

spaCy can provide a second detector for person names. It does not detect phone numbers, email addresses, birth dates, or street addresses as PERSON entities, so this comparison is limited to the person category.

source("R/use-spacy.R")

pipeline <- use_project_spacy()
pipeline_version <- spacy_pipeline_version()

parsed <- spacy_parse(
  c(applicant_note = applicant_note),
  pos = TRUE,
  lemma = TRUE,
  entity = TRUE,
  dependency = TRUE,
  nounphrase = TRUE
)

spacy_people <- entity_extract(parsed, type = "all") |>
  filter(entity_type == "PERSON") |>
  transmute(
    category = "person",
    surface = str_replace_all(entity, "_", " ")
  )

spacy_found <- identifiers |>
  filter(category == "person") |>
  semi_join(spacy_people, by = join_by(category, surface))
spacy_missed <- identifiers |>
  filter(category == "person") |>
  anti_join(spacy_people, by = join_by(category, surface))

spacy_accounting <- tibble(
  measure = c("PERSON found", "PERSON missed"),
  identifiers = c(nrow(spacy_found), nrow(spacy_missed))
)

spacy_finalize()

knitr::kable(
  spacy_people,
  col.names = c("Category", "spaCy surface"),
  caption = "spaCy PERSON detections in the invented note",
  row.names = FALSE
)
spaCy PERSON detections in the invented note
Category spaCy surface
person Maya Green
person Taylor Reed
knitr::kable(
  spacy_accounting,
  col.names = c("Measure", "Identifiers"),
  caption = "spaCy PERSON accounting against the expected names",
  row.names = FALSE
)
spaCy PERSON accounting against the expected names
Measure Identifiers
PERSON found 2
PERSON missed 1

spaCy finds the two full names and misses the standalone surname. This second detector changes the route to the same warning: the note still leaks a name.

Production de-identification

The simple toy rules above miss the complexity of handling real personal data. In production (often using specialized toolkits like Microsoft Presidio), teams must track several critical concepts:

  • Masking, pseudonymization, and anonymization: Replacing a name with [PERSON] is masking. Under GDPR, it is pseudonymization only when separately held, protected additional information can restore the link to a person. Pseudonymized data remains personal data. Anonymization asks whether a person is no longer reasonably identifiable; it is not a promise that re-identification is impossible.
  • Quasi-identifiers: Details like a unique medical symptom, an exact timeline, or a specific geographic area might not be direct identifiers on their own, but when combined, they uniquely identify a person.
  • Category-wise recall: An honest evaluation measures the share of true identifiers found in each category. An overall score can hide a weak category.
  • Legal regimes and residual risk: GDPR processing needs an applicable lawful basis. HIPAA instead defines permitted uses and two de-identification routes, Safe Harbor and Expert Determination. A masking pass plus a residual risk note does not by itself satisfy either regime.

What to remember

  • De-identification starts by marking what must not escape.
  • Found, missed, wrongly masked, and leaked are different measures.
  • The rule set missed a surname that also looks like a common word.
  • A second detector is not a safety guarantee.
  • Rule-based masking is not safe enough for real personal data.
  • Invented safe-looking values still need documented conventions.

This invented note is safe enough for a lesson because the values are fake and reserved. The one leaked surname is enough reason to keep the rule set away from real applicant notes.

Sources