尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

pycrfsuite实战糖尿病NER:从特征工程到知识图谱构建

发布时间:2026/9/24 18:15:10

资讯中心
01
ARTICLE

pycrfsuite实战糖尿病NER:从特征工程到知识图谱构建

pycrfsuite实战糖尿病NER:从特征工程到知识图谱构建
简介一份面向天池瑞金医院MMC人工智能辅助构建知识图谱大赛初赛的完整参赛方案聚焦糖尿病相关医疗命名实体识别基于pycrfsuite实现。资源包共1699个文件、约11.65MB其中784个csv与485个txt构成训练语料和特征数据421个ann为实体标注文件7个py与1个ipynb为建模与处理代码1个md为说明文档可支撑从数据预处理到模型训练与评估的完整流程。目前已有145人学习下载。对参赛选手和医疗NLP入门者而言资源的核心价值在于可复现的CRF实体识别基线、规范的标注数据组织方式以及设计文档与源码的对照参考能帮助理解医疗命名实体识别任务中的数据清洗、特征工程和序列标注模型构建思路适合作为baseline或实验参考。1. 天池瑞金MMC大赛初赛里的糖尿病NER为什么先选pycrfsuite医疗命名实体识别NER是知识图谱构建的地基天池瑞金医院MMC人工智能辅助构建知识图谱大赛初赛给的场景很直接从糖尿病相关的病历文本里把疾病、症状、检查项、药品、检验值这些实体一个个拎出来。很多人一上来就想上BERT但初赛阶段数据量不大、标注文本以短句为主CRF反而更容易出稳定结果。pycrfsuite是Python里封装好的CRF工具训练快、特征工程透明、调参直观特别适合比赛起步和线下验证。这篇文章就按我当时做这个初赛的思路把数据准备、特征模板、训练参数和踩过的坑完整讲一遍新手能照着跑通熟手也能在特征和参数上找到可调的抓手。2. 先立理论再动手CRF、BIO标注与pycrfsuite的选型逻辑2.1 从知识图谱倒推命名实体识别先定实体边界再谈关系知识图谱的构建链路是「命名实体识别 → 关系抽取 → 实体链接 → 图谱存储」。天池瑞金MMC初赛只考第一环但这一环直接决定后续图谱质量实体边界错了关系抽取拿到的是残缺主语图谱里的节点就是脏数据。糖尿病领域的文本里实体类型通常包括糖尿病类型如“2型糖尿病”、症状如“多饮多尿”、并发症如“糖尿病肾病”、药物如“二甲双胍”、检查指标如“糖化血红蛋白”和检验值如“7.2mmol/L”。初赛文本大多是结构化程度较低的医嘱、出院小结和随访记录句子短、术语密集实体之间很少跨长距离依赖。这种场景下CRF的线性链假设恰好够用它只建模相邻标签之间的转移关系却能在这个假设下把词本身的特征字符、词性、词典命中利用得很充分。对比深度模型CRF不依赖大规模预训练语料一台普通笔记本就能在几分钟内完成训练这对比赛初赛阶段的快速迭代非常关键。我当时的判断标准很简单如果训练集只有几千条标注句子先跑CRF拿到一个基线F1再决定要不要上BERT。CRF的另一个好处是完全可解释——每个特征都有权重模型预测错了可以倒查是哪个特征拖后腿。这在比赛调试期比一个黑匣子模型有用得多。2.2 线性链CRF的核心机制与pycrfsuite的工程实现线性链CRF的本质是在给定观测序列字符/词的特征时对标签序列建模条件概率。它不像HMM那样要求观测独立而是可以自由设计特征函数来捕捉上下文。比如“患者血糖控制在”后面大概率跟的是一个数值实体这种上下文模式通过特征模板就能表达。CRF训练时通过最大化似然函数来学习特征权重推断时用维特比算法找全局最优标签序列。pycrfsuite是CRFsuite的Python绑定底层是C实现训练速度比纯Python实现的CRF快一个数量级。它的API设计很精简Trainer负责加载训练数据、设置参数并训练Tagger负责加载模型并做预测。每个样本由一组特征字典{特征名: 值}组成特征值只能是数值或布尔值。pycrfsuite不要求你预先构建特征矩阵而是支持在线特征提取——每来一个token就调用一次特征函数返回dict这对中文NER很友好因为字符级特征可以现场组合。相比sklearn-crfsuitepycrfsuite少了sklearn的封装代码多写几行但换来的是更少的隐藏处理和更直接的参数暴露。比赛场景我倾向用pycrfsuite因为它的params字典里每个键都对应CRFsuite文档里的原始参数调参时心里有底。2.3 最小可跑的代码骨架先验证链路再填特征拿到数据后别急着堆特征先把「训练 → 保存 → 加载 → 预测」的最小闭环跑通。下面这段代码是pycrfsuite的固定用法我在每次比赛和项目里都是先写这一段import pycrfsuite # 训练数据每个样本是[(word, pos_tag), ...]的列表这里用简化形式 train_sents [ [(患, v), (者, n), (糖, n), (尿, n), (病, n)], [(二, m), (甲, n), (双, n), (胍, n)] ] # 标签BIO格式B-DIS表示疾病开始I-DIS表示疾病内部O表示非实体 train_labels [ [O, O, B-DIS, I-DIS, I-DIS], [B-DRUG, I-DRUG, I-DRUG, I-DRUG] ] def word2features(sent, i): word sent[i][0] features { w: word, w.isdigit: word.isdigit(), } if i 0: features[w-1] sent[i-1][0] if i len(sent) - 1: features[w1] sent[i1][0] return features def sent2features(sent): return [word2features(sent, i) for i in range(len(sent))] def sent2labels(labels): return labels X_train [sent2features(s) for s in train_sents] y_train [sent2labels(l) for l in train_labels] trainer pycrfsuite.Trainer(verboseTrue) for xseq, yseq in zip(X_train, y_train): trainer.append(xseq, yseq) trainer.set_params({ c1: 0.1, # L1正则系数控制特征稀疏性 c2: 0.01, # L2正则系数防止过拟合 max_iterations: 100, feature.possible_transitions: True }) trainer.train(diabetes_ner.crfsuite)这段代码的逻辑很直白先把原始文本转成特征字典序列每一层的word2features函数只提取当前字、前后各一个字和是否为数字这三个特征然后塞给Trainer训练。feature.possible_transitions设为True会让模型学习所有可能的标签转移概率而不是只学数据里出现过的转移这对对付训练集覆盖不到的标签组合很重要。参数说明c1和c2是正则化系数c1越大特征越稀疏适合特征维度高的情况c2越大模型越平滑。初赛阶段建议c10.1, c20.01起步后面根据验证集F1再调。max_iterations控制训练轮数CRFsuite默认用LBFGS优化100轮对几千条数据通常够用如果loss还在下降就继续加。跑完这段磁盘上会多出一个diabetes_ner.crfsuite文件这就是可以拿去预测的模型。3. 处理糖尿病医疗文本数据清洗、BIO标注与训练集构建3.1 医疗文本和通用文本的三个差别直接决定特征设计第一医疗文本里数字和单位是强实体信号。“空腹血糖8.6mmol/L”里的“8.6”和“mmol/L”必须被识别成一个检验值实体而通用文本里数字通常是噪声。所以特征设计里一定得有一个is_digit和一个contains_unit特征后者可以用单位词典如mmol/L、mg/dL、umol/L做前缀匹配。第二医疗实体有大量嵌套和组合。“2型糖尿病伴糖尿病肾病”这个短语里“2型糖尿病”是主体疾病“糖尿病肾病”是并发症两者共享“糖尿病”三个字。CRF的线性标签序列处理这种嵌套天然吃亏但初赛一般只要求平铺的实体边界所以标注时要定义好边界优先级——组合词整体标成大实体不再内嵌小实体。第三文本里存在大量英文缩写和药名商品名。“糖适平”“格华止”其实都是二甲双胍的商品名模型如果没见过就识别不出。这提示特征里要加入词典特征把常见药名、别名、商品名做成一个词典集合word in drug_dict直接作为布尔特征。3.2 BIO标注的细节标签体系设计与边界一致性BIO标注里B表示实体开始I表示实体内部O表示非实体。糖尿病场景我见过最稳的标签体系是六类DIS疾病、SYM症状、DRUG药物、CHECK检查项、VALUE检验值、BODY部位。有人会把VALUE分成数值和单位两个标签但CRF对短实体的区分能力有限分太细反而容易在B和I之间迷路。标注时最容易犯的错是B和I混用。比如“糖化血红蛋白”如果标成B-CHECK I-CHECK I-CHECK I-CHECK没问题但中间断成B-CHECK O B-CHECK I-CHECK就是灾难。pycrfsuite训练时不检查标签合法性它会照单全收学出一套混乱的转移概率。所以训练前一定要写一个校验脚本确保所有I标签前面要么是B要么是I且类型一致。实体类型的粒度也要和数据量匹配。初赛训练集如果只有几千条六类已经是上限。每类实体至少要有几百个样本模型才能学到稳定的内部字符模式。如果发现某类实体只有几十个合并到相近类别里比硬着头皮单独建模更现实。3.3 把原始病历文本转成pycrfsuite训练样本的脚本天池初赛给的数据通常是text和label两列label是类似{entities: [{start: 0, end: 5, type: DIS}]}的JSON。需要自己把实体偏移量转成BIO标签序列。下面的脚本完成了从原始标注到训练样本的转换import json import re def char_bio_from_entities(text, entities): 根据实体起止偏移生成字符级别的BIO标签 entities: [{start: 0, end: 3, type: DIS}, ...] labels [O] * len(text) for ent in entities: start, end, etype ent[start], ent[end], ent[type] if end len(text): end len(text) if start end: continue labels[start] fB-{etype} for i in range(start 1, end): labels[i] fI-{etype} return labels # 示例从天池初赛数据格式到pycrfsuite特征序列 def build_training_samples(raw_data_path, output_path): samples [] with open(raw_data_path, r, encodingutf-8) as f: for line in f: item json.loads(line) text item[text] entities item.get(entities, []) bio_labels char_bio_from_entities(text, entities) # pycrfsuite特征按字符级别提取 chars list(text) xseq sent2features(chars) # 复用第2章的word2features yseq bio_labels samples.append((xseq, yseq)) with open(output_path, w, encodingutf-8) as f: # 这里直接保存为pycrfsuite可读的训练格式或直接用trainer.append json.dump(samples, f, ensure_asciiFalse) return samples逻辑说明char_bio_from_entities是核心函数它把实体的字符偏移转成等长的BIO列表默认所有字符先置O然后把实体区间内的标签替换为B和I。转换完成后文本和标签一一对应长度必须严格相等——这是训练前最需要检查的约束。sent2features(chars)这里传入的是字符列表所以word2features里的word就变成了单个汉字特征模板的粒度也就锁定在字符级。一个容易忽略的坑是中文文本里的空格和标点。text里如果有全角空格会被当成一个字符计入长度导致label和text错位。我在转换前会先做一次清洗把全角空格替换成空字符串统一标点符号确保len(labels) len(text)。另外实体跨句子边界的情况要直接丢弃或手动拆分CRF按句子建模实体不能横跨两个训练样本。4. 特征模板与训练参数让CRF在医疗文本上出效果的配置4.1 字符级特征、词典特征与上下文窗口——特征模板怎么搭才不亏CRF的性能上限由特征决定加入工特征在医疗场景收益最高的三个方向字符本身、前后缀模式、词典命中。字符本身捕捉高频字在实体中的位置角色比如“病”常出现在疾病实体尾部“片”常出现在药名尾部。前后缀模式应对数字和英文比如判断当前字符是否是数字、是否包含小数点、是否是大写字母开头。词典命中直接把先验知识注入模型。下面是我在MMC初赛用的完整特征模板每一行都有明确目的def word2features(sent, i): word sent[i] features { w: word, w.isdigit: word.isdigit(), w.isalpha: word.isalpha(), w.isspace: word.isspace(), } # 前后字符 if i 0: features[w-1] sent[i-1] features[w-1.isdigit] sent[i-1].isdigit() else: features[BOS] True # 句首标记 if i len(sent) - 1: features[w1] sent[i1] features[w1.isdigit] sent[i1].isdigit() else: features[EOS] True # 句尾标记 # 双字符组合捕捉常见医学词根 if i 0: features[w-1w] sent[i-1] word if i len(sent) - 1: features[ww1] word sent[i1] # 数字和单位的组合模式例如 8.6mmol/L if word.isdigit() or (/ in word): features[contains_unit] True return features参数说明w-1和w1是上下文窗口窗口越大特征维度越高训练越慢。对糖尿病文本我一般只用到前后各1个字符2-gram特征通过w-1w和ww1补足。BOS和EOS是句边界信号医疗文本里主语常被省略句首字符对实体起始的判断作用很大。contains_unit这个特征虽然粗糙但能帮助模型把“mmol/L”“mg/dL”这类单位词标记为VALUE实体的尾部。词典特征也应该加进去。把药品名、症状名、检查项名各建一个集合特征函数里查一遍# 词典加载 def load_dict(path): with open(path, r, encodingutf-8) as f: return set(line.strip() for line in f if line.strip()) drug_dict load_dict(drug_dict.txt) symptom_dict load_dict(symptom_dict.txt) def word2features(sent, i): word sent[i] features { w: word, in_drug: word in drug_dict, in_symptom: word in symptom_dict, w.isdigit: word.isdigit(), w.isalpha: word.isalpha(), } # 窗口特征保留... if i 0: prev sent[i-1] features[prev_in_drug] prev in drug_dict if word in drug_dict and prev in drug_dict: features[drug_cont] True return features词典特征有个陷阱词典里的词和真实文本不完全对齐比如药品别名没收录。所以词典特征最好配合字符特征一起用别把词典命中的权重设太高否则模型会对词典外的同义表达失明。4.2 pycrfsuite参数的中文对照与首轮推荐值pycrfsuite的set_params字典核心参数只有几个参数名作用推荐初值调参方向c1L1正则系数控制特征稀疏性0.1特征维度特别高时增大到0.5-1.0防止过拟合c2L2正则系数平滑权重0.01训练集小时增大到0.1减少方差max_iterations最大迭代轮数100loss下降不明显时加到200-300feature.possible_transitions是否学习所有可能标签转移True标签体系稳定后保持Truefeature.minfreq特征最小出现频率阈值0不启用特征太稀疏时设为2或3过滤噪声特征delta停止阈值loss变化小于该值即停1e-5不需要动这里面feature.minfreq是我后期才发现的利器。初赛特征维度到了一万多维时很多特征只出现过一两次它们是训练集噪声而非信号。把minfreq设为2让出现频率低于2的特征不参与训练模型泛化能力立刻提升F1能涨0.5-1个点。feature.possible_transitionsTrue的值容易被忽略。默认False意味着模型只学习训练数据里出现过的标签转移比如B-DIS后面没见过I-DIS就直接不允许。但验证集里出现B-DIS I-DIS I-DIS是很有可能的所以这里必须设True。4.3 完整训练脚本与模型保存把上面所有特征和参数串起来一个完整的训练脚本长这样import pycrfsuite import json def load_samples(json_path): with open(json_path, r, encodingutf-8) as f: samples json.load(f) X [s[0] for s in samples] # 每项是特征字典列表 y [s[1] for s in samples] # 每项是BIO标签列表 return X, y X_train, y_train load_samples(train_samples.json) X_dev, y_dev load_samples(dev_samples.json) trainer pycrfsuite.Trainer(verboseTrue) for xseq, yseq in zip(X_train, y_train): trainer.append(xseq, yseq) trainer.set_params({ c1: 0.1, c2: 0.01, max_iterations: 200, feature.possible_transitions: True, feature.minfreq: 2, # 过滤低频特征 }) trainer.train(mmc_ner.crfsuite) # 评估 tagger pycrfsuite.Tagger() tagger.open(mmc_ner.crfsuite) print(模型已保存开发集大小:, len(X_dev)) # 预测一条样本看看效果 example X_dev[0] pred tagger.tag(example) print(预测结果:, pred)训练完成后模型保存为mmc_ner.crfsuite这个文件可以直接用于之后的预测不需要重新训练。它内部包含了特征函数提取规则和权重只要特征提取代码保持一致加载后就能复现结果这也是pycrfsuite做比赛时的一个优势训练集和评估集的模型分发非常方便。开发集的作用是给调参一个客观依据。每次改完特征或参数后用tagger.tag()跑一遍开发集算一下F1对比上一版再决定是否保留改动。没有开发集的调参就是在猜这在比赛里是要吃大亏的。5. 避坑与排查糖尿病实体识别在CRF下的五个常见翻车点5.1 标签与文本长度不一致导致训练直接报错现象trainer.append(xseq, yseq)时抛出ValueError: Length of item sequences mismatch。原因最常见是char_bio_from_entities转换时实体偏移是按照原始文本算的但文本预处理时删除了空格或标点导致len(bio_labels)和len(chars)不一致。比如文本里有全角空格统计长度时多算了一个字符。解决在处理管线的最开始就统一字符口径。我的经验是在清洗函数里加一句断言assert len(bio_labels) len(chars), f长度不一致: text{text}, labels{bio_labels}这句断言能在训练前暴露出所有对齐问题而不是等到pycrfsuite训练到一半才报错。另外在char_bio_from_entities里要对实体的end做截断防止实体越界。5.2 O标签过多导致模型把所有词都预测为O现象训练完成后预测结果全部是O一个实体都抽不出来F1直接归零。原因训练数据里非实体字符占比太高通常超过90%。CRF优化的是全局似然把所有位置都标成O依然能取得很高的准确率但召回率为零。这不是模型坏了是类别不平衡导致优化方向偏了。解决策略有三个方向。第一检查实体是否过少如果实体比例确实很低考虑在训练时给实体句子加权复制或者缩减非实体句子的比例。第二调大c1让特征更稀疏增强模型对少数特征的敏感度。第三检查特征模板是否有效——如果特征只有当前字符本身模型根本分不清实体边界这时候加词典特征和BOS/EOS标记通常能立刻改善。我在初赛时就是靠加词典特征把全O局面打破的。5.3 B和I标签混用模型学到错误的转移模式现象预测结果里出现B-DIS I-DRUG这种跨类型的标签组合或者I-DIS出现在句首。原因训练数据标注不规范人工标注时边界画错或者转换脚本的BIO生成逻辑有bug同一个实体内部标签类型不一致。解决写一个标签合法性校验器训练前跑一遍def validate_bio(labels): valid_types {DIS, SYM, DRUG, CHECK, VALUE, BODY} for i, label in enumerate(labels): if label O: continue prefix, _, etype label.partition(-) if prefix not in (B, I) or etype not in valid_types: return False, f非法标签: {label} at {i} if prefix I and i 0: return False, fI标签出现在句首: {label} at {i} if prefix I and labels[i-1] O: return False, fI标签前是O: {label} at {i} if prefix I and labels[i-1].partition(-)[2] ! etype: return False, fI标签前是不同类型的B: {label} at {i} return True, OK这段校验代码能抓出绝大多数标注脏数据比人工翻文本高效得多。如果校验不通过优先查转换脚本的偏移量计算而不是直接改数据。5.4 长实体超过10个字频繁被截断现象像“糖尿病周围神经病变”这样的长实体预测只识别出“糖尿病”三个字剩余部分变成O。原因CRF的窗口特征只能看到前后1-2个字符长实体内部的依赖关系建模不足。另外训练数据里长实体样本占比低模型没见过足够多的完整模式。解决在特征模板里增加跳跃特征比如当前字符和往前第3个字符的组合让模型能捕捉到更长距离的上下文。更有效的做法是给词典特征加上连续命中计数——当连续多个字符都命中同一个词典词时标记一个dict_continuity特征模型就更容易把整段连续词典命中识别成一个实体。5.5 模型文件在预测时报错特征函数不一致现象训练时正常加载模型预测时抛出AttributeError: NoneType object has no attribute tag。原因Tagger没有正确打开模型文件多半是路径写错或模型文件被移动。pycrfsuite的Tagger在打开失败时不会立刻抛错而是把内部状态置空到调用tag()时才暴露。解决每次预测前先判断文件是否存在import os model_path mmc_ner.crfsuite if not os.path.exists(model_path): raise FileNotFoundError(f模型文件不存在: {model_path}) tagger pycrfsuite.Tagger() tagger.open(model_path)另外一个容易踩的坑是特征函数不在同一个模块里定义。pycrfsuite的Tagger只依赖训练好的模型文件本身特征是在训练时就固化在模型里的预测时输入的必须是和训练时一样格式的特征字典序列。如果你把特征提取函数复制到另一个文件务必保证接口一致否则输入的特征字典缺失字段预测结果直接乱套。6. 从BIO序列到知识图谱实体后处理与验证的收尾技巧6.1 BIO标签到实体的还原算法模型输出的是字符级别的BIO标签序列要喂给知识图谱构建得先还原成实体列表start、end、type。这一步的逻辑很简单但容易写错我用一个正则表达式搞定import re def bio_to_entities(chars, bio_labels): entities [] i 0 while i len(bio_labels): if bio_labels[i].startswith(B-): etype bio_labels[i][2:] j i 1 while j len(bio_labels) and bio_labels[j] fI-{etype}: j 1 entity_text .join(chars[i:j]) entities.append({start: i, end: j, type: etype, text: entity_text}) i j else: i 1 return entities # 使用示例 text list(患者空腹血糖8.6mmol/L诊断为2型糖尿病) labels [O, O, B-VALUE, I-VALUE, I-VALUE, O, O, O, O, B-DIS, I-DIS, I-DIS, I-DIS, I-DIS] result bio_to_entities(text, labels) print(result) # 输出: [{start: 2, end: 5, type: VALUE, text: 血糖8.6}, ...]注意这里循环跳转用的是i j不是i 1否则会重复扫描已合并的实体内部字符。还原后的实体列表就是知识图谱的节点候选后续可以接neo4j构建知识图谱做实体关系存储和可视化。天池MMC初赛到这一步就算完整闭环了原始文本 → BIO标注 → CRF训练 → 实体还原。6.2 用F1和混淆矩阵验证模型别只盯准确率NER任务里准确率是最具欺骗性的指标。假设90%的字符是O全预测成O就有90%准确率但实体一个没抓到。正确做法是算实体级别的精确率、召回率和F1也就是把每个完整的预测实体当成一个整体来匹配。pycrfsuite没有内置评估器我通常用seqeval库from seqeval.metrics import classification_report y_true [[O, B-DIS, I-DIS, O], [B-DRUG, I-DRUG, O]] y_pred [[O, B-DIS, O, O], [B-DRUG, I-DRUG, O]] print(classification_report(y_true, y_pred))seqeval按实体整体计算指标B和I必须同时匹配才算对一个实体。它输出的每个实体类型的F1能直接告诉你哪类实体是短板如果DRUG类的F1比DIS低10个点说明药名词典不够全或者药名训练样本太少回去补数据比调CRF参数更有效。我的习惯是每调整一次特征或参数就把开发集的分类报告存一份邮件给自己命名带日期和改动点。一轮比赛打下来翻了五六版特征没有记录根本想不起来哪版改了什么。最后说一个经验比赛做NER别等到所有特征都加完才开始训练拿最小特征跑通一遍再迭代加特征每加一类特征都跑一次验证集这样一眼就能看出新特征是真涨点还是噪声。希望帮到你。本文还有配套的精品资源点击获取
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。