Matlab中KNN与K-means区别及实战:手写分类器与加速优化 简介压缩包内为一份MATLAB实现的KNN算法示例代码面向机器学习入门者演示如何借助K均值聚类辅助完成K近邻分类任务适合需要快速上手KNN或对比两种经典算法的学习者。代码仅一个M文件压缩包大小六百二十一字节虽小巧但完整覆盖KNN核心流程包括数据标准化或归一化预处理、K值选取、距离计算欧氏距离、曼哈顿距离等与多数表决等步骤。K均值在此扮演数据预处理角色其簇中心可作为一种距离参考帮助缓解高维空间中的维度灾难这一思路值得深入学习。目前已有二百一十九人浏览学习可结合簇中心理解KNN中距离度量的改进也可将MATLAB实现直接移植到自有数据集上进行验证。对于希望理解两种算法内在联系并动手实践的用户这份代码提供了简洁实用的参照。1. KNN算法在Matlab里落地先别急着调fitcknn分清KNN和K-means再动手用Matlab做分类很多人第一反应是调fitcknn一行代码出结果。但真到了项目里——比如股票量化分析中给行情状态打标签或者图像特征分类——你很快就会撞上两个问题预测结果时好时坏样本量上来之后计算慢得离谱。网上流传的“KNN.rar”这类压缩包往往把K-means和KNN两种算法打包在一起名字像亲兄弟实际一个做无监督聚类一个做有监督分类K的含义也完全不同。这篇文章不讨论怎么解压那个rar而是把K-means与KNN在Matlab里的正确关系讲透给你一套能复现、能调参、能绕开常见坑的落地代码。2. 先看透KNN与K-means的边界监督、懒惰学习与迭代聚类的差异2.1 KNN的“训练”是存数据懒惰学习的代价与收益KNN算法在Matlab里的“训练”阶段几乎什么都不做。fitcknn返回的模型对象本质上保存了训练样本矩阵和标签外加你指定的邻居数、距离度量这些超参数。我常说它是懒惰学习不是贬义而是说它的计算全部发生在预测那一刻新样本进来和所有历史样本算一遍距离取最近的K个邻居投票。% 以内置的fisheriris为例训练一个5近邻KNN模型 mdl fitcknn(meas, species, NumNeighbors, 5); % 模型对象里实际存的就是训练数据和标签 X mdl.X; Y mdl.Y; disp(size(X)); disp(unique(Y));这段代码用来验证KNN的懒惰本质fitcknn执行后没有迭代没有梯度只是把X和Y原样挂到模型对象上。参数NumNeighbors就是K值决定每次投票让多少个邻居参与。这种设计的收益是简单、稳定对非线性边界不需要假设函数形式代价是预测阶段的时间和存储成本都随训练集规模线性上涨。十万条样本、每条样本算一次距离再排序取前K个单次预测的延迟就按毫秒到几十毫秒走。所以KNN适合中小样本、特征维度几十到几百的场景不适合动不动就千万级的在线推理。2.2 K-means的迭代本质K是簇数不是邻居数K-means是另一种“K”目标是给无标签数据划分成K个簇。它做的事情是反复迭代随机初始化K个质心把每个样本分给最近的质心再根据每个簇里的样本均值更新质心位置直到质心不再明显移动。% 用kmeans把样本聚成5簇 % idx: 每个样本所属簇编号, C: 每个簇的质心坐标 [idx, C] kmeans(meas, 5, Replicates, 10, MaxIter, 500); % 查看每个簇的样本数量 histcounts(idx);这里的Replicates是我每次必设的参数。kmeans的初始质心是随机的一次运行可能陷进局部最优重复10次能保留划分误差最小的一次结果。MaxIter控制单次迭代上限默认值偏小数据量大时不跑到收敛就提前停了。K-means聚出的每个簇没有语义标签只有编号。真正用它做分类辅助时需要人工给簇定义含义或者统计每个簇里已知类别的占比按多数类别给簇贴标签。维度KNNK-means学习方式有监督分类无监督聚类K的含义邻居个数簇个数训练阶段仅存储样本迭代更新质心典型用途分类、回归分组、数据压缩、异常发现Matlab常用函数fitcknn、手写距离计算kmeans这张表是我给团队讲这两个算法时的固定开场。KNN的K是投票人数K-means的K是分组数如果混着叫调参时必然乱套。2.3 为什么KNN和K-means总被放在同一个包里三种常见配合网上的Matlab算法包里经常同时出现K-means和KNN不是因为它们像而是因为它们互补。我实际用过的配合至少三种。第一种是伪标签。把无标签数据先用kmeans聚类把簇编号当成类别喂给KNN做后续样本的归类。这在业务冷启动时很有用比如刚接入一批不带标注的设备状态数据先用聚类粗分再让KNN对新数据做实时归类。% 先用K-means生成伪标签 [idx, ~] kmeans(unlabeledData, 5, Replicates, 10); % 把伪标签当训练标签训练KNN mdl fitcknn(unlabeledData, idx, NumNeighbors, 3);注意这里的伪标签只是工程手段不代表真实客观类别。用它训练KNN前最好抽一批样本人工确认簇的语义否则模型学到的边界可能跟业务真实边界对不上。第二种是原型选择。用kmeans把大量训练样本压缩成K个质心再用质心作为训练集喂给KNN训练集从十万级降到百级预测速度大幅提升。后面第4章展开讲。第三种是状态匹配。在股票量化分析里先用K-means把历史行情切成几个状态簇比如震荡、上涨、下跌再用KNN判断当前K线特征接近哪个状态簇。这种做法的好处是状态划分可解释坏处是K-means的簇边界本身是线性的状态切换瞬间容易误判属于典型的“聚类粗筛近邻细分类”场景。3. 用Matlab手写一个KNN分类器距离度量、K值选择与加权投票全放进去3.1 最小可跑的myknn函数从pdist2到投票判决fitcknn虽然方便但黑匣子味道太重。我建议至少手写一版KNN理解每个环节之后再回到内置函数也不迟。先上一个最小实现只用Matlab基础函数。function pred myknn(trainData, trainLabel, testData, K) % 手写KNN分类器 % trainData: 训练样本矩阵每行一个样本 % trainLabel: 训练标签列向量 % testData: 测试样本矩阵每行一个待预测样本 % K: 邻居个数 [nt, ~] size(testData); pred zeros(nt, 1); for i 1:nt % 计算当前测试样本与所有训练样本的欧氏距离 diff trainData - testData(i, :); dist sqrt(sum(diff .^ 2, 2)); % 按距离升序排序取前K个邻居 [~, idx] sort(dist); idx idx(1:K); % 邻居标签投票mode取出现次数最多的值 labels trainLabel(idx); pred(i) mode(labels); end end这段代码的核心逻辑就三步广播计算差向量、按行求平方和再开方、排序取前K投票。需要注意训练数据和测试数据必须保证维度一致否则diff会报维度错误。trainLabel建议用数值型列向量mode函数对数值最友好如果标签是字符串或categorical最好先grp2idx转成数值编码。K值的设定直接影响过拟合。K1时模型完全信任距离最近的单个样本噪声点附近会跟着错K太大时投票会把远处样本也拉进来边界变平滑但也变模糊。通常先取奇数二分类时能避免平票。3.2 距离度量不该写死用pdist2支持欧氏、曼哈顿和余弦上面的程序写死了欧氏距离真实项目里往往需要换距离。Matlab的pdist2函数能一次算成对的任意距离同样一个循环体只需要传一个距离指标参数。function pred myknn2(trainData, trainLabel, testData, K, distMetric) % distMetric 支持 euclidean / cityblock / cosine [nt, ~] size(testData); pred zeros(nt, 1); for i 1:nt % pdist2返回当前测试样本与所有训练样本的距离向量 dist pdist2(testData(i, :), trainData, distMetric); [~, idx] sort(dist); idx idx(1:K); pred(i) mode(trainLabel(idx)); end endpdist2第一个参数是观测点第二个参数是训练集返回一行列向量。换成cityblock就是曼哈顿距离对异常值比欧氏稳健一些。换成cosine就是余弦相似度适合文本特征或高维稀疏向量。距离度量适用场景注意事项euclidean连续数值特征、量纲已统一特征尺度不一致时会被大数值特征主导cityblock含噪声或异常值较多的特征对维度之间的补偿关系更稳健cosine文本、稀疏高维向量只关心方向不关心长度选距离不是玄学先看特征语义再看数据分布。如果所有特征都是同一物理量比如温度曲线各点欧氏距离最自然如果是用户行为特征不同维度量纲跨度大优先标准化后再用欧氏。3.3 加权投票距离倒数作为权重避免被远处样本带偏多数投票的一个隐患是5个邻居里近的3个投类别A但离得较远的2个投类别BA赢得很勉强。更合理的做法是让距离近的样本拥有更大话语权。function pred myknn_weighted(trainData, trainLabel, testData, K) [nt, ~] size(testData); pred zeros(nt, 1); for i 1:nt dist pdist2(testData(i, :), trainData, euclidean); [d, idx] sort(dist); d d(1:K); idx idx(1:K); labels trainLabel(idx); % 距离倒数作为权重距离为0时加eps防止除零 w 1 ./ (d eps); % 按类别累加权重 classes unique(trainLabel); score zeros(length(classes), 1); for c 1:length(classes) score(c) sum(w(labels classes(c))); end [~, best] max(score); pred(i) classes(best); end end这里的核心改动是w 1 ./ (d eps)距离越近权重越大。eps是一个极小的正数专门防止某个邻居与测试样本完全重合时距离为0导致除零。加权投票能明显改善边界样本的预测稳定性代价是要多维护一个类别列表并对每个类别累加权重。如果样本量很大、类别又多这段内层循环会成为瓶颈可以改用accumarray按类别向量化累加。4. 用K-means给KNN提速聚类索引、原型选择与边界代价4.1 KNN的预测延迟当训练集到十万级时全量距离算不动KNN的惰性学习在训练集规模上来之后非常尴尬。一个十万行、五十维特征的训练集单次预测要算十万次距离加起来是五百万次浮点运算再排序取前K。Matlab里用循环实现单条预测可能要几十毫秒放到实时股票量化分析里每秒处理几十个新样本就会卡顿。我最早做这类优化时第一反应是上KDTreeSearcher。Matlab确实提供了这个封装构建树之后检索复杂度能降不少但KD树在高维空间里效率衰减很快。另一种思路更直观先用K-means把训练数据粗分成几十个簇预测时先定位最近的簇再只在簇内做KNN。这本质是“粗筛加细分类”用聚类中心做索引。4.2 方法一聚类中心粗筛簇内再做KNN下面这个函数把K-means和KNN串起来训练阶段先对训练集聚类预测阶段先找最近的簇中心再只把该簇内的训练样本作为近邻候选池。function pred knn_with_km(trainData, trainLabel, testData, K, numCluster) % 先用K-means对训练集聚类 [idx, center] kmeans(trainData, numCluster, Replicates, 5); % 提前算好每个测试样本最近的簇中心编号 [~, testCluster] pdist2(center, testData, euclidean, Smallest, 1); [nt, ~] size(testData); pred zeros(nt, 1); for i 1:nt ci testCluster(i); memberIdx find(idx ci); % 簇内样本太少时回退到全量训练集 if length(memberIdx) K subData trainData; subLabel trainLabel; else subData trainData(memberIdx, :); subLabel trainLabel(memberIdx, :); end dist pdist2(testData(i, :), subData, euclidean); [~, sortIdx] sort(dist); pred(i) mode(subLabel(sortIdx(1:K))); end end代码里pdist2(center, testData, Smallest, 1)返回的是一个索引向量每一列对应一个测试样本最近的簇中心编号。这个提前计算可以避免在循环里反复对中心矩阵做距离计算。numCluster选多少是门学问。经验上是取训练样本数的1%到5%或者接近sqrt(N)。簇数太少每个候选池仍然很大加速不明显簇数太多单个簇样本量少旁边的真实邻居容易被排除掉。这个方案的代价在簇边界如果测试样本恰好落在两个簇的交界处只选最近一个簇就会漏掉真正属于邻近簇的邻居。我的处理方式是扩充候选范围把“最近1个簇”换成“最近2到3个簇”。% 取最近的3个簇作为候选池 [~, candidateIdx] pdist2(center, testData(i, :), euclidean, Smallest, 3); memberIdx find(ismember(idx, candidateIdx));ismember会把属于这三个簇的所有训练样本都拉进候选池。候选池一般还是远小于全量训练集速度依然比全量KNN快一个数量级而准确率损失大幅下降。4.3 方法二用K-means做原型选择减少训练集规模另一个思路是直接给训练集“减肥”对每个类别分别做K-means把得到的簇中心作为新的训练样本簇的标签按该簇多数类决定。训练集从N降到KKNN计算量随之下降。function [protoData, protoLabel] prototype_selection(trainData, trainLabel, perClassCluster) % perClassCluster: 每个类别保留多少个原型簇中心 classes unique(trainLabel); protoData []; protoLabel []; for c 1:length(classes) classData trainData(trainLabel classes(c), :); kc min(perClassCluster, size(classData, 1)); [~, center] kmeans(classData, kc, Replicates, 3); protoData [protoData; center]; protoLabel [protoLabel; repmat(classes(c), kc, 1)]; end end这段代码先按类别把训练集拆开再对每个类别单独聚类。perClassCluster通常设为每个类别样本量的1%到5%如果原始样本只有几十个就取原始样本数不再压缩。原型选择是典型的有损压缩。簇中心的分布能反映类别大致的中心区域但会丢掉类边界附近的重要样本。我一般会在压缩前后各跑一遍交叉验证对比错误率如果准确率掉得超过两个百分点就调大perClassCluster或者保留一部分边界样本。5. KNN与K-means混用避坑指南五个我后悔没早知道的坑5.1 量纲不一致欧氏距离被大数值特征主导现象用KNN分类时模型几乎只关心数值范围最大的那一两列特征其他特征变成了摆色交叉验证准确率怎么调都上不去。原因欧氏距离对量纲非常敏感。比如一个特征取值范围是0到1另一个特征是0到10000距离计算时后者稍微波动一点就把前者的差异淹没了。解决训练前先做标准化最好用zscore而且必须用训练集算出来的均值和标准差去归一化测试集不能把测试集混进来一起算否则会造成信息泄漏。mu mean(trainData); sigma std(trainData); trainData (trainData - mu) ./ sigma; testData (testData - mu) ./ sigma;这段代码是KNN项目里的第一步几乎所有后续调参都建立在标准化的基础上。fitcknn函数里有Standardize, true这个参数效果类似但手写方案时千万别漏。5.2 K值取偶数导致平票mode的隐式行为坑人现象二分类任务里K设为2预测结果时对时错单独看某条样本会发现两个邻居类别各一票投票结果像随机选的。原因mode函数在出现多个最高频值时会返回数值最小的那个类别。如果类别标签0代表“正常”1代表“故障”平票时永远偏向0看起来就是偶发性漏报。解决最简单是把K设为奇数。但多分类任务中奇数K也可能平票更稳妥的做法是显式处理平票统计每个候选类别的邻居数量如果最高票并列再比较并列类别的平均距离距离和更小的一方胜出。实质上就是加权投票的思路所以我在手写版本里干脆都启用加权。5.3 K-means初始质心随机聚类结果不稳定现象同一份数据同样设K为30跑两次kmeans得到的簇编号完全不一样导致后续KNN的预测结果也跟着变。原因kmeans的初始质心是随机选择的不同的起点可能收敛到不同的局部最优。簇变了原型的选取也变了KNN的候选池自然就不稳定。解决用随机种子把kmeans固定下来同时加大Replicates次数。rng(42); % 固定随机种子让结果可复现 [idx, center] kmeans(trainData, 30, Replicates, 20, ... Options, statset(UseParallel, true));rng是调试后悔药不设seed的聚类结果今天一个样明天一个样。Replicates设为20意味着重复20次聚类取误差最小的结果。UseParallel需要并行工具箱没有的话去掉也行只是慢一点。5.4 只用最近簇中心粗筛在簇边界翻车现象用K-means加速KNN后整体准确率比全量KNN低了三五个点检查错误样本发现集中在两个簇的交界区域。原因测试样本真正最近的邻居分布在邻近簇里但簇中心粗筛只选了最近一个簇把真实邻居排除在候选池外。解决把候选簇从1个扩展到3个左右前面4.2节已经给出ismember的写法。这里的关键权衡是候选簇数量越大越接近全量KNN加速效果越小。建议用一小部分验证集试跑观察准确率和延迟的拐点。5.5 fitcknn不调参就上路预测结果像摇骰子现象直接用fitcknn默认参数训练做了几轮预测曲线看着波动很大换一批测试数据结果就对不上了。原因默认参数下K往往很小相当于只看最近的一两个样本对噪声和离群点几乎没有抵抗力。解决先把K通过交叉验证选出来再显式传给fitcknn同时打开标准化选项。mdl fitcknn(trainData, trainLabel, ... NumNeighbors, 7, ... Distance, euclidean, ... Standardize, true);不要以为封装函数会自己优化这些超参数fitcknn的默认值只是保证“能跑”不保证“跑得好”。我见过太多人把fitcknn当黑匣子出问题就换函数其实他们缺的就是手动选K这一步。6. 用交叉验证选K值把错误率曲线画出来再上路6.1 用K折交叉验证找出稳定K值选K值最可靠的办法不是拍脑袋而是画一条交叉验证错误率随K变化的曲线。下面这段代码用fitcknn做5折交叉验证K从1扫到15步长为2把每个K的验证错误率画出来。rng(42); cv cvpartition(species, KFold, 5); KList 1:2:15; err zeros(length(KList), 1); for ki 1:length(KList) foldErr zeros(cv.NumTestSets, 1); for f 1:cv.NumTestSets trIdx cv.training(f); teIdx cv.test(f); mdl fitcknn(meas(trIdx,:), species(trIdx), ... NumNeighbors, KList(ki), Standardize, true); pred predict(mdl, meas(teIdx,:)); foldErr(f) 1 - mean(pred species(teIdx)); end err(ki) mean(foldErr); end plot(KList, err, o-); xlabel(K); ylabel(5-Fold Cross-Validation Error);cvpartition把数据均匀分成5份每次用4份训练、1份验证。外层循环扫K内层循环跑完5折取平均这一条曲线比任何经验公式都直观。选K时不要只盯最低点看整条曲线如果K5和K9的错误率只差0.1%我一般选5因为更小的K意味着更快的预测和更简单的边界。如果用的是自己手写的myknn在交叉验证前必须记得对每一折都用训练折的均值和标准差去标准化验证折不能用全量数据的统计量。fitcknn的Standardize参数内部会自动按训练折处理这也是我推荐新手直接用fitcknn跑交叉验证的原因。我自己做KNN项目已经形成习惯先标准化全数据再跑一遍这条错误率曲线确认错误率平台区然后选择平台区左侧的K值。很多调参翻车其实不是算法问题而是连一张这样的曲线都没画过。KNN不是一个复杂的模型但它的效果高度依赖K、距离度量和数据尺度这三样东西每一项都值得用交叉验证去检验。希望这套流程能帮你的Matlab KNN项目少走点弯路。本文还有配套的精品资源点击获取