NLP中的对抗训练

一. 对抗训练定义

对抗训练是一种引入噪声的训练方式,可以对参数进行正则化,提升模型鲁棒性和泛化能力

1.1 对抗训练特点

  • 相对于原始输入,所添加的扰动是微小的
  • 添加的噪声可以使得模型预测错误

1.2 对抗训练的基本概念

就是在原始输入样本 x 上加上一个扰动Δx得到对抗样本,再用其进行训练,这个问题可以抽象成这样一个模型:
m a x θ P ( y ∣ x + Δ x ; θ ) max_{\theta}P(y|x+\Delta x; \theta) maxθP(yx+Δx;θ)
其中,y 是ground truth, θ \theta θ 是模型参数。意思就是即使在扰动的情况下求使得预测出y的概率最大的参数,扰动可以被定义为:

Δ x = ϵ ∗ s i g n ( ∇ x L ( x , y ; θ ) ) \Delta x= \epsilon *sign(\nabla x L(x,y; \theta)) Δx=ϵsign(xL(x,y;θ))

其中,sign为符号函数,L 为损失函数

最后,GoodFellow还总结了对抗训练的两个作用:

  1. 提高模型应对恶意对抗样本时的鲁棒性
  2. 作为一种regularization,减少overfitting,提高泛化能力

1.3 Min-Max公式

Madry在2018年的ICLR论文Towards Deep Learning Models Resistant to Adversarial Attacks中总结了之前的工作,对抗训练可以统一写成如下格式:

m i n θ E ( x , y ) ∼ D [ m a x Δ x ∈ Ω L ( x + Δ x , y ; θ ) ] min_{\theta} E_{(x,y) \sim D}[max_{\Delta x \in \Omega }L(x+\Delta x,y; \theta)] minθE(x,y)D[maxΔxΩL(x+Δx,y;θ)]

其中D代表训练集,x代表输入,y代表标签,θ是模型参数,L(x,y;θ)是单个样本的loss,Δx是对抗扰动,Ω是扰动空间。这个统一的格式首先由论文《Towards Deep Learning Models Resistant to Adversarial Attacks》提出。

这个式子可以分步理解如下:

1、往属于x里边注入扰动Δx,Δx的目标是让L(x+Δx,y;θ)越大越好,也就是说尽可能让现有模型的预测出错;

2、当然Δx也不是无约束的,它不能太大,否则达不到“看起来几乎一样”的效果,所以Δx要满足一定的约束,常规的约束是∥Δx∥≤ϵ,其中ϵ是一个常数;

3、每个样本都构造出对抗样本x+Δx之后,用(x+Δx,y)作为数据对去最小化loss来更新参数θ(梯度下降);

4、反复交替执行1、2、3步。

由此观之,整个优化过程是max和min交替执行,这确实跟GAN很相似,不同的是,GAN所max的自变量也是模型的参数,而这里max的自变量则是输入(的扰动量),也就是说要对每一个输入都定制一步max。

1.4 FGM

现在的问题是如何计算Δx,它的目标是增大L(x+Δ,y;θ),而我们知道让loss减少的方法是梯度下降,那反过来,让loss增大的方法自然就是梯度上升,因此可以简单地取

Δx=ϵ∇xL(x,y;θ)

当然,为了防止Δx过大,通常要对∇xL(x,y;θ)做些标准化,比较常见的方式是

Δ x = ϵ ∇ x L ( x , y ; θ ) ∥ ∇ x L ( x , y ; θ ) ∥ 或 Δ x = ϵ s i g n ( ∇ x L ( x , y ; θ ) ) Δx=ϵ \frac{∇xL(x,y;θ)}{∥∇xL(x,y;θ)∥}或Δx=ϵsign(∇xL(x,y;θ)) Δx=ϵ∥∇xL(x,y;θ)xL(x,y;θ)Δx=ϵsign(xL(x,y;θ))

有了Δx之后,就可以代回式(1)进行优化

m i n θ E ( x , y ) ∼ D [ L ( x + Δ x , y ; θ ) ] min_θE_{(x,y)∼D}[L(x+Δx,y;θ)] minθE(x,y)D[L(x+Δx,y;θ)]

这就构成了一种对抗训练方法,被称为Fast Gradient Method(FGM),它由GAN之父Goodfellow在论文《Explaining and Harnessing Adversarial Examples》首先提出。

NLP 中的对抗训练

对于CV领域的任务,上述对抗训练的流程可以顺利执行下来,因为图像可以视为普通的连续实数向量,ΔxΔx也是一个实数向量,因此x+Δxx+Δx依然可以是有意义的图像。但NLP不一样,NLP的输入是文本,它本质上是one hot向量(如果还没认识到这一点,欢迎阅读《词向量与Embedding究竟是怎么回事?》),而两个不同的one hot向量,其欧氏距离恒为2−−√2,因此对于理论上不存在什么“小扰动”。

一个自然的想法是像论文《Adversarial Training Methods for Semi-Supervised Text Classification》一样,将扰动加到Embedding层。这个思路在操作上没有问题,但问题是,扰动后的Embedding向量不一定能匹配上原来的Embedding向量表,这样一来对Embedding层的扰动就无法对应上真实的文本输入,这就不是真正意义上的对抗样本了,因为对抗样本依然能对应一个合理的原始输入。

那么,在Embedding层做对抗扰动还有没有意义呢?有!实验结果显示,在很多任务中,在Embedding层进行对抗扰动能有效提高模型的性能。

思路分析

对于CV任务来说,一般输入张量的shape是(b,h,w,c),这时候我们需要固定模型的batch size(即b),然后给原始输入加上一个shape同样为(b,h,w,c)、全零初始化的Variable,比如就叫做Δx,那么我们可以直接求loss对x的梯度,然后根据梯度给Δx赋值,来实现对输入的干扰,完成干扰之后再执行常规的梯度下降。

对于NLP任务来说,原则上也要对Embedding层的输出进行同样的操作,Embedding层的输出shape为(b,n,d),所以也要在Embedding层的输出加上一个shape为(b,n,d)的Variable,然后进行上述步骤。但这样一来,我们需要拆解、重构模型,对使用者不够友好。

不过,我们可以退而求其次。Embedding层的输出是直接取自于Embedding参数矩阵的,因此我们可以直接对Embedding参数矩阵进行扰动。这样得到的对抗样本的多样性会少一些(因为不同样本的同一个token共用了相同的扰动),但仍然能起到正则化的作用,而且这样实现起来容易得多。

代码参考

def adversarial_training(model, embedding_name, epsilon=1):
    """给模型添加对抗训练
    其中model是需要添加对抗训练的keras模型,embedding_name
    则是model里边Embedding层的名字。要在模型compile之后使用。
    """
    if model.train_function is None:  # 如果还没有训练函数
        model._make_train_function()  # 手动make
    old_train_function = model.train_function  # 备份旧的训练函数

    # 查找Embedding层
    for output in model.outputs:
        embedding_layer = search_layer(output, embedding_name)
        if embedding_layer is not None:
            break
    if embedding_layer is None:
        raise Exception('Embedding layer not found')

    # 求Embedding梯度
    embeddings = embedding_layer.embeddings  # Embedding矩阵
    gradients = K.gradients(model.total_loss, [embeddings])  # Embedding梯度
    gradients = K.zeros_like(embeddings) + gradients[0]  # 转为dense tensor

    # 封装为函数
    inputs = (model._feed_inputs +
              model._feed_targets +
              model._feed_sample_weights)  # 所有输入层
    embedding_gradients = K.function(
        inputs=inputs,
        outputs=[gradients],
        name='embedding_gradients',
    )  # 封装为函数

    def train_function(inputs):  # 重新定义训练函数
        grads = embedding_gradients(inputs)[0]  # Embedding梯度
        delta = epsilon * grads / (np.sqrt((grads**2).sum()) + 1e-8)  # 计算扰动
        K.set_value(embeddings, K.eval(embeddings) + delta)  # 注入扰动
        outputs = old_train_function(inputs)  # 梯度下降
        K.set_value(embeddings, K.eval(embeddings) - delta)  # 删除扰动
        return outputs

    model.train_function = train_function  # 覆盖原训练函数

定义好上述函数后,给Keras模型增加对抗训练就只需要一行代码了:

# 写好函数后,启用对抗训练只需要一行代码
adversarial_training(model, 'Embedding-Token', 0.5)

需要指出的是,由于每一步算对抗扰动也需要计算梯度,因此每一步训练一共算了两次梯度,因此每步的训练时间会翻倍。

参考

https://www.jianshu.com/p/3e354f735fd4

对抗训练浅谈:意义、方法和思考(附Keras实现)

你可能感兴趣的:(NLP,自然语言处理,深度学习)