
简介基于Python的JD智能分诊文本分类项目源码面向医疗健康领域的开发者与AI学习者以自然语言处理技术解决分诊场景中的效率与准确性问题。压缩包共58个文件、约78.61MB主要包含30个CSV数据文件、8个TXT文本、6个Python源代码、3个字节码文件、2个Excel表格以及TF-IDF、LDA、词向量和预训练模型等辅助文件覆盖数据预处理、特征提取、模型训练与评估的完整流程。项目集成ResNet、BERT、XLNet等预训练模型进行文本特征抽取同时引入LDA主题模型与TF-IDF加权策略可有效提升分诊文本分类的性能源码中提供了数据读取、模型定义、训练与测试等核心代码便于读者直接运行并理解每个环节。当前已有296人学习下载适合作为毕业设计、科研实验或智能医疗系统开发的参考实现帮助快速掌握文本分类与多模型融合的工程落地方法。1. 基于Python的JD智能分诊文本分类项目先想清楚“分诊”和“普通文本分类”差在哪把“我胸口疼得厉害喘不上气出汗很多”这句话送进一个文本分类模型模型该输出什么如果输出科室它可能是心内科也可能是呼吸科。但如果这是急诊分诊场景模型还必须输出“挂急诊、走胸痛中心绿色通道”的优先级判断。同样是这句话丢在门诊和丢在急诊分类标签完全不同。这就是JD智能分诊文本分类项目最容易被低估的地方它不是一个 16 分类的文本分类任务而是一个“短文本 → 科室 优先级 风险标记”的联合决策任务。这类基于Python的JD智能分诊文本分类项目源码在公开仓库里能搜到不少但多数只是模型训练脚本缺的是数据工程、标签体系、接口封装和评估闭环。这篇文章按一个可交付的分诊系统来拆先定标签和数据再选模型和参数最后谈部署和线上调优。适合正在做医疗NLP项目、或者想把文本分类真正接进业务流程的工程师。2. 分诊标签体系与训练数据工程智能分诊项目的地基2.1 标签体系设计先有分诊流程再有分类标签很多做文本分类的工程师拿到分诊需求后第一反应是“先找分类模型”这是顺序错了。智能分诊的标签不是拍脑袋定的它必须映射到挂号科室和分诊优先级。常见做法是参照医院的分诊管理办法先定义一份标签字典再决定模型是单任务还是多任务。标签维度取值示例业务含义一级科室内科、外科、妇产科、儿科、五官科、皮肤科、急诊科挂号导流的第一层二级科室心血管内科、呼吸内科、消化内科、神经内科门诊分诊的细粒度目标分诊级别I级濒危、II级危重、III级急症、IV级非急症决定排队优先级风险标记胸痛、卒中、大出血、高热惊厥触发绿色通道或特殊处理我一般会把模型的主任务设为二级科室分类因为一级科室太粗模型学不到区分度。比如“心内科”和“呼吸内科”的主诉里都可能有“胸痛”但“活动后加重、向左肩放射”明显偏心内“咳嗽后胸痛、深呼吸加重”偏呼吸。这两个科室的边界正是智能分诊文本分类项目最有价值的部分。分诊级别不建议和科室放在同一个多标签模型里虽然技术上能放在一个模型里做多任务但业务上两者的标注来源不同科室标签来自挂号记录分诊级别来自护士分诊记录。如果混在一起训练样本量少的一侧会把另一侧带偏。2.2 从挂号记录构建弱标签数据集不依赖手工标注智能分诊项目冷启动时最缺的是标注数据。手工标 5 万条主诉文本的成本很高常见做法是从医院信息系统HIS里导出挂号记录把“最终挂号科室”当作弱标签。SELECT t.visit_time, t.patient_id, t.chief_complaint, t.register_dept, p.triage_level, p.risk_flag FROM visit_record t LEFT JOIN triage_record p ON t.visit_id p.visit_id WHERE t.visit_time 2023-01-01 AND t.visit_time 2024-01-01 AND t.chief_complaint IS NOT NULL AND LENGTH(TRIM(t.chief_complaint)) BETWEEN 4 AND 200 ORDER BY t.visit_time;这段 SQL 里有几个关键点。长度限制 4 到 200 字符是为了过滤掉“腹痛”这种过短主诉和整段复制粘贴的病历文本。用visit_time而不是随机抽样是为了后面按时间切训练集和测试集避免同一患者短期多次就诊造成数据泄漏。triage_level和risk_flag用左连接因为不是所有患者都走了分诊台。拿到原始数据后要先看标签分布。一个典型的门诊挂号记录里内科系统的样本可能占四成而“急诊科”和“风湿免疫科”可能只占 0.5%。这种长尾分布如果直接做分类模型会对高频科室过拟合低频科室几乎学不到特征。后面 2.4 节单独说怎么处理。2.3 中文主诉清洗与脱敏的Python实操急诊主诉和门诊主诉的文本噪声模式不一样。门诊主诉相对规范急诊主诉口语化严重经常出现“我妈头晕”“孩子发烧三天了”“昨晚喝的酒现在胃疼”这类带有身份称呼和时间口语的句子。清洗的目标是去掉跟分诊无关的噪声但保留症状、部位、持续时间、伴随症状这些关键信息。import re def clean_and_mask(text: str) - str: if not isinstance(text, str): return text text.replace(\u3000, ).replace( , ) text re.sub(r\s, , text) text re.sub(r[。.!?,;、], , text) text re.sub(r\d{11}, [PHONE], text) text re.sub(r\d{4}-\d{2}-\d{2}, [DATE], text) text re.sub(r\d{2}:\d{2}, [TIME], text) text re.sub(r(男|女)(性)?[,\s], , text) return text.strip() text 患者男性65岁2024-03-12 14:30 因胸痛伴大汗1小时来诊既往有高血压病史 print(clean_and_mask(text))函数里的四个替换逻辑分别处理全角空格、标点符号、手机号和日期时间、性别标记。手机号和身份证号必须提前脱敏这是医疗数据出域的前提条件在这个清洗函数里直接用占位符替换掉。性别标记之所以要移除是因为模型很容易学到“男性就该挂男科、女性就该挂妇科”这种表面关联而分诊场景里性别和科室的关联并不稳定移除后反而能逼模型学症状特征。年龄不建议直接删但建议单独作为字段传给模型不拼在主诉里。2.4 类别不平衡与长尾科室的两个处理思路处理分诊文本分类的类别不平衡核心思路不是让模型对所有科室一视同仁而是在保证高频科室不劣化的前提下把低频科室的召回拉上来。第一种是样本加权在损失函数里给低频科室更高的权重。第二种是分层抽样训练时保证每个 batch 里都包含低频科室的样本。第三种更实用把低频科室做向上合并比如把“风湿免疫科”和“血液内科”合并成“内科其他”在线下分诊时用规则或二次询问来细化。这个策略在样本量不足时远比硬训练十几个稀疏类别可靠。from sklearn.utils.class_weight import compute_class_weight import numpy as np def build_sample_weights(labels: np.ndarray) - np.ndarray: classes np.unique(labels) weights compute_class_weight(class_weightbalanced, classesclasses, ylabels) weight_map dict(zip(classes, weights)) return np.array([weight_map[label] for label in labels])这段代码用的是 scikit-learn 的compute_class_weightbalanced模式会自动计算每个类别的逆频率权重。注意一点类别权重上限要截断比如最大权重不超过 10否则个别极低频样本会在训练时产生巨大梯度导致模型震荡。3. 文本分类模型选型与训练从短文本场景倒推技术路线3.1 用TF-IDF加逻辑回归跑通第一个分诊Baseline在引入深度模型之前一定要先跑一个传统机器学习 Baseline。这不是走形式而是为了校准后续模型的收益。分诊主诉是典型的短文本平均长度 20 到 60 个字符标点噪声大症状词密集。TF-IDF 加线性分类器在这种场景下往往能到 70% 到 80% 的宏平均 F1对冷启动项目来说这个数字已经能支撑内部试用。from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.linear_model import LogisticRegression from sklearn.pipeline import Pipeline from sklearn.model_selection import train_test_split tfidf_params { max_features: 50000, ngram_range: (1, 2), min_df: 3, max_df: 0.85, sublinear_tf: True, } model Pipeline([ (tfidf, TfidfVectorizer(**tfidf_params)), (clf, LogisticRegression(C4.0, max_iter1000, class_weightbalanced)), ]) X_train, X_test, y_train, y_test train_test_split( texts, labels, test_size0.2, stratifylabels, random_state42 ) model.fit(X_train, y_train)ngram_range(1, 2)对中文主诉很重要因为“胸痛”和“胸闷”是两个字词单独的字没有区分能力加上二字词组之后模型才能捕捉症状词。max_df0.85是过滤掉“患者”“来诊”“门诊”这类在绝大多数文本里都出现的高频噪词sublinear_tf做了词频的平滑压缩防止某个症状词在超长文本里重复出现导致权重失衡。3.2 用TextCNN做智能分诊模型训练代码与参数表当传统模型到瓶颈之后下一步我一般选 TextCNN而不是一上来就微调 BERT。原因是分诊主诉短、无复杂长距离依赖TextCNN 的多个卷积核足够捕捉“症状词 部位 修饰语”的组合特征而且推理速度快单条预测在 CPU 上能做到 1 到 3 毫秒。import torch import torch.nn as nn import torch.nn.functional as F class TriageTextCNN(nn.Module): def __init__(self, vocab_size, embed_dim, num_classes, kernel_sizes(2, 3, 4), num_filters128): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx1) self.convs nn.ModuleList([ nn.Conv1d(embed_dim, num_filters, k, paddingk // 2) for k in kernel_sizes ]) self.dropout nn.Dropout(0.5) self.fc nn.Linear(len(kernel_sizes) * num_filters, num_classes) def forward(self, x): emb self.embedding(x).transpose(1, 2) conv_out [F.relu(conv(emb)) for conv in self.convs] pooled [F.max_pool1d(c, c.size(2)).squeeze(2) for c in conv_out] cat torch.cat(pooled, dim1) return self.fc(self.dropout(cat))TextCNN 的三个卷积核尺寸 2、3、4 分别对应二字症状词、三字短语和四字习惯表达paddingk // 2保证卷积输出长度和输入一致避免信息在边界处丢失。下面是一组在分诊主诉场景下相对稳的参数直接可以用作起点。参数推荐值说明embed_dim128预训练词向量或随机初始化均可kernel_sizes(2, 3, 4)覆盖二字到四字特征num_filters128 或 256特征图数量过大容易过拟合dropout0.5分诊数据量小正则偏重要batch_size64短文本场景下显存占用很小learning_rate2e-3Adam 优化器建议值max_seq_len64分诊主诉 95% 在 64 字以内训练时建议冻结词向量做前几个 epoch等分类层收敛后再解冻整个 embedding 微调。直接随机初始化 embedding 会让模型花了大量更新步数在学词的语义上而分诊数据量通常不够学出高质量的词表示。3.3 切换到预训练模型的时机与微调配置当标注数据达到 5 万条以上且线上要求科室细分类别在 20 个以上时TextCNN 的特征表达会到瓶颈这时可以切换到中文预训练模型。分诊文本是领域文本直接使用通用中文模型会有领域偏移的问题但用脱敏后的历史主诉在通用模型基础上做领域预训练不涉及用户资料出域Token 级的掩码语言模型目标对硬件要求也不高一张消费级显卡就能跑。微调配置上学习率比常规分类任务低一个量级常见的做法是 2e-5 到 5e-5。分诊数据集量级在几万条时全量微调跑 3 到 5 个 epoch 足够再往多跑就会在低频科室上过拟合。预热比例设到 10%让模型先用小的学习率试探当前数据分布再进入正式更新区间。参数更新上分类层可以用一个相对大的学习率如 1e-4预训练骨干用 2e-5。PyTorch 里给不同层设置不同学习率需要把参数分组传进优化器不少人在这里踩坑如果嫌麻烦就直接用一个学习率加 warmup 也够用。3.4 输出层不只是argmax置信度、多标签与优先级分诊模型的输出层如果直接对一级科室取 argmax会丢掉很多信息。一个患者主诉“腹痛伴恶心呕吐”消化内科和普通外科的概率都可能超过 0.3。区分这两个科室依赖更多信息模型在训练数据上难分但置信度差异是可以用阈值和后处理来兜底的。常见做法是把分类输出从单标签改为“科室概率分布 分诊级别多标签 风险词命中”三个输出。编排方式上用专家规则做第一层筛查比如命中急性胸痛相关词直接进胸痛流程模型概率作为第二层参考。这个做法的好处是模型只处理它擅长的“识别”不替业务做最终决策。置信度低于预设阈值的样本走人工复核队列而不是硬给出一个科室。probs torch.softmax(model_output, dim-1) max_prob, pred torch.max(probs, dim-1) if max_prob.item() 0.5: final_label 待人工复核 elif 0.5 max_prob.item() 0.75: final_label f{idx2label[pred.item()]}(建议复核) else: final_label idx2label[pred.item()]阈值 0.5 和 0.75 不是拍脑袋定的它来自开发集上的置信度分布分析。操作方式是统计所有正确预测样本的置信度分位数取 P50 和 P90 作为两档阈值再人工抽验边界样本确认。模型推理时的输出不能只留 top1要保留完整概率分布方便后续做规则集联和日志分析。4. 系统实现与接口部署把分诊分类模型包成线上服务4.1 推理代码的边界划分加载、预处理、预测分离线上服务最忌讳把训练代码直接搬过来用。训练代码里有数据增强、随机 dropout、梯度计算这些推理时全部不需要。推理模块要独立组织加载阶段只做一次模型权重读取预处理阶段做文本清洗和 ID 映射预测阶段只做前向推理。import pickle import torch class TriageInference: def __init__(self, model_path: str, vocab_path: str): self.device torch.device(cuda if torch.cuda.is_available() else cpu) self.model TriageTextCNN(...) self.model.load_state_dict(torch.load(model_path, map_locationself.device)) self.model.eval() with open(vocab_path, rb) as f: self.vocab pickle.load(f) def predict(self, text: str) - dict: cleaned clean_and_mask(text) ids [self.vocab.get(w, self.vocab[[UNK]]) for w in cleaned.split()] ids ids[:64] [0] * max(0, 64 - len(ids)) tensor torch.tensor([ids], dtypetorch.long, deviceself.device) with torch.no_grad(): logits self.model(tensor) probs torch.softmax(logits, dim-1)[0] max_prob, pred torch.max(probs, dim-1) return {label: idx2label[pred.item()], confidence: max_prob.item()}model.eval()必须显式调用它会把 dropout 层关闭否则每次推理结果都会轻微抖动。with torch.no_grad()关闭自动求导节省显存且加速推理。构造输入 ID 时做了两个动作截断到 64 个 token、用[PAD]的 ID 补齐到定长这是为了满足模型对定长输入的要求。4.2 用FastAPI暴露分诊接口请求格式与响应设计服务框架我推荐 FastAPI它自带请求校验和 OpenAPI 文档联调时直接看/docs页面就能试接口。分诊接口的入参除了主诉文本还应该带上年龄和可选的生命体征这两个字段对分诊级别判断有实际作用。from fastapi import FastAPI, HTTPException from pydantic import BaseModel app FastAPI(titleJD Smart Triage Service) class TriageRequest(BaseModel): chief_complaint: str age: int | None None gender: str | None None body_temperature: float | None None class TriageResponse(BaseModel): department: str confidence: float triage_level: str risk_tags: list[str] need_manual_review: bool app.post(/triage, response_modelTriageResponse) def triage(req: TriageRequest): if not req.chief_complaint or len(req.chief_complaint.strip()) 4: raise HTTPException(status_code422, detail主诉过短无法分诊) result triage_engine.predict(req.chief_complaint) return result响应里加risk_tags字段是为了给前端展示警示标签比如“胸痛风险”“卒中风险”。这些 tag 可以是规则模型产出的结果不一定来自深度模型但和分类结果一起返回前端只需要一次请求就能渲染完整的分诊卡片。接口层只做协议封装和参数校验业务编排放到独立的 service 层这样模型升级时接口不需要改。4.3 并发参数与gunicorn的4个调整项分诊服务属于 IO 密集和 CPU 密集兼有的场景部署时用 gunicorn 跑 Uvicorn Worker 是常见做法。这里有四个参数值得认真调而不是全部照着默认值来。gunicorn -w 4 -k uvicorn.workers.UvicornWorker \ --timeout 30 \ --max-requests 4096 \ --max-requests-jitter 512 \ --preload \ -b 0.0.0.0:8000 main:app-w 4是 worker 进程数最佳值通常是 CPU 核心数加 1。如果用的是 PyTorch CPU 推理每个 worker 里模型占一份内存开太多 worker 会吃满内存建议单个 worker 内存占用控制在物理内存的 15% 以内。--preload让主进程先加载模型再 fork 出 worker这样多个 worker 共享同一份模型参数的内存页能省掉一大块内存。--max-requests设成 4096 是为了解决 PyTorch 模型长期运行后产生内存碎片的问题worker 处理够一定请求数后自动重启释放内存。--max-requests-jitter让重启时间错开避免所有 worker 在同一时刻集体重启造成服务抖动。4.4 规则热词与模型预测的兜底配合把分诊模型部署上去之后绝不能只靠模型输出做分诊。主诉文本是患者自己写的有些表达方式模型没见过比如“胸口像压了块大石头”“人晕了一下站不住”。这类表达字面上和训练集里的规范医学术语差异很大但风险很高。规则模块在这里比模型更可靠。常见做法是维护三张表风险症状词表、绝对禁忌词表、模糊表达词表。命中第一张表直接提高分诊级别并加上对应风险标签命中第二张表不走模型直接进人工复核第三张表是高危症状的近义表达用来辅助模型降级置信度。HIGH_RISK_PATTERNS { 胸痛: [胸痛, 胸口疼, 心前区疼痛, 压榨感, 濒死感], 卒中: [言语不清, 口角歪斜, 一侧肢体无力, 突然跌倒], 大出血: [呕血, 便血, 阴道大量出血, 外伤出血不止], } def rule_check(text: str, risk_tags: list[str]) - str: for risk, keywords in HIGH_RISK_PATTERNS.items(): for kw in keywords: if kw in text and risk not in risk_tags: risk_tags.append(risk) return risk_tags规则和模型的关系不是谁替代谁而是模型负责科室分类规则负责风险识别和人工兜底。这个协作模式最重要的收益是模型在某一类新表达上整体失准时规则能守住底线不会出现胸痛患者被分到皮肤科这种错误。规则理论上由护士长和急诊科医生定期维护源码里应当把关键词表独立成配置文件不要硬编码在 Python 里否则每次调词都要重新发版。5. 分诊模型评估与线上调优准确率之外的三个动作5.1 按时间切分评估集不按患者随机切分诊数据里同一个患者可能在三个月内来两三次主诉文本高度相似。如果按患者随机切分训练集和测试集里会出现同一患者的相似文本评估结果会虚高两个到三个百分点。正确的切法是以时间线为界比如前 10 个月做训练后 2 个月做验证模拟模型上线后的真实场景。用 2.2 节 SQL 里的visit_time字段直接切分即可代码上只是train_test_split的切分依据从随机改成时间阈值。5.2 用混淆矩阵定位重新分诊的科室错分宏平均 F1 只能告诉你整体水平分诊上线后的真正问题是“哪些科室之间互相搞混”混淆矩阵直接呈现这个信息。from sklearn.metrics import confusion_matrix, classification_report import matplotlib.pyplot as plt cm confusion_matrix(y_true, y_pred, labelslist(label2idx.keys())) plt.figure(figsize(12, 10)) plt.imshow(cm, cmapBlues) plt.xticks(range(len(label2idx)), label2idx.keys(), rotation90) plt.yticks(range(len(label2idx)), label2idx.keys()) plt.colorbar() plt.tight_layout() plt.savefig(triage_confusion_matrix.png, dpi150)看混淆矩阵的重点不是对角线而是对角线外的高亮格子。分诊系统里最常见的是“心血管内科 ↔ 呼吸内科”“消化内科 ↔ 普通外科”这两组错分。前者需要模型理解“劳力性”“放射痛”等特征词后者需要模型区分“保守治疗”和“手术指征”的语义单纯加数据量提升有限。找到错分组之后针对每个错分组加标注样本比全局加数据效率高得多。5.3 置信度分级与阈值调整模型输出一个 0.92 概率的心内科和输出一个 0.46 概率的消化内科线上应该走完全不同的流程。置信度分级能让系统在召回和准确之间做更平滑的权衡而不是只靠一个硬阈值。def calibrate_thresholds(probs: list[float], labels: list[str], y_true: list[str]): from sklearn.metrics import precision_recall_curve precision, recall, thresholds precision_recall_curve( [1 if t l else 0 for t, l in zip(y_true, labels)], probs ) for i, th in enumerate(thresholds): if precision[i] 0.95: return th return 0.7这段代码的思路很直接找出能让准确率达到 95% 的那个最小置信度阈值低于这个阈值的样本进人工复核。这个校准逻辑按科室分别跑每个科室有自己的阈值。心内科的样本量大、模式清晰阈值可能只有 0.55风湿免疫科样本少、特征分散阈值可能要 0.85。统一阈值只会让少样本科室的预测大量进入复核流程增加护士台压力。另一个实用的动作是在高热词“胸痛”“卒中”命中的情况下即使模型置信度高也保留一个必须在 24 小时内人工回访的标记。这类样本在统计学上可能被模型正确分类但临床风险决定了它们不能用置信度一票通过。5.4 bad case回流机制与模型迭代模型上线之后每天会积累大量真实预测和医生反馈这是最宝贵的数据资产。建议每周做一次 bad case 抽检筛选规则是模型高置信度但被医生改判的案例以及低置信度但最终证实是危重症的案例。前者暴露模型的过拟合点后者暴露规则和模型的盲区。将这两类案例补充到训练集里做增量训练时有一个容易被忽略的细节不要只加 bad case 而不清理旧数据。如果某个类别的 bad case 一次性加了几百条类别分布会被瞬间改变模型可能出现灾难性遗忘。稳妥的做法是每次增量训练时把新增 bad case 和同比例的旧数据混合保证训练集的分布不剧烈跳动。智能分诊这类对稳定性要求高的系统迭代节奏宁可慢一点也不要在一次更新里引入大幅波动。本文还有配套的精品资源点击获取