☰
中文命名实体识别实战:BERT+BILSTM+CRF 从数据到部署全流程
2026/10/9 8:09:51 网站建设 项目流程

简介:这份资源面向计算机、人工智能、数据科学等专业的学生与开发者,提供一套完整的中文命名实体识别实战方案,采用BERT+BILSTM+CRF经典组合,可用于课程设计、毕业设计或初期项目立项演示。压缩包共58个文件,约13.75MB,包含16个Python源码文件、19个编译缓存文件、9个文本数据与说明、4份Markdown文档、5张效果图及若干配置与数据文件,覆盖模型定义、数据预处理、训练脚本与工具模块。项目内置BERT_BILSTM_CRF、BILSTM_CRF、IDCNN_CRF等多种网络实现,并附带人民日报、MSRA等中文NER数据集及预处理代码,方便直接复现实验。目前已有1210人学习下载,适合希望快速理解序列标注流程、掌握BERT微调与CRF解码细节的读者参考借鉴。

1. 中文命名实体识别:BERT+BILSTM+CRF 到底解决了什么业务问题

做中文 NLP 的工程师大概率都遇到过这样的场景:一批用户评论、合同文本或者医疗病历丢过来,老板要你从中把「人名、地名、机构名、时间、药品、疾病」这些关键信息抽出来,做成结构化字段入库。正则写了几十条,规则越堆越厚,遇到「张三丰在北京市海淀区成立了一家叫字节跳动的公司」这种句子,规则直接崩盘——「张三丰」是人名还是地名?「北京市海淀区」要不要拆成两级?「字节跳动」后面跟「公司」才算机构吗?这就是中文命名实体识别(NER)要解决的核心问题。

标题里的 BERT+BILSTM+CRF 是目前中文 NER 最经典、落地最稳的一套组合方案。BERT 负责把字变成带上下文语义的向量,BILSTM 负责捕捉序列前后依赖,CRF 负责保证输出的标签序列合法(比如 I-PER 不能出现在 B-PER 前面)。这套方案在 MSRA、人民日报、Weibo 等公开数据集上 F1 普遍能到 90% 以上,工业界大量抽取任务至今仍在用。适合谁?适合手上有标注数据、需要快速搭一套可解释、可微调的中文实体抽取流水线的工程师,也适合想从「调包」进阶到「自己训模型」的 Python 开发者。下面我按「数据怎么准备 → 模型怎么搭 → 怎么训 → 怎么避坑 → 怎么调优」的顺序,把这条链路完整走一遍。

2. 数据准备与标签体系:从原始文本到 BERT 能吃的输入

2.1 中文 NER 的标签体系怎么定

中文 NER 最通用的标注格式是 BIO 或 BMES。BIO 简单直接:B-XXX 表示实体开头,I-XXX 表示实体内部,O 表示非实体。BMES 更细:B 开头、M 中间、E 结尾、S 单字实体,对边界敏感的任务(比如地址、药品名)更友好。我一般推荐新手先用 BIO,标注成本低,工具兼容性好。

标签集合取决于你的业务。通用场景常见的有 PER(人名)、LOC(地名)、ORG(机构名)、TIME(时间)。垂直领域要自己扩展,比如医疗加 DIS(疾病)、DRUG(药品)、SYM(症状),金融加 STOCK(股票)、AMT(金额)。标签数量直接决定 CRF 转移矩阵的大小,标签越多,训练越容易过拟合,所以别一上来就搞三四十个标签。

一个容易翻车的点是:实体嵌套。比如「北京大学第三医院」既是 ORG 又包含 LOC,BIO 体系处理不了嵌套,只能选一个主标签。如果业务强依赖嵌套实体,得换 Span-based 或者多层标注方案,这套 BERT+BILSTM+CRF 就不太够用了。

2.2 把标注数据转成模型输入

假设你拿到的是 JSON 格式的标注数据,每条包含text和labels(与字符一一对应的标签列表)。BERT 的 tokenizer 对中文是按字切分,但会插入 [CLS]、[SEP],还可能把某些字符拆成 subword。所以对齐是第一个大坑。

from transformers import BertTokenizer import torch tokenizer = BertTokenizer.from_pretrained("bert-base-chinese") label2id = {"O": 0, "B-PER": 1, "I-PER": 2, "B-LOC": 3, "I-LOC": 4, "B-ORG": 5, "I-ORG": 6, "[PAD]": 7, "[CLS]": 8, "[SEP]": 9} def convert_to_features(text, labels, max_len=128): tokens = ["[CLS]"] + list(text) + ["[SEP]"] label_ids = [label2id["[CLS]"]] + [label2id[l] for l in labels] + [label2id["[SEP]"]] # 截断 if len(tokens) > max_len: tokens = tokens[:max_len] label_ids = label_ids[:max_len] # padding attention_mask = [1] * len(tokens) while len(tokens) < max_len: tokens.append("[PAD]") label_ids.append(label2id["[PAD]"]) attention_mask.append(0) input_ids = tokenizer.convert_tokens_to_ids(tokens) return { "input_ids": torch.tensor(input_ids), "attention_mask": torch.tensor(attention_mask), "labels": torch.tensor(label_ids) }

这段代码的逻辑是:先手动拼 [CLS] 和 [SEP],保证标签和 token 严格对齐;然后截断到 max_len,再做 padding。参数说明:max_len=128适合短文本,长文本(合同、病历)建议 256 或 512,但显存占用会平方级增长。label2id里给 [PAD]、[CLS]、[SEP] 单独分配 id,训练时要把这些位置的 loss 忽略掉,否则模型会去学预测特殊符号。

提示:如果你的标注数据里实体边界和 BERT tokenizer 的切分不一致(比如英文混中文),一定要写单元测试验证对齐,我见过太多因为错位一个字符导致 F1 掉 10 个点的血泪案例。

2.3 数据集划分与 DataLoader

按 8:1:1 划分训练、验证、测试集,注意同一篇文档的句子不要跨集合,否则会有信息泄漏。用 PyTorch 的 Dataset 封装:

from torch.utils.data import Dataset, DataLoader class NERDataset(Dataset): def __init__(self, samples, tokenizer, max_len=128): self.samples = samples self.tokenizer = tokenizer self.max_len = max_len def __len__(self): return len(self.samples) def __getitem__(self, idx): s = self.samples[idx] return convert_to_features(s["text"], s["labels"], self.max_len) train_loader = DataLoader(NERDataset(train_samples, tokenizer), batch_size=32, shuffle=True)

batch_size=32在 8G 显存的卡上跑 bert-base-chinese + BILSTM 基本够用,显存不够就降到 16 或 8,同时把 max_len 调小。shuffle 只对训练集开,验证和测试集保持顺序方便排查。

3. 模型搭建:BERT 输出怎么接 BILSTM 再接 CRF

3.1 三个模块各自干什么

BERT 的输出是每个 token 的 768 维向量(base 版),它已经包含了丰富的上下文语义,但它是双向 Transformer,对序列的「顺序约束」建模不如 RNN 显式。BILSTM 接在 BERT 后面,进一步捕捉相邻标签之间的依赖,同时把 768 维降到隐藏层维度(常用 128 或 256),减少 CRF 的计算量。CRF 层则学习标签之间的转移概率,保证输出序列合法。

为什么不能只用 BERT + 线性分类?因为线性分类是每个位置独立预测,会出现「B-PER 后面跟 I-LOC」这种非法序列。CRF 通过维特比解码全局最优路径,能显著提升边界准确率。实测在 MSRA 上,加 CRF 比不加 F1 高 1.5 到 2 个点。

3.2 完整模型代码

import torch.nn as nn from transformers import BertModel from torchcrf import CRF class BertBiLstmCrf(nn.Module): def __init__(self, bert_path, num_labels, lstm_hidden=256, dropout=0.3): super().__init__() self.bert = BertModel.from_pretrained(bert_path) self.dropout = nn.Dropout(dropout) self.bilstm = nn.LSTM( input_size=768, hidden_size=lstm_hidden, num_layers=1, bidirectional=True, batch_first=True ) self.classifier = nn.Linear(lstm_hidden * 2, num_labels) self.crf = CRF(num_labels, batch_first=True) 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) lstm_out, _ = self.bilstm(sequence_output) emissions = self.classifier(lstm_out) if labels is not None: # 把 padding 位置的标签设为 -100,CRF 内部会 mask 掉 mask = attention_mask.bool() loss = -self.crf(emissions, labels, mask=mask, reduction="mean") return loss else: return self.crf.decode(emissions, mask=attention_mask.bool())

逻辑说明:BERT 输出last_hidden_state形状是(batch, seq_len, 768);BILSTM 输出(batch, seq_len, 512)(双向 256 拼接);classifier 映射到标签数;CRF 接收 emissions 和真实标签算负对数似然损失。参数说明:lstm_hidden=256是经验值,太小欠拟合,太大过拟合且慢;dropout=0.3在 BERT 微调时常用,数据量小可以加到 0.5;num_layers=1够用,两层 BILSTM 收益递减还容易梯度问题。

注意:torchcrf这个库需要单独pip install pytorch-crf,它内部已经处理了转移矩阵和维特比解码,不用自己手写。如果你用的是 HuggingFace 的 Trainer,需要自定义 compute_metrics 和模型 forward 返回格式。

3.3 标签对齐与 loss 屏蔽

训练时最关键的一步是把 [PAD]、[CLS]、[SEP] 位置的标签设成 -100 或者用 mask 屏蔽。上面代码里用attention_mask.bool()作为 CRF 的 mask,CRF 内部会把 mask 为 0 的位置排除在转移计算之外。但 classifier 输出的 emissions 仍然包含这些位置,所以更稳妥的做法是在算 loss 前把 labels 里对应位置改成 -100,并在 CRF 里用mask参数。两种方式选一种,别混用。

如果发现 loss 一直不降,先检查标签 id 有没有越界、mask 有没有传对、BERT 的attention_mask是不是全 1。这三个地方是新手最常见的翻车点。

4. 训练、评估与推理:把模型跑起来并验证效果

4.1 训练循环与关键超参

from transformers import AdamW from tqdm import tqdm device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = BertBiLstmCrf("bert-base-chinese", num_labels=len(label2id)).to(device) optimizer = AdamW(model.parameters(), lr=2e-5, weight_decay=0.01) for epoch in range(10): model.train() total_loss = 0 for batch in tqdm(train_loader): 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() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() optimizer.zero_grad() total_loss += loss.item() print(f"epoch {epoch}, loss {total_loss / len(train_loader):.4f}")

超参说明:lr=2e-5是 BERT 微调的标准学习率,太大容易灾难性遗忘,太小收敛慢;weight_decay=0.01防过拟合;clip_grad_norm_设 1.0 防止梯度爆炸,BILSTM 接 BERT 时梯度偶尔会飙。epoch 一般 5 到 10,看验证集 F1 早停。

4.2 用 seqeval 算实体级 F1

token 级准确率会骗人,因为 O 标签占大多数。必须用实体级评估:

from seqeval.metrics import classification_report, f1_score def evaluate(model, loader, id2label): model.eval() preds, trues = [], [] with torch.no_grad(): for batch in loader: input_ids = batch["input_ids"].to(device) attention_mask = batch["attention_mask"].to(device) labels = batch["labels"].numpy() pred_ids = model(input_ids, attention_mask) for p, l, m in zip(pred_ids, labels, attention_mask.numpy()): pred_seq = [id2label[i] for i, mm in zip(p, m) if mm == 1] true_seq = [id2label[i] for i, mm in zip(l, m) if mm == 1] preds.append(pred_seq) trues.append(true_seq) print(classification_report(trues, preds)) return f1_score(trues, preds)

seqeval会把 B/I 标签合并成实体再比对,输出每个实体类型的 precision、recall、F1。参数说明:id2label是 label2id 的反转,注意把 [PAD]、[CLS]、[SEP] 排除掉。如果某个实体类型 F1 特别低,先看训练集里这类实体有多少条,少于 200 条基本学不好。

4.3 推理与后处理

def predict(text, model, tokenizer, id2label, max_len=128): model.eval() tokens = ["[CLS]"] + list(text) + ["[SEP]"] input_ids = tokenizer.convert_tokens_to_ids(tokens)[:max_len] attention_mask = [1] * len(input_ids) input_ids = torch.tensor([input_ids]).to(device) attention_mask = torch.tensor([attention_mask]).to(device) with torch.no_grad(): pred_ids = model(input_ids, attention_mask)[0] entities = [] current = None for char, pid in zip(list(text), pred_ids[1:len(text)+1]): label = id2label[pid] if label.startswith("B-"): if current: entities.append(current) current = {"type": label[2:], "text": char} elif label.startswith("I-") and current: current["text"] += char else: if current: entities.append(current) current = None if current: entities.append(current) return entities

后处理逻辑:遇到 B- 开新实体,遇到 I- 追加,遇到 O 或新 B- 就收尾。注意 pred_ids 要去掉 [CLS] 和 [SEP] 的位置。实际业务里还要加规则兜底,比如时间实体用正则再捞一遍,模型和规则互补。

提示:推理时 batch 化能显著提速,但要注意 padding 后的 mask 对齐。单条推理在 CPU 上大概 50ms,GPU 上 5ms 以内,生产环境建议用 ONNX 或 TensorRT 导出。

5. 避坑与排查:中文 NER 训练中最容易翻车的 5 个点

5.1 现象:loss 降到 0.1 以下但 F1 只有 60%

原因:标签泄漏或者数据泄漏。常见情况是验证集和训练集有重复句子,或者 [CLS]、[SEP] 位置的标签没屏蔽,模型学会了直接复制输入。解决:检查数据集划分有没有重叠,确认 CRF 的 mask 传的是 attention_mask 而不是全 1,把特殊位置的 label 设成 -100。

5.2 现象:某些实体类型 F1 为 0

原因:训练集里该类型样本太少,或者标签定义有歧义。比如「苹果」有时是 ORG 有时是 FOOD,标注不一致。解决:统计每类实体数量,少于 200 条的先合并或补充标注;写标注规范文档,让标注员对齐边界规则。

5.3 现象:训练到第 3 个 epoch 后验证集 F1 开始下降

原因:过拟合。BERT 参数量大,小数据集上很容易记住训练样本。解决:加大 dropout 到 0.5,加 weight_decay,早停(patience=2),或者冻结 BERT 前 6 层只微调后 6 层。数据量少于 5000 条时,冻结更多层往往更稳。

5.4 现象:推理时实体边界多一个字或少一个字

原因:BIO 标注边界和 tokenizer 切分不一致,或者后处理逻辑有 bug。解决:写单元测试,用「北京大学」这种已知实体验证输出;检查 tokenizer 是否把某些字拆成了 subword,如果是,需要在对齐时把 subword 的标签设成 -100 或复制首字标签。

5.5 现象:GPU 显存溢出(OOM)

原因:max_len 太大、batch_size 太大、或者 BILSTM 隐藏层太宽。解决:先把 batch_size 降到 8,max_len 降到 128,lstm_hidden 降到 128。还可以用梯度累积模拟大 batch:loss = loss / accum_steps; loss.backward(),每 accum_steps 步再 optimizer.step()。

6. 进阶调优:让 BERT+BILSTM+CRF 在垂直领域多拿 3 个点

6.1 用领域预训练模型替换 bert-base-chinese

通用 BERT 在医疗、金融、法律领域表现会打折。如果手上有领域语料,继续做 MLM 预训练,或者直接用现成的领域模型(比如医疗领域的 MC-BERT、金融领域的 FinBERT)。替换方式很简单,把BertModel.from_pretrained("bert-base-chinese")里的路径换成领域模型路径即可,tokenizer 也要同步换。实测在病历数据上,领域预训练能带来 2 到 4 个点的 F1 提升。

6.2 对抗训练与 FGM

在 embedding 层加扰动做对抗训练,能提升泛化。FGM(Fast Gradient Method)实现只要十几行:

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 = {}

用法:正常 forward + backward 后,fgm.attack(),再 forward + backward 一次,然后fgm.restore(),最后 optimizer.step()。epsilon 一般设 0.5 到 1.0,太大反而掉点。这个技巧在数据量少的时候特别管用,我一般会在验证集 F1 卡住时加上试试。

6.3 学习率预热与分层衰减

BERT 微调建议用 warmup,前 10% 步数线性升温,之后余弦衰减。HuggingFace 的get_linear_schedule_with_warmup直接可用。另外可以对 BERT 底层用更小的学习率,顶层和 BILSTM、CRF 用正常学习率,分层设置:

optimizer_grouped_parameters = [ {"params": model.bert.parameters(), "lr": 1e-5}, {"params": model.bilstm.parameters(), "lr": 1e-3}, {"params": model.classifier.parameters(), "lr": 1e-3}, {"params": model.crf.parameters(), "lr": 1e-3}, ] optimizer = AdamW(optimizer_grouped_parameters, weight_decay=0.01)

这样底层不轻易被破坏,顶层快速适配任务。实测比统一学习率稳,尤其在标注数据只有几千条的时候。

6.4 模型集成与投票

单模型 F1 到 92 之后,再想涨点就得靠集成。训 3 到 5 个不同随机种子的模型,推理时对每个 token 的标签做投票,或者对 emissions 取平均再走 CRF 解码。投票实现简单,但要注意标签 id 对齐。集成一般能再涨 0.5 到 1 个点,代价是推理成本翻倍,看业务能不能接受。

最后说个我自己的习惯:每次训完模型,我都会拿 20 条典型 badcase 逐条看,是边界问题、标签歧义还是数据缺失,比盯着 F1 数字有用得多。这套 BERT+BILSTM+CRF 方案不新,但胜在稳定、可解释、好调,垂直领域把数据和领域预训练做扎实,90% 以上的场景都能覆盖。希望帮到你。

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

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

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

立即咨询