1. ERNIESage 运行实例介绍 (1.8x 版本)
本项目主要是为了直接提供一个可以运行 ERNIESage 模型的环境。
在很多工业应用中,往往存在一种特殊的图:Text Graph。顾名思义,图的节点属性由文本构成,而边的构建提供了结构信息。如搜索场景下的 Text Graph,节点可由搜索词、网页标题、网页正文来表达,用户反馈和超链信息则可构成边关系。
ERNIESage 由 PGL 团队提出,是 ERNIE Sample Aggregation 的简称,该模型可以同时建模文本语义与图结构信息,有效提升 Text Graph 的应用效果。其中 ERNIE 是百度推出的基于知识增强的持续学习语义理解框架。
ERNIESage 是 ERNIE 与 GraphSAGE 碰撞的结果,它的结构如下图所示,主要思想是通过 ERNIE 作为聚合函数(Aggregators),建模自身节点和邻居节点的语义与结构关系。ERNIESage 对于文本的建模是构建在邻居聚合的阶段,中心节点文本会与所有邻居节点文本进行拼接;然后通过预训练的 ERNIE 模型进行消息汇聚,捕捉中心节点以及邻居节点之间的相互关系;最后使用 ERNIESage 搭配独特的邻居互相看不见的 Attention Mask 和独立的 Position Embedding 体系,就可以轻松构建 TextGraph 中句子之间以及词之间的关系。
使用 ID 特征的 GraphSAGE 只能够建模图的结构信息,而单独的 ERNIE 只能处理文本信息。通过 PGL 搭建的图与文本的桥梁,ERNIESage 能够很简单的把 GraphSAGE 以及 ERNIE 的优点结合一起。以下面 TextGraph 的场景,ERNIESage 的效果能够比单独的 ERNIE 以及 GraphSAGE 模型都要好。
ERNIESage 可以很轻松地在 PGL 中的消息传递范式中进行实现,目前 PGL 在 github 上提供了 3 个版本的 ERNIESage 模型:
- ERNIESage v1: ERNIE 作用于 text graph 节点上;
- ERNIESage v2: ERNIE 作用在 text graph 的边上;
- ERNIESage v3: ERNIE 作用于一阶邻居及起边上;
主要会针对 ERNIESage V1 和 ERNIESage V2 版本进行一个介绍。
1.1 算法实现
可能有同学对于整个项目代码文件都不太了解,因此这里会做一个比较简单的讲解。
核心部分包含:
- 数据集部分
- data.txt - 简单的输入文件,格式为每行 query \t answer,可作简单的运行实例使用。
- 模型文件和配置部分
- ernie_config.json - ERNIE 模型的配置文件。
- vocab.txt - ERNIE 模型所使用的词表。
- ernie_base_ckpt/ - ERNIE 模型参数。
- config/ - ERNIESage 模型的配置文件,包含了三个版本的配置文件。
- 代码部分
- local_run.sh - 入口文件,通过该入口可完成预处理、训练、infer 三个步骤。
- preprocessing 文件夹 - 包含 dump_graph.py, tokenization.py。在预处理部分,我们首先需要进行建图,将输入的文件构建成一张图。由于我们所研究的是 Text Graph,因此节点都是文本,我们将文本表示为该节点对应的 node feature(节点特征),处理文本的时候需要进行切字,再映射为对应的 token id。
- dataset/ - 该文件夹包含了数据 ready 的代码,以便于我们在训练的时候将训练数据以 batch 的方式读入。
- models/ - 包含了 ERNIESage 模型核心代码。
- train.py - 模型训练入口文件。
- learner.py - 分布式训练代码,通过 train.py 调用。
- infer.py - infer 代码,用于 infer 出节点对应的 embedding。
- 评价部分
- build_dev.py - 用于将我们的验证集修改为需要的格式。
- mrr.py - 计算 MRR 值。
要在这个项目中运行模型其实很简单,只要运行下方的入口命令就 ok 啦!但是,需要注意的是,由于 ERNIESage 模型比较大,所以如果 AIStudio 中的 CPU 版本运行模型容易出问题。因此,在运行部署环境时,建议选择 GPU 环境。
另外,如果提示出现了 GPU 空间不足等问题,我们可以通过调小对应 yaml 文件中的 batch_size 来调整,也可以修改 ERNIE 模型的配置文件 ernie_config.json,将 num_hidden_layers 设小一些。在这里,我仅提供了 ERNIESageV2 版本的 gpu 运行过程,如果同学们想运行其他版本的模型,可以根据需要修改下方的命令。
运行完毕后,会产生较多的文件,这里进行简单的解释。
- workdir/ - 这个文件夹主要会存储和图相关的数据信息。
- output/ - 主要的输出文件夹,包含了以下内容:(1) 模型文件,根据 config 文件中的 save_per_step 可调整保存模型的频率,如果设置得比较大则可能训练过程中不会保存模型; (2) last 文件夹,保存了停止训练时的模型参数,在 infer 阶段我们会使用这部分模型参数;(3) part-0 文件,infer 之后的输入文件中所有节点的 Embedding 输出。
为了可以比较清楚地知道 Embedding 的效果,我们直接通过 MRR 简单判断一下 data.txt 计算出来的 Embedding 结果,此处将 data.txt 同时作为训练集和验证集。
1.2 核心模型代码讲解
首先,我们可以通过查看 models/model_factory.py 来判断在本项目有多少种 ERNIESage 模型。
from models.base import BaseGNNModel
from models.ernie import ErnieModel
from models.erniesage_v1 import ErnieSageModelV1
from models.erniesage_v2 import ErnieSageModelV2
from models.erniesage_v3 import ErnieSageModelV3
class Model(object):
@classmethod
def factory(cls, config):
name = config.model_type
if name == "BaseGNNModel":
return BaseGNNModel(config)
if name == "ErnieModel":
return ErnieModel(config)
if name == "ErnieSageModelV1":
return ErnieSageModelV1(config)
if name == "ErnieSageModelV2":
return ErnieSageModelV2(config)
if name == "ErnieSageModelV3":
return ErnieSageModelV3(config)
else:
raise ValueError
可以看到一共有 ERNIESage 模型一共有 3 个版本,另外我们也提供了基本的 GNN 模型和 ERNIE 模型,感兴趣的同学可以自行查阅。
接下来,我主要会针对 ERNIESageV1 和 ERNIESageV2 这两个版本的模型进行关键部分的讲解,主要的不同其实就是消息传递机制(Message Passing)部分的不同。
1.2.1 ERNIESageV1 关键代码
# ERNIESageV1 的 Message Passing 代码
# 查找路径:erniesage_v1.py(__call__中的 self.gnn_layers) -> base.py(BaseNet 类中的 gnn_layers 方法) -> message_passing.py
# erniesage_v1.py
def __call__(self, graph_wrappers):
inputs = self.build_inputs()
feature = self.build_embedding(graph_wrappers, inputs[-1]) # 将节点的文本信息利用 ERNIE 模型建模,生成对应的 Embedding 作为 feature
features = self.gnn_layers(graph_wrappers, feature) # GNN 模型的主要不同,消息传递机制入口
outputs = [self.take_final_feature(features[-1], i, "final_fc") for i in inputs[:-1]]
src_real_index = L.gather(graph_wrappers[0].node_feat['index'], inputs[0])
outputs.append(src_real_index)
return inputs, outputs
# base.py -> BaseNet
def gnn_layers(self, graph_wrappers, feature):
features = [feature]
initializer = None
fc_lr = self.config.lr / 0.001
for i in range(self.config.num_layers):
if i == self.config.num_layers - 1:
act = None
else:
act = "leaky_relu"
feature = get_layer(
self.config.layer_type, # 对于 ERNIESageV1, 其 layer_type="graphsage_sum",可以到 config 文件夹中查看
graph_wrappers[i],
feature,
self.config.hidden_size,
act,
initializer,
learning_rate=fc_lr,
name="%s_%s" % (self.config.layer_type, i))
features.append(feature)
return features
# message_passing.py
def graphsage_sum(gw, feature, hidden_size, act, initializer, learning_rate, name):
"""doc"""
msg = gw.send(copy_send, nfeat_list=[("h", feature)]) # Send
neigh_feature = gw.recv(msg, sum_recv) # Recv
self_feature = feature
self_feature = fluid.layers.fc(self_feature,
hidden_size,
act=act,
param_attr=fluid.ParamAttr(name=name + "_l.w_0", initializer=initializer,
learning_rate=learning_rate),
bias_attr=name+"_l.b_0"
)
neigh_feature = fluid.layers.fc(neigh_feature,
hidden_size,
act=act,
param_attr=fluid.ParamAttr(name=name + "_r.w_0", initializer=initializer,
learning_rate=learning_rate),
bias_attr=name+"_r.b_0"
)
output = fluid.layers.concat([self_feature, neigh_feature], axis=1)
output = fluid.layers.l2_normalize(output, axis=1)
return output
通过上述代码片段可以看到,关键的消息传递机制代码就是 graphsage_sum 函数,其中 send、recv 部分如下。
def copy_send(src_feat, dst_feat, edge_feat):
"""doc"""
return src_feat["h"]
msg = gw.send(copy_send, nfeat_list=[("h", feature)]) # Send
neigh_feature = gw.recv(msg, sum_recv) # Recv
通过代码可以看到,ERNIESageV1 版本,其主要是针对节点邻居,直接将当前节点的邻居节点特征求和。再看到 graphsage_sum 函数中,将邻居节点特征进行求和后,得到了 neigh_feature。随后,我们将节点本身的特征 self_feature 和邻居聚合特征 neigh_feature 通过 fc 层后,直接 concat 起来,从而得到了当前 gnn layer 层的 feature 输出。
1.2.2 ERNIESageV2 关键代码
ERNIESageV2 的消息传递机制代码主要在 erniesage_v2.py 和 message_passing.py,相对 ERNIESageV1 来说,代码会相对长了一些。
为了使得大家对下面有关 ERNIE 模型的部分能够有所了解,这里先贴出 ERNIE 的主模型框架图。
具体的代码解释可以直接看注释。
# ERNIESageV2 的 Message Passing 代码
# 下面的函数都在 erniesage_v2.py 的 ERNIESageV2 类中
# ERNIESageV2 的调用函数
def __call__(self, graph_wrappers):
inputs = self.build_inputs()
feature = inputs[-1]
features = self.gnn_layers(graph_wrappers, feature)
outputs = [self.take_final_feature(features[-1], i, "final_fc") for i in inputs[:-1]]
src_real_index = L.gather(graph_wrappers[0].node_feat['index'], inputs[0])
outputs.append(src_real_index)
return inputs, outputs
# 进入 self.gnn_layers 函数
def gnn_layers(self, graph_wrappers, feature):
features = [feature]
initializer = None
fc_lr = self.config.lr / 0.001
for i in range(self.config.num_layers):
if i == self.config.num_layers - 1:
act = None
else:
act = "leaky_relu"
feature = self.gnn_layer(
graph_wrappers[i],
feature,
self.config.hidden_size,
act,
initializer,
learning_rate=fc_lr,
name="%s_%s" % ("erniesage_v2", i))
features.append(feature)
return features
接下来会进入 ERNIESageV2 主要的代码部分。
可以看到,在 ernie_send 函数用于将我们的邻居信息发送到当前节点。在 ERNIESageV1 中,我们在 Send 阶段对邻居节点通过 ERNIE 模型得到 Embedding 后,再直接求和,实际上当前节点和邻居节点之间的文本信息在消息传递过程中是没有直接交互的,直到最后才**concat**起来;而 ERNIESageV2 中,在 Send 阶段,源节点和目标节点的信息会直接 concat 起来,通过 ERNIE 模型得到一个统一的 Embedding,这样就得到了源节点和目标节点的一个信息交互过程,这个部分可以查看下面的 ernie_send 函数。
gnn_layer 函数中包含了三个函数:
1. ernie_send: 将 src 和 dst 节点对应文本 concat 后,过 Ernie 后得到需要的 msg,更加具体的解释可以看下方代码注释。
2. build_position_ids: 主要是为了创建位置 ID,提供给 Ernie,从而可以产生 position embeddings。
3. erniesage_v2_aggregator: gnn_layer 的入口函数,包含了消息传递机制,以及聚合后的消息 feature 处理过程。
# 进入 self.gnn_layer 函数
def gnn_layer(self, gw, feature, hidden_size, act, initializer, learning_rate, name):
def build_position_ids(src_ids, dst_ids): # 此函数用于创建位置 ID,可以对应到 ERNIE 框架图中的 Position Embeddings
# ...
pass
def ernie_send(src_feat, dst_feat, edge_feat):
"""doc"""
# input_ids,可以对应到 ERNIE 框架图中的 Token Embeddings
cls = L.fill_constant_batch_size_like(src_feat["term_ids"], [-1, 1, 1], "int64", 1)
src_ids = L.concat([cls, src_feat["term_ids"]], 1)
dst_ids = dst_feat["term_ids"]
term_ids = L.concat([src_ids, dst_ids], 1)
# sent_ids,可以对应到 ERNIE 框架图中的 Segment Embeddings
sent_ids = L.concat([L.zeros_like(src_ids), L.ones_like(dst_ids)], 1)
# position_ids,可以对应到 ERNIE 框架图中的 Position Embeddings
position_ids = build_position_ids(src_ids, dst_ids)
term_ids.stop_gradient = True
sent_ids.stop_gradient = True
ernie = ErnieModel( # ERNIE 模型
term_ids, sent_ids, position_ids,
config=self.config.ernie_config)
feature = ernie.get_pooled_output() # 得到发送过来的 msg,该 msg 是由 src 节点和 dst 节点的文本特征一起过 ERNIE 后得到的 embedding
return feature
def erniesage_v2_aggregator(gw, feature, hidden_size, act, initializer, learning_rate, name):
feature = L.unsqueeze(feature, [-1])
msg = gw.send(ernie_send, nfeat_list=[("term_ids", feature)]) # Send
neigh_feature = gw.recv(msg, lambda feat: F.layers.sequence_pool(feat, pool_type="sum")) # Recv,直接将发送来的 msg 根据 dst 节点来相加。
# 接下来的部分和 ERNIESageV1 类似,将 self_feature 和 neigh_feature 通过 concat、normalize 后得到需要的输出。
term_ids = feature
cls = L.fill_constant_batch_size_like(term_ids, [-1, 1, 1], "int64", 1)
term_ids = L.concat([cls, term_ids], 1)
term_ids.stop_gradient = True
ernie = ErnieModel(
term_ids, L.zeros_like(term_ids),
config=self.config.ernie_config)
self_feature = ernie.get_pooled_output()
self_feature = L.fc(self_feature,
hidden_size,
act=act,
param_attr=F.ParamAttr(name=name + "_l.w_0",
learning_rate=learning_rate),
bias_attr=name+"_l.b_0"
)
neigh_feature = L.fc(neigh_feature,
hidden_size,
act=act,
param_attr=F.ParamAttr(name=name + "_r.w_0",
learning_rate=learning_rate),
bias_attr=name+"_r.b_0"
)
output = L.concat([self_feature, neigh_feature], axis=1)
output = L.l2_normalize(output, axis=1)
return output
return erniesage_v2_aggregator(gw, feature, hidden_size, act, initializer, learning_rate, name)
2.总结
通过以上两个版本的模型代码简单的讲解,我们可以知道他们的不同点,其实主要就是在消息传递机制的部分有所不同。ERNIESageV1 版本只作用在 text graph 的节点上,在传递消息 (Send 阶段) 时只考虑了邻居本身的文本信息;而 ERNIESageV2 版本则作用在了边上,在 Send 阶段同时考虑了当前节点和其邻居节点的文本信息,达到更好的交互效果。