做深度学习的人都知道,模型在测试集上的漂亮分数,到了真实环境里往往要打一点折扣。更扎心的是,有些模型面对的不是普通的天气变化、光线变化,而是有人故意给图片加上一点点人眼几乎看不出来的噪声,整个模型就原地崩溃,准确率从百分之九十多直接掉到百分之几。这类噪声有一个专门的称呼——对抗样本,而让模型在它们面前依然能稳住的能力,就是鲁棒性。我这次想聊的,就是基于 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, eps=8/255, alpha=2/255, iters=10): """ 生成 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, max=eps) x_adv = torch.clamp(x + delta, min=0, max=1).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, eps=8/255, alpha=2/255, iters=7, adv_ratio=0.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, eps=eps, alpha=alpha, iters=iters) # 混合干净样本和对抗样本 batch_size = x.size(0) split = int(batch_size * adv_ratio) x_mixed = torch.cat([x[:split], x_adv[split:]], dim=0) y_mixed = torch.cat([y[:split], y[split:]], dim=0) # 预测与损失 out = model(x_mixed) loss = F.cross_entropy(out, y_mixed) loss.backward() optimizer.step() # 统计 with torch.no_grad(): pred = out.argmax(dim=1) 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, eps=8/255, alpha=2/255, iters=10): 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, eps=eps, alpha=alpha, iters=iters) 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 的“净化训练”,也就是只用干净数据以很小的学习率继续微调模型。这个过程往往能在几乎不损失对抗精度的前提下,把干净精度往回拉几个点。我试过几次,效果都比我预想要好。反正核心原则就是,别把全部赌注押在单一的对抗目标上,训练过程的余量和回旋空间,都要给模型留出来。