ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

BiLSTM-CRF命名实体识别实战:从BIO标注到模型调参

2026/10/7 21:42:44 拓冰建站 浏览量
BiLSTM-CRF命名实体识别实战:从BIO标注到模型调参 简介本资源面向医疗领域NLP研究人员与开发者提供一套基于BiLSTM-CRF架构的命名实体识别完整实现方案可用于从临床记录中自动提取病症、医疗操作、药物等关键实体适合具备一定深度学习基础、希望快速复现NER流程的中高级学习者。压缩包共24个文件约2.23MB以Python脚本、txt数据与说明文档为主辅以xlsx标注数据、json词表、png可视化图表及docx任务描述覆盖数据预处理、模型训练、结果展示等环节。代码层面包含train.py训练主流程、preprocess.py分词标注与向量化、dataset.py批量数据加载以及visual.py可视化脚本并附带训练好的模型与预处理数据目录。说明文档.txt详细交代了运行方式、参数设置与结果解读outcome_with_crf.png等图片直观呈现训练效果与标签分布。目前已有263人学习可帮助读者省去底层实现细节专注模型创新与医疗NLP应用落地。1. 从一堆简历里抽出公司名NLP 命名实体识别为什么绕不开 BiLSTM-CRF假设你手上有 5000 份招聘启事老板要你统计「哪些公司最常招 Python 工程师」。你写了个正则去匹配「XX 有限公司」结果发现有的写「XX 科技」有的写「XX 信息技术有限责任公司」还有的干脆只写「XX 集团」——正则越写越长维护成本直接爆炸。这时候你需要的不是更复杂的正则而是命名实体识别NER让模型自己学会从一句话里把「公司名」「人名」「地名」这些片段抠出来。NER 是 NLP 里最经典的信息抽取任务之一而BiLSTM-CRF是过去几年工业界落地最稳的一套方案。它不依赖大规模预训练模型单卡就能训标注几百条数据就能出效果特别适合垂直领域医疗、法律、招聘、金融里那些通用大模型认不出来的专有名词。这篇文章不讲空泛概念我会把数据标注格式、BiLSTM 的代码结构、CRF 层怎么接、参数怎么调、训练时哪里最容易翻车一条条拆开讲清楚。如果你手头正好有一批业务文本要抽实体跟着走一遍就能跑通。2. 先搞清楚数据长什么样BIO 标注与词表构建2.1 为什么 NER 的数据必须按字符级标注很多人第一次做 NER拿到的是「句子 实体列表」这种格式比如{text: 张三在阿里巴巴工作, entities: [{start: 0, end: 2, type: PER}]}。这种格式人看着舒服但模型训练时需要的是每个 token 对应一个标签。中文 NER 里最常见的做法是字符级标注也就是把句子拆成单个汉字每个字打一个标签。标签体系用BIOB-X 表示实体 X 的开始I-X 表示实体 X 的中间或结尾O 表示不属于任何实体。上面那句话就变成字符张三在阿里巴巴工作标签B-PERI-PEROB-ORGI-ORGI-ORGI-ORGOO为什么不用 BIESO 或者 BMESBIO 最简单标注成本最低对 BiLSTM-CRF 来说已经够用。如果你的实体边界经常出错再考虑升级到 BMES但那是后话。2.2 用一段脚本把原始标注转成模型输入假设你拿到的原始数据是 JSON 行格式每行一个句子加实体列表。下面这段 Python 把它转成tokens和labels两个列表import json def bio_tagging(text, entities): text: 原始句子字符串 entities: [{start: 0, end: 2, type: PER}, ...] 返回: (chars, labels) chars list(text) labels [O] * len(chars) for ent in entities: s, e, t ent[start], ent[end], ent[type] # 注意 end 是开区间实际最后一个字符下标是 e-1 labels[s] fB-{t} for i in range(s 1, e): labels[i] fI-{t} return chars, labels # 读取 JSONL 并转换 with open(raw_data.jsonl, r, encodingutf-8) as f: for line in f: item json.loads(line) chars, labels bio_tagging(item[text], item[entities]) # 这里可以写入新的训练文件 print(chars, labels)这段代码的关键在end的处理很多标注工具导出的end是开区间也就是实体最后一个字符的下标是end - 1。如果你按闭区间处理标签会整体错位一位模型学出来的实体边界永远偏右。我见过至少三个项目栽在这个细节上。2.3 词表怎么建别直接拿全量字符去训字符级 NER 的词表就是所有出现过的汉字、数字、字母、标点。但如果你直接把训练集里所有字符都塞进词表会遇到两个问题一是低频字符太多embedding 学不好二是测试集出现未登录字符时直接报错。常见做法是统计字符频率保留出现次数大于等于 2 的字符其余统一映射到UNK。另外必须预留PAD用于 batch 内对齐。下面是一个最小词表构建逻辑from collections import Counter def build_vocab(sentences, min_freq2): counter Counter() for chars in sentences: counter.update(chars) # 特殊 token 固定放在前两位 vocab {PAD: 0, UNK: 1} for ch, freq in counter.items(): if freq min_freq: vocab[ch] len(vocab) return vocab参数min_freq我一般设 2数据量小于 1000 句时设 1否则UNK会吃掉太多信息。词表大小控制在 5000 以内比较稳超过这个数说明你的数据里混进了大量噪声字符先回去清洗数据。3. BiLSTM 层怎么搭从 embedding 到上下文向量3.1 为什么是 BiLSTM 而不是单向 LSTMNER 的标签不仅依赖前面的字也依赖后面的字。比如「苹果」这个词在前面出现「吃了一个」时是水果在前面出现「发布了新款」时是公司。单向 LSTM 只能看到左侧上下文判断「苹果」是 ORG 还是 O 时会缺一半信息。BiLSTM 用两个方向的 LSTM 分别扫一遍再把每个时间步的两个隐状态拼起来这样每个字都同时拥有左右两侧的上下文。具体到维度假设 embedding 维度是 128LSTM 隐层维度是 256那么 BiLSTM 每个时间步输出 512 维向量。这个向量再送进 CRF 层做标签解码。3.2 用 PyTorch 写一个可跑的 BiLSTM 编码器下面这段代码定义了一个标准的 BiLSTM 编码器输入是字符 id 序列输出是每个位置的上下文向量import torch import torch.nn as nn class BiLSTMEncoder(nn.Module): def __init__(self, vocab_size, embed_dim128, hidden_dim256, dropout0.3): 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 ) self.dropout nn.Dropout(dropout) # BiLSTM 输出维度是 hidden_dim * 2 self.out_dim hidden_dim * 2 def forward(self, x, maskNone): # x: (batch, seq_len) emb self.embedding(x) # (B, L, E) emb self.dropout(emb) out, _ self.lstm(emb) # (B, L, 2H) out self.dropout(out) return out几个参数需要说明。embed_dim设 128 是中文 NER 的常见起点数据量上万句可以加到 256。hidden_dim设 256 意味着 BiLSTM 输出 512 维再大容易过拟合再小表达能力不够。dropout设 0.3 是我在多个项目里试出来的平衡点低于 0.2 训练集准确率飙得很快但验证集不涨高于 0.5 收敛太慢。padding_idx0很重要它让PAD的 embedding 始终为 0不参与梯度更新。如果你忘了设这个参数padding 位置会引入噪声模型在短句上表现明显变差。3.3 batch 内对齐与 mask 的处理实际训练时一个 batch 里的句子长度不同需要 padding 到同一长度。但 BiLSTM 在 padding 位置也会产生输出如果直接送进 CRF这些无效位置会干扰损失计算。标准做法是同时传入一个 mask 矩阵在计算 loss 时把 padding 位置排除掉。from torch.nn.utils.rnn import pad_sequence def collate_fn(batch): # batch: [(char_ids, label_ids), ...] chars, labels zip(*batch) chars [torch.tensor(c) for c in chars] labels [torch.tensor(l) for l in labels] # padding 到 batch 内最大长度 chars_padded pad_sequence(chars, batch_firstTrue, padding_value0) labels_padded pad_sequence(labels, batch_firstTrue, padding_value-1) mask (chars_padded ! 0) # True 表示真实 token return chars_padded, labels_padded, mask注意 label 的 padding 值设成-1而不是 0因为 0 可能是某个真实标签的 id。后面 CRF 计算 loss 时用 mask 把-1位置过滤掉。这个细节如果搞错模型会把 padding 当成 O 标签来学导致预测时短句末尾多出莫名其妙的实体。4. CRF 层为什么 softmax 不够以及怎么接上去4.1 标签之间的依赖关系 softmax 学不到如果 BiLSTM 输出后直接接一个全连接层加 softmax每个位置独立预测标签会出现什么情况模型可能预测出B-PER后面跟I-ORG或者I-PER前面没有B-PER。这些在 BIO 体系下都是非法序列。softmax 逐位置独立计算无法建模标签之间的转移约束。CRF 层在 BiLSTM 输出之上加了一个转移矩阵学习「从标签 A 转移到标签 B」的分数。解码时用 Viterbi 算法找全局最优路径保证输出序列合法。训练时用负对数似然损失同时优化发射分数和转移分数。4.2 一个最小可用的 CRF 实现下面是一个简化版 CRF 层只保留核心逻辑方便理解class CRF(nn.Module): def __init__(self, num_tags): super().__init__() self.num_tags num_tags # 转移矩阵trans[i][j] 表示从 j 转移到 i 的分数 self.transitions nn.Parameter(torch.randn(num_tags, num_tags)) # 限制非法转移不能从任何标签转移到 START也不能从 END 转移出去 self.transitions.data[:, 0] -1e4 # 假设 0 是 START self.transitions.data[0, :] -1e4 def forward(self, emissions, tags, mask): # emissions: (B, L, num_tags) # tags: (B, L) # mask: (B, L) True 表示真实 token # 返回负对数似然 return -self._log_likelihood(emissions, tags, mask).mean() def _log_likelihood(self, emissions, tags, mask): # 分子真实路径分数 score self._compute_score(emissions, tags, mask) # 分母所有路径分数之和前向算法 partition self._compute_partition(emissions, mask) return score - partition实际项目中我一般直接用pytorch-crf这个库它把前向算法和 Viterbi 解码都封装好了接口干净。但你要理解上面这段的核心CRF 训练时算的是「真实路径分数减去所有路径分数的 logsumexp」预测时用 Viterbi 找最高分路径。4.3 把 BiLSTM 和 CRF 拼成完整模型class BiLSTMCRF(nn.Module): def __init__(self, vocab_size, num_tags, embed_dim128, hidden_dim256): super().__init__() self.encoder BiLSTMEncoder(vocab_size, embed_dim, hidden_dim) self.hidden2tag nn.Linear(self.encoder.out_dim, num_tags) self.crf CRF(num_tags) def forward(self, x, tags, mask): out self.encoder(x, mask) # (B, L, 2H) emissions self.hidden2tag(out) # (B, L, num_tags) loss self.crf(emissions, tags, mask) return loss def predict(self, x, mask): out self.encoder(x, mask) emissions self.hidden2tag(out) return self.crf.decode(emissions, mask)hidden2tag这个线性层把 512 维上下文向量映射到标签数量维度。假设你有 PER、ORG、LOC 三类实体加上 O 和START、END标签总数大概是 8 到 10 个。这个映射是逐位置独立的CRF 负责在它之上加转移约束。5. 训练与调参学习率、batch size 和早停怎么设5.1 优化器和学习率的选择BiLSTM-CRF 参数不多用 Adam 就够了学习率从 1e-3 开始试。如果训练 loss 震荡厉害降到 5e-4如果 loss 下降太慢升到 2e-3。我一般会加一个ReduceLROnPlateau调度器验证集 F1 连续 3 个 epoch 不涨就把学习率砍半。optimizer torch.optim.Adam(model.parameters(), lr1e-3) scheduler torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, modemax, factor0.5, patience3 )modemax是因为我们监控的是 F1 分数越大越好。patience3表示容忍 3 个 epoch 不提升。这个配置在数据量 2000 到 10000 句时比较稳。5.2 batch size 和序列长度的权衡batch size 设 32 或 64 都行但要注意如果你的句子平均长度是 50 个字batch 64 大概占 2GB 显存如果平均长度 200 字batch 32 就可能爆显存。我一般先跑一个 batch 看显存占用再决定最终值。另一个容易忽略的点是长句截断。超过 200 字的句子建议截断或分段预测因为 BiLSTM 在超长序列上梯度传播会衰减CRF 的前向算法计算量也随长度平方增长。实际业务里超过 200 字的句子占比通常不到 5%截断的损失可以接受。5.3 验证集怎么评估实体级 F1 而不是标签级准确率训练时打印标签级准确率会给你虚假的安全感——因为 O 标签通常占 80% 以上模型全预测 O 也能有 80% 准确率。必须用实体级 F1预测出的实体和真实实体完全匹配才算正确。def extract_entities(tags): 从 BIO 标签序列中抽取实体 span entities [] start None for i, tag in enumerate(tags): if tag.startswith(B-): if start is not None: entities.append((start, i, tags[start][2:])) start i elif tag.startswith(I-) and start is not None: continue else: if start is not None: entities.append((start, i, tags[start][2:])) start None if start is not None: entities.append((start, len(tags), tags[start][2:])) return set(entities)用这个函数把预测标签和真实标签都转成实体集合再算精确率、召回率和 F1。我一般要求验证集 F1 至少到 0.85 才考虑上线低于 0.8 说明数据标注质量或标签体系有问题。6. 避坑与排查训练不收敛、实体边界错、O 标签泛滥6.1 训练 loss 不降反升现象前几个 epoch loss 从 10 降到 5然后突然跳到 20 并持续震荡。原因学习率太大或者 CRF 转移矩阵初始化不当导致梯度爆炸。CRF 的转移矩阵如果初始值方差太大前向算法里的 logsumexp 会溢出。解决把学习率降到 1e-4转移矩阵用torch.nn.init.uniform_(-0.1, 0.1)初始化。另外加梯度裁剪torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0)这个几乎是我每个 NER 项目的标配。6.2 实体边界总是多一个字或少一个字现象预测出的公司名是「阿里巴巴集团」但真实标注是「阿里巴巴」或者反过来。原因BIO 标注时end下标处理错了或者标注规范本身不统一。我见过一个项目一半数据把「有限公司」算进公司名另一半不算模型直接学懵。解决先写脚本检查标注一致性统计所有实体长度分布看有没有异常值。然后统一标注规范重新标注冲突样本。如果数据量够大也可以在 CRF 转移矩阵里手动调低B-X到I-X的转移分数让模型更倾向于合并实体但这只是权宜之计。6.3 验证集 F1 很高但上线效果差现象验证集 F1 0.92上线后业务方反馈「一半实体没抽出来」。原因验证集和训练集同分布但线上数据分布不同。常见的是线上文本更长、噪声更多、或者包含训练集没见过的实体类型。解决从线上真实数据里抽 200 条重新标注作为测试集评估。如果 F1 掉到 0.7 以下说明模型泛化不够需要补充线上风格的训练数据或者引入预训练词向量比如用领域语料训一个 word2vec 初始化 embedding。6.4 O 标签占比过高导致实体召回率低现象模型倾向于把所有字都预测成 O实体召回率不到 0.5。原因O 标签样本太多损失函数被 O 主导。CRF 的负对数似然在 O 占 90% 时模型只要全预测 O 就能拿到很低的 loss。解决在 loss 里给实体标签加权或者用 focal loss 的思路降低易分类样本的权重。更简单的做法是过采样包含实体的句子让 batch 里实体和非实体的比例接近 1:3。6.5 预测结果里出现非法标签序列现象输出里出现I-PER开头或者B-PER后面直接跟B-ORG。原因CRF 的转移矩阵没有正确限制非法转移或者解码时没用 Viterbi 而是用了逐位置 argmax。解决检查 CRF 初始化代码确保START只能转移到B-X或OI-X只能从B-X或I-X转移过来。如果用的是pytorch-crf它默认已经处理了这些约束出现非法序列说明你手动改了转移矩阵。7. 进阶技巧用预训练词向量和对抗训练再压榨几个点BiLSTM-CRF 从零训练在 5000 句数据上大概能到 0.85 F1但如果你想让效果再往上走有两个性价比很高的技巧。第一个是用领域语料预训练字向量。通用词向量比如在新闻语料上训的对医疗、法律这些垂直领域帮助有限因为「阿司匹林」「不可抗力」这些词在通用语料里出现太少。我一般会把业务方提供的无标注文本几万到几十万句拿去训一个 word2vec 或 fasttext然后把 embedding 矩阵初始化成这些向量训练时可以选择冻结或微调。实测在医疗 NER 上能涨 3 到 5 个点 F1。# 用 gensim 训练字向量并加载到 embedding 层 from gensim.models import Word2Vec import numpy as np # sentences 是字符列表的列表 w2v Word2Vec(sentences, vector_size128, window5, min_count2, workers4) embed_matrix np.random.normal(0, 0.1, (vocab_size, 128)) for ch, idx in vocab.items(): if ch in w2v.wv: embed_matrix[idx] w2v.wv[ch] model.encoder.embedding.weight.data.copy_(torch.tensor(embed_matrix)) # 如果数据量小于 3000 句建议冻结 embedding # model.encoder.embedding.weight.requires_grad False第二个是对抗训练FGM。在 embedding 层加一个微小的扰动让模型对输入噪声更鲁棒。实现起来就是在每次前向传播后对 embedding 的梯度方向加一个 epsilon 倍的扰动再算一次 loss 并累加。这个技巧在标注数据少的时候特别有用相当于免费做了一次数据增强。class FGM: def __init__(self, model, epsilon0.5): self.model model self.epsilon epsilon def attack(self): for name, param in self.model.named_parameters(): if embedding in name and param.requires_grad: self.backup param.data.clone() norm torch.norm(param.grad) if norm ! 0: r_at self.epsilon * param.grad / norm param.data.add_(r_at) def restore(self): for name, param in self.model.named_parameters(): if embedding in name and param.requires_grad: param.data self.backup用法是在正常反向传播后调用attack()再前向传播一次算对抗 loss累加后一起更新最后restore()恢复原始 embedding。epsilon 设 0.5 比较稳设 1.0 以上容易把模型带偏。最后一个习惯每次实验都固定随机种子把训练集、验证集、测试集的划分文件存下来。我吃过亏有一次调参调了两天最后发现是数据划分变了导致指标不可比白白浪费一个周末。现在我的项目里一定有一个seed_everything(42)函数在训练脚本第一行就调用。希望这些经验能帮你少走点弯路。本文还有配套的精品资源点击获取