MiniMind 预训练学习指南
这份文档专门介绍 MiniMind 项目的预训练部分,帮助你真正理解:
train_pretrain.py在训练什么pretrain_hq.jsonl这种数据为什么能拿来训练- tokenizer、
input_ids、embedding、hidden_size分别是什么 - “下一 token 预测”到底怎么做
- 训练日志里的
loss / logits_loss / aux_loss应该怎么理解
本文默认对应当前仓库中的:
- 训练脚本:trainer/train_pretrain.py
- 数据读取:dataset/lm_dataset.py
- 模型定义:model/model_minimind.py
- tokenizer 配置:model/tokenizer_config.json
- tokenizer 本体:model/tokenizer.json
MiniMind代码仓库:https://github.com/jingyaogong/minimind.git
1. 预训练在做什么
预训练本质上是在做一件事:
给模型一段文本,让它根据前文去预测“下一个 token 是什么”。
这叫做:
- Causal Language Modeling
- 自回归语言模型训练
- 下一 token 预测
例如给模型一句话:
1 | 今天天气很好 |
模型不是一次性理解整句话后输出答案,而是反复学习:
- 看到“今天”后,后面大概率会出现什么
- 看到“今天天气”后,后面大概率会出现什么
- 看到“今天天气很”后,后面大概率会出现什么
训练久了,模型就会逐渐学会语言的局部规律、常见搭配、语法结构、问答模式和一些常识性分布。
2. 先看完整训练流程
在理解 tokenizer、embedding、loss 这些局部概念之前,先看一眼一条样本在预训练里会经历什么。
flowchart LR
A[jsonl中的一条text] --> B[tokenizer编码]
B --> C[input_ids]
C --> D[补bos/eos]
D --> E[padding到固定长度]
E --> F[得到labels并屏蔽pad位置]
F --> G[Embedding层]
G --> H[Transformer多层计算]
H --> I[lm_head输出logits]
I --> J[shift后做下一token预测]
J --> K[交叉熵loss]
K --> L[反向传播更新参数]
如果把 batch 维度也加进去,大致会看到这样的张量流动:
flowchart TD
A["原始文本 x batch_size"] --> B["tokenizer后: input_ids [B, T]"]
B --> C["Embedding后: hidden_states [B, T, H]"]
C --> D["Transformer后: hidden_states [B, T, H]"]
D --> E["输出logits: [B, T, V]"]
E --> F["shift_logits / shift_labels"]
F --> G["cross_entropy(ignore_index=-100)"]
其中:
B= batch sizeT= max sequence lengthH= hidden sizeV= vocab size
先把这个主线记住,后面的每个概念都只是这个流程中的一个环节。
3. train_pretrain.py 用的是什么数据
train_pretrain.py 默认读取:
1 | parser.add_argument("--data_path", type=str, default="../dataset/pretrain_hq.jsonl", help="预训练数据路径") |
所以默认训练集是:pretrain_hq.jsonl
其格式非常简单,每一行都是一条 JSON:
1 | {"text": "如何才能摆脱拖延症? 治愈拖延症并不容易,但以下建议可能有所帮助..."} |
也就是说,预训练阶段最核心的数据字段只有一个:
text- 一条原始文本样本
- 可能是一段连续文本
- 也可能是多个短片段拼接成的一条长文本
4. 为什么 pretrain_hq.jsonl 里会有 <|im_end|>
你会在数据里看到类似这样的内容:
1 | {"text": "你好<|im_end|>请帮我查天气<|im_end|>北京今天天气晴...<|im_end|>"} |
这里的 <|im_end|> 不是普通字符串,而是 tokenizer 里预定义好的特殊 token。
在当前项目中:
bos_token = <|im_start|>eos_token = <|im_end|>pad_token = <|endoftext|>
对应配置见:model/tokenizer_config.json
这些特殊 token 的作用可以先直观理解成“给文本结构打标记”:
bos_token = <|im_start|>bos是 beginning of sequence- 表示“一条输入序列从这里开始”
- 在当前项目里,预训练数据读取时会手动加在每条样本最前面
eos_token = <|im_end|>eos是 end of sequence- 表示“一条片段或一条样本到这里结束”
- 在样本内部它常常表示片段边界,在样本最后它表示整条样本结束
pad_token = <|endoftext|>pad是 padding token- 用于把不同长度的样本补到同一个长度,方便组成 batch
- 它不是“真实文本内容”,只是一个占位符
可以把 <|im_end|> 理解成“结束边界”:
- 在样本内部,它可以表示一个片段结束、一个问答轮次结束
- 在样本最后,它也可以表示整条样本结束
所以它本质上的语义是一致的:都是“到这里结束”。
5. 为什么不把每个短片段都拆成独立样本
因为预训练通常会设定一个固定的 max_seq_len,trainer/train_pretrain.py中:
1 | parser.add_argument('--max_seq_len', default=340, type=int, help="训练的最大截断长度") |
如果每条样本都特别短,例如只有 10 个 token,但 max_seq_len=340,那其余 330 个位置都会变成 padding,算力浪费很大。
这里的 padding 可以理解成“补空位”。
例如一条样本真实只有:
1 | [101, 205, 88, 32, 9] |
但训练脚本要求所有样本都整理成长度 10,那么就会在后面补上 pad_token_id:
1 | [101, 205, 88, 32, 9, 0, 0, 0, 0, 0] |
这些后补的 0 就是 padding。
它们的作用只是把样本长度对齐,好让很多样本可以一起送进模型。
因此很多预训练数据会把多个短片段打包成一条更长的文本:
1 | 片段A<|im_end|>片段B<|im_end|>片段C<|im_end|> |
这样做的好处:
- 提高每条样本的有效 token 占比
- 降低 padding 浪费
- 让模型学到片段边界
- 提升训练吞吐
看到这里,一个很自然的问题会出现:
训练脚本拿到的是原始字符串,但神经网络只能处理数字,那中间这一步是谁来负责把文本变成数字的?
答案就是 tokenizer。
所以 tokenizer 不是一个需要孤立记忆的名词,它是在训练流程里“被逼出来”的:
1 | 原始文本 |
换句话说,tokenizer 是“原始文本进入模型之前的第一道转换器”。
7. tokenizer 是什么
神经网络不能直接吃字符串,只能处理数字。
tokenizer 的作用就是:
- 把原始文本切成 token
- 再把 token 映射成整数 id
当前项目里的 tokenizer 是一个:
PreTrainedTokenizerFastByteLevel BPEtokenizer
可以从 model/tokenizer.json 里看到:
pre_tokenizer.type = ByteLevelmodel.type = BPE
它不是“按汉字一个一个切”的简单规则,也不是“按空格 split”。
它会根据自己训练好的词表和 merge 规则,把文本自动切成 token。
8. tokenizer 和 embedding 不是一回事
这两个概念特别容易混。
tokenizer 负责:
- 文本 -> token -> id
例如:
1 | 你好<|im_end|>世界 |
可能被编码成:
1 | [5134, 2, 2219] |
而 embedding 负责:
- id -> 向量
例如:
1 | 5134 -> 一个 512 维向量 |
所以:
- tokenizer 是“离散化工具”
- embedding 是“向量化层”
9. 先从训练时的形状变化理解参数
假设当前训练配置是:
batch_size = 32max_seq_len = 340hidden_size = 512
那么一批数据在训练过程中,会大致经历这样的形状变化:
1 | 原始文本 |
这时再回头理解这些参数就会更自然。
batch_size 是什么
表示每次同时喂给模型多少条样本。
例如:
batch_size = 32
那就表示一次迭代同时训练 32 条样本。
max_seq_len 是什么
表示一条样本最多保留多少个 token。
例如:
max_seq_len = 340
那么每条样本最终都会被整理成长度固定为 340 的 input_ids 和 labels。
hidden_size 是什么
表示每个 token 在模型内部会被表示成多少维的向量。
例如:
hidden_size = 512
那每个 token 在 embedding 之后,就会变成一个 512 维向量。
注意:
hidden_size不是文本长度hidden_size也不是“整条文本被压成 512 维”- 它是“每个 token 的向量维度”
vocab_size 是什么
表示 tokenizer 词表里一共有多少个 token。
例如在当前 MiniMind 配置中:
vocab_size = 6400
这意味着模型最终不是在“无限多种文本”里直接选答案,而是在:
- 6400 个候选 token
里预测“下一个 token 是哪一个”。
所以当模型输出 logits 时,最后一个维度就是 vocab_size:
1 | [B, T, V] |
如果:
B = 32T = 340V = 6400
那么 logits 的形状大致就是:
1 | [32, 340, 6400] |
可以把它理解成:
- 32 条样本
- 每条样本 340 个位置
- 每个位置都要对 6400 个 token 分别打分
vocab_size 还会直接影响模型大小,因为 embedding 表的形状就是:
1 | [vocab_size, hidden_size] |
例如:
1 | [6400, 512] |
所以:
- 词表越大
- embedding 参数通常也越多
- 输出层的参数量也会随之变大
num_hidden_layers 是什么
表示 Transformer block 的层数。
例如:
num_hidden_layers = 8
就表示每个 token 的表示会被连续加工 8 层。
use_moe 是什么
表示是否启用 MoE(Mixture of Experts)结构。
use_moe = 0- 普通 Dense 模型
use_moe = 1- 使用专家路由结构
当前默认是 0,所以你看到的训练日志里 aux_loss 通常是 0.0000。
从学习顺序上,也更建议先把默认 Dense 模型理解透,再去看 MoE。
10. 文本是怎么变成 input_ids 的
预训练数据读取逻辑在:
核心过程:
1 | tokens = self.tokenizer(str(sample['text']), add_special_tokens=False, max_length=self.max_length - 2, truncation=True).input_ids |
可以拆成这几步:
- 用 tokenizer 把文本编码成 id
- 手动在开头加
bos_token_id - 手动在结尾加
eos_token_id - padding 到固定长度
- 复制一份得到
labels - 把 padding 对应的 label 改成
-100
11. 一个极简样本例子
假设原始文本是:
1 | 你好<|im_end|>世界 |
假设 tokenizer 编码后得到:
1 | [5134, 2, 2219] |
其中:
5134对应某个 token2就是<|im_end|>2219对应某个 token
再假设:
bos_token_id = 1eos_token_id = 2pad_token_id = 0max_length = 10
那么处理过程就是:
1 | 原始 text: |
12. 为什么 labels 先复制 input_ids
因为语言模型训练的目标是:
给定当前位置之前的内容,预测下一个 token。
目标序列和输入序列其实就是同一串文本,只是训练时会错开一位。
模型里的关键代码在:
1 | shift_logits = logits[..., :-1, :].contiguous() |
也就是说:
- 第 0 个位置的输出去预测第 1 个 token
- 第 1 个位置的输出去预测第 2 个 token
- 依此类推
所以 labels = input_ids.clone() 是很自然的写法。
13. 为什么 pad 位置要改成 -100
因为 padding 只是为了对齐 batch,并不是真实文本。
如果让模型也去学习这些 padding 位置,就会让训练目标被污染。
当前项目在计算交叉熵时使用了:
1 | ignore_index=-100 |
这表示:
- labels 中所有等于
-100的位置 - 直接跳过,不参与 loss 计算
所以把 pad 位置改成 -100 的作用是:
- 保留固定长度 batch
- 但不让 padding 影响训练
这里可以把 pad_token 和 -100 区分开:
pad_token- 还在
input_ids里 - 模型仍然会看到它这个位置的输入
- 还在
-100- 出现在
labels里 - 表示这个位置不参与 loss
- 出现在
所以:
pad_token是输入对齐方案-100是 loss 屏蔽方案
14. token 是怎么变成向量的
这一步不是 tokenizer 做的,而是模型里的 embedding 层做的。
代码在:
1 | self.embed_tokens = nn.Embedding(config.vocab_size, config.hidden_size) |
如果:
vocab_size = 6400hidden_size = 512
那它本质上就是一个形状约为:
1 | [6400, 512] |
的可训练矩阵。
你可以把它理解成一张大表:
- 每个 token id 对应一行
- 每一行就是一个 512 维向量
例如:
1 | id 0 -> [ ... 512个数 ... ] |
当输入:
1 | input_ids = [1, 5134, 2, 2219, 2] |
embedding lookup 之后就得到:
1 | [ |
形状为:
1 | [5, 512] |
15. train_pretrain.py 默认训练的是多大的模型
到这里再看“模型大小”和配置参数,会更顺手一些,因为你已经知道这些参数会在训练流程的哪个环节起作用。
默认参数在:
1 | hidden_size=512 |
这对应 README 里的:
大约是:
- 26M 参数
README 中的常见规模:
MiniMind2-Small: 26MMiniMind2: 104MMiniMind2-MoE: 145M
可以先这样粗略理解模型大小是怎么“涨起来”的:
hidden_size越大- 每层线性变换矩阵越大
- embedding 表也更大
num_hidden_layers越多- Transformer block 叠得越多
- 参数量近似线性增长
use_moe=1- FFN 部分会引入更多专家参数
- 总参数量会进一步增大
如果只做最粗糙的直觉估计,可以先记住:
1 | 参数量 ~ 每层参数量 × 层数 + embedding/lm_head参数 |
在 MiniMind 默认配置里:
- 词表大小约
6400 hidden_size = 512num_hidden_layers = 8
所以 embedding 表本身大约就有:
1 | 6400 × 512 ≈ 3.28M |
再加上 8 层 Transformer 里的 attention、MLP、norm 等参数,总体就来到约 26M。
这里不用一开始就精确手推到每一项,只要先建立这个直觉:
- 模型大不大,本质上取决于“词表有多大、每个 token 用多宽的向量表示、层数有多少”
16. 为什么反向传播会学出“语义相近”
模型一开始并不知道什么叫语义相近。
它只是反复做:
- 根据前文预测下一个 token
- 预测错了就根据 loss 调整参数
如果两个 token 经常出现在相似上下文里,它们就会收到相似方向的梯度更新。
例如:
- “小猫在睡觉”
- “小狗在睡觉”
为了把这两句话都预测好,模型会逐渐把“猫”和“狗”的表示学得更接近。
所以模型学到的不是词典定义,而是“上下文中的行为相似性”。
17. hidden_states -> logits 的迷你例子
为了帮助理解,我们构造一个极简模型:
- 词表大小
vocab_size = 3 - 向量维度
hidden_size = 4
3 个 token:
id 0 = 猫id 1 = 狗id 2 = 车
设 embedding 矩阵为:
1 | E = |
输入:
1 | input_ids = [0, 2] |
embedding lookup 后:
1 | hidden_states = |
假设输出层使用同权重投影,则:
1 | logits = hidden_states × E^T |
对第一个位置:
1 | [2.0, 1.9, 0.0] |
对第二个位置:
1 | [0.0, 0.1, 2.0] |
于是整条序列的 logits 为:
1 | [ |
这说明:
- “猫”这个位置和“狗”的相似度也很高
- “车”和“猫/狗”差异更大
这就是向量空间表示的直觉来源。
18. 交叉熵损失是什么
在每个位置上,模型都会输出一个长度为 vocab_size 的分数向量,也就是 logits。
例如某个位置输出:
1 | [2.0, 1.9, 0.0] |
如果词表只有 3 个 token,这表示模型认为:
- token0 的可能性较高
- token1 的可能性也较高
- token2 的可能性很低
把 logits 做 softmax 后,就会变成概率分布。
假设真实答案是 token1,softmax 后它的概率是 0.44,那该位置的交叉熵就是:
1 | -log(0.44) |
真实答案概率越高,loss 越低。
在当前项目中,交叉熵定义在:
1 | loss = F.cross_entropy( |
19. loss / logits_loss / aux_loss 各是什么意思
训练日志中会打印:
losslogits_lossaux_loss
定义位置见:
trainer/train_pretrain.pytrainer/train_pretrain.py
logits_loss
主任务损失,也就是语言模型的交叉熵损失。
这是最重要的指标。
aux_loss
辅助损失,主要在开启 MoE 时才有意义。
它用于约束专家路由更均衡,避免个别专家负载过重。
如果你当前是:
1 | use_moe = 0 |
那么通常会看到:
1 | aux_loss = 0.0000 |
loss
总损失:
1 | loss = logits_loss + aux_loss |
如果不用 MoE,那通常就是:
1 | loss = logits_loss |
20. 预训练脚本的完整主线
把整个流程连起来,就是:
- 从
pretrain_hq.jsonl读取text - tokenizer 把文本变成 token ids
- 手动加
bos/eos - pad 到固定长度,得到
input_ids - 复制生成
labels,把 pad 位置改成-100 input_ids进入 embedding 层,变成 token 向量- 经过多层 Transformer 得到
hidden_states - 用
lm_head投影到词表大小,得到 logits - 用
shift_logits和shift_labels做下一 token 预测 - 计算交叉熵损失
- 反向传播更新参数
21. 预训练阶段最容易混淆的几个点
误区 1:hidden_size 是输入长度
不是。
输入长度主要由 max_seq_len 决定,hidden_size 是每个 token 的向量维度。
误区 2:tokenizer 就是 embedding
不是。
tokenizer 负责文本转 id,embedding 负责 id 转向量。
误区 3:labels 跟 input_ids 一样就没有意义
不是。
真正训练时会做 shift,对齐到“预测下一个 token”。
误区 4:<|im_end|> 只是普通字符串
不是。
它在当前 tokenizer 里是特殊 token,也是 eos_token。
误区 5:先背参数定义,再理解训练过程
不太推荐。
更自然的顺序通常是:
- 先看一条样本在训练里如何流动
- 再看
input_ids / labels / logits / loss - 最后再回头理解
hidden_size / max_seq_len / batch_size / num_hidden_layers
这样参数就不容易变成抽象名词。
22. 从预训练到 SFT 的自然衔接
预训练学到的是:
- 基础语言规律
- 基础续写能力
- 一些常见知识分布
但它还不等于“好用的聊天模型”。
因为预训练阶段只是让模型学会:
- 文本如何继续写
而 SFT 阶段会进一步让模型学会:
- 如何按“用户-助手”的对话格式回答
- 哪些内容应该由 assistant 来生成
- 如何更像一个有指令跟随能力的助手
所以:
train_pretrain.py是打基础train_full_sft.py是把基础模型进一步调成“会对话”的模型
23. 一句话总结
MiniMind 的预训练阶段,本质上就是:
把 pretrain_hq.jsonl 中的原始文本,借助 tokenizer 变成 token id 序列,再通过 embedding 和 Transformer 预测“下一个 token”,并用交叉熵不断纠正预测错误。
如果你已经理解了本文中的:
- tokenizer
input_ids / labels- embedding
hidden_size / max_seq_len / batch_sizeshift_logits / shift_labels- 交叉熵损失
那么你已经掌握了 MiniMind 预训练阶段最核心的脉络。