如何讓Bert在finetune小數據集時更“穩”一點

作者:邱震宇(華泰證券股份有限公司 算法工程師)

知乎專欄:我的ai之路

來自:AINLP

最近刷到一篇論文,題目是Revisiting Few-sample BERT Fine-tuning 。論文剛掛到arxiv上,雖然關注的人還不是很多,但是讀完之後發現內容很實用,很適合應用到實際的業務中。本文主要就這篇論文中的一些觀點進行解讀和實驗驗證。

話不多說,直接進入正題。這篇論文主要探討的主題是如何更有效地使用bert在小數據集上進行finetune。論文指出目前bert的finetune存在不穩定的問題,尤其是在小數據集上,訓練初期,模型會持續震盪,進而會降低整個訓練過程的效率,減慢收斂的速度,也會在一定程度上降低模型的精度。文章主要總結了三個優化的方向,分別從優化方法、權重參數、訓練方式等角度探討了如何在小數據集上穩定finetune bert模型。下面將分別從這三個的角度詳細解讀。

Adam優化的debiasing

不知道大家在使用tensorflow或者pytorch版本的官方bert源碼時,有沒有發現他們的Adam實現源碼與原版的Adam實現略有不同。我們先來簡單回顧一下Adam算法的流程:

adam主要是結合了一階動量、二階動量滑動平均,並輔以learning rate的adaptive change,使得模型訓練能夠更加高效且能夠自適應改變learning rate。除此之外,adam還有一個算法細節需要關注,即bias correcting。注意到上圖中紅色框標註的部分,在梯度更新操作之前,需要對一階動量和二階動量進行bias修正。這樣做的原因在於adam的動量均是使用0來初始化。因此在模型訓練初期以及指數衰減率超參數(  )很小的時候,動量估計值很容易往0的方向偏移,此時需要對動量做偏移修正,具體的修正操作如圖紅色框所示。具體的推導可以參考原始的Adam論文。這裏簡單回顧一下,以二階動量的推導爲例:

推導的主要邏輯在於建立二階動量  的期望  與  的期望  的表達式關係。

首先根據上圖的步驟8,可以將二階動量 轉化爲以歷史時間戳上的梯度  爲變量的函數:

對上式兩邊同時求期望,可以得到:


(updated on 2020.06.17)新增內容:這裏的推導我又研究了一下,最後看到一個網站上的回答有些道理,這裏貼出來供大家參考:Understanding a derivation of bias correction for the Adam optimizer

首先要弄清楚  是怎麼得到的。推測 是根據當前時刻的梯度  去估計歷史梯度  時的誤差項。有了這個誤差項,我們就可以將  項從求和公式中移出來,不再依賴i。而所有包含  的項此時可以看成是常量,可以從期望的括號中移出來。當二階動量是一個穩態分佈時,在每個時刻t上它都是一個常量,因此  爲0。

那麼接下來還有一個問題就是  如何化簡爲  的呢?這就需要用到有限等比數列求和的相關公式了。對於一個有限等比數列,它的求和可以表達爲如下公式:

自我吐槽一下:高中數學全還給老師了,汗顏。。。

此時將  帶入上式,同時由於我們已經將當前時刻的  從求和公式中提了出來,因此可以做出如下推導:

上述第二項等式通過將  乘到右邊的除法項,同時分子分母同時乘以  就可以得到第三項。

其中,  可以通過控制衰減率超參數  ,來讓其接近於0。那麼剩下的偏移影響因素就是  。因此,我們通過將  除以這個項來達到偏移修正的目的。

這塊推導由於本人數學能力不強,所以理解得不是很深入,歡迎數學不錯的同學前來拍磚。

Bert的adam

我們查看google給出的官方bert源碼工程(github.com/google-resea)中的optimization.py,在其AdamWeightDecay類中,可以看到其省略了上述偏移修正的步驟:

m = tf.get_variable(
          name=param_name + "/adam_m",
          shape=param.shape.as_list(),
          dtype=tf.float32,
          trainable=False,
          initializer=tf.zeros_initializer())
v = tf.get_variable(
          name=param_name + "/adam_v",
          shape=param.shape.as_list(),
          dtype=tf.float32,
          trainable=False,
          initializer=tf.zeros_initializer())

# Standard Adam update.
next_m = (
          tf.multiply(self.beta_1, m) + tf.multiply(1.0 - self.beta_1, grad))
next_v = (
          tf.multiply(self.beta_2, v) + tf.multiply(1.0 - self.beta_2,
                                                    tf.square(grad)))

update = next_m / (tf.sqrt(next_v) + self.epsilon)

通過查閱Bert的原始論文,並沒有發現作者在這塊有具體的說明,只能推測使用bert做預訓練時,由於訓練語料規模非常龐大,且訓練的步數也是非常多,因此即使不做偏移修正,模型仍然能夠在訓練過程中慢慢保持穩定狀態,且減去了偏移修正的計算量,整體的計算成本還降低了一些。

然而,如果在樣本較少的下游任務場景下,仍然使用這種優化方式就會出現訓練不穩定的問題。爲了驗證這個結論,論文作者做了細緻的比對實驗,他們在四個不同的數據集上,嘗試了50種不同的隨機種子,分別用帶偏移修正的原始adam和不帶修正的bertAdam去做finetune任務。實驗結果分別從不同角度來驗證上述的觀點,比如下圖:

這是一個模型在不同數據集上的測試集效果箱線圖,圖中表明在四個數據集上,使用偏移修正的adam能夠極大提升模型在測試集上的效果。

再看下面這個圖:

這張圖反映了模型在小數據集RTE上的訓練曲線。可以看到,使用偏移修正的Adam來finetune能夠更快達到收斂,同時獲得更小的loss。

再次驗證

實踐出真知,爲了驗證上述結論的有效性,我決定找一個小數據集進行實際測試。正好最近有一個ccks舉辦的實體識別比賽,名稱是面向試驗鑑定的命名實體識別任務。這個比賽的訓練樣本只有400條,實體類型有4種,足以稱得上是小數據量了,正好可以拿來做實驗。實驗的模型主體是Bert+crf框架,超參數和隨機種子都固定不變,唯一改變條件的就是是否使用偏移修正。

updated on 2020.06.17)在tf的bert實現中,要將原始的偏移修正補充進去,要添加一定量的代碼,主要是增加  的計算和更新,以及偏移修正的邏輯計算。通過閱讀原始的tf的adam源碼,可以發現它的偏移修正是通過對learning_rate進行修正,即  。除此之外,tensorflow還對的更新和賦值有自己的計算圖優化邏輯,所以相比較於keras的代碼更爲複雜。下面貼出補充完誤差修正後的adamweightdecay代碼,可與原始的adam代碼對比查看:

class AdamWeightDecayOptimizer(optimizer.Optimizer):
    """A basic Adam optimizer that includes "correct" L2 weight decay."""

    def __init__(self,
                 learning_rate,
                 weight_decay_rate=0.0,
                 beta_1=0.9,
                 beta_2=0.999,
                 epsilon=1e-6,
                 exclude_from_weight_decay=None,
                 name="AdamWeightDecayOptimizer"):
        """Constructs a AdamWeightDecayOptimizer."""
        super(AdamWeightDecayOptimizer, self).__init__(False, name)

        self.learning_rate = learning_rate
        self.weight_decay_rate = weight_decay_rate
        self.beta_1 = beta_1
        self.beta_2 = beta_2
        self.epsilon = epsilon
        self.exclude_from_weight_decay = exclude_from_weight_decay
        self.learning_rate_t = None
        self._beta1_t = None
        self._beta2_t = None
        self._epsilon_t = None
    
    def _get_beta_accumulators(self):
        with ops.init_scope():
            if context.executing_eagerly():
                graph = None
            else:
                graph = ops.get_default_graph()
            return (self._get_non_slot_variable("beta1_power", graph=graph),
                    self._get_non_slot_variable("beta2_power", graph=graph))


    def _prepare(self):
        self.learning_rate_t = ops.convert_to_tensor(
            self.learning_rate, name='learning_rate')
        self.weight_decay_rate_t = ops.convert_to_tensor(
            self.weight_decay_rate, name='weight_decay_rate')
        self.beta_1_t = ops.convert_to_tensor(self.beta_1, name='beta_1')
        self.beta_2_t = ops.convert_to_tensor(self.beta_2, name='beta_2')
        self.epsilon_t = ops.convert_to_tensor(self.epsilon, name='epsilon')

    def _create_slots(self, var_list):
        first_var = min(var_list, key=lambda x: x.name)
        self._create_non_slot_variable(initial_value=self.beta_1,
                                    name="beta1_power",
                                    colocate_with=first_var)
        self._create_non_slot_variable(initial_value=self.beta_2,
                                    name="beta2_power",
                                    colocate_with=first_var)
        for v in var_list:
            self._zeros_slot(v, 'm', self._name)
            self._zeros_slot(v, 'v', self._name)

    def _apply_dense(self, grad, var):
        learning_rate_t = math_ops.cast(
            self.learning_rate_t, var.dtype.base_dtype)
        beta_1_t = math_ops.cast(self.beta_1_t, var.dtype.base_dtype)
        beta_2_t = math_ops.cast(self.beta_2_t, var.dtype.base_dtype)
        epsilon_t = math_ops.cast(self.epsilon_t, var.dtype.base_dtype)
        weight_decay_rate_t = math_ops.cast(
            self.weight_decay_rate_t, var.dtype.base_dtype)

        m = self.get_slot(var, 'm')
        v = self.get_slot(var, 'v')
        beta1_power, beta2_power = self._get_beta_accumulators()
        beta1_power = math_ops.cast(beta1_power, var.dtype.base_dtype)
        beta2_power = math_ops.cast(beta2_power, var.dtype.base_dtype)
        learning_rate_t = math_ops.cast(self.learning_rate_t, var.dtype.base_dtype)
        learning_rate_t = (learning_rate_t * math_ops.sqrt(1 - beta2_power) / (1 - beta1_power))
    
        # Standard Adam update.
        next_m = (
            tf.multiply(beta_1_t, m) +
            tf.multiply(1.0 - beta_1_t, grad))
        next_v = (
            tf.multiply(beta_2_t, v) + tf.multiply(1.0 - beta_2_t,
                                                   tf.square(grad)))

        update = next_m / (tf.sqrt(next_v) + epsilon_t)

        if self._do_use_weight_decay(var.name):
            update += weight_decay_rate_t * var

        update_with_lr = learning_rate_t * update

        next_param = var - update_with_lr

        return control_flow_ops.group(*[var.assign(next_param),
                                        m.assign(next_m),
                                        v.assign(next_v)])

    def _resource_apply_dense(self, grad, var):
        learning_rate_t = math_ops.cast(
            self.learning_rate_t, var.dtype.base_dtype)
        beta_1_t = math_ops.cast(self.beta_1_t, var.dtype.base_dtype)
        beta_2_t = math_ops.cast(self.beta_2_t, var.dtype.base_dtype)
        epsilon_t = math_ops.cast(self.epsilon_t, var.dtype.base_dtype)
        weight_decay_rate_t = math_ops.cast(
            self.weight_decay_rate_t, var.dtype.base_dtype)

        m = self.get_slot(var, 'm')
        v = self.get_slot(var, 'v')
        beta1_power, beta2_power = self._get_beta_accumulators()
        beta1_power = math_ops.cast(beta1_power, var.dtype.base_dtype)
        beta2_power = math_ops.cast(beta2_power, var.dtype.base_dtype)
        learning_rate_t = math_ops.cast(self.learning_rate_t, var.dtype.base_dtype)
        learning_rate_t = (learning_rate_t * math_ops.sqrt(1 - beta2_power) / (1 - beta1_power))
    
        # Standard Adam update.
        next_m = (
            tf.multiply(beta_1_t, m) +
            tf.multiply(1.0 - beta_1_t, grad))
        next_v = (
            tf.multiply(beta_2_t, v) + tf.multiply(1.0 - beta_2_t,
                                                   tf.square(grad)))

        update = next_m / (tf.sqrt(next_v) + epsilon_t)

        if self._do_use_weight_decay(var.name):
            update += weight_decay_rate_t * var

        update_with_lr = learning_rate_t * update

        next_param = var - update_with_lr

        return control_flow_ops.group(*[var.assign(next_param),
                                        m.assign(next_m),
                                        v.assign(next_v)])

    def _apply_sparse_shared(self, grad, var, indices, scatter_add):
        learning_rate_t = math_ops.cast(
            self.learning_rate_t, var.dtype.base_dtype)
        beta_1_t = math_ops.cast(self.beta_1_t, var.dtype.base_dtype)
        beta_2_t = math_ops.cast(self.beta_2_t, var.dtype.base_dtype)
        epsilon_t = math_ops.cast(self.epsilon_t, var.dtype.base_dtype)
        weight_decay_rate_t = math_ops.cast(
            self.weight_decay_rate_t, var.dtype.base_dtype)

        m = self.get_slot(var, 'm')
        v = self.get_slot(var, 'v')
        beta1_power, beta2_power = self._get_beta_accumulators()
        beta1_power = math_ops.cast(beta1_power, var.dtype.base_dtype)
        beta2_power = math_ops.cast(beta2_power, var.dtype.base_dtype)
        learning_rate_t = math_ops.cast(self.learning_rate_t, var.dtype.base_dtype)
        learning_rate_t = (learning_rate_t * math_ops.sqrt(1 - beta2_power) / (1 - beta1_power))
    
        m_t = state_ops.assign(m, m * beta_1_t,
                               use_locking=self._use_locking)

        m_scaled_g_values = grad * (1 - beta_1_t)
        with ops.control_dependencies([m_t]):
            m_t = scatter_add(m, indices, m_scaled_g_values)

        v_scaled_g_values = (grad * grad) * (1 - beta_2_t)
        v_t = state_ops.assign(v, v * beta_2_t, use_locking=self._use_locking)
        with ops.control_dependencies([v_t]):
            v_t = scatter_add(v, indices, v_scaled_g_values)

        update = m_t / (math_ops.sqrt(v_t) + epsilon_t)

        if self._do_use_weight_decay(var.name):
            update += weight_decay_rate_t * var

        update_with_lr = learning_rate_t * update

        var_update = state_ops.assign_sub(var,
                                          update_with_lr,
                                          use_locking=self._use_locking)
        return control_flow_ops.group(*[var_update, m_t, v_t])

    def _apply_sparse(self, grad, var):
        return self._apply_sparse_shared(
            grad.values, var, grad.indices,
            lambda x, i, v: state_ops.scatter_add(  # pylint: disable=g-long-lambda
                x, i, v, use_locking=self._use_locking))

    def _resource_scatter_add(self, x, i, v):
        with ops.control_dependencies(
                [resource_variable_ops.resource_scatter_add(
                    x.handle, i, v)]):
            return x.value()

    def _resource_apply_sparse(self, grad, var, indices):
        return self._apply_sparse_shared(
            grad, var, indices, self._resource_scatter_add)

    def _do_use_weight_decay(self, param_name):
        """Whether to use L2 weight decay for `param_name`."""
        if not self.weight_decay_rate:
            return False
        if self.exclude_from_weight_decay:
            for r in self.exclude_from_weight_decay:
                if re.search(r, param_name) is not None:
                    return False
        return True
    def _finish(self, update_ops, name_scope):
        # Update the power accumulators.
        with ops.control_dependencies(update_ops):
            beta1_power, beta2_power = self._get_beta_accumulators()
            with ops.colocate_with(beta1_power):
                update_beta1 = beta1_power.assign(
                    beta1_power * self.beta_1_t, use_locking=self._use_locking)
                update_beta2 = beta2_power.assign(
                    beta2_power * self.beta_2_t, use_locking=self._use_locking)
            return control_flow_ops.group(*update_ops + [update_beta1, update_beta2],
                                        name=name_scope)

主要關注_get_beta_accumulators,_finish,以及在各個_apply_方法中進行偏移修正的計算邏輯:

beta1_power, beta2_power = self._get_beta_accumulators()
beta1_power = math_ops.cast(beta1_power, var.dtype.base_dtype)
beta2_power = math_ops.cast(beta2_power, var.dtype.base_dtype)
learning_rate_t = math_ops.cast(self.learning_rate_t, var.dtype.base_dtype)
learning_rate_t = (learning_rate_t * math_ops.sqrt(1 - beta2_power) / (1 - beta1_power))

最後,根據實驗結果,驗證了上述結論的有效性。通過使用誤差修正,模型訓練效率顯著提升,只用了一半的訓練步數就達到了未用誤差修正的訓練loss,相當於加快了收斂的速度。最終模型的精度也有小幅的提升。

這裏提一下蘇神的bert4keras框架中,很早就注意到了誤差修正這個問題,增加了誤差修正的選項和步驟,有興趣的同學可以去研究一下該框架。建議對adam理解不深入的同學閱讀蘇神的代碼,很簡潔易懂,而tf中的源碼爲了優化圖計算寫了很多複雜代碼。

Weight Re-initializing

論文提到的第二個優化點是權重再初始化。我們使用bert做finetune時,通常會使用bert的預訓練權重去初始化下游任務中的模型參數,這樣做是爲了充分利用bert在預訓練過程中學習到的語言知識,將其能夠遷移到下游任務的學習當中。衆所周知,bert主要由很多transformer層堆疊構成,那麼問題來了,是否所有的transformer層都對下游任務有幫助呢?

之前有一些論文專門討論了bert中不同層的權重分別學習到了哪些信息,大致思想是靠近底部的層(靠近input)學到的是比較通用的語義方面的信息,比如詞性、詞法等語言學知識,而靠近頂部的層會傾向於學習到接近下游任務的知識,對於預訓練來說就是類似masked word prediction、next sentence prediction任務的相關知識。當使用bert預訓練模型finetune其他下游任務(比如序列標註)時,如果下游任務與預訓練任務差異較大,那麼bert頂層的權重所擁有的知識反而會拖累整體的finetune進程,使得模型在finetune初期產生訓練不穩定的問題。

因此,我們可以在finetune時,只保留接近底部的bert權重,對於靠近頂部的層的權重,可以重新隨機初始化,從頭開始學習。論文做了如下實驗來驗證上述結論:重新初始化bert的pooler層(文本分類會用到),同時嘗試重新初始化bert的top-L層權重,  。該超參數可以使用交叉驗證法來調整。具體步驟和實驗結果如圖所示:

根據上述實驗結果,在四個數據集上,模型通過重新初始化部分權重,在精度上都有不同程度的提升。另外,作者還做了一個實驗驗證到底該對多少層的權重進行重新初始化。實驗結果表明這個並沒有顯著規律,實際上初始化層數與具體的任務和數據集相關的,需要通過調參來決定。但是有一點是可以肯定的,對於需要用到pooler層的分類任務,對pooler層進行重新初始化肯定能對模型的訓練有一定的幫助。

再次驗證

同樣的,我也在ccks的實體識別比賽中驗證了上述的想法。通過固定其他參數(包括不使用偏移修正的Adam),我對bert的前6層進行了重新初始化,具體代碼實現只需要在modeling.py中的get_assignment_map_from_checkpoint方法中,將需要重新初始化的權重層參數從assignment_map中過濾掉就可以了,具體如下:

def get_assignment_map_from_checkpoint(tvars, init_checkpoint):
    """Compute the union of the current variables and checkpoint variables."""
    assignment_map = {}
    initialized_variable_names = {}
    name_to_variable = collections.OrderedDict()
    for var in tvars:
        name = var.name
        m = re.match("^(.*):\\d+$", name)
        if m is not None:
            name = m.group(1)
        name_to_variable[name] = var

    init_vars = tf.train.list_variables(init_checkpoint)

    assignment_map = collections.OrderedDict()
    filtered_layer_names = [......] //這裏放需要重新初始化的權重參數名稱就可以了
    for x in init_vars:
        (name, var) = (x[0], x[1])
        if name not in name_to_variable:
            continue
        if name not in filtered_layer_names:
            assignment_map[name] = name_to_variable[name]
        initialized_variable_names[name] = 1
        initialized_variable_names[name + ":0"] = 1

    return (assignment_map, initialized_variable_names)

通過實驗,驗證了上述的結論。將bert頂部的6層權重重新初始化後,模型的訓練效率有了較大提升,收斂速度加快了30-40%,然而最後模型的精度似乎沒有太大的變化,應該還是需要根據驗證集來調整最合適的重新初始化層數,才能達到精度的提升。

用更長的步數來finetune

這塊優化內容我感覺似乎沒有太大的亮點。作者的意思是通過增加訓練步數能夠提升finetune的效果。但是一般我都是用early-stopping機制來控制訓練的步數,因此感覺這塊內容幫助不大,這裏我就不過多介紹了。

更多對比實驗

論文在最後還做了一組對比實驗,他將目前幾個比較經典的解決訓練震盪的方法列了出來,具體如下:

1、Pre-trained Weight Decay,傳統的weight decay中,權重參數會減去一個正則項  。而pre-trained weight decay則是在finetune時,將預訓練時的權重 引入到weight decay計算中 ,最終正則項爲  。通過這種方式,能夠使得模型的訓練變得更穩定。

2、Mixout。在finetune時,每個訓練iter都會設定給一個概率p,模型會根據這個p將模型參數隨機替換成預訓練的權重參數。這個方法主要是爲了減緩災難性遺忘,讓模型不至於在finetune任務時忘記預訓練時學習到的知識。

3、Layerwise Learning Rate Decay。這個方法我也經常會去嘗試,即對於不同的層數,會使用不同的學習率。因爲靠近底部的層學習到的是比較通用的知識,所以在finetune時並不需要它過多的去更新參數,相反靠近頂部的層由於偏向學習下游任務的相關知識,因此需要更多得被更新。

4、Transferring via an Intermediate Task。即在finetune一個小樣本數據集任務時,先在一個較大的過渡任務上進行finetune。

作者將上述四個方法與本論文中的幾個優化點做了對比實驗,最後發現相對於只使用偏移修正的Adam優化算法,Pre-trained Weight Decay、Mixout、Layerwise Learning Rate Decay並沒有顯著的優勢。當結合了偏移修正和權重重新初始化之後,上述三個方法的效果是明顯有差距的。而對於Transferring via an Intermediate Task,雖然它的效果很好,但是它需要額外的標註數據,成本比較高。而且我自己也做了一些驗證測試,我使用了MSRA的中文NER數據集先做了finetune,然後再用其權重參數嘗試了ccks的NER任務,結果並沒有得到明顯的提升,個人認爲這個過度任務可能需要與目標任務的領域有一定的相關性,不然還需要做領域遷移的工作。

小結

本文主要解讀了論文Revisiting Few-sample BERT Fine-tuning。通過深入研究bert在finetune小樣本數據集時遇到的訓練不穩定問題,提出了幾個優化方法,包括使用帶偏移修正的adam優化方法、重新初始化部分權重參數等。作者做了詳盡的實驗來驗證上述方法,同時本人也在一個小樣本任務上做了簡單的二次驗證,最終證明上述方法是有效的。由於上述方法操作非常簡便,對原始的代碼改動很少,因此非常適合應用於實際的項目中。

添加個人微信,備註:暱稱-學校(公司)-方向即可獲得

1. 快速學習深度學習五件套資料

2. 進入高手如雲DL&NLP交流羣

記得備註呦

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章