我最初接触对抗样本时,注意力主要放在FGSM、PGD这类白盒攻击上,觉得只要理解了梯度怎么被利用、扰动怎么让模型出错,就对这个问题有了比较完整的认识。后来把迁移攻击和经典黑盒攻击放在同一条线索上重新梳理,我才发现之前的理解偏窄:现实中的攻击者往往看不到模型内部,查询也受限,此时对抗样本能否迁移、查询成本高不高、一些正常功能会不会被组合成新的传递或攻击路径,都会影响风险评估。
基于这个发现,本文尝试从威胁模型、攻击目标和算法选择入手,把白盒攻击、迁移攻击和黑盒攻击串起来,帮助读者建立“威胁模型—攻击目标—算法选择—防御评估”的整体认识,而不是只盯着攻击成功率。这次研究让我意识到,对抗样本的意义在于更真实地评估模型鲁棒性和安全部署风险:白盒攻击暴露脆弱点,迁移和黑盒攻击提醒我们现实攻击可能性不能低估。
1.1 什么是对抗样本
什么是人工智能模型
假设我们训练了一个图像分类模型,能够识别猫、狗等不同物体,模型的工作流程可以简化为:
输入照片 → 神经网络提取特征 → 输出各类别得分 → 选择预测类别在正常情况下可以正确识别,但是如果对输入图片添加一些人类几乎无法察觉的微小扰动,模型可能就会产生完全不同的预测结果。
这就是对抗样本攻击的基本思想。
什么是对抗样本
英文名称:Adversarial Examples。
对抗样本是指:
通过对正常输入数据添加经过特殊构造的扰动,使机器学习模型产生错误预测或攻击者期望的预测结果,同时尽可能保持原始输入的语义不变。
用一个公式表示:

其中:
符号 | 含义 |
|---|---|
$x$ | 原始输入样本 |
$\delta$ | 对抗扰动(Adversarial Perturbation) |
$x'$ | 对抗样本 |
$f(x)$ | 模型对原始样本的输出 |
$f(x')$ | 模型对对抗样本的输出 |
攻击的核心目标:

也就是说,原始输入和对抗样本在语义上基本相同,但是模型预测结果发生了变化。
这里需要了解:对抗样本不是简单地给图片添加随机噪声,而是根据模型的弱点,有目的地构造扰动。
随机噪声不一定能让模型分类错误,而对抗扰动会利用模型的决策机制,尽可能高效地改变模型预测结果。
为什么会出现对抗样本攻击
涉及机器学习模型的一个核心问题:模型学习到的特征不一定与人类理解的语义完全一致。
例如: 人类识别一只猫,可能会关注:
耳朵形状。
眼睛。
胡须。
身体轮廓。
我进一步意识到,神经网络并不一定像人那样“理解”输入,它可能学到大量人类难以直观解释的统计特征。这些特征在正常数据分布下确实能帮助模型分类,但我也认识到,它们可能让模型对某些特定方向上的微小扰动格外敏感。
早期研究中一个很重要的解释是:模型在高维空间中可能表现出局部线性行为,大量微小扰动经过特征和权重的累积,最后可能足以改变分类结果。
不过我在研究时也提醒自己,不能把这种解释当成所有对抗样本现象的唯一答案;模型结构、训练数据、决策边界和输入分布等因素,都会影响模型的对抗脆弱性。
安全意义: 模型在正常测试集上准确率很高,并不意味着它能够抵抗攻击者精心构造的输入。
1.2 理解损失函数和梯度
机器学习模型的分类过程
假设我们有一个二分类模型:
类别 0:猫
类别 1:狗
模型接收输入:

经过神经网络计算,输出:

其中:
$p_0$:预测为猫的概率。
$p_1$:预测为狗的概率。
例如:

模型认为这张图片是狗的概率为 90%。
最终预测类别:

即选择概率最大的类别。下文用 $f(x)$ 表示模型输出,用 $\hat{y}(x)$ 表示最终预测类别;实验代码中的网络输出是 logits,交叉熵函数会在内部处理,不需要提前调用 softmax。
什么是损失函数
损失函数用于衡量模型预测结果与真实标签之间的差距。
机器学习训练的目的,就是不断调整模型内部的参数,使得这个“损失值”降到最低。对于当前样本,真实类别的预测概率越高,交叉熵损失通常越小。
假设:
假设真实类别为 1,也就是狗:

模型预测:

模型给真实类别的概率为 0.9,预测正确,损失较小。
模型预测:

真实类别的预测概率只有 0.1,模型预测错误,损失也更大。
分类问题常用交叉熵损失作为损失函数:

其中:
$C$:类别数量。
$y_i$:真实标签的独热编码(通常是 One-hot 编码,正确类别为1,其余为0),从而损失 = $-\log(\text{预测正确的概率})$。
$p_i$:模型预测概率。
解释一下原理:
在信息论中,一件越不可能发生的事情发生了,它包含的信息量就越大。公式定义为:$I(p)=-\log(p)$
交叉熵(Cross-Entropy):衡量“我们用预测的概率分布去表达真实概率分布时,所需的平均信息量”
对抗攻击者希望利用这个损失函数,找到一个扰动,使模型的损失增加。
什么是梯度(Gradient)
梯度是一个向量,它指出了函数值(比如损失)上升最快的方向。
对于神经网络:

表示损失函数关于输入数据 $x$ 的梯度。
注意,这里不是对模型参数求梯度,而是对输入数据求梯度。
训练时,我们通常对模型参数求梯度,让损失下降;这里则固定模型参数,修改输入,让损失上升。两者使用的都是梯度,但求导对象和更新方向不同。
为什么梯度可以用于攻击
假设我们想让模型分类错误。
我们可以:
输入一张正常图片。
计算模型预测结果。
计算损失函数。
求损失函数对输入图片的梯度。
根据梯度方向修改图片。
再次输入模型,观察预测结果。
数学表达:

我们希望:

同时限制扰动的大小:

其中:
$\epsilon$:扰动预算。
$p$:使用的范数类型。
$L$:模型损失函数。
这就是经典的非定向对抗攻击优化目标。
简单来说,训练时希望模型少犯错,非定向攻击则故意让模型更容易犯错。
特别注意: 扰动预算越大,通常越容易改变模型预测,但也可能让输入变化更加明显,所以要限制扰动的大小在预算之内。
这类只要求预测错误、不指定错误类别的方式,叫作非定向攻击。
1.3 攻击方式的分类
按攻击者掌握的信息分类
白盒攻击(White-box Attack)
掌握模型内部信息
攻击者可以获取模型结构、参数,通常还可以计算梯度。
例如:知道模型使用 ResNet,并可以访问模型权重。
黑盒攻击(Black-box Attack)
无法直接访问模型内部
攻击者无法直接获得模型参数和梯度,可能只能提交输入并观察输出。
例如:只能通过在线图像识别 API 获取分类结果。
灰盒攻击(Gray-box Attack)
掌握部分信息
攻击者了解模型部分结构、接口或训练信息,但无法完全访问模型内部。
这里需要特别注意:
白盒、黑盒和灰盒描述的是攻击者的信息与访问能力,而不是攻击算法本身。
按攻击目标分类
(1)非定向攻击(Untargeted Attack)
攻击者只要求模型预测错误,不关心错误预测成什么类别。
例如:
原始图片:猫。
模型原本预测:猫。
攻击后预测:狗、兔子、狐狸都可以。
目标:

优化目标:

(2)定向攻击(Targeted Attack)
攻击者希望模型预测成指定类别。
例如:
原始图片:猫。
攻击目标:狗。
攻击后必须预测成狗。
目标:

其中 $y_t$ 是攻击者指定的目标类别。
定向攻击通常通过最小化目标类别对应的损失来实现:

按扰动范围分类
常见的范数约束:
范数 | 含义 | 特点 |
|---|---|---|
$L_0$ | 发生变化的像素数量 | 控制修改位置数量 |
$L_1$ | 扰动绝对值之和 | 控制总体扰动量 |
$L_2$ | 扰动平方和开根号 | 控制整体欧氏距离 |
$L_\infty$ | 最大单个元素扰动 | 控制每个像素的最大变化 |
其中,FGSM 和 PGD 常使用 $L_\infty$ 约束。
例如:

表示每个归一化像素的扰动绝对值不能超过 $8/255$。
(Fast Gradient Sign Method)
快速梯度符号法,它是一种经典的白盒攻击(White-box Attack)方法,旨在通过利用神经网络的梯度信息,以极低的计算成本生成对抗样本,使模型产生分类错误。
2.1 原理
FGSM 的核心思想不是通过复杂的迭代优化来寻找对抗扰动,而是沿着损失函数梯度上升的方向,单步推入一个固定大小的扰动。
数学表达式为:

公式中:
$x$:原始图片。
$x_{\mathrm{adv}}$:对抗样本。
$\epsilon$:扰动幅度。
$L$:损失函数。
$\nabla_x L$:损失函数对输入图片的梯度。
$\operatorname{sign}$:符号函数。
符号函数的规则:

例如,计算得到梯度:

经过符号函数:

假设扰动幅度为:

那么添加的扰动为:

这意味着每个像素按照梯度的正负方向进行修改。
这里有一个细节:
为什么使用 sign(gradient),而不是直接使用梯度?
因为 FGSM 的目标是在 $L_\infty$ 约束下,通过一次梯度计算最大化局部线性近似的损失。
下面把损失简写为 $L(x)$,表示固定模型与真实标签后,损失随输入的变化。对于线性近似:

在满足:

的情况下,选择梯度符号方向可以最大化这个一阶近似目标。
这就是 FGSM 的数学基础。
FGSM 的特点
优点:
实现简单。
计算成本低。
只需要一次反向传播。
适合快速测试模型的对抗脆弱性。
缺点:
只进行一次扰动。
不一定能找到最强的对抗样本。
攻击效果依赖扰动幅度。
可能存在攻击失败的情况。
2.2 实验准备
实验使用 MNIST 手写数字数据集,图片为 28 × 28 的灰度图,标签是 0~9。这里先训练一个简单的卷积神经网络,不使用预训练模型。
需要安装的依赖:
python -m pip install torch torchvision matplotlib如果使用 GPU,请按 PyTorch 官方说明选择与本机匹配的安装方式。代码只使用 ToTensor() 将像素转换到 $[0,1]$,没有额外做均值方差标准化,因此后面可以直接将像素限制在这个范围内。
项目结构如下
adversarial-experiment/
│
├── model.py # 定义神经网络
├── train.py # 训练模型
├── attack.py # 实现攻击函数
├── evaluate.py # 测试攻击效果
└── results/2.3 定义模型
其中 model.py:
import torch
import torch.nn as nn
import torch.nn.functional as F
class MNISTModel(nn.Module):
def __init__(self):
super().__init__()
self.conv1 = nn.Conv2d(1, 16, 3, padding=1)
self.conv2 = nn.Conv2d(16, 32, 3, padding=1)
self.pool = nn.MaxPool2d(2, 2)
self.fc1 = nn.Linear(32 * 7 * 7, 128)
self.fc2 = nn.Linear(128, 10)
def forward(self, x):
x = self.pool(F.relu(self.conv1(x)))
x = self.pool(F.relu(self.conv2(x)))
x = x.view(x.size(0), -1)
x = F.relu(self.fc1(x))
x = self.fc2(x)
return x解释一下代码:
先看导入的包
torch:PyTorch 的核心库,用于张量(Tensor)计算。torch.nn:包含了构建神经网络所需的各种层(如卷积层、全连接层)。torch.nn.functional:包含了各种激活函数(如 ReLU)和不需要学习参数的操作(如池化)。
这里使用了 CNN(卷积神经网络)。因为 MNIST 是图像分类数据集,CNN 能够通过卷积操作提取图像中的局部特征。
之后定义了模型类进行初始化
所有的 PyTorch 模型都必须继承
nn.Module类,并调用super().__init__()来初始化父类。__init__函数里定义了模型的“零件”(网络层)
代码 | 说明 |
|---|---|
| 第 1 个卷积层。输入通道 |
| 第 2 个卷积层。接收上一层的 16 个通道,输出 |
| 最大池化层。窗口大小 |
| 第 1 个全连接层。把经过两次池化后的特征图拉平,输入维度是 |
| 第 2 个全连接层(输出层)。接收 128 个节点,输出 |
几个重要知识点:
Conv2d:二维卷积层,提取图片特征。ReLU:激活函数,引入非线性。MaxPool2d:池化层,降低特征图尺寸。Linear:全连接层,完成分类。forward():定义模型前向传播过程。
最后输出 10 个数字类别的分数:

分数最高的类别就是模型预测结果。
2.4 训练模型
train.py
import torch
import torch.nn as nn
import torch.optim as optim
from torchvision import datasets, transforms
from torch.utils.data import DataLoader
from model import MNISTModel
def train():
device = torch.device(
"cuda" if torch.cuda.is_available() else "cpu"
)
transform = transforms.ToTensor()
train_data = datasets.MNIST(
root="./data",
train=True,
download=True,
transform=transform
)
train_loader = DataLoader(
train_data,
batch_size=128,
shuffle=True
)
model = MNISTModel().to(device)
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(
model.parameters(),
lr=0.001
)
for epoch in range(3):
model.train()
total_loss = 0
for images, labels in train_loader:
images = images.to(device)
labels = labels.to(device)
optimizer.zero_grad()
outputs = model(images)
loss = criterion(outputs, labels)
loss.backward()
optimizer.step()
total_loss += loss.item()
print(
f"Epoch {epoch + 1}, "
f"Loss: {total_loss / len(train_loader):.4f}"
)
torch.save(
model.state_dict(),
"mnist_model.pth"
)
print("模型训练完成!")
if __name__ == "__main__":
train()运行训练程序:
python train.py程序会下载 MNIST 数据集,训练 3 轮,并将权重保存为 mnist_model.pth。
2.5 实现 FGSM 攻击
将下面的代码保存到 attack.py:
attack.py
import torch
def fgsm_attack(model, images, labels, epsilon):
# 1.复制原始图片,确保输入图像开启梯度追踪
images = images.clone().detach()
images.requires_grad = True
# 2.前向传播获取模型输出
outputs = model(images)
# 3.计算交叉熵损失
loss = torch.nn.functional.cross_entropy(
outputs,
labels
)
# 4.清空模型参数的历史梯度
model.zero_grad()
# 5.反向传播,计算输入梯度
loss.backward()
# 6.获取输入图片的梯度
data_grad = images.grad
# 7.根据梯度符号生成扰动
perturbation = epsilon * data_grad.sign()
# 8.生成对抗样本
adv_images = images + perturbation
# 9.限制像素范围,由于 MNIST 图片像素范围是 0~1,因此需要限制扰动后的像素范围。
adv_images = torch.clamp(
adv_images,
0,
1
)
return adv_images.detach()2.6 测试攻击效果
evaluate.py
import torch
import matplotlib.pyplot as plt
from torchvision import datasets, transforms
from torch.utils.data import DataLoader
from model import MNISTModel
from attack import fgsm_attack
def evaluate():
device = torch.device(
"cuda" if torch.cuda.is_available() else "cpu"
)
test_data = datasets.MNIST(
root="./data",
train=False,
download=True,
transform=transforms.ToTensor()
)
test_loader = DataLoader(
test_data,
batch_size=128,
shuffle=False
)
model = MNISTModel().to(device)
model.load_state_dict(
torch.load(
"mnist_model.pth",
map_location=device
)
)
model.eval()
epsilon = 0.15
correct_clean = 0
correct_adv = 0
attack_success = 0
clean_correct_total = 0
for images, labels in test_loader:
images = images.to(device)
labels = labels.to(device)
# 原始图片预测
with torch.no_grad():
outputs = model(images)
clean_preds = outputs.argmax(dim=1)
# 只统计原始预测正确的样本
clean_mask = clean_preds.eq(labels)
clean_correct_total += clean_mask.sum().item()
correct_clean += clean_mask.sum().item()
# 生成对抗样本
adv_images = fgsm_attack(
model,
images,
labels,
epsilon
)
# 对抗样本预测
with torch.no_grad():
adv_outputs = model(adv_images)
adv_preds = adv_outputs.argmax(dim=1)
# 对抗样本仍然预测正确的数量
correct_adv += (
adv_preds.eq(labels) & clean_mask
).sum().item()
# 原本正确、攻击后错误的数量
attack_success += (
adv_preds.ne(labels) & clean_mask
).sum().item()
clean_accuracy = (
correct_clean / len(test_data)
)
robust_accuracy = (
correct_adv / clean_correct_total
if clean_correct_total else 0
)
asr = (
attack_success / clean_correct_total
if clean_correct_total else 0
)
print(f"Clean Accuracy: {clean_accuracy:.4f}")
print(f"Robust Accuracy: {robust_accuracy:.4f}")
print(f"Attack Success Rate: {asr:.4f}")
# 展示一个攻击样本
images, labels = next(iter(test_loader))
images = images[:1].to(device)
labels = labels[:1].to(device)
adv_images = fgsm_attack(
model,
images,
labels,
epsilon
)
with torch.no_grad():
original_pred = model(images).argmax(dim=1).item()
adv_pred = model(adv_images).argmax(dim=1).item()
plt.figure(figsize=(8, 3))
plt.subplot(1, 2, 1)
plt.imshow(images[0].cpu().squeeze(), cmap="gray")
plt.title(f"Original: {original_pred}")
plt.axis("off")
plt.subplot(1, 2, 2)
plt.imshow(adv_images[0].cpu().squeeze(), cmap="gray")
plt.title(f"Adversarial: {adv_pred}")
plt.axis("off")
plt.tight_layout()
plt.show()
if __name__ == "__main__":
evaluate()将上面的代码保存为 evaluate.py,在项目目录中运行:

这里解释一下这几个指标
指标 | 含义 |
|---|---|
Clean Accuracy | 原始测试集准确率 |
Robust Accuracy | 攻击后,在原本预测正确的样本中仍然正确的比例 |
Attack Success Rate | 攻击成功率 |

这里的ASR就是Attack Success Rate。
2.7 改变扰动幅度
这里可以进行分别测试:

记录结果,绘制扰动幅度与攻击成功率的关系。
epsilon=0时:
Clean Accuracy: 0.9819
Robust Accuracy: 1.0000
Attack Success Rate: 0.0000
=================
epsilon=0.3时:
Clean Accuracy: 0.9819
Robust Accuracy: 0.0195
Attack Success Rate: 0.9805根据epsilon的改变,可以发现以下几个规律
当
epsilon=0时,图片没有被修改,作为基线对照,ASR 为 0。在笔记记录的测试中,较大的
epsilon对应更高的攻击成功率;继续增大扰动后,提升空间会逐渐变小。扰动增加后,图片开始明显失真
不能只追求攻击成功率。扰动越大,数字可能变得越难辨认;还需要结合原图、对抗图和实际像素变化判断是否仍保持原有语义。
例如,100 张图中原本有 90 张预测正确,攻击让其中 30 张出错,那么 ASR 是 30/90,不是 30/100。
Projected Gradient Descent:多次迭代生成扰动
3.1 原理
PGD 是对抗样本研究中非常重要的一种攻击算法。
我先把 PGD 理解为:在迭代攻击的基础上加入随机初始化,并在每一步把样本投影回允许的扰动范围。
对我来说,随机初始化可以理解成“换一个起点再找方向”,它不保证一定能找到最强的攻击样本,但能减少攻击只依赖某一个起点的情况。这里虽然沿用了 PGD(投影梯度下降)这个名称,但在非定向攻击中,实际做的是让损失上升,而不是下降。
其公式:

其中:
$\delta_0$:随机初始化的扰动。
$\Pi$:投影操作。
$B_\epsilon(x)$:以原始输入为中心、半径为 $\epsilon$ 的允许扰动集合。
为什么需要投影?
假设:
原始像素:

扰动范围:

那么对抗样本必须满足:

假设某次梯度更新后:

它超过了允许的范围。
投影操作就会把它限制回:

因此,PGD 可以在多轮迭代过程中,始终控制对抗扰动的大小。
PGD 常用于评估模型的对抗鲁棒性,但也要注意:攻击是否足够强,取决于扰动预算、迭代次数、步长、随机重启和模型特性,不能只因为使用 PGD 就认为评估一定充分。
3.2 实验参数
之前的FGSM攻击代码
data_grad = images.grad
perturbation = epsilon * data_grad.sign()
adv_images = images + perturbation对应数学公式:

它的特点是:
只计算一次梯度。
一次性生成扰动。
不进行迭代优化。
可以理解为:计算一次损失增长最快的方向,然后直接向这个方向移动。
PGD 的核心思想
PGD 可以看作迭代式的 FGSM。
它不是只计算一次梯度,而是:
首先对原始图片添加随机扰动。
将扰动后的图片输入模型。
计算损失函数。
计算损失对输入图片的梯度。
沿着梯度符号方向修改图片。
将图片投影回允许的扰动范围。
重复上述过程。
PGD 的数学公式:
随机初始化:

迭代更新:

其数学公式包含三个阶段:
随机初始化:在允许的扰动范围内随机选择一个初始位置,而不是始终从原始图片开始。
梯度上升:每次计算新的梯度,寻找使损失增大的方向。
投影:确保每轮更新后的样本都没有超过扰动预算。
实验的初始参数如下
参数 | 初始值 | 说明 |
|---|---|---|
epsilon | 0.15 | 最大扰动幅度 |
alpha | 0.03 | 每次迭代步长 |
steps | 10 | 迭代次数 |
random_start | True | 是否随机初始化 |
norm | L∞ | 扰动约束类型 |
3.3 实现 PGD 攻击
用下面这份代码替换前面的 attack.py,其中保留 FGSM,并新增 PGD:
import torch
import torch.nn.functional as F
# FGSM
def fgsm_attack(model, images, labels, epsilon):
images = images.clone().detach()
images.requires_grad = True
outputs = model(images)
loss = F.cross_entropy(outputs, labels)
model.zero_grad()
loss.backward()
data_grad = images.grad
perturbation = epsilon * data_grad.sign()
adv_images = images + perturbation
adv_images = torch.clamp(adv_images, 0, 1)
return adv_images.detach()
def pgd_attack(
model,
images,
labels,
epsilon=0.15,
alpha=0.03,
steps=10,
random_start=True
):
# 1. 保存原始图片,因为需要进行迭代扰动
original_images = images.clone().detach()
# 2. 随机初始化:会生成与原始图片形状相同的随机噪声
if random_start:
random_noise = torch.empty_like(
original_images
).uniform_(-epsilon, epsilon)
adv_images = original_images + random_noise
# 得到初始对抗样本。
adv_images = torch.clamp(
adv_images,
0,
1
)
else:
adv_images = original_images.clone()
# 3. 迭代攻击
for step in range(steps):
# 每次迭代都创建新的计算图
adv_images = adv_images.detach()
adv_images.requires_grad = True
# 前向传播
outputs = model(adv_images)
# 计算交叉熵损失,FGSM 计算的是原始图片的损失梯度。PGD 计算的是当前对抗样本的损失梯度。
loss = F.cross_entropy(
outputs,
labels
)
# 计算损失关于输入图片的梯度
data_grad = torch.autograd.grad(
loss,
adv_images
)[0]
# 4. 梯度上升,PGD 攻击使用的是梯度上升,而不是梯度下降。
with torch.no_grad():
adv_images = (
adv_images +
alpha * data_grad.sign()
)
# 5. 投影:限制相对原始图片的扰动
delta = adv_images - original_images
delta = torch.clamp(
delta,
min=-epsilon,
max=epsilon
)
# 重新生成对抗样本
adv_images = original_images + delta
# 6. 限制图片像素范围
adv_images = torch.clamp(
adv_images,
min=0,
max=1
)
return adv_images.detach()3.4 对比 FGSM 和 PGD
再用下面的代码替换 evaluate.py,依次测试 FGSM 和 PGD:
from pathlib import Path
import torch
import matplotlib.pyplot as plt
from torchvision import datasets, transforms
from torch.utils.data import DataLoader
from model import MNISTModel
from attack import fgsm_attack, pgd_attack
def evaluate_attack(
model,
test_loader,
device,
attack_type,
epsilon=0.15,
alpha=0.03,
steps=10
):
total = 0
clean_correct = 0
adv_correct = 0
attack_success = 0
# 保存一个用于可视化的样本
visual_sample = None
for images, labels in test_loader:
images = images.to(device)
labels = labels.to(device)
# 1. 原始图片预测
with torch.no_grad():
clean_outputs = model(images)
clean_preds = clean_outputs.argmax(dim=1)
# 原始预测正确的样本
clean_mask = clean_preds.eq(labels)
clean_correct += clean_mask.sum().item()
# 2. 生成对抗样本
if attack_type == "FGSM":
adv_images = fgsm_attack(
model,
images,
labels,
epsilon
)
elif attack_type == "PGD":
adv_images = pgd_attack(
model,
images,
labels,
epsilon=epsilon,
alpha=alpha,
steps=steps,
random_start=True
)
else:
raise ValueError("未知攻击类型")
# 3. 对抗样本预测
with torch.no_grad():
adv_outputs = model(adv_images)
adv_preds = adv_outputs.argmax(dim=1)
# 4. 统计攻击后的正确数量
adv_mask = adv_preds.eq(labels)
adv_correct += (adv_mask & clean_mask).sum().item()
# 5. 统计攻击成功数量
success_mask = clean_mask & (~adv_mask)
attack_success += success_mask.sum().item()
total += labels.size(0)
# 保存第一个原本预测正确的样本
if visual_sample is None:
valid_indices = torch.where(
clean_mask
)[0]
if len(valid_indices) > 0:
idx = valid_indices[0].item()
visual_sample = {
"original": images[idx].detach().cpu(),
"adversarial": adv_images[idx].detach().cpu(),
"label": labels[idx].item(),
"clean_pred": clean_preds[idx].item(),
"adv_pred": adv_preds[idx].item()
}
# 6. 计算评估指标
clean_accuracy = clean_correct / total
robust_accuracy = (
adv_correct / clean_correct
if clean_correct > 0 else 0
)
attack_success_rate = (
attack_success / clean_correct
if clean_correct > 0
else 0
)
return {
"clean_accuracy": clean_accuracy,
"robust_accuracy": robust_accuracy,
"attack_success_rate": attack_success_rate,
"visual_sample": visual_sample
}
def show_comparison(fgsm_result, pgd_result):
fig, axes = plt.subplots(2, 2, figsize=(9, 7))
samples = [
("FGSM", fgsm_result),
("PGD", pgd_result)
]
for col, (name, result) in enumerate(samples):
sample = result["visual_sample"]
if sample is None:
continue
# 原始图片
axes[0, col].imshow(
sample["original"].squeeze(),
cmap="gray"
)
axes[0, col].set_title(
f"{name} - Original\n"
f"Prediction: {sample['clean_pred']}"
)
# 对抗图片
axes[1, col].imshow(
sample["adversarial"].squeeze(),
cmap="gray"
)
axes[1, col].set_title(
f"{name} - Adversarial\n"
f"Prediction: {sample['adv_pred']}"
)
for ax in axes.flat:
ax.axis("off")
plt.tight_layout()
Path("results").mkdir(exist_ok=True)
plt.savefig(
"results/attack_comparison.png",
dpi=150
)
plt.show()
def main():
device = torch.device(
"cuda" if torch.cuda.is_available() else "cpu"
)
print("当前设备:", device)
# 加载 MNIST 测试数据
test_data = datasets.MNIST(
root="./data",
train=False,
download=True,
transform=transforms.ToTensor()
)
test_loader = DataLoader(
test_data,
batch_size=128,
shuffle=False
)
# 加载训练好的模型
model = MNISTModel().to(device)
model.load_state_dict(
torch.load(
"mnist_model.pth",
map_location=device
)
)
model.eval()
# PGD 和 FGSM 使用相同的扰动预算
epsilon = 0.15
# PGD 参数
alpha = 0.03
steps = 10
print("\n========== FGSM 攻击 ==========")
fgsm_result = evaluate_attack(
model,
test_loader,
device,
attack_type="FGSM",
epsilon=epsilon
)
print(
f"Clean Accuracy: "
f"{fgsm_result['clean_accuracy']:.4f}"
)
print(
f"Robust Accuracy: "
f"{fgsm_result['robust_accuracy']:.4f}"
)
print(
f"Attack Success Rate: "
f"{fgsm_result['attack_success_rate']:.4f}"
)
print("\n========== PGD 攻击 ==========")
pgd_result = evaluate_attack(
model,
test_loader,
device,
attack_type="PGD",
epsilon=epsilon,
alpha=alpha,
steps=steps
)
print(
f"Clean Accuracy: "
f"{pgd_result['clean_accuracy']:.4f}"
)
print(
f"Robust Accuracy: "
f"{pgd_result['robust_accuracy']:.4f}"
)
print(
f"Attack Success Rate: "
f"{pgd_result['attack_success_rate']:.4f}"
)
# 对比可视化
show_comparison(
fgsm_result,
pgd_result
)
if __name__ == "__main__":
main()程序会分别打印 FGSM 和 PGD 的结果,并将对比图保存到 results/attack_comparison.png。这里没有新增 PGD 的数值结果,具体结果以实际运行输出为准。可视化选择的是原本预测正确的样本,并不保证它在攻击后一定变错。
3.5 继续调整参数
改变扰动预算 epsilon
固定
alpha = 0.03
steps = 10修改分别测试
epsilons = [
0.00,
0.05,
0.10,
0.15,
0.20,
0.30
]固定其他参数,观察攻击成功率、攻击后正确比例和图片失真的变化。通常更大的预算会给攻击留下更多空间,但具体数值仍需要实际测试,不能用趋势代替结果。
改变迭代次数
固定:
epsilon = 0.15
alpha = 0.03分别测试:
steps_list = [1, 3, 5, 10, 20, 40]增加迭代次数通常有助于搜索,但会增加计算时间,也可能很快达到收益不明显的阶段。建议同时记录 ASR 和耗时,看看是否有必要继续增加迭代次数。
这里特别注意:攻击效果不一定随着迭代次数单调增加,步长和投影范围都会影响优化过程。
改变随机初始化
分别测试:
random_start = True和:
random_start = False其他参数保持一致。
随机初始化会影响攻击结果
可以尝试多次随机重启,并保留每张图片中已经攻击成功的结果,而不是只看最后一次运行。
攻击者掌握的信息量不同。白盒有模型结构和参数(能直接算梯度),黑盒只能通过输入输出查询来推断。
如何实现一个黑盒攻击? 有两条经典路径:查询攻击(通过有限差分估计梯度,准确但慢且易被检测)和迁移攻击(在代理模型上生成样本,高效且隐蔽)。
白盒攻击的判断依据:
代码直接调用
model(images),说明攻击者持有模型对象,能拿到完整的前向输出。loss.backward()要求 PyTorch 能访问整个计算图,包括每一层的权重和运算。这意味着攻击者不仅知道模型结构,还知道全部参数值。只有白盒条件下,才能对输入做精确的反向传播,拿到真实的 $\nabla_x J$。
如果是黑盒,攻击者只能拿到 model.predict(x) 的输出(甚至只有类别标签,没有 logits),不能调用 backward()。这时常见做法是:
迁移攻击:自己训练一个替身模型,在替身上做白盒 FGSM,把生成的对抗样本直接拿去攻击目标模型。
有限差分估计梯度:对每个像素反复查询“改一点输出怎么变”,用查询结果近似梯度。代价是查询次数巨大,MNIST 一张图有 784 个像素,逐个估计不现实。
基于分数的攻击:如果黑盒能返回置信度分数,可以用 zeroth-order 优化(如 SPSA、NES)估计梯度方向。
4.1 白盒攻击与黑盒攻击
FGSM 的数学表达式:

其中:
$x$:原始输入。
$\epsilon$:扰动强度。
$L$:损失函数。
$f(x)$:模型输出。
$\nabla_x L$:损失函数关于输入的梯度。
FGSM 为什么能够攻击模型?
因为攻击者可以获取模型的参数,并通过反向传播计算损失函数关于输入的梯度。
但是假设现在存在一个在线图像分类服务:
例如,攻击者提交一张猫的图片:
Input:
image.jpg
Target Model API:
{
"label": "cat",
"confidence": 0.98
}攻击者不知道:
模型使用什么网络架构。
模型有多少层。
模型权重是多少。
梯度是多少。
损失函数是什么。
但攻击者可以修改图片后重新提交,观察返回结果。
例如:
第一次查询:
图片 A → cat,置信度 0.98
第二次查询:
图片 B → cat,置信度 0.65
第三次查询:
图片 C → dog,置信度 0.91这就产生了一个问题:
既然无法直接计算梯度,我们如何知道应该向哪个方向修改输入,才能让模型分类错误?
这就是黑盒攻击要解决的核心问题。
4.2 黑盒攻击的三种攻击类型
这是你必须掌握的一个知识点。
攻击类型 | 模型返回的信息 | 能否获得目标梯度 |
|---|---|---|
White-box | 模型内部参数、梯度等 | 可以 |
Score-based | Logits、概率、置信度 | 不可以直接获得 |
Decision-based | 仅最终分类标签 | 不可以直接获得 |
Transfer-based | 利用替代模型的梯度 | 不能获得目标梯度 |
注意:迁移攻击与前两种 Query-based 攻击有所不同,它的核心不是在目标模型上进行零阶优化,而是利用替代模型上的攻击结果迁移到目标模型。
Transfer-based:迁移攻击
核心思想:
我虽然不知道目标模型的内部结构,但是我可以训练一个自己的模型,然后在自己的模型上生成对抗样本,再拿去攻击目标模型。
假设有两个模型:
Model A:攻击者自己训练的模型。
Model B:目标模型,攻击者无法查看内部参数。
为什么这样可行?
因为存在一个重要性质:对抗样本迁移性。
在某个模型上生成的对抗样本,有一定概率也能让另一个模型产生错误预测。
即使两个模型具有不同的网络结构,只要完成相同任务,也可能存在这种现象。
Score-based:基于分数的攻击
核心思想:
虽然我看不到模型梯度,但是我能看到模型返回的预测分数,所以可以通过多次查询,估计梯度方向。
例如目标模型返回:
{
"cat": 0.98,
"dog": 0.01,
"bird": 0.01
}攻击者对图片进行微小修改:
x → cat: 0.98
x + δ → cat: 0.92
x - δ → cat: 0.99通过观察输出分数的变化,可以推测某个方向对损失函数的影响。
这类攻击涉及:
有限差分(Finite Difference)。
零阶优化(Zeroth-order Optimization)。
NES。
ZOO。
SPSA。
Square Attack。
Decision-based:基于决策的攻击
这是三类方法中目标模型信息最少的一类。
假设目标模型只返回:
{
"label": "cat"
}没有置信度,没有概率,没有 Logits。
这时候攻击者如何工作?
只能通过不断提交样本,观察分类标签是否发生变化。
假设:
原始图片:
cat
大幅修改图片:
dog
尝试减少扰动:
dog
继续减少扰动:
dog
继续减少扰动:
cat那么我们就能知道:原始图片与对抗图片之间,存在一个分类决策边界。
Boundary Attack 和 HopSkipJumpAttack 就属于这类攻击。HSJA 使用决策边界附近的二值信息估计搜索方向。
4.3 黑盒攻击与 FGSM 的数学联系
在正式写代码之前,我希望你先理解黑盒攻击为什么需要不同的优化方法。
先看对抗样本的统一优化目标。
对于非定向攻击(Untargeted Attack):

约束条件:

其中:
$x$:原始样本。
$\delta$:对抗扰动。
$y$:原始标签。
$L$:损失函数。
$\epsilon$:最大扰动预算。
FGSM 的问题在于,它需要:

但在黑盒场景下,这个梯度无法直接获取。
因此出现两条技术路线:
路线一:替代模型。
训练一个自己可以计算梯度的模型:

然后

再把对抗样本提交给目标模型。
这就是迁移攻击。
路线二:梯度估计。
不直接使用模型的真实梯度,而是使用模型的查询结果估计梯度:

然后使用估计梯度更新输入。
这就是 Score-based 攻击的一条核心路线。
Decision-based 则更进一步,连损失分数都没有,只能根据分类标签在决策边界附近搜索。
5.1 什么是迁移攻击?
先回顾白盒攻击。
在 FGSM 中,我们需要知道目标模型 $f$ 的梯度:

再通过梯度符号生成扰动:

但是在黑盒场景下,攻击者无法获取目标模型的梯度。
迁移攻击提供了一种解决思路:
既然无法获取目标模型的梯度,那么就自己准备一个模型,在自己的模型上生成对抗样本,再把对抗样本提交给目标模型。
这两个模型分别叫:
替代模型(Surrogate Model):攻击者可以控制、训练和访问梯度的模型。
目标模型(Target Model):攻击者希望攻击的模型,内部参数和梯度不可见。
用数学表达:
设替代模型为:

目标模型为:

其中:
$\theta_s$:攻击者可以访问。
$\theta_t$:攻击者无法访问。
攻击者在替代模型上计算:

然后构造:

最后将 $x_{\mathrm{adv}}$ 输入目标模型:

如果目标模型也产生了错误分类,就说明攻击成功迁移。
5.2 实际例子
假设某个图像识别平台使用了 ResNet 模型,但它没有开放模型权重,也没有提供梯度接口。
攻击者无法直接在这个 ResNet 上运行 FGSM。
于是攻击者自己使用公开数据集训练了一个 VGG 模型。
现在有以下情况:
项目 | Model A | Model B |
|---|---|---|
模型架构 | VGG | ResNet |
模型角色 | 替代模型 | 目标模型 |
参数是否可见 | 是 | 否 |
梯度是否可获取 | 是 | 否 |
是否参与攻击样本生成 | 是 | 否 |
是否参与攻击效果评估 | 否 | 是 |
假设原始图片是一只猫:
原始图片 x
Model A (VGG):
cat,置信度 99%
Model B (ResNet):
cat,置信度 98%在 VGG 上使用 FGSM:

得到对抗样本以后:
对抗图片 x_adv
Model A (VGG):
dog,置信度 95%
Model B (ResNet):
dog,置信度 72%这里:
VGG 攻击成功。
ResNet 也攻击成功。
虽然攻击者没有获取 ResNet 的梯度,但攻击依然成功。
这就是一个典型的迁移攻击案例。
当然,这只是用于说明原理的假设结果,不代表不同架构之间一定能够成功迁移。
这是迁移攻击最重要的理论问题。
我们需要研究:为什么不同模型生成的对抗样本,可能对其他模型也有效?
6.1 决策边界(Decision Boundary)
首先需要理解一个概念:决策边界。
假设现在有一个二分类模型,需要区分猫和狗。
模型的输入空间可以简单理解为一个二维平面:
一部分区域预测为猫。
另一部分区域预测为狗。
两个区域之间存在分类边界。
假设原始样本 $x$ 位于猫类区域。

攻击者添加一个扰动:

当扰动将样本推过决策边界,模型就可能从猫变成狗。
那么,两个不同模型是否拥有完全相同的决策边界?
当然不是。
但是它们的决策边界可能存在一定相似性。
这就给迁移攻击创造了条件。
6.2 不同模型的决策边界为什么可能相似?
假设两个模型都是在 CIFAR-10 上训练的。
Model A:ResNet18。
Model B:VGG16。
它们虽然网络结构不同,但是需要学习同一批类别的图像特征。
例如:
猫的耳朵形状。
猫的眼睛特征。
狗的鼻子特征。
图像的边缘、纹理和轮廓。
不同模型在训练过程中,可能学到相似的判别特征,也可能形成部分相似的决策区域。
因此,攻击 Model A 时产生的扰动,可能也会影响 Model B 使用的判别特征。
这就是迁移性的一个重要解释。
但要注意:特征学习相似性不是迁移性的完整数学证明。数据分布、模型结构、训练过程、损失函数以及扰动方向都会影响迁移效果。
6.3 从梯度方向理解迁移性
这是一个非常重要的数学知识点。
假设有两个模型:

它们在同一个输入样本 $x$ 上计算损失函数:

分别计算输入梯度:

对于 FGSM:

如果两个模型的梯度方向具有较高的一致性,那么在 A 上生成的扰动就可能也增大 B 的损失。
我们可以用余弦相似度衡量两个梯度方向:

解释:
余弦相似度 | 含义 |
|---|---|
接近 1 | 两个梯度方向较为一致 |
接近 0 | 两个梯度方向近似正交 |
接近 -1 | 两个梯度方向大致相反 |
注意,这个指标是辅助分析指标,不能简单认为余弦相似度越高就必然具有更高的实际攻击成功率。FGSM 使用的是梯度符号,目标模型的非线性边界和扰动约束也会影响最终结果。
6.4 对抗样本子空间(Adversarial Subspace)
还有一个更深入的理论:对抗样本并不一定只存在于某一个特定的扰动方向上。
研究者发现,不同模型可能存在部分重叠的对抗样本子空间。
假设模型 A 和模型 B 分别存在对抗扰动集合:

如果两个集合存在交集:

那么就可能找到一个扰动,同时让两个模型分类错误。

可以把它理解为:
模型 A 有自己的脆弱方向。
模型 B 也有自己的脆弱方向。
两者的脆弱方向可能存在交集。
攻击者只需要找到一个落在共享区域中的扰动,就可能同时攻击两个模型。
因此,迁移攻击的研究重点不只是提高替代模型上的攻击成功率,还要让扰动尽可能落在不同模型共同敏感的区域。
核心算法:Jacobian-based Dataset Augmentation
先理解问题:
假设有一个目标模型:

无法访问其内部参数。
自己训练了一个替代模型:

最开始,替代模型只看到少量样本:

经过目标模型查询,你得到标签:

然后训练替代模型:

但是,只有这部分数据还不够。
替代模型可能只学习了已有样本附近的决策区域,并不能很好地近似目标模型。
因此,需要产生新的训练样本。
第一步:理解 Jacobian 矩阵
对于一个分类模型:

其中:
$C$:分类类别数量。
$F_j(x)$:模型对第 $j$ 个类别的输出。
$x$:输入样本。
Jacobian 矩阵表示:

它描述的是:
输入的每一个维度发生微小改变时,模型每一个类别的输出会发生怎样的变化。
例如 MNIST 图片:

将图片展平:

假设有 10 个类别,那么:

其中第 3 行表示模型输出中类别 3 对 784 个像素的敏感程度。
第二步:寻找敏感方向
假设目标模型返回:

表示模型认为当前图片属于数字 3。
我们在替代模型的 Jacobian 中,选择类别 3 对应的一行:

然后取符号:

得到一个与原始图片维度相同的方向向量。
第三步:生成新的合成样本

通过对抗样本攻击的学习,发现自己之前对对抗样本的理解还是有些片面。以前更多关注FGSM、PGD这类白盒攻击怎么利用梯度“找弱点”,却较少去想迁移攻击为什么能把在一个模型上构造的样本用到另一个模型上,也没充分意识到经典黑盒攻击在看不到模型内部、查询受限时,仍可能通过迁移、查询以及一些不起眼的功能组合找到可乘之机。
本文的意义在于帮助读者把“威胁模型—攻击目标—算法选择—防御评估”串起来,明白对抗样本研究不只是追求攻击成功率,而是为了更真实地评估模型鲁棒性和安全部署风险。
白盒攻击暴露的是模型脆弱点,黑盒和迁移攻击提醒我们现实中的攻击可能性不能低估;以后无论是设计模型还是做防御,都要多考虑攻击成本、迁移性和自适应攻击,用攻防博弈的眼光看模型安全。当然,本文讨论有特定的威胁模型和实验条件,不能直接认为某个模型一定存在漏洞,有时候问题不在于防护没做好,而是一些正常功能组合在一起产生了意想不到的效果。如有理解不准确的地方,也欢迎师傅们交流指正。