☰
基于BERT的关系三元组抽取实战:从数据标注到模型训练避坑指南
2026/10/1 2:03:47 网站建设 项目流程

简介:基于BERT实现关系三元组抽取的完整项目包,主要面向自然语言处理方向的毕业设计、课程设计与项目实战学习者。代码已经过测试并成功运行,功能覆盖数据预处理、模型训练、预测与推理等完整环节,内置两套关系抽取数据集,并配置了中文BERT预训练模型;同时附带项目说明文档、模型与参数配置文件,便于快速理解整体结构、复现实验结果或进行二次开发。压缩包共五十六个文件,主要包含十六个脚本、十四个参数配置、二十四个文本数据和两个说明文档,包体仅三点八二MB,整体轻量、结构清晰,适合在普通环境中部署运行。已有四百五十五人学习下载,可作为高校课程设计、毕业设计的参考范例,也适合初学者按模块学习关系抽取、命名实体识别等内容,并可基于此扩展更多业务功能。

1. 基于BERT的关系三元组抽取:一个zip包能让入门者把任务跑通到什么程度

关系三元组抽取的目标很直白:给定一句话,输出(subject, relation, object)这样的结构化信息,例如从“张三创办了智谱公司”抽出“(张三,创办,智谱公司)”。过去做这个任务要堆规则、做特征工程,遇到同义表达、指代和长尾关系就很吃力。引入BERT之后,这个任务的落地方式变成“预训练模型 + 抽取头”,也就是把关系抽取当成一个可微调的序列标注或指针预测问题。这样一个zip包式的Python源码项目,对算法工程师、NLP爱好者和做毕设的学生来说,是一条很清晰的路径:先准备数据集,再写模型,然后训练、评估、挑错误案例。这篇文章按照我实际做信息抽取的经验,把这个项目的原理、数据格式、训练参数和最容易翻车的场景全部拆开讲,你可以照着跑通,也知道每一步为什么要这么设。

2. 从句子到三元组:数据标注格式、序列化方法和BERT输入的三个坑

2.1 关系三元组抽取的两种主流实现:序列标注和指针网络

关系三元组抽取在BERT之前常用序列标注:把句子里的每个token标注成实体类型和关系类型的一部分,比如B-SUBJECT、I-OBJECT、B-RELATION。这种做法的优点是实现简单,一个softmax分类器就能跑,缺点是碰到实体重叠和关系重叠时很难处理,比如“张三和李四共同创办了公司A”这种句子,如果两个subject共享同一个object,序列标注的标签空间会冲突。

现在更常见的做法是“指针网络”,也被称为“多头选择”或“CasRel风格”。基本思路是:先用BERT给句子编码,对每个token都输出两个二分类结果,一个判断它是不是某个subject的起始位置,另一个判断它是不是subject的结束位置;然后再为每种预定义关系分别做object的起始和结束位置预测。这样每个三元组都有一组指针位置,天然支持一个句子中抽出多个关系、多个实体,也支持相同实体参与不同关系。对于这个zip项目来说,源码里大概率走的是这种指针路子,因为它是处理重叠三元组最常见、也最稳的方案。

序列标注适合关系种类少、句子结构简单的数据集;指针网络适合关系数量在几十个、句子信息密度高的业务场景。如果你的数据里经常出现“一个实体参与多条关系”或“一个句子有多个三元组”,我建议直接选指针网络,不要回头改序列标注模型,改到后面光是设计标签就够你受的。

2.2 数据集格式与预处理:把JSON三元组变成BERT能吃的标签

关系抽取数据集的常见格式是JSON,每个样本至少包含两样东西:文本和三元组列表。三元组列表里的每一项通常是["subject", "relation", "object"],也就是实体文本、关系名、实体文本。下面这一段就是典型结构:

{ "text": "张三创办了智谱公司,总部位于北京。", "triple_list": [ ["张三", "创办", "智谱公司"], ["智谱公司", "位于", "北京"] ] }

这里triple_list里的关系名“创办”“位于”是预定义关系集合里的值,不能是模型没见过的词。我处理过的数据里,经常有标注人员把“创办于”“成立于”这种带时态的词写进关系名,如果不做归一化,模型会把同样语义的关系当成两个类别去学,数据一稀疏就学不动。

把JSON转成训练样本时,要先把实体文本映射到句子里的字符位置。如果你的数据集只给了实体文本,没有给出offset,就得用字符串匹配方式找起点和终点。此时要格外小心:实体“智谱公司”如果在句子中出现两次,默认只取第一个,会漏掉另一个位置的标注;我的做法是写一个映射函数,把所有出现位置都记下来,再让每条三元组对应唯一一组起止位置。下面是一段常见的数据预处理逻辑:

def locate_entity(text, entity): """ 返回实体在句子中的起止字符位置;找不到就返回(-1, -1) """ start = text.find(entity) if start == -1: return -1, -1 return start, start + len(entity)

这段代码看起来很基础,但它是整个数据管线里最容易出问题的一环:直接用find会把“北京”和“北京市”混淆,后面的len(entity)在英文数据里也会因为空格和大小写造成偏移。所以稳妥的做法是先统一做字符级归一化,比如全角转半角、去掉实体两端的空格,再执行定位。定位完成后,训练样本才能安全地转成标签序列。

2.3 BERT输入构造:CLS、SEP、token type id和max length

BERT输入不能直接塞原始字符串,需要转成input_ids、attention_mask和token_type_ids。对中文来说,bert-base-chinese的tokenizer基本是按字切分,这比英文按子词切分简单很多,但问题在于:实体起止位置是按字符算的,BERT tokenizer处理后token序列和字符序列可能不对齐。所以一种可靠做法是先取原始字符中的start/end,再找到这些字符在token序列里对应的token下标。

from transformers import BertTokenizer tokenizer = BertTokenizer.from_pretrained("bert-base-chinese") text = "张三创办了智谱公司" tokens = tokenizer.tokenize(text) char_to_token = tokenizer.encode(text, add_special_tokens=False)

注意这段代码里我用的是tokenizer.encode而不是tokenize,因为tokenize返回中文单字,但英文会返回子词,容易和字符位置错位;encode返回的是token id,再通过tokenizer的offset_mapping能拿到每个token对应的字符区间。更规范的做法是使用tokenizer(text, return_offsets_mapping=True),这样可以直接基于offset构造标签,而不是自行推算下标。

构造序列时,我一般会在开头加[CLS]、结尾加[SEP],并把max length设成128。设置128不是拍脑袋:BERT的自注意力复杂度是序列长度的平方,长句子带来的训练时间增长非常明显;业务数据里90%的三元组都在两句话以内,128个字符足够覆盖大多数中文文本。如果句子超过128,超出部分要截断,但要注意不能让一个完整三元组的头和尾被拦腰截断。预处理阶段就要过滤掉跨句三元组,或者把超长句子按“包含同一三元组的子句”重新切分。

3. 用BERT做抽取头:模型结构选型、损失函数和训练参数怎么定

3.1 模型结构:BERT + 指针头,还是BERT + CRF

把预训练模型接到抽取任务上,常见的做法有两种:BERT + 指针头,和BERT + CRF。CRF用在序列标注上,可以约束标签间的转移关系,例如“B-entity后面不能直接跟其他实体的I标签”,但它本质上还是给每个token一个离散标签,处理不了重叠三元组。指针头不一样,它预测的是起点和终点,每个token做两次二分类,不要求实体标签是连续序列,因此允许两个实体共享一个对象的边界。

具体设计上,我用的是BERT encoder后接两个线性层:一个预测所有subject的起点,一个预测所有subject的终点;然后再为每个关系类别复制同样两个线性层,用来预测object的起点和终点。因为关系类别可能有二三十个,显存会相应增加,但换来的是每个关系都能独立抽三元组。如果你的关系类别超过50个,建议再加一层投影先压缩BERT输出的hidden_size,否则模型尾部参数会占掉一大块显存。

损失函数不建议用交叉熵直接算多分类,因为位置预测实际上是多个二分类任务。每个token都有可能在多个关系里充当subject或object,用二值交叉熵(BCE)可以独立控制正负样本,也方便在类别不均衡的时候给正样本加权。下面的代码展示了一个最简指针头定义:

import torch from torch import nn class BertPointerExtractor(nn.Module): def __init__(self, bert_model, num_relations): super().__init__() self.bert = bert_model hidden_size = bert_model.config.hidden_size self.rel_num = num_relations # subject头只预测一组起止位置 self.sub_start = nn.Linear(hidden_size, 1) self.sub_end = nn.Linear(hidden_size, 1) # object头按关系数复制 self.obj_start = nn.ModuleList( [nn.Linear(hidden_size, 1) for _ in range(num_relations)] ) self.obj_end = nn.ModuleList( [nn.Linear(hidden_size, 1) for _ in range(num_relations)] ) def forward(self, input_ids, attention_mask): outputs = self.bert(input_ids, attention_mask=attention_mask) seq_out = outputs.last_hidden_state return seq_out

这里的nn.ModuleList不能写成list,否则PyTorch不会把内部参数注册到模型里,训练时这些层根本不会更新。这是我见过很多新手踩的坑,后面单独讲。

3.2 关键训练参数:batch size、学习率、max length和epoch

微调BERT的参数设置有相对固定的经验区间,先看下面这张表,再讲调整逻辑。

参数常见初始值调整方向
batch size16显存不足时降到8或4,或用梯度累积
学习率2e-5数据量大时3e-5,数据量小用1e-5
max length128长文本数据集加大到256,但显存成本显著上升
epoch3数据少时5,验证集F1连续下降就提前停
warmup ratio0.1大batch和大epoch下适当调低

为什么学习率要用2e-5?BERT经过预训练后参数已经在一个较好位置,微调时学习率太高会破坏预训练学到的语义;太低又很难在新任务上快速收敛。batch size和max length共同决定显存占用,如果一个句子长度128,batch size 16,一张12G显存卡跑bert-base-zh刚刚够,加上指针头后开销还能接受。

训练时还需要做两件事:第一,把所有标签张量放到和模型输出一致的设备上,最好在建batch时就统一to(device);第二,每个epoch结束用验证集算一次F1,而不是只观察训练loss。训练loss降得漂亮不代表能抽出正确三元组,因为正负样本不均衡很容易让loss表面好看。

3.3 最小可复现的Python训练脚本骨架

下面给出一个可以放在源码项目里作为train.py核心逻辑的骨架,它删掉了数据读取细节,保留模型初始化、训练循环和损失计算:

from transformers import BertModel, BertTokenizer import torch from torch.optim import AdamW tokenizer = BertTokenizer.from_pretrained("bert-base-chinese") bert = BertModel.from_pretrained("bert-base-chinese") model = BertPointerExtractor(bert, num_relations=len(relation_list)) opt = AdamW(model.parameters(), lr=2e-5) bce = torch.nn.BCEWithLogitsLoss() 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) seq_out = model(input_ids, attention_mask) # 简化示例:只算subject起点的损失,object头同理 sub_start_logits = model.sub_start(seq_out).squeeze(-1) loss = bce(sub_start_logits, labels["sub_start"]) loss.backward() opt.step() opt.zero_grad()

注意这里有几个关键点:AdamW的权重衰减和解耦机制比传统Adam更适合预训练模型微调;BCEWithLogitsLoss会在内部做sigmoid,所以模型输出的logits不需要再手动过sigmoid,否则数值会被算两次;损失计算时需要把labels["sub_start"]和sub_start_logits的shape对齐,通常都是[batch_size, seq_len]。如果你照这个骨架跑,第一轮loss会从0.7到0.8左右往下掉,但如果一开始就掉到0.1以下,多半是标签没有对齐,而不是模型学得好。

4. 避坑/常见问题/排查:5个最容易翻车的场景和解决办法

4.1 标签错位:分词后的BIO标签对不上

现象:训练时loss掉得很快,验证时预测结果却几乎全乱,抽出来的实体边界总比真实标注偏几个token。

原因:在英文或混合文本里,tokenizer会把“company”切成“company”,也可能把“companies”切成“compan”和“##ies”,字符级实体边界直接映射到token级就会错位。中文虽然按字切,但遇到全角空格和特殊符号仍然会偏移。

解决:永远基于offset_mapping或tokenizer(text, return_offsets_mapping=True)来构造标签,不要自己手动算下标。如下:

encoded = tokenizer( text, return_offsets_mapping=True, truncation=True, max_length=128 ) for token_idx, (start, end) in enumerate(encoded["offset_mapping"]): # start == end 时表示特殊token或空token if start <= entity_char_start < end: # 当前token属于实体开始位置 pass

这能保证标签始终和token一一对应。构造训练集时,我习惯于先用offset_mapping生成一个token 到 字符区间的映射,再给每个token打标签,而不是反过来。

4.2 同一句子多个三元组时只抽出一个

现象:一句话里有三个三元组,模型只输出置信度最高的那个,其余全部漏掉。

原因:如果模型结构是全局指针头,每个关系独立预测,一般不会出现“只抽一个”的问题。出现这个现象,大概率是因为源码实现里把“不同关系相同实体”合并了,或者在解码时只保留subject_start概率最高的那个路径。

解决:解码阶段不要先选一个subject,再去找object;正确的做法是遍历所有关系,对每个关系独立找subject和object,然后合并重复三元组。还要确认obj_start和obj_end的预测是针对每个关系类别分别进行的,不是共享一套线性层。共享参数会在关系数量多时明显互相干扰。

4.3 数据集里的三元组跨句子,预测结果对不上

现象:训练集文本很长,标注的三元组主语在第一句,宾语在第三句;预测时模型只在当前句内找实体,造成召回率很低。

原因:BERT的注意力虽然能看全文,但中文BERT按512个字截断时,长距离实体之间的关联已经被稀释;再加上很多项目会把长句切分,三元组信息被拆断。

解决:预处理阶段检测三元组实体在原始文本中的offset,当subject和object不在同一个切分窗口时,跳过该三元组。如果业务不允许丢弃,可以把含有同一三元组的相邻子句合成一个样本,但max length要相应调到256或更长。这个规则写进数据清洗脚本,能直接提升验证集F1约5到10个百分点。

4.4 显存爆炸和训练速度慢

现象:12G显存的卡在batch size 8、max length 128下竟然OOM了;或者单epoch跑20分钟,完全没法调参数。

原因:BERT的attention矩阵大小为batch_size * num_heads * seq_len * seq_len,max length从128提到256,显存消耗近似翻4倍。很多人还把ModuleList里的每个线性层都复制到了显存,导致参数量暴涨。

解决:先用小batch size和短max length跑通一个epoch,确认数据集和代码正确后再加量。推荐组合是max length 128、batch size 16,OOM就把batch size降一半,并用梯度累积补偿。另外,把所有关系头的线性层合并成一个[num_relations, hidden_size]的大矩阵,通过矩阵乘法一次算出所有关系的logits,显存占用和计算速度都会好很多。你也可以打开AMP混合精度训练,在保持准确率不降的同时减少约40%显存。

4.5 关系类别严重不均衡,F1拉不上去

现象:数据里“位于”关系有五千条,“参股”关系只有三十条,模型训练后“参股”关系的精确率和召回率都是0。

原因:BCE默认对所有正负样本一视同仁;负样本远多于正样本时,模型直接预测全部为负,loss也能保持较低。这和关系抽取自带的类别不均衡有关。

解决:给每个关系类别做loss加权,权重和当前关系在训练集中的样本数成反比,但要做平滑,比如weight = 1 / sqrt(count),避免冷门关系权重过大。更好的做法是在采样时对冷门关系所在句子多复制几份,也就是简单过采样。注意不能只对三元组采样,要保留包含该三元组的完整句子,否则上下文被切断,模型学不到有效特征。

5. 从能跑到能用的进阶:评估指标、错误案例分析和微调策略

5.1 用最小Python脚本算实体级精确率、召回率和F1

跑通训练后首先要做的是算标准的三元组级别评估,而不是只看token级别的accuracy。一个三元组只有当subject、relation、object都和标注完全一致时才算正确。下面的脚本可以直接放在evaluate.py里用:

def calc_f1(pred_triples, gold_triples): # pred_triples 和 gold_triples 都是三元组列表 pred_set = set(pred_triples) gold_set = set(gold_triples) correct = len(pred_set & gold_set) precision = correct / len(pred_set) if pred_set else 0 recall = correct / len(gold_set) if gold_set else 0 f1 = 2 * precision * recall / (precision + recall) if precision + recall else 0 return precision, recall, f1

这里有个容易忽略的点:如果直接拿字符串比较,模型会输出“智谱AI公司”,标注是“智谱AI”,就算实体文本在语义上没区别,也会被判错。实际项目中我通常先做一次实体边界对齐,把预测实体截断到和标注实体一致,再计算指标,否则指标会虚低,且无法定位模型到底是边界问题还是关系识别问题。

5.2 打印bad case,区分模型问题还是数据问题

评估完F1之后,一定要抽几个错误三元组,把上下文和预测结果打印出来,这是最快定位问题的方式。一个简单的做法是保存所有预测错的样本,然后用“关系名 + 实体对”做聚合。

error_path = "bad_cases.json" for idx, pred, gold in zip(ids, pred_triples, gold_triples): if pred != gold: wrong_cases.append({ "text": texts[idx], "predict": pred, "gold": gold })

打印出来之后,如果错误集中在“宾语位置偏了一个字符”,那是标签对齐问题;如果错误集中在“关系识别错”,比如把“位于”预测成“总部在”,那是关系标签定义边界不清晰;如果错误是一个三元组只预测出一半,即subject对了但object全空,则是长距离依赖问题,需要从max length和负样本采样入手。这一步比反复调学习率有效得多,因为很多情况下模型没错,是数据标注口径不统一。

5.3 换领域时的微调策略

如果你把这个项目从公开数据集迁到自己的业务数据上,不能直接拿着通用BERT跑。我的做法是先从业务语料里挑出所有含目标关系的句子,用预训练好的模型做一轮zero-shot预测,把预测错但置信度高的案例收集成补充训练数据,再在专有数据上做二次微调。这个过程相当于用模型自己挑难样本,比随机抽样更高效。

关系三元组抽取的复杂度不在模型,而在数据链路:从实体定位、token对齐,到解码策略和类别均衡,每个环节都有可能让模型翻车。我记得第一次做这个任务时,整整两天都在查“为什么loss降了但F1不涨”,最后发现是标签错位一个token。一个冷布置的教训:跑任何BERT抽取项目之前,先打印20条训练样本,人工检查[token, offset, label]三者是否对齐,再决定要不要训练。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询