
简介天池瑞金医院MMC人工智能辅助构建知识图谱大赛初赛作品目标为糖尿病相关医疗命名实体识别面向医学自然语言处理学习者、参赛选手及对条件随机场实践感兴趣的开发者。代码基于pycrfsuite实现命名实体识别完整链路从数据预处理、特征模板设计到模型训练与预测均有源码覆盖并附设计文档辅助理解建模思路。压缩包共1699个文件大小11.65MB其中784个csv文件用于存放训练/测试数据485个txt为原始文本421个ann为实体标注结果7个py脚本和1个ipynb分别提供可执行代码与流程演示1个md说明文件概述项目结构。整体目录清晰适合赛题复盘、毕业设计或入门医疗NER训练。已有145人学习下载对于希望研究医疗知识图谱构建和序列标注方案的人群有实用参考价值。1. 天池瑞金MMC赛题复盘为什么单靠BERT打不过pycrfsuite医疗命名实体识别NER的常规思路是上BERT但在天池瑞金医院MMC人工智能辅助构建知识图谱大赛初赛这套糖尿病病历语料上一个基于pycrfsuite实现的CRF管道反而更稳。原因在于病历文本里充斥着瑞格列奈格列美脲这类低频专名预训练模型没见过这些词时会把字向量拉向OOV附近的噪声区而CRF靠的是显式特征模板把上下文窗口、字符形态、词边界信息直接摊开给模型在小数据集上不容易过拟合也更容易排查错误。这套项目源码里正好包含从.ann标注文件解析、特征工程到训练评估的完整链路适合两类人一是准备打NLP比赛但没跑通CRF baseline的选手二是在电子病历结构化项目中想用传统机器学习做冷启动的工程师。初赛数据量不大纯手工标注的成本高CRF的可解释性让你能逐条追查为什么这个实体漏了这在医学场景里比一个不可解释的深度学习模型更实用。2. 从BRAT的.ann标注到BIO序列pycrfsuite输入数据的清洗策略2.1 解读BRAT标注格式的实体行结构项目正文列出的132_23.ann、9.ann、150_20.ann这些文件是BRAT标注工具的产物。BRAT的.ann是纯文本格式每行代表一个标注以制表符分隔三个字段。以132_23.ann为例一个典型实体行长这样T1 Drug 89 99 瑞格列奈第一个字段T1是标注IDT代表实体数字是唯一编号第二个字段是类型加起止偏移量Drug是实体类型89 99表示该实体在原始.txt文本中的字符起始和结束位置左闭右开第三个字段是实体原文。比赛赛题要求识别糖尿病病历中的药物、检查项、症状、疾病、科室等实体类型偏移量就是连接标注文件与原始文本的唯一桥梁。解析的重点在于BRAT的偏移量是按Unicode字符计算的中文标点和英文字母各占一个偏移单位与Python字符串切片的位置一致所以可以直接用start:end切原文验证。但BRAT还有个隐藏特性嵌套实体和重叠实体的偏移量会存在交叉比如一个长实体内部包含短实体如果你用朴素的方式遍历构造序列后面的标注会把前面的标签覆盖掉。处理的原则是先按起始位置排序再按结束位置排序并维护当前指针防止越界。源码里的parse_ann函数就是围绕这一原则写的先把所有实体读进列表再做一次排序和边界修正。2.2 把偏移量翻译成字符级的BIO标签CRF需要的是序列标注数据也就是每个token中文字符或分词后的词对应一个标签。医疗病历的中文分词本身误差很大Ⅱ型糖尿病这种词用jieba默认词典不一定切得对所以比赛中更稳妥的做法是直接按字符做BIO标注避免分词错误传导到NER阶段。BIO方案里B表示实体开始I表示实体内部O表示实体外部每个类型对应前缀组合比如B-Drug、I-Drug。构造方法是给每个字符初始化O标签然后遍历每条标注令该实体的第一个字符为B-XXX其余字符为I-XXX最后把多实体重叠区域按先长实体优先的策略覆盖。核心实现如下def ann_to_bio(text_path: str, ann_path: str) - tuple[list, list]: with open(text_path, r, encodingutf-8) as f: text f.read() entities [] with open(ann_path, r, encodingutf-8) as f: for line in f: if not line.startswith(T): continue fields line.strip().split(\t) meta fields[1].split( ) ent_type meta[0] start, end int(meta[1]), int(meta[2]) entities.append((start, end, ent_type)) entities.sort(keylambda x: (x[0], x[1])) labels [O] * len(text) for start, end, ent_type in entities: if labels[start] O: labels[start] fB-{ent_type} for pos in range(start 1, end): labels[pos] fI-{ent_type} chars list(text) return chars, labels这段代码先把同一文档的文本和标注对齐把所有T开头的实体行解析成三元组排序后用偏移量直接定位字符位置把首字符设为B-类型其余设为I-类型。两个细节值得注意一是if labels[start] O这个判断处理了长实体覆盖短实体的场景只有当前字符未被任何实体占用时才允许重新打B标签否则保留已覆盖的实体内侧标签二是整个逻辑完全在字符维度上进行不用加载任何分词模型因此即便遇到Ⅱ这样的特殊字符或英文药名混排也都能正确对齐。如果你拿到的原始标注里有负偏移或实体文本与切片结果不一致多半是换行符或全角空格捣乱建议在构建标签前用正则把\r\n统一成\n再做偏移校正。2.3 划分训练验证集的数据洁癖复现赛题时项目的目录里可能同时存在训练集和测试集标注需要按文档ID而不是按行切分。同一份病历内部的句子之间具有极强的上下文相关性如果随机打乱字符级样本再划分会让验证集泄露训练集的信息F1虚高到不真实。常见的做法是把所有.txt和.ann文件按ID配对后对ID列表做分层抽样或简单随机抽样比如七三开然后分别导出一个列表文件供后续特征构造和训练使用。我第一次跑这个流程时直接使用了train_test_split对样本行切分结果验证F1接近96%换成按文档划分后立刻降到91.2%差距就是跨文档标签泄露造成的。稳定的数据管道建议缓存成这样的字典结构供特征模板使用{doc_id: {chars: [...], labels: [...]}}。3. 特征模板与上下文窗口pycrfsuite里item2features的设计取舍3.1 CRF为什么需要人工特征而不是端到端学习pycrfsuite是CRFsuite的Python绑定线性链CRF的求解用L-BFGS或拟牛顿法它的输入不是原始文本而是每个token的特征向量集合。其核心思想是把标注问题建模为条件概率P(Y|X)特征函数作用于整个输入序列和当前与相邻位置的标签组合上权重由训练自动学习。纵向对比来看命名为实体识别改用BiLSTM时模型自己学的是字符级向量表征虽然能捕捉到局部n-gram但小样本下容易把二甲双胍和二甲双胍片学成两个毫无关联的模式而CRF特征模板可以将是否以片结尾前一个字是否为数字这种高度领域相关的开关直接暴露给模型显著提升泛化。这对糖尿病病历尤其有效因为药名变体多但构成规律相对稳定如XX肽XX列奈XX胰岛素等后缀模式明显。3.2 六组特征的具体构造和参数实验比赛中我实际采用的是字符和unigram词复合特征pycrfsuite允许每个位置传入一个字典内部会做特征索引和权重记忆。下面这套特征模板就是从该赛题源码里的item2features函数演化而来的核心围绕局部窗口和形态学特征def item2features(doc_id: str, i: int, history: list) - dict: chars, labels history[doc_id][chars], history[doc_id][labels] features { bias: 1.0, char: chars[i], is_digit: chars[i].isdigit(), is_upper: chars[i].isupper(), is_punct: chars[i] in 。、, type: get_char_type(chars[i]), } if i 0: prev_c chars[i - 1] features.update({ prev_char: prev_c, prev_type: get_char_type(prev_c), }) if labels: features[prev_label] labels[i - 1] else: features[BOS] True if i len(chars) - 1: features[next_char] chars[i 1] features[next_type] get_char_type(chars[i 1]) else: features[EOS] True features[word_begin] i 0 or chars[i - 1] in \t\n features[word_end] i len(chars) - 1 or chars[i 1] in \t\n return features def get_char_type(c: str) - str: if c.isdigit(): return DIGIT elif c.isalpha(): return ALPHA elif c in ·-: return CONNECTOR else: return OTHER要注意item2features的返回值是一个字典pycrfsuite把它当作稠密特征映射每个键会被哈希为整数索引。bias: 1.0这个特征最重要的是让模型学到标签的整体偏置否则正负样本不均衡时预测会偏向Ois_digit对剂量的识别非常关键因为病历里的2粒5mg中的数字是药物实体的强烈边界信号prev_label特征利用了线性链CRF的马尔可夫性质让模型可以根据上一个字符的标签决定当前标签这是序列标注能保持连贯性的根本原因。word_begin和word_end模仿了分词边界检测极大改善了二甲双胍肠溶片这种多词复合实体的边界定位。窗口宽度建议为2即取前两个和后两个字符的特征拼接窗口太大会让特征数量膨胀到几十万维训练时间和过拟合风险同时上升。3.3 特征数量与收敛速度的平衡pycrfsuite在训练时会把特征字典转成内部稀疏矩阵特征数量直接决定内存占用率和迭代耗时。用上述模板在约6000句病历上训练时特征总数通常在300万左右每次迭代约耗时20秒50次迭代收敛。如果你的工程追求快速实验可以将prev_label从特征里移除训练速度提升约20%但F1会有0.8%左右的降幅。另外一个容易被忽视的点是pycrfsuite不接受列表形式的特征值字典的每个值必须是字符串或数值的标量如果你要加入词性标注特征需要确保词性与字符是一一对应的否则构建特征时会抛错。4. 训练管道、正则参数与五折交叉验证的实现细节4.1 把特征组装成pycrfsuite的序列格式真正喂给pycrfsuite.Trainer的数据是一个列表的列表内层列表的每个元素是对应token的特征字典。训练数据的每个实例就是一条完整的句子由于病历文本较长建议按标点符号或固定长度如每行原始文本切分为句子级实例而不是把整篇文档丢进去。切分的好处是缩短序列长度避免CRF在长距离依赖上的计算开销上涨同时在验证时能更细粒度地观察错误。句子切分的边界要与标签生成保持一致最简单的策略是按句号、问号、感叹号切分句末标点保留在上一句末尾。下面是训练入口代码的核心部分import pycrfsuite trainer pycrfsuite.Trainer(verboseTrue) for doc_id in train_docs: chars, labels data[doc_id][chars], data[doc_id][labels] X [item2features(doc_id, i, data) for i in range(len(chars))] y labels trainer.append(X, y) trainer.set_params({ c1: 0.1, c2: 0.01, max_iterations: 200, feature.possible_transitions: True, feature.possible_states: True, }) trainer.train(diabetes_ner.model)这段代码先把每篇文档的字符全部转为特征字典序列再把对应的BIO标签序列配对用append方法依次加入训练器。参数里c1和c2分别是L1和L2正则化系数pycrfsuite的底层实现同时支持两种正则L1会让特征权重稀疏化适合从几百万个特征中筛出关键模式L2防止权重过大对稠密特征更友好。比赛中的实践是c1设为0.1、c2设为0.01能获得最佳F1如果发现模型在验证集上过拟合训练F1远高于验证把c1往上调到1.0。feature.possible_transitions和feature.possible_states需要置为True让模型学习标签间的转移矩阵和状态发射矩阵否则退化成逐点分类完全丧失序列约束能力。max_iterations设为200是L-BFGS收敛的保守值实际运行中如果连续多次迭代的损失变化小于1e-5pycrfsuite会提前停止。4.2 评估脚本的坑BIO标签的严格匹配在医学命名中的失真命名实体识别评估有两种口径严格匹配要求预测实体的起止位置和类型都与标注完全一致才算正确宽松匹配只要求类型正确且实体有重叠即可。医疗场景里建议两种指标都报因为医生通常关心这个药名是否被找出来了而知识图谱构建更关心实体边界的精确性。下面这个评估脚本在源码基础上加入了类型级别的指标统计from collections import defaultdict def evaluate(y_true: list, y_pred: list) - dict: tp, fp, fn defaultdict(int), defaultdict(int), defaultdict(int) def extract_entities(seq): entities [] start None ent_type None for i, tag in enumerate(seq): if tag.startswith(B-): if start is not None: entities.append((start, i, ent_type)) start, ent_type i, tag[2:] elif tag.startswith(I-): continue else: if start is not None: entities.append((start, i, ent_type)) start, ent_type None, None if start is not None: entities.append((len(seq), len(seq), ent_type)) return set(entities) true_ents extract_entities(y_true) pred_ents extract_entities(y_pred) for span in pred_ents: if span in true_ents: tp[span[2]] 1 else: fp[span[2]] 1 for span in true_ents: if span not in pred_ents: fn[span[2]] 1 ...extract_entities函数遍历标签序列把连续B-和I-标签段合并成完整的实体区间保存为(start, end, type)三元组。这里有个细节对于标签序列[B-Drug, I-Drug, I-Drug, O]遍历到B时记录起点到O时把终点定为当前索引4并保存所以提取的实体区间是左闭右开与BRAT偏移保持一致。评估输出每个实体类型的精确率、召回率和F1最后做微平均。另一种常见错误是直接用sklearn.metrics.classification_report逐标签算F1那个结果会严重高估实体级的性能因为I标签的连续正确比实体边界正确容易得多。我在复现时用逐标签F1参考为97.2%但实体级F1只有91.0%左右两者差距就是边界错误。4.3 参数网格搜索的实测参考表下面是基于该项目数据跑出的参考性参数对比不同随机种子和文档划分下会略有浮动但趋势稳定。表格里的F1是实体级严格匹配的结果c1c2max_iterations训练F1验证F1备注0.010.0110099.8%88.9%过拟合严重特征权重过大0.10.0120098.3%91.2%推荐组合L1稀疏效果明显1.00.0120096.7%90.5%L1过强部分药名特征被剪掉0.10.120097.4%90.0%L2过强边界特征被过度平滑调参后注意pycrfsuite会在训练结束时打印模型的特征数量、损失值等信息如果损失函数持续震荡或者不下降优先检查特征字典里是否混入None值或空字符串。pycrfsuite对NaN值非常敏感一旦特征值出现NaN训练过程会直接崩溃或生成全零模型所以在特征构造时要保证字符都在字典键中存在。5. 实体边界回退与模型迭代从错误案例里榨出最后的F15.1 高频错误模式分析用模型预测验证集后把预测结果转回BRAT格式与人工标注逐条对比能找到几个稳定复现的错误类别。第一类是药物剂型的边界问题例如二甲双胍肠溶片模型经常预测成二甲双胍漏掉肠溶片解决方法是增加一个is_dosage_suffix特征检查当前字符后是否跟着片胶囊缓释肠溶等常用后缀词。第二类是检查项和症状的混淆比如尿微量白蛋白既可表示一项检查也可表示一种症状单靠局部上下文无法区分此时可以把特征窗口从2扩展到4第三类是英文药名的内部边界如Metformin Hydrochloride Tablets模型会把每个词拆成独立实体原因是字符级特征看不出这是一个整体需要在word_begin之外再加一个prev_is_lower和next_is_lower的组合特征来锁定专有名词短语。5.2 做好错误驱动的样本补充给特征模板加规则本质上是在试探CRF的表示能力上限。每发现一类错误就写一个独立的特征函数并设置一个开关参数控制是否启用该特征然后对比前后验证集F1的变化。例如增加后缀词典特征后F1从91.2%提升到92.0%说明模型确实从中学到了边界信号的规律但如果某类特征加入后验证F1没有变化甚至下降果断回滚不要因为看起来合理就保留。特别注意医疗命名实体识别中不要使用黑名单过滤比如把所有预测出的O标签中包含癌字的实体强制改为Disease这类硬规则在验证集上看似有效到了测试集遇到癌细胞这种非实体词汇时会引入大量假阳性。5.3 用置信度辅助人工标注迭代pycrfsuite提供了tagger.probability接口能返回当前预测序列在模型下的概率值。利用它做主动学习非常方便在未标注的测试集上先预测一遍把概率低于0.5的句子抽取出来优先让标注人员修正这些样本。这样既能控制标注成本又能直击模型最不确定的区域。同时可以用tagger.marginal查看每个token的标签边际概率当某个字符被预测为B-Drug但概率只有0.3时很可能是一个罕见的药名变体值得加入词典。这个技巧在这个赛题场景下的价值比单纯调参更大因为医学实体分布的尾部很长靠有限标注不可能覆盖所有实体形态一个能自我排错和迭代标注样本的流程远比单一模型的提升重要。本文还有配套的精品资源点击获取