AI对抗训练:攻击不够用了之后
上周在做一个图像分类项目时,模型在测试集上准确率 98.2%,看着很稳,结果被一个实习生随手加了点肉眼看不出来的"噪声"后,分类直接错得一塌糊涂。
后来加到 0.01,肉眼还是看不出什么变化,但模型已经把猫认成烤面包机了。
先看一个问题
为什么一个看起来明明很好的模型会被那么一点扰动搞崩?
简单说,模型在训练时见过的"世界"太干净了。如果数据集里的猫都是正脸、光线合适、角度正常,模型记住的是"这类条件下像猫",未必真懂猫长什么样。
对抗攻击的思路就是在保持人类感官不变的前提下,找出那些对模型判断影响最大的扰动方向。这就像给模型喂一点特制的"致幻剂",让它把看到的东西理解成完全不同的东西。
从 FGSM 开始
先拿最简单也最容易理解的 FGSM(Fast Gradient Sign Method)开刀。
原理很直白:计算损失函数对输入的梯度,然后沿着梯度方向加一点扰动。相当于问模型"你往哪边最容易出错",然后就把数据往那个方向推一推。
import torch
import torch.nn as nn
import torchvision.models as models
from torchvision import transforms
from PIL import Image
# 用一个预训练的 ResNet50 当小白鼠
model = models.resnet50(pretrained=True)
model.eval()
# 图像预处理( imagenet 标准化)
preprocess = transforms.Compose([
transforms.Resize(256),
transforms.CenterCrop(224),
transforms.ToTensor(),
transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
])
def fgsm_attack(image, epsilon, data_grad):
# 梯度符号方向
sign_data_grad = data_grad.sign()
# 生成对抗样本
perturbed_image = image + epsilon * sign_data_grad
# 裁剪到合理范围
perturbed_image = torch.clamp(perturbed_image, 0, 1)
return perturbed_image
def generate_adversarial_example(model, image, target_class, epsilon=0.007):
image = preprocess(image).unsqueeze(0)
image.requires_grad = True
# 前向传播
output = model(image)
loss = nn.CrossEntropyLoss()(output, torch.tensor([target_class]))
# 反向传播
model.zero_grad()
loss.backward()
data_grad = image.grad.data
# 生成对抗样本
perturbed_image = fgsm_attack(image, epsilon, data_grad)
return perturbed_image
# 加载一张图片
image = Image.open('/home/liqinsi/data/images/cat.jpg')
target_class = 281 # ImageNet 的 tabby cat 类别
adv_image = generate_adversarial_example(model, image, target_class, epsilon=0.01)
第一次跑的时候,epsilon 设得太小(0.001),折腾半天发现扰动基本没啥效果,模型照样能认出是猫。后来加到 0.01,肉眼还是看不出什么变化,但模型已经把猫认成烤面包机了。
这事儿挺讽刺的——人类看不见的噪声,模型却"看得一清二楚",而且还是错误的那一面。
踩坑记录
复现过程中遇到几个坑,写出来避免别人也踩进去。
第一个坑:忘记设置 model.eval()。
一开始直接拿训练模式的模型搞,结果发现 dropout、batchnorm 都在工作,生成的对抗样本质量极不稳定。对预训练模型一定记得切到 eval 模式。
第二个坑:梯度计算顺序搞反了。
有次顺手写了 image = image + epsilon * data_grad.sign(),结果跑出来全是 NaN。一检查才发现,Tensor 操作应该先 detach 再计算,否则计算图会出问题。正确的做法是使用 data_grad 而不是直接操作 image.grad。
第三个坑:预处理和反预处理不匹配。
我原本用的是 ImageNet 的标准化参数,但在保存和可视化对抗样本时忘记了反标准化。结果保存出来的图片要么全白要么全黑,折腾了好久才发现是数据范围问题。可视化时记得做反向变换:
def denormalize(tensor, mean, std):
mean = torch.tensor(mean).view(3, 1, 1)
std = torch.tensor(std).view(3, 1, 1)
return tensor * std + mean
# 保存时反标准化
adv_image_denorm = denormalize(adv_image.squeeze(), [0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
torchvision.utils.save_image(adv_image_denorm, 'adversarial_example.png')
PGD:多步攻击
FGSM 只走一步,虽然快但不够狠。PGD(Projected Gradient Descent)相当于在 FGSM 的基础上多走几步,每次都把扰动投影回 ε-ball 范围内,这样能找到更"致命"的扰动。
def pgd_attack(model, image, target_class, epsilon=0.01, alpha=0.003, num_iter=10):
image = image.unsqueeze(0)
original_image = image.clone().detach()
# 随机初始化扰动
perturbation = torch.zeros_like(image).uniform_(-epsilon, epsilon)
perturbed_image = torch.clamp(image + perturbation, 0, 1)
for i in range(num_iter):
perturbed_image.requires_grad = True
output = model(perturbed_image)
loss = nn.CrossEntropyLoss()(output, torch.tensor([target_class]))
model.zero_grad()
loss.backward()
data_grad = perturbed_image.grad.data
# 梯度上升
perturbed_image = perturbed_image + alpha * data_grad.sign()
# 投影到 epsilon 范围
perturbation = torch.clamp(perturbed_image - original_image, -epsilon, epsilon)
perturbed_image = torch.clamp(original_image + perturbation, 0, 1)
return perturbed_image
PGD 的参数选择有点讲究:
epsilon:最大扰动量,太大了图片质量肉眼可见下降,太小了攻击力度不够。一般 0.01-0.03 之间alpha:每步移动的距离,通常设为 epsilon 的 1/3 到 1/2num_iter:迭代次数,10-20 步通常够用,再多了边际收益递减
我试过用 50 步去攻击,效果确实比 10 步好一点,但训练时间也长了 5 倍。不是所有场景都需要这么狠的攻击。
防御思路
了解了怎么攻击,自然会想怎么防御。
理论上最直接的思路就是让模型在训练时见过各种攻击,这样它就不会轻易被新的攻击骗到。这就是对抗训练的核心思想。
但一开始我有个误判:以为对抗训练就是"每一步都生成对抗样本然后训练"。结果跑了两天发现模型训练不收敛,准确率一直在 50% 上下徘徊。
后来才明白,对抗训练不是要把模型训练成"对任何扰动都无感",而是要提升它对"自然扰动"和"恶意扰动"的区分能力。过度的对抗训练反而会让模型过拟合到攻击方式上。
实际落地对抗训练
这是我后来用的比较实用的训练方案,基于 TRADES(TRadeoff between Accuracy and Robustness)的思路,在标准准确率和鲁棒性之间做平衡。
import torch.optim as optim
from torch.utils.data import DataLoader
from torchvision import datasets
# 定义一个简单的分类器
class SimpleClassifier(nn.Module):
def __init__(self, num_classes=10):
super().__init__()
self.features = nn.Sequential(
nn.Conv2d(1, 32, 3, padding=1),
nn.ReLU(),
nn.MaxPool2d(2),
nn.Conv2d(32, 64, 3, padding=1),
nn.ReLU(),
nn.MaxPool2d(2),
)
self.classifier = nn.Sequential(
nn.Linear(64 * 7 * 7, 128),
nn.ReLU(),
nn.Dropout(0.5),
nn.Linear(128, num_classes)
)
def forward(self, x):
x = self.features(x)
x = x.view(x.size(0), -1)
x = self.classifier(x)
return x
model = SimpleClassifier(num_classes=10)
optimizer = optim.Adam(model.parameters(), lr=0.001)
criterion = nn.CrossEntropyLoss()
# MNIST 数据集
train_dataset = datasets.MNIST('/home/liqinsi/data/mnist', train=True, download=True, transform=transforms.ToTensor())
train_loader = DataLoader(train_dataset, batch_size=128, shuffle=True)
def generate_pgd_batch(model, images, labels, epsilon=0.1, alpha=0.01, num_iter=7):
perturbed_images = images.clone()
perturbed_images.requires_grad = True
for _ in range(num_iter):
outputs = model(perturbed_images)
loss = criterion(outputs, labels)
optimizer.zero_grad()
loss.backward()
data_grad = perturbed_images.grad.data
perturbed_images = perturbed_images + alpha * data_grad.sign()
# 投影到 epsilon 范围
delta = torch.clamp(perturbed_images - images, -epsilon, epsilon)
perturbed_images = torch.clamp(images + delta, 0, 1)
perturbed_images = perturbed_images.detach()
perturbed_images.requires_grad = True
return perturbed_images
# 对抗训练循环
for epoch in range(20):
model.train()
for batch_idx, (data, target) in enumerate(train_loader):
# 标准训练
optimizer.zero_grad()
output = model(data)
loss_standard = criterion(output, target)
# 对抗训练
adv_data = generate_pgd_batch(model, data, target, epsilon=0.1, alpha=0.01, num_iter=7)
output_adv = model(adv_data)
loss_adv = criterion(output_adv, target)
# 混合损失(这里可以调权重)
loss = loss_standard + 0.5 * loss_adv
loss.backward()
optimizer.step()
print(f'Epoch {epoch}: Standard Loss: {loss_standard.item():.4f}, Adversarial Loss: {loss_adv.item():.4f}')
这个方案有几个关键点:
- 混合损失:标准 loss 和对抗 loss 一起算,权重按场景调
- 对抗生成参数:训练时的 epsilon 通常比测试时小一点,避免模型过拟合到极端攻击上
- 迭代次数:7 步左右是个经验值,再多了训练太慢,再少了对抗性不够
实际效果对比
在 MNIST 上做了个简单的对比实验:
| 训练方式 | 标准确率 | FGSM 攻击准确率 | PGD 攻击准确率 |
|---|---|---|---|
| 标准训练 | 98.5% | 45.2% | 32.1% |
| 对抗训练 | 97.8% | 88.3% | 82.7% |
可以看到,标准训练的模型在攻击面前基本废了,而对抗训练的模型虽然标准准确率略降一点点,但在攻击场景下表现明显好很多。
结果也符合预期:训练任务变难了,标准准确率略降,攻击场景下的准确率升上来。
还没解决的问题
折腾了一圈,有些问题还没想清楚,或者说没找到特别好的解决方案。
问题一:对抗训练的计算成本太高。
每一步训练都要生成对抗样本,相当于训练时间翻倍。在大模型场景下,这个成本可能很难接受。有一些研究在做"免梯度"的对抗样本生成或者"快速估计",但效果都一般。
问题二:防御和攻击的军备竞赛。
我对模型做了 PGD 防御,结果有人换了个 CW(Carlini & Wagner)攻击,防御效果又不行了。理论上说,如果攻击方法足够强,防御就得不停地跟在后面跑。这不是一个稳定的状态。
问题三:评估指标不好统一。
现在大多还是用对抗准确率来衡量鲁棒性,但不同的攻击方法、不同的参数设置,结果可能差很多。有些论文故意用很弱的攻击方法来吹自己的防御效果,这种现象让人挺无奈的。
一些不那么成熟的判断
说几点不一定对的判断,仅供参考。
对抗训练不是万能药,它解决的是"特定攻击方式下的鲁棒性",不是"所有未预见攻击的鲁棒性"。
在实际项目中,如果安全要求不是特别高,可能没必要上全套对抗训练。做好数据增强、模型正则化,效果可能比想象的要好。
防御的重点应该在"降低攻击收益"而不是"完全阻止攻击"。如果攻击需要极高的成本或专业知识,实际上已经达到防御目的了。
对抗训练目前更像是一个研究方向,而不是工程实践。有些理论上的东西(比如证明模型在某些扰动下是鲁棒的)距离落地还有距离。
写在最后
MNIST 上对抗训练把 FGSM/PGD 攻击准确率从 30–45% 拉到 80%+,标准准确率只掉不到 1 个点。代价是训练时间大约翻倍,大模型上这个成本更难扛。
PGD 防御挡不住 CW 这类换招攻击,评估指标也容易被弱攻击方法灌水。安全要求不高的话,数据增强加正则化往往够用。系统侧怎么在模型判错时降级,下次再单独写。
版权声明: 本文首发于 指尖魔法屋-AI对抗训练:攻击不够用了之后(https://blog.thinkmoon.cn/post/231-adversarial-training-attack-defense-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!
评论
使用 GitHub 账号登录后即可留言,支持 Markdown。