AI 工程基础体系 · 第 74/100 篇。内容覆盖机器学习、深度学习与生成式 AI;模型、数据、评测、权限和成本会作为同一生产系统处理。

LLM 预训练数据工程:采集、去重、过滤、配比、版权与污染

LLM(Large Language Model,大语言模型)预训练数据工程,是把分散、异构、带有重复、噪声、权限约束和评测风险的数据,转化为可追溯、可复现、适合模型训练的数据产品。

它不是“爬网页并清洗文本”。一个生产级数据集至少同时回答六个问题:

  1. 数据从哪里来,采集时依据的授权和时间点是什么?
  2. 哪些内容是重复的,重复应在文档级、段落级还是语义级消除?
  3. 哪些内容虽然合法可获取,却不适合训练?
  4. 不同来源应该以什么比例进入训练,如何避免某一来源支配模型?
  5. 每条数据的版权、许可、个人信息和使用限制如何追踪?
  6. 评测集、测试集或模型答案是否已经泄漏到训练数据中?

这些问题互相耦合。例如,先粗暴去重再做数据配比,可能改变来源比例;先把所有公开网页放入语料库,再讨论版权,通常已经失去逐条追溯能力;只依据训练损失判断数据质量,则无法发现评测污染和许可证违规。


一、先定义对象:样本、文档、语料库和训练 token

1. 预训练究竟训练什么

以自回归语言模型为例,给定 token 序列:

x=(x1,x2,,xT)x=(x_1,x_2,\ldots,x_T)

模型学习条件概率:

Pθ(x)=t=1TPθ(xtx<t)P_\theta(x)=\prod_{t=1}^{T}P_\theta(x_t\mid x_{<t})

其中:

  • xtx_t 是第 tt 个 token;
  • x<tx_{<t} 是当前位置之前的 token;
  • θ\theta 是模型参数;
  • TT 是序列长度。

训练目标通常是最小化平均负对数似然:

L(θ)=1Ni=1Nt=1TilogPθ(xi,txi,<t)\mathcal{L}(\theta) = -\frac{1}{N} \sum_{i=1}^{N} \sum_{t=1}^{T_i} \log P_\theta(x_{i,t}\mid x_{i,<t})

这里的 NN 可以表示文档数,也可以表示 token 数,具体取决于实现。工程上更常见的是以有效训练 token 数进行统计,因为不同文档长度差异很大。

Transformer 的注意力机制负责让当前位置根据上下文计算表示。原始 Transformer 使用:

Attention(Q,K,V)=softmax(QKdk)V\operatorname{Attention}(Q,K,V) = \operatorname{softmax} \left( \frac{QK^\top}{\sqrt{d_k}} \right)V

但注意力结构本身不会自动判断“这段内容是否有版权”“这篇网页是否重复”“这条记录是否是评测答案”。模型架构解决的是条件建模问题,数据工程解决的是训练信号是否可靠的问题。

2. 数据层级必须分开

“数据集”在不同阶段指不同对象:

层级 含义 典型字段
原始对象 抓取到的 HTML、PDF、代码仓库、书籍文件或 API 响应 URL、响应时间、原始字节、HTTP 状态
文档 从原始对象解析出的逻辑内容 标题、正文、作者、语言、发布日期
样本 经过切分或格式化后交给 tokenizer 的单位 文本、来源、标签、许可
token 序列 tokenizer 输出的整数序列 input_ids、attention mask
训练片段 为满足上下文长度而拼接或切分的 token 块 长度、文档边界、segment 信息

这一区分很重要。两个 HTML 文件可能正文完全相同但广告和导航不同;两个样本可能来自同一个文档的不同段落;两个 token 块可能因为跨文档拼接而没有明确的文档边界。

如果只在最终 token 块上去重,通常已经太晚:

  • 文档被切分后,重复关系更难识别;
  • 同一文档的多个片段仍会重复计权;
  • 训练样本可能跨越文档边界,污染检测和质量分析变得困难。

因此,通常应保留原始对象,先在文档层归一化和去重,再进行样本切分和 token 化。


二、完整数据流:从采集到训练清单

一个可审计的数据管线可以表示为:

flowchart LR
    A[来源登记与授权判断] --> B[原始采集]
    B --> C[原始对象存储]
    C --> D[解析与规范化]
    D --> E[语言/格式/长度过滤]
    E --> F[精确去重]
    F --> G[近似去重]
    G --> H[质量与安全过滤]
    H --> I[来源配比与采样]
    I --> J[训练/验证/测试隔离]
    J --> K[Tokenizer 编码]
    K --> L[训练 shard]
    L --> M[训练运行]
    M --> N[评测与污染审计]

    C -. 原始哈希、URL、时间、许可 .-> P[数据目录与血缘]
    D -. 解析器版本 .-> P
    F -. 去重指纹 .-> P
    H -. 过滤规则与分数 .-> P
    I -. 采样权重 .-> P
    J -. 数据集快照 .-> P

关键路径不是“过滤后直接训练”,而是每个阶段产生不可变快照。例如:

  • raw/2025-01-15/
  • normalized/v3/
  • dedup/v2/
  • filtered/v5/
  • mixture/run-042/
  • train-manifest/commit-a81f...

每个快照都应记录输入快照、代码版本、规则版本、依赖版本和输出统计。否则当模型质量下降时,无法判断是采集源变化、解析器变化、过滤阈值变化,还是采样权重变化。

1. 原始采集:先记录事实,再做判断

采集阶段的核心不是“拿到文本”,而是保存足够的证据:

{
  "object_id": "sha256:...",
  "source_type": "web",
  "source_url": "https://example.com/a",
  "retrieved_at": "2025-01-15T10:30:00Z",
  "http_status": 200,
  "content_type": "text/html",
  "content_length": 182731,
  "raw_sha256": "...",
  "license_claim": "unknown",
  "robots_observed": true,
  "parser_version": null
}

raw_sha256 是原始字节的哈希,text_sha256 则是规范化文本的哈希,二者不能混为一谈。前者用于证明抓到了什么,后者用于判断内容是否重复。

采集系统通常需要处理以下故障:

  • HTTP 429:服务端限流,不能简单无限重试;
  • HTTP 5xx:临时故障,可使用带上限的指数退避;
  • 内容类型与扩展名不一致:必须依据响应头和文件签名判断;
  • 下载中断:临时文件完成校验后再原子重命名;
  • 页面内容随时间变化:同一 URL 必须允许形成多个时间快照;
  • robots 规则变化:至少记录采集时的规则状态,不能事后假设。

并发下载时要设置全局速率限制、每域名并发限制、连接和读取超时。否则即使技术上能抓取,也可能违反来源方的使用条件,或者因为大量失败重试拖垮采集任务。

2. 解析不是纯格式转换

HTML、PDF、扫描件、代码、表格和论坛页面的“正文”定义不同。

例如:

  • HTML 需要去除导航、页脚、广告和重复菜单;
  • PDF 可能存在双栏顺序错误、页眉页脚重复和 OCR 错字;
  • 代码必须保留缩进、换行和注释结构;
  • 表格转换为线性文本时,行列关系可能丢失;
  • 论坛页面需要区分原帖、引用和回复;
  • Markdown 中的链接、代码块、标题层级具有语义。

如果解析器把所有空白压成一个空格,普通散文可能变好处理,但代码的缩进、表格的结构和数学公式会被破坏。因此规范化应按文档类型执行,而不是一个全局 lower() 加空白压缩规则。


三、规范化与去重:删除重复,不等于删除相似

去重的目标是避免同一信息被重复计权,同时尽量保留独立表达、不同版本和有价值的变体。

1. 精确去重

设规范化后的文本为 n(d)n(d),则精确去重使用:

h(d)=SHA256(n(d))h(d)=\operatorname{SHA256}(n(d))

如果两个文档的哈希相同,就认为它们在当前规范化规则下完全相同。

但“规范化”会决定去重结果。例如:

  • 是否统一 Unicode NFC;
  • 是否统一换行符;
  • 是否去掉末尾空白;
  • 是否移除 HTML 导航;
  • 是否大小写折叠;
  • 是否去掉 URL 参数;
  • 是否保留代码大小写。

对代码执行大小写折叠可能把不同变量名错误视为相同内容;对自然语言不处理 Unicode 变体,则可能漏掉明显重复。因此哈希必须和规范化版本一起记录。

2. 近似去重

精确去重无法发现以下情况:

版本 A:Transformer 使用注意力机制建模序列关系。
版本 B:Transformer 通过注意力机制来建模序列之间的关系。

常见近似去重方法是将文档分解为 kk-gram 集合:

S(d)={(x1,,xk),(x2,,xk+1),}S(d)=\{ (x_1,\ldots,x_k), (x_2,\ldots,x_{k+1}), \ldots \}

两个文档的 Jaccard 相似度为:

J(A,B)=ABABJ(A,B)=\frac{|A\cap B|}{|A\cup B|}

J(A,B)J(A,B) 超过阈值时,可以将它们视为近似重复。

直接两两比较的复杂度约为 O(n2)O(n^2),大规模数据不可行。MinHash 用较短签名近似 Jaccard 相似度,LSH(Locality-Sensitive Hashing,局部敏感哈希)再把可能相似的文档放入候选桶,从而把昂贵的精确比较限制在候选集合内。

典型流程是:

  1. 为每个文档生成 shingle;
  2. 计算多个 MinHash 值;
  3. 将签名分成若干 band;
  4. 相同 band 的文档进入候选集合;
  5. 对候选集合重新计算精确 Jaccard 或编辑相似度;
  6. 根据阈值和来源策略决定保留哪个版本。

MinHash 的相似度估计不是规范保证,而是近似算法。阈值越低,召回的重复越多,但误合并风险越高;阈值越高,误删少,但会漏掉改写、模板化复制和局部复制。

3. 文档级、段落级与语义级去重

三种粒度解决不同问题:

  • 文档级去重:适合整页转载、镜像站和数据集重复;
  • 段落级去重:适合网页模板、FAQ、许可证文本和公共定义反复出现;
  • 语义级去重:适合改写或翻译后的高度相似内容,但误删风险最高。

语义向量相似并不等价于“重复”。两篇都解释二叉树的教程可能向量相似,但它们可以提供不同例子、不同推导和不同语言风格。若把相似度模型当作删除规则,可能造成主题坍缩,只剩一种表达。

更稳妥的策略是分层处理:

  1. 先做原始字节和规范化文本的精确去重;
  2. 再做高置信度的近似重复合并;
  3. 对低置信度候选保留多个版本,但降低重复来源的采样权重;
  4. 对高价值、强许可、版本明确的文档保留版本关系,而不是任意删除。

4. 去重的反例:过度去重

假设语料中有:

  • 一份官方 Python 文档;
  • 十份不同作者的教程;
  • 一千份复制官方文档的网页。

删除一千份复制品通常合理。但如果按照“主题相似”删除所有教程,模型会失去不同解释方式和面向初学者的示例。

另一个反例是代码。相同的短函数可能在很多独立项目中出现:

def clamp(x, lo, hi):
    return max(lo, min(hi, x))

它可能是公共惯用实现,也可能是多个项目独立编写的代码。对短代码片段进行强近似去重,会误删真实的编程分布。近似去重应考虑文档长度、语言、仓库关系和许可证,而不是只看相似度。


四、过滤:质量、语言、安全和个人信息是不同维度

“过滤低质量数据”不是一个二元判断。至少应将以下维度分别建模:

q(d)=(qlanguage,qstructure,qquality,qsafety,qpii,qlicense)q(d)= (q_{\text{language}}, q_{\text{structure}}, q_{\text{quality}}, q_{\text{safety}}, q_{\text{pii}}, q_{\text{license}})

其中每个分量都可以是标签、分数或不确定状态。

1. 规则过滤

规则适合处理高精度、可解释的异常:

  • 文本长度低于阈值;
  • 字母数字比例极低;
  • 重复行比例过高;
  • HTML 标签残留严重;
  • 编码替换字符过多;
  • URL、邮箱或电话号码密度异常;
  • 同一句话连续重复;
  • 文档主要由导航、广告或脚本组成。

例如,定义行重复率:

rrepeat=1不同规范化行数非空行数r_{\text{repeat}}= 1-\frac{\text{不同规范化行数}} {\text{非空行数}}

rrepeatr_{\text{repeat}} 接近 1 时,文档很可能是爬虫错误、模板循环或垃圾内容。但代码生成器、日志和法律条款也可能天然包含高重复行,所以规则只能产生候选,不应无条件删除所有高重复文档。

2. 语言识别和混合语言

语言识别用于:

  • 控制不同语言的数据配比;
  • 发现解析错误;
  • 过滤目标范围外的内容;
  • 识别网页中的导航语言与正文语言不一致。

一篇中文文档包含英文代码和数学公式,不应因为字符比例而被错误过滤。语言判定最好同时保存:

  • 主语言;
  • 语言置信度;
  • 语言片段分布;
  • 是否包含代码、公式或表格。

3. 困惑度过滤的正确理解

困惑度(perplexity)衡量模型对文本的预测不确定性。若平均负对数似然为:

(d)=1Tt=1TlogP(xtx<t)\ell(d) = -\frac{1}{T} \sum_{t=1}^{T} \log P(x_t\mid x_{<t})

则:

PPL(d)=e(d)\operatorname{PPL}(d)=e^{\ell(d)}

低困惑度意味着某个参考模型认为文本更可预测,高困惑度意味着更意外。它不是质量分数。

低困惑度文本可能是:

  • 高质量、格式稳定的百科内容;
  • 大量复制的模板;
  • 简单但正确的教程;
  • 已经出现在参考模型训练集中的内容。

高困惑度文本可能是:

  • OCR 错误;
  • 编码损坏;
  • 噪声网页;
  • 数学、代码、少数语言或新领域内容。

因此不能简单地“删除最高困惑度的文本”。正确做法是按语言、文档类型和长度分桶,再结合规则、分类器和人工抽样校准阈值。参考模型的训练数据还可能与目标语料重叠,使困惑度产生误导。

4. 质量分类器和安全过滤

质量分类器可以输出 high_qualityuncertainlow_quality,而不是只输出保留或删除。对不确定样本保留审计队列,便于评估规则变化的影响。

安全过滤需要区分:

  • 训练数据中描述危险内容;
  • 教学性、新闻性或历史性讨论;
  • 明确提供可执行伤害步骤;
  • 个人信息暴露;
  • 仇恨、骚扰或剥削性内容。

训练目标、地区法律和产品用途不同,允许的内容范围也不同。过滤模型本身会有误报和漏报,尤其在少数语言、方言、代码注释和隐喻文本上。过滤决策必须保留规则版本、分类器版本、分数和拒绝原因。

5. 个人信息删除不是简单正则替换

邮箱和电话号码可以用正则表达式初筛,但姓名、地址、身份证号码、医疗信息和上下文中的隐私事实需要实体识别和上下文判断。

脱敏有两种不同目标:

  • 删除:移除整段或整条记录;
  • 替换:把具体值替换为 <EMAIL><PHONE> 等占位符。

替换可能保留句法模式,但也可能改变训练分布;删除可能损失有价值的文本。对高风险数据,通常需要保存原始数据在受限存储中,并让训练快照只包含经过授权的脱敏版本。


五、数据配比:训练 token 的比例不是文件数量的比例

数据配比决定模型看到什么。若代码、中文、英文、数学、书籍和网页分别组成不同来源,来源比例应以实际有效训练 token计算,而不是文件数或字节数。

1. 从目标比例计算采样权重

设来源 ii 的可用 token 数为 NiN_i,目标训练比例为 pip_i,满足:

ipi=1\sum_i p_i=1

如果从来源 ii 均匀抽样,则每个 token 被抽到的概率应满足:

wipiNiw_i \propto \frac{p_i}{N_i}

归一化后:

wi=pi/Nijpj/Njw_i= \frac{p_i/N_i} {\sum_j p_j/N_j}

例如有两个来源:

  • 来源 A:NA=900N_A=900 亿 token;
  • 来源 B:NB=100N_B=100 亿 token;
  • 目标比例:pA=0.7p_A=0.7pB=0.3p_B=0.3

则:

wA0.7900,wB0.3100w_A\propto \frac{0.7}{900}, \qquad w_B\propto \frac{0.3}{100}

两者相除:

wBwA=0.3/1000.7/9003.86\frac{w_B}{w_A} = \frac{0.3/100}{0.7/900} \approx 3.86

也就是说,B 中的每个 token 被抽到的机会约为 A 的 3.86 倍。若直接按文件数量混合,结果不会是 70%/30%。

2. 数据重复采样与有效 token

小来源常需要重复采样。设来源 ii 被采样 eie_i 个 epoch,则它贡献的 token 约为:

Ci=eiNiC_i=e_iN_i

目标比例实际是:

p^i=CijCj\hat p_i=\frac{C_i}{\sum_j C_j}

但重复采样会降低有效多样性。若同一内容重复出现,优化器仍会接收更多梯度,但模型得到的独立信息没有同比增加。因此“训练 token 数”与“独立 token 数”必须同时统计。

可以为每个来源记录:

  • 原始 token 数;
  • 去重后 token 数;
  • 实际采样 token 数;
  • 重复采样倍数;
  • 独立文档数;
  • 估计的重复覆盖率。

3. 配比不是永恒常数

不同训练阶段可能需要不同配比:

  • 早期训练使用广覆盖、低成本的数据;
  • 中后期提高高质量书籍、代码、数学或多语言数据;
  • 领域继续预训练可能只使用目标领域数据;
  • 为缓解灾难性遗忘,领域数据中可混入通用数据。

但改变配比会改变优化轨迹,不能只看最终数据总量。每次配比实验至少要固定:

  • tokenizer;
  • 模型初始化;
  • 总训练 token;
  • 学习率和 batch size;
  • 验证集;
  • 数据处理版本。

否则无法判断质量变化来自配比还是其他变量。

4. 采样实现中的状态问题

分布式训练中,数据混合器需要处理:

  • worker 是否拥有相同的随机种子;
  • 每个 rank 是否拿到重复样本;
  • checkpoint 恢复后是否从正确位置继续;
  • 某个来源耗尽时是否重采样;
  • 过滤后样本数变化是否导致比例漂移;
  • 预取队列中是否残留旧快照样本。

生产系统不应只保存随机种子,还应保存:

  • 数据集快照 ID;
  • 每个来源的采样权重;
  • 当前 shard;
  • shard 内偏移;
  • 全局已消费 token 数;
  • 采样器状态。

否则恢复训练可能表面上从同一个 step 继续,实际上读到了不同数据。


六、版权与权限:可访问不等于可训练

版权和数据权限是法律与治理问题,不是一个简单的 license 字符串。

1. 需要区分四种状态

对每个来源,至少区分:

  1. 访问权限:能否合法访问或下载;
  2. 复制权限:能否把内容复制到内部存储;
  3. 训练使用权限:能否用于模型训练;
  4. 再分发权限:能否公开数据集、权重、输出或衍生物。

“网页公开可见”通常只能说明访问门槛较低,不能自动推出后面三种权限。robots.txt 主要表达自动化访问偏好或规则,不能替代版权许可;反过来,拥有训练许可也不一定允许无视访问控制。

2. 许可证不是单一标签

常见许可证包含不同义务,例如:

  • 署名;
  • 相同方式共享;
  • 非商业限制;
  • 禁止衍生;
  • 许可证文本保留;
  • 版权声明保留;
  • 数据库权利或地区性例外。

对代码还需要区分仓库许可证、单文件版权声明、依赖包许可证和生成文件的来源。一个仓库采用宽松许可证,不代表其中复制进来的第三方代码都拥有相同许可。

应建立来源级和文档级的权限记录:

{
  "document_id": "doc-123",
  "source_id": "site-7",
  "license_spdx": "unknown",
  "license_evidence": "page footer, retrieved 2025-01-15",
  "training_allowed": "review_required",
  "redistribution_allowed": "unknown",
  "attribution_required": null,
  "territory": "unknown",
  "policy_version": "legal-policy-12"
}

unknownallowed 不能合并。未知不是默认允许,也不是默认违法,而是表示需要进一步判断或排除。

3. 数据谱系必须能反向追踪

数据血缘(lineage)是从训练样本追溯到来源证据的关系:

training shardsamplenormalized documentraw objectsource and permission evidence\text{training shard} \rightarrow \text{sample} \rightarrow \text{normalized document} \rightarrow \text{raw object} \rightarrow \text{source and permission evidence}

应至少保存:

  • 原始内容哈希;
  • 规范化文本哈希;
  • 来源 URL 或仓库提交;
  • 采集时间;
  • 解析器版本;
  • 过滤结果;
  • 去重组 ID;
  • 许可证证据;
  • 进入哪些数据集快照;
  • 训练作业和模型版本。

这样,当某个权利人提出删除请求,系统才能定位受影响文档、数据快照、训练任务和发布物。需要注意,删除训练数据并不等于能够从已经训练完成的模型中精确删除其影响;后者通常需要专门的机器遗忘或重新训练方法,不能作过度承诺。


七、污染:训练数据与评测数据的边界失效

数据污染(data contamination)是指本不应参与训练或调参的数据,已经以相同或近似形式进入训练流程。

至少有三类污染:

  1. 训练集—测试集污染:测试样本或其近似版本出现在预训练数据中;
  2. 调参污染:开发集被反复用于选择超参数,导致结果过度乐观;
  3. 时间污染:训练使用了评测时点之后才公开的信息。

污染不要求全文完全相同。以下都可能构成污染:

  • 题目原文被转载;
  • 答案出现在教程或论坛;
  • 题目被轻微改写;
  • 评测数据被翻译;
  • 代码题的函数签名和标准答案同时出现;
  • benchmark 解析文章包含完整解答。

1. 为什么污染会扭曲指标

假设测试样本 zz 以概率 cc 被模型训练见过。若未污染样本的正确率为 aa,污染样本的正确率为 bb,总体准确率近似为:

Acc=(1c)a+cb\operatorname{Acc}=(1-c)a+cb

通常 b>ab>a,因此污染会让指标上升,但这个上升不代表模型具备同等程度的泛化能力。

对于生成任务,污染还可能表现为:

  • 输出与参考答案高度相似;
  • 直接复现题库解释;
  • 在固定措辞上异常自信;
  • 对训练时间之后的新题表现明显下降。

2. 去重与污染检测的正确顺序

必须在划分训练、验证、测试集之前进行全局去重和污染检测。若先划分,再分别去重,可能出现:

  • 训练集和测试集分别保留同一文档;
  • 两边使用不同规范化规则;
  • 近似重复只在单个分区内比较,跨分区泄漏无法发现。

检测可以分层进行:

  1. 规范化文本哈希;
  2. n-gram 重叠;
  3. MinHash 或其他近似指纹;
  4. 题目、答案、代码签名的结构化匹配;
  5. 语义相似度和人工复核。

一个简单的 n-gram 重叠指标是:

R(d,e)=S(d)S(e)min(S(d),S(e))R(d,e)= \frac{|S(d)\cap S(e)|}{\min(|S(d)|,|S(e)|)}

它与 Jaccard 不同:分母取较小集合,更适合发现“短测试题被长网页包含”的情况。但它仍然会误报通用句子、模板和常见代码片段。

3. 时间切分解决什么问题

若要衡量模型对未来信息的泛化能力,应使用时间切分:

train time<validation time<test time\text{train time}<\text{validation time}<\text{test time}

同时记录网页的发布日期、仓库提交时间、数据集发布时间和实际抓取时间。抓取时间不能替代内容发布时间:一篇 2020 年发布、2025 年抓取的文章,仍然属于 2020 年的信息。

时间切分也有边界:

  • 内容可能被编辑,发布日期不可靠;
  • 网页转载会产生多个时间;
  • 代码仓库的提交时间和首次公开时间不同;
  • 数据集可能在测试集发布前已被镜像。

因此时间字段应保留证据等级,而不是假设全部准确。


八、一个可运行的最小处理示例

下面的 Python 示例只演示一个小规模、可审计的处理骨架:规范化、精确去重、基于字符 shingle 的近似去重和简单规则过滤。它不适合直接处理生产规模数据,也不替代许可证审查、PII 检测或高质量分类器。

from __future__ import annotations

import hashlib
import re
import unicodedata
from dataclasses import dataclass, asdict


@dataclass
class Document:
    doc_id: str
    source: str
    text: str
    license_status: str = "unknown"


def normalize(text: str) -> str:
    # NFC 统一 Unicode 表示,避免视觉相同但字节不同
    text = unicodedata.normalize("NFC", text)
    text = text.replace("\r\n", "\n").replace("\r", "\n")

    # 去除每行首尾空白,但不把换行全部删除
    lines = [line.strip() for line in text.split("\n")]

    # 连续空行压缩,保留段落边界
    output = []
    previous_blank = False
    for line in lines:
        blank = (line == "")
        if blank and previous_blank:
            continue
        output.append(line)
        previous_blank = blank

    return "\n".join(output).strip()


def sha256(text: str) -> str:
    return hashlib.sha256(text.encode("utf-8")).hexdigest()


def shingles(text: str, k: int = 5) -> set[str]:
    # 示例使用字符 shingle,生产中应按语言和文档类型选择 token/词/字符粒度
    compact = re.sub(r"\s+", " ", text)
    if len(compact) < k:
        return {compact} if compact else set()
    return {compact[i:i + k] for i in range(len(compact) - k + 1)}


def jaccard(a: set[str], b: set[str]) -> float:
    union = a | b
    return len(a & b) / len(union) if union else 1.0


def passes_rules(text: str) -> tuple[bool, list[str]]:
    reasons = []
    if len(text) < 40:
        reasons.append("too_short")

    replacement_ratio = text.count("\ufffd") / max(len(text), 1)
    if replacement_ratio > 0.01:
        reasons.append("encoding_damage")

    lines = [x for x in text.splitlines() if x]
    if lines:
        repeated_ratio = 1 - len(set(lines)) / len(lines)
        if repeated_ratio > 0.8:
            reasons.append("many_repeated_lines")

    return not reasons, reasons


def process(documents: list[Document], near_dup_threshold: float = 0.85):
    # 1. 规范化并保存可追踪的规范化哈希
    normalized = []
    for doc in documents:
        text = normalize(doc.text)
        normalized.append({
            "doc": doc,
            "text": text,
            "norm_sha256": sha256(text),
        })

    # 2. 精确去重:同一规范化哈希只保留第一条
    exact_seen = set()
    exact_unique = []
    for item in normalized:
        if item["norm_sha256"] in exact_seen:
            continue
        exact_seen.add(item["norm_sha256"])
        exact_unique.append(item)

    # 3. 规则过滤:保留拒绝原因
    filtered = []
    rejected = []
    for item in exact_unique:
        ok, reasons = passes_rules(item["text"])
        item["filter_reasons"] = reasons
        if ok:
            filtered.append(item)
        else:
            rejected.append(item)

    # 4. 小规模近似去重。生产系统应使用倒排索引、MinHash/LSH 等方法。
    kept = []
    duplicate_of = {}
    signatures = []

    for item in filtered:
        sig = shingles(item["text"])
        found = None

        for kept_item, kept_sig in signatures:
            if jaccard(sig, kept_sig) >= near_dup_threshold:
                found = kept_item["doc"].doc_id
                break

        if found is None:
            kept.append(item)
            signatures.append((item, sig))
        else:
            duplicate_of[item["doc"].doc_id] = found

    return kept, rejected, duplicate_of


if __name__ == "__main__":
    docs = [
        Document("a", "source-A", "Transformer 使用注意力机制建模序列关系。\n"),
        Document("b", "source-B", "Transformer 使用注意力机制建模序列关系。\n"),
        Document("c", "source-C", "这是一段长度足够、表达不同的教程内容,应该被保留下来。"),
        Document("d", "source-D", "x\nx\nx\nx\nx\nx\nx\nx\nx\nx\n"),
    ]

    kept, rejected, duplicate_of = process(docs)

    print("kept:")
    for item in kept:
        print({
            "doc_id": item["doc"].doc_id,
            "source": item["doc"].source,
            "norm_sha256": item["norm_sha256"],
        })

    print("\nrejected:")
    for item in rejected:
        print(item["doc"].doc_id, item["filter_reasons"])

    print("\nnear_duplicates:")
    print(duplicate_of)

在这组输入中,预期现象是:

  • a 被保留;
  • b 因规范化后哈希相同,被精确去重;
  • c 通过长度和重复行检查;
  • d 因重复行比例过高被拒绝。

这个示例有几个重要边界:

  1. 字符 shingle 对中文、英文和代码的表现不同,不能直接使用同一个阈值;
  2. 只保留第一条文档会丢失许可证更明确的副本,生产系统应按授权、来源可信度、版本和完整性选择代表;
  3. 近似去重的两两比较是 O(n2)O(n^2),百万级文档会不可接受;
  4. 规则过滤没有理解语义,不能判断文本是否事实正确;
  5. license_status 没有被自动判定,故意保留为治理字段而不是伪装成技术结论。

九、训练数据快照、分片与可恢复性

数据处理完成后,应生成不可变 manifest。一个 manifest 不只是文件列表,还应描述每个分片的身份和统计:

{
  "dataset_id": "mixture-042",
  "parent_snapshots": {
    "normalized": "normalized-v3",
    "dedup": "dedup-v2",
    "filtered": "filtered-v5"
  },
  "tokenizer": {
    "name": "tokenizer-commit-abc",
    "vocab_hash": "..."
  },
  "shards": [
    {
      "path": "shard-00017.parquet",
      "sha256": "...",
      "tokens": 125000000,
      "sources": {
        "code": 0.20,
        "web": 0.60,
        "books": 0.20
      }
    }
  ],
  "rules": {
    "filter_policy": "filter-policy-12",
    "dedup_policy": "dedup-policy-7"
  }
}

训练读取分片时,应先校验哈希,再加入队列。常见错误路径包括:

  • 分片文件上传不完整,但对象存储已返回成功;
  • manifest 更新后,训练 worker 仍缓存旧版本;
  • 训练过程中覆盖同名分片;
  • worker 在重试时重复提交同一 shard;
  • 数据源过滤规则更新,导致同一数据集 ID 内容变化。

因此分片应采用内容寻址或不可变路径,输出使用临时路径加原子提交,manifest 也应带版本和哈希。训练作业启动时固定 manifest,不允许运行中隐式读取“最新数据”。


十、如何诊断数据问题

1. 训练损失下降但能力变差

可能原因包括:

  • 重复数据过多,模型记忆模板而非学习新模式;
  • 高质量来源被低质量网页稀释;
  • 过滤器误删了代码、数学或少数语言;
  • 配比实现错误,实际比例与目标比例不符;
  • tokenizer 版本变化导致 token 统计和长度分布改变;
  • 训练数据包含评测集,指标虚高。

诊断不应只看总 loss,应按切片统计:

  • 来源;
  • 语言;
  • 文档类型;
  • 文档长度;
  • 去重组;
  • 时间区间;
  • 许可证状态;
  • 过滤分数区间。

如果某个来源的 loss 极低但验证集没有改善,可能是重复和模板化,而不是高质量。

2. 评测异常高

首先检查:

  1. 评测样本的精确哈希是否出现在训练文档;
  2. n-gram 重叠是否异常;
  3. 题目和答案是否出现在同一来源或转载页面;
  4. 评测数据发布时间是否早于训练快照;
  5. 调参是否多次使用公开测试集;
  6. 生成结果是否只是复现固定答案。

可构建“污染审计集”:一部分样本只在训练后公开,另一部分样本保持私有。对公开 benchmark 的结果,应同时报告污染检测方法和不能检测的边界,而不是只报告一个数字。

3. 数据量增加但效果没有增加

数据量可能增加了以下“无效 token”:

  • 重复转载;
  • 导航和模板;
  • 极短文本;
  • 编码损坏;
  • 低信息密度列表;
  • 被强烈过采样的小来源;
  • 无法被 tokenizer 有效表达的格式噪声。

此时应比较:

有效信息密度去重后有效 token总 token\text{有效信息密度} \approx \frac{\text{去重后有效 token}} {\text{总 token}}

这不是严格的质量定律,但可以作为数据审计指标。还应按训练 token 成本计算边际收益,而不是只看存储规模。


十一、生产取舍:质量、覆盖、权限、成本和可复现性

1. 更严格过滤不一定更好

过滤会减少噪声,也会减少覆盖。过于严格的语言检测可能删除方言和混合语言;过于严格的安全分类可能删除必要的医学、法律和历史讨论;过于严格的代码规则可能删除真实项目中的重复模式。

因此应保留三类结果:

  • accept:自动接收;
  • reject:自动拒绝;
  • review:进入抽样或人工审核。

阈值调整时,用固定审计样本测量误报和漏报,而不是只观察总 token 数变化。

2. 版权更严格可能提高成本,但降低系统性风险

完整保存来源证据、许可证文本、版本关系和删除索引,会增加存储和处理成本;对未知权限数据进行人工审查,也会降低可用数据规模。

但如果数据来源无法追溯,后续无法:

  • 响应删除或授权请求;
  • 解释模型训练来源;
  • 重建同一训练集;
  • 证明某个发布物使用了哪些许可;
  • 定位某次质量回归的根因。

成本应按“数据进入训练后再返工”的风险评估,而不是只看抓取阶段的费用。

3. 数据工程与模型、评测、权限和成本必须放在同一生产系统里

一个数据集不是独立的压缩包,而是模型生产链中的输入资产。至少应建立以下关联:

数据快照
  -> tokenizer 版本
  -> 训练作业
  -> 模型 checkpoint
  -> 评测结果
  -> 发布版本
  -> 权限与删除记录
  -> 计算和存储成本

当训练预算固定时,重复数据和无效 token 会直接占用计算;当评测集被污染时,模型选择会被误导;当许可证信息缺失时,发布模型可能承担无法量化的合规风险。


十二、最终验收应检查什么

一个可用于预训练的数据快照,至少应满足以下可验证条件:

  • 每个文档都能追溯到原始对象、采集时间和来源;
  • 原始哈希与规范化哈希分开保存;
  • 去重规则、指纹算法和阈值有版本;
  • 过滤结果包含原因和模型或规则版本;
  • 语言、文档类型、长度和来源分布可统计;
  • 配比按有效 token 计算,并能验证实际比例;
  • 训练、验证、测试在全局去重和污染检查后再隔离;
  • 评测集有时间信息和污染审计结果;
  • 未知许可证不会被静默视为允许;
  • PII 和安全过滤保留处理证据;
  • tokenizer 版本固定且可重建;
  • manifest、分片和训练读取状态可校验、可恢复;
  • 同一输入快照、代码版本和配置能够重建同一输出,或清楚说明非确定性来源。

预训练数据工程的核心不是把数据“清洗得更干净”,而是让每一个训练 token 都有可解释的来源、可验证的处理过程、明确的权限状态和可衡量的训练价值。只有这样,模型的能力、评测结果、版权边界和计算成本,才真正处于同一条可审计的生产链上。


系列导航与关联阅读

官方资料

本文依据研究论文、标准组织与主流框架官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。