简介:面向天池瑞金医院MMC人工智能辅助构建知识图谱大赛初赛,这份压缩包是基于pycrfsuite的糖尿病相关医疗命名实体识别参赛方案。资源面向参赛选手、自然语言处理学习者和医疗文本挖掘从业者,包含设计文档、源代码及配套数据说明,可用于理解CRF模型在医学实体识别中的完整落地流程。压缩包内共1699个文件,整体约11.65MB,以csv数据、txt语料、ann标注文件为主,另有7个Python脚本、1个ipynb分析笔记本和1个md说明文档,分别承担数据处理、特征提取、模型训练、结果评估与方案说明等角色。已有145人学习下载,目录结构清晰,能够帮助读者快速定位数据准备、模型实现和文档说明模块。通过学习该资源,可掌握医疗命名实体识别的标注格式、pycrfsuite特征模板设计以及预测输出处理等关键细节,为后续构建医学知识图谱或参加同类赛事提供可复用的代码基础。
1. 天池瑞金MMC糖尿病NER:pycrfsuite参赛源码拆解与复现路线
天池瑞金医院MMC人工智能辅助构建知识图谱大赛初赛,糖尿病相关医疗命名实体识别,基于pycrfsuite实现——这份参赛源码我拆过一遍之后,先给你一个结论:它比不少深度学习baseline更适合用来做人工智能大作业和毕设起步。整条流程从BRAT格式的.ann标注文件出发,经过BIO序列编码,再用CRF训练出糖尿病医疗文本的实体识别模型,最后输出带偏移的实体结果,直接对接知识图谱构建的上游。你拿到手的不只是三五个脚本,而是一条从病历语料到命名实体识别再到图谱入库的完整流水线。适合谁?准备打NLP竞赛、要交人工智能课程大作业、或者刚接触知识图谱构建但暂时还看不懂BERT源码的新手,都能从中找到能跑通的落地点。
2. 先看懂BRAT标注:.ann文件结构与BIO序列编码
2.1 病历标注的存储结构
在写任何训练代码之前,第一步不是调包,而是把随资源一起给出的标注文件读明白。你翻开资源会看到132_23.ann、9.ann、152_17.ann这类文件,每一个.ann都对应同一编号的病历文本。以132_23.ann为例,里面每一行都是以T开头的标注条目,结构是:实体编号、实体类型加起止偏移、原始实体文本。
T1 Diabetes_type 0 7 2型糖尿病 T2 Symptom 12 16 周围神经病变 T3 Lab 30 34 空腹血糖这类格式是BRAT标注工具的标准导出格式,实体类型写在中间一栏,冒号后面的数值是字符级偏移,右开区间。我一般会先把.ann读成结构化的实体列表,再和原文逐条核对。这里有个关键点:标注文本里的偏移量是按字符数算的,包含空格和标点,不是按分词后的词序号算的。很多人在第一步就用错索引,后面所有标签都会错位。
在这份糖尿病病历语料里,实体类型以糖尿病类型、相关症状、检查项目、检验指标、药物和剂量为主。同一个实体在不同病历里写法可能不同,比如空腹血糖和FPG指向同一概念,这类别名问题初赛阶段不用处理,但到了知识图谱构建阶段就得做实体对齐。所以解析.ann的时候,我习惯把类型和偏移拆开存,而不是把整行字符串丢给后续逻辑。
2.2 把标注转成BIO序列:字符级对齐
CRF训练需要的是字符或词与标签组成的有序序列。pycrfsuite接收的是特征列表和标签列表,标签必须按BIO规则来写,一般情况下用B-实体类型标记实体首字,I-实体类型标记实体后续字,其余位置标O。下面这段代码把.ann解析成实体列表:
import os, re def parse_ann(ann_path: str): entities = [] with open(ann_path, encoding='utf-8') as f: for line in f: line = line.strip() if not line.startswith('T'): continue fields = line.split('\t') if len(fields) < 3: continue type_pos = fields[1].split(' ') # type_pos = [实体类型, 起始偏移, 结束偏移] typ = type_pos[0] start = int(type_pos[1]) end = int(type_pos[2]) entities.append((typ, start, end)) return entities这里用utf-8打开文件,split('\t')以后取fields[1]再做空格拆分,因为BRAT的单行格式是T编号、类型加偏移、实体文本三列。注意type_pos里也可能出现多个偏移段,某些标注工具会记录不连续片段,初赛数据里一般只有一段,稳妥起见取第一段偏移。
接下来把实体区间转成字符级标签。核心思路是先建一个全O的标签数组,再遍历实体,在实体区间内改写B和I:
def bio_encode(text: str, entities): n = len(text) tags = ['O'] * n # 按实体长度降序,处理可能的重叠 entities = sorted(entities, key=lambda x: x[2] - x[1], reverse=True) for typ, start, end in entities: start = max(0, min(start, n)) end = max(0, min(end, n)) if start >= end: continue if tags[start] != 'O': continue # 已被更长的实体覆盖 tags[start] = 'B-' + typ for i in range(start + 1, end): if tags[i] == 'O': tags[i] = 'I-' + typ return tags这里有两个容易出错的地方。一是end是右开区间,所以实体覆盖的字符下标是start到end减一。二是当不同实体发生重叠时,先按实体长度降序处理,长实体优先占位,短实体如果落在已经被占用的位置就跳过。这样能避免同一个字被两个标签同时覆盖,CRF的状态序列也就始终保持合法。
写完转换逻辑,建议输出几行字符和标签的对照来验证:
text = '患者既往有2型糖尿病病史,空腹血糖偏高' entities = [('Diabetes_type', 5, 12), ('Lab', 15, 19)] tags = bio_encode(text, entities) for ch, tag in zip(text, tags): print(f'{ch}\t{tag}')这个打印结果能直观校验标签边界是否正确,尤其是实体紧挨着实体、实体中间夹着空格这类情况。如果看到某个实体首字前面跟的是I,或者两个实体之间没有O隔开,那就是实体解析或者偏移映射出了问题,趁早修正,别等训练完再回头查数据。
3. pycrfsuite建模:特征窗口、正则参数与模型持久化
3.1 为什么选择CRF而不是直接上BERT
先回答一个很多人会问的问题:现在都深度学习时代了,拿pycrfsuite做命名实体识别,是不是有点过气?我的看法是分场景。天池初赛给的是标注好的电子病历语料,数量不大,而医院病历文本的噪声主要在标点、全角半角、药品缩写上,这种数据规模下CRF能够用很小的训练成本拿到一份可解释的baseline。CRF的核心优势是它对标签之间的转移约束是显式的,B-药物后面可以直接接I-药物,但B-症状后面突然接I-药物就会被转移特征压低权重,这种约束在知识图谱构建上游非常重要,因为实体边界错了,三元组提取就全错。
另一方面,如果你只是要交人工智能大作业或者做毕设选题,CRF方案还能绕开GPU依赖,带一台普通笔记本就能跑完整个pipeline。后续要做模型升级,把特征函数抽成接口,再换成BERT-CRF也比较顺。这份资源真正值钱的地方在于把这类赛题的整个数据处理链路串通了,模型本身反而是最便宜的部分。
3.2 特征工程:序列标注的灵魂
有了BIO标签只是起步,接下来要把每个位置转成特征。pycrfsuite要求的输入是Item列表,每个Item可以挂任意多个字符串特征,训练器会给每个特征分配权重。特征是字符串,训练器会自己做特征哈希和权重学习,所以特征命名的一致性直接影响效果。
import pycrfsuite def extract_char_features(text: str, idx: int): feats = [f'w={text[idx]}'] if idx > 0: feats.append(f'w-1={text[idx-1]}') if idx < len(text) - 1: feats.append(f'w+1={text[idx+1]}') if idx > 1: feats.append(f'w-2={text[idx-2]}') if idx < len(text) - 2: feats.append(f'w+2={text[idx+2]}') feats.append(f'w.isdigit={text[idx].isdigit()}') feats.append(f'w.isalpha={text[idx].isalpha()}') return feats注意特征名必须是字符串,而且同一特征要带相同的名字。如果你把同一个位置一会写成w=血,一会写成w=血糖的血,训练器会把它当成两个不同特征,白白浪费样本统计量。字符级特征窗口一般取前后各两个字符就够用,再大的窗口会引入大量稀疏特征,训练时间变长但F1提升有限。
词典特征的做法是维护一份糖尿病领域词典,比如常见药物名、胰岛素字样、血糖指标缩写,当当前位置的字命中词典时,加入lex前缀的特征。资源设计文档里对这种词典匹配有专门说明,我按常规做法实现如下:
def extract_lexicon_feature(text, idx, lexicon): matched = None for w in lexicon: if text.startswith(w, idx): if matched is None or len(w) > len(matched): matched = w return f'lex={matched}' if matched else 'lex=None'词典匹配要按最长匹配来选,否则二甲双胍会被字典里的双胍先命中,导致边界识别靠特征也救不回来。生成训练样本时,把所有特征收集成Item对象:
def seq2items(text: str, idx: int): feats = extract_char_features(text, idx) feats.append(extract_lexicon_feature(text, idx, DRUG_LEXICON)) return pycrfsuite.Item(feats)这里我没有在Item里传form或pos这些字段,pycrfsuite.Item只要求传一个特征列表即可。如果你后续想叠加词性特征,把jieba或pkuseg的词性结果以pos=xxx的形式拼进特征字符串就行,不需要改数据结构。
3.3 训练参数与模型持久化
训练部分的核心逻辑很简单,把特征序列和标签序列append进去,然后设置参数。但参数设置有几个影响很大的开关,直接决定模型是欠拟合还是过拟合。
trainer = pycrfsuite.Trainer() for items, tags in train_sequences: trainer.append(items, tags) trainer.set_params({ 'c1': 0.1, 'c2': 0.02, 'algorithm': 'lbfgs', 'max_iterations': 200, 'feature.possible_states': True, 'feature.possible_transitions': True, 'feature.minfreq': 1, }) trainer.train('diabetes_ner.crfsuite')逐个说参数。c1是L1正则系数,倾向于让特征权重稀疏,对噪音特征多的场景有帮助。c2是L2正则系数,防止过拟合,病历实体类别多、样本少时c2一般设在0.01到0.1之间。algorithm建议用lbfgs,比pa和ap收敛得更稳。possible_transitions打开后,模型会显式学习标签之间的转移矩阵,例如B-药物后接I-药物不会收到惩罚,而B-症状直接跳I-药物则会被模型记住概率很低。
feature.minfreq设为1表示出现次数少于1的特征丢弃,基本等于不过滤。如果特征量太大,可以调到2或3,把只出现一两次的偶然特征过滤掉,训练速度会快不少。模型训练完后直接在项目目录下生成.crfsuite文件,这个文件是标准的libcrfsuite模型格式,预测时用Tagger打开即可。不要用pickle存pycrfsuite对象,后文避坑部分会细说。
4. 完整复现:数据划分、实体级F1与提交文件生成
4.1 数据划分与指标口径
做竞赛和做大作业最大的区别在于对指标的严谨程度。建议以病历文件为单位划分训练集和验证集,不要把同一条病历切成两半。如果同一份病历的上下文特征泄漏到验证集,验证分数会虚高,提交成绩却对不上。
from sklearn.model_selection import GroupKFold ann_files = ['132_23.ann', '9.ann', '152_17.ann'] # 实际按目录读取 groups = [f.split('.')[0] for f in ann_files] gkf = GroupKFold(n_splits=5) for train_idx, valid_idx in gkf.split(ann_files, groups=groups): train_files = [ann_files[i] for i in train_idx] valid_files = [ann_files[i] for i in valid_idx]GroupKFold的groups参数传文件编号,保证同一个病历的所有句子只落在训练集或验证集其中一侧。这一点对医疗文本尤其重要,因为同一份病历前后的表述高度相似,泄漏会让实体级F1虚高五到十个点。
评估命名实体识别一般有两个口径。一个按标签序列算accuracy,太粗,O标签占多数时accuracy会虚高。另一个按实体级别算precision、recall和F1,这也是知识图谱构建场景更关心的指标,因为后续三元组提取直接消费实体边界。实体级F1要求预测实体的类型、起始偏移、结束偏移和真实实体一致才算对。
def entity_f1(gold_entities, pred_entities): from collections import defaultdict tp = defaultdict(int) fp = defaultdict(int) fn = defaultdict(int) gset = set(gold_entities) # (类型, start, end) pset = set(pred_entities) for e in pset & gset: tp[e[0]] += 1 for e in pset - gset: fp[e[0]] += 1 for e in gset - pset: fn[e[0]] += 1 # 计算宏平均F1 total_f1 = 0.0 for typ in set(list(tp.keys()) + list(fp.keys()) + list(fn.keys())): p = tp[typ] / (tp[typ] + fp[typ] + 1e-9) r = tp[typ] / (tp[typ] + fn[typ] + 1e-9) total_f1 += 2 * p * r / (p + r + 1e-9) return total_f1 / len(set(list(tp.keys()) + list(fp.keys()) + list(fn.keys())))这里的集合运算能保证类型、起始偏移、结束偏移全部一致才算命中,偏移差一个字就是错误。实际比赛里偏移不对就是零分,这个口径要在本地先对齐,不要用宽松匹配自我安慰。
4.2 实体合并与提交文件生成
Tagger返回的是标签序列,但提交需要的是实体。BIO合并规则:遇到B-类型就开启一个新实体,后面紧跟同类型I-标签就继续追加,遇到O或者其他B-就结束。
def merge_entities(text, tags): entities = [] i = 0 n = len(tags) while i < n: if tags[i].startswith('B-'): typ = tags[i][2:] start = i j = i + 1 while j < n and tags[j] == 'I-' + typ: j += 1 entities.append((typ, start, j, text[start:j])) i = j else: i += 1 return entities合并时只允许B后面跟同类型的I,如果出现B-药物后面跟I-症状这种跨类型组合,直接断开,避免把两个不同实体拼成一个。这个逻辑写在提交脚本里,能挡住不少标签噪声导致的边界漂移。
提交文件按天池赛题惯例,每一行是实体ID、类型加起止偏移、文本,其中实体ID从1开始自增。注意偏移必须还原到原始病历文本的位置,而不是切分后某一句子的位置。如果你在预处理时对文本做了去空格、去换行之类的清洗,提交前必须做偏移映射,否则分数直接归零。
4.3 跑一遍全流程,确认模型可复现
资源里包含了设计文档和标注样本,我自己跑的时候习惯分三步:先只训练10个iteration,确认loss下降、标签长度一致;再全量训练,导出模型;最后在验证集上跑实体级F1。这三个步骤里任何一步打印出来的信息不对劲,都要停下来看数据,不要闷头调参。设计文档里提到的特征组合和参数范围,可以作为第一版配置直接用,跑出来的分数就是后续所有改动的对照基准。
5. 常见问题排查:pycrfsuite做医疗NER的四个必踩坑
5.1 标注文件编码与回车符导致偏移错位
现象:明明模型预测的实体文本是对的,打印出来也是空腹血糖,但用len比较,发现实体起止位置和标注对不上,提交后全部判定为位置错误。
原因:Windows下打开文件时\r\n被读成\n,BRAT标注的偏移是按原始文本算的,一旦你读取时用文本模式把\r去掉,字符位置就会整体前移。中文病历里还有全角空格和半角空格混用的情况,肉眼看不见,但偏移就是这么差出来的。
解决:用open(..., encoding='utf-8', newline='')读取txt,保持原始换行符不变;同时在解析.ann时打印前10个实体偏移与文本切片,人工核对一遍。不要用splitlines()后的行拼接还原全文,那会把换行符全部吃掉,偏移必错。
5.2 Trainer.append报错标签长度不一致
现象:traine r.append调用时报ValueError,提示输入的xseq和yseq长度不同;或者某些样本能append进去,训练到一半才崩。
原因:特征是按句子切分产生的,标签是按实体偏移生成的,两套切分逻辑对不上。常见做法是用split()切词后再给每个词打标签,但实体边界是按字算的,中英文混排时词列表长度根本对不上。
解决:统一按字符级序列来构造,一句病历文本的字符数就是标签数。如果非要使用词级序列,从.ann转BIO时就要按词对齐,而不是按字对齐。我在代码里加了断言,每条样本都检查len(items)==len(tags),不等就直接报错退出,绝不带着脏数据进训练。
5.3 模型文件换环境后加载失败
现象:本地训练好.crfsuite,传到服务器验证,Tagger.open报错找不到特征索引或者提示版本不兼容。
原因:pycrfsuite后台调用的是libcrfsuite,不同版本之间模型文件不保证兼容。再有就是有人用pickle.dump把tagger对象整个存下来,换环境反序列化时找不到原类型,训练时的特征字典也丢了。
解决:始终保留训练脚本,换环境后用同一版本pycrfsuite重新训练。分发模型时同时分发requirements.txt,把pycrfsuite版本钉住,比如pycrfsuite==0.9.7。如果你只是要给别人演示结果,直接给对方训练脚本和数据,比给模型文件更省事。
5.4 实体类别不均衡导致少数类召回率低
现象:验证集上药物类F1很高,剂量类几乎全是0,打印预测结果发现模型几乎不输出B-剂量。
原因:医疗文本里剂量实体往往伴随数字,但特征没有显式地加入当前字符是数字且前序字符是单位这类组合特征。加上训练样本里剂量类实体占比本来就低,转移特征学不到可靠的B到I的路径。
解决:在特征工程里加入数字单位组合特征,比如前一个字符是数字且当前字符是单位,同时检查训练集中各类别样本数。类别严重失衡时,可以对少数类样本做简单的同类病历过采样,或者让训练器对少数类标签的损失加权。这个坑最隐蔽,因为整体F1看起来还行,一拆类别就露馅。
6. 进阶:把NER结果接到neo4j,完成知识图谱构建的最后一公里
6.1 从实体列表到图谱导入
训练完的NER模型输出的是带有偏移的实体集合。知识图谱构建的下一步是把同一病历内的实体组织成三元组,MVP做法是构建症状与检查、疾病与药物一类的关联边,把实体和关系写成CSV,再利用neo4j的LOAD CSV导入。
LOAD CSV WITH HEADERS FROM 'file:///entities.csv' AS row MERGE (e:Entity {name: row.name, type: row.type});这里用MERGE而不是CREATE,是为了避免同一个实体名重复建节点。临床病历里同一概念会以不同写法出现,比如空腹血糖和FPG,严格做法是先在实体对齐层做归一化,至少要做到去重,否则图谱里会堆出一堆近义节点。
6.2 用错误样本反推特征
我在复现这套资源时,另一个习惯是自带一个badcase脚本:对验证集中每个预测错误的实体,打印所在句子、真实标签、预测标签。CRF的好处是特征权重可以直接查,哪条特征把模型带偏了一目了然。
def print_badcase(sent, y_true, y_pred): for i, (t, p) in enumerate(zip(y_true, y_pred)): if t != p: print(sent[max(0, i - 3):i + 4], '真实:', t, '预测:', p)权重检查则用tagger.weights()找到对应特征名的权重值,确认是不是某个词典特征权重过大,把旁边本应标成O的字符也拽进了实体。这套闭环改特征的方法,比盲目调c1和c2高效得多。
6.3 与BERT方案的取舍
如果你后续要把这个baseline做成毕设的完整方案,推荐走CRF到BERT-CRF的升级路径:CRF保持工程链路不变,把extract_features的输出换成BERT编码向量,再进CRF层做序列标注。两者对比下来,CRF在百级样本下能到稳定可用的F1,训练只要几十秒;BERT-CRF在小样本下容易过拟合,需要配合更大的正则和更小的学习率,优势要在语料扩充到千级以上才明显。
从那以后,我每次拿到竞赛源码,都强制自己先跑通标注解析、BIO编码、特征抽取、训练、实体合并、偏移校验这六步,再谈调优。这套习惯帮我少走了很多弯路,也让我在复现这个天池瑞金医院MMC初赛项目时,能快速把注意力放到特征和边界问题上。希望这份笔记里的流程能帮到你。
本文还有配套的精品资源,点击获取