中文医学命名实体识别实战:从CCKS2019数据到BERT+CRF全流程
发布时间:2026/10/1 16:13:28来源:尧图网络
简介这是一份基于 CCKS2019 中文命名实体识别赛题的完整项目包包含可运行的 Java 源码与 Python 辅助脚本适合 NLP 方向毕业设计、竞赛复现及工程入门者学习。压缩包共 2696 个文件以 txt 数据与标注文本、py/pyc 训练与调用脚本、csv/xlsx 评测与统计表格为主另含 p 模型权重、hdf5/bin 词向量及 ipynb 实验笔记等整体约 42.36MB基本覆盖数据预处理、模型训练、预测评估全流程。内容预览中可见 word2vec、BiLSTM-CRF、bert_sklearn 等方法的性能对比并包含疾病、解剖部位等医疗实体统计说明项目在通用 NER 基础上侧重临床文本挖掘。目前已有 308 人学习适合希望快速上手命名实体识别、并参考 Java 工程实现与深度学习调参思路的开发者。1. CCKS2019中文命名实体识别任务.zip这不是一个让你跑完demo就丢的玩具包从课程作业、技术面试到给医疗文本做后处理你早晚会撞见这个压缩包CCKS2019中文命名实体识别任务.zip。它装的是当年全国知识图谱与语义计算大会评测任务的完整物料解压之后通常能得到标注好实体标签的训练语料、等待预测的测试文本、实体类型说明和官方评测口径。这个任务的常见形态是面向中文电子病历的命名实体识别实体覆盖身体部位、症状描述、疾病诊断、检查项目、检查结果、药物和手术本质上是一个标准的序列标注问题。适合两类人刚入行、想拿真实中文医疗语料把NER从数据处理到指标复现完整走一遍的算法工程师以及要在自己的病历或泛医学文本上做实体抽取、但苦于没有标注数据的落地团队。金融方向的事件抽取评测包结构类似踩坑点基本通用。2. 解开zip先别急着训数据格式、编码与标签统计2.1 解压与文件清单先分清train、dev、test和官方说明拿到这个zip我一般不建议直接双击解压更不建议一上来就写模型。第一件事是看压缩包里到底有什么顺序错了后面全是黑匣子。unzip -l CCKS2019中文命名实体识别任务.zip-l参数只列出文件清单不实际解压。这一步能让你在几十秒内看清目录结构常见结构是train/、test/、README.md、实体标注说明有的包还带着官方评测脚本。先看文件清单是为了确认有没有dev集。很多参赛包只有train和test没有验证集如果没有dev你得自己从train里按9:1切一份出来这件事在写代码之前就要定好不然后面调参没有后悔药。正式解压时有个多年血泪经验Windows下打包的zip到了Linux服务器上容易出中文乱码。推荐这样解unzip -O gbk CCKS2019中文命名实体识别任务.zip -d ccks2019_ner-O gbk让unzip按GBK解码文件名解决Windows打包、Linux解压的乱码问题。如果你的unzip版本不支持-O参数直接换7z7z x CCKS2019中文命名实体识别任务.zip -o./ccks2019_ner解压完不要急着写Dataloader先确认一个关键信息——文件编码。常见做法是用file命令file train.txt期望输出是UTF-8 Unicode text。如果输出类似ISO-8859或Non-ISO extended-ASCII说明文件是GBK编码后面Python读取时必须指定encodinggbk否则第一行就报UnicodeDecodeError。这一步30秒就能做完却能省掉你一下午的翻车时间。2.2 把BIO标注转成模型输入标签编码与token对齐CCKS2019的NER数据几乎都是BIO标注格式每个字符一行字符和标签之间用\t分隔空行代表一个句子结束。实体类型通过B-类型和I-类型表达O表示非实体。先把这种文件读成句子列表def load_bio_file(path): sentences [] chars, labels [], [] with open(path, encodingutf-8) as f: for line in f: line line.strip() if not line: if chars: sentences.append((chars, labels)) chars, labels [], [] continue parts line.split(\t) if len(parts) 2: # 少数数据文件不缺tab却混入了空格或全角空格这里兜底 parts line.replace(\u3000, ).split() chars.append(parts[0]) labels.append(parts[1]) if chars: sentences.append((chars, labels)) return sentences这段代码的逻辑很直白按空行切句子每行按\t拆成字符和标签。真正容易翻车的是parts line.replace(\u3000, ).split()这行兜底逻辑。我遇到过不止一份标注文件不知什么原因把分隔符混成了全角空格U3000直接split(\t)会把字符和标签裁开导致后面的实体边界全部错位。这个坑在第4章还会细讲。读进来之后第二步是把字符序列转成BERT能吃的输入。这里有一个新手最容易踩的坑中文BERT的tokenizer按字切分但遇到数字、英文、连续标点时会把一个字符切成多个subword。如果直接让每个字符对应一个标签subword一多标签序列和token序列就对不齐了。from transformers import BertTokenizerFast import torch tokenizer BertTokenizerFast.from_pretrained(bert-base-chinese) label2id {O: 0} # 从全部标签里收集类型给每个标签分配id for label in sorted(all_labels): if label not in label2id: label2id[label] len(label2id) def align_labels_with_tokenizer(chars, labels, max_len128): enc tokenizer( chars, is_split_into_wordsTrue, truncationTrue, paddingmax_length, max_lengthmax_len, return_tensorspt ) word_ids enc.word_ids() aligned [] prev None for wid in word_ids: if wid is None: # [CLS]、[SEP]、[PAD] 对应的标签CRF解码时靠mask忽略 aligned.append(label2id[O]) elif wid ! prev: # 当前token是某个原始字符的第一个subword继承它的标签 aligned.append(label2id[labels[wid]]) else: # 同一个原始字符的后续subword统一给O避免重复计数 aligned.append(label2id[O]) prev wid return enc[input_ids].squeeze(0), enc[attention_mask].squeeze(0), torch.tensor(aligned)这里的关键参数是is_split_into_wordsTrue。加上它tokenizer会把输入当成一个已经切好词的列表而不是重新分词配合word_ids()返回的映射就能把每个token准确对应回原始字符位置。aligned里wid is None的位置是特殊token标签随便填因为后面CRF的mask会把这些位置屏蔽掉。这比手写BMES到BERT的转换要省心得多也是当前transformers库的标准做法。2.3 先统计再动手实体分布和长度分布决定你的超参数据能读进来了下一个动作不是开训而是统计。我见过太多人忽略这一步直接按默认参数训完才发现某个实体类型压根没学到或者max_len设短了长文本里的实体全被切没。from collections import Counter import numpy as np def extract_entities(label_seq): spans [] start None cur_type None for i, lab in enumerate(label_seq): if lab O: if start is not None: spans.append((cur_type, start, i - 1)) start None continue prefix, _, etype lab.partition(-) if prefix B: if start is not None: spans.append((cur_type, start, i - 1)) start, cur_type i, etype elif prefix I and start is None: # I开头属于脏数据容错成B不然后面具实体统计会漏 start, cur_type i, etype if start is not None: spans.append((cur_type, start, len(label_seq) - 1)) return spans type_counter Counter() for chars, labels in train_sentences: for etype, s, e in extract_entities(labels): type_counter[etype] 1 lengths np.array([len(c) for c, _ in train_sentences]) print(type_counter) print(长度分布 p50/p90/p99:, np.percentile(lengths, [50, 90, 99]))统计结果直接决定后面几个关键选择。第一类别不平衡如果“症状描述”有上万条而“手术”只有几十条模型大概率会把手术相关词全预测成O或症状这时候要考虑对低频类别过采样或者换用支持类别权重的交叉熵解码。第二长度分布如果p90在110字左右max_len128是合理起点如果直接给整篇病历设512显存压力大训练速度也慢。第三抽20条样本人工看一眼BIO标签确认没有“B后面不跟I”之类的标注错误。这半小时的功课比后面多调十个参数都值。3. 用BERTCRF跑通CCKS2019任务从训练脚本到官方指标3.1 选型理由为什么我不推荐在BERT后面再接BiLSTMCCKS2019这个任务的时代背景很特殊2019年BERT刚成为主流好多参赛队伍还在用字向量加BiLSTM加CRF的经典组合能跑BERT的队伍已经算走在前面了。但现在是2026年如果你还照着当年的冠军方案搭一个BERT加BiLSTM加CRF我的建议是省省。方案训练成本相对效果工程复杂度字向量 BiLSTM CRF低单卡也能跑基线偏弱小语料吃亏低BERT BiLSTM CRF高整体慢30%-50%通常比BERT-CRF高不到1个点经常持平中BERT CRF中稳定绝大多数NER任务够用低道理不难理解BERT输出的上下文表示已经很强了BiLSTM在它之上能学到的增量其实很有限反而引入更多超参数和训练时间。BERT后面接CRF是为了补上标签之间的转移约束——中文NER里B后面必须跟同类型的IB后面直接跳B是非法路径CRF天然建模这种约束而单用softmax会漏掉这一点。常见做法是直接上bert-base-chinese加CRF层把数据跑通之后再拿MacBERT或Roberta变种去替换编码器每个模型训练一轮对比F1而不是一开始就叠最重的结构。3.2 最小训练脚本模型定义、数据加载与loss计算模型定义我一般这样写用torchcrf里的CRF模块逻辑干净不用自己写维特比解码from transformers import BertModel from torchcrf import CRF import torch.nn as nn class BertCRF(nn.Module): def __init__(self, num_labels): super().__init__() self.bert BertModel.from_pretrained(bert-base-chinese) self.dropout nn.Dropout(0.1) self.classifier nn.Linear(self.bert.config.hidden_size, num_labels) self.crf CRF(num_labels, batch_firstTrue) def forward(self, input_ids, attention_mask, labelsNone): outputs self.bert(input_idsinput_ids, attention_maskattention_mask) emissions self.classifier(self.dropout(outputs.last_hidden_state)) if labels is not None: # torchcrf返回的是negative log likelihood越小越好直接当loss用 return self.crf(emissions, labels, maskattention_mask.bool()) return self.crf.decode(emissions, maskattention_mask.bool())这个结构的核心在于BERT输出每个token的768维向量经过dropout抑制过拟合再线性映射到标签数量维度得到发射分数emissions。CRF拿这些发射分数和真实标签序列计算损失同时学习标签间的转移矩阵。attention_mask.bool()告诉CRF哪些位置是paddingpadding位置不参与loss也不参与解码。对应的训练循环参数是经过验证的起点from torch.utils.data import DataLoader from torch.nn.utils import clip_grad_norm_ model BertCRF(num_labelslen(label2id)) optimizer torch.optim.AdamW(model.parameters(), lr2e-5) for epoch in range(3): model.train() for input_ids, attention_mask, label_ids in loader: loss model(input_ids, attention_mask, label_ids) loss.backward() clip_grad_norm_(model.parameters(), 1.0) # 防止CRF训练后期loss偶发爆炸 optimizer.step() optimizer.zero_grad()几个参数说明一下。lr2e-5是BERT微调的经典起点比这大容易让预训练权重被冲坏比这小收敛太慢。epochs3对CCKS2019这种几千句规模的语料够用跑多了dev集F1反而可能下降。clip_grad_norm_(1.0)是很多人的血泪经验CRF的loss在某个batch上偶尔会突然飙高加了梯度裁剪基本能压住。batch_size在16到32之间选取决于显存BERT-base在12G显存下跑batch_size16比较稳。3.3 预测、提交与官方指标精确匹配F1的隐性要求训练完拿到的是token级别的标签序列但官方评测不看你标签预测得多漂亮只看一个数实体级别的精确匹配F1。也就是说一个实体要被算对预测的起始位置、结束位置、实体类型三者必须和gold完全一致多一个字符、少一个字符都算错。先把token级预测转成实体列表再按官方提交格式落盘。常见格式是每行一个实体字段包括文档编号、起始偏移、结束偏移、类型、实体文本def build_submission(doc_ids, token_seq_batch, pred_batch, out_path): with open(out_path, w, encodingutf-8) as f: for doc_id, tokens, pred_labels in zip(doc_ids, token_seq_batch, pred_batch): spans extract_entities(pred_labels) for etype, s, e in spans: text .join(tokens[s:e1]) f.write(f{doc_id}\t{s}\t{e}\t{etype}\t{text}\n)这里需要注意extract_entities用的是第2.3节那个函数它处理B、I、O三种标签并还原出实体span。如果预测结果里出现“B-疾病”后面直接接“I-症状”CRF解码时会被转移矩阵拦掉所以这种情况很少见但BIO手工后处理时仍要写兜底逻辑。提交之前我建议先在本地dev集上用官方评测脚本跑一遍确认输出格式能被识别。常见的评测脚本是独立Python脚本或可执行jar用法写在README里。不要拿着自己的F1代码就去估官方分数两个口径可能差好几个点。dev集上格式通了再动测试集这是最稳妥的流程。4. 避坑解压乱码、伪加密与标注错位的5条实战记录4.1 文件名乱码、GBK内容读不出来现象Windows下解压一切正常到了Linux服务器上unzip解出来的中文文件名全是乱码Python用utf-8读数据文件直接抛UnicodeDecodeError。原因压缩包在Windows里用GBK/CP936编码记录文件名和文件内容Linux的unzip默认按UTF-8解两边一对不上就花了。解决解压时用unzip -O gbk或7z读文件时不要死磕单一编码写一个带降级的读取函数。def smart_read(path): for enc in (utf-8, gbk, gb18030): try: with open(path, encodingenc) as f: return f.read() except UnicodeDecodeError: continue raise RuntimeError(f{path} 编码无法识别用 file 命令确认后手工处理)gb18030是GBK的超集实际覆盖字符更全放在最后兜底效果最好。数据文件能读出来之后记得统一转存成utf-8再进后续流程免得每次加载都猜编码。4.2 zip伪加密提示要密码其实数据没加密现象解压CCKS2019任务包时unzip突然提示输入密码试了空密码和常见密码都不对但分享者明确说没设过密码。原因zip文件的General purpose bit flag里有一个加密标志位某些工具在压缩时把这个位置了1实际数据流并没有加密。这种状态叫zip伪加密在比赛数据传播过程中很常见多半是有人为了防自动抓包也可能是压缩工具抽风。解决先判断到底是不是真加密再决定怎么办。zipinfo -v data.zip | grep -i encrypt 7z x data.zip -o./datazipinfo -v的输出里如果只有加密标志、没有AES-256之类的加密算法描述基本可以断定是伪加密。7z处理伪加密通常不需要密码直接解开。如果手头只有Pythonzipfile在这种状态会抛RuntimeError: File ... is encrypted这是正常现象不代表文件损坏换7z或unzip -P配合空密码再试即可。4.3 全角空格、全角标点和\t混用导致实体偏移现象用line.split(\t)解析BIO文件时某些行的第二个字段不是标签而是字符文本的一部分导致解析出的句子长度和标签长度对不上训练时loss直接报shape mismatch。原因标注文件在流转过程中分隔符被替换成了全角空格U3000或全角冒号这种字符肉眼看着像空格但\t切不到。解决清洗时把全角空格显式转成半角再做分割。line line.strip().replace(\u3000, ).split(\t) if len(line) 2: line line.strip().replace(\u3000, ).split()注意清洗只能针对字符和标签之间的分隔符不能动字符本身的标点。实体文本里的句号、逗号是全角还是半角要原样保留因为官方评测对比的是原始文本偏移位置清洗过度会让预测span对不上gold。4.4 测试集没有gold标签提交时doc id顺序错位现象本地dev集F1跑得不错一提交测试集分数低到离谱甚至直接报格式错误。原因测试集大概率只给原始文本不带标注。预测循环里如果用了DataLoader的shuffleTrue或者对测试集按长度排序来加速输出的预测结果和doc id就对不上位了。官方评测脚本逐行比对错一行意味着后面全错。解决测试集loader必须保证顺序。test_loader DataLoader(test_dataset, batch_size32, shuffleFalse)同时维护一份与原始文本顺序一致的doc id列表。预测时把doc id、token序列、实体span三元组同步收集提交前再按doc id排序写文件。我见过有人按max_len分桶加速最后合并时没还原顺序dev集上99分的模型提交变0分这个坑值得记一笔。4.5 长病历被截断实体从中间被切断现象把整篇出院小结直接塞给模型显存爆了把max_len设成128F1反而比不截断还低某些长实体永远预测不对。原因电子病历里的主诉、现病史经常超过200字一段文本把实体切成两半后模型永远学不到完整上下文。解决用滑窗切句窗口之间保留重叠。def split_into_windows(text, max_chars110, overlap10): if len(text) max_chars: return [text] windows [] i 0 while i len(text): windows.append(text[i:i max_chars]) i max_chars - overlap return windowsmax_chars110留给BERT的tokenizer余量加上special token后不超过bert-base-chinese常用的128长度限制overlap10保证窗口交界处不会正好切在实体中间。预测完对重叠区域的实体做去重保留span更长或置信度更高的那个。实测这一招对电子病历长文本的实体召回提升很明显。5. 想再涨一个点F1词典注入、自训练与批量推理5.1 词典注入把训练集里出现过的所有实体文本收集成词典预测阶段在CRF标记为O的区域做最长匹配召回。如果某个连续文本片段能在词典里命中且长度大于2就把这段修正为对应类型的实体。注意只用它召回模型漏掉的实体不要覆盖模型已经预测出的结果否则会把正确的预测改错。词典匹配的准确率一般要接受它引入少量噪声换来的是低频实体召回率上升。5.2 自训练伪标签测试集虽然没标注但模型可以在上面预测出实体。把置信度超过0.9的预测结果当作伪标签拼回训练集再训一轮对CCKS2019这种标注量不算大的任务通常能提0.5到1.5个F1。置信度阈值是唯一的旋钮设太高伪标签数量不够设太低噪声压过信号。我的教训是不要迭代超过两轮第二轮涨点明显变小第三轮可能直接回吐因为伪标签的噪声会随着迭代自我强化。5.3 批量推理加速推理时统一走model.eval()加torch.no_grad()把测试样本按长度分桶、每个桶内padding到相同长度再batch跑比逐个句子预测能省一半时间。CRF解码本身是维特比decode支持批量输入只要attention_mask正确传入即可。如果线上服务对时延敏感把bert-base-chinese换成更轻量的中文ELECTRA或蒸馏后的模型F1掉零点几个点时延能降一大截。我现在拿到任何比赛包第一件事都不是写模型而是先花半小时做三件事解压后查文件编码、跑通官方的评测脚本、统计实体分布和长度分布。这三件事做完模型选型、窗口长度、是否处理类别不平衡基本都有数了后面不会走太多弯路。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网