参考文档 :https://zhuanlan.zhihu.com/p/538901776
完整代码如下:
import torch
import torch.nn.functional as F
from torch_geometric.nn import GCNConv
from torch_geometric.datasets import Planetoid, NELL
from tqdm import tqdm
import copy
import numpy as np
# 设置设备:如果有GPU就用GPU,否则用CPU
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
# 1. 定义模型
class GCN(torch.nn.Module):
def __init__(self, in_feats, h_feats, out_feats):
super(GCN, self).__init__()
# GCNConv 是 PyG 提供的标准图卷积层
self.conv1 = GCNConv(in_feats, h_feats)
self.conv2 = GCNConv(h_feats, out_feats)
def forward(self, data):
x, edge_index = data.x, data.edge_index
# 第一层卷积 + ReLU激活 + Dropout
x = self.conv1(x, edge_index)
x = F.relu(x)
x = F.dropout(x, p=0.6, training=self.training)
# 第二层卷积
x = self.conv2(x, edge_index)
return x
# 2. 加载数据函数
def load_data(path, name):
if name == 'NELL':
dataset = NELL(root=path + '/NELL')
else:
# Planetoid 会自动下载 Cora, CiteSeer, PubMed
dataset = Planetoid(root=path, name=name)
data = dataset[0].to(device) # 获取第一张图(也是唯一一张)
if name == 'NELL':
data.x = data.x.to_dense()
return data, dataset.num_node_features, dataset.num_classes
# 3. 测试/评估函数 (你补充的部分)
@torch.no_grad() # 装饰器:该函数内不计算梯度
def test(model, data):
model.eval() # 切换到评估模式 (关闭 Dropout)
out = model(data) # 前向传播,得到所有节点的输出
loss_function = torch.nn.CrossEntropyLoss().to(device)
# --- 验证集 Validation ---
# 计算验证集的 Loss,用于判断模型是否在变好
val_loss = loss_function(out[data.val_mask], data.y[data.val_mask])
# --- 测试集 Test ---
# 1. 找到概率最大的类别 (pred)
_, pred = out.max(dim=1)
# 2. 比较预测值和真实标签,只看测试集掩码的部分
correct = int(pred[data.test_mask].eq(data.y[data.test_mask]).sum().item())
# 3. 计算准确率
acc = correct / int(data.test_mask.sum())
model.train() # 切换回训练模式,为了接下来的训练循环
return val_loss.item(), acc
# 4. 训练函数
def train(model, data):
optimizer = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=5e-4)
loss_function = torch.nn.CrossEntropyLoss().to(device)
min_val_loss = np.Inf
best_model = None
min_epochs = 5 # 至少训练多少轮才开始保存模型
model.train() # 开启训练模式
final_test_acc = 0
for epoch in tqdm(range(200)): # 迭代 200 轮
out = model(data) # 前向传播
optimizer.zero_grad() # 梯度清零
# 只计算训练集的 Loss
loss = loss_function(out[data.train_mask], data.y[data.train_mask])
loss.backward() # 反向传播
optimizer.step() # 更新参数
# 每轮训练完,调用 test 函数评估
val_loss, test_acc = test(model, data)
# 保存最佳模型逻辑
if val_loss < min_val_loss and epoch + 1 > min_epochs:
min_val_loss = val_loss
final_test_acc = test_acc
best_model = copy.deepcopy(model) # 深拷贝保存模型
# 这里的 print 可以选择性开启,避免刷屏
# tqdm.write(f'Epoch {epoch:03d} Loss: {loss:.4f} Val Loss: {val_loss:.4f} Test Acc: {test_acc:.4f}')
return best_model, final_test_acc
# 5. 主程序入口
def main():
path = './data' # 数据下载路径
# 加载数据
data, num_in_feats, num_out_feats = load_data(path, name='CiteSeer')
# 初始化模型
model = GCN(num_in_feats, 16, num_out_feats).to(device)
# 开始训练
best_model, test_acc = train(model, data)
print(f'Final Test Accuracy: {test_acc:.4f}')
if __name__ == '__main__':
main()
这是一个非常经典的图卷积神经网络(Graph Convolutional Network, GCN)入门代码,通常用于节点分类任务(比如判断论文属于哪个领域)。
我会按照你的要求,从 main() 函数开始,顺藤摸瓜,逐行解释执行逻辑 ,并在过程中详细讲解涉及到的 PyTorch 和 PyTorch Geometric (PyG) 的核心知识点。
第一部分:程序入口 main()
Python 脚本通常从 if __name__ == '__main__': 开始执行。我们直接看 main() 函数内部。
1. 加载数据
dataset, num_in_feats, num_out_feats = load_data(path, name='CiteSeer')
-
执行逻辑 :调用自定义的
load_data函数,加载名为 'CiteSeer' 的数据集。返回了数据集对象、输入特征维度(每个节点有多少特征)和输出特征维度(有多少个分类类别)。 -
知识点跳转(load_data 内部):
-
Planetoid数据集 :代码中的Planetoid(root=path, name=name)是 PyG 内置的一个类。它会自动下载并处理 Cora, CiteSeer, PubMed 这三个最经典的图神经网络引用数据集。 -
dataset[0]:图数据集通常被视为一个列表。对于像 CiteSeer 这样的任务,只有一张大图 ,所有的节点(论文)都在这张图里。所以我们取dataset[0]拿到这个唯一的图对象(Data对象)。 -
to(device):这是 PyTorch 的核心概念。将数据移动到 GPU (cuda) 或 CPU 上。深度学习的大规模矩阵运算在 GPU 上快得多。 -
Data对象结构 (PyG 核心):-
data.x: 节点特征矩阵。形状为 \[N, F\],N是节点数,F是特征数。 -
data.edge_index: 边索引。形状为 \[2, E\],E是边数。它定义了图的连接关系(谁连着谁)。通常是 COO (Coordinate) 格式。 -
data.y: 标签。形状为 \[N\],表示每个节点的类别。 -
data.train_mask/val_mask/test_mask: 掩码。这是图学习特有的。因为只有一张图,我们不能像传统机器学习那样切分文件。我们用 True/False 的掩码来标记哪些节点用于训练,哪些用于验证和测试。
-
-
2. 初始化模型
model = GCN(num_in_feats, 64, num_out_feats).to(device)
-
执行逻辑 :实例化
GCN类。-
num_in_feats: 输入层维度(即每个节点的特征向量长度)。 -
64: 隐藏层维度(Hidden Size),这是你可以调整的超参数。 -
num_out_feats: 输出层维度(即分类的类别数)。 -
.to(device): 同样,把模型搬到 GPU 上。
-
-
知识点跳转(GCN 类内部):
-
torch.nn.Module:这是 PyTorch 所有神经网络的基类(父类)。自定义模型必须继承它。 -
__init__(定义层):-
self.conv1 = GCNConv(in_feats, h_feats): 定义第一层图卷积。 -
GCNConv (PyG 核心) :这是图卷积层的实现。普通的卷积(CNN)是在像素格子上做聚合,GCNConv 是在图结构上做聚合。它会把"邻居节点"的特征加权求和传给当前节点。数学公式大致为:
X' = \\hat{D}\^{-1/2} \\hat{A} \\hat{D}\^{-1/2} X \\Theta
简单理解就是:告诉我你邻居是谁,我就能通过聚合他们的特征来更新你的特征。
-
-
forward(前向传播):-
x, edge_index = data.x, data.edge_index: 取出特征和图结构。 -
self.conv1(x, edge_index): 图卷积操作 。必须同时传入特征x和图结构edge_index,因为卷积需要知道谁是谁的邻居。 -
F.relu(...): 激活函数。引入非线性,让神经网络能拟合复杂数据。ReLU 把负数变成 0,正数保持不变。 -
F.dropout(x, p=0.6, ...): 正则化 。随机把 60% 的神经元输出置零。这为了防止过拟合(防止模型死记硬背训练数据)。注意training=self.training,这意味着 Dropout 只在训练时开启,测试时自动关闭。
-
-
3. 训练模型
model, test_acc = train(model, dataset)
- 执行逻辑 :调用
train函数,传入未训练的模型和数据,返回训练好的最佳模型和最终准确率。
第二部分:核心引擎 train() 函数详解
这是机器学习最关键的循环(Loop)。
1. 准备工作
optimizer = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=1e-4)
loss_function = torch.nn.CrossEntropyLoss().to(device)
-
Optimizer (优化器) :
Adam是最常用的优化算法。它的作用是根据计算出的梯度(Gradient)来更新模型的参数(权重)。-
lr=0.01: 学习率 (Learning Rate)。步子迈多大。太大容易通过最优解,太小收敛太慢。 -
weight_decay: L2 正则化。惩罚过大的权重参数,防止过拟合。
-
-
Loss Function (损失函数) :
CrossEntropyLoss(交叉熵)。用于衡量"模型预测的概率分布"与"真实标签"之间的差距。差距越大,Loss 越大。
2. 训练循环
model.train() # 1. 开启训练模式
for epoch in tqdm(range(200)): # 2. 迭代 200 次
out = model(data) # 3. 前向传播
optimizer.zero_grad() # 4. 梯度清零
loss = loss_function(out[data.train_mask], data.y[data.train_mask]) # 5. 计算损失
loss.backward() # 6. 反向传播
optimizer.step() # 7. 更新参数
这是 PyTorch 训练的标准五步曲(必背知识点):
-
model.train():告诉 PyTorch 正在训练。这会开启 Dropout 和 BatchNorm 等特定层的行为。 -
前向传播 (
model(data)) :数据输入模型,得到预测结果out。 -
optimizer.zero_grad():非常重要。PyTorch 默认会累加梯度。在每一次更新参数前,必须把上一步遗留的梯度清零,否则梯度会乱掉。 -
计算损失 (
loss_function) :核心在于out[data.train_mask],确保 Loss 只由训练集节点产生。-
注意这里用了
data.train_mask。 -
out[data.train_mask]: 我们只取出被标记为"训练集"的那些节点的预测结果。 -
data.y[data.train_mask]: 取出对应的真实标签。 -
半监督学习 (Semi-supervised Learning) :图神经网络的一个特点是,我们在训练时可以看到所有节点的特征和所有边,但是计算 Loss 时,只用一小部分带标签的节点。
-
-
反向传播 (
loss.backward()) :PyTorch 的魔法。它自动计算 Loss 对每个参数的导数(梯度),并将梯度存入参数的.grad属性中。 -
更新参数 (
optimizer.step()) :优化器利用刚刚计算的.grad,按照 Adam 算法的公式修改模型参数。
【注意】
在训练时输入的数据是直接data,这样应该是吧整个数据包括训练与测试部分都传入了吧?训练时也看到了测试数据,后续的测试要如何做呢?
答案请见附录3!!!
3. 验证与模型保存 (Validation)
val_loss, test_acc = test(model, data) # 注意:test函数在你提供的代码里缺失,但我能推断其逻辑
if val_loss < min_val_loss ...:
best_model = copy.deepcopy(model)
-
执行逻辑:在每个 Epoch 结束时,用验证集(Validation Set)来看看模型表现如何。
-
Early Stopping (早停) 机制的雏形 :代码记录了
min_val_loss。如果当前的验证损失比历史最低还要低,说明模型变好了,我们用copy.deepcopy把当前的模型参数深拷贝 保存下来作为best_model。- 知识点 :为什么不能直接
best_model = model?因为 Python 中对象是引用传递。如果直接赋值,后续model继续训练变差了,best_model也会跟着变差。必须用深拷贝复制一份独立的副本。
- 知识点 :为什么不能直接
第三部分:test() 函数详解 (重点)
1. 装饰器与模式切换
@torch.no_grad()
def test(model, data):
model.eval()
-
@torch.no_grad(): 这是一个上下文管理器。它告诉 PyTorch:"在这个函数里运行的所有计算,都不要记录梯度"。- 为什么? 计算梯度非常消耗显存和时间。测试阶段我们不需要更新参数,只需要结果,所以关掉梯度可以大幅加速并节省内存。
-
model.eval(): 这是一个开关。它会改变模型中特定层的行为。- Dropout : 在
train模式下,随机丢弃神经元;在eval模式下,所有神经元全功率工作。如果不加这一行,你的测试结果会因为随机 Dropout 而波动,不准确。
- Dropout : 在
2. 前向传播
out = model(data)
- 再次运行模型。因为有
eval(),这次输出的是稳定、确定的结果。
3. 计算验证集 Loss (用于 Model Selection)
loss_function = torch.nn.CrossEntropyLoss().to(device)
val_loss = loss_function(out[data.val_mask], data.y[data.val_mask])
-
目的 :我们计算验证集的 Loss,不是为了更新参数(因为没有
backward),而是为了判断。 -
如果
val_loss在下降,说明模型在变好;如果val_loss开始上升,说明模型开始过拟合了(在训练集上死记硬背,但在新数据上表现变差)。
4. 计算测试集准确率 (Accuracy)
_, pred = out.max(dim=1)
-
out是一个形状为[节点数, 6]的矩阵。每一行有 6 个概率值。 -
.max(dim=1): 在维度 1(列方向)寻找最大值。-
它返回两个值:
(最大值, 最大值的索引)。 -
我们只需要索引(即类别 ID),所以用
_忽略最大值,用pred接收索引。correct = int(pred[data.test_mask].eq(data.y[data.test_mask]).sum().item())
-
这是一行非常 Pythonic 的 PyTorch 代码:
-
pred[data.test_mask]: 取出测试集节点的预测类别。 -
data.y[data.test_mask]: 取出测试集节点的真实类别。 -
.eq(...): Element-wise Equal。逐元素比较,相等的返回 True (1),不等的返回 False (0)。 -
.sum(): 把所有 True 加起来,得到预测正确的个数。 -
.item(): 把 Tensor 转为 Python 的整数。acc = correct / int(data.test_mask.sum())
准确率 = 猜对的个数 / 测试集总人数。
5. 恢复训练模式
model.train()
-
侧重逻辑 :函数快结束了,但主程序的
for循环还在继续。马上又要进行下一次训练了。 -
必须切回
train():否则下一次循环时,Dropout 依然是关闭状态,模型就无法进行正常的正则化训练了。
第四部分:保存最佳模型 (Early Stopping)
回到 train 函数的这一段:
if val_loss < min_val_loss and epoch + 1 > min_epochs:
min_val_loss = val_loss
best_model = copy.deepcopy(model)
执行逻辑:
-
即使我们训练 200 轮,可能第 150 轮的时候模型效果最好,后面 50 轮过拟合了。
-
我们通过监控
val_loss(验证集损失)来决定何时保存快照。 -
copy.deepcopy(model): 这是一个纯 Python 知识点。如果写best_model = model,这只是起了一个别名。当model在下一轮变化时,best_model也会变。deepcopy则是把模型当前的参数完全复制一份存到内存的另一块地方,永久锁定那个高光时刻。
第五部分:总结与知识点图谱
通过这段代码,你实际上学习了以下核心模块:
1. PyTorch 基础 (torch)
-
nn.Module: 所有网络的基石。 -
nn.CrossEntropyLoss: 多分类任务的标准损失函数。 -
optim.Adam: 优化算法。 -
backward()/step(): 自动微分与参数更新机制。 -
.to(device): 硬件加速管理。
2. PyTorch Geometric (torch_geometric)
-
Data对象 : 图数据的标准容器(包含x,edge_index,y,mask)。 -
GCNConv: 图卷积算子,实现了图上的消息传递机制。 -
数据集加载 : 如
Planetoid,实现了图数据的自动化下载与预处理。
3. 机器学习概念
-
Epoch: 所有训练数据被模型看一遍叫一个 Epoch。
-
Overfitting (过拟合): 模型在训练集表现极好,测试集极差。解决方法:Dropout, Weight Decay。
-
Train/Val/Test Split:
-
Train: 它是课本,用来学习。
-
Val: 它是模拟考,用来调整状态(保存最佳模型)。
-
Test: 它是高考,最后考一次看最终效果。
-
4.总结
这份代码展示了一个标准的 GNN 训练范式:
-
加载全图(直推式学习,Transductive)。
-
训练循环:
model.train()-> Forward -> Loss (on Train Mask) -> Backward -> Update.
-
评估插槽:
-
model.eval()-> Forward -> Metrics (on Val/Test Mask). -
利用
Val Loss挑选最佳模型。 -
切记恢复
model.train()。
-
-
最终测试:
- 使用保存下来的
best_model报告最终成绩。
- 使用保存下来的
附录
1.transductive模式与inductive模式
在机器学习领域,Inductive(归纳式)和Transductive(直推式)是两种不同的学习范式,核心区别在于模型在训练时是否"见过"测试数据,以及其目标是建立普适规则还是解决特定问题。
1. Inductive 模式(归纳式学习)
这是最传统的机器学习模式(如常见的监督学习)。
- 核心逻辑 :从特定的训练样本中归纳出通用的预测函数或规则。
- 流程:训练集 --> 模型(通用规则)--> 应用于新数据。
- 特点 :
- 泛化能力强:训练完成后,模型可以处理任何以前从未见过的全新样本。
- 预测效率高:预测新样本时不需要重新训练。
- 典型例子:决策树、逻辑回归、神经网络(如图片分类器)。
2. Transductive 模式(直推式学习)
这种模式常见于图神经网络(GNN)和半监督学习。
- 核心逻辑 :不追求通用的规则,而是利用已有的带标签数据和特定的未带标签数据(测试集),直接推断这些特定测试数据的标签。
- 流程:{训练集 + 特定测试集} --> 直接得出测试集的预测结果。
- 特点 :
- 针对性强:模型在训练时利用了测试集的分布信息(虽然没看标签),通常在特定任务上表现更好。
- 无法直接处理新数据 :如果来了一个训练时没出现过的新样本,通常需要重新运行或重新训练模型。
- 典型例子:K-近邻(KNN)、标签传播算法(Label Propagation)、图卷积网络(GCN)的半监督节点分类。
核心对比表
| 特性 | Inductive (归纳式) | Transductive (直推式) |
|---|---|---|
| 训练时可见数据 | 仅训练集 | 训练集 + 测试集特征 |
| 目标 | 学习通用的F(x)映射 | 预测特定集合的标签 |
| 新数据处理 | 直接预测(无需重训) | 通常需要重训 |
| 适用场景 | 生产环境、流式数据 | 静态图数据、标签极少的情况 |
举个通俗的例子:
- Inductive:你学透了数学公式,考试时给你任何新题你都能解出来。
- Transductive:老师直接把期末考试卷(不带答案)提前发给你看,你根据课本内容和考卷题目之间的联系,定向琢磨出这套卷子的答案。一旦换套卷子,你又得重新琢磨。
总结一下就是:
Inductive learning 中文意为归纳式学习,它在训练过程中只在训练集上训练,完全不知道测试集的数据内容,模型训练完毕后,将其应用到测试集上,其具有一定的泛化能力。
Transductive learning 中文意为直推式学习,它在训练过程中已经知道测试集的数据,尽管没有标签,但是可以从其特征分布中学到一些额外的信息,可以增加模型的效果,但这意味着有新的样本加入就需要重新训练。
二者的区别:
- 在模型训练上:Inductive learning 在训练过程中完全不知道测试集信息,而 Transductive learning 在训练过程中已经利用了测试集信息。
- 在模型预测上:Transductive learning 只能预测在其训练过程中所用到的样本(Specific --> Specific),而Inductive learning,只要样本特征属于同样的欧拉空间,即可进行预测(Specific --> Gerneral)
- 模型复用性:当有新样本时,Transductive learning 需要重新进行训练;Inductive Leaning 则不需要。
- 模型计算量:显而易见,Transductive Leaning 是需要更大的计算量的,即使其有时候确实能够取得相比 Inductive learning 更好的效果。
2.监督学习 VS 半监督学习
1. 监督学习 (Supervised Learning)
这是最常见的模式,类似于**"有老师教"**的学习。
- 定义 :模型在训练时,数据包含输入(特征)和对应的正确答案(标签/Label)。
- 逻辑:模型通过对比自己的预测值与正确答案的差距,不断修正参数,从而学会如何建立输入到输出的映射。
- 场景 :
- 分类:预测类别(如:根据图片特征判断是猫还是狗)。
- 回归:预测数值(如:根据地段、面积预测房价)。
- 挑战:需要大量的人工标注数据,成本很高。
2. 半监督学习 (Semi-supervised Learning)
这种模式介于监督学习与无监督学习之间,类似于**"老师只教一点,剩下自己悟"**。
- 定义 :训练集中包含少量带标签数据 和大量不带标签数据。
- 逻辑 :
- 利用少量标签确立基本的分类框架。
- 利用大量无标签数据的分布特征(如:数据点聚集的规律、平滑性)来优化分类边界。
- 经典应用:Google Photos 的人脸识别
- 步骤:它会自动把相似的人脸归为一类(无监督聚类);只要你给其中一张照片标上名字"张三"(监督学习),系统就能把这一类里的几千张照片都标为"张三"。
- 优势:在标注数据极其昂贵(如医疗影像分析)的情况下,利用廉价的无标签数据能显著提升模型效果。
核心对比
| 特性 | 监督学习 | 半监督学习 |
|---|---|---|
| 数据构成 | 全部带有标签 | 少部分有标签 + 大部分无标签 |
| 成本 | 高(人工标注费时费力) | 较低(充分利用现有原始数据) |
| 学习目标 | 最小化预测值与真实标签的误差 | 结合标签引导与数据本身的结构信息 |
3.无监督学习 (Unsupervised Learning)
是一种不需要"老师"指导的学习模式。模型直接处理**没有标签(Label)**的原始数据,目标是发现数据内部潜藏的结构、规律或聚集模式。
常见的任务包括:
- 聚类 (Clustering):把相似的东西自动归为一类(如:将相似购买行为的客户建模为同一群体)。
- 降维 (Dimensionality Reduction):在保留核心信息的同时简化数据特征(如:PCA 主成分分析)。
- 关联规则 (Association Rules):发现事物间的潜在联系(如:超市里买啤酒的人通常也会买尿布)。
如何在没有标签的情况下评估模型?
由于没有"标准答案"可以比对,评估无监督模型主要依靠内部评估(基于数据分布)和外部验证(基于业务或辅助数据):
- 内部指标 (Intrinsic/Internal Metrics)
直接通过数据本身的分布来判断"分得好不好"。
- 轮廓系数 (Silhouette Score):测量数据点与其所属簇的相似度,以及与其他簇的疏远程度。数值越接近 1,说明分类越清晰。
- 紧凑度与分离度 (Inertia/Compactness):计算簇内成员到中心点的距离总和(如 K-Means 中的惯性)。距离越短,说明同一类别的点结合得越紧密。
- 肘部法则 (Elbow Method):通过观察误差下降的斜率来确定最佳的聚类数量。
- 外部验证 (Extrinsic/External Metrics)
如果手头有一小部分"隐藏标签"或者已知的分组信息,可以用来做参考。
- 调整兰德指数 (Adjusted Rand Index, ARI):衡量模型聚类结果与参考标签的一致性,会排除随机巧合的影响。
- 互信息 (Normalized Mutual Information, NMI):衡量两种分组信息之间的相关性。
- 业务与下游任务验证
- 领域专家审查:由懂业务的人看模型分出的类别是否符合逻辑(如:分出的 3 类客户是否真的代表了高、中、低消费群)。
- 下游指标 :如果无监督学习是为了后续的预测任务(如先降维再分类),那么最终分类的准确率提升就是对无监督模型最好的评估。
总之,
监督学习VS半监督学习:是根据训练时是否带标签来讲的;
归纳式学习VS直推式学习:是根据训练时是否需要测试集数据来讲的。
3.图神经网络是直推式学习(Transductive Learning)
这是一个非常敏锐且极具价值的问题!你触碰到了图神经网络(GNN)与传统机器学习(如 CNN、RNN 或普通全连接网络)之间最大的区别之一。
简单直接的回答是:是的,你的观察完全正确。 在训练过程中,模型确实"看"到了测试集的节点特征(Features)和它们之间的连接关系(Edges)。
但是,这不算"作弊",也不会影响测试的公正性。
为什么呢?我们需要区分**"看到特征(Features)"和 "看到答案(Labels)"**的区别,并引入一个核心概念:直推式学习(Transductive Learning)。
1. 核心机制:只看"题目",不看"答案"
在 GCN 的训练过程中:
-
模型输入(Input):是整张图(所有节点的 X 和所有边 Edge_Index)。这意味着模型确实知道测试集节点长什么样(特征),以及它连着谁(结构)。
-
损失计算(Loss) :这是关键! 请看代码这一行:
loss = loss_function(out[data.train_mask], data.y[data.train_mask])虽然模型输出了所有 节点的预测结果
out(包括训练集、验证集、测试集),但在计算梯度(反向传播)来更新参数时,我们只用了训练集节点(data.train_mask)的预测误差。
比喻:
想象你在参加一场开卷考试,任务是推断所有人的"政治倾向"(Label)。
-
全图输入 :你可以看到教室里所有人(包括还没被点名的人)穿什么衣服(Feature),以及谁和谁在聊天(Structure)。
-
训练过程:老师只告诉你前排 10 个人的真实政治倾向(Train Label)。你根据这 10 个人的信息,以及他们和周围人的关系,来调整你的推断逻辑。
-
测试过程:对于后排的人(Test Set),虽然你早就看到了他们穿什么衣服、和谁聊天,但老师从来没告诉过你他们的真实倾向。你依然需要用训练好的逻辑去猜。
结论:模型利用了测试节点的特征作为"桥梁"来辅助训练节点的信息聚合,但从未利用测试节点的"标签"来优化参数。所以没有发生标签泄露(Label Leakage)。
2. 为什么要这样做?(图卷积的原理)
如果不把测试集放进去,GCN 就"废"了一半。这与 GCN 的**消息传递(Message Passing)**机制有关。
假设一个简单的场景:
-
节点 A(训练集,已知标签)
-
节点 B(测试集,未知标签)
-
连接关系:A <--> B
在 GCN 中,节点 A 的特征更新公式依赖于它的邻居。也就是说,为了算准 A 的特征,模型必须读取 B 的特征。
如果你在训练时强行把 B 删掉(Mask 掉):
-
破坏了图结构:A 变成了孤立点,图的连通性被切断了。
-
信息丢失:A 失去了来自 B 的重要上下文信息(比如 B 的特征可能暗示了 A 的类别)。
因此,在图神经网络中,测试集的节点充当了**"信息传递的桥梁 "或 "背景板"**,帮助训练集节点更好地学习自身的表示。
3. 专业术语:直推式学习 vs. 归纳式学习
你遇到的这种设定,在学术上称为 Transductive Learning(直推式学习)。
| 特性 | Transductive Learning (直推式) | Inductive Learning (归纳式) |
|---|---|---|
| 典型代表 | GCN (本代码), Label Propagation | GraphSAGE, GAT, 传统 CNN/RNN |
| 数据设定 | 训练和测试都在同一张大图上。 | 训练在一张图上,测试在完全不同、没见过的另一张图上。 |
| 测试节点可见性 | 训练时可见(仅特征和结构)。 | 训练时完全不可见。 |
| 应用场景 | 引用网络分类(Cora, CiteSeer)、社交网络分析(给定一张固定的图,填补未知的空)。 | 动态图、推荐系统(会有新用户不断加入,模型必须能处理从未见过的节点)。 |
4. 总结
在你的代码中:
-
数据加载 :
data包含了全图。 -
前向传播 :
out = model(data)计算了全图所有节点的 Embedding。此时测试节点的特征参与了运算(作为邻居被聚合了)。 -
计算 Loss :
loss = ... out[data.train_mask] ...这道"防火墙"保证了测试集的标签(答案)绝对没有被模型看到。
只要由于 train_mask 的存在,梯度(Gradient)只会从训练集的损失回传。测试集的预测结果虽然算出来了,但它对模型参数的更新贡献为 0。
所以,这种做法不仅是合法的,而且是处理单张大图(Single Graph)任务的标准范式。