ARTICLE DETAIL

资讯详情

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

Python实现JD智能分诊文本分类:从TF-IDF到BERT微调与部署

Python实现JD智能分诊文本分类:从TF-IDF到BERT微调与部署 简介一套面向医疗健康领域的Python智能分诊文本分类完整源码项目基于ResNet、BERT、XLNet等预训练模型构建三阶段流程基础模型搭建、优化策略应用与集成特征提取可有效提升分诊效率适合自然语言处理进阶学习者、算法工程师及医疗信息化开发者参考。压缩包共58个文件约78.61MB主要包含CSV与TXT文本数据、6个Python源码和3个字节码、Excel结果表、JSON配置、预训练模型文件夹及LDA主题模型文件完整涵盖从数据清洗、特征提取到模型评估与调优的文本分类全流程。目前已有296人学习下载源码按基础模型、优化策略和预训练集成三大模块组织目录结构清晰便于定位与复现实验。除可运行代码外还附带多组实验数据、测试集标注文件、运行结果说明及LDA主题分析文件能帮助读者深入理解特征工程、预训练模型集成与分诊场景落地实践。1. 为什么基于 Python 的 JD 智能分诊文本分类项目值得自己动手做急诊分诊台是医院里最容易出状况的地方之一护士要在几分钟内根据患者主诉判断病情严重程度决定先抢救还是先排队。白天还好到了后半夜一个年轻护士面对“胸口疼”“肚子疼”“头晕恶心”这类描述很容易把心梗早期症状当成胃痛处理。这里真正要解决的核心问题就是文本分类——把患者的主诉自然语言映射到分诊级别和科室建议上。基于 Python 的 JD 智能分诊文本分类项目设计与实现源码做的就是这件事它把分诊经验从人脑里搬进一套可以运行的 Python 系统输入一段主诉输出分诊等级和科室推荐并且全部过程用源码串起来能训练、能评估、能部署。这套流程特别适合自然语言处理方向的课程设计或毕业设计也适合医疗信息化工程师拿去做分诊辅助工具的雏形。下面按一条完整可跑的路径来讲先定义数据再训练模型然后封装成接口最后把这套系统里最容易翻车的地方逐个说明白。2. 先把分诊任务拆成数据问题主诉字段、标签规则和训练集构造做 JD 智能分诊文本分类最容易犯的错是拿到数据后直接跑模型。事实上分诊场景的数据如果不先做清洗和标签约束后边换来换去都是模型在“猜”上线后反而暴露更多问题。这一章先解决三件事标签怎么定、主诉文本怎么洗干净、训练集怎么切才不丢重症样本。2.1 四级分诊标签与科室标签把定义先锁死模型才不会乱学分诊标签不能拍脑袋定行业内最常用的是四级分类一级生命体征不稳定需要立即抢救比如心跳骤停、大出血、意识丧失二级有潜在生命危险需要尽快处理比如胸痛、呼吸困难、持续剧烈腹痛三级急症但生命体征相对平稳比如闭合性外伤、中度发热四级非急症可以按顺序排队比如慢性病复查、普通感冒。在构造训练数据时要把“分诊级别”和“推荐科室”一起作为标签。科室标签不要一开始就做几十个细分类我一般会先压缩成内科、外科、儿科、妇产科、五官科、皮肤科这样的一级科室。原因很直接细分类会把样本摊薄某些科室只有几十条数据模型全被大科室带偏准确率表面上不差查一下混淆矩阵全是偏科。标签字段设计建议用这种结构label_map_level {一级: 0, 二级: 1, 三级: 2, 四级: 3} label_map_dept {内科: 0, 外科: 1, 儿科: 2, 妇产科: 3, 五官科: 4, 皮肤科: 5}这里把中文标签转成整数为的是后续训练时能直接交给 sklearn 或 PyTorch。分诊级别是模型的主输出科室是辅助输出两者放在同一条样本里后边评估时可以联合看比如“二级 内科”是不是和真实记录一致。2.2 用 pandas 清洗主诉文本把“肚子疼”和“腹痛”拉齐分诊主诉是护士手工录入的文本质量差异非常大有全角符号、有错别字、有口语化的“肚子疼”也有书面化的“腹痛”。如果不做清洗同一个意思会被模型当成两个特征样本稀缺时模型会学得很别扭。下面这段清洗逻辑是我用来处理分诊原始表的标准动作import pandas as pd import re import unicodedata # 原始表常见字段chief_complaint, department, triage_level df pd.read_csv(triage_raw.csv, encodingutf-8) synonym_dict { 肚子疼: 腹痛, 肚子痛: 腹痛, 胸口疼: 胸痛, 胸口痛: 胸痛, 喘不上气: 呼吸困难, 上不来气: 呼吸困难, 拉肚子: 腹泻, 发烧: 发热, 心里发慌: 心悸, } def clean_text(text): if not isinstance(text, str): return # 先用 NFKC 统一全角半角再去掉问诊录入时常见的冗余词 text unicodedata.normalize(NFKC, text.strip()) text text.replace(患者, ).replace(主诉, ).replace(【, ).replace(】, ) # 只保留中文、英文字母、数字和少量分隔符去掉表情符号和特殊字符 text re.sub(r[^\u4e00-\u9fa5a-zA-Z0-9,.;:。:、()], , text) # 同义词归一把口语拉回标准表达 for k, v in synonym_dict.items(): text text.replace(k, v) return text df[clean_text] df[chief_complaint].apply(clean_text) df[triage_level] df[triage_level].map(label_map_level) df[department] df[department].map(label_map_dept) print(df[[chief_complaint, clean_text, triage_level, department]].head())这段代码的核心逻辑有三层第一层是 NFKC 归一解决全角冒号、全角括号这类录入差异第二层是正则过滤删掉表情、特殊符号避免模型在无关字符上学到噪声第三层是同义词映射这是分诊文本分类特有的关键点分诊台用语天然有口语和书面语混用同义词表比模型硬记更可控。同义词表需要定期补充。常见做法是从验证集里挑出预测错误的样本把新出现的口语表达加进映射表。比如“胃里反酸水”这种描述在医院里很常见模型大概率学不到加一条映射到“反酸”就能立刻改进。2.3 训练集划分按“级别 科室”分层采样避免把危重样本都切丢如果直接随机切分训练集和验证集一级、二级样本本来就少很可能全被切到验证集里模型根本没学过几个危重样本。这个问题在分诊场景里会被放大因为一级样本可能只占全部数据的 1%。我一般会构造一个复合分层字段再交给 train_test_split 做分层切分from sklearn.model_selection import train_test_split # 按“分诊级别 科室”组合成分层字段 strata df[triage_level].astype(str) - df[department].astype(str) # 如果某个组合只有一条样本需要先过滤否则 train_test_split 会报错 df df[df.groupby(strata)[clean_text].transform(count) 2] # 重新生成分层字段 strata df[triage_level].astype(str) - df[department].astype(str) train_df, valid_df train_test_split( df, test_size0.2, stratifystrata, random_state42, ) print(训练集分布:, train_df[triage_level].value_counts().to_dict()) print(验证集分布:, valid_df[triage_level].value_counts().to_dict())这里最值得说的是groupby(...).transform(count) 2这一步。如果你的原始数据里存在“一级 儿科”只有一条记录这种极端情况分层采样会因无法保证这层同时分到训练和验证而报错。先过滤掉这些孤立样本再切分就能把这个问题挡在前面。切分后的数据不要马上丢掉建议看一眼验证集的一二级样本数。我给自己定的标准是验证集里至少要有 20 条一级样本如果没有就调整test_size或者再做一次欠采样合并保证评估时能看到模型对危重样本的真实表现。3. 用 Python 实现 JD 智能分诊分类器从 TF-IDF 基线到 BERT 微调数据准备好之后模型部分反而是最透明的。JD 智能分诊文本分类通常走两条路一条是 TF-IDF 加线性分类器适合机器配置有限、需要快速上线的情况另一条是 BERT 微调适合追求准确率和语义理解的情况。先跑通基线再上预训练模型这是我在文本分类项目里的固定顺序。3.1 先跑通最小可用的 TF-IDF 逻辑回归基线第一个模型不追求极致准确率目标是建立一个能对比的基线。用 TF-IDF 提取特征用逻辑回归做分类训练时间短代码也简单from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.linear_model import LogisticRegression from sklearn.pipeline import Pipeline baseline Pipeline([ (tfidf, TfidfVectorizer( ngram_range(1, 2), max_features50000, min_df3, sublinear_tfTrue, )), (clf, LogisticRegression( C1.0, class_weightbalanced, max_iter1000, solverlbfgs, )), ]) baseline.fit(train_df[clean_text], train_df[triage_level])参数这里有几个讲究。ngram_range(1, 2)表示同时用单词和双词组合作为特征“胸痛”和“剧烈胸痛”都能被捕捉到min_df3表示至少在 3 条样本里出现过的词才保留这能滤掉那些只在某一条记录里出现的生僻词sublinear_tfTrue是对词频做对数缩放防止“医生”“患者”这类高频词把分诊特征淹没class_weightbalanced是必须加的因为分诊数据天然不均衡一级样本少不加这个参数模型会倾向把所有样本都预测成三、四级。训练完成后立刻做一轮验证看分类报告from sklearn.metrics import classification_report, confusion_matrix pred baseline.predict(valid_df[clean_text]) print(classification_report( valid_df[triage_level], pred, target_names[一级, 二级, 三级, 四级], )) cm confusion_matrix(valid_df[triage_level], pred) print(混淆矩阵:) print(cm)重点看一级和二级的召回率。分类报告里如果一级召回率是 0说明模型把危重样本全判成了低级这种基线即使整体准确率有 85%也不能直接上线。此时优先调整class_weight或者在数据层面做一次过采样。TF-IDF 基线的好处是几分钟就能跑完一轮调整参数非常快是后续所有模型对比的参照物。3.2 微调 BERT 中文模型的完整代码参数设置与训练过程TF-IDF 基线只能理解词面上的关联遇到“胸闷伴大汗”这种需要医学常识来判断严重程度的文本特征表达就不够了。BERT 这类预训练模型能把上下文语义编码进去在分诊场景里通常能把 F1 提升 5 到 10 个百分点。下面是一套可以完整运行的微调代码import torch from torch.utils.data import Dataset from transformers import AutoTokenizer, AutoModelForSequenceClassification, Trainer, TrainingArguments class TriageDataset(Dataset): def __init__(self, encodings, labels): self.encodings encodings self.labels labels def __getitem__(self, idx): item {k: torch.tensor(v[idx]) for k, v in self.encodings.items()} item[labels] torch.tensor(self.labels[idx], dtypetorch.long) return item def __len__(self): return len(self.labels) tokenizer AutoTokenizer.from_pretrained(bert-base-chinese) model AutoModelForSequenceClassification.from_pretrained( bert-base-chinese, num_labels4, ) train_encodings tokenizer( train_df[clean_text].tolist(), truncationTrue, paddingTrue, max_length128, ) valid_encodings tokenizer( valid_df[clean_text].tolist(), truncationTrue, paddingTrue, max_length128, ) train_dataset TriageDataset(train_encodings, train_df[triage_level].tolist()) valid_dataset TriageDataset(valid_encodings, valid_df[triage_level].tolist()) training_args TrainingArguments( output_dir./bert_triage_ckpt, per_device_train_batch_size16, per_device_eval_batch_size32, learning_rate2e-5, num_train_epochs3, evaluation_strategyepoch, save_strategyepoch, save_total_limit2, logging_dir./bert_triage_log, logging_steps50, seed42, ) trainer Trainer( modelmodel, argstraining_args, train_datasettrain_dataset, eval_datasetvalid_dataset, ) trainer.train()代码里几个关键点值得展开。max_length128对分诊主诉是完全够用的主诉一般不会超过几十个字设成 512 只会拖慢训练和推理速度learning_rate2e-5是微调中文分类任务的常见起步值调参时先固定这个值跑一轮再尝试 3e-5 和 1e-5evaluation_strategyepoch让每个 epoch 结束都做一次验证方便观察是否过拟合。数据集类TriageDataset的作用是把 tokenizer 输出的字典转成 PyTorch 张量并额外加上labels字段。这里容易踩坑的是labels必须用torch.long类型否则 Trainer 内部计算损失时会报类型错误。另外第一次跑的时候建议把per_device_train_batch_size设成 8如果显存不够 16 会直接 OOM这是我在 8G 显卡上反复遇到过的体验。3.3 分诊效果评估优先盯“漏掉的重症”而不是整体准确率训练结束后最终模型评估我一般同时做三件事先看总准确率再看分类报告里的各类 F1最后画一张混淆矩阵图。下面这段代码会把结果拼成一张便于汇报的表from sklearn.metrics import classification_report, f1_score bert_pred trainer.predict(valid_dataset) bert_pred_label bert_pred.predictions.argmax(axis1) print(classification_report( valid_df[triage_level], bert_pred_label, target_names[一级, 二级, 三级, 四级], )) # 计算带权重的综合 F1 weighted_f1 f1_score( valid_df[triage_level], bert_pred_label, averageweighted, ) print(weighted_f1:, round(weighted_f1, 4))评估分诊模型时整体准确率会骗人。假设一级样本只占 2%模型把所有一级都判成三级准确率可能仍然有 90% 以上但这在临床上不可接受。我习惯把“一级召回率”和“二级召回率”作为核心指标一级召回率至少要达到 0.9二级要达到 0.8才算具备辅助决策价值。如果 BERT 模型的召回率反而不如 TF-IDF 基线优先检查训练数据里是不是存在标签噪声比如同一症状在不同记录里被标成了不同等级这种问题不是换模型能解决的需要回到数据标注环节修复。4. 把 JD 智能分诊模型封装成可用服务Flask 接口设计与三个必调参数模型训练完只是完成了“算法验证”这一步。真正要在分诊台用起来需要把模型包成一个 HTTP 接口让前端页面或者护士工作站能够直接调用。这一章讲怎么用 Flask 把 sklearn 模型封成接口以及上线前必须调的三个参数。4.1 用 Flask 封装模型加载和预测接口先保存训练好的模型然后用 Flask 写一个最小的分类接口import joblib # 保存 TF-IDF 基线模型BERT 模型建议单独保存 checkpoint joblib.dump(baseline, triage_baseline.pkl)然后创建app.pyfrom flask import Flask, request, jsonify import joblib import re import unicodedata app Flask(__name__) # 启动时加载一次模型避免每个请求都重新读文件 model joblib.load(triage_baseline.pkl) LEVEL_NAMES {0: 一级, 1: 二级, 2: 三级, 3: 四级} synonym_dict { 肚子疼: 腹痛, 胸口疼: 胸痛, 喘不上气: 呼吸困难, 拉肚子: 腹泻, 发烧: 发热, } def clean_text(text): if not isinstance(text, str): return text unicodedata.normalize(NFKC, text.strip()) text re.sub(r[^\u4e00-\u9fa5a-zA-Z0-9,.;:。:、()], , text) for k, v in synonym_dict.items(): text text.replace(k, v) return text app.route(/triage/classify, methods[POST]) def classify(): data request.get_json(forceTrue) text data.get(chief_complaint, ) if not text: return jsonify({code: 1, message: chief_complaint 不能为空}), 400 text_clean clean_text(text) proba model.predict_proba([text_clean])[0] level int(model.predict([text_clean])[0]) return jsonify({ code: 0, triage_level: level, triage_level_name: LEVEL_NAMES[level], probability: {LEVEL_NAMES[i]: round(float(x), 4) for i, x in enumerate(proba)}, }) if __name__ __main__: app.run(host0.0.0.0, port8000, threadedTrue)这段接口代码有两点要特别注意。第一clean_text必须和训练时用的清洗逻辑保持一致哪怕只差一个正则表达式都会导致线上预测分布偏移。第二返回结果里除了分诊等级还带了probability字典这个信息在实际使用中非常关键——当最大概率低于 0.5 时前端可以提示“模型置信度较低请人工复核”这是分诊辅助工具保命的一层设计。启动服务后用 curl 验证一下curl -X POST http://127.0.0.1:8000/triage/classify \ -H Content-Type: application/json \ -d {chief_complaint: 胸口疼出冷汗呼吸困难}正常会返回 JSON包含分诊级别和概率分布。第一次调通这个接口后整个 JD 智能分诊文本分类的最小闭环就建成了。4.2 上线前必调的三个参数线程、超时与排序Flask 内置服务器只适合开发环境正式部署时我一般用 gunicorn 启动应用gunicorn -w 4 -b 0.0.0.0:8000 app:app这里-w 4表示启动 4 个工作进程。在 gunicorn 环境下有四个参数必须提前调好否则上线后必然出问题。第一个是请求超时默认是 30 秒但分诊接口如果部署的是 BERT 这类大模型首次推理可能就要 2 到 3 秒并发一高就容易排队超时我一般会显式设置--timeout 60。第二个是并发进程数-w不要超过 CPU 核心数的两倍设太大反而会因为 CPU 切换开销导致延迟升高。第三个是模型加载预热gunicorn 的 worker 是独立进程每个 worker 都会加载一次模型启动瞬间内存会短暂翻倍需要确认服务器内存够用。第四个是接口层做一层简单缓存同一段主诉文本在短时间内重复请求的概率很高用一个字典缓存最近 100 条结果能显著降低模型压力from functools import lru_cache lru_cache(maxsize128) def predict_with_cache(text): text_clean clean_text(text) proba model.predict_proba([text_clean])[0] level int(model.predict([text_clean])[0]) return level, proba缓存的坑在于清理策略。分诊文本是多样化的缓存超过 128 条后旧结果会被淘汰这是可以接受的但如果医院改动了分诊标准需要重启服务强制清空缓存否则模型已经更新缓存还在返回旧结果。5. JD 智能分诊分类的避坑记录5 个让模型翻车的常见问题前四章把完整流程走通了这一章集中写我在实际项目中见过、踩过的问题。每条都按“现象 → 原因 → 解决”来写可以当作上线前的自检清单。5.1 危重样本被模型忽略一级召回率几乎为零现象训练完成后看分类报告总准确率 86%但一级召回率只有 0.05几乎所有的危重患者都被判成了三级或四级。原因分诊数据里一级样本数量极少通常占比不到 2%逻辑回归默认会最大化整体准确率于是把所有样本都往占多数的类别上推。分类器发现“猜三级”能让总损失最小它就懒得认真学一级的特征了。解决在逻辑回归里加class_weightbalanced或者对一级、二级样本做随机过采样。更有效的做法是把评估指标从“准确率”切换成“一级召回率”并在训练时把损失函数改成加权的交叉熵。上线后还要定期统计一级召回率的变化如果低于 0.9说明模型安全裕度不够宁愿让它“多报”一级也不能“漏报”。5.2 同一句话换个说法分诊结果就变了现象验证阶段把“胸口疼出冷汗”和“胸闷出汗”两条不同表达送进模型得到不同的分诊级别但医生判断这两种情况应该同级。原因TF-IDF 模型把每个词当成孤立特征“胸痛”“胸闷”“胸口疼”在特征空间里是三个完全不同的维度样本量不够时模型学不到它们的语义联系。同义词表覆盖不全时这个现象会被放大。解决第一层是同义词归一化把“胸口疼”“胸口痛”“胸骨后疼痛”统一到“胸痛”第二层是换用 BERT 这类预训练模型它能通过上下文把近义词映射到相近的向量空间。我的经验是先加同义词表再评估 BERT 的收益同义词表能解决 30% 的表达差异问题剩下的才交给模型去学。5.3 护士标注习惯不一致模型上线后临床反馈差现象验证集上模型表现不错但护士实际用的时候说预测结果“不如人靠谱”很多明显是急症的文本被模型定成了三级。原因训练数据是不同护士标注的有的护士习惯把“腹痛待查”标成二级有的标成三级标签本身就不一致。模型学习到的不是分诊标准而是某个护士的个人偏好。解决在标注阶段就做一致性控制。常见做法是让两个护士独立标注同一批样本只保留两人结论一致的样本进训练集如果医院没有双标注条件至少要按人分组检查差异把明显偏离平均水平的标注者找出来重新培训。上线后同样要收集一线的反馈记录定期回填到训练集。5.4 BERT 接口一压测就超时并发 10 个请求后开始排队现象用 gunicorn 部署 BERT 模型后单次请求正常但用压测工具发 10 个并发请求平均响应时间从 0.8 秒飙升到 5 秒部分请求直接超时。原因BERT 模型参数量大CPU 推理每个请求都要跑一遍完整网络再加上max_length512会翻倍增加计算量。Flask 的threadedTrue只能并发接收请求并不能并行加速模型计算。解决第一层优化是把max_length从 512 降到 128分诊主诉根本不需要那么长第二层是把模型转换成 ONNX 格式或者用 TorchScript 静态化CPU 推理速度能提升一倍以上第三层是接入缓存相同主诉不重复计算。如果优化之后单机还是扛不住并发最实际的做法是在 TF-IDF 基线和 BERT 之间做降级策略——流量高峰时先走快模型慢模型作为异步校验。5.5 分诊级别和科室建议两个任务互相干扰现象模型预测出“四级 外科”这种组合但实际患者是心内科问题分诊台按照科室建议挂了普通外科号。原因把分诊级别和科室两个标签放进同一个多分类模型里模型为了兼顾两个目标学出了一套折中的特征表达。当“胸痛”既会被内科当成心绞痛又会被外科当作肋骨外伤时单个模型的输出就会摇摆。解决更稳定的做法是拆成两个模型或者用一个多任务网络共享 BERT 底层编码顶层分成两个分类头。如果不引入多任务结构至少要在服务层加一道科室映射规则表把“胸痛”强制映射到心血管内科和呼吸内科不接受模型自由发挥。6. 用高不确定样本回流让 JD 智能分诊模型落地后继续进化模型上线不是终点分诊场景的文本分布会随时间变化冬春季呼吸道症状变多夏秋季外伤和胃肠道症状变多节假日醉酒和车祸伤激增。固定的训练集永远跟不上真实世界的节奏我最后的建议是给系统加一条主动学习回路。操作流程是这样的接口返回的probability字段里当最大概率低于 0.6 时把这条请求连同模型预测结果写入一个uncertain_samples表每周导出一批由分诊护士人工复核真实分诊级别修正后的样本进入下一轮训练集增量训练后替换线上模型。这个回路不需要复杂的平台支撑一个数据库表加一个定时脚本就够了import sqlite3 import json from datetime import datetime def save_uncertain(text, proba, level): max_prob max(proba.values()) if max_prob 0.6: conn sqlite3.connect(triage_feedback.db) conn.execute( INSERT INTO uncertain_samples (text, proba, level, created_at) VALUES (?, ?, ?, ?), (text, json.dumps(proba, ensure_asciiFalse), level, datetime.now().isoformat()) ) conn.commit() conn.close()这个“低置信度回流”机制比一味换更大的模型更省钱也更贴近分诊场景的真实痛点。我在这个方向上的经验是模型提升最大的时候往往不是调参调出来的而是把护士们标记过的错误样本重新喂回去的时候。刚开始做 JD 智能分诊文本分类时我也迷信过更复杂的网络结构后来发现 80% 的准确率提升来自数据清洗、标签一致性和不确定样本回流剩下的 20% 才来自模型迭代。希望这套项目设计与实现路径能帮你少走几趟弯路把源码工程化这件事稳稳落地早日看到它真正发挥作用。本文还有配套的精品资源点击获取
返回列表