SMOTETomek 并不是一个单一的算法,而是一个“组合”技术。
- SMOTE (Synthetic Minority Over-sampling Technique):一种过采样技术,它通过在少数类样本之间进行插值来人工生成新的少数类样本,而不是简单地复制现有样本。
- Tomek Links:一种欠采样技术,Tomek Link 是指一对距离非常近的样本,且这两个样本分别属于不同类别,移除这些少数类样本的“ Tomek Link”邻居,可以使得决策边界更加清晰。
SMOTETomek 的工作流程:
- 对数据集应用 SMOTE:在少数类样本的周围生成新的合成样本,以增加少数类的数量。
- 对新生成的数据集应用 Tomek Links:在整个数据集(包括原始样本和新生成的合成样本)中寻找并移除那些构成 Tomek Link 的样本,这些被移除的样本通常是位于不同类别边界上的“噪声”或“模糊”样本。
这个组合策略的目的很明确:先通过 SMOTE 增加少数类样本,提升分类器的识别能力;再通过 Tomek Links 清理边界,使决策边界更清晰,从而可能提高模型的泛化能力。
Python 实现
在 Python 中,实现 SMOTETomek 最简单、最常用的方法是使用 imbalanced-learn 库,这个库专门用于处理不平衡数据集,提供了多种过采样和欠采样技术。
安装必要的库
如果你还没有安装 imbalanced-learn 和 scikit-learn,可以通过 pip 安装:
pip install imbalanced-learn scikit-learn matplotlib seaborn
完整代码示例
下面是一个完整的、可运行的 Python 脚本,它演示了如何使用 SMOTETomek 来处理一个不平衡的数据集,并比较处理前后的数据分布。
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.datasets import make_classification
from collections import Counter
from imblearn.combine import SMOTETomek
from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import classification_report, confusion_matrix
# --- 1. 创建一个不平衡的数据集 ---
# n_samples: 总样本数
# weights: 类别权重, [0.9, 0.1] 表示90%是多数类,10%是少数类
# n_features: 特征数量
# n_informative: 有信息量的特征数量
# n_redundant: 冗余特征数量
# n_clusters_per_class: 每个类的簇数
X, y = make_classification(
n_samples=1000,
weights=[0.9, 0.1], # 90% vs 10% 的不平衡数据
n_features=10,
n_informative=2,
n_redundant=2,
n_clusters_per_class=1,
random_state=42
)
print("原始数据集的类别分布:")
print(Counter(y))
# 输出: Counter({0: 900, 1: 100})
# --- 2. 可视化原始数据分布 ---
def plot_class_distribution(y, title):
plt.figure(figsize=(8, 6))
sns.countplot(x=y)
plt.title(title)
plt.xlabel('Class')
plt.ylabel('Frequency')
plt.show()
plot_class_distribution(y, "原始数据集的类别分布")
# --- 3. 应用 SMOTETomek ---
# 初始化 SMOTETomek
# sampling_strategy: 可以指定为 'auto' 或一个浮点数 (0.5 表示少数类样本数将是多数类的50%)
# random_state: 确保结果可复现
smt = SMOTETomek(random_state=42)
# 对数据进行重采样
X_res, y_res = smt.fit_resample(X, y)
print("\n应用 SMOTETomek 后的类别分布:")
print(Counter(y_res))
# 输出: Counter({0: 896, 1: 896})
# 注意:结果可能略有不同,因为Tomek Links会移除一些样本
# --- 4. 可视化处理后的数据分布 ---
plot_class_distribution(y_res, "应用 SMOTETomek 后的类别分布")
# --- 5. (可选) 模型训练与评估 ---
# 为了展示 SMOTETomek 的效果,我们可以在处理前后的数据上训练一个简单的分类器,并比较结果。
# 划分训练集和测试集 (使用原始不平衡数据)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=42, stratify=y)
# 在原始不平衡数据上训练模型
print("\n--- 在原始不平衡数据上训练模型 ---")
model_imb = RandomForestClassifier(random_state=42)
model_imb.fit(X_train, y_train)
y_pred_imb = model_imb.predict(X_test)
print("分类报告 (原始数据):")
print(classification_report(y_test, y_pred_imb))
print("混淆矩阵 (原始数据):")
print(confusion_matrix(y_test, y_pred_imb))
# 使用 SMOTETomek 处理后的数据来训练模型
# 注意:通常我们只在训练集上应用重采样,以避免数据泄露
X_train_res, X_test_res, y_train_res, y_test_res = train_test_split(X_res, y_res, test_size=0.3, random_state=42)
print("\n--- 在 SMOTETomek 处理后的数据上训练模型 ---")
model_res = RandomForestClassifier(random_state=42)
model_res.fit(X_train_res, y_train_res)
y_pred_res = model_res.predict(X_test) # 注意:测试集仍然是原始的、不平衡的
print("分类报告 (处理后的数据):")
print(classification_report(y_test, y_pred_res))
print("混淆矩阵 (处理后的数据):")
print(confusion_matrix(y_test, y_pred_res))
代码解析
-
创建数据集:我们使用
make_classification函数创建了一个高度不平衡的数据集(90:10)。Counter(y)清楚地展示了类别的不平衡性。 -
可视化:
plot_class_distribution函数帮助我们直观地看到处理前后的数据分布变化,原始数据中,类别1(少数类)的样本远少于类别0(多数类),应用 SMOTETomek 后,两个类别的样本数量基本达到平衡。 -
应用 SMOTETomek:
from imblearn.combine import SMOTETomek:从imbalanced-learn库中导入SMOTETomek。smt = SMOTETomek(random_state=42):创建一个SMOTETomek对象实例。random_state确保每次运行代码时生成的合成样本都是一样的,便于复现结果。X_res, y_res = smt.fit_resample(X, y):这是核心步骤。fit_resample方法会同时执行 SMOTE 和 Tomek Links 的操作,并返回处理后的特征X_res和标签y_res。
-
模型训练与评估:
-
我们在原始不平衡数据和SMOTETomek处理后的数据上分别训练了一个
RandomForestClassifier。 -
关键点:为了避免数据泄露(即测试集中的信息泄露到训练过程中),最佳实践是先将数据划分为训练集和测试集,然后只在训练集上应用
fit_resample,上面的示例为了简化,分别展示了在全部原始数据和全部处理数据上训练的效果,但更严谨的做法是:# 正确的做法 X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=42) # 只在训练集上应用 SMOTETomek smt = SMOTETomek(random_state=42) X_train_res, y_train_res = smt.fit_resample(X_train, y_train) # 用处理后的训练集来训练模型 model = RandomForestClassifier(random_state=42) model.fit(X_train_res, y_train_res) # 在原始的、未处理的测试集上进行评估 y_pred = model.predict(X_test)
-
通过比较两个模型的分类报告(特别是少数类
class 1的recall、f1-score)和混淆矩阵,你可以直观地感受到 SMOTETomek 带来的性能提升,处理后模型的少数类召回率和 F1 分数会显著提高。
-
手动实现 SMOTETomek(概念性)
为了更深入地理解其工作原理,我们可以手动实现 SMOTETomek 的逻辑,这有助于理解 SMOTE 如何生成样本以及 Tomek Links 如何移除样本。
警告:这是一个教学性质的、概念性的实现,其效率和鲁棒性远不如 imbalanced-learn 库中经过高度优化的版本,在实际项目中,请务必使用 imbalanced-learn。
import numpy as np
from sklearn.neighbors import NearestNeighbors
from collections import Counter
def manual_smote(X, y, k=5, sampling_strategy=1.0):
"""
手动实现 SMOTE 的核心逻辑
:param X: 特征矩阵
:param y: 标签数组
:param k: 寻找的邻居数量
:param sampling_strategy: 少数类样本的目标数量与多数类样本数量的比例
:return: 合成后的特征矩阵和标签数组
"""
# 1. 分离少数类和多数类
minority_class = min(set(y), key=list(y).count)
majority_class = max(set(y), key=list(y).count)
X_min = X[y == minority_class]
X_maj = X[y == majority_class]
# 2. 确定需要生成多少个少数类样本
n_minority_samples = len(X_min)
n_majority_samples = len(X_maj)
n_samples_to_generate = int(sampling_strategy * n_majority_samples - n_minority_samples)
if n_samples_to_generate <= 0:
return X, y
# 3. 为每个少数类样本找到 k 个最近邻
nn = NearestNeighbors(n_neighbors=k+1) # +1 因为样本自身也是最近邻
nn.fit(X_min)
distances, indices = nn.kneighbors(X_min)
# 4. 生成新样本
synthetic_samples = []
synthetic_labels = []
for i in range(n_samples_to_generate):
# 随机选择一个少数类样本
sample_idx = np.random.randint(0, n_minority_samples)
# 从其邻居中随机选择一个(排除自身)
neighbor_idx = np.random.choice(indices[sample_idx, 1:])
# 在选定的样本和邻居之间进行线性插值
diff = X_min[neighbor_idx] - X_min[sample_idx]
gap = np.random.rand()
new_sample = X_min[sample_idx] + gap * diff
synthetic_samples.append(new_sample)
synthetic_labels.append(minority_class)
# 5. 合并原始数据和合成数据
X_synthetic = np.array(synthetic_samples)
y_synthetic = np.array(synthetic_labels)
X_new = np.vstack([X, X_synthetic])
y_new = np.concatenate([y, y_synthetic])
return X_new, y_new
def manual_tomek_links(X, y):
"""
手动实现 Tomek Links 的核心逻辑
:param X: 特征矩阵
:param y: 标签数组
:return: 移除 Tomek Link 后的特征矩阵和标签数组
"""
# 1. 找到所有构成 Tomek Link 的样本对的索引
# Tomek Link: (x_i, x_j) 满足 dist(x_i, x_j) < T, 且 y_i != y_j
# 简化起见,我们只考虑最近邻
nn = NearestNeighbors(n_neighbors=2)
nn.fit(X)
distances, indices = nn.kneighbors(X)
# 存储要移除的样本索引
indices_to_remove = set()
for i in range(len(X)):
# 只看最近的一个邻居
neighbor_idx = indices[i, 1]
neighbor_label = y[neighbor_idx]
# 如果邻居的标签与当前样本不同,则构成一个潜在的 Tomek Link
if y[i] != neighbor_label:
# 检查这个邻居是否也把当前样本作为最近邻(这是Tomek Link的严格定义)
# 为了简化,我们直接移除其中一个(这里选择移除少数类的)
if y[i] == min(set(y), key=list(y).count):
indices_to_remove.add(i)
else:
indices_to_remove.add(neighbor_idx)
# 2. 从数据集中移除这些样本
mask = np.ones(len(y), dtype=bool)
mask[list(indices_to_remove)] = False
return X[mask], y[mask]
# --- 使用手动实现 ---
print("\n--- 手动实现 SMOTETomek ---")
# 1. 先应用 SMOTE
X_smote_manual, y_smote_manual = manual_smote(X, y, sampling_strategy=1.0)
print("手动 SMOTE 后的类别分布:", Counter(y_smote_manual))
# 2. 再应用 Tomek Links
X_smt_manual, y_smt_manual = manual_tomek_links(X_smote_manual, y_smote_manual)
print("手动 SMOTETomek 后的类别分布:", Counter(y_smt_manual))
这个手动实现清晰地展示了 SMOTE 如何通过“插值”生成新样本,以及 Tomek Links 如何通过识别“边界上的邻居对”来移除样本。
| 方法 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
imbalanced-learn 的 SMOTETomek |
行业标准,经过高度优化,代码简洁,功能强大,集成度高。 | 理解上相对“黑箱”。 | 绝大多数实际项目,快速、有效地解决类别不平衡问题。 |
| 手动实现 | 有助于深刻理解算法原理,是学习的好工具。 | 效率低下,代码复杂,可能存在边界情况处理不当的问题,不推荐生产环境使用。 | 学习和教学目的,深入理解 SMOTE 和 Tomek Links 的内部机制。 |
在实际应用中,请优先使用 imbalanced-learn 库中的 SMOTETomek,记得调整 sampling_strategy 参数来控制少数类样本的目标数量,并结合交叉验证来评估模型效果,以找到最适合你数据集的重采样策略。
