DSP 005: Transformacja zmiennych z użyciem WoE

Transformacja WoE  jest bodaj najpopularniejszym reprezentantem metod tzw. target encodingu. W tym odcinku Data Science Plus opowiadam o jej zastosowaniach, wadach i zaletach. Zapraszam do przeczytania i obejrzenia!

Ten odcinek w sposób bezpośredni odnosi się do poprzednich odcinków Data Science Plus. Jeśli chcesz mieć pełną wiedzę o tym, co robię, to zachęcam Cię do obejrzenia go, zanim zapoznasz się z najnowszą częścią. 🙂

W tym odcinku usłyszysz:

  • Czym jest WoE (ang. Weight of Evidence)?
  • Po co stosować WoE? Jakie są jej wady i zalety?
  • Do jakich zmiennych należy stosować WoE?
  • Jak przeprowadzić transformację WoE, bez narażania na przecieki danych?
  • O ile punktów Gini transformacja WoE może poprawić model regresji logistycznej?

Czym jest WoE?

WoE (ang. Weight of Evidence) – tłumacząc dosłownie: waga dowodów/przesłanek – jest metodą transformacji zmiennej objaśniającej. Należy do kategorii tzw. target encodingu, lub likelihood encodingu. Polega na zamianie zmiennej nominalnej na zmienną ciągłą. To, co jest niezwykłego w WoE, to fakt, że do przeprowadzenia transformacji używa zmiennej celu.

Powyższy akapit to nie koniec ciekawostek na temat WoE. Transformacja WoE jest niejako „skrojona” pod regresję logistyczną, m.in. stąd jej ogromna popularność w sektorze finansowym. Gdy spojrzymy na wzór WoE, zauważymy logarytm naturalny (patrz poniżej). Ten sam logarytm naturalny jest widoczny we wzorze funkcji logitowej, leżącej u podstaw regresji logistycznej (również poniżej). 😉 Fakt ten niesie ze sobą kilka istotnych zalet:

  1. Skala logarytmiczna jest skalą naturalną dla regresji logistycznej.
  2. WoE zapewnia liniowy związek zmiennej objaśniającej – będącej teraz logarytmem naturalnym ilorazów szans danej kategorii – ze zmienną objaśnianą. Dzięki niej jesteśmy zatem zgodni nie tylko z założeniami modelu regresji, ale również z wymaganiami regulatora.

Wzory funkcji logitowej i WoE:

logit = \ln(\frac{p}{1-p})\\WoE = \ln(\frac{rozklad\ 0}{rozklad\ 1}), gdzie:\\rozklad\ 0 = \frac{liczba\ 0\ w\ danej\ kategorii}{liczba\ 0\ w\ probie}\\rozklad\ 1 = \frac{liczba\ 1\ w\ danej\ kategorii}{liczba\ 1\ w\ probie}

WoE, a przecieki danych

Kontrolowany przeciek jest tu konieczny - do transformacji używamy przecież zmiennej celu. Należy jednak pamiętać, że dopuszczamy do niego tylko i wyłącznie w zbiorze uczącym. Transformacja zbiorów: walidacyjnego i testowego jest przeprowadzona z użyciem słownika zbudowanego na zbiorze uczącym. 🙂

Jak przeprowadzić transformację WoE?

Transformacja wykonywana jest w trzech krokach (dla zmiennych ciągłych):

  1. Kategoryzacja zmiennej ciągłej na kilka kategorii (za pomocą drzew decyzyjnych, kwartyli, decyli, lub innej metody). Zakładamy, że wszystkie wartości zmiennej, które zostały zawarte w danej kategorii oddziałują w jednakowym stopniu na zmienną celu.
  2. Obliczanie WoE dla każdej kategorii transformowanej zmiennej.
  3. Zamiana pierwotnych wartości zmiennej poprzez wartości WoE.

Dla zmiennych kategorycznych można (choć nie trzeba) pominąć krok pierwszy.

Główne korzyści i zagrożenia

Część korzyści już została wymieniona w poprzednich akapitach. Poniżej wymieniam pozostałe i dodaję kilka bolączek metody.

Zalety:
  • poprawa jakości modelu - mam tu na myśli bezpośrednie przełożenie na statystykę Gini,
  • eliminacja części bolączek regresji logistycznej - wrażliwość na wartości odstające, założenia dotyczące rozkładu - po transformacji WoE przestają nas (w dużym stopniu) one interesować,
  • ujednolicenie skali zmiennych - podsumowując model i patrząc na współczynniki regresji, jesteśmy w stanie ocenić wpływ zmiennej/kategorii na predykcję; przed transformacją trudno porównać, czy np. rok doświadczenia zawodowego wpływa równie mocno na predykcję, co jeden tysiąc dodatkowego dochodu miesięcznie; po transformacji WoE porównujemy "jabłka do jabłek"; poza tym już podczas budowy modelu możemy w prosty sposób wnioskować o tym, czy np. stan_cywilny = "żonaty" będzie mieć taki sam wpływ na ryzyko, jak wyksztalcenie = "średnie"; jeśli jesteś ciekaw, jaki wpływ na ryzyko ma sytuacja, gdy dana osoba: stan_cywilny = "żonaty" i wyksztalcenie = "średnie", to wystarczy zsumować wartości WoE odpowiadające obu kategoriom 🙂
  • umożliwia ocenę wpływu danej kategorii na zmienną celu - WoE to logarytm naturalny ilorazu szans na predykcję klas 0 i 1, zatem im mniejsza wartość WoE, tym większe prawdopodobieństwo 1, a im większa wartość WoE, tym mniejsze prawdopodobieństwo defaultu,
  • zapewnia monotoniczną relację zmiennej objaśnianej ze zmienną objaśnianą - wraz ze wzrostem wartości WoE maleje default rate, a więc teoretycznie maleje prawdopodobieństwo defaultu,
  • WoE jest składową IV - IV, czyli Information Value, to jedna z najważniejszych statystyk używanych do selekcji zmiennych; absolutny standard w bankowości i sektorze finansowym.
Wady:
  • utrata części informacji - kategoryzacja zmiennej ciągłej sprawia, że tracimy część informacji zawartej w zmiennej,
  • podatność na przeuczenie - wynika to z dwóch elementów: źle przeprowadzonej kategoryzacji zmiennej ciągłem, lub/i zastosowaniu niezgodnym z przeznaczeniem - WoE użyte z np. XGBoostem na 99% doprowadzi do przeuczenia - ogólna zasada jest następująca: WoE + logit = OK, WoE + wysublimowane algorytmy = OVERFITTING; 🙂 dla lasów losowych i XGBoost-a znacznie lepiej sprawdzi się mean encoding okraszony odrobiną szumu (poprzez m.in. CV, lub wygładzanie),
  • podatność na przecieki danych - do przecieków dochodzi, gdy jest zastosowana w sposób niewłaściwy; należy pamiętać o kalkulacji WoE tylko i wyłącznie na zbiorze uczącym,
  • nie uwzględnia zależności pomiędzy zmiennymi - WoE jest transformacją jednowymiarową i nie uwzględnia zależności/interakcji pomiędzy zmiennymi objaśniającymi.

Wystarczy już teoretyzowania. Przejdźmy do praktycznego przykładu. 🙂

1. Import bibliotek.

In [1]:
from category_encoders import WOEEncoder
from creme_de_la_creme_of_ML import forward_selection
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import seaborn as sns
from sklearn.metrics import roc_auc_score, classification_report, confusion_matrix, accuracy_score, recall_score, precision_score, roc_curve
from sklearn.model_selection import cross_val_score, train_test_split
from sklearn.tree import DecisionTreeClassifier
from statsmodels.discrete.discrete_model import Logit
import statsmodels.api as sm_api

2. Wczytanie zbioru danych.

In [2]:
x_tr = pd.read_csv('data/x_tr.csv', index_col = 0)
x_te = pd.read_csv('data/x_te.csv', index_col = 0)
x_va = pd.read_csv('data/x_va.csv', index_col = 0)
y_tr = pd.read_csv('data/y_tr.csv', header = None, index_col = 0)
y_te = pd.read_csv('data/y_te.csv', header = None, index_col = 0)
y_va = pd.read_csv('data/y_va.csv', header = None, index_col = 0)

Sprawdzam rozkład zmiennej celu.

In [3]:
y_tr[1].value_counts(normalize = True)
y_te[1].value_counts(normalize = True)
Out[3]:
0    0.764957
1    0.235043
Name: 1, dtype: float64

76.5% do 23.5% - ewidentnie możemy tu mówić o zbiorze niezbalansowanym.

Zmiana w zbiorze - zamieniam dummy coding na label encoding. Z pomocą WoE mogę nieco zmniejszyć "szerokość" zbioru bez negatywnego wpływu na model.

In [4]:
x_tr = x_tr.iloc[:, 0:-7]
x_te = x_te.iloc[:, 0:-7]
x_va = x_va.iloc[:, 0:-7]
In [5]:
ef = pd.ExcelFile('data/default_of_credit_card_clients.xls')
df = ef.parse('Data', skiprows=1, names = ['id', 'lim_kredytu', 'plec', 'wyksztalcenie', 'stan_cywilny', 'wiek', 'opozn_plat_wrz', 'opozn_plat_sie', 'opozn_plat_lip', 'opozn_plat_cze', 'opozn_plat_maj', 'opozn_plat_kwi', 'kwota_wyciagu_wrz', 'kwota_wyciagu_sie', 'kwota_wyciagu_lip', 'kwota_wyciagu_cze', 'kwota_wyciagu_maj', 'kwota_wyciagu_kwi', 'platnosc_wrz', 'platnosc_sie', 'platnosc_lip', 'platnosc_cze', 'platnosc_maj', 'platnosc_kwi', 'y'])
df.drop('id', axis = 1, inplace = True)
In [6]:
x_tr = pd.merge(x_tr, df[['wyksztalcenie', 'stan_cywilny']], left_index=True, right_index=True)
x_te = pd.merge(x_te, df[['wyksztalcenie', 'stan_cywilny']], left_index=True, right_index=True)
x_va = pd.merge(x_va, df[['wyksztalcenie', 'stan_cywilny']], left_index=True, right_index=True)

3. Przygotowanie zbioru.

3.1. Kategoryzacja zmiennych drzewem.

Przygotowanie funkcji do kategoryzacji.

In [7]:
def categorize_data(x, y):
    """Tree based data categorization.

    Parameters:
    -----------
    x : Pandas Series, feature to categorize       
    y : Pandas Series, target variable
   
    Returns:
    --------    
    dt_model : sklearn.tree.DecisionTreeClassifier, fitted decision tree model  
    categories : numpy.ndarray, list of categories
    """
    dt_model = DecisionTreeClassifier(max_depth = 3)
    dt_model.fit(x.values.reshape(-1, 1), y)
    categories = dt_model.apply(x.values.reshape(-1, 1))
    return dt_model, categories

Sprawdzę jeszcze listę unikalnych wartości w poszczególnych zmiennych.

In [8]:
x_tr.nunique()
Out[8]:
intercept                1
lim_kredytu             51
plec                     2
wiek                    55
opozn_plat_wrz          11
opozn_plat_sie          11
opozn_plat_lip          10
opozn_plat_cze          10
opozn_plat_maj          10
opozn_plat_kwi          10
kwota_wyciagu_wrz    12373
kwota_wyciagu_sie    12104
kwota_wyciagu_lip    11894
kwota_wyciagu_cze    11605
kwota_wyciagu_maj    11274
kwota_wyciagu_kwi    11026
platnosc_wrz          4520
platnosc_sie          4465
platnosc_lip          4214
platnosc_cze          3900
platnosc_maj          3901
platnosc_kwi          3895
wyksztalcenie            7
stan_cywilny             4
dtype: int64
In [9]:
# Tylko zmienne, które mają więcej niż 8 wartości unikalanych.
zmienne_do_kategoryzacji = [zmienna for zmienna in x_tr.columns if x_tr[zmienna].unique().shape[0] > 8]

for zmienna in zmienne_do_kategoryzacji:
    model, kategorie = categorize_data(x_tr[zmienna], y_tr[1])
    x_tr = x_tr.assign(_kat = kategorie)
    x_te = x_te.assign(_kat = model.apply(x_te[zmienna].values.reshape(-1, 1)))
    x_va = x_va.assign(_kat = model.apply(x_va[zmienna].values.reshape(-1, 1)))
    
    nazwa_zmiennej = zmienna + '_kat'
    
    x_tr.rename(columns = {'_kat' : nazwa_zmiennej}, inplace = True)
    x_te.rename(columns = {'_kat' : nazwa_zmiennej}, inplace = True)
    x_va.rename(columns = {'_kat' : nazwa_zmiennej}, inplace = True)
3.2. Transformacja WoE.
In [10]:
zmienne_do_transformacji_woe = [zmienna for zmienna in x_tr.columns if x_tr[zmienna].unique().shape[0] <= 8]
zmienne_do_transformacji_woe.remove('intercept')
In [11]:
woe_encoder = WOEEncoder(cols = zmienne_do_transformacji_woe)
In [12]:
x_tr = woe_encoder.fit_transform(x_tr, y_tr[1])
x_te = woe_encoder.transform(x_te)
x_va = woe_encoder.transform(x_va)

W tym miejscu muszę zaznaczyć kilka elementów:

  • Biblioteka z której korzystam o transformacji WoE jest moim niedawnym odkryciem. Testuję ją od około miesiąca.
  • Autorzy zakładają, że "Good" jest oznaczany w zmiennej celu za pomocą 1, czyli odwrotnie niż w przypadku międzynarodowego sektora finansowego. W sektorze finansowym "Good", czyli dobry kredytobiorca jest oznaczany za pomocą 0. Jeśli zdecydujesz się na używanie biblioteki, to można z tego wybrnąć mnożąc uzyskane wyniki WoE przez -1.
  • Domyślnie klasa WOEEncoder zakłada niewielką regularyzację. Jej celem są: uniknięcie błędu związanego z dzieleniem przez 0 i uniknięcie zbyt wysokiego dopasowania do zbioru uczącego. Jeśli pracujesz w branży regulowanej prawnie, sprawdź dokładne parametry klasy przed jej zastosowaniem.

4. Modelowanie.

4.1. Selekcja zmiennych.
In [13]:
x_tr.head()
Out[13]:
intercept lim_kredytu plec wiek opozn_plat_wrz opozn_plat_sie opozn_plat_lip opozn_plat_cze opozn_plat_maj opozn_plat_kwi ... kwota_wyciagu_lip_kat kwota_wyciagu_cze_kat kwota_wyciagu_maj_kat kwota_wyciagu_kwi_kat platnosc_wrz_kat platnosc_sie_kat platnosc_lip_kat platnosc_cze_kat platnosc_maj_kat platnosc_kwi_kat
14828 1.0 150000 -0.07338 35 0 0 0 0 0 0 ... 0.142217 0.175387 0.110083 0.149887 0.005696 0.094372 0.026454 -0.041845 0.036686 -0.025596
16255 1.0 180000 -0.07338 27 1 -2 -2 -2 -2 -2 ... 0.017958 -0.017077 -0.029701 -0.031560 0.599139 0.490912 0.470904 0.414770 0.340347 0.307439
6364 1.0 30000 -0.07338 59 0 0 0 0 0 0 ... 0.142217 0.175387 0.110083 0.149887 -0.468795 -0.106620 0.026454 -0.041845 0.036686 -0.025596
22630 1.0 200000 -0.07338 41 1 -2 -1 -1 -2 -1 ... 0.017958 -0.017077 -0.029701 -0.031560 0.599139 0.094372 0.470904 0.414770 0.036686 0.307439
9163 1.0 230000 -0.07338 44 1 -1 -1 -1 -1 -2 ... -0.122636 -0.017077 -0.029701 -0.031560 0.005696 -0.106620 0.026454 0.414770 0.340347 0.307439

5 rows × 44 columns

In [14]:
finalne_zmienne = forward_selection(pd.concat([x_tr, x_va]), pd.concat([y_tr, y_va]))
Step: 1
Adding feature: opozn_plat_wrz_kat
New best score: 0.424
Step: 2
Adding feature: lim_kredytu_kat
New best score: 0.492
Step: 3
Adding feature: opozn_plat_cze_kat
New best score: 0.51
Step: 4
Adding feature: platnosc_sie_kat
New best score: 0.527
Step: 5
Adding feature: opozn_plat_kwi_kat
New best score: 0.532
Step: 6
Adding feature: platnosc_cze_kat
New best score: 0.535
Step: 7
Adding feature: wyksztalcenie
New best score: 0.538
Step: 8
Adding feature: opozn_plat_lip_kat
New best score: 0.54
Step: 9
Adding feature: platnosc_wrz_kat
New best score: 0.542
Step: 10

Czemu zredukowałem liczbę zmiennych do 9? Modele zawierające kilkadziesiąt zmiennych są ciężkie w utrzymaniu. W praktyce unika się tego typu sytuacji.

Data Scientistów korzystających z Pandas-a można podzielić na dwie grupy:

  • tych, którzy mieli problemy z pamięcią RAM,
  • tych, który będą mieć problemy z pamięcią RAM. 😉

Jeśli należysz do drugiej grupy, sprawdź mój wpis o 5 sposobach na radzenie sobie z dużymi zbiorami w Pandas.

4.2. Budowa modelu z wybranymi zmiennymi.
In [15]:
model_rl = Logit(y_tr, x_tr[finalne_zmienne]).fit()
prawd_rl = model_rl.predict(x_te[finalne_zmienne])
gini = 2*roc_auc_score(y_te, prawd_rl)-1
print('Gini score:', gini.round(3))
Optimization terminated successfully.
         Current function value: 0.564373
         Iterations 6
Gini score: 0.563

5. Porównanie wyników.

5.1. Surowe zmienne vs WoE.

W pierwszej kolejności zweryfikuję wpływ kategoryzacji i transformacji WoE na wynik. Użyję jedynie 9 wybranych zmiennych.

5.1.1. Zmienne przed kategoryzacją drzewem.
In [16]:
x_tr = pd.read_csv('data/x_tr.csv', index_col = 0)
x_te = pd.read_csv('data/x_te.csv', index_col = 0)
y_tr = pd.read_csv('data/y_tr.csv', header = None, index_col = 0)
y_te = pd.read_csv('data/y_te.csv', header = None, index_col = 0)
In [17]:
x_tr = x_tr.iloc[:, 0:-7]
x_va = x_va.iloc[:, 0:-7]
In [18]:
ef = pd.ExcelFile('data/default_of_credit_card_clients.xls')
df = ef.parse('Data', skiprows=1, names = ['id', 'lim_kredytu', 'plec', 'wyksztalcenie', 'stan_cywilny', 'wiek', 'opozn_plat_wrz', 'opozn_plat_sie', 'opozn_plat_lip', 'opozn_plat_cze', 'opozn_plat_maj', 'opozn_plat_kwi', 'kwota_wyciagu_wrz', 'kwota_wyciagu_sie', 'kwota_wyciagu_lip', 'kwota_wyciagu_cze', 'kwota_wyciagu_maj', 'kwota_wyciagu_kwi', 'platnosc_wrz', 'platnosc_sie', 'platnosc_lip', 'platnosc_cze', 'platnosc_maj', 'platnosc_kwi', 'y'])
df.drop('id', axis = 1, inplace = True)
In [19]:
x_tr = pd.merge(x_tr, df[['wyksztalcenie', 'stan_cywilny']], left_index=True, right_index=True)
x_te = pd.merge(x_te, df[['wyksztalcenie', 'stan_cywilny']], left_index=True, right_index=True)
In [20]:
finalne_zmienne
Out[20]:
['opozn_plat_wrz_kat',
 'lim_kredytu_kat',
 'opozn_plat_cze_kat',
 'platnosc_sie_kat',
 'opozn_plat_kwi_kat',
 'platnosc_cze_kat',
 'wyksztalcenie',
 'opozn_plat_lip_kat',
 'platnosc_wrz_kat']

Usuwam przyrostki, których nie ma w surowym zbiorze.

In [21]:
finalne_zmienne_bez_przyrostkow = ['opozn_plat_wrz',
 'lim_kredytu',
 'opozn_plat_cze',
 'platnosc_sie',
 'opozn_plat_kwi',
 'platnosc_cze',
 'wyksztalcenie',
 'opozn_plat_lip',
 'platnosc_wrz']
In [22]:
model_rl = Logit(y_tr, x_tr[finalne_zmienne_bez_przyrostkow]).fit()
prawd_rl = model_rl.predict(x_te[finalne_zmienne_bez_przyrostkow])
gini = 2*roc_auc_score(y_te, prawd_rl)-1
print('Gini score:', gini.round(3))
Optimization terminated successfully.
         Current function value: 0.487625
         Iterations 6
Gini score: 0.461
5.1.2. Zmienne po drzewie, przed WoE.
In [23]:
# Tylko zmienne, które mają więcej niż 8 wartości unikalanych.
zmienne_do_kategoryzacji = [zmienna for zmienna in x_tr.columns if x_tr[zmienna].unique().shape[0] > 8]

for zmienna in zmienne_do_kategoryzacji:
    model, kategorie = categorize_data(x_tr[zmienna], y_tr)
    x_tr = x_tr.assign(_kat = kategorie)
    x_te = x_te.assign(_kat = model.apply(x_te[zmienna].values.reshape(-1, 1)))
    
    nazwa_zmiennej = zmienna + '_kat'
    
    x_tr.rename(columns = {'_kat' : nazwa_zmiennej}, inplace = True)
    x_te.rename(columns = {'_kat' : nazwa_zmiennej}, inplace = True)
In [24]:
model_rl = Logit(y_tr, x_tr[finalne_zmienne]).fit()
prawd_rl = model_rl.predict(x_te[finalne_zmienne])
gini = 2*roc_auc_score(y_te, prawd_rl)-1
print('Gini score:', gini.round(3))
Optimization terminated successfully.
         Current function value: 0.473741
         Iterations 6
Gini score: 0.517
5.1.3. Zmienne po obu transformacjach.

Wynik widoczny w punkcie 4.2: Gini score = 0.563. Wyniki dla 9 zmiennych wyglądają więc następująco:

Zmienne surowe (9 zmiennych) Zmienne kategoryzowane drzewem (9 zmiennych) Zmienne kategoryzowane drzewem + transformowane WoE (9 zmiennych)
Gini 0.461 0.517 0.563
5.2. Porównanie najlepszego modelu z innymi modelami budowanymi na tym samym zbiorze.
Logit (29 zmiennych) Logit + kategoryzacja drzewem (49 zmiennych) Logit + kategoryzacja drzewem + WoE + selekcja zmiennych (9 zmiennych) XGBoost + randomized search + Boruta + kategoryzacja drzewem (16 zmiennych)
Gini 0.481 0.566 0.563 0.58

Po zastosowaniu WoE wynik spadł z 0.566 do 0.563. Nie uznaję tego jednak za porażkę, gdyż zredukowałem liczbę zmiennych do 9. 🙂 Do pobicia wyniku uzyskanego z pomocą XGBoost i kilku zaawansowanych metod troszkę jeszcze brakuje. Nie powiedziałem jednak ostatniego słowa i mam asa w rękawie (patrz ogłoszenie na końcu wpisu). 😉

6. Podsumowanie.

To już koniec prac nad tym zbiorem. Jestem zadowolony z postaci modelu, wykonanych transformacji, liczby finalnych zmiennych i końcowego wyniku. W kolejnych wpisach i odcinkach Data Science Plus będę brać na tapetę kolejne tematy z zakresu zaawansowanej analityki. Do usłyszenia! 🙂

KRÓTKIE OGŁOSZENIE
W najbliższy dniach udostępnię kilkudziesięciominutowe wideo szkoleniowe w postaci screencastu, które przygotowałem specjalnie dla subskrybentów bloga. Budując newsletter założyłem, że będę się dzielić z subskrybentami materiałami premium, niedostępnymi dla szerszego grona czytelnikow.

Wspomniany we wpisie "as z rękawa" idealnie pasuje na główny temat tego typu materiału. :) Uchylając rąbka tajemnicy napiszę, że jest głównym tematem screencastu będzie nietrywialna technika, która pozwala znacznie poprawić jakość modelu i zbliżyć się, lub wręcz pobić wynik osiągany z użyciem black-boxów. Jeśli jeszcze tego nie zrobiłeś, dołącz do subskrybentów, by otrzymywać darmowe poradniki, informacje ode mnie i materiały premium. :)

Linki:

Podobał Ci się ten artykuł?

Jeśli tak, to zarejestruj się, by otrzymywać informacje o nowych wpisach. Dodatkowo w prezencie wyślę Ci bezpłatny poradnik 🙂

Bądź pierwszy, który skomentuje ten wpis!

Dodaj komentarz

Twój adres email nie zostanie opublikowany.


*