本文为gcn的PyTorch版本pygcn代码的注释解析(代码地址),也作为学习PyTorch时的一个实例加深对PyTorch API的理解。
模型代码一般分为下面几个关键步骤:
- 数据预处理
- 搭建模型
- 定义损失函数
- 训练与测试
其中代码量最大的是前两步,数据预处理包括如何从文件中读取数据,并存储成深度学习框架可处理的tensor类型,构建训练集、测试集和验证集等;搭建模型则是核心,需要对模型内部的运算流程有详细的认识,还包含很多论文中没有提到的细节。由于PyTorch相比Tensorflow更为简便,所以在模型和损失函数的定义方面,代码量要简洁,且易读。
数据预处理
对应于与pygcn/train.py
文件中的:
adj, features, labels, idx_train, idx_val, idx_test = load_data()
所以关键在于pygcn/utils.py
中的load_data()
方法:
def load_data(path="../data/cora/", dataset="cora"):
"""Load citation network dataset (cora only for now)"""
print('Loading {} dataset...'.format(dataset))
idx_features_labels = np.genfromtxt("{}{}.content".format(path, dataset),
dtype=np.dtype(str))
features = sp.csr_matrix(idx_features_labels[:, 1:-1], dtype=np.float32)
labels = encode_onehot(idx_features_labels[:, -1])
# build graph
idx = np.array(idx_features_labels[:, 0], dtype=np.int32)
idx_map = {j: i for i, j in enumerate(idx)}
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)
# build symmetric adjacency matrix
adj = adj + adj.T.multiply(adj.T > adj) - adj.multiply(adj.T > adj)
features = normalize(features)
adj = normalize(adj + sp.eye(adj.shape[0]))
idx_train = range(140)
idx_val = range(200, 500)
idx_test = range(500, 1500)
features = torch.FloatTensor(np.array(features.todense()))
labels = torch.LongTensor(np.where(labels)[1])
adj = sparse_mx_to_torch_sparse_tensor(adj)
idx_train = torch.LongTensor(idx_train)
idx_val = torch.LongTensor(idx_val)
idx_test = torch.LongTensor(idx_test)
return adj, features, labels, idx_train, idx_val, idx_test
load_data()
的参数是用来拼接数据集的字符串。
第5-6行:idx_features_labels
用来存储读取data/cora/cora.content
文件中的内容。这里涉及numpy
中的方法genfromtxt()
,关于该方法如何使用可参考[1,2],可以快速将txt中保存的文本内容转换成numpy
中的数组。文件数据的格式为id features labels
,因此idx_features_labels[:, 0]
、idx_features_labels[:, 1:-1]
、idx_features_labels[:, -1]
分别代表以上三部分。
第7行:idx_features_labels[:, 1:-1]
表示节点本身附带的特征,由于这些特征构成的矩阵是稀疏矩阵,使用csr_matrix()
方法将其存储为稀疏矩阵。关于该方法的使用参考[3,4]。
第8行:labels
在文件中是用字符串表示的某个论文所属的类别,如“reinforce learning”,要将其表示成one-hot向量的形式,用到了encode_onehot()
的方法。具体来看:
def encode_onehot(labels):
classes = set(labels)
classes_dict = {c: np.identity(len(classes))[i, :] for i, c in
enumerate(classes)}
labels_onehot = np.array(list(map(classes_dict.get, labels)),
dtype=np.int32)
return labels_onehot
先将所有由字符串表示的标签数组用set
保存,set
的重要特征就是元素没有重复,因此表示成set
后可以直接得到所有标签的总数,随后为每个标签分配一个编号,创建一个单位矩阵,单位矩阵的每一行对应一个one-hot向量,也就是np.identity(len(classes))[i, :]
,再将每个数据对应的标签表示成的one-hot向量,类型为numpy
数组。
第11行:将所有节点的id
表示成numpy
数组。
第12行:由于文件中节点并非是按顺序排列的,因此建立一个编号为0-(node_size-1)
的哈希表idx_map
,哈希表中每一项为id: number
,即节点id
对应的编号为number
。
第13-14行:edges_unordered
为直接从边表文件中直接读取的结果,是一个(edge_num, 2)
的数组,每一行表示一条边两个端点的idx
。
第15-16行:上边的edges_unordered
中存储的是端点id
,要将每一项的id
换成编号。在idx_map
中以idx
作为键查找得到对应节点的编号,reshape
成与edges_unordered
形状一样的数组。
第17-19行:首先要明确coo_matrix()
的作用,该方法是构建一个矩阵,根据给出的下标、数据和形状,构建一个矩阵,其中下标位置的值对应数据中的值,使用方法见[5]。所以这一段的作用就是,网络有多少条边,邻接矩阵就有多少个1,所以先创建一个长度为edge_num
的全1数组,每个1的填充位置就是一条边中两个端点的编号,即edges[:, 0], edges[:, 1]
,矩阵的形状为(node_size, node_size)
。
第22行:对于无向图,邻接矩阵是对称的。上一步得到的adj
是按有向图构建的,转换成无向图的邻接矩阵需要扩充成对称矩阵。
第24-25行:分别对特征矩阵features
和邻接矩阵adj
做标准化,用到了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
首先对每一行求和得到rowsum
;求倒数得到r_inv
;如果某一行全为0,则r_inv
算出来会等于无穷大,将这些行的r_inv
置为0;构建对角元素为r_inv
的对角矩阵;用对角矩阵与原始矩阵的点积起到标准化的作用,原始矩阵中每一行元素都会与对应的r_inv
相乘。
第27-37行:分别构建训练集、验证集、测试集,并创建特征矩阵、标签向量和邻接矩阵的tensor
,用来做模型的输入。
搭建模型
模型部分对应于pygcn/train.py
中的:
model = GCN(nfeat=features.shape[1],
nhid=args.hidden,
nclass=labels.max().item() + 1,
dropout=args.dropout)
模型定义在了pygcn/model.py
中,下面看看模型是怎么定义的:
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)
模型的定义看起来十分简洁,如果熟悉GCN的公式的话都不要注释。GCN
由两个GraphConvolution
层构成。GCN
的forward(self, x, adj)
方法对应输入分别是特征和邻接矩阵。最后输出为输出层做log_softmax
变换的结果。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__()
self.in_features = in_features
self.out_features = out_features
self.weight = Parameter(torch.FloatTensor(in_features, out_features))
if bias:
self.bias = Parameter(torch.FloatTensor(out_features))
else:
self.register_parameter('bias', None)
self.reset_parameters()
def reset_parameters(self):
stdv = 1. / math.sqrt(self.weight.size(1))
self.weight.data.uniform_(-stdv, stdv)
if self.bias is not None:
self.bias.data.uniform_(-stdv, stdv)
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
def __repr__(self):
return self.__class__.__name__ + ' ('
+ str(self.in_features) + ' -> '
+ str(self.out_features) + ')'
__init__
部分初始化了一些用到的参数,包括输入和输出的维度,并初始化了每一层的权重。
forward()
方法说明了每一层对数据的操作,其实就是先将输入特征矩阵与权重矩阵相乘,在左乘标准化的邻接矩阵,由于邻接矩阵的存储时用的是稀疏矩阵,所以有别于上一行,torch.spmm
表示sparse_tensor
与dense_tensor
相乘。
定义损失函数
GCN的损失函数分为两部分,一部分是分类损失,一部分是权重的正则化。
定义优化器(pygcn/train.py
):
optimizer = optim.Adam(model.parameters(),
lr=args.lr, weight_decay=args.weight_decay)
weight_decay
表示正则化的系数,默认取的是5e-4
。所以在定义优化器的时候就已将将正则化的损失定义了,不要在后续看代码的过程中发出找不到定义权重正则化的代码。
loss_train = F.nll_loss(output[idx_train], labels[idx_train])
另一部分是分类损失,用的是交叉熵,由于在算output
时已经使用了log_softmax
,这里使用的损失函数就是NLLloss,如果前面没有加
log运算,这里就要使用
CrossEntropyLoss`了,有关两者之间的差别,见参考[6]。
训练与测试
# train
def train(epoch):
t = time.time()
model.train()
optimizer.zero_grad()
output = model(features, adj)
loss_train = F.nll_loss(output[idx_train], labels[idx_train])
acc_train = accuracy(output[idx_train], labels[idx_train])
loss_train.backward()
optimizer.step()
# val
loss_val = F.nll_loss(output[idx_val], labels[idx_val])
acc_val = accuracy(output[idx_val], labels[idx_val])
print('Epoch: {:04d}'.format(epoch+1),
'loss_train: {:.4f}'.format(loss_train.item()),
'acc_train: {:.4f}'.format(acc_train.item()),
'loss_val: {:.4f}'.format(loss_val.item()),
'acc_val: {:.4f}'.format(acc_val.item()),
'time: {:.4f}s'.format(time.time() - t))
# test
def test():
model.eval()
output = model(features, adj)
loss_test = F.nll_loss(output[idx_test], labels[idx_test])
acc_test = accuracy(output[idx_test], labels[idx_test])
print("Test set results:",
"loss= {:.4f}".format(loss_test.item()),
"accuracy= {:.4f}".format(acc_test.item()))
训练与测试则比较常规,注意一下PyTorch的顺序,先将model置为训练状态;梯度清零;将输入送到模型得到输出结果;计算损失与准确率;反向传播求梯度更新参数。不得不说,见识过了Tensorflow后真的对PyTorch这种极其简介的定义颇为好感,api简单易懂。