圖機器學習(15)——鏈接預測在社交網絡分析中的應用
- 0. 鏈接預測
- 1. 數據處理
- 2. 基于 node2vec 的鏈路預測
- 3. 基于 GraphSAGE 的鏈接預測
- 3.1 無特征方法
- 3.2 引入節點特征
- 4. 用于鏈接預測的手工特征
- 5. 結果對比
0. 鏈接預測
如今,社交媒體已成為最具價值且豐富多元的信息源之一。每日涌現數十萬條新連接、無數用戶加入社群、數十億貼文被分享。這些自發性且非結構化的交互活動,通過圖結構得以數字化呈現,從而建立秩序化關聯。
在社交圖分析中,機器學習能有效解決諸多重要問題。通過合理配置,可從海量數據中提取關鍵洞察:優化營銷策略、識別危險行為用戶、預測用戶閱讀新帖的概率等。
其中,鏈路預測是該領域最具價值的研究方向之一。根據社交圖中連接關系的不同含義,預測未來邊可應用于好友推薦、電影推薦或商品購買預測等場景,該任務旨在評估節點間未來建立連接的可能性,可通過多種機器學習算法實現。
接下來,將介紹如何應用監督與無監督圖嵌入算法,繼續在 SNAP Facebook 社交圖上預測潛在連接,并評估節點特征對預測任務的貢獻度。
1. 數據處理
為執行鏈路預測任務,需對數據集進行預處理。該問題將作為監督學習任務處理:算法輸入為節點對,目標值為二元標簽——若節點在實際網絡中相連則標記為"已連接",否則標記為"未連接"。
由于采用監督學習框架,需創建訓練集與測試集。我們將生成兩個節點數相同但邊數不同的子圖(通過移除部分邊作為算法訓練/測試的正樣本):
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
中生成無監督節點嵌入:
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
為每對嵌入節點生成聯合特征向量,作為分類器輸入:
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) 采用基于決策樹的集成算法隨機森林進行分類訓練:
rf = RandomForestClassifier(n_estimators=10)
rf.fit(train_embeddings, labels_train)
(4) 應用訓練好的模型為測試集生成嵌入向量:
y_pred = rf.predict(test_embeddings)
(5) 使用訓練好的模型進行預測并輸出評估指標:
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
合并自我網絡提供強大的鏈路預測表征能力:
Precision: 0.971125
Recall: 0.9458242025809593
F1-Score: 0.9583076353768348
3. 基于 GraphSAGE 的鏈接預測
接下來,我們將使用 GraphSAGE
來學習節點嵌入并進行邊分類。我們將構建一個雙層 GraphSAGE
架構:該架構接收帶標簽的節點對作為輸入,輸出對應的節點嵌入向量。隨后,這些嵌入向量將通過一個全連接神經網絡進行處理,最終生成鏈路預測結果。需要注意的是,GraphSAGE
模型和全連接網絡串聯起來進行端到端訓練,使得嵌入學習階段能夠受到預測結果的反向傳播影響。
3.1 無特征方法
(1) GraphSAGE
需要節點描述符(特征),這些特征在數據集中可能有,也可能沒有。我們首先分析不考慮現有節點特征的情況。此時,常見的做法是為每個節點分配一個長度為 ∣V∣|V|∣V∣ (圖中節點總數)的獨熱編碼特征向量,其中只有對應節點的位置為 1
,其余位置為 0
:
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
的輸出)的對,使用二元運算符(在本節是內積)生成邊嵌入,最后將這些嵌入傳遞通過一個全連接神經網絡進行分類:
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 xclass 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
優化器,損失函數使用均方誤差:
in_channels = num_nodes # 輸入特征維度
hidden_channels = 20
out_channels = 20encoder = 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
:
# 訓練函數
def train():encoder.train()predictor.train()total_loss = 0for 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) 訓練完成后,評估模型性能:
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.7946Test Metrics:
Precision: 0.7404
Recall: 0.8197
F1-Score: 0.7780
可以看到,性能低于 node2vec
方法,但我們還沒有考慮真實的節點特征,而這些特征能夠提供重要信息,接下來我們引入真實節點特征。
3.2 引入節點特征
(1) 為合并的自我網絡提取節點特征的過程較為繁瑣,這是由于每個自我網絡都通過多個文件以及所有特征名稱和值來描述。編寫輔助函數來解析所有自我網絡以提取節點特征:
load_features
函數解析每個自我網絡并創建兩個字典:feature_index
,將數字索引映射到特征名稱inverted_feature_indexes
,將名稱映射到數字索引
def load_features():import globfeat_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 376index, name = parse_featname_line(line)feat_index[index] = namefeatname_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] = valindex_file.close()for key in feature_index.keys():val = feature_index[key]inverted_feature_index[val] = key
parse_nodes
函數接收組合的自我網絡G
和自我節點的ID
。然后,網絡中的每個自我節點被分配上通過load_features
函數加載的相應特征:
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 = 0for 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] + 1i += 1for 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 = 0for 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] + 1i += 1featname_file.close()feat_file.close()egofeat_file.close()edge_file.close()
(2) 接下來,調用這些函數以加載每個節點在組合自我網絡中的特征向量:
load_features()
parse_nodes(G, ego_nodes)
(3) 通過打印網絡中任意節點(如 ID
為 0
的節點)信息來檢查結果:
print(G.nodes[0])
輸出結果如下所示:
{'features': array([1., 1., 1., ..., 0., 0., 0.])}
可以看到,節點包含一個以 features
為鍵的字典,其對應值即為分配給該節點的特征向量。
(4) 接下來,按照之前的步驟重復訓練 GraphSAGE
模型,使用數據集中節點特征構建訓練數據:
# 準備節點特征
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
:
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 xclass 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 = 0for 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}')
輸出結果如下所示,可以看到,引入真實的節點特征模型性能發生了顯著的改進:
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) 表示的輸入邊,將考慮四個指標:
- 最短路徑:uuu 和 vvv 之間的最短路徑的長度。如果 uuu 和 vvv 通過一條邊直接連接,那么在計算最短路徑之前將移除這條邊;如果 uuu 不可從 vvv 到達,則使用值
0
表示 Jaccard
系數:給定一對節點 (u,v)(u,v)(u,v),定義為u和v的鄰居集合的交集與并集的比值。設 s(u)s(u)s(u) 表示節點 uuu 的鄰居集合,s(v)s(v)s(v) 表示節點 vvv 的鄰居集合,則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)?- uuu 中心度:計算節點 uuu 的度中心性
- vvv 中心度:計算節點 vvv 的度中心性
- uuu 社區:使用
Louvain
啟發式算法分配給節點 uuu 的社區ID
- vvv 社區:使用
Louvain
啟發式算法分配給節 vvv 的社區ID
(1) 編寫輔助函數,通過 Python
和 networkx
計算以上指標:
def get_shortest_path(G, u, v):"""返回節點u,v之間的最短路徑長度(臨時移除直接邊后計算)"""removed = Falseif G.has_edge(u, v):removed = TrueG.remove_edge(u, v) # 臨時移除邊try:sp = len(nx.shortest_path(G, u, v))except:sp = 0if removed:G.add_edge(u, v) # 恢復被移除的邊return spdef 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) 接下來,計算訓練集和測試集上每條邊的特征:
# 轉換訓練和測試數據
feat_train = get_hc_features(graph_train, samples_train, labels_train)
feat_test = get_hc_features(graph_test, samples_test, labels_test)
(3) 將以上特征將直接作為輸入用于隨機森林分類器:
# 訓練隨機森林分類器
rf = RandomForestClassifier(n_estimators=10)
rf.fit(feat_train, labels_train)
(4) 計算模型性能:
# 預測并評估
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))
輸出結果如下所示:
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
等復雜算法造成干擾,特別是在引入節點特征時。舉例來說,兩個興趣相似的用戶本應建立連接,但由于分屬不同自我網絡(對應自我用戶可能身處世界兩端),最終并未產生連接。不過,這類算法可能實際上預測的是更長期的連接趨勢,由于聯合自我網絡只是特定時間段的快照,真實網絡可能早已演化出不同形態。
機器學習算法的可解釋性是領域內最富挑戰性的課題。因此,需要深入分析數據集,合理解讀結果表現。需要注意的是,本節未對任何算法進行超參數調優,通過適當調整完全可能獲得更優結果。