圖神經網路七日打卡營學習筆記及心得

疾風維繫發表於2020-11-28

學習來源百度AIstudio:https://aistudio.baidu.com/aistudio/index

Day-1 圖學習

Part1 什麼是圖

圖的兩個基本元素:點、邊
圖是一種統一描述複雜事物的語言
常見的圖:社交網路、推薦系統、化學分子結構…

Part2 什麼是圖學習

圖學習: Graph Learning。深度學習中的一個子領域,強調處理的資料物件為圖。
與一般深度學習的區別:能夠方便地處理不規則資料(樹、圖),同時也可以處理規則資料(如影像)。

Part3 圖學習的應用

我們可以把圖學習的應用分為節點級別任務、邊級別任務、圖級別任務。 課程中介紹了以下幾種任務。

節點級別任務:金融詐騙檢測(典型的節點分類)、自動駕駛中的3D點雲目標檢測
邊級別任務:推薦系統(典型的邊預測)
圖級別任務:氣味識別(典型的圖分類)、發現“宇宙”

Part4 圖學習是怎麼做的

圖遊走類演算法:通過在圖上的遊走,獲得多個節點序列,再利用 Skip Gram 模型訓練得到節點表示(下節課內容)
圖神經網路演算法:端到端模型,利用訊息傳遞機制實現。
知識圖譜嵌入演算法:專門用於知識圖譜的相關演算法。

Part5 PGL 圖學習庫簡介

Github 連結:https://github.com/PaddlePaddle/PGL

API文件: https://pgl.readthedocs.io/en/latest/

Part6 熟悉 PGL 使用

  1. 環境安裝 !pip install pgl
  2. 使用以下程式碼來構圖:
import pgl
from pgl import graph  # 匯入 PGL 中的圖模組
import paddle.fluid as fluid # 匯入飛槳框架
import numpy as np

def build_graph():
    # 定義圖中的節點數目,我們使用數字來表示圖中的每個節點
    num_nodes = 10

    # 定義圖中的邊集
    edge_list = [(2, 0), (2, 1), (3, 1),(4, 0), (5, 0),
             (6, 0), (6, 4), (6, 5), (7, 0), (7, 1),
             (7, 2), (7, 3), (8, 0), (9, 7)]

    # 隨機初始化節點特徵,特徵維度為 d
    d = 16
    feature = np.random.randn(num_nodes, d).astype("float32")

    # 隨機地為每條邊賦值一個權重
    edge_feature = np.random.randn(len(edge_list), 1).astype("float32")

    # 建立圖物件,最多四個輸入
    g = graph.Graph(num_nodes = num_nodes,
                    edges = edge_list,
                    node_feat = {'feature':feature},
                    edge_feat ={'edge_feature': edge_feature})

    return g

g = build_graph()

接下來我們列印圖的一些資訊:

print('圖中共計 %d 個節點' % g.num_nodes)
print('圖中共計 %d 條邊' % g.num_edges)
  1. 定義圖模型
    我們可以定義下面的一個簡單圖模型層,這裡的結構是新增了邊權重資訊的類 GCN 層。
# 定義一個同時傳遞節點特徵和邊權重的簡單模型層。
def model_layer(gw, nfeat, efeat, hidden_size, name, activation):
    '''
    gw: GraphWrapper 圖資料容器,用於在定義模型的時候使用,後續訓練時再feed入真實資料
    nfeat: 節點特徵
    efeat: 邊權重
    hidden_size: 模型隱藏層維度
    activation: 使用的啟用函式
    '''

    # 定義 send 函式
    def send_func(src_feat, dst_feat, edge_feat):
        # 將源節點的節點特徵和邊權重共同作為訊息傳送
        return src_feat['h'] * edge_feat['e']

    # 定義 recv 函式
    def recv_func(feat):
        # 目標節點接收源節點訊息,採用 sum 的聚合方式
        return fluid.layers.sequence_pool(feat, pool_type='sum')

    # 觸發訊息傳遞機制
    msg = gw.send(send_func, nfeat_list=[('h', nfeat)], efeat_list=[('e', efeat)])
    output = gw.recv(msg, recv_func)
    output = fluid.layers.fc(output,
                    size=hidden_size,
                    bias_attr=False,
                    act=activation,
                    name=name)
    return output
  1. 模型定義
    這裡我們簡單的把上述定義好的模型層堆疊兩層,作為我們的最終模型。
class Model(object):
    def __init__(self, graph):
        """
        graph: 我們前面建立好的圖
        """
        # 建立 GraphWrapper 圖資料容器,用於在定義模型的時候使用,後續訓練時再feed入真實資料
        self.gw = pgl.graph_wrapper.GraphWrapper(name='graph',
                    node_feat=graph.node_feat_info(),
                    edge_feat=graph.edge_feat_info())
        # 作用同 GraphWrapper,此處用作節點標籤的容器
        self.node_label = fluid.layers.data("node_label", shape=[None, 1],
                    dtype="float32", append_batch_size=False)

    def build_model(self):
        # 定義兩層model_layer
        output = model_layer(self.gw, 
                             self.gw.node_feat['feature'], 
                             self.gw.edge_feat['edge_feature'],
                             hidden_size=8, 
                             name='layer_1', 
                             activation='relu')
        output = model_layer(self.gw, 
                             output, 
                             self.gw.edge_feat['edge_feature'],
                             hidden_size=1, 
                             name='layer_2', 
                             activation=None)
                             
        # 對於二分類任務,可以使用以下 API 計算損失
        loss = fluid.layers.sigmoid_cross_entropy_with_logits(x=output, 
                                                              label=self.node_label)
        # 計算平均損失
        loss = fluid.layers.mean(loss)
        
        # 計算準確率
        prob = fluid.layers.sigmoid(output)
        pred = prob > 0.5
        pred = fluid.layers.cast(prob > 0.5, dtype="float32")
        correct = fluid.layers.equal(pred, self.node_label)
        correct = fluid.layers.cast(correct, dtype="float32")
        acc = fluid.layers.reduce_mean(correct)

        return loss, acc
  1. 訓練前準備
# 是否在 GPU 或 CPU 環境執行
use_cuda = False
place = fluid.CUDAPlace(0) if use_cuda else fluid.CPUPlace()

# 定義程式,也就是我們的 Program
startup_program = fluid.Program() # 用於初始化模型引數
train_program = fluid.Program()   # 訓練時使用的主程式,包含前向計算和反向梯度計算
test_program = fluid.Program()    # 測試時使用的程式,只包含前向計算

with fluid.program_guard(train_program, startup_program):
    model = Model(g)
    # 建立模型和計算 Loss
    loss, acc = model.build_model()
    # 選擇Adam優化器,學習率設定為0.01
    adam = fluid.optimizer.Adam(learning_rate=0.01)
    adam.minimize(loss) # 計算梯度和執行梯度反向傳播過程

# 複製構造 test_program,與 train_program的區別在於不需要梯度計算和反向過程。
test_program = train_program.clone(for_test=True)

# 定義一個在 place(CPU)上的Executor來執行program
exe = fluid.Executor(place)
# 引數初始化
exe.run(startup_program) 

# 獲取真實圖資料
feed_dict = model.gw.to_feed(g) 
# 獲取真實標籤資料
# 由於我們是做節點分類任務,因此可以簡單的用0、1表示節點類別。其中,黃色點標籤為0,綠色點標籤為1。
y = [0,1,1,1,0,0,0,1,0,1]
label = np.array(y, dtype="float32")
label = np.expand_dims(label, -1)
feed_dict['node_label'] = label
  1. 開始訓練
for epoch in range(30):
    train_loss = exe.run(train_program,
        feed=feed_dict,      # feed入真實訓練資料
        fetch_list=[loss],   # fetch出需要的計算結果
        return_numpy=True)[0]
    print('Epoch %d | Loss: %f' % (epoch, train_loss))

Day-2 圖遊走類模型

1.生成單條 DeepWalk 遊走序列

import numpy as np

%matplotlib inline
import matplotlib.pyplot as plt
import networkx as nx # networkx是一個常用的繪製複雜圖形的Python包。

import pgl

構建graph

在進行deepwalk遊走之前,我們需要構建一個圖網路。
圖網路的構建需要用到Graph類,Graph類的具體實現可以參考 PGL/pgl/graph.py
簡單展示一下如果構建一個圖網路:

def build_graph():
    # 定義節點的個數;每個節點用一個數字表示,即從0~9
    num_node = 10
    # 新增節點之間的邊,每條邊用一個tuple表示為: (src, dst)
    edge_list = [(2, 0), (2, 1), (3, 1),(4, 0), (0, 5), 
             (6, 0), (6, 4), (5, 6), (7, 0), (1, 7),
             (2, 7), (7, 3), (8, 0), (9, 7)]

    g = pgl.graph.Graph(num_nodes = num_node, edges = edge_list)

    return g

# 建立一個圖物件,用於儲存圖網路的各種資料。
g = build_graph()

def display_graph(g):
    nx_G = nx.Graph()
    nx_G.add_nodes_from(range(g.num_nodes))
    nx_G.add_edges_from(g.edges)
    
    pos = nx.spring_layout(nx_G, iterations=50)
    
    nx.draw(nx_G, 
            pos,
            with_labels=True,
            node_color=['y','y','g','g','g','y','y','g','y','g'], 
            node_size=1000)

    plt.show()

display_graph(g)

Deepwalk 取樣

DeepWalk會等概率的選取下一個相鄰節點加入路徑,直至達到最大路徑長度,或者沒有下一個節點可選。
在這裡插入圖片描述
因此, 假如我們想要得到一條walk, 我們需要輸入一個graph, 起始節點ID, 遊走的深度walk_len。

def deepwalk(graph, start_node, walk_len):
    walk = [start_node] # 初始化遊走序列

    for d in range(walk_len): # 最大長度範圍內進行取樣
        current_node = walk[-1] 
        successors = graph.successor(np.array([current_node])) # graph.successor: 獲取當前節點的後繼鄰居
        print("當前節點: %d" % current_node)
        print("後繼鄰居", successors[0])
        succ = successors[0]
        if len(succ) == 0:
            break
        next_node = np.random.choice(succ, 1)
        walk.extend(next_node)
        
    return walk

walk = deepwalk(g, 2, 4)
print(walk)

Day-3 圖神經網路模型(一)

今天課堂主要講解了三個部分:GCN 演算法、GAT 演算法、Message Passing 訊息傳遞機制。

GCN引數補充解釋

主要是幫助大家理解訊息傳遞機制的一些引數型別。

這裡我們給出一個簡化版本的 GCN 模型,幫助大家理解PGL框架實現訊息傳遞的流程。

import paddle.fluid.layers as L

def gcn_layer(gw, feature, hidden_size, activation, name, norm=None):
    """
    描述:通過GCN層計算新的節點表示
    輸入:gw - GraphWrapper物件
         feature - 節點表示 (num_nodes, feature_size)
         hidden_size - GCN層的隱藏層維度 int
         activation - 啟用函式 str
         name - GCN層名稱 str
         norm - 標準化tensor float32 (num_nodes,),None表示不標準化
    輸出:新的節點表示 (num_nodes, hidden_size)
    """

    # send函式
    def send_func(src_feat, dst_feat, edge_feat):
        """
        描述:用於send節點資訊。函式名可自定義,引數列表固定
        輸入:src_feat - 源節點的表示字典 {name:(num_edges, feature_size)}
             dst_feat - 目標節點表示字典 {name:(num_edges, feature_size)}
             edge_feat - 與邊(src, dst)相關的特徵字典 {name:(num_edges, feature_size)}
        輸出:儲存傳送資訊的張量或字典 (num_edges, feature_size) or {name:(num_edges, feature_size)}
        """
        return src_feat["h"] # 直接返回源節點表示作為資訊

    # send和recv函式是搭配實現的,send的輸出就是recv函式的輸入
    # recv函式
    def recv_func(msg):
        """
        描述:對接收到的msg進行聚合。函式名可自定義,引數列表固定
        輸出:新的節點表示張量 (num_nodes, feature_size)
        """
        return L.sequence_pool(msg, pool_type='sum') # 對接收到的訊息求和

    ### 訊息傳遞機制執行過程
    # gw.send函式
    msg = gw.send(send_func, nfeat_list=[("h", feature)]) 
    """ 
    描述:觸發message函式,傳送訊息並將訊息返回
    輸入:message_func - 自定義的訊息函式
         nfeat_list - list [name] or tuple (name, tensor)
         efeat_list - list [name] or tuple (name, tensor)
    輸出:訊息字典 {name:(num_edges, feature_size)}
    """

    # gw.recv函式
    output = gw.recv(msg, recv_func)
    """ 
    描述:觸發reduce函式,接收並處理訊息
    輸入:msg - gw.send輸出的訊息字典
         reduce_function - "sum"或自定義的reduce函式
    輸出:新的節點特徵 (num_nodes, feature_size)

    如果reduce函式是對訊息求和,可以直接用"sum"作為引數,使用內建函式加速訓練,上述語句等價於 \
    output = gw.recv(msg, "sum")
    """

    # 通過以activation為啟用函式的全連線輸出層
    output = L.fc(output, size=hidden_size, bias_attr=False, act=activation, name=name)
    return output

Day-4 Graphsage 取樣程式碼實踐

GraphSage的PGL完整程式碼實現位於 PGL/examples/graphsage/

本次實踐將帶領大家嘗試實現一個簡單的graphsage 取樣程式碼實現

1. 構建graph

在實現graphsage取樣之前,我們需要構建一個圖網路。

圖網路的構建需要用到Graph類,Graph類的具體實現可以參考 PGL/pgl/graph.py

下面我們簡單展示一下如何構建一個圖網路:

import random
import numpy as np
import pgl
import display
def build_graph():
    # 定義節點的個數;每個節點用一個數字表示,即從0~9
    num_node = 16
    # 新增節點之間的邊,每條邊用一個tuple表示為: (src, dst)
    edge_list = [(2, 0), (1, 0), (3, 0),(4, 0), (5, 0), 
             (6, 1), (7, 1), (8, 2), (9, 2), (8, 7),
             (10, 3), (4, 3), (11, 10), (11, 4), (12, 4),
             (13, 5), (14, 5), (15, 5)]

    g = pgl.graph.Graph(num_nodes = num_node, edges = edge_list)

    return g

# 建立一個圖物件,用於儲存圖網路的各種資料。
g = build_graph()
display.display_graph(g)

2. GraphSage取樣函式實現

GraphSage的作者提出了取樣演算法來使得模型能夠以Mini-batch的方式進行訓練,演算法虛擬碼見論文附錄A。

1.假設我們要利用中心節點的k階鄰居資訊,則在聚合的時候,需要從第k階鄰居傳遞資訊到k-1階鄰居,並依次傳遞到中心節點。
2.取樣的過程剛好與此相反,在構造第t輪訓練的Mini-batch時,我們從中心節點出發,在前序節點集合中取樣Nt個鄰居節點加入取樣集合。
3.接著將鄰居節點作為新的中心節點繼續進行第t-1輪訓練的節點 取樣,以此類推。
4.最後將取樣到的節點和邊一起構造得到子圖。

def traverse(item):
    """traverse
    """
    if isinstance(item, list) or isinstance(item, np.ndarray):
        for i in iter(item):
            for j in traverse(i):
                yield j
    else:
        yield item

def flat_node_and_edge(nodes):
    """這個函式的目的是為了將 list of numpy array 扁平化成一個list
    例如: [array([7, 8, 9]), array([11, 12]), array([13, 15])] --> [7, 8, 9, 11, 12, 13, 15]
    """
    nodes = list(set(traverse(nodes)))
    return nodes

def graphsage_sample(graph, start_nodes, sample_num):
    subgraph_edges = []
    # pre_nodes: a list of numpy array, 
    pre_nodes = graph.sample_predecessor(start_nodes, sample_num)

    # 根據取樣的子節點, 恢復邊
    for dst_node, src_nodes in zip(start_nodes, pre_nodes):
        for node in src_nodes:
            subgraph_edges.append((node, dst_node))

    # flat_node_and_edge: 這個函式的目的是為了將 list of numpy array 扁平化成一個list
    # [array([7, 8, 9]), array([11, 12]), array([13, 15])] --> [7, 8, 9, 11, 12, 13, 15]
    subgraph_nodes = flat_node_and_edge(pre_nodes)

    return subgraph_nodes, subgraph_edges

seed = 458
np.random.seed(seed)
random.seed(seed)

start_nodes = [0]

layer1_nodes, layer1_edges = graphsage_sample(g, start_nodes, sample_num=3)
print('layer1_nodes: ', layer1_nodes)
print('layer1_edges: ', layer1_edges)
display.display_subgraph(g, {'orange': layer1_nodes}, {'orange': layer1_edges})

layer2_nodes, layer2_edges = graphsage_sample(g, layer1_nodes, sample_num=2)
print('layer2_nodes: ', layer2_nodes)
print('layer2_edges: ', layer2_edges)
display.display_subgraph(g, {'orange': layer1_nodes, 'Thistle': layer2_nodes}, {'orange': laye

Day-5 ERNIESage程式碼解析

本專案主要是為了直接提供一個可以執行ERNIESage模型的程式碼介紹,以便同學們能夠直觀感受到ERNIESage的魅力,同時也會對ERNIESage中的部分關鍵程式碼進行必要講解。Let’s enjoy!

ERNIESage可以很輕鬆地在PGL中的訊息傳遞正規化中進行實現,目前PGL在github上提供了3個版本的ERNIESage模型:

ERNIESage v1: ERNIE 作用於text graph節點上;
ERNIESage v2: ERNIE 作用在text graph的邊上;
ERNIESage v3: ERNIE 作用於一階鄰居及起邊上;
講解流程
資料
模型
訓練

# 拉取PGL程式碼,由於github拉取較慢,已經提前拉取完畢了
# !git clone https://github.com/PaddlePaddle/PGL
# !cd PGL/example/erniesage
# 為了正常執行程式碼,首先我們需要安裝以下依賴
!pip install pgl
!pip install easydict
!python3 -m pip install --no-deps paddle-propeller
!pip install paddle-ernie
!pip uninstall -y colorlog
!export CUDAV_VISIBLE_DEVICES=0

資料

輸入example資料集

example_data/link_predict/graph_data.txt - 簡單的輸入檔案,格式為每行query \t answer,可作簡單的執行例項使用,link predict任務一般直接用圖中的邊作為訓練目標。


```javascript
! head -n 3 example_data/link_predict/graph_data.txt
! wc -l example_data/link_predict/graph_data.txt

如何表達一個文字圖

1.出現過的每一個文字段當作一個節點,比如“黑緣粗角肖葉甲觸角有多大?”就是一個節點
2.一行兩個節點作為一條邊
3.節點的文字段逐字轉成id,形成id序列,作為節點特徵

from preprocessing.dump_graph import dump_graph
from preprocessing.dump_graph import dump_node_feat
from preprocessing.dump_graph import download_ernie_model
from preprocessing.dump_graph import load_config
from pgl.graph_wrapper import BatchGraphWrapper
import propeller.paddle as propeller
import paddle.fluid as F
import paddle.fluid.layers as L
import numpy as np
from preprocessing.dump_graph import load_config
from models.pretrain_model_loader import PretrainedModelLoader
from pgl.graph import MemmapGraph
from models.encoder import linear
from ernie import ErnieModel
np.random.seed(123)
config = load_config("./config/erniesage_link_predict.yaml")
from preprocessing.dump_graph import dump_graph
from preprocessing.dump_graph import dump_node_feat
from preprocessing.dump_graph import download_ernie_model
from preprocessing.dump_graph import load_config
from pgl.graph_wrapper import BatchGraphWrapper
import propeller.paddle as propeller
import paddle.fluid as F
import paddle.fluid.layers as L
import numpy as np
from preprocessing.dump_graph import load_config
from models.pretrain_model_loader import PretrainedModelLoader
from pgl.graph import MemmapGraph
from models.encoder import linear
from ernie import ErnieModel
np.random.seed(123)
config = load_config("./config/erniesage_link_predict.yaml")
# 將原始QA資料產出一個文字圖,並使用grpah.dump存放到 workdir 目錄下
dump_graph(config)
dump_node_feat(config)
# MemmapGraph可以將PGL中graph.dump的模型,重新load回來
graph = MemmapGraph("./workdir/") 
# 看一下圖基礎資訊
print("節點", graph.num_nodes,"個") 
print("邊", graph.edges, graph.edges.shape)
# 看一下節點特徵
print([("%s shape is %s" % (key, str(graph.node_feat[key].shape))) for key in graph.node_feat])
print(graph.node_feat) #  按字的粒度轉成ID,每段文字為一個節點,文字全部保留40長度
# 1021個節點,每個節點有長度為40的id序列

模型

ERNIESage V1 模型核心流程

ERNIE提取節點語義 -> GNN聚合

# ERNIESage V1,ERNIE作用在節點上
class ERNIESageV1Encoder():
    def __init__(self, config):
        self.config = config

    def __call__(self, graph_wrappers, inputs):
        
        # step1. ERNIE提取節點語義
        # 輸入每個節點的文字的id序列
        term_ids = graph_wrappers[0].node_feat["term_ids"]
        
        cls = L.fill_constant_batch_size_like(term_ids, [-1, 1], "int64",
                                              self.config.cls_id) # cls [B, 1]
        term_ids = L.concat([cls, term_ids], 1) # term_ids [B, S]
        # [CLS], id1, id2, id3 .. [SEP]

        ernie_model = ErnieModel(self.config.ernie_config) 
        # 獲得ERNIE的[CLS]位置的表達
        cls_feat, _ = ernie_model(term_ids) # cls_feat [B, F]

        # step2. GNN聚合
        feature = graphsage_sum(cls_feat, graph_wrappers[0], self.config.hidden_size, "v1_graphsage_sum", "leaky_relu")
        
        final_feats = [
            self.take_final_feature(feature, i, "v1_final_fc") for i in inputs
        ]
        return final_feats
    
    def take_final_feature(self, feature, index, name):
        """take final feature"""
        feat = L.gather(feature, index, overwrite=False)
        feat = linear(feat, self.config.hidden_size, name)
        feat = L.l2_normalize(feat, axis=1)
        return feat


def graphsage_sum(feature, gw, hidden_size, name, act):
    # copy_send
    msg = gw.send(lambda src, dst, edge: src["h"], nfeat_list=[("h", feature)])
    # sum_recv
    neigh_feature = gw.recv(msg, lambda feat: L.sequence_pool(feat, pool_type="sum"))

    self_feature = linear(feature, hidden_size, name+"_l", act)
    neigh_feature = linear(neigh_feature, hidden_size, name+"_r", act)
    output = L.concat([self_feature, neigh_feature], axis=1) # [B, 2H]
    output = L.l2_normalize(output, axis=1)
    return output

# 隨機構造些資料
feat_size = 40
feed_dict = {
    "num_nodes": np.array([4]),
    "num_edges": np.array([6]),
    "edges": np.array([[0,1],[1,0],[0,2],[2,0],[0,3],[3,0]]),
    "term_ids": np.random.randint(4, 10000, size=(4, feat_size)),
    "inputs": np.array([0])}
place = F.CUDAPlace(0)
exe = F.Executor(place)

# 模型v1
erniesage_v1_encoder = ERNIESageV1Encoder(config)

main_prog, start_prog = F.Program(), F.Program()
with F.program_guard(main_prog, start_prog):
    with F.unique_name.guard():
        num_nodes = L.data("num_nodes", [1], False, 'int64')
        num_edges = L.data("num_edges", [1], False, 'int64')
        edges = L.data("edges", [-1, 2], False, 'int64')
        node_feat = L.data("term_ids", [-1, 40], False, 'int64')
        inputs = L.data("inputs", [-1], False, 'int64')

        # 輸入圖的基本資訊(邊、點、特徵)構造一個graph 
        gw = BatchGraphWrapper(num_nodes, num_edges, edges, {"term_ids": node_feat})
        outputs = erniesage_v1_encoder([gw], [inputs])

exe.run(start_prog)
outputs_np = exe.run(main_prog, feed=feed_dict, fetch_list=[outputs])[0]
print(outputs_np)

ERNIESage V2 核心程式碼

GNN send 文字id -> ERNIE提取邊語義 -> GNN recv 聚合鄰居語義 -> ERNIE提取中心節點語義並concat
圖片替換文字
為了使得大家對下面有關ERNIE模型的部分能夠有所瞭解,這裡先貼出ERNIE的主模型框架圖。

# ERNIESage V2,ERNIE作用在邊上
class ERNIESageV2Encoder():
    def __init__(self, config):
        self.config = config

    def __call__(self, graph_wrappers, inputs):
        gw = graph_wrappers[0]
        term_ids = gw.node_feat["term_ids"] # term_ids [B, S]
        
        # step1. GNN send 文字id
        def ernie_send(src_feat, dst_feat, edge_feat):
            def build_position_ids(term_ids):
                input_mask = L.cast(term_ids > 0, "int64")
                position_ids = L.cumsum(input_mask, axis=1) - 1
                return position_ids
            
            # src_ids, dst_ids 為傳送src和接收dst節點分別的文字ID序列
            src_ids, dst_ids = src_feat["term_ids"], dst_feat["term_ids"]

            # 生成[CLS]對應的id列, 並與前半段concat
            cls = L.fill_constant_batch_size_like(
                src_feat["term_ids"], [-1, 1], "int64", self.config.cls_id) # cls [B, 1]
            src_ids = L.concat([cls, src_ids], 1) # src_ids [B, S+1]

            # 將src與dst concat在一起作為完整token ids
            term_ids = L.concat([src_ids, dst_ids], 1) # term_ids [B, 2S+1]
            # [CLS], src_id1, src_id2.. [SEP], dst_id1, dst_id2..[SEP]

            sent_ids = L.concat([L.zeros_like(src_ids), L.ones_like(dst_ids)], 1)
            #   0, 0, 0 .. 0, 1, 1 .. 1 

            position_ids = build_position_ids(term_ids)
            #   0, 1, 2, 3 ..  
            
            # step2. ERNIE提取邊語義 
            ernie_model = ErnieModel(self.config.ernie_config)
            cls_feat, _ = ernie_model(term_ids, sent_ids, position_ids)
            # cls_feat 為ERNIE提取的句子級隱向量表達
            return cls_feat

        msg = gw.send(ernie_send, nfeat_list=[("term_ids", term_ids)])
        
        # step3. GNN recv 聚合鄰居語義 
        # 接收了鄰居的CLS語義表達,sum聚合在一起
        neigh_feature = gw.recv(msg, lambda feat: F.layers.sequence_pool(feat, pool_type="sum"))

        # 為每個節點也拼接一個CLS表達
        cls = L.fill_constant_batch_size_like(term_ids, [-1, 1],
                                              "int64", self.config.cls_id)
        
        term_ids = L.concat([cls, term_ids], 1)
        # [CLS], id1, id2, ... [SEP]
        
        # step4. ERNIE提取中心節點語義並concat
        # 對中心節點過一次ERNIE    
        ernie_model = ErnieModel(self.config.ernie_config)

        # 獲取中心節點的語義CLS表達
        self_cls_feat, _ = ernie_model(term_ids)

        hidden_size = self.config.hidden_size        
        self_feature = linear(self_cls_feat, hidden_size, "erniesage_v2_l", "leaky_relu")
        neigh_feature = linear(neigh_feature, hidden_size, "erniesage_v2_r", "leaky_relu")
        output = L.concat([self_feature, neigh_feature], axis=1)
        output = L.l2_normalize(output, axis=1)

        final_feats = [
            self.take_final_feature(output, i, "v2_final_fc") for i in inputs
        ]
        return final_feats

    def take_final_feature(self, feature, index, name):
        """take final feature"""
        feat = L.gather(feature, index, overwrite=False)
        feat = linear(feat, self.config.hidden_size, name)
        feat = L.l2_normalize(feat, axis=1)
        return feat
In [10]
# 直接run一下
erniesage_v2_encoder = ERNIESageV2Encoder(config)

main_prog, start_prog = F.Program(), F.Program()
with F.program_guard(main_prog, start_prog):
    with F.unique_name.guard():
        num_nodes = L.data("num_nodes", [1], False, 'int64')
        num_edges = L.data("num_edges", [1], False, 'int64')
        edges = L.data("edges", [-1, 2], False, 'int64')
        node_feat = L.data("term_ids", [10, 40], False, 'int64')
        inputs = L.data("inputs", [2], False, 'int64')

        gw = BatchGraphWrapper(num_nodes, num_edges, edges, {"term_ids": node_feat})
        outputs = erniesage_v2_encoder([gw], [inputs])

exe = F.Executor(place)
exe.run(start_prog)
outputs_np = exe.run(main_prog, feed=feed_dict, fetch_list=[outputs])[0]
print(outputs_np)

ERNIESage V3 核心過程

GNN send 文字id序列 -> GNN recv 拼接文字id序列 -> ERNIE同時提取中心和多個鄰居語義表達


from models.encoder import v3_build_sentence_ids
from models.encoder import v3_build_position_ids

class ERNIESageV3Encoder():
    def __init__(self, config):
        self.config = config

    def __call__(self, graph_wrappers, inputs):
        gw = graph_wrappers[0]
        term_ids = gw.node_feat["term_ids"]

        # step1. GNN send 文字id序列
        # copy_send
        msg = gw.send(lambda src, dst, edge: src["h"], nfeat_list=[("h", term_ids)])

        # step2. GNN recv 拼接文字id序列
        def ernie_recv(term_ids):
            """doc"""
            num_neighbor = self.config.samples[0]
            pad_value = L.zeros([1], "int64")

            # 這裡使用seq_pad,將num_neighbor個鄰居節點的文字id序列拼接在一下
            # 對於不足num_neighbor個鄰居的將會pad到num_neighbor個
            neighbors_term_ids, _ = L.sequence_pad(
                term_ids, pad_value=pad_value, maxlen=num_neighbor) # [B, N*S]

            neighbors_term_ids = L.reshape(neighbors_term_ids, [0, self.config.max_seqlen * num_neighbor])
            return neighbors_term_ids
    
        neigh_term_ids = gw.recv(msg, ernie_recv)
        neigh_term_ids = L.cast(neigh_term_ids, "int64")

        # step3. ERNIE同時提取中心和多個鄰居語義表達
        cls = L.fill_constant_batch_size_like(term_ids, [-1, 1], "int64",
                                              self.config.cls_id) # [B, 1]

        # 將中心與多個鄰居的文字全部拼接在一起,形成超長的文字(num_nerghbor+1) * seqlen
        multi_term_ids = L.concat([cls, term_ids[:, :-1], neigh_term_ids], 1) # multi_term_ids [B, (N+1)*S]
        # [CLS], center_id1, center_id2..[SEP]n1_id1, n1_id2..[SEP]n2_id1, n2_id2..[SEP]..[SEP]
        slot_seqlen = self.config.max_seqlen
        final_feats = []
        for index in inputs:
            term_ids = L.gather(multi_term_ids, index, overwrite=False)
            position_ids = v3_build_position_ids(term_ids, slot_seqlen)
            sent_ids = v3_build_sentence_ids(term_ids, slot_seqlen)

            # 將需要計算的超長文字,使用Ernie提取CLS位置的語義表達
            ernie_model = ErnieModel(self.config.ernie_config)
            cls_feat, _ = ernie_model(term_ids, sent_ids, position_ids)

            feature = linear(cls_feat, self.config.hidden_size, "v3_final_fc")
            feature = L.l2_normalize(feature, axis=1)
            final_feats.append(feature)
        return final_feats

# 直接run一下
erniesage_v3_encoder = ERNIESageV3Encoder(config)

main_prog, start_prog = F.Program(), F.Program()
with F.program_guard(main_prog, start_prog):
    num_nodes = L.data("num_nodes", [1], False, 'int64')
    num_edges = L.data("num_edges", [1], False, 'int64')
    edges = L.data("edges", [-1, 2], False, 'int64')
    node_feat = L.data("term_ids", [-1, 40], False, 'int64')
    inputs = L.data("inputs", [-1], False, 'int64')

    gw = BatchGraphWrapper(num_nodes, num_edges, edges, {"term_ids": node_feat})
    outputs = erniesage_v3_encoder([gw], [inputs])

exe.run(start_prog)
outputs_np = exe.run(main_prog, feed=feed_dict, fetch_list=[outputs])[0]
print(outputs_np)
下面展示一些 `內聯程式碼片`。

訓練

link predict任務
以一個link predict的任務為例,讀取一個語義圖,以上面的邊為目標進行無監督的訓練

In [17]
class ERNIESageLinkPredictModel(propeller.train.Model):
    def __init__(self, hparam, mode, run_config):
        self.hparam = hparam
        self.mode = mode
        self.run_config = run_config

    def forward(self, features):
        num_nodes, num_edges, edges, node_feat_index, node_feat_term_ids, user_index, \
            pos_item_index, neg_item_index, user_real_index, pos_item_real_index = features

        node_feat = {"index": node_feat_index, "term_ids": node_feat_term_ids}
        graph_wrapper = BatchGraphWrapper(num_nodes, num_edges, edges,
                                          node_feat)

        #encoder = ERNIESageV1Encoder(self.hparam)
        encoder = ERNIESageV2Encoder(self.hparam)
        #encoder = ERNIESageV3Encoder(self.hparam)

        # 中心節點、鄰居節點、隨機取樣節點 分別提取特徵
        outputs = encoder([graph_wrapper],
                          [user_index, pos_item_index, neg_item_index])
        user_feat, pos_item_feat, neg_item_feat = outputs
    
        if self.mode is not propeller.RunMode.PREDICT:
            return user_feat, pos_item_feat, neg_item_feat
        else:
            return user_feat, user_real_index

    def loss(self, predictions, labels):
        user_feat, pos_item_feat, neg_item_feat = predictions
        pos = L.reduce_sum(user_feat * pos_item_feat, -1, keep_dim=True) # 
        #neg = L.reduce_sum(user_feat * neg_item_feat, -1, keep_dim=True)# 60.
        neg = L.matmul(user_feat, neg_item_feat, transpose_y=True) # 80.
        # 距離(中心,鄰居)> 距離(中心,隨機負)
        loss = L.reduce_mean(L.relu(neg - pos + self.hparam.margin))
        return loss

    def backward(self, loss):
        adam = F.optimizer.Adam(learning_rate=self.hparam['learning_rate'])
        adam.minimize(loss)

    def metrics(self, predictions, label):
        return {}

from link_predict import train
from link_predict import predict

train(config, ERNIESageLinkPredictModel)

predict(config, ERNIESageLinkPredictModel)

! head output/part-0
如何評價

為了可以比較清楚地知道Embedding的效果,我們直接通過MRR簡單判斷一下graphp_data.txt計算出來的Embedding結果,此處將graph_data.txt同時作為訓練集和驗證集。

!python build_dev.py --path "./example_data/link_predict/graph_data.txt" # 此命令用於將訓練資料輸出為需要的格式,產生的檔案為dev_out.txt

# 接下來,計算MRR得分。
# 注意,執行此程式碼的前提是,我們已經將config對應的yaml配置檔案中的input_data引數修改為了:"data.txt"
# 並且注意訓練的模型是針對data.txt的,如果不符合,請重新訓練模型。
!python mrr.py --emb_path output/part-0

總結

通過以上三個版本的模型程式碼簡單的講解,我們可以知道他們的不同點,其實主要就是在訊息傳遞機制的部分有所不同。ERNIESageV1版本只作用在text graph的節點上,在傳遞訊息(Send階段)時只考慮了鄰居本身的文字資訊;而ERNIESageV2版本則作用在了邊上,在Send階段同時考慮了當前節點和其鄰居節點的文字資訊,達到更好的互動效果, ERNIESageV3則作用在中心和全部鄰居上,使節點之間能夠互相attention。

希望通過這一執行例項,可以幫助同學們對ERNIESage有更好的瞭解和認識,大家快快用起來吧!

相關文章