公司动态

KNN算法实战:从泰坦尼克号数据预测看机器学习全流程

📅 2026/8/23 3:27:52
KNN算法实战:从泰坦尼克号数据预测看机器学习全流程
1. 项目缘起从“泰坦尼克号”数据到KNN实战的思考如果你对数据分析或机器学习感兴趣那么“泰坦尼克号生还预测”这个数据集几乎是你绕不开的“Hello World”。它不像鸢尾花分类那样过于干净和理想也不像房价预测那样维度复杂。它恰到好处地混合了数值型、类别型数据包含了大量缺失值和现实世界中的逻辑关联是一个绝佳的、用于理解数据科学全流程的“沙盒”。而K最近邻算法作为机器学习领域最直观、最易于理解的算法之一常被初学者用来叩开分类问题的大门。但很多人只是跟着教程跑通代码看到准确率就结束了这恰恰错过了这个项目最精华的部分如何将一个经典的算法应用到一个不完美的真实数据集上并理解每一步操作背后的“为什么”。这次我们不打算走马观花。我将以一个从业者的视角带你完整地走一遍用Python实现KNN算法预测泰坦尼克号乘客生还情况的全过程。重点不在于复现一个结果而在于拆解过程中的每一个决策点数据为什么这么清洗特征为什么要如此构造KNN的参数如何选择又为何如此选择模型评估除了准确率还能看什么这些才是从“会写代码”到“会解决问题”的关键跨越。你会发现即使是一个简单的KNN在面对泰坦尼克号数据时也能引出一连串值得深思的实践问题。2. 理解战场泰坦尼克号数据集深度剖析在动手写一行代码之前我们必须像将军勘察地形一样彻底理解我们的“数据战场”。泰坦尼克号数据集通常包含以下字段每个字段都不是孤立存在的它们背后是1912年那场海难的社会缩影。2.1 数据字段的“生存逻辑”解读PassengerId: 乘客ID。纯粹的索引标识对预测无直接意义但需保留用于最终结果对齐。Survived: 标签Label。0代表遇难1代表生还。这是我们模型要预测的目标。Pclass: 船舱等级。这不仅仅是一个数字1/2/3它强烈关联着社会阶级、船舱位置高层甲板更安全、以及登船救生艇的优先级。它是预测生存的强特征。Name: 乘客姓名。初看无用但细看宝藏。姓名中包含了“Mr.”, “Mrs.”, “Miss”, “Master”对未成年男孩的尊称等称呼Title这能精确提取出乘客的性别、年龄阶段、婚姻状况等隐含信息尤其是当年龄数据缺失时。Sex: 性别。“女士与儿童优先”的撤离原则使得这个字段成为最强的预测因子之一。Age: 年龄。儿童和部分老人拥有更高的生存优先级。但此字段有大量缺失值如何处理是建模的关键挑战。SibSp: 同行的兄弟姐妹或配偶数量。反映了家庭规模可能影响互助行为或撤离决策。Parch: 同行的父母或子女数量。同样是家庭结构的反映。Ticket: 船票号。格式混乱包含字母数字组合。直接使用价值低但可以尝试提取票号前缀可能与购票渠道或仓位有关不过对于KNN初版模型通常选择暂时舍弃或简化处理。Fare: 票价。与Pclass强相关但也可能独立反映乘客的经济实力。Cabin: 船舱号。有大量缺失值。但它的首字母如C, B, E代表甲板区域而不同甲板距离救生艇的远近直接决定了生存机会。这是一个信息量大但噪声也大的字段。Embarked: 登船港口C Cherbourg; Q Queenstown; S Southampton。可能与社会经济地位或旅行目的有微弱关联。理解这些字段背后的物理意义和社会意义是进行有效特征工程的前提。例如你不能简单地把‘Cabin’的缺失值填为‘Unknown’就了事你需要思考“缺失Cabin信息”这件事本身是否意味着乘客处于更低等级的舱位记录不全或更拥挤的区域这本身可能就是一个特征。2.2 核心挑战与建模目标我们的核心挑战来自数据本身缺失值Age年龄缺失约20%Cabin船舱缺失约77%Embarked登船港口缺失极少。数据类型混合数值型Age, Fare、二分类Sex、多分类Pclass, Embarked、文本型Name, Ticket, Cabin。特征尺度不一Age范围在0-80Fare范围在0-512直接计算距离会被Fare主导。建模目标很明确构建一个基于K最近邻KNN的分类模型根据乘客的特征预测其是否生还Survived: 0或1。这是一个典型的二分类问题。3. 武器准备KNN算法核心原理与Python环境3.1 KNN算法用“邻居的民意”做决策K最近邻算法可能是最符合人类直觉的机器学习算法。它的核心思想一句话就能说清“物以类聚人以群分”。工作原理存储KNN是一种“惰性学习”算法它不对训练数据做任何概括性的模型构建只是简单地把所有训练样本的特征和标签存储起来。距离计算当需要预测一个新样本时算法计算这个新样本与训练集中每一个样本的“距离”通常是欧氏距离。投票决策在距离最近的K个训练样本即K个“邻居”中统计它们所属的类别。将新样本预测为这K个邻居中出现次数最多的那个类别多数表决。关键参数KK值过小如K1模型变得非常复杂对噪声数据异常敏感容易过拟合。相当于“只听最近一个人的意见”结果不稳定。K值过大模型变得简单但可能忽略数据的局部特征导致欠拟合。相当于“听取全村人的意见”决策边界变得平滑可能模糊了类别之间的精细界限。如何选择K没有理论上的最优值需要通过交叉验证等实验方法来确定。通常选择一个较小的奇数避免平票然后在一个范围内例如1到20进行测试。为什么选择KNN作为起点因为它几乎没有内部模型参数需要学习除了K决策过程透明可以查看是哪些邻居决定了结果非常适合理解分类任务的基本流程和评估概念。但它也有明显缺点预测时计算开销大需计算与所有样本的距离对高维数据和稀疏数据效果不佳且对特征尺度和无关特征非常敏感——这正好引出了我们下一步必须做的特征工程。3.2 Python环境与工具栈我们将使用最主流的数据科学工具链确保每一步都可复现。# 核心库 pip install numpy pandas matplotlib seaborn scikit-learnNumPy: 底层数值计算。Pandas: 数据操作与分析的核心用于数据加载、清洗、转换。Matplotlib/Seaborn: 数据可视化帮助我们理解数据分布和关系。Scikit-learn: 机器学习算法库我们用它来实现KNN、数据预处理和模型评估。这里有个实操心得建议使用Jupyter Notebook或VS Code的Jupyter扩展来逐步执行代码。交互式环境能让你在每一步都查看数据形态对于学习过程至关重要。在VS Code中配置Python环境只需安装Python扩展并选择正确的解释器即可远比想象中简单。4. 战役第一阶段数据清洗与特征工程这是整个项目中最耗时、也最见功力的部分。模型的上限往往由数据和特征决定。4.1 数据加载与初步探查import pandas as pd import numpy as np import matplotlib.pyplot as plt import seaborn as sns from sklearn.model_selection import train_test_split, cross_val_score, GridSearchCV from sklearn.preprocessing import StandardScaler, LabelEncoder from sklearn.neighbors import KNeighborsClassifier from sklearn.metrics import accuracy_score, classification_report, confusion_matrix # 设置可视化风格 sns.set_style(whitegrid) %matplotlib inline # 加载数据 train_df pd.read_csv(train.csv) # 训练集包含Survived标签 test_df pd.read_csv(test.csv) # 测试集不包含Survived标签 # 查看训练集前几行和基本信息 print(训练集形状:, train_df.shape) print(train_df.info()) print(train_df.head()) print(\n缺失值统计:) print(train_df.isnull().sum())运行后你会清晰地看到Age、Cabin、Embarked的缺失情况。Cabin缺失太多我们暂时搁置先处理Age和Embarked。4.2 缺失值处理策略比填充本身更重要1. 处理Embarked缺失值只有2个缺失直接使用众数出现频率最高的值填充是最合理、影响最小的选择。train_df[Embarked].fillna(train_df[Embarked].mode()[0], inplaceTrue) test_df[Embarked].fillna(test_df[Embarked].mode()[0], inplaceTrue)2. 处理Age缺失值重点与难点不能简单地用整体均值填充因为不同人群的年龄分布差异很大。一个更聪明的策略是利用其他特征进行分组填充。观察发现乘客的Title从姓名提取和Pclass与Age有较强关联例如“Master”通常是年轻男孩“Mr.”可能是成年男性头等舱的乘客平均年龄可能更高。# 首先从Name中提取Title for df in [train_df, test_df]: df[Title] df[Name].str.extract( ([A-Za-z])\., expandFalse) # 查看Title与Age的关系 print(train_df.groupby(Title)[Age].median()) # 定义一个函数根据Title和Pclass来填充Age的缺失值 def fill_age_missing(df): # 计算每个Title和Pclass组合下的年龄中位数 age_median_by_group df.groupby([Title, Pclass])[Age].median() # 对每个缺失Age的行用对应的组中位数填充 for index, row in df[df[Age].isnull()].iterrows(): group_key (row[Title], row[Pclass]) if group_key in age_median_by_group: df.loc[index, Age] age_median_by_group[group_key] else: # 如果组合不存在用该Title的中位数填充 df.loc[index, Age] df[df[Title] row[Title]][Age].median() return df train_df fill_age_missing(train_df) test_df fill_age_missing(test_df)注意在测试集上填充时必须使用测试集自身计算出的统计量或与训练集一致的映射关系严禁“数据泄露”。这里我们分别处理但逻辑一致。3. 处理Fare缺失值测试集中有1个用对应Pclass的中位数填充。test_df[Fare].fillna(test_df.groupby(Pclass)[Fare].transform(median), inplaceTrue)4. 处理Cabin缺失值缺失率过高直接填充意义不大。但我们可以从中提取一个二值特征HasCabin表示是否有船舱记录。这本身可能就是一个信号。for df in [train_df, test_df]: df[HasCabin] df[Cabin].notnull().astype(int)4.3 特征构造从原始数据中挖掘信息1. 家庭规模与是否独行SibSp和Parch可以合并创建一个新特征FamilySize。进一步地我们可以发现家庭规模为1的乘客即SibSp和Parch都为0是“独行者”这可能影响其生存机会缺乏家人协助。for df in [train_df, test_df]: df[FamilySize] df[SibSp] df[Parch] 1 # 包括自己 df[IsAlone] 0 df.loc[df[FamilySize] 1, IsAlone] 12. 姓名中的Title归类之前提取的Title类别太多有些类别样本极少。我们可以将其归并为几大类。for df in [train_df, test_df]: df[Title] df[Title].replace([Lady, Countess,Capt, Col,\ Don, Dr, Major, Rev, Sir, Jonkheer, Dona], Rare) df[Title] df[Title].replace(Mlle, Miss) df[Title] df[Title].replace(Ms, Miss) df[Title] df[Title].replace(Mme, Mrs)3. 年龄分段将连续的Age转换为有序的类别特征有时对KNN这类基于距离的算法有帮助能减少噪声。for df in [train_df, test_df]: df[AgeBand] pd.cut(df[Age], bins[0, 12, 18, 35, 60, 100], labels[1, 2, 3, 4, 5]) df[AgeBand] df[AgeBand].astype(int)4.4 特征选择与编码为KNN准备“食粮”KNN算法计算距离因此必须将所有特征转换为数值型并且最好进行标准化消除量纲影响。1. 删除无用特征PassengerId,Name,Ticket,Cabin原始字段Age原始连续值我们已经有了AgeBandSibSp,Parch我们已经有了FamilySize和IsAlone。根据你的特征构造策略可以灵活选择删除哪些。drop_elements [PassengerId, Name, Ticket, Cabin, Age, SibSp, Parch] train_df_clean train_df.drop(drop_elements, axis1) test_df_clean test_df.drop([col for col in drop_elements if col in test_df.columns], axis1)2. 对类别特征进行标签编码Label Encoding将Sex,Embarked,Title等文本类别转换为数字。注意对于只有两个类别的Sex用0/1编码是合理的。对于多于两个类别的如果类别间没有顺序关系如Embarked更严谨的做法是使用独热编码One-Hot Encoding但会增加维度。对于KNN和树模型标签编码有时也可行这里为简化先使用标签编码。label_encoders {} categorical_cols [Sex, Embarked, Title] for col in categorical_cols: le LabelEncoder() # 合并训练集和测试集的所有唯一值进行拟合以确保编码一致性 all_data pd.concat([train_df_clean[col], test_df_clean[col]], ignore_indexTrue) le.fit(all_data) train_df_clean[col] le.transform(train_df_clean[col]) test_df_clean[col] le.transform(test_df_clean[col]) label_encoders[col] le # 保存编码器以备后用3. 分离特征和标签X_train train_df_clean.drop(Survived, axis1) y_train train_df_clean[Survived] X_test test_df_clean # 测试集没有Survived列4. 特征标准化至关重要这是使用KNN前的规定动作。如果不标准化Fare范围0-512的微小变化对距离计算的贡献将远远超过AgeBand1-5或Pclass1-3的巨大变化导致模型完全被Fare主导。scaler StandardScaler() X_train_scaled scaler.fit_transform(X_train) # fit_transform: 计算均值和标准差并转换训练集 X_test_scaled scaler.transform(X_test) # transform: 使用训练集的均值和标准差转换测试集核心要点StandardScaler的fit方法只能在训练集上计算参数均值、标准差然后用这些参数去转换训练集和测试集。绝对不能用测试集单独fit否则就是严重的数据泄露会得到过于乐观且无效的评估结果。5. 战役第二阶段KNN模型训练、调参与评估数据准备就绪现在进入模型环节。5.1 初步建立基准模型我们先用一个“经验值”K5来建立一个基准模型看看效果。# 初始化KNN分类器K5使用欧氏距离 knn_baseline KNeighborsClassifier(n_neighbors5, metriceuclidean) knn_baseline.fit(X_train_scaled, y_train) # 在训练集上做交叉验证看看模型的平均表现 cv_scores cross_val_score(knn_baseline, X_train_scaled, y_train, cv5, scoringaccuracy) print(f基准模型(K5)交叉验证准确率: {cv_scores.mean():.4f} (/- {cv_scores.std()*2:.4f}))交叉验证准确率大概会在0.80左右波动。这算是一个不错的起点但肯定不是最优。5.2 超参数调优寻找最佳K值我们需要系统性地寻找最优的K值。使用网格搜索GridSearchCV是最佳实践。# 定义参数网格搜索K从1到20的奇数 param_grid {n_neighbors: list(range(1, 21, 2))} # 1, 3, 5, ..., 19 # 初始化网格搜索使用5折交叉验证以准确率为评价指标 grid_search GridSearchCV(KNeighborsClassifier(metriceuclidean), param_grid, cv5, scoringaccuracy, return_train_scoreTrue) grid_search.fit(X_train_scaled, y_train) # 输出最佳参数和最佳得分 print(f最佳K值: {grid_search.best_params_[n_neighbors]}) print(f最佳交叉验证准确率: {grid_search.best_score_:.4f}) # 可视化不同K值下的训练集和验证集表现 results pd.DataFrame(grid_search.cv_results_) plt.figure(figsize(10,6)) plt.plot(param_grid[n_neighbors], results[mean_train_score], labelTraining Score, markero) plt.plot(param_grid[n_neighbors], results[mean_test_score], labelCross-Validation Score, markers) plt.fill_between(param_grid[n_neighbors], results[mean_test_score] - results[std_test_score], results[mean_test_score] results[std_test_score], alpha0.2) plt.xlabel(K Value) plt.ylabel(Accuracy) plt.title(KNN Model Performance vs K Value) plt.legend() plt.grid(True) plt.show()通过图表你会清晰地看到当K1时训练分数很高几乎完美但验证分数较低这是过拟合的典型表现模型记住了训练集的每一个噪声点。随着K增大训练分数下降验证分数先上升后下降。两条曲线之间的间隙在减小说明模型复杂度在降低。验证分数曲线的峰值点对应的K值就是我们寻找的最佳平衡点通常在5到15之间。假设我们找到的最佳K是7。5.3 模型评估超越准确率用最佳参数重新训练模型并在一个预留的验证集或交叉验证上进行更全面的评估。# 使用最佳K值重新训练最终模型 best_k grid_search.best_params_[n_neighbors] knn_final KNeighborsClassifier(n_neighborsbest_k, metriceuclidean) knn_final.fit(X_train_scaled, y_train) # 我们可以将原始训练集再分出一部分作为验证集进行最终评估 X_train_split, X_val_split, y_train_split, y_val_split train_test_split( X_train_scaled, y_train, test_size0.2, random_state42, stratifyy_train) knn_final.fit(X_train_split, y_train_split) y_val_pred knn_final.predict(X_val_split) print(验证集准确率:, accuracy_score(y_val_split, y_val_pred)) print(\n分类报告:) print(classification_report(y_val_split, y_val_pred)) # 绘制混淆矩阵 cm confusion_matrix(y_val_split, y_val_pred) sns.heatmap(cm, annotTrue, fmtd, cmapBlues, xticklabels[Predicted 0, Predicted 1], yticklabels[Actual 0, Actual 1]) plt.ylabel(True Label) plt.xlabel(Predicted Label) plt.title(Confusion Matrix) plt.show()重点看分类报告和混淆矩阵准确率Accuracy整体分类正确的比例。但在这个数据集中生还和遇难人数并不完全平衡约62%遇难38%生还仅看准确率可能掩盖问题。精确率Precision预测为生还的人中实际生还的比例。高精确率意味着我们“说一个人能活他大概率真的活了”减少误报。召回率Recall实际生还的人中被我们预测出来的比例。高召回率意味着我们“尽可能多地找到了所有生还者”减少漏报。F1分数精确率和召回率的调和平均数是综合衡量指标。对于泰坦尼克号这样的场景我们可能更关注召回率希望尽可能多地识别出可能生还的人但这取决于你设定的问题视角。混淆矩阵能直观地展示被错误分类的样本数量。5.4 特征重要性分析针对KNN的间接方法KNN模型本身不提供像决策树那样的特征重要性分数。但我们可以通过观察不同特征在距离计算中的贡献来间接分析。一个常用的方法是逐一屏蔽某个特征观察模型性能如准确率的下降程度。下降越多的特征通常越重要。feature_importance {} original_score cross_val_score(knn_final, X_train_scaled, y_train, cv5, scoringaccuracy).mean() for i, col in enumerate(X_train.columns): # 创建删除该特征后的数据集 X_train_dropped np.delete(X_train_scaled, i, axis1) score_dropped cross_val_score(knn_final, X_train_dropped, y_train, cv5, scoringaccuracy).mean() # 计算重要性得分性能下降幅度 feature_importance[col] original_score - score_dropped # 按重要性排序并可视化 importance_df pd.DataFrame(sorted(feature_importance.items(), keylambda x: x[1], reverseTrue), columns[Feature, Importance]) plt.figure(figsize(10,6)) sns.barplot(xImportance, yFeature, dataimportance_df, paletteviridis) plt.title(Feature Importance (by Drop-Column Importance for KNN)) plt.xlabel(Decrease in Accuracy when Feature is Removed) plt.tight_layout() plt.show()你会发现Sex、Title、Pclass、Fare等特征的重要性排名靠前这与我们的业务直觉是吻合的。6. 实战中的陷阱、技巧与进阶思考走到这一步你已经有了一个可工作的KNN模型。但真正的经验来自于踩坑和优化。6.1 常见陷阱与解决方案陷阱一忘记特征标准化。这是KNN新手最常犯的错误。后果就是模型完全被数值范围大的特征如Fare所控制。务必在训练任何基于距离的模型KNNSVM with RBF kernel K-means前进行标准化或归一化。陷阱二使用测试集参与任何训练过程。包括用测试集计算缺失值的填充值、标准化参数等。这会导致模型在测试集上表现“虚高”但实际泛化能力很差。必须严格遵守fit只在训练集transform用于训练集和测试集的流程。陷阱三K值选择不当。盲目使用默认值K5。一定要通过交叉验证来选择合适的K。可以使用网格搜索GridSearchCV系统化地寻找。陷阱四忽略类别不平衡。泰坦尼克号数据中遇难者多于生还者。在KNN的投票环节如果K值较大多数类遇难可能会“淹没”少数类生还的局部特征。可以考虑使用加权投票距离近的邻居投票权重更高weightsdistance参数。在评估时使用F1分数或AUC-ROC曲线而不是单纯看准确率。陷阱五高维灾难。KNN在特征维度很高时性能会急剧下降因为在高维空间中所有点之间的距离都趋于相等。对于泰坦尼克号数据特征20维问题不大但如果特征工程后维度激增例如对所有类别特征做独热编码需要考虑使用特征选择如基于树模型的重要性选择或降维如PCA技术。6.2 性能优化技巧距离度量选择欧氏距离是默认选择。对于稀疏数据或高维数据可以尝试曼哈顿距离metricmanhattan或余弦相似度metriccosine。可以通过网格搜索一并优化。算法实现选择sklearn的KNeighborsClassifier默认使用algorithmauto它会根据数据自动选择kd_tree或ball_tree等数据结构来加速最近邻搜索。对于大数据集这能极大提升预测速度。加权KNN设置weightsdistance让更近的邻居在投票中拥有更大话语权。这通常能略微提升模型性能特别是当数据分布不均匀时。6.3 模型局限性及后续方向KNN在这个项目上能达到约82%-83%的交叉验证准确率这已经是一个不错的结果。但它也有天花板计算成本高预测时需要计算与所有训练样本的距离当训练集很大时预测速度慢。对无关特征和噪声敏感即使做了标准化如果特征中包含大量与预测目标无关的特征也会干扰距离计算降低性能。需要大量内存必须存储整个训练集。作为数学建模或学习的下一步你可以尝试其他模型用同样的特征工程数据跑一下逻辑回归、随机森林、梯度提升树如XGBoost等模型对比性能。你会发现树模型通常能轻松达到85%以上因为它们能更好地处理特征间的复杂交互和非线性关系。深入特征工程我们只做了最基础的特征工程。你可以尝试更多例如根据Fare和Pclass构造“人均票价”特征从Ticket号码中提取更有意义的模式对Cabin首字母进行更精细的生存率编码等。模型集成将KNN、随机森林、逻辑回归等模型的预测结果进行投票或平均软投票往往能获得比单一模型更稳定、更优的性能。这个项目就像一块敲门砖通过它你实践了数据科学的完整管道数据理解、清洗、特征工程、模型训练、调参、评估和优化。每一个环节的思考深度决定了你最终结果的高度。希望这份详细的拆解能让你下次面对类似数据集时不再只是机械地跑代码而是能带着理解去设计和改进每一个步骤。