资讯详情

基于BERT的关系三元组抽取实战:从数据标注到模型部署

📅 2026/10/8 4:11:24 | 华诺云谱 👁 阅读
基于BERT的关系三元组抽取实战:从数据标注到模型部署
简介本资源面向计算机相关专业学生与NLP入门学习者提供一套基于BERT实现关系三元组抽取的完整项目可用于毕业设计、课程设计、期末大作业或项目实战练习。项目围绕命名实体识别与关系抽取两条主线展开包含模型定义、数据加载、训练与预测等核心模块并配套中文预训练模型与多份数据集配置便于直接复现与二次开发。压缩包共56个文件以16个py源码、24个txt说明、14个json配置及2个md文档为主整体约3.82MB结构清晰、便于按模块查阅。目前已有455人学习下载。代码经测试运行成功答辩评审平均分达96.5分读者可据此掌握三元组抽取的完整流程、数据处理思路与模型调参方法也可在此基础上修改扩展完成自己的毕设或课设任务。1. 从一份 BERT 关系三元组抽取源码包说起它到底解决了什么如果你手上有一批中文文本想自动抽出「谁对谁做了什么」这种结构化事实比如从新闻里抽出某公司收购某团队、从合同里抽出甲方支付金额那你大概率绕不开关系三元组抽取这个任务。它的输出形式是 (头实体, 关系, 尾实体)比单纯做命名实体识别多了一层关系判断也比纯文本分类更贴近真实业务。一份「基于 bert 实现关系三元组抽取 python 源码数据集项目说明」的压缩包本质上就是把这条链路打包好了预训练模型加载、数据标注格式、训练脚本、推理入口、评估指标一整套。我见过太多人拿到这类包之后卡在三个地方一是不知道数据该标成什么格式二是不知道 BERT 在这里到底怎么接分类头三是训练完发现指标虚高、上线就翻车。这篇笔记就按「先讲清任务和数据长什么样再拆模型结构然后跑通训练和推理最后把踩过的坑摊开」的顺序写。适合已经会写 Python、装过 PyTorch、但对关系抽取还停留在听说阶段的工程师也适合想拿这套东西改造成自己业务抽取器的人。2. 关系三元组抽取的任务拆解与数据格式先搞清楚标注长什么样2.1 三元组抽取和 NER 的本质区别命名实体识别只回答「这段字是不是实体、是什么类型」它不关心两个实体之间有没有关系。关系三元组抽取要在实体识别的基础上再判断头实体和尾实体之间属于哪种预定义关系。常见做法有两类一类是流水线式先抽实体再两两配对做关系分类另一类是联合抽取把实体和关系放在一个模型里一起解码。这份源码包通常走的是流水线里最稳的那条BERT 编码 关系分类头因为实现简单、可解释、调试成本低。关系分类的标签体系是自定义的比如「任职于」「收购」「出生于」「位于」这些。你要做的第一件事不是看代码而是把业务里真正需要的关系列出来控制在 10 到 30 类之间。类别太少没价值太多会让每个类的样本被稀释模型学不动。这一步定错了后面调参全是白费。2.2 数据标注格式JSON 行式还是 BIO 序列这类项目的数据集一般有两种存法。一种是 JSON Lines每行一条样本包含文本和三元组列表另一种是 BIO 序列标注把实体和关系编码进标签里。前者可读性好后者训练时省事。我一般推荐先用 JSON Lines 存原始标注训练前再用脚本转成模型输入。{text: 张三于2019年加入阿里巴巴担任技术专家。, triples: [{head: 张三, head_type: 人物, relation: 任职于, tail: 阿里巴巴, tail_type: 机构}]} {text: 李四毕业于清华大学计算机系。, triples: [{head: 李四, head_type: 人物, relation: 毕业于, tail: 清华大学, tail_type: 机构}]}每行一个 JSON 对象text是原始句子triples是这条句子里所有关系三元组。注意head和tail必须是text里能精确匹配到的子串否则后面做实体位置对齐时会直接报错。这个约束看起来简单但标注时手滑多打一个空格就会让整条样本失效所以转换脚本里一定要加校验。2.3 从原始标注到模型输入的转换脚本模型真正吃进去的不是字符串而是input_ids、attention_mask和实体位置。下面这段脚本把 JSON Lines 转成 BERT 关系分类需要的格式核心思路是用 BERT 的分词器把句子切成 token找到头实体和尾实体在 token 序列里的起止位置然后在头实体第一个 token 的位置上拼一个特殊标记。import json from transformers import BertTokenizer tokenizer BertTokenizer.from_pretrained(bert-base-chinese) def convert_sample(sample, max_len128): text sample[text] encoding tokenizer(text, max_lengthmax_len, truncationTrue, return_offsets_mappingTrue, return_tensorsNone) input_ids encoding[input_ids] offset_mapping encoding[offset_mapping] labels [] head_pos, tail_pos [], [] for triple in sample[triples]: # 在原始文本里定位实体字符区间 h_start text.find(triple[head]) t_start text.find(triple[tail]) if h_start -1 or t_start -1: continue # 实体没匹配上跳过这条三元组 # 把字符区间映射到 token 区间 h_tok [i for i, (s, e) in enumerate(offset_mapping) if s h_start and e h_start len(triple[head]) and s ! e] t_tok [i for i, (s, e) in enumerate(offset_mapping) if s t_start and e t_start len(triple[tail]) and s ! e] if not h_tok or not t_tok: continue head_pos.append(h_tok[0]) tail_pos.append(t_tok[0]) labels.append(triple[relation]) return { input_ids: input_ids, attention_mask: encoding[attention_mask], head_pos: head_pos, tail_pos: tail_pos, labels: labels, }这段代码有三个关键点。第一return_offsets_mappingTrue是必须的它给出每个 token 对应原文的字符区间没有它就没法把字符位置映射到 token 位置。第二s ! e用来过滤掉特殊 token因为[CLS]、[SEP]的 offset 是 (0,0)不排除会污染位置计算。第三一条句子里可能有多个三元组所以head_pos、tail_pos、labels都是列表训练时要么逐条算 loss要么用多标签方式处理。参数max_len设 128 对大多数中文短句够用长文本建议 256但显存会翻倍自己权衡。3. BERT 关系分类模型结构分类头到底接在哪一层3.1 为什么用头实体位置的向量做关系分类BERT 输出的是每个 token 的隐状态维度通常是 768。关系分类需要一个固定维度的句子级表示。常见做法有三种取[CLS]向量、取头实体所有 token 的平均、取头实体第一个 token 的向量。这份源码包一般用第三种因为关系的主体是头实体用它的表示去预测关系最直接。具体来说模型在头实体起始位置取last_hidden_state[b, head_pos, :]再拼上尾实体的位置信息送进一个全连接层做分类。import torch import torch.nn as nn from transformers import BertModel class BertRelationModel(nn.Module): def __init__(self, num_relations, pretrainedbert-base-chinese): super().__init__() self.bert BertModel.from_pretrained(pretrained) hidden self.bert.config.hidden_size # 768 # 头实体向量 尾实体向量拼接后分类 self.classifier nn.Sequential( nn.Linear(hidden * 2, hidden), nn.ReLU(), nn.Dropout(0.1), nn.Linear(hidden, num_relations), ) def forward(self, input_ids, attention_mask, head_pos, tail_pos): outputs self.bert(input_idsinput_ids, attention_maskattention_mask) hidden outputs.last_hidden_state # [B, L, 768] batch hidden.size(0) # 按 batch 索引取出头尾实体向量 head_vec hidden[torch.arange(batch), head_pos] # [B, 768] tail_vec hidden[torch.arange(batch), tail_pos] # [B, 768] logits self.classifier(torch.cat([head_vec, tail_vec], dim-1)) return logitsnum_relations就是你的关系类别数不含「无关系」时记得加一。Dropout(0.1)是防过拟合的常规操作样本少于五千条时可以调到 0.2 或 0.3。head_pos和tail_pos是 LongTensor形状[B]每个元素是 token 下标。这里有个容易翻车的点如果一条样本有多个三元组上面这种写法一次只能处理一个训练时要展开成多条或者改成多标签分类。我一般会在数据加载阶段就把多三元组样本拆成多条单三元组样本简单粗暴但不容易出错。3.2 训练脚本的关键参数与损失函数训练部分没什么玄学就是标准的交叉熵。真正需要调的是学习率、batch size 和 warmup。BERT 微调学习率一般设 2e-5 到 5e-5太大直接发散太小收敛慢。batch size 在显存允许下尽量大16 或 32 都行。from torch.utils.data import DataLoader from transformers import AdamW, get_linear_schedule_with_warmup model BertRelationModel(num_relations15).cuda() optimizer AdamW(model.parameters(), lr3e-5, weight_decay0.01) total_steps len(train_loader) * 5 # 训练 5 个 epoch scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_stepsint(total_steps * 0.1), num_training_stepstotal_steps) criterion nn.CrossEntropyLoss() for epoch in range(5): model.train() for batch in train_loader: input_ids batch[input_ids].cuda() attention_mask batch[attention_mask].cuda() head_pos batch[head_pos].cuda() tail_pos batch[tail_pos].cuda() labels batch[labels].cuda() logits model(input_ids, attention_mask, head_pos, tail_pos) loss criterion(logits, labels) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() scheduler.step() optimizer.zero_grad()weight_decay0.01是 BERT 微调的常用值clip_grad_norm_设 1.0 防止梯度爆炸这两个参数在样本少的时候尤其重要。warmup比例 0.1 是经验值让学习率在前 10% 的步数里线性上升避免一开始就把预训练权重带偏。训练轮数 5 是个起点如果验证集 loss 连续两轮不降就停别硬跑。3.3 推理阶段怎么把预测结果还原成三元组推理比训练多一步模型只输出关系类别实体本身还得靠实体识别模块或者预先给定的实体列表。这份源码包通常假设实体已经抽好了推理时输入句子和实体对输出关系。如果关系分类置信度低于阈值就判为无关系。def predict(text, head, tail, model, tokenizer, id2rel, threshold0.5): encoding tokenizer(text, return_tensorspt, truncationTrue, max_length128) input_ids encoding[input_ids].cuda() attention_mask encoding[attention_mask].cuda() h_start text.find(head) t_start text.find(tail) # 简化处理用字符位置近似 token 位置实际项目要用 offset_mapping head_pos torch.tensor([h_start]).cuda() tail_pos torch.tensor([t_start]).cuda() model.eval() with torch.no_grad(): logits model(input_ids, attention_mask, head_pos, tail_pos) probs torch.softmax(logits, dim-1) conf, pred probs.max(dim-1) if conf.item() threshold: return None return id2rel[pred.item()], conf.item()阈值threshold设 0.5 是默认值业务对准确率要求高就调到 0.7 以上对召回要求高就降到 0.3。注意上面用字符位置近似 token 位置只是为了演示真实项目必须用offset_mapping做精确映射否则中文分词后位置对不上预测全是错的。这个坑我在第一次做的时候踩得很结实指标看着还行一上真实数据就崩。4. 避坑与排查训练指标好看但上线就废的五个原因4.1 实体位置映射错位导致模型学到噪声现象是训练 loss 正常下降验证集准确率也能到 0.9但拿新句子一测关系全乱。原因多半是字符位置和 token 位置没对齐尤其是中文里数字、英文、标点混排时分词器会把一个词切成多个 token字符下标和 token 下标差了好几位。解决办法是强制用offset_mapping做映射并且在数据预处理阶段加断言检查每个实体的 token 区间是否连续、是否落在有效范围内。我一般会写个校验函数把映射失败的样本打印出来人工看一眼通常看十几条就能发现规律。4.2 关系类别不平衡模型全预测成多数类现象是准确率虚高但少数类召回接近零。原因是某些关系样本只有几十条而多数类有几千条交叉熵会被多数类主导。解决办法有两个一是对损失函数加类别权重用CrossEntropyLoss(weightclass_weights)权重按类别频率的倒数算二是对少数类做数据增强比如同义词替换、实体替换。我一般先加权重效果不够再增强因为增强容易引入噪声。4.3 训练集和验证集同分布评估结果过于乐观现象是验证集 F1 到 0.95换一批新数据掉到 0.6。原因是划分数据时随机切分同一个句子的不同三元组可能同时出现在训练和验证集里造成信息泄漏。解决办法是按句子或按文档划分确保同一个来源的样本只出现在一边。如果数据本身来自多个来源按来源划分更稳。这个坑很隐蔽因为指标好看的时候没人会怀疑数据划分。4.4 学习率设太大预训练权重被带偏现象是训练前几个 step loss 直接飙到几十甚至 NaN。原因是 BERT 微调学习率超过 1e-4 就容易发散。解决办法是把学习率降到 2e-5 到 5e-5加 warmup加梯度裁剪。如果已经发散重新加载预训练权重再来别在发散的模型上继续训。我习惯在训练脚本里加一个 loss 监控前 100 个 step 如果 loss 不降反升直接中断调参。4.5 推理时实体未做归一化匹配不上现象是明明句子里有「阿里巴巴集团」但传入的实体是「阿里巴巴」text.find返回 -1位置计算出错。原因是实体识别模块输出的实体和关系模块期望的实体不一致。解决办法是在推理前做一层实体对齐用最长匹配或模糊匹配把实体统一到原文子串。更稳的做法是让实体识别和关系分类共享同一套实体边界别各算各的。5. 把这份源码包改造成自己业务的抽取器三个进阶技巧5.1 用 BIO 标注替代 JSON 输入减少预处理开销如果你的实体识别和关系分类要串成一条流水线每次推理都做字符到 token 的映射很浪费。可以把实体边界直接编码进输入比如用特殊标记[E1]、[/E1]包住头实体[E2]、[/E2]包住尾实体让 BERT 自己去学标记位置。这样推理时不需要额外算位置模型输入就是纯文本加标记。代价是标注数据要重新生成但一次投入长期省事。def build_marked_text(text, head, tail): # 用特殊标记包住实体注意先替换后面的避免位置偏移 if text.find(head) text.find(tail): text text.replace(head, f[E1]{head}[/E1], 1) text text.replace(tail, f[E2]{tail}[/E2], 1) else: text text.replace(tail, f[E2]{tail}[/E2], 1) text text.replace(head, f[E1]{head}[/E1], 1) return text这个函数的关键是替换顺序先替换位置靠前的实体否则先替换后面的会让前面的位置偏移。特殊标记要加到 tokenizer 的additional_special_tokens里否则会被切成多个 token。这种做法的好处是模型能看到实体周围的上下文对关系判断更准缺点是标记本身会占用 token 长度长文本要留意截断。5.2 用对抗训练提升鲁棒性业务数据里常有错别字、口语化表达标准微调的模型容易过拟合到训练集的用词。对抗训练的思路是在 embedding 上加一点扰动让模型在扰动下仍然预测正确。实现上可以用 FGM 或 PGD代码量不大但效果明显。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 param.requires_grad and embedding in name: 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 {}用法是在正常反向传播后调用attack()再算一次 loss 和反向传播然后restore()。epsilon设 1.0 是常用值太大反而伤效果。这个技巧在样本少于一万条时提升明显样本多的时候收益递减。5.3 用置信度校准决定是否人工复核模型输出的 softmax 概率往往偏高0.9 的置信度可能实际准确率只有 0.7。上线时如果直接按 0.5 阈值放行错误会混进去。稳妥做法是留一批验证数据做置信度校准画出准确率-置信度曲线找到准确率满足业务要求的那个阈值。比如业务要求准确率 95%校准后发现置信度要 0.85 以上才达标那就把阈值设 0.85低于这个值的走人工复核。置信度区间样本数实际准确率处理策略0.9 以上12000.96自动放行0.7 到 0.98000.82抽检0.5 到 0.74000.61人工复核0.5 以下2000.33丢弃或重抽这张表是我在一个项目里实际统计出来的不同数据分布会不一样但规律类似高置信度区间准确率明显更高低置信度区间基本不可用。有了这张表你就能跟业务方说清楚「自动放行多少、人工兜底多少」而不是拍脑袋定阈值。最后说个我自己的习惯每次拿到新的关系抽取任务先不急着改模型而是花半天时间把数据格式、实体边界、类别分布这三件事查一遍。这三件事对了模型用最朴素的 BERT 加全连接就能到可用水平这三件事错了换再大的模型也是白搭。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑