【GCN】Semi-Supervised Classification with Graph Convolutional Networks

GCN代码详细解读

  • 如何运行
  • 代码详解
    • utils.py
  • GCN的不足[6]
  • Reference

【GCN】Semi-Supervised Classification with Graph Convolutional Networks_第1张图片
基于谱分解的经典方法代表之一:GCN, ICLR2017
Paper
Code(pytorch)

如何运行

先看整个代码文件的结构(运行过的)
【GCN】Semi-Supervised Classification with Graph Convolutional Networks_第2张图片
运行的话:
首先一键安装需要的库python setup.py install
可能scipy下载有问题,手动pip install scipy即可
然后就可以直接运行了python train.py

  • 200个epoch两秒内跑完(GPU)
  • 只用CPU需要10分钟

【GCN】Semi-Supervised Classification with Graph Convolutional Networks_第3张图片

就是不清楚图的loss这么高也可以,cv中一般不得行
验证集和测试集上的loss远大于训练集

代码详解

先看一部分train.py

# load parameters
args = parser.parse_args()
args.cuda = not args.no_cuda and torch.cuda.is_available()

# generate random seed
# 确保在使用 NumPy 和 PyTorch 库时生成的随机数是可重复的
np.random.seed(args.seed)
torch.manual_seed(args.seed)
if args.cuda:
    torch.cuda.manual_seed(args.seed)

# Load data
adj, features, labels, idx_train, idx_val, idx_test = load_data()

# Model and optimizer
model = GCN(nfeat=features.shape[1],
            nhid=args.hidden,
            nclass=labels.max().item() + 1,
            dropout=args.dropout)
optimizer = optim.Adam(model.parameters(),
                       lr=args.lr, weight_decay=args.weight_decay)

主要是加载参数,生成随机种子和加载数据,
重点看到调用模型部分:

model = GCN(nfeat=features.shape[1],
            nhid=args.hidden,
            nclass=labels.max().item() + 1,
            dropout=args.dropout)
optimizer = optim.Adam(model.parameters(),
                       lr=args.lr, weight_decay=args.weight_decay)

这段代码创建一个GNN模型,并将其赋值给变量model;
还创建了一个优化器对象,并将其赋值给变量 optimizer,一般常用Adammodel.parameters() 用于获取模型中需要被训练的参数,这些参数将会被优化器更新。涉及两个超参数:lr是学习率,表示每次参数更新的步长大小。weight_decay是正则化项的权重衰减系数,用于控制模型的复杂度,防止过拟合。

创建模型使用了GCN类,nfeat设置了输入特征的维度,它是features张量的列数,也就是feature的个数;labels.max().item() + 1计算出了类别的数量。具体地,我们可以在model.py找到这个类的定义:
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
  • nfeat为底层节点的参数,feature的个数
  • nhid为隐藏层的节点个数
  • nclass为最终的分类数
  • dropout,一种防止过拟合的有效技术,其基本思想是在每批训练过程中随机忽略网络中的某些单元。

gc1输入尺寸nfeat,输出尺寸nhid
gc2输入尺寸nhid,输出尺寸nclass

可以看到,model初始化实际是在GraphConvolution类中完成的,在文件layer.py里

# bias 是一个布尔参数,指示是否应该包括偏差项
def __init__(self, in_features, out_features, bias=True):
# 以输入输出的特征维度构造权重张量(tensor)
self.weight = Parameter(torch.FloatTensor(in_features, out_features))

参数初始化部分:
(为了让每次训练产生的初始参数尽可能的相同,从而便于实验结果的复现)
def reset_parameters(self):
# 标准差,均匀初始化(通常基于经验或调优)
stdv = 1. / math.sqrt(self.weight.size(1)) # size()函数统计矩阵元素个数:size(0)列,size(1)行
# uniform()方法将在[-stdv, stdv]范围内随机生成下一个实数
self.weight.data.uniform_(-stdv, stdv)
if self.bias is not None:
self.bias.data.uniform_(-stdv, stdv)
前向传播部分:
(GCN卷积公式推导在接下来的part)
support = torch.mm(input, self.weight) # mm 执行二维矩阵相乘
output = torch.spmm(adj, support) # spmm 执行稀疏矩阵乘法
A ^ X W \hat{A}XW A^XW,即邻接矩阵,输入特征,权重三个矩阵相乘

以上是GCN类初始化的示意
继续看,GCN类的前向传播部分

    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结构及其卷积操作:

  • 结合[1][3]可以推导出GCN-Filter卷积操作
    f ′ = θ D ~ − 1 2 A ~ D ~ − 1 2 f f^{\prime}=\theta \tilde D^{-\frac{1}{2}} \tilde A \tilde D^{-\frac{1}{2}}f f=θD~21A~D~21f

简单来说,就是在Cheby-Filter上做的限制和再归一化技巧(对于Cheby-Filter的推导也可以跟着[3])

限制:

  1. 将切比雪夫多项式的阶数K设为1(通过堆叠多层GCN来解决简化后只能建立一阶邻居的依赖的代价)
  2. 定义 λ m a x ≈ 2 {\lambda}_{max} \approx2 λmax2 (有理论证明[4] λ N ≤ 2 {\lambda_{N}}\leq2 λN2)
  3. 简化运算复杂度:令参数 θ = θ 0 ′ = − θ 1 ′ \theta=\theta_0^{\prime}=-\theta_1^{\prime} θ=θ0=θ1

renormalization trick:
对称归一化
【GCN】Semi-Supervised Classification with Graph Convolutional Networks_第4张图片

GCN:
【GCN】Semi-Supervised Classification with Graph Convolutional Networks_第5张图片

那么再结合论文提供的网络结构图来理解
【GCN】Semi-Supervised Classification with Graph Convolutional Networks_第6张图片

首先,获取节点的特征表示 X X X并计算邻接矩阵 A ^ = D ~ − 1 2 A ~ D ~ − 1 2 \hat{A}=\tilde D^{-\frac{1}{2}} \tilde A \tilde D^{-\frac{1}{2}} A^=D~21A~D~21
然后,输入到一个两层(如图a)的GCN网络中(前一层的输出作为下一层的输入),得到每个标签的预测结果。
本文隐藏层使用的激活函数为ReLU,输出层的激活函数为softmax,因此标签的预测结果表示为(成为论文中的样子了):
在这里插入图片描述

另外,文中提到,“对于半监督分类问题,使用所有labeled节点的交叉熵作为损失函数…在这项工作中,我们使用完整数据集对每个训练迭代执行批量梯度下降。”

前向传播就是这么个操作,现在再回看到代码就可以理解了

def forward(self, x, adj):
	# 拥有节点特征x和对称邻接矩阵输入
	# 给第一层GCN后,接激活函数ReLU
	x = F.relu(self.gc1(x, adj))
	# 输出进行dropout,防止过拟合
    x = F.dropout(x, self.dropout, training=self.training)
    # 经过第二层GCN
    x = self.gc2(x, adj)
    # 通过softmax回归得到最终的输出,维度为1
    # (对于分类任务通常会使用softmax函数将输出转化为概率分布)
    return F.log_softmax(x, dim=1)

以上属于本文的核心代码,在train.py中完成对函数的调用
还可以看到train.py中定义了训练和测试过程
直接写上代码注释,不单列叙述

# 对某epoch进行训练
def train(epoch):
    # 返回当前时间
    t = time.time()
    # 将模型设置为训练模式。在训练模式下,框架会自动跟踪参数的梯度
    model.train()
    # 优化器梯度置为零。因为梯度在每个迭代中都会累积
    optimizer.zero_grad()
    # 将特征值和邻接矩阵输入模型,得到输出
    output = model(features, adj)
    # 计算损失,在output计算就是我们的模型中已经使用了softmax损失(可回顾前面的叙述)
    # 可以理解为CrossEntropLoss()=log_softmax()+NLLLoss() cite from [5]
    loss_train = F.nll_loss(output[idx_train], labels[idx_train])
    # 计算accuracy
    acc_train = accuracy(output[idx_train], labels[idx_train])
    # 反向传播
    loss_train.backward()
    # 更新所有参数
    # 由反向传播计算的模型参数的梯度指导更新模型的权重
    optimizer.step()

    # 如果不执行验证操作
    if not args.fastmode:
        # 单独评估验证集的性能,不使用dropout
        # 将模型设置为测试模式:不更新梯度
        model.eval()
        output = model(features, adj)

    # 对验证集计算loss和accuracy
    # 验证集的反馈可以影响模型的选择,调整和训练过程,以改进模型的性能
    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))


# 定义测试函数
# 测试集是用来最终评估模型在实际应用中的性能
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()))


# Train model
t_total = time.time()
# 逐个epoch进行训练
for epoch in range(args.epochs):
    train(epoch)
print("Optimization Finished!")
# 打印总运行时长
print("Total time elapsed: {:.4f}s".format(time.time() - t_total))

# Testing
test()

utils.py

  • 独热码处理
    也称为一位有效编码,是一种用于将分类数据转换为数值数据的编码方法。
    在很多分类任务中,特征标签通常都是不连续的内容(如本文中的特征是离散的字符串类型),为了便于后续计算、处理,需要将所有标签提取并映射到一个独热码向量中。[5]
def encode_onehot(labels):
    # 将所有标签整合成一个不重复的列表,可以知道标签中有多少不同的类别
    classes = set(labels)  # set()函数创建一个无序不重复元素集
    # 创建一个字典,索引为label,值为独热码向量
    classes_dict = {c: np.identity(len(classes))[i, :] for i, c in  # 创建一个len(classes)行的单位矩阵,选取矩阵中的第i行表示c
                    enumerate(classes)}    # 迭代classed集合中不同类别,获取他们的索引i和值c
    # 将标签转换为独热编码
    labels_onehot = np.array(list(map(classes_dict.get, labels)),
                             dtype=np.int32)  # map()会根据提供的函数对指定序列做映射,list()将映射结果转换为列表,再到numpy数组
    return labels_onehot # 包含了独热编码后的标签的numpy数组
  • 特征归一化函数
    对于本文使用的cora的数据集来说,每一行是一个样本,每一个样本是1433个特征,但又大多数为0,所以使用稀疏矩阵的方式进行存储。
def normalize(mx):
    """Row-normalize sparse matrix"""
    rowsum = np.array(mx.sum(1))
    r_inv = np.power(rowsum, -1).flatten()
    # 在计算倒数的时候存在一个问题:如果原来的值为0,则其倒数为无穷大
    # 因此需要对r_inv中无穷大的值进行修正,更改为0
    r_inv[np.isinf(r_inv)] = 0.
    r_mat_inv = sp.diags(r_inv)
    mx = r_mat_inv.dot(mx)
    return mx
  • scipy稀疏矩阵转换为torch稀疏向量
    这种转换确保了稀疏数据可以与深度学习模型无缝集成,同时充分利用了稀疏性质带来的内存和计算效益。
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)
  • 加载数据
def load_data(path="../data/cora/", dataset="cora"):
    """Load citation network dataset (cora only for now)"""
    print('Loading {} dataset...'.format(dataset))

    # 读出cora文件中的内容,以二维数组的形式存储
    idx_features_labels = np.genfromtxt("{}{}.content".format(path, dataset),
                                        dtype=np.dtype(str))
    # 以稀疏矩阵(采用CSR格式压缩)存储数据中的特征
    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)}
    # 读取cite文件
    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)
    # A^ = A + I
    adj = normalize(adj + sp.eye(adj.shape[0]))  # sp.eye(adj.shape[0])创建了一个大小与邻接矩阵相同的单位矩阵

    idx_train = range(140)
    idx_val = range(200, 500)
    idx_test = range(500, 1500)
    
    # 转换为tensor
    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

GCN的不足[6]

GCN的优势:

  • 权值共享,参数共享
  • 局部连接,参数少
  • 感受野正比于卷积层层数,也就是随着卷积层的增加,远处邻居的信息也会逐渐聚集
  • 复杂度大大降低,不用计算拉普拉斯矩阵和特征分解

后续改进针对 不足:

  • 扩展性差:需要已知所有节点来计算邻接矩阵
  • 局限于浅层:文中表明目前GCN只局限于浅层,实验中使用两层GCN效果最好,为了加深,需要使用残差连接等trick,但是即使使用了这些trick,也只能勉强保存性能不下降,并没有提高
  • 不能处理有向图:因为推导过程中用到拉普拉斯矩阵的特征分解需要满足拉普拉斯矩阵是对称矩阵的条件(也就是只能处理无向图)

Reference

[1] 图卷积神经网络(GCN)详解:包括了数学基础(傅里叶,拉普拉斯)
[2] Graph Convolution Network图卷积网络(二)数据加载与网络结构定义
[3] 马耀, 汤继良.图深度学习
[4] Laplacian Matrices
[5] pytorch框架下—GCN代码详细解读
[6] 图卷积网络 GCN Graph Convolutional Network(谱域GCN)的理解和详细推导

你可能感兴趣的:(Graph,pytorch,图论)