对抗训练#
对抗训练的一般性原理,可以概括为如下的最大最小化公式。
在上述公式中,又有min,又有max,怎么理解?重点需要理解的一点是上述公式中的min和max分别是要调整哪部分来达到min和max的效果。
先说max:max要调整的是 r_{adv},由于 r_{adv} 是加到输入X上的,所以max其实是要调整输入X,使得最终的损失最大化;
再说min:min要调整的模型的权重\theta,它其实是想通过调整模型的权重\theta来使最终的损失最小化;
用一句话形容对抗训练的本质,就是在输入上进行梯度上升(增大loss),在参数上进行梯度下降(减小loss)。
实际操作时,由于输入会进行embedding lookup,所以实际的做法是在embedding table上进行梯度上升。
理解了上述对抗训练的本质,接下来各种对抗训练的方法就容易理解了,然后各种对抗训练的方法提出基本围绕两部分进行:
- 得到更优的扰动:这个对应到公式中就是使得max那部分更大;
- 提升训练速度:这个主要是因为加入对抗训练之后速度会大幅降低,所以有不少研究会重点优化对抗训练方法的训练速度;
1、FGSM(Fast Gradient Sign Method)#
这个思路非常朴素,假设梯度是 g=\nabla L(\theta, x, y),那么扰动就沿着梯度的负方向就可以了,如下:
其中函数 sign(g) 表示取 g 的符号。
由其公式可知,每次的步长都是完全相同的,都是 \epsilon。
2、FGM(Fast Gradien Method)#
该方法相比于FGSM,主要优化的就是每一步的步长。公式如下所示,它是对获得的梯度做scale,具体方式就是做L2的归一化,用这种方式来获取到比FGSM更好的扰动。
这里的 g 是embedding层的梯度。
3、PGD(Projected Gradient Descent)#
3.1 原理说明#
这个PGD中所采用的方式,莫名的觉得其和SGD很相似;
在FGM中,寻找一个输入的扰动的过程是:只求一次梯度,然后就用这个梯度通过公式 r_{adv} = \epsilon \cdot sign(g) 获取扰动,将该扰动添加到输入上;
该方法可能存在的问题是,通过这种简单粗暴的"一步到位"可能找不到对于当前的输入来说最优的扰动。这种"一步到位"可能由于步子不够长,没有走到最低点;或者由于步子太长,超过了最低点;
这里需要注意的是:找最优扰动这个步骤里,优化目标是极大化 L(f_{\theta}(X+r_{adv}), y),可调整的参数是 r_{adv},在这个步骤中模型的权重 \theta 是个常数。
由于FGM方法存在上述问题,所以提出了FGD这种 "小步走,多走几步" 的方法。其公式为:
且要满足约束 ||r||_2 \leq \epsilon,这里的 g_t 是embedding层的梯度。
这个方法由于要迭代多次,而最后获取的最优扰动只用最后一次得到的扰动。有些易混淆的地方,这里我们放一下它的伪代码。
伪代码:
对于每个x(每个x表示一条样本):
1. 计算x的前向loss、反向传播得到梯度
2. 备份整个模型的梯度,备份embedding层的权重
对于每步t:
3. 根据embedding矩阵的梯度计算出r,并加到当前embedding上,对应公式为x+r(超出范围则投影回epsilon内,也就是上面公式部分的约束)
4. t不是最后一步: 将梯度归0,根据上一步的x+r计算前后向并得到梯度
5. t是最后一步: 恢复步骤(2)备份的梯度,计算最后的x+r并将梯度累加到步骤(2)备份的梯度上
6. 将embedding层的权重恢复为步骤(2)备份的值
7. 根据步骤(5)得到的梯度对参数进行更新
简单来说,在实际操作时,一般是先将embedding层权重深度拷贝一份备份。在多次迭代的对抗训练过程中,不断把求解出来的梯度r累加到模型的embedding上,多次迭代完之后,使用最后一步的embedding权重做前后向计算得到整个模型的梯度。然后将embedding层的权重使用最初备份的值还原。这里不理解没关系,看完下面的代码解析就理解了。
3.2 代码解析#
实现一个完整的PGD的代码并不复杂,整个使用PGD做对抗训练的代码分为两部分:
- 一部分是PGD这个类,它里面实现的功能有模型参数的备份、参数的恢复、embedding层梯度的计算和更新等;
- 另一部分是在主训练流程中,把PGD这个功能添加进去;
下面先给出PGD类的定义,以及各个函数的功能,先不细究其实现细节,而是先把整个流程捋顺了。
3.2.1 定义PGD类#
对抗训练用的PGD类的各个函数的功能如下:
class PGD:
def __init__(self, model):
self.model = model
def backup_model_grad(self):
""" 对模型 self.model 中所有的梯度做备份 """
def restore_model_grad(self):
""" 使用函数 backup_model_grad() 中备份的值对模型中的梯度进行还原 """
def backup_embedding_data(self):
""" 对模型 embedding 层中的权重进行备份 """
def restore_embedding_data(self):
""" 使用函数 backup_embedding_data() 中备份的值对 embedding 的权重进行还原 """
def attack(self):
""" 按照3.1小节中给的公式计算梯度,并使用该梯度更新 embedding 层的权重 """
3.2.2 将对抗训练添加到主训练流程中#
首先,不使用对抗训练的常规训练流程代码如下(这个是简化又简化的版本):
for batch in data_loader:
loss = self.calc_loss(pred=self(**batch), **batch) # 前向传播并计算损失
loss.backward() # 反向传播计算出梯度并累计
self.optimizer.step() # 实用梯度对模型的权重进行更新
self.model.zero_grad() # 清空模型中所有的梯度
然后,按照上面 3.1 节中提供的伪代码,逐步进行实现如下。
pgd_k = 3 # 一般设置为3,也可以设置为其他值
self.pgd = PGD(model=self.model)
for batch in data_loader:
loss = self.calc_loss(pred=self(**batch), **batch) # 前向传播并计算损失。对应伪代码中的步骤(1);
loss.backward() # 反向传播计算出梯度并累计。对应伪代码中的步骤(1);
self.pgd.backup_model_grad() # 将模型中最初的所有的梯度进行备份。对应伪代码中的步骤(2);
self.pgd.backup_embedding_data(emb_name="word_embeddings") # 对 embedding 层的权重进行备份。对应伪代码中的步骤(2);
for _t in range(pgd_k):
# 按照3.1小节中给的公式计算梯度,并将该梯度加到模型的embedding上,每执行一次该行代码,embedding层的权重
# 就会朝着使loss变大的方向变化一次。对应伪代码中的步骤(3);
self.pgd.attack(is_first_attack=(_t == 0))
if _t != pgd_k - 1:
# 如果当前不是最后一步,将梯度归0,然后继续计算损失和梯度,注意此时 embedding 中的权重已经不是原始
# 权重了,而是在函数 pgd.attack() 中更新之后的。对应伪代码中的步骤(4);
self.model.zero_grad()
loss = self.calc_loss(pred=self(**batch), **batch)
loss.backward()
else:
# 如果当前是最后一步,将梯度恢复为最初备份的梯度。并且再次计算一次梯度累加到最初的梯度上。
# 对应伪代码中的步骤(5);
self.pgd.restore_model_grad()
loss = self.calc_loss(pred=self(**batch), **batch)
loss.backward()
self.pgd.restore_embedding_data(emb_name="word_embeddings") # 将embedding中的权重恢复为最初备份的值。对应伪代码中的步骤(6);
self.optimizer.step() # 实用经过上述对抗训练得到的梯度更新模型的权重。对应伪代码中的步骤(7);
self.model.zero_grad()
3.2.3 分析PGD类的实现细节#
整个PGD类的源码如下所示:
class PGD:
def __init__(self, model, eps=1., alpha=0.3):
self.model = (model.module if hasattr(model, "module") else model)
self.eps = eps
self.alpha = alpha
self.emb_backup = {}
self.grad_backup = {}
def backup_model_grad(self):
""" 对模型 self.model 中所有的梯度做备份 """
for name, param in self.model.named_parameters():
if param.requires_grad and param.grad is not None:
self.grad_backup[name] = param.grad.clone()
def restore_model_grad(self):
""" 使用函数 backup_model_grad() 中备份的值对模型中的梯度进行还原 """
for name, param in self.model.named_parameters():
if param.requires_grad and param.grad is not None:
param.grad = self.grad_backup[name]
def backup_embedding_data(self, emb_name="word_embeddings"):
""" 对模型 embedding 层中的权重进行备份 """
for name, param in self.model.named_parameters():
if param.requires_grad and emb_name in name:
self.emb_backup[name] = param.data.clone()
def restore_embedding_data(self, emb_name="word_embeddings"):
""" 使用函数 backup_embedding_data() 中备份的值对 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 attack(self):
""" 按照3.1小节中给的公式计算梯度,并使用该梯度更新 embedding 层的权重 """
for name, param in self.model.named_parameters():
if param.requires_grad and emb_name in name:
norm = torch.norm(param.grad)
if norm != 0 and not torch.isnan(norm):
r_at = self.alpha * param.grad / norm
param.data.add_(r_at)
param.data = self.project(name, param.data)
def project(self, param_name, param_data):
""" 这个函数是保证累加到embedding层的梯度不会超过范围,即3.1小节中提到的约束条件 """
r = param_data - self.emb_backup[param_name]
if torch.norm(r) > self.eps:
r = self.eps * r / torch.norm(r)
return self.emb_backup[param_name] + r
其中前四个函数 backup_model_grad()
、restore_model_grad()
、backup_embedding_data()
、restore_embedding_data()
的功能非常清晰,不再解释。
主要解释一下 attack()
这个函数。这里把该函数对应的公式再写一遍:r_{adv|t+1} = \alpha \frac{g_t}{||g_t||_2},且要满足约束 ||r||_2 \leq \epsilon,这里的 g_t 是embedding层的梯度。把 attack()
函数拿出来逐行说明:
def attack(self):
""" 按照3.1小节中给的公式计算梯度,并使用该梯度更新 embedding 层的权重 """
for name, param in self.model.named_parameters():
if param.requires_grad and emb_name in name:
norm = torch.norm(param.grad)
if norm != 0 and not torch.isnan(norm):
r_at = self.alpha * param.grad / norm
param.data.add_(r_at)
param.data = self.project(name, param.data)
def project(self, param_name, param_data):
""" 这个函数是保证累加到embedding层的梯度不会超过范围,即3.1小节中提到的约束条件 """
r = param_data - self.emb_backup[param_name]
if torch.norm(r) > self.eps:
r = self.eps * r / torch.norm(r)
return self.emb_backup[param_name] + r
- 第5行为计算梯度 g_t 的 L_2 归一化的结果,对应公式为 ||g_t||_2;
- 第7行为计算 r_{adv|t+1},对应公式为 r_{adv|t+1} = \alpha \frac{g_t}{||g_t||_2};
- 第8行为更新embedding的权重;
- 第9行的功能为保证对embedding权重的修改不会超过范围,对应公式为 ||r||_2 \leq \epsilon;
总结#
本文介绍了对抗学习中的三种方法:FGSM
、FGM
、PGD
。对于方法 FGSM
和 FGM
仅介绍了其原理。对于方法 PGD
除了介绍原理以外还详细分析了其代码如何实现。