简介:面向机器学习与数据科学实践者的SMOTE算法MATLAB实现,针对二分类或多分类任务中常见的类别不平衡问题,提供了简便的少数类样本合成方案,适合在建模前扩充稀有类别样本、提升分类器对少数类的识别能力。资源压缩包共5个文件、约3KB,主体为两个MATLAB脚本:一个实现SMOTE核心插值逻辑,另一个提供测试调用入口;另含txt说明文本及Git工程配置文件,结构清晰,便于直接在MATLAB中运行与二次开发。已有1322人学习/下载,适合正在做分类建模、算法复现或课程设计的读者参考。通过脚本可自定义近邻数K与合成样本数量,观察K近邻线性插值的生成过程;还可配合SVM、决策树等常用模型,在训练前完成数据平衡,并借助AUC、F1等指标对比SMOTE前后的效果,验证数据均衡对模型性能的改善。资源体量虽小,却覆盖了从数据读取、预处理到合成样本融合的完整流程,可作为后续拓展Tomek、EasyEnsemble等混合采样策略的实验起点。
1. SMOTE算法MATLAB实测:不平衡数据集就缺这一手插值
在一个校园网日志分析项目里,用户流失预测的两类样本比例到了99比1。直接训练分类器,模型几乎只会把样本判为多数类——准确率好看,但真正想抓的那批流失用户一个都没召回。SMOTE算法就是应对这种场景的经典方案:不靠复制少数类样本,而是在样本与近邻之间插值合成新样本,让训练集比例不再一边倒。这个MATLAB版本把近邻搜索、差值计算、随机插值整条链路封装成了可直接调用的函数,读一遍源码就能接进自己的分类流程。适合正在做数据预处理、又需要自己动手写算法的开发者,拿它当基线再往上叠改造会顺手很多。
2. 技术定位与方案设计:SMOTE为什么不在平面里硬凑
2.1 SMOTE的纯正机制:样本间插值而非复制
SMOTE的全称是Synthetic Minority Over-sampling Technique,核心思想非常朴素:对于少数类里的每个样本,在它的k个同类近邻中随机挑一个,然后沿着这两点的连线随机取一个新位置,把这个位置作为合成样本。这个操作在线性代数里叫interpolate,中文一般翻译成插值。
插值和复制最大的区别在于信息量。复制只是在原位置上多加一份同样的数据,对分类边界几乎不提供新信息,很容易造成模型对那几个被复制样本的过拟合。而插值出来的样本分布在特征空间的局部空隙里,等于把少数类分布的形状往外推了一点,让分类器在少数类边界上能学得更踏实。这个差异在二维散点图上很容易验证:复制得到的点堆叠在原点上,插值得到的点散布在原样本和近邻之间的空地上。
常见做法是先把所有特征归一化到[0,1]区间,再跑插值,这样欧氏距离的计算不会被某个大数值特征带偏。归一化这一步虽然不是SMOTE公式的一部分,但实际项目里几乎都会做,不然合成出来的样本在某些维度上会显得非常不合理。我在第一次跑通这个MATLAB版本时偷懒跳过归一化,结果年龄特征和消费金额特征混在一起算距离,合成样本几乎只沿着金额维度变动,画出来就是一根细线,后来老老实实加了标准化才正常。
2.2 算法流程与参数含义:k和N决定了合成样本的形态
流程可以拆成四步。第一步,输入少数类样本矩阵X_min,假设有m个样本,每个样本有d个特征。第二步,计算两两之间的欧氏距离,找出每个样本的k个最近邻。第三步,确定过采样倍率N,也就是每个原始样本要生成几个新样本。第四步,对每个原始样本重复N次:随机挑一个近邻,取0到1之间的随机数λ,用公式X_new = X_i + λ * (X_nn - X_i) 算出新样本。
参数k控制的是插值时的活动范围。k太小,比如k=1,合成样本只在相邻的两两之间产生,分布会比较窄,拟合出来的决策边界容易碎。k太大,比如k=15,近邻里可能混入离得比较远的样本,插值出来的点会跨越更大区域,也可能引入噪声。N则直接控制数据集的平衡比例,假设原来少数类和多数类是1比99,设N=20,少数类样本就变成原来的21倍,仍然追不上多数类,那就要配合降采样或者分多次做SMOTE。
| 参数 | 典型值 | 作用 | 过大或过小的影响 |
|---|---|---|---|
| k | 5 | 控制插值临近范围 | 太小生成集中,太大混入噪声 |
| N | 依据类别比计算 | 控制新样本数量 | 太小不解决不平衡,太大训练集膨胀 |
| λ | 随机0到1 | 控制插值点位 | 固定值会丢失随机性,模型容易过拟合 |
这个算法从提出到现在衍生了不少变体,比如Borderline-SMOTE只对靠近决策边界的样本做合成,ADASYN则根据样本密度动态调整生成数量。本文这个MATLAB版本实现的是最基础的原始SMOTE,但函数接口留得比较干净,二次开发时改近邻选择策略就能往变体方向靠。
3. 核心模块逐段拆解:从pdist到随机插值的完整链路
3.1 主函数设计:输入输出直接对标调用场景
代码的第一个关键决定是把整个算法封装成一个函数,而不是写成脚本。这样在分类流水线里调用起来很直接:给出少数类样本矩阵和标签,指定近邻个数k和倍率N,函数返回合成样本和对应的标签。
function [synData, synLabels] = smote_raw(X, labels, k, N) % SMOTE_RAW 基础版SMOTE合成少数类过采样 % 输入: % X - 少数类样本矩阵, m x d 数值数组 % labels - 少数类标签列向量, m x 1 % k - 近邻个数, 标量, 默认取5 % N - 每个样本生成的新样本数, 标量, 默认取1 % 输出: % synData - 合成样本矩阵, (m*N) x d % synLabels - 合成样本标签, 与labels保持一致 m = size(X, 1); if k >= m k = m - 1; % 近邻不能包含自身, 且不能超过样本总数 warning('k值过大, 已自动调整为 %d', k); end synData = zeros(m * N, size(X, 2)); synLabels = repmat(unique(labels), m * N, 1);这段代码的逻辑说明:入口处先取矩阵行数m,如果k大于等于m,就强制回退到m-1,因为近邻搜索不能把自己算进去,同时也避免索引越界。synLabels用repmat平铺,保证返回的合成样本带着与原始少数类一致的标签,后续接分类器时可以直接拼到训练集里。这里的warning不是摆设,k越界是新手最容易踩的坑,自动修正比直接报错友好得多。
参数说明:X必须是数值二维矩阵,labels可以是任意可比较类型,函数内部不强依赖标签的具体值,唯一要求是同一批输入里标签保持一致。实际调用时建议显式传k和N,不要依赖默认值,因为不同数据集的类别分布差异很大,默认值往往不适用。
3.2 近邻搜索:用pdist2一行算完距离矩阵
原始SMOTE需要反复查找近邻,MATLAB里最省事的做法是用pdist2一次性算出所有样本两两之间的距离矩阵,再按行排序取前k个索引。
% 计算两两欧氏距离矩阵 D = pdist2(X, X, 'euclidean'); [~, idx] = sort(D, 2); neighborIdx = idx(:, 2:k+1); % 去掉第一列, 因为第一列是自己 % 按行遍历, 为每个样本准备它自己的近邻索引表 cellNeighbors = cell(m, 1); for i = 1:m cellNeighbors{i} = neighborIdx(i, :); end逻辑说明:pdist2的返回值是一个m×m矩阵,D(i,j)表示样本i与样本j的距离。sort(D,2)对每一行升序排列,第一列永远是0,因为样本和自己的距离是0,所以取第2列到第k+1列才是真正的k个近邻。把近邻索引存进cell数组,是为了在后面合成样本时能按样本下标快速取用,避免每次都重新做矩阵切片。这一步虽然看起来多写了几行,但循环里反复调用时的效率差异很明显。
参数说明:距离度量在这里固定用'euclidean'。如果你的特征空间经过标准化,欧氏距离通常是安全的;如果遇到文本向量或者稀疏特征,可以考虑改成'cosine',但那样插值公式本身也需要核对语义是否仍然成立。本实现默认欧氏距离,后续要换成其他距离度量,只需要改pdist2的第三个参数。
3.3 合成样本生成:随机选近邻与随机插值
这一小节是算法的核心。外层循环遍历每一个原始少数类样本,内层循环按N的数值生成对应数量的新样本。每一步都做两件随机事:随机选一个近邻,随机取一个插值系数λ。
sampleCount = 0; for i = 1:m for j = 1:N r = randi(k); % 随机选一个近邻下标 nnIdx = cellNeighbors{i}(r); % 取实际样本编号 lambda = rand(); % 插值系数, 0到1之间 sampleCount = sampleCount + 1; synData(sampleCount, :) = X(i, :) + lambda * (X(nnIdx, :) - X(i, :)); end end逻辑说明:randi(k)产生1到k之间的随机整数,用来在cellNeighbors里挑一个近邻。rand()产生0到1之间均匀分布的随机数,控制新样本在连线上的位置——λ接近0时新样本靠近原始样本,接近1时靠近近邻样本。插值公式本质上是向量运算,所以无论特征维度d是多少,这一行都能直接跑。
参数说明:这里N可以是大于1的整数。如果想把倍率细化为按比例控制而不是固定整数倍,可以在调用前先把小数倍率拆成整数倍加余数处理,或者直接修改函数内部逻辑,把N改成目标样本总数。实际应用中N一般设在1到20之间,超过20容易让训练集变得臃肿,训练时间成倍上涨。
3.4 主脚本调用与结果拼接示例
工程里一般不会只调函数,还要把合成样本和原始训练数据拼在一起,供后面的分类器使用。这一步最容易出错的是拼接维度,MATLAB的方括号拼接要求两个矩阵的行数一致,合成样本的行数是原始少数类样本数乘以N,和原始训练集的行数天然不同,所以必须用纵向拼接。
% 假设表dataTable中已有特征列和标签列 X_all = table2array(dataTable(:, 1:end-1)); y_all = dataTable.label; % 找到少数类 minorityClass = mode(y_all(y_all ~= mode(y_all))); idxMin = find(y_all == minorityClass); X_min = X_all(idxMin, :); y_min = y_all(idxMin); % 调用SMOTE生成新样本 [synX, synY] = smote_raw(X_min, y_min, 5, 10); % 与原始数据合并, 构造新的训练集 X_aug = [X_all; synX]; y_aug = [y_all; synY];逻辑说明:先按出现频率找出少数类,把少数类样本单独抽出来,调用smote_raw生成新样本,再用纵向拼接把合成样本追加到原始训练数据尾部。这里的N=10表示每个少数类样本生成10个新样本,如果少数类有100个,合成后就是1000个,再加上多数类样本,整体比例就能明显改善。
4. 运行指南:从数据文件到第一条合成样本
4.1 环境要求与目录组织
这个MATLAB版本不依赖第三方工具包,基础函数用到了pdist2、randi、rand,这些都在MATLAB基础库范围内,不需要额外安装统计工具箱——当然装了更好,因为后面画分布图时会用到绘图函数。建议把函数文件smote_raw.m、示例脚本demo_smote.m和数据集放在同一个工作目录下,避免路径问题。
目录结构示例:
smote_matlab/ ├── smote_raw.m # 核心函数 ├── demo_smote.m # 示例脚本, 演示完整调用流程 ├── data.mat # 示例数据集, 包含X和labels两个变量 └── output/ # 运行结果输出目录data.mat的变量格式说明:X是n×d的数值矩阵,每一行是一个样本;labels是n×1的列向量。示例数据里既有多数类也有少数类,方便直接验证合成效果。如果你的数据是CSV格式,先用readtable读进来,再用table2array转成矩阵,和上面代码里的处理方式一致。
4.2 从MATLAB命令行或脚本运行
在MATLAB的命令行窗口敲下面三行,或者直接把内容写进demo_smote.m然后点运行:
load('data.mat'); [minData, minLabels] = extractMinority(X, labels); [synData, synLabels] = smote_raw(minData, minLabels, 5, 10); save('output/augmented.mat', 'synData', 'synLabels');function [minData, minLabels] = extractMinority(X, labels) % 简单抽取少数类样本的工具函数 c = unique(labels); counts = histcounts(labels, [c; c(end)+1]); [~, idxMin] = min(counts); minorityClass = c(idxMin); keep = (labels == minorityClass); minData = X(keep, :); minLabels = labels(keep); end逻辑说明:extractMinority先统计每个类别的样本数,找出数量最少的那个类,然后返回该类对应的全部样本。这是预处理步骤,和执行SMOTE本身无关,但几乎每次都会用到,就顺手写成了工具函数。histcounts的边界参数[c; c(end)+1]确保了最后一个类别的样本也能被正确统计。
参数说明:N的取值直接决定合成样本总量。假设原始训练集有2000个多数类样本和100个少数类样本,期望把少数类扩到800个,N就设7,因为100乘以7等于700,加上原始的100正好800。如果期望的比例落在整数之间,可以在调用后用随机下采样或者再补几个插值样本,凑到目标数量。
4.3 验证合成结果是否合理
跑完SMOTE之后不要直接丢进分类器,先画图看一眼分布是否合理。这一步是排查问题最快的手段,比盯着数值矩阵看半天有效得多。
% 特征只有两维时可以直接散点图对比 figure; scatter(X_all(:,1), X_all(:,2), 20, 'k', 'filled'); hold on; scatter(synData(:,1), synData(:,2), 10, 'r', 'filled'); legend({'原始样本', 'SMOTE合成样本'}, 'Location', 'best');逻辑说明:散点图能直观反映合成样本是否落在原始样本的局部区域内。如果合成样本飞出整个数据云的边界,多半是特征没做归一化,或者近邻个数k设置太大。如果合成样本挤成一团,说明k太小,插值空间太窄。反过来,如果合成样本分布均匀且和原始样本形态一致,说明参数设置基本合理,可以放心进入训练环节。
5. 避坑与排查:MATLAB里跑SMOTE最容易翻车的五个地方
5.1 合成样本出现违反特征物理含义的数值
现象:合成后的年龄特征出现负值,或者某种计数特征出现了小数。
原因:插值公式X_i + λ * (X_nn - X_i)不检查每个特征列的含义。当特征本身是非负整数,两个样本之间取0.5的插值系数,结果就会是小数;如果两个样本在该特征上一正一负,取0.5插值后可能算出负值。
解决:在调用SMOTE之前先做归一化,合成完成后做一次反变换;对严格非负的特征,在插值后手动裁剪为max(0, value)。直接在合成步骤后加一行剪裁就能解决,具体做法是synData(synData < 0) = 0。
5.2 k值大于少数类样本总数导致索引越界
现象:当少数类样本只有几个,k却设成默认的5,sort后取索引列就会直接报错,提示索引超出数组边界。
原因:近邻搜索时只取了第2到第k+1列,k一旦超过样本数m减1,矩阵切片就会越界。很多人以为设k=5很安全,但少数类样本量本身可能只有3个。
解决:函数开头加了保护逻辑——k大于等于m时自动降为m-1,并给出warning。二次开发时如果从别的语言移植代码,记得保留这个边界判断,别自以为是地把warning删掉。
5.3 训练集和测试集混在一起做SMOTE,造成数据泄露
现象:离线评估时模型AUC高得离谱,甚至接近1.0,但上线后效果大幅缩水,召回率反而比不用SMOTE还差。
原因:SMOTE在划分训练集和测试集之前就对全量数据做了合成,测试集里混入了由训练样本插值生成的影子样本,模型在评估时见过几乎一样的数据,属于经典的数据泄露。
解决:SMOTE只能作用在训练集内部。先把原始数据按7比3划分,对训练集里的少数类做合成,测试集保持原样。代码上只需要把smote_raw调用放在cvpartition分割之后即可。
5.4 合成样本数量爆炸,训练时间成倍增加
现象:把N设成50或更大,合成样本数量直接变成原来的51倍,分类器训练吃满内存,跑一次要等好几分钟。
原因:N按倍数线性放大,当少数类本身有几千个样本时,N=50意味着合成十几万样本,再加上原始数据,规模很容易失控。这个问题在小数据集上不明显,数据量一上来就暴露。
解决:先用类分布比例算清楚到底需要多少新样本,而不是拍脑袋设N。大多数情况下,把少数类扩到多数类的30%到50%已经足够,缺的用其他清理策略补齐,比如下采样一部分多数类样本。
5.5 使用了未归一化的特征,欧氏距离被大数值特征主导
现象:合成样本在部分特征维度上变化极小,在另一个维度上却大得离谱,散点图呈现一条细线,基本没有二维分布的形状。
原因:欧氏距离对量纲敏感,比如特征A取值范围是0到1,特征B取值范围是1000到5000,距离几乎完全由特征B决定,近邻选择和插值时特征A几乎没有贡献。SMOTE虽然照常执行,但合成样本的质量已经崩了。
解决:在SMOTE之前用zscore或rescale做标准化处理。这个改动对合成质量的影响往往比调k还明显,我在实际项目中把这一步作为硬性前置条件,不管数据来自哪里,进入SMOTE之前先标准化。
6. 二次开发:把SMOTE接进分类流水线的三个改造方向
6.1 扩展为Borderline-SMOTE:只在边界区域合成样本
原始SMOTE对每个少数类样本都平均用力,但分类器最难学的是边界区域。改造思路是:让每个少数类样本先找出它在全量数据里的k个近邻,统计其中多数类样本的数量,如果多数类近邻占比超过一半,就认为这个样本在边界区,只对边界样本执行SMOTE插值。
% 伪代码示意 for i = 1:m neighborsAll = knnsearch(X_all, X_min(i,:), 'K', k+1); majorityCount = sum(y_all(neighborsAll) == majorityClass); if majorityCount > k/2 candidateIdx(end+1) = i; end end % 只对candidateIdx中的样本生成新数据这个改造对分类性能的提升通常很直接,代价是代码量多出几十行。要接进现在的MATLAB实现,只需要把近邻索引表从全量近邻替换成过滤后的候选集。
6.2 结合Tomek Links做清理:先合成再下采样
SMOTE合成样本多了之后,少数类和多数类边界可能变得模糊,甚至互相重叠。常见做法是合成完再做一遍Tomek Links清理,把互为最近邻但属于不同类别的样本对删掉较难判断的那一个。两步合起来的效果,比单独用SMOTE或单独下采样都要稳。
% Tomek Links检测 D = pdist2(X_aug, X_aug, 'euclidean'); [~, minIdx] = min(D + eye(size(D)) * inf, [], 2); % 若样本i的最近邻是j, 且j的最近邻是i, 且类别不同, 则构成Tomek Link6.3 交叉验证中嵌入SMOTE的正确姿势
把SMOTE放进交叉验证循环时,最容易踩的坑是在整个数据集上合成完再切Fold。正确做法是在每个Fold内部,只对训练部分做SMOTE,验证部分完全不参与合成。具体代码上,可以在kfold循环体内调用smote_raw,而不是在进入循环之前就调用。从实际项目经验看,这个顺序问题比参数调整更影响最终评估结果,因为数据泄露带来的分数虚高是看不到的隐患。
从我自己的习惯来说,每次拿到SMOTE改造后的代码,都会强制自己在训练、合成、验证三个环节各画一次特征分布图,确认合成样本只在训练集出现过,再往模型里喂。这个检查花不了两分钟,但能省掉后面排查暗坑的半天时间。希望帮到你。
本文还有配套的精品资源,点击获取