尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

BERT-BiLSTM-CRF中文命名实体识别项目实战与踩坑记录

BERT-BiLSTM-CRF中文命名实体识别项目实战与踩坑记录 简介命名实体识别是自然语言处理中的经典序列标注任务目标是从非结构化文本中自动抽取人名、地名、机构名乃至订单号、地址等关键信息。传统正则或词典方法在复杂表达面前往往规则爆炸且难以维护而基于深度学习的方案通过预训练语义表示、双向上下文编码与结构化标签约束的组合大幅提升了实体边界的识别准确率与泛化能力。BERT作为预训练语言模型提供了强大的字符级语义表征BiLSTM进一步压缩特征并捕捉局部序列关系CRF则显式学习相邻标签的转移约束三者结合成为当前中文NER性价比极高的技术方案。在实际工程中该模型可广泛应用于智能客服工单分类、信息抽取、知识图谱构建等场景帮助业务自动解析描述文本中的关键实体。本文完整记录了一次BERT-BiLSTM-CRF中文命名实体识别项目的搭建过程从环境配置、数据标注到模型调优的诸多细节均有涉及为希望上手中文NER的开发者提供了可复现的工程参考。 做智能客服工单分类的时候我碰到一个很头疼的问题用户的一大段描述里面“订单编号”“收货地址”“快递公司”这些关键实体总是混在自然语言里靠正则去匹配写了几十条规则还是漏规则一多又互相打架。后来转向命名实体识别NER这条路线才算是把抽取逻辑从“穷举模式”切到了“模型推断模式”。这也是我第一次完整跑通BERT-BiLSTM-CRF模型做中文命名实体识别项目自带python源码、项目说明和数据集非常适合想上手序列标注任务的NLP入门者。这篇文章就把我从选型到跑通再到调优踩坑的完整过程写出来给同样在中文NER门口徘徊的人一点参考。1. 为什么这个项目值得作为中文NER的入门必做清单之一我先说结论如果你只想用一个深度学习模型快速搞定中文实体抽取并且希望这个方案在教学、实验、小规模业务验证之间都能站稳脚跟BERT-BiLSTM-CRF是目前性价比最高的组合没有之一。1.1 命名实体识别究竟在解决什么问题命名实体识别是典型的序列标注任务给定一句中文文本模型要为每个字符打上一个标签告诉你这个字是实体的开始、实体的中间还是非实体。最常用的标签体系是BIO比如“我/在/北/京/上/班”这句话正确的标注是“O/O/B-LOC/I-LOC/O/O”其中B-LOC表示地名的第一个字“北”I-LOC表示地名的后续部分“京”。实体类型通常包括人名PER、地名LOC、机构名ORG、时间TIME等。实际业务里还可以自定义成“订单号”“产品型号”“金额”这个项目里用的正是这套通用的BIO标注规范你可以自行替换标签集。1.2 技术路线演进为什么不是纯规则也不是纯BERT早期的NER大量依赖规则和词典比如收集一份人名表、地名表去匹配。这种方式在小场景里有效但遇到同义词、歧义词、新词就抓瞎。后来出现了基于统计的HMM、CRF模型它们能利用上下文概率做推断但特征工程极其繁琐一个词的词性、前后词、是否在大词典里都要人工设计成特征模板。再后来是BiLSTM-CRF用LSTM自动抽取上下文特征CRF保证标签之间的约束关系一度是最强方案。BERT出现之后大家发现直接用BERT加CRF就能获得很不错的结果而BERT-BiLSTM-CRF则是把“预训练上下文表示”和“序列特征建模”“结构化标签约束”三件事同时做到位。1.3 BERT已经很能打了为什么还要留BiLSTM和CRF有人会问BERT的输出已经包含上下文语义直接经过Softmax分类不就行了么理论上可行但你试过之后会发现效果通常不是最优。CRF的存在价值在于标签之间的依赖约束。实体抽取不是每个字独立判断——比如“京”字前面如果是O那它几乎没有理由被标成I-PERB-LOC后面允许I-LOC跟随但B-PER后面突然跳一个I-ORG也是不合理的。Softmax分类会把每个位置当成独立事件完全不管这些“转移规则”CRF则显式学习一个转移概率矩阵把相邻标签的关系纳入整体打分。这就是为什么CRF在很多序列标注任务里仍然是标配。BiLSTM在BERT之后还保留主要有两个原因。一是BERT输出维度较大base模型是768维直接接线性层参数量可观BiLSTM可以把维度压缩到128或256并保留双向上下文信息二是BiLSTM在短文本上的局部序列建模能力对实体边界识别有实际帮助尤其是训练数据量不大的时候这种额外的结构约束能抑制过拟合。对一个开源项目来说把这三层串起来还有一个额外的好处——教学价值极高。预训练模型怎么接入、序列数据怎么按batch组织、结构化预测的损失函数怎么算全部在这个项目里体验一遍以后再去看别的NER模型比如基于Span的抽取模型、生成式的UniversalNER也能很快迁移理解。2. 环境准备与依赖版本跑通前最耗时的部分这个项目用的是经典Python深度学习技术栈环境本身不复杂但版本搭配的坑不少。我复现时用的是Python 3.8 PyTorch HuggingFace Transformers下面按我的实操经历来写。2.1 依赖清单与安装命令先给出标准的requirements.txt内容torch1.10.0 transformers4.20.0 datasets2.0.0 seqeval1.2.5 pytorch-crf0.7.2 pandas1.4.0 scikit-learn1.0.0 numpy1.21.0安装命令pip install -r requirements.txt安装完成后可以用一段简短代码验证关键依赖是否正常import torch from transformers import BertTokenizer, BertModel from torchcrf import CRF print(torch.__version__) bert BertModel.from_pretrained(bert-base-chinese) print(bert.config.hidden_size)能打印出版本号和768就说明环境基本OK。如果卡在from_pretrained这一步通常是网络下载模型权重的问题可以检查一下网络代理设置或者改用本地已下载好的模型目录来加载。2.2 我踩过的最离谱的版本坑第一个坑是transformers版本太新导致接口变动。比如新版本里BertModel.from_pretrained默认返回BaseModelOutput但你还是能通过.last_hidden_state拿到输出这个还好。真正容易出错的是tokenizer某些新版本不再默认返回token_type_ids而BERT输入有时需要三个tensor如果你直接按旧习惯用tokenizer(text)返回的token_type_ids参数可能会遇到KeyError。处理办法是显式传return_token_type_idsTrue。第二个坑是pytorch-crf和torchcrf这两个库的API差异。pytorch-crf的CRF类在定义时需要指定batch_firstTrue因为你的模型输出的emissions是(batch, seq_len, num_tags)而torchcrf默认按batch_firstFalse组织输入。两者混用会直接报维度错误而且报错信息不太直观我第一次遇到时查了半天才发现是batch_first的问题。第三个坑是CUDA版本和PyTorch匹配问题。如果你直接运行pip install torch装的是CPU版还是GPU版取决于当前环境的配置。建议先到PyTorch官网按自己的CUDA版本选择对应的安装命令否则GPU根本用不上后续训练会慢得怀疑人生。CPU训练小数据集能跑但BERT部分的耗时会让迭代周期变得非常长有条件还是用GPU。提示我建议用conda单独建一个虚拟环境来跑这个项目不要直接装在base环境里。transformers生态依赖更新很频繁今天装的版本过两周可能就变老版本了隔离环境能避免很多“昨天能跑今天报错”的连锁问题。2.3 模型权重的获取方式项目用到的中文预训练模型是bert-base-chinese权重需要从HuggingFace下载。下载完成后会自动缓存到本地目录下次加载会直接读缓存。如果你需要把项目部署到离线环境可以先把模型加载一次然后把缓存目录整体拷贝过去再用from_pretrained(本地路径)加载。3. 数据标注格式与标签映射代码里最容易出错的一环模型结构再漂亮数据没处理好也白搭。这个项目的数据集用的是最常见的中文NER标注文件格式每一行是一个字符加上它的标签中间用空格或制表符分隔空行表示一句话结束。3.1 数据集长什么样直接看一段train.txt的内容我 O 在 O 北 B-LOC 京 I-LOC 上 O 班 O 张 B-PER 三 I-PER 在 O 北 B-LOC 京 I-LOC每行第一列是字第二列是标签。句与句之间空一行。实体类型这里主要有PER人名、LOC地名、ORG机构名你可以根据项目说明在代码里扩展成自定义类型。3.2 BIO与BIOES两种标注规范怎么选BIO用B表示实体首字I表示实体延续O表示非实体。BIOES在BIO基础上增加了E实体结尾和S单字实体信息更完整但标签种类更多模型需要学的东西也更细。这个项目默认用的是BIO简单稳定在学术任务里BIOES在实体边界预测上有时候会更好尤其对单字实体的识别更友好。你可以把项目里涉及标注的地方改成BIOES再对比一下F1这是个不错的练手实验。3.3 数据加载与标签映射的代码逻辑项目中的load_data函数核心逻辑是逐行读取遇到空行就切分句子。一个典型实现如下def load_data(file_path): sentences, tags [], [] sent, tag_seq [], [] with open(file_path, encodingutf-8) as f: for line in f: line line.strip() if line : if sent: sentences.append(sent) tags.append(tag_seq) sent, tag_seq [], [] else: char, tag line.split() sent.append(char) tag_seq.append(tag) return sentences, tags这个函数看似简单但有三个细节需要注意。第一文件编码必须是UTF-8否则中文会乱码。第二split()不要写成split( )因为某些数据集里是用制表符分隔用split()可以自动处理空白字符。第三如果文件最后一段末尾没有空行需要在外层循环结束后再补一次append判断否则会丢最后一条数据。有了sentences和tags之后要构建标签到ID的映射label_list [] for tag_seq in tags: for tag in tag_seq: if tag not in label_list: label_list.append(tag) label2id {label: idx for idx, label in enumerate(label_list)} id2label {idx: label for label, idx in label2id.items()}注意这里我没有用set去重因为set的遍历顺序不固定会导致每次运行label2id的映射顺序都不一样。虽然模型最终能学出来但预测时id2label映射要跟着变极易出错。用list加in判断虽然慢一点但保证了顺序稳定。3.4 标签与BERT输入的对齐策略数据加载完之后需要把句子转成BERT的输入格式。做法是在句子开头加[CLS]、结尾加[SEP]同时给标签序列的开头和结尾各补一个O。比如原始句子“在北京”token序列变成“[CLS] 在 北 京 [SEP]”标签序列变成“O O B-LOC I-LOC O”。然后把所有序列补到固定长度max_len不足的位置padding为0标签补成O。这个padding操作里藏着一个很关键的细节padding位置的标签不能参与CRF损失计算否则模型会学着在空白位置输出O白白浪费学习能力。解决方案是通过attention_mask把padding位置标记为0CRF在计算时会自动跳过这些位置。提示如果你在后续自己的项目中改用BIOES或者自定义实体类型一定要同步修改label2id和id2label。实际调试中我发现最多的问题不是模型结构而是id2label和预测结果对不上——预测出来的数字明明没错映射回标签却发现实体乱成一团。4. BERT编码层与实体标签对齐的技术细节BERT是整条链路里最核心的表示层也是新手最容易产生“黑盒恐惧”的部分。我拆开几个关键点讲。4.1 为什么中文BERT按字切分也能有效建模bert-base-chinese的词表里有大量的中文字符也有少量的英文子词和特殊符号。文本送入BERT后每个中文字符基本对应一个token所以中文NER天然适合BERT的WordPiece切分方式。相比之下英文一个单词可能被切成几个subword标签对齐要分情况处理中文就没有这个烦恼。但不要因此掉以轻心如果文本里混有数字、英文人名、URL等tokenizer依然会把这些内容切碎或映射为[UNK]。比如“OpenAI”在中文BERT里可能被切成“Open”和“AI”也可能变成[UNK]这会破坏字符与token的一一对应关系。所以我在预测前会先检查tokenizer对特殊字符的处理结果必要时做文本替换或清洗。4.2 标签对齐的完整实现我建议在Dataset类中完成encode这样每个batch通过DataLoader加载时直接拿tensor。一个简化版实现如下class NERDataset(Dataset): def __init__(self, sentences, tags, tokenizer, label2id, max_len): self.sentences sentences self.tags tags self.tokenizer tokenizer self.label2id label2id self.max_len max_len def __len__(self): return len(self.sentences) def __getitem__(self, idx): sent self.sentences[idx] tag_seq self.tags[idx] tokens [[CLS]] list(sent) [[SEP]] tag_tokens [O] list(tag_seq) [O] input_ids self.tokenizer.convert_tokens_to_ids(tokens) tag_ids [self.label2id[t] for t in tag_tokens] # 截断 if len(input_ids) self.max_len: input_ids input_ids[:self.max_len] tag_ids tag_ids[:self.max_len] attention_mask [1] * len(input_ids) # padding pad_len self.max_len - len(input_ids) input_ids input_ids [self.tokenizer.pad_token_id] * pad_len tag_ids tag_ids [self.label2id[O]] * pad_len attention_mask attention_mask [0] * pad_len return { input_ids: torch.tensor(input_ids, dtypetorch.long), attention_mask: torch.tensor(attention_mask, dtypetorch.long), tag_ids: torch.tensor(tag_ids, dtypetorch.long), }这里我特意用了convert_tokens_to_ids而不是直接调用tokenizer(sent)因为手动控制token序列和标签序列的长度保持一致代码更直观调试也容易。如果你的数据里英文很多需要更严谨的subword对齐可以换用tokenizer(..., is_split_into_wordsTrue)再通过word_ids映射标签但中文为主的场景用不上。4.3 BERT微调的学习率配置BERT是预训练好的模型微调的时候学习率不能太大否则会把预训练学到的语言知识洗掉。一般BERT层用2e-5到5e-5下游随机初始化的层BiLSTM、线性层、CRF可以用1e-3级别。在PyTorch里按参数组设置不同的学习率optimizer AdamW([ {params: model.bert.parameters(), lr: 2e-5}, {params: model.lstm.parameters(), lr: 1e-3}, {params: model.fc.parameters(), lr: 1e-3}, {params: model.crf.parameters(), lr: 1e-3}, ])如果你把所有参数一股脑用同一个学习率要么BERT学得太猛导致过拟合要么下游层学得太慢迟迟不收敛。这个细节在很多教程里都一笔带过但实际对训练结果影响很大。5. BiLSTM与CRF的实现逻辑不止是拼装模型模型结构看起来就是BERT接BiLSTM接线性层接CRF但每一块在训练和推理时承担的角色不同代码实现上也有不少容易踩的坑。5.1 BiLSTM的作用把BERT表示压缩成序列特征BERT输出的序列隐藏状态维度是768BiLSTM在这里做了两件事以双向RNN的方式捕捉每个位置的前后文局部特征同时把隐藏维度压缩到你指定的尺寸。通常hidden_size设128或256双向之后线性层的输入维度就是2倍hidden_size。这里有个参数选择问题hidden_size越大模型容量越大但过拟合风险也越高。项目默认128比较稳妥数据量大的时候可以调到256试试。BiLSTM层数不必多一层双向就够再深收益很小训练还慢。5.2 CRF的转移矩阵与标签约束BiLSTM的输出经过线性层后得到一个形状为(batch, seq_len, num_tags)的发射分数emission score表示每个位置属于每个标签的得分注意这里不是概率。CRF层在这个基础之上再学习一个(num_tags2, num_tags2)的转移矩阵加2是因为要额外建模序列的起始和结束状态。转移矩阵里每一行表示“上一个标签是A时下一个标签是B的得分”。模型训练时会让“B-PER后面跟I-PER”的得分更高让“O后面直接跟I-PER”的得分更低从而学会标签之间的合法性约束。顺便说一句CRF并不是把“概率”直接相乘而是对所有可能的标签路径计算一个全局得分通过Softmax归一化得到路径概率。训练时最大化正确路径的概率也就是最小化负对数似然预测时用维特比算法在指数级路径中找出得分最高的那条。这两个过程涉及的数学有点本文还有配套的精品资源点击获取
返回列表