轉自:https://zhuanlan.zhihu.com/p/91269728
本文分享一個“萬物皆可盤”的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],這里就不贅述了。不想要理解理論細節的讀者也可以直接看最后的代碼實現。
1. 對抗樣本
我們常常會聽到“對抗樣本”、“對抗攻擊”、“對抗訓練”等等這些令人頭禿的概念,為了讓大家對“對抗”有個更清晰的認識,我們先把這些概念捋捋清楚。
Taxonomy
Szegedy在14年的ICLR中 [6] 提出了對抗樣本這個概念。如上圖,對抗樣本可以用來攻擊和防御,而對抗訓練其實是“對抗”家族中防御的一種方式,其基本的原理呢,就是通過添加擾動構造一些對抗樣本,放給模型去訓練,以攻為守,提高模型在遇到對抗樣本時的魯棒性,同時一定程度也能提高模型的表現和泛化能力。
那么,什么樣的樣本才是好的對抗樣本呢?對抗樣本一般需要具有兩個特點:
- 相對於原始輸入,所添加的擾動是微小的;
- 能使模型犯錯。
下面是一個對抗樣本的例子,決定就是你啦,胖達:
一只胖達加了點擾動就被識別成了長臂猿
2. 對抗訓練的基本概念
GAN之父Ian Goodfellow在15年的ICLR中 [7] 第一次提出了對抗訓練這個概念,簡而言之,就是在原始輸入樣本
上加一個擾動
,得到對抗樣本后,用其進行訓練。也就是說,問題可以被抽象成這么一個模型:
![[公式]](/image/aHR0cHM6Ly93d3cuemhpaHUuY29tL2VxdWF0aW9uP3RleD0rJTVDbWluXyU3QiU1Q3RoZXRhJTdELSU1Q2xvZytQJTI4eSU3Q3glMkJyXyU3QmFkdiU3RCUzQiU1Q3RoZXRhJTI5Kw==.png)
其中,
為gold label,
為模型參數。那擾動要如何計算呢?Goodfellow認為,神經網絡由於其線性的特點,很容易受到線性擾動的攻擊。
This linear behavior suggests that cheap, analytical perturbations of a linear model should also damage neural networks.
於是,他提出了 Fast Gradient Sign Method (FGSM) ,來計算輸入樣本的擾動。擾動可以被定義為:
![[公式]](/image/aHR0cHM6Ly93d3cuemhpaHUuY29tL2VxdWF0aW9uP3RleD1yXyU3QmFkdiU3RCslM0QrJTVDZXBzaWxvbislNUNjZG90KyU1Q3RleHQlN0JzZ24lN0QlMjglNUN0cmlhbmdsZWRvd25feCtMJTI4JTVDdGhldGElMkMreCUyQyt5JTI5JTI5.png)
其中,
為符號函數,
為損失函數。Goodfellow發現,令
,用這個擾動能給一個單層分類器造成99.9%的錯誤率。看似這個擾動的發現有點拍腦門,但是仔細想想,其實這個擾動計算的思想可以理解為:將輸入樣本向着損失上升的方向再進一步,得到的對抗樣本就能造成更大的損失,提高模型的錯誤率。回想我們上一節提到的對抗樣本的兩個要求,FGSM剛好可以完美地解決。
在 [7] 中,Goodfellow還總結了對抗訓練的兩個作用:
- 提高模型應對惡意對抗樣本時的魯棒性;
- 作為一種regularization,減少overfitting,提高泛化能力。
3. Min-Max 公式
在 [7] 中,對抗訓練的理論部分被闡述得還是比較intuitive,Madry在2018年的ICLR中 [8]總結了之前的工作,並從優化的視角,將問題重新定義成了一個找鞍點的問題,也就是大名鼎鼎的Min-Max公式:
![[公式]](/image/aHR0cHM6Ly93d3cuemhpaHUuY29tL2VxdWF0aW9uP3RleD0lNUNtaW5fJTVDdGhldGErJTVDbWF0aGJiJTdCRSU3RF8lN0IlMjh4JTJDK3klMjklNUNzaW0rJTVDbWF0aGNhbCU3QkQlN0QlN0QrJTVCJTVDbWF4XyU3QnJfJTdCYWR2JTdEKyU1Q2luKyU1Q21hdGhjYWwlN0JTJTdEJTdEK0wlMjglNUN0aGV0YSUyQyt4JTJCcl8lN0JhZHYlN0QlMkMreSUyOSU1RA==.png)
該公式分為兩個部分,一個是內部損失函數的最大化,一個是外部經驗風險的最小化。
- 內部max是為了找到worst-case的擾動,也就是攻擊,其中,
為損失函數,
為擾動的范圍空間。 - 外部min是為了基於該攻擊方式,找到最魯棒的模型參數,也就是防御,其中
是輸入樣本的分布。
Madry認為,這個公式簡單清晰地定義了對抗樣本攻防“矛與盾”的兩個問題:如何構造足夠強的對抗樣本?以及,如何使模型變得刀槍不入?剩下的,就是如何求解的問題了。
4. 從 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對抗訓練的常用的幾個方法和具體實現吧。
5. NLP中的兩種對抗訓練 + PyTorch實現
a. Fast Gradient Method(FGM)
上面我們提到,Goodfellow在15年的ICLR [7] 中提出了Fast Gradient Sign Method(FGSM),隨后,在17年的ICLR [9]中,Goodfellow對FGSM中計算擾動的部分做了一點簡單的修改。假設輸入的文本序列的embedding vectors
為
,embedding的擾動為:
![[公式]](/image/aHR0cHM6Ly93d3cuemhpaHUuY29tL2VxdWF0aW9uP3RleD0lNUNiZWdpbiU3QmFsaWduJTdEK3JfJTdCYWR2JTdEKyUyNiUzRCslNUNlcHNpbG9uKyU1Q2Nkb3QrZyUyRiU3QyU3Q2clN0MlN0NfMislNUMlNUMrZyslMjYlM0QrJTVDdHJpYW5nbGVkb3duX3grTCUyOCU1Q3RoZXRhJTJDK3glMkMreSUyOSslNUNlbmQlN0JhbGlnbiU3RA==.png)
實際上就是取消了符號函數,用二范式做了一個scale,需要注意的是:這里的norm計算的是,每個樣本的輸入序列中出現過的詞組成的矩陣的梯度norm。原作者提供了一個TensorFlow的實現 [10],在他的實現中,公式里的
是embedding后的中間結果(batch_size, timesteps, hidden_dim),對其梯度
的后面兩維計算norm,得到的是一個(batch_size, 1, 1)的向量
。為了實現插件式的調用,筆者將一個batch抽象成一個樣本,一個batch統一用一個norm,由於本來norm也只是一個scale的作用,影響不大。筆者的實現如下:
import torch 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 and not torch.isnan(norm): 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 = {}
需要使用對抗訓練的時候,只需要添加五行代碼:
# 初始化 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,再進行梯度下降。不過這樣實現就與我們追求插件式簡單好用的初衷相悖,這里就不贅述了,感興趣的讀者可以自行實現。
b. Projected Gradient Descent(PGD)
內部max的過程,本質上是一個非凹的約束優化問題,FGM解決的思路其實就是梯度上升,那么FGM簡單粗暴的“一步到位”,是不是有可能並不能走到約束內的最優點呢?當然是有可能的。於是,一個很intuitive的改進誕生了:Madry在18年的ICLR中[8],提出了用Projected Gradient Descent(PGD)的方法,簡單的說,就是“小步走,多走幾步”,如果走出了擾動半徑為
的空間,就映射回“球面”上,以保證擾動不要過大:
![[公式]](/image/aHR0cHM6Ly93d3cuemhpaHUuY29tL2VxdWF0aW9uP3RleD0lNUNiZWdpbiU3QmFsaWduJTdEK3hfJTdCdCUyQjElN0QrJTI2JTNEKyU1Q1BpXyU3QnglMkIlNUNtYXRoY2FsJTdCUyU3RCU3RCUyOHhfdCUyQiU1Q2FscGhhK2clMjh4X3QlMjklMkYlN0MlN0NnJTI4eF90JTI5JTdDJTdDXzIlMjkrJTVDJTVDK2clMjh4X3QlMjkrJTI2JTNEKyU1Q3RyaWFuZ2xlZG93bl94K0wlMjglNUN0aGV0YSUyQyt4X3QlMkMreSUyOSslNUNlbmQlN0JhbGlnbiU3RCs=.png)
其中
為擾動的約束空間,
為小步的步長。
import torch 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 and not torch.isnan(norm): 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(