简介:这份链路预测代码资源面向网络分析、复杂系统与图数据挖掘方向的学习者和开发者,帮助通过已知网络结构预测节点间未知连接或潜在关系,可应用于社交网络、信息网络与生物网络等场景。压缩包共23个文件,以10个Python脚本为核心,覆盖相似度指标、矩阵分解、机器学习与图神经网络等常见实现路径,另含6个XML配置、4个pyc编译文件、2张训练过程PNG图及1个iml工程文件,整体约88KB,结构紧凑便于快速阅读与二次开发。资源已有2849人学习下载,热度较高。代码从数据读取、清洗、转换到模型构建、训练与评估形成较完整流程,并涉及精度、召回率、F1与AUC-ROC等评价指标,读者可据此理解链路预测的技术脉络,将其迁移到实际网络分析任务中。
1. 链路预测代码:从邻接矩阵到可复现实验,中间隔着多少坑
社交网络里推荐“你可能认识的人”、电商里猜“你下一件想买的商品”、生物网络里推断“这两个蛋白有没有相互作用”,底层都绕不开同一个任务:链路预测。它的输入是一张已知的图,输出是那些还没连上的节点对之间“应该连上”的概率排序。很多人第一次写链路预测代码,以为把邻接矩阵丢进某个库、调一个函数就完事,结果跑出来的 AUC 只有 0.5 出头,跟抛硬币没区别。问题往往不在模型,而在数据划分、负采样和评估这三步里埋的雷。这篇笔记面向已经会写 Python、用过 NetworkX 或 PyTorch Geometric、但还没把链路预测跑出稳定结果的工程师,把从邻接矩阵到可复现实验的完整路径拆开讲,重点放在参数怎么设、坑在哪、失败时看什么。
2. 链路预测代码的骨架:数据、划分、负采样三件事
链路预测代码看起来五花八门,但骨架高度一致:读图得到边列表,划分训练集和测试集,为训练集生成负样本,训练一个打分模型,在测试集上评估。真正决定成败的是划分策略和负采样方式,模型反而排在第三位。下面按这个顺序把每一步落到代码。
2.1 从边列表构建邻接矩阵与特征
大多数公开数据集给的是边列表(source, target 两列),第一步是转成模型能吃的结构。小图直接用稠密邻接矩阵,大图必须用稀疏矩阵,否则内存直接爆。
import numpy as np import scipy.sparse as sp import networkx as nx def load_graph(edge_index, num_nodes): """ edge_index: shape (2, E) 的 numpy 数组,每列是一条边 num_nodes: 节点总数,决定邻接矩阵维度 返回:稀疏邻接矩阵 A(对称化后) """ # 用 COO 格式构造,避免中间稠密矩阵 rows = edge_index[0] cols = edge_index[1] data = np.ones(len(rows), dtype=np.float32) A = sp.coo_matrix((data, (rows, cols)), shape=(num_nodes, num_nodes)) # 无向图必须对称化,否则邻居聚合只看到一半边 A = A + A.T # 去掉重复边,避免权重被重复累加 A = A.tocsr() A[A > 1] = 1.0 return A def build_features(A): """ 最朴素的节点特征:行归一化后的邻接行 实际项目里可以换成度、PageRank、节点属性拼接 """ deg = np.asarray(A.sum(axis=1)).flatten() deg[deg == 0] = 1.0 # 防止孤立节点除零 D_inv = sp.diags(1.0 / deg) return D_inv @ A这段代码里有两个容易忽略的点。第一,对称化必须在去重之前做,否则 A + A.T 会把原本的单向边变成权重 2,再去重才正确。第二,行归一化时孤立节点的度是 0,直接取倒数会得到 inf,必须兜底成 1。特征构建这一步很多人直接跳过,用单位矩阵当特征,结果模型学不到任何结构信息,AUC 自然上不去。
2.2 边划分:随机划分为什么会让结果虚高
链路预测的测试集必须是“训练时不可见的边”。常见错误是先在全图上算特征,再随机划分边,这样测试边的信息已经泄漏进特征里了。正确做法是先删边、再算特征。
def split_edges(edge_index, num_nodes, val_ratio=0.1, test_ratio=0.1, seed=42): """ 按边划分,返回训练/验证/测试三组边 关键:测试边从图中删除后,训练集的特征只能基于剩余边计算 """ rng = np.random.default_rng(seed) E = edge_index.shape[1] perm = rng.permutation(E) n_test = int(E * test_ratio) n_val = int(E * val_ratio) test_idx = perm[:n_test] val_idx = perm[n_test:n_test + n_val] train_idx = perm[n_test + n_val:] train_edges = edge_index[:, train_idx] val_edges = edge_index[:, val_idx] test_edges = edge_index[:, test_idx] # 用训练边重建图,后续特征只能基于这个图 A_train = load_graph(train_edges, num_nodes) return train_edges, val_edges, test_edges, A_train参数上,验证集和测试集各占 10% 是常见起点,边数少于 5000 的小图建议降到 5%,否则训练边太少,图结构被破坏得太厉害。seed 必须固定,否则每次跑出来的 AUC 波动能到 0.05 以上,根本没法比较模型。这里有个血泪经验:如果数据集本身有重复边或自环,划分前一定要先去重和去自环,否则测试集里会出现训练集里已经存在的边,评估结果虚高得离谱。
2.3 负采样:1:1 还是 1:5,差别比你想的大
链路预测的训练数据是正边加负边。负边从“不存在的节点对”里采样。采样比例和采样分布直接决定模型学到的决策边界。
def negative_sampling(edge_index, num_nodes, num_neg=1, seed=42): """ 为每条正边采样 num_neg 条负边 返回负边 edge_index,shape (2, E * num_neg) """ rng = np.random.default_rng(seed) pos_set = set(map(tuple, edge_index.T.tolist())) neg_src, neg_dst = [], [] E = edge_index.shape[1] for i in range(E): u, v = edge_index[0, i], edge_index[1, i] count = 0 while count < num_neg: # 均匀采样一个目标节点 w = rng.integers(0, num_nodes) if w == u: continue if (u, w) in pos_set or (w, u) in pos_set: continue neg_src.append(u) neg_dst.append(w) count += 1 return np.array([neg_src, neg_dst])num_neg 设 1 是最常见的起点,训练快、正负均衡。但正边稀疏的图(比如平均度小于 3)建议设到 3 到 5,否则模型见到的负样本太少,容易把“没见过的边”全判成负。采样分布上,均匀采样是最简单的,但真实场景里负边往往集中在高度节点附近,用度分布加权的负采样更贴近实际。注意 while 循环里必须检查 (w, u) 和 (u, w) 两个方向,无向图里这两个都算已存在边,只查一个方向会漏掉一半,导致负样本里混进正边。
3. 打分模型:从启发式到图神经网络,怎么选不翻车
骨架搭好之后,核心是选一个打分函数,给节点对输出一个分数。启发式方法(共同邻居、Adamic-Adar、资源分配)不需要训练,几行代码就能跑;图神经网络(GCN、GraphSAGE、SEAL)表达能力强,但训练成本和调参难度高一个量级。选哪个取决于图的规模、稀疏程度和你能接受的实验周期。
3.1 启发式指标:三行代码的基线不能跳过
任何链路预测实验都应该先跑启发式基线,它决定了你的 GNN 到底有没有真正学到东西。如果 GNN 跑不过共同邻居,说明模型或训练流程有问题。
def common_neighbors_score(A, u, v): """共同邻居数:A[u] 和 A[v] 的行内积""" return float(A[u].multiply(A[v]).sum()) def adamic_adar_score(A, u, v): """Adamic-Adar:共同邻居按度对数加权""" common = A[u].multiply(A[v]).toarray().flatten() deg = np.asarray(A.sum(axis=1)).flatten() weights = np.zeros_like(common) mask = common > 0 # 度必须大于 1,否则 log(1)=0 会丢信息 weights[mask] = 1.0 / np.log(np.maximum(deg[mask], 2)) return float((common * weights).sum()) def resource_allocation_score(A, u, v): """资源分配:共同邻居按度直接倒数加权,比 AA 更激进""" common = A[u].multiply(A[v]).toarray().flatten() deg = np.asarray(A.sum(axis=1)).flatten() weights = np.zeros_like(common) mask = common > 0 weights[mask] = 1.0 / np.maximum(deg[mask], 1) return float((common * weights).sum())这三个指标里,Adamic-Adar 在多数社交网络和引文网络上表现最稳,资源分配在度分布极不均匀的图上偶尔反超。注意 deg 的兜底:Adamic-Adar 里度小于 2 的节点取 log 会得到 0 或负数,必须用 max(deg, 2) 兜底。共同邻居在稀疏图上经常出现大量 0 分,导致 AUC 虚高,因为负样本也大量是 0 分,排序区分度反而下降。
3.2 图神经网络打分器:编码器加内积解码器
GNN 做链路预测的标准范式是:用 GCN 或 GraphSAGE 编码节点得到 embedding,再用内积或 MLP 解码节点对分数。下面是一个最小可跑的 PyTorch 实现。
import torch import torch.nn as nn import torch.nn.functional as F class GCNEncoder(nn.Module): def __init__(self, in_dim, hidden_dim, out_dim, num_layers=2, dropout=0.5): super().__init__() self.layers = nn.ModuleList() dims = [in_dim] + [hidden_dim] * (num_layers - 1) + [out_dim] for i in range(num_layers): self.layers.append(nn.Linear(dims[i], dims[i + 1])) self.dropout = dropout def forward(self, x, adj_norm): # adj_norm 是归一化后的稀疏邻接矩阵,已转为 torch.sparse for i, layer in enumerate(self.layers): x = torch.sparse.mm(adj_norm, x) x = layer(x) if i < len(self.layers) - 1: x = F.relu(x) x = F.dropout(x, p=self.dropout, training=self.training) return x class LinkPredictor(nn.Module): def __init__(self, encoder): super().__init__() self.encoder = encoder def decode(self, z, edge_index): # 内积解码:z[u] 和 z[v] 的点积 return (z[edge_index[0]] * z[edge_index[1]]).sum(dim=1) def forward(self, x, adj_norm, edge_index): z = self.encoder(x, adj_norm) return self.decode(z, edge_index)参数上,hidden_dim 设 64 或 128 是常见起点,num_layers 设 2 层,超过 3 层在链路预测上几乎必然过平滑,AUC 反而下降。dropout 设 0.5 是标准值,图小的时候可以降到 0.3。adj_norm 的归一化方式很关键:用 D^{-1/2} A D^{-1/2} 对称归一化比 D^{-1} A 行归一化更稳,后者在训练后期容易梯度爆炸。训练时正负样本要拼在一起算 BCE 损失,不要分开算再平均,否则正负比例一变,损失尺度就变了。
3.3 训练循环与早停:验证集 AUC 不涨就停
训练循环里最容易翻车的是没有早停,模型在训练集上 AUC 冲到 0.99,验证集上 0.7 就开始往下掉。
def train_epoch(model, optimizer, x, adj_norm, pos_edges, neg_edges): model.train() optimizer.zero_grad() pos_logits = model(x, adj_norm, pos_edges) neg_logits = model(x, adj_norm, neg_edges) # 正样本标签 1,负样本标签 0 logits = torch.cat([pos_logits, neg_logits]) labels = torch.cat([torch.ones_like(pos_logits), torch.zeros_like(neg_logits)]) loss = F.binary_cross_entropy_with_logits(logits, labels) loss.backward() optimizer.step() return loss.item() def evaluate_auc(model, x, adj_norm, pos_edges, neg_edges): model.eval() with torch.no_grad(): pos_scores = model(x, adj_norm, pos_edges).cpu().numpy() neg_scores = model(x, adj_norm, neg_edges).cpu().numpy() from sklearn.metrics import roc_auc_score scores = np.concatenate([pos_scores, neg_scores]) labels = np.concatenate([np.ones_like(pos_scores), np.zeros_like(neg_scores)]) return roc_auc_score(labels, scores)早停的 patience 设 20 到 50 个 epoch,具体看数据集大小。学习率用 0.01 配 Adam 是安全起点,图特别大时降到 0.001。注意评估时负样本要和训练时的负采样分布一致,如果训练用均匀采样、评估用度加权采样,AUC 会虚高,因为模型在评估时见到的负样本更容易区分。这个坑我踩过不止一次,排查了半天才发现是评估负样本分布的问题。
4. 链路预测代码的避坑清单:五个真实翻车现场
这一章把前面散落的坑集中起来,每条按现象、原因、解决写清楚。这些坑在公开数据集上不一定暴露,但一换到自己的业务图,几乎必然遇到。
4.1 现象:AUC 稳定在 0.5,模型完全不收敛
原因通常是特征和标签对不上。最常见的是节点编号不连续,比如边列表里节点 ID 是 1001 到 2000,但邻接矩阵按 0 到 999 建的,导致所有边都指向了错误的节点。另一个原因是邻接矩阵没有对称化,无向图被当成有向图处理,邻居聚合只看到一半信息。
解决:建图前先做节点 ID 重映射,用字典把原始 ID 映射到 0 到 N-1。对称化在 load_graph 里已经做了,但如果你用的是自己写的建图逻辑,务必检查 A[u][v] 和 A[v][u] 是否相等。排查时打印前 10 条边的两个端点,看它们在邻接矩阵里是否有非零值。
4.2 现象:训练集 AUC 0.95,验证集 0.65,差距巨大
原因是过拟合,但链路预测的过拟合往往不是模型太大,而是负采样泄漏。如果负采样时没有排除测试集里的边,模型在训练时已经见过测试边作为负样本,验证时这些边变成正样本,模型自然判错。
解决:负采样必须基于训练边构建的已存在边集合,测试边和验证边不能出现在这个集合里。另外检查特征构建是否用了全图,如果 A_train 之外还用了全图算 PageRank 之类的特征,测试边信息就泄漏了。把特征构建严格限制在训练图上,是消除这个差距的第一步。
4.3 现象:换了随机种子,AUC 波动超过 0.05
原因是图太小或负采样随机性太大。边数少于 2000 的图,单次划分的方差本身就大。负采样如果每次只采 1 条,方差也会叠加。
解决:固定所有随机源,包括 numpy、torch、python random 的种子。跑 5 到 10 次不同种子的实验,报告均值和标准差,而不是单次结果。如果标准差超过 0.02,说明数据集太小,需要考虑用交叉验证或者换更大的图。这个习惯能帮你避免“调参调了个寂寞”的情况。
4.4 现象:GNN 跑不过共同邻居基线
原因可能是过平滑、特征太弱或训练不充分。两层 GCN 在稀疏图上有时确实打不过 Adamic-Adar,这不是代码 bug,是方法本身的局限。
解决:先确认基线代码没问题,再检查 GNN 的 adj_norm 是否用了对称归一化、dropout 是否开在训练模式、学习率是否合适。如果都正常还是打不过,试试加残差连接或者换成 GraphSAGE 的均值聚合。有些图上启发式就是最优解,接受这个事实比硬调 GNN 更省时间。
4.5 现象:评估时 AUC 很高,但实际推荐结果全是高度节点
原因是评估指标和业务目标脱节。AUC 衡量的是全局排序,但业务上可能只关心每个节点的 Top-K 推荐。高度节点因为连接多,天然容易拿到高分,导致推荐结果集中在少数节点上。
解决:补充 HitRate@K 和 NDCG@K 指标,按节点分组评估。如果发现推荐结果集中度过高,在打分时对节点度做惩罚,或者用 Personalize PageRank 之类的个性化指标替代全局内积。评估指标选错,模型调得再好也是白费。
5. 把链路预测代码跑成可复现实验的几个硬习惯
前面四章把链路预测代码的骨架、模型和坑都过了一遍,最后落到一个具体技巧上:怎么让实验结果可复现、可比较、可迭代。这件事听起来像工程规范,但实际做起来,它决定了你是在积累还是在一遍遍重来。
第一个习惯是配置文件化。把 num_neg、hidden_dim、dropout、learning_rate、patience 这些参数全部写进一个 YAML 或 JSON,训练脚本只读配置,不硬编码。这样换参数不用改代码,实验记录也能直接存配置文件的哈希值。我一般会在配置里加一个 experiment_name 字段,输出目录按这个名字建,跑完自动存模型权重、评估指标和配置副本。下次想复现某个结果,直接拿配置重跑就行,不用回忆当时改了哪行代码。
第二个习惯是固定数据划分的种子,并且把划分结果存下来。链路预测的评估对划分极其敏感,同一个模型在不同划分上差 0.05 是常事。把 train/val/test 的边索引存成 npy 文件,所有模型用同一套划分,比较才有意义。如果要做交叉验证,就存多套划分,每套一个种子,报告均值和标准差。
第三个习惯是评估指标至少报三个:AUC、AP(平均精度)和 HitRate@50。AUC 对正负样本比例不敏感,AP 对正样本的排序更敏感,HitRate@50 更贴近 Top-K 推荐场景。三个指标一起看,能避免被单一指标的虚高骗过去。如果三个指标趋势一致,说明模型确实在学到东西;如果 AUC 涨但 HitRate 不涨,说明模型只是把负样本排得更低了,对实际推荐没帮助。
第四个习惯是给负采样加缓存。负采样在训练循环里如果每次重新采,一是慢,二是每次采的负样本不同,损失波动大。我一般会在每个 epoch 开始前采好这个 epoch 的负样本,存成 tensor,训练时直接切片。这样既快,又能保证同一个 epoch 内负样本一致,损失曲线更平滑。注意缓存不要跨 epoch 复用,否则模型会记住固定的负样本,泛化变差。
最后一个习惯是记录失败实验。跑挂的实验、AUC 只有 0.5 的实验,把配置和现象简单记一行。这些记录在调参时比成功实验更有用,因为你能快速排除掉已知的死路。我自己的习惯是在项目根目录放一个 experiments.md,每次跑完不管成败都追加一行:日期、配置摘要、AUC、备注。三个月后回头看,哪些参数区间有效、哪些纯属浪费时间,一目了然。
链路预测这个方向,代码本身不难,难的是把评估做干净、把坑排明白。上面这些习惯看着琐碎,但每一条都是踩过坑之后留下来的。希望帮到你。
本文还有配套的精品资源,点击获取