圖神經網絡14-TextGCN:基於圖神經網絡的文本分類

論文題目:Graph Convolutional Networks for Text Classification
論文地址:https://arxiv.org/pdf/1809.05679.pdf
論文代碼:https://github.com/yao8839836/text_gcn
發表時間:AAAI 2019

ps:注意這篇論文作者在2018年已經公開在arxiv,我們再此不討論預訓練模型的事情 ^_^

論文摘要與簡介

文本分類是自然語言處理過程中一個非常重要和經典的問題,在論文和實踐過程中可以說經久不衰的任務。或多或少接觸NLP的同學,應該比較清楚目前文本分類的模型衆多,比如Text-RNN(LSTM),Text-CNN等,但是當時很少有關於將神經網絡用於文本分類的任務中。

本文提出一種將圖卷積網絡模型用於文本分類的模型,主要思路爲基於詞語共現以及文本單詞之間的關係構建語料庫中文本的Graph,然後將GCN學習文本的表示用於文本分類。通過多個基準數據集實驗表明,Text-GCN無需額外的單詞嵌入或者先驗知識就能夠取得由於最新的文本分類方法。另一方面,Text-GCN還能夠學習和預測詞語與文檔的嵌入表示。

論文動機與相關工作

圖形結構在自然語言處理任務的文本數據中有許多有趣的應用,如語義角色標記(Titov2017)、關係分類(Li,Jin和Luo2018)和機器翻譯(Bastings等)。

傳統上,針對文本分類的模型一直側重於單詞嵌入的有效性和用於文檔嵌入的聚合單詞嵌入。這些詞嵌入可以是無監督的預訓練嵌入(例如word2vec或Glove),然後將其輸入分類器中。最近,諸如CNN和RNN的深度學習模型已經成爲有用的文本編碼器。在這兩種情況下,文本表示都是從單詞嵌入中學習的。本文作者建議同時學習單詞和文檔嵌入以進行文本分類。

在本文之前其實也有GCN用於文本分類的研究,但是大部分工作都是將文檔或者詞語看做節點,相比之下,本文在構建語料庫圖時,我們將文檔和單詞視爲節點(因此是異構圖),並且不需要文檔間的關係。本文提出的Text-GCN,獲取給定的文檔和單詞的語料庫,並構造一個圖形,其中文檔和單詞爲節點(有關的詳細構建,我們稍後討論)。利用此構造的圖,`Text-GCN·利用圖卷積網絡來學習更好的節點表示(單詞和文檔的表示)。然後可以將這些更新的表示形式輸入到分類器中。

GCN:Graph Convolutional Networks

我們快速對GCN進行回顧下,原文可以查看Semi-supervised classification with graph convolutional networks

首先我們定義下GCN的輸入Graph。圖G=(V,E),由節點集合V和邊集合E組成。連接節點的邊可以用林局長了表示A \in \mathbb{R}^{{|V|}\times{|V|}}。如果A_{ij}不爲空,那麼表示節點i和節點j之間存在關係,並且權重爲A_{ij}的具體數值。GCN還向節點添加自環,因此鄰接矩陣變爲:
\hat A =A+I
另外,每個節點都可以有一個向量表示,這個向量可以認爲是該節點的特徵。因此我們還有一個矩陣X \in \mathbb{R}^{|V|\times|D|},其中X_{i}是第i^{th}個節點的節點特徵,D_{in}代表特徵向量的大小。對於模型訓練需要一個權重矩陣W_{i} \in \mathbb{R}^{|D_{in}|\times |D_{out}|},其中D_{out}是輸出維度大小。最後我們還需要引入一個節點的度對角矩陣D\in\mathbb{R}^{|V|\times|V|},對角線每個值代表了節點的度大小。

有了以上條件和基礎,我們可以給出GCN層的公式表示了:
X_{t+1}\in \mathbb{R}^{|V|\times D_{out}}=f(D^{-1/2} \hat A D^{-1/2}X_{t}W_{t})

我們一步步解釋下這個公式。其中\tilde{X}=X_{i}W_{t}\in \mathbb{R}^{|V|\times D_{out}},代表了輸入節點的隱層輸出向量表示。另外注意\tilde{A}=D^{-1/2}\hat{A}D^{-1/2}本質上是鄰接矩陣,但是通過節點的度進行了歸一化。

從上面可以看到,GCN本質上是學習了節點鄰居和節點本身的節點表示形式(請記住自循環)。
GCN層允許節點從跳遠的其他節點接收信息。GCN屬於一類圖形神經網絡,稱爲消息傳遞網絡,其中消息(在這種情況下,邊緣權重乘以節點表示形式)在鄰居之間傳遞。我們可以將這些消息傳遞網絡視爲幫助學習節點表示的方法,該節點表示法考慮了其圖結構的附近鄰居。因此,圖的構造方式,即在哪些節點之間形成哪些邊,非常重要。接下來我們討論下圖卷積網絡如何用於文本分類,文本圖如何構造。

Text-GCN:基於圖神經網絡的文本分類

文本Graph的構建

構造“文本”圖的細節如下。首先,節點總數是文檔d數加上不同詞語w的個數。節點特徵矩陣是恆等矩陣X=I每個節點表示都是一個one-hot向量。同樣,鄰接矩陣(文檔和單詞節點之間的邊緣)定義如下

\#W(i,j)是包含單詞i和單詞j的滑動窗口的數量,而\#W(i)是包含單詞i的滑動窗口的數量。\# W是滑動窗口的總數。

文本與詞語之前的關係比較好刻畫,文中直接採用我們常見的Tfidf來構建文檔與詞的邊。對於詞與詞的關係採用PMIPMI(i,j)是兩個單詞節點之間的逐點互信息,用於查看兩個單詞的共現次數。用於計算共現的窗口大小是模型的超參數。在本文中,作者將其設置爲20。直觀地,圖造嘗試將相似的單詞和文檔放置在圖形中彼此靠近的位置。

Text-GCN模型

完成文本Graph構建後,作者只需運行兩層GCN,然後運行softmax函數來預測標籤。公式爲:
Z=softmax((\tilde{A}ReLU(\tilde{A}XW_{0}))W_{1})

對於損失函數,使用交叉熵損失。

論文實驗

作者將他們的模型與 CNN、LSTM 變體和其他基線詞或段落嵌入模型進行了比較。比較是在5個數據集上執行的。


  • 20NG超過18,000個文檔平均分佈在20個類別中
  • Ohsumed共有23種疾病類別的7000多種心血管疾病摘要
  • R52是Reuters-21578的子集,該文件於1987年出現在路透社新聞專欄中。大約10000個文檔,涉及52個類別
  • R8與上述相同,但具有7500個文檔和8個類別
  • MR電影評論數據集,包含10000條評論和兩個類別:正面情緒和負面情緒

實驗結果如下表所示:



從結果可以看出,TextGCN與CNN,LSTM和其他基準相比,每個數據集的效果最佳或接近最佳。這種性能純粹來自上一節中定義的邊緣和邊緣權重,沒有輸入詞向量,可見效果很好~。

GCN網絡學習到的向量表示

爲了獲得對學習的表示的一些見解,作者展示了通過獲取的文檔嵌入的t-SNE可視化 TextGCN。我們可以看到,即使在應用了GCN的一層之後,文檔嵌入也能夠很好地區分自己。



更具體地講,我們還可以使用中的嵌入來查看每個類的前10個單詞的結果 TextGCN。我們可以看到該模型能夠預測每個類別的相關詞。


論文核心代碼

  • Text Graph的構建

https://github.com/yao8839836/text_gcn/blob/master/build_graph.py

'''
Doc word heterogeneous graph
'''

# word co-occurence with context windows
window_size = 20
windows = []

for doc_words in shuffle_doc_words_list:
    words = doc_words.split()
    length = len(words)
    if length <= window_size:
        windows.append(words)
    else:
        # print(length, length - window_size + 1)
        for j in range(length - window_size + 1):
            window = words[j: j + window_size]
            windows.append(window)
            # print(window)


word_window_freq = {}
for window in windows:
    appeared = set()
    for i in range(len(window)):
        if window[i] in appeared:
            continue
        if window[i] in word_window_freq:
            word_window_freq[window[i]] += 1
        else:
            word_window_freq[window[i]] = 1
        appeared.add(window[i])

word_pair_count = {}
for window in windows:
    for i in range(1, len(window)):
        for j in range(0, i):
            word_i = window[i]
            word_i_id = word_id_map[word_i]
            word_j = window[j]
            word_j_id = word_id_map[word_j]
            if word_i_id == word_j_id:
                continue
            word_pair_str = str(word_i_id) + ',' + str(word_j_id)
            if word_pair_str in word_pair_count:
                word_pair_count[word_pair_str] += 1
            else:
                word_pair_count[word_pair_str] = 1
            # two orders
            word_pair_str = str(word_j_id) + ',' + str(word_i_id)
            if word_pair_str in word_pair_count:
                word_pair_count[word_pair_str] += 1
            else:
                word_pair_count[word_pair_str] = 1

row = []
col = []
weight = []

# pmi as weights

num_window = len(windows)

for key in word_pair_count:
    temp = key.split(',')
    i = int(temp[0])
    j = int(temp[1])
    count = word_pair_count[key]
    word_freq_i = word_window_freq[vocab[i]]
    word_freq_j = word_window_freq[vocab[j]]
    pmi = log((1.0 * count / num_window) /
              (1.0 * word_freq_i * word_freq_j/(num_window * num_window)))
    if pmi <= 0:
        continue
    row.append(train_size + i)
    col.append(train_size + j)
    weight.append(pmi)

# word vector cosine similarity as weights

'''
for i in range(vocab_size):
    for j in range(vocab_size):
        if vocab[i] in word_vector_map and vocab[j] in word_vector_map:
            vector_i = np.array(word_vector_map[vocab[i]])
            vector_j = np.array(word_vector_map[vocab[j]])
            similarity = 1.0 - cosine(vector_i, vector_j)
            if similarity > 0.9:
                print(vocab[i], vocab[j], similarity)
                row.append(train_size + i)
                col.append(train_size + j)
                weight.append(similarity)
'''
# doc word frequency
doc_word_freq = {}

for doc_id in range(len(shuffle_doc_words_list)):
    doc_words = shuffle_doc_words_list[doc_id]
    words = doc_words.split()
    for word in words:
        word_id = word_id_map[word]
        doc_word_str = str(doc_id) + ',' + str(word_id)
        if doc_word_str in doc_word_freq:
            doc_word_freq[doc_word_str] += 1
        else:
            doc_word_freq[doc_word_str] = 1

for i in range(len(shuffle_doc_words_list)):
    doc_words = shuffle_doc_words_list[i]
    words = doc_words.split()
    doc_word_set = set()
    for word in words:
        if word in doc_word_set:
            continue
        j = word_id_map[word]
        key = str(i) + ',' + str(j)
        freq = doc_word_freq[key]
        if i < train_size:
            row.append(i)
        else:
            row.append(i + vocab_size)
        col.append(train_size + j)
        idf = log(1.0 * len(shuffle_doc_words_list) /
                  word_doc_freq[vocab[j]])
        weight.append(freq * idf)
        doc_word_set.add(word)

node_size = train_size + vocab_size + test_size
adj = sp.csr_matrix(
    (weight, (row, col)), shape=(node_size, node_size))

  • Text GCN 實現

https://github.com/yao8839836/text_gcn/blob/master/models.py

from layers import *
from metrics import *
import tensorflow as tf

flags = tf.app.flags
FLAGS = flags.FLAGS


class Model(object):
    def __init__(self, **kwargs):
        allowed_kwargs = {'name', 'logging'}
        for kwarg in kwargs.keys():
            assert kwarg in allowed_kwargs, 'Invalid keyword argument: ' + kwarg
        name = kwargs.get('name')
        if not name:
            name = self.__class__.__name__.lower()
        self.name = name

        logging = kwargs.get('logging', False)
        self.logging = logging

        self.vars = {}
        self.placeholders = {}

        self.layers = []
        self.activations = []

        self.inputs = None
        self.outputs = None

        self.loss = 0
        self.accuracy = 0
        self.optimizer = None
        self.opt_op = None

    def _build(self):
        raise NotImplementedError

    def build(self):
        """ Wrapper for _build() """
        with tf.variable_scope(self.name):
            self._build()

        # Build sequential layer model
        self.activations.append(self.inputs)
        for layer in self.layers:
            hidden = layer(self.activations[-1])
            self.activations.append(hidden)
        self.outputs = self.activations[-1]

        # Store model variables for easy access
        variables = tf.get_collection(tf.GraphKeys.GLOBAL_VARIABLES, scope=self.name)
        self.vars = {var.name: var for var in variables}

        # Build metrics
        self._loss()
        self._accuracy()

        self.opt_op = self.optimizer.minimize(self.loss)

    def predict(self):
        pass

    def _loss(self):
        raise NotImplementedError

    def _accuracy(self):
        raise NotImplementedError

    def save(self, sess=None):
        if not sess:
            raise AttributeError("TensorFlow session not provided.")
        saver = tf.train.Saver(self.vars)
        save_path = saver.save(sess, "tmp/%s.ckpt" % self.name)
        print("Model saved in file: %s" % save_path)

    def load(self, sess=None):
        if not sess:
            raise AttributeError("TensorFlow session not provided.")
        saver = tf.train.Saver(self.vars)
        save_path = "tmp/%s.ckpt" % self.name
        saver.restore(sess, save_path)
        print("Model restored from file: %s" % save_path)


class MLP(Model):
    def __init__(self, placeholders, input_dim, **kwargs):
        super(MLP, self).__init__(**kwargs)

        self.inputs = placeholders['features']
        self.input_dim = input_dim
        # self.input_dim = self.inputs.get_shape().as_list()[1]  # To be supported in future Tensorflow versions
        self.output_dim = placeholders['labels'].get_shape().as_list()[1]
        self.placeholders = placeholders

        self.optimizer = tf.train.AdamOptimizer(learning_rate=FLAGS.learning_rate)

        self.build()

    def _loss(self):
        # Weight decay loss
        for var in self.layers[0].vars.values():
            self.loss += FLAGS.weight_decay * tf.nn.l2_loss(var)

        # Cross entropy error
        self.loss += masked_softmax_cross_entropy(self.outputs, self.placeholders['labels'],
                                                  self.placeholders['labels_mask'])

    def _accuracy(self):
        self.accuracy = masked_accuracy(self.outputs, self.placeholders['labels'],
                                        self.placeholders['labels_mask'])

    def _build(self):
        self.layers.append(Dense(input_dim=self.input_dim,
                                 output_dim=FLAGS.hidden1,
                                 placeholders=self.placeholders,
                                 act=tf.nn.relu,
                                 dropout=True,
                                 sparse_inputs=True,
                                 logging=self.logging))

        self.layers.append(Dense(input_dim=FLAGS.hidden1,
                                 output_dim=self.output_dim,
                                 placeholders=self.placeholders,
                                 act=lambda x: x,
                                 dropout=True,
                                 logging=self.logging))

    def predict(self):
        return tf.nn.softmax(self.outputs)


class GCN(Model):
    def __init__(self, placeholders, input_dim, **kwargs):
        super(GCN, self).__init__(**kwargs)

        self.inputs = placeholders['features']
        self.input_dim = input_dim
        # self.input_dim = self.inputs.get_shape().as_list()[1]  # To be supported in future Tensorflow versions
        self.output_dim = placeholders['labels'].get_shape().as_list()[1]
        self.placeholders = placeholders

        self.optimizer = tf.train.AdamOptimizer(learning_rate=FLAGS.learning_rate)

        self.build()

    def _loss(self):
        # Weight decay loss
        for var in self.layers[0].vars.values():
            self.loss += FLAGS.weight_decay * tf.nn.l2_loss(var)

        # Cross entropy error
        self.loss += masked_softmax_cross_entropy(self.outputs, self.placeholders['labels'],
                                                  self.placeholders['labels_mask'])

    def _accuracy(self):
        self.accuracy = masked_accuracy(self.outputs, self.placeholders['labels'],
                                        self.placeholders['labels_mask'])
        self.pred = tf.argmax(self.outputs, 1)
        self.labels = tf.argmax(self.placeholders['labels'], 1)

    def _build(self):

        self.layers.append(GraphConvolution(input_dim=self.input_dim,
                                            output_dim=FLAGS.hidden1,
                                            placeholders=self.placeholders,
                                            act=tf.nn.relu,
                                            dropout=True,
                                            featureless=True,
                                            sparse_inputs=True,
                                            logging=self.logging))

        self.layers.append(GraphConvolution(input_dim=FLAGS.hidden1,
                                            output_dim=self.output_dim,
                                            placeholders=self.placeholders,
                                            act=lambda x: x, #
                                            dropout=True,
                                            logging=self.logging))

    def predict(self):
        return tf.nn.softmax(self.outputs)

論文總結

本文提出了一個簡單的GCN在文本分類應用中的有趣應用,並且確實顯示了令人欣喜的結果。但是該模型確實具有侷限性,因爲它具有傳導性(通常是GCN的侷限性)。在訓練過程中,模型將訓練數據集中的每個單詞和文檔,包括測試集。儘管在訓練過程中沒有對測試集進行任何預測,但是該模型不能應用預測一個全新的文檔。這導致了將來可能的工作,即如何將新文檔合併到已經構建的圖形中。總的來說,我認爲本文顯示了圖神經網絡的強大能力及其在我們可以定義和構建某種有用圖結構的任何領域中的適用性。

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