简介:这套基于Python的中文命名实体识别项目,面向有自然语言处理基础的学生、教师与企业开发者,解决中文人名、地名、机构名识别需求。项目完整实现了HMM、CRF、BiLSTM、BiLSTM+CRF等多种经典模型,涵盖B/M/E实体边界标记规则,附带已标注数据集及ALBERT预训练模型调用示例;同时提供Jupyter Notebook训练流程与Flask可视化交互页面,从数据加载、模型训练到性能评估形成完整实验链路,读者可直观查看实体标记效果与不同模型的对比结果。压缩包共28个文件,包括6个Python源码、3个Notebook、9个前端页面/样式脚本,还包含4份模型映射文件、3张性能对比图与1个演示动图,整体大小仅625KB,结构清晰、便于按models、notebooks、static等目录拆解学习;依赖清单与README降低复现门槛,训练与预测分离的设计也便于二次开发。目前已有90人学习下载,适合用于课程设计、毕业设计及中文NER入门到进阶的对照实践,也可作为课堂教学演示与项目立项初期的快速原型。
1. 中文命名实体识别:四种模型从统计到深度,先别急着跑代码
中文命名实体识别的任务,是把一段没有空格边界的汉字文本里抽出人名、地名、组织机构名等实体的边界和类别。标题里打包的HMM、CRF、BiLSTM、BiLSTM+CRF这四个模型,几乎就是序列标注从统计学习走向深度学习的一条完整演进路线。HMM和CRF靠人工定义特征和转移约束工作,BiLSTM用神经网络自动学习上下文表示,BiLSTM+CRF则把两者的优势合在一起。很多拿到代码的人第一反应是直接跑训练脚本,但实际项目里数据标注格式、字符粒度、标签分布对结果的影响往往比模型结构更大。下面的内容按一条可复现的中文NER管线展开,覆盖数据预处理、四类模型的实现与调参思路、以及实务里高频出现的踩坑场景,适合正在业务数据集上做对比基线的工程师,也适合入门序列标注的学生。
2. 中文NER数据集与预处理:从BIO标注到PyTorch训练集
2.1 数据集选型与标注体系:BIO和BIOES差在边界
做中文NER,第一步永远是数据。手上没有标注语料时,常用的公开数据集有这么几类:人民日报1998年标注语料(新闻领域,实体以人名、地名、机构名为主)、MSRA语料(微软亚洲研究院标注的新闻文本)、CLUENER2020(圈内知名度较高的细粒度中文实体评测数据,覆盖地址、书名、公司、游戏等10类实体)。这些数据集都有公开获取渠道,下载后第一件事是统计标签分布,而不是直接开训。
业务场景里直接迁移公开语料会有领域错位,这是比模型选择更值得警惕的事。新闻语料里的“华为”标成机构名没问题,在手机评测文本里却很可能指产品名,这种语义漂移不是靠换模型能解决的,必须用领域数据。常见的做法是先自标注一批种子数据,比如2000条左右,把标注规范定清楚,再结合模型辅助迭代扩充。
标注体系的选择直接影响模型上限。BIO把实体首字符标为B-XXX,实体内部标为I-XXX,非实体标为O;BIOES在BIO基础上增加E(实体尾)和S(单字实体)两种标签,所以也叫BIOE。BIO标签集合小,数据不稀疏,实现简单;BIOES多出的E和S给模型带来了显式的实体边界信息。实测结论是这样的:实体平均长度超过3个字符的数据集上,BIOES一般比BIO高1到2个F1点;单字实体为主的数据上两者差别不大,BIOES多出来的标签类别反而会引入转移矩阵噪声。我的建议是默认选BIOES,除非你明确在对比不同标准。
| 对比维度 | BIO | BIOES |
|---|---|---|
| 标签数量 | 基础三组 | 增加E和S |
| 实体边界 | 隐含在B/I变化中 | 显式标出E和S |
| 数据稀疏度 | 相对低 | 相对高 |
| 适用场景 | 快速跑通基线 | 多数正式实验推荐 |
2.2 字符级输入与词边界特征:中文NER的粒度选择
英文NER可以按空格和词级别建token,中文没有天然边界,很多人习惯先用分词器把句子切开再按词标注。这个做法有一个很实际的隐患:分词错误会被传导到NER。一个本来没分对词、实体边界也因此错位的样本,会直接干扰模型对真实边界的判断。所以中文NER的主流做法是字符级输入,每个汉字是一个token,一个句子变成一串字符序列。这样即使分词失败,模型也能独立学习到实体边界,代价是序列变长、计算量略增。
分词边界作为附加特征依然值得试试。实务里常见做法是:用分词器(比如jieba)给每个字打一个标记,词首标1、其余标0,把flag拼接到字符Embedding后面。这个组合在含大量多字地名的数据上,实测比纯字符好2个点左右。不过要提醒一句:分词器对未登录词的处理会影响flag质量,人名被切开、机构名被分错是常态,flag噪声过大的时候反而起反作用。建议的做法是先跑一版纯字符做基线,再拼上flag看收益,别一上来就上全套特征。
2.3 预处理代码解析:解析BIO文件、构建词典、写Dataset
先说文件解析。BIO和BIOES标注文件最常见的存储格式是“每行一个字符+空格+标签”,空行分隔句子。第一步要把这种文本结构解析成Python对象,我习惯写成下面这样:
def parse_bio_file(filepath): """解析BIO标注文件:每行一个字符+标签,空行分隔句子。 示例输入: 我 O 爱 B-LOC 北 I-LOC 京 I-LOC """ samples = [] chars, tags = [], [] with open(filepath, encoding="utf-8") as f: for line in f: line = line.strip() if not line: if chars: samples.append((chars, tags)) chars, tags = [], [] else: parts = line.split() if len(parts) >= 2: chars.append(parts[0]) tags.append(parts[1]) if chars: samples.append((chars, tags)) return samples这个函数返回一个列表,每个元素是一个二元组(chars, tags),chars是字符列表,tags是同长度的标签列表。三个细节值得注意:一是文件编码必须用utf-8,Windows下保存的gbk文件不转码会直接乱码;二是严格按空行做句子切分,文件结尾没有空行时也要把最后一个样本收进来;三是标签列可能混入多余空格或制表符,用split()而不是split(' ')能顺带处理。
然后是构建字符词典和标签词典:
def build_mappings(samples, min_char_freq=2): """构建字符表和标签表,低频字符统一映射到<UNK>""" char_counter = {} for chars, _ in samples: for ch in chars: char_counter[ch] = char_counter.get(ch, 0) + 1 char2idx = {"<PAD>": 0, "<UNK>": 1} for ch, freq in sorted(char_counter.items(), key=lambda x: x[1], reverse=True): if freq >= min_char_freq: char2idx[ch] = len(char2idx) tag2idx = {"O": 0} for _, tags in samples: for tag in tags: if tag not in tag2idx: tag2idx[tag] = len(tag2idx) return char2idx, tag2idx这里min_char_freq=2表示出现次数小于2的字符统一归类为<UNK>。阈值根据数据量定:小数据集设1或2,百万字符量级可以设5甚至更高,避免词典过大。tag2idx里把O固定在0,是为了配合后面padding的填充值。紧接着把样本转成索引序列,写好Dataset和collate_fn:
from torch.utils.data import Dataset import torch class NERDataset(Dataset): """字符和标签都转成索引,超过max_len的部分截断""" def __init__(self, samples, char2idx, tag2idx, max_len=128): self.data = [] for chars, tags in samples: char_ids = [char2idx.get(c, char2idx["<UNK>"]) for c in chars[:max_len]] tag_ids = [tag2idx[t] for t in tags[:max_len]] self.data.append((char_ids, tag_ids)) def __len__(self): return len(self.data) def __getitem__(self, idx): return self.data[idx]def collate_fn(batch): """把batch内的样本padding到统一长度,并返回mask和真实长度""" char_ids, tag_ids = zip(*batch) lengths = torch.tensor([len(c) for c in char_ids]) max_len = lengths.max().item() padded_chars = torch.zeros(len(batch), max_len, dtype=torch.long) padded_tags = torch.zeros(len(batch), max_len, dtype=torch.long) mask = torch.zeros(len(batch), max_len, dtype=torch.bool) for i, (c, t) in enumerate(zip(char_ids, tag_ids)): padded_chars[i, :len(c)] = torch.tensor(c) padded_tags[i, :len(t)] = torch.tensor(t) mask[i, :len(c)] = True return padded_chars, padded_tags, mask, lengthsmask是后面训练损失计算的关键:它决定哪些位置参与梯度计算,哪些是填充的无效数据。lengths则专门给BiLSTM里的pack_padded_sequence用。padded_tags里填充位置的值都是0,也就是O标签,但实际计算时会被mask过滤掉,不会污染梯度。
提示:如果哪天你把
tag2idx里的O换成了别的索引,collate_fn里的填充值也要同步调整,否则CRF的转移矩阵会学到奇怪的填充行为。
3. HMM与CRF实现:统计模型的参数估计、平滑与特征模板
3.1 HMM实现:用计数估计三组概率,用维特比解码
HMM是生成式模型,核心是把序列标注看作隐状态链(标签)和观测链(字符)的双层结构。实现当中最核心的是两个环节:参数估计和解码。参数估计用最大似然,直接数频次。为了避免训练集里没出现过的组合在测试时概率为0,我会加拉普拉斯平滑。
import numpy as np class HMMNER: """中文NER的HMM实现,标签为隐状态,字符为观测值""" def __init__(self, num_tags, num_chars, smoothing=0.01): self.num_tags = num_tags self.num_chars = num_chars self.smoothing = smoothing self.pi = np.zeros(num_tags) # 初始状态分布 self.A = np.zeros((num_tags, num_tags)) # 标签转移矩阵 self.B = np.zeros((num_tags, num_chars)) # 发射概率矩阵 def fit(self, char_ids_list, tag_ids_list): """统计训练数据频次,拉普拉斯平滑后归一化""" for char_ids, tag_ids in zip(char_ids_list, tag_ids_list): for i, tag in enumerate(tag_ids): if i == 0: self.pi[tag] += 1 else: self.A[tag_ids[i - 1], tag] += 1 self.B[tag, char_ids[i]] += 1 total_seqs = len(tag_ids_list) self.pi = (self.pi + self.smoothing) / ( total_seqs + self.smoothing * self.num_tags ) row_sum_A = self.A.sum(axis=1, keepdims=True) self.A = (self.A + self.smoothing) / ( row_sum_A + self.smoothing * self.num_tags ) row_sum_B = self.B.sum(axis=1, keepdims=True) self.B = (self.B + self.smoothing) / ( row_sum_B + self.smoothing * self.num_chars ) def decode(self, obs): """维特比算法,obs是字符索引序列,返回最优标签路径""" T = len(obs) log_pi = np.log(self.pi + 1e-12) log_A = np.log(self.A + 1e-12) log_B = np.log(self.B + 1e-12) dp = np.zeros((T, self.num_tags)) backpointer = np.zeros((T, self.num_tags), dtype=int) dp[0] = log_pi + log_B[:, obs[0]] for t in range(1, T): # scores[i, j] = 前一刻状态i的dp值 + i->j转移 + 当前字符在j上的发射 scores = dp[t - 1][:, None] + log_A + log_B[:, obs[t]][None, :] backpointer[t] = np.argmax(scores, axis=0) dp[t] = np.max(scores, axis=0) best_path = [int(np.argmax(dp[-1]))] for t in range(T - 1, 0, -1): best_path.insert(0, int(backpointer[t, best_path[0]])) return best_path用对数空间计算是刻意为之:连续概率相乘会快速下溢成0,log把乘法变加法可以避开这个问题。1e-12是兜底项,防止训练集未出现的“标签对+字符组合”在log后变成负无穷。
HMM的问题也很明显:一阶马尔可夫假设太强,当前标签只依赖上一个标签和当前字符,不能利用未来信息。人名里的“张伟”和“张明敏”对前一个字依赖很强,对后一个字的依赖同样重要,HMM在这种场景下经常出边界错误。工程层面还有一个局限:特征工程空间很窄,不能灵活加入分词、词典等外部信息。所以HMM在大规模中文NER任务上不是最优选择,但作为快速基线足够合适,尤其是训练数据只有几千条时,它往往比没调好的深度学习模型更稳。
我建议你把HMM跑通后记下验证集F1,这个数字就是后续模型对比的“地板”。很多人直接上BiLSTM+CRF,没有一个可比较的统计基线,出了问题都说不清是代码实现错了,还是数据上本来就只能到这么多分。
3.2 CRF实现:特征模板决定CRF的上限
CRF是判别式模型,直接对P(Y|X)建模,不再假设转移和发射独立。工程落地最常用的库是sklearn-crfsuite,它封装了python-crfsuite,提供scikit-learn风格的接口。核心工作在于写特征模板。
from sklearn_crfsuite import CRF def get_char_type(ch): """把字符归并成四个类别,降低特征稀疏度""" if '\u4e00' <= ch <= '\u9fff': return 'hanzi' if ch.isdigit(): return 'digit' if ch.isalpha(): return 'letter' return 'symbol' def word2features(sent, i): """为第i个字符构造特征模板,按需要增删""" ch = sent[i] features = { 'bias': 1.0, 'char': ch, 'char_type': get_char_type(ch), 'prev_char': sent[i - 1] if i > 0 else '__BOS__', 'next_char': sent[i + 1] if i < len(sent) - 1 else '__EOS__', 'prev_type': get_char_type(sent[i - 1]) if i > 0 else '__BOS__', 'next_type': get_char_type(sent[i + 1]) if i < len(sent) - 1 else '__EOS__', 'is_first': i == 0, 'is_last': i == len(sent) - 1, } return features def sent2features(chars): return [word2features(chars, i) for i in range(len(chars))]特征模板设计直接决定CRF的上限。char和char_type是基础特征,prev_char和next_char让模型看到前后字符。char_type这个特征对数字、英文、符号三类混排场景尤其重要,比如新闻文本里的“2024年GDP数据”,digit和letter类别的归并让模型能快速学到数字边界的置信度。bias: 1.0是偏置项,等价于线性模型的截距,必须保留。
组装训练数据和训练:
X_train = [sent2features(chars) for chars, _ in train_samples] y_train = [tags for _, tags in train_samples] X_test = [sent2features(chars) for chars, _ in test_samples] crf = CRF( algorithm='lbfgs', c1=0.1, # L1正则化系数 c2=0.01, # L2正则化系数 max_iterations=100, all_possible_transitions=True, ) crf.fit(X_train, y_train) y_pred = crf.predict(X_test)| 参数 | 取值建议 | 作用 |
|---|---|---|
| c1 | 0.1~0.5 | L1正则化,特征多时防过拟合,让权重稀疏 |
| c2 | 0.01~0.1 | L2正则化,控制参数整体幅度 |
| max_iterations | 100~200 | 训练迭代上界,LBFGS一般100轮内收敛 |
| all_possible_transitions | True | 学所有标签对转移,包括训练集没出现的组合 |
把all_possible_transitions设成True很重要。如果设成默认的False,CRF只学习训练数据中实际出现过的标签转移,测试集一旦出现新的相邻标签组合就直接预测错误。显式开启可以让模型学到“B-PER后面理论上不可能接I-ORG”这种约束。
CRF在中文NER上的优势是特征灵活、可解释性强,能轻松加入分词flag、词典命中、标点类别等特征;缺点也明显:特征全得靠人写,长距离依赖建模弱,序列长度上来后每条样本的特征计算开销不小。和HMM相比,CRF已经是能实战的统计模型了,在几千到几万条规模的数据上,它的表现经常超过没调好的BiLSTM。
4. BiLSTM与BiLSTM+CRF实现:PyTorch训练的核心代码
4.1 BiLSTM基线:双向上下文与pack_padded_sequence
BiLSTM把字符序列输入双向LSTM,用前向和反向隐状态拼接来编码上下文。对中文来说,字符前后信息缺一不可:实体边界需要前后文语义确认。下面是一个可以直接训练的基线模型:
import torch import torch.nn as nn from torch.nn.utils.rnn import pack_padded_sequence, pad_packed_sequence class BiLSTMNER(nn.Module): """BiLSTM基线模型:每个位置独立做softmax分类,不考虑标签转移""" def __init__(self, vocab_size, embed_size, hidden_size, num_tags, dropout=0.3): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_size, padding_idx=0) self.lstm = nn.LSTM( embed_size, hidden_size, bidirectional=True, batch_first=True, ) self.dropout = nn.Dropout(dropout) # 双向拼接,线性层输入维度是 hidden_size * 2 self.fc = nn.Linear(hidden_size * 2, num_tags) def forward(self, char_ids, lengths): emb = self.embedding(char_ids) # (batch, seq, embed) packed = pack_padded_sequence( emb, lengths.cpu(), batch_first=True, enforce_sorted=False ) lstm_out, _ = self.lstm(packed) out, _ = pad_packed_sequence(lstm_out, batch_first=True) logits = self.fc(self.dropout(out)) return logitspack_padded_sequence是这段代码的关键。batch内样本长度不一,padding部分如果直接进LSTM,会产生不携带真实信息的隐状态,而双向LSTM的反向传播会把这部分状态传回有效序列,破坏编码。packed序列机制只在有效长度范围内计算LSTM,pad_packed_sequence再把结果恢复成张量。enforce_sorted=False允许batch不按长度降序排列,省去外部排序步骤。
训练循环的写法有个细节:
criterion = nn.CrossEntropyLoss(reduction='none') def train_step(model, optimizer, char_ids, tags, mask, lengths): model.train() optimizer.zero_grad() logits = model(char_ids, lengths) # (batch, seq, num_tags) logits = logits.permute(0, 2, 1) # (batch, num_tags, seq) loss = criterion(logits, tags) # (batch, seq) loss = (loss * mask.float()).sum() / mask.float().sum() loss.backward() nn.utils.clip_grad_norm_(model.parameters(), 5.0) optimizer.step() return loss.item()用reduction='none''先得到每个token的loss,再用mask过滤padding位置,只对有效token取平均。这一步对BERT类模型同样适用,只是BERT用attention mask,机制完全一样。clip_grad_norm_设置5.0是常规操作,后面第5章的NaN排查会再提到它。
纯BiLSTM的预测就是logits.argmax(dim=-1)。它的结构性问题在于没有任何标签依赖约束。模型可以同时输出“B-PER”和“I-ORG”相邻的非法组合,实体边界经常被切出错位。数据规模大时,语义信息能部分掩盖这个缺陷,但它始终是结构短板。
4.2 接上CRF层:转移矩阵、序列得分与维特比
BiLSTM+CRF的做法是:BiLSTM担任特征抽取器,输出每个词位的发射分数,CRF层在上面学习标签转移矩阵,并用全局推理完成预测。实现里有几个细节值得仔细展开。
先看构建部分:
class BiLSTM_CRF(nn.Module): """BiLSTM输出发射分数,CRF层学习标签转移""" def __init__(self, vocab_size, embed_size, hidden_size, num_tags): super().__init__() self.num_tags = num_tags self.embedding = nn.Embedding(vocab_size, embed_size, padding_idx=0) self.lstm = nn.LSTM(embed_size, hidden_size, bidirectional=True, batch_first=True) self.fc = nn.Linear(hidden_size * 2, num_tags) # CRF参数:起始/终止转移,和标签间转移矩阵 self.start_trans = nn.Parameter(torch.randn(num_tags)) self.end_trans = nn.Parameter(torch.randn(num_tags)) self.transitions = nn.Parameter(torch.randn(num_tags, num_tags)) def _get_emissions(self, char_ids, lengths): emb = self.embedding(char_ids) packed = pack_padded_sequence( emb, lengths.cpu(), batch_first=True, enforce_sorted=False ) out, _ = self.lstm(packed) out, _ = pad_packed_sequence(out, batch_first=True) return self.fc(out) # (batch, seq, num_tags)转移矩阵的初始化建议用均匀分布小值,而不是randn。randn产生的数值可能在极端情况下让序列分数快速累计,触发训练数值问题,这在第5章会专门讲。
然后计算给定标签序列的得分:
def score_sequence(self, emissions, tags, mask): """计算给定标签序列的得分,分子部分""" batch_size, seq_len = tags.shape score = self.start_trans[tags[:, 0]] + emissions[ torch.arange(batch_size), 0, tags[:, 0] ] for t in range(1, seq_len): valid = mask[:, t] trans_score = self.transitions[tags[:, t - 1], tags[:, t]] emit_score = emissions[torch.arange(batch_size), t, tags[:, t]] score = score + valid.float() * (trans_score + emit_score) lengths = mask.sum(dim=1) last_idx = torch.clamp(lengths - 1, min=0) score = score + self.end_trans[tags[torch.arange(batch_size), last_idx]] return score理解这个函数的关键在于valid:对一个已经结束的句子,后面时刻的转移和发射贡献必须是0,所以用mask把它遮住。last_idx用于找到每条样本最后一个真实token的标签,把终止转移加进得分。这里对长度为1的序列依然成立,因为循环从t=1开始就不进入,终止转移会直接加到起始标签上。
接下来是损失里最重的部分——规范化因子的计算,用前向算法实现:
def log_sum_exp(vec, dim): """数值稳定的log sum exp实现,防止指数溢出""" m, _ = torch.max(vec, dim=dim, keepdim=True) return m.squeeze(dim) + torch.log(torch.sum(torch.exp(vec - m), dim=dim)) def forward_alg(self, emissions, mask): """前向算法计算所有可能标签路径的log_sum_exp""" batch_size, seq_len, num_tags = emissions.shape score = self.start_trans.unsqueeze(0) + emissions[:, 0] # (batch, num_tags) for t in range(1, seq_len): emit = emissions[:, t].unsqueeze(1) # (batch, 1, num_tags) trans = self.transitions.unsqueeze(0) # (1, num_tags, num_tags) next_score = log_sum_exp(score.unsqueeze(2) + trans + emit, dim=1) score = torch.where(mask[:, t].unsqueeze(1), next_score, score) return log_sum_exp(score + self.end_trans.unsqueeze(0), dim=1)这个动态规划的思路需要耐心捋一遍。score.unsqueeze(2)形状是(batch, num_tags, 1),trans是(1, num_tags, num_tags),emit是(batch, 1, num_tags),三者加和得到(batch, num_tags, num_tags),其中元素(i, j)的含义是“上一时刻状态i的累计分数 + 从i转移到j的分数 + 当前时刻发射到j的分数”。log_sum_exp(dim=1)对上一时刻的状态维求和,得到当前时刻在每个状态j上的累计分数。torch.where则保证已结束序列的分数不被后续填充位置改动。
把分子分母合起来就是负对数似然损失:
def ner_loss(self, emissions, tags, mask): """负对数似然损失 = log Z - 正确路径得分""" real_score = self.score_sequence(emissions, tags, mask) # 正确路径得分 log_norm = self.forward_alg(emissions, mask) # 所有路径log和 return -(real_score - log_norm).mean()推理时用维特比算法,逻辑和forward_alg对称,只是把log_sum_exp换成max和argmax:
def viterbi_decode(self, emissions, mask): """维特比解码:返回batch中每条样本的最优标签序列""" batch_size, seq_len, num_tags = emissions.shape results = [] for b in range(batch_size): length = int(mask[b].sum().item()) v = self.start_trans + emissions[b, 0] # (num_tags,) backpointers = [] for t in range(1, length): # scores[i, j] = v[i] + trans[i, j] + emit[j] scores = v.unsqueeze(1) + self.transitions + emissions[b, t].unsqueeze(0) best_tags = scores.argmax(dim=0) # 对每个当前标签j,最优前一标签i v = scores.max(dim=0).values backpointers.append(best_tags) last_tag = (v + self.end_trans).argmax().item() path = [last_tag] for bt in reversed(backpointers): path.insert(0, int(bt[path[0]])) results.append(path) return results回溯时bt[path[0]]的含义是“当前标签对应的最优前一标签”,沿着这个指针从序列尾部一路回溯到头部。标签种类不超过20个时,这个Python循环完全够用;追求吞吐率再改成张量批量实现,但可读性会付出代价。
5. 中文NER模型避坑:五个高频翻车场景与排查
5.1 模型训练正常但预测几乎全是O
现象:训练loss收敛到0.1以下,验证集上实体召回率却远低于精确率,大量真实实体被模型判成O。
原因:中文NER的标注不均衡是天然的。新闻语料里O标签通常占字符总数80%以上,模型用交叉熵优化时,把token预测成O的loss惩罚远低于预测成实体标签的惩罚,模型自然倾向于把所有位置都倾向到O。另一种常见情况是实体标签总量太少,某些类别只有几十个正样本,模型没学到足够的判别特征。
解决:按三个步骤来。第一步,用classification_report按标签查看F1,确认是全部实体差还是个别类别差。第二步,给损失函数按标签频次加权,实体标签权重设1.5到2.0,O标签保持1.0。第三步,用验证集实体级F1作为早停依据,不要盯着loss。如果实体总数确实少于200个,优先补数据,调参只能小幅改善。
5.2 训练时F1不错,但预测的实体边界总差一个字符
现象:模型把“北京南站”识别成“北京南”,把“上海市浦东新区”识别成“上海浦东新区”,边界不是多一个字符就是少一个字符。
原因:这种误差绝大多数是训练数据标注不一致造成的。同一批语料可能出自不同标注人员,对“地名中要不要包含市、站这类泛称后缀”的判断不统一。尤其BIOES体系下,如果E标签位置标错,模型学到的实体结束边界就是模糊的。
解决:把训练数据里所有实体片段抽取出来,按实体类型聚类,人工检查标注分歧。规范定清楚后写脚本统一标注。这里有一个血泪经验:如果数据里同时混用BIO和BIOES,必须先把标准统一。BIO转BIOES的规则是连续的B-I-I末尾I改E、单个B改S;反方向是E改I、S改B。转换逻辑看着简单,但经常手滑,转换前后各跑一次实体边界统计做校验,能省下大半天排查时间。
5.3 BiLSTM+CRF训练出现NaN,或验证分数反复震荡
现象:训练到几十个step后loss变成NaN,断点恢复后同一批数据又正常。另一些情况loss正常下降,验证F1却在0.2到0.8之间来回跳。
原因:NaN绝大部分出在log_sum_exp的exp计算溢出。exp输入数值过大直接变inf,再取log就变NaN。另一个常见来源是转移矩阵初始值过大,序列分数快速累计后溢出。loss震荡一般和学习率偏大、batch太小、梯度范数失控有关。
解决:log_sum_exp必须用“先减最大值再exp”的写法,这是数值计算的固定操作。转移矩阵用nn.Parameter(torch.empty(num_tags, num_tags).uniform_(-0.1, 0.1))初始化,比randn安全得多。梯度裁剪clip_grad_norm_(model.parameters(), 5.0)加在backward之后、optimizer.step之前。这三个细节配好后,新数据集上只需要调整学习率和训练轮次,不用靠玄学调参。
5.4 测试集评分很高,真实业务文本上效果暴跌
现象:留出测试集上F1有90+,一拿业务方给的真实文本就跌到60多分,连基础的人名抽取都频频出错。
原因:领域漂移是主因。测试集和训练集同分布,真实业务文本的领域、风格、实体类型都可能不同。还有一个容易被忽视的问题:文本噪声。业务系统里的句子常带网页标签、表情符号、全角半角标点混排,直接塞进NER模型会污染字符分布。
解决:把文本清洗做进数据管线,滤掉无意义的控制字符和网页标签,全角字符统一转半角。模型层面,如果领域偏移严重,最可靠的办法是拿少量领域数据做增量训练。想快速落地就用BERT类预训练模型替换LSTM再微调。沿用BiLSTM+CRF的话,一定记得把领域语料的高频字符补进词表,这一步最容易忽略,但影响最大。
5.5 CRF层训练推理太慢,batch越大速度反而下降
现象:同样数据量,纯BiLSTM一个epoch只要几分钟,BiLSTM+CRF要十几分钟。调大batch_size后每step耗时陡增,不是线性增长。
原因:CRF的前向算法和维特比都是时间步上的串行动态规划,时间步之间不能并行。batch的并行只体现在同一时间步内的矩阵运算,时间维度的串行开销无法被大batch摊薄。标签类别变多时,num_tags²的复杂度会迅速占据主导。
解决:工程上几个手段按性价比排序。用梯度累积替代大batch,等效batch不变,显存占用和每step耗时都更低。推理时限制输入长度,长文本切片预测后再做实体合并。给转移矩阵加约束,把不合法的转移初始化为-10000并冻结,比如B-PER后面接I-ORG,既减少搜索空间又让模型更好收敛。如果业务延迟要求极高,可以只训练BiLSTM,推理用维特比加规则约束做后处理,F1大约损失0.5个点,吞吐能提升好几倍。
6. 四模型对比的三个验证维度:别只看F1分数
四个模型全部跑完训练和评估之后,我建议你做的第一件事不是比F1,而是拉三个方向的验证。
第一是实体级F1的分类型统计。把预测结果按实体类型拆开,HMM通常在人名这类单字实体上得分高,在地名这类多字实体上得分低;BiLSTM+CRF则相对均匀。哪个类型明显偏差,就人工去原始文本里翻那个类型的30条样本,大概率能在标注规范里找到系统性错误。这个动作比调参有意义得多。
第二是从错误中区分边界偏移和类型错误。写个脚本把预测实体和标准实体做对齐,统计三类错误:完全错检、边界偏移、类型错分。边界偏移占大头,说明模型对边界的建模基本没问题,问题在数据标注不一致;类型错分占大头,则需要调整标签权重或增强特征抽取。
第三是训练和推理代价的实测。同样数据量下,CRF在小规模数据上的训练速度很快,效果甚至不输参数没调好的小BiLSTM;数据量上来以后,BiLSTM+CRF的收益才明显体现。时间上不能只看训练耗时,还要实测CPU上的单条推理延迟。
我现在的固定套路是:先用HMM跑一个快速基线拿到地板分数,再用CRF检验特征工程的效果,最后在BiLSTM和BiLSTM+CRF之间做精度和吞吐的取舍。数据不足一万条时直接选CRF,数据达到数万条且标签体系完整时,BiLSTM+CRF是性价比最稳的选择。
另一个值得投入时间的点是推理后的业务规则层。CRF保证的是标签序列合法,不能保证实体符合业务口径。医疗文本里“阿司匹林”和“阿司匹林肠溶片”都可能是合法药品实体,但业务上可能需要做归一化或合并。我的做法是让规则后处理参与仲裁,规则优先级高于模型输出,这也是很多线上系统的最终形态。希望帮到你。
本文还有配套的精品资源,点击获取