PyTorch中文电子病历NER实战:从BIOES标注到BiLSTM+CRF实现 简介基于Python与PyTorch构建的中文电子病历命名实体识别项目面向医疗信息化从业者、NLP学习者和科研人员聚焦从病历文本中自动抽取病史特点、症状、诊断等医疗实体覆盖数据预处理、序列标注、模型训练与评估的完整流程。包内共2000个文件约11.22MB以1994个txt病历原始文本为主同时包含5个Python源码文件main.py、model.py、data_manager.py、utils.py、transfer_data.py与1个README说明文档结构清晰便于对照运行与二次开发。已有114人浏览学习。项目系统讲解了Python编程、PyTorch动态图建模、中文分词、BIO标注、准确率/召回率/F1评测等关键知识点并给出实体字典构建与模块化设计思路。下载后可直接获得可运行的NER系统源码、标注病历数据及使用说明适合用来入门中文医疗NLP也可作为课程设计或医疗信息抽取项目的参考实现。1. 拆解这份PyTorch中文电子病历NER压缩包五个py文件加四个txt能干什么电子病历命名实体识别说白了就是从一段“病史特点”文本里把症状、部位、检查结果、用药这些实体边界标出来。网上开源的中文NER项目不少但大部分是新闻语料或通用领域真正能落到医疗场景、直接跑起来的很少。而这个zip解压后只有9个文件5个py源码加4个txt病历文本没有庞大依赖没有动辄几个G的预训练权重结构非常干净。它解决的是医疗文本结构化的问题把非结构化的病程记录转成可检索的结构化字段让下游系统能统计和查询。适合三类人想快速跑通医学领域NER基线的NLP工程师拿这套代码做课程设计或毕设的学生以及需要把实体抽取集成进自己系统的医疗信息化从业者。下面从解压后的第一眼说起。2. 环境搭建与项目结构用conda创建pytorch环境先摸清五个py文件的职责边界拿到压缩包之后我建议先别急着双击main.py否则大概率会在import阶段就翻车。这个项目的依赖虽然不算重但PyTorch的安装版本和CUDA版本如果对不上后面所有训练结果都会变成黑匣子。先把环境搞定再逐文件看代码最后才动数据这是最省时间的顺序。2.1 环境要求与pytorch安装项目基于Python PyTorch核心依赖其实就两个torch和numpy。文本处理用到的Python标准库比如re、collections、json都是自带的。我一般会用conda单独建一个虚拟环境不污染系统Python也方便以后复制给其他项目用。conda create -n emr_ner python3.8 conda activate emr_ner # CPU版本 pip install torch1.13.1 torchvision0.14.1 torchaudio0.13.1 # GPU版本需要先确认自己的CUDA版本 pip install torch1.13.1cu117 torchvision0.14.1cu117 torchaudio0.13.1 -f https://download.pytorch.org/whl/torch_stable.htmlconda create -n emr_ner python3.8这行指定了Python 3.8原因很简单PyTorch 1.x系列对3.8的兼容性最好后续装任何依赖都不容易出幺蛾子。如果机器上有NVIDIA显卡用GPU版能明显加速训练但注意cu117代表CUDA 11.7安装前用nvidia-smi看一眼驱动版本驱动太旧会装得上却跑不动。装完后验证一下python -c import torch; print(torch.__version__, torch.cuda.is_available())输出里cuda.is_available()必须是True才说明GPU可用。环境搭建这一步搜索量大的问题通常出在pip源上。国内网络拉取PyTorch官方源容易超时我一般会在pip命令后加-i https://pypi.tuna.tsinghua.edu.cn/simple或者提前把conda的.condarc里的源换成清华镜像能省下大量等待时间。2.2 代码模块职责README之外的调用关系环境弄好后先打开README.md扫一眼再到各个py文件里看函数定义和调用关系。这个项目的模块划分很清晰五个py文件各管一段文件职责关键函数main.py训练与评估入口控制整个流程train()evaluate()model.py定义神经网络结构核心是序列标注模型build_model()data_manager.py数据加载、标签映射、batch生成load_data()make_batch()utils.py文本清洗、指标计算、词典构建clean_text()compute_f1()transfer_data.py原始病历文本转BIO标注格式transfer()save_data()调用关系是一条主线main.py从data_manager.py拿数据从model.py建模型训练完用utils.py算指标。transfer_data.py是上游工具它把最原始的txt病历转成模型能吃带标签数据所有写这个项目的代码的人都会先跑它。# main.py 的骨架结构常见写法 import torch from model import build_model from data_manager import load_data, make_batch from utils import compute_f1 def train(): train_data, dev_data load_data(train.txt, dev.txt) model build_model(vocab_size, tag_size) optimizer torch.optim.Adam(model.parameters(), lr5e-4) for epoch in range(20): for batch in make_batch(train_data): loss model(batch).loss optimizer.zero_grad() loss.backward() optimizer.step()load_data()负责把txt文件读进来并做字符级切分make_batch()把样本打包成固定长度的张量model(batch)前向计算损失这一步通常在model内部已经做了CRF解码。这个骨架的好处是每个环节都可以单独替换比如把build_model()换成BERT版本不影响上下游。2.3 四个txt文件的定位和数据规模四个“病史特点”txt是项目自带的医疗数据文件名里的数字是样本序号。我拿到手的第一件事是统计每个文件的样本量和文本长度分布wc -l 病史特点-49.txt 病史特点-19.txt 病史特点-102.txt 病史特点-269.txt head -20 病史特点-49.txtwc -l能看到四个文件行数分别是49、19、102、269行合计439条样本。head则能看到原始病历的格式每一行是一条独立的病史描述比如“患者于3月前无明显诱因出现咳嗽、咳痰伴活动后气短”。这种格式对后续处理很友好因为一行就是一条样本不需要再做段落切分。四个文件大小不一恰恰说明病历文本长度差异很大有的几十字有的几百字这对模型训练时的padding策略提出了要求。我一般会把49和19两个小文件拆成验证集和测试集102和269合成训练集这样训练集有371条验证集和测试集各几十条比例上更合理。如果官方没给划分脚本自己在data_manager.py里加一个随机种子就行。3. 数据预处理与序列标注从病史文本到BIO标签transfer_data.py的转换逻辑数据处理是整个NER项目里最耗时、也最影响最终效果的一环。很多人在模型上折腾半天最后发现F1上不去问题其实出在训练标签本身就有错。电子病历和新闻文本不一样它有大量全角符号、括号、英文缩写、剂量单位这些都会干扰字符级模型的输入必须先清洗干净。3.1 电子病历文本预处理的三个关键动作预处理做得好不好直接决定模型能不能学到干净的分布。中文电子病历里最常见的噪声有三类全角/半角混用、特殊符号如“3/4”里的斜杠、重复的套话如“患者于……就诊”。清洗时我会用一个统一的函数处理import re def clean_text(text: str) - str: # 全角转半角统一标点符号 text text.replace(\u3000, ) # 去掉多余空白 text re.sub(r\s, , text) # 去掉方括号里的剂量说明如 [5mg] text re.sub(r[\[(][^)\]]*(mg|ml|g|片|粒)[^)\]]*[\])], , text) # 保留中文、英文、数字和常见医疗符号 text re.sub(r[^\u4e00-\u9fa5a-zA-Z0-9。、/%], , text) return text.strip()这里的核心思路是“只删噪声不删信息”。“全角转半角”这一步很多人会忽略但全角逗号和半角逗号在字符级模型里是两个完全不同的输入训练时统计的字符表会平白多出一倍。剂量说明用正则直接删掉因为“5mg”这类字符对实体边界判断没有帮助反而会让模型把数字和单位当成一个实体的一部分。最后一步把不认识的符号全部过滤避免测试时出现训练集里从未见过的字符。3.2 BIO与BIOES标注为什么电子病历场景更推荐BIOES序列标注的标签体系项目文档里明确提到BIO和BIOES两种。BIO用B-实体类型表示实体开头、I-实体类型表示实体内部、O表示非实体BIOES则额外增加E-实体类型表示实体结尾、S-实体类型表示单个字独立成实体。对于电子病历我更倾向BIOES原因是病历里的实体长度极不均匀——“咳嗽”只有两个字而“右肺下叶背段可见结节状高密度影”这种描述里一个实体可以长达十几个字。BIOES里的S和E标签给CRF层提供了更明确的边界信号尤其在两个不同类型实体相邻时比如“咳嗽伴咳痰”BIOES能把“咳嗽”标记为S-症状“伴”标记为O“咳痰”标记为S-症状边界判定比BIO更稳。标签体系标签数量边界信息适用场景BIO2N1只有开头和内部实体长度相对均匀BIOES4N1开头、内部、结尾、单字实体长度差异大边界重叠多这里的N是实体类型数量假设定义症状、部位、检查、疾病四类实体BIO需要9个标签BIOES需要17个。标签更多意味着模型需要更多数据来拟合但在这个项目只有几百条样本的规模下BIOES带来的边界收益通常大于数据稀疏的代价。3.3 transfer_data.py把原始文本转成带标签序列transfer_data.py是整个数据流水线的源头。它的核心逻辑是读入原始文本用实体字典做匹配给每个字符打上对应的BIOES标签。实体字典是这个项目的隐藏武器它把症状、部位、检查、疾病四类高频词提前收集好匹配时直接查表。from typing import List, Tuple entity_dict { 症状: [咳嗽, 咳痰, 胸痛, 呼吸困难], 部位: [肺部, 右肺, 左肺, 纵隔], 检查: [CT, 胸片, 支气管镜], 疾病: [肺炎, 肺癌, 肺结核], } def transfer(text: str) - List[Tuple[str, str]]: # 输出 (字符, 标签) 列表 labels [O] * len(text) for etype, words in entity_dict.items(): for word in words: start 0 while True: idx text.find(word, start) if idx -1: break if len(word) 1: labels[idx] fS-{etype} else: labels[idx] fB-{etype} for j in range(1, len(word)): # 避免覆盖已标注为实体结尾的字符 if labels[idx j] O: labels[idx j] fI-{etype} start idx max(1, len(word) - 1) return list(zip(text, labels))这段代码里text.find(word, start)做的是朴素字符串匹配start idx max(1, len(word) - 1)是为了处理重叠匹配的情况比如字典里有“肺”和“肺部”匹配完“肺”之后不能让“肺部”完全错过。这里有一个实际会遇到的问题一个词可能同时命中多个实体类型比如“肺部”既可以是部位也可能是疾病描述的一部分。解决优先级我一般定为“疾病 症状 部位 检查”因为疾病名的信息价值最高优先保住它的边界。代码本身只演示了字典匹配真正的工程里还会结合正则规则比如“右肺”后面跟“可见”时判定为部位而非疾病。3.4 data_manager.py的数据读取与batch生成清洗和标注完成之后数据要变成PyTorch能吃的张量。data_manager.py负责把字符序列映射成id并按照固定长度做padding。import torch from torch.utils.data import Dataset, DataLoader class NERDataset(Dataset): def __init__(self, texts, labels, char2id, tag2id, max_len256): self.texts, self.labels texts, labels self.char2id, self.tag2id char2id, tag2id self.max_len max_len def __getitem__(self, idx): text, labels self.texts[idx], self.labels[idx] # 截断到max_len text, labels text[:self.max_len], labels[:self.max_len] input_ids [self.char2id.get(c, 1) for c in text] # 1是UNK tag_ids [self.tag2id[t] for t in labels] mask [1.0] * len(input_ids) return input_ids, tag_ids, mask def __len__(self): return len(self.texts) def collate_fn(batch): input_ids, tag_ids, masks zip(*batch) max_len max(len(x) for x in input_ids) padded_inputs torch.zeros(len(batch), max_len, dtypetorch.long) padded_tags torch.zeros(len(batch), max_len, dtypetorch.long) padded_masks torch.zeros(len(batch), max_len, dtypetorch.float) for i, (ids, tags, mask) in enumerate(batch): padded_inputs[i, :len(ids)] torch.tensor(ids) padded_tags[i, :len(tags)] torch.tensor(tags) padded_masks[i, :len(mask)] torch.tensor(mask) return padded_inputs, padded_tags, padded_maskschar2id.get(c, 1)里面的1是UNK标记训练时没见过的字全部映射到这个位置上。collate_fn是PyTorch DataLoader的常规写法它把长短不一的样本pad到当前batch的最大长度而不是整个数据集的固定长度这样能减少计算浪费。max_len设256是经验值这份病历数据大多数样本在100到200字之间256已经留足了余量设太大反而会让padding部分占比过高拖慢训练。4. 模型构建与训练BiLSTMCRF的核心实现与超参数设置模型层是这个项目的重头戏。在医疗NER这种数据量不大的场景下BiLSTMCRF依然是最可靠的选择之一。它不像BERT那样需要海量数据和大量显存也不像传统CRF那样依赖手工特征模板。用PyTorch写动态图去实现CRF的前向计算和维特比解码调试起来比TensorFlow的静态图直观得多这也是现在学术和工业界更倾向用PyTorch做NLP的原因之一。4.1 模型选型为什么是BiLSTMCRF而不是纯BiLSTM或BERT序列标注问题里BiLSTM负责提取上下文特征CRF负责约束标签之间的转移关系。如果只用BiLSTM模型在解码时会独立预测每个位置的标签可能出现“B-症状后面直接接B-部位”这种非法序列。CRF层在训练时学习标签转移矩阵比如“B-症状后面必须是I-症状或O”这个约束在病历这种实体密集的文本里尤其重要因为“咳嗽伴胸痛”这类连续实体很容易让纯BiLSTM的输出乱掉。方案上下文建模标签约束数据需求训练速度传统CRF窗口特征强中快纯BiLSTM双向LSTM无中中BiLSTMCRF双向LSTM强中中BERTCRFTransformer强大慢在这个项目只有几百条样本的规模下BERTCRF不一定能跑出比BiLSTMCRF更好的效果反而可能因为过拟合导致F1波动。我见过不少人在小数据上硬上BERT结果验证集F1还不如一个调好参的BiLSTMCRF这个现象在NER任务里很常见。所以这份代码把主力模型定为BiLSTMCRF是一个务实的选择先把baseline跑通后续再考虑升级。4.2 model.py的网络结构实现model.py的核心是定义一个继承nn.Module的类内部包含字符Embedding、BiLSTM、线性映射和CRF层。代码结构大致如下import torch import torch.nn as nn class BiLSTM_CRF(nn.Module): def __init__(self, vocab_size, tag_size, embedding_dim128, hidden_size256, num_layers2, dropout0.5): super().__init__() self.embedding nn.Embedding(vocab_size, embedding_dim, padding_idx0) self.bilstm nn.LSTM(embedding_dim, hidden_size // 2, num_layersnum_layers, batch_firstTrue, bidirectionalTrue, dropoutdropout) self.dropout nn.Dropout(dropout) self.hidden2tag nn.Linear(hidden_size, tag_size) # CRF层需要自己实现或用pytorch-crf包 from torchcrf import CRF self.crf CRF(tag_size, batch_firstTrue) def forward_loss(self, input_ids, masks, tags): embeds self.dropout(self.embedding(input_ids)) lstm_out, _ self.bilstm(embeds) features self.hidden2tag(self.dropout(lstm_out)) # CRF的负对数似然损失 return -self.crf(features, tags, maskmasks.bool()) def predict(self, input_ids, masks): embeds self.dropout(self.embedding(input_ids)) lstm_out, _ self.bilstm(embeds) features self.hidden2tag(self.dropout(lstm_out)) return self.crf.decode(features, maskmasks.bool())hidden_size // 2是一个关键细节BiLSTM会把前向和后向两个方向的隐藏状态拼接起来维度正好等于hidden_size。也就是说声明hidden_size256时实际每个方向的LSTM输出是128维拼接后才是256。如果不除以2线性层的输入维度就会对不上。num_layers2意味着堆叠两层LSTM第一层的输出是第二层的输入这能捕捉更抽象的特征但也更容易过拟合所以配合dropout0.5做正则化。CRF层直接用了torchcrf包它的decode方法内置了维特比解码返回的是每个序列的最优标签路径。4.3 训练循环与超参数学习率、L2正则化与梯度裁剪训练部分在main.py里核心超参数直接影响最终F1。先说优化器我通常用Adam学习率设5e-4。Adam已经自带自适应学习率但对序列标注任务来说学习率过大依然会出现loss在训练中途变成nan的情况所以我会在每次反向传播后做梯度裁剪。import torch from torch.optim import Adam from torch.nn.utils import clip_grad_norm_ def train(model, train_loader, dev_loader, epochs30, lr5e-4): optimizer Adam(model.parameters(), lrlr, weight_decay1e-4) best_f1 0.0 for epoch in range(epochs): model.train() total_loss 0.0 for input_ids, tags, masks in train_loader: optimizer.zero_grad() loss model.forward_loss(input_ids, masks, tags) loss.backward() # 梯度裁剪防止梯度爆炸 clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step() total_loss loss.item() avg_loss total_loss / len(train_loader) # 每个epoch结束在验证集上评估 dev_f1 evaluate(model, dev_loader) print(fepoch {epoch1}, loss {avg_loss:.4f}, dev f1 {dev_f1:.4f}) if dev_f1 best_f1: best_f1 dev_f1 torch.save(model.state_dict(), best_model.pt)weight_decay1e-4是L2正则化它给大权重施加惩罚防止模型死记训练集中的特定字组合这在病历这种小数据集上很关键。clip_grad_norm_(model.parameters(), max_norm5.0)的作用是把所有参数的梯度范数限制在5.0以内超过的部分等比缩放这就是深度学习中L2正则化之外另一个常用防爆炸手段PyTorch代码里写起来就一行。早停策略是看验证集F1连续5个epoch不提升就提前终止训练torch.save只保存验证集F1最高的那一次模型参数避免最后几个过拟合的epoch把最好的结果覆盖掉。5. 评估与避坑指南F1计算的正确姿势和五个血泪踩坑记录训练结束后评价指标直接决定模型值不值得保留。摘要里提到的Precision、Recall和F1如果只按单个字符做比对会发现分数虚高得离谱。因为病历里“咳嗽”识别成“咳痰”时字符重叠率很高但实体其实是错的。所以评估时一定要按实体级别去计算而不是标记级别。5.1 实体级别的Precision、Recall、F1实现实体级评估的思路很简单把预测出的BIOES序列还原成实体列表然后判断每个实体起止位置类型是否与标注完全一致只有完全一样的才计入正确预测。def extract_entities(tags): entities [] i, n 0, len(tags) while i n: if tags[i].startswith(B-): etype tags[i][2:] j i 1 while j n and tags[j] fI-{etype}: j 1 # 遇到E-结束实体完整 if j n and tags[j] fE-{etype}: entities.append((i, j, etype)) i j 1 continue else: entities.append((i, j - 1, etype)) i j elif tags[i].startswith(S-): entities.append((i, i, tags[i][2:])) i 1 else: i 1 return entities def compute_f1(pred_tags, gold_tags): pred_entities set(extract_entities(pred_tags)) gold_entities set(extract_entities(gold_tags)) correct len(pred_entities gold_entities) pred_num, gold_num len(pred_entities), len(gold_entities) precision correct / pred_num if pred_num else 0.0 recall correct / gold_num if gold_num else 0.0 f1 2 * precision * recall / (precision recall) if (precision recall) else 0.0 return precision, recall, f1extract_entities里对B和E的处理是最容易出错的点如果B-症状后面直接跟了一个E-症状这是完整实体如果后面跟的是其他标签说明模型预测的实体不完整但依然要按实际边界截断。用set做交集的好处是天然去掉重复预测的实体避免一个实体被模型识别两次但只应该算一次的边界情况。这个函数的逻辑看起来简单但实际跑起来你会发现模型输出的标签序列里经常有“B后面跟I但类型不同”的非法拼接所以编写时要对每个标签类型都做容错。5.2 五个高频踩坑现象、原因、解决坑1训练loss变成nan模型参数全部变成NaN现象训练到第几个epochloss突然输出nan之后所有指标归零。原因是学习率过大导致梯度爆炸尤其是CRF层的转移矩阵比较敏感数值稍大就溢出。解决加上clip_grad_norm_并降低学习率到3e-4左右。这个现象在PyTorch里最常见遇到先查学习率和梯度范数不要急着改模型结构。坑2模型在验证集上几乎全部预测为O现象F1只有个位数打印出预测结果发现所有标签都是O一个实体都没识别出来。原因通常是实体字典和标注数据的标签集合不一致tag2id里少了某个实体类型导致模型训练时从没见过某些标签。解决在data_manager.py里打印tag2id和训练集中出现的标签并集确认两者一致。另一个常见原因是O类样本占比超过90%模型学会了“全猜O”也能拿到不错的loss这时需要给非O类标签加权重或者做欠采样。坑3CRF解码报错ValueError: feature size mismatch现象训练正常到了predict阶段报错提示特征维度不匹配。原因torchcrf默认要求输入是(batch, seq, tag_num)而model.py里LSTM输出经过hidden2tag后维度是对的但有可能忘了dropout导致训练和预测时特征分布不一致。解决预测前确保模型处于eval()模式并且输入张量与训练时维度保持一致。这个错误本质上是训练/预测流程不对称排错时先看维度再看mode。坑4全角括号和单位未归一化导致实体被切断现象病历里“咳嗽伴胸闷5天”识别出来后“咳嗽”和“伴胸闷”被预测成了两个独立实体但实际上应该是“咳嗽伴胸闷”一个整体症状描述。原因预处理时没做全角转半角括号字符干扰了模型的字符序列分布。解决回到3.1的clean_text把全角括号转半角并决定括号内内容是否保留而不是把括号当成普通字符留在文本里。这个坑在电子病历里尤其常见因为医生输入习惯五花八门。坑5用预训练字向量替换Embedding时unk处理不当现象F1不升反降而且很多病历专有名词识别不出来。原因直接拿预训练词向量初始化nn.Embedding时没有覆盖训练集中出现的所有字符新字符被随机初始化后在训练中不稳定。解决加载预训练向量后把char2id里所有字符的embedding检查一遍凡是预训练词表里不存在的字统一用torch.nn.init.uniform_重新初始化并置为requires_gradTrue让它在训练中继续更新。这一步属于“经验性操作”网上很多教程不会提但实际跑起来影响很大。6. 进阶技巧把这套系统迁移到其他病历类型以及推理时的三个边界问题项目自带的“病史特点”数据集只是起点真正落地时你大概率会拿到呼吸科、心内科、消化科等不同科室的病历。迁移的关键不在于改模型而在于把transfer_data.py里的实体字典换成新科室的词表重新走一遍数据转换流程。我一般会先在新数据上随机抽20条人工标好BIOES和程序转换的结果做对比确认字典覆盖率和边界切分都符合预期再开始训练。这一步能省掉后面很多调试时间。第二个边界问题是推理时的文本长度。训练时设置了max_len256但医生写的病史有时会超过这个长度直接截断会把实体切成两半。我习惯用滑动窗口的方式做推理窗口大小200步长150预测完再把重叠部分按实体置信度合并这样长文本不会丢实体。代价是推理时间翻倍但医疗场景对准确率的要求通常高于速度。第三个边界问题是字符对齐。模型输出的标签序列是按字符索引对齐的一旦预处理阶段对输入文本做了删除操作比如删了空格和括号那么预测结果就回不到原始文本的位置上。解决办法是把clean_text里对每个字符的偏移量记录下来推理完成后再映射回原文坐标。这个细节很琐碎但漏掉的话下游系统拿到的实体起止位置全是错的。如果你需要把这个PyTorch模型转成ONNX部署到生产环境CRF层是个麻烦点——torchcrf的decode方法里用了动态循环ONNX导出时往往不支持。常见做法是在导出前把CRF层替换成一个带维特比解码的自定义模块或者直接在服务端用torch.load加载原模型牺牲一点性能换稳定性。从那以后我每次换数据集都强制先跑一遍transfer_data.py并人工抽10条核对BIO序列确认没问题才进train推理代码也要求输入输出中间不能有任何黑匣子。希望帮到你。本文还有配套的精品资源点击获取