ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

实体关系抽取Pipeline实践:BiLSTM+CRF与BERT构建知识图谱

实体关系抽取Pipeline实践:BiLSTM+CRF与BERT构建知识图谱 简介面向自然语言处理研究者与知识图谱构建开发者这份资源提供了一套基于BiLSTMCRF与BERT的实体关系抽取完整解决方案。系统采用分阶段处理架构先通过双向长短期记忆网络与条件随机场结合的序列标注模型完成实体识别再借助BERT预训练语言模型实现关系分类可服务于结构化知识库与智能问答等应用场景的构建。压缩包共29个文件约40KB以17个Python脚本为核心覆盖实体识别模型、关系分类模型、联合训练器、数据加载与预处理等完整流水线另含配置与映射JSON文件、测试数据及备份内容模块划分清晰便于独立优化与组件替换。已有30人学习下载适合希望复现实验基准或在此框架上扩展关系推理能力的中高级NLP开发者。1. 实体关系抽取pipline是什么一个句子两种模型一串三元组如果只靠一个句子就要输出“李四工作于阿里巴巴”这种结构化三元组快速落地的成熟做法是基于BiLSTMCRF与BERT的实体关系抽取pipline实现。简单说pipeline把任务切成两段——前段用序列标注模型识别实体边界和类型后段用关系分类模型判断实体对之间有没有关系、是什么关系。这个方案最大的价值不是单点模型多先进而是每个环节可以独立调优、独立换模型线上出了问题能一眼看出是NER掉了还是关系分类掉了。适合正在做知识图谱、信息抽取落地或者准备用BERT系列模型搭第一版可运行系统的算法工程师和学生。2. 数据准备是pipeline的地基BIO标注、实体对构建与负采样实体关系抽取pipeline对数据的依赖程度比一般文本分类高得多。分类任务只需要文本和标签而实体关系抽取需要同时知道实体边界、实体类型、实体对位置和关系类型。数据组织得不干净后面两个模型都会跟着翻车。2.1 实体类型先定义实体span与标注工具的坐标系统一我一般会把实体类型控制在三类以内起步比如PER人名、ORG机构、POS职位。类型太多会导致标注一致性快速下降关系分类的类别空间也膨胀。先看一个标准的json样例它同时承载了实体标注和关系标注{ text: 李四在阿里巴巴担任技术经理, entities: [ {text: 李四, type: PER, start: 0, end: 2}, {text: 阿里巴巴, type: ORG, start: 3, end: 6}, {text: 技术经理, type: POS, start: 8, end: 10} ], relations: [ {head: [0, 2], tail: [3, 6], type: 工作于} ] }这里最容易被忽略的是start和end的坐标系。上面的样例用的是左闭右开区间也就是从start到end-1的字都属于实体。但很多标注工具导出的是左闭右闭start0, end2表示占了第0和第1两个字。这两种坐标系混用来NER训练时标签整体偏移一格验证集F1会突然掉到0.5附近。我的习惯是进入训练流程之前先写一个转换函数把所有数据统一成左闭右开并且做一次自动校验text[start:end] entity.text。注意实体标注里的“实体文本必须能由span切片原样还原”这是检查数据质量的第一条规则。2.2 把实体标注转成BIO序列NER模型的训练标签BIO是序列标注最常用的标签体系B表示实体开头I表示实体内部O表示非实体。为什么不直接上BMES因为BIO实现简单实体边界从B到I的连续区段就能还原对中小规模标注数据更稳。def convert_entities_to_bio(text, entities): tags [O] * len(text) for ent in entities: start, end ent[start], ent[end] # 统一左闭右开 if end start: continue tags[start] B- ent[type] for i in range(start 1, end): tags[i] I- ent[type] return tags # 示例text李四在阿里巴巴担任技术经理 # 输出[B-PER, O, O, B-ORG, I-ORG, I-ORG, O, O, B-POS, O]这个函数的逻辑很简单但里面有一个参数细节如果实体文本和句子长度不一致或者多音字占位问题切片就会错位。中文字符串按字符索引没问题但混入英文或数字时分词器的token长度和字符长度不一致转换成模型输入时要做对齐。常见做法是字符级NER用字符索引tokenizer之后建立char_to_token映射这一步不做后面预测时实体边界全乱。标签映射表建议这样组织{O: 0, B-PER: 1, I-PER: 2, B-ORG: 3, I-ORG: 4, B-POS: 5, I-POS: 6}。注意标签数量在训练和推理时必须完全一致我在工程里遇到过一次预处理脚本覆盖了tag2id文件导致离线评估正常、线上服务解码越界定位了一下午。2.3 关系分类训练样本实体对与负样本采样关系分类不是整句分类而是以实体对为单位。一个句子有n个实体理论上有n×(n-1)个候选对但只有一小部分是有关系的正例。为了让模型学会判断“无关系”必须构造负样本否则模型会倾向于把任何实体对都预测成有关系。import random def build_relation_samples(text, entities, relations, negative_ratio3): samples [] gold {} for rel in relations: head (rel[head][0], rel[head][1]) tail (rel[tail][0], rel[tail][1]) gold[(head, tail)] rel[type] gold[(tail, head)] rel[type] # 双向都视为关系方向在推理时再处理 pairs [] for i in range(len(entities)): for j in range(len(entities)): if i j: continue head_span (entities[i][start], entities[i][end]) tail_span (entities[j][start], entities[j][end]) pairs.append((head_span, tail_span)) positive_list [] negative_list [] for head_span, tail_span in pairs: if (head_span, tail_span) in gold: positive_list.append({ text: text, head_span: head_span, tail_span: tail_span, head_type: None, tail_type: None, relation: gold[(head_span, tail_span)] }) else: negative_list.append({ text: text, head_span: head_span, tail_span: tail_span, head_type: None, tail_type: None, relation: 无关系 }) random.shuffle(negative_list) negative_list negative_list[: len(positive_list) * negative_ratio] samples positive_list negative_list random.shuffle(samples) return samplesnegative_ratio建议设置在3左右。太高会让模型把所有样本都判为无关系太低则模型对无关系缺乏判别力。双向加入关系是个务实技巧训练阶段让模型学到“这段文本里这两个span存在关系”推理阶段再根据实体顺序或额外特征确定方向。如果直接不做反向扩增正样本数量直接砍半小型数据集上F1会下降两三个点。2.4 数据集划分NER和关系分类各存一份pipeline两个模型的数据不要合并保存。NER训练文件只保留text和BIO标签关系分类训练文件保留text、实体span和关系标签。原因是评估时实体识别的错误会传导给关系分类如果把NER的错误实体也作为关系分类训练样本模型会学着把错误边界当成合理输入上线后误差被放大。我习惯按照8:1:1切分句子级数据切分时以句子为最小单位而不是文档。如果多个句子属于同一文档需要保证同文档的句子不在训练和测试两个集合里同时出现否则文档级主题词泄漏会让验证指标虚高。3. NER双模型实现BiLSTMCRF与BERTCRF的训练要点实体识别是整个pipeline的第一个环节实体错了后面关系分类几乎不可能对。这个模块我通常维护两套可切换的模型一套BiLSTMCRF用于快速迭代和低成本部署一套BERTCRF用于追求准确率。两套模型共享同一套BIO标签映射切换成本很低。3.1 CRF在NER里的价值约束标签转移单独用BiLSTM或BERT输出每个token的标签概率本质上是在做逐位置独立决策。问题在于序列标签之间存在强约束I-PER不能出现在O后面B-ORG后面如果跟I-PER那显然是错误路径。CRF引入一个可学习的转移矩阵在解码阶段寻找整条序列的全局最优路径把这些非法转移直接压掉。这也是模型效果好坏的“黑匣子”之一。很多人觉得CRF只是加了个约束实际效果上它对F1的提升通常在1到3个点之间尤其对长实体的尾部边界修正帮助明显。3.2 BiLSTMCRF模型结构与bilstm代码下面是一份可以直接跑通的BiLSTMCRF模型定义核心结构是Embedding层、双向LSTM、线性发射层和CRF转移矩阵。import torch import torch.nn as nn class BiLSTMCRF(nn.Module): def __init__(self, vocab_size, tag_size, emb_dim128, hidden_dim256): super().__init__() self.embedding nn.Embedding(vocab_size, emb_dim, padding_idx0) self.bilstm nn.LSTM(emb_dim, hidden_dim // 2, num_layers2, batch_firstTrue, bidirectionalTrue) self.dropout nn.Dropout(0.5) self.hidden2tag nn.Linear(hidden_dim, tag_size) # CRF转移矩阵多出2个状态表示START和STOP self.transitions nn.Parameter(torch.randn(tag_size 2, tag_size 2)) self.start_tag tag_size self.stop_tag tag_size 1 def forward(self, input_ids): emb self.dropout(self.embedding(input_ids)) output, _ self.bilstm(emb) emissions self.hidden2tag(self.dropout(output)) return emissionsforward返回的emissions是每个token属于每个标签的分数后面要用前向算法计算CRF的loss用维特比算法解码。这里有几个参数值得说明hidden_dim256是双向LSTM输出的总维度实际每个方向是128。hidden_dim不要一上来就调到512序列标注任务在小数据集上很容易过拟合。num_layers2对中文NER是经验值一层欠拟合三层收益很小且训练变慢。pad_token_idx设为0需要和tokenizer保持一致。如果文本embedding和CRF是分开初始化的务必要用相同的随机种子。训练时的loss计算不建议自己手写前向算法来节省时间成本直接用torchcrf这类经过验证的CRF实现也可以。我还是更建议把转移矩阵留成可读的nn.Parameter这样在调试时可以打印出哪些转移分数异常便于判断标签约束是否学崩了。3.3 用bert模型做编码器并接CRFbert参数下载与本地加载换成BERT做编码器后模型结构其实只改了一个部分把EmbeddingLSTM换成pre-trained Transformer。加载预训练权重时第一次调用会自动下载到缓存目录之后重载会直接读缓存。对中文任务来说用本地路径加载更快更可控。from transformers import BertModel class BertCRF(nn.Module): def __init__(self, bert_path, tag_size): super().__init__() self.bert BertModel.from_pretrained(bert_path) self.dropout nn.Dropout(0.3) self.classifier nn.Linear(self.bert.config.hidden_size, tag_size) self.transitions nn.Parameter(torch.randn(tag_size 2, tag_size 2)) self.start_tag tag_size self.stop_tag tag_size 1 def forward(self, input_ids, attention_mask): outputs self.bert(input_ids, attention_maskattention_mask) seq_out outputs.last_hidden_state emissions self.classifier(self.dropout(seq_out)) return emissionsBERT的hidden_size是768classifier输出维度是tag_sizeCRF部分与BiLSTM版本完全一致。bert参数下载这个环节我用的是huggingface transformers自动缓存机制路径通常在系统用户目录下的.cache/huggingface/hub里。手动下载时需要注意模型权重文件必须放在同一个目录并且目录名不能随意改否则加载时找不到索引文件。数据量小于5万句时不建议对BERT做全参数微调。常见做法是冻结前几层只微调后几层和分类头。全参数微调的效果在多轮标注数据上会更好但需要更小的学习率这个在下一节讲。3.4 训练参数和微调策略从学习率到early stopping我把两套模型的训练参数分开维护最简洁的方式是直接在命令行脚本里指定。python train_ner.py \ --model bilstm_crf \ --epochs 30 \ --batch_size 64 \ --learning_rate 1e-3 \ --max_seq_len 128 \ --warmup_ratio 0.1 \ --early_stop 5如果切到BERT模型参数变化比较大单独列一张对照表更直观参数BiLSTMCRFBERTCRF学习率1e-35e-5批大小6416或32序列最大长度128128优化器AdamAdamWwarmup不需要0.1比例主要调节手段hidden_dim、dropout微调层数、学习率训练时我习惯在同一个脚本里把两组参数分开BiLSTM部分用1e-3BERT部分用5e-5而不是统一设一个学习率。BERT微调里如果发现loss在几个epoch内不下降先别急着加大学习率大概率是学习率太高导致优化不稳定。BERT部分的最优学习率应该在2e-5到8e-5之间超过1e-4基本是玄学翻车重灾区。early_stop设5验证指标用实体级F1。这里验证集必须和训练集完全隔离否则early stopping选出的模型过拟合风险很高。4. 关系分类与pipeline串接把NER输出变成关系三元组NER模型把实体找出来之后关系分类决定这些实体之间是什么关系。这一步的输入设计直接决定关系分类能不能学会“位置”和“类型”带来的语义。4.1 关系分类的输入特征选择只靠CLS输出不够最直接的做法是把句子和两个实体向量拼在一起但常见的翻车方式是只取BERT的CLS向量忽略了实体本身的位置。CLS向量携带的是整句语义它对“第一个实体是谁”不敏感。同样是“李四在阿里巴巴担任技术经理”如果换两句文本的主语和宾语位置CLS向量不会自动区分。我现在的做法是把三个信息拼进特征向量BERT的CLS输出、head和tail的位置embedding、head和tail的实体类型embedding。位置embedding让模型感知实体在句子里的相对位置实体类型embedding让模型知道主语是一个人名、宾语是一个机构名。4.2 关系分类模型结构位置、类型特征与分类头import torch import torch.nn as nn from transformers import BertModel class RelationClassifier(nn.Module): def __init__(self, bert_path, rel_size, max_seq_len128, type_size8): super().__init__() self.bert BertModel.from_pretrained(bert_path) self.head_pos nn.Embedding(max_seq_len, 64) self.tail_pos nn.Embedding(max_seq_len, 64) self.type_emb nn.Embedding(type_size, 32) feature_dim self.bert.config.hidden_size 64 * 2 32 * 2 self.classifier nn.Sequential( nn.Linear(feature_dim, 256), nn.ReLU(), nn.Dropout(0.3), nn.Linear(256, rel_size) ) def forward(self, input_ids, attention_mask, head_pos, tail_pos, head_type, tail_type): cls_out self.bert(input_ids, attention_maskattention_mask).last_hidden_state[:, 0] feature torch.cat([ cls_out, self.head_pos(head_pos), self.tail_pos(tail_pos), self.type_emb(head_type), self.type_emb(tail_type) ], dim-1) return self.classifier(feature)这个结构的重点是head_pos和tail_pos它们是head实体在句子中起始位置对应的token索引通常取start位置。对于中文句子按字切分的情况start位置就是字的序号。类型embedding的长度是type_size实体的类型数加一个unknown避免预测阶段出现NER模型吐出一个训练时没见过的类型导致索引越界。分类头用了两层MLP中间维度256。这个维度不需要很大关系分类的决策更多依赖实体本身的语义和类型组合而不是句子所有细节。激活函数ReLUdropout设在0.3左右分类头过拟合速度通常较快。4.3 pipeline串接与候选生成一个句子被切成多少个关系对推理阶段的pipeline逻辑就是把NER结果和关系分类结果串起来。先跑NER再用实体对生成候选最后过关系分类器。def run_pipeline(text, ner_model, rel_model, id2rel, max_gap20, threshold0.7): entities ner_model.predict(text) pairs [] for i in range(len(entities)): for j in range(len(entities)): h, t entities[i], entities[j] # 跳过同一实体和位置倒置 if h[start] t[start]: continue gap t[start] - h[end] if gap max_gap: continue pairs.append((h, t)) if not pairs: return [] # 批量拼输入同一个实体对构造一次输入 features [build_relation_feature(text, h, t) for h, t in pairs] logits rel_model.predict_batch(features) triples [] for (h, t), logit in zip(pairs, logits): rel_id int(logit.argmax()) score float(logit.max()) if rel_id 0 and score threshold: rel_name id2rel[rel_id] triples.append((h[text], rel_name, t[text], score)) return triplesmax_gap这个参数很实用一句话里两个实体相隔20个字以上大部分关系都不成立直接剪掉可以减少关系分类的推理次数。如果一个句子有8个实体理论上是28个候选对加了这个过滤可能只剩十来个推理耗时能降一半。注意这里的rel_id大于0才输出是把“无关系”类别固定为0。训练关系分类时类别0专门用于无关系样本这比在输出端再套一层阈值更稳。4.4 阈值与方向三元组怎么写成规范顺序三元组的顺序问题容易被忽略。关系分类模型训练时如果只用一个方向预测时遇到“阿里巴巴的李四”这类句子就会把主语和宾语搞反。我的处理方式是训练阶段双向扩增推理阶段根据实体类型先判断关系方向。常见做法是维护一个方向优先级规则如果关系类型是“工作于”主语一般是PER宾语一般是ORG如果主语类型出现在宾语类型之前就按原文顺序输出。这个规则写死在推理代码里虽然不优雅但线上效果比让模型自己猜方向稳定得多。阈值0.7是个经验值数据多可以往上调数据少就得往下放否则召回率会变得很难看。5. 实体关系抽取pipeline避坑记录数据、训练和上线的五类问题5.1 标注不一致导致验证集F1忽高忽低现象NER模型在训练集上每次迭代都上升验证集F1有时0.9有时0.72波动范围很大而且不随epoch数收敛。原因标注人员对实体边界的标准不统一。比如“阿里巴巴”有的标成“阿里”有的标成“阿里巴巴集团”导致同一实体在训练和验证中有不同的期望边界。解决先制定标注规范规定实体取最小完整词还是最大组织名冻结下来再训练。同时抽出10%的标注数据做双人重标用实体级F1评估两个人的标注一致性一致性低于90%先别上模型。5.2 BIO体系处理不了嵌套实体现象句子“阿里巴巴集团旗下的淘宝”里“阿里巴巴集团”是ORG“淘宝”也是ORG如果还想标出“阿里巴巴”这个ORG嵌套在“阿里巴巴集团”内部BIO直接无解。原因BIO是扁平标签体系一个token只有一个标签。解决如果任务确实存在嵌套实体就抛弃BIO改用span抽取方案比如用BIO标注后再做后处理或者直接上基于机器阅读理解的抽取方式。若坚持pipeline就把嵌套实体里的内层实体视为负样本重标注避免模型学到互相矛盾的标签。5.3 关系类别不平衡模型整体偏向“无关系”现象关系分类验证集accuracy很高但每个具体关系类别的F1很低。一查预测结果几乎所有样本都落在了“无关系”类别里。原因负样本比例太高。句子里的实体对大多数没有关系如果不做负采样模型只要全部分类成无关系就能获得极高准确率。解决训练时把负样本比例限制在正样本的3倍以内评估时看macro-F1而不是整体accuracy对低频关系类别做损失加权权重可以设为类别样本数的倒数再归一化。5.4 BERT微调一上来就把学习率拉到1e-4loss直接起飞现象BERTCRF训练前几个epoch loss大幅震荡后续也不下降验证F1几乎不涨。原因预训练模型微调对学习率极其敏感尤其是CRF的转移矩阵和BERT分类头一起更新时太高的学习率会破坏预训练权重。解决BERT部分学习率设置在5e-5附近配套warmup_ratio约0.1。分类头和CRF层可以单独设高一点的学习率比如1e-4到2e-4但不宜超过这个范围。还有一个经验如果显存允许批大小尽量稳定在16或32不要为了提速强行调到128。5.5 一句话10个实体关系分类推理慢到不可接受现象NER单个句子只需要几毫秒但整个pipeline跑一个句子要200毫秒以上一查耗时全在关系分类的循环推理上。原因n个实体产生n×(n-1)/2个候选对逐对跑模型自然慢。很多实现里每个候选对都单独过一遍BERT没有复用句子的句向量。解决先按实体类型剪枝比如“工作于”关系几乎不会出现在两个PER之间。再引入max_gap限制。剩下的候选对合并成batch一次过模型比循环推理快很多。线上比对精度要求更高时还可以用规则表先卡掉明显不相关的实体类型组合例如POS与ORG之间通常不构成任职关系。6. 三元组指标拆解与加速方向把pipeline调到可上线评估pipeline不能只盯NER或关系分类的单点指标要落到三元组级F1。三元组定义为(head_span, head_type, relation, tail_span, tail_type)实体span和类型必须完全匹配才算对。def triplet_f1(pred_triples, gold_triples): pred_set set(pred_triples) gold_set set(gold_triples) tp len(pred_set gold_set) precision tp / len(pred_set) if pred_set else 0 recall tp / len(gold_set) if gold_set else 0 f1 2 * precision * recall / (precision recall) if precision recall else 0 return precision, recall, f1拿到三元组F1之后我的习惯是先做badcase归因。把预测错误分成两类实体边界或类型错导致的定位到NER实体对完全正确但关系标签错的定位到关系分类。这一步筛完再去调对应的模型和特征而不是两头一起盲调。加速方向上一个可行做法是把BERT蒸馏到BiLSTMCRF。NER部分蒸馏后实体F1损失通常在1个点以内但推理速度可以快一个数量级。关系分类部分如果数据量不大直接用BiLSTMattention替代BERT把实体位置和类型embedding作为输入效果差距会更小。我现在拿到新的NERRE任务第一反应永远是先把一套完整pipeline跑通再根据badcase分布决定要不要换联合抽取模型。虽然联合模型在公开数据集上指标更漂亮但pipeline的定位和排查成本低得多尤其是数据还在快速迭代的阶段。希望帮到你。本文还有配套的精品资源点击获取
返回列表