
1. 背景与核心概念为什么心衰预测需要“证据驱动”的特征工程1.1 从一张临床表说起心力衰竭Heart Failure是一种由于心脏泵血功能下降导致全身组织供血不足的临床综合征。在实际的机器学习项目中我们拿到的往往不是医生看完病人后写的诊断结论而是一张类似下面这样的结构化表格年龄、是否贫血、肌酸磷酸激酶值、射血分数、血清肌酐、血小板、血钠、随访时间等十几列数字。表格的最后一列是死亡事件标记用来表示患者在随访期内是否发生了死亡。我们要做的事情就是根据入院或随访时的指标预测患者发生死亡事件的风险。这类任务属于典型的二分类问题数据集并不复杂很多初学者会直接把所有字段丢给随机森林或逻辑回归跑一个准确率就结束。但在真实工程里这样做会丢掉大量临床语义。为什么因为模型看到的只是一个数值而医生看到的是一组连续变化、存在阈值和分层的生理指标。比如射血分数 38% 和 49%在数值上只差 11 个百分点但在临床分类上前者属于“射血分数降低的心力衰竭”后者属于“射血分数轻度降低的心力衰竭”两者对应的治疗方案、风险等级和随访策略都不一样。如果不做特征工程模型很难从原始数值中自动学习到这种带有医学共识的分层信息尤其是在样本量只有几百条的小数据集上模型更容易被噪声带偏。所以在做心衰预测项目时第一步不是调参也不是换模型而是把“临床知识”转化为“模型能理解的表示”。这个转化过程就是特征工程。1.2 Pipeline 到底解决什么问题提到 Pipeline不同领域的人会联想到完全不同的东西。做后端开发的同学会想到 Jenkins Pipeline也就是 CI/CD 流水线做缓存的同学会想到 Redis Pipeline即把多条命令打包批量发送做图像处理的同学会想到 ISP Pipeline也就是从传感器原始数据到最终图像的信号处理链路。这些都是“流水线”思想在不同场景下的落地。在本文中我们讨论的是 scikit-learn 体系下的机器学习流水线全称是sklearn.pipeline.Pipeline。它做的事情很简单把“数据预处理 特征构造 模型训练”这几个串行步骤打包成一个整体统一调用fit、predict和score。如果不使用 Pipeline代码通常会写成下面这样X df.drop(DEATH_EVENT, axis1) y df[DEATH_EVENT] # 先做预处理 from sklearn.impute import SimpleImputer imputer SimpleImputer(strategymedian) X imputer.fit_transform(X) # 再做标准化 from sklearn.preprocessing import StandardScaler scaler StandardScaler() X scaler.fit_transform(X) # 再训练 from sklearn.ensemble import RandomForestClassifier model RandomForestClassifier() model.fit(X, y)这段代码最大的问题是当我们需要在测试集或线上新数据上复现同样一套流程时必须记得把 imputer、scaler、model 这三个对象依次保存、加载、再依次调用。一旦中间步骤漏掉或者顺序写错线上预测结果就完全对不上。更严重的是在交叉验证中如果先对整个数据集做填充和标准化再划分训练集和测试集就会发生特征泄漏导致验证指标虚高。Pipeline 的核心价值就是把“流程”本身变成一个有状态、可复现、可部署的对象。1.3 什么是 Evidence-Linked 特征工程标题里的 “Evidence-Linked” 直译是“证据关联”。放在心衰预测场景下它指的是特征工程不是凭感觉造列而是基于医学证据和临床指南来构造特征。举个例子血清肌酐是衡量肾功能的重要指标但单个数值的临床意义和患者的性别、年龄、肌肉量都有关。在简化场景下我们可以根据慢性肾病分期指南把血清肌酐分成“正常”“轻度升高”“中度升高”“重度升高”几个等级。这种分级不是乱拍的而是有临床证据支持的。另一个更典型的例子是射血分数。2021 年 ESC 心力衰竭指南将心力衰竭按射血分数分为 HFrEFLVEF 40%、HFmrEFLVEF 40%-49%、HFpEFLVEF ≥ 50%三类。我们把连续的射血分数映射到这三个类别就相当于把一份医学共识“注入”到了模型里。所以Evidence-Linked 特征工程与“暴力特征工程”最大的区别在于前者在构造每个特征时都有明确的医学解释或文献支撑模型不仅知道“数值是多少”还知道“这个数值在临床上意味着什么”。这种思路在小样本医学数据上尤其重要因为模型本身的学习能力有限合理的先验知识能显著降低过拟合风险。2. 环境准备与数据集说明2.1 数据集的来源与结构本文使用的心衰数据集常见版本是 UCI 的 Heart Failure Clinical Records 数据集包含 299 名心力衰竭患者的临床记录共 13 个字段。由于网络数据源可能失效且真实数据涉及患者隐私本文不提供直接下载链接而是使用一份结构一致的模拟数据来演示完整流程。模拟数据的字段名、字段类型、数值范围都与真实公开数据集对齐但数据内容不来自任何真实患者。等你拿到真实数据后只需要把“加载模拟数据”这一步替换成读取真实 CSV 文件即可。字段结构如下字段含义类型age年龄数值anaemia是否贫血二分类creatinine_phosphokinase肌酸磷酸激酶数值diabetes是否糖尿病二分类ejection_fraction射血分数数值high_blood_pressure是否高血压二分类platelets血小板计数数值serum_creatinine血清肌酐数值serum_sodium血清钠数值sex性别二分类smoking是否吸烟二分类time随访时间数值DEATH_EVENT死亡事件标签为了便于运行下面用 NumPy 生成一份模拟数据。请把它保存为一个脚本例如data_prepare.py或者直接在 Jupyter Notebook 中运行。import pandas as pd import numpy as np np.random.seed(42) n 299 data { age: np.random.randint(40, 95, n), anaemia: np.random.choice([0, 1], n, p[0.57, 0.43]), creatinine_phosphokinase: np.random.randint(23, 7861, n), diabetes: np.random.choice([0, 1], n, p[0.58, 0.42]), ejection_fraction: np.random.randint(14, 80, n), high_blood_pressure: np.random.choice([0, 1], n, p[0.65, 0.35]), platelets: np.random.randint(25000, 850000, n), serum_creatinine: np.random.uniform(0.5, 9.4, n).round(1), serum_sodium: np.random.randint(113, 148, n), sex: np.random.choice([0, 1], n, p[0.35, 0.65]), smoking: np.random.choice([0, 1], n, p[0.68, 0.32]), time: np.random.randint(4, 285, n), DEATH_EVENT: np.random.choice([0, 1], n, p[0.68, 0.32]) } df pd.DataFrame(data) df.head()运行后会看到一份 299 行、13 列的 DataFrame。注意这里的 DEATH_EVENT 是随机生成的所以后续训练出的模型指标没有实际医学含义只是用来演示流程。如果你拿到了真实数据模型结果才有临床参考价值。2.2 环境与依赖版本说明推荐的运行环境是 Python 3.9 或以上版本核心依赖如下pandas负责表格读取与基础操作。numpy负责数值计算与随机数据生成。scikit-learn提供 Pipeline、ColumnTransformer、标准化、编码、模型训练等能力。matplotlib 或 seaborn可选用于特征分布可视化。版本方面本文代码不依赖特定小版本但建议 scikit-learn 版本不低于 1.0因为get_feature_names_out等特征名获取接口需要 1.0 以上才能稳定使用。可以用pip install pandas numpy scikit-learn安装依赖具体版本以你本机环境为准。如果你的环境比较旧代码中的个别 API 可能需要微调。2.3 项目结构建议为了后续扩展和维护建议按下面的结构组织项目heart_failure_project/ ├── data/ │ └── heart_failure.csv ├── src/ │ ├── features.py │ ├── pipeline.py │ └── train.py ├── notebooks/ │ └── explore.ipynb └── README.mdsrc/features.py放特征构造自定义函数src/pipeline.py放 Pipeline 组装逻辑src/train.py放训练与评估代码。这样分层的目的是把“特征逻辑”“流水线逻辑”“训练逻辑”解耦后续任何一步变动都不会影响其他模块。3. 核心思路拆解把临床证据翻译成特征3.1 原始特征的处理思路拿到一张原始表我们首先要判断每一列应该走“数值处理”还是“分类处理”。数值特征包括age、creatinine_phosphokinase、ejection_fraction、platelets、serum_creatinine、serum_sodium、time。这些特征数值跨度差异很大例如血小板可能是 25 万而射血分数只有 30所以通常需要标准化。二分类特征包括anaemia、diabetes、high_blood_pressure、sex、smoking。这些特征取值是 0 或 1可以直接作为分类特征送入 OneHotEncoder。有人会问0/1 变量已经是数值了为什么还要编码从 scikit-learn 的惯例来说把它们显式声明为 categorical 特征可以避免模型把它们当成连续变量做线性加权尤其在逻辑回归这类模型中更有意义。另外还要注意time这个字段它表示随访时间。在真实研究中是否发生死亡事件和随访时间长度密切相关直接把它当作普通数值特征要谨慎。本文为了演示流程把它保留为数值特征但在真实项目里应该结合生存分析思路单独处理或者明确说明时间窗口。3.2 基于临床证据的特征构造这是本文的核心。我们要基于可靠医学共识在原始特征之上构造新的特征列。第一个是射血分数分级。根据临床指南的常见框架将ejection_fraction分成三档HFrEF射血分数小于 40%表示收缩功能严重下降。HFmrEF射血分数在 40% 到 49% 之间表示收缩功能轻度下降。HFpEF射血分数大于等于 50%表示射血分数保留。第二个是肾功能分级。血清肌酐是反映肾功能的常用指标虽然精确评估肾功能需要结合性别、年龄和体重计算肾小球滤过率但在简化场景下我们可以按血清肌酐数值粗略分级正常小于 1.2 mg/dL。轻度升高1.2 到 2.0 mg/dL。中度升高2.0 到 3.0 mg/dL。重度升高大于等于 3.0 mg/dL。需要特别说明这个分级是教学用简化版本真实项目中应该查阅最新版临床指南例如 KDIGO 慢性肾病分期并结合性别进行调整。第三个是低钠血症标记。血清钠低于 135 mmol/L 在临床上被认为是低钠血症与心衰患者的不良预后显著相关因此可以生成一个sodium_risk布尔特征。第四个是年龄分组。心衰患者中高龄是明确的危险因素可以把年龄划分为 65 岁以下、65 到 80 岁、80 岁以上三组。这些新特征不是凭空制造的而是把领域知识编码为特征的一种方式。有了这些特征模型可以在小样本情况下更快捕捉到风险边界。3.3 数值特征与分类特征的处理策略在 Pipeline 中我们通常使用ColumnTransformer对不同类型的特征施加不同处理。数值特征采用“中位数填充 标准化”。中位数填充相比于均值填充对异常值更稳健。标准化则能让模型不受量纲影响。分类特征采用“众数填充 OneHot 编码”。OneHot 编码会让决策树模型在某些情况下仍然可以工作但对于线性模型和神经网络这是必须的。另一个细节是handle_unknownignore它保证在测试集中出现训练集没有见过的类别时不会直接报错而是把该类别编码为全零向量。处理顺序上要注意必须先填充缺失值再做编码或标准化。如果先标准化再填充填充值会对均值方差产生不可控影响。4. 完整实战构建 Evidence-Linked 特征工程流水线4.1 数据准备与划分我们先把模拟数据划分为训练集和测试集。注意这一步要在任何特征构造和填充之前完成否则会引入特征泄漏。from sklearn.model_selection import train_test_split X df.drop(DEATH_EVENT, axis1) y df[DEATH_EVENT] 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)stratifyy表示按标签比例分层抽样保证训练集和测试集中正负样本比例接近。4.2 编写特征构造函数下面实现add_clinical_features函数。这个函数接收一个 DataFrame返回一个添加了新特征列的 DataFrame。为了让 Pipeline 可以正常调用函数输入输出都必须是 DataFrame。import pandas as pd import numpy as np def add_clinical_features(df: pd.DataFrame) - pd.DataFrame: 基于临床证据补充心力衰竭风险相关特征。 注意这是教学简化版本真实项目请根据最新指南调整阈值。 df df.copy() # 射血分数分级HFrEF / HFmrEF / HFpEF df[ef_category] pd.cut( df[ejection_fraction], bins[0, 40, 50, 100], labels[HFrEF, HFmrEF, HFpEF] ) # 年龄分组 df[age_group] pd.cut( df[age], bins[0, 65, 80, 120], labels[65, 65_80, 80] ) # 肾功能简化分级 df[ckd_stage] pd.cut( df[serum_creatinine], bins[0, 1.2, 2.0, 3.0, 20], labels[normal, mild, moderate, severe] ) # 低钠血症标记 df[sodium_risk] np.where(df[serum_sodium] 135, 1, 0) # 合并症计数把几个常见合并症累加作为风险负荷提示 df[comorbidity_count] ( df[anaemia] df[diabetes] df[high_blood_pressure] df[smoking] ) return df这里有几个需要注意的点pd.cut返回的是分类类型后续送入OneHotEncoder时会被当成分类特征处理。df.copy()是为了避免修改传入的原始 DataFrame。comorbidity_count属于一种轻量级交互特征把多个二分类变量聚合为一个风险负荷分数。这种特征在医学研究中比较常见因为它反映了患者的整体基础疾病负担。我在函数注释和 docstring 中标注了“临床依据”这是 Evidence-Linked 工程实践的重要习惯。每个特征为什么存在、阈值依据是什么都应该留下记录。4.3 组装 ColumnTransformer 与总 Pipeline接下来定义数值特征列表和分类特征列表然后组装ColumnTransformer。from sklearn.compose import ColumnTransformer from sklearn.pipeline import Pipeline from sklearn.impute import SimpleImputer from sklearn.preprocessing import StandardScaler, OneHotEncoder num_features [ age, creatinine_phosphokinase, ejection_fraction, platelets, serum_creatinine, serum_sodium, time ] cat_features [ anaemia, diabetes, high_blood_pressure, sex, smoking, ef_category, age_group, ckd_stage, sodium_risk ] preprocessor ColumnTransformer( transformers[ (num, Pipeline([ (imputer, SimpleImputer(strategymedian)), (scaler, StandardScaler()) ]), num_features), (cat, Pipeline([ (imputer, SimpleImputer(strategymost_frequent)), (onehot, OneHotEncoder(handle_unknownignore)) ]), cat_features) ] )有了预处理器之后我们再用总的 Pipeline 把“特征构造 预处理 模型”串起来from sklearn.ensemble import RandomForestClassifier full_pipeline Pipeline([ (clinical_features, FunctionTransformer(add_clinical_features, validateFalse)), (preprocessor, preprocessor), (classifier, RandomForestClassifier( n_estimators200, random_state42, class_weightbalanced )) ])这里FunctionTransformer的作用是把add_clinical_features包装成 sklearn 的 transformer。validateFalse表示不强制要求输入是 numpy 二维数组允许传入 DataFrame。class_weightbalanced是为了处理类别不平衡。心衰死亡数据集中死亡事件通常属于少数类如果不做处理模型会倾向于预测多数类导致召回率很低。4.4 训练与评估训练非常简单直接调用fit即可Pipeline 会自动完成特征构造、填充、编码、标准化和随机森林训练。full_pipeline.fit(X_train, y_train) y_pred full_pipeline.predict(X_test) y_prob full_pipeline.predict_proba(X_test)[:, 1] from sklearn.metrics import classification_report, roc_auc_score print(AUC:, roc_auc_score(y_test, y_prob)) print(classification_report(y_test, y_pred))因为模拟数据的标签是随机生成的AUC 大概会在 0.5 到 0.6 之间浮动这很正常。本文的重点是演示 Pipeline 结构而不是追求一个好看的指标。使用真实数据时这个流程可以直接复用。4.5 特征可解释性分析Evidence-Linked 特征工程的另一个好处是特征可解释性更强。我们可以从训练好的 Pipeline 中取出预处理器拿到变换后的特征名再和随机森林的特征重要性对齐。preprocessor_step full_pipeline.named_steps[preprocessor] feature_names preprocessor_step.get_feature_names_out() model full_pipeline.named_steps[classifier] importance pd.Series(model.feature_importances_, indexfeature_names) importance.sort_values(ascendingFalse).head(10)输出结果中会出现num__serum_creatinine、cat__ef_category_HFrEF、cat__ckd_stage_severe这类带前缀的特征名。前缀num__和cat__表示该特征来自数值分支还是分类分支。这种命名方式让我们可以明确追踪每个模型输入特征来自原始表的哪一列、经过了什么转换这正是工程可行性中非常关键的一环。5. 常见问题与排查思路5.1 RuntimeError: A dependency error occurred during pipeline creation如果你在使用类似 transformers 或 diffusers 深度学习库时看到这个报错那一般是模型组件依赖关系没有满足例如缺少某个安装包或某个层次的输入输出不匹配。但在 scikit-learn 的 Pipeline 场景下类似的“依赖错误”通常会以另一种形态出现某个 transformer 接收到的输入不满足下一步的预期从而在fit或transform过程中抛出 ValueError。常见原因有三个第一ColumnTransformer中指定的列名在FunctionTransformer输出后的 DataFrame 里不存在。比如你忘记在add_clinical_features里生成ef_category却在cat_features里引用了它就会报错。排查方法是单独对add_clinical_features(X_train)执行一次打印列名确认所有引用都存在。第二FunctionTransformer返回类型不是 DataFrame。某些 pandas 操作会把 DataFrame 降级成 Series后续ColumnTransformer按列名取数时就会失败。解决办法是确保函数内部用df.copy()并返回 DataFrame。第三Pipeline 中某一步输出的列顺序发生了变化。ColumnTransformer默认会按 transformer 的顺序重排列如果后续步骤依赖原始列顺序就会出现隐性问题。建议在组装时显式指定列名而不是依赖位置索引。5.2 特征泄漏最常见的坑特征泄漏是特征工程 pipeline 中最隐蔽、影响最大的问题。常见错误写法是先对整个 DataFrame 做SimpleImputer或StandardScaler然后再切分训练集和测试集。这样做会让测试集的信息在训练阶段就被模型“看到”导致验证指标虚高但上线后效果断崖式下跌。正确做法是把预处理步骤写进 Pipeline让fit只作用在训练集上transform在测试集上使用训练集的统计量。这也是为什么本文建议把所有预处理都放进Pipeline而不是手动写代码。5.3 OneHotEncoder 稀疏矩阵与特征名对齐问题OneHotEncoder默认返回稀疏矩阵。在ColumnTransformer中多个 transformer 的输出会被组合成一个大稀疏矩阵这通常没有问题也不需要手动转换。但在做特征重要性分析时如果你直接打印model.feature_importances_却使用原始的列名列表去对齐就会发生错位。正确做法是像 4.5 节那样使用preprocessor_step.get_feature_names_out()获取编码后的完整特征名再和特征重要性对齐。需要注意该 API 要求 scikit-learn 版本不低于 1.0。6. 最佳实践与工程建议6.1 让每个特征有据可查在医学预测项目中特征工程不是“越多越好”而是“越可解释越好”。建议在代码注释或单独的文档中记录每个构造特征的临床依据和阈值来源。比如ef_category的阈值来自心力衰竭指南sodium_risk的阈值来自低钠血症的临床定义。这样做有两个好处一是当模型结果异常时医生或同事可以沿着特征定义回溯问题二是当指南更新时可以快速定位需要修改的代码位置。6.2 为 Pipeline 建立单元测试Pipeline 一旦组装好可以长期复用。为了确保后续修改不破坏原逻辑建议为特征构造函数写简单的单元测试。下面是一个最小示例def test_add_clinical_features(): sample pd.DataFrame({ age: [70], anaemia: [1], creatinine_phosphokinase: [100], diabetes: [0], ejection_fraction: [35], high_blood_pressure: [1], platelets: [200000], serum_creatinine: [1.8], serum_sodium: [132], sex: [1], smoking: [0], time: [30] }) out add_clinical_features(sample) assert ef_category in out.columns assert out[ef_category].iloc[0] HFrEF assert out[sodium_risk].iloc[0] 1这种测试在重构时会非常有价值。6.3 数据版本与结果复现医疗数据项目对复现性的要求很高。建议在项目开始时记录数据集的来源、版本和预处理脚本版本。最简单的方式是除了 CSV 数据文件外额外保存一份data_meta.yaml里面写明数据来源、下载日期、样本量、字段说明。模型训练时还可以把full_pipeline通过 joblib 或 pickle 序列化保存同时保存X_train的列名便于线上预测时做一致性校验。import joblib joblib.dump(full_pipeline, heart_failure_pipeline.pkl)线上加载后用loaded_pipeline.predict(X_new)即可完成预测不需要再单独处理填充、编码、标准化等步骤。6.4 医疗模型的安全边界最后要强调任何心衰预测模型都不应该直接替代医生诊断。这类模型更适合用于风险分层、临床研究辅助或资源分配参考。在真实项目中需要遵循数据合规要求对患者敏感信息做脱敏处理并且保证模型输出可以被解释。如果模型要进入临床辅助决策流程还需要经过严格的临床验证和监管审批这不是一个技术教程能覆盖的范围。7. 总结与后续学习方向从一张 13 列的临床表出发我们完成了完整的“证据驱动特征工程 机器学习流水线”搭建先理解心衰数据的临床含义再基于医学共识构造射血分数分级、肾功能分级、低钠血症标记和合并症计数然后用FunctionTransformer把这些自定义逻辑封装进Pipeline用ColumnTransformer统一处理数值和分类特征最后用随机森林完成训练评估并实现了特征可解释性分析。这条流水线的价值在于它把领域知识、数据预处理和模型训练固化成了可复现、可部署的代码避免了手动转换带来的泄漏和错位风险。如果你正在做类似的临床预测项目建议先跑通最小闭环再逐步丰富证据特征如果后续用到梯度提升树或神经网络这套 Pipeline 的骨架依然可以复用只需要替换最后的分类器即可。下一步可以继续学习的方向包括交叉验证与超参数调优、模型解释工具 SHAP、生存分析模型、以及如何处理真实临床数据中的缺失值和时序信息。无论往哪个方向深入都建议保留“证据驱动 工程化”这两个习惯每个特征都有出处每一步处理都在流程之中。