ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

中文电子病历NER实战:基于PyTorch的BiLSTM-CRF工程全解析

中文电子病历NER实战:基于PyTorch的BiLSTM-CRF工程全解析 简介面向自然语言处理入门者与医疗信息开发者的PyTorch中文电子病历命名实体识别项目目标是从电子病历文本中自动提取症状、病史、检查结果等医疗实体为病历结构化与临床决策支持提供基础工具。整个资源包共包含2000个文件其中1994个txt病历语料构成了主要数据来源配合5个Python源文件和1个Markdown说明文件整体体积11.22MB结构紧凑、易于下载后直接开展实验。源码模块覆盖数据管理、模型定义、训练工具和主流程入口同时内置大量真实病历文本语料适合结合中文分词、文本预处理、BIO/BIOES序列标注、准确率/召回率/F1评估以及实体字典构建等核心环节系统学习。目前已有114人学习下载特别适合作为深度学习/NER课程设计、毕设项目或医疗文本挖掘方向的实践参考。1. 中文电子病历命名实体识别这份PyTorch工程包能直接跑通你的第一步拿到一份电子病历人工找“症状”“诊断”“检查结果”不难但一次性面对几十份、上百份就真的头大。中文电子病历命名实体识别NER就是干这个的效率工具用算法把非结构化的病史文本拆成结构化的实体标签。这份资源是一个完整的PythonPyTorch工程包含四份病史特点txt样本、数据转换脚本、模型定义和训练入口不需要从零拼代码按步骤就能复现一条NER流水线。它能解决的是“病历文本如何变成模型输入、模型如何训练、实体如何抽取”的全部问题。适合刚学完PyTorch基础、想拿真实NLP项目练手的新手也适合医疗文本挖掘从业者做基线和对照。2. 拆包看结构五个脚本与四份病史文本的分工2.1 文件清单谁负责数据、谁负责模型、谁负责流程先给一份文件清单方便对照着打开工程文件类型职责判断README.md文档环境要求、运行命令、数据格式说明main.py训练入口参数解析、训练循环、保存模型model.py模型定义网络结构如BiLSTM-CRFdata_manager.py数据管理加载数据、padding、batch切分utils.py工具模块指标计算、日志、词表构建transfer_data.py数据转换原始txt到BIO序列病史特点-*.txt数据集脱敏后的中文病史文本拿到压缩包第一件事不是双击main.py而是打开README。很多项目跑不起来都是因为跳过了环境要求。README一般会写明Python版本、依赖清单和运行示例。这套项目里五个脚本各司其职main.py是程序的入口相当于流水线的总控model.py定义网络决定模型长什么样data_manager.py负责把原始文本变成张量处理padding和maskutils.py放一些通用函数transfer_data.py专门做文本到标签的转换。这种模块化设计的价值在处理规模的调整上。比如你不想用BiLSTM想换成BERT只需要改model.py和对应的数据处理部分main.py里的训练循环几乎不用动。如果一开始把所有逻辑都堆在一个文件里改一个地方就可能牵一发动全身调试成本很高。外科手术式的改动依赖的就是清晰的文件边界。2.2 模型选型为什么这个工程大概率是BiLSTM-CRF资源里没有直接写明网络结构但按PyTorch做中文NER的典型方案最常见的是BiLSTM-CRF。先理解任务本质NER是一个序列标注问题对输入句子里的每个字或词预测一个标签。中文没有空格分词所以很多工程选择按字标注避免分词错误往下游传播。BiLSTM是双向LSTM能从前后两个方向捕捉上下文。比如“患者因胸闷入院”“胸闷”两边的字是“因”和“入”双向信息让模型更容易判断这是个症状描述。CRF层则学习标签之间的转移关系。没有CRF时模型对每个位置独立预测可能出现“B-症状后面紧接I-疾病”这种不合规的组合加上CRF后转移矩阵会学到“B后面才允许接I”“O后面不能直接接I”这些规则解码时选择整体最优路径而不是局部最优。那为什么不直接用BERT呢BERT在这种任务上通常精度更高但显存和训练时间也是成倍增加。对一个入门型工程来说BiLSTM-CRF在普通GPU甚至纯CPU上都能跑起来而且结构直观能清楚看到前向、损失、解码每一步。等把这套流程吃透再换BERT就是改编码器层的事。所以从学习效率和资源占用两个角度看这份工程用BiLSTM-CRF是合理的。BIO和BIOES的差别也值得在这里交代。BIO把实体第一个字标为B-类型中间和后续字标为I-类型非实体标OBIOES额外用E表示实体结尾用S表示单字实体。BIOES对边界更敏感但标签类别多小数据集上反而容易欠拟合。这份资源如果要快速跑通用BIO就够了模型简单解码也容易。2.3 四份病史文本先看清原始数据再决定预处理策略四个txt文件名字都是“病史特点”后面跟的编号应该是不同患者或不同病历片段。文件名49、19、102、269看起来像是编号不是大小。里面的内容大概率是脱敏后的中文病史段落比如现病史、既往史、体格检查等。这类文本和日常文本最大的区别在于标点混乱、夹杂检查数值、半角全角混用。试想一段典型文本“患者3天前受凉后出现咳嗽咳黄痰伴发热最高体温38.5℃查体双肺可闻及湿啰音。”预处理要把“。”统一把“℃”前面的半角字符处理好还要决定按什么粒度切分。中文按字标注的好处是词典可以逐步扩充坏处是序列长度变长。好在这类病史文本单句往往不长序列长度压力可控。实体类型也需要在设计标签前想清楚。常见的中文电子病历实体有症状、体征、疾病诊断、检查检验、药物等。这份资源没有给标签规范那你就要在transfer_data.py里定义自己的BIO标签字典。我建议第一版先用“症状/疾病/检查/治疗”四类覆盖大多数病史特点描述等模型能跑通再细化。在这个阶段最值得做的一件事是把一份原始txt手动标注出来作为开发集。不要急着全量标注先标一份走通整个流程再考虑扩充。原因很简单NER标注成本高而且不同人标出来的边界很可能不一致先统一定义比先堆数量重要。3. 从txt到BIO序列环境搭建与数据转换脚本3.1 环境搭建用conda隔离一个PyTorch运行环境进入训练前先布置环境。不管你是Windows还是Linux我都建议用conda建一个独立环境别直接在base环境里pip install。base环境的包版本往往已经互相绑定多装一个深度学习框架很容易把别的包搞坏。常见做法是conda create -n ner python3.8 -y conda activate ner pip install torch numpy如果机器有NVIDIA显卡先通过nvidia-smi查看驱动支持的CUDA版本再装对应PyTorch版本。我一般会创建一个requirements.txt把项目依赖写进去比如torch、numpy、tqdm这样换机器时一行命令就能复现环境。注意pip install torch默认装的是CPU版如果需要GPU得去PyTorch官网按系统与CUDA组合复制安装命令。这一步是很多人在环境阶段卡住的地方以为是驱动问题其实装成了CPU版。环境装好之后验证一下python -c import torch; print(torch.__version__)能输出版本号就算通路。如果你的机器只有CPU也能跑只是速度慢建议把batch_size调小一点。conda创建环境如果太慢可以换成mamba命令基本一样速度能快不少。我自己的习惯是mamba create后续activate和pip命令都不变。3.2 transfer_data.py把中文病历文本切成字并打上BIO标签环境就绪后的第一件事是把原始txt转成模型能读的序列。这个脚本是整个流程的地基。常见做法是先清洗文本再逐字扫描配合实体词典打BIO标签。下面是按这个资源场景重构的transfer_data.py核心逻辑# transfer_data.py 核心逻辑常见做法示例 import re def clean_text(text): # 统一全角半角去多余空白 text text.replace(, ,).replace(。, .).replace( , ) text re.sub(r\s, , text) return text.strip() def char_level_tags(sentence, entity_dict): # entity_dict: {高血压: 疾病, 胸闷: 症状} chars, tags [], [] i 0 while i len(sentence): matched False for kw, etype in entity_dict.items(): if sentence[i:ilen(kw)] kw: chars.extend(list(kw)) tags.append(B- etype) tags.extend([I- etype] * (len(kw) - 1)) i len(kw) matched True break if not matched: chars.append(sentence[i]) tags.append(O) i 1 return chars, tags逻辑说明clean_text负责清理全角、多余空白char_level_tags用词典贪心匹配遇到词典词就打上B和I否则打O。注意词典里长词要排在前面否则“高血压”还没匹配到“高”就先被当O处理了。这是典型的“最长匹配优先”策略。如果你的资源里已经有完整标注文件这段代码就是示例但思路可以复用。参数说明entity_dict是整个标注体系的核心。你可以用现成医疗词典也可以从病史特点文本里人工抽一批高频词。第一版100个词左右就能让模型动起来后面再逐步扩充。另外断句也很有讲究按句号、逗号分完以后句子内保留原始字符顺序tag和char必须一一对应不能错位否则训练时模型根本学不到东西。转换完成后建议顺手统计一下标签分布。如果O标签占比超过95%说明实体词表太稀疏模型大概率会偷懒把所有位置都预测成O。这一步能提前暴露数据标注阶段的问题不用等训练完才发现。3.3 data_manager.pypadding、mask和batch切分的常见处理transfer_data.py输出的是文本和标签序列data_manager.py负责把它们变成固定长度的张量。核心是三个要素字符转ID、padding、mask。下面是一段常见实现# data_manager.py 常见逻辑 def make_batch(sentences, char2idx, tag2idx, max_len128): input_ids, labels, masks [], [], [] for chars, tags in sentences: ids [char2idx.get(c, 1) for c in chars][:max_len] # 1 是 unk tag_ids [tag2idx.get(t, 0) for t in tags][:max_len] pad_len max_len - len(ids) masks.append([1] * len(ids) [0] * pad_len) ids [0] * pad_len # 0 是 pad tag_ids [0] * pad_len input_ids.append(ids) labels.append(tag_ids) return torch.tensor(input_ids, dtypetorch.long), torch.tensor(labels, dtypetorch.long), torch.tensor(masks, dtypetorch.long)逻辑说明每个batch内保留最大长度短的补0同时用mask标记真实token。模型计算loss时只关注mask1的位置避免把padding当成O训练。参数说明max_len设128时需要观察实际句长分布如果大部分句长不超过64设64就够了太长会无谓增加显存。char2idx和tag2idx来自utils.py通常在训练前一次性构建好然后序列化保存保证训练和推理用的是同一套映射。这里有个容易翻车的细节char2idx里pad、unk、CLS这些特殊标记的ID必须固定并且和mask一致。比如pad用0unk用1但某些开源代码里unk也是0这样就会导致模型把真正的未知字当成padding忽略掉。我一般会在utils.py里加一个vocab类的save和load确保推理时不会因为词表错位而预测崩坏。4. 训练与调参main.py里的超参数和评价指标4.1 main.py入口从命令行参数说起数据准备好就进入训练环节。main.py通常用argparse管理超参数这样切换配置不用改代码。一个典型的运行命令是python main.py --train_file data/train.txt --dev_file data/dev.txt \ --epochs 30 --batch_size 32 --lr 1e-3 --hidden_size 256 \ --num_layers 2 --dropout 0.5 --use_cuda逐项解释train_file和dev_file指定训练集与验证集路径epochs设30数据量小的时候轮次太多容易过拟合batch_size取决于显存32不够用就降到16lr1e-3是Adam优化器的常见起点如果loss震荡就减半到5e-4hidden_size是BiLSTM隐藏层大小256在中小型NER任务里是平衡点太小特征表达不足太大容易过拟合且训练变慢num_layers是LSTM层数2层比1层能捕捉更抽象的特征但也意味着参数量翻倍。dropout设为0.5是防止过拟合的常用值尤其当训练样本只有几百条时很关键。use_cuda这个参数在你没有GPU的机器上不要开否则会报RuntimeError。损失函数这一块如果模型用CRFloss就是真实路径分数减去log-sum-expPyTorch有torchcrf库也可以自己实现。如果不用CRF就用交叉熵。交叉熵实现简单但无法学习标签转移约束效果通常差一截。这种工程里既然有model.py我建议在模型里加上CRF层代码量不大但F1能提升几个点。训练过程中main.py会在每个epoch结束后保存best_model.pt。这个“best”不是看训练loss而是看验证集F1。所以模型文件里要同时保存state_dict和对应的超参数。这样以后推理时才能正确恢复模型结构。4.2 评价指标Precision、Recall、F1怎么算NER用的不是字符准确率而是实体级别的查准与查全。一个实体只有类型和边界都完全正确才算真正命中。比如真实标签是“B-疾病 I-疾病”高血压模型预测成“B-症状 I-症状”类型错预测成“B-疾病 I-疾病 I-疾病”高血压病边界错同样不算对。计算时要先把BIO标签序列解码成实体列表再逐个对比。简化版逻辑如下def decode_entities(tags): entities [] cur_type None start None for i, t in enumerate(tags): if t.startswith(B-): if cur_type is not None: entities.append((cur_type, start, i - 1)) cur_type t[2:] start i elif t.startswith(I-): if cur_type is None: # 非法 I直接丢弃 cur_type None else: if cur_type is not None: entities.append((cur_type, start, i - 1)) cur_type None if cur_type is not None: entities.append((cur_type, start, len(tags) - 1)) return entities逻辑说明按BIO规则扫描标签B开头起一个新实体I延续当前实体O或B关闭当前实体。参数说明遇到以I开头却没有B的情况说明解码结果非法通常是因为训练时CRF没学好或后处理出错直接丢弃可以避免虚高分数。有了实体列表就能统计TP、FP、FN。F1是Precision和Recall的调和平均公式是F12PR/(PR)。如果你的实体类别有四五种建议分别算每一类的F1再看宏观平均。只用总的micro-F1容易掩盖某一类完全没学出来的问题。实际项目里症状和疾病类因为出现频次高F1通常好看检查类和治疗类频次低哪怕整体F1到了0.8单独看治疗类可能只有0.3这种信息对后续优化非常关键。4.3 训练过程中的三个观察点第一个是loss曲线。如果前几个epoch loss一直不降先检查数据预处理看看标签和输入是否错位再检查学习率大于1e-2很容易发散。第二个是验证集F1。每个epoch结束都要在dev集上评测F1出现下降说明可能过拟合保存的模型应该停在F1最高点而不是最后一轮。第三个是标签分布。如果预测结果里几乎全是O说明模型陷在多数类里需要处理标签不平衡见下一章。主循环里我习惯加early stopping连续5个epoch验证F1没有提升就停止训练并把学习率调低重新跑一次。这个“先大步探索再小步收敛”的策略在样本量不大的中文NER任务上几乎总是有效。5. 避坑指南从CUDA内存到中文乱码的五个翻车现场5.1 环境与数据阶段的坑现象一运行main.py报“Expected scalar type Long but got Float”。原因data_manager里ids数组被某个操作转成了float而Embedding层要求Long类型。这种错误很隐蔽因为代码里可能在某一步不小心用了torch.tensor(ids)而不是torch.tensor(ids, dtypetorch.long)。解决在make_batch返回值处统一指定dtypetorch.long。另外data_manager里所有和标签、ID相关的张量都建议显式指定类型别依赖默认推断这是我踩过一次后才养成的习惯。现象二读入txt出现乱码打印出来是“鍙茬梾鐨?”训练loss直接放飞。原因病史特点-*.txt很可能是GB2312编码默认UTF-8打开就会乱。解决用open(path, encodingutf-8, errorsreplace)先试如果仍有问题用chardet自动探测编码。我在处理这类下载资源时会先写一个三行脚本把所有txt都转成UTF-8with open(src, rb) as f: data f.read() # 常见做法先用utf-8失败再gbk for enc in [utf-8, gbk, gb18030]: try: text data.decode(enc) break except UnicodeDecodeError: continue with open(dst, w, encodingutf-8) as f: f.write(text)这段代码能解决大部分中文乱码问题。注意转码后要检查一下是否损伤了原内容比如“℃”这类特殊符号在某些编码下会变。Windows下面尤其容易出这个问题因为txt可能是ANSI保存而Python默认UTF-8两者对不上就是乱码。5.2 模型与训练阶段的坑现象三训练开始后直接CUDA out of memory。原因GPU显存不够或者max_len设太大。一份病历段落可能很长如果整段送进去CRF层还要动态规划显存很容易爆。解决把max_len降到64或128batch_size降到16先跑通再调大。如果还想用大模型可以增大max_len但减小batch_size两者是跷跷板。我的经验法则是先减batch_size再减max_len。因为batch_size减半对模型质量影响小而max_len减半会把长实体截断直接引入错误。现象四loss在几个epoch后变成nan。原因学习率过大导致梯度爆炸或文本里有不可见字符被映射成极端ID。解决把lr降到1e-4同时在clean_text里过滤\x00这类控制字符。还有一个常见原因word2vec初始化时词向量包含了nan值这通常是预训练文件损坏重新下载或改用随机初始化。如果用了torchcrf还要检查CRF转移矩阵初始化极端情况下初始值太大也会导致指数运算溢出。5.3 评估与复现阶段的坑现象五相同参数每次跑出来的F1差一到两个点。原因没有固定随机种子。PyTorch里模型参数初始化、Dropout和DataLoader的shuffle都是随机的不固定就无法复现实验。解决在main.py开头设置以下三行并在DataLoader里传入固定generator。random.seed(42) torch.manual_seed(42) torch.cuda.manual_seed_all(42)固定seed后同一台机器上结果可以复现。注意如果你的代码里用了cudnn.benchmarkTrue也会引入不确定性训练时建议关掉。另外如果在多卡环境下做训练数据加载顺序还会受num_workers影响最好把num_workers固定成同一个值再对比实验结果。6. 进阶玩法实体字典增强与模型泛化验证技巧6.1 用词典修正模型漏诊的实体模型训练好之后总会在专有名词上翻车。常见做法是准备一个领域词典在推理后做后处理修正。比如模型把“右肺下叶”识别成O但词典里有“肺下叶”就可以补上。# post_process.py 思路 def dict_revise(pred_tags, chars, entity_dict): text .join(chars) for kw, etype in entity_dict.items(): start 0 while True: idx text.find(kw, start) if idx -1: break pred_tags[idx] B- etype for i in range(1, len(kw)): pred_tags[idx i] I- etype start idx len(kw) return pred_tags参数说明entity_dict是通用或专科词典注意覆盖顺序如果一个词是另一个词的前缀优先匹配长者否则会把长词截断。你也可以在模型输出上做置信度过滤只修正低置信度片段避免把正确标签改坏。举个例子如果模型对“胸闷”输出O但置信度只有0.4词典改过来是安全的如果模型已经正确识别成B-症状置信度0.99那就不用动。6.2 验证模型真的能用脱敏病历的小样本测试模型最终要放到新病历上才算数。常见做法是挑20份没进过训练集的病历手工标注实体然后跑一遍推理对比F1。如果某类实体F1低于0.7就要检查是不是训练数据里这类实体太少。还可以按科室拆开测内外科病历写法差异大一份内科训练的模型到外科可能明显掉点。这个验证过程不复杂但能反映模型的真实泛化能力比拿开发集分数说事靠谱得多。从那以后我每次拿到新科室的病历都会先抽20份做快速验证确认标签定义和训练集一致再批量跑。这个习惯帮我避免了很多“测试集刷分、上线就垮”的尴尬。希望帮到你。本文还有配套的精品资源点击获取
返回列表