De-identifying text

Masking personal identifiers and measuring leaks

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

The coordinator wants to share a sample applicant note in a teaching packet. The note contains names, a phone number, an email address, a birth date, and a street address. Those details need masking before distribution.

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

Note

The applicant note and Riverton setting are invented. The note describes no real person.

Important

Every personal value in this lesson is invented for teaching. The phone number uses the reserved 555-0100 through 555-0199 fiction range. 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.
  • 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 applies masking patterns, stringr matches text, and spacyr supplies 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]")
)
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)
  )
)
Expected identifiers found and missed by the rules
Measure Identifiers
found 6
missed 1
Extra masks and leaks after replacement
Measure Identifiers
wrongly masked 0
leaked after masking 1
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 give an estimate of recall on real notes. The name rule is a list of the two full names in this note. It cannot find a third name. The leaked value is Green, a surname that the rule does not recognize when it appears alone.

Compare spaCy PERSON labels

spaCy provides a second detector for person names. Its PERSON entities do not cover phone numbers, email addresses, birth dates, or street addresses. This comparison therefore covers only 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()
spaCy PERSON detections in the invented note
Category spaCy surface
person Maya Green
person Taylor Reed
spaCy PERSON accounting against the expected names
Measure Identifiers
PERSON found 2
PERSON missed 1

spaCy finds the two full names but misses the standalone surname. The note still contains a name after either detector identifies the full names.

Production de-identification

These teaching rules do not address the complexity of real personal data. Production teams often use specialized tools such as Microsoft Presidio. They must distinguish several concepts:

  • Masking, pseudonymization, and anonymization: Replacing a name with [PERSON] is masking. Under GDPR, it is pseudonymization only when separately kept, 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 does not promise that re-identification is impossible.
  • Quasi-identifiers: A unique medical symptom, exact timeline, or specific geographic area may not directly reveal a person’s identity alone. Together, those details can uniquely reveal that identity.
  • Category-wise recall: An honest evaluation measures the share of 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 states how teams can use data. It gives two de-identification methods, 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 with a record of details that must not remain visible.
  • 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.
  • Document the conventions for invented values, even when those values appear safe.

The lesson uses invented values rather than real applicant details. Its phone number and email domain follow reserved fiction conventions. The leaked surname demonstrates why these rules are unsuitable for real applicant notes.

Sources