一、说明
对论文《Semi-Supervised Classification with Graph Convolutional Network》的代码整理。
第一部分主要说明了数据预处理和初始化等工作,这节主要说明gcn和mlp模型建模以及数据训练过程,以下是笔记和代码逻辑的整理。
注:本人是初学者,如发现哪里写的不对或不妥当,欢迎随时和我交流,一起进步!
二、代码
1、model.py文件
会引用layers.py和metrics.py文件里的函数,我直接放在一起讲了
(1)首先定义一个抽象的模型类Model(),它提供了初始化、构建、训练和报错神经网络的功能和接口,可以用来构建各种类型的神经网络模型。下面我会一一详细介绍,这种方法值得学习。
class Model(object):
首先是模型的初始化方法,接收传入的关键字参数,在此定义允许传入的关键字参数,然后检查传入的关键字是否被允许。之后定义模型名称、配置日志记录、变量字典、占位字典、层列表、激活函数列表、输入输出、损失和正确率、优化器和优化操作
def __init__(self, **kwargs):allowed_kwargs = {'name', 'logging'} # 定义了允许传入的关键字参数for kwarg in kwargs.keys():# 对传入的关键字进行检查,确保属于被允许的关键字assert kwarg in allowed_kwargs, 'Invalid keyword argument: ' + kwargname = kwargs.get('name')if not name:name = self.__class__.__name__.lower() # 使用类名的小写作为模型名称self.name = namelogging = kwargs.get('logging', False)self.logging = logging # 日志记录的配置self.vars = {} # 变量字典self.placeholders = {} # 占位字典self.layers = [] # 层列表self.activations = [] # 激活函数列表self.inputs = Noneself.outputs = Noneself.loss = 0self.accuracy = 0self.optimizer = None # 优化器self.opt_op = None # 优化操作
然后是定义了一个抽象方法_build,会在子类中写模型的具体结构;
一个build()方法,是对_build()的包装。首先创建变量作用域,调用_build()方法构建具体的模型结构;构建序列层模型,将模型输入添加到激活函数中,遍历每层,对每层进行前向传播计算,得到隐藏层输出,将其添加到激活函数列表中,将最后一个激活函数输出作为最终输出;获取模型变量存储到字典中;调用loss和accurary函数,这些需要在子类中定义;使用优化器最小化模型损失。
# 定义了一个抽象方法_build,在子类中构建模型具体结构def _build(self):raise NotImplementedError# 对_build()方法的包装def build(self):# 创建变量作用域,调用_build方法构建模型具体结构with tf.compat.v1.variable_scope(self.name):self._build()# 构建序列层模型self.activations.append(self.inputs)for layer in self.layers:hidden = layer(self.activations[-1]) # 对每层进行前向传播计算,得到隐藏层输出self.activations.append(hidden) # 添加到激活函数列表中self.outputs = self.activations[-1] # 将最后一个激活函数的输出作为最终输出# 获取模型所有变量,存储到变量字典中variables = tf.compat.v1.get_collection(tf.compat.v1.GraphKeys.GLOBAL_VARIABLES, scope=self.name)self.vars = {var.name: var for var in variables}# 在子类中定义self._loss()self._accuracy()# 使用优化器最小化模型损失self.opt_op = self.optimizer.minimize(self.loss)
接下来是三个空函数,需要在子类中个性化定义
def predict(self):passdef _loss(self):raise NotImplementedErrordef _accuracy(self):raise NotImplementedError
最后是一个保存模型变量的函数save()和加载模型变量的函数load()
save()函数中首先保存模型所有变量到saver,然后保存到指定路径save_path,保存.ckpt结构
laod()函数首先保存模型变量到saver,然后读取模型路径到save_path,并加载模型变量到会话中
def save(self, sess=None):if not sess:raise AttributeError("TensorFlow session not provided.")saver = tf.train.Checkpoint(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.Checkpoint(self.vars)save_path = "tmp/%s.ckpt" % self.name # 指定模型路径# 从指定路径加载模型变量到会话中saver.restore(sess, save_path)print("Model restored from file: %s" % save_path)
(2)MLP模型
继承了刚刚的Model类
class MLP(Model):
首先定义模型的初始化方法,是调用了父类的初始化方法,设置输入是features,输入维度是input_dim,输出维度是labels的第二个维度大小,优化器是Adam,调用父类的构建模型的方法self.build()
# 定义了初始化方法def __init__(self, placeholders, input_dim, **kwargs):# 调用父类model的初始化方法super(MLP, self).__init__(**kwargs)self.inputs = placeholders['features']self.input_dim = input_dim# 获取标签占位符的形状信息,并将其第二个维度的大小赋值给 self.output_dimself.output_dim = placeholders['labels'].get_shape().as_list()[1]self.placeholders = placeholdersself.optimizer = tf.keras.optimizers.Adam(learning_rate=FLAGS.learning_rate)self.build()
接着定义模型的损失函数_loss(),首先遍历第一层的所有变量,计算权重衰减损失,添加到总损失中,然后将交叉熵损失添加到总损失中。
计算交叉熵损失的函数masked_softmax_cross_entropy()在metrics.py文件中,这里考虑了标签的掩码。传入预测值 outpus、真实标签labels、标签掩码labels_mask,先计算预测值和真实值之间的softmax交叉熵损失,然后对mask进行归一化操作,与loss逐元素相乘,得到经掩码处理过的损失,再返回损失的均值。
# 定义_loss(),用于计算模型的损失。
# 包括权重衰减损失(L2正则化)和交叉熵损失(使用softmax_cross_entropy函数计算)
def _loss(self):# 遍历第一层的所有变量的值,将权重衰减损失添加到总损失中for var in self.layers[0].vars.values():self.loss += FLAGS.weight_decay * tf.nn.l2_loss(var)# 将交叉熵损失添加到总损失中,考虑了标签掩码self.loss += masked_softmax_cross_entropy(self.outputs, self.placeholders['labels'],self.placeholders['labels_mask'])def masked_softmax_cross_entropy(preds, labels, mask):# 计算预测值pred和labels的softmax交叉熵损失loss = tf.nn.softmax_cross_entropy_with_logits(logits=preds, labels=labels)mask = tf.cast(mask, dtype=tf.float32) #变成float32类型# 进行归一化操作,除以平均值,保证掩码总和为1mask /= tf.reduce_mean(mask)# 逐元素相乘将掩码应用到损失上loss *= maskreturn tf.reduce_mean(loss)
然后是计算正确率的函数_accurary(),调用了metrics.py文件的函数masked_accuracy(),传入的也是这三个参数,首先根据索引分别对preds和labels的样本类别逐个比较,返回bool类型,然后就是对mask实行归一化,并与正确率逐元素相乘,最后返回平均值。
def _accuracy(self):self.accuracy = masked_accuracy(self.outputs, self.placeholders['labels'], self.placeholders['labels_mask'])
def masked_accuracy(preds, labels, mask):# tf.argmax(preds, 1):返回预测值 preds 中每个样本预测结果最大概率的类别索引# tf.argmax(labels, 1)返回与labels中每个样本真实类别的索引# tf.equal()比较对应元素是否相等,返回布尔类型的张量correct_prediction = tf.equal(tf.argmax(preds, 1), tf.argmax(labels, 1))accuracy_all = tf.cast(correct_prediction, tf.float32)mask = tf.cast(mask, dtype=tf.float32)mask /= tf.reduce_mean(mask) # 归一化accuracy_all *= mask # 考虑索引return tf.reduce_mean(accuracy_all)
接下来调用父类的_build()函数建立模型,包括两个全连接层。
Dense()类在layers.py文件中,见第三节
# MLP模型包括两个全连接层(Dense层)# 第一个全连接层的输出维度为FLAGS.hidden1,激活函数为ReLU# 第二个全连接层的输出维度为输出维度(labels的维度),激活函数为线性函数def _build(self):self.layers.append(Dense(input_dim=self.input_dim,output_dim=FLAGS.hidden1,placeholders=self.placeholders,act=tf.nn.relu, # 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))
最后是predict()函数,返回outputsj经过softmax函数处理后的结果,用于进行分类预测。
def predict(self):return tf.nn.softmax(self.outputs)
(3)GCN()类
继承了Model父类,包括__init__()函数、_loss()函数、_accuracy()函数、_build()函数、predict()函数,其中只有_build()函数不一样,包括两个图卷积层,
# 用于构建GCN模型的具体结构。# 该GCN模型包括两个图卷积层(GraphConvolution层),# 第一个图卷积层的输出维度为FLAGS.hidden1,激活函数为ReLU,# 第二个图卷积层的输出维度为输出维度(labels的维度),激活函数为线性函数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,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))
2、Layer.py文件
layer.py文件也跟刚才一样,首先建立一个抽象类Layer,然后再建立全连接层Dense和GraphConvolution继承自Layer类。
(1)Layer类
class Layer(object):
首先是__init__()函数,和model类的很类似,检查传入的参数、指定名字、指定变量、指定日志的配置方式,设置稀疏矩阵的默认是false
def __init__(self, **kwargs):allowed_kwargs = {'name', 'logging'}for kwarg in kwargs.keys():assert kwarg in allowed_kwargs, 'Invalid keyword argument: ' + kwargname = kwargs.get('name')if not name:layer = self.__class__.__name__.lower()name = layer + '_' + str(get_layer_uid(layer))self.name = nameself.vars = {}logging = kwargs.get('logging', False)self.logging = loggingself.sparse_inputs = False
接着是_call()函数和__call__()函数,_call函数现在是空壳,__call__()函数接受inputs作为输入,使用tf.name_scope()函数创建命名空间,如果启用了日志&输入不是稀疏矩阵,就将输入的直方图添加到日志中;输出是调用_call()函数得到输出,如果启用了日志,就将输出的直方图添加到日志中,并返回输出。
def _call(self, inputs):return inputsdef __call__(self, inputs):with tf.name_scope(self.name):if self.logging and not self.sparse_inputs:tf.summary.histogram(self.name + '/inputs', inputs)outputs = self._call(inputs)if self.logging:tf.summary.histogram(self.name + '/outputs', outputs)return outputs
最后是_log_vars()函数,对于传入的所有变量,依次将每个变量的直方图摘要添加到日志中。
def _log_vars(self):for var in self.vars:tf.summary.histogram(self.name + '/vars/' + var, self.vars[var])
(2)全连接层Dense()
Dense()类继承自Layer()类,重写__init__()和_call()函数。
class Dense(Layer):
首先是重写__init__()函数,传入输入维度、输出维度、占位符、丢弃率、输入是否稀疏矩阵、激活函数、偏置项bias、输入特征是否为空featureless、参数。调用父类Layer()的初始化方法,设置dropout,设置一系列参数;处理稀疏输入;使用tf.compat.v1.variable_scope()函数创建变量作用域,创建权重weights和偏置bias添加到变量中,使用glorot()和zeros()方法分别初始化。
def __init__(self, input_dim, output_dim, placeholders, dropout=0., sparse_inputs=False,act=tf.nn.relu, bias=False, featureless=False, **kwargs):super(Dense, self).__init__(**kwargs)if dropout:self.dropout = placeholders['dropout']else:self.dropout = 0.self.act = actself.sparse_inputs = sparse_inputsself.featureless = featurelessself.bias = bias# helper variable for sparse dropout# 处理稀疏输入self.num_features_nonzero = placeholders['num_features_nonzero']# 使用变量作用域,创建权重weights和偏置biaswith tf.compat.v1.variable_scope(self.name + '_vars'):self.vars['weights'] = glorot([input_dim, output_dim],name='weights')if self.bias:self.vars['bias'] = zeros([output_dim], name='bias')if self.logging:self._log_vars() #日志记录权重和偏置摘要
然后是重写_call()函数,功能是实现dropout和矩阵乘得到输出。
首先判断输入x是否是稀疏矩阵,是就调用稀疏矩阵的丢弃函数sparse_dropout(),不是就直接执行tf.nn.dropout()。然后执行y=wx+b,先将x与权重weight执行矩阵乘,在dot()函数中,稀疏矩阵使用tf.compat.v1.sparse_tensor_dense_matmul()函数实现矩阵乘,普通矩阵使用tf.matmul()函数;再与偏置项bias相加,返回output。
def _call(self, inputs):x = inputs# dropoutif self.sparse_inputs:x = sparse_dropout(x, 1-self.dropout, self.num_features_nonzero)else:x = tf.nn.dropout(x, 1-self.dropout)# transformoutput = dot(x, self.vars['weights'], sparse=self.sparse_inputs)# biasif self.bias:output += self.vars['bias']return self.act(output)
def sparse_dropout(x, keep_prob, noise_shape):random_tensor = keep_prob# 加上随机生成的[0,1]的均匀分布矩阵random_tensor += tf.random.uniform(noise_shape)# 先向下取整:>1的变成1; <1的变成0# 再取布尔值:1→true,0→false,这样在统计上保证keep_prob的被保留dropout_mask = tf.cast(tf.floor(random_tensor), dtype=tf.bool)# 相同位置如果是true的被保留,是false的被丢弃(设为0)pre_out = tf.sparse.retain(x, dropout_mask)return pre_out * (1./keep_prob)def dot(x, y, sparse=False):"""矩阵乘(稀疏矩阵 vs 常规)."""if sparse:res = tf.compat.v1.sparse_tensor_dense_matmul(x, y)else:res = tf.matmul(x, y)return res
(3)图卷积层GraphConvolution()
GraphConvolution()类继承自Layer()类,重写__init__()和_call()函数。
class GraphConvolution(Layer):
首先是__init__()函数,与mlp类的初始化函数不同之处在于,它导入了placeholders['support'],即经处理过的图数据,以及创建weight和bias的方式:创建作用域后,首先读取support的长度,然后根据长度i分别创建i个weight,维度是[input_dim, output_dim],并使用glorot()函数进行初始化;但只需创建并初始化一个bias。
self.support = placeholders['support'] # 是图数据# 处理稀疏矩阵,是noise_shapeself.num_features_nonzero = placeholders['num_features_nonzero']# variable_scope是创建作用域with tf.compat.v1.variable_scope(self.name + '_vars'):for i in range(len(self.support)): # len()=1self.vars['weights_' + str(i)] = glorot([input_dim, output_dim],name='weights_' + str(i))if self.bias:self.vars['bias'] = zeros([output_dim], name='bias')
然后是_call()函数,此处只解释卷积操作:首先遍历support里的元素,如果有节点特征,就将特征(稀疏矩阵)和权重进行矩阵乘得到pre_sup,如果节点无特征,直接将权重值赋给pre_sup。然后执行图卷积操作,将矩阵和节点特征进行卷积,即support[i]和pre_sup进行矩阵乘,然后得到的结果进行累加tf.add_n(),得到输出。
# 卷积操作!!!supports = list()for i in range(len(self.support)):if not self.featureless: # 节点有特征,将特征和权重相乘pre_sup = dot(x, self.vars['weights_' + str(i)],sparse=self.sparse_inputs)else: # 节点无特征,将权重值赋给pre_suppre_sup = self.vars['weights_' + str(i)]# 图卷积操作:将矩阵和节点特征进行卷积support = dot(self.support[i], pre_sup, sparse=True)supports.append(support)output = tf.add_n(supports) #元素累加
3、train.py文件:
首先着重说明一下support变量,在模型选择代码中,support = [preprocess_adj(adj)],相关的函数已在1中说明,此处直接解释函数功能,打印support的值为
[(array([[ 0, 0],[ 633, 0],[1862, 0],...,[1473, 2707],[2706, 2707],[2707, 2707]]), array([0.25 , 0.25 , 0.2236068, ..., 0.2 , 0.2 ,0.2 ]), (2708, 2708))]
可以看出support是一个元组,包含了三个部分:
①一个二维数组,每个元素表示稀疏矩阵的非零元素的坐标 (row_index, col_index)。
②一个一维数组,包含了稀疏矩阵中每个非零元素的数值。
③一个元组,表示稀疏矩阵的形状。
此处接着第一节继续说明代码功能。
(1)建立模型、初始化全局变量
# Create model
model = model_func(placeholders, input_dim=features[2][1], logging=True)
with tf.compat.v1.Session() as sess:# 初始化全局变量sess.run(tf.compat.v1.initializers.global_variables())
model_func即前面提到的GCN和MLP,在model.py文件里(见下面)。
(2)训练模型
训练epochs轮,每轮循环时,首先根据训练集数据创建feed dictionary,更新placeholders里的变量对应的数据;然后指定优化器model.opt_op,使用sess.run()运行函数,计算loss和accuracy;然后运行验证集,得到损失、正确率和所用时间,并将损失添加到cost_val中;打印每个epoch的训练和验证结果。进行早停判断:首先确保模型至少训练了一定轮再判断,然后判断当前验证集的损失是否大于最近几次验证集损失的均值,大于说明当前一轮的损失过大,可能出现了过拟合,执行早停。
# Train model
for epoch in range(args.epochs):t = time.time()# 训练集:创建 feed dictionary,更新占位符变量对应的数据feed_dict = construct_feed_dict(features, support, y_train, train_mask, placeholders)feed_dict.update({placeholders['dropout']: args.dropout})# Training step,指定优化器,计算loss和accuracyouts = sess.run([model.opt_op, model.loss, model.accuracy], feed_dict=feed_dict)# 验证集:y_val val_maskcost, acc, duration = evaluate(features, support, y_val, val_mask, placeholders)cost_val.append(cost)# 打印每个epoch的训练和验证结果print("Epoch:", '%04d' % (epoch + 1), "train_loss=", "{:.5f}".format(outs[1]),"train_acc=", "{:.5f}".format(outs[2]), "val_loss=", "{:.5f}".format(cost),"val_acc=", "{:.5f}".format(acc), "time=", "{:.5f}".format(time.time() - t))# 早停判断:# 第一个>:确保模型至少训练了一定轮,再判断早停# 第二个>:判断当前的验证集损失是否>最近args.early_stopping次验证集损失的均值,说明最近一次损失过大,可能是过拟合if epoch > args.early_stopping and cost_val[-1] > np.mean(cost_val[-(args.early_stopping+1):-1]):print("Early stopping...")break
(3)测试集
还有y_test和test_mask没有运行,首先执行evaluate()函数,返回cost、accuracy和时间,然后直接输出
# Testing
test_cost, test_acc, test_duration = evaluate(features, support, y_test, test_mask, placeholders)
print("Test set results:", "cost=", "{:.5f}".format(test_cost),"accuracy=", "{:.5f}".format(test_acc), "time=", "{:.5f}".format(test_duration))
三、笔记
1、学习建模方法,首先建立抽象模型类,定义几个空壳函数,然后建立子类继承自抽象模型类,在子类里重写函数。子类模型中构建时,建立多层时也可以首先建立抽象层,然后建立子类层重写方法。
2、归一化操作:除以平均值,保证掩码总和为1
其中tf.reduce_mean()是求平均值的意思
mask /= tf.reduce_mean(mask)
3、将Tebnsorflow1.x的代码改成2.x的,大部分都是加上compat.v1
例如 tf.variable_scope →→ tf.compat.v1.variable_scope
4、矩阵乘
常规的矩阵乘:res = tf.matmul(x, y)
稀疏矩阵相乘:res = tf.compat.v1.sparse_tensor_dense_matmul(x, y)
5、元素累加
output = tf.add_n(supports) #元素累加
6、早停判断
# 早停判断:# 第一个>:确保模型至少训练了一定轮,再判断早停# 第二个>:判断当前的验证集损失是否>最近args.early_stopping次验证集损失的均值,说明最近一次损失过大,可能是过拟合if epoch > args.early_stopping and cost_val[-1] > np.mean(cost_val[-(args.early_stopping+1):-1]):print("Early stopping...")break