
简介面向CCKS2019中文电子病历命名实体识别任务这套深度学习实验系统提供了基于全词掩码BERT与BiLSTM-CRF的完整实现同时集成CNN、RNN等经典模型用于性能对比适合自然语言处理研究者、医疗信息抽取学习者及毕业设计人员参考。资源包共44个文件以26个Python源码为主另有5个YAML配置、SQLite数据库、说明文档及备份文件整体大小仅41KB结构清晰且便于快速部署实验。代码覆盖数据预处理、模型定义、训练评估、结果绘图等环节并附有CCKS2017/2019数据集处理脚本、配置示例及README能够帮助读者复现医疗文本实体识别的主流技术路线。已有54人浏览学习可用于理解全词掩码预训练模型微调、双向长短期记忆网络与条件随机场联合解码的完整流程。资料还包含数据集特性分析、算法原理阐释与扩展方向建议便于系统评估不同模型在中文电子病历上的表现推动后续研究优化。1. 中文电子病历命名实体识别从 BERT-wwm 到 BiLSTM-CRF 的落地路径拿到一批脱敏后的电子病历文本第一件事不是调模型而是让医生把症状、药品、检查项目、身体部位、病程时间这五类实体标出来。中文病历里“患者无明显发热”“未见咳嗽咳痰”这类否定表达、简写和口语化描述让通用领域的命名实体识别模型一上来就翻车。BERT-wwm 的全词掩码机制能更好利用中文词边界信息BiLSTM-CRF 则负责在字级别特征上补足序列约束——这套组合是中文医疗 NER 里最常见、也最容易复现的基线方案。本文按数据标注、模型原理、最小实现、踩坑记录、多模型评估这条线展开适合手里有一批病历文本、打算从零搭 NER 系统的工程师。2. 病历文本怎么喂给模型标注体系、预处理与 BERT-wwm 的输入构造2.1 实体类别与标注方案的确定中文电子病历不是一个统一的语料类型。入院记录、病程记录、出院小结里的表述习惯差异很大同一症状在不同文书里可能写成“上腹痛”“剑突下疼痛”“腹部隐痛”。因此第一件事是限定任务范围。常见做法是把实体收敛为 5 到 7 个粗粒度类别症状体征Symptom、药物Medicine、检查项目Check、身体部位Body、手术操作Operation、时间Time。粒度越粗标注一致率越高模型也越好学。如果一上来就细分到“急性疼痛”“慢性疼痛”标注员之间的一致率会跌到 80% 以下模型再强也救不回来。标注格式上BIO 还是 BIOES 是个经典问题。BIO 用 B-实体类型、I-实体类型、O 三种标签简单直接BIOES 额外引入 E 和 S迫使模型显式学习实体边界。在电子病历场景下实体长度偏短平均 24 个字BIOES 对边界识别更友好代价是标签数量从 3 变成 2n1 个类别不平衡更明显。我的建议是如果实体平均长度超过 4 个字用 BIO如果以 23 字短实体为主用 BIOES。病历里药物名和检查项目名多是 24 字所以一般选 BIOES 更稳。BIOES 标签的生成逻辑可以用一段脚本批量处理def bio_to_bioes(tags): 把 BIO 标签序列转为 BIOES。 tags: [B-Sym, I-Sym, O, B-Med, I-Med, I-Med] out [] i 0 n len(tags) while i n: if tags[i] O: out.append(O) i 1 continue # 找到一个实体的起点 start i while i n and tags[i].startswith(I-): i 1 # 实际上这里应该处理 B/I 的交替见下方说明 # 更稳妥的写法是继续往下直到遇到非 I while i n and (tags[i].startswith(I-) or tags[i].startswith(B-)): if tags[i].startswith(B-): break i 1 length i - start entity_type tags[start].split(-, 1)[1] if length 1: out.append(S- entity_type) else: out.append(B- entity_type) out.extend([I- entity_type] * (length - 2)) out.append(E- entity_type) # 跳过当前已处理部分 return out这段代码写得比较朴素实际生产中建议用seqeval库的内部工具或者先标注 BIOES 再统一转换。重点是转换完成后要做一轮边界校验B 后面必须跟 I 或 ES 前后不能出现同类型 I。标注软件导出的数据常常在这一步有脏数据不做校验直接训练模型会学到“B 后面跟 B 也被允许”的错误规律。2.2 字级输入与词语信息的取舍中文 NER 的常见路线是字级别序列标注。电子病历里存在大量未登录词、缩写、错别字按词切分会导致 OOVOut-of-Vocabulary问题被放大。比如“肝Ca”这种半中半英的写法标准分词器大概率切成“肝 / Ca”“Ca”不在词典里就变成 UNK。字级别输入天然规避了这个问题代价是丢失词边界信息。BERT-wwm 恰好能部分弥补它在预训练阶段用全词掩码替代单字掩码让模型在字编码阶段就隐式学习词边界。from transformers import BertTokenizer tokenizer BertTokenizer.from_pretrained(hfl/chinese-bert-wwm-ext, do_lower_caseTrue) text 患者无明显发热咳嗽给予阿莫西林胶囊治疗 # 不加分词直接切成字 tokens tokenizer.tokenize(text) print(tokens) # [患, 者, 无, 明, 显, 发, 热, 咳, 嗽, , 给, 予, 阿, 莫, 西, 林, 胶, 囊, 治, 疗]注意do_lower_caseTrue对中文没有实际影响但保留它对英文字母如“Ca”“mg”会统一小写避免同一种药物因大小写被拆成两个特征。tokenize返回的是基本 token。病历里如果混着英文缩写和数字建议在预处理阶段把连续字母数字作为一个 token 再交给 tokenizer避免“Ca”被切碎不过对 BertTokenizer 来说它有自己的切分逻辑一般保持原样即可。2.3 token 与标签的对齐策略训练时标签序列的长度必须和 BERT 输出序列长度一致。BERT 会在句子首尾加[CLS]和[SEP]这两个位置的标签通常置为 O。此外中文 BERT 默认按字切分一般不会出现一个中文字被切成多个 subword 的情况所以对齐问题比英文简单得多。def align_label(text, ner_tags, tokenizer, max_len): 将字级 ner_tags 对齐到 BERT subword 序列。 返回 input_ids, attention_mask, label_ids。 tokens tokenizer.tokenize(text[: max_len - 2]) # 预留 [CLS] [SEP] # 中文场景下 tokens 和字的对齐很简单 # tokenizer.tokenize 对中文字是逐字返回的所以长度一致 assert len(tokens) len(ner_tags[: max_len - 2][: len(tokens)]), \ f长度不匹配: {len(tokens)} vs {len(ner_tags)} label_map {tag: i for i, tag in enumerate(label_list)} label_ids [0] [label_map[t] for t in ner_tags[: len(tokens)]] [0] # [CLS] 和 [SEP] 对应的标签置为 O 的 id通常 O 的 id 是 0 input_ids tokenizer.convert_tokens_to_ids(tokens) input_ids [tokenizer.cls_token_id] input_ids [tokenizer.sep_token_id] attention_mask [1] * len(input_ids) # 补齐到 max_len pad_len max_len - len(input_ids) if pad_len 0: input_ids [tokenizer.pad_token_id] * pad_len attention_mask [0] * pad_len label_ids [-100] * pad_len # -100 在 CrossEntropyLoss 里被忽略 return input_ids[:max_len], attention_mask[:max_len], label_ids[:max_len]这段代码里有个容易翻车的点assert只在调试时开生产环境下如果len(tokens)不等于len(ner_tags)会直接崩溃。更稳妥的做法是对齐后再把 label 截到 tokens 长度比如ner_tags ner_tags[:len(tokens)]。另外label_ids用了-100做 padding 标签这是 PyTorchCrossEntropyLoss内置的 ignore_index 约定不这样做的话被 padding 的位置也在计算损失会严重拉低模型效果。3. 模型是怎么搭起来的BERT-wwm 抽特征BiLSTM 管序列CRF 定边界3.1 BERT-wwm 和普通 BERT 的差别在哪BERT-wwm 的全称是 Whole Word Masking它的核心改动在预训练阶段普通 BERT 在 Mask 时随机遮住单个字比如“发热”可能遮住“热”而保留“发”wwm 会把一个词的所有字都遮住。对中文来说词边界由分词器事先算出wwm 强制模型在预测时同时看到同一词的多个被遮位置这让字向量更容易携带词级信息。在电子病历 NER 里这个特性带来的具体收益是模型对“阿莫西林”“头孢曲松”这类多字药名在字向量层面就已经具备整体识别能力而不是依赖 CRF 去强行纠正。实际上如果只跑单模型对比BERT-wwm 相对 BERT-base 在医疗 NER 上的 F1 提升通常有 12 个百分点代价是预训练耗时更长、需要更大的语料。另一个变体 BERT-wwm-ext 在原有基础上扩充了预训练语料效果又比 wwm 基础版好一点所以我一般直接选hfl/chinese-bert-wwm-ext作为主力。3.2 BiLSTM 在这条链路里的真实作用BERT 输出的是每个 token 的上下文表示理论上已经很强为什么还要再接一层 BiLSTM两个原因。第一BERT 的输出是独立逐 token 的每个位置的输出没有显式建模相邻标签间的转移关系BiLSTM 在序列维度上再做一轮双向编码能捕捉局部上下文模式。第二计算资源的现实约束如果序列长度 128、batch size 32BERT 输出维度是 768直接接线性层做 Softmax 也可以但实测中在标签转移频繁的短实体上BiLSTM 的归纳偏置能减少 0.51 个点的 F1 损失。class BiLSTMDecoder(nn.Module): def __init__(self, input_dim, hidden_dim, num_tags, dropout0.5): super().__init__() self.lstm nn.LSTM( input_dim, hidden_dim, num_layers1, batch_firstTrue, bidirectionalTrue ) self.dropout nn.Dropout(dropout) # 双向 LSTM 输出维度是 hidden_dim * 2 self.hidden2tag nn.Linear(hidden_dim * 2, num_tags) self.hidden_dim hidden_dim def forward(self, bert_outputs, mask): # bert_outputs: [batch, seq_len, 768] # mask: [batch, seq_len], 1 表示有效位置 packed nn.utils.rnn.pack_padded_sequence( bert_outputs, mask.sum(1).cpu(), batch_firstTrue, enforce_sortedFalse ) lstm_out, _ self.lstm(packed) lstm_out, _ nn.utils.rnn.pad_packed_sequence( lstm_out, batch_firstTrue ) lstm_out self.dropout(lstm_out) return self.hidden2tag(lstm_out)这里用pack_padded_sequence是为了跳过 padding 位置的计算训练时可以省 10%20% 时间的做法。enforce_sortedFalse表示不要求 batch 内按长度降序排列PyTorch 会自动处理排序。一个值得注意的细节mask.sum(1).cpu()把长度移到 CPU这个操作每个 batch 都做观测下来GPU同步开销很小不用过度优化。3.3 CRF 层标签转移约束的量化BiLSTM 输出的每个位置的 logits 是独立预测的没有考虑标签之间的转移。举个例子模型可能预测出“B-Sym 后面跟着 B-Sym”这在 BIOES 标注下是非法序列。CRF 层把标签序列建模为条件随机场引入一个转移矩阵学习“B 后面只能跟 I 或 E、S 后面不能再跟同实体 B”这类规则。from torchcrf import CRF class BertWwmBiLSTMCRF(nn.Module): def __init__(self, bert_namehfl/chinese-bert-wwm-ext, num_tagslen(label_list), lstm_hidden256, dropout0.5): super().__init__() self.bert BertModel.from_pretrained(bert_name) self.bilstm BiLSTMDecoder( input_dimself.bert.config.hidden_size, hidden_dimlstm_hidden, num_tagsnum_tags, dropoutdropout ) self.crf CRF(num_tags, batch_firstTrue) def forward(self, input_ids, attention_mask, label_idsNone): bert_outputs self.bert( input_ids, attention_maskattention_mask ).last_hidden_state emissions self.bilstm(bert_outputs, attention_mask) if label_ids is not None: # 负对数似然损失 loss -self.crf(emissions, label_ids, maskattention_mask.bool()) return loss else: # 推理时用维特比解码 decoded self.crf.decode(emissions, maskattention_mask.bool()) return decodedCRF 的decode默认用维特比算法时间复杂度是 O(seq_len × num_tags²)在这个规模下可以忽略。训练时的负对数似然损失并不等于CrossEntropyLoss它是对整个标签序列做全局归一化。因此直观来看CRF 与 BiLSTM 的损失曲线变化规律不同CRF 损失下降通常更慢但验证集 F1 更稳定。4. 最小可运行实现从数据集到模型训练、推理的完整闭环4.1 数据加载与 Dataset 类设计数据组织方式直接决定后续 debug 效率。常见做法是每个训练样本一份 JSON包含text、entities其中entities是{label_type, start, end, entity_text}的列表。加载时把实体转换成 BIOES 标签序列然后交给 Dataset 类做 token 化和标签对齐。import json from torch.utils.data import Dataset from transformers import BertTokenizer class MedicalNERDataset(Dataset): def __init__(self, json_path, tokenizer, max_len128): self.samples [] self.tokenizer tokenizer self.max_len max_len self.label_list [O, B-Sym, I-Sym, E-Sym, S-Sym, B-Med, I-Med, E-Med, S-Med, ...] with open(json_path, encodingutf-8) as f: raw_data json.load(f) for item in raw_data: text item[text] tags self._entities_to_tags(text, item[entities]) input_ids, mask, label_ids self._align(text, tags) self.samples.append({ input_ids: input_ids, attention_mask: mask, label_ids: label_ids }) def _entities_to_tags(self, text, entities): tags [O] * len(text) for ent in entities: start, end ent[start], ent[end] typ ent[label_type] if end - start 1: tags[start] fS-{typ} else: tags[start] fB-{typ} for i in range(start 1, end): if tags[i] ! O: # 实体重叠冲突丢弃较短的实体 continue tags[i] fI-{typ} tags[end - 1] fE-{typ} return tags这段实现刻意暴露了一个坑实体重叠时用“先到先得”策略后面的实体如果和已有实体冲突则丢弃。实际标注数据里重叠实体的比例很低约 1%3%“直接丢弃”这种做法在工程上是够用的。但要注意for i in range(start 1, end)里不能覆盖end位置否则会把下一个实体的起点改成 I 标签训练数据直接污染。4.2 训练循环与参数选择训练参数的选择比模型结构更影响最终效果。BERT 部分用 2e-5 学习率、BiLSTM 部分用 1e-3这个组合算是医疗 NER 的“默认值”。我一般用 AdamW 和线性学习率衰减训练 58 个 epochbatch size 1632。序列最大长度取 128因为电子病历的句子普遍短超过 128 的训练样本占比小于 5%截断后影响不大。from torch.optim import AdamW from transformers import get_linear_schedule_with_warmup optimizer AdamW([ {params: model.bert.parameters(), lr: 2e-5}, {params: model.bilstm.parameters(), lr: 1e-3}, {params: model.crf.parameters(), lr: 1e-3} ], weight_decay0.01) total_steps len(train_loader) * epochs scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_stepsint(total_steps * 0.1), num_training_stepstotal_steps ) for epoch in range(epochs): model.train() for batch_idx, batch in enumerate(train_loader): input_ids batch[input_ids].cuda() attention_mask batch[attention_mask].cuda() label_ids batch[label_ids].cuda() loss model(input_ids, attention_mask, label_ids) loss.backward() # 梯度裁剪CRF 层很容易梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0) optimizer.step() scheduler.step() optimizer.zero_grad() if batch_idx % 100 0: print(fEpoch {epoch}, Batch {batch_idx}, Loss {loss.item():.4f})两个参数值得展开。第一是weight_decay0.01这个值对 BERT 系的权重衰减至关重要调成 0 或太大都会影响收敛BertAdam 时代默认 0.01换 AdamW 后保持相同的值。第二是num_warmup_steps设为总步数的 10%这是基础工程经验warmup 太少会让 BERT 在大学习率下破坏预训练权重太多则训练后期学习率过低、损失降不下去。对抗梯度爆炸我加上了梯度裁剪。BERT 冻结状态切换解冻全部参数后的前两个 epoch 是梯度爆炸高发期epoch 1 的 loss 偶尔飙到 NaN原因几乎都是 CRF 的发射矩阵出现奇点裁剪后基本解决。4.3 推理与实体的重组训练完成后推理阶段每个 token 得到一个 BIOES 标签需要把它们还原成不重叠的实体列表。维特比解码直接返回decoded序列然后做实体重组。def decode_predictions(text, tag_seq, label_list): 从 BIOES 标签序列还原实体列表。 entities [] i 0 while i len(tag_seq): tag tag_seq[i] if tag.startswith(B-): entity_type tag.split(-, 1)[1] # 向后合并 I 直到遇到 E 或非 I start i i 1 while i len(tag_seq) and tag_seq[i] fI-{entity_type}: i 1 end i - 1 # 闭区间 if tag_seq[end].startswith(E-): entities.append({ type: entity_type, start: start, end: end 1, # Python 切片风格 text: text[start:end 1] }) # 如果最后没遇到 E 而只是孤立 B 标签视为无效预测丢弃 elif tag.startswith(S-): entity_type tag.split(-, 1)[1] entities.append({ type: entity_type, start: i, end: i 1, text: text[i] }) i 1 else: i 1 return entities推理阶段有个隐藏问题如果模型预测出“B-Sym 后面没有 E-Sym 就结束了”上面的重组逻辑会把孤立 B 丢弃。这看起来是保守处理但实际病历比较结果里这类非法序列在本模型上约占 3%5%丢弃它们后的精确率比强制补全 E 还要高。原因在于这些边界处的预测通常本来就不太可靠与其补一个错误边界不如丢弃换取更高精确率。5. 避坑指南电子病历 NER 里最容易翻车的四个环节5.1 标签错位mask 位置导致的系统性偏移现象训练 loss 正常下降但验证集 F1 一直徘徊在 0.5 附近。打印几条预测结果发现所有实体边界整体右移一个 token。原因标签对齐时没有考虑 BERT 加入[CLS]后序列整体右移导致模型学到“真实标签被推后一个位置”的错误映射。这种情况在 token 化后的序列长度小于原始文本时尤其隐蔽因为截断会让偏移在序列末尾“消失”看起来不严重实际是整体错位。解决数据准备阶段打印任意一条样本的(text, tokens, label_ids)人工对比第 510 个位置是否对齐。我习惯在 Dataset 初始化时把这个打印固化成 debug 开关模型迭代时就自己纠错。还有一个辅助验证统计训练集每个标签的分布如果某个实体类型的 B 标签数量明显少于 E 标签说明序列起始处的标签被截断了。5.2 截断“切掉”长实体现象验证集上长度超过 20 个字的实体如“胸部正位X线检查提示无明显异常”几乎全部识别失败而短实体识别效果正常。原因max_len128是按截断处理的BERT 只关注前 128 个 token后半部分的标签全部变成-100被忽略。更细节的原因是训练时被截断的实体标签被丢弃模型从未见过“长实体完整出现在输入中”的情况推理时自然给不出正确边界。解决先把训练文本按句号、分号、换行切成短句再做截断这样长实体跨句的概率被压低。残留的跨句实体数量会很少足以用后处理规则拼回。另外把max_len提到 192代价是显存占用上升约 50%在单卡 16G 的条件下仍可跑。5.3 数据里的类别不平衡被 ignore_index 放大现象损失曲线收敛后检查类别混淆矩阵发现“时间”类实体预测精确率只有 60%远低于其他类型。原因时间实体的标注数量可能只有症状类的 1/20且-100padding 占了一半以上序列。模型在极大类别不平衡下会把时间实体倾向性预测为 O。这属于标注分布不合理不是模型的锅。解决轻量办法是给损失函数加类别权重CRF 的torchcrf不支持类别权重所以我在训练时做“伪采样”把含时间实体的样本复制 2 倍放入训练集。更规范的做法是改用BERT-CRF后接CrossEntropyLoss时给每个标签权重但那样就绕开了 CRF。按我的经验先复制样本、跑通基线再考虑更复杂的不平衡方案。5.4 词边界“玄学”wwm 带来的虚假增益现象同一份数据把bert-base-chinese换成hfl/chinese-bert-wwm-ext后F1 提升 2 个点。在另一份公开资料上却毫无提升甚至下降。原因wwm 的增益依赖预训练语料的词边界质量。如果模型用的分词器对医学实体切得不准全词掩码反而学了错误的词边界进一步污染微调。这在哪些语料上有效没有定论基本是玄学。解决只凭验证集表现选模型不要默认 wwm 更好。搭建一个“一个脚本、多模型切换”的评估流程每次实验都跑 BERT-base、BERT-wwm-ext、RoBERTa-wwm-ext 三个基线用相同的固定随机种子对比而不是靠单次实验印象。6. 多模型性能评估从 F1 到部署前的验证清单6.1 评估指标怎么定针对命名实体识别任务标准评估用seqeval计算实体级别的精确率、召回率和 F1。字符级指标在生产环境中没有参考价值——医生关心的是“某个症状是否被完整提取”出来。一个完整的实体需要边界和类型同时正确才算对这一点要写进评估脚本的注释里防止团队内部对指标口径产生分歧。模型PRF1BERT-base Softmax0.8710.8420.856BERT-base BiLSTM-CRF0.8850.8630.874BERT-wwm-ext BiLSTM-CRF0.8980.8750.886RoBERTa-wwm-ext BiLSTM-CRF0.9020.8810.891上表模拟的是一份 8000 条标注病历、五类实体上的典型结果。直观读法BiLSTM-CRF 相对 Softmax 的提升集中在精确率因为 CRF 过滤了大量非法标签序列wwm 的提升集中在召回率因为词边界信息让长实体更容易被完整识别。6.2 消融实验与稳定性验证除了一次性对比更值得做的是“增量验证”。把同一个测试集按医生、科室、文本来源分组分别算 F1观察模型在哪类文本上退化。曾见过某模型在门诊病历 F1 0.90在 ICU 记录只有 0.72——原因是 ICU 记录里缩写密度高出数倍。如果数据来源多元建议在预处理阶段给每条样本打上来源标签评估时按来源分桶报告。另一个有效的稳定性测试是随机重复训练 3 次取 F1 均值和标准差。深度模型对随机种子很敏感同一种子单次实验的结果差异可能高达 1.5 个点只看单次结果会误导模型选型。用固定的SEED和PyTorch Lightning或transformers.Trainer的确定性模式能缓解但挡不住 CUDNN 在某些深度上的非确定性——只能靠重复实验打败它。6.3 部署后怎么持续验证模型上线后建议每月抽 200 条新病历做一轮人工复核计算真实环境 F1 和训练测试集 F1 的差值。这个差值会随着电子病历模板变更、新药名出现而逐步拉大。当差值超过 5 个点时就该触发增量训练流程抽样新增病历、标注、合并旧训练集、重训。整个过程并不复杂但需要在标注阶段留好实体级别的来源 ID方便后面按来源筛选训练数据。做完这几步剩下的就是定期观察 CRF 转移矩阵里异常转移是否增加——如果“B 直接跳 E”的转移权重突然上升说明新数据里短实体变多了模型在学习适应不是故障。带着这套验证手法换新模型时就不会迷茫。希望帮到你。本文还有配套的精品资源点击获取