
简介基于PythonJupyter构建的医疗实体识别模型完整资料包面向自然语言处理初学者、课程设计及毕业设计学生解决医疗文本中疾病、症状、身体部位等实体自动识别与标注难的问题。项目自带词典和语料标注疾病词典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因为 EEnd的好处是让模型知道实体边界但在学习率调不好时更容易出现标签混乱。课程设计阶段 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, encodingutf-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()里只写了encodingutf-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(), keylen, reverseTrue) 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(fB-{type_}) else: labels.append(fI-{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[i1] 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( algorithmlbfgs, c10.1, c20.1, max_iterations100, all_possible_transitionsTrue, ) crf.fit(X_train, y_train)参数解释algorithmlbfgs是 L-BFGS 优化器医疗文本这种中小规模数据它收敛稳定别用默认的l2sgd省时间换来飘忽不定的效果c1/c2是 L1/L2 正则化系数取值 0.1 起调是个常规做法。课程设计里最值得讲清楚的参数是all_possible_transitionsTrue它允许模型在训练时见到“即使当前数据里没出现过的标签转移路径”测试时遇到新邻居也有概率路径可用。特征模板里prev_char和next_char只看了前后一个字对中文医疗文本来说“单字粒度 前后文各一字”已经能表达大多数边界信息再加窗口长度反而容易过拟合。训练完成后预测时也调用sent2features生成特征注意保持训练和预测的特征键完全一致否则 CRF 会报特征缺失错误。3.3 深度学习方案课程设计如何理性选择医疗 NER 课程设计最容易掉进“强行深度学习”的坑。BERT 需要下载预训练权重显卡显存不够时 CPU 跑一个 epoch 要半小时起步BiLSTM-CRF 需要拼接词向量Word2Vec 向量又需要额外训练。如果语料在 1000 句以下CRF 几乎肯定比 BiLSTM-CRF 稳定。如果你确实想展示深度学习能力建议做一个对比实验CRF 作为 baselineBiLSTM-CRF 作为提升项然后在论文里如实写“受限于数据量两者性能差距不大”。这不丢人反而显得有实验素养。深度学习方案的另一个问题是随机性。同一个训练集同一份代码跑三次出来的 F1 可能差 2 到 3 个点。答辩时候讲不清这个波动会被追问到底。所以课程设计里模型选 CRF 是稳妥打法把深度学习作为“扩展方向”列在文档末尾即可。4. 训练与迭代把模型的 F1 从 70 拉到 85 的过程模型选型定了以后大部分人的做法是直接把全部标注数据丢进去训练然后看一个总准确率。这种做法完全没有体现出工程能力。本节讲把模型“伺候”到合格水平的完整闭环划分、评估、错误分析、再标注。4.1 数据划分保证实体分布不被随机打散训练集/开发集/测试集的划分不要用train_test_split(x, y, random_state42)一把梭。医疗语料里某些实体出现频率很低比如“Check”类实体可能只在 60 句话里出现而随机划分不小心把那 60 句全分到训练集开发集上这类实体的 F1 直接是 0。写作时候我们称呼这种问题叫“小众实体被切没”。正确做法是按句子维度做分层抽样统计每个句子包含哪些实体类型然后按类型比例把句子分配到三个集合。以下是一个按实体类型分层的划分思路from collections import defaultdict import random def stratified_split(sentences, train_ratio0.7, dev_ratio0.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_trainn_dev]) test_ids.extend(bucket_ids[n_trainn_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_iterations100是最低值但如果你发现训练日志里 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 的encodingutf-8不带 BOM 解析它会把 BOM 当成字符读进来于是第一行关键词的匹配全被破坏。解决阅读文件时统一使用encodingutf-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_dict1特征。模型会自己学习“词典命中与上下文是否一致”。同时定期审视标注一致性拿模型预测结果与原标注对比找出标注噪声并修正。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( fnpredict_entities, inputsgr.Textbox(lines5, placeholder请输入一段病历文本...), outputsgr.Dataframe(headers[实体, 类型, 位置]), title医疗实体识别演示, ) demo.launch()参数说明gr.Textbox(lines5)设置了输入框高度适合粘贴多行病历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 踩到凌晨三点强。本文还有配套的精品资源点击获取