☰
中文命名实体识别实战:CCKS2019数据集解压、BIO标注与BiLSTM-CRF基线
2026/10/1 1:04:32 网站建设 项目流程

简介:面向中文自然语言处理学习者的CCKS2019命名实体识别(NER)赛题资源包,紧密围绕中文临床文本实体抽取任务,可用于毕业设计、NLP项目实践及医疗信息提取研究,旨在帮助读者解决中文实体识别中的分词、实体边界划分与类别标注等问题。压缩包内共2696个文件,整体42.36MB,其中txt文档多为数据集、标注语料与使用说明,Python脚本和pyc文件负责模型训练与推理,CSV与Excel表格记录各模型性能对比和实体词频统计,p、hdf5、bin等文件用于存储词向量与训练权重,另有ipynb示例和json配置,便于复现实验。已有308人学习下载。资源核心为Chinese-clinical-NER-master项目,提供基于Java的NER系统实现,涵盖预处理、分词、特征提取、模型训练与评估等完整流程,并附有word2vec、BiLSTM+CRF、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, encoding='utf-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_LEN=128适用于大多数短病历句子,但要注意截断会直接切掉跨过 128 的实体,后面避坑章节专门讲。tag2id把 O 固定在 0,CRF 的转移矩阵会对 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_dim=128, hidden=256): super().__init__() self.embed = nn.Embedding(vocab_size, embed_dim) self.lstm = nn.LSTM(embed_dim, hidden // 2, num_layers=2, batch_first=True, bidirectional=True, dropout=0.5) self.fc = nn.Linear(hidden, tag_size) self.crf = CRF(tag_size, batch_first=True) 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, mask=mask, reduction='mean')

这里最容易接错维度的是nn.Linear(hidden, tag_size):双向 LSTM 的输出维度是hidden,不是hidden // 2,第一次写很容易在这里报维度不匹配。torchcrf 是社区常用的 CRF 库,batch_first=True必须和 LSTM 保持一致;nll返回负对数似然,CRF 的 forward 本身就是损失,不需要再接 CrossEntropy。

参数常用值说明
embed_dim128字向量维度,医学领域字符表小,128 够用
hidden256双向 LSTM 隐层维度,Linear 输入维度等于 256
num_layers2两层双向,对实体边界更敏感
dropout0.5作用于 LSTM 层间,防止过拟合
optimizerAdamlr 1e-3,loss 震荡时降到 5e-4
batch_size32显存不够先减到 16,别动 max_len
epochs30配合 early stopping,patience 设 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, mask=mask) 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', encoding='utf-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内部就是 Viterbi,mask=mask会跳过 padding 位置,这里少传一个参数,实体级分数直接掉几个点。写出格式我按“句子 id、起始、结束、类型”四列示例,实际提交前一定对照说明文件里的模板;如果官方按文档而不是按句给 id,预处理阶段就要把句子 id 映射回文档 id,否则位置全对不上。

4. 评分口径与 F1 计算:实体级严格匹配为什么不能套 sklearn

CCKS2019 这套任务最容易被新手忽视的是评测口径。官方算的是实体级 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, f1

bio_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 下用默认编码打包,中文文件名和文本是 GBK,Linux 端 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_LEN=128在预处理阶段把超过 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-5,warmup 比例 0.1,直接套用原来的学习率基本必翻车。

第三步,做错误分析。每次实验结束,我都会打印实体级混淆矩阵,重点看哪两个类型在相互串、哪个类型的边界总差一个字。CCKS2019 这类医疗 NER 的难点不在模型结构,而在边界和类别语义。这三板斧做完,再换任何新数据都能复用同一套流程。

现在拿到新的 NER 任务,我第一件事一定是先解压统计标签、跑通官方评测脚本,再做模型。这个习惯帮我绕开了太多返工。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询