基于Bert+CRF的三元组识别实战:从序列标注到关系抽取
2026/9/24 0:51:36 网站建设 项目流程

简介:这是一份基于Bert+CRF的中文三元组识别NLP实战项目,主要面向自然语言处理入门及进阶开发者,尤其是知识图谱构建、实体关系抽取方向的学习者,用于从非结构化文本中自动抽取主体-谓词-客体三元组信息。压缩包共11个文件,包含6个Python脚本(模型搭建、数据预处理、训练、预测、配置等)、3个Markdown说明文档、1个依赖清单与1张示意图,整体仅37KB,结构紧凑、便于快速上手。目前已有122人学习浏览,适合当作序列标注任务的完整参考。项目融合BERT预训练模型的语义表征能力和CRF的标签约束,提供从数据划分、训练评估到预测推理的完整流程,并附bert-base-chinese中文预训练权重及配置参数,可直接复现三元组识别效果,也可迁移至命名实体识别、关系抽取等相似任务。

1. 把一个 zip 解压出来不是重点,重点是你拿到的是不是一套能出三元组的 Bert+CRF 方案

做一个三元组识别项目时,我最怕看到的就是压缩包名字叫11-Bert+CRF 三元组识别.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 是 768,num_labels 由你的标注标签集合决定 def forward(self, input_ids, attention_mask): outputs = self.bert(input_ids=input_ids, attention_mask=attention_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_first=True) # torchcrf 的 CRF 层内部会维护一个转移矩阵 # 训练时返回 loss,预测用 viterbi_decode 解码 def forward(self, input_ids, attention_mask, labels=None): outputs = self.bert(input_ids=input_ids, attention_mask=attention_mask) logits = self.dropout(outputs.last_hidden_state) emissions = self.classifier(logits) if labels is not None: loss = -self.crf(emissions, labels, mask=attention_mask.bool(), reduction="mean") return loss else: return self.crf.decode(emissions, mask=attention_mask.bool())

训练阶段loss = -self.crf(...)的负号很多人会漏掉。CRF 内部实现是前向算法算的是正确序列相对所有序列的 log 概率,它是越大越好,所以损失要取负。漏掉负号直接导致 loss 越训越大,最后全模型崩掉。mask=attention_mask.bool()是为了把 padding 位置排除在 CRF 运算外——BERT tokenizer 会把短句补齐到同一个长度,padding 位置的标签没有意义,不 mask 的话 CRF 会把它们也当真实标签算进转移概率。

2.3 三种标注策略:管道式、联合式、生成式,标题里的方案属于哪种

拿这个标题去做检索,你会看到三种流派。管道式是先用 Bert+CRF 做实体识别,再把识别出的实体对和句子拼起来送入关系分类模型,优点是两个步骤都能用现成模型,缺点是错误会累积,实体识别阶段错一个,关系分类就被喂了错误输入。联合式是在一个模型里同时预测实体和关系,用参数共享降低错误累积。生成式则是用类似文本生成的方式直接输出三元组,比如双向联合抽取框架,但训练复杂、推理速度慢。

标题里的“Bert+CRF 三元组识别”更贴近联合式里的序列标注变体。它的核心记号是把关系直接揉进标签里,比如标签集合里有“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. 把 Bert+CRF 跑起来:项目结构、数据格式、训练命令与关键超参

3.1 拿到 zip 后先看这三个文件

11-Bert+CRF 三元组识别.zip解压开之后,正常情况下能看到数据集、模型文件、训练脚本和预测脚本四类东西。但基于我拆过大量相似包的经验,真正能不能跑通,先看这三个文件:

  • 数据标注文件,一般是.txt.json格式,决定了你重新训练的成本
  • 配置脚本,通常叫config.pyrun.py,里面写死了数据集路径、模型路径、标签列表
  • 模型权重目录,比如bert-base-chinese/,如果没有,就得用 transformers 的 API 从模型库下载

用 Linux 环境解压安装是常规操作,unzip 11-Bert+CRF三元组识别.zip -d ./bert_crf_project把压缩包解开到指定目录。如果 zip 是 Windows 下压的,里面文件名带中文,解压后经常乱码,用unzip -O gbk再解一次就正常了。这一步别跳过——训练脚本按路径读文件,文件名乱码会让数据集加载直接抛 KeyError。

拿到 zip 先跑一个最小预测而不是立刻重训,这是血泪经验。先加载权重、对一个样本做 predict,确认前向流程通,再去看训练代码。因为压缩包里大概率藏着版本不匹配问题:训练时 transformers 是 4.x,你现在环境是 5.x,同一个from_pretrained加载出来的张量形状都对得上,但行为已经变了。先用最小命令验证环境,省去一上午排查时间。

unzip 11-Bert+CRF三元组识别.zip -d ./bert_crf_project cd ./bert_crf_project pip install transformers==4.30.0 torchcrf seqeval python predict.py --input "华为发布昇腾芯片" --model_dir ./output/best_model

torchcrf这个库已经很久不更新了,但它在 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_words=True, truncation=True, max_length=max_len, padding="max_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_words=True告诉 tokenizer 你传进去的是一个已按字切好的列表,它会在 token 序列里插入[CLS][SEP],以及对连续数字字符可能做拆分的处理。word_ids()返回每个 token 对应原始字的序号,其中None表示[CLS][SEP]或 padding 位置。把这类位置的标签置为 -100,是因为 PyTorch 的CrossEntropyLoss会默认忽略标签为 -100 的位置,这里 CRF 也用同样的对齐策略参与 loss 计算。

如果漏了这一步对齐,用tokenizer(tokens, is_split_into_words=True)之后直接拿原始标签列表去填充 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", encoding="utf-8") as f: json.dump(label2id, f, ensure_ascii=False)

tf_crfcrf.save会把转移矩阵单独存一份,加载时crf.load恢复。label2id.json是最容易忘的,加载模型后没有它就无法把预测的标签 ID 映射回实体类别。推理的时候要保证加载顺序正确——先加载 label2id,再加载 CRF,最后加载 Bert:

def load_model(model_dir, device): label2id = json.load(open(model_dir + "/label2id.json", encoding="utf-8")) id2label = {int(k): v for k, v in label2id.items()} model = BertCRF(num_labels=len(label2id)) model.load_state_dict(torch.load(model_dir + "/bert_crf.pt", map_location=device)) 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.0,CRF 的实现里 torchcrf 也接受reduction参数,但更直接的做法是数据层采样——每句保留所有标签,但把全 O 或实体极少的短句过滤掉一部分。第二,预测阶段对 logits 施加一个小阈值,只有超过阈值的标签才被 CRF 解码,否则输出 O。经验值 0.3 到 0.5 之间,用验证集试出来。

4.3 预测时直接 argmax,CRF 白挂了

现象:推理阶段没有调用crf.decode,直接用torch.argmax(logits, dim=-1)取标签,出现“B-公司 后面直接跟 I-产品”这种明显交叉的标签序列。

原因:把预测写成了普通分类任务的 argmax,完全绕过了 CRF 的转移矩阵。这个问题在代码评审里经常遇到——训练时用了 CRF 计算 loss,预测时却手写 argmax,等于训练和推理不一致。

解决:统一走viterbi_decodetorchcrfdecode方法返回一个 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 默认编码是 GBK,Linux 解压按 UTF-8 解就会中文文件名乱码。更隐蔽的是数据文件本身编码:Excel 导出的 CSV 或标注工具的 .txt 可能是 GBK,而训练代码用open(f, encoding="utf-8")去读,直接 UnicodeDecodeError。还有一类 zip 文件带“伪加密”标记——文件头里加密标志位是 1,但内容并未真正加密,python 的 zipfile 看到标志会要求密码,直接拒绝读出。

解决:解压时用unzip -O gbk指定编码;如果文件头是伪加密,直接用 python 脚本重写文件头,把通用位标志里的加密位从 0x01 改成 0x00,再正常解压。数据文件的编码问题在训练代码里统一用encoding="utf-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))

seqevalclassification_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(axis=1, keepdims=True) 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 里最重要的不是那个已经跑通的基线结果,而是你能否通过三次翻车:标签对齐、类别不平衡、编码和解压导致的数据问题,积累出稳定的处理流程。我的习惯是:拿到一个三元组识别项目,先看标签体系和数据编码,模型永远是相对标准化的那一层。把这些处理干净,模型的下一步优化才有根基。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询