简介:来自天池瑞金医院MMC人工智能辅助构建知识图谱大赛初赛的这份压缩包,聚焦糖尿病相关医疗命名实体识别,整套方案基于pycrfsuite实现,面向NLP学习者、医学文本挖掘参赛者及希望了解知识图谱构建前期文本处理流程的研发人员。压缩包内含1699个文件,共11.65MB,其中784个csv文件承担语料与特征数据组织,485个txt提供原始病例文本,421个ann文件对应实体标注结果,另有7个Python脚本与1个Jupyter Notebook记录建模过程,1个Markdown设计文档说明整体思路与实现要点。已有145人学习,从中可完整看到数据预处理、特征模板设定、CRF模型训练与效果评估的实践路径,尤其适合想用传统机器学习方法快速落地医疗NER任务的研究者。借助该资料,可以直接参考源码逻辑、标注格式与参数调优细节,在已有数据上复现或改进初赛方案,为后续知识图谱构建打下基础。
1. 天池瑞金医院MMC人工智能辅助构建知识图谱大赛初赛:用pycrfsuite做糖尿病医疗命名实体识别,这条路到现在依然能打
天池瑞金医院MMC人工智能辅助构建知识图谱大赛初赛的任务很直接:从海量糖尿病相关医疗文本里,把疾病、症状、药物、检查等医学实体一个不落地抠出来,这就是医疗命名实体识别。很多人一听到NER就直接上BERT,但初赛场景有个现实约束——数据量不大、标注噪声高、迭代周期短。pycrfsuite这套基于CRF的传统方案在这种条件下的上限不低,而且训练一个模型几十秒就完事,调参靠直觉也能走通。这篇文章把我自己复现这套流程的完整路径拆开讲,新手能照着跑通,熟手可以直接拿走特征模板和调参经验。
2. 先把初赛任务的底数摸清:数据集特点与标注转换
2.1 这个初赛到底要识别哪些医学实体
天池瑞金MMC初赛的文本来源是糖尿病相关的临床记录和科普文章,实体类型一般覆盖六类:疾病、症状、药物、检查、手术、身体部位。这里面最麻烦的不是疾病和药物这种长实体,而是症状和检查——同一个词在不同语境下类别会变。比如"血糖"在"血糖升高"里是症状主体,在"查血糖"里又是检查项。所以做特征工程前,第一步是把标注样例逐条读一遍,统计各类实体的长度分布和边界词。我一般会先跑一个统计脚本,看看训练集里实体的平均长度、最长实体、以及最常见的左右边界字,这些数字直接决定词级别的特征要不要做、窗口开多大。如果实体大量集中在2~4个字,那么基于单字的特征就比基于分词的特征更稳,因为分词错误会把实体边界切碎。
还有一个必须做的事是检查标注一致性。医疗文本里常见"冠心病""冠状动脉粥样硬化性心脏病"指同一个病,标注却可能一个标成疾病、另一个拆成多个实体。这种不一致会让CRF学到的转移特征混乱,表现为相同上下文预测出不同标签。我的做法是把训练集里所有标注实体按文本去重,抽出来人工扫一遍,把明显标注不一致的样本修正后再进模型。这一步脏活累活花不了多少时间,但对最终F1的影响比调任何超参数都大。
2.2 从原始标注到BIO序列:实体边界怎么编码
竞赛给的数据通常是JSON或文本文件,每条记录是一段文本加一个实体列表,每个实体有start、end、type三个字段。pycrfsuite不认识这种结构,必须转成字符级别的BIO序列。B是实体首字,I是实体内部字,O是实体外字。这里有个关键选择:每个实体类型都要独立的B和I标签,而不是一个笼统的实体标签。比如疾病和症状两类实体,标签集就是:B-disease、I-disease、B-symptom、I-symptom、O。如果只用一个B-entity和I-entity,CRF就失去了区分实体类型的能力,预测结果还得做二次分类,等于自己给自己挖坑。
转换成字符级序列还有个细节:实体内部的连续字必须保证B后面紧跟着I,而I后面不能再接另一个B开头的同类型实体。比如"糖尿病肾病",如果标注成B-disease、I-disease、B-disease、I-disease,CRF训练时会因为转移特征矛盾而收敛变慢,预测时也更容易产生非法标签序列。我一般会在转换函数里做一次合法性校验,把所有不符合BIO约束的样本直接打日志,而不是静默放行。转换后的数据结构是一个列表,每个元素是(字符, 标签)对,pycrfsuite需要的正是这种形式。
# bio_convert.py # 把实体列表转成字符级BIO标签序列 def convert_to_bio(text, entities): """ text: str,原始文本 entities: list of dict,每个dict含start/end/type 返回: list of (char, label) """ labels = ['O'] * len(text) for ent in entities: start, end, etype = ent['start'], ent['end'], ent['type'] # 越界和空实体直接跳过,防止脏数据 if start < 0 or end > len(text) or end <= start: continue labels[start] = f'B-{etype}' for i in range(start + 1, end): labels[i] = f'I-{etype}' return [(ch, lb) for ch, lb in zip(text, labels)] # 示例:文本"患者血糖偏高,建议复查空腹血糖" # entities: [{"start":2,"end":4,"type":"symptom"}, {"start":10,"end":14,"type":"check"}] # 转换结果片段: ('血','O'),('糖','B-symptom'),('偏','I-symptom'),('高','I-symptom')这段代码的核心是逐字符打标签,而不是按词打标签。按字符的原因在于中文分词器在医学术语上的表现不稳定,"空腹血糖"经常被分成"空腹/血糖",如果按词标注就得先分词,分错的词直接导致标签错位。字符级标注没有这个问题,模型自己从上下文学边界,后面用特征模板把相邻字的信息喂进去就行。另外我跳过了无效实体和空实体,因为竞赛原始数据里偶尔会出现start等于end的脏标注,不处理会让标签序列出现空洞。
2.3 训练集和验证集的划分策略
NER任务的验证集划分不能直接随机抽句子,因为同一份文本可能被截断成多条训练样本,随机划分会导致同一实体的不同片段同时出现在训练集和验证集里,评估结果虚高。正确的做法是按文本ID分组,整个文本要么进训练集要么进验证集。我在初赛里还加了一个约束:验证集里必须覆盖每一种实体类型,并且各类实体的比例和训练集大致相当。原因是有些实体类型样本量很小,比如手术类在糖尿病文本里可能只有几十条,如果随机划分把它们全分到训练集,验证集的F1就会被这类缺失拉低,调参的时候产生误导。
划分比例我一般用8:2,但如果训练语料很小——比如只有几千条句子——会改成9:1,因为CRF模型对这种小数据量的训练非常敏感,验证集占得太多会让模型学不到足够的转移特征。另外我强烈建议在划分后做一个实体级别的数据分布统计,打印每类实体在训练集和验证集的个数,确保没有零样本类别。这一步是后面所有调参的前提,数据分布都不对,调c1、c2没有任何意义。
3. 用pycrfsuite实现糖尿病医疗NER:特征工程与最小训练脚本
3.1 CRF为什么在这个任务上比规则和朴素分类器更稳
条件随机场(CRF)是序列标注问题的经典建模方式,核心优势是它建模的是整个标签序列的联合概率,而不是每个字符独立分类。具体到医疗NER,这意味着它会学到"B-disease后面大概率跟着I-disease,但B-disease后面不太可能直接接B-symptom"这类标签转移规律。在糖尿病文本里,"糖尿病"三个字如果分别是B-disease、I-disease、I-disease,那么下一个字即使特征不明显,模型也会倾向给I-disease或0,而不是跳到其他实体标签。这个全局约束能力是普通分类器不具备的。
pycrfsuite是CRFsuite的Python绑定,底层是C++实现,训练效率非常高。大概一万个字符的标注数据,加上中等规模的特征模板,几十秒内就能完成训练。而同等数据量的BiLSTM-CRF至少需要几分钟到几十分钟,还不算调参试错的时间。初赛阶段要频繁迭代特征,每轮训练都等半小时会很消耗时间,pycrfsuite几乎是唯一能满足快速试错的选择。
3.2 特征模板:把字符、词边界、词典特征组合成一个字典
pycrfsuite的坐标是特征函数返回的字典,每个键值对都算作一个特征。做医疗NER时我一般配四组特征:字符本身、字符的上下文字符、字符的位置标记、词典匹配标记。字符本身和上下文字符是让CRF学到"某字符在特定上下文里大概率是实体首字";位置标记是告诉模型"句首的字符更可能是实体开始";词典匹配标记则是在已有糖尿病术语表的基础上,标记当前字符是否命中了词典里的词条。这四组特征缺一组,F1都会有明显下降,尤其是词典特征,在专业术语上作用很大。
窗口大小我默认取2,就是看当前字符前后各两个字符的特征。窗口太大特征数会爆炸——假设字典里有50个键,窗口5就会产生250维特征,训练时间和模型体积都会翻倍,而F1提升非常有限。窗口2到窗口3之间通常能带来0.5~1个点的提升,再大就得不偿失。特征模板里还有一个不能不写的bias特征,它让CRF可以为每个标签学习一个基础偏置,相当于分类器里的bias项,不写这个模型效果会明显变差。
# feature_extractor.py # 定义CRF特征函数:核心是返回当前字符及上下文的特征字典 def word2features(sent, i): """ sent: list of (char, label) 或 (char, postag, label) i: 当前字符下标 返回特征字典,pycrfsuite要求所有特征值必须是字符串或布尔值 """ features = { 'bias': 1.0, # 基础偏置特征,必留 'char': sent[i][0], # 当前字符 'char.isdigit()': sent[i][0].isdigit(), 'char.isupper()': sent[i][0].isupper(), } # 前一个字特征 if i > 0: features['prev_char'] = sent[i-1][0] features['prev_char.isdigit()'] = sent[i-1][0].isdigit() else: features['BOS'] = True # 句子开头标记 # 后一个字特征 if i < len(sent) - 1: features['next_char'] = sent[i+1][0] features['next_char.isdigit()'] = sent[i+1][0].isdigit() else: features['EOS'] = True # 句子结尾标记 # 前两个字组合特征,捕捉常见医学词根 if i > 1: features['prev2_chars'] = sent[i-2][0] + sent[i-1][0] # 后两个字组合特征 if i < len(sent) - 2: features['next2_chars'] = sent[i+1][0] + sent[i+2][0] # 当前字符与前一字的组合,对"糖尿病""冠心病"这类两字词很有用 if i > 0: features['bigram'] = sent[i-1][0] + sent[i][0] return features # 句子级别的特征包装 def sent2features(sent): return [word2features(sent, i) for i in range(len(sent))] def sent2labels(sent): return [label for _, label in sent]这段代码里有个细节值得注意:组合特征是手工做的,而不是靠CRF自己去组合。原因是CRFsuite的线性链模型不会自动做特征交叉,每个特征都是独立的权重,想让模型学到"前一个字是'糖'、当前字是'尿'、组合起来大概率是疾病名开头",就必须显式地把bigram特征写出来。同样的逻辑适用于三字组合,但三字组合的特征数会急剧膨胀,我一般只对高频医学词根做白名单式的三字组合,而不是对所有字符组合都建特征。
3.3 最小训练脚本:从数据加载到模型保存
训练脚本的核心只有三行:创建Trainer、append特征和标签、train。但append的数据必须是已经转换成特征字典的格式,所以整个流程是原始文本 → 字符/标签对 → 特征字典列表。这里有个容易犯的错误是把特征字典变成了list of dict而不是dict,append直接报错,报错信息还不直观。
# train_crf.py # 完整的CRF训练脚本 import pycrfsuite def load_data(filepath): """从标注文件加载文本和实体,返回sentences列表""" # 这里的加载逻辑按竞赛数据格式实现,输出为 # sentences = [[('患', 'O'), ('者', 'O'), ('血', 'B-symptom'), ('糖', 'I-symptom'), ...], ...] pass def main(): # 1. 加载数据并转换为BIO序列 sentences = load_data('train.json') # 2. 转换为特征列表 X_train = [sent2features(s) for s in sentences] y_train = [sent2labels(s) for s in sentences] # 3. 创建CRF训练器 trainer = pycrfsuite.Trainer(verbose=False) for xseq, yseq in zip(X_train, y_train): trainer.append(xseq, yseq) # 4. 设置超参数 trainer.set_params({ 'c1': 0.1, # L1正则化系数,控制特征稀疏性 'c2': 0.01, # L2正则化系数,控制模型平滑 'max_iterations': 100, # 最大迭代次数 'feature.possible_states': True, 'feature.possible_transitions': True, }) # 5. 训练并保存 trainer.train('diabetes_ner.crfsuite') print("模型训练完成,保存为 diabetes_ner.crfsuite") if __name__ == '__main__': main()我用的是feature.possible_states=True和feature.possible_transitions=True这两个参数,它们让模型为所有标签对生成额外的状态特征和转移特征。如果不打开,CRF只会从训练数据中出现过的位置学习,对于数据稀疏的医疗文本,验证集里出现新上下文时预测会非常不稳定。代价是模型体积变大,训练时间稍微增加,但效果提升很客观。
max_iterations我初始设100,但实际训练中模型往往在30到50轮就收敛了。判断收敛的方法是看训练日志里是否连续多轮目标函数值没有明显下降,pycrfsuite的verbose=True可以打印每轮的损失值,初赛阶段建议开verbose,方便观察收敛曲线。如果100轮还不收敛,不要急着加大迭代次数,优先怀疑特征设计有问题,比如特征里有高基数连续值。
4. 调参与预测:c1、c2怎么配,实体怎么从预测标签里还原
4.1 pycrfsuite三个必调参数:c1、c2、max_iterations
c1是L1正则权重,c2是L2正则权重。L1会把不重要的特征权重压缩到0,起到特征选择作用,特征模板铺得很宽时c1调大一些能防止过拟合;L2把权重整体拉小,但不归零,适合让大量弱特征共同起作用。医疗NER场景下特征数量和样本量比值往往很高,我一般从c1=0.1、c2=0.01起步,然后分别试0.01/0.1和0.5/0.1两组,对比验证集F1再微调。如果发现训练集F1远高于验证集,明显过拟合,就加大c1到0.5甚至1.0;如果训练集和验证集F1都低,往往是特征表达力不够,这时调参救不了,要回特征工程补特征。
max_iterations不建议一开始就给很大值。CRF训练用的是LBFGS拟牛顿法,每轮迭代都要做一次全量前向后向计算,迭代次数和训练时长近似线性关系。我默认设100,绝大多数情况下足够。有些情况100轮不收敛是因为特征里混入了id类特征或者连续值的未离散化特征——CRFsuite默认把所有数值特征当作离散值处理,比如年龄42这个数值会被当成一个独立的特征键"42",而不是一个有序数值,这会导致特征空间膨胀。正确做法是数值特征做分桶离散化,或者直接砍掉。
还有一个细节:验证集上选模型不只看F1,还要看每类实体的召回率分布。医疗场景里,药物和检查实体的召回率往往低于疾病和症状,因为它们有大量别名和缩写。如果某类实体召回率特别低,优先检查特征模板里有没有覆盖这类实体的典型上下文词,比如检查类实体前经常出现"查""示""提示"等词,把这些词加进词典特征,比全局调参更直接。
4.2 预测流程与实体还原:从BIO标签到结构化输出
预测阶段和新数据的预处理必须和训练时完全一致,否则特征错位,预测结果会一塌糊涂。我用一个统一的函数处理输入文本:按字符切分、生成特征、调用tagger.tag得到标签序列,然后把BIO标签合并成实体列表。合并逻辑不复杂:遇到B-type开始一个实体,后续连续的I-type继续追加,遇到O或新B-type就收尾。
# predict.py # 加载模型并对新文本做实体抽取 import pycrfsuite def extract_entities(text, tagger): """ text: 原始中文文本 tagger: 已加载的pycrfsuite.Tagger对象 返回: list of {"text": str, "start": int, "end": int, "type": str} """ # 按字符生成特征,注意这里必须和训练时的数据形态一致 chars = [(ch, 'O') for ch in text] features = sent2features(chars) labels = tagger.tag(features) entities = [] i = 0 while i < len(labels): if labels[i].startswith('B-'): etype = labels[i][2:] start = i i += 1 # 收集连续的I-标签 while i < len(labels) and labels[i] == f'I-{etype}': i += 1 end = i entities.append({ "text": text[start:end], "start": start, "end": end, "type": etype, }) else: i += 1 return entities # 模型加载只需一行 tagger = pycrfsuite.Tagger() tagger.open('diabetes_ner.crfsuite') # 示例 sample = "患者男,58岁,2型糖尿病病史10年,近期出现视物模糊,查眼底提示糖尿病视网膜病变" result = extract_entities(sample, tagger) for ent in result: print(f"{ent['text']} -> {ent['type']} (位置 {ent['start']}:{ent['end']})")预测速度上,pycrfsuite在CPU上单条文本的标注时间在毫秒级,可以轻松支持线上实时调用。但要注意:Tagger对象不是线程安全的,多线程推理时每个线程都要单独open一次模型,或者做线程局部保存,否则偶发崩溃很难排查。我习惯在服务启动时预加载模型,而不是每次请求都open,这样能避免重复IO开销,也便于统一管理模型版本。
4.3 从初步结果反推特征优化方向
跑通预测只是第一步,真正让F1从80提到90的过程是不断分析Bad Case的过程。我会把验证集上预测错的实体全部打印出来:预测类别错的、边界错的、完全没识别出来的分成三个文件。边界错的往往需要加bigram特征或调整窗口;完全没识别出来的往往需要补词典特征;类别错的通常是上下文不够,需要看看前后几个字有没有更长的组合可以加进去。这里有个血泪教训:不要只看整体F1,一定要分实体类型去看,如果整体F1高是因为某一种大类实体拉上来,小类实体依然漏报严重,那模型在真实场景里不可用——医生关心的是漏诊,不是平均成绩。
5. 复现这条链路最容易翻车的四个位置:现象、原因、解决
5.1 训练完预测结果全是O标签
现象:模型训练过程中loss在下降,训练集上F1也正常,但对验证集和新文本预测时所有字符全部输出O,一个实体都抽不出来。
原因:这是特征模板和数据形态两处不匹配导致的。最常见的是训练时sent2features传入的sent是带label的完整三元组,但预测时只传了字符列表,特征模板里访问sent[i][1]时拿到的是O或其他占位符,导致预测时的特征空间和训练时完全不同。另一个常见原因是特征里混入了句子ID、样本ID这类全局唯一特征,CRF直接学到的是"某个特征只出现在某个标签上",因为这类特征在训练集里具有完美的记忆性,但新数据里特征消失,模型只能全部预测O。
解决:把训练和预测的特征生成函数严格统一,写成一个函数,训练和验证都调用它,不要搞两个版本。可以追加一个自检:训练完成后,拿几条训练集文本跑predict,如果训练集本身都预测不出实体,说明特征或代码有bug;如果训练集能预测而验证集全O,优先查是不是样本量太少导致的验证集特征覆盖不足。
5.2 实体边界总是错位,多一个字或少一个字
现象:实体类型预测对了,但边界往外扩了一个字,或者把相邻的一个字吃进来。比如"视网膜病变"识别成"糖尿病视网膜病变",把修饰语也圈进来了。
原因:CRF学到的是局部窗口内上下文信息的大概率判断,当修饰语频繁出现在疾病名前面时,转移特征会让B-disease的触发位置前移。糖尿病文本里大量出现"糖尿病肾病""糖尿病视网膜病变",模型很容易把"糖尿病"和后面的疾病名合并成一个长实体。
解决:在特征里增加实体长度分布的先验。具体做法是特征模板中加入"当前位置与句子开头距离""当前字符后面是否出现特定标点"等特征,帮助CRF区分修饰语和核心实体。另外可以在后处理阶段做一个过滤白名单:如果一个实体以常见修饰词结尾,且该修饰词单独作为实体出现的频率很低,就把这个字从实体末尾切掉。但后处理规则要克制,每加一条规则都可能引入新错误。
5.3 训练慢到让人怀疑人生
现象:几千条样本的训练任务,预期几十秒跑完,实际跑了十几分钟还在迭代,CPU占用却不高。
原因:特征数量爆炸。pycrfsuite的训练复杂度跟特征数量强相关,如果特征模板中包含了从训练数据动态生成的所有词组合,特征数量会到几十万甚至上百万级别,LBFGS每轮迭代都要处理这些特征的梯度。另一个原因是没设置feature.minfreq,导致只出现一次的特征也全部保留,这些稀疏特征对泛化毫无帮助,纯粹拖慢速度。
解决:设置feature.minfreq=2,让出现次数小于2的特征直接丢弃,大部分场景下能砍掉一半以上特征,F1几乎没有损失。同时检查特征模板里是否有类似word[-5:]这种超长后缀特征,中文里五字后缀没有区分度,改成word[-2:]和word[-3:]就够了。最后可以通过trainer.train('model.crfsuite', verbose=True)观察日志,如果每轮迭代耗时都在增加,说明特征量确实太大,需要回特征工程做裁剪。
5.4 预处理的文本和模型训练时的文本对不上
现象:训练时输入的是全角标点、带换行符的原始文本,预测时新文本经过了别的清洗流程,标点变成半角、空格被删除。预测结果在标点附近频繁出错,甚至完全没有实体输出。
原因:字符级NER模型对字符序列非常敏感。全角逗号"、"和半角逗号","在特征层面是两个完全不同的特征键,训练时模型学到了全角符号的上下文模式,预测时换成半角符号,所有邻接特征全部失配。
解决:在训练和预测的入口统一用一个清洗函数,做四件事:全角标点转半角、统一换行符为\n、删除不可见字符、把连续空格压缩。这个函数必须只有一个实现,训练和预测共用,绝不允许在数据管道中各自清洗。另外要检查文本编码,统一用UTF-8保存,不要在中间环节出现GBK和UTF-8混用。
6. 自建小样本上复现一遍:验证、优化和最终该养成的习惯
复现这套方案不要一上来就去跑完整竞赛集,先找50条自己手里的医疗文本,手动标注实体,把训练和预测链路跑通。这样能快速暴露代码问题,同时为下一步放大数据集打下可靠基础。验证模型质量时用精确匹配的F1,实体必须类型和边界完全正确才算对。
优化方向里最值得投入的是词典特征的扩充。竞赛数据只覆盖了一部分术语,真实场景里药品名、检查名浩如烟海,而且不断有新药出现。我的习惯是从公开医学词库、药品说明书和检验项目参考范围里抽取术语表,构建成词典文件,在特征模板里标记命中情况。这个词表要定期更新,尤其在跑新领域的医疗文本前先检查覆盖度。
最后想分享一个我自己的坏习惯:早期做NER总喜欢不断往特征模板里堆东西,觉得特征越多效果越好。后来发现,每加一个特征都要在验证集上跑一组实验,记录F1变化,无效特征立刻删除。现在我的特征模板已经稳定在一个相对精简的状态,跑一次实验几分钟出结果,整个迭代周期短,效率反而更高。
还有一个习惯是每次训练前固定随机种子,并在代码里打印数据分布摘要。CRF训练本身是确定性的,但数据洗牌、词典加载顺序都可能有随机成分,固定种子保证实验结果可复现。打印数据分布是为了在每次迭代前确认数据管道没有因为代码改动而悄悄改变。
这套基于pycrfsuite的糖尿病医疗NER方案,在初赛和真实小规模业务里依然是性价比最高的选择。训练快、可解释、部署轻,不需要GPU就能落地。希望这篇文章能帮你把这条路走通,省去我自己当年踩坑浪费的时间。
本文还有配套的精品资源,点击获取