简介:基于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_weight="balanced", classes=classes, y=labels) weight_map = dict(zip(classes, weights)) return np.array([weight_map[label] for label in labels])这段代码用的是 scikit-learn 的compute_class_weight,balanced模式会自动计算每个类别的逆频率权重。注意一点:类别权重上限要截断,比如最大权重不超过 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(C=4.0, max_iter=1000, class_weight="balanced")), ]) X_train, X_test, y_train, y_test = train_test_split( texts, labels, test_size=0.2, stratify=labels, random_state=42 ) model.fit(X_train, y_train)ngram_range=(1, 2)对中文主诉很重要,因为“胸痛”和“胸闷”是两个字词,单独的字没有区分能力,加上二字词组之后模型才能捕捉症状词。max_df=0.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_filters=128): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=1) self.convs = nn.ModuleList([ nn.Conv1d(embed_dim, num_filters, k, padding=k // 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, dim=1) return self.fc(self.dropout(cat))TextCNN 的三个卷积核尺寸 2、3、4 分别对应二字症状词、三字短语和四字习惯表达,padding=k // 2保证卷积输出长度和输入一致,避免信息在边界处丢失。
下面是一组在分诊主诉场景下相对稳的参数,直接可以用作起点。
| 参数 | 推荐值 | 说明 |
|---|---|---|
| embed_dim | 128 | 预训练词向量或随机初始化均可 |
| kernel_sizes | (2, 3, 4) | 覆盖二字到四字特征 |
| num_filters | 128 或 256 | 特征图数量,过大容易过拟合 |
| dropout | 0.5 | 分诊数据量小,正则偏重要 |
| batch_size | 64 | 短文本场景下显存占用很小 |
| learning_rate | 2e-3 | Adam 优化器建议值 |
| max_seq_len | 64 | 分诊主诉 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_location=self.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], dtype=torch.long, device=self.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(title="JD 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_model=TriageResponse) def triage(req: TriageRequest): if not req.chief_complaint or len(req.chief_complaint.strip()) < 4: raise HTTPException(status_code=422, 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, labels=list(label2idx.keys())) plt.figure(figsize=(12, 10)) plt.imshow(cm, cmap="Blues") plt.xticks(range(len(label2idx)), label2idx.keys(), rotation=90) plt.yticks(range(len(label2idx)), label2idx.keys()) plt.colorbar() plt.tight_layout() plt.savefig("triage_confusion_matrix.png", dpi=150)看混淆矩阵的重点不是对角线,而是对角线外的高亮格子。分诊系统里最常见的是“心血管内科 ↔ 呼吸内科”“消化内科 ↔ 普通外科”这两组错分。前者需要模型理解“劳力性”“放射痛”等特征词,后者需要模型区分“保守治疗”和“手术指征”的语义,单纯加数据量提升有限。找到错分组之后,针对每个错分组加标注样本,比全局加数据效率高得多。
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 和同比例的旧数据混合,保证训练集的分布不剧烈跳动。智能分诊这类对稳定性要求高的系统,迭代节奏宁可慢一点,也不要在一次更新里引入大幅波动。
本文还有配套的精品资源,点击获取