news 2026/10/2 21:55:46

中文命名实体识别实战:BERT+BiLSTM+CRF课设指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
中文命名实体识别实战:BERT+BiLSTM+CRF课设指南

简介:这份资源面向计算机相关专业的本科生与课程设计学习者,提供一套基于BERT+BiLSTM+CRF的中文命名实体识别完整源码,适合作为毕业设计、期末大作业或NLP入门实战项目。项目采用预训练语言模型提取语义特征,结合双向LSTM与条件随机场完成序列标注,覆盖数据预处理、模型训练、预测推理与服务部署等环节,代码注释详尽,新手也能理解整体流程。压缩包共54个文件,以34个Python源码为核心,辅以11张运行截图、3份说明文档、3个Markdown笔记及构建脚本与许可证文件,整体约475KB,结构紧凑便于快速部署。目前已有178人学习关注。读者可获得一套可直接运行的NER工程方案,包括训练与预测脚本、服务端接口示例、依赖清单及环境配置说明,便于在此基础上替换数据集、调整超参数或扩展实体类别,也能借助截图与文档快速排查运行问题,具备较高的课设与毕设参考价值。

1. 中文命名实体识别:为什么 BERT+BiLSTM+CRF 仍是课设首选

做过中文 NER 的人都有一个共识:数据集小、实体边界模糊、标注不一致,这三件事凑在一起,模型很容易学成“复读机”——把训练集里的实体背下来,换一段新文本就崩。我带过几届毕业设计,见过太多同学用纯 BiLSTM+CRF 跑出 85% 的 F1,答辩时被问“换个领域还能用吗”就答不上来。问题不在模型结构,而在字向量太薄,没有预训练语言模型的语义先验。

BERT+BiLSTM+CRF 这套组合,恰好卡在“效果够用”和“算力可承受”之间。BERT 负责把每个汉字映射成带上下文信息的向量,BiLSTM 捕捉序列前后的长距离依赖,CRF 层则保证输出的标签序列合法——比如“B-ORG”后面不会直接跟“I-PER”。对于中文命名实体识别这个任务,标签之间的转移约束不是锦上添花,而是刚需。你如果只做课设或毕业设计,数据量通常在几千到几万条,这套结构能在单卡 8G 显存内跑起来,训练两三个小时就能看到收敛趋势。

适合谁读:正在做 Python 毕业设计或课程设计、需要一份能跑通的中文 NER 源码、对 BERT 微调流程不熟但想搞懂每一步在干什么的人。下面我会按“数据怎么进、模型怎么搭、参数怎么调、坑怎么避”的顺序,把整套方案拆成可复现的步骤。你不需要先精通 Transformer,但得会装 Python 环境、能看懂 PyTorch 的基础张量操作。

2. 从原始文本到 BERT 输入:数据预处理与标签对齐

2.1 中文 NER 的数据格式与标签体系

中文 NER 常见的数据格式有两种:BIO 和 BIOES。BIO 用 B-X 表示实体开始,I-X 表示实体内部,O 表示非实体;BIOES 多了 E-X(实体结束)和 S-X(单字实体)。课设里我一般推荐 BIO,因为标注成本低,而且 BERT+BiLSTM+CRF 对 BIO 的边界识别已经足够好。标签体系取决于你的数据集,比如 MSRA 用 PER、LOC、ORG 三类,人民日报语料用 Nh、Ns、Ni。你自己标数据的话,先定好实体类型,别中途加类,否则前面标的全废。

数据文件通常是一行一个字加标签,空行分隔句子。下面是一个标准样例:

北 B-LOC 京 I-LOC 是 O 中 B-ORG 国 I-ORG 的 O 首 O 都 O

读取时按空行切分句子,每句变成一个(chars, labels)对。注意:中文不需要分词,BERT 的 tokenizer 对汉字基本是一字一 token,但遇到英文或数字会拆成子词。如果你的数据里混了英文实体,比如“Python”,tokenizer 可能把它拆成“Py”“##thon”,这时候标签对齐就会出问题。常见做法是:在预处理阶段把英文和数字也按字符拆开,或者直接用BertTokenizer的tokenize方法逐字处理,保证一个汉字对应一个 token。

2.2 用 BertTokenizer 做字符级编码与标签同步

BERT 的输入需要三样东西:input_ids、attention_mask、token_type_ids。对于单句 NER,token_type_ids全零即可。关键难点在于:tokenizer 可能会插入[CLS]和[SEP],还会对某些字符做拆分,导致 token 数量与原始字符数不一致。你必须把标签序列同步扩展到 token 级别,否则训练时 loss 会算错。

我一般用is_split_into_words=True模式,先把句子拆成字符列表,再传给 tokenizer。这样 tokenizer 会按字符对齐,不会额外拆分汉字。代码示例如下:

from transformers import BertTokenizer tokenizer = BertTokenizer.from_pretrained("bert-base-chinese") def encode_sentence(chars, labels, label2id, max_len=128): # chars: ["北", "京", "是", ...] # labels: ["B-LOC", "I-LOC", "O", ...] encoding = tokenizer( chars, is_split_into_words=True, max_length=max_len, truncation=True, padding="max_length", return_tensors="pt" ) # 获取每个 token 对应的原始字符索引 word_ids = encoding.word_ids(batch_index=0) label_ids = [] for word_idx in word_ids: if word_idx is None: label_ids.append(-100) # 特殊 token 忽略 loss else: label_ids.append(label2id[labels[word_idx]]) return encoding["input_ids"], encoding["attention_mask"], label_ids

逻辑说明:word_ids()返回每个 token 对应的原始字符位置,None表示[CLS]、[SEP]或 padding。把这些位置的标签设为-100,PyTorch 的CrossEntropyLoss会自动忽略。参数max_len根据你的数据长度分布来定,中文 NER 句子通常不超过 128 个字,设 128 能覆盖 95% 以上的样本。如果显存不够,降到 64 再试,但要注意截断会丢实体。

注意:is_split_into_words=True时,传入的必须是字符列表,不能是整句字符串。否则 tokenizer 会按词切分,中文会被拆成多字词,标签对齐直接乱掉。

3. 搭 BERT+BiLSTM+CRF:模型结构拆解与 PyTorch 实现

3.1 BERT 输出怎么接 BiLSTM:维度、dropout 与残差

BERT 的输出是[batch_size, seq_len, hidden_size],bert-base-chinese的hidden_size是 768。BiLSTM 的输入维度必须等于 768,隐藏层维度一般设 128 或 256。双向 LSTM 会把每个时间步的输出拼接成[batch_size, seq_len, 2*hidden_dim]。如果你设hidden_dim=128,输出就是 256 维。然后接一个线性层映射到标签数量,比如 7 个标签就是[batch_size, seq_len, 7]。

这里有个细节:BERT 最后一层的输出直接喂给 BiLSTM,效果不一定最好。我习惯在 BERT 输出后加一个Dropout(0.3),再进 BiLSTM。原因是 BERT 微调时参数更新幅度大,dropout 能防止过拟合。另外,BiLSTM 的batch_first=True必须设,否则维度顺序是[seq_len, batch, hidden],后面接 CRF 会报错。

import torch import torch.nn as nn from transformers import BertModel class BertBiLSTMCRF(nn.Module): def __init__(self, bert_path, num_tags, lstm_hidden=128, 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_tags) self.crf = CRF(num_tags, batch_first=True) # 假设用 pytorch-crf def forward(self, input_ids, attention_mask, token_type_ids): outputs = self.bert( input_ids=input_ids, attention_mask=attention_mask, token_type_ids=token_type_ids ) sequence_output = outputs.last_hidden_state # [B, L, 768] sequence_output = self.dropout(sequence_output) lstm_output, _ = self.bilstm(sequence_output) # [B, L, 256] emissions = self.classifier(lstm_output) # [B, L, num_tags] return emissions

参数说明:lstm_hidden设 128 是课设里的甜点值,再大显存吃紧,再小欠拟合。dropout设 0.3 到 0.5 之间,数据量小于 5000 条时用 0.5。num_layers=1足够,堆两层 BiLSTM 在中文 NER 上收益很小,反而容易过拟合。

3.2 CRF 层的作用与解码:为什么不能直接用 softmax

CRF 的核心是学习标签之间的转移分数。比如在 BIO 标注下,B-LOC后面跟I-PER的概率应该极低,CRF 通过转移矩阵把这个约束学出来。如果没有 CRF,模型可能输出B-LOC I-PER I-PER这种非法序列,后处理还得写规则修,得不偿失。

训练时,CRF 的 loss 是负对数似然,计算的是所有合法路径的分数之和。解码时用 Viterbi 算法找最优路径。pytorch-crf库已经封装好了,你只需要把 emissions 和标签传进去。

from torchcrf import CRF # 训练阶段 crf = CRF(num_tags=7, batch_first=True) emissions = model(input_ids, attention_mask, token_type_ids) loss = -crf(emissions, tags, mask=attention_mask.bool(), reduction="mean") loss.backward() # 预测阶段 predictions = crf.decode(emissions, mask=attention_mask.bool())

逻辑说明:mask参数必须传,否则 padding 位置会参与转移计算,导致 loss 异常。reduction="mean"会对 batch 内所有有效 token 求平均,比sum更稳定。解码返回的是每个样本的标签 id 列表,长度等于实际 token 数,不含 padding。

注意:pytorch-crf的decode返回的是 list of list,不是张量。如果你要批量计算 F1,得自己写对齐逻辑,把预测标签和真实标签按有效长度截齐。

4. 训练参数怎么设:学习率、batch size 与早停策略

4.1 BERT 微调的学习率分层设置

BERT 微调最忌讳的就是全局用同一个学习率。BERT 本体已经预训练好了,只需要微调,学习率设 2e-5 到 5e-5 就够;而 BiLSTM 和分类层是随机初始化的,需要更大的学习率,一般设 1e-3。如果你用 AdamW 统一设 2e-5,BiLSTM 收敛会非常慢,训练 10 个 epoch 可能还在震荡。

我一般用参数分组的方式,给 BERT 和新增层分别设学习率:

from transformers import AdamW bert_params = list(model.bert.named_parameters()) new_params = list(model.bilstm.named_parameters()) + \ list(model.classifier.named_parameters()) + \ list(model.crf.named_parameters()) optimizer = AdamW([ {"params": [p for n, p in bert_params], "lr": 3e-5}, {"params": [p for n, p in new_params], "lr": 1e-3} ], weight_decay=0.01)

参数说明:weight_decay=0.01是 BERT 微调的常规值,能抑制过拟合。如果你发现 BERT 层 loss 下降很慢,可以把 BERT 学习率提到 5e-5,但别超过 1e-4,否则预训练知识会被冲掉。

4.2 batch size 与梯度累积:8G 显存下的可行配置

bert-base-chinese模型本身约 400MB,加上 BiLSTM 和 CRF,推理时显存占用约 2GB。训练时还要存激活值和梯度,8G 显存下batch_size设 16、max_len=128基本能跑。如果显存不够,用梯度累积:batch_size=8,累积 2 步,等效 batch size 还是 16。

accum_steps = 2 optimizer.zero_grad() for step, batch in enumerate(dataloader): loss = model_train_step(batch) loss = loss / accum_steps loss.backward() if (step + 1) % accum_steps == 0: optimizer.step() optimizer.zero_grad()

逻辑说明:梯度累积把多个小 batch 的梯度加起来再更新,效果接近大 batch,但显存占用按小 batch 算。注意 loss 要除以累积步数,否则梯度会放大。

早停策略:每轮在验证集上算 F1,如果连续 3 轮 F1 不升,就停。课设数据量小,通常 5 到 8 个 epoch 就能收敛。别训练太多轮,BERT 微调过拟合很快,训练集 F1 到 99% 时验证集可能已经开始掉了。

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

5.1 标签对齐错位:现象是 loss 不降,预测全是 O

现象:训练几个 epoch 后 loss 卡在 2.0 左右不降,预测结果全是 O 标签。原因:tokenizer 的word_ids没处理好,标签和 token 错位,模型学到的全是噪声。解决:在预处理阶段打印一条样本的chars、word_ids、label_ids,逐字核对。确保[CLS]和[SEP]位置的标签是-100,汉字位置的标签与原始标签一致。

5.2 学习率过大导致 BERT 灾难性遗忘

现象:训练初期 loss 骤降,但验证集 F1 从第 2 轮开始暴跌。原因:BERT 学习率设成了 1e-3 或更高,预训练权重被破坏。解决:BERT 层学习率降到 3e-5,新增层保持 1e-3。如果已经跑崩了,重新加载bert-base-chinese从头训。

5.3 CRF 的 mask 没传导致 loss 异常

现象:loss 出现 NaN 或异常大的值。原因:crf()调用时没传mask,padding 位置的标签参与了转移计算。解决:确保mask=attention_mask.bool()传入,并且attention_mask在 padding 位置是 0。

5.4 实体类别不均衡导致小类 F1 极低

现象:PER 和 LOC 的 F1 有 90%,ORG 只有 40%。原因:ORG 实体在训练集中样本太少。解决:在 loss 里给每个标签加权,权重与类别频率成反比。或者用 focal loss 替代交叉熵。课设里如果 ORG 太少,可以在数据增强时多造一些 ORG 样本。

5.5 推理时 batch 内句子长度不一致导致解码错位

现象:单条推理正常,批量推理时部分样本预测结果错乱。原因:crf.decode返回的列表长度与输入长度一致,但 padding 位置也被解码了。解决:解码后按attention_mask的有效长度截取,只保留真实 token 的标签。

6. 进阶技巧:用对抗训练和模型融合把 F1 再提 2 个点

课设做到 85% F1 已经能交差,但如果你想让答辩更稳,可以加两个技巧:FGM 对抗训练和 BERT 多层特征融合。FGM 的思路是在 embedding 层加一个扰动,让模型对微小变化更鲁棒。实现很简单,在训练循环里加一步:

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,反向传播后调用fgm.attack(),再前向传播一次算对抗 loss,累加后更新参数,最后fgm.restore()。epsilon设 1.0 是常用值,太大反而掉点。

另一个技巧是取 BERT 最后四层的输出做平均,再喂给 BiLSTM。bert-base-chinese有 12 层,最后四层包含的语义信息最丰富,平均后能缓解单层输出的噪声。代码上只需改output_hidden_states=True,然后取hidden_states[-4:]求均值。

outputs = self.bert(..., output_hidden_states=True) hidden_states = outputs.hidden_states # tuple of 13 tensors last_four = torch.stack(hidden_states[-4:], dim=0).mean(dim=0) sequence_output = self.dropout(last_four)

这两个技巧叠加,在 MSRA 数据集上通常能提 1.5 到 2.5 个 F1。但注意:对抗训练会让训练时间增加约 50%,课设如果时间紧,只加多层融合就够了。

我自己的习惯是:先把 baseline 跑通,确认数据管道没问题,再加技巧。见过太多同学一上来就堆模块,结果 loss 不降,连问题出在哪都定位不到。先让模型能跑、能出结果,再谈优化。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/2 21:51:58

S7-1200 Profinet无线通讯:从选型到调试完整例程

1. 为什么要做Profinet无线通讯,什么时候该做去年有个老同学找到我,说厂区里两台西门子S7-1200PLC之间要传数据,两台设备一个在配电室,一个在车间另一头的产线边上,直线距离不到一百米,但中间隔着两排机台和…

作者头像 李华
网站建设 2026/10/2 21:49:14

高速公路矢量数据处理:WGS84坐标校验与PostGIS入库实战

简介:这份资源提供2024年全国最新高速公路矢量数据,采用WGS84地理坐标系,面向GIS从业者、交通规划研究人员、地图开发工程师及高校相关专业师生。可用于路网分析、可达性评估、专题制图、空间建模与城市交通研究等场景,帮助解决全…

作者头像 李华
网站建设 2026/10/2 21:46:36

openrig 配置指南:统一管理 Claude Code 与 Codex 的 AI 编码助手运行环境

1. openrig 到底想解决什么问题第一次看到 openrig 这个名字,我下意识把它和一堆“AI 命令行工具”联系到了一起。原因很简单,最近围绕 Claude Code、Codex 这类终端智能助手的讨论实在太多,而 openrig 恰好出现在同一批热搜词里。但真正把玩…

作者头像 李华
网站建设 2026/10/2 21:37:00

给AI编程工具写个人规则:Trae与Cursor的高效配置指南

我最近花了不少时间在折腾Trae和Cursor这两个AI编程工具,越用越觉得有意思。很多人把这俩工具当成“高级问答框”,用完就关,其实它们真正的威力全藏在一个容易被忽略的地方——个人规则。所谓个人规则,就是你自己写给AI的一套行为…

作者头像 李华
网站建设 2026/10/2 21:30:28

从零搭建AI工程能力:三次踩坑经验与完整落地指南

从零搭建AI工程能力这件事,我前前后后折腾过三回。第一回是跟着网上的教程跑通了几个Demo,觉得自己行了;第二回是接手一个真实项目,发现Demo和工程之间隔着一条鸿沟;第三回才算真正摸到了门道——不是模型调得多好&…

作者头像 李华