ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Bert+CRF中文三元组识别:从序列标注到知识抽取的工程实践

Bert+CRF中文三元组识别:从序列标注到知识抽取的工程实践 简介这是一份基于Python的NLP实战资源聚焦使用BertCRF模型完成三元组识别适用于想实现知识图谱关系抽取、或学习序列标注任务的学习者与开发者。压缩包共11个文件以6个py脚本为核心覆盖model.py、utils.py、main.py、predict.py、split_data.py、config.py等完整流程对应模型定义、数据处理、配置管理、数据切分、训练与预测等环节另有3个md文档、1个txt依赖清单和1张示意图整体仅37KB轻量易读。已有122人学习下载。通过该项目可掌握Bert预训练模型与CRF层结合的建模思路理解主体、谓词、客体标注标签如S/P/O的构造方式并实践中文文本清洗、分词、编码等预处理流程同时熟悉基于PyTorch/TensorFlow的模型训练、评估与推理代码组织方式适合作为NLP关系抽取方向的项目参考。项目整体结构清晰README文档提供了环境配置与使用说明适合快速上手复现也可作为课程设计或NLP入门进阶素材。1. 为什么是 BertCRF 做三元组识别从序列标注到知识抽取的最短路径三元组识别Subject-Predicate-Object是知识图谱构建里绕不开的一步BertCRF 这条技术路线是把「语义理解」和「标签依赖」结合得最稳的经典组合。这个压缩包不是单个脚本而是一整套能直接跑通的中文三元组识别工程数据切分、序列编码、Bert 微调、CRF 解码、预测后处理六个 Python 文件把完整流程全包了。我拆过不少抽取方案纯规则太脆纯 Bert 加 softmax 在长句上会翻车BertCRF 是性价比最高的落地点。这份资源适合两类人想快速落地三元组抽取、拿自己数据复跑的 NLP 工程师以及想搞懂序列标注输出层怎么接 CRF 的学习者。2. 项目结构与数据管线split_data.py、utils.py、config.py 怎么串起来的拿到压缩包先别急着跑 main.py执行顺序错了后面全是排查时间。这个工程把数据准备和模型训练拆得很开首次使用的人最容易栽在数据格式上。2.1 目录功能对照与启动顺序先看整体结构每个文件在管线里的位置很清晰文件职责执行时机README.md安装说明、使用流程第一步读split_data.py原始数据切分训练/验证/测试第二步utils.py序列编码、标签构建、批次 padding被 main.py 调用config.py路径、模型名、超参数集中管理被各脚本 importmodel.pyBert CRF 模型定义被 main.py / predict.py 调用main.py训练入口第三步predict.py加载权重做推理最后bert-base-chinese/预训练权重与词表加载时读取data/原始语料与切分结果被 split_data.py 读写requests.txt 在这个包里其实是依赖清单如果里面的包没装齐main.py 会在 import 阶段直接报错。建议先按清单建一个独立 conda 环境再跑数据切分。整个管线是单向的split_data 产出数据集utils 把原始文本变成 input_ids 和标签序列model 定义网络main 启动训练predict 做最终抽取。img 目录放的是效果图或结构图不影响运行。2.2 三元组如何落到 token 级别的标注三元组「马云 是 阿里巴巴的创始人」在 token 层面没法直接抽要拆成序列标注问题。工程用的是 BIOES 方案每个 token 归到 B实体开始、I实体内部、E实体结尾、S单字实体、O无关成分之一角色维度再拆成 SUBJECT、PREDICATE、OBJECT 三类组合出具体标签。标签含义示例「马云是阿里巴巴的创始人」B-SUBJECT主体开始马E-SUBJECT主体结尾云B-PREDICATE谓词开始是B-OBJECT客体开始阿I-OBJECT客体内部里 / 巴O无关成分的注意这里的谓词是关系名不是语法上的动词。同样是「是」在「马云是阿里巴巴的创始人」里它是谓词在「苹果是红色的」里它也是谓词但语义完全不同。三元组抽取关心的是实体间的关系不是句法成分这个区别决定后处理不能按词性去猜必须按标注角色走。data 目录下的原始数据建议统一成两列格式句子文本和标签序列用制表符分隔标签之间用空格分隔。如果从别的标注工具导出的是 JSON要先转成这个格式再跑 split_data.py。2.3 split_data.py 与 utils.py切分和编码的落地代码split_data.py 的核心逻辑很简单def split_dataset(file_path, train_ratio0.8, dev_ratio0.1, seed42): with open(file_path, r, encodingutf-8) as f: lines [line.strip() for line in f if line.strip()] # 固定随机种子保证每次切分结果一致这是可复现实验的前提 random.seed(seed) random.shuffle(lines) train_end int(len(lines) * train_ratio) dev_end train_end int(len(lines) * dev_ratio) return (lines[:train_end], lines[train_end:dev_end], lines[dev_end:])这段逻辑不复杂关键是seed42固定了 shuffle 顺序。NLP 实验不固定种子两次训练结果对不上调参就成了玄学。train_ratio 和 dev_ratio 是这里唯二要动的参数数据量不足一万条时把训练比例提到 0.85验证集只要保证每个标签类别都出现过就行不用贪大。utils.py 里最关键的是标签序列编码它直接决定模型能不能学到正确的东西def encode_sentence(tokenizer, text, label_ids, max_len128): # 中文 Bert 直接用 tokenizer 切字不需要额外分词 tokens tokenizer.tokenize(text) if len(tokens) max_len - 2: # 预留 [CLS] 和 [SEP] 两个位置截断时标签序列要同步截 tokens tokens[:max_len - 2] label_ids label_ids[:max_len - 2] # 首尾加特殊标记对应标签用 0O对齐 ids tokenizer.convert_tokens_to_ids([[CLS]] tokens [[SEP]]) label_ids [0] label_ids [0] attention_mask [1] * len(ids) # 不足部分 pad标签补 -1 表示「此处不参与损失计算」 ids [0] * (max_len - len(ids)) label_ids [-1] * (max_len - len(label_ids)) attention_mask [0] * (max_len - len(attention_mask)) return ids, label_ids, attention_mask两个细节值得展开。第一padding 位置的标签填 -1 而不是 O因为 CRF 计算损失时用 attention_mask 把 padding 位置遮掉填 -1 是给自己一个明确的「此处不算」标记填 0 的话模型会强行学预测 [PAD] 为 O白白浪费容量。第二max_len 是全局一致的config.py 里改了这个值utils.py 这边必须同步不然 batch 内维度对不齐报错位置在 DataLoader 的 collate 阶段排查起来很绕。固定长度 padding 会浪费计算量短句也被塞到 128数据量大、训练慢的时候可以改成 batch 内动态 padding速度能提升 30% 左右。这个工程为了简单用固定长度128 在显存和效率之间是均衡点。3. 模型搭建model.py 里 Bert 输出如何喂给 CRF 层3.1 为什么是 Bert CRF而不是 Bert softmax如果只是做命名实体识别Bert 加一层 softmax 也能出结果。但三元组识别对标签转移有硬约束I 标签前面必须是同类实体B-OBJECT 后面不可能直接跟 I-SUBJECT。softmax 是逐 token 独立预测天然不感知这类转移关系长句上经常出现标签跳变——主体还没结束突然跳到客体。CRF 层学习的是一个标签转移矩阵把「上一个标签到当前标签」的转移概率显式建模出来解码时用维特比找全局最优路径。说白了CRF 是在帮模型守规矩这也是三元组识别这类强结构化任务普遍选它的原因。3.2 model.py 核心代码与参数说明model.py 的核心结构可以简化成下面这段class BertCrfForTriple(nn.Module): def __init__(self, num_labels, bert_dirbert-base-chinese): super().__init__() self.bert BertModel.from_pretrained(bert_dir) self.dropout nn.Dropout(0.1) self.classifier nn.Linear(self.bert.config.hidden_size, num_labels) self.crf CRF(num_labels, batch_firstTrue) def forward(self, input_ids, attention_mask, labelsNone): # Bert 编码得到上下文表示 outputs self.bert(input_idsinput_ids, attention_maskattention_mask) seq_out self.dropout(outputs.last_hidden_state) # 线性层把隐藏状态映射到标签空间 logits self.classifier(seq_out) # (batch, seq_len, num_labels) if labels is not None: # 训练阶段CRF 负对数似然作为损失 loss -self.crf(logits, labels, maskattention_mask.bool()) return loss # 推理阶段维特比解码出全局最优标签路径 return self.crf.decode(logits, maskattention_mask.bool())几个参数要单独拎出来说。num_labels 是标签总数BIOES 四态乘三类角色是 12 个再加 O 一共 13 个具体数值以 config.py 为准。改数据格式时这个数必须同步改漏了会在 forward 的 Linear 层直接报维度不匹配。bert_dir 指向压缩包里的 bert-base-chinese 目录里面必须有 pytorch_model.bin、config.json、vocab.txt 三个文件缺一个 from_pretrained 就报错。如果是自己下载的 bert 预训练参数包先核对目录结构再放进来我见过不少权重文件散在根目录导致加载失败的例子。batch_firstTrue 必须和训练数据的维度约定一致。CRF 层默认按 (seq_len, batch, num_labels) 组织不设这个参数模型内部会做转置一旦和 DataLoader 的输出对不上报错信息藏在损失计算里面第一眼看过去很难定位。attention_mask.bool() 在这里既是 padding mask 又是 CRF 合法位置 mask一个参数两重作用漏传的话 padding 位置的非法转移也会被算进路径F1 会掉两三个点。提示from_pretrained 会读取 bert-base-chinese 目录下的 config.json。如果 config.json 里的 hidden_size 和权重文件不一致加载时可能不报错但模型输出维度全错。核对下载的 bert 参数包时三个文件必须来自同一版本。3.3 CRF 损失与维特比解码到底在算什么CRF 的损失是负对数似然。给定一个候选标签序列它的分数等于所有位置的发射分数之和加上相邻标签的转移分数之和。模型对所有可能的路径做 softmax 归一化目标就是让真实标签路径的分数最高。发射分数来自 Bert 加线性层转移分数来自 CRF 的转移矩阵两者相加就是路径分数。值得强调的是训练时只对真实路径计算 loss不逐条对比负路径——softmax 归一化本身已经隐式压低了其他路径这也是 CRF 比逐点 softmax 更稳的原因它学的是路径级的差异性。推理阶段的 crf.decode 内部跑维特比算法复杂度是 O(seq_len × num_labels²)对 128 的序列长度、十几类标签计算量可以忽略不会成为推理瓶颈。整个前向过程可以这样理解Bert 输出每个位置每个标签的发射分数CRF 在这个分数上叠加转移约束做全局校正。它不是独立模型而是 Bert 之上的约束层这也是 model.py 里全部的计算逻辑。4. 训练与预测main.py 的参数配置、损失计算与 predict.py 的后处理4.1 main.py 训练循环与关键参数main.py 是训练入口。第一次跑别直接全量训练先用几百条数据做冒烟测试确认 loss 在下降、维度没报错再放开全量。config.py 里的关键参数如下# config.py 关键参数 learning_rate 3e-5 # Bert 微调常用区间太大会灾难性遗忘 batch_size 16 # 显存不够就降到 8 max_len 128 # 和 utils.py 保持一致 epochs 10 warmup_ratio 0.1 # 前 10% 步数线性升温 use_crf True # 设 False 退化成纯 softmax 基线 model_dir checkpoints/ # 权重保存目录训练循环是标准的 PyTorch 流程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) # model 内部已经算好 CRF 负对数似然损失 loss model(input_ids, attention_mask, labels) loss.backward() optimizer.step() scheduler.step() optimizer.zero_grad()有几个细节直接决定训练质量。优化器用 transformers 的 AdamW把 Bert 层的权重衰减参数和非权重衰减参数分开设置这是 Hugging Face 微调任务的标准做法。warmup_ratio0.1 表示前十分之一的步数里学习率从 0 线性升到 3e-5能明显减少早期 loss 震荡。保存权重时只存 model.state_dict()同时把 num_labels、max_len 这些配置一并存成 jsonpredict.py 加载时按同一套参数初始化不然推理阶段的维度会和权重对不上。早停也很重要验证集三元组 F1 连续三个 epoch 不涨就停止训练并回滚到历史最优权重很多工程里没有这段逻辑我一般会自己加一个计数器维护 best_f1。注意显存只有 6G 左右时batch_size 从 16 降到 8、max_len 从 128 降到 96训练时间会变长但不会 OOM。别为了省显存把 batch_size 调到 1梯度更新会非常不稳收敛效果明显变差。4.2 predict.py 后处理从标签序列还原三元组模型输出的是标签序列要变成 (subject, predicate, object) 结构化结果predict.py 里要做两件事先把标签按边界拼回实体再按角色组装三元组。def extract_triples(label_list, token_list): entities [] # (start, end, role, text) i 0 while i len(label_list): label label_list[i] if label.startswith(B-): # 实体开始 role label[2:] j i 1 # 连续收集同角色的 I 标签 while j len(label_list) and label_list[j] I- role: j 1 text .join(token_list[i:j]) entities.append((i, j, role, text)) i j # 跳过实体内部避免重复抽取 else: i 1 # 按角色分组后配对 subjects [e for e in entities if e[2] SUBJECT] predicates [e for e in entities if e[2] PREDICATE] objects [e for e in entities if e[2] OBJECT] triples [] for s in subjects: for p in predicates: for o in objects: # 按位置顺序约束排除主语出现在宾语后面的异常组合 if s[0] p[0] o[0]: triples.append((s[3], p[3], o[3])) return triples这个函数有两个容易翻车的点。第一个是配对逻辑s[0] p[0] o[0] 假设主语在谓词前、谓词在宾语前对绝大多数陈述句成立但倒装句、被动句会被误杀。工程上更稳的做法是训练阶段保证标注质量后处理阶段用就近配对——一个谓词同时配多个候选实体时取位置最近的组合。第二个是角色名硬编码一旦 config.py 里改了标签命名这里的 SUBJECT / PREDICATE / OBJECT 字符串要同步改否则过滤之后全是空列表。这类字符串不一致的问题不报错只是结果为空排查起来全靠瞪眼。4.3 评估指标分两档实体级 F1 与三元组级 F1评估时实体级只看三类实体有没有被完整识别边界完全一致才算命中三元组级要求 S、P、O 都匹配且组合关系正确才算一条命中。具体计算时实体级用解码后的实体集合求交集三元组级先转成 (s, p, o) 元组去重再求交集用 set 判重比 list 逐条比对快一个量级。三元组级 F1 通常比实体级低 10 到 15 个点这是正常现象。如果看到实体 F1 很高而三元组 F1 很低说明问题基本出在配对逻辑而不是模型去查 predict.py 的后处理比调模型参数更有效。这也是这份工程把评估拆成两步的原因先把实体抽取的底子打牢再单独优化配对策略。5. 避坑记录三元组识别项目里最常踩的五个问题下面五条都是复跑这个工程时的高频坑按「现象、原因、解决」列每一条都对应真实的调参血泪经验。排错顺序有个基本原则先排数据再排代码最后才动模型结构。5.1 loss 降下去了预测结果全是 O现象训练 loss 正常下降但验证集 F1 是零模型对任何句子都输出全 O。原因标签严重不平衡。三元组实体在句子里占比本来就低O 标签占绝对多数。CRF 的转移矩阵在训练初期倾向于走全 O 路径因为这条路径没有任何惩罚模型容易陷在这个局部最优里。解决先统计训练数据里非 O 标签比例低于 5% 说明是数据问题优先扩充而非调参也可以在损失函数里按类别加权调高实体类别的权重。CRF 场景下要多给几个 epoch让转移矩阵慢慢学到非 O 转移的收益。还有一个排查办法抽 50 条样本把标签手动全改成非 O看 loss 是否有明显变化如果几乎不变说明模型根本没在学实体识别。5.2 [CLS] 和 [SEP] 被预测成实体现象解码结果里句子开头或结尾出现一个只有 1 个字长度的实体内容往往是 [CLS] 或 [SEP]。原因encode_sentence 里标签对齐出了问题。如果 label_ids 没有在首尾补 0Bert 会在这两个特殊 token 位置学到错误的标签分布CRF 解码时也会把这些位置纳入路径。解决检查编码函数里是否正确地在 label_ids 前面和后面各补了 0。更保险的做法是在 extract_triples 里显式跳过位置 0 和最后一个位置双重防护。这个坑最恶心的地方是不报错只有看解码结果才能发现。另外 decode 返回的是标签索引转回字符串时如果 label2id 映射错位也会出现 [CLS] 位置被解析成实体的假象两个问题要分开排查。5.3 单字实体永远抽不出来现象语料里有「苏」「京」这类单字名字模型一个都识别不出来两字以上的实体一切正常。原因BIOES 方案里单字实体应该标 S-ROLE但很多标注工具只有 B/I 两态把单字实体标成了 B 后面没有 I。CRF 学到「B 后面必须跟同角色 I」的强转移单字实体的路径分数被转移矩阵压得很低直接丢弃。解决先检查训练数据里单字实体的标注是否用了 S 标签。如果上游数据确实只有 B/I 两态后处理阶段可以允许孤立的 B-ROLE 单独成实体但这是治标不治本——训练阶段 CRF 依然不认这个路径。正确做法是把数据里的单字实体统一重标为 S 再训练。单字实体在中文场景特别扎眼因为中文名字一个字的情况并不罕见这也是中文序列标注普遍推荐 BIOES 而不是 BIO 的原因。5.4 生僻字变成 [UNK]整句三元组全丢现象句子只要出现生僻字或特殊符号整句一个三元组都抽不出来相同句式不含生僻字时完全正常。原因中文 Bert 词表覆盖不了低频字tokenizer 把它们换成了 [UNK]。这个位置的输出向量基本没有语义如果它恰好在实体中间实体边界就断了。另一个隐藏因素是截断句子超过 max_len 时如果在实体中间被硬切后半段实体也没了。解决预处理阶段做两件事。一是全角转半角、统一引号和括号能把特殊字符替换掉的尽量替换二是截断时不要硬切优先在标点或空格处断开给实体留出完整空间。扫一遍数据统计 [UNK] 比例超过 1% 就必须处理别指望模型自己学回来。另一个容易漏的来源是繁体字业务数据里有繁体文本时最好在预处理阶段统一转简体。5.5 验证集 F1 正常换一批新数据就崩现象验证集上 F1 有 80 多把模型接到新场景的数据上实体召回直接掉到 40 以下。原因训练集和真实分布不一致。三元组抽取对实体泛化极其敏感模型对训练里见过的实体能记住对没见过的实体基本靠猜这是实体抽取公认的黑匣子问题纯模型层面很难根治。解决把新场景的高频实体人工标注一批增量加进训练数据。最省力的路径是用现有权重做继续训练只喂新实体所在的句子跑三五轮就能把实体召回拉回来。上线前用验证集做一次错误聚类把 top 5 的错误样本打印出来通常能发现标注不一致的问题比如同一实体在不同句子里边界标注不同这比调参有用得多。务实一点还可以在 predict.py 里加置信度过滤实体分数低于阈值的直接丢弃宁缺毋滥因为三元组错了比没抽到更伤下游。6. 进阶用法用已训练权重做领域迁移与结果校验跑通一遍只是开始这份工程真正的价值在于迁移。config.py 里的 model_dir 指到新权重predict.py 就能直接换模型推理。但 Bert 微调权重是领域敏感的金融语料训出来的模型拿去做医疗三元组效果直接对折。做领域迁移我一般先冻结 Bert只训 CRF 和分类头。操作上就是把 model.py 里 Bert 参数的 requires_grad 设为 False让新领域数据先纠正标签转移和分类边界跑两三个 epoch 后再解冻全部参数学习率降到 1e-5 左右继续微调。这样既省显存也不会因为新领域数据量少把预训练语义冲掉。批量小、类别不均衡的时候这个「先冷后热」的两段式训练比直接全量微调稳得多。结果校验有一个很实用的回填技巧把抽出来的三元组 (s, p, o) 按模板拼回句子和原始句子做一致性检查如果拼出来的句子在原始文本里找不到对应片段这条三元组大概率是配对错误。更简单的是导出 CSV 抽样 100 条统计三类错误的比例实体边界错、实体识别对但配对错、标签角色错。哪个占比高下一步就优先动哪一块比盲调参数有效得多。从那以后我每次复跑这类序列标注工程都强制走一遍「小数据冒烟、单实体校验、三元组回填」的流程确认链路每一步都在正确学习而不是在碰运气。这套资源的 model.py 和 predict.py 边界留得很干净很适合拿来做这样的工程化验证希望帮到你。本文还有配套的精品资源点击获取
返回列表