查看原文
其他

浅谈NLP中的对抗训练方式

林远平 PaperWeekly 2022-07-06


©作者 | 林远平

单位 | QTrade AI研发中心

研究方向 | 自然语言处理




前言


什么是对抗训练呢?说起“对抗”,我们就想起了计算机视觉领域的对抗生成网络(GAN)。在计算机视觉中,对抗思想的基本配置是一个生成器,一个判别器。生成器希望能够生成一些样本能够“欺骗”判别器,让判别器无法正确判别样本。而判别器当然需要不断增强自己的判别能力,使得生成器不能够欺骗自己啦。在这个相互作用下,判别器的能力不断增强,从而得到一个很好的判别器。

下面,我们举一个对抗样本的例子,决定就是你啦,长臂猿~~不对,搞错了,再来,决定就是你啦,胖达。

▲ 图1 对抗样本实例

从图中我们可以看到,panda 在噪声的干扰下被识别成了长臂猿。

对抗样本一般需要具有两个特点:

1、相对于原始输入,所添加的扰动是微小的; 

2、能使模型犯错。


大名鼎鼎的 Min-Max 公式:



该公式分为两个部分,一个是内部损失函数的最大化,一个是外部经验风险的最小化。用一句话形容对抗训练的思路,就是在输入上进行梯度上升(增大 loss)使得输入尽可能跟原来不一样,在参数上进行梯度下降(减小 loss)使得模型尽可能能够正确识别。

众所周知,在 CV 中我们可以通过在原图像中加入噪点,但是并不影响原图像的性质。而在 NLP 领域,我们并不能直接的通过在词编码上添加噪点,因为词嵌入本质上就是 one-hot,如果在 one-hot 上增加上述噪点,就会对原句产生歧义。因此,一个自然的想法就是在 embedding 上增加扰动。



FGM


说起 FGM,那么就绕不开它的前世 FGSM,

我们先讲讲 FGSM 的公式:


Sign 是一个符号函数,如下所示:

▲ 图2 Sign函数

FGSM 作者的想法很简单,就是把扰动加到参数中,这样就能使得损失值越来越大,模型将输入图像错分类成正确类别以外的其他任何一个类别都算攻击成功。其论文通过对损失函数做一个梯度,然后对梯度取 sign,当梯度大于 0 时恒为 1,当梯度小于 0 时恒为 -1,当梯度等于 0 时恒为 0。

但是这样的方式会有一个问题,就是会改变梯度的方向,为了解决这个问题,FGM 从而被提出了。

FGM 取消了符号函数,用二范数做了一个 scale,这样的话刚好避免了 FGM 改变梯度方向的问题。


笔者一直在强调 FGSM 改变了梯度的方向,但可能很多刚接触对抗训练的小伙伴并不懂为什么这么说。下面给个证明:


假设梯度为 ,那么 ,而对于 ,因此可以看出 FGSM 改变了梯度的方向,而 FGM 只是做了个缩放,并没有改变梯度的方向。
接下来笔者把 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:
                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()

接下来我们思考一个问题:

通过 min-max 公式我们可以知道,对抗训练主要做的就是内部 max 的过程,而在内部 max 的过程,本质上是一个非凹的约束优化问题,FGM 解决的思路其实就是梯度上升,那么 FGM 简单粗暴的“一步到位”,是不是有可能并不能走到约束内的最优点呢?

既然一步走到位不行,那么我们就分多步来走,这样不就行了吗?



PGD


Madry 在 18 年提出了用 Projected Gradient Descent(PGD)的方法,简单的说,就是“小步走,多走几步”,如果走出了扰动半径为的空间,就映射回“球面”上,以保证扰动不要过大。公式如下:




其中 为扰动的约束空间, 为每步的步长。


可能公式对于初学者来说不太友好,那么我们直接上伪代码吧。

对于每个输入x,假设运行K步:
1、计算x的前向loss、反向传播得到的梯度并保存,备份初始时的embedding,
   对于每一步t:
   2、根据embedding矩阵的梯度计算出扰动r,并加到当前embedding上,相当于x+r(超出范围则投影回epsilon内)
   3、当t不是第K-1步(K从0开始)时:将梯度变回0,根据(2)中的x+r计算前向loss和反向梯度
   4、当t是第K-1步(K从0开始)时:恢复(1)的梯度,计算最后的x+r并将梯度累加到(1)上
5、将embedding恢复为(1)时的值
6、根据(4)的梯度对参数进行更新

如果伪代码不好理解,那么我们就结合着代码来看吧。

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



FreeLB


FreeLB 是在 PGD 的基础上做了一些改进,我们首先对比一下 PGD 和 FreeLB 的公式

PGD 的公式:


FreeLB 的公式:



FreeLB 和 PGD 主要有两点区别: 

1、PGD 是迭代 K 次 r 后取最后一次扰动的梯度更新参数,FreeLB 是取 K 次迭代中的平均梯度

2、PGD 的扰动范围都在 epsilon 内,因为 PGD 伪代码第 3 步将梯度归 0 了,每次投影都会回到以第 1 步 x 为圆心,半径是 epsilon 的圆内,而 FreeLB 每次的 x 都会迭代,所以 r 的范围更加灵活,更可能接近局部最优


作者原文的伪代码如下:


自己对于伪代码的理解:

对于每个输入x:
  1、通过均匀分布初始化扰动r,初始化梯度g为0,设置步数为K
  对于每步t=1...K:
    2、根据x+r计算前向loss和后向梯度g1,累计梯度g=g+g1/k
    3、更新扰动r,更新方式跟PGD一样
  4、根据g更新梯度

然后我们结合代码来代码来理解吧:


class FreeLB():
    def __init__(self, model, args, optimizer, base_model='xlm-roberta'):
        self.args = args
        self.model = model
        self.adv_K = self.args.adv_K
        self.adv_lr = self.args.adv_lr
        self.adv_max_norm = self.args.adv_max_norm
        self.adv_init_mag = self.args.adv_init_mag  # adv-training initialize with what magnitude, 即我们用多大的数值初始化delta
        self.adv_norm_type = self.args.adv_norm_type
        self.base_model = base_model
        self.optimizer = optimizer

    def attack(self, model, inputs):
        args = self.args
        input_ids = inputs['input_ids']
        #获取初始化时的embedding
        embeds_init = getattr(model, self.base_model).embeddings.word_embeddings(input_ids.to(args.device))

        if self.adv_init_mag > 0:   # 影响attack首步是基于原始梯度(delta=0),还是对抗梯度(delta!=0)
            input_mask = inputs['attention_mask'].to(embeds_init)
            input_lengths = torch.sum(input_mask, 1)
            if self.adv_norm_type == "l2":
                delta = torch.zeros_like(embeds_init).uniform_(-11) * input_mask.unsqueeze(2)
                dims = input_lengths * embeds_init.size(-1)
                mag = self.adv_init_mag / torch.sqrt(dims)
                delta = (delta * mag.view(-111)).detach()
        else:
            delta = torch.zeros_like(embeds_init)  # 扰动初始化
        # loss, logits = None, None


        for astep in range(self.adv_K):
            delta.requires_grad_()
            inputs['inputs_embeds'] = delta + embeds_init  # 累积一次扰动delta
            # inputs['input_ids'] = None
            loss, _ = model(input_ids=None,
                            attention_mask=inputs["attention_mask"].to(args.device),
                             token_type_ids=inputs["token_type_ids"].to(args.device),
                             labels=inputs["sl_labels"].to(args.device),
                            inputs_embeds=inputs["inputs_embeds"].to(args.device))

            loss = loss / self.adv_K

            loss.backward()

            if astep == self.adv_K - 1:
                # further updates on delta
                break

            delta_grad = delta.grad.clone().detach()  # 备份扰动的grad
            if self.adv_norm_type == "l2":
                denorm = torch.norm(delta_grad.view(delta_grad.size(0), -1), dim=1).view(-111)
                denorm = torch.clamp(denorm, min=1e-8)
                delta = (delta + self.adv_lr * delta_grad / denorm).detach()
                if self.adv_max_norm > 0:
                    delta_norm = torch.norm(delta.view(delta.size(0), -1).float(), p=2, dim=1).detach()
                    exceed_mask = (delta_norm > self.adv_max_norm).to(embeds_init)
                    reweights = (self.adv_max_norm / delta_norm * exceed_mask + (1 - exceed_mask)).view(-111)
                    delta = (delta * reweights).detach()
            else:
                raise ValueError("Norm type {} not specified.".format(self.adv_norm_type))

            embeds_init = getattr(model, self.base_model).embeddings.word_embeddings(input_ids.to(args.device))
        return loss

使用 FreeLB 的时候代码如下:

for batch_input, batch_label in data:
    # 正常训练
    loss = model(batch_input, batch_label)
    loss.backward() # 反向传播,得到正常的grad
    # 对抗训练
    freelb = FreeLB( model, args, optimizer, base_model)
    loss_adv = freelb.attack(model, batch_input)
    loss_adv.backward() # 反向传播,并在正常的grad基础上,累加对抗训练的梯度
    # 梯度下降,更新参数
    optimizer.step()
    model.zero_grad()



总结


其实对抗训练在 NLP 中的效果还是不错的,笔者介绍的几种对比算法都是在前人的基础上不断地进行改进,FGSM->FGM->PGD->FreeLB,虽然每个改动点都不是很大,但是确实比较有效果。当然,并不是说 FreeLB 就是最有效的。每个数据集都会有差异,从而会出现 PGD 在某数据集效果是最好的,但是在另外一个数据集反而不如 FGM 的情况也是有可能的。希望本文能对读者们在对抗训练方法方面有一定的帮助,能够有进一步的认识。如果本文哪里您觉得不对的,也欢迎指出,谢谢!



参考文献

[1] FGSM: Explaining and Harnessing Adversarial Examples

[2] FGM: Adversarial Training Methods for Semi-Supervised Text Classification

[3] FreeLB: Enhanced Adversarial Training for Language Understanding

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



特别鸣谢

感谢 TCCI 天桥脑科学研究院对于 PaperWeekly 的支持。TCCI 关注大脑探知、大脑功能和大脑健康。


更多阅读




#投 稿 通 道#

 让你的文字被更多人看到 



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


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


PaperWeekly 鼓励高校实验室或个人,在我们的平台上分享各类优质内容,可以是最新论文解读,也可以是学术热点剖析科研心得竞赛经验讲解等。我们的目的只有一个,让知识真正流动起来。


📝 稿件基本要求:

• 文章确系个人原创作品,未曾在公开渠道发表,如为其他平台已发表或待发表的文章,请明确标注 

• 稿件建议以 markdown 格式撰写,文中配图以附件形式发送,要求图片清晰,无版权问题

• PaperWeekly 尊重原作者署名权,并将为每篇被采纳的原创首发稿件,提供业内具有竞争力稿酬,具体依据文章阅读量和文章质量阶梯制结算


📬 投稿通道:

• 投稿邮箱:hr@paperweekly.site 

• 来稿请备注即时联系方式(微信),以便我们在稿件选用的第一时间联系作者

• 您也可以直接添加小编微信(pwbot02)快速投稿,备注:姓名-投稿


△长按添加PaperWeekly小编




🔍


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

进入知乎首页搜索「PaperWeekly」

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



·
·

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

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