無所不能的Embedding5 - skip-thought的兄弟們[Trim/CNN-LSTM/quick-thought]

風雨中的小七發表於2020-12-07

這一章我們來聊聊skip-thought的三兄弟,它們在解決skip-thought遺留問題上做出了不同的嘗試【Ref1~4】, 以下paper可能沒有給出最優的解決方案(對不同的NLP任務其實沒有最優只有最合適)但它們提供了另一種思路和可能性。上一章的skip-thought有以下幾個值得進一步探討的點

  • Q1 RNN計算效率低:Encoder-Decoder都是用的RNN, RNN這種依賴上一步輸出的計算方式天然和平行計算互斥, 所以訓練那叫一個慢
  • Q2 Decoder:作為最後預測時完全用不到的元件,Decoder在訓練時佔用了大量時間,能否優化?
  • Q3 通用文字向量的樣本構建:skip-thought只預測前/後一個句子合理麼?
  • Q4 兩個decoder的神奇設計有道理麼?
  • Q5 pretrain word embedding考慮一下?
  • Q6 除了hidden_state還有別的提取句子向量的方式麼?

以下按照文章讓人眼前一亮的程度從小到大排序

Trim/Rethink skip-thought

【Ref1/2】是同一個作者的a/b篇對skip-thought模型的一些細節進行調整,並在benchmark裡拿到了和skip-thought不相上下的結果。主要針對以上Q4,Q5,Q6

作者認為兩個decorder的設計沒啥必要,基於中間句子的資訊,前後句子可以用相同的decoder進行reconstruct。這個假設感覺對翻譯類的語言模型不太能接受,不過放在訓練通用文字向量的背景下似乎是可以接受的,因為我們希望encoder部分能儘可能提取最大資訊並能夠在任意上下文語境中泛化,所以簡化Decoder更合適。

作者對比了用Glove,word2vec來初始化詞向量,結果顯示在Evaluation上會比隨機初始化表現更好。感覺用預訓練詞向量初始化的好處有兩個,一個是加速收斂,另一個是在做vocabulary expansion時,linear-mapping可能會更準確,用預訓練詞向量來初始化已經是比較通用的解決方案了。

針對Q6,原始的skip-thought最終輸出的文字向量就是Encoder最後一個hidden_state,那我們有沒有可能去利用到整個sequence的hiddden state輸出呢? 作者提出可以借鑑avg+max pooling, 對Encoder部分所有hidden state做avg, max pooling然後進行拼接作為 輸出的文字向量=\([\frac{\sum_{i=1}^T h_i}{T} , max_{i=1}^T h_i]\)。這種方案的假設其實不是把embedding作為一個整體來看,而是把embedding的每一個unit單獨作為一個/類特徵來看,序列不同位置的output state可能提取了不同的資訊,通過avg/max來抽取最有代表性的特徵作為句子特徵。這個問題我們之後還會多次碰到,語言模型訓練好了拿什麼做句子向量更合適呢?這裡留個伏筆吧

所以感覺自己實現的其實是Trimed skip-thought, 我用的word2vec來初始化,只用了1個decoder來訓練pair樣本。。。感興趣的望過來 Github-Embedding-skip_thought

Trim算是對skip-thought進行了瘦身,想要提速?看下面?

CNN-LSTM

【Ref3】對Q1給出的解決方案是用CNN來替代RNN作為提取句子資訊的Encoder, 這樣就可以解決RNN計算無法並行的問題。具體實現就需要解決兩個問題:

  • 如何把不定長的sequence壓縮到相同長度
  • CNN如何抽取序列特徵

無所不能的Embedding5 - skip-thought的兄弟們[Trim/CNN-LSTM/quick-thought]

模型結構如上,這裡sequence的token經過embedding之後作為輸入, 假定sequence的padding length相同都是N,embedding的維度都是K, 輸入就是N * K。按1維影像來理解,這裡N是影像長度,K是影像channel。

作者定義了3種不同kernel_size=3/4/5的cnn cell,其實和n-gram的原理近似就是分別學習區域性window_size=3/4/5的三種序列資訊,因為cnn是共享引數的所以1個filter只能提取1種token組合的序列特徵,所以每個cnn cell都有800個filter。以kernel_size=3為例,cnn的權重向量維度是3K800, 和sequence embedding 進行計算後的輸出是(N-3+1)* 800。

為了壓縮到相同長度,在以上輸出後加入了max_pooling層(多數cnn用於NLP的任中max據說都比avg要好),沿sequence維度進行pooling把以上輸出壓縮到1* 800,簡單理解就是每個filter在該sequence上只保留最顯著的1個特徵。3個不同kernel_size的輸出拼接就得到了hidden_size=2400的向量。這也是最終得到的文字對應的向量表達。

考慮只有encoder差別比較大,索性把CNN-LSTM和上一章的skip-thought放一塊了,只對encoder/decoder的cell選擇做了區分。這裡只給出CNN Encodere的實現,bridge的部分是參考了google的seq2seq,完整程式碼看這裡Github-Embedding-skip_thought

def cnn_encoder(input_emb, input_len, params):
    # batch_szie * seq_len * emb_size -> batch_size * (seq_len-kernel_size + 1) * filters
    outputs = []
    params = params['encoder_cell_params']
    for i in range(len(params['filters'])):
        output = tf.layers.conv1d(inputs = input_emb,
                                  filters = params['filters'][i],
                                  kernel_size = params['kernel_size'][i], # window size, simlar as n-gram
                                  strides = params['strides'][i],
                                  padding = params['padding'][i]
                                )
        output = params['activation'][i](output)
        # batch_size * (seq_len-kernel_size + 1) * filters -> batch_size * filters
        outputs.append(tf.reduce_max(output, axis=1))
    # batch_size * sum(filters)
    output = tf.concat(outputs, axis=1)
    return ENCODER_OUTPUT(output=output, state=(output,))

感覺這裡壓縮到相同長度也可以用Padding,以及cnn學習不同長度的文字資訊,作者用的是不同kernel size做拼接,也可以嘗試stack cnn,這樣兩個kernel=3的cnn就能學到長度為9的文字序列資訊。

Decoder這裡作者使用了LSTM,不過就像之前在skip-thought中提到的,因為有teacher forcing感覺decoder並不十分重要這裡就不提了。

論文還有一個比較有意思的點就Q3,作者對skip-thought的核心假設發出了靈魂提問:為啥中間句子的資訊=用於reconstruct前後句子的資訊? (其實上面Trim的論文中中也做了類似的嘗試這裡和在一起說)

作者給出了幾個方案

  • 中間句子reconstruct中間句子的autoencoder任務
  • 中間句子reconstruct中間句子,以及前/後1個句子的composite任務
  • 放大時間視窗,用中間句子預測之後好幾個句子的hierarchical任務

感覺autoencoder更多捕捉intra-sentence的syntax資訊,比如語法/句式結構,而前後句子的reconstruct任務學習inter-sentence的semantic資訊,例如上下文語境。所以是不是也可以理解為,autoencoder訓練得到文字向量的相似可能會長得相似,而前後句子訓練得到的文字向量的相似會更多存在語義/上下文語境的相似。

拋去直覺唯指標論的話,在Trim論文里加入AE的模型只在question-type的分類任務(more syntax)上有提升,對其他例如movie-review等semantic classification任務都有損失。但在CNN的論文裡只用AE/加入AE的模型在所有分類任務上表現都更好,我也是有些迷惑。。。

那究竟什麼訓練樣本可以訓練得到通用的文字向量?這裡的通用是指在任意downstream任務裡都能拿到不錯的效果。這裡留個疑問吧,看後面USE等基於多工聯合學習的嘗試能不能解答這個問題~

Quick-thought

【Ref4】終於跳出了翻譯類語言模型的框框,對Q2給出了新的解決方案。既然對於文字向量表達來說Decoder又慢又沒用,那我們索性不要了,直接把reconstruct任務替換為分類任務。之後這個思路也在BERT預訓練中作為NSP訓練任務直接使用。

這裡分類任務的思路和word2vec中使用的negative sampling來訓練詞向量可以說是同樣的配方熟悉的味道, 都涉及到正負樣本的構建,對於word2vec的skip-gram來說正樣本就是window_size內的單詞,負樣本從詞典中隨機取樣得到。這裡Quick-thought和skip-thought保持一致,正樣本是window_size內的句子,也就是用中間句子來預測前後句子,負樣本則是batch裡面除了前後句子之外的其他句子。

既然提到正負樣本,那skip-thought的正負樣本是什麼呢? 考慮到teacher-forcing的使用,skip-thought是基於中間句子和前後句子T-1的單詞來預測第T個單詞是什麼,負樣本就是除了第T個單詞外vocabulary裡面的其他單詞(和skip-gram一毛一樣)。所以作者也在論文中提到這種reconstruct任務可能會學到過於表面的文字資訊而難以學到更general的語義資訊。而分類任務這種只需要上下文句子整體比其他句子更相似的訓練框架不會存在這個問題。

無所不能的Embedding5 - skip-thought的兄弟們[Trim/CNN-LSTM/quick-thought]

模型結構如上,Encoder部分用任意方式提取資訊,可以是skip-thogut裡面使用的gru,也可以用上面的CNN。這裡和skip-gram一樣用兩套獨立引數的encoder分別對input和target來進行資訊提取得到兩個定長的output state。為了保證最大化state學到的文字資訊,分類器這裡採取了最簡單的操作,就是兩個state直接做向量內積,然後內積直接做binary classification。

在預測的時候用兩個encoder分別對輸入句子進行資訊提取,然後把得到的state進行拼接作為模型提取的文字向量

懶得挪地就把quick thought也和skip thought也放在一起了,反正Encoder部分是可以共享的, 完整程式碼看這裡Github-Embedding-skip_thought

class EncoderBase(object):
    def __init__(self, params):
        self.params = params
        self.init()

    def init(self):
        with tf.variable_scope('embedding', reuse=tf.AUTO_REUSE):
            self.embedding = tf.get_variable(dtype = self.params['dtype'],
                                             initializer=tf.constant(self.params['pretrain_embedding']),
                                             name='word_embedding' )

            add_layer_summary(self.embedding.name, self.embedding)

    def general_encoder(self, features):
        encoder = ENCODER_FAMILY[self.params['encoder_type']]

        seq_emb_input = tf.nn.embedding_lookup(self.embedding, features['tokens']) # batch_size * max_len * emb_size

        encoder_output = encoder(seq_emb_input, features['seq_len'], self.params) # batch_size

        return encoder_output

    def vectorize(self, state_list, features):
        with tf.variable_scope('inference'):
            result={}
            # copy through input for checking
            result['input_tokenid']=tf.identity(features['tokens'], name='input_id')
            token_table = tf.get_collection('token_table')[0]
            result['input_token']= tf.identity(token_table.lookup(features['tokens']), name='input_token')

            result['encoder_state'] = tf.concat(state_list, axis = 1, name ='sentence_vector')

        return result
        
        
class QuickThought(EncoderBase):
    def __init__(self, params):
        super(QuickThought, self).__init__(params)

    def build_model(self, features, labels, mode):
        input_encode = self.input_encode(features)

        output_encode = self.output_encode(features, labels, mode)

        sim_score = tf.matmul(input_encode.state[0], output_encode.state[0], transpose_b=True) # [batch, batch] sim score
        add_layer_summary('sim_score', sim_score)

        loss = self.compute_loss(sim_score)

    def input_encode(self, features):
        with tf.variable_scope('input_encoding', reuse=False):
            encoder_output = self.general_encoder(features)

            add_layer_summary('state', encoder_output.state)
            add_layer_summary('output', encoder_output.output)
        return encoder_output

    def output_encode(self, features, labels, mode):
        with tf.variable_scope('output_encoding', reuse=False):
            if mode == tf.estimator.ModeKeys.PREDICT:
                encoder_output = self.general_encoder(features)
            else:
                encoder_output=self.general_encoder(labels)

            add_layer_summary('state', encoder_output.state)
            add_layer_summary('output', encoder_output.output)
        return encoder_output

    def compute_loss(self, sim_score):
        with tf.variable_scope('compute_loss'):
            batch_size = sim_score.get_shape().as_list()[0]
            sim_score = tf.matrix_set_diag(sim_score, np.zeros(batch_size))

            # create targets: set element within diagonal offset to 1
            targets = np.zeros(shape = (batch_size, batch_size))
            offset = self.params['context_size']//2 ## offset of the diagonal
            for i in chain(range(1, 1+offset), range(-offset, -offset+1)):
                diag = np.diagonal(targets, offset = i)
                diag.setflags(write=True)
                diag.fill(1)

            targets = targets/np.sum(targets, axis=1, keepdims = True)

            targets = tf.constant(targets, dtype = self.params['dtype'])

            losses = tf.nn.softmax_cross_entropy_with_logits(labels = targets,
                                                             logits = sim_score)

            losses = tf.reduce_mean(losses)

        return losses

歡迎留言吐槽以及評論喲~

無所不能的embedding系列?
https://github.com/DSXiangLi/Embedding
無所不能的Embedding1 - Word2vec模型詳解&程式碼實現
無所不能的Embedding2 - FastText詞向量&文字分類
無所不能的Embedding3 - word2vec->Doc2vec[PV-DM/PV-DBOW]
無所不能的Embedding4 - Doc2vec第二彈[skip-thought & tf-Seq2Seq原始碼解析]


【REF】

  1. Rethinking Skip-thought: A Neighbourhood based Approach, Tang etc, 2017
  2. Triming and Improving Skip-thought Vectors, Tang etc, 2017
  3. Learning Generic Sentence Representations Using Convolutional Neural Netword, Gan etc, 2017
  4. An Efficient Framework fir learning sentennce representations, Lajanugen etc, 2018
  5. https://zhuanlan.zhihu.com/p/50443871

相關文章