免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

医疗NLP实战:词典构建与最大匹配实体标注

医疗NLP实战:词典构建与最大匹配实体标注 简介一套基于Python与Jupyter构建的医疗实体识别模型资源面向疾病、症状、身体部位三类实体完整呈现词典构造、语料标注、模型训练与结果评估的工程化流程。压缩包共147个文件约581MB具体包含18个txt词典/文本、10个ipynb交互式Notebook、7个dic词典、6个xlsx标注表格、5个scel输入法词库、checkpoint与pkl模型权重、result_metric评测结果及说明文档目录按数据、模型、输出、文档分层便于快速定位。词典数据经网络爬取与ICD10补充疾病、症状、身体部位分别约3.9万、7400、1900条采用最大匹配法自动获得实体位置并标注类型也可替换词典迁移至其他医学文本。对NLP初学者以及期末大作业、课程设计、项目开发来说既可对照源码复现流程也能继续扩充词条、微调模型。目前已有90人学习适合需要完整医疗命名实体识别参考实现的开发者。1. 医疗实体识别不是玄学词典、最大匹配与标注闭环做医疗文本挖掘的人都有一个共识实体识别是第一步也是最卡壳的一步。电子病历里的症状、疾病、身体部位抽不出来后面做结构化、做辅助诊断全是空谈。但这个项目给了另一条路——不依赖人工标注先把疾病、症状、身体部位三类词典建起来再用最大匹配算法在语料上自动打标签。整套东西跑在 Python Jupyter 上词典、语料标注、源码、文档都齐了。期末大作业、课程设计、或者你手里正好有个医疗 NLP 项目要起步它都能当底子用。核心思路就一句话词典先行匹配标注先解决“没有标注数据”这个死结。2. 词典构造与数据清洗39615 条疾病名背后的工程细节2.1 三类词典的来源与规模这个项目里词典是整套系统的地基。疾病词典 39615 条包含互联网爬取的疾病名称、疾病别名、ICD10 疾病名称三路数据合并去重症状词典 7457 条来自爬取的症状描述身体部位词典 1929 条同样来自互联网爬取。规模上看疾病词最多因为要覆盖 ICD10 编码体系里的各种正式命名和临床俗称。词典类别去重后词条数主要来源典型词条疾病词典39615互联网爬取 ICD10 疾病别名结石性胆囊炎、妊娠合并系统性红斑狼疮、腹型过敏性紫癜症状词典7457互联网爬取症状描述胀痛、鼻塞、粉红色泡沫样痰、痔疮便血、头昏眼花身体部位词典1929互联网爬取身体部位描述鼻唇沟、鼻翼、鼻黏膜、鼻窦软骨三个词典加起来接近五万词条这个量级对课程设计来说已经相当充足。ICD10 疾病名称的引入是关键的一步——它让疾病词典从“大众叫法”扩展到了“临床规范命名”匹配电子病历原文时召回率会明显高出一截。而身体部位词典虽然词条最少却最容易出问题后面第 5 章会专门讲。2.2 爬取数据的清洗与去重爬虫抓回来的词条第一眼永远是不能直接用的。HTML 标签、全角空格、换行符、括号备注全都混在里面。我一般会先做一轮正则清洗把无关字符剥掉再把带括号的备注单独拆出来处理——比如“阑尾炎急性”正文匹配时“阑尾炎”进主词典“急性”这种描述性内容不参与匹配否则会把实体边界撑大。import re def clean_word(raw: str) - str: # 去掉 HTML 标签比如 a、span 这类爬虫残留 text re.sub(r[^], , raw) # 去掉各种空白符空格、\t、\n、\u00a0 全角空格 text re.sub(r[\s\u00a0], , text) # 统一括号为英文半角方便后面拆别名 text text.replace(, ().replace(, )) # 去掉括号内的备注内容保留核心实体名 text re.sub(r\(.*?\), , text) return text.strip() def build_vocab(path: str) - set: vocab set() with open(path, r, encodingutf-8) as f: for line in f: w clean_word(line) if w and w not in vocab: vocab.add(w) return vocab disease_vocab build_vocab(dict/disease.txt) print(len(disease_vocab)) # 去重后的疾病词条数逻辑说一下build_vocab返回一个set天然去重。清洗的正则顺序有讲究——先去 HTML 标签再处理空白符再拆括号备注。为什么先拆标签因为爬虫抓的 HTML 片段里标签内可能夹着空白和缩进先剥标签能避免“a 阑尾炎 /a”这种词被洗成带空格的残次品。\u00a0是网页里常见的非断行空格肉眼看不出来但会影响字符串完全匹配这步不能省。去重之后还有一个细节疾病词典里“糖尿病”和“2型糖尿病”是否保留我的习惯是保留。它们是不同粒度的实体电子病历原文可能是“2型糖尿病”整体出现也可能上文刚提过“糖尿病”下文只说“该病”。最大匹配天然偏向长词所以细粒度词条不会干扰粗粒度词条的命中率。2.3 词典的存储与加载这个项目所有词典都是 txt 文件一行一个词条UTF-8 编码。为什么用 txt因为后期要手动差补、要对比版本、要在 Jupyter 里直接打开看txt 是最透明的格式。如果你后面要扩展建议不要把词典硬编码进 Python 文件里独立目录存放训练脚本和数据分离期末答辩时也好讲清楚数据流。加载方式很直接一次性读进内存。五万词条对 Python 内存来说完全不算事每个词平均 4-6 个字符总共也就几 MB。但要注意匹配阶段如果每次都去磁盘读词典速度会慢到怀疑人生。正确做法是启动时加载一次后续全部在内存里操作。注意词典文件必须统一用 UTF-8 编码保存Windows 记事本默认的 ANSI 编码在 Linux 服务器上跑会直接乱码匹配永远为零。3. 最大匹配标注实战Trie 实现与 BIO 标签输出3.1 为什么要用最大匹配而不是分词很多人拿到医疗文本第一反应是“能不能直接 jieba 分词然后查词典”。这个项目没有走那条路原因很现实通用分词器对医疗实体的识别能力很弱。“鼻黏膜”在 jieba 里大概率被切成“鼻/黏膜”“妊娠合并系统性红斑狼疮”这种长病名更是一刀切碎好几个片段。词典分词在这个场景下有两个不可替代的优势词表完全可控实体边界完全可控。你能准确知道每一个实体来自哪条词典这对课程设计的演示逻辑来说是巨大的加分项。最大匹配的原理不复杂从文本当前位置开始用词典里最长的词条长度作为窗口上限在窗口内尝试匹配匹配不上就把窗口缩短一个字继续试直到窗口缩成单字。这个策略的假设是医学实体倾向于完整的词而不是词的一部分。3.2 正向最大匹配的 Trie 实现直接拿set做匹配也可以但每次都要做字符串切片和集合查找几万条词、几千句文本跑下来效率不够看。我一般会先构建一棵 Trie 树把匹配复杂度压到接近文本长度。def build_trie(vocab: set) - dict: trie {} for word in vocab: node trie for ch in word: # setdefault 不存在的字符就新建子节点 node node.setdefault(ch, {}) node[#] True # # 标记这是一个完整词条 return trie def mm_annotate(text: str, tries: dict) - list: n len(text) tags [O] * n i 0 while i n: matched None # 疾病、症状、身体部位三本词典轮询取最长命中 for typ in [disease, symptom, bodypart]: node tries[typ] end None k i # 沿着 Trie 边走边看记录最近一次完整词条的结束位置 while k n and text[k] in node: node node[text[k]] k 1 if node.get(#, False): end k if end is not None: length end - i if matched is None or length matched[1] - matched[0]: matched (i, end, typ) if matched: s, e, typ matched tags[s] B- typ for idx in range(s 1, e): tags[idx] I- typ i e else: i 1 return tags这段代码的逻辑顺一遍外层while从文本左侧开始往右扫每到一个位置先在疾病词典的 Trie 里向下走边走边确认有没有完整词条记录最后一个完整词条的结束位置end三个词典轮完之后取结束位置最靠右的那个作为命中实体。为什么取最长因为“鼻黏膜发炎”里“鼻黏膜”和“发炎”如果都在症状词典最长匹配会先吞掉“鼻黏膜”这就避免了实体边界被切碎。tags列表里B-表示实体首字I-表示实体内部字O表示非实体这是标准的 BIO 标注结构。3.3 标注结果输出成 BIO 格式Jupyter 里跑完标注最终产物要落盘。常见做法是输出两种文件一种是对齐的文本和标签另一种是 JSON 结构化的实体列表。前者用于可视化检查后者用于喂给下游模型。import json def dump_entities(text: str, tags: list) - list: entities [] cur None for i, tag in enumerate(tags): if tag.startswith(B-): cur {start: i, end: i 1, type: tag[2:], text: text[i]} elif tag.startswith(I-) and cur is not None: cur[end] i 1 cur[text] text[i] else: if cur is not None: entities.append(cur) cur None return entities text 患者鼻黏膜充血伴有头昏眼花 tags mm_annotate(text, tries) entities dump_entities(text, tags) print(json.dumps(entities, ensure_asciiFalse, indent2))输出会是[ { start: 2, end: 5, type: bodypart, text: 鼻黏膜 }, { start: 10, end: 14, type: symptom, text: 头昏眼花 } ]start和end是字符级偏移量这个信息在课程设计答辩时非常有用——你能直接展示“模型在哪个位置识别出了什么实体”比口头解释有说服力得多。dump_entities里的边界处理是个细节cur[end] i 1是闭区间转半开区间方便后面用text[start:end]直接切出实体原文。4. 源码运行链路从 checkpoint 事件文件到 TensorBoard 曲线4.1 工程目录与事件文件的定位拿到手的工程里除了词典和源码还有一个checkpoint目录里面放了一堆events.out.tfevents.*文件时间戳集中在 2017 年底。这些是 TensorFlow 训练过程中自动生成的事件文件记录 loss、accuracy 等标量随 step 的变化曲线。注意事件文件不是模型权重它只是“训练日志”真正能推理用的模型参数在 checkpoint 权重文件里。目录结构按功能大概是这个分工词典 txt 放数据目录最大匹配和训练脚本放源码目录checkpoint目录存放训练中间产物文档目录放课程设计报告。这种结构对期末作业来说已经是标准工程模板了。4.2 环境准备与启动流程运行起来不需要太重的基础设施一个带 Jupyter 的 Python 环境就够了。依赖的核心包是pandas数据处理、tensorflow事件文件生成如果你只要词典标注则可以跳过、jupyter交互开发。我在本地实测时用 Python 3.8 跑通流程更早的版本只要注意部分语法差异即可。pip install pandas jupyter jupyter notebook启动 Jupyter 后打开工程里的标注 notebook从上到下依次执行先加载词典再构建 Trie然后读入待标注语料跑mm_annotate最后把标注结果导出。如果你只需要词典和标注能力不碰 TensorFlow 部分环境会更轻量——pip install pandas jupyter两个包就足够。提示项目文档里如果写了依赖清单按清单装如果没写优先装 pandas 和 jupyter别一上来就pip install tensorflow当前版本 TensorFlow 和新版 Python 的兼容坑能折腾你一晚上。4.3 TensorBoard 查看训练曲线checkpoint目录里的多个events.out.tfevents文件说明训练过程被拆成多次运行每一次都会追加新的事件文件。想直观看到训练曲线用 TensorBoard 打开这个目录tensorboard --logdir checkpoint浏览器访问localhost:6006就能看到 loss 曲线和 accuracy 曲线。如果曲线在某一轮之后平得跟地板一样说明模型已经收敛如果在剧烈震荡说明学习率偏大或者标注数据里有太多噪声样本。这一步对课程设计来说几乎是必做的——答辩时贴上训练曲线图比你空口说“模型效果不错”强十倍。5. 避坑与常见问题排查标注边界、词典冲突与 Jupyter 报错5.1 身体部位词典把疾病实体“掐断”了现象语料里“鼻窦炎”应该被标成疾病但输出结果却是“鼻窦”被标成了身体部位“炎”变成 O整个实体被拦腰截断。原因身体部位词典里有“鼻窦”疾病词典里有“鼻窦炎”。最大匹配在同一位置先轮询身体部位命中“鼻窦”后直接跳过了“鼻窦炎”的开始位置长词再也没有机会匹配。解决三种词典轮询时不要按词典类型顺序决定优先级要按匹配长度决定。代码里matched变量记录的是所有类型中的最长命中这个逻辑顺序不能反。如果你用的是逐类型单向匹配改成先统计再取最长。这是血泪教训顺序错一个字五万条词典白建。5.2 爬虫词条里藏着看不见的脏字符现象词典里明明有“痔疮便血”语料里也写了“痔疮便血”就是匹配不上。在 Jupyter 里len()一比对词典词条是 5 个字符文本里却是 7 个。原因爬虫抓下来的词条中间夹着nbsp;转义符或者\u200b零宽空格肉眼看不见字符串却已经被切断。解决加载词典时强制做一轮全量清洗把nbsp;、\u200b、\ufeff这类零宽字符和转义符全部替换成空字符串。我的习惯是在build_vocab里加一条re.sub(r[\u200b-\u200d\ufeff]|nbsp;, , text)零宽字符在大多数编辑器里不显示但匹配时是实打实的字符位处理不当会浪费一晚上的排查时间。5.3 Jupyter 报 PermissionError 或 kernel 直接崩溃现象在 Jupyter 里执行保存或者新建 notebook 时报PermissionError: [Errno 13] Permission denied加载词典那一步 kernel 卡住然后重启。原因前者是 Jupyter 工作目录没有写权限后者是词典一次性读入内存时构建了多个临时数据结构或者你在每次匹配循环里重复读文件。解决PermissionError 时用jupyter notebook --notebook-dir/你指定的目录指定一个可写目录启动不要在系统盘默认目录里扛。kernel 崩溃的坑更隐蔽——检查你的代码是不是把build_trie放在循环里重复执行了。词典加载和 Trie 构建只做一次匹配函数里传引用不要传副本。5.4 预处理先改了原文标注坐标全部错位现象标注结果里start偏移量和实体实际位置对不上文本里明明在第 100 个字标注结果却标在第 85 个字。原因你先做了去掉标点、去掉停用词之类的预处理把原文本改短了然后在清洗后的文本上运行标注。BIO 偏移量是基于清洗后文本计算的回贴原文本必然错位。解决标注永远在原始文本上进行。预处理如果有只做“查看用”的副本标注链路里保持原文不变。如果你必须在清洗后文本上标注那就把清洗过程记录成映射表标注完再用映射表换算回原坐标。这类问题最难定位因为它不报错只是结果肉眼看着不对劲。5.5 TensorBoard 读不出事件文件现象tensorboard --logdir checkpoint启动后页面空白或者提示找不到标量曲线。原因事件文件是 TensorFlow 1.x 写入的而你装了 TensorFlow 2.x 的 tensorboard或者路径里有中文事件文件读取失败。解决先确认events.out.tfevents.*确实在checkpoint目录下再看 tensorboard 版本——事件文件如果是旧版格式安装对应版本的tensorboard后重启。路径里带中文是玄学级别的坑英文路径一劳永逸。6. 验证与进阶抽样检查后把规则标注喂给 CRF规则标注最大的质疑点是“你凭什么认为词典匹配是对的”。所以要给标注结果做一次可信度验证。做法是抽一个黄金测试集从语料里随机选 100 句人工逐字核对实体边界对比自动标注结果算出精确率和召回率。计算逻辑很简单但能让你答辩时有数据可讲def evaluate(gold_entities, pred_entities): gold_set set(gold_entities) pred_set set(pred_entities) precision len(gold_set pred_set) / len(pred_set) if pred_set else 0 recall len(gold_set pred_set) / len(gold_set) if gold_set else 0 return precision, recallgold_entities是人工标注的(start, end, type)三元组pred_entities是mm_annotate的输出。实体级评估要三元组完全一致才算对字符位置差一个都不算命中。我一般控制在 100-200 句的规模再多人工标注也受不了。验证通过之后进阶的方向很明确把规则标注生成的 BIO 数据当作训练语料交给 CRF 或 BERT 去做序列标注。规则标注负责“冷启动”模型负责“泛化”——词典没收录的变体写法CRF 能通过上下文特征学出来。特征是经典的词本身、词性、前后词窗口再加一个“是否命中词典”的强特征def sent2features(sent, vocab_hit): features [] for i, token in enumerate(sent): features.append({ word: token, is_upper: token.isupper(), prefix_2: token[:2] if len(token) 2 else token, suffix_2: token[-2:] if len(token) 2 else token, hit_dict: vocab_hit[i] }) return features这套打法的完整闭环是词典最大匹配 → BIO 标注 → 抽样验证 → CRF 训练 → 泛化推理。从那以后我每次做类似项目都强制自己留一份黄金测试集压在手里固定随机种子、固定抽样方式保证每次改动词典或算法都能在同一基准线上比较。词典更新了、匹配逻辑调整了先跑黄金集看指标涨了就往下走跌了回头查。这个习惯帮你省掉的返工时间远比你花在验证上的那点时间多。希望帮到你。本文还有配套的精品资源点击获取
返回列表