FGSM & PGD
FGSM (Fast Gradient Sign Method) 和 PGD (Projected Gradient Descent) 都是 基于梯度的白盒攻击方法(Gradient-based White-box Attacks) ,因此我们放在一个章节讲。FGSM是开山鼻祖,PGD是最为常用的加强版。
FGSM和PGD系列的攻击算法时间表如下:
| 年份 | 算法名称 | 核心论文 | 关键里程碑 / 贡献 |
|---|---|---|---|
| 2013.12 | L-BFGS | Intriguing properties of neural networks | 开山之作 。首次发现对抗样本的存在,利用二阶优化算法寻找扰动。 |
| 2014.12 | FGSM | Explaining and Harnessing Adversarial Examples | 效率革命 。Goodfellow 提出线性假设,将攻击转化为单步梯度计算,极大降低了成本。 |
| 2015.11 | DeepFool | DeepFool: a simple and accurate method to fool deep neural networks | 精确度提升 。通过迭代将样本推向最近的决策边界,生成的扰动比 FGSM 更小、更难察觉。 |
| 2016.07 | BIM / I-FGSM | Adversarial examples in the physical world | 迭代化 。首次将 FGSM 拆解为多个小步,并证明了对抗样本在物理世界(如打印出来再拍照)依然有效。 |
| 2016.08 | C&W Attack | Towards Evaluating the Robustness of Neural Networks | 打破防御 。针对当时的“蒸馏防御”设计,将攻击转化为极致的优化问题,是衡量模型鲁棒性的“黄金标准”。 |
| 2017.06 | PGD | Towards Deep Learning Models Resistant to Adversarial Attacks | 最强攻击/防御基础 。引入随机初始化和投影,证明了 PGD 是\(L_\infty\)约束下的“一阶最优攻击”,也是对抗训练的核心。 |
| 2017.10 | MIM | Boosting Adversarial Attacks with Momentum | 迁移性增强 。将动量引入迭代过程,解决了迭代攻击容易陷入局部解的问题,大幅提升了黑盒攻击的成功率。 |
。
数学原理
核心思想
在训练模型时,我们的目标是找到最优参数 \(\theta\),使得预测结果与真实标签 \(y\) 的差距最小。在训练模型时,我们根据固定输入\(x\),调整参数\(\theta\),并通过梯度下降,沿着负梯度方向更新\(\theta\);又或者说,我们通过减少Loss,来让模型预测结果向正确标签\(y\)靠拢:
而在对抗攻击中,我们假定模型是已经训练好的,我们根据已经固定了的\(\theta\),来调整输入\(x\);我们的目标是找到一个微小的扰动\(\Delta x\),让损失函数最大化;又或者说,我们的目标是让模型的预测结果远离正确标签\(y\):
这便是对抗攻击的基本思想。
在对抗攻击中,有两个核心的变量:
- 最小化扰动\(\Delta x\)。人眼看不出区别,隐蔽性强。
- 最大化Loss。模型必须分类错误。
在数学上,这是一个约束优化问题。我们通常通过固定其中的一个,去优化另一个。
我们都知道,神经网络非常复杂。Goodfellow提出了一个非常神奇的观点:在极高维的空间中,ReLU、Sigmoid等激活函数和多层级结构并非高度扭曲的非线性的,而是被拉直的近似线性的。
这种考虑并不是为了严谨的数学证明,而是为了实践。如果我们考虑曲线的每一个弯折,神经网络的计算量会爆炸。Goodfellow认为,虽然模型整体是非线性的,但是在对抗攻击这个微小的扰动范围内,非线性(弯曲程度)并不是导致失败的主因,线性(斜率)才是。只要模型在局部有一点点斜率,在高维空间下,这个斜率就会被无限放大。
在这种假设下,我们用泰勒展开,利用简单的多项式来模仿复杂的函数。这就好比虽然我们不知道远处的地形,但是我们脚下的这一小块区域我们却是知道的,我们可以把这一小块区域延伸出去来把山坡看成一个斜平面的滑梯:
- 零阶近似: \(L(x + \eta) \approx L(x)\)(假设完全平坦,高度不变)。
- 一阶近似(线性近似): \(L(x + \eta) \approx L(x) + \text{坡度} \times \text{距离}\)。
这里的坡度就是梯度 \(\nabla_x L\)。所以公式:
本质上就是: 新高度 \(\approx\) 旧高度 + (坡度 \(\times\) 迈出的步子) 。公式的最后一项\(\nabla_x L^T \cdot \eta\)是两个向量的点积(Dot Product)。根据线性代数,两个向量 \(\vec{A}\) 和 \(\vec{B}\) 的点积公式是:
其中\(\theta\)是两个向量之间的夹角。我们希望这个值最大,因为\(\|\vec{A}\|\)(梯度)和\(\|\vec{B}\|\)(扰动大小)是相对固定的,唯一的变量是\(\cos(\theta)\)。因此当\(\theta = 0^\circ\)时(即 两个向量方向完全一致 ),\(\cos(0^\circ) = 1\),点积达到最大值。
上述内容解释了为什么要沿着梯度的方向加扰动,因为它是逻辑上能让损失函数增加最快的方向。
总结来说,所有的基于梯度的攻击,都遵循我们上面提到的这些核心假设或概念:
- 目标相同: \(\max_{\Delta x} L(f_\theta(x + \Delta x), y)\)。
- 假设相同: 局部线性假设 + 泰勒一阶展开。
- 动力相同: 利用点积最大化,让扰动方向与梯度方向一致(\(\theta = 0^\circ\))。
梯度上升攻击
我们知道在训练模型时用的是 梯度下降(Gradient Descent) :
梯度上升(Gradient Ascent) 则是反过来:
这里的 \(\alpha\) 是步长(Step size),\(\nabla_x L\) 是损失函数对输入 \(x\) 的梯度。
这便是梯度上升攻击。梯度上升攻击直接利用损失函数对输入的梯度,通过迭代的方式修改输入图像,使得模型对该图像的判断彻底出错。一般我们这么写:
- \(x_t\) :第 \(t\) 次迭代时的图像。
- \(\alpha\) :步长(Learning Rate),控制每一步挪动多远。
- \(\nabla_{x_t} L\) :当前图像产生的梯度。
在实际操作中,梯度上升通常有两种实际操作方式:
- 无约束攻击,即不限制\(\Delta x\)的大小。这种攻击极其容易成功,Loss可以升的很高,但图片也会变成布满雪花点般的噪点,甚至完全变成一片杂乱,因而人眼一眼就能看出来,失去了攻击隐蔽性的要求。
- 带约束的攻击(BIM),即限制总扰动。常见的是\(L_2\) 约束(限制所有像素变动的平方和)。
在带约束的攻击中,一般通过如下方式:
- 计算梯度。
- 沿着梯度走一小步。
- 检查: 如果总扰动超标了,就按比例把所有像素的变动缩减回去(这就是所谓的“投影”)。
单纯的梯度上升攻击虽然在图像领域不如 FGSM 常用,但它是 所有迭代攻击(如 PGD)的理论基石 。
- FGSM 是把梯度上升“简化”成了单步符号运算。
- PGD 则是把梯度上升“精细化”了——它本质上就是 带约束的多步梯度上升 。
FGSM
在数学上,两个向量 \(\vec{A}\) 和 \(\vec{B}\) 的点积 \(\vec{A}^T \cdot \vec{B}\) 其实就是它们对应元素的乘积之和。我们设梯度向量 \(g = \nabla_x L\)。那么我们要最大化的目标函数 \(\nabla_x L^T \cdot \eta\) 可以写成:
这里的 \(i\) 代表图像中的每一个像素点(比如一张 224x224 的图,\(i\) 就是第 1 个到第 50176 个像素)。由于每一个像素点 \(i\) 的扰动 \(\eta_i\) 是相互独立的,我们要让整个和最大,本质上就是让 每一项 \(g_i \cdot \eta_i\) 都尽可能大 。
现在看某一个像素点 \(i\),我们面临的情况如下:
- 已知量: \(g_i\)(这是模型算出来的梯度,可能是正数,也可能是负数)。
- 约束条件: \(|\eta_i| \le \epsilon\)(这意味着 \(\eta_i\) 必须在 \([-\epsilon, \epsilon]\) 这个区间里)。
- 目标: 选一个 \(\eta_i\),让 \(g_i \cdot \eta_i\) 最大。
接着我们需要分情况讨论:
- 如果 \(g_i > 0\)(正梯度)。为了让 \(g_i \cdot \eta_i\) 最大,由于 \(g_i\) 是正的,\(\eta_i\) 当然越大越好。在 \([-\epsilon, \epsilon]\) 约束下,最大能取到多少? 取 \(\epsilon\)* 。此时,*\(g_i \cdot \eta_i = g_i \cdot \epsilon\)。
- 如果 \(g_i < 0\)(负梯度)。注意了!因为 \(g_i\) 是负数,要让乘积 \(g_i \cdot \eta_i\) 变成一个很大的正数,\(\eta_i\) 必须也是负的(负负得正)。在 \([-\epsilon, \epsilon]\) 约束下,往负方向走最远能取到多少? 取 \(-\epsilon\)* 。此时,*\(g_i \cdot \eta_i = (-|g_i|) \cdot (-\epsilon) = |g_i| \cdot \epsilon\)。
- 如果 \(g_i = 0\)。此时不管 \(\eta_i\) 取什么,乘积都是 0,对增加 Loss 没有贡献(通常忽略不计)。
于是我们便发现,无论 \(g_i\) 是正还是负,为了达到最大化,\(\eta_i\) 的 绝对值永远取 \(\epsilon\)* ,而它的 *符号永远跟着 \(g_i\) 走 。这个特性正是符号函数\(\text{sign}(x)\) 的定义:
所以,最理想的 \(\eta_i\) 就可以写成:
我们把所有的像素点合并回向量形式,就得到了FGSM的核心组件\(\eta\)。\(\eta\)(希腊字母 Eta)在数学中通常用来代表 “扰动(Perturbation)” 或者 “噪声” :
也即
我们的目标是给原始图像\(x\)添加一个扰动,即:
由此我们便得到了FGSM的攻击方法:
- \(J(\theta, x, y)\) : 模型损失函数。
- \(\nabla_x\) : 对输入 \(x\) 求梯度。
- \(\text{sign}(\cdot)\) : 符号函数(只取正负号)。
- \(\epsilon\) : 扰动的大小,控制干扰强度。
。
PGD
我们在上面已经知道,FGSM就是针对图像进行一步攻击,无论这一步走到哪里,都只走一步。我们用epsilon来限制这一步的大小,来限制干扰强度,从而让图片不至于变化的太多。
PGD在FGSM的基础上,把走一步改为走很多小步,每一步的步长是alpha,每走一步都要把结果投影/裁剪回允许的扰动范围内:
- \(\Pi_{x+S}\) : 投影算子,确保对抗样本始终在以 \(x\) 为中心、半径为 \(\epsilon\) 的范围内。
- \(\alpha\) : 步长。
多步通常比一步更强,这是因为多步走可以沿着非线性模型的局部地形不断爬坡。
这里要注意,由于FGSM只做1次更新,然后就结束并clip到合法范围内。PGD需要重复T次,所以在每一轮中都要投影。投影的意思就是把\(\delta\)拉回到合法范围内,即\(\lvert \lvert \delta \rvert \rvert _\infin \in [0,1]\),并保证\(x+\delta \in [0,1]\)(或归一化范围)。\(L_\infty\) 范数规定每一个像素点改动的最大值(比如每个点最多改 8 个灰度值)。这是最常用的,因为它保证了全图没有一个地方突兀。
这里要注意,PGD会进行T次迭代更新扰动,每一次都会对整个图像的所有像素 (所有通道)按梯度符号方向做一次小幅更新(步长是 α,方向是sign(\(\Delta\))),然后投影回去;在每步后投影,保证扰动不超过 \(\epsilon\) 且图像值仍在合法范围内。”
有目标攻击
刚才我们默认扰动是无目标(untargeted)的,即目的在于让模型更容易出错,因此我们沿着梯度上升的方向进行扰动。如果我们想把模型推向目标类/目标文本\(y_t\),那么我们需要最小化对目标的损失。
无目标FGSM:(一般默认提到的公式)
有目标FGSM:
上面公式中的\(y\)和\(y_t\)分别代表:
- \(y\), ground-truth or true label, 这里的\(y\)就是图片本身应该被判别为的类别,比如“猫”,所以是x+...
- \(y_t\), target-label or target class, 这里的\(y_t\)是目标类别,即我们希望模型判别图片为的方向,所以是x-...
在PGD中也是同理,无目标PGD:
有目标PGD:
。
FGSM攻击实例
原理复习
从优化的角度来看,我们的目标是最大化loss:
其中delta是扰动(perturbation)。然后我们的约束是\(L_{\infin}\),即每个像素的改动不大于epsilon。我们在该约束条件下,最大化Loss。Loss 本质上是在衡量“模型预测和真实标签之间的差距”。Loss 越大,说明模型越偏离正确答案。
FGSM解:
最终对抗样本:
.
为了量化“人眼看不看得见”,我们规定了三种范数:
- \(L_\infty\) 范数: 规定每一个像素点改动的最大值(比如每个点最多改 8 个灰度值)。这是最常用的,因为它保证了全图没有一个地方突兀。
- \(L_2\) 范数: 规定所有像素改动的 平方和 (总能量)。它允许某些点改动大一点,只要整体改动小就行。
- \(L_0\) 范数: 规定只能改动多少个像素点(比如整张图只准改 10 个点,但改多少度随意)。
这里要注意,范数本身并不是对抗攻击专有的,但是在对抗攻击中,\(L_\infty\) 成了对抗攻击的明星,是因为它对“最大像素改动”的严格限制最符合人类视觉系统的特性(只要任何一点都不突兀,整体看起来就没变)。
我们使用 L∞(无穷范数)扰动约束 ,FGSM 形式如下:
其中:
- \(x\):原始输入图片
- \(y\):真实标签
- \(f(x)\):模型输出 logits
- \(L(\cdot)\):损失函数(通常交叉熵)
- \(∇xL\):对输入 x 求梯度
- \(sign(\cdot)\):梯度符号函数
- \(\epsilon\):扰动大小(你需要明确给出)
clip:把生成的对抗图像裁剪回有效输入范围
这里要注意,FGSM是单步攻击,只进行一次攻击。在数学上,其本质是一次线性近似:
在约束:
下的最优解:
公式中的clip指的是“把数值裁剪到合法范围内”。对抗样本添加扰动后,可能超出输入允许的取值范围,比如超出像素空间[0,1]或归一化后[-1,1]。所以需要压回去:
在PyTorch中,用 clamp裁剪函数来实现clip:
adv_image = adv_image.clamp(-1, 1)
即表示把adv_image的每个元素都限制在[-1,1]内:
- 小于-1的变成-1
- 大于1的变成1
- 中间的不变
FGSM攻击
FGSM(Fast Gradient Sign Method) 是 2014 年 Goodfellow 等人提出的最简单的对抗攻击方法。其核心公式是:
拆开来理解:
- \(\nabla_x L(f(x), y))\):loss 对输入图片的梯度。平时训练时我们求的是 loss 对权重的梯度来更新权重;这里反过来,固定权重,求 loss 对输入像素的梯度,表示"往哪个方向改变像素,loss 上升最快"
- sign(·):只取梯度的符号(+1 或 -1),丢掉大小。这样每个像素要么加 ε 要么减 ε,扰动幅度完全可控
- ε(epsilon):扰动强度,作业里测的是 2/255、4/255、8/255。像素值范围是 0~255,所以 8/255 大约只有 3% 的像素值变化,人眼几乎察觉不到
- clip:把扰动后的像素值钳制回合法范围(归一化后是 [−1,1][−1,1]),防止超出图片的合法值域
了解上述原理后,实现起来就很明了了:
- 用loss对输入图片求梯度
- 沿着梯度上升的方向对图片做一个很小的扰动
在扰动的时候,我们要注意,神经网络输入的图片本质上是一个四维张量:
例如CIFAR-10的\((1,3,32,32)\)。每一个元素在归一化后就是-1到1之间的一个像素点,例如某个像素:
其梯度为:
我们把梯度取sign,得到-1,因而可以算出扰动为\(\epsilon \cdot (-1)\),于是新的像素点就是\(0.24-\epsilon\)。本质上就是给像素加或减一个很小的数。
这里要注意,FGSM会对整张图片的每一个像素都添加扰动,而不仅仅是某几个点:
并且,FGSM攻击对每张照片,只进行一次梯度计算,生成一次扰动。
意义与分析
我们不禁思考三个问题:
- 改变多少或者说epsilon多大,可以让模型误判?
- 为什么这么小的扰动就有意义?
- 这种扰动的意义是什么?
首先,虽然没有一个固定的epsilon值,但是对于CIFAR-10或者ImageNet这种被研究很多的数据集,基本上有一个典型的范围:
| ε (pixel) | ε (归一化后) | 人眼感受 | 模型影响 |
|---|---|---|---|
| 1/255 | 0.004 | 完全不可见 | 几乎无影响 |
| 2/255 | 0.008 | 不可见 | 少量误判 |
| 4/255 | 0.016 | 不可见 | 明显误判 |
| 8/255 | 0.031 | 几乎不可见 | 大量误判(常用) |
| 16/255 | 0.062 | 略有噪声 | 极高误判率 |
| 32/255 | 0.125 | 明显噪声 | 几乎全错 |
其中8/255是最常用的标准,是肉眼不可见情况下能够显著降低模型准确率的攻击。
例如:
正常:
airplane: 0.92
bird: 0.04
cat: 0.02
攻击后:
airplane: 0.12
bird: 0.18
cat: 0.51 ← 误判
之所以能够以这么小的扰动来骗过模型,是因为神经网络是高维线性系统的组合。比如说,对于CIFAR-10来说,其图片维度是\(3 \times 32 \times 32 = 3072\),如果每个像素只改0.03,那么总变化量就能达到100。
换句话说,假设分类器:
如果每个\(x_i\)增加0.03,那么总变化将是:
这将会很大。
这里的总变化量指的是对模型输出(score/logit)的总影响,而不是图像数值本身。其关键在于:神经网络的第一层本质就是加权求和。
梯度噪声是最有效的攻击方向,和随机噪声相比,梯度噪声是专门针对模型弱点发起的攻击。在神经网络高维空间的类别划分中,其本质上改变了点的位置,从而让点从决策边界的一侧转移到了另一侧。
PyTorch示例
我们使用CIFAR-10数据集和ResNet架构来尝试FGSM攻击。
必要package导入与数据集加载如下:
## Your potential Task:
##Your Package imported
## For your reference, here is the package I am using
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
import torchvision
import torchvision.transforms as transforms
from torch.utils.data import DataLoader
import matplotlib.pyplot as plt
from IPython.display import display, clear_output
import pandas as pd
# You can also write your own data loading code
# I split the training set into training set and val set
# to use train/val only in training, without test set.
from torch.utils.data import Subset
# === 数据增强(Data Augmentation)===
# 注意:增强只能加在训练集的 transform 里,不能加在 val/test 上。
# 原因:val 和 test 需要稳定、可复现的评估结果;
# 如果对它们随机裁剪/翻转,每次评估结果都不一样,失去了参考意义。
# 因此需要为训练集和评估集定义两套不同的 transform。
# 训练集 transform:加入随机增强,人为扩充训练数据的多样性
train_transform = transforms.Compose([
# RandomCrop: 先在图片四周各填充 4 个像素(padding=4),再随机裁剪回 32x32
# 模拟图片平移,让模型学会识别不同位置的目标
transforms.RandomCrop(32, padding=4),
# RandomHorizontalFlip: 以 50% 概率随机水平翻转图片
# 对 CIFAR-10 的大多数类别(车、鸟、船等)来说,翻转后语义不变
transforms.RandomHorizontalFlip(),
transforms.ToTensor(),
transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))
])
# 评估集 transform:只做必要的归一化,不做任何随机变换
eval_transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))
])
# 加载两份原始数据集(数据文件相同,只是 transform 不同)
# full_trainset 用于探查 .classes 等属性
full_trainset = torchvision.datasets.CIFAR10(root='./data', train=True,
download=True, transform=train_transform)
full_trainset_eval = torchvision.datasets.CIFAR10(root='./data', train=True,
download=True, transform=eval_transform)
# 用固定随机种子切分索引,保证 train/val 每次划分一致
val_size = 5000
train_size = len(full_trainset) - val_size
generator = torch.Generator().manual_seed(42) # 固定种子,结果可复现
indices = torch.randperm(len(full_trainset), generator=generator).tolist()
# train split:使用带增强的 full_trainset
# val split:使用不带增强的 full_trainset_eval(相同索引,不同 transform)
trainset = Subset(full_trainset, indices[val_size:])
valset = Subset(full_trainset_eval, indices[:val_size])
trainloader = DataLoader(trainset, batch_size=64, shuffle=True)
valloader = DataLoader(valset, batch_size=64, shuffle=False)
# 测试集:只用 eval_transform,不做增强
testset = torchvision.datasets.CIFAR10(root='./data', train=False,
download=True, transform=eval_transform)
testloader = DataLoader(testset, batch_size=64, shuffle=False)
可以检视数据集:
import numpy as np
import matplotlib.pyplot as plt
# full_trainset 保留了 .classes 属性,trainset/valset 是 Subset 没有该属性
classes = full_trainset.classes
# === 基本信息 ===
print(f"Full train set : {len(full_trainset)}")
print(f" -> Train split: {len(trainset)}")
print(f" -> Val split: {len(valset)}")
print(f"Test set : {len(testset)}")
# 取一张图看格式(从 full_trainset 取,Subset 索引机制不同)
sample_img, sample_label = full_trainset[0]
print(f"\nSingle image tensor shape : {sample_img.shape}")
print(f"Pixel range : [{sample_img.min():.3f}, {sample_img.max():.3f}]")
print(f"Label example : {sample_label} -> '{classes[sample_label]}'")
# === 查看一个 batch 的 shape ===
sample_batch_imgs, sample_batch_labels = next(iter(trainloader))
print(f"\nOne batch shape : {sample_batch_imgs.shape}")
print(f"Label shape : {sample_batch_labels.shape}")
# === 可视化每个类别的样例图片 ===
fig, axes = plt.subplots(2, 5, figsize=(12, 5))
shown = {i: False for i in range(10)}
for img, label in full_trainset:
label = int(label)
if not shown[label]:
ax = axes[label // 5][label % 5]
npimg = (img.numpy() * 0.5 + 0.5).transpose(1, 2, 0)
ax.imshow(np.clip(npimg, 0, 1))
ax.set_title(classes[label])
ax.axis('off')
shown[label] = True
if all(shown.values()):
break
plt.suptitle("CIFAR-10 Sample Images (32x32)", fontsize=13)
plt.tight_layout()
plt.show()
# === 各类别样本数量分布 ===
from collections import Counter
label_counts = Counter([label for _, label in full_trainset])
print("\nFull training set class distribution:")
for i, cls in enumerate(classes):
print(f" {cls:>10}: {label_counts[i]}")
模型架构如下:
import torch
from torch import nn
class BasicBlock(nn.Module):
"""
ResNet 的基本构建块(Basic Block)。
结构:Conv -> BN -> ReLU -> Conv -> BN -> (+shortcut) -> ReLU
核心思想:残差连接(skip connection)
让输出 H(x) = F(x) + x,模型只需学习残差 F(x) = H(x) - x。
这样梯度可以直接通过 shortcut 回传,解决了深层网络的梯度消失问题。
当 stride != 1 或通道数变化时,shortcut 需要用 1x1 Conv 匹配维度。
"""
def __init__(self, in_channels, out_channels, stride=1):
super().__init__()
# 主路径:两个 3x3 卷积,Conv -> BN -> ReLU -> Conv -> BN
self.conv1 = nn.Conv2d(in_channels, out_channels,
kernel_size=3, stride=stride, padding=1, bias=False)
self.bn1 = nn.BatchNorm2d(out_channels)
self.relu = nn.ReLU(inplace=True)
self.conv2 = nn.Conv2d(out_channels, out_channels,
kernel_size=3, stride=1, padding=1, bias=False)
self.bn2 = nn.BatchNorm2d(out_channels)
# shortcut 路径:当 stride != 1 或通道数变化时,用 1x1 Conv 对齐维度
self.shortcut = nn.Sequential()
if stride != 1 or in_channels != out_channels:
self.shortcut = nn.Sequential(
nn.Conv2d(in_channels, out_channels,
kernel_size=1, stride=stride, bias=False),
nn.BatchNorm2d(out_channels)
)
def forward(self, x):
out = self.relu(self.bn1(self.conv1(x))) # Conv -> BN -> ReLU
out = self.bn2(self.conv2(out)) # Conv -> BN
out += self.shortcut(x) # 残差加法:F(x) + x
out = self.relu(out)
return out
class ResNet18(nn.Module):
"""
ResNet-18 for CIFAR-10.
结构:
stem: Conv3x3(3->64, stride=1) + BN + ReLU [不降采样,保留 32x32]
layer1: 2x BasicBlock(64->64, stride=1) [32x32]
layer2: 2x BasicBlock(64->128, stride=2) [16x16]
layer3: 2x BasicBlock(128->256, stride=2) [ 8x8]
layer4: 2x BasicBlock(256->512, stride=2) [ 4x4]
head: AdaptiveAvgPool2d(1) + Flatten + Linear(512->10)
"""
def __init__(self, num_classes=10):
super().__init__()
# stem: 用 3x3 conv(stride=1) 替代原版 7x7 conv(stride=2),适配 32x32 输入
self.stem = nn.Sequential(
nn.Conv2d(3, 64, kernel_size=3, stride=1, padding=1, bias=False),
nn.BatchNorm2d(64),
nn.ReLU(inplace=True)
)
self.layer1 = self._make_layer(64, 64, num_blocks=2, stride=1)
self.layer2 = self._make_layer(64, 128, num_blocks=2, stride=2)
self.layer3 = self._make_layer(128, 256, num_blocks=2, stride=2)
self.layer4 = self._make_layer(256, 512, num_blocks=2, stride=2)
# 全局平均池化后 flatten,接一个全连接层输出 10 类
self.head = nn.Sequential(
nn.AdaptiveAvgPool2d(1), # 任意空间尺寸 -> 1x1
nn.Flatten(),
nn.Linear(512, num_classes)
)
def _make_layer(self, in_channels, out_channels, num_blocks, stride):
# 第一个 block 负责下采样(stride),后续 block stride=1
layers = [BasicBlock(in_channels, out_channels, stride)]
for _ in range(1, num_blocks):
layers.append(BasicBlock(out_channels, out_channels, stride=1))
return nn.Sequential(*layers)
def forward(self, x):
x = self.stem(x)
x = self.layer1(x)
x = self.layer2(x)
x = self.layer3(x)
x = self.layer4(x)
x = self.head(x)
return x
net = ResNet18(num_classes=10)
print(net)
# 验证 forward pass 维度正确
dummy = torch.zeros(1, 3, 32, 32)
print(f"\nOutput shape: {net(dummy).shape}") # 应为 torch.Size([1, 10])
训练模型并保存:
device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
## Code Starting Here
# The training code references the training code I used in CS130 and CS137.
# 首先将数据集分成若干份,其数量=总数据集数量/批次大小.
# 我们设置了batch_size = 64,这个的意思就是每次喂给模型64张图片。
# iterations = len(trainset) / 64 = 781
# 这个长度的意思是每一个epoch我们要跑781个batch,才能把整个数据集跑完
# 一次iteration,就是跑完了一个batch,也就是看了64张图片。
# 一次epoch,就是跑完了所有的图片,也就是跑完781次iteration。
model = net.to(device)
criterion = nn.CrossEntropyLoss()
# 优化器:SGD + Momentum + Weight Decay(ResNet 原论文配置,泛化性优于 AdamW)
# - lr=0.1:SGD 的初始学习率通常比 Adam 大得多
# - momentum=0.9:动量,让梯度更新方向带有"惯性",有助于冲出局部最优、加速收敛
# - weight_decay=5e-4:L2 正则化系数,让权重保持小值,防止过拟合
optimizer = torch.optim.SGD(model.parameters(),
lr=0.1,
momentum=0.9,
weight_decay=5e-4)
# 学习率调度器:MultiStepLR
# 在指定的 epoch 到达时,将学习率乘以 gamma(即缩小为原来的 1/10)
# 训练前期用大 lr 快速下降,后期用小 lr 精细收敛:
# epoch 1-59: lr = 0.1
# epoch 60-79: lr = 0.01
# epoch 80+: lr = 0.001
scheduler = torch.optim.lr_scheduler.MultiStepLR(optimizer, milestones=[60, 80], gamma=0.1)
import os
os.makedirs('./resnet_checkpoints', exist_ok=True)
train_losses = []
test_accuracies = []
best_acc = 0.0
# 100 epoch:配合 MultiStepLR 在第 60、80 轮衰减学习率
num_epochs = 100
for epoch in range(num_epochs):
# 使用nn.Module类,切换.train()开关
# 这里的.train()是开关,主要是dropout和batchNorm这种特殊层在训练和测试时动作不一样
# dropout:评估时不随机关闭神经元
# BatchNorm,停止计算,这个后面再说,这里暂时不问
model.train()
# running loss是用来手动记录训练误差的变量
running_loss = 0.0
# for image, label in trainloader:
for images, labels in trainloader:
images, labels = images.to(device), labels.to(device)
# 清除上一个batch留下的梯度,因为PyTorch默认梯度是累积的。
optimizer.zero_grad()
# 前向传播,得到预测结果
outputs = model(images)
# 对比预测结果和实际结果,算出loss(交叉熵损失函数)
loss = criterion(outputs, labels)
# 反向传播,算出每一层的梯度。
loss.backward()
# 更新参数
optimizer.step()
# 在PyTorch中,loss是一个带有计算图的tensor,直接累加是不合适的
# .item()可以把tsnor中具体的数字抠出来,变成轻量级的python浮点数
running_loss += loss.item()
avg_loss = running_loss / len(trainloader)
train_losses.append(avg_loss)
# 在验证集上评估(val set 从训练集切出,用于训练过程监控)
model.eval()
correct = 0
total = 0
with torch.no_grad():
for images, labels in valloader:
images, labels = images.to(device), labels.to(device)
outputs = model(images)
_, predicted = torch.max(outputs, 1)
total += labels.size(0)
correct += (predicted == labels).sum().item()
val_acc = 100 * correct / total
test_accuracies.append(val_acc)
# scheduler.step() 必须在每个 epoch 结束后调用,触发学习率按计划衰减
scheduler.step()
current_lr = scheduler.get_last_lr()[0]
print(f"Epoch [{epoch+1}/{num_epochs}] Loss: {avg_loss:.4f} Val Acc: {val_acc:.2f}% LR: {current_lr:.5f}")
# 每次验证集准确率创新高时,覆盖保存 best_model(用于部署)
if val_acc > best_acc:
best_acc = val_acc
torch.save(model.state_dict(), './best_model_resnet.pth')
print(f" -> New best val accuracy: {best_acc:.2f}%, best model saved.")
# 每5轮保存一次checkpoint(包含 scheduler 状态,方便从中断处恢复训练)
if (epoch + 1) % 5 == 0:
torch.save({
'epoch': epoch + 1,
'model_state_dict': model.state_dict(),
'optimizer_state_dict': optimizer.state_dict(),
'scheduler_state_dict': scheduler.state_dict(),
'train_losses': train_losses,
'test_accuracies': test_accuracies,
}, f'./resnet_checkpoints/checkpoint_epoch{epoch+1}.pth')
print(f" -> Checkpoint saved at epoch {epoch+1}")
# 跑完后保存 final 权重
torch.save(model.state_dict(), './resnet_final_weights.pth')
print("Training complete. Final weights saved to resnet_final_weights.pth")
FGSM攻击:
import torch
import torch.nn as nn
def fgsm_attack(model, image, label, epsilon, device):
"""
FGSM (Fast Gradient Sign Method) - 无目标攻击,L∞ 扰动约束
公式:x_adv = clip( x + ε · sign(∇_x L(f(x), y)) )
参数:
model : 已训练好的模型(攻击过程中不更新权重)
image : 输入图片 tensor,shape (B, C, H, W),已归一化到 [-1, 1]
label : 真实标签 tensor,shape (B,)
epsilon: 扰动强度(在归一化空间内,由调用方传入)
device : 计算设备(cpu / cuda)
返回:
adv_image : 对抗样本(与原图形状相同)
perturbation: 实际施加的扰动(adv_image - 原图,供可视化用)
"""
# Requirements:
# - Use the same loss as training (e.g., CrossEntropyLoss)
# - Compute gradient w.r.t. image
# - adv_image = image + epsilon * sign(grad)
# - clamp adv_image to valid range (if normalized CIFAR-10: [-1, 1])
# ε(epsilon)定义:在归一化空间 [-1,1] 内的扰动上限。
# 调用方会将像素空间的 ε(如 8/255)转换为归一化空间:eps_norm = eps_pixel / 0.5
# 这里直接使用传入的 epsilon,无需再转换。
criterion = nn.CrossEntropyLoss()
# Step 1: clone 原图并开启 requires_grad
# 默认 tensor 不参与梯度计算;FGSM 需要对输入图片求梯度,
# 所以必须先 clone(避免修改原始数据),再设 requires_grad=True
image = image.clone().detach().to(device)
image.requires_grad = True
# Step 2: 前向传播
# 注意:不能用 torch.no_grad(),因为需要保留计算图才能反向传播
# model.eval() 已在调用方设置,攻击期间固定 BN/Dropout 状态
output = model(image)
# Step 3: 计算 loss(使用和训练时相同的损失函数:交叉熵)
# - Use the same loss as training (e.g., CrossEntropyLoss)
loss = criterion(output, label)
# Step 4: 对输入图片反向传播,求 ∇_x L
# 这里求的是 loss 对 image(输入像素)的梯度,
# 而不是对模型参数的梯度(模型参数保持不变)
model.zero_grad() # 清除模型参数上可能残留的旧梯度
loss.backward() # 反向传播,image.grad 中存放 ∂L/∂x
# Step 5: 取梯度符号,构造扰动
# sign(grad): 每个像素 +1 或 -1,表示让 loss 上升的方向
# 乘以 epsilon 控制扰动幅度(L∞ 约束:每个像素改变量 ≤ ε)
# - Compute gradient w.r.t. image
# - adv_image = image + epsilon * sign(grad)
grad_sign = image.grad.sign()
# Step 6: 生成对抗样本,clamp 到合法范围
# 输入已用 Normalize(0.5,0.5,0.5) 归一化,像素范围为 [-1, 1]
# - clamp adv_image to valid range (if normalized CIFAR-10: [-1, 1])
adv_image = image + epsilon * grad_sign
adv_image = adv_image.clamp(-1, 1).detach() # detach 切断计算图,节省显存
# 扰动 = 对抗样本 - 原图(clamp 后的真实扰动,用于可视化)
perturbation = adv_image - image.detach()
return adv_image, perturbation
FGSM Evaluation:
# === FGSM evaluation (provided) ===
import matplotlib.pyplot as plt
# eps in pixel space
eps_list_pixel = [2/255, 4/255, 8/255]
# If your inputs are normalized by Normalize(0.5,0.5,0.5), convert eps to normalized space:
eps_list = [e / 0.5 for e in eps_list_pixel]
def eval_clean_and_adv(model, testloader, eps_list, device, max_batches=20):
model.eval()
results = []
for eps in eps_list:
clean_correct, adv_correct, total = 0, 0, 0
for i, (images, labels) in enumerate(testloader):
if i >= max_batches:
break
images, labels = images.to(device), labels.to(device)
# clean
with torch.no_grad():
out = model(images)
pred = out.argmax(dim=1)
clean_correct += (pred == labels).sum().item()
# adversarial (FGSM needs gradients)
adv_images, _ = fgsm_attack(model, images, labels, eps, device)
with torch.no_grad():
out_adv = model(adv_images)
pred_adv = out_adv.argmax(dim=1)
adv_correct += (pred_adv == labels).sum().item()
total += labels.size(0)
results.append({
"eps_pixel": eps * 0.5, # convert back for reporting
"clean_acc": clean_correct / total,
"adv_acc": adv_correct / total,
})
return results
results = eval_clean_and_adv(model, testloader, eps_list, device, max_batches=20)
for r in results:
print(f"eps={r['eps_pixel']:.6f} | clean_acc={r['clean_acc']:.4f} | adv_acc={r['adv_acc']:.4f}")
# plot
plt.figure()
plt.plot([r["eps_pixel"] for r in results], [r["adv_acc"] for r in results], marker='o')
plt.xlabel("epsilon (pixel space)")
plt.ylabel("adversarial accuracy")
plt.title("FGSM: adversarial accuracy vs epsilon")
plt.grid(True)
plt.show()
可以看到:

FGSM Visualization:
# === FGSM visualization (provided) ===
import numpy as np
import matplotlib.pyplot as plt
def imshow(img):
# undo normalization (assumes Normalize(0.5,0.5,0.5))
img = img.detach().cpu().numpy()
img = (img * 0.5) + 0.5
npimg = np.transpose(img, (1, 2, 0))
return np.clip(npimg, 0, 1)
# pick a batch
dataiter = iter(testloader)
images, labels = next(dataiter)
images, labels = images.to(device), labels.to(device)
# choose epsilon (pixel space) and convert if normalized
eps_pixel = 8/255
eps = eps_pixel / 0.5
# generate adversarial examples
adv_images, perturbation = fgsm_attack(model, images, labels, eps, device)
# predictions
with torch.no_grad():
pred_clean = model(images).argmax(dim=1)
pred_adv = model(adv_images).argmax(dim=1)
# show first 5
k = 5
plt.figure(figsize=(10, 2*k))
for i in range(k):
orig = imshow(images[i])
adv = imshow(adv_images[i])
pert = (adv_images[i] - images[i]).detach().cpu().numpy()
pert = (pert - pert.min()) / (pert.max() - pert.min() + 1e-8)
pert = np.transpose(pert, (1, 2, 0))
plt.subplot(k, 3, 3*i+1)
plt.imshow(orig); plt.axis('off')
plt.title(f"Orig | y={labels[i].item()} | pred={pred_clean[i].item()}")
plt.subplot(k, 3, 3*i+2)
plt.imshow(adv); plt.axis('off')
plt.title(f"Adv | pred={pred_adv[i].item()}")
plt.subplot(k, 3, 3*i+3)
plt.imshow(pert); plt.axis('off')
plt.title("Perturbation")
plt.tight_layout()
plt.show()
可以看到:

PGD攻击实例
原理复习
无目标PGD:
有目标PGD:
。
伪代码
我们来看一下在实际应用中,如何去完整地完成一次PGD攻击:
【INPUT】
模型model,参数固定不更新
原始图像x,像素范围假设是-1到1
最大扰动 epsilon,L_inf约束
步长 alpha
迭代次数T
攻击目标:y或者y_t
可选:随机初始化
可选:PNG/unit8量化:QAA/STE等
【初始化】
复制原图为x0,确保不参与梯度更新(作为常量)
初始化扰动 delta,如果是随机初始化,就在[-epsilon, +epsilon]中随机采样一个delta;否则delta=0
令对抗样本 x_adv = clamp(x_0 + delta, 0, 1),保证像素合法
如果开启量化感知,把x_adv量化到k/255网络,再反推回delta=x_adv-x_0,确保一开始就在PNG网格上
准备记录最优结果:best_loss = float('inft'), best_delta = float('inf')
【迭代优化】
重复T次:
生成当前对抗样本 x_adv = clamp(x_0 + delta, 0, 1)
(如果做量化感知QAA/STE,在前向时用量化图,反向时让梯度像没量化一样传回去)
前向传播计算loss
如果是分类任务,loss = CrossEntropy(model(x_for_loss), y或y_t)
如果是多模态任务,loss = AttackLoss(model, x_for_loss, target_text)
记录当前最优loss
反向传播得到梯度
首先记得清掉旧梯度
loss.backward()得到 g = ∂loss/∂δ
FGSM扰动
如果是有目标的,δ = δ - α * sign(g)
如果是无目标的,δ = δ + α * sign(g)
投影/裁剪
先保证扰动幅度不超过epsilon: delta = clamp(delta, -epsilon, +epsilon)
再保证像素仍在[0, 1]:delta = clamp(x0_delta, 0, 1) - x0
(如果做量化感知,每一步再吸附到unit8网络,提高PNG鲁棒性)
x_snap = round(clamp(x0+δ,0,1)*255)/255
δ = x_snap - x0
【输出】
用最优扰动生成最终对抗样本:x_adv_final = clamp(x0 + best_δ, 0, 1)
如果开启量化感知,最终也量化一次:x_adv_final = round(x_adv_final*255)/255
RETURN: x_adv_final,best_δ(以及可选的 loss 曲线)
。
ViT注入攻击示例
我们以Qwen2.5-VL-3B-Instruct为例,进行文本注入攻击。Qwen2.5-VL-3B-Instruct(下文简称Qwen2.5VL)架构简单理解:
Input Image (H×W×C)
↓
Split into patches (P×P) → N patches
↓
Linear projection to D-dim tokens (N×D)
↓
Add positional embedding (+ optional [CLS])
↓
Transformer Encoder × L
- (Window) Multi-head self-attention
- MLP (SwiGLU/GELU)
- Norm + residual
↓
Output visual token sequence (N×D) / or CLS for classification
输入图片切成长宽为P的patches厚,每个patch被编码为长度为D的embedding,然后作为序列导入到transformer中。
为了让输出文本出现target_text或者包含target_text,有两种比较主流的注入方法:
- 传统PGD扰动,通过不断扰动图像像素,让模型生成概率分布朝目标文本靠近
- 视觉prompt injection,把指令作为输入内容的一部分(图中可读的文本、元数据等),利用模型的指令遵从机制改变行为(不需要梯度)。
第一种的原理是做目标文本损失:
targeted PGD:
或者写成迭代更新形式:
而视觉/多模态Prompt Injection则是把“指令”作为上下文内容进行诱导。这类通常不是“对像素做可微优化”,更像是在选择一段注入内容\(s\)(比如图中可读文字、或模型会提取到的指令),让模型更可能输出攻击者想要的结果 \(y^∗\)。
上下文被注入后,攻击者想最大化目标输出概率:
如果强调“模型先从图像抽取文字/内容再进入推理”,可以写成:
这里 \(E(\cdot)\) 表示“模型从图像得到的表示/抽取到的文本/视觉语义”(不一定显式 OCR,但概念上就是图像信息进入上下文/条件)。