简介:一套基于Python的心脏病数据分析与预测项目资料,面向计算机相关专业学生,适用于毕业设计、期末大作业或项目实训参考。项目依托UCI心脏病数据集,完整覆盖数据预处理、特征工程、模型构建与结果评估等技术环节。整套资料共84个文件、约23.8MB,主要包含Python源码(数据预处理、建模、模型选择、可视化)、CSV标准数据集、PDF分析报告、答辩PPT,以及大量结果图表(如混淆矩阵、ROC曲线、特征重要性分布等)。目前已有56人学习/下载。除可直接运行的代码与数据外,还提供了多种机器学习模型(决策树、随机森林、支持向量机、神经网络等)的对比评估过程与可视化图表,便于理解模型调参和性能验证方法;文档与PPT结构完整,可作为毕业设计或课程综合项目的整体参考范例。
1. 心脏病数据分析系统不是玄学:先从UCI数据集看清边界
一套能跑通、能交差、能写进简历的 «基于Python的心脏病数据分析系统»,听起来像是一个毕业设计题目的标准答案,但实际上它有非常具体的技术边界:数据来自UCI Machine Learning Repository公开的心脏病数据集,最常用的是Cleveland子集,共303条样本、14个字段。这份资源的价值不在于“机器学习高大上”,而在于它把一条完整的数据分析链路——从字段清洗、特征工程、模型训练到可视化展示——封装成可以直接改、直接跑的Python代码。
它适合谁?两类人。一类是正在做期末大作业或毕业设计的学生,需要一份能讲清楚“每一步为什么这么做”的参考实现;另一类是刚接触Python数据分析的从业者,想在一个小数据集上完整走一遍pandas、scikit-learn和可视化工具链。先说结论:这套系统最大的难点不在算法选型,而在数据本身的坑——UCI这份数据里有缺失值、有语义模糊的分类字段、有不平衡的标签分布,这些才是真正决定项目质量的地方。
2. UCI心脏病数据集拆解:字段语义、分布规律与清洗策略
2.1 十四列字段逐一过一遍,别把它们都当成数值
UCI心脏病数据集是从克利夫兰诊所的冠心病研究数据整理而来,常见文件名是heart.csv,也有版本叫processed.cleveland.data。数据列分别为age(年龄)、sex(性别,1男0女)、cp(胸痛类型)、trestbps(静息血压)、chol(血清胆固醇)、fbs(空腹血糖是否大于120mg/dl)、restecg(静息心电图结果)、thalach(最大心率)、exang(运动诱发心绞痛)、oldpeak(ST段压低值)、slope(ST段峰值斜率)、ca(主要血管数)、thal(地中海贫血类型)、target(是否患有心脏病,1有0无)。
这份数据里最容易被当成连续数值乱用的字段是cp、restecg、slope、thal和ca。它们是医学语义上的分类编码,比如cp的取值1到4分别代表典型心绞痛、非典型心绞痛、非心源性疼痛、无症状,数字之间没有“越大越严重”的递进关系。如果直接把原始编码丢给线性模型,模型会强行学习到数字大小的单调关系,这会导致病理性错误。我一般会在建模前单独处理这些字段,要么做独热编码,要么按医学常识重新映射成有序等级。
import pandas as pd df = pd.read_csv('heart.csv') # 先看一眼缺失值和数据类型 print(df.info()) print('缺失值统计:') print(df.isnull().sum()) # ca 和 thal 在原始文件中存在无效占位符,读取后需要排查 # 常见做法:把无法解析的字符替换成 NaN df['ca'] = pd.to_numeric(df['ca'], errors='coerce') df['thal'] = pd.to_numeric(df['thal'], errors='coerce') print('清洗后缺失值:') print(df.isnull().sum())这段代码解决的是数据入场后的第一道坎。pd.to_numeric加errors='coerce'会把无法解析的字符串变成NaN,避免后续建模时类型报错。我用df.info()先确认每列的数据类型和内存占用,再集中统计缺失值,这一步在UCI原始数据上必须做——Cleveland版本的数据文件里,ca和thal列有少量以?表示的缺失值,直接用read_csv读进来会得到 object 类型,后面做标准化时就会翻车。
2.2 缺失值处理方式:删行还是补值,取决于缺失率和业务含义
清洗完缺失值统计之后,下一步是决定哪些缺失值该删、哪些该补。这里没有统一标准,我的习惯是先看缺失比例:如果某列缺失超过30%,直接考虑删列;如果是个别样本的缺失,优先删行;如果删除会损失太多样本,再用中位数或众数补值。
Cleveland数据集本身只有303条样本,扣掉缺失后剩余可用样本通常不到300条,这意味着每删一条都会直接影响训练集大小。所以我倾向于对ca和thal这两列做众数填充,而不是粗暴删行。
# 用众数填充缺失列,避免有效样本被浪费 for col in ['ca', 'thal']: df[col] = df[col].fillna(df[col].mode()[0]) # 填充完成后再次确认 print(df.isnull().sum().sum())填充之后不要急着建模,先检查分类字段的取值分布是否正常。UCI数据集中thal的有效取值是3、6、7,分别对应正常、固定缺陷和可逆缺陷,但有些版本的数据里会出现数值0的无效样本——这是我实际复现时踩到的第一个坑。无效取值不属于缺失值,isnull()查不出来,但它会污染模型训练。排查方式很简单:用value_counts()看每个分类字段的分布,发现异常取值再单独处理。
2.3 数据分布的可视化检查:先从散点和直方图里发现规律
数据清洗不是闷头写代码,可视化能更快暴露问题。我习惯先做一个全局的分布矩阵图,重点观察标签列target的分布是否均衡、关键特征thalach与target之间的关系、以及是否存在明显的离群点。
import matplotlib.pyplot as plt import seaborn as sns sns.set_theme(style='whitegrid') fig, axes = plt.subplots(2, 2, figsize=(12, 8)) sns.histplot(data=df, x='age', hue='target', multiple='stack', ax=axes[0, 0]) sns.boxplot(data=df, x='target', y='thalach', ax=axes[0, 1]) sns.countplot(data=df, x='cp', hue='target', ax=axes[1, 0]) sns.scatterplot(data=df, x='oldpeak', y='thalach', hue='target', ax=axes[1, 1]) plt.tight_layout() plt.savefig('data_distribution.png', dpi=120)这张图能同时看出三个信号:第一,target的两类样本数量是否接近,如果悬殊太大,后面必须考虑重采样或调整评价指标;第二,thalach在两类人群中的分布是否明显不同,这决定了它作为特征的区分度;第三,cp的不同取值下心脏病患者的占比差异,通常cp=4(无症状)组的心肌梗死比例会异常高。
3. 建模主线:特征工程、三类模型对比与指标选择
3.1 特征工程:分类字段独热编码,数值字段标准化
建模前的最后一步是特征工程。age、trestbps、chol、thalach、oldpeak这些数值型特征量纲不同,直接丢给模型会让chol这种数值大的字段天然获得更高权重,必须做标准化或归一化。分类字段按上一章的分析做独热编码,这样逻辑回归能真正学习到每个类别的独立效应。
from sklearn.preprocessing import StandardScaler, OneHotEncoder from sklearn.compose import ColumnTransformer cat_cols = ['cp', 'restecg', 'slope', 'thal', 'sex', 'fbs', 'exang'] num_cols = ['age', 'trestbps', 'chol', 'thalach', 'oldpeak', 'ca'] preprocessor = ColumnTransformer( transformers=[ ('num', StandardScaler(), num_cols), ('cat', OneHotEncoder(handle_unknown='ignore'), cat_cols) ] )ColumnTransformer的好处在于它能把预处理流程打包成一个对象,在交叉验证里随模型一起训练,避免数据泄露。注意ca虽然数值不大,但它本质上是“血管数量”的计数,这里直接放进数值列做标准化,属于可接受的折中方案。如果你追求更严谨的写法,应该把它也转成有序分类变量。
3.2 三类模型做横向对比:逻辑回归、随机森林、XGBoost
心脏病的二分类问题不需要复杂的深度学习,传统机器学习的三件套就够用。我对比的是逻辑回归、随机森林和XGBoost,理由很直接:逻辑回归适合做基线和可解释性分析,随机森林抗过拟合能力强、对分类特征不敏感,XGBoost在表格数据上通常能拿到更高的AUC。三个模型共用同一套预处理管线,把训练集和测试集同时过一遍,结果才具备可比性。
import xgboost as xgb from sklearn.pipeline import Pipeline from sklearn.linear_model import LogisticRegression from sklearn.ensemble import RandomForestClassifier models = { 'logistic': Pipeline([ ('pre', preprocessor), ('clf', LogisticRegression(max_iter=1000, random_state=42)) ]), 'rf': Pipeline([ ('pre', preprocessor), ('clf', RandomForestClassifier(n_estimators=200, max_depth=6, random_state=42)) ]), 'xgb': Pipeline([ ('pre', preprocessor), ('clf', xgb.XGBClassifier(n_estimators=200, max_depth=4, learning_rate=0.05, eval_metric='auc', random_state=42)) ]) }这里的参数不是随手填的,都是我在这个数据集上试过之后留下的合理默认值。max_iter=1000是因为独热编码后特征维度变多,逻辑回归收敛需要更多轮次;max_depth控制在4到6是为了防止树模型在小样本上过拟合;learning_rate调低到0.05搭配200棵树,通常比默认值更稳。你可以先跑一遍,再用网格搜索微调,但不要一上来就上复杂调参,小数据集更看重稳定性和可解释性。
3.3 评估指标别只看准确率,ROC曲线才能说明问题
UCI心脏病数据集的类别分布大致呈40%比60%,样本总量不到300条,这时候accuracy很容易给出虚假的安全感。比如模型把所有样本都预测成“无心脏病”,准确率也有接近六成,但这个模型毫无价值。我评估时核心看三个指标:roc_auc、recall和f1-score。
from sklearn.model_selection import train_test_split from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score, roc_auc_score X = df.drop('target', axis=1) y = df['target'] X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, stratify=y, random_state=42 ) for name, model in models.items(): model.fit(X_train, y_train) y_pred = model.predict(X_test) y_prob = model.predict_proba(X_test)[:, 1] print(f'{name}: acc={accuracy_score(y_test, y_pred):.3f}, ' f'recall={recall_score(y_test, y_pred):.3f}, ' f'f1={f1_score(y_test, y_pred):.3f}, ' f'auc={roc_auc_score(y_test, y_prob):.3f}')注意train_test_split里加了stratify=y,这是为了在切分时保证训练集和测试集中正负样本的比例与原始数据一致。这在小数据集上尤其重要,否则运气不好切分出来的测试集可能全是某一类样本,导致评估指标剧烈波动。执行完这段代码,你会看到三个模型的四项指标对比——在多数种子下,XGBoost的AUC能跑到0.92左右,随机森林略低,逻辑回归最低但胜在系数可以直接解读。这份资源的核心交付物之一就是这套对比结果和画出ROC曲线的完整代码。
4. 把分析系统落成Python应用:训练、预测与可视化闭环
4.1 保存模型和预处理管线,预测时不能再重复训练
数据分析项目做到模型训练只是前半段,后半段是把模型落地成“输入一堆指标、输出风险概率”的系统。常见的做法是用joblib把训练好的Pipeline整个保存成文件,而不是单独保存模型和预处理对象。为什么要整个打包?因为Pipeline里包含了独热编码的列名映射和标准化参数的均值方差,这些元信息在预测阶段必须和训练时保持一致,分开保存容易在不同环境间出现列顺序错乱的问题。
import joblib from sklearn.model_selection import cross_val_score from sklearn.metrics import roc_curve # 挑AUC最优的模型做最终训练 best_model = models['xgb'] best_model.fit(X_train, y_train) joblib.dump(best_model, 'heart_model.joblib') print('模型已保存,文件大小:') import os print(f'{os.path.getsize("heart_model.joblib") / 1024:.1f} KB')保存之后最好立即做一次反序列化验证,确认加载出来的模型能用同样的输入做预测,不然部署到另一台机器上发现模型文件损坏或者环境依赖版本不对,排查起来非常耗时。这个习惯我后来一直在用,每次保存模型前都强制走一遍“保存 → 加载 → 预测 → 比对结果”的闭环。
4.2 用Flask写一个最小可用的预测接口
预测接口是整个“系统”中最容易让初学者卡住的部分。不需要复杂的前端框架,Flask写三四十行代码就能提供一个支持POST请求的JSON接口。接收的参数是患者指标,返回的是心脏病风险概率和风险等级,同时把模型的决策依据回传给调用方。
from flask import Flask, request, jsonify import joblib import numpy as np app = Flask(__name__) model = joblib.load('heart_model.joblib') feature_order = ['age', 'sex', 'cp', 'trestbps', 'chol', 'fbs', 'restecg', 'thalach', 'exang', 'oldpeak', 'slope', 'ca', 'thal'] @app.route('/predict', methods=['POST']) def predict(): try: data = request.get_json() features = [float(data[col]) for col in feature_order] features = np.array(features).reshape(1, -1) prob = model.predict_proba(features)[0][1] risk_level = '高风险' if prob >= 0.5 else '低风险' return jsonify({'risk_probability': round(prob, 4), 'risk_level': risk_level}) except KeyError as e: return jsonify({'error': f'缺少字段 {e}'}), 400 if __name__ == '__main__': app.run(host='127.0.0.1', port=5000, debug=False)接口中feature_order列表的顺序必须和训练时X_train的列顺序完全一致,这是最容易翻车的地方。如果训练时df.drop('target', axis=1)的列顺序是英文表头的排列顺序,而预测请求按别的顺序传参,模型输出的概率就是错的——但程序不会报错,因为特征个数相同。这一类“静默错误”在部署阶段最有迷惑性,需要额外的断言来兜底。
4.3 前端可视化:交互式Web界面让分析结果直观可见
除了JSON接口,系统里通常还配一个简单的交互页面。用Flask的render_template渲染一个HTML表单,用户填写13项身体指标,点击提交后前端用fetch把数据POST到/predict接口,拿到结果后在前端展示风险概率和风险等级。这种架构的好处是后端逻辑和前端展示完全解耦,后续换成Vue或者React只改前端,不动模型代码。
# templates/index.html 关键部分 <form id="heartForm"> <label>年龄</label> <input type="number" name="age" required> <label>胸痛类型 (1-4)</label> <select name="cp"> <option value="1">典型心绞痛</option> <option value="2">非典型心绞痛</option> <option value="3">非心源性疼痛</option> <option value="4">无症状</option> </select> <button type="submit">预测风险</button> </form> <div id="result"></div> <script> document.getElementById('heartForm').addEventListener('submit', async (e) => { e.preventDefault(); const formData = new FormData(e.target); const payload = Object.fromEntries(formData.entries()); const resp = await fetch('/predict', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) }); const result = await resp.json(); document.getElementById('result').innerHTML = `风险概率: ${result.risk_probability},等级: ${result.risk_level}`; }); </script>到这里,整条链路已经走通了:数据清洗 → 特征工程 → 模型训练 → 接口封装 → 前端展示。这套系统交付后,你可以直接运行python app.py然后在浏览器里操作整个预测流程。
5. 避坑与排查:复现这套系统最容易翻车的五个地方
5.1 现象:read_csv读出来的ca列全是对象类型,模型无法训练
原因:UCI原始文件里缺失值用?表示,Pandas默认不识别它为空值,会把它当成普通字符串读入,整列的dtype就变成了object。后续进行数值运算时抛出 TypeError。
解决:读取时直接指定na_values=['?'],这样?会按缺失值处理,再走填充逻辑。不要等到建模前才处理,数据读入阶段就应完成这一步。
df = pd.read_csv('heart.csv', na_values=['?'])5.2 现象:模型AUC只有0.75,怎么调都上不去
原因:ca列里存在无意义取值,比如数值0之外的异常值。UCI文档对ca的定义是0到3之间的整数,但部分版本的数据文件里混入了其他字符或异常编码。这些异常样本干扰了特征分布。
解决:建模前检查df['ca'].value_counts(),把取值不符合文档定义的样本单独标记或剔除。我当时的处理是把ca取值范围限制在0到3之间,范围外的统一按缺失值填充。
5.3 现象:训练集AUC接近1.0,测试集只有0.7,差距非常大
原因:在训练之前先把整个数据集做了标准化,包括测试集的数据。另一种可能是独热编码在训练集和测试集上分别执行,导致某些类别只在测试集中出现,产生维度不匹配。
解决:用Pipeline把预处理和训练打包成一个整体,在交叉验证中对训练集做fit_transform,对测试集只做transform。这能从根本上杜绝数据泄露。如果发现类别不匹配,在OneHotEncoder里设handle_unknown='ignore'。
5.4 现象:模型预测结果全是“无心脏病”,查代码逻辑没有明显错误
原因:target列中的正样本(有心脏病)占比较低,模型在默认阈值0.5下偏向预测多数类。这不是代码bug,而是类别不平衡问题。
解决:两个方案。一是训练时给少数类加权重,比如class_weight='balanced';二是根据临床场景调整阈值,因为漏诊心脏病(假阴性)的代价远比误报(假阳性)高。把阈值从0.5降到0.3,让模型更敏感,同时用ROC曲线找到约登指数最优的切点。
5.5 现象:Flask接口部署到服务器上后,预测速度很慢
原因:每次请求都重新加载一次joblib模型文件,没有把模型对象放到全局变量或缓存中。模型文件虽然不大,但在高并发场景下重复读取磁盘会影响响应时间。
解决:模型加载放到模块的顶层作用域,在应用启动时加载一次,后续请求直接复用。测试时直接把模型对象定义为全局变量,然后循环调用100次对比响应时间,你会看到显著的差距。
6. 结果验证与进阶:交叉验证和SHAP值让模型不再黑匣子
模型选型和接口封装都完成后,还有一步很多人会忽略——用交叉验证和特征重要性解释把结论夯实。单次train_test_split的结果具有随机性,可能是某一组划分恰好运气好。我通常会在项目收尾阶段,用5折分层交叉验证重新评估模型,得到每折AUC的均值和标准差,这样写进报告里的数字才足够硬气。
from sklearn.model_selection import StratifiedKFold, cross_val_score cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) cv_scores = cross_val_score(best_model, X, y, cv=cv, scoring='roc_auc') print(f'5折AUC: {cv_scores.mean():.4f} ± {cv_scores.std():.4f}')交叉验证之后,再用SHAP值分析每个特征对预测结果的贡献方向。SHAP能告诉我们是thalach升高会降低风险,还是oldpeak升高会显著提升风险,这类结论比一个裸的AUC数字更有说服力,尤其适合写进毕业设计的论文里。
import shap explainer = shap.TreeExplainer(best_model.named_steps['clf']) X_processed = best_model.named_steps['pre'].transform(X_test) shap_values = explainer.shap_values(X_processed) shap.summary_plot(shap_values, X_processed, feature_names=feature_order)SHAP的summary plot能按重要度排序展示所有特征的影响方向。我在这个数据集上跑出来的结果,thalach(最大心率)和oldpeak(ST段压低)通常排在前两位,这跟心内科的临床常识一致——运动负荷试验中的ST段压低幅度是判断心肌缺血的核心指标之一。
从那以后,我每次做完一个数据分析项目都会强制走一遍交叉验证加SHAP解释的流程,即使时间紧也要跑一个5折的AUC均值。原因很简单:单次划分的测试集结果没法说服别人,但交叉验证的均值和可视化解释能。如果你照着这份资源复现,建议也把这个习惯保留下来,你的系统会多一份可信度,希望帮到你。
本文还有配套的精品资源,点击获取