双向LSTM+CRF命名实体识别:从课程作业到可用的NER基线

📅 发布时间:2026/10/5 7:18:56
双向LSTM+CRF命名实体识别:从课程作业到可用的NER基线
简介这份资源是面向计算机、人工智能、通信等专业学生与从业者的命名实体识别课程作业完整包对应 NLP 四大基础任务之一的序列标注问题采用双向 LSTM 结合条件随机场 CRF 的经典方案在 LSTM 输出层后引入 CRF 自动学习标签转移约束避免出现连续 B-LOC 等非法标签序列从而提升标注精度适合作为期末大作业、课程设计或毕业设计的参考实现。压缩包共 3 个文件约 201KB包含一份 Python 源码、一份 PDF 作业报告和一份 Markdown 说明文档分别对应模型实现、实验分析与使用指引结构精简、便于快速上手。目前已有 138 人学习下载。读者可从中获得完整的序列标注建模流程、LSTMCRF 的代码组织方式、实验报告撰写思路以及可复现的调试经验基础较好的同学还能在此基础上替换数据集或调整网络结构完成个性化扩展。1. 从一份课程作业说起双向LSTMCRF做命名实体识别到底能跑出什么命名实体识别NER是NLP里最像“基本功”的任务之一从简历解析、医疗病历结构化到电商评论里的品牌型号抽取背后都是它。很多同学第一次接触NER是在课程作业里被要求“基于Python实现双向LSTM条件随机场CRF的命名实体识别模型”并且要交源码和报告。这个标题听起来像堆了两个模型实际上它是一条非常经典的序列标注流水线双向LSTM负责读上下文CRF负责约束标签之间的转移合法性。你拿到的这份作业包核心价值不在于模型多新而在于它把数据预处理、词向量加载、模型搭建、训练、评估、预测这条链路完整跑通了。适合谁适合刚学完Python和深度学习基础、想找一个能复现的序列标注项目练手的人也适合需要快速搭一个NER基线、再往上换BERT或大模型的人。下面我按自己复现这类作业的习惯把每一步拆开讲清楚。2. 双向LSTM和CRF各自解决什么问题选型理由与数据准备2.1 为什么不是单向LSTM也不是纯CRF单向LSTM在序列标注里有个硬伤它只能看到当前词左侧的上下文。但命名实体识别里判断“苹果”是水果还是公司往往要看它右边跟着“发布”还是“吃了”。双向LSTM把前向和后向的隐藏状态拼在一起每个时间步的输出就同时包含左右两侧信息。纯CRF则相反它擅长建模标签之间的转移关系比如“I-ORG”后面不该直接跟“I-PER”但它需要人工设计特征对长距离语义的捕捉能力有限。把两者接起来双向LSTM负责从词向量里抽特征CRF层负责在输出标签序列时加一层全局约束这就是标题里“双向LSTMCRF”的常见组合逻辑。常见做法是Embedding层 → 双向LSTM层 → 全连接层 → CRF层。全连接层把LSTM输出映射到每个标签的分数CRF再基于这些分数和转移矩阵解码出最优标签序列。2.2 数据格式与标签体系怎么定课程作业里常见的数据集是CoNLL-2003格式或BIO标注的中文数据。BIO的意思是B-XXX表示实体开始I-XXX表示实体内部O表示非实体。比如“张三在北京大学读书”标注后是“B-PER I-PER O B-ORG I-ORG I-ORG O”。你拿到源码后第一件事是确认标签到id的映射表通常代码里会有一个tag2id字典。如果标签体系是BIOES那会多出E-XXX和S-XXX解码逻辑不变但标签数量变多CRF转移矩阵也要相应变大。数据准备阶段要做的三件事读取原始文件、按句子切分、把每个词和标签转成id序列。下面这段代码是我复现时常用的数据读取骨架你可以直接对照作业里的data_loader.py看差异。# 读取BIO格式数据返回句子列表和标签列表 def read_data(file_path): sentences, labels [], [] with open(file_path, r, encodingutf-8) as f: sent, tag [], [] for line in f: line line.strip() if not line: # 空行表示句子结束 if sent: sentences.append(sent) labels.append(tag) sent, tag [], [] continue parts line.split() if len(parts) 2: sent.append(parts[0]) tag.append(parts[-1]) if sent: # 处理文件末尾没有空行的情况 sentences.append(sent) labels.append(tag) return sentences, labels逻辑说明按行读取空行作为句子分隔符每行至少取第一个字段作为词、最后一个字段作为标签。参数说明file_path是训练集或测试集路径如果数据里词和标签之间是制表符或空格split()都能处理注意有些数据集词本身含空格这时要改用固定列切分。读取完成后统计一下标签分布如果O标签占比超过90%说明实体稀疏训练时容易偏向全预测O后面评估要看F1而不是准确率。2.3 词向量与词表构建的取舍课程作业里常见两种做法一种是随机初始化Embedding让模型自己学另一种是加载预训练词向量比如GloVe或Word2Vec。随机初始化在数据量小的时候容易过拟合加载预训练词向量通常能提升几个点。我一般会先看作业里有没有提供vec.txt或embedding.npz。如果有就按词表顺序构建嵌入矩阵如果没有就用随机初始化但把embedding_dim设小一点比如100或128别一上来就300。构建词表时低频词统一映射到UNK注意PAD的id通常是0后面计算损失时要忽略这个位置。# 构建词表和标签表word2id和tag2id def build_vocab(sentences, labels, min_freq1): word_freq {} for sent in sentences: for w in sent: word_freq[w] word_freq.get(w, 0) 1 word2id {PAD: 0, UNK: 1} for w, c in word_freq.items(): if c min_freq: word2id[w] len(word2id) tag2id {PAD: 0} for tag_seq in labels: for t in tag_seq: if t not in tag2id: tag2id[t] len(tag2id) return word2id, tag2id逻辑说明先统计词频再按最小频率过滤PAD和UNK固定占前两个id。参数说明min_freq1表示所有词都保留数据量大时可以设2或3来降词表规模tag2id里加PAD是为了对齐批次长度实际计算损失时要把这个位置mask掉。注意标签id的顺序会影响CRF转移矩阵的初始化但训练后会自动调整不用太纠结初始顺序。3. 用PyTorch把双向LSTMCRF搭起来模型定义与训练循环3.1 模型结构逐层拆解整个模型可以拆成四块Embedding层、双向LSTM层、线性输出层、CRF层。Embedding层把词id转成向量双向LSTM层设置batch_firstTrue输出维度是(batch, seq_len, hidden_dim*2)线性层把hidden_dim*2映射到num_tagsCRF层接收发射分数和标签序列计算负对数似然损失。这里有个关键点CRF的转移矩阵是(num_tags, num_tags)它学习的是标签之间的转移分数比如从B-PER到I-PER的分数应该比到O的分数高。下面是我常用的模型定义代码和作业里model.py的结构基本一致。import torch import torch.nn as nn from torchcrf import CRF # 常见做法是安装pytorch-crf class BiLSTM_CRF(nn.Module): def __init__(self, vocab_size, tag2id, embedding_dim128, hidden_dim256): super().__init__() self.embedding nn.Embedding(vocab_size, embedding_dim, padding_idx0) self.lstm nn.LSTM(embedding_dim, hidden_dim, num_layers1, bidirectionalTrue, batch_firstTrue) self.fc nn.Linear(hidden_dim * 2, len(tag2id)) self.crf CRF(len(tag2id), batch_firstTrue) self.tag2id tag2id def forward(self, x, tagsNone, maskNone): emb self.embedding(x) # (batch, seq_len, emb_dim) lstm_out, _ self.lstm(emb) # (batch, seq_len, hidden*2) emissions self.fc(lstm_out) # (batch, seq_len, num_tags) if tags is not None: # 训练时返回负对数似然损失 loss -self.crf(emissions, tags, maskmask, reductionmean) return loss else: # 预测时返回最优标签序列 return self.crf.decode(emissions, maskmask)逻辑说明forward根据是否传入tags区分训练和预测。训练时调用self.crf计算损失预测时调用decode返回标签id列表。参数说明embedding_dim常用100到300hidden_dim常用128到256num_layers1在课程作业里够用想加深可以设2但要注意过拟合padding_idx0让PAD的嵌入不参与梯度更新。注意pytorch-crf这个库不是PyTorch官方自带需要pip install pytorch-crf如果作业里自己实现了CRF那就对照它的forward和decode方法看。3.2 批次构造与mask处理序列标注任务里一个批次里句子长度不同必须用PAD补齐到最大长度。补齐后要生成mask矩阵标记哪些位置是真实词、哪些是填充。mask在CRF里非常重要如果不传maskCRF会把填充位置也纳入转移计算导致损失和预测都出错。下面这段是批次构造和mask生成的代码。from torch.nn.utils.rnn import pad_sequence def collate_fn(batch, word2id, tag2id): # batch是(sent_ids, tag_ids)的列表 sents, tags zip(*batch) # 补齐到批次内最大长度 sents_padded pad_sequence([torch.tensor(s) for s in sents], batch_firstTrue, padding_value0) tags_padded pad_sequence([torch.tensor(t) for t in tags], batch_firstTrue, padding_value0) # mask: 真实词位置为1填充位置为0 mask (sents_padded ! 0) return sents_padded, tags_padded, mask逻辑说明pad_sequence把变长序列补齐padding_value0对应PAD的id。mask用sents_padded ! 0生成因为PAD的id是0。参数说明如果UNK的id也是0就会混淆所以构建词表时PAD和UNK要分开通常PAD是0UNK是1。注意标签的PAD也用0但计算损失时CRF内部会根据mask忽略这些位置不用手动再处理。3.3 训练循环与学习率设置训练循环的骨架很固定前向传播算损失、反向传播、优化器更新。优化器常用Adam学习率设1e-3到1e-4之间。双向LSTMCRF在课程作业规模的数据集上通常训练10到30个epoch就能收敛。我一般会加一个简单的早停如果验证集F1连续3个epoch不提升就停。下面是一个最小训练循环。import torch.optim as optim from sklearn.metrics import f1_score def train(model, train_loader, dev_loader, epochs20, lr1e-3): optimizer optim.Adam(model.parameters(), lrlr) best_f1 0.0 for epoch in range(epochs): model.train() total_loss 0 for sents, tags, mask in train_loader: optimizer.zero_grad() loss model(sents, tags, mask) loss.backward() # 梯度裁剪防止LSTM梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step() total_loss loss.item() # 验证集评估 f1 evaluate(model, dev_loader) print(fepoch {epoch}, loss {total_loss:.4f}, dev_f1 {f1:.4f}) if f1 best_f1: best_f1 f1 torch.save(model.state_dict(), best_model.pt) return best_f1逻辑说明每个epoch遍历训练集算损失、反向传播、裁剪梯度、更新参数然后在验证集上算F1。参数说明lr1e-3是常见起点如果损失震荡就降到5e-4max_norm5.0是梯度裁剪阈值LSTM容易梯度爆炸这个不能省epochs20是保守值实际看验证集F1什么时候平。注意evaluate函数里要调用model.eval()并关闭梯度解码后用sklearn的f1_score按实体级别或标签级别计算课程作业里常用标签级别F1。4. 训练完怎么验证评估指标、预测输出与常见翻车点4.1 评估指标别只看准确率序列标注里O标签通常占大多数如果模型全预测O准确率也能到80%以上但F1是0。所以评估必须看F1而且最好看实体级别的F1也就是把预测的实体片段和真实实体片段做匹配。课程作业里如果只要求标签级别F1用sklearn.metrics.f1_score加averagemacro或weighted就行。但你要心里清楚标签级别F1会偏高因为B-PER和I-PER分开算。下面是一个评估函数骨架。def evaluate(model, data_loader): model.eval() all_preds, all_trues [], [] with torch.no_grad(): for sents, tags, mask in data_loader: preds model(sents, maskmask) # 返回list of list for p, t, m in zip(preds, tags.tolist(), mask.tolist()): for pi, ti, mi in zip(p, t, m): if mi: # 只统计真实词位置 all_preds.append(pi) all_trues.append(ti) return f1_score(all_trues, all_preds, averagemacro)逻辑说明遍历验证集取预测标签和真实标签只保留mask为1的位置最后算macro F1。参数说明averagemacro对每个标签一视同仁适合看稀有实体averageweighted按标签频率加权更接近整体表现。注意model(sents, maskmask)调用时没有传tags所以走的是decode分支返回的是标签id列表不是张量。4.2 预测新句子时要注意的预处理训练完模型拿一个新句子预测时预处理必须和训练时完全一致分词方式、词表映射、未知词处理。常见翻车点是训练时用jieba分词预测时用空格分词导致词表对不上全变成UNK。我一般会把预处理封装成一个函数训练和预测共用。下面是一个预测示例。def predict(model, sentence, word2id, id2tag): model.eval() # 假设sentence已经分好词比如[张三, 在, 北京, 大学] ids [word2id.get(w, word2id[UNK]) for w in sentence] x torch.tensor([ids]) mask torch.tensor([[1] * len(ids)]) with torch.no_grad(): pred_ids model(x, maskmask)[0] return [(w, id2tag[i]) for w, i in zip(sentence, pred_ids)]逻辑说明把词转成id构造mask调用模型解码最后把标签id转回标签名。参数说明word2id.get(w, word2id[UNK])处理未登录词mask全1因为这是单句没有填充。注意如果句子长度超过训练时最大长度LSTM本身能处理但CRF的转移矩阵不受长度影响所以不用截断只是显存会涨。4.3 三个血泪踩坑记录现象一损失不下降一直卡在某个值。原因通常是CRF的mask没传对或者标签id和CRF的num_tags不一致。解决打印emissions.shape和tags.max()确认num_tags等于len(tag2id)mask的shape和tags一致。现象二验证集F1很高但预测新句子全是O。原因是训练集和验证集同分布但新句子里的词大量是UNK模型没见过。解决降低min_freq让更多词进词表或者加载预训练词向量或者用字符级模型兜底。现象三训练到后面损失突然变成nan。原因是LSTM梯度爆炸或学习率太大。解决加梯度裁剪clip_grad_norm_把学习率降到1e-4检查输入里有没有空句子导致长度为0。现象四CRF解码速度特别慢。原因是pytorch-crf的decode默认用维特比算法句子长的时候会慢。解决预测时用torch.no_grad()批次别设太大或者换用自己实现的维特比解码。现象五报告里写“模型准确率99%”但被老师问住。原因是只报了准确率没报F1而且没说明O标签占比。解决报告里同时给准确率、macro F1、weighted F1并附上混淆矩阵或每个实体的P/R/F1。5. 从课程作业到能用的NER基线进阶技巧与验证习惯5.1 把双向LSTM换成预训练模型时保留CRF层课程作业里的双向LSTMCRF是一个干净的基线。如果你想往上走最常见的做法是把EmbeddingBiLSTM换成BERT但保留CRF层。BERT输出每个token的向量接一个线性层得到发射分数再接CRF。这样既利用了预训练模型的语义能力又保留了标签转移约束。代码改动很小把self.embedding和self.lstm换成transformers里的BertModel注意BERT的attention_mask要传给CRF的mask。参数上BERT的学习率要设小通常2e-5到5e-5CRF层可以设大一点比如1e-3用分组学习率。5.2 用交叉验证和错误分析代替单次划分课程作业通常只做一次训练集/验证集划分但这样得到的F1波动可能很大。我一般会做5折交叉验证看F1的均值和方差。如果方差超过2个点说明数据划分影响大报告里要说明。错误分析也很重要把预测错的句子导出来看是边界错B和I混淆、类型错PER和ORG混淆还是漏标。边界错通常是CRF转移矩阵没学好类型错通常是上下文特征不够。下面是一个导出错误样本的代码片段。def error_analysis(model, data_loader, id2tag, word2id): id2word {v: k for k, v in word2id.items()} model.eval() errors [] with torch.no_grad(): for sents, tags, mask in data_loader: preds model(sents, maskmask) for s, t, p, m in zip(sents.tolist(), tags.tolist(), preds, mask.tolist()): words [id2word.get(i, UNK) for i in s] true_tags [id2tag[i] for i in t] pred_tags [id2tag[i] for i in p] if true_tags ! pred_tags: errors.append((words, true_tags, pred_tags)) return errors[:20] # 只看前20条逻辑说明遍历数据把id转回词和标签对比真实和预测收集不一致的样本。参数说明id2tag是标签id到标签名的映射word2id的反转字典用于还原词。注意这个函数只用于分析不参与训练。5.3 一个具体技巧用学习率预热和衰减稳住训练双向LSTMCRF在训练初期容易震荡尤其是加载了预训练词向量的时候。我习惯加一个简单的学习率预热前2个epoch从1e-5线性升到1e-3之后按0.9的因子每epoch衰减。这样损失曲线会平滑很多。实现方式可以用torch.optim.lr_scheduler.LambdaLR也可以手动在epoch循环里改optimizer.param_groups[0][lr]。这个技巧在课程作业里不算必须但如果你发现损失前几个epoch上蹿下跳加上它基本能解决。最后说个我自己的习惯每次跑完一个NER模型不管F1多高我都会拿几条真实句子手动过一遍预测结果。因为指标是宏观的但错误往往是具体的。有一次我训练集F1到0.92结果预测“苹果手机”时把“苹果”标成了水果原因是训练数据里水果语料太多。这种问题不看具体样本根本发现不了。希望帮到你。本文还有配套的精品资源点击获取