简介基于BiLSTM-CRF网络的中文电子病历命名实体识别项目面向自然语言处理初学者、医学信息抽取研究人员以及计算机、数学、电子信息等专业的课程设计和毕业设计学生。压缩包共999个文件约84.55MB核心包含17个Python源码文件另有798个TXT语料文件及模型训练生成的data、meta、index、checkpoint等数据文件覆盖训练集、测试集、标注结果和评测指标目录结构清晰。源码可直接运行并配有项目说明文档便于理解从语料预处理、模型搭建到训练评估的完整流程。项目特别适合作为课程作业或毕设的参考实现已有127人学习下载。通过研读代码和实验输出可深入掌握BiLSTM-CRF在中文医疗文本实体识别中的实际应用包括标签体系设计、序列标注策略及结果分析方法为后续拓展研究提供扎实基础。1. 中文电子病历里的命名实体识别为什么 BiLSTM-CRF 到现在仍是首选你手里这份项目标题写着“BiLSTM-CRF网络的中文电子病历命名实体识别python源码项目说明.zip”它解决的问题很具体从一段病人主诉、现病史或出院小结里把“症状、体征、疾病诊断、检查项目、药物、手术、时间”这类实体一个个捞出来。直接读病历的人能看出来哪里是症状但机器要拿结构化字段去做统计、质控或辅助诊断就得先过命名实体识别NER这一关。纯规则匹配在专科病历上能到八成准确率换个科室、换个表述方式马上就掉这就是为什么需要一个能记住上下文、又能约束标签合法顺序的模型。BiLSTM-CRF 这套组合在中文医疗文本上有一个明显优势它不需要 GPU 集群一张普通显卡甚至 CPU 都能跑同时 CRF 层保证了“B-症状 后面不可能直接接 I-药物”这样的硬约束这对病历里大量连续出现的症状描述尤其重要。适合你有四类人一是要做科研对照实验的学生二是医院信息科或创业团队要做临床数据结构化三是想转行 NLP 的工程师四是刚拿到脱敏病历数据、需要快速出基线模型的算法人员。这篇笔记沿着“数据准备 → 模型原理 → 训练调参 → 踩坑 → 验证”的顺序写你能直接照着搭出一套可复现的流程。2. 从病历文本到训练样本BIO 标注、字典构建与数据切分2.1 病历文本长什么样拿到原始数据第一件事中文电子病历和新闻、微博完全不是一个物种。它有三个让你预处理时想骂人的特点口语和书面语混杂、大量简写、时间表达极不规范。我拿到一份真实出院小结时第一眼看到的是“患者因咳嗽、咳痰2周入院查体双肺呼吸音粗可及湿啰音予头孢呋辛抗感染治疗”。这里“咳嗽”“咳痰”“湿啰音”是症状实体“头孢呋辛”是药物但“2周”也得作为时间实体抽出来。麻烦的是同一份病历里可能既有“2周”又有“两周”既有“头孢”又有“头孢呋辛钠”。所以第一步不是写模型而是先建立一份实体类型清单。常见做法是七类症状sym、体征sign、疾病诊断dis、检查check、治疗treatment、药物drug、时间time。实体类型定得太细标注员崩溃模型也学不动定得太粗下游结构化用不了。我见过一个项目把“症状”和“体征”合并成“表现”结果医生不认可后来还是拆开了。数据来源也好解决医院内部的脱敏病历最理想没有的话用公开中文医学 NER 数据集如 CMeEE 这类评测数据先跑通流程。注意一个前置检查文本里不能有半角英文逗号和括号混进中文句子否则字典里会多出一堆低频符号。我一般会把原始 txt 按段落切分每段不超过 150 个字符超出就按句号、分号断开。样例按以下格式存成 JSON每行一个病例{text: 患者因咳嗽、咳痰2周入院, tags: [O, O, B-sym, I-sym, O, B-sym, I-sym, B-time, I-time]}这里“咳嗽”标成 B-sym、I-sym“咳痰”也是 B-sym、I-sym“2周”是 B-time、I-time其余都是 O。普通句子和实体之间用这个 BIO 序列建立一一对应关系。你可以把这个 JSON 文件随意命名为 train.json、dev.json、test.json后面训练脚本只认这个格式。2.2 BIO 标注与字典构建把中文拆成模型能吃的字中文 NER 的默认做法是字级别标注而不是词级别。原因很简单分词错误会直接传导给实体边界比如“鼻窦炎”被切成“鼻窦/炎”实体边界就废了。而按字标注“鼻 窦 炎”三个字分别对应 B-dis、I-dis、I-dis边界稳定分词问题彻底绕开。实际项目中只有极少数场景因为要拼词向量才用词级别Baseline 一律字级别起步。下面这段代码把 JSON 读进来构建字到索引的映射并统计标签集合# load_data.py import json from collections import Counter def load_json_data(path): 读取每行一个 {text: ..., tags: [...]} 的标注文件 samples [] with open(path, r, encodingutf-8) as f: for line in f: line line.strip() if not line: continue obj json.loads(line) chars list(obj[text]) tags obj[tags] # 保险起见长度不一致就丢弃避免训练时崩 if len(chars) ! len(tags): print(f警告: 长度不一致已跳过 - {obj[text][:20]}) continue samples.append((chars, tags)) return samples def build_vocab(all_samples, min_freq1): 统计字频构建 char2idx 字典PAD 和 UNK 固定占前两位 counter Counter() for chars, _ in all_samples: counter.update(chars) char2idx {PAD: 0, UNK: 1} for char, freq in counter.items(): if freq min_freq: char2idx[char] len(char2idx) return char2idx def build_tag2idx(all_samples): 从标注里收集所有标签转成 id注意 O 要排在最前面 tag_set set() for _, tags in all_samples: tag_set.update(tags) tag2idx {O: 0} for tag in sorted(tag_set): if tag ! O: tag2idx[tag] len(tag2idx) return tag2idx逻辑说明load_json_data 把文本按字拆成 list因为中文没有空格天然分字list() 直接逐字拆分是最稳妥的。build_vocab 的 min_freq 参数用来卡掉只出现一次的罕见字把这些字全部折叠成UNK避免模型去硬记某个错别字。参数说明min_freq1 表示至少出现一次就保留这在病历 5 万字符时是常用设置如果数据量到 20 万字符以上我会把 min_freq 提到 2降低字典体积。build_tag2idx 里 O 必须占 id0因为后面 CRF 层对数字 id 没有硬编码要求但 tag 索引 0 会被用作默认填充值让 O 占 0 是最省事的。然后要把标注数据变成 PyTorch 能吃的张量并按 batch 做 padding# dataset.py import torch from torch.utils.data import Dataset class NerDataset(Dataset): def __init__(self, samples, char2idx, tag2idx, max_len150): self.data [] for chars, tags in samples: chars chars[:max_len] tags tags[:max_len] input_ids [char2idx.get(c, char2idx[UNK]) for c in chars] label_ids [tag2idx[t] for t in tags] self.data.append((input_ids, label_ids)) # 留给调用方取长度的属性 self.max_len max_len def __len__(self): return len(self.data) def __getitem__(self, idx): input_ids, label_ids self.data[idx] return torch.tensor(input_ids, dtypetorch.long), torch.tensor(label_ids, dtypetorch.long)注意这里的截断策略max_len150 是因为 BiLSTM 在长序列上反向传播慢而病历一段通常不超过 150 字。如果你真想处理跨段的上下文应该从段落层面切句子而不是把 max_len 调到 500——长度一上去CRF 解码也会明显变慢。3. 模型结构拆解Embedding、双向 LSTM、CRF 层各司其职3.1 为什么是 BiLSTM 而不是纯 BERT成本与可解释性的权衡我见过不少人在拿到这个标题后第一反应是“直接上 BERTBiLSTMCRF”。这当然可以但你要清楚你正在下载的是什么BERT 预训练模型动辄几百 MB微调时显存需求按倍数涨推理速度更是慢一个量级。BiLSTM-CRF 的出发点是用训练好的字向量或随机初始化的 Embedding 层在 CPU 上也能把一条 100 字的病历在几十毫秒内跑完这对医院内网的部署环境是实打实的优势。从效果端看BERT 在实体边界上的优势主要来自预训练阶段对上下文语义的记忆但医疗文本有很多专有名词是预训练语料里没有的比如“冠脉CT”、“房颤射频消融术”。BiLSTM 靠反向传播照样能把这类字序列的边界模式记住只不过需要更多训练语料。在几千条标注的病历上做对比差距通常在 23 个点之内如果标注数据不足 1000 条BERT 反而更容易过拟合。CRF 层存在的意义是给标签序列加约束。没有 CRFBiLSTM 的输出在每一个位置独立取最大概率标签会出现“B-sym 后面直接跟 I-drug”这种物理上不可能的跳跃。CRF 把标签转移也变成可学习的参数在解码时用维特比搜索全局最优序列相当于学了一套 BIO 语法规则。3.2 模型代码Embedding-LSTM-线性发射-CRF 一条线完整实现写出来大约 60 行 PyTorch 代码核心部分如下# model.py import torch import torch.nn as nn from torchcrf import CRF class BiLSTM_CRF(nn.Module): def __init__(self, vocab_size, tag_size, embedding_dim128, hidden_dim256, dropout0.3): super().__init__() self.embedding nn.Embedding(vocab_size, embedding_dim, padding_idx0) # hidden_dim 被双向拆成两份每份 hidden_dim // 2 self.bilstm nn.LSTM( input_sizeembedding_dim, hidden_sizehidden_dim // 2, num_layers2, batch_firstTrue, bidirectionalTrue, dropoutdropout if num_layers_placeholder 1 else 0.0, ) self.dropout nn.Dropout(dropout) # 将 BiLSTM 输出映射到每个标签的发射分数 self.fc nn.Linear(hidden_dim, tag_size) # torchcrf 提供对数似然损失和维特比解码 self.crf CRF(tag_size, batch_firstTrue) def forward(self, input_ids, mask, tagsNone): # 词向量: (batch, seq_len, embedding_dim) emb self.dropout(self.embedding(input_ids)) # BiLSTM: (batch, seq_len, hidden_dim) lstm_out, _ self.bilstm(emb) lstm_out self.dropout(lstm_out) # 发射分数: (batch, seq_len, tag_size) emissions self.fc(lstm_out) if tags is not None: # 训练阶段返回负对数似然损失 return -self.crf(emissions, tags, maskmask, reductionmean) # 预测阶段直接用维特比解码, 返回每个 batch 的最优标签序列 return self.crf.decode(emissions, maskmask)逻辑说明embedding 层的 padding_idx0 保证PAD位置不参与更新避免填充符被学成一个有意义的向量。BiLSTM 的 hidden_size 写的是 hidden_dim // 2是因为 LSTM 设置为 bidirectionalTrue 后两个方向的输出拼接后维度才是 hidden_dim这样接 Linear 层时尺寸刚好对上。num_layers2 让每个方向各堆两层病历这类短文本两层够用堆到四层收益很小且训练变慢。参数说明embedding_dim128 是字向量常见起点如果加载预训练字向量例如百度的中文词向量或腾讯词向量这个值要去对齐预训练维度一般是 200 或 300。hidden_dim256 在病历数据 300010000 条时是性价比最高的区间隐藏单元减少到 128 会掉 12 个点增加到 512 训练时间翻倍但效果几乎不动。dropout0.3 是 LSTM 层的标准值太小容易过拟合太大在数据量少时直接欠拟合。注意 torchcrf 这个库是我验证过最顺手的 CRF 封装它内部用前向后向算法算配分函数并用维特比解码。你需要注意 mask 的构造方式mask 必须是布尔型张量True 表示有效位置False 表示 padding 位置。常见翻车是把 mask 用了 0/1 整型张量torchcrf 不会报错但结果完全不对。3.3 损失函数与解码NLL 和维特比模型里 CRF 返回的是负对数似然损失这是 CRF 的标准训练目标让正确标签序列的概率最大。所谓配分函数partition function是模型在所有可能标签序列上的概率之和CRF 用前向算法高效求和避免了穷举指数级序列。解码阶段用维特比算法。维特比的核心思想是动态规划定义 dp[i][j] 表示在第 i 个位置、以标签 j 结尾的所有路径中得分最高的一条转移时把当前发射分数和前一步的转移分数相加逐位置推进。因为转移矩阵规模是 tag_size × tag_size解一条 150 字序列的速度是微秒级不需要担心性能。挑选预测结果时我建议保留每个位置的得分或至少保留 top3 候选这在后面做坏例分析时非常有用。纯返回整数标签序列虽然好看但你看不出模型在“症状”和“体征”之间纠结的程度。4. 训练与调参命令行入口、学习率、batch size 与早停4.1 训练入口命令行参数按可复现方式组织训练脚本我习惯于做成一个 main.py所有超参数通过 argparse 从命令行传入这样每个实验的输出目录、运行命令都留痕复现实验时不会忘记当时用的什么设置。# main.py import argparse import torch from torch.utils.data import DataLoader from load_data import load_json_data, build_vocab, build_tag2idx from dataset import NerDataset, pad_collate from model import BiLSTM_CRF def parse_args(): parser argparse.ArgumentParser(descriptionBiLSTM-CRF 中文电子病历 NER) parser.add_argument(--train_data, typestr, defaultdata/train.json) parser.add_argument(--dev_data, typestr, defaultdata/dev.json) parser.add_argument(--epochs, typeint, default50) parser.add_argument(--batch_size, typeint, default32) parser.add_argument(--lr, typefloat, default2e-3) parser.add_argument(--embedding_dim, typeint, default128) parser.add_argument(--hidden_dim, typeint, default256) parser.add_argument(--max_len, typeint, default150) parser.add_argument(--clip, typefloat, default5.0) parser.add_argument(--patience, typeint, default5) parser.add_argument(--seed, typeint, default42) parser.add_argument(--device, typestr, defaultcuda if torch.cuda.is_available() else cpu) return parser.parse_args() if __name__ __main__: args parse_args() # 固定随机种子, 保证实验可复现 torch.manual_seed(args.seed) # 加载数据 train_samples load_json_data(args.train_data) dev_samples load_json_data(args.dev_data) char2idx build_vocab(train_samples) tag2idx build_tag2idx(train_samples dev_samples) # 构造 DataLoader train_ds NerDataset(train_samples, char2idx, tag2idx, args.max_len) dev_ds NerDataset(dev_samples, char2idx, tag2idx, args.max_len) train_loader DataLoader(train_ds, batch_sizeargs.batch_size, shuffleTrue, collate_fnpad_collate) dev_loader DataLoader(dev_ds, batch_sizeargs.batch_size, shuffleFalse, collate_fnpad_collate) model BiLSTM_CRF(len(char2idx), len(tag2idx), args.embedding_dim, args.hidden_dim) model.to(args.device) print(f字典大小: {len(char2idx)}, 标签数: {len(tag2idx)}) print(f参数: {sum(p.numel() for p in model.parameters()) / 1e6:.2f}M)逻辑说明DataLoader 的 collate_fn 是关键不同病例长度不同pad_collate 要把每个 batch 内的样本统一到同一长度并同时构造出给 CRF 用的 bool mask。训练时用 shuffleTrue 打乱样本顺序验证时不需要。参数说明lr2e-3 是随机初始化 BiLSTM 的常用起点。如果你后面替换成 BERT 作为编码器学习率要降到 2e-5 量级因为 BERT 底层参数已经预训练用大学习率会把学好的语义破坏掉。patience5 表示验证集 F1 连续 5 个 epoch 不涨就早停这个值在病历数据上比较稳太小的 patience 容易被训练初期的小波动骗到。pad_collate 的完整代码在这里# dataset.py 内新增 part def pad_collate(batch): 把一个 batch 内的 input_ids / label_ids 补齐到相同长度, 并生成 mask max_len max(len(ids) for ids, _ in batch) input_ids, label_ids, masks [], [], [] for ids, lids in batch: padding_len max_len - len(ids) input_ids.append(ids [0] * padding_len) # 0 对应 PAD label_ids.append(lids [-100] * padding_len) # -100 位置不参与损失计算 masks.append([True] * len(ids) [False] * padding_len) return ( torch.tensor(input_ids, dtypetorch.long), torch.tensor(label_ids, dtypetorch.long), torch.tensor(masks, dtypetorch.bool), )这里 label padding 用 -100 而不是 0是为了配合 nn.CrossEntropyLoss 的默认 ignore_index-100。如果你自定义了损失记得把 -100 排除。4.2 调参逻辑与训练循环梯度裁剪是 BiLSTM 的救命稻草训练循环本身不长但有两个细节容易忽略每个 batch 都要做梯度裁剪损失要取均值。BiLSTM 在长序列的梯度范数经常突然暴涨不裁剪的话 loss 会在某个 epoch 直接变成 NaN。def train_one_epoch(model, loader, optimizer, clip, device): model.train() total_loss, total_tokens 0.0, 0 for input_ids, label_ids, mask in loader: input_ids input_ids.to(device) label_ids label_ids.to(device) mask mask.to(device) optimizer.zero_grad() loss model(input_ids, mask, tagslabel_ids) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), clip) optimizer.step() total_loss loss.item() * mask.sum().item() total_tokens mask.sum().item() return total_loss / total_tokens验证阶段评估 F1 前要先预测出标签序列再和真实标签对齐。评估时注意不要用模型输出的 __getitem__ 结果因为 padding 的 -100 标签会和真实标签混在一起。一个完整的训练命令大概长这样python main.py \ --train_data data/train.json \ --dev_data data/dev.json \ --epochs 50 \ --batch_size 32 \ --lr 2e-3 \ --hidden_dim 256 \ --clip 5.0 \ --patience 5参数说明batch_size32 在 8G 显存上绰绰有余。如果你的数据全是 150 字的长段batch_size 开到 64 会吃满显存但收益不大。clip5.0 的意思是梯度范数超过 5 就缩放回 5这个值在 35 之间调整太大起不到作用太小模型学不动。4.3 看训练日志loss 掉到多少才算正常训练要看三条曲线训练 loss、验证 loss、验证 F1。随机初始化 lr2e-3 时第一个 epoch 的 CRF 平均负对数似然一般在 2 到 3 之间随着训练降到 0.2 左右验证 F1 在 70% 上下浮动。如果你看到第一个 epoch loss 就在 0.1 以下基本可以确定标签或者 mask 出了问题——模型在作弊比如把所有位置都预测成 OCRF 压根没学到实体。打印日志时我习惯用标准格式训练轮次、训练 loss、验证 loss、验证 F1以及当前最佳模型 checkpoint 路径。早停机制触发的条件是 F1 连续 patience 轮不涨这时保存的最佳模型就是最后要用的那一份而不是最后一轮的模型。5. 避坑与排查标签错位、CRF 学歪、时间实体消失和显存翻车5.1 标签序列与文本长度不一致训练时直接静默丢弃现象load_json_data 打了“长度不一致已跳过”的警告最后训练数据比文件里少了好几百条F1 上不去。原因标注脚本在处理标点时把“”和“”算成了两个字符但生成 tags 时算成了一个或者有人手动改过文本标签没跟着同步。解决训练前写一段校验脚本逐条检查 len(text) len(tags)。一旦发现不一致不要尝试自动对齐直接把这条例子挑出来让人工重新标。自动对齐就算把这轮救回来下一轮还是会出错。5.2 CRF 输出出现 B-症状 后接 B-药物转移矩阵学错了现象训练 loss 下降正常F1 也有 70 多但人工检查预测结果时发现模型把一句话里的相邻实体并成了一个奇怪的序列比如“B-sym B-drug I-drug”。原因这是典型的“CRF 约束没生效”问题。检查 mask 或 CRF 内部实现是否用了默认的 allow_all 转移另一个原因是部分标签 B、I 在训练集中出现次数太少转移矩阵的对应行没有学到有效约束。解决先确认你用的 CRF 版本是否真的调用了 decode 而不是 argmax。然后在训练代码中加入显式的非法转移惩罚把 CRF 的 transition 矩阵初始化为 -100 的偏好矩阵让 B 后不能跟 I。更简单的做法是清理数据里标注不一致的实体——比如某条例子“B-sym I-drug”本身标错了模型就是把你的垃圾标注当作语法记住了。5.3 病历里的时间实体几乎全被识别成 O现象症状、药物识别尚可唯独“2周”“入院第3天”这种时间表达全部漏掉F1 各项里 time 类别只有 20%。原因时间实体在训练集里出现次数太少和症状、药物相比属于“少数类”。BiLSTM 学不到低频类别的边界CRF 转移矩阵里 time 相关的转移概率也被多数类淹没。解决三类办法同时用。第一样本增强把时间表达用正则替换成同义写法比如“2周”替换成“两周”“14天”生成更多带标注样本。第二检查标注一致性很多标注员会把“入院后第3天”整个标成时间有些人只标“第3天”这种不一致让 CRF 学到混乱的边界。第三调整损失权重给 time 类别的发射分数乘一个权重系数让模型更重视这个类。5.4 GPU 显存溢出 OOM现象训练跑了几个 epoch 后报 CUDA out of memory单词 size 不大但 batch 跑不下。原因max_len150 时BiLSTM 的时间步是 150batch_size32 其实还好但 padding 造成的浪费远比想象大——一个 batch 里最长的样本是 150 字其他平均只有 60 字的样本全被 padding 到 150显存消耗接近三倍。解决在 pad_collate 里对每个 batch 按本 batch 的实际最大长度 padding而不是按全局 max_len。这个我在 4.1 的代码里已经做了很多开源项目图省事在 dataset 层全局 padding直接导致显存浪费。还有一招是按长度分桶bucket把长度相近的样本放进同一个 batch反传到 20 个 epoch 能省近一半训练时间。另外关掉梯度 checkpoint 或用混合精度torch.cuda.amp也能压一截显存但项目说明里没提的话不建议动。6. 验证与进阶别只看整体 F1逐类别拆开才是医疗场景的关键训练结束后最忌讳的就是看一眼整体 F1 就收工。病历实体里每类的难度完全不一样症状类药物类能上 85时间类可能只有 60整体值会掩盖局部问题。你需要按实体类别逐项评估。我常用 seqeval 这个库它对 BIO 标签做实体级别的精确匹配比逐 token 的 accuracy 更有参考价值。# evaluate.py from seqeval.metrics import classification_report, f1_score from seqeval.scheme import IOB2 def evaluate(model, dataloader, id2tag, device): model.eval() y_true, y_pred [], [] with torch.no_grad(): for input_ids, label_ids, mask in dataloader: input_ids input_ids.to(device) mask mask.to(device) preds model(input_ids, mask) # 每个样本是一个标签 id list for i, pred in enumerate(preds): # 去掉 padding, 只保留有效位置 valid_len mask[i].sum().item() true_tags [id2tag[t] for t in label_ids[i][:valid_len].tolist()] pred_tags [id2tag[t] for t in pred[:valid_len]] y_true.append(true_tags) y_pred.append(pred_tags) report classification_report(y_true, y_pred, schemeIOB2, modestrict) print(report) return f1_score(y_true, y_pred, schemeIOB2, modestrict)输出的 classification_report 会按实体类型分别给出 precision、recall、F1你一眼就能看出哪类实体在拖后腿。针对低类别再用差分方式定位问题把预测结果和 gold 逐条并排打印找三条典型的错例看模型是把边界扩大了一个字还是完全识别错了类型。例如模型把“咳嗽、咳痰”整体标成一个症状实体而 gold 标成两个这是边界型错误把“肺部CT”标成治疗而不是检查这是分类型错误。这两类错误的修法完全不同——边界型要检查标注规范和 CRF 约束分类型要看上下文的特征够不够。我自己的习惯是训练完先跑一次 bad case 分析把 20 条预测错的最严重的样例打印到一个 txt 里逐条看模型到底是哪里犯了傻。做这个分析花的半小时通常比盲目调参一周更有效。如果你是第一次跑这个项目建议先用 500 条数据把整个流程跑通再决定要不要上全量数据。整个方案值不值得投入取决于你的业务目标如果只是做科研基线BiLSTM-CRF 提供的这个可复现流程已经完全够用如果要上生产环境你验证完后大概率会把编码器换成了 BERT——那份经验正好来自你现在踩过的每一个坑。希望帮到你。本文还有配套的精品资源点击获取