简介:这是一份基于 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_mask用labels != -100生成,因为 labels 中 [CLS]、[SEP]、padding 和 subword 后续片段都会被预处理成 -100,这些位置不应该参与标签转移路径。推理阶段没有 labels,只能退而用attention_mask,解码结果会包含首尾特殊 token,后面清理掉即可。
参数设置通常按照下表起步:
| 参数 | 常见值 | 说明 |
|---|---|---|
| bert_dir | bert-base-chinese | 中文场景默认选择,切换预训练模型后 hidden_size 需要同步 |
| lstm_hidden | 256 | 双向拼接后为 512,过小丢特征,过大在小数据集上容易过拟合 |
| num_labels | 7 或 9 | BIO 下 3 类实体为 7 个标签,4 类实体为 9 个 |
| dropout | 0.1 | BERT 输出后加一次 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。
| 标注体系 | 标签格式 | 适合场景 |
|---|---|---|
| BIO | B-PER, I-PER, O | 数据少、任务简单 |
| BIOES | B-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 可以先按下表跑通流程,再根据训练曲线微调:
| 参数 | 初始值 | 调整方向 |
|---|---|---|
| epoch | 5 | 过拟合先减,欠拟合再加 |
| batch_size | 16 | 显存不足时降到 8 |
| max_len | 128 | 长实体多时加到 192 |
| warmup_ratio | 0.1 | BERT 训练前 10% 步数逐步升温 |
| clip_norm | 1.0 | LSTM 和 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.pt | model.state_dict() |
| label2id.json | 字符串标签到 id 的映射 |
| config.json | bert_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-ORG和I-PER -> I-PER的分数差,如果差值很小,说明模型没有充分学到“同类实体内部延续”的约束,下一步要增大 CRF 相关参数的学习率或增加数据量。
这个检查比单纯看 loss 曲线更贴近 NER 任务本身。答辩演示时可以把转移矩阵几个关键值打印出来,例如 B-PER 到 I-PER 是正数,I-PER 到 B-ORG 是负数,正好说清楚 CRF 模块在项目里的实际作用。你可以在答辩现场用这三个句子跑一遍推理,再切到转移矩阵页面,这就是一个直观的检查点。
本文还有配套的精品资源,点击获取