ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

中文电子病历NER实战:基于PyTorch的BiLSTM-CRF实现与踩坑指南

中文电子病历NER实战:基于PyTorch的BiLSTM-CRF实现与踩坑指南

简介:基于Python与PyTorch实现的中文电子病历命名实体识别完整项目,面向自然语言处理学习者和医疗信息处理研究人员。项目覆盖NER核心流程,包括中文分词、文本预处理、BIO标注、实体字典构建、模型训练与准确率、召回率、F1值等评价指标,代码结构清晰,含数据管理、工具函数、模型定义与主程序模块,并配套说明文档。包体共2000个文件,以1994个txt病史语料文本为主,另有5个Python脚本和1个README说明文件,压缩包仅11.22MB,轻量易部署。已有114人学习下载。通过该项目可掌握基于PyTorch构建序列标注模型的完整思路,直接复用数据预处理与训练脚本,结合真实病历语料开展实战,有助于快速搭建电子病历实体识别系统,提升医疗信息处理效率。

1. 这个zip包能帮你少写多少代码:中文电子病历NER的实战起点

拿到“基于python+pytorch实现的中文电子病历命名实体识别.zip”这份源码包,等于拿到一条能直接复现的中文医学NER技术路线:用Python和PyTorch,把非结构化病历文本里的症状、疾病、检查、药品、手术等信息,按“边界+类型”抽取出来。它能解决的问题很具体——病案首页编码、临床科研队列筛选、药品不良反应监测,都需要先把自由文本转成结构化字段。适合三类人:医学NLP方向的研究生、医疗信息化公司里做数据治理的工程师、想拿真实中文序列标注项目练手PyTorch的开发者。这个方向值不值得投入,核心看两件事:标注成本能不能接受、模型上线后误报能不能容忍。

2. 把病历文本切成模型能学懂的样本:BIO标注与滑窗切分

中文电子病历和新闻文本差别很大:句子大量省略主语、口语化强、中文数字和英文缩写混在一起。直接把通用NER那套标注搬过来,训练时处处碰壁。动手写模型之前,先把数据准备做扎实。这一章说的就是数据准备阶段最常见的三件事:定标注格式、做滑窗切分、按患者分组划分数据集。

2.1 标注格式选BIO还是BMES:先定实体类型,再定标签

命名实体识别(NER)的标注格式有很多种,中文医学场景里最常用的是BIO和BMES。BIO把每个token标记为B-X(实体开头)、I-X(实体内部)或O(非实体),BMES多了一个S-X(单字实体)。电子病历里“痛”“热”这种单字症状很常见,用BMES在单字实体的边界表达上更干脆;但BMES的标签数量翻倍,五类实体加O要从11个标签变成21个标签,标注和调试成本更大。源码包默认BIO是合理的,因为大多数标注任务里实体都在两个字符以上,BIO处理单字实体的方式是B-SYM加一个空格后直接接O,解码时同样能正确还原。

实体类型需要提前定死。我跑电子病历项目时通常先和标注方确认五类:症状/体征(SYM)、疾病/诊断(DIS)、检查/检验(CHECK)、药品(DRUG)、手术/操作(OPERATION)。这套类别覆盖了病程记录和出院小结里绝大多数信息抽取需求。类型定义直接影响标签映射表,表结构长这样:

实体类型含义病历示例BIO标注
SYM症状、体征活动后胸闷B-SYM I-SYM I-SYM I-SYM
DIS疾病、诊断冠状动脉粥样硬化性心脏病B-DIS I-DIS I-DIS I-DIS I-DIS
CHECK检查、检验胸部CTB-CHECK I-CHECK
DRUG药品、用药阿司匹林肠溶片B-DRUG I-DRUG I-DRUG I-DRUG
OPERATION手术、操作经皮冠状动脉介入治疗B-OPERATION I-OPERATION I-OPERATION I-OPERATION

有了类型和格式,下一步就是写一个把标注转成token级标签的脚本。标注如果来自brat或labelstudio,通常导出的是字符级偏移量加实体类型。下面的代码把json格式的标注转换成长度与文本一致的标签数组:

def make_bio_labels(text, entities, type_to_id): """ 把实体级标注转成BIO序列。 entities: [{"start": 0, "end": 4, "type": "SYM"}, ...] start/end是字符下标,end不包含边界。 """ labels = ["O"] * len(text) for ent in entities: start, end = ent["start"], ent["end"] t = ent["type"] if end <= start: continue labels[start] = "B-" + t for i in range(start + 1, end): labels[i] = "I-" + t return labels

这段代码最需要注意的是下标对齐。Python里len()统计字符数,中文和英文都按一个字符算,和标注工具里按字符计数的偏移一致;但如果你在标注之前先做了全角半角转换或去空格,偏移就会全面错位。我一般建议标注前先做一次文本规范化,再让标注工具导出偏移,否则转换脚本会出现实体错位到一句话之外的情况。

如果标注方导出的是原文和实体文本而不是偏移量,就需要先用str.find()定位实体位置。这个做法能应付大多数情况,但遇到“胸闷”在文本里出现多次时,必须从左到右逐次查找,否则会重复匹配到同一个位置,导致后面实体全部对应不上。

2.2 滑窗切分与实体完整性校验:一个函数解决边界切碎问题

病历文本动辄几千字,模型不能一次吃完整篇。常见做法是把长文本切成定长窗口,每个窗口是一个训练样本。但直接按固定长度硬切,实体很容易被拦腰切断:比如“经皮冠状动脉介入治疗”如果正好在第126个字符处被切开,切成“经皮冠状动脉介入”和“治疗”两段,标注就废了。切窗函数必须做实体完整性校验。

def sliding_window(text, labels, entities, max_len=128, step=64): samples = [] n = len(text) i = 0 while i < n: end = min(i + max_len, n) # 检查窗口结尾是否落在某个实体内部,若是则扩到实体末尾 for ent in entities: ent_start, ent_end = ent["start"], ent["end"] if ent_start >= i and ent_start < end and ent_end > end: end = ent_end break samples.append((text[i:end], labels[i:end])) if end == n: break # 下一个窗口往右挪step步长,保留重叠区 i = i + step return samples

这里的step是窗口滑动步长,建议设为max_len的一半。重叠区的存在不是为了做数据增广,而是保证那些落在两窗交界处的实体至少有一个完整窗口能容纳它。max_len这个参数要结合模型一起调:BiLSTM-CRF对序列长度不敏感,但CPU训练时128和256的耗时差距接近一倍;如果后续要换BERT做对比,max_len超过512会被BERT的位置编码卡死,所以源码包里默认128是一个稳妥值。

切窗之后要做一次校验:统计每个窗口里是否存在“B-SYM后面紧跟着O或B-DIS”这样的非法标签序列。出现这种情况,几乎可以断定是切窗或偏移问题,不要带病训练。可以直接写断言:

def validate_samples(samples): for text, labels in samples: for i in range(1, len(labels)): if labels[i].startswith("I-") and labels[i-1] == "O": raise ValueError(f"非法标签序列:{text[max(0,i-3):i+3]}")

这个断言会拦住一类隐蔽错误:实体正好从窗口起始处被切掉前半段,使得后半段以I-开头。训练时模型看到大量这种样本,会学到“I-可以出现在O后面”的错误规律,最后解码出来的实体全是残废的。

2.3 按病历文件切分train/dev/test:先别急着shuffle

数据切分是大部分新手翻车最狠的一步。很多人拿到标注好的句子,直接random.shuffle然后按比例切分,训练出来的模型在验证集上F1高达95%,一到真实病历就掉到75%。原因不在模型,而在同一个患者的病历片段被同时分进了训练集和验证集。

电子病历有个特点:同一个患者的入院记录、病程记录、出院小结在措辞上高度重复,比如“患者仍有胸闷、气短”这句话会出现在该患者的多份文档里。如果按句子随机切分,同一句话的变体会同时出现在两边,验证集就成了开卷考试。正确做法是按病历文档ID做分组切分,保证同一个患者的所有文档只落在某一个集合里。

from sklearn.model_selection import GroupShuffleSplit def split_by_patient(samples, doc_ids): """ samples: 切好的窗口样本 doc_ids: 与samples等长的数组,标识每段样本来自哪个患者/哪份病历 用患者ID做分组切分,避免同源文本泄漏到训练集和验证集。 """ gss = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42) train_idx, dev_idx = next(gss.split(samples, groups=doc_ids)) return samples[train_idx], samples[dev_idx]

GroupShuffleSplit的groups参数传的就是患者ID。如果没有现成的患者ID,至少要按病历文件名分组,不能按句子分组。有经验的标注团队还会把test集单独按时间段留出,比如取上个月的病历做测试,这样能真实反映模型在时间漂移之后的可用程度。train/dev/test的比例,我常用7:1.5:1.5或按患者数6:2:2,病历量少的时候test集占比可以再降,但不要低于10%,否则实体级别的P/R/F1波动太大,调参时看不出真实差异。

3. 用PyTorch写BiLSTM-CRF:从特征提取到维特比解码

数据准备完,进入核心模型环节。这个zip包里最值得读的代码就是模型定义部分。很多开源实现把CRF当成一个黑匣子,调包跑通就结束了;但改损失函数、调转移矩阵初始化时,不理解内部机制根本无从下手。所以这一章把BiLSTM-CRF的结构拆开,重点讲清楚CRF层到底在算什么。

3.1 为什么是BiLSTM-CRF:局部打分与全局约束的分工

中文电子病历NER在几千份病历、几十万token标注规模下,BERT类模型能提升的F1通常只有2到4个点,训练时长却从分钟级推到小时级。BiLSTM-CRF是这个资源规模下性价比最高的方案,也是这份源码包默认的模型结构。

BiLSTM负责从上下文里抽取每个token的特征表示,输出的是“每个字符属于每个标签”的发射分数(emission score)。但纯BiLSTM有一个绕不过去的坑:它对每个token独立分类,完全不看前后标签是否合法。实际病历里“活动后胸闷”这四个字在纯BiLSTM下可能被解成B-SYM I-SYM B-SYM I-SYM,同一个实体的开头位置出现了两次,这在物理上不合法。CRF层解决的就是这个问题:它把标签之间的转移概率作为参数学习,在解码时全局搜索一条得分最高且合法的标签路径。一句话总结分工:BiLSTM看“这个字像不像实体开头”,CRF看“上一个标签能不能接这一个标签”。

3.2 模型骨架:embedding、双向LSTM与全连接输出

先看整体结构。代码里模型定义分成三个部分:词嵌入层、双向LSTM编码器、全连接映射到标签空间。

import torch import torch.nn as nn class BiLSTMCRF(nn.Module): def __init__(self, vocab_size, tag_size, embedding_dim=128, hidden_dim=256, 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, 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 * 2, tag_size) self.crf = CRF(tag_size) def forward(self, input_ids, mask): # input_ids: (batch_size, seq_len),mask: (batch_size, seq_len) emb = self.dropout(self.embedding(input_ids)) lstm_out, _ = self.lstm(emb) lstm_out = self.dropout(lstm_out) emissions = self.fc(lstm_out) return emissions

几个关键参数值得展开说。padding_idx=0意味着词表里0号位置固定是PAD,语义上它不会被更新,等于把所有padding字符的特征固定为零向量。bidirectional=True时,LSTM输出维度是hidden_dim的两倍,因为两个方向的隐状态在时间步上拼接,这就是fc层输入维度写成hidden_dim * 2的原因。dropout放在embedding输出和LSTM输出两处,等于给特征注入随机噪声,防止LSTM把训练集里的个别措辞死记下来;预测时dropout自动关闭,不需要手动处理。

emissions的名字在后续代码里会反复出现。它的形状是(batch_size, seq_len, tag_size),每个位置的值表示“该字符属于某个BIO标签”的置信度,但这个分数没有经过归一化,要在CRF的全局打分里才能变成合法路径的概率。

3.3 CRF层手写实现:转移矩阵、负对数似然与维特比解码

CRF层是这份源码包里含金量最高的部分。先定义转移矩阵:它的形状是(tag_size+1, tag_size+1),多出来的一行一列分别代表START和END,表示“从START转移到第一个真实标签”和“从最后一个真实标签转移到END”的分数。初始化时把矩阵取值缩小到0.1倍,防止初始转移概率在训练早期主导发射分数。

class CRF(nn.Module): def __init__(self, tag_size): super().__init__() self.tag_size = tag_size self.start_idx = tag_size self.end_idx = tag_size # 多给两个维度分别作为START和END self.transitions = nn.Parameter(torch.randn(tag_size + 1, tag_size + 1) * 0.1) def _forward_alpha(self, emissions, mask): """ 前向算法:计算到每个时刻、每个标签的所有路径得分之和(logsumexp)。 emissions: (batch_size, seq_len, tag_size) mask: (batch_size, seq_len),padding位置为0 返回整个序列的归一化常数Z。 """ bsz, seq_len, tag = emissions.shape alpha = torch.full((bsz, tag), -1e12, device=emissions.device) # 从START到第一个token alpha = emissions[:, 0] + self.transitions[self.start_idx, :tag] for t in range(1, seq_len): # 上一时刻所有标签分数 + 任意两标签间的转移分 scores = alpha.unsqueeze(2) + self.transitions[:tag, :tag].unsqueeze(0) scores = scores + emissions[:, t].unsqueeze(1) # (bsz, tag, tag) new_alpha = torch.logsumexp(scores, dim=1) # padding位置不做更新 alpha = torch.where(mask[:, t].bool().unsqueeze(1), new_alpha, alpha) # 最后统一转移到END return torch.logsumexp(alpha + self.transitions[:tag, self.end_idx], dim=1)

这段代码是CRF训练的核心。alpha在每一时刻维护一个(batch_size, tag_size)的矩阵,代表“走到当前字符且以某个标签结尾的所有合法路径的logsumexp得分”。转移矩阵的unsqueeze(0)是为了让每个batch样本共享同一套转移参数,这是合理的,因为标签转移规律在不同病历里是一致的,不需要按样本区分。

训练时的损失函数是负对数似然(NLL)。真实路径的得分单独计算,然后把“真实路径得分”和“所有路径的logsumexp得分”相减,得到的就是负对数似然,PyTorch的优化器要最小化这个值:

def forward_loss(self, emissions, tags, mask): # 真实路径得分 score = self._score_sent(emissions, tags, mask) # 所有路径的归一化常数 norm = self._forward_alpha(emissions, mask) return -(score - norm).mean()

这边有一个新手很容易看懵的点:NLL的数值是正的还是负的?真实路径得分通常是一个很大的负数,所有路径的logsumexp得分也是一个很大的负数但比真实路径得分大,两者相减得到一个正数,取负后loss是正的。如果你在代码里看到loss是负数,大概率是符号方向写反了,把“log似然”当成了“loss”。这个坑在避坑章节会详细说。

推理时不需要计算前向算法,改用维特比解码,目标是在所有合法路径里找到得分最高的一条:

def decode(self, emissions, mask): bsz, seq_len, tag = emissions.shape emissions = emissions + (-1e12) * (~mask.bool()).unsqueeze(-1) dp = emissions.new_full((bsz, seq_len, tag), -1e12) bp = emissions.new_zeros((bsz, seq_len, tag), dtype=torch.long) dp[:, 0] = emissions[:, 0] + self.transitions[self.start_idx, :tag] for t in range(1, seq_len): for s in range(tag): # 计算所有上一标签转移到s的得分 scores = dp[:, t-1] + self.transitions[:tag, s] + emissions[:, t, s] best = scores.argmax(dim=-1) dp[:, t, s] = scores.gather(-1, best.unsqueeze(-1)).squeeze(-1) bp[:, t, s] = best # 从最后时刻回溯 best_last = (dp[:, -1] + self.transitions[:tag, self.end_idx]).argmax(dim=-1) paths = [best_last.unsqueeze(1)] for t in range(seq_len - 1, 0, -1): prev = bp[:, t].gather(-1, paths[-1]).squeeze(-1) paths.append(prev.unsqueeze(1)) paths = torch.cat(paths[::-1], dim=1) return paths

维特比解码里有个细节值得注意:emissions在padding位置被加上了-1e12,让这些位置的分数不可能成为路径的最大值,同时dp初始化为-1e12。这是为了避免模型在padding位置上“脑补”出实体,尤其是那些以真实实体结尾、后面跟着一长串PAD的样本。bp矩阵存的是每个时刻每个标签的前驱标签索引,回溯时从最后一刻往前串,最后翻转成正向序列。

转移矩阵在这个模型里是可学习的。训练初期它接近随机初始化,模型输出会显得混乱;训练充分后,矩阵中“B-SYM到I-SYM”“I-SYM到I-SYM”“I-SYM到O”这些转移项会明显高于其他项,这就是CRF学到的标签约束。如果训练完发现B类和I类之间的转移分仍然很低,排查重点不是模型,而是标注数据里是否存在大量边界不一致的样本。

4. 训练一轮看效果:环境、超参与早停策略

模型结构搭好,接下来要把它真正训起来。源码包能跑到什么程度,很大程度取决于环境配置和几个关键超参。这一章从环境搭建、训练脚本到早停策略完整过一遍。

4.1 先确认环境再动手:PyTorch、CUDA与CPU兜底

先花十分钟把环境理顺比急着跑训练脚本更划算。PyTorch的安装方式取决于机器有没有可用GPU。先用nvidia-smi看一下显卡驱动支持的CUDA版本,再按对应版本安装PyTorch。用Anaconda管理环境是最省心的方式:

conda create -n ner python=3.10 conda activate ner # CPU版本 pip install torch --index-url https://download.pytorch.org/whl/cpu # GPU版本,以CUDA 11.8为例 pip install torch --index-url https://download.pytorch.org/whl/cu118

CUDA版本和PyTorch版本必须匹配,装错版本最常见的现象是torch.cuda.is_available()返回False,代码却按GPU逻辑去加载模型,速度不升反降。如果没有NVIDIA显卡,直接用CPU版就行,BiLSTM-CRF在这个数据规模下CPU也能在几十分钟内训完;在Windows上用WSL跑PyTorch也是常见做法,但要注意WSL内同样要先装好对应驱动再装PyTorch,否则检测不到GPU。AMD显卡的用户如果搜不到合适CUDA版本,建议直接用ROCm版或退回CPU训练,不要在这上面耗时间。

环境配好后写一个最小验证脚本,确认以下三行能通过:

import torch print(torch.__version__) # 例如 2.1.0 print(torch.cuda.is_available()) # False表示CPU模式 print(torch.version.cuda) # CPU模式下可能为None,正常

4.2 训练主循环:优化器、梯度裁剪与学习率

训练脚本的结构不复杂,但每一步都有讲究。优化器用Adam,学习率默认1e-3对BiLSTM-CRF是合理的起点;如果后面把backbone换成了BERT或RoBERTa,学习率必须降到2e-5到5e-5,否则预训练权重会被大步长冲坏。batch_size要看显存和内存:GPU上64到128都行,CPU上16到32比较稳妥,太大容易把内存打满。

import torch.optim as optim from tqdm import tqdm def train_epoch(model, loader, optimizer, device): model.train() total_loss = 0 for batch in tqdm(loader): input_ids, labels, mask = [x.to(device) for x in batch] emissions = model(input_ids, mask) loss = model.crf.forward_loss(emissions, labels, mask) optimizer.zero_grad() loss.backward() # 梯度裁剪:防止长句路径打分造成的梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) optimizer.step() total_loss += loss.item() * input_ids.size(0) return total_loss / len(loader.dataset)

梯度裁剪这行很多人会省略,但CRF的损失计算里包含整条路径的得分求和,长句的梯度范数天然偏大,尤其在训练初期转移矩阵还没收敛时,偶尔一个batch会让loss跳到几百。max_norm=5.0是一个不太保守也不激进的默认值,如果loss曲线频繁出现尖峰,把它降到1.0。

学习率和batch_size的关系也值得说一句:小batch下的梯度噪声大,学习率要相应调低;大batch下的梯度更平滑,可以适当调高学习率。同样的模型和数据,batch_size从16调到128,学习率不跟着动,就可能看到loss缓慢爬坡或者震荡的曲线。

4.3 早停与随机种子:两个影响最终F1的隐藏因素

训练多少轮合适?不要靠感觉,用早停。监控指标不是loss,而是验证集上的实体级F1。每隔一个epoch算一次F1,如果连续3个epoch不涨,就回退到历史最优模型。这个“3”就是patience参数,病历数据噪声大,F1曲线会有小幅波动,patience设太小的1容易误杀,设5又太浪费时间,3是多数项目里比较平衡的值。

best_f1 = 0.0 patience = 3 bad_epochs = 0 for epoch in range(max_epochs): train_loss = train_epoch(...) dev_f1 = evaluate(model, dev_loader) if dev_f1 > best_f1: best_f1 = dev_f1 torch.save(model.state_dict(), "best_model.pt") bad_epochs = 0 else: bad_epochs += 1 if bad_epochs >= patience: break

随机种子对BiLSTM-CRF的影响比想象中大,同一份代码只改随机数,F1可以差出2到3个点。原因是CRF转移矩阵的初始取值会直接影响早期梯度方向,LSTM的参数初始化也一样,不同的起始点最后收敛到不同的局部最优。这个现象不是玄学,是这类模型在中等数据规模下的客观特性。固定随机种子是最划算的复现手段:

import random import numpy as np import torch def set_seed(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False

如果要做超参对比实验,务必每个配置用同样的种子起跑,否则无法判断F1的差异是来自参数还是来自随机性。cudnn.deterministic=True会让卷积和循环网络的计算变成确定性的,代价是略微降低速度,但在对比实验阶段值得。

训练阶段的超参配置可以汇总成一张表,方便对照调整:

超参数默认值调整方向
embedding_dim128增大提升表达力,CPU训练不建议超过256
hidden_dim256增大提升时序建模能力,显存随之上升
num_layers23层以上收益有限,训练时间明显变长
dropout0.5过拟合时调大到0.6
lr1e-3换BERT后调至2e-5
batch_size32(CPU)/ 64(GPU)显存不足时减半
max_len128长实体多时调大至256
max_grad_norm5.0loss尖峰多时降至1.0
patience3验证F1震荡大时调到5

5. 中文病历NER的常见坑:从数据泄露到标签不平衡

模型能跑通只算第一步。把这份源码包用到真实病历上,有几个坑几乎每批数据都会踩一遍。按“现象、原因、解决”的方式把它写透,遇到同类的报错和异常可以直接对号入座。

5.1 “住院号12345678”混进句子:隐私文本污染

现象:模型预测结果里,药品和检查实体后面经常跟一个很长的数字尾巴,有的甚至把“住院号20231015”整体识别成疾病名。
原因:病历里有大量日期、住院号、身份证号、联系电话,这些高价值token在训练数据里的分布非常特殊,模型很容易把它们当成实体的一部分。尤其当标注人员习惯把“胸部CT(2023-10-15)”里的日期也顺手标进检查实体时,模型就学会了“数字必须跟实体连在一起”。
解决:在数据预处理阶段做一次正则清洗,把日期、纯数字序列替换成占位符,比如把“2023年10月15日”整体替换为“[DATE]”,把11位手机号替换为“[PHONE]”。这个清洗步骤必须在标注之前做,如果标注已经完成,至少要在生成训练样本时把占位符和原始文本对齐。

import re def clean_phi(text): text = re.sub(r"\d{4}年\d{1,2}月\d{1,2}日", "[DATE]", text) text = re.sub(r"\d{11}", "[PHONE]", text) text = re.sub(r"住院号[::]?\s*\d{5,}", "[INPATIENT_ID]", text) return text

替换成占位符之后,模型看到的是固定的token,不会再去记忆某个具体号码。要注意的是,实体标注里的偏移也要同步更新,最稳妥的顺序是先clean再标注,否则要在标注转换脚本里加偏移修正逻辑,很容易出错。

5.2 dev集F1虚高:同一患者同时出现在训练集与验证集

现象:训练过程中验证F1一路涨到0.93,测试集表现也不错,但换到一批新病历上F1直接掉到0.70。
原因:这是本章最容易忽略的一个坑——数据切分时没有按患者分组。同一个患者的住院记录、护理记录、出院小结在措辞上大量重复,“患者症状明显好转”这类套话会原封不动出现在同一患者的多份文档里。按句子随机切分,等于把同一句话放到训练集和验证集各一份,模型背下来了。
解决:切分必须按患者ID或病历文档ID做group级别的划分。第一次做电子病历NER的人,可以先写个统计脚本检查一下:训练集和验证集里重复句子的比例,如果超过5%,切分策略就要重做。验证集F1高于测试集F1太多(超过8个百分点)也是这个问题的典型信号。

5.3 实体边界不一致:标注规范冲突

现象:同一个病历里,“右肺上叶磨玻璃结节”有时被标成“结节”,有时被标成“右肺上叶磨玻璃结节”全称;“冠心病”和“冠状动脉粥样硬化性心脏病”被当成两种疾病,但医生都指的是同一个病。
原因:电子病历的实体边界不像新闻里的地名、人名那么清晰。修饰词“右肺上叶”是否并入实体、“磨玻璃”是不是疾病的一部分,不同的标注员理解完全不一样。CRF学到的是“相邻标签转移的统计规律”,如果同一个实体有三种边界标法,转移矩阵会被拉向中间态,解码时自然不稳定。
解决:训练前必须有一份标注规范,至少定清楚三条:解剖部位是否并入实体、影像学描述是否单独成实体、同义疾病名怎么统一。有条件的话,让两个标注员各标一遍,算一下Kappa一致性系数,低于0.8就先讨论规范再重标。没有规范就开训,后面所有调参都是在弥补数据问题,效率极低。

5.4 loss变负数、F1不涨:两个现象的真相

现象:训练日志里loss一开始是正的,慢慢下降,突然变负;还有一批反过来的,loss一直在降,但实体级F1纹丝不动。
原因:loss变负不是bug。我们在代码里计算的是负对数似然(NLL),它确实是正的;但有些实现里forward_loss返回的是“真实路径得分减去logsumexp归一化常数”,这个值的期望接近真实路径的平均得分。真实路径得分通常是负的,所以loss变负是正常的,只要曲线单调下降且斜率渐缓,训练就健康。至于loss降但F1不涨,是因为loss统计的是token级别的平均误差,而电子病历里O标签占比超过85%,token级loss主要被O类主导,模型只要“学会说O”就能把loss压下去,但实体的精确边界可能完全没学到。
解决:训练过程中的早停指标一定用实体级F1,不要用token accuracy或loss。实体级F1的计算方式是:把预测的BIO序列转成实体列表,再按“边界+类型完全匹配”和gold实体做对比。统计token级准确率没有意义,在O占比85%的场景下,全预测O就能有85%的准确率,但它什么实体都没抽出来。

5.5 标签不平衡:90%的token是O

现象:模型在验证集上F1卡在0.82左右,怎么调学习率和batch_size都上不去;查看bad case发现,模型漏掉的多是实体较长、边界模糊的疾病名和手术名。
原因:一个窗口128个token里通常只有3到8个token是实体标签,O标签占比超过90%。BiLSTM-CRF虽然比纯LSTM抗不平衡,但转移矩阵里“O到O”的转移项会被大量训练样本推得很高,导致解码时模型倾向于输出O。
解决:三个手段可以组合用。第一,监控指标和早停都看实体级F1,尤其关注recall。第二,滑窗时对包含长实体的样本做一遍过采样,让batch里实体比例不至于过低。第三,如果某些实体类别(比如手术名)样本极少,可以在损失函数里对不同标签施加权重,但效果有限,最有效的还是把这一类别的训练数据补到足够规模。

def oversample_long_entity(samples): """把包含长实体的样本复制一份加入训练集,缓解O标签主导。""" augmented = [] for text, labels in samples: if any(ent_type == "OPERATION" for lab in labels if lab.startswith("I-") for ent_type in ["OPERATION"]): augmented.append((text, labels)) samples.extend(augmented) return samples

这一章的五条经验,基本对应了把这批源码包用真跑一遍时最常遇到的五类问题。其中数据切分和实体边界两条属于“教科书不会写但必踩”的那种,务必在数据阶段就处理好,不要拖到模型训练后再返工。

6. 让输出回到临床:BIO转实体、坏样本分析与方案迁移

模型训练完,输出只是一串BIO标签,离“能用”还差一步:把它还原成结构化实体列表。这章做一个预测脚本,再做一次坏样本分析,最后说说这套方案换到其他病历类型时怎么迁移。

6.1 用一段predict把BIO序列还原成实体列表

从BIO序列还原实体看似简单,但有个边界细节容易写错:I标签必须挂在同类型的B标签后面,如果遇到“I-SYM出现在O后面”或“I-SYM出现在B-DIS后面”的情况,要直接截断而不是继续累加。

def bio_to_entities(tokens, pred_labels): entities = [] cur_type = None cur_start = None for i, (tok, lab) in enumerate(zip(tokens, pred_labels)): if lab.startswith("B-"): if cur_type is not None: entities.append((cur_type, cur_start, i)) cur_type = lab[2:] cur_start = i elif lab.startswith("I-") and cur_type == lab[2:]: continue else: if cur_type is not None: entities.append((cur_type, cur_start, i)) cur_type = None if cur_type is not None: entities.append((cur_type, cur_start, len(pred_labels))) return entities

这里的else分支是很多实现会漏掉的。如果模型输出了“O O B-SYM I-SYM I-DIS I-SYM”,循环走到I-DIS时cur_type是SYM,二者不匹配,else会把前面的SYM实体截断,然后清空cur_type,后面的I-SYM因为没有B开头直接丢弃。这样处理虽然保守,但比把互不匹配的标签强行拼成一个实体要可靠得多。

6.2 坏样本分析看什么:不是看整体F1,而是看混淆

整体F1只能告诉你模型好不好,不能告诉你好在哪里、坏在哪里。把测试集里预测实体和标注实体做对齐,分两类统计:精确匹配(start/end/type完全一致)和宽松匹配(type一致且区间重叠超过50%),然后打印出混淆矩阵。最常见的混淆模式有几种:症状和疾病混淆(“肺部感染”既可能被标成SYM也可能被标成DIS)、检查和疾病混淆(“CT示肺部感染”里模型容易把“肺部感染”判定为CHECK的附属描述)、实体边界偏移(预测比标注多一个字或少一个字)。

针对混淆集中的类型,最高效的做法不是调模型,而是在预测脚本后面加规则后处理。比如“胸部CT、MRI、X线”等词出现在实体前15个字符以内时,检查类实体优先判定;看到“诊断明确:”这类前缀时,后面的描述符优先判定为DIS。模型负责解决大部分情况,规则负责把剩下20%的边界案件纠正过来,这是电子病历NER落地的常见组合。

6.3 从电子病历迁移到影像报告:哪些要重训,哪些能复用

换到CT报告、检验报告单、手术记录时,这个方案大部分能复用,但有几件工作躲不掉:重新定义标签集、重建词典、重做数据切分和清洗。影像报告的实体往往是“病灶部位+影像征象+结论”,护理记录则是“护理操作+症状观察”,和电子病历的五类实体重叠有限。

模型结构不需要改,BiLSTM-CRF的骨架可以原样迁过去;需要重训的是词表和CRF转移矩阵。医疗文本词汇分布差异很大,影像报告里的“磨玻璃影、分叶征、毛刺征”在病历里几乎不出现,直接用病历的词表会丢掉大量信息。重新建词表时把词表下限设为2,也就是说出现次数小于2的字符直接映射为UNK,这个阈值在医疗场景里能有效控制词表大小。

迁移后验证模型是否及格,第一件事看的是新类型O占比是否合理。如果护理记录里护理操作类实体只占3%,模型很可能退化成全O预测;这时候先把训练集里护理操作相关的样本做一遍过采样,再谈调参。这套“先看标签分布、再做数据增强、最后才动模型结构”的顺序,是医疗文本NER里通用的。

我第一次用这类源码包做迁移时,第一件事不是跑训练,而是把手头五十份真实病历丢进坏样本分析脚本里看边界错位。结果发现数据清洗带来的收益远比调模型庞大——把日期占位符加上之后,检查类实体的F1一下涨了5个点。从那以后养成的习惯是:改代码不如先改数据,改模型不如先看混淆矩阵。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表