图机器学习(15)------链接预测在社交网络分析中的应用
-
- [0. 链接预测](#0. 链接预测)
- [1. 数据处理](#1. 数据处理)
- [2. 基于 node2vec 的链路预测](#2. 基于 node2vec 的链路预测)
- [3. 基于 GraphSAGE 的链接预测](#3. 基于 GraphSAGE 的链接预测)
-
- [3.1 无特征方法](#3.1 无特征方法)
- [3.2 引入节点特征](#3.2 引入节点特征)
- [4. 用于链接预测的手工特征](#4. 用于链接预测的手工特征)
- [5. 结果对比](#5. 结果对比)
0. 链接预测
如今,社交媒体已成为最具价值且丰富多元的信息源之一。每日涌现数十万条新连接、无数用户加入社群、数十亿贴文被分享。这些自发性且非结构化的交互活动,通过图结构得以数字化呈现,从而建立秩序化关联。
在社交图分析中,机器学习能有效解决诸多重要问题。通过合理配置,可从海量数据中提取关键洞察:优化营销策略、识别危险行为用户、预测用户阅读新帖的概率等。
其中,链路预测是该领域最具价值的研究方向之一。根据社交图中连接关系的不同含义,预测未来边可应用于好友推荐、电影推荐或商品购买预测等场景,该任务旨在评估节点间未来建立连接的可能性,可通过多种机器学习算法实现。
接下来,将介绍如何应用监督与无监督图嵌入算法,继续在 SNAP Facebook 社交图上预测潜在连接,并评估节点特征对预测任务的贡献度。
1. 数据处理
为执行链路预测任务,需对数据集进行预处理。该问题将作为监督学习任务处理:算法输入为节点对,目标值为二元标签------若节点在实际网络中相连则标记为"已连接",否则标记为"未连接"。
由于采用监督学习框架,需创建训练集与测试集。我们将生成两个节点数相同但边数不同的子图(通过移除部分边作为算法训练/测试的正样本):
python
import torch
from torch_geometric.data import Data
from torch_geometric.utils import train_test_split_edges, negative_sampling
from node2vec import Node2Vec
from sklearn.ensemble import RandomForestClassifier
from sklearn import metrics
import numpy as np
import networkx as nx
# 转换为 PyG 的 Data 格式用于边分割
edge_index = torch.tensor(list(G.edges)).t().contiguous()
data = Data(edge_index=edge_index, num_nodes=len(G.nodes))
# 1. 边分割
# 第一次分割:取出 10% 作为测试边
data_temp = train_test_split_edges(data, test_ratio=0.1, val_ratio=0)
test_pos_edge = data_temp.test_pos_edge_index.t().numpy() # [num_test_edges, 2]
test_neg_edge = data_temp.test_neg_edge_index.t().numpy() # [num_test_edges, 2]
# 第二次分割:从剩余的 90% 中再取 10% 作为训练边
remaining_edges = data_temp.train_pos_edge_index
n_remaining = remaining_edges.size(1)
n_train = int(0.9 * n_remaining) # 保留 90% 的剩余边
perm = torch.randperm(n_remaining)
train_pos_edge = remaining_edges[:, perm[:n_train]].t().numpy() # [num_train_edges, 2]
val_pos_edge = remaining_edges[:, perm[n_train:]].t().numpy() # [num_val_edges, 2]
# 生成训练集和验证集的负样本(使用 PyG 的 negative_sampling)
train_neg_edge = negative_sampling(
edge_index=remaining_edges[:, perm[:n_train]],
num_nodes=data.num_nodes,
num_neg_samples=n_train
).t().numpy()
val_neg_edge = negative_sampling(
edge_index=remaining_edges[:, perm[n_train:]],
num_nodes=data.num_nodes,
num_neg_samples=remaining_edges.size(1) - n_train
).t().numpy()
# 合并样本和标签
samples_train = np.vstack([train_pos_edge, train_neg_edge])
labels_train = np.hstack([np.ones(len(train_pos_edge)), np.zeros(len(train_neg_edge))])
samples_test = np.vstack([test_pos_edge, test_neg_edge])
labels_test = np.hstack([np.ones(len(test_pos_edge)), np.zeros(len(test_neg_edge))])
接下来,我们将介绍三种链接预测方法:
- 基于
node2vec
的无监督嵌入:通过node2vec
无监督学习训练图的节点嵌入,将生成的嵌入向量作为监督分类算法的输入特征,用于判断节点对是否实际相连 - 图神经网络
GraphSAGE
的端到端学习:采用基于图神经网络的GraphSAGE
算法,同步完成节点嵌入学习和分类任务 - 人工特征工程与节点
ID
结合:从图中提取人工设计的特征,结合节点ID
作为监督分类器的输入特征
2. 基于 node2vec 的链路预测
(1) 使用 node2vec
从训练图 graph_train
中生成无监督节点嵌入:
python
node2vec = Node2Vec(G, dimensions=128, walk_length=80, num_walks=10, workers=4)
model = node2vec.fit(window=10, min_count=1, batch_words=4)
(2) 通过 hadamard_embedding
为每对嵌入节点生成联合特征向量,作为分类器输入:
python
def hadamard_embedding(u, v):
return model.wv[str(u)] * model.wv[str(v)]
train_embeddings = np.array([hadamard_embedding(u, v) for u, v in samples_train])
test_embeddings = np.array([hadamard_embedding(u, v) for u, v in samples_test])
(3) 采用基于决策树的集成算法随机森林进行分类训练:
python
rf = RandomForestClassifier(n_estimators=10)
rf.fit(train_embeddings, labels_train)
(4) 应用训练好的模型为测试集生成嵌入向量:
python
y_pred = rf.predict(test_embeddings)
(5) 使用训练好的模型进行预测并输出评估指标:
python
print('Precision:', metrics.precision_score(labels_test, y_pred))
print('Recall:', metrics.recall_score(labels_test, y_pred))
print('F1-Score:', metrics.f1_score(labels_test, y_pred))
输出结果如下所示,可以看到基于 node2vec
的嵌入方法能为 Facebook
合并自我网络提供强大的链路预测表征能力:
shell
Precision: 0.971125
Recall: 0.9458242025809593
F1-Score: 0.9583076353768348
3. 基于 GraphSAGE 的链接预测
接下来,我们将使用 GraphSAGE
来学习节点嵌入并进行边分类。我们将构建一个双层 GraphSAGE
架构:该架构接收带标签的节点对作为输入,输出对应的节点嵌入向量。随后,这些嵌入向量将通过一个全连接神经网络进行处理,最终生成链路预测结果。需要注意的是,GraphSAGE
模型和全连接网络串联起来进行端到端训练,使得嵌入学习阶段能够受到预测结果的反向传播影响。
3.1 无特征方法
(1) GraphSAGE
需要节点描述符(特征),这些特征在数据集中可能有,也可能没有。我们首先分析不考虑现有节点特征的情况。此时,常见的做法是为每个节点分配一个长度为 ∣ V ∣ |V| ∣V∣ (图中节点总数)的独热编码特征向量,其中只有对应节点的位置为 1
,其余位置为 0
:
python
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch_geometric.data import Data
from torch_geometric.nn import SAGEConv
from torch_geometric.loader import LinkNeighborLoader
from torch_geometric.utils import negative_sampling
from sklearn.metrics import precision_score, recall_score, f1_score
from torch_geometric.utils import train_test_split_edges, negative_sampling
import numpy as np
# 转换为 PyG 的 Data 格式
edge_index = torch.tensor(list(G.edges)).t().contiguous()
num_nodes = G.number_of_nodes()
data = Data(edge_index=edge_index, num_nodes=num_nodes)
# 边分割(训练/测试)
# 第一次分割:取出 10% 作为测试边
data_temp = train_test_split_edges(data, test_ratio=0.1, val_ratio=0)
test_pos_edge = data_temp.test_pos_edge_index
test_neg_edge = data_temp.test_neg_edge_index
# 第二次分割:从剩余的 90% 中取 10% 作为训练边(即原图的 9%)
remaining_edges = data_temp.train_pos_edge_index
n_remaining = remaining_edges.size(1)
n_train = int(0.9 * n_remaining) # 保留 90% 的剩余边(即原图的 81%)
perm = torch.randperm(n_remaining)
train_pos_edge = remaining_edges[:, perm[:n_train]]
val_pos_edge = remaining_edges[:, perm[n_train:]]
# 生成负样本
train_neg_edge = negative_sampling(
edge_index=train_pos_edge,
num_nodes=num_nodes,
num_neg_samples=train_pos_edge.size(1)
)
val_neg_edge = negative_sampling(
edge_index=val_pos_edge,
num_nodes=num_nodes,
num_neg_samples=val_pos_edge.size(1)
)
# 创建训练图和测试图
graph_train = Data(
edge_index=train_pos_edge,
num_nodes=num_nodes,
x=torch.eye(num_nodes) # 单位矩阵作为节点特征
)
graph_test = Data(
edge_index=test_pos_edge,
num_nodes=num_nodes,
x=torch.eye(num_nodes)
)
# 准备训练/测试样本和标签
samples_train = torch.cat([train_pos_edge.t(), train_neg_edge.t()], dim=0).numpy()
labels_train = np.concatenate([np.ones(train_pos_edge.size(1)),
np.zeros(train_neg_edge.size(1))])
samples_test = torch.cat([test_pos_edge.t(), test_neg_edge.t()], dim=0).numpy()
labels_test = np.concatenate([np.ones(test_pos_edge.size(1)),
np.zeros(test_neg_edge.size(1))])
num_nodes = graph_train.num_nodes
graph_train.x = torch.eye(num_nodes) # 使用单位矩阵作为节点特征
graph_test.x = torch.eye(num_nodes)
# 创建数据加载器
batch_size = 64
num_neighbors = [4, 4] # 每层采样的邻居数
# 训练数据加载器
train_loader = LinkNeighborLoader(
data=graph_train,
num_neighbors=num_neighbors,
edge_label_index=torch.tensor(samples_train).t(),
edge_label=torch.tensor(labels_train),
batch_size=batch_size,
shuffle=True
)
# 测试数据加载器
test_loader = LinkNeighborLoader(
data=graph_test,
num_neighbors=num_neighbors,
edge_label_index=torch.tensor(samples_test).t(),
edge_label=torch.tensor(labels_test),
batch_size=batch_size,
shuffle=False
)
(2) 定义 GraphSAGE
模型,包含两个隐藏层,每个隐藏层的大小为 20
,且每个层都有偏置项,并添加了一个 Dropout
层以减少过拟合。然后,GraphSAGE
模块的输出与一个链接分类层连接,该层接收节点嵌入( GraphSAGE
的输出)的对,使用二元运算符(在本节是内积)生成边嵌入,最后将这些嵌入传递通过一个全连接神经网络进行分类:
python
class GraphSAGE(nn.Module):
def __init__(self, in_channels, hidden_channels, out_channels):
super().__init__()
self.conv1 = SAGEConv(in_channels, hidden_channels)
self.conv2 = SAGEConv(hidden_channels, out_channels)
self.dropout = nn.Dropout(0.3)
def forward(self, x, edge_index):
x = self.conv1(x, edge_index)
x = F.relu(x)
x = self.dropout(x)
x = self.conv2(x, edge_index)
return x
class LinkPredictor(nn.Module):
def __init__(self, in_channels):
super().__init__()
self.lin = nn.Linear(in_channels * 2, 1)
def forward(self, z_src, z_dst):
h = torch.cat([z_src, z_dst], dim=-1)
return torch.sigmoid(self.lin(h)).squeeze()
(3) 创建模型,使用 Adam
优化器,损失函数使用均方误差:
python
in_channels = num_nodes # 输入特征维度
hidden_channels = 20
out_channels = 20
encoder = GraphSAGE(in_channels, hidden_channels, out_channels)
predictor = LinkPredictor(out_channels)
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
encoder = encoder.to(device)
predictor = predictor.to(device)
optimizer = torch.optim.Adam(encoder.parameters(), lr=1e-3)
criterion = nn.MSELoss()
(4) 定义训练函数,并训练模型 10
个 epoch
:
python
# 训练函数
def train():
encoder.train()
predictor.train()
total_loss = 0
for batch in train_loader:
batch = batch.to(device)
optimizer.zero_grad()
h = encoder(batch.x, batch.edge_index)
pred = predictor(h[batch.edge_label_index[0]],
h[batch.edge_label_index[1]])
loss = criterion(pred, batch.edge_label.float())
loss.backward()
optimizer.step()
total_loss += loss.item()
return total_loss / len(train_loader)
# 测试函数
@torch.no_grad()
def test(loader):
encoder.eval()
predictor.eval()
y_pred, y_true = [], []
for batch in loader:
batch = batch.to(device)
h = encoder(batch.x, batch.edge_index)
pred = predictor(h[batch.edge_label_index[0]],
h[batch.edge_label_index[1]])
y_pred.append(pred.cpu())
y_true.append(batch.edge_label.cpu())
y_pred = torch.cat(y_pred).numpy()
y_true = torch.cat(y_true).numpy()
y_pred_binary = np.round(y_pred)
return {
'precision': precision_score(y_true, y_pred_binary),
'recall': recall_score(y_true, y_pred_binary),
'f1': f1_score(y_true, y_pred_binary)
}
# 训练循环
epochs = 10
for epoch in range(1, epochs + 1):
loss = train()
print(f'Epoch: {epoch:02d}, Loss: {loss:.4f}')
输出结果如下所示:
Epoch: 08, Loss: 0.1569
Epoch: 09, Loss: 0.1565
Epoch: 10, Loss: 0.1565
(5) 训练完成后,评估模型性能:
python
train_metrics = test(train_loader)
test_metrics = test(test_loader)
print("\nTraining Metrics:")
print(f'Precision: {train_metrics["precision"]:.4f}')
print(f'Recall: {train_metrics["recall"]:.4f}')
print(f'F1-Score: {train_metrics["f1"]:.4f}')
print("\nTest Metrics:")
print(f'Precision: {test_metrics["precision"]:.4f}')
print(f'Recall: {test_metrics["recall"]:.4f}')
print(f'F1-Score: {test_metrics["f1"]:.4f}')
输出结果如下所示:
Training Metrics:
Precision: 0.7633
Recall: 0.8286
F1-Score: 0.7946
Test Metrics:
Precision: 0.7404
Recall: 0.8197
F1-Score: 0.7780
可以看到,性能低于 node2vec
方法,但我们还没有考虑真实的节点特征,而这些特征能够提供重要信息,接下来我们引入真实节点特征。
3.2 引入节点特征
(1) 为合并的自我网络提取节点特征的过程较为繁琐,这是由于每个自我网络都通过多个文件以及所有特征名称和值来描述。编写辅助函数来解析所有自我网络以提取节点特征:
load_features
函数解析每个自我网络并创建两个字典:feature_index
,将数字索引映射到特征名称inverted_feature_indexes
,将名称映射到数字索引
python
def load_features():
import glob
feat_file_name = 'tmp.txt'
if not os.path.exists(feat_file_name):
feat_index = {}
featname_files = glob.iglob("facebook/*.featnames")
for featname_file_name in featname_files:
featname_file = open(featname_file_name, 'r')
for line in featname_file:
# example line:
# 0 birthday;anonymized feature 376
index, name = parse_featname_line(line)
feat_index[index] = name
featname_file.close()
keys = feat_index.keys()
keys = sorted(keys)
out = open(feat_file_name,'w')
for key in keys:
out.write("%d %s\n" % (key, feat_index[key]))
out.close()
index_file = open(feat_file_name,'r')
for line in index_file:
split = line.strip().split(' ')
key = int(split[0])
val = split[1]
feature_index[key] = val
index_file.close()
for key in feature_index.keys():
val = feature_index[key]
inverted_feature_index[val] = key
parse_nodes
函数接收组合的自我网络G
和自我节点的ID
。然后,网络中的每个自我节点被分配上通过load_features
函数加载的相应特征:
python
def parse_nodes(network, ego_nodes):
for node_id in ego_nodes:
featname_file = open(f'facebook/{node_id}.featnames','r')
feat_file = open(f'facebook/{node_id}.feat','r')
egofeat_file = open(f'facebook/{node_id}.egofeat','r')
edge_file = open(f'facebook/{node_id}.edges','r')
ego_features = [int(x) for x in egofeat_file.readline().split(' ')]
network.nodes[node_id]['features'] = np.zeros(len(feature_index))
i = 0
for line in featname_file:
key, val = parse_featname_line(line)
if ego_features[i] + 1 > network.nodes[node_id]['features'][key]:
network.nodes[node_id]['features'][key] = ego_features[i] + 1
i += 1
for line in feat_file:
featname_file.seek(0)
split = [int(x) for x in line.split(' ')]
node_id = split[0]
features = split[1:]
network.nodes[node_id]['features'] = np.zeros(len(feature_index))
i = 0
for line in featname_file:
key, val = parse_featname_line(line)
if features[i] + 1 > network.nodes[node_id]['features'][key]:
network.nodes[node_id]['features'][key] = features[i] + 1
i += 1
featname_file.close()
feat_file.close()
egofeat_file.close()
edge_file.close()
(2) 接下来,调用这些函数以加载每个节点在组合自我网络中的特征向量:
python
load_features()
parse_nodes(G, ego_nodes)
(3) 通过打印网络中任意节点(如 ID
为 0
的节点)信息来检查结果:
python
print(G.nodes[0])
输出结果如下所示:
shell
{'features': array([1., 1., 1., ..., 0., 0., 0.])}
可以看到,节点包含一个以 features
为键的字典,其对应值即为分配给该节点的特征向量。
(4) 接下来,按照之前的步骤重复训练 GraphSAGE
模型,使用数据集中节点特征构建训练数据:
python
# 准备节点特征
node_features = []
node_id_map = {node_id: i for i, node_id in enumerate(G.nodes())}
num_nodes = len(node_id_map)
for node_id in G.nodes():
node_features.append(G.nodes[node_id]['features'])
node_features = np.array(node_features)
x = torch.tensor(node_features, dtype=torch.float)
# 准备边索引
edge_index = []
for u, v in G.edges():
edge_index.append([node_id_map[u], node_id_map[v]])
edge_index.append([node_id_map[v], node_id_map[u]]) # 无向图
edge_index = torch.tensor(edge_index, dtype=torch.long).t().contiguous()
# 创建初始Data对象
data = Data(x=x, edge_index=edge_index)
# 数据集划分(按照您指定的方式)
# 第一次分割:取出10%作为测试边
data_temp = train_test_split_edges(data, test_ratio=0.1, val_ratio=0)
test_pos_edge = data_temp.test_pos_edge_index
test_neg_edge = data_temp.test_neg_edge_index
# 第二次分割:从剩余的90%中取10%作为训练边(即原图的9%)
remaining_edges = data_temp.train_pos_edge_index
n_remaining = remaining_edges.size(1)
n_train = int(0.9 * n_remaining) # 保留90%的剩余边(即原图的81%)
perm = torch.randperm(n_remaining)
train_pos_edge = remaining_edges[:, perm[:n_train]]
val_pos_edge = remaining_edges[:, perm[n_train:]]
# 生成负样本
train_neg_edge = negative_sampling(
edge_index=train_pos_edge,
num_nodes=num_nodes,
num_neg_samples=train_pos_edge.size(1)
)
val_neg_edge = negative_sampling(
edge_index=val_pos_edge,
num_nodes=num_nodes,
num_neg_samples=val_pos_edge.size(1)
)
# 创建训练图和测试图
graph_train = Data(
x=data.x,
edge_index=train_pos_edge,
num_nodes=num_nodes
)
graph_test = Data(
x=data.x,
edge_index=test_pos_edge,
num_nodes=num_nodes
)
# 准备训练/测试样本和标签
samples_train = torch.cat([train_pos_edge.t(), train_neg_edge.t()], dim=0).numpy()
labels_train = np.concatenate([np.ones(train_pos_edge.size(1)),
np.zeros(train_neg_edge.size(1))])
samples_test = torch.cat([test_pos_edge.t(), test_neg_edge.t()], dim=0).numpy()
labels_test = np.concatenate([np.ones(test_pos_edge.size(1)),
np.zeros(test_neg_edge.size(1))])
# 创建数据加载器
batch_size = 64
num_neighbors = [4, 4] # 每层采样的邻居数
# 训练数据加载器
train_loader = LinkNeighborLoader(
data=graph_train,
num_neighbors=num_neighbors,
edge_label_index=torch.tensor(samples_train).t(),
edge_label=torch.tensor(labels_train),
batch_size=batch_size,
shuffle=True
)
# 测试数据加载器
test_loader = LinkNeighborLoader(
data=graph_test,
num_neighbors=num_neighbors,
edge_label_index=torch.tensor(samples_test).t(),
edge_label=torch.tensor(labels_test),
batch_size=batch_size,
shuffle=False
)
(5) 最后,创建模型,编译模型,并训练 10
个 epoch
:
python
class GraphSAGE(torch.nn.Module):
def __init__(self, in_channels, hidden_channels, out_channels):
super().__init__()
self.conv1 = SAGEConv(in_channels, hidden_channels)
self.conv2 = SAGEConv(hidden_channels, out_channels)
def forward(self, x, edge_index):
x = self.conv1(x, edge_index).relu()
x = self.conv2(x, edge_index)
return x
class LinkPredictor(torch.nn.Module):
def __init__(self, in_channels):
super().__init__()
self.lin = torch.nn.Linear(in_channels * 2, 1)
def forward(self, z_src, z_dst):
h = torch.cat([z_src, z_dst], dim=1)
return torch.sigmoid(self.lin(h)).squeeze() # 使用sigmoid将输出限制在[0,1]范围内
model = GraphSAGE(data.num_features, 64, 64)
predictor = LinkPredictor(64)
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = model.to(device)
predictor = predictor.to(device)
optimizer = torch.optim.Adam(
list(model.parameters()) + list(predictor.parameters()),
lr=0.001
)
# 训练和测试函数
def train():
model.train()
predictor.train()
total_loss = 0
for batch in train_loader:
batch = batch.to(device)
optimizer.zero_grad()
h = model(batch.x, batch.edge_index)
h_src = h[batch.edge_label_index[0]]
h_dst = h[batch.edge_label_index[1]]
pred = predictor(h_src, h_dst)
# 使用MSE损失
loss = torch.nn.functional.mse_loss(pred, batch.edge_label.float())
loss.backward()
optimizer.step()
total_loss += float(loss) * pred.size(0)
return total_loss / len(train_loader.dataset)
@torch.no_grad()
def test(loader):
model.eval()
predictor.eval()
preds, targets = [], []
for batch in loader:
batch = batch.to(device)
h = model(batch.x, batch.edge_index)
h_src = h[batch.edge_label_index[0]]
h_dst = h[batch.edge_label_index[1]]
pred = predictor(h_src, h_dst)
preds.append(pred.cpu().numpy())
targets.append(batch.edge_label.cpu().numpy())
preds = np.concatenate(preds, axis=0)
targets = np.concatenate(targets, axis=0)
preds_binary = (preds > 0.5).astype(int)
precision = metrics.precision_score(targets, preds_binary)
recall = metrics.recall_score(targets, preds_binary)
f1 = metrics.f1_score(targets, preds_binary)
return precision, recall, f1
# 8. 训练循环
for epoch in range(1, 11):
loss = train()
print(f'Epoch: {epoch:02d}, Loss: {loss:.4f}')
train_precision, train_recall, train_f1 = test(train_loader)
test_precision, test_recall, test_f1 = test(test_loader)
print(f'Train Precision: {train_precision:.4f}, Recall: {train_recall:.4f}, F1: {train_f1:.4f}')
print(f'Test Precision: {test_precision:.4f}, Recall: {test_recall:.4f}, F1: {test_f1:.4f}')
输出结果如下所示,可以看到,引入真实的节点特征模型性能发生了显著的改进:
python
Epoch: 10, Loss: 0.2299
Train Precision: 0.7927, Recall: 0.6746, F1: 0.7289
Test Precision: 0.8746, Recall: 0.3787, F1: 0.5285:
最后,我们将评估一种浅层嵌入方法,该方法将使用手工构建的特征来训练监督分类器。
4. 用于链接预测的手工特征
浅层嵌入方法是处理监督任务的一种简单而强大的方法。本质上,对于每条输入边,我们将计算一组指标作为分类器的输入特征。
在本节中,对于每个由节点对 ( u , v ) (u,v) (u,v) 表示的输入边,将考虑四个指标:
- 最短路径: u u u 和 v v v 之间的最短路径的长度。如果 u u u 和 v v v 通过一条边直接连接,那么在计算最短路径之前将移除这条边;如果 u u u 不可从 v v v 到达,则使用值
0
表示 Jaccard
系数:给定一对节点 ( u , v ) (u,v) (u,v),定义为u和v的邻居集合的交集与并集的比值。设 s ( u ) s(u) s(u) 表示节点 u u u 的邻居集合, s ( v ) s(v) s(v) 表示节点 v v v 的邻居集合,则Jaccard
系数为:
j ( u , v ) = s ( u ) ∩ s ( v ) s ( u ) ∪ s ( v ) j(u,v)=\frac {s(u)\cap s(v)}{s(u)\cup s(v)} j(u,v)=s(u)∪s(v)s(u)∩s(v)- u u u 中心度:计算节点 u u u 的度中心性
- v v v 中心度:计算节点 v v v 的度中心性
- u u u 社区:使用
Louvain
启发式算法分配给节点 u u u 的社区ID
- v v v 社区:使用
Louvain
启发式算法分配给节 v v v 的社区ID
(1) 编写辅助函数,通过 Python
和 networkx
计算以上指标:
python
def get_shortest_path(G, u, v):
"""返回节点u,v之间的最短路径长度(临时移除直接边后计算)"""
removed = False
if G.has_edge(u, v):
removed = True
G.remove_edge(u, v) # 临时移除边
try:
sp = len(nx.shortest_path(G, u, v))
except:
sp = 0
if removed:
G.add_edge(u, v) # 恢复被移除的边
return sp
def get_hc_features(pyg_data, samples_edges, labels):
# 将PyG图转换为NetworkX图
G = to_networkx(pyg_data, to_undirected=True)
# 预计算指标
centralities = nx.degree_centrality(G)
parts = community_louvain.best_partition(G)
feats = []
for (u, v), l in zip(samples_edges, labels):
shortest_path = get_shortest_path(G, u, v)
j_coefficient = next(nx.jaccard_coefficient(G, ebunch=[(u, v)]))[-1]
u_centrality = centralities[u]
v_centrality = centralities[v]
u_community = parts.get(u)
v_community = parts.get(v)
# 添加特征向量
feats.append([shortest_path, j_coefficient, u_centrality, v_centrality])
return np.array(feats)
(2) 接下来,计算训练集和测试集上每条边的特征:
python
# 转换训练和测试数据
feat_train = get_hc_features(graph_train, samples_train, labels_train)
feat_test = get_hc_features(graph_test, samples_test, labels_test)
(3) 将以上特征将直接作为输入用于随机森林分类器:
python
# 训练随机森林分类器
rf = RandomForestClassifier(n_estimators=10)
rf.fit(feat_train, labels_train)
(4) 计算模型性能:
python
# 预测并评估
y_pred = rf.predict(feat_test)
print('Precision:', metrics.precision_score(labels_test, y_pred))
print('Recall:', metrics.recall_score(labels_test, y_pred))
print('F1-Score:', metrics.f1_score(labels_test, y_pred))
输出结果如下所示:
shell
Precision: 0.9636952636282395
Recall: 0.9777853337866939
F1-Score: 0.9706891701828411
5. 结果对比
我们训练了三种算法(含监督/无监督)来学习适用于链接预测的嵌入表示。结果汇总如下:
算法 | 嵌入 | 节点特征 | 准确率 | 召回率 | F1 分数 |
---|---|---|---|---|---|
node2vec | 无监督 | 无 | 0.97 | 0.95 | 0.96 |
GraphSAGE | 含监督 | 无 | 0.74 | 0.81 | 0.77 |
GraphSAGE | 含监督 | 有 | 0.87 | 0.37 | 0.52 |
浅层方法 | 手工特征 | 无 | 0.96 | 0.98 | 0.9 |
如上表所示,基于 node2vec
的方法无需监督学习和节点信息就能实现较高预测性能。这种优异表现与联合自我网络的特殊结构有关。由于该网络具有高度子模块化特性(由多个自我网络组成),预测两个用户是否连接很可能与这两个候选节点在网络内部的连接方式密切相关。
例如,可能存在一种系统性规律:当两个用户都连接到同一自我网络中的多个用户时,他们彼此连接的概率也很高。反之,属于不同自我网络或相距甚远的用户则不太可能产生连接,这使得预测任务相对简单。这一推断也得到浅层方法优异表现的佐证。
然而,这种网络特性可能对 GraphSAGE
等复杂算法造成干扰,特别是在引入节点特征时。举例来说,两个兴趣相似的用户本应建立连接,但由于分属不同自我网络(对应自我用户可能身处世界两端),最终并未产生连接。不过,这类算法可能实际上预测的是更长期的连接趋势,由于联合自我网络只是特定时间段的快照,真实网络可能早已演化出不同形态。
机器学习算法的可解释性是领域内最富挑战性的课题。因此,需要深入分析数据集,合理解读结果表现。需要注意的是,本节未对任何算法进行超参数调优,通过适当调整完全可能获得更优结果。