章节导航 第 8 章 / 共 30 章
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 Key | sk-xxxxxxxxxx | 高 | 必脱敏 |
| AWS Key | AKIA… | 高 | 必脱敏 |
| 姓名地址 | 张三 / 北京市海淀区… | 高 | 视场景 |
| 医疗信息 | 糖尿病、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 NER | 95-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-F1 Macro F1 Exact micro-F1 Claude Sonnet 4.6 0.636 0.619 0.602 GPT-4.1 0.597 0.565 0.558 OpenAI Privacy Filter 0.512 0.254 0.171 GLiNER 0.320 0.224 0.293 Presidio(规则型) 0.195 0.063 0.145 怎么读这张对照表(三点口径限定,缺一不可):
- 第1层的 ~92% 只在”带校验位的结构化 PII”上成立——身份证、银行卡、手机号这类有校验和可供验证的实体,正则的精度确实远高于通用实体识别。REDACT 里 Presidio 测的是通用实体(姓名、地址、组织等无校验位类型),两者不是同一口径。本章原文未作此限定,读者容易按通用 PII 理解,这是本表最需要补的一句话。
- 第3层的 99.4% 在真实多语种场景下没有公开基准支持。REDACT 里最强的前沿 LLM(Claude Sonnet 4.6)exact micro-F1 也只有 0.602;而本章第3层用的是 gpt-4o-mini / Claude Haiku 这类轻量模型,能力档位更低。
- 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 |
|---|---|
| 本文微调 encoder | 88.8 |
| GLiNER2(已发表版,豁免 11 类不可表示类型) | 69.1(不豁免为 68.8) |
| GLiNER2(适配本文训练数据) | 67.8 |
| Microsoft Presidio | 57.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 | 仅歧义时 | 建议默认全量 |
三条落地原则:
- 敏感度分级是与”检测方法”正交的第二个维度,不是它的子集。只按方法分层,等于假设各敏感度档的漏检率相同,而这个假设已被证伪。
- 评估必须按敏感度分层报召回。只看一个总 F1,HIGH 档 0.07 会被中低档的高分淹没。
- “快速放行”的加速通道只能用于低风险档。省下来的那点延迟,不值得拿 HIGH 档的漏检去换。
- 敏感度(要不要送检)与吞吐(送得起几次)是两个独立约束。 上面第 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 或单一检测器命中(中置信) | 送人工复核队列 |
| Tier3 | LLM-judge 或模糊匹配命中(低置信) | 仅打标记,不脱敏 |
要点:“更高精度的检测器”与”更谨慎的处置策略”是两个正交维度——不能因为上了 LLM-judge 就把 Tier3 的低置信命中直接拿去脱敏。检测精度决定”能不能信”,处置动作决定”信了之后怎么办”。
8.3 Presidio 框架
Microsoft Presidio 是工业级 PII 检测与脱敏的开源框架,集成了正则+NER+多种脱敏策略。
版本注记:本节及 §8.9 的示例对应
presidio-analyzer2.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 五种策略对比
| 策略 | 示例 | 适用场景 |
|---|---|---|
| 替换 Replace | 13812345678 → <PHONE> | 通用,推荐 |
| 哈希 Hash | zhangsan@gmail.com → a3b2c1…@<EMAIL_DOMAIN> | 需要保持唯一性 |
| 泛化 Generalize | 28岁 → 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。
三个可迁移的要点:
- 合成不是”生成完就用”,门控校验是与生成同等重要的一半。真正把合成语料从”看起来像”抬到”能用”的,是那 13 道门,不是生成提示词本身。
- 门要分成”结构”与”标注”两类分别设。结构一致性(文档形态、字段齐全)与标注一致性(标签与实体对得上)是两种失败模式,混在一起设门会互相掩盖。
- 与本语言的弱监督路径的关系:§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)→ 全量推理
课后练习
- 用 Presidio 跑一遍你的数据,统计 PII 分布
- 实现一个自定义的中国身份证识别器
- 用 Snorkel 框架做一次弱监督打标
- 训练一个领域分类器,评估其准确率
下一章我们将进入数据混合配比——预训练数据工程的核心环节,配比决定模型的”性格”。
🛠️ 本章衍生工具
数据工程·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,文本全程不出本机