1. 为什么软件测试要关心对抗样本
1.1 模型测试与传统测试的差异
做软件测试做了十来年,从功能测试到接口测试再到自动化测试,我一直有个感受:测试方法论的发展永远追着被测对象跑。前几年公司开始把一个图像识别模型当作被测对象,要求测试团队给出质量评估报告,我拿着传统的等价类划分、边界值分析、判定表这些看家本事上去,第一轮就翻车了。
传统软件的逻辑是确定的。写一个 if (age >= 18),边界值就是 17、18、19,测清楚这三个数,这条规则就稳了。但模型不是这么回事,一个图像分类模型输入一张 32x32 的图片,输出的是 10 个类别的概率分布,模型参数可能上百万个,你没法用穷举法去覆盖输入空间,更没法写一个“期望结果”出来——因为连模型自己都可能不知道哪张图会触发错误行为。
这时候对抗样本就进入了我的视野。对抗样本的本质是:在原样本上叠加一层微小的、肉眼几乎无法察觉的扰动,让模型给出完全错误的预测结果。比如一张正常的猫图,加了一点点噪声之后,人看起来还是猫,模型却信心满满地判断成狗。这不是模型“坏了”,而是模型的决策边界在局部区域存在缺陷,对抗样本就是用来探测这些缺陷的探针。
对测试从业者来说,对抗样本测试可以理解为一种特殊的健壮性测试:它不验证模型“能不能用”,而是验证模型“在恶意输入下还能不能保持正确”。这在模型上线前的安全评估里非常关键,尤其是人脸识别、安防监控、自动驾驶感知这类对安全性要求极高的场景。
1.2 对抗样本到底是什么
先给完全没接触过的朋友补个基础。对抗样本(Adversarial Example)这个概念最早是 2014 年前后由 Szegedy 等人提出的,他们发现深度学习模型有一个反直觉的特性:在原始输入上加上一个精心构造的微小扰动,人眼完全看不出区别,模型却会把预测结果从正确类别翻转到错误类别。
用一个生活化的类比来解释。想象一个门禁系统靠人脸识别开门,你在脸上贴一小块特制的创可贴,创可贴上的花纹是你自己设计的,肉眼看起来就是块普通膏药,但门禁系统却把你识别成了另外一个人。这个“创可贴”就是对抗扰动,贴了创可贴的脸就是对抗样本。对模型来说,它看到的不是“一张贴了膏药的脸”,而是一组浮点数特征,这组特征在它的决策空间里正好落在了错误类别的区域。
这里有个关键点要强调:对抗样本不是随机噪声。随机往图片上撒噪点,大部分情况下模型还是能正确分类,因为噪声没有方向性。而对抗样本的扰动是沿着模型梯度的方向精确计算的,相当于找到了模型决策边界最薄弱的那个点,轻轻一推就过去了。这也是“自动化生成”这个方向能成立的原因——生成对抗样本不是靠手调像素,而是靠算法计算。
1.3 这类测试适合什么项目
不是所有项目都需要做对抗样本测试,我的经验是,判断标准有三个。
第一,被测对象是深度学习模型,且模型的输入是可微分的连续数据。图像、语音、文本嵌入向量都满足,但决策树、规则引擎这类传统模型不适用,因为梯度信息不可用。
第二,模型会暴露给不可信的外部环境。比如一个公开的图片识别 API、一个接收用户上传图片的分类系统、一个自动驾驶的视觉模块,这些场景里攻击者有真实的动机去构造对抗样本。反过来,如果模型只在内部离线跑,不对外提供服务,对抗样本测试的优先级就可以低一些。
第三,团队有能力处理测试发现的问题。对抗样本测试说白了是一个“发现问题容易,修复问题难”的领域。如果模型团队连训练数据分布都没搞清楚,你测出一堆对抗样本缺陷,他们大概率只能对着报告发愁。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 整体测试设计思路拆解
2.1 明确测试目标和测试范围
做测试的第一步永远是问“测什么”。对抗样本测试不是无脑生成一堆扰动样本看模型出不出错,而是要有明确的测试目标和范围定义。
我习惯把测试目标拆成三个层级。第一层是鲁棒性评估:模型在对抗样本下的准确率下降了多少,这是一个量化指标,可以直接写进测试报告。第二层是安全性验证:模型是否存在特定类别的系统性漏洞,比如所有“停止”标志都会被识别成“限速”标志,这类问题通常会触发安全事故。第三层是防御有效性验证:如果模型已经加了对抗训练等防御手段,我需要验证这些手段到底有没有用,防御强度够不够。
测试范围的定义则要回答几个问题:被测模型的输入输出约定是什么,是图片分类、目标检测还是语义分割;测试数据的来源是什么,用公开数据集还是业务真实数据;攻击的假设是什么,攻击者是否能拿到模型的梯度信息;允许的扰动上限是多少,这个直接决定了测试的严苛程度。
举个例子,如果被测的是一个人脸识别模型,输入是 112x112 的 RGB 图像,输出是特征向量或分类结果。那么我的测试范围可能是:在 L_infinity 范数下,扰动不超过 8/255 像素,测试模型在 1000 张人脸样本上的识别错误率。这个扰动上限不是拍脑袋定的,它是攻击者“肉眼无感知”和“物理可实现”的边界。
2.2 攻击类型与测试用例设计
对抗样本生成算法的分类方式很多,从测试设计的角度,我建议从两个维度来理解:攻击者知道什么信息,攻击者想达到什么目的。
按信息掌握程度分,有白盒攻击和黑盒攻击。白盒攻击假设攻击者知道模型的全部参数和梯度,可以精确计算扰动方向,攻击效率高,代表方法是 FGSM、PGD。黑盒攻击假设攻击者只能通过 API 调用拿到模型的预测结果,需要通过查询来估计梯度或者迁移攻击,代表方法是基于代理模型的迁移攻击、基于查询的边界攻击。实际测试中,即使模型部署后不暴露梯度,训练好的模型文件也可能泄露,或者攻击者可以训练一个替代模型来近似目标模型的行为,所以这两种场景都应该覆盖。
按攻击目的分,有非目标攻击和目标攻击。非目标攻击只要求模型把样本错分成任意一个错误类别,目标攻击要求模型把样本错分成指定的类别,比如要求模型把“停止”标志识别成“限速”标志。目标攻击的难度更高,也更贴近真实的安全场景。
从测试用例设计的角度看,我习惯把每种攻击算法映射成一组测试用例,用例的核心字段包括:测试数据批次、攻击算法、扰动预算、迭代次数、攻击目标、预期判定。这里关键的设计思路是:对抗样本测试用例不是静态的,同一个原始样本在参数变化后会产生完全不同的测试效果,所以用例设计必须和参数空间绑定。
| 攻击类型 | 信息假设 | 目标类型 | 典型应用场景 |
|---|---|---|---|
| FGSM | 白盒,单步梯度 | 非目标/目标 | 快速筛查模型的脆弱点 |
| PGD | 白盒,多步迭代 | 非目标/目标 | 评估模型的最坏情况鲁棒性 |
| BIM | 白盒,多步迭代 | 非目标 | FGSM 的迭代加强版 |
| DeepFool | 白盒,最小扰动 | 非目标 | 寻找最小对抗扰动 |
| C&W | 白盒,优化求解 | 目标 | 验证防御机制的有效性 |
| 迁移攻击 | 黑盒,代理模型 | 非目标/目标 | 模拟真实攻击者的能力 |
2.3 自动化生成的整体流程
自动化是这个方向的核心诉求。手动画一个样本的扰动再丢给模型测试,一次两次还能忍,要覆盖一千张测试图片、五种攻击算法、三档扰动预算,手工操作根本不可行。整体流程我拆成五个环节。
数据准备。选择或构造测试数据集,可以是公开数据集的一部分,也可以是业务侧提供的真实样本。数据要覆盖模型的各个类别,并且要保证原始样本能被模型正确分类——如果一个正常样本模型本身就判错,拿它测对抗样本就没有意义了。
模型加载。把被测模型加载进测试环境,获取模型的预测输出和梯度信息。这里要注意的是,被测模型可能是训练好的 pt 文件、onnx 模型或者部署在远端服务的 API,不同的存在形式决定了测试脚本的写法。
攻击生成。这是核心环节,根据测试配置调用对应的攻击算法,对每个原始样本生成对抗样本。攻击算法本质上是一个优化问题:在扰动约束下,最大化模型损失函数。实现这一步需要能够计算模型关于输入的梯度。
质量评估。对生成的对抗样本做两个层面的评估:从模型角度看,攻击成功率是多少、模型平均置信度下降了多少;从样本角度看,扰动是否在预算范围内、对抗样本和原始样本的差异是否能被人眼感知。
报告输出。汇总所有测试结果,生成结构化的测试报告,包含样本可视化、攻击前后预测结果对比、指标统计、缺陷清单。报告要能让模型团队的同事一眼看出问题在哪。
这五个环节只要跑通一次,后面就是改配置、跑批量、看报告的循环。我下面会详细讲每个环节的具体实现。
3. 自动化生成对抗样本的工程实现
3.1 环境搭建与工具选型
工欲善其事,必先利其器。我用的技术栈是 Python 3.9 + PyTorch 2.0 + torchvision,配合一套测试自研的框架代码。选择 PyTorch 主要原因是它的动态图和自动求导机制对实现攻击算法非常友好,计算梯度只需要一行 loss.backward()。TensorFlow 也能做,但 API 设计上绕一些,调试体验不如 PyTorch 直观。
工具库方面,IBM 开源的 Adversarial Robustness Toolbox(ART)是个不错的参考,里面实现了大量现成的攻击算法,可以直接调用。但我在实际项目中只把它当参考实现,不完全依赖它,原因是 ART 的抽象层级比较高,出了问题不容易定位,而且它的接口设计和我们公司的模型加载方式经常不匹配。自己写核心攻击代码,依赖少,可控性强,出了问题一眼就能看出来。
硬件方面,如果只有 CPU,小规模测试也能跑,但速度会慢很多。我自己开发时用一台带 RTX 3080 的机器,跑 1000 张 CIFAR-10 图片的 FGSM 生成加测试,大概两三分钟。如果模型更大、数据更多,建议用 GPU 集群或者云 GPU 实例。
目录结构我习惯这样组织:
text复制adversarial_testing/
├── configs/ # 测试配置,yaml 格式
│ └── cifar10_test.yaml
├── attacks/ # 攻击算法实现
│ ├── fgsm.py
│ ├── pgd.py
│ └── __init__.py
├── models/ # 被测模型加载
│ └── model_loader.py
├── utils/ # 工具函数
│ ├── metrics.py
│ └── visualization.py
├── data/ # 测试数据
├── output/ # 测试结果输出
├── run_test.py # 主入口
└── requirements.txt
3.2 FGSM 快速梯度符号法实现
FGSM(Fast Gradient Sign Method)是必学的第一个攻击算法,也是所有后续迭代攻击的基础。它的思想非常直白:既然模型学习时沿着梯度方向更新权重,那攻击时就沿着梯度的反方向扰动输入。
实现的数学表达式是:
text复制x_adv = x + epsilon * sign(gradient(Loss(x, y_true)))
其中 epsilon 是扰动幅度,sign 是符号函数,把梯度向量压缩成每个维度取 +1 或 -1。这样算出来的扰动虽然幅度固定,但方向精准地指向了模型损失增大的方向。
我写的一个最小可运行版本:
python复制import torch
import torch.nn.functional as F
def fgsm_attack(model, images, labels, epsilon=0.03):
"""
FGSM 非目标攻击
model: 被测模型,已经设为 eval 模式
images: 输入图片张量,范围 [0, 1]
labels: 真实标签
epsilon: 扰动幅度
"""
model.eval()
images.requires_grad = True
# 前向传播,计算损失
outputs = model(images)
loss = F.cross_entropy(outputs, labels)
# 反向传播,得到梯度
model.zero_grad()
loss.backward()
# 获取梯度符号并生成对抗样本
data_grad = images.grad.data
signed_grad = data_grad.sign()
adv_images = images + epsilon * signed_grad
# 裁剪回有效范围
adv_images = torch.clamp(adv_images, 0, 1)
return adv_images
这段代码看着简单,但有几个坑我踩过。第一个坑是 images.requires_grad = True 必须设置,否则 images.grad 会是 None。第二个坑是模型一定要在 eval 模式,如果在训练模式,BatchNorm 和 Dropout 的行为会改变模型输出,生成出来的对抗样本不准确。第三个坑是生成完成后要 clamp(0, 1),因为图片像素值有范围约束,超出范围的扰动在物理上不可实现。
epsilon 的取值直接影响攻击效果和样本质量。我一般从 0.01 开始,逐步增大到 0.05、0.1,观察模型准确率的变化曲线。CIFAR-10 上训练充分的 ResNet 模型,epsilon 取 0.03 时,FGSM 攻击成功率能做到 40% 到 60%。如果 epsilon 太大,比如超过 0.1,人眼已经能明显看出图片失真,这就不符合对抗样本“难以察觉”的前提了。
3.3 PGD 迭代攻击实现
FGSM 只是单步攻击,相当于只推了一下门,门没推开就放弃了。PGD(Projected Gradient Descent)是 FGSM 的迭代加强版:每步走一小步,走完检查一下有没有超出扰动预算,超了就投影回来,重复多轮。打个比方,FGSM 是拿着一把尺子一下拍过去,PGD 是用小锤子一个点一个点地敲,敲到位置不对就调整方向继续敲,每一步都确保不超出“扰动范围”这个圈子。
PGD 的核心是“多步小步 + 投影约束”,公式如下:
text复制x_adv_{t+1} = clip(x_adv_t + alpha * sign(gradient(Loss(x_adv_t, y_true))),
x - epsilon, x + epsilon)
每次迭代的步长 alpha 通常取 epsilon 的一个比例,比如 epsilon=0.03 时,alpha 取 0.005,迭代 10 到 20 轮。每一步之后都做一次 clip,把扰动限制在原始样本的 epsilon 邻域内。
python复制def pgd_attack(model, images, labels, epsilon=0.03, alpha=0.005, iters=10):
"""
PGD 非目标攻击
"""
model.eval()
# 保存原始样本,用于扰动约束
original_images = images.clone().detach()
# 随机初始化扰动,先给一个随机起点
adv_images = images.clone().detach() + torch.empty_like(images).uniform_(-epsilon, epsilon)
adv_images = torch.clamp(adv_images, 0, 1)
for _ in range(iters):
adv_images.requires_grad = True
outputs = model(adv_images)
loss = F.cross_entropy(outputs, labels)
model.zero_grad()
loss.backward()
data_grad = adv_images.grad.data
signed_grad = data_grad.sign()
# 沿梯度方向走一小步
adv_images = adv_images.detach() + alpha * signed_grad
# 投影约束:先限制在原始样本邻域内,再限制在有效像素范围
adv_images = torch.clamp(adv_images, original_images - epsilon, original_images + epsilon)
adv_images = torch.clamp(adv_images, 0, 1)
return adv_images.detach()
注意这里有个细节:每次迭代都要重新设置 requires_grad,因为前一步的 detach() 把计算图断开了。如果不想每轮都处理这些细节,可以写一个循环,每轮复制一份新的叶子张量。
PGD 的攻击效果比 FGSM 强很多,但代价是计算量大。同样是 CIFAR-10 的 ResNet 模型,10 轮迭代的 PGD 在 epsilon=0.03 下攻击成功率通常能做到 95% 以上。这也是为什么业界评估模型鲁棒性时,最低标准是 PGD 而不是 FGSM——FGSM 防住了不代表 PGD 防住了。
| 指标 | FGSM | PGD |
|---|---|---|
| 迭代次数 | 1 | 10-20 |
| 计算开销 | 低 | 中高 |
| 攻击成功率 | 中 | 高 |
| 实现难度 | 低 | 中 |
| 用途 | 快速筛查 | 深度评估 |
3.4 自动化测试脚本与批量执行
单个攻击函数只能生成一个对抗样本,要形成“测试”的能力,必须把整个流程串起来。我写了一个主入口脚本,负责读取配置、加载模型和数据、执行攻击、统计指标、输出报告。
python复制import argparse
import json
import torch
import torchvision
from torchvision import transforms
from attacks.fgsm import fgsm_attack
from attacks.pgd import pgd_attack
from models.model_loader import load_model
from utils.metrics import compute_accuracy, compute_attack_success_rate
def load_test_data(batch_size=64):
"""加载 CIFAR-10 测试集子集"""
transform = transforms.Compose([
transforms.ToTensor(),
])
dataset = torchvision.datasets.CIFAR10(
root="./data", train=False, download=True, transform=transform
)
# 这里简化处理,取前 1000 张做测试
subset = torch.utils.data.Subset(dataset, range(1000))
loader = torch.utils.data.DataLoader(subset, batch_size=batch_size, shuffle=False)
return loader
def evaluate_attack(model, loader, attack_fn, attack_name, epsilon, device):
"""执行一种攻击算法并统计结果"""
total = 0
correct_before = 0
correct_after = 0
for images, labels in loader:
images, labels = images.to(device), labels.to(device)
# 原始样本的准确率
outputs = model(images)
pred_before = outputs.argmax(dim=1)
correct_before += (pred_before == labels).sum().item()
# 生成对抗样本并测试
adv_images = attack_fn(model, images, labels, epsilon=epsilon)
outputs_adv = model(adv_images)
pred_after = outputs_adv.argmax(dim=1)
correct_after += (pred_after == labels).sum().item()
total += labels.size(0)
acc_before = correct_before / total
acc_after = correct_after / total
attack_success_rate = 1.0 - correct_after / max(correct_before, 1)
print(f"[{attack_name}] epsilon={epsilon} | 原始准确率: {acc_before:.4f} | "
f"对抗后准确率: {acc_after:.4f} | 攻击成功率: {attack_success_rate:.4f}")
return {
"attack": attack_name,
"epsilon": epsilon,
"original_accuracy": acc_before,
"adversarial_accuracy": acc_after,
"attack_success_rate": attack_success_rate,
}
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--config", type=str, default="configs/cifar10_test.yaml")
parser.add_argument("--device", type=str, default="cuda")
args = parser.parse_args()
device = torch.device(args.device if torch.cuda.is_available() else "cpu")
# 加载被测模型,这里以 ResNet18 为例
model = torchvision.models.resnet18(weights=torchvision.models.ResNet18_Weights.DEFAULT)
model.fc = torch.nn.Linear(model.fc.in_features, 10)
model = model.to(device).eval()
# 加载测试数据
loader = load_test_data()
# 执行测试计划
results = []
epsilons = [0.01, 0.03, 0.05]
for epsilon in epsilons:
results.append(evaluate_attack(model, loader, fgsm_attack, "fgsm", epsilon, device))
results.append(evaluate_attack(model, loader, pgd_attack, "pgd", 0.03, device) if epsilon == 0.03 else None)
# 输出汇总
with open("output/results.json", "w") as f:
json.dump([r for r in results if r], f, indent=2)
if __name__ == "__main__":
main()
这个脚本是我经过多轮迭代后精简出来的版本。实际项目中你可能需要加入更多东西:比如对每个错误样本的图片保存、对抗样本和原始样本的 PSNR/SSIM 计算、把结果输出成 HTML 报告等。但核心骨架就是这样一个循环:加载数据、算梯度、做扰动、测指标。
这里有个工程实践上的建议:批量执行时一定要有断点续跑的概念。我跑 5000 张图片的 PGD 攻击时遇到过中途 GPU 显存溢出,如果结果没有实时保存,前面所有计算都白费了。稳妥的做法是每处理完一个 batch 就把中间结果存到磁盘,最后统一汇总。
4. 测试结果分析与回归策略
4.1 结果指标怎么看
生成对抗样本之后,怎么判断测试结论?我主要看三个维度的指标。
第一个是攻击成功率。这个指标最直观,但在看它之前,必须先确认原始样本在干净状态下能被模型正确分类。如果一个模型对干净样本的准确率只有 60%,那么它对抗样本准确率掉到 40% 并不能完全归咎于对抗攻击。所以我在报告里会同时列出原始准确率和对抗后准确率。
第二个是置信度变化。有些对抗样本没有让模型错误分类,但模型的置信度从 0.95 掉到了 0.51,这同样是安全风险——一个犹豫不决的模型在关键时刻可能做出错误的判断。我的做法是统计所有样本的平均置信度下降幅度,超过某个阈值就标记为“置信度异常”。
第三个是扰动质量。对抗样本不只是用来攻击的,它也是模型缺陷的可视化证据。我会计算对抗样本与原始样本的峰值信噪比(PSNR)和结构相似性(SSIM),如果扰动幅度很小但攻击成功率很高,说明模型决策边界存在严重的脆性问题,这种问题比大扰动攻击才能成功的情况更值得优先修复。
什么算“测试不通过”?我的经验是要和业务方一起定义阈值。比如一个图片分类服务,业务方可能认为“对抗样本攻击成功率不超过 10%”是可以接受的,因为实际场景中攻击者要精确构造对抗样本并不容易;但如果是人脸支付场景,可能“任何一例对抗样本导致的错误识别”都不能接受。测试的交付物不是一堆数字,而是对“这个模型能不能上线”这个问题的回答。
4.2 生成缺陷报告
对抗样本测试发现的问题,本质上也是缺陷,但它的呈现方式和传统缺陷很不一样。传统缺陷只要描述清楚复现步骤和期望结果就行,对抗样本缺陷必须附带可视化证据和量化数据。
我的缺陷报告模板包含这几个字段:
| 字段 | 内容示例 |
|---|---|
| 缺陷编号 | ADV-2024-0001 |
| 被测模型 | ResNet18-CIFAR10-v2 |
| 攻击算法 | PGD,epsilon=0.03,iterations=10 |
| 原始样本 | 左侧展示原图 |
| 对抗样本 | 右侧展示扰动后的图 |
| 扰动可视化 | 中间展示差值放大图 |
| 原始预测 | cat (置信度 0.93) |
| 对抗预测 | dog (置信度 0.87) |
| 严重程度 | 高 |
| 可能影响 | 恶意构造输入可导致分类服务误判 |
把对抗样本的可视化做到位比写多少字都管用。模型团队看到一张原图和一张对抗样本图放在一起,肉眼几乎分不出区别,但模型却给出了完全不同的结果,这个冲击力比一串攻击成功率的数字强得多。我在输出报告时会把每个错误样本的图片直接嵌到 HTML 页面里,方便相关同事在浏览器里查看。
4.3 回归与持续跟踪
对抗样本测试不应该是一次性的,它应该像功能回归测试一样,在模型每次更新后重新执行。
模型团队修复对抗样本缺陷的常见手段是“对抗训练”,就是把生成的对抗样本混入训练集让模型重新学习。这种修复方式的效果需要验证:一方面要验证之前生成的对抗样本现在是否已经无法攻击成功,另一方面要验证模型是否有新的脆弱点——因为对抗训练有时会收紧模型对某些扰动的鲁棒性,却放松了其他方向的防御。
我的做法是在模型更新后自动跑一遍回归测试脚本,用一套固定的“回归数据集”对比攻击成功率的变化。回归数据集包含之前所有测试中发现的对抗样本,再加上一套固定的原始测试集。如果攻击成功率从 30% 降到了 5%,说明修复有效;如果从 30% 升到了 50%,说明修复引入了新的问题,需要打回给模型团队。
自动化流水线是保证这个机制能够持续运转的关键。我在公司内部把对抗样本测试接入了 CI/CD 的定时任务,每天晚上跑一次,生成报告并发送到项目群。模型团队每次提交新模型时,也能手动触发测试,在合并代码之前看到安全性评估结果。这样一来,对抗样本测试就不再是“上线前补一次”,而是变成了一个常态化的质量门禁。
5. 常见问题与排查技巧实录
5.1 问题速查表
对抗样本测试从搭建环境到落地执行,会遇到不少稀奇古怪的问题,我把踩过的坑整理成了速查表。
| 问题现象 | 常见原因 | 解决方法 |
|---|---|---|
| 模型加载时报权重维度不匹配 | 预训练模型输出类别数和业务数据类别数不一致 | 检查最后一层全连接层的输出维度,按需替换 |
| 生成的对抗样本攻击成功率极低 | 模型处于训练模式,BN 层统计信息被更新 | 调用 model.eval() 并检查是否有 dropout |
| 保存对抗样本后重新加载无法复现攻击效果 | 保存时经过了归一化或格式转换导致像素值变化 | 统一保存和加载的预处理流程,建议保存原始张量 |
| 图像梯度出现大量 NaN | 输入包含 NaN 像素或学习率设置不合理 | 检查数据前处理,使用 torch.nan_to_num 处理梯度 |
| 黑盒攻击时查询次数过多导致接口限流 | 单样本查询次数过高 | 使用批量查询、降低迭代次数、采用代理模型迁移攻击 |
| GPU 显存不足 | batch size 太大或输入图像分辨率太高 | 降低 batch size、使用梯度累积或切换到 CPU 分批计算 |
| 测试结果不稳定,两次跑成功率差很多 | 模型有随机性(如 Dropout)或数据加载顺序不同 | 固定随机种子、统一数据加载逻辑 |
| 对抗样本人眼可见明显噪声 | epsilon 或迭代步长设置过大 | 降低扰动预算,检查是否符合“难以察觉”的约束 |
5.2 环境与版本选择心得
PyTorch 和 torchvision 的版本兼容问题是我最头疼的。建议直接用同一份 requirements.txt 锁死版本,不要什么新装什么。我目前用的组合是 torch 2.0.1 + torchvision 0.15.2,配合 CUDA 11.8,跑 CIFAR-10、ImageNet 相关的模型都没遇到兼容性问题。如果你要加载的是别人训练好的模型,对方用什么版本的 PyTorch 存的,最好就用兼容的版本来加载,否则可能报序列化版本不匹配的错误。
还有一个小技巧:模型文件加载时先 model.eval() 再跑测试,这个问题我见过太多次了。很多模型的训练脚本里最后忘记调用 eval(),部署时也没发现,但一旦跑对抗样本测试,BatchNorm 在训练模式和评估模式下的行为差异会直接影响梯度计算,导致生成的对抗样本质量很差。
5.3 样本质量与指标陷阱
最后聊聊一个容易被忽视的问题:对抗样本测试的质量评估,本身也需要质量评估。
有一次我在模型团队提交的模型上跑 FGSM,攻击成功率只有 9%,团队很高兴,觉得模型很安全。但我把对抗样本可视化出来发现,epsilon 取 0.03 时,部分样本的猫已经出现了明显的色块偏移,人眼能轻易看出异常。这说明模型并不是真的鲁棒,它只是对“大扰动”不敏感,如果攻击者用更精细化的小扰动,效果可能完全不同。
还有一次我犯了个错误:拿模型训练时的验证集做对抗样本测试的数据源。验证集和训练集分布太接近,模型在这些样本上的表现本身就比较好,对抗样本攻击的成功率不能代表模型在真实场景中的表现。后来我改成从业务侧的真实数据中采样,效果数据才更有参考意义。
所以我现在对测试结果的态度是:从不多看单一指标下结论。每次测试都会检查三样东西:攻击成功率、扰动平均幅度、对抗样本的视觉质量。只有当扰动足够小、攻击成功率足够高、样本视觉质量足够“像原图”时,我才会判定这个模型存在真实的安全缺陷。如果只是攻击成功率一个指标高,但扰动大到肉眼一眼就能识别,那只能说明模型对“有损输入”不够鲁棒,跟对抗攻击的安全性关系不大。
从个人实践来说,我现在已经把这个流程沉淀成了一套可复用的测试方案,从一个 idea 变成了支撑模型发布决策的例行检查。如果你所在的团队正好在做模型测试,建议先从 FGSM 加 PGD 这两种算法起步,配上 1000 张左右的测试样本,把流水线跑通,再逐步扩展攻击算法库和更大规模的数据集。这个过程不难,但迈出第一步之后,你对模型安全性的理解会完全不同。
