目錄
一張圖Graph:torch_geometric.data.Data
處理成資料集.pt檔:仿 torch_geometric.data.Dataset 自訂義類別
Graph Neural Network簡介
圖Global、Graph:U = (V, E)
圖片來源:GNN/GCN-CSDN博客
圖的類型
- 有/無向:有向表示邊為從某一節點連向另一節點,無向表示兩節點為雙向連接。
- 同/異構:存在多種類型的節點或邊,或既有多種類型的節點亦有多種類型的邊。
圖的三類任務
- Graph-level task:整個圖的分類。例如預測某分子是否具有毒性、具有什麼氣味,或者這個關係圖屬於哪種方面的(家庭、學校、社會)。
- Node-level task:判斷某節點的性質、屬性,甚至是值。例如在人際關係圖中的某個人和誰比較親近、屬於哪一派。
- Edge-level task:節點之間的關係分類。
圖神經網路:圖輸入,圖輸出
圖片來源:圖像化神經網路(1):Graph Neural Network小簡介
環境運行-Pycharm建置Pytorch、PyG
安裝Pytorch
[Pytorch]如何在Pycharm用pip3安装pytorch&如何查看/安装对应版本的CUDA Toolkit_pip install cudatoolkit-CSDN博客
參考上面鏈結照著做進行安裝。
第一步驟可以直接在Pycharm終端機輸入nvidia-smi命令就能夠確認顯卡驅動是否正確安裝了。
第三步(配置pip3環境變量)時可以先確認是否已經配置過了。
最後一步驟測試:在環境工作資料夾裡新增一python檔,內容如下。
import torch
print(torch.__version__)
print(torch.cuda.is_available())
# 用於檢查是否支援cuDNN。cuDNN是NVIDIA提供的用於深度神經網路的GPU加速庫。
# 這個函數會傳回一個布林值,表示目前PyTorch是否支援cuDNN。
print(torch.version.cuda)
執行後看到以下結果就是安裝成功啦!
安裝PyG(PyTorch Geometric)
Installation — pytorch_geometric documentation
到上面網址選好自己的狀況,把Run那一欄他生成的程式碼複製到自己的Pycharm終端機執行。
我的情況就是在pycharm終端機執行:
pip install torch_geometric # Optional dependencies: pip install torch_scatter torch_sparse torch_cluster torch_spline_conv -f https://data.pyg.org/whl/torch-2.1.0+cu121.html
看到下面的結果就是成功了!
資料準備-圖與資料集格式
我的Graph
以一張圖片裡的每個電子元件作為節點,依從左至右從上至下的順序編號。每個電子元件的分類(one-hot encoding型態)作為節點特徵。邊分類(one-hot encoding型態)的涵義定義為任意倆元件為串聯還是並聯關係。由於節點和邊皆有多個類別,故屬於異構圖;由於一張電路圖圖片中任意兩元件間必有關係(即必有GNN圖中的edge),故屬於無項圖;目標是以GNN模型預測倆元件間的關係,也就是邊的分類,故屬於Edge- level task。
一張圖Graph:torch_geometric.data.Data
一個Data型別的資料包含三個參數:x-節點特徵、edge_index-邊索引、y-解答。本實作想要做邊分類,所以解答y就是放邊分類的標準答案。你可以將自己的節點和解答資料以如下格式分別存在.txt檔,後面資料集程式裡再將之讀出。
節點特徵
這裡每個節點特徵有5位,後續設定GNN的輸入通道數in_channels便是依照這個長度。
node_features = [ # 第一張圖節點特徵
[ [0, 1, 0, 0, 0], # 第一張圖的第一個節點特徵
[0, 0, 1, 0, 0], # 第一張圖的第二個節點特徵
[0, 0, 0, 0, 1],
[0, 0, 1, 0, 0],
[0, 0, 0, 1, 0] ],
# 第二張圖節點特徵
[ [0, 1, 0, 0, 0],
[0, 0, 0, 0, 1],
[0, 0, 0, 0, 1] ],
# ... 可以有更多圖的節點特徵
]
邊索引
每一張圖的邊索引會以二維串列表示。列出相連倆節點的編號就能夠表達出「邊」的意涵,所以你把這二維上下並排就可以看出每條邊:0 - 1、0 - 2、0 - 3、0 - 4、1 - 0、1 - 2、1 - 3、1 - 4...,這二維相同位置的編號就是有相關聯的兩個節點編號。build_dataset.py已經撰寫好自動依照每張圖的節點數量為每張圖生成邊索引的函式edgeprocess(),所以你不需要自己寫這裡的資料。
edge_lists = [ # 第一張圖的邊索引
[
[0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4], # 所有源節點編號
[1, 2, 3, 4, 0, 2, 3, 4, 0, 1, 3, 4, 0, 1, 2, 4, 0, 1, 2, 3] # 所有目標節點編號
],
# 第二張圖的邊索引
[ [0, 0, 1, 1, 2, 2],
[1, 2, 0, 2, 0, 1] ]
]
邊分類解答:用one-hot encodeing表示
這裡每條邊用2位表示,後續設定GNN輸出通道數out_channels便是依據這個長度。
edge_label = [ # 第一張圖每個邊的分類,順序依照邊索引
[ # 依序為0、1節點相連的邊分類;0、2節點相連的邊分類...
[0, 1], [1, 0], [0.1, 0.9], [0.1, 0.9],
# 依序為1、0節點相連的邊分類;1、2節點相連的邊分類...
[0, 1], [1, 0], [0.1, 0.9], [0.1, 0.9],
# 由於是無向圖,0、1節點相連的邊即1、0節點相連的邊。
[1, 0], [1, 0], [1, 0], [1, 0],
[0.1, 0.9], [0.1, 0.9], [1, 0], [0, 1],
[0.1, 0.9], [0.1, 0.9], [1, 0], [0, 1] ],
# 第二張圖每個邊的分類
[ [0, 1], [0, 1],
[0, 1], [0, 1],
[0, 1], [0, 1] ],
]
由於edge_classification_model.py使用FastRGCNConv的edge_type參數要求的邊分類是一維的,所以邊分類還需要另外處理成以一位直接表示是第幾類寫法的邊分類解答。
edge_type = [ # 第一張圖每個邊的分類,順序依照邊索引
[0, 1, 0, 0, 0, 1, 0, 0, 1, 1, 1, 1, 0, 0, 1, 0, 0, 0, 1, 0],
# 第二張圖每個邊的分類
[0, 0, 0, 0, 0, 0]
]
edge_classification_model.py class GCN的labelprocess()函式是在將one-hot encoding的邊分類轉成直接以一位數字表示屬於第幾類。你可以依據你的邊分類表示長度修改此段程式碼。
def labelprocess(self, edge_label):
edge_label_index = []
for i, labellist in enumerate(edge_label):
if labellist[1] > labellist[0]: # 預設邊以兩位表示
edge_label_index.append(0) # [ , ]第1位大於第0位設為第0類的邊
else:
edge_label_index.append(1) # 反之設為第1類的邊
return edge_label_index
或是如果你有未one-hot encoding的邊分類解答,可以直接調用給FastRGCNConv,但要注意程式碼裡要如何取出該批次(batch)該張圖對應的edge type。
處理成資料集.pt檔:仿 torch_geometric.data.Dataset 自訂義類別
記得在直接執行build_dataset.py前,要先在工作區建立mydataset資料夾。執行之後你應該可以工作區發現多了一個.pt檔,那就是你的圖資料庫!
get_node()函式:讀入你自訂的節點特徵。
get_edge()函式:讀入你的邊分類解答資訊。
edgeprocess()函式:生成邊索引。
get()函式:將一張圖的資料轉成torch.tensor的型別,然後將這些資料存入Data型別,成為一張圖。
最後在process()函式會將每張圖都處理成Data型別,存入data.pt檔案裡。
build_dataset.py 完整程式碼:
import os.path as osp
import torch
from torch_geometric.data import Data
from torch_geometric.data.collate import collate
from itertools import permutations
import ast
class MyDataset():
def __init__(self, root):
if isinstance(root, str):
root = osp.expanduser(osp.normpath(root))
else:
print('root problem')
self.root = root
self.__indices__ = None
self.node_features = self.get_node()
self.edge_label = self.get_edge()
self.edge_lists = self.edgeprocess()
self.num_features = len(self.node_features[0][0])
self.num_classes = len(self.edge_label[0][0])
def get_node(self): # 讀入節點特徵資料
file_path = 'node.txt'
with open(file_path, 'r') as file:
lines = file.readlines()
return [ast.literal_eval(line) for line in lines]
def get_edge(self): # 讀入邊分類解答
file_path = 'edge.txt'
with open(file_path, 'r') as file:
lines = file.readlines()
return [ast.literal_eval(line) for line in lines]
def edgeprocess(self): # 生成邊索引:有哪些節點有連接。格式為[[所有源節點編號(以逗號隔開)], [所有目標節點編號]]
edgelists = []
for i in range(len(self.node_features)): # 有幾張圖就執行幾次
numbers = list(range(len(self.node_features[i]))) # 創建字串,內容為節點編號(例如有五個節點,就是[0, 1, 2, 3, 4, 5])
str_numbers = ''.join(map(str, numbers)) # 把每個元素轉成字串,為了能夠執行permutations()的效果
edge_lists = list(permutations(str_numbers, 2)) # 得到兩兩相連節點的標籤
# permutations('ABCD', 2) --> AB AC AD BA BC BD CA CB CD DA DB DC
edge_lists = list(zip(*edge_lists)) # 將之改成.data裡的edge_index格式
'''
a = [1,2,3]
b = [4,5,6]
zipped = zip(a,b) # 打包为元组的列表
>>> [(1, 4), (2, 5), (3, 6)]
zip(*zipped) # 与 zip 相反,*zipped 可理解为解压,返回二维矩阵式
>>> [(1, 2, 3), (4, 5, 6)]
'''
if edge_lists != []:
for j in range(2):
edge_lists[j] = list(edge_lists[j]) # 把tuple改成list
edgelists.extend([edge_lists]) # 把第i張圖的邊索引存入edge_lists的第i個位置
# 把字串轉成int
for sublist in edgelists:
for i in range(len(sublist)):
for j in range(len(sublist[i])):
sublist[i][j] = int(sublist[i][j])
return edgelists
# 返回數據裡面圖的數量
def len(self):
return len(self.node_features)
def __len__(self):
return len(self.node_features)
def get(self, idx):
# 獲取第idx個徒的所有資訊
edge_list = torch.tensor(self.edge_lists[idx], dtype=torch.long)
node_features = torch.tensor(self.node_features[idx], dtype=torch.float)
edge_label = torch.tensor(self.edge_label[idx], dtype=torch.float)
# torch_geometric.data.Data()
data = Data(x=node_features, edge_index=edge_list, y=edge_label) # 將第idx圖的所有參數存成data型別
return data
def __getitem__(self, idx):
if isinstance(idx, int): # 檢查idx是否是整數類型(int),若是便取得第idx個圖的data資料。
data = self.get(idx)
else: # 若idx唯一個串列,依序取得每張圖的data格式資料
data = [self.get(i) for i in idx]
return data
def tocollate(self, data_list):
if len(data_list) == 1:
return data_list[0], None
data, slices, _ = collate( # torch_geometric.data.collate.collate()
data_list[0].__class__,
data_list=data_list,
increment=False,
add_batch=False,
)
'''
將input數據每一個特徵進行聚合得到一個大的Data數據,用返回的索引slices區分不同的數據。
slices為字典類型,包含三個key值,對應著輸入數據的三個特徵,每個值是一個一維tensor,每個數字代表特徵在data的起始位置。
'''
return data, slices
def process(self): # 將數據處理好存入pt文件
data_list = [self.__getitem__(i) for i in range(self.len())] # 將每一張圖的數據轉成data格式依序儲存在data_list
data, slices = self.tocollate(data_list) # 將數據轉為pt檔儲存格式
torch.save((data, slices), osp.join(self.root, 'data.pt')) # 將檔案儲存在指定資料夾下,檔名為data.pt
def main():
MyDataset(root='mydataset').process()
if __name__ == "__main__":
main()
模型原理
處理邊分類任務的原理就是將邊對應的節點訊息聚合到邊上再加上分類器。本實作選用RGCN關係圖卷積神經網路:他用於處理節點同構、邊異構的異構圖情境。我們的節點其實是異構的,每一個是不同類型的電子元件,但也可以把它當成同構的-皆屬於電子元件只是特徵不同。
RGCN運行原理
圖卷積神經網路和其他神經網路一樣,每一次迭代會對所有節點逐次更新。每次更新時,會將與該節點以同一類別之邊連接的節點特徵送進同一個線性全連接層。如下圖,若綠色表示邊類別一、紅色表示邊類別為第二種,則以綠色邊連接的節點進入的全連接層與以紅色邊連接的節點特徵進入的全連接層為獨立的。最後會加上該節點本身的特徵,與兩個全連接層的結果進行聚合,也就是取mean平均/sum相加和,得到該節點的新特徵。這個特徵就蘊含著各類型邊對應的節點訊息之序和結果。
解碼器運行原理
前面提過,邊分類任務的原理就是將邊對應的節點訊息聚合到邊上再加上分類器。RGCN只幫我們做了一半:將邊對應的節點訊息聚合,但要怎麼把聚合的訊息聚合回邊上呢?由於圖神經網路也跟其他神經網路一樣,什麼樣式輸入就是什麼樣式輸出,儘管更新出來的圖中,每個節點已經蘊含我們要的資訊,但顯然是不匹配邊的數量的,如此就沒辦法將這個結果跟解答比對來訓練正確率。所以我們需要一個解碼器,把隱藏在RGCN產出圖裡的資訊放進邊做為邊的特徵,並加上分類器(也就是一個線性全連接層)預測邊的分類。
具體的做法是,將RGCN圖中的每個節點特徵依照邊索引的對應順序倆倆組合。在邊索引的章節有說過,圖用列出相連倆節點的編號來表達這兩個節點的連結,邊索引二維陣列相同位置的編號就是相連的兩個節點。所以我們現在將RGCN產出圖兩兩節點的特徵相接,組合成這連接這倆節點之邊的特徵。
例如,經過RGCN後的新節點0特徵為:
[ 0.7729, 0.4176, -0.3470, -0.5017, -0.3894]
新節點1特徵為:
[ 0.5777, 0.6314, 0.3491, -0.7999, -0.1461]
則邊0 - 1的特徵就是:
[ 0.7729, 0.4176, -0.3470, -0.5017, -0.3894, 0.5777, 0.6314, 0.3491, -0.7999, -0.1461 ]
以此類推,我們就獲得根邊數量一樣多筆的資料了!就可以把些資料丟進去全連接層分類,獲得每條邊的預測分類值。
你也可以搭配著程式碼理解:
class Decoder(torch.nn.Module):
def __init__(self, featurelen, edgeclasslen):
super().__init__()
self.classifier = torch.nn.Linear(featurelen*2, edgeclasslen) # 分類器
def forward(self, encodedata, edge_index):
h = torch.cat([encodedata[edge_index[0]], encodedata[edge_index[1]]], 1) # 將聚合結果放回邊
y = self.classifier(h)
return y
程式流程
這裡主要解釋模型的執行流程順序。
class GCN(torch.nn.Module):
def __init__(self, in_channels, out_channels):
super().__init__()
self.in_channels = in_channels
self.out_channels = out_channels
self.conv = FastRGCNConv(in_channels=in_channels, out_channels=in_channels,
num_relations=out_channels, is_sorted=True)
self.pred = Decoder(featurelen=self.in_channels, edgeclasslen=self.out_channels)
對應的主程式是這行:
# 載入模型
GCNmodel = GCN(in_channels=dataset.num_features, out_channels=dataset.num_classes)
print(GCNmodel)
在class GCN forward才真的進入神經網路。
class GCN(torch.nn.Module):
def forward(self, x, edge_index, edge_label):
labelindex = self.labelprocess(edge_label)
labelindex = torch.tensor(labelindex, dtype=torch.long)
encode_data_on_edge = self.conv(x=x, edge_index=edge_index, edge_type=labelindex)
decode = self.pred(encodedata=encode_data_on_edge, edge_index=edge_index)
return decode
def labelprocess(self, edge_label):
edge_label_index = []
for i, labellist in enumerate(edge_label):
if labellist[1] > labellist[0]:
edge_label_index.append(0)
else:
edge_label_index.append(1)
return edge_label_index
class Decoder(torch.nn.Module):
def __init__(self, featurelen, edgeclasslen):
super().__init__()
self.classifier = torch.nn.Linear(featurelen*2, edgeclasslen)
def forward(self, encodedata, edge_index):
h = torch.cat([encodedata[edge_index[0]], encodedata[edge_index[1]]], 1)
y = self.classifier(h)
return y
class GCN forward是在train里執行的:
loss = train(train_loader, GCNmodel, optimizer)
def train(batchdata, model, op):
total_loss = 0.0
for batch in batchdata:
optimizer.zero_grad() # 在参数更新之后,梯度需要清零,以便进行下一轮的反向传播。
x, edge_index, edge_label = batch.x, batch.edge_index, batch.y # 请根据实际数据结构调整
output = model(x, edge_index, edge_label)
los = criterion(output, edge_label) # 通过将模型输出和实际标签传递给损失函数,计算了模型在当前批次(或样本)上的损失值。
los.backward() # 计算损失函数关于模型参数的梯度。
op.step() # 根据梯度更新模型的参数,以最小化损失函数。
total_loss += los.item()
average_loss = total_loss / len(batchdata)
return average_loss
model(x, edge_index, edge_label)就是在執行class GCN forward區塊,將該迭代(epoch)該批次(batch)的每張圖資訊分別輸入神經網路。
在驗證和測試的時候也是這樣的流程。
edge_classification_model.py 完整程式碼:
import torch
from torch_geometric.nn.conv import FastRGCNConv
from build_dataset import MyDataset
from torch.utils.data import random_split
from torch_geometric.loader import DataLoader
import matplotlib.pyplot as plt
class GCN(torch.nn.Module):
def __init__(self, in_channels, out_channels):
super().__init__()
self.in_channels = in_channels # 輸入通道數:即一節點的特徵長度
self.out_channels = out_channels # 輸出通道數:即邊類別表示所需的長度
self.conv = FastRGCNConv(in_channels=in_channels, out_channels=in_channels,
num_relations=out_channels, is_sorted=True) # 設定RGCN捲積層
self.pred = Decoder(featurelen=self.in_channels, edgeclasslen=self.out_channels) # 設定解碼器參數並預測邊分類
def forward(self, x, edge_index, edge_label):
labelindex = self.labelprocess(edge_label) # 取得RGCN()所需的邊分類解答
labelindex = torch.tensor(labelindex, dtype=torch.long) # labelprocess結果為串列,要改成torch.tensor資料
encode_data_on_edge = self.conv(x=x, edge_index=edge_index, edge_type=labelindex) # 執行RGCN捲積
decode = self.pred(encodedata=encode_data_on_edge, edge_index=edge_index) # 解碼並預測邊分類
return decode
def labelprocess(self, edge_label): # 生成FastRGCNConv所需要的邊解答格式,以一位數表示邊屬於第幾類,而非one-hot encoding編碼的格式
edge_label_index = []
for i, labellist in enumerate(edge_label):
if labellist[1] > labellist[0]: # 預設邊以兩位表示
edge_label_index.append(0) # [ , ]第1位大於第0位設為第0類的邊
else:
edge_label_index.append(1) # 反之設為第1類的邊
return edge_label_index
class Decoder(torch.nn.Module): # 解碼器
def __init__(self, featurelen, edgeclasslen):
super().__init__()
self.classifier = torch.nn.Linear(featurelen*2, edgeclasslen) # 邊的分類器
def forward(self, encodedata, edge_index):
h = torch.cat([encodedata[edge_index[0]], encodedata[edge_index[1]]], 1) # 解碼,將結點聚合的資料聚合回邊上
y = self.classifier(h) # 進行邊分類預測
return y
def train(batchdata, model, op): # 訓練
total_loss = 0.0
for batch in batchdata: # 依每個批次資料進行訓練
optimizer.zero_grad() # 在参數更新之后,梯度需要清零,以便進行下一輪的反向傳播。
x, edge_index, edge_label = batch.x, batch.edge_index, batch.y
output = model(x, edge_index, edge_label)
los = criterion(output, edge_label) # 將預測結果以criterion設定的損失函數計算損失值
los.backward() # 計算損失函數關於模型參數的梯度
op.step() # 根據梯度更新模型的參數
total_loss += los.item()
average_loss = total_loss / len(batchdata)
return average_loss
@torch.no_grad() # 上下文管理器,停用梯度追蹤。 提高效能並減少不必要的計算
def test(batchdata, model):
model.eval() # 設定模型為評估模式
total_loss = 0.0
for batch in batchdata:
x, edge_index, edge_label = batch.x, batch.edge_index, batch.y
# 模型推斷
output = model(x, edge_index, edge_label)
# 計算損失
los = criterion(output, edge_label)
total_loss += los.item()
# 計算平均損失率
average_loss = total_loss / len(batchdata)
return average_loss
# 讀取資料庫
dataset = MyDataset(root='mydataset')
print()
print(f'Dataset: {dataset}:')
print(f'Number of graphs: {dataset.len()}')
print('======================')
# 分出訓練集、驗證集、測試集
train_dataset, val_dataset, test_dataset = random_split(dataset, [0.7, 0.2, 0.1])
print()
print('Number of graphs train_data:', len(train_dataset))
print('Number of graphs val_data:', len(val_dataset))
print('Number of graphs test_data', len(test_dataset))
# 把資料分批
train_loader = DataLoader(train_dataset, batch_size=10, shuffle=True) # batch_size每次迭代的時候要隨機選擇幾個資料
val_loader = DataLoader(val_dataset, batch_size=10, shuffle=True) # shuffle 參數用於指定是否對資料進行隨機打亂
test_loader = DataLoader(test_dataset, batch_size=10, shuffle=True)
# 載入模型
GCNmodel = GCN(in_channels=dataset.num_features, out_channels=dataset.num_classes)
print(GCNmodel)
# 定義儲存損失的串列
train_losses = []
val_losses = []
test_losses = []
# 執行模型訓練驗證和測試
optimizer = torch.optim.Adam(GCNmodel.parameters(), lr=0.01, weight_decay=5e-4)
criterion = torch.nn.CrossEntropyLoss()
num_epochs = 100 # 迭代次數
for epoch in range(num_epochs):
loss = train(train_loader, GCNmodel, optimizer)
val_los = test(val_loader, GCNmodel)
test_los = test(test_loader, GCNmodel)
print(f'Epoch: {epoch:03d}, Loss: {loss:.4f}, Val loss: {val_los:.4f}, '
f'Test loss: {test_los:.4f}')
# 儲存每epoch的損失值
train_losses.append(loss)
val_losses.append(val_los)
test_losses.append(test_los)
# 繪製圖表展現模型學習狀況
plt.figure(figsize=(10, 6))
plt.plot(range(1, num_epochs + 1), train_losses, label='Train Loss', marker='o')
plt.plot(range(1, num_epochs + 1), val_losses, label='Validation Loss', marker='o')
plt.plot(range(1, num_epochs + 1), test_losses, label='Test Loss', marker='o')
# 添加圖表的標題和標籤
plt.title('Training, Validation, and Test Loss Over Epochs')
plt.xlabel('Epochs')
plt.ylabel('Loss')
plt.legend()
plt.grid(True)
plt.show()