ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于Python实现双向LSTM+CRF命名实体识别模型:从原理到实践

基于Python实现双向LSTM+CRF命名实体识别模型:从原理到实践 简介这份资源是面向计算机、人工智能、自动化等专业学生与从业者的命名实体识别课程作业完整包对应NLP四大基础任务之一的序列标注问题得分96分。项目采用Python实现双向LSTM结合条件随机场CRF的模型架构在LSTM层后引入CRF以约束标签转移、避免出现连续B-LOC等不合理预测并借助交叉熵损失与梯度下降完成训练适合作为期末大作业、课程设计或毕业设计的参考范例。压缩包共3个文件包含1个py源码、1个pdf作业报告和1个md说明文档整体约201KB源码经本地调试可直接运行报告则完整记录了实验思路与结果分析。目前已有138人学习关注。读者可从中获得一套可复现的序列标注实现方案、模型搭建与调参思路以及规范的实验报告撰写参考基础较好的同学还能在此基础上修改调整以适配不同标注任务。1. 从一份课程作业说起双向 LSTM 加 CRF 到底在解决什么问题如果你正在搜“基于 Python 实现双向 LSTM 条件随机场 CRF 的命名实体识别模型”大概率是两种情况一是课程作业要求交一份能跑的 NER 源码加报告二是想找一个结构完整、能改能扩的序列标注基线。命名实体识别要干的事很朴素——把一句话里属于人名、地名、机构名、时间这些片段的字逐个打上标签。真正麻烦的地方在于标签之间不是独立的B-PER 后面大概率跟 I-PER绝不会突然跳到 I-LOC。双向 LSTM 负责看上下文CRF 负责约束标签转移两者拼起来就是过去几年工业界和教学里最经典的 NER 组合。这篇笔记按“数据怎么进、模型怎么搭、训练怎么稳、坑在哪”的顺序讲一遍目标是让你照着能复现而不是只抄一份看不懂的代码。2. 数据准备与标签体系BIO 标注为什么是绕不开的第一步2.1 从原始句子到 BIO 序列命名实体识别的输入是一句话输出是每个字对应的标签。最常见的标注体系是 BIOB 表示实体开头I 表示实体内部O 表示非实体。比如“张三在杭州工作”按字切分后标签是 B-PER、I-PER、O、B-LOC、I-LOC、O、O。这里有个容易被忽略的点中文 NER 通常按字切英文按词切因为中文分词本身会引入误差按字反而更稳。课程作业里常见的数据格式是每行“字 标签”句子之间用空行隔开这种格式和 CoNLL-2003 一致处理起来最省事。def read_data(path): 读取 CoNLL 格式数据返回句子列表和标签列表 sentences, labels [], [] with open(path, encodingutf-8) as f: sent, lab [], [] for line in f: line line.strip() if not line: # 空行代表句子结束 if sent: sentences.append(sent) labels.append(lab) sent, lab [], [] continue parts line.split() if len(parts) 2: continue sent.append(parts[0]) lab.append(parts[1]) if sent: # 处理文件末尾没有空行的情况 sentences.append(sent) labels.append(lab) return sentences, labels这段代码的关键在空行判断和文件末尾兜底。很多人第一次写会漏掉末尾那句if sent结果最后一句永远进不了数据集训练时样本数对不上还找不到原因。参数上没什么可调的但要注意编码统一用 utf-8Windows 下用记事本另存过的文件可能带 BOM读进来第一个字会多出\ufeff表现为标签对不上用encodingutf-8-sig能规避。2.2 构建词表和标签表模型不认识汉字只认识数字所以要把字映射成 id。词表一般保留两个特殊符号PAD用于补齐到统一长度UNK用于处理没见过的字。标签表则要把 BIO 标签固定顺序因为 CRF 的转移矩阵是按标签 id 索引的顺序一旦在训练和推理之间不一致结果会整体错乱。from collections import Counter def build_vocab(sentences, min_freq1): 统计字频构建字到 id 的映射 counter Counter(ch for sent in sentences for ch in sent) # 过滤低频字减少词表规模 chars [c for c, n in counter.items() if n min_freq] # PAD 必须放在 0后面 padding 对齐用 vocab {PAD: 0, UNK: 1} for c in chars: if c not in vocab: vocab[c] len(vocab) return vocab def build_label_map(labels): 标签固定顺序保证训练和推理一致 tag_set sorted({t for seq in labels for t in seq}) label2id {t: i for i, t in enumerate(tag_set)} id2label {i: t for t, i in label2id.items()} return label2id, id2labelmin_freq设成 1 表示所有字都进词表小数据集上这样召回更好数据量大时可以设 2 或 3把只出现一次的字归到UNK减少参数量。标签排序用sorted是为了可复现别用 set 直接转 list顺序在不同运行里可能变。这里有个血泪经验标签表一定要存下来推理时加载的是同一份否则模型输出的 id 对应的标签和你想的完全不是一回事。2.3 补齐与批次构造一个 batch 里句子长度不一必须补齐到该 batch 的最大长度。补齐用PAD的 id同时要生成 mask让模型和损失函数忽略这些位置。CRF 层对 padding 位置尤其敏感如果不传 mask转移分数会把补齐的假标签也算进去训练出来的模型在短句上表现会明显变差。import torch def pad_batch(batch, vocab, label2id, device): 把一个 batch 的句子和标签补齐成张量 max_len max(len(s) for s, _ in batch) x_list, y_list, mask_list [], [], [] for sent, tags in batch: ids [vocab.get(c, vocab[UNK]) for c in sent] tids [label2id[t] for t in tags] pad_len max_len - len(ids) # 标签 padding 用 -1后面 loss 里 ignore_index 会跳过 x_list.append(ids [vocab[PAD]] * pad_len) y_list.append(tids [-1] * pad_len) mask_list.append([1] * len(ids) [0] * pad_len) x torch.tensor(x_list, dtypetorch.long, devicedevice) y torch.tensor(y_list, dtypetorch.long, devicedevice) mask torch.tensor(mask_list, dtypetorch.bool, devicedevice) return x, y, mask标签 padding 用 -1 而不是 0是因为 0 可能是某个真实标签的 id用 -1 配合ignore_index-1才能干净地排除。mask 用 bool 类型PyTorch 里做索引和乘法都方便。batch size 在单卡上一般设 32 或 64句子特别长时降到 16显存不够优先降 batch 而不是砍句子长度因为截断会直接丢实体。3. 模型结构双向 LSTM 和 CRF 各自负责哪一段3.1 嵌入层与双向 LSTM 编码模型的第一层是嵌入层把字 id 变成稠密向量。嵌入维度常见 128 或 256太小表达不够太大在小数据集上容易过拟合。接着是双向 LSTM前向和后向各跑一遍把两个方向的隐状态拼起来这样每个字都能看到左右两边的上下文。单向 LSTM 在 NER 上会明显吃亏因为判断一个词是不是实体开头往往要看它后面的字。import torch.nn as nn class BiLSTMEncoder(nn.Module): def __init__(self, vocab_size, embed_dim, hidden_dim, num_layers1, dropout0.3): super().__init__() self.embed nn.Embedding(vocab_size, embed_dim, padding_idx0) self.lstm nn.LSTM( embed_dim, hidden_dim, num_layersnum_layers, bidirectionalTrue, batch_firstTrue, dropoutdropout if num_layers 1 else 0, ) self.dropout nn.Dropout(dropout) def forward(self, x, mask): emb self.dropout(self.embed(x)) # 把 padding 位置的输出清零避免影响后续 out, _ self.lstm(emb) out out * mask.unsqueeze(-1) return self.dropout(out)padding_idx0让嵌入层对PAD不更新梯度省参数也避免噪声。batch_firstTrue让输入形状是[batch, seq, dim]符合直觉。num_layers设 1 就够堆到 2 层在小数据集上收益很小还容易过拟合。dropout 放在嵌入后和 LSTM 后各一次0.3 是常用起点数据量小可以加到 0.5。3.2 从隐状态到发射分数双向 LSTM 的输出维度是hidden_dim * 2要经过一个线性层映射到标签数量得到每个位置的发射分数。这个分数表示“这个字是某个标签”的原始打分还没有考虑标签之间的转移关系。发射分数的形状是[batch, seq, num_tags]后面直接喂给 CRF。class EmissionLayer(nn.Module): def __init__(self, hidden_dim, num_tags): super().__init__() self.fc nn.Linear(hidden_dim * 2, num_tags) def forward(self, lstm_out): # 输出 [batch, seq, num_tags] return self.fc(lstm_out)线性层不需要加激活函数因为 CRF 内部会做 softmax 归一化中间加非线性反而破坏分数的可解释性。num_tags就是标签表大小包含 B、I、O 各种组合常见的中文 NER 有 7 到 13 个标签。3.3 CRF 层转移矩阵和维特比解码CRF 的核心是一个转移矩阵形状[num_tags, num_tags]trans[i][j]表示从标签 i 转到标签 j 的分数。训练时用前向算法算所有路径的归一化对数似然推理时用维特比算法找分数最高的路径。自己实现 CRF 是这份作业最有价值的部分因为调库会掩盖很多细节。class CRF(nn.Module): def __init__(self, num_tags): super().__init__() self.num_tags num_tags # 转移矩阵随机初始化 self.transitions nn.Parameter(torch.randn(num_tags, num_tags)) # 起始和结束的转移分数 self.start_trans nn.Parameter(torch.randn(num_tags)) self.end_trans nn.Parameter(torch.randn(num_tags)) def forward(self, emissions, tags, mask): 计算负对数似然训练用 # emissions: [batch, seq, num_tags] # tags: [batch, seq]padding 为 -1 batch, seq_len, _ emissions.shape # 把起始分数加到第一个位置 score self.start_trans[tags[:, 0]] emissions[:, 0].gather(1, tags[:, 0].unsqueeze(1)).squeeze(1) for t in range(1, seq_len): cur tags[:, t] prev tags[:, t - 1] # 只在有效位置累加转移分数 trans_score self.transitions[prev, cur] emit_score emissions[:, t].gather(1, cur.unsqueeze(1)).squeeze(1) score score (trans_score emit_score) * mask[:, t] # 加上结束分数 last_idx mask.sum(dim1) - 1 score score self.end_trans[tags.gather(1, last_idx.unsqueeze(1)).squeeze(1)] # 用前向算法算配分函数 log_z self._compute_log_z(emissions, mask) return (log_z - score).mean() def _compute_log_z(self, emissions, mask): 前向算法动态规划算所有路径分数之和 batch, seq_len, num_tags emissions.shape # 初始化起始分数 第一个位置的发射分数 alpha self.start_trans.unsqueeze(0) emissions[:, 0] for t in range(1, seq_len): # 广播相加[batch, num_tags, 1] [num_tags, num_tags] emit emissions[:, t].unsqueeze(2) trans self.transitions.unsqueeze(0) scores alpha.unsqueeze(1) trans emit # logsumexp 在标签维度上做 new_alpha torch.logsumexp(scores, dim1) # 用 mask 决定是否更新 m mask[:, t].unsqueeze(1) alpha torch.where(m, new_alpha, alpha) alpha alpha self.end_trans.unsqueeze(0) return torch.logsumexp(alpha, dim1)这段代码里torch.where配合 mask 是关键padding 位置不参与状态更新否则配分函数会被污染。logsumexp是数值稳定的求和方式直接 exp 再 log 在分数较大时会溢出。转移矩阵初始化用randn没问题训练会自己学但别初始化成全零那样梯度对称性破不开。3.4 维特比解码推理时不能简单地对每个位置取最大发射分数因为那样可能产生非法的标签序列比如 I-PER 出现在 B-PER 之前。维特比算法在标签转移约束下找全局最优路径是 CRF 推理的标准做法。torch.no_grad() def decode(self, emissions, mask): 维特比解码返回最优标签路径 batch, seq_len, num_tags emissions.shape # 初始化 score self.start_trans.unsqueeze(0) emissions[:, 0] # 记录每个位置每个标签的最优前驱 history [] for t in range(1, seq_len): emit emissions[:, t].unsqueeze(2) trans self.transitions.unsqueeze(0) # [batch, num_tags(prev), num_tags(cur)] scores score.unsqueeze(1) trans emit best_score, best_path scores.max(dim1) m mask[:, t].unsqueeze(1) # 无效位置保持原分数 score torch.where(m, best_score, score) history.append(best_path) # 加上结束分数选全局最优终点 score score self.end_trans.unsqueeze(0) best_last score.argmax(dim1) # 回溯路径 best_paths [best_last] for hist in reversed(history): best_last hist.gather(1, best_last.unsqueeze(1)).squeeze(1) best_paths.append(best_last) best_paths torch.stack(best_paths[::-1], dim1) return best_paths回溯时history存的是每个位置的最优前驱标签从最后一个位置的最优终点倒着推回去。mask 在解码里同样要处理无效位置不更新分数否则短句会被 padding 干扰。解码结果再通过id2label映射回 BIO 标签就能抽实体了。4. 训练、评估与调参让模型真正跑起来4.1 训练循环与损失函数损失函数直接用 CRF 返回的负对数似然不需要额外加交叉熵。优化器用 Adam学习率 1e-3 是常见起点配合梯度裁剪防止 LSTM 梯度爆炸。每个 epoch 后在验证集上算 F1保存最好的模型。from torch.optim import Adam from torch.nn.utils import clip_grad_norm_ def train(model, crf, loader, optimizer, device): model.train() total_loss 0 for x, y, mask in loader: optimizer.zero_grad() lstm_out model(x, mask) emissions model.emission(lstm_out) loss crf(emissions, y, mask) loss.backward() # 梯度裁剪防止 LSTM 梯度爆炸 clip_grad_norm_(list(model.parameters()) list(crf.parameters()), max_norm5.0) optimizer.step() total_loss loss.item() return total_loss / len(loader)max_norm5.0是经验值太小会拖慢收敛太大起不到保护作用。优化器要同时包含模型和 CRF 的参数别漏了 CRF否则转移矩阵永远不更新模型退化成纯 BiLSTM。学习率如果 loss 震荡明显降到 5e-4 再试。4.2 评估指标实体级 F1 而不是标签级准确率标签级准确率会骗人因为 O 标签占大多数全预测 O 也能有很高的准确率。真正要看的是实体级 F1即预测出的实体和真实实体完全匹配才算对。评估时要按实体边界切分B 开头到下一个非 I 结束。def extract_entities(tags): 从 BIO 标签序列抽取实体返回 (start, end, type) 列表 entities [] start, etype None, None for i, tag in enumerate(tags): if tag.startswith(B-): if start is not None: entities.append((start, i, etype)) start, etype i, tag[2:] elif tag.startswith(I-) and start is not None and tag[2:] etype: continue else: if start is not None: entities.append((start, i, etype)) start, etype None, None if start is not None: entities.append((start, len(tags), etype)) return entities def compute_f1(pred_tags, true_tags): 按实体集合算 precision、recall、F1 pred_set set(extract_entities(pred_tags)) true_set set(extract_entities(true_tags)) tp len(pred_set true_set) precision tp / len(pred_set) if pred_set else 0 recall tp / len(true_set) if true_set else 0 f1 2 * precision * recall / (precision recall) if precision recall else 0 return precision, recall, f1extract_entities里对 I 标签的判断要检查类型一致I-LOC跟在B-PER后面是非法序列应该断开。评估时把 padding 位置的标签去掉再算否则会多出假实体。F1 在验证集上连续几个 epoch 不涨就可以停了小数据集上一般 20 到 50 个 epoch 收敛。4.3 关键参数怎么设参数常用值影响embed_dim128 / 256太小欠拟合太大过拟合hidden_dim128 / 256双向拼接后翻倍显存主要消耗在这dropout0.3 / 0.5数据少往大调learning_rate1e-3 / 5e-4震荡就降batch_size32 / 64显存不够优先降这个max_norm5.0梯度裁剪阈值hidden_dim 设 256 时双向输出 512 维加上嵌入层单卡 8G 显存跑 batch 32 一般够用。如果 OOM先把 batch 降到 16再考虑降 hidden_dim。学习率用 Adam 默认的 1e-3 起步如果前几个 epoch loss 不降反升多半是学习率太大或者数据标签有问题。5. 避坑与排查那些让模型不收敛的细节5.1 现象loss 一直是 nan原因通常是学习率过大或者 CRF 的 logsumexp 数值溢出。先检查学习率是不是设成了 1e-2 以上再确认_compute_log_z里用的是logsumexp而不是先 exp 再 log。如果都没问题看看输入里有没有空句子空句子会让 seq_len 为 0动态规划直接崩。5.2 现象训练集 F1 很高验证集很低典型的过拟合。先加 dropout从 0.3 提到 0.5再减小 hidden_dim 或 embed_dim数据量实在小就考虑冻结嵌入层或者用预训练字向量初始化。还有一种可能是训练集和验证集分布差太多检查一下两个文件是不是同一批数据随机切的。5.3 现象预测结果全是 O先看标签分布如果训练数据里 O 占 95% 以上模型会倾向于全预测 O。解决办法是在 loss 里给非 O 标签加权或者对 O 标签下采样。另一个常见原因是标签表顺序在训练和推理时不一致导致解码出来的 id 映射错位检查label2id是不是同一份。5.4 现象实体边界总是差一个字BIO 标注里 B 和 I 的边界最容易出错。检查extract_entities里 I 标签的类型判断I-PER必须跟在B-PER或I-PER后面。如果标注数据本身边界就不统一模型学到的也是乱的这种要先清洗数据。CRF 的转移矩阵可以加约束比如禁止I-X转到B-Y但一般让模型自己学就够了。5.5 现象GPU 利用率低训练很慢多半是数据加载成了瓶颈。把DataLoader的num_workers设成 4 或 8pin_memoryTrue。另外检查是不是每个 batch 都重新建了 tensor那样 CPU 到 GPU 的拷贝会很频繁。句子长度差异大时按长度分桶能减少 padding 浪费提速明显。6. 进阶技巧用预训练向量和约束解码再提几个点模型跑通之后想再往上提 F1最划算的一步是用预训练字向量初始化嵌入层。中文可以用公开的字向量文件加载后把PAD和UNK对应的行保持随机或置零其余行按字表填入。这样在小数据集上通常能涨 2 到 5 个点因为预训练向量已经编码了字的语义相似性。def load_pretrained(embed_layer, vec_path, vocab): 用预训练字向量初始化嵌入层 import numpy as np vecs {} with open(vec_path, encodingutf-8) as f: for line in f: parts line.rstrip().split( ) if len(parts) 10: continue vecs[parts[0]] np.array([float(x) for x in parts[1:]]) dim embed_layer.weight.shape[1] hit 0 for ch, idx in vocab.items(): if ch in vecs and len(vecs[ch]) dim: embed_layer.weight.data[idx] torch.tensor(vecs[ch]) hit 1 print(f命中 {hit}/{len(vocab)}) return embed_layer加载后可以把嵌入层的学习率调低比如用两组参数分别设 lr嵌入层 1e-4其余 1e-3。另一个技巧是在维特比解码时加硬约束比如禁止O直接转到I-X这能消掉一部分非法序列。约束加在转移矩阵上把对应位置的分数设成一个很大的负数即可。def apply_constraints(crf, label2id): 禁止 O 直接转到 I-X 的非法转移 with torch.no_grad(): for tag, i in label2id.items(): for nxt, j in label2id.items(): if tag O and nxt.startswith(I-): crf.transitions.data[i, j] -1e4约束要在训练前或训练后统一加别在训练中途改否则转移矩阵会来回震荡。加完之后重新解码验证集看非法序列是不是少了。我自己的习惯是先把基线跑稳再逐项加改进每加一项都记录 F1 变化不然改到最后都不知道是哪个起了作用。这套 BiLSTMCRF 的结构虽然不算新但作为理解序列标注的入口非常扎实把每个环节都手写一遍后面换 Transformer 或者 BERT 做 NER 时你会清楚哪些东西是共通的。希望帮到你。本文还有配套的精品资源点击获取
返回列表