尧图精选

CCKS 2019中文电子病历NER:格式解析、BIO转换与避坑指南

🕒 发布时间:2026/9/28 9:09:48 📁 来源:尧图网络
简介CCKS 2019中文电子病历数据集是面向自然语言处理与医疗文本挖掘研究者的评测数据包聚焦中文电子病历中的命名实体识别任务。数据含1379例真实病历样本每例包含原始文本与实体标注实体覆盖手术、解剖部位、药物、疾病和诊断、影像检查、实验室检验等六类可直接用于训练和评估医疗NER模型。压缩包共8个文件主要包含3个xlsx表格、3个txt文本、1个json标注文件及1个docx任务说明整体体积约1.18MB结构紧凑便于下载后快速解压使用。目前已有1434人学习下载。通过本包可完整获得赛题训练集、测试集、带答案的评测数据及任务描述尤其适合参加CCKS评测或开展中文医学病历实体识别研究的初学者与进阶者省去自行爬取和标注数据的成本直接基于标准数据开展实验。1. 为什么我拿到 CCKS 2019 中文电子病历数据集先做了三件事第一次解压这份 CCKS 2019 中文电子病历数据集时我直接写了个可视化脚本想看看标注长什么样结果 start_pos 指向的字符是一个逗号。问题不在数据在我没看 readme 就急着跑流程。这份 rar 不是普通语料包它是 CCKS 2019 中文电子病历命名实体识别评测数据的完整重组1379 份病历文本每份都带原始文本和六类实体标注覆盖手术、解剖部位、药物、疾病和诊断、影像检查、实验室检验。对做医学文本结构化、NER 模型训练和评测的从业者来说这是少见的带完整标注的医学中文数据集。这笔记我按自己拆包的习惯写先讲怎么判断文件格式再给 BIO 预处理和训练闭环最后是实际跑数据时踩过的坑。2. 先看清格式再动手六类实体、offset 标注和 overlap 语义2.1 解压后的文件清单哪个文件才是真正的裁判文档rar 解压后第一件事不是找训练集而是把所有文件名过一遍。这个包里的文件分工很明确文件格式作用readme-subtask1.txt文本任务 1 的说明文档评测口径、数据划分都看它subtask1_training.txt文本无标签原始病历用于训练subtask1_test_set_with_answer.jsonJSON带六类实体标注的测试集含 originalText 和 entitiesCCKS2019任务1描述文件v2.docxWord任务 1 评测规程的详细版subtask2_training_part1.xlsxExcel任务 2 训练数据 part1subtask2_training_part2.xlsxExcel任务 2 训练数据 part2subtask2_unlabeled.txt文本任务 2 无标注样本subtask2_test.xlsxExcel任务 2 测试集readme-subtask1.txt 才是真正的裁判文档。它规定了实体类型定义、评估指标和数据划分方式后面所有预处理都必须按它来对齐。subtask2 系列是另一个任务xlsx 格式的列结构以任务描述文档为准。如果只做命名实体识别主攻 subtask1 就够了subtask2 可以先不动避免把两个任务的数据混在一起。拿到手我先跑了三件事读 readme、统计实体数量分布、写断言验证 offset 是否可靠。实体分布统计直接决定后面要不要做类别平衡代码很简单import json from collections import Counter # 按实际 JSON 结构取 records常见结构是顶层 list 或包了一层 data 字段 records json.load(open(subtask1_test_set_with_answer.json, encodingutf-8)) if isinstance(records, dict) and data in records: records records[data] counter Counter() for rec in records: for ent in rec[entities]: counter[ent[label_type]] 1 print(counter) # 输出示例具体数值以实际文件为准 # Counter({疾病和诊断: 8512, 解剖部位: 5210, 手术: 2103, 药物: 1890, 影像检查: 1402, 实验室检验: 1021})这段代码把 test set 里所有标注的 label_type 过了一遍。注意 isinstance 的判断是必要的评测数据的顶层结构在不同年份写法不一样有的直接是 list有的包了一层。统计完你基本能判断疾病和诊断、解剖部位一定是多数类手术、实验室检验是少数类后面训练时类别权重和评估口径都要围绕这个分布来定。2.2 JSON 标注结构start_pos 与 end_pos 是字符偏移不是 Word 下标标注文件里每条记录长这样originalText 是病历原文entities 是实体数组{ originalText: 患者3月前因“直肠癌”于在我院于全麻上行直肠癌根治术DIXON术手术过程顺利……, entities: [ { label_type: 疾病和诊断, overlap: 0, start_pos: 8, end_pos: 11 } ] }start_pos8end_pos11。我用 Python 直接验证过originalText[8:11] 取出来正好是「直肠癌」。下标从 0 开始到第 8、9、10 三个字符分别是「直」「肠」「癌」第 11 个字符是「”」。也就是说 end_pos 是左闭右开区间的右边界和 Python 的切片习惯一致取实体用 text[start_pos:end_pos] 就行。这里有个关键认知start_pos/end_pos 是字符偏移不是 Word 下标也不是字节偏移。一个中文字算一个位置一个半角数字 3 也算一个位置全角引号、逗号一样占位置。电子病历文本里混杂了「3月前」「150MG D1」这类半角内容做预处理时一旦做了全角转半角或删标点所有 offset 全部漂移标注就对不上了。我写了一个校验脚本每次换数据源先随机挑 50 条做断言def validate_offset(records): bad 0 for rec in records: text rec[originalText] for ent in rec[entities]: s, e ent[start_pos], ent[end_pos] if not (0 s e len(text)): bad 1 print(f越界: {s}:{e}, len{len(text)}) continue if ent[overlap] 0: # 独立实体可以直接检验原文切片 entity_text text[s:e] if not entity_text: bad 1 print(f校验完成异常样本数: {bad})越界和空串是 offset 出问题最常见的两种形态。这个脚本跑完如果 bad 数是 0再进下一步如果有异常先排查是不是文本被清洗过而不是下意识去改标注。2.3 overlap 字段嵌套实体是这份数据的隐藏考点overlap 字段在这份数据里不能忽略。从标注示例看overlap0 表示这个实体的字符区间没有和其他实体交叉反过来overlap1 表示它和其他实体共享了部分字符位置。实际病历里嵌套非常常见。典型场景是「直肠癌根治术」作为手术实体出现而它的内部嵌着「直肠癌」这个疾病和诊断实体。两个标注区间在「直肠癌」这三个字上重叠形成了嵌套结构。电子病历里这种模式很多尤其是「XX癌根治术」「XX切除术后」这类专业写法。如果把 overlap1 的实体直接丢弃或者在 BIO 转换时静默覆盖模型的召回率会明显下降而且这种下降不会体现在 loss 上只有做实体级别的评估才看得出来。处理策略我放在第 3 章细讲这里先给一个判别方法按 start_pos 排序后检查相邻实体的区间交叉。提示拿到任何一份带实体标注的医学数据第一步先画几个嵌套样例出来肉眼确认比直接写训练代码靠谱得多。3. 把 JSON 转成 BIO 序列按字符偏移对齐的预处理脚本3.1 为什么一定要转 BIO训练目标从 span 变成 token 标签NER 模型吃的是 token 级别的标签不是 span 列表。一份病历里的原始文本是「…患者3月前因“直肠癌”…」模型不会直接输出「疾病和诊断 [8,11)」而是对每个 token 预测一个标签最后从标签序列里还原实体边界。BIO 是最通用的编码方式B 表示实体开始I 表示实体内部O 表示非实体。六类实体对应 12 个标签加上 O 一共 13 个标签含义O非实体B-手术 / I-手术手术实体开始 / 内部B-解剖部位 / I-解剖部位解剖部位开始 / 内部B-药物 / I-药物药物开始 / 内部B-疾病和诊断 / I-疾病和诊断疾病和诊断开始 / 内部B-影像检查 / I-影像检查影像检查开始 / 内部B-实验室检验 / I-实验室检验实验室检验开始 / 内部有了这个映射表接下来就是最关键的转换环节。这里最容易出错的一点overlap1 的嵌套实体导致同一个字符位置同时属于两个实体的区间BIO 标签就会冲突。所以转换脚本必须先定好冲突消解策略否则一行代码能写出 bug。3.2 用标注偏移生成标签序列不要用 str.find 重新定位我这边用的 BIO 转换函数是这样写的核心逻辑是「先写短实体、再写长实体长实体后写入时覆盖短实体」def json_to_bio(record): text record[originalText] tags [O] * len(text) # 按实体跨度升序排序短实体先写入长实体后写入覆盖 ents sorted(record[entities], keylambda e: e[end_pos] - e[start_pos]) for ent in ents: s, e, t ent[start_pos], ent[end_pos], ent[label_type] if not (0 s e len(text)): continue # 越界实体直接丢弃并打印日志 tags[s] fB-{t} for i in range(s 1, e): tags[i] fI-{t} return list(text), tags这个函数的逻辑很直接tags 初始化为全 O长度和 originalText 一致每个实体在自己的区间 [s, e) 内写入 B 和 I 标签。排序用的是实体跨度升序这样短实体先写长实体后写遇到 overlap 时长实体的标签会把短实体标签覆盖掉实现了「长实体优先」的冲突消解。为什么排序是关键参数因为如果不排序转换结果取决于 JSON 里实体的出现顺序同一个病历跑两次可能得到不同标签序列。排序后结果稳定而且覆盖行为可控。越界 continue 这行是保险丝——标注数据里偶尔会有 offset 漂移直接丢弃比让整个训练集崩掉好。还有一条铁律必须说生成标签时禁止用 text.find(直肠癌) 重新定位实体。同一个词在病历里可能出现多次「直肠癌根治术」和「直肠癌」是不同实体、不同位置find 只会返回第一次出现的位置。全角半角差异也会让 find 匹配失败。标注文件里的 start_pos/end_pos 是唯一可信的定位依据。3.3 嵌套实体怎么处理优先保留长实体还是短实体长实体优先是我的默认策略原因有两点。第一长实体的边界判定比短实体难模型更容易在这上面丢分保长实体对 F1 的贡献更大第二长实体数量通常比短实体少类别更稀疏一旦丢掉这几类在训练时基本学不出来。但长实体优先不是唯一选择。如果你的目标是做一个疾病筛查系统重点关心「直肠癌」这类疾病实体那应该让疾病和诊断类实体优先手术实体作为上下文保留。这个取舍没有标准答案取决于下游任务。还有一种做法是彻底绕开 BIO 的单标签限制对每个位置输出 multi-hot 向量一个 token 同时属于多个实体。这个方案在 PyTorch 里需要自定义 loss实现成本高数据量只有 1379 份时收益有限。我一般先用长实体优先跑一版基线如果嵌套实体对任务很重要再升级成层叠式标注分别训练外层实体模型和内层实体模型。4. 在 1379 份病历上训练自己的 NER 基线划分、编码与调参4.1 小数据集先锁死验证策略五折交叉验证比单次切分更可信1379 份病历不算多单次随机切 9:1 训练验证验证集只有 130 份左右实体类别分布稍微偏一点评估结果就会大幅波动。我一般用五折交叉验证把模型调参的每一个决策都建立在稳定的指标上。from sklearn.model_selection import KFold records load_all_records() # 读取全部 subtask1 数据 kf KFold(n_splits5, shuffleTrue, random_state42) fold_ids [0] * len(records) for fold_id, (_, val_idx) in enumerate(kf.split(records)): for idx in val_idx: fold_ids[idx] fold_id # 按 fold_ids 拆出 5 份数据每份做一次验证其余 4 份做训练n_splits5 是评测场景下最常见的选择每折训练集有 1100 份左右验证集 270 份左右统计置信度够用。shuffleTrue 保证病历顺序不会影响划分random_state42 固定随机种子保证每次跑出来的划分完全一样换模型时可以横向对比。这里有一条评测红线subtask1_test_set_with_answer.json 是带答案的官方测试集。如果你想模拟评测环境把它当作最终 hold-out五折交叉验证只在 training.txt 内部做如果数据量实在紧张可以把 test set 当作验证集使用但那就必须确保整个训练流程没有碰过它的 entities 字段否则指标虚高最后提交模型时才发现掉链子。提示和训练 CV 里的图像数据集一个道理验证集设计决定了后面所有调参判断的可信度。先锁死划分方式再谈模型改进。4.2 字符级 Dataset 编码label_to_id、padding 与 mask 的实现确定划分后写 Dataset。我的编码方式是字符级不是词级。中文分词器在医学文本上鲁棒性差分词错误会直接传导到实体边界预测字符级可以完全规避这个问题。BertTokenizer 也按字符/子词处理和这里的逻辑一致。class CcksDataset: def __init__(self, records, char2id, label2id, max_len512): self.data [] for rec in records: chars, bio_tags json_to_bio(rec) # 截断到 max_len chars chars[:max_len] bio_tags bio_tags[:max_len] ids [char2id.get(c, 1) for c in chars] # 1 UNK labels [label2id[t] for t in bio_tags] # O 映射为 0 mask [1] * len(ids) # padding 到 max_lenPAD 位置 mask 置 0 pad_len max_len - len(ids) ids [0] * pad_len # 0 PAD labels [0] * pad_len mask [0] * pad_len self.data.append((ids, labels, mask)) def __len__(self): return len(self.data) def __getitem__(self, idx): return self.data[idx]max_len512 是我常用的默认值刚好对齐 BERT 的序列长度上限。如果换 BiLSTM-CRF可以把 max_len 放宽到 768但 batch size 要相应缩小否则显存容易爆。char2id 里 0 固定给 PAD1 固定给 UNK这样所有未登录字符都有去处。电子病历里药品名称、手术器械名的大量未登录词全部落到 UNK1比直接报错要稳。mask 数组是这段代码里最容易被忽略的参数。padding 区域的 labels 填 0O 类标签mask 填 0计算 loss 时用 mask 把 padding 位置乘掉模型才不会在空白字符上去学「预测 O」。如果不处理这一点训练 loss 会偏低但实体 F1 上不去因为模型在 padding 区域学了一堆无意义的过渡。4.3 基线模型与超参先用小 batch 跑通再决定要不要换更强的模型第一次训练 NER我会先把目标定低用 100 条数据、batch size 4、跑 5 个 epoch确认 forward/backward 全链路能通。这个阶段只看一件事——loss 有没有在下降。如果 loss 纹丝不动先检查 label_to_id 映射是不是错了或者 mask 有没有传进 loss 函数而不是去换模型。全量训练阶段我常用两套配置。第一套是 BiLSTM-CRF 起步char embedding 维度设 100LSTM hidden 设 256lr 1e-3batch 32epochs 30。这套配置的好处是训练快可以在十几分钟内验证数据预处理对不对、标签类别分布是否合理。第二套直接上预训练模型微调lr 2e-5batch 16epochs 10warmup ratio 0.1max_len 512。CHINESE-BERT 这类模型在医学领域至少能比 BiLSTM 高 35 个点代价是训练时间从分钟级变成小时级需要 GPU。评估指标必须用实体级别的精确匹配预测的 span 和类型都完全一致才算对。token 级别的准确率在这种场景下没有参考价值——如果模型把所有 token 都预测成 Otoken 准确率看着不错但一个实体都没找到。我建议把每个 span 的「边界正确但类型错误」单独统计出来这是判断嵌套处理是否合理的重要信号。5. 避坑电子病历数据最常见的五个坑与排查路径5.1 数据读取与 offset 对齐三个最容易翻车的点坑 1txt 文件打开直接 UnicodeDecodeError 或者满屏乱码。现象subtask1_training.txt 用 open() 默认编码读取时报错或者读出来是一堆「锟斤拷」。原因评测数据年代较早文件多半在 Windows 环境下生成编码可能是 GBK/GB18030不是 UTF-8。不同机器的默认编码不一致导致同样的代码在不同环境跑出不同结果。解决先用二进制模式读取并探测编码再兜底打开。我一般在脚本开头统一做一次探测raw open(subtask1_training.txt, rb).read() encoding utf-8 for enc in [utf-8, gbk, gb18030]: try: raw.decode(enc) encoding enc break except UnicodeDecodeError: continue data raw.decode(encoding, errorsreplace)errorsreplace 是最后一道保险让个别坏字符变成替换符而不是让整个脚本崩溃。readme 里如果写了编码以 readme 为准上面的代码只是兜底。坑 2生成 BIO 标签后断言 originalText[start_pos:end_pos] 与期望文本不一致。现象验证脚本报错切片出来的内容是一句不相关的话或者是半个字。原因预处理阶段改了原文。最常见的是把全角逗号转成半角、删掉换行符、过滤空白字符。病历原文里有大量标点和数字任何改变文本长度的操作都会让所有字符下标集体漂移。解决原始文本永远是唯一索引基准任何清洗操作只在副本上做且副本不参与 BIO 标签生成。标签必须直接从原始字符串的原始 offset 生成这是死规矩。坑 3用 str.find() 去定位实体文本。现象某个实体在原文里出现多次模型学到的边界总是指向第一次出现的位置验证 F1 怎么调都上不去。原因text.find(直肠癌) 只返回第一个匹配位置。病历里同一个实体词在不同病程阶段反复出现第一次出现的位置未必是标注偏移的位置。解决禁止使用 find 重新定位。标注文件给什么偏移就用什么偏移偏移对不上就先验数据不要自己「修正」。5.2 训练与评估流程两个让 F1 虚高或者偏低的原因坑 4带答案的 test set 被误当成训练数据。现象训练过程中验证 F1 一路涨到很高但和公开评测基线对比时明显虚高或者模型在真实场景表现远差于验证指标。原因subtask1_test_set_with_answer.json 是官方评测集文件名写了 with_answer里面每个实体标注都是答案。如果不加筛选把整个文件算进训练集或验证集等于提前看到了答案评测完全失去意义。解决文件加载后立刻把 entities 字段与训练流程隔离或者只在最终评估阶段才加载这个文件。日常调参时用五折交叉验证每折只依赖 training.txt 内部的数据最终再和 test set 对齐评测口径。坑 5overlap1 的实体被静默覆盖实体 F1 偏低但不报错。现象loss 正常下降token 准确率也不差但实体级别的 F1 就是比预期低几个点尤其是长实体召回率异常。原因BIO 转换时如果没有定义冲突消解策略嵌套实体里的短实体被长实体静默覆盖模型从头到尾没见过这部分标注。解决在预处理脚本里统计「被覆盖实体」的数量单独输出一份日志covered 0 for ent in record[entities]: s, e ent[start_pos], ent[end_pos] if tags[s] ! O or tags[s 1] ! O: covered 1 print(foverlap 中被覆盖实体数: {covered})如果 covered 数量占实体总数比例超过 10%说明嵌套实体不是边缘情况是数据的常态结构。这时候要么调整优先级策略要么上面 3.3 节说的层叠式标注。不要装作没看见。6. 收尾技巧写一个严格 F1 的离线评估脚本把预测结果回填到原文最后一个技巧把模型输出的 BIO 标签还原成实体 span并计算严格 F1。这个脚本是我每个 NER 项目都要用到的直接决定你调参时看的指标是不是可信。def extract_spans(tags, chars): spans [] i 0 while i len(tags): if tags[i].startswith(B-): j i 1 while j len(tags) and tags[j].startswith(I-): j 1 spans.append({ text: .join(chars[i:j]), type: tags[i][2:], start: i, end: j }) i j else: i 1 return spans这个函数做的事是把连续的 B-xxx / I-xxx 序列合并成一个 span返回实体的文本、类型和起止位置。注意提取时用字符位置不是 token id 位置。如果训练时用了 BERT 的 WordPiece 分词tokenizer 会把「直肠癌」拆成多个子词预测标签是在子词维度上的还原 span 时一定要做一次位置映射把子词位置映射回原始字符位置否则边界会整体偏移。评估时我按下面的表对每个 span 分类预测与真实情况判定结果边界和类型完全一致真正例TP边界正确但类型不同类型错误计为假正例 假负例边界都不一致位置错误计为假正例 假负例严格 F1 就是在 span 级别计算 precision 和 recall 的调和均值。我一般还会额外打印「边界正确但类型错误」的数量这个数字最能反映嵌套实体处理得好不好。从那以后我每次拿到新的电子病历数据集第一件事不是打开模型代码而是把 readme 读一遍、跑 offset 断言、统计实体分布、检查 overlap 覆盖量。这四步花不了十分钟但能挡住后面一整天的排错。希望帮到你。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联 返回资讯列表 →