简介本资源面向中文命名实体识别NER方向的初学者与毕业设计、课程设计需求者提供一套基于PyTorch实现的BERT-BiLSTM-CRF完整项目。项目将预训练BERT、双向LSTM与条件随机场结合覆盖数据加载、模型构建、训练、评估与预测全流程代码含详细注释新手也能看懂并快速部署。压缩包共22个文件约2.4MB包含8个py源码文件、5个txt数据集与标签文件、5个xml配置、1个md说明文档及json等辅助文件源码、数据与说明齐备目录结构清晰。目前已有663人学习下载属于导师认可的高分项目。读者可获得可直接运行的NER训练与预测脚本、中文数据集、参数配置与项目说明便于理解BERT-BiLSTM-CRF的工程实现并在此基础上完成模型调优、实验复现或二次开发。1. 中文命名实体识别为什么还在用 BERT-BiLSTM-CRF从一次标注翻车说起如果你手头有一批中文文本需要把里面的人名、地名、机构名、时间、金额甚至行业专有名词自动抽出来大概率绕不开命名实体识别NER。我最早做这块时试过纯规则、试过词典匹配也试过直接用 BERT 加一个分类头结果在「嵌套实体」和「标签边界」上反复翻车——比如「北京市海淀区」被切成「北京市」和「海淀区」两个地名或者「张三丰」被识别成「张三」加「丰」。后来换成 BERT-BiLSTM-CRF 这套组合边界和标签依赖的问题才明显收敛。这套模型的结构并不复杂BERT 负责把字变成带上下文的向量BiLSTM 负责捕捉前后文序列关系CRF 负责约束标签之间的转移合法性比如 I-ORG 不能直接接 B-PER。它适合谁适合手头有几千到几万条标注数据、想快速搭一个中文 NER 基线、又不想从零训词向量的 Python 开发者。下面我按「数据怎么准备 → 模型怎么搭 → 怎么训 → 怎么避坑 → 怎么进阶」的顺序把可复现的路径讲清楚。2. 数据准备与标注格式把原始文本变成 BERT-BiLSTM-CRF 能吃的输入2.1 中文 NER 常见标注体系与标签设计中文 NER 最常用的标注体系是 BIO 和 BIOES。BIO 只有 B、I、O 三种前缀BIOES 多了 E实体结束和 S单字实体。我一般推荐 BIOES因为它在边界上更明确CRF 的转移约束也更好设计。标签集合取决于你的业务通用领域常用 PER人名、LOC地名、ORG机构名垂直领域比如医疗要加 DIS疾病、DRUG药物金融要加 MONEY、TIME。标签设计有个血泪经验不要一开始就堆几十个标签。标签越多每个标签的样本越少CRF 转移矩阵越难学。我通常先做 4 到 8 个核心标签跑通基线后再逐步加。标签文件单独存成labels.txt每行一个标签顺序固定训练和推理必须用同一份。2.2 把原始语料转成 BIOES 格式的 Python 脚本假设你有一份raw.txt每行是一句中文实体信息存在另一个entities.json里格式是{句子索引: [[起始位置, 结束位置, 实体类型], ...]}。下面这个脚本把它转成 BIOES 标注import json def bioes_tag(sentence, entities): # 初始化全 O tags [O] * len(sentence) for start, end, label in entities: if start end: tags[start] fS-{label} else: tags[start] fB-{label} for i in range(start 1, end): tags[i] fI-{label} tags[end] fE-{label} return tags with open(raw.txt, r, encodingutf-8) as f: sentences [line.strip() for line in f if line.strip()] with open(entities.json, r, encodingutf-8) as f: entity_map json.load(f) with open(train.bioes, w, encodingutf-8) as out: for idx, sent in enumerate(sentences): ents entity_map.get(str(idx), []) tags bioes_tag(sent, ents) for ch, tag in zip(sent, tags): out.write(f{ch}\t{tag}\n) out.write(\n) # 句子之间空行分隔逻辑说明bioes_tag按实体起止位置打标签单字实体用 S多字实体首尾用 B/E中间用 I。参数说明entities.json的 key 必须和句子索引对应位置是闭区间。转换后每行是「字 tab 标签」空行分句。这个格式后面喂给 Dataset 类时直接按空行切句即可。2.3 数据集划分与标签对齐检查划分比例我一般用 8:1:1但中文 NER 数据少的时候会用 7:1.5:1.5。关键是标签对齐训练集里出现过的标签验证集和测试集可以没有但推理时的标签列表必须和训练时完全一致否则 CRF 转移矩阵对不上。我习惯在训练前跑一个检查脚本统计每个标签的出现次数出现次数少于 5 的标签要么合并要么删掉不然 CRF 学出来的转移分数基本是噪声。提示如果你的数据里实体有重叠比如「北京大学人民医院」既是 ORG 又包含 LOCBIOES 单层标注放不下需要改成多层标注或改用 span 抽取方案这套 BERT-BiLSTM-CRF 就不适用了。3. 模型搭建BERT 输出怎么接 BiLSTM 再接 CRF3.1 BERT 层选中文预训练模型与输出维度处理BERT 层直接用 HuggingFace 的transformers加载中文预训练模型。常见做法是选bert-base-chinese它输出的是每个字的 768 维向量。注意 BERT 的 tokenizer 对中文是按字切分但遇到英文和数字会按子词切所以你的标签也要按 tokenizer 的实际切分对齐。我一般用is_split_into_wordsTrue先按字切好再喂给 tokenizer这样标签和 token 一一对应。from transformers import BertTokenizer, BertModel import torch import torch.nn as nn class BertEncoder(nn.Module): def __init__(self, model_namebert-base-chinese): super().__init__() self.bert BertModel.from_pretrained(model_name) self.hidden_size self.bert.config.hidden_size # 768 def forward(self, input_ids, attention_mask): outputs self.bert(input_idsinput_ids, attention_maskattention_mask) # outputs.last_hidden_state: [batch, seq_len, 768] return outputs.last_hidden_state参数说明model_name换成你本地的预训练模型路径也行离线环境提前下载好。attention_mask必须传否则 padding 位置会参与 BiLSTM 计算导致标签偏移。BERT 层一般不加额外投影直接输出 768 维给 BiLSTM。3.2 BiLSTM 层隐藏维度、层数与 dropout 的设置BiLSTM 接在 BERT 后面输入维度 768隐藏维度我一般设 256双向拼接后是 512。层数 1 到 2 层足够再深容易过拟合且训练慢。dropout 设 0.3 到 0.5放在 BiLSTM 输出之后、CRF 之前。class BiLSTMEncoder(nn.Module): def __init__(self, input_dim768, hidden_dim256, num_layers1, dropout0.4): super().__init__() self.lstm nn.LSTM( input_dim, hidden_dim, num_layersnum_layers, bidirectionalTrue, batch_firstTrue, dropoutdropout if num_layers 1 else 0 ) self.dropout nn.Dropout(dropout) self.output_dim hidden_dim * 2 # 512 def forward(self, x, attention_mask): # x: [batch, seq_len, 768] lstm_out, _ self.lstm(x) lstm_out self.dropout(lstm_out) return lstm_out # [batch, seq_len, 512]参数说明hidden_dim256是经验值数据量小可以降到 128数据量大可以升到 384。num_layers1时 dropout 不生效这是 PyTorch 的行为别误以为设了没用。attention_mask在这里没直接用到但后面 CRF 的 mask 要用它来屏蔽 padding。3.3 CRF 层转移矩阵与维特比解码的实现要点CRF 层是这套模型的「后悔药」——它保证输出的标签序列合法。核心是一个转移矩阵transitions[i][j]表示从标签 i 转到标签 j 的分数加上发射分数BiLSTM 输出经过一个线性层映射到标签数。训练时用负对数似然推理时用维特比解码。from torchcrf import CRF class BertBiLSTMCRF(nn.Module): def __init__(self, num_labels, model_namebert-base-chinese): super().__init__() self.bert BertEncoder(model_name) self.bilstm BiLSTMEncoder(input_dimself.bert.hidden_size) self.classifier nn.Linear(self.bilstm.output_dim, num_labels) self.crf CRF(num_labels, batch_firstTrue) def forward(self, input_ids, attention_mask, labelsNone): bert_out self.bert(input_ids, attention_mask) lstm_out self.bilstm(bert_out, attention_mask) emissions self.classifier(lstm_out) # [batch, seq_len, num_labels] if labels is not None: # 训练返回负对数似然 loss -self.crf(emissions, labels, maskattention_mask.bool(), reductionmean) return loss else: # 推理维特比解码 return self.crf.decode(emissions, maskattention_mask.bool())逻辑说明torchcrf的CRF类封装了前向算法和维特比解码。mask参数必须传否则 padding 位置的标签会参与转移计算。参数说明num_labels等于标签文件行数reductionmean对 batch 内取平均数据不平衡时可以改成sum再手动加权。推理返回的是每条序列的标签 id 列表需要映射回标签名。4. 训练与推理从配置到跑通的完整命令4.1 训练脚本与关键超参配置训练脚本我一般写成train.py核心超参如下表参数推荐值说明batch_size16 或 32显存 8G 用 1616G 用 32learning_rate2e-5BERT 微调经典值BiLSTM 部分可用 1e-3epochs10 到 20看验证集 F1 早停max_seq_len128 或 256中文句子一般不超过 128dropout0.4BiLSTM 输出后optimizerAdamWweight_decay 设 0.01import torch from torch.utils.data import DataLoader from transformers import BertTokenizer tokenizer BertTokenizer.from_pretrained(bert-base-chinese) model BertBiLSTMCRF(num_labelslen(label_list)).cuda() optimizer torch.optim.AdamW(model.parameters(), lr2e-5, weight_decay0.01) for epoch in range(epochs): model.train() for batch in train_loader: input_ids batch[input_ids].cuda() attention_mask batch[attention_mask].cuda() labels batch[labels].cuda() loss model(input_ids, attention_mask, labels) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() optimizer.zero_grad() # 验证集评估见 4.2逻辑说明clip_grad_norm_防止梯度爆炸BERT 微调时尤其重要。参数说明max_norm1.0是常用值训练不稳定可以降到 0.5。optimizer.zero_grad()放在step()之后是个人习惯放前面也行但别漏。4.2 用 seqeval 算实体级 F1 而不是 token 级准确率token 级准确率在 NER 里会骗人——因为大部分标签是 O模型全预测 O 也能有 90% 以上准确率。必须用实体级 F1seqeval库是标准做法。from seqeval.metrics import classification_report, f1_score def evaluate(model, val_loader, id2label): model.eval() all_preds, all_labels [], [] with torch.no_grad(): for batch in val_loader: input_ids batch[input_ids].cuda() attention_mask batch[attention_mask].cuda() labels batch[labels] preds model(input_ids, attention_mask) for pred, label in zip(preds, labels): pred_tags [id2label[p] for p in pred] true_tags [id2label[l.item()] for l in label if l.item() ! -100] all_preds.append(pred_tags) all_labels.append(true_tags) print(classification_report(all_labels, all_preds)) return f1_score(all_labels, all_preds)逻辑说明-100是 padding 标签的忽略值评估时要过滤掉。参数说明seqeval要求输入是标签字符串列表的列表不是 id。classification_report会输出每个实体类型的 precision、recall、F1。4.3 推理脚本加载模型并对新句子做实体抽取推理时把模型切到eval()关掉 dropout按同样的 tokenizer 处理输入。def predict(text, model, tokenizer, id2label, max_len128): model.eval() tokens list(text) encoding tokenizer(tokens, is_split_into_wordsTrue, max_lengthmax_len, truncationTrue, paddingmax_length, return_tensorspt) input_ids encoding[input_ids].cuda() attention_mask encoding[attention_mask].cuda() with torch.no_grad(): pred_ids model(input_ids, attention_mask)[0] tags [id2label[i] for i in pred_ids] # 按 BIOES 合并实体 entities [] current None for ch, tag in zip(tokens, tags): if tag.startswith(B-): current [ch, tag[2:]] elif tag.startswith(I-) and current: current[0] ch elif tag.startswith(E-) and current: current[0] ch entities.append(tuple(current)) current None elif tag.startswith(S-): entities.append((ch, tag[2:])) else: if current: entities.append(tuple(current)) current None return entities逻辑说明BIOES 合并时 B 开头、I 延续、E 结束、S 单独成实体。参数说明max_len要和训练时一致截断策略默认是尾部截断。注意 tokenizer 对英文数字的子词切分会让tokens和tags长度不一致稳妥做法是用word_ids()对齐这里为简洁按纯中文处理。5. 避坑与排查BERT-BiLSTM-CRF 训练中常见的 5 个翻车点5.1 损失不下降CRF 转移矩阵全是噪声现象训练几个 epoch 后 loss 卡在 2.0 左右不降验证集 F1 接近 0。原因标签列表里有出现次数极少的标签CRF 转移矩阵对这些标签的分数学不出来反而干扰了其他标签。解决统计标签频次把少于 5 次的标签合并到 O 或相近标签重新生成标签文件再训。5.2 验证集 F1 很高但推理结果全是 O现象验证集 F1 0.9但拿新句子推理输出全是 O。原因验证集和训练集同分布而新句子长度超过max_seq_len被截断实体正好在截断部分或者 tokenizer 的is_split_into_words没开标签和 token 错位。解决检查推理输入长度确认 tokenizer 配置和训练一致打印 token 和标签对齐结果。5.3 显存溢出batch_size 降到 1 还报 OOM现象8G 显存跑 batch_size 16 直接 OOM降到 1 仍然报错。原因BERT 模型本身占显存加上 BiLSTM 和 CRF 的中间变量以及max_seq_len256时的注意力矩阵。解决先把max_seq_len降到 128开启torch.cuda.amp混合精度BiLSTM 的hidden_dim从 256 降到 128通常能省一半显存。5.4 实体边界识别错误B 和 I 标签混淆现象模型把「北京市海淀区」识别成「北京市海」加「淀区」。原因BIOES 的 E 标签样本太少CRF 没学好 E 的转移。解决检查标注质量确认 E 标签没有漏标增加 E 标签的样本或者改用 BIO 标注边界问题交给后处理规则。5.5 加载预训练模型时报缺失 key 或维度不匹配现象from_pretrained报size mismatch或大量 missing keys。原因用了非中文预训练模型或者模型配置里的vocab_size和 tokenizer 不一致。解决确认model_name和tokenizer来自同一个预训练模型如果自己改过词表需要 resize embedding 并重新微调。6. 进阶技巧用对抗训练和标签平滑把 F1 再提两个点基线跑通后想再提点我一般先上两个技巧FGM 对抗训练和标签平滑。FGM 在 embedding 层加扰动让模型对输入噪声更鲁棒标签平滑缓解 CRF 对硬标签的过拟合。这两个都不改模型结构只改训练循环。class FGM: def __init__(self, model, epsilon1.0): self.model model self.epsilon epsilon self.backup {} def attack(self): for name, param in self.model.named_parameters(): if embedding in name and param.requires_grad: 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.backward()之后、optimizer.step()之前先fgm.attack()再算一次 loss 并 backward然后fgm.restore()最后 step。参数说明epsilon1.0是常用值太大反而掉点。标签平滑在 CRF 里没有直接接口常见做法是在发射分数上做平滑或者改用CrossEntropyLoss(label_smoothing0.1)替代 CRF 的 NLL——但这样会丢掉转移约束我一般只在数据量很大时才这么干。验证方法每次改动只动一个变量跑三次不同随机种子取平均 F1避免被单次波动骗了。我自己的习惯是任何「提点」技巧如果三次平均提升不到 0.5 个点就不加进最终方案保持训练脚本干净。这套 BERT-BiLSTM-CRF 的代码结构不复杂难的是数据质量和标签一致性把这两块守住F1 不会差。希望帮到你。本文还有配套的精品资源点击获取