[隐藏左侧目录栏][显示左侧目录栏]

对抗训练#

对抗训练的一般性原理,可以概括为如下的最大最小化公式。

\min_{\theta} E_{(Z, y) \backsim D} \big[ \max_{||r_{adv}|| \leq \epsilon} L(f_{\theta}(X+r_{adv}), y) \big]

在上述公式中,又有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),那么扰动就沿着梯度的负方向就可以了,如下:

r_{adv} = \epsilon \cdot sign(g)

其中函数 sign(g) 表示取 g 的符号。

由其公式可知,每次的步长都是完全相同的,都是 \epsilon

2、FGM(Fast Gradien Method)#

该方法相比于FGSM,主要优化的就是每一步的步长。公式如下所示,它是对获得的梯度做scale,具体方式就是做L2的归一化,用这种方式来获取到比FGSM更好的扰动。

r_{adv} = \epsilon \frac{g}{||g||_2}

这里的 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_{adv|t+1} = \alpha \frac{g_t}{||g_t||_2}

且要满足约束 ||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_tL_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

总结#

本文介绍了对抗学习中的三种方法:FGSMFGMPGD。对于方法 FGSMFGM 仅介绍了其原理。对于方法 PGD 除了介绍原理以外还详细分析了其代码如何实现。