1. 程式人生 > 實用技巧 >《【煉丹技巧】功守道:NLP中的對抗訓練 + PyTorch實現》

《【煉丹技巧】功守道:NLP中的對抗訓練 + PyTorch實現》



瓦特蘭蒂斯

【煉丹技巧】功守道:NLP中的對抗訓練 + PyTorch實現

本文分享一個“萬物皆可盤”的NLP對抗訓練實現,只需要四行程式碼即可呼叫。你值得擁有。

最近,微軟的FreeLB-Roberta [1] 靠著對抗訓練 (Adversarial Training)在GLUE榜上超越了Facebook原生的Roberta,追一科技也用到了這個方法僅憑單模型 [2] 就在CoQA榜單中超過了人類,似乎“對抗訓練”一下子變成了NLP任務的一把利器。剛好筆者最近也在看這方面的內容,所以開一篇部落格,講一下。

GLUE Leaderboard

CoQA Leaderboard

提到“對抗”,相信大多數人的第一反應都是CV中的對抗生成網路 (GAN),殊不知,其實對抗也可以作為一種防禦機制,並且經過簡單的修改,便能用在NLP任務上,提高模型的泛化能力。關鍵是,對抗訓練可以寫成一個外掛的形式,用幾行程式碼就可以在訓練中自由地呼叫,簡單有效,使用成本低

。不過網上的大多數部落格對於NLP中的對抗訓練都介紹得比較零散且無程式碼實現,筆者在這篇部落格中,對NLP任務中的對抗訓練做了一個簡單的綜述,並提供了外掛形式的PyTorch實現。

本文專注於NLP對抗訓練的介紹,對對抗攻擊基礎感興趣的讀者,可以看這幾篇部落格及論文 [3] [4] [5],這裡就不贅述了。不想要理解理論細節的讀者也可以直接看最後的程式碼實現。

對抗樣本

我們常常會聽到“對抗樣本”、“對抗攻擊”、“對抗訓練”等等這些令人頭禿的概念,為了讓大家對“對抗”有個更清晰的認識,我們先把這些概念捋捋清楚。

Taxonomy of Adversarial

Szegedy在14年的ICLR中 [6] 提出了對抗樣本這個概念。如上圖,對抗樣本可以用來攻擊和防禦,而對抗訓練其實是“對抗”家族中防禦的一種方式,其基本的原理呢,就是通過新增擾動構造一些對抗樣本,放給模型去訓練,以攻為守,提高模型在遇到對抗樣本時的魯棒性,同時一定程度也能提高模型的表現和泛化能力。

那麼,什麼樣的樣本才是好的對抗樣本呢?對抗樣本一般需要具有兩個特點:

  1. 相對於原始輸入,所新增的擾動是微小的;
  2. 能使模型犯錯。

下面是一個對抗樣本的例子,決定就是你啦,胖達:

一隻胖達加了點擾動就被識別成了長臂猿

對抗訓練的基本概念

GAN之父Ian Goodfellow在15年的ICLR中 [7] 第一次提出了對抗訓練這個概念,簡而言之,就是在原始輸入樣本[Math Processing Error]x上加一個擾動[Math Processing Error]radv,得到對抗樣本後,用其進行訓練。也就是說,問題可以被抽象成這麼一個模型:

[Math Processing Error]minθ−log⁡P(y|x+radv;θ)

其中,[Math Processing Error]y為gold label,[Math Processing Error]θ為模型引數。那擾動要如何計算呢?Goodfellow認為,神經網路由於其線性的特點,很容易受到線性擾動的攻擊。

This linear behavior suggests that cheap, analytical perturbations of a linear model should also damage neural networks.

於是,他提出了 Fast Gradient Sign Method (FGSM) ,來計算輸入樣本的擾動。擾動可以被定義為:

[Math Processing Error]radv=ϵ⋅sgn(▽xL(θ,x,y))

其中,[Math Processing Error]sgn為符號函式,[Math Processing Error]L為損失函式。Goodfellow發現,令[Math Processing Error]ϵ=0.25,用這個擾動能給一個單層分類器造成99.9%的錯誤率。看似這個擾動的發現有點拍腦門,但是仔細想想,其實這個擾動計算的思想可以理解為:將輸入樣本向著損失上升的方向再進一步,得到的對抗樣本就能造成更大的損失,提高模型的錯誤率。回想我們上一節提到的對抗樣本的兩個要求,FGSM剛好可以完美地解決。

在 [7] 中,Goodfellow還總結了對抗訓練的兩個作用:

  1. 提高模型應對惡意對抗樣本時的魯棒性;
  2. 作為一種regularization,減少overfitting,提高泛化能力。

Min-Max 公式

在 [7] 中,對抗訓練的理論部分被闡述得還是比較intuitive,Madry在2018年的ICLR中 [8]總結了之前的工作,並從優化的視角,將問題重新定義成了一個找鞍點的問題,也就是大名鼎鼎的Min-Max公式:

[Math Processing Error]minθE(x,y)∼D[maxradv∈SL(θ,x+radv,y)]

該公式分為兩個部分,一個是內部損失函式的最大化,一個是外部經驗風險的最小化。

  1. 內部max是為了找到worst-case的擾動,也就是攻擊,其中,[Math Processing Error]L為損失函式,[Math Processing Error]S為擾動的範圍空間。
  2. 外部min是為了基於該攻擊方式,找到最魯棒的模型引數,也就是防禦,其中[Math Processing Error]D是輸入樣本的分佈。

Madry認為,這個公式簡單清晰地定義了對抗樣本攻防“矛與盾”的兩個問題:如何構造足夠強的對抗樣本?以及,如何使模型變得刀槍不入?剩下的,就是如何求解的問題了。

從 CV 到 NLP

以上提到的一些工作都還是停留在CV領域的,那麼問題來了,可否將對抗訓練遷移到NLP上呢?答案是肯定的,但是,我們得考慮這麼幾個問題:

首先,CV任務的輸入是連續的RGB的值,而NLP問題中,輸入是離散的單詞序列,一般以one-hot vector的形式呈現,如果直接在raw text上進行擾動,那麼擾動的大小和方向可能都沒什麼意義。Goodfellow在17年的ICLR中 [9] 提出了可以在連續的embedding上做擾動:

Because the set of high-dimensional one-hot vectors does not admit infinitesimal perturbation, we define the perturbation on continuous word embeddings instead of discrete word inputs.

乍一思考,覺得這個解決方案似乎特別完美。然而,對比影象領域中直接在原始輸入加擾動的做法,在embedding上加擾動會帶來這麼一個問題:這個被構造出來的“對抗樣本”並不能map到某個單詞,因此,反過來在inference的時候,對手也沒有辦法通過修改原始輸入得到這樣的對抗樣本。我們在上面提到,對抗訓練有兩個作用,一是提高模型對惡意攻擊的魯棒性,二是提高模型的泛化能力。在CV任務,根據經驗性的結論,對抗訓練往往會使得模型在非對抗樣本上的表現變差,然而神奇的是,在NLP任務中,模型的泛化能力反而變強了,如[1]中所述:

While adversarial training boosts the robustness, it is widely accepted by computer vision researchers that it is at odds with generalization, with classification accuracy on non-corrupted images dropping as much as 10% on CIFAR-10, and 15% on Imagenet (Madry et al., 2018; Xie et al., 2019). Surprisingly, people observe the opposite result for language models (Miyato et al., 2017; Cheng et al., 2019), showing that adversarial training can improve both generalization and robustness.

因此,在NLP任務中,對抗訓練的角色不再是為了防禦基於梯度的惡意攻擊,反而更多的是作為一種regularization,提高模型的泛化能力

有了這些“思想準備”,我們來看看NLP對抗訓練的常用的幾個方法和具體實現吧。

NLP中的兩種對抗訓練 + PyTorch實現

Fast Gradient Method(FGM)

上面我們提到,Goodfellow在15年的ICLR [7] 中提出了Fast Gradient Sign Method(FGSM),隨後,在17年的ICLR [9]中,Goodfellow對FGSM中計算擾動的部分做了一點簡單的修改。假設輸入的文字序列的embedding vectors[Math Processing Error][v1,v2,…,vT]為[Math Processing Error]x,embedding的擾動為:

[Math Processing Error]radv=ϵ⋅g/||g||2g=▽xL(θ,x,y)

實際上就是取消了符號函式,用二正規化做了一個scale,需要注意的是:這裡的norm計算的是,每個樣本的輸入序列中出現過的片語成的矩陣的梯度norm。原作者提供了一個TensorFlow的實現 [10],在他的實現中,公式裡的[Math Processing Error]x是embedding後的中間結果(batch_size, timesteps, hidden_dim),對其梯度[Math Processing Error]g的後面兩維計算norm,得到的是一個(batch_size, 1, 1)的向量[Math Processing Error]||g||2。為了實現外掛式的呼叫,筆者將一個batch抽象成一個樣本,一個batch統一用一個norm,由於本來norm也只是一個scale的作用,影響不大。筆者的實現如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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:
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 = {}

需要使用對抗訓練的時候,只需要新增五行程式碼:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 初始化
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()

PyTorch為了節約記憶體,在backward的時候並不儲存中間變數的梯度。因此,如果需要完全照搬原作的實現,需要用register_hook介面[11]將embedding後的中間變數的梯度儲存成全域性變數,norm後面兩維,計算出擾動後,在對抗訓練forward時傳入擾動,累加到embedding後的中間變數上,得到新的loss,再進行梯度下降。不過這樣實現就與我們追求外掛式簡單好用的初衷相悖,這裡就不贅述了,感興趣的讀者可以自行實現。

Projected Gradient Descent(PGD)

內部max的過程,本質上是一個非凹的約束優化問題,FGM解決的思路其實就是梯度上升,那麼FGM簡單粗暴的“一步到位”,是不是有可能並不能走到約束內的最優點呢?當然是有可能的。於是,一個很intuitive的改進誕生了:Madry在18年的ICLR中[8],提出了用Projected Gradient Descent(PGD)的方法,簡單的說,就是“小步走,多走幾步”,如果走出了擾動半徑為[Math Processing Error]ϵ的空間,就映射回“球面”上,以保證擾動不要過大:

[Math Processing Error]xt+1=Πx+S(xt+αg(xt)/||g(xt)||2)g(xt)=▽xL(θ,xt,y)

其中[Math Processing Error]S={r∈Rd:||r||2≤ϵ}為擾動的約束空間,[Math Processing Error]α為小步的步長。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
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:
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]

使用的時候,要麻煩一點:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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()

在[8]中,作者將這一類通過一階梯度得到的對抗樣本稱之為“一階對抗”,在實驗中,作者發現,經過PGD訓練過的模型,對於所有的一階對抗都能得到一個低且集中的損失值,如下圖所示:

樣本+隨機擾動在兩種模型下的loss值

我們可以看到,面對約束空間[Math Processing Error]S內隨機取樣的十萬個擾動,PGD模型能夠得到一個非常低且集中的loss分佈,因此,在論文中,作者稱PGD為“一階最強對抗”。也就是說,只要能搞定PGD對抗,別的一階對抗就不在話下了。

實驗對照

為了說明對抗訓練的作用,筆者選了四個GLUE中的任務進行了對照試驗。實驗程式碼是用的Huggingface的transfomers/examples/run_glue.py[12],超參都是預設的,對抗訓練用的也是相同的超參。

任務MetricsBERT-BaseFGMPGD
MRPC Accuracy 83.6 86.8 85.8
CoLA Matthew’s corr 56.0 56.0 56.8
STS-B Person/Spearman corr. 89.3/88.8 89.3/88.8 89.3/88.9
RTE Accuracy 64.3 66.8 64.6

我們可以看到,對抗訓練還是有效的,在MRPC和RTE任務上甚至可以提高三四個百分點。不過,根據我們使用的經驗來看,是否有效有時也取決於資料集。畢竟:

緣,妙不可言~

總結

這篇部落格梳理了NLP對抗訓練發展的來龍去脈,介紹了對抗訓練的數學定義,並對於兩種經典的對抗訓練方法,提供了外掛式的實現,做了簡單的實驗對照。由於筆者接觸對抗訓練的時間也並不長,如果文中有理解偏差的地方,希望讀者不吝指出。

一個彩蛋:Virtual Adversarial Training

除了監督訓練,對抗訓練還可以用在半監督任務中,尤其對於NLP任務來說,很多時候輸入的無監督文字多的很,但是很難大規模地進行標註,那麼就可以參考[13]中提到的Virtual Adversarial Training進行半監督訓練。

首先,我們抽取一個隨機標準正態擾動([Math Processing Error]d∼N(0,I)∈Rd),加到embedding上,並用KL散度計算梯度:

[Math Processing Error]g=▽x′DKL(p(⋅|x;θ)||p(⋅|x′;θ))x′=x+ξd

然後,用得到的梯度,計算對抗擾動,並進行對抗訓練:

[Math Processing Error]minθDKL(p(⋅|x;θ)||p(⋅|x∗;θ))x∗=x+ϵg/||g||2

實現方法跟FGM差不多,這裡就不給出了。

Reference

[1]:FreeLB: Enhanced Adversarial Training for Language Understanding.https://arxiv.org/abs/1909.11764
[2]:Technical report on Conversational Question Answering.https://arxiv.org/abs/1909.10772
[3]:EYD與機器學習:對抗攻擊基礎知識(一).https://zhuanlan.zhihu.com/p/37260275
[4]:Towards a Robust Deep Neural Network in Text Domain A Survey.https://arxiv.org/abs/1902.07285
[5]:Adversarial Attacks on Deep Learning Models in Natural Language Processing: A Survey.https://arxiv.org/abs/1901.06796
[6]:Intriguing properties of neural networks.https://arxiv.org/abs/1312.6199
[7]:Explaining and Harnessing Adversarial Examples.https://arxiv.org/abs/1412.6572
[8]:Towards Deep Learning Models Resistant to Adversarial Attacks.https://arxiv.org/abs/1706.06083
[9]:Adversarial Training Methods for Semi-Supervised Text Classification.https://arxiv.org/abs/1605.07725
[10]:Adversarial Text Classification原作實現.https://github.com/tensorflow/models/blob/e97e22dfcde0805379ffa25526a53835f887a860/research/adversarial_text/adversarial_losses.py
[11]:register_hook api.https://www.cnblogs.com/SivilTaram/p/pytorch_intermediate_variable_gradient.html
[12]:huggingface的transformers.https://github.com/huggingface/transformers/tree/master/examples
[13]:Distributional Smoothing with Virtual Adversarial Training.https://arxiv.org/abs/1507.00677