第5讲:数据工程
5.5 质量过滤
SFT 数据质量过滤的信号谱系、实用决策树与多样性度量
质量过滤的两层原则
面对一个"待入库"的 SFT 候选池,如何快速筛出高质量子集?工程实践总结出两层原则:
- 硬规则(Hard Rules):先用不可商量的条件剔除——长度越界、语言错误、格式非法、毒性过阈值;
- 软指标(Soft Signals):在通过硬规则的样本上,按 Perplexity、多样性、质量评分做排序 + 截断。
一、过滤信号谱系
1.1 Perplexity Filter(困惑度过滤)
用一个参考语言模型(如 GPT-2、Qwen-base)对样本计算困惑度:
两种用法:
- 高 PPL 剔除:过于"离谱"的样本(乱码、错别字堆积、不连贯回复);
- 低 PPL 剔除:过于"套路化"的样本(模板回复、同质化输出)——这一点在合成数据中尤为重要,因为 GPT-4 合成的回复往往集中在困惑度分布的低峰。
实用观察:健康的 SFT 数据集的 PPL 分布应近似对数正态(log-normal),呈现一个单峰。如果你看到双峰分布,多半是合成数据与人工数据混合,需要分别调试。
1.2 n-gram 覆盖
衡量回复与指令之间的 n-gram 重叠率:
- 过高(>0.8):模型"复述指令而非回答",是 SFT 失败的典型信号;
- 过低(<0.05):可能跑题,或回复与指令毫不相关。
1.3 嵌入语义去重
基于 Sentence Embedding 做近似重复检测:
from sentence_transformers import SentenceTransformer
import numpy as np
model = SentenceTransformer("BAAI/bge-m3")
embs = model.encode([s["user_msg"] for s in samples], normalize_embeddings=True)
# 构建相似度矩阵(大数据集用 FAISS)
sim = embs @ embs.T
to_remove = set()
for i in range(len(samples)):
if i in to_remove:
continue
dup_idx = np.where(sim[i] > 0.95)[0]
to_remove.update(dup_idx[dup_idx > i]) # 保留首个大规模数据(100 万+)时改用 MinHash-LSH 或 FAISS-IVF。
1.4 毒性检测
毒性检测器(如 unitary/toxic-bert、Perspective API)对每条样本的 user 与 assistant 分别打分。决策规则:
| 场景 | 处理 |
|---|---|
| User 毒性高、Assistant 拒答 | 保留(安全样本) |
| User 毒性高、Assistant 配合 | 剔除(越狱泄漏) |
| User 正常、Assistant 毒性高 | 剔除(错误回复) |
1.5 长度分布
import numpy as np
lens = [len(s["assistant_msg"]) for s in samples]
print(f"p50={np.percentile(lens, 50):.0f} p95={np.percentile(lens, 95):.0f}")经验阈值:中文 assistant 长度通常保留在 30-2000 字符之间。极短(< 30)多为"好的/明白了"无信息回复,极长(> 2000)多为模型"啰嗦失控"。
1.6 多样性指标(Diversity Score)
多样性是 SFT 数据的"天花板信号"——LIMA 论文反复强调。常见度量:
Distinct-n:回复中独立 n-gram 的比例:
Self-BLEU(越低越多样):对每条样本计算它与其它样本的 BLEU 平均值,反向衡量相似度。
Embedding Coverage:
- 将所有样本嵌入到 ;
- 聚类(K-Means, K=50);
- 统计每个簇的样本数;理想分布是接近均匀。过度集中于少数簇意味着任务多样性不足。
二、过滤决策树(工程实践)
步骤 1:硬规则剔除(一票否决)
- 长度:user ∈ [5, 4000],assistant ∈ [30, 3000](按业务调整);
- 语言:用
fasttext-langid验证是否为目标语言; - 格式:JSON 合法、字段齐全;
- 毒性:超过阈值(如 0.7)的 assistant 直接剔除。
步骤 2:去重(必做)
- 精确去重:user + assistant 的 SHA256;
- 近似去重:BGE 向量 cosine > 0.95 仅保留首个。
步骤 4:截断与抽检
按目标规模(如 1 万条)截取 Top-K,然后人工抽检 50-100 条。这一步必不可少——任何自动指标都有盲区。
三、一个完整的过滤 Pipeline 骨架
import json
import numpy as np
from pathlib import Path
from sentence_transformers import SentenceTransformer
def filter_pipeline(raw_samples, target_size=1000):
# ===== 1. 硬规则 =====
hard = []
for s in raw_samples:
u, a = s["user_msg"], s["assistant_msg"]
if not (5 <= len(u) <= 4000): continue
if not (30 <= len(a) <= 3000): continue
if s.get("toxic_score", 0) > 0.7: continue
hard.append(s)
print(f"硬规则后: {len(hard)}")
# ===== 2. 去重 =====
seen = set()
dedup = []
for s in hard:
key = (s["user_msg"].strip(), s["assistant_msg"].strip())
if key in seen: continue
seen.add(key)
dedup.append(s)
print(f"精确去重后: {len(dedup)}")
# ===== 3. 语义去重 =====
emb_model = SentenceTransformer("BAAI/bge-m3")
embs = emb_model.encode(
[s["user_msg"] for s in dedup],
normalize_embeddings=True,
batch_size=64,
)
keep = np.ones(len(dedup), dtype=bool)
for i in range(len(dedup)):
if not keep[i]: continue
sim = embs[i] @ embs[i+1:].T
keep[i+1:][sim > 0.95] = False
semantic = [s for s, k in zip(dedup, keep) if k]
print(f"语义去重后: {len(semantic)}")
# ===== 4. 综合打分 =====
for s in semantic:
s["_score"] = (
-0.3 * s.get("ppl", 50)
+ 0.5 * s.get("judge_score", 0)
+ 0.2 * s.get("div_bonus", 0)
)
semantic.sort(key=lambda x: -x["_score"])
# ===== 5. 截断 =====
return semantic[:target_size]四、多样性 vs 质量的权衡
这是做数据时最常见的"灵魂拷问"。核心结论:
| 选择 | 什么时候做 |
|---|---|
| 质量优先 | 目标是明确的垂直任务(法律咨询、病历摘要),风格需要高度统一 |
| 多样性优先 | 目标是通用助手,需要覆盖多种任务类型 |
| 混合(推荐) | 先按质量截断到 2×目标量,再按多样性从中选 1×目标量 |
陷阱:只看质量 Judge 得分,会让数据集塌缩成"标准答案堆",模型微调后极容易过拟合到同一种行文模板。务必加入多样性约束。
本节小结
| 信号 | 作用 | 阈值经验(中文) |
|---|---|---|
| Perplexity | 剔除乱码与过度模板化 | PPL > 100 剔除;PPL < 5 抽检 |
| n-gram 覆盖 | 检测复述/跑题 | 0.05 < overlap < 0.8 |
| 语义去重 | 消除近似重复 | cosine > 0.95 |
| 毒性 | 安全兜底 | score > 0.7 剔除 |
| 长度 | 过滤失控输出 | assistant 30-3000 字符 |
| 多样性 | 防止塌缩 | Embedding 50 簇,最大占比 < 15% |