news 2026/9/28 22:38:59

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
医疗命名实体识别小样本实战:基于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', encoding='utf-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', encoding='utf-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[start+1 : end]打 I 标签,end位置本身不打标签。这个语义必须和 brat 工具保持一致,否则实体边界会整体偏移一个字,后面评估时 F1 会非常难看。

2.3 标签体系设计:要不要区分实体的细粒度类型

实体类型的选择直接影响模型边界。我建议至少区分「症状」和「疾病」,因为糖尿病文本里「糖尿病」本身高频出现,如果全归成一个大类,模型容易把所有医学名词都预测成同一类,知识图谱构建时会丢失「症状—疾病—药物」之间的关系语义。初赛阶段五类足够,不要分太细,比如把「药物」再拆成药名和剂量,样本量不够时这类细分只会稀释每个类别的训练信号。

训练语料里每篇文本平均实体数量大概在 5~15 个,O 标签占比极高。这个类别不平衡问题后面通过特征和评估指标来处理,而不是加权重硬拉。

3. 特征工程:让 pycrfsuite 在千条样本上出效果

3.1 为什么特征比模型更重要

pycrfsuite 是 CRFsuite 的 Python 封装,核心算法是线性链条件随机场,它不自己学特征,只学特征权重。所以决定模型上限的是你喂进去的特征函数。深度学习模型能从原始字向量里自动抽象特征,CRF 做不到,必须人工设计。但反过来说,人工设计特征的好处是可控、可解释,出问题能定位到具体特征维度上。

对中文医疗 NER,我一般会用四组特征:字符本身、字的边界位置、词性线索、以及窗口上下文。医疗术语里「糖」「尿」「酮」「胰」这类字有强指示性,窗口特征能捕获「患者血糖升高」里「升高」跟在「血糖」后面的模式。

3.2 特征提取函数模板

def word2features(sent, i, LTP=False): cur = sent[i] pre = sent[i-1] if i > 0 else '#BOS#' nxt = sent[i+1] if i < len(sent)-1 else '#EOS#' pre2 = sent[i-2] if i > 1 else '#BOS#' nxt2 = sent[i+2] if i < len(sent)-2 else '#EOS#' features = { 'w': cur, 'w-1': pre, 'w+1': nxt, 'w-2': pre2, 'w+2': nxt2, 'w-1:w': pre + cur, 'w:w+1': cur + nxt, 'w-2:w-1': pre2 + pre, 'w+1:w+2': 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和w+1是核心,w-1:w和w:w+1是相邻字组合特征,这两个组合特征能抓住「血+糖」「胰+岛」这类双字医学词。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[i+1] 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

一段文本里实体的普遍特征是「长度 2~8 个字」和「依附于医学高频字出现」,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(verbose=True) 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.minfreq=0表示所有出现过的特征都保留,不设最小频次过滤。这个参数在样本小时建议保持 0,因为医学实体本来就是长尾分布,一个只在 3 篇文本里出现过的特征可能正是某个罕见疾病名的强信号。feature.possible_states=True会让模型为每个状态(标签)单独学习特征权重,而不是共享一套权重,这会增加模型体积但能提升精度。

4.2 交叉验证的代码路径

初赛评估一般用官方给的测试集,但本地要快速验证特征是否有效,必须自己搞交叉验证。pycrfsuite 的模型类不支持直接拿训练好的模型继续增量训练,所以每折都要重新训练,代价不大,因为 CRF 训练分钟级完成。

from sklearn.model_selection import KFold kf = KFold(n_splits=5, shuffle=True, random_state=42) all_y_true = [] all_y_pred = [] for fold, (train_idx, test_idx) in enumerate(kf.split(all_sequences)): trainer = pycrfsuite.Trainer(verbose=False) for idx in train_idx: trainer.append(x_all[idx], y_all[idx], 0) trainer.set_params(same_params) trainer.train(f'tmp_fold_{fold}.crfsuite') tagger = pycrfsuite.Tagger() tagger.open(f'tmp_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(f'tmp_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(f'P={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建点,识别结果直接批量导入。

最后给一条血泪教训:不要在特征函数里硬编码任何和测试集相关的统计值,我第二版加了全局词频统计特征后本地验证漂亮得不行,提交现场立刻露馅。从那以后我每次训练前都强制走一遍「特征函数参数只来自训练集」的检查清单,并且本地交叉验证的切分永远按文本文件分组。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/28 22:36:53

模型优化器实战:量化、算子融合与剪枝加速推理

1. 模型优化器到底在解决什么问题第一次接触 Model-Optimizer 这个概念&#xff0c;是在一个推荐系统的排序模型上。当时线上推理延迟卡在 85ms 下不去&#xff0c;GPU 利用率却只有 30% 出头&#xff0c;显存倒是先爆了。排查了一圈发现&#xff0c;问题不在模型结构&#xff…

作者头像 李华
网站建设 2026/9/28 22:34:37

OSI七层模型实战拆解:用分层思维快速定位网络故障

1. 为什么搞懂OSI模型比单纯记住七层名字更重要很多人在学习网络基础时&#xff0c;第一步就是背OSI七层模型的名字&#xff1a;物理层、数据链路层、网络层、传输层、会话层、表示层、应用层。背倒是都背下来了&#xff0c;可一旦遇到实际问题——比如网页打不开、视频连不上、…

作者头像 李华
网站建设 2026/9/28 22:34:36

滚轮缩放图片:基于transform和JavaScript的图片预览实现

最近好几个前端新手问我同一个需求&#xff1a;做一个图片预览功能&#xff0c;鼠标滚轮一滚&#xff0c;图片就能变大变小。很多人第一反应是给图片加一个 CSS3 的 zoom&#xff0c;结果要么在 Firefox 上没反应&#xff0c;要么缩放之后旁边的布局被挤得乱七八糟。如果你也正…

作者头像 李华
网站建设 2026/9/28 22:34:25

Docker网络原理与故障排查:从端口映射到跨主机组网

各位老哥&#xff0c;今天想跟大伙聊聊 Docker 网络这块儿。我最早接触 Docker 的时候&#xff0c;以为容器网络就是"跑起来就能访问"&#xff0c;结果第一次部署应用就被上了一课&#xff1a;容器起来了&#xff0c;MySQL 本机连不上&#xff0c;两个容器之间互相 p…

作者头像 李华
网站建设 2026/9/28 22:32:20

基本存储、存储堆栈与NAS的维度错位与选型指南

1. 三个名词的维度错位&#xff1a;它们为什么总被放在一起比较很多刚接触存储的人第一次看到这个标题都会愣一下&#xff1a;这三个概念真的能放在一起对比吗&#xff1f;说实话&#xff0c;我第一次被客户问到“基本存储和网络附加存储到底哪个好”的时候&#xff0c;也差点被…

作者头像 李华
网站建设 2026/9/28 22:32:06

AMC1306隔离Sigma-Delta电流采样与SDFM配置实战指南

1. 从一块AMC1306说起&#xff1a;为什么电机控制里电流采样这么难搞搞电机控制的人都有一个共识&#xff1a;算法写得再漂亮&#xff0c;电流采样一塌糊涂&#xff0c;整个系统就是空中楼阁。我做了七八年电机驱动&#xff0c;从最早的 shunt 电阻加运放方案&#xff0c;到后来…

作者头像 李华