Press "Enter" to skip to content

闲话NLP中的对抗训练

本站内容均来自兴趣收集,如不慎侵害的您的相关权益,请留言告知,我们将尽快删除.谢谢.

简介

 

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

 

一开始打比赛,看到大佬们都说使用对抗训练提升了一丢丢的效果,让我仿佛看到了炼丹的法宝,实际自己上手一用,效果不增反降。简直是反向操作,心里苦啊。

 

本篇博客参考自 功守道:NLP中的对抗训练 + PyTorch实现

 

首先来了解一下什幺是“对抗训练”

 

对抗训练是一种引入噪声的训练方式,可以对参数进行正则化,提升模型鲁棒性和泛化能力。

 

什幺样的样本才是最好的对抗样本呢?对抗样本一般需要具备两个特点:

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

对抗训练的基本概念

 

GAN之父Ian Goodfellow在15年的ICLR中第一次提出了对抗训练这个概念,简而言之,就是在原始输入样本$x$加上一个扰动$r_adv$,得到对抗样本后,用其进行训练。也就是说,问题可以被抽象成这幺一个模型:

 

\[min_\theta-log P(y\mid x+r_{adv};\theta)\]

 

其中,$y$为gold label,$\theta$为模型参数。那幺扰动要如何计算呢?Goodfellow认为, 神经网络由于其线性的特点,很容易受到扰动的攻击

 

于是,他提出了Fast Gradient Sign Method(FGSM),来计算输入样本的扰动。扰动可以被定义为:

 

\[r_{adv} = \epsilon *sgn(\nabla_xL(\theta,x,y))\]

 

其中,$sgn$为符号函数,$L$为损失函数。Goodfellow发现,令$\epsilon=0.25$,用这个扰动能给一个单层分类起造成99.9%的错误率。看似这个扰动的发现有点拍脑门,但是仔细想想,其实这个扰动计算的思想可以理解为:将输入样本向着损失上升的方向再进一步,得到的对抗样本就能造成更大的损失,提高模型的错误率。回想我们上一节提到的对抗样本的两个要求,FGSM刚好可以完美地解决。

 

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

提高模型应对恶意样本时的鲁棒性;
作为一种regularization,减少过拟合,提高泛化能力。

Min-Max公式

 

对抗训练的理论部分被阐述得还是比较intuitive,Madry在2018年的LCLR中总结了之前的工作,并从优化的角度,将问题重新定义成了一个找鞍点的问题,也就是大名鼎鼎的MIin-Max公式:

 

\[min_\theta E_{(x,y)}\sim D[max_{r_{adv}\in S}L(\theta,x+r_{adv},y)]\]

 

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

 

 

    1. 内部max是为了找到worst-case的扰动,也就是攻击,其中,$L$为损失函数,$S$为扰动的范围空间。

 

    1. 外部min是为了基于该攻击方式,找到最鲁棒的模型参数,也就是防御,其中$D$是输入样本的分布。

 

 

上述公式简单清晰地定义了对抗样本攻防“矛与盾”的两个问题:如何构造足够强的对抗样本?以及,如何使模型变得刀枪不入?剩下的,就是如何求解的问题了。

 

从CV到NLP

 

以上提到的一些工作都还是停留在CV的领域,那幺问题来了,可否将对抗训练迁移到NLP上呢?答案是肯定的,但是,我们得考虑这幺几个问题:

 

首先,cv任务的输入是连续的RGB值,而NLP问题中,输入是离散的单词序列,一般以one-hot vector的形式呈现,如果直接在raw text 上进行扰动,那幺扰动的大小和方向可能都没什幺意义。Goodfellow在17年的ICLR中提出了可以在连续的embedding上做了扰动。

 

乍一思考,觉得这个方案似乎特别完美,。然而,对比图像领域中直接在原始输入加扰动的做法,在embedding上加扰动会带来这幺一个问题:这个被构造出来的“对抗样本”并不能对应到某个单词上,因此,反过来在inference的时候,对手也没有办法通过修改原始输入得到这样的对抗样本。在CV任务,根据经验性的结论,对抗训练往往使得模型在非对抗样本上表现变差,然而神奇的是,在NLP任务重,模型的泛化能力反而变强了。

 

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

 

用一句话形容对抗训练的思路,就是 在输入上进行梯度上升(增大loss),在参数上进行梯度下降(减小loss)
。由于输入会进行embedding lookup,所以实际的做法是在embedding table进行梯度上升。

 

NLP中的两种对抗训练+Pytorch实现

 

a.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 的扰动为:

 

\(r_{adv} =\epsilon * g/\mid\mid g \mid\mid\\
g=\nabla_xL(\theta,x,y)\)
实际上就是取消了符号函数,用二范式做了一个scale,需要注意的是:这里的norm计算的是,每个样本的输入序列中出现过的词组成的矩阵的梯度norm。为了实现插件式的调用,笔者将一个batch抽象成一个样本,一个batch统一用一个norm,由于本来norm也只是一个scale的作用,影响不大。笔者的实现如下:

 

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 = {}

 

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

 

# 初始化
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()

 

b. Projected Gradient Descent(PGD)

 

内部max的过程,本质上是一个非凹的约束优化问题,FGM解决的思路其实就是梯度上升, 那幺FGM简单粗暴的“一步到位”,是不是有可能并不能走到约束内的最优点呢?
当然是有可能的。于是,一个很intuitive的改进诞生了:Madry在18年的ICLR中,提出了用Projected Gradient Descent(PGD)的方法,简单的说,就是 “小步走,多走几步”
,如果走出了扰动半径为$\epsilon$的空间,就映射回“球面”上,以保证扰动不要过大:
\(x_{t+1}=\Pi_{x+S}(x_t+\alpha g(x_t)/\mid\mid g(x_t)\mid\mid_2)\\
g(x_t) = \nabla_xL(\theta,x_t,y)\)
其中s
\(S=r\in \R ^d: \mid\mid r \mid\mid_2 \leq \epsilon\)
为扰动的约束空间,$\alpha$为小步的步长。

 

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 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()
    # 对抗训练
    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()

 

总结

 

上面简单地介绍了NLP中对抗训练概念以及常用的两种对抗训练方式,在一些NLP任务取得了一些性能的提升。不过,根据我们使用的经验来看,是否有效有时也取决于数据集。毕竟炼丹,真的很奇妙。

Be First to Comment

发表评论

电子邮件地址不会被公开。 必填项已用*标注