☰
两段式实体关系抽取实战:BiLSTM+CRF与BERT组合pipeline
2026/10/2 14:59:29 网站建设 项目流程

简介:面向自然语言处理研究与工程实践,这份实体关系抽取Pipeline实现以BiLSTM+CRF完成实体识别、BERT完成关系分类,最终输出可导入知识图谱的三元组结果。适合NLP学习者、算法工程师及知识图谱方向研究者,用于复现实体关系抽取流程、对比序列标注模型与深度语义模型的配合效果。压缩包共29个文件,整体约40KB,主体为17个Python脚本,按mains、data_loader、utils等目录组织,覆盖数据预处理、实体识别训练、关系分类训练、联合推理与部署调用;另有4个JSON映射配置、备份文件与README,便于快速搭建可运行环境并理清模块间调用关系。项目采用分阶段解耦设计:实体识别阶段利用BiLSTM双向上下文特征与CRF标签转移约束提升边界准确性;关系分类阶段借助BERT预训练表征获取深层语义向量。已有30人学习该资源,内含训练流水线与评估指标,并附demo预测脚本和测试数据,可直接扩展为知识图谱自动抽取工具,也适合作为论文复现或课程设计的参考基线。

1. 实体关系抽取这条pipeline:为什么两段式比端到端更值得先落地

做知识图谱抽取的人,迟早会在同一个路口卡住:公开数据集上刷分的端到端模型,换成自己的业务文本,准确率直接腰斩。原因不复杂——真实文本里的实体边界模糊、关系类型分布极不均衡,端到端模型把两个难问题绑在一起,一出错根本分不清是实体识别错了还是关系判断错了。这份资源给的是一条两段式实体关系抽取pipeline:先用BiLSTM+CRF做序列标注把实体边界定下来,再用BERT对候选实体对做关系分类,最后拼成结构化三元组。两段分开,意味着每一段都能单独调参、单独评估、单独换模型,这对从业者来说是最实用的落地路径。适合正在做知识图谱、信息抽取、文本结构化,手里有一批业务数据但还没跑通全流程的人。

2. BiLSTM+CRF序列标注:从BIO标注到解码的完整实现

2.1 选型:为什么序列标注环节不直接上BERT

很多初学者上来就问:既然后面关系分类都用了BERT,为什么实体识别不直接用BERT?答案有两层。第一层是工程成本:实体识别要跑全文本,BERT推理速度远慢于BiLSTM,一段500字的文本,BERT做NER的耗时是BiLSTM+CRF的5到10倍。第二层是数据量:BERT在几万条样本上很难发挥出预训练优势,而BiLSTM+CRF在这个量级上完全够用,训练收敛快,调参空间也直观。大多数业务场景的实体类型不超过10种,序列标注是一个已经非常成熟的课题,CRF层引入的转移约束能有效避免“B-PER后面直接跟I-ORG”这类非法标签序列,这对输出规范性是实打实的保证。

2.2 数据准备:BIO标注与id序列化

不管用什么框架训练,数据格式都得先定死。我这里采用BIO标注体系:B表示实体开始,I表示实体中间或结尾,O表示非实体。对中文文本,我强烈建议按字符标注而不是按词标注,理由后面避坑章会详细说。标注结果长这样:

张 B-PER 三 I-PER 出 O 生 O 于 O 北 B-LOC 京 I-LOC

处理时把这些标签映射成id,同时构造一个mask标记哪些位置是有效字符(因为要做batch,长度不一需要padding)。常见做法是直接给每个字符分配一个token id,标签也对应成label id。这里有个值得养成的习惯:额外维护一个char_to_origin_index数组,记录每个字符在原始文本里的偏移位置,这是后面pipeline对齐的数据基础。

def encode_example(chars, tags, char2idx, tag2idx, max_len=128): # chars: 字符列表,tags: 对应的BIO标签列表 ids = [char2idx.get(c, char2idx["[UNK]"]) for c in chars][:max_len] labels = [tag2idx[t] for t in tags][:max_len] mask = [1] * len(ids) # 长度补齐 pad_len = max_len - len(ids) ids += [char2idx["[PAD]"]] * pad_len labels += [tag2idx["O"]] * pad_len mask += [0] * pad_len return ids, labels, mask

这段代码把一条样本规整成模型需要的三个输入。[PAD]位置的标签也补成O,但mask为0,告诉CRF层这些位置不参与损失计算,这个细节能避免padding位置被模型乱学一通。max_len的取值要看业务文本长度,我一般先统计训练集的字符长度分布,取95分位,而不是固定128——太短会截掉尾部实体,太长会拖慢训练。

2.3 模型实现:BiLSTM+CRF核心代码

模型结构没有花活:Embedding层、双向LSTM、线性映射到标签空间、CRF层做序列解码。关键是CRF层的使用方式——训练时算负对数似然,推理时用Viterbi解码。

import torch import torch.nn as nn from torchcrf import CRF class BiLSTMCRF(nn.Module): def __init__(self, vocab_size, tag_size, embedding_dim=256, hidden_dim=512): super().__init__() self.embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx=0) self.lstm = nn.LSTM(embedding_dim, hidden_dim // 2, num_layers=2, bidirectional=True, batch_first=True, dropout=0.3) self.fc = nn.Linear(hidden_dim, tag_size) self.crf = CRF(tag_size, batch_first=True) def forward(self, x, mask, tags=None): emb = self.embedding(x) lstm_out, _ = self.lstm(emb) emissions = self.fc(lstm_out) if tags is not None: # 训练阶段:返回负对数似然作为loss return -self.crf(emissions, tags, mask=mask) # 推理阶段:Viterbi解码出最优标签序列 return self.crf.decode(emissions, mask=mask)

这里直接用torchcrf的CRF实现,它内部已经实现了前向算法计算归一化因子,同时提供decode方法做Viterbi解码,不需要手写logsumexp。Embedding的padding_idx=0保证了padding字符的向量是零向量,LSTM在这些位置上不会产生有效信息。hidden_dim=512表示双向拼接后的总维度,实际每个方向的LSTM隐层是256。dropout=0.3加在两层LSTM之间,防止小数据集过拟合,如果训练数据超过5万条,可以降到0.2。

训练时优化器我常用Adam,学习率设在0.001到0.002之间,配合线性warmup。一个值得关注的参数是CRF的实现细节:它要求mask是bool类型,并且batch_first=True要和LSTM保持一致。如果这两处不统一,训练时不会报错,但推理结果会乱掉,这是BiLSTM+CRF最容易犯的隐蔽错误。

2.4 训练与推理参数速查

训练配置直接给一份可抄的底稿:

参数取值说明
embedding_dim256字符向量维度,中文按字训练够用
hidden_dim512双向LSTM拼接后的隐层维度
num_layers2层数超过2收益递减且易过拟合
dropout0.3数据量小时调高,大时调低
batch_size64文本短可以加大到128
learning_rate1e-3Adam配合warmup使用
max_len128按训练集长度分布取95分位

这份参数在一个1万条样本的新闻语料上,实体F1能做到85%左右。如果你的实体更规整(比如日期、金额),分数会更高。推理阶段把batch_size调大一倍,因为不需要反向传播,显存占用更小。

3. BERT关系分类模块:把[CLS]向量变成关系标签

3.1 任务形态:识别实体对,不是识别全句

关系分类这个任务经常被误解成“对整句话做分类”,这是大忌。正确姿势是:给定一个句子和两个实体(用起始位置标记),判断这两个实体之间属于哪种预定义关系。比如“张三出生于北京”这句话,输入的是“张三”和“北京”,输出是“出生于”。同一句话换个实体对,“张三”和“北京”之外再挑一个“张三个人”和“北京”,输出的关系就完全不同。

数据格式上需要为每个实体对构造一条独立样本。一份典型的训练数据长这样:

{ "text": "张三出生于北京,后来定居上海。", "e1": {"text": "张三", "start": 0, "end": 2}, "e2": {"text": "北京", "start": 4, "end": 6}, "relation": "出生于" }

start和end是字符级别的起止下标,闭区间还是开区间务必统一,我习惯用左闭右开,因为Python切片就是这样,少一层心智转换。

3.2 输入构造:实体标记与segment id

BERT关系分类有几种输入构造方式,最常见的做法是直接在原文字符序列里插入特殊标记,让模型直接看到实体边界。也就是把“张三出生于北京”改造成“[E1]张三[/E1]出生于[E2]北京[/E2]”。这比用segment embedding区分两个实体更有效,因为特殊标记把边界信息显式编码进去了。

def build_relation_input(text, e1_start, e1_end, e2_start, e2_end, tokenizer, max_len=256): # 左闭右开区间,先插入靠后的实体再插入靠前的,避免下标错乱 chars = list(text) if e2_start > e1_start: chars.insert(e2_end, "[/E2]") chars.insert(e2_start, "[E2]") chars.insert(e1_end + 2, "[/E1]") chars.insert(e1_start, "[E1]") else: chars.insert(e1_end, "[/E1]") chars.insert(e1_start, "[E1]") chars.insert(e2_end + 2, "[/E2]") chars.insert(e2_start, "[E2]") tokens = tokenizer.tokenize("".join(chars)) # 截断处理 tokens = tokens[:max_len - 2] ids = tokenizer.convert_tokens_to_ids(["[CLS]"] + tokens + ["[SEP]"]) attention_mask = [1] * len(ids) return ids, attention_mask

这里有一个非常关键的插入顺序逻辑:先在文本中插入位置靠后的实体标记,再插入位置靠前的。如果顺序反了,先插入前面的实体标记会导致后面实体的原始下标全部位移,结果全错。插入实体标记之后还需要把[E1]等token加入tokenizer的词表,然后调用model.resize_token_embeddings扩展embedding矩阵,否则会报找不到token id。

3.3 模型搭建:加载预训练BERT与分类头

关系分类使用BertForSequenceClassification最省事,它自带分类头,直接在[CLS]向量上接全连接层输出关系类别的logits。

from transformers import BertForSequenceClassification, BertTokenizer model_path = "./bert-base-chinese" # 本地已经下载好的预训练权重 tokenizer = BertTokenizer.from_pretrained(model_path) model = BertForSequenceClassification.from_pretrained( model_path, num_labels=len(relation_labels) ) # 关键一步:加入实体标记后必须扩展embedding special_tokens = ["[E1]", "[/E1]", "[E2]", "[/E2]"] tokenizer.add_special_tokens({"additional_special_tokens": special_tokens}) model.resize_token_embeddings(len(tokenizer))

预训练模型建议提前下载到本地目录,不要每次跑脚本都现拉。bert-base-chinese这个权重在中文场景下是最稳的选择,文件大小约400MB,包含词表、配置和模型权重,训练关系分类时除了最后的分类头,BERT主体层的学习率要调低,常见做法是BERT层设2e-5,分类头设1e-4——如果统一用一个大学习率,微调几轮后BERT的预训练知识会被冲掉,表现反而变差。

3.4 阈值与负样本:让模型不偷懒

关系分类最大的坑是类别分布严重倾斜。常见的关系类型可能只有十几种,但数据里大量实体对之间是没有关系的,如果你把“无关系”作为单独一类放进去,这个类别的样本数会爆炸到其他类的几十倍,模型学完就只会输出“无关系”。

应对方案是控制正负样本比例。一条经验法则:负样本数量控制在正样本总数的1到3倍,最多不超过5倍。训练时在loss里给正样本关系类别加权重,BertForSequenceClassification的loss_fct直接接收weights参数,可以用sklearn的class_weight.compute_class_weight算出来。推理阶段则不要直接argmax,而是对每个类别的概率设一个置信度阈值,低于阈值就判定为无关系。阈值的确定要从验证集上画P/R曲线,选择F1最高的点。

4. 把两段串成pipeline:数据对齐、候选过滤与输出三元组

4.1 整体数据流:从原始文本到三元组

两个模型单独跑通之后,真正的工程问题才开始。整条pipeline的数据流是这样的:原始文本先进NER模块,得到实体列表,每项包含实体文本、类型、起始和结束位置;然后对这些实体两两配对,过滤掉明显不可能是关系的组合;剩下的实体对逐一送进BERT关系分类模块;最后把判定有关系的实体对连同关系类型输出成三元组。这一步的顺序是硬约束,前一个模块的输出格式直接决定后一个模块的输入质量。

# 推理阶段的标准调用流程(伪命令,按实际脚本调整) python ner_predict.py --input news.txt --output entities.json python relation_predict.py --input entities.json --output triples.json

这两个脚本分开跑的好处是能独立复现问题。如果最终三元组的质量不行,你可以先用NER的输出去做实体级别的评估,确认实体是不是都识别对了,再决定调哪一段。串行跑是最可靠的工程落地方式,后面会讲它的问题和代价。

4.2 候选实体对过滤:避免关系分类被组合爆炸压垮

假设一段文本里NER抽出了10个实体,两两组合就有45个候选对,如果一段话有20个实体,就是190对。直接全量送进BERT关系分类,计算量直接翻几十倍,而且绝大多数候选对都是无关系样本,白白拉高延迟。所以必须在送入BERT之前做一轮硬过滤。

def build_relation_candidates(entities, max_dist=100, type_allow=None): cands = [] for i, e1 in enumerate(entities): for e2 in entities[i + 1:]: # 完全相同位置的实体不构成关系 if e1["start"] == e2["start"] and e1["end"] == e2["end"]: continue # 跨度过滤:实体相距太远,一般不存在直接关系 if abs(e2["start"] - e1["start"]) > max_dist: continue # 类型约束:只保留可能构成关系类型组合的实体对 if type_allow and (e1["type"], e2["type"]) not in type_allow: continue cands.append({"e1": e1, "e2": e2}) return cands

这个过滤函数有三个可调参数。max_dist控制两个实体之间的距离,我一般设为100个字符,因为中文关系里“出生于”“就职于”这类动词通常出现在两个实体之间或附近,相隔太远的关系即使存在,抽取出来也难以下游使用。type_allow的类型组合表要根据你的关系集合定制,比如“出生于”只允许PER-LOC组合,就过滤掉PER-ORG、LOC-LOC这些组合。过滤完的候选数量通常能降到全量的20%到30%。

4.3 两个模块的工程衔接:推理服务化与批处理

工程衔接的基本问题是:NER和BERT两个模型推理耗时差异巨大,前者在CPU上也能跑得动,后者基本得靠GPU。如果每来一条文本就串行调用一次,GPU在跑NER时是空闲的,整条pipeline的吞吐被NER卡死。解法有两个层级。低成本的方案是离线批量处理:一段文本先跑NER,把所有NER结果缓存下来,再统一构造关系分类样本,用一个大batch一次跑完BERT推理。高成本的方案是两个模块各自部署成独立服务,中间用队列解耦,NER服务处理完一批文本就往队列丢实体对,BERT服务消费队列批量推理。

# 伪代码:批量流水线核心逻辑 def run_pipeline(texts, model_ner, model_rel, batch_size=32): all_entities = model_ner.predict(texts) # 第一步:NER rel_samples = build_all_samples(texts, all_entities) # 第二步:构造候选 triples = model_rel.predict(rel_samples, batch_size=batch_size) return triples

这里的batch_size对BERT推理影响明显,32的batch在单卡V100上能接近线性加速,但超过64后受益递减。实际部署时要在延迟和吞吐之间取平衡——如果下游系统需要准实时,batch_size设小一点,比如8;如果离线批处理,直接顶到64。

4.4 输出格式:JSON落盘与审计可视化

pipeline的输出不能只是一个三元组列表,那样出了问题没法查。我习惯把中间结果全部落盘成一个JSON,从原始文本、实体列表到最后的预测三元组都在里面。每个三元组额外带一个ner_conf和rel_conf字段,存两个模型各自的置信度。这样做的好处是:如果知识图谱里发现一条错误三元组,你能顺着这条记录往回追溯到是哪一步出了错。

{ "text": "张三出生于北京,后来定居上海。", "entities": [ {"text": "张三", "type": "PER", "start": 0, "end": 2}, {"text": "北京", "type": "LOC", "start": 4, "end": 6} ], "triples": [ {"e1": "张三", "rel": "出生于", "e2": "北京", "rel_conf": 0.97} ] }

这样的JSON格式既是输出也是审计日志。后续做错误分析时,直接基于这些字段做统计就能分锅。

5. 避坑排查:五条让抽取结果崩掉的真实踩坑记录

5.1 模型全预测成“无关系”

现象:训练完的关系分类模型,在验证集上准确率看着还行,一上测试集发现80%的预测结果都是“无关系”,任何实体对都判不成有关系。

原因:训练数据里“无关系”负样本占比过高,模型学到的先验分布就是“大多数配对都没关系”,于是走捷径。另一个助推因素是损失函数直接选交叉熵,没有做类别加权。

解决:把训练数据里的负样本采样比例压到正样本的2倍以内,同时在loss里给正样本类别加权。推理时直接看各个类别的归一化概率,低置信度的判为无关系,不要用固定阈值一刀切。

5.2 NER输出位置错位:实体对不齐导致关系全错

现象:NER单独测试实体F1有85%,接上BERT关系分类后,三元组准确率掉到50%以下。检查发现很多实体对在原始文本里根本找不到对应文本,BERT输入里的实体标记位置和实际文本对不上。

原因:数据预处理时对文本做了分词,用词级别做了标注,后又按字符重新切分,但没有重新计算偏移量。分词结果和字符下标对不上,导致送入BERT时实体区间错位。

解决:中文序列标注全部按字符处理,从标注到推理始终保持字符级偏移量。任何一个实体的start和end必须能直接切片回原始文本,并且切片结果是实体原文,这条可以作为pipeline里的一个校验脚本来跑。

5.3 长文本被截断:实体在窗口外,关系直接被砍掉

现象:用BERT做关系分类时,输入超过512 token的文本,实体对在截断边界附近,加上[E1]和[E2]标记后被截掉了其中一个,模型直接判无关系。

原因:BERT的序列长度上限是512,但业务文本经常超过这个长度,直接截断把关键信息截没了。

解决:不截全文,以实体对为中心裁剪。取两个实体中居中的位置作为窗口中心,前后各留128个token的上下文,超出部分丢掉。这种做法的好处是既保留了关系判断需要的上下文,又能把大部分文本塞进512窗口里。

5.4 NER和BERT串行推理:GPU空转,吞吐上不去

现象:pipeline部署上线后,每秒处理的文本数远低于预期,观察GPU利用率发现大部分时间在20%以下跑动。

原因:串行流程里每来一条文本,先做NER(消耗CPU),收集完实体再跑BERT(消耗GPU)。CPU和GPU的工作被强制串起来了,慢的那一方决定了整条流水线的速度。

解决:先说结论——中间加一个实体缓存队列,NER先批量处理一批文本,把得到的实体对推入队列,BERT关系分类从队列里批量取实体对推理。如果不想引入消息队列,直接在一个进程里用queue.Queue也能解决,优先级是异步化而不是进程数。

5.5 类别集合里出现了空标签:关系分类训练直接报错

现象:训练BERT关系分类时,label参数的值超出了num_labels的范围,程序报IndexError,或者label id与类别列表错位。

原因:手动构造relation_labels列表时,排序和训练数据里的标签不一致,常见的错误是忘了把“无关系”也编进去。

解决:在构造数据之前,先统一从训练集里收集所有关系类型,排序后固定一份label2id字典,存成JSON文件。训练、验证、推理三个脚本只从这一份JSON加载标签映射,绝不在脚本里手写第二份。

6. 验证与进阶:错例归因是pipeline调优的唯一捷径

6.1 级联误差拆解:先分锅再调参

两段式pipeline有个鲜明特点:下游的错误有一部分是上游传下来的。BERT关系分类错判了,不一定是关系分类的问题,可能是NER把实体边界识别错了。调参之前必须先分锅。具体做法是拿一批带标注的验证集,把pipeline的每个阶段输出分别和标准答案对比。

6.2 一个轻量的错例归因脚本

我常用的归因方法是:拿标准三元组和预测三元组做对齐,凡是实体重合但关系不对的算关系分类错误;实体没抽中的算NER错误;两个都对但置信度低的算阈值问题。

def trace_errors(gold_triples, pred_triples): gold_set = {(g["e1"], g["rel"], g["e2"]) for g in gold_triples} pred_set = {(p["e1"], p["rel"], p["e2"]) for p in pred_triples} missed = gold_set - pred_set # 漏抽:上游NER问题或下游阈值偏高 extra = pred_set - gold_set # 多抽:关系分类误判或NER多出实体 ner_errors = {} for item in missed: e1, rel, e2 = item # 检查实体本身是否被NER抽中 if e1 not in [g_e1 for g_e1, _, _ in pred_set]: ner_errors.setdefault("miss_e1", []).append(item) else: ner_errors.setdefault("rel_wrong", []).append(item) return {"missed": missed, "extra": extra, "ner_errors": ner_errors}

实际使用时,把结果按天落盘成表格,每周统计一次NER错误占比和关系分类错误占比。如果NER错误占比在上升,就去调标注数据;反过来就调BERT关系分类的阈值和负采样比例。我从不在没做归因的情况下盲调参,那等于把pipeline当黑匣子乱戳。

6.3 把错例沉淀成规则补丁

每一次归因出来的典型错误,都值得沉淀成规则补丁。比如“杭州”被识别成LOC没问题,但“杭州西湖”在某个语境里整体该是PER——这种边界错误模型短期内很难学明白,就在NER输出后加一条后处理规则强制合并或拆分。这些规则最好和模型参数分开维护,单独留一个postprocess_rules.json,让同事也能改。从那以后我每次搭实体关系抽取pipeline,都强制走一遍“先归因、后调参、再补规则”的流程,否则上线后每天都会收到乱七八糟的抽取结果反馈。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询