简介:基于BERT-BiLSTM-CRF的中文命名实体识别项目,面向自然语言处理学习者与需要完成课程设计的高校学生,提供从数据预处理、标签编码、模型训练、验证评估到结果输出的完整Python实现方案。压缩包共含18个文件,以9个Python脚本为核心,分别承担数据读取、参数配置、模型搭建、训练验证与评估预测等任务;另一部分为Word版使用说明、Markdown文档、结构示意图与运行效果图,帮助快速理解项目逻辑。整体体积仅299KB,目录包括训练日志与结果输出模块,便于定位关键代码和实验记录。目前已有259人学习下载,项目已获导师指导并给出97分高分评价,适合直接用于课程设计或期末大作业,无需修改即可运行。源码内置示例数据与预训练模型,可直观展示BERT语义表示、BiLSTM序列建模和CRF约束解码在中文命名实体识别中的协同流程,也可作为后续研究或个人项目的基线模板。
1. 中文命名实体识别:BERT-BiLSTM-CRF源码包能直接跑通的三件事
做中文命名实体识别(NER),BERT-BiLSTM-CRF这套组合到今天依然是课程设计、期末大作业和科研基线里最稳的三层结构。这份python源码不是单文件demo,而是一个下载后能直接跑通的完整项目:数据、预处理、训练入口、评估脚本、预训练模型权重全部打包在内,按手册.docx和README装好依赖、改好路径就能复现完整训练流程。它解决的核心问题是:从一段中文文本里自动抽取人名、地名、机构名等实体,并标注出每个实体的边界和类别。适合三类人:正在选课设题目、需要高分交付物的学生;想快速搭一条NER基线做对比实验的研究生;以及想搞清楚BERT输出到底怎么接BiLSTM再接CRF的初学者。
2. 三层架构原理:BERT向量、BiLSTM编码与CRF约束各自解决什么问题
2.1 BERT层:字级向量与动态上下文表示的取舍
BERT在中文NER里的角色是表示层,它把每个字映射成一个768维向量,而且这个向量是动态的——同一个字在“华为”和“中华”里面拿到的向量完全不同。这一点比word2vec那种静态词向量强太多,直接绕开了中文分词错误向NER传导的老问题。这一层用transformers库里的BertModel加载公开中文预训练权重即可,输入是token ids、segment ids和attention mask三样东西,输出最后一层的hidden state作为下游特征。
整个模型里最吃显存的就是这一层,它也是全模型中唯一一个“黑匣子”:你不会去改它内部的任何参数结构,只做微调。实际项目里我一般让BERT部分保持较低学习率,因为预训练权重已经学到了很强的语义表示,步子迈太大反而会把学到的知识冲掉。后面所有batch_size、max_seq_len的配置,本质上都是在迁就这一层的显存开销。
2.2 BiLSTM层:序列依赖与双向信息的进一步压缩
BERT输出的每个token向量虽然已经融合了上下文,但在结构上它们是彼此独立的,缺少显式的顺序建模。BiLSTM在这里补上两件事:一是把从左到右和从右到左两个方向的历史信息揉进每个位置;二是把768维向量压缩到更小的hidden_size维度,让下游CRF层处理起来更轻快。这两件事对中文NER的实际效果都很关键,尤其是实体边界判定,前向信息负责找起始,后向信息负责卡结尾。
在models/BERT_BiLSTM_CRF.py里,BiLSTM层把输入从768维投影到2×hidden_size,前向和后向的隐状态拼接在一起。hidden_size这个参数一般设128或256就够用,设置过大会让后续线性层参数爆炸,在小数据集上反而过拟合。项目里的rnncell.py是自定义的LSTM单元实现,如果你本地装的是新版本PyTorch,整体换成nn.LSTM也是完全兼容的,训练速度通常还会更快,效果没有差别。
2.3 CRF层:标签转移矩阵与维特比解码的价值
如果没有CRF层,很多序列标注模型的输出会在每个位置独立做softmax,结果就是可能出现“B-PER后面直接跟I-LOC”这种明显违反标签规范的低级错误。CRF层干的事情很直接:显式学习一个转移矩阵,约束标签之间的合法性,然后用维特比算法在整条序列上寻找全局最优标签路径。这意味着它看的不是一个位置,而是一整句话的标签序列。
举一个具体例子:B-PER后面只允许跟I-PER或O,CRF会把“I-PER跟在B-PER后面”的转移分数学得很高,把“I-LOC跟在B-PER后面”的转移分数压到很低,这种标签间的约束是纯softmax结构学不出来的。所以即使在小数据集上,BiLSTM+CRF也经常能压过BERT直接接softmax的方案。CRF层的可学习参数就是那个转移矩阵,形状是num_tags×num_tags,这是除BERT之外唯一需要关注维度的位置。
2.4 三层衔接的维度流转
把三层串起来看,数据维度是这样走的:input_ids进入BERT,得到[batch_size, max_seq_len, 768]的上下文表示;经过BiLSTM后变成[batch_size, max_seq_len, 2×hidden_size],前向和后向拼接;再过一个线性层投影到[batch_size, max_seq_len, num_tags],每个token在每个标签上的发射分数;最后CRF层叠加转移矩阵,输出最终的标签序列。训练时这条链路由crf损失函数统一驱动,推理时由维特比解码拿到结果。
换数据集或调参时,我都会先在脑子或草稿纸上过一遍这个维度流转。最容易翻车的两个位置:一是hidden_size设太大导致发射分数层参数爆炸,二是num_tags和实际标签数量对不上——BIO还是BIOES体系没搞清楚就去启动训练,后患无穷。基线模型的稳定,全靠这一层维度关系先立住。
3. 源码与数据链路:从原始文本到BERT输入的预处理细节与文件分工
3.1 项目文件清单与职责划分
拿到压缩包解压后,第一件事不是看代码,而是先搞清楚每个目录是干嘛的。这份项目里README.md和手册.docx把环境依赖和启动步骤写得比较清楚,关键代码文件的分工如下表:
| 文件 / 目录 | 职责 |
|---|---|
| run.py | 训练与预测入口,所有参数在这里解析 |
| models/BERT_BiLSTM_CRF.py | 三层模型主体封装 |
| rnncell.py | 自定义LSTM单元实现,可替换为nn.LSTM |
| data_helper.py | 读取原始数据,构造input_ids、label_ids、attention_mask |
| utils.py | padding、数据划分、批次构造等通用工具 |
| train_val_test.py | 把原始语料按比例切分为训练/验证/测试三份 |
| base_config.py | 全局配置:数据路径、模型路径、超参数 |
| conlleval.py | CoNLL格式的严格实体级评估脚本 |
| ckpt/ log/ result/ | 检查点保存、训练日志、预测结果输出 |
| imgs/ | bert_bilstm_crf.png结构图、demo.png演示截图,答辩展示直接用 |
这个文件划分是比较标准的工程结构:配置、数据、模型、训练入口、评估各自独立,意味着你可以只改base_config.py和data/下的数据就跑通新场景,不用动模型代码。动手改之前,我强烈建议先按原数据原参数跑通一遍完整流程,确认环境没问题,再谈修改。
3.2 标签体系:BIO还是BIOES,先搞清楚再动手
中文NER标准数据集里最常见的标签体系是BIO和BIOES两种。BIO把每个实体切为B-(开始)和I-(内部),非实体统一为O;BIOES在BIO基础上加了E-(结束)和S-(单个字实体),边界信息更完整。同一份数据,BIO体系下标签数量是三类实体×2+1,BIOES体系下是三类实体×4+1,num_tags对不上就全错。
常见的人名/地名/机构名标注,BIO体系会是B-PER(人名开始)、I-PER(人名内部)、B-LOC(地名开始)、I-LOC(地名内部)、B-ORG(机构名开始)、I-ORG(机构名内部)加一个O,总共7类;如果换成BIOES就变成13类。这个项目是哪套体系,以data/目录下的原始标签文件里实际出现的标签为准,不要想当然。我第一次复现类似项目时默认了BIO,结果数据里混着E-和S-,num_tags设置直接让训练崩掉,当时还不知道看标签文件里到底有哪些标记。
3.3 数据处理:分字、对齐与padding遮罩
中文BERT是字粒度模型,大多数中文字符切出来就是一个token,但英文单词、数字、标点有时会被WordPiece切成多个子词。这意味着label_ids必须严格跟随tokenizer的输出对齐,否则训练时模型看到的“第i个位置的标签”和“第i个字符”根本对不上。数据构造的核心逻辑用一个标准函数表示:
def build_bert_input(sentence, label_ids, tokenizer, max_seq_len): tokens = ["[CLS]"] valid_label_ids = [0] # CLS位置不参与实体预测,塞0占位 for char, label in zip(sentence, label_ids): token = tokenizer.tokenize(char) if len(token) == 0: continue # 极端情况下某些符号切不出token tokens.append(token[0]) valid_label_ids.append(label) tokens.append("[SEP]") valid_label_ids.append(0) # SEP位置同样不参与预测 input_ids = tokenizer.convert_tokens_to_ids(tokens) attention_mask = [1] * len(input_ids) # padding到max_seq_len,超过部分直接截断 pad_len = max_seq_len - len(input_ids) input_ids += [0] * pad_len attention_mask += [0] * pad_len valid_label_ids += [-100] * pad_len # pad位用-100遮罩 return input_ids, attention_mask, valid_label_ids这段代码里的关键设计是padding位置用-100而不是0:后面计算损失时设置ignore_index=-100,这些占位token就被彻底排除,不参与梯度计算。如果用0去填充,会被当成O类别一起算损失,导致模型被大量无意义的pad位置带偏,训练loss永远降不干净。input_ids的pad位用0是BERT词表里[PAD]的固定id,attention_mask的0则是告诉模型这些位置没有实际token。
参数方面需要关注的只有max_seq_len这一个值,它决定长文本里被截断的内容多少和显存占用上限。中文BERT对单个字直接切分,所以这段代码里的zip(sentence, label_ids)逐字对齐是有效的;但遇到英文或数字子词时只取了第一个token的标签,如果你的语料里混着大量英文,这里要做更细的子词标签同步,否则边界错误会集中在这些token上。utilities里的pad逻辑在batch构造时把所有样本统一到max_seq_len,注意先排序再padding能减少无效计算。
3.4 数据划分与检查点机制
train_val_test.py做的是按比例把原始语料分割成训练、验证、测试三个部分。这个步骤看似不起眼,实际影响很大:如果切分前不做shuffle,而原始数据按来源堆在一起,训练集和测试集分布就会跑偏,评估结果虚高或虚低都出现过。常见做法是按8:1:1切分,同时固定随机种子,保证每次复现的结果一致——这个细节在答辩时经常被评委追问,能答上来很加分。
ckpt目录下保存的是模型权重和训练状态。跑长训练时我会强制开启断点保存,每完成一个epoch就存一次,并且同时保存optimizer和scheduler的状态,这样中断后可以从最近检查点恢复,不用从头再来。这是给自己留的“后悔药”,尤其在调参阶段,一次训练跑三四个小时,没有检查点恢复机制的成本太高了。
4. 训练与推理:run.py参数调优、检查点保存与显存控制
4.1 run.py入口与核心参数速查
run.py是所有训练和预测操作的统一入口,参数通过命令行或者直接在文件头部配置。源码里的base_config.py定义了默认值,我的建议是改配置优先改base_config.py而不是命令行覆盖,这样每次启动都能复用到同一套参数,尤其是复现结果时。以下这张表是这一类项目里最核心的参数,也是我每次换环境必然核对一遍的位置:
| 参数 | 常见取值 | 对训练的影响 |
|---|---|---|
| max_seq_len | 128 ~ 256 | 越长显存占用越高,长文本实体召回越好 |
| batch_size | 8 ~ 32 | 显存第一决定因素,越大训练越稳 |
| epochs | 5 ~ 10 | 小数据集一般10轮内收敛 |
| lr_bert | 2e-5 ~ 5e-5 | BERT微调步长,过大震荡、过小收敛慢 |
| lr_other | 1e-3 | BiLSTM与CRF层的初始化学习率 |
| hidden_size | 128 ~ 256 | BiLSTM单元维度,过大会过拟合 |
| dropout | 0.1 ~ 0.5 | 下游层防过拟合 |
| accumulation_steps | 1 ~ 8 | 小batch通过梯度累积模拟大batch |
| use_fp16 | True / False | 显存不够时开启,低显存环境必备 |
max_seq_len这个参数值得多说一句:很多课设数据里的句子本身不超过50个字,把max_seq_len设成512纯属浪费显存。先统计一下data/里句长的分布,去掉极少数超长句后选一个覆盖90%样本的长度,是我在这个项目上总结出来的最优做法。128通常就能覆盖绝大多数中文NER数据的日常场景。
4.2 训练主循环:梯度累积与学习率调度
训练主循环的结构在同类项目里已经非常固定:取batch、前向算loss、反向传播、优化器更新。这里最关键的是梯度累积逻辑,它能让小显存环境跑出大batch效果。核心代码如下:
for epoch in range(args.epochs): model.train() for step, batch in enumerate(train_dataloader): input_ids, segment_ids, mask, label_ids = [t.cuda() for t in batch] loss = model(input_ids, segment_ids, mask, label_ids=label_ids) loss = loss / args.accumulation_steps loss.backward() if (step + 1) % args.accumulation_steps == 0: optimizer.step() scheduler.step() optimizer.zero_grad()loss除以accumulation_steps这一步是梯度累积的精髓:几次小batch的梯度累加后才更新一次参数,等效于把batch_size放大到原来的accumulation_steps倍。比如显存只够跑batch_size=8,设置accumulation_steps=2,就模拟出了batch_size=16的更新效果。注意accumulation_steps变大时训练速度会线性变慢,因为前向和反向还是要完整走完每个小batch,只是参数更新次数变少了。
segment_ids是BERT的句子类型编码,单句子样本里整段都是0;attention_mask告诉模型哪些位置是真实token、哪些是padding;label_ids里已经用-100把不需要计算损失的位置遮罩好了。scheduler这里用的是warmup加linear decay,前10%的训练步数里学习率从0线性爬升到设定峰值,之后再线性衰减到0。这一步在BERT微调里几乎是必须的,直接上满学习率会让BERT层的预训练权重在第一步就被冲坏。
训练过程中我会额外把关的是loss的绝对数值。BERT-BiLSTM-CRF的loss在第一个epoch跑到几十甚至上百都很正常,随着训练进行应该稳定下降到一个个位数的小值。如果loss卡在一个大数不动,大概率不是运气问题,而是后面避坑章节里要讲的学习率或对齐问题。
4.3 推理流程:模型加载与结果解码
推理阶段和训练最大的区别是:一定要把模型切到eval模式,并且用torch.no_grad()包住前向计算,否则模型里的dropout层在推理时还会工作,结果每次预测都不一样,这属于新手必踩的坑。加载检查点时的标准做法是先用同样的配置重建模型结构,再load_state_dict,顺序反了会报key不匹配。
推理输出的原始结果是每个token对应一个标签id,比如[0, 13, 14, 14, 0]代表O、B-PER、I-PER、I-PER、O的组合。要把这些标签id还原成可读的实体列表,需要一个decode函数:从第一个B-开头的标签开始,连续向后找相同前缀的I-标签,碰到O或者其他B-就截断,一个实体的边界就出来了。这一步是后续所有评估和结果展示的地基。result/目录下保存的就是推理后的完整结果文件,格式通常是每一行三列:原始token、真实标签、预测标签,空行分隔句子,这种格式直接喂给conlleval.py就能出评估报告。
提示:预测结果的decode逻辑千万不要在GPU上做复杂循环,先把argmax后的标签id拿到CPU,再用纯python处理,速度反而最快。
5. 避坑与排查:从训练报错到评估全零的五个高频问题
5.1 训练loss不降,前几个epoch原地踏步
现象:训练日志显示的loss值很大,跑了两三个epoch几乎没有下降趋势,甚至偶尔往上跳。
原因:最常见的是BERT部分和下游层共用了同一个学习率。BERT里的预训练参数只需要2e-5到5e-5的微调步长,而BiLSTM和CRF是从零开始随机初始化,需要1e-3量级的学习率才能快速收敛。一个学习率同时喂两层时,要么下游层学不动,要么BERT层在过大的步长下反复震荡。
解决:按参数名分组,让BERT走小学习率、其他层走大学习率。标准代码写法如下:
bert_params = [p for n, p in model.named_parameters() if "bert" in n] other_params = [p for n, p in model.named_parameters() if "bert" not in n] optimizer = AdamW([ {"params": bert_params, "lr": 2e-5}, {"params": other_params, "lr": 1e-3}, ])这段代码的核心是利用参数名里的“bert”前缀做区分,这条规则在绝大多数BERT微调项目里都适用。如果你用的是transformers库,注意它内部有些层名里不含“bert”但实际属于BERT范围,保险做法是把所有带“bert”、“encoder”开头的参数都归进低学习率组,宁多勿漏。改完这个之后训练loss的下降趋势会明显改善。
5.2 预测实体的边界整体偏移
现象:模型训练正常,loss也收敛了,但预测出的实体总是边界不对——B的位置对,E的位置多一个字或少一个字,或者整句标签序列整体往后错一位。
原因:这是label_ids没有和tokenizer对齐的标志。BERT用WordPiece分词,英文和数字会被切成子词,如果数据构造时是按“每个字符一个label”直接编号,而tokenizer输出的token序列和字符序列长度不一致,两边就错位了。另一个常见诱因是把CLS和SEP位置塞进了真实标签,等于让模型学会了错误的序列结构。
解决:回到第3章的build_bert_input函数,让label_ids严格跟随tokenizer输出的token序列逐位构造,CLS和SEP一律不参与实体预测,padding位置用-100遮罩。每次换数据集后,打印一条样本的tokens与labels进行逐位目检是最有效的排查手段——把前20个字符对应的token和标签并列打印出来,肉眼就能看出错位发生在哪一段。
5.3 GPU显存直接爆掉,第一轮batch就报错
现象:启动训练后第一个batch前向计算直接报CUDA out of memory,训练进程崩溃。
原因:BERT的self-attention复杂度随序列长度呈平方级上升,max_seq_len设512、batch_size设32的组合在普通显卡上几乎必炸。而且很多人只知道调batch_size,忽略了max_seq_len才是更本质的显存变量——同样是减少一半计算量,序列长度减半比batch减半效果更明显。
解决:两个参数一起调。先把max_seq_len降到128或256,再把batch_size降到8到16,然后用第4章说的accumulation_steps把有效batch补回来。如果这组组合拳打完还炸,就开启fp16半精度训练。低显存环境跑BERT类模型,这组“小batch+梯度累积+半精度”的组合是唯一正规路径,不要指望改一两行代码就解决问题。
5.4 conlleval.py评估结果全为零
现象:训练loss正常下降,验证曲线也正常,但conlleval.py跑出来的F1值居然是0.00或者0.01,让人怀疑模型学了个寂寞。
原因:conlleval.py执行的是严格实体级匹配——预测出来的实体边界和类别必须与真实标签完全一致,才算一个正确样本。只要边界差一个字,或者把I-LOC预测成了I-PER,这个实体就是0分。token级的accuracy再高,到实体级F1这里都会被严格标准压下来。另一个常见原因是评估文件句子之间缺少空行分隔,conlleval会把整段文本当成一个句子,标签边界全乱。
解决:评估前先看result/目录里的输出格式,确认每一行是“词 正确标签 预测标签”三列结构,句子之间有空行。拿一条只包含两三个实体的短句跑一次冒烟测试,验证评估脚本本身没问题,再全量评估。如果冒烟测试F1正常但全量数据F1低,那就是模型问题而不是脚本问题,回去调参数。
5.5 加载预训练模型报key不匹配
现象:加载BERT权重时出现size mismatch,或者提示某些key找不到,模型结构对不上预训练权重。
原因:base_config.py里指定的bert_model_dir目录下缺少pytorch_model.bin,或者transformers库版本不同导致预训练权重的参数名前缀不一致。不同版本之间,BERT内部层的命名从“bert.encoder.”变成“encoder.”的情况偶尔会出现,直接load_state_dict自然报错。
解决:先确认bert_model_dir目录下有config.json、pytorch_model.bin、vocab.txt三个文件,一个都不能少。加载成功后再打印一行模型中间层shape做快速校验,确认embedding输出维度是768,不为0、不为随机值。换transformers版本后,第一件事就是验证预训练权重能完整无警告加载,而不是直接跑全量训练,否则跑完几小时才发现权重压根没加载进去,这种血泪教训我没有经历过第二次。
6. 评估与进阶:conlleval严格F1之外还能榨出的实战效率
6.1 conlleval的输出怎么看
conlleval.py跑完后的输出会包含整体accuracy、precision、recall、FB1这几个核心指标,还会按实体类型分别列出准确率召回率。我一般只盯FB1这个整体值,它是精度和召回率的调和平均,一个数字说明模型质量。注意conlleval出的是严格实体级F1,比token级F1低是正常的,别被数字吓到,这个数字才是答辩时能站得住脚的指标。
6.2 显存不足时的半精度组合拳
如果你的显卡只有6G显存,开fp16是唯一能训完的路径。PyTorch里用AMP模块,在训练循环前加一行scaler初始化,loss.backward()前用scaler.scale(loss),optimizer.step()前用scaler.step(optimizer)再scaler.update()。半精度会让训练速度提升、显存占用降低,但偶见数值不稳定,所以检查点保存机制在fp16模式下更要勤快。
6.3 一个手写的实体提取脚本
推理得到的标签序列最终要还原成“实体文本 + 类别 + 起始位置”的结构,这个小脚本我每次评估前都会跑一遍,直接把result里的raw标签转成可读实体列表:
def extract_entities(tokens, label_ids, id2label): entities = [] i = 0 while i < len(label_ids): label = id2label[label_ids[i]] if label.startswith("B-"): entity_type = label[2:] start = i i += 1 while i < len(label_ids) and id2label[label_ids[i]] == "I-" + entity_type: i += 1 entities.append((entity_type, "".join(tokens[start:i]), start, i)) continue i += 1 return entities这段代码的核心逻辑是:遇到B-开头的标签就开启一个实体,不断往后吞噬同类型的I-标签,直到遇到O、其他B-或者序列结束。start和end记录的是token位置,拿回去对照原始文本就能定位实体的字符范围。评估前跑一遍这个脚本,用肉眼抽查50条预测结果,比任何评估指标都更能暴露模型问题。
从那以后我每次换数据集,都强制把max_seq_len、batch_size和标签体系这三项在base_config.py里核对一遍,训练前打印一条样本确认tokens与labels对齐,评估前跑一轮冒烟测试排除脚本格式问题,最后才敢看conlleval的F1值。这套流程救过我很多次,希望帮到你。
本文还有配套的精品资源,点击获取