简介:面向自然语言处理研究者与知识图谱构建开发者,这份资源提供了一套基于BiLSTM+CRF与BERT的实体关系抽取完整解决方案。系统采用分阶段处理架构,先通过双向长短期记忆网络与条件随机场结合的序列标注模型完成实体识别,再借助BERT预训练语言模型实现关系分类,可服务于结构化知识库与智能问答等应用场景的构建。压缩包共29个文件,约40KB,以17个Python脚本为核心,覆盖实体识别模型、关系分类模型、联合训练器、数据加载与预处理等完整流水线,另含配置与映射JSON文件、测试数据及备份内容,模块划分清晰,便于独立优化与组件替换。已有30人学习下载,适合希望复现实验基准或在此框架上扩展关系推理能力的中高级NLP开发者。
1. 实体关系抽取pipline是什么:一个句子,两种模型,一串三元组
如果只靠一个句子就要输出“(李四,工作于,阿里巴巴)”这种结构化三元组,快速落地的成熟做法是:基于BiLSTM+CRF与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的字都属于实体。但很多标注工具导出的是左闭右闭,start=0, end=2表示占了第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_ratio=3): 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双模型实现:BiLSTM+CRF与BERT+CRF的训练要点
实体识别是整个pipeline的第一个环节,实体错了后面关系分类几乎不可能对。这个模块我通常维护两套可切换的模型:一套BiLSTM+CRF用于快速迭代和低成本部署,一套BERT+CRF用于追求准确率。两套模型共享同一套BIO标签映射,切换成本很低。
3.1 CRF在NER里的价值:约束标签转移
单独用BiLSTM或BERT输出每个token的标签概率,本质上是在做逐位置独立决策。问题在于序列标签之间存在强约束:I-PER不能出现在O后面;B-ORG后面如果跟I-PER,那显然是错误路径。CRF引入一个可学习的转移矩阵,在解码阶段寻找整条序列的全局最优路径,把这些非法转移直接压掉。
这也是模型效果好坏的“黑匣子”之一。很多人觉得CRF只是加了个约束,实际效果上,它对F1的提升通常在1到3个点之间,尤其对长实体的尾部边界修正帮助明显。
3.2 BiLSTM+CRF模型结构与bilstm代码
下面是一份可以直接跑通的BiLSTM+CRF模型定义,核心结构是Embedding层、双向LSTM、线性发射层和CRF转移矩阵。
import torch import torch.nn as nn class BiLSTMCRF(nn.Module): def __init__(self, vocab_size, tag_size, emb_dim=128, hidden_dim=256): super().__init__() self.embedding = nn.Embedding(vocab_size, emb_dim, padding_idx=0) self.bilstm = nn.LSTM(emb_dim, hidden_dim // 2, num_layers=2, batch_first=True, bidirectional=True) 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_dim=256是双向LSTM输出的总维度,实际每个方向是128。hidden_dim不要一上来就调到512,序列标注任务在小数据集上很容易过拟合。
- num_layers=2对中文NER是经验值,一层欠拟合,三层收益很小且训练变慢。
- pad_token_idx设为0,需要和tokenizer保持一致。如果文本embedding和CRF是分开初始化的,务必要用相同的随机种子。
训练时的loss计算不建议自己手写前向算法来节省时间成本,直接用torchcrf这类经过验证的CRF实现也可以。我还是更建议把转移矩阵留成可读的nn.Parameter,这样在调试时可以打印出哪些转移分数异常,便于判断标签约束是否学崩了。
3.3 用bert模型做编码器并接CRF:bert参数下载与本地加载
换成BERT做编码器后,模型结构其实只改了一个部分:把Embedding+LSTM换成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_mask=attention_mask) seq_out = outputs.last_hidden_state emissions = self.classifier(self.dropout(seq_out)) return emissionsBERT的hidden_size是768,classifier输出维度是tag_size,CRF部分与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模型,参数变化比较大,单独列一张对照表更直观:
| 参数 | BiLSTM+CRF | BERT+CRF |
|---|---|---|
| 学习率 | 1e-3 | 5e-5 |
| 批大小 | 64 | 16或32 |
| 序列最大长度 | 128 | 128 |
| 优化器 | Adam | AdamW |
| warmup | 不需要 | 0.1比例 |
| 主要调节手段 | hidden_dim、dropout | 微调层数、学习率 |
训练时我习惯在同一个脚本里把两组参数分开,BiLSTM部分用1e-3,BERT部分用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_len=128, type_size=8): 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_mask=attention_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。这个维度不需要很大,关系分类的决策更多依赖实体本身的语义和类型组合,而不是句子所有细节。激活函数ReLU,dropout设在0.3左右,分类头过拟合速度通常较快。
4.3 pipeline串接与候选生成:一个句子被切成多少个关系对
推理阶段的pipeline逻辑就是把NER结果和关系分类结果串起来。先跑NER,再用实体对生成候选,最后过关系分类器。
def run_pipeline(text, ner_model, rel_model, id2rel, max_gap=20, threshold=0.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-4,loss直接起飞
现象:BERT+CRF训练前几个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蒸馏到BiLSTM+CRF。NER部分蒸馏后实体F1损失通常在1个点以内,但推理速度可以快一个数量级。关系分类部分如果数据量不大,直接用BiLSTM+attention替代BERT,把实体位置和类型embedding作为输入,效果差距会更小。
我现在拿到新的NER+RE任务,第一反应永远是先把一套完整pipeline跑通,再根据badcase分布决定要不要换联合抽取模型。虽然联合模型在公开数据集上指标更漂亮,但pipeline的定位和排查成本低得多,尤其是数据还在快速迭代的阶段。希望帮到你。
本文还有配套的精品资源,点击获取