Ostatni, najbardziej zaawansowany z algorytmów iteracyjno-optymalizacyjnych, który opisuję na blogu. K-prototypów, bo o nim mowa pozwala na grupowanie mieszanego zbioru składającego się ze zmiennych kategorycznych i ciągłych.
Na blogu opisywałem już algorytmy, które znakomicie radzą sobie ze zmiennymi ciągłymi (k-średnich, k-median, oraz zmiennymi dyskretnymi (k-modes). Problem pojawiał się ze zbiorem posiadającym co najmniej dwa typy zmiennych: dyskretne i ciągłe. W pewnych sytuacjach, np. jak to słusznie zauważył jeden z czytelników - Adam, w komentarzu pod wpisem o k-median - owego algorytmu można użyć dla "zmiennych nienumerycznych, ale w skali porządkowej (np. wykształcenie podstawowe-średnie-wyższe)". Co zrobić jednak w przypadku zmiennych dyskretnych? Z pomocą przychodzi algorytm k-prototypów. 🙂 Zaczynamy!
Opis algorytmu¶
K-prototypes - najważniejsze informacje:
- Jest on zaliczany do metod iteracyjno-optymalizacyjnych ze względu na schemat działania.
- W kolejnych iteracjach wykonywana jest optymalizacja wyniku działania algorytmu przedstawionego w postaci odległości wszystkich obserwacji danej grupy względem jej "prototypu".
- Nazwa k-prototypes odnosi się do k prototypów będących reprezentantami tendencji centralnej danej grupy. Prototyp jest obiektem będącym reprezentantem danej grupy obserwacji. Jest odpowiednikiem centroidu z algorytmu k-średnich i mody z algorytmu k-modes.
- Zgodnie z powyższym punktem, algorytm ten jest połączeniem k-średnich i k-modes.
- Zamiast dystansu (jak w k-średnich) używa on miary odmienności, będącej zmodyfikowaną wersją miary odmienności opisywanej przeze mnie przy okazji [algorytmu k-modes].
- Miara odmienności (więcej o niej w akapicie poniżej) jest połączeniem odległości z k-średnich i miary odmienności k-modes.
- Algorytm dąży do minimalizacji wariancji wewnątrz grup i jej maksymalizacji pomiędzy grupami.
- Dla zmiennych ciągłych algorytm bazuje na centroid.
- Dla zmiennych kategorycznych algorytm bazuje na częstościach występowania kategorii.
- Im mniejsza jej wartość, tym większe podobieństwo pomiędzy obserwacjami. Miara odmienności jest przedstawiona jako suma niedopasowań poszczególnych zmiennych kategorycznych pomiędzy obserwacjami.
- Implementacja algorytmu w Python jest dostępna w bibliotece KModes.
Wyznaczanie miary odmienności.
- Jest ona sumą odmienności dwóch obserwacji (obserwacja nr 1 będąca prototypem i obserwacja nr 2 należąca do tego samego segmentu).
- Miara odmienności jest sumą dwóch wartości:
- dystans dla zmiennych ciągłych. Jest to odległość euklidesowa tak jak w k-średnich. $$s^r$$
- miara odmienności dla zmiennych kategorycznych. Jest zdefiniowanych jako liczba niedopasowań kategorii między dwoma obiektami. Parametr y został dodany, by równoważyć wpływ obu miar (dla zmiennych ciągłych i kategorycznych) na końcowy wynik, czyli finalną miarę odmienności: $$y*s^c$$
- Finalny wzór na miarę odmienności to: $$S = s^r + y*s^c$$.
Schemat działania k-prototypów.
- Losowanie k-prototypów (gdzie k, to liczba segmentów, a prototyp, to środek segmentu, czyli najbardziej typowa obserwacja, która początkowo jest "kiepsko" dobrana) startowych w przestrzeni.
- Przyporządkowanie wszystkich obserwacji do najbliższego prototypu z pomocą miary odmienności (ang. dissimilarity measure).
- Dla każdej z k-grup wyznaczamy nowy prototyp, będącym reprezentantem segmentu.
- Powtarzamy krok 2 i 3 (obserwacje migrują pomiędzy segmentami, optymalizując (zmniejszając miarę odmienności dla obserwacji wewnątrz segmentu) aż do osiągnięcia warunku stopu, którym może być:
- Osiągnięcie zbieżności, ew. „znacznej” poprawy względem wybranej miary jakości grupowania.
- Osiągnięcie momentu, w którym przydział obserwacji do grup się nie zmienia.
- Osiągnięcie zakładanej liczby iteracji.
Wady i zalety algorytmu¶
Stosując algorytm k-median warto mieć również na uwadze zarówno wszystkie jego ograniczenia, jak i mocne strony.
Wady:
- Wymaga ustalenia liczby grup – zanim uruchomimy algorytm, musimy a priori podać liczbę grup, które mają zostać wyznaczone. Bez uprzedniego wizualizowania zbioru lub wykonania dodatkowych analiz jest to dosyć trudne.
- Wrażliwy na dobór punktów startowych – w pierwszej iteracji swojego działania algorytm losowo dobiera punkty startowe (ew. możesz je ręcznie zdefiniować). To jak dobre wyniki uzyska, zależy zatem w pewnym stopniu od czynnika losowego.
Zalety:
- Dosyć szybki – wynika to bezpośrednio ze sposobu jego działania. Niższa złożoność obliczeniowa sprawia, że w porównaniu np. z grupowaniem aglomeracyjnym, algorytm k-modes działa błyskawicznie. Wielkość zbioru przestaje więc być tak dużym problemem.
- Wspiera zmienne kategoryczne - bez konieczności jakichkolwiek transformacji.
- Wspiera zmienne ciągłe – algorytm wspiera zmienne numeryczne. W k-modes bylibyśmy zmuszeni do przeprowadzenia kategoryzacji zmiennych ciągłych. Tu nie ma takiej konieczności. Zmienne ciągłe są wspierane "out of the box". 😉
- Działa pomimo brakujących wartości - braki są uzupełniane automatycznie i tworzona jest z nich odrębna kategoria.
Wprowadzenie teoretyczne do algorytmów iteracyjno-optymalizacyjnych.
Przykład użycia¶
By pokazać działanie algorytmu, posłużę się biblioteką KModes. Poniżej kilka istotnych informacji na jej temat:
- Posiada ona ten sam styl budowania modeli, co scikit-learn. Jest zatem intuicyjna dla wszystkich użytkowników sklearna.
- Brakujące dane są uzupełniane automatycznie i traktowane jako odrębna kategoria (braki powinny być ujęte jako np.NaN) - jest to ułatwienie, choć nawet sam autor zaleca, by w większości przypadków lepiej zdecydować się na ręczne uzupełnianie braków zgodne z np. wiedzą biznesową.
- Implementacja algorytmu wspiera równoległe przetwarzanie procesów w ramach jednej maszyny (względem procesorów).
- Zmienne kategoryczne powinny być stringami. W bibliotece kmodes algorytm korzysta ze zmiany kodowania LabelEncoding, która na wejściu powinna mieć tekst. W bibliotece Pandas sprowadzi się to do tego, że zmienna (po wykonaniu metody abt.dtypes) powinna być widoczna jako Object.
- Jeśli w ABT umieścimy same zmienne ciągłe, to algorytm nie wykona się tak, jak byśmy chcieli. Zostanie wyświetlony komunikat sugerujący zmianę na k-średnich.
- Jeśli w ABT umieścimy same zmienne nominalne/kategoryczne, to algorytm nie wykona się tak jak byśmy chcieli. Zostanie wyświetlony komunikat sugerujący zmianę na k-modes.
- Algorytm wymaga ustalenia liczby grup – zanim uruchomimy algorytm, musimy a priori podać liczbę grup, które mają zostać wyznaczone. Bez uprzedniego wizualizowania zbioru lub wykonania dodatkowych analiz jest to dosyć trudne.
- Algorytm jest wrażliwy na dobór punktów startowych – w pierwszej iteracji swojego działania algorytm losowo dobiera punkty startowe. To jak dobre wyniki uzyska, zależy zatem w pewnym stopniu od czynnika losowego.
- Rozwiązaniem jest tu wykonywanie algorytmu kilka razy z różnymi punktami startowymi. Na końcu wybrany zostaje ten model, który dał najlepszy wynik (najmniejsza wartość zadanej statystyki). Nie trzeba tego robić w sposób ręczny - biblioteka kmodes daje taką możliwość z użyciem parametru przy wywoływaniu modelu (parametr n_init=liczba_wywolan przy inicjowaniu nowego obiektu klasy KPrototypes).
- Algorytm jest wrażliwy na wpływ obserwacji odstających i szum. Do wyznaczenia przeciętnej obserwacji używana jest wartość średnia współrzędnych wszystkich obserwacji danej grupy.
- K-prototypes jest kombinacją algorytmów k-średnich i k-modes. Należy zatem pamiętać o założeniach k-średnich. Dobrą praktyką jest wykonanie kilku operacji na zmiennych ciągłych:
- Usunięcie ujemnych wartości (na potrzeby transformacji logarytmicznej).
- Usunięcie skośności zmiennych (transformacja logarytmiczna).
- Centrowanie i skalowanie zmiennych (przesunięcie się o 1 w zmiennej_1 waży tyle, co 1 w zmiennej_2).
Główne informacje o zbiorze użytym w przykładzie:
- Default of Credit Card Clients Data Set – UCI.
- Autor: I-Cheng Yeh.
- Dodano w 2016.01.26.
- 24 zmienne, 30 000 obserwacji.
Opis zmiennych,których użyję:
- X1: Suma_kredytow / Limit_na_karcie (danej osoby i najbliższej rodziny).
- X2: Plec (1 = mężczyzna; 2 = kobieta).
- X4: Stan_cywilny (1 = w_zwiazku; 2 = kawaler_panna; 3 = inny).
- X5: Wiek (podany w latach).
1. Wczytuję kilka niezbędnych bibliotek.¶
import numpy as np
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt
from kmodes.kprototypes import KPrototypes
from sklearn.preprocessing import StandardScaler
2. Wczytuję zbiór.¶
ef = pd.ExcelFile('data/credit_card_default.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)
3. Zamieniam wartości jakie przyjmują poszczególne zmienne.¶
df.plec.replace([1,2], ['kobieta', 'mezczyzna'], inplace = True)
df.stan_cywilny.replace([0, 1, 2, 3], ['nieznany', 'w_zwiazku', 'kawaler_panna', 'inny'], inplace = True)
4. Ograczam zbiór do czterech zmiennych, którymi się posłużę.¶
df = df[['lim_kredytu', 'plec', 'stan_cywilny', 'wiek']]
df.head()
5. Usuwam obserwacje odstające, standaryzuję zmienne numeryczne i usuwam skośność.¶
Sprawdzam, czy istnieją odstające obserwacje.
q1 = df.quantile(0.25)
q3 = df.quantile(0.75)
iqr = q3 - q1
low_boundary = (q1 - 1.5 * iqr)
upp_boundary = (q3 + 1.5 * iqr)
num_of_outliers_L = (df[iqr.index] < low_boundary).sum()
num_of_outliers_U = (df[iqr.index] > upp_boundary).sum()
outliers = pd.DataFrame({'lower_boundary':low_boundary, 'upper_boundary':upp_boundary,'num_of_outliers__lower_boundary':num_of_outliers_L, 'num_of_outliers__upper_boundary':num_of_outliers_U})
outliers
Usuwam odstające obserwacje.
for row in outliers.iterrows():
df = df[(df[row[0]] >= row[1]['lower_boundary']) & (df[row[0]] <= row[1]['upper_boundary'])]
Usuwam skośność zmiennych.
df_modified = df.copy()
df_modified = df_modified.assign(wiek = np.log(df_modified.wiek))
df_modified = df_modified.assign(lim_kredytu = np.log(df_modified.lim_kredytu))
Wykonuje standaryzację zmiennych.
scaler = StandardScaler()
scaler.fit(df_modified.wiek.values.reshape(-1, 1))
df_modified = df_modified.assign(wiek = scaler.transform(df_modified.wiek.values.reshape(-1, 1)))
scaler.fit(df_modified.lim_kredytu.values.reshape(-1, 1))
df_modified = df_modified.assign(lim_kredytu = scaler.transform(df_modified.lim_kredytu.values.reshape(-1, 1)))
Podgląd zbioru.
df_modified.head()
6. Sprawdzam na ile grup podzielić zbiór.¶
df_sample = df_modified.sample(frac=0.2)
res = []
for n in range(1, 21):
kp = KPrototypes(n_clusters=n, init='Huang', n_init=3, n_jobs=4)
kp.fit_predict(df_sample, categorical=[1, 2])
res.append([n, kp.cost_])
res = pd.DataFrame(res, columns=[0, 'wspolcz_odm']).set_index(0)
plt.figure(figsize=(10,7))
sns.set(font_scale=1.4, style="whitegrid")
sns.lineplot(data = res, palette = ['#eb6c6a']).set(title = "Miara odmienności grup vs liczba grup")
plt.show()
Powyższy wykres wskazuje, że "łokieć" znajduje się dokładnie przy 4 grupach. To o czym należy pamiętać, to by nie zdawać się tylko i wyłącznie na powyższe wyniki. Analiza statystyk uzyskanych grup jest o wiele ważniejsza niż wiedza płynąca z wykresu łokcia, który jest jedynie punktem wyjścia dla poszukiwań. Przeprowadziłem "offline" analizę statystyczną dla segmentacji z użyciem 3, 4, 5, 6 i 7 segmentów. Metodą prób i błędów zdecydowałem się na 6 segmentów.
7. Przeprowadzam grupowanie.¶
kp = KPrototypes(n_clusters=6, init='Huang', n_init=5, n_jobs=4)
clusters = kp.fit_predict(df_modified, categorical=[1, 2])
kp.cluster_centroids_
W powyższym przykładzie centroidy, to prototypy, a więc "sztuczne" obserwacje reprezentujące tendencję centralna grup.
8. Przygotowuję dane do końcowej analizy.¶
df = df.assign(segment = clusters)
df.segment = df.segment.astype(str)
9. Analiza wyników grupowania.¶
Analiza całego zbioru.
Prześledzę rozkłady całego zbioru, tak by mieć punkt odniesienia dla poszczególnych grup.
for column in ['plec', 'stan_cywilny']:
print((df[column].value_counts(normalize = True) * 100).round(2))
print('')
df.describe().transpose()
Segment 0.
segment_0 = df[df.segment == "0"]
for column in ['plec', 'stan_cywilny']:
print((segment_0[column].value_counts(normalize = True) * 100).round(2))
print('')
segment_0.describe().transpose()
Charakterystyka segmentu:
- Zmienna "plec" - zdecydowana przewaga mężczyzn.
- Zmienna "stan_cywilny" - przewaga osób w stałych związkach.
- Zmienna "lim_kredytu" - powyżej średniej i mediany dla całego zbioru.
- Zmienna "wiek" - nieco poniżej średniej i mediany.
Segment 0 to głównie młodzi mężczyźni będący w stałych związkach i o ponad przeciętnym limicie kredytu.
Segment 1.
segment_1 = df[df.segment == "1"]
for column in ['plec', 'stan_cywilny']:
print((segment_1[column].value_counts(normalize = True) * 100).round(2))
print('')
segment_1.describe().transpose()
Charakterystyka segmentu:
- Zmienna "plec" - rozkład niemal identyczny z rozkładem dla całego zbioru. Nie można mówić zatem o zdecydowanej dyskryminacji grupy ze względu na którąkolwiek wartość.
- Zmienna "stan_cywilny" - przewaga osób w stałych związkach.
- Zmienna "lim_kredytu" - znacznie powyżej średniej i mediany dla całego zbioru.
- Zmienna "wiek" - znacznie powyżej średniej i mediany (34 vs 48 lat).
Segment 1 to starsi klienci banku, będący w stałych związkach i o ponad przeciętnym limicie kredytu.
Segment 2.
segment_2 = df[df.segment == "2"]
for column in ['plec', 'stan_cywilny']:
print((segment_2[column].value_counts(normalize = True) * 100).round(2))
print('')
segment_2.describe().transpose()
Charakterystyka segmentu:
- Zmienna "plec" - przewaga mężczyzn.
- Zmienna "stan_cywilny" - niemal sami kawalerowie/panny.
- Zmienna "lim_kredytu" - powyżej średniej i mediany dla całego zbioru.
- Zmienna "wiek" - znacznie poniżej średniej i mediany.
Segment 2 to młodzi klienci banku, niebędący w stałych związkach i o ponad przeciętnym limicie kredytu.
Segment 3.
segment_3 = df[df.segment == "3"]
for column in ['plec', 'stan_cywilny']:
print((segment_3[column].value_counts(normalize = True) * 100).round(2))
print('')
segment_3.describe().transpose()
Charakterystyka segmentu:
- Zmienna "plec" - mając na uwadze rozkład zmiennej dla całego zbioru, możemy mówić tu po raz pierwszy o znaczącej przewadze kobiet.
- Zmienna "stan_cywilny" - przewaga osób w stałych związkach.
- Zmienna "lim_kredytu" - znacznie poniżej średniej i mediany (140k vs 50k) dla całego zbioru.
- Zmienna "wiek" - znacznie powyżej średniej i mediany.
Segment 3 to głównie starsze kobiety (mając na uwadze statystyki całego zbioru), będące w stałych związkach i o niskim limicie kredytu.
Segment 4.
segment_4 = df[df.segment == "4"]
for column in ['plec', 'stan_cywilny']:
print((segment_4[column].value_counts(normalize = True) * 100).round(2))
print('')
segment_4.describe().transpose()
Charakterystyka segmentu:
- Zmienna "plec" - jeszcze większa przewaga kobiet niż w przypadku segmentu 3.
- Zmienna "stan_cywilny" - przewaga osób niebędących w stałych związkach.
- Zmienna "lim_kredytu" - bardzo niski (mediana 140k vs 30k).
- Zmienna "wiek" - bardzo niski (mediana 34 vs 25 lat).
Segment 4 to młode panny o bardzo niskim limicie kredytu.
Segment 5.
segment_5 = df[df.segment == "5"]
for column in ['plec', 'stan_cywilny']:
print((segment_5[column].value_counts(normalize = True) * 100).round(2))
print('')
segment_5.describe().transpose()
Charakterystyka segmentu:
- Zmienna "plec" - zdecydowana przewaga mężczyzn.
- Zmienna "stan_cywilny" - przewaga osób niebędących w stałych związkach.
- Zmienna "lim_kredytu" - poniżej średniej i mediany dla całego zbioru.
- Zmienna "wiek" - znacznie poniżej średniej i mediany.
Segment 5 to młodzi kawalerowie o niskim limicie kredytu (męska wersja segmentu 4).
9. Pogrupowane wyniki segmentacji.¶
Zmienna "plec".
((df.groupby(['plec', 'segment'])['segment'].count().unstack().fillna(0)/df['segment'].value_counts())*100).round(2)
Widoczny rozkład pomiędzy kobiety i mężczyzn w poszczególnych segmentach.
Zmienna "stan_cywilny".
((df.groupby(['stan_cywilny', 'segment'])['segment'].count().unstack().fillna(0)/df['segment'].value_counts())*100).round(2)
Podsumowanie¶
To już ostatni wpis opisujący algorytmy iteracyjno-optymalizacyjne. Mam nadzieję, że zawarte w nim informację okażą się dla Ciebie użyteczne. 🙂
Jeśli masz jakieś pytania, to proszę, podziel się nimi w komentarzu pod wpisem. Na wszystkie postaram się odpowiedzieć. Zapraszam do dyskusji. 🙂
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 :-)
Dodaj komentarz