简介这份资源面向自然语言处理初学者与算法工程师围绕命名实体识别任务讲解如何基于BERT中文预训练模型进行微调落地。内容从实体类型定义入手覆盖地址、书籍、公司、游戏、政府、电影、姓名、组织、职位、场景共10类实体并给出B-/I-标签编码与id2label、label2id映射的完整实现随后演示BertTokenizer中文分词、input_ids、token_type_ids、attention_mask三项输入构造以及分词后标签对齐等易错细节最后延伸至线性分类层搭建、损失函数与优化器选择、准确率召回率F1评估及新文本实体抽取应用。资源包为1个PDF文档约545KB以图文与代码片段结合的方式呈现便于边看边复现。目前已有1242人学习下载适合希望快速掌握BERT微调NER全流程、理解标签对齐与分词机制的读者参考。1. 命名实体识别为什么总在业务语料上翻车命名实体识别NER是 NLP 落地里最容易被低估的任务。很多人拿 BERT 预训练模型在公开数据集上跑出 95% 的 F1兴冲冲迁到自己的业务语料结果掉到 70% 出头甚至更低。问题不在 BERT而在「预训练分布」和「业务分布」之间的鸿沟公开语料是新闻、百科业务语料是工单、合同、聊天记录实体边界、嵌套方式、标点习惯全不一样。这篇笔记讲的就是怎么用 BERT 预训练模型微调把 NER 从「demo 能跑」做到「业务能用」。适合两类人一类是刚接触 NER、想跑通一条完整链路的工程师另一类是在业务里踩过坑、想搞清楚参数和边界的老手。我会按「数据怎么标 → 模型怎么选 → 训练怎么调 → 坑在哪」的顺序讲每一步都给可复现的命令和参数不绕弯子。2. 从标注到 BERT 微调一条能跑通的 NER 链路2.1 先想清楚标注体系再动手标数据NER 的标注体系决定了后面所有环节。常见的有 BIO、BIOES、BMES 三种。BIO 最简单B-XXX 表示实体开头I-XXX 表示实体内部O 表示非实体。BIOES 多了 E-XXX结尾和 S-XXX单字实体能更精确地表达边界但标注成本高。我一般建议如果实体大多是两字以上、边界清晰用 BIO 就够如果实体里有大量单字实体比如人名里的单姓、地名里的简称BIOES 更稳。下面是一个 BIO 标注的 JSON 样例每行一个 token 和对应标签{ tokens: [张, 三, 在, 北, 京, 签, 了, 合, 同], labels: [B-PER, I-PER, O, B-LOC, I-LOC, O, O, O, O] }逻辑说明tokens 是分词后的序列labels 与 tokens 一一对应。参数上要注意两点一是分词粒度要和 BERT 的 tokenizer 对齐中文建议按字切英文按 subword二是标签集要固定训练和推理必须用同一套 id 映射否则会出现「训练时是 B-PER推理时变成 B-PERSON」这种低级错误。标注工具上常见做法是用开源标注平台导出 JSONL每行一个样本。标注规范要写清楚实体边界怎么定、嵌套实体怎么处理、标点算不算实体的一部分。这些规则不写清楚后面模型学到的就是噪声。2.2 用 HuggingFace 加载 BERT 并接一个 Token Classification 头BERT 做 NER 的标准做法是在 BERT 输出层接一个线性分类头对每个 token 输出标签概率。HuggingFace 的BertForTokenClassification已经封装好了直接加载即可。下面是最小可运行代码from transformers import BertTokenizerFast, BertForTokenClassification import torch # 加载中文 BERT 预训练模型和 tokenizer model_name bert-base-chinese tokenizer BertTokenizerFast.from_pretrained(model_name) model BertForTokenClassification.from_pretrained( model_name, num_labels9, # 根据你的标签集调整比如 BIO 下 4 类实体就是 12*49 hidden_dropout_prob0.1, attention_probs_dropout_prob0.1 ) # 编码一个样本 text 张三在北京签了合同 inputs tokenizer(text, return_tensorspt, paddingTrue, truncationTrue, max_length128) labels torch.tensor([[0, 1, 0, 2, 3, 0, 0, 0, 0]]) # 对应 B-PER, I-PER, O, B-LOC, I-LOC, O, O, O, O outputs model(**inputs, labelslabels) loss outputs.loss logits outputs.logits print(loss.item(), logits.shape)逻辑说明num_labels必须等于标签集大小BIO 下如果有 PER、LOC、ORG 三类实体就是 1 2*3 7再加 MISC 就是 9。hidden_dropout_prob和attention_probs_dropout_prob是 BERT 内部 dropout业务语料小的时候可以适当调大0.2~0.3防过拟合。max_length根据你的文本长度分布定中文 NER 一般 128 够用长文档可以到 256 或 512但显存会涨。注意tokenizer 的padding和truncation要开否则 batch 里长度不一致会报错。另外如果按字切分tokenizer的do_lower_case对中文没影响但英文场景要设成 True。2.3 训练脚本学习率、batch size 和 warmup 怎么设BERT 微调 NER 的训练脚本不复杂但参数很关键。下面是一个基于 HuggingFace Trainer 的完整训练配置from transformers import TrainingArguments, Trainer from datasets import Dataset # 假设 train_data 是 list of dict每个 dict 有 tokens 和 labels def tokenize_and_align(examples): tokenized tokenizer( examples[tokens], is_split_into_wordsTrue, truncationTrue, paddingmax_length, max_length128 ) labels [] for i, label in enumerate(examples[labels]): word_ids tokenized.word_ids(batch_indexi) label_ids [] previous_word_idx None for word_idx in word_ids: if word_idx is None: label_ids.append(-100) # 特殊 token 忽略 elif word_idx ! previous_word_idx: label_ids.append(label[word_idx]) else: label_ids.append(-100) # 子词只保留第一个 previous_word_idx word_idx labels.append(label_ids) tokenized[labels] labels return tokenized train_dataset Dataset.from_list(train_data).map(tokenize_and_align, batchedTrue) training_args TrainingArguments( output_dir./ner_bert, learning_rate2e-5, per_device_train_batch_size16, per_device_eval_batch_size32, num_train_epochs5, weight_decay0.01, warmup_ratio0.1, logging_steps50, eval_strategyepoch, save_strategyepoch, load_best_model_at_endTrue, metric_for_best_modelf1, fp16True ) trainer Trainer( modelmodel, argstraining_args, train_datasettrain_dataset, eval_dataseteval_dataset, compute_metricscompute_metrics # 自定义 F1 计算 ) trainer.train()逻辑说明learning_rate2e-5是 BERT 微调的经典值太大容易震荡太小收敛慢。per_device_train_batch_size16在 16G 显存下跑 128 长度基本够用显存不够就降到 8 并开梯度累积。warmup_ratio0.1让前 10% 步数学习率线性上升避免一开始就大步更新破坏预训练权重。weight_decay0.01是常规正则。fp16True能省显存、加速训练但要注意有些老显卡不支持。tokenize_and_align里的-100是 PyTorch CrossEntropyLoss 的 ignore_index表示这些位置不计算 loss。子词只保留第一个标签是因为 BERT 的 subword 会把一个词拆成多个 token但 NER 标签是按词给的不处理会导致标签错位。2.4 评估别只看 F1要看实体级别的 precision 和 recallToken 级别的准确率会骗人。比如「北京市」被预测成「北京」「市」token 级别可能只错一个但实体级别完全错。所以评估要用实体级别的 precision、recall、F1。下面是一个基于 seqeval 的评估函数from seqeval.metrics import classification_report, f1_score def compute_metrics(p): predictions, labels p predictions predictions.argmax(-1) # 去掉 ignore_index 和特殊 token true_predictions [ [id2label[p] for p, l in zip(pred, label) if l ! -100] for pred, label in zip(predictions, labels) ] true_labels [ [id2label[l] for p, l in zip(pred, label) if l ! -100] for pred, label in zip(predictions, labels) ] return { precision: classification_report(true_labels, true_predictions, output_dictTrue)[weighted avg][precision], recall: classification_report(true_labels, true_predictions, output_dictTrue)[weighted avg][recall], f1: f1_score(true_labels, true_predictions) }逻辑说明id2label是标签 id 到标签名的映射必须和训练时一致。seqeval会自动处理 BIO 标签的边界算出实体级别的指标。如果 F1 高但 precision 低说明模型乱报实体如果 recall 低说明漏报多。业务上通常更怕漏报所以 recall 权重可以调高。3. 避坑指南BERT 微调 NER 的 5 个血泪教训3.1 标签错位子词对齐没做对loss 直接爆炸现象训练 loss 不降或者降到某个值后震荡评估 F1 接近 0。原因BERT 的 tokenizer 会把一个词拆成多个 subword但 NER 标签是按词给的。如果直接把词级标签赋给所有 subword会导致标签重复计算模型学到的边界是错的。解决用word_ids()做对齐只保留每个词的第一个 subword 的标签其余设为 -100。上面tokenize_and_align里的逻辑就是干这个的。另外中文按字切时一般不会拆但英文和混合文本一定要处理。3.2 过拟合业务语料只有几百条F1 虚高现象训练集 F1 到 99%验证集只有 60%测试集更惨。原因BERT 参数量大小数据集上很容易记住训练样本。加上如果验证集和训练集同分布指标会虚高。解决一是加 dropouthidden_dropout_prob调到 0.2~0.3二是加 weight decay0.01 起步三是早停load_best_model_at_endTrue配合metric_for_best_modelf1四是数据增强比如实体替换、同义词替换。如果数据实在少可以考虑冻结 BERT 前几层只训练后面几层和分类头。3.3 学习率太大预训练权重被冲垮现象训练初期 loss 飙升或者直接 NaN。原因BERT 微调的学习率通常在 1e-5 到 5e-5 之间超过 1e-4 很容易把预训练学到的语言知识冲掉。解决用 2e-5 起步配合 warmup。如果 loss 还是震荡降到 1e-5。另外分类头的学习率可以设大一点比如 1e-3BERT 主体设小一点用分层学习率。HuggingFace 的 Trainer 不直接支持分层学习率但可以自定义 optimizer。3.4 实体边界模糊BIO 和 BIOES 混用现象评估时 precision 和 recall 都还行但人工看预测结果实体边界总是差一个字。原因标注时 BIO 和 BIOES 混用或者标注规范没写清楚「北京市」是一个实体还是「北京」「市」。解决统一标注体系写清楚边界规则。如果实体边界经常差一个字可以试试 BIOES它对单字实体和边界更敏感。另外后处理可以加规则比如「市」「省」「区」结尾的自动合并到前一个实体。3.5 推理速度慢没做 batch 和 ONNX 优化现象单条推理要几百毫秒线上 QPS 上不去。原因PyTorch 默认推理没开torch.no_grad()也没做 batch每条都走一遍完整前向。解决推理时开model.eval()和torch.no_grad()多条文本拼 batch。如果还慢导出 ONNX 用 ONNX Runtime 跑速度能快 2~3 倍。下面是一个 batch 推理的示例model.eval() with torch.no_grad(): inputs tokenizer(texts, return_tensorspt, paddingTrue, truncationTrue, max_length128) outputs model(**inputs) predictions outputs.logits.argmax(-1)逻辑说明paddingTrue让 batch 内长度对齐truncationTrue防止超长。argmax(-1)取每个 token 的最大概率标签。注意推理时不需要传 labels否则会多算 loss。4. 进阶技巧用 CRF 层和对抗训练把 F1 再拉 3 个点BERT 线性分类头已经能打但如果你想把 F1 再往上推有两个方向值得试CRF 层和对抗训练。CRF 层的作用是建模标签之间的转移约束。比如 BIO 体系下I-PER 不能直接跟在 B-LOC 后面CRF 会学到这个约束减少非法序列。HuggingFace 的BertForTokenClassification不带 CRF需要自己接一个。常见做法是用torchcrf或pytorch-crf在 BERT 输出后面加一层 CRF训练时用 CRF 的负对数似然作为 loss。实测在实体边界模糊的数据集上CRF 能涨 1~2 个点。对抗训练FGM/PGD是在 embedding 上加扰动让模型对噪声更鲁棒。业务语料里常有错别字、口语化表达对抗训练能明显提升泛化。FGM 的实现不复杂在训练循环里对 embedding 做一次梯度上升再恢复。下面是一个 FGM 的核心代码class FGM: def __init__(self, model, epsilon1.0): self.model model self.epsilon epsilon self.backup {} def attack(self): for name, param in self.model.named_parameters(): if param.requires_grad and embedding in name: self.backup[name] param.data.clone() norm torch.norm(param.grad) if norm ! 0: r_at self.epsilon * param.grad / norm param.data.add_(r_at) def restore(self): for name, param in self.model.named_parameters(): if name in self.backup: param.data self.backup[name] self.backup {}逻辑说明epsilon控制扰动大小一般 0.5~1.0。attack在 embedding 上加扰动restore恢复原参数。训练时先正常前向反向再 attack 一次再前向反向最后 restore。这样模型会学到对扰动不敏感的特征。验证方法上除了看 F1还要看混淆矩阵。哪些实体容易混比如 ORG 和 PER 在中文里经常混「华为」是 ORG「任正非」是 PER但「华为的任正非」边界容易错。混淆矩阵能帮你定位问题针对性补数据。我自己的习惯是每次调完参先跑一遍验证集看 F1、precision、recall 三个指标再看混淆矩阵最后人工抽 50 条预测结果。如果人工看下来边界都对指标低一点也能接受如果指标高但人工看下来一堆错那指标就是假的。这个习惯帮我省了很多「上线后翻车」的后悔药。希望帮到你。本文还有配套的精品资源点击获取