Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

预训练数据工程

上一章,我们给模型准备了 256 条短句。每句话从哪里来、颜色是否前后一致,扫一眼就能看清。假如语料换成几百万份网页,这种办法就不够用了。

同一篇文章可能换了三个网址;正文之间夹着菜单、广告和脚本;一份看似普通的问答,可能恰好是准备用来测试模型的题目。文件夹越来越大,模型能学到的东西却未必按同样的速度增加。

这一章先不训练网络。我们来搭一条小小的数据处理流水线,让每条文本带着来历进入系统,也带着处理记录离开。示例全部由本书编写,不需要下载网页或提交个人资料。

1. 先问“这是什么数据”,再问“有多少数据”

想象你收到一个压缩包,里面是十万份文本。文件名很整齐,编码也一致。能直接拿去训练吗?

至少还有几个问题没有答案:它们来自哪里?谁提供的?允许怎样使用?什么时候收集的?里面是否混着私人通信?如果后来发现某个来源不该出现,能否找到受影响的训练版本?

因此,一条训练记录通常不只有 text:

字段它帮我们回答的问题
稳定的记录 ID讨论的是哪一条,而不是“第几行附近”
来源及原始标识从哪份文档、哪个版本取得
授权与使用条件允许做什么,依据保存在哪里
语言、领域、时间等信息这批材料代表了什么,又缺少什么
清洗版本、哈希与处理记录发生过哪些修改,进入了哪次训练
重复组与数据划分它的近亲有没有落入验证集或测试集

“可以公开访问”只是取得文本的条件之一,不能替代授权与使用条件的确认。示例把一条记录标为“授权待确认”,处理程序先将它留在候选集之外;这只是模拟工作流,真实项目需要检查真实的来源和条件。

网页、书籍、代码、对话和合成文本,各有自己的脾气。代码中的缩进可能决定语义;对话有说话人和先后关系;合成文本需要记下生成模型、提示和筛选方法。把这些差别全压成一列字符串,后面就很难解释模型到底学过什么。

Datasheets for Datasets 提倡记录数据的动机、组成、收集过程和适用场景。我们的小表格可以看作这个想法的起点:数据也需要一份说明书。

2. 清洗:让正文露出来,也保住有用的信息

先看两份输入:

打印报告前,请检查纸张余量,并确认打印机已经连接。
<p>打印报告前,请检查纸张余量,并确认打印机已经连接。</p>

对阅读这句话的人来说,它们几乎一样。对按字节计算哈希的程序来说,它们完全不同。去掉 HTML 包装以后,再做重复检查,才能发现它们说的是同一句话。

示例依次提取文本、统一 Unicode 的 NFC 表示、合并连续空白,再替换一种明显的邮箱格式。这样处理适合这里的短说明文本;如果输入是 Python 源码,压缩空白就可能把程序弄坏。

清洗规则也会改变分布。例如,按“字母和数字所占比例”过滤,可以清掉一串标点噪声,也可能误删公式、表格和特殊书写方式。短文本不一定没价值,“请关门”很短,却是完整的指令。因此,我们把示例中的“至少 12 个字符、字母或数字比例不低于 0.6”称为格式筛选,不把它当成通用的质量标准。

质量更接近一个需要验证的问题:保留这类数据以后,目标任务有没有改善?FineWeb 的研究 对过滤和去重策略做了成组对照。它值得学习的地方,是把处理选择放回实验中比较,而不是给所有语料套同一张“好文本”清单。

替换一个邮箱,不等于消除了隐私问题

示例中有一段虚构联系方式:

请联系 demo@example.org,并写明错误信息。

程序把邮箱替换为 [EMAIL],同时记下替换次数。这里使用保留的示例域名,不涉及真实联系人。

不过,正则表达式可能漏掉“名字 at 域名”这样的变体,也无法理解“住在某栋楼、每天几点出门”组合起来暴露了什么。删除直接标识符,也不能保证剩余内容无法关联到个人。真实的数据处理还需要来源控制、适合场景的识别与复核,以及对原始材料和处理日志的访问管理。原始记录值得保留以便追查,但不该因为要“可复现”就随意公开。

3. 去重:删掉副本,还要认出改过一点的副本

完全重复比较容易处理:对清洗后的文本计算哈希,相同哈希的记录只保留一份,同时把多个原始 ID 留在来源记录中。实际系统也可以在哈希相同后再核对文本。

稍微改动一下就麻烦了:

打印报告前,请检查纸张余量,并确认打印机已经连接。
打印报告前,请检查纸张余量,并确认打印机已经连接!

最后一个标点不同,哈希就不同。可它们提供的信息几乎一样。

把文本切成重叠的小片段

这里用一种朴素办法:把文本切成连续的三个字符,得到一个集合。例如:

训练模型之前
→ 训练模、练模型、模型之、型之前

这种重叠片段叫 shingle。取两段文本的片段集合 AA、BB,计算 Jaccard 相似度:

J(A,B)=∣A∩B∣∣A∪B∣ J(A,B)=\frac{|A\cap B|}{|A\cup B|}

A∩BA\cap B 是共同片段,A∪BA\cup B 是两边出现过的全部不同片段,竖线表示集合里有多少项。两边完全一样时得 1,没有共同片段时得 0。

示例的句号版与感叹号版得到 0.917;“分为训练集”与“划分为训练集”这对文本得到 0.808。我们先把阈值设为 0.72,找出候选近重复对。阈值是演示选择,没有经过大语料调优。

这种方法比较的是表面片段,并不理解含义。“允许”和“不允许”只差一个字,却可能改变整段意思。相似度高的文本适合进一步处理,不能自动认定哪一份没有价值。

数据很大时,逐对比较的数量会快速增长。nn 条文本共有:

n(n−1)2 \frac{n(n-1)}{2}

个不同文本对。实际工程会用指纹、MinHash 等办法先缩小候选范围,再比较候选;文档级去重之外,还可以检测长的重复子串。Lee 等人的研究 展示了去重对记忆训练文本、训练效率以及训练与评测重叠的影响。这里的九条文本足够小,我们先保留最容易检查的逐对实现。

划分时,把近亲放在一起

假设句号版在训练集,感叹号版在测试集。模型即使只是记住原句,也可能答得很好。整句没有完全重复,并不能说明测试足够独立。

我们的处理方式是:完全重复只留一份;近重复暂时保留,连接成组,再按组划分训练、验证和测试数据。这样可以避免已检测到的近重复跨越划分边界。

图 1:最后的 7 是组数,不是又删掉了两条文本。9 条文本仍然保留,按 7 个组分配。

分组也有一个细节:A 像 B,B 像 C,程序会把三者连成一组,即使 A 与 C 没达到相似度阈值。连通分组便于避免交叉泄漏,却可能形成很大的组,需要检查。

同样,按组划分 70% 不保证恰好得到 70% 的文档,更不保证 70% 的词元。组的大小不一样,我们应报告实际数量。

4. 评测污染:题目提前进了课本

如果准备用于检验模型的题目和答案早已进入训练数据,评测结果就混入了“见过题”的影响。这通常称为评测污染。

示例预先登记了一句受保护的评测文本。处理候选训练记录时,发现有人把它嵌进“示例试题:……”里,就排除这条候选记录。这里是在训练前执行预先确定的隔离规则,没有用测试成绩反复挑训练数据。

但这只是精确片段匹配:改写、翻译、答案泄漏、同源材料和没有登记的题目都可能漏过。真实检查需要结合来源、近重复检索和人工抽样;报告中也要说明检查了什么。一次“没有命中”,不能变成“绝无污染”的证明。

日常验证数据和最终测试数据也需要分工。我们可以依据验证表现调整数据处理规则;最终测试成绩应留到方案选定以后再看。否则,清洗程序本身也会逐渐迎合测试集。

5. 配比:数量最多的来源,要占最多课时吗

假设三个领域分别有 900、90、10 个词元。完全按数量混合,三者占比是 90%、9%、1%。最后那个小领域可能很少被模型看到。

一种常见的配比写法是:

pk=nkα∑jnjα p_k=\frac{n_k^\alpha}{\sum_j n_j^\alpha}

nkn_k 是第 kk 个来源的词元量,pkp_k 是分配给它的训练词元份额,α\alpha 控制配比有多偏向大来源。α=1\alpha=1 恢复原比例;α=0.5\alpha=0.5 就先开平方再归一化。

图 2:这是独立的假设数量,不是前面九条文本的统计。百分比经过四舍五入,显示值的和可能略偏离 100%。

平方根配比大约是 70.3%、22.2%、7.4%。小领域得到更多练习机会,不过它的原始内容并没有增加:固定训练总量下,重复阅读可能更多。这能否改善目标任务,要靠对照实验判断。

还要区分“抽到某类文档的概率”和“这类文档贡献的词元比例”。一份说明只有几十字,一份代码有几千行,按文档各抽一半,词元不会各占一半。本例直接演示词元份额;真正的采样器需要据此组织文本或词元块,并记录实际交付量。

6. 动手跑一条小流水线

保存或下载 pretraining_data.py,用 Python 标准库运行:

python3 src/examples/pretraining_data.py

输出为:

原始记录:14
来源筛选后:13
格式筛选后:11
污染筛查后:10
完全去重后:9
近重复分组数:7
未保留:r02 规范化后完全重复;r05 授权待确认;r07 长度/字符比例筛选;r08 长度/字符比例筛选;r09 命中受保护评测文本
近重复:r01/r03=0.917;r04/r13=0.808
邮箱替换次数:1
训练:r04, r10, r12, r13, r14
验证:r11
测试:r01, r03, r06
配比示例的词元量:900、90、10(与上述小语料无关)
alpha=1.0:90.0%、9.0%、1.0%
alpha=0.5:70.3%、22.2%、7.4%

验证集只有一条,当然不足以判断语言模型好不好。这次实验检验的是处理逻辑:r01 与 r03 没有分家,r04 与 r13 也没有;r02 虽然没有重复存储,仍能通过 r01 的 original_ids 找回来源关系。

加上可选参数,可以把每条结果写为一行 JSON,并保存旁边的审计记录:

python3 src/examples/pretraining_data.py --out runs/curated.jsonl

这里的原始记录是虚构教学数据,审计文件可以完整保存。换成真实材料时,先决定哪些字段可以保留、由谁访问。

展开完整代码:清洗、去重、分组与配比
"""第 22 章:自编短文本的清洗、去重分组和溯源;不抓取外部数据。"""

import argparse
from hashlib import sha256
from html.parser import HTMLParser
from itertools import combinations
import json
from pathlib import Path
from random import Random
import re
import unicodedata

PROTECTED = "模型评测集的问题不应进入训练数据。"


def records():
    texts = [
        "打印报告前,请检查纸张余量,并确认打印机已经连接。",
        "<p>打印报告前,请检查纸张余量,并确认打印机已经连接。</p>",
        "打印报告前,请检查纸张余量,并确认打印机已经连接!",
        "训练模型之前,先把数据分为训练集、验证集和测试集。",
        "这份材料的授权情况尚未确认,请先保留来源记录。",
        "如需反馈打印问题,请联系 demo@example.org,并写明错误信息。",
        "你好!",
        "!??!??!??!??!??!??",
        "示例试题:" + PROTECTED,
        "图像分类器接收像素数组,输出每个候选类别的分数。",
        "计算平均损失时,应统计实际参与预测的词元数量。",
        "保存模型时,同时记录词表、网络配置和参数文件。",
        "训练模型之前,先把数据划分为训练集、验证集和测试集。",
        "中文文本通常没有明确的空格词边界,切分方式需要统一。",
    ]
    return [dict(id=f"r{i:02}", source="本书自编示例", domain="说明文本",
                 rights="待确认(模拟)" if i == 5 else "作者提供的教学文本", text=text)
            for i, text in enumerate(texts, 1)]


class ExtractText(HTMLParser):
    def __init__(self):
        super().__init__(convert_charrefs=True)
        self.parts, self.hidden = [], 0

    def handle_starttag(self, tag, attrs):
        if tag in {"script", "style"}:
            self.hidden += 1
        if tag in {"p", "div", "br"} and not self.hidden:
            self.parts.append(" ")

    def handle_endtag(self, tag):
        if tag in {"script", "style"}:
            self.hidden = max(0, self.hidden-1)
        if tag in {"p", "div"} and not self.hidden:
            self.parts.append(" ")

    def handle_data(self, data):
        if not self.hidden:
            self.parts.append(data)


def clean(text):
    parser = ExtractText()
    parser.feed(text)
    text = unicodedata.normalize("NFC", "".join(parser.parts))
    text = " ".join(text.split())
    # 只演示一种明显格式;不能替代真实隐私审查。
    return re.subn(r"[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}", "[EMAIL]", text)


def shingles(text, size=3):
    return {text[i:i+size] for i in range(max(0, len(text)-size+1))}


def jaccard(left, right):
    a, b = shingles(left), shingles(right)
    return len(a & b)/len(a | b) if a | b else 0.0


def pipeline():
    raw = records()
    stages, rejected, kept, by_hash = [("原始记录", len(raw))], [], [], {}
    permitted = [row for row in raw if row["rights"] == "作者提供的教学文本"]
    rejected.extend((row["id"], "授权待确认") for row in raw if row not in permitted)
    stages.append(("来源筛选后", len(permitted)))
    quality = []
    for row in permitted:
        text, redactions = clean(row["text"])
        ratio = sum(char.isalnum() for char in text)/max(1, len(text))
        if len(text) < 12 or ratio < 0.6:
            rejected.append((row["id"], "长度/字符比例筛选"))
        else:
            quality.append({**row, "text": text, "redactions": redactions})
    stages.append(("格式筛选后", len(quality)))
    screened = []
    for row in quality:
        if PROTECTED in row["text"]:
            rejected.append((row["id"], "命中受保护评测文本"))
        else:
            screened.append(row)
    stages.append(("污染筛查后", len(screened)))
    for row in screened:
        digest = sha256(row["text"].encode()).hexdigest()
        if digest in by_hash:
            by_hash[digest]["original_ids"].append(row["id"])
            rejected.append((row["id"], "规范化后完全重复"))
        else:
            item = {**row, "sha256": digest, "original_ids": [row["id"]]}
            by_hash[digest] = item
            kept.append(item)
    stages.append(("完全去重后", len(kept)))
    # 相近文本保留,但整个连通组进入同一个数据划分。
    parent = {row["id"]: row["id"] for row in kept}

    def root(key):
        while key != parent[key]:
            key = parent[key]
        return key

    pairs = []
    for left, right in combinations(kept, 2):
        score = jaccard(left["text"], right["text"])
        if score >= 0.72:
            parent[root(right["id"])] = root(left["id"])
            pairs.append((left["id"], right["id"], score))
    groups = sorted({root(row["id"]) for row in kept})
    Random(2026).shuffle(groups)
    train_end = int(0.7*len(groups))
    assignments = {group: "训练" if i < train_end else "验证" if i == train_end else "测试"
                   for i, group in enumerate(groups)}
    for row in kept:
        row["group"] = root(row["id"])
        row["split"] = assignments[row["group"]]
    stages.append(("近重复分组数", len(groups)))
    return kept, stages, sorted(rejected), pairs


def mixture(counts, alpha):
    weights = [count**alpha for count in counts]
    return [weight/sum(weights) for weight in weights]


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--out", type=Path, help="可选:保存处理后 JSONL 与同名 .audit.json")
    args = parser.parse_args()
    kept, stages, rejected, pairs = pipeline()
    for name, count in stages:
        print(f"{name}:{count}")
    print("未保留:" + ";".join(f"{key} {reason}" for key, reason in rejected))
    print("近重复:" + ";".join(f"{a}/{b}={score:.3f}" for a, b, score in pairs))
    print("邮箱替换次数:" + str(sum(row["redactions"] for row in kept)))
    for split in ["训练", "验证", "测试"]:
        print(f"{split}:" + ", ".join(row["id"] for row in kept if row["split"] == split))
    print("配比示例的词元量:900、90、10(与上述小语料无关)")
    for alpha in [1.0, 0.5]:
        print(f"alpha={alpha:.1f}:" + "、".join(f"{p:.1%}" for p in mixture([900, 90, 10], alpha)))
    if args.out:
        args.out.parent.mkdir(parents=True, exist_ok=True)
        args.out.write_text("".join(json.dumps(row, ensure_ascii=False)+"\n" for row in kept))
        audit = dict(version=1, stages=stages, rejected=rejected, near_pairs=pairs,
                     raw=records(), near_threshold=0.72, split_seed=2026)
        args.out.with_suffix(".audit.json").write_text(json.dumps(audit, ensure_ascii=False, indent=2))
        print(f"已保存处理结果与审计记录:{args.out}")


if __name__ == "__main__":
    main()

7. 停下来试一试

  1. 为什么去掉 HTML 包装后再计算哈希,更容易发现本例的重复文本?
  2. 若 A={a,b,c}A=\{a,b,c\}、B={b,c,d}B=\{b,c,d\},Jaccard 相似度是多少?
  3. 近重复阈值从 0.72 提高到 0.95,本例两对近重复会怎样?这是否一定更好?
  4. 为什么按组划分以后,还需要统计各划分的文档数和词元数?
  5. 把 α\alpha 设为 0,三个非空来源的词元份额是多少?小来源会承担什么代价?
  6. 在一条文本里加入 <script>alert(1)</script>,观察正文提取结果。再用带缩进的代码试试合并空白,说明这条规则的适用范围。
  7. 完全重复去掉以后,为什么还不能宣称测试集独立?
  8. 为脚本增加一项记录:每个处理步骤改变了哪些字段。为什么应把“修改”和“排除”分开统计?
参考答案与提示
  1. 两份原始输入的字节不同;提取正文后文本一致,才能在同一比较规则下相遇。
  2. 共同项有 2 个,全部不同项有 4 个,因此是 2/4=0.52/4=0.5。
  3. 0.917 和 0.808 都低于 0.95,不再连成组。误合并可能减少,漏掉近重复的机会也会增加;需要结合实际文本检查。
  4. 大组和小组占一个组名,但包含的内容量不同。按组数的比例不等于按文本量的比例。
  5. 每个来源都是 1/31/3。小来源需要被重复采样更多次,未必带来同样多的新知识。
  6. 本例会丢弃脚本内容;合并空白却会损坏某些代码排版甚至语义。不同类型材料需要不同处理规则。
  7. 还有近重复、同源片段、改写,以及共享模板造成的高度相似。需要明确独立性的层次。
  8. 邮箱替换是修改且保留;授权待确认是暂不进入处理结果。把二者混成“删了多少数据”,无法解释最终文本是怎样形成的。

8. 继续阅读

数据整理好以后,下一步是把它们交给训练循环。模型一次看到哪些词元,哪些位置算损失,训练中断以后又从哪里继续?我们在下一章把这些细节接起来。