
简介这份资源是面向NLP初学者与进阶开发者的BertCRF三元组识别实战项目聚焦从中文文本中自动抽取主体、谓词、客体构成的结构化知识可用于知识图谱构建、信息抽取等场景。压缩包共11个文件以6个Python脚本为核心涵盖模型定义、数据预处理、训练主流程、数据集划分与预测推理另含3个Markdown说明文档、1个依赖清单和1张示意图整体约37KB结构清晰便于按模块阅读。项目完整呈现了Bert编码与CRF序列标注结合的实现路径涉及Hugging Face Transformers调用、中文预训练权重加载、序列标注标签设计、模型训练评估及预测后处理等关键环节读者可据此理解三元组识别的端到端流程并在此基础上迁移到自有数据或相似抽取任务。目前已有122人学习适合希望掌握深度学习文本抽取技巧的开发者参考。1. BertCRF 做三元组识别为什么单独用 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 后面完全靠模型自己从数据里悟样本一少就悟不出来。BertCRF 这套组合就是冲着这个痛点来的。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 还是 BIOESBIO 够用但 BIOESB/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-expdecode 用 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, dropout0.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_firstTrue) def forward(self, input_ids, attention_mask, labelsNone): outputs self.bert(input_ids, attention_maskattention_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, maskattention_mask.bool()) return loss # 推理Viterbi 解码 return self.crf.decode(emissions, maskattention_mask.bool())逻辑说明emission把每个 token 的 768 维向量映射到标签分数CRF 在此基础上加转移分数。参数说明dropout0.1是常规起点语料小可以调到 0.3 防过拟合mask必须传否则 padding 位置会污染转移路径这是新手最常翻车的地方。3.2 接法对比要不要在 CRF 前加 BiLSTMBERT 本身已经是双向的理论上不需要 BiLSTM。但我在工业设备台账这种长实体、嵌套表达多的语料上试过BERT 后加一层 BiLSTMhidden 256再接 CRFF1 能涨 0.5~1 个点代价是显存多占约 20%、训练慢 30%。选型建议接法适用场景显存训练速度边界 F1BERTLinearCRF通用、语料中等基准基准基准BERTBiLSTMCRF长实体、嵌套多20%-30%0.5~1BERTCRF无 Linear不推荐--掉点明显第三种是直接拿 BERT 输出当发射分数维度对不上必须加 Linear别省这一步。3.3 转移矩阵初始化与约束CRF 的转移矩阵默认随机初始化训练初期会走很多非法路径。可以手动加约束把O - I-XXX、B-XXX - I-YYYXXX≠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. 数据准备与训练从原始标注到能跑的 batch4.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] fS-{role} else: tags[start] fB-{role} for i in range(start 1, end): tags[i] fI-{role} tags[end] fE-{role} return tags # 读取并转换 with open(raw.json, r, encodingutf-8) as f: data json.load(f) labels [l.strip() for l in open(labels.txt, encodingutf-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, encodingutf-8), ensure_asciiFalse)逻辑说明text.find(entity)定位实体重叠实体只保留第一个匹配这是简化处理生产环境要用偏移量。参数说明labels从labels.txt读保证 id 一致ensure_asciiFalse保证中文不转义。4.2 训练参数学习率、batch、epoch 怎么设BERTCRF 微调的参数和纯分类任务不同CRF 层对学习率更敏感。我一般用分层学习率BERT 主体 2e-5CRF 和 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_decay0.01) # 线性 warmup总步数 epoch * steps_per_epoch total_steps 10 * len(train_loader) scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_stepsint(0.1 * total_steps), num_training_stepstotal_steps)逻辑说明BERT 预训练权重已经很好大学习率会破坏CRF 是随机初始化的需要大学习率快速收敛。参数说明weight_decay0.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 状态机抽 spanprev_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, hidden768): 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_spanNone): out self.bert(input_ids, attention_maskattention_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:e1, :].mean(dim1, keepdimTrue).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% 就值得上指针网络。希望帮到你。本文还有配套的精品资源点击获取