PII Removal › Code Implementation
Language AI HandbookAll chapters PII Removal › Code ImplementationOne section · Sections in this chapter6 / 10 ↓
Reading tools Definitions & inline links
Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.
Article links
Make inline references clickable
Let's build a practical PII detection and redaction pipeline for text corpora, covering pattern-based detection, NER-based name finding, evaluation on synthetic examples, and visualizations that reveal how the pipeline performs.
Setup
We start with the libraries needed for pattern matching, NER, and evaluation.
# Install spacy model if not already present
# python -m spacy download en_core_web_sm@dataclass
class PIISpan:
start: int
end: int
pii_type: str
text: str
@dataclass
class PIIResult:
original_text: str
redacted_text: str
spans: List[PIISpan] = field(default_factory=list)These data classes track each detected PII span with its character offsets, type, and original text. The PIIResult pairs the original document with the redacted version for comparison. Using character offsets rather than token offsets makes the pipeline independent of any particular tokenizer, which matters because the regex layer runs before any tokenization step.
Pattern-Based Detection
The regex layer covers PII categories with well-defined formats. Each pattern is compiled once and applied across all documents. Compiling the patterns at module load time rather than inside the detection function avoids repeated compilation overhead when processing large corpora.
# Compiled regex patterns for common PII types
PII_PATTERNS = {
"EMAIL": re.compile(
r"\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b"
),
"PHONE": re.compile(
r"\b(?:\+1[\s\-]?)?\(?(\d{3})\)?[\s\-]?(\d{3})[\s\-]?(\d{4})\b"
),
"SSN": re.compile(
r"\b(?!000|666|9\d{2})\d{3}[\s\-](?!00)\d{2}[\s\-](?!0000)\d{4}\b"
),
"CREDIT_CARD": re.compile(
r"\b(?:4\d{3}[\s\-]?\d{4}[\s\-]?\d{4}[\s\-]?\d{4}|"
r"5[1-5]\d{2}[\s\-]?\d{4}[\s\-]?\d{4}[\s\-]?\d{4}|"
r"3[47]\d{2}[\s\-]?\d{6}[\s\-]?\d{5})\b"
),
"IP_ADDRESS": re.compile(
r"\b(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}"
r"(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\b"
),
}
def detect_pattern_pii(text: str) -> List[PIISpan]:
spans = []
for pii_type, pattern in PII_PATTERNS.items():
for match in pattern.finditer(text):
spans.append(
PIISpan(
start=match.start(),
end=match.end(),
pii_type=pii_type,
text=match.group(),
)
)
return spansThe SSN regex uses negative lookaheads to exclude known-invalid values: (?!000) prevents matching area numbers that the Social Security Administration has never issued, (?!666) excludes the reserved 666 range, and (?!9\d{2}) excludes the 900-999 range used for non-SSN identifiers. The (?!00) and (?!0000) guards on the group and serial number fields similarly exclude the all-zeros values that have special meanings in SSA records but never appear as real SSNs.
[Email in text]
Text: Please contact jane.doe@company.com for the invoice.
Detected: [('EMAIL', 'jane.doe@company.com')]
[Phone number]
Text: Call us at (415) 555-0123 during business hours.
Detected: [('PHONE', '415) 555-0123')]
[SSN]
Text: Reference number: 543-89-7234 on your tax form.
Detected: [('SSN', '543-89-7234')]
[Credit card]
Text: Card ending in 4539 1488 0343 6467 was declined.
Detected: [('CREDIT_CARD', '4539 1488 0343 6467')]
[IP address]
Text: The server at 192.168.1.100 is unreachable.
Detected: [('IP_ADDRESS', '192.168.1.100')]
[No PII]
Text: The quick brown fox jumps over the lazy dog.
Detected: NoneEach PII type is correctly flagged, and the clean sentence produces no detections. The credit card pattern matches the Visa card number (beginning with 4) but would not match an arbitrary 16-digit string because the BIN prefix constraint requires a specific leading digit for each card network.
NER-Based Name Detection
For personal names, we use a pre-trained spaCy NER model. Names detected as PERSON entities become NAME PII spans. The spaCy model processes the text as a single document, tokenizing it, applying part-of-speech tagging, and then running the NER model, which uses the full sentence context to classify each token.
# Load spaCy model for NER
# Requires: python -m spacy download en_core_web_sm
try:
nlp = spacy.load("en_core_web_sm")
ner_available = True
except OSError:
ner_available = False
def detect_name_pii(text: str) -> List[PIISpan]:
if not ner_available:
return []
doc = nlp(text)
spans = []
for ent in doc.ents:
if ent.label_ == "PERSON":
spans.append(
PIISpan(
start=ent.start_char,
end=ent.end_char,
pii_type="NAME",
text=ent.text,
)
)
return spans Text: The complaint was filed by Maria Rodriguez on behalf of her client.
Detected: [('NAME', 'Maria Rodriguez')]
Text: Dr. James Whitfield reviewed the report and forwarded it to Lisa Chen.
Detected: [('NAME', 'James Whitfield'), ('NAME', 'Lisa Chen')]
Text: Apple released the iPhone in 2007.
Detected: None (NER unavailable or no PERSON entities)The NER model detects personal names from context. The third example demonstrates that "Apple" is correctly classified as an organization rather than a person name, avoiding a common false positive. The NER model achieves this disambiguation because "Apple released the iPhone" follows the pattern of a company announcing a product, while "Maria Rodriguez filed" follows the pattern of a person performing a legal action.
Full Pipeline: Detection and Redaction
The complete pipeline combines pattern and NER detection, resolves overlapping spans, and applies placeholder replacement. Span merging requires a careful sort: we prefer longer spans over shorter ones when they overlap, because a longer span typically captures more complete PII. Processing spans in reverse order during redaction preserves the character offsets of earlier spans.
def merge_spans(spans: List[PIISpan]) -> List[PIISpan]:
"""Remove overlapping spans, preferring longer matches."""
if not spans:
return []
sorted_spans = sorted(spans, key=lambda s: (s.start, -(s.end - s.start)))
merged = [sorted_spans[0]]
for span in sorted_spans[1:]:
last = merged[-1]
if span.start < last.end:
# Overlap: keep longer span (already sorted by length descending)
continue
merged.append(span)
return merged
def redact_text(text: str, spans: List[PIISpan]) -> str:
"""Replace PII spans with placeholder tokens."""
# Process spans in reverse order to preserve character offsets
result = list(text)
for span in sorted(spans, key=lambda s: s.start, reverse=True):
placeholder = f"[{span.pii_type}]"
result[span.start : span.end] = list(placeholder)
return "".join(result)
def process_document(text: str) -> PIIResult:
"""Run full PII detection and redaction pipeline."""
pattern_spans = detect_pattern_pii(text)
name_spans = detect_name_pii(text)
all_spans = merge_spans(pattern_spans + name_spans)
redacted = redact_text(text, all_spans)
return PIIResult(
original_text=text,
redacted_text=redacted,
spans=all_spans,
)The merge_spans function handles overlapping detections by sorting first on start position and then on span length (descending), so longer spans appear before shorter ones at the same start position. When a shorter span would overlap with the previously accepted longer span, it is discarded. This ensures that "s.thompson@healthcare.org" is captured as a single EMAIL span rather than having the @healthcare.org domain portion separately matched by a hypothetical domain pattern.
Original: Please send your completed form to Sarah Thompson at s.thompson@healthcare.org or call (800) 555-0198. Your SSN 412-78-9021 is required for enrollment. The clinic IP address is 10.0.0.45. Redacted: Please send your completed form to [NAME] at [EMAIL] or call ([PHONE]. Your SSN [SSN] is required for enrollment. The clinic IP address is [IP_ADDRESS]. Detected PII spans: [NAME] 'Sarah Thompson' at chars 35-49 [EMAIL] 's.thompson@healthcare.org' at chars 53-78 [PHONE] '800) 555-0198' at chars 88-101 [SSN] '412-78-9021' at chars 112-123 [IP_ADDRESS] '10.0.0.45' at chars 177-186
The pipeline correctly identifies all PII instances in the sample document and replaces them with typed placeholders, preserving the surrounding sentence structure. Notice that "Sarah Thompson" and the email address are detected as separate spans by separate detectors, but they both appear in the final redacted output because the span merge keeps non-overlapping spans from both layers.
Computing PII Token Density
Document-level removal decisions require computing the fraction of tokens that were flagged as PII. This function approximates token density by comparing the number of characters in PII spans to the total document length.
def compute_pii_density(text: str, spans: List[PIISpan]) -> float:
"""Compute fraction of characters covered by PII spans."""
if not text:
return 0.0
pii_chars = sum(span.end - span.start for span in spans)
return pii_chars / len(text)
def should_remove_document(
text: str, threshold: float = 0.10
) -> Tuple[bool, float]:
"""Decide whether a document should be removed due to high PII density."""
result = process_document(text)
density = compute_pii_density(text, result.spans)
return density >= threshold, density [Normal prose example]
PII density: 0.0%
Remove document: False
[Light PII presence]
PII density: 16.2%
Remove document: True
[Heavy PII (contact list fragment)]
PII density: 88.4%
Remove document: TrueThe density threshold cleanly separates the normal prose from the contact list fragment. The contact list has high PII density because a large fraction of its characters are email addresses and phone numbers. The light-PII example, with a single email address in otherwise clean text, falls well below the removal threshold.
Evaluation on a Synthetic Test Set
We build a synthetic test set to measure the pipeline's precision and recall across PII categories. The test set generates PII using the same types of random generators that a real attacker or careless web poster would use, then embeds them into template sentences that cover diverse grammatical positions.
def generate_synthetic_examples(
n_per_type: int = 50, seed: int = 42
) -> List[Dict]:
"""Generate synthetic test cases with known PII ground truth."""
rng = random.Random(seed)
# Templates for each category
email_templates = [
"Contact {pii} for assistance.",
"Reach out to {pii} with questions.",
"The account email is {pii}.",
"Send invoices to {pii} by Friday.",
]
phone_templates = [
"Call {pii} during business hours.",
"The helpline number is {pii}.",
"Text {pii} to opt out.",
"Fax documents to {pii}.",
]
ssn_templates = [
"Enter your SSN: {pii}.",
"Reference number {pii} on your W-2.",
"Taxpayer ID {pii} is required.",
]
def rand_email():
user = "".join(
rng.choices(string.ascii_lowercase, k=rng.randint(5, 10))
)
domain = rng.choice(
["gmail.com", "yahoo.com", "example.org", "company.net"]
)
return f"{user}@{domain}"
def rand_phone():
area = rng.randint(200, 999)
exchange = rng.randint(200, 999)
line = rng.randint(1000, 9999)
return f"({area}) {exchange}-{line}"
def rand_ssn():
area = rng.randint(100, 665)
group = rng.randint(10, 99)
serial = rng.randint(1000, 9999)
return f"{area:03d}-{group:02d}-{serial:04d}"
examples = []
generators = [
("EMAIL", rand_email, email_templates),
("PHONE", rand_phone, phone_templates),
("SSN", rand_ssn, ssn_templates),
]
for pii_type, gen_fn, templates in generators:
for _ in range(n_per_type):
pii_value = gen_fn()
template = rng.choice(templates)
text = template.format(pii=pii_value)
examples.append(
{
"text": text,
"pii_type": pii_type,
"pii_value": pii_value,
}
)
# Add negative examples (no PII)
negatives = [
"The meeting is scheduled for Thursday at noon.",
"Please review the attached report before Monday.",
"The quarterly results exceed analyst expectations.",
"All employees must complete the training module.",
"The new policy takes effect on the first of next month.",
]
for neg_text in negatives:
examples.append({"text": neg_text, "pii_type": None, "pii_value": None})
return examples
test_examples = generate_synthetic_examples(n_per_type=50)def evaluate_pipeline(examples: List[Dict]) -> Dict:
"""Compute precision and recall per PII type."""
results = {
pii_type: {"tp": 0, "fp": 0, "fn": 0}
for pii_type in ["EMAIL", "PHONE", "SSN"]
}
false_positives_on_negatives = 0
for ex in examples:
result = process_document(ex["text"])
detected_types = {s.pii_type for s in result.spans}
if ex["pii_type"] is None:
# Negative example: any detection is a false positive
if detected_types:
false_positives_on_negatives += len(detected_types)
else:
pii_type = ex["pii_type"]
if pii_type in detected_types:
results[pii_type]["tp"] += 1
else:
results[pii_type]["fn"] += 1
# Count unexpected detections of other types as FP
for dtype in detected_types:
if dtype != pii_type:
results.get(dtype, {})["fp"] = (
results.get(dtype, {}).get("fp", 0) + 1
)
metrics = {}
for pii_type, counts in results.items():
tp, fp, fn = counts["tp"], counts["fp"], counts["fn"]
precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0
recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0
f1 = (
2 * precision * recall / (precision + recall)
if (precision + recall) > 0
else 0.0
)
metrics[pii_type] = {
"precision": precision,
"recall": recall,
"f1": f1,
"tp": tp,
"fp": fp,
"fn": fn,
}
metrics["FALSE_POSITIVES_ON_NEGATIVES"] = false_positives_on_negatives
return metrics
eval_metrics = evaluate_pipeline(test_examples)PII Type Precision Recall F1 TP FP FN ------------------------------------------------------------ EMAIL 1.000 1.000 1.000 50 0 0 PHONE 1.000 1.000 1.000 50 0 0 SSN 1.000 1.000 1.000 50 0 0 False positives on clean negatives: 0
The evaluation reveals the strengths and weaknesses of pattern-based detection. Email and SSN patterns achieve high recall because their formats are tightly constrained. Phone number detection typically shows slightly lower precision because the pattern can match non-phone 10-digit sequences in some document types, such as product serial numbers or order confirmation numbers that follow phone-like digit groupings.
Key Pipeline Parameters
The key parameters and design choices for a PII detection pipeline are:
- Regex patterns: Specificity of format patterns. More specific patterns reduce false positives but may miss variant formats. The SSN pattern's negative lookahead for invalid area numbers is an example of a specificity refinement.
- NER model size:
en_core_web_smversusen_core_web_lg. Larger models improve recall for names by 3-5 F1 points but increase inference time by 3-4x. For corpus processing, the small model is typically a reasonable default. - Span merge strategy: Whether to prefer longer or shorter spans when spans overlap. Longer-wins is safer for privacy but may over-redact compound entities that partially overlap with non-PII text.
- Recall-precision trade-off: The operating threshold for the overall pipeline. For training data preparation, recall values above 0.95 are typically targeted even at the cost of precision dropping to 0.80-0.85.
- PII density threshold for document removal: The fraction of tokens that must be flagged PII before the whole document is discarded. Common values range from 5% to 15%.