第 8 章 篇一:预训练数据工程

PII 识别与脱敏

个人可识别信息的识别、标注与脱敏/替换策略。

PII 脱敏 隐私

隐私脱敏是合规底线,文档打标是配比的基础。

这两件事不做,模型无法上线;做不好,配比无法精细。


8.1 PII 类目全景

PII(Personally Identifiable Information,个人身份信息)是预训练数据里必须处理的内容。未经脱敏训练大模型会带来严重的法律、伦理风险。

8.1.1 PII 类目分类

类目例子风险等级必处理
邮箱user@gmail.com高必脱敏
电话+86 138-xxxx-xxxx高必脱敏
身份证号110101199001011234极高必脱敏
护照号E12345678极高必脱敏
银行卡号622202xxxxxxxxxxxx极高必脱敏
信用卡号4532-xxxx-xxxx-xxxx极高必脱敏
IP 地址192.168.1.1中推荐脱敏
API Keysk-xxxxxxxxxx高必脱敏
AWS KeyAKIA…高必脱敏
姓名地址张三 / 北京市海淀区…高视场景
医疗信息糖尿病、HIV极高必脱敏
种族宗教维吾尔族 / 穆斯林高视场景
生物特征指纹 / 虹膜 / 人脸极高必脱敏

8.1.2 法律合规要求

法规适用地区PII 要求
GDPR欧盟任何个人信息必须脱敏或征得同意
CCPA加州用户有”被遗忘权”,PII 需可删除
《个人信息保护法》中国敏感个人信息必须脱敏
HIPAA美国(医疗)医疗信息严格保护
《数据安全法》中国重要数据需安全评估

8.2 双路检测:正则 + NER + 密钥扫描

PII 检测推荐双路并行:正则匹配(高精度低召回)+ NER 模型(高召回低精度)+ 密钥扫描(特定模式)。

8.2.1 正则匹配

import re

PII_REGEX_PATTERNS = {
    'email': re.compile(r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b'),
    
    'phone_cn': re.compile(r'(?:(?:\+?86)?\s*)?1[3-9]\d{9}'),
    'phone_intl': re.compile(r'\+\d{1,3}[\s-]?\d{1,4}[\s-]?\d{3,4}[\s-]?\d{3,4}'),
    
    'id_card_cn': re.compile(
        r'\b[1-9]\d{5}(?:19|20)\d{2}'
        r'(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])\d{3}[\dXx]\b'
    ),
    
    'bank_card_cn': re.compile(r'\b6(?:2(?:02|21|25|26|28|29|30|31|32|33|34|35|36|37|38|39|40))\d{14,17}\b'),
    
    'passport_cn': re.compile(r'\b[EeDdGgPpHh]\d{8}\b'),
    
    'ipv4': re.compile(r'\b(?:\d{1,3}\.){3}\d{1,3}\b'),
    'ipv6': re.compile(r'(?:[A-Fa-f0-9]{1,4}:){7}[A-Fa-f0-9]{1,4}'),
    
    # API Keys
    'aws_access_key': re.compile(r'\bAKIA[0-9A-Z]{16}\b'),
    'aws_secret_key': re.compile(r'(?i)aws_secret_access_key\s*=\s*[A-Za-z0-9/+=]{40}'),
    'openai_key': re.compile(r'\bsk-[A-Za-z0-9]{20,}\b'),
    'github_token': re.compile(r'\bgh[pousr]_[A-Za-z0-9]{36,}\b'),
    'google_api_key': re.compile(r'\bAIza[A-Za-z0-9_\-]{35}\b'),
    
    # 信用卡号
    'visa': re.compile(r'\b4\d{3}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b'),
    'mastercard': re.compile(r'\b5[1-5]\d{2}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b'),
}

def detect_pii_regex(text):
    """正则匹配 PII"""
    matches = []
    for pii_type, pattern in PII_REGEX_PATTERNS.items():
        for match in pattern.finditer(text):
            matches.append({
                'type': pii_type,
                'text': match.group(),
                'start': match.start(),
                'end': match.end()
            })
    return matches

8.2.2 NER 模型识别

正则只能抓固定格式,对自然语言中的姓名、地址、机构等需要 NER:

from transformers import AutoTokenizer, AutoModelForTokenClassification
from transformers import pipeline

# 多语种 NER 模型
MODEL_NAME = 'Babelscape/wikineural-multilingual-ner'

tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
model = AutoModelForTokenClassification.from_pretrained(MODEL_NAME)

ner_pipeline = pipeline(
    'ner', model=model, tokenizer=tokenizer,
    aggregation_strategy='simple',
    device='cuda:0'
)

def detect_pii_ner(text):
    """NER 识别 PII"""
    entities = ner_pipeline(text[:4096])  # 截断保护
    
    pii_entities = []
    PII_TYPES = {'PER', 'ORG', 'LOC'}  # 人名、机构、地名
    
    for ent in entities:
        if ent['entity_group'] in PII_TYPES:
            pii_entities.append({
                'type': ent['entity_group'].lower(),
                'text': ent['word'],
                'start': ent['start'],
                'end': ent['end'],
                'confidence': ent['score']
            })
    
    return pii_entities

8.2.3 密钥扫描

专门的密钥扫描工具能识别 100+ 种 API Key 格式:

# 推荐 truffleHog 或 detect-secrets
# 命令行用法:
# trufflehog filesystem --directory=./data --json --output=secrets.json

# 或用 Python 库
# pip install detect-secrets
from detect_secrets import SecretsCollection
from detect_secrets.core import scan

def detect_secrets_in_text(text):
    """扫描 API Keys/密钥"""
    secrets = []
    for line in text.split('\n'):
        detected = scan.scan_line(line)
        for item in detected:
            secrets.append({
                'type': item.type,
                'text': item.secret_value,
                'line': line
            })
    return secrets

8.2.4 双路融合

def detect_pii(text):
    """
    双路 PII 检测
    返回所有 PII 实体
    """
    # 路径1: 正则
    regex_matches = detect_pii_regex(text)
    
    # 路径2: NER
    ner_entities = detect_pii_ner(text)
    
    # 路径3: 密钥扫描
    secret_matches = detect_secrets_in_text(text)
    
    # 合并去重(同位置范围内)
    all_pii = regex_matches + ner_entities + secret_matches
    all_pii = dedup_overlapping(all_pii)
    
    return all_pii

8.2.5 三层防御架构(2026 生产级方案)

2026 年生产系统中,PII 脱敏已演进为三层级联架构:

层级方法召回率延迟角色
第1层正则+校验和(Checksum)~92%<1ms/doc高精度快速过滤
第2层DeBERTa-v3 NER95-97%~10ms/doc高召回语义识别
第3层LLM-Judge (gpt-4o-mini/Claude Haiku)~99.4%(合并)~500ms/span歧义消解+跨句共指

三层合并后总体指标:召回 99.4%、误报 1.7%。第3层 LLM-judge 处理前两层都无法确定的歧义 span(如”张主任”需上下文判定是否为姓名)和跨句共指(如”他”指代上文出现的姓名)。

⚠️ 上表数字是工程经验值,不是公开基准实测值,不可当作可交付 SLA。

本章未给出这三个数字的评测集、语种与实体类型构成。目前覆盖面最广的公开多语言 PII 基准 REDACT(arXiv:2606.19881,ServiceNow,2026-06-18)在 13,427 条记录 / 324,078 个实体标注 / 51 类实体 / 4,127 种表层形式 / 25 语种 / 9 种书写系统上,对锁定语言分层的 1,000 条样本评测五个检测器,实测水平显著更低:

检测器Partial micro-F1Macro F1Exact micro-F1
Claude Sonnet 4.60.6360.6190.602
GPT-4.10.5970.5650.558
OpenAI Privacy Filter0.5120.2540.171
GLiNER0.3200.2240.293
Presidio(规则型)0.1950.0630.145

怎么读这张对照表(三点口径限定,缺一不可):

  1. 第1层的 ~92% 只在”带校验位的结构化 PII”上成立——身份证、银行卡、手机号这类有校验和可供验证的实体,正则的精度确实远高于通用实体识别。REDACT 里 Presidio 测的是通用实体(姓名、地址、组织等无校验位类型),两者不是同一口径。本章原文未作此限定,读者容易按通用 PII 理解,这是本表最需要补的一句话。
  2. 第3层的 99.4% 在真实多语种场景下没有公开基准支持。REDACT 里最强的前沿 LLM(Claude Sonnet 4.6)exact micro-F1 也只有 0.602;而本章第3层用的是 gpt-4o-mini / Claude Haiku 这类轻量模型,能力档位更低。
  3. F1 口径要看清楚:partial(部分重叠即算命中)比 exact(完全重叠才算)宽松得多。报召回/精度时必须声明是哪种,否则 0.636 与 0.602 这类差异会被误当成噪声。

8.2.5b 架构选型的第二根轴:吞吐

上一节的三层表给的是单条延迟(<1ms/doc、~10ms/doc、~500ms/span)。延迟列不等于吞吐列,而”这套架构能不能全量跑”由吞吐决定,不由单条延迟决定。

先把第3层的全量成本算出来:表里的 ~500ms/span 是每个 span 的开销。一篇文档若有 N 个候选 span,第3层的实际开销就是 N × 500ms。§8.2.5 说第3层处理”歧义 span + 跨句共指”,但从未给出候选率——读者因此无法判断第3层是在处理 1% 的 span,还是在处理 100% 的 span。这是整条流水线里唯一一个成本不可估的环节,也是唯一一个可能让它跑不起来的环节。

再补一手吞吐量级(arXiv:2609.38630《Strong Multilingual Privacy Tagging at Encoder Speed》,Jonathan Graehl,2026-09-29)。该工作在多语言 encoder(XLM-R 系)上加 affine span-tagging head,用前沿模型在 35 种语言上的标注做微调,关键技术点是 mapped human gold replay + coverage-aware masking(避免把未标注类型当成负例)与 ±1 字符边界修正;在 1,283 条 human-gold 测试段 / 7 种语言上的 redaction F1 如下:

系统redaction F1
本文微调 encoder88.8
GLiNER2(已发表版,豁免 11 类不可表示类型)69.1(不豁免为 68.8)
GLiNER2(适配本文训练数据)67.8
Microsoft Presidio57.3
OpenAI Privacy Filter 最佳已发表微调版35.8

成本侧的三条数字才是本节要的:该 encoder 架构的 CPU 吞吐是 GLiNER2 的 4.9 倍;在单张 96GB GPU 上跑本地 LLM,即便只当冻结 encoder 用,编码也比 XLM-R 推理慢 30–95 倍;让它做 prompted annotator 则慢 180–1,100 倍。

⚠️ 口径警示:本表与 §8.2.5 的 REDACT 表不可比大小。 REDACT 测的是 span 定位精度(partial / exact micro-F1,评测对象为锁定语言分层的 1,000 条样本);2609.38630 测的是 脱敏决策正确性(redaction F1,评测对象为 1,283 条七语种 human-gold 段)。两套指标、两套样本、两种判定口径——“Presidio 0.195”与”Presidio 57.3”不是同一件事。本章凡并列两表,必须写全指标名。

两条轴合起来的选型结论:精度侧,REDACT 显示最强前沿 LLM 的 exact micro-F1 也只有 0.602,而本章第3层用的是更低档位的轻量模型;成本侧,2609.38630 显示 prompted LLM annotator 比 encoder 慢两个数量级。因此第3层的默认选项应当是”微调 encoder”,LLM-judge 退居”encoder 也判不了的长尾歧义”。 这与 §8.2.5 原来的”regex → NER → LLM”不是同一套排序逻辑:原表按能力递增排,本节的结论按单位成本的有效性排。原架构不必推翻,但必须按后一种逻辑写出来,否则第3层全量落地时不可执行。

8.2.6 为什么高召回不等于低风险:敏感度分层

上一节的三层架构按检测方法(regex → NER → LLM)分层,隐含假设是”层层叠加 → 召回单调上升 → 风险单调下降”。REDACT 用 32 万条标注给出了一条反直觉的实证:漏检不是均匀分布的。

该基准为每条实体标注了 GDPR 对齐的敏感度分级,分层评估后发现:

“Aggregate F1 masks an architecture-dependent failure structure: the rule-based detector performs poorly on the highest-stakes data, including HIGH-sensitivity categories (recall 0.07) and non-verbatim disclosure forms, while the LLM detectors remain more robust, with the HIGH tier as their strongest sensitivity slice.”

即:规则型检测器在最不能漏的 HIGH 敏感度档上召回只有 0.07,而 LLM 检测器恰恰在 HIGH 档表现最强。同一篇论文还报告了随敏感度升高的召回塌缩——从 LOW 档一路降到 HIGH 档的 0.07。

这对本章架构的直接后果是:“第1层正则做高精度快速过滤、判为无 PII 即放行”的设计,恰好会把最高风险的那批实体在第一层就放走,它们根本走不到第3层。 总 F1 会把这个洞平摊掉——这正是 aggregate 指标”掩盖失效结构”的含义。

可操作的改造(把一维架构升成二维):

LOW 档(通用人名、城市名)HIGH 档(身份证号、病历号、金融账号)
第1层 正则✅ 可走快速通道放行❌ 跳过快速通道,直接进 NER
第2层 NER常规常规
第3层 LLM-judge仅歧义时建议默认全量

三条落地原则:

  1. 敏感度分级是与”检测方法”正交的第二个维度,不是它的子集。只按方法分层,等于假设各敏感度档的漏检率相同,而这个假设已被证伪。
  2. 评估必须按敏感度分层报召回。只看一个总 F1,HIGH 档 0.07 会被中低档的高分淹没。
  3. “快速放行”的加速通道只能用于低风险档。省下来的那点延迟,不值得拿 HIGH 档的漏检去换。
  4. 敏感度(要不要送检)与吞吐(送得起几次)是两个独立约束。 上面第 1 行”LOW 档可走快速通道放行”成立的前提是第2层 NER 能一次判清;若 NER 在 LOW 档上输出大量低置信候选(本章 §8.2.4 末尾的 Tier 表已承认”单一 NER 命中 = 中置信 → 送人工复核”),那么 LOW 档的实际成本并不比 HIGH 档低多少,快速通道等于没有省下东西。可操作的做法是把 §8.2.4 的 Tier 表与本节的敏感度表合成一张”敏感度 × 置信度 → 处置动作”的联合表,把”LOW 档 + 单一 NER 命中”这类格子写死,而不是让读者自己做两张表的笛卡尔积。

补充一条同样来自 REDACT 的观察:敏感度分档本身是该任务最难的一根轴——论文用三模型 reference-free LLM-as-judge 复核后指出,sensitivity-tier assignment 是任务中最难的维度。这意味着即使你决定做敏感度分层,“判定某个实体属于哪一档”也需要单独投入,不能假设现有检测器顺带就能给出可靠分档。

merge 层的关键操作:span 去重(同一位置的多层命中合并)→ 标签规范化(统一实体类型名)→ 置信度打分(多层交叉验证加权)→ 冲突解决(LLM-judge 优先,NER 次之,regex 为底线)。

从”检测精度分层”到”处置动作分层”:上面的三层架构描述的是检测手段的递进(regex → NER → LLM-judge),而生产系统还需要与之正交的第二个维度——按置信度决定处置动作(Presidio + gitleaks 实践中常见的 Tier1/Tier2/Tier3 分级):

Tier置信度来源处置动作
Tier1正则 + 多检测器一致命中(高置信)直接自动脱敏
Tier2单一 NER 或单一检测器命中(中置信)送人工复核队列
Tier3LLM-judge 或模糊匹配命中(低置信)仅打标记,不脱敏

要点:“更高精度的检测器”与”更谨慎的处置策略”是两个正交维度——不能因为上了 LLM-judge 就把 Tier3 的低置信命中直接拿去脱敏。检测精度决定”能不能信”,处置动作决定”信了之后怎么办”。

8.3 Presidio 框架

Microsoft Presidio 是工业级 PII 检测与脱敏的开源框架,集成了正则+NER+多种脱敏策略。

版本注记:本节及 §8.9 的示例对应 presidio-analyzer 2.2.x 系列(核对时 PyPI 最新为 2.2.364,2026-10-09 查)。Presidio 在 2.2.x 下保持高频 patch 发布,跨 minor 升级时请以官方 migration 说明为准。

⚠️ 兼容性待实测: 在 2.2.x 下运行本章示例前,请自行验证三处:predefined_recognizers 的导出名(SpacyRecognizer 是否仍在该路径下)、SpacyRecognizer 的构造与 NLP engine 配置方式(2.x 有过变化)、AnonymizerEngine.anonymize 的 operators 参数签名。三者任一变化都会让本节的示例代码跑不通。

8.3.1 基础用法

from presidio_analyzer import AnalyzerEngine, RecognizerRegistry
from presidio_anonymizer import AnonymizerEngine
from presidio_analyzer.predefined_recognizers import (
    EmailRecognizer, PhoneRecognizer, IpRecognizer,
    CreditCardRecognizer, SpacyRecognizer
)

# 初始化分析器
analyzer = AnalyzerEngine()

# 添加预定义识别器
# Presidio 内置了大量识别器
analyzer.registry.add_recognizer(EmailRecognizer())
analyzer.registry.add_recognizer(PhoneRecognizer())
analyzer.registry.add_recognizer(IpRecognizer())
analyzer.registry.add_recognizer(CreditCardRecognizer())

# 中文支持需要自定义识别器
from presidio_analyzer import Pattern, PatternRecognizer

# 中国身份证识别器
cn_id_pattern = Pattern(
    name='cn_id_card',
    regex=r'\b[1-9]\d{5}(?:19|20)\d{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])\d{3}[\dXx]\b',
    score=0.95
)
cn_id_recognizer = PatternRecognizer(
    supported_entity='CN_ID_CARD',
    patterns=[cn_id_pattern]
)
analyzer.registry.add_recognizer(cn_id_recognizer)

# 中文电话识别器
cn_phone_pattern = Pattern(
    name='cn_phone',
    regex=r'\b1[3-9]\d{9}\b',
    score=0.9
)
analyzer.registry.add_recognizer(
    PatternRecognizer(
        supported_entity='CN_PHONE',
        patterns=[cn_phone_pattern]
    )
)

# 分析文本
results = analyzer.analyze(
    text='我的邮箱是 zhangsan@gmail.com,电话 13812345678',
    language='zh',
    entities=['EMAIL_ADDRESS', 'CN_PHONE', 'CN_ID_CARD']
)

# 结果:[EmailRecognizer result, CN_PHONE result]

8.3.2 脱敏操作

from presidio_anonymizer import AnonymizerEngine
from presidio_anonymizer.entities import (
    AnonymizerConfig,
    OperatorConfig
)

anonymizer = AnonymizerEngine()

# 默认操作:替换为占位符
result = anonymizer.anonymize(
    text='我的邮箱是 zhangsan@gmail.com,电话 13812345678',
    analyzer_results=results,
    operators={
        'EMAIL_ADDRESS': OperatorConfig('replace', {'new_value': '<EMAIL>'}),
        'CN_PHONE': OperatorConfig('replace', {'new_value': '<PHONE>'}),
    }
)

# 结果:"我的邮箱是 <EMAIL>,电话 <PHONE>"

8.4 脱敏策略

8.4.1 五种策略对比

策略示例适用场景
替换 Replace13812345678 → <PHONE>通用,推荐
哈希 Hashzhangsan@gmail.com → a3b2c1…@<EMAIL_DOMAIN>需要保持唯一性
泛化 Generalize28岁 → 20-30岁保留统计特征
删除 Delete整段含 PII 的内容删除极端敏感场景
可逆脱敏 Reverse<EMAIL-7a3b> → LLM处理 → 还原真实邮箱Agent/RAG需返回真实PII
差分隐私 DP在统计结果上加噪聚合数据

⚠️ 可逆脱敏的生产级失败模式(社区实践,需在生产落地前验证):可逆脱敏(reversible/anonymize 后 unmask)看似优雅,实则有两个隐蔽陷阱——(1) 确定性映射反推风险:若同一 PII 值始终映射到同一占位符(如 zhangsan@gmail.com → <EMAIL-7a3b> 恒定),攻击者可通过对占位符做归并计数(同一占位符出现频次 = 该身份出现频次)反推原始身份,生产环境应加盐(salt)或使用随机化映射;(2) unmask 状态一致性静默失败:unmask 逻辑依赖脱敏时的密钥/状态,若两者不一致(如密钥轮换、状态未持久化)会静默失败——不报错却返回错误还原结果,需建立”脱敏↔还原”的一致性校验。

from presidio_anonymizer.entities import OperatorConfig

# 替换策略
OPERATORS_REPLACE = {
    'EMAIL_ADDRESS': OperatorConfig('replace', {'new_value': '<EMAIL>'}),
    'CN_PHONE': OperatorConfig('replace', {'new_value': '<PHONE>'}),
    'CN_ID_CARD': OperatorConfig('replace', {'new_value': '<ID>'}),
    'IP_ADDRESS': OperatorConfig('replace', {'new_value': '<IP>'}),
}

# 哈希策略(保留唯一性)
OPERATORS_HASH = {
    'EMAIL_ADDRESS': OperatorConfig('custom', {
        'lambda': lambda x: f'user_{hashlib.md5(x.encode()).hexdigest()[:8]}@<EMAIL>'
    }),
}

# 泛化策略(保留统计特征)
OPERATORS_GENERALIZE = {
    'AGE': OperatorConfig('custom', {
        'lambda': lambda age: f'{int(age)//10*10}-{int(age)//10*10+9}'
    }),
}

# 删除策略(最严格)
OPERATORS_DELETE = {
    'EMAIL_ADDRESS': OperatorConfig('delete', {}),
    'CN_PHONE': OperatorConfig('delete', {}),
    'CN_ID_CARD': OperatorConfig('delete', {}),
}

8.4.2 推荐策略组合

工业级推荐的”分层策略”:

# 工业级分层脱敏策略
PII_STRATEGY = {
    # 高风险 PII:直接删除(包含原文都不要留)
    'delete': ['CN_ID_CARD', 'PASSPORT', 'BANK_CARD', 'CREDIT_CARD', 'API_KEY'],
    
    # 中风险 PII:替换为占位符
    'replace': ['EMAIL_ADDRESS', 'CN_PHONE', 'IP_ADDRESS', 'PERSON'],
    
    # 低风险 PII:泛化处理
    'generalize': ['AGE', 'DATE_TIME', 'ADDRESS'],
}

8.4.3 差分隐私(高级)

差分隐私(Differential Privacy)通过添加噪声保护个体:

import numpy as np

def dp_aggregate(values, epsilon=1.0, sensitivity=1.0):
    """
    差分隐私聚合
    epsilon: 隐私预算,越小越隐私但准确性越差
    sensitivity: 函数敏感度
    """
    # Laplace 机制
    noise = np.random.laplace(0, sensitivity/epsilon, size=len(values))
    return np.array(values) + noise

# 例如:统计平均年龄,但保护个体
ages = [25, 30, 35, 40, 45]
true_mean = np.mean(ages)  # 35.0
dp_mean = np.mean(dp_aggregate(ages, epsilon=0.5))
# 输出可能是 35.8 或 34.2,但不暴露任何个体年龄

踩坑经验:差分隐私适合聚合统计,不适合单条文本。对单条文本,还是用替换/删除。

8.5 多维打标

打标(Tagging)是给每篇文档打上多维标签,为后续配比做准备。

8.5.1 推荐维度

DOCUMENT_TAGGING_SCHEMA = {
    # 基础维度
    'language': 'str',         # 语种: en, zh, code
    'source': 'str',           # 来源: cc, github, arxiv, wiki
    
    # 内容维度
    'domain': 'list[str]',     # 领域: [tech, science, finance, ...]
    'doc_type': 'str',         # 类型: article, tutorial, news, qa, code
    'difficulty': 'int',      # 难度 1-5
    'educational_value': 'int', # 教育价值 1-5(FineWeb-Edu)
    
    # 质量维度
    'knowledge_density': 'float',  # 知识密度 0-1
    'language_style': 'str',       # 风格: formal, casual, technical
    'audience': 'str',             # 受众: expert, general, children
    
    # 结构维度
    'length': 'int',               # 字符数
    'has_formulas': 'bool',        # 是否含数学公式
    'has_code': 'bool',            # 是否含代码
    'has_tables': 'bool',          # 是否含表格
    
    # 元数据
    'created_at': 'datetime',
    'toxicity_score': 'float',     # 毒性 0-1
    'pii_detected': 'bool',        # 是否检测到 PII
}

8.5.2 打标流程示例

def tag_document(text):
    """
    给单篇文档打多维标签
    """
    tags = {}
    
    # 1. 基础维度(已在前面环节计算)
    tags['language'] = detect_language(text)
    tags['length'] = len(text)
    
    # 2. 教育价值打分(FineWeb-Edu 分类器)
    tags['educational_value'] = fineweb_edu_classify(text)
    
    # 3. 难度评估
    tags['difficulty'] = estimate_difficulty(text)
    
    # 4. 领域分类(多标签)
    tags['domain'] = classify_domain(text)
    
    # 5. 类型分类
    tags['doc_type'] = classify_doc_type(text)
    
    # 6. 结构特征
    tags['has_formulas'] = bool(re.search(r'\$.*\$', text))
    tags['has_code'] = bool(re.search(r'```.*```', text, re.DOTALL))
    tags['has_tables'] = bool(re.search(r'\|.*\|.*\n.*\|.*\|', text))
    
    # 7. 毒性
    tags['toxicity_score'] = detect_toxicity(text)['toxicity']
    
    # 8. PII 检测
    pii_results = detect_pii(text)
    tags['pii_detected'] = len(pii_results) > 0
    
    return tags


def estimate_difficulty(text):
    """
    评估文本难度 1-5
    """
    # 简化版:基于句子长度、词汇复杂度、概念密度
    sentences = text.split('.')
    avg_sentence_len = np.mean([len(s.split()) for s in sentences])
    
    # 长难词比例
    words = text.split()
    long_words = sum(1 for w in words if len(w) > 8)
    long_word_ratio = long_words / max(len(words), 1)
    
    # 难度评分
    if avg_sentence_len < 10 or long_word_ratio < 0.05:
        return 1
    elif avg_sentence_len < 15 or long_word_ratio < 0.10:
        return 2
    elif avg_sentence_len < 20 or long_word_ratio < 0.15:
        return 3
    elif avg_sentence_len < 25 or long_word_ratio < 0.20:
        return 4
    else:
        return 5


def classify_domain(text, top_k=3):
    """
    多标签领域分类
    """
    # 用 zero-shot 分类器
    from transformers import pipeline
    classifier = pipeline('zero-shot-classification', model='facebook/bart-large-mnli')
    
    domains = [
        'technology', 'science', 'medicine', 'finance', 'law',
        'history', 'literature', 'arts', 'sports', 'politics',
        'education', 'travel', 'food', 'health', 'entertainment'
    ]
    
    result = classifier(text[:1024], domains, multi_label=True)
    
    # 取 top_k
    top_domains = []
    for label, score in zip(result['labels'], result['scores']):
        if score > 0.3 and len(top_domains) < top_k:
            top_domains.append(label)
    
    return top_domains

8.5.3 打标流程之外:垂直领域语料从哪来

§8.6.1 的流程起点是”人工标注 1–5K 条数据”。这条起点有一个隐含前提:真实语料可以进标注平台。在医疗、金融这类场景,这个前提不成立——病历、账户流水因合规原因不能外传,标注员也看不到。而恰恰是这些场景(医疗在 §8.1.1 里风险等级标为极高)脱敏需求最刚性、真实标注最难拿。

一条可行的替代路径是合成语料 + 确定性门校验。Meddies-PII(arXiv:2609.12544,2026-09-11)给出了一份完整范例:用 attribute-conditioned prompts 生成 100 万篇合成临床文档 / 17 种语言 / 9 类 PII 标签,再用 13 道确定性门(deterministic gates)强制一致性;在此基础上训练的 BIOES token 分类器(Meddies-PII-Model)在 15 个外部基准上平均 exact-match entity-level F1 = 0.827,最强基线为 0.658。

三个可迁移的要点:

  1. 合成不是”生成完就用”,门控校验是与生成同等重要的一半。真正把合成语料从”看起来像”抬到”能用”的,是那 13 道门,不是生成提示词本身。
  2. 门要分成”结构”与”标注”两类分别设。结构一致性(文档形态、字段齐全)与标注一致性(标签与实体对得上)是两种失败模式,混在一起设门会互相掩盖。
  3. 与本语言的弱监督路径的关系:§8.6.2 的 Snorkel 投票解决的是”多个弱信号怎么合”,而门控解决的是”生成出来的样本该不该进训练集”。两者可以串接——先过门,再投票。

⚠️ 可用性限定:该工作的摘要明确写明数据集、基准套件、模型、生成框架与评测代码均 “upon acceptance” 才公开。本章引用时只能引方法与已公布的数字,当前不可直接取用其数据。

另一条同类线索:REDACT 中文覆盖问题(§8.8 坑1)与本节是同一个结构性缺口——通用多语种 PII 与中文垂直场景都缺可下载的现成语料。若两者都拿不到,剩下的路只有自建:按敏感度分档构造分层评测集,再按本节的两类门校验合成样本。

8.6 分类器训练:从种子标注到全量推理

工业级打标通常需要训练自定义分类器,流程如下:

8.6.1 三阶段训练流程

阶段1: 种子标注
  人工标注 1-5K 条数据
  ├─ 领域专家标注:高准确率
  └─ 众包标注:高吞吐
       ↓
阶段2: 弱监督扩展
  用规则/启发式自动标注 10-100K 条
  ├─ Snorkel 框架:多 labeling function 投票
  └─ Label Model:学习标注函数的可靠性
       ↓
阶段3: 全量推理
  用训练好的分类器对全量数据打标
  ├─ 1B-3B 小模型(中等吞吐)
  └─ fastText(极快吞吐)

8.6.2 弱监督示例(Snorkel)

# pip install snorkel
from snorkel.labeling import labeling_function, LFApplier, LabelModel

# 定义多个标注函数
@labeling_function()
def lf_contains_equation(text):
    """含 LaTeX 公式→可能是科学论文"""
    if '$' in text and ('\\' in text):
        return SCIENCE
    return ABSTAIN

@labeling_function()
def lf_contains_code(text):
    """含代码块→可能是技术文档"""
    if '```' in text or '    def ' in text:
        return TECH
    return ABSTAIN

@labeling_function()
def lf_long_sentences(text):
    """长句子多→可能是学术"""
    sentences = text.split('.')
    if np.mean([len(s.split()) for s in sentences]) > 25:
        return ACADEMIC
    return ABSTAIN

@labeling_function()
def lf_short_sentences(text):
    """短句子多→可能是新闻"""
    sentences = text.split('.')
    if np.mean([len(s.split()) for s in sentences]) < 10:
        return NEWS
    return ABSTAIN

# 应用所有 labeling functions
lfs = [lf_contains_equation, lf_contains_code, lf_long_sentences, lf_short_sentences]
applier = LFApplier(lfs)
L_train = applier.apply(data)  # 生成标签矩阵

# 训练 Label Model 学习每个 LF 的可靠性
label_model = LabelModel(cardinality=5, verbose=True)
label_model.fit(L_train, n_epochs=500)

# 得到伪标签
predictions = label_model.predict(L_train)

8.6.3 训练自定义分类器

from sklearn.model_selection import train_test_split
from sklearn.metrics import classification_report
import lightgbm as lgb

def train_domain_classifier(texts, labels):
    """
    训练领域分类器
    """
    # Step 1: 特征提取(用 sentence embedding)
    from sentence_transformers import SentenceTransformer
    encoder = SentenceTransformer('all-MiniLM-L6-v2')
    embeddings = encoder.encode(texts, show_progress_bar=True)
    
    # Step 2: 划分训练/验证
    X_train, X_val, y_train, y_val = train_test_split(
        embeddings, labels, test_size=0.2, stratify=labels
    )
    
    # Step 3: 训练 LightGBM
    clf = lgb.LGBMClassifier(
        n_estimators=200,
        learning_rate=0.05,
        num_leaves=64,
        max_depth=8,
        class_weight='balanced'
    )
    clf.fit(X_train, y_train)
    
    # Step 4: 评估
    y_pred = clf.predict(X_val)
    print(classification_report(y_val, y_pred))
    
    return clf

# Step 5: 全量推理(用 Ray 并行)
import ray

@ray.remote(num_gpus=0.2)
def batch_classify(texts_batch, classifier):
    """批量分类"""
    embeddings = encoder.encode(texts_batch)
    return classifier.predict(embeddings)

def tag_all_documents(documents, classifier):
    """全量打标"""
    batches = [documents[i:i+1000] for i in range(0, len(documents), 1000)]
    futures = [batch_classify.remote(b, classifier) for b in batches]
    results = ray.get(futures)
    return [item for batch in results for item in batch]

8.7 打标质量保障

8.7.1 质量检查清单

def validate_tags(doc):
    """验证打标质量"""
    issues = []
    
    # 1. 必填字段
    required = ['language', 'domain', 'doc_type', 'difficulty']
    for field in required:
        if field not in doc or doc[field] is None:
            issues.append(f'missing_{field}')
    
    # 2. 字段类型
    if not isinstance(doc.get('difficulty'), int):
        issues.append('difficulty_not_int')
    if not (1 <= doc.get('difficulty', 3) <= 5):
        issues.append('difficulty_out_of_range')
    
    # 3. 一致性
    if doc.get('language') == 'zh' and doc.get('domain') == ['english_literature']:
        issues.append('language_domain_mismatch')
    
    # 4. 异常值
    if doc.get('length', 0) > 1_000_000:
        issues.append('length_too_large')
    if doc.get('toxicity_score', 0) > 0.7:
        issues.append('high_toxicity_not_filtered')
    
    return issues

8.8 踩坑经验

坑1:PII 检测召回不够

某团队只用了正则,结果漏掉了大量”姓名+地址”组合(无法用正则匹配)。必须双路:正则+NER。

怎么验收自己的管线够不够(自测资源):REDACT(arXiv:2606.19881)是目前覆盖面最广的开源多语言 PII 基准——13,427 条记录 / 324,078 个标注 / 51 类实体 / 4,127 种表层形式 / 25 语种 / 9 种书写系统,且已开源 benchmark、schema、prompts 与分层评估 harness。它的价值不在那个总 F1,而在它用 strength-2 covering array 采样器控制了 9 个生成轴(领域、格式、难度、长度、密度、语码转换、语言、邻接、共现),因此能定位”你的检测器到底在哪种表层形式上失效”——这比一个总分有用得多。配合 §8.2.6 的敏感度分层一起用,可以直接看出 HIGH 档是不是塌了。

✅ 中文团队注意(2026-10-09 已核实,此前为”待确认”):REDACT 摘要只说”25 languages across 9 scripts”,未逐条列出语种清单,本章此前一直标注”中文是否在列需自行确认”。本轮回源 arXiv:2606.19881 HTML 正文的生成轴定义,语种已逐条刊出,中文在列且占两席:

language (25): AR, CS, DA, DE, EN, ES, FI, FR, FR_CA, HE, HI, HU, IT, JA, KO, NL, NO, PT_BR, PT_EU, RU, SV, TH, TR, ZH_CN(简体中文), ZH_TW(繁体中文)

因此:REDACT 可以用于中文 PII 管线的分层验收,§8.2.6 的敏感度分层与本节的高召回验收对中文同样适用。仍需注意——它的 1,000 条锁定语言分层评测样本是按 25 语种分摊的,单语种样本量很小,中文上的绝对数值置信区间会宽,宜作为”相对比较”而非”绝对 SLA”使用。

与漏检相对的失效模式是过度标记:多轮对话等场景的公开评测显示,规则型检测器容易出现标记体积远大于金标、precision 极低的情况。过度标记会直接抬高脱敏成本并破坏文本可用性(该打标的全打了,正文反而不成句)。因此验收时应同时看召回与”标记体积比”(标记字符数 / 金标字符数),只看召回会奖励”宁可错杀”的烂检测器。

坑2:脱敏后破坏了原文结构

某团队把所有 PII 都替换成 <PII>,结果训练数据里满是 <PII>,模型学到的是”生成 <PII>”。每种 PII 用不同占位符:<EMAIL>、<PHONE>,让模型学会区分。

坑3:分类器训练数据偏置

某团队训练领域分类器时,“科学”类标注全是 arXiv,结果把所有含公式的文档都判为”科学”——包括代码(含 LaTeX 注释)。标注数据要覆盖各种来源和风格。

坑4:打标维度过多导致冗余

某团队定义了 50 个标签维度,但发现 80% 的维度高度相关。推荐用 10-15 个核心维度,PCA 分析后再删冗余。

坑5:标签更新不向后兼容

某团队上线后发现 domain 字段需要新增 'agriculture',但已有数据没有这个标签。标签 schema 必须支持版本化,且更新时全量重打标。

8.9 工业级流水线

class TaggingPipeline:
    """工业级打标流水线"""
    
    def __init__(self):
        self.pii_detector = PresidioPIIDetector()
        self.langid = fasttext.load_model('lid.176.bin')
        self.edu_classifier = load_fineweb_edu()
        self.domain_classifier = train_domain_classifier(...)  # LightGBM
        self.toxicity_detector = Detoxify('multilingual')
    
    def process(self, doc):
        text = doc['text']
        
        # Step 1: PII 检测与脱敏
        pii_results = self.pii_detector.detect(text)
        text = self.pii_detector.anonymize(text, pii_results)
        
        # Step 2: 语种识别
        lang = self.langid.predict(text.replace('\n', ' '))
        
        # Step 3: 教育价值打分
        edu_score = self.edu_classifier(text[:4096])
        
        # Step 4: 领域分类
        domain = self.domain_classifier([text])
        
        # Step 5: 毒性检测
        toxicity = self.toxicity_detector.predict(text)
        
        # 输出
        doc.update({
            'text': text,  # 脱敏后
            'language': lang,
            'edu_score': edu_score,
            'domain': domain,
            'toxicity': toxicity,
            'tags_computed_at': datetime.now()
        })
        
        return doc

8.10 本课总结

  • PII 类目:邮箱/电话/身份证/银行卡/IP/API Key 等 12+ 类
  • 双路检测:正则(固定格式)+ NER(自然语言)+ 密钥扫描
  • Presidio 框架:Microsoft 开源,规则型检测器的工业级事实标准( 不是 PII 检测整体的事实标准:REDACT 上 Presidio 的 exact micro-F1 为 0.145,2609.38630 上 redaction F1 为 57.3、被微调 encoder 的 88.8 大幅拉开)
  • 五种脱敏策略:替换(推荐)/ 哈希 / 泛化 / 删除 / 差分隐私
  • 多维打标:基础/内容/质量/结构/元数据 5 大类标签
  • 分类器训练:种子标注 → 弱监督(Snorkel)→ 全量推理

课后练习

  1. 用 Presidio 跑一遍你的数据,统计 PII 分布
  2. 实现一个自定义的中国身份证识别器
  3. 用 Snorkel 框架做一次弱监督打标
  4. 训练一个领域分类器,评估其准确率

下一章我们将进入数据混合配比——预训练数据工程的核心环节,配比决定模型的”性格”。

🛠️ 本章衍生工具

🔏

数据工程·PII 脱敏实验室

《AI 数据全书》第 8 章「PII 识别与脱敏」配套——① 三层检测流水线:8 篇陷阱样本(张主任称谓/跨句共指/假身份证/版本号误报/教学密钥/中文姓名地址/HIPAA 医疗/BIN vs 订单号)逐层跑,第 1 层正则+GB 11643 校验和在浏览器内真跑可编辑、第 2/3 层为人工标注真值,三色高亮 + 召回/误报仪表 + merge 四步动画(span 去重→标签规范化→置信度打分→LLM-judge 优先的冲突解决) · ② Tier 处置分诊台:检测层级×处置动作 3×3 正交矩阵,可故意把 Tier3 连到「直接自动脱敏」看误杀清单实时算出,讲透「更高精度的检测器」与「更谨慎的处置策略」是两个正交维度 · ③ 六策略对照台:替换/哈希(FNV-1a)/泛化/删除/可逆/差分隐私并排输出 + §8.4.2 工业分层预设 + Laplace 机制真算(ε 滑杆) · 可逆脱敏两个陷阱:20 条日志归并计数攻击(确定性映射一键反推身份 vs 加盐后失效)、密钥轮换 unmask 静默失败(张三→李四不报错) · ④ 打标 schema 工作台:§8.5.1 十六字段勾选实时出 JSON、超 15 个触发坑 4 告警 + 三阶段训练流程 + §8.8 五张踩坑卡 · 顶栏 GDPR/CCPA/个保法/HIPAA/数安法切换,13 类目按法规重排「必处理」优先级

🧼

数据工坊·文档脱敏工作台

《AI 数据全书》第 8 章「PII 识别与脱敏」配套产品原型(系列第二个实用工具)——15 类 PII 真检测:身份证走 GB 11643 加权校验和、银行卡走 Luhn、手机号/邮箱/住址/车牌/日期/QQ微信/URL/IP 结构匹配,外加 sk- 与 AKIA 密钥、JWT、私钥头、医疗词典(HIPAA);版本号 10.0.19041.1 自动排除、假身份证与疑似订单号自动降级为"校验不通过" · 六种策略(替换/掩码/哈希/泛化/删除/保留)可全局设置也能按类目覆盖,占位符序号与哈希加盐开关 · 命中清单逐条判误报(取消勾选即不脱敏)、置信度滑杆压误报、原文高亮对照(虚线框=低置信需人工确认) · ⭐ 二次扫描查残留:脱敏后再扫一遍,有残留直接红条点名「脱敏不彻底」 · 风险分(极高×5/高×3/中×1)+等级+命中密度 · 可逆还原台复现 §8.4.1 两个陷阱:确定性映射的归并计数攻击(加盐可破但跨批次 ID 不保)、密钥轮换导致 unmask 静默失败(不报错但还原成错的人) · 导出脱敏 .txt + 映射表 .json,文本全程不出本机