文本分類模型第二彈:HAN(Hierarchy Attention Network)

一、前言

本文是文本分類的第二篇,來介紹一下微軟在2016年發表的論文《Hierarchical Attention Networks for Document Classification》中提出的文本分類模型 HAN(Hierarchy Attention Network)。同時也附上基於 Keras 的模型實現,代碼解讀,以及通過實驗來測試 HAN 的性能。

這裏是文本分類系列:

文本分類模型第一彈:關於Fasttext,看這一篇就夠了

文本分類模型第二彈:HAN(Hierarchy Attention Network)

文本分類模型第三彈:BoW(Bag of Words) + TF-IDF + LightGBM


二、相關論文

說到模型結構和原理,我們還是先來讀讀原論文吧:

(1)Document Modeling with Gated Recurrent Neural Network for Sentiment Classification

(2)Hierarchical Attention Networks for Document Classification

說到 HAN,不可不讀的論文有兩篇。首先第一篇論文《Document Modeling with Gated Recurrent Neural Network for Sentiment Classification》是哈工大在2015年發表的,而第二篇論文《Hierarchical Attention Networks for Document Classification》則是在第一篇的基礎之上,加入了 Attention 機制,因此這裏就依次對兩篇論文進行解讀。

1、Document Modeling with Gated Recurrent Neural Network for Sentiment Classification.

在 LDA 主題模型的思想中,一篇文章首先是由單詞組成了主題,再由不同的主題來組成文章。而這篇論文的思想也非常相似,認爲一篇文章首先由單詞組成句子,再由句子組成文章。如此一來我們要想對一篇文章進行分類,就需要分兩步來進行,首先從單詞層面分析每個句子的語義。總結出每個句子的語義後,再將句子綜合起來表徵整篇文章的語義,並對其進行分類。這個思想很好的體現在本篇論文所提出的模型結構上,我們就po出論文中提出的模型來看看:

從下往上來解讀模型,首先每個虛線框中爲一句話所包含的單詞,藍色的 vector 爲每個單詞的詞向量,也就是詞的表示(Word Representation)。通過 CNN/LSTM 來提取一句話中包含所有單詞的語義特徵,形成句子的特徵向量(Sentence Representation)。再經過一個 Bi-Directional Gated Neural Network(LSTM/GRU) 以及一些列的操作,最終生成整個文章的特徵向量(Document Representation)。最後通過Softmax來進行文章分類。

當使用 CNN 作爲句子級別的特徵抽取器時,其結構形如 TextCNN(TextCNN我們下一彈再來說),使用了大小分別爲1\times d2\times d3\times d 的卷積窗口來對句子進行卷積,卷積窗口的寬度設置爲1,2,3, 此舉是考慮到對文本中的 uni-gram,bi-gram,tri-gram 的特徵進行提取。隨後對提取後的特徵分別進行 average pooling,並使用 Tanh 函數進行激活。最後求出得到的三個 vector 的平均值,得到句子級別的特徵向量表示,其整個結構如下圖所示:

論文中並沒有給出使用 LSTM 作爲句子級別特徵抽取器的結構,但其過程應該與文章層面特徵抽取的方式如出一轍,下面來看看文章層面的特徵抽取是如何進行的。

篇章級別的特徵抽取器結構如上圖所示。論文中介紹了兩種方法,一種是使用 GNN(LSTM/GRU)最後一個時刻的隱藏層輸出向量作爲整個文章的特徵表示,另一種則是取 GNN 每一個時刻的隱藏層輸出,並對其求平均,用求平均後的向量作爲整個文章的特徵表示。兩種方法最後都通過 Softmax 來進行最終的分類工作。

最後通過實驗數據,得出了相較於 TextCNN,使用 LSTM 抽取句子級別的語義特徵,搭配使用 Bi-GNN(Bi-LSTM / Bi-GRU)可以得到更好的效果。

2、Hierarchical Attention Networks for Document Classification.

第二篇論文,《Hierarchical Attention Networks for Document Classification》提出我們今天要說的模型 HAN,別看論文的題目對 GNN 隻字不提,但它相較於第一篇論文而言,其實使用了向同的特徵提取結構,區別就在於加入了多層注意力機制(Hierarchical Attention)。

首先我們還是來看看模型的結構吧:

模型的結構就如上圖所示,分爲上下兩個 block,兩個 block 的結構完全一致,都是由 Bi-GNN 組成特徵抽取器,同時添加了注意力機制。下層的 block 做句子級別的特徵抽取,抽取後的特徵作爲上層 block 每一時刻的輸入,再由上層 block 進行篇章級別的特徵抽取,最後還是使用 Softmax 做最後的分類。

GNN 層面,無論是 GRU 還是 LSTM 都已經是老生常談的結構,這裏就不做介紹也不po公式了。Attention 機制作爲整個模型的精髓所在,我們下一節用單獨的一個篇章來說,這裏先來看論文中的實驗環節吧。

這張表是論文使用的數據集,當我看到這張語料統計表時,心裏當時是涼涼了一半的,最小的數據集也包含了 33w 篇文章。在我們實際使用中確實難以獲取到如此大的數據集來讓模型進行發揮,不過後續在我自己的實驗中,發現 HAN 在面對小數據集時也能有不錯的表現,其性能是超過了 Fasttext 和 TextCNN 的。

接下來是實驗結果,表中 HN-AVE 與 HN-MAX 爲將各層的特徵向量生成方式由 Attention 加權求和轉變爲了直接做 Average Pooling 和 Max Pooling。但最終結果表明,Attention 的效果還是要優點於前兩者的,這裏也對比了我們前面提到的第一篇文章,由於在句子級別的特徵抽取上也是用了 Bi-GNN 的結構,所以當使用 HN-AVE 與 HN-MAX 時性能也是超越了前者。

三、Attention

HAN 的模型結構其實比較簡單,上一部分的論文解讀其實已經將模型介紹的很清楚了,這一部分就主要來說一下 HAN 的精髓部分—— Attention 是如何進行計算的。

由於單詞級別 Attention 和句子級別 Attention 的機制完全一樣,我們就只來說說單詞級別的實現原理,接下來還是要放上這張模型結構圖:

每一層的注意力機制對該層中,每一時刻的 Bi-GNN 的隱層輸出進行注意力權重的計算和權重的歸一化。注意力採用了Scaled Dot-Product Attention 的方法來計算,公式如下所示:

首先每一時刻的 GNN 的輸出向量 h_{i} 需要進行一次非線性變換得到 u_{i} 。爲了與每一個單詞和句子的特徵向量區別開來,我們這裏將 u_{s}u_{w} 稱爲 “全局句子特徵向量” 與 “全局單詞特徵向量”,這兩個向量維度與 Bi-GNN 的隱藏層輸出向量相等,在訓練開始前隨機初始化,並在訓練中更新。論文中並沒由明確的描述 u_{i} 與 u_{w} 的意義,只是用 content vector 與 word level context vector 來表示。筆者認爲這兩個向量是語料中單詞級別的特徵與句子級別的特徵的濃縮,與 Doc2Vec 中的 Paragraph Vector 是同樣的思想,有異曲同工之妙。Doc2Vec 見下圖,其中的 Paragraph Vector 也是隨機初始化,並跟隨詞向量一同訓練,最終Paragraph Vector 就代表了整個文章的特徵。

 

四、代碼

這部分主要來介紹一下 HAN 的實現,使用的是 Keras 框架,Backend 爲 TensorFlow-gpu-1.14.0 版本。博客上主要介紹一下模型部分的代碼,完整代碼我會放到 Git 上,供大家參考。

首先是 Attention 的部分:

# Attentnion Layer
from keras.engine.topology import Layer
from keras import initializers as initializers, regularizers, constraints
from keras import backend as K

def dot_product(x, kernel):
    """
    Wrapper for dot product operation, in order to be compatible with both
    Theano and Tensorflow
    Args:
        x (): input
        kernel (): weights
    Returns:
    """
    if K.backend() == 'tensorflow':
        return K.squeeze(K.dot(x, K.expand_dims(kernel)), axis=-1)
    else:
        return K.dot(x, kernel)
class AttentionWithContext(Layer):
    """
    Attention operation, with a context/query vector, for temporal data.
    Supports Masking.
    Follows the work of Yang et al. [https://www.cs.cmu.edu/~diyiy/docs/naacl16.pdf]
    "Hierarchical Attention Networks for Document Classification"
    by using a context vector to assist the attention
    # Input shape
        3D tensor with shape: `(samples, steps, features)`.
    # Output shape
        2D tensor with shape: `(samples, features)`.
    How to use:
    Just put it on top of an RNN Layer (GRU/LSTM/SimpleRNN) with return_sequences=True.
    The dimensions are inferred based on the output shape of the RNN.
    Note: The layer has been tested with Keras 2.0.6
    Example:
        model.add(LSTM(64, return_sequences=True))
        model.add(AttentionWithContext())
        # next add a Dense layer (for classification/regression) or whatever...
    """

    def __init__(self,
                 W_regularizer=None, u_regularizer=None, b_regularizer=None,
                 W_constraint=None, u_constraint=None, b_constraint=None,
                 bias=True, **kwargs):

        self.supports_masking = True
        self.init = initializers.get('glorot_uniform')

        self.W_regularizer = regularizers.get(W_regularizer)
        self.u_regularizer = regularizers.get(u_regularizer)
        self.b_regularizer = regularizers.get(b_regularizer)

        self.W_constraint = constraints.get(W_constraint)
        self.u_constraint = constraints.get(u_constraint)
        self.b_constraint = constraints.get(b_constraint)

        self.bias = bias
        super(AttentionWithContext, self).__init__(**kwargs)

    def build(self, input_shape):
        assert len(input_shape) == 3

        self.W = self.add_weight((input_shape[-1], input_shape[-1],),
                                 initializer=self.init,
                                 name='{}_W'.format(self.name),
                                 regularizer=self.W_regularizer,
                                 constraint=self.W_constraint)
        if self.bias:
            self.b = self.add_weight((input_shape[-1],),
                                     initializer='zero',
                                     name='{}_b'.format(self.name),
                                     regularizer=self.b_regularizer,
                                     constraint=self.b_constraint)

        self.u = self.add_weight((input_shape[-1],),
                                 initializer=self.init,
                                 name='{}_u'.format(self.name),
                                 regularizer=self.u_regularizer,
                                 constraint=self.u_constraint)

        super(AttentionWithContext, self).build(input_shape)

    def compute_mask(self, input, input_mask=None):
        # do not pass the mask to the next layers
        return None

    def call(self, x, mask=None):
        uit = dot_product(x, self.W)

        if self.bias:
            uit += self.b

        uit = K.tanh(uit)
        ait = dot_product(uit, self.u)

        a = K.exp(ait)

        # apply mask after the exp. will be re-normalized next
        if mask is not None:
            # Cast the mask to floatX to avoid float64 upcasting in theano
            a *= K.cast(mask, K.floatx())

        # in some cases especially in the early stages of training the sum may be almost zero
        # and this results in NaN's. A workaround is to add a very small positive number ε to the sum.
        # a /= K.cast(K.sum(a, axis=1, keepdims=True), K.floatx())
        a /= K.cast(K.sum(a, axis=1, keepdims=True) + K.epsilon(), K.floatx())

        a = K.expand_dims(a)
        weighted_input = x * a
        return K.sum(weighted_input, axis=1)

    def compute_output_shape(self, input_shape):
        return input_shape[0], input_shape[-1]

Attention 的部分使用 Keras 中的自定義層實現,在 build 中初始化權重,偏置,以及全局特徵向量 u_{s}u_{w}。call 中主要實現了 Attention 的計算過程,具體的計算方法與論文中的公式一致。

接下來是 HAN 模型的構建:

from keras import Input, Model
from keras.layers import Embedding, Dense, Bidirectional, CuDNNLSTM, TimeDistributed, CuDNNGRU

class HAN(object):
    def __init__(self, maxlen_sentence, maxlen_word, max_features, embedding_dims, embedding_matrix, hidden_size, l2_reg, class_num=10, last_activation='softmax'):
        self.maxlen_sentence = maxlen_sentence
        self.maxlen_word = maxlen_word
        self.max_features = max_features
        self.embedding_dims = embedding_dims
        self.class_num = class_num
        self.last_activation = last_activation
        self.embedding_matrix = embedding_matrix
        self.hidden_size = hidden_size
        self.l2_reg = l2_reg
        

    def get_model(self):
        # Word part
        input_word = Input(shape=(self.maxlen_word,))
        embedder = Embedding(self.max_features + 1, self.embedding_dims, input_length=self.maxlen_word, weights=[self.embedding_matrix], trainable=True)
        embedding_vector = embedder(input_word)
        x_word = Bidirectional(CuDNNGRU(self.hidden_size, return_sequences=True, kernel_regularizer=self.l2_reg))(embedding_vector)  # LSTM or GRU
        x_word = AttentionWithContext()(x_word)
        model_word = Model(input_word, x_word)

        # Sentence part
        input_sen = Input(shape=(self.maxlen_sentence, self.maxlen_word))
        x_sentence = TimeDistributed(model_word)(input_sen)
        x_sentence = Bidirectional(CuDNNGRU(self.hidden_size, return_sequences=True, kernel_regularizer=self.l2_reg))(x_sentence)  # LSTM or GRU
        x_sentence = AttentionWithContext()(x_sentence)

        output = Dense(self.class_num, activation=self.last_activation)(x_sentence)
        model_sentence = Model(inputs=input_sen, outputs=output)
        
        return model_sentence 

模型部分構建了兩個 Block,分別爲 Word Part 和 Sentence Part,每一部分單獨構建爲一個 model,這裏由於我的數據集較少,所以 Bi-GNN 部分採用了參數量較少的 Bi-GRU 來構建。Embedding 層使用了預訓練好的 Word2Vec 詞向量,並在訓練中進行 Fine-tuning

接下來是數據處理的部分:

def cut_doc_2_sentences(doc, sentence_flags=None, skip_limit=8, long_cut_limit=130,
                        all_flags=['.', '!', '?', '~', '。', '!', '?', '~', '\n', ' '],
                        strip_flags=None):
    if strip_flags is None:
        strip_flags = [' ']
    if sentence_flags is None:
        sentence_flags = all_flags
    last_flag = 0
    sentence_list = []
    doc_length = len(doc)
    for i in range(doc_length):
        cut_flags = sentence_flags
        if i + 1 - last_flag > long_cut_limit:
            cut_flags = all_flags
        if (i <= doc_length - 2 and doc[i] in cut_flags and doc[i + 1] not in cut_flags) or i == doc_length - 1:
            temp = doc[last_flag:i + 1]
            chars_no_flags = [char for char in temp if char not in cut_flags]
            if len(chars_no_flags) < skip_limit:
                # 句子內非標點句長小於閥值 skip_limit 的併入下一個分句
                continue
            # 分完句以後去掉前後無用的字符
            for flag in strip_flags:
                temp = temp.strip(flag)
            sentence_list.append(temp)
            last_flag = i + 1
    return sentence_list


def cut_docs(docs):
    start_time = time.time()
    print('start 分句...')
    docs_sentence_list = [cut_doc_2_sentences(doc) for doc in docs]
    print('end 分句,Total docs = {},Cost time = {}'.format(len(docs), time.time() - start_time))
    start_time = time.time()
    print('start 分詞...')
    docs_cut = [[data_clean(sentence) for sentence in sentence_list] for sentence_list in docs_sentence_list]
    print('end 分詞, Cost time = {}'.format(time.time() - start_time))
    return docs_cut


# 根據訓練集生成 vocabulary,返回 fit 後的 tokenizer
def build_vocabulary_tokenizer(docs_cut):
    vocabulary = []
    for doc_sentence_list in docs_cut:
        for sentence_list in doc_sentence_list:
            for word in sentence_list:
                vocabulary.append(word)
    tokenizer = keras.preprocessing.text.Tokenizer()
    tokenizer.fit_on_texts([vocabulary])
    return tokenizer


# 根據fit後的tokenizer,將分詞分句後的doc中的詞替換成index
def index_docs_func(tokenizer, docs_cut):
    index_docs = []
    for doc_sentence_list in docs_cut:
        index_docs.append(tokenizer.texts_to_sequences(doc_sentence_list))
    return index_docs


def pad_docs(index_docs, doc_max_sentence_num, sentence_max_word_num, padding_value=0):
    data = []
    for doc in index_docs:
        doc_data = []
        for sentence in doc:
            # 句子 word 數補齊成 sentence_max_word_num
            if len(sentence) < sentence_max_word_num:
                sentence.extend([padding_value] * (sentence_max_word_num - len(sentence)))
            doc_data.append(sentence[:sentence_max_word_num])
        # 每篇文章句子數補夠 doc_max_sentence_num
        if len(doc_data) < doc_max_sentence_num:
            doc_data.extend([[padding_value] * sentence_max_word_num] * (doc_max_sentence_num - len(doc_data)))
        data.append(doc_data[:doc_max_sentence_num])
    data = np.array(data)
    return data


def dump_data(data, file):
    with open(file, 'wb') as f:
        pickle.dump(data, f)


def load_data(file):
    with open(file, 'rb') as f:
        data = pickle.load(f)
    return data

def pre_process_train_docs(docs, doc_max_sentence_num, sentence_max_word_num):
    docs_cut = cut_docs(docs)  # 分詞分句
    start_time = time.time()
    print('start build_vocabulary_tokenizer...')
    tokenizer = build_vocabulary_tokenizer(docs_cut)
    print('end build_vocabulary_tokenizer, Cost time = {}'.format(time.time() - start_time))
    index_docs = index_docs_func(tokenizer, docs_cut)
    data = pad_docs(index_docs, doc_max_sentence_num, sentence_max_word_num)
    vocabulary_size = len(tokenizer.word_index.values()) + 1
    return data, vocabulary_size, tokenizer

在數據處理方面, HAN 模型需要提前設置好每一篇文章中的句子數量以及每一個句子中的單詞數量,對超過長度的進行截取,對長度不足的進行 Padding 補齊。我們使用 maxlen_word 和 maxlen_sentence 分別表示 “每個句子中包含單詞的最大數量” 和 “每篇文章中包含句子的最大數量” ,處理完成後傳入模型進行的訓練的數據的 shape 應爲:

[ batch_size, maxlen_sentence, maxlen_word ]。

由於要同時對句子和單詞進行 Padding 的操作,所以 maxlen_word 及 maxlen_sentence 的值的選取對於數據有效信息的保留就顯得至關重要了。

這部分的代碼用來統計數據集中的一些語料指標,如:文章中單詞數量分佈,文章中句子數量分佈,句子中單詞數量分佈。可以根據這些語料的統計指標,合理的選取 maxlen_sentence 和 maxlen_word 的值,從而將 Padding 對文本信息的損失降到最小。

# 標籤分佈
dict_list = {}
for label in data_df["label"]:
    if label in dict_list:dict_list[label] += 1
    else:dict_list[label] = 1
print(dict_list)
# 查看 doc 字數分佈
doc_lens = np.array([len(data_clean(doc)) for doc in all_content_list])
n, bins, patches = plt.hist(x=doc_lens, bins='auto', color='#0504aa', alpha=0.7, rwidth=0.85)
plt.grid(axis='y', alpha=0.75)
doc_lens.mean(), doc_lens.max(), doc_lens.min(), np.median(doc_lens)
# 查看 doc 中句子數量分佈
docs_sentence_list = [cut_doc_2_sentences(doc, ['.', '!', '?', ';', '。', '!', '?', ';'], 10, 80) for doc in all_content_list]
sentence_lens = np.array([len(sentence) for sentence in docs_sentence_list])
n, bins, patches = plt.hist(x=sentence_lens, bins='auto', color='#0504aa', alpha=0.7, rwidth=0.85)
plt.grid(axis='y', alpha=0.75)
np.mean(sentence_lens), np.min(sentence_lens), np.max(sentence_lens), np.median(sentence_lens)
# 查看句子長度分佈
sentence_lens = [[len(seq) for seq in sentence] for sentence in docs_sentence_list]
seq_lens = []
for i in sentence_lens:
    seq_lens.extend(i)
n, bins, patches = plt.hist(x=seq_lens, bins='auto', color='#0504aa',alpha=0.7, rwidth=0.85)
plt.grid(axis='y', alpha=0.75)
np.mean(seq_lens), np.min(seq_lens), np.max(seq_lens), np.median(seq_lens)
# 句子中詞數分佈
sentence_lens = [[len(jieba.lcut(sentence)) for sentence in sentence_list] for sentence_list in docs_sentence_list[:]]
seq_lens = []
for i in sentence_lens:
    seq_lens.extend(i)
n, bins, patches = plt.hist(x=seq_lens, bins='auto', color='#0504aa',alpha=0.7, rwidth=0.85)
plt.grid(axis='y', alpha=0.75)
np.mean(seq_lens), np.min(seq_lens), np.max(seq_lens), np.median(seq_lens)

五、實驗

接下來就通過實驗看看 HAN 模型的性能究竟如何吧。

爲了對比模型性能,我們還是使用了文本分類第一彈中用到的數據集,來對 HAN 與 Fasttext 的性能做一個對比。由於 HAN模型主要針對篇章級別的長文本進行分類,而 IMDb Subjectivity Dataset V1.0 以及 Sentiment140 情感分類數據集都以短文本爲主,所以我們這次主要使用 SougoCS 新聞數據集。

1.Experiment 1

實驗數據:SougoCS 新聞數據集,分別取其中IT類、汽車類、商業類、體育類、娛樂類新聞各 5500 條,訓練集與測試集比例爲 9:1。

模型參數:dim=300;hidden size=64;batch size=128;epoch=10;maxlen_word=30;maxlen_sentence=45;

實驗結果:

HAN 在樣本均衡的 SouguCS 新聞數據集上,平均準確率爲 0.9612,高於 Fasttext 的 0.9408。在各個子類的 F1-score 方面,HAN 均比 Fasttext 高 0.01~0.04。

2.Experiment 2

實驗數據:SougoCS 新聞數據集,分別取其中IT類 5500 條、教育類 4400 條、汽車類 5500 條、商業類 11000 條、體育類 22000 條、娛樂類 11000 條,訓練集與測試集比例爲 9:1。

模型參數:dim=300;hidden size=256;batch size=512;epoch=10;maxlen_word=30;maxlen_sentence=40;

實驗結果:

對於樣本量不均衡的數據集,HAN 模型平均準確率爲0.9583,與 Fasttext 相當,但在各個子類的 F1-score 方面,HAN 模型均要高於 Fasttext。

由於實驗數據集中的語料均爲新聞語料,語義較爲單一,所以在該數據集上無法體現出 HAN 在面對包含複雜語義的長文本語料時的優勢所在。

六、總結

HAN 模型針對篇章文本由單詞組成句子,再由句子組成文章的特點,從句子層面到篇章層面分別建模。模型結構科學合理,並且增加了 Attention 機制,對句子中包含關鍵語義的單詞及文章中包含關鍵語義的句子賦予更高的權重。總的來說,HAN 模型代表了以 GNN 結構作爲特徵抽取器的文本分類模型的性能巔峯。此後的2017年, Transformer 隆重登場,Multi-Head self-Attention 開始大行其道,曾經風光無限的 GNN 結構也就從此風光不再。

對於 HAN 的使用,本人覺得還是要根據語料的數量以及類型來進行模型的選擇。HAN 中的 GNN 結構無法並行計算,處理訓練數據耗時,並且難以訓練,batch size learning rate 設置的稍有不慎就會發生梯度爆炸。而 TextCNN 和 Fasttext 訓練難度低,訓練速度快,所以在面對語義較爲單一的短文本分類時,TextCNN 和 Fasttext 這類通過提取關鍵詞信息作爲主要特徵的模型顯然是更好的選擇。但當面對語義較爲複雜的長文本分類、篇章級分類、複雜情感分類時,HAN 則能通過分析關鍵詞的上下語境,運用 Attention 機制爲不同語境下的關鍵詞賦予不同的權重,從而更好的完成分類任務。

七、參考文獻及資源鏈接

1.參考文獻

1.https://www.aclweb.org/anthology/D15-1167

2.https://www.microsoft.com/en-us/research/uploads/prod/2017/06/Hierarchical-Attention-Networks-for-Document-Classification.pdf

3.https://blog.csdn.net/liuchonge/article/details/73610734

2.數據集

  1. SougoCS:https://download.csdn.net/download/zjrn1027/11463182

 

如有錯誤遺漏歡迎交流指正,轉載請註明出處。

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