最优化
优化器
梯度下降 (Gradient Descent,GD)
梯度下降法是最为经典的凸优化优化器,思想也非常明确:通过 loss 反向传导计算参数的梯度,参数往哪个方向跑可以让 loss 下降,就让参数往哪个方向更新:
<math xmlns="http://www.w3.org/1998/Math/MathML"> Δ W k = ∂ l o s s ∂ W k = ∂ l o s s ∂ Z n ∂ Z n ∂ Z n − 1 ⋅ ⋅ ⋅ ∂ Z k + 1 ∂ W k \Delta W_k= \frac{\partial {loss}} {\partial {W_k}}=\frac{\partial {loss}} {\partial {Z_{n}}}\frac{\partial {Z_n}} {\partial {Z_{n-1}}}···\frac{\partial {Z_{k+1}}} {\partial {W_k}} </math>ΔWk=∂Wk∂loss=∂Zn∂loss∂Zn−1∂Zn⋅⋅⋅∂Wk∂Zk+1
<math xmlns="http://www.w3.org/1998/Math/MathML"> W k ← W k − α Δ W k W_k←W_k−αΔW_k </math>Wk←Wk−αΔWk
解释
什么是梯度下降?
-
想象你在一座山上,目标是找到山的最低点
-
你每次都朝着最陡的方向走一小步
-
这个"最陡的方向"就是梯度 [告诉我们损失函数在当前点的变化方向]
-
"走一小步"的步长就是学习率(α)
- 太大:可能跳过最优点(就像下山时步子太大,可能跳过山谷)
- 太小:收敛太慢(就像下山时步子太小,要走很久)
代码实战
python
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)
普通梯度下降的问题:
- 学习率固定,不够灵活
- 每个参数用相同的学习率,不够合理
- 容易陷入局部最小值
想象你在下山:
-
动量:你有一定的惯性,不会因为一个小石头就改变方向
- <math xmlns="http://www.w3.org/1998/Math/MathML"> m t = β 1 m t − 1 + ( 1 − β 1 ) Δ W m_t=β_1m_{t−1}+(1−β_1)ΔW </math>mt=β1mt−1+(1−β1)ΔW
-
自适应学习率:
- <math xmlns="http://www.w3.org/1998/Math/MathML"> v t = β 2 v t − 1 + ( 1 − β 2 ) Δ W 2 v_t=β_2v_{t−1}+(1−β_2)ΔW^2 </math>vt=β2vt−1+(1−β2)ΔW2
- 陡峭的地方(梯度大),你迈小步
- 平缓的地方(梯度小),你迈大步
解释
- 动量:记住历史梯度信息
- 自适应学习率:不同参数有不同学习率
- 偏差修正:解决训练初期的问题
<math xmlns="http://www.w3.org/1998/Math/MathML"> m t ^ = m t 1 − β 1 t \hat{\mathrm{m_t}}=\frac{\mathrm{m_t}}{1-\mathrm{\beta_1^t}} </math>mt^=1−β1tmt
<math xmlns="http://www.w3.org/1998/Math/MathML"> v t ^ = v t 1 − β 2 t \hat{\mathrm{v_t}}=\frac{\mathrm{v_t}}{1-\mathrm{\beta_2^t}} </math>vt^=1−β2tvt
<math xmlns="http://www.w3.org/1998/Math/MathML"> W t ← W t − 1 − α v t ^ + ϵ m t ^ \mathrm{W_t\leftarrow W_{t-1}-\frac{\alpha}{\sqrt{\hat{v_t}}+\epsilon}\hat{m_t}} </math>Wt←Wt−1−vt^ +ϵαmt^
实际使用中,通常 <math xmlns="http://www.w3.org/1998/Math/MathML"> β 1 = 0.9 , β 2 > 0.9 \beta_1=0.9,\beta_2>0.9 </math>β1=0.9,β2>0.9。BERT 源代码中,预训练的 <math xmlns="http://www.w3.org/1998/Math/MathML"> β 2 \beta_2 </math>β2为 0.98,微调的 <math xmlns="http://www.w3.org/1998/Math/MathML"> β 2 \beta_2 </math>β2为 0.999,其目的是为了减少对预训练中得到的原始参数结构的破坏,使收敛更为平缓。此外, <math xmlns="http://www.w3.org/1998/Math/MathML"> m 0 m_0 </math>m0 和 <math xmlns="http://www.w3.org/1998/Math/MathML"> v 0 v_0 </math>v0皆为初始化得来,因此训练时参数种子的设置往往对模型结果的影响较大。从上述公式可以看出,训练前期的学习率和梯度更新是比较激进的,到后期逐渐平稳。
代码实战
python
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 的目的。具体的举措,是在最终的参数更新时引入参数自身:
<math xmlns="http://www.w3.org/1998/Math/MathML"> m t = β 1 m t − 1 + ( 1 − β 1 ) Δ W \mathrm{m_t=\beta_1m_{t-1}+(1-\beta_1)\Delta W} </math>mt=β1mt−1+(1−β1)ΔW
<math xmlns="http://www.w3.org/1998/Math/MathML"> v t = β 2 v t − 1 + ( 1 − β 2 ) Δ W 2 \mathrm{v_t~=\beta_2v_{t-1}~+~(1-\beta_2~)\Delta W^2} </math>vt =β2vt−1 + (1−β2 )ΔW2
<math xmlns="http://www.w3.org/1998/Math/MathML"> m t ^ = m t 1 − β 1 t \hat{\mathrm{m_t}}=\frac{\mathrm{m_t}}{1-\mathrm{\beta_1^t}} </math>mt^=1−β1tmt
<math xmlns="http://www.w3.org/1998/Math/MathML"> v t ^ = v t 1 − β 2 t \hat{\mathrm{v_t}}=\frac{\mathrm{v_t}}{1-\mathrm{\beta_2^t}} </math>vt^=1−β2tvt
<math xmlns="http://www.w3.org/1998/Math/MathML"> W t ← W t − 1 − α ( m t ^ v t ^ + ϵ + λ W t − 1 ) \mathrm{W_t~\leftarrow~W_{t-1}~-\alpha(\frac{\hat{m_t}}{\sqrt{\hat{v_t}}~+\epsilon}~+\lambda W_{t-1}~)} </math>Wt ← Wt−1 −α(vt^ +ϵmt^ +λWt−1 )
<math xmlns="http://www.w3.org/1998/Math/MathML"> λ λ </math>λ即为权重衰减因子,常见的设置为 0.005/0.01。这一优化策略目前正广泛应用于各大预训练语言模型。
解释
- AdamW是 Adam 优化器的改进版本
- 主要解决了模型过拟合的问题
- 通过权重衰减(weight decay)实现
代码实战
python
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 优化器的作用便在于使模型在进行大批量数据训练时,能够维持梯度更新的精度:
<math xmlns="http://www.w3.org/1998/Math/MathML"> m t = β 1 m t − 1 + ( 1 − β 1 ) Δ W \mathrm{m_t=\beta_1m_{t-1}+(1-\beta_1)\Delta W} </math>mt=β1mt−1+(1−β1)ΔW
<math xmlns="http://www.w3.org/1998/Math/MathML"> v t = β 2 v t − 1 + ( 1 − β 2 ) Δ W 2 \mathrm{v_t~=\beta_2v_{t-1}~+~(1-\beta_2~)\Delta W^2} </math>vt =β2vt−1 + (1−β2 )ΔW2
<math xmlns="http://www.w3.org/1998/Math/MathML"> r t = m t v t + ϵ \mathrm{r_t~=~\frac{m_t}{\sqrt{v_t}~+\epsilon}} </math>rt = vt +ϵmt
<math xmlns="http://www.w3.org/1998/Math/MathML"> W t ← W t − 1 − α ⋅ ϕ ( ∣ ∣ W t − 1 ∣ ∣ ∣ ∣ r t + λ W t − 1 ∣ ∣ ) ( r t + λ W t − 1 ) \mathrm{W_t~\leftarrow~W_{t-1}~-\alpha\cdot\phi(\frac{||W_{t-1}||}{||r_t~+\lambda W_{t-1}||})(r_t~+\lambda W_{t-1})} </math>Wt ← Wt−1 −α⋅ϕ(∣∣rt +λWt−1∣∣∣∣Wt−1∣∣)(rt +λWt−1)
其中,\phi 是一个可选择的映射函数,一种是 <math xmlns="http://www.w3.org/1998/Math/MathML"> ϕ ( z ) = z ϕ ( z ) = z </math>ϕ(z)=z ,另一种则为起到归一化作用的 <math xmlns="http://www.w3.org/1998/Math/MathML"> ϕ ( z ) = m i n ( m a x ( z , γ 1 ) , γ u ) ϕ ( z ) = min ( max ( z , γ_1 ) , γ _u ) </math>ϕ(z)=min(max(z,γ1),γu) 。 <math xmlns="http://www.w3.org/1998/Math/MathML"> γ 1 γ_1 </math>γ1 和 <math xmlns="http://www.w3.org/1998/Math/MathML"> γ u γ_u </math>γu为预先设定的超参数,分别代表参数调整的下界和上界。这一简单的调整所带来的实际效果非常显著。
使用 AdamW 时,batch size 超过 512 便会导致模型效果 大幅下降, 但在 LAMB 下,batch size 可以直接提到 32,000 而不会导致精度损失。
由于在下游微调预训练模型时,通常无需过大的数据集,因而 LAMB 仅在预训练环节使用。遗憾的是,LAMB 在 batch size 512
以下时无法起到显著作用,目前只能作为大体量财团的工具。
解释
- 解决大批量训练的问题
- 特别适合训练大型模型(如BERT)
- 可以用更大的batch size来加速训练
代码实战
python
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_()
-
范数
-
用来衡量向量或矩阵"大小"的一种度量。
-
其实就是向量的模长
python# 一维向量 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)
特点
- 学习率从初始值平滑地降到接近零
- 开始时学习率较大,快速探索
- 结束时学习率较小,精细调整
- 比固定学习率效果更好
使用场景:
- 训练深度神经网络
- 需要精细调整最终结果
- 希望避免学习率突变
代码实战
<math xmlns="http://www.w3.org/1998/Math/MathML"> η t = η i n i t i a l ⋅ 1 2 ( 1 + cos ( π ⋅ t T ) ) \eta_t = \eta_{initial} \cdot \frac{1}{2}(1 + \cos(\frac{\pi \cdot t}{T})) </math>ηt=ηinitial⋅21(1+cos(Tπ⋅t))
python
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}")