☰
BiLSTM+CRF、IDCNN+CRF、BERT+BiLSTM+CRF实战解析
2026/10/1 19:16:23 网站建设 项目流程

简介:这是一份中文命名实体识别完整Python源码包,覆盖BILSTM+CRF、IDCNN+CRF、BERT+BILSTM+CRF三种主流模型,适合自然语言处理初学者、高校学生及需要完成课程设计或毕业设计的开发者。压缩包共58个文件、整体约13.75MB,其中16个py脚本为核心实现,19个pyc为依赖缓存,9个txt和4个md提供说明文档,另有5张png示意图、少量xml配置与csv数据文件;目录包含数据预处理、模型定义、训练与工具模块,层次清晰。代码按模型拆分为IDCNN_CRF、BILSTM_CRF、BERT_BILSTM_CRF等独立入口,并附带人民日报、MSRA、data2等数据集的预处理脚本及train.py训练流程,便于开展模型效果对照实验,可对照模型差异、直接复现或二次开发。资源内项目均调试通过,目前已有525人学习下载;无论想快速跑通基线、对比BERT引入后的效果,还是将NER模块接入自己的毕设或课设,都能基于这套源码较顺畅地完成。

1. 中文命名实体识别为什么绕不开这三种模型组合

做合同要素抽取、病历结构化、工单自动分类时,第一个卡住的往往不是分类模型,而是把“中国石油”“张三丰”这类人名、地名、机构名从一长段中文里捞出来。这就是中文命名实体识别(中文NER)要解决的问题。市面上能直接跑的 Python 源码,绝大多数绕不开 BILSTM+CRF、IDCNN+CRF、BERT+BILSTM+CRF 这三条路线:一条是序列标注的经典基线,一条是不吃预训练模型也能有一定感受野的卷积方案,一条是刷榜和上生产的首选。它们的共同点是都依赖 CRF 做标签约束,区别只在特征抽取器怎么把上下文信息编码出来。这篇我会把每条路线的数据准备、模型结构、核心参数和常见翻车点写透,源码以 PyTorch 为主,适合想快速落地、不想被论文公式劝退的读者。

2. 先把数据喂对:中文NER的字符切分、BIOES标注与字典构建

2.1 中文NER为什么按单字切而不是按词切

中文NER和英文NER最大的区别在于基本单元。英文按空格分词,词边界天然存在;中文如果按词切,就需要先跑一个分词器,一旦分词出错,实体边界就跟着错。更麻烦的是,中文里“中华/人民/共和国”切错了,整个标签序列就废了。所以业界做中文NER的默认做法是按单字(character-level)切分,一个汉字一个 token,标点符号也单独占一个 token。

这样做还有一个好处:OOV(未登录词)问题被压到最小。按词切的话,专业术语、人名生僻字很容易掉出词表;按字切,只要字典里覆盖了常用汉字,实体里几乎不会出现未登录 token。代价是序列变长,一句 50 个字的电话号加地址,切成 token 后有 60 多个,对后续模型的计算量和 CRF 的路径搜索都会产生影响。这个代价在实际工程里是值得的,因为标签错位的概率低了一大截。

2.2 构造字典与标签序列:一个 build_dataset 的完整实现

数据预处理的产物是三样东西:字表(word2idx)、标签表(label2idx)、以及把它们编码成 id 的训练样本。中文NER的标签体系常用 BIO(Begin/Inside/Outside)或 BIOES(多一个 End/Single)。BIOES 对实体边界的刻画更细,CRF 学起来更容易收束,我一般默认用 BIOES。

下面这段代码是把原始标注文本转成模型输入的常见做法:

def build_vocab(sentences, labels): word2idx = {"[PAD]": 0, "[UNK]": 1, "[CLS]": 2, "[SEP]": 3} label2idx = {"O": 0} for chars, tag_seq in zip(sentences, labels): for c in chars: if c not in word2idx: word2idx[c] = len(word2idx) for tag in tag_seq: if tag not in label2idx: label2idx[tag] = len(label2idx) return word2idx, label2idx def encode_sample(chars, tags, word2idx, label2idx, max_len): input_ids = [word2idx.get(c, word2idx["[UNK]"]) for c in chars][:max_len] tag_ids = [label2idx[t] for t in tags][:max_len] # 统一补到 max_len,避免 DataLoader 拼 batch 时报错 input_ids += [word2idx["[PAD]"]] * (max_len - len(input_ids)) tag_ids += [label2idx["O"]] * (max_len - len(tag_ids)) mask = [1] * min(len(chars), max_len) + [0] * max(0, max_len - len(chars)) return input_ids, tag_ids, mask

这段逻辑里三个关键点:一是[PAD]的 label 补的是O而不是某个特殊 id,因为 CRF 计算时会被 mask 掉,补什么都行,但补O最直观;二是 mask 必须和标签一一对应,否则 CRF 会把 padding 位置也当成真实标签去算转移概率;三是[UNK]兜底,防止验证集里出现训练集没见过的新字导致索引越界。如果你手头数据是词级别的标注,建议写个脚本先按字展开成 character-level 标注再入模,不要在模型侧做分词对齐,否则标签很容易错位。

2.3 实体类别不平衡:O 类占比过高时要做的两件事

中文文本里非实体字符通常占七成以上,如果不做任何处理,模型很容易学到“全部预测为 O”这种烂策略。常见做法是两件事:一是在 loss 上给实体标签加权重,二是用准确率之外的评价指标(实体级 F1)做早停和选模型。加权重可以直接改 CRF 的 loss 实现,在 log 似然上对每个标签位置乘一个class_weight,或者更省事一点,只在训练时用torch.nn.CrossEntropyLoss预训练一个辅助分类头,再切回 CRF 训练。我倾向于直接训练 CRF,但把class_weight设成统计出来的标签频率倒数的平方根,这样不用改模型结构。

3. 从零搭 BILSTM+CRF 与 IDCNN+CRF:两个非预训练模型的 PyTorch 实现

3.1 网络结构图景:BiLSTM 捕获上下文,CRF 约束标签转移

BILSTM+CRF 的定位是序列标注的经典基线。BiLSTM 负责把每个字的上下文信息编码成特征向量,CRF 负责在标签序列层面做全局约束:比如 B-person 后面不能直接接 I-org,O 后面不能紧跟 I-org,这类规则如果只靠 LSTM 输出每个位置独立 softmax,是学不干净的。BiLSTM 输出的每个位置分布,CRF 会结合转移矩阵重新打分,选出全局最优路径。

具体实现上,BiLSTM 的输入是字向量序列(可以是随机初始化的 embedding,也可以是预训练词向量),输出是每个位置的前向隐状态和后向隐状态拼接。拼接后的向量过一个线性层,压缩到标签类别数,得到 emission score。这个 score 不直接做 softmax,而是喂给 CRF 做维特比解码。

3.2 CRF 层自己写:转移矩阵、前向评分与维特比解码

很多读者会直接用torchcrf或fastNLP里现成的 CRF 层,但理解 CRF 内部逻辑对调参和排错非常重要。下面这个 CRF 类覆盖了核心逻辑:

import torch import torch.nn as nn class CRF(nn.Module): def __init__(self, num_tags): super().__init__() self.num_tags = num_tags # 转移矩阵,transitions[i][j] 表示从标签 i 转移到标签 j 的得分 self.transitions = nn.Parameter(torch.randn(num_tags, num_tags) * 0.01) self.start_transitions = nn.Parameter(torch.randn(num_tags) * 0.01) self.end_transitions = nn.Parameter(torch.randn(num_tags) * 0.01) def forward_alg(self, emissions, mask): # emissions: (batch, seq_len, num_tags) batch, seq_len, num_tags = emissions.size() score = self.start_transitions.expand(batch, -1) + emissions[:, 0] for t in range(1, seq_len): current_emission = emissions[:, t] # (batch, num_tags, 1) + (num_tags, num_tags) + (batch, 1, num_tags) next_score = score.unsqueeze(-1) + self.transitions.unsqueeze(0) + current_emission.unsqueeze(1) next_score = torch.logsumexp(next_score, dim=1) # 只有 mask 为 1 的位置才更新累计得分 score = torch.where(mask[:, t].unsqueeze(-1), next_score, score) final_score = score + self.end_transitions.unsqueeze(0) return torch.logsumexp(final_score, dim=1) def viterbi_decode(self, emissions, mask): batch, seq_len, num_tags = emissions.size() score = self.start_transitions.expand(batch, -1) + emissions[:, 0] backpointers = [] for t in range(1, seq_len): next_score = score.unsqueeze(-1) + self.transitions.unsqueeze(0) + emissions[:, t].unsqueeze(1) best_score, best_tag = torch.max(next_score, dim=1) backpointers.append(best_tag) score = torch.where(mask[:, t].unsqueeze(-1), best_score, score) final_score = score + self.end_transitions.unsqueeze(0) _, best_last_tag = torch.max(final_score, dim=1) paths = [best_last_tag] for bptrs in reversed(backpointers): paths.append(bptrs.gather(1, paths[-1].unsqueeze(1)).squeeze(1)) return torch.stack(paths, dim=1)[:, ::-1]

参数说明里最重要的一条:torch.logsumexp替代手工torch.exp再torch.log,避免指数上溢。转移矩阵初始化乘以 0.01 是为了让初始转移得分接近零,如果初始化得太大,CRF 前期会偏向于走某条固定标签路径,实体类别多的时候收敛很慢。mask 的逐时间步传递容易漏:很多实现只在 loss 里 mask,但forward_alg和viterbi_decode里也要 mask,不然 padding 位置的假标签会污染转移矩阵的学习。

3.3 IDCNN 实现:膨胀卷积块与残差连接

IDCNN(Iterated Dilated CNN)的思路是用膨胀卷积扩大感受野,替代 BiLSTM 的序列依赖。相比 LSTM,CNN 的好处是训练速度快、显存占用低,而且在 GPU 上并行度更高,缺点是感受野有限。IDCNN 的做法是把多个膨胀率不同的卷积层串起来,让高层能看到更长的上下文。

import torch import torch.nn as nn class DilatedConvBlock(nn.Module): def __init__(self, hidden_size, dilation): super().__init__() self.conv1 = nn.Conv1d(hidden_size, hidden_size, kernel_size=3, padding=dilation, dilation=dilation) self.conv2 = nn.Conv1d(hidden_size, hidden_size, kernel_size=3, padding=dilation, dilation=dilation) self.relu = nn.ReLU() self.dropout = nn.Dropout(0.2) def forward(self, x): # x: (batch, hidden, seq_len) out = self.relu(self.conv1(x)) out = self.dropout(out) out = self.conv2(out) return self.relu(out + x) # 残差连接,缓解深层网络梯度消失

这里padding=dilation的目的是让卷积输出长度和输入保持一致,不需要额外做长度对齐。整个 IDCNN 一般堆 3 到 4 个 block,每层 block 的 dilation 依次设为 1、2、4、8,最终每个位置的感受野能覆盖几十个字符。hidden_size 通常取 128 或 256,太小学不到复杂实体模式,太大显存翻倍但 F1 提升有限。如果实体普遍很短(人名、地名),dilation 到 4 就够;如果要识别“中国石油天然气集团有限公司”这种超长机构名,建议把 dilation 序列加长到 16。

IDCNN 的输入和 BiLSTM 不同,它需要的是(batch, hidden, seq_len)的三维张量,所以 embedding 输出后要先做维度转置。如果你拿到的手写 embedding 层输出是(batch, seq_len, hidden),记得x.permute(0, 2, 1)一下。

3.4 两种非预训练模型的选型与必备训练参数

BILSTM+CRF 适合数据集规模中等(1 万到 10 万句)、实体类型偏结构化、上下文语境相对固定的场景。IDCNN+CRF 更适合对时延敏感、训练资源受限、数据量在几千句的小项目。两者的训练参数我一般这样设:embedding 维度 100 或 200,LSTM hidden_size 取 128(双向拼接后是 256),dropout 取 0.5,batch_size 取 16 到 32,初始学习率 1e-3,每隔 2 个 epoch 在验证集上算实体级 F1,连续 3 个 epoch 不涨就降一半学习率。

有一个常见误区:有人喜欢给 BiLSTM 预训练一个 word2vec 或 glove 向量。中文NER 里这个操作收益不稳定,因为字级别的 embedding 需要的是“字在特定实体上下文里的语义”,而 word2vec 学的是词级别共现,对字符级别帮助有限。我现在一般直接随机初始化 embedding,把训练预算留在 CRF 和 LSTM 上,效果反而更稳定。

4. BERT+BILSTM+CRF:预训练特征如何与序列标注模块拼合

4.1 用 transformers 接入 BERT 并处理 padding 与 mask

BERT+BILSTM+CRF 和前面两种模型最大的差别在输入侧:BERT 吃的是 token 序列,输出是每个 token 的上下文语义向量(768 维)。这些向量替代了随机初始化的 embedding,进入 BiLSTM 和 CRF。代码结构上其实是把nn.Embedding换成BertModel。

from transformers import BertModel, BertConfig import torch.nn as nn class BertBilstmCrf(nn.Module): def __init__(self, bert_dir, num_tags, hidden_size=256, dropout=0.5): super().__init__() self.bert = BertModel.from_pretrained(bert_dir) self.bilstm = nn.LSTM( input_size=768, hidden_size=hidden_size // 2, num_layers=1, batch_first=True, bidirectional=True, dropout=dropout ) self.classifier = nn.Linear(hidden_size, num_tags) self.crf = CRF(num_tags) def forward(self, input_ids, attention_mask, labels=None): # 传入 attention_mask,让 BERT 自己屏蔽 padding 位置 bert_output = self.bert(input_ids=input_ids, attention_mask=attention_mask) sequence_output = bert_output.last_hidden_state # (batch, seq_len, 768) lstm_output, _ = self.bilstm(sequence_output) emissions = self.classifier(lstm_output) if labels is not None: # 这里需要把 attention_mask 转成 bool 型(1 表示有效,0 表示 padding) loss = -self.crf.forward_alg(emissions, attention_mask.bool()) # 还要减去真实标签路径的得分,构造负对数似然 loss = loss - self.crf.score(emissions, labels, attention_mask.bool()) return loss.mean() pred = self.crf.viterbi_decode(emissions, attention_mask.bool()) return pred

这里最关键的坑是 attention_mask 要透传到 CRF。BERT 的输出层会自动处理 padding,但 BiLSTM 不会,CRF 更不会。如果你只把 attention_mask 传给 BERT,CRF 在计算转移路径时会把 padding 位置也当成真实 token 去算,轻则 F1 偏低,重则训练不收敛。另外一个细节是 BERT 的 tokenizer 会把一些英文或数字拆成 subword,中文基本一字一 token,但如果你文本里混着英文和数字,input_ids长度会不等于字符数。这时候标签序列要和 BERT tokenizer 的 token 序列对齐,不能用原始字符标签硬怼。

4.2 分段学习率:BERT 与下游模块的四个必调参数

BERT 预训练参数和随机初始化的下游模块(LSTM、CRF、线性层)对学习率的敏感度完全不同。BERT 本身已经收敛到很平滑的损失面,学习率大了直接灾难性遗忘,小了下游模块不收敛。常见做法是用 AdamW 分配两段学习率:

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

参数设置思路:BERT 部分用 2e-5 到 5e-5 之间,超过 5e-5 很常见的情况是验证集 F1 先涨后崩;下游模块用 1e-3,因为它们是随机初始化,需要更大的步长快速收敛。max_len 建议设 128,中文一句话平均 30 到 50 个字,128 基本覆盖绝大多数情况,超过 128 的句子截断比硬塞进模型更划算。batch_size 在单卡 11G 显存上,BERT-base 加 LSTM 加 CRF 设 16 比较稳,超过 32 大概率 OOM。还有一个容易忽略的是 warmup。BERT 刚加载时对输入 distribution 有个适应过程,前 10% 的 step 用很小的学习率热身,我用的是get_linear_schedule_with_warmup,步数设成总步数的 10%。

4.3 何时可以去掉 BiLSTM,只留 BERT+CRF

BERT 输出的句向量本身已经带了双向上下文信息,BiLSTM 再加一层序列编码,很多时候属于锦上添花,而不是雪中送炭。在数据量少于 1 万句、实体类型固定(比如只抽手机号和身份证号)时,BERT+BILSTM+CRF 反而容易过拟合,因为 LSTM 层多出来的参数没有足够数据约束。实测中常见的情况是:BERT+CRF(把 LSTM 换成线性层直接映射到标签数)在 5000 句左右的小数据集上,比 BERT+BILSTM+CRF 高出 1 到 2 个点的 F1。

做大项目时我会把两者都跑一遍:用 BERT+线性层+CRF 当快速基线,如果验证集 F1 显示实体内部标签错乱(比如“阿里巴巴”标成了 B-org、I-org、O),再上 BiLSTM 增强上下文建模。BERT+BILSTM+CRF 真正发挥优势的场景是长文本里嵌套了多个实体、且实体上下文高度相似(比如法律文书里反复出现“原告”“被告”),这时候 BiLSTM 对局部上下文的重组能力能帮 CRF 更好地区分边界。

5. 三个模型实战避坑:标签错位、显存爆炸与CRF不收敛的排查记录

5.1 标签错位导致 CRF 训练损失正常但 F1 为 0

现象:训练 loss 稳步下降,验证集实体级 F1 却一直是 0,模型预测出的标签全员 O。

原因:标签序列和输入序列错位。最常见的是用 BERT tokenizer 处理文本后,忘了处理 subword 拆分,或者把原始字符标签直接硬贴到 token 序列上。Character-level 的数据,一个“ah”英文单词会被 tokenizer 拆成两个 token,原始标签只有一位,对不上。

解决:统一走“先过 tokenizer,再按 token 对齐标签”的流程。中文部分一字一 token 很好办,遇到英文和数字,在预处理时把连续的英文/数字块先合并成一个 token 位,人工标注时也按块标。如果已经错位了,训练前打印几条 input_ids 和标签的对照,一眼就能看出来。

5.2 显存爆炸:BERT 在 16G 卡上跑不起来

现象:batch_size 设 32,BERT+BILSTM+CRF 一跑就 CUDA out of memory,把 batch 调到 8 还是偶尔爆。

原因:BERT-base 每句话要算 12 层 transformer,中间激活值非常占显存;加上 BiLSTM 的反向传播,激活值进一步累积。序列长度稍微一长,显存暴涨。

解决:第一是把 max_len 从 256 砍到 128,中文实体很少跨 128 字;第二是开梯度累积,batch_size 设 8,累积 4 步再更新参数,效果和 batch 32 近似;第三是用混合精度训练,torch.cuda.amp的 GradScaler 能省一半显存,F1 几乎无损;最后实在不行就用 IDCNN+CRF 替代 BiLSTM,这部分能省出 2 到 3G 显存。

5.3 CRF 把实体边界学成“一个实体内标签全部相同”

现象:预测结果里“北京大学”四个字全部标成 B-org,或者全部标成 I-org,边界完全错。

原因:CRF 的转移矩阵学出了问题。BIOES 标签体系里 B 和 I 的转移约束很强,但如果训练数据较少,转移矩阵里“B 到 B”和“I 到 I”的概率被学成了高概率,模型倾向于整个连续片段用同一个标签。

解决:检查训练集标注质量,很多手工标注数据存在“B 后面接 B”的错误样本,CRF 会把这种错误当成规律。先写脚本统计训练集里所有转移对的频次,看 B→B、I→O 这种非法转移占比是否偏高;再考虑把 BIO 换成 BIOES 体系,E 标签能强制模型在实体结束时切换状态,边界识别会明显变稳。还有一个玄学点:CRF 的转移矩阵初始化改成均匀分布torch.rand(num_tags, num_tags) * 0.1,某些随机种子下能避免收敛到局部最优。

5.4 IDCNN 把长机构名识别成两段

现象:IDCNN+CRF 在“中国石油天然气集团有限公司”上只识别出“中国石油”和“天然气集团”,中间断开。

原因:IDCNN 的感受野不够。dilation 只堆到 4 时,每个位置的上下文窗口约 13 个字符,长实体中间的位置看不到实体开头和结尾,CRF 拿到的 emission score 不够强,模型选择在中间断开。

解决:把 dilation 序列改成 1、2、4、8、16,或者重复两个同样的膨胀卷积块。显存允许的话把 kernel_size 从 3 改成 5。改完之后在长实体样本上单独统计 F1,不要只看全局指标,全局 F1 容易被短实体刷上去。IDCNN 模型我不建议在实体平均长度超过 20 字的场景里硬刚,换 BiLSTM 或 BERT 更稳。

5.5 BERT+BILSTM+CRF 比 BERT+CRF 还差,模型训练不正常

现象:同一个数据集上,BERT+BILSTM+CRF 验证集 F1 反而比去掉 BiLSTM 的版本低 2 个点以上。

原因:小数据过拟合。BiLSTM 那层参数是随机初始化的,在 5000 句级别的数据上,它学到了训练集特有的局部模式,泛化能力不如直接线性映射。

解决:先用 BERT+CRF 跑出一个 F1 基线,再去调 BiLSTM。如果 BiLSTM 版本确实差,检查它的 dropout 是否足够(0.5 起步);再检查 BiLSTM 的 hidden_size,128 已经偏大时可以降到 64;如果还不够,就把 BiLSTM 换成一层nn.Linear(768, num_tags),不要为了“标题里有这个结构”而死守一个效果更差的模型。模型选型是工程问题,不是论文复现。

6. 三套模型横向验证:用同一套评估脚本做选型决策

模型不能只看训练 loss,中文NER 只看准确率也没有意义,因为 O 类占了大部分。我一般用实体级 precision、recall、F1 来做横向对比,seqeval这个库算的就是实体级别的指标,比 sklearn 的 token 级准确率靠谱得多:

from seqeval.metrics import classification_report, f1_score def evaluate(model, dataloader, idx2label): model.eval() true_entities, pred_entities = [], [] with torch.no_grad(): for batch in dataloader: input_ids, masks, tag_ids = batch pred_ids = model.predict(input_ids, masks) for t, p, m in zip(tag_ids, pred_ids, masks): true_seq = [idx2label[i] for i in t[:m.sum().item()]] pred_seq = [idx2label[i] for i in p[:m.sum().item()]] true_entities.append(true_seq) pred_entities.append(pred_seq) print(classification_report(true_entities, pred_entities, digits=4)) return f1_score(true_entities, pred_entities)

用这个脚本把三种模型在同一个验证集上的 F1 打出来,再结合训练时间、显存占用和推理时延做选型,决策就有依据了。表格是我常用的判断模板:

模型组合数据量要求训练显存(base 级)推理速度适用场景
BILSTM+CRF1 万句以上2G 以内快通用基线、无预训练资源
IDCNN+CRF几千句可用1G 左右最快低时延、短实体为主
BERT+BILSTM+CRF3 万句以上8G 到 12G慢长文本、边界复杂、高精度

最后分享一个我养成的坏习惯改出来的经验:拿到 NER 任务先不要急着上 BERT 全家桶,先用 BILSTM+CRF 跑一夜当基线,记录它在哪里错;再根据错误类型决定要不要换 IDCNN 或上 BERT。很多时候基线模型的错误集中在某一种实体类别上,加几条规则或补标注就解决了,不需要动用预训练模型。如果你直接上 BERT+BILSTM+CRF,调参和排错的成本会翻好几倍,而且很难说清到底是模型结构的问题还是数据的问题。希望帮到你。

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

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

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

立即咨询