简介:一套面向医疗文本信息抽取的完整项目,基于Python与Jupyter构建,聚焦医疗实体识别模型的训练与语料标注,适用于期末大作业、课程设计及毕业设计。内置疾病、症状、身体部位三类词典,疾病词典整合互联网爬取数据与ICD10标准编码,约3.9万条;症状词条约7400条,身体部位词条约1900条,支持基于词典的最大匹配算法自动标注实体位置与类别。资源包共147个文件,压缩后约581MB,包含Python源码、Jupyter Notebook、词典文件、TensorFlow训练检查点、评估结果表格及说明文档,覆盖从数据预处理、模型训练到效果评估的完整链路。目前已有90人学习下载。源码经过严格测试,可直接参考扩展;通过扩充词典、调整标注策略或增加实体类别,可灵活适配不同医疗场景,作为课程设计或大作业的完整解决方案。
1. 医疗实体识别怎么落地:先跑通词典,再谈模型的课程设计路线
期末拿到医疗实体识别题目时,大多数人第一反应是“直接上 BERT 微调”。但真实情况是,课程设计阶段最常见的翻车不是模型不够强,而是连训练数据都没整理明白,就急着调整网络结构。医疗实体识别,就是从病历、检查报告这类非结构化文本里自动抽出疾病、症状、药物、检查等医学实体;而 Jupyter Notebook 是这个任务最合适的试验场——改一段代码立刻看到结果,做数据探索和效果演示都方便。这套方案同时适合期末大作业、课程设计、项目开发三类场景:先做词典匹配兜底,再做 BIO 语料标注,然后逐步换成 CRF 或深度学习模型,最终交付源码和文档。
2.1 为什么先做词典匹配而不是直接训练模型
课程设计与真实科研的关键差异在于约束:时间有限、算力有限,而且评分看重的是“完整链路”而不是单一指标。词典匹配的可解释性很强,每一处识别结果都能追溯到词典词条或规则条件,写进报告里非常清楚。直接上 BERT 模型,如果数据量只有几千条标注,训练出来的效果很可能还不如词典。
常见做法是把“词典 + 规则打分”作为基线(Baseline),先把它跑通,再用这个基线去指导后续模型训练。这个流程在医疗 NER 实践里很成熟:先让词典告诉你哪些词是实体,再让统计模型学习词典之外的泛化规律。你也会在过程中自然理解实体边界的难点,比如“高血压”和“高血压病”到底算一个实体还是两个,这种问题只有亲手做过一次才有体感。
2.2 词典词表设计与读取:txt 文件的一行一词原则
医疗词典文件不需要复杂格式,最常见的形态就是 UTF-8 编码的 txt 文件,一行一个实体词,后面加逗号和实体类型标签。比如:
高血压,疾病 2型糖尿病,疾病 阿莫西林,药物 头晕,症状 乏力,症状读取时要注意编码问题。Windows 上用记事本编辑容易存成 GBK 或 ANSI,到 Jupyter 里读出来就是乱码。我一般会写一个兼容读取函数,把编码作为参数暴露出来,出问题时不至于改业务代码:
import codecs def load_dict(path, encoding='utf-8'): entity_dict = {} with codecs.open(path, 'r', encoding=encoding) as f: for line in f: line = line.strip() if not line: continue parts = line.split(',') if len(parts) == 2: word, etype = parts[0].strip(), parts[1].strip() entity_dict[word] = etype else: entity_dict[line] = '未知' return entity_dictload_dict返回的字典结构是{实体词: 实体类型},后续所有匹配逻辑都以它为基础。encoding 参数默认 UTF-8,如果遇到乱码,可以临时尝试 GBK 或 GB2312 把数据读出来,但项目最终提交时,所有文件统一保存为 UTF-8 是最省心的方式。课程设计评分时,老师大概率会直接打开你的 txt 文件看,乱码会留下非常不好的第一印象。
2.3 正向最大匹配算法:用 Python 实现词典实体抽取
词典匹配最简单的实现是正向最大匹配。核心思路是:设定一个最大词长,每次从文本当前位置向右截取这个长度的子串去词典里查,查不到就缩短一个字符,直到匹配成功或缩到长度为 1。这个算法在中文分词里用了几十年,用在实体识别上依然稳定。
def max_match(text, entity_dict, max_len=8): result = [] i = 0 n = len(text) while i < n: matched = False for l in range(min(max_len, n - i), 0, -1): word = text[i:i+l] if word in entity_dict: result.append((word, entity_dict[word], i, i+l)) i += l matched = True break if not matched: i += 1 return resultmax_len是第一个要调的参数。中文医学术语大多在 2 到 8 个字之间,设太小(比如 4),“慢性阻塞性肺疾病”这种长实体就拆不开;设太大,匹配时有额外的切片开销,但对单条文本影响不大。课程设计数据量通常很小,max_len=8起步,跑完一遍看结果再调整。返回结果里带着(实体词, 类型, 起始位置, 结束位置),起始和结束位置在后续做 BIO 标注时可以直接复用。
2.4 用 Jupyter 验证匹配结果并加入词典规则打分
匹配结果直接输出元组列表不够直观,在 Jupyter 里转成 DataFrame 展示是更常见的做法,老师一眼能看懂:
import pandas as pd sample_text = "患者有高血压病史3年,近日因头晕、乏力就诊,长期服用阿莫西林。" matches = max_match(sample_text, entity_dict, max_len=8) df = pd.DataFrame(matches, columns=['实体', '类型', '起始', '结束']) df运行后 Jupyter 会以表格形式渲染,实体、类型、位置一目了然。这一步建议保留在最终的演示 notebook 里,作为“词典基线效果”的展示页面。
词典规则打分,是在词典之外再加一层上下文规则:比如“诊断为”后面大概率跟疾病,“服用”后面大概率跟药物。用一个简单的打分函数,把规则命中的词加大权重,从而在词典匹配结果基础上做排序或过滤:
def rule_score(text, span, entity_dict): score = 0 before = text[max(0, span[2]-4):span[2]] # span[2]是实体起始位置 if '诊断' in before or '患' in before: score += 1 if '服用' in before or '口服' in before: score += 0.5 return score这个打分函数体现的是“词典+规则”的完整思路,方便和后期模型的效果作对比。你也可以按自己的需求设计更多上下文关键词,但注意打分规则不要写得太多,否则报告里解释不清,而且规则之间互相冲突时很难排查。课程设计的核心是体现方法论,规则打分点到为止就够了。
3. 语料标注与 BIO 格式:给模型一份干净的训练数据
3.1 BIO 标注方案选型:为什么课程设计选 BIO 而不是 BIOES
模型训练需要带标签的数据,医疗实体识别最常用的标注体系有 IO、BIO、BIOES 三种。IO 最简单,只区分“实体”和“非实体”,但实体边界完全丢失;BIOES 信息量最全,多出了 E(实体结尾)和 S(单字实体),但对标注者要求高,漏标和错标率明显上升。课程设计推荐用 BIO:B 表示实体开头,I 表示实体内部,O 表示非实体。
这个方案在学术论文和开源工具里都通用,后续接 sklearn-crfsuite 或深度学习框架不需要额外转换格式。而且 BIO 的标注工作量比 BIOES 少,人工校对时不容易出错。很多医疗 NER 公开数据集的标注规范也是 BIO,这意味着你的代码可以直接迁移到公开数据集上做横向对比,这是报告里很有说服力的一页。
3.2 用 Jupyter 构建带实体标注的语料文件
标注语料的组织方式,常见做法是“一字一行,一列标签”。
患 O 者 O 高 B-Disease 血 I-Disease 压 I-Disease 病 O 史 O在 Jupyter 里把这些行读进来,转成适合训练的数组。人工从零标注非常费时,我常用的流程是“预标注 + 人工校对”:先让第 2 章的词典匹配自动跑一遍原始文本,生成自动 BIO 标签,再让标注人员在 Jupyter 里打开校对,只修正错误的位置。这个流程能把工作量压缩到原来的三分之一。
def auto_label(text, matches, label_scheme='BIO'): labels = ['O'] * len(text) for word, etype, start, end in matches: labels[start] = 'B-' + etype for i in range(start + 1, end): labels[i] = 'I-' + etype return labelsauto_label的核心逻辑是把实体的首字标为 B,后续字标为 I。注意同一个实体内部不能出现两个 B,否则标注不一致,下面的检查脚本就是为这个设计的。如果你决定用 BIOES,还需要额外处理单字实体和结尾字,代码逻辑会多两行,但泛化性未必提升多少。
3.3 数据预处理:把原始病历文本切分成训练样本
原始病历文本可能包含换行、空格、数字单位,直接进模型会产生大量噪声。课程设计阶段,预处理统一按字切分就够了:
def text_to_bio_sample(text, entities): text = text.replace('\n', '').replace(' ', '') labels = ['O'] * len(text) for entity in entities: word, etype, start, end = entity # 预处理后位置可能偏移,这里需要重新查找 start = text.find(word) if start == -1: continue end = start + len(word) labels[start] = 'B-' + etype for i in range(start + 1, end): labels[i] = 'I-' + etype return list(text), labels这里有一个关键坑:预处理后文本长度发生了变化,原标注的 start/end 会失效。我一般都在预处理之后再做实体定位,而不是先定位再预处理。这个顺序搞反,后面的训练数据基本是废的,你在结果里会看到大量对不齐的标签。
预处理后的样本保存为两个 Python list——chars和labels,长度完全一致。训练时模型输入的例子是这样的:
chars: ['患', '者', '高', '血', '压', '病', '史'] labels: ['O', 'O', 'B-Disease', 'I-Disease', 'I-Disease', 'O', 'O']3.4 标注一致性检查:防止重叠与错位
人工标注最大的问题是不一致。在 Jupyter 里做一个快速检查脚本,能省很多事。重叠标注是我踩得最深的坑:两个实体在文本里位置重叠,比如“高血压”和“高血压病”同时被标为实体,训练时后一个覆盖前一个,模型完全学不到前面那个实体。
def check_overlap(entities): spans = sorted(entities, key=lambda x: x[2]) for i in range(len(spans) - 1): if spans[i][3] > spans[i+1][2]: print(f"重叠实体验到: {spans[i]} vs {spans[i+1]}") print("检查完成,无重叠则无输出")另一个常见问题是标签错位,比如“高”标成了 B-Disease,“血”标成了 O,导致“高血压”被拆成两个片段。检查方法很简单:统计所有标签序列,凡是出现B-Disease紧跟O再跟B-Disease的情况,基本就是漏标了中间的 I。
标注工作完成后,你需要随机抽查 10% 的样本人工复核。这个环节不要偷懒,因为模型效果的天花板由标注质量决定,标注里全是噪声,后面调参再努力也白费。
4. 从词典到模型:CRF 与深度学习的选型与实现
4.1 模型选型对比:CRF、BiLSTM、BERT 怎么选
词典匹配做完了,语料也标注好了,下一步是选择一个统计模型。我把三种常见方案放在一个表里对比:
| 模型方案 | 适合数据量 | 训练时长 | 可解释性 | 硬件要求 | 课程设计推荐度 |
|---|---|---|---|---|---|
| 词典 + CRF | 1千~1万句 | 分钟级 | 高,特征可追溯 | 无特殊要求 | 推荐 |
| BiLSTM-CRF | 1万句以上 | 小时级 | 低 | 英伟达 GPU 可选 | 备选 |
| BERT 微调 | 5万句以上 | 数小时起 | 极低 | 必须 GPU | 加分项 |
课程设计的数据量通常只有几百到几千句,CRF 是性价比最高的选择。CRF 能学习标签之间的转移概率,比如 B-Disease 后面接 I-Disease 的概率很高,接 B-Drug 几乎不可能,这种约束在医疗场景下非常有用。如果你数据量确实超过 1 万句,可以尝试 BiLSTM-CRF,但你需要额外处理训练时间波动和调参的问题。
4.2 特征工程与 CRF 训练:用 sklearn-crfsuite 跑通最小闭环
sklearn-crfsuite 是 CRF 训练最常用的工具包,接口和 scikit-learn 风格一致,上手快。先定义特征提取函数:
import sklearn_crfsuite def word2features(sent, i): word = sent[i] features = { 'word': word, 'is_first': i == 0, 'is_last': i == len(sent) - 1, 'is_digit': word.isdigit(), 'prev_word': '' if i == 0 else sent[i-1], 'next_word': '' if i == len(sent)-1 else sent[i+1], } return features def sent2features(sent): return [word2features(sent, i) for i in range(len(sent))] def sent2labels(sent): # sent 是 (chars, labels) 对 return sent[1]特征模板决定模型能学到什么。prev_word和next_word是相邻字特征,对实体边界识别最有效;is_digit用于处理检查报告里的数值型实体。注意,这里不要写词性标注特征,医疗文本的词性标注准确率本身不高,加了反而引入噪声。
训练代码就是把特征和标签喂给 CRF:
X_train = [sent2features(s) for s in train_data] y_train = [sent2labels(s) for s in train_data] 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)c1和c2是正则化系数,c1控制 L1 正则(让权重稀疏),c2控制 L2 正则(防止过拟合)。课程设计阶段两个都设 0.1 起步,如果验证集 F1 明显下降,再做小范围调整。all_possible_transitions=True让模型学习所有标签间的转移概率,这样 B-Disease 到 I-Disease 这类合法转移会被强化,非法转移会被抑制。
4.3 把词典匹配结果作为 CRF 的额外特征
这是医疗 NER 实践中效果提升最明显的一招:把词典匹配命中的词作为二值特征加入特征模板。CRF 自己从数据里学实体边界很慢,但如果词典已经告诉它“这里有一个词典词”,它的学习难度就大幅下降。
def word2features(sent, i, dict_matches): word = sent[i] features = { 'word': word, 'is_first': i == 0, 'is_last': i == len(sent) - 1, 'is_digit': word.isdigit(), 'prev_word': '' if i == 0 else sent[i-1], 'next_word': '' if i == len(sent)-1 else sent[i+1], } # 词典特征:当前字是否落在词典实体区间内 for start, end, etype in dict_matches: if start <= i < end: features['dict_match'] = etype break return featuresdict_matches是第 2 章词典匹配的结果。加上这一列特征后,CRF 的效果通常能反超纯规则基线,而且训练时间几乎不变。这个改进写成报告很有价值:它说明词典知识可以作为先验信息注入统计模型,而不是被抛弃。
4.4 评估指标与结果对比:准确率、召回率、F1
医疗 NER 不能用普通准确率评估,因为“O”标签占比太高,模型全预测成 O 也有 90% 以上的准确率,没有意义。使用 seqeval 库按实体级别计算准确率、召回率、F1 是通用做法:
from seqeval.metrics import classification_report y_pred = crf.predict(X_test) y_true = [sent2labels(s) for s in test_data] print(classification_report(y_true, y_pred, digits=3))输出的报告里,每一类实体(Disease、Drug、Symptom)都有独立的 Precision、Recall、F1。你需要关注的不是平均值,而是哪一类实体效果最差。根据我的经验,药品名里的商品名和化学名混合出现时,召回率会明显偏低,原因是词典里没有那些商品名。这正好是报告中可以讨论的改进方向:扩充词典,或增加更丰富的上下文特征。
评估时务必固定测试集,不要反复用同一批数据调参。课程设计报告里把词典基线、词典+规则、词典+CRF 三组结果放进同一张表,对比逻辑清晰,老师看了最容易给高分。
5. 避坑指南:Jupyter 环境下医疗 NER 的 5 个常见报错
5.1 PermissionError: [Errno 13] Permission denied
现象:在 Jupyter 里保存语料文件或读取上级目录文件时,直接抛出 PermissionError,导致整个 notebook 中断。
原因:Jupyter Notebook 默认保存路径设置在系统保护目录,或者当前 notebook 所在目录的写权限不足。Windows 系统上尤其常见,比如直接把 notebook 放在 C 盘 Program Files 下。
解决:修改 Jupyter 默认保存路径。在终端运行jupyter notebook --generate-config,生成配置文件后,找到c.NotebookApp.notebook_dir选项,把它指向一个有写权限的目录(比如D:/workspace/medical_ner),然后重启 Jupyter。稳妥的做法是,把所有项目文件集中在一个普通用户目录下,避免使用管理员权限。
5.2 中文乱码与编码不一致
现象:读取 txt 词典后,输出变成“日头”之类的乱码,或者训练时模型对中文字符的识别全部失效。
原因:文件保存为 GBK 编码,而 Python 默认按 UTF-8 解码;或相反。在 Windows 记事本编辑过的词典文件最容易触发。
解决:读取时显式指定 encoding。项目里所有文本文件统一保存为 UTF-8,不要混用编码。已经变成乱码的文件,可以在 Jupyter 里用codecs.open(path, 'r', 'gbk')读出来,再重新保存为 UTF-8。记住,编码问题是项目开发里最不值得花时间的坑,规范文件编码格式就能根治。
5.3 词典匹配把“高血压患者”拆成碎片
现象:词典里只有“高血压”,文本里出现“高血压患者”,匹配结果只抽出“高血压”,“患者”两个字被跳过,后续实体边界识别出错。
原因:正向最大匹配算法只匹配词典里存在的词,不处理词典之外的相邻文本;而“患者”在医疗文本里往往不是实体,但它的存在干扰了上下文特征提取。
解决:先调整max_len参数,尝试把“高血压患者”整体加入词典;如果不行,在匹配后追加一个后处理步骤——把连续两个实体的间隔小于等于 1 个字的情况合并或丢弃。另外,检查词典里是否存在互斥词条,比如“高血压”和“高血压病”同时存在,多词条冲突时需要有优先级规则。
5.4 Jupyter 内核卡死与变量覆盖
现象:反复训练 CRF 或加载大语料后,内存占用涨到几个 GB,Jupyter 内核直接卡死,重启后所有变量丢失。
原因:Notebook 的全局环境会保留所有中间变量,多次训练时旧模型和旧数据没有被释放,内存持续累积。这是 Jupyter 做项目开发最容易忽视的问题。
解决:训练代码封装成函数,减少全局变量残留;大规模训练前手动执行import gc; gc.collect(),并删除不再使用的训练集变量。还有一种做法是把训练过程拆到独立的.py文件中,Notebook 里只保留结果展示,既避免内核卡死,又方便复用代码。
5.5 数据量小导致 F1 虚高或暴跌
现象:测试集只有 100 句时,词典+CRF 跑出来的 F1 高达 0.9,看起来效果很好;换一个测试集又降到 0.6,前后矛盾。
原因:数据量太少,评估结果方差很大。某些实体类别在测试集里只出现几次,一次识别错误就会让 F1 大幅波动。
解决:使用 k 折交叉验证,把数据分成 5 份,轮流做测试集,最后取平均 F1。报告里同时附上交叉验证的标准差,这比单纯一个测试集上的分数有说服力得多。如果时间紧张,至少也要做两次随机划分,标注评估口径,避免老师质疑结果的可重复性。
6. 交付前的最后一步:把项目整理成能打动人心的完整作品
课程设计交的不只是代码,是一套能讲清楚的技术方案。项目目录我建议这样组织:
medical_ner/ ├── data/ │ ├── dict/ # 医疗词典 txt 文件 │ ├── raw/ # 原始病历语料 │ └── annotated/ # BIO 标注语料 ├── notebooks/ │ ├── 01_词典匹配演示.ipynb │ ├── 02_语料标注与检查.ipynb │ └── 03_CRF训练与评估.ipynb ├── src/ │ ├── dictionary.py │ ├── labeling.py │ └── train_crf.py └── README.mdREADME 里写清楚运行环境、安装依赖的命令、数据格式说明,以及三行命令跑通全流程。老师拿到项目不会先读代码,而是先看 README,这是第一印象。
Notebook 的展示顺序也有讲究。第 1 个 notebook 展示词典匹配效果,用表格输出每个实体;第 2 个 notebook 展示标注流程和检查逻辑;第 3 个 notebook 展示 CRF 训练曲线和分类报告。把每个 notebook 顶部的注释改为“摘要+运行结果说明”,评阅时不用逐个单元格点开,浏览速度会快很多。
最后交付前,我还习惯在 Jupyter 里做一次全流程验证:清空所有输出,重新执行一遍“Kernel → Restart & Run All”。这一步能发现不少隐藏问题——很多同学提交的 notebook 里,代码单元格有输出,但根本不能从头运行,老师一执行就报错,前功尽弃。把运行耗时控制在 10 分钟内,超长训练的结果提前缓存成图片或 CSV,演示现场不会尴尬。
这个流程我带过很多轮课程设计,从词典到 CRF 一步步跑通,基本都能拿到不错的评价。项目开发阶段,你还可以把 CRF 换成 BiLSTM-CRF,或者用现成的预训练模型做微调,但数据流程和避坑经验是完全通用的。希望帮到你。
本文还有配套的精品资源,点击获取