圖卷積神經網路(GCN)理解與tensorflow2.0程式碼實現

VariableX發表於2020-11-19

圖(Graph),一般用 G = ( V , E ) G=(V,E) G=(V,E) 表示,這裡的 V V V是圖中節點的集合, E E E 為邊的集合,節點的個數用 N N N表示。在一個圖中,有三個比較重要的矩陣:

  1. 特徵矩陣 X X X:維度為 N × D N\times D N×D ,表示圖中有N個節點,每個節點的特徵個數是D。
  2. 鄰居矩陣 A A A:維度為 N × N N\times N N×N ,表示圖中N個節點之間的連線關係。
  3. 度矩陣 D D D:維度為 N × N N\times N N×N ,是一個對角矩陣,即只有對角線上不為零,其他位置元素都是 0 ,表示圖中N個節點與其他節點相連的邊的個數。對於無權圖而言, D i i = ∑ j A i j D_{ii}=\sum_j A_{ij} Dii=jAij

鄰接矩陣與度矩陣例子如下圖所示:

在這裡插入圖片描述

對於影像(Image)資料,我們可以用卷積核來提取特徵,無論卷積核覆蓋在影像的哪個部分,其內部結構都是一樣的,這是因為圖片結構具有平移不變性,如下圖左半部分所示:

在這裡插入圖片描述

但是對於圖(Graph)資料而言,其形狀是不規則的,不具有平移不變性。於是 GCN,也就是圖卷積神經網路,其目標就是設計一種特徵提取器,進而完成節點分類、變預測等任務,還順便可以得到每個節點的 embedding 表示。

上面展示了一個簡單的 3x3 的卷積核,每次自左向右,自上而下掃描圖片(Image)時,都是將 3x3 的畫素進行加權求和,即: ∑ i = 1 9 w i x i \sum_{i=1}^9 w_i x_i i=19wixi,然後將求和的結果作為該 3x3 區域的特徵。

那麼在圖(graph)中要怎麼提取特徵?這裡給出兩種思路。

圖卷積

思路一

CNN加權求和的思想也可以應用到圖(Graph)的特徵提取上,如下圖所示:

在這裡插入圖片描述

對於節點 i i i,我們可以用其鄰接節點加權求和的結果來表示當前節點,這個操作我們稱為“聚合(aggregate)”:
a g g ( X i ) = ∑ j ∈ n e i g h b o r ( i ) A i j X j agg(X_i) = \sum_{j \in neighbor(i)} A_{ij} X_j agg(Xi)=jneighbor(i)AijXj
考慮到與節點 i i i 沒有邊連線的節點 j j j ,對應的權重 A i j A_{ij} Aij 為 0 ,因此上面的公式又可以改寫為:
a g g ( X i ) = ∑ j ∈ N A i j X j agg(X_i) = \sum_{j \in N} A_{ij} X_j agg(Xi)=jNAijXj
那麼,對於所有的節點而言,其聚合的結果可以用下面的公式表示:
a g g ( X ) = A X agg(X) = AX agg(X)=AX
上面的公式只考慮了鄰居加權求和的結果,很多情況下,節點自身的資訊是不可忽略的,因此一般情況下會把自身的特徵也加回來:
a g g ( X i ) = ∑ j ∈ N A i j X j + X i agg(X_i) = \sum_{j \in N} A_{ij} X_j + X_i agg(Xi)=jNAijXj+Xi
於是有:
a g g ( X ) = A X + X = ( A + I ) X agg(X) = AX + X = (A+I)X agg(X)=AX+X=(A+I)X
其中, I I I 是單位矩陣,令:
A ~ = A + I \tilde A = A+I A~=A+I
則有:
a g g ( X ) = A ~ X agg(X) = \tilde AX agg(X)=A~X
也就是說把單位矩陣 I I I 加到鄰接矩陣 A A A 上,即可在聚合操作中加入自身特徵了。

現在有個問題,只能用自身節點以及鄰居節點加權求和的結果來表示某個節點的特徵嗎?其實還有另一種思路。

思路二

在某些情況下,我們更關注節點之間的差值,因此可以對差值進行加權求和:
a g g ( X i ) = ∑ j ∈ N A i j ( X i − X j ) = D i i X i − ∑ j ∈ N A i j X j \begin{aligned} agg(X_i) & = \sum_{j \in N} A_{ij} (X_i - X_j) \\ &= D_{ii}X_i- \sum_{j \in N} A_{ij}X_j \\ \end{aligned} agg(Xi)=jNAij(XiXj)=DiiXijNAijXj
其中,D 表示度矩陣,表示節點與其他節點相連的邊的個數,對於無權圖而言, D i i = ∑ j A i j D_{ii}=\sum_j A_{ij} Dii=jAij

對於整個圖的節點而言,上面的公式可以轉換為矩陣化的表示:
a g g ( X ) = D X − A X = ( D − A ) X \begin{aligned} agg(X) &= DX - AX \\ &= (D-A)X \end{aligned} agg(X)=DXAX=(DA)X

實際上,上面公式中的 D − A D-A DA 是拉普拉斯矩陣(用 L L L 表示):
L = D − A L = D - A L=DA
拉普拉斯矩陣如下圖所示:

在這裡插入圖片描述

如果想更多地瞭解拉普拉斯矩陣在GCN中的作用,可以參考:如何理解 Graph Convolutional Network(GCN)?

歸一化

無論是思路一的 A ~ \tilde A A~ 還是思路二的 L L L,與CNN的卷積相似之處都是區域性資料的聚合操作,只不過CNN 中卷積的區域性連線數是固定的。但是在Graph中每個節點的鄰居個數都可能不同,進行聚合操作後,對於度較大的節點,得到的特徵比較大,度較少的節點得到的特徵就比較小,因此還需要進行歸一化的處理。

歸一化的思路有兩種:

(1)算數平均
L r w = D − 1 L L^{rw}=D^{-1}L Lrw=D1L
(2)幾何平均
L s y m = D − 0.5 L D − 0.5 L^{sym}=D^{-0.5}LD^{-0.5} Lsym=D0.5LD0.5
幾何平均受極端值影響較小,因此是GCN中比較常用的歸一化方法,於是有:
a g g ( X ) = L s y m X = D − 0.5 L D − 0.5 X = D − 0.5 ( D − A ) D − 0.5 X \begin{aligned} agg(X) &= L^{sym} X \\ &= D^{-0.5}LD^{-0.5}X \\ &= D^{-0.5}(D-A)D^{-0.5} X \end{aligned} agg(X)=LsymX=D0.5LD0.5X=D0.5(DA)D0.5X
當然也可以是:
a g g ( X ) = D − 0.5 A ~ D − 0.5 X = D − 0.5 ( A + I ) D − 0.5 X \begin{aligned} agg(X) & = D^{-0.5}\tilde A D^{-0.5} X\\ & = D^{-0.5}(A+I)D^{-0.5} X \end{aligned} agg(X)=D0.5A~D0.5X=D0.5(A+I)D0.5X
在實際的GCN程式碼實現中,會對聚合結果進行一些變換,第 l l l 層到第 l + 1 l+1 l+1 層的傳播方式為:
H ( l + 1 ) = σ ( D ~ − 1 2 A ~ D ~ − 1 2 H ( l ) W ( l ) ) H^{(l+1)}=\sigma\left(\tilde{D}^{-\frac{1}{2}} \tilde{A} \tilde{D}^{-\frac{1}{2}} H^{(l)} W^{(l)}\right) H(l+1)=σ(D~21A~D~21H(l)W(l))
其中:

  • A ~ = A + I \tilde A=A+I A~=A+I ,也可以是 A ~ = D − A \tilde A = D - A A~=DA
  • D ~ \tilde D D~ A ~ \tilde A A~ 的度矩陣,每個元素為: D ~ i i = ∑ j A ~ i j \tilde D_{ii}=\sum_j \tilde A_{ij} D~ii=jA~ij
  • H H H 是每一層的特徵,對於輸入層而言, H H H 就是 X X X
  • σ 是 sigmoid 函式

由於 D 是在矩陣 A 的基礎上得到的,因此在給定矩陣 A 之後, D ~ − 1 2 A ~ D ~ − 1 2 \tilde{D}^{-\frac{1}{2}} \tilde{A} \tilde{D}^{-\frac{1}{2}} D~21A~D~21 就可以事先計算好。

程式碼實現

相關程式碼可以在文末獲取。

Cora 資料集介紹

Cora資料集由機器學習論文組成,是近年來圖深度學習很喜歡使用的資料集。整個資料集有2708篇論文,所有樣本點被分為8個類別,類別分別是1)基於案例;2)遺傳演算法;3)神經網路;4)概率方法;5)強化學習;6)規則學習;7)理論。每篇論文都由一個1433維的詞向量表示,所以,每個樣本點具有1433個特徵。詞向量的每個元素都對應一個詞,且該元素只有0或1兩個取值。取0表示該元素對應的詞不在論文中,取1表示在論文中。

定義圖卷積層

import tensorflow as tf
from tensorflow.keras import activations, regularizers, constraints, initializers

class GCNConv(tf.keras.layers.Layer):
    def __init__(self,
                 units,
                 activation=lambda x: x,
                 use_bias=True,
                 kernel_initializer='glorot_uniform',
                 bias_initializer='zeros',
                 **kwargs):
        super(GCNConv, self).__init__()

        self.units = units
        self.activation = activations.get(activation)
        self.use_bias = use_bias
        self.kernel_initializer = initializers.get(kernel_initializer)
        self.bias_initializer = initializers.get(bias_initializer)


    def build(self, input_shape):
        """ GCN has two inputs : [shape(An), shape(X)]
        """
        fdim = input_shape[1][1]  # feature dim
        # 初始化權重矩陣
        self.weight = self.add_weight(name="weight",
                                      shape=(fdim, self.units),
                                      initializer=self.kernel_initializer,
                                      trainable=True)
        if self.use_bias:
            # 初始化偏置項
            self.bias = self.add_weight(name="bias",
                                        shape=(self.units, ),
                                        initializer=self.bias_initializer,
                                        trainable=True)

    def call(self, inputs):
        """ GCN has two inputs : [An, X]
        """
        self.An = inputs[0]
        self.X = inputs[1]
        # 計算 XW
        if isinstance(self.X, tf.SparseTensor):
            h = tf.sparse.sparse_dense_matmul(self.X, self.weight)
        else:
            h = tf.matmul(self.X, self.weight)
        # 計算 AXW
        output = tf.sparse.sparse_dense_matmul(self.An, h)

        if self.use_bias:
            output = tf.nn.bias_add(output, self.bias)

        if self.activation:
            output = self.activation(output)

        return output

定義 GCN 模型

class GCN():
    def __init__(self, An, X, sizes, **kwargs):
        self.with_relu = True
        self.with_bias = True

        self.lr = FLAGS.learning_rate
        self.dropout = FLAGS.dropout
        self.verbose = FLAGS.verbose
        
        self.An = An
        self.X = X
        self.layer_sizes = sizes
        self.shape = An.shape

        self.An_tf = sp_matrix_to_sp_tensor(self.An)
        self.X_tf = sp_matrix_to_sp_tensor(self.X)

        self.layer1 = GCNConv(self.layer_sizes[0], activation='relu')
        self.layer2 = GCNConv(self.layer_sizes[1])
        self.opt = tf.optimizers.Adam(learning_rate=self.lr)

    def train(self, idx_train, labels_train, idx_val, labels_val):
        K = labels_train.max() + 1
        train_losses = []
        val_losses = []
        # use adam to optimize
        for it in range(FLAGS.epochs):
            tic = time()
            with tf.GradientTape() as tape:
                _loss = self.loss_fn(idx_train, np.eye(K)[labels_train])

            # optimize over weights
            grad_list = tape.gradient(_loss, self.var_list)
            grads_and_vars = zip(grad_list, self.var_list)
            self.opt.apply_gradients(grads_and_vars)

            # evaluate on the training
            train_loss, train_acc = self.evaluate(idx_train, labels_train, training=True)
            train_losses.append(train_loss)
            val_loss, val_acc = self.evaluate(idx_val, labels_val, training=False)
            val_losses.append(val_loss)
            toc = time()
            if self.verbose:
                print("iter:{:03d}".format(it),
                      "train_loss:{:.4f}".format(train_loss),
                      "train_acc:{:.4f}".format(train_acc),
                      "val_loss:{:.4f}".format(val_loss),
                      "val_acc:{:.4f}".format(val_acc),
                      "time:{:.4f}".format(toc - tic))
        return train_losses

    def loss_fn(self, idx, labels, training=True):
        if training:
            # .nnz 是獲得X中元素的個數
            _X = sparse_dropout(self.X_tf, self.dropout, [self.X.nnz])
        else:
            _X = self.X_tf

        self.h1 = self.layer1([self.An_tf, _X])
        if training:
            _h1 = tf.nn.dropout(self.h1, self.dropout)
        else:
            _h1 = self.h1

        self.h2 = self.layer2([self.An_tf, _h1])
        self.var_list = self.layer1.weights + self.layer2.weights
        # calculate the loss base on idx and labels
        _logits = tf.gather(self.h2, idx)
        _loss_per_node = tf.nn.softmax_cross_entropy_with_logits(labels=labels,
                                                                 logits=_logits)
        _loss = tf.reduce_mean(_loss_per_node)
        # 加上 l2 正則化項
        _loss += FLAGS.weight_decay * sum(map(tf.nn.l2_loss, self.layer1.weights))
        return _loss

    def evaluate(self, idx, true_labels, training):
        K = true_labels.max() + 1
        _loss = self.loss_fn(idx, np.eye(K)[true_labels], training=training).numpy()
        _pred_logits = tf.gather(self.h2, idx)
        _pred_labels = tf.argmax(_pred_logits, axis=1).numpy()
        _acc = accuracy_score(_pred_labels, true_labels)
        return _loss, _acc

訓練模型

# 計算標準化的鄰接矩陣:根號D * A * 根號D
def preprocess_graph(adj):
    # _A = A + I
    _adj = adj + sp.eye(adj.shape[0])
    # _dseq:各個節點的度構成的列表
    _dseq = _adj.sum(1).A1
    # 構造開根號的度矩陣
    _D_half = sp.diags(np.power(_dseq, -0.5))
    # 計算標準化的鄰接矩陣, @ 表示矩陣乘法
    adj_normalized = _D_half @ _adj @ _D_half
    return adj_normalized.tocsr()

if __name__ == "__main__":
    # 讀取資料
    # A_mat:鄰接矩陣,以scipy的csr形式儲存
    # X_mat:特徵矩陣,以scipy的csr形式儲存
    # z_vec:label
    # train_idx,val_idx,test_idx: 要使用的節點序號
    A_mat, X_mat, z_vec, train_idx, val_idx, test_idx = load_data_planetoid(FLAGS.dataset)
    # 鄰居矩陣標準化
    An_mat = preprocess_graph(A_mat)

    # 節點的類別個數
    K = z_vec.max() + 1

    # 構造GCN模型
    gcn = GCN(An_mat, X_mat, [FLAGS.hidden1, K])
    # 訓練
    gcn.train(train_idx, z_vec[train_idx], val_idx, z_vec[val_idx])
    # 測試
    test_res = gcn.evaluate(test_idx, z_vec[test_idx], training=False)
    print("Dataset {}".format(FLAGS.dataset),
          "Test loss {:.4f}".format(test_res[0]),
          "test acc {:.4f}".format(test_res[1]))

GCN 小結

本文使用到的程式碼與資料集地址:https://github.com/zxxwin/tf2_gcn

GCN的優點: 可以捕捉graph的全域性資訊,從而很好地表示node的特徵。

GCN的缺點:屬於直推式(transductive)的學習方式,需要把所有節點都參與訓練才能得到node embedding,無法快速得到新node的embedding。如果要得到新節點的表示,很可能就需要重新訓練所有節點的表示。

參考文章:

如何理解 Graph Convolutional Network(GCN)?

2020年,我終於決定入門GCN

GCN(Graph Convolutional Network)的理解

相關文章