简介:这份资源面向自然语言处理初学者与需要落地实体抽取任务的开发者,围绕BERT预训练模型微调实现命名实体识别展开。内容从实体类型定义入手,给出address、book、company、game、government、movie、name、organization、position、scene共10类实体的标签体系,并演示B-/I-编码与id2label、label2id的映射构建,随后讲解BertTokenizer中文分词、input_ids、token_type_ids、attention_mask三项输入的含义,以及分词后标签对齐的常见问题与处理思路,最后延伸到微调模型结构、评估指标与实际应用。资源包为1个PDF文档,约545KB,以图文与代码片段结合的方式呈现,便于按章节查阅与复现。目前已有1242人学习,适合希望快速理解BERT微调NER完整流程、对照代码梳理标签编码与分词细节的读者参考。
1. 命名实体识别为什么总在「边界」上翻车
命名实体识别(NER)是 NLP 落地里最像「脏活」的一类任务:你要从一段自由文本里把人名、地名、机构名、时间、金额这些片段精确地框出来,还要给它们贴上正确的类型标签。很多团队第一次做 NER,会直接拿一个 BERT 预训练模型加一层分类头,跑完发现整体 F1 看着还行,一上业务数据就崩——崩的地方往往不是「完全没识别出来」,而是边界切错、类型串味、嵌套实体只认出一半。
这个标题讲的就是用 BERT 预训练模型做微调,把通用语言知识迁移到 NER 任务上。它适合两类人:一类是手里已经有标注数据、想快速跑通 baseline 的算法工程师;另一类是想把 NER 接进搜索、风控、工单抽取等系统,但不确定该用 BERT+CRF 还是纯 BERT+Softmax 的工程同学。核心结论先放这里:BERT 微调能解决「语义泛化」问题,但解决不了「标签依赖」和「边界歧义」问题,后者要靠解码层和数据处理来补。
2. 从 BERT 到 NER:标签体系与模型结构怎么定
2.1 先定标签体系,再谈模型选型
NER 的标签体系决定了后面所有代码的形态。常见做法是 BIO、BIOES 两套。BIO 只有 B-、I-、O 三种前缀,标注成本低,但模型在实体边界上容易把「B-ORG」后面跟的「I-ORG」提前截断;BIOES 多了 E-(实体结束)和 S-(单字实体),边界信息更明确,代价是标注一致性要求更高。
我一般会这样选:如果标注团队规模小、迭代快,先用 BIO 跑通;如果业务对边界精度要求高(比如合同金额、身份证号),直接上 BIOES。下面是一个 BIO 标注的示例,用 Python 字典表示:
# BIO 标签体系示例:B-开始,I-内部,O-非实体 label2id = { "O": 0, "B-PER": 1, "I-PER": 2, # 人名 "B-LOC": 3, "I-LOC": 4, # 地名 "B-ORG": 5, "I-ORG": 6, # 机构名 } id2label = {v: k for k, v in label2id.items()} # 一条标注样本:token 与标签一一对应 tokens = ["张", "三", "在", "北", "京", "大", "学", "读", "书"] labels = ["B-PER", "I-PER", "O", "B-ORG", "I-ORG", "I-ORG", "I-ORG", "O", "O"]这段代码的关键在于label2id的索引顺序必须和模型输出维度严格对齐,否则训练 loss 会正常下降但预测全错。参数上,O放在 0 是惯例,因为大部分 token 都是非实体,这样在计算类别权重时方便把 0 类权重调低。
2.2 BERT + Softmax 还是 BERT + CRF
这是选型里最常被问的问题。BERT + Softmax 把每个 token 当作独立分类,训练快、实现简单,但会产出非法序列,比如「I-PER」前面没有「B-PER」。BERT + CRF 在 BERT 输出之上加一层条件随机场,用转移矩阵约束标签之间的合法跳转,能显著减少这类错误。
代价是 CRF 层会拖慢训练速度,大约增加 20%~40% 的单步耗时,而且解码时要用 Viterbi,推理延迟也会上升。我的经验是:实体类型少于 5 类、边界要求一般,用 Softmax 就够;实体类型多、嵌套多、或者业务明确不能接受非法序列,直接上 CRF。
import torch import torch.nn as nn from transformers import BertModel class BertCrfForNER(nn.Module): def __init__(self, bert_path, num_labels, dropout=0.1): super().__init__() self.bert = BertModel.from_pretrained(bert_path) self.dropout = nn.Dropout(dropout) # 分类头:hidden_size -> num_labels self.classifier = nn.Linear(self.bert.config.hidden_size, num_labels) # CRF 转移矩阵:num_labels x num_labels self.transitions = nn.Parameter(torch.randn(num_labels, num_labels)) # 非法转移约束:START 不能转到 I- 类,O 不能转到 I- 类等 self.num_labels = num_labels def forward(self, input_ids, attention_mask, labels=None): outputs = self.bert(input_ids=input_ids, attention_mask=attention_mask) sequence_output = self.dropout(outputs.last_hidden_state) emissions = self.classifier(sequence_output) # [B, L, num_labels] if labels is not None: # 训练时计算 CRF 负对数似然 loss = self._crf_loss(emissions, labels, attention_mask) return loss else: # 推理时 Viterbi 解码 return self._viterbi_decode(emissions, attention_mask)这里transitions是 CRF 的核心参数,形状是[num_labels, num_labels],transitions[i][j]表示从标签 i 转移到标签 j 的分数。实际训练时不需要手动设约束,让模型从数据里学即可,但可以在初始化时把明显非法的转移(如O -> I-PER)设成极小值加速收敛。dropout=0.1是 BERT 微调的常用值,数据量小于 5000 条时可以提到 0.2~0.3。
2.3 数据预处理:subword 与标签对齐的坑
BERT 用的是 WordPiece 分词,一个中文词可能被切成多个 subword,英文单词更常见。标注数据通常是按字或按词标的,必须把标签对齐到 subword 上。常见做法是:只有第一个 subword 保留原标签,后续 subword 标为-100(PyTorch CrossEntropy 的 ignore_index),或者统一标成I-类。
from transformers import BertTokenizerFast tokenizer = BertTokenizerFast.from_pretrained("bert-base-chinese") def align_labels_with_tokens(tokens, labels): """把字级标签对齐到 subword 级""" new_labels = [] for i, token in enumerate(tokens): sub_tokens = tokenizer.tokenize(token) if len(sub_tokens) == 0: continue # 第一个 subword 保留原标签,其余用 -100 忽略 new_labels.append(labels[i]) for _ in sub_tokens[1:]: new_labels.append(-100) return new_labels-100是 PyTorchCrossEntropyLoss默认的ignore_index,不参与 loss 计算。如果用的是自定义 CRF,需要在 loss 里手动 mask 掉这些位置。参数上要注意tokenizer必须和预训练模型匹配,中文场景常用bert-base-chinese,它的词表是按字为主,subword 切分少,对齐逻辑简单;如果换成多语言模型,英文单词会被切碎,对齐逻辑要更小心。
3. 微调训练:从数据加载到超参设置
3.1 用 HuggingFace Trainer 跑通最小训练
HuggingFace 的Trainer能省掉大量样板代码,适合快速验证。下面是一个完整的最小训练脚本,数据集用datasets库加载,假设已经存成 JSON 格式,每行是{"tokens": [...], "labels": [...]}。
from datasets import load_dataset from transformers import BertTokenizerFast, Trainer, TrainingArguments from transformers import DataCollatorForTokenClassification import numpy as np # 1. 加载数据集 dataset = load_dataset("json", data_files={"train": "train.json", "valid": "valid.json"}) # 2. 初始化 tokenizer tokenizer = BertTokenizerFast.from_pretrained("bert-base-chinese") # 3. 预处理:tokenize + 标签对齐 def preprocess(examples): tokenized = tokenizer( examples["tokens"], truncation=True, max_length=128, is_split_into_words=True, ) labels = [] for i, label in enumerate(examples["labels"]): word_ids = tokenized.word_ids(batch_index=i) label_ids = [] for word_id in word_ids: if word_id is None: label_ids.append(-100) # special token else: label_ids.append(label[word_id]) labels.append(label_ids) tokenized["labels"] = labels return tokenized tokenized_dataset = dataset.map(preprocess, batched=True) # 4. 数据整理器:动态 padding data_collator = DataCollatorForTokenClassification(tokenizer=tokenizer) # 5. 训练参数 training_args = TrainingArguments( output_dir="./ner-bert", learning_rate=2e-5, per_device_train_batch_size=16, per_device_eval_batch_size=32, num_train_epochs=5, weight_decay=0.01, evaluation_strategy="epoch", save_strategy="epoch", load_best_model_at_end=True, metric_for_best_model="f1", logging_steps=50, fp16=True, # 有 GPU 时开启 ) # 6. 训练 trainer = Trainer( model=model, args=training_args, train_dataset=tokenized_dataset["train"], eval_dataset=tokenized_dataset["valid"], data_collator=data_collator, compute_metrics=compute_metrics, ) trainer.train()关键参数说明:learning_rate=2e-5是 BERT 微调的经典值,太大容易灾难性遗忘,太小收敛慢;per_device_train_batch_size=16在 16GB 显存下跑bert-base加 CRF 基本够用,不够就降到 8 并开梯度累积;num_train_epochs=5是经验值,数据量小于 2000 条时 3~4 轮就够,多了会过拟合;weight_decay=0.01对 BERT 参数做正则,别设太大,否则预训练知识被压掉。
3.2 学习率与 batch size 的联动调法
很多人只调学习率不调 batch size,结果 loss 震荡或者收敛到次优。经验规则是:batch size 翻倍,学习率可以乘以 1.5~2 倍,但 NER 任务上不建议超过 5e-5。如果显存不够,用梯度累积模拟大 batch:
training_args = TrainingArguments( per_device_train_batch_size=8, gradient_accumulation_steps=4, # 等效 batch size = 8 * 4 = 32 learning_rate=3e-5, # ... 其他参数 )gradient_accumulation_steps=4表示每 4 个 step 才更新一次参数,等效 batch size 是 32。注意这时logging_steps要相应调整,否则日志里 loss 看起来跳动很大。另外fp16=True在部分显卡上会导致 loss 出现 NaN,遇到这种情况先关掉 fp16,或者用bf16(需要 Ampere 及以上架构)。
3.3 评估指标:别只看整体 F1
NER 的评估必须分实体类型看 P/R/F1,整体 F1 会掩盖某一类实体完全失效的问题。用seqeval库计算:
from seqeval.metrics import classification_report, f1_score def compute_metrics(eval_pred): predictions, labels = eval_pred predictions = np.argmax(predictions, axis=2) # 去掉 -100 和 special 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 { "f1": f1_score(true_labels, true_predictions), "report": classification_report(true_labels, true_predictions), }seqeval的f1_score默认按实体级别计算,不是 token 级别,这更符合业务直觉。如果发现某一类实体 F1 特别低,先看训练集里这类实体的数量,少于 200 条基本学不好,需要补充数据或做数据增强。
4. 避坑与排查:微调 NER 最常见的 5 个翻车现场
4.1 现象:loss 正常下降,但预测全是 O
原因通常是标签对齐错了。比如preprocess里word_ids返回的索引和labels列表长度不一致,导致大部分标签被标成-100,模型只学了「输出 O」。解决方法是打印一条样本的input_ids、word_ids、labels对照看,确认每个非 special token 都有有效标签。
4.2 现象:验证集 F1 比训练集低 20 个点以上
这是过拟合的典型信号。先看数据量,小于 1000 条时 BERT 微调很容易过拟合。解决手段按优先级:增加数据、提高 dropout 到 0.3、减小学习率到 1e-5、早停(early_stopping_patience=2)。如果数据里实体分布极不均衡,还要加类别权重。
4.3 现象:CRF 层训练 loss 出现 NaN
CRF 的转移矩阵初始化值太大时,前向算法里的 log-sum-exp 会溢出。把transitions初始化改成torch.randn(num_labels, num_labels) * 0.1,或者在 loss 里加torch.clamp。另外fp16=True时 CRF 更容易 NaN,建议 CRF 模型用fp32训练。
4.4 现象:推理时实体边界多一个字或少一个字
这是 subword 对齐的遗留问题。如果训练时把后续 subword 标成-100,推理时 Viterbi 解码出来的标签序列在 subword 级别是断开的,需要后处理合并。常见做法是:遇到B-或I-标签时,把连续的同类标签合并成一个实体,遇到-100位置直接跳过。
4.5 现象:换了预训练模型后效果反而下降
不是所有 BERT 变体都适合 NER。比如某些用 MLM 目标在通用语料上训练的模型,对中文分词的粒度和你业务不一致,subword 切分差异大,标签对齐后有效 token 变少。换模型前先用tokenizer.tokenize看几个业务样本的切分结果,切得太碎的就别用。
5. 进阶技巧:用对抗训练和词典增强把 F1 再抬 2 个点
5.1 对抗训练:FGM 在 BERT 微调里的最小实现
对抗训练(FGM)在 embedding 层加扰动,能提升模型鲁棒性,NER 上通常能涨 1~2 个 F1。实现很简单,在训练循环里加一步:
class FGM: def __init__(self, model, epsilon=1.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 = {}用法是在loss.backward()之后、optimizer.step()之前调用fgm.attack(),然后再算一次 loss 并 backward,最后fgm.restore()。epsilon=1.0是常用值,太大反而掉点。注意 FGM 会让训练时间增加约 50%,数据量少的时候收益更明显。
5.2 词典增强:把业务词表接进解码
如果业务里有明确的实体词典(比如产品名、城市名),可以在 Viterbi 解码时给这些词对应的标签路径加分。做法是维护一个word -> label的映射,解码时如果当前 token 序列命中词典,就把对应标签的 emission 分数加一个bias,比如+2.0。这比重新训练模型快得多,适合冷启动阶段。
5.3 验证方法:用混淆矩阵定位类型串味
整体 F1 涨了不代表所有类都好。用sklearn的confusion_matrix按实体类型画矩阵,重点看ORG和LOC之间的混淆,这两个类在中文里经常串。如果混淆严重,要么补充区分性样本,要么在 CRF 转移矩阵里手动调低ORG -> LOC的转移分数。
我自己的习惯是:每次微调完先跑一遍混淆矩阵,再决定要不要加数据或调解码。这个习惯帮我省了很多「以为模型不行、其实是数据标错」的后悔药。希望帮到你。
本文还有配套的精品资源,点击获取