大语言模型(LLM)训练的教师强制(Teacher Forcing)方法
大语言模型(LLM)在训练时使用一种名为“教师强制(Teacher Forcing)”的方法,而不是它们在推理(生成文本)时使用的“自回归(Autoregressive)”方法 。阐明关于LLM训练的一些常见问题,例如如何控制输出长度,以及为什么训练效率比推理更高。
训练 vs. 推理:Teacher Forcing vs. 自回归
区分大语言模型在两个不同阶段的运作方式:
-
自回归(推理/生成阶段):这是LLM在生成文本(即推理)时使用的方法 。它以序列化的方式,逐个token地工作 。为了预测下一个token,模型会将它已经生成的token作为输入 。例如,为了写出“The quick brown fox”,模型首先预测出“The”,然后用“The”作为输入来预测“quick”,再用“The quick”来预测“brown”,以此类推。这个过程是天生的序列化过程,因此速度较慢。
-
Teacher Forcing(训练阶段):该方法用于模型的训练阶段 。在训练时,模型不会使用自己先前(可能不正确)的预测作为下一步的输入,而是始终被喂入正确、真实的答案(即“标签”或“ground truth”)。为了预测序列中的每一个token,模型会接收来自训练数据中真实的前序token作为输入 。然后,模型会并行地计算出所有位置的输出,再将其预测的输出(如t2’, t3’, t4’)与真实的后续token(如t2, t3, t4)进行比较,以计算损失(Loss)。
使用Teacher Forcing进行训练的主要优点是:
- 高效率:因为模型不必等待前一个token生成后才能预测下一个,所有token的预测都可以并行计算 。这使得训练过程比自回归推理快得多,并能带来更高的GPU利用率 。
- 长度控制:由于模型在训练时是“看着答案”来生成的,它被训练去生成一个与所提供的标签长度完全相同的输出,这直接解决了训练期间输出长度如何控制的问题 。
Teacher Forcing是一种有效的训练方法吗?
Teacher Forcing是一种科学上合理的方法,它并不会影响模型的学习目标 。通过数学推导证明了Teacher Forcing、最大似然估计(Maximum Likelihood Estimation, MLE)和交叉熵损失(Cross-Entropy Loss)这三者的优化目标是等价的 。
-
最大似然估计(MLE):模型训练的根本目标是最大化模型在给定输入
x
的条件下,生成正确token序列y
的概率 。这可以表示为最大化整个序列的对数似然。- 一个序列的概率是其每个token在给定所有前序token的条件下的概率之积:pθ(y1:T∣x)=∏t=1Tpθ(yt∣y<t,x)p_{\theta}(y_{1:T}|x)=\prod_{t=1}^{T}p_{\theta}(y_{t}|y_{<t},x)pθ(y1:T∣x)=∏t=1Tpθ(yt∣y<t,x) 。
- MLE的目标是最大化所有训练样本的对数概率之和:LMLE(θ)=∑∑logpθ(yt∣y<t,x)\mathcal{L}_{MLE}(\theta) = \sum \sum \log p_{\theta}(y_{t}|y_{<t},x)LMLE(θ)=∑∑logpθ(yt∣y<t,x) 。
-
Teacher Forcing损失:在Teacher Forcing中,一个序列的总损失是其每一个token位置上损失的总和 。在每一步
t
,损失是正确token yty_tyt 在给定真实前序token y<ty_{<t}y<t 条件下的负对数概率。- 总损失为:L=−∑t=1Tlogpθ(yt∣y<t,x)L = -\sum_{t=1}^{T}\log p_{\theta}(y_{t}|y_{<t},x)L=−∑t=1Tlogpθ(yt∣y<t,x) 。
-
交叉熵损失:此损失函数用于衡量两个概率分布之间的差异。在这里,它比较的是模型预测的下一个token的概率分布与真实的概率分布(一个one-hot向量,其中正确token的概率为1)。在整个序列上累加的交叉熵损失,在数学上与Teacher Forcing的损失是完全相同的 。
因此,得出了以下等价关系:
CrossEntropy=LTeacher−Forcing=−LMLE(θ)CrossEntropy = L_{Teacher-Forcing} = - \mathcal{L}_{MLE}(\theta)CrossEntropy=LTeacher−Forcing=−LMLE(θ)
这表明,使用Teacher Forcing来最小化交叉熵损失,等同于最大化训练数据的似然。
Teacher Forcing在代码中如何实现
使用Hugging Face的transformers
库,提供了一个实践层面的代码讲解。
- 数据准备:
DataCollatorForLanguageModeling
被用来准备数据 。它通过简单地复制inputs
来创建用于计算损失的labels
。 - 前向传播与损失计算:当模型执行其
forward
方法时,如果传入了labels
参数,就会自动触发损失的计算 。 - 移位以实现“预测下一个token”:最关键的一步是,在损失函数(例如
ForCausalLMLoss
)内部,模型的输出(logits)和标签(labels)会被相互错开一位 。标签被移动,使得输入中位置i
的token(input_ids
)被用来预测标签中位置i+1
的token 。这种错位正是“预测下一个token”这一任务的本质。 - 计算损失:最终的损失是通过在模型的预测(logits)和移位后的正确标签(shifted labels)之间计算
cross_entropy
(交叉熵)得出的 。这个代码实现与Teacher Forcing的示意图完全对应,即模型在每个位置的输出都与真实序列中的下一个token进行比较。
我们跟随一个具体的例子,看看一句话是如何在训练流程中被处理的,以及每一步的数据细节是怎样的。
核心目标:教会模型“接话”
首先要理解,LLM训练的核心任务是预测下一个词(Next Token Prediction) 。
比如我们给模型一句话“天空是”,我们希望它能预测出“蓝色”这个词。训练的本质就是给模型看海量的文本,让它反复练习这个“接话”的游戏,直到它做得非常好。
完整训练流程的详细拆解
让我们以一句话 “大模型爱学习” 为例,看看它在训练中经历了什么。
第1步:数据准备(输入与标签的制作)
在模型开始训练前,我们首先要准备好给它“吃”的数据。
-
文本分词 (Tokenization):计算机不认识汉字,只认识数字。所以第一步是把文本切分成最小的单元(Token),然后转换成对应的数字ID。
- “大模型爱学习” -> อาจจะถูกตัดเป็น ->
["大", "模型", "爱", "学习"]
- 然后,我们查阅一个预先制作好的词典(Tokenizer),将这些词转换为唯一的数字ID。
- 假设词典是这样的:
{"大": 10, "模型": 25, "爱": 33, "学习": 88}
- 那么我们的输入数据就变成了
[10, 25, 33, 88]
。这个在代码里通常被称为input_ids
。
- “大模型爱学习” -> อาจจะถูกตัดเป็น ->
-
制作标签 (Labels):这是最关键的一步。在Teacher Forcing训练模式下,标签(labels)就是输入(inputs)的一个完整复制品 。
- 所以,我们现在有两份一模一样的数据:
inputs
(输入):[10, 25, 33, 88]
labels
(标签):[10, 25, 33, 88]
- 所以,我们现在有两份一模一样的数据:
你可能会问:为什么输入和标签一样?别急,玄机在后面。
第2步:模型进行并行预测
现在我们把 inputs
([10, 25, 33, 88]
) 送进大模型。
- 与推理时一个一个词往外蹦不同,在训练时,模型会并行地处理整个输入序列 。这意味着它会同时计算出每一个位置的“下一个词预测”。
- 模型的输出是一系列的
logits
。Logits
可以理解为一个超长的列表,代表了模型对词典里每一个词的预测分数。分数越高,代表模型认为这个词是下一个词的可能性越大。- 当模型看到
10
(“大”) 时,它会输出一个logits
向量,这是对第二个词的预测。 - 当模型看到
10, 25
(“大”, “模型”) 时,它会输出另一个logits
向量,这是对第三个词的预测。 - …以此类推。
- 当模型看到
所以,输入是一个长度为4的序列,输出也是4组对应的logits
预测。
第3步:关键操作 —“移位”,对齐预测和答案
这是整个流程中最巧妙、最核心的部分。现在我们手上有两样东西:
- 模型的预测 (Logits):模型在每个位置上对 下一个 词的预测。
- 真实的答案 (Labels):我们知道每个位置后面 应该 跟哪个词。
我们的目标是:用模型在位置 i
的预测,去对比位置 i+1
的真实答案。
例如:用模型看了“大”之后的预测,去和标准答案“模型”做对比。
为了实现这个目标,代码里会执行一个**移位(Shift)**操作 。
让我们把数据可视化:
输入 (Inputs) | 模型看到的内容 | 模型的预测目标 | 预测输出 (Logits) | 真实答案 (Labels) |
---|---|---|---|---|
10 (“大”) | “大” | “模型” | Logits_1 | 25 (“模型”) |
25 (“模型”) | “大”, “模型” | “爱” | Logits_2 | 33 (“爱”) |
33 (“爱”) | “大”, “模型”, “爱” | “学习” | Logits_3 | 88 (“学习”) |
88 (“学习”) | “大”, “模型”, “爱”, “学习” | (无) | Logits_4 | (无) |
如上表所示,我们需要比较的是“预测输出”和“真实答案”这两列。代码通过简单的数组切片就实现了这个对齐:
- 取所有位置的预测:
Logits
- 取所有位置的、向左移动一位的标签:即从
labels
的第二个元素开始取 。labels
:[10, 25, 33, 88]
shifted_labels
:[25, 33, 88]
这样,Logits_1
就会和 25
比较,Logits_2
就会和 33
比较,以此类推。这完美地实现了“预测下一个词”的训练目标。
第4步:计算损失 (Loss)
对齐之后,我们就在每个位置上计算损失。
- 这个计算是通过交叉熵损失函数 (Cross-Entropy Loss) 完成的 。
- 你可以把交叉熵理解为衡量“惊讶程度”的指标。
- 在位置1,模型预测的
Logits_1
如果给正确答案25
(“模型”) 打了很高的分,那么损失就很小(模型不惊讶)。 - 反之,如果模型给
25
打了很低的分,那么损失就很大(模型很惊讶,它猜错了)。
- 在位置1,模型预测的
- 最后,我们会把所有位置(位置1, 2, 3)的损失加起来,得到这个句子总的损失值 。
第5步:模型学习与更新
计算出的总损失值是一个数字,它告诉我们模型这次“考试”考得有多差。然后,通过反向传播算法,这个损失值会被用来微调模型内部亿万个参数。目的就是让模型下次再看到类似的输入时,能给出更接近正确答案的预测,从而让损失值变得更小。
这个过程会重复数万亿次,模型最终就学会了语言的规律。
总结一下完整流程:
- 准备数据:将一句话“大模型爱学习”转换成数字ID,并复制一份作为标签。
inputs
:[10, 25, 33, 88]
labels
:[10, 25, 33, 88]
- 并行预测:模型接收
inputs
,并行为每个位置输出一个对下一个词的预测(logits
)。 - 移位对齐:将
labels
向左移一位,使得模型的预测logits[i]
与正确答案labels[i+1]
对齐。 - 计算损失:在每个位置上,使用交叉熵比较模型的预测和正确答案,计算出损失。
- 累加损失:将所有位置的损失相加,得到总损失 。
- 更新模型:根据总损失,使用优化算法(如梯度下降)更新模型的内部参数,完成一次学习。
import torch
import torch.nn as nn
from torch.optim import AdamW# ==============================================================================
# 1. 初始化设置 (Setup & Initialization)
# ==============================================================================# 假设我们有一个预训练好的大语言模型 (LLM) 和它的分词器 (Tokenizer)
# 在实际应用中,这些通常从Hugging Face加载
# model = AutoModelForCausalLM.from_pretrained("some-llm")
# tokenizer = AutoTokenizer.from_pretrained("some-llm")# 为了演示,我们用模拟的组件代替
class MockTokenizer:def __init__(self):# 词典:将词映射到ID。-100是Hugging Face中常用的一个特殊值,# 用于在计算损失时忽略某些token 。我们后面会用到它。self.vocab = {"[PAD]": 0, "我": 1, "爱": 2, "学": 3, "习": 4, "大": 5, "模": 6, "型": 7}self.pad_token_id = 0self.ignore_index = -100def encode(self, text):return [self.vocab[char] for char in text]class MockLLM(nn.Module):def __init__(self, vocab_size):super().__init__()# 模型的词嵌入层和输出头self.embedding = nn.Embedding(vocab_size, 768) # 假设隐藏层维度是768self.head = nn.Linear(768, vocab_size)def forward(self, input_ids, attention_mask=None):# 1. 输入的token ID通过嵌入层,变成向量表示embedded_vectors = self.embedding(input_ids) # (batch, seq_len) -> (batch, seq_len, hidden_dim)# 2. 经过模型主体(Transformer层),这里简化处理# 在真实模型中,这里会有一系列的Transformer Blocktransformer_output = embedded_vectors # 简化,实际有复杂计算# 3. 通过输出头,将向量表示转换回词典大小的logitslogits = self.head(transformer_output) # (batch, seq_len, hidden_dim) -> (batch, seq_len, vocab_size)return logits# 实例化我们的模拟组件
tokenizer = MockTokenizer()
model = MockLLM(vocab_size=len(tokenizer.vocab))
optimizer = AdamW(model.parameters(), lr=5e-5)# ==============================================================================
# 2. 数据准备 (Data Preparation) - Teacher Forcing的关键起点
# ==============================================================================# 我们的原始训练数据
sample_text = "我爱学习大模型"# (2.1) 分词并将文本转为数字ID (Tokenization)
# input_ids: [1, 2, 3, 4, 5, 6, 7]
input_ids = tokenizer.encode(sample_text)# (2.2) 转换为PyTorch张量(Tensor),并增加一个batch维度(批处理大小为1)
# 形状: [1, 7] (1个样本, 序列长度为7)
input_ids_tensor = torch.tensor([input_ids], dtype=torch.long)# (2.3) 创建标签(labels)。这是核心步骤之一:
# “这个Label仅仅是input的复制” 。
# labels = inputs.clone()
labels_tensor = input_ids_tensor.clone()print(f"原始输入 (input_ids): {input_ids_tensor}")
print(f"原始标签 (labels): {labels_tensor}")
print("-" * 30)# ==============================================================================
# 3. 训练步骤 (A Single Training Step)
# ==============================================================================# 清零之前的梯度
optimizer.zero_grad()# (3.1) 前向传播 (Forward Pass)
# 将准备好的input_ids送入模型,得到logits
# logits的形状: [batch_size, sequence_length, vocab_size]
# 也就是 [1, 7, 8] -> (1个样本, 7个token, 每个token都有8个可能的分数,对应词典大小)
logits = model(input_ids=input_ids_tensor)print(f"模型输出Logits的形状: {logits.shape}")
print("-" * 30)# (3.2) 移位操作 (Shift) - Teacher Forcing的核心实现
# 这是反复强调的最关键细节。
# 我们的目标:用位置i的预测(logits),去和位置i+1的答案(labels)做对比。# logits形状: [1, 7, 8]
# labels形状: [1, 7]# 截取logits:我们不需要用最后一个token的输出来预测任何东西,所以去掉最后一个。
# 取从第0个到倒数第二个token的预测结果。
# shift_logits形状: [1, 6, 8]
shift_logits = logits[:, :-1, :]# 截取labels:我们预测的目标是从第1个token开始,所以去掉第0个。
# shift_labels形状: [1, 6]
shift_labels = labels_tensor[:, 1:]print("--- 移位操作 ---")
print(f"移位前Logits形状: {logits.shape} (预测了7次)")
print(f"移位后Logits形状: {shift_logits.shape} (我们只关心前6次的预测)")
print(f"移位前Labels形状: {labels_tensor.shape} (7个真实答案)")
print(f"移位后Labels形状: {shift_labels.shape} (我们只关心后6个真实答案)")
print(f"移位后的标签内容: {shift_labels}")
print("-" * 30)# (3.3) 计算损失 (Loss Calculation)
# 在计算损失前,通常会将数据展平(Flatten) 。
# 将 [batch_size, sequence_length, vocab_size] -> [(batch_size * sequence_length), vocab_size]
# 将 [batch_size, sequence_length] -> [(batch_size * sequence_length)]
# 这样就可以一次性计算所有token的损失。# 展平后的logits形状: [6, 8]
flat_logits = shift_logits.reshape(-1, model.head.out_features)# 展平后的labels形状: [6]
flat_labels = shift_labels.reshape(-1)print("--- 展平操作 ---")
print(f"展平后Logits形状: {flat_logits.shape}")
print(f"展平后Labels形状: {flat_labels.shape}")
print("-" * 30)# 使用交叉熵损失函数 。
# ignore_index告诉损失函数,如果label是-100,就不要计算这个位置的损失。
# 这在处理padding(为了让句子等长而填充的无意义token)时非常有用。
loss_function = nn.CrossEntropyLoss(ignore_index=tokenizer.ignore_index)# loss_function会自动比较flat_logits (模型的预测分布) 和 flat_labels (正确答案的ID)。
# 它会计算出模型预测的“惊讶程度”,即损失值。
loss = loss_function(flat_logits, flat_labels)# (3.4) 反向传播 (Backward Pass)
# 根据计算出的损失值,自动计算模型中每个参数的梯度。
loss.backward()# (3.5) 参数更新 (Optimizer Step)
# 指示优化器根据刚刚计算出的梯度,来更新模型的所有参数。
optimizer.step()print(f"计算出的总损失 (Loss): {loss.item()}")
print("模型参数已更新!完成一次训练迭代。")
- Teacher Forcing的起点:训练的
inputs
和labels
一开始是完全相同的副本 。 - 并行计算:模型一次性处理整个输入序列,并行地为每个输入位置生成一个对“下一个词”的预测(
logits
)。这解释了为什么训练比推理效率高 。 - 核心是移位(Shift):通过对
logits
和labels
进行简单的切片(一个去掉结尾,一个去掉开头),我们就完美地创造出了“用前一个词预测后一个词”的训练任务,这正是Teacher Forcing的精髓 。 - 损失函数:最终使用标准的交叉熵损失函数 来衡量预测与真实答案之间的差距,并驱动模型的学习。