dage
3.3 预训练 - 让大语言模型学会预测下一个词
📖 目录
- 🎯 开篇:为什么要预训练?
- 第一部分:预训练是什么?
- 第二部分:预测下一个词的数学原理
- 第三部分:损失函数详解
- 第四部分:优化器与学习率
- 第五部分:大规模训练技巧
- 🧪 自我检验:20道思考题
- 📚 参考答案
🎯 开篇:为什么要预训练?
三个核心问题
-
预训练解决什么问题? 模型刚初始化时,所有参数都是随机的,完全不懂语言。预训练让模型通过”阅读”海量文本,学会语言的基本规律。
-
为什么是”预测下一个词”? 这是最简单、最自然的学习任务。不需要人工标注,只要有文本就能训练。而且这个任务迫使模型理解语法、语义、常识。
-
预训练后模型会什么? 模型学会了”续写文本”的能力。给它”今天天气很”,它能预测”好”。但它还不会”回答问题”,需要后续的微调。
第一部分:预训练是什么?
这一部分解决什么问题?
核心问题:模型刚初始化时,参数都是随机数,完全不懂语言。如何让它学会语言的基本规律?
解决方案:预训练 —— 在海量无标注文本上,让模型学习”预测下一个词”。
突破点:
- 不需要人工标注(文本本身就是标签)
- 数据量巨大(整个互联网的文本)
- 学到的知识可以迁移到各种下游任务
局限:预训练后的模型只会”续写”,不会”对话”,需要后续微调。
1.1 什么是预训练?
12岁版解释
想象你要教一个外星人学中文。你不会一开始就教它”如何写作文”,而是先让它读大量的中文书,让它熟悉中文的用词、语法、表达习惯。
预训练就是这个”大量阅读”的过程。
预训练的定义: 在大规模无标注文本上,训练模型预测下一个词,让模型学会语言的统计规律。
流程:
海量文本(几TB)
↓
随机初始化模型参数
↓
输入:"今天天气很"
模型预测:"好"(概率80%)、"冷"(概率15%)、"热"(概率5%)
↓
计算损失(预测和真实的差距)
↓
反向传播,更新参数
↓
重复数万亿次
↓
得到预训练模型(Base Model)
1.2 为什么是”预测下一个词”?
三个原因:
原因1:不需要标注
传统监督学习:
需要:(问题, 答案) 对
成本:人工标注,昂贵
预训练:
需要:纯文本
成本:几乎为0(互联网上到处都是)
原因2:任务自然
"今天天气很___"
→ 人类自然会填"好"
→ 模型也学习这个能力
原因3:迫使模型理解语言
要预测下一个词,模型必须理解:
- 语法:主谓宾结构
- 语义:"天气"和"好"的关系
- 常识:"天气很好"是常见表达
类比
就像学英语,你不是先学语法规则,而是先大量阅读、听力,自然而然就懂了。
1.3 预训练 vs 微调
| 预训练 | 微调 | |
|---|---|---|
| 数据 | 海量无标注文本(TB级) | 少量标注数据(GB级) |
| 任务 | 预测下一个词 | 特定任务(对话、翻译等) |
| 目标 | 学习语言规律 | 学习任务规律 |
| 结果 | Base Model(会续写) | Chat Model(会对话) |
| 时间 | 数周到数月 | 数小时到数天 |
| 成本 | 极高(数百万美元) | 较低(数千到数万美元) |
类比:
- 预训练 = 读万卷书(打基础)
- 微调 = 行万里路(学应用)
第二部分:预测下一个词的数学原理
这一部分解决什么问题?
核心问题:“预测下一个词”听起来简单,但在数学上如何实现?模型如何输出”概率”?
解决方案:通过Softmax函数,把模型的输出向量转换成概率分布。
突破点:
- 模型输出的是词表中每个词的”得分”
- Softmax把得分转换成概率(和为1)
- 选择概率最高的词作为预测
局限:词表越大,计算越慢(需要计算所有词的概率)。
2.1 从输入到输出的完整流程
输入:一段文本
"今天天气很"
步骤1:分词
["今天", "天气", "很"]
→ Token IDs: [1234, 5678, 9012]
步骤2:Embedding
[1234, 5678, 9012]
→ 向量序列(每个Token变成512维向量)
步骤3:Transformer处理
向量序列 → Self-Attention → FFN → ...
→ 输出向量序列(每个位置一个向量)
步骤4:预测下一个词
取最后一个位置的输出向量(512维)
→ 通过线性层映射到词表大小(50000维)
→ 每个维度对应一个词的"得分"
步骤5:Softmax转概率
得分向量 [3.2, 1.5, 0.8, ..., -2.1]
→ Softmax
→ 概率向量 [0.45, 0.12, 0.08, ..., 0.001]
步骤6:选择预测词
找概率最高的词
→ 词ID: 7890
→ 查表得到词:"好"
2.2 Softmax函数详解
公式:
其中:
- :第i个词的得分
- :词表大小(如50000)
- :自然常数(约2.718)
12岁版解释
Softmax就像一个”公平的评分系统”:
- 把每个得分转换成正数(通过)
- 除以总和,让所有概率加起来等于1
- 得分越高,概率越大
例子:
假设词表只有3个词:["好", "冷", "热"]
得分:[3.0, 1.0, 0.5]
步骤1:计算e^z
e^3.0 = 20.09
e^1.0 = 2.72
e^0.5 = 1.65
总和 = 24.46
步骤2:除以总和
P("好") = 20.09 / 24.46 = 0.82 (82%)
P("冷") = 2.72 / 24.46 = 0.11 (11%)
P("热") = 1.65 / 24.46 = 0.07 (7%)
Softmax的特性:
- ✅ 输出都是正数
- ✅ 所有输出加起来等于1
- ✅ 得分越高,概率越大
- ✅ 放大差异(得分3.0比1.0高2倍,但概率82%比11%高7倍)
2.3 为什么不用简单归一化?
简单归一化:
问题1:无法处理负数
得分:[3.0, -1.0, 0.5]
简单归一化:
总和 = 3.0 + (-1.0) + 0.5 = 2.5
P(1) = 3.0 / 2.5 = 1.2 (120%???)
P(2) = -1.0 / 2.5 = -0.4 (负概率???)
问题2:无法放大差异
得分:[100, 99, 1]
简单归一化:
P(1) = 100/200 = 0.50 (50%)
P(2) = 99/200 = 0.495 (49.5%)
P(3) = 1/200 = 0.005 (0.5%)
Softmax:
P(1) ≈ 0.73 (73%)
P(2) ≈ 0.27 (27%)
P(3) ≈ 0.00 (0%)
Softmax能更好地突出”最优选择”。
2.4 温度参数(Temperature)
公式:
温度的作用:
T = 1(标准):
得分:[3.0, 1.0, 0.5]
概率:[0.82, 0.11, 0.07]
T = 0.5(低温,更确定):
得分除以0.5:[6.0, 2.0, 1.0]
概率:[0.95, 0.04, 0.01]
→ 更集中在最高分
T = 2.0(高温,更随机):
得分除以2.0:[1.5, 0.5, 0.25]
概率:[0.62, 0.22, 0.16]
→ 更平均分布
应用场景
- 低温(T<1):需要确定性输出(如代码生成、翻译)
- 高温(T>1):需要创造性输出(如写诗、头脑风暴)
第三部分:损失函数详解
这一部分解决什么问题?
核心问题:模型预测出了概率分布,如何衡量”预测得好不好”?如何指导模型改进?
解决方案:交叉熵损失函数 —— 衡量预测分布和真实分布的差距。
突破点:
- 损失越小,预测越准确
- 可以通过梯度下降优化
- 对数形式让计算更稳定
局限:只关注正确答案的概率,忽略了其他词的分布。
3.1 什么是损失函数?
12岁版解释
损失函数就像考试的”扣分规则”:
- 答对了:扣0分
- 答错了:扣很多分
- 答得越离谱,扣分越多
模型的目标就是”减少扣分”。
定义: 损失函数衡量模型预测和真实答案的差距。
例子:
输入:"今天天气很"
真实答案:"好"
模型预测:
P("好") = 0.82
P("冷") = 0.11
P("热") = 0.07
损失 = -log(0.82) = 0.198
损失的含义:
- 损失 = 0:预测完全正确(P=1)
- 损失 = 0.2:预测比较准确(P=0.82)
- 损失 = 2.3:预测很差(P=0.1)
- 损失 = ∞:预测完全错误(P=0)
3.2 交叉熵损失函数
公式:
其中:
- :序列长度
- :第t个位置的真实词
- :模型预测的概率
12岁版解释
对每个位置:
- 看模型预测正确词的概率
- 取对数(log)
- 加负号(让损失为正数)
- 所有位置平均
详细例子:
输入序列:“今天天气很好”
分词:[“今天”, “天气”, “很”, “好”]
预测任务:
位置1:给定"今天",预测"天气"
位置2:给定"今天 天气",预测"很"
位置3:给定"今天 天气 很",预测"好"
模型预测:
位置1:P("天气") = 0.9
位置2:P("很") = 0.7
位置3:P("好") = 0.8
计算损失:
损失1 = -log(0.9) = 0.105
损失2 = -log(0.7) = 0.357
损失3 = -log(0.8) = 0.223
总损失 = (0.105 + 0.357 + 0.223) / 3 = 0.228
3.3 为什么用对数(log)?
原因1:数值稳定
概率很小时:
P = 0.001
直接用P:0.001(太小,容易下溢)
用-log(P):6.91(合理范围)
原因2:乘法变加法
多个位置的联合概率:
P(x1, x2, x3) = P(x1) × P(x2) × P(x3)
= 0.9 × 0.7 × 0.8 = 0.504
取对数:
log P(x1, x2, x3) = log P(x1) + log P(x2) + log P(x3)
= log(0.9) + log(0.7) + log(0.8)
= -0.105 + (-0.357) + (-0.223) = -0.685
加法比乘法更稳定,不容易数值溢出。
原因3:梯度更好
损失 = -log(P)
梯度 = -1/P
当P很小时(预测很差):
梯度很大 → 更新幅度大 → 快速改进
当P接近1时(预测很好):
梯度很小 → 更新幅度小 → 避免过度调整
3.4 困惑度(Perplexity, PPL)
定义:
其中是平均损失。
12岁版解释
困惑度表示”模型有多困惑”:
- PPL = 1:完全不困惑(完美预测)
- PPL = 10:在10个词中犹豫
- PPL = 100:在100个词中犹豫
- PPL越小越好
例子:
平均损失 = 0.228
PPL = e^0.228 = 1.256
含义:模型平均在1.256个词中犹豫
(接近完美预测)
实际模型的PPL:
GPT-2(小):PPL ≈ 35
GPT-3(大):PPL ≈ 20
GPT-4:PPL ≈ 15(估计)
PPL越小,模型越强
3.5 损失函数的直觉理解
情况1:预测很准
真实词:"好"
模型预测:P("好") = 0.95
损失 = -log(0.95) = 0.051(很小)
情况2:预测一般
真实词:"好"
模型预测:P("好") = 0.5
损失 = -log(0.5) = 0.693(中等)
情况3:预测很差
真实词:"好"
模型预测:P("好") = 0.01
损失 = -log(0.01) = 4.605(很大)
情况4:预测完全错误
真实词:"好"
模型预测:P("好") = 0.0001
损失 = -log(0.0001) = 9.210(极大)
关键洞察
损失函数”惩罚”模型给正确答案的低概率。模型必须学会给正确答案高概率,才能降低损失。
3.6 交叉熵损失的PyTorch实现
标准实现:
import torch
import torch.nn.functional as F
def cross_entropy_loss(logits, targets):
"""
计算交叉熵损失
参数:
logits: [batch_size, seq_len, vocab_size] 模型输出的得分
targets: [batch_size, seq_len] 真实的词ID
返回:
loss: 标量,平均损失
"""
# 重塑为2D:[batch_size * seq_len, vocab_size]
logits = logits.view(-1, logits.size(-1))
targets = targets.view(-1)
# 计算交叉熵(PyTorch内部会先做Softmax)
loss = F.cross_entropy(logits, targets)
return loss
# 使用示例
batch_size = 4
seq_len = 512
vocab_size = 50000
logits = torch.randn(batch_size, seq_len, vocab_size) # 模型输出
targets = torch.randint(0, vocab_size, (batch_size, seq_len)) # 真实词ID
loss = cross_entropy_loss(logits, targets)
print(f"损失: {loss.item():.4f}")数值稳定版本:
def stable_cross_entropy_loss(logits, targets):
"""
数值稳定的交叉熵损失
技巧:
1. 减去最大值,避免exp溢出
2. 使用log_softmax代替softmax + log
"""
# 重塑
logits = logits.view(-1, logits.size(-1))
targets = targets.view(-1)
# 方法1:使用log_softmax(推荐)
log_probs = F.log_softmax(logits, dim=-1)
loss = F.nll_loss(log_probs, targets)
return loss
# 手动实现(理解原理)
def manual_cross_entropy(logits, targets):
"""手动实现交叉熵,展示数值稳定技巧"""
# 减去最大值,避免exp(大数)溢出
logits_max = logits.max(dim=-1, keepdim=True)[0]
logits_stable = logits - logits_max
# 计算log_softmax
log_sum_exp = torch.log(torch.exp(logits_stable).sum(dim=-1, keepdim=True))
log_probs = logits_stable - log_sum_exp
# 选择正确词的log概率
batch_size = logits.size(0)
correct_log_probs = log_probs[range(batch_size), targets]
# 平均损失(加负号)
loss = -correct_log_probs.mean()
return loss标签平滑版本(Label Smoothing):
def label_smoothing_loss(logits, targets, smoothing=0.1):
"""
标签平滑交叉熵损失
作用:防止模型过度自信,提高泛化能力
原理:
原始标签:[0, 0, 1, 0, 0](one-hot)
平滑后:[0.02, 0.02, 0.92, 0.02, 0.02]
参数:
smoothing: 平滑系数(通常0.1)
"""
vocab_size = logits.size(-1)
# 计算log_softmax
log_probs = F.log_softmax(logits.view(-1, vocab_size), dim=-1)
targets = targets.view(-1)
# 创建平滑标签
confidence = 1.0 - smoothing
smooth_value = smoothing / (vocab_size - 1)
# one-hot编码
one_hot = torch.zeros_like(log_probs).scatter_(1, targets.unsqueeze(1), 1)
# 应用平滑
smooth_labels = one_hot * confidence + (1 - one_hot) * smooth_value
# 计算损失
loss = -(smooth_labels * log_probs).sum(dim=-1).mean()
return loss
# 使用示例
logits = torch.randn(4, 512, 50000)
targets = torch.randint(0, 50000, (4, 512))
loss_standard = cross_entropy_loss(logits, targets)
loss_smooth = label_smoothing_loss(logits, targets, smoothing=0.1)
print(f"标准损失: {loss_standard.item():.4f}")
print(f"平滑损失: {loss_smooth.item():.4f}")实践建议
- 训练初期:使用标准交叉熵
- 训练后期:考虑标签平滑(smoothing=0.1),提高泛化
- 大模型:标签平滑效果更明显
第四部分:优化器与学习率
这一部分解决什么问题?
核心问题:知道了损失,如何更新参数?更新多少?如何避免训练不稳定?
解决方案:优化器(如AdamW)+ 学习率调度(如Cosine Decay)。
突破点:
- Adam自适应调整每个参数的学习率
- 学习率调度让训练更稳定
- Warmup避免初期震荡
局限:超参数(学习率、warmup步数等)需要仔细调整。
4.1 什么是优化器?
12岁版解释
优化器就像一个”导航系统”:
- 损失函数告诉你”现在在哪”
- 梯度告诉你”应该往哪个方向走”
- 优化器决定”走多大步”
目标:找到损失最小的参数。
梯度下降的基本思想:
当前参数:θ
损失:L(θ)
梯度:∇L(θ)(损失对参数的导数)
更新规则:
θ_new = θ - η × ∇L(θ)
其中η是学习率(步长)
例子:
假设只有一个参数θ,损失L(θ) = (θ - 5)²
当前:θ = 10
梯度:∇L = 2(θ - 5) = 2(10 - 5) = 10
学习率:η = 0.1
更新:θ_new = 10 - 0.1 × 10 = 9
重复多次后,θ会逐渐接近5(最优值)
4.2 为什么不用简单的梯度下降?
问题1:学习率难选
学习率太大:
θ_new = θ - 1.0 × ∇L
→ 步子太大,跳过最优点,震荡
学习率太小:
θ_new = θ - 0.0001 × ∇L
→ 步子太小,收敛太慢
问题2:不同参数需要不同学习率
参数1:梯度很大(变化快)→ 需要小学习率
参数2:梯度很小(变化慢)→ 需要大学习率
简单梯度下降:所有参数用同一个学习率 ❌
问题3:容易卡在鞍点
鞍点:梯度为0,但不是最优点
简单梯度下降:梯度为0 → 停止更新 ❌
4.3 Adam优化器
全称:Adaptive Moment Estimation(自适应矩估计)
核心思想:
- 动量(Momentum):考虑历史梯度,避免震荡
- 自适应学习率:每个参数有自己的学习率
公式:
# 一阶矩估计(梯度的指数移动平均)
m_t = β1 × m_{t-1} + (1 - β1) × g_t
# 二阶矩估计(梯度平方的指数移动平均)
v_t = β2 × v_{t-1} + (1 - β2) × g_t²
# 偏差修正
m_hat = m_t / (1 - β1^t)
v_hat = v_t / (1 - β2^t)
# 参数更新
θ_t = θ_{t-1} - η × m_hat / (√v_hat + ε)参数:
- :一阶矩的衰减率
- :二阶矩的衰减率
- :防止除零
- :学习率(如0.0001)
12岁版解释
- m_t:记住过去的梯度方向(像惯性)
- v_t:记住过去的梯度大小(用来调整步长)
- m_hat / √v_hat:梯度大的参数,步长小;梯度小的参数,步长大
AdamW = Adam + Weight Decay
# 在Adam的基础上,增加权重衰减
θ_t = θ_{t-1} - η × (m_hat / (√v_hat + ε) + λ × θ_{t-1})
其中λ是权重衰减系数(如0.01)作用:防止过拟合,让参数不要太大。
4.4 学习率调度
问题:固定学习率不够好
训练初期:需要大学习率,快速接近最优点
训练后期:需要小学习率,精细调整
解决方案:学习率调度(Learning Rate Schedule)
常见策略:
1. Warmup(预热)
前N步:学习率从0线性增加到最大值
例如:
步数0:η = 0
步数500:η = 0.0001
步数1000:η = 0.0002(最大值)
作用:避免初期梯度不稳定导致的震荡。
2. Cosine Decay(余弦衰减)
Warmup后:学习率按余弦曲线衰减到0
公式:
η_t = η_min + (η_max - η_min) × (1 + cos(πt/T)) / 2
其中:
t:当前步数
T:总步数
η_max:最大学习率
η_min:最小学习率(通常是η_max的10%)
3. 完整的学习率曲线
学习率
↑
| ╱‾‾‾╲
| ╱ ╲___
| ╱ ╲___
| ╱ ╲___
| ╱ ╲___
|╱________________________╲___
└────────────────────────────→ 步数
Warmup Cosine Decay
实际例子(GPT-3):
总步数:300B tokens
Warmup:375M tokens(0.125%)
最大学习率:6e-5
最小学习率:6e-6(最大值的10%)
优化器:AdamW
β1 = 0.9, β2 = 0.95
权重衰减:0.1
4.5 为什么需要Warmup?
问题:训练初期,参数是随机的,梯度不稳定
没有Warmup:
步数0:η = 0.0002(很大)
梯度:很大且不稳定
更新:θ - 0.0002 × 大梯度 = 跳得很远
结果:损失爆炸,训练崩溃 ❌
有Warmup:
步数0:η = 0(从0开始)
步数500:η = 0.0001(逐渐增大)
步数1000:η = 0.0002(达到最大值)
初期:小学习率 → 小步子 → 稳定
后期:大学习率 → 大步子 → 快速收敛
经验法则
Warmup步数通常是总步数的0.1%-1%。 例如:训练100万步,Warmup 1000-10000步。
4.6 优化器配置的完整代码
AdamW优化器配置:
import torch
from torch.optim import AdamW
# 模型参数
model = YourTransformerModel()
# 配置AdamW优化器
optimizer = AdamW(
model.parameters(),
lr=6e-4, # 学习率(GPT-3用6e-5,小模型可以更大)
betas=(0.9, 0.95), # β1和β2(一阶和二阶矩的衰减率)
eps=1e-8, # 防止除零
weight_decay=0.1 # 权重衰减(L2正则化)
)
print(f"优化器配置完成")
print(f"学习率: {optimizer.defaults['lr']}")
print(f"权重衰减: {optimizer.defaults['weight_decay']}")分组参数配置(高级):
def configure_optimizers(model, learning_rate, weight_decay):
"""
为不同参数组配置不同的优化策略
策略:
- 权重矩阵:应用权重衰减
- 偏置和LayerNorm:不应用权重衰减
"""
# 需要权重衰减的参数
decay_params = []
# 不需要权重衰减的参数
no_decay_params = []
for name, param in model.named_parameters():
if not param.requires_grad:
continue
# 偏置和LayerNorm参数不应用权重衰减
if 'bias' in name or 'norm' in name or 'ln' in name:
no_decay_params.append(param)
else:
decay_params.append(param)
# 创建参数组
param_groups = [
{'params': decay_params, 'weight_decay': weight_decay},
{'params': no_decay_params, 'weight_decay': 0.0}
]
optimizer = AdamW(param_groups, lr=learning_rate, betas=(0.9, 0.95))
print(f"参数分组完成:")
print(f" 需要权重衰减: {len(decay_params)}个参数")
print(f" 不需要权重衰减: {len(no_decay_params)}个参数")
return optimizer
# 使用示例
optimizer = configure_optimizers(
model=model,
learning_rate=6e-4,
weight_decay=0.1
)梯度裁剪配置:
def train_step_with_grad_clip(model, batch, optimizer, max_grad_norm=1.0):
"""
训练步骤,包含梯度裁剪
梯度裁剪作用:
- 防止梯度爆炸
- 稳定训练
"""
# 前向传播
loss = model(batch)
# 反向传播
loss.backward()
# 梯度裁剪(在optimizer.step()之前)
torch.nn.utils.clip_grad_norm_(
model.parameters(),
max_norm=max_grad_norm # 梯度范数的最大值
)
# 参数更新
optimizer.step()
optimizer.zero_grad()
return loss.item()
# 使用示例
for batch in dataloader:
loss = train_step_with_grad_clip(
model=model,
batch=batch,
optimizer=optimizer,
max_grad_norm=1.0 # GPT-3使用1.0
)4.7 学习率调度的完整实现
线性Warmup + 余弦衰减:
import math
class CosineWarmupScheduler:
"""
学习率调度器:Warmup + Cosine Decay
参数:
optimizer: 优化器
warmup_steps: Warmup步数
total_steps: 总训练步数
min_lr_ratio: 最小学习率 / 最大学习率(通常0.1)
"""
def __init__(self, optimizer, warmup_steps, total_steps, min_lr_ratio=0.1):
self.optimizer = optimizer
self.warmup_steps = warmup_steps
self.total_steps = total_steps
self.min_lr_ratio = min_lr_ratio
self.base_lr = optimizer.defaults['lr']
self.current_step = 0
def step(self):
"""更新学习率"""
self.current_step += 1
lr = self.get_lr()
for param_group in self.optimizer.param_groups:
param_group['lr'] = lr
def get_lr(self):
"""计算当前学习率"""
step = self.current_step
# Warmup阶段:线性增长
if step < self.warmup_steps:
lr = self.base_lr * step / self.warmup_steps
# Cosine Decay阶段
else:
progress = (step - self.warmup_steps) / (self.total_steps - self.warmup_steps)
lr = self.min_lr_ratio * self.base_lr + \
(self.base_lr - self.min_lr_ratio * self.base_lr) * \
0.5 * (1 + math.cos(math.pi * progress))
return lr
# 使用示例
total_steps = 100000
warmup_steps = 2000
scheduler = CosineWarmupScheduler(
optimizer=optimizer,
warmup_steps=warmup_steps,
total_steps=total_steps,
min_lr_ratio=0.1
)
# 训练循环
for step, batch in enumerate(dataloader):
# 前向+反向+更新
loss = train_step_with_grad_clip(model, batch, optimizer)
# 更新学习率
scheduler.step()
if step % 1000 == 0:
current_lr = scheduler.get_lr()
print(f"步数 {step}: 损失={loss:.4f}, 学习率={current_lr:.6f}")使用PyTorch内置调度器:
from torch.optim.lr_scheduler import LambdaLR
def get_cosine_schedule_with_warmup(optimizer, warmup_steps, total_steps, min_lr_ratio=0.1):
"""
创建Warmup + Cosine Decay调度器(PyTorch版本)
"""
def lr_lambda(current_step):
# Warmup
if current_step < warmup_steps:
return float(current_step) / float(max(1, warmup_steps))
# Cosine Decay
progress = float(current_step - warmup_steps) / float(max(1, total_steps - warmup_steps))
return max(min_lr_ratio, 0.5 * (1.0 + math.cos(math.pi * progress)))
return LambdaLR(optimizer, lr_lambda)
# 使用示例
scheduler = get_cosine_schedule_with_warmup(
optimizer=optimizer,
warmup_steps=2000,
total_steps=100000,
min_lr_ratio=0.1
)
# 训练循环
for batch in dataloader:
loss = train_step_with_grad_clip(model, batch, optimizer)
scheduler.step() # 每步更新学习率4.8 学习率曲线可视化
ASCII艺术图:Warmup + Cosine Decay
学习率
↑
0.0006| ╱‾‾‾‾‾╲
| ╱ ╲
0.0005| ╱ ╲___
| ╱ ╲___
0.0004| ╱ ╲___
| ╱ ╲___
0.0003| ╱ ╲___
| ╱ ╲___
0.0002| ╱ ╲___
| ╱ ╲___
0.0001| ╱ ╲___
| ╱‾‾‾ ╲___
0.00006|____╱________________________________________________________________╲___
└────────────────────────────────────────────────────────────────────→ 步数
0 2k 100k
←Warmup→←─────────────────── Cosine Decay ──────────────────────→
关键点:
- 步数0:学习率=0
- 步数2000:学习率=6e-4(最大值,Warmup结束)
- 步数100000:学习率=6e-5(最小值,训练结束)
不同学习率策略对比:
学习率
↑
| ┌───────────────────────────────── 固定学习率(不推荐)
| │
| │ ╱‾‾‾‾‾╲___ Warmup + Cosine(推荐)
| │ ╱ ╲___
| │ ╱ ╲___
| │ ╱ ╲___
| │╱ ╲___
| ╱ ╲___ 线性衰减
| ╱ ╲
|╱______________________________________╲___
└────────────────────────────────────────→ 步数
对比:
- 固定学习率:简单但效果差,后期无法精细调整
- 线性衰减:效果一般,衰减速度不够平滑
- Cosine衰减:效果最好,平滑衰减,广泛使用
第五部分:大规模训练技巧
这一部分解决什么问题?
核心问题:大模型参数量巨大(几十亿到万亿),单个GPU放不下,训练时间长达数月。如何高效训练?
解决方案:数据并行 + 模型并行 + 混合精度训练 + 梯度累积。
突破点:
- 数据并行:多GPU同时处理不同数据
- 模型并行:把模型切分到多个GPU
- 混合精度:用FP16加速,节省内存
- 梯度累积:模拟大batch size
局限:需要大量GPU(数百到数千块),成本极高。
5.1 为什么需要分布式训练?
单GPU的限制:
GPU内存:80GB(A100)
模型大小:7B参数 × 4字节/参数 = 28GB
优化器状态:28GB × 2 = 56GB(Adam需要存m和v)
梯度:28GB
激活值:10-20GB
总需求:28 + 56 + 28 + 20 = 132GB > 80GB ❌
训练时间:
假设:
- 模型:7B参数
- 数据:1T tokens
- 单GPU吞吐:1000 tokens/秒
- 需要时间:1T / 1000 = 10亿秒 ≈ 31年 ❌
解决方案:用多个GPU并行训练
5.2 数据并行(Data Parallelism)
核心思想:每个GPU有完整的模型副本,处理不同的数据
流程:
步骤1:每个GPU加载模型副本
GPU 0: 模型副本
GPU 1: 模型副本
GPU 2: 模型副本
GPU 3: 模型副本
步骤2:分配不同的数据
GPU 0: batch 0 (数据0-31)
GPU 1: batch 1 (数据32-63)
GPU 2: batch 2 (数据64-95)
GPU 3: batch 3 (数据96-127)
步骤3:各自计算梯度
GPU 0: 梯度0
GPU 1: 梯度1
GPU 2: 梯度2
GPU 3: 梯度3
步骤4:梯度平均(All-Reduce)
平均梯度 = (梯度0 + 梯度1 + 梯度2 + 梯度3) / 4
步骤5:所有GPU用平均梯度更新参数
所有GPU的参数保持同步
12岁版解释
就像4个学生一起做作业:
- 每人有一份相同的题目(模型)
- 每人做不同的练习题(数据)
- 做完后,大家交流答案(梯度平均)
- 所有人都学到了相同的知识(参数同步)
优点:
- ✅ 实现简单
- ✅ 加速明显(4个GPU ≈ 4倍速度)
- ✅ 适合中小模型(<10B参数)
缺点:
- ❌ 每个GPU需要存完整模型
- ❌ 大模型放不下
5.3 模型并行(Model Parallelism)
核心思想:把模型切分到多个GPU
方式1:层间并行(Pipeline Parallelism)
GPU 0: Layer 1-8
GPU 1: Layer 9-16
GPU 2: Layer 17-24
GPU 3: Layer 25-32
数据流:
输入 → GPU 0 → GPU 1 → GPU 2 → GPU 3 → 输出
问题:GPU利用率低
时间轴:
GPU 0: [计算] [等待] [等待] [等待]
GPU 1: [等待] [计算] [等待] [等待]
GPU 2: [等待] [等待] [计算] [等待]
GPU 3: [等待] [等待] [等待] [计算]
利用率:25% ❌
改进:微批次流水线(Micro-batch Pipeline)
把batch切成多个micro-batch,流水线处理
时间轴:
GPU 0: [mb1] [mb2] [mb3] [mb4]
GPU 1: [mb1] [mb2] [mb3] [mb4]
GPU 2: [mb1] [mb2] [mb3] [mb4]
GPU 3: [mb1] [mb2] [mb3] [mb4]
利用率:提升到70-80% ✅
方式2:张量并行(Tensor Parallelism)
把每一层的矩阵切分到多个GPU
例如:线性层 Y = X @ W
W的维度:[4096, 4096]
切分成4份:每个GPU存 [4096, 1024]
GPU 0: Y0 = X @ W0
GPU 1: Y1 = X @ W1
GPU 2: Y2 = X @ W2
GPU 3: Y3 = X @ W3
最后拼接:Y = [Y0, Y1, Y2, Y3]
优点:
- ✅ 可以训练超大模型
- ✅ 突破单GPU内存限制
缺点:
- ❌ 通信开销大
- ❌ 实现复杂
5.4 混合精度训练(Mixed Precision Training)
核心思想:用FP16(16位浮点数)代替FP32(32位浮点数)
FP32 vs FP16:
FP32(单精度):
- 32位:1位符号 + 8位指数 + 23位尾数
- 范围:±3.4 × 10^38
- 精度:7位有效数字
FP16(半精度):
- 16位:1位符号 + 5位指数 + 10位尾数
- 范围:±6.5 × 10^4
- 精度:3位有效数字
优点:
内存:减少50%
7B模型:28GB → 14GB
速度:提升2-3倍
GPU的FP16计算单元更多
问题:数值不稳定
FP16范围小:
- 太大的数:溢出(变成inf)
- 太小的数:下溢(变成0)
例如:
梯度 = 0.00001(FP32)
→ 0(FP16,下溢)❌
解决方案:混合精度
前向传播:FP16(快)
反向传播:FP16(快)
参数更新:FP32(精确)
关键技巧:
1. 损失缩放(Loss Scaling)
损失 × 1000 → 梯度 × 1000 → 避免下溢
更新时再除以1000
2. 主权重(Master Weights)
用FP32存储参数的"主副本"
FP16只用于计算
5.5 梯度累积(Gradient Accumulation)
问题:GPU内存有限,batch size受限
GPU内存:80GB
模型:28GB
单个样本:1GB
最大batch size:(80 - 28) / 1 = 52
但我们想要batch size = 512 ❌
解决方案:梯度累积
把大batch切成多个小batch,累积梯度
例如:batch size = 512,切成8个小batch(每个64)
步骤1:前向+反向(batch 0-63)
计算梯度,不更新参数
步骤2:前向+反向(batch 64-127)
累积梯度(梯度 += 新梯度)
...重复8次...
步骤8:前向+反向(batch 448-511)
累积梯度
步骤9:参数更新
用累积的梯度更新参数
清零梯度
12岁版解释
就像搬砖:
- 一次搬512块太重(内存不够)
- 分8次搬,每次64块(梯度累积)
- 搬完8次后,一起结算工资(参数更新)
代码示例:
accumulation_steps = 8
optimizer.zero_grad()
for i, batch in enumerate(dataloader):
# 前向传播
loss = model(batch)
# 反向传播(梯度累积)
loss = loss / accumulation_steps
loss.backward()
# 每accumulation_steps步更新一次
if (i + 1) % accumulation_steps == 0:
optimizer.step()
optimizer.zero_grad()5.6 实际训练配置(LLaMA-2 7B)
模型:7B参数
数据:2T tokens
GPU:2048 × A100 80GB
训练时间:约3周
并行策略:
- 数据并行:256路(256个GPU组)
- 张量并行:8路(每组8个GPU)
- 总GPU数:256 × 8 = 2048
Batch size:
- 全局batch size:4M tokens
- 每个GPU:4M / 2048 = 2048 tokens
- 序列长度:4096
- 每个GPU样本数:2048 / 4096 = 0.5
- 梯度累积:2步(0.5 × 2 = 1个样本)
优化器:AdamW
学习率:3e-4
Warmup:2000步
权重衰减:0.1
梯度裁剪:1.0
混合精度:FP16 + FP32主权重
5.7 分布式训练的完整代码框架
数据并行(DDP)实现:
import torch
import torch.distributed as dist
from torch.nn.parallel import DistributedDataParallel as DDP
from torch.utils.data.distributed import DistributedSampler
def setup_distributed():
"""初始化分布式训练环境"""
# 初始化进程组
dist.init_process_group(backend='nccl') # NCCL适合GPU
# 获取当前进程的rank和world_size
rank = dist.get_rank()
world_size = dist.get_world_size()
# 设置当前进程使用的GPU
torch.cuda.set_device(rank)
return rank, world_size
def cleanup_distributed():
"""清理分布式训练环境"""
dist.destroy_process_group()
def train_with_ddp():
"""使用DDP训练的完整示例"""
# 1. 初始化分布式环境
rank, world_size = setup_distributed()
device = torch.device(f'cuda:{rank}')
print(f"进程 {rank}/{world_size} 启动")
# 2. 创建模型并移到GPU
model = YourTransformerModel().to(device)
# 3. 包装为DDP模型
model = DDP(model, device_ids=[rank])
# 4. 创建优化器
optimizer = AdamW(model.parameters(), lr=6e-4, weight_decay=0.1)
# 5. 创建数据加载器(使用DistributedSampler)
dataset = YourDataset()
sampler = DistributedSampler(
dataset,
num_replicas=world_size,
rank=rank,
shuffle=True
)
dataloader = torch.utils.data.DataLoader(
dataset,
batch_size=32,
sampler=sampler,
num_workers=4,
pin_memory=True
)
# 6. 训练循环
model.train()
for epoch in range(num_epochs):
# 每个epoch开始时设置sampler的epoch(确保每个epoch的shuffle不同)
sampler.set_epoch(epoch)
for batch_idx, batch in enumerate(dataloader):
# 数据移到GPU
batch = {k: v.to(device) for k, v in batch.items()}
# 前向传播
loss = model(batch)
# 反向传播
optimizer.zero_grad()
loss.backward()
# 梯度裁剪
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
# 参数更新
optimizer.step()
# 只在rank 0打印日志
if rank == 0 and batch_idx % 100 == 0:
print(f"Epoch {epoch}, Batch {batch_idx}, Loss: {loss.item():.4f}")
# 7. 清理
cleanup_distributed()
# 启动命令(使用torchrun):
# torchrun --nproc_per_node=8 train.py梯度累积实现:
def train_with_gradient_accumulation(
model, dataloader, optimizer,
accumulation_steps=8,
max_grad_norm=1.0
):
"""
使用梯度累积训练
参数:
accumulation_steps: 累积多少步后更新一次参数
"""
model.train()
optimizer.zero_grad()
for batch_idx, batch in enumerate(dataloader):
# 前向传播
loss = model(batch)
# 损失除以累积步数(重要!)
loss = loss / accumulation_steps
# 反向传播(梯度累积)
loss.backward()
# 每accumulation_steps步更新一次参数
if (batch_idx + 1) % accumulation_steps == 0:
# 梯度裁剪
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=max_grad_norm)
# 参数更新
optimizer.step()
optimizer.zero_grad()
if batch_idx % 100 == 0:
print(f"Batch {batch_idx}, Loss: {loss.item() * accumulation_steps:.4f}")
# 使用示例
train_with_gradient_accumulation(
model=model,
dataloader=dataloader,
optimizer=optimizer,
accumulation_steps=8 # 模拟8倍的batch size
)混合精度训练实现:
from torch.cuda.amp import autocast, GradScaler
def train_with_mixed_precision(model, dataloader, optimizer):
"""
使用混合精度训练
优点:
- 速度提升2-3倍
- 内存减少50%
"""
model.train()
# 创建GradScaler(用于损失缩放)
scaler = GradScaler()
for batch_idx, batch in enumerate(dataloader):
optimizer.zero_grad()
# 使用autocast自动混合精度
with autocast():
# 前向传播(自动使用FP16)
loss = model(batch)
# 损失缩放 + 反向传播
scaler.scale(loss).backward()
# 梯度裁剪(需要先unscale)
scaler.unscale_(optimizer)
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
# 参数更新(自动处理缩放)
scaler.step(optimizer)
scaler.update()
if batch_idx % 100 == 0:
print(f"Batch {batch_idx}, Loss: {loss.item():.4f}")
# 使用示例
train_with_mixed_precision(
model=model,
dataloader=dataloader,
optimizer=optimizer
)完整训练循环(DDP + 梯度累积 + 混合精度):
def train_complete(
model, dataloader, optimizer, scheduler,
rank, world_size,
accumulation_steps=8,
max_grad_norm=1.0,
log_interval=100
):
"""
完整的训练循环:DDP + 梯度累积 + 混合精度
"""
model.train()
scaler = GradScaler()
for epoch in range(num_epochs):
dataloader.sampler.set_epoch(epoch)
for batch_idx, batch in enumerate(dataloader):
# 数据移到GPU
batch = {k: v.to(f'cuda:{rank}') for k, v in batch.items()}
# 混合精度前向传播
with autocast():
loss = model(batch)
loss = loss / accumulation_steps
# 反向传播
scaler.scale(loss).backward()
# 每accumulation_steps步更新一次
if (batch_idx + 1) % accumulation_steps == 0:
# 梯度裁剪
scaler.unscale_(optimizer)
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=max_grad_norm)
# 参数更新
scaler.step(optimizer)
scaler.update()
optimizer.zero_grad()
# 学习率调度
scheduler.step()
# 日志(只在rank 0打印)
if rank == 0 and (batch_idx + 1) % log_interval == 0:
current_lr = scheduler.get_last_lr()[0]
print(f"Epoch {epoch}, Batch {batch_idx + 1}, "
f"Loss: {loss.item() * accumulation_steps:.4f}, "
f"LR: {current_lr:.6f}")
# 启动命令:
# torchrun --nproc_per_node=8 train.py5.8 混合精度训练的内存对比
FP32 vs FP16内存占用对比:
组件 FP32 FP16 节省
─────────────────────────────────────────────────
模型参数(7B) 28 GB 14 GB 50%
优化器状态(Adam) 56 GB 28 GB 50%
梯度 28 GB 14 GB 50%
激活值(估计) 20 GB 10 GB 50%
─────────────────────────────────────────────────
总计 132 GB 66 GB 50%
结论:
- FP32:需要132GB,A100 80GB放不下 ❌
- FP16:需要66GB,A100 80GB可以放下 ✅
ASCII柱状图:
内存占用 (GB)
↑
140| ████████████████████
| ████████████████████
120| ████████████████████
| ████████████████████
100| ████████████████████
| ████████████████████
80| ████████████████████ ───────── A100容量上限
| ████████████████████ ██████████
60| ████████████████████ ██████████
| ████████████████████ ██████████
40| ████████████████████ ██████████
| ████████████████████ ██████████
20| ████████████████████ ██████████
| ████████████████████ ██████████
0| ──────────────────── ──────────
└──────────────────────────────────
FP32 FP16
(132GB) (66GB)
❌ 超出容量 ✅ 可以训练
5.9 并行策略对比分析
| 并行策略 | 优点 | 缺点 | 通信开销 | 适用场景 |
|---|---|---|---|---|
| 数据并行 | 实现简单 加速明显 通信少 | 每个GPU需要完整模型 大模型放不下 | 低 (只需同步梯度) | 中小模型 (<10B参数) |
| 张量并行 | 可训练超大模型 突破内存限制 | 通信开销大 实现复杂 | 高 (每层都需要通信) | 大模型 (>10B参数) |
| 流水线并行 | 可训练超大模型 通信较少 | GPU利用率低 需要微批次优化 | 中 (层间通信) | 超大模型 (>100B参数) |
| 混合并行 | 结合各种优点 最高效 | 配置复杂 需要调优 | 中 | 工业级训练 (GPT-3, LLaMA) |
实际选择建议:
模型大小 推荐策略 GPU数量
─────────────────────────────────────────────────
< 1B 数据并行 8-32
1B - 10B 数据并行 32-128
10B - 100B 数据并行 + 张量并行 128-1024
> 100B 数据并行 + 张量并行 1024+
+ 流水线并行
第六部分:实际案例深度分析
这一部分解决什么问题?
核心问题:理论很美好,但实际训练大模型会遇到什么问题?如何配置超参数?
解决方案:通过GPT-3、LLaMA-2等真实案例,学习实际训练经验。
价值:
- 了解工业级训练的完整流程
- 学习超参数选择的经验法则
- 避免常见的训练陷阱
6.1 GPT-3训练过程完整复盘
基本信息:
模型:GPT-3 175B
发布时间:2020年5月
训练机构:OpenAI
论文:Language Models are Few-Shot Learners
训练数据:
总量:300B tokens(约570GB文本)
数据来源:
1. Common Crawl(60%):410GB → 过滤后 180B tokens
- 网页抓取数据
- 质量参差不齐,需要大量过滤
2. WebText2(22%):66B tokens
- Reddit高质量链接
- 质量较高
3. Books1 + Books2(16%):48B tokens
- 电子书数据
- 长文本,有助于学习长程依赖
4. Wikipedia(3%):9B tokens
- 高质量百科数据
- 事实性强
数据混合策略:
- 不是均匀采样,而是按质量加权
- 高质量数据(Books, Wikipedia)重复采样
- 低质量数据(Common Crawl)降低采样率
模型架构:
参数量:175B
层数:96层
隐藏维度:12288
注意力头数:96
序列长度:2048
词表大小:50257
计算量:
- 每个token的FLOPs:约 6 × 175B = 1050 GFLOPs
- 总FLOPs:300B tokens × 1050 GFLOPs = 3.14 × 10^23 FLOPs
硬件配置:
GPU:10000+ × V100 32GB
训练时间:数月(具体未公开,估计3-4个月)
成本:估计400-500万美元(GPU租赁 + 电费)
并行策略:
- 数据并行:大规模
- 模型并行:每个模型分布在多个GPU
- 具体配置未公开
训练超参数:
优化器:AdamW
β1 = 0.9
β2 = 0.95
ε = 1e-8
权重衰减 = 0.1
学习率:6e-5(峰值)
Warmup:375M tokens(0.125%)
衰减:Cosine Decay到 6e-6(10%)
Batch size:3.2M tokens
序列长度:2048
样本数:3.2M / 2048 = 1562个样本
梯度裁剪:1.0
混合精度:FP16 + FP32主权重
训练过程中的关键事件:
阶段1:初期(0-50B tokens)
现象:
- 损失快速下降
- 困惑度从1000+降到100左右
- 模型开始学会基本语法
挑战:
- 训练不稳定,偶尔出现损失尖峰
- 需要仔细监控,及时调整学习率
解决:
- 使用较长的Warmup(375M tokens)
- 梯度裁剪设为1.0
- 监控梯度范数,发现异常立即回滚
阶段2:中期(50B-200B tokens)
现象:
- 损失稳定下降
- 困惑度降到30-40
- 模型开始展现few-shot能力
挑战:
- 训练速度变慢(损失下降变缓)
- 需要更多数据才能继续提升
解决:
- 保持学习率稳定(Cosine Decay的平台期)
- 增加数据多样性
阶段3:后期(200B-300B tokens)
现象:
- 损失缓慢下降
- 困惑度降到20左右
- 模型能力接近饱和
挑战:
- 边际收益递减
- 需要权衡训练成本和性能提升
决策:
- 在300B tokens停止训练
- 学习率已衰减到最小值
训练失败案例:
案例1:学习率过大导致训练崩溃
时间:训练初期(约10B tokens)
现象:
- 损失突然从3.5跳到100+
- 梯度范数爆炸(>1000)
- 模型输出全是乱码
原因:
- Warmup不足,学习率增长太快
- 某个batch的数据质量差,梯度异常
解决:
- 回滚到崩溃前的checkpoint
- 延长Warmup时间(从100M增加到375M tokens)
- 加强数据过滤
案例2:混合精度导致数值溢出
时间:训练中期(约150B tokens)
现象:
- 损失变成NaN
- 部分参数变成inf
- 训练无法继续
原因:
- FP16范围有限(±65504)
- 某些激活值过大,超出FP16范围
解决:
- 增加损失缩放系数(从1000增加到2000)
- 在关键层使用FP32(LayerNorm, Softmax)
- 监控激活值范围,及时调整
最终结果:
训练损失:约2.0
验证困惑度:约20
Zero-shot性能:显著提升
Few-shot性能:接近人类水平(某些任务)
关键发现:
1. 规模效应:模型越大,few-shot能力越强
2. 数据质量:高质量数据比数据量更重要
3. 训练稳定性:大模型训练需要极其小心
6.2 LLaMA-2 7B训练配置深度分析
为什么选择这些超参数?
学习率:3e-4(比GPT-3的6e-5大5倍)
原因:
1. 模型更小(7B vs 175B)
- 小模型可以承受更大的学习率
- 大模型需要更小心,避免不稳定
2. 数据更多(2T vs 300B tokens)
- 更多数据需要更快的学习速度
- 否则训练时间过长
3. 优化器配置不同
- β2 = 0.95(vs GPT-3的0.95)
- 更激进的二阶矩估计
经验法则:
- 1B模型:学习率约 1e-3
- 7B模型:学习率约 3e-4
- 70B模型:学习率约 1e-4
- 175B模型:学习率约 6e-5
Warmup:2000步(vs GPT-3的375M tokens)
为什么用"步数"而不是"tokens"?
- LLaMA-2论文用步数更方便
- 2000步 × 4M tokens/步 = 8B tokens
- 占总训练量的0.4%(2T tokens)
为什么比GPT-3少?
- GPT-3:375M / 300B = 0.125%
- LLaMA-2:8B / 2T = 0.4%
- 实际上LLaMA-2的Warmup比例更大!
原因:
- 更大的学习率需要更长的Warmup
- 确保训练初期稳定
Batch size:4M tokens(vs GPT-3的3.2M)
为什么更大?
1. 更多GPU(2048 vs 估计1000+)
- 更大的batch size可以充分利用GPU
2. 更稳定的训练
- 大batch size减少梯度噪声
- 有助于大学习率的稳定性
3. 更快的收敛
- 大batch size意味着更少的更新步数
- 2T tokens / 4M = 500K步
权衡:
- 优点:训练稳定,收敛快
- 缺点:可能影响泛化能力(但影响不大)
并行策略:256×8(数据并行×张量并行)
为什么这样配置?
数据并行256路:
- 每路处理 4M / 256 = 16K tokens
- 每路8个GPU,每个GPU处理 16K / 8 = 2K tokens
- 序列长度4096,每个GPU处理0.5个样本
- 需要梯度累积2步
张量并行8路:
- 7B模型,每层约200M参数
- 8个GPU分摊,每个GPU约25M参数
- 单个A100 80GB可以轻松容纳
- 通信开销可控(8路不算多)
为什么不用更多张量并行?
- 16路:通信开销翻倍,收益递减
- 4路:每个GPU负担过重,内存紧张
- 8路:最佳平衡点
为什么不用流水线并行?
- 7B模型不够大,不需要流水线
- 流水线并行适合100B+模型
权重衰减:0.1(和GPT-3相同)
为什么是0.1?
- 经验值,广泛验证有效
- 太小(0.01):正则化不足,可能过拟合
- 太大(0.5):正则化过度,欠拟合
如何选择?
1. 从0.1开始
2. 观察训练/验证损失的差距
3. 差距大:增加权重衰减
4. 差距小:减少权重衰减
6.3 训练失败案例集锦
案例1:学习率选择不当
场景:训练一个3B模型
配置:学习率 = 1e-3(太大)
现象:
- 前100步:损失从8.0降到4.0(看起来很好)
- 100-200步:损失在3.5-4.5之间震荡
- 200步后:损失突然跳到10+,训练崩溃
诊断:
- 学习率过大,步子太大
- 初期运气好,没有遇到陡峭的梯度
- 后期遇到陡峭区域,一步跳出了最优区域
解决:
- 降低学习率到3e-4
- 增加Warmup步数(从1000增加到5000)
- 重新训练,稳定收敛
案例2:Warmup不足
场景:训练一个7B模型
配置:Warmup = 100步(太少)
现象:
- 前10步:损失从8.0降到6.0
- 10-50步:损失剧烈震荡(5.0-7.0)
- 50步后:损失稳定下降,但收敛速度慢
诊断:
- Warmup太短,学习率增长太快
- 初期梯度不稳定,导致参数震荡
- 虽然没有崩溃,但浪费了很多步数
解决:
- 增加Warmup到2000步
- 损失曲线变得平滑
- 收敛速度提升20%
案例3:梯度累积配置错误
场景:训练一个7B模型
配置:
- 目标batch size = 4M tokens
- 每个GPU = 4096 tokens
- GPU数量 = 1024
- 梯度累积 = 1步
问题:
- 实际batch size = 4096 × 1024 = 4.2M tokens ✓
- 但忘记在loss.backward()前除以accumulation_steps
现象:
- 损失下降非常慢
- 梯度范数异常大(>10)
- 训练效率低
诊断:
- 梯度累积时,每步的梯度都是完整的
- 累积N步后,梯度是N倍
- 如果不除以N,相当于学习率放大了N倍
解决:
- 在backward前:loss = loss / accumulation_steps
- 或在optimizer中调整学习率
案例4:混合精度的损失缩放问题
场景:训练一个13B模型
配置:FP16 + 损失缩放1000
现象:
- 前1000步:正常训练
- 1000步后:损失变成NaN
- 检查发现某些梯度变成inf
诊断:
- 损失缩放1000,梯度也放大1000倍
- 某些梯度本身就很大(如Embedding层)
- 放大后超出FP16范围(±65504)
解决:
- 使用动态损失缩放(GradScaler自动调整)
- 或降低初始缩放系数到512
- 监控梯度范围,及时调整
第七部分:故障排查完全指南
这一部分解决什么问题?
核心问题:训练大模型时会遇到各种问题,如何快速诊断和解决?
解决方案:系统化的故障排查流程和解决方案。
价值:
- 节省调试时间
- 避免重复踩坑
- 提高训练成功率
7.1 梯度问题诊断与解决
问题1:梯度爆炸
识别特征:
# 监控梯度范数
total_norm = 0
for p in model.parameters():
if p.grad is not None:
param_norm = p.grad.data.norm(2)
total_norm += param_norm.item() ** 2
total_norm = total_norm ** 0.5
print(f"梯度范数: {total_norm:.2f}")
# 判断标准
if total_norm > 100:
print("⚠️ 梯度爆炸!")
elif total_norm > 10:
print("⚠️ 梯度偏大,需要注意")
else:
print("✅ 梯度正常")常见原因:
1. 学习率过大
- 症状:梯度范数>1000,损失突然跳到很大
- 解决:降低学习率(减半或1/10)
2. 初始化不当
- 症状:第一步就梯度爆炸
- 解决:使用Xavier或He初始化
3. 数据异常
- 症状:某些batch梯度特别大
- 解决:检查数据,过滤异常样本
4. 网络结构问题
- 症状:某些层的梯度特别大
- 解决:添加LayerNorm,使用残差连接
解决方案:
# 方案1:梯度裁剪(最常用)
torch.nn.utils.clip_grad_norm_(
model.parameters(),
max_norm=1.0 # GPT-3使用1.0
)
# 方案2:降低学习率
optimizer = AdamW(model.parameters(), lr=3e-4) # 原来6e-4
# 方案3:增加Warmup
scheduler = CosineWarmupScheduler(
optimizer,
warmup_steps=5000, # 原来2000
total_steps=100000
)
# 方案4:检查数据
def check_batch_quality(batch):
"""检查batch是否有异常"""
# 检查是否有NaN
if torch.isnan(batch['input_ids']).any():
print("⚠️ 数据包含NaN")
return False
# 检查数值范围
if batch['input_ids'].max() > vocab_size:
print("⚠️ Token ID超出词表范围")
return False
return True问题2:梯度消失
识别特征:
# 监控每层的梯度
for name, param in model.named_parameters():
if param.grad is not None:
grad_norm = param.grad.data.norm(2).item()
print(f"{name}: {grad_norm:.6f}")
if grad_norm < 1e-6:
print(f"⚠️ {name} 梯度消失!")常见原因:
1. 网络太深
- 症状:底层梯度接近0
- 解决:使用残差连接(Transformer已有)
2. 激活函数问题
- 症状:Sigmoid/Tanh导致梯度消失
- 解决:使用ReLU/GELU(Transformer已用GELU)
3. 学习率过小
- 症状:所有层梯度都很小,损失下降极慢
- 解决:增大学习率
4. 初始化问题
- 症状:第一步梯度就很小
- 解决:调整初始化方差
解决方案:
# 方案1:检查残差连接
class TransformerBlock(nn.Module):
def forward(self, x):
# 确保有残差连接
x = x + self.attention(x) # ✅
x = x + self.ffn(x) # ✅
return x
# 方案2:增大学习率
optimizer = AdamW(model.parameters(), lr=1e-3) # 原来3e-4
# 方案3:检查初始化
def check_initialization(model):
"""检查模型初始化是否合理"""
for name, param in model.named_parameters():
std = param.data.std().item()
print(f"{name}: std={std:.6f}")
if std < 1e-4:
print(f"⚠️ {name} 初始化方差过小")
elif std > 1.0:
print(f"⚠️ {name} 初始化方差过大")7.2 训练不稳定问题处理
问题1:损失突然上升
诊断流程:
def diagnose_loss_spike(loss_history, current_step):
"""诊断损失突然上升的原因"""
# 1. 检查是否是损失尖峰
recent_losses = loss_history[-100:]
avg_loss = sum(recent_losses) / len(recent_losses)
current_loss = loss_history[-1]
if current_loss > avg_loss * 2:
print(f"⚠️ 损失尖峰!当前: {current_loss:.4f}, 平均: {avg_loss:.4f}")
# 2. 检查梯度
total_norm = compute_grad_norm(model)
if total_norm > 100:
print("原因:梯度爆炸")
return "gradient_explosion"
# 3. 检查学习率
current_lr = scheduler.get_last_lr()[0]
if current_lr > 1e-3:
print("原因:学习率过大")
return "lr_too_high"
# 4. 检查数据
if not check_batch_quality(current_batch):
print("原因:数据异常")
return "bad_data"
return "unknown"解决方案:
# 方案1:回滚到之前的checkpoint
def rollback_to_checkpoint(model, optimizer, checkpoint_path):
"""回滚到之前的checkpoint"""
checkpoint = torch.load(checkpoint_path)
model.load_state_dict(checkpoint['model'])
optimizer.load_state_dict(checkpoint['optimizer'])
print(f"✅ 已回滚到步数 {checkpoint['step']}")
# 方案2:降低学习率
for param_group in optimizer.param_groups:
param_group['lr'] *= 0.5
print(f"✅ 学习率降低到 {optimizer.param_groups[0]['lr']:.6f}")
# 方案3:跳过异常batch
if not check_batch_quality(batch):
print("⚠️ 跳过异常batch")
continue问题2:困惑度震荡
现象:
步数 困惑度
1000 45.2
2000 42.8
3000 46.1 ← 上升了
4000 43.5
5000 47.3 ← 又上升了
诊断:
def diagnose_ppl_oscillation(ppl_history):
"""诊断困惑度震荡"""
# 计算震荡幅度
recent_ppl = ppl_history[-10:]
ppl_std = np.std(recent_ppl)
ppl_mean = np.mean(recent_ppl)
oscillation_ratio = ppl_std / ppl_mean
if oscillation_ratio > 0.1:
print(f"⚠️ 困惑度震荡严重: {oscillation_ratio:.2%}")
# 可能原因
print("可能原因:")
print("1. 学习率过大")
print("2. Batch size过小")
print("3. 数据分布不均匀")解决方案:
# 方案1:降低学习率
optimizer = AdamW(model.parameters(), lr=1e-4) # 原来3e-4
# 方案2:增大batch size
# 通过梯度累积实现
accumulation_steps = 16 # 原来8
# 方案3:数据shuffle
dataloader = DataLoader(
dataset,
batch_size=32,
shuffle=True, # 确保开启shuffle
sampler=DistributedSampler(dataset, shuffle=True)
)7.3 内存优化方案
问题:GPU内存溢出(OOM)
诊断:
def diagnose_memory_usage():
"""诊断内存使用情况"""
# 获取当前内存使用
allocated = torch.cuda.memory_allocated() / 1024**3 # GB
reserved = torch.cuda.memory_reserved() / 1024**3 # GB
max_allocated = torch.cuda.max_memory_allocated() / 1024**3
print(f"当前分配: {allocated:.2f} GB")
print(f"当前保留: {reserved:.2f} GB")
print(f"峰值分配: {max_allocated:.2f} GB")
# 分析内存占用
print("\n内存占用分析:")
print(f"模型参数: {get_model_memory(model):.2f} GB")
print(f"优化器状态: {get_optimizer_memory(optimizer):.2f} GB")
print(f"梯度: {get_gradient_memory(model):.2f} GB")
print(f"激活值: {allocated - get_model_memory(model) - get_gradient_memory(model):.2f} GB")
def get_model_memory(model):
"""计算模型参数占用的内存"""
param_size = 0
for param in model.parameters():
param_size += param.nelement() * param.element_size()
return param_size / 1024**3
def get_optimizer_memory(optimizer):
"""计算优化器状态占用的内存"""
# Adam: 2倍参数量(m和v)
return get_model_memory(model) * 2
def get_gradient_memory(model):
"""计算梯度占用的内存"""
grad_size = 0
for param in model.parameters():
if param.grad is not None:
grad_size += param.grad.nelement() * param.grad.element_size()
return grad_size / 1024**3解决方案1:梯度检查点(Gradient Checkpointing)
from torch.utils.checkpoint import checkpoint
class TransformerBlockWithCheckpoint(nn.Module):
def __init__(self, config):
super().__init__()
self.attention = Attention(config)
self.ffn = FeedForward(config)
def forward(self, x):
# 使用checkpoint,不保存中间激活值
x = x + checkpoint(self.attention, x)
x = x + checkpoint(self.ffn, x)
return x
# 效果:
# - 内存减少:50-70%
# - 速度降低:20-30%(需要重新计算)
# - 适合:内存紧张时使用解决方案2:减小batch size
# 原配置
batch_size = 32
accumulation_steps = 1
# 新配置(保持有效batch size不变)
batch_size = 16 # 减半
accumulation_steps = 2 # 翻倍
# 效果:
# - 内存减少:约30-40%
# - 速度影响:几乎没有解决方案3:减小序列长度
# 原配置
seq_length = 4096
# 新配置
seq_length = 2048 # 减半
# 效果:
# - 内存减少:约40-50%
# - 注意:会影响模型能力(长文本处理)解决方案4:使用更高效的注意力
# 原始注意力:O(n²)内存
def standard_attention(Q, K, V):
scores = Q @ K.T # [seq_len, seq_len] 占用大量内存
attn = softmax(scores)
return attn @ V
# Flash Attention:O(n)内存
from flash_attn import flash_attn_func
def flash_attention(Q, K, V):
# 内存高效的实现
return flash_attn_func(Q, K, V)
# 效果:
# - 内存减少:50-70%(长序列)
# - 速度提升:2-4倍
# - 需要:安装flash-attn库7.4 学习率选择指南
方法1:学习率范围测试(LR Range Test)
def lr_range_test(model, dataloader, min_lr=1e-7, max_lr=1e-2, num_steps=1000):
"""
学习率范围测试
原理:
- 从很小的学习率开始
- 指数增长到很大的学习率
- 记录每个学习率对应的损失
- 找到损失下降最快的学习率
"""
model.train()
optimizer = AdamW(model.parameters(), lr=min_lr)
lr_schedule = np.logspace(np.log10(min_lr), np.log10(max_lr), num_steps)
losses = []
lrs = []
for step, batch in enumerate(dataloader):
if step >= num_steps:
break
# 设置当前学习率
current_lr = lr_schedule[step]
for param_group in optimizer.param_groups:
param_group['lr'] = current_lr
# 训练一步
loss = model(batch)
loss.backward()
optimizer.step()
optimizer.zero_grad()
# 记录
losses.append(loss.item())
lrs.append(current_lr)
if step % 100 == 0:
print(f"步数 {step}, LR: {current_lr:.6f}, Loss: {loss.item():.4f}")
# 找到最佳学习率
# 方法1:损失最小的点
best_idx = np.argmin(losses)
best_lr_v1 = lrs[best_idx]
# 方法2:损失下降最快的点(推荐)
gradients = np.gradient(losses)
best_idx = np.argmin(gradients)
best_lr_v2 = lrs[best_idx]
print(f"\n推荐学习率:")
print(f"方法1(损失最小): {best_lr_v1:.6f}")
print(f"方法2(下降最快): {best_lr_v2:.6f}")
print(f"建议使用: {best_lr_v2 / 10:.6f} (最佳值的1/10)")
return lrs, losses
# 使用示例
lrs, losses = lr_range_test(model, dataloader)
# 可视化(如果有matplotlib)
# plt.plot(lrs, losses)
# plt.xscale('log')
# plt.xlabel('Learning Rate')
# plt.ylabel('Loss')
# plt.show()方法2:根据模型大小选择
def recommend_lr_by_model_size(num_params):
"""
根据模型大小推荐学习率
经验法则(AdamW):
- 100M-1B: 1e-3
- 1B-10B: 3e-4
- 10B-100B: 1e-4
- 100B+: 6e-5
"""
if num_params < 1e9: # <1B
return 1e-3
elif num_params < 10e9: # 1B-10B
return 3e-4
elif num_params < 100e9: # 10B-100B
return 1e-4
else: # >100B
return 6e-5
# 使用示例
num_params = sum(p.numel() for p in model.parameters())
recommended_lr = recommend_lr_by_model_size(num_params)
print(f"模型参数量: {num_params / 1e9:.2f}B")
print(f"推荐学习率: {recommended_lr:.6f}")方法3:根据batch size调整
def adjust_lr_by_batch_size(base_lr, base_batch_size, actual_batch_size):
"""
根据batch size调整学习率
线性缩放规则:
- batch size翻倍,学习率也翻倍
- 但有上限,避免过大
例如:
- 基准:batch_size=256, lr=3e-4
- 实际:batch_size=1024
- 调整:lr = 3e-4 * (1024/256) = 1.2e-3
"""
scale = actual_batch_size / base_batch_size
adjusted_lr = base_lr * scale
# 设置上限(避免过大)
max_lr = base_lr * 4
adjusted_lr = min(adjusted_lr, max_lr)
print(f"基准batch size: {base_batch_size}, LR: {base_lr:.6f}")
print(f"实际batch size: {actual_batch_size}, LR: {adjusted_lr:.6f}")
return adjusted_lr
# 使用示例
base_lr = 3e-4
base_batch_size = 256
actual_batch_size = 1024
adjusted_lr = adjust_lr_by_batch_size(base_lr, base_batch_size, actual_batch_size)Warmup步数选择:
def recommend_warmup_steps(total_steps, lr):
"""
推荐Warmup步数
经验法则:
- 总步数的0.1%-1%
- 学习率越大,Warmup越长
"""
# 基础比例
if lr >= 1e-3:
ratio = 0.01 # 1%
elif lr >= 3e-4:
ratio = 0.005 # 0.5%
else:
ratio = 0.001 # 0.1%
warmup_steps = int(total_steps * ratio)
# 设置下限和上限
warmup_steps = max(warmup_steps, 100) # 至少100步
warmup_steps = min(warmup_steps, 10000) # 最多10000步
print(f"总步数: {total_steps}")
print(f"学习率: {lr:.6f}")
print(f"推荐Warmup: {warmup_steps}步 ({ratio*100:.2f}%)")
return warmup_steps
# 使用示例
total_steps = 100000
lr = 3e-4
warmup_steps = recommend_warmup_steps(total_steps, lr)第八部分:深度数学推导
这一部分解决什么问题?
核心问题:为什么这些公式是这样的?背后的数学原理是什么?
解决方案:从信息论、概率论、优化理论角度深入理解。
价值:
- 理解”为什么”而不只是”是什么”
- 能够自己推导和改进算法
- 面试和研究必备
8.1 Softmax的信息论解释
问题:为什么用exp而不是其他函数?
从最大熵原理推导:
假设我们有一个概率分布 ,需要满足:
- 所有概率非负:
- 概率和为1:
- 期望得分等于观测得分:
目标:在满足约束的前提下,找到熵最大的分布(最不确定的分布)
熵的定义:
拉格朗日函数:
求导并令其为0:
解得:
应用约束 :
最终得到:
令 (温度参数),就得到了Softmax公式!
关键洞察
Softmax是在给定约束下,熵最大的概率分布。这意味着它是”最不偏向任何选项”的分布,只根据得分来决定概率。
为什么不用其他函数?
尝试1:线性归一化
问题:
- 无法处理负数
- 不满足最大熵原理
- 无法放大差异
尝试2:平方归一化
问题:
- 负数变正数,丢失了符号信息
- 不满足最大熵原理
尝试3:Sigmoid
问题:
- 概率和不为1
- 只适合二分类
结论:只有exp函数满足最大熵原理,且能处理任意实数。
8.2 交叉熵的深度推导
从KL散度推导交叉熵
KL散度定义(衡量两个分布的差异):
其中:
- :真实分布
- :模型预测分布
展开:
其中:
- :真实分布的熵(常数)
- :交叉熵
最小化KL散度:
由于 是常数,等价于:
在语言模型中:
- 真实分布 :one-hot编码,,其他为0
- 模型分布 :模型预测的概率分布
交叉熵简化为:
这就是我们使用的交叉熵损失!
关键洞察
最小化交叉熵 = 最小化KL散度 = 让模型分布尽可能接近真实分布。
为什么用log?
从信息论角度:
信息量的定义:
含义:
- 概率越小的事件,信息量越大
- 概率为1的事件,信息量为0
例子:
事件1:"太阳从东边升起" → P=1 → I=0(没有信息)
事件2:"明天下雨" → P=0.5 → I=1(有一些信息)
事件3:"中彩票" → P=0.0001 → I=13.3(信息量很大)
交叉熵的含义:
即:在真实分布P下,使用模型Q编码所需的平均信息量(比特数)。
最小化交叉熵 = 最小化编码长度 = 最优压缩
8.3 Adam的数学原理
问题:为什么Adam有效?
回顾Adam更新规则:
# 一阶矩(梯度的指数移动平均)
m_t = β1 × m_{t-1} + (1 - β1) × g_t
# 二阶矩(梯度平方的指数移动平均)
v_t = β2 × v_{t-1} + (1 - β2) × g_t²
# 偏差修正
m_hat = m_t / (1 - β1^t)
v_hat = v_t / (1 - β2^t)
# 参数更新
θ_t = θ_{t-1} - η × m_hat / (√v_hat + ε)为什么需要一阶矩(动量)?
问题:梯度下降容易震荡
损失曲面(2D示例):
↑
| ╱╲╱╲╱╲
| ╱ ╲ ╲
| ╱ ╲ ╲
| ╱ ╲ ╲___
| ╱ ╲ ╲___
|╱__________╲__________╲___
└────────────────────────→
梯度下降路径:
起点 → ↘ ↗ ↘ ↗ ↘ ↗ → 终点
(震荡,收敛慢)
动量路径:
起点 → ↘ → → → → → 终点
(平滑,收敛快)
数学解释:
一阶矩 是梯度的指数移动平均:
当 时,相当于对过去10步的梯度加权平均:
- 当前梯度:权重 0.1
- 1步前:权重 0.09
- 2步前:权重 0.081
- …
效果:
- 如果梯度方向一致: 累积,加速收敛
- 如果梯度方向震荡: 抵消,减少震荡
为什么需要二阶矩(自适应学习率)?
问题:不同参数需要不同学习率
参数1:梯度大(变化快)→ 需要小学习率
参数2:梯度小(变化慢)→ 需要大学习率
解决:用梯度的历史方差来调整学习率
- 如果 大(梯度历史上很大):有效学习率小
- 如果 小(梯度历史上很小):有效学习率大
为什么需要偏差修正?
问题:初始时 ,导致初期估计偏小
例子:
真实梯度:g = 1.0
β1 = 0.9
步数1:m_1 = 0.9 × 0 + 0.1 × 1.0 = 0.1(偏小!真实应该是1.0)
步数2:m_2 = 0.9 × 0.1 + 0.1 × 1.0 = 0.19(还是偏小)
步数3:m_3 = 0.9 × 0.19 + 0.1 × 1.0 = 0.271(逐渐接近)
...
偏差修正:
步数1:m_hat_1 = 0.1 / (1 - 0.9^1) = 0.1 / 0.1 = 1.0 ✅
步数2:m_hat_2 = 0.19 / (1 - 0.9^2) = 0.19 / 0.19 = 1.0 ✅
Adam vs 其他优化器:
| 优化器 | 动量 | 自适应LR | 偏差修正 | 适用场景 |
|---|---|---|---|---|
| SGD | ❌ | ❌ | ❌ | 简单任务 |
| Momentum | ✅ | ❌ | ❌ | 凸优化 |
| RMSprop | ❌ | ✅ | ❌ | RNN训练 |
| Adam | ✅ | ✅ | ✅ | 大模型训练 |
| AdamW | ✅ | ✅ | ✅ + 权重衰减 | 大模型预训练 |
8.4 Warmup的数学解释
问题:为什么初期需要小学习率?
从梯度方差的角度分析:
训练初期,参数是随机初始化的,梯度的方差很大:
初期:
- 模型输出接近随机
- 不同batch的梯度差异很大
- 很大
后期:
- 模型已经学到一些规律
- 不同batch的梯度比较一致
- 较小
梯度方差对训练的影响:
参数更新:
期望更新方向:
更新方差:
如果学习率太大:
Warmup的作用:
- 初期:小学习率 → 小更新方差 → 稳定
- 后期:大学习率 → 快速收敛
最优Warmup步数的理论分析:
根据经验和理论分析,最优Warmup步数约为:
其中:
- :模型维度(参数量)
- :最大学习率
- :batch size
例子:
模型:7B参数
学习率:3e-4
Batch size:4M tokens
T_warmup ≈ 7e9 / ((3e-4)^2 × 4e6)
≈ 7e9 / 3.6e-1
≈ 2e10 步?(太大了)
实际:使用经验值 0.5% × 总步数 ≈ 2000步
理论公式给出的是上界,实际使用经验值即可。
🧪 自我检验:20道思考题
基础题(检验概念理解)
-
什么是预训练?它和微调有什么区别?
-
为什么预训练的任务是”预测下一个词”?
-
Softmax函数的作用是什么?为什么不用简单归一化?
-
什么是温度参数(Temperature)?它如何影响输出?
-
什么是损失函数?它衡量什么?
-
为什么损失函数用对数(log)?
-
什么是困惑度(PPL)?它和损失的关系是什么?
中等题(检验原理理解)
-
从”今天天气很”到预测”好”,完整的计算流程是什么?
-
交叉熵损失函数的公式是什么?如何计算?
-
为什么需要优化器?简单的梯度下降有什么问题?
-
Adam优化器的核心思想是什么?它解决了什么问题?
-
什么是学习率调度?为什么需要Warmup?
-
为什么需要分布式训练?单GPU有什么限制?
-
数据并行和模型并行有什么区别?各适合什么场景?
-
什么是混合精度训练?它如何加速训练?
进阶题(检验应用理解)
-
手算一个Softmax例子:得分[2.0, 1.0, 0.5],计算概率分布。
-
给定预测概率P(“好”)=0.6,计算损失和困惑度。
-
为什么FP16会导致数值不稳定?如何解决?
-
什么是梯度累积?它如何模拟大batch size?
-
如果你要训练一个7B模型,需要多少GPU?如何配置并行策略?
📚 参考答案
基础题答案
-
预训练和微调的区别
- 预训练:在海量无标注文本上学习”预测下一个词”,让模型学会语言的基本规律。数据量大(TB级),时间长(数周到数月),成本高(数百万美元)。结果是Base Model,会续写文本。
- 微调:在少量标注数据上学习特定任务(如对话、翻译),让模型学会任务规律。数据量小(GB级),时间短(数小时到数天),成本低(数千到数万美元)。结果是Chat Model,会对话。
-
为什么是”预测下一个词” 三个原因:
- 不需要标注:文本本身就是标签,成本几乎为0
- 任务自然:人类也是这样学语言的(大量阅读)
- 迫使理解:要预测下一个词,模型必须理解语法、语义、常识
-
Softmax的作用 把任意实数得分转换成概率分布(和为1,都是正数)。简单归一化无法处理负数,也无法放大差异。Softmax通过exp函数放大差异,让重要的值更突出。
-
温度参数的作用 控制输出的”确定性”:
- 低温(T<1):概率更集中,输出更确定(适合代码生成、翻译)
- 高温(T>1):概率更平均,输出更随机(适合创作、头脑风暴)
- 标准(T=1):正常分布
-
损失函数的作用 衡量模型预测和真实答案的差距。损失越小,预测越准确。模型的目标是最小化损失。
-
为什么用对数 三个原因:
- 数值稳定:概率很小时(如0.001),直接用容易下溢,用-log(0.001)=6.91更稳定
- 乘法变加法:多个位置的联合概率是乘法,取对数后变加法,更稳定
- 梯度更好:预测很差时梯度大,快速改进;预测很好时梯度小,避免过度调整
-
困惑度和损失的关系 PPL = e
中等题答案
-
完整的计算流程
输入:"今天天气很" → 分词:["今天", "天气", "很"] → [1234, 5678, 9012] → Embedding:每个ID变成512维向量 → 位置编码:加上位置信息 → Transformer:Self-Attention + FFN处理 → 取最后位置的输出向量(512维) → 线性层:映射到词表大小(50000维) → Softmax:转换成概率分布 → 选择概率最高的词:7890 → "好" -
交叉熵损失公式
计算步骤:
- 对每个位置,看模型预测正确词的概率
- 取对数,加负号
- 所有位置平均
例如:P(“天气”)=0.9, P(“很”)=0.7, P(“好”)=0.8 损失 = -(log(0.9) + log(0.7) + log(0.8)) / 3 = 0.228
-
简单梯度下降的问题
- 学习率难选:太大震荡,太小收敛慢
- 所有参数用同一个学习率:不同参数需要不同学习率
- 容易卡在鞍点:梯度为0就停止更新
Adam解决:自适应调整每个参数的学习率,考虑历史梯度(动量)。
-
Adam的核心思想 两个关键机制:
- 动量(Momentum):记住过去的梯度方向,避免震荡
- 自适应学习率:梯度大的参数用小学习率,梯度小的参数用大学习率
AdamW = Adam + 权重衰减,防止过拟合。
-
学习率调度和Warmup 学习率调度:训练初期用大学习率快速接近最优点,后期用小学习率精细调整。常用Cosine Decay。
Warmup:前N步学习率从0线性增加到最大值。作用是避免初期梯度不稳定导致的震荡。通常Warmup步数是总步数的0.1%-1%。
-
分布式训练的必要性 单GPU限制:
- 内存:7B模型需要132GB(模型28GB + 优化器56GB + 梯度28GB + 激活20GB),但A100只有80GB
- 时间:单GPU训练1T tokens需要31年
解决:用多个GPU并行训练,加速并突破内存限制。
-
数据并行 vs 模型并行
- 数据并行:每个GPU有完整模型副本,处理不同数据。优点是实现简单,加速明显。缺点是每个GPU需要存完整模型,大模型放不下。适合中小模型(<10B)。
- 模型并行:把模型切分到多个GPU。优点是可以训练超大模型。缺点是通信开销大,实现复杂。适合大模型(>10B)。
-
混合精度训练 用FP16(16位)代替FP32(32位)进行计算。优点:内存减少50%,速度提升2-3倍。问题:FP16范围小,容易溢出或下溢。解决:前向和反向用FP16(快),参数更新用FP32(精确),损失缩放避免下溢。
进阶题答案
-
手算Softmax 得分:[2.0, 1.0, 0.5]
步骤1:计算e
- e^2.0 = 7.39
- e^1.0 = 2.72
- e^0.5 = 1.65
- 总和 = 11.76
步骤2:除以总和
- P(1) = 7.39 / 11.76 = 0.63 (63%)
- P(2) = 2.72 / 11.76 = 0.23 (23%)
- P(3) = 1.65 / 11.76 = 0.14 (14%)
-
计算损失和困惑度 P(“好”) = 0.6
损失 = -log(0.6) = 0.511 困惑度 = e^0.511 = 1.67
含义:模型平均在1.67个词中犹豫,预测比较准确。
-
FP16的数值不稳定 FP16范围小(±6.5×10
- 太大的数溢出(变成inf)
- 太小的数下溢(变成0),如梯度0.00001 → 0
解决:
- 损失缩放:损失×1000 → 梯度×1000,避免下溢,更新时再除以1000
- 主权重:用FP32存储参数的”主副本”,FP16只用于计算
-
梯度累积 把大batch切成多个小batch,累积梯度后再更新。
例如:想要batch size=512,但GPU只能放64
- 前向+反向(batch 0-63),计算梯度,不更新
- 前向+反向(batch 64-127),累积梯度
- 重复8次
- 用累积的梯度更新参数
效果:模拟了batch size=512,但内存只需要64的大小。
-
训练7B模型的配置 参考LLaMA-2 7B:
- GPU数量:2048 × A100 80GB
- 并行策略:数据并行256路 × 张量并行8路
- 全局batch size:4M tokens
- 每个GPU:2048 tokens(约0.5个样本,需要梯度累积2步)
- 优化器:AdamW,学习率3e-4
- 混合精度:FP16 + FP32主权重
- 训练时间:约3周(2T tokens)
📚 核心要点总结
一句话总结
预训练通过让模型在海量文本上学习"预测下一个词",让模型掌握语言的基本规律。核心是交叉熵损失函数 + Adam优化器 + 学习率调度 + 分布式训练。
关键概念
预训练流程:
- 任务:预测下一个词(自监督学习)
- 数据:海量无标注文本(TB级)
- 模型:Transformer架构
- 损失:交叉熵(衡量预测和真实的差距)
- 优化:AdamW + Cosine Decay + Warmup
- 训练:数据并行 + 模型并行 + 混合精度
核心公式:
Softmax:
交叉熵损失:
困惑度:
Adam更新:
实际训练参数(GPT-3)
模型:175B参数
数据:300B tokens
GPU:10000+ × V100
训练时间:数月
优化器:AdamW
β1 = 0.9, β2 = 0.95
权重衰减 = 0.1
学习率:6e-5
Warmup:375M tokens
Cosine Decay到6e-6
Batch size:3.2M tokens
混合精度:FP16 + FP32
梯度裁剪:1.0
下一步学习
完成预训练后,模型只会”续写”,还需要:
- 指令微调(SFT):教模型如何回答问题
- 人类反馈强化学习(RLHF):让回答更符合人类偏好
- 部署优化:量化、推理加速
🎓 延伸阅读
论文:
- Attention Is All You Need (2017) - Transformer原论文
- Language Models are Unsupervised Multitask Learners (2019) - GPT-2
- Language Models are Few-Shot Learners (2020) - GPT-3
- Training Compute-Optimal Large Language Models (2022) - Chinchilla定律
博客:
- The Illustrated Transformer - Jay Alammar
- How GPT-3 Works - Visualizations and Animations
- Scaling Laws for Neural Language Models - OpenAI
代码:
- nanoGPT - Andrej Karpathy(最简洁的GPT实现)
- Megatron-LM - NVIDIA(大规模训练框架)
- DeepSpeed - Microsoft(分布式训练优化)