简介:这是一份基于MATLAB实现SMOTE算法(合成少数类过采样技术)的开源代码包,面向处理不平衡数据集的数据科学学习者与机器学习工程师。SMOTE方法可有效缓解少数类样本不足导致的模型偏差问题,资源包内含SMOTE主程序、测试脚本和说明文档,源码约3KB,结构精简,便于直接运行或二次修改,适合希望理解算法原理并快速上手应用的中级MATLAB用户。包内共5个文件,其中2个m脚本为核心实现,其余为文本说明与版本管理配置,整体简洁清晰。目前该资源已被1321人浏览学习,受到一定关注。借助该源码,用户可掌握K近邻合成新样本的完整环节,包括数据预处理、SMOTE操作、数据融合与后续模型评估思路,并可将生成的平衡数据集接入SVM或决策树等分类器,为实际项目中的类别不平衡问题提供可复现的解决路径。
1. SMOTE算法MATLAB版:把少数类样本从99比1拉回到能训练的样子
做分类建模的人大概率撞过这堵墙:正负样本比例悬殊,模型把所有样本都判成多数类,准确率照样99%,上线之后才发现完全不能用。SMOTE就是专门治这个的,它的思路不是复制少数类样本,而是在少数类样本之间插值,合成出全新的样本。这份MATLAB版SMOTE代码我拆了一遍,核心逻辑完整、可以直接改参数跑通,适合要在MATLAB里复现论文实验、做课程设计,或者给毕业设计加一个过采样模块的从业者。下面从原理到代码再到踩坑,一条线讲清楚。
2. SMOTE算法原理:为什么插值生成的样本比复制更有用
2.1 过采样的本质问题:复制样本只是把权重放大,没有增加信息
随机过采样做起来很简单,把少数类样本复制几份,让类别比例到1:1,模型训练看起来没什么毛病。但实际跑出来的模型在真实数据上往往表现很差,原因在于复制样本没有引入任何新信息。决策边界只是被重复样本“撑”了一下,模型学到的只是少数类样本原来的分布密度,对真实分布没有补充。SMOTE不一样,它在特征空间里做线性插值,等于在已有的少数类样本之间“猜”出一些位于流形中间的新样本,让模型的决策边界能被真实地推向多数类一侧。
SMOTE的全称是Synthetic Minority Over-sampling Technique,最原始的论文由Chawla在2002年提出。它做的事情可以概括成三步:先为每个少数类样本找到k个近邻,再从这k个近邻里随机挑一个,最后在这两个样本的连线上随机取一个点作为新样本。这个新样本的每个特征,都是原有特征按同一个比例混合出来的。
2.2 从近邻搜索到插值生成:算法流程拆解
具体展开说,SMOTE对每一个少数类样本,需要先算出它和其他所有少数类样本的距离。欧氏距离是最常用的度量,公式是关注意义不大的常规数学表达式,但实现时要注意特征量纲问题,不然距离计算会完全失真。找到k个近邻之后,算法会在这k个近邻里随机选一个,然后生成一个0到1之间的随机数λ,新样本的每个特征值等于原始样本的特征值加上λ乘以近邻样本和原始样本特征值的差。
这个插值方式的妙处在于新样本不会偏离少数类分布太远。比如两个少数类样本在特征空间里相距比较远,那么插值点也会落在这两点之间,整体分布不会出现离谱的离群点。如果某些特征存在明显的边界范围,插值也可能越界,这是后话,在第4章会专门讲。
从计算的角度看,整个SMOTE算法最核心的步骤是近邻搜索,这也就是为什么在MATLAB里实现时,优先用统计工具箱里的knnsearch而不是自己写双重循环。
3. MATLAB代码落地:主函数、调用方式与参数调优
3.1 核心函数实现:一份能直接跑的SMOTE主函数
我在MATLAB里按标准流程实现了一个精简版主函数,输入少数类样本矩阵、近邻数、目标生成数量,输出合成样本矩阵。代码不长,核心逻辑都在下面:
function X_new = smote_light(X, k, n) % 输入: % X - 少数类样本矩阵,m行d列,每行一个样本 % k - 近邻数量,默认取5 % n - 需要生成的合成样本数量 % 输出: % X_new - 合成样本矩阵,n行d列 [m, d] = size(X); if n < 1 error('n必须大于等于1'); end % 用knnsearch找每个样本的k+1个近邻 % 加1是因为第一个近邻是样本自身,需要去掉 idx = knnsearch(X, X, 'K', k + 1); idx(:, 1) = []; % 去掉自身那一列 X_new = zeros(n, d); for i = 1:n % 随机选一个基样本 base = randi(m); % 从该样本的k个近邻里随机选一个 neighbor = idx(base, randi(k)); % 插值系数,0到1之间均匀分布 lambda = rand(); % 线性插值生成新样本 X_new(i, :) = X(base, :) + lambda * (X(neighbor, :) - X(base, :)); end end这段代码有几个地方值得说明。第一,knnsearch返回的近邻索引第一列一定是样本自己,所以'K'设置为k+1再删掉第一列,这才是真正要找的k个近邻。第二,每次循环随机选基样本和近邻,所以同一组输入运行两次,生成的合成样本不完全一样,这是随机过程的正常现象,不影响整体分布。第三,插值系数lambda用的是rand(),生成的是0到1之间的均匀随机数,决定新样本落在连线上的具体位置。
3.2 典型调用与参数设置:K值和生成数量怎么配
假设现在有一个二分类训练集,多数类样本有5000个,少数类样本只有200个,特征维度是30。常见做法是先取出少数类样本,计算需要生成的样本数量,然后调用上面这个函数:
% 读入原始训练数据,X_all是全部样本,y_all是对应标签 X_major = X_all(y_all == 1, :); % 多数类 X_minor = X_all(y_all == 0, :); % 少数类 % 目标:让少数类数量达到多数类的一半,即2500个 n_target = 2500; n_to_generate = n_target - size(X_minor, 1); % 调用SMOTE生成合成样本 X_syn = smote_light(X_minor, 5, n_to_generate); % 合并原始少数类和合成样本 X_minor_balanced = [X_minor; X_syn]; y_minor_balanced = zeros(size(X_minor_balanced, 1), 1); % 重新拼接成完整训练集 X_train_new = [X_major; X_minor_balanced]; y_train_new = [ones(size(X_major, 1), 1); y_minor_balanced];参数选择上,近邻数k一般取5,少数类样本个数特别少的时候可以降到3,样本量大且分布集中时可以取8到10。k越小,合成样本越贴近某个原始样本周围,多样性越低;k越大,插值范围越广,合成样本覆盖的区域越大,但也更容易跨越到多数类区域里去。生成数量不要追求绝对1:1,尤其是少数类本身很少时,硬拉到和多数类一样多会造成合成样本大量重叠,模型过拟合风险反而上升。
下面把主要参数的影响整理成一张表,方便随手查:
| 参数 | 推荐值 | 影响 | 调整方向 |
|---|---|---|---|
| 近邻数k | 默认5 | k越小样本越局促,k越大分布越广 | 样本少用3,分布密集用8 |
| 目标比例 | 1:1到1:3之间 | 比例越极端过拟合风险越高 | 多数类样本本身就多时别追求1:1 |
| 距离度量 | 欧氏距离 | 特征量纲不同会严重干扰近邻选择 | 先做zscore标准化再跑SMOTE |
| 插值系数 | 0到1均匀分布 | 决定新样本在连线上的位置 | 不用改,随机采样是标准做法 |
参数调完先别急着训练模型,前几批合成样本生成之后,建议随手做个分布检查,最简单的做法是把原始少数类和合成样本在二维散点图里画出来,看合成样本是否落在原始样本连线的周围。如果发现合成样本全部挤在一个小区域,通常是k值太大或者原始样本本身存在大量重复。
3.3 环境与版本兼容性:只依赖统计工具箱
实现这个函数只依赖MATLAB统计和机器学习工具箱里的knnsearch,不涉及其他第三方库。我用的是MATLAB R2023b环境,R2018b以上的版本都能直接运行。如果用的是不带工具箱的纯基础版,knnsearch会报未定义,替代方案是先用pdist2算距离矩阵再手动排序取索引,但那个做法只适合样本量在几千以内的场景,样本量一大内存就撑不住了。
3.4 运行效率:千样本量级直接跑,十万样本要谨慎
样本量在5000以内时,knnsearch的耗时基本在一秒以内,可以直接跑。少数类样本超过两万时,近邻搜索的计算量会明显上升,这时建议改用分块策略,每次只对随机抽出的子集做近邻搜索和插值,而不是一次性把全量样本放进knnsearch。有一个比较取巧的做法是分batch循环生成,每批处理500个基样本,效果一样,内存占用能降一个数量级。
4. 避坑清单:五个常见的SMOTE翻车场景
4.1 合成样本挤成一团:特征没有标准化
现象:画出来的合成样本全部堆在某个小范围里,与原始少数类样本的分布明显脱节。
原因:knnsearch默认用欧氏距离,如果某个特征的数值范围远大于其他特征,距离计算被它主导,近邻选择完全失真。比如一个特征是年龄0到100,另一个特征是收入0到1000000,收入特征一票否决,年龄特征形同虚设,近邻关系就错了。
解决:跑SMOTE之前先做标准化,MATLAB里直接zscore(X_minor),或者用mapminmax把每个特征缩放到0到1之间。标准化之后再做近邻搜索和插值,生成完新样本如果后续要还原到原始量纲,再乘以原来的标准差加回均值。这个步骤是血泪经验,我刚开始跑SMOTE时跳过了标准化,生成的样本怎么看怎么不对劲,排查了很久才找到根因。
4.2 过采样倍率太猛:类别比例反转后过拟合
现象:训练集上准确率接近满分,但验证集、测试集上F1分数不升反降。
原因:把少数类强行过采样到和多数类一样多甚至更多,合成样本在特征空间里大量重叠,模型把注意力放在合成样本的细节上,本质上是把噪声也给拟合进去了。
解决:别追求1:1。常见做法是把过采样后的比例控制在1:2到1:3,即少数类数量达到多数类的三分之一到一半即可。如果原始少数类只有50个,目标数量定在100到150之间就够了,硬拉出来500个合成样本,质量高不了。
4.3 样本量一大就卡死:近邻搜索内存爆掉
现象:少数类样本上万时,程序运行越来越慢,报内存不足错误。
原因:如果改用pdist2算全量距离矩阵,样本量为m时矩阵大小是m乘m,一万个样本就是1亿个浮点数,光这个矩阵就要几百MB内存,样本量翻倍内存直接爆炸。
解决:坚持用knnsearch而不是手写全量距离矩阵计算。knnsearch底层用kd树或穷举法,内存占用小很多。如果knnsearch也慢,按行分块调用knnsearch,每块1000行,结果累计起来,再对插值步骤做循环。
4.4 离散特征被插值破坏:类别编码变成无意义小数
现象:合成样本里的性别、颜色、地区这类离散特征出现了0.3、1.7这样的小数。
原因:SMOTE是对所有特征统一做线性插值,对连续数值特征有效,但对类别型特征没有意义。比如性别编码为男=0、女=1,插值得到0.4,这个值不代表任何性别。
解决:把离散特征和连续特征分开处理。先只对连续特征做SMOTE插值,离散特征用最近邻居的众数来填充,或者直接用该基样本原始的特征值。如果离散特征编码是one-hot格式,插值得到的小数可以阈值化成0或1,但那样会在多个类别变量之间引入伪相关性,不如分开处理干净。
4.5 SMOTE放在全体数据集上执行:数据泄露
现象:模型在测试集上表现好得异常,但上线后效果暴跌。
原因:在划分训练集和测试集之前就对全量数据做了SMOTE,合成样本同时出现在训练集和测试集里,或者测试集被合成样本污染,评估结果虚高。这种数据泄露是隐蔽的,准确率摆在那里很漂亮,但模型真正的泛化能力完全没被验证出来。
解决:SMOTE必须只针对训练集做,测试集保持原始分布不动。交叉验证时,SMOTE要放在每一折的交叉验证内部,即在每次划分出训练折之后、训练模型之前执行,具体做法在第6章展开讲。
5. 进阶玩法:从SMOTE变体到完整不平衡数据管线
5.1 Borderline-SMOTE与ADASYN:两个经典变体思路
原始SMOTE对每个少数类样本一视同仁,不管它在类别边界还是在少数类区域深处。实际应用中,边界附近的样本往往携带更多信息,深处那些几乎和多数类不重叠的样本对改善决策边界帮助有限。Borderline-SMOTE的思路是先识别出那些处在边界区域的少数类样本,也就是其近邻中多数类占比较高但又不是纯多数类样本的那部分,然后只对这部分样本做过采样。这个操作能在不增加太多样本的前提下更精准地推挤决策边界。
ADASYN的侧重点不同,它能自动为不同少数类样本分配不同的采样权重。核心逻辑是:如果一个少数类样本的近邻中多数类很多,说明这个样本很难学,就多给它分配一些随机选择概率,生成更多新样本。相比之下,ADASYN更适应类别分布本身的复杂性。MATLAB里实现这两个变体并不复杂,把第3章的smote_light主函数改一下,替换基样本的选择逻辑,就能从标准SMOTE变成ADASYN。
5.2 评估口径变了:别再看准确率
不平衡数据场景下准确率是极具欺骗性的指标,哪怕完全不做任何处理,用全部预测为多数类这个最简单的策略,在99:1的数据上都能拿到99%的准确率。评估过采样效果时,我一般同时看三个指标:F1分数、AUC值和混淆矩阵。F1综合了精确率和召回率,能反映少数类被识别出来的能力;AUC不依赖分类阈值,可以在不同阈值下观察模型区分两类的能力;混淆矩阵能直观看出漏报和误报分别有多少。
5.3 一条完整的管线:从原始数据到可部署模型
基于前面的组件,完整的不平衡数据建模流程我一般这样组织。第一步做数据清洗和去重;第二步对连续特征做zscore标准化,离散特征做编码;第三步划分训练集和测试集;第四步只在训练集上做SMOTE过采样;第五步用交叉验证评估模型,且在每一折内部重复执行标准化和SMOTE;第六步在原始分布的测试集上做最终评估。
% 完整管线示例:以决策树分类器为例 rng(42); % 固定随机种子,方便复现 % 第一步:划分训练测试集 cv = cvpartition(y_all, 'HoldOut', 0.3); X_train_raw = X_all(cv.training, :); y_train = y_all(cv.training); X_test = X_all(cv.test, :); y_test = y_all(cv.test); % 第二步:在训练集上做标准化 [X_train_raw, mu, sigma] = zscore(X_train_raw); X_test = (X_test - mu) .* sigma; % 用训练集的均值标准差做测试集标准化 % 第三步:分别取出多数类和少数类 X_minor = X_train_raw(y_train == 0, :); X_major = X_train_raw(y_train == 1, :); n_minor = size(X_minor, 1); n_major = size(X_major, 1); % 第四步:生成目标数量的合成样本 n_target = round(n_major * 1/3); X_syn = smote_light(X_minor, 5, n_target - n_minor); % 第五步:拼接成新训练集 X_train_bal = [X_major; X_minor; X_syn]; y_train_bal = [ones(n_major, 1); zeros(n_minor, 1); zeros(size(X_syn, 1), 1)]; % 第六步:训练并评估 mdl = fitctree(X_train_bal, y_train_bal); y_pred = predict(mdl, X_test); confmat = confusionmat(y_test, y_pred); f1_score = 2 * confmat(2,2) / (2 * confmat(2,2) + confmat(1,2) + confmat(2,1));管线里每一步的顺序都不能乱。特别要注意的是,测试集标准化必须用训练集的均值和标准差,不能自己在测试集上重新算一遍,否则会造成信息泄露。过采样倍率也不要拍脑袋定,建议从1:3开始,逐步调整到1:2,观察验证集上的F1分数变化趋势,找到拐点即可。
6. 验证SMOTE效果的招数:PCA可视化和交叉验证内部过采样
SMOTE到底有没有用,不能只看训练集上的数字。我习惯做的第一件事是PCA降维可视化。把原始少数类样本、SMOTE合成样本和多数类样本一起投影到二维平面,用不同颜色标示,一眼就能看出合成样本是贴合在少数类分布周围,还是漫无边际地飘在特征空间里。MATLAB实现就几行,用pca函数算出前两个主成分,再scatter画散点:
% 合并三类样本做PCA投影 X_all = [X_minor; X_syn; X_major]; [coeff, score] = pca(X_all); figure; scatter(score(1:n_minor, 1), score(1:n_minor, 2), 10, 'b', 'filled'); hold on; scatter(score(n_minor+1:n_minor+size(X_syn,1), 1), ... score(n_minor+1:n_minor+size(X_syn,1), 2), 10, 'r', 'filled'); scatter(score(n_minor+size(X_syn,1)+1:end, 1), ... score(n_minor+size(X_syn,1)+1:end, 2), 5, 'k', 'filled'); legend({'原始少数类', 'SMOTE合成', '多数类'});合成样本如果呈带状分布在少数类连线两侧,说明插值逻辑正常;如果合成样本像烟雾一样扩散到多数类区域,就要检查k值是否过大或者标准化有没有出问题。
第二件事是交叉验证内部做SMOTE。很多人的做法是先对全训练集过采样再交叉验证,这会让同一批合成样本同时出现在不同折里,造成严重的数据泄露。正确的做法是:每一折交叉验证都重新做一次SMOTE,且只在当前训练折内做,验证折完全保持原始分布。这样评估出来的指标才是模型在真实数据上的表现,而不是被合成样本粉饰过的假象。
% 5折交叉验证内部SMOTE示意 cv_folds = cvpartition(y_train, 'KFold', 5); f1_list = zeros(cv_folds.NumTestSets, 1); for i = 1:cv_folds.NumTestSets X_tr = X_train(cv_folds.training(i), :); y_tr = y_train(cv_folds.training(i)); X_va = X_train(cv_folds.test(i), :); y_va = y_train(cv_folds.test(i)); % 在第i折的训练折内部做SMOTE X_minor_fold = X_tr(y_tr == 0, :); X_major_fold = X_tr(y_tr == 1, :); n_syn = round(size(X_major_fold, 1) / 3) - size(X_minor_fold, 1); X_syn_fold = smote_light(X_minor_fold, 5, n_syn); X_bal = [X_major_fold; X_minor_fold; X_syn_fold]; y_bal = [ones(size(X_major_fold, 1), 1); zeros(size(X_minor_fold, 1), 1); zeros(size(X_syn_fold, 1), 1)]; % 训练与评估 mdl_fold = fitctree(X_bal, y_bal); y_pred_fold = predict(mdl_fold, X_va); cm = confusionmat(y_va, y_pred_fold); f1_list(i) = 2 * cm(2,2) / (2 * cm(2,2) + cm(1,2) + cm(2,1)); end fprintf('F1均值: %.3f\n', mean(f1_list));从那以后,我每次拿到不平衡数据集都强制走一遍这套流程:先zscore标准化,再在交叉验证内部做SMOTE,最后用PCA可视化和F1分数双重验证。踩过的坑多了,反而觉得SMOTE这个算法本身不复杂,真正决定成败的是预处理和评估方式。希望这篇拆解能帮你在自己的数据集上少走几段弯路。
本文还有配套的精品资源,点击获取