章节导航 第 5 章 / 共 20 章
安全过滤与去重
Perspective/Detoxify/CSAM安全过滤、SHA256精确去重、MinHash+LSH模糊去重、SimHash、Suffix Array句级去重、训练-评测集13-gram去污染
去重让训练效果好 1.5 倍,去污染让评测可信,安全过滤让模型合规。
这三件事,一件都不能省。
5.1 安全过滤:合规底线
5.1.1 为什么要做安全过滤
预训练数据来自全网,包含:
- 毒性内容:辱骂、攻击性言论
- 色情内容:成人内容(可能涉及违法)
- 暴力内容:极端暴力、自残引导
- CSAM:儿童性虐待材料(法律红线,发现必须报告)
- 仇恨言论:种族、性别、宗教歧视
如果不做过滤:
- 法律风险:CSAM、仇恨言论在多国违法
- 模型行为失控:模型会复制有毒模式
- 合规审查失败:模型无法上架
- 品牌损害:用户用 prompt 引导出有毒输出,舆论危机
5.1.2 主流工具
| 工具 | 类型 | 速度 | 准确率 | 推荐场景 |
|---|---|---|---|---|
| Perspective API | Google API | 慢(API限速) | 高 | 大规模过滤 |
| Detoxify | 开源本地 | 快 | 中-高 | 工业级首选 |
| ShieldGemma | 开源本地 | 中(GPU) | 高 | 高准确率需求 |
| OpenAI Moderation API | API | 慢 | 极高 | 最终复核 |
| 自训练分类器 | 本地 | 快 | 可控 | 定制需求 |
5.1.3 Detoxify 实战
from detoxify import Detoxify
# 加载多语种版本(支持英/法/西/俄等多语)
model = Detoxify('multilingual', device='cuda:0')
def detect_toxicity(text):
"""
检测文本毒性
返回各维度概率
"""
if not text or len(text) > 10000:
text = text[:10000] # 长文本截断
results = model.predict(text)
# results 形如:
# {'toxicity': 0.85, 'severe_toxicity': 0.20,
# 'obscene': 0.30, 'identity_attack': 0.75,
# 'insult': 0.80, 'threat': 0.10,
# 'sexual_explicit': 0.05}
return results
# 推荐阈值
TOXICITY_THRESHOLDS = {
'toxicity': 0.7, # 毒性 >0.7 拒绝
'severe_toxicity': 0.3, # 严重毒性 >0.3 拒绝
'identity_attack': 0.5, # 身份攻击 >0.5 拒绝
'threat': 0.4, # 威胁 >0.4 拒绝
'sexual_explicit': 0.6 # 性露骨 >0.6 拒绝
}
def is_safe(text):
scores = detect_toxicity(text)
for dim, threshold in TOXICITY_THRESHOLDS.items():
if scores.get(dim, 0) > threshold:
return False, dim
return True, 'pass'
5.1.4 CSAM 过滤的工程实现
CSAM(儿童性虐待材料)是法律红线,任何疑似内容必须立即移除并报告:
def csam_filter(text, image_hashes=None):
"""
CSAM 过滤的工程实现
文本:用关键词 + hash 双路
图片:用 PhotoDNA hash 比对
"""
# 1. 关键词检测(粗筛)
CSAM_KEYWORDS = [...] # 不在此列出,可用 NCMEC 提供的清单
text_lower = text.lower()
for kw in CSAM_KEYWORDS:
if kw in text_lower:
# 触发后必须记录、移除、并按法规报告
log_csam_incident(text)
return False, 'csam_keyword'
# 2. 图片 PhotoDNA 比对
if image_hashes:
known_csam_hashes = load_ncmec_database() # NCMEC 提供的已知 CSAM hash 库
for img_hash in image_hashes:
if img_hash in known_csam_hashes:
log_csam_incident(text, img_hash)
return False, 'csam_image_match'
return True, 'pass'
踩坑经验:CSAM 处理必须严格遵守当地法规(中国《未成年人保护法》、美国 NCMEC 报告义务等)。建议咨询合规法务后再上线流水线。
5.2 为什么必须去重
去重是预训练数据工程的强制环节,三大理由:
5.2.1 去重后效果好
Lee et al. (2022) 的实验:在 C4 上训练 1.5B 模型,去重 vs 不去重:
| 数据 | 训练tokens | MMLU | TriviaQA |
|---|---|---|---|
| C4 原版(含重复) | 1T | 24.3 | 32.5 |
| C4 去重后 | 0.7T | 28.1 | 38.7 |
用 70% 的数据量,效果反而更好——因为去重让模型更高效利用每个 token。
5.2.2 防过度记忆
不去重的话,模型会逐字记住重复出现的内容:
- 同一篇文章出现 100 次 → 模型能逐字复述
- 但这是”死记硬背”,不是”理解能力”
- 评测时模型在记忆样本上虚高,在新样本上崩塌
5.2.3 防模板主导
不去重的话,少量高度模板化的内容会主导训练:
- 网站页脚、cookies声明、SEO 模板
- 这些内容模式高度重复,模型容易”过拟合”这些模式
- 生成时频繁吐出”cookies policy”、“click here”等模板短语
5.3 精确去重:SHA256
最简单的去重是精确匹配——对整篇文档做 hash,相同 hash 直接删。
5.3.1 实现
import hashlib
def exact_dedup(documents):
"""
基于SHA256的精确去重
适合"完全相同的文档"
"""
seen_hashes = set()
unique_docs = []
for doc in documents:
# 标准化:去前后空格、合并连续空白
text = ' '.join(doc['text'].split())
# 计算SHA256
doc_hash = hashlib.sha256(text.encode('utf-8')).hexdigest()
if doc_hash not in seen_hashes:
seen_hashes.add(doc_hash)
unique_docs.append(doc)
else:
doc['dedup_reason'] = 'exact_match'
return unique_docs
# 统计:CC 中精确去重通常能去掉 5-15% 的重复
5.3.2 精确去重的局限
精确去重只能抓”完全相同”的,对以下情况无效:
- 微小改动:多了空格、换行符不同 → hash 不同
- 转载修改:媒体转载时改了标题或段落顺序 → hash 不同
- 模板填充:同一个模板填了不同数据 → hash 不同
- 抄袭改写:内容大致相同但表述略改 → hash 不同
工业级必须用模糊去重补足。
5.4 MinHash + LSH:模糊去重的事实标准
MinHash + LSH(Locality-Sensitive Hashing)是大规模去重的事实标准。
5.4.1 核心原理
MinHash 的核心思想:用一组哈希函数的”最小值”近似集合的 Jaccard 相似度。
对于两个集合 A、B:
P(min(h(A)) == min(h(B))) = Jaccard(A, B) = |A∩B| / |A∪B|
用 k 个哈希函数,得到 k 个最小值,组成签名(signature)。两个签名的相同位数比例 ≈ Jaccard 相似度。
5.4.2 n-gram + MinHash 流程
import mmh3 # MurmurHash3,速度比 hashlib 快100倍
import numpy as np
from datasketch import MinHash, MinHashLSH
def minhash_dedup(documents, num_perm=128, threshold=0.8):
"""
MinHash + LSH 模糊去重
参数说明:
- num_perm: 排列数,影响精度与速度
推荐 128(生产)/ 256(高精度)
- threshold: 相似度阈值
推荐 0.8(保留高质量)/ 0.7(更激进)
"""
# Step 1: 为每篇文档生成 MinHash 签名
minhashes = {}
for doc_id, doc in enumerate(documents):
m = MinHash(num_perm=num_perm)
# 用 3-gram 作为 shingle
words = doc['text'].split()
for i in range(len(words) - 2):
ngram = ' '.join(words[i:i+3])
m.update(ngram.encode('utf-8'))
minhashes[doc_id] = m
# Step 2: LSH 索引
# LSH 把 num_perm 个 hash 分成 b 个 band,每个 band r 个 hash
# 工业推荐:b=32, r=4(即 num_perm=128)
# 这意味着两个文档如果有 4 个 hash 完全相同,就被候选为相似
lsh = MinHashLSH(threshold=threshold, num_perm=num_perm)
for doc_id, m in minhashes.items():
lsh.insert(str(doc_id), m)
# Step 3: 找出所有相似对
duplicates = set()
for doc_id, m in minhashes.items():
similar_docs = lsh.query(m)
# 保留 doc_id 最小的,其余标记为重复
for sim_id in similar_docs:
sim_id_int = int(sim_id)
if sim_id_int > doc_id:
duplicates.add(sim_id_int)
return [doc for i, doc in enumerate(documents) if i not in duplicates]
5.4.3 工程参数详解
# 推荐的工业级参数配置
MINHASH_CONFIG = {
'num_perm': 128, # 排列数
'shingle_size': 3, # n-gram 阶数
'shingle_unit': 'word', # word 或 char(中文推荐 char)
'threshold': 0.8, # 相似度阈值
# LSH 参数(与 num_perm 关联)
# 推荐 b*r = num_perm
# b=32, r=4 → num_perm=128
# b=64, r=4 → num_perm=256
# b=16, r=8 → num_perm=128(更严格,false positive少)
'bands': 32,
'rows': 4,
# 内存控制
'batch_size': 100000, # 批处理大小
'storage': 'disk', # 大规模数据用磁盘存储
'storage_config': {
'type': 'leveldb', # 大规模推荐 LevelDB 或 Redis
'path': '/tmp/lsh_index'
}
}
5.4.4 参数选择对比
| num_perm | threshold | 速度 | 准确率 | 内存 | 推荐场景 |
|---|---|---|---|---|---|
| 64 | 0.8 | 极快 | 中 | 低 | 大规模粗筛 |
| 128 | 0.8 | 快 | 高 | 中 | 工业首选 |
| 256 | 0.9 | 中 | 极高 | 高 | 精细去重 |
| 512 | 0.95 | 慢 | 极高 | 极高 | 学术研究 |
5.4.5 LSH 的 band/rows 权衡
LSH 把 num_perm 个 hash 函数分成 b 个 band,每个 band r 个 hash。两个文档被判定为候选的条件是:至少一个 band 内的 r 个 hash 完全相同。
候选概率与 Jaccard 相似度 J 的关系:
P(candidate | J) = 1 - (1 - J^r)^b
不同 (b, r) 的曲线对比:
| (b, r) | J=0.5 候选率 | J=0.8 候选率 | J=0.9 候选率 |
|---|---|---|---|
| (32, 4) | 0.04 | 0.83 | 0.998 |
| (16, 8) | 0.001 | 0.20 | 0.85 |
| (64, 4) | 0.31 | 0.99 | 1.0 |
踩坑经验:(b=32, r=4) 是经典默认配置,适合 threshold=0.8。如果想更严格地抓”近似重复”,用 (b=16, r=8);如果想抓”高度相似”,用 (b=64, r=4)。
5.5 SimHash:另一种思路
SimHash(Charikar, 2002)是 Google 用来去重网页的算法,思路与 MinHash 不同。
5.5.1 原理
- 对每个 token 计算一个 64-bit hash
- 对每个 bit 位,如果 hash 在该位为 1,token 权重 +1;为 0,权重 -1
- 累加所有 token 的权重,得到一个 64 维向量
- 把向量的每个分量做 sign 函数(>0 取 1,<0 取 0),得到 64-bit SimHash 签名
- 两篇文档的相似度 = 1 - Hamming 距离 / 64
import mmh3
import numpy as np
def simhash(text, hash_bits=64):
"""SimHash 签名生成"""
tokens = text.split()
weighted_sum = np.zeros(hash_bits)
for token in tokens:
# 用 MurmurHash3 生成 64-bit hash
h = mmh3.hash64(token)[0] # 取第一个64位hash
# 对每一位累加
for i in range(hash_bits):
bit = (h >> i) & 1
weighted_sum[i] += 1 if bit else -1
# sign 函数生成签名
signature = 0
for i in range(hash_bits):
if weighted_sum[i] > 0:
signature |= (1 << i)
return signature
def hamming_distance(h1, h2):
"""汉明距离"""
xor = h1 ^ h2
return bin(xor).count('1')
# 两篇文档相似度判定
def is_similar(text1, text2, threshold=3):
"""
Hamming 距离 ≤ threshold 视为相似
推荐 threshold=3(64-bit hash 上,约0.95相似度)
"""
h1 = simhash(text1)
h2 = simhash(text2)
return hamming_distance(h1, h2) <= threshold
5.5.2 MinHash vs SimHash
| 特性 | MinHash | SimHash |
|---|---|---|
| 度量 | Jaccard 相似度 | Cosine 相似度 |
| 签名长度 | 128-256 bit | 64 bit |
| 速度 | 快 | 极快 |
| 准确率 | 高 | 中-高 |
| 大规模LSH | 成熟 | 较复杂 |
| 推荐场景 | 文档级去重 | 网页级去重 |
工业实践:MinHash 用于”文档级近似重复”,SimHash 用于”网页级近似重复”。两者可以互补使用。
5.5.3 语义去重:从 n-gram 到 LLM 嵌入
MinHash 和 SimHash 都基于 token/char n-gram 的浅层匹配。它们在以下场景中会失效:
- 模板污染:同模板不同内容(如导航栏相同但正文不同)——n-gram 方法无法区分
- 语义重复:同语义不同表述(如转载时改写措辞)——n-gram 重叠度低,导致漏检
- 短文本扰动:极短重复片段嵌入长文本——n-gram 信噪比过低
SemHash-LLM(Fang et al., arXiv:2607.01601v1, 2026-07-02)是 2026 H2 的代表性工作,提出了一套多粒度语义去重框架:
- 语义投影哈希(SPH):将 LLM 嵌入蒸馏为紧凑二进制码,克服 SimHash 无法捕捉语义相似性的缺陷
- 注意力加权 MinHash:利用 Transformer 注意力权重抑制模板噪声(导航栏、广告),强调内容核心
- 级联过滤:SPH 粗筛 → 注意力 MinHash 精筛 → LLM 裁决(<1% 神经成本),将 LLM 验证控制在可接受范围
⚠️ SemHash-LLM 为 2026 年 7 月预印本,尚未经同行评议。但其核心思路——用 LLM 嵌入空间判定”语义等价”而非”n-gram 重叠”——代表了 2026 H2 语义去重的重要趋势。
工业趋势信号:NeMo Curator 26.04 已将语义去重作为内置模块(默认启用),后端从 SentenceTransformers 切换为 vLLM,默认模型从 all-MiniLM-L6-v2 升级为 google/embeddinggemma-300m。这标志着工业界已开始从”n-gram 重叠”向”语义等价”过渡。
工程定位:语义去重计算成本显著高于 MinHash(需 GPU + 嵌入推理),建议在 MinHash 粗筛后用语义方法做精筛——而非替代 MinHash。
5.6 Suffix Array:句级去重
文档级去重抓不到”句子级”的重复。例如:
- 文章 A 中有一段是 Wikipedia 内容
- 文章 B 中也有同一段 Wikipedia 内容
- 文章 A 和 B 整体不同,文档级去重抓不到
Suffix Array 用于解决这类句级/段落级重复。
5.6.1 原理
Suffix Array 是字符串所有后缀的有序索引,能在 O(N log N) 时间内找到所有重复子串。
import numpy as np
def suffix_array(text):
"""构建后缀数组(简化版)"""
suffixes = [(text[i:], i) for i in range(len(text))]
suffixes.sort()
return [s[1] for s in suffixes]
def find_duplicate_substrings(texts, min_length=100):
"""
找出所有重复的子串
min_length: 最小重复长度(字符数)
"""
# 把所有文本拼成一个大字符串
# 用分隔符标记文档边界
SEP = '\x00'
combined = SEP.join(texts)
# 构建后缀数组和 LCP(最长公共前缀)数组
sa = suffix_array(combined)
# 计算LCP数组
lcp = compute_lcp(combined, sa)
# 找出 LCP >= min_length 的位置
duplicates = []
for i in range(1, len(lcp)):
if lcp[i] >= min_length:
# 找到重复子串
start = sa[i]
end = start + lcp[i]
duplicate = combined[start:end]
duplicates.append(duplicate)
return duplicates
5.6.2 工业实现:Google re2
工业级实现不用 Python,而是用 C++ 后缀数组库(如 sdsl):
# 推荐工具:datasketch 的 suffix_array 模块
# 或 Lee et al. (2022) 开源的 ccnet 实现
# ccnet 的句级去重命令
python -m ccnet.dedup \
--input data.jsonl \
--output dedup.jsonl \
--min-length 100 \
--method suffix_array
踩坑经验:Suffix Array 的内存复杂度是 O(N)(N 是所有文本字符数)。10TB 文本需要约 20TB 内存——只能用分布式版本(如 Spark 上的实现)。
5.7 训练-评测集去污染
去污染(Decontamination)是去重的反向应用——不是去除训练集内部的重复,而是去除训练集中与评测集重叠的部分。
5.7.1 为什么必须去污染
如果训练集里包含 MMLU 的测试题,模型在 MMLU 上”刷榜”成绩会虚高,但实际能力并不强。
| 评测集 | 题目数 | 估计污染率 |
|---|---|---|
| MMLU | 14,042 | 5-15% |
| GSM8K | 1,319 | 10-20% |
| HumanEval | 164 | 20-40% |
| TriviaQA | 95K | 15-25% |
不去污染,benchmark 成绩不可信。
5.7.2 13-gram 去污染
工业标准做法是13-gram 重叠检测:
def decontaminate(train_texts, eval_texts, n=13):
"""
13-gram 去污染
训练集中如果包含与评测集任一13-gram重叠的内容,整篇删除
"""
# Step 1: 构建评测集的 13-gram 集合
eval_ngrams = set()
for text in eval_texts:
words = text.lower().split()
for i in range(len(words) - n + 1):
ngram = ' '.join(words[i:i+n])
eval_ngrams.add(ngram)
# Step 2: 检查训练集
clean_train = []
contaminated = 0
for text in train_texts:
words = text.lower().split()
is_contaminated = False
for i in range(len(words) - n + 1):
ngram = ' '.join(words[i:i+n])
if ngram in eval_ngrams:
is_contaminated = True
break
if not is_contaminated:
clean_train.append(text)
else:
contaminated += 1
print(f'Contaminated: {contaminated} / {len(train_texts)}')
return clean_train
# 主流评测集清单(必须全部去污染)
EVAL_SETS = [
'mmlu', 'gsm8k', 'human_eval', 'trivia_qa',
'natural_questions', 'arc_challenge', 'arc_easy',
'winogrande', 'hellaswag', 'piqa', 'siqa',
'boolq', 'openbookqa', 'math', 'mbpp',
'copa', 'wsc', 'rte', 'cb', 'wic'
]
5.7.3 为什么是 13-gram?
13 是经验值:
- 太短(如 5-gram):会误杀大量”巧合”重叠
- 太长(如 50-gram):会漏掉”轻微改写”的污染
Brown et al. (2020) 的实验表明:13-gram 是检测”原文泄露”和”过度匹配”的合理平衡点。
5.7.4 去污染的踩坑
坑1:只去题目,没去题目周围的解释
CC 抓取的 benchmark 解读文章里,题目和解释一起出现。如果只匹配题目,会漏掉。
解决:用题目+答案+解释的拼接做 13-gram 匹配。
坑2:评测集翻译版未去污染
某团队用英文 MMLU 做了去污染,但训练数据里有中文版 MMLU 翻译,结果中文 MMLU 成绩虚高。
解决:所有语言的评测集都要做去污染。
坑3:评测集更新后没重新去污染
某团队去污染后,MMLU 升级了版本,新题被训练数据覆盖,结果模型在新版 MMLU 上崩盘。
解决:评测集每次更新都要重新做去污染。
5.8 去重的工程实现:分布式架构
TB 级数据的去重必须分布式实现:
┌──────────────────────────────────────────────────────────────┐
│ 数据分片层 (Spark) │
│ 按 hash(shingle) 分桶,相同 shingle 进入同一桶 │
└──────────────────────────────┬───────────────────────────────┘
│
┌────────────────┼────────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Shard 1 │ │ Shard 2 │ │ Shard N │
│ LSH Index│ │ LSH Index│ │ LSH Index│
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │
└───────────────┼───────────────┘
▼
┌──────────────────────────────────────────────────────────────┐
│ 合并去重层 │
│ 各分片内去重后,再做跨分片的二阶段去重 │
└──────────────────────────────┬───────────────────────────────┘
▼
┌──────────────────────────────────────────────────────────────┐
│ 去重结果存储 │
│ 输出: unique_docs.parquet + dedup_report.json │
└──────────────────────────────────────────────────────────────┘
5.8.1 推荐工具栈
| 工具 | 用途 |
|---|---|
| datasketch | 单机 MinHash+LSH,适合 <100GB |
| Spark + datasketch | 分布式 MinHash,适合 TB 级 |
| ccnet (Facebook) | 工业级流水线,含句级去重 |
| BigQuery | 用 SQL 实现 hash 比对(云上) |
| DuckDB | 单机 TB 级 SQL 查询 |
| NeMo Curator Semantic Dedup | GPU 语义去重(vLLM + EmbeddingGemma-300M),TB 级 |
| FED (arXiv:2501.01046) | GPU 加速 MinHash,>100GB 场景,滚动哈希实现数量级加速 |
5.9 去重的工程参数总览
# 推荐的工业级去重参数
DEDUP_CONFIG = {
# 精确去重
'exact_dedup': {
'enabled': True,
'hash_algorithm': 'sha256',
'normalization': 'lowercase_strip_whitespace'
},
# MinHash 模糊去重(文档级)
'minhash_dedup': {
'enabled': True,
'num_perm': 128,
'shingle_size': 3,
'shingle_unit': 'word', # 中文用 char
'threshold': 0.8,
'lsh_bands': 32,
'lsh_rows': 4
},
# SimHash 网页级去重(可选,与 MinHash 互补)
'simhash_dedup': {
'enabled': False, # 一般用 MinHash 就够了
'hash_bits': 64,
'hamming_threshold': 3
},
# Suffix Array 句级去重
'suffix_array_dedup': {
'enabled': True,
'min_duplicate_length': 100, # 字符
'implementation': 'ccnet' # 或 spark-suffix-array
},
# 评测集去污染
'decontamination': {
'enabled': True,
'ngram_size': 13,
'eval_sets': EVAL_SETS,
'match_method': 'exact_ngram', # 或 'minhash'
'action': 'remove_doc' # 或 'mask_span'
},
<!-- edit: 2026-07-24 新增语义去重配置(基于 NeMo Curator 26.04 TextSemanticDeduplicationWorkflow) -->
# 语义去重(可选,需 GPU)
'semantic_dedup': {
'enabled': False, # 默认关闭,仅在 GPU 可用时开启
'backend': 'vllm', # NeMo Curator 26.04+
'model': 'google/embeddinggemma-300m',
'threshold': 0.95, # Cosine 相似度阈值
'stage': 'post_minhash' # 在 MinHash 精筛后使用,控制 GPU 成本
}
}
5.10 本课总结
- 安全过滤:Detoxify 是工业首选,CSAM 必须严格处理
- 精确去重:SHA256,去掉 5-15% 完全重复
- MinHash+LSH:模糊去重事实标准,参数 (128, 32, 4, 0.8)
- SimHash:网页级去重的备选,速度极快
- Suffix Array:句级去重,抓段落级重复
- 语义去重(前瞻):SemHash-LLM 等多粒度方法利用 LLM 嵌入空间判定语义等价,NeMo Curator 26.04 已内置支持
- 去污染:13-gram 比对,所有评测集必须做
课后练习
- 在 1 万篇文档上实现 MinHash 去重,统计去重率
- 调参对比 (b=32,r=4) vs (b=16,r=8) 在你的数据上的去重率
- 实现 13-gram 去污染,检查你的数据集对 MMLU 的污染率
- 思考:你的数据流水线最薄弱的去重环节是哪一环?
下一章我们将进入隐私脱敏与文档打标——把 PII 信息挡在门外,给每篇文档贴上多维标签,为后续配比打基础。