使用 PyG 進行圖神經網絡的節點分類、鏈路預測和異常檢測

圖神經網絡 (Graph Neural Networks) 是一種針對圖結構數據 (如社交圖、網絡安全網絡或分子表示) 設計的機器學習算法。它在過去幾年裏發展迅速,被用於許多不同的應用程序。在這篇文章中我們將回顧 GNN 的基礎知識,然後使用 Pytorch Geometric 解決一些常見的主要問題,並討論一些算法和代碼的細節。

常見的圖神經網絡應用

GNN 可以用來解決各種與圖相關的機器學習問題:

在這篇文章中,我們將回顧節點分類、鏈接預測和異常檢測的相關知識和用 Pytorch Geometric 代碼實現這三個算法。

圖卷積

圖神經網絡在過去的幾年裏發展迅速,並且有許多的變體。在這些 GNN 變體中圖卷積網絡可能是最流行和最基本的算法。在本節中我們將對其進行回顧和介紹。

圖卷積是一種基於圖結構提取 / 彙總節點信息的有效方法。它是卷積神經網絡卷積運算的一個變體,卷積神經網絡通常用於解決圖像問題。

在圖像中,像素在網格中按結構排序,卷積操作中的過濾器或卷積核 (權重矩陣) 以預先確定的步幅在圖像上滑動。像素的鄰域由過濾器大小決定(下圖中過濾器大小爲 3 x 3,藍色過濾器中的 8 個灰色像素爲鄰域),過濾器中的加權像素值被聚合爲單個值。卷積運算的輸出比輸入圖像的尺寸小,但對輸入圖像有更高層次的視圖,這對預測圖像問題很有用,比如圖像分類。

在圖中,節點以非結構化的方式排序,節點之間的鄰域大小不同。圖卷積取給定節點 (下圖中的紅色節點) 及其鄰居 (藍圈內的灰色節點) 的節點特徵的平均值,計算更新後的節點表示值。通過這種卷積操作,節點表示捕獲局部的圖信息。

下圖顯示了更多關於圖卷積操作的細節。鄰居節點 (藍色) 的節點特徵和目標節點 (紅色) 的節點特徵被平均。然後與權重向量 (W) 相乘,其輸出更新目標節點的節點特徵(更新後的節點值也稱爲節點嵌入)。

對於那些相關的節點,節點特徵使用度矩陣的逆進行歸一化,然後再聚合而不是簡單的平均(原始論文公式 8 中提出)

這個卷積操作中需要注意的一點是,圖卷積的數量決定了節點特徵被聚合到每個節點的步數。在下圖中,第一個卷積將藍色節點的特徵聚合到橙色節點中,第二個卷積將綠色節點的特徵合併到橙色節點中。

可以看到卷積的數量決定了聚合的節點特徵有多遠

在接下來的幾節中,我們實現 GCN。但是在深入研究它們之前,先熟悉一下將要使用的數據集。

Cora - 基準數據集

Cora 數據集是一個論文引用網絡數據,包含 2708 篇科學論文。圖中的每個節點代表一篇論文,如果一篇論文引用另一篇論文,則有節點間有一條邊相連。

我們使用 PyG (Pytorch Geometric) 來實現 GCN, GCN 是 GNN 的流行庫之一。Cora 數據集也可以使用 PyG 模塊加載:

 from torch_geometric.datasets import Planetoid
 
 dataset = Planetoid(root='/tmp/Cora', name='Cora')
 graph = dataset[0]

Cora 數據集來源於 Pytorch Geometric 的 “Automating the Construction of Internet Portals with Machine Learning” 論文。

節點特徵和邊緣信息如下所示。節點特徵是 1433 個詞向量,表示每個出版物中的詞不存在 (0) 或存在 (1)。邊在鄰接列表中表示。

每個節點都是七個類別中的一個,這將就是分類的目標標籤

利用 NetworkX 庫可以實現圖數據的可視化。節點顏色代表不同的類。

 import random
 from torch_geometric.utils import to_networkx
 import networkx as nx
 
 def convert_to_networkx(graph, n_sample=None):
 
    g = to_networkx(graph, node_attrs=["x"])
    y = graph.y.numpy()
 
    if n_sample is not None:
        sampled_nodes = random.sample(g.nodes, n_sample)
        g = g.subgraph(sampled_nodes)
        y = y[sampled_nodes]
 
    return g, y
 
 
 def plot_graph(g, y):
 
    plt.figure(figsize=(9, 7))
    nx.draw_spring(g, node_size=30, arrows=False, node_color=y)
    plt.show()
     
     
 g, y = convert_to_networkx(graph, n_sample=1000)
 plot_graph(g, y)

節點分類

對於節點分類問題,可以使用 PyG 中的 RandomNodeSplit 模塊將節點分爲 train、valid 和 test(我替換數據中的原始分割掩碼,因爲它的訓練集太小了)。

 import torch_geometric.transforms as T
 
 split = T.RandomNodeSplit(num_val=0.1, num_test=0.2)
 graph = split(graph)

數據分割標記會被寫入到圖對象的掩碼屬性中 (參見下圖),而不是圖本身。這些掩碼用於訓練損失計算和模型評估,而圖卷積使用整個圖數據。

節點分類基線模型 (MLP)

在構建 GCN 之前,只使用節點特徵來訓練 MLP(多層感知器,即前饋神經網絡) 來創建一個基線性能。該模型忽略節點連接 (或圖結構),並試圖僅使用詞向量對節點標籤進行分類。模型類如下所示。它有兩個隱藏層 (Linear),帶有 ReLU 激活,後面是一個輸出層。

 import torch.nn as nn
 
 class MLP(nn.Module):
    def __init__(self):
        super().__init__()
        self.layers = nn.Sequential(
        nn.Linear(dataset.num_node_features, 64),
        nn.ReLU(),
        nn.Linear(64, 32),
        nn.ReLU(),
        nn.Linear(32, dataset.num_classes)
        )
 
    def forward(self, data):
        x = data.x # only using node features (x)
        output = self.layers(x)
        return output

我們用一個普通的 Pytorch 訓練 / 驗證流程來定義訓練和評估函數。

 def train_node_classifier(model, graph, optimizer, criterion, n_epochs=200):
 
    for epoch in range(1, n_epochs + 1):
        model.train()
        optimizer.zero_grad()
        out = model(graph)
        loss = criterion(out[graph.train_mask], graph.y[graph.train_mask])
        loss.backward()
        optimizer.step()
 
        pred = out.argmax(dim=1)
        acc = eval_node_classifier(model, graph, graph.val_mask)
 
        if epoch % 10 == 0:
            print(f'Epoch: {epoch:03d}, Train Loss: {loss:.3f}, Val Acc: {acc:.3f}')
 
    return model
 
 
 def eval_node_classifier(model, graph, mask):
 
    model.eval()
    pred = model(graph).argmax(dim=1)
    correct = (pred[mask] == graph.y[mask]).sum()
    acc = int(correct) / int(mask.sum())
 
    return acc
   
   
 mlp = MLP().to(device)
 optimizer_mlp = torch.optim.Adam(mlp.parameters(), lr=0.01, weight_decay=5e-4)
 criterion = nn.CrossEntropyLoss()
 mlp = train_node_classifier(mlp, graph, optimizer_mlp, criterion, n_epochs=150)
 
 test_acc = eval_node_classifier(mlp, graph, graph.test_mask)
 print(f'Test Acc: {test_acc:.3f}')

該模型的測試精度爲 73.2%。

GCN 進行節點分類

接下來,我們將對 GCN 進行訓練並將其性能與 MLP 進行比較。這裏使用的是一個非常簡單的模型,有兩個圖卷積層和它們之間的 ReLU 激活。此設置與論文原文相同 (公式 9)。

 from torch_geometric.nn import GCNConv
 import torch.nn.functional as F
 
 class GCN(torch.nn.Module):
    def __init__(self):
        super().__init__()
        self.conv1 = GCNConv(dataset.num_node_features, 16)
        self.conv2 = GCNConv(16, dataset.num_classes)
 
    def forward(self, data):
        x, edge_index = data.x, data.edge_index
 
        x = self.conv1(x, edge_index)
        x = F.relu(x)
        output = self.conv2(x, edge_index)
 
        return output
     
     
 gcn = GCN().to(device)
 optimizer_gcn = torch.optim.Adam(gcn.parameters(), lr=0.01, weight_decay=5e-4)
 criterion = nn.CrossEntropyLoss()
 gcn = train_node_classifier(gcn, graph, optimizer_gcn, criterion)
 
 test_acc = eval_node_classifier(gcn, graph, graph.test_mask)
 print(f'Test Acc: {test_acc:.3f}')

該模型的測試集準確度爲 88.0%。從 MLP 中獲得了大約 15% 的精度提高。

鏈接預測

鏈接預測比節點分類更復雜,因爲我們需要使用節點嵌入對邊緣進行預測。預測步驟大致如下:

該模型結構來源於變分圖自動編碼器中原始的鏈接預測實現。代碼改編自 PyG repo 中的代碼示例,並基於 Graph Auto-Encoders 實現

 from sklearn.metrics import roc_auc_score
 from torch_geometric.utils import negative_sampling
 
 
 class Net(torch.nn.Module):
    def __init__(self, in_channels, hidden_channels, out_channels):
        super().__init__()
        self.conv1 = GCNConv(in_channels, hidden_channels)
        self.conv2 = GCNConv(hidden_channels, out_channels)
 
    def encode(self, x, edge_index):
        x = self.conv1(x, edge_index).relu()
        return self.conv2(x, edge_index)
 
    def decode(self, z, edge_label_index):
        return (z[edge_label_index[0]] * z[edge_label_index[1]]).sum(
            dim=-1
        ) # product of a pair of nodes on each edge
 
    def decode_all(self, z):
        prob_adj = z @ z.t()
        return (prob_adj > 0).nonzero(as_tuple=False).t()
     
 
 def train_link_predictor(
    model, train_data, val_data, optimizer, criterion, n_epochs=100
 ):
 
    for epoch in range(1, n_epochs + 1):
 
        model.train()
        optimizer.zero_grad()
        z = model.encode(train_data.x, train_data.edge_index)
 
        # sampling training negatives for every training epoch
        neg_edge_index = negative_sampling(
            edge_index=train_data.edge_index, num_nodes=train_data.num_nodes,
            num_neg_samples=train_data.edge_label_index.size(1), method='sparse')
 
        edge_label_index = torch.cat(
            [train_data.edge_label_index, neg_edge_index],
            dim=-1,
        )
        edge_label = torch.cat([
            train_data.edge_label,
            train_data.edge_label.new_zeros(neg_edge_index.size(1))
        ], dim=0)
 
        out = model.decode(z, edge_label_index).view(-1)
        loss = criterion(out, edge_label)
        loss.backward()
        optimizer.step()
 
        val_auc = eval_link_predictor(model, val_data)
 
        if epoch % 10 == 0:
            print(f"Epoch: {epoch:03d}, Train Loss: {loss:.3f}, Val AUC: {val_auc:.3f}")
 
    return model
 
 
 @torch.no_grad()
 def eval_link_predictor(model, data):
 
    model.eval()
    z = model.encode(data.x, data.edge_index)
    out = model.decode(z, data.edge_label_index).view(-1).sigmoid()
 
    return roc_auc_score(data.edge_label.cpu().numpy(), out.cpu().numpy())

對於這個鏈接預測任務,我們希望將鏈接 / 邊隨機分割爲訓練數據、有效數據和測試數據。我們可以使用 PyG 的 RandomLinkSplit 模塊來做到這一點。

 import torch_geometric.transforms as T
 
 split = T.RandomLinkSplit(
    num_val=0.05,
    num_test=0.1,
    is_undirected=True,
    add_negative_train_samples=False,
    neg_sampling_ratio=1.0,
 )
 train_data, val_data, test_data = split(graph)

輸出數據如下所示。

這個輸出數據有以下 3 點需要注意:

1、在 edge_index 上執行分割,這樣訓練和驗證分割不包括來自驗證和測試分割的邊 (即只有來自訓練分割的邊),而測試分割不包括來自測試分割的邊。這是因爲編碼器使用 edge_index 和 x 來創建節點嵌入,這種方式確保了在對驗證 / 測試數據進行預測時,節點嵌入上沒有目標泄漏。

2、向每個分割數據添加兩個新屬性 (edge_label 和 edge_label_index)。它們是與每次分割相對應的邊標籤和邊索引。Edge_label_index 將用於解碼器進行預測,edge_label 將用於模型評估。

3、向 val_data 和 test_data 添加與正鏈接相同數量的負鏈接 (neg_sampling_ratio=1.0)。它們被添加到 edge_label 和 edge_label_index 屬性中,但沒有添加到 edge_index 中,因爲我們不希望在編碼器(或節點嵌入創建) 上使用負鏈接。我們沒有向這裏的訓練集添加負鏈接(設置 add_negative_train_samples=False),因爲會在上面的 train_link_predictor 的訓練循環中添加它們。訓練過程中的這種隨機化應該會使模型更健壯。

下圖總結了如何對編碼器和解碼器執行邊緣分割 (每個階段使用彩色邊緣)。

我們現在可以用下面的代碼來訓練和評估模型。

 model = Net(dataset.num_features, 128, 64).to(device)
 optimizer = torch.optim.Adam(params=model.parameters(), lr=0.01)
 criterion = torch.nn.BCEWithLogitsLoss()
 model = train_link_predictor(model, train_data, val_data, optimizer, criterion)
 
 test_auc = eval_link_predictor(model, test_data)
 print(f"Test: {test_auc:.3f}")

該模型的測試 AUC 爲 92.5%。

異常檢測

再次使用 Cora 數據集進行異常檢測任務,但它與前面的數據集略有不同: 我們需要合成注入異常值。數據集有兩種不同類型的異常值:

對於這個異常檢測任務,需要使用的是 PyGOD 庫,它是建立在 PyG 之上的一個圖異常值檢測庫。可以通過 PyGOD 模塊加載已經進行了異常值注入的 Cora 數據集。

 from pygod.utils import load_data
 
 graph = load_data('inj_cora')

下面的代碼顯示了異常值類型分佈。

 Counter(graph.y.tolist())
 
 #Counter({0: 2570, 1: 68, 2: 68, 3: 2})

0: 正常,1: 僅上下文異常,2: 結構異常,3: 上下文和結構都異常

如果你對這些異常值是如何注入的感興趣,可以查看關於異常值生成器模塊的 PyGOD 文檔,該文檔解釋了操作細節。這裏我們需要注意的是標籤 y 將只用於模型評估,而不是用於訓練標籤,因爲我們正在訓練一個無監督的模型。

爲了檢測這些異常點,我們訓練了 DOMINANT(Deep Anomaly Detection on Attributed Networks) 模型。它是一個具有圖卷積層的自編碼器網絡,其重構誤差將是節點異常評分。該模型遵循以下步驟進行預測。

 from pygod.models import DOMINANT
 from sklearn.metrics import roc_auc_score, average_precision_score
 
 def train_anomaly_detector(model, graph):
    return model.fit(graph)
 
 def eval_anomaly_detector(model, graph):
 
    outlier_scores = model.decision_function(graph)
    auc = roc_auc_score(graph.y.numpy(), outlier_scores)
    ap = average_precision_score(graph.y.numpy(), outlier_scores)
    print(f'AUC Score: {auc:.3f}')
    print(f'AP Score: {ap:.3f}')
 
 
 graph.y = graph.y.bool()
 model = DOMINANT()
 model = train_anomaly_detector(model, graph)
 eval_anomaly_detector(model, graph)

模型的 AUC 爲 84.1%,平均精度爲 20.8%。這種差異很可能是由於目標不平衡造成的。由於這是一個無監督的模型,我們不可能期望得到一個非常精確的模型,但可以在原始論文中看到,它仍然優於任何其他流行的異常檢測算法。

引用

本文的源代碼可以在 colab 找到:

https://colab.research.google.com/drive/1Ksca_p4XrZjeN0A6jT5aYN6ARvwFVSbY?usp=sharing

引用如下:

作者:Tomonori Masui

轉載:Deephub Imba

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/9qIzmcidWeFhK3kStuT9Qg