PyGCN 源码阅读

PyGCN 源码阅读

代码地址: https://github.com/tkipf/pygcn

GCN 论文 Semi-Supervised Classification with Graph Convolutional Networks

广而告之

可以在微信中搜索 “珍妮的算法之路” 或者 “world4458” 关注我的微信公众号;另外可以看看知乎专栏 PoorMemory-机器学习, 以后文章也会发在知乎专栏中;

cora 数据介绍

该数据集包含有关 Machine Learing 主题的 paper, 主要由 cora.content 以及 cora.sites 两个文件组成, 其中 cora.content 的格式为:

The .content file contains descriptions of the papers in the following format:

​            <paper_id> <word_attributes>+ <class_label>

第一行是 <paper_id>, 总共有 2708 篇 paper

最后一行是该 paper 属于哪个领域, 总共有 7 个领域

从这些 paper 取出 unique words, 总共 1433 个,

中间的 <word_attributes> 长度为 1433, 每个位置上的值为 0/1, 表示 words 之间的连接, 该属性表示每篇 paper 的特征.

cora.sites 的格式为:

The .cites file contains the citation graph of the corpus. Each line describes a link in the following format:

​            <ID of cited paper> <ID of citing paper>

对于 paper_id1 paper_id2, 含义为 paper2 引用了 paper1.

cora 图数据加载

  • 获取节点特征, 并进行归一化
  • 获取 labels, 并进行 one-hot encode
  • 获取邻接矩阵, 对称化, 归一化
  • 转换为 torch Tensor. (尤其是 scipy sparse matrix 转换为 torch sparse tensor)

图数据加载的代码位于 utils.py 中, 其中 load_data 这个函数用于 cora 数据集的加载, 分别读取 cora.content 文件获得节点 id, features 与 labels, 以及读取 cora.sites 来获得边的信息来构建 paper 之间的引用关系图. 获取图的邻接矩阵 adj 代码写的很简洁:

# 读取 cora.sites 文件, 获取 paper_id, 利用 idx_map 将 paper_id 转换为 Graph 中的节点 id.
edges_unordered = np.genfromtxt("{}{}.cites".format(path, dataset),
                                dtype=np.int32)
edges = np.array(list(map(idx_map.get, edges_unordered.flatten())),
                 dtype=np.int32).reshape(edges_unordered.shape)
adj = sp.coo_matrix((np.ones(edges.shape[0]), (edges[:, 0], edges[:, 1])),
                    shape=(labels.shape[0], labels.shape[0]),
                    dtype=np.float32)

其中 edges[:, 0] 表示 paper_id1, edges[:, 1] 表示 paper_id2. 利用 sp.coo_matrix 来构建 “COOrdinate” 类型的稀疏矩阵. 如果两个节点之间有连接, 相应的位于 (edges[:, 0], edges[:, 1]) 处的值就是 1.

邻接矩阵预处理

GCN 论文 Semi-Supervised Classification with Graph Convolutional Networks 中对邻接矩阵 A A A 的处理是, 先加上 self-connections, 之后再用度矩阵 D ~ \tilde{D} D~ A ~ \tilde{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
其中 A ~ = A + I N \tilde{A} = A + I_N A~=A+IN, D ~ i i = ∑ j A ~ i j \tilde{D}_{ii} = \sum_{j}\tilde{A}_{ij} D~ii=jA~ij, 注意 A A A无向图 G G G 的邻接矩阵.

代码实现中, 由于 cora.sites 读取完得到的是有向图, 故作者先将有向图的 adj 邻接矩阵转换为对称的无向图邻接矩阵:

# build symmetric adjacency matrix
adj = adj + adj.T.multiply(adj.T > adj) - adj.multiply(adj.T > adj)

该代码写成公式如下:

A + ( A T − A ) ∗ ( A T > A ) A + (A^T - A) * (A^T > A) A+(ATA)(AT>A)

首先明确 A T A^T AT 的意思: A [ i , j ] A[i, j] A[i,j] 表示节点 i 指向节点 j 的权重, 那么 A T [ j , i ] = A [ i , j ] A^T[j, i] = A[i, j] AT[j,i]=A[i,j] 就表示节点 j 指向 i 的权重.

那么 A + ( A T − A ) ∗ ( A T > A ) A + (A^T - A) * (A^T > A) A+(ATA)(AT>A) 就能将得到无向图的权重 (因为 A A A 中只有 0 值和正数, A T > A A^T > A AT>A 将获取所有节点 i 指向节点 j 但是节点 j 却不指向节点 i 的位置, A T ∗ ( A T > A ) A^T * (A^T > A) AT(AT>A) 将得到节点 j 指向节点 i 的权重, 由于 A A A 中只保留了节点 i 指向 j 的权重, 那么两者相加就可以获得无向图 G G G 的权重).

(2020.04.22 补充: 这里再补充点细节. 首先要明确, 上面公式的目的是为了将有向图的邻接矩阵 A A A 转换为无向图的邻接矩阵. 原本情况可以很简单, 如果 A A A 是对角线全为 0 的上三角矩阵, 那么直接使用 A + A T A + A^T A+AT 就能得到无向图的邻接矩阵. 但意外情况是, 有向图可能存在 i → j i\rightarrow j ij j → i j \rightarrow i ji 的权重不相等的状况, 上面公式的作用是将 i → j i\rightarrow j ij j → i j\rightarrow i ji 中权重最大的那个, 作为无向图的节点 i i i 与节点 j j j 的边权. 下面具体来看:

对于 A + ( A T − A ) ∗ ( A T > A ) A + (A^T - A) * (A^T > A) A+(ATA)(AT>A) 公式, 它可以分为两个部分: A + A T ∗ ( A T > A ) A + A^T * (A^T > A) A+AT(AT>A) − A ∗ ( A T > A ) -A * (A^T > A) A(AT>A), 先讨论第一部分. 举个例子, 邻接矩阵 A A A 的形式如下:

A = [ 0 1 3 0 0 2 5 0 0 ] A = \left[ \begin{matrix} 0 & 1 & 3 \\ 0 & 0 & 2 \\ 5 & 0 & 0 \end{matrix} \right] A=005100320

这表示节点 0 → 1 0 \rightarrow 1 01 的权重为 1 1 1, 节点 0 → 2 0 \rightarrow 2 02 的权重为 3 3 3, 节点 1 → 2 1 \rightarrow 2 12 的权重为 2 2 2, 节点 2 → 0 2 \rightarrow 0 20 的权重为 5 5 5. 注意到节点 0 → 2 0 \rightarrow 2 02 的权重(为 w 0 , 2 = 3 w_{0,2} = 3 w0,2=3)和节点 2 → 0 2 \rightarrow 0 20 的权重( w 2 , 0 = 5 w_{2, 0} = 5 w2,0=5)不一致. 现在对 A A A 进行转置, 得到的结果是:

A T = [ 0 0 5 1 0 0 3 2 0 ] A^T = \left[ \begin{matrix} 0 & 0 & 5 \\ 1 & 0 & 0 \\ 3 & 2 & 0 \end{matrix} \right] AT=013002500

可以看到, 通过转置, 我们可以得到节点 j → i j \rightarrow i ji 的权重, 同时 A T > A A^T > A AT>A 的结果是:

A T > A = [ 0 0 1 1 0 0 0 1 0 ] A^T > A = \left[ \begin{matrix} 0 & 0 & 1 \\ 1 & 0 & 0 \\ 0 & 1 & 0 \end{matrix} \right] AT>A=010001100

进一步得到 A T ∗ ( A T > A ) A^T * ( A^T > A) AT(AT>A) 的结果为:

A T ∗ ( A T > A ) = [ 0 0 5 1 0 0 0 2 0 ] A^T * (A^T > A) = \left[ \begin{matrix} 0 & 0 & 5 \\ 1 & 0 & 0 \\ 0 & 2 & 0 \end{matrix} \right] AT(AT>A)=010002500

最后算出 A + A T ∗ ( A T > A ) A + A^T * (A^T > A) A+AT(AT>A) 的结果为:

A + A T ∗ ( A T > A ) = [ 0 1 8 1 0 2 5 2 0 ] A + A^T * (A^T > A) = \left[ \begin{matrix} 0 & 1 & 8 \\ 1 & 0 & 2 \\ 5 & 2 & 0 \end{matrix} \right] A+AT(AT>A)=015102820

可以看到, 此时 A + A T ∗ ( A T > A ) A + A^T * (A^T > A) A+AT(AT>A) 不完全对称, 但问题只是出在节点 0 → 2 0 \rightarrow 2 02 的权重(为 w 0 , 2 = 3 w_{0,2} = 3 w0,2=3)和节点 2 → 0 2 \rightarrow 0 20 的权重( w 2 , 0 = 5 w_{2, 0} = 5 w2,0=5)不一致的情况上, 此时 0 → 2 0 \rightarrow 2 02 的权重为 8 8 8, 是 w 0 , 2 = 3 w_{0,2}=3 w0,2=3 w 2 , 0 = 5 w_{2, 0}=5 w2,0=5 之和. 为了处理这种情况, 需要用到下面介绍的 − A ∗ ( A T > A ) -A * (A^T > A) A(AT>A) 这一项来进行修正, 只保留 max ⁡ ( w 0 , 2 , w 2 , 0 ) \max(w_{0,2}, w_{2, 0}) max(w0,2,w2,0), 即两者中的最大值.

由于 − A ∗ ( A T > A ) -A * (A^T > A) A(AT>A) 的结果为:

− A ∗ ( A T > A ) = [ 0 0 − 3 0 0 0 0 0 0 ] -A * (A^T > A) = \left[ \begin{matrix} 0 & 0 & -3 \\ 0 & 0 & 0 \\ 0 & 0 & 0 \end{matrix} \right] A(AT>A)=000000300

于是 A + ( A T − A ) ∗ ( A T > A ) A + (A^T - A)* (A^T > A) A+(ATA)(AT>A) 的结果为:

A + ( A T − A ) ∗ ( A T > A ) = [ 0 1 5 1 0 2 5 2 0 ] A + (A^T - A)* (A^T > A) = \left[ \begin{matrix} 0 & 1 & 5 \\ 1 & 0 & 2 \\ 5 & 2 & 0 \end{matrix} \right] A+(ATA)(AT>A)=015102520

为对称的矩阵. 此时原有向图的邻接矩阵 A A A 被转换为了无向图的矩阵. )

其中 − A ∗ ( A T > A ) -A * (A^T > A) A(AT>A) 这一项, 作者在 issue build symmetric adjacency matrix 中有谈到, 是为了处理其他情形, 比如当 i 指向 j 与 j 指向 i 的权重不一致时, 可以保留最大的权重:
A = [ 0 1 2 0 ] A T = [ 0 2 1 0 ] A = \left[\begin{matrix} 0 & 1 \\ 2 & 0\end{matrix}\right] \qquad A^T = \left[\begin{matrix} 0 & 2 \\ 1 & 0\end{matrix}\right] A=[0210]AT=[0120]
那么 A + ( A T − A ) ∗ ( A T > A ) A + (A^T - A) * (A^T > A) A+(ATA)(AT>A) 的结果为:
[ 0 2 2 0 ] \left[\begin{matrix} 0 & 2 \\ 2 & 0\end{matrix}\right] [0220]
结果是对称的, 并且保留了其中权重最大的边.

构建完对称矩阵之后, 下一步是创建 D ~ − 1 2 A ~ D ~ − 1 2 \tilde{D}^{-\frac{1}{2}}\tilde{A}\tilde{D}^{-\frac{1}{2}} D~21A~D~21:

adj = normalize(adj + sp.eye(adj.shape[0]))

其中 normalize 定义为:

def normalize(mx):
    """Row-normalize sparse matrix"""
    rowsum = np.array(mx.sum(1))
    r_inv = np.power(rowsum, -1).flatten()
    r_inv[np.isinf(r_inv)] = 0.
    r_mat_inv = sp.diags(r_inv)
    mx = r_mat_inv.dot(mx)
    return mx

从这个代码来看, 作者实现的效果应该是: D ~ − 1 A ~ \tilde{D}^{-1}\tilde{A} D~1A~.

作者在 issue What is the difference between two adjacency matrix normalization? 说到可以将这个公式也看成是超参数, 在训练时可以尝试使用 D ~ − 1 A ~ \tilde{D}^{-1}\tilde{A} D~1A~ 以及 D ~ − 1 2 A ~ D ~ − 1 2 \tilde{D}^{-\frac{1}{2}}\tilde{A}\tilde{D}^{-\frac{1}{2}} D~21A~D~21.

作者实现的 TensorFlow 版本中 https://github.com/tkipf/gcn, 在 https://github.com/tkipf/gcn/blob/master/gcn/utils.pynormalize_adj 函数中实现了 D ~ − 1 2 A ~ D ~ − 1 2 \tilde{D}^{-\frac{1}{2}}\tilde{A}\tilde{D}^{-\frac{1}{2}} D~21A~D~21:

def normalize_adj(adj):
    """Symmetrically normalize adjacency matrix."""
    adj = sp.coo_matrix(adj)
    rowsum = np.array(adj.sum(1))
    d_inv_sqrt = np.power(rowsum, -0.5).flatten()
    d_inv_sqrt[np.isinf(d_inv_sqrt)] = 0.
    d_mat_inv_sqrt = sp.diags(d_inv_sqrt)
    return adj.dot(d_mat_inv_sqrt).transpose().dot(d_mat_inv_sqrt).tocoo()

实际上, 在 如何理解 Graph Convolutional Networks? (GCN) 一文的高赞回答中说到, 常用的拉普拉斯矩阵实际上有三种:

  • Combinatorial Laplacian

  • D ~ − 1 2 A ~ D ~ − 1 2 \tilde{D}^{-\frac{1}{2}}\tilde{A}\tilde{D}^{-\frac{1}{2}} D~21A~D~21 被称为对称归一化拉普拉斯算子 Symmetric normalized Laplacian

  • D ~ − 1 A ~ \tilde{D}^{-1}\tilde{A} D~1A~ 被称为随机游走归一化拉普拉斯算子 Random walk normalized Laplacian

Scipy 中的 sparse matrix 转换为 PyTorch 中的 sparse matrix

可以复用的代码:

构建 sparse tensor, 一般需要 coordinate indices, values 以及 shape 等信息.

def sparse_mx_to_torch_sparse_tensor(sparse_mx):
    """Convert a scipy sparse matrix to a torch sparse tensor."""
    sparse_mx = sparse_mx.tocoo().astype(np.float32)
    indices = torch.from_numpy(
        np.vstack((sparse_mx.row, sparse_mx.col)).astype(np.int64))
    values = torch.from_numpy(sparse_mx.data)
    shape = torch.Size(sparse_mx.shape)
    return torch.sparse.FloatTensor(indices, values, shape)

模型定义

GCN 定义如下:

class GCN(nn.Module):
    def __init__(self, nfeat, nhid, nclass, dropout):
        super(GCN, self).__init__()

        self.gc1 = GraphConvolution(nfeat, nhid)
        self.gc2 = GraphConvolution(nhid, nclass)
        self.dropout = dropout

    def forward(self, x, adj):
        x = F.relu(self.gc1(x, adj))
        x = F.dropout(x, self.dropout, training=self.training)
        x = self.gc2(x, adj)
        return F.log_softmax(x, dim=1)

发现代码其实很简单, 把 GraphConvolution 想象成 nn.Linear. 上面是两层的 GCN 模型定义.

GraphConvolution 层定义如下:

class GraphConvolution(Module):
    """
    Simple GCN layer, similar to https://arxiv.org/abs/1609.02907
    """

    def __init__(self, in_features, out_features, bias=True):
        super(GraphConvolution, self).__init__()
        # ....

    def forward(self, input, adj):
        support = torch.mm(input, self.weight)
        output = torch.spmm(adj, support)
        if self.bias is not None:
            return output + self.bias
        else:
            return output

其输入为节点的特征 input 以及归一化的邻接矩阵 D ~ − 1 A ~ \tilde{D}^{-1}\tilde{A} D~1A~. 该实现和如下公式是一致的:
H ( l + 1 ) = σ ( D ~ − 1 A ~ H ( l ) W ( l ) ) H^{(l+1)} = \sigma\left(\tilde{D}^{-1}\tilde{A}H^{(l)}W^{(l)}\right) H(l+1)=σ(D~1A~H(l)W(l))
另外, torch.spmm 是稀疏矩阵的乘法.

如果节点的特征 input 不存在的话, 可以考虑将节点的 one-hot 表示作为特征输入到模型中.

模型训练

由于 GCN 模型 forward 方法最后的输出为 F.log_softmax(x, dim=1), 因此, 在设置 Loss 时, 使用的是:

F.nll_loss

方法.

Embedding 获取

相关代码作者没有提供, 在 issue 中谈到这个问题: Could I get the node embedding?

可以将隐藏层的 embedding 提取出来, 另外, 由于监督模型学出来的 embedding 非常 task-specialized. 作者还推荐他的另一个库: GAE

https://github.com/tkipf/gae

其他

其他可以参考学习, 复用的内容

准确率

可以复用.

def accuracy(output, labels):
    preds = output.max(1)[1].type_as(labels)
    correct = preds.eq(labels).double()
    correct = correct.sum()
    return correct / len(labels)

注意 output.max(1) 的返回结果. output.max(1)[1] 表示最大值所在的 indice. 比如:

>>> a = torch.arange(6).view(3, 2)
>>> a
tensor([[0, 1],
        [2, 3],
        [4, 5]])
>>> a.max(1)
torch.return_types.max(
values=tensor([1, 3, 5]),
indices=tensor([1, 1, 1]))
>>> a.max(1)[1]
tensor([1, 1, 1])
  • 13
    点赞
  • 46
    收藏
    觉得还不错? 一键收藏
  • 21
    评论
评论 21
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值