尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

CCKS2019医渡云4k电子病历NER实战:从数据到BERT-BiLSTM-CRF全流程

CCKS2019医渡云4k电子病历NER实战:从数据到BERT-BiLSTM-CRF全流程 简介命名实体识别是中文自然语言处理中的基础任务旨在从非结构化文本中抽取具有特定意义的实体。在医疗领域电子病历中蕴含着大量症状、疾病、检查、药物等关键信息精准识别这些实体是构建医学知识图谱、辅助临床决策的重要前提。传统基于词向量与BiLSTM-CRF的方案受限于分词误差和未登录词问题而基于预训练模型的BERT-BiLSTM-CRF结构通过字级语义表示与条件随机场全局约束能显著提升实体边界的识别效果。该技术广泛应用于医疗信息化、智能质控与科研数据提取等场景。本文以CCKS2019医渡云4k电子病历命名实体识别任务为例系统拆解数据标注体系、模型选型、训练细节与踩坑经验为中文医学NER工程落地提供可复用的实践参考。 如果你是做中文NLP的尤其做过医疗垂直领域的信息抽取那你一定绕不开CCKS2019医渡云4k电子病历命名实体识别这个任务。这个评测当年的定位基本就是中文医学NER的入门必修课几乎所有想进医疗AI赛道的算法团队都用它跑过基线、验证过方案。我就是当时追着这个评测一路踩坑过来的从最原始的BiLSTM-CRF到后来换成BERT系列走了不少弯路也沉淀下不少能直接抄作业的经验。今天这篇文章就把这个任务从数据到建模到细节排查完整过一遍。我会先拆解ccksyidu4k-ner数据集的构成和标注体系再讲我自己在方案选型上的思考过程然后给出可直接复现的实操流程最后把训练时踩过的坑和复盘结论一并放出来。内容面向正在做中文NER的工程师、准备入门医学NLP的研究生以及想在医疗信息化方向落地的团队保证你看完能少走一段时间弯路。1. 任务数据与评测背景深挖1.1 CCKS2019这个评测任务是怎么一回事CCKS是国内知识图谱和语义计算方向的老牌会议每年都会放出几个和工业界结合紧密的评测任务。2019年这个电子病历命名实体识别任务数据由医渡云提供目标很直接让参赛团队从真实的电子病历文本中抽取出临床实体为后续医学知识图谱构建、辅助诊疗、智能质控这类应用打基础。我当时看到这个任务的第一反应是“这不就是个普通NER嘛”但真把数据下载下来之后发现自己想简单了。中文电子病历和新闻语料完全是两个物种它既有医学术语的高度专业性又有口语化、缩写、中英混杂这类噪声。评测任务把数据限定在真实脱敏病历上其实就是在提醒参赛者你要解决的不是一个实验室里的玩具问题而是一个贴近真实生产的临床文本抽取问题。评测标准用的是实体级别的严格匹配也就是说一个实体必须边界预测正确、类型也预测正确才算一个真正预测对。这种严格指标的坏处是你辛苦抽出来的实体只要边界差一个字就白搭对模型精度的要求被放得很大。正是这个设定逼着所有参赛团队把细节抠到极致。1.2 医渡云4k数据集的构成与标注体系先说大家最关心的数据规模。ccksyidu4k-ner这个压缩包里的数据整体对应约4000份电子病历文档。实际使用的时候要注意训练集中人工标注的病历大概只有1000份出头另外3000份左右是未标注的原始病历文本。理解这个结构很重要因为后续做半监督学习、用伪标签扩充训练集都是基于这3000份未标注数据展开的。实体类别方面标注体系基本覆盖了临床文本里的常见语义域包括疾病诊断、症状体征、检查检验、治疗方式、身体部位、药物等几大类细分下来在8种左右。遇到“患者因上腹部疼痛伴恶心呕吐就诊CT提示胰腺炎”这类句子你需要把“上腹部”“疼痛”“恶心呕吐”“CT”“胰腺炎”这些实体逐一识别出来并打上类别标完之后相当于把一段自由文本变成了结构化信息可以直接喂给知识图谱。标注格式沿用了序列标注的经典范式按字级别打标签官方数据以BIO为主。我训练时习惯把BIO转成BIOES再送进模型因为BIOES能更明确地表示实体边界在严格匹配评估下对边界预测有正向帮助。这一步属于小细节但对最终F1有可见的提升。1.3 病历文本的特点它为什么比新闻NER难这么多如果你拿人民日报语料训练出来的NER模型直接跑到这份病历数据上大概率会崩得很惨。电子病历文本有几个非常突出的特点。第一术语表达极其多样。同一个病在不同的历史记录里可能写成“冠心病”“冠状动脉粥样硬化性心脏病”“CHD”还可能写成“冠脉粥样硬化性心脏病”这些变体对模型来说就像多个独立实体一样难搞。第二实体长度差异巨大。短的实体只有两个字比如“咳嗽”长的实体能到十几个字比如“慢性阻塞性肺疾病急性加重期”让模型同时学到长短两种实体的边界规律本身就是一个挑战。第三病历里大量夹杂英文缩写、数值和单位。“T 36.5℃”“WBC 10.2×10^9/L”“CT示右肺上叶结节”如果按普通中文文本处理这些内容很容易被切得支离破碎。第四医疗实体之间的嵌套现象比新闻文本严重得多。比如“右肺上叶结节”它既可以是“身体部位右肺上叶”和“症状/病变结节”的组合也可能作为整体被视为一个病历描述对象。嵌套实体的处理直接决定了你评估时是选择“识别出最外层实体”还是“内部实体也尽量保留”这个取舍我在后面实操部分会专门讲。2. 方案选型为什么最后选了BERT-BiLSTM-CRF2.1 先把任务难点拆明白再动手选模型在做任何技术选型之前我习惯先把任务难点拆成一个清单然后对照清单看哪种方案能覆盖最多问题。这个任务的难点清单大概是这样的标注数据少有标注的病历只有1000份出头直接上大规模模型很容易过拟合。实体类别不均衡部分类别出现频次极低模型很容易把所有实体都往高频类别上猜。实体边界模糊中文没有天然空格分词结果直接影响实体边界的判定而分词和实体边界是耦合的。中英混合与数值单位普通BERT的tokenizer对这类内容处理得不算友好需要额外检查。症状体征类实体数量庞大且表述多样模型需要较强的上下文理解才能判断哪些描述算实体。对着这个清单最稳妥的方案结构其实已经浮出来了用预训练语言模型加强上下文语义理解用序列标注头完成边界预测用CRF层保证标签路径的合法性。这就是后来大家熟知的BERT-BiLSTM-CRF结构。2.2 从BiLSTM-CRF到BERT两代方案对比我最早跑基线用的是经典BiLSTM-CRF配的是通用中文词向量。训练过程耗费大量时间在调词向量和分词边界上因为那时候我们默认要先用分词器把病历切开再把分词结果按BIO标记对齐。问题是中文分词的粒度本身就和医疗实体边界不一致比如“上腹部疼痛”在分词器里可能被切成“上腹部/疼痛”模型学到的是两个词而不是一个“症状或身体部位相关描述”的整体实体。分词错误直接传播到实体识别结果里这是词级方案最大的一个坑。后来切到BERT之后输入直接从字级走绕开了分词这个瓶颈。BERT对每个字输出一个上下文相关的向量表示对“上腹部疼痛”这类连续字符模型可以直接在字级别学出哪些字属于同一个实体。基于同样的原因英文缩写和数字符号也能在字/子词级别得到相对合理的表示不再过度依赖词表覆盖。我总结一下两代方案的实际对比BiLSTM-CRF词向量训练速度快对硬件要求低但对OOV实体和分词歧义较敏感F1上限明显偏低。BERT-BiLSTM-CRF输入用的是字级或子词级表示上下文编码能力显著更强对实体边界识别更准代价是训练显存和耗时都上去了。我当时的最终选择是BERT-BiLSTM-CRF但为了控制过拟合没有直接用12层BERT的全部特征去硬塞而是在BERT输出后接了一层BiLSTM做序列特征再进CRF。这层BiLSTM的隐藏单元数量我设成128既保留了上下文建模能力又让模型规模可控。2.3 要不要加词典特征和分词信息实测收益与代价不少人会在BERT基础上再叠加一些词典特征我曾经也试过。做法很简单引入一份医学词典把词典中出现的词在输入序列上标记出来然后把“是否为词典词”作为一个额外的特征或者注意力偏置加进模型。还有一个变体思路是保留分词信息把词边界用特殊标记告诉模型。这类方法在BiLSTM时代很有效但在BERT时代效果会变得不那么明显因为BERT自己已经能学到大量词汇和边界信息。我在这个任务上的实测体会是对“药物”“检查”这类相对有确定性词形的实体词典特征的收益还在但对“症状”“疾病”这类表达多变的实体词典特征不仅帮不上忙还可能引入噪声。比如词典里收录了“疼痛”但病历里写的是“隐痛”“刺痛”“绞痛”这些变体词典覆盖不到模型在词典特征引导下反而容易把搜索范围缩小。最后的取舍很克制我只在输出层旁边加了一个轻量词边界补充特征而且加了dropout防止过拟合。如果你的机器资源比较紧张不推荐一上来就堆词典特征。先跑一个干净的BERT-CRF基线把结果基准确立下来再考虑要不要加额外特征这是性价比更高的路线。3. 实操全流程从原始数据到训出最终模型3.1 先拆解ccksyidu4k-ner.zip的数据组织拿到ccksyidu4k-ner.zip解压后第一件事不是急着写模型而是先把目录结构和标注文件读懂。通常这类评测包会包含一个训练数据目录和一个测试数据目录训练数据里面有标注好的病历文本可能还附带未标注的原始文本测试数据则是需要你预测实体并输出特定格式结果的文件。评测包还应该有一份说明文档里面会写明实体类别定义、标注规则、提交格式这些信息直接影响你后面写评估脚本所以一定要先通读。我习惯性地统计一下标注文件的实体类别数量分布画一张分布表这样对类别不均衡的程度心里有数。从实际统计看症状体征和身体部位这两类占比明显偏高而很少出现的实体类别可能只有极少量的样本。遇到这种情况如果直接按原始分布训练模型一定会偏向高频类别我在下一章讲具体的处理办法。3.2 把原始标注转成模型能直接吃的序列官方给的数据一般不是直接的“每行一个字一个标签”的形式而是原始句子外加一份实体偏移标注。因此第一步是把实体偏移标注转成字级别的BIO标签序列。这个转换逻辑很简单遍历每个实体把实体覆盖的每个字标成B-类别或I-类别实体外的字标成O。如果使用BIOES则把实体首字标成B尾字标成E中间字标成I单字实体标成S。转换的时候有个陷阱实体可能出现重叠嵌套或者同一个偏移范围对应多个实体。如果你把两个实体的标签都叠加到同一段字符上就会产生冲突。我当时的处理策略是只保留最外层标注意义或在设计标签方案时给嵌套实体制成额外的起始标记但后者会明显增加模型输出空间的复杂度。对第一次复现来说先保留最外层实体跑通流程再扩展嵌套支持这个思路更能控制复杂度。下面给出一段参考代码读者可以在此基础上改成自己的数据格式。def convert_offsets_to_bio(text, entities): tags [O] * len(text) for ent in entities: start ent[start] end ent[end] label ent[type] if end start: continue if end - start 1: tags[start] S- label else: tags[start] B- label for idx in range(start 1, end): tags[idx] I- label tags[end - 1] E- label return tags如果你把BIOES转换成BIO则移除S和E标记统一把两者归并成B和I即可。转完之后建议打印几条样本人工检查一遍确认偏移没有错位。3.3 BERT输入构造与标签对齐用BERT做中文NER通常直接按字切分但这里仍然存在一个必须处理的细节BERT的tokenizer并不总是把一个汉字切成一个token。遇到全角字符、特殊符号、某些生僻字时tokenizer可能把它拆成子词序列。如果你的标签是按原始字符对齐的而BERT的token是子词级别二者就会错位。这个错位不解决训练loss看起来正常但结果会一塌糊涂。我建议的流程是先调用tokenizer对句子做切分拿到每个token与原始字符的起始和结束位置映射然后对每个token取它对应原始字符的标签作为这个token的标签如果某个token对应多个字符就取第一个字符的标签。显然[CLS]和[SEP]这两个特殊token也要补一个O标签或直接设为-100让它们在计算loss时被忽略。def prepare_bert_input(text, labels): tokenizer_output tokenizer( list(text), is_split_into_wordsTrue, return_offsets_mappingTrue, max_lengthmax_seq_len, truncationTrue, paddingmax_length ) input_ids tokenizer_output[input_ids] word_ids tokenizer_output.word_ids() label_ids [] for word_id in word_ids: if word_id is None: label_ids.append(-100) else: label_ids.append(label2id[labels[word_id]]) return input_ids, label_ids注意这里用了一个关键点把文本拆成字符列表传入tokenizer然后通过word_ids拿到每个token对应的原始字符索引。使用-100作为特殊token的标签是为了让模型在计算交叉熵时忽略这些位置。如果你的代码框架支持ignore_index参数这是一个非常干净的操作。3.4 模型结构BERT编码加BiLSTM加CRF我使用的完整模型结构分四层第一层是BERT编码器输出每个token的768维上下文向量。第二层是BiLSTM输入的是BERT输出前向和后向各128维拼接后得到256维特征。第三层是线性分类层把256维特征映射到标签数量输出每个token属于每个标签的发射分数。第四层是CRF解码层学习标签之间的转移约束在解码时输出全局最优的标签路径。CRF层在这个任务里作用非常明显。比如模型单独预测某个字是I-症状但前一个字的标签是O那么CRF会认为这种转移不合理从而降低这条路径的分数。这种全局约束对实体的连续性和类别连续性都有帮助。CRF的训练目标是最小化负对数似然也就是在所有可能的标签序列路径上最大化真实路径的概率。如果你觉得BERT后接BiLSTM太占显存也可以直接接线性层再进CRF性能差距在部分任务上并不大。我自己的经验是BiLSTM这层在小规模数据上能提供微弱的F1提升但代价是训练速度变慢、显存占用变大。如果机器资源不够先去BiLSTM保留BERT线性层CRF也能拿到不错的基线。3.5 训练策略与超参数训练策略上我一共训了10个epoch左右加载在验证集上F1最高的那个checkpoint做测试。优化器用AdamWBERT部分的学习率设成2e-5BiLSTM和CRF的学习率设成1e-3。这里把下游结构的学习率调高是因为BERT已经预训练过了只需要很小的步长做领域适应而下游结构是随机初始化的需要更大的学习率才能快速收敛。其他关键超参数我直接列成表供参考最大序列长度max_seq_len128。病历句子普遍不长但偶尔有超长的超过部分直接截断。batch_size32如果显存不够可以降到16。学习率BERT层2e-5下游层1e-3。warmup比例0.1。前10%的step线性增加学习率后面再线性衰减。weight_decay0.01。dropout0.1BERT内和下游层都加。训练过程中我发现当前验证F1和后期验证F1并不是严格单调上升的中间会出现震荡。因此不要用最后一个epoch的结果作为最终模型而是每次验证完记录模型参数最终选择验证集F1最高的那一版。这是老生常谈但在小数据集上尤其重要不然很容易因为最后几个epoch的震荡丢掉峰值。3.6 评测脚本严格实体匹配提交到评测系统之前我习惯先在自己切出的验证集上跑一遍严格实体匹配的评分脚本。逻辑是遍历所有预测结果把连续的非O标签聚合成一个实体记录它的类别和起止位置遍历所有真实实体判断预测实体是否与某个真实实体起始位置、结束位置、类别三者完全一致。一致则计为真正例否则计为假正例。最后按标准公式计算精确率、召回率和F1。写这段评测脚本的时候有一个地方非常容易踩坑多个预测实体聚合成同一个实体时BIOES标签序列中间如果夹了一个O你的聚合逻辑必须保证不把它们误并成一个实体。另一个坑是类别名称在代码转换时不统一比如“症状”有时写成Symptom有时写成symptom导致评测结果突然下降。统一标签映射表是评测前必做的一项检查。4. 训练过程踩过的坑与排查实录4.1 标签对齐错误导致整个训练白跑我第一次用BERT跑这个任务训练loss下降得很正常但验证集F1始终在60%左右晃悠怎么调都上不去。排查半天才发现是标签对齐的bug我把句子按字符列表传给tokenizer后tokenizer返回的token数量在某些样本上和原始字符数量不一致但我的标签数组还是按原始字符长度直接硬切于是从某个token开始标签整体错了一位。这个问题最隐蔽的点在于错位之后的序列仍然是一串合法标签loss和梯度都正常模型甚至还能学到一部分规律但准确率上限被死死压住。解决方式就是我前面提到的用word_ids做对齐并在训练前打印几个batch的token和标签映射人工核对。这个小检查花不了5分钟但能避免浪费一整天的训练时间。4.2 实体太长导致CRF解码路径异常电子病历里经常出现过长的实体描述尤其是“疾病诊断相关描述”这种能覆盖十几个字甚至二十几个字。第一次跑的时候我把max_seq_len设成64本意是加速训练结果发现很多长实体被截断了标签序列不完整CRF无法正确学习较长实体的转移约束。后续我把max_seq_len提到128并根据训练集统计了实体长度分布确认绝大多数实体在20个字以内128的窗口完全够用。但提醒一句不是max_seq_len越大越好序列越长显存占用和推理耗时都线性增长。先统计再设参比凭感觉设置要高效得多。4.3 类别不均衡导致低频实体几乎全军覆没最先跑出来的模型在症状体征和身体部位上表现不错但药物和既往史相关实体识别得极差几乎全部被漏掉。原因不复杂这些类别的标注样本太少模型在整体loss中根本感知不到它们的存在。我做了两个调整。第一在计算分类loss时为每个类别设置权重低频类别权重上调高频类别权重下调让模型在优化时更关注低频类别。第二对包含低频实体的句子做复制增强也就是在训练集里把含低频实体的样本多采样几遍相当于变相提高它们的出现次数。两个方法叠加之后F1提升比较明显尤其是低频类别的召回率上来了不少。另外分享一个思路如果你有那3000份未标注病历可以用训练好的模型做自动标注把高置信度的预测结果作为伪标签补充进训练集实现半监督自训练。我在这个任务上尝试过筛选置信度大于0.95的实体做伪标注给最终F1带来了约1到2个点的提升。这个收益在深度学习模型接近饱和时相当可观。4.4 验证集划分不合理导致指标虚高刚开始我图方便把标注病历按句子级别随机切分成训练集和验证集训练过程验证F1直接冲到90%以上心里还挺高兴。后来提交到评测系统发现分数掉了一截才意识到问题出在数据划分上同一份病历里的不同句子高度相似模型在训练集里见过这个病历的一些句子验证集里同病历的新句子很容易被猜中导致验证指标虚高。正确的做法是按病历号进行划分保证同一份病历的所有句子只出现在一个集合里。这样验证集和训练集的文本分布差异更接近真实评测场景。请大家务必在数据划分阶段就想清楚这个问题否则后续所有调参都建立在一个虚高的幻觉上。4.5 BERT过拟合问题的现场处理1000份左右的标注数据对BERT来说并不多训练到第3个epoch之后训练集loss还在下降但验证集F1开始停滞甚至回落这是典型的过拟合信号。我试过几种策略比较有效的是加大dropout、降低BERT层学习率、使用早停、在验证F1连续3个epoch不涨时停止训练。另一个技巧是对输入文本做轻量的数据增强比如随机替换同类别实体词、对部分实体做掩码让模型预测。但这个方向要非常小心替换词病句容易破坏临床语义如果把握不大就不要做宁可让它过拟合然后靠早停兜底。4.6 评测细节不一致导致分数忽高忽低在本地评测和线上提交之间出现分数差异不一定是模型问题很多时候是评测细节不一致。比如本地评测时算的是预测实体与真实实体完全匹配但线上评测可能对部分实体做了等价判定或者本地把单字实体算作S-类型线上统一按B-开头处理细节差异都会导致分数波动。我的做法是严格按照任务官方给的评分脚本复现如果官方不提供就仔细阅读说明文档里的评分口径把“是否去括号”“是否合并连续实体”“是否忽略标点”这些规则逐一确认。反正记住一句话评测脚本的严谨程度直接决定你调参方向的正确性。5. 结果复盘与医学NER的后续演进5.1 最终指标与典型错误分布经过两三轮方案迭代我在这个任务上的最终严格F1稳定在87%附近这在当年使用BERT类模型的选手里算是一个还不错的水平。排在前面的团队大多使用了模型集成和半监督扩充单纯靠单个模型能拿到的上限大致就在这个区间。对错误样本做了归类之后我发现最难解决的几类问题嵌套实体的边界选择模型往往能识别出“右肺上叶结节”但不确定是只需要标注结节还是把整个描述串都标成“症状或病变”这个语义判断换人来做也有分歧。同义表达的实体归一化“高血压病”“高血压”“HTN”本质上是一个实体但NER层面它们永远是三个独立的表面形式评测指标只看表面匹配不看语义等价模型再强也只能靠上下文猜。中英混写和数字缩写结构这类实体内部的长距离依赖和字符跳跃让模型难以稳定学习。5.2 从评测任务到真实医疗场景的落地gap评测任务跑得再高和真实医疗场景之间仍然隔着不小的距离。电子病历数据在医院里往往是非结构化的甚至存在扫描件、OCR识别错误、医生手写体的历史记录。进入真实系统后隐私合规、数据脱敏、实时推理性能都是必须解决的问题。评测任务里不需要考虑这些但真正做过医疗AI项目的人都知道算法只是整个链路里很小的一环。还有一个容易被忽视的gap是术语体系差异。不同医院、不同科室的病历写法不一样在同一组数据上训练完的模型换到另一家医院的病历上精度大概率会掉很多。领域自适应和持续学习才是医疗NER落地的主战场。5.3 后续可以扩展的方向如果你跑完这个任务还想继续深挖我的建议是有四条路可以走。第一条是换用医学领域的预训练模型在BERT基础上继续用医学语料做领域预训练对医疗术语的表征效果通常会更好。第二条是尝试当前热门的span抽取式方法不通过序列标注直接预测实体起止和类型对长实体和嵌套实体有天然优势。第三条是严肃考虑实体归一化把所有抽取到的表面实体通过医学知识图谱映射到标准概念这是从“能跑NER”到“能支撑上层的知识应用”的关键一步。第四条是回到那3000份未标注病历上把半监督学习做到极致毕竟评测任务真正的瓶颈在于标注数据稀少而现实世界里的无标注病历到处都是谁能低成本的利用好它们谁就能在工程上赢得更多优势。我个人实际体会最深的一点是这类医学NER任务最大的收获不是刷高了一个F1而是逼着你认真思考数据分布、标注噪声、评估口径这些和算法同样重要的工程细节。模型方案本身已经很透明了真正的壁垒在于处理数据、设计和执行实验的严谨程度。如果你现在正准备复现这个评测我的建议是先跑通一个最简单的BERT-CRF基线把数据和评估脚本的可靠性验证完再上复杂结构和优化技巧这个顺序能让你少走很多弯路。本文还有配套的精品资源点击获取
返回列表