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

医疗命名实体识别小样本实战:基于pycrfsuite的CRF特征工程与避坑

发布时间:2026/9/28 22:39:05

资讯中心
01
ARTICLE

医疗命名实体识别小样本实战:基于pycrfsuite的CRF特征工程与避坑

医疗命名实体识别小样本实战:基于pycrfsuite的CRF特征工程与避坑
简介这是天池瑞金医院MMC人工智能辅助构建知识图谱大赛初赛的参赛作品聚焦糖尿病相关医疗命名实体识别NER基于pycrfsuite实现。整套资料面向参加同类竞赛的算法学习者与医疗NLP入门者可直接用于复现赛题方案、理解条件随机场在实体抽取中的应用。压缩包共1699个文件约11.65MB主要包含784个csv数据文件、485个txt文本、421个ann标注文件以及7个py源码、1个ipynb分析脚本和1个markdown说明。csv与ann构成了典型的训练/测试语料与实体标注体系py和ipynb则展示数据处理、特征模板与模型训练流程结构清晰。目前已有145人学习浏览是初赛阶段较完整的参考实现之一从中可获取医疗文本标注格式、CRF特征工程思路、训练与预测脚本以及赛题文档等一手内容有助于快速上手基于传统机器学习方法的命名实体识别任务并为后续知识图谱构建提供基线方案。1. 医疗命名实体识别初赛复盘为什么 pycrfsuite 在 1300 条标注上比深度学习更能打做天池瑞金医院 MMC 知识图谱大赛初赛时摆在我面前的是一个很实际的选型问题糖尿病相关的医疗文本命名实体识别标注数据只有一千多条每条是几十到几百字的出院小结或病程记录。当时团队里有人提议直接上 BiLSTM-CRF 或者 BERT 微调我犹豫了一下最后选了 pycrfsuite 跑 CRF。原因不玄学样本量摆在那里深度学习模型在这种规模下学不到稳定的上下文模式反而 CRF 这种线性链模型能靠手工特征把领域先验灌进去训练快、可解释、迭代成本低。这个资源包来自初赛提交核心是一整套基于 pycrfsuite 的命名实体识别实现数据集是 MMC 大赛官方提供的 .ann 标注文件任务是识别糖尿病相关文本里的医学实体包括症状、检查、药物、疾病名等。适合两类人一是准备参加知识图谱类竞赛、想快速上手序列标注的选手二是做医疗文本处理但不想一上来就调 BERT 的生产环境工程师。下文我按自己拆这个竞赛包的实际路径来讲数据标注格式、特征模板、训练评估、避坑、以及最后怎么把实体输出用起来。2. 读懂 .ann 标注文件BIO 序列的构造与标签体系设计2.1 brat 标注格式的字段拆解打开压缩包里面是一堆以数字命名的 .ann 文件比如 9.ann、152_17.ann、133_1.ann每个文件对应一篇医疗文本。.ann 是 brat 标注工具的标准输出格式每行一个实体标注典型结构如下T1 Diabetes 0 8 糖尿病 T2 Symptom 45 51 多饮字段含义从左到右依次为标注 ID、实体类型 原文起始字符偏移 原文结束字符偏移、以及对应文本。注意偏移是字符级按 Unicode 码点算中文字符一个算一个这也是之后最容易翻车的地方后面避坑章细说。比赛任务要识别的实体类别常见设计有五类左右Disease疾病、Symptom症状、Drug药物、Test检查、BodyPart部位。标注文件里每一行给的是实体边界和类型但 CRF 需要的是「每个 token 一个标签」所以第一步要把 .ann 转成 BIO 序列。2.2 从 .ann 到 BIO 序列的转换脚本我的转换思路是先把原文按字切分然后根据 .ann 里的偏移量把每个字打上标签B 表示实体开头I 表示实体中间或结尾O 表示非实体。# -*- coding: utf-8 -*- import glob def ann_to_bio(text, ann_entities): # text: 原始文本字符串 # ann_entities: [(start, end, entity_type), ...] n len(text) seq [O] * n for start, end, etype in ann_entities: seq[start] B- etype for i in range(start 1, end): seq[i] I- etype return seq def parse_ann_file(ann_path): entities [] with open(ann_path, r, encodingutf-8) as f: for line in f: if line.startswith(T): parts line.strip().split(\t) rest parts[1].split() etype rest[0] start int(rest[1]) end int(rest[2]) entities.append((start, end, etype)) return entities if __name__ __main__: ann_file 9.ann text_file ann_file.replace(.ann, .txt) with open(text_file, r, encodingutf-8) as f: text f.read() entities parse_ann_file(ann_file) bio_seq ann_to_bio(text, entities) # 打印前 50 个字及其标签 for ch, tag in zip(text[:50], bio_seq[:50]): print(ch, tag)逻辑说明先用全 O 序列兜底再遍历实体标注把对应区间改写成 BIO 标签。这样做的关键假设是实体不重叠大赛数据基本满足。要注意parse_ann_file里对偏移量的解析brat 的偏移字段可能带分号分隔的多个区间比如多段非连续文本共用一个实体 ID初赛数据里很少见但如果遇到直接取第一段区间即可或者用split(;)[0]做保护。参数说明start和end是左闭右开区间seq[start]打 B 标签seq[start1 : end]打 I 标签end位置本身不打标签。这个语义必须和 brat 工具保持一致否则实体边界会整体偏移一个字后面评估时 F1 会非常难看。2.3 标签体系设计要不要区分实体的细粒度类型实体类型的选择直接影响模型边界。我建议至少区分「症状」和「疾病」因为糖尿病文本里「糖尿病」本身高频出现如果全归成一个大类模型容易把所有医学名词都预测成同一类知识图谱构建时会丢失「症状—疾病—药物」之间的关系语义。初赛阶段五类足够不要分太细比如把「药物」再拆成药名和剂量样本量不够时这类细分只会稀释每个类别的训练信号。训练语料里每篇文本平均实体数量大概在 515 个O 标签占比极高。这个类别不平衡问题后面通过特征和评估指标来处理而不是加权重硬拉。3. 特征工程让 pycrfsuite 在千条样本上出效果3.1 为什么特征比模型更重要pycrfsuite 是 CRFsuite 的 Python 封装核心算法是线性链条件随机场它不自己学特征只学特征权重。所以决定模型上限的是你喂进去的特征函数。深度学习模型能从原始字向量里自动抽象特征CRF 做不到必须人工设计。但反过来说人工设计特征的好处是可控、可解释出问题能定位到具体特征维度上。对中文医疗 NER我一般会用四组特征字符本身、字的边界位置、词性线索、以及窗口上下文。医疗术语里「糖」「尿」「酮」「胰」这类字有强指示性窗口特征能捕获「患者血糖升高」里「升高」跟在「血糖」后面的模式。3.2 特征提取函数模板def word2features(sent, i, LTPFalse): cur sent[i] pre sent[i-1] if i 0 else #BOS# nxt sent[i1] if i len(sent)-1 else #EOS# pre2 sent[i-2] if i 1 else #BOS# nxt2 sent[i2] if i len(sent)-2 else #EOS# features { w: cur, w-1: pre, w1: nxt, w-2: pre2, w2: nxt2, w-1:w: pre cur, w:w1: cur nxt, w-2:w-1: pre2 pre, w1:w2: nxt nxt2, shape: digit if cur.isdigit() else (alpha if cur.isalpha() else other), } return features def sent2features(sent): return [word2features(sent, i) for i in range(len(sent))]逻辑说明每个字抽 10 个特征维度。w-1和w1是核心w-1:w和w:w1是相邻字组合特征这两个组合特征能抓住「血糖」「胰岛」这类双字医学词。shape特征在医疗文本里对「3.5mmol/L」「5mg」这类数值加单位的结构很有用。参数说明#BOS#和#EOS#是句子边界占位符替代索引越界时的空值这比直接跳过句首句尾字的做法好因为句首的字往往有实体起始的高概率。shape里把中文归为alpha数字归为digit其他符号归为other这样的粗分足够。3.3 扩展特征标点、数字单位与上下文类别医疗文本和新闻文本最大的差别在于数值密集型表达「空腹血糖 8.2mmol/L」「糖化血红蛋白 7.5%」。这类表达如果只靠字符特征模型学到的是「8」「.」「2」「m」「m」「o」「l」各自独立出现非常稀疏。我一般会加一个特征当前字是否和数字/单位词相邻。def word2features_v2(sent, i): base word2features(sent, i) cur sent[i] prev sent[i-1] if i 0 else nxt sent[i1] if i len(sent)-1 else # 是否出现在数值上下文 if cur.isdigit(): base[DIGIT_POS] digit_in if prev.isdigit() and nxt in mmol/L%mg: base[UNIT_AFTER] prev nxt return base一段文本里实体的普遍特征是「长度 28 个字」和「依附于医学高频字出现」CRF 的转移特征会自动学习「B 后面跟 I 的概率高」「O 后面直接跳 B 的概率」。pycrfsuite 的特征模板是字典直接传进去它会为每个 key 为 True 的特征生成一个维度不需要手动做 one-hot。实际跑下来加了数值上下文特征后检查类实体如「血糖」「糖化血红蛋白」的 F1 提升了 3 个点左右因为「数字 单位」前后大概率是检查名或疾病名。注意不要把所有key为False的特征也放进字典pycrfsuite 只对存在的 key 建立维度。4. 训练与评估pycrfsuite 参数、交叉验证和坏样本分析4.1 训练脚本与参数选择pycrfsuite 的训练接口非常简洁核心是构造x_train每个样本是特征字典列表和y_train每个样本是标签列表然后调用train。import pycrfsuite trainer pycrfsuite.Trainer(verboseTrue) for xseq, yseq in zip(x_train, y_train): trainer.append(xseq, yseq, 0) # 最后一个参数是 group_id留给分折用 trainer.set_params({ c1: 0.1, # L1 正则系数 c2: 0.05, # L2 正则系数 max_iterations: 100, feature.minfreq: 0, # 特征最小出现频率 feature.possible_states: True, # 生成状态特征 feature.possible_transitions: True, # 生成转移特征 }) trainer.train(model.crfsuite) print(训练完成模型已保存)逻辑说明group_id参数是给分层交叉验证用的同一篇文本的字符序列会被分到同一折避免同一篇文本部分在训练集、部分在测试集导致的评估虚高。c1和c2是正则化系数医疗文本特征维度大但样本少正则化必须开否则特征权重会被少数样本里的偶然模式带偏。参数说明feature.minfreq0表示所有出现过的特征都保留不设最小频次过滤。这个参数在样本小时建议保持 0因为医学实体本来就是长尾分布一个只在 3 篇文本里出现过的特征可能正是某个罕见疾病名的强信号。feature.possible_statesTrue会让模型为每个状态标签单独学习特征权重而不是共享一套权重这会增加模型体积但能提升精度。4.2 交叉验证的代码路径初赛评估一般用官方给的测试集但本地要快速验证特征是否有效必须自己搞交叉验证。pycrfsuite 的模型类不支持直接拿训练好的模型继续增量训练所以每折都要重新训练代价不大因为 CRF 训练分钟级完成。from sklearn.model_selection import KFold kf KFold(n_splits5, shuffleTrue, random_state42) all_y_true [] all_y_pred [] for fold, (train_idx, test_idx) in enumerate(kf.split(all_sequences)): trainer pycrfsuite.Trainer(verboseFalse) for idx in train_idx: trainer.append(x_all[idx], y_all[idx], 0) trainer.set_params(same_params) trainer.train(ftmp_fold_{fold}.crfsuite) tagger pycrfsuite.Tagger() tagger.open(ftmp_fold_{fold}.crfsuite) for idx in test_idx: pred tagger.tag(x_all[idx]) all_y_pred.extend(pred) all_y_true.extend(y_all[idx]) import os os.remove(ftmp_fold_{fold}.crfsuite)交叉验证的指标不能只算 token 级 accuracy因为 O 标签占比 80%全预测 O 也能有 80% 准确率这个数字没有意义。要按实体级别评估只有预测出的实体片段和真实实体的起始偏移、类型完全一致才算对。token 级标签准确率高不代表实体被正确切分CRF 最常见的错误是实体边界多一个字或少一个字比如把「血糖升高」整体预测成实体而真实标注只有「血糖」。4.3 用 sklearn 计算实体级指标def extract_entities(bio_seq, text): entities [] i 0 n len(bio_seq) while i n: if bio_seq[i].startswith(B-): etype bio_seq[i][2:] j i 1 while j n and bio_seq[j] I- etype: j 1 entities.append((i, j, etype, text[i:j])) i j else: i 1 return entities # 计算 P / R / F1 true_entities extract_entities(y_true, text) pred_entities extract_entities(y_pred, text) true_set set([(s, e, t) for s, e, t, _ in true_entities]) pred_set set([(s, e, t) for s, e, t, _ in pred_entities]) tp len(true_set pred_set) p tp / len(pred_set) if pred_set else 0 r tp / len(true_set) if true_set else 0 f1 2 * p * r / (p r) if p r 0 else 0 print(fP{p:.3f} R{r:.3f} F1{f1:.3f})这个评估逻辑要严格按字符偏移对齐因为后续要对接知识图谱构建实体边界偏移一位会导致图谱里的属性值错误。我第一版特征只用了单字和窗口实体级 F1 大概 78%加入数字单位特征后到 82%再加入字在词中的位置特征首字、尾字、中间字后到 84% 左右。特征设计的边际收益在样本量只有 1300 条时比调模型参数明显得多。5. 避坑手册BIO 不一致、特征泄漏与 CRF 的玄学问题5.1 BIO 标注序列和原文长度对不上现象训练时直接报错append时提示xseq和yseq长度不一致或者交叉验证时的准确率异常低。原因.ann文件里的偏移是字符级但很多人在读原文时用了len(text)按 Unicode 码点算或者读取.txt文件时没有去掉换行符和首尾空格。parse_ann_file里拿到的偏移是按原始文件里的字节位置算的如果read()之后做了strip()字符索引整体左移实体偏移全错位。解决读原文后立刻计算raw_len len(raw_text)和 .ann 文件里最大的偏移量做一次核对如果差超过 1优先怀疑换行符。我建议读入后不做任何清洗先构造 BIO 序列再统一去除 O 标签对应的空白符而不是先清洗再标注。做一次样本级校验随机抽 5 个 .ann把标注实体对应的原文切片打印出来和标注文件里的实体文本比对这一步能挡住 90% 的偏移错误。5.2 交叉验证的 group_id 没传评估结果虚高现象5 折交叉验证 F1 到 92%但提交到官方评测只有 80%差一大截。原因同一篇文本的相邻句子高度相似如果不按文本分组同一篇被切进了训练集和测试集模型相当于见过原文。CRF 的上下文特征会把这变成记忆而非泛化。解决训练时trainer.append(xseq, yseq, group_id)的第三个参数必须填文本序号不能全填 0。交叉验证切分也要以文本为单位而不是以字符序列为单位。5.3 特征里混入了测试集统计信息现象加了某个「全局特征」之后训练集效果暴涨测试集反而下降。原因我在做特征时想把「糖尿病」这个高频词直接标记为强特征统计了全量语料里词频前 50 的医学词放进特征这个统计用了所有数据包括测试集。这属于特征泄漏特征值里带上了测试集的分布信息。解决所有统计类特征词频、字频、词性先验只在训练集上统计然后应用到训练集和测试集。其实更稳妥的做法是这类特征干脆不用因为样本量不大时CRF 自己能从窗口特征里学到高频词模式。5.4 预测阶段 Tagger 和训练阶段 Trainer 的特征不一致现象训练 F1 正常加载模型预测时所有实体都预测成 O。原因pycrfsuite.Tagger()用的特征函数和Trainer的不是同一个函数两个版本在shape特征的取值定义上不一致导致训练时特征是digit/alpha/other预测时变成了is_digit/is_alpha/is_other模型看到的特征空间完全不同自然全预测 O 或随机打标签。解决把特征函数定义在单独模块里训练脚本和预测脚本都从该模块导入同一个word2features不要复制粘贴。我在项目里用features_builder.py统一管理特征函数任何改动只在那个文件里做。5.5 训练完模型文件打不开现象trainer.train(model.crfsuite)成功但tagger.open(model.crfsuite)报错文件格式错误。原因多半是model.crfsuite被写成相对路径而当前工作目录不对或者训练时没有写权限导致写入了临时文件。另一个常见问题是模型文件被当作文本文件打开过编辑器自动加上了 BOM 头或换行修改。解决用绝对路径保存和加载tagger.open之前先用os.path.exists检查文件大小是否大于 0。另外CRFsuite 的模型不跨版本通用训练和预测要用同一个 pycrfsuite 版本建议在 requirements 里锁死版本号。6. 把实体输出对齐回 .ann写自己的实体回写工具6.1 从预测序列还原实体 JSON模型的预测结果是每篇文本一个标签列表最终提交前要还原成 .ann 格式或官方要求的 JSON。这里最关键的技巧是不要在转换中间环节用join()拼回去再重新找偏移而是直接在原始字符序列上记录起始和结束索引。def predict_to_ann(tagger, sent, features_func): xseq [features_func(sent, i) for i in range(len(sent))] pred_labels tagger.tag(xseq) entities [] i 0 n len(pred_labels) while i n: if pred_labels[i].startswith(B-): etype pred_labels[i][2:] j i 1 while j n and pred_labels[j] I- etype: j 1 entities.append({ start: i, end: j, type: etype, text: sent[i:j], }) i j else: i 1 return entities # 示例对单篇文本预测 tagger pycrfsuite.Tagger() tagger.open(/path/to/model.crfsuite) text 患者因多饮多尿伴消瘦半月入院查空腹血糖8.2mmol/L。 result predict_to_ann(tagger, text, word2features) for ent in result: print(ent[text], ent[type], ent[start], ent[end])这里还原出的start和end是字符偏移可以直接写回 .ann。注意text[i:j]的切片和偏移是一一对应的不要用len()重新算。实际项目里我通常还会附带一个normalized字段做术语归一化比如把「空腹血糖」和「FPG」映射成同一个概念 ID这对接知识图谱的实体对齐步骤能省很多时间。6.2 错误分布统计锚定边界错误和类型混淆拿到实体级预测后把错误实体按错误类型归类边界多字、边界少字、类型混淆、完全漏检、完全误检。这个统计比只看 F1 更有指导价值因为改进方向完全不同。from collections import Counter error_counter Counter() for true_ent in true_entities_all: tp_flag any(abs(t.start - p.start) 1 and abs(t.end - p.end) 1 and t.etype p.etype for p in pred_entities_all) if not tp_flag: # 判断是边界错还是漏检 overlap [p for p in pred_entities_all if p.start t.end and p.end t.start] if overlap: error_counter[边界错] 1 else: error_counter[漏检] 1 print(error_counter)我当时跑出来的结果中「漏检」占大头尤其是症状类实体原因很简单症状表达方式千变万化「多饮」「多尿」「消瘦」「视物模糊」这些词在训练集里覆盖不全CRF 遇到没见过的表达就会输出 O。解决办法不是加特征而是尽量扩大症状类实体的标注覆盖或者把症状类特征里加入「常见医学主诉词表」作为外部知识辅助特征。6.3 把 NER 输出喂给知识图谱构建初赛只是命名实体识别但整个大赛目标是在 MMC 知识图谱里做关系抽取和融合。我的经验是 NER 的输出格式在最初就要面向图谱设计实体文本保留原始写法同时生成entity_id字段方便和梅奥诊所的 MMC 本体映射。如果用 Neo4j 做图谱存储实体节点用MERGE按entity_id建点识别结果直接批量导入。最后给一条血泪教训不要在特征函数里硬编码任何和测试集相关的统计值我第二版加了全局词频统计特征后本地验证漂亮得不行提交现场立刻露馅。从那以后我每次训练前都强制走一遍「特征函数参数只来自训练集」的检查清单并且本地交叉验证的切分永远按文本文件分组。希望帮到你。本文还有配套的精品资源点击获取
02
RELATED NEWS

相关资讯

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

03
WHY YAOTU

想打造同款高转化官网?

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

◈

场景化定制

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

◐

营销型架构

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

▲

全周期服务

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

免费获取你的建站方案

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