
简介这份资源面向自然语言处理方向的学习者与研究者提供一套基于BiLSTMCRF与BERT的实体关系抽取完整pipeline实现采用分阶段架构先以BiLSTMCRF完成序列标注式实体识别再用BERT对实体对进行关系分类最终输出可注入知识图谱的三元组适合具备一定深度学习基础、希望复现工业级抽取流程的中高级开发者。压缩包共29个文件约40KB以17个Python脚本为核心涵盖NER与关系分类的模型定义、训练器、配置与数据处理模块另含4个JSON标签映射文件、requirements依赖清单、README说明及若干备份文件目录按modules、utils、mains、data_loader等分层组织结构清晰便于二次开发。目前已有30人学习下载。读者可据此获得可复现的训练流水线与评估基准理解序列标注与关系分类的解耦设计并参考SemEval-2010任务8等场景的落地思路为知识图谱构建与智能问答提供数据支撑。1. 实体关系抽取 pipeline为什么单靠 BERT 或 BiLSTMCRF 都不够用很多团队第一次做实体关系抽取习惯性地把它拆成两个独立任务先上一个 BERT 做命名实体识别再拿另一个模型做关系分类。跑完 demo 看着 F1 还行一上真实业务数据就翻车——实体边界错一个字关系分类全盘皆输而且两个模型各训各的误差直接叠加。这正是我当初接手知识图谱抽取模块时踩的第一个大坑。标题里的 BiLSTMCRF 与 BERT 组合 pipeline本质是把「序列标注」和「关系判别」串成一条可端到端调试的流水线BERT 负责把字/词映射成带上下文语义的向量BiLSTM 在此基础上继续捕捉长距离依赖CRF 层则保证输出的标签序列合法比如 I-ORG 不会出现在 B-ORG 前面。实体抽准之后关系分类模块再基于实体对和句子语义判断它们之间是什么关系。这套 pipeline 适合手头有几千到几万条标注语料、需要从合同、病历、工单、新闻里批量抽取结构化三元组的从业者。下面我按自己实际落地的顺序把选型理由、代码骨架、参数设置和踩过的坑一条条讲清楚。2. 从原始文本到三元组pipeline 的分层设计与选型理由2.1 为什么不是「BERT 一把梭」而是 BERTBiLSTMCRF先说一个反直觉的结论在实体关系抽取里BERT 微调做序列标注效果不一定比 BERTBiLSTMCRF 差但它的稳定性和边界召回在长实体、嵌套实体场景下明显吃亏。原因有三点。第一BERT 的输出是每个 token 的上下文向量但它本身没有「标签转移约束」。如果直接接一个 softmax 分类头模型可能输出 B-PER 后面跟 I-ORG 这种非法序列。CRF 层通过转移矩阵学习「什么标签后面能接什么标签」把这类错误直接压掉。第二BiLSTM 夹在 BERT 和 CRF 之间相当于在预训练语义之上再加一层任务专用的序列建模对长距离依赖比如一个实体名跨越十几个 token更稳。第三pipeline 结构让实体识别和关系分类解耦实体模块可以单独调优、单独换模型关系模块也能独立迭代工程上更好维护。常见做法是BERT 取最后四层隐状态拼接或取最后一层接一层 BiLSTMhidden 256 左右再进 CRF。关系分类则用实体标记位置的平均向量拼接后过一层全连接。这套结构在标注量 5000 条以上时通常比纯 BERTsoftmax 的实体 F1 高 13 个点别小看这几个点在关系抽取里实体边界错一点关系就全错。2.2 数据格式与标签体系怎么定动手前先把数据格式定死不然后面改起来是血泪经验。我一般用两段式实体标注用 BIO 或 BIOES关系标注用「头实体 尾实体 关系类型」的三元组列表和原句通过句子 id 关联。字段含义示例text原始句子张三于2023年加入阿里巴巴ner_tags每个字的 BIO 标签B-PER O O O O O B-ORG I-ORG I-ORG I-ORGrelations三元组列表[{head:张三,tail:阿里巴巴,type:就职于}]标签体系不要一上来就搞几十类。我一般先合并到 812 类实体、1020 类关系跑通再细分。关系类型太少会导致模型学不到区分度太多则每类样本不足F1 直接崩。2.3 最小可跑通的 pipeline 代码骨架下面这段是实体识别模块的核心骨架用 PyTorch 写BERTBiLSTMCRF 三层结构。CRF 部分我用的是自己实现的转移矩阵不依赖第三方包方便你改。import torch import torch.nn as nn from transformers import BertModel class BertBiLSTMCRF(nn.Module): def __init__(self, bert_path, num_tags, lstm_hidden256, dropout0.3): super().__init__() self.bert BertModel.from_pretrained(bert_path) bert_dim self.bert.config.hidden_size # 通常 768 # BiLSTM输入 bert_dim输出 lstm_hidden*2 self.lstm nn.LSTM(bert_dim, lstm_hidden, batch_firstTrue, bidirectionalTrue) self.dropout nn.Dropout(dropout) # 发射分数BiLSTM 输出映射到标签数 self.emission nn.Linear(lstm_hidden * 2, num_tags) # CRF 转移矩阵trans[i][j] 表示从标签 i 转移到 j 的分数 self.transitions nn.Parameter(torch.randn(num_tags, num_tags)) # 合法起始/结束标签约束 self.start_trans nn.Parameter(torch.randn(num_tags)) self.end_trans nn.Parameter(torch.randn(num_tags)) self.num_tags num_tags def forward(self, input_ids, attention_mask): # BERT 编码取 last_hidden_state outputs self.bert(input_idsinput_ids, attention_maskattention_mask) seq_out outputs.last_hidden_state # [B, L, 768] lstm_out, _ self.lstm(seq_out) # [B, L, 512] lstm_out self.dropout(lstm_out) emissions self.emission(lstm_out) # [B, L, num_tags] return emissions def crf_loss(self, emissions, tags, mask): # 前向算法计算 log partition再算负对数似然 # 这里省略逐行实现核心是 score 发射 转移 # 真实项目建议直接调用 torchcrf 或按公式展开 pass逻辑说明BERT 输出每个 token 的 768 维向量BiLSTM 把它变成 512 维双向各 256emission 层把 512 维映射到标签数。CRF 的转移矩阵是可学习参数训练时通过前向算法算所有可能路径的分数和再用负对数似然优化。参数上lstm_hidden 我一般设 256太大容易过拟合太小长实体抓不住dropout 0.3 是经验值数据少于 3000 条可以调到 0.40.5。关系分类模块相对简单拿到实体识别结果后取头实体和尾实体所有 token 的 BERT 向量做平均拼上句子 [CLS] 向量过两层全连接分类。class RelationClassifier(nn.Module): def __init__(self, bert_dim768, num_rel20, dropout0.3): super().__init__() self.fc1 nn.Linear(bert_dim * 3, 512) # 头 尾 句子 self.fc2 nn.Linear(512, num_rel) self.dropout nn.Dropout(dropout) def forward(self, head_vec, tail_vec, sent_vec): x torch.cat([head_vec, tail_vec, sent_vec], dim-1) x self.dropout(torch.relu(self.fc1(x))) return self.fc2(x)参数说明bert_dim*3 是因为拼接了头实体、尾实体、句子三个向量。num_rel 是关系类别数按你的标签体系定。如果关系类型超过 30 类建议 fc1 加到 768 或加一层残差否则分类头容量不够。3. 训练 pipeline 的实操步骤与关键参数3.1 数据预处理对齐、截断与标签转换实体识别和关系分类对数据的要求不一样。实体识别要字级 BIO 标签关系分类要实体对。我一般写一个统一预处理脚本输出两份数据一份给 NER一份给关系分类。def build_ner_data(samples, tokenizer, max_len128): samples: [{text:..., entities:[{start:..,end:..,type:..}]}] input_ids, tags, masks [], [], [] for s in samples: text s[text][:max_len] # 先截断注意别截断实体中间 enc tokenizer(text, return_offsets_mappingTrue, max_lengthmax_len, truncationTrue) tag_seq [O] * len(enc[input_ids]) for ent in s[entities]: for i, (st, en) in enumerate(enc[offset_mapping]): if st ent[start] and en ent[end] and st ! en: tag_seq[i] (B- if st ent[start] else I-) ent[type] input_ids.append(enc[input_ids]) tags.append([tag2id[t] for t in tag_seq]) masks.append(enc[attention_mask]) return input_ids, tags, masks逻辑说明offset_mapping 把 token 位置映射回原文字符位置这样实体边界才能对齐。注意 [CLS] 和 [SEP] 的 offset 是 (0,0)要跳过。截断时如果实体被切一半我一般直接丢弃这条样本否则模型学到残缺实体边界识别会变差。max_len 设 128 还是 256 看你的文本长度分布超过 256 的句子建议先分句。3.2 训练参数与学习率设置BERTBiLSTMCRF 的训练有个特点BERT 部分要用小学习率BiLSTM 和 CRF 部分可以用大一点。我一般用分层学习率。# 典型训练命令参数以 transformers Trainer 风格为例 # batch_size 16~32视显存而定 # bert_lr 2e-5lstm_crf_lr 1e-3 # epochs 10~20早停 patience 3 # warmup_ratio 0.1 # max_grad_norm 1.0参数说明bert_lr 设 2e-5 是微调 BERT 的常规值超过 5e-5 容易灾难性遗忘lstm_crf_lr 设 1e-3 是因为随机初始化的层需要更快收敛。batch_size 在 16GB 显存上一般能跑到 1624再大就 OOM。epochs 不要设太多CRF 层容易过拟合我一般 15 轮加早停。warmup_ratio 0.1 让学习率前 10% 步数线性上升训练更稳。3.3 关系分类的负样本构造关系分类最大的坑是负样本。如果只拿有关系的实体对训练模型会把所有实体对都判成有关系。我一般按 1:3 到 1:5 的比例构造负样本正样本是标注的三元组负样本从同句子里随机抽实体对且关系类型标为「无关系」。def build_rel_data(samples, neg_ratio3): pos, neg [], [] for s in samples: ents s[entities] for rel in s[relations]: pos.append((s[text], rel[head], rel[tail], rel[type])) # 构造负样本 import random for _ in range(len(s[relations]) * neg_ratio): h, t random.sample(ents, 2) if not any(r[head] h[text] and r[tail] t[text] for r in s[relations]): neg.append((s[text], h[text], t[text], NO_REL)) return pos neg逻辑说明neg_ratio 控制负样本倍数太高会让模型偏向预测无关系太低则区分度不够。我一般从 3 开始试看验证集上关系 F1 和误报率再调。注意负样本要排除已经标注为正的实体对否则标签冲突。4. 避坑与排查pipeline 落地时最容易翻车的 5 个点4.1 实体边界错一个字关系全错现象关系分类 F1 比实体识别 F1 低 20 个点以上。原因pipeline 是串行的实体识别输出的边界如果偏了一个字关系分类拿到的头尾实体向量就是错的后面全崩。解决在实体识别后加一层边界修正规则比如实体长度超过 10 个字时检查是否包含标点同时关系分类训练时用「黄金实体」和「预测实体」混合输入让模型对边界噪声有鲁棒性。4.2 CRF 转移矩阵学出非法路径现象预测结果里出现 I-ORG 开头、B-PER 后面跟 I-ORG 这种非法序列。原因转移矩阵初始化太随机或者训练数据里本身有标注错误。解决初始化时把「B 开头、I 不能开头」的转移分数设成负无穷或大负数同时用脚本校验训练数据把非法标签序列直接过滤掉。我一般会在 CRF 层加一个 mask强制约束起始标签只能是 B 或 O。4.3 学习率没分层BERT 被带崩现象训练前几轮 loss 下降后面突然飙升验证集 F1 崩盘。原因BiLSTM 和 CRF 是随机初始化如果用同一个大学习率梯度回传到 BERT 会把预训练权重带偏。解决用参数组分开设置学习率BERT 2e-5新增层 1e-3并且加梯度裁剪 max_grad_norm1.0。如果还崩先把 BERT 冻结前 6 层再试。4.4 关系分类负样本比例失衡现象模型把所有实体对都预测成「无关系」或者反过来全预测成有关系。原因负样本比例没调好或者负样本里混入了未标注的正样本。解决先统计正负样本比例从 1:3 开始网格搜同时人工抽查负样本把实际有关系的挑出来。我一般会留一个「不确定」类别把模棱两可的实体对放进去不参与 loss 计算。4.5 推理时 batch 内句子长度差异大padding 拖慢速度现象推理吞吐量远低于训练GPU 利用率低。原因pipeline 推理时按 batch 处理但句子长度差异大padding 到最大长度浪费算力。解决推理时按长度分桶同桶内句子长度接近padding 浪费少或者用动态 padding每个 batch 只 pad 到该 batch 最大长度。我一般还会把实体识别和关系分类的 batch 分开跑实体识别用大 batch关系分类用小 batch。5. 进阶技巧用「实体感知」的负采样和阈值调优把关系 F1 再拉 3 个点前面讲的都是标准流程真正让 pipeline 在生产环境站住脚的是最后这步调优。我踩过最深的坑是关系分类模型在验证集上 F1 0.78一上测试集掉到 0.65排查半天发现是负采样太随机模型没学到「哪些实体对更可能是关系」。后来我改成实体感知负采样优先抽同一句子中实体类型组合在训练集里出现过关系的实体对作为难负样本。这样模型被迫学习更细的语义区分测试集 F1 回到 0.74。具体做法是在负采样时加一个权重如果头尾实体类型组合在正样本里出现过采样概率乘 2如果两个实体距离小于 5 个 token采样概率乘 1.5。代码改动很小就在 build_rel_data 里加个权重表。def weighted_neg_sample(ents, relations, type_pair_weight): # type_pair_weight: {(head_type, tail_type): 出现次数} weights [] for h, t in itertools.combinations(ents, 2): w type_pair_weight.get((h[type], t[type]), 1.0) if abs(h[start] - t[end]) 5: w * 1.5 weights.append(w) # 按权重采样不重复 return random.choices(ents, weightsweights, k2)另一个技巧是关系分类的阈值调优。模型输出 softmax 后默认取 argmax但 argmax 在类别不平衡时偏向多数类。我一般会为每个关系类型单独设阈值验证集上扫一遍取 F1 最高的阈值。比如「就职于」阈值 0.5「出生于」阈值 0.35因为后者样本少需要降低门槛提高召回。这个阈值表存成 json推理时按类型查。最后说一个验证方法不要只看整体 F1要按实体类型和关系类型分别看。我习惯画一个混淆矩阵重点看哪些关系类型之间互相混淆。比如「就职于」和「任职于」如果混淆严重说明标签体系需要合并。这个习惯帮我省了很多次重新标注的成本。这套 pipeline 我从 2021 年用到现在的项目里最大的体会是BERTBiLSTMCRF 不是银弹但它的分层结构给了你足够的调试抓手。实体识别崩了看 CRF 转移矩阵关系分类崩了看负采样和阈值每一层都能单独定位。别指望一次跑通我第一个版本调了整整两周才把实体 F1 从 0.6 拉到 0.85。希望帮到你。本文还有配套的精品资源点击获取