简介:这份压缩包围绕Python环境下使用预训练BERT完成中文命名实体识别展开,面向具备一定Python基础、希望掌握NLP模型微调与实体抽取流程的开发者与学习者。内容以中文人名、地名、机构名等实体识别为典型场景,串起BERT输入构造、IOB标注、训练评估与结果分析等关键环节。包内共9个文件,包含4个txt数据与词表文件、2个py脚本、1个md说明文档,另有png效果图和pl评测脚本,压缩包约3.72MB,目录按数据、代码与说明组织,便于对照查阅。已有3349人学习下载,适合用作课程实验、项目练手或NER任务入门参考。其中主脚本可用于加载和微调BERT,评估脚本与评测脚本配合完成指标统计,说明文档与效果图帮助快速理解任务流程和输出结果,整体兼顾代码实践与实验复现。
1. 中文 NER 想绕开规则词典,BERT 微调是最短那条路
上周有人把一个压缩包丢过来,问能不能直接拿去抽合同里的甲方、乙方和签约日期。包里是一套Python 开发的自然语言处理工程:基于预训练语言模型 BERT 做中文 NER,从数据校验、字级对齐、BERT+CRF 训练,到分批推理和指标评估,全流程都有脚本。我先跑通再逐行拆——它不是玩具 demo,是一份可以改成自己业务语料的骨架。适合手上有几百到几万条标注、被正则和词典维护成本折磨的开发者;不适合只想调个接口零训练的人,因为它要你自己训、自己调、自己评估。下面按「这是什么 → 怎么跑 → 坑在哪」的顺序拆。
2. 任务先拆干净:中文 NER 的标签体系与微调边界
拿到一份 NER 代码,第一件事不是pip install,而是看它的标签定义和评测口径。这两处定错了,后面训到 loss 收敛也是白训。这套工程用的是字符级(char-level)输入、BIO 标注、四类实体,整体是标准的序列标注范式:输入一串汉字,输出每个位置一个标签,再把连续标签拼成实体。听起来简单,但中文没有空格分词,实体边界完全靠模型自己从上下文里学,这就决定了后面的所有细节——分字还是分词、subword 怎么对齐、CRF 要不要留。
2.1 BIO 还是 BIOES:标签集合直接决定边界质量
BIO 只有三种前缀:B-表示实体首字,I-表示实体内部,O表示非实体。四类实体(人名、地名、机构名、其余专名)加上O,一共 9 个标签。BIOES 多出E-(实体末字)和S-(单字实体),四类实体就是 13 个标签。
两者的差别不在训练难度,在边界信号。BIO 下模型只要判断「这个字属于机构名内部」就够了,至于机构名在哪结束,靠的是下一个字变成O——间接信号。BIOES 把「结束」变成一个显式类别,模型多了一个直接监督目标,长实体、嵌套边界、相邻同类实体(比如「北京大学附属医院」这种连写的机构名)的切分通常更稳。
我一般这样选:实体平均长度小于 3 个字、语料在 5000 句以内,用 BIO,标签少、样本稀释轻;实体长、相邻实体多、或者下游要把实体拼回原文做精确匹配,用 BIOES,多花的那点标注成本能换回 1~2 个点的边界 F1。这套工程默认给的是 BIO,脚本里留了label_list的配置位,改成 BIOES 只需要改配置加数据转换,模型侧不用动。
| 标签体系 | 标签数(4 类实体) | 边界显式监督 | 适合场景 |
|---|---|---|---|
| BIO | 9 | 无 | 短实体、小样本、快速验证 |
| BIOES | 13 | 有 | 长实体、相邻实体、上线精度优先 |
| BMES(无类型) | 4 | 有 | 只做实体边界、不做分类 |
注意:换标签体系必须同时换训练数据和推理时的解码约束,只改一边会得到一堆非法序列,F1 直接掉到 0.3 以下。这一点在第 5 章会展开。
2.2 为什么是 BERT 微调,而不是从零训 BiLSTM-CRF
BiLSTM-CRF 是中文 NER 的经典基线,结构干净、推理快、显存占用小。但它的软肋在 embedding:从零训的词向量在几千句语料上根本学不出「原告」「法定代表人」这类词的语义,模型实际上是在背词典。BERT 这类预训练语言模型把这一步省了——它在海量无标注文本上已经学过上下文关系,微调阶段只需要教会它「哪些片段属于哪类实体」。
代价也很实在:一个 12 层、hidden 768 的 BERT,序列长度 256、batch 32 的时候,16G 显存基本吃满,单卡推理一条 200 字文本大概 8~15ms,比 BiLSTM 慢三到五倍。所以选型不是「谁更先进」,而是看你的瓶颈在哪:
| 方案 | 标注量需求 | 单条推理延迟 | 显存 | 典型用途 |
|---|---|---|---|---|
| 词典 + 正则 | 0 | <1ms | 无 | 实体形态固定、量小的场景 |
| BiLSTM-CRF 从零训 | 2 万句以上 | 2~4ms | 低 | 高频调用、延迟敏感 |
| BERT + Linear + CRF | 3 千句起 | 8~15ms | 高 | 精度优先、可离线批处理 |
| 蒸馏/小层数 BERT | 3 千句起 | 4~7ms | 中 | 上线后要压延迟 |
我的实际做法是先用 BERT 把精度天花板摸出来,确认这套标注体系能到多少 F1,再决定要不要蒸馏或换小模型。反过来先压模型,你永远不知道掉的那几个点是模型容量的问题还是标注的问题。
2.3 目录结构与运行环境:先把依赖钉死
这套工程的目录是这样组织的,改代码前先认路:
bert_ner/ ├── configs/ │ └── ner_base.json # 标签表、max_len、batch、lr 全在这里 ├── data/ │ ├── raw/ # 原始标注,一行一句 + 标签串 │ ├── processed/ # 校验、切分后的 train/dev/test │ └── labels.txt # 标签顺序,必须与模型输出头对齐 ├── src/ │ ├── check_data.py # BIO 合法性校验 │ ├── dataset.py # Dataset 与动态 padding │ ├── model.py # BERT + dropout + linear + CRF │ ├── train.py # 训练循环、早停、保存最优 │ ├── evaluate.py # 实体级 P/R/F1 │ └── predict.py # 滑窗推理,支持长文本 └── requirements.txt环境我一般用 Python 3.9 或 3.10,太新的版本偶尔会在编译扩展上翻车。依赖按下面钉版本,torch 和 transformers 的版本组合是这套代码跑通的基准,别随手升:
python -m venv venv && source venv/bin/activate pip install torch==2.1.2 --index-url https://download.pytorch.org/whl/cu118 pip install transformers==4.36.2 seqeval==1.2.2 numpy==1.24.4 tqdmseqeval不能省,后面算实体级 P/R/F1 全靠它;用 token 级 accuracy 评估 NER 是自欺欺人,因为O标签通常占 85% 以上,模型全预测O也能拿到很高的准确率。权重方面,把中文 BERT 的config.json、vocab.txt、pytorch_model.bin放到本地目录,配置里写相对路径,不要依赖联网拉取——离线环境和内网部署迟早会遇到。
ner_base.json里几个关键项的含义:max_len是截断长度(含[CLS]和[SEP],实际可用 254 个字符);label2id的顺序必须和labels.txt逐行一致;crf开关控制是否接 CRF 层,关掉就是纯 token 分类。这三项任何一项在训练和推理之间不一致,结果都会莫名其妙地差。
3. 从原始标注到模型输入:数据链路里最容易翻车的一段
数据链路是这类工程里最容易出问题的地方,而且出了问题不报错,只是 F1 悄悄低几个点。整套链路是:原始「字 + 标签」对 → BIO 合法性校验 → 分词/分字 → tokenizer 编码 → 标签对齐 → 动态 padding。中间任何一步错位,模型学到的就是噪声。
3.1 语料格式与 BIO 合法性校验
原始数据我习惯用两列格式(字 + 标签),句子之间空行分隔;也有用「整句 + 标签串」的,代码里两种都兼容。不管哪种,训之前必须跑一遍校验,把非法序列挑出来。
最常见的三类非法:I-X前面不是B-X或I-X(孤儿内部标签)、实体被句号切断、标签串长度和字符数不一致。第三类通常是人工标注时多敲了一个空格,肉眼几乎看不出来。
# check_data.py:逐句校验 BIO 序列合法性 import sys from collections import Counter def load_pairs(path): """读取两列格式:每行 '字\t标签',空行分隔句子""" sents, buf = [], [] with open(path, encoding="utf-8") as f: for line in f: line = line.rstrip("\n") if not line.strip(): if buf: sents.append(buf); buf = [] continue char, tag = line.split("\t") buf.append((char, tag)) if buf: sents.append(buf) return sents def check(sents): bad, tag_counter = [], Counter() for idx, sent in enumerate(sents): prev = "O" for char, tag in sent: tag_counter[tag] += 1 # 非法:I-X 的前驱既不是 B-X 也不是 I-X if tag.startswith("I-"): typ = tag[2:] if prev not in (f"B-{typ}", f"I-{typ}"): bad.append((idx, char, prev, tag)); break prev = tag return bad, tag_counter if __name__ == "__main__": sents = load_pairs(sys.argv[1]) bad, counter = check(sents) print(f"总句数 {len(sents)},非法句 {len(bad)}") for item in bad[:10]: print("非法样本:", item) print("标签分布:", counter.most_common())这段代码做两件事:校验 BIO 合法性和统计标签分布。prev记录上一个标签,遇到I-X就回头检查前驱,不合法立刻记录句号并跳出——同一句里可能有多处错误,但先修一处通常整句要重标,没必要全列。标签分布打出来是给自己看的:如果B-ORG只有几十个,说明这类实体在语料里极度稀疏,训出来必然召回低,这时候该做的是补标注或者合并类别,而不是调学习率。
提示:非法样本不要「自动修复」。把孤儿
I-X改成B-X看着省事,实际上掩盖了标注规范没对齐的问题,训出来的模型边界会很飘。正确做法是导出这几十句,回标注环节重标。
3.2 中文分字与 tokenizer 对齐:subword 那一步的错位
中文 BERT 的分词器基本是字级的,但不是纯字级。英文单词、数字、连续标点会被切成多个 subword;生僻字会落到[UNK];[CLS]和[SEP]会各占一个位置。所以「第 i 个字符对应第 i 个标签」这个朴素假设,在真实语料上一定会错位。
处理方式有两种。第一种是先把文本切成字列表(中文按字、英文数字按整词保留),再用is_split_into_words=True喂给 tokenizer,这样能用word_ids()精确还原每个 token 属于第几个字。第二种是直接编码整句,靠word_ids()里None的位置(特殊符号)和重复的 word_id(一个字的多个 subword)来对齐:一个字的第一个 subword 保留原标签,后续 subword 和特殊符号一律置-100,让损失函数忽略它们。
# dataset.py:tokenizer 编码 + 标签对齐,核心是 word_ids from transformers import BertTokenizerFast import torch from torch.utils.data import Dataset IGNORE_INDEX = -100 class NerDataset(Dataset): def __init__(self, samples, label2id, tokenizer_path, max_len=256): # samples: [{"chars": ["北","京"], "tags": ["B-ORG","I-ORG"]}, ...] self.samples = samples self.label2id = label2id self.max_len = max_len self.tok = BertTokenizerFast.from_pretrained(tokenizer_path) def __len__(self): return len(self.samples) def __getitem__(self, i): s = self.samples[i] enc = self.tok( s["chars"], is_split_into_words=True, # 关键:告诉 tokenizer 输入已经是切好的 truncation=True, max_length=self.max_len, padding=False, # padding 交给 collate 动态做 return_tensors=None, ) word_ids = enc.word_ids() # 每个 token 对应的字下标,特殊符号为 None labels, prev_wid = [], None for wid in word_ids: if wid is None: # [CLS] / [SEP] / [PAD] labels.append(IGNORE_INDEX) elif wid != prev_wid: # 该字的第一个 subword labels.append(self.label2id[s["tags"][wid]]) else: # 同一字的后续 subword labels.append(IGNORE_INDEX) prev_wid = wid enc["labels"] = labels return {k: torch.tensor(v) for k, v in enc.items()}三个参数值得说清楚。is_split_into_words=True是整段代码的前提,不加它 tokenizer 会把字列表当成一个字符串重新切,word_ids()直接失效。truncation=True配max_length会从右侧截断,如果实体正好跨在截断点上,这句样本的尾部实体就成了残缺标签——我一般在校验阶段就把超长句按标点切开,而不是靠截断兜底。padding=False是有意的,每条样本长度不同,在这里 pad 到固定长度会浪费大量显存,交给collate_fn按 batch 内最大长度 pad 更划算。
prev_wid那几行是对齐的核心:word_ids()里同一个字的所有 subword 拿到相同的 wid,只有第一次出现时才取标签,后面的置-100。这样即使某个字被切成三个 token,损失里也只算一次,不会因为切分粒度不同导致某些字被过度加权。
注意:
-100是 PyTorchCrossEntropyLoss的默认ignore_index,不是随便挑的魔法数。如果你在别处改了ignore_index,这里必须同步改,否则会被当成一个真实类别参与训练。
3.3 动态 padding 与 collate_fn
collate_fn是 DataLoader 和模型之间的适配层,负责把长度不一的样本 pad 成同一个矩形张量,同时产出attention_mask。BERT 的注意力必须靠 mask 屏蔽 padding 位,否则 pad 的[PAD]token 会参与注意力计算,短句受影响尤其明显。
# dataset.py:按 batch 内最大长度动态 pad import torch def collate_fn(batch, pad_id=0): max_len = max(len(x["input_ids"]) for x in batch) input_ids, attention_mask, token_type_ids, labels = [], [], [], [] for x in batch: pad = max_len - len(x["input_ids"]) input_ids.append(torch.cat([x["input_ids"], torch.full((pad,), pad_id, dtype=torch.long)])) attention_mask.append(torch.cat([x["attention_mask"], torch.zeros(pad, dtype=torch.long)])) token_type_ids.append(torch.cat([x["token_type_ids"], torch.zeros(pad, dtype=torch.long)])) # labels 补 -100,保证 pad 位不计损失 labels.append(torch.cat([x["labels"], torch.full((pad,), -100, dtype=torch.long)])) return { "input_ids": torch.stack(input_ids), "attention_mask": torch.stack(attention_mask), "token_type_ids": torch.stack(token_type_ids), "labels": torch.stack(labels), }pad_id=0对应 BERT 词表里的[PAD],这一点不同权重可能不同,配之前查一下vocab.txt第一行。attention_mask补 0、token_type_ids补 0、labels补-100,三个补齐值不能混用:mask 补错会让模型注意到 padding,labels 补错会把 pad 当成O类参与训练,直接拉低召回。
3.4 模型结构:BERT + dropout + linear + CRF
结构本身不复杂,关键在输出头和 CRF 的接线方式。BERT 输出[batch, seq_len, 768],接一个 dropout 防过拟合,再接一个Linear(768, num_labels)得到发射分数(emission),最后可选地接一层 CRF 做序列级解码。
# model.py:BERT 编码 + 线性发射 + 可选 CRF import torch import torch.nn as nn from transformers import BertModel from torchcrf import CRF class BertNer(nn.Module): def __init__(self, bert_path, num_labels, use_crf=True, dropout=0.1): super().__init__() self.bert = BertModel.from_pretrained(bert_path) self.dropout = nn.Dropout(dropout) self.classifier = nn.Linear(self.bert.config.hidden_size, num_labels) self.use_crf = use_crf if use_crf: self.crf = CRF(num_labels, batch_first=True) def forward(self, input_ids, attention_mask, token_type_ids, labels=None): out = self.bert(input_ids=input_ids, attention_mask=attention_mask, token_type_ids=token_type_ids) emissions = self.classifier(self.dropout(out.last_hidden_state)) # [B, L, C] if not self.use_crf: return emissions # 训练时外部算 CrossEntropyLoss mask = attention_mask.bool() if labels is not None: # CRF 的标签里不能有 -100,先换成 0 再用 mask 屏蔽 crf_labels = labels.masked_fill(labels == -100, 0) return -self.crf(emissions, crf_labels, mask=mask, reduction="mean") # 返回 loss return self.crf.decode(emissions, mask=mask) # 推理返回标签 id 列表这里有个必须知道的细节:torchcrf不接受-100这种越界标签,所以训练时要先masked_fill成 0,再靠 mask 把这些位置排除在转移路径之外。如果不换,程序会直接抛索引越界;如果换了但忘了传 mask,pad 位会被当成真实标签参与转移矩阵计算,loss 会虚高且不收敛。
use_crf关掉时,外部用CrossEntropyLoss(ignore_index=-100)算损失,训练更快、更容易调,但推理阶段可能输出非法序列(比如I-ORG出现在O后面)。CRF 的价值就在这里:它在解码时用维特比算法约束转移合法性,从结构上消灭这类错误。我一般先用use_crf=False跑通全流程确认数据链路没问题,再打开 CRF 做正式训练——两个阶段分开调,出问题好定位。
4. 训练与评估:超参怎么定、指标看哪个
模型能 forward 只是开始,这套工程真正值钱的是训练脚本里的几个默认值和评估口径。NER 是个对超参相对宽容但对评估口径极度敏感的任务,同一个模型用 token 级 accuracy 评估能报 0.97,用实体级 F1 评估可能只有 0.72。
4.1 超参表:这几个数不要凭感觉改
| 参数 | 默认值 | 取值范围 | 说明 |
|---|---|---|---|
| learning_rate | 2e-5 | 1e-5 ~ 5e-5 | 全量微调首选 2e-5,超过 5e-5 容易崩 |
| batch_size | 16 | 8 ~ 32 | 显存允许就往大调,配合 lr 线性缩放 |
| epochs | 5 | 3 ~ 8 | 看 dev F1 早停,别硬跑满 |
| max_len | 256 | 128 ~ 512 | 覆盖 95% 样本长度即可 |
| warmup_ratio | 0.1 | 0.05 ~ 0.1 | 防止前几百步把预训练权重带偏 |
| weight_decay | 0.01 | 0 ~ 0.01 | 只作用于非 bias、非 LayerNorm 参数 |
| max_grad_norm | 1.0 | 0.5 ~ 1.0 | 防梯度爆炸,CRF 层尤其需要 |
| dropout | 0.1 | 0.1 ~ 0.3 | 小样本(<3000 句)可提到 0.2 |
学习率是这套配置里最玄学的一个。2e-5 是 BERT 微调的经典起点,但如果你只训 CRF 和分类头(冻结 BERT),这个值太小,得提到 1e-3 量级;反过来全量微调还给 1e-3,前 100 步 loss 就会飙到十几然后一路不降。我见过最常见的翻车就是直接抄了别人 BERT 分类任务的学习率(1e-4 甚至 5e-4),训到第 2 个 epoch 开始 F1 缓慢下降——那不是过拟合,是权重被推离了预训练的最优点。
4.2 训练循环:分层学习率加梯度裁剪
优化器用 AdamW,参数分两组:BERT 主干给lr,分类头和 CRF 给lr * 10。原因是新初始化的层需要更大的步长才能快速收敛,而预训练层只需要微调。
# train.py:优化器分组 + 线性 warmup + 早停 import torch from transformers import get_linear_schedule_with_warmup from torch.optim import AdamW def build_optimizer(model, lr=2e-5, weight_decay=0.01): no_decay = ["bias", "LayerNorm.weight"] head_params = list(model.classifier.named_parameters()) if model.use_crf: head_params += list(model.crf.named_parameters()) head_names = {n for n, _ in head_params} groups = [ # BERT 主干:需要衰减的 {"params": [p for n, p in model.bert.named_parameters() if not any(nd in n for nd in no_decay)], "lr": lr, "weight_decay": weight_decay}, # BERT 主干:bias / LayerNorm 不衰减 {"params": [p for n, p in model.bert.named_parameters() if any(nd in n for nd in no_decay)], "lr": lr, "weight_decay": 0.0}, # 任务头:学习率放大 10 倍 {"params": [p for n, p in model.named_parameters() if n in head_names or n.startswith("classifier") or n.startswith("crf")], "lr": lr * 10, "weight_decay": 0.0}, ] return AdamW(groups) def train_one_epoch(model, loader, optimizer, scheduler, device, max_norm=1.0): model.train() total_loss = 0.0 for step, batch in enumerate(loader): batch = {k: v.to(device) for k, v in batch.items()} out = model(**batch) loss = out if model.use_crf else \ torch.nn.functional.cross_entropy( out.view(-1, out.size(-1)), batch["labels"].view(-1), ignore_index=-100) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm) # 梯度裁剪 optimizer.step(); scheduler.step(); optimizer.zero_grad() total_loss += loss.item() return total_loss / len(loader)三个地方要解释。分组里给任务头lr * 10是我常用的经验值,如果你用的是小样本(2000 句以下),可以放得更激进到 20 倍,同时把 BERT 主干学习率压到 1e-5。clip_grad_norm_必须放在backward()之后、step()之前,CRF 层的转移矩阵梯度偶尔会突然变大,不裁剪的话偶发 loss 爆炸。scheduler.step()每步调用一次(不是每 epoch),warmup 步数按「总步数 × warmup_ratio」算,总步数是len(loader) * epochs,这个数算错了 warmup 会跑偏。
早停逻辑按 dev 集实体级 F1 存最优权重,而不是按 dev loss。loss 低但 F1 掉的情况很常见,尤其是实体稀疏的类别——模型学会了把稀有实体全预测成O来降低平均损失。保存时我一般只存state_dict加label2id,不存整个模型对象,避免版本升级后加载失败。
4.3 评估口径:必须用实体级 P/R/F1
seqeval的classification_report输出的是实体级指标:一个实体只有起始位置和类型都对才算预测正确,边界差一个字就算完全错误。这个口径比「逐 token 对错」严格得多,也更接近业务感受——业务要的是「抽出来的公司名」能直接用,不是「90% 的字判对了」。
# evaluate.py:实体级评估,输出每类 P/R/F1 import torch from seqeval.metrics import classification_report, f1_score @torch.no_grad() def evaluate(model, loader, id2label, device): model.eval() preds, golds = [], [] for batch in loader: batch = {k: v.to(device) for k, v in batch.items()} labels = batch.pop("labels") if model.use_crf: paths = model(**batch) # CRF 直接返回 id 序列 else: paths = model(**batch).argmax(-1).tolist() for p, g in zip(paths, labels.tolist()): pred_seq, gold_seq = [], [] for pi, gi in zip(p, g): if gi == -100: # 跳过 padding 与 subword 位 continue pred_seq.append(id2label[pi]) gold_seq.append(id2label[gi]) preds.append(pred_seq); golds.append(gold_seq) print(classification_report(golds, preds, digits=4)) return f1_score(golds, preds) # 默认 macro 平均-100的跳过逻辑必须和训练时完全一致,否则评估会把 padding 位算成O,虚高精确率。f1_score默认是 macro 平均,也就是每类 F1 求平均——这在类别极不均衡时更有参考价值,因为数量少的类别不会被O淹没;如果业务只关心某一类,就单独看classification_report里那一行。
一个实测经验:这套工程在 5000 句左右的四类实体语料上,BERT + CRF 训练 4 个 epoch,dev 集 macro F1 大概能到 0.85~0.90 区间,其中人名、地名通常最高,机构名因为形态自由(简称、全称、连写)会低 3~6 个点。如果你跑出来人在 0.6 附近,先别怀疑模型,回到第 3 章查数据链路。
4.4 推理脚本:长文本滑窗与结果合并
线上文本八成超过max_len,直接截断会丢掉后半段的实体。工程里predict.py用滑窗切分:窗口 200 字、步长 160 字(重叠 40 字),每窗独立预测,再按原位置把标签拼回去,重叠区以「完整实体优先」的规则合并。
# predict.py:滑窗推理,重叠区按实体完整性合并 def sliding_predict(text, model, tok, id2label, device, win=200, stride=160, max_len=256): results = [None] * len(text) for start in range(0, max(len(text) - win, 0) + 1, stride): chunk = text[start:start + win] enc = tok(chunk, return_tensors="pt", max_length=max_len, truncation=True) enc = {k: v.to(device) for k, v in enc.items()} ids = model(**enc) offset = 1 # 跳过 [CLS] for i, tid in enumerate(ids): pos = start + i if pos >= len(text): break tag = id2label[tid] # 重叠区:已有实体的起点优先,避免把实体截成两半 if results[pos] is None or (results[pos].startswith("I-") and tag.startswith("B-")): results[pos] = tag return [(text[i], results[i] or "O") for i in range(len(text))]滑动窗口的关键是stride < win,保证相邻窗口有重叠,否则实体正好跨在窗口边界上会被切成两个残缺实体。合并规则我写得很朴素:重叠区保留先到的标签,但如果新窗口给出B-而旧值是以I-开头,说明旧值是从半个实体开始的,用新的覆盖。这段逻辑不是最优解,但比「后到覆盖先到」稳,前者容易把完整实体打碎,后者容易把碎片粘错。
窗口大小和步长的取舍:窗口越大,单次推理越慢但上下文越完整;步长越接近窗口,重叠越少、总耗时越低,但边界风险越高。我一般取窗口 200、重叠 20%,配合max_len=256留足余量。
5. 避坑与排查:五条真金白银换来的记录
这套代码我在三类语料上跑过,前两次都是先上线再返工。下面五条是排查时最常撞上的,每条按「现象 → 原因 → 解决」记,遇到问题可以对号入座。
5.1 训练 loss 从第一个 epoch 就不动,预测全是 O
现象:train loss卡在 0.4 附近几个 epoch 纹丝不动,dev 集预测出来全是O,实体级 F1 接近 0。
原因通常有两个。一是标签对齐时把所有位置都置成了-100——比如is_split_into_words=True忘了加,word_ids()全部返回None,于是整个 batch 的 loss 变成 0 或 nan,模型根本没在学。二是学习率给太大,第一波梯度就把 BERT 权重推坏了,之后的梯度方向全是噪声。
解决:先检查labels里非-100的比例,正常应该在 95% 以上;如果全是-100,回到 3.2 节查 tokenizer 调用。如果比例正常,把学习率降到 1e-5 重跑,并在前 20 步打印梯度范数(clip_grad_norm_返回值),正常量级在 0.5~5 之间;如果范数动辄几十上百,说明 lr 还是太大或者数据里混进了超长样本。
5.2 训练集 F1 0.98,测试集只有 0.6
现象:dev F1 一路涨到 0.98,换一份真实业务文本掉到 0.6 以下,混进训练集里的那部分样本又预测得很准。
原因几乎都是切分方式导致的泄漏。常见做法是按文件切,如果同一份文档被切成多段分到 train 和 test,模型就见过了同源上下文;更隐蔽的是同模板样本——一批合同用的是同一份模板,只有姓名和日期不同,随机切分后测试集里的样本和训练集高度相似。
解决:按「实体/来源/时间」做分组切分,同一份文档、同一个模板只出现在一个集合里。如果拿不到来源信息,至少做一遍近似去重(按字符 5-gram 做 Jaccard 相似度,超过 0.8 的归到同一组)。另外留一份「完全外部」的验证集,哪怕只有 200 句,也比你自欺欺人强。
5.3 实体首字或末字总是丢,边界差一个字
现象:模型能把机构名的主体认出来,但经常把首字判成O,或者多带一个「的」「等」;评估时精确率和召回率都很高,F1 却上不去。
原因:一是截断位置切断了实体,truncation=True从右侧硬切,跨在max_len上的实体标签就成了B-ORG后面什么都没有;二是标签体系是 BIO 但解码没加约束,模型输出了起点模糊的I-。
解决:截断前先按标点切句,保证没有实体跨越切分点;打开 CRF(use_crf=True),让维特比解码从结构上约束转移合法性。如果用了 CRF 还丢首字,检查训练时的attention_mask是否传对——mask 传错时模型会看到被 pad 的 dummy 内容,边界判断会退化成随机。
5.4 换了标签体系之后 F1 直接归零
现象:把 BIO 改成 BIOES 重训,loss 正常下降,但评估时seqeval报错或者 F1 是 0。
原因:标签表改了但labels.txt顺序没同步,label2id和模型输出头错位;或者数据还带着旧体系的标签,模型学的是B-开头,解码时按新体系解释,全部错位。
解决:改标签体系是一个原子操作——labels.txt、configs/ner_base.json里的label2id、训练数据里的标签、评估脚本的id2label必须一次性全改,改完用 3.1 的校验脚本重跑一遍。我一般会在训练启动时加一个断言:set(label2id) == set(数据里出现的所有标签),不满足直接报错退出,比训完再查省事得多。
5.5 显存 OOM,batch 调到 1 还是爆
现象:CUDA out of memory,把 batch 降到 1 也不行,但同事同样的卡能跑。
原因:max_len太长(512)导致单样本激活值就很大;或者 CRF 层在seq_len很大时中间张量膨胀(转移分数是[B, L, C, C]量级);也可能是评估时忘了torch.no_grad(),每次前向都在建图。
解决:按顺序试——先把max_len降到 256 甚至 192;评估函数加@torch.no_grad();再不行开torch.cuda.amp混合精度,显存能省 30%~40%;还要大 batch 就用梯度累积(累积 4 步等效 batch ×4,显存不变)。CRF 层在L=512, C=13时中间张量不小,长文本场景建议滑窗而不是硬扛长序列。
6. 想再往上抬两个点:边界、小样本与部署的取舍
模型跑通之后,真正拉开差距的是边界质量和数据效率。这套工程的默认配置能给你一个 0.85 左右的 baseline,往上抬最有效的一招不是换更大的模型,而是只训任务头 + 分层学习率:冻结 BERT 前 8 层,主干学习率 1e-5,任务头 2e-4,2000 句的小样本上通常比全量微调稳,且训练时间减半。第二招是加 FGM 对抗训练,在 embedding 上加扰动再前向一次,对机构名这类形态自由的实体能提 1~2 个点,代价是训练时间翻倍。
| 手段 | 典型增益 | 成本 | 适用条件 |
|---|---|---|---|
| 冻结底层 + 分层 lr | +0.5~1.5 | 训练时间减半 | 样本 < 5000 句 |
| FGM 对抗训练 | +1~2 | 训练慢一倍 | 实体形态多变 |
| BIOES 标签体系 | +1~2(边界) | 改标注与配置 | 长实体、相邻实体 |
| 词典/规则后处理 | +1~3(召回) | 维护成本 | 实体有强形态特征 |
| 蒸馏到小模型 | -0.5~1(精度) | 需教师模型 | 延迟敏感上线 |
验证方法上,我只看一个东西:外部测试集的实体级 macro F1 加每类召回。dev F1 再好看,只要机构名召回低于 0.75,上线就会被业务打回来。所以每次调参我都固定跑一遍外部 200 句,把结果记在一个 CSV 里,看的是趋势而不是单次数值——单次波动 0.5 个点以内基本是随机种子的问题,别急着改配置。
踩过最疼的一次是:本地 dev F1 0.91 就上线了,结果真实业务里「某某科技有限公司」这类长机构名只认出一半,回头查发现是滑窗步长设得太大,实体正好跨在窗口边界。从那以后我每次上模型之前,都强制拿截断位置附近的样本单跑一遍,专门看边界上的实体有没有被切碎。希望帮到你。
本文还有配套的精品资源,点击获取