
简介基于BERT模型的电子病历命名实体识别系统源码面向医疗信息化开发者和NLP研究人员用于从电子病历文本中自动识别疾病、药物、治疗手段等关键实体提升医疗数据结构化水平。压缩包共38个文件以Python源代码为主体覆盖模型定义、数据处理、训练、预测等环节同时包含文本说明、Markdown笔记、XML配置、Git忽略文件、许可证、Jupyter示例等辅助材料整体约395KB。目前已有320人学习下载。源码完整实现了从数据预处理到模型评估的闭环内置预训练、分类等训练脚本以及RNNCell等实用模块还附带了基于BERT的电影评论情感分析Notebook便于快速上手和对比实验。对于希望在临床文本上微调BERT、开展命名实体识别研究或构建医疗信息抽取系统的开发者这套资源提供了可直接运行的基础代码和清晰的项目结构有助于缩短开发周期。1. 电子病历实体识别设计源码真正的门槛不在BERT之前带内部工具组做电子病历结构化时我最常被问到的问题是“上BERT是不是就稳了”。等真把电子病历命名实体识别做成一套可运行的设计源码才明白模型选型只占三分之一另外三分之二是数据标准、标签体系和解码规则。这个项目要解决的是从入院记录、出院小结、病程记录里自动抽出症状、检查、诊断、药物、部位等医学实体为结构化归档、科研检索和临床辅助决策打标签。适合临床科研团队、医学信息项目也常见于课程设计和毕业设计场景。下面我按实际落地顺序讲一遍每一节都能照着复现。2. 建模前先定标准实体类型与BIO标注是源码的第一份资产2.1 实体类别定义与标注边界一个实体识别工程能不能交付第一眼看的往往不是模型代码而是标签体系。电子病历和新闻文本差异很大实体边界由医生表达习惯决定不像人名、地名那么规整。常见做法是把实体定为六类症状、体征、检查、诊断、手术、药物再叠加解剖部位。以“患者近3月出现寒战、发热查胸部CT示右肺上叶结节”为例普通标注工具会给“发热”打症状标签但实质还要标“寒战”部位“右肺上叶”和检查“胸部CT”也要分开。跨类实体是否允许重叠必须在标注规范里约定。我常用的做法是第一版先做扁平实体标注嵌套重叠实体如“右肺上叶”既是部位又出现在“右肺上叶结节”描述里先记录在案不在第一版引入嵌套标注方案。原因很直接BERT的基线解码头是线性分类或CRF输出的是单标签序列支持嵌套要改成层叠指针或分裂头结构第一版直接做嵌套会造成标签冲突和训练不稳定。先把扁平实体跑通把数据管道和后端推理打通后续要升级再单独加。这一阶段最值得做的其实是一份标注规范。规范里不写“病名”这种模糊词要具体到“糖尿病”是诊断、“血糖升高”是症状、“2型糖尿病”整体按诊断处理实体边界遇到“左前降支”时宁可多收一个字也不要少收。规范写得越具体后面清洗、转换、训练踩坑就越少。2.2 原始病历清洗脚本去掉编号、乱码保留标点信号病历文本进入工作流后第一个必须处理的环节是清洗。真实导出数据往往带着段首编号、全角空格、控制字符和断行问题直接喂给标注工具会造成大量脏标签。我的清洗原则是“轻清洗”只去除破坏结构的干扰不做强归一化。具体脚本如下import re def clean_emr(raw: str) - str: 电子病历原始文本轻清洗去除格式噪声保留标点语义。 if not raw: return # 1. 去掉段首自动编号1. 2) 3、 等 text re.sub(r^\s*\d{1,2}[\.、\)]\s*, , raw, flagsre.MULTILINE) # 2. 去掉病历导出常见的全角空格与不间断空格 text text.replace(\u3000, ).replace(\xa0, ) # 3. 去掉控制字符保留换行符避免整段粘成一行 text re.sub(r[\x00-\x08\x0b\x0c\x0e-\x1f], , text) # 4. 连续换行压成单个换行避免标注视图出现大量空行 text re.sub(r\n{2,}, \n, text).strip() return text这段代码看起来简单但有两个细节容易被忽略。第一注释里特别强调不要做全角转半角、中文标点转英文标点医疗文本里“体温: 36.5℃”和“体温36.5℃”并存强制转换后模型依赖的标点信号被抹平症状边界会变模糊。第二清洗要在标注之前做不要在训练前临时改文本否则已经标好的字符位置全部错位前后端数据不一致这是最典型的返工场景。清洗完后建议看一眼实体长度分布统计一下每个实体的字符数。电子病历里长诊断名特别多比如“冠状动脉粥样硬化性心脏病”21个字如果数据里长实体占比高后面的滑窗参数就要提前设计而不是等到训练完再补救。2.3 从字符位置到BIO序列标签转换的两个边界坑标注工具导出的格式通常是字符级位置也就是start, end, type而BERT输入需要的是每个token对应一个标签。这就需要一个转换函数把位置信息变成BIO序列。BIO里B代表实体开头I代表实体内部O代表非实体。转换代码并不复杂复杂的是边界处理def build_bio(text: str, spans: list[tuple[int, int, str]]) - list[str]: 把字符位置标注转换为BIO标签序列。 参数text为原始字符串spans为 (start, end, type) 列表end不包含。 labels [O] * len(text) for start, end, typ in spans: if start 0 or end len(text) or start end: continue # 越界span静默丢弃标注端要同步校验 labels[start] fB-{typ} for i in range(start 1, end): labels[i] fI-{typ} return labels函数隐含了一个重要约定end是开区间不包含结束位置。标注工具如果用的闭区间转换前要统一减一否则每个实体都会向右多占一个字。另一个坑是重叠spanBIO序列天然不支持一个token有多个标签如果两个实体重叠后写的span会覆盖先写的内容模型学到的就是错误边界。我习惯在导入时就做重叠检测凡是重叠实体直接排除或保留较长的那个宁缺毋滥。还有一个转换问题是字符级标签和token级标签的对齐。中文汉字在BERT里基本是一字一token不裂开但英文单词会被WordPiece继续切分比如“CT”会被切成两个token标签不能直接平移到token序列上。解决办法是用tokenizer返回的offset_mapping先建立token到字符的映射再把字符级标签填到token级标签数组里。忽略这一步的人训练时loss没报错预测时实体位置全部右移属于比较隐蔽的翻车点。2.4 标注一致性两轮质检比调参数更值钱很多项目把重心放在模型调参上但电子病历实体识别的天花板往往由标注质量决定。常见做法是先让两名标注员各标相同的一百条样本算一遍实体级别的一致率不一致的地方全部拉出来讨论把规范补丁打上再开始大规模标注。这个流程看起来慢实际是省时间的因为错误标注进训练集后很难被模型自动纠正后期排查成本更高。这个阶段可以配套一个术语词典做预标注。把常见症状、诊断、药物、手术名称维护成一张表自动把字典命中的词串先在文本里标记出来标注员只需确认和修正边界。词典预标注能压掉大量低水平噪声特别是“左前降支”“卡托普利”这类低频但专业的词模型没见过标注员知道的边界也有限预先锁定能显著提高一致性。这一步完成后再进入下一章模型设计数据资产才算真正立住。3. 用BERT做命名实体识别编码器选型、tokenizer对齐与最小训练代码3.1 BERT模型实操为什么编码器选双向而不是单向命名实体识别在深度学习时代的主流路径是“预训练语言模型加token分类头”。BERT把双向注意力当作编码器对电子病历这类文本非常合适因为一个实体是不是实体、边界在哪里往往取决于上下文共同作用。比如“患者无明显阳性体征”这句话“阳性”出现在“无”和“体征”之间要同时看到前否定和后缀判定边界如果换成GPT这类单向语言模型只能看到左侧信息很容易把“阳性”错判成独立症状。中文场景下更推荐直接用bert-base-chinese这类官方中文权重词表覆盖常见汉字和医疗文本里的高频字模型结构简单改造空间小。实际项目里我通常让BERT只做编码器输出每个token的上下文表示再接一个分类头做标签预测。这样做的原因是BERT本身已经提供了很强的语言先验微调时只需要较低的学习率适配医学文本风格不需要从头训练。3.2 tokenizer对齐CLS、SEP、-100与长度截断电子病历中最难受的是长文本。BERT输入上限是512 token一份入院记录往往几千字无脑截断会切掉中间的关键实体。我一般先按标点分句如果句子长度超过窗口再用重叠滑窗切分窗口长度256或512相邻窗口重叠50个token左右让实体不容易被切在窗口边缘。训练阶段还会按“是否包含实体”做采样加权包含实体的句子多采避免大量纯背景句压过实体信息。截断和标签对齐的代码是整套源码里最容易出错的环节尤其要处理好CLS、SEP和定长paddingfrom transformers import BertTokenizer tok BertTokenizer.from_pretrained(bert-base-chinese) def encode_train_example(text: str, bio_tags: list[str], max_len: int 256): 对齐tokenizer与BIO标签padding部分用-100让损失函数忽略。 enc tok( list(text), is_split_into_wordsTrue, truncationTrue, paddingmax_length, max_lengthmax_len, return_tensorspt, ) # 首尾留出CLS和SEP位置padding位置填-100 labels [-100] [label2id[t] for t in bio_tags][: max_len - 2] [-100] labels [-100] * (max_len - len(labels)) return enc, labels这里的两个关键参数需要说明。is_split_into_wordsTrue表示按字列表输入让中文不会先被分词再拼接减少标签错位label2id是前面定义好的标签映射表。首尾补的-100对应PyTorch CrossEntropyLoss的默认ignore_index模型在CLS、SEP和padding位置不计算损失既避免了标签噪声也省掉了手动构造mask矩阵的步骤。3.3 softmax解码还是CRF先打通再约束BERT输出每个token的隐藏向量后最常见的解码头有两种。第一种是Linear加softmax每个token独立预测速度快但对标签间的转移约束弱容易产出“B-DIS后直接接O再接I-DIS”这类非法路径。第二种是Linear加CRFCRF在解码时学习相邻标签的转移概率用维特比求全局最优序列可以让B后只能跟同类I或OI不能凭空出现整体预测更稳定。电子病历的医疗实体大多由多个字符连续组成CRF的约束收益比通用领域更明显。但CRF也不是没有代价训练时多维护一个转移矩阵实现复杂度高预测时慢一点最重要的是标注噪声大时转移矩阵容易被错误样本带偏陷入局部震荡。我的习惯是第一版先用softmax把流程跑通拿到基线F1后再换CRF这样一旦效果下降能判断是数据问题还是解码头问题而不是两个因素混在一起查不出来。3.4 最小可运行训练代码参数含义与踩坑口一套能跑通的最小训练循环其实很浓缩核心就是BERT模型加一个优化器import torch from transformers import BertForTokenClassification, AdamW model BertForTokenClassification.from_pretrained(bert-base-chinese, num_labelslen(label2id)) opt AdamW(model.parameters(), lr3e-5) # BERT微调常见范围 2e-5 ~ 5e-5 for texts, batches in dataloader: enc, labels batches out model( input_idsenc[input_ids], attention_maskenc[attention_mask], labelslabels, ) loss out.loss # num_labels下自动使用交叉熵-100位置被忽略 loss.backward() opt.step() opt.zero_grad()这段代码里没有token_type_ids因为单句任务不需要它。最容易被忽略的是num_labels它必须与前面label2id的真实类别数一致很多人新增实体类别后忘了改这里的值训练日志一切正常预测时概率矩阵始终少一列实体会被静默丢掉。学习率方面BERT微调不像从头训练lr在2e-5量级比较稳再往上容易把预训练学到的语言知识洗掉需要更大batch时用梯度累积warmup比例保持0.1左右即可。初期不建议开混合精度虽然省显存但出问题时排错路径更长先把流程跑干净再优化。4. 训练排查电子病历实体识别的5个高频翻车点4.1 长文本截断把实体一刀切滑窗overlap要怎么设现象验证时大量长诊断名只有前半段或后半段比如“冠状动脉粥样硬化性心脏病”预测成“冠状动脉粥样”。原因往往是训练和推理时直接按max_len截断实体正好落在窗口边界。解决方法是训练前先统计实体长度分布推理阶段用重叠滑窗切分前后各保留一段合并预测结果时同一个实体跨窗口出现保留长度更长的那一侧。实体超过窗口长度属于极端情况直接放弃也比预测出残缺实体好医学场景漏检比错检更容易被接受。4.2 类别严重不均衡少数类实体直接零预测现象训练样例里症状、诊断类占比70%手术、用药类占比5%结果手术类实体一个都预测不出来。原因是电子病历文本里药名、手术名本身出现的频次低标注样本天然长尾。解决方法是先按实体实例频率做样本过采样一个句子包含手术实体就多复制几份进入训练集再用label smoothing把预测分布拉平一点减少少数类被多数类完全压倒。注意不要只调损失函数权重NER训练里序列标签多简单加权容易让模型在O标签上过度自信。4.3 标注噪声和重叠实体有些错模型永远学不会现象同一术语在不同病历里边界一会儿多一个字一会儿少一个字模型学到的边界不稳定验证集F1忽高忽低。原因多数是标注规范没定死比如“左前降支”有的标成“前降支”有的把“左”一起带上。解决方法是把这类高频术语整理成统一表预标注后再让标注员确认边界重叠span直接过滤只保留最长实体。数据问题优先级高于模型问题标注规范补丁比换更大的模型立竿见影。4.4 B-后直接跟O解码约束缺失导致实体残缺现象softmax解码时出现“B-DIS、O、I-DIS”的诡异碎片或者B后面没有实体内容就直接结束。原因是每个token独立预测没有全局转移约束。解决方式有两种一是切换CRF从根本上约束标签转移二是在解码阶段加过滤规则只有当前实体类型延续时才接受I标签def decode_entities(pred_ids, text): 按标签id输出实体严格控制B开头、I延续、O闭合。 tags [id2label[i] for i in pred_ids] entities [] cur_type, cur_start None, -1 for pos, tag in enumerate(tags [O]): if tag.startswith(B-) and cur_type is None: cur_type, cur_start tag[2:], pos elif tag.startswith(I-) and cur_type and tag[2:] cur_type: continue elif cur_type is not None: entities.append({ entity: text[cur_start:pos], type: cur_type, start: cur_start, end: pos, }) cur_type None return entities这段代码的核心是末尾加了一个“O”哨兵保证最后一个实体也能正常闭合。B开头后如果没有同类I实体长度就只有起点一个字符宁可这样也不要把不同实体的碎片拼到一起。4.5 中文BERT的分字特性药品名和计量单位被拆散现象药物名“头孢克肟”有时被完整识别有时被拆成“头孢”和“克肟”两个碎块后面跟的“25mg”也识别不完整。原因是中文BERT按字切分对“词”级实体不敏感电子病历里又常混英文、数字、单位WordPiece把“25”“mg”拆开模型很难学到完整边界。轻量级做法是注入词典匹配结果把字典命中的词串先锁定为强边界模型注意力不拆更重的方法是加词边界特征或做领域增量预训练。我一般先统计模型错切的高频词数量不多时直接用外部词典做后处理修正投入产出比最高。5. 验证与部署实体级F1与onnx导出别拿loss挑模型5.1 实体级span-F1按start和end严格匹配训练过程中很多人只看loss下降就以为模型在变好但NER任务的字符级准确率有很强的欺骗性。一条“冠心病”被预测成“冠心”字符级准确率可能接近满分但医疗场景里实体边界错了就是错了。应该用实体级别的严格匹配F1来判断预测实体的start、end、type与真实实体三者完全一致才算命中。一版简洁的评估函数如下def compute_span_f1(pred: list[set], gold: list[set]) - tuple[float, float, float]: pred/gold为样本级实体set元素格式为 (start, end, type)。 exact sum(len(p g) for p, g in zip(pred, gold)) precision exact / max(sum(len(s) for s in pred), 1) recall exact / max(sum(len(s) for s in gold), 1) f1 2 * precision * recall / max(precision recall, 1e-9) return f1, precision, recall计算时要先去掉CLS、SEP和padding位用attention_mask过滤后再映射回原始字符位置否则所有实体坐标都会整体偏移。上线后我还习惯多算一版“部分匹配”即类型正确但边界略偏的情况用来判断模型到底缺的是边界精修还是整体漏检如果部分匹配很高、严格匹配低说明模型找对了位置但边界不稳优先优化解码约束而不是换模型。5.2 把BERT模型部署成服务动态batch与导出线下验证通过后模型要落地成接口。常见做法是FastAPI包一层模型但线上不能每个请求都走一遍全量Python推理至少要做动态batch和推理缓存。部署格式上PyTorch直接加载最方便但大批量时显存占用高ONNX Runtime有编译优化CPU和GPU都能跑适合固定服务TorchScript适合单机常驻但导出时对动态图容易报错。几类方案的对比如下部署方式优势注意点PyTorch直接加载代码少、迭代快显存占用高缺少编译优化ONNX RuntimeCPU/GPU推理稳定性能好tokenizer和前后处理仍在外部TorchScript单机常驻部署方便动态结构导出偶尔报错FastAPI服务团队协作简单易扩展要自己处理并发、batch和超时如果用ONNX导出要把input_ids和attention_mask都设置成动态维度否则输入长度被固定死调用方要把所有文本padding到统一长度浪费大量计算。导出前记得跑一遍同一输入在PyTorch和ONNX下的输出一致性差一点都不能上线。部署时把长文本切窗、重叠窗口合并放在预处理逻辑里预测结果再按原始坐标拼回去这部分最容易漏掉的是tokenizer的offset_mapping。5.3 输入输出边界mask过滤、原始位置映射与病历脱敏电子病历数据特殊部署前要明确输入输出边界。输入文本先做脱敏把姓名、身份证号、手机号、住院号替换成占位符再进入实体识别避免模型把标识信息当医学实体输出。接口要限制单次输入长度超长文本拒绝处理或自动分片防止外部请求把服务算爆。同时给每个实体返回token级概率和字符级位置方便前端展示或人工复核。医疗场景里黑匣子很难被信任能回溯到原文的实体标注才有说服力。上线前准备十几条真实病历走一遍回归用例覆盖日期、时间、药量单位、检验结果等容易误判的样本。像“19-07-14”这种日期如果不提前在标注阶段排除模型会把所有数字组合都识别成时间实体训练时污染标签部署后也会不停误报。回归用例通过后再逐步放量不要第一天就切全量流量。6. 进阶技巧半监督回流与领域增量预训练最后说两个我最近常用的技巧收益比调参明显。一是用存量未标注病历做领域增量预训练。BERT在通用中文语料学到的语言先验和电子病历写作方式差距不小病程记录里的缩写、单位、药物名在通用语料里很少见。把脱敏后的病历按句拆分用MaskedLM任务继续训练不需要任何标签让模型先熟悉医学文本的词汇分布再回来做NER微调。病历量足够大时实体F1通常能再涨一到两个点数据量只有几千份时收益不明显这时候不值得花算力硬上。二是半监督回流。先拿现有标注数据训练第一版模型对无标注病历用高置信度阈值预测伪标签加进训练集再训第二版。这里的关键词是“高置信度”我试过阈值降到0.7结果错误预测被自我强化越训越偏白白浪费一周复核时间。成功后正确做法是只保留同一句话在两次预测里完全一致的结果边界都相同才当新标注。这个手段本质上是把模型的可信预测变成人工标注的补充而不是替代人工。我自己现在的习惯是先做五十条人工标注跑一轮基线估算清准确率和召回率缺口再决定要不要投入增量预训练和半监督。第一版半监督带进假阳性的教训让我记住一个原则置信度阈值宁可设高伪标签宁缺毋滥。这套基于BERT的电子病历命名实体识别设计源码核心不是模型本身而是把数据标准、编码器选型、解码约束和验证闭环串起来每一步都能小步回归先跑通再优化。这个方向值得投入希望帮到你。本文还有配套的精品资源点击获取