ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

CCKS 2019中文电子病历数据集预处理与BiLSTM-CRF实体识别实战

2026/9/11 23:50:26 拓冰建站 浏览量
CCKS 2019中文电子病历数据集预处理与BiLSTM-CRF实体识别实战 简介CCKS 2019 发布的中文电子病历命名实体识别数据集面向自然语言处理与医学信息抽取方向的学习者、研究人员及相关竞赛选手可用于训练和评测中文医学命名实体识别模型。资源共包含1379例病历样本每份病历均提供原始文本与实体标注实体类型覆盖手术、解剖部位、药物、疾病和诊断、影像检查、实验室检验。压缩包共9个文件以xlsx、txt、json、docx、md为主要类型其中xlsx和json用于存放结构化标注结果txt为原始文本数据docx与md为赛题说明和文档涵盖subtask1与subtask2的训练集、测试集、未标注集整体仅1.18MB便于快速下载与使用。目前已有544人学习/下载。通过该数据集读者可直接获得规范化的中文医学语料与标注体系用于开展NLP实验、论文复现或备赛练习是研究中文医学实体识别的实用入门资源。1. CCKS 2019 中文电子病历数据集是医疗 NER 绕不开的起点CCKS 2019 中文电子病历数据集.rar这个压缩包在医疗 NLP 圈子里的名气不小但不少人解压后对着满屏 txt 不知道从哪下手。它来自 CCKS 2019 评测任务是用真实病历文本整理出的中文电子病历标注语料核心用途是中文医疗命名实体识别NER。这类数据跟新闻类通用语料差别很大病历里口语化表述多、全角标点混杂、专业缩写密集直接拿通用 tokenizer 和通用模型跑实体边界错得离谱。这篇按完整流程走一遍从标注格式解析开始完成预处理再用 BiLSTM-CRF 训练基线并计算实体级 F1最后补几个立即见效的排错和调优技巧。熟手可以直接跳到第 2 章和第 5 章新手建议老老实实从第 2 章往后做。2. 读懂 CCKS 2019 中文电子病历数据集的标注文件与标签体系2.1 压缩包内部结构与逐行标注格式拿到压缩包后第一步先解压并确认目录结构。rar 格式在 Linux 环境用 unrar 或 7z 都能处理建议解压到单独目录避免后续生成的缓存文件和数据源混在一起mkdir -p ccks2019_emr unrar x CCKS2019_EMR.rar ccks2019_emr/解压后通常会得到若干 txt 文件每个文件对应一份病历的标注结果文件名多为编号不携带语义信息。用任意文本编辑器打开一个文件会看到这样的内容既 O 往 O 有 O 高 B-DISEASE 血 I-DISEASE 压 I-DISEASE 病 I-DISEASE 史 O这份数据的标注粒度是字符级每一行代表一个汉字、数字或标点。第一列是原始字符第二列是 BIO 标签B 标记实体起始I 标记实体延续O 表示不属于任何实体。整个文件按句组织句与句之间用空行切开。正因为几乎全是中文字符级序列标注成为绝大多数复现方案的选择。相比词级标注字符级不依赖分词器也不会把「未见异常」这类诊疗套话切碎解析逻辑简单后处理容易对齐。2.2 实体类别与 BIO 标签的对应关系CCKS 2019 中文电子病历数据的实体类目在评测任务说明里写得很明确。按公开复现资料中常见的整理方式实体通常被归为五类标签直接拼在 IOB 后缀上格式统一为 B-类型 / I-类型。五类实体的含义和典型样例整理如下实体类别标签后缀病历中的典型样例边界难点身体部位BODY右下肢、左上肺、胃窦常与检查名称组合出现症状与体征SIGN咳嗽、低热、双下肢水肿否定前缀多如「无明显」疾病与诊断DISEASE高血压、2型糖尿病缩写和俗称混杂检查与检验CHECK胸部CT、血常规设备名与部位连写治疗TREAT抗感染、胰岛素动词与名词易切分错位以五类实体为例B 和 I 后缀各 5 个加一个 O标签表共 11 个类别。如果你的压缩包版本不同实体类别可能有增减可以先打印全部标签再建映射不必死扣这个数字。实体级识别难点集中在 DISEASE 和 SIGN因为同一症状在病历里可能有发热、发烧、Tmax 38.5℃ 三种形态这种表述差异靠规则枚举不完模型必须对同义表达有泛化能力。2.3 解析标注文件时最容易踩的三个格式坑第一个坑是分隔符不统一。多数行用空格或制表符但部分文件混入了全角空格U3000。全角空格在视觉上和普通空白几乎没有区别直接调用line.split()不会按它切开导致一行内容变成一个完整 token。第二个坑是行尾回车符Windows 环境下产生的文件常带\r\n如果不先把\r剥掉标签列会粘上一个隐藏字符label 表里就会出现B-DISEASE\r这种脏类别。第三个坑是空行策略不稳定有的句子间是单个空行有的文件末尾没有换行符统计句数时容易漏掉最后一句。用下面这段代码做粗校验确认文件能否被稳定解析成字符与标签两列from pathlib import Path root Path(ccks2019_emr) for fp in sorted(root.glob(**/*.txt))[:3]: with open(fp, r, encodingutf-8, errorsignore) as f: lines f.readlines() bad [] for idx, ln in enumerate(lines[:200]): ln ln.strip() if not ln: continue if len(ln.split()) ! 2: bad.append((idx, repr(ln))) print(fp.name, 异常行数:, len(bad)) if bad: print(示例:, bad[:2])说明errorsignore会忽略无法解码的字节这一步只做总量评估后续正式解析时不应该再忽略错误。repr(ln)能在输出里暴露隐藏的\r或全角空格。如果异常行数占比超过 1%说明数据里混了别的格式需要回到解压环节确认文件来源而不是在解析代码里继续打补丁。粗校验通过后再进入正式的预处理阶段。3. 用 Python 完成中文电子病历数据集的预处理与数据划分3.1 兼容全角空格与异常行长的解析器预处理是整个流程里性价比最高的一步把 2.3 里识别的三类问题一次性解决。先定义统一的分隔符归一化函数把全角空格、普通空格、制表符和连续空白全部替换成单个普通空格再按空格切分。这样无论原始文件用哪种空白解析出来的列结构都是一致的。import re from pathlib import Path SPACE_RE re.compile(r[\s\u3000]) def parse_emr_file(fp: Path): samples [] chars, labels [], [] with open(fp, r, encodingutf-8-sig) as f: content f.read() for raw_line in content.splitlines(): line SPACE_RE.sub( , raw_line.strip()) if not line: if chars: samples.append((chars, labels)) chars, labels [], [] continue parts line.split( ) if len(parts) ! 2: continue chars.append(parts[0]) labels.append(parts[1]) if chars: samples.append((chars, labels)) return samples三个设计点需要解释。第一open 用utf-8-sig编码自动剥掉文件头可能存在的 BOM避免第一个字符变成\ufeff。第二splitlines()同时兼容\n和\r\n省掉单独处理回车的代码。第三异常行直接continue不抛异常也不记录前提是前面已经用粗校验确认过异常占比很低。返回结果按句子组织成列表而不是整个文件连成一条序列这对训练时的 batch 组织更友好也不会把两份病历拼到一起。3.2 构建字符表、标签表与 id 映射字符表直接从全量训练文本统计不需要外部词表。核心逻辑是统计完字符频率以后给 PAD 和 UNK 各留一个固定位置再加一层偏移。具体代码如下from collections import Counter char_counter Counter() label_counter Counter() all_samples [] for fp in sorted(Path(ccks2019_emr).glob(**/*.txt)): file_samples parse_emr_file(fp) for chars, labels in file_samples: char_counter.update(chars) label_counter.update(labels) all_samples.append((chars, labels)) char2idx {PAD: 0, UNK: 1} for c, _ in char_counter.most_common(): char2idx[c] len(char2idx) label2idx {lab: i for i, lab in enumerate(sorted(label_counter))} idx2label {i: lab for lab, i in label2idx.items()} print(字符表大小:, len(char2idx)) print(标签集合:, sorted(label_counter))这段代码里char2idx的构造顺序是关键先放两个占位符再 append 其余字符这样PAD恒为 0UNK恒为 1。标签表用sorted排序保证多轮运行的映射一致。最后的打印语句用来确认标签集合是否和预期一致如果你手上的压缩包实体类别有出入这里会直接暴露出来后续代码不需要改动只要映射表正确模型就能自动适配。3.3 训练集/验证集划分与序列长度处理数据划分按文件执行不按句子打散。原因很简单同一份病历内部上下文高度连续如果同一份病历的句子既进训练集又进验证集验证分数会虚高掩盖模型对陌生病历的泛化能力。常见做法是把文件列表按 8:2 随机划分并固定随机种子保证结果可复现。import random all_files sorted(Path(ccks2019_emr).glob(**/*.txt)) random.seed(42) random.shuffle(all_files) split int(len(all_files) * 0.2) valid_files all_files[:split] train_files all_files[split:] lengths [len(chars) for chars, _ in all_samples] max_len int(sorted(lengths)[int(len(lengths) * 0.98)]) print(max_len:, max_len)这里max_len取句子长度分布的 98 分位数而不是单纯取最大值。病历文本里偶发超长段落如果 padding 到最大长度batch 内大部分是 pad训练慢且浪费显存。不同场景下的长度策略可以参考下表长度处理策略max_len 取值适用场景98 分位数截断常见默认通用训练覆盖绝大多数病历95 分位数截断更短显存紧张或短句居多不截断取最大值实体跨行长句占比高截断方式统一取前max_len个字符不做滑窗逻辑简单且不会引入重复采样偏差。实体跨句的标签在评测时不参与计算因此截断带来的损失有限完全可以通过复现验证。4. 用 BiLSTM-CRF 跑通中文电子病历实体识别基线4.1 模型结构与 PyTorch 实现要点基线模型选择 BiLSTM-CRF 而不是单纯的 BiLSTM-Softmax原因在于电子病历的标签之间存在很强的结构化约束。比如 B 标签后面可以接同类型 I但不能接另一类型的 I更不能在句首出现 I。这些约束靠逐 token softmax 无法表达而 CRF 层通过转移矩阵显式建模相邻标签的关系推理时直接输出全局最优路径。import torch import torch.nn as nn from torchcrf import CRF class BiLSTMCRF(nn.Module): def __init__(self, vocab_size, tag_size, emb_size128, hidden_size256, num_layers1): super().__init__() self.embedding nn.Embedding(vocab_size, emb_size, padding_idx0) self.lstm nn.LSTM(emb_size, hidden_size // 2, num_layersnum_layers, bidirectionalTrue, batch_firstTrue) self.proj nn.Linear(hidden_size, tag_size) self.crf CRF(tag_size, batch_firstTrue) def forward(self, input_ids, mask): emb self.embedding(input_ids) output, _ self.lstm(emb) logits self.proj(output) return logits def loss(self, input_ids, labels, mask): logits self.forward(input_ids, mask) return -self.crf(logits, labels, mask)forward 只返回每个位置的发射分数loss 方法在训练时调用。这里使用 torchcrf 提供的 CRF 封装默认实现了维特比解码和负对数似然。关键参数说明如下参数说明padding_idx0与 3.2 节的映射对齐保证 padding 位 embedding 恒为 0batch_firstTrue输入张量形状为 (batch, seq_len)和 DataLoader 默认输出一致maskCRF 计算时跳过 padding 位置不参与转移计算hidden_size // 2双向 LSTM 拼接后正好是 hidden_size最容易出错的是 mask 的构造。mask 必须是 BoolTensor形状与 input_ids 一致valid 位置为 Truepadding 位置为 False。遗漏 mask 会让 CRF 把 padding 位置也当作正常 token 做标签转移测试时 F1 虚高但换成更长的病历后指标立刻回落。embedding 从零开始训练即可数据集规模下能正常收敛想进一步提升可以在医疗语料上单独训练 word2vec 后初始化 embedding但要注意字表不一致时需要重新映射。4.2 训练循环与关键超参数训练循环按常规写法组织优化器用 Adam训练中记录平均 loss并在每个 epoch 末尾用验证集计算实体级 F1。LSTM 类模型梯度爆炸比较常见梯度裁剪是必须加的。optimizer torch.optim.Adam(model.parameters(), lr1e-3) scheduler torch.optim.lr_scheduler.StepLR(optimizer, step_size20, gamma0.1) for epoch in range(60): model.train() total_loss 0.0 for batch_x, batch_y, batch_mask in train_loader: optimizer.zero_grad() loss model.loss(batch_x, batch_y, batch_mask) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step() total_loss loss.item() scheduler.step() valid_f1 evaluate(model, valid_loader)推荐的超参数组合如下表这是一组在 11 个标签、字符级输入下能稳定收敛的组合超参数推荐值调整说明emb_size128增大到 256 提升有限训练变慢hidden_size256双向后实际维度 256batch_size32显存不足时降为 16lr1e-3Adam 默认配置max_norm5.0梯度裁剪阈值经验值step_size / gamma20 / 0.1第 20、40 轮各降一次学习率关于 loss 曲线torchcrf 返回的是负对数似然因此 loss 是正值数值偏大不代表异常重点看相对趋势。如果 loss 前 10 轮完全没有下降优先检查数据加载比如 mask 和 label 的维度是否错位、字符 id 是否全部为 0。训练到 30 轮左右验证集 F1 通常会进入平台期继续训练主要改善低频类别的召回。4.3 批量预测与结果落盘训练完成后把测试语料的每条句子转成 (input_ids, mask)解码时不使用 loss而是调用 crf.decode 返回维特比最优路径。decode 输出是每个句子的标签 id 序列再通过 idx2label 还原成可读标签import torch def predict(model, input_ids, mask): model.eval() with torch.no_grad(): logits model(input_ids, mask) pred model.crf.decode(logits, mask) return [[idx2label[i] for i in seq] for seq in pred]model.eval()与torch.no_grad()需要同时存在的原因eval 切换 BatchNorm 和 Dropout 行为no_grad 关闭自动求导记录。预测时 batch 中的序列长度不齐decode 返回结果前需要把序列回转到原始长度再与真实标签对齐。还原后的标签序列直接写出为与输入同格式的标注文件即可用于后续评测也可以人工抽样检查边界错误模式。5. 实体级 F1 的计算思路与三个有效的收尾技巧5.1 实体级 F1 的正确计算方式评测指标必须按实体级计算。把预测标签序列还原成 (start, end, type) 三元组再计算预测集合与真实集合的交集与并集。单个字符差一点就代表整个实体判错因此实体级 F1 通常低于按字计算的准确率也更接近业务侧对抽取质量的要求。还原逻辑可以直接参考 seqeval 的 BIO 解码思路先扫描 B 和 I 的连续性遇到 B 开始一个新实体遇到 O 或不同类型标签则结束当前实体。自己手写时最要注意边界情况比如实体长度为 1 时只有 B 没有 I以及文件末尾实体被截断的情况。5.2 三个能直接提升实体级 F1 的实用技巧第一个技巧是后处理过滤非法标签序列。即便有 CRF解码结果偶尔也会出现「首个位置是 I-XXX」或「B-BODY 后紧跟 I-DISEASE」这类结构原因多为训练轮次不足或标注噪声。处理方式是扫描预测结果把非法 I 就地改为 O这个规则简单且不会误伤合法边界。第二个技巧是全角字符归一化。病历来源复杂数字、字母和标点经常全角半角混用建议在 3.1 的解析阶段就把全角数字、字母和标点转为半角。注意实体内部的汉字不需要转换只处理数字、字母和标点避免把「」和「CT」这类写法统一后实体边界发生偏移。第三个技巧是筛选验证集时使用分层采样。如果训练集和验证集按文件随机划分后实体类别分布差异较大验证指标会来回震荡。此时应以「实体类别占比」作为分层依据对文件列表做分层采样而不是对句子做分层采样保证每个实体类别在验证集中的比例与训练集接近。5.3 验证训练结果的两个自查手段调参完成后挑一份验证集里长度居中的病历文件把原文、真实标签、预测标签逐行打印到临时文件人工扫一遍最容易发现边界错位。另一种更量化的自查是查看每个实体类别的独立 F1如果 BODY 明显低于其他类别回看预处理阶段是否把部位与检查名称连写的情况误标成两段。把这几步落实到脚本里再对评测脚本本身做一次回归确认它对「精确匹配一个字符」和「错开一个字符」的两组结果给出不同分数然后每次调整预处理后重跑一遍完整流程比对实体级 F1 的变化确认改动确实有效再合入实验记录。本文还有配套的精品资源点击获取