PyTorch实战中文电子病历命名实体识别:BiLSTM-CRF全流程解析
发布时间:2026/9/24 22:32:40来源:尧图网络
简介这份资源是基于Python和PyTorch实现的中文电子病历命名实体识别项目主要面向自然语言处理初学者、医疗信息化开发人员以及需要落地NER任务的算法工程师。项目覆盖数据处理、模型搭建、训练评估等完整流程涉及BIO标注、评价指标、实体字典构建、序列标注问题等核心知识点。压缩包共2000个文件包含1994个txt病史文本、5个Python源码文件及1个README文档整体约11.22MB。txt文件为病历训练与测试样本py文件对应主程序、模型定义、数据管理、工具函数等模块结构清晰便于对照学习。目前已有114人学习下载。结合预览可见资源内含README说明和main.py、model.py、data_manager.py等脚本可快速掌握项目结构并直接运行复现适合通过实战方式理解中文电子病历NER流程为医疗文本信息抽取提供一套可借鉴的基线方案。1. 这个项目到底解决什么问题中文病历难在所有词都不按词典出牌电子病历里的文本是全院信息系统中最后一块没被结构化的硬骨头。医生写“患者右上腹持续性隐痛3天伴恶心无呕吐”这句话里“右上腹”“持续”“隐痛”“恶心”都是模型要认出来的实体可它们既不在标准词典里边界也模糊“右上腹”到底是部位还是症状修饰词得靠上下文判断。这个基于 Python PyTorch 实现的中文电子病历命名实体识别项目做的就是这件事把非结构化的病历原文拆成“症状—体征—检查—药物—诊断”这样的结构化实体序列供后续的检索、质控、科研筛选直接用。项目本身不复杂核心就三块一份标注好的病历数据、一个 BiLSTM-CRF 或类似结构的序列标注模型、一套能跑通训练和评估的 PyTorch 代码。适合的人群很明确正在做医疗 NLP 课题的学生、医院信息科要落地病历后结构化的工程师、以及想用 PyTorch 进入 NER 领域但不想从 toy 数据集开始的开发者。它解决的是一个非常现实的问题通用 NER 在新闻语料上跑得很好一换到病历上就全线崩盘因为病历里有大量英文缩写、检查值、否定表达和口语化描述需要专门处理。2. 环境准备谁在 PyTorch 版本上翻车谁就在项目上多花两天2.1 用 Anaconda 建隔离环境避开 Python 与 PyTorch 的版本打架这个项目跑起来的第一步不是写模型而是把环境锁死。PyTorch 的版本迭代非常快不同版本之间 API 有细微差异比如老版本里torch.nn.utils.rnn.pack_padded_sequence的行为在 1.9 之后就有调整。我一般会先用 Anaconda 创建独立的 conda 环境不给系统 Python 添乱。conda create -n ner_env python3.8 -y conda activate ner_env pip install torch1.13.1 --index-url https://download.pytorch.org/whl/cpu pip install numpy pandas scikit-learn tqdm这里指定 Python 3.8 而不是最新的 3.12原因很实际很多开源 NER 项目的代码基于 3.8 编写3.9 之后 dict 的默认行为变化会导致某些老代码报错。--index-url指定 CPU 版本是给没装 CUDA 的机器准备的如果你有 NVIDIA 显卡且要上 GPU 训练换成 cu117 或 cu118 的对应命令这个我们下一节细说。tqdm用来显示训练进度条跑长训练时能直观看到每个 epoch 的耗时。2.2 安装 PyTorch先想清楚 CPU 还是 GPU再动手# 查看本机 CUDA 版本 nvidia-smi # 如果输出 CUDA Version: 11.7就装对应版本 pip install torch1.13.1cu117 --index-url https://download.pytorch.org/whl/cu117GPU 不是必选项。这个项目的训练数据量通常在几万条以内病历 NER 用的模型参数量也不大CPU 上跑 10 个 epoch 大概需要半小时到一小时完全能接受。但如果你要调参或者后面接手更大的数据集GPU 的价值就体现出来了。装错版本是新手最常见的坑nvidia-smi显示的 CUDA 版本是驱动支持的最高版本不是当前环境的运行版本。比如驱动显示 CUDA 12.0你装 PyTorch 的 cu121 版本没问题但如果你本来就装好的是 CUDA 11.7 的 PyTorch那也能跑因为 PyTorch 内部自带 CUDA 运行库不依赖系统全局 CUDA。这里最容易踩的坑是装了 CPU 版然后在 GPU 机器上等半天检查方式很直接python -c import torch; print(torch.cuda.is_available())输出True说明 GPU 可用False就说明你装的是 CPU 版或者 CUDA 运行时和 PyTorch 版本不匹配。这个检查命令应该作为环境搭建完成后必做的第一步验证不要等到训练跑到一半才发现模型根本没上 GPU。2.3 验证环境跑一段最小 PyTorch 代码确认计算图正常python -c import torch from torch import nn x torch.randn(2, 3, 4) lstm nn.LSTM(input_size4, hidden_size8, batch_firstTrue, bidirectionalTrue) out, _ lstm(x) print(output shape:, out.shape) 这段代码验证三件事PyTorch 能否正常导入、LSTM 层能否前向传播、双向 LSTM 的输出维度是否符合预期。input_size4对应字向量的维度hidden_size8是隐藏单元数bidirectionalTrue表示双向。输出 shape 应该是(2, 3, 16)2 是 batch size3 是序列长度16 是 hidden_size 乘以 2双向。如果这里报错基本就是 PyTorch 安装有问题需要回头检查版本兼容性。3. 数据才是这个项目的命门清洗、BIO 标注与喂给模型的方式3.1 厘清实体类型症状、检查、药物、诊断是四个必分边界电子病历里的实体类型不同的标注规范差别很大但核心逃不开四类症状如“发热”“咳嗽”、检查如“血常规”“CT”、药物如“阿莫西林”“二甲双胍”、诊断如“肺炎”“2型糖尿病”。有些规范还会拆出手术、部位、时间等实体类别越多模型越难学所以对于一个刚开始上手的项目先把四类做扎实比追求全类别更重要。LABEL_TO_ID { O: 0, B-SYM: 1, I-SYM: 2, B-TEST: 3, I-TEST: 4, B-DRUG: 5, I-DRUG: 6, B-DIAG: 7, I-DIAG: 8, }这段映射是序列标注任务的地基。B 表示实体开头BeginI 表示实体内部InsideO 表示非实体Outside。比如“患者发热伴咳嗽”这句话“发热”是症状实体标注为B-SYM I-SYM“伴”是 O“咳嗽”是B-SYM I-SYM。为什么要区分 B 和 I因为两个相邻的同类型实体中间没有其他词时比如“发热咳嗽”如果不区分边界模型会把它们当成一个实体这就错了。LABEL_TO_ID把标签字符串转成数字 ID模型输出的 softmax 就是在这些 ID 上的概率分布。3.2 字符级切分与 BIO 标签序列中文 NER 的基准做法中文 NER 有两个流派字符级和词级。词级要先分词分词错误会直接传导到实体识别上误差累积很严重。字符级没有这个问题每个字独立成 token模型自己学字符之间的组合模式。这个项目采用字符级切分是更稳妥的选择因为病历里的表达实在太自由分词工具很难覆盖。def load_data(file_path): sentences, labels [], [] with open(file_path, r, encodingutf-8) as f: sent, lab [], [] for line in f: line line.strip() if not line: if sent: sentences.append(sent) labels.append(lab) sent, lab [], [] else: parts line.split() if len(parts) 2: char, tag parts[0], parts[1] sent.append(char) lab.append(tag) if sent: sentences.append(sent) labels.append(lab) return sentences, labels sentences, labels load_data(train.txt) print(sentences[0], labels[0])这个函数从标注文件读取数据。标注文件每行一个字符加一个标签用空格分隔空行代表一句话的结束。这是一个约定俗成的数据格式开源的中文 NER 数据集大多长这个样。注意判断if len(parts) 2这行它在过滤脏数据——有时候原文里有特殊符号会被拆开或者文件末尾有多余空格不加这个判断程序会直接崩。字符和标签对齐存放后续构造模型输入时可以直接索引。3.3 用 DataLoader 批量喂数据补齐 batch 内的长度对齐from torch.utils.data import Dataset, DataLoader from torch.nn.utils.rnn import pad_sequence class NERDataset(Dataset): def __init__(self, sentences, labels, char_to_id, label_to_id): self.sentences sentences self.labels labels self.char_to_id char_to_id self.label_to_id label_to_id def __len__(self): return len(self.sentences) def __getitem__(self, idx): sent [self.char_to_id.get(c, 1) for c in self.sentences[idx]] # 1 是 UNK lab [self.label_to_id[l] for l in self.labels[idx]] return torch.tensor(sent), torch.tensor(lab) def collate_fn(batch): sents, labels zip(*batch) sents pad_sequence(sents, batch_firstTrue, padding_value0) labels pad_sequence(labels, batch_firstTrue, padding_value0) return sents, labels train_loader DataLoader(NERDataset(sentences, labels, char_to_id, LABEL_TO_ID), batch_size32, shuffleTrue, collate_fncollate_fn)pad_sequence是 PyTorch 自带的对齐函数padding 值用 0。为什么 padding 要用 0因为char_to_id里 0 通常留给PAD这样后面模型做预测时可以把 padding 位置直接踢掉不参与损失计算。batch_size32是个起步值如果显存够大可以调到 64但不要一上来就开 128因为序列标注任务里每个样本长度不同padding 会造成计算浪费大 batch 未必对收敛有好处。4. 建模核心用 PyTorch 搭一个能用的 BiLSTM-CRF4.1 为什么是 BiLSTM CRF一个提供方向信息一个约束全局既然是 PyTorch 项目模型选型就得在效果和可解释性之间找平衡。BiLSTM-CRF 是序列标注的经典组合到现在也没过时。BiLSTM 的“双向”体现在每个位置的输出同时包含从左到右和从右到左的上下文信息比如判断“右侧”这个词向前看能看到“患者右侧肢体”向后看能看到“乏力3天”两个方向的信息汇聚起来才能判断“右侧”是部位描述还是症状本身。CRF 层解决的是另一类问题BiLSTM 的每个位置独立做分类输出的标签序列可能违反基本约束。现实中最典型的是B-SYM后面直接跟B-DRUG或者出现I-SYM开头这在 BIO 标注体系里是非法序列。CRF 层学习的是标签之间的转移概率它会给“B 后面跟同类型 I”打高分给“B-SYM 后面跟 B-DRUG”打低分从而从全局角度纠正局部预测的荒谬组合。4.2 搭建 BiLSTM-CRF 的 PyTorch 模型代码import torch import torch.nn as nn from torchcrf import CRF class BiLSTM_CRF(nn.Module): def __init__(self, vocab_size, tag_size, embedding_dim100, hidden_dim128): super().__init__() self.embedding nn.Embedding(vocab_size, embedding_dim, padding_idx0) self.lstm nn.LSTM(embedding_dim, hidden_dim // 2, batch_firstTrue, bidirectionalTrue, num_layers1) self.dropout nn.Dropout(0.5) self.fc nn.Linear(hidden_dim, tag_size) self.crf CRF(tag_size, batch_firstTrue) def forward(self, x, mask): emb self.embedding(x) lstm_out, _ self.lstm(emb) lstm_out self.dropout(lstm_out) logits self.fc(lstm_out) return logits def loss(self, x, y, mask): logits self.forward(x, mask) return -self.crf(logits, y, mask, reductionmean) def predict(self, x, mask): logits self.forward(x, mask) return self.crf.decode(logits, mask)num_layers1是大多数 NER 任务的默认选择一层双向 LSTM 已经能捕捉足够的上下文信息加深到两层收益不大反而过拟合风险上升、训练时间翻倍。hidden_dim128是左右两个方向各 64 维拼接后的结果所以传给nn.LSTM的hidden_size要写hidden_dim // 2否则输出维度对不上。torchcrf是一个独立的第三方库封装了 CRF 的前向计算、解码和损失函数没必要自己实现 Viterbi 解码直接调用crf.decode即可。mask的作用是告诉 CRF 哪些位置是有效 token、哪些是 padding避免 padding 参与转移概率计算。4.3 三个直接影响效果的参数embedding 维度、hidden 维度、dropout这组超参数直接决定了模型能不能在病历数据上收敛。embedding_dim100是起步值。如果你用随机初始化的字向量100 维足够如果要用预训练的腾讯词向量或自己训练的字向量维度以预训练向量的维度为准一般是 200 或 300这时候要把embedding_dim改成对应值。hidden_dim控制模型的记忆容量。128 对病历这种小规模标注数据是合适的如果数据量超过 5 万条可以加到 256但不要盲目加大因为病历 NER 的数据集通常只有几千到几万条hidden 太大直接过拟合训练集 F1 九十多、验证集五六十这就是典型症状。dropout0.5是个经验值。0.3 到 0.5 之间算安全区间0.5 在数据量小的时候效果最稳。Dropout 只在训练时生效推理时自动关闭PyTorch 的nn.Dropout已经帮你处理好了。如果你发现训练损失下降很慢可以把 dropout 降到 0.3 试试但要注意这往往意味着你可能需要更多的训练轮次来达到同样的收敛效果。5. 训练、评估与避坑显存溢出、OOV 和标签不平衡5.1 训练循环与早停让模型在验证集上收敛而不是死记硬背optimizer torch.optim.Adam(model.parameters(), lr1e-3) best_f1 0.0 patience 3 bad_counter 0 for epoch in range(20): model.train() total_loss 0.0 for sents, labels in train_loader: mask (sents ! 0).float() loss model.loss(sents, labels, mask) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0) optimizer.step() total_loss loss.item() val_f1 evaluate(model, val_loader) print(fEpoch {epoch1}, Loss: {total_loss:.4f}, Val F1: {val_f1:.4f}) if val_f1 best_f1: best_f1 val_f1 torch.save(model.state_dict(), best_model.pt) bad_counter 0 else: bad_counter 1 if bad_counter patience: print(Early stopping!) break学习率1e-3配合 Adam 是 NER 任务的标配不需要花太多精力调。clip_grad_norm_是必加的一行梯度裁剪的值设在 5.0。为什么必须加因为 CRF 的损失函数是负对数似然在训练初期可能出现极端大的梯度一次更新就能把参数打飞后面再也回不来了。加了裁剪之后梯度向量的范数被限制在 5.0 以内训练稳定很多。best_model.pt保存的是验证集 F1 最高的模型不是最后一个 epoch 的模型这是避免过拟合的最后一道防线。patience3表示连续三个 epoch 验证集 F1 没有提升就停止训练通常 20 个 epoch 内能收敛多了就是浪费时间。5.2 评估指标只看 F1电子病历 NER 的准确率没有任何参考意义def evaluate(model, data_loader): model.eval() submit_true, submit_pred [], [] with torch.no_grad(): for sents, labels in data_loader: mask (sents ! 0).float() preds model.predict(sents, mask) for i in range(len(sents)): length mask[i].sum().int().item() submit_true.extend(labels[i][:length].tolist()) submit_pred.extend(preds[i]) return f1_score(submit_true, submit_pred, averagemicro)f1_score从 sklearn.metrics 导入用averagemicro。为什么用 micro 而不是 macro 或 weightedmicro-F1 把所有位置上的预测结果汇总成大混淆矩阵再算 F1它更关注出现频率高的实体类型是否预测正确。在病历 NER 里症状和诊断的标注频率远高于药物和检查如果某些类型样本极少macro-F1 会被这些类型拉得很低不能反映真实水平。如果你要单独看某类实体的效果比如“药物”识别得怎么样就改成labels[5, 6]做二分类评估但作为整体指标micro-F1 是唯一有参考意义的。5.3 四个踩坑记录从显存溢出到标签不平衡坑一显存溢出OOM。现象是训练跑到第二个 epoch 直接报CUDA out of memory。原因是 batch_size 设得太大或者 padding 导致序列过长。病历文本短则几个字长则几百字padding 到最长的那个样本后短样本的位置全在算无意义的 softmax。解决方法是把 batch_size 降到 16 甚至 8同时检查有没有特别长的异常文本比如整段复制粘贴的大病历在数据预处理阶段直接截断到 256 字以内。坑二预测结果里有I-SYM开头。现象是模型输出[I-SYM, I-SYM, O]这种明显非法的序列。原因是用 softmax argmax做预测没有加 CRF 层。BiLSTM 每个位置独立取概率最大的标签它不知道这个标签在序列里是否合法。解决方法是训练和预测都要走 CRF 的decode路径不要直接对nn.Linear的输出做 argmax。如果你发现预测结果有这种问题优先检查预测代码是不是写错了。坑三标注文件里全角空格。现象是读取数据时报ValueError: too many values to unpack。原因是文本编辑软件把半角空格自动转成了全角str.split()按空格切分时切不干净。解决方法是加载数据后立刻做一次检查if len(parts) 2这个判断如果频繁被跳过说明分隔符有问题用line.replace( , )把全角空格统一替换成半角再切分。坑四某些实体类型一个都预测不出来。现象是打印每个类别的 F1“药物”的 precision 和 recall 全是 0。原因是数据极度不平衡“药物”实体占比可能只有 5%模型学到了把所有预测都偏到症状和诊断上。解决方法是给损失函数加类别权重或者直接在 trainer 里设置class_weight更简单粗暴的方法是检查训练集看看是不是某类标注本身就太少几百条起步才能让模型看到足够多的正例。数据量不够时多跑几个 epoch 只会让模型更偏向大头类别不是调参能解决的问题。6. 从“能跑”到“能用”BERT 迁移、词典增强与落地取舍BiLSTM-CRF 是这个项目的基线但它给你的天花板是有限的。如果你的电子病历数据量够大、标注规范且算力充足把 LSTM 换成 BERT 是提升最明显的路径。做法很简单用transformers加载bert-base-chinese取出最后一层隐层输出作为logits的输入CRF 层保留不动。这就是经典的 Bert-BiLSTM-CRF但实践下来你会发现去掉 BiLSTM 直接 Bert-CRF 的效果差异很小因为 BERT 已经通过 12 层 Transformer 编码了足够的上下文信息LSTM 再叠上去只是冗余计算。PyTorch 生态的好处是切换模型时只需要改动forward部分数据管线和评估代码完全不用动。词典增强是把领域知识灌进模型的一个轻量手段。电子病历里的药物名、检查缩写字面上看不出规律“阿莫西林克拉维酸钾”这个词 CRF 学多少次都不如直接告诉模型它是一个整体。常见做法是做一个自定义词典在数据预处理阶段把词典命中的连续片段强制标注成对应实体相当于在输入层加了一条先验规则。这会带来微小的精确率提升但代价是泛化能力下降——新药名、新缩写进不来。我的建议是词典只用于高频、稳定、不会变化的检查项如“血常规”“尿常规”不要用在药物和诊断上。部署到业务系统时要果断砍功能。病历 NER 在离线批量处理场景最有价值比如科研数据提取、病历质控、随访人群筛选这些场景对延迟不敏感可以拿离线脚本跑。如果要在在线系统里做实时识别就不要用 CRF 解码了Viterbi 在长句上有性能损耗直接换成纯 BERT 加 softmax 就能在 GPU 上轻松做到毫秒级返回损失的几个点 F1 对大部分应用场景完全可接受。模型导出用torch.jit.trace或 ONNX后者在 CPU 上还能再快一点。这个项目值得做但不要一口吃成胖子——先跑通基线再往里面加工程化能力这是我认为最不容易翻车的路径。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网