资讯动态

Matlab实现SMOTE算法:不平衡分类过采样原理与调参避坑指南

发布时间:2026/10/17 13:59:17 来源:尧图企业网站定制
简介面向不平衡数据集处理场景的数据科学家与机器学习开发者这份MATLAB版SMOTE算法实现完整给出Chawla等人提出的合成少数类过采样核心代码适用于分类任务中少数类样本不足、模型过度偏向多数类等典型问题。压缩包共含5个文件其中2个m文件分别承担算法主实现与测试用例另有txt格式许可证说明及Git管理配置文件整体仅3KB轻量精简便于直接嵌入既有数据预处理流程或作为二次改造的起点。已有1322人学习中高级使用者可借助该实现快速生成合成样本理解基于K近邻线性插值合成新数据点的关键逻辑并以此为起点尝试与Tomek Link、EasyEnsemble等策略组合在不同数据集上对比过采样效果从而提升少数类识别能力与模型泛化性能。1. 先说结论SMOTE算法matlab版本值得自己实现一次但别急着套用在做不平衡分类时绝大多数人第一反应就是下采样多数类或过采样少数类。SMOTE算法matlab版本属于后者但它不是简单复制少数类样本而是在少数类样本之间插值合成出看起来像但又不完全相同的新样本。我第一次在某个工业质检项目里用它把缺陷样本从30条扩到200条分类器召回率从62%提到81%但后来换了数据集效果却崩了。这套matlab实现的SMOTE代码适合正在做故障检测、欺诈识别、医学诊断这类正负样本比例悬殊的从业者也适合想搞懂插值过采样原理、准备自己改算法的研究生。它不是开箱即用的黑匣子——k近邻数、距离度量、特征空间是否连成片都会直接决定合成样本的质量。这篇文章会把matlab版本的实现拆开从公式到完整函数再到调参和踩坑帮你把它真正落地。2. SMOTE原理与Matlab实现思路从插值公式到向量化写法2.1 插值公式与k近邻选取为什么新样本“看起来像”原样本SMOTESynthetic Minority Over-sampling Technique的核心是对少数类里的每个样本x在它的k个同类别近邻中随机选一个近邻x_neighbor然后沿两者连线方向随机插值。插值公式是x_new x delta * (x_neighbor - x)其中delta是[0,1]之间的随机数控制新样本落在连线的哪个位置。delta越接近0新样本越靠近原始样本越接近1越靠近近邻样本。这个公式决定了新样本不会偏离原始分布太远因为所有合成点都被限制在少数类样本的凸包内部或者说在已有样本张成的空间内。这里有个关键点k近邻的选取必须用同一个特征空间。如果特征取值范围差异很大比如某个特征范围是[0,1]另一个是[0,10000]那么距离计算会被大数值特征主导近邻选出来实际上只在一个维度上近合成样本就失去意义。所以后面章节会专门强调归一化。在matlab里实现时最常见的方法是先算少数类样本的成对距离矩阵然后对每一行排序取前k个。这里我一般不用matlab自带的knnsearch而是用pdist2直接算距离矩阵因为SMOTE要针对所有少数类样本生成多个新样本一次性算出距离矩阵可以让后续循环更可控也方便在调试时直接观察距离分布。% 少数类特征矩阵 X_min每行一个样本 D pdist2(X_min, X_min); D(1:size(X_min,1)1:end) Inf; % 自身距离设为无穷大防止把自己选作近邻 [~, idx] sort(D, 2); neighbors idx(:, 1:k); % 每行存的是该样本的k个近邻索引这段代码的逻辑是pdist2得到所有少数类样本两两间的欧氏距离对角线上的自身距离被设置为Inf排序后近邻索引就不包含自己。neighbors矩阵的行对应每个少数类样本列是k个近邻在原矩阵中的行号。注意这里默认距离度量为欧氏距离如果特征分布特殊可以换成cosine或cityblock但换度量之前先想想特征本身有没有可比性。2.2 快速实现框架用pdist2代替逐样本循环很多入门版本的SMOTE写的是三重循环外层遍历少数类样本中层遍历要生成的个数内层遍历特征维度做插值。这种做法在小数据集上没问题但样本量一旦到万级别速度会慢到让人怀疑人生。我通常的做法是先用矩阵运算生成插值对的索引矩阵再用一个循环按批次生成新样本。N 3; % 每个少数类样本生成N个新样本 [n_min, n_dim] size(X_min); new_X zeros(n_min * N, n_dim); for i 1:n_min chosen neighbors(i, randi(k, N, 1)); % 从k个近邻中随机抽取N个 delta rand(N, 1); % 每个近邻对应一个插值系数 diff X_min(chosen, :) - X_min(i, :); new_X((i-1)*N (1:N), :) X_min(i, :) delta .* diff; end这里的randi(k, N, 1)是关键对每个原始样本i随机抽取N个近邻索引。注意近邻索引可能重复这是允许的因为每次的delta不同生成的新样本位置仍然不同。diff是当前样本与选中近邻的差值向量delta是N行1列的随机数通过广播机制与diff逐行相乘然后加上原始样本位置。这样每个原始样本生成N个新样本最终总数是n_min*N。从实现角度看这个框架比逐样本循环生成少了一层最内层的特征维度循环同时利用matlab的矩阵广播大幅缩短执行时间。如果你在写自己的版本可以额外考虑当N不是整数时比如过采样倍率是1.5需要先对部分样本多生成一个这部分后面避坑章节会说。还有一个容易被忽略的点neighbors矩阵是排序后取的k个近邻如果少数类样本数本身就小于k会出问题。一般我会加一个断言要求n_min k1。3. 完整可运行的SMOTE函数参数说明与调用示例3.1 函数签名与输入输出定义我把SMOTE封装成一个独立函数输入是少数类特征矩阵、过采样倍率、近邻数和距离度量方式输出是合成样本矩阵。函数签名如下function [X_syn] smote_mine(X_min, N, k, metric) % 输入 % X_min - 少数类样本特征矩阵每行一个样本大小 n_min x n_dim % N - 每个少数类样本需要合成的数量整数至少为1 % k - 近邻数量默认5 % metric - 距离度量默认euclidean可选cosine、cityblock % 输出 % X_syn - 合成样本矩阵大小 (n_min*N) x n_dim这个设计是为了让调用方灵活调整参数。N在这里是整数倍也就是说如果要让少数类总数翻倍N1即可如果要翻两倍N2。为什么不用直接的百分比因为很多实际代码里N是整数配合后续循环更直观。如果你用的是网上流传的原始SMOTE论文N为整数百分比的倍数本质一样。参数metric容易被忽视。默认欧氏距离适用于大多数连续数值特征但如果特征经过了PCA降维或标准化欧氏距离是稳妥的如果特征方向性明显、模长无意义用cosine相似度更合理。cityblock曼哈顿距离适合特征单位相近、且你希望避免大数值特征主导的场景。注意改变metric后你的特征预处理策略也要跟着调整否则距离和插值公式之间可能不匹配。3.2 代码实现完整的matlab函数接上面的框架写成完整可运行函数。这个版本处理了三个边界情况k大于少数类总数时自动截断N为0时返回空矩阵输入包含NaN时报错。近邻索引允许重复因为delta不同。function [X_syn] smote_mine(X_min, N, k, metric) if nargin 3, k 5; end if nargin 4, metric euclidean; end [n_min, n_dim] size(X_min); if n_min k 1 k n_min - 1; warning(少数类样本过少k已调整为 %d, k); end if N 0 X_syn zeros(0, n_dim); return; end if any(isnan(X_min(:))) error(X_min 含 NaN请先清洗数据); end % 计算距离矩阵并去掉对角线 D pdist2(X_min, X_min, metric); D(1:n_min1:end) Inf; [~, idx] sort(D, 2); neighbors idx(:, 1:k); % 逐样本生成 X_syn zeros(n_min * N, n_dim); for i 1:n_min chosen_idx randi(k, N, 1); chosen neighbors(i, chosen_idx); delta rand(N, 1); diff X_min(chosen, :) - X_min(i, :); X_syn((i-1)*N (1:N), :) X_min(i, :) delta .* diff; end end这段代码的逻辑说明pdist2加metric可以直接返回指定距离把对角线置Inf是为了排序时跳过自身warning处理了少数类数量少于k的极端情况但我不建议k设得太大否则合成样本会集中在少数类簇的“内部”失去插值的覆盖作用。randi产生的是行向量转置成列向量后用于行索引这里要注意matlab的索引方向曾经因为忘了转置导致每次选到的近邻索引不对合成样本全是重复的排查了半天。X_syn X_min(i, :) delta .* diff这行里delta是N×1diff是N×n_dimmatlab会自动把delta广播到每一列这是R2016b之后才支持的行为。如果你的版本老需要改成repmat(delta,1,n_dim) .* diff。3.3 调用示例与合成结果可视化我们用一个容易观察的二维人工数据来验证。假设少数类有60个样本服从两个小簇的正态分布。调用函数后把原样本和合成样本画在同一张图上直观检查合成样本是否落在合理区域。rng(42); X_min [randn(30,2)*0.3 [1,1]; randn(30,2)*0.3 [-1,-1]]; N 5; k 5; X_syn smote_mine(X_min, N, k, euclidean); figure; plot(X_min(:,1), X_min(:,2), ko, MarkerSize, 6); hold on; plot(X_syn(:,1), X_syn(:,2), r, MarkerSize, 4); legend(原始少数类,合成样本);这段代码用rng固定随机种子便于复现X_min的两个簇分别位于[1,1]和[-1,-1]附近合成点应该均匀出现在两个簇之间以及簇内。如果N5合成样本总数是300个。画图时如果发现合成样本偏离到簇外很远大概率是因为某个特征的标准差太大或者k选择得太大导致近邻跨越了两个簇这时可以退回k3试试。可视化这一步非常重要因为它能快速暴露距离度量或归一化的问题不用等你跑完整个分类流程才发现。4. 在实际分类任务中验证从特征归一化到评估指标4.1 实验设计用交叉验证避免数据泄漏SMOTE最常见的误用方式是把整个数据集含测试集一起过采样然后在过采样后的训练集上训练、在原始测试集上测试。这会造成数据泄漏因为模型看到的合成样本来自测试集分布的一部分最终评估指标虚高。正确做法是先把数据集划分成训练集和测试集只在训练集内部做SMOTE测试集保持原始比例。如果要调参可以在训练集里再划分验证集或者用交叉验证包裹SMOTE。提示交叉验证时每一折都需要单独对训练折做SMOTE验证折不要做任何过采样或欠采样。这是很多人忽略的细节。我通常会写一个流水线函数把SMOTE放到数据划分的“内侧”。下面给出一个结构化实验脚本的骨架。% 假设置特征矩阵X和标签yy中1为少数类0为多数类 rng(42); cv cvpartition(y, Holdout, 0.3); X_train X(training(cv), :); y_train y(training(cv)); X_test X(test(cv), :); y_test y(test(cv)); % 在训练集中分离少数类 minority_idx find(y_train 1); majority_idx find(y_train 0); X_min_train X_train(minority_idx, :); num_min length(minority_idx); num_maj length(majority_idx); % 计算需要合成的数量让少数类数量接近多数类 N floor(num_maj / num_min) - 1; if N 0 X_syn_train smote_mine(X_min_train, N, 5, euclidean); X_train_bal [X_train; X_syn_train]; y_train_bal [y_train; ones(size(X_syn_train,1), 1)]; else X_train_bal X_train; y_train_bal y_train; end这段代码的逻辑是用cvpartition固定30%作为测试集找到训练集中的少数类索引计算N 多数类数量/少数类数量 - 1意思是把少数类扩到和多数类同样数量。比如多数类3000少数类300N9合成2700个样本加上原有300总共3000。注意如果多数类是少数类的5.3倍floor之后N4合成后总数为原有5倍仍然不平衡。这时可以选择N5并截取一部分或者在函数内支持分数倍生成。我一般倾向N取整数因为后续可以在分类器class weight上再调整。4.2 代码训练与评估流程用过采样后的平衡数据集训练分类器我一般用fitcensemble随机森林或AdaBoost的封装。由于matlab的分类器大部分自带交叉验证选项但训练前调用SMOTE会影响样本量所以这里手动计算指标。mdl fitcensemble(X_train_bal, y_train_bal, ... Method, Bag, ... NumLearningCycles, 200, ... Learners, templateTree(MaxNumSplits, 20)); y_pred predict(mdl, X_test); C confusionmat(y_test, y_pred); TP C(1,1); FP C(2,1); FN C(1,2); TN C(2,2); precision_pos TP / (TP FP); recall_pos TP / (TP FN); f1_pos 2 * precision_pos * recall_pos / (precision_pos recall_pos);这里的confusionmat默认把1当作正类假设类别顺序为[0 1]时C(1,1)对应实际类别为1且预测为1其实confusionmat输出格式是行代表真实类列代表预测类且按grpOrder排序。为了保险建议显式指定顺序confusionmat(y_test, y_pred, Order, [1, 0])这样第一行第一列就是少数类的真正例。我曾经因为顺序没指定把TP和TN搞反了得到的召回率低得离谱还以为是SMOTE没用后来才发现是混淆矩阵读反了。fitcensemble里的templateTree限制每棵树的深度这是为了防止过拟合。SMOTE会让少数类样本在训练集中占据一半模型会倾向学到少数类的细节限制分裂数能降低过拟合。这里Bag方法配合200棵树在样本量不大的时候训练很快如果样本量大可以把循环数降到100。4.3 结果解读看召回率与F1别只看准确率不平衡数据下如果多数类占95%模型全预测多数类也拿到95%准确率。所以评估必须看正类的召回率少数类被找出来的比例和F1。SMOTE的目标是提升正类召回率同时不过度牺牲精确率。如果召回率提升了但精确率大幅下降说明合成样本让决策边界过于激进这时可以减小N或者让少数类数量只扩到多数类的0.5倍过度采样一半即保留一些不平衡性反而更好。我在那个工业质检项目里做过对比原数据少数类30条多数类500条直接用随机森林召回率62%SMOTE把少数类扩到200条后召回率到81%但精确率从78%降到59%后来把N从17降到10召回率76%精确率71%F1更高。这个例子说明SMOTE不是越大越好它只是给你更多“能被模型学到的位置”但学到的位置可能是噪声位置。如果你观察合成样本的质量可以用一个简单指标合成样本与其最近原始少数类样本的距离分布。如果大部分距离都大于特征空间标准差的一半说明合成点可能过度激进。这个检查可以放在调参阶段不一定要写到正式代码里。5. 常见问题与避坑记录为什么你的SMOTE效果不好5.1 现象合成样本与原样本重叠度高分类器没提升原因有两个一是k值太小比如k1时每个新样本都位于原样本与其最近邻连线上位置非常密集相当于在原样本附近加了抖动模型学不到新信息二是特征空间维度太高且未归一化近邻选择被大数值特征主导插值只在少数维度上变化合成样本几乎等于原样本。解决把k设为5到7之间在调用smote_mine之前对训练集特征做zscore标准化然后用同一个mean和std去transform测试集。注意不要在标准化之前就划分训练测试集否则测试集信息泄漏。mu mean(X_train); sigma std(X_train); X_train_std (X_train - mu) ./ sigma; % 之后再用X_train_std做SMOTE和训练 X_test_std (X_test - mu) ./ sigma;这段代码的逻辑是先在训练集上计算均值和标准差然后应用到测试集。这样测试集的标准化参数来自训练集不会把测试集的信息掺进训练过程。实际项目里我习惯把这个归一化写进一个函数便于在交叉验证时重复调用。5.2 现象模型过拟合严重训练集F10.99测试集却低原因在交叉验证的每一折里你先对整个训练集SMOTE然后划分出验证集导致合成样本和验证集中的原始样本极其相似因为验证集样本参与了SMOTE的近邻计算。模型在验证集上“见过”合成样本的位置评估虚高在真正的测试集上就露馅。解决严格遵循“先在训练折内再SMOTE”的流程。正确嵌套方式见第4章代码。如果用的是cvpartition需要在每个fold内部只用training那一部分做SMOTEtest部分完全不碰。另外如果你做SMOTE时N太大合成样本数量超过原始少数类的十倍模型会把很多注意力放在微小的插值变化上也会过拟合。建议N最大不超过10或者用分数倍控制在少数类总量在多数类的80%左右。5.3 现象离散特征如性别、地区编码被插值成小数原因SMOTE公式对所有特征做线性插值对类别型特征会产生“性别0.35”这种无意义的结果。如果我们把类别型特征编码成0/1或者1/2/3插值后变成小数模型无法正确解释。解决离散特征不参与SMOTE插值。常见做法是把特征分成连续特征和离散特征。连续特征做插值离散特征保持为原样本的值。修改smote_mine的调用方式传入continuous_maskfunction [X_syn] smote_mine_mixed(X_min, N, k, continuous_mask) % continuous_mask是逻辑数组1表示连续特征0表示离散特征 diff_all zeros(N, size(X_min,2)); for i 1:n_min chosen neighbors(i, randi(k, N, 1)); delta rand(N, 1); diff (X_min(chosen, :) - X_min(i, :)) .* continuous_mask; X_syn((i-1)*N (1:N), :) X_min(i, :) delta .* diff; end逻辑说明diff乘上continuous_mask后离散特征的差值为0新样本的离散特征就等于原样本的离散特征。这样合成样本不会出现0.35这种特征值。如果你不想修改函数也可以在调用前把离散特征从特征矩阵中单独提出来SMOTE只处理连续特征最后再拼接回去。推荐后者因为更容易调试也能保留原始特征的语义。5.4 现象生成大量重复样本原因当少数类样本中有大量完全相同的重复行比如某类故障记录里很多样本的特征都被置为默认值pdist2计算距离时这些样本之间距离为0排序后近邻索引会大量指向这些重复样本。合成样本插值后等于原样本导致重复。解决清洗阶段先去除全重复行如果特征中有大量0值或常数特征对这类特征做SMOTE没有意义先做特征筛选去掉方差几乎为0的列。还有一种情况是k值接近少数类样本数近邻选择随机性不够生成的新样本位置都比较集中。把k降到5以下重复率会明显减少。你可以统计合成样本中完全重复的数量来验证X_syn_unique unique(X_syn, rows); dup_rate 1 - size(X_syn_unique,1) / size(X_syn,1); fprintf(重复率: %.2f%%\n, dup_rate*100);如果dup_rate超过5%就要检查数据质量和k设置。我另一个项目里发现重复率高达40%后来发现原因是特征工程时把某个ID列当作特征加进来了ID重复导致距离为0。去掉ID列后重复率降到0。5.5 现象评估指标虚高但业务反馈模型没用原因模型在数据分布上和测试集指标都好看但实际场景里的数据分布与训练集不同。SMOTE是假设少数类样本周围是同类区域如果实际场景中少数类样本位于高维流形的边界之外插值只会制造出不符合真实分布的样本。另一个常见原因是没有考虑时间序列的数据泄漏比如对时间维顺序打乱后再SMOTE合成样本可能跨越了时间窗这在故障预测里很致命。解决对于时序数据SMOTE不能直接用于原始时间顺序。需要先按时间窗口构造特征每个窗口作为一个样本划分训练测试时按时间先后切分而不是随机划分SMOTE只应用于训练时间段内。如果你处理的是非时序数据也要始终记得SMOTE提升的是训练分布内的少数类覆盖对新品类、新模式无效。最好的防御是在业务上线时记录真实少数类样本定期用新样本微调模型。6. 进阶玩法Borderline-SMOTE与调参基线6.1 只对边界样本过采样鉴别边界样本的两种判据基础SMOTE对所有少数类样本一视同仁这在类别重叠较严重时会扩大噪声。Borderline-SMOTE的思路是只对“处于边界区域”的少数类样本进行过采样。判别方法通常有两种第一种是看样本的k个最近邻中多数类的数量如果多数类邻居数量介于某个比例比如k的一半到全部之间就认为该样本在边界第二种是看该样本到多数类簇中心的相对距离。matlab实现时可以在原有函数基础上加一个步骤k_border round(k * 0.7); D_all pdist2(X_min, X_major); % 少数类到多数类的距离矩阵 neighbor_major_count zeros(n_min, 1); for i 1:n_min neighbor_major_count(i) sum(D_all(i, :) prctile(D_all(i, :), 50)); end border_idx neighbor_major_count 0 neighbor_major_count k_border;这里逻辑是对每个少数类样本统计多数类距离小于中位数的数量数量在0到k_border之间的视为边界样本。然后对border_idx选中的样本调用smote_mine。实际项目里边界样本的合成数量可以设置成普通样本的2~3倍这样决策边界会贴近少数类真实分布的边缘而不是在簇中心拼命合成。6.2 用GridSearch找最优k与倍率一个简单脚本SMOTE的参数k近邻数和N合成倍率没有理论最优值只能在具体任务上试。我习惯写一个极简的网格搜索评估指标用少数类的F1并且固定随机种子保证每个参数组合的划分一致k_list [3, 5, 7]; N_list [3, 5, 8]; best_f1 -Inf; best_k 0; best_N 0; % 外层做5折交叉验证 rng(42); cvi cvpartition(y_train, KFold, 5); for ki 1:length(k_list) for ni 1:length(N_list) f1_fold zeros(5,1); for f 1:5 trIdx cvi.training(f); vaIdx cvi.test(f); X_tr X_train(trIdx,:); y_tr y_train(trIdx); X_va X_train(vaIdx,:); y_va y_train(vaIdx); % SMOTE训练部分 min_tr X_tr(y_tr1,:); maj_tr X_tr(y_tr0,:); X_syn smote_mine(min_tr, N_list(ni), k_list(ki), euclidean); X_bal [X_tr; X_syn]; y_bal [y_tr; ones(size(X_syn,1),1)]; % 训练并用验证集评估 mdl fitcensemble(X_bal, y_bal, Method, Bag, ... NumLearningCycles, 50); y_pred predict(mdl, X_va); C confusionmat(y_va, y_pred, Order, [1,0]); tp C(1,1); fp C(2,1); fn C(1,2); if (tp fp) 0 || (tp fn) 0 f1_fold(f) 0; else prec tp/(tpfp); rec tp/(tpfn); f1_fold(f) 2*prec*rec/(precrec); end end mean_f1 mean(f1_fold); if mean_f1 best_f1 best_f1 mean_f1; best_k k_list(ki); best_N N_list(ni); end end end这段代码解开了三件事一是k/N组合通过5折交叉验证的平均F1来排选二是每一折里都只对训练折做SMOTE验证折保持原样三是score统一用Order[1,0]保证混淆矩阵方向一致。网格搜索的代价是5×2×2×5次训练等于是100次集成学习训练。通常数据集在几千条时跑起来还快上万条时可以适当减少折数或循环次数。如果你用的是matlab的ClassificationPartitionedModel也可以把fitcensemble换掉但注意SMOTE必须放在训练内部。从那以后我每次跑SMOTE实验都强制走一遍这个网格搜索流程哪怕只用三个k值和三个N值至少能让参数选择有据可依而不是靠感觉把k设成5就万事大吉。我还在脚本最后多写一行fprintf(best k%d, N%d, F1%.3f\n, best_k, best_N, best_f1)把结果留在命令窗口里方便回溯。希望这篇拆解能帮你把SMOTE在matlab里用得更稳少踩几步我踩过的坑。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑