提到“对抗”,相信大多数人的第一反应都是CV中的对抗生成网络 (GAN),殊不知,其实对抗也可以作为一种防御机制,并且经过简单的修改,便能用在NLP任务上,提高模型的泛化能力。关键是,对抗训练可以写成一个插件的形式,用几行代码就可以在训练中自由地调用,简单有效,使用成本低。
1. 对抗样本
如上图,对抗样本可以用来攻击和防御,而对抗训练其实是“对抗”家族中防御的一种方式,其基本的原理呢,就是通过添加扰动构造一些对抗样本,放给模型去训练,以攻为守,提高模型在遇到对抗样本时的鲁棒性,同时一定程度也能提高模型的表现和泛化能力。
那么,什么样的样本才是好的对抗样本呢?对抗样本一般需要具有两个特点:
- 相对于原始输入,所添加的扰动是微小的;
- 能使模型犯错。
2. 对抗训练的基本概念
对抗训练,简而言之,就是在原始输入样本 上加一个扰动
,得到对抗样本后,用其进行训练。也就是说,问题可以被抽象成这么一个模型:
其中, 为 label,
为模型参数。那么问题来了,我们将如何获得扰动
?我们应该将扰动
添加到哪里呢?
(1)我们该如何获取扰动?
GAN之父Ian Goodfellow认为,神经网络由于其线性的特点,很容易受到线性扰动的攻击。于是,他提出了 Fast Gradient Sign Method (FGSM) ,来计算输入样本的扰动。扰动可以被定义为:
其中, 为符号函数,
为损失函数。需要注意的是:我们的扰动是根据损失函数的梯度来计算的,Goodfellow发现,令
,用这个扰动能给一个单层分类器造成99.9%的错误率。其实这个扰动计算的思想可以理解为:将输入样本向着损失上升的方向再进一步,得到的对抗样本就能造成更大的损失,提高模型的错误率。回想我们上一节提到的对抗样本的两个特点,FGSM刚好可以完美地解决。
Goodfellow还总结了对抗训练的两个作用:
- 提高模型应对恶意对抗样本时的鲁棒性。
- 作为一种regularization,减少overfitting,提高泛化能力。
(2)我们应该将扰动添加在哪里呢?
对于文本分类,输入是离散的,通常用一系列高维one-hot向量表示。由于高维one-hot向量集不允许无穷小扰动,我们将扰动定义在连续的word embedding上,而不是离散的one-hot向量上。这里我们以LSTM为例。代表每个词的频率,
代表每个词的embedding,
是对
归一化之后的结果,
的计算方式如下:
3. Min-Max 公式
对抗训练中的Min-Max公式:
该公式分为两个部分,一个是内部损失函数的最大化,一个是外部经验风险的最小化。
(1) 内部max是为了找到worst-case的扰动,也就是攻击,其中,为损失函数,
为扰动的范围空间。
(2)外部min是为了基于该攻击方式,找到最鲁棒的模型参数,也就是防御,其中是输入样本的分布。
这个公式简单清晰地定义了对抗样本攻防“矛与盾”的两个问题:如何构造足够强的对抗样本?以及,如何使模型变得刀枪不入?剩下的,就是如何求解的问题了。
4. NLP中的两种对抗训练
(1) Fast Gradient Method(FGM)
上面我们提到,Goodfellow在15年的ICLR 中提出了Fast Gradient Sign Method(FGSM),随后,Goodfellow对FGSM中计算扰动的部分做了一点简单的修改。假设输入的文本序列的embedding vectors 为
,embedding的扰动为:
实际上就是取消了符号函数,用二范式做了一个缩放,需要注意的是:这里的norm计算的是,每个样本的输入序列中出现过的词组成的矩阵的梯度norm,具体实现可以参考torch.norm()。公式里的 是embedding后的中间结果(batch_size, timesteps, hidden_dim),对其梯度
的后面两维计算norm,得到的是一个(batch_size, 1, 1)的向量
。为了实现插件式的调用,这里我们将一个batch抽象成一个样本,一个batch统一用一个norm,由于本来norm也只是一个缩放的作用,影响不大。
伪代码如下:
对于每个x:
1.计算x的前向loss、反向传播得到梯度
2.根据embedding矩阵的梯度计算出r,并加到当前embedding上,相当于x+r
3.计算x+r的前向loss,反向传播得到对抗的梯度,累加到(1)的梯度上
4.将embedding恢复为(1)时的值
5.根据(3)的梯度对参数进行更新
pytorch实现如下:
#FGM的定义
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:
#先用字典将embedding参数备份
self.backup[name] = param.data.clone()
#求梯度的二范式
norm = torch.norm(param.grad)
if norm != 0 and not torch.isnan(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
#将之前备份的embedding参数还原
param.data = self.backup[name]
self.backup = {}
#FGM的使用,只需要做一点小小的改变,加几行代码即可
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()
(2)Projected Gradient Descent(PGD)
内部max的过程,本质上是一个非凹的约束优化问题,FGM解决的思路其实就是梯度上升,那么FGM简单粗暴的“一步到位”,是不是有可能并不能走到约束内的最优点呢?当然是有可能的。于是,一个很直观的改进诞生了:Madry在18年的ICLR中,提出了用Projected Gradient Descent(PGD)的方法,简单的说,就是“小步走,多走几步”,如果走出了扰动半径为 的空间,就映射回“球面”上,以保证扰动不要过大:
其中 为扰动的约束空间,
为小步的步长。这里
含义时在
-ball上执行投射。
在论文中,作者将通过一阶梯度得到的样本称为“一阶对抗”,而在所有的一阶对抗样本中,效果上认为PGD为最优的方法。
为在
-ball球上的投影,即如果我们的扰动幅度过大,我们将origin部分其拉回边界球project处,

多次操作后,即是扰动在球内多次叠加即:
<br />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)的梯度对参数进行更新
PGD的pytorch实现如下: ```cpp
PGD的定义
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: #第一次攻击,先备份embedding的参数 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 #把计算的扰动加到embedding参数中 param.data.add_(r_at) #利用project方法判断所加扰动是否超出约束空间,若超出重新计算 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 = 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()
5.总结
这篇文章简单介绍了对抗训练的概念,公式推导,并对经典的两种训练方法FGM,PGD用pytorch实现了出来。