NLP中的对抗训练

什么是对抗训练

对抗训练(adversarial training),第一次由GAN之父 Ian Goodfellow在2015年的ICLR [2] 上提出,简单的来说就是在输入样本上加上一个扰动$r_{adv}$得到对抗样本,然后再用它做输入进行训练。用公式描述就是:

意思就是当$x$被$r_{adv}$扰动后,要找到最佳的参数$\theta$使得$P$最大,则上式最小。也就是要让模型参数在被扰动攻击的情况下仍然能够正确地分类样本。

Goodfellow认为,神经网络由于其线性的特点,很容易受到线性扰动的攻击。

This linear behavior suggests that cheap, analytical perturbations of a linear model should also damage neural networks.

他提出了 Fast Gradient Sign Method(FGSM),来计算输入样本的扰动$r_{adv}$。

其中,sgn为符号函数,L为损失函数。Goodfellow发现,令 ϵ=0.25,能给一个单层分类器造成99.9%的错误率。这个扰动计算的思路也很容易理解:将输入样本向着关于损失的梯度再进一步,得到的对抗样本当然能造成更大的损失,提高模型的错误率。

Goodfellow 还总结了对抗训练的两个作用:

  1. 提高模型应对恶意对抗样本时的鲁棒性。

  2. 作为一种 regularization,减少 overfitting,提高泛化能力。

更进一步

Madry在2018年的ICLR中总结了之前的工作,并从优化的视角,将问题重新定义成了一个找鞍点的问题,也即是大名鼎鼎的Min-Max公式:

该公式分为两个部分,一个是内部损失函数的最大化,一个是外部经验风险的最小化。

  1. 内部Max是为了找到能让分类器效果最worst的扰动,也就是最佳的攻击,S为扰动范围。
  2. 外部Min是为了防御该攻击,找到最鲁棒的模型参数$\theta$,让分类器能够将对抗样本也正确分类。

NLP中的应用

Goodfellow 在17年的ICLR [4] 中提出了可以在连续的 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.

上面提到,对抗训练有两个作用,一是提高模型对恶意攻击的鲁棒性,二是提高模型的泛化能力。在 CV 任务,根据经验性的结论,对抗训练往往会使得模型在非对抗样本上的表现变差,然而神奇的是,在 NLP 任务中,模型的泛化能力反而变强了,如[1]中所述:

While adversarial training boosts the robustness, it is widely accepted by computer vision researchers that it is at odds with generalization, with classification accuracy on non-corrupted images dropping as much as 10% on CIFAR-10, and 15% on Imagenet (Madry et al., 2018; Xie et al., 2019). Surprisingly, people observe the opposite result for language models (Miyato et al., 2017; Cheng et al., 2019), showing that adversarial training can improve both generalization and robustness.

因此,在 NLP 任务中,对抗训练的角色不再是为了防御基于梯度的恶意攻击,反而更多的是作为一种 regularization,提高模型的泛化能力。

Pytorch实现

Fast Gradient Method(FGM)

Goodfellow 在 15 年的 ICLR [2] 中提出了 Fast Gradient Sign Method(FGSM),随后,在 17 年的 ICLR [4] 中,Goodfellow 对 FGSM 中计算扰动的部分做了一点简单的修改。假设输入的文本序列的 embedding vectors $
\left[v_{1}, v_{2}, \dots, v_{T}\right]
$为 x ,embedding 的扰动为:

实际上就是取消了符号函数,用二范式做了一个 scale,需要注意的是:这里的 norm 计算的是,每个样本的输入序列中出现过的词组成的矩阵的梯度 norm。原作者提供了一个 TensorFlow 的实现 [5],在他的实现中,公式里的 x 是 embedding 后的中间结果(batch_size, timesteps, hidden_dim),对其梯度 g 的后面两维计算 norm,得到的是一个 (batch_size, 1, 1) 的向量$|g|_{2}$。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import torch
class FGM():
def __init__(self, model):
self.model = model
self.backup = {}

def attack(self, epsilon=1., emb_name='emb.'):
# emb_name这个参数要换成你模型中embedding的参数名
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)
if norm != 0 and not torch.isnan(norm):
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 = {}

需要使用对抗训练的时候,只需要添加五行代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 初始化
fgm = FGM(model)
for batch_input, batch_label in data:
# 正常训练
loss = model(batch_input, batch_label)
loss.backward() # 反向传播,得到正常的grad
# 对抗训练
fgm.attack() # 在embedding上添加对抗扰动
loss_adv = model(batch_input, batch_label)
loss_adv.backward() # 反向传播,并在正常的grad基础上,累加对抗训练的梯度
fgm.restore() # 恢复embedding参数
# 梯度下降,更新参数
optimizer.step()
model.zero_grad()

PyTorch 为了节约内存,在 backward 的时候并不保存中间变量的梯度。因此,如果需要完全照搬原作的实现,需要用 register_hook 接口 [6] 将 embedding 后的中间变量的梯度保存成全局变量,norm 后面两维,计算出扰动后,在对抗训练 forward 时传入扰动,累加到 embedding 后的中间变量上,得到新的 loss,再进行梯度下降。不过这样实现就与我们追求插件式简单好用的初衷相悖,这里就不赘述了,感兴趣的读者可以自行实现。

Projected Gradient Descent(PGD)

内部 max 的过程,本质上是一个非凹的约束优化问题,FGM 解决的思路其实就是梯度上升,那么 FGM 简单粗暴的“一步到位”,是不是有可能并不能走到约束内的最优点呢?当然是有可能的。于是,一个很 intuitive 的改进诞生了:Madry 在 18 年的 ICLR 中 [3],提出了用 Projected Gradient Descent(PGD)的方法,简单的说,就是“小步走,多走几步”,如果走出了扰动半径为 ϵ 的空间,就映射回“球面”上,以保证扰动不要过大:

其中$\mathcal{S} = r\in R^{d}$,$|r|_{2} \leq \epsilon $为扰动的约束空间,$\alpha$为小步的步长。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import torch
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 and not torch.isnan(norm):
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 param_data + r

def backup_grad(self):
for name, param in self.model.named_parameters():
if param.requires_grad:
self.grad_backup[name] = param.grad

def restore_grad(self):
for name, param in self.model.named_parameters():
if param.requires_grad:
param.grad = self.grad_backup[name]

使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
pgd = PGD(model)
K = 3
for batch_input, batch_label in data:
# 正常训练
loss = model(batch_input, batch_label)
loss.backward() # 反向传播,得到正常的grad
pgd.backup_grad()
# 对抗训练
for t in range(K):
pgd.attack(is_first_attack=(t==0)) # 在embedding上添加对抗扰动, first attack时备份param.data
if t != K-1:
model.zero_grad()
else:
pgd.restore_grad()
loss_adv = model(batch_input, batch_label)
loss_adv.backward() # 反向传播,并在正常的grad基础上,累加对抗训练的梯度
pgd.restore() # 恢复embedding参数
# 梯度下降,更新参数
optimizer.step()
model.zero_grad()

在 [3] 中,作者将这一类通过一阶梯度得到的对抗样本称之为“一阶对抗”,在实验中,作者发现,经过 PGD 训练过的模型,对于所有的一阶对抗都能得到一个低且集中的损失值,如下图所示:

我们可以看到,面对约束空间 S 内随机采样的十万个扰动,PGD 模型能够得到一个非常低且集中的 loss 分布,因此,在论文中,作者称 PGD 为“一阶最强对抗”。也就是说,只要能搞定 PGD 对抗,别的一阶对抗就不在话下了。

Virtual Adversarial Training

除了监督训练,对抗训练还可以用在半监督任务中,尤其对于 NLP 任务来说,很多时候输入的无监督文本多的很,但是很难大规模地进行标注,那么就可以参考 [7] 中提到的 Virtual Adversarial Training 进行半监督训练。

首先,我们抽取一个随机标准正态扰动 $d \sim N(0, I) \in R^{d}$ ,加到 embedding 上,并用 KL 散度计算梯度:

然后,用得到的梯度,计算对抗扰动,并进行对抗训练:

实现方法跟 FGM 差不多。

参考链接:https://mp.weixin.qq.com/s/zwzl-Tel3Avc4Dm7L5FS5A

Reference

[1] FreeLB: Enhanced Adversarial Training for Language Understanding

https://arxiv.org/abs/1909.11764

[2] Explaining and Harnessing Adversarial Examples

https://arxiv.org/abs/1412.6572

[3] Towards Deep Learning Models Resistant to Adversarial Attacks

https://arxiv.org/abs/1706.06083

[4] Adversarial Training Methods for Semi-Supervised Text Classification

https://arxiv.org/abs/1605.07725

[5] Adversarial Text Classification原作实现

https://github.com/tensorflow/models/blob/e97e22dfcde0805379ffa25526a53835f887a860/research/adversarial_text/adversarial_losses.py

[6] register_hook api

https://www.cnblogs.com/SivilTaram/p/pytorch_intermediate_variable_gradient.html

[7] Distributional Smoothing with Virtual Adversarial Training

https://arxiv.org/abs/1507.00677

11FjxS.jpg