16 분 소요


image


SMOTE : 불균형 데이터 합성 샘플링 가이드

TL;DR

  • 문제: 클래스 불균형은 머신러닝 모델의 소수 클래스 예측 성능을 저하시킴
  • 해결책: SMOTE(Synthetic Minority Over-sampling TEchnique) - 합성 샘플 생성
  • 핵심 아이디어: K-최근접 이웃 기반 선형 보간으로 소수 클래스의 새로운 샘플 생성
  • 효과: 단순 복제 대비 과적합 위험 감소, 결정 경계 개선
  • 주의사항: 노이즈 증폭, 고차원 저주, 경계 모호화 등의 한계 존재


1. 클래스 불균형 문제의 본질

1.1 왜 클래스 불균형이 문제인가?

import numpy as np
from sklearn.datasets import make_classification
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import confusion_matrix, classification_report

# 극단적인 불균형 데이터 생성 (99:1)
X, y = make_classification(
    n_samples=10000,
    n_features=20,
    n_informative=15,
    n_redundant=5,
    n_classes=2,
    weights=[0.99, 0.01],  # 클래스 0: 99%, 클래스 1: 1%
    random_state=42
)

print(f"Class distribution: {np.bincount(y)}")
# Output: [9900  100]

# 불균형 데이터로 학습
from sklearn.model_selection import train_test_split

X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.3, stratify=y, random_state=42
)

model = LogisticRegression(max_iter=1000)
model.fit(X_train, y_train)

y_pred = model.predict(X_test)

print("\n=== 불균형 데이터 학습 결과 ===")
print(classification_report(y_test, y_pred, target_names=['Majority', 'Minority']))
Class distribution: [9859  141]

=== 불균형 데이터 학습 결과 ===
              precision    recall  f1-score   support

    Majority       0.99      1.00      0.99      2958
    Minority       1.00      0.14      0.25        42

    accuracy                           0.99      3000
   macro avg       0.99      0.57      0.62      3000
weighted avg       0.99      0.99      0.98      3000

문제점 분석:

  • 높은 Accuracy의 함정: 99% 정확도지만 소수 클래스는 거의 예측 못함
  • 극단적으로 낮은 Recall: 소수 클래스의 97%를 놓침 (Recall = 0.03)
  • 비즈니스 임팩트: 사기 탐지, 질병 진단 등에서 치명적

1.2 클래스 불균형의 수학적 관점

머신러닝 모델의 손실 함수는 일반적으로 다음과 같이 정의됩니다:

$L = \frac 1N \sum loss(y_{i}, \hat{y}_{i})$

불균형 데이터에서는 다수 클래스의 손실이 전체 손실을 지배:

$L \approx \frac{N_{majority}}{N} \times L_{majority} + \frac{N_{minority}}{N} \times L_{minority}$

예: 99:1 비율의 경우

$L \approx 0.99 \times L_{majority} + 0.01 \times L_{minority}$

결과: 모델이 소수 클래스를 무시하고 다수 클래스에만 최적화


2. SMOTE 알고리즘 Deep Dive

2.1 SMOTE의 작동 원리

핵심 아이디어: 소수 클래스의 기존 샘플과 그 이웃 사이를 선형 보간

알고리즘 단계:

For each minority sample x_i:
    1. K-최근접 이웃 찾기: N(x_i) = {x_1, x_2, ..., x_k}
    2. 이웃 중 하나를 랜덤 선택: x_neighbor
    3. 선형 보간으로 합성 샘플 생성:
       x_synthetic = x_i + λ * (x_neighbor - x_i)
       where λ ~ Uniform(0, 1)
    4. 원하는 비율이 될 때까지 반복

2.2 수학적 표현

소수 클래스 샘플 $x_i$ $\in$ $\mathbb{R}^{d}$와 그 k-최근접 이웃 중 하나인 $x_{neighbor}$가 주어졌을 때:

x_synthetic = x_i + λ · (x_neighbor - x_i)
            = (1 - λ) · x_i + λ · x_neighbor

where:
- λ ~ U(0, 1): 보간 가중치
- d: 특성 차원
- k: 이웃 수 (기본값 5)

시각화 예제:

import matplotlib.pyplot as plt
from sklearn.neighbors import NearestNeighbors

# 간단한 2D 예제
np.random.seed(42)
minority_samples = np.array([
    [2, 3],
    [3, 4],
    [2.5, 3.5],
    [1.8, 2.8]
])

# K=2 최근접 이웃 찾기
knn = NearestNeighbors(n_neighbors=2)
knn.fit(minority_samples)

# 첫 번째 샘플에 대한 합성 샘플 생성
sample = minority_samples[0]
neighbors_idx = knn.kneighbors([sample], return_distance=False)[0]

plt.figure(figsize=(12, 5))

# 원본 샘플
plt.subplot(1, 2, 1)
plt.scatter(minority_samples[:, 0], minority_samples[:, 1], 
           c='blue', s=100, label='Original Minority Samples', edgecolors='black')
plt.scatter(sample[0], sample[1], c='red', s=200, 
           marker='*', label='Selected Sample', edgecolors='black')

# 이웃 표시
for idx in neighbors_idx[1:]:  # 자기 자신 제외
    neighbor = minority_samples[idx]
    plt.scatter(neighbor[0], neighbor[1], c='green', s=150, 
               marker='^', edgecolors='black')
    plt.plot([sample[0], neighbor[0]], [sample[1], neighbor[1]], 
            'k--', alpha=0.3)

plt.title('Step 1: Find K-Nearest Neighbors', fontsize=14, fontweight='bold')
plt.legend()
plt.grid(True, alpha=0.3)

# 합성 샘플 생성
plt.subplot(1, 2, 2)
plt.scatter(minority_samples[:, 0], minority_samples[:, 1], 
           c='blue', s=100, label='Original', edgecolors='black', alpha=0.6)
plt.scatter(sample[0], sample[1], c='red', s=200, 
           marker='*', label='Selected Sample', edgecolors='black')

# 여러 λ 값으로 합성 샘플 생성
synthetic_samples = []
for idx in neighbors_idx[1:]:
    neighbor = minority_samples[idx]
    for lambda_val in [0.2, 0.4, 0.6, 0.8]:
        synthetic = sample + lambda_val * (neighbor - sample)
        synthetic_samples.append(synthetic)
        plt.scatter(synthetic[0], synthetic[1], c='orange', s=80, 
                   alpha=0.7, edgecolors='black')
    plt.plot([sample[0], neighbor[0]], [sample[1], neighbor[1]], 
            'k--', alpha=0.3)

synthetic_samples = np.array(synthetic_samples)
plt.scatter(synthetic_samples[:, 0], synthetic_samples[:, 1], 
           c='orange', s=80, label='Synthetic Samples', 
           edgecolors='black', alpha=0.7)

plt.title('Step 2: Generate Synthetic Samples', fontsize=14, fontweight='bold')
plt.legend()
plt.grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

print(f"원본 샘플 수: {len(minority_samples)}")
print(f"생성된 합성 샘플 수: {len(synthetic_samples)}")

image

2.3 SMOTE의 핵심 파라미터

from imblearn.over_sampling import SMOTE

smote = SMOTE(
    sampling_strategy='auto',  # 목표 비율 (기본: 1:1)
    k_neighbors=5,             # 고려할 이웃 수
    random_state=42
)

파라미터 설명:

파라미터 설명 권장값
sampling_strategy 목표 클래스 비율 (float, dict, ‘auto’) ‘auto’ (1:1)
k_neighbors K-NN에서 사용할 이웃 수 5 (소수 샘플이 적으면 3)
n_jobs 병렬 처리 워커 수 -1 (모든 CPU)

sampling_strategy 예제:

# 1. 'auto': 모든 클래스를 다수 클래스와 동일하게
smote_auto = SMOTE(sampling_strategy='auto')

# 2. float: 소수/다수 비율 지정
smote_ratio = SMOTE(sampling_strategy=0.5)  # 1:2 비율

# 3. dict: 각 클래스별 목표 샘플 수
smote_dict = SMOTE(sampling_strategy={0: 1000, 1: 800, 2: 900})

# 4. 소수 클래스만 오버샘플링
smote_minority = SMOTE(sampling_strategy='minority')


3. SMOTE 실전 구현

3.1 기본 사용법

from imblearn.over_sampling import SMOTE
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import classification_report, roc_auc_score
import time

# 불균형 데이터 생성
X, y = make_classification(
    n_samples=10000,
    n_features=20,
    n_classes=2,
    weights=[0.95, 0.05],
    random_state=42
)

X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.3, stratify=y, random_state=42
)

print("=== BEFORE SMOTE ===")
print(f"Training set class distribution: {np.bincount(y_train)}")

# SMOTE 적용
smote = SMOTE(random_state=42)
start_time = time.time()
X_train_smote, y_train_smote = smote.fit_resample(X_train, y_train)
smote_time = time.time() - start_time

print("\n=== AFTER SMOTE ===")
print(f"Training set class distribution: {np.bincount(y_train_smote)}")
print(f"SMOTE execution time: {smote_time:.4f} seconds")

# 모델 학습 비교
print("\n" + "="*60)
print("WITHOUT SMOTE")
print("="*60)
model_no_smote = RandomForestClassifier(random_state=42)
model_no_smote.fit(X_train, y_train)
y_pred_no_smote = model_no_smote.predict(X_test)
print(classification_report(y_test, y_pred_no_smote))
print(f"ROC-AUC: {roc_auc_score(y_test, model_no_smote.predict_proba(X_test)[:, 1]):.4f}")

print("\n" + "="*60)
print("WITH SMOTE")
print("="*60)
model_with_smote = RandomForestClassifier(random_state=42)
model_with_smote.fit(X_train_smote, y_train_smote)
y_pred_with_smote = model_with_smote.predict(X_test)
print(classification_report(y_test, y_pred_with_smote))
print(f"ROC-AUC: {roc_auc_score(y_test, model_with_smote.predict_proba(X_test)[:, 1]):.4f}")
=== BEFORE SMOTE ===
Training set class distribution: [6622  378]

=== AFTER SMOTE ===
Training set class distribution: [6622 6622]
SMOTE execution time: 0.0117 seconds

============================================================
WITHOUT SMOTE
============================================================
              precision    recall  f1-score   support

           0       0.97      1.00      0.98      2838
           1       0.84      0.40      0.54       162

    accuracy                           0.96      3000
   macro avg       0.91      0.70      0.76      3000
weighted avg       0.96      0.96      0.96      3000

ROC-AUC: 0.9357

============================================================
WITH SMOTE
============================================================
              precision    recall  f1-score   support

           0       0.99      0.97      0.98      2838
           1       0.56      0.77      0.65       162

    accuracy                           0.96      3000
   macro avg       0.77      0.87      0.81      3000
weighted avg       0.96      0.96      0.96      3000

ROC-AUC: 0.9472

3.2 파이프라인에 SMOTE 통합

from imblearn.pipeline import Pipeline as ImbPipeline
from sklearn.preprocessing import StandardScaler

# ⚠️ 주의: sklearn.pipeline이 아닌 imblearn.pipeline 사용!
pipeline = ImbPipeline([
    ('scaler', StandardScaler()),
    ('smote', SMOTE(random_state=42)),
    ('classifier', RandomForestClassifier(random_state=42))
])

# Cross Validation
from sklearn.model_selection import cross_val_score

cv_scores = cross_val_score(
    pipeline, 
    X_train, 
    y_train, 
    cv=5, 
    scoring='f1'
)

print(f"Cross-validation F1 scores: {cv_scores}")
print(f"Mean F1: {cv_scores.mean():.4f} (+/- {cv_scores.std():.4f})")
Cross-validation F1 scores: [0.58536585 0.54857143 0.6519337  0.62944162 0.6519337 ]
Mean F1: 0.6134 (+/- 0.0405)

중요

  • SMOTE는 학습 데이터에만 적용
  • 테스트 데이터는 원본 분포 유지 (현실 반영)
  • Cross Validation 시 각 fold마다 SMOTE를 별도로 적용

3.3 잘못된 사용 예시 (Anti-pattern)

# ❌ 잘못된 방법: 전체 데이터에 SMOTE 적용 후 분할
X_smote, y_smote = smote.fit_resample(X, y)
X_train, X_test, y_train, y_test = train_test_split(X_smote, y_smote)

# 문제점:
# 1. 합성 샘플이 테스트 세트에 포함될 수 있음
# 2. 원본 샘플과 유사한 합성 샘플이 train/test 양쪽에 존재
# 3. 과도하게 낙관적인 성능 추정 (Data Leakage)
# ✅ 올바른 방법: 분할 후 학습 데이터에만 SMOTE 적용
X_train, X_test, y_train, y_test = train_test_split(X, y)
X_train_smote, y_train_smote = smote.fit_resample(X_train, y_train)

# 장점:
# 1. 테스트 세트는 원본 분포 유지
# 2. 합성 샘플은 학습에만 사용
# 3. 실제 성능을 정확하게 추정


4. SMOTE의 변형 기법들

4.1 Borderline-SMOTE

아이디어: 클래스 경계(decision boundary) 근처의 샘플만 오버샘플링

from imblearn.over_sampling import BorderlineSMOTE

# Borderline-SMOTE의 두 가지 변형
# Type 1: 경계 샘플의 소수 클래스 이웃만 사용
borderline_smote1 = BorderlineSMOTE(
    kind='borderline-1',
    k_neighbors=5,
    m_neighbors=10,  # 경계 샘플 판별용 이웃 수
    random_state=42
)

# Type 2: 경계 샘플의 모든 이웃 사용 (다수 클래스 포함)
borderline_smote2 = BorderlineSMOTE(
    kind='borderline-2',
    k_neighbors=5,
    m_neighbors=10,
    random_state=42
)

X_train_bl, y_train_bl = borderline_smote1.fit_resample(X_train, y_train)

작동 원리:

  1. 각 소수 클래스 샘플 xi에 대해 m-최근접 이웃 찾기
  2. 이웃 중 다수 클래스 비율 계산
  3. 비율이 50% 이상이면 “위험(DANGER)” 샘플로 분류
  4. DANGER 샘플에 대해서만 SMOTE 적용
# 시각화: Borderline-SMOTE가 집중하는 영역
from sklearn.datasets import make_moons

X_moons, y_moons = make_moons(n_samples=1000, noise=0.3, random_state=42)

# 불균형 생성 (소수 클래스 10%)
minority_idx = np.where(y_moons == 1)[0][:50]
majority_idx = np.where(y_moons == 0)[0]

X_imb = np.vstack([X_moons[majority_idx], X_moons[minority_idx]])
y_imb = np.hstack([y_moons[majority_idx], y_moons[minority_idx]])

# SMOTE vs Borderline-SMOTE 비교
smote_regular = SMOTE(random_state=42)
smote_borderline = BorderlineSMOTE(random_state=42)

X_smote, y_smote = smote_regular.fit_resample(X_imb, y_imb)
X_bl_smote, y_bl_smote = smote_borderline.fit_resample(X_imb, y_imb)

fig, axes = plt.subplots(1, 3, figsize=(18, 5))

# Original
axes[0].scatter(X_imb[y_imb==0, 0], X_imb[y_imb==0, 1], 
               c='blue', alpha=0.6, label='Majority', s=30)
axes[0].scatter(X_imb[y_imb==1, 0], X_imb[y_imb==1, 1], 
               c='red', alpha=0.6, label='Minority', s=30)
axes[0].set_title('Original Imbalanced Data', fontsize=14, fontweight='bold')
axes[0].legend()

# SMOTE
axes[1].scatter(X_smote[y_smote==0, 0], X_smote[y_smote==0, 1], 
               c='blue', alpha=0.4, s=20)
axes[1].scatter(X_smote[len(X_imb):][y_smote[len(X_imb):]==1, 0], 
               X_smote[len(X_imb):][y_smote[len(X_imb):]==1, 1], 
               c='orange', marker='^', alpha=0.6, label='SMOTE Synthetic', s=40)
axes[1].scatter(X_imb[y_imb==1, 0], X_imb[y_imb==1, 1], 
               c='red', alpha=0.8, label='Original Minority', s=30)
axes[1].set_title('Regular SMOTE', fontsize=14, fontweight='bold')
axes[1].legend()

# Borderline-SMOTE
axes[2].scatter(X_bl_smote[y_bl_smote==0, 0], X_bl_smote[y_bl_smote==0, 1], 
               c='blue', alpha=0.4, s=20)
axes[2].scatter(X_bl_smote[len(X_imb):][y_bl_smote[len(X_imb):]==1, 0], 
               X_bl_smote[len(X_imb):][y_bl_smote[len(X_imb):]==1, 1], 
               c='green', marker='s', alpha=0.6, label='Borderline Synthetic', s=40)
axes[2].scatter(X_imb[y_imb==1, 0], X_imb[y_imb==1, 1], 
               c='red', alpha=0.8, label='Original Minority', s=30)
axes[2].set_title('Borderline-SMOTE', fontsize=14, fontweight='bold')
axes[2].legend()

plt.tight_layout()
plt.show()

image

4.2 ADASYN (Adaptive Synthetic Sampling)

핵심 차이: 학습하기 어려운 샘플(밀도가 낮은 영역)에 더 많은 합성 샘플 생성

from imblearn.over_sampling import ADASYN

adasyn = ADASYN(
    sampling_strategy='auto',
    n_neighbors=5,
    random_state=42
)

X_train_adasyn, y_train_adasyn = adasyn.fit_resample(X_train, y_train)

알고리즘

  1. 각 소수 샘플 $x_i$의 k-이웃 중 다수 클래스 비율 계산: $r_i$
  2. 정규화: $\hat{r_i} = r_i / \sum{r_i}$
  3. 각 샘플이 생성할 합성 샘플 수: $g_i = \hat{r_i} \times G$ (총 생성할 샘플 수)
  4. 밀도가 낮은 영역($r_i$가 높은)에 더 많은 샘플 생성
# SMOTE vs ADASYN 성능 비교
from sklearn.svm import SVC

models = {
    'No Sampling': (X_train, y_train),
    'SMOTE': (X_train_smote, y_train_smote),
    'Borderline-SMOTE': (X_train_bl, y_train_bl),
    'ADASYN': (X_train_adasyn, y_train_adasyn)
}

results = {}

for name, (X_tr, y_tr) in models.items():
    svm = SVC(kernel='rbf', probability=True, random_state=42)
    svm.fit(X_tr, y_tr)
    y_pred = svm.predict(X_test)
    
    results[name] = {
        'F1': f1_score(y_test, y_pred),
        'Recall': recall_score(y_test, y_pred),
        'Precision': precision_score(y_test, y_pred),
        'ROC-AUC': roc_auc_score(y_test, svm.predict_proba(X_test)[:, 1])
    }

# 결과 시각화
import pandas as pd

results_df = pd.DataFrame(results).T
print(results_df)

results_df.plot(kind='bar', figsize=(12, 6), rot=0)
plt.title('Comparison of Sampling Techniques', fontsize=16, fontweight='bold')
plt.ylabel('Score')
plt.legend(loc='lower right')
plt.grid(axis='y', alpha=0.3)
plt.tight_layout()
plt.show()

image

4.3 SMOTE-ENN & SMOTE-Tomek

아이디어: SMOTE로 오버샘플링 후 언더샘플링으로 노이즈 제거

from imblearn.combine import SMOTEENN, SMOTETomek

# SMOTE + Edited Nearest Neighbors
smote_enn = SMOTEENN(random_state=42)
X_train_senn, y_train_senn = smote_enn.fit_resample(X_train, y_train)

# SMOTE + Tomek Links
smote_tomek = SMOTETomek(random_state=42)
X_train_stom, y_train_stom = smote_tomek.fit_resample(X_train, y_train)

print(f"Original: {len(X_train)}")
print(f"After SMOTE: {len(X_train_smote)}")
print(f"After SMOTE-ENN: {len(X_train_senn)} (일부 제거됨)")
print(f"After SMOTE-Tomek: {len(X_train_stom)} (일부 제거됨)")
Original: 7500
After SMOTE: 14170
After SMOTE-ENN: 12591 (일부 제거됨)
After SMOTE-Tomek: 14170 (일부 제거됨)

Tomek Links란?

  • 서로 다른 클래스의 최근접 이웃 쌍
  • 경계를 명확히 하기 위해 제거
# Tomek Links 시각화
from imblearn.under_sampling import TomekLinks

tomek = TomekLinks()
X_tomek, y_tomek = tomek.fit_resample(X_train, y_train)

removed_idx = set(range(len(X_train))) - set(tomek.sample_indices_)
print(f"Removed {len(removed_idx)} Tomek Links")
Removed 58 Tomek Links


5. SMOTE vs 다른 클래스 불균형 해결 기법

5.1 기법 비교표

기법 방식 장점 단점 적합한 상황
Random Oversampling 소수 클래스 복제 간단, 빠름 과적합 위험 높음 베이스라인
SMOTE 합성 샘플 생성 과적합 완화, 다양성 노이즈 증폭 가능 일반적 상황
ADASYN 적응형 합성 어려운 샘플에 집중 계산 비용 높음 복잡한 경계
Random Undersampling 다수 클래스 제거 빠름, 메모리 효율 정보 손실 데이터 충분
Tomek Links 경계 샘플 제거 경계 명확화 제거 샘플 적음 SMOTE와 결합
Class Weights 손실 함수 조정 원본 데이터 유지 모델 의존적 트리 모델
Ensemble 여러 균형 서브셋 정보 손실 없음 계산 비용 높음 충분한 데이터

5.2 실전 코드 비교

from imblearn.over_sampling import RandomOverSampler
from imblearn.under_sampling import RandomUnderSampler
from sklearn.utils.class_weight import compute_class_weight

# 1. Random Oversampling
ros = RandomOverSampler(random_state=42)
X_ros, y_ros = ros.fit_resample(X_train, y_train)

# 2. Random Undersampling
rus = RandomUnderSampler(random_state=42)
X_rus, y_rus = rus.fit_resample(X_train, y_train)

# 3. Class Weights (모델 파라미터 조정)
class_weights = compute_class_weight(
    'balanced',
    classes=np.unique(y_train),
    y=y_train
)
class_weight_dict = {i: w for i, w in enumerate(class_weights)}

rf_weighted = RandomForestClassifier(
    class_weight=class_weight_dict,
    random_state=42
)

# 4. EasyEnsemble (앙상블 언더샘플링)
from imblearn.ensemble import EasyEnsembleClassifier

easy_ensemble = EasyEnsembleClassifier(
    n_estimators=10,
    random_state=42
)

# 성능 비교
techniques = {
    'Original': (X_train, y_train, RandomForestClassifier(random_state=42)),
    'Random Over': (X_ros, y_ros, RandomForestClassifier(random_state=42)),
    'Random Under': (X_rus, y_rus, RandomForestClassifier(random_state=42)),
    'SMOTE': (X_train_smote, y_train_smote, RandomForestClassifier(random_state=42)),
    'Class Weights': (X_train, y_train, rf_weighted),
    'Easy Ensemble': (X_train, y_train, easy_ensemble)
}

comparison_results = []

for name, (X_tr, y_tr, model) in techniques.items():
    model.fit(X_tr, y_tr)
    y_pred = model.predict(X_test)
    
    comparison_results.append({
        'Technique': name,
        'F1': f1_score(y_test, y_pred),
        'Recall': recall_score(y_test, y_pred),
        'Precision': precision_score(y_test, y_pred),
        'Training Size': len(X_tr)
    })

comparison_df = pd.DataFrame(comparison_results)
print(comparison_df.to_string(index=False))
    Technique       F1  Recall  Precision  Training Size
     Original 0.622449   0.488   0.859155           7500
  Random Over 0.658009   0.608   0.716981          14170
 Random Under 0.467532   0.864   0.320475            830
        SMOTE 0.649518   0.808   0.543011          14170
Class Weights 0.584615   0.456   0.814286           7500
Easy Ensemble 0.428571   0.864   0.284960           7500


6. SMOTE의 한계점과 주의사항

6.1 노이즈 증폭 (Noise Amplification)

# 노이즈가 포함된 데이터에서 SMOTE의 문제
np.random.seed(42)

# 노이즈 샘플 추가
X_clean, y_clean = make_classification(
    n_samples=1000,
    n_features=2,
    n_informative=2,
    n_redundant=0,
    n_classes=2,
    weights=[0.9, 0.1],
    random_state=42
)

# 소수 클래스에 아웃라이어 추가
noise_samples = np.random.randn(5, 2) * 5 + [10, 10]
noise_labels = np.ones(5, dtype=int)

X_noisy = np.vstack([X_clean, noise_samples])
y_noisy = np.hstack([y_clean, noise_labels])

# SMOTE 적용
smote_noisy = SMOTE(random_state=42)
X_smote_noisy, y_smote_noisy = smote_noisy.fit_resample(X_noisy, y_noisy)

# 시각화
fig, axes = plt.subplots(1, 2, figsize=(14, 6))

axes[0].scatter(X_noisy[y_noisy==0, 0], X_noisy[y_noisy==0, 1], 
               c='blue', alpha=0.5, s=30, label='Majority')
axes[0].scatter(X_noisy[y_noisy==1, 0], X_noisy[y_noisy==1, 1], 
               c='red', alpha=0.7, s=30, label='Minority (with noise)')
axes[0].scatter(noise_samples[:, 0], noise_samples[:, 1], 
               c='red', edgecolors='black', s=200, marker='X', 
               label='Noise/Outliers')
axes[0].set_title('Before SMOTE (with outliers)', fontsize=14, fontweight='bold')
axes[0].legend()
axes[0].grid(True, alpha=0.3)

axes[1].scatter(X_smote_noisy[y_smote_noisy==0, 0], 
               X_smote_noisy[y_smote_noisy==0, 1], 
               c='blue', alpha=0.3, s=20)
axes[1].scatter(X_smote_noisy[len(X_noisy):][y_smote_noisy[len(X_noisy):]==1, 0], 
               X_smote_noisy[len(X_noisy):][y_smote_noisy[len(X_noisy):]==1, 1], 
               c='orange', alpha=0.5, s=40, marker='^', 
               label='SMOTE Synthetic (noise amplified!)')
axes[1].scatter(noise_samples[:, 0], noise_samples[:, 1], 
               c='red', edgecolors='black', s=200, marker='X', 
               label='Original Outliers')
axes[1].set_title('After SMOTE (noise amplified!)', fontsize=14, fontweight='bold')
axes[1].legend()
axes[1].grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

image

해결책

  • 전처리: SMOTE 적용 전 아웃라이어 제거
  • SMOTE-Tomek/ENN: 오버샘플링 후 노이즈 샘플 제거

6.2 고차원 저주 (Curse of Dimensionality)

고차원 공간에서는 “가까운 이웃”의 의미가 약해짐:

from sklearn.metrics import pairwise_distances

def analyze_smote_in_high_dimensions():
    dimensions = [2, 10, 50, 100, 500]
    results = []
    
    for d in dimensions:
        X, y = make_classification(
            n_samples=1000,
            n_features=d,
            n_informative=min(d, 10),
            n_classes=2,
            weights=[0.9, 0.1],
            random_state=42
        )
        
        minority_samples = X[y == 1]
        
        # 평균 최근접 이웃 거리 계산
        if len(minority_samples) > 1:
            distances = pairwise_distances(minority_samples)
            np.fill_diagonal(distances, np.inf)
            avg_nearest_distance = distances.min(axis=1).mean()
            
            results.append({
                'Dimensions': d,
                'Avg NN Distance': avg_nearest_distance,
                'Minority Samples': len(minority_samples)
            })
    
    results_df = pd.DataFrame(results)
    print(results_df)
    
    plt.figure(figsize=(10, 6))
    plt.plot(results_df['Dimensions'], results_df['Avg NN Distance'], 
            marker='o', linewidth=2, markersize=8)
    plt.xlabel('Number of Dimensions', fontsize=12)
    plt.ylabel('Average Nearest Neighbor Distance', fontsize=12)
    plt.title('SMOTE Challenge in High Dimensions', 
             fontsize=14, fontweight='bold')
    plt.grid(True, alpha=0.3)
    plt.tight_layout()
    plt.show()

analyze_smote_in_high_dimensions()
Output 예시:

   Dimensions  Avg NN Distance  Minority Samples
0           2            0.832               100
1          10            3.241               100
2          50            8.912               100
3         100           12.734               100
4         500           28.456               100

해결책

  • 차원 축소: PCA, t-SNE 등으로 차원 감소 후 SMOTE
  • Feature Selection: 중요한 특성만 선택

6.3 클래스 경계 모호화

# 선형 분리 가능한 데이터에서 SMOTE의 부작용
X_sep, y_sep = make_classification(
    n_samples=500,
    n_features=2,
    n_informative=2,
    n_redundant=0,
    n_clusters_per_class=1,
    weights=[0.85, 0.15],
    class_sep=3.0,  # 명확한 분리
    random_state=42
)

smote_sep = SMOTE(random_state=42)
X_smote_sep, y_smote_sep = smote_sep.fit_resample(X_sep, y_sep)

# 결정 경계 시각화
from sklearn.svm import SVC

fig, axes = plt.subplots(1, 2, figsize=(14, 6))

for idx, (X, y, title) in enumerate([
    (X_sep, y_sep, 'Without SMOTE'),
    (X_smote_sep, y_smote_sep, 'With SMOTE')
]):
    svm = SVC(kernel='linear', C=1.0)
    svm.fit(X, y)
    
    # 결정 경계
    xx, yy = np.meshgrid(
        np.linspace(X[:, 0].min()-1, X[:, 0].max()+1, 200),
        np.linspace(X[:, 1].min()-1, X[:, 1].max()+1, 200)
    )
    Z = svm.decision_function(np.c_[xx.ravel(), yy.ravel()])
    Z = Z.reshape(xx.shape)
    
    axes[idx].contourf(xx, yy, Z, alpha=0.3, levels=20, cmap='RdYlBu')
    axes[idx].contour(xx, yy, Z, colors='black', linewidths=2, levels=[0])
    
    axes[idx].scatter(X[y==0, 0], X[y==0, 1], c='blue', s=30, alpha=0.6)
    axes[idx].scatter(X[y==1, 0], X[y==1, 1], c='red', s=30, alpha=0.6)
    axes[idx].set_title(title, fontsize=14, fontweight='bold')
    axes[idx].grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

image


7. 실무 적용 가이드라인

7.1 SMOTE 적용 의사결정 트리

def should_use_smote(X, y):
    """SMOTE 적용 여부 판단"""
    
    class_counts = np.bincount(y)
    minority_count = class_counts.min()
    imbalance_ratio = class_counts.max() / minority_count
    
    n_features = X.shape[1]
    
    recommendations = []
    
    # 1. 불균형 비율 확인
    if imbalance_ratio < 1.5:
        recommendations.append("✓ 불균형이 심하지 않음 - SMOTE 불필요")
        return False, recommendations
    elif imbalance_ratio > 10:
        recommendations.append("⚠️  극단적 불균형 - SMOTE + 앙상블 기법 고려")
    else:
        recommendations.append("✓ SMOTE 적용 권장")
    
    # 2. 소수 클래스 샘플 수 확인
    if minority_count < 10:
        recommendations.append("⚠️  소수 샘플 부족 - SMOTE 효과 제한적, k_neighbors 줄이기")
    elif minority_count < 50:
        recommendations.append("→ k_neighbors=3 권장")
    else:
        recommendations.append("→ k_neighbors=5 (기본값) 사용 가능")
    
    # 3. 차원 확인
    if n_features > 100:
        recommendations.append("⚠️  고차원 데이터 - 차원 축소 후 SMOTE 권장")
    
    # 4. 노이즈/아웃라이어 확인
    from sklearn.ensemble import IsolationForest
    iso = IsolationForest(contamination=0.1, random_state=42)
    outlier_pred = iso.fit_predict(X[y == y.min()])
    outlier_ratio = (outlier_pred == -1).sum() / len(outlier_pred)
    
    if outlier_ratio > 0.1:
        recommendations.append(f"⚠️  소수 클래스에 아웃라이어 {outlier_ratio:.1%} 존재 - SMOTE-Tomek 권장")
    
    return True, recommendations

# 사용 예시
use_smote, recommendations = should_use_smote(X_train, y_train)
print("SMOTE 적용 권장:", use_smote)
print("\n추천 사항:")
for rec in recommendations:
    print(f"  {rec}")

7.2 최적의 k_neighbors 찾기

def find_optimal_k(X_train, y_train, X_test, y_test, k_range=range(3, 11)):
    """최적의 k_neighbors 탐색"""
    
    results = []
    
    for k in k_range:
        smote = SMOTE(k_neighbors=k, random_state=42)
        X_smote, y_smote = smote.fit_resample(X_train, y_train)
        
        rf = RandomForestClassifier(random_state=42)
        rf.fit(X_smote, y_smote)
        
        y_pred = rf.predict(X_test)
        
        results.append({
            'k': k,
            'F1': f1_score(y_test, y_pred),
            'Recall': recall_score(y_test, y_pred),
            'Precision': precision_score(y_test, y_pred)
        })
    
    results_df = pd.DataFrame(results)
    
    # 시각화
    plt.figure(figsize=(10, 6))
    plt.plot(results_df['k'], results_df['F1'], marker='o', label='F1', linewidth=2)
    plt.plot(results_df['k'], results_df['Recall'], marker='s', label='Recall', linewidth=2)
    plt.plot(results_df['k'], results_df['Precision'], marker='^', label='Precision', linewidth=2)
    
    best_k = results_df.loc[results_df['F1'].idxmax(), 'k']
    plt.axvline(best_k, color='red', linestyle='--', 
               label=f'Best k={int(best_k)}', linewidth=2)
    
    plt.xlabel('k_neighbors', fontsize=12)
    plt.ylabel('Score', fontsize=12)
    plt.title('Optimal k_neighbors for SMOTE', fontsize=14, fontweight='bold')
    plt.legend()
    plt.grid(True, alpha=0.3)
    plt.tight_layout()
    plt.show()
    
    return results_df

# 실행
optimal_k_results = find_optimal_k(X_train, y_train, X_test, y_test)
print(optimal_k_results)

image

    k        F1  Recall  Precision
0   3  0.648464   0.760   0.565476
1   4  0.657718   0.784   0.566474
2   5  0.649518   0.808   0.543011
3   6  0.639241   0.808   0.528796
4   7  0.631922   0.776   0.532967
5   8  0.632258   0.784   0.529730
6   9  0.634304   0.784   0.532609
7  10  0.647249   0.800   0.543478

7.3 SMOTE 체크리스트

SMOTE 적용 전 체크리스트

  1. 데이터 분할 완료 (train/test split)
  2. 테스트 세트는 원본 분포 유지
  3. 아웃라이어 탐지 및 제거 수행
  4. 고차원 데이터의 경우 차원 축소 고려
  5. 소수 클래스 샘플 수 확인 (최소 10개 이상)
  6. 적절한 k_neighbors 값 설정
  7. Cross Validation에서 각 fold마다 SMOTE 적용
  8. 모델 파이프라인에 SMOTE 포함 (imblearn.pipeline 사용)
  9. F1-Score, Recall, Precision 모두 모니터링
  10. SMOTE 변형 기법(Borderline, ADASYN) 실험

주의사항

  • SMOTE는 만능이 아님: 클래스 가중치, 앙상블 등과 비교
  • 과도한 오버샘플링 주의: 1:1 비율이 항상 최선은 아님
  • 도메인 지식 활용: 합성 샘플이 의미 있는지 검토
  • 실시간 시스템: SMOTE는 학습 시에만, 추론 시엔 사용 안 함


8. 고급 주제: SMOTE의 이론적 한계

8.1 왜 SMOTE가 항상 작동하지 않는가?

문제 1: 무조건적 보간의 맹점

SMOTE의 가정: “두 소수 클래스 샘플 사이의 모든 점은 소수 클래스에 속함”

# 반례: Disjoint 소수 클래스
X_disjoint = np.vstack([
    np.random.randn(900, 2) * 0.5 + [0, 0],     # Majority
    np.random.randn(50, 2) * 0.3 + [-3, -3],    # Minority cluster 1
    np.random.randn(50, 2) * 0.3 + [3, 3]       # Minority cluster 2
])

y_disjoint = np.array([0]*900 + [1]*100)

# SMOTE 적용
smote_disjoint = SMOTE(random_state=42)
X_smote_disjoint, y_smote_disjoint = smote_disjoint.fit_resample(
    X_disjoint, y_disjoint
)

# 문제점: 두 클러스터 사이에 생성된 합성 샘플은 비현실적
plt.figure(figsize=(14, 6))

plt.subplot(1, 2, 1)
plt.scatter(X_disjoint[y_disjoint==0, 0], X_disjoint[y_disjoint==0, 1], 
           c='blue', alpha=0.5, s=20, label='Majority')
plt.scatter(X_disjoint[y_disjoint==1, 0], X_disjoint[y_disjoint==1, 1], 
           c='red', alpha=0.7, s=50, label='Minority (2 clusters)')
plt.title('Original: Disjoint Minority Clusters', fontsize=14, fontweight='bold')
plt.legend()
plt.grid(True, alpha=0.3)

plt.subplot(1, 2, 2)
plt.scatter(X_smote_disjoint[y_smote_disjoint==0, 0], 
           X_smote_disjoint[y_smote_disjoint==0, 1], 
           c='blue', alpha=0.3, s=10)
plt.scatter(X_smote_disjoint[len(X_disjoint):, 0], 
           X_smote_disjoint[len(X_disjoint):, 1], 
           c='orange', alpha=0.5, s=30, marker='^',
           label='Synthetic (unrealistic!)')
plt.scatter(X_disjoint[y_disjoint==1, 0], X_disjoint[y_disjoint==1, 1], 
           c='red', alpha=0.7, s=50, label='Original Minority')
plt.title('After SMOTE: Bridge Between Clusters', fontsize=14, fontweight='bold')
plt.legend()
plt.grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

해결책: Cluster-based SMOTE 사용

8.2 확률 분포 관점의 SMOTE

SMOTE는 소수 클래스의 확률 분포 P(X y=1)을 근사하려는 시도:
P_SMOTE(X|y=1) ≈ Σ K(x - x_synthetic)

where:
- K: 커널 함수 (암묵적으로 uniform kernel)
- x_synthetic: 선형 보간으로 생성된 샘플

한계:

  • 실제 분포가 멀티모달이면 부정확
  • 분포의 꼬리(tail) 부분을 과소평가


9. 최신 연구 동향

9.1 Deep Learning 기반 오버샘플링

# VAE (Variational Autoencoder)를 이용한 합성 샘플 생성
# 개념적 예시 (실제 구현은 복잡함)

from tensorflow.keras.layers import Input, Dense, Lambda
from tensorflow.keras.models import Model
from tensorflow.keras import backend as K

def build_vae_oversampler(input_dim, latent_dim=2):
    """VAE 기반 오버샘플러 (개념적)"""
    
    # Encoder
    inputs = Input(shape=(input_dim,))
    h = Dense(64, activation='relu')(inputs)
    z_mean = Dense(latent_dim)(h)
    z_log_var = Dense(latent_dim)(h)
    
    # Sampling
    def sampling(args):
        z_mean, z_log_var = args
        batch = K.shape(z_mean)[0]
        dim = K.int_shape(z_mean)[1]
        epsilon = K.random_normal(shape=(batch, dim))
        return z_mean + K.exp(0.5 * z_log_var) * epsilon
    
    z = Lambda(sampling)([z_mean, z_log_var])
    
    # Decoder
    decoder_h = Dense(64, activation='relu')
    decoder_out = Dense(input_dim, activation='sigmoid')
    
    h_decoded = decoder_h(z)
    outputs = decoder_out(h_decoded)
    
    vae = Model(inputs, outputs)
    
    return vae

# SMOTE보다 복잡한 분포 학습 가능

9.2 GAN (Generative Adversarial Networks)

  • GAN을 이용한 소수 클래스 샘플 생성
  • 이론적 우위: SMOTE보다 복잡한 비선형 패턴 학습 가능


10. 결론 및 권장사항

Best Practices

  • Always baseline first: SMOTE 전에 클래스 가중치부터 시도
  • Understand your data: 노이즈, 분포, 차원 먼저 분석
  • Compare techniques: SMOTE, ADASYN, Borderline-SMOTE 모두 실험
  • Monitor multiple metrics: F1, Recall, Precision, ROC-AUC 종합 평가
  • Use proper validation: Stratified K-Fold with SMOTE in each fold
  • Consider alternatives: 때로는 앙상블이나 이상치 탐지가 더 적합

When to Use What

# 의사결정 가이드
if imbalance_ratio < 2:
    use_class_weights()
elif imbalance_ratio < 5:
    try_smote()
elif imbalance_ratio < 10:
    try_smote_variants()  # Borderline, ADASYN
else:
    use_ensemble_methods()  # EasyEnsemble, BalancedBagging

References

  • Chawla, N. V., et al. (2002). “SMOTE: Synthetic Minority Over-sampling Technique.” Journal of Artificial Intelligence Research, 16, 321-357.
  • He, H., et al. (2008). “ADASYN: Adaptive Synthetic Sampling Approach for Imbalanced Learning.” IEEE IJCNN.
  • Han, H., et al. (2005). “Borderline-SMOTE: A New Over-Sampling Method in Imbalanced Data Sets Learning.” ICIC.
  • Batista, G. E., et al. (2004). “A Study of the Behavior of Several Methods for Balancing Machine Learning Training Data.” ACM SIGKDD.
  • imbalanced-learn Documentation

댓글남기기