☰
中文NER实战:三种模型选型与PyTorch实现
2026/10/9 6:49:13 网站建设 项目流程

简介:资源包聚焦中文命名实体识别(NER)主流模型,集成BILSTM+CRF、IDCNN+CRF、BERT+BILSTM+CRF三种Python实现,覆盖数据预处理、模型训练与预测的完整流程,适合NLP初学者、算法工程师及需要完成课程设计或毕设的在校学生参考。压缩包内共58个文件,以16个Python脚本为核心,另含9个TXT配置/数据文件、4个Markdown说明文档、5张PNG结构示意图及CSV数据表等辅助材料,整体约13.75MB,按模型与数据集分目录存放,便于定位和理解。目前已有525人次浏览学习,代码经运行验证,可直接执行或在此基础上调整参数、扩展新模型。学习者可从中获得多模型对比思路、具体的数据预处理脚本与训练配置,并能快速复用MSRA、人民日报等公开数据集的处理逻辑,降低中文NER入门门槛。

1. 中文命名实体识别为什么值得自己跑一遍源码:从选型焦虑到可复现的基线

中文命名实体识别(NER)在真实项目里很少能直接套用现成的云端接口,医疗、法律、工业文档里的实体类型往往是定制的,你需要一套能自己控制数据、模型和推理逻辑的代码。BILSTM+CRF、IDCNN+CRF、BERT+BILSTM+CRF这三种架构,基本覆盖了从低资源快速上线到追求精度的常见路径,也是面试和工程方案里最常被拿出来对比的三种形态。这篇文章想解决的是:这三种模型各自的选型依据是什么,怎么用PyTorch把它们写出来并跑通,以及训练中文数据时那些不亲自踩一遍就不知道的坑。适合正在搭NER基线、准备替换掉第三方接口、或者需要向团队证明某个架构是否值得投入的工程师。

2. 三种模型的选型逻辑:BILSTM+CRF、IDCNN+CRF与BERT+BILSTM+CRF各自的适用边界

2.1 从序列标注视角理解NER:BILSTM+CRF为什么是标配基线

命名实体识别本质上是个序列标注任务,输入是字符或分词后的单元,输出是每个位置上的标签,常见方案是BIO标注,B表示实体开始,I表示实体内部,O表示非实体。想要让模型在“北京”和“背景”之间做出区分,仅仅看当前字符是不够的,需要左侧上下文和右侧上下文同时参与判断。这就是双向LSTM存在的理由,它把一句话的正向和反向信息各自编码一遍,然后在每个位置上拼接两个方向的特征。

但光有BiLSTM还不够,因为LSTM在每个位置上做出的分类是独立的,它不知道“B-PER后面必须跟I-PER”这样的约束。比如“张三”被预测成“B-PER I-PER”是合法的,“B-PER B-PER”就不合理。CRF(条件随机场)层做的正是这件事,它学习一个标签转移矩阵,在解码阶段用维特比算法找到全局最优的标签序列。BiLSTM提供发射分数,CRF提供转移约束,二者结合是中文NER最经典的基线方案。

class BiLSTMCRF(nn.Module): def __init__(self, vocab_size, tag_size, embedding_dim, hidden_dim): super().__init__() self.embedding = nn.Embedding(vocab_size, embedding_dim) self.bilstm = nn.LSTM(embedding_dim, hidden_dim, batch_first=True, bidirectional=True) self.fc = nn.Linear(hidden_dim * 2, tag_size) self.crf = CRF(tag_size)

这段代码里CRF用了外部库,常见做法是使用pytorch-crf或者自己实现对数似然损失。hidden_dim一般取128或256,embedding_dim取100到300,中文场景下直接加载预训练的词向量往往比随机初始化更稳,但如果训练数据量够大,随机初始化也能收敛,不必在这个环节纠结太久。

选型上,如果你的标注数据在一万句以内、硬件只有一块普通GPU甚至CPU、项目周期以周为单位,BILSTM+CRF是性价比最高的起点。它的训练时间通常在分钟级到小时级,推理也快,而且参数规模可控,便于后续上线部署。很多生产环境里,这个模型的F1值在通用中文NER数据集上能做到85到90之间,对定制实体来说,只要数据质量过关,效果往往比想象中好。

2.2 IDCNN+CRF的定位:用空洞卷积替代循环网络省显存

IDCNN是Iterated Dilated CNN的缩写,核心思路是用空洞卷积(dilated convolution)扩大感受野,同时保持参数量和计算量可控。循环网络在处理长序列时必须按时间步展开,并行度差,IDCNN则可以在整句上并行卷积,训练速度明显更快,显存占用也低。代价是它没有显式的顺序建模能力,对远距离依赖的捕捉不如BiLSTM细腻,所以需要叠加多层不同空洞率的卷积来弥补。

常见做法是堆叠四层空洞卷积,空洞率分别取1、1、2,这种组合能让每个输出位置看到较大范围的上下文,同时避免过大的空洞率导致信息跳跃。每一层后面接归一化和ReLU,最后把每层的输出拼接起来作为特征。IDCNN对短实体(人名、地名、组织机构名)效果尚可,但遇到“某个条款中的某个金额”这类需要长依赖的实体,会比BiLSTM弱一些。

class IDCNN(nn.Module): def __init__(self, input_dim, filters, dilations): super().__init__() self.layers = nn.ModuleList() for d in dilations: self.layers.append(nn.Conv1d(input_dim, filters, kernel_size=3, padding=d, dilation=d)) self.activate = nn.ReLU() self.norm = nn.LayerNorm(filters) def forward(self, x): # x: (batch, seq_len, input_dim) -> permute for Conv1d x = x.permute(0, 2, 1) outputs = [] for layer in self.layers: x = self.activate(layer(x)) x = self.norm(x.permute(0, 2, 1)).permute(0, 2, 1) outputs.append(x) return torch.cat(outputs, dim=-1)

这段IDCNN的输入输出设计里,dilations参数是调参重点。只用一组空洞率1、1、2的话感受野有限,我会再叠一组2、4、8的配置,让浅层捕捉局部字形信息,深层捕捉跨字符的语义线索。filters一般取128或256,太小拟合不足,太大训练变慢而收益有限。

选型上,IDCNN+CRF适合对延迟敏感的上线场景。它比BiLSTM快不少,显存占用也友好,在CPU上推理时优势尤其明显。如果你需要处理的是短文本、实体边界规整的领域,IDCNN+CRF完全够用;如果实体嵌套复杂、长距离依赖明显,那就回到BiLSTM或者上BERT。

2.3 BERT+BILSTM+CRF的代价与收益:预训练上下文到底值多少

BERT+BiLSTM+CRF在结构上很好理解:BERT负责把每个字符转成带有上下文信息的向量,BiLSTM在这个向量序列上再做一次序列建模,CRF负责输出约束。很多人会问,BERT本身已经很强了,为什么还要加BiLSTM?直接接CRF不行吗?直接接也能跑,但在小数据集上BiLSTM可以起到正则化和序列平滑的作用,并且它让模型保留了对局部顺序结构的建模能力。当然,如果你用的是大规模预训练后微调过的BERT,层数够深,直接BERT+CRF往往也能追平。

class BertBiLSTMCRF(nn.Module): def __init__(self, bert_model, tag_size, hidden_dim): super().__init__() self.bert = bert_model self.bilstm = nn.LSTM(768, hidden_dim, batch_first=True, bidirectional=True) self.fc = nn.Linear(hidden_dim * 2, tag_size) self.crf = CRF(tag_size)

BERT输出的768维向量是这份代码里最关键的输入。hidden_dim建议128就够了,因为BERT已经提供了很强的特征表示,BiLSTM实际上在做一个轻量级的再编码工作,隐藏层过大会导致过拟合,尤其在标注数据少于两万句的场景下。微调时的学习率一般设置为2e-5到5e-5,比训练BiLSTM时的1e-3小一到两个数量级,因为BERT内部的参数已经处于一个较好的局部最优附近,学习率太大会破坏预训练权重。

选型判断要看你的资源和精度诉求。BERT系列模型的显存占用通常在6GB到16GB之间,以Bert-base为例,单卡训练十万句级别的中文数据需要数小时到一天。如果你的实体类型超过十种、文本风格多样且数据量充足,BERT+BILSTM+CRF往往能比前两种模型高出3到5个点的F1。如果预算紧张或者需要高并发的线上推理,前两种轻量模型更务实。我一般会先用BILSTM+CRF跑一版基线,记录时间和F1,再决定要不要往BERT迁移。

3. 把数据喂给模型前的必修课:标注格式、字典构造与批次填充

3.1 BIO标注与数据文件格式:从原始文本到train/dev/test

动手写模型之前,先把数据格式定下来。中文NER最常用的标注粒度是字符级,原始句子被拆成单个汉字,每个字对应一个标签。比如“张三在北京”这句话,在BIO标注下应该写成一列文本加一列标签,每行一对,句子之间用空行隔开。文件通常按train.txt、dev.txt、test.txt组织,模型训练时按行读取并重新组合成句子序列。

标注的一致性直接影响模型上限。我见过不少团队在“机构的简称算不算实体”“数字加单位算不算一个实体”这些边界问题上没有统一标准,结果训练集和测试集标注口径不一致,模型再强也只能学到一个混乱的目标。所以数据文件写好之后,至少要抽一版统计结果,看每个标签的样本数量分布是否合理。

# 查看训练集中每个标签的出现次数 awk '{print $2}' train.txt | sort | uniq -c | sort -rn

这段命令在数据预处理阶段很有用。如果发现B-PER有一万条而I-PER只有几百条,说明你的标注把多字人名拆开了,或者标注过程中截断出了问题。标签分布极端不均衡会让CRF学到过于保守的转移概率,推理时倾向于输出O标签。

3.2 字符索引与标签索引的构造:padding、mask与唯一索引

模型吃数字,不吃汉字。你需要构建两个字到索引的映射表,一个是字符表vocab,一个是标签表tag_vocab。字符表一般基于训练集构建,dev和test里出现的生僻字会被映射为UNK。标签表固定为BIO标签加一个PAD,比如B-PER、I-PER、B-LOC、I-LOC、O、PAD共六类。索引从0开始,但PAD标签要单独占一个索引,在损失计算时通过mask忽略掉。

padding的逻辑是:一个batch里句子长度不同,要把短句补到该batch内最长句子的长度。这个长度不能直接取整个训练集的最长句,而要看显存。BERT模型的长度上限是512,很多中文长文本超过这个长度,常见做法是截断或者滑动窗口切分。BILSTM和IDCNN没有512这个限制,但过长的句子会让训练效率下降,一般控制在256以内。

def build_vocab(file_path): vocab = {"[PAD]": 0, "[UNK]": 1} with open(file_path, 'r', encoding='utf-8') as f: for line in f: line = line.strip() if line: char = line.split()[0] if char not in vocab: vocab[char] = len(vocab) return vocab

这段代码把字符收集进字典,注意[PAD]和[UNK]用0和1这个写法,后面所有地方都要保持一致。如果你在训练时用了这个字典,但推理时重新构建了一份字典,索引就会错位,预测结果全错。我一般把vocab.pkl和tag_vocab.pkl在训练结束后序列化保存,推理脚本只负责加载,绝不现场重建。

3.3 三个影响结果的数据细节:随机种子、实体边界与样本均衡

第一个细节是随机种子。PyTorch里数据加载顺序、dropout、LSTM初始化都依赖随机数,不固定种子会导致每次训练结果浮动一到两个点。在代码开头设置seed,并且保证DataLoader的shuffle参数基于这个种子,才能复现实验结果。我在项目里习惯把seed直接写进配置文件的固定值,训练脚本从头到尾引用同一个值。

第二个细节是实体边界的样本均衡。BIO标注天然存在类不平衡,O标签通常占全部标签的80%以上。这不一定是问题,因为模型需要学会拒绝非实体,但如果O的比例超过90%,模型会倾向把所有输入都预测为O来压低损失。常见缓解做法是在损失函数里给B和I标签更高的权重,或者对O标签做欠采样。用类别权重的方式实现起来最简单。

第三个细节是长句和短句的batch内长度差异。如果一句20个字和一句180个字放进同一个batch,padding后大部分计算浪费在PAD位置上。解决方法是按句子长度排序后分批,或者使用bucket机制。虽然PyTorch的DataLoader不能直接支持bucket,但可以在预处理阶段把训练数据按长度分成几个桶,每个桶内部打乱再拼接。

4. 用PyTorch实现三种中文NER模型:核心代码与训练参数

4.1 BILSTM+CRF的搭建:从BiLSTM编码到CRF解码的完整实现

BiLSTM+CRF的训练核心是CRF的对数似然损失。前向时你需要计算真实标签序列的路径分数和所有可能路径分数的logsumexp,两者相减就是损失。维特比解码在推理阶段使用,它动态规划地找到一条分数最高的标签路径。很多现成库提供了封装好的CRF层,但如果你要上线生产,自己实现一遍能让你在报错时更快定位问题。

import torch from torchcrf import CRF class BiLSTMCRF(nn.Module): def __init__(self, vocab_size, tag_size, embedding_dim=128, hidden_dim=256): super().__init__() self.embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx=0) self.bilstm = nn.LSTM(embedding_dim, hidden_dim, num_layers=1, batch_first=True, bidirectional=True) self.fc = nn.Linear(hidden_dim * 2, tag_size) self.crf = CRF(tag_size) def forward(self, x, mask, labels=None): emb = self.embedding(x) lstm_out, _ = self.bilstm(emb) emissions = self.fc(lstm_out) if labels is not None: return -self.crf(emissions, labels, mask=mask) else: return self.crf.decode(emissions, mask=mask)

逻辑说明:embedding层的padding_idx设为0,对应数据预处理时[PAD]的索引,这样padding位置不会被更新梯度。forward里传入mask是关键,CRF在计算路径分数时必须跳过padding位置,否则维特比路径会把PAD标签当成合法输出。训练时返回负的对数似然作为损失,推理时不传labels,直接调用decode返回预测序列。参数上,bilstm的num_layers我一般设1,加深到2层对NER任务收益不明显,但训练时间接近翻倍。hidden_dim设为256时,单卡训练一千句左右的标注数据,大约五分钟后就能看到损失明显下降。

4.2 IDCNN+CRF的实现:空洞卷积堆叠与感受野控制

IDCNN的输入是字符向量序列,输出是每个位置的发射分数。它的结构比LSTM简单,但调参更敏感。空洞率的设定决定了模型能看多远,空洞率太小感受野不够,太大则可能跳过了关键的局部字符组合。前面提到过的“1、1、2、2、4、8”六层结构是我常用的配置,每层输出通道恒定,最后把特征拼接后过线性层映射到标签数量。

class IDCNNCRF(nn.Module): def __init__(self, vocab_size, tag_size, embedding_dim=128, filters=128, dilations=[1, 1, 2, 2, 4, 8]): super().__init__() self.embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx=0) self.idcnn = IDCNN(embedding_dim, filters, dilations) self.fc = nn.Linear(filters * len(dilations), tag_size) self.crf = CRF(tag_size) def forward(self, x, mask, labels=None): emb = self.embedding(x) features = self.idcnn(emb) emissions = self.fc(features) if labels is not None: return -self.crf(emissions, labels, mask=mask) else: return self.crf.decode(emissions, mask=mask)

逻辑说明:IDCNN内部每层卷积后都接了LayerNorm,这对训练稳定性很重要,空洞卷积叠加层数多之后梯度容易抖动,LayerNorm能把它压住。filters取128时显存占用只有BiLSTM方案的三分之二左右,训练速度却快30%到50%。需要注意padding参数必须等于dilation值,这样卷积后的序列长度保持不变,否则每个batch的seq_len会在层间变化,CRF阶段就会因序列长度不一致而报错。

有一个容易忽略的细节:IDCNN对字符的位置顺序敏感度弱于LSTM,虽然卷积核能捕捉局部n-gram模式,但依赖“词序反转后语义完全不同”的场景。如果你处理的实体类型中有“前总裁”和“总裁前”这种顺序敏感的表述,IDCNN的效果可能会让你失望。

4.3 BERT+BILSTM+CRF:加载预训练权重后的微调策略

BERT方案的第一步是选一个合适的中文预训练模型。bert-base-chinese是通用领域的首选,如果数据来自医疗、法律等专业领域,用哈工大讯飞联合发布的MacBERT或者法律BERT可能在下游任务上更稳。加载方式用transformers库的BertModel.from_pretrained即可,但要记得把模型切到训练模式并关闭不需要的输出。

from transformers import BertModel class BertBiLSTMCRF(nn.Module): def __init__(self, pretrained_path, tag_size, hidden_dim=128): super().__init__() self.bert = BertModel.from_pretrained(pretrained_path) self.bilstm = nn.LSTM(self.bert.config.hidden_size, hidden_dim, batch_first=True, bidirectional=True) self.fc = nn.Linear(hidden_dim * 2, tag_size) self.crf = CRF(tag_size) def forward(self, input_ids, attention_mask, labels=None): bert_out = self.bert(input_ids=input_ids, attention_mask=attention_mask).last_hidden_state lstm_out, _ = self.bilstm(bert_out) emissions = self.fc(lstm_out) valid_mask = attention_mask.bool() if labels is not None: return -self.crf(emissions, labels, mask=valid_mask) else: return self.crf.decode(emissions, mask=valid_mask)

逻辑说明:BERT输出的sequence_output包含了每个token的上下文表示,直接喂给BiLSTM。这里有两个细节格外重要。第一,attention_mask既被BERT用来屏蔽PAD位置的注意力,也被CRF用来屏蔽PAD标签的路径计算,两边必须用同一个mask张量,有一处不一致就会导致训练和推理行为差异。第二,BiLSTM的hidden_dim设128,因为BERT的768维特征已经足够丰富,再用大的隐藏层只会拖慢速度。

微调时的优化器设置直接影响收敛质量。常见做法是使用AdamW,学习率2e-5,weight_decay设为0.01。BERT部分的参数和BiLSTM、CRF部分的学习率可以不一样,做法是对模型参数分组,BERT组用小学习率,新初始化的层用1e-3的学习率。这样做的理由是新层需要更快收敛,而预训练参数只需要微调即可。

4.4 统一训练入口:损失函数、学习率与收敛判断

三种模型共用一个训练循环,只需要在配置里切换model_name。训练循环里要处理的细节包括:梯度裁剪、学习率调度和验证逻辑。LSTM和IDCNN这类非预训练模型,梯度裁剪能防止RNN梯度爆炸,一般设max_grad_norm为5.0。BERT模型则不太需要梯度裁剪,AdamW自带权重衰减已经在约束参数更新幅度。

for epoch in range(epochs): model.train() for batch in train_loader: x, mask, labels = batch loss = model(x, mask, labels) optimizer.zero_grad() loss.backward() if model_name != 'bert': torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0) optimizer.step()

学习率调度方面,BERT用warmup策略,前10%的step线性增加学习率,之后线性衰减。BiLSTM和IDCNN用固定学习率1e-3配ReduceLROnPlateau,当验证集F1连续三个epoch不涨时把学习率乘以0.5。这种方法简单粗暴但有效,能避免手动调学习率衰减时机。收敛判断以验证集F1为唯一标准,不要看训练集loss,训练集loss降得很低并不代表泛化好。

5. 中文NER训练避坑记录:五个最常见的翻车现场

5.1 现象:训练loss下降但F1不动——标签不平衡与实体边界学习不足

loss一直降,F1卡在某个低位徘徊,这是训练时最让人难受的情况。现象背后通常有两个原因:一是O标签占比过高,模型学会把所有位置都预测为O来降低损失;二是模型学到了实体的大致位置,但边界不准确,预测结果总是把实体的第一个字或最后一个字标错。解决方法是给损失函数加类别权重,把B和I标签的权重设成O的2到3倍。另一个辅助手段是检查预测结果中实体长度的分布,看看模型是不是只输出了单字实体。如果B标签预测正确但I标签大面积错误,问题多半出在CRF转移矩阵上没有学到B到I的强约束,需要检查训练数据里是否真的存在连续的B标签输出。

5.2 现象:BERT版本一上来就OOM——max_length、batch_size与梯度累积的配合

BERT模型对显存的要求远高于非预训练模型。OOM的错误信息常见的是CUDA out of memory,第一反应应该是调低batch_size,但这会让训练变慢。梯度累积可以缓解这个矛盾,所谓梯度累积,就是把原本一个batch的梯度分成几个小batch分别计算,累加后再更新一次参数。常见做法是batch_size设16,梯度累积步数设2,等效batch_size为32。另一个被忽视的OOM来源是max_length设得太大,中文文本动不动就512的序列长度,显存占用随长度线性增长,如果业务实体多出现在短句中,把max_length从512降到256会节省一半显存。

5.3 现象:CRF解码报错——非标注序列与transitions矩阵的维度问题

CRF的报错通常集中在推理阶段,报错信息类似于expected tag size mismatch。原因往往有两个:一是标签数量在训练和推理时不一致,例如训练时用了六个标签,推理时传入的tensor却只包含四个标签索引;二是batch内句子实际长度和mask不一致,导致CRF在维特比解码时走到了PAD标签。解决方法是写一个断言,在decode前检查emissions的最后一个维度等于tag_vocab的长度,同时检查mask的True数量等于句子的真实长度。排错时可以在代码里临时打印batch里的原始文本和mask,对照着看问题出现在哪个环节。

5.4 现象:IDCNN感受野不够——空洞率设置与层数的搭配

IDCNN一个典型问题是模型对长实体的识别几乎完全失败,只对两三个字的人名有效。这个现象的直接原因是感受野没有覆盖到实体的完整长度。如果真实的实体长度在六个字以上,而空洞卷积叠加后的感受野只有五个字符,模型根本看不到完整的实体上下文。解决方法是增加空洞率较大的层,比如在原有配置上追加dilation=8和dilation=16的层,同时适当增加filters来补偿卷积参数增多后的表示能力。

5.5 现象:模型预测全是O——标签映射混乱与预处理不一致

预测阶段全输出O的翻车现场比多数人想象的更常见。排查的第一步是打印一两个样本的input_ids,看它们对应的字符是否和原始句子一致。很多时候问题是vocab字典在训练和推理脚本里不一致,同一个字符在两套字典里的索引完全不同。第二步是检查标签索引,训练时的tag_vocab是按B-PER、I-PER、B-LOC、I-LOC、O、PAD这个顺序建的,推理时如果代码里写死了标签名称而没有加载保存的字典,预测结果就无法映射回正确的标签。第三步是看输入格式,BERT和BiLSTM的输入都要求是LongTensor,如果你在推理时用了numpy数组直接传给模型,PyTorch可能会静默地把浮点数截断成整数,导致所有字符索引变成0或1,输出自然全是O。这个坑我在生产环境踩过一次,排查了整整一个下午。

6. 把模型部署到真实文本上的最后一步:推理脚本、指标计算与效果验证

模型训练完,真正的工作才刚开始。推理脚本需要一个干净的单条文本处理流程:原始句子进来,先按字符转为索引,做padding和mask,然后传入模型得到预测标签序列,最后把标签索引映射回BIO标签并与原始字符合并展示。这个流程必须和训练时的预处理完全一致,包括vocab字典、max_length、是否加特殊token。BERT模型推理时要注意,transformers的tokenizer会把中文按字切分,同时加入[CLS]和[SEP],你需要把这两个特殊token对应的预测位置剔除,才能拿到与原始文本长度对齐的标签序列。

验证指标用F1就够了,但micro和macro要选清楚。实体级别的F1是按预测出来的实体片段和真实实体片段做匹配来计算的,只要有一个词的边界不同就算错。我在团队里习惯另外写一个脚本,把模型预测结果和真实结果逐条对齐输出到文件里,再用diff工具手动检查错误模式。这个习惯救过我很多次,因为F1只是一个数字,掩盖了错误类型,不亲自看一眼预测结果,你永远不知道模型是把“北京”识别成了人名还是漏掉了带引号的机构名。

部署时有一个不复杂但重要的技巧:给模型包裹一层统一的函数,输入字符串,输出实体列表。这个函数内部做所有的预处理和标签映射,调用方不需要关心模型细节。这个封装的价值在于,无论你后续把模型换成BERT还是把BiLSTM换成IDCNN,调用方的代码都不用改动。我在生产项目里就是靠这一层封装,把三种模型跑了AB对比,最终用F1和推理时延两个指标说服了团队保留哪种方案。希望你也能把这三条路跑通,找到最适合自己业务的那一个。

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

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

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

立即咨询