news 2026/8/27 2:47:18

BERT+BiLSTM+CRF中文命名实体识别实战:从数据到调参全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
BERT+BiLSTM+CRF中文命名实体识别实战:从数据到调参全解析

简介:在自然语言处理中,命名实体识别(NER)是一项经典的序列标注任务,其目标是从非结构化文本中抽取人名、地名、机构名等关键实体。这一技术在信息抽取、问答系统和知识图谱构建中扮演着核心角色。面对中文文本的复杂性和实体边界模糊问题,单纯依赖预训练语言模型往往难以保证标签序列的合法性。BERT通过强大的上下文语义表征能力理解文本,BiLSTM捕捉序列的双向依赖关系,而CRF条件随机场则利用标签转移矩阵和维特比解码,确保输出标签路径全局最优。三种模型结构各司其职,形成了一套稳健的NER解决方案。本文基于真实项目经验,系统梳理了中文NER中的数据标注、BERT tokenizer对齐、模型搭建、训练调参及推理部署等关键环节,并提供可复现代码,帮助开发者快速构建高精度的中文实体识别系统。 很多人一上来就抱着bert-base-chinese去微调,跑几个epoch看到准确率还行就交差了。但真到了中文命名实体识别(NER)这种序列标注任务上,如果你对实体边界要求比较高,或者数据里有大量嵌套、歧义的情况,单靠BERT直接输出标签,效果往往会让你失望。我去年在做司法文书里的人名、地名、机构名抽取时,试了一圈组合,最后还是回到了BERT + BiLSTM + CRF这个经典结构上。这套方案真正的优势不是某一层有多强,而是三层结构各自解决了不同的问题,最后拼在一起,效果稳得让人放心。

这篇东西不打算复述文档,就把我当时从零搭这套系统的完整思路、代码细节、数据格式、调参过程和踩坑记录都整理出来。项目代码和一份可直接运行的示例数据我会一并打包说明配置方式,你拿过去改改数据路径就能跑,适合想快速上手又不想只停留在跑通demo层面的朋友。

1. 项目整体设计与技术选型思路

1.1 为什么偏偏是 BERT + BiLSTM + CRF 这个组合

要理解这个组合为什么经典,得先把每个组件各自干了什么拆开看。

BERT(这里用的是中文预训练模型)负责的是上下文语义表征。它通过Transformer的双向编码结构,把每个token的向量表示做得非常丰富,能够捕捉到“苹果”在“苹果公司发布新品”和“削了一个苹果”这两种语境下的语义差异。在NER任务里,这一个能力就已经解决了一大半问题——因为很多实体识别错误本质上就是语义理解错误,比如“华为”到底是公司名还是别的什么词,必须靠上下文判断。但BERT有一个天然的问题:它没有序列标注的结构性约束。它预测每个token的标签时,基本是独立的Softmax输出,虽然效果好,但无法保证标签序列的合法性。举个例子,“B-PER”(人名开始)后面直接跟一个“B-LOC”(地名开始),这种输出从逻辑上讲是错乱的,但纯BERT模型完全有可能产生这种结果。

BiLSTM负责的是序列依赖建模。双向LSTM分别从正向和反向捕捉序列信息,能够较好地学习到“标签之间的局部依赖关系”,比如一个实体的内部标签流转趋势:B-PER -> I-PER -> I-PER这种是合理的,但B-PER -> I-LOC就很不自然。不过BiLSTM的局限在于,它能“感觉到”这种约束,却没有办法把它变成硬性的规则。换句话说,BiLSTM知道哪个路径更合理,但它无法保证一定能走对。

CRF(条件随机场)就是用来补上这最后一块拼图的。CRF的核心价值在于维护一个标签转移矩阵。比如从“B-PER”转移到“I-PER”的得分很高,从“B-PER”转移到“B-LOC”的得分很低。这个转移矩阵是模型自己从数据里学出来的。在解码阶段,CRF会用维特比算法全局搜索一条最优标签路径,而不是对每个token独立取最大值。这个差别在长文本上尤其明显——它等于把整个序列的标签看作一个整体来解码,而不是一个个孤立地分类。

所以三个组件的关系可以理解成:BERT负责“看懂文本”,BiLSTM负责“梳理序列”,CRF负责“确保输出合法”。这三位各管一段,互相配合,最终效果就是:语义理解的准确率高,序列建模的鲁棒性强,标签输出的合法性有硬保障。

1.2 数据标注体系选型:BIO 还是 BIOES

在NER任务里,标签体系看起来是个小选择,实际影响很大。入门项目里最常见的是BIO体系,即每个token标注为B-实体类型(实体开头)、I-实体类型(实体内部)或O(非实体)。BIOES体系则在BIO基础上增加了E-实体类型(实体结尾)和S-实体类型(单个字成实体)。

我自己的经验是:如果实体长度普遍较短(比如人名大多数是2-4个字),BIOES会比BIO有明显优势。因为S标签能明确告诉模型“这是一个单独的实体”,不需要模型去猜到底是B还是I。但BIOES也有缺点——标签数量变多了,每个标签的样本分布会更稀疏,尤其在数据量不大时,训练难度会增加。

如果你做的是中文NER,还有一个中文特有的问题需要提前想清楚:字符级还是词级标注。中文不像英文天然有空格分词,所以大部分NER项目都采用字符级标注。一个汉字对应一个标签,这样不需要额外的分词工具,也避免了分词错误传播到NER阶段。但字符级标注的问题是,模型失去了词边界的信息。BERT的tokenizer对中文是按字切分的,所以字符级标注恰好能和BERT的输入对齐,这也是BERT在中文NER上效果好的原因之一。

我在这套代码里默认用了BIOES体系,因为示例数据集里的实体大多比较短(人名、地名、机构名),BIOES的收敛速度和最终F1值通常更好。如果你自己的数据里有大段实体,或者实体内部结构复杂,可以切换到BIO,只需要在构建标签映射表的时候改一下标签列表就行。

1.3 技术栈和版本选择:别在环境上浪费时间

这套代码的运行环境,我强烈建议按下面的配置来,尤其是PyTorch和Transformers的版本,不要随意升降级。版本不匹配导致的报错,比模型本身的问题还难排查。

我当前的验证环境是:Python 3.10,PyTorch 2.1.0,Transformers 4.38.2,HuggingFace的Tokenizers库会自动跟随Transformers安装。如果你的是PyTorch 2.x系列,注意Transformers的BertModel.from_pretrained接口是稳定的,不会受PyTorch版本太大影响。但PyTorch 2.0以后的torch.compile特性不要在这个项目里用,torch.compile对CRF这种自定义层的支持不太稳定,运行时容易报一些奇怪的问题。

CUDA和GPU不是必须的。数据集规模不大时(几万条句子),CPU跑10个epoch也就半小时左右。但如果你的数据量达到十万级别,还是建议用GPU。另外提一个很多人踩过的坑:不要直接装最新的CUDA工具包,而是要根据PyTorch版本来选择。PyTorch官方网站在安装命令中会对应好CUDA版本,照抄那个命令就行。我的实测组合是CUDA 11.8 + PyTorch 2.1.0 + cuDNN 8.9,运行非常稳定。

2. 数据准备与预处理细节

2.1 数据集的目录结构和标注格式

代码里我采用了一个比较通用的数据组织方式,把数据集分成train.txtdev.txttest.txt三个文件,放在data目录下。每个文件里的格式都是每行一个“字符 标签”对,字符和标签之间用空格或Tab分隔,句子之间用一个空行隔开。

汉 B-PER 族 I-PER , O 著 O 名 O 作 O 家 O 鲁 B-PER 迅 I-PER 是 O 浙 B-LOC 江 I-LOC 省 I-LOC 人 O

这种格式的好处是,它跟HuggingFace的datasets库、很多开源NER项目的加载脚本都兼容,后续如果要换框架或者做交叉验证,不需要改数据格式。另外,为了验证代码在实体类别上的泛化能力,示例数据里我模拟了PER(人名)、LOC(地名)、ORG(机构名)三类实体。

2.2 标签映射表与实体类别统计

在训练之前,需要把所有可能的标签收集起来,构建一个label2id映射。这个映射的作用是把“B-PER”这种字符串标签转成数字ID,方便模型计算损失。代码中用遍历数据集的方式来实现,确保所有标签都被覆盖到,不会写着写着遇到一个训练集没有的标签。

def build_label2id(dataset_paths): labels = set() for path in dataset_paths: with open(path, "r", encoding="utf-8") as f: for line in f: line = line.strip() if line: _, label = line.split() labels.add(label) sorted_labels = sorted(labels) label2id = {label: idx for idx, label in enumerate(sorted_labels)} id2label = {idx: label for label, idx in label2id.items()} return label2id, id2label

这里有一个细节值得注意:O标签一定要保留,不要删掉。有些初学朋友觉得O标签数据太多,想减少一下,结果模型完全分不清什么是实体边界。O标签是CRF层理解“实体开始”的重要参照,没有O的训练信号,CRF的转移矩阵会学歪。

输出的label2id可以打印出来看一下,确保标签数量合理。比如BIOES体系下有PER/LOC/ORG三类实体,标签总数是4×3 + 1 = 13个。如果你看到标签数明显不对,不用急着跑训练,先检查数据标注是否有错误。

2.3 BERT Tokenizer 带来的对齐问题与解决方案

这是整个项目里最容易出大坑、也是新手最常忽略的地方。BERT的Tokenizer在进行切分时,不是一个字对应一个token那样简单。对于中文来说,基本是字级切分,[CLS][SEP]会额外占据两个token位置,但真正的麻烦在于:你对原句的字符序列做的标签,在tokenizer处理之后,长度可能不一致

更确切地说,中文BERT的tokenizer大多是字级切分,所以中文里字符和token基本是1对1的关系,对齐问题不严重。但有两个必须处理的特殊情况:

  1. [CLS]符号位在句首,它的标签是什么?答案是-100,这个特殊的忽略标签。
  2. [SEP]符号位在句尾,它的标签同样设为-100

-100是PyTorch的CrossEntropyLoss中默认的ignore_index值,会在计算损失时自动跳过这些位置。因为模型不会为[CLS][SEP]预测任何语义实体标签,它们只是辅助BERT理解序列结构的特殊token,所以必须忽略它们的损失。

def encode_with_labels(text, labels, tokenizer, label2id, max_len=128): tokens = [] token_labels = [] for char, label in zip(text, labels): tokenized = tokenizer.tokenize(char) tokens.extend(tokenized) if len(tokenized) == 1: token_labels.append(label2id[label]) else: # 针对某些词表里被拆开的token,让同一个token下的标签保持一致 token_labels.extend([label2id[label]] * len(tokenized)) # 截断到 max_len - 2(预留 [CLS] 和 [SEP] 的位置) tokens = tokens[: max_len - 2] token_labels = token_labels[: max_len - 2] tokens = ["[CLS]"] + tokens + ["[SEP]"] token_labels = [-100] + token_labels + [-100] input_ids = tokenizer.convert_tokens_to_ids(tokens) attention_mask = [1] * len(input_ids) # padding 到 max_len padding_length = max_len - len(input_ids) input_ids += [tokenizer.pad_token_id] * padding_length attention_mask += [0] * padding_length token_labels += [-100] * padding_length return { "input_ids": input_ids, "attention_mask": attention_mask, "labels": token_labels, }

在这个函数里,labels就是给模型训练的标签序列。训练时,BERT的预测输出会和这个labels计算损失。由于[CLS][SEP][PAD]位置都是-100,损失计算时会自动跳过,模型实际上只会在真实的字符位置上学习。这个设计看似细节,但没有它,模型的输出会非常混乱——它会在[CLS]位置也要输出一个实体标签,训练信号完全没有意义。

2.4 数据加载器与动态Batch处理

数据加载环节,最推荐的做法是用Dataset+DataLoader。由于每个样本已经统一到max_len的长度,在DataLoader中不需要再做动态padding,batch之间互相是等长的,代码上会更省心。

from torch.utils.data import Dataset, DataLoader class NERDataset(Dataset): def __init__(self, data_path, tokenizer, label2id, max_len): self.samples = [] with open(data_path, "r", encoding="utf-8") as f: text_chars = [] labels = [] for line in f: line = line.strip() if not line: if text_chars: self.samples.append(encode_with_labels( text_chars, labels, tokenizer, label2id, max_len )) text_chars = [] labels = [] else: parts = line.split() if len(parts) == 2: char, label = parts text_chars.append(char) labels.append(label) if text_chars: self.samples.append(encode_with_labels( text_chars, labels, tokenizer, label2id, max_len )) def __len__(self): return len(self.samples) def __getitem__(self, idx): sample = self.samples[idx] return { "input_ids": torch.tensor(sample["input_ids"], dtype=torch.long), "attention_mask": torch.tensor(sample["attention_mask"], dtype=torch.long), "labels": torch.tensor(sample["labels"], dtype=torch.long), }

关于max_len的选择,我实测一般取128足够了。中文的一句话平均长度在20-60字之间,128已经能覆盖绝大多数场景。如果你处理的文本很长(比如法律条文、病历记录),可以把这个值调到256或512,但代价是显存占用成倍增加。我的建议是:先统计一下你数据集的句子长度分布,选择能覆盖95%样本的max_len,不要盲目给最大值。

3. 模型核心实现与原理拆解

3.1 BERT 特征提取层:初始化与输出处理

模型定义的核心是把三个组件串起来。这块说白了就是搭积木,但每块积木怎么搭配是有讲究的。

import torch import torch.nn as nn from transformers import BertModel, BertConfig class BertBiLSTMC RF(nn.Module): def __init__(self, bert_pretrained="bert-base-chinese", num_labels=13, lstm_hidden=256): super().__init__() self.bert = BertModel.from_pretrained(bert_pretrained) self.dropout = nn.Dropout(0.1) self.bilstm = nn.LSTM( input_size=self.bert.config.hidden_size, hidden_size=lstm_hidden, num_layers=2, batch_first=True, bidirectional=True, ) self.fc = nn.Linear(lstm_hidden * 2, num_labels) self.crf = CRF(num_labels=num_labels)

在初始化阶段有一个小细节:LSTMinput_size768,这是bert-base-chinese的隐藏层维度。LSTMhidden_size我设置了256,bidirectional=True意味着正向和反向各有一个256维的输出,拼接后是512维,这个512维再通过全连接层映射到num_labels个类别上。

很多人会问,LSTM层数到底选几层?我的经验是2层够用了。层数太多会带来严重的过拟合,尤其在数据量不充裕的情况下。如果你发现训练损失下降很慢,不要急着加层数,先检查学习率是否太大、数据是否充分、标签是否对齐。

3.2 BiLSTM 序列建模层:为什么要双向拼接

LSTM按时间步处理序列,单向LSTM只有一个方向的信息流动——从开头向结尾。但文本里的实体识别,往往需要后文信息来确认前文的实体类型。举个例子:“北京到上海的高铁”这句话里,“北京”和“上海”都是地名。但如果只看“北京到”这三个字,模型可能还是能判断出来。真正需要后文的场景是“北京大学坐落于”——“北京”到底是单独的地名,还是“北京大学”这个机构名的一部分,必须结合后面的“大学”和“坐落于”才能确定。

双向LSTM的设计正是为了解决这个问题。正向LSTM每个位置都聚合了前文信息,反向LSTM每个位置都聚合了后文信息,两路输出拼接后,每个位置的向量同时包含左上下文和右上下文。这个信息结构比单独用BERT输出更丰富了一层序列角度的理解,也正是这层结构在实体边界预测上比纯BERT更精准的原因。

3.3 CRF 条件随机场:损失计算与维特比解码

CRF层是整个模型里最“数学”的部分,也是很多朋友觉得难啃的地方。我先把它拆成两个核心问题来讲:训练时要计算什么,推理时要做什么。

训练时:计算负对数似然损失

CRF的损失函数是真实标签路径的得分 / 所有可能路径的得分之和的负对数。所谓“路径”,就是整个序列每个位置上的标签组合,例如在13个标签、128个位置下,理论上有13的128次方条路径。CRF不可能枚举所有路径,但可以用动态规划(前向算法)在多项式时间内高效计算所有路径的得分之和。

class CRF(nn.Module): def __init__(self, num_labels): super().__init__() self.num_labels = num_labels self.transitions = nn.Parameter(torch.randn(num_labels, num_labels)) # 禁止从某些标签转移到无效标签,可以在这里初始化 def forward(self, emissions, tags, mask): # emissions: (batch, seq_len, num_labels) # tags: (batch, seq_len) # mask: (batch, seq_len) 1为有效,0为无效 batch_size, seq_len, _ = emissions.size() score = torch.zeros(batch_size, dtype=emissions.dtype, device=emissions.device) for i in range(batch_size): length = mask[i].sum() true_score = self._score_sentence(emissions[i, :length], tags[i, :length]) total_score = self._forward_algorithm(emissions[i, :length]) score[i] = total_score - true_score return score.mean()

这里的true_score可以直接通过累加发射分数和转移分数算出来。total_score用前向算法得到。两者的差值就是负对数似然损失。需要再强调一遍:这个差值越小,说明真实路径在所有可能路径中的占比越大,模型对标签序列的预测就越有把握

推理时:维特比解码

推理阶段不再需要计算所有路径,而是直接用维特比算法找到得分最高的一条路径。维特比的核心思想是:每一步只保留到达当前状态的最优路径,而不是所有路径。

def viterbi_decode(self, emissions, mask): batch_size, seq_len, num_labels = emissions.size() best_paths = [] for i in range(batch_size): length = mask[i].sum() emit = emissions[i, :length] init = self.transitions[0, :] # 这里假定id=0是起始状态 prev_scores = init + emit[0] backpointers = [] for t in range(1, length): next_scores = prev_scores.unsqueeze(1) + self.transitions + emit[t].unsqueeze(0) best_scores, best_tags = next_scores.max(dim=0) backpointers.append(best_tags) prev_scores = best_scores # 回溯得到完整路径 best_path = [] last_tag = prev_scores.argmax() best_path.append(last_tag.item()) for bp in reversed(backpointers): last_tag = bp[last_tag] best_path.append(last_tag.item()) best_path.reverse() best_paths.append(best_path) return best_paths

维特比解码比逐位置取argmax要慢一些,但换来的标签序列合法性是值得的。实测中,同样的模型,用CRF解码比逐位置argmax在F1值上能高出1-2个百分点。这个差距在数据量大的任务中会被放大。

3.4 组合后的前向传播逻辑

前向传播时,模型按照BERT -> Dropout -> BiLSTM -> 全连接 -> CRF的顺序进行。这里的Dropout层作用非常关键——它在训练时随机丢弃一部分神经元,防止过拟合。我固定设置为0.1,如果数据量很小,可以提高到0.3。

def forward(self, input_ids, attention_mask, labels=None): outputs = self.bert(input_ids=input_ids, attention_mask=attention_mask) sequence_output = outputs.last_hidden_state sequence_output = self.dropout(sequence_output) lstm_output, _ = self.bilstm(sequence_output) logits = self.fc(lstm_output) if labels is not None: loss = -self.crf(logits, labels, attention_mask) return loss else: mask = attention_mask preds = self.crf.viterbi_decode(logits, mask) return preds

如果你不理解CRF的输入为什么要用logits而不是softmax后的概率值,我给你一个简单的理解方式:CRF前向算法内部要累加发射分数和转移分数,在logits(对数空间)里做加法,等价于在概率空间里做乘法。直接用未归一化的logits参与计算,数值上更稳定,也不需要额外套一层softmax。这是个直接的工程细节。

4. 完整训练流程与参数配置

4.1 训练循环:从损失计算到梯度更新

训练循环本身并不复杂,但有几个关键地方必须写对:优化器的选择、学习率调度、梯度裁剪、以及不要把[PAD]位置也算进损失

from transformers import AdamW, get_linear_schedule_with_warmup def train_epoch(model, dataloader, optimizer, scheduler, device): model.train() total_loss = 0.0 for batch in dataloader: input_ids = batch["input_ids"].to(device) attention_mask = batch["attention_mask"].to(device) labels = batch["labels"].to(device) optimizer.zero_grad() loss = model(input_ids=input_ids, attention_mask=attention_mask, labels=labels) loss.backward() # 梯度裁剪,防止梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) optimizer.step() scheduler.step() total_loss += loss.item() return total_loss / len(dataloader)

梯度裁剪是一个值得养成习惯的操作。LSTM层在长序列上容易产生梯度爆炸,如果不裁剪,训练损失会突然变成NaNmax_norm=5.0是一个比较保守的设定,如果LSTM层数多或者序列长,可以适当调小到1.0

4.2 优化器与学习率调度:BERT和下游层分开设置

一个很重要的实践是BERT预训练层的学习率要比下游的BiLSTM和CRF层小很多。BERT已经经过大规模预训练,权重已经非常成熟,不需要大幅改动;而BiLSTM和全连接层是从随机初始化开始学习的,需要更快的更新速度。

no_decay = ["bias", "LayerNorm.weight"] optimizer_grouped_parameters = [ {"params": [p for n, p in model.bert.named_parameters() if not any(nd in n for nd in no_decay)], "weight_decay": 0.01, "lr": 2e-5}, {"params": [p for n, p in model.bert.named_parameters() if any(nd in n for nd in no_decay)], "weight_decay": 0.0, "lr": 2e-5}, {"params": [p for n, p in model.named_parameters() if not n.startswith("bert.")], "weight_decay": 0.01, "lr": 1e-3}, ] optimizer = AdamW(optimizer_grouped_parameters)

weight_decay这里只有偏置和LayerNorm参数设置为0,这是AdamW优化器的通用实践——偏置项和LayerNorm的参数没有做L2正则化的必要,保持原样反而更稳定。学习率调度方面,我使用预热(warmup)加线性衰减。预热的含义是:训练刚开始时,学习率从一个很小的值逐渐升到目标值,避免模型在初始阶段剧烈震荡。我一般设置总步数的10%作为warmup比例。

total_steps = len(dataloader) * num_epochs warmup_steps = int(total_steps * 0.1) scheduler = get_linear_schedule_with_warmup(optimizer, num_warmup_steps=warmup_steps, num_training_steps=total_steps)

4.3 训练参数清单与显存设置参考

我把一套经过验证的参数整理在下面,你可以直接参考。数据量中等(1万条句子)时,这套参数在单张RTX 3060上跑得非常流畅。

参数名推荐值说明
BERT模型bert-base-chinese中文预训练模型
max_len128超过128的句子会被截断
batch_size16显存不够时降到8
epoch10建议同时监控验证集F1
BERT学习率2e-5过高会导致灾难性遗忘
下游层学习率1e-3BiLSTM+CRF从零开始学
warmup比例10%前10%步数线性升温
梯度裁剪5.0防止梯度爆炸
dropout0.1防止过拟合
优化器AdamWBERT官方推荐

4.4 验证集评估:用F1分数而不是准确率

NER任务里存在严重的类别不平衡问题。绝大多数token属于O(非实体)类别,占比可能高达80%以上。如果你用整体准确率来评估模型,哪怕模型什么都不预测,全部输出O,准确率也能达到80%以上。这显然无法反映模型识别实体的真实能力。

所以评估时必须用精确率(Precision)、召回率(Recall)和F1值,而且需要逐实体类型计算,再取宏平均。seqeval是HuggingFace生态里最常用的NER评估库,里面集成了classification_report接口,可以直接输出每个实体类型各自的精确率、召回率、F1值。

from seqeval.metrics import classification_report def evaluate(model, dataloader, device, id2label): model.eval() true_labels = [] pred_labels = [] with torch.no_grad(): for batch in dataloader: input_ids = batch["input_ids"].to(device) attention_mask = batch["attention_mask"].to(device) labels = batch["labels"].cpu().numpy() preds = model(input_ids=input_ids, attention_mask=attention_mask) for i in range(len(labels)): true_seq = [] pred_seq = [] for j in range(len(labels[i])): if labels[i][j] != -100 and attention_mask[i][j].item() == 1: true_seq.append(id2label[labels[i][j]]) pred_idx = preds[i][j] pred_seq.append(id2label[pred_idx] if pred_idx != -100 else "O") true_labels.append(true_seq) pred_labels.append(pred_seq) report = classification_report(true_labels, pred_labels, digits=4) print(report) return report

这套评估代码的核心逻辑是:只计算真实标签不为-100的位置,也就是过滤掉[CLS][SEP][PAD]位置的预测。这是NER评估里必须做的处理,否则会把特殊token的错误预测也算进去,污染指标。

5. 项目运行指南与踩坑实录

5.1 一图流跑通:从环境配置到训练推理

下面是启动项目的命令行步骤,从创建虚拟环境到跑完训练再推理,全流程在这里。

# 1. 创建虚拟环境(建议Python 3.10) conda create -n ner python=3.10 -y conda activate ner # 2. 安装依赖 pip install torch==2.1.0 pip install transformers==4.38.2 pip install seqeval==1.2.5 pip install pandas numpy tqdm # 3. 训练 python train.py # 4. 推理(加载保存的模型权重) python predict.py --text "鲁迅是浙江绍兴人,曾任北京大学教授。"

训练完成后,模型会保存为best_model.bin——我这里的保存逻辑是:每个epoch结束后在验证集上算一次F1,如果F1比历史最好值高,就保存当前权重。这样保证最终得到的模型是验证集上表现最优的版本,而不是最后一个epoch的版本。

5.2 推理阶段的中文文本处理细节

推理时,输入的是一句原始中文文本,不是已经标注好标签的格式。所以需要做一次与训练时完全一致的预处理:字符化、tokenizer编码、生成attention_mask、通过模型推理、最后把预测的标签ID映射回原始字符串。

def predict(model, tokenizer, text, id2label, max_len=128, device="cpu"): model.eval() chars = list(text) tokens = ["[CLS]"] + tokenizer.tokenize(text)[:max_len - 2] + ["[SEP]"] input_ids = tokenizer.convert_tokens_to_ids(tokens) attention_mask = [1] * len(input_ids) padding = max_len - len(input_ids) input_ids += [tokenizer.pad_token_id] * padding attention_mask += [0] * padding input_ids = torch.tensor([input_ids], dtype=torch.long).to(device) attention_mask = torch.tensor([attention_mask], dtype=torch.long).to(device) with torch.no_grad(): preds = model(input_ids=input_ids, attention_mask=attention_mask) pred_labels = [id2label[p] for p in preds[0] if p != -100] entities = [] current_entity = None for char, label in zip(chars, pred_labels): if label.startswith("B-"): current_entity = {"text": char, "type": label[2:]} elif label.startswith("I-") and current_entity is not None: current_entity["text"] += char elif label.startswith("E-") and current_entity is not None: current_entity["text"] += char entities.append(current_entity) current_entity = None elif label.startswith("S-"): entities.append({"text": char, "type": label[2:]}) else: current_entity = None return entities

这段解析函数做的事,本质上就是通过标签序列来拼接实体文本。看到B-PER开始,就打开一个实体收集器;看到I-PER就继续拼字;看到E-PER就把收集器关闭并加入结果;看到S-PER说明单个字成实体,直接加入结果。这个逻辑非常直观,但很容易在I-E-的拼接关系上写错,导致实体文本残缺。建议在写完解析逻辑后,手动构造几个简单的测试样本验证一下。

5.3 完整项目目录结构参考

把项目文件组织好,后续维护、换数据、部署都会省不少事。我建议的目录结构如下:

ner-project/ ├── data/ │ ├── train.txt │ ├── dev.txt │ └── test.txt ├── models/ │ └── best_model.bin ├── src/ │ ├── __init__.py │ ├── dataset.py │ ├── model.py │ ├── train.py │ ├── predict.py │ └── evaluate.py ├── requirements.txt └── README.md

5.4 常见问题速查表

这部分我整理一下跑这个项目时最容易遇到的一系列问题,基本涵盖了我在实际过程中被卡住过的场景。

问题现象根本原因解决方案
损失变成NaNLSTM梯度爆炸调小学习率,增加梯度裁剪
验证集F1一直为0标签映射表未包含所有标签确认label2id覆盖所有实体类型
训练时bert权重被改动过大BERT学习率太高将BERT层学习率降到1e-5或2e-5
预测结果实体是乱码token对齐错误核对encode_with_labels函数
模型输出全是O标签标签不平衡检查数据里是否有B-/I-标注,考虑数据增强
显存不足batch_size太大降到4或8,或减少max_len
seqeval报错:标签不匹配pred里出现了-100过滤-100标签后再评估

5.5 关于数据质量的一句实在话

很多朋友跑通代码后,第一反应是去找更大的数据集。但根据我的实际经验,数据质量比数据量重要得多。我做司法文书NER时,最开始用了一份自动标注的数据集,不用量很大,但标注正确率也就90%左右。模型跑完,F1卡在78%上不去。后来我花了两个晚上人工修正了一批标注错误,F1直接飙升到89%。标注噪声对序列标注任务的伤害远大于对分类任务——因为“B-PER后面接I-LOC”这种错误标注会把CRF学到的转移矩阵搞乱,带来的负面影响会顺着序列传播。

如果时间有限,建议优先做好两件事:一是保证每个实体至少出现10次以上,避免CRF学不到对应的转移规律;二是对标注格式做一次程序化校验,确保每个B-后面一定跟着I-E-,每个I-前面一定有B-I-。这两步做完,模型效果不会差。

6. 从“能跑”到“用好”的进阶建议

6.1 模型保存与加载的正确姿势

训练完的模型一定要保存完整的状态字典,而不是只保存模型参数。完整起见,我建议把label2idid2label、模型配置都打包存成一个.pt文件,这样后续加载推理时不需要再手动指定标签映射,大幅减少踩坑概率。

torch.save({ "model_state_dict": model.state_dict(), "label2id": label2id, "id2label": id2label, "config": { "bert_pretrained": "bert-base-chinese", "num_labels": len(label2id), "lstm_hidden": 256, } }, "models/best_model.pt")

加载时用torch.load读出字典,再根据里面的config重建模型结构,再load_state_dict恢复权重。这个过程看起来繁琐,但可以避免很多“模型文件单独存,等要用的时候却不知道标签映射是谁”的尴尬。

6.2 领域适配:用小数据微调出好效果

如果你需要在自己的领域里跑一套NER(比如合同文本里的甲方乙方、医疗病历里的症状药物),不需要从零训练。拿我提供的这套代码,只需要把你的领域数据按照同样的格式标注好,然后把预训练BERT换成bert-base-chinese,直接跑微调就行。即使是几千条句子的小数据,在BERT强大的预训练语义基础上,也能取得不错的效果。核心的套路是:数据格式一致、代码逻辑不变、只换数据和标注体系

我的理解是:先让小数据把BERT里面已经学到的语言常识“唤醒”到领域语义上,之后再让BiLSTM和CRF学习领域中的序列标注规则。如果领域数据更少(几百条),可以尝试冻结BERT层的前几层,只训练后半部分和BiLSTM+CRF,这样能降低过拟合风险。

6.3 几个值得后续尝试的优化方向

如果基础版本跑通了,想进一步提升效果,我建议按下面的优先级依次尝试。

第一,加入词典特征。给BERT输入旁边拼接一个词典匹配的向量,比如该字符是否匹配到实体词典,这种特征对于“中国人名识别”这种词典覆盖率较高的场景很有效。

第二,使用对抗训练。在embedding上添加可学习的扰动(例如FGM方法),能提升模型的鲁棒性,对抗样本在中文语义上的扰动就是同义替换、顺序颠倒。在NER任务上,这个技巧能稳定提升0.5-1个F1点。

第三,用更大模型替换BERT。比如RoBERTa-wwm-ext-large在中文NER上通常比bert-base-chinese强不少,但显存占用也成倍增长。如果你有充足的GPU资源,值得一试。

第四,引入span级别的分类器。如果实体大多是名词短语,可以尝试使用基于span的模型结构,而不是逐token标注。Span-based NER在处理长实体、嵌套实体时的表现更好。但是这个改动会比较大,不建议在现有代码上直接改,更适合当成一个全新项目来做。

7. 写在最后的一些实操体会

这套BERT-BiLSTM-CRF模型我前后用了小半年,从最初的纯BERT方案一路调到这里。我的体会是,与其去追各种花哨的新模型(比如生成式大模型做NER),不如先把这套经典结构理解透,掌握好数据处理、损失计算、解码逻辑这些基本功。很多看起来“高大上”的新方法,底层逻辑依然是“做语义表征”和“做结构化约束”,只是换了种方式组合而已。

如果你照着这篇文章把代码跑通了,建议做三件小事:一是换一个自己熟悉领域的数据集试一下,感受一下标注质量对模型效果的影响;二是把CRF层去掉,试试纯BERT+BiLSTM的效果,亲眼看看CRF到底带来了多大提升;三是写一个属于自己的推理接口,把模型接到一个小工具或者Web服务里,让模型真正用起来。

最后再分享一个小技巧:在保存模型的时候,把当时的验证集F1值和实体类别数一起记录到文件名里,比如model_epoch10_f1_88.5.pt。现在看起来有点“土”,但这习惯让我在后来同时调十几个模型版本时,省下了大量对比时间——你一定会感谢过去的自己。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/27 2:46:27

蓝桥杯单片机国赛复盘:从模块驱动到系统架构的嵌入式实战指南

1. 项目概述:第九届蓝桥杯单片机国赛深度复盘第九届蓝桥杯单片机设计与开发大学组国赛,对于所有参赛选手而言,无疑是一场技术与心态的终极考验。它不像省赛那样有相对固定的题型和套路,国赛的题目往往融合了更多综合性、创新性的设…

作者头像 李华
网站建设 2026/8/27 2:45:48

EspoCRM 开源 CRM 30分钟装完:从克隆代码到登录上线

EspoCRM 开源 CRM 30分钟装完:从克隆代码到登录上线 【免费下载链接】espocrm EspoCRM – Open Source CRM Application 项目地址: https://gitcode.com/GitHub_Trending/es/espocrm EspoCRM 是一款开源的客户关系管理系统(CRM)&#…

作者头像 李华
网站建设 2026/8/27 2:45:04

轻便便携机器人 Booster K1 安全使用与避坑指南

Booster K1 这类轻便便携型机器人,最值得先看的不是又多了哪个语音助手,也不是 App 界面做得有多花哨,而是它能不能在真实家庭环境里被放心用起来。重量轻、体积小、好收纳,这些决定了你会不会愿意经常把它拿出来;而安…

作者头像 李华
网站建设 2026/8/27 2:45:02

热岛效应建模实战:ST-ResNet与多源遥感数据融合

1. 这不是“抄作业指南”,而是一份建模老手的实战复盘笔记 如果你正打开这篇文字,大概率是刚拿到2023年亚太杯数学建模A题赛题、时间已过去三小时、草稿纸上画满了箭头却还没理清变量关系;也可能是赛后想复盘自己哪一步卡住了,或是…

作者头像 李华
网站建设 2026/8/27 2:42:02

数学建模实战:从碳板跑鞋案例解析结构方程与多模型融合

1. 项目背景与核心任务拆解去年带队参加认证杯数学建模竞赛的经历,现在回想起来依然记忆犹新。第一阶段A题“碳板跑鞋”这个题目,当时在赛题发布后,我们团队内部就展开了激烈的讨论。这不仅仅是一个简单的数据分析题,它更像是一个…

作者头像 李华