☰
中文电子病历命名实体识别:BiLSTM-CRF实战详解
2026/10/7 12:38:35 网站建设 项目流程

简介:这是一份基于BiLSTM-CRF网络的中文电子病历命名实体识别项目,包含Python源码与项目说明,面向自然语言处理初学者、高校计算机类专业学生以及需要完成课程设计或毕业设计的开发者。项目涵盖数据预处理、模型训练、评估与预测等完整流程,可直接运行调试,适合作为NLP序列标注任务的入门实践参考。

压缩包共999个文件,大小约84.55MB。其中798个txt文件为电子病历语料及各分项测试训练数据,17个py文件为模型构建、训练与评估脚本,另含模型检查点、数据索引、评估结果及标签文件等,便于定位训练日志与指标。压缩包内还附带checkpoint模型文件,可加载已训练参数进行复现。

目前已有127人学习下载。通过该资源,读者可学习BiLSTM-CRF实体识别原理、中文医疗文本特征处理方法,并获得一份结构完整的项目源代码,帮助理解序列标注工程的目录组织与调参思路。

1. 从电子病历里挖实体,为什么偏偏是 BiLSTM-CRF

住院病历、出院小结、超声报告里全是「患者因上腹痛伴恶心 2 天入院,既往有高血压病史 5 年」这类自由文本,如果不把症状、疾病、检查、药品抽成结构化字段,后面的科研统计、病历质控、临床辅助决策全都无从谈起。基于BiLSTM-CRF网络的中文电子病历命名实体识别,就是当前在标注数据有限、领域术语密集的场景下,性价比最高的一条落地路径——双向LSTM负责读上下文,CRF负责管标签之间的约束,两者配合能把「症状」「疾病」「检查」这些实体从病历原文里按边界切出来。适合正在做医疗信息化、病历后结构化或临床科研数据提取的Python开发者。这一章先讲清楚一个判断:这类任务为什么不要一上来就上BERT。

很多人拿到电子病历 NER 任务,第一反应是直接砸一个预训练模型。但医疗领域有一个现实问题:标注数据贵,而且病历里的表述和新闻语料差距极大,「肺部听诊未见异常」这种句子里的每一个字在通用语料里都见过,但组合方式完全不一样。BiLSTM-CRF 的优势在于它不依赖大规模预训练权重,把字符 embedding 随机初始化或者用一个小规模的领域语料训一下,就能在一个几万句的标注集上达到可用的 F1。训练时间短,一张普通显卡几十分钟能跑完,调参也直接,没有 BERT 那一堆玄学超参数。如果目标是先把手里的病历数据跑通、产出一版能用的结构化结果,这个方案是最稳的起手式,后续想提升再换 embedding 层也不迟。

2. 序列标注任务拆解:实体类型、标注体系与数据格式

2.1 实体类型先定清楚:六类起步,两类优先

中文电子病历的实体类型没有统一标准,不同项目差别很大。做肿瘤科研的要抽「肿瘤分期」,做药品不良反应监测的要抽「药物」和「不良反应」,但绝大多数病历后结构化项目有一批共性实体:症状与体征、疾病诊断、检查项目、检验项目、手术操作、药品、身体部位。先定六类起步,后面不够再加,这是我做医疗 NLP 项目一贯的做法。类型定太多会让标注成本直线上升,标注员在「症状」和「体征」之间反复纠结,返工率极高。

优先保证「症状/体征」和「疾病诊断」这两类的质量,因为它们是病历里出现频率最高、下游用途最广的实体。检查项目和药品一般有字典辅助,就算模型漏了几个,规则字典还能兜底;但症状和疾病写法太灵活,字典覆盖不住,必须靠模型去泛化。还有一类「身体部位」经常被忽略,实际上它在很多项目里是必须的——「左肺」「右肺」「上腹部」这些修饰限定,直接决定下游实体归一化能不能做。我见过不少项目一开始只标四类,后来做结构化字段时发现缺少部位信息,又回头补标,浪费大量时间。宁可前期多定一到两类,也不要后期返工。

2.2 标注体系选 BIO:为什么不用 BIOES

序列标注的标签体系一般有三种:BIO、BIOES、IO。IO 太松,所有实体标注都不区分边界起止,模型容易把相邻实体混成一个;BIOES 对实体边界描述更精确,在实体较长、嵌套较多的时候有优势,但它有五个标签状态,对标注和训练的计算量都更大。中文电子病历的实体普遍不长,几个字到十几个字,实体内部也没有嵌套关系,BIO 完全够用。BIO 还有一个实际好处:标注工具和代码生态最成熟,遇到问题容易搜到解决方案,团队协作时标注规范也更好讲。

BIO 体系里每个字的标签是:B-实体类型(实体的第一个字)、I-实体类型(实体其余字)、O(非实体)。比如句子「胃痛 2 天」,「胃」是 B-症状,「痛」是 I-症状,其余字是 O。训练时模型对每个位置输出一个概率分布,分布的维度数等于标签数。假设实体类型有 6 类,加上 BIO 前缀后标签总数是 6×2+1 个,也就是 13 个标签。这个数字直接决定了模型最后一层全连接的输出维度,也决定了 CRF 转移矩阵的尺寸,后面模型实现部分会用到。

2.3 数据长什么样:从标注文本到字符对齐序列

标注数据最常见的保存格式有两种:一种是 JSON 形式,文本和实体坐标分开存;另一种是「每行一个字 + 标签」的序列文件形式。前者适合人工标注和多人协作,后者适合直接喂给模型训练。两个格式之间需要一个转换脚本,而且转换过程最容易出的问题就是字符对齐——中文里全角半角、标点占位不一致,都会导致坐标偏移。项目说明里如果标了数据格式,一般就是这两种之一,转换函数却常常被当成「理所当然的小步骤」忽略掉。

下面这段脚本做的是把 JSON 格式的标注转换成标准序列格式。实体坐标是字符级别的,每个实体由 start 和 end 指定起止下标,end 不包含结尾。

def convert_json_to_seq(text, entities): """ 将 JSON 格式的实体标注转为 BIO 字符标签序列 entities 示例: [{"start": 0, "end": 2, "type": "症状"}, ...] """ labels = ["O"] * len(text) for ent in entities: start, end, etype = ent["start"], ent["end"], ent["type"] if end <= start or end > len(text): continue # 越界的标注直接跳过,避免训练时报错 labels[start] = f"B-{etype}" for idx in range(start + 1, end): labels[idx] = f"I-{etype}" return list(text), labels # 用法示例 text = "患者因上腹痛伴恶心2天入院" ents = [{"start": 3, "end": 6, "type": "症状"}] chars, labels = convert_json_to_seq(text, ents) for c, l in zip(chars, labels): print(c, l)

这个函数的核心逻辑是先把所有字符初始化为 O,再遍历每个实体把对应位置的第一个字改成 B-类型、其余字改成 I-类型。注意两点:一是 end 采用左闭右开区间,下标从 0 开始,end 位置的字不属于实体;二是要加一个边界检查,标注工具偶尔会出现 end 越界的情况,直接抛异常会打断整个转换流程,跳过并记录下来更稳妥。有时候标注文本里混了全角空格或者换行符,字符数量对不上,我会在转换之前统一做一步 normalize,把全角符号转半角、去掉不可见字符。

转换完后,训练脚本还需要把文本序列映射成数字索引。中文场景下一般按字建词表,每个字符有一个唯一 id,再加一个<PAD>用于补齐 batch 内不同长度的句子。词表大小通常在几千到两万之间,如果按词建词表,分词器本身的误差会直接影响模型效果,这也是我坚持用字级输入的原因之一。

3. 模型结构逐层落地:字向量、双向 LSTM 与 CRF 转移约束

3.1 用字还是用词:这是中文医疗 NER 的第一个选择

中文 NLP 的第一步永远是切分粒度问题。分词器在通用领域表现尚可,但到了电子病历里,「肺源性心脏病」「胃镜下黏膜剥离术」这类长术语经常被切得七零八落,而且切错的词会被 Word2vec 编码成完全不合理的向量,错误一路传导到下游模型。做中文电子病历 NER 最常见且可靠的做法是用字级输入——每个汉字作为一个 token,不依赖外部分词器。医疗领域实体很多是长度为 4 到 12 个字的组合,字级别的 BiLSTM 照样能通过上下文把边界学出来,而且词表更小、OOV 问题更轻。

字向量可以用三种方式来初始化。最省事的是随机初始化,让模型在训练中自己学习,适用于标注数据比较多的场景;其次是拿领域内的无标注病历语料训练一个 Word2vec 字向量模型,这种做法的效果好于随机初始化,且只需要几十 MB 的语料就能有可见提升;再往上是直接用预训练语言模型的动态向量,但那样就背离了选这个方案的初衷——复杂度上去了,训练时间也上去了。我一般会先在随机初始化的配置下把基线跑通,保存模型,再换 Word2vec 初始化对比一次,两次 F1 差距在 1 到 3 个百分点之间。差距不算大,但提升是白拿的,前提是你手头有足够多的未标注病历原文。

3.2 BiLSTM 编码层:两个方向的上下文怎么拼

BiLSTM 的结构说简单也简单:同一句话分别用正向 LSTM 和反向 LSTM 各读一遍,正向的隐藏状态包含从句子开头到当前位置的信息,反向的隐藏状态包含从当前字到句子结尾的信息,每个位置把两个方向的隐藏状态拼接起来,就得到了该字的上下文表示。这种设计对 NER 非常关键,因为确定一个实体边界需要同时看左右两侧——「发热待查」这个实体,左边有一句「因」、右边有「3 天入院」,模型只有同时看到两侧才能正确判断「发热」是症状而不是疾病。

隐藏状态维度是个值得细算的参数。我一般把 hidden_size 设在 128 到 256 之间,标注样本量在几千句的规模用 128 足够,超过三万句可以用 256。每层 LSTM 的方向隐藏维度是 hidden_size / 2,拼接后正好是 hidden_size,再经过全连接层映射到标签数量的维数。这里有一个新手容易困惑的点:nn.LSTM的hidden_size参数指的是每个方向的维度,所以当你想得到拼接后维度为 128 的上下文向量,里面要填 64。另一个常见的坑是 batch 内部句子长度不一,需要用 pack_padded_sequence 或者 mask 的方式跳过<PAD>位置的运算,否则 padding 位置的隐藏状态会干扰真实位置的表示。

import torch.nn as nn class BiLSTMEncoder(nn.Module): def __init__(self, vocab_size, embedding_dim, hidden_size, drop_prob=0.5): super().__init__() self.embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx=0) self.lstm = nn.LSTM( embedding_dim, hidden_size // 2, # 双向,拼接后为 hidden_size batch_first=True, bidirectional=True, ) self.dropout = nn.Dropout(drop_prob) def forward(self, token_ids, mask): embedded = self.embedding(token_ids) # [batch, seq_len, embedding_dim] embedded = self.dropout(embedded) # 传入 mask 让模型跳过 pad 位置的运算 packed = nn.utils.rnn.pack_padded_sequence( embedded, mask.sum(1).cpu(), batch_first=True, enforce_sorted=False ) lstm_out, _ = self.lstm(packed) lstm_out, _ = nn.utils.rnn.pad_packed_sequence(lstm_out, batch_first=True) return lstm_out # [batch, seq_len, hidden_size]

这里把 padding_idx 设为 0,词表里 id 为 0 的字符通常是<PAD>,embedding 层会自动为它生成全零向量,不再参与梯度更新。pack_padded_sequence 的作用是把变长序列按真实长度压缩后输入 LSTM,autopad 的效率远高于手动循环,也避免了 pad 位置的隐藏状态污染真实表示。dropout 放在 embedding 之后和 LSTM 之后各一层都可以,我习惯放在 embedding 之后,因为医疗语料噪点多,输入层的随机失活能在早期挡掉一部分噪声。第二个 dropout 我一般放在全连接映射之前,这个在后面的完整模型里会体现。

3.3 CRF 解码层:转移矩阵比 Softmax 强在哪

如果不用 CRF,每个位置直接套 Softmax 就能得到标签概率,但这样会造成一个严重的问题:预测结果里毫无约束地出现 B-症状 后面接 I-疾病 这种非法跳转,或者每一类标签的 I 标签直接接在 O 后面。病历里实体往往是连续成段的,标签之间存在强依赖关系——一个实体的第一个字必须是 B 开头,后续字必须是 I,实体结束后的标签不能直接跳到另一类实体的 I 上。Softmax 隐式假设标签之间相互独立,完全把标签序列的结构信息丢掉了。

CRF 层就是在 BiLSTM 输出的每个位置分数之上,加了一个标签转移矩阵。矩阵的第 i 行第 j 列表示「前一个位置是标签 i,当前位置是标签 j」的转移得分,这些得分是模型参数,在训练中自动学习。比如模型会学到从 B-症状 转移到 I-症状 的得分很高,从 B-症状 转移到 I-疾病 的得分很低。推理时用维特比算法在整条标签路径上搜索得分最高的合法序列,保证全局最优。这一层对实体边界的作用远比想象中大,在标注数据不充分的医疗场景里,CRF 往往比纯 BiLSTM 高出 4 到 6 个点的 F1。

class BiLSTMCRF(nn.Module): def __init__(self, vocab_size, tag_size, embedding_dim, hidden_size, drop_prob=0.5): super().__init__() self.embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx=0) self.lstm = nn.LSTM( embedding_dim, hidden_size // 2, batch_first=True, bidirectional=True ) self.dropout = nn.Dropout(drop_prob) self.hidden2tag = nn.Linear(hidden_size, tag_size) # CRF 参数:转移矩阵 + 起始/终止得分 self.transitions = nn.Parameter(torch.randn(tag_size, tag_size)) self.start_trans = nn.Parameter(torch.randn(tag_size)) self.end_trans = nn.Parameter(torch.randn(tag_size)) def emission_scores(self, token_ids, mask): embedded = self.embedding(token_ids) embedded = self.dropout(embedded) packed = nn.utils.rnn.pack_padded_sequence( embedded, mask.sum(1).cpu(), batch_first=True, enforce_sorted=False ) lstm_out, _ = self.lstm(packed) lstm_out, _ = nn.utils.rnn.pad_packed_sequence(lstm_out, batch_first=True) lstm_out = self.dropout(lstm_out) return self.hidden2tag(lstm_out) # [batch, seq_len, tag_size]

CRF 层的训练目标是对数似然最大化,也就是让正确标签序列的得分和所有可能标签序列得分的对数和之间的差尽量小。前向计算时需要用迭代求和的方式算出所有路径的总分,这个计算过程依赖动态规划,代码量不小;解码时用维特比算法,维护每个位置每个标签的最优路径。如果你不打算从头造轮子,常见做法是直接引入 torchcrf 库或者沿用它里面的算法自己封装,后者可控性更好,因为 CRF 的 mask 处理和 LSTM 的 pack 逻辑经常因为维度不匹配报错。

4. 训练与评估:一份能复现的中文电子病历 NER 调参记录

4.1 训练主循环:参数配置、早停与梯度裁剪

训练一个 BiLSTM-CRF 模型的超参数并不复杂,但每一个都值得花时间确认。常见配置是:embedding 维度 100 到 200,LSTM hidden_size 128,batch_size 16 到 32,学习率 1e-3,dropout 0.5,用 Adam 优化器。数据量少时 dropout 可以降到 0.3,防止模型欠拟合。训练轮数设 30 到 50,配早停——验证集 F1 连续 5 到 10 个 epoch 不升就停止,防止过拟合到标注数据的噪声上。这些参数在大多数病历语料上都能直接跑出不错的结果,不需要做大量搜索。

训练循环里真正容易翻车的是梯度裁剪和损失值监控。LSTM 本身就存在梯度爆炸风险,CRF 层的对数似然计算对数值稳定性要求又高,不做梯度裁剪很容易在某个 step 直接 NaN 掉。每次 backward 之后、optimizer.step 之前,对模型参数统一做一次 clip_grad_norm_,阈值 5.0 是常用起点。每 100 个 step 打印一次 loss,如果 loss 出现上升或者突然变 NaN,先停掉检查学习率,不要等到整个 epoch 跑完才发现。

def train_epoch(model, dataloader, optimizer, clip_grad=5.0): model.train() total_loss = 0.0 for batch in dataloader: token_ids = batch["token_ids"].to(device) # [batch, seq_len] tag_ids = batch["tag_ids"].to(device) # 真实标签 id mask = batch["mask"].to(device) # 1 代表有效位置 emissions = model.emission_scores(token_ids, mask) # 计算 CRF 负对数似然损失 loss = model.negative_log_likelihood(emissions, tag_ids, mask) optimizer.zero_grad() loss.backward() nn.utils.clip_grad_norm_(model.parameters(), clip_grad) optimizer.step() total_loss += loss.item() return total_loss / len(dataloader)

negative_log_likelihood 函数需要自己实现或者依赖库完成,核心思路是计算正确标签路径的得分和所有路径总得分的差,详细实现会用 log-sum-exp 的动态规划。mask 的作用是告诉 CRF 哪里是<PAD>不参与计算,否则 padding 位置会被当成合法 token 参与转移得分累计。clip_grad_norm_ 的第一个参数传 model.parameters(),它会把整个模型所有参数的梯度向量范数压到 5.0 以内,有效防止梯度爆炸导致 loss 跳到无穷大。

4.2 评估指标:span 级 F1 的正确算法

分类任务里常用的准确率在 NER 上几乎没用,因为非实体字符占到八成以上,模型全预测为 O 准确率也能到 80% 多,但一个实体都抽不出来。必须用实体级别的精确率、召回率和 F1。关键点在于“实体级别”这三个字——模型预测出来的一组连续标签片段,只有边界和类型都和真实标注完全一致才算预测对。

实现实体级 F1 需要先把序列标签还原成实体片段列表。遍历预测标签序列,遇到 B-类型 就开启一个新片段,记录起始下标和类型;遇到 I-类型 且和当前片段类型一致就扩展片段;其他情况关闭当前片段并加入列表。真实标注同样操作,然后拿两个片段列表求交集和差集。这个逻辑不复杂,但极容易写错——边界差一个字就算错、片段关闭时机不对、不连续片段被错误合并,都是高频 bug。写完评估函数后一定要用几条手工标注的输入验证一遍,不要直接上测试集。

def extract_spans(labels): """BIO 序列 -> [(start, end, type), ...],end 不包含""" spans = [] start, cur_type = None, None for i, label in enumerate(labels): if label.startswith("B-"): if start is not None: spans.append((start, i, cur_type)) start, cur_type = i, label[2:] elif label.startswith("I-") and start is not None and label[2:] == cur_type: continue # 扩展当前实体 else: if start is not None: spans.append((start, i, cur_type)) start, cur_type = None, None if start is not None: spans.append((start, len(labels), cur_type)) return spans # 求 precision / recall / f1 def evaluate_spans(preds, golds): pred_spans = [extract_spans(p) for p in preds] gold_spans = [extract_spans(g) for g in golds] # 分别统计正确数、预测总数、真实总数,再计算 P/R/F1

这里有个容易踩的细节:测试集的句子如果是从原始病历里随机切出来的,实体可能会被拦腰截断,导致无法对齐。后面避坑部分会专门讲这个问题。评估时要按实体类型分别计算每一类的 P/R/F1,也要算宏观平均,因为「手术操作」这类实体少,整体 F1 会掩盖它在低召回上的问题。

4.3 调参顺序:先让模型过拟合一个 batch

调参的第一步不是改结构,而是先验证模型和管线能不能跑通。拿一个小 batch 的数据——8 到 16 条句子就够了——训练 20 到 30 个 step,看 loss 是不是稳定下降。如果 loss 完全不降,大概率是数据格式或者 label2id 映射错乱,模型根本没法学习;如果 loss 降很快但预测出来全是 O,说明标签映射或者评估函数的还原逻辑对不上。先把训练集上 loss 压到接近 0,再谈泛化。

数据管线确认没问题之后,再调学习率。1e-3 在多数场景能快速下降,但如果发现 loss 曲线震荡,降到 5e-4 或者 3e-4 试试。学习率和 batch_size 是关联的,batch 越大学习率可以适当提高,但医疗数据标注量少,batch_size 32 基本就是上限。最后调的是 hidden_size 和 embedding_dim,这两个参数的影响在 F1 上不会超过 1 个百分点,不用花太多时间。真正影响效果的是数据质量,这个结论在医疗 NER 里反复被验证——标注一致性好,哪怕模型结构稍微落后,效果也不会差。

5. 避坑实录:标注、训练与解码阶段的 5 个高频踩坑点

5.1 标签序列里出现「I 逃逸」:BIO 规范被破坏

现象:模型训练完成后,预测结果里有大量「O 后面直接接 I-症状」这样的路径,或者 B-症状 和 I-疾病 混在一段里,实体整体无法解析成合法片段。

原因:数据预处理阶段把原始的标注转换成了 BIO 序列,但原始标注里实体本身就标得不规范,有的实体被标成多个片段,有的实体和另一个实体紧挨着,转换脚本按规则强制生成 B 和 I,导致部分片段缺失 B 开头。另一个来源是训练数据里混入了别处拷贝的标注文件,标签体系写的是「Symptom」「Dis」而不是约定的「症状」「疾病」,模型根本没见过这种类型的标签。

解决:转换脚本里增加一个校验函数,逐一检查生成的序列是否合法。合法的序列必须是:I 之前必须有同类型的 B 或 I;B 之后可以接同类型的 I,也可以直接接 O 或其他 B。校验不通过就打印出错样本的具体内容,回溯到原始标注去修正。宁可多花一天修标注,也不要带着坏数据训练,坏标签在 CRF 里会被当作正常规律学进去,污染转移矩阵。

5.2 Loss 不降或直接 NaN:学习率与梯度裁剪的配合问题

现象:训练开始 loss 很正常地下降,到了某个 step 突然变成 NaN,之后一直在 NaN 和负数之间跳动;或者 loss 一直卡在某个值附近完全不降。

原因:CRF 的损失函数是负对数似然,公式里包含对所有可能路径的 log-sum-exp。当序列长度长、标签空间大时,前向计算出的路径分数总和可能溢出,直接变成 inf,再取 log 就变成 NaN。另一个高频原因是梯度爆炸,LSTM 的循环结构在长句子上梯度范数累积变大,没有做梯度裁剪。

解决:训练循环里梯度裁剪必须加上,阈值 5.0,这是最简单也最有效的保护。如果换了数据集之后依然出现 NaN,检查 CRF 的 log-sum-exp 计算是否做了数值稳定处理——通用的做法是每步减掉当前行的最大值再算 exp。还有一种情况是学习率太大,比如 1e-2,这时 loss 会反复震荡,降到 1e-3 以下就行。最后排查 embedding 层是否不小心让 padding 位置的向量参与了更新,padding_idx 没设置好的时候,全零向量也会产生梯度,污染 embedding 矩阵。

5.3 罕见实体召回率低:类别不平衡处理

现象:整体 F1 看起来有 85% 以上,「症状」「疾病」类都表现很好,但「手术操作」「药品」这两类的召回率只有 50% 左右,大量实体被识别成 O 或者相邻的类别。

原因:病历里实体分布天然不均衡,手术操作和药品的出现频率远低于症状和疾病。模型训练时每个样本的损失都是平均计算的,多数类占据主导,少数的梯度信号被淹没。CRF 的转移矩阵也会跟着偏向 O,因为 O 是绝大多数标签,从 O 到 I 的转移被学得很保守。

解决:第一优先是给少数类加标注样本,不用多,每类补充一百到两百个实体就能看到明显提升。如果补充标注不现实,可以给损失函数加标签权重,对「手术操作」「药品」对应的发射分数加大惩罚倍数。还有一种常见做法是训练后处理——在预测阶段维护一个白名单字典,模型输出 O 但字典命中时强制改成对应实体类型,这层规则兜底能把少数类召回拉回几个点。医疗领域字典资源多,规则兜底永远是不用白不用的廉价手段。

5.4 长句子被截断:实体被拦腰切断

现象:模型在验证集上 F1 很高,一到实际病历上,一些长句子预测出来的实体明显缺了后半段,比如「胃镜下黏膜剥离术」只出了「胃」或者「胃镜」。

原因:训练时把所有句子统一截断到 max_len,默认取前 128 或 256 个字符,但病历里的主诉、现病史段落动辄三四百字。一个实体恰好在截断的位置附近,后半段被直接删掉,模型既看不到完整的训练样例,推理时也会因为输入被截断而输出不完整的实体。

解决:训练时不要从句子开头硬截,长文本可以按滑窗切段,窗口之间重叠 32 个字符左右,并且只在完整窗口内标注实体——实体的起止必须完全落在窗口内才算有效样本。推理时同样用滑窗,预测完再把各窗口的结果合并回去,重叠区域以第一次出现的结果为准。这一步不做,模型上线后的表现可能比测试集低 5 到 8 个百分点,是医疗长文本场景里最典型的训练和推理不一致问题。

5.5 训练 F1 不错、上线就翻车:训练集与预测窗口不一致

现象:测试集上跑出 86% 的 F1,把模型接到实际病历上输出一塌糊涂,症状和疾病乱标,甚至整句全 O。

原因:测试集是随机从标注数据里抽的,句子长度和实体分布跟真实病历不完全一致,更关键的是真实病历里包含大量未出现过的科室术语、缩写、错别字。另一个被忽略的原因是测试时处理流程和训练时没对齐——键盘输入的字符编码不同、全角半角混着来、换行符被当成字符参与预测,这些小差异在单个样本上影响不大,积累起来就是灾难。

解决:验证模型能不能用,要做一次「样本外泛化测试」。拿一份不带标注、和训练数据来源不同的病历原文走一遍完整流程(预处理、切窗、预测、合并),人工抽查预测结果。预处理必须和训练时完全一致,同一个 normalize 函数、同一条正则替换规则、同一种截断逻辑。最好把这个测试脚本固化下来,每次改数据或者改模型之后跑一遍对比,比看测试集 F1 更真实。这个习惯帮我避免过很多次上线翻车,尤其是电子病历这种文本风格差异极大的场景。

6. 从训练到落地:在线预测、模型导出与字段抽取

6.1 在线预测用滑窗,别用最大长度硬截

模型训练完成后,部署阶段有个最容易被忽略的细节:加载模型后先跑一遍model.eval(),把 dropout 关掉,再用统一的预处理函数处理输入文本。预测长文本时按滑窗切分,窗口长度和训练时保持一致,重叠部分取第一个窗口的结果。窗口返回的标签序列要映射回原始文本的坐标,这里最容易算错的是窗口内字符偏移量,写代码时用全局字符指针累计长度,不要用窗口内相对位置。

6.2 把标注结果转成结构化字段

预测出来的 BIO 标签序列通过 extract_spans 还原成实体列表之后,还要做一步针对医疗场景的后处理。常见做法是给每个实体类型维护一份归一化字典,比如把「胃痛」「胃疼」「上腹痛」统一归到同一个标准术语下。另外模型偶尔会标出「高血压病」和「高血压」两个重叠实体,按实体类型和字符重合度去重。做完这两步,结果就可以整理成 JSON 结构落库了。实际应用中规则兜底永远别丢,症状和疾病类可以交给模型泛化,检查项和药品类尽量叠加字典匹配,两者取并集后再按置信度排序。

最后说一个我自己的习惯:每次调整标注规范或者模型结构,都会把旧模型在最近一批病历上的预测结果保存下来做 diff,对照着看新模型是改善了哪些实体,又引入了哪些新的错误。这种对比比任何指标都直观,也最容易发现标注规范本身的问题。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询