多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

KNN算法Python实现:从原理到实战,详解机器学习分类与优化

KNN算法Python实现:从原理到实战,详解机器学习分类与优化 1. 项目概述从“近邻”到决策的智慧如果你在超市里想买一款没喝过的酸奶大概率会先看看周围的人都拿了什么或者当你搬到一个新小区想知道附近哪家餐馆好吃最直接的办法可能就是问问邻居们的推荐。这种“看看周围人怎么做我就怎么做”的朴素思想其实就是KNNK-Nearest NeighborsK最近邻算法最核心、最生活化的体现。它不像一些高深的算法那样有复杂的数学推导和内部模型KNN的哲学非常简单物以类聚人以群分。判断一个未知事物属于哪一类就看它在特征空间里离哪些已知的“邻居”最近。这个“超详细”的实现指南就是要带你亲手把这种生活中的直觉变成计算机可以理解和执行的代码。我们不止步于理解“KNN是什么”更要深入到“如何用Python一步步实现它”、“在实际数据中会遇到哪些坑”以及“怎么让它更好地为你服务”。无论你是刚开始接触机器学习的新手想找一个直观的入门算法还是有一定经验的开发者需要在具体项目比如用户分类、商品推荐、异常检测中快速实现一个可靠的基线模型KNN都是一个绝佳的起点。它的实现过程几乎涵盖了机器学习从数据准备、算法编码到模型评估的全流程理解它就等于打通了机器学习实践的“任督二脉”。2. KNN算法核心原理与生活化解读2.1 “近邻”思想的数学化表达KNN算法本身并不在训练阶段构建任何模型它只是把所有的训练样本“记住”。当一个新的数据点我们称之为查询点到来时算法才会开始工作。它的工作流程可以概括为三步计算距离计算查询点与训练集中每一个样本点的距离。寻找邻居根据计算出的距离找出距离最近的K个训练样本即K个“最近邻”。投票决策对于分类任务统计这K个邻居中哪个类别最多就将查询点归为该类别对于回归任务则取这K个邻居目标值的平均值作为预测值。这里最核心的概念就是“距离”。在生活中我们判断两个水果是否相似可能会看它们的颜色、大小、形状。在数学上我们将这些特征颜色、大小、形状数值化构成一个多维空间里的点。两点之间的“远近”就用距离公式来衡量。最常用的距离是欧氏距离也就是我们中学学过的两点间直线距离公式在多维空间的推广。假设一个水果用颜色值c和重量值w表示那么两个水果A(c1, w1)和B(c2, w2)的欧氏距离就是sqrt((c1-c2)² (w1-w2)²)。除此之外曼哈顿距离、余弦相似度等也是常用的度量方式适用于不同的数据特性。2.2 关键参数K的选择人云亦云还是独立思考参数K的选择是KNN算法的灵魂它直接决定了模型的“性格”。K值过小例如K1模型变得非常“敏感”和“有个性”。它只听取最近的一个邻居的意见容易受到噪声数据或异常点的干扰导致模型过拟合即在训练集上表现很好但遇到新数据时泛化能力很差。好比只听一个人的推荐就做决定风险很高。K值过大模型变得非常“平滑”和“从众”。它考虑了大量邻居的意见使得决策边界趋于平缓可能忽略掉数据局部结构的细节导致模型欠拟合即无法捕捉数据中潜在的模式。好比问了一整条街的人虽然意见主流但可能淹没了真正懂行的少数派的声音。那么如何选择K呢没有银弹但有一个黄金标准通过交叉验证来选取。通常的做法是尝试一系列可能的K值比如1到20之间的奇数以避免平票在训练集上通过交叉验证计算每个K值对应的平均准确率然后选择准确率最高的那个K。奇数是为了在分类投票时避免出现两个类别票数相同的情况。在实际操作中我通常会从一个小K值如5开始尝试观察模型性能随K值变化的曲线选择一个在曲线上处于性能平台期且不过大的K值。注意K值的选择也和数据规模有关。如果总训练样本只有100个那么K50显然太大了因为你的“邻居圈”包含了半数样本已经失去了局部性意义。3. 从零开始Python手撕KNN分类器理解了原理我们开始动手实现。我们将用一个经典的鸢尾花数据集作为例子它包含了150个样本每个样本有4个特征花萼长度、花萼宽度、花瓣长度、花瓣宽度目标是将它们分为3类鸢尾花。3.1 数据准备与预处理磨刀不误砍柴工任何机器学习项目的第一步也是至关重要的一步就是和数据打交道。糟糕的数据准备会让最优秀的算法也黯然失色。import numpy as np from sklearn import datasets from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler import matplotlib.pyplot as plt # 1. 加载数据 iris datasets.load_iris() X iris.data # 特征矩阵形状 (150, 4) y iris.target # 标签向量形状 (150,) # 2. 数据探查非常重要 print(f数据集形状: {X.shape}) print(f特征样例:\n{X[:5]}) print(f标签样例: {y[:5]}) print(f类别名称: {iris.target_names}) print(f各类别样本数量: {np.bincount(y)}) # 3. 划分训练集和测试集 # 通常用70%-80%的数据训练剩下的测试。random_state确保每次划分结果一致便于复现。 X_train, X_test, y_train, y_test train_test_split(X, y, test_size0.3, random_state42) print(f训练集大小: {X_train.shape}, 测试集大小: {X_test.shape}) # 4. 特征标准化对KNN尤其关键 # KNN基于距离计算如果特征量纲不同如一个特征范围是0-1另一个是10-1000 # 范围大的特征会完全主导距离计算淹没其他特征的作用。 scaler StandardScaler() X_train_scaled scaler.fit_transform(X_train) # 拟合scaler计算均值和标准差并转换训练集 X_test_scaled scaler.transform(X_test) # 使用训练集的均值和标准差转换测试集 # 切记不能对测试集单独做fit_transform否则就是“数据泄露”模型评估会过于乐观。实操心得train_test_split的random_state参数是个好东西。在开发调试阶段把它固定下来可以确保每次运行代码得到的数据划分是一样的这样模型性能的波动就只来源于你修改的代码而不是随机的数据划分极大提升了调试效率。等最终模型确定后可以尝试多次不同的随机划分来评估模型的稳定性。3.2 KNN核心算法实现距离计算与投票我们不直接调库而是自己实现一个最基础的KNN类来彻底搞懂它的运作机制。class SimpleKNN: def __init__(self, k5): 初始化KNN分类器。 Args: k: 邻居数量默认为5。 self.k k self.X_train None self.y_train None def fit(self, X_train, y_train): ‘训练’模型。对于KNN来说就是存储训练数据。 这是KNN被称为‘懒惰学习’的原因它几乎没有训练过程。 self.X_train X_train self.y_train y_train # 可以在这里添加一些检查比如X_train和y_train的长度是否一致 return self def _compute_distances(self, X): 计算输入样本X与所有训练样本之间的欧氏距离。 使用向量化操作避免低效的循环。 # 利用公式 (a-b)^2 a^2 - 2ab b^2 进行向量化计算 # X: (m, n), self.X_train: (n_train, n) # 结果 distances: (m, n_train) distances np.sqrt( np.sum(X**2, axis1, keepdimsTrue) - 2 * np.dot(X, self.X_train.T) np.sum(self.X_train**2, axis1) ) return distances def predict(self, X): 对输入样本X进行预测。 Args: X: 待预测样本形状 (m, n)。 Returns: predictions: 预测的类别标签形状 (m,)。 if self.X_train is None: raise ValueError(模型尚未训练请先调用fit方法。) distances self._compute_distances(X) # (m, n_train) # 获取每个样本距离最近的k个邻居的索引 # argsort对距离进行排序返回的是索引值取前k个 k_nearest_indices np.argsort(distances, axis1)[:, :self.k] # (m, k) # 获取这k个邻居对应的标签 k_nearest_labels self.y_train[k_nearest_indices] # (m, k) # 投票统计每个样本的k个邻居中每个类别出现的次数 # 使用bincount的向量化版本需要一些技巧这里用循环更清晰对于教学 predictions np.zeros(X.shape[0], dtypeself.y_train.dtype) for i in range(X.shape[0]): # np.bincount 统计0,1,2...出现的次数minlength确保输出长度至少为类别数 counts np.bincount(k_nearest_labels[i], minlengthnp.max(self.y_train)1) # 取票数最多的类别作为预测argmax返回第一个最大值的索引 predictions[i] np.argmax(counts) return predictions def score(self, X, y): 计算模型在给定数据和标签上的准确率。 predictions self.predict(X) accuracy np.mean(predictions y) return accuracy代码解析与避坑指南向量化计算距离在_compute_distances方法中我们使用了基于线性代数的向量化计算而不是用双层循环。这对于大数据集来说速度可能有成百上千倍的提升。这是实现高效KNN的关键技巧。argsort的妙用np.argsort(distances, axis1)沿着每一行每个测试样本对距离进行排序返回的是排序后的索引而不是距离值本身。这让我们能直接定位到最近的邻居。投票环节np.bincount是统计整数数组中各值出现次数的利器。注意minlength参数它确保了即使某个类别在最近的k个邻居中一次也没出现它的计数值也是0而不是被忽略。“懒惰学习”请注意fit方法几乎什么都没做只是保存了数据。所有繁重的计算距离计算、排序、投票都发生在predict阶段。这意味着KNN的预测速度会随着训练集规模的增大而线性下降这是它的主要缺点之一。3.3 模型训练与评估看看我们的“手写”模型表现如何现在让我们用自己写的SimpleKNN类来跑一遍流程。# 1. 实例化并训练模型 my_knn SimpleKNN(k5) my_knn.fit(X_train_scaled, y_train) # 2. 在测试集上进行预测并评估 test_accuracy my_knn.score(X_test_scaled, y_test) print(f【手写KNN】测试集准确率: {test_accuracy:.4f}) # 3. 对比Scikit-learn官方实现作为验证 from sklearn.neighbors import KNeighborsClassifier sklearn_knn KNeighborsClassifier(n_neighbors5) sklearn_knn.fit(X_train_scaled, y_train) sklearn_accuracy sklearn_knn.score(X_test_scaled, y_test) print(f【Sklearn KNN】测试集准确率: {sklearn_accuracy:.4f}) # 通常两者结果会非常接近微小差异可能源于距离计算或排序算法的实现细节。运行这段代码你会看到自己实现的KNN和业界标准库Scikit-learn的结果几乎一致。这种成就感是单纯调库无法比拟的它意味着你真正理解了算法的内核。4. 进阶实战KNN在复杂场景下的优化与应用一个基础的KNN实现出来了但在真实世界中我们面对的数据和问题要复杂得多。直接套用基础版往往会碰壁。4.1 处理大数据集KD树与球树加速当训练样本数N很大比如超过1万特征维度D也不低时计算每个查询点与所有N个点的距离称为暴力搜索Brute-Force的复杂度是O(N*D)会变得非常慢。解决方案是使用空间索引结构来加速近邻搜索最常用的两种是KD树和球树。KD树是一种二叉树它递归地将k维空间进行划分。搜索时可以快速排除大量不可能包含最近邻的区域将平均搜索复杂度降至O(D * logN)。适用于低维空间例如D20。球树每个树节点定义一个超球面。它对于高维数据或数据分布不均匀时通常比KD树表现更好因为球形的边界在高维空间可能比矩形的划分更有效。在Scikit-learn中我们只需要在初始化时指定algorithm参数即可轻松切换。from sklearn.neighbors import NearestNeighbors import time # 生成一个较大的模拟数据集 np.random.seed(42) X_large np.random.randn(10000, 10) # 10000个样本10个特征 # 比较不同算法的查询速度 query_point np.random.randn(1, 10) print(查询一个点的最近邻k5所需时间) # 暴力搜索 start time.time() nn_brute NearestNeighbors(n_neighbors5, algorithmbrute).fit(X_large) distances, indices nn_brute.kneighbors(query_point) print(f 暴力搜索: {time.time() - start:.4f} 秒) # KD树 start time.time() nn_kd NearestNeighbors(n_neighbors5, algorithmkd_tree).fit(X_large) distances, indices nn_kd.kneighbors(query_point) print(f KD树: {time.time() - start:.4f} 秒) # 球树 start time.time() nn_ball NearestNeighbors(n_neighbors5, algorithmball_tree).fit(X_large) distances, indices nn_ball.kneighbors(query_point) print(f 球树: {time.time() - start:.4f} 秒) # 注意构建索引fit本身也需要时间对于需要多次查询的场景构建一次索引是值得的。实操心得对于中小型数据集比如几千个样本暴力搜索可能更快因为构建树结构也有开销。一个实用的策略是让Scikit-learn自动帮你选择algorithm‘auto’。它会根据你的数据规模、特征维度等自动在‘ball_tree’,‘kd_tree’,‘brute’中选择它认为最高效的一个。4.2 特征权重与距离度量让模型更智能基础的KNN中所有邻居的投票权重是相等的一票制所有特征的权重在距离计算中也是相等的。我们可以让它变得更精细。距离加权投票离查询点越近的邻居它的意见应该越重要。我们可以根据距离的倒数或其他衰减函数来给邻居的投票加权。class WeightedKNN(SimpleKNN): def predict(self, X): distances self._compute_distances(X) k_nearest_indices np.argsort(distances, axis1)[:, :self.k] k_nearest_labels self.y_train[k_nearest_indices] k_nearest_distances np.take_along_axis(distances, k_nearest_indices, axis1) predictions np.zeros(X.shape[0], dtypeself.y_train.dtype) for i in range(X.shape[0]): # 计算权重距离越小权重越大。这里使用距离的倒数并加上一个小常数防止除零。 weights 1.0 / (k_nearest_distances[i] 1e-8) # 为每个类别累计加权票数 weighted_votes {} for label, weight in zip(k_nearest_labels[i], weights): weighted_votes[label] weighted_votes.get(label, 0) weight # 选出加权票数最多的类别 predictions[i] max(weighted_votes, keyweighted_votes.get) return predictions选择合适的距离度量欧氏距离并非万能。如果你的数据是文本词向量余弦相似度衡量方向一致性可能比欧氏距离衡量绝对位置差异更合适。对于分类特征可以使用汉明距离。在Scikit-learn的KNeighborsClassifier中可以通过metric参数指定如metric‘cosine’或metric‘hamming’。4.3 超参数调优实战用网格搜索找到最佳K之前我们提到用交叉验证选K这里我们用Scikit-learn的GridSearchCV来自动化、系统化地完成这个工作并同时优化其他参数比如距离度量方式。from sklearn.model_selection import GridSearchCV from sklearn.pipeline import Pipeline # 创建一个包含标准化和KNN的流水线 # 这样做可以确保在交叉验证的每一折中标准化只使用该折训练集的数据来拟合避免数据泄露。 pipeline Pipeline([ (scaler, StandardScaler()), (knn, KNeighborsClassifier()) ]) # 定义要搜索的参数网格 param_grid { knn__n_neighbors: [3, 5, 7, 9, 11, 13, 15], # 不同的K值 knn__weights: [uniform, distance], # 等权投票 vs 距离加权投票 knn__metric: [euclidean, manhattan, cosine] # 不同的距离度量 } # 创建网格搜索对象 # cv5 表示5折交叉验证 scoringaccuracy 表示以准确率作为评估标准 grid_search GridSearchCV(pipeline, param_grid, cv5, scoringaccuracy, verbose1, n_jobs-1) # n_jobs-1 表示使用所有CPU核心并行计算大大加快搜索速度。 # 在训练集上执行网格搜索注意这里用原始的X_train, y_train流水线内部会处理标准化 grid_search.fit(X_train, y_train) # 输出最佳参数和最佳得分 print(f最佳参数组合: {grid_search.best_params_}) print(f最佳交叉验证准确率: {grid_search.best_score_:.4f}) # 用最佳模型在测试集上做最终评估 best_model grid_search.best_estimator_ final_test_accuracy best_model.score(X_test, y_test) print(f最佳模型在测试集上的准确率: {final_test_accuracy:.4f}) # 可视化不同参数的表现以K值为例 results_df pd.DataFrame(grid_search.cv_results_) # 可以过滤出 weightsuniform, metriceuclidean 的结果绘制K值与平均测试得分的关系曲线通过网格搜索我们不仅找到了最优的K还可能发现距离加权投票(weightsdistance)或曼哈顿距离(metricmanhattan)在这个数据集上表现更好。这个过程体现了机器学习中“没有免费午餐定理”——需要针对具体问题寻找最合适的配置。5. 真实世界挑战与解决方案实录在实际项目中教科书式的干净数据很少见。以下是使用KNN时最常遇到的几个“坑”及其应对策略。5.1 类别不平衡问题当“多数派”声音太大想象一个疾病检测数据集99%是健康样本1%是患病样本。对于一个新样本即使它离患病的少数样本更近但由于K个邻居中健康样本占绝对多数KNN很可能将其误判为健康。这就是类别不平衡。解决方案调整类别权重Scikit-learn的KNN可以通过weights参数实现距离加权这本身就有一定缓解作用。更直接的方法是使用class_weight参数如果算法支持或者使用sample_weight在fit时给少数类样本更高的权重。重采样过采样增加少数类样本的副本或生成合成样本如SMOTE算法。欠采样随机减少多数类样本的数量。注意过采样应在训练集内部进行且必须在数据划分之后避免信息泄露到测试集。改变评估指标在类别不平衡时准确率是欺骗性的。应关注精确率、召回率、F1-score或ROC-AUC曲线。例如在疾病检测中我们可能更关心召回率找出所有病人的能力。from sklearn.metrics import classification_report, confusion_matrix # 假设我们处理了一个不平衡数据集并用best_model做了预测 y_pred best_model.predict(X_test_scaled) print(classification_report(y_test, y_pred, target_namesiris.target_names)) print(混淆矩阵:) print(confusion_matrix(y_test, y_pred)) # 通过混淆矩阵你可以清晰看到模型在哪两个类别之间容易混淆。5.2 高维灾难当“距离”失去意义随着特征维度D急剧增加数据点在高维空间中会变得异常稀疏任意两点间的欧氏距离会趋于相似。这使得“最近邻”的概念变得模糊KNN的性能会显著下降。这就是“维数灾难”。解决方案特征选择使用过滤法如方差阈值、相关系数、包装法如递归特征消除RFE或嵌入法如基于树模型的特征重要性来选择最具判别力的特征子集。特征降维使用主成分分析或t-SNE等方法将高维数据映射到低维空间如2D或3D同时尽可能保留原始数据的结构。然后再在低维空间应用KNN。from sklearn.decomposition import PCA # 将4维鸢尾花特征降至2维进行可视化 pca PCA(n_components2) X_train_pca pca.fit_transform(X_train_scaled) X_test_pca pca.transform(X_test_scaled) # 然后在X_train_pca和X_test_pca上训练和评估KNN重新审视问题高维灾难是KNN的固有缺陷。如果特征维度真的很高比如成百上千可能需要考虑其他对高维数据更鲁棒的算法如线性模型、支持向量机或基于树的模型。5.3 计算效率与在线学习KNN的阿克琉斯之踵KNN需要存储全部训练数据预测时需要计算与所有训练样本的距离这使得它的空间复杂度和预测时间复杂度都很高不适合大数据或实时预测场景。优化策略近似最近邻搜索当不需要100%精确的最近邻时可以使用近似算法如LSH、Annoy、Faiss等库。它们用一定的精度损失换取巨大的速度提升非常适合推荐系统、图像检索等大规模应用。数据缩减在训练前可以使用聚类方法如K-Means对训练集进行聚类然后用聚类中心点作为代表样本来训练KNN大幅减少需要存储和计算距离的样本数。选择替代算法如果实时性要求极高考虑使用模型参数固定、预测速度极快的算法如逻辑回归、朴素贝叶斯或决策树。一个实用的建议在项目初期KNN是一个优秀的基线模型。用它快速验证特征的有效性和问题的可分离性。一旦基线建立再去尝试更复杂但效率可能更高的模型。6. 从鸢尾花到生活KNN的多样化应用场景理解了算法的内核和调优方法我们来看看KNN能在哪些实际场景中大显身手。它的应用远超简单的分类。6.1 推荐系统“和你喜好相似的人也喜欢...”这是KNN最经典的应用之一。基于用户的协同过滤其核心思想就是KNN。步骤将每个用户表示为一个特征向量例如对商品的评分。要为用户A推荐商品首先找到与A最相似的K个用户基于评分向量的余弦相似度或皮尔逊相关系数。汇总这K个邻居喜欢高评分而用户A尚未接触过的商品按热度或加权评分排序生成推荐列表。挑战与优化用户-商品矩阵极其稀疏用户只给少数商品评分。需要处理稀疏矩阵并可能结合基于内容的过滤物品本身的特征来提升效果。6.2 异常检测“远离群体的孤独者”在信用卡交易、工业设备传感器监测中正常数据点往往是聚集的而异常点则远离主要群体。步骤使用正常历史数据训练KNN或者直接将其作为参考集。对于一个新数据点计算它到其第K个最近邻的距离。如果这个距离超过某个阈值则判定为异常。这个阈值可以通过在验证集上调整来确定。优势无需对异常类型做任何假设是一种无监督的检测方法。6.3 图像识别与内容检索“以图搜图”的简单实现在小型或特定领域的图像库中KNN可以用于简单的图像分类或检索。步骤特征提取使用预训练的卷积神经网络如ResNet提取图像的特征向量通常是全连接层前的输出。构建索引将所有库中图像的特征向量作为训练集存储。查询对于一张新图片提取其特征向量在特征向量空间中用KNN寻找最相似的K张图片。注意这里KNN的作用是快速检索相似样本真正的“识别”能力来自于强大的特征提取器CNN。6.4 缺失值插补“用邻居的值来填补空白”当数据集中存在缺失值时可以用KNN来插补。步骤对于某个有缺失值的样本找到它在其他特征上与完整的样本中最相似的K个邻居然后用这K个邻居在该特征上的值平均值、众数来填补缺失值。与均值/中位数插补对比KNN插补考虑了样本之间的相似性通常比全局均值插补更合理。从我个人的多次实践来看KNN就像一把瑞士军刀简单但用途广泛。它的最大价值在于其直观性和可解释性——你可以轻松地向业务方解释“我们推荐这个商品是因为和你消费习惯最像的10个客户中有7个都买了它。”这种解释能力在当今强调AI可信度的时代尤为珍贵。当然它的效率问题决定了它更适合作为中小规模数据上的基线方案或辅助工具。在动手实现它之后你会对“距离”、“相似度”、“局部性”这些机器学习中的核心概念有更深刻的体会这是学习更复杂模型的一块坚实跳板。下次当你再需要快速验证一个想法时不妨先从一句from sklearn.neighbors import KNeighborsClassifier开始。
返回列表