
简介面向使用 MATLAB 处理类别不平衡数据的机器学习学习者与工程人员这份 SMOTE 算法实现包提供了一套可直接参考的合成少数类过采样代码。核心由两个 .m 文件构成其中一个承载 SMOTE 主流程另一个作为调用与测试示例便于对照理解 K 近邻选取、随机线性插值以及新样本合成的具体写法随包的 txt 许可证说明与 git 配置文件则交代了授权范围与版本管理信息。压缩包共 5 个文件以 .m 源码为主辅以说明与配置类文本整体约 3KB体积轻量解压后即可阅读。已有 1324 人学习说明其在教学演示与工程复现中有一定参考面。对需要在分类任务中快速接入过采样环节的读者而言可借这份代码观察平衡前后模型在召回率、F1 分数等指标上的变化也便于按自身数据格式调整近邻数与合成比例作为理解算法细节的起点。1. SMOTE 不是万能药一份 MATLAB 版本能帮你解决什么一个做设备异常检测的朋友把模型结果贴给我看准确率 97.4%我让他把混淆矩阵也发过来——故障类召回 0。这类数据在 MATLAB 里跑十个模型九个都这样正样本占比不到 3%分类器只要全判正常就能拿到高分。SMOTE 算法Synthetic Minority Over-sampling Technique就是冲着这个局面来的它不复制少数类而是在少数类样本与其 KNN 近邻的连线上随机插值造出一批“像但不等同”的新样本让分类边界重新有得选。这份 MATLAB 版本的价值不在于公式多高深而在于把近邻选择、插值方向、采样倍率全部摊开成可控参数方便你在自己的数据上验证它到底是帮了模型还是只加了噪声。适合手上有类别不平衡数据、会用 MATLAB 做建模、但被召回率卡住的从业者。2. 环境与数据把不平衡数据集整理成可复现的输入SMOTE 的效果有一半不取决于算法本身而取决于喂进去的数据长什么样。同一份代码特征没标准化、切分顺序反了、类别字段被强行插值跑出来的合成样本就是一堆没法解释的数字。这一章先把输入这一侧的事情做干净后面调 k 值和倍率才有意义。2.1 数据读取与类别分布体检MATLAB 里不平衡数据常见的三种来源CSV/Excel 表格、.mat里的矩阵、数据库查询结果。不管哪种第一步都是把特征矩阵和标签向量分开然后统计每个类的样本数和不平衡比。不平衡比是最多类样本数除以最少类样本数这个数决定了你后面该用 SMOTE 还是该考虑别的路子。% 读取表格数据约定最后一列是标签前面都是特征 T readtable(fault_log.csv); y T.label; % 标签列数值或字符串均可 X table2array(T(:, 1:end-1)); % 特征矩阵 N x Pdouble % 统计类别分布 [classList, ~, gidx] unique(y); counts accumarray(gidx, 1); ratio counts / sum(counts); fprintf(类别数: %d\n, numel(classList)); for i 1:numel(classList) fprintf( 类别 %s: %d 条 (%.2f%%)\n, ... string(classList(i)), counts(i), ratio(i) * 100); end imbalance max(counts) / min(counts); fprintf(不平衡比: %.1f : 1\n, imbalance);这段代码里unique的第三个返回值gidx是每个样本所属类别的编号accumarray按编号累加就得到每类计数比对字符串标签做循环判断稳得多。table2array要求前面所有列都是数值型如果表里有文本列得先单独处理不能直接转。体检结果决定后续策略大致可以按下面这个区间判断不平衡比典型场景处理建议1:1 3:1轻度倾斜优先调分类器权重SMOTE 收益有限3:1 20:1常见业务数据SMOTE 或加权 阈值调整组合使用20:1 100:1故障、欺诈类SMOTE 边界清理必须看召回和精确率超过 100:1极稀有事件先确认少数类样本是否够做 KNN考虑异常检测思路最后一行是血泪经验少数类只有十几个样本时k 取 5 已经逼近样本总数合成出来的点高度集中等于把噪声放大。这种情况先把少数类样本补到 30 条以上再谈 SMOTE。2.2 划分策略先分层切分再做重采样这是整篇最容易翻车的一步。很多人习惯先把少数类过采样到平衡再cvpartition切训练测试集结果测试集里混进了由训练样本插值出来的合成点评估分数虚高得离谱上线就掉。正确顺序只有一个先切分再只在训练集上过采样测试集保持原始分布不动。% 固定随机流保证切分可复现 rng(2024, twister); % 分层留出法cvpartition 对分类标签默认按类分层 cvp cvpartition(y, Holdout, 0.3); Xtr X(training(cvp), :); ytr y(training(cvp)); Xte X(test(cvp), :); yte y(test(cvp)); fprintf(训练集: %d 条测试集: %d 条\n, ... numel(ytr), numel(yte)); % 确认切分后训练集里的少数类还在 [clsTr, ~, gTr] unique(ytr); cntTr accumarray(gTr, 1); disp(table(string(clsTr), cntTr, VariableNames, {class,count}));cvpartition的Holdout参数是测试集比例0.3 表示留 30%。对分类标签它默认分层也就是尽量保持训练集和测试集的类别比例一致不需要额外写Stratify。分层切分之后还要打印一次训练集类别计数因为少数类样本本来就少随机切分有可能把某一类的样本切得只剩个位数后面 KNN 直接失效。如果数据量小留出法单次切分的方差太大改成 k 折交叉验证更稳cvp cvpartition(y, KFold, 5); for f 1:cvp.NumTestSets Xtr X(training(cvp, f), :); ytr y(training(cvp, f)); Xte X(test(cvp, f), :); yte y(test(cvp, f)); % 在这里对 Xtr/ytr 做 SMOTE再训练、再在 Xte/yte 上评估 end注意 SMOTE 必须写在循环内部、只作用于当前折的训练集。写在外面一次过采样再循环评估等于让每一折都看到了其他折的信息交叉验证分数会明显偏高。2.3 特征标准化与类别型字段的处理SMOTE 在特征空间里做线性插值距离度量默认是欧氏距离所以量纲的影响非常直接一个取值范围 0 到 10000 的特征会把 0 到 1 的特征完全压住KNN 找到的“近邻”基本只在这一个大尺度特征上相近插值出来的样本在其余维度上是乱的。标准化参数必须从训练集统计不能对全量数据做否则测试集的均值方差信息泄漏进训练过程% 只用训练集统计均值和标准差 mu mean(Xtr, 1); sg std(Xtr, 0, 1); sg(sg 0) 1; % 常数列避免除零 Xtr (Xtr - mu) ./ sg; Xte (Xte - mu) ./ sg;std的第二个参数 0 表示按样本标准差除以 n-1计算和zscore的默认行为一致。常数列把标准差置 1 而不是 0是为了避免整列变成 NaN这一列本身对模型没贡献但保留维度比删列更省事。类别型字段是另一个雷区。独热编码后的 0/1 列做线性插值会得到 0.37 这种没有意义的值分类器要么报错要么把它当连续特征处理。常见做法是在进入 SMOTE 之前把类别列拆出去只对连续特征做插值合成完再把类别列按最近邻样本的类别值补回去或者干脆对类别列改用 SMOTENC 一类的思路。MATLAB 原生没有现成的 SMOTENC工程上更省事的办法是把低频类别合并、高基数类别做目标编码转成数值后再统一处理。3. 手写 SMOTE 核心KNN 选点与插值公式在 MATLAB 里怎么落地自己写一遍不是为了替代工具箱而是为了在参数不生效、结果对不上的时候能拆开看。SMOTE 的公式本身只有一行坑基本都在近邻索引和随机数的用法上。3.1 算法三步骤与输入输出约定标准 SMOTE 的流程拆成三步找出少数类样本集合对集合里每个样本用 KNN 找 k 个最近邻在样本与随机选中的某个近邻之间做线性插值合成新样本。插值公式是x_new x_i λ · (x_zi − x_i)其中x_zi是x_i的某个近邻λ是 0 到 1 之间的随机数。λ取 0 时合成点与原样本重合取 1 时落在近邻上中间的取值保证新样本落在两点连线上。这也解释了为什么 SMOTE 对噪声敏感如果x_i本身就是被错标的样本插值会把错误标签扩散到一整片区域。函数接口我一般定成五个输入特征矩阵、标签向量、近邻数 k、目标比例、随机种子输出合成样本矩阵和对应标签。这个约定和后面调参、对照实验都能对上。3.2 一个可直接调的 smote 函数实现function [X_syn, y_syn] smote_matlab(X, y, k, ratio, seed) % smote_matlab 手写 SMOTE 过采样二分类/多分类通用每次只处理一个少数类 % X 特征矩阵 N x Pdouble已标准化 % y 标签向量 N x 1数值或字符串 % k 近邻数正整数必须小于少数类样本数 % ratio 目标比例合成后少数类样本数 ratio * 多数类样本数 % seed 随机种子保证结果可复现 if nargin 5, seed 42; end rng(seed, twister); % 找出样本数最少的类作为少数类 [classList, ~, gidx] unique(y); counts accumarray(gidx, 1); [~, minIdx] min(counts); minority classList(minIdx); X_min X(y minority, :); n_min size(X_min, 1); n_maj sum(counts) - n_min; % 计算需要合成多少条 n_target round(ratio * n_maj); n_need n_target - n_min; if n_need 0 X_syn X_min; y_syn repmat(minority, n_min, 1); return; end if k n_min error(k (%d) 必须小于少数类样本数 (%d), k, n_min); end % KNN 近邻K 取 k1 是为了把自身排掉 idx_knn knnsearch(X_min, X_min, K, k 1); idx_knn idx_knn(:, 2:end); % 第一列是自身丢弃 % 逐条生成合成样本 X_syn zeros(n_need, size(X, 2)); for i 1:n_need base randi(n_min); % 随机选一个少数类样本 nb idx_knn(base, randi(k)); % 从它的 k 个近邻里随机选一个 lambda rand(1, size(X, 2)); % 每个维度独立的随机插值系数 X_syn(i, :) X_min(base, :) lambda .* (X_min(nb, :) - X_min(base, :)); end y_syn repmat(minority, n_need, 1); endknnsearch返回的索引按距离升序排列第一列是查询点自身距离 0所以取2:end把自身排除这一步漏掉的话合成样本会大量与原样本重合等于做了随机复制。randi(n_min)是有放回抽样同一样本可能被选中多次这是 SMOTE 的原始设计不需要去重。lambda这里用了rand(1, P)每个特征维度一个独立随机数也有实现用单个标量rand()对所有维度共用两种都有人用前者生成的样本分布更分散后者更贴近原始论文。做对照实验时固定一种别中途换。调用方式[Xtr_syn, ytr_syn] smote_matlab(Xtr, ytr, 5, 0.8, 42); Xtr_bal [Xtr; Xtr_syn]; ytr_bal [ytr; ytr_syn]; fprintf(过采样前少数类 %d 条过采样后 %d 条\n, ... sum(ytr minority), sum(ytr_bal minority));ratio取 0.8 表示把少数类补到多数类的 80%不一定要补到 1:1。补到完全平衡在多数场景下并不是最优尤其是少数类里混有噪声时1:1 会把噪声的影响放大一倍。3.3 向量化提速别再写双重循环上面的循环版在小数据上没问题样本上万、需要合成几十万条时就会明显变慢。MATLAB 的矩阵索引可以把整个生成过程压成几行速度差一个量级。% 向量化版本替换循环部分 baseIdx randi(n_min, n_need, 1); % 每条样本的基准点 nbPick randi(k, n_need, 1); % 每条样本选第几个近邻 linIdx sub2ind(size(idx_knn), baseIdx, nbPick); % 转成线性索引 nbIdx idx_knn(linIdx); % 近邻样本下标 lambda rand(n_need, size(X, 2)); % 逐维度随机系数 X_syn X_min(baseIdx, :) ... lambda .* (X_min(nbIdx, :) - X_min(baseIdx, :));关键是sub2indidx_knn是n_min × k的矩阵baseIdx和nbPick是两个列向量直接写idx_knn(baseIdx, nbPick)在旧版本 MATLAB 里会按笛卡尔积展开成n_need × n_need的矩阵内存瞬间爆掉。sub2ind把它转成线性下标取出来的才是逐元素对应的那一个近邻。这个坑我在一次三十万条合成的任务里踩过机器直接卡死。向量化之后X_min(baseIdx, :)会复制出一份n_need × P的矩阵内存占用大约是合成样本本身的两倍。数据维度很高时P 上千可以分块生成每块一万条拼完再返回。3.4 用内置 smote 函数做交叉验证较新版本的 Statistics and Machine Learning Toolbox 提供了smote函数参数名和默认值在不同版本间可能有差异先用help smote或exist(smote, file)确认本地有没有再照着帮助文档写。手写版和内置版各跑一遍、对比合成样本的均值和协方差是确认自己实现没写错的最快方式。if exist(smote, file) 2 % 参数名以本地 help smote 为准这里只示意调用形式 [X_builtin, y_builtin] smote(Xtr, ytr, Class, minority, ... K, 5, Ratio, 0.8); fprintf(内置函数合成后样本数: %d\n, size(X_builtin, 1)); else fprintf(当前环境没有 smote 函数使用手写版本\n); end对比时不要只看样本总数要看每个特征的均值和标准差有没有被拉偏。合成样本的均值应该落在原少数类均值附近标准差略小于原分布因为插值会向中心收缩。如果某个特征的标准差突然翻倍多半是那一列没标准化或者混进了异常值。4. 参数怎么设k 值、倍率与随机种子SMOTE 的可调参数不多但每一个都对结果有实质影响。默认值照抄能跑通跑出来的模型好不好用是另一回事。4.1 k 值的取值边界与失效场景k 是每个少数类样本参考的近邻个数原始论文建议 5工程上 3 到 10 都常见。k 太小合成样本紧贴原样本多样性不足模型容易过拟合到那几个点k 太大近邻可能跨越类别边界合成点直接落进多数类区域等于给模型喂错标样本。取值有个硬约束k 必须小于少数类样本数否则knnsearch找不到足够的近邻。少数类只有 20 条时 k 最大取 19但实际取到 15 以上已经没有意义近邻集合几乎覆盖整个少数类插值退化成在全局范围内随机撒点。我一般按少数类样本数分档少数类样本数建议 k说明 201 3近邻信息不足优先补数据而不是调 k20 1003 5最常用的区间100 10005 10可以尝试更大 k 提升平滑度 10005 15边际收益递减重点转向边界清理判断 k 是否合适有个简单办法合成完之后统计每个合成点到其最近多数类样本的距离和少数类原样本到最近多数类样本的距离分布做对比。合成点的这个距离明显更小说明插值跨过了边界k 该调小。4.2 合成倍率与目标比例倍率决定合成多少条。补到 1:1 是最直觉的做法但在噪声多的数据上往往不是最优。少数类里如果混了 10% 的错标样本1:1 过采样会把这 10% 的错误放大成和多数类同量级的错误信号模型精确率会掉得很难看。比较稳的做法是从小倍率开始往上加看验证集上的召回和精确率曲线ratios [0.2 0.4 0.6 0.8 1.0]; results zeros(numel(ratios), 4); % recall, precision, gmean, auc for r 1:numel(ratios) [Xs, ys] smote_matlab(Xtr, ytr, 5, ratios(r), 42); mdl fitcensemble([Xtr; Xs], [ytr; ys], Method, Bag, ... NumLearningCycles, 100); [results(r,1), results(r,2), results(r,3), results(r,4)] ... evalModel(mdl, Xte, yte, minority); endfitcensemble的Bag是 bagged 决策树对不平衡数据本身有一定鲁棒性适合作为 SMOTE 的配套分类器NumLearningCycles控制树的数量100 是速度和效果的折中数据量大时降到 50 也不会差太多。evalModel是下一章要写的评估函数放在这里先占位。跑完把results画出来横轴是倍率纵轴是指标通常能看到召回随倍率上升、精确率缓慢下降G-mean 在某一点达到峰值。峰值对应的倍率就是你这份数据上的合理值不要照搬别人的 1:1。4.3 随机种子与可复现性SMOTE 内部有两次随机选哪个基准样本、选哪个近邻、λ 取多少。不固定种子同样的输入跑两次结果能差好几个百分点调参时根本分不清是参数起作用还是随机波动。% 每次实验前固定且不同实验之间用不同种子做重复 seeds [11 22 33 44 55]; scores zeros(numel(seeds), 1); for s 1:numel(seeds) [Xs, ys] smote_matlab(Xtr, ytr, 5, 0.6, seeds(s)); mdl fitcensemble([Xtr; Xs], [ytr; ys], Method, Bag); [~, ~, g, ~] evalModel(mdl, Xte, yte, minority); scores(s) g; end fprintf(G-mean 均值 %.4f标准差 %.4f\n, mean(scores), std(scores));单次结果只能说明“能跑”五次不同种子的均值加减标准差才能说明“稳定”。如果标准差超过 0.03说明模型对采样太敏感要么增加数据量要么改用对随机性更鲁棒的方案。还有一个容易忽略的点rng是全局状态如果代码里用了parfor并行每个 worker 的随机流是独立的rng(seed)在并行环境下不能保证结果可复现。需要并行时用parfor内部的rng(seed workerId)或者改用RandStream显式指定流。4.4 距离度量与特征缩放knnsearch默认欧氏距离对连续特征合适如果特征里有大量二值列欧氏距离会被稀疏散布稀释可以考虑Distance参数改成cosine或mahalanobis。马氏距离需要协方差矩阵可逆特征数大于样本数时直接报错得先降维。% 马氏距离示例要求少数类样本数 特征数 if size(X_min, 1) size(X_min, 2) idx_knn knnsearch(X_min, X_min, K, k 1, ... Distance, mahalanobis); else idx_knn knnsearch(X_min, X_min, K, k 1); % 退回欧氏距离 end这段判断不是多余的马氏距离在样本数不足时协方差矩阵奇异knnsearch会抛警告然后返回 NaN后续插值全是 NaN错误信息要到训练分类器时才出现排查起来很绕。5. 避坑排查SMOTE 用错的五个典型现场SMOTE 的代码短错法倒是不少。下面五条是我自己和小范围交流里反复见到的按“现象 → 原因 → 解决”记下来遇到时对照着查。5.1 召回上去了精确率崩到没法看现象过采样之后少数类召回从 0.3 涨到 0.85但精确率从 0.7 掉到 0.15模型把所有可疑样本都判成少数类。原因合成倍率过高或者少数类内部混有错标样本插值把噪声扩散成一片连续区域分类器学到的边界过宽。少数类样本本身如果是从多数类里错分出来的SMOTE 会让这些点的邻域被大量合成样本覆盖。解决先把倍率降到 0.4 左右重跑观察精确率是否回升再用最近邻清理把少数类里 k 个近邻中多数类占优的样本剔掉再合成。MATLAB 里可以先用knnsearch在原始全量数据上找每个少数类样本的近邻统计近邻里多数类的比例超过 0.6 的标记为可疑样本排除。5.2 交叉验证分数漂亮换测试集就掉十几个点现象五折交叉验证 AUC 0.93留出测试集只有 0.78。原因过采样写在了交叉验证循环外面合成样本先混进全量数据再切折同一批合成点既出现在训练折又出现在验证折验证集被污染。这类泄漏在随机森林和 KNN 上尤其明显因为模型能直接记住合成点的位置。解决把 SMOTE 移到每一折的训练集内部验证折完全不参与合成。用cvpartition的KFold时training(cvp, f)和test(cvp, f)要严格分开合成只作用于前者。改完分数通常会掉 5 到 15 个点这才是真实水平。5.3 报错“K 必须小于样本数”或合成样本大量重复现象knnsearch抛索引越界或者合成完之后去重发现 90% 的样本和原样本重合。原因k 设得比少数类样本数还大或者knnsearch返回的近邻索引里第一列不是自身数据里有完全重复的点时会出现2:end丢掉的是真实近邻而不是自身。解决函数入口加k n_min的校验这一点 3.2 的代码里已经写了。去重问题先检查数据里有没有重复行unique(X_min, rows)去重后再算少数类样本数否则计数是虚的。如果确实需要大量合成把 k 降到 3 到 5并接受一定比例的重复。5.4 合成样本落在多数类密集区现象把合成样本和原始数据画到二维PCA 降维后一看一批合成点明显扎在多数类的簇里。原因近邻跨越了类别边界。少数类样本如果分布在多数类两侧k 取大了以后近邻会包含另一侧的样本插值路径直接穿过多数类区域。另一个原因是特征没标准化距离被单一特征主导近邻选得毫无意义。解决合成后做一次过滤计算每个合成点到最近多数类样本的距离小于少数类原样本该距离中位数的直接丢弃。再做一次标准化检查确认所有特征的取值范围在同一量级。这两步能把越界的合成点清掉大半。5.5 每次运行结果都不一样调参无从下手现象同一组参数跑三次AUC 分别是 0.81、0.76、0.84。原因没有固定随机种子或者固定了但代码里有parfor、randperm混用了不同的随机流。还有一种是分类器本身带随机性随机森林的 bootstrap 采样只固定 SMOTE 的种子不够。解决在脚本最开头统一rng(seed, twister)SMOTE 函数内部不要再调用一次rng覆盖全局状态改成把种子作为参数传进去但只在入口设置一次。分类器那边同样要设Reproducible, true如果该模型支持。最后用五次不同种子的均值加减标准差汇报结果而不是拿单次最好成绩。6. 进阶用法与验证用指标和交叉验证判断过采样到底值不值参数调完不算结束得有一套固定的评估流程来回答“加 SMOTE 到底有没有用”。我习惯把对照实验写成一个小脚本一次性跑完基线、加权、SMOTE 三组输出同一张表里对比。function [recall, precision, gmean, auc] evalModel(mdl, Xte, yte, posClass) % 在原始分布的测试集上评估posClass 指定少数类标签 [label, score] predict(mdl, Xte); cm confusionmat(yte, label, Order, mdl.ClassNames); posIdx find(strcmp(string(mdl.ClassNames), string(posClass)), 1); tp cm(posIdx, posIdx); fn sum(cm(posIdx, :)) - tp; fp sum(cm(:, posIdx)) - tp; tn sum(cm(:)) - tp - fn - fp; recall tp / max(tp fn, eps); precision tp / max(tp fp, eps); gmean sqrt(recall * (tn / max(tn fp, eps))); posCol find(strcmp(string(mdl.ClassNames), string(posClass)), 1); [~, ~, ~, auc] perfcurve(yte, score(:, posCol), posClass); endconfusionmat的Order参数保证行列顺序和mdl.ClassNames一致不然posIdx对不上算出来的召回和精确率会张冠李戴。perfcurve的第四个返回值是 AUC第二个输入必须是正类的分数列列号同样按ClassNames查。三组对照的写法方案训练集处理分类器设置基线原样默认参数加权原样Prior设为Empirical或手动指定类权重SMOTE过采样到 0.6默认参数跑完看三列数字召回提升幅度、精确率损失、G-mean 净变化。SMOTE 组如果 G-mean 比加权组还低说明这份数据上过采样不如直接调权重没必要硬上。还有一个我常做的检查把合成样本和原样本分别标记训练完之后看模型对合成样本的预测准确率。如果模型对合成样本的准确率接近 100%对原始少数类样本只有 70%说明它在背合成点这批合成样本的质量有问题得回头查 k 和边界清理。从那以后我每次上 SMOTE 之前都强制先跑一遍“先切分、再标准化、只在训练折内合成”的流程检查参数可以慢慢调顺序错了后面全是白工。希望帮到你。本文还有配套的精品资源点击获取