☰
中文电子病历NER实战:BERT-wwm + BiLSTM-CRF完整复现指南
2026/10/7 6:37:48 网站建设 项目流程

简介:一套面向中文电子病历命名实体识别(NER)的深度学习实验系统,基于CCKS2019评测数据构建,专注解决医疗文本中疾病、临床表现、治疗方案等医学实体的自动抽取问题,适合医疗NLP研究者、算法工程师以及相关方向学生进行模型对比与复现。压缩包共44个文件,以26个Python脚本为主体,覆盖数据预处理、预训练、模型定义、训练评估及结果可视化等环节,并辅以yml配置、SQLite数据库、README说明与若干备份文件,整体仅41KB,属于轻量级代码工程。目前已有54人浏览学习。系统实现了BERT-wwm预训练语言模型与BiLSTM-CRF序列标注框架,同时内置CNN、RNN等经典基线,可通过configs目录下的配置文件灵活切换模型和参数,便于在统一标准下系统评估不同架构在中文电子病历上的识别性能。附带的训练、评估、绘图脚本和项目说明,为复现竞赛任务、构建基准实验或开展课程设计提供了完整可操作的参考。

1. 中文电子病历命名实体识别:为什么 BERT-wwm 配 BiLSTM-CRF 值得复现

处理过医疗文本的人应该都有同一个感受:病历里全是缩写和倒装句式,“患者三天前无明显诱因出现胸闷、心悸”这种句子里,实体边界模糊、术语密度高。直接套通用 NER 模型,召回率撑死 60%;纯规则词典匹配需要持续维护词表,换个科室就失灵。这份资源给了一条从标注、训练到评估的完整链路,核心是三件事:用 BERT-wwm 做语义编码、用 BiLSTM-CRF 做序列解码、用多模型对比量化每一层模块的真实收益。适合个人学习医学 NLP 的从业者、做课程设计的同学,以及想评估电子病历信息抽取方案可行性的工程师。按文中流程整理几百条病历即可启动复现。

2. 数据与标注:把病历文本切成 BIO 序列

2.1 实体类型定义与标注规范

中文电子病历里最常见的实体类型是症状、体征、疾病、药物、检查、治疗六类。实体数量不宜定义过多,类别越多标注一致性越差,最终会拖累模型 F1。课程设计和入门项目里定义 4~6 类实体是普遍做法,超过 8 类时标注边界会迅速失控。

标注规范采用 BIO 体系:B-xxx 表示实体开始,I-xxx 表示实体中间或结尾,O 表示非实体。这里刻意不用 BIESO,因为 BIO 的标签转移关系对 CRF 已经足够。每类实体对应两个标签,比如 B-Symptom / I-Symptom,标签总数是 2 类实体数加 1 个 O。

电子病历场景有一个关键约定:实体边界从症状或疾病名称的本体开始,不包含修饰性副词和否定词。例如“无明显诱因出现胸闷”,只标注“胸闷”为 B-Symptom、I-Symptom,“无明显诱因”保留为 O。这个约定的作用会在 5.1 节体现——边界规则不一致是 F1 卡在 0.8 附近的头号元凶。

实体类型示例标签前缀
症状胸闷、心悸、乏力B-Symptom / I-Symptom
体征体温 38.5℃、血压偏高B-Sign / I-Sign
疾病慢性阻塞性肺疾病B-Disease / I-Disease
药物阿司匹林、硝酸甘油B-Drug / I-Drug
检查心电图、胸部 CTB-Exam / I-Exam
治疗溶栓、支架植入B-Treatment / I-Treatment

数据组织上推荐先整理成 JSON 格式,每条样本包含原始文本和实体列表。实体列表中的 start 和 end 是字符级索引,不是分词后的 token 索引。中文按单字切分,标点符号也占一个字符位,这一点在从标注工具导出数据时最容易出错。

2.2 从 JSON 标注转 BIO 序列的脚本

拿到一批标注好的 JSON 数据之后,需要把它转成 token 序列和标签序列才能喂给模型。写一个转换函数:

def text_to_bio(text, entities): tags = ["O"] * len(text) for ent in entities: start = ent["start"] end = ent["end"] label = ent["label"] if start < 0 or end > len(text) or start >= end: print(f"跳过非法实体: {ent}, text: {text}") continue tags[start] = "B-" + label for i in range(start + 1, end): tags[i] = "I-" + label return list(text), tags

逻辑不复杂:先把每个字符位初始化为 O,然后遍历实体列表,在起始位置打 B-,在中间位置连续打 I-。这里有两个细节值得注意。第一,start 和 end 遵循 Python 切片习惯,end 是开区间,比如文本“出现胸闷”里的“胸闷”索引是 [2, 4)。第二,校验逻辑必须保留,手工标注难免出现越界或 end 小于 start 的脏数据。

转换完成后要做一次全角半角归一化和中英文标点统一。病历文本里经常混着全角逗号、半角括号、不间断空格,BERT tokenizer 对全角半角的处理方式不同,归一化能减少无意义的字符碎片。我一般先用unicodedata.normalize把全角字符转成半角,再把空白字符压缩成单个空格——不是删掉,因为空格本身会被 tokenizer 编码。

2.3 数据集划分:按患者 ID 而不是按句子随机切

电子病历数据有一个天然陷阱:同一个患者的多条病程记录会出现高度相似的句子。如果直接按句子随机切分训练集和验证集,同一患者的一部分句子落在训练集、另一部分落在验证集,模型相当于提前看到了答案,验证集 F1 会虚高 3 到 5 个点。这是一条血泪经验。

做法是给每条记录打上 patient_id,按 patient_id 做分组划分:

import random records = load_all_records() patient_ids = list({r["patient_id"] for r in records}) random.seed(42) random.shuffle(patient_ids) split_point = int(len(patient_ids) * 0.8) train_ids = set(patient_ids[:split_point]) valid_ids = set(patient_ids[split_point:]) train_data = [r for r in records if r["patient_id"] in train_ids] valid_data = [r for r in records if r["patient_id"] in valid_ids]

样本量较小时可以用 K 折交叉验证,但同样要按 patient_id 分组。固定随机种子同样重要——两次训练因为划分顺序不同导致 F1 相差两个点,排查了很久才发现是数据集的锅。在数据集没有 patient_id 字段时,退而求其次用住院号或身份证号做分组键,如果实在没有唯一标识,则按文本去重后合并相似记录再划分。

3. BERT-wwm 微调:用预训练模型换领域特征

3.1 为什么选 BERT-wwm 而不是 BERT-base

BERT-wwm 的全称是 BERT with Whole Word Masking,即全词掩码预训练。BERT-base 在预训练时随机遮盖单个字,比如“肺栓塞”可能只遮盖“栓”,模型只需要从上下文猜这一个字,学到的更多是局部字符共现。BERT-wwm 会把“肺栓塞”整个词的所有字符同时遮盖,模型必须依据上下文推断整个词。这种预训练目标让中文词语边界和专有名词的内部语义表征更完整。

电子病历场景恰恰是专业术语密集区。“患者既往有糖尿病史,今日突发胸骨后压榨性疼痛”这句话里,“胸骨后压榨性疼痛”是一个完整体征描述,包含位置修饰和性质修饰。BERT-wwm 在通用中文语料上学习到的词级语义,能让模型把它作为一个整体语义单元来理解。实际对比实验中,BERT-wwm 相对 BERT-base 在电子病历 NER 上的 F1 通常能高出 1 到 2 个百分点。

资源里使用的是 hfl/chinese-bert-wwm-ext 这个 checkpoint,它是在更大规模中文语料上继续预训练的版本。如果显存有限,可以退而用 ALBERT 之类的小模型,但 NER 精度会明显下降,个人学习场景下不建议为了省显存牺牲效果——电子病历实体抽取的边界判断非常依赖深层语义。

3.2 微调参数配置与 tokenizer 对齐

给出一套经过验证的参数组合:

参数建议值说明
max_seq_len128 或 256短门诊记录用 128,长病程记录用 256
batch_size16 或 812G 显存下 max_seq_len=256 时建议降到 8
learning_rate5e-5BERT 微调常用范围 2e-5 ~ 5e-5
epochs3 ~ 5数据量不足一千条时 3 轮即可
warmup_ratio0.1前 10% 训练步数线性预热

加载模型和 tokenizer 的代码:

from transformers import BertForTokenClassification, BertTokenizer tokenizer = BertTokenizer.from_pretrained("hfl/chinese-bert-wwm-ext") model = BertForTokenClassification.from_pretrained( "hfl/chinese-bert-wwm-ext", num_labels=len(label2id) )

label2id 是把 BIO 标签映射成整数,例如 {"O": 0, "B-Symptom": 1, "I-Symptom": 2}。这里有一个多数人都会踩的坑:BERT tokenizer 对中文汉字基本保持单字 token,但遇到字典外字符会拆成 unknown,token 序列长度和标签序列长度不一致。需要在数据预处理阶段把标签序列对齐到 token 序列,padding 位置设为 -100,这个值会被 CrossEntropyLoss 的 ignore_index 自动忽略。

def align_labels_with_tokens(labels, word_ids): aligned = [] prev_word_idx = None for word_idx in word_ids: if word_idx is None: aligned.append(-100) elif word_idx != prev_word_idx: aligned.append(labels[word_idx]) else: aligned.append(-100) prev_word_idx = word_idx return aligned

这段代码的逻辑是:对每个 token 找到它对应的原始字符位置,只保留第一个子词的标签,其余子词位置设为 -100,让损失函数跳过它们。

3.3 训练循环与损失函数

训练主循环可以用 transformers 的 Trainer,也可以手写。手写的好处是中间层输出可以直接接 BiLSTM-CRF。给出核心训练片段:

from transformers import AdamW, get_linear_schedule_with_warmup optimizer = AdamW(model.parameters(), lr=5e-5, weight_decay=0.01) total_steps = len(train_dataloader) * epochs scheduler = get_linear_schedule_with_warmup( optimizer, num_warmup_steps=int(total_steps * 0.1), num_training_steps=total_steps ) criterion = torch.nn.CrossEntropyLoss(ignore_index=-100) for epoch in range(epochs): for batch in train_dataloader: input_ids = batch["input_ids"].to(device) attention_mask = batch["attention_mask"].to(device) labels = batch["labels"].to(device) outputs = model(input_ids, attention_mask=attention_mask) logits = outputs.logits # (batch, seq_len, num_labels) loss = criterion(logits.view(-1, num_labels), labels.view(-1)) loss.backward() optimizer.step() scheduler.step() optimizer.zero_grad()

注意这里的 logits 展开方式:(batch, seq_len, num_labels)展平成(batch * seq_len, num_labels),labels 同步展平。weight_decay=0.01 用来抑制过拟合,病历数据量只有几千条时特别重要。warmup 的作用是让 BERT 在训练初期用较小的学习率,避免破坏预训练权重。

3.4 从 BERT 隐层到 BiLSTM 的衔接

BERT 的最终隐层输出维度是(batch_size, seq_len, 768)。直接接 CRF 也能工作,但 BiLSTM 可以在 BERT 之上继续编码相邻 token 的上下文依赖。BiLSTM 的 hidden_size 通常设 256,双向拼接后变成 512 维,再经过线性层映射到 num_labels:

class BertBiLSTMCRF(nn.Module): def __init__(self, bert_model, hidden_size, num_labels): super().__init__() self.bert = bert_model self.bilstm = nn.LSTM( input_size=768, hidden_size=hidden_size, num_layers=1, bidirectional=True, batch_first=True ) self.dropout = nn.Dropout(0.1) self.fc = nn.Linear(hidden_size * 2, num_labels) def forward(self, input_ids, attention_mask): outputs = self.bert(input_ids, attention_mask=attention_mask) sequence_output = outputs.last_hidden_state lstm_out, _ = self.bilstm(sequence_output) lstm_out = self.dropout(lstm_out) logits = self.fc(lstm_out) return logits

dropout 放在 BiLSTM 输出之后、线性层之前,这是影响 CRF 收敛的关键位置。不加 dropout,CRF 的转移矩阵容易过拟合到训练集的标签共现模式;dropout 加到 0.2 以上又会削弱序列特征的连续性,0.1 是经验值。

4. BiLSTM-CRF 解码:序列标注的最后一公里

4.1 为什么在 BERT 之上还要叠 BiLSTM-CRF

BERT-wwm 已经在每个字符位产出了高质量语义表征,但直接对每个位置的 logits 做 argmax,会出现一个严重问题:预测结果里出现“B-Symptom 后直接接 B-Symptom”或“I-Symptom 前面没有 B-Symptom”的非法序列。原因在于 token 级分类目标没有显式建模相邻标签之间的依赖关系。

CRF 层学习的是一个标签转移矩阵,矩阵元素表示从标签 i 转移到标签 j 的得分。推理时用维特比算法求全局最优路径。训练语料中如果从未出现过 “O → I-Symptom”,CRF 会把该转移得分压得非常低,推理时几乎不会输出这种非法序列。BiLSTM 负责编码上下文,CRF 负责解码约束,分工明确。

一个直观经验是,直接在 BERT-wwm 输出上接 CRF 已经能拿到不错的结果,加 BiLSTM 后的 F1 提升大概 0.5~1 个点,收益不算大。但在训练数据不足时,BiLSTM 对短文本和少数类的鲁棒性更好,整套结构也更接近论文的基准配置。个人学习场景推荐保留,因为跑通一次完整结构比省一个点的训练时间更有价值。

4.2 CRF 层与维特比解码

标准 CRF 解码可以拆成两部分:发射分数和转移分数。发射分数由 BiLSTM 输出的 logits 提供,表示每个 token 属于每个标签的原始得分;转移分数由 CRF 层内部的 transition 矩阵提供。训练时 CRF 的损失是真实标签序列在所有可能序列中的负对数似然,推理时用维特比算法动态规划求解得分最高的标签路径。

torchcrf 库封装好了这些逻辑,常见用法如下:

from torchcrf import CRF class NERModel(nn.Module): def __init__(self, bert_model, hidden_size, num_labels): super().__init__() self.bert = bert_model self.bilstm = nn.LSTM(768, hidden_size, bidirectional=True, batch_first=True) self.fc = nn.Linear(hidden_size * 2, num_labels) self.crf = CRF(num_labels, batch_first=True) def forward(self, input_ids, attention_mask, labels=None): outputs = self.bert(input_ids, attention_mask=attention_mask) seq_out = outputs.last_hidden_state lstm_out, _ = self.bilstm(seq_out) logits = self.fc(lstm_out) if labels is not None: mask = attention_mask.bool() loss = -self.crf(logits, labels, mask=mask) return loss decoded = self.crf.decode(logits, mask=attention_mask.bool()) return decoded

mask 参数是 CRF 最容易出错的地方。计算转移代价时必须跳过 padding token、CLS 和 SEP,否则这些位置会被当成真实标签参与转移矩阵更新,解码结果直接错位。attention_mask 转成 bool 之后传给 CRF 的 mask 参数,CLS 和 SEP 位置在 attention_mask 里是 1,所以还需要额外把这两个位置的 mask 改成 False。

维特比解码返回一个 list of list,内层是每个 token 的预测标签索引。后续提取实体时要跳过 CLS 和 SEP 对应的位置,这一步经常被忽略。

4.3 从标签序列提取实体 span

模型输出的标签序列不能直接交付,还需要还原成实体文本。写一个提取函数:

def extract_entities_from_tags(tag_list, tokens, idx2label): entities = [] cur_start = None cur_label = None for i, tag_idx in enumerate(tag_list): tag = idx2label[tag_idx] if tag.startswith("B-"): if cur_start is not None: entities.append({"text": "".join(tokens[cur_start:i]), "label": cur_label, "start": cur_start, "end": i}) cur_start, cur_label = i, tag[2:] elif tag.startswith("I-") and cur_start is not None: if tag[2:] != cur_label: entities.append({"text": "".join(tokens[cur_start:i]), "label": cur_label, "start": cur_start, "end": i}) cur_start, cur_label = i, tag[2:] else: if cur_start is not None: entities.append({"text": "".join(tokens[cur_start:i]), "label": cur_label, "start": cur_start, "end": i}) cur_start = None if cur_start is not None: entities.append({"text": "".join(tokens[cur_start:len(tag_list)]), "label": cur_label, "start": cur_start, "end": len(tag_list)}) return entities

核心逻辑是扫描标签序列,遇到 B- 时开始新实体,遇到连续 I- 时扩展当前实体,遇到 O 时结束当前实体。代码额外处理了“B-Symptom 后直接接 I-Drug”这种跨界序列——虽然 CRF 已经大幅抑制了这种情况,但一旦发生,就强制截断前一个实体并开启新实体,避免一个实体内部混入另一个类型的 token。

提取完成后要过滤纯符号实体、空串实体,以及文本长度小于 2 的碎片。评估阶段尤其要做这个过滤,因为它能把一小部分噪声拉低 F1 的问题提前挡住。

5. 避坑指南:五个最容易翻车的环节

5.1 标注边界不一致导致 F1 卡在 0.8 附近

现象:训练时 loss 正常下降,但验证集 F1 在 0.78~0.82 之间反复横跳,调参也上不去。

原因:标注规范对实体边界定义不严格。例如“患者无明显诱因出现胸闷”,有的标注员把“无明显诱因出现”也划进实体前缀,有的只标“胸闷”。同一句式在训练集中出现两种标签序列,CRF 只能学一个折中的转移矩阵。

解决:预处理阶段按“症状名词本体不包含修饰词”的规则自动裁剪实体边界,然后随机抽 50 条验证集样本人工核对。边界规范化脚本比重新标注的成本低得多,做完这两步 F1 往往立刻提升 1~2 个点。

5.2 长病历截断导致尾部实体全部丢失

现象:测试集中较长的病程记录结尾处实体大面积未被识别。

原因:max_seq_len=128 时,超过 128 个字符的句子被直接截断,尾部实体自然消失。更隐蔽的是,实体从第 127 个字符开始、在第 129 个字符结束,被拦腰截断后模型只看到一个不完整的半实体。

解决:先统计训练集文本长度分布再确定 max_seq_len,平均值在 150 左右就直接用 256。另一种常见做法是按句号、分号把长文本拆成短句,拆句时保证句内实体完整,拆完后每个 chunk 单独进模型。

5.3 GPU 显存溢出

现象:程序跑几个 batch 就 OOM,或者训练中途显存爆炸。

原因:max_seq_len=256 加 batch_size=32,BERT-wwm 需要约 20G 显存,12G 显卡必然爆。另一个隐藏因素是把原始文本字符串装进了 batch,字符串在 CPU 侧占用显存缓冲。

解决:batch_size 降到 8,max_seq_len 降到 192,或者把 BiLSTM 的 hidden_size 降为 128。还可以用梯度累积模拟大 batch:

accumulation_steps = 4 loss = loss / accumulation_steps loss.backward() if global_step % accumulation_steps == 0: optimizer.step() optimizer.zero_grad()

注意 loss 除以 accumulation_steps 是为了保持等效学习率不变,梯度累积之后 optimizer.step() 的频率变成原来的四分之一。

5.4 CRF 转移矩阵不收敛

现象:验证集 F1 在 0.3 到 0.8 之间剧烈震荡,每次运行结果差异很大。

原因:常见的是 BERT 微调学习率过大。5e-5 对 BERT 合适,但对 CRF 转移矩阵来说步幅偏大,导致转移得分后期来回振荡。另一个原因是 CRF 层前没有 dropout,对训练集标签共现模式过拟合。

解决:把 dropout 设为 0.1,并把 CRF 层学习率单独调低:

optimizer = AdamW([ {"params": bert_parameters, "lr": 5e-5}, {"params": crf_parameters, "lr": 5e-6} ], weight_decay=0.01)

检查是否收敛的方法很直观:打印预测序列,如果某个批次连续输出多个相同标签且与真实分布明显不符,说明转移矩阵还没稳定。

5.5 数据泄漏导致指标虚高

现象:验证集 F1 异常高(超过 0.95),换到新数据上效果明显变差。

原因:按句子随机划分数据集导致同一患者的记录同时出现在训练集和验证集。模型在训练时见过这个人,验证等于开卷考试。

解决:严格按 patient_id 分组划分。没有 patient_id 时,用住院号或身份证号做分组键。我在划分数据前都会检查训练集和验证集的 patient_id 是否重叠,这一步已经变成流程里的固定动作。

6. 多模型性能评估:怎么量化 BERT-wwm + BiLSTM-CRF 的真实收益

6.1 评估指标与对比表

NER 评估不能只看 token 级准确率——O 类占比超过 90%,模型全部预测 O 也能拿到 90% 以上的准确率。正确做法是实体级别评估,precision、recall、F1 都要求预测实体的文本和标签与真实实体完全一致才算对,边界差一个字也算错。

资源里附带的多模型评估脚本会输出如下对比表:

模型PrecisionRecallF1
BiLSTM-CRF(随机初始化字向量)0.7950.7620.778
BERT-wwm + CRF0.8680.8550.861
BERT-wwm + BiLSTM + CRF0.8810.8690.875

以上是以公开中文电子病历数据为基准的参考值,实际数据分布下会有浮动。两个规律大概率不变:一是 BERT-wwm 相对随机字向量的提升在 8~10 个点;二是 BiLSTM 在 BERT 基础上的增量在 1 个点左右。数据量只有几百条时,BiLSTM 的增量可能不明显,但 CRF 层仍然必不可少。

6.2 验证方法:用预测分布反查标注质量

训练完成别急着收工,先做一轮“预测-抽查-修正”的反向验证。取 100 条验证集样本跑完整预测,把预测实体和标注实体的差异可视化输出,重点看三类样本:模型预测为空但标注有实体的漏召回,实体文本重叠但边界错位的漂移,以及标签类型错误的误分类。

这三类样本的比例直接反映标注质量。漏召回多说明训练数据覆盖不足;边界错位多说明标注规范执行不一致;类型错误多说明实体类型定义本身存在歧义。处理完数据再重新训练,效果通常比直接调参数更扎实。我吃过一次大亏:当初跳过这一步,用一份表面干净的标注数据训练,验证集 F1 到 0.86,部署到新病历上直接掉到 0.7,逐个排查才发现是“症状”和“体征”的界定本身就模糊。从那以后我每次跑完训练都强制走一遍预测-抽查-再标注的循环,宁可多花半天修正数据,也不去调那些说不清规律的玄学参数。希望这份资源和这篇拆解能让你少走这一步弯路。

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

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

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

立即咨询