Módulo 6: Data Privacy & PII Protection

3. PII Detection: Patrones, Regex y Presidio

Descripción

En la cápsula anterior entendiste los mecanismos de LLM02 — cómo los LLMs memorizan y filtran datos sensibles. Ahora necesitas la herramienta para detectar esos datos antes de que lleguen al modelo o después de que salgan. La detección de PII (Personally Identifiable Information) es la primera pieza operativa del PII Protection Layer.

Detectar PII es más difícil de lo que parece. Un regex para emails funciona bien para el formato estándar, pero falla con variantes como "juan [at] empresa [dot] com". Un regex para nombres es prácticamente imposible — ¿"Rosa" es un nombre o un color? ¿"Santiago" es una persona o una ciudad? Para resolver esto, necesitas combinar tres enfoques: regex para patrones estructurados (emails, teléfonos, SSN), Named Entity Recognition (NER) con modelos de lenguaje para entidades contextuales (nombres, organizaciones), y reconocedores custom para datos específicos de tu dominio (números de cuenta, IDs internos).

En esta cápsula construyes un PII Scanner completo usando Microsoft Presidio como motor principal, con extensiones de regex y reconocedores custom. Este componente se reutiliza directamente en el proyecto de la cápsula 08.


¿Qué cuenta como PII?

PII es cualquier información que puede usarse para identificar a una persona, directa o indirectamente. La lista es más larga de lo que la mayoría de developers asume:

pii_categories = {
    "Direct Identifiers": {
        "description": "Identifican a una persona directamente",
        "examples": [
            "Nombre completo",
            "Número de Seguro Social (SSN)",
            "Número de pasaporte",
            "Número de licencia de conducir",
            "Datos biométricos (huella, facial)",
        ],
        "risk": "Crítico — identificación directa",
    },
    "Contact Information": {
        "description": "Permiten contactar a la persona",
        "examples": [
            "Email",
            "Teléfono",
            "Dirección física",
            "Dirección IP",
        ],
        "risk": "Alto — contacto directo + geo-localización",
    },
    "Financial Data": {
        "description": "Datos financieros personales",
        "examples": [
            "Número de tarjeta de crédito",
            "Número de cuenta bancaria",
            "IBAN/CLABE",
            "Información tributaria",
        ],
        "risk": "Crítico — fraude financiero",
    },
    "Health Data": {
        "description": "Información médica personal",
        "examples": [
            "Diagnósticos",
            "Medicaciones",
            "Número de seguro médico",
            "Historial clínico",
        ],
        "risk": "Crítico — regulado por HIPAA/GDPR",
    },
    "Quasi-identifiers": {
        "description": "Combinados pueden identificar a una persona",
        "examples": [
            "Fecha de nacimiento",
            "Código postal",
            "Género",
            "Ocupación",
            "Nacionalidad",
        ],
        "risk": "Medio — re-identificación por combinación",
    },
}

print("Categorías de PII:\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()

Detección con regex: patrones estructurados

Para PII con formato predecible (emails, teléfonos, SSN, tarjetas), regex es la herramienta más directa y rápida.

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:
    """Detector de PII basado en expresiones regulares."""
    
    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]:
        """Detecta PII en el texto usando 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)


# --- Demostración ---

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"Texto: \"{test_text[:60]}...\"\n")
print(f"PII encontrado: {len(matches)} entidades\n")
for m in matches:
    print(f"  [{m.score:.2f}] {m.entity_type}: \"{m.text}\" (pos {m.start}-{m.end})")

# Output esperado:
# PII encontrado: 5 entidades
#
#   [0.95] EMAIL: "maria.garcia@empresa.com" (pos 35-59)
#   [0.85] PHONE_US: "555-123-4567" (pos 65-77)
#   [0.99] SSN: "123-45-6789" (pos 89-100)
#   [0.90] CREDIT_CARD: "4111-1111-1111-1111" (pos 126-145)
#   [0.60] DATE_OF_BIRTH: "03/15/1990" (pos 156-166)

Limitaciones del regex

regex_limitations = [
    {
        "limitation": "No detecta nombres de personas",
        "example": "'María García' — no hay patrón regex para nombres",
        "solution": "Usar NER (spaCy, Presidio)",
    },
    {
        "limitation": "No entiende contexto",
        "example": "'555-123-4567' puede ser un teléfono o un código de producto",
        "solution": "Combinar con análisis de contexto",
    },
    {
        "limitation": "Falsos positivos con formatos similares",
        "example": "'192.168.1.1' es IP interna, no necesariamente PII",
        "solution": "Clasificar por contexto de uso",
    },
    {
        "limitation": "No maneja variantes de formato",
        "example": "'juan [at] empresa [dot] com' evade regex de email",
        "solution": "Normalizar texto antes de aplicar regex",
    },
]

print("Limitaciones del regex para PII detection:\n")
for lim in regex_limitations:
    print(f"  ❌ {lim['limitation']}")
    print(f"     Ejemplo: {lim['example']}")
    print(f"     Solución: {lim['solution']}")
    print()

Microsoft Presidio: detección enterprise-grade

Presidio es la librería open-source de Microsoft para detección y anonimización de PII. Combina regex, NER con spaCy, y reconocedores configurables en un engine unificado.

Instalación y setup

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

Uso básico

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"Texto analizado ({len(text)} chars)")
print(f"Entidades encontradas: {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})"
    )

# Output esperado:
# Texto analizado (112 chars)
# Entidades encontradas: 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)

Configuración de entidades

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"Solo PII específico: {len(only_pii)} entidades")
print(f"Todas las entidades: {len(all_entities)} entidades")
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}\"")

# Output esperado (puede variar según modelo spaCy):
# Solo PII específico: 3 entidades
# Todas las entidades: 5+ entidades

Confidence scores y 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)} entidades (alta confianza)")
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)} entidades (baja confianza)")
for r in low_confidence:
    print(f"  [{r.score:.2f}] {r.entity_type}: \"{text[r.start:r.end]}\"")

# Output esperado:
# Threshold 0.8: 1-2 entidades (alta confianza)
#   [1.00] EMAIL_ADDRESS: "test@example.com"
#
# Threshold 0.3: 3+ entidades (baja confianza)
#   [1.00] EMAIL_ADDRESS: "test@example.com"
#   [0.75] PHONE_NUMBER: "555-0100"
#   [0.40] PERSON: "Robert"

spaCy NER para PII contextual

spaCy proporciona Named Entity Recognition — la capacidad de identificar personas, organizaciones, y lugares en texto libre. Presidio usa spaCy internamente, pero puedes usarlo directamente para más 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"Entidades NER encontradas:\n")
for ent in doc.ents:
    print(f"  [{ent.label_}] \"{ent.text}\" (pos {ent.start_char}-{ent.end_char})")

# Output esperado:
# Entidades NER encontradas:
#
#   [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 en español con 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"Entidades NER en español:\n")
for ent in doc.ents:
    print(f"  [{ent.label_}] \"{ent.text}\"")

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

Mapeo de entidades spaCy a 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]:
    """Convierte entidades spaCy a formato PII estándar."""
    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']})")

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

Reconocedores custom en Presidio

Presidio permite agregar reconocedores custom para PII específico de tu dominio — números de cuenta, IDs internos, formatos de datos propietarios.

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


# Reconocedor custom para número de cuenta bancaria mexicano (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",
)


# Reconocedor para número de empleado interno
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"],
)


# Reconocedor para número de ticket de soporte
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"Entidades con reconocedores custom:\n")
for r in sorted(results, key=lambda r: r.start):
    print(f"  [{r.score:.2f}] {r.entity_type}: \"{text[r.start:r.end]}\"")

# Output esperado:
#   [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: clase integrada

Ahora integra regex, Presidio y reconocedores custom en un PIIScanner unificado:

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:
    """Scanner de PII con Presidio + reconocedores custom."""

    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:
        """Escanea texto para 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),
        )


# --- Demostración ---

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  Detalle:")
for entity in result.entities:
    print(
        f"    [{entity.severity.value}] {entity.entity_type}: "
        f"\"{entity.text}\" (score: {entity.score:.2f})"
    )

# Output esperado:
# PII Scan Results:
#   Has PII: True
#   Entities: 5
#   Max Severity: critical
#   Scan Time: ~50-200ms
#
#   Detalle:
#     [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

La detección de PII tiene un trade-off fundamental entre encontrar todo el PII (recall) y evitar falsos positivos (precision).

def demonstrate_tradeoff():
    """Demuestra el trade-off precision vs recall."""
    
    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("Threshold alto (0.5) — Alta precisión, menor recall:")
    print(f"  Entidades: {high_threshold.entity_count}")
    for e in high_threshold.entities:
        print(f"    {e.entity_type}: \"{e.text}\" ({e.score:.2f})")
    
    print(f"\nThreshold bajo (0.3) — Mayor recall, menor precisión:")
    print(f"  Entidades: {low_threshold.entity_count}")
    for e in low_threshold.entities:
        print(f"    {e.entity_type}: \"{e.text}\" ({e.score:.2f})")
    
    print("\nTrade-off:")
    print("  Threshold alto → Menos falsos positivos, pero puede perder PII real")
    print("  Threshold bajo → Más PII detectado, pero más falsos positivos")
    print("  Recomendación: 0.5 para producción, 0.3 para audit/compliance")

demonstrate_tradeoff()

Estrategia de calibración

calibration_strategies = {
    "production_api": {
        "threshold": 0.5,
        "rationale": (
            "Equilibrio entre seguridad y usabilidad. "
            "Los falsos positivos bloquean requests legítimas."
        ),
        "action_on_detect": "redact_and_continue",
    },
    "compliance_audit": {
        "threshold": 0.3,
        "rationale": (
            "Maximizar recall. Mejor detectar de más que perder algo. "
            "Un humano revisa los resultados."
        ),
        "action_on_detect": "flag_for_review",
    },
    "data_pipeline": {
        "threshold": 0.7,
        "rationale": (
            "Solo actuar en detecciones de alta confianza. "
            "Los datos se procesan en batch y hay otras capas de control."
        ),
        "action_on_detect": "log_and_redact",
    },
    "healthcare": {
        "threshold": 0.2,
        "rationale": (
            "Máximo recall — cualquier dato médico filtrado es una violación. "
            "Los falsos positivos se aceptan como costo de seguridad."
        ),
        "action_on_detect": "block_request",
    },
}

print("Estrategias de calibración por contexto:\n")
for context, config in calibration_strategies.items():
    print(f"  {context}:")
    print(f"    Threshold: {config['threshold']}")
    print(f"    Acción: {config['action_on_detect']}")
    print(f"    Razón: {config['rationale'][:60]}...")
    print()

Multi-language PII detection

En producción, tus usuarios escriben en múltiples idiomas. Presidio soporta varios idiomas, y puedes combinar modelos de spaCy para cobertura multilingüe.

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


def create_multilang_analyzer() -> AnalyzerEngine:
    """Crea un analyzer con soporte multilingüe."""
    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)


# Si tienes ambos modelos instalados:
# 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: detección de idioma + scan
def detect_language_simple(text: str) -> str:
    """Detección simple de idioma basada en caracteres comunes."""
    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]}...\"")

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

Conexión con el proyecto

El PIIScanner que construiste en esta cápsula es el primer componente del PII Protection Layer:

PII Protection Layer
├── PIIScanner (ESTA CÁPSULA)         ← Detección
├── Pre-LLM Redactor (Cápsula 04)    ← Redacción antes del LLM
├── Post-LLM Redactor (Cápsula 04)   ← Redacción después del LLM
├── Data Minimizer (Cápsula 05)      ← Minimización
├── Retention Scheduler (Cápsula 06) ← Retención
└── Audit Logger                     ← Logging

En el proyecto (cápsula 08), el PIIScanner se integra en dos puntos del pipeline:

  1. Pre-LLM: Escanea el input del usuario y el contexto RAG antes de enviarlo al modelo
  2. Post-LLM: Escanea el output del modelo antes de enviarlo al usuario

Troubleshooting

Problema 1: "Presidio no detecta nombres en español"

spaCy necesita el modelo de español (es_core_news_md) para NER en español. Sin el modelo correcto, Presidio usa solo regex y pierde entidades contextuales.

Solución:

python -m spacy download es_core_news_md

Y configura Presidio con soporte multilingüe como se muestra en la sección de multi-language.

Problema 2: "El scan de PII es lento (>500ms)"

Presidio carga el modelo de spaCy en cada llamada si no se cachea el engine.

Solución: Crea el AnalyzerEngine una sola vez y reutilízalo. El primer scan será lento (carga del modelo), pero los siguientes serán ~50-200ms.

analyzer = AnalyzerEngine()

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

Problema 3: "Demasiados falsos positivos con números"

Presidio puede detectar números de teléfono en secuencias que son IDs de producto, códigos postales, o números de orden.

Solución: Usa el parámetro entities para limitar qué tipos de PII buscas. Si tu aplicación no procesa teléfonos, exclúyelos:

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

Problema 4: "Necesito detectar PII que Presidio no soporta"

Tu dominio puede tener tipos de PII específicos (números de paciente, IDs de contrato, etc.).

Solución: Crea un PatternRecognizer custom como se muestra en la sección de reconocedores custom. Combina regex con palabras de contexto para mejorar la precisión.


Ejercicios

Ejercicio 1: Reconocedor custom para RFC mexicano

Crea un reconocedor de Presidio que detecte RFCs mexicanos (formato: 4 letras + 6 dígitos + 3 alfanuméricos).

Ver solución
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]}\"")

# Output esperado:
#   [0.80] MX_RFC: "GAPA850101ABC"

Ejercicio 2: Scanner con reporte estadístico

Extiende el PIIScanner para generar un reporte estadístico de los tipos de PII encontrados.

Ver solución
from collections import Counter


def generate_pii_report(scan_results: list[PIIScanResult]) -> dict:
    """Genera un reporte estadístico de múltiples 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}")

# Output esperado:
# 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}

Ejercicio 3: Detector de PII en documentos RAG

Crea una función que escanee una lista de chunks de RAG y reporte cuáles contienen PII.

Ver solución
@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:
    """Escanea chunks de RAG para 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']}")

Ejercicio 4: Comparador regex vs Presidio

Crea una función que compare los resultados de detección de PII entre regex puro y Presidio para el mismo texto.

Ver solución
def compare_detection_methods(text: str) -> dict:
    """Compara regex vs Presidio para detección de PII."""
    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 detecta más tipos (nombres, locations) pero "
            "regex es más rápido para patrones estructurados"
        ),
    }


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']} entidades")
print(f"Presidio: {comparison['presidio_count']} entidades")
print(f"Solo Presidio: {comparison['only_presidio']}")
print(f"Solo Regex: {comparison['only_regex']}")

Resumen

  • 🔑 PII incluye más de 30 tipos de datos: desde identificadores directos (SSN, pasaporte) hasta quasi-identifiers (fecha de nacimiento, código postal) que combinados pueden identificar personas
  • 🔑 Regex es ideal para patrones estructurados (emails, teléfonos, SSN, tarjetas) pero no puede detectar entidades contextuales como nombres o direcciones
  • 🔑 Microsoft Presidio combina regex + spaCy NER + reconocedores configurables en un engine unificado enterprise-grade — es el core del PII Scanner
  • 🔑 spaCy NER proporciona detección contextual de personas, organizaciones, y lugares — es lo que permite detectar "María García" como PERSON
  • 🔑 Los reconocedores custom de Presidio permiten detectar PII específico de tu dominio (números de cuenta, IDs de empleado, formatos propietarios)
  • 🔑 El trade-off precision vs recall se calibra con el score_threshold: 0.5 para producción, 0.3 para compliance, 0.7 para batch pipelines
  • 🔑 La detección multilingüe requiere modelos de spaCy por idioma — sin el modelo correcto, Presidio pierde entidades contextuales
  • 🔑 El PIIScanner integra todo y produce un PIIScanResult con entidades, severidades, y métricas de performance

Recursos adicionales

  1. Microsoft Presidio Documentation — Documentación oficial completa de Presidio
  2. Presidio Supported Entities — Lista de entidades soportadas por defecto
  3. Presidio Custom Recognizers — Tutorial de reconocedores custom
  4. spaCy NER Models — Modelos de NER disponibles por idioma
  5. NIST PII Guide (SP 800-122) — Guía del NIST para protección de PII
  6. Regular Expressions for PII Detection — Colección de regex para patrones comunes de PII
  7. Presidio Analyzer API Reference — Referencia de la API de Presidio Analyzer
  8. spaCy Named Entity Recognition — Guía de NER con spaCy

Creado: Marzo 2026 Versión: 1.0