人工智能实践(语言智能)
第5讲:数据工程

5.5 质量过滤

SFT 数据质量过滤的信号谱系、实用决策树与多样性度量

质量过滤的两层原则

面对一个"待入库"的 SFT 候选池,如何快速筛出高质量子集?工程实践总结出两层原则

  1. 硬规则(Hard Rules):先用不可商量的条件剔除——长度越界、语言错误、格式非法、毒性过阈值;
  2. 软指标(Soft Signals):在通过硬规则的样本上,按 Perplexity、多样性、质量评分做排序 + 截断

一、过滤信号谱系

1.1 Perplexity Filter(困惑度过滤)

用一个参考语言模型(如 GPT-2、Qwen-base)对样本计算困惑度:

PPL(x)=exp(1Tt=1TlogPref(xtx<t))\text{PPL}(x) = \exp\left(-\frac{1}{T}\sum_{t=1}^{T} \log P_{\text{ref}}(x_t | x_{<t})\right)

两种用法

  • 高 PPL 剔除:过于"离谱"的样本(乱码、错别字堆积、不连贯回复);
  • 低 PPL 剔除:过于"套路化"的样本(模板回复、同质化输出)——这一点在合成数据中尤为重要,因为 GPT-4 合成的回复往往集中在困惑度分布的低峰。

实用观察:健康的 SFT 数据集的 PPL 分布应近似对数正态(log-normal),呈现一个单峰。如果你看到双峰分布,多半是合成数据与人工数据混合,需要分别调试。

1.2 n-gram 覆盖

衡量回复与指令之间的 n-gram 重叠率:

Overlapn(q,a)=ngramsn(q)ngramsn(a)ngramsn(q)\text{Overlap}_n(q, a) = \frac{|\text{ngrams}_n(q) \cap \text{ngrams}_n(a)|}{|\text{ngrams}_n(q)|}
  • 过高(>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 的比例:

Distinct-n=unique n-grams in all responsestotal n-grams\text{Distinct-}n = \frac{|\text{unique n-grams in all responses}|}{|\text{total n-grams}|}

Self-BLEU(越低越多样):对每条样本计算它与其它样本的 BLEU 平均值,反向衡量相似度。

Embedding Coverage

  1. 将所有样本嵌入到 Rd\mathbb{R}^d
  2. 聚类(K-Means, K=50);
  3. 统计每个簇的样本数;理想分布是接近均匀。过度集中于少数簇意味着任务多样性不足。

二、过滤决策树(工程实践)

步骤 1:硬规则剔除(一票否决)

  • 长度:user ∈ [5, 4000],assistant ∈ [30, 3000](按业务调整);
  • 语言:用 fasttext-langid 验证是否为目标语言;
  • 格式:JSON 合法、字段齐全;
  • 毒性:超过阈值(如 0.7)的 assistant 直接剔除。

步骤 2:去重(必做)

  • 精确去重:user + assistant 的 SHA256;
  • 近似去重:BGE 向量 cosine > 0.95 仅保留首个。

步骤 3:软指标排序

每条样本按综合得分排序:

Score=w1(PPL)+w2Judge+w3DivBonus\text{Score} = w_1 \cdot (-\text{PPL}) + w_2 \cdot \text{Judge} + w_3 \cdot \text{DivBonus}

其中 DivBonus 是簇多样性奖励(属于稀有簇的样本加分)。

步骤 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%