对抗样本的基本概念
要认识对抗训练,首先要了解 "对抗样本",它首先出现在论文 Intriguing properties of neural networks 之中。简单来说,它是指对于人类来说 "看起来" 几乎一样,但对于模型来说预测结果却完全不一样的样本,比如下面的经典例子(一只熊猫加了点扰动就被识别成了长臂猿)
那么,什么样的样本才是好的对抗样本呢?对抗样本一般需要具有两个特点:
- 相对原始输入,所添加的扰动是微小的
- 能使模型犯错
对抗训练的基本概念
GAN 之父 lan Goodfellow 在 15 年的 ICLR 中第一次提出了对抗训练的概念,简言之,就是在原始输入样本 $x$ 上加一个扰动 $\Delta x$,得到对抗样本之后,用其进行训练。也就是说,问题可以被抽象成这样一个模型:
$$ \begin{equation} \max_{\theta} P(y|x+\Delta x;\theta) \end{equation} $$
其中,$y$ 为 ground truth,$\theta$ 为模型参数。那扰动 $\Delta x$ 如何计算呢?Goodfellow 认为:神经网络由于其线性的特点,很容易受到线性扰动的攻击
This linear behavior suggests that cheap, analytical perturbations of a linear model should also damage neural networks
于是,他提出了 Fast Gradinet Sign Method(FGSM),来计算输入样本的扰动。扰动可以被定义为:
$$ \Delta x = \epsilon \cdot \text{sgn}(\nabla_x L(x, y;\theta)) $$
其中,$\text {sgn}$ 为符号函数,$L$ 为损失函数(很多地方也用 $J$ 来表示)。Goodfellow 发现,$\epsilon=0.25$ 时,这个扰动能给一个单层分类器造成 99.9% 的错误率。看似这个扰动的发现有点拍脑门,但仔细想想,其实这个扰动计算的思想可以理解为:将输入样本想着损失上升的方向再进一步,得到的对抗样本就能造成更大的损失,提高模型的错误率
为了帮助读者理解上面一段话的含义,我们首先回顾一下梯度下降:在神经网络中,为了使得降低模型的损失,我们有这么一个简单的式子:
$$ \text{new_weights = old_weights - lr * gradients} $$
如果要我指出其中最重要的部分,那必然是减号
。这个减号使得无论当前梯度 gradients
是正还是负,最终 new_weights
的前进方向必然是使得 loss 下降的方向。那么反过来,如果将减号改为加号,并且将 weights
改为 $x$,对抗训练中使得损失上升的思想就出来了
$$ x = x + \Delta x $$
上图中,我们看到两个箭头代表了两种不同的梯度调整策略。左侧的方程是训练神经网络最常见方程,它朝着梯度下降、损失下降的方向前进。右侧的方程则不是这样,它朝着梯度上升、损失上升的方向前进
实际上公式中的 $\text {sgn}$ 函数作用仅仅只是为了防止 $\nabla xL (x,y;\theta)$ 过大所做的缩放,除了 $\text {sgn}$ 函数以外,还有一种常见的方式是:
$$ \Delta x = \epsilon·\frac{\nabla_x L(x,y;\theta)}{||\nabla_xL(x,y;\theta)||} $$
最后,Goodfellow 还总结了对抗训练的两个作用:
- 提高模型应对恶意对抗样本时的鲁棒性
- 作为一种 regularization,减少 overfitting,提高泛化能力
Min-Max 公式
Madry 在 2018 年的 ICLR 论文 Towards Deep Learning Models Resistant to Adversarial Attacks 中总结了之前的工作。总的来说,对抗训练可以统一写成如下格式:
$$ \min_{\theta}\mathbb{E}_{(x,y)\sim\mathcal{D}}\left[\max_{\Delta x\in\Omega}L(x+\Delta x, y;\theta)\right] $$
其中 $\mathcal {D}$ 代表数据集,$x$ 代表输入,$y$ 代表标签,$\theta$ 是模型参数,$L (x,y;\theta)$ 是单个样本的 loss,$\Delta x$ 是扰动,$\Omega$ 是扰动空间。这个式子可以分步理解如下:
- 往 $x$ 里注入扰动 $\Delta x$,$\Delta x$ 的目标是让 $L (x+\Delta x, y;\theta)$ 越大越好,也就是说尽可能让现有模型的预测出错
- 当然 $\Delta x$ 也不是无约束的,它不能太大,否则达不到 "看起来几乎一样" 的效果,所以 $\Delta x$ 要满足一定的约束,常规的约束是 $||\Delta x||\leq \epsilon$,其中 $\epsilon$ 是一个常数
- 每个样本都构造出对抗样本 $x+\Delta x$ 之后,用 $(x+\Delta,y)$ 作为数据去最小化 loss 来更新参数 $\theta$(梯度下降)
- 反复交替执行 1、2、3 步
从 CV 到 NLP
对于 CV 领域的任务,上述对抗训练的流程可以顺利执行下来,因为图像可以视为普通的连续实数向量,$\Delta x$ 也是一个实数向量,因此 $x+\Delta x$ 依然可以是有意义的图像。但 NLP 不一样,NLP 的输入是文本,它本质上是 one-hot 向量,而两个不同的 one-hot 向量,其欧式距离恒为 $\sqrt {2}$,因此对于理论上不存在什么 "小扰动"
一个自然的想法是像论文 Adversarial Training Methods for Semi-Supervised Text Classification 一样,将扰动加到 Embedding 层
Because the set of high-dimensional one-hot vectors does not admit infinitesimal perturbation, we define the perturbation on continuous word embeddings instead of discrete word inputs.
这个思路在操作上没有问题,但问题是,扰动后的 Embedding 向量不一定能匹配上原来的 Embedding 向量表,这样一来对 Embedding 层的扰动就无法对应上真实的文本输入,这就不是真正意义上的对抗样本了,因为对抗样本依然能对应一个合理的原始输入
那么,在 Embedding 层做对抗扰动还有没有意义呢?有!实验结果显示,在很多任务中,在 Embedding 层进行对抗扰动能有效提高模型的性能
Fast Gradient Method(FGM)
上面提到,Goodfellow 在 15 年的 ICLR 中提出了 Fast Gradient Sign Method(FGSM),随后,在 17 年的 ICLR 中,Goodfellow 对 FGSM 中计算扰动的部分做了一点简单的修改。假设输入文本序列的 Embedding vectors $[v_1,v_2,...,v_T]$ 为 $x$,Embedding 的扰动为
$$ \begin{align*} \Delta x &= \epsilon · \frac{g}{||g||_2}\\ g &= \nabla_x L(x,y;\theta) \end{align*} $$
实际上就是取消了符号函数,用二范式做了一个 scale,需要注意的是:这里的 norm 计算的是,每个样本的输入序列中出现过的词组成的矩阵的梯度 norm。原作者提供了一个 TensorFlow 的实现,在他的实现中,公式里的 $x$ 是 Embedding 后的结果(batch_size, seq_len, hid_dim),对其梯度 $g$ 的后面两维计算 norm,得到的是一个维度为(batch_size, 1, 1)的向量 $||g||_2$。为了实现插件式的调用,笔者将一个 batch 抽象成一个样本,一个 batch 统一用一个 norm,其实 norm 本来也只是一个缩放的作用,影响不大。实现如下:
- class FGM():
- def __init__(self, model):
- self.model = model
- self.backup = {}
-
- def attack(self, epsilon=1., emb_name='emb'):
- # emb_name这个参数要换成你模型中embedding的参数名
- # 例如,self.emb = nn.Embedding(5000, 100)
- for name, param in self.model.named_parameters():
- if param.requires_grad and emb_name in name:
- self.backup[name] = param.data.clone()
- norm = torch.norm(param.grad) # 默认为2范数
- if norm != 0:
- r_at = epsilon * param.grad / norm
- param.data.add_(r_at)
-
- def restore(self, emb_name='emb'):
- # emb_name这个参数要换成你模型中embedding的参数名
- for name, param in self.model.named_parameters():
- if param.requires_grad and emb_name in name:
- assert name in self.backup
- param.data = self.backup[name]
- self.backup = {}
需要使用对抗训练的时候,只需要添加五行代码:
- # 初始化
- fgm = FGM(model)
- for batch_input, batch_label in data:
- # 正常训练
- loss = model(batch_input, batch_label)
- loss.backward() # 反向传播,得到正常的grad
- # 对抗训练
- fgm.attack() # embedding被修改了
- # optimizer.zero_grad() # 如果不想累加梯度,就把这里的注释取消
- loss_sum = model(batch_input, batch_label)
- loss_sum.backward() # 反向传播,在正常的grad基础上,累加对抗训练的梯度
- fgm.restore() # 恢复Embedding的参数
- # 梯度下降,更新参数
- optimizer.step()
- optimizer.zero_grad()
Projected Gradient Descent(PGD)
FGM 的思路是梯度上升,本质上来说没有什么问题,但是 FGM 简单粗暴的 "一步到位" 是不是有可能并不能走到约束内的最优点呢?当然是有可能的。于是,一个新的想法诞生了,Madry 在 18 年的 ICLR 中提出了 Projected Gradient Descent(PGD)方法,简单的说,就是 "小步走,多走几步",如果走出了扰动半径为 $\epsilon$ 的空间,就重新映射回 "球面" 上,以保证扰动不要过大:
$$ \begin{align*} x_{t+1}&=\prod_{x+S}(x_t+\alpha\frac{g(x_t)}{||g(x_t)||_2})\\ g(x_t)&=\nabla_{x_t}L(x_t,y;\theta) \end{align*} $$
其中 $S=\{r\in \mathbb {R}^d:||r||_2\leq \epsilon\}$ 为扰动的约束空间,$\alpha$ 为小步的步长
由于 PGD 理论和代码比较复杂,因此下面先给出伪代码方便理解,然后再给出代码
- 对于每个x:
- 1.计算x的前向loss,反向传播得到梯度并备份
- 对于每步t:
- 2.根据Embedding矩阵的梯度计算出r,并加到当前Embedding上,相当于x+r(超出范围则投影回epsilon内)
- 3.t不是最后一步: 将梯度归0,根据(1)的x+r计算前后向并得到梯度
- 4.t是最后一步: 恢复(1)的梯度,计算最后的x+r并将梯度累加到(1)上
- 5.将Embedding恢复为(1)时的值
- 6.根据(4)的梯度对参数进行更新
可以看到,在循环中 $r$ 是逐渐累加的,要注意的是最后更新参数只使用最后一个 x+r 算出来的梯度
- class PGD():
- def __init__(self, model):
- self.model = model
- self.emb_backup = {}
- self.grad_backup = {}
-
- def attack(self, epsilon=1., alpha=0.3, emb_name='emb', is_first_attack=False):
- # emb_name这个参数要换成你模型中embedding的参数名
- for name, param in self.model.named_parameters():
- if param.requires_grad and emb_name in name:
- if is_first_attack:
- self.emb_backup[name] = param.data.clone()
- norm = torch.norm(param.grad)
- if norm != 0:
- r_at = alpha * param.grad / norm
- param.data.add_(r_at)
- param.data = self.project(name, param.data, epsilon)
-
- def restore(self, emb_name='emb'):
- # emb_name这个参数要换成你模型中embedding的参数名
- for name, param in self.model.named_parameters():
- if param.requires_grad and emb_name in name:
- assert name in self.emb_backup
- param.data = self.emb_backup[name]
- self.emb_backup = {}
-
- def project(self, param_name, param_data, epsilon):
- r = param_data - self.emb_backup[param_name]
- if torch.norm(r) > epsilon:
- r = epsilon * r / torch.norm(r)
- return self.emb_backup[param_name] + r
-
- def backup_grad(self):
- for name, param in self.model.named_parameters():
- if param.requires_grad:
- self.grad_backup[name] = param.grad.clone()
-
- def restore_grad(self):
- for name, param in self.model.named_parameters():
- if param.requires_grad:
- param.grad = self.grad_backup[name]
使用的时候要麻烦一点:
- pgd = PGD(model)
- K = 3
- for batch_input, batch_label in data:
- # 正常训练
- loss = model(batch_input, batch_label)
- loss.backward() # 反向传播,得到正常的grad
- pgd.backup_grad() # 保存正常的grad
- # 对抗训练
- for t in range(K):
- pgd.attack(is_first_attack=(t==0)) # 在embedding上添加对抗扰动, first attack时备份param.data
- if t != K-1:
- optimizer.zero_grad()
- else:
- pgd.restore_grad() # 恢复正常的grad
- loss_sum = model(batch_input, batch_label)
- loss_sum.backward() # 反向传播,并在正常的grad基础上,累加对抗训练的梯度
- pgd.restore() # 恢复embedding参数
- # 梯度下降,更新参数
- optimizer.step()
- optimizer.zero_grad()
Virtual Adversarial Training
除了监督任务,对抗训练还可以用在半监督任务中,尤其对于 NLP 任务来说,很多时候我们拥有大量的未标注文本,那么就可以参考 Distributional Smoothing with Virtual Adversarial Training 进行半监督训练
首先,抽取一个随机标准正态扰动 $(d\sim \mathcal {N}(0, 1) \in \mathbb {R}^d)$,加到 Embedding 上,并用 KL 散度计算梯度:
$$ \begin{align*} g &= \nabla_{x'} D_{KL}(p(·\mid x;\theta)||p(·\mid x';\theta))\\ x' &= x + \xi d \end{align*} $$
然后,用得到的梯度,计算对抗扰动,并进行对抗训练:
$$ \begin{align} \min_\theta & D_{KL}(p(\cdot|x;\theta)||p(\cdot|x^*;\theta)) \\\\ x^* &= x+\epsilon \frac{g}{||g||_2} \end{align} $$
实现起来有很多细节,并且笔者对于 NLP 的半监督任务了解并不多,因此这里就不给出实现了
实验对照
为了说明对抗训练的作用,网上有位大佬选了四个 GLUE 中的任务进行了对照试验,实验代码使用的 Huggingface 的 transformers/examples/run_glue.py
,超参都是默认的,对抗训练用的也是相同的超参
任务 | Metrics | BERT-Base | FGM | PGD |
---|---|---|---|---|
MRPC | Accuracy | 83.6 | 86.8 | 85.8 |
CoLA | Matthew's corr | 56.0 | 56.0 | 56.8 |
STS-B | Person/Spearmean corr | 89.3/88.8 | 89.3/88.8 | 89.3/88.8 |
RTE | Accuracy | 64.3 | 66.8 | 64.6 |
可以看出,对抗训练还是有效的,在 MRPC 和 RTE 任务上甚至可以提高三四个百分点。不过,根据我们使用的经验来看,是否有效有时也取决于数据集
为什么对抗训练有效?
Adversarial Training 能够提升 Word Embedding 质量的一个原因是:
有些词与比如(good 和 bad),其在语句中 Grammatical Role 是相近的,我理解为词性相同(都是形容词),并且周围一并出现的词语也是相近的,比如我们经常用来修饰天气或者一天的情况(The weather is good/bad; It's a good/bad day),这些词的 Word Embedding 是非常相近的。文章中用 Good 和 Bad 作为例子,找出了其最接近的 10 个词:
可以发现在 Baseline 和 Random 的情况下,good 和 bad 出现在了彼此的邻近词中,而喂给模型经过扰动之后的 X-adv 之后,也就是 Adversarial 这一列,这种现象就没有出现,事实上, good 掉到了 bad 接近程度排第 36 的位置
我们可以猜测,在 Word Embedding 上添加的 Perturbation 很可能会导致原来的 good
变成 bad
,导致分类错误,计算的 Adversarial Loss 很大,而计算 Adversarial Loss 的部分是不参与梯度计算的,也就是说,模型(LSTM 和最后的 Dense Layer)的 Weight 和 Bias 的改变并不会影响 Adversarial Loss,模型只能通过改变 Word Embedding Weight 来努力降低它,进而如文章所说:
Adversarial training ensures that the meaning of a sentence cannot be inverted via a small change, so these words with similar grammatical role but different meaning become separated.
这些含义不同而语言结构角色类似的词能够通过这种 Adversarial Training 的方法而被分离开,从而提升了 Word Embedding 的质量,帮助模型取得了非常好的表现
梯度惩罚
这一部分,我们从另一个视角对上述结果进行分析,从而推出对抗训练的另一种方法,并且得到一种关于对抗训练更直观的几何理解
假设已经得到对抗扰动 $\Delta x$,那么我们在更新 $\theta$ 时,考虑对 $L (x+\Delta x,y;\theta)$ 的泰勒展开:
$$ \begin{align*} &\min_{\theta}\mathbb{E}_{(x,y)\sim\mathcal{D}}\left[L(x+\Delta x, y;\theta)\right]\\ \approx&\, \min_{\theta}\mathbb{E}_{(x,y)\sim\mathcal{D}}\left[L(x, y;\theta)+\langle\nabla_x L(x, y;\theta), \Delta x\rangle\right] \end{align*} $$
其中,$\langle x,y \rangle = x・y = x^Ty$
对应 $\theta$ 的梯度为
$$ \nabla_{\theta} L(x,y;\theta)+\langle \nabla_{\theta}\nabla{x}L(x,y;\theta), \Delta x\rangle $$
带入 $\Delta x = \epsilon \nabla_x L (x,y;\theta)$,得到
$$ \begin{aligned}&\nabla_{\theta}L(x, y;\theta)+\epsilon\langle\nabla_{\theta}\nabla_x L(x, y;\theta), \nabla_x L(x, y;\theta)\rangle\\ =&\,\nabla_{\theta}\left(L(x, y;\theta)+\frac{1}{2}\epsilon\left\Vert\nabla_x L(x, y;\theta)\right\Vert^2\right) \end{aligned} $$
$$ \begin{align*} &\langle \frac{\partial ^2L}{\partial x \partial \theta}, \frac{\partial L}{\partial x}\rangle\\ =&\frac{\partial (\frac{1}{2}(\frac{\partial L}{\partial x})^2)}{\partial \theta}\\ =&\nabla_\theta(\frac{1}{2}||\nabla_xL(x,y;\theta)||^2) \end{align*} $$
这个结果表示,对输入样本施加 $\epsilon \nabla_x L (x,y;\theta)$ 的对抗扰动,一定程度上等价于往 loss 里边加入 "梯度惩罚"
$$ \frac{1}{2}\epsilon ||\nabla_x L(x,y;\theta)||^2 $$
如果对抗扰动 $\Delta x = \epsilon \frac {\nabla_x L (x,y;\theta)}{||\nabla_x L (x,y;\theta||}$,那么对应的梯度惩罚项则是 $\epsilon ||\nabla_x L (x,y;\theta)||$
总结
这篇博客梳理了 NLP 对抗训练发展的来龙去脉,介绍了对抗训练的数学定义,并对于两种经典的对抗训练方法,提供了插件式的实现,做了简单的实验对照。由于笔者接触对抗训练的时间也并不长,如果文中有理解偏差的地方,希望读者不吝指出。另外还有一些对抗训练算法,读者有兴趣可以查看一文搞懂 NLP 中的对抗训练以及对抗训练的理解,以及 FGM、PGD 和 FreeLB 的详细介绍这两篇文章
大佬,能不能加个微信,想求教
请问大佬用的是什么开源博客框架,我也想整一个
百度搜 Typecho
if param.requires_grad and emb_name in name:
请问这里判断不应该是 name in emb_name 吗?
nope
作者您好,求教一个问题,这个插件怎么引入到文本相似度计算中啊,是对两个文本的词嵌入都进行扰动吗?这样在训练的过程中会不会改变标签啊,比如原来输入的两个文本是相似的,扰动后变成不相似的,那怎么解决啊?小白实在不懂,求解惑拜托 [拜托]
Adversarial Loss 的部分是不参与梯度计算的,这句话没太听明白,能稍微展开下吗
先谢过。在大佬的博客上学到不少东西,有些内容似乎加密了,可以通过付费查看吗?
加密就是不想让大家看见,比方说我自己的试验记录等等。不是付费内容
大佬能加个好友么?(vx 或者什么) 有一些问题想向您请教。
如果可以那就太好了,哪怕不方便也没关系,确实受益匪浅,再次感谢共享博客!