对抗样本这个方向,只要你在安全圈或者机器学习圈待过一阵子,大概率绕不开一个名字——L-BFGS。很多人第一次看到它是在一篇2014年的经典文章里,那篇文章第一次系统性地告诉大家:只要在输入上加一点点精心构造的扰动,就能让当时表现极好的深度模型彻底“翻车”。而实现这个扰动的核心求解手段,就是今天要聊的L-BFGS方法。
我写这个系列的第二篇,是因为第一篇讲对抗样本概念的时候,后台问得最多的问题就是“那个扰动到底怎么算出来的”。光知道概念没用,你得能自己跑出来一个对抗样本,才算真正入门。这篇就专门把L-BFGS这条线讲透,从它为什么被选中、背后的数学原理、到一步步用代码复现,再到实际调试中会踩的坑,全部摊开说。适合已经了解对抗样本基本概念、想动手实现第一个攻击样本的读者,也适合想搞清楚“优化器选型”这件事背后逻辑的从业者。
1. 为什么第一个对抗攻击要用L-BFGS
1.1 对抗样本的本质是一个带约束的优化问题
先把问题定义清楚。假设你有一个训练好的分类模型,输入是一张图片x,模型输出标签y。你想构造一个新的输入x',让它和原图看起来几乎一样,但模型会把它分错。用数学语言写出来就是两件事:第一,最小化x'和x之间的差异,让肉眼看不出来;第二,让模型对x'的预测结果变成你指定的错误标签。
这两件事天然是矛盾的。差异越小,越难骗过模型;骗得越狠,差异往往越大。所以这不是一个单纯的求最小值问题,而是一个“在满足欺骗条件的前提下,让扰动尽可能小”的约束优化问题。形式化地写:
minimize ||x' - x|| subject to C(x') = target_label这里的范数通常用L2或者L∞,前者衡量整体能量,后者衡量单点最大改动。约束条件就是模型分类结果等于目标标签。
问题来了,这个约束是高度非线性的,因为模型本身是一堆卷积、激活函数堆出来的黑盒。你没法直接解出解析解,只能靠数值优化一步步逼近。这就自然引出了“选哪个优化器”的问题。
1.2 L-BFGS在众多优化器里凭什么胜出
当时可选的优化器不少,梯度下降、动量法、Adam这些都能用。但L-BFGS有几个特性特别契合这个场景。
第一,它是拟牛顿法的一种,利用历史梯度信息去近似海森矩阵,收敛速度比一阶方法快得多。对抗样本的求解往往需要几百上千次迭代,如果每次迭代都慢,整体耗时就爆炸。L-BFGS在中小规模问题上通常几十次迭代就能收敛到不错的解。
第二,它不需要手动设置学习率。梯度下降最头疼的就是学习率调参,太大震荡、太小龟速。L-BFGS通过线搜索自动确定步长,省去了这个麻烦。对于刚入门的人来说,少一个超参数就少一个坑。
第三,它适合中小规模的连续优化。对抗样本的扰动维度虽然和图片像素数一样大,但相比训练整个网络,它只是一个输入层面的优化,参数量可控。L-BFGS的“limited memory”版本正是为这种内存受限场景设计的,不用存完整的海森矩阵,只存最近若干步的梯度差和参数差。
注意:L-BFGS并不是万能的。它在非凸、非光滑、随机噪声大的目标上表现会打折。对抗样本的目标函数恰好是非凸的,所以它求到的是局部最优,不是全局最优。这一点后面会展开讲。
1.3 和后续攻击方法的定位差异
后来出现的FGSM、PGD这些方法,走的是另一条路——直接沿着梯度符号方向走一大步或者多步。它们快,但扰动往往更大。L-BFGS属于“慢工出细活”的类型,追求的是在成功欺骗的前提下把扰动压到最小。所以它常被当作“最优扰动”的参考基准,用来衡量其他快速攻击方法的效率损失。
理解这个定位很重要。你做研究对比实验的时候,L-BFGS给出的扰动范数往往是最小的那一档,虽然它慢。这就是它的价值所在。
2. L-BFGS求解对抗扰动的核心原理拆解
2.1 把约束问题转成无约束问题
直接带约束不好优化,常规做法是用罚函数或者拉格朗日乘子把它转成无约束形式。经典文章里的做法是构造一个目标函数,把“分类损失”和“扰动大小”加权求和:
loss = c * ||x' - x||^2 + J(x', target_label)这里J是模型的损失函数,比如交叉熵。c是一个超参数,控制两项之间的权衡。c越大,优化器越倾向于把扰动压小;c越小,越倾向于快速让模型分错。
这个转换的巧妙之处在于,它把一个“硬约束”变成了“软惩罚”。你不需要严格保证分类一定成功,而是让损失函数自己去平衡。实际跑的时候,如果最后分类没成功,就把c调小一点再跑;如果成功了但扰动太大,就把c调大。
2.2 目标函数里每一项的作用
拆开看这个目标函数,第一项c * ||x' - x||^2是L2范数的平方,衡量扰动能量。用平方而不是直接范数,是为了求导方便,梯度是线性的。第二项J(x', target_label)是让模型往目标标签靠的损失。
有个细节值得说:这里用的是目标标签的损失,而不是“非原标签”的损失。也就是说,你指定一个想让模型误判成的类别,然后最小化模型对这个类别的损失。这叫“目标攻击”。如果只想让模型分错、不指定错成什么,那就是“非目标攻击”,损失项换成负的原标签损失即可。
两种方式在实现上只差一行代码,但难度不一样。目标攻击通常更难,因为你要把模型推向一个特定的方向,而不是随便推离原方向。
2.3 梯度是怎么算出来的
L-BFGS需要目标函数对x'的梯度。这个梯度可以通过反向传播算出来。因为x'是输入层的一个变量,模型的所有参数固定,你只需要对输入求导。
具体来说,把x'当作需要优化的变量,前向传播得到模型输出,计算损失,然后反向传播到输入层,得到的梯度就是目标函数关于x'的梯度。这一步在主流深度学习框架里都是自动的,你只要把输入张量的requires_grad设为True就行。
实操心得:很多人第一次写的时候忘了把模型参数冻结,结果反向传播把模型权重也更新了。记住,对抗攻击优化的是输入,不是模型。优化器里只传x'这一个变量。
2.4 线搜索与收敛判据
L-BFGS每次迭代会做一次线搜索,找一个合适的步长。线搜索的准则是Armijo条件或者Wolfe条件,保证目标函数有足够下降。如果线搜索失败,优化器会报错或者提前终止。
收敛判据一般是梯度范数小于某个阈值,或者目标函数变化小于阈值,或者达到最大迭代次数。对抗样本场景下,通常设一个最大迭代次数(比如100到500),然后看分类是否成功。因为目标函数非凸,梯度范数不一定能降到很小,所以迭代次数上限是主要的停止条件。
3. 手把手复现L-BFGS对抗攻击
3.1 环境准备与依赖
我用的是PyTorch,版本1.10以上都行。需要numpy做数值处理,matplotlib做可视化,torchvision加载数据集和预训练模型。如果你用TensorFlow,思路一样,只是API不同。
pip install torch torchvision numpy matplotlib数据集用MNIST或者CIFAR-10都行。MNIST简单,跑得快,适合第一次验证。CIFAR-10更接近真实场景,但模型更大,优化慢一些。我建议先用MNIST跑通流程,再换CIFAR-10。
3.2 加载模型和数据
import torch import torch.nn as nn import torchvision.models as models import torchvision.transforms as transforms from torchvision.datasets import MNIST from torch.utils.data import DataLoader device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') # 加载预训练模型,这里用一个简单的CNN class SimpleCNN(nn.Module): def __init__(self): super().__init__() self.conv1 = nn.Conv2d(1, 32, 3, 1) self.conv2 = nn.Conv2d(32, 64, 3, 1) self.fc1 = nn.Linear(64*5*5, 128) self.fc2 = nn.Linear(128, 10) def forward(self, x): x = torch.relu(self.conv1(x)) x = torch.max_pool2d(x, 2) x = torch.relu(self.conv2(x)) x = torch.max_pool2d(x, 2) x = x.view(x.size(0), -1) x = torch.relu(self.fc1(x)) x = self.fc2(x) return x model = SimpleCNN().to(device) # 假设已经训练好,加载权重 # model.load_state_dict(torch.load('model.pth')) model.eval()实际使用时你需要一个训练好的模型。如果手头没有,可以快速在MNIST上训几轮,准确率到98%以上就够用了。
3.3 构造目标函数和优化循环
这是核心部分。我把关键步骤拆开写。
def lbfgs_attack(model, image, target_label, c=1e-2, max_iter=100): """ image: 单张图片,shape [1, C, H, W] target_label: 想要误判成的类别 c: 扰动惩罚系数 """ # 把图片变成需要优化的变量 x_adv = image.clone().detach().requires_grad_(True) optimizer = torch.optim.LBFGS([x_adv], max_iter=max_iter, line_search_fn='strong_wolfe') target = torch.tensor([target_label]).to(image.device) criterion = nn.CrossEntropyLoss() def closure(): optimizer.zero_grad() output = model(x_adv) loss_adv = criterion(output, target) loss_dist = torch.norm(x_adv - image, p=2) ** 2 loss = c * loss_dist + loss_adv loss.backward() return loss optimizer.step(closure) # 检查是否攻击成功 with torch.no_grad(): final_pred = model(x_adv).argmax(dim=1).item() return x_adv.detach(), final_pred这段代码有几个关键点。第一,x_adv是从原图clone出来的,并且requires_grad设为True。第二,优化器只接收x_adv这一个参数。第三,closure函数里先清零梯度,再前向传播算损失,再反向传播。第四,line_search_fn设为strong_wolfe,这是L-BFGS常用的线搜索策略。
3.4 参数c的选择与二分搜索
c这个参数很关键。太小了,扰动大;太大了,攻击不成功。经典文章里用二分搜索来找到最小的c,使得攻击刚好成功。
def find_minimal_c(model, image, target_label, c_init=1e-2, max_search=9, tol=1e-6): c = c_init x_adv, pred = lbfgs_attack(model, image, target_label, c) if pred != target_label: # 攻击没成功,减小c while pred != target_label and c > tol: c /= 2 x_adv, pred = lbfgs_attack(model, image, target_label, c) else: # 攻击成功,尝试增大c来减小扰动 while pred == target_label and c < 1e2: c *= 2 x_adv, pred = lbfgs_attack(model, image, target_label, c) c /= 2 # 回退一步 return c, x_adv这个二分搜索的逻辑是:先试一个初始c,如果攻击失败就减半,直到成功;如果成功就加倍,直到失败,然后回退。最终得到一个临界值附近的c。
注意:二分搜索会让整个攻击耗时成倍增加。实际做实验时,如果只是验证概念,固定一个c就行。要做定量对比再上二分。
3.5 可视化与扰动分析
跑完之后,把原图、对抗样本、扰动放大图并排画出来,直观感受一下。
import matplotlib.pyplot as plt def visualize(image, x_adv, pred_orig, pred_adv): perturbation = (x_adv - image).squeeze().cpu().numpy() img_orig = image.squeeze().cpu().numpy() img_adv = x_adv.squeeze().cpu().numpy() fig, axes = plt.subplots(1, 3, figsize=(12, 4)) axes[0].imshow(img_orig, cmap='gray') axes[0].set_title(f'Original: {pred_orig}') axes[1].imshow(img_adv, cmap='gray') axes[1].set_title(f'Adversarial: {pred_adv}') axes[2].imshow(perturbation * 10, cmap='gray') axes[2].set_title('Perturbation x10') plt.show()扰动放大10倍之后,你能看到模型到底在哪些像素上做了改动。通常改动集中在边缘和纹理区域,这些地方对分类结果影响大。
4. 实操中绕不开的坑与排查技巧
4.1 攻击不成功怎么办
最常见的问题就是跑完优化,模型还是分对了。原因通常有三个。
第一,c太大,扰动惩罚项压过了分类损失,优化器只顾着减小扰动,没动力去骗模型。解决办法是减小c,比如从1e-2降到1e-3。
第二,迭代次数不够。L-BFGS虽然收敛快,但非凸问题可能需要更多迭代。把max_iter从100提到500试试。
第三,目标标签选得太难。有些类别之间特征差异大,模型很难被推过去。换一个和目标类别相近的标签,比如把“1”误判成“7”,比误判成“0”容易。
4.2 扰动过大怎么压
如果攻击成功了,但扰动肉眼可见,说明c太小。增大c,让优化器更在意扰动大小。但注意别增太大导致攻击失败,这就是为什么需要二分搜索来找平衡点。
另一个技巧是用L∞范数代替L2。L2惩罚的是整体能量,可能在某些像素上改动很大但整体范数不大。L∞直接限制单像素最大改动,视觉上更均匀。不过L∞不可导,需要用近似或者次梯度方法。
4.3 优化器报错与线搜索失败
L-BFGS的线搜索有时候会失败,报“line search failed”或者“maximum iterations reached”。这通常是因为目标函数在某些点梯度变化剧烈,线搜索找不到满足Wolfe条件的步长。
应对方法有几个。一是减小初始步长,通过设置optimizer的lr参数(虽然L-BFGS会覆盖,但初始值有影响)。二是换用weak_wolfe线搜索,条件更宽松。三是给x_adv加一个微小的随机初始化,避免落在梯度病态的区域。
实操心得:如果线搜索频繁失败,检查一下输入是否做了归一化。未归一化的像素值范围是0到255,梯度尺度很大,线搜索容易出问题。把输入归一化到0到1或者标准化到均值0方差1,会稳定很多。
4.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决办法 |
|---|---|---|---|
| 攻击始终不成功 | c太大 | 检查c值 | 减小c,重新跑 |
| 攻击成功但扰动大 | c太小 | 计算扰动范数 | 增大c或二分搜索 |
| 线搜索报错 | 输入未归一化 | 检查输入范围 | 归一化到0-1 |
| 优化极慢 | 图片尺寸大 | 看输入shape | 降采样或换小数据集 |
| 梯度为None | 忘记requires_grad | 检查x_adv属性 | 设置requires_grad_(True) |
| 模型权重被更新 | 优化器传了模型参数 | 检查optimizer参数 | 只传x_adv |
4.5 性能优化的小技巧
L-BFGS攻击单张图片在MNIST上大概几秒,CIFAR-10上可能几十秒。如果要批量跑,有几个加速思路。
一是用GPU,把模型和图片都放到cuda上。二是减少max_iter,很多时候50次迭代就够,不必跑到500。三是用更小的模型做代理,比如用一个小CNN生成对抗样本,再迁移到大模型上,这就是迁移攻击的思路。
另外,L-BFGS的memory参数(历史步数)默认是10,调小到5可以省内存但可能影响收敛,调大到20收敛更稳但更慢。根据你的硬件权衡。
5. L-BFGS攻击的局限与后续演进思路
5.1 计算开销是最大短板
说实话,L-BFGS在实际攻击场景里用得不多,主要就是因为慢。每张图都要单独跑一次优化,没法像FGSM那样一次前向反向就出结果。做大规模评估的时候,几千张图跑下来时间成本很高。
但它的价值在于给出理论下界。你做研究对比的时候,用L-BFGS的结果作为“最优扰动”的参考,其他方法跟它比,看差距有多大。这个基准作用是其他快速方法替代不了的。
5.2 非凸优化带来的不确定性
L-BFGS求的是局部最优,不同的初始化可能收敛到不同的解。这意味着同样的图片、同样的c,跑两次可能得到不同的扰动。做实验的时候要注意固定随机种子,否则结果不可复现。
另外,局部最优不代表全局最优。可能存在一个更小的扰动也能成功攻击,但L-BFGS没找到。这是所有基于梯度的攻击方法的通病,不是L-BFGS独有的。
5.3 从L-BFGS到后续方法的演进逻辑
后来的方法基本都在解决L-BFGS的两个痛点:慢和调参麻烦。FGSM用一步梯度符号代替迭代优化,快但扰动大。PGD用多步投影梯度,在快和好之间找平衡。C&W攻击改进了目标函数设计,用更平滑的损失和更好的参数选择策略,本质上还是优化框架,但工程上更实用。
理解这条演进线,你就能明白每个方法在解决什么问题。L-BFGS是起点,它证明了对抗样本的存在性和可计算性。后续方法是在它的基础上做工程优化。
5.4 防御视角下的意义
从防御角度看,L-BFGS攻击揭示了一个根本问题:模型的决策边界在输入空间里离真实样本太近了。只要沿着梯度方向稍微走一点,就能跨过边界。这促使了对抗训练等防御方法的出现——在训练时就把对抗样本加进去,让决策边界离样本远一点。
所以学L-BFGS不只是学一个攻击方法,更是理解对抗样本领域核心矛盾的一把钥匙。这个矛盾就是:模型要拟合复杂函数,就必然在输入空间产生陡峭的决策边界,而陡峭边界意味着小扰动就能改变分类结果。
我在实际跑L-BFGS的时候,最大的体会是别把它当成一个“拿来就能用”的攻击工具,而是当成一个理解优化过程的实验平台。通过调c、看扰动、分析失败案例,你能直观感受到模型决策边界的形状。这种直觉比跑通代码本身更值钱。另外,如果你要做批量实验,建议先把单张图的流程调稳,固定好随机种子和归一化方式,再扩展到数据集级别,否则各种随机因素混在一起,排查问题会很痛苦。