资讯动态

L-BFGS对抗样本攻击实战:从数学原理到代码复现

发布时间:2026/10/13 14:05:19 来源:尧图企业网站定制
对抗样本这个方向只要你在安全圈或者机器学习圈待过一阵子大概率绕不开一个名字——L-BFGS。很多人第一次看到它是在一篇2014年的经典文章里那篇文章第一次系统性地告诉大家只要在输入上加一点点精心构造的扰动就能让当时表现极好的深度模型彻底“翻车”。而实现这个扰动的核心求解手段就是今天要聊的L-BFGS方法。我写这个系列的第二篇是因为第一篇讲对抗样本概念的时候后台问得最多的问题就是“那个扰动到底怎么算出来的”。光知道概念没用你得能自己跑出来一个对抗样本才算真正入门。这篇就专门把L-BFGS这条线讲透从它为什么被选中、背后的数学原理、到一步步用代码复现再到实际调试中会踩的坑全部摊开说。适合已经了解对抗样本基本概念、想动手实现第一个攻击样本的读者也适合想搞清楚“优化器选型”这件事背后逻辑的从业者。1. 为什么第一个对抗攻击要用L-BFGS1.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, c1e-2, max_iter100): image: 单张图片shape [1, C, H, W] target_label: 想要误判成的类别 c: 扰动惩罚系数 # 把图片变成需要优化的变量 x_adv image.clone().detach().requires_grad_(True) optimizer torch.optim.LBFGS([x_adv], max_itermax_iter, line_search_fnstrong_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, p2) ** 2 loss c * loss_dist loss_adv loss.backward() return loss optimizer.step(closure) # 检查是否攻击成功 with torch.no_grad(): final_pred model(x_adv).argmax(dim1).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_init1e-2, max_search9, tol1e-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, cmapgray) axes[0].set_title(fOriginal: {pred_orig}) axes[1].imshow(img_adv, cmapgray) axes[1].set_title(fAdversarial: {pred_adv}) axes[2].imshow(perturbation * 10, cmapgray) 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_adv4.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用多步投影梯度在快和好之间找平衡。CW攻击改进了目标函数设计用更平滑的损失和更好的参数选择策略本质上还是优化框架但工程上更实用。理解这条演进线你就能明白每个方法在解决什么问题。L-BFGS是起点它证明了对抗样本的存在性和可计算性。后续方法是在它的基础上做工程优化。5.4 防御视角下的意义从防御角度看L-BFGS攻击揭示了一个根本问题模型的决策边界在输入空间里离真实样本太近了。只要沿着梯度方向稍微走一点就能跨过边界。这促使了对抗训练等防御方法的出现——在训练时就把对抗样本加进去让决策边界离样本远一点。所以学L-BFGS不只是学一个攻击方法更是理解对抗样本领域核心矛盾的一把钥匙。这个矛盾就是模型要拟合复杂函数就必然在输入空间产生陡峭的决策边界而陡峭边界意味着小扰动就能改变分类结果。我在实际跑L-BFGS的时候最大的体会是别把它当成一个“拿来就能用”的攻击工具而是当成一个理解优化过程的实验平台。通过调c、看扰动、分析失败案例你能直观感受到模型决策边界的形状。这种直觉比跑通代码本身更值钱。另外如果你要做批量实验建议先把单张图的流程调稳固定好随机种子和归一化方式再扩展到数据集级别否则各种随机因素混在一起排查问题会很痛苦。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑