简介:这份资源是一个基于Python的NLP实战项目,面向自然语言处理学习者和知识图谱开发者,核心是利用Bert模型与CRF条件随机场进行三元组识别,从非结构化文本中抽取主体、谓词、客体信息,适用于信息抽取、知识图谱构建等场景。压缩包共11个文件,以6个Python脚本为主,涵盖模型构建、主流程、预测、数据切分等模块,同时附有配置文件、说明文档和示意图,整体仅37KB,体量轻但功能完整。目前已有122人学习下载。通过该项目,读者可以掌握基于Transformers的Bert微调、CRF序列标注层搭建、数据预处理与模型评估的完整流程,并可复用或改进代码完成自己的三元组抽取任务,适合希望将深度学习应用于结构化信息提取的入门及进阶开发者。
1. 拿到「Bert+CRF 三元组识别.zip」之后,先搞清楚它在解决什么问题
做知识图谱、文档结构化这类项目,最先要解决的就是三元组抽取:给一句话,抽出头实体、尾实体以及它们之间的关系。Bert+CRF 是这套任务里最值得先跑的基线——它把实体识别和关系分类合并成一个序列标注问题,用 Bert 编码上下文、用 CRF 约束标签跳转,实现成本低,效果通常远超规则和传统 BiLSTM+CRF。
这个以 zip 交付的项目包,新手拿到手的第一反应是解压跑起来。但更靠谱的顺序是先把数据格式、标签体系和损失函数看懂,否则后面调参都是玄学。麻烦集中在两个地方:一是数据怎么从「句子 + 实体 + 关系」变成模型能吃的 label_id,二是 CRF 的训练目标和普通分类任务不一样,评估方式也不同。
这篇文章就按「数据 → 模型 → 训练 → 排查 → 验证」的顺序,把这个方案拆开讲清楚。适用的读者是正在做信息抽取、实体链接、知识图谱构建的从业者;看完之后,你应该能自己从零复现一个能用的三元组抽取器,并且知道遇到问题时该查哪个环节。
2. 三元组识别如何改造成序列标注:数据格式与标签体系
在动手写代码之前,得先统一认知:三元组识别不是三个独立模型拼起来的事。常见做法有两种,一种是 pipeline,先做命名实体识别,再对实体对做关系分类,代码直观但误差会累积;另一种是联合抽取,把实体边界和关系类型合成一个标签序列,一次标注、一次解码。Bert+CRF 三元组识别走的是后者。你手里这份 zip 如果能正常解压,打开数据文件时看到的应该是一批「句子 + 对应标签序列」的样本,而不是「实体表 + 关系表」这种结构。
2.1 把「北京是中国的首都」标成什么样才算三元组
「北京是中国的首都」期望抽出的三元组是 (北京, 首都, 中国)。在序列标注框架里,首先要确定关系方向:默认取原句顺序,头实体出现在前面。标签要同时表达两件事——实体边界和关系类型。常用的组合方式是「位置前缀 + 关系类型」,比如用 BIOES 体系里的 B、I、E、S 表达边界,后面接关系类型名。
标出来是这个样子:
- 北 B-实体-首都
- 京 E-实体-首都
- 是 O
- 中 B-实体-首都(尾实体「中国」,关系类型同样是首都)
- 国 E-实体-首都
- 的 O
- 首 B-实体-首都(注意:这是关系里的属性名,不是实体)
- 都 E-实体-首都
这种标注下,解码时直接把「实体-首都」的两段取出来,按照在句子里的出现顺序配对,就还原成 (北京, 首都, 中国)。这里有个细节值得留意:头实体和尾实体用的是同一个关系名标签,靠句子顺序来区分主客体。如果业务里关系本身有方向(比如「收购」的主客体不能反),那标签里要显式区分「头」和「尾」,否则抽出来还得再写规则裁判定方向。
提示:如果同一句里出现实体重叠(「北京大学」既是机构名,又包含「北京」这个地名),纯序列标注会力不从心,这种情况建议换阅读理解式抽取或生成式方案。三元组数量多但不是重叠实体的话,序列标注仍然够用。
2.2 标签体系选 BIO 还是 BIOES,关系标签会不会爆炸
BIO 用 B/I 标记实体开始和中间,O 表示非实体;BIOES 额外用 E 表示结束、S 表示单字实体。对中文这种单字粒度数据,BIOES 对边界更友好:单个字成实体时不需要靠上下文补边界,CRF 层约束起来也更明确。实际工程里,句子短、实体长度集中在 1~4 个字时,我一般选 BIOES;实体长度波动大、标注数据里边界本来就不清晰时选 BIO,标签集合小,训练和解码都简单。
但「位置前缀 + 关系类型」有一个绕不开的问题:关系类型一多,标签集就会爆炸。10 种关系、用 BIOES,标签数是 1 + 4 × 10 = 41 种,这还没算头尾区分。CRF 的转移矩阵大小按标签数的平方增长,标签越多,训练数据里每种转移的样本就越稀疏,模型越容易在验证集上画出奇葩转移。常见做法有两个:一是控制关系类型数量,一个模型最多覆盖 20 种左右的关系;二是头尾实体共享同一组「实体-关系」标签,靠句子顺序区分,标签数量直接减半。
| 方案 | 标签数举例(3 种关系) | 优点 | 缺点 |
|---|---|---|---|
| BIO + 关系类型 | 1 + 2 × 3 = 7 | 标签少、训练快 | 边界表达弱,单字实体易碎 |
| BIOES + 关系类型 | 1 + 4 × 3 = 13 | 边界清楚、解码规整 | 需要更多数据喂每种转移 |
实际动手前先统计一遍标签频次。如果「O」占了 85% 以上,说明序列很长但实体稀疏,这时要给 CRF 层设置合理的类别权重,或者在 loss 上给非 O 标签加权,不然模型学到的全是「全预测成 O」。
2.3 数据处理脚本:从标注 JSON 到 label_id
拿到 zip 里的数据,通常是 JSON 或 CSV。先把它统一读进来,再交给 tokenizer。下面是我常用的数据加载脚本,关键点都在注释里。
import json import torch from transformers import BertTokenizer def load_samples(data_path, tokenizer_name="bert-base-chinese", max_len=128, label_map=None): tokenizer = BertTokenizer.from_pretrained(tokenizer_name) arrays = {"input_ids": [], "attention_mask": [], "labels": []} with open(data_path, "r", encoding="utf-8") as f: lines = f.readlines() for line in lines: obj = json.loads(line) text = obj["text"] tags = obj["tags"] # 与 text 等长的字符串列表 ids = tokenizer( text, max_length=max_len, truncation=True, padding="max_length", return_tensors="pt", return_offsets_mapping=True, # 关键:拿字符偏移做对齐 ) labels = [-100] * max_len # -100 表示不参与 loss 计算 labels[0] = label_map["[CLS]"] if "[CLS]" in label_map else -100 offset = ids["offset_mapping"][0] for token_i, (start, end) in enumerate(offset[1:-1], start=1): if start == 0 and end == 0: # padding 位置 continue char_i = start # 中文单字基本 start == end - 1 if char_i < len(tags): labels[token_i] = label_map[tags[char_i]] ids.pop("offset_mapping") arrays["input_ids"].append(ids["input_ids"][0]) arrays["attention_mask"].append(ids["attention_mask"][0]) arrays["labels"].append(torch.tensor(labels)) return {k: torch.stack(v) if isinstance(v[0], torch.Tensor) else v for k, v in arrays.items()}这段代码的核心逻辑是按 offset_mapping 做「字符到 token」的映射。中文场景下,tokenizer 大多把一个字切成一个 token,但遇到英文、数字或特殊符号时可能切成子词,直接用 enumerate(words) 对位会全乱。用 offset_mapping 拿到每个 token 对应的原始字符区间,取 start 作为字符下标,再找回标签,就避开了「BERT 切词导致标签错位」这个高频翻车点。
参数上,max_len 按业务句子长度设置,不是越大越好:128 够覆盖大多数短文本场景,超过 128 的部分会被截断;label_map 是标签到整数 id 的映射。数据预处理时一定要单独保存一份 label_map.json,训练和预测必须用同一个映射,否则解码时标签完全对不上。还有一个常被忽略的点:标签列表里如果出现「O」之外的未知标签,直接让脚本报错,别静默跳过,这类脏数据越早暴露越好。
3. 为什么偏偏是 Bert+CRF:编码器与解码层各管什么
在写模型代码之前,先把两个组件拆开。Bert 负责读句子、产出每个 token 的语义特征向量;CRF 负责在特征向量之上给标签序列算一个全局分数。它不是简单的叠加,而是明确分工:Bert 解决「这个词应该是什么实体」,CRF 解决「实体之间怎么跳转才算合法」。
3.1 Bert 为什么要替代 BiLSTM 当编码器
传统做法是「词向量 + BiLSTM + CRF」。BiLSTM 的缺陷在于它只能通过隐状态间接建模上下文,遇到一词多义(「苹果」是水果还是公司)或者长距离指代时,特征表达不够。Bert 是自注意力结构,每个 token 理论上都能观测全句,词义信息在预训练阶段已经吃进了参数里。用 Bert 做编码器,相当于把「学语言常识」这件事提前解决,下游任务只需要学关系标签和实体边界的判别。
bert-base-chinese 是中文文本场景的常用模型,参数量一亿出头,加载后权重文件 400MB 上下。类名固定为 bert-base-chinese,从本地目录加载时,目录里要有 config.json、pytorch_model.bin、vocab.txt 三个文件。很多项目写成「Bert-BiLSTM-CRF」,但只要底层是 from_pretrained 加载,不影响使用。这里要记住一点:不要把 Bert 理解成「更深的词向量」,它输出的是 token 级别的上下文特征序列,后面的分类头只做线性变换,不承担语义理解。
3.2 CRF 层的数学直觉:它到底约束了什么
CRF 引入一个标签转移矩阵 A,A[i][j] 表示从标签 i 跳到标签 j 的分数。整句的标签序列分数 = 每个 token 的发射分数之和 + 每个相邻标签对的转移分数之和。训练时最大化正确序列相对所有可能序列的分数差;解码用维特比,保证输出的序列在全句分数上最优,而不是单个 token 各自取最大。
举例:「北京」被 Bert 编码后,模型可能给「北」输出 0.6 的概率是 B、0.3 是 I;「京」输出 0.5 是 B、0.4 是 I。如果做逐 token 贪心解码,就会得到 B-B 这种不可能序列;CRF 把「B 后必须接 I 或 E」压进转移矩阵,直接禁止 B→B。对实体边界要求高的任务,CRF 的收益非常明显。
这也是它和「Bert + Linear + Softmax」的核心分歧:后者只在乎单点概率高,不在乎序列合法性。实体抽取里边界错误会导致整个实体判错——一个 span 差一个字,实体 F1 就是零。所以哪怕 CRF 实现稍微绕一点,这个解码层也值得加。
3.3 模型定义代码:一份可以直接用的 BertCRF
这里用 transformers 的 BertModel 加 torchcrf 库来写。torchcrf 是常用的 CRF 封装,提供了 log_likelihood 和 decode 两个关键方法,不需要手写维特比。
import torch import torch.nn as nn from transformers import BertModel from torchcrf import CRF class BertCRF(nn.Module): def __init__(self, model_name_or_path="bert-base-chinese", num_labels=13, dropout=0.1): super().__init__() self.bert = BertModel.from_pretrained(model_name_or_path) self.dropout = nn.Dropout(dropout) self.classifier = nn.Linear(self.bert.config.hidden_size, num_labels) self.crf = CRF(num_labels, batch_first=True) def forward(self, input_ids, attention_mask, labels=None): outputs = self.bert(input_ids=input_ids, attention_mask=attention_mask) seq_feats = outputs.last_hidden_state # (batch, seq, hidden) emissions = self.classifier(self.dropout(seq_feats)) if labels is not None: loss = -self.crf(emissions, labels, mask=attention_mask.bool(), reduction="mean") return loss return self.crf.decode(emissions, mask=attention_mask.bool())逻辑上,forward 分成训练和预测两条路径。训练时返回负对数似然 loss:CRF 的 log_likelihood 是越大越好,取负变成最小化问题,所以外面套了一个负号。预测时不计算 loss,直接调用 decode 做维特比解码,得到每个样本的最优标签 id 序列,返回的是 list,不是 tensor,做 batch 评估时要注意转换。
参数上,num_labels 必须和前面 label_map 的长度一致,这里写 13 是假设用了 BIOES + 3 种关系的组合,实际按你的数据算,建议让它在构建模型时根据 label_map 自动推导,不要手写死。dropout 默认 0.1,数据量小时可以提到 0.2 观察;CRF 层的转移矩阵不参与 dropout,只有 Bert 输出到分类头之间会丢神经元。
3.4 Bert 和 CRF 之间,要不要再插一层 BiLSTM
很多从「BiLSTM+CRF」迁移过来的同学,会把 Bert 的输出再送进一层 BiLSTM。这个操作在数据量只有几千条实体标注时收益有限,还可能让训练变慢、梯度更难传播。Bert 的自注意力已经做了双向建模,BiLSTM 只是把同一句话又读了一遍,语义增量很小。真正让 CRF 发挥作用的是转移约束,不是额外的序列建模。
我的经验是:先用 Bert+CRF 直连跑基线,把数据、标签、解码全部检查干净,确认是模型容量不够再去加层。如果确实要加 BiLSTM,hidden_size 设 256、num_layers 设 1 就够,别上两层——参数量上去了,效果经常不升反降。
4. 训练与预测的可复现流程:从 DataLoader 到实体级 F1
模型结构定下来之后,训练流程直接决定你能不能复现出一个「能用的模型」。这一章讲的是我自己跑通的最小流程,参数都不是拍脑袋定的,后面会逐个解释为什么这么设。
4.1 先分数据:按句子还是按文档切,直接影响验证集可信度
三元组标注样本通常是逐句标注的,划分时直接按句子随机切训练集和验证集即可。但如果你手里的原始数据是按文档组织的,句子之间存在跨句实体关系,那么随机切句子会让同一文档的信息同时出现在训练集和验证集里,造成验证集虚高。这种情况要按「文档级别」划分:同一个文档的所有句子进同一边,评分前再按文档去重。
划分比例上,样本量小于 5000 时,验证集留 20%;大于 2 万条可以留 10%。对三元组任务,验证集的类别分布比数量更重要,最好做分层抽样,保证每种关系类型在验证集里都出现过,否则 CRF 对少数关系的转移矩阵基本是瞎猜的,评估数字毫无意义。
4.2 训练超参与分组学习率:为什么 CRF 层要单独给学习率
| 超参 | 推荐值 | 说明 |
|---|---|---|
| Bert 层学习率 | 2e-5 | 微调预训练参数的常规量级 |
| CRF 层学习率 | 1e-2 或 1e-3 | 随机初始化层可以学得快一点 |
| batch_size | 16~32 | 受显存约束,越小越容易震荡 |
| max_len | 128 | 覆盖句子长度分布的中位数附近 |
| warmup_ratio | 0.1 | 前 10% 训练步数线性升温 |
| epochs | 10~30 | 以验证集 F1 早停为准 |
分组学习率的含义是,给 AdamW 优化器传入不同的参数组。Bert 参数带预训练先验,学习率大了会破坏表达能力,俗称「灾难性遗忘」;CRF 的转移矩阵和分类头是随机初始化的,学习率太小会让它前几个 epoch 都在原地打转。
from transformers import AdamW, get_linear_schedule_with_warmup def build_optimizer(model, bert_lr=2e-5, crf_lr=1e-2): bert_params = [p for n, p in model.named_parameters() if "crf" not in n and p.requires_grad] crf_params = [p for n, p in model.named_parameters() if "crf" in n and p.requires_grad] optimizer = AdamW([ {"params": bert_params, "lr": bert_lr}, {"params": crf_params, "lr": crf_lr}, ]) return optimizer def train_one_epoch(model, dataloader, optimizer, scheduler): model.train() total_loss = 0 for batch in dataloader: input_ids = batch["input_ids"].to(device) attention_mask = batch["attention_mask"].to(device) labels = batch["labels"].to(device) loss = model(input_ids, attention_mask, labels) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) optimizer.step() scheduler.step() total_loss += loss.item() return total_loss / len(dataloader)训练时每 50 步打印一次 loss,并在每个 epoch 结束后用验证集算实体 F1。早停条件是连续 3 个 epoch 验证 F1 没有回升,就加载历史最佳 checkpoint。梯度裁剪 max_norm=5.0 是 Bert 系模型的常见默认值:Transformer 堆叠深,梯度范数偶尔冲高到几百,不裁剪的话一个 batch 就能把预训练权重写坏;但裁剪阈值太低会拖慢收敛,1~10 之间都合理,我这里取 5。
4.3 预测脚本:把 label_id 还原成三元组
训练完的模型输出是「标签 id 序列」,还需要一步解码才能变成三元组。这一步出了错,前面的模型精度全白搭。
def decode_to_spans(pred_labels, id2label, text, offset_mapping): """把 token 级标签还原成实体 spans。""" spans = [] cur_type, cur_start = "", -1 for i, (start, end) in enumerate(offset_mapping): if start == 0 and end == 0: # [CLS] 或 padding continue label = id2label[pred_labels[i]] if label.startswith("B-"): cur_type, cur_start = label[2:], start elif label.startswith("I-") and cur_start != -1: pass elif label.startswith("E-") and cur_start != -1: spans.append((cur_type, cur_start, end)) cur_start = -1 elif label.startswith("S-"): spans.append((label[2:], start, end)) elif label == "O": cur_start = -1 return [(text[s:e], typ, s, e) for typ, s, e in spans]要点:span 的结束位置用 end(开区间),还原文本时是 text[s:e];B 后面跟着 I 时,只有遇到 E 或 O 才收束,否则跨 padding 时会把半个实体吞掉。CRF 理论上会抑制不合法转移,但后处理代码里仍然要兼容乱序标签,不能假设模型永远输出合法序列——模型训崩的时候,什么标签组合都可能出现。
拿到 spans 后,按关系类型分组,再按照起始位置排序,以「头实体在前」的规则配对成三元组。这里的配对逻辑取决于前面 2.2 的标签设计:如果标签里已经区分了头尾,直接读标签即可;如果靠顺序区分,就要把同一关系类型的实体按出现顺序两两配对。
4.4 评估指标:实体级 F1,而不是 token 级 accuracy
三元组任务的核心指标是实体级精确率、召回率、F1。判定一个三元组预测正确,要求头实体、尾实体、关系类型三者都一致,也就是「严格匹配」。边界差一个字、关系类型错,都算错。
def span_f1(pred_triples, gold_triples): pred_set = set((s, typ, e) for s, typ, e in pred_triples) gold_set = set((s, typ, e) for s, typ, e in gold_triples) tp = len(pred_set & gold_set) p = tp / len(pred_set) if pred_set else 0 r = tp / len(gold_set) if gold_set else 0 f1 = 2 * p * r / (p + r) if p + r else 0 return {"precision": p, "recall": r, "f1": f1}这个函数按三元组整体比较,头尾实体都抽到了但配不成对、关系类型对不上,都会判错。它还对标注一致性极其敏感:标注的时候「北京-首都-中国」和「中国-首都-北京」一旦混用,F1 会被系统性拉低。所以数据清洗阶段先统一关系方向,再谈模型调优。
5. 避坑与排查:这批 zip 包典型事故的五个现场
从解压到训练到预测,每个环节都有各自的高频事故。这一章列五个我实际踩过的坑,每个都按「现象 → 原因 → 解决」来写,你遇到类似问题时可以直接对着查。
5.1 zip 解压乱码与伪加密
现象:双击 zip 报「文件损坏」,或者要求输入密码,但交付方明确说没设密码;在 Linux 下用 unzip 解压后,文件名变成一堆乱码,放到 Windows 看又是正常的。
原因:一部分是「伪加密」——打包工具把加密标志位置为 1,但内容并没有真正加密,所以任何密码都解不开;另一部分是文件名编码问题,zip 头里的文件名用了 GBK,而 unzip 默认按 UTF-8 解析,于是中文文件名全部乱码。
解决:伪加密的包先别急着删,用 Python 的 zipfile 直接读一下内容列表,或者用 7-Zip 打开后解压到指定目录,7-Zip 对伪加密的容错明显更好。Linux 下解压中文 zip 时用unzip -O gbk 文件名.zip指定编码,Windows 上建议把右键菜单里的「压缩为 zip 文件夹」相关项去掉,改用 7-Zip 或 WinRAR 统一处理,避免系统自带压缩器写出非标准头。先用这个小脚本确认包是否可读:
import zipfile def inspect_zip(path): with zipfile.ZipFile(path) as zf: for info in zf.infolist(): print(info.filename, info.file_size, "加密" if info.flag_bits & 0x1 else "未加密")如果打印出来文件名正常、加密标志为真但交付方说没密码,那基本就是伪加密。此时用 7-Zip 直接解压即可,不需要真的移除密码。这种东西是典型的「看着吓人,其实一分钟能解决」,别在第一步浪费太多时间。
5.2 Bert 参数下载失败:权重文件卡住不动
现象:第一次运行 from_pretrained("bert-base-chinese") 时进度条长时间不动,或者直接抛连接错误;也有同学下载到一半中断,本地缓存里留了半个 pytorch_model.bin,之后每次加载都在同一位置报错。
原因:权重默认从外网下载,网络不通就会卡住;本地缓存存在损坏的半截文件也会让 from_pretrained 反复失败,而且报错信息不太直观,容易被误判成代码问题。
解决:先检查本机是否已有完整权重目录,路径下必须有 config.json、pytorch_model.bin、vocab.txt 三个文件。有的话直接把 from_pretrained 的入参改成本地目录绝对路径,离线加载。没有完整权重时,配置镜像站点下载到本地再转移,下载完成后核对文件大小与源端是否一致,不要直接使用中断留下的文件。我习惯把权重下载分成独立一步,和训练代码解耦,这样换机器部署时不用重新下载,也方便多人共用同一份缓存。
5.3 标签错位:loss 正常下降但验证集 F1 是零
现象:训练 loss 平稳下降,但验证集实体 F1 长期为零,或者预测出来的实体永远落在句子开头几个字上。
原因:最常见的是标签与 token 没有对齐。Bert 会把「北京」切成「北」「京」两个字,也会把英文单词切成「tr」和「##ump」这样的子词。如果你直接按字符位置顺序给每个 token 分配标签,短句没问题,一旦出现英文、数字或标点,后续所有标签整体偏移一位,整个序列全错。
解决:统一用 2.3 节里的 offset_mapping 方案。每个 token 都有它在原句的字符区间 (start, end),标签取 start 对应字符的标签;[CLS] 和 [SEP] 特殊 token 不参与 loss,补 -100。如果验证集仍然为零,就打印 5 条预测样本和 ground truth 并排对照,逐个 token 看错位点。90% 的错位是训练和预测用了不一致的 tokenizer 参数,比如训练时传了 max_len,预测时忘了传,padding 后长度对不上。
5.4 CRF 在学,但实体全是「B」没有「I」
现象:训练 5 个 epoch 后,预测结果里 B 开头实体很多,但实体内部全是单字,I 和 E 标签几乎不出现,实体长度普遍被压成 1。
原因:数据里单字实体占比过高,导致 CRF 学到的转移概率朝「B→O」或「B→E」倾斜;另一种可能是 BIOES 标签里 I 和 E 的数量本来就少,并且标注中存在「B 后接 B」这种矛盾样本,让 CRF 无法稳定学到实体延续关系。
解决:先统计训练集各标签的频次。如果单字实体占 50% 以上,考虑改用 BIO 标签体系,并排查标注人员是不是把「北京市」标成了「北 B / 京 B / 市 B」——这是很典型的人工标注错误。数据没问题的话,把 CRF 层学习率从 1e-2 降到 1e-3,让转移矩阵学得保守一些,同时把 B、I、E 三类样本做平衡采样。实体长度分布扭曲时,先改数据,不要急着改网络结构。
5.5 GPU 显存不够:一句话就 OOM
现象:batch_size 设 32,max_len 设 128,一跑训练就报 CUDA out of memory,换 batch_size 16 依旧报错。
原因:Bert 自注意力的空间复杂度随序列长度近似平方增长,max_len 翻倍,显存占用不是翻倍而是逼近 4 倍。显存瓶颈几乎总在 Bert 层,CRF 相关的开销相对小得多。
解决:三个手段按顺序试。第一步,把 max_len 降到 64,先保证能跑起来,再根据句子长度分布决定是否恢复;第二步,用梯度累积,每 4 步更新一次参数,等效扩大 batch 但显存只占一轮的;第三步,开启 gradient_checkpointing,用时间换显存,训练速度大概慢 20%,但显存能省下三分之一以上。先砍 max_len,再开累积,最后才上 checkpointing——因为 checkpointing 会实打实拖慢迭代速度,是下策不是上策。
6. 验证模型是「真能抽」而不是「loss 好看」
6.1 20 条样本过拟合测试
正式训练前,我会先做一个小样本过拟合测试:拿训练集前 20 条样本,跑 50~100 步,观察 loss 是否快速下降。这一步不是为了出指标,而是验证「模型结构、数据管线、标签映射」三者是否对齐。如果 loss 卡住不动或者越降越慢,先回头查 2.3 的标签编码,再检查 3.3 的 CRF 是否收了错误的 mask。数据量再大,管线是断的也跑不出东西。我调过的大部分「训练不收敛」,最后都定位在数据和标签上,而不是模型本身。
6.2 逐样本可视化:边界错还是类型错
预测完成不要只看 F1 数字。把验证集样本按「gold vs pred」逐条打印出来,手动翻 20 条。重点看三类现象:实体边界差一个字(标注歧义或模型边界弱)、关系类型错(类别样本不均衡)、三元组配对错(头尾位置反了)。这三类问题的修法完全不同:边界问题调 CRF 或换 BIOES;类型问题做数据增强;配对问题查解码逻辑。见到现象再动手,比反复调学习率快得多。
6.3 推理加速与半精度
验证通过后要上线,可以先做两件事:一是把模型切到半精度推理,显存占用和响应速度都有改善;二是把 batch 内改成动态 padding,句子按长度排序后分组填充,而不是全部 pad 到 max_len,这一招在长文本场景能把推理耗时降三成左右。如果用的是 PyTorch,直接包装一个 fp16 的推理函数即可,不需要换框架。
说到底,Bert+CRF 三元组识别能跑通,靠的不是哪一段代码写得多漂亮,而是数据对齐、标签体系、解码还原这三件事不犯错。我印象最深的一次翻车经历:调了一周网络结构,F1 纹丝不动,最后发现是 label_map.json 里两个标签共享了同一个 id。数据有问题时,模型结构再花哨也骗不了人。希望帮到你。
本文还有配套的精品资源,点击获取