ARTICLE DETAIL

资讯详情

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

Framingham心脏病数据集实战:从数据清洗到心血管风险打分卡

Framingham心脏病数据集实战:从数据清洗到心血管风险打分卡 简介源自1948年启动的Framingham心脏研究这是一项长期前瞻性队列研究本数据集即为其公开发布的心血管病分析样本。对数据分析、机器学习和医学统计学习者而言它是练习风险预测、危险因素挖掘与生存分析的经典素材。压缩包共3个文件csv数据表为核心可直接用Excel、Python、R读取R脚本涵盖加载、清洗、建模等常用分析流程便于复现txt文件说明变量含义与字段背景整体仅56KB轻量方便。已有2447人学习下载适合公共卫生、数据科学初学者及科研人员快速上手。通过这份资源读者可完整了解队列研究字段结构覆盖人口统计特征、生活习惯、生理指标、临床检查结果及随访结局等维度既可构建心脏病风险预测模型也可探索吸烟、血压、胆固醇等危险因素与心血管事件的关联还能将分析脚本迁移至其他医学数据集是理论结合实操的高价值入门材料。1. Framingham心脏病数据集先认清它是“队列”而不是“比赛题”Framingham心脏病数据集来自美国弗拉明汉心脏研究可能是心血管风险预测公开数据里被用得最“表面”的一个。很多人把它当成又一个分类练习跑个随机森林AUC接近0.8就收工但真正上手后会发现它和鸢尾花、泰坦尼克号这类干净数据集完全是两码事列不多缺失不少标签还明显不平衡。它的价值在于让你第一次用真实队列的方式理解什么叫临床数据——血压、胆固醇、吸烟这些风险因素不是孤立的数字而是带着病史、用药和随访结局的线索。适合三类人想把分类建模从练习往医学落地推进的开发者刚接触特征工程的数据分析师以及在模型交付中需要向医生或业务方解释预测逻辑的从业者。下面按“理解数据→清洗→建模→纠偏→交付”的顺序把这个只有约四千条记录的数据集讲到能直接拿去复现的程度。提示全文基于公开渠道可获取的framingham.csv常见版本约4240行、16列如果你拿到的文件列名略有差异先对照数据字典改列名再跑。2. 特征口径是第一道坎把Framingham数据字典翻译成可建模的特征2.1 字段全览哪些是真数值哪些是伪数值拿到的Framingham.csv通常包含15个预测特征和1个目标变量TenYearCHD10年内是否发生冠心病0/1。表里有些字段一眼就是数值但实际语义完全不同。字段类型建模时的真实口径male二分类0女/1男性别编码不能当连续量age连续基线年龄范围约32-70考虑分段或非线性education分类1-4级低基数分类不该当线性数值currentSmoker二分类是否当前吸烟cigsPerDay连续/缺失每日吸烟支数缺失不等于0BPMeds二分类是否服用降压药和sysBP强相关prevalentStroke二分类基线卒中史正例极少prevalentHyp二分类基线高血压史注意是“史”不是当时测量值diabetes二分类糖尿病史totChol连续/缺失总胆固醇mg/dLsysBP / diaBP连续收缩压/舒张压mmHgBMI连续/缺失身体质量指数heartRate连续静息心率glucose连续/缺失血糖mg/dL缺失率常超过30%TenYearCHD目标10年内CHD事件0否/1是从这里能看出的第一个坑education和male这类变量pandas读进来是int但本质是分类。直接把education当连续值喂进线性模型就等于假设每升一级教育程度、风险线性上升这在临床上站不住。我的习惯是先声明分类列再决定是one-hot还是按业务含义分组。2.2 缺失值不是脏数据是临床记录的现实Framingham数据集的缺失不是随机产生的。常见版本里glucose的缺失率能到30%-40%totChol、BMI、cigsPerDay也有不小比例缺失。问题在于删除整行会丢掉大量样本全填均值又会抹掉“缺这个测量值本身就是一种状态”的信息。先别急着填用代码把缺失分布看清楚import pandas as pd df pd.read_csv(framingham.csv) # 只保留预测特征和目标列去掉无建模意义的ID列如果有 feature_cols [ male, age, education, currentSmoker, cigsPerDay, BPMeds, prevalentStroke, prevalentHyp, diabetes, totChol, sysBP, diaBP, BMI, heartRate, glucose ] X df[feature_cols].copy() y df[TenYearCHD].astype(int) # 按列统计缺失比例并按缺失率降序排 miss_ratio (X.isnull().sum() / len(X)).sort_values(ascendingFalse) print(miss_ratio[miss_ratio 0])这段代码做的事很简单但决定了后续所有清洗策略看缺失集中在哪几列而不是看有多少行有缺失。如果glucose缺失率高到一半那它就不该成为主模型的必需特征如果cigsPerDay只缺5%可以考虑用currentSmoker分组后填充。缺失率排序这个输出是你后面跟同事解释“为什么某个特征没进模型”的最有力证据。常见做法是缺失率低于5%的用中位数或众数填高于30%的先尝试预测填补不行就弃用。2.3 临床变量的编码陷阱血压测量值和病史标签不是一回事sysBP、diaBP是体检测量值prevalentHyp是“基线时已知有高血压”的诊断史BPMeds是“正在服用降压药”的用药记录。这三列信息高度纠缠一个正在吃降压药的人血压可能是正常水平一个没吃药的未诊断者血压可能已经到了140/90。直接把三者同时倒进逻辑回归系数解释会非常拧巴——这不算数据错误但会让你的特征重要性看起来像玄学。我一般会做两版特征一版全量入模用来冲预测性能一版把prevalentHyp和BPMeds合并成“hypertension_managed”这类复合变量用于解释性分析。第二版不是为了更好看而是为了在向业务方汇报时能说清“血压对风险的影响到底是测量值贡献的还是诊断史贡献的”这决定了后续干预建议落在哪里。3. 用Scikit-learn跑通CHD风险预测数据清洗、基线模型与评估脚本3.1 先定好随机种子和分层策略再动train_test_split建模第一步不是选算法而是把验证方式定死。因为TenYearCHD正例只占15%左右直接随机切分很容易把正例全切进训练集或测试集。stratify参数就是干这个的保证切分后训练集和测试集的正例比例都接近15%。import pandas as pd from sklearn.model_selection import train_test_split # 沿用上一章清洗后的X、y先把缺失比例低于阈值的列简单填充 # 对cigsPerDay缺失者大概率不吸烟/吸烟未记录先用0占位后续再做敏感性分析 X[cigsPerDay] X[cigsPerDay].fillna(0) # 数值列用中位数填充避免均值被极端值拉偏 for col in [totChol, BMI, glucose, heartRate, diaBP, sysBP]: X[col] X[col].fillna(X[col].median()) # 分类列填众数BPMeds、education等 for col in [BPMeds, education]: X[col] X[col].fillna(X[col].mode()[0]) X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state42, stratifyy ) print(X_train.shape, X_test.shape) print(训练集正例占比:, y_train.mean().round(3)) print(测试集正例占比:, y_test.mean().round(3))这里的两个关键参数random_state42保证了任何人跑同一份数据得到相同切分stratifyy确保了类别比例一致。对cigsPerDay用0填充是个风险较大的决定因为0在业务上会被理解为“不吸烟”而缺失的人未必都是不吸烟者。所以我在注释里专门标了一句“后续再做敏感性分析”——第5章会讲怎么排查这个问题。3.2 基线用逻辑回归别上来就XGBoost在四千样本、十几个特征的规模上逻辑回归不是“弱基线”而是最值得先跑的方案。它有显式概率输出、系数可直接解读成log-odds还能为第6章的打分卡铺路。先把特征标准化再做逻辑回归这是最常被跳过的细节——不做标准化连续变量量纲差异会让正则化惩罚失衡。from sklearn.pipeline import Pipeline from sklearn.preprocessing import StandardScaler from sklearn.linear_model import LogisticRegression from sklearn.metrics import roc_auc_score, accuracy_score, f1_score # 分类变量one-hot数值变量标准化后进逻辑回归 X_model pd.get_dummies(X, columns[education], drop_firstTrue) pipe_lr Pipeline([ (scaler, StandardScaler()), (lr, LogisticRegression(max_iter1000, random_state42)), ]) pipe_lr.fit(X_train, y_train) y_prob pipe_lr.predict_proba(X_test)[:, 1] print(AUC:, roc_auc_score(y_test, y_prob).round(4)) print(ACC:, accuracy_score(y_test, (y_prob 0.5).astype(int)).round(4)) print(F1:, f1_score(y_test, (y_prob 0.5).astype(int)).round(4))AUC是第一个要看的指标不是ACC。15%正例的数据集上ACC天然会很高随便预测全部为0也有85%准确率。F1则惩罚这种偷懒行为。StandardScaler在Pipeline里fit在训练集上transform在测试集上不会把测试集信息泄漏进训练过程这是用Pipeline而不是手动两步处理的原因。max_iter1000是为了避免默认迭代次数下逻辑回归不收敛的告警并非玄学参数。3.3 用交叉验证看稳定性单次切分的结果会骗人单次train_test_split受随机种子影响很大同样的模型换个种子AUC可能从0.72跳到0.77。医学小数据集上我习惯用StratifiedKFold跑5折既要看平均分更要看折间标准差。import numpy as np from sklearn.model_selection import StratifiedKFold, cross_val_score cv StratifiedKFold(n_splits5, shuffleTrue, random_state42) # 对同一个pipeline分别算AUC和F1而不是只算一个指标 cv_auc cross_val_score(pipe_lr, X_model, y, cvcv, scoringroc_auc) cv_f1 cross_val_score(pipe_lr, X_model, y, cvcv, scoringf1) print(5折AUC: %.3f ± %.3f % (cv_auc.mean(), cv_auc.std())) print(5折F1: %.3f ± %.3f % (cv_f1.mean(), cv_f1.std()))如果标准差超过0.03就说明模型对数据切分太敏感这时候去调采样或特征没有意义应该先回到数据重新审视。另一个细节cross_val_score的cv对象必须shuffleTrue否则按原始顺序切分会把某种数据时段特征带进每一折。4. 类别不平衡是藏在标签里的坑重采样与阈值选择的实操对比4.1 正例只有15%准确率是会骗人的Framingham数据集的TenYearCHD正例比例通常在15%上下接近真实冠心病人群的10年发病率量级。这个不平衡程度不算极端但足以让“全预测为0”的模型拿到85%准确率让第一次跑模型的同事误以为自己已经做到了很好的效果。评估指标要换成AUC、召回率、PR曲线这一套把“能不能找出真正会发病的人”放在“预测全体的准确率”之前。4.2 SMOTE要在训练集上做别把验证集一起重采样处理不平衡最常见的手段是SMOTE合成少数类过采样但很多人直接把整个X、y扔进SMOTE再切分这是典型的泄漏验证集中出现了合成样本评估结果乐观得没有参考价值。正确顺序是先切分再只在训练集上过采样。from imblearn.over_sampling import SMOTE from sklearn.ensemble import RandomForestClassifier from sklearn.model_selection import StratifiedKFold from imblearn.pipeline import Pipeline as ImbPipeline # 在交叉验证内部做SMOTE保证每一折只对训练部分重采样 cv_inner StratifiedKFold(n_splits5, shuffleTrue, random_state42) pipe_smote ImbPipeline([ (scaler, StandardScaler()), (smote, SMOTE(random_state42, k_neighbors5)), (rf, RandomForestClassifier(n_estimators300, random_state42)), ]) scores cross_val_score(pipe_smote, X_model, y, cvcv_inner, scoringroc_auc) print(SMOTERF 5折AUC: %.3f ± %.3f % (scores.mean(), scores.std()))关键点这里用的是imblearn的Pipeline而不是sklearn的Pipeline因为要保证SMOTE只在fit阶段对训练集执行而cross_val_score在每一折内部都会重新fit整个流水线。SMOTE的k_neighbors5是默认值样本量小的少数类里如果某个邻居距离过近合成样本会和原始样本几乎重合这时候把k_neighbors降到3更稳。随机森林的n_estimators在这个数据规模上300棵足够再多只会增加训练时间。4.3 更稳的做法不重采样直接调决策阈值重采样改变了训练数据的分布模型学到的概率就不再是真实概率后续校准会遇到麻烦。如果目标是用概率做风险分层而不是单纯排序保留原始分布、调预测阈值是更好的路径。常见做法是用验证集画出PR曲线找召回和精确率的平衡点。from sklearn.metrics import precision_recall_curve # 用第3章训练好的逻辑回归在测试集上输出概率 precision, recall, thresholds precision_recall_curve(y_test, y_prob) # 找满足“召回率≥0.6”的最低阈值作为人群筛查用阈值 for thr, prec, rec in zip(thresholds, precision[:-1], recall[:-1]): if rec 0.6: print(阈值%.3f 精确率%.3f 召回率%.3f % (thr, prec, rec)) break # 对比默认0.5阈值下的查全率 print(默认0.5阈值召回率:, recall_score(y_test, (y_prob 0.5).astype(int)).round(3))这段代码的意义在于默认0.5阈值把大量中高危人群挡在门外阈值降到0.3左右虽然会带来更多假阳性但作为初筛工具有更高的敏感性。这类策略不是模型层面的改动而是交付层面的选择——你需要先问清楚使用场景是要特异性高还是要敏感性高再定阈值而不是让sklearn默认的0.5替你做决定。5. Framingham建模避坑笔记5个容易翻车的细节与排查方法5.1 AUC高但业务用不了连续变量当线性用的锅现象测试集AUC跑到0.78看起来不错但把模型输出按年龄分组画出来70岁组和40岁组几乎拉不开差距。 原因age直接以连续值进逻辑回归等价于假设“每增加一岁风险对数比恒定上升”实际年龄对CHD风险的影响是中老年加速上升远不是线性。 解决把age离散化成年龄段如45、45-54、55-64、65再one-hot或对age做样条变换后重跑。树模型对这个不敏感但线性模型必须处理。5.2 cigsPerDay缺失填0吸烟史被洗没了现象模型系数里cigsPerDay的系数为负业务方质疑“吸烟越多风险越低这模型是不是反了”。 原因把缺失的cigsPerDay统一填了0而缺失的人往往混杂了两类人——从不吸烟者填0合理和吸烟但未记录支数者填0就把他们算成了不吸烟者导致吸烟信号被稀释甚至反转。 解决用currentSmoker列分组看cigsPerDay的分布。只有currentSmoker1的人才有真实的包数信息currentSmoker0的人可以安全填0对吸烟者中的缺失按吸烟者列的中位数填。跑完再看系数方向是否恢复正常。5.3 BPMeds让逻辑回归系数出现“反转”现象单独看sysBP是风险因素多变量模型里sysBP系数的log-odds忽然变成接近0甚至负数。 原因BPMeds服用降压药与sysBP高度纠缠。服药使血压读数下降但服药者的真实心血管风险更高两个变量在模型里互相抵消。 解决做敏感性分析——跑两版模型一版带BPMeds一版去掉BPMeds看sysBP系数变化。如果业务方要的是解释“血压对风险的影响”报告里用去掉BPMeds的版本如果只追求AUC保留BPMeds反而能提升排序能力。这不是哪个对而是先明确交付目标再选口径。5.4 glucose缺失率太高直接整行删除不可行现象dropna()之后样本量从4240掉到不足3000建模组数据锐减而且剩下的人偏年轻健康。 原因pandas的dropna默认对整行生效glucose一缺就删掉整条记录等于让高缺失率的单列决定全表的可用性。 解决先按列做缺失统计glucose这种高缺失列要么用totCholdiabetes做预测填补要么干脆不进主模型保留样本量。医学数据里“一个变量的缺失率高”本身提示了测量条件受限强行填充后再解释它反而失真。5.5 默认参数随机森林测试集AUC比训练集低0.15以上现象训练集AUC接近0.95测试集只有0.78一上来就被说成过拟合。 原因Framingham只有四千样本、十几个特征默认参数下随机森林每棵树的min_samples_leaf太小树深度大训练集被背下来。 解决加约束——max_depth6、min_samples_leaf20或者直接回到逻辑回归做baseline对比。在小样本医疗数据上树模型被默认参数坑掉的经验我至少见过三次以上优先上带正则的简单模型才是正路。6. 把概率校准成可解释的打分卡从模型到临床可用工具的最后一公里模型给出一堆概率医生没法直接用业务方也看不懂“0.35的概率”意味着什么。更实用的做法是先把连续变量分箱再用分箱后的哑变量重新拟合逻辑回归把系数换算成整数分数得到一张类似“评分表”的工具分数越高10年风险越高。import numpy as np import pandas as pd from sklearn.linear_model import LogisticRegression df_score X_model.copy() # 按临床常用切点分箱而不是按分位数硬切 df_score[age_group] pd.cut( df_score[age], bins[30, 45, 55, 65, 80], labels[45, 45-55, 55-65, 65] ) df_score[sysBP_group] pd.cut( df_score[sysBP], bins[90, 120, 140, 160, 220], labels[120, 120-140, 140-160, 160] ) df_score[chol_group] pd.cut( df_score[totChol], bins[150, 200, 240, 300, 600], labels[200, 200-240, 240-300, 300] ) score_data pd.get_dummies( df_score[[age_group, sysBP_group, chol_group]], drop_firstTrue ) lr_score LogisticRegression(max_iter1000) lr_score.fit(score_data, y) # 用系数最大值折算分值25分对应一个单位log-odds变化 scale round(25 / lr_score.coef_.max()) score_map { col: int(round(coef * scale)) for col, coef in zip(score_data.columns, lr_score.coef_[0]) } print(score_map)分箱边界的选取用的是临床惯例血压120/140/160、胆固醇200/240是医生熟悉的切点比单纯按数据分位数更好解释。scale这个参数决定分值跨度25分一档的意思是“风险最高的一组比最低的一组大概差100多分”你完全可以根据业务习惯把它调成10分或50分。分数表做出来后必须用calibration_curve验证模型预测概率和实际事件率是否对齐——只报AUC不报校准度的模型在临床场景里几乎都会被挑战。我第一次把Framingham模型交付出去时只交了一个AUC0.78的报告业务方回了一句话“这个数字和我的病人有什么关系”后来把特征分箱、做出打分卡再配一张校准曲线才能坐在一起谈落地。数据集的坑可以靠经验避开但对结果负责的这一步谁也替你省不掉。希望帮到你。本文还有配套的精品资源点击获取
返回列表