MATLAB实现K近邻算法:手写代码与fitcknn实战
简介面向数学建模、科学计算与科研数据分析场景这份MATLAB实现K近邻KNN算法的压缩包定位于帮助初学者快速上手监督学习中的经典分类与回归方法。KNN以“物以类聚”为核心思想无需训练过程原理直观该资源恰好以精简代码呈现这一过程。包体仅含1个MATLAB脚本文件m文件压缩包大小约1KB代码涵盖邻域搜索、距离度量与分类决策等关键环节虽小巧却具备完整的参考价值。目前已有202人学习下载简洁的结构既适合刚接触算法的本科生/研究生对照运行也适合有经验者快速搭建基线版本。通过脚本中的示例读者可以掌握欧氏距离等度量方式的MATLAB实现并进一步将KNN迁移到模式识别、异常检测或时间序列等自身课题中提升算法落地效率整体是一份小而精的算法学习素材。1. K近邻为什么总在数学建模的“保底算法”名单里不管是全国大学生数学建模竞赛还是华为杯研究生数学建模竞赛拿到赛题后先做数据探索几乎都会试一遍分类器。K近邻KNN通常是第一个被拉出来跑通流程的算法不需要训练模型、不用假设数据分布、代码二十行就能出分类结果。在数学建模和科学计算场景里它还是处理小样本分类、异常检测、缺失值填补的通用工具甚至比决策树、支持向量机更好解释——评委问你“为什么这么分”你只需要说“离它最近的几个样本这么分所以它分到这一类”。但K近邻能上榜靠的不是深奥的统计理论而是它的执行逻辑足够直白一个样本的类别由它周围最近的k个样本投票决定。这个逻辑既是优点也是陷阱。k取多少、距离怎么算、特征要不要标准化、数据维度高了以后邻居还准不准每一个环节都会让准确率剧烈波动。用MATLAB实现K近邻算法时可以直接调fitcknn也可以手写一个循环版本跑通整个流程。这篇内容会从两种实现路径讲起再结合数学建模案例和参数调优把这套算法在MATLAB里的细节拆开方便你在赛场上快速复现。2. 从欧氏距离到MATLAB函数K近邻的两个实现层次2.1 手写K近邻的核心步骤2.1.1 距离计算先理解KNN的决策基础K近邻的所有决策都建立在“距离”上。最常见的欧氏距离在MATLAB里用pdist2一行就能算但自己写一遍能看清楚计算过程更方便排查量纲问题。% 构造两类二维样本 rng(42); data1 mvnrnd([0 0], eye(2)*0.3, 30); data2 mvnrnd([2 2], eye(2)*0.5, 30); X [data1; data2]; % 60x2 的特征矩阵 y [ones(30,1); -ones(30,1)]; % 类别标签 1 / -1 % 待预测样本 xq [1.0 1.2]; % 方案一用矩阵运算手写欧氏距离 diff X - xq; % 每个样本与查询点的坐标差60x2 dist sqrt(sum(diff.^2, 2)); % 按行平方求和再开方得到60x1距离向量上面这段代码里diff记录了每个样本与查询点在各维度上的差值sum(..., 2)表示沿第二个维度列方向求和得到每个样本的平方距离汇总最后开方得到距离。手写的好处是如果中途需要换成带权重的距离可以直接在diff上乘一个权重向量。2.1.2 投票与回归从距离到决策只有三步拿到距离向量后找出距离最近的k个样本分类问题看票数回归问题看均值k 5; [~, idx] sort(dist); % 距离从小到大排序 neighborIdx idx(1:k); % 前k个邻居的位置 neighborLabels y(neighborIdx); % 分类多数投票 predClass sign(sum(neighborLabels)); % 两类用符号函数统计 % 回归邻居均值 predValue mean(neighborLabels);sort(dist)返回排序结果和原始索引idx(1:k)就是最近k个邻居在原始样本矩阵中的行号。分类这里用sum(neighborLabels)配合sign适用于±1编码的两分类问题如果面对多分类改用mode(neighborLabels)取众数就可以了。回归场景里连续量的预测值直接取邻居均值经验做法是以距离倒数为权重做加权平均距离近的样本对结果影响更大。手写流程对数学建模比赛的意义在于当自带工具箱不可用时这套逻辑迁到Python、R或者Excel里也能成立。它同时也暴露了KNN的关键瓶颈——每预测一个样本都要计算它与全部训练样本的距离数据量大时计算开销成倍增长。2.2 用fitcknn快速建模范式2.2.1 fitcknn与predict的最小闭环MATLAB的Statistics and Machine Learning Toolbox提供了封装好的fitcknn它能自动处理距离计算、邻居搜索策略和投票规则。对数学建模赛题来说优先使用这个函数能省下大量调循环的时间。% 加载自带数据鸢尾花 load fisheriris X meas; % 150x4 花瓣与花萼尺寸 Y species; % 150x1 花的类别字符串元胞 % 切分训练集与测试集 cv cvpartition(length(Y), HoldOut, 0.3); trainIdx training(cv); testIdx test(cv); % 训练KNN分类器k默认取与类别数不相关的经验值5 mdl fitcknn(X(trainIdx,:), Y(trainIdx), NumNeighbors, 5); % 测试集预测 yPred predict(mdl, X(testIdx,:)); accuracy sum(yPred Y(testIdx)) / numel(yPred); fprintf(准确率: %.2f%%\n, accuracy*100);fitcknn实际是创建一个分类器对象ClassificationKNNpredict方法负责对新样本做预测。cvpartition是MATLAB内置的交叉验证工具上面用的HoldOut把70%样本划给训练集30%用于测试。NumNeighbors是k值它是KNN最重要的超参数后面会专门讲怎么选。Y是字符串元胞数组MATLAB对分类变量的支持直接体现在这里不需要手动编码成数字。2.2.2 让模型同时输出概率与邻居索引比赛里写论文时需要“可解释性”材料比如“这个样本被判为A类的依据是什么”。predict只能返回类别但fitcknn对象还有另一个方法predict后接多个输出参数或者用resubPredict拿邻居信息。[yPred, score, cost] predict(mdl, X(testIdx(1:5),:)); % yPred: 预测类别 % score: 各类别得分KNN得分是训练集中邻居类别的比例 % cost: 按误分类成本计算后的期望成本 % 另一个常用工具resubPredict返回训练集自身的邻居信息 resubPred resubPredict(mdl);score矩阵里每一行是每个测试样本被分到各个类别的概率估计。KNN的得分不是真正意义上经过校准的概率它只是邻居类别比例的归一化结果但在数学建模论文里足够表达不确定性。用score还能观察两个类别得分接近的样本这些往往是分类边界附近的“危险样本”值得在答辩时拿出来分析。3. 数学建模里的三组K近邻实战案例3.1 案例一分类建模中用交叉验证评估KNN3.1.1 用鸢尾花数据跑通完整分类流程很多数学建模赛题本质上是多分类问题例如根据土壤成分判断地质类型、根据光学特征判断目标类别。鸢尾花数据集结构简单、特征和类别都清晰适合作为KNN分类建模的沙盘。load fisheriris X meas; Y species; % 标准化数学建模必做的一步 mu mean(X); sigma std(X); X_scaled (X - mu) ./ sigma; % 五折交叉验证 mdl fitcknn(X_scaled, Y, NumNeighbors, 7, Standardize, false); cvmdl crossval(mdl, KFold, 5); loss kfoldLoss(cvmdl); % 损失函数默认是误分率 acc 1 - loss; fprintf(五折交叉验证准确率: %.2f%%\n, acc*100);上面代码先手动标准化再训练fitcknn里的Standardize参数没有重复开启。原因是我把标准化的主动权握在自己手里这样在后续做特征重要性分析时每个特征的实际尺度是已知的不会被fitcknn内部的标准化步骤遮挡。五折交叉验证用crossval实现kfoldLoss返回的是平均损失用1减去得到准确率这个值比单次随机划分训练集和测试集得到的准确率更稳定。3.1.2 不同k值下的分类效果对比固定其他条件把k从1变到15观察准确率变化。这一步在数学建模中属于“算法稳健性分析”很多评委喜欢看这类参数敏感性图表。kList 1:2:15; accList zeros(size(kList)); for i 1:numel(kList) cvmdl crossval(fitcknn(X_scaled, Y, NumNeighbors, kList(i)), KFold, 5); accList(i) 1 - kfoldLoss(cvmdl); end table(kList, accList, VariableNames, {k值, 交叉验证准确率})循环里每次都重建并交叉验证一个KNN模型数据量只有150个样本计算代价很低。输出表格可以看到当k太小时模型对噪声敏感k太大时不同类别的样本混入投票准确率下降。对鸢尾花来说k在5到9之间通常表现稳定。3.2 案例二回归预测连续变量3.2.1 用KNN做回归的固定套路KNN的分类和回归在MATLAB里是两条线fitcknn管分类fitrknn管回归。后者的预测值来自邻居标签的均值。rng(0); x linspace(0, 4*pi, 200); y sin(x) 0.2*randn(200, 1); % 带噪声的正弦曲线 % 训练KNN回归模型 mdlR fitrknn(x, y, NumNeighbors, 5, Standardize, true); % 生成更密的预测网格 xTest linspace(0, 4*pi, 500); yPred predict(mdlR, xTest); figure; scatter(x, y, 10, filled); hold on; plot(xTest, yPred, r-, LineWidth, 1.5); legend(含噪观测, KNN回归预测);fitrknn的调用方式与分类版几乎一致但底层用的距离搜索和投票规则不同。回归KNN在曲线光滑度上远不如样条插值但在数据波动大、无明确函数形式的赛题中它不需要任何先验假设。注意特征只有x一个维度时标准化与否影响不大一旦回归输入变成多个特征比如预测土壤含水量时同时用温度、湿度、光照标准化就是必需的前置步骤。3.2.2 用KDTree加速回归预测数学建模中如果训练样本达到十万级朴素的KNN每次预测都要全量扫一遍样本速度会拖慢计算。fitrknn里可以指定搜索方法。mdlR2 fitrknn(x, y, NumNeighbors, 5, ... NSMethod, kdtree, Distance, euclidean);NSMethod设为kdtree会构建KDTree来加速邻居搜索适合特征维度较低一般少于20维的情况。特征维度超过20后KDTree的检索优势会减弱此时用默认的exhaustive暴力搜索可能更稳定。数学建模赛题的数据维度通常在几十到几百之间选择哪一档需要看实际维度这条经验在后续调参时非常关键。3.3 案例三用KNN填补缺失值3.3.1 为什么数学建模数据清洗需要KNN赛题附件里经常出现缺失值常见处理是删行或填均值。删行浪费信息均值填充会改变数据分布。KNN填补的思路是找到目标样本在完整特征空间里的k个近邻用这些邻居在该缺失特征上的均值或中位数来填充。data [ 1.0 2.0 NaN; 1.1 2.1 9.0; 0.9 1.9 8.8; 5.0 6.0 NaN; 4.8 5.9 12.0; ]; % 找到含缺失值的行 missIdx find(any(isnan(data), 2)); for i 1:numel(missIdx) m missIdx(i); % 先暂时删掉该行避免用不完整样本作邻居 tmp data; tmp(m,:) []; colMiss isnan(data(m,:)); % 只使用没有缺失的特征计算距离 validCols ~colMiss; distToAll sqrt(sum((tmp(:,validCols) - data(m,validCols)).^2, 2)); [~, order] sort(distToAll); k 2; nearVals tmp(order(1:k), colMiss); % 用邻居均值填充缺失列 data(m, colMiss) mean(nearVals); end这段代码把KNN填补的逻辑压缩在了一个循环里对每个缺失样本先找到在非缺失维度上距离最近的k个完整样本再取这k个样本在缺失维度上的均值作为填充值。它的隐患是如果某个样本同时丢失多列特征距离计算只基于少数完整维度误差会放大。数学建模中更稳妥的做法是先用均值填充跑通流程再对比不同填充方式对最终模型准确率的影响。4. K近邻的关键参数怎么调k值、距离度量与特征标准化4.1 网格搜索寻找最优k值4.1.1 k的取值边界与常见误区k值太小时模型容易过拟合训练集准确率接近100%但测试集表现不稳定k太大时决策边界过于平滑小类别容易被大类别“淹没”。数学建模里一个反直觉的经验是k的取值不必追求奇偶数对称而应该与数据量和类别分布挂钩。当样本量不足100时k取3或5即可当样本量超过1000k取10到20比较合理。rng(123); X randn(200, 5); Y double(sum(X, 2) 0); % 人工构造二分类标签 kRange 1:2:31; cvAcc zeros(size(kRange)); for i 1:numel(kRange) mdl fitcknn(X, Y, NumNeighbors, kRange(i), Distance, euclidean); cvmdl crossval(mdl, KFold, 10); cvAcc(i) 1 - kfoldLoss(cvmdl); end [bestAcc, bestIdx] max(cvAcc); fprintf(最优k%d, 交叉验证准确率%.3f\n, kRange(bestIdx), bestAcc);这段网格搜索覆盖了k从1到31的所有奇数。选择奇数不是绝对要求而是为了避免二分类投票出现平票。实际赛题里我一般会把搜索范围扩大到偶数观察平票是否真实存在如果评分函数里写了平票时的处理逻辑奇偶就不再重要。crossval内部每次都会重随机分组所以同一k值在不同运行中会有一点点浮动严谨的做法是外层再套一层重复交叉验证取多次运行的平均值。4.1.2 用损失曲线判断k的敏感区间网格搜索的结果可以直接画成曲线观察“k值的稳定平台期”。如果准确率在k5到k15之间都维持在高位说明数据本身分类边界清晰对k不敏感如果准确率只在某个单点最高两侧快速下降说明数据噪声较大需要额外关注其他参数。4.2 距离度量的选择与标准化4.2.1 标准化是KNN的生命线KNN对特征尺度极其敏感。比如两个特征分别是身高180厘米和体重70公斤欧氏距离的计算中身高数值会完全压制体重的贡献。即使只是预测一个类别量纲不一致也会让邻居选择被高量纲特征主导。下列代码展示了标准化前后的差异。% 未标准化特征2的量纲远大于特征1 X1 [1 100; 2 200; 3 300; 100 500]; Y1 [0; 0; 1; 1]; mdlRaw fitcknn(X1, Y1, NumNeighbors, 3); % 标准化 X1S zscore(X1); mdlS fitcknn(X1S, Y1, NumNeighbors, 3);zscore按列做零均值单位方差标准化计算方式是每列减去均值再除以标准差。很多新手会在fitcknn里同时开启Standardize, true又在外面对数据手动标准化造成重复中心化虽然结果通常不差但会让代码难以解释。建议固定一个策略要么全部用fitcknn内部标准化要么全部由自己手动完成。4.2.2 不同距离度量在MATLAB中的写法除了欧氏距离数学建模中还常用到曼哈顿距离、切比雪夫距离和余弦距离。fitcknn通过Distance参数切换。Distance参数对应公式适用场景euclideanP2范数连续数值特征量纲一致时cityblock各维度绝对差之和高维稀疏特征对离群点更鲁棒chebychev各维度绝对差最大值强调最大差异的场景cosine1减去向量夹角余弦文本向量、方向敏感数据mdlRaw fitcknn(X_train, Y_train, NumNeighbors, 5, Distance, cityblock);余弦距离在高维文本特征中效果好于欧氏距离因为文本向量的模长受文档长度影响很大只关心方向不关心长度。但在数值型赛题里余弦距离并不多见需要特征本身具有方向语义才用得上。数学建模里最稳妥的路线还是先标准化再用欧氏距离跑一版然后用cityblock对比看准确率是否明显提升。4.3 维度灾难与特征选择4.3.1 高维度下邻居不再“近”当特征维度超过30欧氏距离的区分能力会迅速下降所有点之间的距离都变得差不多KNN的邻居选择几乎失效。数学建模赛题中如果遇到高维特征常见做法是先做特征选择保留与目标相关性高的特征。% 用fscmrmr做最小冗余最大相关性特征选择 [idx, scores] fscmrmr(X, Y); % idx返回特征索引按重要性从大到小排序 % 取前15个特征重新训练 X_sel X(:, idx(1:15)); mdl fitcknn(X_sel, Y, NumNeighbors, 7, Standardize, true);fscmrmr是MATLAB自带的多分类特征选择函数它基于最小冗余最大相关性准则选择特征速度快且稳。特征选择之后KNN的准确率经常不降反升“少即是多”在高维KNN场景里几乎是定论。比赛时如果特征数上百不要急着调k先砍特征这个顺序错了会事倍功半。4.3.2 当数据维度不高但样本不平衡时样本不平衡在高维分类里会放大k值的影响。多数类样本在空间中占据更大面积少数类样本容易被淹没。解决思路不是强行调小k而是用fitcknn的Prior参数给少数类更高先验权重这比调k更直接。5. 验证模型、加速计算与不平衡数据的处理技巧数学建模论文中光给出一个“预测准确率”是不够的评审更关心这个准确率是怎么算出来的。用crossval做k折交叉验证算是基本操作但KNN还有一个容易被忽视的验证陷阱交叉验证划分时必须保持类别比例。cvpartition(Y, KFold, 10, Stratify, true)这个写法在MATLAB新版本中并不总是连续可用的更稳的做法是先手工分层采样再用cvpartition的Stratify选项或者在crossval里传入类别信息让MATLAB自动处理。对于样本量达到万级的赛题KNN训练本身几乎不耗时但预测阶段会慢得明显。fitcknn提供两种加速手段一是NSMethod,kdtree二是并行预测。后者用predict(mdl, Xtest, UseParallel, true)即可但要先开启并行池parpool否则选项会被忽略。KDTree适合特征维度小于20的情况超过这个阈值建议还是用exhaustive或者先做PCA降维再进KNN。不平衡数据的处理除了调Prior另一个技巧是改投票规则。MATLAB的fitcknn自带ScoreTransform参数可以配合LossFun设计代价敏感的权重。比如把少数类的误判代价设为多数类的5倍模型会把决策边界往多数类方向推提升少数类召回率。这个操作在fitcknn里的写法是创建分类器后修改cost属性mdl fitcknn(X_train, Y_train, NumNeighbors, 7); mdl.Cost [0 1; 5 0]; % 假设类别是二分类 mdl fitcknn(X_train, Y_train, NumNeighbors, 7, Cost, mdl.Cost);这里Cost矩阵的行表示真实类别列表示预测类别Cost(2,1)5意味着把第2类预测成第1类要承担5倍代价。修改后再做交叉验证观察少数类召回率和总体准确率之间的取舍这是数学建模论文中“模型改进”部分非常实用的素材。实际比赛中先用默认参数跑通流程再集中调k值、标准化和特征维数最后用混淆矩阵和交叉验证把结果写进论文。如果手写版本和fitcknn版本结果不一致优先检查训练集和测试集的划分种子是否固定KNN对样本顺序并不敏感但对划分方式很敏感。本文还有配套的精品资源点击获取