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
01000
20010

这样任意两个类别之间的距离都相等,消除了错误的顺序关系。

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测试集 / 新数据复用训练集学到的参数,只做变换,不重新学习

具体到本例:

  • StandardScalerfit_transform 时计算训练集的均值 μ 和标准差 σ;transform 时用同一套 μ、σ 去标准化测试集,即 (x - μ_train) / σ_train
  • OneHotEncoderfit_transform 时记录训练集出现过的类别;transform 时按同一套类别集合编码测试集。

为什么测试集不能也 fit

  1. 测试集要模拟"未来未知数据"。真实上线时新样本是一条条到来的,根本没有测试集的整体统计量可算,只能套用训练时固定下来的参数。
  2. 若对测试集单独 fit,会算出测试集自己的 μ/σ,两套标准不一致——同一个原始值在训练和测试里被映射成不同数值,模型评估结果会失真(通常偏乐观)。
  3. 保证一致性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。
  • weightsuniform 所有邻居等权投票;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 可将训练好的模型序列化保存,避免每次使用都重新训练。

九、小结

  1. 清洗:删除缺失行,保证数据质量。
  2. 划分:先分特征/标签,再分训练/测试,固定随机种子。
  3. 特征工程:数值标准化、类别独热编码、二元透传,用 ColumnTransformer 统一编排;训练集 fit_transform、测试集 transform 防止数据泄露。
  4. 调参GridSearchCV + 交叉验证自动挑选最优 K 和权重。
  5. 评估:用最佳模型在测试集上验证泛化性能。
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

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐