(五)對抗訓練詳解


https://spaces.ac.cn/archives/7234

https://fyubang.com/2019/10/15/adversarial-train/

一、KERAS實現

當前,說到深度學習中的對抗,一般會有兩個含義:一個是生成對抗網絡(Generative Adversarial Networks,GAN),代表着一大類先進的生成模型;另一個則是跟對抗攻擊、對抗樣本相關的領域,它跟GAN相關,但又很不一樣,它主要關心的是模型在小擾動下的穩健性。本博客里以前所涉及的對抗話題,都是前一種含義,而今天,我們來聊聊后一種含義中的“對抗訓練”。

本文包括如下內容:

1、對抗樣本、對抗訓練等基本概念的介紹;

2、介紹基於快速梯度上升的對抗訓練及其在NLP中的應用;

3、給出了對抗訓練的Keras實現(一行代碼調用);

4、討論了對抗訓練與梯度懲罰的等價性;

5、基於梯度懲罰,給出了一種對抗訓練的直觀的幾何理解。

 

方法介紹 #

近年來,隨着深度學習的日益發展和落地,對抗樣本也得到了越來越多的關注。在CV領域,我們需要通過對模型的對抗攻擊和防御來增強模型的穩健型,比如在自動駕駛系統中,要防止模型因為一些隨機噪聲就將紅燈識別為綠燈。在NLP領域,類似的對抗訓練也是存在的,不過NLP中的對抗訓練更多是作為一種正則化手段來提高模型的泛化能力!

這使得對抗訓練成為了NLP刷榜的“神器”之一,前有微軟通過RoBERTa+對抗訓練在GLUE上超過了原生RoBERTa,后有我司的同事通過對抗訓練刷新了CoQA榜單。這也成功引起了筆者對它的興趣,遂學習了一番,分享在此。

基本概念 #

要認識對抗訓練,首先要了解“對抗樣本”,它首先出現在論文《Intriguing properties of neural networks》之中。簡單來說,它是指對於人類來說“看起來”幾乎一樣、但對於模型來說預測結果卻完全不一樣的樣本,比如下面的經典例子:

對抗樣本經典例子。來自論文《Explaining and Harnessing Adversarial Examples》

對抗樣本經典例子。來自論文《Explaining and Harnessing Adversarial Examples》

 

理解對抗樣本之后,也就不難理解各種相關概念了,比如“對抗攻擊”,其實就是想辦法造出更多的對抗樣本,而“對抗防御”,就是想辦法讓模型能正確識別更多的對抗樣本。所謂對抗訓練,則是屬於對抗防御的一種,它構造了一些對抗樣本加入到原數據集中,希望增強模型對對抗樣本的魯棒性;同時,如本文開篇所提到的,在NLP中它通常還能提高模型的表現。

Min-Max #

總的來說,對抗訓練可以統一寫成如下格式

minθE(x,y)D[maxΔxΩL(x+Δx,y;θ)](1)(1)minθE(x,y)∼D[maxΔx∈ΩL(x+Δx,y;θ)]


其中DD代表訓練集,xx代表輸入,yy代表標簽,θθ是模型參數,L(x,y;θ)L(x,y;θ)是單個樣本的loss,ΔxΔx是對抗擾動,ΩΩ是擾動空間。這個統一的格式首先由論文《Towards Deep Learning Models Resistant to Adversarial Attacks》提出。

 

這個式子可以分步理解如下:

1、往屬於xx里邊注入擾動ΔxΔx,ΔxΔx的目標是讓L(x+Δx,y;θ)L(x+Δx,y;θ)越大越好,也就是說盡可能讓現有模型的預測出錯;

2、當然ΔxΔx也不是無約束的,它不能太大,否則達不到“看起來幾乎一樣”的效果,所以ΔxΔx要滿足一定的約束,常規的約束是Δxϵ‖Δx‖≤ϵ,其中ϵϵ是一個常數;

3、每個樣本都構造出對抗樣本x+Δxx+Δx之后,用(x+Δx,y)(x+Δx,y)作為數據對去最小化loss來更新參數θθ(梯度下降);

4、反復交替執行1、2、3步。

由此觀之,整個優化過程是maxmax和minmin交替執行,這確實跟GAN很相似,不同的是,GAN所maxmax的自變量也是模型的參數,而這里maxmax的自變量則是輸入(的擾動量),也就是說要對每一個輸入都定制一步maxmax。

快速梯度 #

現在的問題是如何計算ΔxΔx,它的目標是增大L(x+Δ,y;θ)L(x+Δ,y;θ),而我們知道讓loss減少的方法是梯度下降,那反過來,讓loss增大的方法自然就是梯度上升,因此可以簡單地取

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


當然,為了防止ΔxΔx過大,通常要對xL(x,y;θ)∇xL(x,y;θ)做些標准化,比較常見的方式是

Δx=ϵxL(x,y;θ)xL(x,y;θ)Δx=ϵsign(xL(x,y;θ))(3)Δx=ϵ∇xL(x,y;θ)‖∇xL(x,y;θ)‖或Δx=ϵsign(∇xL(x,y;θ))


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

minθE(x,y)D[L(x+Δx,y;θ)](4)(4)minθE(x,y)∼D[L(x+Δx,y;θ)]


這就構成了一種對抗訓練方法,被稱為Fast Gradient Method(FGM),它由GAN之父Goodfellow在論文《Explaining and Harnessing Adversarial Examples》首先提出。

 

此外,對抗訓練還有一種方法,叫做Projected Gradient Descent(PGD),其實就是通過多迭代幾步來達到讓L(x+Δx,y;θ)L(x+Δx,y;θ)更大的ΔxΔx(如果迭代過程中模長超過了ϵϵ,就縮放回去,細節請參考《Towards Deep Learning Models Resistant to Adversarial Attacks》。)。但本文不旨在對對抗學習做完整介紹,而且筆者認為它不如FGM漂亮有效,所以本文還是以FGM為重點。關於對抗訓練的補充介紹,建議有興趣的讀者閱讀富邦同學寫的《功守道:NLP中的對抗訓練 + PyTorch實現》

回到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)(b,h,w,c),這時候我們需要固定模型的batch size(即bb),然后給原始輸入加上一個shape同樣為(b,h,w,c)(b,h,w,c)、全零初始化的Variable,比如就叫做ΔxΔx,那么我們可以直接求loss對xx的梯度,然后根據梯度給ΔxΔx賦值,來實現對輸入的干擾,完成干擾之后再執行常規的梯度下降。

對於NLP任務來說,原則上也要對Embedding層的輸出進行同樣的操作,Embedding層的輸出shape為(b,n,d)(b,n,d),所以也要在Embedding層的輸出加上一個shape為(b,n,d)(b,n,d)的Variable,然后進行上述步驟。但這樣一來,我們需要拆解、重構模型,對使用者不夠友好。

不過,我們可以退而求其次。Embedding層的輸出是直接取自於Embedding參數矩陣的,因此我們可以直接對Embedding參數矩陣進行擾動。這樣得到的對抗樣本的多樣性會少一些(因為不同樣本的同一個token共用了相同的擾動),但仍然能起到正則化的作用,而且這樣實現起來容易得多。

代碼參考 #

基於上述思路,這里給出Keras下基於FGM方式對Embedding層進行對抗訓練的參考實現:

核心代碼如下:

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)

需要指出的是,由於每一步算對抗擾動也需要計算梯度,因此每一步訓練一共算了兩次梯度,因此每步的訓練時間會翻倍。

效果比較 #

為了測試實際效果,筆者選了中文CLUE榜的兩個分類任務:IFLYTEK和TNEWS,模型選擇了中文BERT base。在CLUE榜單上,BERT base模型在這兩個數據上的成績分別是60.29%和56.58%,經過對抗訓練后,成績為62.46%、57.66%,分別提升了2%和1%!

 

無對抗訓練加對抗訓練IFLYTEK60.29%62.46%TNEWS56.58%57.66%IFLYTEKTNEWS無對抗訓練60.29%56.58%加對抗訓練62.46%57.66%

 

訓練腳本請參考:task_iflytek_adversarial_training.py

當然,同所有正則化手段一樣,對抗訓練也不能保證每一個任務都能有提升,但從目前大多數“戰果”來看,它是一種非常值得嘗試的技術手段。此外,BERT的finetune本身就是一個非常玄乎(靠人品)的過程,前些時間論文《Fine-Tuning Pretrained Language Models: Weight Initializations, Data Orders, and Early Stopping》換用不同的隨機種子跑了數百次finetune實驗,發現最好的結果能高出好幾個點,所以如果你跑了一次發現沒提升,不妨多跑幾次再下結論。

延伸思考 #

在這一節中,我們從另一個視角對上述結果進行分析,從而推出對抗訓練的另一種方法,並且得到一種關於對抗訓練的更直觀的幾何理解。

梯度懲罰 #

假設已經得到對抗擾動ΔxΔx,那么我們在更新θθ時,考慮對L(x+Δx,y;θ)L(x+Δx,y;θ)的展開:

(5)minθE(x,y)∼D[L(x+Δx,y;θ)]≈minθE(x,y)∼D[L(x,y;θ)+⟨∇xL(x,y;θ),Δx⟩]


對應的θθ的梯度為

θL(x,y;θ)+θxL(x,y;θ),Δx(6)(6)∇θL(x,y;θ)+⟨∇θ∇xL(x,y;θ),Δx⟩


代入Δx=ϵxL(x,y;θ)Δx=ϵ∇xL(x,y;θ),得到

(7)∇θL(x,y;θ)+ϵ⟨∇θ∇xL(x,y;θ),∇xL(x,y;θ)⟩=∇θ(L(x,y;θ)+12ϵ‖∇xL(x,y;θ)‖2)


這個結果表示,對輸入樣本施加ϵxL(x,y;θ)ϵ∇xL(x,y;θ)的對抗擾動,一定程度上等價於往loss里邊加入“梯度懲罰

12ϵxL(x,y;θ)2(8)(8)12ϵ‖∇xL(x,y;θ)‖2


如果對抗擾動是ϵxL(x,y;θ)/xL(x,y;θ)ϵ∇xL(x,y;θ)/‖∇xL(x,y;θ)‖,那么對應的梯度懲罰項則是ϵxL(x,y;θ)ϵ‖∇xL(x,y;θ)‖(少了個1/21/2,也少了個2次方)。

 

事實上,這個結果不是新的,據筆者所知,它首先出現論文《Improving the Adversarial Robustness and Interpretability of Deep Neural Networks by Regularizing their Input Gradients》里。只不過這篇文章不容易搜到,因為你一旦搜索“adversarial training gradient penalty”等關鍵詞,出來的結果幾乎都是WGAN-GP相關的東西。

幾何圖像 #

事實上,關於梯度懲罰,我們有一個非常直觀的幾何圖像。以常規的分類問題為例,假設有nn個類別,那么模型相當於挖了nn個坑,然后讓同類的樣本放到同一個坑里邊去:

分類問題就是挖坑,然后將同類樣本放在同一個坑內

分類問題就是挖坑,然后將同類樣本放在同一個坑內

 

梯度懲罰則說“同類樣本不僅要放在同一個坑內,還要放在坑底”,這就要求每個坑的內部要長這樣:

對抗訓練希望每個樣本都在一個“坑中坑”的坑底

對抗訓練希望每個樣本都在一個“坑中坑”的坑底

 

為什么要在坑底呢?因為物理學告訴我們,坑底最穩定呀,所以就越不容易受干擾呀,這不就是對抗訓練的目的么?

“坑底”最穩定。受到干擾后依然在坑底附近徘徊,不容易挑出坑(跳出坑往往意味着分類錯誤)

“坑底”最穩定。受到干擾后依然在坑底附近徘徊,不容易挑出坑(跳出坑往往意味着分類錯誤)

 

那坑底意味着什么呢?極小值點呀,導數(梯度)為零呀,所以不就是希望xL(x,y;θ)‖∇xL(x,y;θ)‖越小越好么?這便是梯度懲罰(8)(8)的幾何意義了。類似的“挖坑”、“坑底”與梯度懲罰的幾何圖像,還可以參考《能量視角下的GAN模型(一):GAN=“挖坑”+“跳坑”》

L約束 #

我們還可以從L約束(Lipschitz約束)的角度來看梯度懲罰。所謂對抗樣本,就是輸入的小擾動導致輸出的大變化,而關於輸入輸出的控制問題,我們之前在文章《深度學習中的L約束:泛化與生成模型》就已經探討過。一個好的模型,理論上應該是“輸入的小擾動導致導致輸出的小變化”,而為了做到這一點,一個很常用的方案是讓模型滿足L約束,即存在常數LL,使得

f(x1)f(x2)Lx1x2(9)(9)‖f(x1)−f(x2)‖≤L‖x1−x2‖


這樣一來只要兩個輸出的差距x1x2‖x1−x2‖足夠小,那么就能保證輸出的差距也足夠小。而《深度學習中的L約束:泛化與生成模型》已經討論了,實現L約束的方案之一就是譜歸一化(Spectral Normalization),所以往神經網絡里邊加入譜歸一化,就可以增強模型的對抗防御性能。相關的工作已經被發表在《Generalizable Adversarial Training via Spectral Normalization》

 

美中不足的是,譜歸一化是對模型的每一層權重都進行這樣的操作,結果就是神經網絡的每一層都滿足L約束,這是不必要的(我們只希望整個模型滿足L約束,不必強求每一層都滿足),因此理論上來說L約束會降低模型表達能力,從而降低模型性能。而在WGAN系列模型中,為了讓判別器滿足L約束,除了譜歸一化外,還有一種常見的方案,那就是梯度懲罰。因此,梯度懲罰也可以理解為一個促使模型滿足L約束的正則項,而滿足L約束則能有效地抵御對抗樣本的攻擊。

代碼實現 #

既然梯度懲罰號稱能有類似的效果,那必然也是要接受實驗驗證的了。相比前面的FGM式對抗訓練,其實梯度懲罰實現起來還容易一些,因為它就是在loss里邊多加一項罷了,而且實現方式是通用的,不用區分CV還是NLP。

Keras參考實現如下:

def sparse_categorical_crossentropy(y_true, y_pred): """自定義稀疏交叉熵 這主要是因為keras自帶的sparse_categorical_crossentropy不支持求二階梯度。 """ y_true = K.reshape(y_true, K.shape(y_pred)[:-1]) y_true = K.cast(y_true, 'int32') y_true = K.one_hot(y_true, K.shape(y_pred)[-1]) return K.categorical_crossentropy(y_true, y_pred) def loss_with_gradient_penalty(y_true, y_pred, epsilon=1): """帶梯度懲罰的loss """ loss = K.mean(sparse_categorical_crossentropy(y_true, y_pred)) embeddings = search_layer(y_pred, 'Embedding-Token').embeddings gp = K.sum(K.gradients(loss, [embeddings])[0].values**2) return loss + 0.5 * epsilon * gp model.compile( loss=loss_with_gradient_penalty, optimizer=Adam(2e-5), metrics=['sparse_categorical_accuracy'], )

可以看到,定義帶梯度懲罰的loss非常簡單,就兩行代碼而已。需要指出的是,梯度懲罰意味着參數更新的時候需要算二階導數,但是Tensorflow和Keras自帶的loss函數不一定支持算二階導數,比如K.categorical_crossentropy支持而K.sparse_categorical_crossentropy不支持,遇到這種情況時,需要自定重新定義loss。

效果比較 #

還是前面兩個任務,結果如下表。可以看到,梯度懲罰能取得跟FGM基本一致的結果。

無對抗訓練加對抗訓練加梯度懲罰IFLYTEK60.29%62.46%62.31%TNEWS56.58%57.66%57.81%IFLYTEKTNEWS無對抗訓練60.29%56.58%加對抗訓練62.46%57.66%加梯度懲罰62.31%57.81%

 

完整的代碼請參考:task_iflytek_gradient_penalty.py

本文小結 #

本文簡單介紹了對抗訓練的基本概念和推導,着重講了其中的FGM方法並給出了Keras實現,實驗證明它能提高一些NLP模型的泛化性能。此外,本文還討論了對抗學習與梯度懲罰的聯系,並給出了梯度懲罰的一種直觀的幾何理解。

 二、pytorch實現

最近,微軟的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] 第一次提出了對抗訓練這個概念,簡而言之,就是在原始輸入樣本 xx 上加一個擾動 radvradv ,得到對抗樣本后,用其進行訓練。也就是說,問題可以被抽象成這么一個模型:

minθlogP(y|x+radv;θ)minθ−log⁡P(y|x+radv;θ)

其中,yy為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) ,來計算輸入樣本的擾動。擾動可以被定義為:

radv=ϵsgn(xL(θ,x,y))radv=ϵ⋅sgn(▽xL(θ,x,y))

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

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

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

Min-Max 公式

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

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

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

  1. 內部max是為了找到worst-case的擾動,也就是攻擊,其中,LL 為損失函數,SS 為擾動的范圍空間。
  2. 外部min是為了基於該攻擊方式,找到最魯棒的模型參數,也就是防御,其中DD是輸入樣本的分布。

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 [v1,v2,,vT][v1,v2,…,vT]為xx,embedding的擾動為:

radvg=ϵg/||g||2=xL(θ,x,y)radv=ϵ⋅g/||g||2g=▽xL(θ,x,y)

實際上就是取消了符號函數,用二范式做了一個scale,需要注意的是:這里的norm計算的是,每個樣本的輸入序列中出現過的詞組成的矩陣的梯度norm。原作者提供了一個TensorFlow的實現 [10],在他的實現中,公式里的 xx 是embedding后的中間結果(batch_size, timesteps, hidden_dim),對其梯度 gg 的后面兩維計算norm,得到的是一個(batch_size, 1, 1)的向量 ||g||2||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)的方法,簡單的說,就是“小步走,多走幾步”,如果走出了擾動半徑為ϵϵ的空間,就映射回“球面”上,以保證擾動不要過大:

xt+1g(xt)=Πx+S(xt+αg(xt)/||g(xt)||2)=xL(θ,xt,y)xt+1=Πx+S(xt+αg(xt)/||g(xt)||2)g(xt)=▽xL(θ,xt,y)

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

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值

 

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

實驗對照

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

任務 Metrics BERT-Base FGM PGD
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進行半監督訓練。

首先,我們抽取一個隨機標准正態擾動(dN(0,I)Rdd∼N(0,I)∈Rd),加到embedding上,並用KL散度計算梯度:

gx=xDKL(p(|x;θ)||p(|x;θ))=x+ξdg=▽x′DKL(p(⋅|x;θ)||p(⋅|x′;θ))x′=x+ξd

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

minθxDKL(p(|x;θ)||p(|x;θ))=x+ϵg/||g||2minθ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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM