Module 6: Data Privacy & PII Protection

3. PII Detection: Patterns, Regex, and Presidio

Overview

In the previous capsule you understood the mechanisms of LLM02 — how LLMs memorize and leak sensitive data. Now you need the tool to detect that data before it reaches the model or after it leaves. PII (Personally Identifiable Information) detection is the first operational piece of the PII Protection Layer.

Detecting PII is harder than it looks. A regex for emails works well for the standard format, but it fails with variants like "juan [at] empresa [dot] com". A regex for names is practically impossible — is "Rosa" a name or a color? Is "Santiago" a person or a city? To solve this, you need to combine three approaches: regex for structured patterns (emails, phones, SSN), Named Entity Recognition (NER) with language models for contextual entities (names, organizations), and custom recognizers for data specific to your domain (account numbers, internal IDs).

In this capsule you build a complete PII Scanner using Microsoft Presidio as the main engine, with regex extensions and custom recognizers. This component is reused directly in the capsule 08 project.


What counts as PII?

PII is any information that can be used to identify a person, directly or indirectly. The list is longer than most developers assume:

pii_categories = {
    "Direct Identifiers": {
        "description": "Identify a person directly",
        "examples": [
            "Full name",
            "Social Security Number (SSN)",
            "Passport number",
            "Driver's license number",
            "Biometric data (fingerprint, facial)",
        ],
        "risk": "Critical — direct identification",
    },
    "Contact Information": {
        "description": "Allow contacting the person",
        "examples": [
            "Email",
            "Phone",
            "Physical address",
            "IP address",
        ],
        "risk": "High — direct contact + geo-location",
    },
    "Financial Data": {
        "description": "Personal financial data",
        "examples": [
            "Credit card number",
            "Bank account number",
            "IBAN/CLABE",
            "Tax information",
        ],
        "risk": "Critical — financial fraud",
    },
    "Health Data": {
        "description": "Personal medical information",
        "examples": [
            "Diagnoses",
            "Medications",
            "Health insurance number",
            "Clinical history",
        ],
        "risk": "Critical — regulated by HIPAA/GDPR",
    },
    "Quasi-identifiers": {
        "description": "Combined, they can identify a person",
        "examples": [
            "Date of birth",
            "Postal code",
            "Gender",
            "Occupation",
            "Nationality",
        ],
        "risk": "Medium — re-identification by combination",
    },
}

print("PII categories:\n")
for category, info in pii_categories.items():
    print(f"  {category} [{info['risk']}]")
    print(f"    {info['description']}")
    for ex in info['examples'][:3]:
        print(f"      - {ex}")
    print()

Regex detection: structured patterns

For PII with a predictable format (emails, phones, SSN, cards), regex is the most direct and fast tool.

import re
from dataclasses import dataclass, field
from typing import Optional


@dataclass
class PIIMatch:
    entity_type: str
    text: str
    start: int
    end: int
    score: float
    source: str = "regex"


class RegexPIIDetector:
    """Regex-based PII detector."""

    PATTERNS = {
        "EMAIL": {
            "pattern": re.compile(
                r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b"
            ),
            "score": 0.95,
        },
        "PHONE_US": {
            "pattern": re.compile(
                r"\b(?:\+1\s?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b"
            ),
            "score": 0.85,
        },
        "PHONE_MX": {
            "pattern": re.compile(
                r"\b(?:\+52\s?)?\(?\d{2,3}\)?[-.\s]?\d{3,4}[-.\s]?\d{4}\b"
            ),
            "score": 0.80,
        },
        "SSN": {
            "pattern": re.compile(r"\b\d{3}-\d{2}-\d{4}\b"),
            "score": 0.99,
        },
        "CREDIT_CARD": {
            "pattern": re.compile(r"\b(?:\d{4}[-\s]?){3}\d{4}\b"),
            "score": 0.90,
        },
        "IBAN": {
            "pattern": re.compile(
                r"\b[A-Z]{2}\d{2}[A-Z0-9]{4}\d{7}(?:[A-Z0-9]?){0,16}\b"
            ),
            "score": 0.85,
        },
        "IP_ADDRESS": {
            "pattern": 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"
            ),
            "score": 0.70,
        },
        "DATE_OF_BIRTH": {
            "pattern": re.compile(
                r"\b(?:0[1-9]|1[0-2])[/-](?:0[1-9]|[12]\d|3[01])[/-]"
                r"(?:19|20)\d{2}\b"
            ),
            "score": 0.60,
        },
        "CURP_MX": {
            "pattern": re.compile(
                r"\b[A-Z]{4}\d{6}[HM][A-Z]{5}[A-Z0-9]\d\b"
            ),
            "score": 0.95,
        },
        "RFC_MX": {
            "pattern": re.compile(
                r"\b[A-ZÑ&]{3,4}\d{6}[A-Z0-9]{3}\b"
            ),
            "score": 0.80,
        },
    }

    def detect(self, text: str, entities: Optional[list[str]] = None) -> list[PIIMatch]:
        """Detects PII in the text using regex."""
        matches = []
        patterns_to_check = self.PATTERNS

        if entities:
            patterns_to_check = {
                k: v for k, v in self.PATTERNS.items() if k in entities
            }

        for entity_type, config in patterns_to_check.items():
            for match in config["pattern"].finditer(text):
                pii_match = PIIMatch(
                    entity_type=entity_type,
                    text=match.group(),
                    start=match.start(),
                    end=match.end(),
                    score=config["score"],
                    source="regex",
                )
                matches.append(pii_match)

        return sorted(matches, key=lambda m: m.start)


# --- Demo ---

detector = RegexPIIDetector()

test_text = (
    "Contacta a María García en maria.garcia@empresa.com o al "
    "555-123-4567. Su SSN es 123-45-6789 y su tarjeta termina "
    "en 4111-1111-1111-1111. Nació el 03/15/1990."
)

matches = detector.detect(test_text)
print(f"Text: \"{test_text[:60]}...\"\n")
print(f"PII found: {len(matches)} entities\n")
for m in matches:
    print(f"  [{m.score:.2f}] {m.entity_type}: \"{m.text}\" (pos {m.start}-{m.end})")

# Expected output:
# PII found: 6 entities
#
#   [0.95] EMAIL: "maria.garcia@empresa.com" (pos 27-51)
#   [0.85] PHONE_US: "555-123-4567" (pos 57-69)
#   [0.80] PHONE_MX: "555-123-4567" (pos 57-69)
#   [0.99] SSN: "123-45-6789" (pos 81-92)
#   [0.90] CREDIT_CARD: "4111-1111-1111-1111" (pos 117-136)
#   [0.60] DATE_OF_BIRTH: "03/15/1990" (pos 147-157)

Regex limitations

regex_limitations = [
    {
        "limitation": "Doesn't detect people's names",
        "example": "'María García' — there's no regex pattern for names",
        "solution": "Use NER (spaCy, Presidio)",
    },
    {
        "limitation": "Doesn't understand context",
        "example": "'555-123-4567' can be a phone or a product code",
        "solution": "Combine with context analysis",
    },
    {
        "limitation": "False positives with similar formats",
        "example": "'192.168.1.1' is an internal IP, not necessarily PII",
        "solution": "Classify by usage context",
    },
    {
        "limitation": "Doesn't handle format variants",
        "example": "'juan [at] empresa [dot] com' evades the email regex",
        "solution": "Normalize the text before applying regex",
    },
]

print("Regex limitations for PII detection:\n")
for lim in regex_limitations:
    print(f"  ❌ {lim['limitation']}")
    print(f"     Example: {lim['example']}")
    print(f"     Solution: {lim['solution']}")
    print()

Microsoft Presidio: enterprise-grade detection

Presidio is Microsoft's open-source library for PII detection and anonymization. It combines regex, NER with spaCy, and configurable recognizers in a unified engine.

Installation and setup

pip install presidio-analyzer presidio-anonymizer spacy
python -m spacy download en_core_web_lg

Basic usage

from presidio_analyzer import AnalyzerEngine, RecognizerResult

analyzer = AnalyzerEngine()

text = (
    "My name is John Smith, my email is john.smith@example.com "
    "and my phone number is 212-555-5555. My SSN is 078-05-1120."
)

results = analyzer.analyze(
    text=text,
    language="en",
)

print(f"Analyzed text ({len(text)} chars)")
print(f"Entities found: {len(results)}\n")

for result in sorted(results, key=lambda r: r.start):
    entity_text = text[result.start:result.end]
    print(
        f"  [{result.score:.2f}] {result.entity_type}: "
        f"\"{entity_text}\" (pos {result.start}-{result.end})"
    )

# Expected output:
# Analyzed text (112 chars)
# Entities found: 4
#
#   [0.85] PERSON: "John Smith" (pos 11-21)
#   [1.00] EMAIL_ADDRESS: "john.smith@example.com" (pos 36-58)
#   [0.75] PHONE_NUMBER: "212-555-5555" (pos 84-96)
#   [0.85] US_SSN: "078-05-1120" (pos 108-119)

Entity configuration

from presidio_analyzer import AnalyzerEngine

analyzer = AnalyzerEngine()

text = (
    "Contact María García at maria@test.com. "
    "Her credit card is 4111-1111-1111-1111 and "
    "she lives at 123 Main St, New York, NY 10001."
)

only_pii = analyzer.analyze(
    text=text,
    language="en",
    entities=[
        "PERSON",
        "EMAIL_ADDRESS",
        "CREDIT_CARD",
        "PHONE_NUMBER",
        "US_SSN",
    ],
)

all_entities = analyzer.analyze(
    text=text,
    language="en",
)

print(f"Only specific PII: {len(only_pii)} entities")
print(f"All entities: {len(all_entities)} entities")
print()

for r in sorted(all_entities, key=lambda r: r.start):
    entity_text = text[r.start:r.end]
    print(f"  [{r.score:.2f}] {r.entity_type}: \"{entity_text}\"")

# Expected output (may vary by spaCy model):
# Only specific PII: 3 entities
# All entities: 5+ entities

Confidence scores and thresholds

from presidio_analyzer import AnalyzerEngine

analyzer = AnalyzerEngine()

text = (
    "Call me at 555-0100 or email test@example.com. "
    "My name is Robert."
)

high_confidence = analyzer.analyze(
    text=text,
    language="en",
    score_threshold=0.8,
)

low_confidence = analyzer.analyze(
    text=text,
    language="en",
    score_threshold=0.3,
)

print(f"Threshold 0.8: {len(high_confidence)} entities (high confidence)")
for r in high_confidence:
    print(f"  [{r.score:.2f}] {r.entity_type}: \"{text[r.start:r.end]}\"")

print(f"\nThreshold 0.3: {len(low_confidence)} entities (low confidence)")
for r in low_confidence:
    print(f"  [{r.score:.2f}] {r.entity_type}: \"{text[r.start:r.end]}\"")

# Expected output:
# Threshold 0.8: 1-2 entities (high confidence)
#   [1.00] EMAIL_ADDRESS: "test@example.com"
#
# Threshold 0.3: 3+ entities (low confidence)
#   [1.00] EMAIL_ADDRESS: "test@example.com"
#   [0.75] PHONE_NUMBER: "555-0100"
#   [0.40] PERSON: "Robert"

spaCy NER for contextual PII

spaCy provides Named Entity Recognition — the ability to identify people, organizations, and places in free text. Presidio uses spaCy internally, but you can use it directly for more control.

import spacy

nlp = spacy.load("en_core_web_lg")

text = (
    "María García works at Acme Corporation in San Francisco. "
    "She joined on January 15, 2024 and reports to David Chen."
)

doc = nlp(text)

print(f"NER entities found:\n")
for ent in doc.ents:
    print(f"  [{ent.label_}] \"{ent.text}\" (pos {ent.start_char}-{ent.end_char})")

# Expected output:
# NER entities found:
#
#   [PERSON] "María García" (pos 0-12)
#   [ORG] "Acme Corporation" (pos 22-38)
#   [GPE] "San Francisco" (pos 42-55)
#   [DATE] "January 15, 2024" (pos 70-86)
#   [PERSON] "David Chen" (pos 101-111)

NER in Spanish with spaCy

import spacy

nlp_es = spacy.load("es_core_news_md")

text_es = (
    "Juan Pérez trabaja en Banco Nacional de México en la "
    "Ciudad de México. Su jefa es Ana Martínez."
)

doc = nlp_es(text_es)

print(f"NER entities in Spanish:\n")
for ent in doc.ents:
    print(f"  [{ent.label_}] \"{ent.text}\"")

# Expected output:
#   [PER] "Juan Pérez"
#   [ORG] "Banco Nacional de México"
#   [LOC] "Ciudad de México"
#   [PER] "Ana Martínez"

Mapping spaCy entities to PII

SPACY_TO_PII_MAP = {
    "PERSON": "PERSON",
    "PER": "PERSON",
    "ORG": "ORGANIZATION",
    "GPE": "LOCATION",
    "LOC": "LOCATION",
    "DATE": "DATE_TIME",
    "MONEY": "FINANCIAL",
    "CARDINAL": None,
    "ORDINAL": None,
}


def spacy_to_pii(doc, pii_relevant_only: bool = True) -> list[dict]:
    """Converts spaCy entities to the standard PII format."""
    results = []
    for ent in doc.ents:
        pii_type = SPACY_TO_PII_MAP.get(ent.label_)
        if pii_relevant_only and pii_type is None:
            continue
        results.append({
            "entity_type": pii_type or ent.label_,
            "text": ent.text,
            "start": ent.start_char,
            "end": ent.end_char,
            "source": "spacy_ner",
            "original_label": ent.label_,
        })
    return results


nlp = spacy.load("en_core_web_lg")
doc = nlp("María García works at Google in New York.")

pii_entities = spacy_to_pii(doc)
for entity in pii_entities:
    print(f"  [{entity['entity_type']}] \"{entity['text']}\" (spaCy: {entity['original_label']})")

# Expected output:
#   [PERSON] "María García" (spaCy: PERSON)
#   [ORGANIZATION] "Google" (spaCy: ORG)
#   [LOCATION] "New York" (spaCy: GPE)

Custom recognizers in Presidio

Presidio lets you add custom recognizers for PII specific to your domain — account numbers, internal IDs, proprietary data formats.

from presidio_analyzer import (
    AnalyzerEngine,
    PatternRecognizer,
    Pattern,
    RecognizerRegistry,
)


# Custom recognizer for a Mexican bank account number (CLABE)
clabe_recognizer = PatternRecognizer(
    supported_entity="MX_CLABE",
    name="Mexican CLABE Recognizer",
    patterns=[
        Pattern(
            name="clabe_pattern",
            regex=r"\b\d{18}\b",
            score=0.6,
        ),
    ],
    context=["clabe", "cuenta", "transferencia", "banco"],
    supported_language="es",
)


# Recognizer for an internal employee number
employee_id_recognizer = PatternRecognizer(
    supported_entity="EMPLOYEE_ID",
    name="Employee ID Recognizer",
    patterns=[
        Pattern(
            name="emp_id_pattern",
            regex=r"\bEMP-\d{6}\b",
            score=0.95,
        ),
    ],
    context=["empleado", "employee", "trabajador", "id"],
)


# Recognizer for a support ticket number
ticket_recognizer = PatternRecognizer(
    supported_entity="SUPPORT_TICKET",
    name="Support Ticket Recognizer",
    patterns=[
        Pattern(
            name="ticket_pattern",
            regex=r"\bTKT-\d{8}\b",
            score=0.90,
        ),
    ],
)


registry = RecognizerRegistry()
registry.load_predefined_recognizers()
registry.add_recognizer(clabe_recognizer)
registry.add_recognizer(employee_id_recognizer)
registry.add_recognizer(ticket_recognizer)

analyzer = AnalyzerEngine(registry=registry)

text = (
    "El empleado EMP-123456 reportó el ticket TKT-20240315. "
    "Su email es maria@empresa.com y su CLABE para depósito "
    "es 012345678901234567."
)

results = analyzer.analyze(text=text, language="en")

print(f"Entities with custom recognizers:\n")
for r in sorted(results, key=lambda r: r.start):
    print(f"  [{r.score:.2f}] {r.entity_type}: \"{text[r.start:r.end]}\"")

# Expected output:
#   [0.95] EMPLOYEE_ID: "EMP-123456"
#   [0.90] SUPPORT_TICKET: "TKT-20240315"
#   [1.00] EMAIL_ADDRESS: "maria@empresa.com"
#   [0.60] MX_CLABE: "012345678901234567"

PII Scanner: integrated class

Now integrate regex, Presidio, and custom recognizers into a unified PIIScanner:

import re
from dataclasses import dataclass, field
from typing import Optional
from enum import Enum

from presidio_analyzer import (
    AnalyzerEngine,
    PatternRecognizer,
    Pattern,
    RecognizerRegistry,
    RecognizerResult,
)


class PIISeverity(Enum):
    LOW = "low"
    MEDIUM = "medium"
    HIGH = "high"
    CRITICAL = "critical"


ENTITY_SEVERITY = {
    "PERSON": PIISeverity.MEDIUM,
    "EMAIL_ADDRESS": PIISeverity.MEDIUM,
    "PHONE_NUMBER": PIISeverity.MEDIUM,
    "US_SSN": PIISeverity.CRITICAL,
    "CREDIT_CARD": PIISeverity.CRITICAL,
    "IBAN_CODE": PIISeverity.HIGH,
    "IP_ADDRESS": PIISeverity.LOW,
    "LOCATION": PIISeverity.LOW,
    "DATE_TIME": PIISeverity.LOW,
    "NRP": PIISeverity.MEDIUM,
    "MEDICAL_LICENSE": PIISeverity.HIGH,
    "URL": PIISeverity.LOW,
    "EMPLOYEE_ID": PIISeverity.MEDIUM,
    "SUPPORT_TICKET": PIISeverity.LOW,
    "MX_CLABE": PIISeverity.HIGH,
}


@dataclass
class PIIEntity:
    entity_type: str
    text: str
    start: int
    end: int
    score: float
    severity: PIISeverity
    source: str


@dataclass
class PIIScanResult:
    text: str
    entities: list[PIIEntity] = field(default_factory=list)
    entity_count: int = 0
    max_severity: PIISeverity = PIISeverity.LOW
    scan_time_ms: float = 0.0

    @property
    def has_pii(self) -> bool:
        return self.entity_count > 0

    @property
    def has_critical(self) -> bool:
        return self.max_severity == PIISeverity.CRITICAL

    def get_by_type(self, entity_type: str) -> list[PIIEntity]:
        return [e for e in self.entities if e.entity_type == entity_type]

    def get_by_severity(self, severity: PIISeverity) -> list[PIIEntity]:
        return [e for e in self.entities if e.severity == severity]


class PIIScanner:
    """PII scanner with Presidio + custom recognizers."""

    SEVERITY_ORDER = {
        PIISeverity.LOW: 0,
        PIISeverity.MEDIUM: 1,
        PIISeverity.HIGH: 2,
        PIISeverity.CRITICAL: 3,
    }

    def __init__(
        self,
        languages: Optional[list[str]] = None,
        score_threshold: float = 0.5,
        custom_recognizers: Optional[list[PatternRecognizer]] = None,
    ):
        self.languages = languages or ["en"]
        self.score_threshold = score_threshold

        registry = RecognizerRegistry()
        registry.load_predefined_recognizers()

        if custom_recognizers:
            for recognizer in custom_recognizers:
                registry.add_recognizer(recognizer)

        self.analyzer = AnalyzerEngine(registry=registry)

    def scan(
        self,
        text: str,
        language: Optional[str] = None,
        entities: Optional[list[str]] = None,
    ) -> PIIScanResult:
        """Scans text for PII."""
        import time
        start = time.perf_counter()

        lang = language or self.languages[0]

        results = self.analyzer.analyze(
            text=text,
            language=lang,
            entities=entities,
            score_threshold=self.score_threshold,
        )

        pii_entities = []
        max_severity = PIISeverity.LOW

        for r in results:
            entity_text = text[r.start:r.end]
            severity = ENTITY_SEVERITY.get(
                r.entity_type, PIISeverity.MEDIUM
            )

            pii_entities.append(PIIEntity(
                entity_type=r.entity_type,
                text=entity_text,
                start=r.start,
                end=r.end,
                score=r.score,
                severity=severity,
                source="presidio",
            ))

            if self.SEVERITY_ORDER[severity] > self.SEVERITY_ORDER[max_severity]:
                max_severity = severity

        scan_time = (time.perf_counter() - start) * 1000

        return PIIScanResult(
            text=text,
            entities=sorted(pii_entities, key=lambda e: e.start),
            entity_count=len(pii_entities),
            max_severity=max_severity,
            scan_time_ms=round(scan_time, 2),
        )


# --- Demo ---

custom_recognizers = [
    PatternRecognizer(
        supported_entity="EMPLOYEE_ID",
        patterns=[Pattern("emp_id", r"\bEMP-\d{6}\b", 0.95)],
    ),
]

scanner = PIIScanner(
    languages=["en"],
    score_threshold=0.4,
    custom_recognizers=custom_recognizers,
)

test_text = (
    "John Smith (EMP-789012) can be reached at john@acme.com "
    "or 555-123-4567. His SSN is 078-05-1120."
)

result = scanner.scan(test_text)

print(f"PII Scan Results:")
print(f"  Has PII: {result.has_pii}")
print(f"  Entities: {result.entity_count}")
print(f"  Max Severity: {result.max_severity.value}")
print(f"  Scan Time: {result.scan_time_ms:.1f}ms")
print(f"\n  Detail:")
for entity in result.entities:
    print(
        f"    [{entity.severity.value}] {entity.entity_type}: "
        f"\"{entity.text}\" (score: {entity.score:.2f})"
    )

# Expected output:
# PII Scan Results:
#   Has PII: True
#   Entities: 5
#   Max Severity: critical
#   Scan Time: ~50-200ms
#
#   Detail:
#     [medium] PERSON: "John Smith" (score: 0.85)
#     [medium] EMPLOYEE_ID: "EMP-789012" (score: 0.95)
#     [medium] EMAIL_ADDRESS: "john@acme.com" (score: 1.00)
#     [medium] PHONE_NUMBER: "555-123-4567" (score: 0.75)
#     [critical] US_SSN: "078-05-1120" (score: 0.85)

Accuracy vs Recall trade-offs

PII detection has a fundamental trade-off between finding all the PII (recall) and avoiding false positives (precision).

def demonstrate_tradeoff():
    """Demonstrates the precision vs recall trade-off."""

    text = (
        "Rosa Martinez called from 555-0100 to ask about "
        "product SKU-12345 delivery to 90210. "
        "Her order total was $1,234.56."
    )

    scanner = PIIScanner(languages=["en"])

    high_threshold = scanner.scan(text)
    scanner.score_threshold = 0.3
    low_scanner = PIIScanner(languages=["en"], score_threshold=0.3)
    low_threshold = low_scanner.scan(text)

    print("High threshold (0.5) — High precision, lower recall:")
    print(f"  Entities: {high_threshold.entity_count}")
    for e in high_threshold.entities:
        print(f"    {e.entity_type}: \"{e.text}\" ({e.score:.2f})")

    print(f"\nLow threshold (0.3) — Higher recall, lower precision:")
    print(f"  Entities: {low_threshold.entity_count}")
    for e in low_threshold.entities:
        print(f"    {e.entity_type}: \"{e.text}\" ({e.score:.2f})")

    print("\nTrade-off:")
    print("  High threshold → Fewer false positives, but may miss real PII")
    print("  Low threshold → More PII detected, but more false positives")
    print("  Recommendation: 0.5 for production, 0.3 for audit/compliance")

demonstrate_tradeoff()

Calibration strategy

calibration_strategies = {
    "production_api": {
        "threshold": 0.5,
        "rationale": (
            "Balance between security and usability. "
            "False positives block legitimate requests."
        ),
        "action_on_detect": "redact_and_continue",
    },
    "compliance_audit": {
        "threshold": 0.3,
        "rationale": (
            "Maximize recall. Better to over-detect than miss something. "
            "A human reviews the results."
        ),
        "action_on_detect": "flag_for_review",
    },
    "data_pipeline": {
        "threshold": 0.7,
        "rationale": (
            "Only act on high-confidence detections. "
            "Data is processed in batch and there are other control layers."
        ),
        "action_on_detect": "log_and_redact",
    },
    "healthcare": {
        "threshold": 0.2,
        "rationale": (
            "Maximum recall — any leaked medical data is a violation. "
            "False positives are accepted as a security cost."
        ),
        "action_on_detect": "block_request",
    },
}

print("Calibration strategies by context:\n")
for context, config in calibration_strategies.items():
    print(f"  {context}:")
    print(f"    Threshold: {config['threshold']}")
    print(f"    Action: {config['action_on_detect']}")
    print(f"    Rationale: {config['rationale'][:60]}...")
    print()

Multi-language PII detection

In production, your users write in multiple languages. Presidio supports several languages, and you can combine spaCy models for multilingual coverage.

from presidio_analyzer import AnalyzerEngine
from presidio_analyzer.nlp_engine import SpacyNlpEngine, NlpEngineProvider


def create_multilang_analyzer() -> AnalyzerEngine:
    """Creates an analyzer with multilingual support."""
    configuration = {
        "nlp_engine_name": "spacy",
        "models": [
            {"lang_code": "en", "model_name": "en_core_web_lg"},
            {"lang_code": "es", "model_name": "es_core_news_md"},
        ],
    }

    provider = NlpEngineProvider(nlp_configuration=configuration)
    nlp_engine = provider.create_engine()

    return AnalyzerEngine(nlp_engine=nlp_engine)


# If you have both models installed:
# analyzer = create_multilang_analyzer()
#
# results_en = analyzer.analyze("John Smith lives in New York", language="en")
# results_es = analyzer.analyze("Juan Pérez vive en Ciudad de México", language="es")

# Fallback: language detection + scan
def detect_language_simple(text: str) -> str:
    """Simple language detection based on common characters."""
    spanish_indicators = ["ñ", "á", "é", "í", "ó", "ú", "¿", "¡"]
    spanish_count = sum(1 for c in text if c in spanish_indicators)
    return "es" if spanish_count > 0 else "en"


test_texts = [
    "Contact John at john@test.com",
    "Contacta a Juan en juan@test.com",
    "María García vive en la calle Reforma 123, CDMX",
]

for text in test_texts:
    lang = detect_language_simple(text)
    print(f"  [{lang}] \"{text[:50]}...\"")

# Expected output:
#   [en] "Contact John at john@test.com..."
#   [en] "Contacta a Juan en juan@test.com..."
#   [es] "María García vive en la calle Reforma 123, CDMX..."

Connection to the project

The PIIScanner you built in this capsule is the first component of the PII Protection Layer:

PII Protection Layer
├── PIIScanner (THIS CAPSULE)          ← Detection
├── Pre-LLM Redactor (Capsule 04)     ← Redaction before the LLM
├── Post-LLM Redactor (Capsule 04)    ← Redaction after the LLM
├── Data Minimizer (Capsule 05)       ← Minimization
├── Retention Scheduler (Capsule 06)  ← Retention
└── Audit Logger                      ← Logging

In the project (capsule 08), the PIIScanner integrates at two points in the pipeline:

  1. Pre-LLM: Scans the user input and the RAG context before sending it to the model
  2. Post-LLM: Scans the model output before sending it to the user

Troubleshooting

Problem 1: "Presidio doesn't detect names in Spanish"

spaCy needs the Spanish model (es_core_news_md) for NER in Spanish. Without the correct model, Presidio uses only regex and misses contextual entities.

Solution:

python -m spacy download es_core_news_md

And configure Presidio with multilingual support as shown in the multi-language section.

Problem 2: "The PII scan is slow (>500ms)"

Presidio loads the spaCy model on every call if the engine isn't cached.

Solution: Create the AnalyzerEngine once and reuse it. The first scan will be slow (model loading), but the following ones will be ~50-200ms.

analyzer = AnalyzerEngine()

for text in texts:
    result = analyzer.analyze(text=text, language="en")

Problem 3: "Too many false positives with numbers"

Presidio can detect phone numbers in sequences that are product IDs, postal codes, or order numbers.

Solution: Use the entities parameter to limit which types of PII you look for. If your application doesn't process phones, exclude them:

results = analyzer.analyze(
    text=text,
    language="en",
    entities=["PERSON", "EMAIL_ADDRESS", "US_SSN", "CREDIT_CARD"],
)

Problem 4: "I need to detect PII that Presidio doesn't support"

Your domain may have specific types of PII (patient numbers, contract IDs, etc.).

Solution: Create a custom PatternRecognizer as shown in the custom recognizers section. Combine regex with context words to improve precision.


Exercises

Exercise 1: Custom recognizer for Mexican RFC

Create a Presidio recognizer that detects Mexican RFCs (format: 4 letters + 6 digits + 3 alphanumeric).

See solution
from presidio_analyzer import PatternRecognizer, Pattern

rfc_recognizer = PatternRecognizer(
    supported_entity="MX_RFC",
    name="Mexican RFC Recognizer",
    patterns=[
        Pattern(
            name="rfc_persona_moral",
            regex=r"\b[A-ZÑ&]{3}\d{6}[A-Z0-9]{3}\b",
            score=0.7,
        ),
        Pattern(
            name="rfc_persona_fisica",
            regex=r"\b[A-ZÑ&]{4}\d{6}[A-Z0-9]{3}\b",
            score=0.8,
        ),
    ],
    context=["rfc", "fiscal", "contribuyente", "sat", "factura"],
    supported_language="es",
)

from presidio_analyzer import AnalyzerEngine, RecognizerRegistry

registry = RecognizerRegistry()
registry.load_predefined_recognizers()
registry.add_recognizer(rfc_recognizer)

analyzer = AnalyzerEngine(registry=registry)

test = "El RFC del contribuyente es GAPA850101ABC"
results = analyzer.analyze(text=test, language="en")

for r in results:
    print(f"  [{r.score:.2f}] {r.entity_type}: \"{test[r.start:r.end]}\"")

# Expected output:
#   [0.80] MX_RFC: "GAPA850101ABC"

Exercise 2: Scanner with a statistical report

Extend the PIIScanner to generate a statistical report of the PII types found.

See solution
from collections import Counter


def generate_pii_report(scan_results: list[PIIScanResult]) -> dict:
    """Generates a statistical report from multiple scans."""
    total_entities = 0
    entity_types = Counter()
    severity_counts = Counter()
    texts_with_pii = 0
    texts_with_critical = 0

    for result in scan_results:
        total_entities += result.entity_count
        if result.has_pii:
            texts_with_pii += 1
        if result.has_critical:
            texts_with_critical += 1
        for entity in result.entities:
            entity_types[entity.entity_type] += 1
            severity_counts[entity.severity.value] += 1

    return {
        "total_texts_scanned": len(scan_results),
        "texts_with_pii": texts_with_pii,
        "texts_with_critical_pii": texts_with_critical,
        "pii_rate": texts_with_pii / len(scan_results) if scan_results else 0,
        "total_entities": total_entities,
        "entity_types": dict(entity_types.most_common()),
        "severity_distribution": dict(severity_counts),
    }


scanner = PIIScanner(languages=["en"], score_threshold=0.4)
texts = [
    "Call John at 555-1234",
    "Email: test@example.com, SSN: 123-45-6789",
    "The weather is nice today",
    "Contact María García at maria@test.com",
]

results = [scanner.scan(t) for t in texts]
report = generate_pii_report(results)

print("PII Report:")
for key, value in report.items():
    print(f"  {key}: {value}")

# Expected output:
# PII Report:
#   total_texts_scanned: 4
#   texts_with_pii: 3
#   texts_with_critical_pii: 1
#   pii_rate: 0.75
#   total_entities: 7
#   entity_types: {'EMAIL_ADDRESS': 2, 'PHONE_NUMBER': 1, 'PERSON': 2, 'US_SSN': 1, ...}
#   severity_distribution: {'medium': 5, 'critical': 1, 'low': 1}

Exercise 3: PII detector for RAG documents

Create a function that scans a list of RAG chunks and reports which ones contain PII.

See solution
@dataclass
class RAGChunk:
    chunk_id: str
    content: str
    source: str
    metadata: dict = field(default_factory=dict)


def scan_rag_chunks(
    chunks: list[RAGChunk],
    scanner: PIIScanner,
) -> dict:
    """Scans RAG chunks for PII."""
    flagged_chunks = []
    clean_chunks = []

    for chunk in chunks:
        result = scanner.scan(chunk.content)
        if result.has_pii:
            flagged_chunks.append({
                "chunk_id": chunk.chunk_id,
                "source": chunk.source,
                "entity_count": result.entity_count,
                "max_severity": result.max_severity.value,
                "entities": [
                    {"type": e.entity_type, "text": e.text[:20]}
                    for e in result.entities
                ],
            })
        else:
            clean_chunks.append(chunk.chunk_id)

    return {
        "total_chunks": len(chunks),
        "flagged": len(flagged_chunks),
        "clean": len(clean_chunks),
        "flagged_details": flagged_chunks,
    }


chunks = [
    RAGChunk("c1", "The product costs $49.99 and ships in 2 days", "products.pdf"),
    RAGChunk("c2", "Contact John Smith at john@acme.com for support", "contacts.pdf"),
    RAGChunk("c3", "Employee SSN: 123-45-6789, hired 2024-01-15", "hr.pdf"),
]

scanner = PIIScanner(languages=["en"], score_threshold=0.4)
report = scan_rag_chunks(chunks, scanner)

print(f"RAG PII Scan: {report['flagged']}/{report['total_chunks']} chunks flagged")
for detail in report['flagged_details']:
    print(f"  Chunk {detail['chunk_id']} ({detail['source']}): "
          f"{detail['entity_count']} entities, severity: {detail['max_severity']}")

Exercise 4: Regex vs Presidio comparator

Create a function that compares the PII detection results between pure regex and Presidio for the same text.

See solution
def compare_detection_methods(text: str) -> dict:
    """Compares regex vs Presidio for PII detection."""
    regex_detector = RegexPIIDetector()
    regex_results = regex_detector.detect(text)

    scanner = PIIScanner(languages=["en"], score_threshold=0.4)
    presidio_results = scanner.scan(text)

    regex_types = {m.entity_type for m in regex_results}
    presidio_types = {e.entity_type for e in presidio_results.entities}

    only_regex = regex_types - presidio_types
    only_presidio = presidio_types - regex_types
    both = regex_types & presidio_types

    return {
        "text_preview": text[:60],
        "regex_count": len(regex_results),
        "presidio_count": presidio_results.entity_count,
        "found_by_both": list(both),
        "only_regex": list(only_regex),
        "only_presidio": list(only_presidio),
        "recommendation": (
            "Presidio detects more types (names, locations) but "
            "regex is faster for structured patterns"
        ),
    }


text = (
    "María García (maria@test.com, 555-123-4567) "
    "lives in New York. SSN: 123-45-6789."
)

comparison = compare_detection_methods(text)
print(f"Regex: {comparison['regex_count']} entities")
print(f"Presidio: {comparison['presidio_count']} entities")
print(f"Only Presidio: {comparison['only_presidio']}")
print(f"Only Regex: {comparison['only_regex']}")

Summary

  • 🔑 PII includes more than 30 types of data: from direct identifiers (SSN, passport) to quasi-identifiers (date of birth, postal code) that combined can identify people
  • 🔑 Regex is ideal for structured patterns (emails, phones, SSN, cards) but can't detect contextual entities like names or addresses
  • 🔑 Microsoft Presidio combines regex + spaCy NER + configurable recognizers in a unified enterprise-grade engine — it's the core of the PII Scanner
  • 🔑 spaCy NER provides contextual detection of people, organizations, and places — it's what lets you detect "María García" as PERSON
  • 🔑 Presidio's custom recognizers let you detect PII specific to your domain (account numbers, employee IDs, proprietary formats)
  • 🔑 The precision vs recall trade-off is calibrated with the score_threshold: 0.5 for production, 0.3 for compliance, 0.7 for batch pipelines
  • 🔑 Multilingual detection requires spaCy models per language — without the correct model, Presidio misses contextual entities
  • 🔑 The PIIScanner integrates everything and produces a PIIScanResult with entities, severities, and performance metrics

Additional resources

  1. Microsoft Presidio Documentation — Complete official Presidio documentation
  2. Presidio Supported Entities — List of entities supported by default
  3. Presidio Custom Recognizers — Custom recognizers tutorial
  4. spaCy NER Models — NER models available by language
  5. NIST PII Guide (SP 800-122) — NIST's guide for PII protection
  6. Regular Expressions for PII Detection — A collection of regex for common PII patterns
  7. Presidio Analyzer API Reference — Presidio Analyzer API reference
  8. spaCy Named Entity Recognition — NER guide with spaCy

Created: March 2026 Version: 1.0