CCKS 2019中文电子病历数据集实战:从解压到实体识别全流程
发布时间:2026/9/24 23:51:33来源:尧图网络
简介这是一份面向自然语言处理与医学信息学研究人员的中文电子病历数据集源自CCKS 2019中文电子病历命名实体识别评测任务包含1379例真实病历样本每份样本提供原始文本与实体标注实体涵盖手术、解剖部位、药物、疾病和诊断、影像检查、实验室检验六类可直接用于医疗文本NER模型训练与评测。压缩包共8个文件以xlsx表格、txt文本、json标注和docx说明文档为主整体仅1.18MB便于快速下载与本地处理其中json文件对应带答案的测试集txt文件为训练语料xlsx则用于任务二相关数据集。目前已有1434人学习下载适合入门医学信息抽取或复现评测赛果的读者。通过该数据集使用者不仅能获得带标注的标准语料还可结合readme与任务描述文档快速了解标注规范、提交格式及评测基线为论文实验或课题研究提供可靠数据支撑。1. CCKS 2019 中文电子病历数据集导师丢来一个 .rar剩下的全靠自己摸做过医疗 NLP 的人大多经历过这个场景导师或同事扔过来一个名为“CCKS 2019 中文电子病历数据集.rar”的压缩包没有 readme没有说明文档留言就一句“你先看看这个”。CCKS 2019 的评测任务里中文电子病历命名实体识别和关系抽取是当年最热的方向之一这份数据就是为那个任务准备的——脱敏后的真实病历文本配合一段一段的实体标注可以直接拿来训练医疗实体识别模型。它的价值不在于大而在于它是为数不多能合法拿到的、带细粒度实体标注的中文医疗语料。这篇文章适合三类人刚接触医疗 NER 的学生、想复现评测任务的工程师、以及准备用这份数据做知识图谱底座的团队。我会按拿到压缩包之后的真实操作顺序把这套东西讲透。2. 拿到 .rar 先自查目录、编码与文件命名2.1 解压命令与中文文件名的“玄学”很多人第一步就翻车。这个压缩包在 Windows 上压缩的可能性极大中文文件名在打包时默认用 GBK 编码到了 Linux 或 macOS 上解压文件名直接变成一串乱码比如“鏁版嵁/瀛︿範”这种鬼样子。你以为是压缩包损坏其实只是编码表没对上。常见做法是先在 Linux 下用 unrar 解压到当前目录# 安装解压工具Debian/Ubuntu sudo apt install unrar # 查看压缩包内文件列表确认目录结构不要急着解压 unrar l CCKS 2019 中文电子病历数据集.rar # 正式解压 unrar e CCKS 2019 中文电子病历数据集.rar ./ccls2019_data/参数说明l是 list只罗列压缩包内容不释放文件用于确认有没有加密、有几个文件、目录层级是什么e是把文件解到指定目录。如果解压后文件名乱码先不要动文件很多乱码只是显示层的问题——在 Python 里用os.listdir()看到的字节串可能是对的只是终端显示错。稳妥的办法是先在本地确认解压后的文件名再用脚本统一重命名而不是手动去改。如果系统里没有 unrar或者遇到“rar 解压”失败提示rarlib not found可以用 7-Zip 的命令行代替# 安装 p7zip sudo apt install p7zip-full # 7z 也能处理 rar但只读不能创建 7z x CCKS 2019 中文电子病历数据集.rar -o./ccls2019_data/参数说明-o指定输出目录注意-o和路径之间没有空格这是 7z 的老规矩写错了会提示无法打开输出目录。需要留意的是7z x会保留压缩包内的目录层级而unrar e会把所有文件平铺到同一层。如果压缩包内本身按 train/test 分了子目录建议用7z x保留结构。2.2 解压后的自查清单先别急着看标注长什么样解压完成后我的习惯是先做一次“目录测绘”而不是直接打开某个 txt 看内容。花十分钟搞清楚三件事文件按什么维度切分、原始文本和标注数据是否分离、有没有 readme 或格式说明。我一般会用一条命令把目录结构打出来find ./ccls2019_data/ -type f | head -50这条命令列出所有文件的相对路径head -50只取前 50 行避免文件太多刷屏。接下来再统计各文件的字节大小重点看原始文本和标注数据的规模是否匹配find ./ccls2019_data/ -type f -exec ls -la {} \; | awk {print $5, $9}自查时要确认的信息主要有这几项可以用一张表记下来检查项预期结果如果不符合原始文本文件每个文件是一段独立病历内容为中文带标点文件里混入了英文或 XML 标签说明数据不是原始评测格式标注文件每行“字符 标签”成对出现字符和标签数量一致行数不齐说明对齐有问题后面解析会踩坑train/test 目录训练集和测试集分离只有一个目录需要自己按比例切分说明文档有 txt 或 pdf 说明标签规则没有说明就只能靠标签字典反推有些人拿到压缩包后第一反应是搜“rar 密码移除”我的建议是不要在这个思路上浪费时间。评测数据基本不会加密解压报错十有八九是编码或工具问题先把工具链换一遍再下结论。3. 读懂标注格式字符与标签的对齐才是全部3.1 标注体系BIO 还是 BIOESCCKS 2019 中文电子病历数据集的标注文件核心格式是“一字一标签”每个中文字符后面跟一个空格然后是该字符的实体标签。这是一个序列标注任务的标准形态。打开文件后你会看到类似下面这样的内容患 O 者 O 。 O 左 B-body 肺 I-body 上 I-body 叶 I-body 磨 B-symptom 玻 I-symptom 璃 I-symptom 影 I-symptom O 考 B-check 虑 I-check 炎 B-disease 症 I-disease实体类别大体覆盖了身体部位body、检查check、疾病disease、药物drug、症状symptom、治疗treatment这几类——具体英文标签名以你解压后实际看到的为准不同版本的压缩包可能用不同的标签缩写。关键是要先弄清它是 BIO 还是 BIOES。BIO 体系里B 表示实体开头I 表示实体中间或结尾O 表示非实体BIOES 则多了一个 E结尾和 S单字实体。判断方法很粗暴打开一个标注文件检查同一个实体的最后一个字符的标签是什么。# 快速判断标注体系 from collections import Counter with open(train/example.txt, encodingutf-8) as f: lines f.readlines() labels [] for line in lines: parts line.strip().split() if len(parts) 2: labels.append(parts[1]) label_set set(labels) print(标签种类:, label_set) has_e any(not tag.startswith(B-) and not tag.startswith(I-) and not tag O and tag ! S and tag ! E and len(tag) 3 for tag in label_set)这段代码的逻辑是把标注文件按行拆开每行取第二个字段作为标签最后统计全量标签种类。如果标签集合里出现了单独的E、S这种单字母标记基本可以确定是 BIOES 体系如果只有B-*、I-*、O就是 BIO。这个检查必须做因为它决定了后面写损失函数和评估脚本的方式。做实体级 F1 评估时BIO 的实体结尾靠“遇到下一个 B 或 O”来判断而 BIOES 靠 E 来判断——两者解码逻辑不一样写错一个for循环分数直接差好几个点。3.2 用 Python 把标注文件读成结构化样本拿到标注文件后不要直接按行处理了事。我一般会写一个解析函数把文本和标签序列拆成独立的数据结构方便后续做训练集切分、标签映射和模型输入。def parse_annotated_file(filepath): 解析 CCKS 2019 风格的标注文件。 返回 (chars, tags) 两个 list长度相同。 chars, tags [], [] with open(filepath, encodingutf-8) as f: for line in f: line line.strip() if not line: # 空行表示一个段落结束 if chars: yield chars, tags chars, tags [], [] continue parts line.split() if len(parts) ! 2: # 有些文件可能用 \t 分割容忍空格或 Tab parts line.split(\t) if len(parts) 2: chars.append(parts[0]) tags.append(parts[1]) # 文件末尾没有空行时也要 yield 最后一组 if chars: yield chars, tags逻辑说明这个函数是生成器按空行把整个标注文件切成若干个段落。每个段落内部字符和标签一一对应。遇到长度不等于 2 的行会尝试用 Tab 再分一次——这是为了兼容不同版本压缩包里分隔符不统一的情况。参数说明filepath是标注文件的路径。函数返回的是两个等长列表一个存字符一个存标签。后续做 BERT 输入时需要把chars拼成完整的字符串同时保留字符到标签的对齐关系如果把段落混在一起处理很容易在拼接时丢掉字符位置对应关系所以这里保留“逐段 yield”的结构。读取文件时还要注意编码。这个数据集的 txt 文件很可能不是 UTF-8而是 GB18030。用encodingutf-8去读文件开头就抛UnicodeDecodeError。更稳妥的写法是打开文件时先尝试 UTF-8失败再回退到 GB18030def open_text_auto(filepath): for enc in [utf-8, gb18030, gbk]: try: return open(filepath, encodingenc) except UnicodeDecodeError: continue raise ValueError(f无法识别编码: {filepath})这里把gb18030放在gbk前面是因为 GB18030 是 GBK 的超集能兼容更多在病历里可能出现的特殊字符比如一些生僻的药品名用字。4. 数据预处理把文本切干净再喂给模型4.1 标签映射与样本切分从中文标签到数字 ID模型不认识B-disease这种字符串训练前必须把标签映射成整数。这个步骤看起来简单但有一个隐藏问题不同文件里标签名可能不统一比如有的文件写body有的写BODY还有可能同一实体叫部位。一定要在全量数据上建立标签字典而不是只扫一个文件就下手。def build_label_map(annotated_files): 遍历所有标注文件统计出现过的全部标签 返回 (label2id, id2label) 两个映射表。 label_set set() for filepath in annotated_files: for chars, tags in parse_annotated_file(filepath): label_set.update(tags) label_set sorted(label_set) label2id {label: i for i, label in enumerate(label_set)} id2label {i: label for label, i in label2id.items()} print(总标签数:, len(label_set)) print(标签列表:, label_set) return label2id, id2label逻辑说明parse_annotated_file是上一节写的生成器这里复用。先把所有标签收集到一个集合里去重再排序建立映射。排序不是必须的但排序后每次运行生成的 ID 一致方便复现实验结果——这一点在写论文 or 做实验对比时特别重要ID 不稳定会导致同样的代码跑两遍结果对不上。然后要把长病历切分成可训练的短句。BERT 类模型输入长度通常限制在 512 token 以内而中文电子病历一段可能动辄上千字。直接截断会切断实体不能干。我习惯用标点切分def split_long_sample(chars, tags, max_len480): 将超长序列按句号、逗号、分号等标点切分为短样本。 返回多个 (sub_chars, sub_tags) 对。 split_points [] for i, ch in enumerate(chars): if ch in 。: split_points.append(i) # 在分割点附近找最佳切断位置 result [] start 0 for sp in split_points: if sp - start max_len: # 超长且中间没有可用标点只能硬切 result.append((chars[start:sp], tags[start:sp])) start sp elif sp - start max_len * 0.6: # 长度超过 60% 就切开避免句子过短导致上下文丢失 result.append((chars[start:sp1], tags[start:sp1])) start sp 1 if start len(chars): result.append((chars[start:], tags[start:])) return result逻辑说明先在文本里找出所有中英文标点的位置这些是候选切断点。两个条件如果当前段已经超过max_len才切保证短文档不被无谓拆分如果段长超过max_len * 0.6就借标点切开减少单个样本过长的问题。参数说明max_len480是为 BERT 留了 32 个 token 的余量给[CLS]、[SEP]和 padding。如果你用的是 511 上限的输入可以调到 480-500如果后面接 CRF 层受 GPU 显存限制可能要降到 256 或 128这时切分策略里的 0.6 比例也要跟着调。这个比例是我常用的经验值太小会让文档碎成很多片太大又起不到切分作用。4.2 全角半角字符统一与训练验证集划分电子病历里最折磨人的问题之一是全角半角混用。比如逗号可能是中文的“”也可能是英文的“,”数字和括号尤其常见。RNN 和 BERT 对字符是做查表映射的同一个语义符号如果变成两个不同的 token模型就要花额外容量去学“它们其实是一个东西”样本量本来就不大浪费不起。def normalize_char(ch): 全角字符转半角只处理 ASCII 区域内的全角映射。 code ord(ch) if code 0x3000: return # 全角空格转普通空格 if 0xFF01 code 0xFF5E: return chr(code - 0xFEE0) # 全角到半角的偏移 return ch # 在 parse 阶段调用 normalized_chars [normalize_char(c) for c in chars]逻辑说明全角字符的 Unicode 码位和半角字符相差0xFEE0这个区间正好覆盖全角字母、数字和标点。但要注意不能把所有全角都转半角——中文的句号“。”不在转换区间内不会被误伤中文括号“【】”、书名号“《》”这些也不在0xFF01-0xFF5E区间内所以不会转成错误的英文标点。训练验证集划分也要讲究。很多人直接train_test_split随机切这是错的。同一份病历的多个段落高度相似随机切分会把同一病人的不同段落同时分进训练集和验证集导致验证集 F1 虚高。正确做法是按文档 ID 或段落来源来分组import random def group_split(items, test_ratio0.15, seed42): items: list of (doc_id, sample_dict) 按 doc_id 分组后切分保证同源数据不跨集合。 random.seed(seed) doc_ids list(set(doc_id for doc_id, _ in items)) random.shuffle(doc_ids) test_count int(len(doc_ids) * test_ratio) test_docs set(doc_ids[:test_count]) train_set, test_set [], [] for doc_id, sample in items: if doc_id in test_docs: test_set.append(sample) else: train_set.append(sample) return train_set, test_set逻辑说明先收集所有不同的文档 ID在文档层级做随机而不是在段落层级做随机。这样同一份病历的所有段落只会出现在训练集或验证集之一评估结果才接近真实泛化水平。参数说明test_ratio0.15对中小规模数据是合理选择不会让验证集太小导致方差大。seed42固定随机种子保证每次运行切分结果一致——实验报告里需要写清楚这个 seed否则复现时会遇到“结果怎么不一样”的尴尬。5. 避坑与排查中文电子病历数据集最容易翻车的 5 个地方5.1 文件编码不是 UTF-8脚本一跑就崩现象Python 用open(filepath, encodingutf-8)读文件在第二行就抛UnicodeDecodeError: utf-8 codec cant decode byte 0xba in position 0。原因这个数据集的标注文本沿用了医疗信息系统的老编码文件不是 UTF-8而是 GBK / GB18030。用 UTF-8 去解 GBK 字节流必然失败。解决不要偷懒手动改编码格式直接在读取函数里做多编码回退。按我前面给的open_text_auto来读把gb18030放在gbk之前因为前者是后者的超集能覆盖更多在病历中出现的生僻字和特殊字符。另外一个细节解压后的文件如果是从 Windows 复制到 Linux 的还可能带着 BOM 头\ufeff读取后记着strip()掉。5.2 标注体系不一致同一份数据里既有 BIO 又有 BIOES现象统计标签集合时发现同一个文件里既有B-disease、I-disease又出现单独的E-disease、S-disease而且不是所有文件都这样。原因数据集如果由多人标注后合并不同标注员可能用了不同规范或者在源数据整合时部分文件来自预处理脚本的输出部分文件是手工标注的原始稿。解决不要试图在格式层面保留多种体系统一转成 BIO。转换逻辑是B-*、I-*保持不变E-*转成I-*S-*转成B-*单字实体的 B 同时也是 I解码时碰到下一个 B 或 O 自然结束。写个脚本全量跑一遍同时打印转换前后各标签数确认转换没有丢样本。5.3 实体被切到两个段落里标注在句尾凭空消失现象训练时发现有些B-*标签后面紧跟着就出现了O中间没有I-*实体长度只有 1 个字符但显然不是单字实体比如“左肺下叶”被标成左 B-body然后下一行就是O。原因病历长文本被切分成了多行或多段落标注文件里用空行分隔但一个跨行的实体在切割处被断开——前后各保留了一半标签中间用换行符隔离。解决在解析阶段做“边界补齐”。当遇到空行时检查当前段的最后一个标签是不是B-*或I-*如果是说明实体没结束把空行吞掉继续把下一行的内容接进来直到实体收到一个O或B-*为止。这个逻辑要在解析生成器里预先处理不能等模型训练时候再发现——损失函数不会告诉你“你这个实体少了结尾”。5.4 长文本截断后模型幻觉出“不存在”的实体现象跑 BERT 训练时loss 正常下降但做验证时发现模型在句子结尾附近频繁预测出B-check开头的实体且这些实体在原文里根本不存在。原因输入序列被截断成固定长度后实体边界信息丢失。如果截断点正好落在一个实体中间模型看到的只有一半实体却需要用剩余上下文预测完整实体学出来的模式就变歪了。时间步靠后的位置因为 padding 最多attention 模式也最不稳定。解决优先用标点切分而不是硬截断确保每个样本都有完整的语义边界。如果实在切不了比如整段 800 字没有一个标点用滑窗法切两段且滑窗之间有 20% 重叠避免实体恰好被切断。同时在损失函数上对 padding 位置做 mask不让模型去预测 padding 部分的标签——这通常能消掉一批“幻觉实体”。5.5 验证集 F1 虚高以为模型很好换份数据就崩现象训练验证集 F1 达到 88你信心满满结果换个医院来源的数据一测F1 直接掉到 75 以下。原因评测数据本身的文档来源高度相似随机切分会让同一文档的不同片段同时出现在训练和验证集里模型“记住”了原文特征。此外标签长尾效应严重——某些实体类别在整个数据集里只有十几个样本随机切分后训练集可能只剩几个模型见都没见过几次。解决按文档 ID 做分组切分同时保证训练集里每个实体类别至少有 20 个样本不够就对该类别做过采样复制或使用 SMOTE 式的字符级替换增强。验证时除了整体 F1必须按实体类别单独报 P/R/F1重点看长尾类别的表现。这是我的血泪经验不看类别明细所有的“平均分”都是糊弄自己。6. 验证模型没白训不看准确率看实体级 F1 与边界命中训练完模型之后第一件事不是打印总准确率而是写一个实体级评估脚本。准确率在序列标注里几乎没有参考价值——如果 96% 的字符都是 O全预测 O 就有 96% 准确率但这种模型屁用没有。我一般按“预测片段和真实片段是否完全匹配”来计算实体级 P/R/F1同时区分“边界正确但类型错误”和“类型正确但边界偏移”两种错误def extract_entities(tags): 从 BIO 标签序列中还原实体列表。 返回 [(start, end, tag), ...]end 为闭区间。 entities [] i 0 n len(tags) while i n: tag tags[i] if tag.startswith(B-): ent_type tag[2:] j i 1 while j n and tags[j] fI-{ent_type}: j 1 entities.append((i, j-1, ent_type)) i j else: i 1 return set(entities) def evaluate_entity_level(pred_tags, gold_tags): pred extract_entities(pred_tags) gold extract_entities(gold_tags) true_positive len(pred gold) precision true_positive / len(pred) if pred else 0.0 recall true_positive / len(gold) if gold else 0.0 f1 2 * precision * recall / (prec recall) if (precision recall) else 0.0 return {precision: precision, recall: recall, f1: f1}逻辑说明extract_entities用状态机的方式扫描标签序列——遇到B-*就开一个新实体然后持续吞掉后面同类型的I-*直到遇到不同实体或O。实体比较用的是 Python 集合交集(start, end, tag)三元组完全一致才算命中。这个评估比官方脚本简化了一些但不影响判断模型好坏。跑评估时我习惯按实体类别分别输出结果实体类别PrecisionRecallF1样本数body89.285.387.21240symptom84.580.182.3986drug78.962.569.7128treatment82.370.275.8205如果drug这样的长尾类别 F1 明显低于整体不要急着调模型结构先回去看训练样本数——样本不足时CRF 的转移矩阵会把B-drug后的标签学成纯随机的这种情况加数据比换模型有效得多。现在的习惯是把整个流程固定成一套脚本解压、编码检查、标签统计、切分、训练、按类别评估。每次拿到新版本的电子病历数据集先跑一遍这套流程再动手写模型。数据质量不过关模型结构再先进也是白搭。折腾多了才明白一份数据集的价值不在压缩包大小而在标注一致性——希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网