☰
BERT中文命名实体识别实战:标签对齐与CRF解码全解
2026/10/17 10:52:45 网站建设 项目流程

简介:针对中文命名实体识别(NER)任务,这份资源基于Python语言和Hugging Face Transformers库,演示如何加载预训练BERT模型,对中文人名、地名、组织名等实体进行端到端识别。压缩包内共9个文件、大小约3.72MB,结构清晰:两个Python脚本分别覆盖模型构建与训练评估,四个文本文件提供训练集、验证集、测试集及词汇表,同时附带conlleval.pl标准评估工具、README说明文档和测试效果图,便于直接运行和对照学习。目前已有3339人学习下载,适合自然语言处理初学者、算法工程师以及需要落地中文信息抽取任务的开发者参考。通过完整实践,读者可掌握BERT的标记化与特殊令牌处理、序列填充、模型微调及Precision/Recall/F1评估等核心流程,理解IOB标注体系在中文NER中的应用,并可将实验代码迁移到自有数据,快速实现实体抽取原型。

1. 用 BERT 做中文 NER:先纠正三个自以为是的前提

做中文命名实体识别,很多人一上来就查 BERT 源码、调 transformers 接口,结果跑通一个 Demo 之后,换到自己的数据集上立刻翻车。我拆过几个用 BERT 做中文 NER 的项目后,发现真正劝退人的不是模型结构,而是三个被默认的前提:分词器输出的 token 和你的标签不齐、CLS 和 SEP 占位符会污染标签序列、BERT 输出接一个 Softmax 就以为能捕捉标签之间的约束。这篇笔记把从环境准备到数据对齐、模型构建、训练避坑、部署预测的完整链路拆开讲,每一步都附可复现的代码和参数说明,适合正在做中文信息抽取、舆情分析或知识图谱构建的开发者参考。

2. 环境与选型:为什么中文 NER 绕不开预训练权重

2.1 环境准备与版本搭配

中文 NER 的常规路线是把预训练语言模型当编码器,在它上面挂序列标注头。这一套的生态依赖很明确:torch 负责训练,transformers 负责加载权重和分词器。版本上我踩过的坑是 transformers 和 tokenizers 之间偶发不兼容,表现为分词时直接报 TokenizerException。

# 创建虚拟环境,Python 版本不要太新,3.9 最稳 conda create -n ner_bert python=3.9 -y conda activate ner_bert # 安装 torch,先确认自己的 CUDA 版本 pip install torch==2.1.2 --index-url https://download.pytorch.org/whl/cu118 # 安装 transformers 和加速组件 pip install transformers==4.36.2 tokenizers==0.15.1 datasets==2.16.1 seqeval # 检查关键依赖版本 python -c "import torch, transformers; print(torch.__version__, transformers.__version__)"

参数说明:torch 2.1.2 配 CUDA 11.8 是经过验证的稳定组合;transformers 4.36.x 的 API 相对完整,BertTokenizerFast和AutoTokenizer都可用;seqeval 是专门评估序列标注结果的库,会按实体级别计算精确率、召回率和 F1,比手写按 token 算的指标可靠得多。这里不装最新的 transformers,因为新版对旧权重文件有时会强制走 safetensors 转换,遇到网络不通会卡很久。

2.2 预训练模型怎么选

中文 NER 场景下,最常用的还是 BERT 的中文权重。它基于中文维基语料训练,词表 21128,对简体中文的支持比较成熟。如果你做的是金融或医疗领域,可以考虑领域模型,但泛化性通常不如通用模型,我一般先用通用权重跑基线,再决定要不要换。

from transformers import BertTokenizerFast, BertModel tokenizer = BertTokenizerFast.from_pretrained("bert-base-chinese") # 加载后会打印模型参数量约 1.02 亿,占用显存约 400MB model = BertModel.from_pretrained("bert-base-chinese") # 验证分词器 text = "张三在北京上班" tokens = tokenizer.tokenize(text) print(tokens) # 输出: ['张', '##三', '在', '北', '##京', '上', '班']

逻辑说明:这里用BertTokenizerFast是为了后续拿到 offset_mapping 做标签对齐,速度比老版 tokenizer 快一个量级。BertModel只加载编码器,序列标注头需要自己加,这样自由度更高,也方便后面接 CRF。

参数注意:from_pretrained第一次会下载约 400MB 的权重,可以设置环境变量HF_ENDPOINT指向镜像站,避免下载失败。如果你不想每次加载都重复下载,就把权重放到本地目录,把from_pretrained的第一个参数改成绝对路径。

2.3 数据格式与标注体系

中文 NER 的输入数据通常是「句子 + 实体标注」。一个常见做法是维护一个实体字典,给每个实体类型分配标签编号。下面是我常用的数据格式:每行一句话,按空格分隔字符和标签,B 开头表示实体开始,I 表示实体内部,O 表示非实体。

李 B-PER 刚 I-PER 在 O 北 B-LOC 京 I-LOC 上 O 班 O

逻辑说明:这种逐字标注的方式符合中文特点——中文没有天然空格分隔,词语边界需要靠分词器决定,而标注是建立在字粒度上的。BIOES 格式也可以支持,区别在于是把实体尾单独标记为 E,这对 CRF 解码有好处,后面会细说。

3. 数据预处理实战:分词器与标签对齐

3.1 BIO 与 BIOES 的取舍

对于中文 NER,BIO 和 BIOES 都能用,但效果有差别。BIOES 增加了 E(尾)和 S(单个实体)标签,等于给模型额外提供了实体边界信息。当你的实体长度普遍较长时,BIOES 的收益更明显;如果实体大多是两三个字,BIO 和 BIOES 的差距很小。

标注方案标签数优点缺点
BIO2×类别数 + 1简单直接缺少边界信息,长实体易断裂
BIOES4×类别数 + 1边界清晰,CRF 收益明显标签更多,样本少时稀疏

我实际对比过同样数据下两种方案的 F1,BIOES 平均能高 1 到 2 个点,代价是训练收敛略慢。建议你第一版跑通就用 BIO,出基线后再切 BIOES 提精度。

3.2 分词器造成的标签错位

中文分词器是字粒度的,但 BERT 的子词切分并不保证一个字对应一个 token。比如「张三」中的「张」可能被拆成['张', '##三'],也可能整个词保留,取决于词表覆盖。当 token 长度与字符数不一致时,标签序列必须跟着变。

# 展示一个常见错位案例 from transformers import BertTokenizerFast tokenizer = BertTokenizerFast.from_pretrained("bert-base-chinese") sentence = "孙杨在泳池训练" labels = ["B-PER", "I-PER", "O", "O", "O", "O", "O"] # 按字标注 tokens = tokenizer(sentence, return_offsets_mapping=True) print(tokens["input_ids"]) print(tokens["offset_mapping"])

逻辑说明:offset_mapping给出每个 token 在原始文本中的起止位置,这是对齐标签的关键。比如(0, 1)说明这个 token 对应原文本第 0 个字符,「孙」;如果某个 token 的 offset 是(0, 0),说明它是[CLS],不参与标签计算。

注意:直接用tokenize和encode的结果做标签数组会导致长度不匹配,训练时报错或者结果完全错位。必须先拿offset_mapping做映射,才能保证 token 和标签一一对应。

3.3 对齐代码实现

def align_labels_with_tokens(labels, offset_mapping, label2id): """ 将字符级标签对齐到 token 级 labels: 字符级标签列表,如 ["B-PER", "I-PER", "O"] offset_mapping: tokenizer 返回的映射 label2id: 标签到 ID 的字典 """ aligned_ids = [] prev_label = "O" for mapping in offset_mapping: start, end = mapping if start == 0 and end == 0: # [CLS] 或 [SEP],填充 -100,PyTorch 的 CE Loss 会自动忽略 aligned_ids.append(-100) elif start == end: # 被分词器拆出来的子词,继承上一个标签 aligned_ids.append(label2id.get(prev_label, 0)) else: # 正常 token,取该位置对应的标签 label = labels[start] aligned_ids.append(label2id.get(label, 0)) prev_label = label return aligned_ids

逻辑说明:这个函数的核心逻辑是——遇到[CLS]/[SEP]填充 -100;遇到被拆分出来的残余子词(如##三)时,它的 start 和 end 相同,直接继承前一个 token 的标签;正常 token 则取该字符位置的标签。标签 0 我约定是O,所以缺省时落到O不会出大问题。

注意:label2id.get(label, 0)里的缺省值一定要对应O,否则预测阶段会全乱。最好在预处理阶段就把所有标签检查一遍,比如非法标签一律抛异常,而不是悄悄落到 0。

from transformers import BertTokenizerFast from torch.utils.data import Dataset class NERDataset(Dataset): def __init__(self, data, tokenizer, label2id, max_len=128): self.data = data # 每个元素是 (chars, labels) self.tokenizer = tokenizer self.label2id = label2id self.max_len = max_len def __len__(self): return len(self.data) def __getitem__(self, idx): chars, labels = self.data[idx] encoding = self.tokenizer( "".join(chars), max_length=self.max_len, truncation=True, padding="max_length", return_offsets_mapping=True, ) aligned = align_labels_with_tokens(labels, encoding["offset_mapping"], self.label2id) return { "input_ids": torch.tensor(encoding["input_ids"]), "attention_mask": torch.tensor(encoding["attention_mask"]), "labels": torch.tensor(aligned), }

参数说明:max_len=128对中文句子足够,超过 128 的句子会被截断;如果业务里有长文本,改成 256 或 512 会显著增加显存占用,训练前要权衡。padding="max_length"会让每条样本长度一致,方便批处理,代价是无效计算,但配合 attention_mask 可以忽略 padding 部分。

4. 模型构建与训练:从 BERT 输出到 CRF 解码

4.1 为什么不能只接 Softmax

BERT 输出的每个 token 有自己的表示,直接接 Linear + Softmax 也能训练,但问题在于它假设标签之间相互独立。实际中文 NER 里,B-PER后面几乎必然是I-PER,I-LOC前面不可能直接接B-PER之外的东西。Softmax 感知不到这种约束,所以会在测试集上产生「跳变」错误,比如孤立的多余 I 标签。

CRF(条件随机场)是专门解决这个问题的。它在解码时建模标签转移概率,比如I-PER到B-LOC的转移被赋予极低分数,模型就不会输出这种非法序列。BERT 负责编码,CRF 负责约束,两者不是竞争关系,是串联。

4.2 模型类实现

import torch import torch.nn as nn from transformers import BertModel class BertCRF(nn.Module): def __init__(self, bert_name, num_labels, use_bilstm=True): super().__init__() self.bert = BertModel.from_pretrained(bert_name) self.use_bilstm = use_bilstm if use_bilstm: self.bilstm = nn.LSTM( input_size=self.bert.config.hidden_size, hidden_size=256, num_layers=1, batch_first=True, bidirectional=True, ) lstm_out_dim = 256 * 2 else: lstm_out_dim = self.bert.config.hidden_size self.dropout = nn.Dropout(0.1) self.classifier = nn.Linear(lstm_out_dim, num_labels) # 初始化 CRF 转移矩阵,num_labels x num_labels from torchcrf import CRF self.crf = CRF(num_labels) def forward(self, input_ids, attention_mask, labels=None): outputs = self.bert(input_ids=input_ids, attention_mask=attention_mask) sequence_output = outputs.last_hidden_state if self.use_bilstm: sequence_output, _ = self.bilstm(sequence_output) logits = self.classifier(self.dropout(sequence_output)) if labels is not None: # labels 里的 -100 会被 CRF 忽略 loss = -self.crf(logits, labels, mask=attention_mask.bool()) return loss else: # 维特比解码,返回最佳序列 predictions = self.crf.decode(logits, mask=attention_mask.bool()) return predictions

逻辑说明:这里用了torchcrf库来实现 CRF 层,它会自动忽略标签为 -100 的位置。use_bilstm=True会在 BERT 输出上再接一层双向 LSTM,这个设计能进一步捕捉序列上下文,在中小规模数据集上通常能提 1~3 个点 F1。代价是训练时间和显存都上升。

参数说明:num_labels是你的标签总数,BIOES 方案下是4×实体类别数 + 1;hidden_size=256是 LSTM 隐层维度,改大不一定更好,我测试过从 256 升到 512,F1 提升不超过 0.5,训练时间几乎翻倍,所以实际项目里保留 256。

注意:attention_mask.bool()是必需的,CRF 内部的 mask 要求布尔张量,忘记转换会报错。如果你的数据全是 padding 到相同长度的,那 mask 全为 True,但遇到截断样本时就危险了。

4.3 训练循环与超参数

from torch.utils.data import DataLoader from transformers import AdamW, get_linear_schedule_with_warmup from seqeval.metrics import classification_report def train_epoch(model, dataloader, optimizer, scheduler, device): model.train() total_loss = 0 for batch in dataloader: input_ids = batch["input_ids"].to(device) attention_mask = batch["attention_mask"].to(device) labels = batch["labels"].to(device) loss = model(input_ids, attention_mask, labels) loss.backward() # 梯度裁剪是 BERT + CRF 训练的必备操作,防止梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) optimizer.step() scheduler.step() optimizer.zero_grad() total_loss += loss.item() return total_loss / len(dataloader) # 超参数配置 BATCH_SIZE = 16 LEARNING_RATE = 2e-5 EPOCHS = 5 model = BertCRF("bert-base-chinese", num_labels=len(label2id)).to(device) optimizer = AdamW(model.parameters(), lr=LEARNING_RATE, weight_decay=0.01) total_steps = len(train_dataloader) * EPOCHS scheduler = get_linear_schedule_with_warmup( optimizer, num_warmup_steps=int(0.1 * total_steps), num_training_steps=total_steps )

参数说明:BERT 微调的学习率通常不超过 5e-5,2e-5 是我在中文 NER 上验证过的安全起点。weight_decay=0.01对 BERT 全参数微调有明显正则化效果。warmup设为总步数的 10%,让 BERT 在前几百步慢慢热身,避免刚开始的激进更新破坏预训练权重。

经验:batch_size和max_len是显存的两个最敏感参数。如果显存只有 6GB,max_len=128 + batch_size=16已经接近上限,建议改成max_len=64或batch_size=8。

5. 常见问题排查:显存溢出、标签错位、不收敛

5.1 显存溢出:OOM 发生在验证阶段

现象:训练第一个 epoch 正常,到验证切模型为 eval 模式时突然报 CUDA out of memory。

原因:验证阶段我没关梯度,with torch.no_grad()忘写了,BERT 的前向仍然保留计算图,显存直接被撑爆。另一部分原因是验证集里有一条超长样本,虽然训练时做了截断,但验证数据没有走同一个预处理管线。

解决:验证环节强制with torch.no_grad():,并把验证集的max_len和 batch size 调小。再一个办法是在forward里对attention_mask做一次torch.where,把 padding 部分的 hidden state 归零,减少无效计算。

def evaluate(model, dataloader, device): model.eval() with torch.no_grad(): for batch in dataloader: input_ids = batch["input_ids"].to(device) attention_mask = batch["attention_mask"].to(device) predictions = model(input_ids, attention_mask) # 注意:这里的 predictions 是 list of list,没有经过 softmax

逻辑说明:model.eval()会关闭 dropout 和 LSTM 的训练行为,no_grad则停止梯度跟踪。预测时 CRF 走维特比解码,返回的是标签 ID 序列,不再是概率张量,所以后处理要用id2label映射回去。

5.2 预测结果整体偏移一位

现象:训练 loss 正常下降,但预测出来的实体全都往右偏了一个 token,或者第一个 token 的标签是乱的。

原因:这是经典的标签对齐错误。排查思路是检查offset_mapping里第一个映射是否为(0, 0),也就是[CLS]占了第一个位置,但标签列表是从第二个位置,也就是第一个字才开始对齐的。如果预处理时用tokenizer.encode(sentence)而不是tokenizer(sentence, return_offsets_mapping=True),非常容易漏掉这个偏移。

解决:在所有数据预处理统一用align_labels_with_tokens,并加一个断言:len(aligned_ids) == len(encoding["input_ids"]),这个断言能拦住九成对齐问题。

assert len(aligned_ids) == len(encoding["input_ids"]), \ f"对齐失败: {len(aligned_ids)} vs {len(encoding['input_ids'])}"

注意:如果你用了padding="max_length",那断言只有在未加 padding 时才能保证相等。加了 padding 后长度一致,但断言失效,所以建议先在未 padding 的样例上跑断言验证,再打开 padding。

5.3 训练 loss 不下降

现象:loss 一直停在 1.5 左右不动,或者前几个 epoch 还在下降,后面直接反弹。

原因:最常见的是标签里有未知 ID,比如-100没有被 CRF 正确忽略,或者label2id里出现了碰撞。另一个原因是BATCH_SIZE太小,比如等于 4 时梯度噪声大,BERT 微调很容易震荡。

解决:把BATCH_SIZE提到 16 以上;检查label2id是否重复;打印一个 batch 的 labels 和 input_ids,肉眼确认-100是否只出现在[CLS]、[SEP]和 padding 位置。还有一个隐蔽点:torchcrf要求标签从 0 开始连续编码,如果O的 ID 不是 0,CRF 初始化会报错或行为异常,把标签预处理改成O固定为 0。

5.4 预测时出现了 B-PER 后接 I-LOC

现象:模型训完了,预测实体边界正确,但实体类型混叠,比如人名里混进了地名片段。

原因:CRF 转移矩阵其实已经学到「同类 I 只能跟在同类 B 后面」,但如果你的训练数据里本身就有标注噪声,比如同一实体类型内部标注不一致,模型会学到错误的转移概率。另一个可能:标签方案从 BIOES 切回 BIO 时,CRF 的转移矩阵尺寸变了,老权重没有重新初始化。

解决:用classification_report打印每种实体类型的 F1,定位具体是哪类混叠。排查训练集里同一个实体的标注是否统一,必要时写脚本自动校验标注一致性,比如检查B-PER之后是否跟着同类I-PER。

6. 进阶:解码细节与业务落地技巧

6.1 预测阶段的解码与后处理

训练完成后,预测代码和解码逻辑要在业务环境里跑稳。我通常在预测时把attention_mask里 padding 对应的预测结果全部过滤掉,只保留模型自己判断的长度,避免把 padding token 的O当成有效标签。

def predict_single(model, tokenizer, text, id2label, device, max_len=128): encoding = tokenizer( text, max_length=max_len, truncation=True, return_tensors="pt", return_offsets_mapping=True, ) input_ids = encoding["input_ids"].to(device) attention_mask = encoding["attention_mask"].to(device) # 不传 labels,模型走 CRF 解码 predictions = model(input_ids, attention_mask) tokens_text = tokenizer.convert_ids_to_tokens(input_ids[0]) pred_tags = [id2label[p] for p in predictions[0]] # 按 offset_mapping 过滤 [CLS]、[SEP] 和 padding results = [] for token, tag, offset in zip(tokens_text, pred_tags, encoding["offset_mapping"][0]): if offset[0] == offset[1] == 0: continue results.append((token, tag)) return results

逻辑说明:predictions[0]是维特比解码后的标签 ID 序列,长度和 input_ids 一致。过滤条件offset[0] == offset[1] == 0会把[CLS]、[SEP]和所有 padding token 一起排除,只留下有效内容。

参数注意:max_len=128在预测阶段同样生效,超长文本会被截断,实体横跨截断边界时会被切碎。如果你的业务文本经常超过 128,建议先把文档切分成句子级再做预测,或者动态按实体字典切分。

6.2 实体合并与边界修正

CRF 给出的标签序列还需要转成实体字典。这个流程有几个容易踩的坑:同类型两个相邻实体紧挨着时,B 标记会重新出现;跨行文本预测时,上一行末尾的 I 和下一行开头的 B 会被拼成一个假实体。我的习惯是在后处理时强制在一次扫描内完成合并,禁止跨样本拼接实体。

def extract_entities(pred_tags, tokens, id2label): entities = [] cur_entity = None for i, tag in enumerate(pred_tags): if tag.startswith("B-"): # 保存上一个实体 if cur_entity is not None: entities.append(cur_entity) cur_entity = {"type": tag.split("-")[1], "text": tokens[i]} elif tag.startswith("I-") and cur_entity is not None: cur_entity["text"] += tokens[i] else: if cur_entity is not None: entities.append(cur_entity) cur_entity = None if cur_entity is not None: entities.append(cur_entity) return entities

逻辑说明:extract_entities的核心是维护一个cur_entity字典,只有遇到B-才开新实体,遇到O直接结束当前实体。这样做能防止实体内部混入O标签,也能正确处理同类型相邻实体。

这个函数我在实际业务里改动过很多次,最典型的一次是某文本里「北京市海淀区」被标注成两个 B-LOC 相连,如果不做合并逻辑,最后会漏掉第二个实体。从那以后我每次做完模型评估都会先把预测结果直接喂进extract_entities打印出来肉眼检查一遍,而不是只看 F1 数值。这套流程虽然繁琐,但在真实数据上比调模型参数更有效,希望帮到你。

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

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

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

立即咨询