拓冰建站拓冰建站
首页 / 资讯中心 / 正文

图卷积网络+BiLSTM:实体关系抽取的完整实现与部署指南

简介基于图卷积神经网络的实体关系抽取完整项目面向深度学习与自然语言处理学习者、毕业设计/课程设计学生解决从原始文本到实体关系分类的端到端流程。压缩包共41个文件、约93.32MB主要包含17个Python源文件和10个pyc编译文件覆盖数据预处理、模型构建、训练、测试、GUI交互等完整模块另有5个文本说明、2个JSON配置、2个pt模型文件以及PL评分脚本、NPY/PKL数据文件等训练好的模型与全部数据均已打包解压后按文档步骤即可复现。文档部分提供项目说明和部署教程源码中封装了词表构建、树结构处理、常量配置、训练器等基础组件结构清晰便于二次开发。目前已有151人学习下载。资源契合高校毕业设计、课程设计或期末项目需求也适合希望掌握图卷积网络在关系抽取中实际应用的开发者对照源码逐步进阶。1. 实体关系抽取为什么绕不开图卷积神经网络先抛一个现象同样是「乔布斯创立了苹果公司并担任首任 CEO」这个短句抽 (乔布斯, 创始人, 苹果公司) 用 BiLSTM 就能行可一旦把实体放到 40 字以上的长句里序列模型很容易把中心词盯丢。实体关系抽取要做的是把句子里的两个实体映射到预定义关系图卷积神经网络GCN的切入角度很直接——先对句子做依赖解析把语法结构当成一张图再用图卷积层做邻接聚合。相比 LSTM 沿时间步线性传信息GCN 能沿依赖树上的短路径把两个实体的信息直接拉近。这个标题对应的是一套完整链路源码、部署教程文档、全部数据、训练好的模型缺一环都跑不出分数。下面按链路展开给出可运行代码和参数。2. 图卷积网络的关系抽取建模依赖树、邻接矩阵与消息传递2.1 从句子到图依赖解析树与自环邻接矩阵先明确一点图卷积网络不直接吃原始句子它消费的是依赖树。以「乔布斯 创立 苹果 公司」这句话为例依赖解析会给出一个以谓词为中心的树结构root 是「创立」nsubj 指向「乔布斯」obl 指向「苹果」。树上的节点是每个词边是词与词之间的语法依赖。这套结构天然就是一张无向图转成邻接矩阵 A 只需两步A[i][j] 1 当且仅当词 i、词 j 之间有依赖边再把对角线全部置 1加上自环。自环是必须的否则 GCN 第 l 层做完邻居聚合之后节点自身的向量完全没有参与第 l1 层的输入等于每过一层就丢一次自己的原始信息层数一多特征就全被邻居「中和」了。依赖树的节点度数差异极大根节点可能只有两条边而并列结构、介词短语能连七八个节点如果不做归一化度数高的节点在聚合时会把邻居数量当成权重放大特征数值随层数膨胀训练很难收敛。2.2 GCN 层的消息传递公式、参数与维度变化单层 GCN 的前向计算可以用下面这个式子概括H^(l1) ReLU( D̃^(-1/2) · à · D̃^(-1/2) · H^(l) · W^(l) )其中 à 是加了自环的邻接矩阵D̃ 是 à 的度矩阵对角阵W^(l) 是该层的线性变换参数H^(l) 是第 l 层的节点特征矩阵。D̃^(-1/2)·Ã·D̃^(-1/2) 做的是对称归一化它等价于把每个节点的邻居特征按「两端节点度数的乘积开根号」缩放聚合结果不再受节点度数影响。下面是一个可以直接使用的 PyTorch 实现def normalize_adj_with_self_loop(adj): # adj: [batch, seq_len, seq_len]对角线为 0尚未加自环 adj adj.clone() bsz, seq_len, _ adj.shape eye torch.eye(seq_len, deviceadj.device).unsqueeze(0) adj adj eye # 第一步补自环节点聚合时保留自身特征 deg adj.sum(dim-1) # 第二步按行求和得到每个节点的度 deg_inv_sqrt deg.pow(-0.5) deg_inv_sqrt[torch.isinf(deg_inv_sqrt)] 0.0 d_inv_sqrt torch.diag_embed(deg_inv_sqrt) return torch.bmm(torch.bmm(d_inv_sqrt, adj), d_inv_sqrt) # 第三步对称归一化这段代码有三处要点。其一补自环用广播加法eye.unsqueeze(0) 变成 [1, L, L] 后直接加到 batch 的每一份邻接矩阵上其二deg.pow(-0.5) 之后要把 inf 置 0避免除以 0 在反向传播时产生 NaN其三两次 bmm 才是 D̃^(-1/2)·Ã·D̃^(-1/2)如果只乘一次 D̃^(-1/2)结果会偏向低度节点。归一化之后GCN 层的聚合退化成一次 batch 矩阵乘法不用像实现 Message Passing 那样逐边遍历。2.3 为什么要做依赖树剪枝路径约束与多跳关系的取舍整棵依赖树直接喂进图卷积网络效果往往不如「剪枝后的子图」。GCN 做关系抽取的经典做法是只保留两个实体节点在依赖树上的最短路径再向外扩展 k 跳邻居。原因有两个第一句子里的修饰成分、插入语、状语从句和当前实体对的关系判断无关它们会通过图卷积把噪声扩散到实体节点上第二两个实体在依赖树上可能隔着七八条边GCN 需要很多层才能把信息传过去而层数一多梯度衰减、过平滑所有节点特征趋同两个问题一起出现。剪枝以后实体间距离通常缩小到 12 跳两层 GCN 就够用。最近邻缓冲带参数 k 一般取 1。k0 意味着只保留路径主干过于严格k2 会把大量无关邻居放进来剪枝就失去意义。下面这个函数示意了路径剪枝加缓冲带的邻接矩阵重建逻辑def build_pruned_adj(dep_edges, e1, e2, max_len, k1): # dep_edges: [(父节点索引, 子节点索引)] 依赖边列表 # e1, e2: 两个实体在句子 token 序列中的位置索引 graph {i: set() for i in range(max_len)} for h, d in dep_edges: graph[h].add(d) graph[d].add(h) # 依赖树转无向图 path bfs_shortest_path(graph, e1, e2) # 实体间最短路径上的节点集合 keep set(path) for node in path: for nb in graph[node]: keep.add(nb) # 向外扩 1 跳引入局部上下文 adj np.zeros((max_len, max_len), dtypenp.float32) for i in keep: for j in graph[i]: if j in keep: adj[i][j] 1.0 return adj注意 keep 集合只决定「哪些节点参与消息传递」节点 i、j 之间有边才在矩阵里置 1。剪枝后邻接矩阵的稀疏度通常会降到原始依赖树的 30%50%配合稀疏矩阵存储一次 epoch 的显存占用和训练时间都能明显下降。依赖树剪枝在预处理阶段离线完成不要在训练循环里现算。3. 源码实现拆解PyTorch 版 BiLSTM-GCN 实体关系抽取模型3.1 项目文件结构拿到压缩包先核对这几项「源码部署教程文档全部数据训练好的模型」这类打包项目目录结构大多相似但拿到手先别急着跑 train.py先按职责核对文件是否齐全避免跑了一半才发现缺 checkpoint。我一般按这张表逐项确认目录/文件职责拿到手先确认的内容src/model.py模型定义输入是 token ids 还是已编码向量src/train.py训练入口读哪个配置文件、checkpoint 存到哪src/data_loader.py数据读取支持 SemEval 还是 JSON 格式data/原始与预处理数据train/dev/test 划分是否与文档一致checkpoints/训练好的模型权重.pt/.pth 文件是否带 optimizer 状态docs/部署教程文档运行环境依赖版本是否与代码匹配核对完再建环境安装依赖我一般这样做conda create -n re-gcn python3.8 -y pip install -r requirements.txt python src/train.py --config src/config/semeval.yaml第一条命令创建独立 Python 3.8 环境避免和系统全局环境互相污染第二条按 requirements.txt 安装全部依赖第三条是训练入口--config 指定数据集配置。常见的失败点是老项目用的 torch 版本和当前 numpy 版本 API 对不上报错集中在 pack_padded_sequence 和 torch.onnx.export 这两处先读 docs 里的环境要求一节不要直接装进全局环境。3.2 输入特征词向量与双实体位置向量拼接实体关系抽取模型的第一层输入通常不是单纯的 token embedding而是「词向量 相对实体位置向量」拼接。位置向量的做法是对句子里的每个 token分别计算它到实体 1 和实体 2 的相对距离再做 embedding。以「乔布斯 创立 苹果 公司」为例实体 1 是「乔布斯」位置 0实体 2 是「苹果」位置 2那么「创立」的相对位置就是 (1, -1)被映射成两个可学习的向量。class RelationEmbedding(nn.Module): def __init__(self, vocab_size, word_dim300, pos_dim50, max_pos60): super().__init__() self.max_pos max_pos self.word_embed nn.Embedding(vocab_size, word_dim, padding_idx0) self.pos1_embed nn.Embedding(max_pos * 2 1, pos_dim) self.pos2_embed nn.Embedding(max_pos * 2 1, pos_dim) def forward(self, token_ids, pos1, pos2): # pos1/pos2: 相对位置偏移范围 [-max_pos, max_pos]加 max_pos 平移成非负索引 w self.word_embed(token_ids) # [B, L, 300] p1 self.pos1_embed(pos1 self.max_pos) # [B, L, 50] p2 self.pos2_embed(pos2 self.max_pos) # [B, L, 50] return torch.cat([w, p1, p2], dim-1) # [B, L, 400]词向量维度 300、位置向量维度 50 是关系抽取里的常见配置拼接后每个 token 的表示是 400 维。pos1/pos2 在预处理阶段就要算好偏移超出 max_pos 的截断到边界不要用随机值填充否则模型会学到「异常距离」和「常见距离」之间的虚假关联。padding 位置的位置向量一般填 max_pos*2也就是边界外一档和所有真实距离区分开语义上也符合「这个 token 离两个实体都特别远」。3.3 BiLSTM 序列编码层拿到拼接特征后先用一层 BiLSTM 做序列编码再喂给图卷积层。为什么中间要隔一层 BiLSTM而不是直接把 word embedding 丢给 GCN因为 word embedding 是静态的不包含上下文GCN 只看依赖树上相邻的词如果两个实体在句法上距离远GCN 需要很多层才能把信息传过去。BiLSTM 先把整句话的上下文压缩进每个位置依赖树上的每个节点就都带着全局信息GCN 的层数可以做得更浅。self.encoder nn.LSTM( input_sizeword_dim 2 * pos_dim, # 400 hidden_size200, num_layers1, batch_firstTrue, bidirectionalTrue, dropout0.0 ) # 输出 [B, L, 400]等价于双向隐藏层拼接num_layers 通常取 1加深 BiLSTM 对关系抽取的提升很小却会把训练显存翻倍。hidden_size 取 200 时双向输出正好是 400 维和输入维度一致便于 GCN 层直接消费。dropout 在 LSTM 内部一般不开放在 GCN 层之间和分类头之前更有效这和使用预训练模型时「只开下游 dropout」的做法一致。3.4 图卷积聚合层邻接矩阵怎么参与前向计算图卷积层的核心是「邻居特征的加权求和」PyTorch 里直接用 bmm 把归一化邻接矩阵和特征矩阵乘起来class GCNLayer(nn.Module): def __init__(self, in_dim, out_dim, dropout0.5): super().__init__() self.linear nn.Linear(in_dim, out_dim, biasFalse) self.dropout nn.Dropout(dropout) def forward(self, h, adj_norm): # h: [B, L, in_dim]adj_norm: [B, L, L]对称归一化后的邻接矩阵 h_transform self.linear(h) # [B, L, out_dim] out torch.bmm(adj_norm, h_transform) # 邻居特征聚合 return F.relu(out)bmm 是 batch 矩阵乘法把每个样本的 [L, L] 邻接矩阵和 [L, out_dim] 特征相乘得到的每一行就是「按归一化权重加权求和后的邻居向量」。这里有两个容易写错的细节第一linear 的 bias 要置 False因为 bias 会在聚合时被累加 num_neighbors 次等于给每个节点叠加了一个与度数相关的常数偏移第二激活函数放聚合之后而不是之前顺序错了会出现「激活→加权→再激活」的双重非线性收敛会变慢。整个模型的 forward 流程是RelationEmbedding → BiLSTM → 多层 GCN每层之间加 dropout 和残差连接→ 取两个实体位置的向量 → 全连接分类头。图卷积网络通常堆 2 到 3 层超过 3 层以后依赖树上的节点感受野重叠严重收益是负的调参时优先动层数而不是 hidden size。3.5 关系分类头与损失函数分类头一般有两种做法。一种是直接把两个实体位置的 GCN 输出拼接后过一个带 dropout 的 Linear另一种是像 PCNN 那样把句子按实体切三段做 max-pooling 再拼接。对关系抽取任务后者对「关系由实体周围局部短语决定」的场景更稳。损失函数用交叉熵即可类别不平衡明显时给样本量小的关系类加大权重权重按 1/log(1 count) 设置比直接反概率更抗极端值。class RelationGCN(nn.Module): def __init__(self, n_rel): super().__init__() self.fc nn.Linear(400 * 2, n_rel) def forward(self, h, e1_idx, e2_idx): # h: [B, L, 400]最后一层图卷积输出 e1 h.gather(1, e1_idx.unsqueeze(-1).expand(-1, -1, h.size(-1))).squeeze(1) e2 h.gather(1, e2_idx.unsqueeze(-1).expand(-1, -1, h.size(-1))).squeeze(1) feat torch.cat([e1, e2], dim-1) # [B, 800] return self.fc(F.dropout(feat, p0.5, trainingself.training))gather 取实体向量时用的是模型输出的最后一层特征而不是 BiLSTM 的中间层输出——最后一层图卷积已经把依赖路径的信息聚合到实体节点上了取中间层等于把剪枝白做。e1_idx.shape 是 [B, 1]unsqueeze 后 expand 成 [B, 1, 400] 才能沿 dim1 gather这是按索引取向量最常见的写法。4. 数据与训练调参图卷积关系抽取模型的 F1 复现与提升要点4.1 训练数据格式SemEval 与远程监督数据的字段说明关系抽取公开数据集里SemEval 2010 Task 8 是图卷积网络类模型最常用的验证集规模小、标注规范单张消费级显卡几小时就能跑完。常见公开版本的原始语料是两行一组第一行是带 、 标记的句子第二行是关系类型例示如下The e1author/e1 of e2the key to salvation/e2 wrote a book. Cause-Effect(e1,e2)数据里一共 19 个关系类别去掉 Other 后实际评测 18 类。加载时先用正则把 ... 和 ... 的标签位置记下来再把标签从句子里剥掉实体在句中的索引就是后面计算相对位置向量的依据。除了 SemEval远程监督数据集如 NYT一般是 JSON 或三列格式字段通常是 sentence / head / tail / relation处理逻辑一致只是实体边界要从 JSON 里读。拿到打包数据后先统计 train/dev/test 的句子长度分布、实体对数量和每个关系类别的样本数再决定 max_len而不是拍脑袋填 128。4.2 预处理管道ID 化、文本截断与邻接矩阵生成预处理管道里最需要盯紧的是「邻接矩阵必须在同一套依赖解析结果上生成」。训练、验证、测试三个阶段必须用同一版本、同一参数的依赖解析器否则训练时的语法结构和推理时不一致F1 直接掉 3 到 5 个点。解析结果生成后离线把邻接矩阵存成边列表或稀疏矩阵不要在线解析否则数据加载速度会被解析器拖垮。def encode_example(tokens, vocab, e1_start, e2_start, max_len, max_pos60): tokens tokens[:max_len] token_ids [vocab.get(t, vocab.unk_idx) for t in tokens] def shift(idx): # 相对位置截断到 [-max_pos, max_pos]再平移成非负索引 return min(max(idx, -max_pos), max_pos) max_pos pos1 [shift(i - e1_start) for i in range(len(tokens))] pos2 [shift(i - e2_start) for i in range(len(tokens))] # 补 paddingtoken 用 pad_idx位置向量用 max_pos*2 这一档 while len(token_ids) max_len: token_ids.append(vocab.pad_idx) pos1.append(max_pos * 2) pos2.append(max_pos * 2) return token_ids, pos1, pos2shift 函数里的 max_pos 是把负偏移变成 Embedding 能查的非负索引。padding 位置的位置向量填 max_pos*2 而不是 0因为 0 对应「恰好距离为 -60」的真实语义混用会让模型分不清「真实远距离」和「占位符」。全部样本编码成 token_ids、pos1、pos2、adj 四个张量后放进 DataLoader 按 batch 抛给模型Shuffle 必须在 group by 句子长度之前做否则同类长度永远同批梯度方向偏斜。4.3 训练超参数表与早停策略图卷积网络的关系抽取模型超参数相对固定容易出效果的一套配置大致如下可以在此基础上再做网格搜索超参数常用值调整倾向词向量300 维 GloVe / 中文 300 维预训练换领域词向量比调 lr 收益大位置向量维度50小数据集增大到 100 无显著提升BiLSTM hidden200数据量大可加到 300GCN 层数2超过 3 层 F1 下降dropout0.5远程监督数据噪声大调到 0.6batch size32显存允许时 64 收敛更稳学习率Adam 1e-3或 SGD 1.0 配衰减换预训练模型时改 2e-5早停 patience10每 2 个 epoch 在 dev 上评估一次提示早停只有一个原则——以 dev Macro-F1 为准而不是以 loss 为准。关系抽取的类别不平衡很严重loss 下降不代表 F1 上升保存的最佳 checkpoint 是 dev F1 最高时的那份不是最后一个 epoch 的。远程监督数据集的 Other 类占比往往超过 70%常见做法是随机负采样把 Other 压到 50% 左右再训练否则模型会学成「永远输出 Other」也能拿到 70 准确率的假象。4.4 评估指标Macro-F1 的计算与 Other 类陷阱关系抽取评测指标统一口径是 Macro-F1即每个关系类别分别算精确率和召回率再对所有类别取平均。这里有两个坑。第一SemEval 官方评分是把 Other 类排除在平均之外的因为 Other 样本量最大把它算进去会把 F1 拉得虚高不同论文之间的对比就失真了第二很多实现直接调 sklearn 的 f1_score(averagemacro)它默认把所有类别都算进去和官方口径对不上复现分数时一定要看评估脚本里是否有跳过 Other 的逻辑。def semeval_macro_f1(preds, labels, relation_ids, other_id0): eps 1e-9 f1_list [] for rid in relation_ids: if rid other_id: continue # 官方口径跳过 Other 再平均 tp ((preds rid) (labels rid)).sum() fp ((preds rid) (labels ! rid)).sum() fn ((preds ! rid) (labels rid)).sum() p tp / (tp fp eps) r tp / (tp fn eps) f1_list.append(2 * p * r / (p r eps)) return float(np.mean(f1_list))注意关系类别是遍历 relation_ids 里配置的真实类别集合而不是从 0 到 num_classes 机械遍历避免把没参与训练的下标圈进来。eps 的作用是防止某个类别在 batch 里没有正样本时除零。测试时把 preds 的 argmax 结果拿来做同样的计算才能和论文基线对齐。先跑通 dev 再上测试集dev 上 F1 和测试集相差超过 3 个点说明预处理有问题不要急着调参。5. 部署上线加载训练好的模型做批量推理的三个关键动作5.1 确认模型文件完整性与入口脚本拿到训练好的模型先别急着看部署文档先写一段加载脚本验证权重能不能正常加载、输出形状是否和关系类别数一致ckpt torch.load(checkpoints/best_model.pt, map_locationcpu) model.load_state_dict(ckpt[model_state_dict]) model.eval() with torch.no_grad(): logits model(*(dummy_batch)) assert logits.shape[-1] num_relations如果 load_state_dict 报 missing 或 unexpected keys多半是训练时用了 DataParallel权重名带 module. 前缀加载时手动剥掉前缀即可from collections import OrderedDict new_state OrderedDict( (k.replace(module., ), v) for k, v in ckpt[model_state_dict].items() ) model.load_state_dict(new_state)map_locationcpu 是第一步先落 CPU确认权重数值完整后再切 GPU避免显存不足时报一堆难排查的 CUDA 错误。5.2 ONNX 导出与动态轴设置模型接进业务接口常见做法是导出 ONNX交给 ONNX Runtime 或 Triton 推理而不是在服务进程里直接跑 PyTorch。导出时最关键的参数是 dynamic_axes——关系抽取的输入序列长度不固定必须把 seq 维度设成动态否则导出后只能用固定 max_len 输入短句子也会被 pad 到 128 甚至 256推理延迟白涨一倍。dummy_token torch.zeros(1, 128, dtypetorch.long) dummy_pos1 torch.zeros(1, 128, dtypetorch.long) dummy_pos2 torch.zeros(1, 128, dtypetorch.long) dummy_adj torch.zeros(1, 128, 128, dtypetorch.float32) torch.onnx.export( model, (dummy_token, dummy_pos1, dummy_pos2, dummy_adj), re_gcn.onnx, input_names[token_ids, pos1, pos2, adj], output_names[logits], dynamic_axes{ token_ids: {0: batch, 1: seq}, pos1: {0: batch, 1: seq}, pos2: {0: batch, 1: seq}, adj: {0: batch, 1: seq, 2: seq}, }, opset_version17, )动态轴里 adj 要同时放开 batch 和两个 seq 维度因为邻接矩阵的形状由 batch 内最大句子长度决定。opset 版本越高算子融合越好但需要确认部署端的 ONNX Runtime 版本支持一般 opset 15 到 17 是稳妥区间。导出后用 onnxruntime 加载跑一遍 dummy 输入和 PyTorch 输出对比最大误差超过 1e-4 就要查是不是模型里有 eval 模式才生效的 dropout。5.3 推理侧的两个工程细节第一个细节是依赖解析器和模型必须用同一套分词粒度。训练时的 token 切分方式决定了依赖树结构和实体位置索引部署时如果换一个分词器切出的词序列不同邻接矩阵就错位预测结果直接崩。所以部署进程里要把分词器和依赖解析器随模型一起打包而不是在业务侧各自为政。第二个细节是 batch 推理的 padding 策略GCN 的邻接矩阵是 [B, L, L]同一 batch 里长短句差距大时padding 到最长句子的计算浪费非常明显。一个有效做法是按句子长度分桶比如 10、10–20、20–40、40 四桶各自成 batch 送推理ONNX Runtime 下通常能带来 30% 到 50% 的吞吐提升。验证部署结果时不要只看端到端功能「能不能出结果」而是从 dev 集随机抽 500 条同时跑离线模型和线上接口对比预测结果一致性一致率低于 99% 说明两头之间某一环分词、依赖解析、实体标注对不齐先回到预处理管道里查偏差。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门