1. 程式人生 > 實用技巧 >GCN程式碼分析學習

GCN程式碼分析學習

本文非原創,主要參考學習博文:

說明:本文是對論文“SEMI-SUPERVISED CLASSIFICATION WITH GRAPH CONVOLUTIONAL NETWORKS, ICLR 2017”中描述的GCN模型程式碼詳細解讀。

程式碼下載地址:https://github.com/tkipf/pygcn
論文下載地址:https://arxiv.org/abs/1609.02907
資料集下載地址:https://linqs-data.soe.ucsc.edu/public/lbc/cora.tgz


一、程式碼結構總覽

  • layers:定義了模組如何計算卷積
  • models:定義了模型train
  • train:包含了模型訓練資訊
  • utils:定義了載入資料等工具性的函式


二、資料集結構及內容

論文中所使用的資料集合是Cora資料集,總共有三部分構成:

  • cora.content:包含論文資訊;

該檔案共2078行,每一行代表一篇論文(即2708篇文章)

論文編號(id)論文詞向量(features)(1433維)和論文類別(labels)三個部分組成

  • cora.cites:包含各論文間的相互引用記錄;

該檔案總共5429行,每一行有兩篇論文編號(id),表示右邊的論文引用左邊的論文。

  • README:對資料集內容的描述

該資料集總共有2708個樣本,而且每個樣本都為一篇論文。根據README可知,所有的論文被分為了7個類別,分別為:

  • 基於案列的論文
  • 基於遺傳演算法的論文
  • 基於神經網路的論文
  • 基於概率方法的論文
  • 基於強化學習的論文
  • 基於規則學習的論文
  • 理論描述類的論文

此外,為了區分論文的類別,使用一個1433維的詞向量,對每一篇論文進行描述,該向量的每個元素都為一個詞語是否在論文中出現,如果出現則為“1”,否則為“0”


三、utils.py

1. 特徵獨熱碼處理:

 1 def load_data(path="../data/cora/", dataset="cora"):
 2 
 3     """Load citation network dataset (cora only for now)"""
 4     print('Loading {} dataset...'.format(dataset))
 5 
 6     # 首先將檔案中的內容讀出,以二維陣列的形式儲存
 7     idx_features_labels = np.genfromtxt("{}{}.content".format(path, dataset),
 8                                         dtype=np.dtype(str))
 9     # 以稀疏矩陣(採用CSR格式壓縮)將資料中的特徵儲存
10 
11     '''content file的每一行的格式為 : <paper_id> <word_attributes>+ <class_label>
12        分別對應 0, 1:-1, -1
13        feature為第二列到倒數第二列,labels為最後一列
14     '''
15     # feature - idx_features_labels[:, 1:-1]:論文詞向量
16     # labels - idx_features_labels[:, -1]:論文類別
17     # idx_features_labels[:, 0]:論文編號
18     features = sp.csr_matrix(idx_features_labels[:, 1:-1], dtype=np.float32)
19     labels = encode_onehot(idx_features_labels[:, -1]) # 這裡的label為onthot格式,如第一類代表[1,0,0,0,0,0,0]
20 
21     """根據引用檔案,生成無向圖"""
22 
23     # 將每篇文獻的編號idx提取出來
24     idx = np.array(idx_features_labels[:, 0], dtype=np.int32)
25 
26     # 對文獻的編號構建字典
27     # 由於檔案中節點並非是按順序排列的(開啟看看就知道了),因此建立一個編號為0-(node_size-1)的雜湊表idx_map,
28     # 雜湊表中每一項為id: 索引值,即節點id(論文編號)對應的索引值
29     '''關於enumerate():例如,s = abcdefghij,則enumerate(s):
30        [(0, 'a'), (1, 'b'), (2, 'c'), (3, 'd'), (4, 'e'), (5, 'f'), (6, 'g'), (7, 'h'), (8, 'i'), (9, 'j')]
31     '''
32     idx_map = {j: i for i, j in enumerate(idx)}
33 
34     # 讀取cite檔案,以二維陣列的形式儲存
35     # edges_unordered為直接從邊表文件中直接讀取的結果,是一個(edge_num, 2)的陣列,每一行表示一條邊兩個端點的idx
36     edges_unordered = np.genfromtxt("{}{}.cites".format(path, dataset),
37                                     dtype=np.int32)
38     # 生成圖的邊,(x,y)其中x、y都是為以文章編號為索引得到的值(也就是邊對應的並非論文編號,而是字典中論文編號對應的索引值),此外,y中引入x的文獻
39     edges = np.array(list(map(idx_map.get, edges_unordered.flatten())), # 在idx_map中以idx作為鍵查詢得到對應節點的索引值,reshape成與edges_unordered形狀一樣的陣列
40                      dtype=np.int32).reshape(edges_unordered.shape)
41 
42     # 生成鄰接矩陣,生成的矩陣為稀疏矩陣,對應的行和列座標分別為邊的兩個點,該步驟之後得到的是一個有向圖
43     # 如51行所示,edges是np.array資料,其中np.array.shape[0]表示行數,np.array.shape[1]表示列數
44     # np.ones是生成全1的n維陣列,第一個引數表示返回陣列的大小
45     '''coo_matrix((data, (i, j)), [shape=(M, N)]) 有三個引數:
46        data[:] 原始矩陣中的資料;
47        i[:] 行的指示符號;例如元素為0則代表data中第一個資料在第0行;
48        j[:] 列的指示符號;例如元素為0則代表data中第一個資料在第0列;
49        綜合上面三點,對data中的第一個資料,它在第i[]行,第j[]列;
50        最後的shape引數是告訴coo_matrix原始矩陣的形狀,除了上述描述的有資料的行列,其他地方都按照shape的形式補0。'''
51     # 根據coo矩陣性質,這一段的作用就是,網路有多少條邊,鄰接矩陣就有多少個1,
52     # 所以先建立一個長度為edge_num的全1陣列,每個1的填充位置就是一條邊中兩個端點的編號,
53     # 即edges[:, 0], edges[:, 1],矩陣的形狀為(node_size, node_size)。
54     adj = sp.coo_matrix((np.ones(edges.shape[0]), (edges[:, 0], edges[:, 1])),
55                         shape=(labels.shape[0], labels.shape[0]),
56                         dtype=np.float32)
57 
58     # 無向圖的領接矩陣是對稱的,因此需要將上面得到的矩陣轉換為對稱的矩陣,從而得到無向圖的領接矩陣
59     '''論文中採用的辦法和下面兩個語句是等價的,僅僅是為了產生對稱的矩陣
60        adj_2 = adj + adj.T.multiply(adj.T > adj)
61        adj_3 = adj + adj.T
62     '''
63     '''test01 = adj.T
64        test02 = adj.T > adj
65        test03 = adj.T.multiply(adj.T > adj)
66        test04 = adj.multiply(adj.T > adj)
67     '''
68     adj = adj + adj.T.multiply(adj.T > adj) - adj.multiply(adj.T > adj)
69 
70     # 定義特徵,呼叫歸一化函式(之後的定義)
71     features = normalize(features)
72 
73     # 進行歸一化,對應於論文中的A^=(D~)^0.5 A~ (D~)^0.5,但是本程式碼實現的是A^=(D~)^-1 A~
74     # A^=I+A,其中eye()即為建立單位矩陣
75     # test = normalize(adj)
76     adj = normalize(adj + sp.eye(adj.shape[0])) # eye建立單位矩陣,第一個引數為行數,第二個為列數
77 
78     # 分別構建訓練集、驗證集、測試集,並建立特徵矩陣、標籤向量和鄰接矩陣的tensor,用來做模型的輸入
79     # range()函式內只有一個引數,則表示會產生從0開始計數的整數列表:如range(140)返回[0,1,2...139]
80     # range()中傳入兩個引數時,則將第一個引數做為起始位,第二個引數為結束位:range(200, 500)返回[200,201,202...499]
81     idx_train = range(140)
82     idx_val = range(200, 500)
83     idx_test = range(500, 1500)
84 
85     # 將特徵轉換為tensor
86     # *這一步做得必要性?
87     features = torch.FloatTensor(np.array(features.todense()))
88     labels = torch.LongTensor(np.where(labels)[1])
89     adj = sparse_mx_to_torch_sparse_tensor(adj)
90 
91     idx_train = torch.LongTensor(idx_train)
92     idx_val = torch.LongTensor(idx_val)
93     idx_test = torch.LongTensor(idx_test)
94 
95     return adj, features, labels, idx_train, idx_val, idx_test

在很多的多分類問題中,特徵的標籤通常都是不連續的內容(如本文中特徵是離散的字串型別),為了便於後續的計算、處理,需要將所有的標籤進行提取,並將標籤對映到一個獨熱碼向量中。

輸入的labels格式如下:

執行完該程式後,輸出的獨熱碼為:(獨熱碼這個概念並不複雜,就是分類標記)

2. 資料載入及處理函式:

 1 def load_data(path="../data/cora/", dataset="cora"):
 2 
 3     """Load citation network dataset (cora only for now)"""
 4     print('Loading {} dataset...'.format(dataset))
 5 
 6     # 首先將檔案中的內容讀出,以二維陣列的形式儲存
 7     idx_features_labels = np.genfromtxt("{}{}.content".format(path, dataset),
 8                                         dtype=np.dtype(str))
 9     # 以稀疏矩陣(採用CSR格式壓縮)將資料中的特徵儲存
10 
11     '''content file的每一行的格式為 : <paper_id> <word_attributes>+ <class_label>
12        分別對應 0, 1:-1, -1
13        feature為第二列到倒數第二列,labels為最後一列
14     '''
15     # feature - idx_features_labels[:, 1:-1]:論文詞向量
16     # labels - idx_features_labels[:, -1]:論文類別
17     # idx_features_labels[:, 0]:論文編號
18     features = sp.csr_matrix(idx_features_labels[:, 1:-1], dtype=np.float32)
19     labels = encode_onehot(idx_features_labels[:, -1]) # 這裡的label為onthot格式,如第一類代表[1,0,0,0,0,0,0]
20 
21     """根據引用檔案,生成無向圖"""
22 
23     # 將每篇文獻的編號idx提取出來
24     idx = np.array(idx_features_labels[:, 0], dtype=np.int32)
25 
26     # 對文獻的編號構建字典
27     # 由於檔案中節點並非是按順序排列的(開啟看看就知道了),因此建立一個編號為0-(node_size-1)的雜湊表idx_map,
28     # 雜湊表中每一項為id: 索引值,即節點id(論文編號)對應的索引值
29     '''關於enumerate():例如,s = abcdefghij,則enumerate(s):
30        [(0, 'a'), (1, 'b'), (2, 'c'), (3, 'd'), (4, 'e'), (5, 'f'), (6, 'g'), (7, 'h'), (8, 'i'), (9, 'j')]
31     '''
32     idx_map = {j: i for i, j in enumerate(idx)}
33 
34     # 讀取cite檔案,以二維陣列的形式儲存
35     # edges_unordered為直接從邊表文件中直接讀取的結果,是一個(edge_num, 2)的陣列,每一行表示一條邊兩個端點的idx
36     edges_unordered = np.genfromtxt("{}{}.cites".format(path, dataset),
37                                     dtype=np.int32)
38     # 生成圖的邊,(x,y)其中x、y都是為以文章編號為索引得到的值(也就是邊對應的並非論文編號,而是字典中論文編號對應的索引值),此外,y中引入x的文獻
39     edges = np.array(list(map(idx_map.get, edges_unordered.flatten())), # 在idx_map中以idx作為鍵查詢得到對應節點的索引值,reshape成與edges_unordered形狀一樣的陣列
40                      dtype=np.int32).reshape(edges_unordered.shape)
41 
42     # 生成鄰接矩陣,生成的矩陣為稀疏矩陣,對應的行和列座標分別為邊的兩個點,該步驟之後得到的是一個有向圖
43     # 如51行所示,edges是np.array資料,其中np.array.shape[0]表示行數,np.array.shape[1]表示列數
44     # np.ones是生成全1的n維陣列,第一個引數表示返回陣列的大小
45     '''coo_matrix((data, (i, j)), [shape=(M, N)]) 有三個引數:
46        data[:] 原始矩陣中的資料;
47        i[:] 行的指示符號;例如元素為0則代表data中第一個資料在第0行;
48        j[:] 列的指示符號;例如元素為0則代表data中第一個資料在第0列;
49        綜合上面三點,對data中的第一個資料,它在第i[]行,第j[]列;
50        最後的shape引數是告訴coo_matrix原始矩陣的形狀,除了上述描述的有資料的行列,其他地方都按照shape的形式補0。'''
51     # 根據coo矩陣性質,這一段的作用就是,網路有多少條邊,鄰接矩陣就有多少個1,
52     # 所以先建立一個長度為edge_num的全1陣列,每個1的填充位置就是一條邊中兩個端點的編號,
53     # 即edges[:, 0], edges[:, 1],矩陣的形狀為(node_size, node_size)。
54     adj = sp.coo_matrix((np.ones(edges.shape[0]), (edges[:, 0], edges[:, 1])),
55                         shape=(labels.shape[0], labels.shape[0]),
56                         dtype=np.float32)
57 
58     # 無向圖的領接矩陣是對稱的,因此需要將上面得到的矩陣轉換為對稱的矩陣,從而得到無向圖的領接矩陣
59     '''論文中採用的辦法和下面兩個語句是等價的,僅僅是為了產生對稱的矩陣
60        adj_2 = adj + adj.T.multiply(adj.T > adj)
61        adj_3 = adj + adj.T
62     '''
63     '''test01 = adj.T
64        test02 = adj.T > adj
65        test03 = adj.T.multiply(adj.T > adj)
66        test04 = adj.multiply(adj.T > adj)
67     '''
68     adj = adj + adj.T.multiply(adj.T > adj) - adj.multiply(adj.T > adj)
69 
70     # 定義特徵,呼叫歸一化函式(之後的定義)
71     features = normalize(features)
72 
73     # 進行歸一化,對應於論文中的A^=(D~)^0.5 A~ (D~)^0.5,但是本程式碼實現的是A^=(D~)^-1 A~
74     # A^=I+A,其中eye()即為建立單位矩陣
75     # test = normalize(adj)
76     adj = normalize(adj + sp.eye(adj.shape[0])) # eye建立單位矩陣,第一個引數為行數,第二個為列數
77 
78     # 分別構建訓練集、驗證集、測試集,並建立特徵矩陣、標籤向量和鄰接矩陣的tensor,用來做模型的輸入
79     # range()函式內只有一個引數,則表示會產生從0開始計數的整數列表:如range(140)返回[0,1,2...139]
80     # range()中傳入兩個引數時,則將第一個引數做為起始位,第二個引數為結束位:range(200, 500)返回[200,201,202...499]
81     idx_train = range(140)
82     idx_val = range(200, 500)
83     idx_test = range(500, 1500)
84 
85     # 將特徵轉換為tensor
86     # *這一步做得必要性?
87     features = torch.FloatTensor(np.array(features.todense()))
88     labels = torch.LongTensor(np.where(labels)[1])
89     adj = sparse_mx_to_torch_sparse_tensor(adj)
90 
91     idx_train = torch.LongTensor(idx_train)
92     idx_val = torch.LongTensor(idx_val)
93     idx_test = torch.LongTensor(idx_test)
94 
95     return adj, features, labels, idx_train, idx_val, idx_test

這一部分比較繞,在筆記本上梳理了一下,比較迷幻的就是第68行的“生成對稱鄰接矩陣”程式碼:

adj = adj + adj.T.multiply(adj.T > adj) - adj.multiply(adj.T > adj)

關於 adj.multiply(adj.T > adj) 不明白其意義所在,在Debug中顯示為全0:

(前面的 adj + adj.T.multiply(adj.T > adj) 理解起來倒是比較直接)

test04 = adj.multiply(adj.T > adj)

3. 特徵歸一化函式:

 1 # 該函式需要傳入特徵矩陣作為引數。對於本文使用的cora的資料集來說,每一行是一個樣本,每一個樣本是1433個特徵。
 2 # 歸一化函式實現的方式:對傳入特徵矩陣的每一行分別求和,取到數後就是每一行非零元素歸一化的值,然後與傳入特徵矩陣進行點乘。
 3 # 其呼叫在第77行:features = normalize(features)
 4 def normalize(mx):
 5     """Row-normalize sparse matrix"""
 6     rowsum = np.array(mx.sum(1)) # 得到一個(2708,1)的矩陣
 7     r_inv = np.power(rowsum, -1).flatten() # 得到(2708,)的元組
 8     # 在計算倒數的時候存在一個問題,如果原來的值為0,則其倒數為無窮大,因此需要對r_inv中無窮大的值進行修正,更改為0
 9     # np.isinf()函式測試元素是正無窮還是負無窮
10     r_inv[np.isinf(r_inv)] = 0.
11     # 歸一化後的稀疏矩陣
12     r_mat_inv = sp.diags(r_inv)  # 構建對角元素為r_inv的對角矩陣
13     # 用對角矩陣與原始矩陣的點積起到標準化的作用,原始矩陣中每一行元素都會與對應的r_inv相乘,最終相當於除以了sum
14     mx = r_mat_inv.dot(mx)
15     return mx

該函式需要傳入特徵矩陣作為引數。對於本文使用的cora的資料集來說,每一行是一個樣本,每一個樣本是1433個特徵。

需要注意的是:由於特徵中有很多的內容是“0”,因此使用稀疏矩陣的方式進行儲存,因此經過該函式歸一化之後的函式,仍然為一個稀疏矩陣。

歸一化函式實現的方式:對傳入特徵矩陣的每一行分別求和,取倒數後就是每一行非零元素歸一化的值,然後與傳入特徵矩陣進行點乘

為了直觀展示歸一化過程,測試如下程式碼:

test = normalize(adj)

輸入adj矩陣如下:(其中 (0, 8) 1.0 表示 第0行8列的值為1)

歸一化後的輸出結果test為:

可以看到,在adj矩陣中,由於第0行是[1, 1, 1, 1, 1],因此經過歸一化後會變成[0.2, 0.2, 0.2, 0.2, 0.2]

4. 精度計算函式:

1 def accuracy(output, labels):
2     # 使用type_as(tesnor)將張量轉換為給定型別的張量。
3     preds = output.max(1)[1].type_as(labels) # 將預測結果轉換為和labels一致的型別
4     correct = preds.eq(labels).double()
5     correct = correct.sum()
6     return correct / len(labels)

5. 稀疏矩陣轉稀疏張量函式

 1 def sparse_mx_to_torch_sparse_tensor(sparse_mx):
 2     """Convert a scipy sparse matrix to a torch sparse tensor."""
 3 
 4     """numpy中的ndarray轉化成pytorch中的tensor : torch.from_numpy()
 5        pytorch中的tensor轉化成numpy中的ndarray : numpy()
 6     """
 7     sparse_mx = sparse_mx.tocoo().astype(np.float32)
 8     indices = torch.from_numpy(
 9         np.vstack((sparse_mx.row, sparse_mx.col)).astype(np.int64))
10     values = torch.from_numpy(sparse_mx.data)
11     shape = torch.Size(sparse_mx.shape)
12     return torch.sparse.FloatTensor(indices, values, shape)
13 
14     # 這一部分不理解可以去看看COO性稀疏矩陣的結構(?)


四、models.py

 1 class GCN(nn.Module):
 2     # nfeat:底層節點的引數,feature的個數;
 3     # nhid:隱層節點個數;
 4     # nclass:最終的分類數
 5     def __init__(self, nfeat, nhid, nclass, dropout):
 6         super(GCN, self).__init__() #  super()._init_()在利用父類裡的物件建構函式
 7 
 8         self.gc1 = GraphConvolution(nfeat, nhid) # gc1輸入尺寸nfeat,輸出尺寸nhid
 9         self.gc2 = GraphConvolution(nhid, nclass) # gc2輸入尺寸nhid,輸出尺寸ncalss
10         self.dropout = dropout
11 
12     # 輸入分別是特徵x和鄰接矩陣adj;
13     # 最後輸出為輸出層做log_softmax變換得到的結果
14     def forward(self, x, adj):
15         x = F.relu(self.gc1(x, adj)) # adj即公式Z=softmax(A~Relu(A~XW(0))W(1))中的A~
16         x = F.dropout(x, self.dropout, training=self.training) # x要dropout
17         x = self.gc2(x, adj)
18         return F.log_softmax(x, dim=1)

定義了一個圖卷積神經網路,其有兩個卷積層:

  • 卷積層1(gc1):輸入的特徵為nfeat,維度是2708;輸出的特徵為nhid,維度是16;
  • 卷積層2(gc2):輸入的特徵為nhid,維度是16;輸出的特徵為nclass,維度是7(即類別的結果)

forward是向前傳播函式,最終得到網路向前傳播的方式為:relu——dropout——gc2——softmax

關於dropout策略的理解:

在前向傳播的時候,讓某個神經元的啟用值以一定的概率p停止工作,這樣可以使模型泛化性更強,因為它不會太依賴某些區域性的特徵,如圖所示:


五、layers.py

layers.py中主要定義了圖資料實現卷積操作的層,類似於CNN中的卷積層,只是一個“層”而已。本節將分別通過屬性定義、引數初始化、前向傳播以及字串表達四個方面對程式碼進一步解析。

1. 屬性定義

GraphConvolution作為一個類,首先需要定義其相關屬性。

主要定義了其輸入特徵in_feature輸出特徵out_feature兩個輸入,以及權重weight偏移向量bias兩個引數,同時呼叫了其引數初始化的方法。

(引數初始化此處沒有詳細說明)

 1  # 初始化層:輸入feature,輸出feature,權重,偏移
 2     def __init__(self, in_features, out_features, bias=True):
 3         super(GraphConvolution, self).__init__()
 4         self.in_features = in_features
 5         self.out_features = out_features
 6 
 7         '''常見用法self.v = torch.nn.Parameter(torch.FloatTensor(hidden_size)):
 8            可以把該函式理解為型別轉換函式,將一個不可訓練的型別Tensor轉換成可訓練的型別parameter,並將parameter繫結至module中。
 9            因此經過型別轉換這個self.v變成了模型的一部分,成為了模型中根據訓練可以改動的引數了。
10            使用這個函式的目的也是希望某些變數在學習的過程中不斷的修改其值以達到最優化。
11         '''
12         self.weight = Parameter(torch.FloatTensor(in_features, out_features)) # 由於weight是可以訓練的,因此使用parameter定義
13         if bias:
14             self.bias = Parameter(torch.FloatTensor(out_features)) # 由於weight是可以訓練的,因此使用parameter定義
15         else:
16             self.register_parameter('bias', None)
17         self.reset_parameters()

2. 引數初始化

為了讓每次訓練產生的初始引數儘可能的相同,從而便於實驗結果的復現,可以設定固定的隨機數生成種子。

1 # 初始化權重
2     def reset_parameters(self):
3         # size()函式主要是用來統計矩陣元素個數,或矩陣某一維上的元素個數的函式  size(1)為行
4         stdv = 1. / math.sqrt(self.weight.size(1)) # sqrt() 方法返回數字x的平方根。
5         # uniform() 方法將隨機生成下一個實數,它在 [x, y] 範圍內
6         self.weight.data.uniform_(-stdv, stdv)
7         if self.bias is not None:
8             self.bias.data.uniform_(-stdv, stdv)

3. 前饋計算

此處主要定義的是本層的前向傳播,通常採用的是A ∗ X ∗ W 的計算方法。由於 A 是一個sparse變數,因此其與 X 進行卷積的結果也是稀疏矩陣。

 1     '''前饋運算 即計算A~ * X * W(0)
 2        input(即X)與權重W相乘,然後adj(即A)矩陣與他們的積稀疏相乘
 3        直接輸入與權重之間進行torch.mm操作,得到support,即XW
 4        support與adj進行torch.spmm操作,得到output,即AXW選擇是否加bias
 5     '''
 6     def forward(self, input, adj):
 7         # torch.mm(a, b)是矩陣a和b矩陣相乘,torch.mul(a, b)是矩陣a和b對應位相乘,a和b的維度必須相等
 8         support = torch.mm(input, self.weight)
 9         # torch.spmm(a,b)是稀疏矩陣相乘
10         output = torch.spmm(adj, support)
11         if self.bias is not None:
12             return output + self.bias
13         else:
14             return output

4. 字串表達

__repr()__ 方法是類的例項化物件用來做“自我介紹”的方法,預設情況下,它會返回當前物件的“類名+object at+記憶體地址”, 而如果對該方法進行重寫,可以為其製作自定義的自我描述資訊。

1 def __repr__(self):
2         return self.__class__.__name__ + ' (' \
3                + str(self.in_features) + ' -> ' \
4                + str(self.out_features) + ')'


六、train.py

train.py完成函式的訓練步驟。

由於該檔案主要完成對上述函式的呼叫,因此只是在程式中進行詳細的註釋,不在分函式進行介紹。

  1 # 在 Python2 中匯入未來的支援的語言特徵中division (精確除法),
  2 # 即from __future__ import division:若在程式中沒有匯入該特徵,
  3 # / 操作符執行的只能是整除,也就是取整數,只有當匯入division(精確演算法)以後,
  4 # / 執行的才是精確演算法。
  5 from __future__ import division
  6 # 在開頭加上from __future__ import print_function這句之後,即使在python2.X,
  7 # 使用print就得像python3.X那樣加括號使用。
  8 # 注意:python2.X中print不需要括號,而在python3.X中則需要。
  9 from __future__ import print_function
 10 
 11 import time
 12 import argparse
 13 import numpy as np
 14 
 15 import torch
 16 import torch.nn.functional as F
 17 import torch.optim as optim
 18 
 19 from pygcn.utils import load_data, accuracy
 20 from pygcn.models import GCN
 21 
 22 '''訓練設定
 23 '''
 24 parser = argparse.ArgumentParser()
 25 parser.add_argument('--no-cuda', action='store_true', default=False,
 26                     help='Disables CUDA training.')
 27 parser.add_argument('--fastmode', action='store_true', default=False,
 28                     help='Validate during training pass.')
 29 parser.add_argument('--seed', type=int, default=42, help='Random seed.')
 30 parser.add_argument('--epochs', type=int, default=200, # 訓練回合200次
 31                     help='Number of epochs to train.')
 32 parser.add_argument('--lr', type=float, default=0.01, # 設定初始學習率(learning rate)
 33                     help='Initial learning rate.')
 34 parser.add_argument('--weight_decay', type=float, default=5e-4, # 定義權重衰減
 35                     help='Weight decay (L2 loss on parameters).')
 36 parser.add_argument('--hidden', type=int, default=16, # 隱藏單元設定為16
 37                     help='Number of hidden units.')
 38 parser.add_argument('--dropout', type=float, default=0.5, # dropout設定
 39                     help='Dropout rate (1 - keep probability).')
 40 
 41 args = parser.parse_args()
 42 # 如果程式不禁止使用gpu且當前主機的gpu可用,arg.cuda就為True
 43 args.cuda = not args.no_cuda and torch.cuda.is_available()
 44 
 45 # 指定生成隨機數的種子,從而每次生成的隨機數都是相同的,通過設定隨機數種子的好處是,使模型初始化的可學習引數相同,從而使每次的執行結果可以復現
 46 np.random.seed(args.seed)
 47 torch.manual_seed(args.seed)
 48 if args.cuda:
 49     torch.cuda.manual_seed(args.seed)
 50 
 51 '''開始訓練
 52 '''
 53 
 54 # 載入資料
 55 adj, features, labels, idx_train, idx_val, idx_test = load_data()
 56 
 57 # Model and optimizer
 58 # 函式來自於models.py
 59 model = GCN(nfeat=features.shape[1], # 特徵維度,number of features
 60             nhid=args.hidden,
 61             nclass=labels.max().item() + 1,
 62             dropout=args.dropout)
 63 optimizer = optim.Adam(model.parameters(),
 64                        lr=args.lr, weight_decay=args.weight_decay)
 65 
 66 # 如果可以使用GPU,資料寫入cuda,便於後續加速
 67 # .cuda()會分配到視訊記憶體裡(如果gpu可用)
 68 if args.cuda:
 69     model.cuda()
 70     features = features.cuda()
 71     adj = adj.cuda()
 72     labels = labels.cuda()
 73     idx_train = idx_train.cuda()
 74     idx_val = idx_val.cuda()
 75     idx_test = idx_test.cuda()
 76 
 77 
 78 def train(epoch):
 79     # 返回當前時間
 80     t = time.time()
 81     # 將模型轉為訓練模式,並將優化器梯度置零
 82     model.train()
 83     # optimizer.zero_grad()意思是把梯度置零,即把loss關於weight的導數變成0;pytorch中每一輪batch需要設定optimizer.zero_grad
 84     optimizer.zero_grad()
 85 
 86     '''由於在算output時已經使用了log_softmax,這裡使用的損失函式是NLLloss,如果之前沒有加入log運算,
 87        這裡則應使用CrossEntropyLoss
 88        損失函式NLLLoss() 的輸入是一個對數概率向量和一個目標標籤. 它不會為我們計算對數概率,
 89        適合最後一層是log_softmax()的網路. 損失函式 CrossEntropyLoss() 與 NLLLoss() 類似,
 90        唯一的不同是它為我們去做 softmax.可以理解為:CrossEntropyLoss()=log_softmax() + NLLLoss()
 91        理論上,對於單標籤多分類問題,直接經過softmax求出概率分佈,然後把這個概率分佈用crossentropy做一個似然估計誤差。
 92        但是softmax求出來的概率分佈,每一個概率都是(0,1)的,這就會導致有些概率過小,導致下溢。 考慮到這個概率分佈總歸是
 93        要經過crossentropy的,而crossentropy的計算是把概率分佈外面套一個-log 來似然
 94        那麼直接在計算概率分佈的時候加上log,把概率從(0,1)變為(-∞,0),這樣就防止中間會有下溢位。 
 95        所以log_softmax本質上就是將本來應該由crossentropy做的取log工作提到預測概率分佈來,跳過了中間的儲存步驟,防止中間數值會有下溢位,使得資料更加穩定。 
 96        正是由於把log這一步從計算誤差提到前面的步驟中,所以用log_softmax之後,下游的計算誤差的function就應該變成NLLLoss
 97        (NLLloss沒有取log這一步,而是直接將輸入取反,然後計算其和label的乘積,求和平均)
 98     '''
 99     # 計算輸出時,對所有的節點都進行計算(呼叫了models.py中的forward即前饋函式)
100     output = model(features, adj)
101     # 損失函式,僅對訓練集的節點進行計算,即:優化對訓練資料集進行
102     loss_train = F.nll_loss(output[idx_train], labels[idx_train])
103     # 計算準確率
104     acc_train = accuracy(output[idx_train], labels[idx_train])
105     # 反向求導  Back Propagation
106     loss_train.backward()
107     # 更新所有的引數
108     optimizer.step()
109     # 通過計算訓練集損失和反向傳播及優化,帶標籤的label資訊就可以smooth到整個圖上(label information is smoothed over the graph)
110 
111     # 通過model.eval()轉為測試模式,之後計算輸出,並單獨對測試集計算損失函式和準確率。
112     if not args.fastmode:
113         # Evaluate validation set performance separately,
114         # deactivates dropout during validation run.
115         # eval() 函式用來執行一個字串表示式,並返回表示式的值
116         model.eval()
117         output = model(features, adj)
118 
119     # 測試集的損失函式
120     loss_val = F.nll_loss(output[idx_val], labels[idx_val])
121     acc_val = accuracy(output[idx_val], labels[idx_val])
122 
123     print('Epoch: {:04d}'.format(epoch+1),
124           'loss_train: {:.4f}'.format(loss_train.item()),
125           'acc_train: {:.4f}'.format(acc_train.item()),
126           'loss_val: {:.4f}'.format(loss_val.item()),
127           'acc_val: {:.4f}'.format(acc_val.item()),
128           'time: {:.4f}s'.format(time.time() - t))
129 
130 # 定義測試函式,相當於對已有的模型在測試集上執行對應的loss與accuracy
131 def test():
132     model.eval()
133     output = model(features, adj)
134     loss_test = F.nll_loss(output[idx_test], labels[idx_test])
135     acc_test = accuracy(output[idx_test], labels[idx_test])
136     print("Test set results:",
137           "loss= {:.4f}".format(loss_test.item()),
138           "accuracy= {:.4f}".format(acc_test.item()))
139 
140 
141 # Train model
142 # 逐個epoch進行train,最後test
143 t_total = time.time()
144 for epoch in range(args.epochs):
145     train(epoch) # 先訓練
146 print("Optimization Finished!")
147 print("Total time elapsed: {:.4f}s".format(time.time() - t_total))
148 
149 # Testing
150 test() # 再測試