CCKS2019医渡云4k电子病历NER实战:从数据到BERT-BiLSTM-CRF全流程
2026/8/27 6:22:28 网站建设 项目流程

简介:命名实体识别是中文自然语言处理中的基础任务,旨在从非结构化文本中抽取具有特定意义的实体。在医疗领域,电子病历中蕴含着大量症状、疾病、检查、药物等关键信息,精准识别这些实体是构建医学知识图谱、辅助临床决策的重要前提。传统基于词向量与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-CRF

2.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_words=True, return_offsets_mapping=True, max_length=max_seq_len, truncation=True, padding='max_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做测试。优化器用AdamW,BERT部分的学习率设成2e-5,BiLSTM和CRF的学习率设成1e-3。这里把下游结构的学习率调高,是因为BERT已经预训练过了,只需要很小的步长做领域适应;而下游结构是随机初始化的,需要更大的学习率才能快速收敛。

其他关键超参数我直接列成表,供参考:

  • 最大序列长度max_seq_len:128。病历句子普遍不长,但偶尔有超长的,超过部分直接截断。
  • batch_size:32,如果显存不够可以降到16。
  • 学习率:BERT层2e-5,下游层1e-3。
  • warmup比例:0.1。前10%的step线性增加学习率,后面再线性衰减。
  • weight_decay:0.01。
  • dropout:0.1,BERT内和下游层都加。

训练过程中我发现,当前验证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基线,把数据和评估脚本的可靠性验证完,再上复杂结构和优化技巧,这个顺序能让你少走很多弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询