基于BERT的关系三元组抽取实战:从原理到Python代码实现
简介这份资源面向计算机相关专业的在校学生与项目实战学习者提供一套基于BERT实现关系三元组抽取的完整Python项目可用于毕业设计、课程设计、期末大作业或立项演示。项目围绕命名实体识别与关系抽取两条主线展开包含模型定义、数据加载、训练与预测脚本并配套中文预训练模型与多份数据集配置便于理解三元组抽取的整体流程。压缩包共56个文件以24个txt数据与说明、16个py源码、14个json配置及2个md文档为主整体约3.82MB目录按模型、数据、脚本分层组织结构清晰。目前已有455人学习下载。读者可据此掌握BERT在信息抽取任务中的落地方式参考数据预处理、参数配置与推理实现并在此基础上修改扩展完成自己的毕设或课设项目。1. 关系三元组抽取到底在抽什么从一句合同文本说起合同里写着「甲方北京某某科技有限公司委托乙方上海某某信息技术有限公司开发智能客服系统合同金额 128 万元」。人一眼能看出谁委托谁、金额多少但机器要把它变成结构化数据就得回答三个问题主体是谁、客体是谁、两者之间是什么关系。这就是关系三元组抽取要干的事——把「主体-关系-客体」这样的三元组从非结构化文本里挖出来最终产出类似(北京某某科技, 委托, 上海某某信息)、(智能客服系统, 合同金额, 128万元)这样的结构化记录。基于 BERT 实现关系三元组抽取本质上是把预训练语言模型当作语义编码器再在它上面接一个关系分类头或序列标注头让模型学会「这句话里哪两个实体之间存在哪种预定义关系」。它适合谁适合手里有一批领域文本合同、病历、工单、招投标公告、专利摘要需要把里面的实体关系批量结构化、又不想从零训练词向量的 Python 开发者。配套的源码、数据集和项目说明价值就在于把「BERT 微调 三元组解码」这条链路完整跑通而不是停在调包层面。2. 为什么选 BERT 做三元组抽取编码器、标注方案与数据格式2.1 BERT 在关系抽取里到底承担什么角色关系三元组抽取的难点不在「识别实体」而在「判断两个实体之间有没有关系、是哪种关系」。传统做法是先用 NER 抽实体再用分类器对实体对做关系分类两阶段误差会累积。BERT 的强项是它输出的每个 token 向量都带了上下文信息同一个词在不同句子里向量不同这对「苹果」在「吃苹果」和「苹果公司发布新机」里的区分至关重要。常见做法是把 BERT 当作共享编码器句子过一遍 BERT 拿到 token 级隐状态然后有两种主流接法一是序列标注式把关系类型编码进标签用 BIO 标注直接标出主体和客体的起止位置二是指针式用两个指针网络分别预测主体和客体的 start/end再对关系做分类。前者实现简单、适合关系种类少的场景后者能处理实体重叠一个实体参与多个关系的情况。选哪种取决于你的数据里实体重叠多不多——如果一句话里一个公司同时是「委托方」和「被投资方」指针式更稳。2.2 数据格式三元组标注长什么样数据集通常是一行一条 JSON包含原始文本和三元组列表。下面是一个最小可用的样本格式也是大多数开源关系抽取数据集如 DuIE、百度关系抽取竞赛数据的通用结构{ text: 甲方北京某某科技有限公司委托乙方上海某某信息技术有限公司开发智能客服系统, spo_list: [ {subject: 北京某某科技有限公司, predicate: 委托, object: 上海某某信息技术有限公司}, {subject: 北京某某科技有限公司, predicate: 开发, object: 智能客服系统} ] }text是原始句子spo_list是三元组列表subject/predicate/object分别对应主体、关系、客体。注意 predicate 必须是预定义关系集合里的值不能自由发挥否则模型没法做闭集分类。如果你的数据里关系是开放式的那要先做关系聚类或改成开放关系抽取那是另一条路。2.3 标注方案把三元组转成模型能学的标签以序列标注式为例需要把三元组转成每个 token 的标签。常见做法是给主体和客体分别用B-SUB/I-SUB、B-OBJ/I-OBJ标注关系类型单独作为一个分类任务或者把关系拼进标签变成B-SUB-委托这种复合标签。下面这段代码演示如何把字符级三元组转成 token 级 BIO 标签是数据预处理里最容易翻车的一步def char_spans_to_bio(text, spo_list, tokenizer, rel2id): # 先按 BERT 分词器切分记录每个 token 对应的原始字符区间 tokens tokenizer.tokenize(text) char_to_token {} idx 0 for i, tok in enumerate(tokens): # 去掉 ## 前缀后计算覆盖的字符数 clean tok.replace(##, ) for _ in range(len(clean)): char_to_token[idx] i idx 1 labels [O] * len(tokens) for spo in spo_list: s_start text.find(spo[subject]) s_end s_start len(spo[subject]) - 1 o_start text.find(spo[object]) o_end o_start len(spo[object]) - 1 # 主体标 B-SUB / I-SUB for c in range(s_start, s_end 1): t char_to_token.get(c) if t is not None: labels[t] B-SUB if c s_start else I-SUB # 客体标 B-OBJ / I-OBJ for c in range(o_start, o_end 1): t char_to_token.get(c) if t is not None: labels[t] B-OBJ if c o_start else I-OBJ return tokens, labels逻辑说明先用 tokenizer 分词再建立「原始字符位置 → token 下标」的映射因为 BERT 会把一个词切成多个 subword三元组标注是按字符给的必须对齐到 token 上。参数说明tokenizer用 BERT 中文预训练模型的分词器rel2id是关系到 id 的映射表如果采用复合标签方案把B-SUB换成B-SUB-{rel}即可。这里最容易踩的坑是text.find遇到重复实体时只找到第一个实际项目里要用更严谨的字符串匹配或记录实体偏移量。3. 用 Python 跑通 BERT 三元组抽取的最小链路3.1 环境准备与依赖安装先把环境搭起来。Python 建议 3.8 及以上PyTorch 和 transformers 是核心依赖。下面这套命令在 Linux 和 Windows 上都能跑注意 transformers 版本不要盲目追新4.x 早期版本和后期版本在BertModel输出结构上有差异选一个稳定版本锁住python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate pip install torch1.13.1 transformers4.28.1 pip install seqeval scikit-learn tqdmtorch负责张量计算和自动求导transformers提供 BERT 模型和分词器seqeval用来算实体级别的 precision/recall/F1token 级准确率会虚高别用它评估tqdm显示训练进度。如果你的机器有 GPU装对应 CUDA 版本的 torchCPU 也能跑只是训练慢小数据集几百条样本 CPU 也能在几十分钟内跑完。3.2 模型定义BERT 加一个序列标注头模型结构很直接BERT 输出每个 token 的隐状态接一个线性层映射到标签空间。下面是最小实现去掉了花哨技巧方便你先把链路跑通再优化import torch import torch.nn as nn from transformers import BertModel class BertForTriple(nn.Module): def __init__(self, bert_path, num_labels, dropout0.1): super().__init__() self.bert BertModel.from_pretrained(bert_path) self.dropout nn.Dropout(dropout) # 线性层把 hidden_size 映射到标签数 self.classifier nn.Linear(self.bert.config.hidden_size, num_labels) def forward(self, input_ids, attention_mask, token_type_idsNone, labelsNone): outputs self.bert( input_idsinput_ids, attention_maskattention_mask, token_type_idstoken_type_ids ) sequence_output outputs[0] # [batch, seq_len, hidden] sequence_output self.dropout(sequence_output) logits self.classifier(sequence_output) # [batch, seq_len, num_labels] loss None if labels is not None: loss_fn nn.CrossEntropyLoss(ignore_index-100) # 展平后算交叉熵-100 的位置不参与损失 loss loss_fn(logits.view(-1, logits.size(-1)), labels.view(-1)) return loss, logits逻辑说明BertModel输出sequence_output是每个 token 的上下文向量classifier把它映射到标签维度。ignore_index-100用来屏蔽 padding 和特殊 token 的损失这是 PyTorch 交叉熵的约定。参数说明bert_path可以是本地下载的bert-base-chinese目录也可以是模型名需联网下载num_labels等于标签集合大小比如O、B-SUB、I-SUB、B-OBJ、I-OBJ就是 5。如果你的关系种类多建议改成复合标签或加一个关系分类分支否则标签空间会爆炸。3.3 训练循环与关键超参训练部分用标准的 AdamW 加线性 warmup学习率对 BERT 微调极其敏感设大了 loss 直接飞设小了学不动。下面这段是训练主循环的骨架from transformers import AdamW, get_linear_schedule_with_warmup from torch.utils.data import DataLoader def train(model, train_dataset, epochs5, batch_size16, lr2e-5): device torch.device(cuda if torch.cuda.is_available() else cpu) model.to(device) loader DataLoader(train_dataset, batch_sizebatch_size, shuffleTrue) optimizer AdamW(model.parameters(), lrlr, weight_decay0.01) total_steps len(loader) * epochs scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_stepsint(total_steps * 0.1), num_training_stepstotal_steps ) model.train() for epoch in range(epochs): for batch in 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, labelslabels) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() scheduler.step() optimizer.zero_grad() print(fepoch {epoch} done)逻辑说明AdamW是 BERT 微调的标准优化器weight_decay0.01抑制过拟合。get_linear_schedule_with_warmup让学习率在前 10% 步数里线性升到峰值再线性衰减这是 transformers 官方推荐的微调策略。clip_grad_norm_把梯度范数限制在 1.0防止梯度爆炸。参数说明lr2e-5是 BERT 微调的经典值范围一般在 1e-5 到 5e-5 之间batch_size16在显存不够时降到 8 或 4同时把学习率按比例调小epochs5对小数据集够用数据量大时 3 轮就收敛。3.4 解码从标签序列还原三元组模型输出的是每个 token 的标签要还原成三元组还得做解码。序列标注式解码的逻辑是找到B-SUB到I-SUB的连续片段作为主体B-OBJ到I-OBJ的片段作为客体再根据关系分类结果配对。下面是一个简化版解码函数def decode_triples(tokens, pred_labels, id2relNone): triples [] subjects, objects [], [] i 0 while i len(pred_labels): if pred_labels[i] B-SUB: j i 1 while j len(pred_labels) and pred_labels[j] I-SUB: j 1 subjects.append(.join(tokens[i:j]).replace(##, )) i j elif pred_labels[i] B-OBJ: j i 1 while j len(pred_labels) and pred_labels[j] I-OBJ: j 1 objects.append(.join(tokens[i:j]).replace(##, )) i j else: i 1 # 简化配对主体和客体按顺序一一对应实际项目需按关系分类结果配对 for s, o in zip(subjects, objects): triples.append((s, 未知关系, o)) return triples逻辑说明遍历标签序列遇到B-SUB就往后吃I-SUB直到断开拼成主体字符串客体同理。replace(##, )把 BERT 分词器的 subword 前缀去掉。参数说明id2rel在复合标签方案里用来还原关系类型这里简化处理成「未知关系」。实际项目里配对逻辑要结合关系分类头的输出不能简单按顺序 zip否则主体客体数量不一致时会错配。4. 避坑与排查三元组抽取里最容易翻车的五件事4.1 实体边界对不齐标签全错位现象训练 loss 正常下降但验证集 F1 一直上不去解码出来的实体总是多一个字或少一个字。原因BERT 分词器会把中文词切成 subword三元组标注是按字符给的字符到 token 的映射没做对导致标签整体偏移。解决在预处理阶段打印几条样本的tokens和labels对照肉眼确认边界用tokenizer(text, return_offsets_mappingTrue)拿到每个 token 的字符区间比手动累加长度可靠得多。4.2 学习率设大loss 直接变 NaN现象第一个 batch 跑完 loss 就是 nan或者训练几个 step 后突然爆炸。原因BERT 微调对学习率极敏感用 1e-3 这种「常规」学习率必炸。解决把学习率降到 2e-5 到 5e-5加 warmup加梯度裁剪。如果还是炸检查数据里有没有空文本或超长文本超长文本截断后标签没同步截断也会导致维度不匹配。4.3 关系种类多导致标签空间爆炸现象关系有几十种用复合标签方案后num_labels上百模型学不动F1 很低。原因复合标签把关系类型乘进了标签空间每个关系都要独立学一套 BIO 模式样本少的关系根本学不会。解决改成「序列标注抽实体 单独关系分类」的两分支结构实体抽取共享标签空间关系分类在实体对上做参数量小得多或者用指针式方案主体客体各两个指针关系单独分类。4.4 评估指标用错token 准确率虚高现象训练时 token 级准确率 99%以为模型很好实际解码出来一堆垃圾。原因大部分 token 是O标签模型全预测O也能拿高准确率这个指标没有意义。解决用seqeval算实体级 precision/recall/F1它按完整实体片段匹配才是真实水平。关系抽取还要额外算三元组级别的 F1主体、客体、关系三者全对才算命中。4.5 推理时 batch 和训练不一致现象训练好好的推理时结果乱七八糟。原因推理时忘了加attention_mask或者 padding 方式和训练不一致或者忘了model.eval()导致 dropout 还在生效。解决推理前调model.eval()用torch.no_grad()包住padding 用和训练相同的tokenizer.pad配置attention_mask一定要传否则 padding 位置的向量会污染注意力。5. 进阶技巧用指针式方案处理实体重叠附验证方法序列标注式方案有个硬伤一个实体如果同时参与多个关系BIO 标签只能标一次没法表达「这个主体既委托又投资」。指针式方案能解决这个问题思路是不标 BIO而是用两个二分类头分别预测每个 token 是不是主体的 start、是不是主体的 end客体同理关系再用一个分类头在候选实体对上做多标签分类。下面是指针式模型的核心部分主体和客体各两个指针class PointerTripleModel(nn.Module): def __init__(self, bert_path, num_rels): super().__init__() self.bert BertModel.from_pretrained(bert_path) hidden self.bert.config.hidden_size # 主体 start/end 指针 self.sub_start nn.Linear(hidden, 1) self.sub_end nn.Linear(hidden, 1) # 客体 start/end 指针 self.obj_start nn.Linear(hidden, 1) self.obj_end nn.Linear(hidden, 1) # 关系分类头多标签 self.rel_classifier nn.Linear(hidden * 2, num_rels) def forward(self, input_ids, attention_mask): seq_out self.bert(input_ids, attention_maskattention_mask)[0] sub_s self.sub_start(seq_out).squeeze(-1) # [batch, seq_len] sub_e self.sub_end(seq_out).squeeze(-1) obj_s self.obj_start(seq_out).squeeze(-1) obj_e self.obj_end(seq_out).squeeze(-1) return sub_s, sub_e, obj_s, obj_e逻辑说明每个指针输出一个标量分数sigmoid 后大于阈值就算命中。主体和客体可以重叠因为指针之间独立。关系分类在拿到主体和客体向量后拼接再分类。参数说明阈值一般设 0.5但实际项目里要在验证集上扫一遍 0.3 到 0.7 找最佳值num_rels是关系种类数用 BCEWithLogitsLoss 做多标签训练。验证方法上我一般会做两件事一是构造一批「实体重叠」的测试样本专门看模型能不能抽出多个三元组二是把预测结果和标注结果按三元组集合做精确匹配算三元组级 F1而不是只看实体级。如果三元组 F1 比实体 F1 低很多说明关系分类是瓶颈得回头查关系样本是否均衡。血泪经验是别一上来就追求复杂模型先用序列标注式把链路跑通拿到一个 baseline F1再根据错误分析决定要不要换指针式。我见过太多人直接上指针式结果预处理没对齐调了两周还不如简单方案。数据质量永远比模型结构重要标注里主体客体边界模糊的样本先清洗再训练比换模型管用。希望帮到你。本文还有配套的精品资源点击获取