ARTICLE DETAIL

资讯详情

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

BERT+CRF三元组识别:中文知识图谱关系抽取实战解析

BERT+CRF三元组识别:中文知识图谱关系抽取实战解析 简介这是一份基于BERT与CRF的中文三元组识别实战项目压缩包面向NLP学习者、算法工程师及知识图谱构建相关开发者用于从非结构化文本中抽取“主体-谓词-客体”结构化信息。压缩包共11个文件以6个Python脚本为主涵盖模型定义、数据预处理、参数配置、训练评估与预测等完整环节另有3个Markdown说明文档、1个依赖列表和1张图片整体仅37KB轻量易部署便于快速阅读和二次开发。截至目前已有122人学习下载。资源内置完整的BERTCRF工程实现既包含中文BERT预训练模型目录与说明文档也提供数据划分、预测等模块并覆盖序列标注、上下文建模、优化器选择与评估指标等核心流程。借助该项目可掌握基于Hugging Face Transformers构建三元组识别系统的完整思路也能为定制知识图谱关系抽取任务提供直接可改用的代码骨架。1. BERTCRF 三元组识别解开就能跑的中文“主谓宾”抽取工程BERTCRF 三元组识别做的事就是给一段中文文本抽取结构化的“主体—谓词—客体”。比如输入“阿里巴巴由马云创立于1999年”模型输出阿里巴巴创立者马云。这类能力是知识图谱构建和关系抽取的底座也是很多信息抽取工程绕不过去的环节。这个 zip 把 bert-base-chinese 本地权重、模型定义、训练脚本、预测脚本和数据划分脚本都打包了解压就能从零训练适合正在做知识图谱、事件抽取的从业者也适合第一次跑序列标注实战的人。方案选型很直接BERT 给字符做上下文编码CRF 负责约束标签序列串联后既能吃语言语义又不会出现“主体段落到一半跳成客体”的结构错误。它不是最新生成式方案但数据量中等、标注规范明确时是稳定性最高的一条落地路线。下面按文件拆解、模型训练、预测推理、避坑、调参这条顺序讲实际操作。2. 先把压缩包拆明白文件职责、依赖安装与数据划分2.1 文件清单先分清哪几个目录动不得解开压缩包后的目录结构大致是这样├── model.py # BERTCRF 模型本体 ├── predict.py # 预测入口 ├── main.py # 训练入口 ├── split_data.py # 数据集划分 ├── utils.py # 数据处理与批次构造 ├── config.py # 全局配置 ├── requests.txt # 依赖清单 ├── bert-base-chinese/ # 本地预训练权重 ├── data/ # 原始标注数据 ├── img/ # 结果可视化示例 └── README.md # 文档说明这里需要先分清哪些文件属于“跑通链路”的一部分哪些只是附属产物。img和README.md不影响运行requests.txt是依赖清单而真正决定模型能不能训练起来的是config.py、model.py、utils.py、main.py这四个文件再加上bert-base-chinese权重目录和data标注数据。四个“动不得”的目录分别是 bert-base-chinese、data、model.py、config.py。权重目录和训练数据是外部输入model.py 定义了模型结构乱改会导致断点续训和预测时模型权重 shape 对不上config.py 是全局唯一配置入口后续几乎所有调试都从这里改。2.2 依赖安装requests.txt 别少装也别乱升级requests.txt 就是依赖清单运行前先装pip install -r requests.txt文件里通常包含 torch、transformers、numpy、tqdm、scikit-learn 这几类包。安装顺序上我习惯先装 torch再装 transformers最后装 scikit-learn。原因是 transformers 对 torch 版本有兼容区间后装 transformers 能让 pip 自动解析到匹配的版本。版本选择上torch 1.8 到 2.0 都能跑transformers 建议固定在 4.30 左右。这个版本对中文 BERT 的加载接口最稳定。如果直接装最新的 4.5x很可能遇到BertModel输出对象属性改名、from_pretrained对本地目录多做了安全校验这类问题。报错信息五花八门排查起来浪费时间锁定版本往往比改代码快得多。2.3 split_data.py 的数据划分句子块不能从中间切开data 目录下的标注数据按句子块存放每个 token 一行标签用空格隔开句子之间用空行分隔。一个典型的三元组句子长这样马 O 云 B-SUB 是 O 阿 B-OBJ 里 I-OBJ 巴 I-OBJ 的 I-OBJ 创 I-OBJ 始 I-OBJ 人 I-OBJ这里 B-SUB 表示主体片段开始I-SUB 表示主体片段内部B-OBJ 表示客体开始I-OBJ 表示客体延续。spilt_data.py 的作用就是把全部句子打乱后按 8:1:1 切出 train、dev、test 三份。它的关键动作是先按空行把数据切成句子块列表再打乱而不是按行打乱。# split_data.py 核心逻辑 with open(data/all_data.txt, encodingutf-8) as f: blocks f.read().strip().split(\n\n) random.seed(2024) random.shuffle(blocks) n_train int(len(blocks) * 0.8) n_dev int(len(blocks) * 0.9) for name, part in [ (data/train.txt, blocks[:n_train]), (data/dev.txt, blocks[n_train:n_dev]), (data/test.txt, blocks[n_dev:]) ]: with open(name, w, encodingutf-8) as fp: fp.write(\n\n.join(part) \n)这里的random.seed(2024)是很多人容易漏掉的一行。不固定随机种子每次拆出来的训练集和验证集分布不一样同一套超参数跑两次验证 F1 都不同后期对比实验会很痛苦。数据切完后最好人工抽查 train.txt 的每一段是否完整段内如果少了最后一个 token大概率是清洗数据时字符串被截断不是脚本的问题。2.4 utils.py标签到 ID 的映射与批次构造utils.py 承担两个职责把 token 和标签转成 ID以及构造训练批次。标签转 ID 的规则不复杂但必须和 num_labels 保持一致。# utils.py 中的标签表与映射 LABELS [O, B-SUB, I-SUB, B-PRE, I-PRE, B-OBJ, I-OBJ] label2id {label: i for i, label in enumerate(LABELS)} id2label {i: label for label, i in label2id.items()}这个标签表一共七个值对应 num_labels7。模型输出的类别数不能和它不一致否则最后预测结果还原阶段会直接错乱。我见过有人手写了 label2id 和 id2label 两个字典结果两边顺序没对齐训练正常但解析出来的实体类型张冠李戴。规范做法是只维护一个 LABELS 列表label2id 和 id2label 都由它动态生成杜绝二次维护。批次构造时还需要处理 BERT 的最大长度。config 里 max_len 一般取 128 或 256超过长度的样本要截断。截断策略分两种只保留开头部分的“头截断”以及开头和结尾都保留的“头尾截断”。三元组抽取场景里客体实体经常出现在句子中后段只做头截断会把谓词和客体切掉所以更适合用 tokenizer 的truncationTrue做头尾截断。这个参数值得单独确认一下改错了数据集越到后面越看不出来。以上就是对压缩包结构和数据文件的拆解。核心结论是这份资源的工程边界很清晰权重目录和 data 只要对齐 config就已经扫清了训练前的所有障碍。3. 模型搭建与训练BERT 编码器加 CRF 解码器怎么协同3.1 为什么非要用 BERTCRF独立分类与序列结构约束的差别如果只把每个 token 的标签当成独立分类问题用 BERT 的输出接一个 softmax 就能做。但三元组识别里标签不是独立存在的B-SUB 后面必须跟 I-SUB 或 O不能直接跳成 B-OBJ实体的边界一旦越界整条三元组就废了。CRF 层在这套方案里干的事就是给标签序列加转移约束让模型学会“从主体跳到谓词是允许的但在主体中间跳到谓词不合理”。这个约束是由 CRF 的转移矩阵参数化的。转移矩阵形状为 num_labels × num_labels训练时模型会学到“B-SUB → I-SUB”的概率很高“I-SUB → O”也合理但“I-SUB → B-OBJ”的概率会被压得很低。这就是 BERTCRF 比纯 BERT 分类在实体边界上更稳的原因尤其是训练数据量不大时这种结构性先验能有效抑制标签乱跳。3.2 model.py 的核心结构发射分数与转移分数如何拼接model.py 的结构基本是四层BERT 编码、Dropout、线性映射、CRF 解码。代码骨架如下class BertCrf(nn.Module): def __init__(self, config): super().__init__() self.bert BertModel.from_pretrained(config.bert_path) self.dropout nn.Dropout(config.dropout_rate) self.fc nn.Linear(config.hidden_size, config.num_labels) self.crf CRF(config.num_labels, batch_firstTrue) def forward(self, input_ids, attention_mask, labelsNone): bert_out self.bert(input_idsinput_ids, attention_maskattention_mask) emission self.fc(self.dropout(bert_out.last_hidden_state)) if labels is not None: loss -self.crf(emission, labels, maskattention_mask.bool()) return loss, emission logits self.crf.decode(emission, maskattention_mask.bool()) return logitsself.fc把 BERT 最后一层 768 维向量映射成七个类别的分数这个分数叫发射分数emission score表示“当前 token 是某个标签”的置信度。CRF 层在此基础上叠加标签之间的转移分数训练时通过负对数似然计算整体损失。这里-self.crf(...)前面的负号非常容易看漏一旦漏掉loss 不下降验证 F1 直接归零。batch_firstTrue这个参数也要和输入形状对齐。torch-crf 默认是 batch_firstFalse如果输入是 (batch, seq_len) 而不改默认值CRF 会把 batch 维度当成序列维度训练过程能跑但结果全错。这类隐患最难受的地方是不报错只在评估阶段才暴露。3.3 config.py 超参数哪些数值值得先调config.py 里最关键的一组参数是 max_len、batch_size、learning_rate 和 epochs。参考经验区间如下参数建议范围影响max_len128256过长拖慢训练过短截断实体batch_size832显存不够就减半加梯度累积learning_rate2e-55e-5BERT 微调的主流区间epochs36太多会导致过拟合BERT 微调的 learning_rate 跟普通 CNN 不一样1e-3 这种量级直接崩。我一般先跑 3e-5五轮观察验证集 F1 的变化曲率。如果验证集在第 2 轮就达到峰值后面狂跌把 epochs 降到 3 并加早停如果五轮还没收敛再考虑提高到 5e-5。还有一个细节是 warmupBERT 微调通常需要在前 10% 的步数里让学习率线性上升避免预训练权重在前几个 batch 被冲坏。3.4 训练循环main.py 里的梯度裁剪与学习率调度main.py 的训练循环核心如下optimizer torch.optim.AdamW(model.parameters(), lrconfig.learning_rate) total_steps len(train_loader) * config.epochs scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_stepstotal_steps // 10, num_training_stepstotal_steps ) for epoch in range(config.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) loss, emission model( input_idsinput_ids, attention_maskattention_mask, labelslabels ) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() scheduler.step() optimizer.zero_grad()用 AdamW 而不是 Adam是因为 BERT 微调阶段对 weight decay 更敏感。warmup 步数占总步数 10% 是常见做法让学习率在头几个 batch 里渐进爬升。clip_grad_norm_限制梯度 L2 范数不超过 1.0这行在 BERT 长序列训练里几乎是必须的否则梯度爆炸时 loss 会突然变成 nan而且这种 nan 很难从数据上排查。模型保存时建议只存model.state_dict()不要用torch.save(model)存整个对象。前者换环境反序列化没有兼容问题后者容易在加载时遇到类的 import 路径不一致。配合保存的应该还有 label2id 和 config否则后续预测时类别顺序对不上识别结果就是乱的。4. 预测与三元组组装从标签解码到结构化输出4.1 加载模型权重路径与本地部署的对齐predict.py 开头是加载过程model BertCrf(config) state torch.load(config.output_path, map_locationtorch.device(cpu)) model.load_state_dict(state) model.eval()如果训练时用的 GPU、预测时切到 CPUmap_location不能省。加载完模型后用一段文本走通整条链路text 马云是阿里巴巴的创始人 input_ids tokenizer.encode(text, add_special_tokensTrue) # input_ids [101, 6862, 6405, 3221, ...] 101 是 [CLS]bert-base-chinese 的分词是逐字切分每个汉字基本对应一个 token文本被编码成一组 ID模型给每个 token 位置输出对应的类别分数。在本地部署场景里这一步最怕的是训练时的分词器和预测时不一致。如果训练数据里用的就是 bert-base-chinese 自带的 vocab.txt那tokenizer.encode和训练时的编码就是同源的。4.2 维特比解码为什么不是逐 token 取最大概率模型把 emission 传入 CRF decode 时会执行维特比算法在候选标签路径中找全局最优。它和逐 token argmax 的区别是CRF 的 decode 过程会参考转移矩阵把那些概率低的结构化路径直接排除掉。假设模型对某个 token 输出 B-OBJ 的发射分数很高但如果前面一个 token 被识别成 I-SUB转移矩阵给 I-SUB → B-OBJ 的打分极低维特比最终就可能选择 O 而不是 B-OBJ。这种序列级的全局决策正是 CRF 在三元组识别里比纯分类网络更有优势的地方。解码输出是一串整数标签[0, 1, 1, 0, 4, 5, 5]对照 id2label 后就是O B-SUB I-SUB O B-OBJ I-OBJ I-OBJ4.3 三元组拼接按 BIO 连续段还原主体、谓词和客体拿到标签序列后需要按 BIO 规则把连续的主体、谓词、客体片段各自拼接成完整文本。规则是遇到 B-SUB 开启主体片段后续连续的 I-SUB 拼接到该片段尾部谓词和客体同理。def extract_entities(tokens, labels): entities {SUB: [], PRE: [], OBJ: []} cur_type, cur_text None, [] for token, label in zip(tokens, labels): if label.startswith(B-): if cur_type and cur_text: entities[cur_type].append(.join(cur_text)) cur_type label[2:] cur_text [token] elif label.startswith(I-) and cur_type label[2:]: cur_text.append(token) else: if cur_type and cur_text: entities[cur_type].append(.join(cur_text)) cur_type, cur_text None, [] if cur_type and cur_text: entities[cur_type].append(.join(cur_text)) return entities这段逻辑里最容易被忽略的是拼接时用的到底是词还是字符。中文 BERT 输入是逐字切分tokenizer.tokenize(阿里巴巴)出来的是[阿, 里, 巴, 巴]用join拼接后能还原为“阿里巴巴”。但如果你的数据本来就是按空格分词的拼回去就需要手动去掉空格。主体、谓词、客体的数量不一定相等一句里可能出现两个主体、一个谓词、两个客体。默认做法是按顺序把第 i 个主体和第 i 个客体配对构成三元组。如果业务要求更精细就得引入主体和谓词的关联判断这部分交给下游关系分类去做CRF 只负责给出实体边界。5. 避坑实录BERTCRF 训练里最容易翻车的五个问题5.1 现象标签错位验证 F1 一直在 0.2 附近打转现象训练 loss 正常下降但验证 F1 一直接近 0.2打印预测结果后发现预测标签整体比真实标签偏后或偏前一个位置。原因BERT 的逐字切分和标注数据的分词粒度不一致。最常见的是数据按“汉字 标签”逐行标注但代码直接把 tokenizer 的输出序列当成了标签序列的长度[CLS] 和 [SEP] 两个特殊 token 插进去后标签整体错位。错位一个位置对 loss 的影响不大所以训练曲线看起来正常。解决构建输入时明确区分“原始字符序列”和“token 序列”。如果训练数据按字符标注就先把标签映射到每个 token 上[CLS] 和 [SEP] 的标签用 -100 或单独符号标记计算 loss 时用ignore_index-100屏蔽。跑训练前打印前两条样本的 tokens 和标签人工确认长度一致再继续。5.2 现象CRF 的 loss 符号写反模型训练停滞现象model.py 里 CRF 的 loss 计算处有个负号删掉之后 loss 一直不降验证结果和随机差不多。原因CRF 的 forward 方法返回的是正对数似然越大表示真实标签路径在全部路径中的占比越高所以我们需要取负作为 loss。有些人把 CRF 的实现改成自己写习惯性返回负对数似然前面的负号再加一遍就变成了正 loss训练方向完全反了。解决明确一个约定model forward 返回 loss 时必须是“越小越好”的标量。如果返回的是 log_likelihood调用处一定要加负号如果返回的是负对数似然就不要再加负号。保存代码之前用一条样本单步 forward 打印 loss 值确认它在反向传播后是下降的。5.3 现象改 num_labels 后 CRF 报维度不匹配现象训练时报RuntimeError: Expected size 7 for start tag vector, got 9或者转移矩阵相关报错。原因改了 config 里的 num_labels但 CRF 初始化时仍然用了旧的标签数量。有些代码里 fc 输出维度从 config 读CRF 的 num_tags 却是硬编码的常量两边不一致就会在反向传播阶段报维度错误。解决所有和标签数量相关的组件统一从 config.num_labels 读取包括 fc 的输出维度、CRF 的 num_tags、label2id 的字典长度。改完参数后重新 import 模型不要在一个 Jupyter notebook 里反复热加载旧实例。5.4 现象调大 batch_size 直接 CUDA out of memory现象batch_size 从 16 调到 32启动训练没多久就报CUDA out of memory。原因BERT 是 12 层 Transformermax_len128 时显存占用已经是普通模型的十几倍。翻倍 batch_size 对显存的冲击是线性的再加上 CRF 的转移矩阵梯度显存很容易爆。直接缩减 batch_size 会让训练不稳定BN 类组件对这种变化很敏感。解决batch 不够时不要硬减用梯度累积模拟大 batch。每两个 batch 更新一次参数等价于 batch_size 翻倍显存不涨。实现方式是accumulation_steps 2 loss loss / accumulation_steps # 每个 batch 只累平均 loss.backward() if (step 1) % accumulation_steps 0: optimizer.step() optimizer.zero_grad()需要注意的是除以 accumulation_steps 要在 loss 相加之前完成否则梯度累积时数值偏大学习率需要等比下调。5.5 现象预测结果全是 O一个实体都抽不出来现象训练结束后跑 predict.py输出的标签序列全部是 O三元组列表为空。原因可能性有三。第一模型没有进入 eval 模式Dropout 在预测时还在随机丢弃特征导致输出概率被污染。第二BERT 微调学习率过大最后的线性层没稳定收敛。第三CRF 的转移矩阵在训练后期被某个不均衡 batch 带偏全局最优路径变成了“全部预测 O”。解决预测前强制加model.eval()用torch.no_grad()包住推理。如果用了 eval 还是全 O把学习率降到 2e-5 重新训练并在验证集上检查预测片段的数量。全 O 预测往往是训练环节出了问题而不是后处理代码的问题排查顺序不要搞反。6. 调参与验证用验证集 P/R 反推参数别让 BERTCRF 黑匣化6.1 区分 P 高 R 低和 R 高 P 低验证集出来之后先看两个数精确率 P 和召回率 R。P 高 R 低说明模型把实体边界收得太紧CRF 转移约束过强不敢开放实体起始位置常见处理是把 CRF 的转移分数学习率相对提高或者把 BERT 学习率降一点让模型有更多容量去拟合边界。R 高 P 低则说明标签过度扩张模型经常把一个长片段整段标成客体这时要考虑在 loss 里给 O 标签加权重压住无意义标签的输出概率。6.2 算片段 F1不要只记 token 级结果很多人习惯直接看 token 级 F1但这个指标在序列标注里会被 O 标签拉高。假如一个句子 90% 都是 O预测全部输出 O 也有 90% 的准确率但三元组一个抽不出来。我习惯单独写一段脚本按实体片段做完全匹配实体文本必须和真实标注完全一致才算一个正例。片段 F1 才是三元组识别真正要对齐的业务指标它直接反映知识图谱入库时有多少条三元组能直接用。6.3 固定随机种子保存最佳验证模型同样的数据和参数BERT 在不同随机种子下训练结果会有几个百分点的波动。我在训练循环里加了一个判断如果当前验证 F1 高于历史最佳就保存权重并且顺手把当时的 config、label2id、验证指标一起写进文件名的后缀里。这样后面每次翻阅 checkpoint都能知道这个模型是在什么参数和数据划分下产出的不会出现两个模型文件分不清的尴尬。从那以后我每次拿到新的三元组标注数据都会强制走一遍固定 seed 的 baseline先跑通标签对齐再记一组验证集 P/R然后才开始调参数。这套流程除了能复现模型更重要的是能过滤掉自己的手误避免把数据切分错当成模型效果差来调半天。希望帮到你。本文还有配套的精品资源点击获取
返回列表