NLP炼丹技巧合集

原创:郑佳伟

在NLP任务中,会有很多为了提升模型效果而提出的优化,为了方便记忆,所以就把这些方法都整理出来,也有助于大家学习。为了理解,文章并没有引入公式推导,只是介绍这些方法是怎么回事,如何使用。

一、对抗训练

近几年,随着深度学习的发展,对抗样本得到了越来越多的关注。通常,我们通过对模型的对抗攻击和防御来增强模型的稳健性,比如自动驾驶系统中的红绿灯识别,要防止模型因为一些随机噪声就将红灯识别为绿灯。在NLP领域,类似的对抗训练也是存在的。

简单来说,“对抗样本” 是指对于人类来说“看起来”几乎一样、但对于模型来说预测结果却完全不一样的样本,比如图中的例子,一只熊猫的图片在加了一点扰动之后被识别成了长臂猿。

“对抗攻击”,就是生成更多的对抗样本,而“对抗防御”,就是让模型能正确识别更多的对抗样本。对抗训练,最初由 Goodfellow 等人提出,是对抗防御的一种,其思路是将生成的对抗样本加入到原数据集中用来增强模型对对抗样本的鲁棒性,Goodfellow还总结了对抗训练的除了提高模型应对恶意对抗样本的鲁棒性之外,还可以作为一种正则化,减少过拟合,提高模型泛化能力。

在CV任务中,输入是连续的RGB的值,而NLP问题中,输入是离散的单词序列,一般以one-hot向量的形式呈现,如果直接在raw text上进行扰动,那么扰动的大小和方向可能都没什么意义。Goodfellow在17年的ICLR中 提出了可以在连续的Embedding上做扰动,但对比图像领域中直接在原始输入加扰动的做法,扰动后的Embedding向量不一定能匹配上原来的Embedding向量表,这样一来对Embedding层的扰动就无法对应上真实的文本输入,这就不是真正意义上的对抗样本了,因为对抗样本依然能对应一个合理的原始输入。那么,在Embedding层做对抗扰动还有没有意义呢?有!实验结果显示,在很多任务中,在Embedding层进行对抗扰动能有效提高模型的性能。之所以能提高性能,主要是因为对抗训练可以作为正则化,一定程度上等价于在loss里加入了梯度惩罚,提升了模型泛化能力。

接下来看一下NLP对抗训练中常用的两个方法和具体实现代码。

第一种是FGM, Goodfellow在17年的ICLR中对他自己在15年提出的FGSM方法进行了优化,主要是在计算扰动的部分做了一点简单的修改。其伪代码如下:

对于每个样本x:

1.计算x的前向loss、反向传播得到梯度

2.根据Embedding矩阵的梯度计算出扰动项r,并加到当前Embedding上,相当于x+r

3.计算x+r的前向loss,反向传播得到对抗的梯度,累加到(1)的梯度上

4.将embedding恢复为(1)时的值

5.根据(3)的梯度对参数进行更新

具体pytorch代码如下:

1.  import torch
2.  class FGM():
3.      def __init__(self, model):
4.          self.model = model
5.          self.backup = {}
6.  
7.      def attack(self, epsilon=1., emb_name='embedding'):
8.          # emb_name这个参数要换成你模型中embedding的参数名
9.          for name, param in self.model.named_parameters():
10.             if param.requires_grad and emb_name in name:
11.                 self.backup[name] = param.data.clone()
12.                 norm = torch.norm(param.grad)
13.                 if norm != 0 and not torch.isnan(norm):
14.                     r_at = epsilon * param.grad / norm
15.                     param.data.add_(r_at)
16. 
17. def restore(self, emb_name='embedding'):
18.         # emb_name这个参数要换成你模型中embedding的参数名
19.     for name, param in self.model.named_parameters():
20.         if param.requires_grad and emb_name in name: 
21.             assert name in self.backup
22.             param.data = self.backup[name]
23.     self.backup = {}

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

需要使用对抗训练的时候,只需要添加五行代码:
1.  # 初始化
2.  fgm = FGM(model)
3.  for batch_input, batch_label in data:
4.      # 正常训练
5.      loss = model(batch_input, batch_label)
6.      loss.backward() # 反向传播,得到正常的grad
7.      # 对抗训练
8.      fgm.attack() # 在embedding上添加对抗扰动
9.      loss_adv = model(batch_input, batch_label)
10.     loss_adv.backward() # 反向传播,并在正常的grad基础上,累加对抗训练的梯度
11.     fgm.restore() # 恢复embedding参数
12.     # 梯度下降,更新参数
13.     optimizer.step()
14.     model.zero_grad()

第二种是PGD, FGM直接通过epsilon参数一下算出了对抗扰动,这样得到的对抗扰动可能不是最优的。因此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)的梯度对参数进行更新

可以看到,在循环中r是逐渐累加的,要注意的是最后更新参数只使用最后一个x+r算出来的梯度。具体代码如下:

1.  import torch
2.  class PGD():
3.      def __init__(self, model):
4.          self.model = model
5.          self.emb_backup = {}
6.          self.grad_backup = {}
7.  
8.      def attack(self, epsilon=1., alpha=0.3, emb_name='emb.', is_first_attack=False):
9.          # emb_name这个参数要换成你模型中embedding的参数名
10.         for name, param in self.model.named_parameters():
11.             if param.requires_grad and emb_name in name:
12.                 if is_first_attack:
13.                     self.emb_backup[name] = param.data.clone()
14.                 norm = torch.norm(param.grad)
15.                 if norm != 0 and not torch.isnan(norm):
16.                     r_at = alpha * param.grad / norm
17.                     param.data.add_(r_at)
18.                     param.data = self.project(name, param.data, epsilon)
19. 
20.     def restore(self, emb_name='emb.'):
21.         # emb_name这个参数要换成你模型中embedding的参数名
22.         for name, param in self.model.named_parameters():
23.             if param.requires_grad and emb_name in name: 
24.                 assert name in self.emb_backup
25.                 param.data = self.emb_backup[name]
26.         self.emb_backup = {}
27. 
28.     def project(self, param_name, param_data, epsilon):
29.         r = param_data - self.emb_backup[param_name]
30.         if torch.norm(r) > epsilon:
31.             r = epsilon * r / torch.norm(r)
32.         return self.emb_backup[param_name] + r
33. 
34.     def backup_grad(self):
35.         for name, param in self.model.named_parameters():
36.             if param.requires_grad:
37.                 self.grad_backup[name] = param.grad.clone()
38. 
39.     def restore_grad(self):
40.         for name, param in self.model.named_parameters():
41.             if param.requires_grad:
42.                 param.grad = self.grad_backup[name]

使用的时候,步骤要多一点:

1.  pgd = PGD(model)
2.  K = 3
3.  for batch_input, batch_label in data:
4.      # 正常训练
5.      loss = model(batch_input, batch_label)
6.      loss.backward() # 反向传播,得到正常的grad
7.      pgd.backup_grad()
8.      # 对抗训练
9.      for t in range(K):
10.         pgd.attack(is_first_attack=(t==0)) # 在embedding上添加对抗扰动, first attack时备份param.data
11.         if t != K-1:
12.             model.zero_grad()
13.         else:
14.             pgd.restore_grad()
15.         loss_adv = model(batch_input, batch_label)
16.         loss_adv.backward() # 反向传播,并在正常的grad基础上,累加对抗训练的梯度
17.     pgd.restore() # 恢复embedding参数
18.     # 梯度下降,更新参数
19.     optimizer.step()
20.     model.zero_grad()

二、Lookahead

Lookahead是近几年新多伦多大学向量学院的研究者提出的一种优化器,它与已有的方法完全不同,它迭代更新两组权重。直观来说,Lookahead 算法通过提前观察另一个优化器生成的「fast weights」序列,来选择搜索方向。该研究发现, Lookahead 算法能够提升学习稳定性,不仅降低了调参需要的时间,同时还能提升收敛速度与效果。此外,我们可以使用 Lookahead 加强已有最优化方法的性能。 Lookahead 的直观过程如图所示,它维护两组权重。Lookahead 首先使用SGD 等标准优化器,更新 k 次「Fast weights」,然后以最后一个 Fast weights 的方向更新「slow weights」。如下 Fast Weights 每更新 5 次,slow weights 就会更新一次。

这种更新机制不仅能够有效地降低方差,而且Lookahead 对次优超参数没那么敏感,以至于它对大规模调参的需求没有那么强。此外,使用 Lookahead 及其内部优化器(如 SGD 或 Adam),还能实现更快的收敛速度,计算开销也比较小。 Lookahead的思路比较简答,准确来说它并不是一个优化器,而是一个使用现有优化器的方案。简单来说它就是下面三个步骤的循环执行:

1)、备份模型现有的权重θ;

2)、从θ出发,用指定优化器更新k步,得到新权重θ̃ ;

3)、更新模型权重为θ←θ+α(θ̃ −θ)。

三、 Warmup

warm up是一种学习率优化方法。一般情况下,我们在训练模型过程中,学习率是不会变化的,而warm up是在不同阶段采用不同的学习策略。比如 在模型训练之初选用较小的学习率,训练一段时间之后(如10 epoches或10000steps)使用预设的学习率进行训练。 warm up的意义在于,在模型训练的初始阶段:模型对数据很陌生,需要使用较小的学习率学习,不断修正权重分布,如果开始阶段,使用很大的学习率,训练出现偏差后,后续需要很多个epoch才能修正过来,或者修正不过来,导致训练过拟合。

在模型训练的中间阶段,当使用较小的学习率学习一段时间后,模型已经根据之前的数据形成了先验知识,这时使用较大的学习率加速学习,前面学习到的先验知识可以使模型的方向正确,加速收敛速度。

在模型训练的学习率衰减阶段:模型参数在学习到一定阶段,参数分布已经在小范围内波动,整体分布变化不大,这时如果继续沿用较大的学习率,可能会破坏模型权值分布的稳定性。

常用的warm up策略介绍三种 (1)constant warm up:学习率从比较小的数值线性增加到预设值之后保持不变 (2)linear warm up:学习率从非常小的数值线性增加到预设值之后,然后再线性减小 (3)Cosine Warmup:学习率先从很小的数值线性增加到预设学习率,然后按照cos函数值进行衰减。

四、混合精度训练

使用混合精度训练并不能提高模型效果,而是为了提高训练速度。混合精度训练时一种在尽可能减少精度损失的情况下利用半精度浮点数加速训练的方法。它使用FP16即半精度浮点数存储权重和梯度。在减少占用内存的同时起到了加速训练的效果。 通常训练神经网络模型的时候默认使用的数据类型为单精度FP32,混合精度训练是指在训练的过程中,同时使用单精度(FP32)和半精度(FP16)。

IEEE标准中的FP16和FP32格式如图所示,float16表示FP6,float表示FP32:

从图中可以看出,与FP32相比,FP16的存储空间是FP32的一半。因此使用FP16训练神经网络可以使权重等参数所占用的内存是原来的一半,节省下来的内存可以放更大的网络模型或者使用更多的数据进行训练。并且在分布式训练,特别是大模型的训练过程中,半精度可以加快数据的流通。但使用FP16同样会带来一些问题,其中最重要的是1)精度溢出和2)舍入误差。

精度溢出:Float16的有效的动态范围约为 [5.96×10^-8,65504], 而Float32的范围是[1.4x10^-45, 1.7x10^38]。可以看到FP16相比FP32的有效范围要小很多,使用FP16替换FP32会出现上溢和下溢的情况。而在神经网络中,由于激活函数的梯度通常要比权重的梯度小,更容易出现下溢的情况。

舍入误差:0.00006666666在FP32中能正常表示,转换到FP16后会表示成为0.000067,不满足FP16最小间隔的数会强制舍入。

为了让深度学习训练,可以使用FP16的好处,并且避免精度溢出和舍入误差,FP16和FP32混合精度训练采用了三种有效的方法:

1.权重备份:权重备份主要是为了解决舍入误差的问题。其主要思路是把神经网络训练过程中产生的激活函数、梯度、以及中间变量等数据,在训练中都利用FP16来存储,同时复制一份FP32的权重参数,用于训练时候的更新。

从图中可以看到,前向传播过程中产生的权重,激活函数,以及梯度都是用FP16进行存储和计算的。参数更新的公式为:
weight=weight+lr*gradient
lr表示学习率,gradient表示梯度,在深度模型中,lr*gradient的值可能会很小,如果利用FP16的权重进行更新,可能会导致误差问题,以至于权重更新无效。因此要使用FP32的权重参数进行更新,即:
weight_{32}=weight_{32}+lr*gradient_{16}

在这里需要注意的是,虽然对权重用FP32格式拷贝增加了内存,但是对于整体的内存占用还是很小的。训练内存的消耗主要是激活,这是因为每一层的批量或激活会保存下来用于重复使用。激活也使用半精度存储,整体的内存基本减半。

2.损失缩放:仅使用FP32进行训练,模型可以收敛的很好,但是如果使用FP32和FP16混合进行训练,会存在模型不收敛。主要原因是梯度的太小,使用FP16表示之后会造成数据下溢的问题,导致模型不收敛,

所以神经网络模型为了匹配FP32的准确性,对前向传播计算出来的Loss值进行放大,例如:对FP32的参数乘以一个因子系数,把可能溢出的数据,转换到FP16可表示的范围。根据链式求导法则,放大Loss后会作用在反向传播的每一层梯度,这样比在每一层梯度上进行放大更加高效。

损失缩放实现的主要过程:

(1)在神经网络模型在前向传播之后,将得到loss增大2^K倍。

(2)在反向传播之后,将权重梯度缩小2^K倍,使用FP32进行表示。 这种损失缩放是使用一个默认值对损失进行缩放,是静态的。动态损失缩放算法是在梯度溢出的时候减少损失缩放规模,并且阶段性的尝试增加损失规模,从而实现在不引起溢出的情况下使用最高损失缩放因子,更好地恢复精度。

具体实现过程如下:

(1)动态损失缩放会在开始的时候使用较高的缩放因子(如2^24),然后在训练迭代中检测数值是否存在溢出;

(2)如果没有数值溢出,则不进行缩放,继续进行迭代,如果检测到数值溢出,则缩放因子会减半,重新确认数值更新情况,直到数值不会溢出;

(3)在训练的后期,loss已经趋近收敛,梯度更新的幅度往往小了,这个时候使用更高的损失缩放因子来防止数据下溢。

3.运算精度:为了有效减少计算过程中的舍入误差,混合精度训练在训练过程中,使用FP16进行矩阵乘法运算,使用FP32来进行矩阵乘法中间的累加部分,然后将FP32格式的值转换成FP16格式的数值。

混合精度训练是减少内存占用、运算时间和运算量的方法。现在已经证明了很多深度模型都可以用这个方法训练,并且在不修改模型参数的情况下,准确率不会下降。Pytorch1.6版本已经实现了NVIDIA的APEX混合精度训练的功能,看一下具体代码:

1.  from apex import amp
2.  model, optimizer = amp.initialize(model, optimizer, opt_level="o1") 
3.  with amp.scale_loss(loss, optimizer) as scaled_loss:
4.      scaled_loss.backward()

opt_level有4种选择,分别为"o0","o1","o2","o3",是APEX混合精度库支持的4种混合精度训练策略。"o0"和"o3"策略分别表示FP32和FP16的纯精度方式。"o1"策略表示使用混合精度训练,但会根据实际Tensor和操作之间的关系建立黑白名单来决定是否使用FP16。例如使用FP16进行GEMM和CNN卷积运算会特别友好,则会把输入的数据和权重转换成FP16进行运算,而使用FP32计算Softmax、Batchnorm等标量和向量。此外,默认使用动态损失缩放。而"o2"策略也是混合精度训练,但没有黑白名单,它是将模型权重参数以及输入模型的参数都转换为FP16,Batch norm使用FP32,另外模型权重文件复制一份FP32,用于跟优化器更新梯度保持一致。同样提供了动态损失缩放。使用权重备份是为了减少舍入误差,使用损失缩放是为了避免数据溢出。

五、Label smoothing

1)使用cross-entropy的问题

传统one-hot编码标签的网络学习过程中,鼓励模型预测为目标类别的概率趋近1,非目标类别的概率趋近0,即最终预测的logits向量(logits向量经过softmax后输出的就是预测的所有类别的概率分布)中目标类别zi的值会趋于无穷大,使得模型向预测正确与错误标签的logit差值无限增大的方向学习,而过大的logit差值会使模型缺乏适应性,对它的预测过于自信。在训练数据不足以覆盖所有情况下,这就会导致网络过拟合,泛化能力差,而且实际上有些标注数据不一定准确,这时候使用交叉熵损失函数作为目标函数也不一定是最优的了。

2)计算公式

y_{k}^{L S}=y_{k}(1-\alpha)+\alpha / K

3) 举例说明

p(cat) = 1

p(pig) = 0

p(dog) = 0

三分类k=3,假设smoothing parameter \alpha=0.1

Cat = (1-0.1)[1,0,0]+0.1/3

=[0.9,0,0]+0.03

=[0.933,0.03,0.03]

Label smoothing就是使用[0.933,0.03,0.03]代替[1,0,0]

六、Focal loss

focal loss 定义

\mathrm{FL}\left(p_{\mathrm{t}}\right)=-\left(1-p_{\mathrm{t}}\right)^{\gamma} \log \left(p_{\mathrm{t}}\right)

在交叉熵的基础上添加了一个可调节因子(1-p_{t})^\gamma, 且{\gamma > 0}

1)、当一个样本被误分类并且pt很小时,调节因子接近1,FL=−log \left(p_{\mathrm{t}}\right), 并且loss不受影响。

2)、当_{}->1 ,调节因子变为0,FL=0, 降低分类良好样本的权重。 焦点参数γ平滑的调整了简单样本被降权的概率。当γ=0是,FL=CE。 简单样本:可以理解为正确分类的样本

3)、 alpha-focal loss

\mathrm{FL}\left(p_{\mathrm{t}}\right)=-\alpha_{\mathrm{t}}\left(1-p_{\mathrm{t}}\right)^{\gamma} \log \left(p_{\mathrm{t}}\right)

调整正负样本的权重。

七、梯度累加(加快训练速度)

如果只有单卡,且可以加载模型,但batch受限的话可以使用梯度累加,进行N次前向后反向更新一次参数,相当于扩大了N倍的batch size。

正常的训练代码是这样的:

for i, (inputs, labels) in enumerate(training_set):
    loss = model(inputs, labels)              # 计算loss
    optimizer.zero_grad()                                     # 清空梯度
    loss.backward()                           # 反向计算梯度
    optimizer.step()                                        # 更新参数

加入梯度累加后:

for i, (inputs, labels) in enumerate(training_set):
    loss = model(inputs, labels)           # 计算loss
    loss = loss / accumulation_steps 
    # Normalize our loss (if averaged)
    loss.backward()                # 反向计算梯度,累加到之前梯度上
    if (i+1) % accumulation_steps == 0:             
        optimizer.step()                      # 更新参数
        model.zero_grad()                  # 清空梯度

要注意的是,batch扩大后,如果想保持样本权重相等,学习率也要线性扩大或者适当调整 。另外batchnorm也会受到影响,小batch下的均值和方差肯定不如大batch的精准,可以调整BN中的momentum参数解决。

八、LAMB(Layer-wise Adaptive Moments optimizer for Batch training)

LAMB:模型在进行大批量数据训练时,能够维持梯度更新的精度。

LAMB主要是综合了Adam和LARS(Layerwise Adaptive Rate Scaling),对学习率进行调整。上文提到当batch变大时学习率也需要变大,这样会导致收敛不稳定,LARS通过给LR乘上权重与梯度的norm比值来解决这个问题:

8.1)WarmUp

模型刚开始训练的时候,先使用一个较小的学习率,训练一些epochs,等模型稳定时再修改为预先设置的学习率。

为什么使用Warmup? 模型随机初始化,若选择一个较大的学习率,可能会带来模型的不稳定,选择Warmup先训练几个epochs, 之后,模型趋于稳定,等模型稳定之后在选择预先设置的学习率可以加快模型的收敛速度,模型效果最佳。

九、文本数据增强

  1. 基础的文本数据增强(EDA)[同义词替换!]
  2. 闭包数据增强
  3. 无监督数据增强(UDA)
  4. 对偶数据增强

EDA对于训练集中的给定句子,随机选择并执行以下操作之一:

  • 同义词替换(SR):从句子中随机选择 n 个不是停用词的词。 用随机选择的同义词之一替换这些单词中的每一个。
  • 随机插入 (RI):在句子中随机找到一个词,并找出其同义词,且该同义词不是停用词。 将该同义词插入句子中的随机位置。 这样做n次。
  • 随机交换(RS):随机选择句子中的两个单词并交换它们的位置。 这样做n次。
  • 随机删除(RD):以概率 p 随机删除句子中的每个单词

闭包数据增强

数据集中每条数据有两个句子 a, b, 1 a, c, 1 a, d, 0 a~b, a~c => b~c a~b, ad不相似 => bd不相

UDA(Unsupervised Data Augmentation for Consistency Training)用于一致性训练的无监督数据增

十、sharpen

让预测标签更接近真实标签,增大概率大的值,减小概率小的值)

import torch.nn as nn
logits = torch.randn(2,3)
print(logits)
t_softmax = torch.softmax(logits, dim=1)
print(t_softmax)
t_sharpen = torch.softmax(logits/0.4, dim=1)
print(t_sharpen)

十一、EMA

EMA在深度学习的优化过程中,\theta_{t}是t时刻的模型权重weights,v_{t}是t时刻的影子权重(shadow weights)。在梯度下降的过程中,会一直维护着这个影子权重,但是这个影子权重并不会参与训练。基本的假设是,模型权重在最后的n步内,会在实际的最优点处抖动,所以我们取最后n步的平均,能使得模型更加的鲁棒。

在保存模型或者评估模型时,会利用影子权重进行评估,如果效果比当前效果好,则保存影子权重的参数,但是之后在继续训练的时候会还原之前的参数进行训练。

class EMA():
    def __init__(self, model, decay):
        self.model = model
        self.decay = decay
        self.shadow = {}
        self.backup = {}

    def register(self):
        for name, param in self.model.named_parameters():
            if param.requires_grad:
                self.shadow[name] = param.data.clone()

    def update(self):
        for name, param in self.model.named_parameters():
            if param.requires_grad:
                assert name in self.shadow
                new_average = (1.0 - self.decay) * param.data + self.decay * self.shadow[name]
                self.shadow[name] = new_average.clone()

    def apply_shadow(self):
        for name, param in self.model.named_parameters():
            if param.requires_grad:
                assert name in self.shadow
                self.backup[name] = param.data
                param.data = self.shadow[name]

    def restore(self):
        for name, param in self.model.named_parameters():
            if param.requires_grad:
                assert name in self.backup
                param.data = self.backup[name]
        self.backup = {}

# 初始化
ema = EMA(model, 0.999)
ema.register()

# 训练过程中,更新完参数后,同步update shadow weights
def train():
    optimizer.step()
    ema.update()

# eval前,apply shadow weights;eval之后,恢复原来模型的参数
def evaluate():
    ema.apply_shadow()
    # evaluate
    ema.restore()

参考

[1]Explaining and Harnessing Adversarial Examples(https://arxiv.org/pdf/1412.6572.pdf)

[2]【炼丹技巧】功守道:NLP中的对抗训练 + PyTorch实现(https://zhuanlan.zhihu.com/p/91269728)

[3]苏剑林. (Mar. 01, 2020). 《对抗训练浅谈:意义、方法和思考(附Keras实现) 》[Blog post]. Retrieved from https://spaces.ac.cn/archives/7234

[4]Mixed Precision Training(https://arxiv.org/pdf/1710.03740.pdf)

[5]When Does Label Smoothing Help?(https://arxiv.org/pdf/1906.02629.pdf)

[6]Label Smoothing分析(https://zhuanlan.zhihu.com/p/302843504)

[7]Focal Loss for Dense Object Detection(https://arxiv.org/pdf/1708.02002.pdf)

[8]【炼丹技巧】指数移动平均(EMA)的原理及PyTorch实现https://zhuanlan.zhihu.com/p/68748778

你可能感兴趣的:(NLP炼丹技巧合集)