PyG入门必读:Cora/Citeseer/Pubmed数据集下载、处理与探索全攻略 刚接触图神经网络的人基本都绕不开PyTorch GeometricPyG这套库而只要开始跑PyG的官方示例你就一定会碰上Cora、Citeseer、Pubmed这三个名字。它们在大量GCN、GAT、GraphSAGE论文里作为标准benchmark反复出现也是我建议每个图学习新人最先下载、处理、探索的三份数据。原因很简单规模适中、类别清楚、自带半监督划分既能验证模型效果又不会因为数据太大把机器跑崩。这篇内容就围绕“下载、处理、探索”展开。我不打算只贴官网文档而是把从零到能跑实验的完整链路拆开讲Planetoid类到底替你做了什么Data对象里每个字段是什么含义三个数据集的统计差异在哪里以及当你需要自己解析类似原始文件时该怎么动手。你会看到完整的代码、输出和我在实际使用中踩过的坑。1. 环境准备与数据下载链路一个Planetoid类搞定三个数据集1.1 安装PyG时最容易出错的依赖关系先说环境。PyG本质上是对PyTorch的图结构扩展所以第一步永远是确认你已经装好了对应版本的PyTorch。我习惯按这个顺序来# 先确认torch已经装好且版本正常 python -c import torch; print(torch.__version__) # 再安装PyG pip install torch-geometric新版PyG在pip安装时一般会自动处理torch-scatter、torch-sparse、torch-cluster等扩展包的依赖关系。如果你照旧教程走看到一堆带-f的显式安装命令也不用慌那属于旧版推荐做法现在多数环境直接用pip install torch-geometric就能通。真正容易出问题的不是安装步骤而是“PyG版本和PyTorch版本不匹配”。比如你本地是PyTorch 2.1PyG却装成了半个月前针对旧版编译的依赖运行时大概率会报类似AttributeError: module torch has no attribute sparse的错误。遇到这种报错最简单的排查方式是把torch-geometric和四个扩展包一起升级到当前兼容版本pip install --upgrade torch-geometric pip install --upgrade torch-scatter torch-sparse torch-cluster torch-spline-conv装完用下面这段代码验证环境import torch import torch_geometric print(torch.__version__) print(torch_geometric.__version__) from torch_geometric.datasets import Planetoid print(PyG ready)我习惯把这一步放在所有实验脚本开头避免后面调试半天才发现是扩展依赖坏了。1.2 Planetoid是什么它替我们做了什么PyG提供了一堆内置数据集类Cora、Citeseer、Pubmed对应的就是Planetoid这个类。Planetoid既是论文里经典的半监督图分类方法也成了这三个数据集的统称。加载方式极其无脑from torch_geometric.datasets import Planetoid root ./data dataset_cora Planetoid(rootroot, nameCora) dataset_citeseer Planetoid(rootroot, nameCiteseer) dataset_pubmed Planetoid(rootroot, namePubmed) for ds in [dataset_cora, dataset_citeseer, dataset_pubmed]: data ds[0] print(f{ds.name}: {ds.num_classes}个类别, {ds.num_features}维特征) print(data) print()第一次运行时会看到程序在下载数据完成后你的root目录结构会变成这样data/ ├── raw/ │ ├── cora.cites │ ├── cora.content │ └── ... └── processed/ ├── cora.pt └── ...raw/里是原始文本文件processed/里是PyG解析好的Data对象序列化结果。第二次加载时PyG发现processed文件已经存在就会直接跳过下载和解析步骤。这也是Planetoid类最省心的地方你不需要关心原始文件格式不需要手工切训练验证测试集类内部已经按照原论文约定把mask划分好了。你拿到的dataset[0]就是一张可以直接送入GNN的图。1.3 网络受限时怎么手动安置原始文件下载失败是新手遇到最高频的问题。如果你发现程序一直卡在下载阶段或者长时间无响应先别急着怀疑代码。PyG下载的是公开发布在外部托管平台上的原始文件当你网络连不上该地址时进程就会一直挂在那。我的处理方式是彻底绕开自动下载手动从公开渠道获取这三个数据集的原始文件cora.content、cora.cites等。在本地root/raw/目录下把文件按名字放好。重新运行Planetoid(rootroot, nameCora)。PyG检测到raw文件已存在会跳过下载直接做解析。这个方法也叫“离线加载”后续如果你换了机器也不会被网络环境卡住。另外提醒一点如果之前下载到一半失败过最好把raw/和processed/目录都清空再重试否则残缺文件可能导致解析报错。提示不要为了省事直接把错误的文件塞进raw目录。PyG解析时会按固定格式读文件格式不对会立刻报错比如IndexError或ValueError: could not convert string to float。宁可多花两分钟检查文件完整度也别让错误数据留在缓存里反复坑你。2. 一个Data对象看懂三个数据集属性、稀疏与mask划分逻辑2.1 核心属性逐个拆解加载完成后dataset[0]返回的是一个Data对象。打印出来大概长这样Data(x[2708, 1433], edge_index[2, 10556], y[2708], train_mask[2708], val_mask[2708], test_mask[2708]) Data(x[3327, 3703], edge_index[2, 9104], y[3327], train_mask[3327], val_mask[3327], test_mask[3327]) Data(x[19717, 500], edge_index[2, 88648], y[19717], train_mask[19717], val_mask[19717], test_mask[19717])每个字段的含义如下x节点特征矩阵形状是[节点数, 特征维度]。Cora每个节点是1433维词袋向量Citeseer是3703维Pubmed是500维TF-IDF特征。edge_index边索引形状是[2, 边数]。第一行是边的起点第二行是边的终点属于COO稀疏格式。y每个节点的类别标签整型。train_mask、val_mask、test_mask布尔型向量长度等于节点数标记哪些节点用于训练、验证、测试。这三个数据集的统计对比如下数据集节点数原始引用关系数PyG中edge_index列数特征维度类别数Cora270854291055614337Citeseer33274732910437036Pubmed1971744338886485003我个人拿到一个新Data对象的第一反应是看edge_index.size(1)因为PyG里的边数和你从论文摘要里看到的“引用关系数”往往不同。原因是PyG在构造无向图时会把一条引用关系拆成两个方向都加进edge_index同时会用去重逻辑去掉重复边。所以在PyG里Cora的edge_index列数是10556而不是5429。你不需要记这个数字但一定要知道两者差异的来源。2.2 为什么edge_index比邻接矩阵更省很多从传统图论入门的朋友会问为什么PyG不直接存一个邻接矩阵因为邻接矩阵的存储复杂度是O(n²)。Pubmed有19717个节点如果是稠密邻接矩阵需要存的元素接近3.9亿个光用float表示就是15亿字节大约1.5GB。这还没算上矩阵运算的内存开销。edge_index用COO格式只存实际的边Pubmed在PyG里也就88648列每列两个整数数据量小好几个数量级。图神经网络里消息传递的基本操作就是根据edge_index把邻居特征聚合起来。你不用担心稀疏格式的运算效率PyG底层会调用稀疏矩阵算子来处理。也正因如此我强烈不建议新手在探索阶段随手调用to_dense_adj()把图转成稠密邻接矩阵。Cora只有2708个节点还能忍Pubmed转一次就可能直接内存溢出。真想看邻接矩阵挑一个小数据集或者做子图采样再转。2.3 train_mask/val_mask/test_mask的默认逻辑这三个mask是半监督节点分类的核心。传统机器学习通常是把数据按行切出训练集、验证集、测试集图神经网络处理的是整张图不能直接把某些节点删掉否则图结构就断了。所以PyG的做法是用布尔mask标记训练时只计算训练节点的损失但消息传递仍然会利用整张图的边。Planetoid类遵循的划分标准来自经典论文每个类别取20个节点作为训练集验证集500个节点测试集1000个节点。因此Cora的训练节点数是1407类×20Citeseer是120Pubmed是60。这里有个容易被忽略的点Pubmed整体有19717个节点但训练节点只有60个属于非常强的半监督设定。模型稍有不慎就会在验证集上过拟合这恰恰是引文网络benchmark的特点。你可以用一行代码验证mask划分是否合理for split in [train, val, test]: mask getattr(data, f{split}_mask) print(f{split}: {int(mask.sum())} 个节点, 占比 {mask.float().mean():.4f})输出会告诉你每个集合的节点数和占比。如果看到test占比超过30%也别奇怪原论文约定就是固定1000个不是按比例切分的。3. 数据探索度分布、连通分量与低维可视化3.1 基础统计从Data对象能直接拿到的信息数据集加载完别急着丢给模型。先花几分钟探索结构你会对数据有具体的感知。PyG的Data对象自带不少快捷方法print(data.num_nodes) print(data.num_edges) print(data.is_undirected()) print(data.contains_self_loops()) print(data.has_isolated_nodes())几个方法返回的信息很有用is_undirected()检查图是否无向。引文网络里的引用关系本质有方向但GCN等模型通常按无向图处理PyG默认做好了这个转换。contains_self_loops()检查是否存在自环。Cora、Citeseer、Pubmed原始数据都没有显式自环GCN实现里一般要自己加。has_isolated_nodes()检查是否存在孤立节点。Citeseer这类引文网络经过整理后仍然可能出现小连通分量或者孤立节点这会直接影响GNN消息传递效果。很多教程会忽略has_isolated_nodes()但它在真实数据里很重要。一个节点连一个邻居都没有聚合邻居特征时就会退化成一个空集模型只能靠自己的特征做判断这和正常节点学出来的表示空间差异巨大。3.2 度分布引文网络为什么很“不均匀”节点的“度”表示它有多少个邻居。用PyG自带的工具函数可以直接算from torch_geometric.utils import degree deg degree(data.edge_index[0], num_nodesdata.num_nodes) print(f最小度 {int(deg.min())}, 最大度 {int(deg.max())}, 平均度 {deg.mean():.2f})Cora打印出来大概是“最小度1、最大度168、平均度约3.9”这样的结果。少数论文节点被引次数极高大部分节点只有几个邻居。这就是典型的幂律分布少部分节点占了大量连接大量节点处于稀疏连接状态。有兴趣的话可以画一个log-log坐标的度分布图import matplotlib.pyplot as plt import numpy as np deg_np deg.numpy().astype(np.int64) counts np.bincount(deg_np) xs np.arange(len(counts))[counts 0] ys counts[counts 0] plt.loglog(xs, ys, o, markersize3) plt.xlabel(degree) plt.ylabel(number of nodes) plt.title(f{dataset_cora.name} degree distribution) plt.show()这种长尾分布也提醒你训练GNN时hub节点高度数节点会把大量邻居信息聚合到自己身上最终表示可能偏向“全局平均”。如果你在做节点分类高度数节点通常分类精度不错低度数节点反而难学。这是数据本身的结构特性不是模型的bug。3.3 连通性检查孤立节点与多连通区域接着做连通性分析。PyG的图可以方便地转成networkx对象import networkx as nx from torch_geometric.utils import to_networkx G to_networkx(data, to_undirectedTrue) components list(nx.connected_components(G)) print(f连通分量数量: {len(components)}) sizes sorted([len(c) for c in components], reverseTrue) print(f最大连通分量: {sizes[0]}) print(f第二大: {sizes[1] if len(sizes) 1 else 0})对Cora这类经过标准预处理的数据最大连通分量通常覆盖绝大多数节点。但Citeseer有时候会出现少量孤立点这些节点如果落在测试集里需要你通过add_self_loops让模型至少能聚合到自身特征否则消息传递完全失效。这里建议你动手处理一下from torch_geometric.utils import add_self_loops, remove_self_loops edge_index_no_loop, _ remove_self_loops(data.edge_index) edge_index_with_loop, _ add_self_loops(edge_index_no_loop) data_processed data.clone() data_processed.edge_index edge_index_with_loop自环不是“可有可无”的细节。GCN在数学推导里用了对称归一化邻接矩阵每一层的更新规则本身就包含“当前节点自身”的信息。如果不加自环节点在聚合时拿不到自己的原始特征表达能力直接打折扣。3.4 低维可视化PCA/t-SNE和特征空间探索图结构探索完之后还可以看看特征空间长什么样。原始特征维度动辄上千不适合直接画图常规做法是先降维再散点图from sklearn.decomposition import PCA from sklearn.manifold import TSNE X data.x.numpy() X_pca PCA(n_components50, random_state42).fit_transform(X) X_tsne TSNE(n_components2, random_state42, initpca, perplexity30).fit_transform(X_pca) plt.figure(figsize(8, 6)) colors data.y.numpy() plt.scatter(X_tsne[:, 0], X_tsne[:, 1], ccolors, cmaptab10, s2, alpha0.8) plt.title(f{dataset_cora.name} t-SNE visualization) plt.colorbar() plt.show()做这个可视化有两个意义。第一观察类别在原始特征空间是否已经分开。如果每个类别在t-SNE里都混成一团说明特征本身区分度就差模型需要花更多力气学邻居信息。第二跑完GNN之后把节点嵌入再做一次t-SNE对比前后变化能直观看到图结构带来的改善。需要注意的是Cora有2708个节点t-SNE通常几秒就能跑完Pubmed有19717个节点pca之后还是有点慢。建议先对Pubmed做随机子采样再可视化不然每次调参都要等很久。提示如果你只想快速看个大概可以用PCA(n_components2)直接画二维散点图速度比t-SNE快一个数量级。t-SNE更精致但也不宜把perplexity调得过大。4. 从原始文件到Data对象手写解析流程理解底层逻辑4.1 原始文件的真实样子PyG已经封装好了解析过程但你迟早会遇到“我要用自己的数据集”的情况。这时再回头看Cora的原始文件一切就清楚多了。cora.content的每一行代表一个节点用空格分隔格式是节点ID 特征1 特征2 ... 特征1433 类别名称也就是说第一列是节点ID中间1433列是词袋特征最后一列是类别名。cora.cites的每一行代表一条引用关系被引论文ID 引用论文ID这些ID和content文件里的ID是同一个体系。PyG要做的就是把字符串ID映射成连续的整数索引把类别字符串转成整数标签再根据cites文件构造edge_index。4.2 自己动手构造Data下面的代码展示了一个可复现的解析流程我把“按ID排序”“过滤无效边”“转成无向边”都考虑了进去import torch from torch_geometric.data import Data def parse_cora(content_file, cites_file, num_train_per_class20, num_val500, num_test1000, seed42): # 读取content node_ids [] features [] labels [] label2idx {} with open(content_file) as f: for line in f: parts line.strip().split() if len(parts) 2: continue node_id int(parts[0]) feats [float(v) for v in parts[1:-1]] label parts[-1] if label not in label2idx: label2idx[label] len(label2idx) node_ids.append(node_id) features.append(feats) labels.append(label2idx[label]) # 按node_id排序确保索引连续 order sorted(range(len(node_ids)), keylambda i: node_ids[i]) node_ids [node_ids[i] for i in order] features [features[i] for i in order] labels [labels[i] for i in order] id2pos {node_id: pos for pos, node_id in enumerate(node_ids)} # 读取cites构造无向边 src [] dst [] with open(cites_file) as f: for line in f: parts line.strip().split() if len(parts) 2: continue a, b int(parts[0]), int(parts[1]) if a not in id2pos or b not in id2pos: continue sa, sb id2pos[a], id2pos[b] if sa sb: continue src.append(sa) dst.append(sb) src.append(sb) dst.append(sa) edge_index torch.tensor([src, dst], dtypetorch.long) x torch.tensor(features, dtypetorch.float) y torch.tensor(labels, dtypetorch.long) data Data(xx, edge_indexedge_index, yy) # 划分mask这里用torch.manual_seed控制复现 torch.manual_seed(seed) train_mask torch.zeros(data.num_nodes, dtypetorch.bool) val_mask torch.zeros(data.num_nodes, dtypetorch.bool) test_mask torch.zeros(data.num_nodes, dtypetorch.bool) all_val_test torch.ones(data.num_nodes, dtypetorch.bool) for c in range(len(label2idx)): idx (data.y c).nonzero(as_tupleFalse).view(-1) perm idx[torch.randperm(idx.size(0))] train_idx perm[:num_train_per_class] train_mask[train_idx] True all_val_test[train_idx] False remaining all_val_test.nonzero(as_tupleFalse).view(-1) perm remaining[torch.randperm(remaining.size(0))] val_idx perm[:num_val] test_idx perm[num_val:num_val num_test] val_mask[val_idx] True test_mask[test_idx] True data.train_mask train_mask data.val_mask val_mask data.test_mask test_mask return data, label2idx核心逻辑其实就三句话读节点、读边、映射成索引。很多第一次接触图神经网络的读者会把注意力放在模型上认为数据加载是件小事。实际上如果你想复现一篇论文数据链路出问题是最隐蔽的。ID没排序、边数缺了一条、mask被污染都会让实验结论发生细微偏移。4.3 引入自环与特征归一化自定义解析得到的Data对象通常没有自环特征也未归一化。跑GCN之前我习惯把预处理集中在一起from torch_geometric.nn import NormalizeFeatures from torch_geometric.utils import add_self_loops data NormalizeFeatures()(data) edge_index, _ add_self_loops(data.edge_index) data.edge_index edge_index print(data)NormalizeFeatures做的是行归一化把每个节点的特征向量除以该向量的L1范数让所有节点特征的量级一致。对于词袋特征这种“一篇论文出现哪些词、频率多次”的计数型数据归一化能避免高频词主导整个表示。有一点必须提醒你当你给Planetoid传入transform参数时这个transform只在第一次处理数据集时生效并且会写入processed缓存文件。如果你已经处理过一次之后再怎么改transform加载到的都是缓存里的旧数据。要更新transform必须把processed/目录删掉再重新实例化。提示我在自定义解析时保留了label2idx这个返回值。它看起来不起眼但真实项目里你很可能需要知道“类别0”到底对应“神经网络”还是“强化学习”否则分析模型错例时连标签含义都对不上。5. 反复折腾后总结的坑与实用建议5.1 缓存、transform与复现PyG的缓存机制是一把双刃剑。好处显而易见加载快。坏处是你一旦修改了处理逻辑它不会自动感知。我遇到的典型场景是想尝试新的边增强方式比如给edge_index加入二阶邻居或去掉重复边。写好了新的transform运行后发现结果和旧代码完全一样。原因就是processed目录还在新transform根本没有被执行。所以我的操作习惯是每次改数据预处理之前先看一眼root/processed/有没有旧缓存明确需要重新处理时直接删除整个processed目录给root起不同的目录名让不同处理版本互不干扰比如./data_cora_raw、./data_cora_no_loop。如果涉及随机划分mask建议在创建数据集或解析函数前固定随机种子torch.manual_seed(42)不然你复现实验时会发现两次训练之间数据划分变了模型结果差异根本没法归因。5.2 常见报错排查我把实践里比较高频的问题整理成了一张表方便你对照排查现象常见原因处理建议创建Planetoid后一直卡住不打印原始文件下载受阻进程在网络请求阶段等待检查网络连通性手动放置raw文件离线加载加载后报错缺少val_mask属性PyG版本不一致、processed文件是旧版缓存删除processed目录重新加载或升级PyG指定transform后数据没有变化缓存存在transform未重新执行删除processed目录再实例化数据集to_dense_adj导致内存不足Pubmed等大图稠密化产生巨大矩阵避免稠密化使用稀疏格式或NeighborLoader数据加载后has_isolated_nodes()返回True原始引文网络存在孤立点或小连通分量调用add_self_loops确保节点能聚合自身特征GPU训练时报device不匹配忘记把data和模型放到同一设备统一执行data data.to(device)其中“device不匹配”这个坑几乎所有用PyG的新人都踩过。你从CPU构造数据然后突然把模型扔到cuda:0结果是模型在GPU数据还在CPU一forward就报错。正确做法是device torch.device(cuda if torch.cuda.is_available() else cpu) data data.to(device) model model.to(device) out model(data.x, data.edge_index)5.3 训练时与mask相关的细节半监督设定的评估办法和传统监督学习不太一样。我见过不少朋友在训练GCN时用test_mask做早停这是典型错误。测试集只能用来做最终评估调参和早停应该用val_mask。Cora、Citeseer、Pubmed的测试集都是固定1000个节点如果你反复用测试集调参模型会逐渐“见过”测试集最终报告的精度会虚高。还有一点是关于训练集规模。Cora训练集140个节点、Pubmed训练集60个节点都是很少的样本量。因此训练这些模型时损失函数只覆盖被标记节点loss F.cross_entropy(out[data.train_mask], data.y[data.train_mask])这个写法是半监督节点分类的标准姿势。如果误写成用全部节点计算损失那就退化成全监督了跑出来的结果没有benchmark意义。另外当你准备把这三个数据集作为实验benchmark时建议把模型训练的epochs、学习率、早停patience全部写死在配置文件里。因为数据集本身就是定死的唯一能“自由发挥”的就是mask的随机性和模型初始化。控制住变量论文里的复现对比才真的可比。最后再分享一点我的使用习惯这三个数据集虽然经典但在日常开发中往往只是起步工具。我每次新建图神经网络项目都会把“Planetoid加载Cora跑通”当成一次环境自检。如果这一步在10分钟内能跑通说明PyG、CUDA、扩展依赖都是好的如果这一步出问题先修环境再谈模型。另外一个小技巧数据探索阶段做过的统计比如度分布、连通分量数量、各类别节点数我会顺手保存成一个markdown文件放在项目目录下。图神经网络实验周期可能长达几周很多时候你回头再看代码早就忘了Citeseer有没有孤立点、Pubmed训练节点到底有几个。留一份数据体检报告在旁边能省掉大量重复探索的时间。希望这篇内容能帮你把PyG的数据链路彻底理清。Cora、Citeseer、Pubmed只是起点把“下载、处理、探索”这套方法吃透之后你换成任何自定义图数据集都不会慌。