第7章:优化技术(Optimization)#
7.1 神经网络优化基础#
在构建故事讲述AI大语言模型的过程中,优化是一个至关重要的环节。优化不仅关系到模型能否成功训练,还直接影响模型的性能、收敛速度和最终效果。本章我们将深入探讨神经网络优化的核心概念、常用算法以及在大语言模型训练中的实践技巧。
神经网络优化的本质是一个寻找最优参数的过程。在数学上,这可以表述为寻找一组参数 θ,使得损失函数 L(θ) 最小化:
$$\theta^* = \arg\min_{\theta} L(\theta)$$
对于大语言模型,损失函数通常是预测下一个词元的交叉熵损失:
$$L(\theta) = -\frac{1}{N}\sum_{i=1}^{N}\log P(x_i|x_{<i}; \theta)$$
其中,$x_i$ 是序列中的第 i 个词元,$x_{<i}$ 表示 $x_i$ 之前的所有词元,$P(x_i|x_{<i}; \theta)$ 是模型预测下一个词元为 $x_i$ 的概率。
优化这样的损失函数面临几个主要挑战:
高维参数空间:现代大语言模型通常有数十亿甚至数千亿参数,使得参数空间极其庞大。
非凸优化问题:神经网络的损失函数通常是非凸的,存在多个局部最小值。
梯度消失/爆炸:深层网络中的梯度在反向传播过程中可能会变得极小或极大。
计算资源限制:大模型训练需要大量计算资源,优化算法必须高效利用这些资源。
泛化性能:优化不仅要使训练损失最小化,还要确保模型在未见数据上表现良好。
为了应对这些挑战,研究人员开发了一系列优化技术,从参数初始化、优化算法到正则化方法等多个方面入手。在本章中,我们将系统地介绍这些技术,并讨论它们在故事生成模型训练中的应用。
7.2 参数初始化方法与重要性#
神经网络训练的第一步是参数初始化。合适的初始化对训练的成功至关重要,它可以加速收敛、避免梯度问题,并帮助模型找到更好的解。
7.2.1 初始化的重要性#
为什么参数初始化如此重要?主要有以下几个原因:
打破对称性:如果所有参数初始化为相同的值,那么每一层中的所有神经元将学习相同的特征,导致网络表达能力大幅降低。随机初始化打破了这种对称性。
控制激活值分布:合适的初始化可以使每一层的激活值保持在合理的范围内,避免饱和(对于sigmoid、tanh等激活函数)或爆炸。
稳定梯度流:良好的初始化可以帮助梯度在网络中平稳流动,减轻梯度消失或爆炸问题。
加速收敛:接近最优解的初始点可以显著减少训练所需的迭代次数。
在大语言模型中,由于网络深度通常很大(如GPT-3有96层Transformer块),初始化的影响被进一步放大,成为训练成功的关键因素之一。
7.2.2 常见初始化方法#
1. 零初始化与常数初始化#
最简单的初始化方法是将所有参数设为零或某个常数:
def zero_init(shape):
return np.zeros(shape)
def constant_init(shape, value=0.1):
return np.full(shape, value)
然而,这种方法会导致对称性问题,使得所有神经元学习相同的特征,严重限制模型的表达能力。因此,零初始化通常只用于偏置项(bias),而不用于权重。
2. 随机初始化#
随机初始化是最常用的方法之一,它从某个分布(通常是均匀分布或正态分布)中随机采样参数值:
def uniform_init(shape, low=-0.1, high=0.1):
return np.random.uniform(low, high, shape)
def normal_init(shape, mean=0.0, std=0.01):
return np.random.normal(mean, std, shape)
简单的随机初始化虽然打破了对称性,但没有考虑网络结构,可能导致激活值和梯度的方差在传播过程中发生剧烈变化。
3. Xavier/Glorot初始化#
Xavier初始化(也称为Glorot初始化)考虑了输入和输出单元的数量,旨在保持每一层输入和输出的方差一致:
def xavier_uniform_init(shape):
fan_in, fan_out = get_fans(shape)
limit = np.sqrt(6 / (fan_in + fan_out))
return np.random.uniform(-limit, limit, shape)
def xavier_normal_init(shape):
fan_in, fan_out = get_fans(shape)
std = np.sqrt(2 / (fan_in + fan_out))
return np.random.normal(0, std, shape)
def get_fans(shape):
if len(shape) == 2: # 全连接层
fan_in, fan_out = shape
elif len(shape) >= 3: # 卷积层
receptive_field_size = np.prod(shape[2:])
fan_in = shape[1] * receptive_field_size
fan_out = shape[0] * receptive_field_size
else:
fan_in = fan_out = int(np.sqrt(shape[0]))
return fan_in, fan_out
Xavier初始化适用于使用线性激活函数或tanh、sigmoid等对称激活函数的网络。
4. He初始化(Kaiming初始化)#
He初始化专为使用ReLU及其变种激活函数的网络设计,考虑了ReLU将约一半的激活值置为零的特性:
def he_uniform_init(shape):
fan_in, _ = get_fans(shape)
limit = np.sqrt(6 / fan_in)
return np.random.uniform(-limit, limit, shape)
def he_normal_init(shape):
fan_in, _ = get_fans(shape)
std = np.sqrt(2 / fan_in)
return np.random.normal(0, std, shape)
5. 正交初始化#
正交初始化生成正交矩阵作为权重,有助于保持梯度范数在反向传播过程中的稳定性:
def orthogonal_init(shape, gain=1.0):
if len(shape) < 2:
raise ValueError("Shape must have at least 2 dimensions")
flat_shape = (shape[0], np.prod(shape[1:]))
a = np.random.normal(0.0, 1.0, flat_shape)
u, _, v = np.linalg.svd(a, full_matrices=False)
# 选择u或v,确保形状匹配
q = u if u.shape == flat_shape else v
q = q.reshape(shape)
return gain * q
6. 特定于Transformer的初始化#
对于Transformer架构,通常使用特定的初始化策略。例如,GPT系列模型中常用的初始化方法:
def gpt_init(module):
if isinstance(module, nn.Linear):
# 线性层使用正态分布初始化
nn.init.normal_(module.weight, mean=0.0, std=0.02)
if module.bias is not None:
nn.init.zeros_(module.bias)
elif isinstance(module, nn.Embedding):
# 嵌入层使用正态分布初始化
nn.init.normal_(module.weight, mean=0.0, std=0.02)
elif isinstance(module, nn.LayerNorm):
# 层归一化参数初始化
nn.init.ones_(module.weight)
nn.init.zeros_(module.bias)
在GPT-2和GPT-3中,权重通常使用标准差为0.02的正态分布初始化,而层归一化的缩放参数初始化为1,偏置初始化为0。
7.2.3 初始化的实践考虑#
在实际应用中,选择合适的初始化方法需要考虑以下因素:
网络架构:不同的网络架构可能需要不同的初始化策略。例如,Transformer通常使用正态分布初始化,而CNN可能更适合He初始化。
激活函数:如前所述,激活函数的选择会影响最佳的初始化方法。ReLU系列激活函数通常搭配He初始化,而tanh或sigmoid则搭配Xavier初始化。
网络深度:对于非常深的网络,可能需要特殊的初始化技巧来确保梯度的稳定传播。
残差连接:带有残差连接的网络(如Transformer)可能需要特殊的初始化策略,例如将残差分支的权重初始化得更小。
预训练模型:当使用预训练模型时,新添加的层的初始化需要与预训练部分兼容。
在故事生成模型中,由于我们主要使用Transformer架构,通常采用GPT系列模型的初始化策略,即使用标准差为0.02的正态分布初始化大多数参数。
7.2.4 初始化的代码实现#
下面是一个在PyTorch中实现各种初始化方法的完整示例:
import torch
import torch.nn as nn
import math
def init_weights(module, init_type='normal', init_gain=0.02):
"""初始化网络权重"""
def init_func(m):
classname = m.__class__.__name__
if hasattr(m, 'weight') and (classname.find('Conv') != -1 or classname.find('Linear') != -1):
if init_type == 'normal':
nn.init.normal_(m.weight.data, 0.0, init_gain)
elif init_type == 'xavier':
nn.init.xavier_normal_(m.weight.data, gain=init_gain)
elif init_type == 'kaiming':
nn.init.kaiming_normal_(m.weight.data, a=0, mode='fan_in')
elif init_type == 'orthogonal':
nn.init.orthogonal_(m.weight.data, gain=init_gain)
elif init_type == 'transformer':
# Transformer特定初始化
nn.init.normal_(m.weight.data, 0.0, 0.02)
else:
raise NotImplementedError(f'初始化方法 {init_type} 未实现')
if hasattr(m, 'bias') and m.bias is not None:
nn.init.constant_(m.bias.data, 0.0)
elif classname.find('BatchNorm2d') != -1:
nn.init.normal_(m.weight.data, 1.0, init_gain)
nn.init.constant_(m.bias.data, 0.0)
elif classname.find('LayerNorm') != -1:
nn.init.constant_(m.weight.data, 1.0)
nn.init.constant_(m.bias.data, 0.0)
module.apply(init_func)
return module
# 使用示例
model = nn.Sequential(
nn.Linear(768, 3072),
nn.GELU(),
nn.Linear(3072, 768)
)
model = init_weights(model, init_type='transformer')
对于故事生成模型,我们可以定义一个专门的初始化函数:
def init_storyteller_model(model):
"""初始化故事讲述模型的权重"""
for name, param in model.named_parameters():
if 'layernorm' in name or 'layer_norm' in name:
# 层归一化参数
if 'weight' in name:
nn.init.ones_(param)
elif 'bias' in name:
nn.init.zeros_(param)
elif 'embeddings' in name or 'wte' in name or 'wpe' in name:
# 嵌入层参数
nn.init.normal_(param, mean=0.0, std=0.02)
elif 'attention' in name and 'weight' in name:
# 注意力权重
nn.init.normal_(param, mean=0.0, std=0.02)
elif 'mlp' in name or 'feed_forward' in name:
# MLP层权重
if 'weight' in name:
nn.init.normal_(param, mean=0.0, std=0.02)
elif 'bias' in name:
nn.init.zeros_(param)
elif 'bias' in name:
# 所有其他偏置项
nn.init.zeros_(param)
else:
# 所有其他权重
nn.init.normal_(param, mean=0.0, std=0.02)
# 可选:特殊处理最后一层
if hasattr(model, 'lm_head') and hasattr(model.lm_head, 'weight'):
nn.init.normal_(model.lm_head.weight, mean=0.0, std=0.02 / math.sqrt(2))
return model
这个初始化函数遵循了GPT系列模型的初始化策略,同时对不同类型的层使用了适当的初始化方法。对于最后的语言模型头部(lm_head),我们使用了稍小的标准差,这有助于稳定初始训练阶段。
7.3 梯度下降及其变种#
优化算法的核心是梯度下降(Gradient Descent)及其变种。这些算法利用损失函数相对于参数的梯度来更新参数,使损失函数逐步减小。
7.3.1 基本梯度下降#
最基本的梯度下降算法可以表示为:
$$\theta_{t+1} = \theta_t - \eta \nabla_\theta L(\theta_t)$$
其中,$\theta_t$ 是第 t 步的参数,$\eta$ 是学习率,$\nabla_\theta L(\theta_t)$ 是损失函数相对于参数的梯度。
根据计算梯度所使用的数据量,梯度下降可以分为三种类型:
批量梯度下降(Batch Gradient Descent):使用整个训练集计算梯度。
随机梯度下降(Stochastic Gradient Descent, SGD):每次只使用一个样本计算梯度。
小批量梯度下降(Mini-batch Gradient Descent):使用一小批样本计算梯度,是最常用的方法。
def batch_gradient_descent(params, gradients, lr=0.01):
"""批量梯度下降"""
for param, grad in zip(params, gradients):
param -= lr * grad
return params
def sgd(params, sample_gradient, lr=0.01):
"""随机梯度下降"""
for param, grad in zip(params, sample_gradient):
param -= lr * grad
return params
def mini_batch_gradient_descent(params, mini_batch_gradients, lr=0.01):
"""小批量梯度下降"""
for param, grad in zip(params, mini_batch_gradients):
param -= lr * grad
return params
在实际应用中,小批量梯度下降是最常用的方法,因为它在计算效率和收敛稳定性之间取得了良好的平衡。
7.3.2 动量法(Momentum)#
基本的梯度下降容易陷入局部最小值或在平坦区域收敛缓慢。动量法通过累积过去的梯度来加速收敛并帮助跳出局部最小值:
$$v_{t+1} = \gamma v_t + \eta \nabla_\theta L(\theta_t)$$ $$\theta_{t+1} = \theta_t - v_{t+1}$$
其中,$v_t$ 是累积的动量向量,$\gamma$ 是动量系数(通常设为0.9)。
def sgd_with_momentum(params, gradients, velocities, lr=0.01, momentum=0.9):
"""带动量的SGD"""
for i, (param, grad) in enumerate(zip(params, gradients)):
velocities[i] = momentum * velocities[i] + lr * grad
param -= velocities[i]
return params, velocities
动量法的优点是可以加速收敛,特别是在梯度方向一致的区域;同时,它也能够在一定程度上克服局部最小值和鞍点的问题。
7.3.3 Nesterov加速梯度(NAG)#
Nesterov加速梯度是动量法的一个变种,它在计算梯度时考虑了动量的未来位置:
$$v_{t+1} = \gamma v_t + \eta \nabla_\theta L(\theta_t - \gamma v_t)$$ $$\theta_{t+1} = \theta_t - v_{t+1}$$
def nesterov_accelerated_gradient(params, compute_gradients, velocities, lr=0.01, momentum=0.9):
"""Nesterov加速梯度"""
# 临时更新参数
temp_params = [param - momentum * vel for param, vel in zip(params, velocities)]
# 在临时位置计算梯度
gradients = compute_gradients(temp_params)
# 更新速度和参数
for i, (param, grad) in enumerate(zip(params, gradients)):
velocities[i] = momentum * velocities[i] + lr * grad
param -= velocities[i]
return params, velocities
NAG通常比标准动量法收敛更快,因为它能够提前”预见”参数的下一个位置。
7.3.4 Adagrad#
Adagrad算法自适应地调整每个参数的学习率,对频繁更新的参数使用较小的学习率,对不频繁更新的参数使用较大的学习率:
$$g_{t,i} = \nabla_{\theta_i} L(\theta_t)$$ $$G_{t,ii} = G_{t-1,ii} + g_{t,i}^2$$ $$\theta_{t+1,i} = \theta_{t,i} - \frac{\eta}{\sqrt{G_{t,ii} + \epsilon}} g_{t,i}$$
其中,$G_t$ 是一个对角矩阵,其对角元素 $G_{t,ii}$ 是参数 $\theta_i$ 的梯度平方和,$\epsilon$ 是一个小常数,防止除以零。
def adagrad(params, gradients, grad_squared, lr=0.01, epsilon=1e-8):
"""Adagrad优化算法"""
for i, (param, grad) in enumerate(zip(params, gradients)):
grad_squared[i] += grad ** 2
param -= lr * grad / (np.sqrt(grad_squared[i]) + epsilon)
return params, grad_squared
Adagrad的主要优点是自动调整学习率,但它的主要缺点是梯度平方的累积会导致学习率单调递减,最终变得非常小,使训练提前停止。
7.3.5 RMSprop#
RMSprop解决了Adagrad学习率单调递减的问题,它使用梯度平方的移动平均而不是简单累加:
$$E[g^2]t = \beta E[g^2]{t-1} + (1-\beta) g_t^2$$ $$\theta_{t+1} = \theta_t - \frac{\eta}{\sqrt{E[g^2]_t + \epsilon}} g_t$$
其中,$\beta$ 通常设为0.9,表示历史梯度平方的衰减率。
def rmsprop(params, gradients, grad_squared, lr=0.01, beta=0.9, epsilon=1e-8):
"""RMSprop优化算法"""
for i, (param, grad) in enumerate(zip(params, gradients)):
grad_squared[i] = beta * grad_squared[i] + (1 - beta) * (grad ** 2)
param -= lr * grad / (np.sqrt(grad_squared[i]) + epsilon)
return params, grad_squared
RMSprop在非凸优化问题上表现良好,是训练深度神经网络的常用选择。
7.3.6 Adam#
Adam(Adaptive Moment Estimation)结合了动量法和RMSprop的优点,同时维护梯度的一阶矩(均值)和二阶矩(未中心化的方差)的指数移动平均:
$$m_t = \beta_1 m_{t-1} + (1-\beta_1) g_t$$ $$v_t = \beta_2 v_{t-1} + (1-\beta_2) g_t^2$$
为了纠正初始化偏差,Adam使用偏差修正:
$$\hat{m}_t = \frac{m_t}{1-\beta_1^t}$$ $$\hat{v}_t = \frac{v_t}{1-\beta_2^t}$$
然后更新参数:
$$\theta_{t+1} = \theta_t - \frac{\eta}{\sqrt{\hat{v}_t} + \epsilon} \hat{m}_t$$
def adam(params, gradients, m, v, t, lr=0.001, beta1=0.9, beta2=0.999, epsilon=1e-8):
"""Adam优化算法"""
t += 1
for i, (param, grad) in enumerate(zip(params, gradients)):
# 更新偏置修正的一阶矩估计
m[i] = beta1 * m[i] + (1 - beta1) * grad
m_hat = m[i] / (1 - beta1 ** t)
# 更新偏置修正的二阶矩估计
v[i] = beta2 * v[i] + (1 - beta2) * (grad ** 2)
v_hat = v[i] / (1 - beta2 ** t)
# 更新参数
param -= lr * m_hat / (np.sqrt(v_hat) + epsilon)
return params, m, v, t
Adam是目前最流行的优化算法之一,因为它结合了动量和自适应学习率的优点,通常能够快速收敛并产生良好的结果。
7.4 AdamW优化器详解#
AdamW是Adam优化器的一个变种,专门设计用于解决Adam在使用L2正则化(权重衰减)时的问题。在标准Adam中,权重衰减被应用于梯度,这与真正的L2正则化不同,并可能导致次优的正则化效果。AdamW将权重衰减从梯度计算中分离出来,直接应用于参数更新步骤。
7.4.1 Adam与L2正则化的问题#
在标准的随机梯度下降中,L2正则化等价于权重衰减:
$$\theta_{t+1} = \theta_t - \eta (\nabla_\theta L(\theta_t) + \lambda \theta_t) = (1 - \eta \lambda) \theta_t - \eta \nabla_\theta L(\theta_t)$$
其中,$\lambda$ 是正则化系数。
然而,在Adam中,由于自适应学习率的存在,这种等价性不再成立。当L2正则化项 $\lambda \theta_t$ 被添加到梯度中时,它也会受到自适应学习率的影响,导致正则化效果被扭曲。
7.4.2 AdamW的解决方案#
AdamW通过将权重衰减与梯度更新分离,解决了这个问题:
$$m_t = \beta_1 m_{t-1} + (1-\beta_1) g_t$$ $$v_t = \beta_2 v_{t-1} + (1-\beta_2) g_t^2$$ $$\hat{m}_t = \frac{m_t}{1-\beta_1^t}$$ $$\hat{v}t = \frac{v_t}{1-\beta_2^t}$$ $$\theta{t+1} = \theta_t - \eta \left( \frac{\hat{m}_t}{\sqrt{\hat{v}_t} + \epsilon} + \lambda \theta_t \right)$$
注意最后一步中,权重衰减项 $\lambda \theta_t$ 是直接添加到更新规则中,而不是添加到梯度中。
def adamw(params, gradients, m, v, t, lr=0.001, beta1=0.9, beta2=0.999, epsilon=1e-8, weight_decay=0.01):
"""AdamW优化算法"""
t += 1
for i, (param, grad) in enumerate(zip(params, gradients)):
# 更新偏置修正的一阶矩估计
m[i] = beta1 * m[i] + (1 - beta1) * grad
m_hat = m[i] / (1 - beta1 ** t)
# 更新偏置修正的二阶矩估计
v[i] = beta2 * v[i] + (1 - beta2) * (grad ** 2)
v_hat = v[i] / (1 - beta2 ** t)
# 更新参数(注意权重衰减项的位置)
param -= lr * (m_hat / (np.sqrt(v_hat) + epsilon) + weight_decay * param)
return params, m, v, t
7.4.3 AdamW的PyTorch实现#
在PyTorch中,AdamW已经作为标准优化器提供:
import torch.optim as optim
# 创建模型
model = TransformerModel(vocab_size=50000, d_model=768, nhead=12, num_layers=12)
# 创建AdamW优化器
optimizer = optim.AdamW(
model.parameters(),
lr=5e-5, # 学习率
betas=(0.9, 0.999), # 一阶和二阶矩的指数衰减率
eps=1e-8, # 分母中添加的小常数,防止除零
weight_decay=0.01 # 权重衰减系数
)
7.4.4 AdamW在大语言模型中的应用#
AdamW已成为训练大语言模型的标准优化器,包括GPT系列、BERT系列和T5等。在这些模型中,典型的超参数设置为:
学习率:1e-4到5e-5(根据模型大小和任务调整)
β₁:0.9
β₂:0.999
ε:1e-8
权重衰减:0.01到0.1
对于故事生成模型,我们可以使用以下设置:
def create_storyteller_optimizer(model, lr=5e-5, weight_decay=0.01):
"""为故事讲述模型创建优化器"""
# 将参数分为两组:不需要权重衰减的参数(如偏置和LayerNorm参数)和其他参数
no_decay = ['bias', 'layernorm', 'layer_norm']
optimizer_grouped_parameters = [
{
'params': [p for n, p in model.named_parameters() if not any(nd in n for nd in no_decay)],
'weight_decay': weight_decay
},
{
'params': [p for n, p in model.named_parameters() if any(nd in n for nd in no_decay)],
'weight_decay': 0.0
}
]
optimizer = optim.AdamW(
optimizer_grouped_parameters,
lr=lr,
betas=(0.9, 0.999),
eps=1e-8
)
return optimizer
这个函数将模型参数分为两组:一组应用权重衰减,另一组不应用。通常,偏置项和层归一化参数不应该应用权重衰减,因为它们已经受到其他约束。
7.5 学习率调度策略#
学习率是优化过程中最重要的超参数之一。合适的学习率调度策略可以加速收敛、提高模型性能,并帮助跳出局部最小值。
7.5.1 固定学习率#
最简单的策略是使用固定的学习率,但这通常不是最佳选择,因为:
学习率过大可能导致发散
学习率过小可能导致收敛缓慢
训练的不同阶段可能需要不同的学习率
7.5.2 学习率衰减#
随着训练的进行,逐渐减小学习率通常是有益的。常见的衰减策略包括:
1. 阶梯衰减(Step Decay)#
每经过固定的训练轮数,将学习率乘以一个衰减因子:
$$\eta_t = \eta_0 \times \gamma^{\lfloor t / s \rfloor}$$
其中,$\eta_0$ 是初始学习率,$\gamma$ 是衰减因子(通常为0.1或0.5),$s$ 是衰减步长,$t$ 是当前训练步数。
def step_decay(initial_lr, decay_factor=0.1, decay_epochs=30):
"""阶梯衰减学习率调度器"""
def scheduler(epoch):
return initial_lr * (decay_factor ** (epoch // decay_epochs))
return scheduler
2. 指数衰减(Exponential Decay)#
学习率按指数衰减:
$$\eta_t = \eta_0 \times \gamma^t$$
其中,$\gamma$ 是衰减率(通常接近但小于1,如0.95或0.99)。
def exponential_decay(initial_lr, decay_rate=0.95):
"""指数衰减学习率调度器"""
def scheduler(epoch):
return initial_lr * (decay_rate ** epoch)
return scheduler
3. 余弦退火(Cosine Annealing)#
学习率按余弦函数从初始值衰减到最小值:
$$\eta_t = \eta_{min} + \frac{1}{2}(\eta_{max} - \eta_{min})(1 + \cos(\frac{t\pi}{T}))$$
其中,$\eta_{max}$ 是初始学习率,$\eta_{min}$ 是最小学习率,$T$ 是总训练步数,$t$ 是当前步数。
def cosine_annealing(initial_lr, min_lr=0, total_epochs=100):
"""余弦退火学习率调度器"""
def scheduler(epoch):
return min_lr + 0.5 * (initial_lr - min_lr) * (1 + math.cos(epoch * math.pi / total_epochs))
return scheduler
4. 带热重启的余弦退火(SGDR: Stochastic Gradient Descent with Warm Restarts)#
在余弦退火的基础上,周期性地将学习率重置为初始值,然后再次衰减:
$$\eta_t = \eta_{min} + \frac{1}{2}(\eta_{max} - \eta_{min})(1 + \cos(\frac{t_{mod}\pi}{T_i}))$$
其中,$t_{mod} = t \mod T_i$,$T_i$ 是第 $i$ 个周期的长度。
def cosine_annealing_warm_restarts(initial_lr, min_lr=0, first_cycle_epochs=10, cycle_mult=2):
"""带热重启的余弦退火学习率调度器"""
def scheduler(epoch):
# 计算当前所处的周期和周期内的位置
cycle = 0
cycle_length = first_cycle_epochs
epoch_in_cycle = epoch
while epoch_in_cycle >= cycle_length:
epoch_in_cycle -= cycle_length
cycle += 1
cycle_length = first_cycle_epochs * (cycle_mult ** cycle)
# 计算当前学习率
return min_lr + 0.5 * (initial_lr - min_lr) * (1 + math.cos(epoch_in_cycle * math.pi / cycle_length))
return scheduler
7.5.3 线性预热(Linear Warmup)#
对于大型模型,特别是Transformer模型,在训练初期使用较小的学习率,然后线性增加到目标值,有助于稳定训练:
$$\eta_t = \begin{cases} \eta_{target} \times \frac{t}{T_{warmup}} & \text{if } t < T_{warmup} \ \eta_{target} & \text{otherwise} \end{cases}$$
其中,$T_{warmup}$ 是预热步数。
def linear_warmup(target_lr, warmup_epochs=10):
"""线性预热学习率调度器"""
def scheduler(epoch):
if epoch < warmup_epochs:
return target_lr * (epoch + 1) / warmup_epochs
else:
return target_lr
return scheduler
7.5.4 线性预热后余弦衰减#
这是训练大语言模型最常用的学习率调度策略,结合了线性预热和余弦衰减:
$$\eta_t = \begin{cases} \eta_{max} \times \frac{t}{T_{warmup}} & \text{if } t < T_{warmup} \ \eta_{min} + \frac{1}{2}(\eta_{max} - \eta_{min})(1 + \cos(\frac{(t-T_{warmup})\pi}{T-T_{warmup}})) & \text{otherwise} \end{cases}$$
def linear_warmup_cosine_decay(max_lr, min_lr=0, warmup_epochs=10, total_epochs=100):
"""线性预热后余弦衰减学习率调度器"""
def scheduler(epoch):
if epoch < warmup_epochs:
return max_lr * (epoch + 1) / warmup_epochs
else:
return min_lr + 0.5 * (max_lr - min_lr) * (
1 + math.cos((epoch - warmup_epochs) * math.pi / (total_epochs - warmup_epochs))
)
return scheduler
7.5.5 在PyTorch中实现学习率调度#
PyTorch提供了多种学习率调度器,可以轻松实现上述策略:
import torch.optim.lr_scheduler as lr_scheduler
# 创建模型和优化器
model = TransformerModel(vocab_size=50000, d_model=768, nhead=12, num_layers=12)
optimizer = optim.AdamW(model.parameters(), lr=5e-5, weight_decay=0.01)
# 1. 阶梯衰减
step_scheduler = lr_scheduler.StepLR(optimizer, step_size=30, gamma=0.1)
# 2. 指数衰减
exp_scheduler = lr_scheduler.ExponentialLR(optimizer, gamma=0.95)
# 3. 余弦退火
cosine_scheduler = lr_scheduler.CosineAnnealingLR(optimizer, T_max=100, eta_min=0)
# 4. 带热重启的余弦退火
cosine_warm_scheduler = lr_scheduler.CosineAnnealingWarmRestarts(optimizer, T_0=10, T_mult=2)
# 5. 自定义学习率调度(如线性预热后余弦衰减)
def lr_lambda(epoch):
warmup_epochs = 10
total_epochs = 100
if epoch < warmup_epochs:
return epoch / warmup_epochs
else:
return 0.5 * (1 + math.cos((epoch - warmup_epochs) * math.pi / (total_epochs - warmup_epochs)))
lambda_scheduler = lr_scheduler.LambdaLR(optimizer, lr_lambda=lr_lambda)
7.5.6 故事生成模型的学习率调度#
对于故事生成模型,我们推荐使用线性预热后余弦衰减的学习率调度策略,这是训练大语言模型的标准做法:
def create_storyteller_scheduler(optimizer, warmup_steps=1000, total_steps=100000):
"""为故事讲述模型创建学习率调度器"""
def lr_lambda(current_step):
if current_step < warmup_steps:
# 线性预热
return current_step / warmup_steps
else:
# 余弦衰减
progress = (current_step - warmup_steps) / (total_steps - warmup_steps)
return 0.5 * (1 + math.cos(math.pi * progress))
return lr_scheduler.LambdaLR(optimizer, lr_lambda)
在实际应用中,预热步数通常设置为总训练步数的1%到10%。对于大型模型,较长的预热期有助于稳定初始训练阶段。
7.6 优化过程中的常见问题与解决方案#
在训练大语言模型的过程中,我们可能会遇到各种优化问题。本节将讨论这些常见问题及其解决方案。
7.6.1 梯度消失与爆炸#
问题描述:
梯度消失:梯度在反向传播过程中变得极小,导致参数几乎不更新。
梯度爆炸:梯度在反向传播过程中变得极大,导致参数更新过度,训练不稳定。
解决方案:
梯度裁剪(Gradient Clipping):限制梯度的范数,防止梯度爆炸。
def clip_gradients(parameters, max_norm=1.0):
"""裁剪梯度"""
torch.nn.utils.clip_grad_norm_(parameters, max_norm)
梯度缩放(Gradient Scaling):在混合精度训练中,先将梯度放大,然后在更新参数前再缩小,有助于防止梯度下溢。
# 使用PyTorch的自动混合精度
from torch.cuda.amp import autocast, GradScaler
# 创建梯度缩放器
scaler = GradScaler()
# 训练循环
for batch in dataloader:
optimizer.zero_grad()
# 使用自动混合精度
with autocast():
outputs = model(batch)
loss = criterion(outputs, targets)
# 缩放梯度并反向传播
scaler.scale(loss).backward()
# 缩放梯度并更新参数
scaler.step(optimizer)
# 更新缩放因子
scaler.update()
残差连接(Residual Connections):在深层网络中使用残差连接,帮助梯度流动。Transformer架构中的残差连接是解决梯度消失的关键组件。
合适的激活函数:使用不容易饱和的激活函数,如ReLU、GELU等,而不是sigmoid或tanh。
合适的初始化:如前所述,使用合适的初始化方法可以帮助控制梯度的尺度。
7.6.2 训练不稳定#
问题描述:训练过程中损失波动大,难以收敛,或者突然发散。
解决方案:
降低学习率:过高的学习率是训练不稳定的常见原因。尝试将学习率降低5-10倍。
使用学习率预热:如前所述,在训练初期使用较小的学习率,然后逐渐增加。
梯度累积(Gradient Accumulation):当批量大小受限于内存时,可以使用梯度累积来模拟更大的批量。
def train_with_gradient_accumulation(model, dataloader, optimizer, accumulation_steps=4):
"""使用梯度累积训练模型"""
model.train()
optimizer.zero_grad()
for i, batch in enumerate(dataloader):
# 前向传播
outputs = model(batch)
loss = criterion(outputs, targets)
# 缩放损失并反向传播
scaled_loss = loss / accumulation_steps
scaled_loss.backward()
# 每accumulation_steps步更新一次参数
if (i + 1) % accumulation_steps == 0:
optimizer.step()
optimizer.zero_grad()
批量归一化(Batch Normalization):虽然在Transformer中不常用,但在某些情况下,批量归一化可以帮助稳定训练。
层归一化(Layer Normalization):Transformer架构中使用的层归一化有助于稳定训练。
权重衰减(Weight Decay):适当的权重衰减可以防止参数过大,有助于稳定训练。
7.6.3 过拟合#
问题描述:模型在训练集上表现良好,但在验证集或测试集上表现不佳。
解决方案:
权重衰减(Weight Decay):如前所述,使用AdamW优化器并设置适当的权重衰减系数。
Dropout:在网络的不同层之间添加Dropout层,随机丢弃一部分神经元,防止过拟合。
class TransformerWithDropout(nn.Module):
def __init__(self, vocab_size, d_model, nhead, num_layers, dropout=0.1):
super().__init__()
self.embedding = nn.Embedding(vocab_size, d_model)
encoder_layer = nn.TransformerEncoderLayer(d_model, nhead, dim_feedforward=4*d_model, dropout=dropout)
self.transformer = nn.TransformerEncoder(encoder_layer, num_layers)
self.dropout = nn.Dropout(dropout)
self.fc = nn.Linear(d_model, vocab_size)
def forward(self, x):
x = self.embedding(x)
x = self.transformer(x)
x = self.dropout(x) # 额外的dropout
x = self.fc(x)
return x
提前停止(Early Stopping):监控验证集性能,当性能不再提升时停止训练。
def train_with_early_stopping(model, train_loader, val_loader, optimizer, patience=5):
"""使用提前停止训练模型"""
best_val_loss = float('inf')
patience_counter = 0
for epoch in range(100): # 最多训练100轮
# 训练一轮
train_loss = train_epoch(model, train_loader, optimizer)
# 在验证集上评估
val_loss = evaluate(model, val_loader)
print(f"Epoch {epoch}: Train Loss = {train_loss:.4f}, Val Loss = {val_loss:.4f}")
# 检查是否需要提前停止
if val_loss < best_val_loss:
best_val_loss = val_loss
patience_counter = 0
# 保存最佳模型
torch.save(model.state_dict(), 'best_model.pt')
else:
patience_counter += 1
if patience_counter >= patience:
print(f"Early stopping after {epoch+1} epochs")
break
数据增强(Data Augmentation):对训练数据进行增强,增加数据多样性。对于文本数据,可以使用同义词替换、回译等方法。
正则化技术:除了权重衰减,还可以使用其他正则化技术,如标签平滑(Label Smoothing)。
def label_smoothing_loss(logits, targets, smoothing=0.1):
"""带标签平滑的交叉熵损失"""
log_probs = F.log_softmax(logits, dim=-1)
nll_loss = -log_probs.gather(dim=-1, index=targets.unsqueeze(1)).squeeze(1)
smooth_loss = -log_probs.mean(dim=-1)
loss = (1 - smoothing) * nll_loss + smoothing * smooth_loss
return loss.mean()
7.6.4 训练效率低下#
问题描述:训练速度慢,资源利用率低。
解决方案:
混合精度训练:使用较低精度(如float16)进行前向和反向传播,但使用float32进行参数更新。
# 使用PyTorch的自动混合精度
from torch.cuda.amp import autocast, GradScaler
# 创建梯度缩放器
scaler = GradScaler()
# 训练循环
for batch in dataloader:
optimizer.zero_grad()
# 使用自动混合精度
with autocast():
outputs = model(batch)
loss = criterion(outputs, targets)
# 缩放梯度并反向传播
scaler.scale(loss).backward()
# 缩放梯度并更新参数
scaler.step(optimizer)
# 更新缩放因子
scaler.update()
梯度检查点(Gradient Checkpointing):通过在前向传播中重新计算中间激活值而不是存储它们,减少内存使用,允许使用更大的批量或更深的模型。
from torch.utils.checkpoint import checkpoint
class TransformerWithCheckpointing(nn.Module):
def __init__(self, vocab_size, d_model, nhead, num_layers):
super().__init__()
self.embedding = nn.Embedding(vocab_size, d_model)
self.layers = nn.ModuleList([
nn.TransformerEncoderLayer(d_model, nhead, dim_feedforward=4*d_model)
for _ in range(num_layers)
])
self.fc = nn.Linear(d_model, vocab_size)
def forward(self, x):
x = self.embedding(x)
for layer in self.layers:
# 使用梯度检查点
x = checkpoint(layer, x)
x = self.fc(x)
return x
优化数据加载:使用多进程数据加载,预取数据,减少CPU和GPU之间的等待时间。
def create_dataloader(dataset, batch_size=32, num_workers=4):
"""创建优化的数据加载器"""
return DataLoader(
dataset,
batch_size=batch_size,
shuffle=True,
num_workers=num_workers, # 多进程加载
pin_memory=True, # 将数据固定在内存中,加速CPU到GPU的传输
prefetch_factor=2 # 预取因子
)
模型并行和数据并行:对于大型模型,可以使用模型并行(将模型分布在多个设备上)和数据并行(在多个设备上复制模型,每个设备处理数据的不同部分)。
# 数据并行
model = nn.DataParallel(model)
# 或者使用分布式数据并行
model = nn.parallel.DistributedDataParallel(model)
使用更高效的实现:某些操作有多种实现方式,选择最高效的实现可以显著提高训练速度。例如,使用Flash Attention代替标准注意力机制。
7.6.5 优化器状态管理#
问题描述:在训练大型模型时,优化器状态(如Adam的动量和方差)可能占用大量内存,甚至超过模型参数本身。
解决方案:
优化器状态分片(Optimizer State Sharding):将优化器状态分布在多个设备上,减少每个设备的内存负担。这是ZeRO优化器的核心思想之一。
使用内存高效的优化器:某些优化器变种设计为更内存高效,如Adafactor,它使用因子化的二阶矩估计,显著减少内存使用。
from transformers.optimization import Adafactor
optimizer = Adafactor(
model.parameters(),
lr=1e-3,
relative_step=False,
scale_parameter=False,
warmup_init=False
)
优化器状态压缩:对优化器状态进行量化或压缩,减少内存使用。
梯度累积:如前所述,使用梯度累积可以减少内存使用,因为它允许使用更小的批量。
检查点保存与恢复:定期保存训练检查点,包括模型参数和优化器状态,以便在训练中断时恢复。
def save_checkpoint(model, optimizer, scheduler, epoch, loss, filename):
"""保存训练检查点"""
torch.save({
'epoch': epoch,
'model_state_dict': model.state_dict(),
'optimizer_state_dict': optimizer.state_dict(),
'scheduler_state_dict': scheduler.state_dict() if scheduler else None,
'loss': loss
}, filename)
def load_checkpoint(model, optimizer, scheduler, filename):
"""加载训练检查点"""
checkpoint = torch.load(filename)
model.load_state_dict(checkpoint['model_state_dict'])
optimizer.load_state_dict(checkpoint['optimizer_state_dict'])
if scheduler and 'scheduler_state_dict' in checkpoint:
scheduler.load_state_dict(checkpoint['scheduler_state_dict'])
epoch = checkpoint['epoch']
loss = checkpoint['loss']
return epoch, loss
7.7 故事生成模型的优化实践#
在本节中,我们将整合前面讨论的所有优化技术,提供一个完整的故事生成模型训练流程。
7.7.1 完整的训练流程#
import torch
import torch.nn as nn
import torch.optim as optim
import torch.nn.functional as F
from torch.utils.data import DataLoader
from torch.cuda.amp import autocast, GradScaler
import math
import time
from tqdm import tqdm
# 假设我们已经定义了模型和数据集
model = StorytellerModel(vocab_size=50000, d_model=768, nhead=12, num_layers=12)
train_dataset = StoryDataset(train_files)
val_dataset = StoryDataset(val_files)
# 创建数据加载器
train_loader = DataLoader(
train_dataset,
batch_size=32,
shuffle=True,
num_workers=4,
pin_memory=True
)
val_loader = DataLoader(
val_dataset,
batch_size=32,
shuffle=False,
num_workers=4,
pin_memory=True
)
# 将模型移动到GPU
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = model.to(device)
# 初始化模型参数
def init_weights(module):
if isinstance(module, (nn.Linear, nn.Embedding)):
module.weight.data.normal_(mean=0.0, std=0.02)
if isinstance(module, nn.Linear) and module.bias is not None:
module.bias.data.zero_()
elif isinstance(module, nn.LayerNorm):
module.bias.data.zero_()
module.weight.data.fill_(1.0)
model.apply(init_weights)
# 创建优化器
no_decay = ['bias', 'layernorm', 'layer_norm']
optimizer_grouped_parameters = [
{
'params': [p for n, p in model.named_parameters() if not any(nd in n for nd in no_decay)],
'weight_decay': 0.01
},
{
'params': [p for n, p in model.named_parameters() if any(nd in n for nd in no_decay)],
'weight_decay': 0.0
}
]
optimizer = optim.AdamW(
optimizer_grouped_parameters,
lr=5e-5,
betas=(0.9, 0.999),
eps=1e-8
)
# 创建学习率调度器
total_steps = len(train_loader) * 10 # 假设训练10轮
warmup_steps = total_steps // 10 # 预热10%的步数
def lr_lambda(current_step):
if current_step < warmup_steps:
return current_step / warmup_steps
else:
return 0.5 * (1 + math.cos((current_step - warmup_steps) * math.pi / (total_steps - warmup_steps)))
scheduler = optim.lr_scheduler.LambdaLR(optimizer, lr_lambda)
# 创建梯度缩放器(用于混合精度训练)
scaler = GradScaler()
# 训练函数
def train_epoch(model, dataloader, optimizer, scheduler, scaler, device):
model.train()
total_loss = 0
start_time = time.time()
for batch in tqdm(dataloader):
# 将数据移动到设备
input_ids = batch['input_ids'].to(device)
attention_mask = batch['attention_mask'].to(device)
labels = batch['labels'].to(device)
# 清零梯度
optimizer.zero_grad()
# 使用混合精度
with autocast():
outputs = model(input_ids, attention_mask=attention_mask, labels=labels)
loss = outputs.loss
# 缩放梯度并反向传播
scaler.scale(loss).backward()
# 梯度裁剪
scaler.unscale_(optimizer)
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
# 更新参数
scaler.step(optimizer)
scaler.update()
# 更新学习率
scheduler.step()
total_loss += loss.item()
avg_loss = total_loss / len(dataloader)
elapsed = time.time() - start_time
print(f"Train Loss: {avg_loss:.4f}, Time: {elapsed:.2f}s")
return avg_loss
# 评估函数
def evaluate(model, dataloader, device):
model.eval()
total_loss = 0
with torch.no_grad():
for batch in tqdm(dataloader):
input_ids = batch['input_ids'].to(device)
attention_mask = batch['attention_mask'].to(device)
labels = batch['labels'].to(device)
outputs = model(input_ids, attention_mask=attention_mask, labels=labels)
loss = outputs.loss
total_loss += loss.item()
avg_loss = total_loss / len(dataloader)
print(f"Validation Loss: {avg_loss:.4f}")
return avg_loss
# 训练循环
best_val_loss = float('inf')
patience = 3
patience_counter = 0
for epoch in range(10): # 训练10轮
print(f"Epoch {epoch+1}/{10}")
# 训练一轮
train_loss = train_epoch(model, train_loader, optimizer, scheduler, scaler, device)
# 评估
val_loss = evaluate(model, val_loader, device)
# 检查是否需要保存模型
if val_loss < best_val_loss:
best_val_loss = val_loss
patience_counter = 0
# 保存模型
torch.save({
'epoch': epoch,
'model_state_dict': model.state_dict(),
'optimizer_state_dict': optimizer.state_dict(),
'scheduler_state_dict': scheduler.state_dict(),
'val_loss': val_loss
}, 'best_storyteller_model.pt')
print(f"Model saved at epoch {epoch+1}")
else:
patience_counter += 1
if patience_counter >= patience:
print(f"Early stopping at epoch {epoch+1}")
break
# 加载最佳模型
checkpoint = torch.load('best_storyteller_model.pt')
model.load_state_dict(checkpoint['model_state_dict'])
print(f"Loaded best model from epoch {checkpoint['epoch']+1} with validation loss {checkpoint['val_loss']:.4f}")
7.7.2 优化技巧总结#
以下是训练故事生成模型的关键优化技巧:
初始化:使用标准差为0.02的正态分布初始化大多数参数,层归一化的权重初始化为1,偏置初始化为0。
优化器:使用AdamW优化器,学习率设为5e-5,权重衰减设为0.01(对于非偏置和非层归一化参数)。
学习率调度:使用线性预热后余弦衰减的学习率调度策略,预热步数为总步数的10%。
混合精度训练:使用PyTorch的自动混合精度功能,减少内存使用并加速训练。
梯度裁剪:将梯度范数限制在1.0以内,防止梯度爆炸。
提前停止:监控验证损失,当连续3轮不再改善时停止训练。
检查点保存:保存验证损失最低的模型检查点。
数据加载优化:使用多进程数据加载、内存固定和预取,减少等待时间。
7.7.3 大规模训练的考虑#
对于更大规模的故事生成模型(如具有数十亿参数的模型),还需要考虑以下优化技术:
分布式训练:使用多个GPU或多台机器进行训练,可以采用数据并行、模型并行或流水线并行等策略。
ZeRO优化器:使用ZeRO(Zero Redundancy Optimizer)减少内存使用,允许在有限资源上训练更大的模型。
梯度累积:当批量大小受限于内存时,使用梯度累积来模拟更大的批量。
梯度检查点:通过在前向传播中重新计算中间激活值而不是存储它们,减少内存使用。
模型量化:在训练过程中使用量化技术减少内存使用和计算需求。
优化器状态分片:将优化器状态分布在多个设备上,减少每个设备的内存负担。
高效注意力实现:使用Flash Attention等高效注意力实现,减少内存使用并加速训练。
7.8 总结与展望#
在本章中,我们深入探讨了神经网络优化的核心概念和技术,包括参数初始化、优化算法、学习率调度以及各种优化问题的解决方案。我们特别关注了AdamW优化器,这是训练大语言模型的标准选择,并讨论了如何在故事生成模型中应用这些优化技术。
优化是训练成功的关键因素,合适的优化策略可以加速收敛、提高模型性能,并帮助模型在有限的计算资源下达到最佳效果。随着模型规模的不断增长,优化技术也在不断发展,以应对新的挑战。
在接下来的章节中,我们将探讨如何进一步提高训练和推理的速度,包括利用不同的计算设备、使用混合精度训练以及分布式优化等技术。这些技术将使我们能够训练更大、更强大的故事生成模型,创造更丰富、更有创意的故事内容。
练习与思考
尝试实现不同的参数初始化方法,并比较它们对模型训练的影响。
比较Adam和AdamW优化器在带有L2正则化的任务上的性能差异。
实现并比较不同的学习率调度策略,观察它们对训练过程和最终模型性能的影响。
设计一个实验,比较混合精度训练与全精度训练在速度和精度上的差异。
思考如何针对故事生成任务设计特定的优化策略,考虑故事的结构、连贯性和创意性等特点。
参考资料
Kingma, D. P., & Ba, J. (2014). Adam: A Method for Stochastic Optimization. arXiv preprint arXiv:1412.6980.
Loshchilov, I., & Hutter, F. (2017). Decoupled Weight Decay Regularization. arXiv preprint arXiv:1711.05101.
He, K., Zhang, X., Ren, S., & Sun, J. (2015). Delving Deep into Rectifiers: Surpassing Human-Level Performance on ImageNet Classification. In Proceedings of the IEEE International Conference on Computer Vision.
Glorot, X., & Bengio, Y. (2010). Understanding the Difficulty of Training Deep Feedforward Neural Networks. In Proceedings of the Thirteenth International Conference on Artificial Intelligence and Statistics.
Smith, L. N. (2017). Cyclical Learning Rates for Training Neural Networks. In 2017 IEEE Winter Conference on Applications of Computer Vision (WACV).
Brown, T. B., Mann, B., Ryder, N., Subbiah, M., Kaplan, J., Dhariwal, P., … & Amodei, D. (2020). Language Models are Few-Shot Learners. In Advances in Neural Information Processing Systems.
Radford, A., Wu, J., Child, R., Luan, D., Amodei, D., & Sutskever, I. (2019). Language Models are Unsupervised Multitask Learners. OpenAI Blog.