ARTICLE DETAIL

建站实战干货

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

pycrfsuite实战:天池MMC糖尿病命名实体识别与特征工程

2026/9/28 11:43:01 拓冰建站 浏览量
pycrfsuite实战:天池MMC糖尿病命名实体识别与特征工程 简介针对天池瑞金医院MMC人工智能辅助构建知识图谱大赛初赛的参赛作品围绕糖尿病相关医疗命名实体识别任务基于pycrfsuite完成实现。资源面向参赛选手及对医疗信息抽取感兴趣的中级开发者覆盖数据处理、特征设计、模型训练与结果评估等关键环节。包内共1699个文件包含784个csv数据表、485个txt文本语料、421个ann标注文件、7个py源码脚本及Jupyter Notebook和说明文档整体压缩包约11.65MB。csv和txt提供原始语料与结构化数据ann文件为实体标注参考py与ipynb展示完整的建模流程md文档则对方案设计进行说明。目前已有145人学习下载。通过这份资料可了解赛事中命名实体识别任务的常见做法学习如何利用pycrfsuite配置特征模板、训练CRF模型并优化识别效果同时获得可复跑的代码与标注样例适合备赛复盘和入门实践。1. 天池瑞金MMC糖尿病NER赛题为什么用pycrfsuite能打初赛医疗命名实体识别是临床文本进入知识图谱构建前的第一道关卡天池瑞金医院MMC人工智能辅助构建知识图谱大赛初赛要求参赛者从糖尿病相关的病历、出院小结、随访记录里抽取出药物、症状、检查、疾病、身体部位等实体。我当时拿到这个题的第一反应不是上BERT而是先把pycrfsuite跑通条件随机场对序列边界建模扎实训练只要一条命令内存占用在CPU上就能扛住在标注样本只有几千条的初赛阶段CRF的F1完全有条件做到0.8以上。这篇笔记写给两类人一是正在复现天池NER基线、想快速出分再谈优化的选手二是医疗文本项目里需要一套稳定可解释NER管线的工程人员。2. 把赛题拆成可标注的实体糖尿病文本里有哪些命名实体2.1 五类实体是主线并发症藏在疾病里初赛的实体定义各家大同小异最常见的是五类疾病、症状与体征、检查检验项、药物、身体部位。糖尿病文本里这五类几乎全覆盖疾病包括“2型糖尿病”“糖尿病肾病”“糖尿病视网膜病变”症状有“多饮”“多尿”“体重下降”检查项有“空腹血糖”“糖化血红蛋白”“尿微量白蛋白”药物有“二甲双胍”“胰岛素注射液”身体部位有“胰岛”“肾脏”“视网膜”。注意“糖尿病肾病”在标注规范里通常按一个完整疾病实体处理而不是拆成“糖尿病”加“肾病”这一点直接影响后续关系抽取的质量。做标注前先看数据分布。MMC标准化代谢性疾病管理中心的随访记录有个特点模板化程度高很多句子的开头是“患者因‘多饮多尿’就诊”中间是实验室检查数值结尾是处方调整。模板化意味着CRF这类序列模型容易学到上下文信号但副作用是测试集如果来自不同医疗中心或不同年份模板措辞一变F1会掉得很明显。我一般会先跑一遍标签分布统计确认五类实体在训练集里的占比如果“检查检验项”占了40%以上后续就要留意模型对少数类别如身体部位的召回会偏低。标注方案上初赛用BIO三标签就够B-开头、I-中间、O表示非实体。BILOU/BIESO这类细粒度标签在嵌套实体多的语料上更优但会显著增加标签数量小数据下反而容易让CRF学出边界噪声。我见过不少选手一上来就上BIOES结果混淆矩阵里B-和S-互相打架不如老老实实BIO。2.2 BIO标注与按病历切分训练集别让同一份病历身首异处读入原始标注数据后先要转成BIO序列。一个典型标注文件长这样每个词一行用空格或Tab分隔列分别是词和标签。中文通常已经做了分词如果原始数据是整句标注你需要自己切词并把标签对齐到词上。对齐时最怕两个字被切成一个词但标注边界在中间这种我会直接弃掉这个样本而不是强行修正因为修正出的标签经常是错的。import pandas as pd def load_bio_file(path): tokens, labels, all_seqs [], [], [] for line in open(path, encodingutf-8): line line.strip() if line: parts line.split() if len(parts) ! 2: continue tokens.append(parts[0]) labels.append(parts[1]) else: if tokens: all_seqs.append((tokens, labels)) tokens, labels [], [] if tokens: all_seqs.append((tokens, labels)) return all_seqs这里有一个经常被忽略的坑文件里空行是句子分隔符但有些病历里检查报告区域会出现连续空行直接跳过空行会让两条不同句子的词拼成一条长序列CRF会在边界处学到虚假的转移特征。我的处理方式是记录空行数出现两个以上连续空行时强制截断序列并打印日志提醒检查原始数据。数据加载完成后按病历ID做分组切分不要直接随机切分句子。同一份病历的上下文高度相关如果训练集和测试集各含同一病历的一半模型记住的是病历级别的模板而不是实体规律评测分数会虚高。2.3 喂给pycrfsuite的序列长什么样一个token一个字典pycrfsuite不像深度学习框架那样接收张量它要求你把每个词转成一个字典key是特征名value是字符串或布尔值。这个字典就是CRF的特征向量序列特征全部由你手工构造。下面这段代码把“患者空腹血糖明显升高”转成CRF输入序列import jieba.posseg as pseg def text_to_items(text): words [(w.word, w.flag) for w in pseg.cut(text)] items [] for i, (word, pos) in enumerate(words): item { word: word, pos: pos, len: len(word), is_digit: word.isdigit(), } if i 0: item[prev_word] words[i-1][0] item[prev_pos] words[i-1][1] else: item[BOS] True if i len(words) - 1: item[next_word] words[i1][0] item[next_pos] words[i1][1] else: item[EOS] True items.append(item) return items这段代码的逻辑是每个token的特征里除了自身词形和词性还带上前一个词和后一个词的信息以及句首句尾标记。词性用jieba.posseg获取虽然中文医学文本的领域词分错率不低但词性作为粗粒度特征对名词性实体是有区分度的。注意item里的key不要带空格和冒号pycrfsuite底层支持有限字符key太脏会导致特征文件解析失败或特征维度膨胀。3. 用pycrfsuite搭NER管线特征、训练、解码一次跑通3.1 特征函数词、词性、词典、边界四类特征写特征时记住一个原则CRF的判别能力来自特征组合单个特征再强也只是提供了当前看到的证据。我通常把特征函数拆成四组表层词特征词本身、前缀后缀、是否数字、是否英文、词性特征、词典匹配特征、位置和边界特征。把它们都写进一个函数每个token返回一个字典pycrfsuite内部会为每个字典key自动建索引。def word2features(sent, i, drug_dict, symptom_dict): word sent[i][0] features { word: word, word.lower(): word.lower(), word[-3:]: word[-3:], word[-2:]: word[-2:], word.isdigit(): word.isdigit(), word.isenglish(): word.isascii() and word.isalpha(), pos: sent[i][1], } for s in (drug_dict, symptom_dict): features[fin_dict_{id(s)}] word in s if i 0: prev sent[i-1][0] features[prev_word] prev features[prev_pos] sent[i-1][1] features[wordprev] prev word else: features[BOS] True if i len(sent)-1: nxt sent[i1][0] features[next_word] nxt features[next_pos] sent[i1][1] features[wordnext] word nxt else: features[EOS] True return features注意这里in_dict_{id(s)}这种写法是临时方案问题在于id(s)在每次进程启动后会变化导致模型里特征名不一致。正确做法是给词典起稳定名字把字典传成命名参数比如drug_dict对应in_drug_dictsymptom_dict对应in_symptom_dict。组合特征prevword和wordnext是CRF在一元特征里最有效的上下文信号尤其对“某某药物”这种短实体前一两个字符就能定边界。3.2 训练参数C1/C2/迭代次数怎么给pycrfsuite的Trainer对象负责训练。把准备好的items序列和标签序列append进去然后设置正则参数执行train。整个流程代码量不大麻烦的是参数含义得吃透。import pycrfsuite trainer pycrfsuite.Trainer(verboseTrue) for items, labels in train_items: trainer.append(items, labels) trainer.set_params({ c1: 0.1, c2: 0.01, max_iterations: 200, feature.minfreq: 0, num_memories: 6, }) trainer.train(dm_ner_model.crfsuite)c1是L1正则系数c2是L2正则系数。L1会让特征权重稀疏化对特征爆炸的情况有抑制作用L2惩罚大权重防止单个特征对预测影响过大。初赛语料小我一般从c10.1、c20.01起步这两个值的比例大约10:1如果发现过拟合明显先把c2往上提。max_iterations控制L-BFGS最大迭代轮数训练过程中verboseTrue会打印每次迭代的目标函数值和误差你会发现前20轮误差掉得飞快后面逐渐平缓如果200轮还没收敛说明特征质量有问题或数据里有冲突标签。feature.minfreq是低频特征过滤阈值等于0表示保留全部特征。中文医学文本里生僻字很多如果特征词典里有大量只出现一次的长尾词模型会被它们干扰建议先从1起步即出现次数不足1次的特征直接丢弃。num_memories是L-BFGS的牛顿梯度存储数6是默认值小数据集不用动。3.3 把BIO标签拼回实体解码与后处理训练完模型加载Tagger做预测。预测返回的是每个token的BIO标签序列还要把它拼回实体列表。def predict_entities(text, tagger): items text_to_items(text) labels tagger.tag(items) words [i[word] for i in items] entities [] entity_type, start None, None for idx, (word, label) in enumerate(zip(words, labels)): if label.startswith(B-): if entity_type is not None: entities.append((words[start:idx], entity_type)) entity_type label[2:] start idx elif label.startswith(I-): if entity_type is None or entity_type ! label[2:]: if entity_type is not None: entities.append((words[start:idx], entity_type)) entity_type label[2:] start idx else: if entity_type is not None: entities.append((words[start:idx], entity_type)) entity_type None if entity_type is not None: entities.append((words[start:], entity_type)) return entities这段解码逻辑做了两层校验遇到B-I标签类型跳变时强制切断上一个实体遇到O标签时关闭当前实体。这样即使模型输出不符合BIO约束也不会产生跨类型的非法实体。预测后还有一个关键动作对每个预测标签调用tagger的marginal方法拿到置信度把低于阈值的实体丢掉。具体做法是遍历token的每个标签边际概率取序列最小概率作为实体置信度阈值调到0.3到0.5之间比不加过滤能提升不少精确率。4. 特征模板与调参顺序有限训练量下的提分主线4.1 从CRF到pycrfsuite模板不是字符串是组合特征用过CRF的人会习惯写unigram模板文件但pycrfsuite没有模板字符串的概念它直接把dict里的每个key当成一个特征组合特征需要你在函数里自己拼。这有利有弊好处是特征表达自由坏处是很多人漏掉了“当前词前后词”这类组合导致模型只看到独立词特征看不出“胰岛素注射”这种连续短语结构。一个实用的组合特征清单当前词前一词、当前词后一词、当前词词性上下文词、词典匹配结果加上相邻词。这些组合在pycrfsuite里等价于CRF的U15、U16类型。我在项目里还会加一类“句子位置”特征——词在整句中的位置归一化后分箱比如前10%、中段、后10%。糖尿病病历里实体常出现在句首主诉区检查项常出现在句末报告区这类位置特征对边界修正很有帮助。def add_position_features(sent, features, i): pos_ratio i / max(len(sent) - 1, 1) if pos_ratio 0.3: features[pos_region] head elif pos_ratio 0.7: features[pos_region] middle else: features[pos_region] tail return features位置特征的区间划分要根据数据分布调有些病历句子长检查项集中在最后两个词把tail区间设定为0.85会更合适。这个参数值得单独做一次小实验。4.2 词典救场把外部专业词汇包进特征CRF在下游表现很大程度上取决于词特征是否能代表领域术语。糖尿病领域的药物名动辄五六字“盐酸二甲双胍缓释片”“精蛋白人胰岛素混合注射液”单独靠字特征很难学出整体概念。词典匹配特征可以弥补把训练集里高频出现的药物实体整理成词典预测时判断当前词是否命中命中即给in_drug_dict特征打True。drug_dict set() symptom_dict set() # 从训练数据的BIO标签中回收实体词构建词典 for items, labels in train_items: for item, label in zip(items, labels): if label B-DRUG or label B-SYMPTOM: pass def make_dict_from_corpus(train_items, target_type): dict_set set() for items, labels in train_items: cur [] for item, label in zip(items, labels): if label B- target_type: cur [item[word]] elif label I- target_type and cur: cur.append(item[word]) else: if len(cur) 2: dict_set.add(.join(cur)) cur [] return dict_set词典构建的逻辑很简单从训练集标注里把完整实体字串回收进集合。但要注意单字实体别加进去“糖”这种字会误伤大量阴性上下文。词典的覆盖度可以通过对比词典词数与训练集实体总数来看如果词典只覆盖了60%的训练实体说明数据里实体拼写变体多这时候要人工补充同义词。外部词典不能盲目堆堆太多低频词会让in_dict特征的精度下降我已经记不清多少次被“二甲双胍缓释片”和“二甲双胍片”这种同义不同名坑过词典里缺一个就丢一堆召回。4.3 参数边界C值影响泛化迭代次数影响稳定性调参顺序比调参本身更重要。先固定一个c1/c2组合把迭代次数和特征窗口调到模型稳定收敛再去动正则。很多人反过来先调半天c值结果每次c值变化伴随特征组合也变根本分不清是谁在起作用。我做小规模网格搜索时通常会同时跑一组特征集和一组参数输出对照表。def train_eval(params, train_items, test_items): trainer pycrfsuite.Trainer(verboseFalse) for items, labels in train_items: trainer.append(items, labels) trainer.set_params(params) trainer.train(/tmp/model.crfsuite) tagger pycrfsuite.Tagger() tagger.open(/tmp/model.crfsuite) y_true, y_pred [], [] for items, labels in test_items: y_pred.extend(tagger.tag(items)) y_true.extend(labels) return precision_recall_f1(y_true, y_pred)网格搜索预算要控制住c1取0.01/0.1/1c2取0.001/0.01/0.1组合数9组每组训练轮数在100以内。pycrfsuite在几十万token规模下训练很快9组最多跑几十分钟。每次调参后我还会看一眼模型文件大小特征维度膨胀时模型文件会异常变大这是最简单粗暴的告警信号。5. 医疗NER避坑指南这5个坑让我各浪费过一两天时间5.1 标注不一致同一个实体两种BIO标签现象训练日志里迭代误差始终降不下去F1在0.7附近卡住检查预测结果发现“二甲双胍”有时被标成B-DRUG I-DRUG有时是B-SYMPTOM。原因标注阶段没有维护统一实体词典不同标注人员对实体类别理解不一致或者原始标注文件里同一个实体在不同句子中被赋予不同类型。解决训练前对标注做一致性校验统计每个唯一实体词对应的标签集合凡是出现一个词对多种标签的样本先拉出来人工仲裁。我写过一个十几行的小脚本扫一遍全量标注输出冲突词列表修完这轮冲突后F1直接有2到3个点的提升。5.2 实体边界吞并长实体把相邻词卷进来现象预测出的“糖尿病肾病”实体里混进了后面的“伴”“双眼”等词变成“糖尿病肾病伴双眼”。原因BIO解码时只认I-延续没有设置实体最大长度限制。糖尿病实体一般不超过8个汉字负面特征没学够。这个问题在嵌套实体多的中文里尤其明显。解决解码时给实体长度加一个启发式约束超过MaxLength就按当前边界截断。我一般取训练集实体长度的95分位数大概在10到12之间。做成一个全局参数上线时再跑测试集验证截断位置是否合适。5.3 训练集和测试集同源污染分句切分制造了虚高分数现象验证集上F1有0.9提交到评测环境却只有0.78差距大得离谱。原因数据切分没有按病历ID分组同一份病历的检查报告被切成几十条样本后随机落入两边。模型在验证集上学到了病历模板的位置记忆换一份新病历就失效。解决切分前先按病历ID对所有句子分组再以组为单位划分train/valid/test。如果原始数据没有病历ID就按文本的第一段内容做聚类哈希把相同开头的句子聚为一份。这是初赛最常见的翻车点。5.4 特征爆炸字典里塞了几十万个词形特征现象训练时可以跑但内存占用持续上涨模型文件从20MB涨到200MB特征是黑匣子完全失控。原因word.lower()、word[-3:]这类特征对中文其实冗余中文没有大小写和词干变化拆出来全是噪声。加上词典特征里的无序dict key特征组合成倍膨胀。解决砍掉中文文本里的lower和istitle特征保留pos和字面组合。用feature.minfreq3过滤低频特征把只出现三五次的词形特征直接丢掉这个操作让我模型体积降了40%F1几乎没动。5.5 指标只看accuracyO标签一多F1真实水平看不见现象日志里accuracy 98%感觉模型很好但肿瘤实体一个没抽出来。CRF的accuracy被大量O标签灌水因为非实体词占比普遍超过80%。原因评估用了整体token accuracy而不是实体级别的精确率召回率。CRF预测的小类实体被淹没在大规模阴性token里F1偏低才是真实水平。解决评测必须按实体级F1计算即先按BIO合并成实体再以实体为单位比较完全匹配。pycrfsuite官方工具包没有直接给实体级评测函数自己写合并逻辑又没有统一标准我的做法是参考上面合并代码先拼实体再对齐比较特别注意实体判对必须边界完全一致多一个字都算错。6. 实体识别之后把CRF结果装进知识图谱的第一步初赛交的是实体识别结果但标题里的知识图谱构建暗示了后续方向CRF抽出的实体只是图中的节点真正形成知识得把实体间关系抽出来。我跑完初赛模型后会顺手把预测实体导出成CSV用Neo4j的LOAD CSV做一次导入把“糖尿病”和“二甲双胍”建起节点这样可以直接看到实体共现是否能对得上临床逻辑。LOAD CSV WITH HEADERS FROM file:///dm_entities.csv AS row MERGE (n:Entity {name: row.name}) SET n.type row.type;这行Cypher把实体表去重写入Neo4j。要注意的是MERGE依赖name做唯一约束中文同义词会被当成两个节点这只是可视化第一步不是正经本体。真正构建知识图谱还需要定义关系类型比如“治疗”“并发”“部位”并把共现统计转换成关系属性。我现在的习惯是每次CRF调完参先把Top100预测实体导进图里用一顿咖啡的时间扫一遍图和原始病历的对应关系很多错误在列表里不觉得画成图就一眼看出它是不是“体重下降”确实该属于症状节点。这套从CRF到图库的验证路径帮我避开了不少纯看指标的自欺欺人希望帮到你。本文还有配套的精品资源点击获取