ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

中文电子病历NER实战:CCKS 2019数据集与BERT-BiLSTM-CRF基线

中文电子病历NER实战:CCKS 2019数据集与BERT-BiLSTM-CRF基线 简介面向中文医学自然语言处理研究者与竞赛学习者这份数据集源自CCKS 2019中文电子病历命名实体识别评测任务包含1379例真实病历样本每份均提供原始文本与实体标注覆盖手术、解剖部位、药物、疾病和诊断、影像检查、实验室检验等五类实体可直接用于训练和评估中文电子病历NER模型解决非结构化病历文本的信息抽取问题。压缩包共8个文件以xlsx标注表、txt训练/未标注语料、json带标注测试集及docx任务说明为主整体仅1.18MB轻量便于下载与复现。借助这些语料研究者可复现官方评测流程对比不同模型在手术、用药、诊断等实体抽取上的表现也能用于构建术语词典或医疗问答系统的底层抽取模块。目前已有1400余人浏览/学习数据来自官方赛题质量可靠适合做学术基准测试、模型调优或入门医学文本挖掘实践。1. 为什么用 CCKS 2019 中文电子病历数据集做 NER 基线测试拿到这份.rar先别急着解压跑模型——CCKS 2019 发布的中文电子病历命名实体识别NER评测数据集名义上只有 1379 例病历样本、五个实体类型但真正让它成为医学 NLP 试金石的是标注细节overlap字段、跨行文本、以及手术与疾病和诊断之间高度嵌套的实体关系。很多团队用通用领域预训练模型直接跑F1 值看起来很高换个科室的病历立刻崩盘原因就在于没有吃透这个数据集的标注边界。它适合两类人一是想快速验证 BERT、RoBERTa、ERNIE 在医学文本上的表现差异二是要给真实临床文本处理系统做领域适配需要一份带标准偏移量start_pos / end_pos的干净标注数据来评估解码策略。接下来我会按看数据 → 转标签 → 建模型 → 踩坑验证的顺序把它拆开。2. 解析病历实体标注的 JSON Schema 与五个标签类型2.1 标注结构为什么start_pos和end_pos是字符级偏移样例里的entities直接给到字符偏移这比常见的 BIO 标注文件更适合复现。originalText是一整段脱敏后的出院记录或病程记录entities是标注列表每个实体包含label_type、overlap、start_pos、end_pos。要注意start_pos和end_pos是左闭右开区间即text[start_pos:end_pos]恰好切出实体原文。以下是该数据集中一个典型样本的字段构成字段类型说明示例值originalTextstring去标识化后的病历原始文本患者3月前因“直肠癌”于在我院于全麻上行直肠癌根治术label_typestring五类实体之一疾病和诊断overlapint是否与其他实体在字符区间上重叠0 或 1start_posint实体开始的字符索引从0计8end_posint实体结束的字符索引开区间11entitieslist当前文本的全部实体标注见代码块示例里rectal cancer被标为疾病和诊断overlap为 0说明该实体在区间上不与任何其他实体交叉。但同一份病历里直肠癌根治术如果被同时标为手术就和直肠癌在字符区间上产生重叠——这就是overlap字段的价值它明确告诉你这份数据集允许嵌套实体存在不是一个纯扁平的序列标注问题。{ originalText: 患者3月前因“直肠癌”于在我院于全麻上行直肠癌根治术DIXON术手术过程顺利术后给予抗感染及营养支持治疗患者恢复好切口愈合良好。术后病理示直肠腺癌中低度分化浸润溃疡型面积3.5*2CM侵达外膜。双端切线另送“近端”、“远端”及环周底部切除面未查见癌。肠壁一站10个、中间组8个淋巴结未查见癌。免疫组化染色示ERCC1弥漫、TS少部分弱、SYN-、CGA-。, entities: [ {label_type: 疾病和诊断, overlap: 0, start_pos: 8, end_pos: 11}, {label_type: 手术, overlap: 1, start_pos: 18, end_pos: 24} ] }这份 JSON 结构没有采用嵌套标注的复杂表示而是用扁平列表加overlap标记来记录交叉关系。我一般会先统计overlap为 1 的实体占比如果超过 5%就不要直接套用 CRF 那套每个 token 一个标签的解码逻辑否则模型天然无法输出重叠实体。2.2 五类实体的边界特征与混淆点CCKS 2019 中文电子病历数据集的五个实体类型并不均衡疾病和诊断和解剖部位数量最多手术次之影像检查和实验室检验最少。这种不均衡会直接放大模型在少数类上的 F1 波动。下面按实际标注中的易混淆程度排序讲解。疾病和诊断是最容易和手术混淆的类型。直肠癌根治术整体是手术但其中嵌套的直肠癌是疾病和诊断胃癌术后里的胃癌是疾病术后不构成实体。标注规则通常以疾病名称本身为准不管它出现在手术名称还是病程描述里。药物类别坑在复合名称上比如奥沙利铂150MG D1——实体是奥沙利铂还是整串用药方案本数据集按最小药物成分名标注剂量和用药频次不进实体。解剖部位与影像检查的区分要留意部位检查连写的情况腹部CT是影像检查腹部单独出现时是解剖部位。实验室检验的边界更细它只覆盖检验项目名词如白细胞计数而白细胞本身解剖部位或成分名则按上下文判别。提示拿到数据先跑一个实体长度分布疾病和诊断往往出现 28 个字的跨度手术则普遍在 412 字。这会影响后面序列标注的标签字典设计和解码时的约束规则。标签不均衡的另一个后果是如果直接按label_type做五分类微调影像检查的召回会显著偏低因为样本里手术记录远多于影像报告。处理办法有两种一是在损失函数里按类别频率加权二是先用一个二分类判断是否是实体再在多分类里判断类型。后一种在医学文本上通常更稳定因为实体区域和背景区域的判别难度远低于五类之间的判别难度。3. 数据集解压后的文件结构与 BIO/BIOES 标签转换3.1subtask1和subtask2的差异从实体识别到关系抽取压缩包中包含的subtask1_training.txt、subtask1_test_set_with_answer.json对应的是第一子任务命名实体识别一个样本一行文本标注以 JSON 形式附加。subtask2_training_part1.xlsx、subtask2_training_part2.xlsx、subtask2_test.xlsx和subtask2_unlabeled.txt则是第二子任务的属性抽取或关系抽取组织方式Excel 里通常包含实体对及其关系类别。如果你只做 NER 基线直接读取 subtask1 的 JSON 即可subtask2_unlabeled.txt是无标注病历适合做领域自适应的无监督预训练或者用弱监督方式生成伪标签。readme-subtask1.txt里说明了实体类型的标注规范但注意它不保证每个文本文件都与测试集 JSON 的originalText完全对应。subtask2 的 Excel 文件里实体偏移量字段可能与 subtask1 不一致需要先按文件名或病历 ID 对齐。我一般会写一个统一的数据加载层把 xlsx 和 txt 统一映射成上面的 JSON 结构再进入下游处理。3.2 从 entities 列表生成字级别的 BIOES 序列不管用什么模型第一步都是把 JSON 标注转成模型可读的标签序列。中文医学 NER 推荐按单字切分不按词切分因为分词错误会直接传导到实体边界上。下面这段代码把一条病历文本和它的 entities 列表转成字级标签def build_bioes_tags(text: str, entities: list): tags [O] * len(text) for ent in entities: s, e ent[start_pos], ent[end_pos] label ent[label_type] if e - s 1: tags[s] S- label else: tags[s] B- label for i in range(s 1, e - 1): tags[i] I- label tags[e - 1] E- label return tags def load_ccks_json(json_path: str): data [] for line in open(json_path, r, encodingutf-8): line line.strip() if not line: continue item json.loads(line) text item[originalText] tags build_bioes_tags(text, item[entities]) data.append({text: text, tags: tags}) return data代码逻辑分两层build_bioes_tags负责把字符区间映射到 BIOES 标签长度等于 1 的实体直接用S-处理避免出现B-后无I-的非法序列load_ccks_json按行读取一个 JSON 列表文件把每条病历转成{text: ..., tags: ...}结构。转换后检查每个样本len(text) len(tags)否则就是偏移越界或文本与标注不对齐。用 BIOES 而不是 BIO 的原因是医学实体特别是手术名称尾部边界信息量大E-标签能帮解码器收敛得更快。经过转换后统计标签分布通常O占比超过 80%实体标签稀少带来严重的类别不平衡问题后面需要做类别权重调整或者对O标签降采样。3.3 训练集验证集划分按病历而不是按句子切很多用户直接把所有样本 shuffle 后按比例切开这会带来严重的数据泄漏同一份病历的多个句子可能被同时分进训练和验证集模型在验证集上见到过同一病人的前后文F1 虚高 24 个百分点。正确做法是按病历维度去重再做划分。subtask1 的originalText通常是一段落一段记录但一个subtask1_training.txt里可能有多条病史记录属于同一个病人。import random from collections import defaultdict patient2items defaultdict(list) for item in loaded_ccks_data: patient2items[item[patient_id]].append(item) patients list(patient2items.keys()) random.shuffle(patients) split_idx int(len(patients) * 0.8) train_patients, valid_patients patients[:split_idx], patients[split_idx:] train_data [d for p in train_patients for d in patient2items[p]] valid_data [d for p in valid_patients for d in patient2items[p]]这里的关键变量是patient_id如果原始 JSON 没有提供就用病历文本的前 20 个字符做哈希近似标识。按病历切分后验证集上的 F1 才是真实泛化能力的度量这也是 CCKS 2019 基线测试的常见坑之一。4. 基于 BERT-BiLSTM-CRF 的中文医学 NER 基线实现4.1 为什么不直接套通用 BERT 做序列标注中文电子病历有大量缩略语和特殊符号ERCC1、SYN、CGA、D2-D6这类药学表达式通用 BERT 的词表里根本没有完整词会被 WordPiece 拆成ER、##CC、##1这样的子词。这对标签对齐是个不小的挑战。推荐的做法是加载一个医学领域预训练模型如果没有就退而求其次使用bert-base-chinese但必须自己处理子词标签对齐。字符偏移与 BERT 子词对齐的映射方式是先对整段文本调用 tokenizer 并开启return_offsets_mapping然后遍历每个 token 的偏移区间带回原来的 BIOES 标签。核心代码如下from transformers import BertTokenizerFast def encode_with_labels(text, tags, tokenizer, max_len128): enc tokenizer( text, paddingmax_length, truncationTrue, max_lengthmax_len, return_offsets_mappingTrue, return_tensorspt ) offset_mapping enc.pop(offset_mapping)[0] label_ids [] label2id {tag: idx for idx, tag in enumerate(sorted(set(tags)))} id2label {idx: tag for tag, idx in label2id.items()} for (start, end) in offset_mapping: if start end 0: label_ids.append(-100) # CLS / SEP / PAD 位置 else: char_idx start if tags[char_idx] in label2id: label_ids.append(label2id[tags[char_idx]]) else: label_ids.append(-100) return enc, torch.tensor(label_ids)这段代码的offset_mapping返回每个 token 在原始文本里的字符区间。中文 BERT 按字切分时每个 token 对应一个字符标签直接取该字符的标签即可遇到ERCC1被拆成多段时我们取起始字符的标签保证标签序列与子词序列一一对应。-100是 PyTorch CrossEntropyLoss 里的默认 ignore index用来屏蔽 CLS、SEP 和 padding 位置。这里一个常见的错误是给子词尾部继承I-标签实际上 CRF 层会把它当成非法转移导致训练崩掉。BiLSTM 层的作用是在字向量之上再建模上下文约束CRF 层则是把B-疾病后面只能跟 I-疾病或 E-疾病这类约束直接注入解码过程。因此结构是BERT 输出字向量 → BiLSTM 编码 → Linear 映射到标签空间 → CRF 解码。在训练时使用负对数似然损失验证时用 Viterbi 解码后转回标签序列。4.2 类别不平衡下的损失函数与超参数设置医学文本里O标签占绝大多数直接训练时模型会倾向把所有 token 判为O。我在训练时会对CE_loss的类别权重做调整统计训练集里每个标签的出现频次把权重设为max_count / (class_count ** 0.5)而不是max_count / class_count。前者更温和不会让低频类被过度加权导致误报暴涨。以下是可复现的参数建议参数推荐值说明max_seq_len256病历文本普遍较长128 会截断过多实体batch_size16显存 12G 以下调成 8learning_rate3e-5BERT 层用 3e-5BiLSTMCRF 用 1e-3warmup_ratio0.1防止训练早期震荡epochs5~8早停耐心值设 2crf_learning_rate1e-3CRF 参数要用独立优化器训练中要每隔 200 步输出一次验证集精确率和召回率不要只盯着 loss。一个很有效的技巧是验证时把 CRF 解码结果和argmax结果做对比如果两者差异超过 3%说明 CRF 学到了一些先验约束模型输出不稳定需要降低学习率继续微调。5. 重叠实体评估与跨病历验证的排错技巧5.1 用overlap字段定位模型的结构性短板我在实际使用中发现模型在overlap0的实体上 F1 轻松到 90% 以上但overlap1的实体如被手术实体包围的疾病和诊断上F1 可能直接跌到 70% 以下。如果只报整体 F1你根本意识不到这个问题。建议按overlap分组分别写出一份指标表from seqeval.metrics import classification_report def report_by_overlap(orig_items, pred_tag_lists, overlap_keyoverlap): group_true, group_pred {0: [], 1: []}, {0: [], 1: []} for item, pred_tags in zip(orig_items, pred_tag_lists): key str(item.get(overlap_key, 0)) group_true[key].append(item[tags]) group_pred[key].append(pred_tags) for k in group_true: print(foverlap{k}) print(classification_report(group_true[k], group_pred[k]))report_by_overlap把实体按overlap分桶再分别用seqeval生成分类报告。这个过程只需要原始数据里有overlap字段即可运行。运行结果通常显示重叠实体的错误集中在E-手术和B-疾病和诊断的边界上。这时可以考虑用实体级全局优化替代 token 级 argmax即在 Viterbi 解码时加入一个约束——如果输出序列中同时出现两个共享开始偏移的实体只保留置信度更高的一个。5.2 跨病历验证嫌疑文本从哪里找CCKS 2019 的官方测试集带有答案适合做最终评测但在开发验证阶段我建议从subtask2_unlabeled.txt里抽 20 份无标注病历做人工标注把它当成一个 mini 测试集。这样可以判断模型的泛化能力是否只存在于官方训练分布内。具体做法是让人工标注者按照readme-subtask1.txt的规范标注这 20 份文本然后跑一遍classification_report。如果训练集 F1 与 mini 测试集 F1 差距超过 8%说明过拟合或标注规范没有吃透。另一个低成本的排错方向是检查解码结果的实体长度分布。CCKS 2019 中文电子病历数据集的文本里存在大量 e 描述型长句子模型容易从中间截断一个过长的实体。一个简单校验规则是将预测实体长度超过 15 个字符的实例全部挑出来人工核对大部分是误报。用这个规则过滤通常能在不损失召回的前提下把精确率提升 1% 到 2%。最后再回到数据层面重新统计每个类别的混淆矩阵优先补强影像检查和实验室检验两类样本的标注质量因为这两个类别在原始数据集里的标注一致性明显弱于其他三类。本文还有配套的精品资源点击获取
返回列表