资讯动态

中文电子病历NER实战:基于PyTorch的BiLSTM+CRF完整工程

发布时间:2026/9/28 13:35:32 来源:尧图企业网站定制
简介基于Python与PyTorch构建的中文电子病历命名实体识别完整项目面向自然语言处理学习者和医疗信息处理开发人员解决从非结构化电子病历文本中自动识别并提取医疗实体的问题。系统覆盖中文分词、文本预处理、BIO序列标注、深度学习模型搭建训练与评估等关键环节并通过实体字典构建增强专业术语识别能力直观呈现NER在医疗领域的落地路径。资源包共包含2000个文件压缩后大小11.22MB。其中1994个txt文件为电子病历病史特点的原始文本数据5个py文件为项目源代码涵盖主程序、模型定义、数据管理、工具函数等模块另有README说明文档辅助快速上手。txt数据量充足便于训练和验证模型效果源代码模块化结构清晰易于在此基础上扩展、替换模型或适配其他医学语料。目前已有114人浏览学习。通过源码与配套数据可完整复现命名实体识别实验流程掌握PyTorch搭建深度学习模型的思路积累医疗文本预处理与标注的实战经验对开展医疗NLP方向研究、课程设计或实际项目开发具有直接参考价值。1. 中文电子病历命名实体识别用 Python 加 PyTorch 跑通医疗 NER 的完整工程如果让你从一份电子病历里手动摘出症状、体征、疾病名你会怎么下手靠关键词匹配显然只能解决固定词表换一种表述方式就从规则里漏掉。基于 Python 和 PyTorch 实现的这套中文电子病历命名实体识别项目把整条识别链路做成了可运行的工程形态几十份真实病史原始文本、模块化拆分的 main.py / model.py / data_manager.py / utils.py / transfer_data.py数据预处理到模型训练再到指标评估每一步都有源码对照。对刚学完 PyTorch 基础、想做 NLP 方向实战项目的初学者这是一个在真实医疗语料上练手的项目对需要快速搭 NER 基线的从业者项目结构也足够直接。解压后不需要再花时间找数据按模块顺序读代码、执行训练脚本整条流程就能动起来。2. 项目结构拆解病史原始文本怎么变成可训练的数据集2.1 文件清单与模块职责先读哪几个文件项目根目录的文件不多刚好覆盖一个标准 NLP 训练工程的核心部分文件职责README.md项目说明、运行步骤与环境要求main.py训练入口串联数据加载、模型构建、训练与评估model.py网络结构定义包含词嵌入、序列编码层、标签解码层data_manager.py原始文本读取、字级别预处理、批次数据构造utils.py标签映射、预测解码、评估指标计算等工具transfer_data.py原始病历文本转换成语料标签格式病史特点-49.txt、19.txt、102.txt、269.txt患者病史原始文本样例我建议的阅读顺序是 transfer_data.py 优先于 model.py。原因很直接数据怎么组织决定了模型输入输出怎么设计。如果先读 model.py看到 embedding 输入维度、序列长度这些概念还得反复回翻数据部分先把数据链路摸清楚模型部分自然顺下来。data_manager.py 的重点在对外暴露的数据接口常见设计是返回一个 Dataset 对象或样本列表每个样本包含三样东西字符索引序列、标签索引序列、有效长度。有效长度这个字段很关键后续模型计算 loss 和解码标签都要依赖它没有这个字段padding 的部分就会把整个训练污染掉。transfer_data.py 做的事是把原始 txt 病历变成严格的 BIO 标签序列。打开 病史特点-49.txt 能看到文本里症状描述、时间信息、检查结果混杂还夹着空格和换行。转换脚本必须让字符和标签一一对应这个对齐过程最容易出问题后面第 4 章会专门讲。2.2 BIO 标注在病历文本上的具体形态中文电子病历 NER 和新闻领域 NER 最大的差异在于实体类别的定义方式。通用 NER 关注人名、地点、机构病历里的实体类别通常是症状、疾病、身体部位、检查项目、时间等。本项目的标注按字符粒度进行每个字分配一个标签标签体系用最常见的 BIO 方案。以“患者有高血压病史5年”为例按常见标注展开就是患 者 有 高 血 压 病 史 5 年 O O O B I I I I B I O这是省略类别后缀的表示。区分实体类型时B 和 I 后面会带疾病或症状的后缀比如 B-Disease、I-Disease。模型训练时字符经 embedding 之后进入双向编码器在每个位置输出一个标签概率分布。因为标签按字对齐“高”“血”“压”三个字必须连续标注成 B、I、I才能让最终解码出来的实体是“高血压”而不是“血压”。这里有个容易被忽略的细节同一份病历里“高血压病史”这个实体哪几个字算开始、哪几个字算结束完全取决于 transfer_data.py 里的转换规则。如果把“病史”也包含进实体模型学到的高血压实体边界就始终带着后缀测试阶段遇到没有“病史”后缀的文本边界立刻混乱。所以转换规则的一致性比规则本身是否包含附属词更重要这是这批数据里最值得先确认的点。2.3 预处理流程编码统一、空白清洗与字符映射从原始 txt 到模型输入中间通常走三步。第一步是清洗去掉多余空白字符把全角标点转成半角过滤噪声符号。病历文本里经常中文括号和英文括号混用数字与单位之间夹全角空格这些都会干扰字符映射。第二步是构建字符到索引的映射表把训练集里出现的全部字符合并成一个 vocab没见过的字统一映射到UNK。第三步是标签映射BIO 标签转成整数索引。样本切分一般按 8:2 或 9:1 做训练验证集划分。数据量只有几十份时验证集每轮的指标波动会很明显这属于小样本场景下的正常现象不代表模型坏了。调参时盯整体趋势不要因为某一个 epoch 的 F1 突然掉下来就急着换结构。这一步的 Dataset 构造可以直接参考下面的写法# data_manager.py 中自定义 Dataset 的参考实现 from torch.utils.data import Dataset class MedicalNERDataset(Dataset): def __init__(self, texts, labels, char2idx, label2idx, max_len128): self.data [] for text, label_seq in zip(texts, labels): chars list(text.strip())[:max_len] ids [char2idx.get(c, char2idx[UNK]) for c in chars] tags [label2idx[x] for x in label_seq[:max_len]] mask [1] * len(chars) # 手动填充到 max_len保证一个 batch 内形状一致 ids [0] * (max_len - len(ids)) tags [label2idx[O]] * (max_len - len(tags)) mask [0] * (max_len - len(mask)) self.data.append((ids, tags, mask)) def __len__(self): return len(self.data) def __getitem__(self, idx): ids, tags, mask self.data[idx] return torch.tensor(ids), torch.tensor(tags), torch.tensor(mask)这段代码的关键在 padding 策略字符索引用 0 填充标签索引用O标签的索引填充mask 用 0 标记 padding 位置。三者长度严格一致后面训练循环里直接用 mask 控制哪些位置参与 loss 计算。max_len128对常见病历文本够用但如果原始样本特别长截断前最好先看一眼文本长度分布避免大量实体被拦腰截断。3. 模型实现与训练配置把 BiLSTM 加 CRF 完整落地到 PyTorch3.1 为什么选 BiLSTM 加 CRF 做病历 NER命名实体识别本质上是序列标注模型要为输入序列的每个位置输出标签而且标签之间存在强约束。中文病历文本里的症状实体大多两到四个字但并不代表标签依赖关系短。典型情况是“患者近一周出现胸闷、气短伴双下肢水肿”两个症状实体紧挨着顿号夹在中间。如果模型只做每位置的独立预测很可能在顿号位置错误地开出新的实体标签导致后面所有标签右移。BiLSTM 解决上下文信息问题。前向 LSTM 捕获左侧语境后向 LSTM 捕获右侧语境拼接后预测“闷”字时模型能同时看到左边的“胸”和右边的“、”边界判断准确很多。CRF 层进一步解决标签转移约束训练时学到标签间的转移矩阵模型就知道实体开始标签后面不能直接接另一个开始标签I 标签前必须是同类型的 B 或 I。有更先进的结构吗当然有BERT 加 CRF、阅读理解式抽取模型在医疗 NLP 里都有应用。但几十份样本的小项目BERT 的预训练参数量会导致严重过拟合效果可能反而不如 BiLSTM 平滑。所以这种资源形态下最合理的基线就是 Embedding、BiLSTM、线性映射到标签空间再配一层 CRF 做约束解码。3.2 model.py 核心结构参考实现下面这段代码是 PyTorch 里实现该结构的常见写法你可以对照着手里的 model.py 理解不一定逐行一致但核心模块基本就这么几个# model.py 中的核心网络结构参考实现 import torch import torch.nn as nn class BiLSTM_CRF(nn.Module): def __init__(self, vocab_size, tag_size, embed_dim128, hidden_dim256, num_layers1, dropout0.5): super().__init__() # padding_idx0字符索引 0 不对应真实字 self.embed nn.Embedding(vocab_size, embed_dim, padding_idx0) self.bilstm nn.LSTM( embed_dim, hidden_dim // 2, num_layersnum_layers, bidirectionalTrue, batch_firstTrue ) self.dropout nn.Dropout(dropout) # 双向拼接后维度恢复为 hidden_dim self.fc nn.Linear(hidden_dim, tag_size) def forward(self, token_ids, maskNone): emb self.embed(token_ids) # [batch, seq_len, embed_dim] lstm_out, _ self.bilstm(emb) # [batch, seq_len, hidden_dim] lstm_out self.dropout(lstm_out) logits self.fc(lstm_out) # [batch, seq_len, tag_size] return logitsembedding 维度取 128LSTM 隐藏层设置 hidden_dim256 且用双向实际每方向隐藏维度是 128拼接后仍是 256与 fc 层的输入维度一致这个对应关系容易写错改参数时要注意保持双向拼接后的维度对齐。padding_idx0保证了填充字符不会产生有意义的向量后面 CRF 计算时配合 mask 可以把它完全排除。CRF 层的实现一般不用自己从零写转移矩阵和维特比解码引入 torchcrf 库在训练脚本里直接调用即可。如果不想增加外部依赖也可以自己实现核心就是维护一个tag_size × tag_size的转移矩阵训练时计算负对数似然解码时用维特比算法搜索最优路径。对新手来说先跑通 torchcrf 的流程再研究内部实现效率更高。3.3 训练主循环优化器、梯度裁剪与参数观察对应 main.py 里的训练循环常见结构是# main.py 训练主循环参考写法 from torch.utils.data import DataLoader def train(model, train_loader, optimizer, epochs40, max_norm5.0): model.train() for epoch in range(epochs): total_loss 0.0 for batch in train_loader: token_ids, tag_ids, mask batch optimizer.zero_grad() logits model(token_ids, mask) loss crf_loss(model, logits, tag_ids, mask) loss.backward() # 梯度裁剪小样本长文本场景下防梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm) optimizer.step() total_loss loss.item() print(fepoch {epoch 1}/{epochs}, loss {total_loss / len(train_loader):.4f})梯度裁剪这行在小样本 NER 里非常关键。病历文本句子长度参差个别长句的梯度累积特别大不裁剪的话 loss 可能一两个 step 内变成 nan。max_norm5.0是常用起点发现 loss 抖动明显可以降到 3。优化器建议直接 Adam学习率从 1e-3 开始。如果训练 loss 下降很慢先不要急着加层把学习率降到 5e-4 或 3e-4 重跑一遍往往数据层面和超参数层面的问题比结构问题更常见。3.4 小样本场景的调参节奏几十份训练样本的情况下epoch 不宜太少也不宜太多。太少模型还没收敛太多直接过拟合。40 轮以内比较合适配合早停策略每轮在验证集上算 F1连续五轮不涨就停保存最好的权重。batch size 建议 16 或 32。数据集本身小时batch 太大相当于一个 epoch 内参数更新次数太少模型学不充分。dropout 设在 0.3 到 0.5 之间embedding 输出后加一层LSTM 输出后再加一层两层 dropout 对抑制小样本过拟合很有效。embedding 如果能换成预训练的中文词向量或字向量做初始化F1 会有一截明显提升但前提是预训练向量的字符表覆盖了病历里的高频用字。4. 避坑指南小样本病历上训练 NER 最容易踩的五个坑小样本 NER 的坑和 CV 里小数据集训练有多像几乎一样但文本数据还有自己的编码和标注问题。下面五条是我实际跑这种项目时反复遇到过的按“现象、原因、解决”逐个说清楚。4.1 loss 不下降预测结果全是 O 标签现象训练跑了几十个 epochloss 几乎不动验证集解码出来的标签全是 O一个实体都没识别出来。原因样本里非实体字符远远多于实体字符模型预测成 O 就能拿到很低的 loss训练目标被多数类主导。另一个常见原因是学习率偏大CRF 转移矩阵在初始阶段被更新到错误方向之后很难拉回来。解决把学习率降到 3e-4 或 1e-4 重跑同时在 loss 里对实体标签加重或者在 transfer_data 阶段对包含实体的样本做重复采样增加它们在训练集里的占比。先打印一轮训练集标注统计看实体标签占比是否低于 5%如果低到离谱优先处理数据而不是改网络。4.2 实体边界整体偏移总是多一个字或少一个字现象模型把“高血压病史”识别成“血压病史”或把一个症状实体识别出多一个字错误模式在不同样本里反复出现。原因训练样本的 BIO 标注边界不一致。transfer_data.py 里实体匹配规则对同一实体在不同病历中的处理方式不同比如某些样本把“病史”两个字划进实体另一些不划进。CRF 学到的标签转移概率来回摇摆最终解码出的边界就是歪的。解决写一个统计脚本列出每个实体类型在训练集里出现的所有边界组合人工制定统一规则重新过一遍 transfer_data。这个操作在小样本项目里非常有必要几十条样本的标注误差占比太高边界不规则会直接体现在最终 F1 上。4.3 loss 异常抖动验证指标忽高忽低现象训练 loss 整体下降但某一步突然出现一个 spike甚至整个流程崩溃验证集 F1 在相邻 epoch 之间大起大落。原因两个来源。一是 padding 位置参与了 loss 计算batch 里短样本被填充后模型相当于在给填充字符学标签噪声信号污染梯度二是梯度爆炸长序列 RNN 的梯度累积到异常值后者在文本长短差距大的 batch 里很常见。解决确认 loss 计算时显式传入了 maskCRF 计算也要排除 padding 位置。同时把梯度裁剪加进训练循环两项一起处理loss 曲线基本就能平稳。验证集指标波动本身在高方差的小样本场景无法完全消除看滑动平均而不是单点值。4.4 读文件直接报 UnicodeDecodeError 崩溃现象程序运行到数据加载阶段就中断报 UnicodeDecodeError或者文本读进来之后乱码。原因病史特点-49.txt 这些原始文件很可能是 GBK 编码data_manager.py 却默认用 UTF-8 打开。Windows 环境下保存的中文文本文件这个概率非常高。解决读取文件时先试 UTF-8失败后用 gbk 或 gb18030 重读同时加errorsignore兜底避免个别坏字符拖垮整个训练。我的习惯是预处理阶段就把原始文件统一转成 UTF-8 副本后续所有代码只认一种编码从根上消灭这类问题。4.5 训练集 F1 很高验证集直接崩现象训练集上实体识别效果非常好接近全对换到验证集上 F1 直接打六折甚至更低。原因过拟合。几十份病历的样本量对 BiLSTM 来说太少了模型把训练集的用词习惯、标点风格都背了进去同领域新文本上的泛化能力很弱。随机初始化的字符 embedding 会加剧这个问题。解决加 dropout、加早停、换预训练字向量三件套一起上。dropout 比例在 0.3 到 0.5 之间调整早停设置在验证集 F1 连续五轮不涨时触发保存最优权重而不是最后一轮权重。如果手头有大规模中文语料上预训练出的字向量用它初始化 embedding 层效果提升会非常直接。5. 用真实病历验证模型把预测结果逐字可视化回文本训练结束后不要只盯着控制台指标把模型在真实病历文本上的预测结果拉出来逐字对照这一步能暴露出很多指标上看不见的问题比如实体边界漂移的具体位置、漏召回集中在哪些表达上。我会写一个很小的推理函数def predict_text(model, text, char2idx, idx2label, max_len128): model.eval() chars list(text.strip()) ids [char2idx.get(c, char2idx[UNK]) for c in chars] ids ids[:max_len] mask [1] * len(ids) # padding 到定长推理阶段也要保证形状一致 ids [0] * (max_len - len(ids)) mask [0] * (max_len - len(mask)) x torch.tensor([ids]) m torch.tensor([mask], dtypetorch.bool) with torch.no_grad(): logits model(x, m) tags decode_crf(logits, m) # 维特比解码返回 [1, seq_len] 的标签索引 tags tags[0][:len(chars)] return list(zip(chars, [idx2label[t] for t in tags]))注意decode_crf是 CRF 层的解码方法在使用 torchcrf 时通常由模型内部提供。推理阶段同样要把 mask 传给 CRF否则 padding 位置的标签会被解码出来干扰后续结果。中间层tags[0][:len(chars)]把输出截断到真实文本长度这一步不要省。把预测结果贴到表格里逐字对比比如这样字符模型预测人工标注患OO者OO有OO高B-DiseaseB-Disease血I-DiseaseI-Disease压I-DiseaseI-Disease病I-DiseaseI-Disease史OO5B-TimeB-Time年I-TimeI-Time一眼就能看出模型对疾病类实体的边界掌握得不错因为“病史”两个字被正确排除在实体外。如果换成另一份样本出现“高血压”和“病史”边界不稳说明训练数据里的边界规则不一致回头去改 transfer_data 而不是调模型。从那以后我每次训练完 NER 模型都会强制走一遍这个可视化验证流程先从训练集里抽十份、从验证集里抽十份打印出来全局扫一遍再决定要不要动预处理或调参。很多问题在指标上只差零点几个百分点但落到具体病历文本上就是完全不可用的输出。这个习惯帮我省掉过不少返工时间希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑