1. 为什么我们需要优化思想
在深度学习和机器学习的世界里,优化算法扮演着发动机的角色。想象你正在驾驶一辆汽车,优化算法就是决定如何踩油门、刹车和转向的那套系统。PyTorch作为当前最流行的深度学习框架之一,其优化能力直接决定了模型训练的效率和最终性能。
我刚开始接触PyTorch时,常常困惑于为什么需要这么多不同的优化器(如SGD、Adam等),以及它们背后的数学原理。直到在实际项目中遇到模型不收敛、训练速度慢的问题,才真正理解优化思想的重要性。优化不仅仅是选择一个现成的优化器那么简单,它关乎如何高效地调整模型参数,使损失函数达到最小值。
最小二乘法作为最基础的优化方法之一,是理解更复杂优化算法的敲门砖。它源于200多年前高斯用于预测行星轨道的工作,至今仍在机器学习的线性回归等问题中广泛应用。通过PyTorch实现最小二乘,我们能直观地看到优化过程如何一步步调整参数,逼近最优解。
提示:理解优化思想的关键在于把数学公式和实际参数更新过程对应起来。不要被符号吓到,每个符号都对应着代码中的具体变量。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. PyTorch中的自动微分机制
2.1 autograd的工作原理
PyTorch的autograd系统是其优化能力的核心。与TensorFlow的静态计算图不同,PyTorch采用动态计算图,这意味着我们可以在运行时构建和修改计算流程。当我第一次使用requires_grad=True标记张量时,PyTorch就开始在背后默默记录所有相关操作,构建一个计算图。
这个计算图本质上是一个有向无环图(DAG),记录了从输入到输出的数据流动路径。每个节点代表一个操作(如加法、矩阵乘法),边代表张量。当我们调用.backward()时,PyTorch会沿着这个图反向传播,自动计算梯度。
python复制import torch
# 创建需要计算梯度的张量
x = torch.tensor([2.0], requires_grad=True)
y = x ** 2 # y = x²
# 反向传播计算梯度
y.backward()
print(x.grad) # dy/dx = 2x → 4.0
2.2 梯度计算的常见陷阱
在实际项目中,我遇到过几个关于autograd的典型问题:
- 梯度累积:在循环中多次调用.backward()而不清零梯度,会导致梯度累加。这有时是有意为之(如小批量累积),但多数情况下需要手动清零:
python复制optimizer.zero_grad() # 清零梯度
loss.backward() # 计算梯度
optimizer.step() # 更新参数
-
分离计算图:使用.detach()可以切断部分计算图,这在生成对抗网络(GAN)等场景很有用。我曾在一个GAN项目中因为忘记detach生成器的输出,导致判别器的梯度影响到生成器,造成训练不稳定。
-
内存泄漏:保留不必要的中间变量引用会导致计算图无法释放。一个诊断技巧是使用torch.cuda.empty_cache()后观察内存变化。
3. 最小二乘法的PyTorch实现
3.1 问题定义与数学基础
最小二乘法要解决的是这样的问题:给定一组观测数据点,找到一条直线(或更一般的曲线)使得所有点到这条曲线的垂直距离平方和最小。用数学表示就是:
minimize ∑(yᵢ - (wxᵢ + b))²
其中w是斜率,b是截距。在PyTorch中,我们可以把这个过程看作是一个特殊的神经网络——只有一个全连接层,使用平方损失函数,没有激活函数。
我第一次实现时犯了一个错误:没有对输入数据做标准化处理。当特征尺度差异很大时,这会导致优化过程非常缓慢。后来我学会了在训练前加上:
python复制X = (X - X.mean()) / X.std()
3.2 从零实现的完整代码
下面是一个完整的PyTorch最小二乘实现,包含了我积累的几个实用技巧:
python复制import torch
import matplotlib.pyplot as plt
# 生成合成数据
torch.manual_seed(42)
X = torch.linspace(0, 10, 100).reshape(-1, 1)
true_w = 1.5
true_b = 0.5
y = true_w * X + true_b + torch.randn(X.shape) * 1.5 # 添加噪声
# 参数初始化(故意设得远离真实值以观察收敛)
w = torch.tensor([-2.0], requires_grad=True)
b = torch.tensor([5.0], requires_grad=True)
# 优化器选择
optimizer = torch.optim.SGD([w, b], lr=0.02) # 学习率需要小心调整
# 训练循环
loss_history = []
for epoch in range(100):
# 前向传播
y_pred = w * X + b
loss = torch.mean((y_pred - y) ** 2) # MSE损失
# 反向传播
optimizer.zero_grad()
loss.backward()
optimizer.step()
# 记录损失
loss_history.append(loss.item())
# 每20轮打印进度
if epoch % 20 == 0:
print(f'Epoch {epoch}: w={w.item():.3f}, b={b.item():.3f}, loss={loss.item():.3f}')
# 结果可视化
plt.figure(figsize=(12, 4))
plt.subplot(1, 2, 1)
plt.scatter(X.numpy(), y.numpy(), alpha=0.7, label='data')
plt.plot(X.numpy(), (w.item() * X + b.item()).numpy(), 'r-', label='fit')
plt.legend()
plt.subplot(1, 2, 2)
plt.plot(loss_history)
plt.xlabel('Epoch')
plt.ylabel('Loss')
plt.show()
这段代码有几个值得注意的地方:
- 使用torch.manual_seed(42)保证可重复性
- 故意将初始参数设得远离真实值,以便观察优化过程
- 学习率设为0.02——这个值经过多次试验,太大容易震荡,太小收敛慢
- 使用Matplotlib同时展示拟合结果和损失曲线
3.3 解析解与梯度下降法的对比
有趣的是,线性回归问题其实有解析解(闭合解):
ŵ = (XᵀX)⁻¹Xᵀy
在PyTorch中可以用以下代码计算:
python复制X_with_bias = torch.cat([X, torch.ones_like(X)], dim=1)
analytic_solution = torch.linalg.inv(X_with_bias.T @ X_with_bias) @ X_with_bias.T @ y
w_analytic, b_analytic = analytic_solution
为什么我们还要用梯度下降呢?原因有三:
- 当特征维度很高时,计算(XᵀX)⁻¹的复杂度是O(n³),非常昂贵
- 梯度下降可以轻松扩展到海量数据集(小批量梯度下降)
- 对于非线性模型,解析解通常不存在,梯度下降是唯一选择
在我的笔记本上测试,对于这个简单问题,解析解确实更快(0.5ms vs 梯度下降的15ms),但随着数据量增大,梯度下降的优势会显现。
4. 优化算法的进阶思考
4.1 学习率的影响与选择
学习率可能是超参数调优中最重要的一个。我制作了一个对比实验,展示不同学习率下的收敛情况:
python复制learning_rates = [0.001, 0.01, 0.05, 0.1, 0.5]
plt.figure(figsize=(10, 6))
for lr in learning_rates:
w = torch.tensor([-2.0], requires_grad=True)
b = torch.tensor([5.0], requires_grad=True)
optimizer = torch.optim.SGD([w, b], lr=lr)
losses = []
for _ in range(100):
optimizer.zero_grad()
loss = torch.mean((w * X + b - y) ** 2)
loss.backward()
optimizer.step()
losses.append(loss.item())
plt.plot(losses, label=f'lr={lr}')
plt.yscale('log')
plt.legend()
plt.show()
从图中可以清晰看到:
- lr=0.001:收敛太慢
- lr=0.01~0.05:稳定收敛
- lr=0.1:开始出现轻微震荡
- lr=0.5:完全发散
实践中,我通常先用一个较大的学习率(如0.1)快速试错,然后根据损失曲线调整。PyTorch还提供了学习率调度器(如ReduceLROnPlateau),可以在训练中动态调整学习率。
4.2 不同优化器的对比
除了基础的SGD,PyTorch还提供了多种优化器。我在相同设置下对比了几种常见优化器:
python复制optimizers = {
'SGD': torch.optim.SGD([w, b], lr=0.05),
'Momentum': torch.optim.SGD([w, b], lr=0.05, momentum=0.9),
'Adam': torch.optim.Adam([w, b], lr=0.05),
'Adagrad': torch.optim.Adagrad([w, b], lr=0.05)
}
plt.figure(figsize=(10, 6))
for name, opt in optimizers.items():
w = torch.tensor([-2.0], requires_grad=True)
b = torch.tensor([5.0], requires_grad=True)
optimizer = opt
losses = []
for _ in range(100):
optimizer.zero_grad()
loss = torch.mean((w * X + b - y) ** 2)
loss.backward()
optimizer.step()
losses.append(loss.item())
plt.plot(losses, label=name)
plt.legend()
plt.show()
结果显示:
- 普通SGD收敛最慢
- 带动量的SGD有明显改善
- Adam表现最好,快速且稳定
- Adagrad在这个简单问题上表现不佳
这解释了为什么Adam成为深度学习中的默认选择,特别是对于新手。但要注意,Adam有时会收敛到次优解,在需要极高精度的任务上,调优后的SGD可能更好。
4.3 正则化与数值稳定性
在实际应用中,我们通常会在损失函数中加入正则项防止过拟合。对于线性回归,最常见的是L2正则化(岭回归):
loss = MSE + λ||w||²
在PyTorch中实现非常简单:
python复制lambda_ = 0.1 # 正则化强度
loss = torch.mean((w * X + b - y) ** 2) + lambda_ * torch.sum(w ** 2)
正则化不仅防止过拟合,还能改善数值稳定性。当特征间存在高度相关性时,XᵀX可能接近奇异矩阵,求逆会不稳定。加入λI项可以保证矩阵可逆。
另一个数值技巧是在计算逆矩阵时使用更稳定的版本:
python复制# 不稳定的
torch.linalg.inv(X.T @ X)
# 更稳定的
torch.linalg.pinv(X.T @ X) # 伪逆
我在一个金融预测项目中就遇到过这个问题——某些宏观经济指标高度相关,导致普通最小二乘完全失效,加入L2正则后模型才稳定工作。
