简介面向中文电子病历的命名实体识别NER实战项目源码基于CCKS2019评测任务与开源数据集构建采用BERT-BiLSTM模型完成从文本预处理到训练评估的完整流程适合自然语言处理学习者、医疗文本挖掘研究人员以及希望快速上手中文医疗NER的开发者。压缩包共17个文件以py源码和txt数据文件为主体辅以png可视化图、docx任务说明及json测试集整体仅1.5MB目录结构清晰便于按模块查阅。源码覆盖数据读取、清洗、特征构建、模型训练与结果可视化代码注释详细、逻辑简洁可直接运行调试配套说明文档帮助理解赛题背景与代码设计也可替换自有数据完成迁移。已有2986人学习下载实用性受到认可是一套能帮助读者从零完成中文电子病历NER任务的高性价比实践资源。中文电子病历命名实体识别从零搭建一个能跑的实战项目先交代一下背景。我之前在医疗信息化相关的项目组里待过一段时间日常接触最多的就是一堆堆从医院信息科导出的电子病历。这些病历是纯文本写着“患者于5年前无明显诱因出现间歇性胸痛伴放射性左肩背部疼痛”“高血压病史3年平素口服硝苯地平控释片血压控制可”这类句子。信息量极大但全是非结构化文本统计、检索、辅助决策全都用不上。要做临床科研数据抽取、药品不良反应监测、病历质控第一步都得先让机器看懂“谁得了什么病、吃了什么药、做了什么检查”——这就是面向中文电子病历的命名实体识别Named Entity Recognition, NER实战项目的核心任务。这个项目我前后折腾了小两个月从最开始拿通用语料训练的模型在病历上“全线崩盘”到最后能稳定抽取出症状、疾病、药物、检查、手术五类实体中间踩的坑不少。这篇文章就围绕这套实战源码把数据准备、模型选型、训练调优、踩坑记录一整套完整拆开讲。目标读者是已经知道NER是什么、跑过一两个通用NER demo但还没在医疗文本上真正动过手的人。文章里的源码思路和关键代码片段都会给出你可以直接照着搭一套。1. 医疗文本不像新闻病历NER难在哪1.1 电子病历的语言特征通用领域NER比如人民日报语料、CoNLL-2003英文语料和中文电子病历的NER难度不是一个量级的。我一开始天真地以为只要把BERT接上CRF跑个几个epoch就能完事结果pipeline一套在真实病历上的F1只能到70%出头基本没法用。为什么因为中文电子病历有几个非常“不友好”的语言特征。首先是句子长度长且结构杂。病历里的现病史段落经常是一整段三五百字的文本里面嵌套着时间、事件、症状、程度、部位、阴性症状描述。比如典型的一段“患者于2021年3月无明显诱因出现活动后胸闷、气促休息3-5分钟可缓解未予重视后症状反复发作于2021年5月就诊于当地医院查心电图提示ST-T改变予阿司匹林、阿托伐他汀口服治疗后症状缓解。”这一段看上去是“人话”但对模型来说多个实体之间没有明显的边界信号“活动后”“休息3-5分钟”都不是实体却是最容易让模型“抢跑”或“卡壳”的干扰片段。其次是口语化和省略。病历不是文章医生写的时候大量省略主语用“无明显诱因”“未予特殊处理”“查体未见明显阳性体征”这类固定表达还会用大量缩略词“高血”“冠心”“甲功”“肝肾功”这些词在标准词典里根本查不到。更头疼的是错别字和音近字比如“低钾”写成“低甲”“慢性”写成“漫性”这类噪声在真实病历数据里一点都不少见。1.2 中文分词的二义性让实体边界更头疼英文NER的token天然是词空格分隔但中文不行。中文病历NER通常的pipeline有两种流派一是先分词再标注二是直接字级别标注。我的经验是一旦涉及医疗实体就不要做分词直接用字级别标注。原因很简单医疗实体边界和分词边界经常不一致。比如“阿司匹林肠溶片”是一个药物实体但分词器很可能切出“阿司匹林/肠溶/片”“肺源性心脏病”是疾病实体分词器可能切成“肺/源性/心脏病”。一旦把分词结果当成输入边界模型的错误就会被分词器放大一点补救机会都没有。所以这套项目里输入的基本单位是单字用BIOS标注体系。B表示实体开始I表示实体内部O表示非实体S表示单字独立成实体。五类实体分别用独立标签比如B-Disease、I-Disease、S-Drug等等。这样设计的好处是模型不需要依赖外部分词器实体边界完全由模型自己学习避免了对分词器的误差传递。2. 数据准备没有标注一切都是空谈2.1 标注方案与数据来源这一块没有捷径。你要么自己标要么找到开源的中文医疗NER数据集。我自己用了CCKS和CMeEE相关的公开数据集做训练和验证同时从实际项目中脱敏了一批病历做测试。CCKS任务里的数据集包含的实体类型跟我项目里的不完全一致但没关系我把所有数据统一映射成了五大类疾病Disease、症状Symptom、药物Drug、检查Check、手术Operation。映射过程中要注意有些实体在不同数据集里归类不一致比如“胸痛”在A数据集是症状在B数据集可能被标成表现这种就得人工review统一口径。标注格式用的是最常见的“字标签”两列一句病历和它的标签序列之间用空行隔开。下面给一个实例患 B-Disease 者 O 于 O 5 O 年 O 前 O 无 O 明 O 显 O 诱 O 因 O 出 O 现 O 间 B-Symptom 歇 I-Symptom 性 I-Symptom 胸 I-Symptom 痛 I-Symptom O 伴 O 放 O 射 O 性 O 左 B-Symptom 肩 I-Symptom 背 I-Symptom 部 I-Symptom 疼 I-Symptom 痛 I-Symptom数据量方面我最终用到的有效标注句子大约2.8万句这里包含了公开数据集中筛选出的部分和自标注数据。自标注的比例不高但要保证质量这个后面会讲。2.2 数据预处理的关键细节拿到原始标注数据后预处理不是简单读进来就行有几个细节直接在后期决定了下限。第一是实体长度的统计。我建议在任何模型开始跑之前先统计一下数据集中实体的长度分布。我这边统计下来中文病历中70%以上的实体集中在2到6个字符之间但“胸部X线正侧位片”“心脏彩色多普勒超声检查”这类检查实体可以长达十几个字符。如果你直接按照通用的max_length128做截断长实体会被切得七零八落模型根本没机会学到完整模式。所以我的做法是把max_length设到256并在截断时尽量保证句子级别的完整性而不是硬切。第二是实体的类别不均衡问题。症状和疾病类实体在病历中出现频率极高而手术类实体相对稀少。直接训练模型会对高频实体过拟合手术类实体F1惨不忍睹。一个直接有效的处理是对类别做频率统计在loss里给低频类别加权或者用WeightedCrossEntropyLoss。我用的权重计算很简单统计训练集中每个标签的频次取倒数再归一化对稀有实体类别的标签给更高权重在计算loss时对整个序列的token标签加权求和。这个操作不复杂但对手术类这类低频实体的召回率提升非常明显。第三是训练集和验证集的划分要按病历样本划分而不是按句子随机打乱。同一份病历的句子之间上下文高度相关如果按句子随机划分验证集中会出现很多“近似训练样本”导致验证结果虚高。按病历文件划分能更真实地反映模型在未见过的病历上的表现。2.3 数据增强不要小看拼音和字形的扰动医疗NLP场景下标注数据永远不够数据增强能做但必须谨慎。我尝试过两种有效的方法同义词替换针对非实体字用《同义词词林》扩展版替换实体区域不替换。比如把“患者”换成“病人”“平素”换成“平时”这能增加模型对字面变化的鲁棒性。拼音/字形扰动这个听起来离谱但对医疗文本意外有效。做法是把少量非实体区域的字替换成拼音相近的字模拟医生打字错误或者替换成形状相似的字。这样模拟真实病历中的错别字噪声。但比例不能高如果扰动超过5%模型性能会明显下降。3. 模型选型为什么我从BiLSTM-CRF一路走到BERT3.1 BiLSTM-CRF入门首选但不是终点先说我第一次搭的基线模型BiLSTM-CRF。这个模型结构很经典BiLSTM负责从上下文中提取特征CRF层负责在标签序列之间建模约束关系。比如“B-Disease后面不能直接跟O再跟I-Disease”这种转移规则CRF天然会学到。我用的是字向量作为输入300维由word2vec在大量中文医疗文本上预训练得到。BiLSTM隐层维度256dropout 0.5CRF损失函数用负对数似然。跑下来在验证集上的F1大概82%左右。作为一套可解释性强、推理速度快的baseline它完全合格尤其适合实体类别少、文本比较规整的场景。但BiLSTM-CRF有一个明显天花板它没有解决“一词多义”的问题。病历里“湿啰音”的“啰音”是症状体征实体但“音响”里的“音”就只是普通字“药物”实体“心得安”里的“安”和形容状态的“安”完全是两个字义。字向量是静态的同一个字在不同上下文里拿到的是同一个向量表达模型很难区分。而预训练语言模型BERT类模型能在每个字的位置根据整个句子的上下文动态生成字向量天然解决了一词多义问题。此外BERT自带的那种Transformer结构对长距离依赖的建模能力对病历这种长句文本帮助极大。3.2 中文医疗预训练模型的选型经验BERT也不是随便拉一个中文版就能上。我试过三种模型参数量医疗文本预训练程度我在病历数据上的F1BERTCRFBERT-base-Chinese110M无通用语料87.2%RoBERTa-wwm-ext-Chinese110M无通用语料但用全词掩码87.8%医疗领域继续预训练的BERT110M在中文医疗文本上继续预训练89.5%结论很明显领域预训练很重要。直接用通用BERT能打87分但一个在医疗语料上继续预训练过的BERT就能再涨两个多点。网上有几个开源的中文医疗BERT模型比如基于PubMed和中文医学语料继续预训练的版本直接拿来用就可以不需要自己从头预训练那成本太高。另外提醒一句如果你用的是HuggingFace的transformers框架加载预训练模型时会默认下载词汇表和配置国内网络环境建议配好镜像或者预先把模型下载到本地。这在后面源码部分会用到。3.3 为什么最终还是用了BERTCRF而不是BERTSoftmaxBERT的最后一层输出接一个Softmax做分类是最简单的做法训练快显存也省。但它有一个问题它独立地对每个token做分类不考虑标签之间的转移约束。在通用NER上这个缺点可能不明显但在医疗NRE里“O后面不能直接跟I”这类约束特别重要。比如实体“心肌梗死”模型在“心”的位置输出了B-Disease在“肌”的位置输出了I-Disease但在“梗”的位置错输出了O那整个实体就断了后续如果要做实体链接或者事件抽取这种错误非常致命。CRF层则能在解码阶段全局考虑标签序列的联合概率。B后面可以跟I但O后面不能跟I这类转移约束可以显式建模。而且CRF层的解码用的是维特比算法能输出全局最优的标签序列而不是每个位置独立取最大概率。在医疗实体上这带来的F1提升通常在1.5%到3%之间所以即便是BERT时代保留一个CRF层依然是性价比极高的选择。4. 源码结构与核心实现拆解4.1 项目目录一览整个项目的源码结构不复杂核心就是数据处理、模型定义、训练评估三个模块。下面是目录medical_ner/ ├── config.py # 全局配置路径、超参数、标签映射 ├── data_loader.py # 数据加载与预处理构造DataLoader ├── model.py # BERT BiLSTM CRF模型定义 ├── train.py # 训练主逻辑包含验证与checkpoint保存 ├── predict.py # 模型推理输出实体结果 ├── utils.py # 标签转换、指标计算等工具函数 ├── data/ │ ├── train.txt # 训练集字标签 │ ├── dev.txt # 验证集 │ └── test.txt # 测试集 ├── pretrain/ # 本地保存的医疗预训练模型目录 └── output/ # 训练输出的模型checkpoint和预测结果4.2 数据加载tokenizer对齐是最大的坑数据加载这块代码不多但踩坑最多。from transformers import BertTokenizer, BertConfig tokenizer BertTokenizer.from_pretrained(./pretrain/)BERT的tokenizer会把输入拆成wordpiece token中文通常是一个字一个token但英文、数字、特殊符号可能被拆成多个subword。而我的标注标签是基于字的所以当3被拆成token时如果3.5被拆成了[3, ., 5]三个token共用一个标签会出错。解决办法是在数据加载时将标签序列与token序列严格对齐先对原始文本做token化拿到每个token在原始文本中的偏移量然后把该token对应的标签设为其起始字标签。遇到特殊情况比如CLS和SEP统一给X标签在计算loss时忽略。def align_label(text, labels, tokenizer, label2id): tokens tokenizer.tokenize(tokenizer.cls_token) label_ids [label2id[O]] # [CLS] label ignore # 对句子里的每个字找到对应token for char, label in zip(text, labels): char_tokens tokenizer.tokenize(char) if len(char_tokens) 0: char_tokens [[UNK]] tokens.extend(char_tokens) for i in range(len(char_tokens)): label_ids.append(label2id[label]) tokens.append(tokenizer.tokenize(tokenizer.sep_token)) label_ids.append(label2id[O]) # [SEP] label ignore return tokens, label_ids这段代码看着简单但真实跑数据的时候会遇到一个问题tokenize一个汉字时返回空列表。这种情况通常发生在特殊符号上比如全角空格、不可见字符。所以一定要加if len(char_tokens) 0的判断不让token和label的数量错位。4.3 模型结构三件套组合模型定义这一块是核心中的核心。完整代码结构如下import torch.nn as nn from transformers import BertModel from torchcrf import CRF class BertBiLSTMCRF(nn.Module): def __init__(self, bert_dir, n_tags, hidden_size256, dropout0.5): super().__init__() self.bert BertModel.from_pretrained(bert_dir) self.bilstm nn.LSTM( input_sizeself.bert.config.hidden_size, hidden_sizehidden_size, num_layers1, bidirectionalTrue, batch_firstTrue ) self.dropout nn.Dropout(dropout) self.fc nn.Linear(hidden_size * 2, n_tags) self.crf CRF(n_tags, batch_firstTrue) def forward(self, input_ids, attention_mask, labelsNone): outputs self.bert(input_idsinput_ids, attention_maskattention_mask) seq_out outputs[0] # [batch, seq_len, hidden_dim] seq_out, _ self.bilstm(seq_out) seq_out self.dropout(seq_out) emissions self.fc(seq_out) # [batch, seq_len, n_tags] if labels is not None: loss -self.crf(emissions, labels, maskattention_mask.bool(), reductionmean) return loss else: decoded self.crf.decode(emissions, maskattention_mask.bool()) return decoded这个结构有个小细节值得说中间那层BiLSTM到底有没有必要加我测试过直接BERTCRF和加上BiLSTM的效果后者在病历数据上F1略高约0.5个百分点但推理速度会更慢。如果你的数据量不大、实体类别不超过十类可以直接去掉BiLSTM用BERTCRF更省事。但如果你追求极致性能并且计算资源够用保留BiLSTM层还是值得的。另外一个很关键的细节是attention_mask要转化成bool类型传给CRF层。torchcrf库要求mask的类型是torch.bool否则会报类型错误。我在第一次跑的时候在这个小坑上浪费了不少时间。4.4 类别不平衡loss的引入前面提到样本不均衡问题代码层面怎么实现在计算loss时不直接用self.crf(emissions, labels)而是先按标签权重处理emissions。CRF内部对每个位置的标签打分用的是emissions里的对应值如果想引入标签权重可以对emissions做加权缩放# 在model.py中添加权重参数 weight torch.tensor([1.0] * n_tags, deviceemissions.device) # 低频标签的权重提高到2~3 for label_name in [B-Operation, I-Operation, S-Operation]: weight[label2id[label_name]] 2.5 weighted_emissions emissions * weight.unsqueeze(0).unsqueeze(0) loss -self.crf(weighted_emissions, labels, maskattention_mask.bool(), reductionmean)这样做的代价是CRF学到的转移矩阵也会受到权重影响刚开始调大权重训练指标会有下降需要观察几个epoch后再调整。但通常这样操作后低频实体类别的F1上涨的幅度会远高于整体指标的微小下降。5. 训练细节、调参与避坑实录5.1 学习率设置一个容易让你白跑一天的坑BERT类模型对微调的学习率极其敏感。我第一版直接沿用了一个通用NER项目的超参数学习率5e-5batch size 32跑了20个epoch。结果loss曲线前几个epoch跌得很快后面开始震荡验证F1在83%左右死活上不去。后来看了不少同类项目的经验才发现问题出在BERT层的学习率和其他层不应该一样。标准做法是分组学习率optimizer_grouped_parameters [ {params: [p for n, p in model.named_parameters() if bert in n], lr: 2e-5}, {params: [p for n, p in model.named_parameters() if bert not in n], lr: 1e-3} ] optimizer AdamW(optimizer_grouped_parameters, weight_decay0.01)BERT层用小的学习率2e-5到3e-5BiLSTM和CRF层用大的学习率1e-3级别。原因是BERT已经在大规模语料上学到了很强的通用语义表示微调时步子太大容易忘记原有知识而BiLSTM和CRF是从零开始训练需要更大的学习率才能尽快收敛。另外warmup的步数建议设成总训练步数的5%到10%。医疗NER训练数据量一般不大不设warmup前期loss容易起飞。5.2 实体边界错位病历文本里最常见的“模型行为异常”训练到第10个epoch左右我去翻了预测结果发现一个极其普遍的错误模式模型把实体的最后一个字丢掉或者多吞一个字。比如“冠心病”预测成“冠心”“2型糖尿病”预测成“型糖尿病”“胸部CT示”预测成“胸部CT示示”。这不是随机错误它很有规律。我排查了很久最后发现根因在数据预处理中文文本里末尾一些助词比如“的”“了”“示”“见”这类字经常会和实体粘连。而由于CRF层学习的转移规则里“B-Symptom后面可以跟I-Symptom”的概率很高但“I-Symptom后面必须跟O”这个结束信号不够强导致模型在“是否继续吞字”这个决策上退化成了“看概率高低”。这也是为什么纯BiLSTM-CRF模型在病历上会系统性多吞字而BERT因为上下文语义更强这个毛病会轻一些。解决办法有两个一是数据层面标注的时候严格规定实体边界。比如“无压痛反跳痛”里的“无”字不能包含进症状实体“心电图示ST-T改变”里的“示”不属于检查实体。标注规范必须明确。二是训练层面CRF层已经提供了转移矩阵可以直接从训练完的模型中导出转移参数检查B-Symptom - O和I-Symptom - O的转移概率是否明显偏低。如果偏低说明数据里实体被截断的情况较多或者实体边界标注不一致。5.3 类别分布与指标解读训练完成后我通常不只盯单一指标而是看每个实体类别分别的精确率、召回率和F1。一组典型的测试结果如下实体类别精确率召回率F1症状91.2%90.5%90.8%疾病89.4%88.1%88.7%药物93.0%92.1%92.5%检查86.5%84.2%85.3%手术83.1%78.4%80.7%可以明显看到手术类实体的表现靠后。不是模型不行是手术实体的标注一致性本来就差——“阑尾切除术”和“腹腔镜阑尾切除术LA”可能被不同标注员标成不同实体。这个问题的根治办法是在下游做实体归一化把“LA”“腹腔镜阑尾切除术”映射到标准术语。属于另一块内容了。5.4 推理阶段的注意点推理时不要每句话都让模型跑一次BERT那太慢了。实际部署时应该把文本先按标点句号、逗号切分成短句再分batch送给模型。我实测在CPU环境下单条长记录一次性输入BERT耗时约180毫秒切句后分batch输入平均耗时能降到30毫秒以内。另外别忘了设置torch.no_grad()推理阶段不要更新梯度能省下不少显存和计算开销。torch.no_grad() def predict_single_text(model, tokenizer, text, id2label, max_seq_len256): tokens tokenizer(text, truncationTrue, max_lengthmax_seq_len, return_tensorspt) input_ids, attention_mask tokens[input_ids], tokens[attention_mask] outputs model(input_ids, attention_mask) # outputs是list每个元素是一句话的标签id序列 pred_ids outputs[0] # 去掉[CLS]和[SEP]对应的标签 pred_ids pred_ids[1:-1] pred_labels [id2label[i] for i in pred_ids] # 解析实体边界 entities [] current_entity None for idx, label in enumerate(pred_labels): if label.startswith(B-): if current_entity: entities.append(current_entity) current_entity {type: label[2:], start: idx, text: text[idx]} elif label.startswith(I-): if current_entity and current_entity[type] label[2:]: current_entity[text] text[idx] else: if current_entity: entities.append(current_entity) current_entity None elif label.startswith(S-): if current_entity: entities.append(current_entity) entities.append({type: label[2:], start: idx, text: text[idx]}) current_entity None else: # O if current_entity: entities.append(current_entity) current_entity None if current_entity: entities.append(current_entity) return entities注意这里返回的实体是基于字符索引偏移的不是原始文本子串的绝对偏移。因为tokenizer可能会忽略文本中的一些空白字符或者不可见字符如果想拿到原文中的准确位置必须使用tokenizer的offset_mapping特性。否则在文本预处理阶段做过清洗的下游系统里实体位置会全部错位。6. 病历NER的下游扩展从抽取到真正解决问题命名实体识别做完之后项目不能止步于输出一串实体。在实际病历应用中NER的产出往往是更复杂任务的上游输入。第一是实体归一化。同一个概念在病历里可能有几十种写法比如“阿司匹林”“拜阿司匹灵”“阿斯匹林”“ASP”其实都是同一个药物。这需要把抽取出的实体通过词典或者文本相似度匹配到标准术语如ICD-10或ATC编码。我在项目里用simhash编辑距离做了两层匹配对常见缩写和不规范写法效果很好。第二是实体关系抽取。比如你要做“药物—剂量—频次”组合抽取或者“症状—部位—持续时间”组合抽取这需要在NER基础上再用规则或者序列标注模型做关系分类。很多情况下一个简单的基于距离的窗口法配合规则引擎就能实现80%以上的关系抽取准确率不一定要立刻上复杂的联合抽取模型。第三是文本结构化生成。从实体和关系生成结构化病例表单可以直接用于临床病例数据库的自动录入。这些扩展方向每个都能单独写成一篇实战但底层地基都是NER模型的稳定性、边界准确性和类别覆盖度。一个NER模型如果实体边界总偏移下游所有任务都会被误差放大。所以如果你只打算花两个星期做一个医疗NLP项目我强烈建议把80%的精力放在标注规范、数据清洗和模型边界质量上而不是一味追求F1的一个百分点提升。我在做这个项目的过程中最深的体会是医学术语的标准上公开数据集和真实病历之间存在一条天然的鸿沟——公开集再干净真实病历的噪声也会让你怀疑人生。所以源码只是个起点真正决定项目能不能落地的是你愿意花多少时间去啃那些“废话连篇”的原始病历。但啃完之后再回头看这套NER项目的价值就显现了——从一堆非结构化的文字里机器终于能稳定抽出疾病、症状、药物、检查和手术五个核心要素这为后续的临床数据分析省下的功夫远大于建模本身。本文还有配套的精品资源点击获取