☰
BERT+CRF三元组识别实战:从BIO标注到边界优化
2026/10/9 5:53:29 网站建设 项目流程

简介:这份资源是面向NLP初学者与进阶开发者的Bert+CRF三元组识别实战项目,聚焦从中文文本中自动抽取主体、谓词、客体构成的结构化知识,可用于知识图谱构建、信息抽取等场景。压缩包共11个文件,以6个Python脚本为核心,涵盖模型定义、数据预处理、训练主流程、数据集划分与预测推理,另含3个Markdown说明文档、1个依赖清单和1张示意图,整体约37KB,结构清晰便于按模块阅读。项目完整呈现了Bert编码与CRF序列标注结合的实现路径,涉及Hugging Face Transformers调用、中文预训练权重加载、序列标注标签设计、模型训练评估及预测后处理等关键环节,读者可据此理解三元组识别的端到端流程,并在此基础上迁移到自有数据或相似抽取任务。目前已有122人学习,适合希望掌握深度学习文本抽取技巧的开发者参考。

1. Bert+CRF 做三元组识别:为什么单独用 BERT 总在边界上翻车

三元组识别(主体-谓语-客体,SPO)是知识抽取里最常被落地的一环。很多团队第一版直接用 BERT 加一个 softmax 分类头,把每个 token 打成 B-SUB、I-SUB、B-PRE、I-PRE、B-OBJ、I-OBJ、O 这几类,跑通训练集看着 F1 挺漂亮,一上真实语料就发现实体边界像被狗啃过——"北京大学"被切成"北京"+"大学"两个片段,"张三丰"的"丰"字经常掉到 O 类里。原因不玄学:softmax 对每个位置独立打分,标签之间的转移约束(比如 I-SUB 不能直接跟在 B-PRE 后面)完全靠模型自己从数据里悟,样本一少就悟不出来。

Bert+CRF 这套组合就是冲着这个痛点来的。BERT 负责把字/词映射成带上下文语义的向量,CRF 层在输出序列上做全局最优解码,用转移矩阵显式约束标签路径。三元组识别里,它通常不是直接抽 SPO,而是先做序列标注把主体、谓语、客体各自的 span 标出来,再按规则或指针网络配对成三元组。适合谁?手里有几千到几万条标注语料、要做垂直领域(医疗、法律、工业设备台账)关系抽取的工程师。这篇就把这套方案从数据格式、模型结构、训练参数到解码配对,按能复现的粒度讲清楚。

2. 三元组识别任务拆解:从 SPO 到 BIO 标注的映射

2.1 为什么先做序列标注而不是直接生成三元组

三元组识别有两条主流路线:一是序列标注 + 配对,二是 seq2seq 直接生成 SPO 文本。生成式路线对预训练模型友好,但有两个硬伤:一是三元组数量不固定时解码长度难控制,二是生成结果里主体客体容易串位,后处理成本高。序列标注路线把问题降维成每个 token 的分类,标签空间固定、训练稳定、可解释性强,配合 CRF 还能保证标签路径合法。垂直领域语料往往只有几千条,标注路线对数据量的要求更低,这是我一般会优先选它的原因。

具体映射方式:给主体、谓语、客体各分配一组 BIO 标签。假设标签集是B-SUB, I-SUB, B-PRE, I-PRE, B-OBJ, I-OBJ, O,那么句子"张三 就职于 北京大学"的标注就是:

token张三就职于北京大学
labelB-SUBB-PREB-OBJI-OBJ

这里有个关键决策:谓语要不要单独标?如果谓语是固定关系词表(比如"就职于""毕业于"),可以只标主体和客体,谓语用词典匹配;如果谓语开放,就必须标。三元组识别里谓语往往是关系类型,标出来才能配对。

2.2 标签体系设计:BIO 还是 BIOES

BIO 够用,但 BIOES(B/I/O/E/S)在边界上更稳。E 表示实体结尾,S 表示单字实体。用 BIOES 时"北京大学"是B-OBJ I-OBJ I-OBJ E-OBJ,"张三"是S-SUB。CRF 的转移矩阵会学到B-OBJ -> I-OBJ -> E-OBJ这条合法路径,非法路径(如B-OBJ -> B-SUB)的转移分数被压到很低。代价是标签数翻倍,小语料上可能欠拟合。我的经验:语料超过 5000 条用 BIOES,低于 3000 条先用 BIO,跑通再升级。

标签体系一旦定下就不要中途改,改一次所有标注数据都要重刷,这是血泪经验。建议在项目根目录放一个labels.txt,训练、推理、评估全部从这一个文件读标签,避免硬编码。

# labels.txt 示例,顺序即 id 顺序,0 必须是 O O B-SUB I-SUB E-SUB S-SUB B-PRE I-PRE E-PRE S-PRE B-OBJ I-OBJ E-OBJ S-OBJ

逻辑说明:CRF 的转移矩阵是(num_labels, num_labels),标签 id 顺序决定矩阵行列含义,训练和推理必须一致。参数说明:第一行O对应 id 0,是 CRF 的默认起始/结束转移基准,不要随意调换顺序。

3. 模型结构:BERT 输出接 CRF 的三种接法与选型

3.1 标准接法:BERT + 线性层 + CRF

最常见也最稳的结构是:BERT 最后一层 hidden state(维度 768)过一个Linear(768, num_labels)得到发射分数(emission),再送进 CRF 层。CRF 的 forward 计算所有路径的分数和(配 log-sum-exp),decode 用 Viterbi 找最优路径。训练时 loss 是负对数似然,即-log P(y|x)。

import torch import torch.nn as nn from transformers import BertModel class BertCrfForNer(nn.Module): def __init__(self, bert_path, num_labels, dropout=0.1): super().__init__() self.bert = BertModel.from_pretrained(bert_path) self.dropout = nn.Dropout(dropout) # 发射层:768 -> 标签数 self.emission = nn.Linear(self.bert.config.hidden_size, num_labels) # CRF 层:num_labels 个标签的转移矩阵 self.crf = CRF(num_labels, batch_first=True) def forward(self, input_ids, attention_mask, labels=None): outputs = self.bert(input_ids, attention_mask=attention_mask) sequence_output = self.dropout(outputs.last_hidden_state) emissions = self.emission(sequence_output) if labels is not None: # 训练:算负对数似然,mask 掉 padding loss = -self.crf(emissions, labels, mask=attention_mask.bool()) return loss # 推理:Viterbi 解码 return self.crf.decode(emissions, mask=attention_mask.bool())

逻辑说明:emission把每个 token 的 768 维向量映射到标签分数,CRF 在此基础上加转移分数。参数说明:dropout=0.1是常规起点,语料小可以调到 0.3 防过拟合;mask必须传,否则 padding 位置会污染转移路径,这是新手最常翻车的地方。

3.2 接法对比:要不要在 CRF 前加 BiLSTM

BERT 本身已经是双向的,理论上不需要 BiLSTM。但我在工业设备台账这种长实体、嵌套表达多的语料上试过,BERT 后加一层 BiLSTM(hidden 256)再接 CRF,F1 能涨 0.5~1 个点,代价是显存多占约 20%、训练慢 30%。选型建议:

接法适用场景显存训练速度边界 F1
BERT+Linear+CRF通用、语料中等基准基准基准
BERT+BiLSTM+CRF长实体、嵌套多+20%-30%+0.5~1
BERT+CRF(无 Linear)不推荐--掉点明显

第三种是直接拿 BERT 输出当发射分数,维度对不上,必须加 Linear,别省这一步。

3.3 转移矩阵初始化与约束

CRF 的转移矩阵默认随机初始化,训练初期会走很多非法路径。可以手动加约束:把O -> I-XXX、B-XXX -> I-YYY(XXX≠YYY)这类非法转移的初始值设成 -1e4。常见做法是写一个allowed_transitions列表,在 CRF 初始化时传入。

# 构造合法转移约束:只允许同类型实体的 B->I->E 和 O->B def build_transitions(labels): trans = {} for i, src in enumerate(labels): for j, dst in enumerate(labels): # 默认允许,非法组合置为 -1e4 if src.startswith('O') and dst.startswith('I'): trans[(i, j)] = -1e4 elif src.startswith('B') and dst.startswith('I'): if src.split('-')[1] != dst.split('-')[1]: trans[(i, j)] = -1e4 return trans

逻辑说明:约束只影响初始值,训练中模型仍可微调,但能大幅减少前期无效探索。参数说明:-1e4是经验值,太小(如 -1)约束不住,太大(如 -1e9)会导致梯度消失,别用极端值。

4. 数据准备与训练:从原始标注到能跑的 batch

4.1 数据格式:JSON 转 BIO 的转换脚本

原始标注常见两种:一是 JSON 里给{text, spo_list},spo_list 里是{subject, predicate, object};二是平台导出的字符偏移。统一转成 BIO 序列是第一步。

import json def spo_to_bio(text, spo_list, labels): # 初始化全 O tags = ['O'] * len(text) for spo in spo_list: for role, key in [('SUB', 'subject'), ('PRE', 'predicate'), ('OBJ', 'object')]: entity = spo[key] start = text.find(entity) if start == -1: continue # 实体没在原文找到,跳过并记日志 end = start + len(entity) - 1 if start == end: tags[start] = f'S-{role}' else: tags[start] = f'B-{role}' for i in range(start + 1, end): tags[i] = f'I-{role}' tags[end] = f'E-{role}' return tags # 读取并转换 with open('raw.json', 'r', encoding='utf-8') as f: data = json.load(f) labels = [l.strip() for l in open('labels.txt', encoding='utf-8')] converted = [] for item in data: tags = spo_to_bio(item['text'], item['spo_list'], labels) converted.append({'text': item['text'], 'tags': tags}) json.dump(converted, open('bio.json', 'w', encoding='utf-8'), ensure_ascii=False)

逻辑说明:text.find(entity)定位实体,重叠实体只保留第一个匹配,这是简化处理,生产环境要用偏移量。参数说明:labels从labels.txt读,保证 id 一致;ensure_ascii=False保证中文不转义。

4.2 训练参数:学习率、batch、epoch 怎么设

BERT+CRF 微调的参数和纯分类任务不同,CRF 层对学习率更敏感。我一般用分层学习率:BERT 主体 2e-5,CRF 和 Linear 层 1e-3。

from transformers import AdamW, get_linear_schedule_with_warmup # 分层学习率 bert_params = list(model.bert.named_parameters()) head_params = list(model.emission.named_parameters()) + list(model.crf.named_parameters()) optimizer = AdamW([ {'params': [p for n, p in bert_params], 'lr': 2e-5}, {'params': [p for n, p in head_params], 'lr': 1e-3}, ], weight_decay=0.01) # 线性 warmup,总步数 = epoch * steps_per_epoch total_steps = 10 * len(train_loader) scheduler = get_linear_schedule_with_warmup( optimizer, num_warmup_steps=int(0.1 * total_steps), num_training_steps=total_steps)

逻辑说明:BERT 预训练权重已经很好,大学习率会破坏;CRF 是随机初始化的,需要大学习率快速收敛。参数说明:weight_decay=0.01防过拟合;warmup 比例 0.1 是常规起点,语料小可以调到 0.2。

4.3 评估:实体级 F1 而不是 token 级

token 级准确率会骗人。一个 4 字实体标对 3 个字,token 准确率 75%,但实体级算全错。评估必须按实体 span 严格匹配。

def extract_entities(tags, labels): entities = [] start = None for i, tag in enumerate(tags): if tag.startswith('B-') or tag.startswith('S-'): if start is not None: entities.append((start, i - 1, prev_type)) if tag.startswith('S-'): entities.append((i, i, tag.split('-')[1])) start = None else: start = i prev_type = tag.split('-')[1] elif tag.startswith('E-') and start is not None: entities.append((start, i, tag.split('-')[1])) start = None elif tag == 'O' and start is not None: entities.append((start, i - 1, prev_type)) start = None return entities

逻辑说明:按 B/I/E/S 状态机抽 span,prev_type记录实体类型。参数说明:评估时用seqeval库更省事,但自己写一遍能看清边界逻辑,排查问题时有用。

5. 避坑与排查:三元组识别里最容易翻车的 5 个点

5.1 现象:训练 loss 正常下降,但验证集 F1 一直是 0

原因:标签 id 和labels.txt顺序不一致,或者 CRF 的num_labels传错。常见于从别人代码里拷了 CRF 实现,标签顺序没对齐。

解决:打印model.crf.num_labels和len(labels)对比,再打印一条样本的labels张量看 id 是否落在合法范围。我一般会在训练前跑一个assert max(label_ids) < num_labels。

5.2 现象:推理时实体边界总是多一个字或少一个字

原因:BIOES 的 E 标签没学好,或者 padding mask 没传对,导致 CRF 在 padding 位置也参与解码。

解决:检查attention_mask是否传进 CRF 的mask参数;如果用的是 BIO 体系,检查I-XXX是否被错误地允许跟在O后面,加转移约束。

5.3 现象:同一实体在句中出现多次,只标出一个

原因:text.find()只找第一个匹配,重叠或重复实体被吞。

解决:改用字符偏移标注,或在转换脚本里维护一个已用位置集合,跳过已占用的 span。生产环境建议直接用偏移量,别用字符串查找。

5.4 现象:谓语标签把主体客体也吞进去

原因:谓语和主体客体在文本上相邻,BIO 标注时边界划错。比如"张三就职于北京大学",如果"就职于"标成B-PRE I-PRE I-PRE,而"北京大学"的B-OBJ紧跟其后,CRF 可能学出I-PRE -> B-OBJ的转移。

解决:在转移约束里显式禁止跨类型实体的I -> B直接转移,或者把谓语单独用词典匹配,不参与序列标注。

5.5 现象:换领域语料后 F1 断崖下跌

原因:BERT 预训练语料和垂直领域差异大,CRF 转移矩阵也过拟合到旧领域的标签分布。

解决:先用领域语料做 MLM 继续预训练,再微调;CRF 层可以重新初始化,只加载 BERT 权重。我一般会保留一个--reset_crf开关,换领域时打开。

6. 进阶技巧:用指针网络替代 CRF 做三元组配对

CRF 解决的是序列标注,但三元组识别最终要的是 SPO 配对。序列标注只告诉你"哪些 span 是主体、哪些是客体",配对还得靠规则或模型。规则配对的典型做法是:主体和客体在同一句、谓语在两者之间,就配成三元组。但遇到"张三和李四都就职于北京大学"这种多主体共享客体,规则就崩了。

进阶方案是换成指针网络(Pointer Network):主体抽取用一组指针标 start/end,客体抽取时以主体为条件,用另一组指针标 start/end。这样配对是模型学出来的,不依赖位置规则。实现上,BERT 输出后接两个线性层分别预测 start 和 end 的 logits,训练时用交叉熵。

class PointerForSPO(nn.Module): def __init__(self, bert_path, hidden=768): super().__init__() self.bert = BertModel.from_pretrained(bert_path) # 主体 start/end 指针 self.sub_start = nn.Linear(hidden, 1) self.sub_end = nn.Linear(hidden, 1) # 客体 start/end 指针,条件在主体表示上 self.obj_start = nn.Linear(hidden * 2, 1) self.obj_end = nn.Linear(hidden * 2, 1) def forward(self, input_ids, attention_mask, sub_span=None): out = self.bert(input_ids, attention_mask=attention_mask).last_hidden_state sub_s = self.sub_start(out).squeeze(-1) sub_e = self.sub_end(out).squeeze(-1) if sub_span is not None: # 取主体 span 的平均向量作为条件 s, e = sub_span sub_vec = out[:, s:e+1, :].mean(dim=1, keepdim=True).expand(-1, out.size(1), -1) cond = torch.cat([out, sub_vec], dim=-1) obj_s = self.obj_start(cond).squeeze(-1) obj_e = self.obj_end(cond).squeeze(-1) return sub_s, sub_e, obj_s, obj_e return sub_s, sub_e

逻辑说明:主体指针独立预测,客体指针把主体向量拼接到每个 token 上,实现条件抽取。参数说明:hidden*2是因为拼接了主体向量;训练时主体 span 用 gold 标签,推理时用预测结果,会有 exposure bias,可以用 scheduled sampling 缓解。

指针网络的代价是标注要求更高——需要主体和客体的配对关系,而不只是 span。如果手里只有 span 级标注,先用 CRF 跑通,再考虑升级。我自己的习惯是:CRF 版本作为 baseline 必须能跑通,指针网络作为提升项,两者 F1 差距小于 2 个点就不折腾,差距大才值得投入。这套方案值不值得做,取决于你的三元组里多主体共享客体、嵌套实体占比高不高,占比超过 20% 就值得上指针网络。

希望帮到你。

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

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

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

立即咨询