ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

手写Matlab KNN实现:从距离矩阵到交叉验证全流程

手写Matlab KNN实现:从距离矩阵到交叉验证全流程 简介面向模式识别、机器学习课程学习者与 Matlab 编程入门者的 KNN 实践材料围绕 KNN 分类原理与代码实现展开可用于课堂作业、课程设计及分类实验复现。压缩包共 3 个文件均为 .m 脚本或函数文件整体约 3KB轻量且便于在 Matlab 中直接阅读、运行和修改内容覆盖数据预处理、距离计算、K 值选择、最近邻筛选与类别投票等关键环节。材料结合“第六章 KNN 编程作业”的示例与说明并涉及剪辑近邻法通过阈值筛选邻居以降低噪声干扰的思路方便读者对照理解算法流程、调试代码并完成自己的分类任务。已有 4444 人学习下载适合作为 KNN 入门实践与作业参考的轻量资料。1. 从一张分不开的散点图说起KNN 为什么总排在模式识别课的第一位模式识别这门课的第一个上机任务十有八九是甩给你一份带标签的数据让你用 KNN 算法跑个分类 baseline。数据发下来Matlab 一开很多人第一反应是敲一行fitcknn交差——能出准确率但被追问一句「距离怎么算的、k 怎么定的、平票怎么处理」当场卡壳。这套 Matlab 实现就是为这个场景准备的。它不依赖任何工具箱从距离矩阵、近邻选取、投票规则到交叉验证选 k一层层拆开写总共不到两百行。跑通之后距离函数、投票权重、k 的搜索网格都可以随手替换把它当后续实验的底座用。适合正在做课设、想搞清楚每一行在算什么的人也适合已经会用工具箱、但想掀开盖子看看齿轮怎么咬合的人。2. 工程骨架一份能直接跑的 KNN Matlab 实现由哪几个文件组成2.1 目录结构与每个文件的职责先把文件拆开。见过太多把 KNN 塞进一个main.m里写到四百行的作业后面想换一种距离度量得在三处地方同时改改漏一处就跑出个莫名其妙的结果排查起来纯属玄学。按职责切分的版本大概长这样knn_lab/ ├── main.m % 入口脚本: 造数据 → 切分 → 归一化 → 选k → 预测 → 评估 ├── make_demo_data.m % 生成带标签的合成数据, 用来打通流程 ├── split_dataset.m % 分层切分, 固定随机种子保证可复现 ├── normalize_zscore.m % z-score 归一化, 统计量只从训练集算 ├── knn_distance.m % 距离矩阵, 度量方式在这里替换 ├── knn_classify.m % 核心分类器: 取近邻 投票 ├── cross_validate_k.m % K 折交叉验证扫 k 网格 ├── evaluate_model.m % 混淆矩阵与逐类召回率 └── data/ └── features.csv % 换成自己的数据放这里文件职责是否依赖工具箱main.m串起全流程的入口脚本否split_dataset.m按类别分层切分训练/测试集否normalize_zscore.m特征标准化返回 mu/sigma 供推理复用否knn_distance.m计算 M×N 距离矩阵支持欧氏/余弦否knn_classify.m近邻选取、加权投票、平票处理否cross_validate_k.mK 折交叉验证输出准确率曲线否evaluate_model.m手写混淆矩阵算 P/R/F1否这样拆的好处很直接换距离度量只动knn_distance.m一个文件换投票策略只动knn_classify.m里那七八行两边互不干扰。做消融实验的时候改动面越小越不容易翻车。2.2 运行环境与最小依赖整套代码只用到 Matlab 的基础语法。R2016b 之后支持隐式扩展M×1和1×N相加自动广播成M×Nvecnorm从 R2017b 开始才有所以我把距离计算写成sum(X.^2, 2)的形式老版本也能跑。rng、sort、unique、sub2ind全是基础函数不需要额外装任何东西。需要说明的是knnsearch和fitcknn这两个函数——它们属于 Statistics and Machine Learning Toolbox。第 4 章会拿来做提速对比但那不是必需品手写版本单独就能跑完整条链路。如果你所在的环境没有这个工具箱跳过对比实验即可。入口脚本main.m按「数据 → 切分 → 归一化 → 选参 → 预测 → 评估」六步串起来每一步落一个函数调用%% main.m —— KNN 全流程入口 clear; clc; % 1) 造一份三类二维数据, 换成自己的 csv 只改这一行 [X, y] make_demo_data(300, 3, 42); % 2) 分层切分 7:3, 种子固定 [XTrain, yTrain, XTest, yTest] split_dataset(X, y, 0.3, 42); % 3) 归一化: mu/sigma 只从训练集统计, 测试集直接套用 [ZTrain, ZTest, mu, sigma] normalize_zscore(XTrain, XTest); % 4) 五折交叉验证扫 k 网格 kList 1:2:21; [bestK, accCurve] cross_validate_k(XTrain, yTrain, kList, 5, 7); fprintf(best k %d, cv acc %.4f\n, bestK, max(accCurve)); % 5) 用最优 k 预测, 开启距离加权 t0 tic; [yPred, score] knn_classify(ZTrain, yTrain, ZTest, bestK, ... struct(weighted, true, metric, euclidean)); fprintf(predict %d samples in %.1f ms\n, numel(yTest), toc(t0) * 1000); % 6) 评估 stats evaluate_model(yTest, yPred); fprintf(test accuracy %.4f\n, stats.accuracy); disp(stats.cm);make_demo_data负责在二维平面上撒几团高斯分布的点类中心均匀铺在半径 4 的圆上噪声取单位方差。它的价值不在于数据有多真实而在于「答案已知」——三团点分得开任何分类器的准确率都应该在 0.95 以上如果跑出来只有 0.6那一定是链路里某个环节出了问题而不是数据太难。做算法调试时一组答案已知的合成数据比真实数据好用十倍。function [X, y] make_demo_data(nPerClass, nClass, seed) %MAKE_DEMO_DATA 生成若干二维高斯簇, 用于快速验证链路是否打通 rng(seed); theta linspace(0, 2*pi, nClass 1); theta(end) []; centers 4 * [cos(theta), sin(theta)]; % 类中心均匀铺在半径 4 的圆上 X zeros(nPerClass * nClass, 2); y zeros(nPerClass * nClass, 1); for c 1:nClass rows (c-1) * nPerClass (1:nPerClass); X(rows, :) centers(c, :) randn(nPerClass, 2); % 单位方差噪声 y(rows) c; end end参数里nPerClass控制每类样本量nClass控制类别数seed交给rng保证同一份数据多次生成结果一致。噪声标准差固定为 1如果想让问题变难把它改成 2 或者 3类之间就开始重叠这时候 k 的取值和加权策略的影响才会明显体现出来。2.3 数据格式约定特征矩阵与标签向量怎么排KNN 的实现里最容易埋雷的地方不是算法是数据格式。约定只有一条X是N×D的 double 矩阵行是样本、列是特征y是N×1的整数向量取值必须是1..C的连续正整数。后面投票时用score(:, c)按类号索引标签一旦出现 0 或者跳号比如 1、3、7score 矩阵的列就对不上了。真实数据多半从 csv 进来读完先做一次标签重编号T readtable(data/features.csv); X table2array(T(:, 1:end-1)); % 前 D 列是特征 rawLabel T{:, end}; % 最后一列是标签, 可能是字符串或数值 % unique 的第三个输出就是 1..C 的重编号, 字符串/数值通吃 [~, ~, y] unique(rawLabel); y y(:); % 强制列向量 X double(X); % 防止某些列被读成整型导致除法截断unique的第三个返回值是索引映射比如原始标签是{cat,dog,cat}重编号后变成[1;2;1]类别名通过unique的第一个输出保留下来评估时想显示原名再映射回去就行。double(X)那句看着多余实际上很关键readtable读整数列时会给int64类型后面算X - mu时整型除法的截断会让归一化彻底失效报错还特别隐蔽。切分函数用分层策略保证每个类在测试集里都有代表function [XTrain, yTrain, XTest, yTest] split_dataset(X, y, testRatio, seed) %SPLIT_DATASET 按类别分层切分, 避免小类在测试集里缺席 if nargin 4, seed 42; end rng(seed); classes unique(y); idxTrain false(numel(y), 1); for c 1:numel(classes) idx find(y classes(c)); idx idx(randperm(numel(idx))); % 类内打乱 nTest max(1, round(numel(idx) * testRatio)); % 每类至少留 1 个 idxTrain(idx(nTest1:end)) true; % 剩下的进训练集 end XTrain X(idxTrain, :); yTrain y(idxTrain); XTest X(~idxTrain, :); yTest y(~idxTrain); endtestRatio一般取 0.2 到 0.3样本量小于 500 时建议走交叉验证而不是单次切分否则测试集太小准确率的方差能大到让你怀疑人生。max(1, ...)这个保底是为了防止某个类只有三五个样本时测试集里一个都没有那样混淆矩阵会缺行后面算召回率直接除零。3. 核心链路拆解从欧氏距离矩阵到多数投票的每一步3.1 距离矩阵一次矩阵乘法顶掉两重循环KNN 的计算量全在距离上。最直观的写法是两层for循环测试样本一个、训练样本一个逐个算欧氏距离1000×2000的数据在 Matlab 里能跑上十几秒。向量化的做法是把平方欧氏距离展开$$|a-b|^2 |a|^2 |b|^2 - 2a^\top b$$右边第一项是测试集每行的模长平方第二项是训练集每行的模长平方第三项是一次矩阵乘法XTest * XTrain。三个都是高度优化的 BLAS 调用写出来就是十几行function D2 knn_distance(XTrain, XTest, metric) %KNN_DISTANCE 计算测试集到训练集的平方距离矩阵 % D2(i,j) 第 i 个测试样本到第 j 个训练样本的距离 if nargin 3, metric euclidean; end switch lower(metric) case euclidean aa sum(XTest.^2, 2); % M×1, 测试集样本模长平方 bb sum(XTrain.^2, 2); % 1×N, 训练集样本模长平方 D2 aa bb - 2 * (XTest * XTrain); % 隐式扩展成 M×N D2(D2 0) 0; % 浮点相减可能出 -1e-16 case cosine Xn XTest ./ sqrt(sum(XTest.^2, 2)); Yn XTrain ./ sqrt(sum(XTrain.^2, 2)); D2 1 - Xn * Yn; % 余弦距离, 范围 [0, 2] otherwise error(unsupported metric: %s, metric); end endD2(D2 0) 0这一行不是洁癖。两个几乎相同的向量做减法浮点误差会让结果出现-1e-16这种负数后面sqrt一下直接变成复数报错信息还指向别处非常难查。截断到 0 成本极低但没有它我至少浪费过一个下午。metric参数是给二次开发留的口子。改成cosine之后注意weighted加权投票逻辑里的1/(deps)依然成立只是物理含义从「距离倒数」变成了「相似度倒数」语义上要自己心里有数。这块第 6 章会再展开。3.2 取前 K 个近邻全排序、局部选择与索引复用拿到 M×N 的距离矩阵之后要对每一行找出最小的 K 个。最省事的写法是sort(D2, 2, ascend)拿到排序后的距离和对应的原始列下标取前 K 列就行。全排序每行是 $O(N\log N)$而我们只需要前 K 个理论上有更快的选择算法。Matlab 从 R2017b 开始提供mink做的是部分选择K 远小于 N 时比sort快不少K min(k, size(XTrain, 1)); % k 不能超过训练样本总数 if exist(mink, file) [dK, idx] mink(D2, K, 2); % R2017b: 每行取最小的 K 个, 返回距离和下标 else [~, idx] sort(D2, 2, ascend); % 老版本回退到全排序 idx idx(:, 1:K); dK D2(sub2ind(size(D2), repmat((1:size(D2,1)), 1, K), idx)); endmink的第二个参数是取多少个第三个参数2表示沿列方向也就是每行内部取。idx拿到的是 M×K 的下标矩阵每一行是这 M 个测试样本各自的 K 个最近训练样本的列号这个矩阵后面投票时要用两次——一次取标签一次取距离。sub2ind那行是把二维下标转成线性下标用于从原始的D2里把对应的距离捞出来。它等价于用循环写for i1:M, dK(i,:) D2(i, idx(i,:)); end但省掉了解释器开销。数据量上万以后这种细节的差距是能感觉到的。3.3 多数投票score 矩阵怎么建加权与平票怎么处理核心分类器把前面两步拼起来再加一个投票环节。思路是给每个测试样本建一个长度 C 的得分向量K 个邻居里属于第 c 类的有几个第 c 位就加几分最后取maxfunction [yPred, score] knn_classify(XTrain, yTrain, XTest, k, opts) %KNN_CLASSIFY 向量化 KNN 分类器 % XTrain/yTrain: 训练特征与标签(标签须为 1..C 连续整数) % XTest: 测试特征(用训练集的 mu/sigma 归一化过) % k : 近邻个数 % opts : .weighted(默认 false) / .metric(默认 euclidean) if nargin 5, opts struct(); end if ~isfield(opts, weighted), opts.weighted false; end if ~isfield(opts, metric), opts.metric euclidean; end nTest size(XTest, 1); nClass max(yTrain); % 标签必须是 1..C K min(k, size(XTrain, 1)); D2 knn_distance(XTrain, XTest, opts.metric); % M×N [~, idx] sort(D2, 2, ascend); idx idx(:, 1:K); % M×K 近邻下标 rows repmat((1:nTest), 1, K); dK D2(sub2ind(size(D2), rows, idx)); % M×K 近邻距离 yNb yTrain(idx); % M×K 近邻标签 if opts.weighted W 1 ./ (dK 1e-12); % 距离越小, 投票权重越大 else W ones(nTest, K); end score zeros(nTest, nClass); for c 1:nClass score(:, c) sum(W .* (yNb c), 2); % 逐类累加票数 end [maxScore, yPred] max(score, [], 2); tie sum(score maxScore, 2) 1; % 平票的行 if any(tie) yPred(tie) yTrain(idx(tie, 1)); % 平票退化成 1-NN end endyTrain(idx)这一句依赖yTrain是列向量idx是矩阵Matlab 会返回同形状的矩阵省掉一层循环。后面的yNb c是逐元素比较sum(..., 2)按行求和整个投票过程没有显式的样本循环。平票是必须处理的分支。K 取偶数时比如 k4两类各 2 票平票是必然发生的max默认返回第一个最大值的下标这就等于「类别号小的赢」纯属实现细节带来的偏见跟算法本身没关系。我一般让平票退化成 1-NN取距离最近的那个邻居的标签——这比按类别号拍脑袋合理得多。1e-12的 eps 是为了防除零归一化之后训练集和测试集里可能出现完全相同的样本距离为 0倒数变成Inf乘上 0 票会变成NaN整个 score 就废了。3.4 K 折交叉验证k 的候选网格怎么扫归一化为什么必须放进折内k 是这套算法唯一真正需要调的超参数靠拍脑袋定下来不合适。做法是划一个候选网格1、3、5…21每一折在训练部分上归一化、训练、在验证部分上打分取平均准确率最高的那个function [bestK, accCurve] cross_validate_k(X, y, kList, nFold, seed) %CROSS_VALIDATE_K K 折交叉验证扫 k 网格 if nargin 5, seed 7; end rng(seed); n size(X, 1); perm randperm(n); foldId zeros(n, 1); foldId(perm) mod(0:n-1, nFold) 1; % 尽量均分到各折 accCurve zeros(numel(kList), 1); for ki 1:numel(kList) k kList(ki); acc zeros(nFold, 1); for f 1:nFold te foldId f; tr ~te; % 关键: 归一化放在折内, 统计量只来自当前折的训练部分 [Ztr, Zte] normalize_zscore(X(tr, :), X(te, :)); yp knn_classify(Ztr, y(tr), Zte, k); acc(f) mean(yp y(te)); end accCurve(ki) mean(acc); end [~, iBest] max(accCurve); bestK kList(iBest); endfoldId(perm) mod(0:n-1, nFold) 1这行是均分技巧先把样本随机打乱再按位置轮流发到各折样本数不能被折数整除时误差也只有 1比按固定块切分靠谱。nFold一般取 5 或 10样本量很小时可以用留一法nFold n但代价是训练次数等于样本数。归一化写在折内这件事值得单独强调。如果先在全体数据上算mu/sigma再做交叉验证验证折的信息已经通过统计量泄漏进了训练过程交叉验证的准确率会系统性偏高通常高出一两个点看起来不明显但足以让你选错 k。这种错误的可怕之处在于它不会报错也不会崩只是让你拿到的数字不再可信。accCurve顺手返回出来画条曲线看看拐点比只看最小值更有信息量。3.5 评估混淆矩阵、逐类召回率与手写累加confusionmat属于工具箱函数为了保持零依赖这里手写累加function stats evaluate_model(yTrue, yPred) %EVALUATE_MODEL 混淆矩阵与逐类指标, 不依赖工具箱 C max([yTrue(:); yPred(:)]); cm zeros(C, C); for i 1:numel(yTrue) cm(yTrue(i), yPred(i)) cm(yTrue(i), yPred(i)) 1; end tp diag(cm); stats.cm cm; stats.accuracy sum(tp) / sum(cm(:)); stats.recall tp ./ max(sum(cm, 2), 1); % 行和 真实类样本数 stats.precision (tp ./ max(sum(cm, 1), 1)); % 列和 预测类样本数 stats.f1 2 * stats.precision .* stats.recall ... ./ max(stats.precision stats.recall, eps); endcm(i,j)表示真实类别是 i、被预测成 j 的样本数对角线是分对的。行方向求和得到每个真实类的样本总数用来算召回率列方向求和得到每个预测类的样本总数用来算精确率。max(..., 1)是为了防止某个类在测试集里完全缺席时除零算出来是 0 而不是NaN至少不会污染后面的平均值。只看总体准确率在类别不平衡时会骗人。三类数据里有一类占了 90%全判成这一类也能拿到 0.9 的准确率。把stats.recall打出来逐类看哪个类被打成了筛子一目了然比盯一个标量数字有用得多。4. 性能与精度调优让手写 KNN 从玩具代码变成实验工具4.1 复杂度账本训练是假的预测才是真的贵KNN 是懒惰学习的典型训练阶段什么也不做就是把数据存起来复杂度 O(1)所有开销堆在预测阶段每个测试样本要和全部 N 个训练样本算距离总复杂度 $O(M \cdot N \cdot D)$。这个不对称性意味着「训练快」根本不是优点真正要盯的是预测耗时和内存。内存这一侧更隐蔽。M×N 的距离矩阵是 double 类型每个元素 8 字节M5000, N20000就是 800MB稍微再大一点直接 out of memory。而这个矩阵只用来取前 K 个最小值绝大部分内容算完就扔占着内存纯属浪费。4.2 分块计算把 M×N 矩阵拆成若干条带解决办法是按测试样本分块一次只算一块的距离矩阵算完取完近邻就释放function yPred knn_predict_chunked(XTrain, yTrain, XTest, k, blockSize) %KNN_PREDICT_CHUNKED 分块预测, 把 M×N 距离矩阵压到 blockSize×N if nargin 5, blockSize 1000; end nTest size(XTest, 1); yPred zeros(nTest, 1); for s 1:blockSize:nTest e min(s blockSize - 1, nTest); yPred(s:e) knn_classify(XTrain, yTrain, XTest(s:e, :), k); end endblockSize是唯一要调的参数取值取决于训练集大小和可用内存。经验公式是让blockSize * N * 8控制在内存的三分之一以内N20000 时blockSize 取 1000 大概占 160MB很安全N100000 时就得降到 200 以下。分块之后总计算量不变但峰值内存降了一个数量级而且因为矩阵变小了缓存命中率反而更高实测常常比分块前还快一点。4.3 parfor 与 knnsearch两条提速路线怎么选再往上提速有两条路。第一条是并行把分块循环换成parfor前提是已经装了 Parallel Computing Toolbox 并且开了并行池parfor s 1:blockSize:nTest e min(s blockSize - 1, nTest); yPred(s:e) knn_classify(XTrain, yTrain, XTest(s:e, :), k); end注意parfor里不能有跨迭代的共享状态yPred用切片方式赋值是合法的但循环内不能再rng或者写全局变量。加速比取决于核数4 核大概能到 2.5 到 3 倍核再多收益递减——瓶颈会转移到内存带宽上。第二条路是直接调用工具箱的knnsearch% 需要 Statistics and Machine Learning Toolbox [idx, d] knnsearch(XTrain, XTest, K, k, NSMethod, kdtree); yNb yTrain(idx); % 后面接同样的投票逻辑NSMethod有两个取值exhaustive是暴力搜索kdtree建 KD 树。KD 树在低维D 小于 10 左右时优势明显建树一次、查询多次维度一高就退化得厉害「维数灾难」在 KNN 里体现得特别直接D 到 50 以上时 KD 树可能比暴力搜索还慢因为这个剪枝结构在高维空间里几乎失效。方案适用场景内存峰值依赖相对耗时双重循环只用来对拍验证正确性最低无最慢向量化全量N×M 小于 1e7高无快分块向量化大数据集常规方案可控无快分块 parfor多核机器、离线实验可控并行工具箱更快knnsearch kdtree低维、多次查询低统计工具箱低维最快表里的「相对耗时」是定性排序具体倍数跟机器、维度、数据分布都有关系别照抄。想拿到自己环境下的真实数字写个 benchmark 循环for r 1:5跑五遍取中位数比看任何表格都靠谱。4.4 精度侧的三个旋钮k、权重、归一化性能之外真正影响准确率的是三个东西按影响从大到小排。第一个是归一化。这条几乎每次都会踩因为不归一化代码照样跑只是准确率悄悄掉下去。量纲差异大的时候尤其致命一个特征范围是 0 到 255另一个是 0 到 1欧氏距离完全被前者主导后一个特征等于没参与计算。z-score 之后每个维度方差为 1贡献才均衡。第二个是 k。k 太小模型方差大一个噪声点就能翻转结果k 太大偏差大类边界被抹平。实践中从k1扫到ksqrt(N)附近看交叉验证曲线的最低点。奇数优先能天然规避两类平票不过多类问题下平票依然可能第 3 章那段平票处理不能省。第三个是加权投票。weightedtrue之后近邻的票数按距离倒数加权靠近边界的样本受影响更大通常在类重叠较多的数据上能涨一到两个点。代价是对噪声更敏感——一个离群点如果恰好离测试样本很近它的权重会非常大一个顶十个。数据洗干净之前别急着开这个开关。5. 避坑记录Matlab 里跑 KNN 最容易翻车的 5 个地方5.1 现象准确率一直卡在随机猜的水平原因九成是没做归一化或者归一化的统计量用错了数据。前者让大方差维度独占距离后者更隐蔽——在全体数据上算mu/sigma再切分测试集的信息泄漏进了训练过程。还有一种少见但同样致命的情况csv 里的整数列被读成int64X - mu时整型除法直接截断归一化等于没做。解决normalize_zscore只接受训练集和测试集两个输入mu和sigma从训练集算、保存下来推理时复用同一组。读数据后强制X double(X)。验证方法很土但有效归一化之后mean(ZTrain)应该接近全零向量std(ZTrain)接近全一向量打出来看一眼。5.2 现象score(:, c)报下标越界或者预测结果只有一类原因标签不是从 1 开始的连续整数。原始标签可能是 0/1/2也可能是字符串元胞数组直接塞进knn_classify里nClass max(yTrain)算出来的类数对不上score的列数和实际类别错位。解决入口处统一重编号。[~, ~, y] unique(rawLabel)这一句对字符串、数值、categorical 全都管用输出保证是1..C的连续整数。同时检查一下y是不是列向量——readtable取出来的可能是行yTrain(idx)的形状会跟着错报错信息指向别处查半天。5.3 现象调大 k 之后准确率断崖式下跌某一类被彻底吞掉原因k 超过了某个小类的样本数。比如某类只有 5 个训练样本k 取 15这 15 个邻居里属于该类的顶多 5 票其他类随便凑凑就压过去了。类别不平衡时这个问题会放大多数类的票数天然占优。解决K min(k, size(XTrain,1))只是防越本文还有配套的精品资源点击获取
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表