news 2026/9/11 18:08:18

PyTorch实现BERT+BiLSTM+CRF命名实体识别项目实战解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
PyTorch实现BERT+BiLSTM+CRF命名实体识别项目实战解析

简介:这是一份基于 PyTorch 的命名实体识别(NER)毕业设计项目源码,主要面向自然语言处理初学者、课程设计学生和论文复现者,聚焦用 BERT+BiLSTM+CRF 自动识别文本中的命名实体。项目提供 BERT-Softmax、BERT-CRF、BiLSTM-CRF、BERT+BiLSTM+CRF 多套可对照实验的模型结构,并拆分为数据预处理、词汇表构建、配置管理、模型定义、训练与评估等模块;数据侧包含 CLUENER20 中文数据集,支持加载 Hugging Face 预训练 BERT 权重,方便替换数据集和超参数。资源包共 99 个文件,大小约 13.33MB,其中以 33 个 Python 脚本为主体,覆盖数据处理、模型构建、train/run 入口和 metrics 指标计算;另有 17 个 JSON 配置/数据、9 个 npz 模型权重、5 张效果图、4 份训练日志以及 md/txt 说明,目录区分不同网络结构,便于按需选用。已有 632 人学习,可用于毕业论文代码支撑、课程实验扩展,也可作为快速上手 NER 项目实践的完整参考。

1. 为什么是 BERT+BiLSTM+CRF 而不是纯注意力

毕业设计选“Pytorch实现基于BERT+ BiLSTM+CRF的命名实体识别项目源码”这个题目,常见动机不是追新,而是把序列标注的标准三件套完整走一遍。命名实体识别(NER)表面上是给每个字打标签,实际难点在边界和类别:实体内部、实体外部、相邻实体之间都有关联。BERT 提供预训练上下文向量,BiLSTM 在本地序列上补充双向依赖,CRF 再对标签序列做全局约束,三者各管一段。这个组合在中文场景下数据量要求不高,训练周期可控,代码量也适合本科毕设。需要读者具备 Python 基础,会 PyTorch 数据流,能理解“tokenizer 结果和标签不一定是一一对应”这件事。

2. BERT 编码、BiLSTM 上下文与 CRF 转移约束:模块分工和参数边界

2.1 BERT 只负责把 token 变成表示,不参与标签决策

很多初学者以为 BERT 本身已经能做序列标注,实际在中文 NER 中,如果只用 BERT 接一个线性分类器,模型能学到很好的字向量,但很难从数据中稳定学到“I 标签不能随便跨类别”这类规则。BERT 的输出是每个 token 的上下文表示,它优化的是语言模型目标,不是序列标注目标。因此后续需要额外结构把表示转成带约束的标签序列。

常见做法是取bert-base-chinese最后一层输出,维度是 768。接一个双向 LSTM,hidden_size 设成 256,双向拼接后是 512,再过一层线性层映射到标签数。这里的 BiLSTM 不是把 BERT 再做一遍编码,而是让实体内部字与字之间的局部依赖更明显,尤其当训练集只有几千条时,LSTM 的归纳偏置能抑制 BERT 在长句上的噪声输出。BiLSTM 的两个方向输出会在最后一个维度上拼接,线性层的输入维度必须写成lstm_hidden * 2,漏掉这个乘 2 是运行时最常见的一个 shape 报错。

2.1.1 参数取值范围不建议照抄英文博客

中文 BERT 的序列长度、字个数、标注粒度与英文不同。max_len 设 128 基本够用,超过 128 的训练代价上升明显;num_labels 如果采用 BIO 标注,三类实体就是 7 个标签,四类实体就是 9 个。标签数直接决定 BiLSTM 后的 Linear 输出维度和 CRF 转移矩阵形状。换一个领域就换一套标签表,BERT 编码器可以不变,BiLSTM 和 CRF 的尺寸必须跟着 label2id 走。

2.2 为什么保留 CRF 而不是只用 Softmax

Softmax 输出每个标签的概率时,是逐位置独立的。它不关心“B-LOC 后面跟 I-PER”这种转移是否合法。CRF 是在“当前词的特征得分”之外再加一个“标签转移得分”。转移矩阵在训练中会自动学到:同类型实体内的转移分数变高,跨类型的非法转移变低。这个约束对实体边界特别有效,例如“北京市朝阳区”这种连续地名,容易被 softmax 拆成不同实体。

此外,CRF 在解码时用维特比算法找到全局最优标签路径,而 softmax 只能贪心取每个位置最大值。贪心结果可能停留在局部合理、整体混乱的状态。CRF 的全局解码对“实体重叠不多、实体长度不长”的中文文本尤其友好。还有一点容易被忽略:CRF 的转移矩阵是非对称的,从 I-PER 到 B-PER 和从 B-PER 到 I-PER 是两个独立参数,模型能区分“继续实体”和“开始新实体”这两种动作。

2.3 模型骨架和 4 个关键参数

下面是一个常见的 PyTorch 模型骨架,把一个独立的 CRF 类挂在最后。这里的 CRF 类先不展开,训练部分再补实现细节。

class BERTBiLSTMCRF(nn.Module): def __init__(self, bert_dir: str, num_labels: int, lstm_hidden: int = 256): super().__init__() self.bert = AutoModel.from_pretrained(bert_dir) self.dropout = nn.Dropout(0.1) self.bilstm = nn.LSTM( self.bert.config.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) def forward(self, input_ids, attention_mask, labels=None): bert_outputs = self.bert(input_ids, attention_mask=attention_mask) sequence_output = bert_outputs.last_hidden_state lstm_output, _ = self.bilstm(sequence_output) logits = self.classifier(self.dropout(lstm_output)) if labels is not None: crf_mask = labels != -100 loss = self.crf(logits, labels, mask=crf_mask) return loss, logits return self.crf.decode(logits, mask=attention_mask.bool())

这里的crf_masklabels != -100生成,因为 labels 中 [CLS]、[SEP]、padding 和 subword 后续片段都会被预处理成 -100,这些位置不应该参与标签转移路径。推理阶段没有 labels,只能退而用attention_mask,解码结果会包含首尾特殊 token,后面清理掉即可。

参数设置通常按照下表起步:

参数常见值说明
bert_dirbert-base-chinese中文场景默认选择,切换预训练模型后 hidden_size 需要同步
lstm_hidden256双向拼接后为 512,过小丢特征,过大在小数据集上容易过拟合
num_labels7 或 9BIO 下 3 类实体为 7 个标签,4 类实体为 9 个
dropout0.1BERT 输出后加一次 dropout,只靠 BERT 自带 dropout 不够

文本里如果以人名、地名、机构名混合出现为多,BIO 三种角色的标签数不会变。真正影响标签数的是实体类别数,不是样本量。模型每一层都需要在前向时看到 attention_mask,尤其是 BiLSTM 不支持变长输入,padding 之后必须靠后续 CRF 的 mask 把无效位置排除。

3. 从标注数据到 Dataloader:token 对齐和动态 padding 是毕设最容易翻车的两步

3.1 先定 BIO 还是 BIOES,再把文本转成 id

标注体系直接决定标签数量。BIO 是三个角色:B 表示实体首字,I 表示实体非首字,O 表示非实体。BIOES 增加了 E 和 S,实体边界更严格,但标签更多,模型需要更多数据。毕设数据量在几千句左右时,BIO 更容易收敛,也更好解释。常见处理方式是:先把字符串切分为字列表,按字给标签,再用 BERT tokenizer 把同一句话编码成 input_ids。

标注体系标签格式适合场景
BIOB-PER, I-PER, O数据少、任务简单
BIOESB-PER, I-PER, E-PER, S-PER, O数据量大、追求边界严格

使用 BIO 的 3 类实体时,标签到 id 的映射一般写成这样:

label2id = { "O": 0, "B-PER": 1, "I-PER": 2, "B-ORG": 3, "I-ORG": 4, "B-LOC": 5, "I-LOC": 6, }

按照这个顺序,CRF 转移矩阵是 7×7。后面代码里如果把 id 顺序改了,保存的模型权重会全部错位,所以 label2id 必须和模型一起保存。

3.2 处理 BERT tokenizer 的 subword 分裂

BERT 输入不是直接传字列表,而是经过 tokenizer。英文会被拆成 subword,中文虽然大多按字切,但遇到英文、数字、特殊符号时同样可能被拆成多个 token。一个原始字对应多个 BERT token 时,标签不能简单复制到每个 token,否则会出现“一个标签复制多份”的边界错误。常见做法是:第一个 subword 继承原始标签,其余 subword 用 -100 表示忽略。

def encode_with_labels(text: str, labels: list[str], tokenizer, label2id): encoded = tokenizer(text, add_special_tokens=True) bert_tokens = tokenizer.convert_ids_to_tokens(encoded["input_ids"]) label_ids = [] label_index = 0 for token in bert_tokens: if token in ["[CLS]", "[SEP]"]: label_ids.append(-100) continue if token.startswith("##"): label_ids.append(-100) continue if label_index >= len(labels): break label_ids.append(label2id[labels[label_index]]) label_index += 1 return encoded["input_ids"], label_ids

这个函数把 [CLS] 和 [SEP] 位置设成 -100,把 subword 后续片段设成 -100。实际操作里要注意:labels必须按 tokenizer 还原出来的顺序对应原文本的每个字。如果原文本经过空格预切词,需要在切词后同步展开标签。

3.2.1 如果原文本按词切分怎么办

有些公开中文 NER 数据集给出的是词序列而不是字序列,比如“北京 大学”。这时候 labels 是词级别的。需要先把词序列拼接成带空格或不带空格的句子,再重新按字拆分标签,否则 tokenizer 切出来的 token 数量对不上。一个可靠办法是放弃原词边界,只把词标注拆到每个字上,例如词标签 B-ORG 表示第一个字是 B-ORG,后面字是 I-ORG。

3.3 动态 padding 和 attention_mask 生成

一个 batch 里句子长度不同,需要把 input_ids、label_ids、attention_mask 都填充到同一长度。padding 部分对应标签要同时填充 -100。用 PyTorch 的 DataLoader 时,自定义 collate_fn 是最直接的做法:

def collate_fn(batch): input_ids = [item["input_ids"] for item in batch] labels = [item["label_ids"] for item in batch] max_len = max(len(ids) for ids in input_ids) padded_ids, padded_labels, masks = [], [], [] for ids, label in zip(input_ids, labels): pad_len = max_len - len(ids) padded_ids.append(ids + [0] * pad_len) padded_labels.append(label + [-100] * pad_len) masks.append([1] * len(ids) + [0] * pad_len) return { "input_ids": torch.tensor(padded_ids, dtype=torch.long), "attention_mask": torch.tensor(masks, dtype=torch.long), "labels": torch.tensor(padded_labels, dtype=torch.long), }

这里 padding 值用 0 对应 BERT 的 [PAD] id,标签 padding 用 -100。CRF 在计算损失时通过labels != -100跳过特殊 token 和 padding,不会参与 emission score,也不会参与转移路径。常见错误是 attention_mask 用 float 类型而 label mask 用 bool 类型,两者在传入 CRF 时类型不匹配,训练中途报错。统一用long构造,需要转 bool 时在模型内部调用.bool()即可。

4. 训练循环和 CRF 损失:为什么不能直接对 BERT 输出做 softmax

4.1 Softmax 的逐位置独立性在 NER 里会造成什么

如果只需要判断“这个词是不是实体”,Softmax 足够。但 NER 要同时回答“实体从哪里开始到哪里结束”。Softmax 在位置 i 输出 I-PER 时,不看它前面是不是 B-PER 或 I-PER,因此极容易出现“某句话开头直接出现 I-ORG”这种错误。CRF 用转移矩阵把相邻标签的联合概率放进目标函数,训练时优化的是整个序列的对数似然,而不是每个 token 的交叉熵。这两者的差别是:softmax 损失只惩罚单个点,CRF 损失会惩罚整条路径。

4.2 不引入第三方实现时,CRF 类要处理三件事

一个可用的 CRF 层需要有三个能力:计算序列得分、计算规范化因子、维特比解码。torchcrf 是现成库,但如果毕业设计源码需要提交整个工程,建议把它的思路自己写一遍。核心是用logsumexp计算所有路径的分子分母,避免直接枚举指数级路径。推理阶段再走维特比。

from torchcrf import CRF self.crf = CRF(num_labels) def loss_and_predict(model, batch, device): input_ids = batch["input_ids"].to(device) attention_mask = batch["attention_mask"].to(device) labels = batch["labels"].to(device) loss, logits = model(input_ids, attention_mask, labels) predictions = model.crf.decode(logits, mask=attention_mask.bool()) return loss, predictions

调用 torchcrf 时,传入的 logits 是 [batch, seq_len, num_labels],mask 是 [batch, seq_len] 的 bool 张量。model 内部会用labels != -100作为 CRF 的训练 mask,返回的 loss 是对数似然取负后的标量,直接 backward 即可。decode 返回的是 list of list,每个元素是一个标签 id 序列,长度和传入 logits 的 seq_len 一致,包含特殊 token 位置,需要后续清理。

4.2.1 代码里容易漏掉的两个类型

CRF 对 mask 的要求比较严格。torchcrf 要求 mask 是torch.bool,attention_mask 从 collate_fn 出来是torch.long,不转换会直接报错。还有一个隐藏问题:如果某个 batch 里有一条 padding 全部在句首而非句尾,CRF 路径会错位。因此 collate_fn 必须保持左对齐方式,不能随手 reverse。

4.3 训练参数:BERT 和下游模块要分开学习率

BERT 预训练参数、BiLSTM 随机初始化和 CRF 转移矩阵对学习率的敏感度不同。BERT 用太大学习率会把预训练权重冲坏,CRF 用常见学习率又更新太慢。常见做法是把参数分成两组:

bert_params = model.bert.parameters() other_params = [p for n, p in model.named_parameters() if not n.startswith("bert.")] optimizer = AdamW([ {"params": bert_params, "lr": 2e-5}, {"params": other_params, "lr": 5e-3}, ])

学习率、batch size、epoch 和 warmup 可以先按下表跑通流程,再根据训练曲线微调:

参数初始值调整方向
epoch5过拟合先减,欠拟合再加
batch_size16显存不足时降到 8
max_len128长实体多时加到 192
warmup_ratio0.1BERT 训练前 10% 步数逐步升温
clip_norm1.0LSTM 和 CRF 容易出现梯度爆炸

训练循环里梯度裁剪放在loss.backward()之后、optimizer.step()之前。通常用clip_grad_norm_同时限制模型所有参数。如果评估集 F1 波动很大,检查随机种子和数据打乱;如果 loss 不下降,先跑一条数据过拟合测试,确认 CRF 能记住一个 batch。这一步能很快区分是代码 bug 还是模型表达能力不足。

5. 评估、保存和推理:seqeval 指标与标签映射的 4 个坑

5.1 用 seqeval 算实体级别 F1,而不是 token 准确率

NER 的正确标准是“实体边界和类型都完全正确”,一次预测里错一个字符就算错一个实体。sklearn 的 classification_report 只看每个标签的 token 命中率,会把实体边界算模糊。seqeval 专门解决这个问题。评估前需要把预测结果从 id 还原成字符串标签,并去掉 -100 和 padding 位置。

from seqeval.metrics import classification_report true_entities = [["O", "B-LOC", "I-LOC", "O"]] pred_entities = [["O", "B-LOC", "O", "O"]] print(classification_report(true_entities, pred_entities, mode="strict"))

mode="strict"表示只在实体完全匹配时算正确。如果只预测中类型但偏了一位,strict 模式会记为错误实体。这个指标会让 F1 比 token 准确率低,是正常现象,不要在报告里刻意隐瞒。

5.1.1 scheme=IOB2 只在严格模式下生效

seqeval 的mode="strict"会同时校验标签序列是否符合 IOB2 规则。如果数据里有少量 BIOES 标注,需要先统一转成 BIO,否则评估函数会直接报错。规则是:B 后面连续接 I,没有 E 或 S,同一实体不能跨类别延续。

5.2 推理时要清掉 [CLS]、[SEP] 和 padding,不能直接比对长度

batch 推理时,CRF decode 返回的是整条 logits 的维特比路径。因为推理时 mask 用的是attention_mask,[CLS] 和 [SEP] 也参与了 decode,所以预测序列首尾各多一个标签。处理方式是按每条样本的attention_mask.sum()截断,再去掉首尾:

def decode_to_tags(model, input_ids, attention_mask, id2label): pred_ids = model.crf.decode( model(input_ids, attention_mask=None, labels=None)[1], mask=attention_mask.bool() )[0] real_len = int(attention_mask.sum().item()) tag_ids = pred_ids[1:real_len - 1] return [id2label[i] for i in tag_ids]

实际写代码时不要在这一步重新 tokenizer,直接沿用训练时的 tokenizer 结果。如果标签序列和原始文本长度对不上,最可能的原因是 subword 对齐阶段多跳过了一个 token,而不是维特比问题。

5.3 保存 state_dict 还不够,label2id 和 tag2id 都要保存

模型推理时需要把预测标签 id 映射回字符串,如果只保存 model.pt,后来换环境运行时就不知道 id 1 是 I-PER 还是 B-LOC。更稳妥的做法是把标签映射和超参数一起存成 json,模型权重单独保存。

文件内容
model.ptmodel.state_dict()
label2id.json字符串标签到 id 的映射
config.jsonbert_dir、max_len、lstm_hidden 等

加载时先读 label2id.json,再实例化模型结构和 CRF,最后 load_state_dict。文件路径不要用绝对路径,毕设源码给别人运行时,建议用Path(__file__).parent拼接相对路径。这样整个工程可以移动目录,不会因为路径问题跑不起来。

6. 答辩前值得做的一个验证:构造对抗句观察 CRF 是否真的在约束边界

6.1 用三个短句跑一次推理

训练完成后,在测试集之外手写三个短句,最好覆盖“长实体、连续实体、易混淆边界”三类情况。例如:

test_sentences = [ "我在北京大学上学", "张三毕业于复旦大学计算机学院", "李四在深圳腾讯工作", ] for sentence in test_sentences: encoded = tokenizer(sentence, add_special_tokens=True) input_ids = torch.tensor([encoded["input_ids"]]).cuda() attention_mask = torch.tensor([[1] * len(encoded["input_ids"])]).cuda() with torch.no_grad(): logits = model(input_ids, attention_mask)[1] pred = model.crf.decode(logits, mask=attention_mask.bool())[0] tag_ids = pred[1:-1] print(sentence, [id2label[i] for i in tag_ids])

如果“张三毕业于复旦大学计算机学院”被拆成“复旦大学”+“计算机学院”两个 ORG,说明模型能区分组织内子机构。如果“北京大学”被识别成 PER,那就不是参数问题,多半是标签数据里“大学”这个后缀的类别标注不一致,需要回看训练集标签规则。

6.2 查看 CRF 转移矩阵来判断约束是否真的被学到

torchcrf 把转移矩阵存在crf.transitions里。打印出来会看到同类型内部转移概率明显高于跨类型转移概率。可以做一次验证:计算I-PER -> B-ORGI-PER -> I-PER的分数差,如果差值很小,说明模型没有充分学到“同类实体内部延续”的约束,下一步要增大 CRF 相关参数的学习率或增加数据量。

这个检查比单纯看 loss 曲线更贴近 NER 任务本身。答辩演示时可以把转移矩阵几个关键值打印出来,例如 B-PER 到 I-PER 是正数,I-PER 到 B-ORG 是负数,正好说清楚 CRF 模块在项目里的实际作用。你可以在答辩现场用这三个句子跑一遍推理,再切到转移矩阵页面,这就是一个直观的检查点。

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

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

ESP-IDF+vscode开发ESP32 联网篇第六讲——蓝牙 beacon 测距

目录 前言 一、蓝牙 1.1 蓝牙基础知识 核心特点 两大类型 1.2 蓝牙软件架构 Bluetooth Controller HCI与传输层 Bluetooth Host Bluedroid NimBLE Bluetooth Profiles Bluetooth Application 1.3 总结 二、蓝牙数据包 2.1 整体数据包 2.2 PDU Header 2.3 PDU…

作者头像 李华
网站建设 2026/9/11 18:07:02

AI视频生成对决:Sora vs 可灵 vs Veo,谁能定义未来

摘要:2026年,AI视频生成迎来爆发——Sora 2.0、Veo 3.0、可灵2.0三足鼎立,一条30秒广告的制作成本从500万骤降至2万。本文从广告案例切入,对比三大平台技术差异,拆解视频扩散模型的时空一致性、运动合理性、长视频退化…

作者头像 李华
网站建设 2026/9/11 18:05:29

从源码拆解VC虚拟示波器:缓冲区、双缓冲与触发测量

简介:一份基于C/VC开发的虚拟示波器完整源码资源,适合电子工程、物理及计算机专业学生,也适合希望深入理解上位机信号采集与波形显示的C开发者。项目利用MFC框架构建界面,涵盖数据采集、信号处理、波形绘制、触发同步、测量分析与…

作者头像 李华
网站建设 2026/9/11 18:04:39

快递柜状态采集与控制系统:基于Spring Boot的课程设计实战

简介:这是一份基于Java的快递柜状态采集与控制系统课程设计源码包,面向Java Web方向学生与毕业设计开发者,覆盖前端Vue交互界面、后端业务逻辑、MySQL数据库以及串口通信硬件采集链路。通过该系统不仅可快速理解快递柜格口状态监测、远程控制…

作者头像 李华
网站建设 2026/9/11 18:03:26

【关注可白嫖源码】--课程设计--毕业设计--社区共享健身房管理系统[编号:project55280](案件分析)

本文仅展示核心实现逻辑与部分代码片段,完整项目源码、配套文档、数据库脚本内容较多,篇幅有限无法全部放出。 有需要完整资源的同学,可以在评论区留言【资料或领源码】,我会一一回复站内私信,发送完整文件 摘 要 随…

作者头像 李华