SMOTE算法详解与Matlab实现:解决类别不平衡分类问题
2026/9/9 21:03:51 网站建设 项目流程

简介:面向不平衡数据分类场景的SMOTE算法MATLAB实现,适合机器学习与数据挖掘开发者应对少数类样本不足的分类问题,也适用于金融风控、医疗诊断等常见类别不均衡场景。压缩包仅3KB,含5个文件,其中2个M函数文件分别提供算法主体与可运行测试脚本,另有许可证及Git版本管理配套说明。已有1321人学习使用。源码基于SMOTE原始思想,完整呈现K近邻搜索、随机选邻、线性插值合成少数类样本的核心流程;测试脚本便于直接复现效果,通过对比合成前后数据集规模与模型指标,可直观理解算法对平衡性的改善,代码结构简洁、依赖低,方便结合具体数据集调整近邻数和合成倍率,也可作为组件集成到后续的分类模型中。整体小巧实用,兼顾教学参考与工程二次开发。 在做分类任务的时候,类别不平衡问题几乎躲不掉。我记得有一次做信贷风控项目,好客户占比超过98%,坏客户不到2%。第一版逻辑回归跑出来,测试集准确率报出99%的漂亮数字,可我盯了一眼混淆矩阵就沉默了——坏客户一个都没逮住,全被模型当成了好人。准确率这指标在极度不平衡的数据上,基本就是自欺欺人。那段时间我认真研究并落地了SMOTE算法(全称 Synthetic Minority Over-sampling Technique,合成少数类过采样技术),也把它在Matlab里完整写了一遍、反复验证过。这篇就围绕SMOTE的Matlab实现展开,从原理、代码、评估到踩坑和进阶思路,一次性讲透。

这篇文章的内容,既适合正在处理不平衡分类问题、想在Matlab里直接复现SMOTE的同学,也适合那些已经用过SMOTE但疑惑"为什么我加了合成样本效果反而变差"的人。我会把实现细节、参数选择、验证方法和常见的坑全部掰开来说。

1. 先搞明白SMOTE到底在解决什么问题

1.1 一个准确率99%的模型为什么会失效

很多人刚接触分类任务时,下意识就把准确率当成最重要的指标。而在类别极不平衡的场景下,这个指标几乎没什么价值。设想一个场景:10000条样本里只有200条是正类,哪怕模型把所有样本都判成负类,准确率依然有98%。但这样的模型在生产环境里等于废物,因为它没有识别出任何正类样本,该预警的没预警,该拦截的没拦截。

我当时排查那个信贷模型时,把概率阈值从0.5一路往下调,从0.3调到0.1,模型才开始放出几个正类预测,但精确率惨不忍睹。这说明模型根本没有从特征里学到正类样本的分布规律,而是被大量负类样本"带偏"了。更本质的问题是:数据量太少,模型无法从200个正样本里提取出足够稳定的模式。

1.2 SMOTE的设计思路:合成样本不是复制粘贴

对类别不平衡的处理思路,粗分两大流派:一是从算法层面做代价敏感学习,给少数类更高的误判代价;二是从数据层面做重采样。数据层面又分欠采样(删负类)和过采样(增正类)。最简单的过采样是直接复制少数类样本,这种做法我一开始也试过,模型确实会多关注正类,但本质上只是在重复已有的信息,容易导致过拟合,泛化能力几乎没有提升。

SMOTE的核心区别在于:它不是复制现有样本,而是在特征空间中通过插值合成全新的样本。它会选取少数类样本的K个近邻,然后在这条连线上随机生成新样本点。换句话说,SMOTE是在少数类样本的"周围"重新补充了一个分布上合理的新点,这让后续训练的模型能看到更丰富的正类样本形态,而不是反复背诵那几张旧面孔。

从结果来看,SMOTE能有效缓解少数类样本稀疏导致的学习不充分问题,同时因生成样本不是简单的重复,过拟合风险也比朴素过采样小得多。这也是它成为不平衡学习领域经典算法的根本原因。

2. 核心原理拆解:KNN找邻居与线性插值造数据

2.1 三步流程:从少数类样本出发造出新样本

SMOTE的算法流程并不复杂,一共三步。假设当前我们要对少数类样本集做 (N) 倍过采样,每个原始样本需要生成 (N) 个新样本:

  1. 对少数类样本集合中的每个样本 (x_i),用欧氏距离找出它在少数类样本中的 (k) 个近邻;
  2. 从这 (k) 个近邻中随机抽取一个邻居 (x_{near});
  3. 在 (x_i) 与 (x_{near}) 之间的线段上,随机取一个插值点作为新样本: [ x_{new} = x_i + \lambda \cdot (x_{near} - x_i) ] 其中 (\lambda) 是 ([0, 1]) 区间上的均匀随机数。

这一步的几何意义非常直观:新样本一定落在少数类样本与其近邻连线之间的区域内,不会跑到特征空间里离谱的位置。换句话说,SMOTE是在"少数类样本云团"的内部和边缘做填充,让这个类别的样本密度变大、形态更完整。

这里的 KNN 近邻数是SMOTE的核心超参数,日常默认取 (k=5)。但如果少数类样本总量特别少,比如只有十几条,那 (k) 要相应调小,甚至取2或3,否则近邻中会混入距离很远的样本,插值出来的点容易偏离真实分布。

2.2 为什么插值参数要随机

关于 (\lambda) 的取值,我在初学的时候有个疑问:为什么不固定取中点,这样生成的样本看起来更"居中"、更稳定?后来结合实验结果想明白了。

如果每次插值都取固定比例,生成的样本会全部落在一条条固定的连线上,形态高度相似,多样性不足,模型照样容易过拟合。而 (\lambda) 随机从 ([0,1]) 中取值,每次生成的样本在连线上的位置随机浮动,同一对样本点可以派生出无数个位置不同的新样本。这不仅增加了样本数量,还增加了样本多样性,让模型在决策边界附近学到的形态更平滑。

不过要注意,论文原版里 (\lambda) 是完全均匀随机。实际工作中偶尔会把 (\lambda) 限制在 ([0.2, 0.8]) 之间,避免新样本与某个原始样本靠得太近,减少噪声。这个根据数据特点自行调整就好,没有绝对的对错。

2.3 k值和采样倍率N的实用取值建议

SMOTE 有两个主要参数:近邻数 (k) 和采样倍率 (N)。

(k) 值的选择依赖少数类样本总量。经验上看,当少数类样本只有几百条时,(k) 取 5 是稳妥的起步值;样本量特别少(小于50条)时,建议降为 3;如果数据噪声较大,可以适当增大 (k),让插值样本更偏向密集区域,减少随机性。

(N) 的确定取决于你想把少数类样本扩到多少。假设少数类有 200 条,多数类有 9800 条,若 (N=10),则少数类变成 2000 条,正负比变成约 1:4.9,已经比原来的 1:49 健康很多。通常建议过采样后的少数类占总体比例为 20% 到 40% 即可,没有必要追求完全50%对50%的比例。过高的过采样比例会让模型死记少数类的局部噪声,通常会带来过拟合。

3. Matlab完整实现:核心函数与调用示例

3.1 基础版SMOTE函数:pdist2加速距离计算

Matlab 里实现 SMOTE 最方便的地方在于内置了pdist2函数,可以一次性算出所有少数类样本两两之间的距离矩阵,省去自己写双层遍历的麻烦。下面是可直接复制运行的函数:

function X_new = smote_minority(X, k, N) % SMOTE 对某个类别做合成过采样 % 输入: % X - 该类样本矩阵,m行d列,每行是一个样本 % k - 近邻数量,默认 5 % N - 每个原始样本要生成的新样本数量,默认 1 % 输出: % X_new - 合成样本矩阵,m*N行d列 if nargin < 3 || isempty(N) N = 1; end if nargin < 2 || isempty(k) k = 5; end [m, d] = size(X); k = min(k, m - 1); % 防止样本数少于 k+1 X_new = zeros(m * N, d); idx_new = 1; % 计算所有样本两两之间的欧氏距离 D = pdist2(X, X); for i = 1:m % 距离从小到大排序,索引第1位是样本自身,跳过 [~, ord] = sort(D(i, :)); neighbors = ord(2:k+1); for j = 1:N % 随机选择一个近邻 nn = neighbors(randi(k)); % 随机插值比例 lambda = rand(); X_new(idx_new, :) = X(i, :) + lambda .* (X(nn, :) - X(i, :)); idx_new = idx_new + 1; end end end

这段代码里有两个细节值得说明。第一,k = min(k, m - 1)这行非常重要,因为如果少数类只有 3 条样本而你传了 (k=5),排序之后根本找不到 5 个近邻,代码直接报索引越界。这种边界处理属于"平时用不上,遇上就头疼"的类型。第二,使用pdist2(X, X)会在样本量大的时候占用较多内存,但如果少数类样本量在几千以内,完全没问题,而且比每次调用knnsearch再两两查一遍要清爽。

如果少数类样本实在太多,超过万级,pdist2生成的 (m \times m) 距离矩阵会非常吃内存,那时建议改用knnsearch分批查询近邻,核心逻辑不变,只是性能优化层面的取舍。

3.2 主脚本示例:从数据划分到调用SMOTE

下面用一个完整的主脚本演示SMOTE在分类流程里的位置。这里假设你已经有一个特征矩阵X_all和标签向量y_all,多数类为0,少数类为1。

% 加载数据(此处为示意) % load('your_data.mat'); % 假设里面有 X_all 和 y_all rng(42); % 固定随机种子,便于复现 % 划分训练集和测试集 cv = cvpartition(y_all, 'Holdout', 0.3); X_train = X_all(training(cv), :); y_train = y_all(training(cv), :); X_test = X_all(test(cv), :); y_test = y_all(test(cv), :); % 提取训练集中的少数类样本 X_min = X_train(y_train == 1, :); num_min = size(X_min, 1); num_maj = sum(y_train == 0); % 计算需要合成多少倍,这里目标是把少数类扩到多数类的三分之一左右 target_min = round(num_maj / 3); N = ceil(target_min / num_min); % 调用SMOTE生成新样本 X_syn = smote_minority(X_min, 5, N); y_syn = ones(size(X_syn, 1), 1); % 合并原始训练集和合成样本 X_aug = [X_train; X_syn]; y_aug = [y_train; y_syn]; % 训练分类器(示例用逻辑回归) mdl = fitclinear(X_aug, y_aug, 'Learner', 'logistic'); % 预测并评估 y_pred = predict(mdl, X_test); confmat = confusionmat(y_test, y_pred); disp(confmat);

这里强调一点:SMOTE 必须只在训练集上操作,测试集保持原始分布不动。如果你把 SMOTE 应用到全量数据再划分训练测试集,会导致测试集中也混入合成样本,评估结果会虚高,因为测试集不再是真实世界分布的反映。这个问题我在初学时踩过,后面专门排查了很久才意识到是数据泄漏。

3.3 参数选择:先划分还是先过采样

很多人会踩一个顺序坑:先对整个数据集过采样,再做训练测试集划分。这样做在代码执行上没有问题,但逻辑上是错误的。测试集的意义在于模拟未来真实数据,而真实数据中的少数类就是那么稀少,不能人为把它变多。正确的流程是:

  1. 先把原始数据划分成训练集和测试集;
  2. 只在训练集上执行 SMOTE 或任何重采样操作;
  3. 用增强后的训练集训练模型;
  4. 用保持原始分布的测试集评估。

我把这个顺序放在使用SMOTE前最需要确认的一件事,因为它直接决定实验结论是否可信。

4. 效果怎么验证:别让准确率骗了你

4.1 为什么准确率在类别不平衡下失去参考价值

如果还是只看准确率,那么SMOTE做与不做,训练出来的模型准确率可能差别不大,甚至会因为合成样本让模型误判一些多数类样本,导致准确率轻微下降。但这不代表SMOTE没有价值,而是评估指标选错了。

在一个正负比 1:49 的数据集上,一个"全猜负类"的模型准确率就有 98%。如果你改进后的模型准确率还是 98%,但召回率从 0% 升到了 60%,这就是巨大的提升。准确率完全体现不出这种进步。

所以在评估SMOTE效果时,我至少会看三个指标:召回率(Recall)、精确率(Precision)和 F1-score。召回率回答"真正的少数类里我抓住了多少",精确率回答"我预测为少数类的样本里有多少是对的",F1 则是两者的调和平均,适合在二者之间取平衡。

4.2 用混淆矩阵、召回率和AUC做对比实验

我在项目中通常做一组对比实验:一组用原始训练集训练模型,一组用SMOTE过采样后的训练集训练模型,然后在同一个测试集上分别计算混淆矩阵、召回率、精确率、F1 和 AUC。这样能很直观地看到SMOTE带来了什么变化。

Matlab 里计算这些指标很顺手:

% 原始训练模型 mdl_raw = fitclinear(X_train, y_train, 'Learner', 'logistic'); y_pred_raw = predict(mdl_raw, X_test); confmat_raw = confusionmat(y_test, y_pred_raw); % SMOTE训练模型 mdl_smote = fitclinear(X_aug, y_aug, 'Learner', 'logistic'); y_pred_smote = predict(mdl_smote, X_test); confmat_smote = confusionmat(y_test, y_pred_smote); % 从混淆矩阵计算指标 TP = confmat(2,2); FP = confmat(1,2); FN = confmat(2,1); recall = TP / (TP + FN); precision = TP / (TP + FP); F1 = 2 * precision * recall / (precision + recall); % ROC与AUC [~, score] = predict(mdl_smote, X_test); [X_roc, Y_roc, ~, AUC] = perfcurve(y_test, score(:,2), 1);

perfcurve是 Matlab 里画 ROC 曲线、计算 AUC 的标准函数,输出AUC就是曲线下面积。AUC 的好处是不需要设定分类阈值,能从整体上评价模型的排序能力,对于不平衡分类问题非常合适。

我实测过多次,在多数场景下,SMOTE 后的模型 Recall 有明显提升,AUC 也往往高于原始模型。但也有少数情况,比如原始少数类样本噪声很大、重叠严重时,SMOTE 后的 Precision 会下降,这时需要结合 F1 来综合判断。

5. 我踩过的坑与进阶方向

5.1 三个典型坑:先划分、连续特征假设和过采样比例失控

第一个坑是数据泄漏,前面已经提到,不再赘述。第二个坑更隐蔽:SMOTE 对离散特征和分类特征非常不友好。它的插值公式默认特征取值是连续实数,如果某个特征是 0/1 的二值特征,插值计算后可能得到 0.3、0.6 这样的中间值,这在业务上完全没法解释。我在处理含大量哑变量的数据时,吃过这个亏,模型指标看着不错,但一上业务系统,规则解释全部乱套。解决思路是把连续特征和离散特征分开处理——只对连续特征做SMOTE,离散特征从近邻样本中直接复制一份取值,甚至放弃SMOTE改用 SMOTENC 这种专门处理混合数据类型的变体。

第三个坑是过采样比例失控。有人为了追求"完美平衡",把少数类扩到和多数类一样多,结果模型在训练集上表现极好,测试集一验证立刻露馅——因为合成样本已经严重偏离真实分布,模型学会了太多只属于"合成区域"的噪声。我通常会让少数类占训练集的 20% 到 35%,先跑一版看 F1,再微调比例,而不是一上来就强行 1:1。

5.2 进阶变体:Borderline-SMOTE、ADASYN 和 SMOTE-ENN

基础版 SMOTE 对所有少数类样本一视同仁,不看它们处在什么位置。但在实际数据中,处在类别边界的少数类样本往往比处在类别内部的样本更有学习价值。边界样本离多数类近,模型容易分错,恰恰是需要重点关注的区域。

Borderline-SMOTE 的思路就是先把少数类样本分成"边界样本"和"安全样本",只对边界样本做插值合成。这样生成的样本集中分布在决策边界附近,能显著提升模型在边界区域的分辨能力。实现时需要在SMOTE流程前面先算每个少数类样本的 m 个近邻中有多少个多数类样本,按比例判定它是否处在边界。

ADASYN 则是按难度自适应分配合成数量——每个少数类样本需要合成多少新样本,取决于它周围多数类样本的密集程度,周围多数类越多,就给它分配越多的合成量。这个思路比 Borderline-SMOTE 更细腻,实现也不算复杂。

此外,SMOTE 和清理技术组合的方案也值得提一下。SMOTE-ENN 是先 SMOTE 过采样,再用 ENN(Edited Nearest Neighbours)把那些与多数类邻居混杂不清的样本删掉,相当于先增后减,把噪声清理出去。SMOTE-Tomek 也是类似思路,通过删除 Tomek links 来降低类别重叠。这两个组合方法在样本噪声比较大的场景下表现往往比单纯 SMOTE 更稳定。

我对 SMOTE 的定位是"常用但不万能"。它非常适合连续特征、样本量适中、类别边界比较清晰的数据。如果你的数据维度特别高、稀疏性强,或者大量离散特征,就需要对基础方法做调整。最终还是那句话:SMOTE 不是越用越多越好,参数调整要结合验证集结果来定,别拍脑袋把少数类扩到天上去。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询