资讯详情

基于PyTorch的对抗训练实战:从PGD攻击到模型鲁棒性提升

📅 2026/10/22 19:25:29 | 华诺云谱 👁 阅读
基于PyTorch的对抗训练实战:从PGD攻击到模型鲁棒性提升
做深度学习的人都知道模型在测试集上的漂亮分数到了真实环境里往往要打一点折扣。更扎心的是有些模型面对的不是普通的天气变化、光线变化而是有人故意给图片加上一点点人眼几乎看不出来的噪声整个模型就原地崩溃准确率从百分之九十多直接掉到百分之几。这类噪声有一个专门的称呼——对抗样本而让模型在它们面前依然能稳住的能力就是鲁棒性。我这次想聊的就是基于 PyTorch 把对抗训练从零到一跑起来的过程中间踩过不少坑也试着做了些更发散的小改动最后模型的抗干扰能力有了明显起色。这篇东西适合正在做模型落地、或者准备入门对抗防御方向的朋友不需要你有很深的积累只要会用 PyTorch 写普通训练循环就能跟着复现。1. 先想清楚一件事鲁棒性为什么难1.1 一个让人印象深刻的例子我最早对抗样本感兴趣是被一个实验触动的。当时手里有一个已经训练好的图像分类模型在测试集上的准确率在九成以上用起来感觉非常可靠。结果有一天有人用优化方法在原始图片上叠加了一层薄薄的扰动扰动幅度很小人眼看过去完全没差别但模型却把“猫”认成了“烤面包机”。这不只是换一个类别的问题而是模型对自己输出结果的置信度也异常自信。那个时候我就意识到模型学到的东西和我们理解的东西之间隔着一道巨大的鸿沟。这里面有个关键点对抗样本其实不是某种只会出现在学术论文里的花活。工业场景里用户的输入本来就可能被各种因素干扰在更极端的场景下还有人会主动构造这样的输入去试探模型底线。不管出于什么动机只要模型部署出去就要面对这类情况。与其等到出事再被动修补不如在训练阶段就主动把这个问题考虑进去。1.2 泛化和鲁棒性根本不是一回事很多人一开始会混淆两个概念泛化和鲁棒性。泛化是说模型在没见过的、但从同一个分布里采样出来的数据上表现好鲁棒性说的是模型在最困难、最不友善的输入上依然表现稳定。这两个目标并非天然一致。神经网络是一个高度非线性的映射你把它在一个区域的输入空间里训练得很好但邻近区域稍微偏移一点输出就可能剧烈震荡。在普通训练里我们几乎不会去扫描那些“看不见的隔壁区域”所以模型对它们的表现就完全不可控。如果打个比方泛化好的人是在熟悉环境里能妥善处理各种日常情况鲁棒性好的人是在有人往路上泼水、扔烟雾弹的时候也能稳稳地走到目的地。对抗训练的本质是把这种极端情况引入到训练过程中让模型在训练时就见过“烟雾弹”这样它在真实环境中遇到类似场景时才不会有太大反应。1.3 对抗训练本质是在做一个有边界的博弈对抗训练的核心思路其实是把训练过程变成一场有限范围内的攻防博弈。模型想尽办法降低损失而对抗样本则在每一步都试图让损失最大化只不过这种攻击不是无限度的而是在一个约束范围内活动。这个约束通常用了一个 epsilon 参数来控制意思是最多允许每个像素改变多少。你允许扰动越大训练出的模型抗干扰能力越强但付出的代价往往是正常样本上的精度下降。用对抗训练的思路来思考问题会发现它在每一个训练批次上都额外多了一步生成对抗样本再拿对抗样本更新模型参数。这听起来简单但实际操作里每一步选择都会影响最终效果比如攻击用几步、每步走多大、随机初始化还是固定初始化、哪些层要冻结、什么时候切换训练策略。这些细节每一个单拎出来都不是难事组合在一起就是一个非常值得钻研的优化问题。这也是为什么我建议想搞懂对抗训练的人最好从自己动手写攻击函数开始而不是直接调用现成的算法库。2. 对抗样本在哪来几种生成方式的取舍2.1 FGSM 快速但粗糙对抗训练里最常提到的起手式是 FGSM全称是 Fast Gradient Sign Method。它的思路很直接在梯度方向上给输入加上一个固定幅度的扰动。假设损失函数对输入 x 的梯度是 g那对抗样本就是 x 加上 epsilon 倍的 sign(g)。因为只要符号不需要精细的幅值所以计算非常快。很多新手都从它开始因为它和普通反向传播几乎一模一样只是把梯度的服务对象从模型参数换成了输入像素。但 FGSM 有一个很明显的短板它只走一步而且这一步是对“线性化损失”的近似。深度模型远不是线性的所以 FGSM 生成的对抗样本在很多时候只是刚好在决策边界附近扰动不算特别强。用它做的对抗训练能带来一点鲁棒性但通常留不住太多。作为教学很好作为生产的核心手段有点不够看。2.2 PGD 多步迭代才是实战主力比 FGSM 更强、也更常用的方法是 PGD投影梯度下降。其实命名里带着“下降”两个字但在对抗生成里它是在最大化损失所以本质是一个有约束的上升过程。基本流程是从原始图片出发或者从带随机扰动的版本出发反复沿着梯度符号方向前进一小步每走完一步就把结果拉回 epsilon 约束的球内。这种投影保证了对抗样本不会跑出允许的范围。实践中PGD 的迭代次数和步长 alpha 是一对互相配合的参数。比如总扰动上限是 8/255你走 10 步那每一步 alpha 可以设为 2/255如果走 20 步alpha 可以更小一点。步数太小时攻击不够充分步数太大时训练变慢但攻击强度却会收敛不会无限制增强。工程上通常用 7 到 10 步作为训练时的标准配置评估时可以增加步数来测试模型的上限。2.3 从单样本到多样本随机初始化和多噪声PGD 也不是没有局限。多次实验之后你会发现固定从原始点出发的攻击容易被模型“记住”。换句话说模型在训练中反复见过同一类扰动模式它就会只对那种特定模式免疫换个扰动分布又会掉链子。比较常见的补救方法是从一个随机扰动开始迭代让每次生成的对抗样本都有一定程度的多样性。随机初始化让攻击在不同批次探索到不同的路径模型见过更多变体之后鲁棒性会更平滑。更有发散性的做法是“重叠扰动”也就是每个样本在一次训练里生成多个不同强度的对抗版本然后一起放进损失函数。这种方式对计算量有些要求但确实能明显改善干净样本上的精度保持能力。不过实际体验下来我没有一开始就用太复杂的方案而是从随机初始化的 PGD 起步先说清楚基线的效果再在它上面叠加其他优化。2.4 到底选哪一种选择哪种攻击方式取决于你的目标。如果只是想快速验证代码流程通不通用 FGSM 就够如果想让模型在面对未知攻击时也稳一些那至少要从 PGD 开始。我个人很建议在项目里把攻击强度做成可配置项这样以后调整实验时不用改代码结构。下面我会拿 PGD 作为主线来讲因为它在真实场景中更有代表性参数也更有文章可做。3. 实操用 PyTorch 把对抗训练跑起来3.1 环境准备与实验基线我这次的项目没有直接在某个现成数据集上做套用而是先用了一个自己手头整理的图像分类任务有几十个类别图片大小统一缩放到 32x32 左右方便快速实验。你可以直接换用自己的数据甚至先在玩具数据上验证逻辑再去跑大规模实验。环境方面只需要 PyTorch 加 torchvision如果有 GPU 会舒服很多不过我下面的代码在 CPU 上也能跑只是速度感人。在写对抗训练之前先把普通训练的基线跑出来。我用的是一个很常见的 CNN 结构几层卷积加全连接标准的交叉熵损失配合随机裁剪和水平翻转做数据增强。普通训练之后我用同样的测试数据做了两个指标干净样本准确率和 PGD 攻击之后的对抗准确率。这两组数字就是后面所有优化的对照组。当时对照组的干净准确率大概在 92% 左右但 PGD 攻击后直接掉到 2% 多一点这个结果反而是好消息说明攻击实现没有问题也说明模型的鲁棒性确实很脆弱。3.2 核心代码PGD 攻击函数下面这段是 PGD 生成函数我在实际项目中反复用也建议你直接收藏起来。输入的 model 是训练中的模型x 是原始图片y 是标签eps 是扰动上限alpha 是步长iters 是迭代次数。核心是把梯度清零之后对输入计算梯度并更新扰动再 clamp 回合法范围。def pgd_attack(model, x, y, eps8/255, alpha2/255, iters10): 生成 PGD 对抗样本。 返回与 x 形状相同的对抗样本且保证在 eps 约束内。 x_adv x.clone().detach().requires_grad_(True) for _ in range(iters): loss F.cross_entropy(model(x_adv), y) model.zero_grad() loss.backward() # 沿梯度符号方向最大化损失 grad x_adv.grad.data x_adv x_adv alpha * grad.sign() # 把扰动约束在 [-eps, eps] 内同时保证像素值合法 delta torch.clamp(x_adv - x, min-eps, maxeps) x_adv torch.clamp(x delta, min0, max1).detach() x_adv.requires_grad_(True) return x_adv这里面有两点要特别说明。第一每次更新之后都要强制 detach 再重新开启 requires_grad否则梯度图会越积越大显存直接被吃掉。第二clamp 的顺序是先约束扰动幅度再约束像素范围如果只做像素范围的 clamp扰动限制会失效。实战里我发现很多人写反了导致生成的对抗样本超出预期强度但还没报错直到做消融实验时才发现数字对不上。另一个常见的陷阱是 batch norm 层的行为。训练模式下batch norm 会使用当前 batch 的统计量。PGD 多步迭代时同一个 batch 的数据被反复前向传递如果每一步都在更新 batch norm 的统计量对抗样本的生成质量会被拉低。稳妥的做法是生成攻击样本时先切换到 eval 模式或者干脆在训练里用 batch size 大一点的设置在攻击前后保持一致。不过 eval 模式会影响 drop-out如果你的模型里有 dropout还得额外注意。3.3 对抗训练主循环攻击函数准备好之后对抗训练的主循环和普通训练相比只多了一个分支。我采用的是混合训练策略每个 batch 里一部分用干净样本一部分用对抗样本而不是全部替换成对抗样本。这个比例我默认取 0.5也就是一半干净一半对抗。这样做的好处是让模型同时兼顾正常样本的表达能力和对抗样本的抵抗能力不会因为训练目标全部变成扭曲输入而丢失太多干净精度。def train_epoch(model, loader, optimizer, device, eps8/255, alpha2/255, iters7, adv_ratio0.5): model.train() total_loss 0.0 total_correct 0 total_samples 0 for x, y in loader: x, y x.to(device), y.to(device) optimizer.zero_grad() # 生成对抗样本 x_adv pgd_attack(model, x, y, epseps, alphaalpha, itersiters) # 混合干净样本和对抗样本 batch_size x.size(0) split int(batch_size * adv_ratio) x_mixed torch.cat([x[:split], x_adv[split:]], dim0) y_mixed torch.cat([y[:split], y[split:]], dim0) # 预测与损失 out model(x_mixed) loss F.cross_entropy(out, y_mixed) loss.backward() optimizer.step() # 统计 with torch.no_grad(): pred out.argmax(dim1) total_correct (pred y_mixed).sum().item() total_samples batch_size total_loss loss.item() * batch_size return total_loss / total_samples, total_correct / total_samples在实际跑的时候我发现 adv_ratio 的取值对结果影响非常大。取 1.0 也就是全用对抗样本时干净精度往往掉得比较狠取 0.25 时对抗精度又不太够看。0.5 是一个比较省心的起点后续可以慢慢往中间调。另外一个对训练稳定性有帮助的细节是学习率对抗训练的损失面相对更陡学习率过大会导致损失震荡甚至直接发散。我通常把普通训练学习率的下调一半到三分之一作为起点然后再配合余弦退火或者步长衰减。3.4 评估指标别只盯着一个数字评估鲁棒性单一指标很容易掩盖问题。我每次实验都会记录三组数字干净样本准确率、PGD 攻击后的准确率、以及在不同 eps 和不同步数下的攻击成功率曲线。后面这个曲线我会直接画出来观察它能告诉我模型在哪个强度范围内开始崩溃。这里给出一个简单的评估函数torch.no_grad() def evaluate(model, loader, device, eps8/255, alpha2/255, iters10): model.eval() clean_correct 0 adv_correct 0 total 0 for x, y in loader: x, y x.to(device), y.to(device) out_clean model(x) clean_correct (out_clean.argmax(1) y).sum().item() adv pgd_attack(model, x, y, epseps, alphaalpha, itersiters) out_adv model(adv) adv_correct (out_adv.argmax(1) y).sum().item() total y.size(0) return clean_correct / total, adv_correct / total其中有一个细节我忍了很久才明白评估时如果模型处于训练模式结果会非常不稳定。必须在评估函数里显式调用 model.eval()防止 batch norm 继续使用 batch 统计量。另外评估用的步数最好大于训练时的步数训练用 7 步评估就能用到 20 步甚至更多。这样才能看出模型的鲁棒性是不是只对训练时见过的迭代次数有效而不是真正防御了对抗样本。3.5 对抗训练后的效果记录我用上面的流程跑过完整的一轮实验记录大概是这样普通训练的干净准确率 92%PGD-10 攻击后只有 2% 左右。经过 30 个 epoch 的 PGD 对抗训练后干净准确率降到 88%但 PGD-10 攻击后的准确率提升到了 65% 上下。如果用 PGD-20 去攻击对抗准确率大概还在 58% 左右。这说明模型不是单纯死记了 10 步的攻击模式而是学到了更本质的平滑决策边界所以面对更强攻击时也有一定的抵抗能力。如果和只用 FGSM 训练的模型相比PGD 训练的模型在对抗精度上多出了大约 20 个百分点。这个差距用一句话来解释FGSM 只是教的模型躲开某个特定方向上的攻击而 PGD 等于让模型在一个更宽阔的扰动范围里反复锻炼避免它针对某一条路径进行了过拟合。4. 调参中的坑与我的排查记录4.1 对抗精度上去了干净精度崩了这是我第一次跑对抗训练时遇到的最大问题。训练了很久对抗精度终于超过了 50%但干净精度掉了许多从 92% 掉到了 80% 以下。这个现象并不意外因为对抗样本本身就偏离自然数据分布模型如果一直在偏离分布的数据上学习它对自然样本的预测能力必然会受损。解决办法可以从几个方向尝试。第一降低混合训练中对抗样本的比例把 adv_ratio 从 0.5 调低到 0.3 或 0.4。第二降低 epsilon对抗强度不是越大越好尤其对图像分辨率较低的任务来说8/255 可能都偏大可以尝试 4/255。第三在损失函数里加一项干净样本的损失例如总损失等于对抗损失加上 lambda 倍的干净损失lambda 保持在一个较小的值让模型不忘本。实际体验下来混合训练本身就带有这种效果把比例调小往往比调损失权重更直观。4.2 训练过程不收敛损失曲线反复横跳另一个让人头疼的情况是损失曲线在前几步快速下降之后就开始震荡甚至出现 loss 上涨。这个问题的根源很大程度上出在对抗样本的生成质量和收敛难度上。对抗训练在生成对抗样本这一步本身就非常消耗梯度信息如果攻击步数过多、学习率又偏大就会导致模型陷入“永远在追赶一个漂移的目标”的困境。我的处理方案是给对抗强度做一个 warmup。比如前 5 个 epoch 只做普通训练之后 5 个 epoch 把 eps 从 2/255 逐渐升到 8/255同时学习率也走 warmup。这个方法看起来有点土但效果立竿见影。对抗本质上是训练一个更难的问题让模型在简单的任务上先站稳再逐步加大难度最后的收敛速度反而更快。4.3 显存爆炸和训练速度太慢对抗训练最影响工程体验的就是训练时间直接翻了数倍。普通训练一个 batch 只需要一次前向和一次反向PGD 对抗训练则要在每个 batch 里额外做多步前向反向。如果迭代次数是 10那一个 batch 的计算量相当于普通的 10 倍左右。很多人一上来就遇到显存爆掉原因往往是在攻击函数里没有 detach导致每一步的梯度图都被保留了下来。要让显存可控我需要强调刚才代码里的一个细节更新完输入后立刻 clamp 并 detach然后重新开启 requires_grad。这样每一步的梯度图都会在更新后被释放不会越积越大。如果显存还是很紧可以减少 PGD 步数到 3 到 5 步或者把 batch size 调小一点。另外还有一个小技巧是每间隔几个 epoch 才做完整 PGD其他 epoch 用更弱的攻击甚至只做普通训练这样可以在不损失太多鲁棒性的情况下把训练时间压下来。4.4 常见问题速查表症状可能原因排查与解决干净精度大幅下降对抗样本占比过高或 epsilon 过大降低 adv_ratio使用 epsilon warmup对抗精度一直很低PGD 步数太少或 alpha 太大增加迭代次数调小 alpha检查梯度是否被 clamp 破坏训练 loss 震荡不收敛学习率过大、攻击强度不稳定缩小学习率做 epsilon warmup显存爆炸攻击梯度图未释放检查是否在每个迭代步后 detach 并重新 requires_grad评估时结果波动没有切换 eval 模式评估函数开头显式调用 model.eval()鲁棒性只对训练步数有效攻击步数过少模型过拟合特定路径评估时用更多步数随机初始化攻击起点5. 优化还能往哪走几个发散方向5.1 把 epsilon 从固定值变成动态值固定 epsilon 的对抗训练有一个隐含假设所有样本允许被扰动的幅度都一样。但真实场景中有的样本本身就更靠近决策边界稍微动一下就出错有的样本则很稳即使扰动量很大也没反应。如果对所有样本都用同一个扰动上限容易导致训练过度关注那些容易出错的样本或者浪费精力在根本不需要防御的样本上。所以我想了一个改进思路根据每个样本的当前损失情况动态调整该样本的攻击强度。损失较大的样本说明它离决策边界比较近就给它一个较小的扰动上限损失较小、离边界远的样本可以适当放大扰动上限。这种做法和课程学习有点像都是让模型由易到难地适应。写起来不复杂就是在攻击函数里对每个样本乘上一个归一化权重我测试之后发现在保持同等对抗精度的前提下干净精度比固定 epsilon 的方式高了两到三个百分点。5.2 多头集成与平滑约束对抗训练的另一种发散方向是把“多个视角”引入训练过程。比如把同一张图片的多个裁剪版本、多个色彩抖动版本、多个对抗强度版本放到同一个 batch 里让模型同时见过自然扰动和对抗扰动。这样模型不会只围绕攻击和防御的二元对抗来做优化而是在一个更广阔的数据分布上变得更稳重。这类做法本质上是在提升决策边界的平滑度。我还试过在损失函数中增加一个正则项约束模型对相邻输入的输出不要变化太大。这个方向和对抗训练可以叠加使用效果上接近把模型变成一个“慢函数”对噪声的敏感度自然降低。不过这类正则项的计算量和调参难度都不低我建议先把基础的 PGD 对抗训练跑通再往这个方向探索。5.3 对自己的启发鲁棒性是一种设计思想做完这轮实验我有一个很深的体会对抗训练不只是给模型打补丁它更像是一种从训练目标上重新设计模型的方式。它迫使你思考模型在哪一类输入上会失败以及你允许它承受多大程度的干扰。这些思考在普通训练里是缺失的。工业部署中一个模型不仅要准确还要可靠不仅要处理常规情况还要在下游环境稍微变化时不会突然失灵。所以即便你手头的任务暂时没有明显的恶意干扰场景我也建议至少把对抗训练当成一种模型质量的体检手段。不一定要上线对抗训练后的版本但可以先跑一下 PGD 攻击看看模型的底线在哪里。很多结构上看起来很先进的模型在这个体检中分数并不高这个分数能帮你判断模型是否真的适合部署。相比上线之后出问题再修补训练阶段多花一点时间成性价比要高得多。最后再分享一个小技巧如果你在对抗训练里发现最终模型的输出平滑度不够可以在训练结束之后额外做几个 epoch 的“净化训练”也就是只用干净数据以很小的学习率继续微调模型。这个过程往往能在几乎不损失对抗精度的前提下把干净精度往回拉几个点。我试过几次效果都比我预想要好。反正核心原则就是别把全部赌注押在单一的对抗目标上训练过程的余量和回旋空间都要给模型留出来。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑