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/2
  • num_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}')

这个方案有几个关键点:

  1. 混合损失:标准 loss 和对抗 loss 一起算,权重按场景调
  2. 对抗生成参数:训练时的 epsilon 通常比测试时小一点,避免模型过拟合到极端攻击上
  3. 迭代次数:7 步左右是个经验值,再多了训练太慢,再少了对抗性不够

实际效果对比

在 MNIST 上做了个简单的对比实验:

训练方式标准确率FGSM 攻击准确率PGD 攻击准确率
标准训练98.5%45.2%32.1%
对抗训练97.8%88.3%82.7%

可以看到,标准训练的模型在攻击面前基本废了,而对抗训练的模型虽然标准准确率略降一点点,但在攻击场景下表现明显好很多。

结果也符合预期:训练任务变难了,标准准确率略降,攻击场景下的准确率升上来。

还没解决的问题

折腾了一圈,有些问题还没想清楚,或者说没找到特别好的解决方案。

问题一:对抗训练的计算成本太高。

每一步训练都要生成对抗样本,相当于训练时间翻倍。在大模型场景下,这个成本可能很难接受。有一些研究在做"免梯度"的对抗样本生成或者"快速估计",但效果都一般。

问题二:防御和攻击的军备竞赛。

我对模型做了 PGD 防御,结果有人换了个 CW(Carlini & Wagner)攻击,防御效果又不行了。理论上说,如果攻击方法足够强,防御就得不停地跟在后面跑。这不是一个稳定的状态。

问题三:评估指标不好统一。

现在大多还是用对抗准确率来衡量鲁棒性,但不同的攻击方法、不同的参数设置,结果可能差很多。有些论文故意用很弱的攻击方法来吹自己的防御效果,这种现象让人挺无奈的。

一些不那么成熟的判断

说几点不一定对的判断,仅供参考。

  1. 对抗训练不是万能药,它解决的是"特定攻击方式下的鲁棒性",不是"所有未预见攻击的鲁棒性"。

  2. 在实际项目中,如果安全要求不是特别高,可能没必要上全套对抗训练。做好数据增强、模型正则化,效果可能比想象的要好。

  3. 防御的重点应该在"降低攻击收益"而不是"完全阻止攻击"。如果攻击需要极高的成本或专业知识,实际上已经达到防御目的了。

  4. 对抗训练目前更像是一个研究方向,而不是工程实践。有些理论上的东西(比如证明模型在某些扰动下是鲁棒的)距离落地还有距离。

写在最后

MNIST 上对抗训练把 FGSM/PGD 攻击准确率从 30–45% 拉到 80%+,标准准确率只掉不到 1 个点。代价是训练时间大约翻倍,大模型上这个成本更难扛。

PGD 防御挡不住 CW 这类换招攻击,评估指标也容易被弱攻击方法灌水。安全要求不高的话,数据增强加正则化往往够用。系统侧怎么在模型判错时降级,下次再单独写。

版权声明: 本文首发于 指尖魔法屋-AI对抗训练:攻击不够用了之后https://blog.thinkmoon.cn/post/231-adversarial-training-attack-defense-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!