ARTICLE DETAIL

资讯详情

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

BiLSTM+CRF与BERT实体关系抽取pipeline实战:从合同文本到三元组

BiLSTM+CRF与BERT实体关系抽取pipeline实战:从合同文本到三元组 简介这份资源面向自然语言处理初学者与知识图谱方向的研究者提供一套基于BiLSTMCRF与BERT的实体关系抽取完整流水线实现用于从非结构化文本中自动识别实体并判定其语义关系最终服务于结构化知识库构建。资源包共29个文件以17个Python脚本为核心辅以4个JSON配置与映射文件、4个备份文件及说明文档压缩包约40KB涵盖数据加载、模型定义、训练器与部署预测等模块目录划分清晰。已有30人学习下载。读者可据此获得分阶段解耦的工程范式实体识别阶段用BiLSTM捕获上下文特征、CRF约束标签转移关系分类阶段用BERT获取深层语义表征并附完整训练流水线与评估指标便于复现实验、替换组件或扩展至图神经网络推理是入门实体关系抽取与知识图谱构建的实用参考。1. 从一堆合同文本里抽出「谁欠谁多少钱」这套 BiLSTMCRF 与 BERT 的实体关系抽取 pipeline 到底能干什么如果你手头有一批合同、判决书、病历或者招投标公告老板让你把里面的人名、公司名、金额、日期、标的物全抽出来还要理清「A 公司收购了 B 公司」「张三担任了某项目负责人」这种关系你大概率会先想到正则。正则写了两百条之后你会发现同一个意思有十几种说法标点、换行、全半角一变就翻车。这时候就需要一套实体关系抽取 pipeline先用 BERT 或 BiLSTMCRF 把实体边界框出来再用关系分类头判断两个实体之间是什么关系。这份资源给的就是这样一条从原始文本到三元组的完整链路包含数据预处理、模型定义、训练脚本和推理接口。它适合已经会写 PyTorch 但没完整搭过 NLP 抽取流程的工程师也适合想对比 BERT 微调和 BiLSTMCRF 两种方案差异的熟手。下面我按自己拆包复现的顺序把关键参数和踩过的坑一条条摊开。2. 先搞清楚 pipeline 里两个模型的分工BERT 做编码BiLSTMCRF 做序列标注2.1 为什么不是「一个模型全干完」实体关系抽取在工程上通常拆成两个子任务命名实体识别NER和关系分类RE。NER 负责把「北京华创科技有限公司」整段标成 ORG把「2023 年 5 月 12 日」标成 DATE关系分类再拿两个实体之间的文本片段去判断是「收购」还是「任职」。这份资源里 NER 部分同时给了两条路一条是 BERT 线性分类头另一条是 BiLSTM CRF。关系分类部分则用 BERT 的[CLS]向量接一个全连接层。为什么 NER 要单独用 CRF因为实体标签之间有强约束比如B-ORG后面只能接I-ORG或O不能突然跳到I-PER。CRF 层把这层转移约束学进转移矩阵里解码时用 Viterbi 找全局最优路径比逐 token 取 argmax 稳得多。BiLSTM 的作用是补上 BERT 对长距离依赖的局部建模尤其在实体嵌套或长实体场景下双向上下文能减少边界切分错误。2.2 数据格式与标签体系资源里的数据目录一般长这样data/ train.txt dev.txt test.txt rel_train.jsontrain.txt是 NER 用的 BIO 标注每行字符\t标签句子之间空行分隔。rel_train.json是关系分类用的每条样本包含text、head、tail和relation四个字段。标签体系在config.py里定义# config.py 关键片段 NER_LABELS [O, B-PER, I-PER, B-ORG, I-ORG, B-LOC, I-LOC, B-DATE, I-DATE] REL_LABELS [None, 收购, 任职, 投资, 合作, 诉讼] MAX_LEN 128 BERT_PATH bert-base-chineseMAX_LEN128是常见起点合同类文本如果句子长可以调到 256但显存占用会翻倍。BERT_PATH指向本地预训练权重目录不要写在线地址否则离线环境直接报错。2.3 从原始文本到模型输入Tokenizer 与对齐BERT 的 tokenizer 会把一个汉字切成一个 token但英文和数字可能被切成子词。做 NER 时标签必须和 token 对齐。资源里用了一个align_labels函数def align_labels(tokenizer, text, labels): tokenized tokenizer(text, truncationTrue, max_lengthMAX_LEN, return_offsets_mappingTrue) offset_mapping tokenized[offset_mapping] aligned [] for i, (start, end) in enumerate(offset_mapping): if start end: # 特殊 token aligned.append(-100) else: aligned.append(labels[start]) # 取该 token 首字符的标签 return tokenized, aligned逻辑说明offset_mapping给出每个 token 在原字符串中的起止位置-100是 PyTorch CrossEntropyLoss 默认忽略的标签值。参数上truncationTrue保证超长句被截断max_length与 config 一致。这里有个容易忽略的点如果一个词被切成多个子词只给第一个子词赋实体标签后续子词也标-100否则 CRF 转移矩阵会学到错误模式。3. BiLSTMCRF 分支从 embedding 到 Viterbi 解码的完整实现3.1 模型结构拆解BiLSTMCRF 分支不依赖 BERT输入是字向量或预训练词向量。资源里默认用 128 维随机初始化 embedding再接一层双向 LSTM最后接 CRF。核心代码在models/bilstm_crf.pyimport torch import torch.nn as nn from torchcrf import CRF class BiLSTMCRF(nn.Module): def __init__(self, vocab_size, embed_dim, hidden_dim, num_labels): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) self.lstm nn.LSTM(embed_dim, hidden_dim, batch_firstTrue, bidirectionalTrue) self.fc nn.Linear(hidden_dim * 2, num_labels) self.crf CRF(num_labels, batch_firstTrue) def forward(self, input_ids, attention_mask, labelsNone): embeds self.embedding(input_ids) lstm_out, _ self.lstm(embeds) emissions self.fc(lstm_out) 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())逻辑说明emissions是每个 token 属于各标签的分数CRF 层负责计算转移分数和路径分数。训练时返回负对数似然损失推理时decode用 Viterbi 返回最优标签序列。参数上hidden_dim默认 256embed_dim默认 128padding_idx0让 padding 不参与梯度。注意attention_mask要转成 bool否则torchcrf某些版本会报类型错误。3.2 训练脚本与关键超参训练入口是train_ner.py核心循环for epoch in range(EPOCHS): model.train() for batch in train_loader: input_ids batch[input_ids].to(device) attention_mask batch[attention_mask].to(device) labels batch[labels].to(device) loss model(input_ids, attention_mask, labels) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step() optimizer.zero_grad()clip_grad_norm_的max_norm5.0是血泪经验BiLSTM 在长序列上容易梯度爆炸不加裁剪 loss 会突然变 NaN。学习率一般设2e-3到5e-4BERT 分支则要降到2e-5到5e-5。batch_size在 16GB 显存上可以设 32再大就要开梯度累积。3.3 评估指标与解码后处理评估用seqeval库算实体级别的 precision、recall、F1from seqeval.metrics import classification_report def evaluate(model, dev_loader, id2label): model.eval() preds, trues [], [] with torch.no_grad(): for batch in dev_loader: pred_ids model(batch[input_ids].to(device), batch[attention_mask].to(device)) preds.extend([[id2label[i] for i in seq] for seq in pred_ids]) trues.extend([[id2label[i] for i in seq] for seq in batch[labels].tolist()]) print(classification_report(trues, preds))注意pred_ids是变长列表因为 CRF decode 已经去掉了 padding 位置。trues里如果包含-100要先过滤掉再传给seqeval否则会报未知标签。常见做法是写一个clean_labels函数把-100和对应位置的预测一起删掉。4. BERT 关系分类分支从 [CLS] 向量到关系概率4.1 关系分类的输入构造关系分类不是把整句丢进去就完事要把头实体和尾实体用特殊标记包起来。资源里用[unused1]和[unused2]作为实体边界标记def build_re_input(tokenizer, text, head, tail): head_start text.find(head) tail_start text.find(tail) # 按位置从后往前插入避免偏移 if head_start tail_start: text text[:tail_start] [unused2] text[tail_start:] text text[:head_start] [unused1] text[head_start:] else: text text[:head_start] [unused1] text[head_start:] text text[:tail_start] [unused2] text[tail_start:] return tokenizer(text, max_lengthMAX_LEN, truncationTrue, paddingmax_length)逻辑说明先插位置靠后的标记再插靠前的这样前面的插入不会影响后面的索引。[unused1]和[unused2]在 BERT 词表里存在但无实际语义微调时会被训练成实体边界表示。参数上paddingmax_length保证 batch 内长度一致方便直接堆叠。4.2 分类头与损失函数模型定义在models/bert_re.pyclass BertRE(nn.Module): def __init__(self, bert_path, num_relations): super().__init__() self.bert AutoModel.from_pretrained(bert_path) self.classifier nn.Linear(self.bert.config.hidden_size, num_relations) self.dropout nn.Dropout(0.1) def forward(self, input_ids, attention_mask, token_type_ids): outputs self.bert(input_ids, attention_maskattention_mask, token_type_idstoken_type_ids) cls_vec outputs.last_hidden_state[:, 0, :] logits self.classifier(self.dropout(cls_vec)) return logitslast_hidden_state[:, 0, :]取的是[CLS]位置的向量这是 BERT 做句子级分类的标准做法。Dropout(0.1)防止过拟合关系分类数据量通常比 NER 小dropout 可以调到 0.2。损失用CrossEntropyLoss如果类别不平衡加weight参数给None类降权。4.3 联合推理先 NER 再 RE 的串行 pipeline推理脚本predict.py把两个模型串起来def extract_triples(text, ner_model, re_model, ner_tokenizer, re_tokenizer): # 第一步NER 抽实体 ner_inputs ner_tokenizer(text, return_tensorspt, truncationTrue, max_lengthMAX_LEN) pred_ids ner_model(ner_inputs[input_ids], ner_inputs[attention_mask]) entities decode_entities(text, pred_ids[0], id2label) # 第二步两两组合做关系分类 triples [] for i in range(len(entities)): for j in range(len(entities)): if i j: continue head, tail entities[i], entities[j] re_inputs build_re_input(re_tokenizer, text, head[text], tail[text]) logits re_model(re_inputs[input_ids], re_inputs[attention_mask], re_inputs[token_type_ids]) rel_id torch.argmax(logits, dim-1).item() if rel_id ! 0: # 0 是 None triples.append((head[text], rel_labels[rel_id], tail[text])) return triples逻辑说明decode_entities把 BIO 标签合并成实体跨度返回{text: ..., type: ..., start: ..., end: ...}。两两组合时跳过自身关系为None的丢弃。参数上MAX_LEN对 NER 和 RE 可以不同RE 通常需要更长的上下文可以单独设 256。注意实体对数量是 N²如果一句话抽出了 20 个实体就要跑 380 次关系分类推理延迟会明显上升。常见优化是先用规则过滤掉明显不可能的组合比如两个 DATE 之间不判断关系。5. 避坑与排查这五个地方我反复翻车过5.1 现象CRF 训练 loss 不降预测全是 O原因标签 ID 从 0 开始但torchcrf默认把 0 当作 padding 标签如果O标签的 ID 也是 0CRF 会把它当 padding 忽略掉。解决在config.py里把O的 ID 设为 1或者初始化 CRF 时传num_labels并确保标签从 1 开始。我一般会在id2label里加一个-100映射训练前打印一次标签分布确认。5.2 现象BERT 微调后 F1 比 BiLSTMCRF 还低原因学习率没降下来。BERT 微调学习率超过5e-5时预训练权重会被破坏尤其是小数据集上。解决BERT 分支用2e-5BiLSTMCRF 用1e-3两个模型不要共用同一个 optimizer。另外 BERT 的warmup_ratio设 0.1前 10% 步数线性升温能明显稳住初期 loss。5.3 现象关系分类把「张三任职于甲公司」预测成「张三收购甲公司」原因头尾实体顺序敏感但训练数据里「收购」和「任职」的实体类型分布有重叠。解决在build_re_input里显式加入实体类型标记比如[unused1]后面紧跟[PER][unused2]后面紧跟[ORG]。另外检查rel_train.json里有没有头尾标反的样本我遇到过标注人员把「A 收购 B」写成「B 收购 A」模型学出来全是反的。5.4 现象推理时实体边界多一个字或少一个字原因tokenizer 的offset_mapping和原始字符索引没对齐尤其是英文单词被切成子词时。解决decode_entities里用offset_mapping的start和end还原字符位置不要直接用 token 索引。如果实体跨子词取第一个子词的start和最后一个子词的end。常见做法是写一个token_to_char_span函数单独做单元测试。5.5 现象显存溢出batch_size 降到 1 还报 OOM原因MAX_LEN256时BERT 的注意力矩阵是batch_size × num_heads × 256 × 256显存占用是 128 时的四倍。解决先降到 128或者用gradient_accumulation_steps4模拟大 batch。另外检查torch.cuda.empty_cache()有没有在验证阶段调用验证时不计算梯度用torch.no_grad()包住能省不少显存。6. 进阶技巧用对抗训练和标签平滑把 F1 再抬两个点6.1 FGM 对抗训练在 NER 上的落地FGMFast Gradient Method在 embedding 层加扰动能提升小样本下的泛化。资源里没默认开但加进去只要二十行class FGM: def __init__(self, model): self.model model self.backup {} def attack(self, epsilon1.0): for name, param in self.model.named_parameters(): if param.requires_grad and embedding in name: self.backup[name] param.data.clone() norm torch.norm(param.grad) if norm ! 0: r_at 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 并反向最后fgm.restore()。epsilon1.0是常用起点太大反而掉点。注意只对 embedding 层加扰动不要动 BERT 的全部参数否则训练时间翻倍。6.2 标签平滑与类别权重关系分类的None类通常占 70% 以上直接训练会让模型倾向于全预测None。在CrossEntropyLoss里加label_smoothing0.1和weightweights torch.tensor([0.3, 1.0, 1.0, 1.0, 1.0, 1.0]).to(device) criterion nn.CrossEntropyLoss(weightweights, label_smoothing0.1)weight给None类 0.3其他类 1.0让模型更关注少数类。label_smoothing0.1防止模型对某个类别过度自信在验证集上通常能涨 1 到 2 个点。我一般会先跑一版不加权重的 baseline再逐步加每次只改一个变量否则不知道是哪个起的作用。6.3 验证方法用混淆矩阵定位关系分类的混淆对不要只看总体 F1把关系分类的混淆矩阵打出来from sklearn.metrics import confusion_matrix import seaborn as sns cm confusion_matrix(true_labels, pred_labels) sns.heatmap(cm, annotTrue, fmtd, xticklabelsREL_LABELS, yticklabelsREL_LABELS)我上次发现「投资」和「收购」互相混淆了 30 多次回头查数据发现标注规范里「战略投资」和「收购」的边界没定义清楚。后来在build_re_input里把「投资」类的样本复制了一份F1 才上来。从那以后我每次跑完关系分类都强制走一遍混淆矩阵不看一眼不放心。希望这套 pipeline 能帮你把合同和公告里的三元组稳稳抽出来。本文还有配套的精品资源点击获取
返回列表