Być może zastanawiałeś się kiedyś nad tym, jakie korzyści niesie ze sobą wdrożenie w organizacji rozwiązania opartego o uczenie maszynowe. Optymalizacja kosztów, przewaga nad konkurencją i możliwość zarządzania ryzykiem to te, które jako pierwsze przychodzą mi do głowy. Jest jednak jeszcze jeden szalenie ważny aspekt, który choć bardzo podstawowy, często jest zapominany.
Mowa tu o automatyzacji procesów. Dokładniej rzecz ujmując, mam na myśli złożone procesy, przy których niezbędna jest wiedza i doświadczenie eksperta. To w takich przypadkach uczenie maszynowe błyszczy najmocniej i pokazuje swą siłę. Dzięki algorytmom jesteśmy w stanie odwzorować proces podejmowania decyzji przez eksperta, a dzięki mocy obliczeniowej dzisiejszych maszyn jesteśmy w stanie znacząco go przyspieszyć.
- Wstęp
- Opis zbioru danych
- Założenia projektu
- Cel projektu
- Wczytanie danych i niezbędnych bibliotek
- Przygotowanie danych do analizy
- Eksploracyjna analiza dnaych
- Przygotowanie danych do modelowania
- Model 1
- Model 2
- Model 3
- Model 4
- Finalna weryfikacja jakości modelu
- Wizualizacja i interpretacja wyników
- Podsumowanie
Mała niespodzianka dla wszystkich nielubiących czytać długich wpisów 🙂
Jeśli nie lubisz czytać długich wpisów, to mam coś dla Ciebie. W poniższym wideo znajduje się wersja wideo tego wpisu. Całość została opowiedziana i zaprezentowana przeze mnie z użyciem Jupyter Notebook.
Z tego wpisu dowiesz się: - Jak można użyć uczenia maszynowego do automatyzacji procesów decyzyjnych w sektorze finansowym? - Jak optymalizować parametry drzewa decyzyjnego, by uniknąć przeuczenia? - Jak dobierać zmienne do modelu? - Jak interpretować i wizualizować proces decyzyjny w postaci drzewa?
Wstęp
W tym wpisie chciałbym poruszyć temat automatyzacji pewnego procesu biznesowego. Pokażę jak można przenieść wiedzę ekspercką na algorytm drzewa decyzyjnego i jak uchwycić spośród wielu cech te, które w największym stopniu wpływają na decyzję.
Proces biznesowy, który będę analizować, pochodzi z sektora finansowego.Dotyczy on podejmowania decyzji w sprawie akceptacji lub odrzucenia wniosku o wydanie karty kredytowej. Sytuacja jest więc relatywnie prosta: potencjalny klient przychodzi do oddziału banku i składa wniosek. W oparciu o zestaw dostępnych cech ekspert (bądź system ekspercki) podejmuje decyzję, czy wydać kartę, czy też nie.
Opis zbioru danych
Zbiór, który użyję, jest dostępny pod tym linkiem. Zawiera on dane dotyczące 690 wniosków o wydanie karty kredytowej, wraz z decyzją: czy wniosek został zaakceptowany, czy też nie. Są to rzeczywiste dane, w których znajduje się łącznie 16 zmiennych. 15 z nich to zmiennej objaśniające (nazwane od A1 do A15). Są one cechami opisującymi osobę składającą wniosek, oraz (prawdopodobnie) danymi behawioralne (opisujące np. historią prowadzenia konta, wcześniejsze spłaty zobowiązań wobec banku, etc.).
Co do znaczenia większości zmiennych zawartych w zbiorze nie można mieć jednak pewności. Wszystkie ich nazwy zostały celowo zmienione, ze względu na ochronę danych osobowych. Można jedynie domniemywać, co się ta kryło pod poszczególnymi nazwami. Jedna spośród zmiennych, której znaczenie jest znane, to zmienna celu (A16). Przyjmuje on dwie wartości: +/-. „+” oznacza, że wniosek został zaakceptowany, „-” opisuje sytuację, w której wniosek odrzucono. Dokładny oryginalny opis zbioru znajduje się tutaj.
Pewnie się zastanawiasz, jak chcę interpretować proces decyzyjny, skoro nie mam nazw zmiennych. Na potrzeby projektu przyjmę ich sztuczną interpretację, którą zaczerpnąłem z tej strony. Nazwy zmiennych zamieniam tak, by przypominały nazwy, z którymi można spotkać się w bankowości. Mam nadzieję, że wybaczysz mi ten zabieg. 🙂
Jedną zmienną, którą zmieniłem w stosunku do tego, co jest widoczne na stronie, jest „ZipCode”, który u mnie będzie saldem konta. Zmienna przyjmuje 170 unikalnych wartości, z czego aż 132 wynoszą 0. Dodatkowo wykonałem analizę zależności zmiennej ze zmienną celu i uznałem, że „Saldo_konta” będzie tu bardziej pasować. 😉
Po zamianie nazwy zmiennych, wraz z ich typami wyglądają następująco:
- Płeć – binarna,
- Wiek – ciągła,
- Zadłużenie – ciągła,
- Stan_cywilny – nominalna,
- Bank – nominalna,
- Wykształcenie – nominalna,
- Pochodzenie – nominalna,
- Lata_pracy – ciągła,
- Poz_hist_kred – binarna,
- Umowa_o_pracę – binarna,
- Score_kredytowy – ciągła,
- Prawo_jazdy – binarna,
- Obywatelstwo – nominalna,
- Saldo_konta – ciągła,
- Przychody – ciągła,
- Wynik – binarna.
Założenia projektu
Przed przystąpieniem do prac nam zbiorem muszę wymienić kilka założeń, które przyjąłem:
- Jako, że problem dotyczy sektora finansowego, do rozwiązania problemu użyję w pełni interpretowalnego algorytmu – drzewa decyzyjnego w wersji CART, z biblioteki Python sklearn.
- Interpretowalność odnosi się nie tylko do samego algorytmu, ale również do jego parametrów. Parametry algorytmu nie mogą utrudniać jego interpretacji. Drzewo nie powinno być zbyt głębokie.
- W interpretacji może przeszkodzić również zbyt duża liczba użytych zmiennych. Analiza głębokiego drzewa z kilkudziesięcioma zmiennymi może przypominać proces szukania z lupą najkrótszej drogi na mapie o dużej skali. W finalnym modelu powinno się znaleźć maksymalnie 10 – 15 zmiennych.
- Dane powinny być przetwarzane w taki sposób, aby możliwie jak najmniej przeszkadzało to w interpretacji osiągniętych wyników. Nie będę zatem wykonywać skomplikowanych transformacji zmiennych.
- Jakość modelu będę badał za pomocą dokładności predykcji (ang. Accuracy).
Cel projektu
Celem ogólnym projektu jest w jak najwyższym stopniu odwzorować proces decyzyjny odpowiadający za akceptację lub odrzucenie wniosku o wydanie karty kredytowej. Celem szczegółowym jest zbudowanie zestawu interpretowalnych reguł, które mogłoby automatyzować proces podejmowania decyzji biznesowej. W skrócie: będę starać się jak najlepiej odwzorowanie proces decyzyjny eksperta.
Celem jakościowym jest osiągnięcie dokładności na poziomie 80% – 90%, przy zachowaniu odpowiedniej stabilności modelu. Tę ostatnią będę badać podczas walidacji krzyżowej modelu. Będzie ona wyrażona w procentach i obliczana z pomocą wzoru: (odchylenie standardowe wyników Accuracy/średnia Accuracy) * 100.
Wczytanie danych i niezbędnych bibliotek
Cały projekt wykonuję w Pythonie, korzystając z Jupyter-a. Pierwszym krokiem jest więc zatem wczytanie niezbędnych bibliotek.
# Podstawowe biblioteki import pandas as pd import numpy as np # Podział zbioru, dobór parametrów modelu from sklearn.model_selection import train_test_split, GridSearchCV # Dobór zmiennych from sklearn.feature_selection import RFE # Walidacja zbioru from sklearn.model_selection import cross_val_score # Algorytm drzewa decyzyjnego from sklearn.tree import DecisionTreeClassifier # Badanie jakości modelu from sklearn.metrics import accuracy_score # Wizualizacja drzewa from sklearn import tree import graphviz
Zanim przejdę dalej, wykonuję jedną prostą, ale szalenie istotną rzecz: zmieniam precyzję wyświetlania liczb zmiennoprzecinkowych. Zamiast każdorazowo zaokrąglać, wystarczy jedna linijka kodu, dzięki której liczby będą wyświetlane w Pandas z precyzją do trzech miejsc po przecinku. Proste, a potrafi zaoszczędzić sporo czasu. 🙂
pd.set_option('float_format', '{:.3f}'.format)
Wczytuję teraz zbiór z pliku csv. To, co robię dodatkowo przy wczytywaniu, to parametrami wskazuję Pandas-owi:
- brak nagłówka w zbiorze,
- nazwy poszczególnych zmiennych,
- typy niektórych zmiennych,
- znak za pomocą którego oznaczone są braki w danych.
df = pd.read_csv('data/crx.data', header=None, names=['Płeć','Wiek','Zadłużenie','Stan_cywilny','Bank','Wykształcenie','Pochodzenie','Lata_pracy','Poz_hist_kred','Umowa_o_pracę','Score_kredytowy','Prawo_jazdy','Obywatelstwo','Saldo_konta','Przychody','Wynik'], decimal='.', na_values='?', dtype = {'Płeć':'category','Stan_cywilny':'category','Bank':'category','Wykształcenie':'category','Pochodzenie':'category','Poz_hist_kred':'category','Umowa_o_pracę':'category','Prawo_jazy':'category','Obywatelstwo':'category','Wynik':'category'})
Mając wczytany zbiór, sprawdzam jego rozmiar i podglądam, jak wyglądają poszczególne obserwacje, by sprawdzić, czy wszystko wczytało się prawidłowo.
print(str(df.shape[0]) + ' wierszy.') print(str(df.shape[1]) + ' kolumn.')
Przygotowanie danych do analizy
Kolej na przygotowanie danych do dalszej analizy. W tym kroku przyjrzę się danym, zbadam ich ogólną strukturę (typy zmiennych, braki i ich liczbę). Zaczynam od analizy braków.
Przywykłem do analizowania danych z użyciem małego, specjalnie do tego zbudowanego data frame-u, w którym znajdują się wszystkie niezbędne informacje. W tym przypadku są to: nazwa zmiennej, informacja o jej typie, informacja czy dana zmienna zawiera braki, liczba braków, oraz procentowa brakującyh wartości w zmiennej.
Nawyk budowania tego typu data frame-ów miał swój początek w raportach, które buduję w pracy. Taką tabelkę łatwo jest bowiem wyeksportować np. do Excela i umieścić w raporcie, lub prezentacji. Raz zrobione, można wykorzystać później wielokrotnie.
summary = pd.DataFrame(df.dtypes, columns=['Dtype']) summary['Nulls'] = pd.DataFrame(df.isnull().any()) summary['Sum_of_nulls'] = pd.DataFrame(df.isnull().sum()) summary['Per_of_nulls'] = round((df.apply(pd.isnull).mean()*100),2) summary.Dtype = summary.Dtype.astype(str) print(summary)
Dtype | Nulls | Sum_of_nulls | Per_of_nulls | |
---|---|---|---|---|
Płeć | category | True | 12 | 1.74 |
Wiek | float64 | True | 12 | 1.74 |
Zadłużenie | float64 | False | 0 | 0.00 |
Stan_cywilny | category | True | 6 | 0.87 |
Bank | category | True | 6 | 0.87 |
Wykształcenie | category | True | 9 | 1.30 |
Pochodzenie | category | True | 9 | 1.30 |
Lata_pracy | float64 | False | 0 | 0.00 |
Poz_hist_kred | category | False | 0 | 0.00 |
Umowa_o_pracę | category | False | 0 | 0.00 |
Score_kredytowy | int64 | False | 0 | 0.00 |
Prawo_jazdy | object | False | 0 | 0.00 |
Obywatelstwo | category | False | 0 | 0.00 |
Saldo_konta | float64 | True | 13 | 1.88 |
Przychody | int64 | False | 0 | 0.00 |
Wynik | category | False | 0 | 0.00 |
print(str(round(df.isnull().any(axis=1).sum()/df.shape[0]*100,2))+'% obserwacji zawiera braki w danych.')
5.36% obserwacji zawiera braki w danych. Pierwsze wnioski wyglądają następująco:
- 7 kolumn zawiera brakujące wartości.
- Najwięcej braków zawiera zmienna 'Saldo_konta’ – 13 brakujących wartości, co stanowi ok. 1,88% wszystkich wartości tej kolumny.
- W sumie mamy 37 obserwacji zawierających jakiekolwiek brakujące wartości, co stanowi ok. 5,36% wszystkich obserwacji.
Mam to szczęście, że algorytm – drzewo decyzyjne – nie jest wrażliwe na brakujące wartości. Nie mniej, liczba brakujących wartości jest na tyle mała, że dla przejrzystości dalszej części projektu, usunę je.
df.dropna(inplace=True) print('Pozostało ' + str(df.shape[0]) + ' obserwacji.')
Pozostało 653 obserwacji. Jak widać w powyższej tabeli, nie wszystkie zmienne mają odpowiednie kategorie. Co więcej, algorytm, z którego będę korzystać przyjmuje na wejściu jedynie zmienne liczbowe, całkowite i zmiennoprzecinkowe (ew. binarne). Warto zatem wszystkie zmienne kategoryczne, które przyjmują tylko dwie kategorie zamienić od razu na zmienną binarną, zakodowaną w formacie 'uint8′.
Sprawdzam zatem liczbę unikalnych kategorii wchodzących w skład wybranych zmiennych.
df.select_dtypes(exclude = ['float', 'int']).describe()
Płeć | Stan_cywilny | Bank | Wykształcenie | Pochodzenie | Poz_hist_kred | Umowa_o_pracę | Prawo_jazdy | Obywatelstwo | Wynik | |
---|---|---|---|---|---|---|---|---|---|---|
count | 653 | 653 | 653 | 653 | 653 | 653 | 653 | 653 | 653 | 653 |
unique | 2 | 3 | 3 | 14 | 9 | 2 | 2 | 2 | 3 | 2 |
top | b | u | g | c | v | t | f | f | g | – |
freq | 450 | 499 | 499 | 133 | 381 | 349 | 366 | 351 | 598 | 357 |
Z powyższego zbioru interesują mnie te zmienne, które mają jedynie dwie kategorie. Mogę je zamienić na zmienne binarne. Pozostałe zmienne hurtowo obsłużę w punkcie „Przygotowanie danych do modelowania”.
# zmiana wartości zmiennych df.Poz_hist_kred.replace(['t','f'],[1,0], inplace=True) df.Umowa_o_pracę.replace(['t','f'],[1,0], inplace=True) df.Płeć.replace(['a','b'],[1,0], inplace=True) df.Prawo_jazdy.replace(['t','f'],[1,0], inplace=True) df.Wynik.replace(['+','-'],[1,0], inplace=True) # zmiana typu zmiennych df['Poz_hist_kred'] = df['Poz_hist_kred'].astype('uint8') df['Umowa_o_pracę'] = df['Umowa_o_pracę'].astype('uint8') df['Płeć'] = df['Płeć'].astype('uint8') df['Prawo_jazdy'] = df['Prawo_jazdy'].astype('uint8') df['Wynik'] = df['Wynik'].astype('uint8')
Eksploracyjna analiza danych
W ramach EDA wykonam dwie podstawowe czynności: weryfikację podstawowych statystyk dotyczących zmiennych numerycznych, weryfikację statystyk dotyczących połączonego zbioru zmiennych: kategorycznych i binarnych. Dla tych ostatnich zbadam zależność ze zmienną celu poprzez zbudowanie tablicy liczebności (crosstab: badana zmienna vs zmienna celu). Crosstab da mi ogólne spojrzenie na moc predykcyjną danej zmiennej.
Uważam, że warto w tym miejscu zaznaczyć, że celowo pomijam analizę korelacji i zależności pomiędzy pozostałymi zmiennymi. Współliniowość w drzewie decyzyjnym mi nie zagraża. Podobnie w sposób świadomy pomijam analizę odstających wartościami. Przyczyna jest prosta: algorytm drzewa decyzyjnego nie jest wrażliwy na outliery. W przypadku zmiennych numerycznych podział węzłów w drzewie decyzyjnym jest wykonywany na podstawie proporcji obserwacji w zbiorze, a nie na podstawie zakresu wartości.
Zmienne numeryczne
stats = df.select_dtypes(['float', 'int']).describe() stats = stats.transpose() stats = stats[['count','std','min','25%','50%','75%','max','mean']] print(stats)
count | std | min | 25% | 50% | 75% | max | mean | median | |
---|---|---|---|---|---|---|---|---|---|
Wiek | 653.00 | 11.84 | 13.75 | 22.58 | 28.42 | 38.25 | 76.75 | 31.50 | 28.42 |
Zadłużenie | 653.00 | 5.03 | 0.00 | 1.04 | 2.83 | 7.50 | 28.00 | 4.83 | 2.83 |
Lata_pracy | 653.00 | 3.37 | 0.00 | 0.17 | 1.00 | 2.62 | 28.50 | 2.24 | 1.00 |
Score_kredytowy | 653.00 | 4.97 | 0.00 | 0.00 | 0.00 | 3.00 | 67.00 | 2.50 | 0.00 |
Saldo_konta | 653.00 | 168.30 | 0.00 | 73.00 | 160.00 | 272.00 | 2000.00 | 180.36 | 160.00 |
Przychody | 653.00 | 5253.28 | 0.00 | 0.00 | 5.00 | 400.00 | 100000.00 | 1013.76 | 5.00 |
W przypadku kilku zmiennych jasno widać, że średnia jest większa niż mediana. Jest to objaw skośności rozkładu. Jeśli chciałbym użyć jednego z algorytmów, który cechuje się wrażliwością na dane odstające (np. regresja logistyczna), to musiałbym dodatkowo:
- wykonać normalizację danych numerycznych – istnieje duża różnica w wartościach, maksymalnych (i średnich) poszczególnych zmiennych, np. 'Score_kredytowy’ vs 'Przychody’),
- pozbyć się odstających obserwacji, co dodatkowo uszczupliłoby zbiór.
Zmienne kategoryczne i binarne
df.select_dtypes(['category']).describe()
Stan_cywilny | Bank | Wykształcenie | Pochodzenie | Obywatelstwo | |
---|---|---|---|---|---|
count | 653 | 653 | 653 | 653 | 653 |
unique | 3 | 3 | 14 | 9 | 3 |
top | u | g | c | v | g |
freq | 499 | 499 | 133 | 381 | 598 |
Dalsza analiza jest dosyć obszerna, dlatego zamieszczam ważniejsze wnioski, wraz z kodem (całość jest dostępna na moim GitHub-ie, do którego link załączyłem w podsumowaniu tego wpisu).
Zmienna „Stan_cywilny”
cat = pd.DataFrame(df.Stan_cywilny.value_counts()) cat.rename(columns={'Stan_cywilny':'Num_of_obs'}, inplace=True) cat['Per_of_obs'] = cat['Num_of_obs']/df.shape[0]*100 print(cat) print('Crosstab:') pd.crosstab(df.Stan_cywilny, df.Wynik)
Num_of_obs | Per_of_obs | |
---|---|---|
u | 499 | 76.42 |
y | 152 | 23.28 |
l | 2 | 0.31 |
Crosstab:
Wynik | 0 | 1 |
---|---|---|
Stan_cywilny | ||
l | 0 | 2 |
u | 250 | 249 |
y | 107 | 45 |
Dwie kategorie się wyróżniają. Zdecydowana większość wniosków dla osób należących do kategorii 'y’, dla zmiennej Stan_cywilny zostaje odrzuconych. 100% wniosków do kategorii 'l’ zostaje zaakceptowanych.
Zmienna „Bank”
cat = pd.DataFrame(df.Bank.value_counts()) cat.rename(columns={'Bank':'Num_of_obs'}, inplace=True) cat['Per_of_obs'] = cat['Num_of_obs']/df.shape[0]*100 print(cat) print('Crosstab:') pd.crosstab(df.Bank, df.Wynik)
Num_of_obs | Per_of_obs | |
---|---|---|
u | 499 | 76.42 |
y | 152 | 23.28 |
l | 2 | 0.31 |
Crosstab:
Wynik | 0 | 1 |
---|---|---|
Bank | ||
l | 0 | 2 |
u | 250 | 249 |
y | 107 | 45 |
Jedna z kategorii się wyróżnia. Zdecydowana większość wniosków dla osób należących do kategorii 'p’, dla zmiennej „Bank” zostaje odrzuconych, ale zaraz… czy czego Ci to nie przypomina? Dokładnie taka sama liczność jak w przypadku zmiennej „Stan_cywilny”. Buduję zatem szybkie porównanie obu zmiennych.
df.groupby(['Bank', 'Stan_cywilny'])['Wynik'].describe()
count | mean | std | min | 25% | 50% | 75% | max | ||
---|---|---|---|---|---|---|---|---|---|
Bank | Stan_cywilny | ||||||||
g | u | 499.00 | 0.50 | 0.50 | 0.00 | 0.00 | 0.00 | 1.00 | 1.00 |
gg | l | 2.00 | 1.00 | 0.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 |
p | y | 152.00 | 0.30 | 0.46 | 0.00 | 0.00 | 0.00 | 1.00 | 1.00 |
Ewidentnie widać tu jakiś błąd. Jedna z tych zmiennych jest zatem zupełnie niepotrzebna 🙂
Zmienna „Poz_hist_kred”
cat = pd.DataFrame(df.Poz_hist_kred.value_counts()) cat.rename(columns={'Poz_hist_kred':'Num_of_obs'}, inplace=True) cat['Per_of_obs'] = cat['Num_of_obs']/df.shape[0]*100 print(cat) print('Crosstab:') pd.crosstab(df.Poz_hist_kred, df.Wynik)
Num_of_obs | Per_of_obs | |
---|---|---|
1 | 349 | 53.45 |
0 | 304 | 46.55 |
Crosstab:
Wynik | 0 | 1 |
---|---|---|
Poz_hist_kred | ||
0 | 286 | 18 |
1 | 71 | 278 |
Ciekawa zmienna. Osoby z pozytywną historią kredytową w większości przypadków otrzymują kartę kredytową. Osoby bez pozytywnej historii kredytowej raczej karty nie otrzymują.
Zmienna „Umowa_o_pracę”
cat = pd.DataFrame(df.Umowa_o_pracę.value_counts()) cat.rename(columns={'Umowa_o_pracę':'Num_of_obs'}, inplace=True) cat['Per_of_obs'] = cat['Num_of_obs']/df.shape[0]*100 print(cat) pd.crosstab(df.Umowa_o_pracę, df.Wynik)
Num_of_obs | Per_of_obs | |
---|---|---|
0 | 366 | 56.05 |
1 | 287 | 43.95 |
Crosstab:
Wynik | 0 | 1 |
---|---|---|
Umowa_o_pracę | ||
0 | 273 | 93 |
1 | 84 | 203 |
W większości przypadków osoby z umową o prace otrzymują kartę kredytową.
Bez względu na wybrany typ podziału drzewa (gini, entropia), powyższe zmienne, to moi faworyci 🙂
p.s. Jeśli jesteś ciekaw, jak wypadły pozostałe zmienne, to na moim GitHub-ie znajdzie notebook Jupyter z kodem i pełnymi wynikami. Link zamieszczam na końcu tego wpisu.
Przygotowanie danych do modelowania
Jak już wspomniałem, do budowy modelu wykorzystam algorytm drzewa decyzyjnego z pakietu SKLearn. Ta implementacja bazuje na algorytmie CART, dlatego też muszę zmienić kodowanie zmiennych kategorycznych (częściowo wykonałem do w kroku „Przygotowanie danych do analizy”).
Lista zmiennych do zamiany:
df.select_dtypes(include=['category']).describe()
Stan_cywilny | Bank | W | Pochodzenie | Obywatelstwo | |
---|---|---|---|---|---|
count | 653 | 653 | 653 | 653 | 653 |
unique | 3 | 3 | 14 | 9 | 3 |
top | u | g | c | v | g |
freq | 499 | 499 | 133 | 381 | 598 |
Zamieniam kodowanie wybranych zmiennych:
df = pd.concat([df, pd.get_dummies(df.Stan_cywilny, prefix='Stan_cywilny__')], axis = 1) df = pd.concat([df,pd.get_dummies(df.Bank, prefix='Bank__')], axis = 1) df = pd.concat([df,pd.get_dummies(df.Wykształcenie, prefix='Wykształcenie__')], axis = 1) df = pd.concat([df,pd.get_dummies(df.Pochodzenie, prefix='Pochodzenie__')], axis = 1) df = pd.concat([df,pd.get_dummies(df.Obywatelstwo, prefix='Obywatelstwo__')], axis = 1)
Usuwam zbędne kolumny:
df.drop(['Stan_cywilny', 'Bank', 'Wykształcenie', 'Pochodzenie', 'Obywatelstwo'], axis = 1, inplace = True)
Po wykonaniu powyższych operacji w zbiorze są 43 zmienne. Zdecydowanie za dużo. Negatywnie wpłynie to na interpretowalność (i pewnie na sam wynik :)), dlatego będzie trzeba coś z tym zrobić.
Podział zbioru
Dzielę zbiór na 2 części:
- Zbiór treningowy – na nim wykonam walidację krzyżową. Celem sprawdzianu krzyżowego będzie dobór parametrów algorytmu i zmiennych użytych w modelu.
- Zbiór testowy – finalny sprawdzian dla zbudowanego modelu. Sprawdzę jak zbudowany algorytm radzi sobie z danymi, których nigdy wcześniej nie widział.
Podział jaki stosuję to:
- 80% – zbiór treningowy i jednocześnie walidacyjny,
- 20% – zbiór testowy.
Przy podziale uwzględniam stratyfikację. Dzięki temu proporcje wniosków odrzuconych do zaakceptowanych będą takie same w obu zbiorach.
Zbiór przed podziałem:
df.Wynik.value_counts(normalize=True)
0: 0.547
1: 0.453
Dzielę zbiór (random_state, to jak zwykle data wykonywania analizy:)):
y = df.Wynik # kopiuje zmienną celu do osobnej zmiennej i usuwam ją ze zbioru df.drop('Wynik', axis = 1, inplace = True) x_tr, x_te, y_tr, y_te = train_test_split(df, y, test_size = 0.2, random_state = 7042018, stratify = y)
Zbiór po podziale wygląda następująco:
print(y_tr.value_counts(normalize = True)) print(y_te.value_counts(normalize = True))
0: 0.55
1: 0.45
0: 0.55
1: 0.45
Ok, mam zatem podzielony i oczyszczony zbiór. Mogę zatem przejść dalej. Mój plan na modelowanie wygląda następująco:
- Zbuduję „czysty” model, który będzie benchmarkiem. Zawierać on będzie wszystkie zmienne i domyślne parametry algorytmu.
- Zoptymalizuję parametry modelu dla wszystkich zmiennych z użyciem GridSearchCV.
- Z użyciem metody RFE wybiorę zestaw zmiennych, które najlepiej opisuję zmienną celu.
- Ponownie dokonam optymalizacji parametrów modelu, tym razem dla podzbioru zmiennych.
Model 1
Opis modelu:
- dobór zmiennych: brak,
- dobór parametrów: brak.
Będzie to mój benchmark. Do wyniku uzyskanego przez ten model będę się odnosić w kolejnych punktach. Wynik będę każdorazowo badać na podstawie średniej z 10 zbudowanych modeli w walidacji krzyżowej.
W tym miejscu chciałbym odpowiedzieć na dwa ważne pytania:
- Czemu akurat walidacja krzyżowa? Otóż, jak wspominałem, drzewo decyzyjne ma tendencję do zbytniego dopasowywania się do zbioru (pisałem o tym w jednym z poprzednich wpisów: Wybór odpowiedniego algorytmu). Przy klasycznym podziale na zbiór uczący i walidacyjny, mógłbym przy optymalizacji parametrów modelu zabrnąć w ślepą uliczkę, kierując się wynikiem, jaki osiągam. Wykonując dziesięciokrotny podział na zbiór uczący i walidacyjny, uśredniam wynik i jest on bardziej miarodajny. Tak więc, w przypadku drzewa decyzyjnego walidacja krzyżowa jest pierwszym sposobem, który pozwoli mi uniknąć przeuczenia modelu.
- Jaki jest drugi sposób na uniknięcie zbytniego dopasowania modelu? Dobór parametrów modelu. A dokładniej, przycięcie drzewa i sprawienie by nie było ono „zbyt pewne” podejmowanych decyzji.
model = DecisionTreeClassifier() cv = cross_val_score(model, x_tr, y_tr, cv = 10, scoring = 'accuracy') print('Średnie Accuracy: ' + str(cv.mean().round(3))) print('Stabilność: ' + str((cv.std()*100/cv.mean()).round(3)) + '%')
Średnie Accuracy: 0.804 – tego wyniku będę używał jako odniesienia przy kolejnych iteracjach modelowania.
Stabilność: 6.326%
Udało się na starcie osiągnąć średni poziom Accuracy na poziomie 0.804. Nie jest źle 🙂 Minusem jest jednak liczba zastosowanych zmiennych (42). Tak duża liczba jest nie do przyjęcia, ale poradzę sobie z tym w kolejnych krokach. 🙂
Dodatkowo sprawdzę jak „surowy” model wypada na zbiorze testowym. Dzięki temu, na koniec projektu będę mógł sprawdzić o ile procent zwiększyłem (lub zmniejszyłem) wynik.
model.fit(x_tr, y_tr) pred = model.predict(x_te) print('Benchmark: ' + str(round(accuracy_score(pred, y_te),3)))
Benchmark: 0.809 – tego wyniku użyję do końcowego porównania.
Model 2
Opis modelu:
- dobór zmiennych: brak,
- dobór parametrów: GridsearchCV #1.
Dodaję do modelu GridSearchCV, który pomoże znaleźć optymalne (dla tego zestawu zmiennych) parametry drzewa.
Uwaga: celowo nie zagłębiam się w szczegóły działania: GridSearchCV i cross_val_score. Zakładam, że znasz ich ogólną ideę i sposób działania. Jeśli nie, to zapraszam na blog już niebawem, gdyż w kolejnych artykułach będę poruszać obie te kwestie 🙂
Parametry, których będę szukać, to:
- kryterium podziału drzewa,
- sposób dzielenia kolejnych „węzłów”,
- maksymalna głębokość drzewa,
- minimalna liczba obserwacji potrzebnych do podziału,
- minimalna liczba obserwacji do zbudowania liścia.
Definiuję parametry:
parameters = {'criterion':('entropy', 'gini'), 'splitter':('best','random'), 'max_depth':np.arange(1,10), 'min_samples_split':np.arange(2,10), 'min_samples_leaf':np.arange(1,5)}
Użyję teraz GridSearchCV() do znalezienia optymalnych parametrów modelu. Dla każdego zestawu parametrów zostanie wykonana walidacja krzyżowa na 10 podzbiorach w celu uśrednienia wyniku i sprawdzenia jak model zachowuje się dla różnych zestawów danych.
classifier = GridSearchCV(DecisionTreeClassifier(), parameters, cv=10) classifier.fit(x_tr, y_tr) classifier.best_params_
Wybrane parametry to:
- criterion: entropy,
- max_depth: 6,
- min_samples_leaf: 1,
- min_samples_split: 3,
- splitter: random.
cv = cross_val_score(DecisionTreeClassifier(**classifier.best_params_), x_tr, y_tr, cv = 10, scoring = 'accuracy') print('Średnie Accuracy: ' + str(cv.mean().round(3))) print('Stabilność: ' + str((cv.std()*100/cv.mean()).round(3)) + '%')
Średnie Accuracy: 0.862.
Stabilność: 7.761%.
Niestety nieco spadła stabilność modelu, ale jest za to znacząca poprawa dokładności. 🙂
Model 3
Opis modelu:
- dobór zmiennych: RFE,
- dobór parametrów: GridsearchCV #1.
Zgodnie z planem, dodaję kolejne usprawnienie w postaci metody doboru zmiennych RFE (ang. recursive feature elimination).
Buduję model, oparty o algorytm zawierający optymalne parametry:
model = DecisionTreeClassifier(**classifier.best_params_)
Poniższy kod zawiera dwie pętle, w których:
- pierwsza, to 20 iteracji, w których definiuję dwie listy do zbierania wyników,
- druga, wykona się każdorazowo po 15 razy. Dobieram w niej zmienne do modelu. Pierwsze wykonanie pętli to dobór 5 najlepszych zmiennych, drugie to 6 najlepszych zmiennych, itd.
W pętli numer 2, dla wybranego zestawu zmiennych uczony jest model, z użyciem
cross_val_score
. W sumie
cross_val_score
wykona się 300 razy. Dodatkowo w każdej walidacji krzyżowej model jest przecież uczony 10 razy. Mam świadomość, że złożoność obliczeniowa procesu może i nie jest najmniejsza, ale przy tak małym zbiorze, nawet na moim leciwym laptopie całość trwa raptem kilkadziesiąt sekund 🙂
Wszystkie wyniki lądują do list:
acc_all
i
stab_all
. W pierwszej z nich zapisywana jest dokładność modelu z wybraną liczbą zmiennych, w drugiej natomiast jego stabilność.
acc_all = [] stab_all = [] for m in np.arange(0,20): stab_loop = [] acc_loop = [] for n in np.arange(5, 20, 1): selector = RFE(model, n, 1) cv = cross_val_score(model, x_tr.iloc[:,selector.fit(x_tr, y_tr).support_], y_tr, cv = 10, scoring = 'accuracy') acc_loop.append(cv.mean()) stab_loop.append(cv.std()*100/cv.mean()) acc_all.append(acc_loop) stab_all.append(stab_loop) acc = pd.DataFrame(acc_all, columns = np.arange(5, 20, 1)) stab = pd.DataFrame(stab_all, columns = np.arange(5, 20, 1)) print(acc.agg(['mean'])) print(stab.agg(['mean']))
Otrzymane wyniki po uśrednieniu prezentują się następująco:
Accuracy:
5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
mean | 0.857 | 0.853 | 0.859 | 0.860 | 0.859 | 0.864 | 0.862 | 0.860 | 0.857 | 0.860 | 0.861 | 0.858 | 0.858 | 0.856 | 0.856 |
Stabilność:
5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
mean | 5.948 | 6.193 | 6.044 | 6.153 | 5.658 | 5.994 | 6.085 | 5.447 | 5.507 | 5.498 | 5.560 | 5.968 | 5.409 | 5.825 | 5.324 |
Nazwy kolumn w obu tabelach odnoszą się do liczby zmiennych w modelu. Jeśli przyjrzymy się, to łatwo można dostrzec moment, w którym stabilność znacząco się poprawia. Dokładnie po dodaniu dziewiątej zmiennej stabilność znacząco wzrasta, a Accuracy maleje zaledwie o 0.1%. Decyduję się zatem na 9 zmiennych.
Model 4
Opis modelu:
- dobór zmiennych: RFE,
- dobór parametrów: GridsearchCV #2.
Mając ustalone zmienne zapewniające odpowiednią stabilność i wysoką dokładność, wykonuję dodatkową iterację poszukiwania optymalnych parametrów modelu. Najpierw jednak muszę uruchomić RFE i wybrać 9 najlepszych zmiennych.
selector = RFE(model, 9, 1) cols = x_tr.iloc[:,selector.fit(x_tr, y_tr).support_].columns print(cols)
Zmiennymi, których użyję w finalnym modelu, są:
- Lata_pracy,
- Poz_hist_kred,
- Umowa_o_pracę,
- Saldo_konta,
- Przychody,
- Stan_cywilny___l,
- Stan_cywilny___y,
- Pochodzenie___ff,
- Pochodzenie___h.
Jeśli wrócisz do punktu „Eksploracyjna analiza danych”, to zobaczysz, że część wskazanych tam zależności potwierdza się tutaj. Warto więc robić analizę zależności 🙂
Uruchamiam drugą wersję
GridSearchCV
, tym razem dla dziewięciu wybranych zmiennych.
classifier2 = GridSearchCV(DecisionTreeClassifier(), parameters, cv=10) classifier2.fit(x_tr[cols], y_tr) print(classifier2.best_params_)
Finalnie wybrane parametry to:
- criterion: gini,
- max_depth: 5,
- min_samples_leaf: 1,
- min_samples_split: 3,
- splitter: random.
Sprawdźmy jeszcze najwyższy wynik, jakiś osiągnął model podczas doboru parametrów.
cv = cross_val_score(DecisionTreeClassifier(**classifier2.best_params_), x_tr[cols], y_tr, cv = 10, scoring = 'accuracy') print('Średnie Accuracy: ' + str(cv.mean().round(3))) print('Stabilność: ' + str((cv.std()*100/cv.mean()).round(3)) + '%')
Średnie Accuracy: 0.871
Stabilność: 6.714%
Najlepsze wynik osiągnięty przez model o wybranych parametrach: 0.871. Jest bardzo dobrze 🙂 teraz wystarczy „dowieźć” ten wynik na zbiorze testowym.
Finalna weryfikacja jakości modelu
Wykonuję test modelu o wybranych parametrach, dla wybranego zestawu zmiennych.
model = DecisionTreeClassifier(**classifier2.best_params_) model.fit(x_tr[cols], y_tr) pred = model.predict(x_te[cols]) print('Finalny wynik to: ' + str(round(accuracy_score(pred, y_te),3)))
Finalny wynik to: 0.901. Jest zatem pełen sukces 🙂 Udało mi się:
- uzyskać rezultat lepszy niż początkowo zakładałem: 80%-90%,
- zbudowałem bardzo dokładny model, przy użyciu zaledwie 9 zmiennych,
- uzyskać całkiem rozsądną głębokość drzewa: 5.
Niestety, ale nie osiągnąłem satysfakcjonującej stabilności modelu. Stabilność na poziomie ok. 6.7% jest nie do przyjęcia. Taki model raczej nie powinien być wdrożony produkcyjnie. Nie jestem do końca pewien, czy to specyfika danych, czy też algorytmu, który w połączeniu z danymi nie działa najlepiej. By to sprawdzić, należałoby przetestować inny algorytm (najlepiej jakiś liniowy), np. regresję logistyczną i porównać uzyskane wyniki.
Wizualizacja i interpretacja wyników
Udało się uzyskać całkiem fajny rezultat, ale jak interpretować predykcje wyznaczone przez model? Otóż jest na to bardzo prosty sposób. Aby to zrobić, wystarczy posłużyć się schematem drzewa, które można wygenerować z użyciem biblioteki 'sklearn’.
dot_data = tree.export_graphviz(model, out_file=None, feature_names=cols, class_names=['0','1'], filled=True, rounded=True, special_characters=True) graph = graphviz.Source(dot_data)
Teraz zrobię mały trik i zmienię wielkość wyjściowej grafiki, zmieniając strukturę zmiennej
dot_data
.
out = dot_data[0:14] + 'ranksep=.75; size = "20,30";' + dot_data[14:]
Wyświetlam teraz gotowy schemat drzewa.
graph = graphviz.Source(out) graph.format = 'png' graph.render('dtree_render',view=True)
Mając wizualizację drzewa, spróbujmy zatem teraz zinterpretować działanie algorytmu dla trzech przykładowych klientów banku. Losuję zatem ze zbioru testowego trzy osoby. By się nie pogubić w wynikach, posortuję ich po indeksie.
samples = x_te[cols].sample(3).sort_index() print(samples)
Lata_pracy | Poz_hist_kred | Umowa_o_pracę | Saldo_konta | Przychody | Stan_cywilny___l | Stan_cywilny___y | Pochodzenie___ff | Pochodzenie___h | |
---|---|---|---|---|---|---|---|---|---|
256 | 2.000 | 0 | 0 | 136.000 | 0 | 0 | 0 | 0 | 0 |
435 | 0.000 | 0 | 1 | 45.000 | 1 | 0 | 1 | 1 | 0 |
454 | 3.750 | 0 | 0 | 0.000 | 350 | 0 | 0 | 0 | 0 |
Teraz sprawdźmy, czy ich wnioski zostały zaakceptowane, czy też odrzucone.
print(y_te[y_te.index.isin(samples.index)].sort_index())
- 256: 0
- 435: 0
- 454: 0
Wszyscy trzej nie dostali karty kredytowej. Teraz sprawdźmy, jak sklasyfikował ich model.
print(model.predict(samples))
- 256: 0
- 435: 0
- 454: 0
W tym przypadku mamy zatem 100% skuteczność. Dla tych trzech klientów banku proces decyzyjny oparty o drzewo, wyglądałby następująco:
Podsumowanie
Dziękuję Ci za dobrnięcie do samego końca! Mam nadzieję, że projekt Ci się spodobał 🙂 Jeśli masz jakiekolwiek uwagi, pytania, lub spostrzeżenia, to proszę, podziel się nimi w komentarzu na social media, lub skontaktuj się ze mną.
Poniżej znajduje się lista plików użytych, wspomnianych, bądź też utworzonych w projekcie:
- Zbiór danych z którego korzystałem.
- Dokładny opis zbioru.
- Moje pozostałe projekty.
- Strona z której zaczerpnąłem nazwy zmiennych.
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 :-)
Cześć. Możesz mi napisać dlaczego do oceny problemu klasyfikacyjnego nie wykorzystujesz miar oceny jakości modelu takich jak tablica trafień (dla zbioru testowego oraz treningowego), Accuracy oraz Balanced Accuracy, Specyfity, Sensitivity? Możesz mi jeszcze napisać co rozumiesz przez stabilność modelu oraz jego interpretowalność? Tzn jak mam rozpoznać, że model jest stabilny? Może chodzi ci o generalizowanie przez model? Nie lepiej w tym przypadku sprawdzić jakie otrzymuje balanced accuracy na zbiorze testowym i treningowym porównując je ze sobą? Dzięki
Cześć Paweł 🙂 Dziękuję za komentarz. Poniżej odpowiedzi na Twoje pytania:
1. „(…)dlaczego do oceny problemu klasyfikacyjnego nie wykorzystujesz miar oceny jakości modelu takich jak tablica trafień” – W tym przypadku wykorzystuję jedynie Accuracy z kilku powodów: zbiór posiada w miarę równy podział na obie klasy (55%:45%), nie jest to typowy problem skoringu kredytowego, więc nie oceniam modelu poprzez miarę Gini. Na potrzeby tak małego projektu dwie miary uważam za w zupełności wystarczające: Accuracy jako główna miara jakości modelu i współczynnik zmienności jako miara jego stabilności.
2. „Możesz mi jeszcze napisać co rozumiesz przez stabilność modelu oraz jego interpretowalność? Tzn jak mam rozpoznać, że model jest stabilny? Może chodzi ci o generalizowanie przez model?” – Tak, sprowadza się to do sprawdzenia jak model generalizuje i weryfikacji stabilności jego działania w różnych przypadkach, na różnych zbiorach. Im większa rozbieżność w wynikach działania modelu, dla różnych zbiorów, tym mniej stabilny jest model. Stabilność mierzę poprzez współczynnik zmienności podczas wykonywania walidacji krzyżowej. Model jest bardziej stabilny, jeśli współczynnik zmienności jest mniejszy.
3. „Nie lepiej w tym przypadku sprawdzić jakie otrzymuje balanced accuracy na zbiorze testowym i treningowym porównując je ze sobą?” – Uważam, że w tym przypadku walidacja krzyżowa daje więcej informacji o jakości modelu i jego stabilności. Porównując wynik uzyskany na zbiorze testowym i treningowych sprawdzasz jedynie dwa zbiory, podczasz gdy w przypadku walidacji krzyżowej weryfikujesz działanie modelu na n-zbiorach. W ten sposób jesteś w stanie obliczyć odchylenie standardowe i średni wynik jaki uzyskuje model.
Mam nadzieję, że zaadresowałem wszystkie Twoje wątpliwości. Jeśli nie, to daj proszę znać 🙂
Cześć,
dzięki za wpis – choć kilka lat już minęło. 🙂 Jaki jest – wg stosowanych przez Ciebie kryteriów – akceptowalny poziom współczynnika zmienności dla CV? 5%?
Hej Adam, nie mam na to reguły. Uważam, że należy to oceniać „case-by-case”. Pamiętam zbiory w których zmienność była bardzo duża – po prostu żaden algorytm nie był w stanie dobrze generalizować objaśnianego zjawiska. Mogliśmy pomarzyć o współczynniku zmienności w CV=5%, niezależnie od użytego algorytmu, a model musiał powstać. 😉
Z drugiej strony, gdy osiągamy współczynnik zmienności w CV=20%, to warto się zastanowić, czy jesteśmy w stanie dodać nowe dane, zmienne, zmienić zakres danych, przeformułować definicję zmiennej celu, ponownie przeprowadzić selekcję zmiennych, ew. przetestować inne metody modelowania lub nawet zupełnie zmienić strategię walidacyjną, bo coś ewidentnie nie gra. Pomysłów jak widać jest sporo.
W sytuacji, gdy model MUSI zostać wdrożony/odświeżony (np. ubezpieczenia, bankowość), dobrym tropem może być również poszukiwanie stabilności kosztem jakości. Redukujemy liczbę zmiennych i weryfikujemy, czy przy spadku np. MSE, wzrasta stabilność.
Mateusz, pytanie o wizualizację w graphviz. Wystarczy pobrać ten soft na dysk a potem odwołać się do niego w imporcie? Mam jakiś problem z tym aby zadziałał, próbowałem jeszcze z pydot ale też konsola błąd mi zwraca.
Hej Adam. Było to już dawno temu, ale z tego co pamiętam, to proces instalacji graphviz w zależności od systemu operacyjnego znacząco się różnił. Na Linux wystarczyło bodajże `!pip install graphviz`. Na Windows głowiłem się kilkadziesiąt minut i chyba skończyło się na ręcznym dodawaniu ścieżki systemowej. Jeśli korzystasz z Windows i jest to jednorazowe użycie graphviz, to zachęcam Cię do przetestowania notebook-ów na Azure (https://notebooks.azure.com), które w podstawowej wersji są darmowe. Gdy mam u siebie problemy ze środowiskiem, to sięgam po Azure 🙂
Zadziałało jak bezpośrednio przez Anacondę zainstalowałem, mimo że ścieżka z pipem jest w większości wspólna, te środowiska się gryzą więc jak ktoś ma Anacondę i Pythona oddzielnie to powinien w systemie Anaconda to instalować i zadziała.
Rozumiem, że Notebook Azure nie robi problemu z dostępnością do bibliotek? Ja zbudowałem swoje wirtualne środowisko więc powinno być stabilne
Z Notebookami na Azure nie miałem problemów. Dziękuję za podpowiedź. Jak będę mieć na Windows problemy z instalacją graphviz, to będę pamiętać, że instalacja poprzez Anacondę jest rozwiązaniem 🙂
Cześć, dlaczego przy walidacji krzyżowej i tworzeniu obiektu danego modelu nie przekazujesz parametru „random state”? Czy wtedy porównywanie różnych wariantów będzie ok ? 🙂
Cześć Damian! Dziękuję za komentarz i słuszną uwagę. 🙂 Jeśli oczekujemy replikacji wyników, to należy korzystać z parametru `random_state` w samym algorytmie ML, jak i w CV. Korzystając z okazji i uzupełniając temat dodam, że `random_state` nie jest dostępny w samej funkcji `cross_val_score`. Można to obejść w następujący sposób, tworząc „obok” obiekt do walidacji krzyżowej.
„`
model = DecisionTreeClassifier(random_state=42)
skf = StratifiedKFold(y, random_state=42)
cvs = cross_val_score(model, X, y, scoring=’roc_auc’, cv=skf)
„`
Cześć,
Świetny materiał! Wszystko bardzo prosto wytłumaczone, za co serdecznie dziękuję! 🙂
Gdzie znajdę uzupełniające skrypty, o których wspominasz w poście? Niestety, ale link do github’a nie działa 🙁
Cześć Rober! Dziękuję za komentarz. Niestety nie mam już tego kodu, a GitHuba usunąłem. 🙁 Wydaje mi się, że w skrypcie nie było nic, czego nie ma powyżej, więc całość można spokojnie odtworzyć. 🙂