简介:面向计算机专业学生课程设计或期末大作业的Python项目,基于BERT、BiLSTM与CRF实现中文命名实体识别(NER),覆盖从数据处理到模型调用的一套完整代码。项目源自作者大三期末作业,经导师指导并获得99分评审分,代码完整可运行,适合零基础或初次接触NER的学习者进行项目实战。压缩包共15个文件,以9个Python源码为主,辅以4个文本说明、1个Markdown文档和1个gitignore配置,整体仅406KB。内容包含torch_ner核心模块、data数据目录、source源码目录、requirements.txt依赖说明与README项目说明,目录结构清晰;同时配有项目整体说明和运行依赖清单,便于快速搭建环境、理解数据流转与模型调用方式。目前已有90人学习下载,可直接作为期末大作业、课程设计或入门级NLP项目的参考工程,也适合在已有代码基础上做二次拓展练习。
1. 把这三个模型拼在一起,到底在解决什么问题
中文命名实体识别(NER)这几年基本绕不开 BERT+BILSTM+CRF 这个组合。你可以把它理解成一条流水线:BERT 负责把每个字变成有语境的向量,BILSTM 负责看上下文重新梳理一遍,CRF 负责最终给每个字贴标签,并且保证标签顺序合法。单独用 BERT 也能做 NER,但边界会乱;单独用 BILSTM+CRF 也能做,但对一词多义、领域词汇的泛化不够。三者串起来,是效果和资源消耗之间比较稳的一个折中方案。
这个标题里的 python 源码 + 项目说明 + 模型,意味着你拿到的不是论文讲解,而是能直接跑的工程包。适合的人群有两类:一是要做毕设或课程设计的学生,二是公司里需要快速给文本抽实体但不想从零写模型的工程师。前者关心怎么跑通、怎么改数据集;后者更关心训练一次多久、显存占用多少、边界准不准。这篇笔记我按两条线展开:先讲清楚每一步为什么要这么做,再给出可复现的训练命令、参数和踩坑记录。
2. 模型结构拆解:BERT的向量、BILSTM的上下文、CRF的约束,为什么要三个串起来
很多项目里把这套模型当成黑匣子,数据灌进去,F1 出来。但调参的时候你会发现,问题根本不在于学习率,而在于你根本没搞懂每一层在干什么。这一章我们拆开看。
2.1 BERT层:动态词向量不是终点,是起点
中文 NER 的基础特征是字的语义表示。早期用 word2vec 静态向量,一个“行”的向量永远是那个“行”,不管它出现在“银行”还是“行走”里。BERT 根据整句上下文生成向量,所以“银行”的“行”和“行走”的“行”在这一层就已经分开了。
但这不代表 BERT 输出可以直接当标签。它输出的每个 token 向量是上下文相关的语言表征,而不是任务相关的分类特征。比如你想识别人名,BERT 知道这个字像“人话”,但它不知道它是不是姓“张”。所以这层叫“特征提取器”,不叫“分类器”。
在工程上,BERT 层通常使用预训练的中文模型,输出维度一般是 768(base 规模),也有的源码里加了一个线性层降维到 bilstm 的 hidden_size。常见做法是取 BERT 最后一层输出作为每个字的向量,而不是拿 CLS 或 pooler。
from transformers import BertModel bert = BertModel.from_pretrained( "bert-base-chinese", output_hidden_states=False ) # 输入: input_ids, attention_mask # 输出: last_hidden_state [batch, seq_len, 768]这里有个参数值得注意:output_hidden_states。如果你只想用最后一层,设为 False 省显存;但有的代码里会拼接最后四层输出,效果能提升零点几个点,代价是显存涨不少。我一般只在数据量比较大的时候才拼多层。
2.2 BILSTM层:双向上下文把边界特征压出来
BERT 已经考虑了上下文了,为什么还要 BILSTM?我的理解是:BERT 的注意力虽然能看全局,但它给出的向量更偏“这个词在语言里怎么用”,而 NER 需要的是“这个词在当前局部窗口中与前后标签的配合关系”。BILSTM 作为序列编码器,能把局部的顺序依赖显式地再建模一遍,尤其在处理连续命名实体时,它对边界的敏感性比纯 BERT 更直接。
BILSTM 的前向和后向各算出一个序列,然后拼接。这一步会把每个字的隐藏状态变成两倍维度,再过一个线性层映射到标签数。比如标签集合是 7 类(B-PER, I-PER, B-ORG, I-ORG, B-LOC, I-LOC, O),线性层输出就是 7 维。
import torch.nn as nn class BILSTM(nn.Module): def __init__(self, input_size, hidden_size, num_labels): super().__init__() self.lstm = nn.LSTM( input_size, hidden_size, num_layers=1, bidirectional=True, batch_first=True ) self.linear = nn.Linear(hidden_size * 2, num_labels) def forward(self, x): # x: [batch, seq_len, 768] output, _ = self.lstm(x) logits = self.linear(output) return logitsnum_layers一般设 1 就够。层数堆到 2 对 NER 任务收益很小,显存却翻倍。hidden_size常见取 128 或 256,数据量大取 256,数据少取 128,否则容易过拟合。
BILSTM 还有一个容易被忽略的作用:它让每个位置的输出既看到左边的字,也看到右边的字。CRF 只看转移关系,如果不做双向,边界信息会被削弱不少。所以在源码里看到这里输出是hidden_size * 2,不要奇怪,那是拼接了前向和后向。
2.3 CRF层:标签转移约束才是NER的胜负手
CRF 整个模型里最容易理解,也是最容易写错的部分。它的作用不是改变每个字的分类得分,而是给“标签之间的转移”加约束。比如 B-PER 后面不能直接接 I-ORG,O 后面不能直接接 I-PER,必须 B 开头。这些规则 CRF 能自动从数据里学出来,不需要你手写。
CRF 在训练阶段计算整个标签序列的分数,包括每帧的发射分数(来自 BILSTM 输出)和转移分数(CRF 自己学的一个方阵,大小是 label_num × label_num)。损失用真实序列分数减去所有可能序列的 logsumexp 分数。推理阶段用维特比解码找到分数最高的路径。
from torchcrf import CRF crf = CRF(num_tags=num_labels, batch_first=True) # 训练时 loss = -crf(logits, labels, mask=attention_mask.bool()) # 推理时 decoded = crf.decode(logits, mask=attention_mask.bool())这里最常踩的坑是 mask。BERT 的 padding 部分也有标签,但那是无效的。如果不把attention_mask传给 CRF,模型会把 padding 位置也算进序列分数,导致训练目标混乱。另外起始和结束标签要单独定义,很多源码里忽略了这一点,导致预测出来的序列以 I 开头,后期还得手工修正。
3. 从零跑通源码:环境、数据格式与最小训练命令
拿到一份 BERT+BILSTM+CRF 的中文 NER 源码,先别急着跑。先确认三件事:数据格式是不是你要的、依赖版本能不能对上、训练参数适不适合你的数据量。这三件只要有一件不对,后面全是非预期行为。
3.1 数据格式:BIO标注与字符级别对齐
中文 NER 大多数源码默认使用 BIO 标注体系。B 表示实体开始,I 表示实体内部,O 表示非实体。比如“小明在北京读书”标注后是:
小 B-PER 明 I-PER 在 O 北 B-LOC 京 I-LOC 读 O 书 O常见的三种后缀:PER 人名、LOC 地名、ORG 机构名。源码里的标签表一般在data/labels.txt或代码里硬编码。你要做的第一件事是数一遍标签数量,然后看模型输出层的num_labels是否一致。这个数错了,训练时维度直接报错。
文本和标签对齐是另一个容易翻车的地方。很多公开数据集是词级别的标注,而 BERT 需要字级别的输入。你需要先做字符切分,再对齐标签。切分最好按空格和标点,不能用list(text)直接粗暴处理,英文和数字会被拆成单个字母。
def align_label(text, label_list): # 假设原始数据是 token 序列, 这里简化为字符 tokens = [c for c in text] assert len(tokens) == len(label_list), "标签长度与字符数不一致" return tokens, label_list如果你的原始标注是词组级别而不是字符级别,需要写展开逻辑:每个字继承其所属词组的标签,词组首字用 B,其余用 I。这一步别偷懒,否则实体边界会偏移一个位置。
3.2 环境与依赖:版本匹配的坑提前说
这个项目最常见的运行环境是 Python 3.8+,PyTorch 1.10+,transformers 4.x。当然你用更新的版本也能跑,但要注意 API 变化。比如 transformers 4.30 以后BertModel.from_pretrained默认行为没变,但一些老代码里的model.bert属性访问方式可能失效。
我建议按源码requirements.txt装,如果没有,至少保证:
pip install torch transformers torchcrftorchcrf 是常用的 CRF 实现库。如果你不想依赖它,也可以把 CRF 源码直接复制进项目里。网上流传的 CRF 实现通常只有一百多行,核心是前向计算 logsumexp 和维特比解码。复制进来更可控,调试时可以加打印。
3.3 最小训练命令与参数表
跑通一个最小实验,我一般这样做:
python train.py \ --bert_dir ../pretrained/bert-base-chinese \ --train_data data/train.txt \ --dev_data data/dev.txt \ --max_len 128 \ --batch_size 16 \ --learning_rate 2e-5 \ --crf_lr 1e-3 \ --epochs 10参数表看这里:
max_len 训练/验证统一截断长度,中文NER建议128起步,长实体多可到256 batch_size 显存允许下尽量大,16到32之间,太小导致收敛不稳 learning_rate BERT层学习率,通常2e-5到5e-5 crf_lr BILSTM和CRF层学习率,可设1e-3,是BERT层的几十倍 epochs 不宜过多,5到15之间,看验证集F1早停这里有一个关键思路:BERT 层和下游层的学习率要分开。BERT 预训练过了,学习率大了会把它学坏,小一点只作适配;BILSTM 和 CRF 是从零开始,需要更大的步长。源码里如果不支持分别设置,建议自己改一下优化器部分。
optimizer = torch.optim.AdamW([ {"params": bert.parameters(), "lr": 2e-5}, {"params": bilstm.parameters(), "lr": 1e-3}, {"params": crf.parameters(), "lr": 1e-3}, ])4. 项目说明里的关键设计:标签映射、掩码、类别不均衡
项目说明如果写得认真,会强调三件事:标签 id 怎么映射、padding 掩码怎么传、样本类别不均衡怎么处理。这三件事直接决定你是跑出 40% 的 F1 还是 90% 的 F1。
4.1 标签映射与O类权重怎么处理
绝大多数真实数据里,O 类标签占绝对多数。人名、地名、机构名的占比可能不到 5%,如果按默认交叉熵训练,模型很容易把所有字都预测成 O,整体准确率看起来很高,但实体一个都抽不出来。因此要在损失函数里给实体类别加权,或者让损失只管实体 token。
CRF 损失天然处理了这一点吗?并没有。CRF 是序列级损失,它更关注标签转移,但对单帧的类别不平衡不敏感。一个常见做法是:在 CRF 发射分数输入前,给实体类别的 logits 乘一个系数,或者在数据采样时对包含实体的句子加权。
label_weight = torch.tensor( [1.0, # O 类权重低 5.0, # B-PER 5.0, # I-PER 10.0, # B-ORG,机构名更难学 10.0, # I-ORG 6.0, # B-LOC 6.0] # I-LOC ).to(device) # 在 BILSTM 输出后直接乘权重,再进 CRF logits = logits * label_weight.unsqueeze(0).unsqueeze(0)但注意权重不要设太大,否则模型会过度预测实体,导致误报增多。我一般从 3-5 倍开始调,验证集如果 P 高 R 低就降权重,P 低 R 高就升权重。
4.2 attention mask 与 padding 对 CRF 的影响
刚才提到过,CRF 必须要拿到掩码。这里再补充一个细节:BERT 的 tokenizer 在中文环境下会自动把句子切分成字,但如果句子长度超过max_len,tokenizer 会截断,这时标签也要同步截断。很多源码的 bug 在于数据读取时只截断了 input_ids,没截断 labels,导致长度不匹配。
更隐蔽的问题是 tokenizer 添加的特殊 token。比如[CLS]和[SEP]占据两个位置,标签序列前要补一个-100(或对应 label 的 ignore index),最后也要补。如果不补,CRF 就把这两个 token 当成普通 token 参与训练。
def encode_with_labels(text, labels, tokenizer, max_len): encoded = tokenizer(text, truncation=True, max_length=max_len) output_labels = [-100] + labels[:max_len - 2] + [-100] # 对齐长度 output_labels = output_labels[:max_len] # 如果长度不够, 补 -100 到 max_len output_labels += [-100] * (max_len - len(output_labels)) return encoded["input_ids"], encoded["attention_mask"], output_labels-100在 PyTorch 的 CrossEntropyLoss 里默认是被忽略的标签。但 CRF 库不认-100,它需要的是 0 到 num_labels-1 之间的数字。所以你要么把 padding 部分掩码掉,要么在 CRF 调用时用mask参数。自己写 CRF 时尤其要小心这一点。
4.3 早停与模型保存:找最优epoch而不是最后epoch
训练 BERT 类模型,验证集 F1 通常不是单调上升的,而是先涨后跌。如果你不做早停,训练完直接拿最后一个 epoch 的模型,大概率不是最优的。项目说明里的“模型保存策略”如果写清楚了,一般会按验证集 F1 存历史最佳。
best_f1 = 0.0 for epoch in range(epochs): train_one_epoch() f1 = evaluate(dev_loader) if f1 > best_f1: best_f1 = f1 torch.save({ "bert": bert.state_dict(), "bilstm": bilstm.state_dict(), "crf": crf.state_dict(), "label2id": label2id, "id2label": id2label, }, "best_ner_model.pt")这里还要保存label2id和id2label,否则预测阶段标签映射对不上。有的源码只保存模型权重,加载时用代码里写死的标签表,遇到标签表改了直接错乱。
5. 避坑:跑通容易,跑好难——中文NER常见问题排查
下面这几条是我实际跑这类项目时遇到的最高频问题。每条写清楚现象、原因和解决,你按顺序对照,能少走不少弯路。
5.1 现象:验证集F1很高,测试集一塌糊涂
原因通常有两个:预处理不一致或数据泄漏。最常见的是把 dev 和 test 混在一起做预处理,导致 tokenizer 字典里混进了测试数据信息。另一个是验证集里实体分布比例,如果 dev 和 test 来自不同领域,F1 自然对不上。
解决:确认 train/dev/test 三个文件完全独立,且走同一套预处理函数。如果实体类别(比如机构名)相对测试集差异很大,建议直接换成领域数据微调,不要指望通用轨迹模型。
5.2 现象:CRF解码全是O,模型不吐实体
这个最让人崩溃。训练日志里 loss 在下降,验证集准确率 99%,但预测结果里所有标签都是 O。原因基本是类别不均衡没处理,模型学到的就是一套“全O”也能把损失压到很低的捷径。
解决:先检查标签分布,计算实体 token 占整体 token 的比例。如果低于 5%,给实体类权重调高到 10 倍再训。如果加了权重还是全O,查看 CRF 初始转移矩阵。有的 CRF 实现初始时对 I 类转移惩罚不足,模型利用这个漏洞跳过所有 B 开头,直接进入 I。
5.3 现象:BERT微调后灾难性遗忘
具体表现是:模型句子级预测还可以,但把 BERT 继续微调几轮之后,对通用语法理解反而下降,实体边界更乱了。这通常是因为学习率没分开,BERT 层被下游任务带偏了。
解决:BERT 层学习率调到 1e-5 以下,或者干脆冻结 BERT 前几层,只微调后几层。做法是在 optimizer 参数组里对不同层设置不同 lr。另外训练轮次控制在 5 以内,BERT 微调在 NER 任务上很容易过拟合。
5.4 现象:长文本显存爆炸
BERT 的显存占用随序列长度近似平方增长。中文 NER 语料里如果有大段公告、文书,max_len=256都可能 OOM。
解决:最直接的办法是设max_len=128,先把长句截断。如果你不想丢尾部信息,可以分句跑,然后合并结果。但合并时要处理好跨断句的实体边界,比如“北京市”被切成了“北京”和“市”,后半个没有上下文会预测成 O。更稳妥的是用滑窗重叠方式切分,重叠 5-10 个字,再取中间部分的预测。
5.5 现象:实体边界总是多一个字或少一个字
这种错误在中文里最常见。比如“阿里巴巴集团”预测成“阿里巴巴”或“阿里巴巴集”。原因往往是训练数据里实体边界标注不一致,或者 BILSTM 对边界字符的区分度不够。
解决:检查数据里是不是同一实体被标注成不同粒度。如果确认标注没问题,就去调 CRF 的转移分数。一个实用技巧是:把容易黏连的后缀词(如“公司”“有限”“集团”)作为特征加入模型输入。在 BERT 的基础上,把这种词典特征拼进 BILSTM 输入,能提升边界准确率。
6. 进阶调优:外部词典、后处理与轻量落地——把F1从85推到91的实操
能跑到 85 分的 F1 已经能交差,但如果要上生产,这个数字只能算及格。我一般会再加三招,它们能带来三到六个百分点的提升。
6.1 加一个外部词典特征,能救回不少低频实体
BERT 对常见人名地名很准,但对产品名、药品名、小众机构名很弱,因为这些词在预训练语料里出现少。如果你手里有领域词典,可以把它融入模型。一种简单做法是:在 BILSTM 输入处拼一个“该字是否在词典中”的特征,维度为 1,指示当前字符是否匹配某个词典词的首字或中间字。
更有效的做法是 Lexicon 接入的偏门方案:把每个字符在词典中能匹配到的词边界信息(如 B、I、E、S)作为额外的标签序列,编码成向量拼接进 BERT 输出。这个改动工作量不大,但能显著提升领域实体召回。我项目里曾经给公司内部产品名词典做了这个特征,F1 从 83 直接到了 88。
6.2 阈值与解码后处理:宁可少抽不可错抽
CRF 本身的解码是找整条路径最优,但实体的置信度并没有显式输出。后处理可以做两层:第一,对 CRF 解码结果里的连续实体 span,计算该 span 内所有 token 发射概率的均值,低于阈值则丢弃;第二,对跨标点符号的长实体做核验,如果中间有逗号或顿号,大概率不是同一个实体。
def filter_low_confidence(decoded, logits, label_list, threshold=0.5): # decoded: [seq_len] 标签id # logits: [seq_len, num_labels] 发射分数 spans = [] start = -1 current_label = None for i, tag_id in enumerate(decoded): label = label_list[tag_id] if label.startswith("B-"): if start != -1: spans.append((start, i - 1, current_label)) start, current_label = i, label[2:] elif label.startswith("I-") and start != -1: continue else: if start != -1: spans.append((start, i - 1, current_label)) start = -1 results = [] for s, e, label in spans: avg_score = logits[s:e+1, :].max(dim=-1).values.mean().item() if avg_score >= threshold: results.append((s, e, label)) return results阈值我一般设 0.3 到 0.5。如果线上业务对误报特别敏感,可以调到 0.6。这本质上是拿召回换精确率,具体数值用小范围测试集调,别拍脑袋。
6.3 轻量落地:蒸馏到小型模型后的验证方法
BERT 模型部署到 CPU 上,单条文本延迟可能超过 100 毫秒,吞吐上不去。常见做法是把它蒸馏到一个更小的模型,比如用 BiLSTM+CRF 直接学习 BERT 的输出分布。但蒸馏后的模型往往会掉点,而且掉点不均匀,对长实体和生僻实体影响更大。
验证蒸馏模型时不要只看整体 F1,要按实体类别拆开看。我一般会写一个评估脚本,分别列出 PER、LOC、ORG 的 P/R/F1。如果某一类掉点超过 3 个点,说明蒸馏时该类样本权重不够,需要针对性增加该类别的蒸馏损失。蒸馏后补一个实体级阈值校准,通常能把差距拉回到 1 个点以内。
最后说一个我自己的习惯:每次跑完一个实验,把模型输出里所有错误的样例存下来,按错误类型分类——边界错误、漏召回、误报。这个习惯比调参本身更能提升效果。因为错误样例看多了,你会发现大部分问题都出在数据标注质量上,而不是模型结构。我最后一次从 89 提到 91,靠的就是修正了 200 条标注不一致的数据,而不是换模型。希望这篇笔记能帮你的中文 NER 项目少走几步弯路。
本文还有配套的精品资源,点击获取