学习了Pytorch官方文档的教程,并且进行了扩展补充与思考

这段代码完整展示了一个经典流程:

MNIST 原始图片
LeNet 正常分类
计算 loss 对输入图片的梯度
按照梯度方向修改图片像素
得到对抗样本
模型重新分类
观察模型是否被误导

一、这段代码到底在做什么?

整体目标是:

测试一个已经训练好的 LeNet 模型,在不同强度的 FGSM 攻击下,对 MNIST 手写数字的分类准确率会下降多少。

代码中有一个攻击强度列表:

epsilons = [0, .05, .1, .15, .2, .25, .3]

每个 epsilon 表示允许给图片加多大的扰动。

当:

epsilon = 0

表示不攻击。

当:

epsilon = 0.3

表示攻击较强,图像像素会被明显改变,模型更容易误判。

最终代码会输出类似:

Epsilon: 0     Test Accuracy = ...
Epsilon: 0.05  Test Accuracy = ...
Epsilon: 0.1   Test Accuracy = ...
...

通常结果会表现为:

epsilon 越大,模型准确率越低

这说明模型对某些特定方向的扰动非常敏感。


二、核心问题:图片本身发生变化了吗?

答案是:

发生了变化,但不是修改原始数据集,而是在内存中生成了一张新的对抗图片。

原图是:

data

反归一化后是:

data_denorm

攻击后生成的新图是:

perturbed_data

核心代码:

perturbed_image = image + epsilon * sign_data_grad

这一步会改变图片像素值。

比如某个像素原来是:

0.40

梯度符号是:

+1

如果:

epsilon = 0.1

那么攻击后这个像素变成:

0.40 + 0.1 = 0.50

如果梯度符号是:

-1

则变成:

0.40 - 0.1 = 0.30

所以图片确实变化了。

但是原始 MNIST 文件没有被覆盖,因为代码没有保存回硬盘。

可以理解为:

原始图片文件:没变
内存中的对抗样本:变了
模型看到的输入:变了

三、FGSM 的核心思想

FGSM 全称是:

Fast Gradient Sign Method

中文可以理解为:

快速梯度符号攻击

它的核心公式是:

$$ x_{adv}=clip(x+\epsilon \cdot sign(\nabla_x J(\theta,x,y)),0,1) $$

逐个解释:

符号含义
(x)原始图片
(x_{adv})对抗样本图片
(\epsilon)扰动强度
(J(\theta,x,y))模型在输入 (x)、标签 (y) 上的 loss
(\nabla_x J)loss 对输入图片的梯度
(sign(\cdot))取梯度符号,只保留方向
clip把像素限制在合法范围 [0,1]

这句话的直觉是:

找到每个像素往哪个方向变化最容易让模型 loss 变大,然后把所有像素都朝这个方向轻轻推一下。


四、为什么不是随机加噪声?

这一点非常重要。

FGSM 不是随机噪声。

随机噪声可能是:

image + random_noise

但是 FGSM 是:

image + epsilon * sign(gradient)

它利用了模型自己的梯度信息。

也就是说,FGSM 知道:

哪些像素应该变亮
哪些像素应该变暗
怎样改最容易让模型犯错

所以 FGSM 是一种有方向、有目的的攻击。

这就是为什么有时候人眼看起来变化很小,但模型已经错了。


五、从数学上理解 FGSM 为什么成立

模型的 loss 是:

$$ J(\theta,x,y) $$

其中:

  • (\theta):模型参数;
  • (x):输入图片;
  • (y):真实标签。

攻击者的目标是:

在尽量不明显改变图片的情况下,让 loss 变大

也就是:

$$ \max_{\delta} J(\theta, x+\delta, y) $$

同时限制扰动不能太大:

$$ ||\delta||_{\infty} \leq \epsilon $$

其中 (L_\infty) 约束表示:

每一个像素最多只能改变 (\epsilon)。

为了简化问题,对 loss 做一阶泰勒展开:

$$ J(x+\delta) \approx J(x) + \delta^T \nabla_x J(x) $$

其中 (J(x)) 是固定的,所以要最大化:

$$ \delta^T \nabla_x J(x) $$

在约束:

$$ ||\delta||_{\infty} \leq \epsilon $$

下,最优选择是:

$$ \delta = \epsilon \cdot sign(\nabla_x J(x)) $$

所以得到 FGSM:

$$ x_{adv}=x+\epsilon \cdot sign(\nabla_x J(x)) $$

代码中对应:

sign_data_grad = data_grad.sign()
perturbed_image = image + epsilon * sign_data_grad

六、为什么要用 sign,而不是直接用梯度?

代码中:

sign_data_grad = data_grad.sign()

这里没有直接用:

image + epsilon * data_grad

而是用了:

image + epsilon * data_grad.sign()

原因是 FGSM 使用的是 (L_\infty) 范数约束。

也就是说,每个像素最多改变 (\epsilon)。

如果直接用原始梯度,某些像素可能变化很大,某些像素变化很小,不容易保证统一的最大扰动限制。

而用 sign() 之后,每个像素的扰动只可能是:

+epsilon
-epsilon
0

也就是说:

梯度为正:像素加 epsilon
梯度为负:像素减 epsilon
梯度为零:像素不变

这样正好满足:

$$ ||\delta||_\infty \leq \epsilon $$

七、LeNet 模型结构笔记

代码定义了一个 LeNet 风格的 CNN:

class Net(nn.Module):

它由这些层组成:

Conv2d
ReLU
Conv2d
ReLU
MaxPool
Dropout
Flatten
Linear
ReLU
Dropout
Linear
LogSoftmax

整体结构可以画成:

输入图片 [1, 1, 28, 28]
conv1: 1 → 32, kernel=3
[1, 32, 26, 26]
ReLU
conv2: 32 → 64, kernel=3
[1, 64, 24, 24]
ReLU
MaxPool2d(2)
[1, 64, 12, 12]
Dropout
Flatten
[1, 9216]
fc1: 9216 → 128
ReLU
Dropout
fc2: 128 → 10
log_softmax
输出 10 类 log 概率

八、为什么 fc1 输入是 9216?

这来自前面卷积和池化后的特征图尺寸。

输入 MNIST 图片是:

[1, 1, 28, 28]

第一层卷积:

self.conv1 = nn.Conv2d(1, 32, 3, 1)

没有 padding,kernel size 是 3,所以:

[ 28 - 3 + 1 = 26 ]

得到:

[1, 32, 26, 26]

第二层卷积:

self.conv2 = nn.Conv2d(32, 64, 3, 1)

尺寸:

$$ 26 - 3 + 1 = 24 $$

得到:

[1, 64, 24, 24]

最大池化:

F.max_pool2d(x, 2)

尺寸减半:

[1, 64, 12, 12]

展平之后:

$$ 64 \times 12 \times 12 = 9216 $$

所以:

self.fc1 = nn.Linear(9216, 128)

是合理的。


九、forward 函数逐步解释

def forward(self, x):

forward 定义输入如何流过模型。


1. 第一层卷积

x = self.conv1(x)

从原始灰度图提取低级特征,比如边缘、笔画方向、局部纹理。


2. ReLU 激活

x = F.relu(x)

ReLU 公式:

$$ ReLU(x)=max(0,x) $$

作用是引入非线性。

如果没有 ReLU,多个线性层叠加起来本质仍然是线性变换,模型表达能力会弱很多。


3. 第二层卷积

x = self.conv2(x)

进一步提取更复杂的局部模式,比如数字的弯曲结构、交叉结构、环形结构。


4. 最大池化

x = F.max_pool2d(x, 2)

最大池化保留局部最强响应。

对于 MNIST 来说,一个数字稍微平移一点,模型仍然应该识别为同一个数字。池化可以带来一定平移鲁棒性。


5. Dropout

x = self.dropout1(x)

训练时随机丢掉部分特征,防止模型过拟合。

测试时因为执行了:

model.eval()

所以 Dropout 不再随机丢弃。


6. 展平

x = torch.flatten(x, 1)

从第 1 维开始展平。

原来:

[batch, channel, height, width]

变成:

[batch, feature]

对于单张图片:

[1, 64, 12, 12] → [1, 9216]

这里的 1 很重要,表示保留 batch 维度。


7. 全连接层

x = self.fc1(x)
x = F.relu(x)

将局部特征整合成全局分类特征。

卷积层关注局部,线性层开始做整体判断。


8. 输出类别分数

x = self.fc2(x)

输出 10 个数字,每个数字对应一个类别。

比如输出 shape 是:

[1, 10]

对应:

数字 0 的分数
数字 1 的分数
...
数字 9 的分数

9. log_softmax

output = F.log_softmax(x, dim=1)

它把原始分数转换成 log probability。

为什么要用它?

因为后面 loss 用的是:

F.nll_loss(output, target)

这一套是配套的:

log_softmax + nll_loss

等价于:

cross_entropy

也就是说,下面两种写法本质上等价:

output = F.log_softmax(x, dim=1)
loss = F.nll_loss(output, target)

和:

loss = F.cross_entropy(logits, target)

区别是第一种手动拆开,第二种 PyTorch 封装在一起。


十、数据预处理:为什么要 Normalize?

代码中:

transforms.Normalize((0.1307,), (0.3081,))

MNIST 的图片被标准化为:

$$ x_{norm} = \frac{x - 0.1307}{0.3081} $$

这里:

0.1307 是 MNIST 的均值
0.3081 是 MNIST 的标准差

标准化的作用:

  1. 让输入分布更稳定;
  2. 让模型训练更容易;
  3. 避免不同输入尺度导致优化困难。

原图像素范围是:

[0, 1]

标准化后不再局限于 [0,1],可能有负数,也可能大于 1。

例如:

$$ x=0 $$

标准化后:

$$ \frac{0-0.1307}{0.3081} \approx -0.424 $$

所以模型实际吃到的不是原始图,而是标准化后的图。


十一、为什么攻击前要 denorm

代码中:

data_denorm = denorm(data)

因为 data 是标准化后的图片。

但是 FGSM 的 epsilon 通常定义在原始像素空间 [0,1] 上。

所以流程是:

标准化图片 data
    ↓ denorm
原始尺度图片 data_denorm,范围约为 [0,1]
    ↓ FGSM
对抗图片 perturbed_data,范围 [0,1]
    ↓ Normalize
标准化后的对抗图片 perturbed_data_normalized
    ↓ model
重新分类

这一步非常关键。

如果你直接对标准化后的 data 做:

data + epsilon * sign(gradient)

那么 epsilon 的含义就变了,不再是原始像素空间里的扰动大小。


十二、denorm 函数详解

def denorm(batch, mean=[0.1307], std=[0.3081]):

这个函数把标准化后的 tensor 转回原始尺度。

标准化是:

$$ x_{norm} = \frac{x - mean}{std} $$

反归一化是:

$$ x = x_{norm} \cdot std + mean $$

代码:

return batch * std.view(1, -1, 1, 1) + mean.view(1, -1, 1, 1)

这里的:

view(1, -1, 1, 1)

是为了适配图像 batch 的 shape:

[B, C, H, W]

对于 MNIST:

B = batch size
C = 1
H = 28
W = 28

所以:

batch shape: [B, 1, 28, 28]
mean shape:  [1, 1, 1, 1]
std shape:   [1, 1, 1, 1]

如果是 RGB 图片,mean/std 有 3 个值,变成:

mean shape: [1, 3, 1, 1]
std shape:  [1, 3, 1, 1]

可以对每个通道分别反归一化。


十三、test 函数是整段代码的核心

def test(model, device, test_loader, epsilon):

它的作用是:

在某个 epsilon 攻击强度下,测试模型被 FGSM 攻击后的准确率,并保存一些对抗样本用于可视化。


1. 初始化统计变量

correct = 0
adv_examples = []

correct 表示攻击后仍然分类正确的数量。

adv_examples 用来保存若干攻击样本。


2. 遍历测试集

for data, target in test_loader:

因为:

batch_size=1

所以每次处理一张图片。

data 是图片:

[1, 1, 28, 28]

target 是标签:

[1]

比如:

target = 7

表示这张图真实类别是 7。


3. 把数据放到设备上

data, target = data.to(device), target.to(device)

如果模型在 GPU 上,数据也必须在 GPU 上。

常见错误是:

model 在 cuda
data 在 cpu

这样会报错。


4. 对输入开启梯度

data.requires_grad = True

这是对抗攻击和普通测试最大的区别。

普通测试只需要:

output = model(data)

不需要梯度。

但是 FGSM 要知道:

输入图片的每个像素怎么变化,会让 loss 变大

所以必须计算:

[ \nabla_x J(\theta,x,y) ]

也就是 loss 对输入 (x) 的梯度。

这就是:

data.requires_grad = True

的作用。


5. 第一次前向传播

output = model(data)

得到模型对原图的预测结果。


6. 取初始预测类别

init_pred = output.max(1, keepdim=True)[1]

output shape 是:

[1, 10]

output.max(1) 表示在类别维度上取最大值。

它返回:

最大值本身
最大值对应的位置

例如:

output.max(1, keepdim=True)

可能返回:

values:  tensor([[-0.01]])
indices: tensor([[7]])

其中 indices=7 表示模型预测为数字 7。

代码中:

[1]

取的是第二个返回值,也就是类别编号。

更直观的写法是:

init_pred = output.argmax(dim=1, keepdim=True)

7. 原本预测错就跳过

if init_pred.item() != target.item():
    continue

这句表示:

如果模型原本就认错了,就不攻击这张图

原因是 FGSM 测试通常关心:

原本能被正确分类的样本,在攻击后是否会变错。

如果原来就错了,再攻击就没有太大分析价值。

不过要注意:

最后准确率分母仍然是:

len(test_loader)

所以原本就错的样本不会进入 correct,最终还是算作错误。


8. 计算 loss

loss = F.nll_loss(output, target)

因为模型输出是 log probability:

F.log_softmax(x, dim=1)

所以这里用:

F.nll_loss

目标是让真实类别的 log probability 尽可能高。

如果模型对真实类别越不自信,loss 越大。


9. 清空旧梯度

model.zero_grad()

PyTorch 默认梯度会累积。

如果不清空,上一次 backward 的梯度会叠加到这一次。

训练中常见写法是:

optimizer.zero_grad()

这里没有优化器,不更新模型参数,只是算梯度,所以使用:

model.zero_grad()

10. 反向传播

loss.backward()

这一步计算梯度。

因为:

data.requires_grad = True

所以 backward 后会得到:

data.grad

它表示:

[ \frac{\partial loss}{\partial data} ]

也就是每个像素对 loss 的影响方向。


11. 获取输入梯度

data_grad = data.grad.data

这就是 FGSM 需要的梯度。

更现代、更安全的写法可以是:

data_grad = data.grad.detach()

不过原代码也可以运行。


12. 生成对抗样本

data_denorm = denorm(data)
perturbed_data = fgsm_attack(data_denorm, epsilon, data_grad)

先把标准化图片变回 [0,1],然后执行 FGSM。

核心攻击:

perturbed_image = image + epsilon * sign_data_grad

再裁剪:

perturbed_image = torch.clamp(perturbed_image, 0, 1)

确保图片像素合法。


13. 攻击后重新归一化

perturbed_data_normalized = transforms.Normalize((0.1307,), (0.3081,))(perturbed_data)

这是为了让对抗样本符合模型输入格式。

模型训练时吃的是 Normalize 后的数据,所以测试也必须 Normalize。


14. 重新分类对抗样本

output = model(perturbed_data_normalized)

这一步看模型面对攻击后的图片会输出什么。


15. 判断是否攻击成功

final_pred = output.max(1, keepdim=True)[1]

取攻击后的预测类别。

如果:

final_pred.item() == target.item()

说明攻击失败,模型仍然认对。

如果:

final_pred.item() != target.item()

说明攻击成功,模型被误导。

例如:

原本预测:7
攻击后预测:2

这就是典型对抗样本。


十四、可视化部分在做什么?

最后这部分代码:

plt.figure(figsize=(8,10))
for i in range(len(epsilons)):
    for j in range(len(examples[i])):
        ...
        plt.imshow(ex, cmap="gray")

作用是把不同 epsilon 下的对抗样本画出来。

每一行对应一个 epsilon。

标题:

plt.title(f"{orig} -> {adv}")

表示:

攻击前预测类别 -> 攻击后预测类别

比如:

7 -> 2

说明模型原来预测为 7,攻击后预测为 2。

左边的 ylabel:

plt.ylabel(f"Eps: {epsilons[i]}", fontsize=14)

表示当前这一行的攻击强度。

你通常会看到:

epsilon = 0      图片干净,预测正确
epsilon = 0.05   图片略变,可能仍然正确
epsilon = 0.1    有些开始误判
epsilon = 0.2    噪声明显,误判更多
epsilon = 0.3    图片明显变脏,模型大量错误

十五、关键语法集中总结

1. class Net(nn.Module)

定义一个 PyTorch 神经网络模型。

所有自定义模型通常都继承:

nn.Module

2. super(Net, self).__init__()

调用父类初始化函数。

现代写法:

super().__init__()

3. nn.Conv2d(1, 32, 3, 1)

二维卷积层。

含义:

输入通道 1
输出通道 32
卷积核大小 3
步长 1

4. F.relu(x)

ReLU 激活函数。

[ ReLU(x)=max(0,x) ]


5. F.max_pool2d(x, 2)

二维最大池化,窗口大小为 2。

一般会让图片高宽减半。


6. torch.flatten(x, 1)

从第 1 维开始展平。

保留 batch 维度。

[1, 64, 12, 12] → [1, 9216]

7. F.log_softmax(x, dim=1)

沿类别维度计算 log probability。

dim=1 是因为 x 的 shape 是:

[batch, class]

8. datasets.MNIST(..., train=False)

加载 MNIST 测试集。


9. transforms.ToTensor()

把图片转成 tensor,并把像素从 [0,255] 转成 [0,1]


10. transforms.Normalize

标准化输入:

[ x_{norm} = \frac{x - mean}{std} ]


11. DataLoader

按 batch 读取数据。

batch_size=1

表示每次读取一张图。


12. .to(device)

把模型或数据移动到 CPU/GPU。


13. model.eval()

让模型进入测试模式。

影响 Dropout 和 BatchNorm。


14. data.requires_grad = True

允许对输入图片求梯度。

对抗攻击必须有这句。


15. loss.backward()

反向传播,计算梯度。


16. data.grad

得到 loss 对输入图片的梯度。


17. .sign()

取符号:

正数 → 1
负数 → -1
零 → 0

18. torch.clamp(x, 0, 1)

把像素限制在 [0,1] 范围。


19. .item()

把单元素 tensor 转成 Python 数字。


20. .squeeze()

去掉长度为 1 的维度。

[1, 1, 28, 28] → [28, 28]

21. .detach().cpu().numpy()

常用于可视化。

含义:

脱离计算图 → 移到 CPU → 转成 numpy

十六、这段代码背后的科研意义

这段代码不是简单的“给图片加噪声”,而是在展示深度学习模型的一个根本问题:

模型学到的决策边界和人类感知并不完全一致。

人类看 MNIST 数字时,更关注整体结构:

笔画形状
数字轮廓
上下左右结构

但神经网络可能依赖高维空间中的某些微妙方向。

FGSM 利用梯度找到这些方向,然后轻微改变输入,使图片跨过模型的决策边界。

所以会出现:

人眼看起来还是 7
模型却认为是 2

这说明模型的鲁棒性不足。


十七、为什么高维空间中小扰动会很危险?

MNIST 是 (28 \times 28) 的图像,也就是 784 维输入。

每个像素只改变一点点,比如:

0.05

单独看一个像素,变化很小。

但是 784 个像素一起沿着同一个攻击目标变化,总体影响就会被放大。

这就是高维空间中的累积效应。

可以粗略理解为:

单个像素变化很小
但所有像素一起朝着让模型犯错的方向变化
最终模型输出被明显改变

这也是对抗样本存在的重要原因之一。


十八、FGSM 是白盒攻击

这段代码里的 FGSM 属于:

White-box Attack

白盒攻击表示攻击者知道模型结构和参数。

因为代码中攻击者可以访问:

model
loss
data.grad

也就是说,攻击者可以直接计算:

$$ \nabla_x J(\theta,x,y) $$

如果攻击者不知道模型结构和参数,那就是黑盒攻击。

黑盒攻击通常要用:

查询模型输出
迁移攻击
替代模型
估计梯度

等方式实现。


十九、FGSM 是 untargeted attack

这段代码实现的是:

Untargeted Attack

也就是非定向攻击。

它只要求模型预测错,不关心错成哪一类。

例如真实标签是 7:

7 → 2 可以
7 → 3 可以
7 → 9 也可以

只要不是 7,就算攻击成功。

如果是定向攻击,目标会变成:

我要让模型必须把 7 识别成 2

定向攻击的目标通常是让目标类别的 loss 变小,或者让真实类别的得分下降、目标类别得分上升。


二十、FGSM 和 PGD 的关系

FGSM 可以理解为一步攻击。

公式:

[ x_{adv}=x+\epsilon sign(\nabla_x J) ]

PGD 可以理解为多步 FGSM。

它每次走一小步:

[ x^{t+1}=Proj_{B_\epsilon(x)}(x^t+\alpha sign(\nabla_x J)) ]

其中:

  • (\alpha):每一步步长;
  • (Proj):投影回允许扰动范围内;
  • (B_\epsilon(x)):以原图为中心、半径为 (\epsilon) 的扰动空间。

简单理解:

FGSM:一步到位
PGD:小步多次攻击,每次都重新计算梯度

所以 PGD 通常比 FGSM 更强,但计算成本也更高。


二十一、这段代码中的一个重要细节:梯度空间和图像空间

代码中有一个容易忽视的问题:

data_grad = data.grad.data
data_denorm = denorm(data)
perturbed_data = fgsm_attack(data_denorm, epsilon, data_grad)

这里的 data_grad 是 loss 对标准化输入 data 的梯度。

data_denorm 是反归一化后的原图。

严格来说,如果完全严谨,梯度和扰动空间需要一致。

不过对于 sign 来说,标准化是线性变换:

$$ x_{norm} = \frac{x - mean}{std} $$

std 是正数时,梯度符号方向通常不会因为正比例缩放而改变。

所以这段代码在 MNIST 单通道情况下通常是可接受的。

但如果你做更复杂图像攻击,尤其是 RGB、多种预处理、复杂归一化时,要非常注意:

攻击是在 normalized 空间做?
还是在 pixel space 做?
epsilon 的单位到底是什么?

这是很多初学者容易混乱的地方。


二十二、这段代码可以如何改得更严谨?

如果想把统计写得更清楚,可以增加一个计数器:

orig_correct = 0
adv_correct = 0

因为目前代码跳过了原本预测错的样本,但最终分母仍然是:

len(test_loader)

如果你想统计:

原本预测正确的样本中,有多少攻击后仍然正确

可以这样写:

orig_correct += 1
...
if final_pred.item() == target.item():
    adv_correct += 1

最后:

robust_acc = adv_correct / orig_correct

这个指标叫:

robust accuracy

它比普通 accuracy 更能反映攻击效果。


二十三、普通准确率和鲁棒准确率的区别

这段代码打印的是:

final_acc = correct / float(len(test_loader))

这可以理解为:

整个测试集上攻击后的准确率

但如果你要研究对抗鲁棒性,通常更关心:

原本能分类正确的样本中,攻击后还有多少保持正确

也就是:

[ RobustAcc = \frac{\text{原本正确且攻击后仍正确的样本数}}{\text{原本正确的样本数}} ]

这可以避免把模型原本就错的样本混入攻击效果分析。


二十四、代码中为什么 model.eval() 重要?

如果不写:

model.eval()

Dropout 会继续随机丢弃神经元。

那么同一张图片每次预测可能都不一样。

这会导致两个问题:

  1. 原始预测不稳定;
  2. 攻击效果不稳定。

对抗攻击实验中,模型应该处于固定状态。

因此:

model.eval()

是必须的。

但是注意:

model.eval()

不会关闭梯度。

所以即使 eval 模式下,仍然可以:

loss.backward()

计算输入梯度。

如果你写了:

with torch.no_grad():

那就不能做 FGSM 了,因为它会关闭梯度追踪。


二十五、不要在 FGSM 中使用 torch.no_grad()

普通测试经常这样写:

with torch.no_grad():
    output = model(data)

这样可以节省显存,加快推理。

但是 FGSM 需要梯度,所以不能这样包住攻击过程。

因为 FGSM 要计算:

data.grad

如果用了 torch.no_grad(),则不会有计算图,也不会有输入梯度。

所以对抗攻击代码和普通测试代码最大的区别之一就是:

普通测试:不需要梯度
对抗攻击:必须需要输入梯度

二十六、为什么 epsilon=0 也要测试?

epsilon=0 表示不加扰动。

它相当于 baseline。

通过它可以知道模型在干净测试集上的准确率。

然后和其他 epsilon 对比:

epsilon = 0      clean accuracy
epsilon = 0.05   weak attack accuracy
epsilon = 0.1    medium attack accuracy
epsilon = 0.3    strong attack accuracy

这样可以画出一条鲁棒性曲线:

x 轴:epsilon
y 轴:accuracy

正常情况下曲线向下。

下降越快,说明模型越脆弱。


二十七、这段代码最核心的三句话

如果只记三句话,应该记:

data.requires_grad = True

表示我要对输入图片求梯度。

loss.backward()

表示反向传播,计算 loss 对输入图片的梯度。

perturbed_image = image + epsilon * data_grad.sign()

表示沿着让 loss 增大的方向修改图片。

这三句就是 FGSM 的灵魂。


二十八、这段代码和深度伪造检测/鲁棒性研究的联系

你现在关注 AIGC / Deepfake 检测,对抗攻击代码非常重要。

因为检测器本质也是分类器:

输入:图片或视频
输出:real / fake

如果检测器是:

fake probability = 0.91

攻击目标就可能是让它变成:

fake probability = 0.10

也就是让 fake 被判成 real。

在 deepfake 检测中,攻击方式可能不只是改像素,还包括:

压缩
转码
resize
加噪
模糊
颜色扰动
频域扰动
时间扰动
帧率变化
视频插帧
后处理增强

FGSM 是最基础的像素空间白盒攻击。它帮你理解:

模型的判断可以被输入空间中很小但有方向的变化破坏。

这对理解检测器鲁棒性非常关键。


二十九、FGSM 对科研的启发

这段代码背后的科研启发有几个:

1. 高准确率不等于鲁棒

模型在 clean data 上准确率高,不代表面对扰动仍然稳定。

所以实验不能只报告:

clean accuracy

还应该报告:

robust accuracy
attack success rate
accuracy under transformations

2. 模型可能依赖脆弱特征

MNIST 模型可能依赖人类不敏感的像素方向。

Deepfake 检测器也可能依赖:

压缩痕迹
水印
分辨率差异
生成器特定纹理
数据集偏差

这类特征一旦被后处理破坏,检测器就可能失效。


3. 梯度暴露了模型的弱点

FGSM 通过梯度找到模型最脆弱的方向。

所以梯度不仅用于训练,也可以用于攻击、解释和诊断模型。


4. 对抗训练可以提升鲁棒性

如果在训练时加入对抗样本:

原图 + 对抗图一起训练

模型可能会变得更鲁棒。

这叫:

Adversarial Training

经典目标是:

$$ \min_\theta \mathbb{E}*{(x,y)} \left[ \max*{\delta \in S} J(\theta, x+\delta, y) \right] $$

外层是训练模型,内层是寻找最强攻击。


三十、完整流程总结

这段代码可以浓缩为:

1. 定义 epsilon 列表,表示攻击强度。
2. 定义 LeNet 模型结构。
3. 加载 MNIST 测试集,并做 ToTensor + Normalize。
4. 选择 CPU/GPU。
5. 初始化模型并加载预训练权重。
6. 设置 model.eval()。
7. 对每张测试图片:
   - 开启 requires_grad;
   - 前向传播;
   - 如果原本预测错,跳过;
   - 计算 loss;
   - backward 得到输入梯度;
   - 取梯度 sign;
   - 对原图加 epsilon * sign;
   - clamp 到 [0,1];
   - 重新 Normalize;
   - 再次送入模型;
   - 统计攻击后是否仍然正确。
8. 对每个 epsilon 重复测试。
9. 画出不同 epsilon 下的对抗样本。

三十一、最终理解版

模型先正常看一张 MNIST 图片,比如它认为这是数字 7。然后我们反过来问模型:如果想让你的 loss 变大,图片的每个像素应该往哪个方向变化?模型通过梯度告诉我们方向。于是我们沿着这个方向轻轻改动图片,得到一张人眼可能仍然认为是 7 的新图片。但模型再次看到它时,可能会认为它是 2。这就是 FGSM 对抗攻击。