
简介面向中文自然语言处理学习者的CCKS2019命名实体识别NER赛题资源包紧密围绕中文临床文本实体抽取任务可用于毕业设计、NLP项目实践及医疗信息提取研究旨在帮助读者解决中文实体识别中的分词、实体边界划分与类别标注等问题。压缩包内共2696个文件整体42.36MB其中txt文档多为数据集、标注语料与使用说明Python脚本和pyc文件负责模型训练与推理CSV与Excel表格记录各模型性能对比和实体词频统计p、hdf5、bin等文件用于存储词向量与训练权重另有ipynb示例和json配置便于复现实验。已有308人学习下载。资源核心为Chinese-clinical-NER-master项目提供基于Java的NER系统实现涵盖预处理、分词、特征提取、模型训练与评估等完整流程并附有word2vec、BiLSTMCRF、BERT等方案的性能报告以及疾病、解剖部位、手术等临床实体的标注数据与词频统计。借助该包可理解CCKS2019官方赛题要求、数据集格式与评价指标借鉴成熟工程化实现是深入掌握中文命名实体识别全流程的实用参考。1. CCKS2019 中文命名实体识别任务这个 zip 里到底装了什么CCKS2019 中文命名实体识别任务这个 zip是 2019 年 CCKS 评测中面向电子病历的中文命名实体识别数据包官方把训练集、测试集和评测说明打在一个 zip 里发布要求模型从病历句子中识别症状、检查、治疗等医疗实体的边界和类型。这份数据真正值钱的地方在于它自带官方评测口径文本干净、实体类型集中特别适合做序列标注模型的基线对比。适合两类人想用公开医学文本练 NER 的学生和要给病历做实体抽取落地的工程师。先说结论这套任务卡人的通常不是模型而是 zip 解压、文件编码和评测口径这三个前置环节。2. 解压与数据格式zip 乱码背后是编码问题标注格式背后是 BIO 约定拿到 zip 后第一件事不是解压就开跑而是先确认压缩包和文件编码没问题。CCKS2019 的数据包多为 Windows 环境下打包中文文件名和文件内容经常是 GBK 编码。这一节先把解压和标注格式两个前置问题处理干净再谈模型。2.1 先安全解压GBK 文件名、zip 伪加密和右键压缩的坑先在服务器上看看压缩包里有什么避免解出一堆路径还找不到数据# 只列目录不实际解压 unzip -l CCKS2019中文命名实体识别任务.zip | head -30 # 文件名乱码时用 -O 指定 GBK 解码 unzip -O GBK CCKS2019中文命名实体识别任务.zip -d ccks2019/ # unzip 版本不支持 -O 时换成 7-Zip 命令行 7z x CCKS2019中文命名实体识别任务.zip -occks2019/-l只列压缩包内容不落地文件适合先确认有没有 readme 和评测脚本-O GBK是让 Info-ZIP 用 GBK 解析文件名这个参数在部分发行版编译的 unzip 里没有报invalid option -- O时就用 7z 兜底。-d指定解压目录养成习惯别把一堆文件散在当前目录。如果服务器上 unzip 都没装机器又是内网最好提前备好 unzip 的离线安装包按“zip linux 离线下载”那类教程操作本质就是先下 rpm 再拷进去装。真正决定数据能不能读的是文件名编码不是解压工具。另一个常见现象是 zip 伪加密解压时弹窗要密码文件实际没有任何保护。判断方法很简单用 Python 的 zipfile 去读不传密码能正常读出条目内容基本就是伪加密。社区里被叫成“zip 密码移除”的教程九成说的就是伪加密场景处理方式是清掉 ZIP 文件头 General Purpose Bit Flag 里的加密位zipceno 这类小工具就是干这个的。真加密的压缩包不存在“移除密码”的说法先回发布页找官方公布解压密码这条路别走歪。注意清理伪加密只适用于你确实持有的压缩包涉及非本人文件的情况请先获得授权。解压时如果报 missing zip entry 或 CRC 校验错误基本是包下载不完整重新下载比修包省事别在断掉的包上浪费时间。文件名的坑也要记一笔不要在 Windows 10 上直接右键“压缩为 zip”再传到 Linux。右键菜单产出的包对中文文件名的编码处理不一致很容易复现上面那排乱码。我一般用 7-Zip 打包时勾选 UTF-8 文件名或者干脆在 Linux 上用zip -r打包跨平台省心。如果unzip -O和 7z 都不顺手还能用 Python 转码解压顺带把文件名修正import zipfile with zipfile.ZipFile(CCKS2019中文命名实体识别任务.zip) as zf: for info in zf.infolist(): # zipfile 默认按 cp437 解码文件名碰到 GBK 中文就乱码 raw info.filename.encode(cp437, ignore) name raw.decode(gbk, ignore) target ccks2019/ name with zf.open(info) as src, open(target, wb) as dst: dst.write(src.read())这段代码的核心是把文件名先还原成原始字节再按 GBK 重新解码。info.filename是 zipfile 已经按 cp437 解码过的字符串encode(cp437)是逆操作如果压缩包本身是 UTF-8 文件名decode(gbk)会得到乱码那时改成decode(utf-8)就行。判断依据看文件名里有没有成片的菱形问号。2.2 看懂标注文件BIO 标签、实体类别与统计脚本解压后一般能看到训练数据、测试数据和一份说明文档。这套任务最常见的标注组织方式是纯文本每行一个字加一个标签空格或制表符分隔空行代表句子边界。标签是 BIO 体系B-xxx 表示实体首字I-xxx 表示实体其余字O 表示非实体。实体类别在任务说明里定义常见的是症状、检查、疾病、治疗、身体部位五类这套 zip 具体用哪几个缩写打开说明文件对照一遍确认。拿到文件先别急着搭模型我一般先跑一个统计脚本确认句子数和标签分布这一步能省掉后面大量调参时间from collections import Counter def load_bio(path): sents, tags_seq [], [] chars, tags [], [] with open(path, encodingutf-8) as f: for line in f: line line.strip() if not line: if chars: sents.append(chars) tags_seq.append(tags) chars, tags [], [] continue parts line.split() if len(parts) 2: chars.append(parts[0]) tags.append(parts[1]) if chars: sents.append(chars) tags_seq.append(tags) return sents, tags_seq sents, tags_seq load_bio(train.txt) print(句子数:, len(sents)) tag_counter Counter() for tags in tags_seq: tag_counter.update(tags) print(标签统计:, tag_counter) entity_counter Counter() for tags in tags_seq: for t in tags: if t.startswith(B-): entity_counter[t[2:]] 1 print(实体类别统计:, entity_counter)line.split()不传参数会把连续空格和制表符都切开省得手动替换\t空行跳出但chars不为空时才收尾避免文件末尾漏掉最后一句。统计实体数量时只数 B- 开头的标签I- 只是延续数 I- 会把实体长度当成实体数这是新手最容易算错的地方。标签分布核对完后可以按实体类别列一张对照表方便后续写映射类别BIO 标签前缀病历示例身体部位B-body / I-body左肺上叶症状B-sym / I-sym咳嗽、发热检查B-exam / I-exam血常规、CT疾病B-dis / I-dis肺炎治疗B-treat / I-treat抗感染治疗以你解压后的实际标注为准缩写可能不同但结构一定还是这套 BIO。到这里数据侧的前置问题清完了可以进模型。3. 用 BiLSTM-CRF 跑通 CCKS2019 中文命名实体识别基线预处理、参数与提交格式2019 年这个任务的时间点上主流提交方案就是 BiLSTM-CRF放到今天它依然是验证数据没跑偏的最快手段训练快、显存占用低、出结果可预期。先跑通它再谈 BERT 微调上分才上得心里有数。3.1 预处理脚本建字表、标签表与定长填充把上一节的 BIO 数据转成模型输入需要做三件事字到 id、标签到 id、定长 padding。电子病历里中文字符占绝大多数按字切分就够不用分词。import torch from torch.utils.data import Dataset from collections import Counter MAX_LEN 128 def build_vocab(sents, tags_seq): char_counter Counter() for s in sents: char_counter.update(s) char2id {w: i 2 for i, (w, _) in enumerate(char_counter.most_common())} char2id[PAD] 0 char2id[UNK] 1 tag2id {O: 0} for tags in tags_seq: for t in tags: if t not in tag2id: tag2id[t] len(tag2id) return char2id, tag2id class NERDataset(Dataset): def __init__(self, sents, tags_seq, char2id, tag2id): self.data [] for chars, tags in zip(sents, tags_seq): char_ids [char2id.get(c, 1) for c in chars][:MAX_LEN] tag_ids [tag2id.get(t, 0) for t in tags][:MAX_LEN] pad MAX_LEN - len(char_ids) char_ids [0] * pad tag_ids [0] * pad self.data.append((torch.tensor(char_ids), torch.tensor(tag_ids))) def __len__(self): return len(self.data) def __getitem__(self, idx): return self.data[idx]char2id 里PAD固定为 0、UNK固定为 1真实字符从 2 开始编号这是为了让 mask 逻辑统一用x ! 0判断MAX_LEN128适用于大多数短病历句子但要注意截断会直接切掉跨过 128 的实体后面避坑章节专门讲。tag2id把 O 固定在 0CRF 的转移矩阵会对 O 序列产生强先验这符合直觉因为大部分字本来就是 O。3.2 模型结构与 4 个必调参数embedding、隐层、dropout 和学习率模型用三层拼起来Embedding 查字向量双向 LSTM 编码上下文Linear 把隐层投影到标签数最后套线性链 CRF。CRF 的作用是显式约束 BIO 转移比如 I-sym 前面必须是 B-sym 或 I-sym这类状态约束 LSTM 学起来慢且易乱CRF 天生适合干这个。import torch.nn as nn from torchcrf import CRF class BiLSTMCRF(nn.Module): def __init__(self, vocab_size, tag_size, embed_dim128, hidden256): super().__init__() self.embed nn.Embedding(vocab_size, embed_dim) self.lstm nn.LSTM(embed_dim, hidden // 2, num_layers2, batch_firstTrue, bidirectionalTrue, dropout0.5) self.fc nn.Linear(hidden, tag_size) self.crf CRF(tag_size, batch_firstTrue) def forward(self, x): h, _ self.lstm(self.embed(x)) return self.fc(h) def nll(self, x, tags, mask): emissions self.forward(x) return -self.crf(emissions, tags, maskmask, reductionmean)这里最容易接错维度的是nn.Linear(hidden, tag_size)双向 LSTM 的输出维度是hidden不是hidden // 2第一次写很容易在这里报维度不匹配。torchcrf 是社区常用的 CRF 库batch_firstTrue必须和 LSTM 保持一致nll返回负对数似然CRF 的 forward 本身就是损失不需要再接 CrossEntropy。参数常用值说明embed_dim128字向量维度医学领域字符表小128 够用hidden256双向 LSTM 隐层维度Linear 输入维度等于 256num_layers2两层双向对实体边界更敏感dropout0.5作用于 LSTM 层间防止过拟合optimizerAdamlr 1e-3loss 震荡时降到 5e-4batch_size32显存不够先减到 16别动 max_lenepochs30配合 early stoppingpatience 设 5训练时还有两个不成文的习惯梯度裁剪设 5.0不裁的话 CRF 的 loss 偶尔会冲成 NaN学习率一上来别乱调先用 1e-3 跑 10 个 epoch 看 loss 曲线再决定。你要是上来就套 BERT 那套 5e-5 的节奏BiLSTM-CRF 收敛会慢到怀疑人生。3.3 预测与提交CRF 解码、实体还原与结果写出预测阶段和训练最大的区别是走 Viterbi 解码把每个位置的标签 id 解出来再还原实体。这里必须把 mask 传给 CRF否则 padding 部分会被解出一堆假实体。model.eval() id2tag {v: k for k, v in tag2id.items()} predictions [] with torch.no_grad(): for char_ids, _ in data_loader: char_ids char_ids.to(device) mask char_ids ! 0 emissions model(char_ids) batch_tags model.crf.decode(emissions, maskmask) for seq_ids, seq_tags in zip(char_ids, batch_tags): tags [id2tag[t] for t in seq_tags] predictions.append(bio_to_entities(tags)) with open(result.txt, w, encodingutf-8) as f: for sent_id, ents in enumerate(predictions): for start, end, etype in ents: f.write(f{sent_id}\t{start}\t{end}\t{etype}\n)crf.decode内部就是 Viterbimaskmask会跳过 padding 位置这里少传一个参数实体级分数直接掉几个点。写出格式我按“句子 id、起始、结束、类型”四列示例实际提交前一定对照说明文件里的模板如果官方按文档而不是按句给 id预处理阶段就要把句子 id 映射回文档 id否则位置全对不上。4. 评分口径与 F1 计算实体级严格匹配为什么不能套 sklearnCCKS2019 这套任务最容易被新手忽视的是评测口径。官方算的是实体级 F1一个预测实体必须和标注实体的起止位置、实体类型完全一致才会计数边界差一个字就算错。4.1 从 BIO 还原实体边界和类型对不上就不算分很多人训练时用 sklearn 的 classification_report 看分数那是 token 级指标只关心每个字分对没有实体边界错一个字照样拿高分。token 级 F1 到 0.95 的模型实体级 F1 可能只有 0.85中间差的十个点全在边界上。def bio_to_entities(tags): entities [] cur_type, start None, None for i, tag in enumerate(tags): if tag.startswith(B-): if cur_type is not None: entities.append((start, i - 1, cur_type)) cur_type, start tag[2:], i elif tag.startswith(I-): if cur_type is None or tag[2:] ! cur_type: if cur_type is not None: entities.append((start, i - 1, cur_type)) cur_type, start None, None else: if cur_type is not None: entities.append((start, i - 1, cur_type)) cur_type, start None, None if cur_type is not None: entities.append((start, len(tags) - 1, cur_type)) return set(entities) def entity_f1(gold_tags_list, pred_tags_list): gold set() pred set() for tags in gold_tags_list: gold | bio_to_entities(tags) for tags in pred_tags_list: pred | bio_to_entities(tags) correct len(gold pred) p correct / len(pred) if pred else 0.0 r correct / len(gold) if gold else 0.0 f1 2 * p * r / (p r) if (p r) else 0.0 return p, r, f1bio_to_entities的返回值用(start, end, type)三元组天然就是严格匹配起点、终点、类型三者都相等才会出现在集合交集里。代码里对 I- 开头但类型对不上的情况做了容错先把已累计实体存掉再重置避免解码出错时把整句标签带偏虽然 CRF 理论上不允许这种非法序列但自己调试解码器时还是会碰到。4.2 边界偏差、嵌套实体与类型串扰分数上不去的三个原因如果实体级 F1 明显低于 token 级问题基本出在三件事上。第一是边界偏移。模型把“左肺上叶”预测成“左肺”把“抗感染治疗”预测成“感染治疗”每个实体都差一头一尾。排查办法是统计错误样本里预测 span 和真实 span 的重叠比例如果大部分错误都是这种差一两个字的说明问题出在 CRF 转移约束或 B 标签预测上而不是特征不够。第二是嵌套实体。病历里“右上肺肺癌”同时包含部位“右上肺”和疾病“肺癌”BIO 只能表达扁平实体模型被迫二选一官方评分又不认嵌套这类样本就是当前表示方法的天花板。我在错误分析里会单独统计嵌套样本占比这样能知道现在分数上限在哪不至于盲目调参。第三是类型串扰。症状和疾病在很多句子里只差一个词“肺炎”在病历里被标成症状并不罕见。这个靠 BiLSTM 已经很难再涨常见做法是换预训练模型或者把类别名做成显式输入特征。实体级 F1 想上一两个点与其反复改模型结构不如先把评测口径和错误类型看清楚。提示先跑通官方评测脚本再调模型这是唯一靠谱的上分顺序。本地永远用实体级指标token 级分数只适合做训练监控。5. CCKS2019 命名实体识别避坑指南5 条踩过的坑下面 5 条是我在这套任务上踩过的坑按出现频率排每一条都按现象、原因、解决三步说清楚。5.1 zip 解压后文件乱码现象在 Linux 服务器上unzip解压文件名变成一串乱码用 vim 打开数据文件正文也是乱码。原因打包方在 Windows 下用默认编码打包中文文件名和文本是 GBKLinux 端 unzip 默认按 UTF-8 处理。解决优先unzip -O GBK重新解压系统 unzip 不支持-O就用 7z文件内容乱码的话用iconv -f GBK -t UTF-8转码后再喂给模型。转码完必须重新跑一遍标签统计确认标签字符没有坏掉再继续。5.2 训练标签和测试标签对不上现象训练集统计出九个标签测试集一跑就报 KeyError或者预测结果几乎全是 O。原因没读说明文件里的标签映射测试集包含了训练集没有的实体类型或者发布方中途更新过标签命名。解决先对全量数据做一次标签统计见过所有标签再建表代码里对未知标签要兜底不要直接抛异常真碰上版本不一致回发布页核对说明别自己猜。5.3 max_len 截断把长实体切两半现象训练 loss 正常实体级 F1 却比预期低两三个点且错误集中在长句子上。原因MAX_LEN128在预处理阶段把超过 128 字的句子从中间切掉实体后半段变成纯 O严格匹配直接判错。解决先统计句子长度分布95% 句子在 128 以内就保持否则提到 256。更稳的做法是按病历原始段落切分而不是按空行切句尽量别让实体被拦腰截断。5.4 高频实体霸榜低频实体几乎不识别现象整体 F1 看着还行分实体类别一看某类召回率只有 0.3。原因电子病历里“症状”类样本动辄几千“治疗”或“检查”只有几百模型容量都学给了高频类。解决评测时按类型分别报告 P/R/F1别只看整体对低频类别做样本采样或损失加权如果业务能接受把低频实体合并到上位语义类再评估。5.5 本地复现分数和提交分数对不上现象本地脚本算出 F1 0.92提交上去官方给 0.87调参也追不回差距。原因评测口径不一致。本地用了 token 级 F1或者脚本对空行、标点的处理规则和官方不同。解决先在训练集上跑通官方评测脚本确认自己的实体级 F1 和脚本输出一致再提交。本地计算严格按(start, end, type)三元组来永远不要拿 sklearn 的结果当实体级分数汇报。6. 把 CCKS2019 当自己的 NER 试验床三个进阶验证技巧基线跑通之后这个 zip 的价值才真正出来——它是一块能反复用的试验田。我会按下面三步往下做。第一步自己切 dev 集。官方只给 train 和 test测试集没有标签别在这上面空耗。我会从 train 里按句子随机切 10% 出来当 dev切之前固定随机种子import random random.seed(42) idx list(range(len(sents))) random.shuffle(idx) dev_idx, train_idx idx[:int(0.1 * len(idx))], idx[int(0.1 * len(idx)):]固定种子的好处是每次实验对比的都是同一份 dev否则两个模型的分差可能只是数据切分带来的噪音。第二步上 BERT 微调。常见做法是把 Embedding 换掉BiLSTM 保留或直接删掉用 bert-base-chinese 接 Linear 加 CRF。学习率必须从 BiLSTM 的 1e-3 降到 2e-5 到 5e-5warmup 比例 0.1直接套用原来的学习率基本必翻车。第三步做错误分析。每次实验结束我都会打印实体级混淆矩阵重点看哪两个类型在相互串、哪个类型的边界总差一个字。CCKS2019 这类医疗 NER 的难点不在模型结构而在边界和类别语义。这三板斧做完再换任何新数据都能复用同一套流程。现在拿到新的 NER 任务我第一件事一定是先解压统计标签、跑通官方评测脚本再做模型。这个习惯帮我绕开了太多返工。希望帮到你。本文还有配套的精品资源点击获取