最优化
优化器
梯度下降 (Gradient Descent,GD)
梯度下降法是最为经典的凸优化优化器,思想也非常明确:通过 loss 反向传导计算参数的梯度,参数往哪个方向跑可以让 loss 下降,就让参数往哪个方向更新:
解释
什么是梯度下降?
-
想象你在一座山上,目标是找到山的最低点
-
你每次都朝着最陡的方向走一小步
-
这个"最陡的方向"就是梯度[告诉我们损失函数在当前点的变化方向]
-
"走一小步"的步长就是学习率(α)
- 太大:可能跳过最优点(就像下山时步子太大,可能跳过山谷)
- 太小:收敛太慢(就像下山时步子太小,要走很久)
代码实战
def gradient_descent_example():
# 初始点
x = torch.tensor([2.0], requires_grad=True)
learning_rate = 0.1 # 学习率α
for step in range(5):
# 前向计算
y = 2 * x
loss = y ** 2
# 反向传播,计算梯度
loss.backward()
# 更新x:x = x - α * gradient
with torch.no_grad(): # 更新时不需要计算梯度 不需要构建计算图
x -= learning_rate * x.grad # 更新参数
x.grad.zero_() # 清除旧的梯度,为下一次迭代做准备
# 因为每一步都是一个新的起点,需要重新计算下山的方向。
print(f"Step {step}, x = {x.item():.4f}, loss = {loss.item():.4f}")
- 正向传播就像是从山脚走到山顶的路径
- 损失函数就是山顶的高度
- 反向传播就是在找下山的最快路径
- 梯度更新就是沿着这条路径走一小步
Adaptive Moment estimation (Adam)
普通梯度下降的问题:
- 学习率固定,不够灵活
- 每个参数用相同的学习率,不够合理
- 容易陷入局部最小值
想象你在下山:
-
动量:你有一定的惯性,不会因为一个小石头就改变方向
-
自适应学习率:
- 陡峭的地方(梯度大),你迈小步
- 平缓的地方(梯度小),你迈大步
解释
- 动量:记住历史梯度信息
- 自适应学习率:不同参数有不同学习率
- 偏差修正:解决训练初期的问题
实际使用中,通常。BERT 源代码中,预训练的 为 0.98,微调的为 0.999,其目的是为了减少对预训练中得到的原始参数结构的破坏,使收敛更为平缓。此外, 和 皆为初始化得来,因此训练时参数种子的设置往往对模型结果的影响较大。从上述公式可以看出,训练前期的学习率和梯度更新是比较激进的,到后期逐渐平稳。
代码实战
def adam_example():
x = torch.tensor([2.0], requires_grad=True)
# 初始化动量和自适应学习率
# torch.zeros_like(x) 是创建一个与 x 形状相同,但所有元素都为0的新张量。
m = torch.zeros_like(x)
v = torch.zeros_like(x)
beta1, beta2 = 0.9, 0.999
learning_rate = 0.1
epsilon = 1e-8
for step in range(5):
# 前向计算
y = 2 * x
loss = y ** 2
loss.backward()
with torch.no_grad():
# 更新动量
m = beta1 * m + (1 - beta1) * x.grad
# 更新自适应学习率
v = beta2 * v + (1 - beta2) * x.grad * x.grad
# 偏差修正
# 上述公式中的 t 就是代码中的 (step + 1)
# 用 (step + 1) 是因为 step 从0开始,而公式中 t 从1开始
m_hat = m / (1 - beta1 ** (step + 1))
v_hat = v / (1 - beta2 ** (step + 1))
# 参数更新
x -= learning_rate * m_hat / (torch.sqrt(v_hat) + epsilon)
x.grad.zero_()
print(f"Step {step}, x = {x.item():.4f}, loss = {loss.item():.4f}")
梯度下降变种(AdamW、LAMB)
Adam Weight Decay Regularization (AdamW)
Adam 虽然收敛速度快,但没能解决参数过拟合的问题。学术界讨论了诸多方案,其中包括在损失函数中引入参数的 L2 正则项。这样的方法在其他的优化器中或许有效,但会因为 Adam 中自适应学习率的存在而对使用 Adam 优化器的模型失效。AdamW 的出现便是为了解决这一问题,达到同样使参数接近于 0 的目的。具体的举措,是在最终的参数更新时引入参数自身:
即为权重衰减因子,常见的设置为 0.005/0.01。这一优化策略目前正广泛应用于各大预训练语言模型。
解释
- AdamW是 Adam 优化器的改进版本
- 主要解决了模型过拟合的问题
- 通过权重衰减(weight decay)实现
代码实战
def adamw_example():
x = torch.tensor([2.0], requires_grad=True)
m = torch.zeros_like(x)
v = torch.zeros_like(x)
beta1, beta2 = 0.9, 0.999
learning_rate = 0.1
weight_decay = 0.01 # 权重衰减系数
epsilon = 1e-8
for step in range(5):
y = 2 * x
loss = y ** 2
loss.backward()
with torch.no_grad():
# Adam部分和之前一样
m = beta1 * m + (1 - beta1) * x.grad
v = beta2 * v + (1 - beta2) * x.grad * x.grad
m_hat = m / (1 - beta1 ** (step + 1))
v_hat = v / (1 - beta2 ** (step + 1))
# AdamW的改进:添加权重衰减
x -= learning_rate * (m_hat / (torch.sqrt(v_hat) + epsilon) + weight_decay * x)
print(f"Step {step}, x = {x.item():.4f}, loss = {loss.item():.4f}")
x.grad.zero_()
Layer-wise Adaptive Moments optimizer for Batching training (LAMB)
LAMB 优化器是 2019 年出现的一匹新秀,原论文标题后半部分叫做 “Training BERT in 76 Minutes”,足以看出其野心之大。 LAMB 出现的目的是加速预训练进程,这个优化器也成为 NLP 社区为泛机器学习领域做出的一大贡献。在使用 Adam 和 AdamW 等优化器时,一大问题在于 batch size 存在一定的隐式上限,一旦突破这个上限,梯度更新极端的取值会导致自适应学习率调整后极为困难的收敛,从而无法享受增加的 batch size 带来的提速增益。LAMB 优化器的作用便在于使模型在进行大批量数据训练时,能够维持梯度更新的精度:
其中,\phi 是一个可选择的映射函数,一种是 ,另一种则为起到归一化作用的 。 和为预先设定的超参数,分别代表参数调整的下界和上界。这一简单的调整所带来的实际效果非常显著。
使用 AdamW 时,batch size 超过 512 便会导致模型效果大幅下降, 但在 LAMB 下,batch size 可以直接提到 32,000 而不会导致精度损失。
由于在下游微调预训练模型时,通常无需过大的数据集,因而 LAMB 仅在预训练环节使用。遗憾的是,LAMB 在 batch size 512
以下时无法起到显著作用,目前只能作为大体量财团的工具。
解释
- 解决大批量训练的问题
- 特别适合训练大型模型(如BERT)
- 可以用更大的batch size来加速训练
代码实战
def lamb_example():
x = torch.tensor([2.0], requires_grad=True)
m = torch.zeros_like(x)
v = torch.zeros_like(x)
beta1, beta2 = 0.9, 0.999
learning_rate = 0.1
weight_decay = 0.01
epsilon = 1e-8
for step in range(5):
y = 2 * x
loss = y ** 2
loss.backward()
with torch.no_grad():
# 计算动量(和Adam一样)
m = beta1 * m + (1 - beta1) * x.grad
v = beta2 * v + (1 - beta2) * x.grad * x.grad
# 计算r_t
r_t = m / (torch.sqrt(v) + epsilon)
# LAMB的核心:计算范数比
w_norm = x.norm() # 参数有多"大"
r_norm = (r_t + weight_decay * x).norm() # 更新量有多"大"
ratio = w_norm / (r_norm + epsilon) # 用范数的比值来调整学习率
# 参数更新
x -= learning_rate * ratio * (r_t + weight_decay * x)
print(f"Step {step}, x = {x.item():.4f}, loss = {loss.item():.4f}")
x.grad.zero_()
-
范数
-
用来衡量向量或矩阵"大小"的一种度量。
-
其实就是向量的模长
# 一维向量 x = torch.tensor([3.0, 4.0]) norm = x.norm() # 结果是5.0(勾股定理:√(3² + 4²)) # 二维向量(矩阵) x = torch.tensor([[1.0, 2.0], [3.0, 4.0]]) norm = x.norm() # 结果是√(1² + 2² + 3² + 4²) ≈ 5.477
-
选择建议:
- 一般任务:使用Adam
- 大模型训练:使用AdamW
- 大批量训练:使用LAMB(batch size > 512)
- 学习原理:从GD开始理解 记住:没有最好的优化器,只有最适合的优化器。选择要根据具体任务和场景来决定。
学习率调度(Cosine Annealing)
特点
- 学习率从初始值平滑地降到接近零
- 开始时学习率较大,快速探索
- 结束时学习率较小,精细调整
- 比固定学习率效果更好
使用场景:
- 训练深度神经网络
- 需要精细调整最终结果
- 希望避免学习率突变
代码实战
def get_cosine_lr(initial_lr, current_step, total_steps):
"""余弦退火学习率计算"""
return initial_lr * 0.5 * (1 + math.cos(math.pi * current_step / total_steps))
def adam_with_cosine_annealing():
x = torch.tensor([2.0], requires_grad=True)
m = torch.zeros_like(x)
v = torch.zeros_like(x)
beta1, beta2 = 0.9, 0.999
initial_lr = 0.1 # 初始学习率
total_steps = 20 # 总步数
epsilon = 1e-8
for step in range(total_steps):
# 计算当前学习率
current_lr = get_cosine_lr(initial_lr, step, total_steps)
# 前向计算
y = 2 * x
loss = y ** 2
loss.backward()
with torch.no_grad():
# Adam优化器部分
m = beta1 * m + (1 - beta1) * x.grad
v = beta2 * v + (1 - beta2) * x.grad * x.grad
m_hat = m / (1 - beta1 ** (step + 1))
v_hat = v / (1 - beta2 ** (step + 1))
# 使用余弦退火的学习率更新参数
x -= current_lr * m_hat / (torch.sqrt(v_hat) + epsilon)
x.grad.zero_()
print(f"Step {step}, lr = {current_lr:.4f}, x = {x.item():.4f}, loss = {loss.item():.4f}")