PyTorch LBFGS:告别传统优化范式,揭秘闭包函数的高效寻优之旅
本文深入探讨了PyTorch中LBFGS优化器的独特优势和使用方法。通过对比传统SGD优化器,揭示了LBFGS利用二阶导数信息和闭包函数实现高效寻优的机制,特别适合解决具有'长窄谷'特征的优化问题。文章以Rosenbrock函数为例展示了LBFGS的卓越性能,并提供了实用的调参技巧和常见问题解决方案。
1. 为什么LBFGS优化器如此特别?
如果你用过PyTorch的SGD或Adam优化器,肯定熟悉那个标准的三步走流程:清空梯度、计算损失、反向传播。但当你第一次看到LBFGS优化器时,可能会被它那个奇怪的closure参数搞得一头雾水。这就像你习惯了开自动挡汽车,突然有人给你一辆需要手动控制离合器的跑车——虽然都是车,但操作方式完全不同。
LBFGS全称Limited-memory Broyden–Fletcher–Goldfarb–Shanno,是一种拟牛顿法优化算法。它和SGD这类一阶优化器的本质区别在于:LBFGS利用了二阶导数信息(Hessian矩阵的近似)来指导优化方向。这就好比爬山时,SGD只告诉你哪个方向是上坡,而LBFGS还会告诉你山坡的曲率,让你能更聪明地选择步长。
在实际项目中,我发现LBFGS特别适合解决那些具有"长窄谷"特征的优化问题。比如经典的Rosenbrock函数,它的最小值位于一个非常平坦的峡谷底部。用SGD优化时,算法会在峡谷两侧来回震荡,进展缓慢;而LBFGS能感知到地形曲率,可以沿着谷底快速前进。
2. 闭包函数:LBFGS的核心魔法
2.1 闭包函数的工作原理
那个让你困惑的closure函数,其实是LBFGS算法的精髓所在。让我们拆解一个典型的闭包实现:
def closure():
optimizer.zero_grad()
loss = criterion(predictions, targets)
loss.backward()
return loss
这个闭包函数做了三件事:清空梯度、计算当前损失、执行反向传播。关键在于,LBFGS会在一次step调用中多次执行这个闭包——这正是它比SGD聪明的地方。
我曾在图像生成项目中使用LBFGS,发现它在每次迭代中会调用闭包3-5次。这是因为LBFGS需要进行线搜索(line search)来确定最佳步长。它会在不同候选点上反复评估损失函数和梯度,直到找到满足Wolfe条件的步长。闭包函数的设计让这种反复评估变得非常自然。
2.2 与标准优化流程的对比
让我们直观比较两种优化流程。标准SGD是这样的:
for epoch in range(epochs):
optimizer.zero_grad()
loss = model(inputs)
loss.backward()
optimizer.step() # 这里只更新参数一次
而LBFGS的流程则是:
for epoch in range(epochs):
def closure():
optimizer.zero_grad()
loss = model(inputs)
loss.backward()
return loss
optimizer.step(closure) # 这里会多次调用closure
在实际编码时,我建议把闭包定义放在循环内部。虽然看起来有些奇怪,但这样能确保每次迭代都使用最新的参数值计算梯度。有次我把闭包定义在循环外,结果模型完全不收敛,调试了半天才发现这个细节问题。
3. LBFGS的实战表现:以Rosenbrock函数为例
3.1 测试环境搭建
为了直观展示LBFGS的优势,我们用经典的Rosenbrock函数做测试。这个函数的数学表达式是:
f(x,y) = (1-x)² + 100(y-x²)²
它在(1,1)处有全局最小值0,但优化路径非常曲折。下面是PyTorch实现:
def rosenbrock(x):
return (1 - x[0])**2 + 100 * (x[1] - x[0]**2)**2
我们设置初始点为(10,10),分别用SGD和LBFGS进行优化。SGD使用固定学习率1e-5,LBFGS使用默认参数。
3.2 结果可视化
经过100次迭代后,两种算法的表现差异惊人:
- SGD的损失值从约8e4降到约1e4,进展缓慢
- LBFGS的损失值直接从8e4降到1e-30以下,几乎达到理论最优
用对数坐标绘制损失曲线,可以清晰看到LBFGS的压倒性优势:
plt.semilogy(sgd_losses, label='SGD')
plt.semilogy(lbfgs_losses, label='L-BFGS')
plt.legend()
plt.show()
在实际应用中,我发现LBFGS这种爆发式的收敛特性特别适合解决以下场景:
- 需要高精度解的科学计算问题
- 参数数量适中(通常小于1万)的优化问题
- 损失函数表面光滑但结构复杂的问题
4. 调参技巧与常见陷阱
4.1 关键参数解析
LBFGS有几个重要参数需要特别注意:
optim.LBFGS(params,
lr=1,
max_iter=20,
max_eval=None,
tolerance_grad=1e-7,
tolerance_change=1e-9,
history_size=100,
line_search_fn=None)
max_iter:每次迭代中允许的最大更新次数。我一般设为4-10,太大容易过拟合history_size:存储的过去梯度/参数更新次数。增大它可以提高近似精度,但会消耗更多内存line_search_fn:建议设为"strong_wolfe",可以避免步长过大或过小的问题
在自然语言处理项目中,我发现设置history_size=50和max_iter=4能在效果和速度间取得很好平衡。
4.2 常见问题排查
使用LBFGS时最容易遇到的几个坑:
-
内存爆炸:LBFGS需要存储历史信息,当参数量很大时会消耗大量内存。有次我在BERT微调时使用LBFGS,直接导致GPU显存溢出。解决方案是减小
history_size或改用小批量训练。 -
NaN损失:由于LBFGS的步长可能很大,有时会导致数值不稳定。可以尝试降低初始学习率
lr(比如从1降到0.5),或者添加梯度裁剪。 -
收敛停滞:如果发现损失不再下降,可以检查闭包函数是否正确实现了梯度计算。我遇到过因为忘记调用
zero_grad()导致梯度累积的bug。
记住一个原则:当问题规模较小时优先尝试LBFGS,它能给你惊喜;但对于大型深度学习模型,可能还是SGD或Adam更稳妥。
更多推荐


所有评论(0)