
简介天池瑞金医院MMC人工智能辅助构建知识图谱大赛初赛参赛作品围绕糖尿病相关医疗命名实体识别任务基于条件随机场工具pycrfsuite实现属于人工智能与自然语言处理交叉方向适合参赛选手、NLP学习者及医疗信息处理人员参考。压缩包共包含1699个文件大小约11.65MB主要类型为784个csv表格数据、485个txt原始文本、421个ann实体标注、7个Python源码脚本、1个ipynb分析笔记本及1个Markdown设计文档其中ann标注采用BRAT格式可直接作为监督学习标签csv与txt则提供对应语料与特征素材。整体目录结构清晰便于按数据、标注、代码和文档分模块查找。整套资源从数据预处理、特征工程、模型训练到评估均有代码覆盖设计文档梳理了任务背景、数据说明和调优思路复现或二次开发都比较方便。目前已有145人学习下载对准备医学命名实体识别竞赛或相关项目具有实用参考价值。1. 医疗文本里的命名实体识别为什么pycrfsuite仍是初赛上分利器天池瑞金医院MMC人工智能辅助构建知识图谱大赛初赛核心落在糖尿病相关医疗命名实体识别上。拿到赛题时大多数人第一反应是上BERT、上BiLSTM-CRF但真正把基线跑稳、把F1抬上去的往往是那颗“老药”——条件随机场。基于pycrfsuite实现的CRF管道训练快、特征可解释、调参成本低在标注数据只有一万多条、语义边界不规整的医疗文本场景里反而比深度学习更抗造。这篇笔记就把我打这个比赛时的完整方案拆开讲从赛题理解、数据构造、特征模板到pycrfsuite训练预测和后期纠错每一步都给出能直接复现的代码和参数。适合正要参加类似知识图谱/NER竞赛、或想在医疗文本上快速搭一个可解释实体识别管道的工程师。2. 糖尿病实体识别任务拆解MMC赛题在问什么CRF凭什么能答2.1 医疗命名实体识别的边界与标注体系MMC初赛给的原始语料是瑞金医院糖尿病相关的门诊病历、检验报告和出院小结片段任务是把其中的医学实体按预设类别抽出来。常见标注体系是BMESB表示实体首字M表示实体中间字E表示实体尾字S表示单个字成实体O表示非实体。糖尿病领域实体通常分成症状、疾病名称、检查项目、检验指标、药物、科室、手术等几大类。先明确一个核心认知医疗NER不是简单的“词表匹配”而是序列标注问题。同一段文本里“血糖”可以是症状描述里的主语也可以是检验指标名“胰岛素”可以是药物实体也可以是生理物质。词表匹配做不到上下文消歧所以需要模型利用周围字和词性信息做决策。pycrfsuite把实体识别建模为“对每个字打一个标签”的序列问题通过转移分数和状态分数共同决定最优标签链这就是它和规则方法最本质的差别。2.2 CRF与BiLSTM-CRF的选择逻辑初赛阶段谁更划算先上结论在初赛这种标注量不大、且需要反复迭代特征的中型任务里pycrfsuite的性价比高于深度学习方案。具体原因有三条。第一CRF特征可解释你能明确知道“气象”这个词窗口特征对“症状”标签的影响权重是多少而深度学习是黑匣子出错时很难定位。第二训练成本低pycrfsuite用L-BFGS优化器在几万条样本上跑完迭代只需几分钟同样数据量在GPU上跑一层BiLSTM-CRF至少半小时起步。第三CRF对标注规范敏感的鲁棒性更强少量标注噪声只会轻微影响转移特征而深度模型会把噪声学进embedding里导致后期很难调回来。对比维度pycrfsuite CRFBiLSTM-CRF训练速度5万字符级分钟级半小时级以上特征可解释性高可导出权重低黑匣子依赖硬件CPU即可建议GPU适合数据量万级标注十万级起步调参成本特征模板驱动直观学习率/层数/预训练模型初赛阶段用CRF跑通赛道把特征工程做到极致已经可以拿到不错的排名把pycrfsuite产出的结果作为伪标注去训练后续深度模型也是常见做法。2.3 数据预处理从原始病历文本到BMES序列拿到原始数据后第一步不是写模型而是理解数据格式。通常赛题会给你两个部分一份是带有实体标注的文本用于训练一份是不带标注的文本用于预测。训练文本的格式需要转成模型能吃的序列我一般会写成CoNLL风格import re import pandas as pd def text_to_conll(text, entities): text: 原始文本字符串 entities: 列表, 每个元素是 (实体名, 类别, 起始offset, 结束offset) 返回 list of (字符, BMES标签) tags [O] * len(text) for entity_name, category, start, end in entities: if start 0 or end len(text) or start end: continue if end - start 1: tags[start] S- category else: tags[start] B- category for idx in range(start 1, end - 1): tags[idx] M- category tags[end - 1] E- category return [(ch, tag) for ch, tag in zip(text, tags)] # 示例一条训练样本 text 患者空腹血糖11.2mmol/L诊断为2型糖尿病 entities [ (空腹血糖, 检查指标, 2, 6), (11.2mmol/L, 检验数值, 7, 17), (2型糖尿病, 疾病, 22, 27), ] conll text_to_conll(text, entities) for ch, tag in conll: print(f{ch}\t{tag})这段代码做的事情是把字符和标签对齐。注意实体的offset必须和文本本身严格对应标点符号、空格也算字符位置。如果原文有半角空格或换行要在预处理阶段统一替换成相同长度的占位符否则offset错位会让整条样本白费。病历里常见“血压 120/80”中间多一个空格我一般会先把所有空白符压缩成单空格再交给标注函数处理这样模型不会把空格本身学成特征。2.4 类别不均衡问题糖尿病语料里“症状”和“检查”的边界撕裂医疗病历里的实体频次极不均衡。我跑过的一份统计里“检查指标”类实体数量是“手术”类的二十倍以上。更麻烦的是类别之间语义纠缠同样是“蛋白尿”在“患者蛋白尿阳性”里是检查结果在“伴蛋白尿”里是症状描述。这个边界靠正则基本无能为力只能交给模型的特征去学。处理不均衡的思路有两条。一是把低频类别的样本复制增强但不是复制文本而是复制该类别在上下文中的特征组合。常见做法是保留所有包含低频实体的句子做同义替换——把“尿蛋白”换成“尿微量白蛋白”但实体offset和标签不变。二是调class weightpycrfsuite没有直接暴露类别权重参数但可以通过在训练数据里多重复低频样本达到类似效果。提示初赛阶段不建议用欠采样因为医疗文本里大量句子的“O”标签本身就是化疗上下文的重要组成部分删掉了反而破坏CRF对边界的判断。3. 特征工程是CRF的命门BMES标注、窗口特征与词典特征怎么搭3.1 pycrfsuite特征模板设计从单字到窗口组合的写法pycrfsuite的输入是一串特征字典每个token对应一个特征集合。模型不会自动看上下文窗口特征必须显式写出来。我一般用的是一个5字窗口加词性和词典命中特征def word2features(sent, i, word_lexiconsNone): features { bias: 1.0, word.lower(): sent[i][0].lower(), word.isdigit(): sent[i][0].isdigit(), word.is_upper(): sent[i][0].isupper(), word[-2:]: sent[i][0][-2:], word[:2]: sent[i][0][:2], word[-1]: sent[i][0][-1], } if i 2: features.update({ prev2.word.lower(): sent[i-2][0].lower(), prev2.isdigit(): sent[i-2][0].isdigit(), }) if i 1: features.update({ prev.word.lower(): sent[i-1][0].lower(), prev.isdigit(): sent[i-1][0].isdigit(), }) features[next.word.lower()] sent[i1][0].lower() if i1 len(sent) else END features[next.isdigit()] sent[i1][0].isdigit() if i1 len(sent) else False if i 2 len(sent): features[next2.word.lower()] sent[i2][0].lower() # 词典特征当前字是否命中药物、症状、检查词表 if word_lexicons: for lex_name, lex_set in word_lexicons.items(): features[flex_{lex_name}] sent[i][0] in lex_set return features def sent2features(sent, word_lexiconsNone): return [word2features(sent, i, word_lexicons) for i in range(len(sent))]这一段的核心思路是单字特征永远不够必须包含前两个字和后两个字的特征让CRF看到局部语境。word.lower()把大写病历里的“TANG”和“tang”统一isdigit识别血糖值数字串前后缀特征抓“糖”“病”“素”这类医学构词成分词典特征则把外部知识强行注入。参数上最值得调的是窗口大小我试过3字窗口的效果明显差于5字窗口但7字窗口提升很小且训练时间变长最终停在5字。3.2 外部词典构建利用公开词表与训练集自动挖掘词典特征的有效性直接取决于词表质量。医疗实体词表除了公开的药物词典和疾病词典外更实用的做法是从训练集里自动挖掘。我的做法是先从训练文本里把所有已知实体按类别汇总成初始词表再扩充同义写法和简称比如“糖尿病眼底病变”和“糖网病变”纳入同一关键词族。注意扩充时不要做实体合并词表只是特征类别标签仍然由模型判断。还有个坑训练集里自动收集的词表如果直接应用到测试集有一定概率造成特征泄漏因为训练集词表天然带有标签偏向。常见规避策略是对测试集只做主词表的命中判断不对训练集独有的实体词做精确匹配。也就是说词表命中特征只把“当前字是否属于某一类词汇集合”这个布尔量传给模型具体命中哪个词不告诉它。3.3 标签转移约束让“症状”不能直接跳“检验指标”的隐藏方法CRF和深度学习的一大区别是转移矩阵可见可调。在pycrfsuite里转移特征是从训练数据自动统计出来的但你可以通过人为修改训练标签策略来约束转移路径。比如在训练数据里凡是一段连续文本中出现“诊断为”之后紧接疾病名中间不允许出现“检查指标”的B标签可以通过后处理在标注时把这种冲突样本改写。我倾向于不硬编码规则到特征里而是保留这种转移冲突让模型自己学。原因是医疗文本表达太灵活硬约束会把“免疫组化结果提示……考虑为糖尿病肾病”这种句子里的“结果提示”和“考虑为”之间的实体边界切错。把教训留给模型比留给规则安全。4. pycrfsuite训练与预测格式构造、参数调优与F1迭代闭环4.1 从CoNLL到pycrfsuite训练数据构造X_seq与y_seqpycrfsuite的训练接口不是吃文本而是吃“每条样本的特征序列 标签序列”。所以数据组装是关键路径。import pycrfsuite def prepare_data(conll_sentences, word_lexiconsNone): X_train, y_train [], [] for sentence, labels in conll_sentences: # sentence: list of (字符, 标签) sent_tokens [(ch, ) for ch, _ in sentence] X_train.append(sent2features(sentence, word_lexicons)) y_train.append([label for _, label in sentence]) return X_train, y_train # 假设 conll_sentences 是已经切好句子的训练集 X_train, y_train prepare_data(conll_sentences, word_lexiconsNone) trainer pycrfsuite.Trainer(verboseTrue) for xseq, yseq in zip(X_train, y_train): trainer.append(xseq, yseq) trainer.set_params({ c1: 0.01, # L1正则系数控制特征稀疏性 c2: 0.01, # L2正则系数控制整体过拟合 max_iterations: 100, feature.minfreq: 0, feature.possible_states: True, # 让所有标签状态都参与训练 feature.possible_transitions: True, # 让所有转移都参与训练 }) trainer.train(diabetes_crf.model)set_params里这几个参数是初赛阶段最关键的。c1和c2默认都是0但你基本上不能设成0否则特征权重趋向极端测试集一换就崩。我的经验是c10.01、c20.01能压住过拟合但如果你特征数量很大c1可以提到0.05。max_iterations设120比较稳pycrfsuite内部有早停机制收敛了会自动停设太大不碍事但训练时间会浪费。feature.possible_states和feature.possible_transitions的默认值其实是False为什么不设False初赛数据处理不当会导致部分标签组合在训练集里从未同时出现如果不打开possible选项预测时这些标签组合被直接判为不可能路径F1会莫名掉点。4.2 预测流程与BIO/BMEAS标签解析回实体模型训完后预测并不复杂但要把模型输出的BMES标签串还原成实体这个过程最容易翻车。tagger pycrfsuite.Tagger() tagger.open(diabetes_crf.model) def predict_entities(test_sent, tagger, word_lexiconsNone): xseq sent2features(test_sent, word_lexicons) labels tagger.tag(xseq) entities [] i 0 while i len(labels): label labels[i] if label O: i 1 continue if label.startswith(B-): ent_type label[2:] start i i 1 while i len(labels) and (labels[i] M- ent_type or labels[i] E- ent_type): i 1 if i len(labels) and i - 1 len(labels) and labels[i-1].startswith(E-): end i # end不包含 entity_text .join(ch for ch, _ in test_sent[start:end]) entities.append((entity_text, ent_type, start, end)) else: # 单独的S类型或异常B/M/E序列 i 1 return entities test_sent list(患者自述近日视物模糊门诊查空腹血糖7.8mmol/L) preds predict_entities(test_sent, tagger) for ent in preds: print(ent)这段解析逻辑里最重要的规则遇到B后必须一路吃M直到E如果标签序列是B-M-M-B这种断裂要立即截断前一个实体不能让B跨到下一个B前。病历文本里缩写、漏字很多CRF偶尔会输出这种非法标签链解析层要容忍并截断而不是报错。4.3 多折交叉验证医疗病历不能随机切分医疗赛题一个隐藏陷阱是病历来源不同、文体差异大。如果按行随机切分训练集和验证集同一个患者的多条记录可能同时出现在两边导致验证集F1虚高。正确做法是按病历ID分组切分保证同一个患者的所有记录只落在一侧。pycrfsuite不支持内置交叉验证我自己会先按病历ID分Fold再在每个Fold里独立做特征统计和训练。这么做能看出模型在不同文体上的泛化水平比如住院小结和门诊处方在实体分布上有明显差异。5. CRF踩坑实录标注不一致、特征穿越、参数玄学5.1 标注不一致同一实体在文本不同位置打出不同边界现象训练集F1达到98%验证集F1骤降到82%回看预测结果发现“2型糖尿病”有时被识别成“2型糖尿病”有时只捞出“糖尿病”有时把“型”字丢掉。原因原始训练数据里标注标准不统一。有的标注员把“2型”都包进实体有的只标“糖尿病”CRF学到的是两种边界模式在预测时随机摇摆。解决训练前必须把所有实体边界做归一化。我的做法是先统计同一实体名出现的所有不同边界组合然后按最长边界统一。比如“2型糖尿病”统一为B-M-E-E四字实体“冠心病”统一为三字。如果上下文里出现修饰词“重度”“轻度”则保持修饰词在外、不放入实体。5.2 特征穿越验证指标虚高让你误判模型实力现象模型在验证集上F1非常漂亮把CRF模型保存下来去提交线上分数掉近10个点。原因特征工程阶段使用了整个数据集的统计信息比如词频、全量词典等于验证集的信息已经通过特征字典泄漏给模型。典型泄漏点是把当前字是否在“全量实体词表”作为特征而这个词表包含了验证集里的实体。解决特征构建必须只用训练集部分。做法是将词典特征和词频信息拆成两个数据集版本训练集特征统计基于交叉验证内部训练部分验证集推理时用同一个特征字典但字典内容不能接触验证集的数据。这个原则一定要贯彻在交叉验证里。5.3 参数玄学c1/c2 两组参数导致结果反复横跳现象调c2从0.01改成0.001后验证集F1不升反降然后改回0.01又看到F1涨了一点但再跑一次又掉了同一个参数同一个数据两次结果不一致。原因pycrfsuite的L-BFGS优化有随机性吗严格说没有但如果你特征数量巨大且max_iterations设得偏小权重收敛未稳定结果会接近局部最优的不同位置。此外trainer.holdout参数默认会切一部分训练数据做早停验证这个holdout是随机切分每次跑结果当然不同。解决把holdout设成0关闭早停固定max_iterations为一确定值比如100同时确保特征字典构建顺序固定不乱序。这样多次训练结果就能完全复现。5.4 句子被截断导致实体跨句被切断现象长病历一句话里有换行符模型把换行当成序列结束实体恰好跨过换行边界就断了。原因文本预处理时按段落粗切没有先做分句。解决分句前先把换行符替换成一个特殊占位符“#”模型把它当普通字符连同标签一起学但句子整体必须完整保留到段落结束再切分。切片位置优先选在句号、分号、问号后且只切标签为O的字符处。6. 实体结果的后处理与图谱入图F1之外还要做对齐与错误回收初赛上分往往不在模型结构而在最后一步后处理。这里说两个我验证过有用的技巧。第一个是规则回退对药品类实体配合一份标准药品词典把模型吐出的“拜糖苹”对齐成“阿卡波糖片”既提升实体链接的一致性还能纠正模型边界错误。第二个是片段补全检验指标类实体常有单位被切走比如“空腹血糖11.2mmol/L”模型只识别出“空腹血糖”丢了数值和单位常见处理是遍历模型输出实体如果实体尾部紧接一个数字或单位标识把这个尾巴合并进来再交给人工审核。入图阶段需要把实体序列转成关系三元组。我的习惯是先用基于距离的共现关系做初版知识图谱同一句子里出现的疾病与症状实体构成一条“表现”关系边然后在实体链接时把标准名和别名统一映射到一个节点ID。这个阶段也一样要回头改NER输出因为经常能发现模型漏掉了作为关系尾实体的检查指标这时不是重训而是补充特征词典后增量训练一轮成本很低。整套方案最终得到的效果稳定在验证集F1 8991之间。这个分数在大赛初赛阶段足够进复赛线。往后如果你要加大投入路径是把pycrfsuite的输出转成BI-LSTM-CRF的输入、用BERT做实体边界修正。但坦白地说先用CRF跑通整个数据管道远比一上来端深度学习模型更能帮你理解医疗文本的边界问题。希望这篇笔记能帮你在同样的赛题上少走几步弯路。本文还有配套的精品资源点击获取