机器学习-KNN 心脏病预测案例详解
KNN 心脏病预测案例详解
本文档详细讲解 ml_tutorial/ch03_knn/4_heart_disease.py,涵盖数据提取、缺失值处理、特征分类、独热编码、标准化以及网格搜索调参的完整流程。
一、整体流程
加载数据 → 清洗缺失值 → 划分特征/标签 → 训练集/测试集切分
→ 特征工程(标准化 + 独热编码 + 透传) → 网格搜索训练 → 评估
二、数据集介绍
数据文件:ml_tutorial/data/heart_disease.csv,共 14 列,最后一列 是否患有心脏病 为标签,其余 13 列为特征。
| 列名 | 含义 | 类型 |
|---|---|---|
| 年龄 | 患者年龄 | 数值型 |
| 性别 | 1=男,0=女 | 二元 |
| 胸痛类型 | 4 种类型(0/1/2/3) | 类别型 |
| 静息血压 | 入院时血压 | 数值型 |
| 胆固醇 | 血清胆固醇 | 数值型 |
| 空腹血糖 | >120mg/dl 为 1 | 二元 |
| 静息心电图结果 | 心电图分类 | 类别型 |
| 最大心率 | 达到的最大心率 | 数值型 |
| 运动性心绞痛 | 1=有,0=无 | 二元 |
| 运动后的 ST 下降 | ST 段下降值 | 数值型 |
| 峰值 ST 段的斜率 | 斜率分类 | 类别型 |
| 主血管数量 | 0~3 | 数值型 |
| 地中海贫血 | 缺陷类型分类 | 类别型 |
| 是否患有心脏病 | 1=患病,0=健康(标签) | 标签 |
三、数据提取与清洗
# 1. 加载数据集
heart_disease_data = pd.read_csv("../data/heart_disease.csv")
# 处理缺失值:直接删除含空值的行
heart_disease_data.dropna(inplace=True)
heart_disease_data.info() # 查看每列的非空数量和数据类型
print(heart_disease_data.head()) # 查看前 5 行
pd.read_csv把 CSV 读为 DataFrame。dropna(inplace=True)删除任何含缺失值的行,inplace=True表示在原对象上修改。info()用于确认清洗后是否还有缺失值,以及各列的数据类型是否符合预期。
四、划分特征与标签
# 2. 数据集划分
X = heart_disease_data.drop("是否患有心脏病", axis=1) # 特征:删除标签列后剩余全部
y = heart_disease_data["是否患有心脏病"] # 标签:单独取出目标列
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.3, random_state=42)
X为特征矩阵,y为目标向量。test_size=0.3:30% 数据作测试集,70% 作训练集。random_state=42:固定随机种子,保证每次划分结果一致,便于复现。
五、特征工程(核心)
5.1 为什么要区分特征类型
KNN 依赖距离计算(默认欧氏距离)判断样本相似度。不同类型的特征需要不同处理:
- 数值型:量纲差异大(如年龄 30 vs 胆固醇 300),若不统一尺度,数值大的特征会主导距离,因此需要标准化。
- 类别型:整数编码会被算法误认为有序数值。例如
胸痛类型=1和=2的差是 1,=1和=3的差是 2,算法会误以为类型 1 更接近类型 2,而实际上它们只是无序类别。因此需要独热编码消除虚假顺序。 - 二元特征:本身就是 0/1,已经是合理的数值形式,无需处理,直接透传。
5.2 特征分组
numerical_features = ["年龄", "静息血压", "胆固醇", "最大心率", "运动后的ST下降", "主血管数量"]
categorical_features = ["胸痛类型", "静息心电图结果", "峰值ST段的斜率", "地中海贫血"]
binary_features = ["性别", "空腹血糖", "运动性心绞痛"]
数据集特征逐一归类如下:
| 特征 | 类型 | 说明 | 处理方式 |
|---|---|---|---|
| 年龄 | 数值型 | 连续值 | 标准化 |
| 静息血压 | 数值型 | 连续值 | 标准化 |
| 胆固醇 | 数值型 | 连续值 | 标准化 |
| 最大心率 | 数值型 | 连续值 | 标准化 |
| 运动后的ST下降 | 数值型 | 连续值 | 标准化 |
| 主血管数量 | 数值型 | 连续值 | 标准化 |
| 胸痛类型 | 类别型(名义变量) | 4 种分类 | 独热编码 |
| 静息心电图结果 | 类别型(名义变量) | 3 种分类 | 独热编码 |
| 峰值ST段的斜率 | 类别型(有序变量) | 3 种分类 | 独热编码 |
| 地中海贫血 | 类别型(名义变量) | 4 种分类 | 独热编码 |
| 性别 | 二元特征 | 0/1 | 透传 |
| 空腹血糖 | 二元特征 | 0/1 | 透传 |
| 运动性心绞痛 | 二元特征 | 0/1 | 透传 |
名义变量 vs 有序变量:名义变量(如胸痛类型)各类别间无先后大小之分;有序变量(如峰值ST段的斜率)类别本身有顺序含义。本案例为简化处理,两者统一用独热编码。若想保留有序变量的顺序信息,也可改用
OrdinalEncoder按序映射为整数。
5.3 独热编码原理
以 胸痛类型(取值 0/1/2/3)为例,独热编码把 1 列展开为多列,每列代表一个类别,命中为 1,其余为 0:
| 原始值 | 类型_0 | 类型_1 | 类型_2 | 类型_3 |
|---|---|---|---|---|
| 0 | 1 | 0 | 0 | 0 |
| 2 | 0 | 0 | 1 | 0 |
这样任意两个类别之间的距离都相等,消除了错误的顺序关系。
drop="first" 的作用:删除第一个类别列,避免多重共线性(Multicollinearity),即哑变量陷阱。
多重共线性是指特征之间存在高度线性相关关系的现象。以 胸痛类型(4 个类别 0/1/2/3)为例,若直接独热编码会生成 4 个新列,此时这 4 列恒满足:
胸痛类型_0 + 胸痛类型_1 + 胸痛类型_2 + 胸痛类型_3 = 1
也就是说,任意一列都可由其余三列线性表示(例如 类型_0 = 1 - 类型_1 - 类型_2 - 类型_3),存在完全线性相关。这会带来两个问题:
- 信息冗余:已知其余列全为 0,就能推断出被删除的那一类,第 4 列不提供新信息。
- 对线性/正则化类模型不友好:设计矩阵不满秩,参数估计不唯一、不稳定(KNN 本身不受影响,但保留冗余列会徒增维度)。
设置 drop="first" 后删除 类型_0,用 3 列即可无损表达 4 个类别,打破了上面的恒等式,消除多重共线性。一般规律:n 个类别只需 n-1 个哑变量。
5.4 标准化原理
StandardScaler 对每个数值特征做 z-score 变换:
z = (x - 均值) / 标准差
变换后每个特征均值为 0、标准差为 1,使所有数值特征处于同一尺度,距离计算不再被大量纲特征主导。
5.5 ColumnTransformer 组装
columnTransformer = ColumnTransformer(
transformers=[
("num", StandardScaler(), numerical_features), # 数值列 → 标准化
("cat", OneHotEncoder(drop="first"), categorical_features), # 类别列 → 独热编码
("bin", "passthrough", binary_features) # 二元列 → 原样透传
]
)
X_train = columnTransformer.fit_transform(X_train) # 拟合 + 转换
X_test = columnTransformer.transform(X_test) # 仅转换
ColumnTransformer让不同列走不同的处理管道,最后横向拼接为一个矩阵。
转换后特征列数会因独热编码而增加(原 13 列 → 展开后更多列),这也是打印 X_train.shape 前后变化的原因。
5.6 为什么训练集用 fit_transform、测试集用 transform
fit_transform = fit(学参数)+ transform(做变换)两步合一,而 transform 只做变换、不学参数。二者不能互换,核心目的是 防止数据泄露(data leakage)。
| 方法 | 作用对象 | 做什么 |
|---|---|---|
fit_transform | 训练集 | 从训练数据学习参数(μ、σ、类别集合),并立即应用变换 |
transform | 测试集 / 新数据 | 复用训练集学到的参数,只做变换,不重新学习 |
具体到本例:
StandardScaler:fit_transform时计算训练集的均值 μ 和标准差 σ;transform时用同一套 μ、σ 去标准化测试集,即(x - μ_train) / σ_train。OneHotEncoder:fit_transform时记录训练集出现过的类别;transform时按同一套类别集合编码测试集。
为什么测试集不能也 fit:
- 测试集要模拟"未来未知数据"。真实上线时新样本是一条条到来的,根本没有测试集的整体统计量可算,只能套用训练时固定下来的参数。
- 若对测试集单独
fit,会算出测试集自己的 μ/σ,两套标准不一致——同一个原始值在训练和测试里被映射成不同数值,模型评估结果会失真(通常偏乐观)。 - 保证一致性:
joblib保存的columnTransformer携带的是训练集参数,上线后对新数据同样只能transform。
一句话:训练集 fit_transform 定标准,测试集 / 新数据 transform 套标准。
六、模型训练与网格搜索调参
6.1 网格搜索原理
网格搜索(Grid Search) 是一种系统化的超参数调优方法,通过遍历预定义的超参数组合,找到使模型性能最优的参数配置,从而避免手动试错、提高效率。
网格搜索通常与交叉验证嵌套使用,以提高调参的可靠性:
- 外层循环:遍历参数网格中的每个参数组合。
- 内层循环:对每个参数组合使用交叉验证评估模型性能,取平均得分作为该组合的最终评分。
以本例为例,参数网格共 10 × 2 = 20 组组合,每组在 10 折交叉验证下训练 10 次,合计训练 200 个模型:
n_neighbors=1, weights="uniform" → 10 折 CV → 平均准确率
n_neighbors=1, weights="distance" → 10 折 CV → 平均准确率
...(共 20 组)
最终取平均准确率最高的那组参数作为最佳超参数。
6.2 超参数 K 值
K 是 KNN 算法的核心超参数 n_neighbors,即预测时参考最近邻居的数量。
预测一个样本时,算法找到训练集中距它最近的 K 个点,用这 K 个点的类别多数投票决定预测结果:
K=3 → 找最近 3 个邻居 → 2 个患病 + 1 个健康 → 预测:患病
K=5 → 找最近 5 个邻居 → 2 个患病 + 3 个健康 → 预测:健康
K 值大小对模型的影响:
| K 小(如 K=1) | K 大(如 K=10) |
|---|---|
| 对训练集拟合极好 | 决策边界更平滑 |
| 容易过拟合,噪声敏感 | 可能欠拟合 |
因此本例用网格搜索遍历 K=1~10,配合 10 折交叉验证,找到泛化能力最好的 K 值,而不是手动猜。
6.3 十折交叉验证
10 折交叉验证(10-Fold Cross Validation) 是把训练集平均分成 10 份,轮流用其中 1 份作验证集、其余 9 份作训练集,重复 10 次,最终取 10 次得分的平均值:
原始训练集(100%)均分为 10 份:
[1][2][3][4][5][6][7][8][9][10]
第 1 轮:[1] 验证 [2~10] 训练 → 得分 a1
第 2 轮:[2] 验证 [1,3~10] 训练 → 得分 a2
...
第 10 轮:[10] 验证 [1~9] 训练 → 得分 a10
最终得分 = (a1 + a2 + ... + a10) / 10
为什么要交叉验证: 若只划分一次验证集,得分可能受划分方式影响(运气好/差)。10 折让每个样本都当过一次验证数据,评估结果更稳定可靠。
结合网格搜索: 每组超参数组合都跑完整的 10 折 CV,最后选平均分最高的那组参数,避免"恰好在某个验证集上表现好"的偶然性。
6.4 代码实现
# 创建 KNN 分类器
knn = KNeighborsClassifier()
# 定义待搜索的超参数网格
param_grid = {
"n_neighbors": list(range(1, 11)), # K 从 1 到 10
"weights": ["uniform", "distance"] # 等权 / 距离加权
}
# 10 折交叉验证的网格搜索
grid_search_cv = GridSearchCV(estimator=knn, param_grid=param_grid, cv=10)
grid_search_cv.fit(X_train, y_train)
n_neighbors:邻居数 K,逐一尝试 1~10。weights:uniform所有邻居等权投票;distance越近的邻居权重越大。cv=10:10 折交叉验证,每组参数训练评估 10 次取平均,结果更稳健。GridSearchCV会遍历所有参数组合(10 × 2 = 20 组),自动挑出表现最好的一组。
查看结果
results = pd.DataFrame(grid_search_cv.cv_results_).to_string()
print(results) # 每组参数的详细得分
print(grid_search_cv.best_estimator_) # 最佳模型对象
print(grid_search_cv.best_params_) # 最佳超参数组合
print(grid_search_cv.best_score_) # 交叉验证的最佳平均得分
七、最终评估
knn = grid_search_cv.best_estimator_ # 取出网格搜索选出的最佳模型
print(knn.score(X_test, y_test)) # 在测试集上评估泛化能力
- 用未参与训练的测试集评估,衡量模型对新数据的真实预测能力(准确率)。
八、模型持久化(代码中已注释,供参考)
import joblib
joblib.dump(value=knn, filename="knn_model") # 保存模型到磁盘
knn_loaded = joblib.load("knn_model") # 加载模型
y_pred = knn_loaded.predict(X_test[10:11]) # 对新数据预测
joblib 可将训练好的模型序列化保存,避免每次使用都重新训练。
九、小结
- 清洗:删除缺失行,保证数据质量。
- 划分:先分特征/标签,再分训练/测试,固定随机种子。
- 特征工程:数值标准化、类别独热编码、二元透传,用
ColumnTransformer统一编排;训练集fit_transform、测试集transform防止数据泄露。 - 调参:
GridSearchCV+ 交叉验证自动挑选最优 K 和权重。 - 评估:用最佳模型在测试集上验证泛化性能。
import pandas as pd
from sklearn.model_selection import train_test_split, GridSearchCV
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import OneHotEncoder, StandardScaler # 独热编码和标准化
from sklearn.neighbors import KNeighborsClassifier
import joblib
# 1. 加载数据集
heart_disease_data = pd.read_csv("../data/heart_disease.csv")
# 处理缺失值
heart_disease_data.dropna(inplace=True)
heart_disease_data.info()
print(heart_disease_data.head())
# 2. 数据集划分
# 划分特征和标签
X = heart_disease_data.drop("是否患有心脏病", axis=1)
y = heart_disease_data["是否患有心脏病"]
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=42)
print(X_train.shape, X_test.shape, y_train.shape, y_test.shape)
# 3. 特征工程
# 数值型特征
numerical_features = ["年龄", "静息血压", "胆固醇", "最大心率", "运动后的ST下降", "主血管数量"]
# 类别型特征
categorical_features = ["胸痛类型", "静息心电图结果", "峰值ST段的斜率", "地中海贫血"]
# 二元特征
binary_features = ["性别", "空腹血糖", "运动性心绞痛"]
# 创建一个列转换器
columnTransformer = ColumnTransformer(
transformers=[
("num", StandardScaler(), numerical_features),
("cat", OneHotEncoder(drop="first"), categorical_features),
("bin", "passthrough", binary_features)
]
)
# 特征转换
X_train = columnTransformer.fit_transform(X_train)
X_test = columnTransformer.transform(X_test)
print(X_train.shape)
print(X_test.shape)
# # 4. 创建模型
# knn = KNeighborsClassifier(n_neighbors=3)
#
# # 5. 模型训练
# knn.fit(X_train, y_train)
#
# # 6. 模型评估,计算预测准确率
# score = knn.score(X_test, y_test)
# print(score)
# # 7. 保存模型
# joblib.dump(value=knn, filename="knn_model")
# # 加载模型,对新数据进行预测
# knn_loaded = joblib.load("knn_model")
# y_pred = knn_loaded.predict(X_test[10:11])
# print(f"预测类别:{y_pred}, 真实类别:{y_test[10]}")
# 创建KNN分类器
knn = KNeighborsClassifier()
# 定义网格搜索参数列表
param_grid = {"n_neighbors": list(range(1, 11)), "weights": ["uniform", "distance"]}
grid_search_cv = GridSearchCV(estimator=knn, param_grid=param_grid, cv=10)
# 模型训练
grid_search_cv.fit(X_train, y_train)
# 打印模型评估结果
results = pd.DataFrame(grid_search_cv.cv_results_).to_string()
print(results)
# 直接获取最佳模型和最佳得分
print(grid_search_cv.best_estimator_)
print(grid_search_cv.best_params_)
print(grid_search_cv.best_score_)
# 使用最佳模型,进行测试评估
knn = grid_search_cv.best_estimator_
print(knn.score(X_test, y_test))
尚硅谷大模型资料
链接:https://pan.baidu.com/s/1xN1ah75DlC_osEUV6a1Jyw?pwd=yyds
更多推荐




所有评论(0)