SMOTE算法实战:从零实现到类别不平衡问题的Python解决方案

1. 为什么我们需要SMOTE?从数据失衡的“坑”说起

朋友们,不知道你们在训练分类模型时有没有遇到过这种情况:模型在训练集上准确率高达95%,结果一到测试集,或者实际应用时,表现就一塌糊涂。我刚开始做机器学习项目时就踩过这个坑,当时花了好几天调参、换模型,效果都不理想。后来一检查数据才发现,问题出在数据本身——我的数据集中,正样本有9000条,负样本只有100条,模型根本“学不会”识别那个稀少的类别。

这就是典型的类别不平衡问题。想象一下,你让一个学生去学习分辨猫和老虎,但你给他看的1000张图片里,999张都是猫,只有1张是老虎。考试时,他只要把所有图片都猜成猫,就能拿到99.9%的“准确率”,但他真的学会认老虎了吗?显然没有。我们的机器学习模型也一样,它会倾向于预测那个样本量大的类别,因为这样“蒙对”的概率更高,但这完全违背了我们的初衷。

在真实世界里,这种不平衡无处不在:金融欺诈检测中,正常交易远多于欺诈交易;医疗诊断中,健康样本远多于患病样本;工厂质检中,合格品远多于瑕疵品。如果我们直接用原始的不平衡数据去训练,模型会变得非常“懒惰”和“偏见”。

那么,怎么解决呢?最直接的想法是让两类数据一样多。传统方法有两种:一是对多数类欠采样,随机扔掉一些数据,但这会损失宝贵的信息;二是对少数类过采样,简单复制几份,但这又容易导致模型过拟合,因为它只是记住了重复的样本。

这时候,SMOTE 就该登场了。它的全称是 Synthetic Minority Over-sampling Technique,翻译过来就是“合成少数类过采样技术”。它由 Nitesh V. Chawla 等人在2002年提出,核心思想不是简单地复制数据,而是“创造”新的、合理的少数类样本。它像一个聪明的数据艺术家,观察现有少数类样本的分布和特征,然后在它们的“邻里之间”巧妙地合成新的数据点。这样既能增加少数类的数量,又能让新样本具有多样性,避免过拟合。接下来,我就带你从零开始,手把手实现这个聪明的算法,并用它来解决实际问题。

2. SMOTE的核心思想:邻居与插值的艺术

SMOTE算法听起来很高端,但它的核心原理其实非常直观,可以用一个简单的比喻来理解。想象你在一个稀疏的居民区(少数类样本),你想让这个区域看起来更热闹、人口密度更合理。愚蠢的做法是给每家每户挂上多个门牌(简单复制),聪明的做法则是在已有的房子之间,根据周围环境和距离,合理地规划并建造新的房子(合成新样本)。

SMOTE做的就是这种“聪明地建造新房子”的工作。它的具体步骤可以分为三步:

第一步:找到目标样本的“好邻居”。对于少数类数据集中的每一个样本(比如一个坐标点),我们用K近邻算法(K-Nearest Neighbors)找出离它最近的K个同属于少数类的样本。这个K值是我们自己设定的,比如K=5,就是找最近的5个邻居。为什么找同类的邻居?因为我们要保证新合成的样本依然具有少数类的特征,不会跑到多数类的地盘上去。

第二步:在“老房子”和“邻居”之间选位置。随机从这K个近邻中挑选一个邻居。现在,我们有了一个“中心样本”和一个“邻居样本”,连接它们就得到了一条线段。新样本的位置,就将出现在这条线段上的某个随机点。

第三步:线性插值,“建造”新样本。这是最关键的一步,用到了线性代数里简单的线性插值。假设我们的中心样本点坐标是 A,邻居样本点坐标是 B。我们可以用下面这个公式来合成一个新样本点 C: C = A + λ * (B - A) 这里的 λ 是一个在0到1之间随机生成的数。(B - A) 表示从A指向B的向量,λ 控制着新点在这个向量方向上的位置。

  • 当 λ = 0 时,C = A,新点就是中心点本身(虽然我们不会这么选)。
  • 当 λ = 1 时,C = B,新点就是邻居点本身。
  • 当 λ = 0.5 时,C 恰好位于A和B连线的中点。
  • 当 λ 是0到1之间的其他随机数时,C 就落在A和B之间的线段上任意一点。

通过这种方式生成的点,既继承了中心样本A的特征,又融入了邻居样本B的信息,并且因为λ是随机的,所以每次生成的点都不同,保证了新样本的多样性。这个过程会重复进行,直到少数类样本的数量达到我们的预期。

注意:SMOTE主要适用于特征空间是连续数值型的数据。对于类别特征(比如“颜色:红、黄、蓝”),直接在特征值之间做插值可能没有意义,需要特殊的处理方式。

3. 从零手搓SMOTE:仅用NumPy构建完整类

理解了原理,我们就要动手实现了。我个人的习惯是,理解一个算法最好的方式就是自己从头写一遍。这里我们不依赖任何高级的机器学习库(如imbalanced-learn),只用最基础的NumPy和random,这样你对每一步的计算都会了然于胸。我们会构建一个SMOTE类,它清晰、可控,并且你可以指定想要生成的新样本数量。

3.1 搭建类的骨架与初始化

首先,我们导入必要的库,并定义类的结构。这个类需要三个核心方法:初始化、计算近邻、生成数据。

import numpy as np
import random
import matplotlib.pyplot as plt  # 用于后续可视化

class SMOTE(object):
    def __init__(self, sample, k=5, gen_num=100):
        pass

    def get_neighbor_point(self):
        pass

    def get_syn_data(self):
        pass

现在,我们来填充最关键的初始化部分 __init__。这个方法需要接收三个参数:

  1. sample: 需要被扩充的少数类样本,是一个二维NumPy数组,形状为 (n_samples, n_features)
  2. k: 寻找最近邻的个数,默认设为5。这里有个小技巧,如果样本数太少,k值不能大于 n_samples - 1
  3. gen_num: 你想要生成的新样本数量。

在初始化函数里,我们需要准备好后续计算需要的一切“材料”:

def __init__(self, sample, k=5, gen_num=100):
    # 1. 存储原始样本
    self.sample = sample

    # 2. 获取原始样本的“形状”:有多少个样本,每个样本有多少个特征
    self.sample_num, self.feature_len = self.sample.shape
    # 例如,sample是(10, 2)的数组,则sample_num=10, feature_len=2

    # 3. 处理k值,防止k大于可用的最近邻数
    self.k = min(k, self.sample_num - 1)

    # 4. 设定要生成的新样本数量
    self.gen_num = gen_num

    # 5. 预分配一个全零数组,用于存放最终生成的所有新样本
    # 它的形状是 (要生成的数量, 每个样本的特征数)
    self.syn_data = np.zeros((self.gen_num, self.feature_len))

    # 6. 预分配一个数组,用于存放每个原始样本的k个最近邻的索引
    # 它的形状是 (原始样本数, k),数据类型是整数
    self.k_neighbor = np.zeros((self.sample_num, self.k), dtype=int)

初始化工作就像盖房子前备好了砖瓦、水泥和图纸。self.syn_data 是我们准备用来装新样本的空容器,self.k_neighbor 则是用来记录每个样本的“邻居是谁”的记事本。

3.2 核心步骤一:为每个样本找到K个近邻

接下来,我们实现 get_neighbor_point 方法。它的任务是为 self.sample 中的每一个样本点,找出离它最近的k个“同类伙伴”的索引,并存入 self.k_neighbor

这里的关键是计算样本点之间的距离。我们采用最常用的欧几里得距离(即直线距离)。对于两个特征向量 point_apoint_b,其欧氏距离的平方是各个维度差值的平方和。我们直接计算平方,因为后面只需要比较相对大小,开根号计算量更大且不影响排序结果。

def get_neighbor_point(self):
    # 遍历原始样本中的每一个样本
    for index, single_signal in enumerate(self.sample):
        # 计算当前样本 single_signal 与所有样本(包括自己)的欧氏距离平方
        # 这里使用了列表推导式,非常高效
        euclidean_distance = np.array([np.sum(np.square(single_signal - i)) for i in self.sample])

        # 对距离进行排序,并获取排序后的索引。argsort()返回的是从小到大排序的索引值
        euclidean_distance_index = euclidean_distance.argsort()

        # 存储当前样本的k个最近邻的索引。
        # 注意:索引0是样本自己(距离为0),所以我们从索引1开始取,取k个。
        self.k_neighbor[index] = euclidean_distance_index[1:self.k + 1]

让我解释一下 argsort() 的妙用。假设 euclidean_distance 计算结果是 [0, 4, 1, 9],这意味着当前样本(索引0)与第0、1、2、3个样本的距离分别是0, 4, 1, 9。argsort() 会返回 [0, 2, 1, 3],表示第0个元素最小(距离0),第2个元素次小(距离1),第1个元素第三小(距离4),第3个元素最大(距离9)。我们跳过自己(索引0),取接下来的k个索引 [2, 1],它们就是当前样本最近的邻居。

3.3 核心步骤二:线性插值生成新样本

最后,我们实现数据生成的 get_syn_data 方法。这个方法会循环 gen_num 次,每次生成一个新的合成样本。

def get_syn_data(self):
    # 第一步:必须先计算好所有样本的近邻关系
    self.get_neighbor_point()

    # 第二步:循环生成指定数量的新样本
    for i in range(self.gen_num):
        # 1. 随机选择一个原始样本作为“中心点”
        center_index = random.randint(0, self.sample_num - 1)
        center_point = self.sample[center_index]

        # 2. 从该中心点的k个近邻中,随机选择一个作为“邻居点”
        #    首先获取中心点的所有近邻索引
        neighbors_of_center = self.k_neighbor[center_index]
        #    然后随机从中选一个索引
        neighbor_index = neighbors_of_center[random.randint(0, self.k - 1)]
        neighbor_point = self.sample[neighbor_index]

        # 3. 计算两个点之间的向量差
        gap = neighbor_point - center_point  # 这是一个向量

        # 4. 进行线性插值:新点 = 中心点 + 随机比例 * 向量差
        #    random.uniform(0, 1) 生成一个[0, 1)区间内的随机浮点数
        self.syn_data[i] = center_point + random.uniform(0, 1) * gap

    # 返回所有生成的新样本
    return self.syn_data

这个过程就像“连连看”游戏。随机选中一个点,再随机连向它的一个邻居,然后在这条连线上随机戳一个新点。重复这个过程,就能在少数类样本分布的区域内,“填充”出许多新的、合理的数据点。

4. 效果可视化:眼见为实的平衡过程

代码写完了,不看看效果怎么行?我们用一个简单的二维例子来可视化SMOTE的工作过程,这样你会对它有更直观的感受。我们生成一些原始的、稀疏的少数类样本点,然后用我们的SMOTE类来扩充它。

4.1 生成模拟数据并应用SMOTE

if __name__ == '__main__':
    # 1. 模拟生成原始的少数类样本:20个点,每个点有2个特征(方便画图)
    #    使用均匀分布生成0到1之间的随机数
    original_data = np.random.uniform(0, 1, size=[20, 2])

    # 2. 创建SMOTE实例
    #    参数:原始数据,寻找5个近邻,生成100个新样本
    smote_generator = SMOTE(sample=original_data, k=5, gen_num=100)

    # 3. 执行过采样,生成新数据
    new_synthetic_data = smote_generator.get_syn_data()

    # 4. 可视化
    plt.figure(figsize=(10, 5))

    # 绘制原始样本点,用蓝色圆点表示
    plt.scatter(original_data[:, 0], original_data[:, 1], c='blue', s=50, alpha=0.7, label='原始少数类样本')
    # 绘制合成样本点,用黄色三角形表示,并调小一点
    plt.scatter(new_synthetic_data[:, 0], new_synthetic_data[:, 1], c='orange', s=20, alpha=0.5, marker='^', label='SMOTE合成样本')

    plt.title('SMOTE过采样效果可视化 (k=5)')
    plt.xlabel('特征 1')
    plt.ylabel('特征 2')
    plt.legend()
    plt.grid(True, alpha=0.3)
    plt.show()

运行这段代码,你会看到一张散点图。蓝色的点是原始的20个少数类样本,分布可能比较稀疏。橙色的三角形是我们用SMOTE生成的100个新样本。仔细观察,你会发现几乎所有橙色点都落在蓝色点与蓝色点之间的连线上,或者其延长线附近(因为λ在0~1之间,点在线段上)。这正是线性插值的结果。

4.2 调整K值:观察决策边界的变化

K值是一个非常重要的超参数,它控制着合成新样本时参考的“邻居范围”。我们可以通过调整K值,来观察生成数据分布的变化。

# 对比不同K值的效果
k_values = [1, 3, 5, 10]
plt.figure(figsize=(15, 10))

for i, k in enumerate(k_values):
    plt.subplot(2, 2, i+1)
    smote_generator = SMOTE(sample=original_data, k=k, gen_num=100)
    new_data = smote_generator.get_syn_data()

    plt.scatter(original_data[:, 0], original_data[:, 1], c='blue', s=50, alpha=0.7, label='原始样本')
    plt.scatter(new_data[:, 0], new_data[:, 1], c='orange', s=15, alpha=0.5, marker='^', label='合成样本')
    plt.title(f'SMOTE效果 (k={k})')
    plt.xlabel('特征 1')
    plt.ylabel('特征 2')
    plt.legend()
    plt.grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

通过这个对比图,你可以清晰地看到:

  • 当K=1时:每个新样本只由一个“中心点”和它唯一的最远邻(其实是最近邻,但k=1)生成。所有新样本都严格落在连接两个原始样本的线段上,数据分布呈现明显的“射线”状,多样性最差。
  • 当K=3或5时:新样本的“选择”变多了。一个中心点可以和3个或5个不同的邻居连线,因此新样本的分布更加均匀、自然,能更好地模拟原始数据分布的潜在区域。
  • 当K=10甚至更大时:由于我们总共只有20个原始样本,K=10意味着几乎考虑了所有其他样本作为邻居。这可能导致新样本的生成过于“发散”,特别是当原始数据中存在噪声或离群点时,可能会将新样本生成到不合理的区域,甚至侵入多数类的空间。

提示:在实际应用中,K值通常设置为3、5或7这样较小的奇数。一般通过交叉验证来选择最优的K值。对于非常小的数据集,K值必须设得更小。

5. 实战演练:用SMOTE提升分类器性能

光说不练假把式,我们现在就把手搓的SMOTE用在一个简单的分类任务上,看看它到底能不能提升模型对少数类的识别能力。我们使用经典的手写数字数据集,并人为制造一个不平衡场景:把数字‘1’作为多数类,数字‘7’作为少数类。

5.1 准备不平衡数据集

from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import classification_report, confusion_matrix

# 加载数据
digits = load_digits()
X, y = digits.data, digits.target # X是特征,y是标签

# 人为制造不平衡:只选取数字1和7,并让1的数量远多于7
X_1 = X[y == 1]
y_1 = y[y == 1]
X_7 = X[y == 7]
y_7 = y[y == 7]

# 对数字7(少数类)进行下采样,使其数量仅为1的1/10
X_7_downsampled = X_7[:len(X_1)//10]
y_7_downsampled = y_7[:len(X_1)//10]

# 合并成不平衡数据集
X_imbalanced = np.vstack((X_1, X_7_downsampled))
y_imbalanced = np.hstack((y_1, y_7_downsampled))

print(f"不平衡数据集形状: {X_imbalanced.shape}")
print(f"类别‘1’的数量: {len(y_imbalanced[y_imbalanced==1])}")
print(f"类别‘7’的数量: {len(y_imbalanced[y_imbalanced==7])}")
print(f"类别比例(7:1)约为: 1:{len(y_1)//len(y_7_downsampled)}")

5.2 在不平衡数据上训练模型(基准)

我们先在不做任何处理的数据上训练一个逻辑回归模型,作为性能基准。

# 划分训练集和测试集
X_train, X_test, y_train, y_test = train_test_split(X_imbalanced, y_imbalanced, test_size=0.3, random_state=42, stratify=y_imbalanced)

# 训练逻辑回归模型
model_baseline = LogisticRegression(max_iter=1000, random_state=42)
model_baseline.fit(X_train, y_train)
y_pred_baseline = model_baseline.predict(X_test)

# 评估模型
print("=== 基线模型(不平衡数据)性能 ===")
print(classification_report(y_test, y_pred_baseline, target_names=['Class 1', 'Class 7']))
print("\n混淆矩阵:")
print(confusion_matrix(y_test, y_pred_baseline))

运行后你很可能会看到,模型对多数类‘1’的查准率、召回率都很高,但对少数类‘7’的召回率(Recall)非常低。这意味着模型把很多真正的‘7’都错误地预测成了‘1’。混淆矩阵也会显示,少数类那一行的数字很小。

5.3 应用我们的SMOTE进行数据平衡

现在,我们在训练集上对少数类‘7’应用SMOTE,平衡数据后再训练模型。

# 从训练集中分离出少数类样本(数字7)
X_train_minority = X_train[y_train == 7]
y_train_minority = y_train[y_train == 7]

print(f"训练集中少数类‘7’的样本数: {len(X_train_minority)}")

# 使用我们手写的SMOTE类进行过采样
# 假设我们想将少数类样本增加到和多数类(数字1)一样多
desired_num = len(y_train[y_train == 1]) - len(X_train_minority)
if desired_num > 0:
    smote = SMOTE(sample=X_train_minority, k=5, gen_num=desired_num)
    X_synthetic = smote.get_syn_data()
    y_synthetic = np.full(desired_num, 7)  # 生成对应的标签

    # 将合成数据与原始训练集合并
    X_train_balanced = np.vstack((X_train, X_synthetic))
    y_train_balanced = np.hstack((y_train, y_synthetic))
else:
    # 如果少数类已经比多数类多(理论上不会发生),则不做处理
    X_train_balanced, y_train_balanced = X_train, y_train

print(f"平衡后训练集形状: {X_train_balanced.shape}")

5.4 在平衡数据上重新训练并对比

# 在平衡后的数据上训练新模型
model_smote = LogisticRegression(max_iter=1000, random_state=42)
model_smote.fit(X_train_balanced, y_train_balanced)
y_pred_smote = model_smote.predict(X_test)

# 评估SMOTE处理后的模型
print("\n=== 应用SMOTE后模型性能 ===")
print(classification_report(y_test, y_pred_smote, target_names=['Class 1', 'Class 7']))
print("\n混淆矩阵:")
print(confusion_matrix(y_test, y_pred_smote))

对比两份评估报告,你会发现应用SMOTE后,模型对少数类‘7’的召回率通常会有显著提升。虽然多数类‘1’的精确率可能会轻微下降(因为模型不再一味地偏向它),但整体的F1-score,特别是少数类的F1-score,往往会得到改善。这意味着模型在两类上的综合表现更均衡、更健壮了。

6. 深入讨论:SMOTE的局限与进阶技巧

经过上面的实战,你应该已经感受到SMOTE的强大。但它并非银弹,也有自己的局限性和适用场景。了解这些,你才能更好地驾驭它。

6.1 SMOTE的局限性

  1. 容易放大噪声:如果少数类样本中存在噪声点(离群点),SMOTE会在噪声点和它的邻居之间生成样本,从而创造出更多不合理的“噪声区域”,污染整个特征空间。
  2. 可能造成类别重叠:当少数类和多数类的边界非常模糊、交织在一起时,SMOTE生成的新样本可能会“侵入”多数类的区域,导致两类边界更加不清,反而降低分类性能。
  3. 对高维稀疏数据效果不佳:在高维空间中,所有点之间的距离都变得非常远且相似(“维度灾难”),K近邻的可靠性下降,基于此的插值也会变得不稳定。
  4. 不适用于所有数据类型:如前所述,线性插值对连续的数值特征很有效,但对于分类特征文本特征混合类型特征,直接插值没有意义。例如,在“颜色={红,黄,蓝}”之间插值出“红黄”是没有定义的。

6.2 针对局限性的改进方案

针对这些问题,研究者们提出了许多SMOTE的变体,在实际项目中可能比原始SMOTE更常用:

  • Borderline-SMOTE:这个算法很聪明,它不会对所有少数类样本都进行过采样,而是只关注那些“处在边界上”的少数类样本。它先区分出三类样本:安全样本(周围都是同类)、边界样本(周围有较多异类)、噪声样本(周围都是异类)。然后只对边界样本使用SMOTE。这样可以有效避免在类内安全区域过度生成样本,并减少噪声干扰。
  • ADASYN:自适应合成采样。它的核心思想是根据样本分布的密度来自适应地决定每个少数类样本需要生成多少新样本。在分布稀疏的区域(可能更难学习)生成更多样本,在分布密集的区域生成较少样本。这比SMOTE均匀采样的策略更合理。
  • SMOTE-NC:这是为混合数据类型设计的。对于数值特征,它使用标准的SMOTE插值;对于分类特征,它则选择最近邻中出现次数最多的那个类别作为新样本的特征值。
  • 结合欠采样:一个非常有效的策略是SMOTE + Tomek LinksSMOTE + ENN。先用SMOTE增加少数类样本,然后用Tomek Links(寻找两类之间非常近的样本对并移除多数类样本)或Edited Nearest Neighbors(ENN,移除那些被其K个近邻中的多数类样本包围的样本)来清理重叠区域和噪声。这种“先过采样再清洗”的流程往往能取得更好的效果。

6.3 工程实践中的几点建议

在我多年的项目经验里,处理不平衡数据时,有几点心得:

  1. 先尝试简单方法:不要一上来就用最复杂的算法。可以先试试调整分类模型的类别权重(如class_weight='balanced'),很多模型(如逻辑回归、SVM、决策树)都支持这个参数,它能让模型在训练时更关注少数类。
  2. SMOTE是工具,不是魔法:一定要在训练集上应用SMOTE,绝对不要在测试集或整个数据集上使用!否则就造成了数据泄露,评估结果会严重失真。正确的流程是:先划分训练集和测试集,然后只在训练集上对少数类进行SMOTE过采样。
  3. 配合交叉验证:当使用SMOTE时,你的交叉验证策略也需要调整。必须在每一折训练集的内部进行SMOTE,而不是在整个数据集上做了SMOTE再划分。可以使用imbalanced-learn库中的PipelineStratifiedKFold来方便地实现。
  4. 评估指标是关键:处理不平衡数据时,准确率是最不可靠的指标。要重点关注精确率、召回率、F1-score,尤其是少数类的这些指标。ROC-AUCPR曲线(精确率-召回率曲线)下的面积也是很好的综合评估指标,PR曲线在不平衡问题上通常比ROC曲线更敏感。

手搓一遍SMOTE,再把它应用到实际数据中看到效果提升,这个过程本身就充满了成就感。它能让你透彻理解算法是如何在特征空间里“无中生有”地创造合理数据的。虽然在实际生产中我们更多会使用成熟的库(如imbalanced-learn),但掌握了底层原理,你就能更自信地调参、选型,甚至根据具体业务数据的特点对算法进行定制化修改。记住,没有最好的算法,只有最合适的解决方案。下次当你遇到那些“偏心的”数据时,不妨试试SMOTE这把钥匙,它很可能帮你打开一扇新的大门。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值