首页 / 资讯中心 / 文章详情

中文NER工程实战:BERT微调、标签对齐与CRF实体级评估

中文NER工程实战:BERT微调、标签对齐与CRF实体级评估 ★ FEATURED ARTICLE
简介这份压缩包围绕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从数据校验、字级对齐、BERTCRF 训练到分批推理和指标评估全流程都有脚本。我先跑通再逐行拆——它不是玩具 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多花的那点标注成本能换回 12 个点的边界 F1。这套工程默认给的是 BIO脚本里留了label_list的配置位改成 BIOES 只需要改配置加数据转换模型侧不用动。标签体系标签数4 类实体边界显式监督适合场景BIO9无短实体、小样本、快速验证BIOES13有长实体、相邻实体、上线精度优先BMES无类型4有只做实体边界、不做分类注意换标签体系必须同时换训练数据和推理时的解码约束只改一边会得到一堆非法序列F1 直接掉到 0.3 以下。这一点在第 5 章会展开。2.2 为什么是 BERT 微调而不是从零训 BiLSTM-CRFBiLSTM-CRF 是中文 NER 的经典基线结构干净、推理快、显存占用小。但它的软肋在 embedding从零训的词向量在几千句语料上根本学不出「原告」「法定代表人」这类词的语义模型实际上是在背词典。BERT 这类预训练语言模型把这一步省了——它在海量无标注文本上已经学过上下文关系微调阶段只需要教会它「哪些片段属于哪类实体」。代价也很实在一个 12 层、hidden 768 的 BERT序列长度 256、batch 32 的时候16G 显存基本吃满单卡推理一条 200 字文本大概 815ms比 BiLSTM 慢三到五倍。所以选型不是「谁更先进」而是看你的瓶颈在哪方案标注量需求单条推理延迟显存典型用途词典 正则01ms无实体形态固定、量小的场景BiLSTM-CRF 从零训2 万句以上24ms低高频调用、延迟敏感BERT Linear CRF3 千句起815ms高精度优先、可离线批处理蒸馏/小层数 BERT3 千句起47ms中上线后要压延迟我的实际做法是先用 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 torch2.1.2 --index-url https://download.pytorch.org/whl/cu118 pip install transformers4.36.2 seqeval1.2.2 numpy1.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, encodingutf-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 (fB-{typ}, fI-{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_wordsTrue喂给 tokenizer这样能用word_ids()精确还原每个 token 属于第几个字。第二种是直接编码整句靠word_ids()里None的位置特殊符号和重复的 word_id一个字的多个 subword来对齐一个字的第一个 subword 保留原标签后续 subword 和特殊符号一律置-100让损失函数忽略它们。# dataset.pytokenizer 编码 标签对齐核心是 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_len256): # 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_wordsTrue, # 关键告诉 tokenizer 输入已经是切好的 truncationTrue, max_lengthself.max_len, paddingFalse, # padding 交给 collate 动态做 return_tensorsNone, ) 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_wordsTrue是整段代码的前提不加它 tokenizer 会把字列表当成一个字符串重新切word_ids()直接失效。truncationTrue配max_length会从右侧截断如果实体正好跨在截断点上这句样本的尾部实体就成了残缺标签——我一般在校验阶段就把超长句按标点切开而不是靠截断兜底。paddingFalse是有意的每条样本长度不同在这里 pad 到固定长度会浪费大量显存交给collate_fn按 batch 内最大长度 pad 更划算。prev_wid那几行是对齐的核心word_ids()里同一个字的所有 subword 拿到相同的 wid只有第一次出现时才取标签后面的置-100。这样即使某个字被切成三个 token损失里也只算一次不会因为切分粒度不同导致某些字被过度加权。注意-100是 PyTorchCrossEntropyLoss的默认ignore_index不是随便挑的魔法数。如果你在别处改了ignore_index这里必须同步改否则会被当成一个真实类别参与训练。3.3 动态 padding 与 collate_fncollate_fn是 DataLoader 和模型之间的适配层负责把长度不一的样本 pad 成同一个矩形张量同时产出attention_mask。BERT 的注意力必须靠 mask 屏蔽 padding 位否则 pad 的[PAD]token 会参与注意力计算短句受影响尤其明显。# dataset.py按 batch 内最大长度动态 pad import torch def collate_fn(batch, pad_id0): 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, dtypetorch.long)])) attention_mask.append(torch.cat([x[attention_mask], torch.zeros(pad, dtypetorch.long)])) token_type_ids.append(torch.cat([x[token_type_ids], torch.zeros(pad, dtypetorch.long)])) # labels 补 -100保证 pad 位不计损失 labels.append(torch.cat([x[labels], torch.full((pad,), -100, dtypetorch.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_id0对应 BERT 词表里的[PAD]这一点不同权重可能不同配之前查一下vocab.txt第一行。attention_mask补 0、token_type_ids补 0、labels补-100三个补齐值不能混用mask 补错会让模型注意到 paddinglabels 补错会把 pad 当成O类参与训练直接拉低召回。3.4 模型结构BERT dropout linear CRF结构本身不复杂关键在输出头和 CRF 的接线方式。BERT 输出[batch, seq_len, 768]接一个 dropout 防过拟合再接一个Linear(768, num_labels)得到发射分数emission最后可选地接一层 CRF 做序列级解码。# model.pyBERT 编码 线性发射 可选 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_crfTrue, dropout0.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_firstTrue) def forward(self, input_ids, attention_mask, token_type_ids, labelsNone): out self.bert(input_idsinput_ids, attention_maskattention_mask, token_type_idstoken_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, maskmask, reductionmean) # 返回 loss return self.crf.decode(emissions, maskmask) # 推理返回标签 id 列表这里有个必须知道的细节torchcrf不接受-100这种越界标签所以训练时要先masked_fill成 0再靠 mask 把这些位置排除在转移路径之外。如果不换程序会直接抛索引越界如果换了但忘了传 maskpad 位会被当成真实标签参与转移矩阵计算loss 会虚高且不收敛。use_crf关掉时外部用CrossEntropyLoss(ignore_index-100)算损失训练更快、更容易调但推理阶段可能输出非法序列比如I-ORG出现在O后面。CRF 的价值就在这里它在解码时用维特比算法约束转移合法性从结构上消灭这类错误。我一般先用use_crfFalse跑通全流程确认数据链路没问题再打开 CRF 做正式训练——两个阶段分开调出问题好定位。4. 训练与评估超参怎么定、指标看哪个模型能 forward 只是开始这套工程真正值钱的是训练脚本里的几个默认值和评估口径。NER 是个对超参相对宽容但对评估口径极度敏感的任务同一个模型用 token 级 accuracy 评估能报 0.97用实体级 F1 评估可能只有 0.72。4.1 超参表这几个数不要凭感觉改参数默认值取值范围说明learning_rate2e-51e-5 5e-5全量微调首选 2e-5超过 5e-5 容易崩batch_size168 32显存允许就往大调配合 lr 线性缩放epochs53 8看 dev F1 早停别硬跑满max_len256128 512覆盖 95% 样本长度即可warmup_ratio0.10.05 0.1防止前几百步把预训练权重带偏weight_decay0.010 0.01只作用于非 bias、非 LayerNorm 参数max_grad_norm1.00.5 1.0防梯度爆炸CRF 层尤其需要dropout0.10.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, lr2e-5, weight_decay0.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_norm1.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()每步调用一次不是每 epochwarmup 步数按「总步数 × warmup_ratio」算总步数是len(loader) * epochs这个数算错了 warmup 会跑偏。早停逻辑按 dev 集实体级 F1 存最优权重而不是按 dev loss。loss 低但 F1 掉的情况很常见尤其是实体稀疏的类别——模型学会了把稀有实体全预测成O来降低平均损失。保存时我一般只存state_dict加label2id不存整个模型对象避免版本升级后加载失败。4.3 评估口径必须用实体级 P/R/F1seqeval的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, digits4)) return f1_score(golds, preds) # 默认 macro 平均-100的跳过逻辑必须和训练时完全一致否则评估会把 padding 位算成O虚高精确率。f1_score默认是 macro 平均也就是每类 F1 求平均——这在类别极不均衡时更有参考价值因为数量少的类别不会被O淹没如果业务只关心某一类就单独看classification_report里那一行。一个实测经验这套工程在 5000 句左右的四类实体语料上BERT CRF 训练 4 个 epochdev 集 macro F1 大概能到 0.850.90 区间其中人名、地名通常最高机构名因为形态自由简称、全称、连写会低 36 个点。如果你跑出来人在 0.6 附近先别怀疑模型回到第 3 章查数据链路。4.4 推理脚本长文本滑窗与结果合并线上文本八成超过max_len直接截断会丢掉后半段的实体。工程里predict.py用滑窗切分窗口 200 字、步长 160 字重叠 40 字每窗独立预测再按原位置把标签拼回去重叠区以「完整实体优先」的规则合并。# predict.py滑窗推理重叠区按实体完整性合并 def sliding_predict(text, model, tok, id2label, device, win200, stride160, max_len256): 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_tensorspt, max_lengthmax_len, truncationTrue) 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_len256留足余量。5. 避坑与排查五条真金白银换来的记录这套代码我在三类语料上跑过前两次都是先上线再返工。下面五条是排查时最常撞上的每条按「现象 → 原因 → 解决」记遇到问题可以对号入座。5.1 训练 loss 从第一个 epoch 就不动预测全是 O现象train loss卡在 0.4 附近几个 epoch 纹丝不动dev 集预测出来全是O实体级 F1 接近 0。原因通常有两个。一是标签对齐时把所有位置都置成了-100——比如is_split_into_wordsTrue忘了加word_ids()全部返回None于是整个 batch 的 loss 变成 0 或 nan模型根本没在学。二是学习率给太大第一波梯度就把 BERT 权重推坏了之后的梯度方向全是噪声。解决先检查labels里非-100的比例正常应该在 95% 以上如果全是-100回到 3.2 节查 tokenizer 调用。如果比例正常把学习率降到 1e-5 重跑并在前 20 步打印梯度范数clip_grad_norm_返回值正常量级在 0.55 之间如果范数动辄几十上百说明 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 却上不去。原因一是截断位置切断了实体truncationTrue从右侧硬切跨在max_len上的实体标签就成了B-ORG后面什么都没有二是标签体系是 BIO 但解码没加约束模型输出了起点模糊的I-。解决截断前先按标点切句保证没有实体跨越切分点打开 CRFuse_crfTrue让维特比解码从结构上约束转移合法性。如果用了 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 显存 OOMbatch 调到 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 层在L512, C13时中间张量不小长文本场景建议滑窗而不是硬扛长序列。6. 想再往上抬两个点边界、小样本与部署的取舍模型跑通之后真正拉开差距的是边界质量和数据效率。这套工程的默认配置能给你一个 0.85 左右的 baseline往上抬最有效的一招不是换更大的模型而是只训任务头 分层学习率冻结 BERT 前 8 层主干学习率 1e-5任务头 2e-42000 句的小样本上通常比全量微调稳且训练时间减半。第二招是加 FGM 对抗训练在 embedding 上加扰动再前向一次对机构名这类形态自由的实体能提 12 个点代价是训练时间翻倍。手段典型增益成本适用条件冻结底层 分层 lr0.51.5训练时间减半样本 5000 句FGM 对抗训练12训练慢一倍实体形态多变BIOES 标签体系12边界改标注与配置长实体、相邻实体词典/规则后处理13召回维护成本实体有强形态特征蒸馏到小模型-0.51精度需教师模型延迟敏感上线验证方法上我只看一个东西外部测试集的实体级 macro F1 加每类召回。dev F1 再好看只要机构名召回低于 0.75上线就会被业务打回来。所以每次调参我都固定跑一遍外部 200 句把结果记在一个 CSV 里看的是趋势而不是单次数值——单次波动 0.5 个点以内基本是随机种子的问题别急着改配置。踩过最疼的一次是本地 dev F1 0.91 就上线了结果真实业务里「某某科技有限公司」这类长机构名只认出一半回头查发现是滑窗步长设得太大实体正好跨在窗口边界。从那以后我每次上模型之前都强制拿截断位置附近的样本单跑一遍专门看边界上的实体有没有被切碎。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站