1. 半监督图卷积网络的核心设计思路拆解1.1 从邻居信息聚合说起为什么图结构数据需要专属方案传统深度学习模型处理的数据大多是欧几里得空间里的规则网格——图像是像素矩阵文本是词序列音频是采样点序列。这些数据的共同特点是每个样本的邻居数量固定、排列有序。但现实世界里大量数据是图结构社交网络里每个人的好友数不同分子结构中每个原子的化学键数量不同论文引用网络中每篇论文的引用数更是千差万别。你没法用固定尺寸的卷积核去扫这种不规则结构。图卷积网络要解决的核心问题就是如何在不规则图结构上定义卷积操作让每个节点聚合邻居信息来更新自身表示。早期方案要么在谱域做图傅里叶变换计算量大、依赖全图拉普拉斯矩阵要么在空域做邻居采样实现复杂、感受野不固定。而半监督图卷积网络的核心贡献在于它把谱域卷积做了一阶近似推导出一个极其简洁的逐层传播规则让图卷积的工程落地门槛大幅降低。这个方案能做什么给定一张图其中只有少量节点有标签比如论文引用网络中只有5%的论文标注了类别模型能利用图结构和大量无标签节点为所有节点学习出高质量的特征表示进而完成节点分类。适合谁参考任何手头有图结构数据、标签获取成本高、但又想用深度学习做节点级预测任务的从业者——社交网络用户画像、分子属性预测、知识图谱实体分类、推荐系统冷启动都是这套方案的直接应用场景。1.2 核心公式的直觉理解一阶近似到底近似了什么原始谱域图卷积的公式是 $g_\theta \star x U g_\theta(\Lambda) U^T x$其中 $U$ 是拉普拉斯矩阵的特征向量矩阵$\Lambda$ 是特征值对角阵。这个公式的问题在于$U$ 的计算需要全图特征分解复杂度 $O(N^3)$而且每次前向传播都要做矩阵乘法大图根本跑不动。半监督图卷积网络的做法是把 $g_\theta(\Lambda)$ 用切比雪夫多项式展开并只取一阶项同时做几个关键近似——令最大特征值约为2令 $\theta_0 -\theta_1$最终化简得到逐层传播规则$$H^{(l1)} \sigma\left(\tilde{D}^{-1/2} \tilde{A} \tilde{D}^{-1/2} H^{(l)} W^{(l)}\right)$$其中 $\tilde{A} A I_N$加自环$\tilde{D}{ii} \sum_j \tilde{A}{ij}$度矩阵$H^{(l)}$ 是第 $l$ 层节点特征矩阵$W^{(l)}$ 是可学习权重。这个公式的直觉解释非常清晰每个节点把自己的特征和所有邻居的特征做加权平均权重由度归一化决定然后乘一个可学习矩阵做特征变换最后过激活函数。加自环是为了让节点在聚合时保留自身信息否则节点更新后完全丢失了自己的原始特征。度归一化是为了防止高度节点在聚合后数值爆炸——一个有一千个好友的用户如果不做归一化聚合后的特征值会是一百个好友用户的十倍这显然不合理。1.3 半监督学习范式的巧妙之处损失只算在有标签节点上这个方案最精妙的设计在于它的训练范式。模型结构是两层图卷积堆叠第一层把输入特征维度映射到隐藏层维度比如1433维映射到16维第二层把隐藏层维度映射到类别数比如7类。前向传播时所有节点无论有无标签都参与图卷积运算因为图结构本身包含了无标签节点的连接信息这些信息对学习有标签节点的表示至关重要。但计算损失时只对有标签节点的输出和真实标签做交叉熵。无标签节点的输出不参与损失计算但它们的特征在前向传播过程中已经被邻居聚合影响了有标签节点的表示。这就是半监督的核心用无标签节点的结构信息来正则化有标签节点的分类边界。我个人的理解是这相当于在说我不需要知道无标签节点是什么类别我只需要知道它们和哪些节点相连这个连接关系本身就包含了分类信息。比如在引用网络中两篇论文如果有引用关系它们大概率属于同一领域即使其中一篇没有标签这个引用关系也能帮助判断另一篇的类别。2. 核心细节解析与实操要点2.1 数据预处理邻接矩阵的构建与归一化拿到图数据后第一步是构建邻接矩阵 $A$。对于引用网络这类数据边通常是无向的所以 $A$ 是对称矩阵。如果是有向图需要根据任务决定是否对称化——节点分类任务通常建议对称化因为信息双向流动更有利于特征传播。构建完 $A$ 后加自环得到 $\tilde{A} A I_N$。这一步的实操细节是如果原始 $A$ 中已经有自环某些数据集会预设不要重复加否则自环权重会变成2影响归一化后的数值分布。检查方法是看 $A$ 的对角线元素是否全为0。度矩阵 $\tilde{D}$ 的计算是 $\tilde{D}{ii} \sum_j \tilde{A}{ij}$然后计算 $\tilde{D}^{-1/2}$。这里有个数值稳定性的坑如果某个节点没有任何边孤立节点加了自环后度至少为1不会出现除零错误。但如果原始数据中有节点度为零且忘记加自环归一化时就会出现inf或nan。我的习惯是在预处理阶段就打印出度矩阵的最小值确认没有零度节点。归一化后的邻接矩阵 $\hat{A} \tilde{D}^{-1/2} \tilde{A} \tilde{D}^{-1/2}$ 是一个稀疏矩阵。实操中强烈建议用scipy.sparse存储和运算因为真实图数据的邻接矩阵稀疏度通常在99%以上用稠密矩阵存储会浪费大量内存。以Cora数据集为例2708个节点稠密矩阵约58MB稀疏存储不到1MB。注意归一化后的邻接矩阵中对角线元素是 $1/\tilde{D}{ii}$非对角线元素是 $1/\sqrt{\tilde{D}{ii}\tilde{D}_{jj}}$。这个数值范围通常在0到1之间保证了多层传播后数值不会爆炸。2.2 特征矩阵的预处理行归一化与维度选择输入特征矩阵 $X$ 的每一行是一个节点的特征向量。对于引用网络特征是词袋模型每个维度对应一个词的出现与否。实操中通常做行归一化L1或L2让每个节点的特征向量长度为1。这样做的好处是消除节点特征绝对大小的影响——有些论文长、有些论文短词袋计数差异很大归一化后只保留相对重要性。特征维度方面第一层图卷积的输出维度隐藏层维度是一个关键超参数。原论文在Cora上用了16维这个数字不是随便选的。维度太低比如4维会导致欠拟合分类准确率明显下降维度太高比如128维在小数据集上容易过拟合而且计算量增加。我的经验是隐藏层维度取类别数的2到4倍比较稳妥。Cora有7类16维正好是2倍多如果类别数是20可以试试40到64维。另一个细节是特征矩阵的稀疏性。词袋特征通常非常稀疏每篇论文只出现几十个词但词汇表有上千维如果转成稠密矩阵再乘计算量会大很多。实操中可以用稀疏矩阵乘法但要注意PyTorch的稀疏矩阵支持有限某些操作会强制转稠密。一个折中方案是如果显存够用直接转稠密如果不够用稀疏矩阵做第一层卷积后续层转稠密。2.3 模型结构设计两层图卷积的容量分析两层图卷积的结构是输入层 → 图卷积层1 → ReLU → Dropout → 图卷积层2 → Softmax。为什么是两层而不是三层或更多这涉及到图卷积的感受野问题。一层图卷积后每个节点聚合了直接邻居的信息两层后聚合了二跳邻居的信息。对于大多数图数据二跳邻居已经覆盖了足够的局部结构信息。三层以上会导致过平滑问题所有节点的表示会趋同因为每次聚合都在做加权平均多次平均后节点间的差异被抹平了。我实测过在Cora上堆三层准确率从81%左右掉到70%出头而且训练损失下降很慢这就是过平滑的典型表现。Dropout的位置也很讲究。原论文在第一层图卷积后、ReLU之前加Dropout概率设为0.5。这个位置的作用是随机丢弃隐藏层特征防止模型对某些特征维度过度依赖。如果放在输入层会破坏原始特征信息如果放在第二层后对最终分类影响太小。我的习惯是在两层图卷积之间加Dropout概率在0.3到0.5之间调小数据集用0.5大数据集用0.3。权重初始化方面第一层用Glorot初始化也叫Xavier初始化第二层也用Glorot。这是因为图卷积的输入输出维度差异较大Glorot能根据 fan_in 和 fan_out 自动调整初始化范围避免梯度消失或爆炸。偏置项可以不加因为归一化邻接矩阵已经包含了自环信息再加偏置容易导致过拟合。2.4 训练策略学习率、权重衰减与早停优化器选Adam学习率设0.01。这个学习率在Cora上实测比较稳太大0.1会导致损失震荡不收敛太小0.001收敛太慢几百个epoch还没到最优。权重衰减L2正则设5e-4这个值能有效防止过拟合特别是当有标签节点很少的时候。训练轮数最多设200个epoch但实际用早停策略每10个epoch检查一次验证集损失如果连续10次没有下降就停止训练。早停的耐心值patience设10到20比较合理。我试过patience5有时候验证损失还在缓慢下降就被停了patience50又浪费计算资源。10到20之间是个平衡点。验证集和测试集的划分要注意验证集和测试集都只从有标签节点中划分。比如Cora有140个有标签节点每类20个通常取500个验证节点和1000个测试节点——等等这里需要澄清原论文的设置是训练集每类20个共140个验证集500个测试集1000个但验证集和测试集的标签在训练时是不可见的只用于评估。训练时只用了140个有标签节点的标签其余节点的标签完全不知道。这个设置经常被初学者误解以为验证集和测试集的标签也参与了训练。提示如果你在自己的数据上跑有标签节点总数可能不是140。原则是训练集每类至少留几个比如5到20个验证集和测试集从剩余有标签节点中按比例划分。如果总共有标签节点就很少比如每类只有10个那验证集可以省掉直接用测试集做早停但这样会有信息泄露风险谨慎使用。3. 实操过程与核心环节实现3.1 环境准备与依赖安装先搭环境。Python 3.8以上PyTorch 1.10以上scipy用于稀疏矩阵numpy做数值计算networkx可选用于图可视化。安装命令如下pip install torch torchvision pip install scipy numpy networkx matplotlib如果你用GPU确认CUDA版本和PyTorch匹配。图卷积的计算量不大Cora这种规模的数据集CPU上跑也就几分钟但大图比如百万节点必须上GPU而且要考虑显存限制。3.2 数据加载与预处理代码实现以Cora数据集为例假设你已经下载了cora.content和cora.cites两个文件。cora.content每行是论文ID、词袋特征1433维、类别标签。cora.cites每行是被引用论文ID、引用论文ID。import numpy as np import scipy.sparse as sp def load_cora(data_path): # 读取内容文件 content [] with open(f{data_path}/cora.content, r) as f: for line in f: content.append(line.strip().split(\t)) # 提取特征和标签 node_ids [int(row[0]) for row in content] features np.array([[float(x) for x in row[1:-1]] for row in content]) labels [row[-1] for row in content] # 标签转数字 label_set sorted(set(labels)) label_to_idx {l: i for i, l in enumerate(label_set)} labels np.array([label_to_idx[l] for l in labels]) # 读取引用关系 edges [] with open(f{data_path}/cora.cites, r) as f: for line in f: edges.append(line.strip().split(\t)) # 构建邻接矩阵 node_id_to_idx {nid: i for i, nid in enumerate(node_ids)} adj sp.lil_matrix((len(node_ids), len(node_ids))) for src, dst in edges: i, j node_id_to_idx[int(src)], node_id_to_idx[int(dst)] adj[i, j] 1 adj[j, i] 1 # 对称化 return features, labels, adj, node_ids def normalize_adj(adj): # 加自环 adj adj sp.eye(adj.shape[0]) # 度矩阵 degree np.array(adj.sum(axis1)).flatten() # 度归一化 d_inv_sqrt np.power(degree, -0.5) d_inv_sqrt[np.isinf(d_inv_sqrt)] 0. d_mat_inv_sqrt sp.diags(d_inv_sqrt) # 归一化邻接矩阵 normalized_adj d_mat_inv_sqrt adj d_mat_inv_sqrt return normalized_adj.tocoo()这段代码的关键点邻接矩阵对称化、加自环、度归一化。d_inv_sqrt[np.isinf(d_inv_sqrt)] 0.这行是处理孤立节点的保险措施虽然加了自环后度至少为1但万一数据有异常这行能防止nan传播。3.3 模型定义与前向传播import torch import torch.nn as nn import torch.nn.functional as F class GraphConvLayer(nn.Module): def __init__(self, in_features, out_features): super().__init__() self.weight nn.Parameter(torch.FloatTensor(in_features, out_features)) nn.init.xavier_uniform_(self.weight) def forward(self, x, adj): # x: (N, in_features), adj: 稀疏归一化邻接矩阵 support torch.mm(x, self.weight) # (N, out_features) output torch.spmm(adj, support) # 稀疏矩阵乘法 return output class GCN(nn.Module): def __init__(self, n_features, n_hidden, n_classes, dropout0.5): super().__init__() self.gc1 GraphConvLayer(n_features, n_hidden) self.gc2 GraphConvLayer(n_hidden, n_classes) self.dropout dropout def forward(self, x, adj): x self.gc1(x, adj) x F.relu(x) x F.dropout(x, self.dropout, trainingself.training) x self.gc2(x, adj) return F.log_softmax(x, dim1)torch.spmm是稀疏矩阵和稠密矩阵的乘法要求稀疏矩阵是COO格式。如果你的PyTorch版本不支持spmm可以用torch.sparse.mm替代。前向传播时所有节点都参与计算但损失只算在有标签节点上。3.4 训练循环与评估def train(model, features, adj, labels, train_mask, val_mask, test_mask, epochs200, lr0.01, weight_decay5e-4): optimizer torch.optim.Adam(model.parameters(), lrlr, weight_decayweight_decay) best_val_loss float(inf) patience 20 patience_counter 0 for epoch in range(epochs): model.train() optimizer.zero_grad() output model(features, adj) loss F.nll_loss(output[train_mask], labels[train_mask]) loss.backward() optimizer.step() # 验证 model.eval() with torch.no_grad(): val_output model(features, adj) val_loss F.nll_loss(val_output[val_mask], labels[val_mask]) val_pred val_output[val_mask].argmax(dim1) val_acc (val_pred labels[val_mask]).float().mean().item() if val_loss best_val_loss: best_val_loss val_loss patience_counter 0 best_model_state model.state_dict() else: patience_counter 1 if patience_counter patience: print(fEarly stopping at epoch {epoch}) break if epoch % 10 0: print(fEpoch {epoch:03d} | Train Loss: {loss.item():.4f} | Val Loss: {val_loss.item():.4f} | Val Acc: {val_acc:.4f}) # 加载最佳模型 model.load_state_dict(best_model_state) model.eval() with torch.no_grad(): test_output model(features, adj) test_pred test_output[test_mask].argmax(dim1) test_acc (test_pred labels[test_mask]).float().mean().item() return test_acc训练循环里几个关键点train_mask、val_mask、test_mask是布尔索引分别标记哪些节点参与训练、验证、测试。损失函数用nll_loss配合log_softmax等价于交叉熵但数值更稳定。早停的耐心值设20验证损失连续20个epoch不下降就停。3.5 超参数调优的实操记录我在Cora上做了一组超参数实验记录如下隐藏层维度Dropout学习率权重衰减验证准确率测试准确率80.50.015e-478.2%77.5%160.50.015e-480.1%81.2%320.50.015e-479.8%80.5%160.30.015e-479.5%80.1%160.70.015e-478.9%79.3%160.50.0055e-479.2%79.8%160.50.025e-478.5%78.9%160.50.011e-379.0%79.5%160.50.011e-480.3%80.8%从这组数据能看出隐藏层16维、Dropout 0.5、学习率0.01、权重衰减5e-4是比较均衡的配置。隐藏层8维欠拟合32维没有明显提升反而略降。Dropout 0.3和0.7都不如0.5。学习率0.005收敛慢0.02震荡。权重衰减1e-3略过拟合1e-4和5e-4差别不大。注意这组实验是在Cora的固定划分上跑的不同随机种子会有1%到2%的波动。如果你在自己的数据上调参建议至少跑3个随机种子取平均否则单次结果可能误导。4. 常见问题与排查技巧实录4.1 损失不下降或准确率卡在随机水平这是最常见的问题。排查顺序如下第一检查邻接矩阵是否归一化。如果忘记归一化高度节点的特征值会爆炸梯度要么消失要么爆炸。打印归一化后邻接矩阵的最大值和最小值应该在0到1之间。第二检查标签索引是否正确。train_mask选出来的节点标签是否和labels数组对应。我踩过一次坑数据加载时标签转数字的顺序和节点顺序不一致导致训练时标签错位准确率一直上不去。第三检查特征矩阵是否归一化。词袋特征如果不做行归一化某些节点的特征值可能达到几百而另一些只有个位数模型很难学到均衡的表示。第四学习率是否太大。0.1的学习率在Cora上会导致损失震荡降到0.01试试。4.2 过平滑导致深层模型性能下降如果你尝试堆三层或更多图卷积层发现准确率反而下降这就是过平滑。解决方法有几个一是减少层数两层通常够用二是加残差连接让每层的输入部分保留到输出三是用JKNetJumping Knowledge Network结构把每层的输出拼接起来做最终分类。我实测过残差连接的效果在两层图卷积的基础上把第一层的输入加到第二层的输出上Cora准确率从81.2%提升到81.8%提升不大但稳定。如果数据图的平均度数很高比如超过20过平滑会更严重残差连接的必要性更大。4.3 显存不足或训练太慢图卷积的显存消耗主要来自特征矩阵和邻接矩阵。如果图很大比如百万节点邻接矩阵即使稀疏存储也可能占几百MB。优化方法一是用torch.sparse的COO格式比稠密矩阵省很多二是分批次训练但图卷积的批次划分比较麻烦因为节点之间有边连接不能简单随机划分。一个实用方案是GraphSAGE的邻居采样但那就不是原版GCN了。训练太慢的话检查是否用了GPU。Cora在CPU上跑200个epoch大概2到3分钟GPU上十几秒。如果GPU上还是很慢可能是稀疏矩阵乘法没有GPU加速试试转成稠密矩阵小图可以大图不行。4.4 常见问题速查表问题现象可能原因排查方法解决方案损失为nan学习率太大或邻接矩阵有inf打印邻接矩阵和损失值降低学习率检查归一化准确率不提升标签错位或特征未归一化检查train_mask对应标签重新对齐标签行归一化特征验证损失上升过拟合对比训练和验证损失曲线增大Dropout或权重衰减训练极慢用了稠密矩阵乘法检查邻接矩阵存储格式转稀疏矩阵用spmm深层模型变差过平滑对比不同层数准确率减少层数或加残差连接孤立节点报错度为零导致除零检查度矩阵最小值加自环设inf为04.5 独家避坑技巧第一个技巧在预处理阶段保存归一化后的邻接矩阵。每次训练都重新计算归一化很浪费时间特别是调参阶段。把normalized_adj存成.npz文件下次直接加载。第二个技巧用验证集做早停但最终报告测试集准确率时用验证集上最好的模型。不要用测试集做早停否则测试集准确率会虚高。我见过有人用测试集早停报告出来的准确率比实际泛化能力高3到5个百分点。第三个技巧随机种子固定但多跑几次。图卷积对初始化敏感单次结果波动可能有2%。跑5次取平均和标准差报告mean ± std更可信。如果标准差超过2%说明模型不稳定需要检查数据或超参数。第四个技巧类别不平衡时用加权损失。如果某些类别的有标签节点特别少nll_loss可以传weight参数给少样本类别更高权重。权重可以设成类别频率的倒数。第五个技巧特征维度很高时先做PCA降维。Cora的1433维特征其实有很多冗余降到500维左右准确率几乎不变但训练速度提升明显。不过降维会损失一些信息如果准确率下降超过1%就别降了。5. 从半监督图卷积出发的扩展思路5.1 归纳学习与直推学习的区别原版半监督图卷积是直推式学习训练时图结构固定所有节点包括测试节点都在图中模型在训练时已经看到了测试节点的特征和连接关系只是不知道标签。这种设定适合静态图比如引用网络一旦构建好就不变了。但很多场景需要归纳学习训练时没见过测试节点测试节点是后来加入的。比如社交网络每天有新用户注册你不可能每次新用户来都重新训练整个模型。归纳学习的解决方案是GraphSAGE不学习每个节点的固定表示而是学习一个聚合函数对新节点的邻居做聚合就能得到表示。GraphSAGE的采样机制还能处理大图因为不需要全图参与计算。5.2 异构图与多关系图的处理原版图卷积假设图只有一种边类型。但现实中的图往往有多种关系社交网络有关注、点赞、评论等不同边学术网络有引用、合作、同一会议等关系。处理这种异构图需要为每种边类型学习单独的权重然后聚合。RGCNRelational GCN就是干这个的它在每个关系上做图卷积然后把多关系的结果加起来或拼接。多关系图的实操难点在于关系数量多的时候参数量爆炸。解决方法是用基分解basis decomposition所有关系共享一组基权重每个关系只学习基的系数。这样参数量从关系数 × 输入维 × 输出维降到基数量 × 输入维 × 输出维 关系数 × 基数量。5.3 图注意力机制的引入原版图卷积的归一化系数是固定的由度决定这意味着邻居的重要性完全由度数决定没有学习空间。GATGraph Attention Network用注意力机制替代固定归一化每个节点对邻居计算注意力分数softmax后作为聚合权重。这样模型可以学习到哪些邻居更重要。GAT的实操注意点多头注意力multi-head能提升稳定性通常用8个头每个头输出维度是总维度除以头数。比如隐藏层64维8个头每个头8维拼接后还是64维。注意力dropout和特征dropout要分开设注意力dropout通常设0.6特征dropout设0.5。5.4 大规模图上的采样训练百万节点以上的图全图训练不现实。采样训练的思路是每个批次只选一批目标节点然后采样它们的K跳邻居构成子图在子图上做图卷积。GraphSAGE用固定大小的邻居采样比如每跳采10个邻居这样每个批次的子图大小可控。采样训练的坑在于采样会引入方差批次之间的梯度估计有噪声。解决方法是用更大的批次或者多轮采样平均。另外采样后的子图归一化要重新计算不能用全图的归一化矩阵否则数值不对。5.5 图卷积在推荐系统中的应用推荐系统天然是图结构用户和物品是节点交互是边。图卷积可以做用户和物品的表示学习然后内积预测交互概率。相比矩阵分解图卷积能利用高阶邻居信息——用户A和用户B有共同交互的物品图卷积能把这个信息传播到A和B的表示中。实操中推荐系统的图通常很大且动态变化需要用采样训练。另外推荐系统的评估指标不是准确率而是RecallK、NDCGK等排序指标。损失函数也常用BPRBayesian Personalized Ranking而不是交叉熵。6. 我个人在实际操作中的体会这套半监督图卷积方案我从几年前开始用踩过的坑和积累的经验大概能写满一个笔记本。最大的体会是图卷积的效果高度依赖图结构的质量。如果图本身噪声很大比如社交网络里大量随机关注图卷积反而会传播噪声效果不如只用节点特征做MLP。我试过在一个噪声图上跑图卷积比MLP低5个百分点后来清洗了边才反超。另一个体会是超参数调优的收益远小于数据质量提升。花一周调超参数可能提升1到2个百分点但花一天清洗数据、补充边关系可能提升5到10个百分点。所以我的建议是先把数据搞干净再调模型。最后分享一个小技巧用图卷积的输出做特征再喂给XGBoost或LightGBM。图卷积学到的节点表示包含了结构信息把这些表示作为特征输入树模型有时候比直接用图卷积分类效果更好特别是当类别边界非线性的时候。我试过在几个数据集上这个组合比纯图卷积高1到3个百分点。当然这样就不是端到端训练了但工程上很实用。