尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

基于双向LSTM+CRF的命名实体识别模型实战:从课程作业到工程落地

发布时间:2026/9/23 20:39:03

资讯中心
01
ARTICLE

基于双向LSTM+CRF的命名实体识别模型实战:从课程作业到工程落地

基于双向LSTM+CRF的命名实体识别模型实战:从课程作业到工程落地
简介这份资源是面向计算机、人工智能、自动化等专业学生与从业者的命名实体识别课程作业完整包对应 NLP 四大基础任务之一的序列标注任务采用双向 LSTM 结合条件随机场 CRF 的经典方案在 LSTM 层后引入 CRF 自动学习转移约束避免出现连续 B-LOC 等不合理标签可作为期末大作业、课程设计或毕业设计的参考实现。压缩包共 3 个文件约 201KB包含一份 Python 源码、一份 PDF 作业报告和一份 Markdown 说明文档分别对应模型实现、实验分析与运行指引结构精简便于快速上手。目前已有 138 人学习关注。读者可从中获得完整的序列标注建模流程、BiLSTMCRF 的网络搭建与训练细节、交叉熵损失与梯度下降调参思路以及一份得分 96 的高分报告作为写作与实验对照适合小白学习进阶也便于在此基础上修改调整以适配不同标注场景。1. 从一份课程作业说起双向LSTMCRF到底在解决什么问题如果你手头正躺着一份「基于Python实现双向LSTM条件随机场CRF的命名实体识别模型源码报告」的课程作业压缩包或者你正准备动手做这么一个东西交差、复现、改造成自己的项目那这篇笔记就是写给你的。命名实体识别NER要干的事很朴素从一段文本里把「人名、地名、机构名、时间、专有名词」这些有意义的片段抠出来并打上标签。听起来像字符串匹配但真做起来你会发现同一个词在不同句子里可能是人名也可能是地名边界还经常切错——这就是为什么大家最后都绕不开「双向LSTM CRF」这套组合。这套结构在深度学习NER里算是经典中的经典双向LSTM负责看上下文把每个字/词的左右信息都编码进来CRF负责在输出层约束标签之间的转移合法性比如「B-PER」后面不该直接跟「I-LOC」。单用LSTM标签序列容易出现「B-PER I-PER I-PER」中间突然蹦出个「O」这种玄学断裂加上CRF解码时会全局找一条最优路径序列一致性明显好转。它适合谁适合已经会点Python、想搞明白序列标注到底怎么落地的人也适合拿它当课程作业、毕设、入门NLP工程的第一块敲门砖。下面我按「数据怎么进、模型怎么搭、训练怎么调、坑怎么躲」的顺序把这份作业里最该讲清楚的东西拆开说。2. 数据准备与标注格式把原始语料喂进双向LSTMCRF之前2.1 为什么NER的数据格式比模型还容易翻车很多人一上来就急着搭网络结果训练loss不降回头一看是数据读错了。NER是序列标注任务输入和标签必须严格对齐每个字符或词对应一个标签句子之间用空行隔开。课程作业里常见的语料是BIO或BIOES标注B表示实体开始I表示实体内部O表示非实体E/S是BIOES里表示结束和单字实体。如果你拿到的原始数据是「词 标签」按行存的那第一步就是把它整理成「字 标签」或者保持词级但一定要保证切分方式和模型输入一致。我一般会先写个小脚本统计一下标签分布看看有没有那种只出现两三次的稀有标签这种标签在训练集里太少模型根本学不会最后全预测成O。还有一个血泪经验中文NER里如果按字切标点、数字、英文混在一起标签对齐特别容易错位建议先统一全半角、去掉多余空格再逐字打标签。2.2 用Python把语料转成模型能吃的格式假设你手里是常见的「词 标签」逐行、句子间空行的数据下面这段代码把它读成句子列表和标签列表并做基本的对齐检查。# 读取BIO格式语料返回句子和标签的列表 def load_data(path): sentences, labels [], [] sent, lab [], [] with open(path, encodingutf-8) as f: 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 # 跳过格式异常行 word, tag parts sent.append(word) lab.append(tag) if sent: # 处理文件末尾没有空行的情况 sentences.append(sent) labels.append(lab) return sentences, labels # 检查输入输出长度是否一致 sents, labs load_data(train.txt) for s, l in zip(sents, labs): assert len(s) len(l), f长度不一致: {s} vs {l} print(f共加载 {len(sents)} 个句子)这段代码的关键点在于空行切句、按空白切词和标签、末尾兜底。参数上没什么可调的但你要注意如果你的语料是已经分好词的中文那模型输入就是词向量如果是按字那word就是单个汉字。两种方式对双向LSTM的影响不同词级序列短、语义强但分词错误会传导字级序列长、覆盖全但计算量更大。课程作业里通常字级更稳因为不用依赖外部分词工具。2.3 构建词表和标签表两个必须落盘的映射模型不认识文字只认识数字。所以要把每个字映射成id把每个标签也映射成id。这里有个坑词表里一定要留出 和 标签表里O的id最好固定为0因为后面算loss时经常要忽略padding位置。from collections import Counter # 构建词表min_freq控制最低出现次数 def build_vocab(sentences, min_freq1): counter Counter() for sent in sentences: counter.update(sent) # 保留特殊符号 vocab {PAD: 0, UNK: 1} for word, freq in counter.items(): if freq min_freq: vocab[word] len(vocab) return vocab # 构建标签表 def build_tag_map(labels): tag_set set() for lab in labels: tag_set.update(lab) # O放在0位方便后续mask tag_map {O: 0} for tag in sorted(tag_set): if tag ! O: tag_map[tag] len(tag_map) return tag_map vocab build_vocab(sents, min_freq1) tag_map build_tag_map(labs) print(f词表大小: {len(vocab)}, 标签数: {len(tag_map)})参数说明min_freq设成1表示所有出现过的字都保留课程作业数据量小的时候可以这么干如果数据量大设成2或3能减少词表体积。tag_map里把O固定为0是个习惯因为后面CRF的转移矩阵和mask操作会用到。这两个映射一定要在训练前保存下来预测时加载同一份否则id对不上结果全乱。3. 双向LSTMCRF模型搭建从Embedding到维特比解码3.1 双向LSTM层到底该怎么设参数双向LSTM是这套模型的主体。输入是词id序列先过Embedding变成向量再进BiLSTM。BiLSTM有两个方向正向按时间顺序读反向倒着读最后把两个方向的隐状态拼起来。这样每个位置的输出都包含了左边和右边的上下文。参数上embedding_dim一般设100到300hidden_dim设128到256层数1到2层足够再多容易过拟合课程作业数据量通常撑不起深层网络。import torch import torch.nn as nn class BiLSTMCRF(nn.Module): def __init__(self, vocab_size, tag_num, embed_dim128, hidden_dim256): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) self.lstm nn.LSTM(embed_dim, hidden_dim, num_layers1, bidirectionalTrue, batch_firstTrue) # 双向输出拼接后维度是hidden_dim*2映射到标签数 self.fc nn.Linear(hidden_dim * 2, tag_num) # CRF转移矩阵tag_num x tag_num self.transitions nn.Parameter(torch.randn(tag_num, tag_num)) self.tag_num tag_num def forward(self, x, mask): emb self.embedding(x) # [B, L, E] out, _ self.lstm(emb) # [B, L, 2H] emissions self.fc(out) # [B, L, T] return emissions逻辑说明Embedding的padding_idx0让padding位置不参与梯度LSTM的batch_firstTrue让输入维度是[batch, seq_len, dim]全连接层把每个位置的隐状态映射成每个标签的分数这叫发射分数。CRF的转移矩阵是一个可学习参数transitions[i][j]表示从标签i转移到标签j的分数。注意这里只写了forwardCRF的loss和解码要单独实现。3.2 CRF层损失函数和维特比解码怎么写CRF的核心是两条训练时算负对数似然损失预测时用维特比算法找最优路径。损失函数要枚举所有可能路径的分数用logsumexp做归一化解码时动态规划找最大分数路径。下面给出关键实现。def crf_loss(self, emissions, tags, mask): # emissions: [B, L, T], tags: [B, L], mask: [B, L] batch_size, seq_len, tag_num emissions.shape # 计算真实路径分数 score self._compute_score(emissions, tags, mask) # 计算所有路径分数的logsumexp logZ self._compute_logZ(emissions, mask) return (logZ - score).mean() def _compute_score(self, emissions, tags, mask): batch_size, seq_len tags.shape score torch.zeros(batch_size, deviceemissions.device) # 加上发射分数 for i in range(seq_len): score emissions[torch.arange(batch_size), i, tags[:, i]] * mask[:, i] # 加上转移分数 for i in range(1, seq_len): score self.transitions[tags[:, i-1], tags[:, i]] * mask[:, i] return score def _compute_logZ(self, emissions, mask): batch_size, seq_len, tag_num emissions.shape # 初始化第一个位置的分数 alpha emissions[:, 0, :] for i in range(1, seq_len): # 上一时刻分数 转移分数 当前发射分数 emit emissions[:, i, :].unsqueeze(1) # [B, 1, T] trans self.transitions.unsqueeze(0) # [1, T, T] alpha torch.logsumexp(alpha.unsqueeze(2) trans emit, dim1) # 对padding位置保持原值 alpha alpha * mask[:, i].unsqueeze(1) alpha * (1 - mask[:, i].unsqueeze(1)) return torch.logsumexp(alpha, dim1)参数说明mask用来屏蔽padding位置避免它们参与分数计算。_compute_score里对每个位置取对应标签的发射分数再累加相邻标签的转移分数。_compute_logZ用前向算法递推每一步对上一时刻所有标签做logsumexp。这里为了可读性用了循环实际训练时可以用矩阵化加速但课程作业数据量小循环也能跑。维特比解码类似只是把logsumexp换成max并记录回溯路径。3.3 训练循环与批次组织padding和mask是重点训练时要把不同长度的句子拼成一个batch短的补0同时生成mask标记哪些位置是真实token。mask在CRF里至关重要如果忘了传padding位置会污染分数模型学出来的东西全是错的。from torch.nn.utils.rnn import pad_sequence def collate_fn(batch, vocab, tag_map): sents, tags zip(*batch) # 转成id sent_ids [[vocab.get(w, vocab[UNK]) for w in s] for s in sents] tag_ids [[tag_map[t] for t in tg] for tg in tags] # padding sent_tensor pad_sequence([torch.tensor(s) for s in sent_ids], batch_firstTrue, padding_value0) tag_tensor pad_sequence([torch.tensor(t) for t in tag_ids], batch_firstTrue, padding_value0) mask (sent_tensor ! 0).float() return sent_tensor, tag_tensor, mask逻辑说明pad_sequence把每个batch里的句子补齐到最长长度padding_value0对应 。mask用sent_tensor ! 0生成真实token为1padding为0。注意标签的padding也用0但计算loss时靠mask屏蔽所以标签0具体是什么不重要只要mask对就行。训练时把mask传给crf_loss确保只有真实位置参与。4. 训练调参与效果验证让模型真正学会标签转移4.1 学习率、批次大小和早停怎么定课程作业的数据量通常不大几千到几万句。学习率我一般从1e-3开始用Adam优化器如果loss震荡就降到5e-4。批次大小设16或32太大容易过拟合太小训练慢。训练轮数不用固定盯着验证集的F1连续3轮不涨就停这就是早停。双向LSTMCRF在小数据上通常几十轮就收敛再训下去只会记住训练集。from torch.optim import Adam model BiLSTMCRF(len(vocab), len(tag_map)) optimizer Adam(model.parameters(), lr1e-3) for epoch in range(50): model.train() total_loss 0 for batch in train_loader: x, y, mask batch emissions model(x, mask) loss model.crf_loss(emissions, y, mask) optimizer.zero_grad() loss.backward() # 梯度裁剪防止LSTM梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step() total_loss loss.item() print(fEpoch {epoch}, Loss: {total_loss / len(train_loader):.4f})参数说明clip_grad_norm_的max_norm设5.0是经验值LSTM容易梯度爆炸裁剪后训练更稳。学习率1e-3配合Adam在多数NER任务上都能跑如果发现loss变成nan先检查学习率是不是太大再检查数据里有没有空句子或超长句子。4.2 用实体级F1评估别只看准确率序列标注的准确率会骗人如果O标签占90%模型全预测O也能拿90%准确率但实体一个没抽出来。所以必须用实体级F1也就是预测的实体边界和类型完全正确才算对。下面是一个简单的评估函数。def evaluate(model, data_loader, tag_map, id2tag): model.eval() preds, golds [], [] with torch.no_grad(): for x, y, mask in data_loader: emissions model(x, mask) pred_tags model.viterbi_decode(emissions, mask) # 返回id序列 for p, g, m in zip(pred_tags, y, mask): length int(m.sum().item()) preds.append([id2tag[i] for i in p[:length]]) golds.append([id2tag[i] for i in g[:length].tolist()]) # 按实体级别统计 correct, pred_num, gold_num 0, 0, 0 for p, g in zip(preds, golds): p_entities extract_entities(p) g_entities extract_entities(g) correct len(p_entities g_entities) pred_num len(p_entities) gold_num len(g_entities) precision correct / pred_num if pred_num else 0 recall correct / gold_num if gold_num else 0 f1 2 * precision * recall / (precision recall) if (precision recall) else 0 return precision, recall, f1逻辑说明extract_entities把BIO序列转成实体集合比如「B-PER I-PER」转成(起始位置, 结束位置, 类型)。评估时只比较实体集合的交集。参数上没什么可调的但要注意id2tag要和训练时的tag_map严格对应。如果F1一直很低先看验证集是不是和训练集分布差太多再看标签是不是有大量稀有类别。4.3 预测新句子从文本到实体列表的完整链路训练完模型最终要能对任意句子做预测。链路是分词/分字 → 查词表转id → 过模型 → 维特比解码 → 标签转实体。下面是一个预测函数。def predict(text, model, vocab, id2tag): model.eval() # 按字切分和训练时保持一致 chars list(text) ids [vocab.get(c, vocab[UNK]) for c in chars] x torch.tensor([ids]) mask torch.ones_like(x).float() with torch.no_grad(): emissions model(x, mask) pred model.viterbi_decode(emissions, mask)[0] tags [id2tag[i] for i in pred] # 合并实体 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: 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 [(text[s:e], t) for s, e, t in entities]参数说明这里按字切分和训练时保持一致。如果训练时按词预测也要先分词。viterbi_decode返回的是标签id序列转成标签字符串后按BIO规则合并。注意I-标签必须跟在同类型的B-后面否则视为O这是BIO的硬规则写合并逻辑时不能漏。5. 避坑与排查双向LSTMCRF训练中最容易翻车的5个地方5.1 现象loss不降或变成nan原因学习率太大、梯度爆炸、数据里有空句子或超长句子。双向LSTM对长序列敏感超过200的长度容易梯度不稳。 解决把学习率降到5e-4或1e-4加梯度裁剪max_norm5.0过滤掉长度小于2或大于200的句子检查数据里有没有全padding的batch。5.2 现象验证集F1很高但预测新句子全是O原因验证集和训练集同分布模型记住了训练集的标签转移但新句子里的实体模式没见过或者词表覆盖太低新句子大量 。 解决检查词表的 比例如果超过10%说明词表太小可以降低min_freq或改用字级另外确认预测时的切分方式和训练一致训练按字预测按词必翻车。5.3 现象CRF转移矩阵学出来的值很怪某些合法转移分数很低原因训练数据里这些转移出现次数太少CRF没学到或者mask传错了padding位置参与了转移计算。 解决统计训练集里标签转移的频次对稀有转移可以不做特殊处理但至少要确认mask在crf_loss里正确屏蔽了padding。检查mask的生成方式确保padding位置为0。5.4 现象训练速度特别慢GPU利用率低原因CRF的logsumexp用了Python循环没有矩阵化或者batch_size太小数据加载是瓶颈。 解决把CRF的前向算法改成矩阵运算用torch.logsumexp一次算完整个序列增大batch_size到32或64用DataLoader的num_workers加速数据读取。课程作业数据量小的话循环也能忍但矩阵化后能快好几倍。5.5 现象实体边界切错比如「张三丰」只识别出「张三」原因BIO标注里I标签的转移约束不够强或者训练数据里长实体太少。双向LSTM对长实体的边界建模能力有限。 解决改用BIOES标注显式区分实体结束位置或者在CRF转移矩阵里手动加约束禁止非法转移比如O到I。另外可以增加长实体的训练样本或者用预训练词向量初始化Embedding。6. 进阶技巧用预训练向量和转移约束把F1再提一截课程作业做到上面那步已经能交差了但如果你想让效果更好看有两个方向值得试。第一个是用预训练词向量初始化Embedding。中文可以用腾讯词向量或百度词向量英文用GloVe加载后把embedding矩阵填进去训练时可以选择冻结或微调。冻结适合数据量极小的情况微调适合数据量中等的情况。第二个是给CRF转移矩阵加硬约束比如禁止「B-PER」直接转到「I-LOC」禁止「O」转到「I-PER」。实现方式是在计算转移分数时对非法转移加一个极大的负值让模型永远不选这些路径。# 在CRF初始化时定义非法转移掩码 def build_transition_mask(tag_map): tag_num len(tag_map) mask torch.zeros(tag_num, tag_num) id2tag {v: k for k, v in tag_map.items()} for i in range(tag_num): for j in range(tag_num): from_tag, to_tag id2tag[i], id2tag[j] # 禁止O到I禁止B-X到I-YX!Y if from_tag O and to_tag.startswith(I-): mask[i][j] -1e4 if from_tag.startswith(B-) and to_tag.startswith(I-): if from_tag[2:] ! to_tag[2:]: mask[i][j] -1e4 return mask # 在计算转移分数时加上mask # score (self.transitions self.transition_mask)[tags[:, i-1], tags[:, i]] * mask[:, i]这段代码的关键是把非法转移的分数压到极低模型在logsumexp和维特比解码时自然避开。参数上-1e4是个经验值够大就行不用精确。加上这个约束后实体边界的一致性会明显改善尤其是多类型实体混在一起的时候。验证改进是否有效不能只看训练loss要固定随机种子在同一个验证集上对比加约束前后的实体级F1。我一般会跑三次取平均避免单次波动误导判断。如果F1提升不到1个点可能数据量太小约束的收益被掩盖了如果提升超过3个点说明你的数据里非法转移确实很多这个技巧值得保留。最后说个我自己的习惯每次改完模型结构或参数先把训练集缩小到100句跑一遍确认能过拟合再上全量数据。如果100句都过拟合不了那肯定是代码有bug不是模型不行。这个「小数据过拟合测试」帮我省了无数排查时间。希望帮到你。本文还有配套的精品资源点击获取
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。