提到“对抗”,相信大多数人的第一反应都是CV中的对抗生成网络 (GAN),殊不知,其实对抗也可以作为一种防御机制,并且经过简单的修改,便能用在NLP任务上,提高模型的泛化能力。关键是,对抗训练可以写成一个插件的形式,用几行代码就可以在训练中自由地调用,简单有效,使用成本低

1. 对抗样本

NLP中的对抗训练 - 图1

如上图,对抗样本可以用来攻击和防御,而对抗训练其实是“对抗”家族中防御的一种方式,其基本的原理呢,就是通过添加扰动构造一些对抗样本,放给模型去训练,以攻为守,提高模型在遇到对抗样本时的鲁棒性,同时一定程度也能提高模型的表现和泛化能力。

那么,什么样的样本才是好的对抗样本呢?对抗样本一般需要具有两个特点:

  1. 相对于原始输入,所添加的扰动是微小的;
  2. 能使模型犯错。

2. 对抗训练的基本概念

对抗训练,简而言之,就是在原始输入样本 NLP中的对抗训练 - 图2 上加一个扰动 NLP中的对抗训练 - 图3 ,得到对抗样本后,用其进行训练。也就是说,问题可以被抽象成这么一个模型:
NLP中的对抗训练 - 图4
其中, NLP中的对抗训练 - 图5 为 label, NLP中的对抗训练 - 图6 为模型参数。那么问题来了,我们将如何获得扰动NLP中的对抗训练 - 图7 ?我们应该将扰动NLP中的对抗训练 - 图8添加到哪里呢?

(1)我们该如何获取扰动?

GAN之父Ian Goodfellow认为,神经网络由于其线性的特点,很容易受到线性扰动的攻击。于是,他提出了 Fast Gradient Sign Method (FGSM) ,来计算输入样本的扰动。扰动可以被定义为:
NLP中的对抗训练 - 图9
其中, NLP中的对抗训练 - 图10 为符号函数, NLP中的对抗训练 - 图11 为损失函数。需要注意的是:我们的扰动是根据损失函数的梯度来计算的,Goodfellow发现,令 NLP中的对抗训练 - 图12 ,用这个扰动能给一个单层分类器造成99.9%的错误率。其实这个扰动计算的思想可以理解为:将输入样本向着损失上升的方向再进一步,得到的对抗样本就能造成更大的损失,提高模型的错误率。回想我们上一节提到的对抗样本的两个特点,FGSM刚好可以完美地解决。
Goodfellow还总结了对抗训练的两个作用:

  1. 提高模型应对恶意对抗样本时的鲁棒性。
  2. 作为一种regularization,减少overfitting,提高泛化能力。

    (2)我们应该将扰动添加在哪里呢?

    对于文本分类,输入是离散的,通常用一系列高维one-hot向量表示。由于高维one-hot向量集不允许无穷小扰动,我们将扰动定义在连续的word embedding上,而不是离散的one-hot向量上。这里我们以LSTM为例。
    image.png
    NLP中的对抗训练 - 图14代表每个词的频率,NLP中的对抗训练 - 图15代表每个词的embedding,NLP中的对抗训练 - 图16是对NLP中的对抗训练 - 图17归一化之后的结果,NLP中的对抗训练 - 图18的计算方式如下:
    image.png

    3. Min-Max 公式

    对抗训练中的Min-Max公式:
    NLP中的对抗训练 - 图20
    该公式分为两个部分,一个是内部损失函数的最大化,一个是外部经验风险的最小化。
    (1) 内部max是为了找到worst-case的扰动,也就是攻击,其中, NLP中的对抗训练 - 图21 为损失函数, NLP中的对抗训练 - 图22 为扰动的范围空间。
    (2)外部min是为了基于该攻击方式,找到最鲁棒的模型参数,也就是防御,其中 NLP中的对抗训练 - 图23 是输入样本的分布。
    这个公式简单清晰地定义了对抗样本攻防“矛与盾”的两个问题:如何构造足够强的对抗样本?以及,如何使模型变得刀枪不入?剩下的,就是如何求解的问题了。

4. NLP中的两种对抗训练

(1) Fast Gradient Method(FGM)

上面我们提到,Goodfellow在15年的ICLR 中提出了Fast Gradient Sign Method(FGSM),随后,Goodfellow对FGSM中计算扰动的部分做了一点简单的修改。假设输入的文本序列的embedding vectors NLP中的对抗训练 - 图24NLP中的对抗训练 - 图25 ,embedding的扰动为:
NLP中的对抗训练 - 图26
实际上就是取消了符号函数,用二范式做了一个缩放,需要注意的是:这里的norm计算的是,每个样本的输入序列中出现过的词组成的矩阵的梯度norm,具体实现可以参考torch.norm()。公式里的 NLP中的对抗训练 - 图27 是embedding后的中间结果(batch_size, timesteps, hidden_dim),对其梯度 NLP中的对抗训练 - 图28 的后面两维计算norm,得到的是一个(batch_size, 1, 1)的向量 NLP中的对抗训练 - 图29 。为了实现插件式的调用,这里我们将一个batch抽象成一个样本,一个batch统一用一个norm,由于本来norm也只是一个缩放的作用,影响不大。
伪代码如下:

  1. 对于每个x:
  2. 1.计算x的前向loss、反向传播得到梯度
  3. 2.根据embedding矩阵的梯度计算出r,并加到当前embedding上,相当于x+r
  4. 3.计算x+r的前向loss,反向传播得到对抗的梯度,累加到(1)的梯度上
  5. 4.embedding恢复为(1)时的值
  6. 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)的方法,简单的说,就是“小步走,多走几步”,如果走出了扰动半径为 NLP中的对抗训练 - 图30 的空间,就映射回“球面”上,以保证扰动不要过大:
NLP中的对抗训练 - 图31
其中 NLP中的对抗训练 - 图32 为扰动的约束空间, NLP中的对抗训练 - 图33 为小步的步长。这里 NLP中的对抗训练 - 图34 含义时在 NLP中的对抗训练 - 图35 -ball上执行投射。
在论文中,作者将通过一阶梯度得到的样本称为“一阶对抗”,而在所有的一阶对抗样本中,效果上认为PGD为最优的方法。

  • NLP中的对抗训练 - 图36 为在 NLP中的对抗训练 - 图37 -ball球上的投影,即如果我们的扰动幅度过大,我们将origin部分其拉回边界球project处,

                                                       ![](https://cdn.nlark.com/yuque/0/2020/jpg/2213227/1601462461787-bab3d19c-4a7c-4272-9396-cc7f07880c91.jpg#align=left&display=inline&height=172&margin=%5Bobject%20Object%5D&originHeight=172&originWidth=205&size=0&status=done&style=none&width=205)
    
  • 多次操作后,即是扰动在球内多次叠加即:

                                                   ![](https://cdn.nlark.com/yuque/0/2020/jpg/2213227/1601462461756-65706c60-677e-4261-9769-0acf8488d820.jpg#align=left&display=inline&height=204&margin=%5Bobject%20Object%5D&originHeight=204&originWidth=215&size=0&status=done&style=none&width=215)<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实现了出来。