简介这是一份基于 Python 的 NLP 实战项目资源面向希望掌握知识图谱三元组抽取的中高级开发者。项目使用 Bert 预训练模型结合 CRF 条件随机场完成序列标注覆盖数据处理、模型构建、训练评估与预测的完整流程适合学习命名实体识别与关系抽取的进阶练习。资源包共 11 个文件以 Python 脚本为主包含模型定义、主程序、配置、数据切分与预测脚本并附有 README 说明文档、需求列表及 bert-base-chinese 预训练权重整体大小约 37KB结构紧凑便于直接对照学习。已有 122 人学习使用该资源。通过动手阅读源码和运行脚本读者可以直观理解 BertCRF 的模型组合方式、序列标注标签设计以及中文三元组抽取的实现细节是一份兼具教学意义与工程参考价值的 NLP 入门到进阶资料。1. 先从“11-BertCRF 三元组识别.zip”这个包说起它到底能帮你省下什么如果你的日常任务里有一类特别磨人的需求从企业公告、合同或者技术文档里抽取结构化知识比如“公司-股东-持股比例”“药物-适应症-不良反应”那你一定见过或者听说过类似“11-BertCRF 三元组识别.zip”的文件。这个压缩包不是数据表格或论文而是一套基于 Bert 预训练模型和 CRF 条件随机场的三元组抽取工程输入一段非结构化文本输出以(主体, 关系, 客体)为基本单元的数据记录。和纯规则抽取相比它能覆盖更多句式和从零搭建相比现成的 zip 包让你把力气花在数据清洗和调参上。它适合刚接触信息抽取的算法工程师也适合需要把长尾关系落到生产环境的 NLP 开发。2. 从BIO标签到BERT的token对齐三元组识别的最小数据管线2.1 三元组抽取被拆成序列标注为什么需要CRF而不是Softmax三元组识别的常见做法有两种管道式和联合式。标题里的“三元组识别”我理解成联合式不是先把实体找出来再单独判断关系而是在一个模型里同时输出“头实体-关系-尾实体”。具体做法是把句子里的每个 token 打上标签标签里同时包含关系类型和实体角色。比如对关系“任职”标签可以是“B-任职-H”任职关系的头实体开始、I-任职-H、B-任职-T、I-任职-T再加一个 O 表示无关。这种做法的优点是一次前向就能拿到三元组误差不会在管道里二次放大缺点是同一个实体出现在多个三元组里时标签只能给一个所以一般要求每个 token 在同一个样本里只承担一种角色。对于大多数垂直领域这个假设能站住脚也是很多 zip 包愿意这么做的主要原因。那为什么是 CRF 而不是 BERT 后面接个 SoftmaxBERT 输出的每个位置的 logits 是独立的Softmax 没有建模标签之间的转移。比如一个句子里同时出现“张三”“腾讯”“技术总监”如果不加 CRF模型可能把“B-任职-H”后面直接跟“I-任职-T”这在标签文法上是不允许的因为头实体还没结束就跳到尾实体。CRF 层在解码时用转移矩阵计算全局路径分数能强制“头实体内部的 I 标签不能跳转到尾实体的 I 标签”这类约束。这也是项目标题里同时出现 BERT 和 CRF 的原因BERT 负责语义CRF 负责标签级约束。对于序列标注类的关系抽取CRF 几乎是默认选择指针网络是另一种方案适合嵌套三元组但复杂度和训练成本都会上一个台阶不是每份工程都愿意踩这个坑。2.2 把文本和SPO转成BERT输入字符标签对齐代码数据处理顺序通常是准备文本和三元组列表生成字符级标签再用 BERT tokenizer 编码。下面是这段管线的核心代码我一般把它放在一个叫data_processor.py的文件里。from transformers import AutoTokenizer def find_span(chars, target): # 朴素匹配目标串在字符列表中的所有出现位置 res [] for i in range(len(chars) - len(target) 1): if chars[i:i len(target)] list(target): res.append((i, i len(target))) return res def encode_one(text, spo_list, tokenizer, tag2id): # spo_list: [(head, relation, tail), ...] chars list(text) labels [O] * len(chars) for head, rel, tail in spo_list: for s, e in find_span(chars, head): labels[s] fB-{rel}-H for i in range(s 1, e): labels[i] fI-{rel}-H for s, e in find_span(chars, tail): labels[s] fB-{rel}-T for i in range(s 1, e): labels[i] fI-{rel}-T input_ids, attention_mask, label_ids [], [], [] for ch, lab in zip(chars, labels): sub tokenizer.tokenize(ch) if not sub: # 某些字符可能被 tokenizer 忽略跳过会导致错位这里要留意 continue input_ids.extend(tokenizer.convert_tokens_to_ids(sub)) label_ids.extend([tag2id[lab]] * len(sub)) attention_mask.extend([1] * len(sub)) return { input_ids: input_ids, attention_mask: attention_mask, label_ids: label_ids, }这段代码完成了文本到 BERT 特征、字符标签到标签 id 的转换。字符级标签是二维的模型需要每个 token 对应一个 label id。如果 BERT 按 subword 切词一个“字”可能被切成多个 token这时 label 必须复制len(sub)份否则模型看到同一字的所有碎片会给出不一致的预测。这里的tag2id需要由你在关系集合确定后统一构建spo_list里的 head 和 tail 必须完全命中文本否则find_span找不到标签就会缺失。这也是很多包在实际数据上表现不佳的第一个原因因为标注工具导出的 span 偶尔和原文差一个空格或全角半角朴素匹配就直接漏掉了。提示如果训练时发现 loss 异常先打印一个样本确认input_ids和label_ids的长度完全一致。不一致时不要硬训先把数据清洗再做对齐。2.3 标签策略的取舍BIO、BILOU和“关系角色”标签选哪个纯 BIO 标签用于实体识别但三元组识别需要把关系类型也放进标签形成“B-关系-角色”的组合标签。以“任职”关系为例BIO 需要四类标签B-任职-H、I-任职-H、B-任职-T、I-任职-T加上 O一个关系就是 5 类。如果换成 BILOU还要多出 L 和 U 两种标签一个关系变成 9 类标签空间直接膨胀。BILOU 解决的是长实体边界问题L 标记实体最后一个 tokenU 标记单 token 实体。在三元组任务里实体边界错误会直接导致头尾实体配对失败所以 BILOU 在理论上有优势。但中文项目里很多 zip 包为了简化数据标注仍然用 BIO。我的建议是如果你只需要二三十种关系且实体普遍不长用 BIO 完全够如果实体经常超过四个字或者你发现验证集里实体边界频繁差一位可以考虑切到 BILOU。标签空间还影响模型训练效率。比如 20 个关系BIO 标签数是 1 20×4 81CRF 的 Viterbi 解码复杂度是 O(L×T²)T 是标签数。当 T 超过 100decode 耗时和显存占用都会明显上升。这时候我会退化成“实体识别 关系分类”两阶段BERTCRF 只做纯实体抽取关系分类换成一个额外的分类头或另一个轻量模型。这样做虽然损失了一部分联合抽取的端到端优势但工程上更容易控制和调优。3. 用PyTorch把BertCRF搭起来模型结构与你需要改的三个地方3.1 用BertModel CRF组装联合模型先贴一段能跑的类定义很多项目包里的模型实现是直接依赖torchcrf这个库的我建议你先跑通再考虑手写。下面是典型的联合模型定义。import torch import torch.nn as nn from transformers import BertModel, AutoConfig from torchcrf import CRF class BertCRF(nn.Module): def __init__(self, model_name, num_tags): super().__init__() config AutoConfig.from_pretrained(model_name) self.bert BertModel.from_pretrained(model_name, configconfig) self.dropout nn.Dropout(0.1) self.fc nn.Linear(config.hidden_size, num_tags) self.crf CRF(num_tags) def forward(self, input_ids, attention_mask, labelsNone): outputs self.bert(input_ids, attention_maskattention_mask) emissions self.fc(self.dropout(outputs.last_hidden_state)) mask attention_mask.bool() if labels is not None: return self.crf(emissions, labels, maskmask) return self.crf.decode(emissions, maskmask)这段代码把 BERT 的last_hidden_state经过一个线性层映射到标签数量的发射分数然后交给 CRF 计算。attention_mask必须传给 CRF因为 padding 位置不能参与标签规划否则模型会把 PAD 也识别成实体。labels传入时返回 loss训练用不传时返回解码后的标签 id 序列推理用。参数说明model_name是预训练模型路径中文场景建议用bert-base-chinese如果在内网环境就指向本地下载好的模型目录。num_tags必须和你前面构建的tag2id一致CRF 初始化会申请num_tags × num_tags的转移矩阵数目不对会直接报维度错误。dropout0.1是经验值数据量小可以提高到 0.2数据量很大且防止过拟合不明显时降到 0.05 也行。3.2 修改项目包必改的三个点标签数、预训练模型路径、解码mask拿到别人给的 zip 包第一件事不是跑训练而是改三个地方。第一个是标签数。假如你的关系集合是“任职”“毕业”“持股”用 BIO 角色标签生成方式如下。relations [任职, 毕业, 持股] tags [O] for rel in relations: for role in [H, T]: tags.append(fB-{rel}-{role}) tags.append(fI-{rel}-{role}) tag2id {tag: i for i, tag in enumerate(tags)} num_tags len(tags) print(tags) print(num_tags)这段代码会输出 1 3×4 13 个标签。千万不能直接沿用包里默认的num_tags否则后面的解码结果很难对应到你自己的关系集合。第二个是预训练模型路径。如果 zip 包里写的是bert-base-uncased对中文文本几乎无效要改成中文模型。第三个是解码 mask。上面的forward在 decode 时已经传了 mask但有些 CRF 库需要你自己在解码后处理 padding 位置这和训练时的 label padding 是两回事后面避坑章节会再强调。3.3 冻结BERT和只训练CRF的实用场景刚开始搭工程时我习惯先冻结 BERT只训练线性层和 CRF。这样做有两个好处一是训练速度快可以先验证数据处理和标签体系有没有问题二是标注数据少时不容易把 BERT 学坏。实现方式很简单for p in model.bert.parameters(): p.requires_grad False optimizer torch.optim.AdamW( filter(lambda p: p.requires_grad, model.parameters()), lr1e-3 )当你的标注数据小于 5000 条或者只是想在小样本上验证 pipeline建议先跑这个冻结版本。如果 F1 明显低再放开 BERT 做全量微调学习率要降到 2e-5 左右。全量微调时BERT 部分会占掉绝大多数显存CRF 的标签转移矩阵参数不大不要为了省显存把max_len压到 64 以内那样长尾实体会牺牲很多。4. 训练与验证学习率、batch、标签权重怎么调才让BERT不白训4.1 训练循环、warmup和梯度裁剪一段可以直接照抄的PyTorch代码训练循环本身不难难的是参数配合。常用的配置是AdamW、线性 warmup、梯度裁剪 1.0。下面是一段可以直接放进train.py的骨架。from transformers import AdamW, get_linear_schedule_with_warmup import torch model BertCRF(bert-base-chinese, len(tag2id)).cuda() optimizer AdamW(model.parameters(), lr2e-5) total_steps len(train_loader) * epochs scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_stepsint(total_steps * 0.1), num_training_stepstotal_steps ) for epoch in range(epochs): model.train() for batch in train_loader: input_ids batch[input_ids].cuda() attention_mask batch[attention_mask].cuda() label_ids batch[label_ids].cuda() loss model(input_ids, attention_mask, label_ids) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() scheduler.step() optimizer.zero_grad()这里的loss是 CRF 的负对数似然它已经包含了发射分数和转移分数。学习率2e-5是 BERT 全量微调的标准值如果像 3.3 那样冻结了 BERT线性层和 CRF 的学习率可以调到1e-3否则收敛太慢。warmup 比例 0.1 是中文 NLP 任务里比较稳的经验值它让模型在前 10% 的 step 慢慢把学习率从 0 升到目标值避免开头大步走把预训练权重冲坏。梯度裁剪 1.0 主要防的是长文本或标签极端情况下出现 loss 尖峰。4.2 验证指标不要只用token accuracy三元组全匹配和实体边界很多第一次做三元组识别的人只看 token accuracy结果 97% 的准确率骗了自己。原因是 O 标签通常占 70% 以上模型全部预测 O 也能拿到高分但三元组一个都抽不出来。验证指标必须用三元组级别的 precision、recall、F1。下面是一个评估函数的伪代码框架def evaluate(model, val_loader, tokenizer, id2tag): model.eval() pred_triples, gold_triples [], [] for batch in val_loader: with torch.no_grad(): paths model(batch[input_ids].cuda(), batch[attention_mask].cuda()) for path, input_ids, label_ids in zip( paths, batch[input_ids], batch[label_ids] ): tokens tokenizer.convert_ids_to_tokens(input_ids) pred_seq [id2tag[p] for p in path] gold_seq [id2tag[l] for l in label_ids if l 0] pred_triples.append(decode_to_spo(tokens, pred_seq)) gold_triples.append(decode_to_spo(tokens, gold_seq)) # 用集合计算 p/r/f1decode_to_spo 在最后一章给出 return calc_f1(pred_triples, gold_triples)注意这里label_ids在 padding 位置不应该参与解码所以只取l 0的部分这也意味着 collate 的时候要把 padding 的标签设为-100。三元组全匹配要求 head、relation、tail 三者完全一致才算对实体边界差一个 token 就会让一个三元组从正确变成错误所以你会看到 token accuracy 和三元组 F1 之间的差距有时超过 30 个百分点这很正常。4.3 batch_size、max_len和CRF标签空间的关系显存占用主要来自 BERTCRF 增加的计算量是 O(L×T²)所以标签数越多batch 越不能开太大。我通常以 max_len128 为基准关系数 5 个左右时 batch 开到 32 或 48 没问题关系数到 20标签数 81batch 降到 16关系数超过 50标签数 201batch 可能只能开到 8。如果你的机器显存只有 8G可以参考下面这组经验值关系数标签数(BIO)batch_sizemax_len12852132208116502018如果显存不够但又想保持 batch 大可以用梯度累积等价于每隔几步做一次optimizer.step()。梯度累积不会减少单步计算量但能稳定训练。另一个参数是 max_len。中文文本切到 128 已经能覆盖多数句子但合同和公告类长文本经常超过 200 字。此时不要盲目把 max_len 拉到 512而是先做句子切分再用滑窗。CRF 的标签转移矩阵不受长度影响但解码时间会线性增长。到这里一个 bert 模型实操的完整闭环才算走通数据、模型、训练、验证。接下来的坑大多是搭建时想不到、上线后才知道的。5. 三元组识别踩坑排查标签错位、关系漏抽和部署后的玄学问题5.1 标签错位预测全O、关系名错乱先检查 tokenizer 和标签复制现象一训练 loss 正常下降验证时预测结果全是 O一个三元组都抽不出来。原因大概率出在数据预处理阶段直接用tokenizer(text, truncationTrue)一次性编码原始字符标签和 token 序号对不上。比如文本里有英文字母或空格一个字符被切成多个 subword标签没有同步复制最终label_ids全部错位到 O。解决方法是回到 2.2 的编码函数并强制打印一个样本的 token 和标签对照表肉眼确认“中”这个字对应的标签不是 O。现象二实体边界抽对了但关系名张冠李戴。比如数据里既出现“毕业于”又出现“毕业院校”在标注时被写成了两种关系标签空间里就有“B-毕业-H”和“B-毕业院校-H”。模型根本分不清这两个是同一关系最后预测结果就乱套。解决方法是把关系 schema 收拢成一个不变量集合所有原始 mention 都映射到这个集合里。relation_map { 任职: 任职, 担任: 任职, 毕业院校: 毕业, 毕业于: 毕业, 持股比例: 持股, 持有: 持股, } # 构造 spo_list 时把 relation 统一用 relation_map[rel] 替换这条规则看起来简单但我在实际项目里不止一次被它坑过。标注同学按自己的理解写关系名模型按字面学习最终线上预测总是出现同义关系互相打架。统一关系映射要做在数据清洗阶段而不要放在后处理阶段否则训练时模型根本没有见过统一后的分布。5.2 部署后的性能落差padding mask、长文本截断与CUDA端玄学现象一验证集 F1 不错部署到线上用单条文本过模型结果和本地对不上偶尔还会抽出一堆乱码实体。原因多半是 CRF decode 时把 padding 位置也当成了有效 token。训练时用的label_ids在 padding 位置是-100但torchcrf.decode返回的路径是完整长度里面的 padding 位置会被模型赋予某个标签而这个标签在后续三元组解析时被当成真实实体。解决方法是 decode 之后强制把attention_mask 0的位置置为 O。paths model(input_ids, attention_mask) for i, path in enumerate(paths): mask attention_mask[i] corrected [ p if m else tag2id[O] for p, m in zip(path, mask.tolist()) ] pred_seq.append([id2tag[p] for p in corrected])现象二长文本被截断跨句子三元组漏抽。很多 zip 包默认max_len128但三元组的头实体和尾实体可能隔着很长的修饰成分甚至分布在两个句子里。直接拉长到 512 是一个办法但显存上涨明显。我一般先按句号、分号切句把每个句子单独过模型再合并实体抽取结果。如果实体本身跨句就需要用滑窗滑动范围保留 10% 重叠重叠区域出现重复实体时只保留置信度高的一次。这一步属于后处理逻辑模型本身不感知窗口边界所以不要指望 BERT 能自己处理超长文本。CUDA 端的玄学问题还有一个常见来源换 GPU 之后 decode 结果不一致。原因是 CRF 的 Viterbi 解码在低精度 FP16 下和 FP32 下可能走出不同路径。如果你训练时混合精度了预测时也要保持同样的精度设置。换句话说模型部署时的 dtype、batch size、甚至 padding 策略都要和验证时保持一致否则你排查半天以为是模型坏了其实是推理配置变了。6. 收尾技巧把CRF解码结果拼回SPO三元组的一行式校验6.1 用一段解码函数把标签序列还原成三元组列表训练时你盯着 loss 曲线很难感受到三元组抽得怎么样。我习惯在每个关系集合改动后先跑 100 条小样本把标签序列还原成三元组列表打印到日志里肉眼扫一遍再上训练。下面这段解码函数是我常用的快速校验工具。def decode_to_spo(tokens, tag_seq): # tokens: tokenizer 处理后的 token 列表 # tag_seq: 与 tokens 等长的标签列表如 [O, B-任职-H, I-任职-H, ...] buf {} triples [] def flush(): nonlocal buf for rel in set(k[0] for k in buf): h .join(buf.get((rel, H), [])) t .join(buf.get((rel, T), [])) if h and t: triples.append((h, rel, t)) buf {} for i, (token, tag) in enumerate(zip(tokens, tag_seq)): if tag O: continue rel, role tag[2:].split(-) prev_tag tag_seq[i - 1] if i 0 else O same_span ( prev_tag tag or ( prev_tag.startswith(f{rel}-) and prev_tag.split(-)[-1] role ) ) if not same_span: flush() buf.setdefault((rel, role), []).append(token) flush() return triples这个函数假设标签顺序是同一个关系的头实体先出现、尾实体后出现遇到新的实体片段就清空缓冲把已经凑齐的 H 和 T 组成三元组。它不是万能的比如同一关系在同一个句子里出现多对实体时需要队列式匹配但对于快速校验已经足够。如果用的是英文 BERTtokenizer 会把词切出##前缀记得在 flush 时把##替换掉再拼接。最后说一个我从踩坑里攒下来的习惯每次改动标签体系或数据标注规则都要先跑一遍“文本 → 模型 → decode_to_spo → 人工看打印”的最小验证。因为 CRF 和三元组配对的最大风险不在 loss而在标签语义和解码逻辑之间那层看不见的约定。这个习惯救了我很多次希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?