ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

基于Bert+CRF的三元组识别实战:从序列标注到关系抽取

2026/9/24 0:51:55 拓冰建站 浏览量
基于Bert+CRF的三元组识别实战:从序列标注到关系抽取 简介这是一份基于BertCRF的中文三元组识别NLP实战项目主要面向自然语言处理入门及进阶开发者尤其是知识图谱构建、实体关系抽取方向的学习者用于从非结构化文本中自动抽取主体-谓词-客体三元组信息。压缩包共11个文件包含6个Python脚本模型搭建、数据预处理、训练、预测、配置等、3个Markdown说明文档、1个依赖清单与1张示意图整体仅37KB结构紧凑、便于快速上手。目前已有122人学习浏览适合当作序列标注任务的完整参考。项目融合BERT预训练模型的语义表征能力和CRF的标签约束提供从数据划分、训练评估到预测推理的完整流程并附bert-base-chinese中文预训练权重及配置参数可直接复现三元组识别效果也可迁移至命名实体识别、关系抽取等相似任务。1. 把一个 zip 解压出来不是重点重点是你拿到的是不是一套能出三元组的 BertCRF 方案做一个三元组识别项目时我最怕看到的就是压缩包名字叫11-BertCRF 三元组识别.zip——因为里面大概率躺着一份标注数据缺头缺尾、代码跑起来报一堆缺库错误的半成品代码。但它确实是入门知识图谱抽取最值得复现的一个组合。三元组识别要解决的是从非结构化文本里抽出(头实体, 关系, 尾实体)比如从“华为发布昇腾芯片”里抽出(华为, 发布, 昇腾芯片)。Bert 负责把每个字的语义编码成向量CRF 负责让标签序列不出现“B-公司后面跟着 I-人名”这种低级错误。这套方案适合两类人一类是刚接触信息抽取、想用一套标准流程跑通全链路的算法工程师另一类是业务侧要做知识图谱、智能问答但不想从零设计模型的研发。它能给你产出一个能跑、能训、能评估的最小实现后面再往业务上扩展也踏实。2. 序列标注框架下Bert 和 CRF 各自干了什么以及三元组怎么从标签里“长”出来2.1 为什么选 Bert 而不是 LSTM动态词向量决定了实体边界是否找得准传统 BiLSTM 在三元组识别里的问题是它把每个词编码成一个固定的向量遇到“苹果发布会”这种短语无法根据上下文判断“苹果”是公司还是水果。Bert 在每层 Transformer 里做双向注意力每个字向量都融合了整句话的信息实体边界自然更准。你说“小米发布新款手机”它知道“小米”是公司你说“买了三斤小米”它知道这是粮食。这个差异直接决定了后面关系分类喂进去的实体头向量准确不准确。用 Bert 做实体识别的常见做法是取[CLS]的输出作为整句表示取每个位置的字向量拼起来送入一个线性层得到每个字在标签集合上的发射分数。很多人把这个线性层的输出叫 logits形状是(batch, seq_len, num_labels)。注意 num_labels 是你定义的标签数不是三元组的关系数。from transformers import BertModel, BertTokenizer import torch.nn as nn class BertForToken(nn.Module): def __init__(self, num_labels): super().__init__() self.bert BertModel.from_pretrained(bert-base-chinese) self.dropout nn.Dropout(0.1) self.classifier nn.Linear(self.bert.config.hidden_size, num_labels) # hidden_size 是 768num_labels 由你的标注标签集合决定 def forward(self, input_ids, attention_mask): outputs self.bert(input_idsinput_ids, attention_maskattention_mask) seq_output outputs.last_hidden_state # (batch, seq_len, 768) logits self.dropout(seq_output) logits self.classifier(logits) # (batch, seq_len, num_labels) return logits这段代码里最关键的是outputs.last_hidden_state它取的是 BERT 最后一层每个 token 的隐状态而不是pooler_output——那个只有[CLS]的信息做分类行做序列标注不行。dropout放在分类层之前训练时随机把一部分神经元置 0防止模型记住训练集里的实体搭配。推理时要把它关掉因为model.eval()会自动关闭 dropout。这里有个容易被忽略的设计num_labels 不是实体类别数而是“实体类别数 × 标签位置数 1”。后面 2.4 会展开。2.2 CRF 不是拿来“提升准确率”的它管的是标签之间的纪律Bert 输出的每个位置是独立预测的它不知道前一个位置的标签是什么。于是常见翻车场景出现了模型预测出 “我 B-公司 是 I-公司” 这种 B 后面跟着同类型 I 是对的但 “B-公司 后面跟着 I-人名” 就乱了。CRF 干的事就是在在标签序列上增加一个转移矩阵告诉模型哪些转移是允许的、哪些是禁止的。CRF 层的参数比 Bert 少得多就是num_labels × num_labels的转移矩阵。这个矩阵在训练中自动学习但有一些约束是可以预设的。经验上我拿到一个新增数据集后先做一个硬约束B-xxx 后面只能跟 I-xxx 或 B-其他O 后面只能跟 B-xxx 或 O。这个规则写进 CRF 的转移矩阵初始化里能省很多无效训练。import torch import torch.nn as nn from torchcrf import CRF class BertCRF(nn.Module): def __init__(self, num_labels): super().__init__() self.num_labels num_labels self.bert BertModel.from_pretrained(bert-base-chinese) self.dropout nn.Dropout(0.1) self.classifier nn.Linear(self.bert.config.hidden_size, num_labels) self.crf CRF(num_labels, batch_firstTrue) # torchcrf 的 CRF 层内部会维护一个转移矩阵 # 训练时返回 loss预测用 viterbi_decode 解码 def forward(self, input_ids, attention_mask, labelsNone): outputs self.bert(input_idsinput_ids, attention_maskattention_mask) logits self.dropout(outputs.last_hidden_state) emissions self.classifier(logits) if labels is not None: loss -self.crf(emissions, labels, maskattention_mask.bool(), reductionmean) return loss else: return self.crf.decode(emissions, maskattention_mask.bool())训练阶段loss -self.crf(...)的负号很多人会漏掉。CRF 内部实现是前向算法算的是正确序列相对所有序列的 log 概率它是越大越好所以损失要取负。漏掉负号直接导致 loss 越训越大最后全模型崩掉。maskattention_mask.bool()是为了把 padding 位置排除在 CRF 运算外——BERT tokenizer 会把短句补齐到同一个长度padding 位置的标签没有意义不 mask 的话 CRF 会把它们也当真实标签算进转移概率。2.3 三种标注策略管道式、联合式、生成式标题里的方案属于哪种拿这个标题去做检索你会看到三种流派。管道式是先用 BertCRF 做实体识别再把识别出的实体对和句子拼起来送入关系分类模型优点是两个步骤都能用现成模型缺点是错误会累积实体识别阶段错一个关系分类就被喂了错误输入。联合式是在一个模型里同时预测实体和关系用参数共享降低错误累积。生成式则是用类似文本生成的方式直接输出三元组比如双向联合抽取框架但训练复杂、推理速度慢。标题里的“BertCRF 三元组识别”更贴近联合式里的序列标注变体。它的核心记号是把关系直接揉进标签里比如标签集合里有“B-H-公司-发布-产品”这种复合标签用来表示“这个 token 是一个头实体的开始且它与后面的尾实体构成‘发布’关系”。这样模型一次前向就把实体边界和关系同时预测出来不需要第二阶段的分类器。代价是标签数膨胀训练数据要求每个 token 标注精确后面会具体讲怎么做。标签设计示例关系揉进标签 O B-公司 # 头实体或独立实体 I-公司 B-产品 I-产品 B-公司-发布-产品 # 头实体开始且与后面某实体构成发布关系 I-公司-发布-产品 B-产品-发布-公司 # 尾实体开始反向关系同样建模 I-产品-发布-公司这种复合标签的本质是把关系抽取转化成序列标注让 CRF 的转移矩阵学习“B-公司-发布-产品 后面必须跟 I-公司-发布-产品直到遇到 B-产品-发布-公司 才能收尾”这一串约束。标签越多转移矩阵越稀疏训练数据需求量越大这是它最主要的代价。2.4 一张参数表label_map、转移矩阵、维特比解码如果你用BertTokenizer处理中文每个汉字基本对应一个 token标点和数字会切成特殊 token这涉及到后面的对齐问题。但参数配置本身要说的更基础参数常见取值说明max_len128 / 256超过长度直接截断三元组标注的实体往往在长句后部截断会丢信息batch_size16 / 32显存 12G 以下选 16纯预测可提高到 64 无风险learning_rate2e-5 / 3e-5Bert 官方建议值超过 5e-5 会让预训练权重被冲掉crf_learning_rate5e-3 / 1e-2CRF 层参数少要用大的学习率否则转移矩阵训练不动num_epochs5 / 10中文数据集一般 5 轮就过拟合看验证 F1 决定是否早停warmup_ratio0.1前 10% 的 step 学习率线性上升防前期震荡标签和 ID 的映射要做成一个字典一次性加载不要在 dataloader 里循环遍历查字典训练会慢一半以上。序列标注的 label_map 设计示例如下label_list [O, B-公司, I-公司, B-产品, I-产品] label2id {label: i for i, label in enumerate(label_list)} id2label {i: label for label, i in label2id.items()}这个映射是后续所有训练、预测、评估代码的统一入口。它决定了模型输出层的维度也决定了 CRF 转移矩阵的尺寸。实际项目中 label_list 经常上百个每次改动都要同步改这一处。训练完成后推理要走维特比解码也就是调用 2.2 里crf.decode那个分支。不要手动去argmax每个 token 的 logits那就等于放弃 CRF 的转移约束标签序列会又乱又碎。CR F 的维特比复杂度是O(seq_len × num_labels²)128 字以内完全是无感延迟这点不用担心。3. 把 BertCRF 跑起来项目结构、数据格式、训练命令与关键超参3.1 拿到 zip 后先看这三个文件11-BertCRF 三元组识别.zip解压开之后正常情况下能看到数据集、模型文件、训练脚本和预测脚本四类东西。但基于我拆过大量相似包的经验真正能不能跑通先看这三个文件数据标注文件一般是.txt或.json格式决定了你重新训练的成本配置脚本通常叫config.py或run.py里面写死了数据集路径、模型路径、标签列表模型权重目录比如bert-base-chinese/如果没有就得用 transformers 的 API 从模型库下载用 Linux 环境解压安装是常规操作unzip 11-BertCRF三元组识别.zip -d ./bert_crf_project把压缩包解开到指定目录。如果 zip 是 Windows 下压的里面文件名带中文解压后经常乱码用unzip -O gbk再解一次就正常了。这一步别跳过——训练脚本按路径读文件文件名乱码会让数据集加载直接抛 KeyError。拿到 zip 先跑一个最小预测而不是立刻重训这是血泪经验。先加载权重、对一个样本做 predict确认前向流程通再去看训练代码。因为压缩包里大概率藏着版本不匹配问题训练时 transformers 是 4.x你现在环境是 5.x同一个from_pretrained加载出来的张量形状都对得上但行为已经变了。先用最小命令验证环境省去一上午排查时间。unzip 11-BertCRF三元组识别.zip -d ./bert_crf_project cd ./bert_crf_project pip install transformers4.30.0 torchcrf seqeval python predict.py --input 华为发布昇腾芯片 --model_dir ./output/best_modeltorchcrf这个库已经很久不更新了但它在 pytorch 1.x 到 2.x 下都能正常工作是社区用得最多的 CRF 实现。seqeval是评测实体识别结果的标准库它按实体级别算了精确率、召回率、F1比逐 token 计算合理得多。3.2 数据标注格式BIO 与 BIESO 选哪个三元组识别任务是句子级别的序列标注。每行是一个 token 和它的标签空行是句子分隔符这是最通用的 CoNLL 格式。具体到中文任务每个 token 是一个汉字华 B-公司 为 I-公司 发 O 布 O 昇 B-产品 腾 I-产品 芯 I-产品 片 I-产品这里选了 BIO 而不是 BIESO。B 表示实体开始I 表示实体内部O 表示非实体。如果句子里的实体只有一两个字用 BIESO 里那个 S单字实体能让模型更容易学到单字实体模式。但中文实体里两三字的占绝大多数BIO 三个符号足够表达标签少意味着数据稀疏问题越轻。除非你的业务实体大量是单字词否则 BIO 是更稳妥的选择。数据清洗里有一个关键的坑全角半角标点混用会让 token 数变多最大长度 128 的设定会悄悄截掉句尾的实体。标注前要跑一遍统一字符函数把全角逗号、句号、括号统一转成半角或干脆过滤掉。另一个坑是空行不当分隔符——用两个\n\n还是单独的\n表示句子边界必须与数据读取逻辑严格一致差一个字符就是 sample 错乱。关系融合标签的设计要更精细。以B-公司-发布-产品为例它本身含义是“这个 token 是头实体公司的开始且这个抽取任务中它和句子后部的产品实体构成发布关系”。如果你有 5 种关系每个关系都有头尾两种方向标签数会是基础 BIO 标签数的数倍模型收敛速度和精度都会下来。经验是关系数超过 10 种就别用纯序列标注做三元组了换成生成式或者管道式更适合。3.3 训练主逻辑5 个需要手动改的关键参数训练脚本的核心是一个 Dataset 类加上一个 collate_fn以及训练循环里对 CRF 层使用不同学习率的逻辑。这里的代码逻辑决定了训练能不能稳定收敛踩过坑的应该都懂。from torch.utils.data import Dataset from transformers import BertTokenizer import torch tokenizer BertTokenizer.from_pretrained(bert-base-chinese) class TripleDataset(Dataset): def __init__(self, lines, label2id): self.samples [] self.label2id label2id for line in lines: tokens, labels [], [] for item in line.strip().split(\n): parts item.split( ) if len(parts) 2: tokens.append(parts[0]) labels.append(parts[1]) if tokens and labels: self.samples.append((tokens, labels)) def __len__(self): return len(self.samples) def __getitem__(self, idx): tokens, labels self.samples[idx] return tokens, labels def collate_fn(batch): input_ids, attention_masks, label_ids [], [], [] max_len 128 for tokens, labels in batch: encoded tokenizer(tokens, is_split_into_wordsTrue, truncationTrue, max_lengthmax_len, paddingmax_length) word_ids encoded.word_ids() # 关键: 对齐token与原始字 label_seq [self.label2id[l] for l in labels] aligned_labels [] prev_word_id None for wid in word_ids: if wid is None: aligned_labels.append(-100) # 特殊token不参与损失 elif wid ! prev_word_id: aligned_labels.append(label_seq[wid]) else: aligned_labels.append(-100) # 子词重复部分不参与损失 prev_word_id wid input_ids.append(encoded[input_ids]) attention_masks.append(encoded[attention_mask]) label_ids.append(aligned_labels) return (torch.tensor(input_ids), torch.tensor(attention_masks), torch.tensor(label_ids))这个 collate_fn 里的细节决定了训练是否能死雅式正确。is_split_into_wordsTrue告诉 tokenizer 你传进去的是一个已按字切好的列表它会在 token 序列里插入[CLS]、[SEP]以及对连续数字字符可能做拆分的处理。word_ids()返回每个 token 对应原始字的序号其中None表示[CLS]、[SEP]或 padding 位置。把这类位置的标签置为 -100是因为 PyTorch 的CrossEntropyLoss会默认忽略标签为 -100 的位置这里 CRF 也用同样的对齐策略参与 loss 计算。如果漏了这一步对齐用tokenizer(tokens, is_split_into_wordsTrue)之后直接拿原始标签列表去填充 label 数组会造成标签错位。最典型的场景是数字“123”会被中文 tokenizer 拆成三个 token标签本该只给在原字位置你却给了全部分三份模型学到的是错位的垃圾数据验证集上 F1 永远三十几分。训练循环里区分 Bert 和 CRF 参数的学习率这个细节对收敛速度影响非常大from torch.optim import AdamW optimizer_grouped_parameters [ {params: [p for n, p in model.named_parameters() if crf not in n], lr: 2e-5}, {params: [p for n, p in model.named_parameters() if crf in n], lr: 5e-3}, ] optimizer AdamW(optimizer_grouped_parameters)分组学习率的理由很简单Bert 是预训练模型权重已经接近最优学习率大了会灾难性遗忘CRF 是随机初始化的从零训练的层没有预训练基础需要更大的步长才能训到合理范围。3.4 模型保存与加载torch.save 别把 tokenizer 忘了模型训练完后服务上线前要保存的是四个对象漏一个都会在 predict 阶段翻车model_save_dir ./output/best_model model.crf.save(model_save_dir /crf.pt) torch.save(model.state_dict(), model_save_dir /bert_crf.pt) model.bert.save_pretrained(model_save_dir /bert) tokenizer.save_pretrained(model_save_dir /tokenizer) with open(model_save_dir /label2id.json, w, encodingutf-8) as f: json.dump(label2id, f, ensure_asciiFalse)tf_crf的crf.save会把转移矩阵单独存一份加载时crf.load恢复。label2id.json是最容易忘的加载模型后没有它就无法把预测的标签 ID 映射回实体类别。推理的时候要保证加载顺序正确——先加载 label2id再加载 CRF最后加载 Bertdef load_model(model_dir, device): label2id json.load(open(model_dir /label2id.json, encodingutf-8)) id2label {int(k): v for k, v in label2id.items()} model BertCRF(num_labelslen(label2id)) model.load_state_dict(torch.load(model_dir /bert_crf.pt, map_locationdevice)) model.crf.load(model_dir /crf.pt) model.to(device).eval() return model, id2label这里注意id2label的 key 要转成 int。json的 key 只能是字符串不转类型会直接导致查找失败。到这里训练全链路已经完整。前 2.1 到 2.4 是原理层面的准备工作3.1 到 3.4 是代码层面的落地动作。走到这一步你会发现真正花时间的不是模型本身而是对齐、保存、加载这些工程细节。4. 实战避坑六次翻车里最典型的五件事4.1 中文按字标注tokenizer 却给拆成了子词现象训练 loss 降不下去验证 F1 在 0.2 上下徘徊预测出的实体边界经常戛然而止比如“苹果公司”预测成“苹果公”。原因BertTokenizer对中文虽然是按字处理但遇到连续数字、英文缩写、特殊符号时会把它拆成更细的子词比如“2024”可能拆成“20”、“##24”两个 token。“##” 开头的 token 在 text 里没有对应的原字按原始标签直接对齐就错位了。解决在 3.3 的collate_fn里用word_ids()对齐。word_ids()存在就是为了处理“一个原始字对应多个子词 token”的情况子词之间取第一个子词的标签其余置 -100。这个逻辑调试一次后要固化成工具函数每个新数据集都用同一套对齐。4.2 O 标签占 93%模型学会了“全预测 O”现象整体准确率 92%但实体召回率几乎为 0。评估脚本打出 F1 只有 0.08模型把所有 token 都预测成了 O。原因三元组数据里实体永远是小部分。平均一句 30 个字实体只有 6 个字O 占比 80% 到 95%。模型发现全预测 O 的 loss 很低根本学不到实体边界。这是序列标注任务里最经典的类别不平衡问题。解决两个手段叠加。第一训练时给非 O 标签加权CrossEntropyLoss(weight...)里把 O 权重设为 0.1其他标签设为 1.0CRF 的实现里 torchcrf 也接受reduction参数但更直接的做法是数据层采样——每句保留所有标签但把全 O 或实体极少的短句过滤掉一部分。第二预测阶段对 logits 施加一个小阈值只有超过阈值的标签才被 CRF 解码否则输出 O。经验值 0.3 到 0.5 之间用验证集试出来。4.3 预测时直接 argmaxCRF 白挂了现象推理阶段没有调用crf.decode直接用torch.argmax(logits, dim-1)取标签出现“B-公司 后面直接跟 I-产品”这种明显交叉的标签序列。原因把预测写成了普通分类任务的 argmax完全绕过了 CRF 的转移矩阵。这个问题在代码评审里经常遇到——训练时用了 CRF 计算 loss预测时却手写 argmax等于训练和推理不一致。解决统一走viterbi_decode。torchcrf的decode方法返回一个 list里面每个元素是该序列维特比解码出的标签 ID 列表。不要做后处理“纠正”后处理会让模型不知道哪个对直接学习了一个反例。解码输出后映射成实体再拼接整个流程要一致。4.4 CRF 层学习率用 2e-5训完还是欠拟合现象训练 10 轮loss 曲线看起来正常下降但验证集的实体边界总是多一个或少一个字符。“腾讯”预测成“讯”“阿里巴巴”预测成“阿里巴”。原因CRF 层从零开始而 Bert 层预训练完里已经几乎收敛。CRF 的转移矩阵要用大学习率才能快速学到标签转移规律。之前见过很多项目把参数统一成learning_rate 2e-5训CRF 的转移矩阵没训到位标签序列局部规则没学出来。解决分组学习率CRF 部分用 5e-3 到 1e-2其他部分用 2e-5。在代码里用named_parameters()里的层名包含crf的区分开。一个判断标准训练到第 3 轮实体边界准确率如果还不到 60%先把 CRF 学习率调大 10 倍试试。4.5 zip 包解压踩坑中文路径、文件编码与“伪加密”现象解压出数据后 Python 报编码错误训练代码能找到权重文件但读取失败安装依赖时linux 离线下载的包传进去老报错。原因Windows 压缩的 zip 默认编码是 GBKLinux 解压按 UTF-8 解就会中文文件名乱码。更隐蔽的是数据文件本身编码Excel 导出的 CSV 或标注工具的 .txt 可能是 GBK而训练代码用open(f, encodingutf-8)去读直接 UnicodeDecodeError。还有一类 zip 文件带“伪加密”标记——文件头里加密标志位是 1但内容并未真正加密python 的 zipfile 看到标志会要求密码直接拒绝读出。解决解压时用unzip -O gbk指定编码如果文件头是伪加密直接用 python 脚本重写文件头把通用位标志里的加密位从 0x01 改成 0x00再正常解压。数据文件的编码问题在训练代码里统一用encodingutf-8-sig读文本——这个编码可以兼容带 BOM 的 UTF-8 和空 BOM 的情况比裸utf-8容错率高很多。这个看起来是小问题但一次数据读取异常就能浪费半天调试时间。5. 模型验证与调优从 F1 0.32 到 0.85 的完整路径5.1 用 seqeval 做实体级评估为什么不能看 token 级准确率三元组识别的评估不能看逐字准确率因为一个 6 个字符的实体猜中 5 个字符的逐字准确率也有 83%但对业务毫无价值。正确的做法是实体级评估模型预测出的完整实体与标注的完整实体完全一致才算对。seqeval是社区标准它基于 CoNLL 格式的序列标签自动把 BIO 序列还原成实体列表再算精确率、召回率、F1。from seqeval.metrics import classification_report, f1_score, precision_score, recall_score pred_entities [] true_entities [] for pred_sentence, true_sentence in zip(pred_labels, true_labels): pred_entities.append([id2label[i] for i in pred_sentence if id2label[i] ! O]) true_entities.append([id2label[i] for i in true_sentence if id2label[i] ! O]) print(classification_report(true_entities, pred_entities))seqeval的classification_report会按实体类别分别输出精确率、召回率、F1比如“公司”类 F1 0.91、“产品”类 F1 0.78。这样你能一眼看出哪个实体类别学得差。它内部会把B-公司、I-公司拼成一个完整实体多个 token 里只要一个预测错整个实体算错这才符合业务对实体抽取的要求。5.2 关系分类的混淆矩阵怎么读如果做的是管道式实体抽取和关系分类是两个独立模型。关系分类一般用 Softmax 多分类输出它的评估不能用准确率要看混淆矩阵。比如“发布”关系和“收购”关系如果经常混淆说明这两个关系在语料里的上下文特征重叠度高需要针对性补充标注数据。from sklearn.metrics import confusion_matrix import numpy as np cm confusion_matrix(y_true, y_pred) # 归一化按行,显示每种真实关系的召回分布 cm_norm cm.astype(float) / cm.sum(axis1, keepdimsTrue) print(np.round(cm_norm, 2))混淆矩阵按行归一化后每一行表示“真实是 A 关系时模型预测成各种关系的比例”。对角线接近 1 是理想状态。如果“收购”有 30% 被预测成“入股”那就是你的语料里这两类关系的上下文太像需要人工检查句子区分度必要时候合并这两类关系标签。三元组识别跟关系分类的区别在于前者把关系和实体边界一起用序列标注预测后者的错误源头是实体识别和关系分类两个阶段的累积。所以联合式方案里混淆矩阵能直接反映 CRF 转移矩阵学得不够紧的标签对。看到哪两类标签频繁互混直接用 CRF 转移矩阵初始化时加上“禁止直接转移”约束比加数据快得多。5.3 靠三张图判断模型状态loss、标签分布、预测样本训练过程中如果只盯 loss 曲线很容易被假象骗了。我调模型时习惯记录三张图。第一张是整体 loss 曲线它下降后平稳说明模型没有被破坏。第二张是各类实体的验证集 F1 曲线如果 B-产品 的 F1 先升后降就是过拟合信号early stopping 的最好时机在每个实体类别 F1 都达到峰值的那个 epoch。第三张最有用把验证集的预测结果打印成表格逐句看预测错误类型。for i in range(10): print(原文: , .join(tokens[i])) print(真实: , format_entities(true_entities[i], tokens[i])) print(预测: , format_entities(pred_entities[i], tokens[i])) print()打印几十条样本后你会很快归类出错误类型是不是所有“公司名”都被截断了是不是长尾实体没学到是不是某些关系的实体对老串位这些行为特征比任何单一指标都有指导性。我见过一个项目 F1 卡在 0.62 两周打印样本后半小时发现问题标注工具把所有标点都强行标成了 O但实体识别在标点处老是错误的把 O 预测成 B-人名。修复方式是训练数据里把标点过滤掉不去学噪音F1 直接涨到 0.71。5.4 小样本下的提速技巧冻结 Bert 只训 CRF标注数据少比如只有 500 条直接微调 Bert 会在第 3 轮开始过拟合。一个被验证有效的方案是前 2 轮冻结 Bert 的所有参数只训 CRF 和分类层。这时候 Bert 就是特征提取器输出的 768 维向量代表每个字CRF 在固定特征上学习标签纪律。等到 loss 降到平台期再解冻 Bert 做整体微调学习率减半。for name, param in model.named_parameters(): if bert in name: param.requires_grad False冻结 Bert 还有个额外好处显存占用大幅下降。12G 显存的卡之前 batch_size 只能开 8冻结后可以开 32训练速度反而快。解冻之后再恢复到 batch_size 16。这个技巧对资源紧张的小团队非常实用。如果数据量少到连 CRF 都训不动那就得考虑预训练语言模型选型——领域数据训练出的中文文本模型比通用中文 BERT 更合适但这就看你的资源能否支撑换了。6. 更进一步的三种做法从管道到联合再到强约束解码与知识注入如果你已经把这个 zip 里的方案跑通并验证到 F1 0.85 以上接下来有三条路可以走。第一条是从管道式走向真正的联合式改进在上述国有标签方案中尝试加入实体类型的先验知识比如用自定义词典对 Bert 输出的 logits 在解码前做一次校正把“苹果”这种歧义词在科技语境里强行偏向“公司”类别。这种“先验注入”用起来简单但要注意别太硬否则会把“小米粥”里的“小米”也硬标成公司。更稳妥的办法是少量标注数据 远程监督先用已有知识库对无标注文本做自动打标把自动打标的样本加入训练集再人工检查高置信度部分。这能把人工标注成本降一半但自动标注的噪音要控制在 20% 以内否则模型会学到把 IN 标成张三等问题。第二条是引入对抗训练做文本扰动把权重加到 embedding 上而不是输入文本上这是 FGM 的常见做法我在做短文本三元组识别时发现它的收益普遍比加 dropout 高。对抗训练的核心是给 embedding 加一个小扰动让模型对输入的小变化不敏感从而提升泛化能力。代码上就是在每个 batch 的 forward 前后算梯度、计算扰动、加回去再走一次 forward参数上只增加一个扰动系数一般设 0.5。这个技巧对实体边界不清晰的数据特别有效比如口语化的短文本用户输入“我想买苹果手机 256g”这种边界容易被“256g”这种非实体干扰。第三条是我现在最常做的把 CRF 的转移矩阵约束与业务规则结合。例如“发布”关系里头实体跟尾实体之间的位置关系不能超过 20 个字超过就不用或者在某个业务里“公司-发布-产品”一定出现在同一个句子而不是跨句。这些约束可以直接在 CRF 的转移矩阵中硬编码。实现上是在 CRF 初始化的 forward 里调整转移矩阵的值。这套做法的本质是把知识注入和序列模型融合比纯数据驱动少依赖标注量。加上 it 之后对长尾关系的数据量需求会明显下降因为约束本身替代了一部分学习需求。回看整个过程你现在这个 zip 里最重要的不是那个已经跑通的基线结果而是你能否通过三次翻车标签对齐、类别不平衡、编码和解压导致的数据问题积累出稳定的处理流程。我的习惯是拿到一个三元组识别项目先看标签体系和数据编码模型永远是相对标准化的那一层。把这些处理干净模型的下一步优化才有根基。希望帮到你。本文还有配套的精品资源点击获取