
简介这份资源面向自然语言处理方向的研究者与工程实践者提供一套基于BiLSTMCRF与BERT的实体关系抽取完整pipeline实现采用分阶段架构先以双向长短期记忆网络结合条件随机场完成实体识别再借助BERT对目标实体对进行关系分类最终输出可转化为知识图谱要素的三元组适用于知识图谱构建、智能问答与语义搜索等场景。压缩包共29个文件约40KB以17个Python脚本为核心辅以json配置与数据映射文件、zbak备份文件及README说明涵盖模型定义、训练器、数据处理与部署预测等模块目录划分清晰。目前已有30人学习下载。读者可获得可复现的训练流水线与评估基准理解序列标注与关系分类的解耦设计并参考模块化结构灵活替换组件例如引入图神经网络增强关系推理为自身实验提供可借鉴的工程范式。1. 从一条标注烂掉的语料说起BiLSTMCRF 与 BERT 的实体关系抽取 pipeline 到底解决什么去年接手一个医疗文本结构化项目客户给了 8000 条病历标注跑完第一版模型 F1 只有 0.61。翻标注一看问题不在模型同一句「患者服用阿司匹林后出现胃部不适」里「阿司匹林」有时标成药物、「胃部不适」有时标成症状但关系标签「药物-引起-症状」的指向在 30% 的样本里是反的。这就是实体关系抽取 pipeline 最真实的战场——它不是单模型调参而是一条从「字符序列进」到「三元组出」的完整链路任何一环塌了后面全白搭。这条 pipeline 的骨架是BERT 做上下文编码BiLSTM 补序列级依赖CRF 约束标签转移合法性最后接关系分类头输出 (头实体, 关系, 尾实体)。它适合谁适合手上有几百到几万条标注语料、需要从合同、病历、工单、研报里批量抽结构化字段的团队。不适合零标注冷启动也不适合实体边界极其规整的场景那种用规则更快。下面按「先立住原理、再跑通代码、最后避坑」的顺序拆开讲每一步都给可抄的配置和参数。2. 为什么是 BERTBiLSTMCRF 这个组合三层各管一段2.1 BERT 负责什么不负责什么BERT 的输出是每个 token 的上下文向量它已经把「阿司匹林」和「胃部不适」的语义关联编码进去了。但 BERT 有个硬伤它输出的是独立的 token 分类分数不做标签序列的全局约束。也就是说模型可能给「B-药物」后面直接接「I-症状」这在实际标注体系里是非法转移。很多人以为 BERT 微调完就完事结果 F1 卡在 0.7 上不去根子就在这。另一个常见误解是拿 BERT 的[CLS]向量直接做关系分类。[CLS]确实聚合了句级语义但它对「哪两个实体之间是什么关系」这种局部对的判别力不够。正确做法是把头实体和尾实体的 span 向量拼起来再过一层分类器。这一步在 pipeline 里叫「关系分类头」和实体抽取是两套输出。2.2 BiLSTM 补的是哪块短板BERT 的 12 层 Transformer 已经能建模长距离依赖为什么还要加 BiLSTM两个原因。第一BERT 的注意力是全局平摊的对相邻标签的局部转移模式不敏感BiLSTM 的前向/后向隐状态能把「上一个标签是什么」这个信息显式带进来。第二在标注量少几千条时BiLSTM 相当于一个轻量的序列平滑器能压住 BERT 输出的抖动。但要注意BiLSTM 不是必须的。如果语料超过 5 万条BERT 本身已经学得很稳加 BiLSTM 反而增加参数量和过拟合风险。我一般会在验证集上对比「BERTCRF」和「BERTBiLSTMCRF」两组差 1 个点以内就砍掉 BiLSTM推理快 30%。2.3 CRF 层为什么不能省CRF 的核心是一个转移矩阵形状是 (标签数2, 标签数2)多出来的 2 是 START 和 END。它做的是维特比解码在所有可能的标签序列里找全局最优路径。举个例子标签集是 {O, B-药物, I-药物, B-症状, I-症状}CRF 会学到「B-药物 → I-症状」的转移分数极低从而在解码时自动排除这种非法组合。参数上CRF 的转移矩阵是随机初始化后跟着一起训练的不需要手工设。但有一个坑如果标签体系里有 20 个以上的标签转移矩阵会变得稀疏需要适当增大lr或加 warmup。我一般把 CRF 的学习率设成 BERT 主干的 510 倍让它更快收敛。3. 用 PyTorch 把 pipeline 跑通从数据到推理的最小实现3.1 数据格式与标签对齐输入数据统一成 JSON Lines每行一条样本{text: 患者服用阿司匹林后出现胃部不适, entities: [{start: 4, end: 8, type: 药物}, {start: 11, end: 15, type: 症状}], relations: [{head: 0, tail: 1, type: 引起}]}注意start/end是字符级偏移不是 token 级。BERT 的 tokenizer 会把「阿司匹林」切成多个 subword所以需要写一个char_to_token的映射函数。这一步是血泪经验偏移错一位整个实体边界全歪而且不会报错只会让 F1 悄悄掉 10 个点。def char_to_token_offset(offset_mapping, char_start, char_end): # offset_mapping 来自 tokenizer(return_offsets_mappingTrue) token_start None token_end None for idx, (s, e) in enumerate(offset_mapping): if s char_start e: token_start idx if s char_end e: token_end idx return token_start, token_end逻辑说明遍历每个 token 的字符区间找到覆盖char_start和char_end的 token 下标。参数上offset_mapping必须用return_offsets_mappingTrue拿到且要跳过[CLS]和[SEP]它们的区间是 (0,0)。如果实体跨了[SEP]边界直接丢弃这条样本不要硬截断。3.2 模型定义BERT 主干 BiLSTM CRFimport torch import torch.nn as nn from transformers import BertModel from torchcrf import CRF class BertBiLSTMCRF(nn.Module): def __init__(self, bert_path, num_tags, lstm_hidden256, dropout0.3): super().__init__() self.bert BertModel.from_pretrained(bert_path) self.dropout nn.Dropout(dropout) self.bilstm nn.LSTM( input_size768, hidden_sizelstm_hidden, num_layers1, bidirectionalTrue, batch_firstTrue ) self.classifier nn.Linear(lstm_hidden * 2, num_tags) self.crf CRF(num_tags, batch_firstTrue) def forward(self, input_ids, attention_mask, labelsNone): outputs self.bert(input_ids, attention_maskattention_mask) sequence_output self.dropout(outputs.last_hidden_state) lstm_output, _ self.bilstm(sequence_output) emissions self.classifier(lstm_output) if labels is not None: loss -self.crf(emissions, labels, maskattention_mask.bool(), reductionmean) return loss else: return self.crf.decode(emissions, maskattention_mask.bool())逻辑说明BERT 输出 768 维BiLSTM 把它压到 256×2512 维再映射到标签数。CRF 的mask参数必须传否则 padding 位置会参与转移计算导致 loss 虚高。参数上lstm_hidden我一般设 128256再大就过拟合dropout在标注量小于 1 万时设 0.30.5大于 5 万时降到 0.1。3.3 训练循环与关键超参from transformers import AdamW, get_linear_schedule_with_warmup optimizer AdamW(model.parameters(), lr2e-5, weight_decay0.01) total_steps len(train_loader) * epochs scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_stepsint(0.1 * total_steps), num_training_stepstotal_steps ) for epoch in range(epochs): model.train() for batch in train_loader: loss model(**batch) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() scheduler.step() optimizer.zero_grad()参数说明lr2e-5是 BERT 微调的经典值CRF 层可以单独设lr1e-3用参数组。clip_grad_norm_的max_norm1.0是防梯度爆炸的后悔药BiLSTM 层尤其容易炸。warmup比例设 0.1让 CRF 的转移矩阵先稳定下来。batch size 在 1632 之间显存不够就用梯度累积。3.4 关系分类头把实体对喂进去实体抽出来后关系分类是第二个模型。输入是「头实体 span 向量 尾实体 span 向量 句向量」拼接class RelationClassifier(nn.Module): def __init__(self, bert_path, num_relations, dropout0.2): super().__init__() self.bert BertModel.from_pretrained(bert_path) self.dropout nn.Dropout(dropout) self.classifier nn.Linear(768 * 3, num_relations) def forward(self, input_ids, attention_mask, head_mask, tail_mask): outputs self.bert(input_ids, attention_maskattention_mask) seq outputs.last_hidden_state head_vec (seq * head_mask.unsqueeze(-1)).sum(1) / head_mask.sum(1, keepdimTrue) tail_vec (seq * tail_mask.unsqueeze(-1)).sum(1) / tail_mask.sum(1, keepdimTrue) cls_vec seq[:, 0, :] logits self.classifier(self.dropout(torch.cat([head_vec, tail_vec, cls_vec], dim-1))) return logits逻辑说明head_mask/tail_mask是实体 span 的 0/1 掩码做平均池化得到实体向量。三个向量拼接后过线性层。参数上num_relations包含一个「无关系」类负样本比例控制在 1:3 左右太多负样本会让模型偏向预测「无关系」。4. 避坑与排查5 个真实翻车现场4.1 现象训练 loss 正常下降验证集 F1 始终 0.5 以下原因标签体系里有大量I-标签直接跟在O后面CRF 转移矩阵学不出来因为训练数据里这种非法转移的样本太少转移分数没被压下去。解决在数据预处理阶段做一次标签合法性校验把非法序列的样本挑出来人工复核或者在 loss 里加一个转移矩阵的正则项惩罚非法转移的分数。4.2 现象推理时实体边界比标注多一个字符原因char_to_token_offset里用了和混用导致边界 token 被多算进去。解决统一用左闭右开区间s char_start e和s char_end e并且对end做一次-1修正。这个 bug 不会报错只能靠对比预测和标注的 span 发现。4.3 现象BiLSTM 层加进去后显存直接爆了原因BiLSTM 的隐状态是(num_layers*2, batch, hidden)如果batch32、seq_len512、hidden256中间激活值占用很大。解决把batch降到 8用梯度累积 4 步模拟 32或者把lstm_hidden降到 128。另一个办法是冻结 BERT 的前 6 层只训后 6 层加 BiLSTM。4.4 现象关系分类的 F1 比实体抽取低 20 个点原因关系分类依赖实体抽取的结果如果实体边界错了关系必然错。这是级联误差。解决两个办法。一是做联合抽取把实体和关系放在一个模型里输出二是关系分类时用「金标准实体」训练推理时用「预测实体」并在训练时加入实体边界扰动做数据增强让关系模型对边界错误鲁棒。4.5 现象换了一个领域的语料模型完全失效原因BERT 的预训练语料和目标任务领域差异大[CLS]向量漂移。解决先用目标领域语料做 MLM 继续预训练再微调下游任务。如果标注量太少至少把 BERT 的最后 2 层解冻用较小的学习率1e-5做领域适配。5. 进阶技巧用对抗训练和标签平滑把 F1 再抬 3 个点5.1 对抗训练给 embedding 加扰动FGMFast Gradient Method是最容易落地的对抗训练方式只在 embedding 层加扰动不改变模型结构class FGM: def __init__(self, model, epsilon1.0): self.model model self.epsilon epsilon self.backup {} def attack(self): for name, param in self.model.named_parameters(): if word_embeddings in name and param.requires_grad: self.backup[name] param.data.clone() norm torch.norm(param.grad) if norm ! 0: r_at self.epsilon * param.grad / norm param.data.add_(r_at) def restore(self): for name, param in self.model.named_parameters(): if name in self.backup: param.data self.backup[name] self.backup {}用法是在loss.backward()之后、optimizer.step()之前调用fgm.attack()然后再算一次 loss 并 backward最后fgm.restore()。epsilon设 0.51.0太大反而掉点。这个技巧在标注量 500020000 时效果最明显我实测能抬 23 个点。5.2 标签平滑压住 CRF 的过度自信CRF 的负对数似然会让模型对正确路径过度自信导致泛化差。标签平滑的做法是在计算 loss 时把 one-hot 标签换成(1-ε)的正确标签加ε/num_tags的均匀分布。在 PyTorch 里可以自己改 CRF 的 loss 计算或者用CrossEntropyLoss(label_smoothing0.1)替换 CRF 的 emission 部分。注意标签平滑和 CRF 的转移约束有轻微冲突ε不要超过 0.1。5.3 验证方法别只看 F1实体抽取要看边界级和类型级两个指标。边界级只要求 span 位置对类型级要求位置和类型都对。很多论文只报类型级 F1实际落地时边界级更重要因为下游关系分类依赖边界。我一般会打印混淆矩阵看哪两类实体最容易混通常是「药物」和「检查」这种语义相近的类别。最后说个习惯每次改完模型先在一个 200 条的小验证集上跑一遍确认没有报错、没有标签越界、没有显存泄漏再上全量。这个习惯帮我省了无数次通宵重跑。希望帮到你。本文还有配套的精品资源点击获取