ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

BiLSTM-CRF命名实体识别完整Python项目实现与优化

BiLSTM-CRF命名实体识别完整Python项目实现与优化 简介面向计算机相关专业学生与开发者这是一套基于BiLSTM-CRF的命名实体识别完整项目源码聚焦序列标注任务可直接用于课程设计、期末大作业或毕业设计参考。项目源于大三期末大作业经导师指导并获99分高分评价代码完整可运行覆盖数据预处理、特征转换、模型训练、评估、推理与远程调用等模块对入门者同样友好。压缩包共89个文件约9.8MB以脚本源码为主辅以文本语料、配置文件、日志记录、启动脚本等目录层次清晰便于按需检索。资源内置微博、MSRA等中文命名实体识别数据集并带有说明文档、测试请求脚本与模型定义从数据增强到知识蒸馏均有对应代码学习者既能复现模型效果也能在此基础上拓展自己的实体抽取方案。目前已有97人学习下载适合用于理解命名实体识别原理与工程实践的参考资料。1. 为什么命名实体识别项目绕不开BiLSTM-CRF命名实体识别是信息抽取的第一步也是很多上下游任务的前置模块。项目标题里的“完整项目Python代码”指的就是覆盖数据预处理、模型训练、解码预测和评估的整套脚本而这套脚本最稳定的地基就是BiLSTM-CRF。从学术界的CoNLL公开评测到工业界的垂直领域自定义实体它始终是值得先行尝试的基线模型不是因为它最先进而是因为训练成本低、收敛快、可解释性强遇到问题时还能逐层检查中间输出。新手跟着完整代码跑通一遍就能理解NER任务的全部闭环——语料怎么进、标签怎么出、坏样本怎么找、上线后怎么排查。熟手拿到这套代码可以直接替换数据集和词向量来验证自己的标注方案这也是我通常建议的快速验证路径。2. 构建命名实体识别项目的语料处理与Python代码2.1 BIO与BIOES标注模式怎么选NER项目落地时第一个需要明确的是标注粒度。绝大多数标注语料采用BIO或BIOES两种模式之一。BIO把每个token标记为BBegin、IInside、OOutsideB表示实体起始I表示实体内部O表示非实体。BIOES在BIO之外增加EEnd和SSingle多字实体用E标记末尾单字实体用S标记。两种模式的实际差异体现在标签总数和边界约束强度上。从序列建模角度看BIOES提供的边界信息更细例如S可以明确告诉模型“这是一个单独成词的实体”而不必经过B和I的推断。但标签数量增加会带来数据稀疏问题尤其当某类实体样本本来就少时E和S标签的出现次数更低CRF转移矩阵中对应行的估计就不稳定。BIO模式虽然边界表达模糊但每个标签的样本更加集中训练更容易收敛。实际项目里我一般先用BIO跑一轮基线记录实体级F1后切换BIOES再跑一轮没有太多理论上的绝对优劣。下面这张表可以用来快速判断自己的场景对比维度BIOBIOES标签数量2n1n为实体类型数4n1单字实体表示用B或O隐式表达用S显式表达边界约束较弱B后跟I或B都合法强E后必须接O或B对标注噪声的敏感度较低较高适用场景中小规模语料、领域实体边界模糊大规模语料、边界规则明确标签方案一旦确定后续的代码逻辑就要在数据读取阶段锁定不能在训练到一半时临时更换否则词表和标签映射都要重新构建。2.2 语料清洗与CoNLL格式读取不管是CoNLL-2003还是中文MSRA最常见的存储格式都是每行一个token和对应标签句子之间用空行分隔。网上能下载到的公开数据经常混有全角英文字符、全角数字和不规范的空白字符这些噪音会直接影响词表大小和embedding的利用率。我的预处理第一步是统一做NFKC归一化import unicodedata def normalize_token(token: str) - str: return unicodedata.normalize(NFKC, token).strip()NFKC会把全角英文数字转成半角同时整理Unicode中常见的特殊空白字符。中文汉字不受影响序列长度也不会改变但词表能因此明显压缩OOV比例下降。这里有一个容易遗漏的点归一化必须在训练和预测两条链路中同时执行如果只清洗训练数据而预测阶段忘记调用线上的OOV会突然升高。读取标准标注文件时解析逻辑要能处理末尾没有空行的文件def parse_conll(file_path): sentences, labels [], [] cur_sent, cur_tags [], [] with open(file_path, r, encodingutf-8) as f: for line in f: line line.strip() if line : if cur_sent: sentences.append(cur_sent) labels.append(cur_tags) cur_sent, cur_tags [], [] else: parts line.split() if len(parts) 2: cur_sent.append(parts[0]) cur_tags.append(parts[1]) if cur_sent: sentences.append(cur_sent) labels.append(cur_tags) return sentences, labels这段解析代码并不复杂关键在于对空行的判断用strip()而不是直接比较 因为有些编辑器会在空行里保留空格。如果文件里存在若干行只有token没有标签当前代码会直接跳过该token这算一种容错处理。真实数据里这类残缺样本不少建议解析完成后统计一次句子数量和标签数是否匹配再进入下一步。2.3 词表、标签映射与padding掩码字符或词的ID映射是整个Python项目里复用率最高的部分from collections import Counter def build_word2id(sentences, min_freq2): counter Counter() for sent in sentences: counter.update(sent) word2id {PAD: 0, UNK: 1} for w, freq in counter.most_common(): if freq min_freq: word2id[w] len(word2id) return word2id def build_label_maps(labels): tag_set set() for seq in labels: tag_set.update(seq) label2id {tag: i for i, tag in enumerate(sorted(tag_set))} id2label {i: tag for tag, i in label2id.items()} return label2id, id2labelmin_freq设为2会把只在语料中出现一次的token视为UNK能有效控制词表大小并略微提升泛化。如果自己的数据集很小整体token量不足几万那么min_freq设成1更合适否则有效信息会被UNK吞掉。padding_idx固定为0这个约定在模型Embedding层里要显式声明PAD位置的向量才不会产生梯度。标签映射里尽量不要额外加入padding类别。正确做法是用一张与句子等长的mask数组标记有效位置padding位置在loss计算和解码时都会被屏蔽。mask的构造放在DataLoader的collate函数里def collate_fn(samples): samples.sort(keylambda s: len(s[0]), reverseTrue) max_len len(samples[0][0]) x, y [], [] for sent, tags in samples: x.append(sent [0] * (max_len - len(sent))) y.append(tags [0] * (max_len - len(tags))) mask torch.tensor( [[1.0 if i len(sent) else 0.0 for i in range(max_len)] for sent, _ in samples] ) return torch.tensor(x), torch.tensor(y), maskpadding时标签位置填0并不会影响训练因为score_sentence里的mask会把对应的转移分数和发射分数乘以0。但这里有个统计陷阱如果用argmax计算token级准确率而不排除padding位置那些全0的padding位置会被当成O标签从而虚高整体准确率。评估时务必用mask把padding区域去掉。3. 用Python实现BiLSTM-CRF模型训练与解码3.1 BiLSTM如何生成发射得分BiLSTM的输入是token的embedding序列前向LSTM从左往右编码后向LSTM从右往左编码每个位置最后的隐状态由两个方向拼接得到。这样一来在“张三去北京开会”这句话里模型判断“北京”时不仅看到左边的“去”还能看到右边的“开会”上下文信息比单向LSTM完整得多。PyTorch里只需要设置bidirectionalTrue但输出维度需要特别留意。LSTM的hidden_size如果设置为128并且双向开启最终输出的最后一维是256。为了让线性层的输入维度可控我习惯把单方向隐藏维度设为hidden_dim // 2这样拼接后正好等于hidden_dimimport torch import torch.nn as nn class BiLSTMEncoder(nn.Module): def __init__(self, vocab_size, embedding_dim, hidden_dim, num_tags, num_layers1, dropout0.5): super().__init__() self.embedding nn.Embedding(vocab_size, embedding_dim, padding_idx0) self.lstm nn.LSTM(embedding_dim, hidden_dim // 2, num_layersnum_layers, bidirectionalTrue, batch_firstTrue, dropoutdropout) self.dropout nn.Dropout(dropout) self.fc nn.Linear(hidden_dim, num_tags) def forward(self, x): emb self.dropout(self.embedding(x)) lstm_out, _ self.lstm(emb) emissions self.fc(self.dropout(lstm_out)) return emissions这段代码返回的emissions形状是(batch, seq_len, num_tags)每个标签位置对应一个未归一化的得分。这个得分只是“当前token在这个位置属于某个标签的可能性”它没有考虑标签与标签之间的依赖关系。如果直接在emissions上取argmax很容易出现O后面直接接I-PER这类不合法序列所以需要CRF层来做全局约束。3.2 CRF层的转移矩阵与路径得分CRF层维护一个transitions矩阵形状为(num_tags, num_tags)transitions[i][j]表示从标签i转移到标签j的得分。此外还有start和stop两个向量分别表示序列起始时进入某个标签的得分和从某个标签结束的得分。一条完整路径的总得分由发射得分、转移得分和起止得分相加得到。训练时需要计算正确路径得分和所有可能路径的对数和后者用前向算法做动态规划避免枚举指数级路径def forward_algorithm(emissions, mask, transitions, start_trans, stop_trans): batch_size, seq_len, num_tags emissions.shape scores emissions[:, 0] start_trans.unsqueeze(0) for t in range(1, seq_len): scores scores.unsqueeze(-1) # (batch, num_tags, 1) emit emissions[:, t].unsqueeze(1) # (batch, 1, num_tags) trans transitions.unsqueeze(0) # (1, num_tags, num_tags) new_scores torch.logsumexp(scores emit trans, dim1) scores torch.where(mask[:, t:t1], new_scores, scores) return scores stop_trans.unsqueeze(0)前向算法的核心是每步保留“到当前所有标签各条路径得分的logsumexp”。mask为0的padding位置不更新得分这样padding不会影响真实位置的累积结果。理解了这个函数CRF的预测解码就只需要把logsumexp改成max也就是维特比算法。3.3 完整模型类封装与超参数选择把BiLSTM、CRF和评分函数封装进一个nn.Module比较适合后续扩展和调试class BiLSTMCRF(nn.Module): def __init__(self, vocab_size, embedding_dim, hidden_dim, num_tags, device): super().__init__() self.hidden_dim hidden_dim self.num_tags num_tags self.device device self.embedding nn.Embedding(vocab_size, embedding_dim, padding_idx0) self.lstm nn.LSTM(embedding_dim, hidden_dim // 2, num_layers1, bidirectionalTrue, batch_firstTrue) self.dropout nn.Dropout(0.5) self.fc nn.Linear(hidden_dim, num_tags) self.transitions nn.Parameter(torch.randn(num_tags, num_tags)) self.start_trans nn.Parameter(torch.randn(num_tags)) self.stop_trans nn.Parameter(torch.randn(num_tags)) def get_emissions(self, x): emb self.dropout(self.embedding(x)) lstm_out, _ self.lstm(emb) return self.fc(self.dropout(lstm_out)) def score_sentence(self, emissions, tags, mask): batch_size, seq_len, _ emissions.shape score self.start_trans[tags[:, 0]] score emissions[torch.arange(batch_size), 0, tags[:, 0]] for t in range(1, seq_len): valid mask[:, t] score score self.transitions[tags[:, t-1], tags[:, t]] * valid score score emissions[torch.arange(batch_size), t, tags[:, t]] * valid last_idx mask.sum(dim1) - 1 last_tags tags.gather(1, last_idx.unsqueeze(1)).squeeze(1) score score self.stop_trans[last_tags] return scorestart_trans和stop_trans作为可训练参数随机初始化训练时会自动更新。score_sentence用于训练阶段计算真实路径得分padding部分通过mask置零。转移矩阵的初始值和embedding层一样不需要特殊处理PyTorch默认的随机初始化范围足够。模型搭建后的超参数一般先照着下面的经验值跑参数推荐值说明embedding_dim100或200随机初始化时从100开始即可hidden_dim128或256双向LSTM拼接后的总维度num_layers1或2中小数据集用1层更稳定dropout0.5太高容易欠拟合0.3到0.5之间调梯度裁剪5.0CRF训练的关键防护手段学习率0.001配合Adam优化器使用4. NER训练循环、损失函数与F1评估的完整Python代码4.1 负对数似然损失的计算BiLSTM-CRF的损失函数在数学上与条件随机场的负对数似然一致。训练要最大化真实标签路径在所有路径中的相对得分因此loss定义为所有路径的logsumexp减去真实路径得分def nll_loss(model, emissions, tags, mask): real_score model.score_sentence(emissions, tags, mask) log_norm model.forward_algorithm( emissions, mask, model.transitions, model.start_trans, model.stop_trans ) loss torch.mean(log_norm - real_score) return losslog_norm和real_score的差值越小说明正确路径的得分越突出。梯度回传时会从CRF层一路传到BiLSTM再传到embedding因此整个模型的参数都能得到有效更新。这里有个容易写错的位置score_sentence中的last_tags获取必须用mask.sum计算每个句子的真实长度而不是简单取tags[:, -1]否则padding位置的标签会错误参与stop_trans的计算。如果训练loss出现nan或不收敛优先检查这里。4.2 训练循环与关键超参数一个标准的训练循环如下它包含梯度裁剪和平均loss打印def train_epoch(model, dataloader, optimizer, clip5.0): model.train() total_loss 0.0 for x, tags, mask in dataloader: x, tags, mask x.to(model.device), tags.to(model.device), mask.to(model.device) optimizer.zero_grad() emissions model.get_emissions(x) loss nll_loss(model, emissions, tags, mask) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), clip) optimizer.step() total_loss loss.item() return total_loss / len(dataloader)梯度裁剪对CRF模型尤其重要。transitions矩阵的梯度由不同实体的样本共同积累偶尔会有个别维度过大不裁剪的话loss曲线会出现突然的尖峰。Adam优化器的初始学习率0.001在绝大多数场景下可以跑通如果你加载了预训练词向量建议降到0.0005避免对预训练表示造成过大扰动。dataloader的batch_size设置在16到32之间比较稳妥。数据量少时用32数据量大或句子特别长时降到16。句子长度差距悬殊时按长度排序的collate_fn能减少padding量训练速度提升明显。4.3 维特比解码与实体还原预测阶段的目标是在给定emissions和转移矩阵的条件下找到得分最高的标签路径。维特比算法和前向算法结构几乎一样只是把logsumexp替换成maxdef viterbi_decode(self, emissions, mask): batch_size, seq_len, num_tags emissions.shape viterbi emissions[:, 0] self.start_trans.unsqueeze(0) backpointers torch.zeros(batch_size, seq_len, num_tags, dtypetorch.long, deviceself.device) for t in range(1, seq_len): scores viterbi.unsqueeze(-1) self.transitions.unsqueeze(0) max_scores, best_tags scores.max(dim1) viterbi max_scores emissions[:, t] backpointers[:, t] best_tags viterbi torch.where(mask[:, t:t1], viterbi, scores[:, 0]) return viterbi, backpointersbackpointers保存每一步每个标签从哪个前驱标签转移而来解码时从序列末尾回溯就能得到完整路径。得到标签序列后还需要把连续的B-XXX和I-XXX合并成实体并记录实体类别和起始位置。合并逻辑的关键是I标签只能归属到类型相同的B标签def extract_entities(tags, tokens): entities [] cur_entity None for i, tag in enumerate(tags): if tag.startswith(B-): if cur_entity: entities.append(cur_entity) cur_entity {type: tag[2:], start: i, tokens: [tokens[i]]} elif tag.startswith(I-) and cur_entity and cur_entity[type] tag[2:]: cur_entity[tokens].append(tokens[i]) else: if cur_entity: entities.append(cur_entity) cur_entity None if cur_entity: entities.append(cur_entity) return entities这段代码在处理O后直接接I时不会产生实体因为在遇到I-PER但当前实体类型不匹配时会先结束上一个实体并清空cur_entity因此非法的I标签会被丢弃。生产环境中如果发现解码结果频繁出现孤立I说明CRF学到的转移约束还不够强可以考虑增加训练轮次或扩大训练数据。4.4 实体级F1评估脚本与早停判断NER任务通常使用实体级别的精确率、召回率和F1来评估token级准确率只能作为训练收敛的参考。实体级严格匹配要求预测实体和真实实体的类别、起始index、结束index完全一致这也符合大多数生产系统的需求。评估代码可以复用extract_entities对每个预测句和真实句分别生成实体列表再比对。计算指标时用如下口径指标计算方式关注点精确率预测正确的实体数 / 预测出的实体总数误报情况召回率预测正确的实体数 / 真实实体总数漏报情况F12 × P × R / (P R)综合平衡token准确率正确token数 / 有效token数仅作训练参考早停判断建议在验证集上计算实体F1连续3到5个epoch没有提升就回滚到最优模型。回滚时需要先保存每个epoch结束后的模型参数不要只保存最后一个epoch的checkpoint因为CRF训练过程允许先上升后小幅波动最后的模型未必是最好的那一个。5. 提升BiLSTM-CRF项目的三个关键技巧5.1 加载预训练词向量降低OOV影响随机初始化的embedding在数据量少的领域数据集上会频繁产生OOV实体边界容易在O和B之间抖动。常见做法是下载公开的中文词向量在构建word2id后按词表顺序初始化embedding权重。具体逻辑是加载向量文件建立word-vector字典遍历word2id能查到的token用预训练向量覆盖查不到的UNK和PAD保持零值。加载进来的向量在训练时可以选择冻结或微调我的经验是微调效果更好但学习率要降到0.0005左右。5.2 使用BERT嵌入并调整学习率数据量和GPU资源充足时可以用BERT编码替换原始词向量。最稳妥的接入点是取BERT最后一层的hidden state经过一个线性层降维后作为BiLSTM的输入。这样模型结构仍然保留BiLSTM-CRF的序列建模能力和CRF的全局约束同时获得更强的上下文语义表示。学习率策略与随机初始化差别很大BERT部分建议使用1e-5到3e-5下游BiLSTM-CRF部分可以使用5e-4。训练步数也可以减少因为BERT的表示能力会加速收敛。5.3 长文档重叠滑窗与首尾实体合并处理长文档时直接截断到max_len会把实体从中切断影响召回率。我一般使用重叠滑窗窗口长度设为256或384相邻窗口重叠32个token。预测时每个窗口独立解码然后做合并def merge_window_predictions(windowed_results, window_size, overlap): merged {} for pos, tag, score in windowed_results: # 重叠区域取置信度更高的预测 if pos not in merged or score merged[pos][1]: merged[pos] (tag, score) return merged合并规则需要注意两个点重叠区域的标签取置信度高的一侧但实体边界判断要以连续B和I序列为准同一个实体如果横跨两个窗口合并后不要因为重叠部分标签不一致而把实体一分为二。实际项目中可以在合并后对实体的连续性做一次校验若实体在重叠处分裂优先保留覆盖token更多的版本。这几个技巧里预训练词向量和重叠滑窗的收益最稳定BERT改造则适合作为二期优化。项目跑通后建议先记录当前baseline的实体级F1再逐一调整参数避免多个改动同时叠加导致无法判断哪个优化生效。本文还有配套的精品资源点击获取
返回列表