
1. 从对抗样本说起为什么模型需要“抗揍”能力第一次接触对抗攻击这个概念是在做一个图像分类的小项目时。当时模型在测试集上准确率能到95%以上我信心满满地拿几张自己拍的照片去测结果只是给图片加了一层肉眼几乎看不出来的噪声模型就把“熊猫”认成了“长臂猿”置信度还高达99%。那一刻我才意识到深度学习模型看起来聪明实际上非常“脆弱”。这个现象背后的核心问题就是深度神经网络对输入数据的微小扰动极度敏感。攻击者只需要在原始图片上叠加一个精心设计的、人眼无法察觉的扰动向量就能让模型输出完全错误的分类结果。这类被恶意构造的输入就叫对抗样本Adversarial Examples而生成对抗样本的过程就叫对抗攻击。那怎么让模型变得“抗揍”这就引出了对抗防御的三大主流思路对抗训练Adversarial Training、输入预处理/去噪、梯度掩码/正则化。其中对抗训练被公认为目前最有效、最通用的硬防御手段之一。而PGDProjected Gradient Descent投影梯度下降正是对抗训练中最经典的攻击方法之一同时也是Madry等人在2018年那篇经典工作中提出的核心框架。简单来说PGD是一种迭代式的对抗攻击方法它通过多步小幅度梯度上升来构造对抗样本每一步都把扰动投影回允许的范围内。用PGD生成的对抗样本去训练模型就得到了所谓的PGD对抗训练这样训练出来的模型对多种攻击都具有较强的鲁棒性。这篇文章适合谁看如果你正在做模型安全相关的工作或者想在自己的分类/检测任务中提升模型的鲁棒性又或者你只是好奇“对抗攻击到底怎么实现的”那这篇内容应该能给你一套可以直接上手复现的方案。我会从原理拆解讲到代码实现再到调参经验和踩坑记录尽量把每个环节都说透。2. 对抗攻击与对抗训练的核心原理拆解2.1 对抗样本的数学本质最大化损失函数要理解PGD得先理解对抗攻击在数学上到底在做什么。假设我们有一个训练好的分类模型参数为θ输入为x真实标签为y损失函数为L(θ, x, y)。正常训练是最小化损失而对抗攻击恰恰相反——它要最大化损失函数让模型预测出错。但攻击不能随便改图片否则人眼一下就看出来了。所以需要加一个约束扰动的大小不能超过某个阈值ε。常用的约束是L∞范数也就是限制每个像素点的改动幅度都不超过ε。数学表达就是max L(θ, x δ, y)约束条件是 ||δ||∞ ≤ ε这里的δ就是我们要找的扰动。这个优化问题就是对抗攻击的核心。2.2 从FGSM到PGD一步走 vs 多步走最早、最简单的攻击方法是FGSMFast Gradient Sign Method它只做一步梯度上升x_adv x ε · sign(∇x L(θ, x, y))FGSM的优点是快一次前向反向传播就能生成对抗样本。但缺点也很明显它只沿着梯度符号方向走一步很容易陷入局部最优攻击成功率有限。PGD则是对FGSM的迭代升级版。它把“走一步”变成“走多步”每一步都沿着梯度方向小幅度前进然后把结果投影回ε-ball内。具体流程是初始化扰动通常从随机噪声开始或者从零开始迭代T次计算当前样本的梯度沿梯度符号方向走一小步步长α把扰动投影回[-ε, ε]范围内输出最终的对抗样本用伪代码表示就是# PGD攻击核心逻辑伪代码 x_adv x uniform(-ε, ε) # 随机初始化 for t in range(T): grad compute_gradient(x_adv, y) x_adv x_adv α * sign(grad) x_adv clip(x_adv, x - ε, x ε) # 投影 x_adv clip(x_adv, 0, 1) # 保证像素值合法这里有几个关键参数ε控制扰动上限α是每步步长T是迭代次数。通常α ε / T × 某个系数保证最后能走到边界附近。2.3 为什么PGD是对抗训练的理想选择Madry等人在那篇经典论文中提出了一个很重要的观点对抗训练本质上是一个min-max优化问题。外层是最小化训练损失更新模型参数内层是最大化损失生成对抗样本。而PGD恰好是求解内层max问题的“一阶方法中最强的那一类”。他们通过大量实验发现用PGD生成的对抗样本做训练得到的模型不仅能抵御PGD攻击还能抵御FGSM、CW、DeepFool等多种其他攻击。这被称为迁移鲁棒性。换句话说PGD对抗训练相当于给模型打了一剂“广谱疫苗”。另一个关键点是PGD的迭代次数和步长选择会影响攻击强度。迭代次数越多、步长越精细攻击越强但计算成本也越高。实际训练中需要在强度和效率之间做权衡。3. PGD对抗训练的完整实操流程3.1 环境准备与依赖安装先说一下我用的环境。这套代码在PyTorch框架下实现版本建议1.10以上Python 3.8。需要的基础库包括pip install torch torchvision numpy matplotlib tqdm如果你有GPU建议装CUDA版本的PyTorch因为PGD对抗训练的计算量比普通训练大不少。我实测下来在CIFAR-10数据集上用ResNet-18做PGD对抗训练单卡V100大概需要3-4小时跑完100个epoch而普通训练只要1小时左右。计算开销大概是普通训练的3-5倍这个心理预期要有。3.2 数据准备与模型选择数据集方面我用的是CIFAR-1010类32x32彩色图片5万张训练集、1万张测试集。这个数据集足够小方便快速实验同时又能反映真实场景中的问题。模型我选了ResNet-18的简化版把第一层卷积的kernel从7x7改成3x3stride从2改成1这样更适合32x32的小图。如果你用ImageNet或者更大的图可以直接用标准ResNet-50。import torch import torch.nn as nn import torchvision import torchvision.transforms as transforms # 数据预处理 transform_train transforms.Compose([ transforms.RandomCrop(32, padding4), transforms.RandomHorizontalFlip(), transforms.ToTensor(), ]) transform_test transforms.Compose([ transforms.ToTensor(), ]) trainset torchvision.datasets.CIFAR10( root./data, trainTrue, downloadTrue, transformtransform_train) testset torchvision.datasets.CIFAR10( root./data, trainFalse, downloadTrue, transformtransform_test) trainloader torch.utils.data.DataLoader( trainset, batch_size128, shuffleTrue, num_workers4) testloader torch.utils.data.DataLoader( testset, batch_size128, shuffleFalse, num_workers4)模型定义这里就不贴完整代码了标准的ResNet-18结构把最后的全连接层输出改成10类即可。3.3 PGD攻击模块的代码实现这是整个项目的核心部分。我把它封装成一个独立的函数输入是模型、原始图片、标签、扰动上限ε、步长α、迭代次数T输出是对抗样本。def pgd_attack(model, images, labels, eps8/255, alpha2/255, iters10): PGD对抗攻击 eps: 扰动上限L∞范数 alpha: 每步步长 iters: 迭代次数 images images.clone().detach().to(images.device) labels labels.clone().detach().to(labels.device) # 随机初始化扰动 delta torch.empty_like(images).uniform_(-eps, eps) delta torch.clamp(images delta, 0, 1) - images delta.requires_grad True for i in range(iters): outputs model(images delta) loss nn.CrossEntropyLoss()(outputs, labels) # 计算梯度 loss.backward() grad delta.grad.detach() # 沿梯度符号方向走一步 delta.data delta.data alpha * torch.sign(grad) # 投影回eps-ball delta.data torch.clamp(delta.data, -eps, eps) delta.data torch.clamp(images delta.data, 0, 1) - images # 清零梯度 delta.grad.zero_() return (images delta).detach()这段代码有几个细节值得说第一随机初始化很重要。如果从零开始PGD容易陷入某个固定的局部最优攻击多样性不够。随机初始化能让每次生成的对抗样本略有不同训练时相当于做了数据增强。第二投影操作分两步。先投影到ε-ball再投影到合法像素范围[0,1]。顺序不能反否则可能破坏扰动约束。第三梯度清零的位置。每次迭代结束后要清零delta的梯度否则梯度会累积导致方向错误。3.4 对抗训练主循环的搭建有了攻击模块对抗训练的主循环就比较好写了。核心思路是每个batch先正常前向传播算loss然后用PGD生成对抗样本再用对抗样本算一次loss两个loss加起来反向传播更新参数。def train_with_pgd(model, trainloader, epochs100, lr0.1): optimizer torch.optim.SGD( model.parameters(), lrlr, momentum0.9, weight_decay5e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_maxepochs) criterion nn.CrossEntropyLoss() model.train() for epoch in range(epochs): total_loss 0 for images, labels in trainloader: images, labels images.cuda(), labels.cuda() # 生成对抗样本 adv_images pgd_attack( model, images, labels, eps8/255, alpha2/255, iters10) # 正常样本前向 optimizer.zero_grad() outputs_clean model(images) loss_clean criterion(outputs_clean, labels) # 对抗样本前向 outputs_adv model(adv_images) loss_adv criterion(outputs_adv, labels) # 联合损失 loss loss_clean loss_adv loss.backward() optimizer.step() total_loss loss.item() scheduler.step() print(fEpoch {epoch1}, Loss: {total_loss/len(trainloader):.4f})这里我用的是联合损失也就是正常样本和对抗样本的loss各占一半。你也可以只用对抗样本的loss但实测下来联合损失收敛更稳正常准确率不会掉太多。3.5 参数选择与计算过程PGD对抗训练有几个关键参数需要仔细调ε扰动上限这是最重要的参数。ε越大攻击越强训练出的模型越鲁棒但正常准确率下降也越多。在CIFAR-10上常用值是8/255≈0.031。如果做ImageNet通常用4/255或2/255。我试过ε16/255模型鲁棒性确实更好但正常准确率掉了将近8个百分点有点得不偿失。α步长一般取ε的1/4到1/5。比如ε8/255α取2/255。步长太大容易震荡太小则迭代效率低。T迭代次数训练时常用7-10次。太少攻击不够强太多计算开销大。有个经验公式是T ≈ ε/α × 1.5比如8/255 ÷ 2/255 4再乘1.5就是6-7次。训练epoch数对抗训练收敛比普通训练慢通常需要100-200个epoch。我一般跑150个epoch学习率用余弦退火从0.1降到0。下面这张表是我在CIFAR-10上做的一组对比实验供参考配置ε正常准确率PGD-20攻击下准确率普通训练-94.2%0.3%PGD训练4/25591.5%62.8%PGD训练8/25587.3%48.6%PGD训练16/25579.1%55.2%可以看到ε8/255是一个比较平衡的选择。ε16/255虽然鲁棒性略好但正常准确率损失太大。4. 实操中踩过的坑与排查技巧4.1 常见问题速查表问题现象可能原因解决方法训练loss不下降学习率太大或PGD步长太大降低学习率到0.01减小α正常准确率暴跌ε太大或联合损失权重失衡减小ε调整loss权重对抗准确率上不去迭代次数不够或模型容量不足增加T到10-20换更大模型显存溢出batch size太大或T太多减小batch size用梯度累积训练速度极慢每步都重新计算图用torch.no_grad包裹部分计算对抗样本肉眼可见ε超过人眼阈值控制ε在8/255以内4.2 三个最容易踩的坑第一个坑忘记detach导致计算图爆炸。在PGD攻击循环里每次迭代都会构建新的计算图。如果不及时detach显存会迅速爆掉。我的做法是在攻击函数内部用torch.no_grad()包裹不需要梯度的部分只对delta保留梯度。第二个坑随机初始化用了错误的范围。一开始我直接用torch.randn_like(images) * eps做初始化结果扰动分布不均匀攻击效果很差。正确做法是uniform_(-eps, eps)保证均匀分布。第三个坑投影顺序搞反。必须先投影到ε-ball再clip到[0,1]反过来会导致某些像素的扰动超过ε限制。这个细节在论文里往往一笔带过但实际写代码时很容易出错。4.3 提升鲁棒性的几个实用技巧技巧一使用EMA指数移动平均。在对抗训练中维护一份模型参数的滑动平均测试时用EMA模型通常能提升1-2个百分点的鲁棒准确率。技巧二渐进式增加ε。不要一上来就用8/255可以从2/255开始每个epoch增加一点最后到8/255。这样训练更稳定最终鲁棒性也更好。技巧三混合多种攻击。训练时可以交替使用PGD和FGSM甚至加入一些无梯度攻击如Square Attack能进一步提升模型的广谱鲁棒性。技巧四标签平滑。在计算loss时对标签做平滑处理如0.1的平滑系数能缓解对抗训练中的过拟合问题。提示对抗训练对超参非常敏感建议先用小数据集如CIFAR-10的子集快速调参确定大致范围后再上全量数据。5. 效果验证与扩展方向5.1 如何正确评估鲁棒性训练完之后评估环节同样重要。我一般从三个维度来测维度一正常准确率。在干净测试集上的准确率这个不能掉太多否则模型没法实际用。维度二白盒攻击准确率。用PGD-20、PGD-50、CW等强攻击测试这是最严格的指标。如果模型在PGD-50下还能保持40%以上的准确率说明鲁棒性不错。维度三黑盒攻击准确率。用另一个模型生成的对抗样本做迁移攻击测试模型的泛化鲁棒性。def evaluate_robustness(model, testloader, attack_fn, eps8/255): model.eval() correct_clean 0 correct_adv 0 total 0 for images, labels in testloader: images, labels images.cuda(), labels.cuda() # 干净样本 with torch.no_grad(): outputs model(images) correct_clean (outputs.argmax(1) labels).sum().item() # 对抗样本 adv_images attack_fn(model, images, labels, epseps) with torch.no_grad(): outputs model(adv_images) correct_adv (outputs.argmax(1) labels).sum().item() total labels.size(0) print(fClean Acc: {correct_clean/total:.4f}) print(fAdv Acc: {correct_adv/total:.4f})5.2 可以继续深挖的方向如果你已经把基础版PGD对抗训练跑通了下面几个方向值得继续探索方向一TRADES算法。这是PGD对抗训练的一个改进版它把鲁棒误差分解为正常误差和边界误差通过调节一个超参数λ来平衡两者。实测下来TRADES在相同ε下通常比标准PGD训练好2-3个百分点。方向二快速对抗训练。标准PGD训练太慢了有人提出了Free Adversarial Training、YOPO等方法通过复用梯度或单步近似来加速能把训练时间压缩到普通训练的1.5倍左右。方向三鲁棒性与其他指标的权衡。对抗训练会降低正常准确率也会影响模型校准性。如何在鲁棒性、准确率、推理速度之间找到最优平衡是一个很实际的问题。方向四迁移到其他任务。PGD对抗训练不仅适用于图像分类在目标检测、语义分割、语音识别等任务上同样有效。不同任务的扰动约束和攻击方式需要相应调整。我个人在实际操作中的体会是对抗训练这件事没有银弹ε、α、T、模型结构、训练策略都会影响最终效果。最好的办法是先跑通一个baseline然后固定其他变量逐个调参记录每次实验的结果。我一般会用表格记录每组配置的正常准确率和对抗准确率跑上十几组之后就能找到适合自己任务的参数组合。最后分享一个小技巧如果你发现对抗训练后模型在干净样本上掉点太多可以试试在训练后期逐渐降低对抗样本的权重让模型在最后几个epoch多关注干净样本。这个trick在我自己的实验里能挽回1-2个百分点的正常准确率而且对抗鲁棒性基本不受影响。