查看原文
其他

训练技巧 | 功守道:NLP中的对抗训练 + PyTorch实现

Nicolas PaperWeekly 2022-03-17


本文分享一个“万物皆可盘”的 NLP 对抗训练实现,只需要四行代码即可调用。盘他。

作者丨Nicolas

单位丨追一科技AI Lab研究员

研究方向丨信息抽取、机器阅读理解


最近,微软的 FreeLB-Roberta [1] 靠着对抗训练(Adversarial Training)在 GLUE 榜上超越了 Facebook 原生的 Roberta,追一科技也用到了这个方法仅凭单模型 [2] 就在 CoQA 榜单中超过了人类,似乎“对抗训练”一下子变成了 NLP 任务的一把利器。刚好笔者最近也在看这方面的内容,所以开一篇文章,讲一下。

▲ GLUE Leaderboard

▲ CoQA Leaderboard
提到“对抗”,相信大多数人的第一反应都是 CV 中的对抗生成网络(GAN),殊不知,其实对抗也可以作为一种防御机制,并且经过简单的修改,便能用在 NLP 任务上,提高模型的泛化能力。关键是,对抗训练可以写成一个插件的形式,用几行代码就可以在训练中自由地调用,简单有效,使用成本低。不过网上的大多数博客对于 NLP 中的对抗训练都介绍得比较零散且无代码实现,笔者在这篇文章中,对 NLP 任务中的对抗训练做了一个简单的综述,并提供了插件形式的 PyTorch 实现。 
本文专注于 NLP 对抗训练的介绍,对对抗攻击基础感兴趣的读者,可以看这几篇博客及论文 [3] [4] [5],这里就不赘述了。不想要理解理论细节的读者也可以直接看最后的代码实现。


对抗样本


我们常常会听到“对抗样本”、“对抗攻击”、“对抗训练”等等这些令人头秃的概念,为了让大家对“对抗”有个更清晰的认识,我们先把这些概念捋捋清楚。

▲ Taxonomy
Szegedy 在 14 年的 ICLR 中 [6] 提出了对抗样本这个概念。如上图,对抗样本可以用来攻击和防御,而对抗训练其实是“对抗”家族中防御的一种方式,其基本的原理呢,就是通过添加扰动构造一些对抗样本,放给模型去训练,以攻为守,提高模型在遇到对抗样本时的鲁棒性,同时一定程度也能提高模型的表现和泛化能力。 
那么,什么样的样本才是好的对抗样本呢?对抗样本一般需要具有两个特点:
1. 相对于原始输入,所添加的扰动是微小的;2. 能使模型犯错。
下面是一个对抗样本的例子,决定就是你啦,胖达:

▲ 一只胖达加了点扰动就被识别成了长臂猿


对抗训练的基本概念


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


其中,y 为 gold label,θ 为模型参数。那扰动要如何计算呢?Goodfellow 认为,神经网络由于其线性的特点,很容易受到线性扰动的攻击。 
This linear behavior suggests that cheap, analytical perturbations of a linear model should also damage neural networks.
于是,他提出了 Fast Gradient Sign Method (FGSM) ,来计算输入样本的扰动。扰动可以被定义为:

 其中,sgn 为符号函数,L 为损失函数。Goodfellow 发现,令 ϵ=0.25 ,用这个扰动能给一个单层分类器造成 99.9% 的错误率。看似这个扰动的发现有点拍脑门,但是仔细想想,其实这个扰动计算的思想可以理解为:将输入样本向着损失上升的方向再进一步,得到的对抗样本就能造成更大的损失,提高模型的错误率。回想我们上一节提到的对抗样本的两个要求,FGSM 刚好可以完美地解决。 
[7] 中,Goodfellow 还总结了对抗训练的两个作用: 
1. 提高模型应对恶意对抗样本时的鲁棒性; 2. 作为一种 regularization,减少 overfitting,提高泛化能力。


Min-Max公式


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


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


从CV到NLP


以上提到的一些工作都还是停留在 CV 领域的,那么问题来了,可否将对抗训练迁移到 NLP 上呢?答案是肯定的,但是,我们得考虑这么几个问题: 
首先,CV 任务的输入是连续的 RGB 的值,而 NLP 问题中,输入是离散的单词序列,一般以 one-hot vector 的形式呈现,如果直接在 raw text 上进行扰动,那么扰动的大小和方向可能都没什么意义。Goodfellow 在 17 年的 ICLR 中 [9] 提出了可以在连续的 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 上加扰动会带来这么一个问题:这个被构造出来的“对抗样本”并不能 map 到某个单词,因此,反过来在 inference 的时候,对手也没有办法通过修改原始输入得到这样的对抗样本。
我们在上面提到,对抗训练有两个作用,一是提高模型对恶意攻击的鲁棒性,二是提高模型的泛化能力。在 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,提高模型的泛化能力。
有了这些“思想准备”,我们来看看 NLP 对抗训练的常用的几个方法和具体实现吧。


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


Fast Gradient Method(FGM) 


上面我们提到,Goodfellow 在 15 年的 ICLR [7] 中提出了 Fast Gradient Sign Method(FGSM),随后,在 17 年的 ICLR [9] 中,Goodfellow 对 FGSM 中计算扰动的部分做了一点简单的修改。假设输入的文本序列的 embedding vectors  为 x ,embedding 的扰动为:



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


为了实现插件式的调用,笔者将一个 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()


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


Projected Gradient Descent(PGD)


内部 max 的过程,本质上是一个非凹的约束优化问题,FGM 解决的思路其实就是梯度上升,那么 FGM 简单粗暴的“一步到位”,是不是有可能并不能走到约束内的最优点呢?当然是有可能的。于是,一个很 intuitive 的改进诞生了:Madry 在 18 年的 ICLR 中 [8],提出了用 Projected Gradient Descent(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:
                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]


使用的时候,要麻烦一点:


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()

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



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


实验对照


为了说明对抗训练的作用,笔者选了四个 GLUE 中的任务进行了对照试验。实验代码是用的 Huggingface 的 transfomers/examples/run_glue.py [12],超参都是默认的,对抗训练用的也是相同的超参。


我们可以看到,对抗训练还是有效的,在 MRPC 和 RTE 任务上甚至可以提高三四个百分点。不过,根据我们使用的经验来看,是否有效有时也取决于数据集。毕竟:缘,妙不可言~


总结


这篇博客梳理了 NLP 对抗训练发展的来龙去脉,介绍了对抗训练的数学定义,并对于两种经典的对抗训练方法,提供了插件式的实现,做了简单的实验对照。由于笔者接触对抗训练的时间也并不长,如果文中有理解偏差的地方,希望读者不吝指出。


一个彩蛋:Virtual Adversarial Training


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


首先,我们抽取一个随机标准正态扰动(),加到 embedding 上,并用 KL 散度计算梯度:



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



实现方法跟 FGM 差不多,这里就不给出了。


Reference


[1] FreeLB: Enhanced Adversarial Training for Language Understanding https://arxiv.org/abs/1909.11764 [2] Technical report on Conversational Question Answeringhttps://arxiv.org/abs/1909.10772 [3] EYD与机器学习:对抗攻击基础知识(一)https://zhuanlan.zhihu.com/p/37260275 [4] Towards a Robust Deep Neural Network in Text Domain A Surveyhttps://arxiv.org/abs/1902.07285 [5] Adversarial Attacks on Deep Learning Models in Natural Language Processing: A Surveyhttps://arxiv.org/abs/1901.06796 [6] Intriguing properties of neural networkshttps://arxiv.org/abs/1312.6199 [7] Explaining and Harnessing Adversarial Exampleshttps://arxiv.org/abs/1412.6572 [8] Towards Deep Learning Models Resistant to Adversarial Attackshttps://arxiv.org/abs/1706.06083 [9] Adversarial Training Methods for Semi-Supervised Text Classificationhttps://arxiv.org/abs/1605.07725 [10] Adversarial Text Classification原作实现https://github.com/tensorflow/models/blob/e97e22dfcde0805379ffa25526a53835f887a860/research/adversarial_text/adversarial_losses.py [11] register_hook apihttps://www.cnblogs.com/SivilTaram/p/pytorch_intermediate_variable_gradient.html [12] huggingface的transformershttps://github.com/huggingface/transformers/tree/master/examples [13] Distributional Smoothing with Virtual Adversarial Traininghttps://arxiv.org/abs/1507.00677



点击以下标题查看更多往期内容: 





#投 稿 通 道#

 让你的论文被更多人看到 



如何才能让更多的优质内容以更短路径到达读者群体,缩短读者寻找优质内容的成本呢?答案就是:你不认识的人。


总有一些你不认识的人,知道你想知道的东西。PaperWeekly 或许可以成为一座桥梁,促使不同背景、不同方向的学者和学术灵感相互碰撞,迸发出更多的可能性。 


PaperWeekly 鼓励高校实验室或个人,在我们的平台上分享各类优质内容,可以是最新论文解读,也可以是学习心得技术干货。我们的目的只有一个,让知识真正流动起来。


📝 来稿标准:

• 稿件确系个人原创作品,来稿需注明作者个人信息(姓名+学校/工作单位+学历/职位+研究方向) 

• 如果文章并非首发,请在投稿时提醒并附上所有已发布链接 

• PaperWeekly 默认每篇文章都是首发,均会添加“原创”标志


📬 投稿邮箱:

• 投稿邮箱:hr@paperweekly.site 

• 所有文章配图,请单独在附件中发送 

• 请留下即时联系方式(微信或手机),以便我们在编辑发布时和作者沟通




🔍


现在,在「知乎」也能找到我们了

进入知乎首页搜索「PaperWeekly」

点击「关注」订阅我们的专栏吧



关于PaperWeekly


PaperWeekly 是一个推荐、解读、讨论、报道人工智能前沿论文成果的学术平台。如果你研究或从事 AI 领域,欢迎在公众号后台点击「交流群」,小助手将把你带入 PaperWeekly 的交流群里。


▽ 点击 | 阅读原文 | 获取最新论文推荐

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存