☰
中文NER BERT微调:标签对齐、实体级F1与推理优化
2026/11/6 2:09:09 网站建设 项目流程

简介:面向具备Python基础、希望进入自然语言处理实战的学习者与开发者,这份项目包以预训练BERT模型完成中文命名实体识别为主线,可用于人名、地名、机构名等实体抽取的课程复现、小规模实验与序列标注入门。压缩包共9个文件、约3.72MB,包含4个txt语料及词表、2个py模型与指标脚本、1个png效果图、1个pl评估脚本和1个md说明文档,兼顾数据准备、代码实现、结果查看与评测复现;目录围绕数据、模型、评测与说明组织,便于按模块检索和二次修改。已有3349人学习下载,适合用来对照理解BERT的双向编码器、MLM/NSP预训练思想以及中文输入处理。读者可从中掌握IOB标注、特殊令牌添加、序列填充、模型微调、AdamW优化和Precision/Recall/F1评估等环节,并借助现成脚本跑通训练与验证流程,形成对中文NER项目的完整认识。

1. BERT 做中文 NER:为什么“直接微调”往往跑不出可用效果

用 Python 做自然语言处理里的中文 NER,最省事的起点就是拿一份 BERT 预训练语言模型权重,再接一个BertForTokenClassification头。二十几行代码就能跑起来,训练 loss 一路降到 0.1 以下,看着很美好。但把模型拉到真实标注集上评一遍,实体级 F1 经常只有 0.6 出头,比规则加词典的基线高不了多少,这时候大多数人第一反应是“模型不够强、数据不够多”,然后去换更大的权重、加更多数据,结果还是原地踏步。

问题几乎不在模型本身。中文 BERT 的切词单元和你手工标的字级标签之间,存在一层容易出错的对齐;评估时用的是 token 级准确率还是实体级 F1,差出来的分数能有二十个点;长实体被max_length从中间截断,模型学到的边界永远是错的。这份资源的价值就在这儿:它把“预训练权重 → 字符级标签 → 可训练张量 → 可解释指标”这条链路上的每一环都摆出来了。适合两类人:一是刚接手中文信息抽取任务、手里有几千条标注想快速出效果的;二是模型能跑但指标不达标、需要定位问题出在哪一环的。

2. 标签体系与字符级对齐:BIO 序列怎么变成模型能吃的张量

2.1 标签体系怎么定:BIO、BIOES 与实体类型的取舍

中文 NER 的标签集不是拍脑袋定的,它直接决定分类头的输出维度,也决定后面评估脚本怎么解析。最常见的是 BIO:B-表示实体首字,I-表示实体内部和结尾,O表示非实体。假设你要抽四类实体——人名、机构、地点、其他专名,标签总数就是1 + 2 × 4 = 9,分类头输出 9 维。

BIOES 把边界信息拆得更细:B-首、I-中、E-尾、S-单字实体,标签数变成1 + 4 × 4 = 17。理论上 BIOES 能消解“相邻同类实体”的歧义,比如“某工程师某导师”连着出现,BIO 标出来是B-PER I-PER B-PER I-PER,勉强能分;但如果标错一个就成了一个长实体。BIOES 里第二个实体以S-PER或B-PER起头,边界是硬约束。

但我的实际取舍是:样本少于五千句,先用 BIO。原因是E-、S-这两类标签在短实体为主的中文语料里出现频次很低,模型对稀疏标签的学习效率差,经常出现“实体首字标对了、尾字标成I-”的软边界,评估时按严格匹配算,反而比 BIO 低一两个点。等到数据量上万、且实体平均长度超过四个字,再切 BIOES 才有正收益。

还有一条容易被忽略的:实体类型别开太多。见过一个方案把日期、时间、金额、百分比、电话号码全塞进一个模型,十几类实体、三十多維输出,最后MISC类和O类互相抢概率,F1 惨不忍睹。常见做法是按业务拆成两三个模型,或者把长尾类型合并成MISC,先把主干跑通。

方案标签数(4 类实体)适用场景主要代价
BIO9样本 < 5k、实体偏短相邻同类实体有歧义
BIOES17样本 > 10k、长实体多稀疏标签学习慢
BMES(不分类型)5只做实体边界识别丢失类型信息

2.2 用 word_ids 做字符到子词的对齐

这是整条链路上最容易出错的地方,也是最值得先写对的地方。中文 BERT 的分词器以单字为主,一句话“某工程师上月调入第二研发组”基本是一字一 token,看着好像天然对齐,于是很多人直接labels = [label2id[l] for l in char_labels]就完事了。跑起来不报错,训练也收敛,但指标就是上不去。

翻车点在于:分词器会在句首加[CLS]、句尾加[SEP],这两位的标签必须是-100(在交叉熵里被忽略),否则模型会被训练成“每句话第一个 token 都是 O”;句子里的阿拉伯数字、英文缩写、连续标点会被合并成一个 token;少数字符会被拆成带##的子词。只要出现一次合并,后面所有标签就整体错位一位,而错位是静默的——loss 照降,指标照烂。

正确做法是用 fast 分词器的word_ids(),它返回每个 token 对应到输入词序列的下标,特殊 token 返回None。把原文按字符切成列表传进去,word_ids()返回的就是字符下标。

from transformers import AutoTokenizer MODEL_DIR = "./weights/chinese-bert-base" # 换成本地权重目录或仓库标识 tokenizer = AutoTokenizer.from_pretrained(MODEL_DIR, use_fast=True) text = "某工程师上月调入第二研发组" char_labels = [ "B-PER", "I-PER", "I-PER", "I-PER", # 某工程师 "O", "O", "O", "O", # 上月调入 "B-ORG", "I-ORG", "I-ORG", "I-ORG", "I-ORG", # 第二研发组 ] label_list = ["O", "B-PER", "I-PER", "B-ORG", "I-ORG", "B-LOC", "I-LOC"] label2id = {l: i for i, l in enumerate(label_list)} # is_split_into_words=True 让分词器按“词”处理,这里的一个“词”就是一个汉字 enc = tokenizer( list(text), is_split_into_words=True, max_length=64, truncation=True, return_offsets_mapping=True, ) word_ids = enc.word_ids() print(word_ids) # 预期形如 [None, 0, 1, 2, 3, 4, ..., 12, None]

拿到word_ids之后,再把字符级标签映射过去。这里有个策略选择:一个字符被拆成多个子词时,后续子词标-100还是继承首子词的标签。我一般标-100,只让首子词承担监督信号,避免同一个字被重复计入 loss 造成权重倾斜;如果实体边界恰好落在被拆的子词中间(英文缩写常见),那这个实体本身就标不干净,应该回到标注环节修数据,而不是在代码里打补丁。

def align_labels(char_labels, word_ids, label2id, ignore_index=-100): """把字符级 BIO 标签对齐到子词级;特殊 token 与后续子词填 -100""" aligned, prev_wid = [], None for wid in word_ids: if wid is None: # [CLS] / [SEP] / [PAD] aligned.append(ignore_index) elif wid != prev_wid: # 该字符的第一个子词,承担监督 aligned.append(label2id[char_labels[wid]]) else: # 同一字符的后续子词,不参与 loss aligned.append(ignore_index) prev_wid = wid return aligned labels = align_labels(char_labels, word_ids, label2id) assert len(labels) == len(word_ids)

参数上要盯住两个:max_length和truncation。中文 NER 的实体很少超过 15 个字,但句子可能很长,max_length=128覆盖绝大多数新闻短句,长文档就得走第 4 章的滑窗。truncation=True默认从右侧截断,如果原文实体正好跨在截断点上,这条样本就是脏的,稳妥做法是预处理阶段先按标点切句,保证切完每段都短于max_length,从源头避免截断。

2.3 组 batch:collate_fn 与 -100 的传播

单条样本处理好之后,要让一个 batch 里长度不一的序列能拼成矩阵。HuggingFace 提供了DataCollatorForTokenClassification,它按 batch 内最大长度做 padding,input_ids 补 pad_token_id,labels 补-100,attention_mask 补 0。这三件事它一次做完,不用自己写。

from transformers import DataCollatorForTokenClassification collator = DataCollatorForTokenClassification( tokenizer=tokenizer, label_pad_token_id=-100, # 必须和 loss 的 ignore_index 一致 pad_to_multiple_of=8, # 对齐到 8 的倍数,GPU 上略快 )

pad_to_multiple_of是个小技巧,把序列长度补齐到 8 或 16 的倍数,张量核心的利用率会高一些,代价是多算几个 pad 位置,实测在 128 长度下能省 5% 左右的训练时间。label_pad_token_id一定要显式写成-100,如果误写成 0(也就是O标签的 id),模型会在一堆 padding 上学习“这里不是实体”,短句子多的 batch 会被严重稀释。

如果要做标签平滑或者类别加权,得自己在 collator 外面包一层。加权交叉熵在中文 NER 上确实有用——O标签通常占 85% 以上,不加权的话模型倾向于全预测O。常见做法是统计训练集每个标签的频次,按1 / sqrt(freq)算权重,再把权重传给CrossEntropyLoss(weight=..., ignore_index=-100)。注意ignore_index位置的权重会被忽略,不用担心把-100对应的权重算进去。

3. 微调脚本:从 BertForTokenClassification 到可复现的训练循环

3.1 主干选型:base、整词掩码与领域继续预训练

中文预训练权重不止一个版本,选错了白费半天算力。最基础的是中文 BERT base:12 层 Transformer、768 隐层、12 个注意力头,参数量约 1.1 亿。它用单字掩码训练,对中文这种没有空格分词的语言其实挺合适,作为基线足够。

往上有整词掩码(whole word masking)版本,训练时把整个词一起遮住而不是遮单字,模型被迫学词级别的语义组合。在中文 NER 上,整词掩码版本对实体边界通常有一到两个点的提升,因为它的表示里天然带了“词”的概念。代价是权重文件和词表略有差异,加载时别混用 tokenizer。

再往上是领域继续预训练。如果你做的是医疗、法律、金融这类垂直领域的实体抽取,通用权重对领域术语的表示很弱,拿几百万字无标注领域文本在 base 权重上再做一轮 MLM,通常比直接微调多两三个点。这一步成本不高,单卡跑一两天能出效果,但要小心学习率别开大,1e-5量级、训一两个 epoch 就够,训多了会把通用语义冲掉。

主干相对成本预期收益什么时候值得上
中文 BERT base1x基线先跑通,拿到对照数
整词掩码版1x+1~2 点 F1数据量 5k 句以上
领域继续预训练5~10x+2~3 点 F1垂直领域、术语多
更大参数量版本3x+1~3 点 F1显存够、延迟不敏感

我的建议是永远先跑 base,把完整链路和指标基线固定下来,再逐个替换主干做对照。跳过基线直接上大模型,后面指标波动你根本分不清是模型变强了还是随机种子变了。

3.2 训练脚本骨架

下面这套是基于Trainer的最小可跑版本,包含数据加载、指标计算和早停。手写 PyTorch 循环当然更灵活,但要自己处理混合精度、梯度累积、断点续训,除非有特殊需求,否则没必要。

import numpy as np import torch from datasets import Dataset from transformers import ( AutoTokenizer, AutoModelForTokenClassification, DataCollatorForTokenClassification, TrainingArguments, Trainer, ) from seqeval.metrics import f1_score, precision_score, recall_score MODEL_DIR = "./weights/chinese-bert-base" label_list = ["O", "B-PER", "I-PER", "B-ORG", "I-ORG", "B-LOC", "I-LOC"] label2id = {l: i for i, l in enumerate(label_list)} id2label = {i: l for l, i in label2id.items()} tokenizer = AutoTokenizer.from_pretrained(MODEL_DIR, use_fast=True) model = AutoModelForTokenClassification.from_pretrained( MODEL_DIR, num_labels=len(label_list), id2label=id2label, label2id=label2id, ) def encode(example): """example: {"chars": [...], "labels": [...]},均为字符级""" enc = tokenizer(example["chars"], is_split_into_words=True, max_length=128, truncation=True) enc["labels"] = align_labels(example["labels_chars"], enc.word_ids(), label2id) return enc def compute_metrics(eval_pred): logits, labels = eval_pred preds = np.argmax(logits, axis=-1) true_list, pred_list = [], [] for p_row, l_row in zip(preds, labels): t_seq, p_seq = [], [] for p, l in zip(p_row, l_row): if l == -100: # 跳过特殊 token 与 padding continue t_seq.append(id2label[l]) p_seq.append(id2label[p]) true_list.append(t_seq) pred_list.append(p_seq) return { "precision": precision_score(true_list, pred_list), "recall": recall_score(true_list, pred_list), "f1": f1_score(true_list, pred_list), } args = TrainingArguments( output_dir="./ckpt-ner", learning_rate=3e-5, per_device_train_batch_size=32, per_device_eval_batch_size=64, num_train_epochs=10, warmup_ratio=0.1, weight_decay=0.01, eval_strategy="epoch", save_strategy="epoch", load_best_model_at_end=True, metric_for_best_model="f1", greater_is_better=True, fp16=torch.cuda.is_available(), logging_steps=50, seed=42, ) trainer = Trainer( model=model, args=args, train_dataset=train_ds, # 已 map(encode) 的 Dataset eval_dataset=dev_ds, data_collator=DataCollatorForTokenClassification( tokenizer=tokenizer, label_pad_token_id=-100), compute_metrics=compute_metrics, ) trainer.train()

几个参数值得单独说。learning_rate=3e-5是 BERT 微调的经验区间,2e-5 到 5e-5 之间都合理,超过 1e-4 基本必崩,表现为 loss 前几十步剧烈震荡然后卡在一个高位。warmup_ratio=0.1让学习率在前 10% 的步数里线性爬升,中文 NER 的 batch 通常只有几十,没有 warmup 的话前几个 step 的大梯度会把预训练权重冲坏。weight_decay=0.01只作用在非 bias、非 LayerNorm 的参数上,Trainer默认就是这么分组的,不用手动改。

fp16在 30 系以上的卡上开了没坏处,显存能省将近一半,batch 可以翻倍。但要注意如果出现 loss 变 NaN,先关掉 fp16 确认是不是混合精度的问题,而不是急着调学习率。

3.3 超参:学习率、batch、warmup 与 dropout

超参搜索不用铺太开,中文 NER 的敏感度排序大致是:学习率 > batch size > warmup > dropout。学习率差一个数量级,结果能差十个点;batch 从 16 到 32 通常只差零点几个点,但会明显影响收敛步数。

参数推荐区间调整信号说明
learning_rate2e-5 ~ 5e-5loss 震荡 → 调小;收敛慢 → 调大最敏感,优先调
batch_size16 ~ 64显存允许就往大取大 batch 需同步调大 lr
warmup_ratio0.05 ~ 0.15前 100 步 loss 飙升 → 调大小数据集取 0.1
num_epochs3 ~ 10看 dev F1 拐点必配早停
dropout0.1 ~ 0.3dev 远低于 train → 调大分类头 dropout 也生效
max_length128 / 256看实体被截断比例中文短句 128 够

数据量小的时候(比如两千句以内),把num_train_epochs开到 20 配load_best_model_at_end也没关系,早停会兜住。但要注意eval_strategy设成epoch时,每个 epoch 都存一次检查点,磁盘会迅速被占满,save_total_limit=2记得加上。

3.4 评估口径:seqeval 的实体级 F1

这是第二个血泪经验密集区。用sklearn的f1_score按 token 算,得到的是微平均的 token 级指标;一个四字实体,边界错了一个字,token 级只扣 1/4,看起来还挺高。而线上业务关心的是“这个实体有没有完整抽对”,必须用实体级严格匹配。

seqeval就是干这个的,它按 CoNLL 的conlleval规则解析 BIO 序列:一个实体只有起止位置和类型全部一致才算 TP。这也是为什么compute_metrics里要先过滤-100再把 id 映射回标签字符串——seqeval只认["B-PER", "I-PER", ...]这种格式,喂 id 进去会直接报错或者算出莫名其妙的值。

from seqeval.metrics import classification_report # true_list / pred_list 均为 List[List[str]] print(classification_report(true_list, pred_list, digits=4))

输出的报告里,macro avg和weighted avg要分开看。macro是各类实体 F1 的算术平均,小样本实体(比如只有几十个的LOC)会显著拉低它;weighted按样本数加权,更能反映整体水平。如果macro比weighted低很多,说明有长尾实体类型没学好,要么补数据,要么合并类型。我一般两个都记,对外报weighted,对内看macro找短板。

还有个小坑:seqeval对非法的标签序列(比如I-PER开头、或者B-PER后跟I-ORG)有自己的容错解析,不同版本行为还不完全一致。所以评估前先把预测序列修一遍(第 4 章有具体做法),保证喂进去的都是合法序列,否则两次跑出来的数对不上,你会怀疑是模型不稳定。

4. 推理落地:长文本滑窗、实体解码与批量加速

4.1 滑窗切分与重叠合并

训练时句子长度受max_length限制,推理时输入可能是整篇文档。直接截断会丢掉后半段,直接放大max_length又会爆显存且位置编码外推效果差。标准解法是滑窗:把长文本切成固定长度的窗口,相邻窗口留一段重叠,每个窗口单独推理,再把结果拼回去。

def sliding_windows(token_ids, max_len=510, stride=128): """返回 [(start, end), ...],窗口长度 max_len,相邻重叠 stride""" if len(token_ids) <= max_len: return [(0, len(token_ids))] windows, start = [], 0 while start < len(token_ids): end = min(start + max_len, len(token_ids)) windows.append((start, end)) if end == len(token_ids): break start = end - stride # 回退 stride 个 token 形成重叠 return windows

max_len=510是给[CLS]和[SEP]留两位,stride=128保证一个实体不会被窗口边界正好劈开——只要实体长度小于stride,它至少会在某一个窗口里完整出现一次。合并时按“先到先得”或者“取重叠区里概率更高的那个”都行,我用后者:对重叠区每个位置比较两个窗口的 max logit,取大的那个标签,避免边界处的软预测互相打架。

注意切窗最好在字符级做,而不是在 token 级。中文虽然基本一字一 token,但数字和英文会被合并,按 token 切可能把一个英文实体劈成两半,合并阶段还原不回来。稳妥流程是:先按字符切窗 → 每个窗口单独 tokenize → 推理 → 结果映射回字符 → 拼回原文本。

4.2 非法标签序列的修复

模型输出是每个位置独立的 argmax,不保证序列合法。常见的非法模式有三种:I-X出现在句首或O之后;B-X后面跟I-Y(类型不一致);B-X后面直接跟B-X(其实这是合法的,表示两个相邻同类实体)。

修复逻辑很简单,按顺序扫一遍:

def fix_bio(tags): """修复非法 BIO 序列,返回合法序列""" fixed = [] for i, tag in enumerate(tags): if tag == "O": fixed.append(tag) continue prefix, etype = tag.split("-", 1) if prefix == "I": prev = fixed[-1] if fixed else "O" # 前一个是 O,或前一个实体类型不同,则降级为 B- if prev == "O" or prev.split("-", 1)[1] != etype: fixed.append(f"B-{etype}") continue fixed.append(tag) return fixed

这段只处理I-的降级,不处理更复杂的情况。如果想更彻底,可以在模型上加一层 CRF,让解码阶段就保证序列合法,pytorch-crf配合BertForTokenClassification改几行就够,代价是训练慢 10% 左右、推理不能用 batch 并行。我的取舍是:实体类型少、序列短的场景用 CRF;类型多、追求吞吐的场景用上面的后处理修复,效果差别在 0.3 个点以内。

4.3 批量推理与 ONNX 加速

线上服务最关心的是 QPS 和尾延迟。PyTorch 原生推理在单条请求上大约几十毫秒(128 长度、单卡),量上来之后必须做三件事:批量、半精度、导出静态图。

import torch model.eval() model.half() # fp16 推理,显存减半、速度提升明显 @torch.no_grad() def batch_predict(texts, batch_size=32, max_len=128): results = [] for i in range(0, len(texts), batch_size): chunk = texts[i:i + batch_size] enc = tokenizer(chunk, max_length=max_len, truncation=True, padding=True, return_tensors="pt") enc = {k: v.to("cuda") for k, v in enc.items()} logits = model(**enc).logits preds = logits.argmax(-1).cpu().numpy() for row, mask in zip(preds, enc["attention_mask"].cpu().numpy()): seq = [id2label[p] for p, m in zip(row, mask) if m == 1] results.append(fix_bio(seq[1:-1])) # 去掉 [CLS] 与 [SEP] return results

padding=True会按 batch 内最长补齐,比逐条推理快三到五倍。model.half()之后要注意输入也得转 fp16,HuggingFace 的模型会自动跟随参数类型做转换,但如果手写了自定义层就要自己处理。再往上就是导出 ONNX 用onnxruntime跑,或者用torch.compile做图优化,前者在 CPU 部署上收益最大,后者在较新的 GPU 上能再省 20% 左右。

5. 排查:F1 卡在 0.6 的五个典型现场

5.1 训练 loss 一路降到 0.05,评估 F1 还是 0.62

现象:训练集 loss 收敛得很漂亮,验证集 F1 从第三个 epoch 开始就不动了,加数据、换模型都没用。

原因:九成是标签对齐错位。中文里有全角标点、数字、字母缩写,分词器会把这些合并或拆分,如果没有用word_ids()做映射而是直接按字符平铺标签,从第一个合并 token 开始后面全部错位一位。错位之后模型学的是“位置 n 的标签等于位置 n-1 的内容”,在自己训练集上能靠记忆拟合,到验证集立刻露馅。验证方法很简单:随便挑十条样本,把input_ids解码出来和labels逐位对照打印,错位一眼就能看出来。

解决:统一走 2.2 节的align_labels,并在数据预处理完成后加一条断言——len(labels) == len(input_ids)且非-100的标签数量等于原文字符数。这条断言我建议一直留着,成本几乎为零。

5.2 准确率 0.93,看着挺好,实体级 F1 只有 0.4

现象:训练日志里 accuracy 一路涨到 0.93,团队汇报时被夸了半天,上线后业务方说“大部分实体都抽不出来”。

原因:O标签占比太高。中文新闻语料里,非实体字符通常占 88% 到 92%,模型全预测O就能拿到 0.9 的 token 级准确率,但它一个实体都没抽出来。accuracy 这个指标在极端不平衡的序列标注任务上基本没有参考价值。

解决:训练日志里只保留实体级 precision / recall / F1,把 accuracy 从回调里删掉。同时监控每个实体类型的召回,如果某一类的 recall 长期低于 0.3,说明样本太少,补数据比调参有效。

5.3 验证集 F1 比本地手测高十个点

现象:Trainer报的 F1 是 0.85,自己拿几条真实文本跑推理,肉眼数下来一半是错的。

原因:验证集和训练集同分布,而真实文本里有大量训练集没覆盖的模式——新出现的机构后缀、带括号的别名、中英混排。另一个更隐蔽的原因是评估脚本里的-100过滤写错了,把 padding 位置也当成了有效标签参与计算。

解决:单独留一份“脏数据”测试集,从线上真实请求里随机抽两百条人工标一遍,不参与任何调参,只用来做最终验收。这份集合的分数通常比验证集低五到十个点,那才是真实水平。

5.4 同一个实体在长文档里只抽出前半截

现象:短句推理正常,整篇文档输入时实体被从中间切断,比如“第二研发组”只输出了“第二研”。

原因:推理阶段偷懒直接truncation=True, max_length=512,超过长度的部分被丢掉;或者做了滑窗但stride设得太小,实体正好跨在两个窗口的边界上,两边各出一半。

解决:stride至少设成最大实体长度的一点五倍,中文 NER 一般 128 够用;合并阶段用 logit 大小决定重叠区归属,而不是简单的后者覆盖前者。另外在预处理阶段就按句号、分号切句,把超长文档拆成短句再推理,比事后合并更省事。

5.5 换了个随机种子,F1 上下浮动三个点

现象:同样的代码、同样的数据,跑三次得到 0.71、0.68、0.74,不知道哪个才是真实水平。

原因:小数据集上微调 BERT 的方差本来就大。两千条样本、batch 32,一个 epoch 只有六十几个 step,初始化的微小差异会被放大。再加上Trainer默认不开确定性,cuDNN 的算法选择本身就有随机性。

解决:固定seed、data_seed,设置torch.backends.cudnn.deterministic = True,然后至少跑三个种子取均值。如果三个种子的标准差超过两个点,说明数据量不够或者学习率太高,先解决这两个问题再谈模型优化。

6. 效果验证:用小样本对照实验锁定提分点

调参调到后期最容易失控——同时改学习率、改标签体系、加 CRF,最后指标涨了也不知道是谁的功劳。我的做法是固定一个对照清单,每次只动一个变量,跑完记一行。清单大概长这样:

实验编号变量设置dev F1结论
E0基线base 权重 + BIO + lr 3e-50.712对照基准
E1主干整词掩码版0.729保留
E2标签体系BIOES0.706数据量不够,弃用
E3损失类别加权 CE0.741保留
E4解码加 CRF 层0.738提升有限,看吞吐取舍
E5数据补 800 条 ORG 样本0.763收益最大

跑这张表有几个纪律。第一,每行只改一个变量,E1 换了主干就不能同时改学习率。第二,每个配置至少跑两个种子取均值,否则零点几个点的差异分不清是真实提升还是噪声。第三,永远保留 E0,任何时候结果反常都回去重跑 E0,确认环境和数据没被污染。

验证环节还有一件值得做的事:把错误样本按类型归类。抽一百条预测错的,分成“边界错”“类型错”“完全漏抽”“多抽”四类,各自占比多少。我踩过一次坑,花了两周调模型结构,最后发现 60% 的错误是“类型错”——标注规范里ORG和MISC的界定本来就模糊,两个标注员一致性只有 0.7。这种情况下模型再怎么调也突破不了标注天花板,正确动作是回去修标注指南、做一致性校验。

从那以后,我每次接手一个 NER 任务,都强制先跑一遍标注一致性检查:抽 200 条让两个人独立标,算 Cohen's kappa,低于 0.8 就先停下修规范,不碰模型。模型的 F1 上限就是标注质量,这条线不抬起来,后面所有调参都是在噪声里找信号。希望帮到你。

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

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

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

立即咨询