简介:面向中文命名实体识别任务的完整Python工程,融合BERT-BILSTM-CRF模型,可满足毕业设计、课程设计或项目实践需求。压缩包内共二十个文件,涵盖六份Python脚本、八份JSON配置、五份TXT数据/标签文件与一份Markdown说明,整体仅1.03MB,结构紧凑,已有1250人学习浏览。项目以dgre、duie两个数据集为例,完整覆盖了原始语料清洗、BIO标注数据生成、模型训练、结果预测等环节。其中main.py可灵活调整最大序列长度、训练轮次和批大小,predict.py能加载已训练模型进行实体抽取;同时提供了chinese-bert-wwm-ext预训练模型目录,并明确列出transformers、pytorch-crf、seqeval等依赖版本,减少环境配置障碍。资源按模型保存、预训练权重、数据集等目录划分,便于快速定位与二次开发。所有代码均经过测试,可直接运行,通用性与扩展性较好,非常适合作为课设、毕设及中文NER入门的实用参考。
1. 中文命名实体识别,为什么非要BERT-BiLSTM-CRF这一套
当你想从一堆医疗病历里抽药品名和疾病名,或者从裁判文书中提取当事人信息,第一反应是写规则词典,但新词和同义表达会让规则快速崩坏。换成普通CRF,又发现它只学到局部上下文,“苹果”到底是水果还是公司名,模型根本没谱。这两个场景是中文命名实体识别最常见的翻车现场,而BERT-BiLSTM-CRF这套组合,恰好把“语义理解”“时序依赖”“标签约束”三件事分开做,成为近两年中文NER的标配方案。这篇文章要拆解的,就是这样一个Python项目——包含源码、使用说明、数据与模型文件,目标读者是想快速跑通中文NER、同时又想把每一步原理吃透的开发者和学生。
2. 先拆模型:BERT、BiLSTM、CRF在NER管线里到底干了什么活
2.1 BERT:把每个字变成带语境的向量,而不是查静态词表
传统词向量如word2vec用一个固定向量代表词,“苹果”在“苹果很好吃”和“苹果公司发布新机”里是同一个向量,模型分不出差异。中文NER是字级别任务,更依赖上下文。BERT用多层Transformer对整句话做双向建模,每个字经过层层自注意力后,输出的向量已经包含了全句其他字的信息。也就是说,“苹”“果”两个字在“很好吃”和“公司发布”两种语境里,得到的向量是完全不同的。
在Python项目中,BERT部分通常通过Hugging Face的transformers库加载,常见做法是用bert-base-chinese预训练权重。加载后取最后一层last_hidden_state作为每个字的特征,维度是[batch_size, seq_len, 768]。代码一般长这样:
from transformers import BertTokenizer, BertModel tokenizer = BertTokenizer.from_pretrained("bert-base-chinese") model = BertModel.from_pretrained("bert-base-chinese") text = "张三在北京上班" inputs = tokenizer(text, return_tensors="pt", max_length=128, truncation=True) outputs = model(**inputs) # outputs.last_hidden_state 的形状是 [batch, seq_len, 768] hidden = outputs.last_hidden_state参数说明:max_length=128限制单句最大长度,超过部分截断;truncation=True表示启用截断。注意tokenizer会在句首和句尾自动插入[CLS]和[SEP],所以seq_len比原始字数多2。后续做序列标注时,要么对齐标签,要么在解码时去掉这两个位置。BERT的768维特征对后续BiLSTM来说正好是输入维度。
2.2 BiLSTM:把BERT的“字向量”按时间顺序再读一遍,捕捉前后依赖
BERT已经建模了上下文,为什么还要再叠一个BiLSTM?原因在于BERT是通用预训练模型,直接接CRF的话,整个模型容量太大,在小规模标注数据上容易过拟合。BiLSTM相当于加了一个轻量的任务专用编码器,能进一步聚焦当前语料中的时序依赖。它把句子从左到右和从右到左两个方向读两遍,每个时刻的隐状态是前向和后向的拼接,所以能同时看到某个字左边和右边的信息。
项目中的BiLSTM层通常用PyTorch的nn.LSTM实现,并设置bidirectional=True。定义参考如下:
import torch.nn as nn class BiLSTM(nn.Module): def __init__(self, input_size=768, hidden_size=256, num_layers=2, dropout=0.5): super().__init__() self.lstm = nn.LSTM(input_size, hidden_size, num_layers, batch_first=True, bidirectional=True, dropout=dropout) self.fc = nn.Linear(hidden_size * 2, num_tags) # 每个字输出所有标签的得分 def forward(self, x, mask): # x: [batch, seq_len, 768] lstm_out, _ = self.lstm(x) # lstm_out 每个时刻的向量是 [hidden_size * 2],因为双向拼接 logits = self.fc(lstm_out) # [batch, seq_len, num_tags] return logits参数说明:hidden_size=256表示单向LSTM隐层维度,双向拼接后是512,所以fc输入是hidden_size * 2。num_layers=2代表两层LSTM堆叠,能学习更高阶依赖,但参数量和过拟合风险也随之增加。dropout=0.5作用于LSTM层之间,实际工程中还经常在fc之前再补一个dropout层。mask参数用于标注padding位置,后续CRF计算和损失计算都需要靠它忽略无意义的补零位。
2.3 CRF:不让模型脱离实际标签规则,强制全局最优
BiLSTM输出的logits是每个字对每个标签的打分,如果直接取最高分作为最终标签,会出现“B-PER后面跟着I-ORG”这种非法序列,因为人名的后面不可能直接接机构名的内部标签。CRF层在BiLSTM之上增加一个标签转移矩阵,学习标签之间的合法性约束。例如“B-ORG”后面只能接“I-ORG”或“O”,不能接“I-PER”。解码时不再逐字取最大,而是用维特比算法寻找整条句子的全局最优路径。
实际项目中常用torchcrf库,或者参照CRF论文自己实现转移矩阵和log-sum-exp。一个典型的CRF接入方法是在模型forward里这样写:
import torch # 假设已有 crf 对象,来自自实现或 torchcrf.CRF class NERModel(nn.Module): def __init__(self, num_tags): super().__init__() self.crf = CRF(num_tags) # 封装好的CRF层 def forward(self, logits, tags, mask): # logits: [batch, seq_len, num_tags] # tags: [batch, seq_len], 合法位置为0~num_tags-1 # mask: [batch, seq_len], padding位置为0 loss = self.crf(logits, tags, mask=mask) decoded = self.crf.decode(logits, mask=mask) return loss, decoded这里loss返回的是负对数似然,训练时希望它越小越好;decoded是预测的标签序列。CRF的训练比普通softmax慢,但换来的是实体边界更规整,在中文NER任务上通常能提升2~4个百分点的F1值。
2.4 为什么不直接用BERT+softmax?CRF的必要性
BERT+softmax也能做序列标注,每个位置独立分类,但它不会显式建模标签依赖。比如“张三在腾讯工作”,如果没有CRF约束,模型可能把“腾讯”内部的“I-ORG”延续到“工作”两个字上,甚至把“张”预测为“B-ORG”后,后面的“三”无法确定是不是该接“I-PER”。CRF的好处是把“标签转移”也变成可学习的参数,模型会发现“B-PER”后面如果跟着“I-PER”的比例远高于“I-ORG”,从而在解码时更倾向合法路径。
当然,CRF也不是没有代价。解码阶段需要基于动态规划,速度比逐位置argmax慢,而且转移矩阵的初始化也影响收敛。如果你用torchcrf,需要处理batch内不同长度序列的mask,稍微粗心就会在padding位置报错。后续第5章会展开哪些坑最容易踩。
3. 跑通这个Python项目的完整流程:环境、数据、训练、预测
3.1 环境准备:python安装级别的依赖配置与bert模型下载
这个项目基本基于Python 3.8以上版本,建议创建独立虚拟环境,避免污染系统Python。首先你需要一个能跑PyTorch的Python环境,如果没有装Python,先装Python再创建虚拟环境。环境搭建的命令如下:
python -m venv ner_env source ner_env/bin/activate # Windows 下用 ner_env\Scripts\activate pip install torch==2.0.1 pip install transformers==4.30.0 pip install seqeval pip install pandas tqdm参数说明:torch==2.0.1是一个稳定的版本,如果要用GPU记得安装对应CUDA版本,纯CPU运行也能训练但速度会慢很多。transformers==4.30.0对应BERT模型加载接口的较稳定版本,太旧可能不支持is_split_into_words参数,太新可能引入不必要的依赖。seqeval是专门用来评测实体F1的库,后面第6章会用到。
下载BERT模型时,transformers会尝试从Hugging Face官网拉取权重,国内网络常常很慢或中断。常见做法是手动下载模型的config.json、pytorch_model.bin、vocab.txt三个文件放到本地目录,然后用BertModel.from_pretrained("./bert-base-chinese/")指向本地路径。如果网络状况不好,也可以配置HF_ENDPOINT环境变量使用镜像站点,但这不涉及任何代理工具。
3.2 看懂数据格式:BIO标注与每个字的标签
项目里的data目录通常包含train.txt、dev.txt、test.txt,每行格式为“字符 标签”,空行分隔不同句子。标签体系最常见的是BIO,也有用BIOES的,区别在于实体最后一个字用E还是I。下面是一个标准BIO标注样例:
张 B-PER 三 I-PER 在 O 北 B-LOC 京 I-LOC 上 O 班 O每个字符和标签之间用空格或制表符分隔。读取代码逻辑很简单,但要注意一定按空行切分:
def load_data(path): sentences, labels = [], [] tokens, tags = [], [] with open(path, encoding='utf-8') as f: for line in f: if line.strip() == '': if tokens: sentences.append(tokens) labels.append(tags) tokens, tags = [], [] else: char, tag = line.strip().split() tokens.append(char) tags.append(tag) return sentences, labels逻辑说明:输入文件可能是UTF-8带BOM,需要用encoding='utf-8'也可能需要utf-8-sig;split默认按空白拆分,如果标签里包含空格就不行了,更稳妥的是split('\t')或split()。读取完成后,需要建立标签到id的映射,例如{'O': 0, 'B-PER': 1, 'I-PER': 2, 'B-LOC': 3, 'I-LOC': 4},并同步建立id2label供解码时反向映射。
3.3 训练模型:从数据加载到损失函数
训练阶段是整个项目的核心。模型由BERT、BiLSTM、CRF三部分串联,输入是input_ids,attention_mask,labels。标签需要和BERT的token长度对齐,中文一个汉字通常是一个token,但数字或英文字符可能被切分得更细,所以需要用word_ids做对齐,后面避坑章节会详细讲。
一个完整的训练循环大致如下:
from transformers import BertTokenizer, BertModel import torch.nn as nn from torch.utils.data import DataLoader, Dataset import torch class NERDataset(Dataset): def __init__(self, sentences, labels, tokenizer, label2id, max_len=128): self.sentences = sentences self.labels = labels self.tokenizer = tokenizer self.label2id = label2id self.max_len = max_len def __len__(self): return len(self.sentences) def __getitem__(self, idx): words = self.sentences[idx] tags = self.labels[idx] # 这里使用 is_split_into_words=True 保证传入的是字列表 encoding = self.tokenizer(words, is_split_into_words=True, max_length=self.max_len, truncation=True, padding='max_length', return_tensors='pt') # 将标签对齐到token labels_ids = [self.label2id[tag] for tag in tags] # 利用 word_ids 将每个token的标签映射到原始字;padding位置设为-100 word_ids = encoding.word_ids() aligned_labels = [] previous_word_idx = None for word_idx in word_ids: if word_idx is None: aligned_labels.append(-100) # padding和特殊token elif word_idx != previous_word_idx: aligned_labels.append(labels_ids[word_idx]) else: aligned_labels.append(labels_ids[word_idx]) # 子词重复取同一标签 previous_word_idx = word_idx return { 'input_ids': encoding['input_ids'].squeeze(0), 'attention_mask': encoding['attention_mask'].squeeze(0), 'labels': torch.tensor(aligned_labels) }然后定义训练函数:
def train_epoch(model, dataloader, optimizer, device): model.train() total_loss = 0 for batch in dataloader: input_ids = batch['input_ids'].to(device) attention_mask = batch['attention_mask'].to(device) labels = batch['labels'].to(device) loss = model(input_ids, attention_mask, labels) optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() return total_loss / len(dataloader)参数说明:padding='max_length'会把所有句子统一填充到128,但填充位置标签为-100,CRF计算时会通过mask忽略。word_ids是tokenizer返回的映射关系,用于把原始字级别的标签复制到每个subword上。如果某个字被切成多个token,这些token都共享同一个标签,这是训练时不丢失标签信息的标准做法。
3.4 预测与解码:用CRF的维特比算法输出实体
预测阶段不再需要标签,只需要输入句子文本。模型forward返回decode结果,再由id2label映射回标签名。一个容易忽略的点是,BERT的is_split_into_words=True与max_length联合使用时,原始words长度不能超过max_len-2,否则会被截断,导致句子末尾实体被切掉。
预测函数参考:
def predict_sentence(text, model, tokenizer, id2label, device, max_len=128): model.eval() words = list(text) encoding = tokenizer(words, is_split_into_words=True, max_length=max_len, truncation=True, padding='max_length', return_tensors='pt') input_ids = encoding['input_ids'].to(device) attention_mask = encoding['attention_mask'].to(device) with torch.no_grad(): decode_ids = model(input_ids, attention_mask) # [batch, seq_len] decoded = decode_ids[0].tolist() # 去掉[CLS]和[SEP],保留原句子长度对应的部分 result_tags = [id2label[i] for i in decoded[1:len(words)+1]] return list(zip(words, result_tags))这里的decode_ids已经去掉了CRF维特比解码结果,但长度还是padding后的长度。我们需要根据原始words长度截取有效部分。注意如果句子长度刚好为max_len,decoded[1:len(words)+1]会越界,所以通常设置max_len时留出2个位置给特殊token,或者在截断前判断长度。
4. 参数都在哪改:让模型适配你自己的数据集
4.1 文本长度与batch size:显存和效果的平衡
max_len决定每个样本最多编码多少个字,BERT的自注意力复杂度是O(n²),128和512的显存消耗相差16倍。中文NER如果句子普遍不超过100字,max_len=128足够;如果是法律条文或长病历,建议先用统计看一下75分位长度,再定max_len。千万不要无脑设512,最后跑起来OOM又回来改数据。
batch_size受显存限制,一般Bert-BiLSTM-CRF在12GB显存上跑max_len=128,batch_size=32可能刚好。如果显存不够,可以把batch_size降到16,同时增大gradient_accumulation_steps来模拟大batch。常见配置如下:
config = { 'max_len': 128, 'batch_size': 32, 'gradient_accumulation_steps': 1, 'epochs': 5, 'lr_bert': 2e-5, 'lr_other': 1e-3, 'hidden_size': 256, 'num_layers': 2, 'dropout': 0.5, }4.2 学习率:BERT层和BiLSTM层要不要分开
BERT是预训练模型,微调学习率如果太大,会把已经学好的通用语义摧毁,常见取值范围是2e-5到5e-5。而BiLSTM和CRF是随机初始化的层,需要更大的学习率去收敛,常见1e-3左右。项目里经常把参数分成两个组传给优化器:
optimizer = torch.optim.AdamW([ {'params': model.bert.parameters(), 'lr': 2e-5}, {'params': model.bilstm.parameters(), 'lr': 1e-3}, {'params': model.fc.parameters(), 'lr': 1e-3}, {'params': model.crf.parameters(), 'lr': 1e-3}, ])参数说明:这里lr_bert=2e-5是BERT微调的常用起点,如果数据集很小,可以降到1e-5;下游层学习率设置过高会导致loss震荡,如果发现训练集loss不稳定,可以把lr_other降到5e-4。
4.3 BiLSTM隐层大小与dropout:过拟合的闸门
hidden_size控制BiLSTM的容量,一般数据量在几千条时用128或256足够,上万条可以考虑512。num_layers我通常设为2,再多容易过拟合,而且训练时间成倍增加。dropout是防过拟合的关键,也影响收敛速度。一个小经验:如果训练F1很高、验证F1很低,先把dropout从0.5提到0.6;如果两个都低,先检查数据对齐,不要盲目调dropout。
4.4 训练轮数与早停:什么时候收手
中文NER微调通常3到5轮就能收敛,因为BERT已经提供了很强的语义先验。但每轮epoch时间很长,手动盯太多轮不现实,所以项目里普遍加入早停机制:如果连续2个epoch验证集F1没有提升,就把学习率减半或直接停止,并保存验证集最佳模型。保存模型时不要只存state_dict,也要一并保存label2id.json和config.json,否则预测时无法恢复标签映射。
5. 避坑与常见问题:我在这套模型上踩过的六个雷
5.1 数据与标注的坑
坑1:BIO标签和BERT token不对齐,导致预测全是O。
现象:训练loss能下降,但用predict_sentence测试时,所有输出都是O。
原因:最常见的是没有处理[CLS]和[SEP],把标签直接拼在原始字上,导致标签序列和第1个token错位;另外英文或数字会被BERT拆成多个subword,原始字标签无法直接对上。
解决:用tokenizer.word_ids()做映射,每个subword继承原始字的标签,padding位置设为-100。预测时按1:len(words)+1截取,最好再用word_ids取每个原始字第一个subword的输出。
坑2:实体在句子间被切成两半。
现象:语料里一个人名“张三丰”刚好跨了两个换行,第一行存“张”,第二行存“三丰”,模型永远学不会完整实体。
原因:数据预处理只按空行切分,没有处理原始文档中断行导致实体断裂。
解决:切句前先按标点(句号、分号)分句,再检查实体是否跨句。如果项目允许,可以做成滑窗重叠,让跨句实体至少一次在窗口内完整出现。
坑3:标注数据里非法序列太多,CRF不收敛。
现象:训练时CRF loss降到某个值后不动,验证F1在0附近。
原因:标注工具允许了“B-PER后面接I-ORG”这类非法标签,CRF难以学习。
解决:写一个校验脚本扫描训练数据,检查每个实体内部的标签连续性,把非法标注找出来重新修正。不要指望CRF自己把脏数据纠正过来。
5.2 模型与训练环境的坑
坑4:torch和transformers版本冲突,BERT加载报错。
现象:from_pretrained时抛AttributeError: 'NoneType' object has no attribute 'to'。
原因:transformers新版本依赖较新的torch API,老版本torch缺少某些方法。
解决:锁定版本组合,比如transformers==4.30.0配torch==2.0.1。如果必须用新transformers,就升级torch到对应版本,不要混装。
坑5:Batch里padding位置参与CRF解码,导致预测结果把填充位置也当成O。
现象:预测输出的标签数量比实际句子长,后面多出一堆O。
原因:CRF decode时没有传入mask,或者传了但padding标签用了0(恰好是O),导致padding位置也被解码成合法标签。
解决:在CRF层内把mask转换成bool,解码时跳过mask==0的位置。如果你自实现CRF,务必在转移得分中加入mask惩罚。
坑6:BERT模型下载卡在中间,from_pretrained一直重试。
现象:跑项目时终端卡在Downloading...半天不动,最终连接失败。
原因:默认从国外服务器下载,网络不稳定。
解决:在代码开头设置os.environ['HF_ENDPOINT'] = 'https://hf-mirror.com',或者手动下载模型文件到本地并用本地路径加载。注意不要依赖任何需要额外配置的工具,直接换成国内可直连的镜像源即可。
6. 进阶:用seqeval严格评测F1,以及如何换到新领域
当模型训练完,别急着用准确率来评价,要用seqeval计算实体级别的F1,因为它是按实体的边界和类型统一匹配的。直接扁平化标签计算准确率,会把“预测出一个完整实体”和“预测出了零散几个字”混淆。评测代码很简单:
from seqeval.metrics import classification_report y_true = [['B-PER', 'I-PER', 'O'], ['O', 'B-LOC', 'I-LOC']] y_pred = [['B-PER', 'I-PER', 'O'], ['O', 'B-LOC', 'I-LOC']] print(classification_report(y_true, y_pred))输出会给出每个实体类型的precision、recall、f1,以及micro/macro平均。我一般只看实体级F1,因为token级准确率往往虚高,比如人名5个字预测对了3个,token准确率看起来不错,但实体级是0分。
如果你想把这个项目迁移到新的领域,比如从新闻迁移到法律文书,要做的事很简单:把标签集合换成你需要的实体类型,重新标注数据,然后继续用bert-base-chinese做初始化。如果新领域数据很少,低于两千条,建议冻结BERT层,只训练BiLSTM和CRF,让BERT参数不动,防止小数据上过拟合。代码上就是把model.bert参数的requires_grad全部设为False:
for param in model.bert.parameters(): param.requires_grad = False这样训练时会快很多,显存占用也低。如果数据量达到五千条以上,再解冻BERT整模型微调。另外一个实用技巧是长文本处理:法院文书经常超过500字,但BERT窗口有限,可以按句子拆分为多个样本,保持实体完整性。拆分时不能让实体从中间断开,所以拆分边界优先选择标点符号,拆分后还要检查实体的首尾标签是否残缺。
这套方案值不值得投入,我的答案是值得。中文命名实体识别在舆情分析、金融信息抽取、医疗结构化里仍然是刚需,BERT-BiLSTM-CRF虽然是三年前的标配,但它的模块化设计让我后来换到RoBERTa、换到领域预训练模型时,只需要改动数据加载和BERT加载部分,BiLSTM和CRF层基本不用动。我最深的教训是头一次跑这个项目时,没有验证标签对齐,结果看到训练loss稳定下降,心里还挺开心,第二天用测试集一跑全是O,那种心情就像推理半天的代码发现是黑匣子里的玄学。希望你从这篇笔记里能直接跳过那个通宵,少走我走过的弯路,让这套模型真正落到你的数据上。希望帮到你。
本文还有配套的精品资源,点击获取