Niezbalansowane dane klasyfikacyjne? Na ratunek SMOTE!

SMOTE

Ostatnio przygotowałem dla Was wprowadzenie czym są dane niezbalansowane i jakie wiążą się z nimi zagrożenia. W tym wpisie opiszę metodę zwaną SMOTE, którą ostatnio wykorzystałem podczas projektu w pracy.

Co to jest SMOTE?

Metoda SMOTE została po raz pierwszy opisana w 2002 w pracy autorstwa Nitesh Chawla zatytułowanej SMOTE: Synthetic Minority Over-sampling Technique. Jak sama nazwa wskazuje jest to technika generująca syntetyczne dane dla klasy mniejszości.

SMOTE niezbalansowane dane

A co znaczy syntetyczne? Oznacza to, że SMOTE działa poprzez łączenie punktów klasy mniejszości odcinkami linii, a następnie umieszcza na tych liniach sztuczne punkty.

ne

Ta technika tworzy nowe instancje danych grup mniejszościowych, kopiując istniejące dane i wprowadzając do nich niewielkie zmiany. To sprawia, że SMOTE świetnie wzmacnia sygnały, które już istnieją w grupach mniejszościowych, ale nie stworzy nowych sygnałów dla tych grup.

Działanie SMOTE

Sposób działania algorytmu SMOTE możemy krótko opisać w 4 prostych krokach:

  1. Wybieramy losowo przykładową obserwację dla klasy mniejszościowej.
  2. Znajdujemy k najbliższych sąsiadów dla wybranej obserwacji (z punktu 1).
  3. Wybieramy jednego z tych sąsiadów i umieszczamy syntetyczny punkt w dowolnym miejscu na linii łączącej rozpatrywany punkt z sąsiadem. Linię tą możemy porównać do przestrzeni cech. Starając sobie to zwizualizować pomyślałem o redukcji wymiarów. Na przykład mając 100 zmiennych opisujących dane zjawisko można wykorzystać PCA (opisałem TUTAJ) i zredukować przestrzeń do 2 wymiarów. W ten sposób łączymy punkty.
  4. Powtarzamy kroki 1-3, aż dane zostaną zbalansowane.

Biblioteka Imbalanced-Learn

SMOTE jest zaimplementowany w Pythonie przy użyciu biblioteki imblearn.

Bibliotekę instalujemy w ten sposób:

pip install imbalanced-learn

A tak można sprawdzić jej wersję:

import imblearn
print(imblearn.__version__)
SMOTE imblearn

Zapraszam również do zerknięcia do samej dokumentacji – najnowszy link znajdziecie w repozytorium na GitHub: https://github.com/scikit-learn-contrib/imbalanced-learn

Prosty syntetyczny przykład

Aby zobaczyć prosty przykład działania SMOTE, wygenerujmy próbkę danych. Niech to będą dwa zbiory z odrobinę różnym rozkładem. Możemy użyć funkcji make_classification() z biblioteki scikit-learn, aby utworzyć zestaw danych syntetycznej klasyfikacji binarnej z rozkładem klas 1:1000.

import pandas as pd

import matplotlib.pyplot as plt
import seaborn as sns

from collections import Counter
from sklearn.datasets import make_classification

X, y = make_classification(n_samples=12345, n_features=2, n_redundant=0,
                           n_clusters_per_class=1, weights=[0.999], flip_y=0, 
                           random_state=12345)
print(f'Class 0: {Counter(y)[0]}; Class 1: {Counter(y)[1]}')

df = pd.concat([pd.DataFrame(X, columns=['x','y']),
                pd.DataFrame(y, columns=['class'])],axis=1)

palette = ['Gray', 'Crimson']

sns.lmplot(x="x", y="y", data=df, fit_reg=False, 
           legend=True, height=5, aspect=2.5, 
           hue='class', palette=palette)

plt.title('Example data generated');
SMOTE example

Teraz napiszmy krótką funkcję, aby wykorzystać SMOTE do wygenerowania takiej liczby punktów, ile jest w klasie mniejszościowej. Czyli, aby było tyle „1” co „0”.

def example(X, y, oversample, title):
    X_smote, y_smote = oversample.fit_resample(X, y)

    df_smote = pd.concat([pd.DataFrame(X_smote, columns=['x','y']),
                          pd.DataFrame(y_smote, columns=['class'])],axis=1)

    sns.lmplot(x="x", y="y", data=df_smote, fit_reg=False, 
           legend=True, height=5, aspect=2.5, 
           hue='class', palette=palette)

    plt.title(f'{title} example');
    
example(X, y, SMOTE(), 'SMOTE')
SMOTE example

Jak widać na przykładzie SMOTE po prostu połączył wcześniej występujące punkty ze sobą, a następnie tworzył syntetyczne dane na ich połączeniach. Super, że tyle tego mamy, ale warto pamiętać, że to też może być czasami wada tego rozwiązania. Większość problemów modelarskich ciężko opisać prostymi liniowymi zależnościami i czasami w ten sposób dogenerowywane dane mogą wpływać na to, jak model potraktuje dane, których jeszcze nie widział.

Inne warianty SMOTE

W bibliotece imblearn jest wiele różnych wariantów SMOTE. Odrobinę się różnią implementacją. Główne z nich to:

  • ADASYNta metoda jest podobna do SMOTE, ale generuje różną liczbę próbek w zależności od oszacowania lokalnego rozkładu klasy miejszościowej;
  • BorderlineSMOTEinna implementacja SMOTE zgodna z pracą z 2005 “Borderline-SMOTE: a new over-sampling method in imbalanced data sets learning” https://ousar.lib.okayama-u.ac.jp/en/19617;
  • SVMSMOTE – inny wariant SMOTE wykorzystujący algorytm SVM  do wykrywania próbki syntetycznych danych;
  • KMeansSMOTEmodyfikacja algorytmu poprzez dodanie klastrowania przed samym algorytmem SMOTE.
from imblearn.over_sampling import ADASYN

example(X, y, ADASYN(), 'ADASYN')
SMOTE example adasyn
from imblearn.over_sampling import BorderlineSMOTE

example(X, y, BorderlineSMOTE(), 'BorderlineSMOTE')
SMOTE example BorderlineSmote
from imblearn.over_sampling import KMeansSMOTE

example(X, y, KMeansSMOTE(cluster_balance_threshold=0.001), 'KMeansSMOTE')
SMOTE example KMeansSMOTE

Polecam poeksperymentować na własnych przykładach i zobaczyć co u Was najlepiej zadziała!

Przykład w Python na danych z fraudami kartowymi

Wykorzystamy w tym celu udostępnione w konkursie Kaggle dane transakcyjne dokonane kartami kredytowymi we wrześniu 2013  przez europejskich posiadaczy kart.

Zestaw zawiera transakcje z dwóch dni, w których mamy 492 oszustw (fraud) na 284 807 transakcji. Na pierwszy rzut oka widać, że zbiór jest wysoce niezrównoważony – klasa dodatnia oszustwa (fraud) stanowi 0,172% wszystkich transakcji.

Zbiór danych można pobrać: TUTAJ.

Dane są tylko numeryczne i większość cech (od V1 do V28) jest wynikiem transformacji PCA (kiedyś o tym pisałem), która została wykonana w celu zapewnienia poufności danych.

Jedynie dwie cechy nie zostały zmienione. Są to:

  • Kwota – kwota transakcji w dolarach $$$,
  • Czas – w sekundach, które upłynęły między każdą transakcją a pierwszą transakcją w zbiorze danych.

Wczytanie i przygotowanie danych

Skoro mamy dane, to je wczytajmy!

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

pd.options.display.max_columns = 13

df = pd.read_csv('../data/creditcard.csv')
df.head()
kaggle fraud

Przygotujmy podział na zbiór do trenowania i zbiór do ostatecznego przetestowania modelu.

from sklearn.model_selection import train_test_split

X = df.loc[:, 'V1':'Amount']
y = df['Class']

X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.25, random_state=2021)

print('Train data shape: {}, frauds: {}'.format(X_train.shape, y_train.sum()))
print('Test data shape: {}, frauds: {}'.format(X_test.shape, y_test.sum()))
pd.Series(y_train).value_counts().plot.bar();
python value counts

Jak widzicie, ostatecznie zbiór treningowy składa się z 366 fraudów i ponad 213 tysięcy obserwacji normalnych! To są dopiero dane niezbalansowane.

Trenowanie modelu i optymalizacja

W pierwszym kroku zbudujmy prosty model. Niech to będzie mój ulubiony XGBoost!

Dodatkowo fajnie będzie zadbać, by nie był to model na domyślnych parametrach. Wobec tego wykorzystam zamiast optymalizacji baysowskiej (której najczęściej używam w pracy) tym razem metodę losowania (ang. Random Search). Jeśli chcesz poczytać więcej o metodach zapraszam TUTAJ.

Wykorzystamy funkcję RandomizedSearchCV, która wykorzystuje walidację krzyżową (ang. cross-validation) domyślnie na 5 zbiorach. Pamiętajcie – zbioru testowego nie dotykamy! Tylko do ostatecznego testu. O zbiorze testowym myślcie jako o maturze – ostateczny egzamin sprawdzający jak się nauczyliśmy.

import xgboost as xgb

from sklearn.model_selection import RandomizedSearchCV

random_param = {
    'max_depth': range(2,5),
    'learning_rate': np.logspace(np.log10(0.005), np.log10(0.5), base = 10, num = 1000),
    'n_estimators': range(25, 250, 25),
    'subsample': np.linspace(0.5, 1, 101),
    'colsample_bytree': np.linspace(0.6, 1, 5),
}

model = xgb.XGBClassifier()
 
random_search = RandomizedSearchCV(estimator=model
                                 , param_distributions=random_param
                                 , n_iter=20 
                                 , scoring='f1')

random_search.fit(X=X_train, y=y_train)
 
best_parameters = random_search.best_params_  
print(best_parameters) 
xgboost random search

Przygotujmy jeszcze funkcję do podsumowania modelu i metryk:

from sklearn.metrics import roc_auc_score, accuracy_score, f1_score, accuracy_score, recall_score, precision_score, matthews_corrcoef, average_precision_score

def create_measures(y,y_pred):
    # minimalna wartość dla cut offa dla takiej samej ilości badów jak występuje w próbce
    cut_off = np.sort(y_pred)[-y.sum():].min()
    y_pred_class = np.array([0 if x < cut_off else 1 for x in y_pred])
    
    d = {'f1_score': [round(f1_score(y, y_pred_class),4)],
         'P-R score': [round(average_precision_score(y, y_pred_class),4)],
         'matthews': [round(matthews_corrcoef(y, y_pred_class),4)],
         'accuracy': [round(accuracy_score(y, y_pred_class),4)],
         'recall': [round(recall_score(y, y_pred_class),4)],
         'precision': [round(precision_score(y, y_pred_class),4)],
        }
    
    return pd.DataFrame.from_dict(d)
 
def calculating_metrics(X_train, X_test, y_train, y_test, model):
    train = create_measures(y_train, model.predict_proba(X_train)[:, 1])
    test = create_measures(y_test, model.predict_proba(X_test)[:, 1])
     
    return pd.concat([train,test]).set_index([pd.Index(['TRAIN', 'TEST'])]) 

Dla mnie główną metryką będzie F1, która szuka kompromisu pomiędzy precision a recall. Chcę dobrze rozpoznawać fraudy, ale nie kosztem normalnych transakcji. Zakładam, że takie transakcje albo będą automatyczne blokowane, albo jeszcze przeglądane przez kogoś. A nasi szefowie nie będą chcieli zatrudniać sztabu osób do przeglądania błędnie wskazanych przez model decyzji.

No to budujemy i sprawdzamy:

model = xgb.XGBClassifier(**best_parameters)
model.fit(X_train, y_train)

calculating_metrics(X_train, X_test, y_train, y_test, model)
f1 PR matthews accurency recall precision

Widać na próbce treningowej, że model został przetrenowany. Trafił wszystko idealnie. Można byłoby pobawić się regularyzacją, jednak w związku z tym, że nie koncentruję się tutaj, aby nie przetrenować modelu, a pokazać jak działa SMOTE, zostawię parametry zwrócone przez random search.

Trenowanie modelu z wykorzystaniem SMOTE

To wykorzystajmy teraz SMOTE do rozmnożenia liczby fraudów:

smote = SMOTE(random_state=2021)

X_train_smote, y_train_smote = smote.fit_resample(X_train, y_train)
pd.Series(y_train_smote).value_counts().plot.bar();
python value counts

Jak widać liczba obserwacji jest identyczna. O to nam tutaj chodziło. Jeśli chcesz inne proporcje np. 1:4, to możesz prosto ustawić to parametrem.

Uwaga! Nigdy, przenigdy nie wykorzystuj metod SMOTE do zmiany na próbce testowej! Chcemy zaimplementować nasz model na danych rzeczywistych, więc chcemy zobaczyć, jak nasz model będzie działał na danych rzeczywistych, a nie na danych syntetycznych, które stworzyliśmy.

To teraz szukanie optymalnmych parametrów:

model_smote = xgb.XGBClassifier()
 
random_search_smote = RandomizedSearchCV(estimator=model_smote
                                 , param_distributions=random_param
                                 , n_iter=20
                                 , scoring='f1')

random_search_smote.fit(X=X_train_smote, y=y_train_smote)

best_parameters_smote = random_search_smote.best_params_  
print(best_parameters_smote) 
xgboost random serach

i ostateczne sprawdzenie wyniku:

model_smote = xgb.XGBClassifier(**best_parameters_smote)
model_smote.fit(X_train_smote, y_train_smote)

calculating_metrics(X_train_smote, X_test, y_train_smote, y_test, model_smote)
f1 PR matthews accurency recall precision

Tadam! W tym przypadku widzimy, że metoda mogła przyczynić się do polepszenia metryk. Dlaczego tylko „mogła„? Ponieważ, aby to potwierdzić, przydałoby się więcej prób. Lepszy wynik może być też dobraniem lepszych hiperparametów. Niemniej jednak drugi model wypada lepiej.

Podsumowanie

Mam nadzieję, że teraz poradzicie sobie w przypadku, gdybyście potrzebowali zbalansować próbkę przy wykorzystaniu syntetycznych danych. Pamiętajcie, że każdy problem, nad którym pracujecie jest inny i nie zawsze jak coś raz zadziała, to będzie można to powtórzyć.

Pozdrawiam z całego serducha,

podpis Mirek

Obraz Aamir Mohd Khan z Pixabay

.

4 Comments on “Niezbalansowane dane klasyfikacyjne? Na ratunek SMOTE!”

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *