Guide · 使用教程
Presidio Python 教程:敏感信息识别、误报漏报与脱敏结果验证
用固定版本与合成样本运行自定义识别和替换,验证字符位置、误报漏报及已知残留,说明零命中和测试通过的边界。
把敏感字段替换成占位符之后,还要确认识别位置正确、业务信息没有误删,并且已知漏报能被测试发现。本教程用 Presidio 的真实 Python 包跑一条小型本地管线,重点验证这些工程条件。
示例只使用合成邮箱和虚构业务账号。直接调用两个自定义 PatternRecognizer,不加载预训练 NER 模型,不调用云 API 或付费模型。它不覆盖姓名、住址、真实电话号码、完整邮箱语法或全部中文敏感信息,不能作为可直接上线的完整检测器。
1. 固定环境和验证范围
本次核对日期为 2026-10-18,使用 Python 3.11,presidio-analyzer==2.2.364 与 presidio-anonymizer==2.2.364。官方安装页列出的 Python 支持范围为 3.10–3.13;安装当前版本时仍应核对依赖兼容性。安装说明
在一个新目录中创建虚拟环境:
python3.11 -m venv .venv
source .venv/bin/activate
python -m pip install 'presidio-analyzer==2.2.364' 'presidio-anonymizer==2.2.364'
本例没有实例化默认 AnalyzerEngine(),因此不需要下载英语 spaCy 模型。完整 AnalyzerEngine 工作流还涉及模型、registry 与上下文增强;加入这些组件后,应重新设计并运行覆盖测试。Analyzer 概念
2. 实现最小识别与转换管线
保存为 pii_pipeline.py。邮箱规则只接受这里定义的 ASCII 形态;账号格式限定为 acct_ 加四位数字。0.8 是示例规则分数,0.5 是本例的过滤阈值,不代表模型校准后的安全概率。
区间采用 Python 字符串的 start:end,不能把 UTF-8 字节位置或 JavaScript UTF-16 索引直接混入。检测后也不要先规范化字符串,再复用旧的 offset。本例遇到重叠区间直接报错,真实应用可以另行设计经过验证的冲突策略。
from presidio_analyzer import Pattern, PatternRecognizer
from presidio_anonymizer import AnonymizerEngine
from presidio_anonymizer.entities import OperatorConfig
# Deliberately narrow synthetic rules: no NER model or cloud API is loaded.
RULES = [
PatternRecognizer(
supported_entity="EMAIL_ADDRESS",
patterns=[Pattern("ascii_email", r"(?<![A-Za-z0-9._%+-])[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}(?![A-Za-z0-9.-])", 0.8)],
),
PatternRecognizer(
supported_entity="ACCOUNT_CODE",
patterns=[Pattern("synthetic_account", r"(?<![A-Za-z0-9_])acct_[0-9]{4}(?![A-Za-z0-9_])", 0.8)],
),
]
OPERATORS = {
"EMAIL_ADDRESS": OperatorConfig("replace", {"new_value": "[EMAIL]"}),
"ACCOUNT_CODE": OperatorConfig("replace", {"new_value": "[ACCOUNT]"}),
}
ENGINE = AnonymizerEngine()
def detect(text):
hits = [h for rule in RULES for h in rule.analyze(text=text, entities=rule.supported_entities)
if h.score >= 0.5]
return sorted(hits, key=lambda h: (h.start, h.end, h.entity_type))
def validate_spans(text, hits):
previous_end = 0
for hit in sorted(hits, key=lambda h: (h.start, h.end)):
if not (0 <= hit.start < hit.end <= len(text)):
raise ValueError("Invalid character span")
if hit.start < previous_end:
raise ValueError("Resolve overlapping spans before transformation")
if hit.entity_type not in OPERATORS:
raise ValueError("No approved operator")
previous_end = hit.end
def redact(text):
hits = detect(text)
validate_spans(text, hits)
result = ENGINE.anonymize(text=text, analyzer_results=hits, operators=OPERATORS)
# Return no matched values or original text as metadata.
return result.text, {"detected_count": len(hits), "ruleset": "synthetic-v1"}
def verify_known_values(output, values):
# This checks only explicitly labelled fixtures; it cannot prove anonymity.
if any(value and value in output for value in values):
raise AssertionError("Known fixture value remains")
def exact_span_counts(text, expected):
predicted = {(h.entity_type, h.start, h.end) for h in detect(text)}
expected = set(expected)
return {"tp": len(predicted & expected), "fp": len(predicted - expected),
"fn": len(expected - predicted)}
redact 只返回转换文本、命中数量和规则版本,不把原值复制到元数据。verify_known_values 只检查显式提供的标注值;即使检查通过,也无法证明未知的敏感内容不存在。
在同一目录执行一个合成例子:
python - <<'PY'
from pii_pipeline import redact
output, metadata = redact('客服🙂[email protected];账号 acct_1234;订单 ord_1234')
assert output == '客服🙂[EMAIL];账号 [ACCOUNT];订单 ord_1234'
assert metadata['detected_count'] == 2
print(output)
PY
预期结果:
客服🙂[EMAIL];账号 [ACCOUNT];订单 ord_1234
这个例子验证已定义的账号和邮箱被替换,订单编号被保留;没有验证中文姓名或地址识别。替换 operator 使用的是 Presidio 的实际 AnonymizerEngine,参见转换示例。
3. 加入误报、漏报和错误区间测试
保存下面内容为 test_pipeline.py。除了重复值与 Unicode 前缀,还加入两个刻意暴露规则局限的样本:
- 手册里的公开演示邮箱,业务策略要求保留,但纯格式规则仍会命中,属于这份标注策略下的误报。
demo [at] example [dot] test被标为应处理的混淆邮箱,但本例规则不会识别,属于漏报。
测试通过表示这些已知问题被正确测量,不表示这些问题已经解决。不要在看到通过提示后丢掉失败样本。
import json
import unittest
from importlib.metadata import version
from pathlib import Path
from presidio_analyzer import RecognizerResult
from pii_pipeline import detect, redact, validate_spans, verify_known_values, exact_span_counts
class PipelineTests(unittest.TestCase):
def test_unicode_and_repeated_values(self):
text = "客服🙂[email protected];再次 [email protected]"
hits = detect(text)
self.assertEqual([text[h.start:h.end] for h in hits], ["[email protected]"] * 2)
output, meta = redact(text)
self.assertEqual(output, "客服🙂[EMAIL];再次 [EMAIL]")
self.assertEqual(meta, {"detected_count": 2, "ruleset": "synthetic-v1"})
verify_known_values(output, ["[email protected]"])
def test_account_and_order_number(self):
output, _ = redact("账号 acct_1234,订单 ord_1234")
self.assertEqual(output, "账号 [ACCOUNT],订单 ord_1234")
def test_start_and_end(self):
output, _ = redact("[email protected] acct_1234")
self.assertEqual(output, "[EMAIL] [ACCOUNT]")
def test_invalid_span_rejected(self):
with self.assertRaises(ValueError):
validate_spans("abc", [RecognizerResult("EMAIL_ADDRESS", 0, 10, 0.8)])
def test_overlap_rejected(self):
with self.assertRaises(ValueError):
validate_spans("abcdef", [RecognizerResult("EMAIL_ADDRESS", 0, 4, 0.8),
RecognizerResult("ACCOUNT_CODE", 3, 6, 0.8)])
def test_known_leak_verifier(self):
with self.assertRaises(AssertionError):
verify_known_values("[email protected]", ["[email protected]"])
def test_zero_hits_is_not_proof(self):
text = "demo [at] example [dot] test"
output, meta = redact(text)
self.assertEqual(meta["detected_count"], 0)
self.assertEqual(output, text)
counts = exact_span_counts(text, [("EMAIL_ADDRESS", 0, len(text))])
self.assertEqual(counts, {"tp": 0, "fp": 0, "fn": 1})
def test_measure_false_positive_and_false_negative(self):
# Policy: the manual's public example should be preserved; customer email removed.
positive = "客户 [email protected]"
negative = "手册样例 [email protected]"
missed = "demo [at] example [dot] test"
cases = [(positive, [("EMAIL_ADDRESS", positive.index("demo"), len(positive))]),
(negative, []), (missed, [("EMAIL_ADDRESS", 0, len(missed))])]
total = {k: 0 for k in ("tp", "fp", "fn")}
for text, expected in cases:
for k, value in exact_span_counts(text, expected).items():
total[k] += value
self.assertEqual(total, {"tp": 1, "fp": 1, "fn": 1})
if __name__ == "__main__":
suite = unittest.defaultTestLoader.loadTestsFromTestCase(PipelineTests)
result = unittest.TextTestRunner(verbosity=2).run(suite)
report = {"date": "2026-10-18", "python": "3.11", "packages": {
p: version(p) for p in ["presidio-analyzer", "presidio-anonymizer"]},
"tests_run": result.testsRun, "failures": len(result.failures), "errors": len(result.errors),
"synthetic_metric_fixture": {"tp": 1, "fp": 1, "fn": 1, "precision": 0.5, "recall": 0.5},
"scope": "Custom ASCII email/account patterns and transformation contracts only; no NER, real PII or cloud API benchmark"}
Path(__file__).with_name("snippet-checks.json").write_text(json.dumps(report, ensure_ascii=False, indent=2))
raise SystemExit(0 if result.wasSuccessful() else 1)
运行:
python test_pipeline.py
本地验证包含八组测试:重复邮箱与中文/emoji 前缀、业务账号与订单号区分、首尾区间、越界拒绝、重叠拒绝、残留值检查、零命中漏报,以及误报漏报统计。测试使用真实 Presidio 包和合成输入,未测预训练 NER、真实业务数据、云服务或生产吞吐量。
4. 怎样解释精确率和召回率
最后一个指标样本组有三个输入,采用实体类型与起止位置完全相同才算命中的规则:客户邮箱 TP=1,公开手册邮箱 FP=1,混淆邮箱 FN=1。因此精确率为 1/(1+1)=0.5,召回率为 1/(1+1)=0.5。这是刻意构造的小型管线示例,不是 Presidio 整体准确率。
真实评估应按语言、实体、输入来源和严重程度分别报告。使用部分重叠计分时需另列口径;不支持格式、请求失败、OCR 失败等也要保留计数,不能只报告成功处理的样本。官方提供了 PII 检测评估说明,可以据此扩展标注与回归工作。
对误报,可以增加上下文和更精确的实体规则,或采用经过审批的保留策略。不要为保留公开演示邮箱,就对白名单域名下所有值一概放行。对漏报,可以增加相应格式、合适的模型或人工复核;不要仅把阈值调低就宣称覆盖了新语言和新实体。
5. 验证转换结果与业务可用性
识别指标与输出验收应分开:
| 检查 | 能说明什么 | 不能说明什么 |
|---|---|---|
| 标注区间匹配 | 检测结果符合当前标注口径 | 未标注信息不存在 |
| 已知原值不再出现 | 这些指定值未以原字符串残留 | 变体、上下文或其他信息不会重识别 |
| 第二遍检测零命中 | 当前检测器没有继续报告 | 所有敏感信息已消除 |
| 下游摘要或检索验收 | 指定业务任务仍可使用输出 | 所有用途都无风险 |
如果同一实体在多处出现,要检查每次出现;如果需要跨段保持关联,要设计映射而非随意生成新名字。映射、密钥、输入副本和调试样本都需要单独保护。散列、加密和伪名化有不同恢复与关联风险,本文的类型替换没有证明数据达到法律意义上的匿名状态。
6. 放到 LLM 工作流的哪个位置
先处理用户输入、历史消息、检索片段和工具输出,再构造实际发送给模型的提示词。需要过滤模型输出时,再增加对应输出检查。日志、异常、追踪、上传文件与缓存也要按实际路径处理;只过滤一条输入不覆盖整个系统。
检测失败或遇到未支持格式时,应按业务策略阻断、进入受控复核或给出明确错误。不要在异常分支直接把原文发送给外部模型。生产系统还需要请求大小、运行时间、鉴权、依赖固定和升级回归,这些没有由本地示例自动完成。
7. 中文应用如何继续
本例能在中文前缀后找到 ASCII 邮箱,仅证明这两个正则与 Python 区间逻辑在测试输入上可用。完整中文识别要适配 NLP engine、recognizer 与实体范围,并建立中文姓名、地址、号码及混合语言的独立验收集。官方语言配置
如果希望比较商业多语言方案,可以看 Presidio vs Limina;已有 Google Cloud 流程可看 Sensitive Data Protection 对比。两者都需要沿用相同的数据处理约束和标注口径。
常见问题
- 示例会使用真实个人信息或调用云 API 吗?
- 不会。它仅用合成邮箱、虚构账号和本地 Presidio 包,不调用云 API、付费模型或预训练 NER。
- 为什么测试通过仍然存在误报和漏报?
- 测试既检查工程约束,也检查能否正确测量已知失败。已知误报和漏报被保留为反例,并没有因测试通过而消失。
- 教程能证明 Presidio 完整支持中文吗?
- 不能。中文和 emoji 前缀仅验证自定义 ASCII 规则的区间处理;中文姓名、地址等需要额外模型、规则和样本。
- 零命中可以证明原文没有敏感信息吗?
- 不能。它只表示当前检测器没有报告。教程用混淆邮箱展示零命中仍然漏报的情况。
- 重叠或越界的识别结果怎样处理?
- 本例在转换前校验区间并拒绝重叠或越界。真实应用可以另行设计冲突规则,但必须用边界样本验证。
- 为什么不直接把匹配原值写进日志?
- 日志会增加原值的复制和访问范围。本例只返回命中数量和规则版本,原文复核应走独立受控流程。