☰
医疗实体识别实战:词典构建与CRF标注训练全流程解析
2026/10/1 12:23:40 网站建设 项目流程

简介:基于Python+Jupyter构建的医疗实体识别模型完整资料包,面向自然语言处理初学者、课程设计及毕业设计学生,解决医疗文本中疾病、症状、身体部位等实体自动识别与标注难的问题。项目自带词典和语料标注,疾病词典39615条、症状词典7457条、身体部位词典1929条,支持基于词典的最大匹配与实体类型标注,并包含了数据预处理、模型构建、训练评估的完整源码与文档。资源共147个文件,主要涵盖ipynb模型构建笔记、py脚本、dic词典、txt文本语料、xlsx标注表、checkpoint模型权重等类型,压缩包约581MB,目录结构清晰,便于按模块查阅和二次开发。目前已有90人学习下载,实测稳定,适合在此基础上扩展更多医疗实体类别或迁移至其他领域文本,是期末大作业和项目实践的可信参考。

1. 医疗实体识别为什么绕不开词典和标注这两座山

医疗实体识别这个题目,在期末大作业和课程设计里翻车率极高。常见现象是:学生上来就套一个 BiLSTM-CRF 或者 Bert 模型,数据集却只有医院官网随便扒下来的几十条文本,训练完的效果跟随机猜差不多。而真正的问题是,医疗文本里实体类型杂、缩写多、边界模糊——“高血压”和“血压升高”在词典里是两条记录,但语义上是同一个实体;病案首页里“否认高血压史”中的“高血压”又根本不该被识别成阳性发现。这些都是纯模型管不了的,需要有词典作为知识约束,需要标注语料教模型看上下文。本标题的关键路线很明确:用 Python + Jupyter 把词典构建、语料标注、模型训练串成一个能跑通的闭环,再把源码和文档整理成交付物。这套思路尤其适合那种“不能只交代码还要答辩讲清楚”的课程设计场景。全文按“构建词典 → 标注语料 → 训练模型 → 迭代评估 → 交付项目”的顺序展开。

2. 搭框架:从医疗文本到可训练语料的完整加工链

拿到课题先别急着写代码。医疗 NER 项目通常死在一个问题上——拿什么数据来训模型。所以先把数据加工链搭起来:原始医疗文本 → 词典匹配候选 → BIO 标注序列 → 训练语料。这一步没有做扎实,后面所有模型实验都是沙上建塔。

2.1 实体类型定义与标注规范:先定 BIO 标准

医疗实体识别第一步不是写模型,而是定义实体类型。常见的医疗实体划分包括疾病名称、症状表现、药物名称、检查项目、手术操作这五类。课程设计不需要做得特别细,4 到 6 类足够展示工作量。我建议第一版只做四类:Disease(疾病)、Symptom(症状)、Drug(药物)、Check(检查项),后续答辩时可以说“预留了扩展接口,可以再加解剖部位、手术操作等类别”。

确定类型后要选定标注粒度。常见做法是 BIO 三段标注:

  • B- 开头表示实体的第一个字
  • I- 后续表示实体中间或结尾的字
  • O 表示非实体

为什么不推荐更细的 BIOES?因为 E(End)的好处是让模型知道实体边界,但在学习率调不好时更容易出现标签混乱。课程设计阶段 BIO 足够。以下是一个最小可运行的标注规范校验小函数,用来检查标注序列是否合法:

def validate_bio(labels): """校验 BIO 标签序列的合法性,返回错误信息列表。""" errors = [] prev = "O" for idx, label in enumerate(labels): # 规则1: I 标签前面不能是 O if label.startswith("I-") and prev == "O": errors.append(f"位置 {idx}: I- 标签前出现 O") # 规则2: 实体类型只能取自预设集合 if label != "O" and label.split("-")[-1] not in {"Disease", "Symptom", "Drug", "Check"}: errors.append(f"位置 {idx}: 未知实体类型 {label}") prev = label return errors labels = ["O", "B-Disease", "I-Disease", "O", "B-Symptom"] print(validate_bio(labels)) # 输出 []

注意第二个规则用label.split("-")[-1]取实体类别,这一步看似多余但很关键。标注过程中很容易手滑打出“B-disease”这种全小写标签,模型训练时会把“disease”和“Disease”当成两类,导致分类头输出维度翻倍,训练损失直接失控。所以校验器的价值不只是检查 BIO 顺序,更是统一标签大小写规范。

2.2 词典构建:从词表到可加载的映射结构

医疗 NER 里的词典,本质是一个“词条 → 实体类别”的映射表。文件格式我一般用最简单的两列 TSV:第一列是词条,第二列是实体类别。课程设计阶段不需要做太复杂的词典管理,一份 500 到 1500 个词条的词典就足以支撑实验。来源可以是公开的医学词库、药品说明书里的通用名、教材附录的疾病索引,或者从既往病例文本里人工摘录高频词。

这里有个容易被忽略的点:医疗实体存在大量同义词。比如“高血压”和“血压高”、“急性心肌梗死”和“心梗”。建议在词典里给每个词条加一列“标准名”,作为实体归一化用途。虽然 NER 任务本身不要求归一化,但答辩时老师大概率会问“怎么处理同义词”,到时候能反问一句“是不是需要做实体链接”会显得很有想法。

词典加载代码可以写成这样:

def load_dict(dict_path): """加载词条词典,返回 {词条: 类型} 映射。 参数: dict_path: 词典文件路径,每行格式为 "词条\t类型" """ term_type = {} with open(dict_path, "r", encoding="utf-8") as f: for line in f: line = line.strip() if not line: # 跳过空行 continue parts = line.split("\t") if len(parts) != 2: print(f"警告: 跳过非法行 {line}") continue term, type_ = parts term_type[term] = type_ return term_type dict_data = load_dict("medical_dict.tsv") print(len(dict_data)) # 实际打印词条数量

词典文件的常见编码坑在 Windows 环境下很致命。用记事本另存的文本经常是 UTF-8 with BOM 或 GB2312 编码,open()里只写了encoding="utf-8"就会在读取一行时突然报UnicodeDecodeError。保险做法是统一使用 UTF-8 编码保存,并在代码里捕获一次编码异常。我一般会在项目根目录放一个.gitattributes文件强制所有.tsv文件按 UTF-8 提交,这手操作在答辩前合代码时非常省心。

2.3 语料标注:用词典做预标注再人工修正

课程设计阶段的语料来源通常有两种:自己从公开医学文本摘录,或者用现成的电子病历脱敏数据。无论哪种,都需要面对一个现实问题——人工从零标注一批 500 句文本要两天,用词典预标注再人工修只要半天。方式是用词典做最长匹配,把句子里的词条标记出来,生成一个“标注候选”,人只负责确认和修正边界。

预标注函数的实现并不复杂,但要注意匹配顺序。需要先按词条长度从长到短排序,优先匹配长词,避免“十二指肠溃疡”被拆成“十二指肠”和“溃疡”两个实体。以下是具体实现:

def dict_pretag(sentence, term_type_dict): """基于词典的最长匹配预标注,返回 (words, labels) 列表。""" terms = sorted(term_type_dict.keys(), key=len, reverse=True) words, labels = [], [] i = 0 while i < len(sentence): matched = False for term in terms: if sentence.startswith(term, i): type_ = term_type_dict[term] for j, ch in enumerate(term): if j == 0: labels.append(f"B-{type_}") else: labels.append(f"I-{type_}") words.append(ch) i += len(term) matched = True break if not matched: words.append(sentence[i]) labels.append("O") i += 1 return words, labels

这里的startswith(term, i)是逐字切分的核心。Jupyter 里把这段封装成一个函数后,再写一个展示函数:分词后的结果按“字标签对”打印,人工一眼能看出匹配是否合理。整个预标注流程在 notebook 里非常顺滑。但要注意,词典匹配永远有边界问题,比如“患者于2023年服用阿司匹林”中的“阿司匹林”会被正确匹配,而“阿司匹林肠溶片”如果词典里有“阿司匹林”和“阿司匹林肠溶片”两条,那长词优先策略会直接匹配后者。默认做法是词典里同时保留短词和长词,不开贪心合并,让模型自己去学边界。

3. 模型选型:从词典匹配到序列标注的两级跳

语料准备完之后,就到了“选什么模型”的问题。课程设计答辩时最怕的场景是:老师问“为什么用这个模型”,学生答“别人说这个效果好”。本节从医疗文本特点出发,讲清规则 → CRF → 深度学习这条阶梯上每一步的取舍。

3.1 词典匹配的边界能力评估

先写一个纯词典匹配评估脚本,把它当作 baseline。这个 baseline 有两个作用:一是验证语料与词典的质量,二是给你一个对比起点。这里注意,比的是“实体级别的精确率和召回率”,不是逐字的准确率。如果词典匹配在测试集上的 F1 已经到 85%,那模型优化空间并不大,问题可能出在词典覆盖不够而不是模型能力不够。

评估环节有个很容易漏掉的细节:要把“标准答案实体集合”和“预测实体集合”都转成集合形式,然后再算交集。每个实体用(起始位置, 结束位置, 类型)三元组表示,这样可以准确判断实体边界是否完全一致。

def extract_entities(words, labels): """把 (word, label) 序列转成实体三元组集合。""" entities = set() start, end, type_ = None, None, None for idx, (word, label) in enumerate(zip(words, labels)): if label.startswith("B-"): # 遇到新的B标签,把上一个实体存起来 if start is not None: entities.add((start, end, type_)) start, end = idx, idx + 1 type_ = label[2:] elif label.startswith("I-") and start is not None: end = idx + 1 else: if start is not None: entities.add((start, end, type_)) start, end = None, None, None if start is not None: entities.add((start, end, type_)) return entities

用这段代码把预测结果和标准结果都转成集合后,len(pred & gold)就是完全命中的实体数。这套逻辑在课程设计里属于“必背代码”,它不牵涉任何框架,单靠 Python 原生的集合运算就把评估指标算清楚。

3.2 上 CRF:用 sklearn-crfsuite 做序列标注的实操

词典匹配解决不了“上下文判断”。比如句子“无明显阳性体征”里的“阳性”不是独立实体;“患者否认糖尿病史”里的“糖尿病”虽然被词典命中,但归类上要看句意。CRF 能做的是:学到“模糊否定词后面出现的疾病名词大概率不是当前患病状态”这类上下文规律。

使用sklearn-crfsuite是目前比较成熟的做法。它把 CRF 封装成了 sklearn 风格接口,训练和预测方式跟你熟悉的fit/predict一致。以下是一个可以直接在 Jupyter 里跑起来的最小训练代码:

import sklearn_crfsuite def sent2features(sent_words): """为句子中的每个字构造特征模板。""" features = [] for i, word in enumerate(sent_words): # 单字特征: 字本身 + 词性扩展位 char_feats = { "char": word, "is_digit": word.isdigit(), "is_punct": word in ",。、;:", } # 前后各看一个字,给模型提供上下文 if i > 0: char_feats["prev_char"] = sent_words[i-1] else: char_feats["prev_char"] = "<BOS>" if i < len(sent_words) - 1: char_feats["next_char"] = sent_words[i+1] else: char_feats["next_char"] = "<EOS>" features.append(char_feats) return features # X_train: 每个句子转成特征列表的列表 X_train = [sent2features(w) for w in train_words] y_train = [l for l in train_labels] crf = sklearn_crfsuite.CRF( algorithm="lbfgs", c1=0.1, c2=0.1, max_iterations=100, all_possible_transitions=True, ) crf.fit(X_train, y_train)

参数解释:algorithm="lbfgs"是 L-BFGS 优化器,医疗文本这种中小规模数据它收敛稳定,别用默认的l2sgd省时间换来飘忽不定的效果;c1/c2是 L1/L2 正则化系数,取值 0.1 起调是个常规做法。课程设计里最值得讲清楚的参数是all_possible_transitions=True,它允许模型在训练时见到“即使当前数据里没出现过的标签转移路径”,测试时遇到新邻居也有概率路径可用。特征模板里prev_char和next_char只看了前后一个字,对中文医疗文本来说“单字粒度 + 前后文各一字”已经能表达大多数边界信息,再加窗口长度反而容易过拟合。训练完成后,预测时也调用sent2features生成特征,注意保持训练和预测的特征键完全一致,否则 CRF 会报特征缺失错误。

3.3 深度学习方案:课程设计如何理性选择

医疗 NER 课程设计最容易掉进“强行深度学习”的坑。BERT 需要下载预训练权重,显卡显存不够时 CPU 跑一个 epoch 要半小时起步;BiLSTM-CRF 需要拼接词向量,Word2Vec 向量又需要额外训练。如果语料在 1000 句以下,CRF 几乎肯定比 BiLSTM-CRF 稳定。如果你确实想展示深度学习能力,建议做一个对比实验:CRF 作为 baseline,BiLSTM-CRF 作为提升项,然后在论文里如实写“受限于数据量,两者性能差距不大”。这不丢人,反而显得有实验素养。深度学习方案的另一个问题是随机性。同一个训练集,同一份代码,跑三次出来的 F1 可能差 2 到 3 个点。答辩时候讲不清这个波动,会被追问到底。所以课程设计里模型选 CRF 是稳妥打法,把深度学习作为“扩展方向”列在文档末尾即可。

4. 训练与迭代:把模型的 F1 从 70 拉到 85 的过程

模型选型定了以后,大部分人的做法是直接把全部标注数据丢进去训练,然后看一个总准确率。这种做法完全没有体现出工程能力。本节讲把模型“伺候”到合格水平的完整闭环:划分、评估、错误分析、再标注。

4.1 数据划分:保证实体分布不被随机打散

训练集/开发集/测试集的划分不要用train_test_split(x, y, random_state=42)一把梭。医疗语料里某些实体出现频率很低,比如“Check”类实体可能只在 60 句话里出现,而随机划分不小心把那 60 句全分到训练集,开发集上这类实体的 F1 直接是 0。写作时候我们称呼这种问题叫“小众实体被切没”。

正确做法是按句子维度做分层抽样:统计每个句子包含哪些实体类型,然后按类型比例把句子分配到三个集合。以下是一个按实体类型分层的划分思路:

from collections import defaultdict import random def stratified_split(sentences, train_ratio=0.7, dev_ratio=0.15): """按句子中实体类型比例做分层划分。""" buckets = defaultdict(list) for sent_id, labels in enumerate(sentences): # 提取该句出现的实体类型,转成 frozenset 作为桶键 types = frozenset(l[2:] for l in labels if l != "O") buckets[types].append(sent_id) train_ids, dev_ids, test_ids = [], [], [] for bucket_ids in buckets.values(): # 桶内数量少 (<3) 的句子直接进训练集 if len(bucket_ids) < 3: train_ids.extend(bucket_ids) continue random.shuffle(bucket_ids) n_train = int(len(bucket_ids) * train_ratio) n_dev = int(len(bucket_ids) * dev_ratio) train_ids.extend(bucket_ids[:n_train]) dev_ids.extend(bucket_ids[n_train:n_train+n_dev]) test_ids.extend(bucket_ids[n_train+n_dev:]) return train_ids, dev_ids, test_ids

代码逻辑:先按句子中包含的实体类型组合分桶,再在桶内做比例切分。比如“同时包含 Disease 和 Drug”的句子会被分到同一个桶里,无论是训练还是验证,模型都能看到这种组合模式。少量特殊组合句子直接归入训练集,保证训练数据覆盖度。

4.2 评估指标:逐类 F1 比整体 F1 更重要

几乎所有课程设计报告里都会写“模型准确率达到 XX%”,但医疗 NER 真正要看的是逐实体类型的精确率和召回率。如果 Symptom 的召回率只有 50%,意味着模型漏掉了一半症状实体,这在医疗场景是不能接受的。评估时使用sklearn_crfsuite.metrics.flat_classification_report会输出逐标签的精确率/召回率/F1。课程设计里至少要把 Disease、Symptom、Drug、Check 四类的 F1 列一张表。

评估结果出来后,要按一句话复盘模型在开发集上的错误案例。我在 Jupyter 里的习惯做法是,把预测结果和正确标注并排打印成两行序列,视觉上很容易发现边界偏移问题:比如“B-Disease I-Disease I-Disease”被模型预测成“B-Disease I-Disease”,说明训练语料里对“急性心肌梗死”这个词的标注不一致。这种错误属于标注噪声,解决方式是修正语料重训,而不是调模型参数。

4.3 迭代策略:开发集错误驱动修正

模型效果不佳时,不要盲目改 CRF 的正则化系数。我一般的迭代顺序是:先用flat_classification_report找出 F1 最低的实体类型;然后枚举该类型的 10 个漏报样本和 10 个误报样本。漏报通常有两种原因——词典没有覆盖新词、或者上下文特征不足;误报一般是词典里同形词干扰。比如词典里有“中心”对应 Check 类(如“影像中心”),但句子“病灶中心坏死”里的“中心”不是检查项目。解决办法不是删词条,而是给“中心”加一个上下文约束:前面出现“影像”“检查”等词条时才算实体。CRF 天然能学这种约束,前提是原始语料里有足够多带上下文标注的样本。

有一个容易被忽略的参数调优点是训练迭代次数。使用 sklearn-crfsuite 时,max_iterations=100是最低值,但如果你发现训练日志里 loss 还在下降,说明模型没收敛,把参数改成 200 到 300 会继续提点。这里要注意,太大的迭代次数会导致开发集 F1 先升后降,这是过拟合信号。训练过程应保留每 10 次迭代后在开发集上的评估记录,而不是只记最终结果。

5. 避坑指南:环境、编码、数据三个方向的高频翻车点

这一章挑几个课程设计和项目开发里真正磨损过我的问题。每一条都是踩下去很难爬上来,爬上来又觉得“不过如此”的类型。

5.1 Jupyter 内核重启后训练好的模型全丢了

现象:在 Jupyter 里训练完 CRF,关掉 notebook 再打开,发现所有变量都没了,只能重新训练。如果你只是实验阶段,重训能接受;但如果你在交作业前想导出预测结果,重训 5 分钟还能忍,重训 20 分钟就崩溃了。

原因:Jupyter 的所有变量都存在于内核内存中,写进 notebook 的只是代码和输出,不包含变量本身。关闭页面即释放内存,模型也随之消失。

解决:训练完成后立即保存。CRF 模型的保存使用joblib.dump(crf, "crf_model.joblib"),下次使用时joblib.load直接恢复。同时把测试集预测结果和评估指标也一并存入.csv文件。“先落盘再继续跑”是避免 Jupyter 项目翻车的第一条铁律。

5.2 “utf-8”和“utf-8-sig”导致的编码错乱

现象:在 Windows 下用 Excel 或记事本编辑词典和语料文件后,Python 读取时第一行第一个字变成了“\ufeff”,导致第一行词条匹配永远失败。

原因:Windows 记事本在保存 UTF-8 文件时会自动附加 BOM(字节顺序标记)在文件头部。Python 的encoding="utf-8"不带 BOM 解析,它会把 BOM 当成字符读进来,于是第一行关键词的匹配全被破坏。

解决:阅读文件时统一使用encoding="utf-8-sig"。这能自动剥离 BOM 头。或者在工作流里约定:所有数据文件只能使用 VS Code、Sublime、Notepad++ 编辑,禁止用系统自带记事本修改。如果你在 mac 或 Linux 下开发,自动保存时选择的编码也是 UTF-8,无 BOM,不会触发此问题。答辩现场如果老师问起来,这个坑非常能体现你踩过实战。

5.3 非实体字符 O 占比过高导致模型“懒”成复读机

现象:模型 F1 看着很高,但把所有测试句子全预测成 O(非实体),也就是一个实体都不输出。查一下标签分布发现训练集中 O 标签占 85% 以上,模型学到的“最优策略”是全部预测 O。

原因:医疗文本里症状和检查描述占比不高,大部分字属于普通叙述。模型发现全预测 O 已经能到 85% 准确率,于是收敛到局部最优。

解决:有两层调整。第一层是感受野,增加特征模板里prev_two_chars和next_two_chars,让模型能捕捉到“患者被诊断为高血糖”这类远距离线索。第二层是评价指标,改用flat_classification_report看实体类型的 F1 而不是看总体准确率,避免被表面高准确率迷惑。更硬核的方法是给非实体样本做下采样,或者给实体识别错误在损失函数里加权重。课程设计阶段做到第一层和指标修正已经足够。

5.4 sklearn-crfsuite 安装后 import 失败

现象:pip install sklearn-crfsuite成功以后,在 Jupyter 里import sklearn_crfsuite直接报ModuleNotFoundError,甚至整个 Python 进程崩溃。

原因:常见于 Anaconda 环境下 Jupyter 使用的 Python 解释器和 pip 安装的 Python 解释器不是同一个。Jupyter 内核可能在 base 环境,而 pip 把包装到了 conda 的另一个环境里。另一个诱发因素是scikit-learn版本冲突。

解决:用!pip list | grep sklearn在 Jupyter 里确认包是否真的在当前内核环境;或者把pip install改成conda install -c conda-forge sklearn-crfsuite把依赖链统一。更稳妥的习惯是在项目开始时创建一个干净的 conda 环境,然后在 Jupyter 里添加新内核,代码、数据、环境三者绑定,答辩评审时也不会因为环境对方无法复现而扣分。

5.5 词典匹配永远比模型预测结果“好看”造成的错觉

现象:做 baseline 对比时,词典匹配在开发集上的 F1 竟然比 CRF 高,于是怀疑模型没用。

原因:词典匹配和 CRF 不在同一条赛道上。词典匹配命中的正例全是词典里存在的高频词,而 CRF 处理的是包括词典外新词、嵌套实体、歧义词在内的全量数据。词典匹配测试集的实体分布天然偏简单。另一个原因是标注语料里部分实体的边界与词典词条不一致,比如标准词是“高血压病”,标注语料里标的是“高血压”,模型学到了词典错误。

解决:把词典匹配的结果作为训练材料输入给模型,而不是把它当作对手。我的做法是把词典词条转换成特征,例如在当前字后面出现词典词条时给一个is_in_dict=1特征。模型会自己学习“词典命中与上下文是否一致”。同时定期审视标注一致性,拿模型预测结果与原标注对比,找出标注噪声并修正。

6. 用 gradio 搭演示界面:让答辩评委 30 秒看懂你在做什么

很多课程设计写完就完了,但答辩时评委在台下看演示,如果只看到黑底白字的终端输出,很难理解“实体识别”的价值。更见效的做法是花一下午搭一个网页小程序,输入一句病历文本,返回这句话的实体标注结果。这里推荐 Gradio,它封装了大量交互组件,几行代码就能起一个本地网页服务。

import gradio as gr import joblib from sklearn_crfsuite import CRF # 加载训练好的 CRF 模型和词典 model = joblib.load("crf_model.joblib") def predict_entities(text): """输入原始文本,返回实体类型列表。""" words = list(text) # 按字切分 features = sent2features(words) # 复用 3.2 节的特征函数 labels = model.predict([features])[0] # 用 3.1 节的 extract_entities 提取实体三元组 entities = extract_entities(words, labels) # 转成方便前端展示的列表 return [{"实体": "".join(words[s:e]), "类型": t, "位置": f"[{s},{e})"} for s, e, t in sorted(entities)] demo = gr.Interface( fn=predict_entities, inputs=gr.Textbox(lines=5, placeholder="请输入一段病历文本..."), outputs=gr.Dataframe(headers=["实体", "类型", "位置"]), title="医疗实体识别演示", ) demo.launch()

参数说明:gr.Textbox(lines=5)设置了输入框高度,适合粘贴多行病历;gr.Dataframe输出表格,实体名、类型、位置三列展示,一目了然。demo.launch()默认在本机 7860 端口启动,答辩时用浏览器打开http://127.0.0.1:7860即可现场演示。注意启动后不要关掉 Jupyter 内核,否则网页服务也会停。

在交付目录上,我的习惯是区分三层:data/放原始文本、词典、标注语料;notebooks/放 Jupyter 的实验过程;src/放可复用的 Python 模块,比如features.py、evaluate.py、demo.py。README 写清楚环境安装方式、数据格式说明、模型复现步骤。这样整个项目在答辩时有“工程感”,代码质量是其次,条理性才是第一印象。回想我带课程设计的经验,能把“词典构建、语料标注、特征设计、模型对比、误差分析”讲得顺的学生,分数都不会低。希望这次的拆解帮你在做同类医疗 NLP 项目时少走几个坑——毕竟把时间花在真正能长能力的地方,比踩 bug 踩到凌晨三点强。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询