3431 字
17 分钟
MiniMind 代码导读(一):从 Token IDs 到训练 Loss
本文定位

前面的自回归语言模型建立了 Next-Token Prediction 的概率目标,Decoder-only Transformer现代 LLM 组件建立了模型结构。

本文开始阅读 MiniMind 源码,沿着一次训练前向传播,建立三者之间的对应:

代码语句 ↔ 数学运算 ↔ 张量形状

本文主要对应三个文件:

  • model/model_minimind.py:模型结构与 Loss;
  • dataset/lm_dataset.py:预训练样本与标签;
  • trainer/train_pretrain.py:模型调用入口。

Attention 内部的 Q/K/V 投影、RoPE、GQA、Causal Mask、Flash Attention 和 KV Cache 暂时视为一个整体,留到下一篇单独展开。

1. 一次训练前向传播的调用链#

预训练循环中真正启动模型计算的是下面两行:

trainer/train_pretrain.py
res = model(input_ids, labels=labels)
loss = res.loss + res.aux_loss

这里的 modelMiniMindForCausalLM,不是仅包含 Transformer 主干的 MiniMindModel。一次调用会依次经过:

MiniMindForCausalLM.forward
└── MiniMindModel.forward
├── Token Embedding
├── MiniMindBlock.forward × L
└── Final RMSNorm
└── LM Head
└── Shifted Cross Entropy

用一个函数概括,就是:

(Z,L)=fθ(I,Y)(Z,\mathcal{L})=f_\theta(I,Y)

II 是输入 Token ID,YY 是训练标签,ZZ 是词表 logits,L\mathcal{L} 是训练损失,θ\theta 表示 Embedding、Attention、FFN、Norm 和 LM Head 中的全部可训练参数。

本文统一使用以下符号:

符号代码参数或含义
BBBatch Size 多少行样本
TTSequence Length 每条样本有多少个 token
VVvocab_size,词表大小
ddhidden_size,隐藏维度
LLnum_hidden_layers,Decoder Block 数量
hqh_qnum_attention_heads,Query 头数
hkvh_{kv}num_key_value_heads,Key/Value 头数
dhd_hhead_dim,每个注意力头的维度
mmintermediate_size,FFN 中间维度

2. MiniMindConfig 将架构选择变成具体维度#

模型配置中的核心代码可以压缩成:

class MiniMindConfig(PretrainedConfig):
def __init__(self, hidden_size=768, num_hidden_layers=8, **kwargs):
self.hidden_size = hidden_size
self.num_hidden_layers = num_hidden_layers
self.vocab_size = kwargs.get("vocab_size", 6400)
self.num_attention_heads = kwargs.get("num_attention_heads", 8)
self.num_key_value_heads = kwargs.get("num_key_value_heads", 4)
self.head_dim = kwargs.get(
"head_dim", self.hidden_size // self.num_attention_heads
)
self.intermediate_size = kwargs.get(
"intermediate_size",
math.ceil(hidden_size * math.pi / 64) * 64
)
self.max_position_embeddings = kwargs.get(
"max_position_embeddings", 32768
)

Config 本身不处理输入张量。它的作用是确定模型中各参数矩阵的尺寸。默认配置对应:

参数默认值数学含义
vocab_size6400V=6400V=6400
hidden_size768d=768d=768
num_hidden_layers8L=8L=8
num_attention_heads8hq=8h_q=8
num_key_value_heads4hkv=4h_{kv}=4
head_dim96dh=d/hq=768/8d_h=d/h_q=768/8
intermediate_size2432m=64πd/64m=64\left\lceil\pi d/64\right\rceil
max_position_embeddings32768RoPE 查找表最多预计算 32768 个位置

这里有两个容易忽略的比例。首先,Query 总维度为:

hqdh=8×96=768=dh_qd_h=8\times96=768=d

其次,GQA 中每组 K/V 需要服务的 Query 头数为:

r=hqhkv=84=2r=\frac{h_q}{h_{kv}}=\frac{8}{4}=2

因此 MiniMind 默认有 8 个 Query 头、4 个 K/V 头,每个 K/V 头会对应 2 个 Query 头。max_position_embeddings 只决定 RoPE 缓冲区的可用位置范围,并不表示每条训练样本一定长达 32768 个 Token;训练脚本仍可以使用更短的 max_seq_len

3. 模型初始化对应参数化函数的组成#

MiniMindModel.__init__ 创建 Transformer 主干:

class MiniMindModel(nn.Module):
def __init__(self, config):
self.embed_tokens = nn.Embedding(
config.vocab_size,
config.hidden_size
)
self.dropout = nn.Dropout(config.dropout)
self.layers = nn.ModuleList([
MiniMindBlock(l, config)
for l in range(config.num_hidden_layers)
])
self.norm = RMSNorm(
config.hidden_size,
eps=config.rms_norm_eps
)

它对应四组数学对象:

ERV×dE\in\mathbb{R}^{V\times d}Block:RB×T×dRB×T×d,=0,1,,L1\operatorname{Block}_\ell: \mathbb{R}^{B\times T\times d} \rightarrow \mathbb{R}^{B\times T\times d}, \qquad \ell=0,1,\ldots,L-1γfinalRd\gamma_{\mathrm{final}}\in\mathbb{R}^{d}

其中 EE 是 Embedding 矩阵,每个 MiniMindBlock 是一个保持隐藏状态形状不变的函数,γfinal\gamma_{\mathrm{final}} 是最终 RMSNorm 的可训练缩放参数。

ModuleList 只是把 LL 个 Block 注册为模型子模块,并不会在初始化阶段执行它们。真正的复合运算发生在 forward 的循环中。

MiniMindForCausalLM 在主干之后增加 LM Head:

class MiniMindForCausalLM(PreTrainedModel, GenerationMixin):
def __init__(self, config=None):
self.model = MiniMindModel(self.config)
self.lm_head = nn.Linear(
self.config.hidden_size,
self.config.vocab_size,
bias=False
)
if self.config.tie_word_embeddings:
self.model.embed_tokens.weight = self.lm_head.weight

LM Head 的权重形状为:

WlmRV×dW_{\mathrm{lm}}\in\mathbb{R}^{V\times d}

tie_word_embeddings=True 时:

Wlm=EW_{\mathrm{lm}}=E

这不是复制一份数值相同的矩阵,而是让输入 Embedding 和输出 LM Head 引用同一个可训练参数。输入端用它按 Token ID 查行,输出端则执行矩阵乘法:

zb,t=hb,tEz_{b,t}=h_{b,t}E^\top

权重共享将两块原本各自需要 VdVd 个参数的矩阵合并为一块,但“查表”和“输出分类”仍然是两种不同运算。

共享为什么合理#

把 LM Head 的计算按分量展开,可以看出它其实是一组内积:

zb,t,v=hb,t,Ev,:z_{b,t,v}=\langle h_{b,t},E_{v,:}\rangle

也就是说,token vv 的 logit 等于当前隐藏状态与 vv 的 Embedding 向量的相似度。于是训练目标可以理解为:让 hb,th_{b,t} 在方向上靠近下一个 Token 的 Embedding。

在这个视角下,共享权重不只是省参数,还意味着输入侧和输出侧使用同一个语义空间:同一个词作为输入时的表示,和作为预测目标时的表示,是同一个向量。

共享带来的三个代价#

梯度来源不同。 每一步更新中,EE 会同时收到两路梯度。Embedding 路径只更新当前 Batch 中实际出现过的行;LM Head 路径则更新全部 VV 行,因为 Softmax 对词表中每个 Token 都产生梯度。两者的更新方向并不总是一致。

表达受到约束。 输入表示和输出表示被强制共用一个空间。部分工作发现在较大模型上解绑(untied)效果更好,因此并非所有模型都启用权重共享。这是一个与规模相关的权衡:小模型省下的 VdVd 占比高,收益大于约束带来的损失;模型变大后,VdVd 的相对占比下降,约束的代价就相对突出。

初始化尺度需要折中。 Embedding 的输出直接进入网络主干,LM Head 的输出要经过 Softmax,两者理想的初始化尺度并不一定相同,共享后只能取一个折中值。

4. Token ID 查表得到初始隐藏状态#

MiniMindModel.forward 首先读取输入形状并调用 Embedding:

def forward(self, input_ids, attention_mask=None,
past_key_values=None, use_cache=False, **kwargs):
batch_size, seq_length = input_ids.shape
hidden_states = self.dropout(
self.embed_tokens(input_ids)
)

输入是整数矩阵(input_ids [B, T] input_ids [B, T] 就是一个 B 行 T 列的整数矩阵,每一行是一条样本的 token id 序列):

I=[ib,t]{0,1,,V1}B×TI=[i_{b,t}]\in\{0,1,\ldots,V-1\}^{B\times T}
例子

例如用预训练的配置:预训练: batch_size=128, max_seq_len=512 → input_ids [128, 512]

┌─── T = 512 个 token ───┐
样本0 │ 1 882 53 ... 0 0 │
样本1 │ 1 204 91 ... 0 0 │
样本2 │ 1 77 16 ... 0 0 │
... │ │
样本127 │ 1 431 62 ... 0 0 │
└───────────────────────┘
↑ B = 128 行

Embedding 按 ID 从 EE 中取行:

Hb,t,:(0)=Eib,t,:H^{(0)}_{b,t,:}=E_{i_{b,t},:}

所以形状发生变化,查表后得到的是三维张量,多出来的那一维就是 hidden_size=768 —— 原来每个格子是一个整数(token id),现在每个格子被替换成一个 768 维向量:

[B,T][B,T,d][B,T]\longrightarrow[B,T,d]

这里不是把 [B,T][B,T] 乘成 [B,T,d][B,T,d],也不是对整数 ID 做连续数值计算,而是执行 B×TB\times T 次查表。

相同 Token ID 在不同位置会先取得 同一个 基础向量,Embedding 层完全不知道位置。区分它们的工作全部由后面的 Attention + RoPE 完成 —— 上下文融合之后,两个同一词的表示才会分化。

而且这个 [B, T, 768] 的形状在整个 8 层主干里保持不变 —— 每个 Block 进去是 [B,T,768],出来还是 [B,T,768]。只有最后过 LM Head 时最后一维才从 768 变成 6400。

Dropout 不改变张量形状。训练阶段它对元素施加随机掩码并进行尺度补偿,推理阶段则相当于恒等映射:

H~(0)=Dropout(H(0))RB×T×d\widetilde H^{(0)} = \operatorname{Dropout}(H^{(0)}) \in\mathbb{R}^{B\times T\times d}

值得注意的是当前实践中 config.dropout 默认是 0.0,而三个训练脚本都没有覆盖它。所以 nn.Dropout(0.0) 在这个项目里全程等于恒等映射 —— 包括 Attention 里的 attn_dropout 和 resid_dropout 也一样。

self.dropout = kwargs.get("dropout", 0.0) # config 默认 0.0
TIP

现代 LLM 预训练通常不用 dropout:数据量足够大、每个样本基本只见一次(epochs=1),过拟合风险很低,而 dropout 会拖慢收敛。Dropout 是”数据少、多轮重复训练”时代的正则手段。不过 LoRA 那次训了 5 个 epoch、数据只有 1.6 万条 —— 那个场景其实是有过拟合风险的,dropout 保持 0 可能是退化原因之一。

MiniMind 没有把位置向量直接加到 Embedding 上。它从预计算的 RoPE 表中切出当前位置:

start_pos = (
past_key_values[0][0].shape[1]
if past_key_values[0] is not None else 0
)
position_embeddings = (
self.freqs_cos[start_pos:start_pos + seq_length],
self.freqs_sin[start_pos:start_pos + seq_length]
)

若当前输入长度为 TT,则:

C,SRT×dhC,S\in\mathbb{R}^{T\times d_h}

训练时没有历史 KV Cache,通常有 start_pos=0;逐 Token 推理时,start_pos 等于已经缓存的历史长度。position_embeddings 会继续传入 Attention,在 Q/K 投影后参与旋转,而不会直接修改此处的 H(0)H^{(0)}

5. MiniMindBlock 对应两次 Pre-Norm 残差更新#

单个 Decoder Block 的主干代码是:

def forward(self, hidden_states, position_embeddings,
past_key_value=None, use_cache=False,
attention_mask=None):
residual = hidden_states
hidden_states, present_key_value = self.self_attn(
self.input_layernorm(hidden_states),
position_embeddings,
past_key_value,
use_cache,
attention_mask
)
hidden_states = hidden_states + residual
hidden_states = hidden_states + self.mlp(
self.post_attention_layernorm(hidden_states)
)
return hidden_states, present_key_value

设进入第 \ell 个 Block 的隐藏状态为 H()H^{(\ell)},代码可以逐行翻译为:

H^()=RMSNorm1(H())\widehat H^{(\ell)} = \operatorname{RMSNorm}_1(H^{(\ell)})A()=Attention(H^();C,S,M,Kpast,Vpast)A^{(\ell)} = \operatorname{Attention} \left( \widehat H^{(\ell)};C,S,M,K_{\mathrm{past}},V_{\mathrm{past}} \right)U()=H()+A()U^{(\ell)} = H^{(\ell)}+A^{(\ell)}F()=MLP(RMSNorm2(U()))F^{(\ell)} = \operatorname{MLP} \left( \operatorname{RMSNorm}_2(U^{(\ell)}) \right)H(+1)=U()+F()H^{(\ell+1)} = U^{(\ell)}+F^{(\ell)}

代码反复覆盖 hidden_states,数学表达则给每个中间结果单独命名。第一处 residual 显式保存 H()H^{(\ell)};第二次残差没有再创建变量,因为此时 hidden_states 本身已经是 U()U^{(\ell)}

整条主干上的形状始终保持:

H(),A(),U(),F(),H(+1)RB×T×dH^{(\ell)},A^{(\ell)},U^{(\ell)},F^{(\ell)},H^{(\ell+1)} \in\mathbb{R}^{B\times T\times d}

只有形状一致,Attention 输出和 FFN 输出才能分别与残差逐元素相加。present_key_value 是供生成阶段复用的旁路输出,不会代替当前层的隐藏状态。

6. Block 堆叠形成完整 Transformer 主干#

MiniMindModel.forward 用循环依次调用全部 Block:

presents = []
for layer, past_key_value in zip(self.layers, past_key_values):
hidden_states, present = layer(
hidden_states,
position_embeddings,
past_key_value=past_key_value,
use_cache=use_cache,
attention_mask=attention_mask
)
presents.append(present)
hidden_states = self.norm(hidden_states)
return hidden_states, presents, aux_loss

这对应函数复合:

H(L)=BlockL1Block1Block0(H(0))H^{(L)} = \operatorname{Block}_{L-1} \circ\cdots\circ \operatorname{Block}_1 \circ \operatorname{Block}_0 \left(H^{(0)}\right)

虽然每层输入输出形状都为 [B,T,d][B,T,d],不同层拥有各自独立的 Attention、FFN 和 Norm 参数,因此每一层学习的变换并不相同。

循环结束后执行最终 RMSNorm:

Hfinal=RMSNormfinal(H(L))RB×T×dH_{\mathrm{final}} = \operatorname{RMSNorm}_{\mathrm{final}}(H^{(L)}) \in\mathbb{R}^{B\times T\times d}

presents 保存每一层产生的 KV Cache;训练时默认 use_cache=False,其中的元素为 Noneaux_loss 用于 MoE 路由负载均衡;普通 FFN 配置下没有 MoE 层,因此它为 0。两者都是主隐藏状态之外的附加输出。

7. LM Head 将隐藏状态变成词表 Logits#

MiniMindForCausalLM.forward 先调用主干,再执行输出投影:

hidden_states, past_key_values, aux_loss = self.model(
input_ids,
attention_mask,
past_key_values,
use_cache,
**kwargs
)
slice_indices = (
slice(-logits_to_keep, None)
if isinstance(logits_to_keep, int)
else logits_to_keep
)
logits = self.lm_head(
hidden_states[:, slice_indices, :]
)

对每个位置的最终隐藏向量 hb,tRdh_{b,t}\in\mathbb{R}^{d},LM Head 计算:

zb,t=Wlmhb,tRVz_{b,t} = W_{\mathrm{lm}}h_{b,t} \in\mathbb{R}^{V}

按照 PyTorch 的批量张量写法,等价于:

Z=HfinalWlmRB×T×VZ=H_{\mathrm{final}}W_{\mathrm{lm}}^\top \in\mathbb{R}^{B\times T\times V}

因此最后一个维度从隐藏特征 dd 变成词表类别 VV

[B,T,d][B,T,V][B,T,d]\longrightarrow[B,T,V]

ZZ 是 logits,不是概率。对某个位置应用 Softmax 才得到条件概率:

pb,t,v=exp(zb,t,v)u=0V1exp(zb,t,u)p_{b,t,v} = \frac{\exp(z_{b,t,v})} {\sum_{u=0}^{V-1}\exp(z_{b,t,u})}

logits_to_keep 是推理优化参数,可以只计算最后若干位置的 logits。默认值为 0,而 Python 中 -0 等于 0,所以 slice(-0, None) 实际是 slice(0, None),训练默认仍会保留全部 TT 个位置。

代码没有在 LM Head 后显式调用 Softmax,因为训练使用的 F.cross_entropy 会在数值更稳定的实现中合并 Log-Softmax 与负对数似然。

8. 标签错位把输入序列变成下一个 Token 目标#

预训练数据集先把输入复制为标签,再把 PAD 位置设为 -100

dataset/lm_dataset.py
tokens = [bos_token_id] + tokens + [eos_token_id]
input_ids = tokens + [pad_token_id] * (
max_length - len(tokens)
)
input_ids = torch.tensor(input_ids, dtype=torch.long)
labels = input_ids.clone()
labels[input_ids == pad_token_id] = -100
return input_ids, labels

看到 labels = input_ids.clone() 时,容易误以为模型在学习原样复制输入。真正的时间错位发生在模型内部:

if labels is not None:
x = logits[..., :-1, :].contiguous()
y = labels[..., 1:].contiguous()
loss = F.cross_entropy(
x.view(-1, x.size(-1)),
y.view(-1),
ignore_index=-100
)

假设一条补齐后的序列是:

input_ids = [BOS, Thinking, Machines, EOS, PAD]
labels = [BOS, Thinking, Machines, EOS, -100]

切片后形成:

模型输入位置产生的 logits对应监督目标
BOS 位置Thinking
Thinking 位置Machines
Machines 位置EOS
EOS 位置-100,忽略

也就是:

zb,t预测ib,t+1z_{b,t}\quad\text{预测}\quad i_{b,t+1}

若完整 logits 形状为 [B,T,V][B,T,V],切片和展平对应:

x:[B,T1,V][B(T1),V]x: [B,T-1,V]\longrightarrow[B(T-1),V]y:[B,T1][B(T1)]y: [B,T-1]\longrightarrow[B(T-1)]

F.cross_entropy 要求每一行包含 VV 个类别分数,并为这一行提供一个整数类别 ID,所以代码把 Batch 维和时间维合并为一个样本维。contiguous() 先确保切片结果在内存中连续,使后续 view 能安全重塑。

设有效目标位置集合为:

Ω={(b,t)Yb,t+1100}\Omega = \{(b,t)\mid Y_{b,t+1}\ne-100\}

默认平均交叉熵为:

LLM=1Ω(b,t)ΩlogPθ(Yb,t+1Ib,t)\mathcal{L}_{\mathrm{LM}} = -\frac{1}{|\Omega|} \sum_{(b,t)\in\Omega} \log P_\theta \left( Y_{b,t+1}\mid I_{b,\le t} \right)

这正是因果语言模型的 Token 级负对数似然。ignore_index=-100 会从求和与平均分母中排除 PAD 目标。交叉熵与 NLL 的完整关系可以回看KL 散度与交叉熵笔记

因果掩码和标签错位承担不同职责:Causal Mask 保证位置 tt 的隐藏状态不能读取未来 Token;标签错位则规定位置 tt 应该预测 Token t+1t+1。缺少前者会泄露答案,缺少后者则不会形成标准的 Next-Token Prediction。

例子#

为了把维度看清,假设使用以下配置:

B=2,T=5,V=100,d=64,L=2B=2,\quad T=5,\quad V=100,\quad d=64,\quad L=2hq=4,hkv=2,dh=16,m=128h_q=4,\quad h_{kv}=2,\quad d_h=16,\quad m=128

一次训练前向传播的形状为:

代码位置张量形状
input_idsII[2,5][2,5]
embed_tokens(input_ids)H(0)H^{(0)}[2,5,64][2,5,64]
MiniMindBlock[0] 输出H(1)H^{(1)}[2,5,64][2,5,64]
MiniMindBlock[1] 输出H(2)H^{(2)}[2,5,64][2,5,64]
self.norm(hidden_states)HfinalH_{\mathrm{final}}[2,5,64][2,5,64]
lm_head(hidden_states)ZZ[2,5,100][2,5,100]
logits[..., :-1, :]xx[2,4,100][2,4,100]
labels[..., 1:]yy[2,4][2,4]
x.view(-1, 100)交叉熵输入[8,100][8,100]
y.view(-1)交叉熵类别 ID[8][8]
F.cross_entropy(...)L\mathcal{L}标量

这个表揭示了完整前向传播中两次关键的最后一维变化:Embedding 把整数 ID 变成 dd 维隐藏向量,LM Head 再把 dd 维隐藏向量变成 VV 维词表分数。中间的 LL 个 Decoder Block 都保持 [B,T,d][B,T,d] 不变。

10. 从代码阅读过渡到组件实现#

现在可以把 MiniMind 主干压缩成一组代码与数学对象的对照:

代码对象数学语言
MiniMindConfig确定 V,d,L,hq,hkv,dh,mV,d,L,h_q,h_{kv},d_h,m
nn.Embedding(V, d)H(0)=E[I]H^{(0)}=E[I]
MiniMindBlockH(+1)=Block(H())H^{(\ell+1)}=\operatorname{Block}_\ell(H^{(\ell)})
ModuleList([...])保存 LL 个参数独立的 Block
for layer in self.layers执行 LL 个函数的顺序复合
RMSNorm归一化特征尺度,不改变形状
lm_headZ=HfinalWlmZ=H_{\mathrm{final}}W_{\mathrm{lm}}^\top
logits[..., :-1, :]去掉没有下一 Token 目标的最后一个预测位置
labels[..., 1:]将监督目标向左对齐到对应预测位置
F.cross_entropyToken 级 NLL 的批量工程实现

到这里,已经能从训练脚本一路追踪到标量 Loss,但仍把 self.self_attn(...) 当作一个整体。下一篇将进入 Attention.forward,逐段对应 Q/K/V 投影、QK Norm、RoPE、GQA、Causal Mask、注意力矩阵和 KV Cache。

MiniMind 代码导读(一):从 Token IDs 到训练 Loss
https://biscuit0613.github.io/posts/minimind/01-model-forward-pass/
作者
Biscuit
发布于
2026-07-20
许可协议
CC BY-NC-SA 4.0
MiniMind 代码导读(二):Attention 的完整张量流
词嵌入基础:word2vec