简介:一份基于 Python 与 PyTorch 实现的中文电子病历命名实体识别(NER)项目,适合医疗信息处理、NLP 入门及序列标注方向学习者参考。资源聚焦从电子病历文本中提取医疗实体,覆盖文本预处理、中文分词、BIO/BIOES 序列标注、实体字典构建及模型评价指标等核心环节。压缩包共 2000 个文件,以 1994 个 txt 病历语料为主,另含 5 个 Python 源码文件与 1 个 README 说明文档,便于快速理解从数据处理到模型训练的整体流程,包体仅 11.22MB,轻量易用。目前已有 114 人学习下载。通过完整源码与案例可掌握 PyTorch 搭建 NER 模型的方法,并了解临床文本处理的实用技巧,为后续医疗知识抽取和智能辅助诊疗提供可直接扩展的基础工具。
1. 中文电子病历命名实体识别:为什么这个 pytorch 项目值得复现一遍
中文电子病历命名实体识别(NER)这几年被反复提起,实际动手做的时候,大家拿到的往往就是这样一个以 zip 分发的 python 项目包。包里是数据处理、模型和训练脚本,但能不能把这个 NER 模型跑通、跑稳,还得看你自己对标签体系和 pytorch 训练细节的掌控。病历文本和新闻语料差得远,主诉口语化、药名长、检查项缩写多,通用 NER 模型拿过来经常边界全乱。对做病案质控、临床科研数据抽取、医保结算结构化的人来说,用 pytorch 复现这套项目,等于把数据清洗、序列标注、模型调优和部署验证的完整链路重新走了一遍。下面按一个可落地的顺序展开,新手能跟着复现,老手也能直接对照参数和避坑点。
2. 方案定型:从标签体系到网络选型
动手写代码之前,最该先定下来的不是模型用几层,而是实体类型和标注规范。电子病历 NER 和通用领域 NER 最大的差别也在这里。
2.1 用 BIO 标注把电子病历变成序列标注任务
电子病历里最常见的实体类型可以归成六类:症状和体征、检查项目、手术操作、药物、疾病诊断、身体部位。有些项目还会拆出“检查结果”或“就诊科室”,但拆得越细,标注一致性和模型收敛难度越高。我一般建议先把六类做稳,再去加第二层标签。
BIO 标注的含义很简单:每个字要么是实体开头 B,要么是实体内部 I,要么不是实体 O。以“患者因反复头痛伴恶心3天入院,行头颅CT检查未见异常,予以布洛芬缓释胶囊治疗,出院诊断:偏头痛”为例,按字符切分后标注成下面这样:
患 O 者 O 因 O 反 O 复 O 头 B-SIG 痛 I-SIG 伴 O 恶 B-SIG 心 I-SIG 3 O 天 O 入 O 院 O 行 O 头 B-BOD 颅 I-BOD C B-CHE T I-CHE 检 O 查 O 予 O 以 O 布 B-DRU 洛 I-DRU 芬 I-DRU 缓 I-DRU 释 I-DRU 胶 I-DRU 囊 I-DRU 偏 B-DIS 头 I-DIS 痛 I-DIS注意几件事:头痛和恶心分别标成两个症状实体,而不是合并在一个实体里;“头颅”是身体部位,和“CT”这个检查实体紧挨着,靠 B 和 I 切分开;布洛芬缓释胶囊是完整药名,不能只标“布洛芬”。实际项目中,常见做法是 data 目录下放 train.txt、dev.txt、test.txt 三个文件,每行一个“字 标签”,空行表示句子结束。训练脚本读这种格式最省事,也方便后面对照原文检查边界错误。
2.2 基于字符的 BiLSTM 是医疗 NER 的稳妥基线
为什么不用分词后再建模?中文分词工具在通用文本上表现尚可,遇到病历里的缩写药名、中英混排检查项、口语化症状时,分词错误会直接传导给 NER,造成实体边界永久性错位。基于字符建模天然绕开分词,每一个汉字都是一个输入单元,实体边界完全由模型学习。这也是中文医疗 NER 社区最主流的做法。
模型主体用 BiLSTM,原因是电子病历实体对上下文的依赖很强:“行头颅CT检查未见异常”里“未见异常”决定了前面 CT 是检查而非诊断;“予以布洛芬”里的“予以”提示后面是个药物实体。双向 LSTM 能同时看到左侧动词和右侧结果描述,信息量比单向 LSTM 高一截。
最后接 CRF 层,是为了约束标签之间的转移关系。纯 Softmax 分类可能输出“O 后面直接跟 I-SIG”这种非法序列,CRF 在学习阶段会记住合法转移:实体必须以 B 开头,I 不能自己起头,不同实体类型切换必须先回到 O 或重新出现 B。小数据集上,这一层通常能带来 2 到 4 个点的实体级 F1 提升,代价是训练时每步要计算整条序列的路径概率,速度会慢一些。
2.3 用 pytorch 搭一个最小可跑环境:目录结构先行
下载下来的 zip 解压后,我习惯先把目录结构理成下面这样,再动训练逻辑:
ner/ data/ train.txt dev.txt test.txt src/ dataset.py model.py train.py predict.py requirements.txtrequirements 里放最小依赖,不锁定版本,保证在新环境能直接装上:
torch torchcrf seqeval numpytorchcrf 提供现成的 BiLSTM+CRF 的解码和损失计算接口;seqeval 用于计算实体级 F1。pytorch 版本注意一点:CPU 机器直接pip install torch就能跑通整个流程,只是训练会慢;有 NVIDIA 显卡时,建议先确认本地 CUDA 版本再装对应包,装完用两行代码验证 GPU 是否真的可用。
import torch print(torch.__version__) print(torch.cuda.is_available())打印True说明当前 pytorch 能调用显卡。项目里所有涉及随机种子的地方统一设置,保证复现时结果可对齐。
3. 准备一份能动手标注的数据集:文本转 BIO 再转张量
模型改得再好,数据进不去也是白搭。这里把从原始病历文本到 pytorch 张量的完整链路拆开。
3.1 先把病历文本切成字符并打好 BIO 标签
数据准备阶段最枯燥但最关键。如果手头没有现成标注,需要先写一个标注辅助脚本,把每段文本逐字打印,人工给标签。标注完成后,要做一个一致性检查脚本,扫三类低级错误:
def check_bio(labels): for i, tag in enumerate(labels): if tag.startswith("I-"): prev = labels[i - 1] if i > 0 else "O" if not prev.endswith(tag[2:]) and prev != tag.replace("I-", "B-"): return False return True逻辑是:I 标签的前一个字要么是同类 B,要么是同类 I,否则说明前一个实体还没开始就出现了内部标签,这类样本进模型只会让 CRF 学到错误转移。常见做法是写一个normalize_text函数,把全角符号、多余空格、乱码先清掉,再进入标注流程。
3.2 构建字符词典和 Dataset 封装
数据格式如果是“字 标签”两列,按空行切句之后,用两个 defaultdict 来构造索引:
from collections import Counter def build_vocab(sentences, min_freq=1, max_size=50000): counter = Counter() for chars, _ in sentences: counter.update(chars) vocab = {"<pad>": 0, "<unk>": 1} for ch, freq in counter.most_common(max_size): if freq >= min_freq: vocab[ch] = len(vocab) return vocabvocab 里<pad>固定为 0,<unk>固定为 1,后续 Embedding 层的padding_idx=0会直接复用这个设计。标签侧不需要<unk>,因为所有标签都在训练集里出现过,只需要一个label_to_id映射。
Dataset 封装的核心是让__getitem__返回定长内容,长度信息留给 collate 去处理:
class NerDataset(Dataset): def __init__(self, file_path, vocab, label_to_id): self.data = [] chars, labels = [], [] with open(file_path, encoding="utf-8") as f: for line in f: line = line.strip() if not line: if chars: self.data.append((chars, labels)) chars, labels = [], [] continue ch, tag = line.split() chars.append(ch) labels.append(label_to_id[tag]) self.vocab = vocab def __len__(self): return len(self.data) def __getitem__(self, idx): chars, labels = self.data[idx] char_ids = [self.vocab.get(c, self.vocab["<unk>"]) for c in chars] return torch.tensor(char_ids), torch.tensor(labels)每一条样本是一个等长的字符序列和等长的标签序列,训练时再按 batch 内最大长度做 padding。
3.3 用 DataLoader 做 padding 和 mask
pytorch 的DataLoader本身不做 padding,需要自己写collate_fn。这里的核心任务是构造 mask,让模型知道哪些位置是真实字符、哪些位置是补的:
def collate_fn(batch): char_ids, label_ids = zip(*batch) lengths = [len(x) for x in char_ids] max_len = max(lengths) padded_chars = torch.zeros(len(batch), max_len, dtype=torch.long) padded_labels = torch.full((len(batch), max_len), -1, dtype=torch.long) mask = torch.zeros(len(batch), max_len, dtype=torch.bool) for i, (c_ids, l_ids) in enumerate(zip(char_ids, label_ids)): padded_chars[i, :len(c_ids)] = c_ids padded_labels[i, :len(l_ids)] = l_ids mask[i, :len(c_ids)] = True return padded_chars, padded_labels, mask, torch.tensor(lengths)三个值的用途要分清楚:padded_chars 进 Embedding;padded_labels 在计算损失时用,padding 位置填-1而不是0,因为0通常代表 O 标签,会让模型把无意义的 padding 位置当成真实负样本;mask 供 CRF 层判断哪些位置参与计算。
参数上,max_len不用全量数据统计,我一般先看数据分布再定:如果 80% 以上的句子在 100 字以内,max_len=128足够;把max_len直接设为 512 会让训练慢好几倍,收益却很小。
4. 写模型与训练脚本:从随机权重到能用的 NER
数据和网络结构都准备好之后,真正动手写模型。这一章用最小代码量把 BiLSTM+CRF 跑成一个可训练的模块,并解释每个参数为什么这么设。
4.1 用 nn.Module 写出 BiLSTM+CRF 网络
以下是一个可以直接进训练循环的模型定义:
import torch import torch.nn as nn from torchcrf import CRF class BiLSTMCRF(nn.Module): def __init__(self, vocab_size, embedding_dim, hidden_dim, label_size, num_layers=2, dropout=0.5): super().__init__() self.embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx=0) self.lstm = nn.LSTM( embedding_dim, hidden_dim // 2, num_layers=num_layers, batch_first=True, bidirectional=True, dropout=dropout if num_layers > 1 else 0.0 ) self.dropout = nn.Dropout(dropout) self.fc = nn.Linear(hidden_dim, label_size) self.crf = CRF(label_size, batch_first=True) def forward(self, char_ids, mask, label_ids=None): emb = self.embedding(char_ids) lstm_out, _ = self.lstm(emb) lstm_out = self.dropout(lstm_out) logits = self.fc(lstm_out) if label_ids is not None: return -self.crf(logits, label_ids, mask=mask, reduction="mean") return logits def decode(self, char_ids, mask): logits = self.forward(char_ids, mask) return self.crf.decode(logits, mask=mask)逻辑说明:hidden_dim 取 256 时,BiLSTM 两个方向各输出 128 维,拼接后刚好是 256,送进全连接层不浪费也不欠拟合。CRF 接收的 logits 是每个字在每个标签上的得分,mask 告诉它哪些位置必须忽略。训练时forward返回负对数似然,预测时decode用维特比解码出全局最优标签序列。
4.2 带 mask 的 CRF 损失怎么算
CRF 的损失和普通交叉熵不同,它计算的是整条标签序列的概率负对数。直观理解:模型给每个字生成一组成分,CRF 在其中找出所有合法标签序列的总概率,然后让正确序列的概率最大。因此 mask 的位置必须和 padding 严格对应,否则 CRF 会把 padding 位置当成额外标签参与转移概率计算,训练出的模型在预测时会对补齐位置产生幻觉。
参数选择上,embedding_dim 常用 128,hidden_dim 常用 256,num_layers 设 2。dropout 0.5 在数据量小的时候能明显抑制过拟合。如果验证集 F1 一直在涨但训练集 F1 接近 1,说明过拟合,把 dropout 调到 0.6 再看一轮。
4.3 训练循环、早停与模型保存
训练循环的核心代码如下:
model = BiLSTMCRF(len(vocab), 128, 256, len(label_to_id)) optimizer = torch.optim.Adam(model.parameters(), lr=0.001, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, mode="max", factor=0.5, patience=2 ) for epoch in range(60): model.train() for char_ids, label_ids, mask, _ in loader: optimizer.zero_grad() loss = model(char_ids, mask, label_ids) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0) optimizer.step() val_f1 = evaluate(model, dev_loader, label_names) scheduler.step(val_f1) if val_f1 > best_f1: best_f1 = val_f1 torch.save(model.state_dict(), "best_model.pt") else: if early_stop >= 5: break说明几个关键点:weight_decay 设 1e-4 相当于 L2 正则,配合 dropout 一起防过拟合;clip_grad_norm 限制梯度模长不超过 5.0,避免 LSTM 在长句子上梯度爆炸。学习率 0.001 是 Adam 的常见起点,ReduceLROnPlateau 会在验证 F1 连续两轮不涨时把学习率减半。早停用 5 轮,如果验证 F1 连续 5 个 epoch 没有刷新,就停止训练,加载best_model.pt用于后续预测。
这里一定要强调:模型保存的触发条件是验证集 F1,不是训练集 loss,更不是训练集 F1。前者反映了真实泛化能力,后者只是在自欺欺人。
5. 电子病历 NER 最容易踩的五个坑:现象、原因、解决
这部分全部来自实际复现过程中的血泪经验。每一条都按“现象 → 原因 → 解决”的顺序展开。
5.1 模型预测全是 O,实体一个都识别不出来
现象:训练完成后对测试集预测,结果几乎全是 O,偶尔吐出几个单字实体,几乎没有连续的实体片段。训练过程中 loss 在下降,但验证集 F1 一直是 0 或接近 0。
原因:电子病历里 O 标签占比通常超过 85%,症状和药名这类实体只占很小比例。模型随便输出一长串 O 就能把损失压得很低,CRF 也发现转移成 O 最保险,于是陷入局部最优。
解决:第一步,检查数据里有没有实体类别缺失,比如某个实体类型只在训练集出现 20 次,模型基本学不到。第二步,给损失或 CRF 转移加入类别先验:对 O 标签的 logits 乘一个小权重,或者提高 B 标签的初始转移分。第三步,最简单也最有效的方式,训练时对数据做随机丢弃,让每个 batch 里含实体的句子比例不低于一半,避免模型长期只看到 O 样本。
5.2 DataLoader 的 mask 漏传,验证集 F1 虚高
现象:训练时 loss 正常下降,验证集 F1 看起来很高,但把预测结果打印到原文上,发现实体边界乱跑,和标注对不上。把 F1 算出来吓人一跳,再仔细看,发现 padding 位置也被当作 O 标签参与评估了。
原因:collate_fn 里 label 的 padding 位置填了 0,而 0 恰好是 O 标签。模型预测 padding 区域时也输出 O,评估代码如果没按 mask 过滤,这些位置会被当成正确预测的正样本,把 F1 顶高。另外,CRF decode 时如果漏传 mask,padding 位置也会参与维特比路径,导致末尾出现一堆人为标签。
解决:padding 标签统一填-1,评估时把-1全部过滤掉;CRF 的decode或forward必须传 mask。写成一条硬性规则:任何用到 mask 的地方,都要确认 token 和 label 两侧同时对齐,不能只对一边做 mask。
5.3 CRF 让训练“肉眼可见”变慢,然后盲目调参
现象:从 Softmax 换成 CRF 后,每个 epoch 的耗时变成原来的两到三倍,GPU 利用率不高,占用却不低。有人为了“省时间”把 max_len 调小,结果长实体被截断,F1 反而掉了。
原因:CRF 损失需要计算整条序列所有路径的转移概率,复杂度大致是“序列长度 × 标签数平方”。如果序列长度从 128 加到 256,耗时增加接近一倍;标签类别从 7 类加到 13 类,转移矩阵的复杂度也成倍增长。
解决:不要盲目动 max_len,先用数据统计确定 95% 句子的长度阈值。更常见也更好用的办法是动态 batch:按句子长度排序,把长度相近的样本组成一个 batch,padding 造成的浪费会大幅降低。实践下来,在不改模型的前提下,动态 batch 通常能让训练提速 40% 到 60%。
5.4 CPU 和 GPU 上预测结果不一致,部署阶段翻车
现象:训练在 GPU 上完成,导出模型后用 CPU 推理,同一句病历得到和训练时不同的实体边界。有时只差一两个字,有时整个实体丢一半。
原因:大概率是推理时忘了把模型切到 eval 模式。dropout 在推理时还在随机丢弃神经元,导致每次预测结果都略不一样。另一个原因是训练和推理时用了不同的数据预处理,比如 GPU 训练流程里做了全角转半角,CPU 推理脚本里忘了做,字符序列不同,边界自然不同。
解决:在预测代码里强制加两行:
model.eval() with torch.no_grad(): pred_ids = model.decode(char_ids, mask)另外把文本标准化逻辑抽成一个独立函数,训练和推理共用同一个文件里的同一个函数,不要各自写一份。也可以固定全局随机种子,让 pytorch 的 dropout 行为尽量稳定。
5.5 全角空格和病历口语让实体边界“玄学”偏移
现象:同一份数据,训练两次,模型对大部分实体都能稳定识别,唯独一些包含括号、冒号、全角空格的病例,实体落点经常偏一个字。看标注数据看不出毛病,但预测结果就是差一个空格的位置。
原因:电子病历里经常混着全角冒号、全角括号和半角括号,同一个实体在不同病历里写成“CT”和“CT”两种形式。模型把全角空格当成一个正常字符输入,实体边界被它顶开;另外“予以”“收治”“查体”这类口语化连接词和实体紧贴,会影响 LSTM 对边界位置的判断。
解决:数据清洗阶段统一做映射,全角数字和字母转半角,全角空格替换为半角空格,多个连续空格合并为一个;括号统一为半角。还要注意,清洗和标注必须用同一份文本,不能在标注完成后再做清洗,否则标注偏移全乱。这个坑我至少翻过两次车,每次都是因为清洗脚本和数据标注顺序不一致。
6. 从评估到进阶:把模型输出还原成可用的实体表
训练完模型只是第一步,要投入使用,还要解决“怎么算好”和“怎么接进业务”这两个问题。
6.1 用十行代码算实体级 F1
医疗场景的业务方真正关心的是实体有没有完整识别,而不是单个字符分对没有。用 seqeval 按实体级别评估:
from seqeval.metrics import classification_report true_sequences = [["O", "B-SIG", "I-SIG", "O"], ...] pred_sequences = [["O", "B-SIG", "I-SIG", "O"], ...] print(classification_report(true_sequences, pred_sequences))它会把每个实体片段当成一个整体计算精确率、召回率和 F1。和字符级准确率相比,实体级 F1 更贴近业务真实观感,也是模型选型和参数调整的主要依据。
6.2 从 BiLSTM+CRF 平滑迁移到 BERT+CRF 的衔接点
数据量足够大、GPU 资源允许时,可以把手写 BiLSTM 换成预训练 BERT。中文 BERT 的 tokenizer 基本按字切分,原来按字对齐的标签体系可以原样保留。升级路径是:把 Embedding 和 BiLSTM 换成 BERT 输出,后面再接 FC 和 CRF,训练时先冻结 BERT 参数跑几个 epoch,再全量微调。由于 CRF 的结构和代码完全不变,从 BiLSTM 到 BERT 的迁移成本主要花在 tokenizer 和显存调优上,模型代码改动其实不大。
我个人的习惯是在每次训练结束后,随机抽 20 条验证集样本,把原文、标注、预测结果并排打印出来,人工过一遍。这一步能发现 F1 反映不出来的问题,比如多个实体紧贴时边界错一位、嵌套实体只识别了外层、药物名拆成两半。准确率指标再高,也不如一版看得见的预测结果踏实。希望这套从数据到训练的流程,能帮你更快把这个中文电子病历 NER 项目跑通并放进自己的业务里。
本文还有配套的精品资源,点击获取