Skip to content

数据准备:模型从什么数据中学习

数据准备流程总览

上一篇看到了预训练的全流程:数据准备 → 模型初始化 → 训练循环 → 保存检查点。整个过程像一条流水线,而数据准备就是这条流水线的第一个环节——也是决定最终效果上限的环节。模型要从海量文本中学习语言规律,那这些文本是什么样子的?又是怎么变成模型能吃的"训练样本"的?这一篇我们就来看这些问题。

语料来源:模型读什么

预训练需要大量文本——不是几本书、几个网页,而是整个互联网规模的文本。常见的语料来源有这些:

  • 网页文本:互联网上爬取的网页内容,量最大、覆盖面最广,但噪声也最多。一个大型语料库可能包含数十亿个网页。
  • 书籍:电子书、扫描识别的纸质书。书籍的语言质量通常比网页高得多,而且涵盖长篇连贯的论述,对模型学习逻辑推理很有帮助。
  • 百科:维基百科、百度百科这类结构化的知识库。信息密度高,事实准确,是模型学习"知识"的重要来源。
  • 论文:学术论文,覆盖数学、物理、计算机等各个学科。让模型接触到严谨的学术表达和专业术语。
  • 代码:GitHub 等代码托管平台上的开源代码。代码有严格的语法规则和逻辑结构,对模型的推理能力提升很大。
  • 论坛和社交媒体:问答、讨论、评论等。语言风格多样,更接近日常对话。

这些不同来源的文本混在一起,构成预训练语料。数据量通常以token 数来衡量——大型模型的训练数据可能有几千亿甚至上万亿个 token。这么多的文本,不可能手动整理,所以预训练语料几乎都是从网上自动收集的。

MiniMind 的语料格式

MiniMind 作为一个小型教学项目,数据量自然没法和大型模型比,但格式是一样的。MiniMind 使用 JSONL 格式存储预训练数据,每一行是一个 JSON 对象,只有一个字段 text

json
{"text": "今天天气很好,适合出门散步。"}
{"text": "Python 是一门流行的编程语言,以其简洁的语法著称。"}
{"text": "人工智能技术在近年来取得了飞速发展,深刻改变了人们的生活方式。"}

就这么简单——每条数据就是一段纯文本,没有标签,没有分类,没有问答对。预训练数据的核心特点就是:大量纯文本,覆盖面广。模型要做的不是学某一种任务,而是从这些文本中普遍地学会语言规律。

数据清洗:垃圾进,垃圾出

从网上爬下来的原始数据是很脏的。如果你打开一个原始网页的 HTML 源码,会看到大量的标签、脚本、样式代码,真正有价值的文本内容淹没在里面。而且就算把文本提取出来了,里面还有各种问题。

常见的噪声包括:

  • 格式噪声:HTML 标签、CSS 代码、JavaScript 脚本、Markdown 标记、导航栏文字、页脚广告
  • 内容噪声:重复的段落、机器生成的垃圾文本、乱码、拼写错误
  • 隐私信息:手机号、身份证号、邮箱地址、家庭住址
  • 有害内容:暴力、歧视、虚假信息

如果不做清洗,直接把原始数据喂给模型,模型就会学到一堆乱七八糟的东西——毕竟模型是靠统计规律学习的,你给它什么,它就学什么。

数据清洗通常包括这些步骤:

  1. 格式提取:从 HTML 等原始格式中提取纯文本,去掉所有标签和代码
  2. 去重:删除重复或高度相似的文本。互联网上有大量复制粘贴的内容,不去重的话模型会在重复内容上浪费学习时间
  3. 质量过滤:用规则或小模型过滤掉低质量内容。比如文本太短、包含太多特殊字符、语言混杂等
  4. 隐私脱敏:用正则表达式或命名实体识别去掉个人隐私信息
  5. 有害内容过滤:过滤掉包含暴力、歧视等有害内容的文本

"垃圾进,垃圾出"这句话在预训练里体现得淋漓尽致。数据质量直接决定模型能力的上限——模型架构再先进,如果喂给它的是垃圾数据,学出来的也只能是垃圾。这也是为什么各大公司花大量精力在数据清洗上——在某些情况下,更好的数据比更大的模型更有效。

从文本到训练样本

数据清洗完之后,我们有了大量干净的文本。但模型不能直接读文本——它只认识数字。所以还需要一个转换过程,把每段文本变成模型能处理的训练样本

读者在 tokenize 章节 已经了解了 BPE 分词的原理,知道 tokenizer 怎么把文本切成 token id 序列。这里我们聚焦预训练场景下的特殊处理——除了分词,还要加特殊标记、统一长度、构建标签。

用一个具体的例子走完整个流程。假设我们有一条文本:

text
今天天气很好,适合出门散步。

MiniMind 的 max_seq_len 设为 340,但为了演示方便,我们假设 max_seq_len = 8

步骤一:分词

tokenizer 把文本切成 token id 序列。读者已经知道,分词的结果取决于 tokenizer 的词表和分词算法。这里假设分词结果是:

text
"今天天气很好,适合出门散步。" → [234, 567, 890, 345, 678]

这一步就是纯粹的文本到数字的转换,和推理时做的分词一模一样。唯一的不同是,预训练时我们设置了 add_special_tokens=False,表示不自动加特殊标记——因为我们想手动控制标记的位置。

步骤二:加特殊标记

在 token 序列的开头加 BOS(Begin Of Sentence),结尾加 EOS(End Of Sentence):

text
分词结果:  [234, 567, 890, 345, 678]
加特殊标记:[1, 234, 567, 890, 345, 678, 2]
              ^                          ^
              BOS (id=1)                 EOS (id=2)

为什么需要这两个标记?

BOS 告诉模型"一段文本从这里开始"。模型看到 BOS token,就知道接下来要开始处理新的输入了。在训练中,BOS 后面的第一个 token 就是模型要学习的第一个预测目标。

EOS 告诉模型"这段文本到这里结束"。模型需要学会在合适的时候生成 EOS,表示"我说完了"。如果没有 EOS,模型在推理时就会一直生成下去,不知道什么时候停。

这两个标记就像文章的开头和句号——虽然没有它们文本也读得懂,但有了它们,结构更清晰,模型处理起来也更规范。

步骤三:截断或填充,统一长度

现在 token 序列有 7 个元素:[1, 234, 567, 890, 345, 678, 2],而 max_seq_len = 8,还差一个。怎么办?用 padding token(通常是 id=0)填满:

text
填充后:[1, 234, 567, 890, 345, 678, 2, 0]
                                         ^
                                      padding (id=0)

为什么要统一长度?因为 GPU 的并行计算要求同一个 batch 内所有样本长度一致。你可以理解成 GPU 是一批一批处理数据的,每批数据像一张表格——所有行(样本)的列数(长度)必须相同,GPU 才能高效地做矩阵运算。如果不统一长度,有的样本长有的样本短,GPU 就没法把它们整齐地排在一起。

实际训练中会碰到两种情况:

  • 文本太长:如果分词后的 token 数超过了 max_seq_len,就直接截断,只保留前面 max_seq_len - 2 个 token(留位置给 BOS 和 EOS)。被截掉的部分就丢弃了。这也是为什么 max_seq_len 不能设太小——太短的话,长文本会被大量截断,模型学不到完整的内容。
  • 文本太短:如果 token 数不够 max_seq_len,就用 padding token 填满剩余位置。MiniMind 的 max_seq_len = 340,但实际文本可能只有十几个 token,这时候就有大量的 padding。这是正常的,不用担心浪费——后面会看到,padding 位置不会参与损失计算。

步骤四:构建 labels

labels 就是 input_ids 的一份副本,把 padding 位置改成 -100。

text
input_ids: [1, 234, 567, 890, 345, 678, 2,   0]
labels:    [1, 234, 567, 890, 345, 678, 2, -100]
                                       唯一的区别 ↑

为什么要把 padding 改成 -100?因为 padding 不是真正的文本,模型不应该在 padding 位置上学习。PyTorch 的交叉熵损失函数有一个约定:看到 -100 就跳过,不计算损失。所以 padding 位置被标记为 -100 后,损失函数只会关注真正的文本内容。

这里有一个容易混淆的问题:为什么不直接在 input_ids 里用 -100 做 padding?因为 input_ids 是给模型看的——模型会把每个位置的 id 查表变成 embedding 向量。-100 不是一个有效的 token id,模型没法用它查表。所以 input_ids 用 0(一个正常的 token)做 padding 让模型正常计算,labels 用 -100 告诉损失函数跳过这些位置。两者职责不同,填充值不同。

经过四个步骤,一段文本变成了两个等长的序列:

text
原始文本:  "今天天气很好,适合出门散步。"
             ↓ 分词
            [234, 567, 890, 345, 678]
             ↓ 加 BOS/EOS
            [1, 234, 567, 890, 345, 678, 2]
             ↓ 填充到 max_seq_len
input_ids:  [1, 234, 567, 890, 345, 678, 2, 0]
             ↓ 复制并把 padding 改为 -100
labels:     [1, 234, 567, 890, 345, 678, 2, -100]

这一对 (input_ids, labels) 就是一个训练样本

批次构建:打包处理

单个训练样本准备好了,但模型不是一个一个处理的,而是一批一批处理的。把多个样本打包在一起,就叫一个批次(batch)

为什么需要 batch?因为 GPU 擅长并行计算。GPU 的设计理念是:与其把一件事做得很快,不如同时做很多件事。一个一个地处理样本,GPU 的大部分计算单元都在闲置,非常浪费。把多个样本打包成 batch 一起送进去,GPU 就可以同时处理它们,效率大幅提升。

MiniMind 的 batch_size = 32,意味着每次同时处理 32 个样本。每个样本的形状是 [max_seq_len](即 [340]),32 个样本堆叠在一起,就形成了一个形状为 [32, 340] 的张量:

text
batch 的形状:[batch_size, max_seq_len] = [32, 340]

┌─────────────────────────────────────────────┐
│ 样本 1: [1, 234, 567, ..., 2, 0, 0, 0, 0]   │  ← input_ids,长度 340
│ 样本 2: [1, 89, 456, ..., 68, 2, 0, 0, 0]   │
│ 样本 3: [1, 123, 456, ..., 789, 2, -1, 0]   │
│ ...                                         │
│ 样本 32: [1, 567, 890, ..., 345, 2, 0, 0]   │
└─────────────────────────────────────────────┘

同样,labels 也是一个 [32, 340] 的张量,和 input_ids 一一对应。

这就是为什么前面说需要统一长度——如果样本长短不一,就没法整齐地堆叠成一个矩阵。padding 的存在就是为了填平这种差异,而 labels 中的 -100 则确保 padding 位置不影响训练。

代码参考

来看看 MiniMind 的 PretrainDataset 是怎么实现上述流程的(文件:external/minimind/dataset/lm_dataset.py):

python
class PretrainDataset(Dataset):
    def __init__(self, data_path, tokenizer, max_length=512):
        super().__init__()
        self.tokenizer = tokenizer
        self.max_length = max_length
        # 从 JSONL 文件加载数据,每行是一个 {"text": "..."} 的 JSON 对象
        self.samples = load_dataset('json', data_files=data_path, split='train')

    def __getitem__(self, index):
        sample = self.samples[index]
        # 步骤一:分词,截断到 max_length - 2(留位置给 BOS 和 EOS)
        tokens = self.tokenizer(
            str(sample['text']),
            add_special_tokens=False,
            max_length=self.max_length - 2,
            truncation=True
        ).input_ids
        # 步骤二:在开头加 BOS,结尾加 EOS
        tokens = [self.tokenizer.bos_token_id] + tokens + [self.tokenizer.eos_token_id]
        # 步骤三:不足 max_length 的位置用 padding token 填充
        input_ids = tokens + [self.tokenizer.pad_token_id] * (self.max_length - len(tokens))
        input_ids = torch.tensor(input_ids, dtype=torch.long)
        # 步骤四:labels = input_ids 的副本,padding 位置设为 -100
        labels = input_ids.clone()
        labels[input_ids == self.tokenizer.pad_token_id] = -100
        return input_ids, labels

代码非常简洁,四个步骤一目了然:

  1. 分词(第 10-15 行):调用 tokenizer 把文本转成 token id,add_special_tokens=False 不自动加标记,truncation=True 超长则截断,max_length=self.max_length - 2 留两个位置给 BOS 和 EOS
  2. 加特殊标记(第 17 行):手动在开头加 bos_token_id(值为 1),结尾加 eos_token_id(值为 2)
  3. 填充(第 19 行):用 pad_token_id(值为 0)填充到 max_length,然后转成 PyTorch 张量
  4. 构建 labels(第 21-22 行):复制 input_ids,把 padding 位置(值为 0)替换为 -100

返回的 (input_ids, labels) 就是前文说的训练样本。DataLoader 会自动把多个样本打包成 batch,形状为 [batch_size, max_seq_len],也就是 [32, 340]

小结

数据准备的完整流程可以概括为一条流水线:

text
原始文本(JSONL 格式,每行一段纯文本)
    ↓ 数据清洗(去重、过滤、脱敏)
    ↓ 分词(tokenizer 切成 token id 序列)
    ↓ 加特殊标记(BOS 在开头,EOS 在结尾)
    ↓ 截断或填充(统一到 max_seq_len)
    ↓ 构建 labels(padding 位置设为 -100)
    ↓ 打包成 batch(形状:[batch_size, max_seq_len])

训练样本就绪:每个 batch 是一对 (input_ids, labels)

几个关键点回顾:

  • 语料:大量纯文本,覆盖面广,以 JSONL 格式存储
  • 清洗:"垃圾进,垃圾出",数据质量决定模型能力上限
  • 特殊标记:BOS 标记文本开始,EOS 标记文本结束
  • padding 与 -100:padding 让所有样本长度统一,-100 让损失函数跳过 padding 位置
  • batch:多个样本打包在一起,充分利用 GPU 的并行计算能力

现在数据准备好了,每个训练样本是一对 (input_ids, labels)。下一篇我们进入训练循环,看看模型怎么用这些数据来更新自己的参数——从随机初始化到学会"说人话",这个过程是怎么发生的。