Bert文字分類實踐(三):處理樣本不均衡和提升模型魯棒性trick

盛小賤吖發表於2021-10-16

寫在前面

​ 文字分類是nlp中一個非常重要的任務,也是非常適合入坑nlp的第一個完整專案。雖然文字分類看似簡單,但裡面的門道好多好多,博主水平有限,只能將平時用到的方法和trick在此做個記錄和分享,希望各位看官都能有所收穫。並且儘可能提供給出簡潔,清晰的程式碼實現。

​ 本文主要討論文字分類中處理樣本不均衡和提升模型魯棒性的trick,也是最近面試總結的一部分(面試好痛苦/(ㄒoㄒ)/~~,但還是得淦)。文章內容是根據平時閱讀論文,知乎,公眾號和實踐得到的,如有表述不夠清楚、詳盡的地方可參考文末的原作者連結。

緩解樣本不均衡

假如我們要實現一個新聞正負面判斷的文字二分類器,負面新聞的樣本比例較少,可能2W條新聞有100條甚至更少的樣本屬於正例。這種現象就是樣本不均衡,因為樣本會呈現一個長尾分佈,頭部的標籤包含了大量的樣本,而尾部的標籤擁有很少的樣本,就像下面這張圖片中表現的那樣出現一個長長的尾巴,所以這種現場也稱為長尾現象。

樣本不均衡會帶來很多問題。模型訓練的本質是最小化損失函式,當某個類別的樣本數量非常龐大,損失函式的值大部分被樣本數量較大的類別所影響,導致的結果就是模型分類會傾向於樣本量較大的類別。我們們拿上面文字分類的例子來說明,現在有2W條使用者搜尋的樣本,其中100條是負面新聞,即正樣本,那麼當模型全部將樣本預測為負例,也能得到99.5%的準確率,但這個模型跟盲猜也沒區別,沒什麼用,我們的目的是找到讓模型能夠正確的區分正例和負例。

模型層面解決樣本不均衡

加入Focal Loss學習難學樣本,具體原理可以參考蘇神的文章

Focal Loss pytorch程式碼實現

class FocalLoss(nn.Module):
    """Multi-class Focal loss implementation"""
    def __init__(self, gamma=2, weight=None, reduction='mean', ignore_index=-100):
        super(FocalLoss, self).__init__()
        self.gamma = gamma
        self.weight = weight
        self.ignore_index = ignore_index
        self.reduction = reduction

    def forward(self, input, target):
        """
        input: [N, C]
        target: [N, ]
        """
        log_pt = torch.log_softmax(input, dim=1)
        pt = torch.exp(log_pt)
        log_pt = (1 - pt) ** self.gamma * log_pt
        loss = torch.nn.functional.nll_loss(log_pt, target, self.weight, reduction=self.reduction, ignore_index=self.ignore_index)
        return loss

程式碼連結:

py版本:https://github.com/PouringRain/blog_code/blob/main/nlp/focal_loss.py

喜歡的話,給萌新的github倉庫一顆小星星哦……^ _^

資料層面解決樣本不均衡

現在我們遇到樣本不均衡的問題,假如我們的正樣本只有100條,而負樣本可能有1W條。如果不採取任何策略,那麼我們就是使用這1.01W條樣本去訓練模型。從資料層面解決樣本不均衡的問題核心是通過人為控制正負樣本的比例,分成欠取樣和過取樣兩種。

3.1 欠取樣

欠取樣的基本做法是這樣的,現在我們的正負樣本比例為1:100。如果我們想讓正負樣本比例不超過1:10,那麼模型訓練的時候數量比較少的正樣本也就是100條全部使用,而負樣本隨機挑選1000條,這樣通過人為的方式我們把樣本的正負比例強行控制在了1:10。這種方式存在一個問題,為了強行控制樣本比例我們生生的捨去了那9000條負樣本,這對於模型來說是莫大的損失。

相比於簡單的對負樣本隨機取樣的欠取樣方法,實際工作中我們會使用迭代預分類的方式來取樣負樣本。具體流程如下圖所示:

img

首先我們會使用全部的正樣本和從負例候選集中隨機取樣一部分負樣本(這裡假如是100條)去訓練第一輪分類器;然後用第一輪分類器去預測負例候選集剩餘的9900條資料,把9900條負例中預測為正例的樣本(也就是預測錯誤的樣本)再隨機取樣100條和第一輪訓練的資料放到一起去訓練第二輪分類器;同樣的方法用第二輪分類器去預測負例候選集剩餘的9800條資料,直到訓練的第N輪分類器可以全部識別負例候選集,這就是使用迭代預分類的方式進行欠取樣。

相比於隨機欠取樣來說迭代預分類的欠取樣方式能最大限度的利用負樣本中差異性較大的負樣本,從而在控制正負樣本比例的基礎上取樣出了最有代表意義的負樣本。

欠取樣的方式整體來說或多或少的會損失一些樣本,對於那些需要控制樣本量級的場景下比較合適。如果沒有嚴格控制樣本量級的要求那麼下面的過取樣可能會更加適合你。

3.2 過取樣

過取樣和上面的欠取樣比較類似,都是人工干預控制樣本的比例,不同的是過取樣不會損失樣本。還拿上面的例子,現在有正樣本100條,負樣本1W條,最簡單的過取樣方式是我們會使用全部的負樣本1W條,但是為了維持正負樣本比例,我們會從正樣本中有放回的重複取樣,直到獲取了1000條正樣本,也就是說有些正樣本可能會被重複取樣到,這樣就能保持1:10的正負樣本比例了。這是最簡單的過取樣方式,這種方式可能會存在嚴重的過擬合。

實際的場景中會通過樣本增強的技術來增加正樣本。

提升模型魯棒性

提升模型魯棒性的方法有很多,其中對抗訓練、知識蒸餾、防止模型過擬合和多模型融合是常見的穩定提升方式,let's see see!

對抗訓練

對抗訓練是一種能有效提高模型魯棒性和泛化能力的訓練手段,其基本原理是通過在原始輸入上增加對抗擾動,得到對抗樣本,再利用對抗樣本進行訓練,從而提高模型的表現。

由於自然語言文字是離散的,一般會把對抗擾動新增到嵌入層上。為了最大化對抗樣本的擾動能力,利用梯度上升的方式生成對抗樣本。為了避免擾動過大,將梯度做了歸一化處理。

\[{g} = -\bigtriangledown_ {\mathcal{L}}(y_i|{v}; {\hat{\theta}} ) \\ {v}^* = {v}+ \epsilon{g} / \|{g}\|_2 \]

其中,v為嵌入向量。實際訓練過程中,我們在訓練完一個batch的原始輸入資料時,儲存當前batch對輸入詞向量的梯度,得到對抗樣本後,再使用對抗樣本進行對抗訓練。

對抗訓練pytorch程式碼實現

class FGM():
    def __init__(self, model):
        self.model = model
        self.backup = {}

    def attack(self, epsilon=1., emb_name='emb'):
        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'):
        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() 
  # 對抗訓練
  fgm.attack() # 修改embedding
  # optimizer.zero_grad() # 梯度累加,不累加去掉註釋
  loss_sum = model(batch_input, batch_label)
  loss_sum.backward() # 累加對抗訓練的梯度
  fgm.restore() # 恢復Embedding的引數

  optimizer.step()
  optimizer.zero_grad()

程式碼連結:

py版本:https://github.com/PouringRain/blog_code/blob/main/nlp/at.py

喜歡的話,給萌新的github倉庫一顆小星星哦……^ _^

知識蒸餾

與對抗訓練類似,知識蒸餾也是一種常用的提高模型泛化能力的訓練方法。

知識蒸餾這個概念最早由Hinton在2015年提出。一開始,知識蒸餾通往往應用在模型壓縮方面,利用訓練好的複雜模型(teacher model)輸出作為監督訊號去訓練另一個簡單模型(student model),從而將teacher學習到的知識遷移到student。Tommaso在18年提出,如果student和teacher的模型完全相同,蒸餾後則會對模型的表現有一定程度上的提升。

防止模型過擬合

正則化

L1和L2正則化

L1正則化可以得到稀疏解,L2正則化可以得到平滑解,原因參考(https://blog.csdn.net/f156207495/article/details/82794151)。

Dropout

dropout是指在深度學習網路的訓練過程中,對於神經網路單元,按照一定的概率將其暫時從網路中丟棄。dropout為什麼能防止過擬合,可以通過以下幾個方面來解釋:

  1. 它強迫一個神經單元,和隨機挑選出來的其他神經單元共同工作,達到好的效果。消除減弱了神經元節點間的聯合適應性,增強了泛化能力。
  2. 類似於bagging的整合效果
  3. 對於每一個dropout後的網路,進行訓練時,相當於做了Data Augmentation,因為,總可以找到一個樣本,使得在原始的網路上也能達到dropout單元后的效果。 比如,對於某一層,dropout一些單元后,形成的結果是(1.5,0,2.5,0,1,2,0),其中0是被drop的單元,那麼總能找到一個樣本,使得結果也是如此。這樣,每一次dropout其實都相當於增加了樣本。

dropout在測試時,並不會隨機丟棄神經元,而是使用全部所有的神經元,同時,所有的權重值都乘上1-p,p代表的是隨機失活率。

資料增強

資料增強即需要得到更多的符合要求的資料,即和已有的資料是獨立同分布的,或者近似獨立同分布的。一般有以下方法:

1)從資料來源頭採集更多資料

2)複製原有資料並加上隨機噪聲

3)重取樣

4)根據當前資料集估計資料分佈引數,使用該分佈產生更多資料等

Early stopping

在模型對訓練資料集迭代收斂之前停止迭代來防止過擬合。因為在初始化網路的時候一般都是初始為較小的權值,訓練時間越長,部分網路權值可能越大。如果我們在合適時間停止訓練,就可以將網路的能力限制在一定範圍內。

交叉驗證

交叉驗證的基本思想就是將原始資料(dataset)進行分組,一部分做為訓練集來訓練模型,另一部分做為測試集來評價模型。我們常用的交叉驗證方法有簡單交叉驗證、S折交叉驗證和留一交叉驗證。

Batch Normalization

一種非常有用的正則化方法,可以讓大型的卷積網路訓練速度加快很多倍,同時收斂後分類的準確率也可以大幅度的提高。BN在訓練某層時,會對每一個mini-batch資料進行標準化(normalization)處理,使輸出規範到N(0,1)的正態分佈,減少了Internal convariate shift(內部神經元分佈的改變),傳統的深度神經網路在訓練是,每一層的輸入的分佈都在改變,因此訓練困難,只能選擇用一個很小的學習速率,但是每一層用了BN後,可以有效的解決這個問題,學習速率可以增大很多倍。

選擇合適的網路結構

通過減少網路層數、神經元個數、全連線層數等降低網路容量

多模型融合

Baggging &Boosting,將弱分類器融合之後形成一個強分類器,而且融合之後的效果會比最好的弱分類器更好,三個臭皮匠頂一個諸葛亮。

參考資料

  1. 文字分類中的樣本不均衡問題
  2. 功守道:NLP 中的對抗訓練 + PyTorch 實現
  3. 欠擬合,過擬合及如何防止過擬合
  4. 知識蒸餾論文
  5. 蘇神focal loss理解

相關文章