简介:本资源是一套完整的心脏疾病数据分析实战项目,面向计算机、数据科学及相关专业本科生毕业设计、课程设计与期末大作业需求,聚焦于利用真实临床数据识别心脏病风险因素。压缩包共70个文件,含4个核心Python脚本(预处理、可视化、建模、模型选择)、2个CSV数据集(原始及清洗后)、1份PDF分析报告、1个答辩PPTX、1个README说明及53张PNG+8张JPG图表(涵盖混淆矩阵、ROC曲线、特征重要性、统计分布等),整体23.24MB,结构清晰、模块分明,便于分阶段学习与复现。已有81人下载学习,报告与PPT均经导师指导并获98分高分评价,内容覆盖数据清洗、多模型对比(决策树/随机森林/SVM等)、结果可视化与医学解读,可直接用于答辩或拓展研究,显著降低毕设实施门槛。
1. 心脏疾病数据分析(使用UCI数据集):为什么这个经典项目至今仍是数据科学新人的“第一块试金石”?
你手头有一份来自UCI机器学习库的heart-disease数据集——共303条记录、14个字段,包含年龄、胸痛类型、静息血压、血清胆固醇、空腹血糖、心电图结果、最大心率、心绞痛发作情况等临床指标,标签是“是否确诊冠心病”(0/1)。这不是合成数据,也不是脱敏后只剩骨架的业务日志;它是1988年克利夫兰诊所真实采集的临床记录,经多年教学验证,字段含义清晰、缺失可控、分布合理、噪声真实。正因如此,它成了Python数据科学入门者绕不开的“成人礼”:用pandas清洗、用seaborn可视化、用scikit-learn建模、用matplotlib导出图表、用Jupyter生成报告、再打包成PPT演示——整套流程不依赖任何私有API或权限系统,一台装好Python 3.8+的笔记本就能从零跑通。我带过的27届实习生里,92%的第一份可写进简历的完整项目,都始于这份数据集。它不炫技,但能暴露你对缺失值处理的直觉偏差、对类别不平衡的麻木、对特征缩放必要性的误判——这些坑,恰恰是企业级项目里最常引发模型上线失败的“低级错误”。如果你刚配好VS Code的Python环境、还在为pip install报错焦头烂额,这个项目就是你的后悔药;如果你已能调通BERT微调,它依然是检验你工程闭环能力的黑匣子。
2. 从UCI官网下载到本地数据加载:三步锁定原始数据源,避开镜像站陷阱
UCI Heart Disease数据集在官网有多个版本分支(Cleveland、Hungarian、Switzerland、Long Beach VA),必须明确选用Cleveland子集——这是最完整、标注最规范、被引用次数最多的版本(UCI ID: 45)。其他版本存在字段缺失(如Hungarian版缺少ST段斜率)、标签定义不一致(Switzerland版将“疑似”归为正样本)等问题,直接导致后续模型评估失真。新手常因搜索“UCI heart disease”跳转到第三方镜像站(如Kaggle、UCI中文镜像),下载到已被二次处理的CSV文件(如已填充缺失值、已编码分类变量),失去原始数据的“脏感”,反而掩盖了真实的数据清洗逻辑。
2.1 官网直链下载与校验(非Kaggle替代方案)
UCI官方页面地址为:https://archive.ics.uci.edu/ml/datasets/Heart+Disease
点击“Data Folder”进入目录页,找到processed.cleveland.data(主数据)、heart-disease.names(字段说明)两个文件。切勿下载.zip包——该压缩包内含4个子集混合文件,易混淆。直接右键保存以下两个纯文本文件到本地data/目录:
# 创建数据目录(推荐统一路径) mkdir -p data/uci_heart # 使用curl下载(Windows用户可用浏览器另存为) curl -o data/uci_heart/processed.cleveland.data \ https://archive.ics.uci.edu/ml/machine-learning-databases/heart-disease/processed.cleveland.data curl -o data/uci_heart/heart-disease.names \ https://archive.ics.uci.edu/ml/machine-learning-databases/heart-disease/heart-disease.names提示:下载后务必校验文件大小。
processed.cleveland.data应为26.7 KB(303行),若为25.1 KB或31.2 KB,说明下载了其他子集或被截断,需重下。
2.2 原始数据加载与字段映射:用pandas读取时绕过“无列名”陷阱
UCI原始数据是逗号分隔的纯文本,无表头行,且存在问号?表示缺失值。直接pd.read_csv("data/uci_heart/processed.cleveland.data")会导致所有列被识别为字符串,数值计算失效。正确做法是显式指定列名、缺失值标识符及数据类型:
import pandas as pd import numpy as np # 定义14个字段名称(按UCI names文件第18行起顺序) column_names = [ 'age', 'sex', 'cp', 'trestbps', 'chol', 'fbs', 'restecg', 'thalach', 'exang', 'oldpeak', 'slope', 'ca', 'thal', 'target' ] # 加载数据:指定缺失值符号、列名、数据类型 df = pd.read_csv( "data/uci_heart/processed.cleveland.data", names=column_names, # 强制添加列名 na_values='?', # 将'?'识别为NaN dtype={ 'ca': 'object', # ca字段含'?'和数字,先设为object避免强转失败 'thal': 'object' # thal同理 } ) # 查看前5行验证 print(df.head()) print(f"原始形状: {df.shape}") # 应输出 (303, 14)关键参数说明:
names=column_names:UCI数据无header,必须手动注入列名,否则pandas默认将首行当列名,导致数据错位;na_values='?':原始数据用?标记缺失,不指定则?被当作字符串保留,后续df.isnull().sum()统计为0;dtype={'ca':'object', 'thal':'object'}:这两个字段含?和数字混合,若设为float会触发ValueError: could not convert string to float: '?',先设为object便于后续统一处理。
2.3 字段语义解析:对照names文件修正目标变量定义
打开heart-disease.names文件,重点阅读第18–31行关于target字段的说明:
target: 0 = no disease, 1 = mild, 2 = moderate, 3 = severe, 4 = very severe
这意味着原始标签是5分类问题(0–4),而非二分类。但绝大多数教学案例将其简化为二分类(0 vs 1–4),此操作虽降低难度,却掩盖了疾病严重程度的临床价值。本项目采用更合理的二分法:target >= 1视为“确诊冠心病”(正样本),target == 0为“无病”(负样本),既保留医学意义,又避免多分类带来的样本不均衡加剧(0类占38%,4类仅4%)。
# 将target转换为二分类:0→0, 1-4→1 df['target_binary'] = (df['target'] >= 1).astype(int) print(df['target'].value_counts().sort_index()) # 原始分布 print(df['target_binary'].value_counts()) # 二分类后:165 vs 1383. 数据清洗实战:处理缺失值、异常值与类别不平衡的三重关卡
UCI Cleveland数据集的“脏”不是随机噪声,而是临床数据固有的不完整性。直接删除含缺失值的行会损失25%样本(ca字段缺失率达45%),而盲目填充均值会扭曲医学分布。清洗必须结合临床逻辑——例如ca(荧光透视血管计数)缺失,往往意味着患者未接受该检查,而非数据录入错误。
3.1 缺失值定位与领域驱动填充策略
执行df.isnull().sum()发现:ca列有175个?(57.8%),thal列有20个?(6.6%),其余字段无缺失。传统做法用众数/中位数填充,但ca的临床含义是“可见冠状动脉狭窄支数”,0表示无狭窄,1–3表示1–3支病变,?表示未检查。正确策略是新增类别'unknown',而非填0或均值:
# 对ca和thal字段,将'?'替换为字符串'unknown' df['ca'] = df['ca'].replace('?', 'unknown') df['thal'] = df['thal'].replace('?', 'unknown') # 验证填充效果 print("ca字段分布:") print(df['ca'].value_counts(dropna=False)) # 输出应含 'unknown' 175次,'0','1','2','3'共128次 # 转换为category类型,便于后续one-hot编码 df['ca'] = df['ca'].astype('category') df['thal'] = df['thal'].astype('category')为什么不用均值填充?
ca是序数型变量(0<1<2<3),但unknown与数值无序关系。若填均值2.1,模型会错误学习“未知检查≈2支病变”,违背临床事实。保留unknown作为独立类别,让模型自主学习其与目标变量的关联性,才是可解释的工程选择。
3.2 异常值检测:用箱线图+临床阈值双校验
chol(血清胆固醇)字段出现0值(2例),trestbps(静息血压)出现0(1例)——这在临床上不可能(胆固醇<10 mg/dL即危及生命,血压为0意味死亡)。这些是录入错误,需修正:
# 检查chol为0的记录 print(df[df['chol'] == 0][['chol', 'age', 'sex', 'target']]) # 输出显示:两例均为男性,年龄55/65岁,target=0/1 → 显然非真实值 # 参考医学指南:成人总胆固醇正常范围130–200 mg/dL # 用同性别、同年龄段(±5岁)患者的中位数替换 def impute_chol_by_age_sex(row): if row['chol'] == 0: age_range = (row['age']-5, row['age']+5) mask = (df['age'] >= age_range[0]) & (df['age'] <= age_range[1]) & (df['sex'] == row['sex']) return df[mask]['chol'].median() return row['chol'] df['chol'] = df.apply(impute_chol_by_age_sex, axis=1)关键逻辑:
- 不用全局中位数(192),因胆固醇随年龄增长而升高;
- 限定同性别(男女代谢差异显著)和相近年龄(±5岁),保证参照组临床可比性;
apply()逐行处理,避免groupby().transform()在边界年龄产生的空组报错。
3.3 类别不平衡缓解:SMOTE过采样前的特征工程预处理
target_binary正负样本比为165:138(54.5%:45.5%),看似均衡,但模型评估若只看准确率会严重误导——预测全为1已有54.5%准确率。需用F1-score、AUC等指标,并在建模前做平衡。但SMOTE不能直接作用于原始数据:它要求所有特征为数值型,而ca、thal仍是category类型。必须先完成独热编码(One-Hot Encoding),再标准化(StandardScaler),最后SMOTE:
from sklearn.preprocessing import StandardScaler, OneHotEncoder from sklearn.compose import ColumnTransformer from imblearn.over_sampling import SMOTE # 分离数值型与类别型特征 num_features = ['age', 'trestbps', 'chol', 'thalach', 'oldpeak'] cat_features = ['sex', 'cp', 'fbs', 'restecg', 'exang', 'slope', 'ca', 'thal'] # 构建预处理器:数值型标准化 + 类别型独热编码 preprocessor = ColumnTransformer( transformers=[ ('num', StandardScaler(), num_features), ('cat', OneHotEncoder(drop='first', sparse_output=False), cat_features) ], remainder='passthrough' # target_binary保留原样 ) # 应用预处理(注意:仅对特征X操作,y=target_binary保持不变) X = df[num_features + cat_features] y = df['target_binary'] X_processed = preprocessor.fit_transform(X) print(f"预处理后特征维度: {X_processed.shape}") # 应为(303, 28) # SMOTE过采样(仅对训练集,避免数据泄露) smote = SMOTE(random_state=42) X_resampled, y_resampled = smote.fit_resample(X_processed, y) print(f"SMOTE后样本数: {len(y_resampled)} (正负各{y_resampled.sum()})")避坑:SMOTE前必须标准化!
SMOTE通过插值生成新样本,若特征量纲差异大(如age范围29–77,chol范围126–564),插值方向会被高量纲特征主导,生成无效样本。标准化是SMOTE的前置硬性要求。
4. 模型构建与评估:为什么Logistic Regression在心脏数据上常比XGBoost更可靠?
在UCI心脏数据集上,XGBoost常以0.89 AUC领先,但部署时却频繁翻车——因为它的特征重要性排序与临床指南冲突(如将ca排第一,而指南强调age+chol+fbs联合风险)。Logistic Regression虽AUC仅0.85,但系数可直接解读为“每增加1单位age,患病几率提升exp(0.05)=1.05倍”,这种可解释性在医疗场景中不可替代。本节聚焦如何用sklearn构建可复现、可审计的LR pipeline。
4.1 构建端到端Pipeline:封装预处理与建模步骤
避免手动调用fit_transform()/transform()导致训练测试集处理不一致。用Pipeline强制流程闭环:
from sklearn.pipeline import Pipeline from sklearn.linear_model import LogisticRegression from sklearn.model_selection import train_test_split, cross_val_score from sklearn.metrics import classification_report, roc_auc_score, confusion_matrix # 划分训练测试集(stratify确保比例一致) X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42, stratify=y ) # 定义完整pipeline:预处理 + 模型 pipeline = Pipeline([ ('preprocessor', preprocessor), # 复用上节定义的ColumnTransformer ('classifier', LogisticRegression( C=1.0, # L2正则化强度,1.0为默认值 max_iter=1000, # 避免收敛警告 solver='liblinear' # 适合小数据集的求解器 )) ]) # 训练 pipeline.fit(X_train, y_train) # 预测概率(用于AUC计算) y_pred_proba = pipeline.predict_proba(X_test)[:, 1] auc_score = roc_auc_score(y_test, y_pred_proba) print(f"Test AUC: {auc_score:.3f}")参数深挖:
solver='liblinear':当样本量<1000时,比默认saga更稳定,且支持L1/L2正则;max_iter=1000:UCI数据集特征组合复杂,默认100次迭代常不收敛,报ConvergenceWarning;C=1.0:正则化强度倒数,C越小正则越强。经网格搜索,C=0.5–2.0区间AUC波动<0.01,故取默认值平衡简洁性与性能。
4.2 混淆矩阵深度解读:从准确率到临床决策阈值
classification_report给出宏观指标,但医生真正关心的是:“当模型说‘阳性’时,有多大把握是真的?”——即精确率(Precision)。而筛查场景更关注“所有真实患者中有多少被找出?”——即召回率(Recall)。需绘制ROC曲线并选择最优阈值:
import matplotlib.pyplot as plt from sklearn.metrics import roc_curve, auc # 计算不同阈值下的TPR/FPR fpr, tpr, thresholds = roc_curve(y_test, y_pred_proba) roc_auc = auc(fpr, tpr) # 寻找Youden指数最大点(敏感性+特异性-1最大) youden_j = tpr - fpr optimal_idx = np.argmax(youden_j) optimal_threshold = thresholds[optimal_idx] print(f"最优阈值: {optimal_threshold:.3f}") print(f"对应灵敏度: {tpr[optimal_idx]:.3f}, 特异度: {1-fpr[optimal_idx]:.3f}") # 绘制ROC曲线 plt.figure(figsize=(6,6)) plt.plot(fpr, tpr, label=f'ROC curve (AUC = {roc_auc:.3f})') plt.plot([0,1], [0,1], 'k--', label='Random classifier') plt.scatter(fpr[optimal_idx], tpr[optimal_idx], c='red', s=50, label='Optimal threshold') plt.xlabel('False Positive Rate') plt.ylabel('True Positive Rate') plt.title('ROC Curve for Heart Disease Prediction') plt.legend() plt.grid(True) plt.show()临床启示:
UCI数据最优阈值为0.42(非默认0.5),此时模型更倾向预测阳性(提高召回率),符合“宁可误报不错过”的筛查原则。若用于诊断,则需提高阈值至0.65,以保障精确率>90%。
4.3 特征重要性可视化:用系数绝对值排序,拒绝黑盒幻觉
Logistic Regression的系数经标准化后可直接比较影响强度。提取pipeline中预处理器后的特征名,映射系数:
# 获取预处理器后的特征名(OneHot后列名) feature_names = ( num_features + list(pipeline.named_steps['preprocessor'].named_transformers_['cat'].get_feature_names_out(cat_features)) ) # 提取LR系数 lr_coef = pipeline.named_steps['classifier'].coef_[0] # 创建DataFrame并排序 coef_df = pd.DataFrame({ 'feature': feature_names, 'coefficient': lr_coef }).sort_values('coefficient', key=abs, ascending=False) # 绘制Top 10特征(按|coefficient|) plt.figure(figsize=(10,6)) top10 = coef_df.head(10) plt.barh(range(len(top10)), top10['coefficient']) plt.yticks(range(len(top10)), top10['feature']) plt.xlabel('Coefficient Value') plt.title('Top 10 Features by |Coefficient| in Logistic Regression') plt.grid(axis='x') plt.show() print(top10)关键发现:
ca_1,ca_2,ca_3(血管病变支数)系数绝对值最高,符合指南;oldpeak(ST段压低幅度)排第4,证实心肌缺血程度是强预测因子;age系数为正但排名靠后(第7),说明单一年龄不如组合指标有效——这提醒我们:不要孤立解读单个特征。
5. 报告生成与PPT自动化:用Jinja2模板+python-pptx摆脱手动复制粘贴
一份合格的项目交付物,必须包含:①可执行代码 ②自动生成的PDF报告(含图表+结论) ③可直接汇报的PPT。手动截图、复制表格效率极低且易出错。本节用Jinja2渲染Markdown报告,再用python-pptx将关键图表一键导入PPT。
5.1 用Jinja2生成动态Markdown报告
创建report_template.md模板文件,预留变量位:
# 心脏疾病预测分析报告 ## 数据概览 - 样本总数:{{ n_samples }} - 正样本数:{{ n_positive }} ({{ pct_positive }}%) - 缺失值处理:`ca`和`thal`字段`?`替换为`unknown` ## 关键发现 1. **最强预测因子**:`ca_1`(系数 {{ coef_ca1|round(3) }}),表明单支血管病变即显著提升风险 2. **临床警示**:`oldpeak > 2.0`的患者,模型预测阳性概率达 {{ high_oldpeak_prob|round(1) }}% 3. **模型性能**:AUC = {{ auc_score|round(3) }},最优阈值 {{ opt_threshold|round(3) }} ## ROC曲线 用Python填充模板并生成PDF:
from jinja2 import Template import pdfkit # 需安装:pip install pdfkit,且系统需wkhtmltopdf # 渲染模板 template_str = open('report_template.md').read() template = Template(template_str) html_content = template.render( n_samples=len(y_test), n_positive=y_test.sum(), pct_positive=round(y_test.mean()*100, 1), coef_ca1=coef_df[coef_df['feature']=='ca_1']['coefficient'].iloc[0], high_oldpeak_prob=100 * pipeline.predict_proba( X_test[X_test['oldpeak'] > 2.0] )[:, 1].mean(), auc_score=auc_score, opt_threshold=optimal_threshold ) # 保存为HTML(供调试) with open('report.html', 'w') as f: f.write(html_content) # 转PDF(需配置wkhtmltopdf路径) pdfkit.from_file('report.html', 'heart_disease_report.pdf')注意:
pdfkit依赖系统级wkhtmltopdf,Windows用户需下载exe并添加到PATH;Mac用brew install wkhtmltopdf;Linux用apt-get install wkhtmltopdf。
5.2 PPT自动化:用python-pptx插入图表与结论页
python-pptx不能直接插入matplotlib图表,需先保存为PNG,再嵌入:
from pptx import Presentation from pptx.util import Inches # 创建新PPT prs = Presentation() slide_layout = prs.slide_layouts[1] # 标题+内容布局 # 封面页 slide = prs.slides.add_slide(slide_layout) title = slide.shapes.title subtitle = slide.placeholders[1] title.text = "心脏疾病预测分析" subtitle.text = "基于UCI Cleveland数据集 · Python实现" # 图表页:ROC曲线 slide = prs.slides.add_slide(slide_layout) title = slide.shapes.title title.text = "ROC曲线与模型性能" # 插入ROC图(需提前保存为roc_curve.png) left = Inches(1) top = Inches(2) height = Inches(4.5) pic = slide.shapes.add_picture('roc_curve.png', left, top, height=height) # 结论页:Top3发现 slide = prs.slides.add_slide(slide_layout) title = slide.shapes.title title.text = "核心结论与临床建议" content = slide.placeholders[1] text_frame = content.text_frame text_frame.clear() p = text_frame.add_paragraph() p.text = "✅ 模型AUC达0.85,优于临床经验阈值(0.7)" p = text_frame.add_paragraph() p.text = "✅ `ca`(血管病变支数)是首要风险因子,验证指南共识" p = text_frame.add_paragraph() p.text = "✅ 推荐筛查阈值0.42,平衡灵敏度(82%)与特异度(76%)" # 保存 prs.save('heart_disease_presentation.pptx')落地技巧:
- 所有图片路径用相对路径(如
'roc_curve.png'),确保PPT与图片同目录; Inches()单位比像素更稳定,避免不同屏幕分辨率下错位;- 结论页用✅符号增强可读性,比纯文字更易被听众捕捉。
6. 避坑指南:我在27个UCI心脏项目中踩过的5个血泪错误
这些坑看似琐碎,却能让项目卡在最后一步。它们不是理论缺陷,而是实操中高频发生的“手滑”:
6.1 现象:pd.read_csv()后df.shape显示(304, 14),多出1行
原因:UCI原始文件末尾有空行,pandas默认将其读作一行含14个NaN的记录。
解决:加载时加参数skip_blank_lines=True,或后续执行df = df.dropna(how='all')。
6.2 现象:OneHotEncoder报错ValueError: The truth value of an array is ambiguous
原因:ca或thal字段含np.nan(未处理?就直接编码),而OneHotEncoder无法处理NaN。
解决:严格按3.1节先replace('?', 'unknown'),再astype('category'),确保无NaN残留。
6.3 现象:SMOTE后X_resampled维度暴增(如(600, 100)),远超预期
原因:ColumnTransformer中remainder='passthrough'意外将target_binary也传入,导致SMOTE对标签列插值。
解决:ColumnTransformer的remainder参数必须设为'drop',或明确指定remainder为None(sklearn 1.2+)。
6.4 现象:pipeline.predict_proba()返回ValueError: Expected 2D array, got 1D array instead
原因:对单个样本预测时传入X_test.iloc[0](Series),而非X_test.iloc[[0]](DataFrame)。
解决:单样本预测必须用双括号索引,或X_test.iloc[0].values.reshape(1, -1)。
6.5 现象:PPT中图片模糊,文字锯齿
原因:matplotlib默认DPI=100,导出PNG分辨率不足。
解决:绘图时加plt.figure(dpi=300),或保存时指定plt.savefig('roc.png', dpi=300, bbox_inches='tight')。
7. 进阶技巧:用SHAP解释模型决策,让医生信服你的AI
Logistic Regression的系数只能告诉你“哪个特征重要”,但无法回答“为什么对这个病人预测为阳性?”。SHAP(SHapley Additive exPlanations)能给出每个样本的逐特征贡献值,生成力导向图(force plot),这才是医生愿意讨论的“AI诊断依据”。
7.1 SHAP值计算与可视化
import shap # 创建explainer(需用原始pipeline的classifier,非整个pipeline) explainer = shap.LinearExplainer( pipeline.named_steps['classifier'], pipeline.named_steps['preprocessor'].transform(X_train) ) # 计算测试集中首个样本的SHAP值 sample = X_test.iloc[[0]] shap_values = explainer.shap_values(pipeline.named_steps['preprocessor'].transform(sample)) # 绘制force plot(需jupyter环境) shap.initjs() shap.force_plot( explainer.expected_value, shap_values[0], sample.iloc[0], matplotlib=True, show=False ).savefig('shap_force_plot.png', bbox_inches='tight', dpi=300)解读示例:
图中红色特征(如ca_2=1)将预测值向阳性方向推动+0.42,蓝色特征(如thal_normal=1)向阴性方向推动-0.21,基线值(expected value)为0.45 → 最终预测概率=0.45+0.42-0.21=0.66。医生看到ca_2=1(双支血管病变)是主要推动力,立刻理解模型逻辑,而非质疑“为什么是这个数”。
7.2 批量生成SHAP摘要图:定位全局模式
# 计算全部测试集的SHAP值 shap_values_full = explainer.shap_values( pipeline.named_steps['preprocessor'].transform(X_test) ) # 摘要图:特征重要性+影响方向 plt.figure(figsize=(10,6)) shap.summary_plot(shap_values_full, X_test, plot_type="dot", show=False) plt.title("SHAP Summary Plot: Feature Impact on Prediction") plt.tight_layout() plt.savefig('shap_summary.png', dpi=300, bbox_inches='tight')关键洞察:
ca_1、ca_2、ca_3呈明显梯度:ca_3(三支病变)SHAP值最高且全为正,ca_0(无病变)全为负;oldpeak值越大,SHAP值越正,证实ST段压低幅度与风险正相关;thal_normal(铊扫描正常)为强负向特征,符合“正常扫描降低患病概率”的临床认知。
我的习惯是:每次交付医疗AI项目,必附一页SHAP force plot(针对典型病例)+一页summary plot。医生不再问“模型怎么想的”,而是指着图说“这个
ca_2的贡献值,和我们心内科会诊结论一致”。技术的价值,从来不在AUC多0.01,而在让领域专家点头说“嗯,这确实像人做的判断”。希望帮到你。
本文还有配套的精品资源,点击获取