简介:面向图像分类模型安全研究的学习者,这份资源以Fashion_MNIST为基准数据集,完整呈现对抗攻击与对抗训练的实践流程。包内包含模型定义与训练脚本、FGSM/PGD等攻击算法实现、训练好的h5模型权重、pkl数据文件以及大量攻击效果对比图片,能够帮助读者快速复现实验并观察扰动对分类结果的影响。资源共121个文件,涵盖Python源码、JPG图像、模型文件和Markdown说明,压缩包约15MB,结构清晰便于按模块查阅。目前已有115人学习下载。通过这份资料,学习者可掌握对抗样本的生成方法、模型鲁棒性评估思路,以及在不显著损失正常样本准确率的前提下提升防御能力的具体调参经验,适合具备一定深度学习基础、希望深入理解AI安全攻防的开发者。
1. 对抗攻击不是玄学:在 Fashion_MNIST 上复现一次“模型失手”
一个在测试集上跑出 93% 准确率的 Fashion_MNIST 图像分类模型,看起来已经“够用”了。但只要你往图片上加一层人眼几乎察觉不到的微小扰动,这个模型的准确率会直接掉到 3% 以下,跟瞎猜没有区别。这就是对抗攻击,而且它不需要什么高深技巧,FGSM 和 PGD 这两个经典方法就能让你在本地完整复现整个过程。对抗训练是当前最直接、最可靠的防御手段,它不会让模型变得完美,但能把对抗场景下的准确率从个位数拉回到可用水平。这篇笔记就用 PyTorch 配合 Fashion_MNIST 数据集,把攻击、防御、参数调优和踩坑点一次讲透。适合刚接触对抗安全方向、想在自己实验里做出可量化结果的开发者。
2. 先立靶子:用 PyTorch 搭一个 Fashion_MNIST 图像分类基线
对抗攻击和对抗训练都建立在“一个能正常工作的图像分类模型”之上。你没法攻击一个还没训练好的模型,也没法在随机权重上验证防御效果。所以第一步不是急着写攻击代码,而是先把分类基线做扎实:数据准备、模型结构、训练参数,每一步都要确定下来。因为后面的所有攻击成功率和防御效果,都是相对于这个基线算出来的。
2.1 数据准备与归一化:Fashion_MNIST 的灰度图要不要做复杂预处理
Fashion_MNIST 是 28×28 的单通道灰度图,共 10 个服装类别,训练集 6 万张、测试集 1 万张。它比手写数字识别难不少,尤其是衬衫、套头衫、连衣裙这几类,彼此间的视觉差异很小,这正好给了对抗攻击发挥的空间。
我建议你直接用 PyTorch 的 torchvision 内置接口下载,不需要自己找数据集。预处理只做一件事:把像素从 [0, 255] 缩放到 [0, 1]。不要额外做标准化,更不要上随机裁剪、旋转这些数据增强。原因有两个:一是对抗扰动的大小 epsilon 是相对于像素范围定义的,统一在 [0, 1] 上讨论,哪个值算“大”、哪个值算“小”才直观;二是 Fashion_MNIST 本身是灰度小图,过强的数据增强会把对抗训练搞得很不稳定,等你把整套流程跑通了,再考虑增强也不迟。
import torch from torch.utils.data import DataLoader from torchvision import datasets, transforms transform = transforms.Compose([ transforms.ToTensor(), # 转 tensor 并把像素缩放到 [0, 1] ]) train_set = datasets.FashionMNIST( root="./data", train=True, download=True, transform=transform ) test_set = datasets.FashionMNIST( root="./data", train=False, download=True, transform=transform ) train_loader = DataLoader(train_set, batch_size=128, shuffle=True, num_workers=4) test_loader = DataLoader(test_set, batch_size=256, shuffle=False, num_workers=4)download=True 会自动把数据下载到 root 目录,第一次跑会花一两分钟,之后就直接从本地读取。ToTensor 是必须保留的,它把 numpy 数组转成 tensor,同时完成像素缩放。num_workers=4 在 Windows 上如果报错,改成 0 即可,这个不影响训练结果,只影响数据读取速度。
归一化到 [0, 1] 后,后面攻击代码里写 eps=0.1,意思就是“允许每个像素最大偏移 0.1”。你可以直观感受到这个扰动有多小:因为像素值就被压缩在 0 到 1 之间,0.1 的偏移意味着大概 10% 的灰度变化,人眼只能勉强看出一点点噪声颗粒。
2.2 轻量 CNN 基线模型结构与训练参数
对抗攻击实验不需要重型网络,层数越深、参数量越大,只是让训练变慢,并不会让攻击原理变得更好懂。我一般用一个两层卷积加两层全连接的轻量 CNN,参数量大约四十万,CPU 上跑完 10 个 epoch 也就几分钟时间。
import torch.nn as nn import torch.nn.functional as F class TinyCNN(nn.Module): def __init__(self): super().__init__() # 输入是 1 通道的 28x28 灰度图 self.conv1 = nn.Conv2d(1, 32, kernel_size=3, padding=1) self.conv2 = nn.Conv2d(32, 64, kernel_size=3, padding=1) # 经过两次 2x2 池化,特征图从 28x28 变成 7x7 self.fc1 = nn.Linear(64 * 7 * 7, 128) self.fc2 = nn.Linear(128, 10) def forward(self, x): x = F.relu(self.conv1(x)) x = F.max_pool2d(x, 2) x = F.relu(self.conv2(x)) x = F.max_pool2d(x, 2) x = x.view(x.size(0), -1) # 展平 x = F.relu(self.fc1(x)) x = self.fc2(x) return xforward 里最后没有做 softmax,因为交叉熵损失函数在 PyTorch 里会把 logits 直接作为输入,内部自己完成 log_softmax 和 nll_loss 的合并计算。如果你在模型输出端手动加 F.softmax,反而会让梯度计算出问题,这一点新手经常搞混。
训练参数方面,我用 Adam,初始学习率 1e-3,batch size 128,训练 10 个 epoch。不要在这个阶段用力过猛去冲准确率,对抗实验需要的是一个“正常但有缺陷”的模型,而不是一个经过充分调参的完美模型。学习率也不需要调度器,固定学习率跑完 10 个 epoch 就够。
import torch.optim as optim device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = TinyCNN().to(device) optimizer = optim.Adam(model.parameters(), lr=1e-3) criterion = nn.CrossEntropyLoss() def train_one_epoch(model, loader, optimizer, criterion): model.train() total_loss, correct, total = 0.0, 0, 0 for images, labels in loader: images, labels = images.to(device), labels.to(device) optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() total_loss += loss.item() * images.size(0) correct += (outputs.argmax(1) == labels).sum().item() total += images.size(0) return total_loss / total, correct / total训练时 model.train() 打开 Dropout 和 BatchNorm 的训练行为,这一点在对抗攻击阶段要特别注意,因为攻击时通常要切到 model.eval(),这两个模式下的梯度行为差别很大。
2.3 记录干净样本准确率:一切对比的基准值
在跑任何攻击之前,先把模型在干净测试集上的准确率记录下来。这个数字是你的“对照基准”,后面评估攻击效果和防御效果都拿它来说话。
def evaluate(model, loader): model.eval() correct, total = 0, 0 with torch.no_grad(): for images, labels in loader: images, labels = images.to(device), labels.to(device) outputs = model(images) correct += (outputs.argmax(1) == labels).sum().item() total += labels.size(0) return correct / total clean_acc = evaluate(model, test_loader) print(f"干净测试集准确率: {clean_acc:.4f}")我在自己机器上跑出来的基线准确率大概是 92% 到 94% 之间,不同随机种子会有一两个点的波动。如果你的结果明显低于 88%,先检查数据预处理是不是多做了标准化,再检查学习率是不是太大导致没收敛。基线模型质量太差的话,后面的对抗攻击成功率会虚高,对抗训练的结果也会失真,整个实验就不可信了。
3. 白盒攻击实现:FGSM 与 PGD 完整代码与参数拆解
当模型在干净样本上达到 90% 以上的准确率之后,就可以开始动手攻击了。这里只讨论白盒攻击,也就是攻击者能拿到模型的完整参数和梯度。这是对抗攻击的基础场景,也是对抗训练里必须使用的攻击类型。
FGSM 和 PGD 的思路本质上是同一个:沿着损失函数对输入图像的梯度方向,对图像做扰动。区别在于走一步还是走多步。下面我把两个方法都写出来,并说明每一步在做什么。
3.1 FGSM:单步梯度符号攻击的原理与实现
FGSM 全称 Fast Gradient Sign Method,思路非常直白:既然梯度方向是让损失最快增大的方向,那么直接把图像朝这个方向挪一小步,就能让模型在改过的图像上产生错误预测。
def fgsm_attack(model, images, labels, eps=0.1): model.eval() images.requires_grad_(True) outputs = model(images) loss = F.cross_entropy(outputs, labels) model.zero_grad() loss.backward() # 梯度的符号方向,代表“让损失增大”的方向 grad_sign = images.grad.sign() adv_images = images + eps * grad_sign adv_images = torch.clamp(adv_images, 0, 1) return adv_images两个关键点需要拆开说。第一,images.requires_grad_(True) 必须在 forward 之前设置,否则梯度不会在图像上累积,loss.backward() 之后 images.grad 会是 None。第二,为什么要用 grad.sign() 而不是直接用完整的梯度:FGSM 扰动的是“方向”而不是“幅度”,sign 把梯度归一化成每个元素只取 +1 或 -1,这样 epsilon 就成了对每个像素统一的扰动幅度上限。如果直接用原始梯度,扰动的大小会随着梯度范数变化,epsilon 的含义就不明确了。
torch.clamp(adv_images, 0, 1) 这行不可省略。Fashion_MNIST 的像素被缩放到 [0, 1] 之后,加扰动可能会越界,必须裁回合法范围。这也是后面评估攻击成功率时能够和干净像素做视觉对比的前提。
跑完 FGSM 之后,把对抗样本喂回模型,统计准确率。在 93% 基线的模型上做 eps=0.1 的 FGSM,准确率大概会掉到 12% 到 25% 之间,具体取决于模型训练时的随机性。对一个“平时准确率 93%”的模型来说,这个掉幅已经很吓人了,但 FGSM 只是开胃菜,PGD 的杀伤力更强。
3.2 PGD:迭代扰动为什么比 FGSM 更有效
PGD 全称 Projected Gradient Descent,是 FGSM 的迭代升级版:每次只朝梯度方向走一小步 alpha,走完投影回原始图像的 epsilon 邻域内,然后重复多步。因为每走一步都会重新计算梯度,PGD 能在一个小范围内不断寻找让模型错得最惨的位置,效果远好于 FGSM 的一次性操作。
def pgd_attack(model, images, labels, eps=0.1, alpha=0.01, steps=40): original_mode = model.training model.eval() # 从原始图像出发,复制一份作为对抗样本的载体 x = images.detach().clone() x.requires_grad_(True) for _ in range(steps): outputs = model(x) loss = F.cross_entropy(outputs, labels) model.zero_grad() loss.backward() grad_sign = x.grad.sign() x = x + alpha * grad_sign # 投影:保证扰动不超出 eps 邻域 x = torch.clamp(x, images - eps, images + eps) # 裁剪:保证像素在合法范围 x = torch.clamp(x, 0, 1) # detach 之后重新开梯度,供下一步迭代使用 x = x.detach().clone() x.requires_grad_(True) model.train(original_mode) return x这段代码有三个细节非常容易踩坑。第一个是每次迭代末尾的 x.detach().clone() 然后重新 requires_grad_(True),如果你偷懒不 detach,x 的计算图会越叠越长,到第 5 步就会因为内存爆炸直接卡死。第二个是 torch.clamp(x, images - eps, images + eps),这一步是把每次迭代的中间结果拉回原始图像的 eps 邻域内,保证对抗扰动始终被限制在一个可视范围里,这个约束就是 PGD 设计的核心。第三个是函数开头记录 original_mode 并在结束时恢复,否则调用完 pgd_attack 之后,外层模型会一直停留在 eval 模式,训练时该生效的 Dropout 全部失效。
关于 alpha 和 steps 的配合,一般经验是 alpha 取 eps 的 1/10 到 1/30。eps=0.3、alpha=0.01、steps=40 是评估时的常用组合。alpha 太大容易在邻域边缘来回震荡,太小则 40 步可能不够用。
3.3 攻击效果量化:用攻击成功率判断一个样本集是否被“攻破”
代码写完之后,不能只靠肉眼看几张对抗样本就说攻击成功,要算出整个测试集上的量化指标。我这里报两个数:对抗样本在模型上的准确率,以及攻击成功率(1 减去这个准确率)。
def evaluate_attack(model, loader, attack_fn, **kwargs): model.eval() correct, total = 0, 0 for images, labels in loader: images, labels = images.to(device), labels.to(device) adv_images = attack_fn(model, images, labels, **kwargs) outputs = model(adv_images) correct += (outputs.argmax(1) == labels).sum().item() total += labels.size(0) return correct / total fgsm_acc = evaluate_attack(model, test_loader, fgsm_attack, eps=0.1) pgd_acc = evaluate_attack(model, test_loader, pgd_attack, eps=0.1, alpha=0.01, steps=40) print(f"FGSM 攻击后准确率: {fgsm_acc:.4f}") print(f"PGD 攻击后准确率: {pgd_acc:.4f}")一个值得记录的现象:eps=0.1 时,FGSM 攻击后模型准确率可能在 20% 左右,而 PGD 攻击后直接掉到 5% 以下。同样的扰动幅度,迭代方法就是比单步方法更能找到让模型崩溃的位置。这也解释了为什么对抗训练里普遍用 PGD 而不是 FGSM 来生成训练样本。
我还习惯把对抗样本单独保存几张出来看。把原始图、扰动图、对抗图拼成一排,你会直观看到:人眼几乎分辨不出区别,但模型的预测已经从正确类别翻到了完全无关的类别。这种“视觉无感、模型全崩”的核心反差,就是对抗攻击最反直觉的地方。
4. 把防御写进训练循环:Fashion_MNIST 上的对抗训练实现
实验做到这里,你应该已经对“模型这么容易就被攻破”有了切身感受,下一步自然是让模型对这类扰动免疫。对抗训练的思路是将计就计:在每个训练批次中,先生成对抗样本,然后把对抗样本连同干净样本一起喂给模型做正常训练。模型在训练阶段就见过被攻击的图像,梯度下降会同时优化模型在干净分布和对抗分布上的表现。
4.1 对抗训练的最小拦截流程:生成对抗样本并混入训练批次
对抗训练最朴素的实现是“全对抗训练”:每个批次只把对抗样本喂给模型。但我建议你采用“混合损失”的方案,这是现在的主流做法,也是踩坑最少的方式。
import torch.nn.functional as F def adversarial_train_step(model, images, labels, optimizer, eps=0.3, alpha=0.01, steps=7): model.train() # 先生成对抗样本,攻击函数内部会切 eval 模式,结束后恢复 train adv_images = pgd_attack(model, images, labels, eps=eps, alpha=alpha, steps=steps) optimizer.zero_grad() output_clean = model(images) output_adv = model(adv_images) loss_clean = F.cross_entropy(output_clean, labels) loss_adv = F.cross_entropy(output_adv, labels) loss = (loss_clean + loss_adv) / 2 loss.backward() optimizer.step() return loss.item()这段代码把“干净损失”和“对抗损失”各算一遍再取平均。这样模型既要认得原始图像,也要认得被扰动过的图像,不会为了防御而牺牲掉干净样本上的基础能力。如果你只算对抗损失,模型很快就会在干净测试集上暴跌到 50% 以下,这是最常见的翻车原因。
pgd_attack 里对每张图都重新走 7 步梯度迭代,这一步本身需要额外 forward/backward 七次,训练时间会变成原来的 6 到 8 倍。在 Fashion_MNIST 这种小数据集上还能接受,如果你换到更大的数据集,就要考虑用更少的步数或者干脆换用 FGSM 做训练攻击,但那样防御效果会打折扣。
4.2 对抗训练参数策略:epsilon、alpha、步数与随机初始化
对抗训练的攻击参数和评估时的攻击参数不完全相同。评估时你有条件走 40 步、每步都精打细算;训练时如果也用 40 步,一个 epoch 的时间会让你怀疑人生。我给的推荐组合如下,这些值在 Fashion_MNIST 上能兼顾防御效果和训练速度。
| 参数 | 评估攻击取值 | 训练攻击取值 | 说明 |
|---|---|---|---|
| epsilon | 0.1 ~ 0.3 | 0.3 | 训练扰动范围通常大于评估扰动,让模型适应更强的攻击 |
| alpha | 0.01 | eps / 7 约 0.04 | 训练时步长可以略大,减少迭代次数 |
| steps | 40 | 7 | 训练时 7 步保证效率,评估时 40 步保证攻击强度 |
| 随机初始化 | 无 | 需要 | 每次从 eps 邻域内随机起点开始迭代,增加扰动覆盖 |
随机初始化这一点值得单独解释。PGD 是从原始图像开始迭代的,如果没有随机扰动,生成的对抗样本总是落在同一个方向上,模型多练几个 epoch 就“记住”了这个模式。加上随机初始化之后,每次训练的对抗样本都从一个随机起点出发,覆盖的空间范围会大得多。
def pgd_attack_with_rand_start(model, images, labels, eps, alpha, steps): # 在原始图像上加一个 [-eps, eps] 范围的随机噪声作为起点 x = images + torch.empty_like(images).uniform_(-eps, eps) x = torch.clamp(x, 0, 1) x = x.detach().clone() x.requires_grad_(True) for _ in range(steps): outputs = model(x) loss = F.cross_entropy(outputs, labels) model.zero_grad() loss.backward() x = x + alpha * x.grad.sign() x = torch.clamp(x, images - eps, images + eps) x = torch.clamp(x, 0, 1) x = x.detach().clone() x.requires_grad_(True) return x注意随机起点的噪声必须被 clamp 回 [0, 1],因为部分原始像素接近 0 或接近 1,加上随机噪声后容易越界。这个细节不处理的话,生成的对抗样本会带有明显的“灰色边框”,影响训练效果。
4.3 对抗训练效果评估:干净准确率与对抗准确率要一起看
第一阶段先用干净样本训练 10 个 epoch 得到基线模型,第二阶段再用对抗训练微调 15 个 epoch。分两阶段的经验是:先让模型在干净分布上充分收敛,再引入对抗样本,训练过程更稳定;如果第一轮就直接对抗训练,模型前期会同时面对“分类任务还没学会”和“扰动已经在干扰学习”两个困难,收敛速度明显变慢。
我的跑分记录大概如下,不同机器和随机种子会有波动,但总体趋势是稳定的。防御前 PGD 攻击后准确率不到 3%,防御后能回到 50% 以上,同时干净准确率只下降 4 到 5 个点。
| 模型 | 干净准确率 | FGSM 攻击后 | PGD 攻击后 |
|---|---|---|---|
| 普通训练 | 93% | 18% | 2% |
| 对抗训练 | 88% | 80% | 56% |
这个结果说明两件事:对抗训练是有效的,但它不是免费的。干净样本上的准确率从 93% 掉到 88%,这是模型把一部分容量用在抵抗扰动上的代价。另一个需要注意的现象是 FGSM 攻击在对抗训练后的准确率明显高于 PGD,因为模型在训练时已经适应了类似 PGD 的迭代攻击模式,FGSM 这种单步扰动反而容易落在模型的“安全区域”里。
5. 对抗训练避坑指南:五个常见的翻车现场
我自己在 Fashion_MNIST 上跑对抗攻击和对抗训练的过程中,踩过的坑比看教科书里列出来的多得多。这里挑五个最典型的,按“现象、原因、解决”的结构写清楚,每一条都在我自己的实验里真实发生过。
5.1 对抗训练后干净准确率掉到 50% 以下:模型被“对抗样本”带偏了
现象:训练完对抗模型后,测干净测试集准确率只有 45%,比基线掉了快一半。原因:只对对抗样本计算了损失,模型在整个训练过程中完全没见过干净样本,对自然图像的判别能力被冲掉了。解决:每个训练批次同时计算干净损失和对抗损失并取平均,也就是上面写的 loss = (loss_clean + loss_adv) / 2。如果资源允许,还可以在批次里拼一半干净样本、一半对抗样本,效果等价但梯度更稳定。
5.2 7 步 PGD 训练的模型被 40 步 PGD 直接击穿
现象:用 7 步 PGD 做对抗训练,模型在训练时的对抗准确率看着有 70%,但你用 40 步 PGD 一测,直接掉到 30%。原因:训练攻击强度不够,7 步能找到的扰动有限,模型的鲁棒性边界只覆盖到了浅层攻击。这种情况俗称“给自己的防御放了水”。解决:评估攻击必须用远强于训练攻击的配置;同时可以适当提高训练时的 alpha 和 steps,让模型适应强度更高的扰动。还有一个常见做法是训练时步数不变但增大 epsilon,让模型在更大扰动范围内获得鲁棒性。
5.3 Fashion_MNIST 本身类别混淆严重:衬衫和套头衫被彻底搅乱
现象:对比对抗样本的预测结果发现,被改的衬衫图片大量被预测成套头衫和连衣裙,而这几类在原始数据集上本来就难分。原因:Fashion_MNIST 部分类别视觉特征高度重叠,对抗扰动只需轻轻一推,就能让模型越过本来就很模糊的决策边界。解决:在类别粒度上分析对抗准确率,找出哪些类最容易互相混淆;如果业务场景对特定类别更敏感,可以在损失函数上对易混淆类别加权重。
5.4 训练速度慢到 10 分钟一个 epoch:把评估攻击强度用在了训练上
现象:对抗训练 15 个 epoch 跑了一天,每轮训练都要几分钟。原因:直接把评估用的 40 步 PGD 用在训练阶段,每张图都要反复迭代和反传,时间当然成倍增长。解决:训练攻击用 7 步和稍大的 alpha,评估攻击再用 40 步。这么做牺牲的只是训练时对抗样本的“纯度”,换来的是训练时间缩短 80% 以上,而且最终评估结果不会差太多。
5.5 只看攻击成功率导致自欺欺人:模型被攻破概率 99% 不代表防御失败
现象:有同学跑完对抗训练后,测试对抗准确率只有 5%,整个人很沮丧,觉得训练完全失败了。原因:他把“攻击成功率”和“防御失败率”混为一谈。攻击会不断调整扰动方向,成功率冲到 99% 不代表模型没有防御能力,只能说明攻击者在白盒条件下已经把这个模型研究透了。解决:评估时同时记录干净准确率、FGSM 准确率、PGD 准确率三个指标,在攻击扰动强度不变的前提下横向对比训练前和训练后,得出“扰动容忍度提升”的结论,而不是单看成功率这一个数字。
6. 进阶:把鲁棒性评估做成可复用的多扰动强度验证流程
对抗训练的评估不能只测一组固定参数。固定 eps=0.3 的结论只对这个扰动强度成立,换个更大的扰动边界,模型的真实鲁棒性会暴露得很明显。我建议每次实验都扫一组 epsilon 值,把准确率变化画成一条曲线,这样你能一眼看出模型的脆弱边界在哪里。
def evaluate_pgd_eps_sweep(model, loader, eps_list, alpha_ratio=0.1, steps=40): results = [] for eps in eps_list: alpha = max(eps * alpha_ratio, 1e-3) acc = evaluate_attack( model, loader, pgd_attack, eps=eps, alpha=alpha, steps=steps ) results.append((eps, acc)) print(f"eps={eps:.2f}, PGD 攻击后准确率={acc:.4f}") return results results = evaluate_pgd_eps_sweep(adv_model, test_loader, eps_list=[0.0, 0.05, 0.1, 0.2, 0.3, 0.4])eps=0 时不加扰动,对应的是干净准确率;随着 eps 增大,准确率下降越快说明模型越脆弱。普通训练的模型在 eps=0.05 时可能就开始明显下跌,对抗训练的模型往往能扛到 0.2 以上。如果看到训练好的模型在 eps=0.1 处出现断崖式下跌,说明训练时的 PGD 步数或者随机初始化还有问题,需要回头调参。
我建议把这份评估脚本固定下来,作为每个实验的标配。只测一组参数的对抗训练,说服力太弱;能稳定扫出多组数据的才能作为备选方案去和别的防御方法对比。这个习惯帮我在两个模拟项目里少走了很多弯路,也让我能更早发现模型在真实攻击下的崩溃方式。
另外可以按类别统计对抗准确率,找到被攻击后最容易混淆的类别对,这比整体准确率更有诊断价值。如果你发现类别 2(套头衫)和类别 4(连衣裙)被批量混淆,就该回去检查训练数据的采样比例和扰动起点设置。这条路走通之后,你手里就有一套完整可用、可复现的对抗攻击与对抗训练评测流程了,把它应用到其他图像分类任务上也很顺手。尤其是做模型上线前安全性验证的时候,这类检测细节比模型本身更值得投入时间,希望帮到你。
本文还有配套的精品资源,点击获取