Kategoryzacja zmiennych z użyciem drzewa decyzyjnego

binning, kategoryzacja drzewem, kategoryzacja zmiennych, uczenie maszynowe, sztuczna inteligencja, python, drzewo decyzyjne

W uczeniu maszynowym istnieje kilka relatywnie prostych metod, które pomimo prostoty dają świetne rezultaty. Jedną z nich jest z pewnością kategoryzacja zmiennych z użyciem drzewa decyzyjnego.

Z tego artykułu dowiesz się:
1. Czym jest kategoryzacja zmiennych?
2. Jak kategoryzować zmienne z użyciem algorytmu drzewa decyzyjnego?
3. Jak osiągnąć znaczną poprawę wyniku, unikając przecieków danych i przeuczenia modelu?

Czym jest kategoryzacja zmiennych?

Kategoryzacja zmiennych, nazywana też grupowaniem (ang. binning), to proces budowania nowych zmiennych na podstawie zmiennych już istniejących. Każda zmienna (ew. wybrane zmienne) numeryczna lub kategoryczna jest transformowana do zmiennej kategorycznej.

W najprostszym przypadku proces transformacji jest wykonywany z użyciem wartości średniej, mediany, kwartyli, lub decyli. Mają one jednak tę poważną wadę: są aplikowalne jedynie do zmiennych numerycznych.

Rozwinięciem wspomnianych metod jest klasyfikacja z użyciem płytkiego drzewa decyzyjnego. Ograniczenie jego głębokości ma zabezpieczać przed przeuczeniem modelu i uzyskaniem zbyt dużej liczby kategorii.

Jak działa ta metoda? Otóż zmienną celu w podstawowym modelu drzewa jest y ze zbiory wejściowego, a jedyną zmienną objaśniającą jest zmienna transformowana. Na podstawie wartości Entropii lub indeksu Giniego następuje podział zmiennej wejściowej. Każdorazowo głównym celem jest podział maksymalizujący moc predykcyjną zmiennej. W rezultacie otrzymujemy liście, które będą kategoriami nowej zmiennej.

Główne korzyści i zagrożenia

Do niewątpliwych plusów tej metody można zaliczyć:

  • uchwycenie zależności nieliniowych pomiędzy zmiennymi – jest to szczególnie istotne, gdy decydujemy się na użycie algorytmu liniowego,
  • uwzględnienie braków danych – w przypadku zmiennych kategorycznych nie trzeba usuwać niekompletnych obserwacji, gdyż brak można ująć jako odrębną kategorię (dotyczy to przypadku, w którym nowa zmienna zastępuje starą),
  • poprawa wyniku bez utraty interpretowalności modelu – kategoryzacja jest jednym z zabiegów, po którego odpowiednim wykonaniu nie tracimy na interpretowalności modelu (np. „Klientowi o identyfikatorze xyz nie udzielono kredytu, gdyż znalazł się w grupie osób o zarobkach pomiędzy 1800 zł, a 2100 zł, która charakteryzuje się wysokim ryzykiem.”),
  • zapewnienie interpretowalności modelu – niekiedy zdarza się, że zmienna jest nieintuicyjna w swej interpretacji. Odpowiednia kategoryzacja pozwala niekiedy zapewnić monotoniczność zmiennej.

Nie ma jednak róży bez kolców, dlatego warto w tym miejscu wspomnieć o minusach:

  • utrata części informacji – w procesie dyskretyzacji tracona jest część informacji,
  • czasem jej implementacja wymaga dodatkowego nakładu pracy – w przypadku niektórych branż konieczne jest zapewnienie monotoniczności zmiennych, co powoduje, że w proces trzeba włożyć nieco więcej wysiłku.

Przykład kategoryzacji z użyciem drzewa decyzyjnego

Przy omawianiu idei kategoryzacji zmiennych posłużę się zbiorem utworzonym przy okazji jednego z ostatnich projektów: „Przewidywanie defaultu wśród posiadaczy kart kredytowych”. Dzięki temu możliwa będzie ocena zysku jaki, dało zastosowanie kategoryzacji.

Zbudowanie funkcji do kategoryzacji

Pierwszym krokiem jest zdefiniowanie funkcji do kategoryzacji zmiennych. Pozwoli ona w pewnym stopniu zautomatyzować cały proces. Funkcja przyjmuje dwie zmienne wejściowe typu Pandas Series: zmienną celu i zmienną objasniająca (obie pochodzą ze zbioru uczącego). Na wyjściu zwracane są: model drzewa decyzyjnego i nowa zmienna kategoryczna.

Nowa zmienna otrzymana w wyniku działania funkcji to zmienna wejściowa poddana dyskretyzacji. Drugim zwracanym obiektem jest model, który posłuży do kategoryzacji zmiennych w zbiorze walidacyjnym i testowym.

Warto podkreślić, że model drzewa jest uczony jedynie na zbiorze uczącym. Zbiór walidacyjny i testowy jest tylko kategoryzowany z użyciem drzewa decyzyjnego, które nigdy „nie widziało” zmiennej celu tych zbiorów. W skrócie: nowe zmienne są budowane na podstawie reguł wykrytych tylko i wyłącznie na zbiorze uczącym. To podejście pozwala uniknąć przecieku danych.

Jak już wcześniej wspomniałem, budowane drzewo powinno być relatywnie płytkie. Przy definiowaniu modelu ustalam zatem jeden parametr: max_depth = 3.

Finalna funkcja do kategoryzacji ma następującą postać:

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
Czy kategoryzacja rzeczywiście działa?

By tego dowieść, wykonam prosty test. Zbuduję dwa modele: pierwszy ze wszystkimi zmiennymi wybranymi przy okazji ostatniego projektu i drugi zawierający jedną dodatkową, nowo utworzoną zmienną kategoryczną.

Dla uproszczenia procesu oceny jakości modelu definiuję funkcję służącą do oceny modelu:

def build_and_score_model(x_tr, x_va, y_tr, y_va, selected_features, plots):
    """Logistic regression model evaluation.

    Parameters:
    -----------
    x_tr : Pandas DataFrame, features from trainig dataset
    x_va : Pandas DataFrame, features from validation dataset
    y_tr : Pandas Series, target variable from trainig dataset
    y_va : Pandas Series, target variable from validation dataset
    selected_features : list, list of selected featured
   
    Returns:
    --------
    gini_score : float, gini score
    model : Statsmodels model, fitted logit model
    """
    model = sm.Logit(y_tr, x_tr[selected_features]).fit(disp = 0)
    pre = model.predict(x_va[selected_features])
    fpr, tpr, thresholds = roc_curve(y_va, pre)
    gini_score = 2 * auc(fpr, tpr) - 1
    print('Gini score: {}.'.format(round(gini_score,5)))
    return gini_score

Na wejściu przyjmuje ona zbiór uczący, walidacyjny (podzielone na zmienne objaśniające i zmienną celu) i listę wybranych zmiennych. Na wyjściu zwraca wartość współczynnika Gini.

Model 1:

  • punkt odniesienia dla pozostałych modeli,
  • brak zmiennych kategoryzowanych,
  • dobór zmiennych z użyciem metody forward selection.
features_1 = ['opozn_plat_wrz', 'lim_kredytu', 'platnosc_sie', 'opozn_plat_cze', 'platnosc_wrz', 'platnosc_maj', 'opozn_plat_sie', 'stan_cywilny_w_zwiazku', 'platnosc_lip', 'opozn_plat_maj', 'plec', 'wyksztalcenie_nieznane', 'stan_cywilny_nieznany', 'opozn_plat_lip', 'wiek', 'intercept']
gini_1 = build_and_score_model(x_tr, x_va, y_tr, y_va, features_1)
print('Gini score: {}.'.format(round(gini_1,5)))
[out]:
Gini score: 0.49558.

Model 2:

  • jedna zmienna kategoryzowana drzewem,
  • brak dodatkowego doboru zmiennych.
m_2, categories = categorize_data(x_tr['lim_kredytu'], y_tr)
x_tr = x_tr.assign(lim_kredytu_bin = categories )
x_va = x_va.assign(lim_kredytu_bin = m_2.apply(x_va['lim_kredytu'].values.reshape(-1, 1)))
features_2 = ['lim_kredytu_bin', 'opozn_plat_wrz', 'lim_kredytu', 'platnosc_sie', 'opozn_plat_cze', 'platnosc_wrz', 'platnosc_maj', 'opozn_plat_sie', 'stan_cywilny_w_zwiazku', 'platnosc_lip', 'opozn_plat_maj', 'plec', 'wyksztalcenie_nieznane', 'stan_cywilny_nieznany', 'opozn_plat_lip', 'wiek', 'intercept']
gini_2 = build_and_score_model(x_tr, x_va, y_tr, y_va, features_2)
print('Gini score: {}.'.format(round(gini_2, 5)))
[out]:
Gini score: 0.49658.

Niezbyt duża, ale mimo wszystko widoczna różnica. Sprawdźmy zatem jaki wynik można uzyskać, wykonując kategoryzację drzewem dla wszystkich zmiennych z listy features_1.

Kategoryzacja wszystkich zmiennych

Poprzez „wszystkie” zmienne mam na myśli zmienne w uzyskane w finalnym modelu poprzedniego projektu, a więc te znajdujące się w liście features_1. 🙂 Pozwoli to bezpośrednio porównać zysk w jakości po zastosowaniu kategoryzacji.

Najpierw usuwam zmienną „lim_kredytu_bin”, którą utworzyłem w poprzednim kroku.

x_tr.drop('lim_kredytu_bin', axis = 1, inplace = True)
x_va.drop('lim_kredytu_bin', axis = 1, inplace = True)

Wykonuję kopię listy zmiennych features_1 i usuwam z niej zmiene, których nie chcę kategoryzować („intercept” i zmienne binarne).

features_3 = features_1.copy()
features_1_c = features_1.copy()
features_1_c.remove('intercept')
features_1_c.remove('stan_cywilny_w_zwiazku')
features_1_c.remove('plec')
features_1_c.remove('wyksztalcenie_nieznane')
features_1_c.remove('stan_cywilny_nieznany')

Dla pozostałych zmiennych wykonuję kategoryzację drzewem.

for feature in features_1_c:
    m, categories = categorize_data(x_tr[feature], y_tr)
    x_tr = x_tr.assign(_bin = categories)
    x_va = x_va.assign(_bin = m.apply(x_va[feature].values.reshape(-1, 1)))
    x_te = x_te.assign(_bin = m.apply(x_te[feature].values.reshape(-1, 1)))
    
    feature_name = feature + '_bin'
    features_3.append(feature_name)
    
    x_tr.rename(columns = {'_bin' : feature_name}, inplace = True)
    x_va.rename(columns = {'_bin' : feature_name}, inplace = True)
    x_te.rename(columns = {'_bin' : feature_name}, inplace = True)

Dla otrzymanego zestawu zmiennych buduję model.
Model 3:

  • zmienne kategoryzowane drzewem,
  • brak dodatkowego doboru zmiennych.
gini_3 = build_and_score_model(x_tr, x_va, y_tr, y_va, features_3)
print('Gini score: {}.'.format(round(gini_3, 5)))
[out]:
Gini score: 0.57601.

No i mamy dużą poprawę wyniku 🙂 W zbiorze znajduje się jednak aż 27 zmiennych – nieco za dużo. Wykonam zatem ponowną selekcję.

Selekcja zmiennych

Do wyboru zmiennych użyje metody krokowej forward selection. Definiuję nową funkcję, z pomocą której wybiorę odpowiedni zestaw.

def forward_selection(data, response):
    """Linear model designed by forward selection.

    Parameters:
    -----------
    data : pandas DataFrame with all possible predictors and response
    response: string, name of response column in data
    feature_limit: int, stop criterion, maximum number of features
    
    Returns:
    --------
    best_features: an "optimal" fitted statsmodels linear model
    with an intercept
    selected by forward selection
    evaluated by Gini score
    """
    if 'intercept' in data.columns:
        data.drop('intercept', axis = 1, inplace = True)
    remaining = set(data.columns.sort_values())
    remaining.remove(response)
    best_features = []
    current_score, best_new_score = 0.0, 0.0
    step = 0
    while remaining and current_score == best_new_score:
        step = step + 1
        print('Step: {}'.format(step))
        scores_with_candidates = []
        for candidate in remaining:
            formula = "{} ~ {} + 1".format(response, ' + '.join(best_features + [candidate]))
            _pre = sm.logit(formula, data).fit(disp = 0).predict(x_va)
            fpr, tpr, thresholds = roc_curve(y_va, _pre)
            score = 2 * auc(fpr, tpr) - 1
            scores_with_candidates.append((score, candidate))
        scores_with_candidates.sort()
        best_new_score, best_candidate = scores_with_candidates.pop()
        if current_score < best_new_score:
            print('Adding feature: {}'.format(best_candidate))
            remaining.remove(best_candidate)
            best_features.append(best_candidate)
            current_score = best_new_score
            print('New best score: {}'.format(round(current_score, 5)))
        else:
            break
    formula = "{} ~ {} + 1".format(response, ' + '.join(best_features))
    best_features.append('intercept')
    return best_features

Uruchamiam powyższą funkcję i buduję model na podstawie wybranych zmienych.

dataset = pd.concat([x_tr[features_3], y_tr], axis = 1).rename({1 : 'y'}, axis = 1)
selected_features = forward_selection(dataset, 'y')
gini_4 = build_and_score_model(x_tr, x_va, y_tr, y_va, selected_features)
print('Gini score: {}.'.format(round(gini_4, 5)))
print('Number of features: {}.'.format(len(selected_features)))
[out]:
Gini score: 0.57953.
Number of features: 20.

Gini wzrósł do poziomu 0.57953, przy jednoczesnym zmniejszeniu liczby predyktorów do 20 🙂

Finalny test modelu

By finalnie potwierdzić wartość klasyfikacji, wykonam jeszcze test na zbiorze testowym. Dla przypomnienia: model oparty o zmienne przed kategoryzacją uzyskał wynik na zbiorze testowym: Gini = 0.467.

model = sm.Logit(y_tr, x_tr[selected_features]).fit(disp = 0)
pred = model.predict(x_te[selected_features])
fpr, tpr, thresholds = roc_curve(y_te, pred)
gini_score = 2 * auc(fpr, tpr) - 1
print('Gini score: {}.'.format(round(gini_score, 5)))
[out]:
Gini score: 0.5648.

Udało się poprawić wynik o niemal 10 punktów procentowych, co uznaję za mały sukces, biorąc pod uwagę niewielki nakład poświęconego czasu 🙂

Możliwe rozwinięcia

Ten wpis nie wyczerpuje tematu kategoryzacji obserwacji. Istnieje wiele możliwych rozwinięć, które mogą wpłynąć na jakość procesu i finalny wynik. Można do nich zaliczyć m.in.:

  • Ustalenie maksymalnej liczby kategorii w nowej zmiennej – zabieg ten ma uchronić przed budowaniem zmiennych o dużej liczbie kategorii i zbyt wysokim dopasowaniem modelu do danych uczących.
  • Ustalenie minimalnej liczby obserwacji potrzebnych do zbudowania kategorii – wartość ta może być ujęta jako minimalny odsetek wszystkich obserwacji, które zostały przydzielone do danej kategorii.
  • Zmiana kodowania zmiennych kategorycznych – pod tym hasłem kryje się użycie tzw. Likelihood Encoding (WoE, mean encoding, count encoding, diff encoding) jako alternatywy dla tradycyjnego label encodingu. Ogólna zasada mówi, że im bardziej skomplikowana i mniej liniowa zależność pomiędzy zmienną celu a wybraną zmienną, tym większą wartość wnoszą wymienione metody.
  • Monotoniczność powstałej zmiennej – zadbanie o intuicyjność w interpretacji modelu i zgodność z obostrzeniami prawnymi narzuconymi przez regulatora danej branży. Należy zapewnić by wraz ze wzrostem danej zmiennej malało bądź rosło ryzyko. Sama monotoniczność może być również rozważana jako gwarant większej stabilności zmiennych.
  • Użycie alternatywnych metod kategoryzacji – drzewo decyzyjne jest jednym z wielu sposobów kategoryzacji zmiennych i istnieją inne metody, które w zależności od przypadku mogą dawać lepsze rezultaty.

Podsumowanie

Mam nadzieję, że ten wpis przypadł Ci do gustu. Jeśli nie znałeś wcześniej opisywanych w nim technik, to mam nadzieję, że okażą się one pomocne w Twojej pracy z danymi i uzupełnią zestaw narzędzi, z których korzystasz.

Zawsze staram się, by treści moich wpisów były uniwersalne i aplikowalne niezależnie od języka programowania. Na co dzień korzystam z Pythona, w związku z tym dominuje on na moim blogu. Czasem jestem pytany o język R. Na ten moment nie planuję jednak żadnych publikacji z nim związanych. Jeśli interesują Cię przykłady projektów i analiz opartych o R, to sprawdź proszę blog Łukasza Prokulskiego, który sam czytam. Szczerze polecam :)

Być może masz własne doświadczenia związane z omawianym tematem, spostrzeżenia, bądź jakiekolwiek pytania. Jeśli tak, to proszę, podziel się nimi. Bardzo liczę na Twój komentarz. 🙂

Linki:

photo: Unsplash.com (Gilly Stewart)

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 :-)

2 Komentarze

  1. Gini score: 0.49558 z modelu 1 i Gini score: 0.49558 z modelu to jednak ta sama wartość. W procedurze selekcji zmiennych są błędy. np. dataset (zamiast data) i sm.logit -> import statsmodels.discrete.discrete_model as sm i wtedy sm.Logit (wielkość ma znaczenie).
    Szkoda, że nie ma informacji na jakie i ile binów podzieliła pierwsza metoda. Czy możesz bardziej szczegółowo opisać co robi max_depth=3 bo raczej nie ustala, że liczba binów to max 3.

    • Hej Sebastian 🙂 Poniżej moje odpowiedzi.

      Gini score: 0.49558 z modelu 1 i Gini score: 0.49558 z modelu to jednak ta sama wartość.

      Wkradł tu się mały błąd. W drugim przypadku Gini = 0.49658. Jest więc poprawa. 🙂

      W procedurze selekcji zmiennych są błędy. np. dataset (zamiast data)

      Zmieniłem już nazwę zmiennej. Na szczęście w tym przypadku nie miało to żadnego wpływ na wyniki 😉 („dataset” jest zbiorem wejściowym zdefiniowanym tuż przed uruchomieniem funkcji).

      sm.logit -> import statsmodels.discrete.discrete_model as sm i wtedy sm.Logit (wielkość ma znaczenie)

      Nie mogę się z tym zgodzić. Dla wyników modelowania nie ma to żadnego znaczenia. 🙂 Wielkość liter ma znaczenie, ale tylko dla sposobu budowania modelu. Przy `sm.logit` korzystamy z formuły i automatycznie dodawany jest „intercept”. Zobaczmy co kryją trzy różne podejścia do budowania modelu regresji logistycznej:

      1. `import statsmodels.discrete.discrete_model as sm => print(sm.Logit) => class 'statsmodels.discrete.discrete_model.Logit’`
      2. `import statsmodels.formula.api as sm => print(sm.Logit) => class 'statsmodels.discrete.discrete_model.Logit’`
      3. `import statsmodels.formula.api as sm => print(sm.logit) => bound method Model.from_formula of class 'statsmodels.discrete.discrete_model.Logit’`

      A więc pod spodem mamy dokładnie to samo. 🙂 `sm.logit` to metoda powiązana z metodą `sm.Logit.from_formula`, która w omawianym przykładzie daje dokładnie te same wyniki co `sm.Logit`. Użyłem jej w funkcji `forward_selection` ze względu na możliwość użycia formuły (pierwotna funkcja fs o której wspomniałem przy okazji projektu skoringu kredytowego, zawierała właśnie formułę). 🙂

      Czy możesz bardziej szczegółowo opisać co robi max_depth=3

      Pewnie. 🙂 `max_depth` to maksymalna głębokość budowanego drzewa.

Dodaj komentarz

Twój adres email nie zostanie opublikowany.


*