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]")
)De-identifying text
Masking personal identifiers and measuring 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.
The applicant note and Riverton setting are invented. The note describes no real person.
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.
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
PERSONentities 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.
| Identifier ID | Category | Surface text |
|---|---|---|
| id1 | person | Maya Green |
| id2 | phone | 555-0104 |
| id3 | 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)
)
)| Measure | Identifiers |
|---|---|
| found | 6 |
| missed | 1 |
| Measure | Identifiers |
|---|---|
| wrongly masked | 0 |
| leaked after masking | 1 |
| 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()| Category | spaCy surface |
|---|---|
| person | Maya Green |
| person | Taylor Reed |
| 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.