FEATURED · 精选文章

CCKS2019电子病历命名实体识别实战:从数据集到BiLSTM+CRF模型

发布时间 / 2026/8/27 6:22:35
来源 / 创域科博编辑部
栏目 / 资讯中心
CCKS2019电子病历命名实体识别实战:从数据集到BiLSTM+CRF模型 简介电子病历中的信息抽取是医疗NLP领域的核心难题其文本缺乏规范标点、语法松散常混有缩写与口语化表达给传统规则系统带来极大挑战。命名实体识别NER作为从非结构化文本中抽取身体部位、症状、疾病、检查、治疗等关键实体的基础技术是构建医疗知识图谱与智能辅助诊断系统的关键环节。通过识别这些实体可支撑病历结构化、临床科研数据挖掘及辅助诊疗等应用场景。CCKS2019评测任务依托医渡云开放的真实脱敏电子病历数据集提供了标准化的实体标注与评测基准成为研究者验证模型性能、探索医疗文本处理方法的理想平台。本文基于该数据集系统介绍了数据格式、实体定义、任务难点并给出了从数据加载到BiLSTMCRF模型搭建的完整实践流程为医疗NLP工程落地提供参考。1. CCKS2019这个评测任务到底考什么1.1 评测背景电子病历里的信息抽取困局做过医疗NLP的朋友应该都清楚电子病历这东西和普通文本完全不是一个量级。它没有规范的标点没有严谨的语法一句话里常常塞满了缩写、错别字和口语化表达比如患者3天前无明显诱因出现胸痛伴大汗持续约10分钟含服硝酸甘油后缓解。这句话正常人扫一眼就懂但让机器把胸痛大汗这类症状实体、以及硝酸甘油这类治疗实体抽出来对传统规则系统来说极其痛苦。CCKS2019全国知识图谱与语义计算大会在当年设置了一个专门的评测任务目的就是把中文电子病历的命名实体识别NERNamed Entity Recognition拉到同一个起跑线上公平比拼。医渡云作为承办方之一把一套经过脱敏处理的4k份真实电子病历数据集开放出来用来训练和验证各家模型的抽取效果。这个任务在当年属于国内医疗文本信息抽取领域少有的公开基准之一很多做医疗AI的团队都拿它当练兵场。我当年拿到这个数据集时第一反应是终于有一个正经的医疗NER公开集可以拿来做实验了。因为在这之前想做中文医疗实体识别基本只能靠自建数据或者去啃英文的i2b2、MIMIC数据集中文资源太稀缺。这届CCKS任务把数据集命名为ccksyidu4k-nerzip包里就是那份4k资源。这个4k指的是数据集中病历文档的规模大约是4000份在医疗文本NLP里算是一个中等偏小的量级做深度学习模型完全够用做预训练大模型微调也勉强撑得起。1.2 医渡云把数据开放出来的价值医渡云在当时已经做了不少医疗数据治理和AI辅助诊疗的产品手上积累了大量真实病历文本资源。CCKS2019上他们愿意把这批脱敏数据放出来对学术界来说是件大事。因为医疗数据向来敏感涉及患者隐私和合规问题很少有机构愿意公开真实病历哪怕是脱敏后的。这份数据集让普通研究者也能接触到接近真实临床语料的文本不用再拿百度百科词条假装病历训练模型。数据集的实体类别设计也很有讲究覆盖了临床文本里最核心的五个维度身体部位、症状描述、疾病诊断、检查项目、治疗方式。这五种实体几乎是所有医疗信息抽取任务的基石无论是做辅助诊断、病历结构化还是做临床科研数据挖掘第一步都是先把这些实体从原始文本里捞出来。换句话说你现在拿GPU跑一次模型、调一个超参数觉得没什么大不了——但在当时这套数据集直接拉低了医疗NLP研究的门槛让校园里的实验室也能复现医疗实体抽取的全流程。2. ccksyidu4k-ner数据集长什么样2.1 数据规模与构成拆解先把数据集的物理形态说清楚。解压ccksyidu4k-ner.zip之后里面不是单个大文件而是按train和test拆分的文件夹结构常见的组织方式是ccksyidu4k-ner/ ├── train/ │ ├── 1.txt │ ├── 2.txt │ └── ... ├── test/ │ ├── 1.txt │ └── ... └── readme.txt每个编号文件对应一份脱敏病历的部分片段不是完整的出院小结而是从病历正文里抽出来的若干文本段落。每份病历内容长度不等多的几百字少的只有几十字。整体加起来训练集大约有5万句左右实体标注总数在10万以上具体数目不同版本有所差异。这里要提醒一句这个数据集虽然叫电子病历数据集但它不是完整的病案首页也不是结构化的电子病历文档而是一个经过标注的NER任务专用数据集。它保留了病历的原始叙述风格——没有标点或半标点非常多数字和单位混在一起偶尔还夹杂无未见异常这类否定词这些都是后续建模时要优先处理的问题。2.2 五类实体怎么定义理解实体类型是第一步。CCKS2019这套数据集把实体统一定义为以下五个类别实体类型含义示例身体部位人体解剖学位置/器官肺、左侧肢体、冠状动脉症状患者主观不适或客观体征胸痛、恶心、双下肢水肿疾病临床诊断或疑似诊断冠心病、肺气肿、胃癌检查影像学/实验室/器械检查心电图、CT、血常规治疗药物、手术、介入等治疗方案硝酸甘油、支架植入术、阿司匹林实体边界统一采用BIEO标注体系。B表示实体首字I表示实体中间和结尾字E表示实体末尾字O表示实体外。五个实体类型配上三种位置标记一共构成15种实体标签再加上O总共16种标签。顺便说一下市面上不少老数据集偏爱BMES或BIO标注但CCKS这套用的是BIEO。用BIEO的好处是每个实体的尾部有明确的E标记解码阶段可以直接从E符号切出实体边界对后续处理比较友好。但坏处是如果模型不收敛E标签经常预测错位需要后处理时把B和I、E之间的不一致规则修掉。2.3 标注文件里的原始格式数据集里的原始文件格式非常简单基本就是一行字加上一行标签字和标签用空格或者制表符分开。举个例子冠 状 动 脉 粥 样 硬 化 性 心 脏 病 B-Disease I-Disease I-Disease I-Disease I-Disease I-Disease I-Disease I-Disease I-Disease E-Disease不过也有版本是按字 标签一列排列的每字一行空行表示句子分隔。因为CCKS官方在不同渠道放出的包格式略有差异所以拿到数据后我建议先花十分钟统计一下文件的列数和标签集合别上来就盲写Dataloader。数据格式虽简单但真实病历文本的噪声非常多。比如心电图示窦性心律ST-T改变这里的ST-T改变到底该标为检查结果还是症状不同标注者的口径可能不一致。还有糖尿病史10年糖尿病是标疾病还是标症状这类灰色地带在数据集中反复出现直接影响最后的上限。3. 实体识别任务的难点在哪里3.1 中文病历和普通文本完全不一样很多人习惯用新闻或百科语料训练NER的经验直接套在医疗文本上效果往往打骨折。原因在于中文电子病历有极强的口语杂糅医学术语混合特征。普通新闻文本的句子结构相对完整有主语有谓语标点符号规范实体边界也比较清晰。但病历文本大量使用省略句、祈使句和电报式表达比如神清精神可饮食睡眠欠佳一句话省掉了主语动词也只保留最核心的部分。这种句式下实体边界不是靠语法结构就能划出来得靠大量的领域知识。再拿右下肺可见片状高密度影举例这里右下肺是身体部位片状高密度影其实可以算作影像学检查发现但严格按数据集的标签体系来看它既不完全是检查、也不完全是症状更像一个影像学描述性发现。这种边界模糊的实体是人也会标错更别说模型了。3.2 数据集的先天缺陷与标注噪音ccksyidu4k-ner作为一个真实临床数据开放出来的标注集天然带有标注一致性的问题。我后来在做交叉验证时发现同一份病历如果让两个标注员独立标注kappa系数只能到0.75到0.80左右也就是说有接近两成的实体标注不完全一致。这些不一致主要体现在三类情况实体边界漂移比如2型糖尿病有的人标成完整实体有的人只标糖尿病。类别判断冲突比如发热在症状和疾病之间摇摆血常规在检查和身体部位之间摇摆。嵌套实体不处理比如冠状动脉粥样硬化性心脏病里既包含疾病也包含身体部位冠状动脉但数据集规定实体不嵌套标注时只能取外层。做研究时这些标注噪音会直接压低下游模型的F1上限。所以在建模前应该先统计一下数据里实体长度的分布、各类型实体数量分布搞清楚数据集到底有多少可用信息量再决定权重和损失函数怎么调。我建议入场先画一张标签分布直方图如果某个实体类型只占5%以下那基本可以断定这个类别的F1会很难看。4. 从零搭建一套识别流程完整实操4.1 数据加载与格式转换第一步是把文本和标签转成模型能吃的张量。不管用什么框架我习惯先把原始数据读成统一的样例结构每个样例是一个字符序列加上对应的标签序列。这里给一份参考用的转换代码用纯Python实现很容易替换成自己的路径def load_ccks_data(file_path): 读取CCKS2019医渡云数据集的单文件。 格式每行包含一个字符和对应标签用空格/制表符分隔 空行表示句子结束。 sentences [] labels [] chars [] tags [] with open(file_path, r, encodingutf-8) as f: for line in f: line line.strip() if line : if chars: sentences.append(chars) labels.append(tags) chars [] tags [] continue parts line.split() if len(parts) ! 2: # 某些版本的标注文件可能是“字 标签”两列 # 但也见过只含标签的文件这里做个容错 continue char, tag parts chars.append(char) tags.append(tag) if chars: sentences.append(chars) labels.append(tags) return sentences, labels有几个小坑值得提前说明某些版本的文件里标签可能用的是UTF-8 BOM编码第一行会带上不可见字符建议读入后做一次strip处理。有的文件里标签不是BIEO格式而是BIO格式或者带O和BODY之类的自定义缩写需要先做一次标签映射归一化。中文数字和阿拉伯数字在数据集中混用比如三和3都表示数字但模型词表里这是两个token影响不大不过不利于后续实体归一化。4.2 文本切分与标签对齐这一步的关键在于不能按普通文本的句子切分策略直接切病历。病历里经常有很长的段落一个段落超过512个字符非常常见。如果用BERT类模型输入长度限制在512以内直接截断会把多段实体切断。我推荐按标点符号做启发式切句优先在句号、分号、问号处切开避免切断实体。但有一个例外情况要留意1. 患者于3年前无明显诱因出现...这种带序号开头的段落序号1.如果被切到上一句末尾会污染上一句的实体。我实际操作时会把数字序号开头的部分单独剥离开或者在切分时把数字.作为一种停靠点。切完后还要做标签对齐检查。如果你对文本做了任何修改比如去除空格、统一全角半角、修复错别字都必须同步修改标签序列否则模型训练时字和标签错位等于喂了脏数据。我建议在训练循环里加一个断言确保每个字符序列的长度和标签序列长度完全一致assert len(chars) len(tags), f长度不一致: {len(chars)} vs {len(tags)}, 内容: {.join(chars)}这种断言在训练初期能省下大量排查时间。4.3 搭建BiLSTMCRF基线针对医疗NER任务BiLSTMCRF始终是性价比最高的基线模型。它不需要预训练权重也能跑出还不错的成绩而且可以做消融实验的参照物。模型结构非常简单输入层把每个字符映射为id再利用word2vec或GloVe等预训练词向量初始化在中文医疗场景里用字向量效果通常比词向量好因为分词误差会被传导到下游。BiLSTM编码层双向LSTM对字符序列进行上下文编码输出的隐藏状态作为每个位置的特征表示。CRF解码层把BiLSTM输出的特征送入线性链CRF学习标签之间的转移概率。CRF层是整个系统的关键。它学了实体标签必须按B到I到E的顺序出现这类约束输出序列不再出现离谱的标签跳跃。举个直观的例子没有CRF模型可能预测出B-Disease I-Body这种连续错标有CRF这种非法转移会被惩罚掉序列整体合理很多。下面给一段简化但不失完整的PyTorch实现思路方便大家照着搭import torch import torch.nn as nn from torchcrf import CRF class BiLSTMCRF(nn.Module): def __init__(self, vocab_size, tag_size, embedding_dim100, hidden_dim256, num_layers1): super().__init__() self.embedding nn.Embedding(vocab_size, embedding_dim, padding_idx0) self.lstm nn.LSTM( embedding_dim, hidden_dim // 2, num_layersnum_layers, batch_firstTrue, bidirectionalTrue, ) self.fc nn.Linear(hidden_dim, tag_size) self.crf CRF(tag_size, batch_firstTrue) def forward(self, x, mask, tagsNone): emb self.embedding(x) out, _ self.lstm(emb) logits self.fc(out) if tags is not None: loss -self.crf(logits, tags, maskmask, reductionmean) return loss return self.crf.decode(logits, maskmask)注意这里的CRF层用的是torchcrf库它提供了现成的转移矩阵学习和维特比解码。如果不想引入额外依赖自己实现CRF也不难只是前向计算式里的配分函数写起来烦人一点新手不建议手搓。4.4 训练细节与评估指标训练医疗NER模型有几个超参数和技巧是我反复验证过有效的首先是优化器。AdamW比Adam更稳在BERT类模型上几乎成为标配对BiLSTMCRF这种轻量模型用普通Adam配合学习率衰减也够了初始学习率设在1e-3左右训练轮次20到30轮。每轮结束后在验证集上计算F1保存最佳模型权重。其次是词表构建。不要直接用全量字符建词表而应该统计训练集字符频率后截断低频字符。我一般保留出现次数大于等于2的字符低频字符统一映射为UNK。如果不做这一步测试集里的生僻字会让模型陷入恐慌效果波动明显。第三是类别不平衡问题。在ccksyidu4k-ner数据集里症状和疾病两类实体的数量通常远大于检查和治疗类这会导致少数类被多数类压制。我的处理方式是在损失函数中叠加类别权重或者使用Focal Loss对难样本增加注意力。但注意如果用了CRF层CRF的转移矩阵本身就带有一定正则效果类别权重加得太大反而会破坏转移约束需要仔细调。评估指标上CCKS官方用的是微观F1micro-F1即把所有类别的TP、FP、FN加总后计算精确率和召回率再算F1。这个指标对大类别友好对小类别并不友好。官方还要求实体级别entity-level的匹配也就是一个实体的每个字都预测正确才算对任何一个字错位都算整体错误。这一点在实际代码里要用实体级别的评价函数不能直接用序列标签级别的accuracy。实体级评估的参考实现思路是解码出预测标签序列后按BIEO规则还原实体列表再和真实实体列表做集合比对def extract_entities(chars, tags): entities [] entity_start None entity_type None buffer [] for i, tag in enumerate(tags): if tag.startswith(B-): if entity_start is not None: entities.append((entity_type, .join(buffer))) entity_start i entity_type tag.split(-)[1] buffer [chars[i]] elif tag.startswith(I-) and entity_start is not None: buffer.append(chars[i]) elif tag.startswith(E-) and entity_start is not None: buffer.append(chars[i]) entities.append((entity_type, .join(buffer))) entity_start None buffer [] else: if entity_start is not None: entities.append((entity_type, .join(buffer))) entity_start None buffer [] return entities这类函数虽然代码不多但在整个评估流程里至关重要。我见过不少人在submit时用token-level accuracy交差最后分数和自己跑的对不上就是这里出的问题。5. 常见问题与排查技巧实录5.1 长文本和GPU显存不足医疗病历文本极少有一句话能控制在64个字符以内的很多句子动辄两三百字。如果用BERT类模型训练BatchSize稍微调大一点显存就直接爆掉。我的经验是分三层下手第一层数据层面做切分。把超过最大长度的句子拆成多个子句然后维护一个子句到原始句的映射表。第二层模型层面用梯度累积。BatchSize设4梯度累积步数设8等效于一个batch size为32的更新显存占用却小得多。第三层硬件层面用混合精度训练。fp16在医疗文本任务上效果几乎无损但显存占用直接减半。如果你显存仍然不够还有一个极端做法对输入长度再做一次截断比如只保留前256个字符。但这样会损失尾部信息具体取舍要看实体在文中出现的分布。我统计过这个数据集实体在句子前端的出现概率明显高于后端所以如果非要截断截尾比截头损失小。5.2 实体边界预测忽长忽短模型跑出来最常见的现象是实体边界错位前缀对、后缀错或者多预测一个字符、少预测一个字符。这在CRF框架下也很常见因为转移矩阵只约束了标签顺序没有约束实体长度分布。一种有效的缓解手段是在损失函数中加入长度惩罚项或者直接在后处理阶段做一个边界修正规则。比如统计训练集里每个实体类型的平均长度测试时对模型预测的实体长度做一次高斯滤波剔除掉那些长度远远偏离分布的实体。更实用的做法是对模型输出做一次字典回溯。准备一份医疗实体词典比如医渡云测试集里经常出现的实体词表如果模型预测的实体在词典里存在就保留如果不在就尝试在原文里用词典做最大匹配修正边界。这个规则在实体识别评测里几乎能稳定提升0.3到0.8个百分点。后处理本身不复杂关键是别把规则写得太硬否则会误伤真阳性。我通常只在预测实体置信度低于某个阈值时应用词典修正置信度高的预测完全信任模型。5.3 标签集合不一致导致崩溃CCKS2019的原始标注文件流传版本很多有的版本把标签写成中文比如身体部位症状和体征有的版本又用英文缩写比如BodySymptom。如果你下载的zip包和别人博客里说的标签名称对不上一定要先做标签映射。建议在代码里维护一个映射字典TAG_MAP { Body: Body, 身体部位: Body, Symptom: Symptom, 症状和体征: Symptom, Disease: Disease, 疾病: Disease, Check: Check, 检查: Check, Treat: Treat, 治疗: Treat, }训练前把全部标签枚举一遍确保没有未映射标签出现在训练集里。这个步骤听着基础但真能卡住不少人。5.4 预训练模型选哪个如果你准备用预训练模型替代BiLSTM来做主体编码器中文医疗NER领域有几种选择直接用BERT-Base-Chinese这是通用预训练模型效果不错但缺乏领域知识用RoBERTa-Chinese或MacBERT通用中文能力更强或者用医疗专用预训练模型比如基于中文医学语料继续训练的MED-BERT。实际用下来在这套数据集上BERT-Base-Chinese已经能跑到85%以上的实体级F1再换MedBERT类模型大概能再提升1到2个百分点。但边际收益有限如果你的目标是刷榜这一步值得做如果只是验证流程上线用通用BERT就够。另外要提醒一个问题预训练模型都自带Tokenizer切分中文时按字切分这和数据集里的标注方式是一致的。你不需要也不应该对病历文本先分词再送进BERT直接按字输入即可。分词器反而可能引入额外的误差。6. 用这个数据集还能做什么除了标准NER任务ccksyidu4k-ner这套数据集还有不少延展玩法适合做进一步研究或工程落地验证。第一事件抽取。病历里的实体本身不是孤立存在的它们之间有语义关系比如症状对应的发生部位、检查提示的疾病、治疗针对的症状。你可以在这套数据集之上用规则或模型做实体关系抽取把结构化电子病历中的主诉-体检-诊断-治疗链还原出来。第二端到端的病历结构化。电子病历结构化的核心目标是输出一份半结构化的临床摘要包括患者基本信息、主诉、现病史、诊断结论和治疗方案等部分。这套NER数据集虽然只提供实体标注但结合规则和解析树可以做一个基础版的病历结构化pipeline。第三模型鲁棒性测试。因为这套数据本身就是真实临床文本带有大量噪声很适合用来测试不同预训练模型在真实场景下的鲁棒性。我自己把同样的训练代码跑在BERT、RoBERTa、MacBERT、以及各种MedicalBERT上对比它们的实体级F1、训练收敛速度和OOD表现这套基准完全够用。第四半监督和主动学习实验。4k份病历的标注量不算大而且标注质量参差不齐。用它做半监督NER的起点数据结合大规模无标注病历做自训练是一个很自然的实验设计。主动学习方向也可以做比如用模型的不确定性采样最有价值的病历进行补充标注在小样本条件下提升性能。在我实际做过的对比实验里使用这个数据集训练的实体识别模型对同源但不同批次的病历文本仍有不错的泛化效果但换一个医院、换一种病历书写风格之后F1会掉到60%以下。这个现象说明医疗文本的数据分布差异极大跨机构泛化是下一步真正要攻克的难题。最后再分享一个小技巧如果你打算把这套数据集用于论文实验建议同时保留数据加载的完整代码和版本记录。组内复现、跨组比较的时候经常因为标签映射、切分逻辑、评估粒度的细微差异导致结论对不上。把数据版本和代码版本写清楚能省掉大量扯皮时间。本文还有配套的精品资源点击获取
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻