K-modes – grupowanie zmiennych kategorycznych

k-modes, data science, segmentacja, grupowanie, k-średnich

Gdy zbiór zawiera zmienne numeryczne mamy do wyboru całą gamę algorytmów grupujących. Całość się nieco komplikuje, gdy w grę wchodzą zmienne kategoryczne. W tej sytuacji rozwiązaniem może być algorytm k-modes. 

Z tego artykułu dowiesz się: 1. Jak działa algorytm k-modes? 2. Jakie ma wady, a jakie zalety? 3. Kiedy go stosować?

Podobnie jak w przypadku algorytmu k-median postanowiłem nie rozdzielać części teoretycznej od praktycznej. Przekładam praktykę ponad teorię, dlatego dosłownie w dwóch akapitach przedstawię najważniejsze informacje, o których należy wiedzieć przed użyciem k-modes w praktyce. Po nich przejdę do części praktycznej z użyciem przykładowego zbioru i biblioteki KModes. Zaczynamy! 🙂

Opis algorytmu

K-modes - najważniejsze informacje:

  • Autor: Joshua Zhexue Huang.
  • Rok publikacji: 1997.
  • Mało popularny, niewiele informacji dostępnych w sieci.
  • Jest on rozszerzeniem k-średnich dla zmiennych kategorycznych.
  • Ang. mode (moda/dominanta/wartość modalna/wartość najczęstsza) - użyta jest jako reprezentant tendencji centralnej danej grupy. Jest to obiekt będący reprezentantem danej grupy obserwacji. Jest odpowiednikiem centroidu z algorytmu k-średnich.
  • Zamiast dystansu (jak w k-średnich) używa on miary odmienności. 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.
  • Istnieją również inne algorytmy grupujące, które wspierają zmienne kategoryczne, lecz nie są one powszechnie używane.
  • Rozwinięciem algorytmu dla zmiennych kategorycznych jest fuzzy k-modes. Zamiast przypisywać obserwację do jednej grupy, obliczane są wartości poziomów przynależności (patrz moje wpisy dotyczące zbiorów rozmytych) danej obserwacji do wszystkich grup.
  • Rozwinięciem algorytmu dla mieszanych zmiennych kategorycznych i numerycznych jest fuzzy k-prototypes.

Być może zadajesz sobie pytanie: czy możemy wykonać grupowanie zmiennych dyskretnych z użyciem k-średnich? W ogólnym przypadku powinno się tego unikać. Transformacje zmiennych kategorycznych poprzez label encoding i użycie k-średnich jest bardzo złym pomysłem. Algorytm k-średnich używa odległości wyrażonych za pomocą wartości numerycznych, a dystans pomiędzy kat_a i kat_b wcale nie musi być równy dystansowi dzielącemu kat_b i kat_c.

K-średnich w przeszłości był używany do segmentacji zmiennych kategorycznych. Często proponowano by zmienne kategoryczne transformować na zmienne dummy (0,1). Jest to "mniejsze zło" niż użycie label encodingu. Największym problemem jest tu jednak wielowymiarowość danych.

Schemat działania algorytmu k-modes:

  1. Losowe wybranie k unikalnych obserwacji ze zbioru jako inicjalnych k-dominant.
  2. Przeliczenie miary odmienności pomiędzy dominantami a wszystkimi obserwacjami.
  3. Przypisanie obserwacji do najbardziej podobnej dominanty.
  4. Wybranie nowych k-dominant dla każdej grupy obserwacji. Jeżeli dominanty się nie zmieniły, to algorytm kończy działanie. W przeciwnym wypadku powtarzane są kroki 2, 3 i 4.

Warunki stopu:

  • Osiągnięcie minimum lokalnego, a więc momentu, w którym przydział obserwacji do grup się nie zmienia.
  • Osiągnięcie zakładanej liczby iteracji.

Kiedy go stosować?

Sprawdź również projekt, w którym wykonuję segmentację ze zbiorem zawierającym zmienne dyskretne.

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.
  • Używa jedynie zmiennych kategorycznych – algorytm nie wspiera zmiennych numerycznych. Istnieje oczywiście możliwość przeprowadzenia kategoryzacji zmiennych ciągłych (co wcale nie jest takim złym pomysłem i potrafi dać całkiem ciekawe rezultaty - będę o tym pisać w jednym z kolejnych wpisów), lub użyć algorytmu k-prototypes.

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 - jakie pierwszy z opisywanych przeze mnie na blogu algorytmów grupujących wspiera zmienne kategoryczne bez konieczności jakichkolwiek transformacji.
  • 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 informacj 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ą.

Główne informacje o zbiorze użytym w przykładzie:

Opis zmiennych,których użyję:

  • X2: Plec (1 = mężczyzna; 2 = kobieta).
  • X3: Edukacja (1 = wyzsze_pelne, link_2; 2 = wyzsze; 3 = srednie; 4 = inne).
  • X4: Stan_cywilny (1 = w_zwiazku; 2 = kawaler_panna; 3 = inny).
1. Wczytuję kilka niezbędnych bibliotek.
In [28]:
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt
from kmodes.kmodes import KModes
2. Wczytuję zbiór.
In [24]:
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.
In [25]:
df.plec.replace([1,2], ['kobieta', 'mezczyzna'], inplace = True)
df.wyksztalcenie.replace([0, 1, 2, 3, 4, 5, 6], ['nieznane', 'wyzsze_pelne', 'wyzsze', 'srednie', 'inne', 'nieznane', 'nieznane'], inplace = True)
df.stan_cywilny.replace([0, 1, 2, 3], ['nieznany', 'w_zwiazku', 'kawaler_panna', 'inny'], inplace = True)
4. Ograczam zbiór do trzech zmiennych, którymi się posłużę.
In [26]:
df = df[['plec', 'wyksztalcenie', 'stan_cywilny']]
In [27]:
df.head()
Out[27]:
plec wyksztalcenie stan_cywilny
0 mezczyzna wyzsze w_zwiazku
1 mezczyzna wyzsze kawaler_panna
2 mezczyzna wyzsze kawaler_panna
3 mezczyzna wyzsze w_zwiazku
4 kobieta wyzsze w_zwiazku
5. Sprawdzam na ile grup podzielić zbiór.
In [33]:
res = []
for n in range(1, 20):
    km = KModes(n_clusters=n, init='Huang', n_init=30, n_jobs=4)
    km.fit_predict(df)
    res.append([n, km.cost_])
In [34]:
res = pd.DataFrame(res, columns=[0, 'wspolcz_odm']).set_index(0)
In [35]:
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()

Na podstawie wykresu i powyższej tabeli decyduję się na podział zbioru na 7 segmentów.

6. Przeprowadzam grupowanie.
In [39]:
km = KModes(n_clusters=7, init='Huang', n_init=30, n_jobs=4)
clusters = km.fit_predict(df)
7. Przygotowuję dane do końcowej analizy.
In [43]:
df = df.assign(segment = clusters)
In [52]:
df.segment = df.segment.astype(str)
8. Analiza wyników grupowania.

Analiza całego zbioru.

Prześledzę rozkłady całego zbioru, tak by mieć punkt odniesienia dla poszczególnych grup.

In [86]:
for column in ['plec', 'wyksztalcenie', 'stan_cywilny']:
    print((df[column].value_counts(normalize = True) * 100).round(2))
    print('')
mezczyzna    60.37
kobieta      39.63
Name: plec, dtype: float64

wyzsze          46.77
wyzsze_pelne    35.28
srednie         16.39
nieznane         1.15
inne             0.41
Name: wyksztalcenie, dtype: float64

kawaler_panna    53.21
w_zwiazku        45.53
inny              1.08
nieznany          0.18
Name: stan_cywilny, dtype: float64

Segment 0.

In [87]:
segment_0 = df[df.segment == "0"]
In [88]:
for column in ['plec', 'wyksztalcenie', 'stan_cywilny']:
    print((segment_0[column].value_counts(normalize = True) * 100).round(2))
    print('')
mezczyzna    73.79
kobieta      26.21
Name: plec, dtype: float64

srednie     96.22
nieznane     2.92
inne         0.86
Name: wyksztalcenie, dtype: float64

w_zwiazku        72.06
kawaler_panna    24.28
inny              2.61
nieznany          1.05
Name: stan_cywilny, dtype: float64

Charakterystyka segmentu:

  • Zmienna "plec" - mieszanka kobiet i mężczyzn. Brak wyraźnej dyskryminacji wg tej zmiennej.
  • Zmienna "wyksztalcenie" - zdecydowana przewaga osób o średnim wykształceniu (najniższe z deklarowanych).
  • Zmienna "stan_cywilny" - przewaga osób będących w związku.

Segment 0 to klienci banku będący w stałych związkach, o średnim wykształceniu.

Segment 1.

In [89]:
segment_1 = df[df.segment == "1"]
In [90]:
for column in ['plec', 'wyksztalcenie', 'stan_cywilny']:
    print((segment_1[column].value_counts(normalize = True) * 100).round(2))
    print('')
mezczyzna    54.42
kobieta      45.58
Name: plec, dtype: float64

wyzsze_pelne    100.0
Name: wyksztalcenie, dtype: float64

w_zwiazku    98.57
inny          1.32
nieznany      0.11
Name: stan_cywilny, dtype: float64

Charakterystyka segmentu:

  • Zmienna "plec" - mieszanka kobiet i mężczyzn. Brak wyraźnej dyskryminacji wg tej zmiennej.
  • Zmienna "wyksztalcenie" - wyłącznie osoby o wykształceniu wyższym, pełnym.
  • Zmienna "stan_cywilny" - przewaga osób będących w związku.

Segment 1 to klienci banku będący w stałych związkach, o wyższym, pełnym wykształceniu.

Segment 2.

In [91]:
segment_2 = df[df.segment == "2"]
In [92]:
for column in ['plec', 'wyksztalcenie', 'stan_cywilny']:
    print((segment_2[column].value_counts(normalize = True) * 100).round(2))
    print('')
mezczyzna    100.0
Name: plec, dtype: float64

wyzsze      96.76
nieznane     2.20
inne         1.04
Name: wyksztalcenie, dtype: float64

kawaler_panna    97.59
inny              2.29
nieznany          0.12
Name: stan_cywilny, dtype: float64

Charakterystyka segmentu:

  • Zmienna "plec" - sami mężczyźni.
  • Zmienna "wyksztalcenie" - zdecydowana przewaga osób o wyższym wykształceniu.
  • Zmienna "stan_cywilny" - przewaga kawalerów/panien.

Segment 2 to klienci banku będący kawalerami o wyższym wykształceniu.

Segment 3.

In [93]:
segment_3 = df[df.segment == "3"]
In [94]:
for column in ['plec', 'wyksztalcenie', 'stan_cywilny']:
    print((segment_3[column].value_counts(normalize = True) * 100).round(2))
    print('')
mezczyzna    61.33
kobieta      38.67
Name: plec, dtype: float64

wyzsze_pelne    100.0
Name: wyksztalcenie, dtype: float64

kawaler_panna    100.0
Name: stan_cywilny, dtype: float64

Charakterystyka segmentu:

  • Zmienna "plec" - mieszanka kobiet i mężczyzn. Brak wyraźnej dyskryminacji wg tej zmiennej.
  • Zmienna "wyksztalcenie" - tylko osoby o wyższym, pełnym wykształceniu.
  • Zmienna "stan_cywilny" - tylko osoby nie będące w stałym związku.

Segment 3 to klienci banku nie będący w stałych związkach, o wykształceniu wyższym, pełnym. (Od segmentu 1 różni ich stan cywilny).

Segment 4.

In [95]:
segment_4 = df[df.segment == "4"]
In [96]:
for column in ['plec', 'wyksztalcenie', 'stan_cywilny']:
    print((segment_4[column].value_counts(normalize = True) * 100).round(2))
    print('')
kobieta    100.0
Name: plec, dtype: float64

wyzsze      96.66
nieznane     2.58
inne         0.75
Name: wyksztalcenie, dtype: float64

w_zwiazku    97.38
inny          2.58
nieznany      0.04
Name: stan_cywilny, dtype: float64

Charakterystyka segmentu:

  • Zmienna "plec" - same kobiety.
  • Zmienna "wyksztalcenie" - zdecydowana przewaga osób o wykształceniu wyższym.
  • Zmienna "stan_cywilny" - przewaga osób będących w związku.

Segment 4 to zamężne klientki banku, o wyższym wykształceniu.

Segment 5.

In [97]:
segment_5 = df[df.segment == "5"]
In [98]:
for column in ['plec', 'wyksztalcenie', 'stan_cywilny']:
    print((segment_5[column].value_counts(normalize = True) * 100).round(2))
    print('')
kobieta    100.0
Name: plec, dtype: float64

wyzsze      75.00
srednie     22.81
nieznane     1.61
inne         0.59
Name: wyksztalcenie, dtype: float64

kawaler_panna    100.0
Name: stan_cywilny, dtype: float64

Charakterystyka segmentu:

  • Zmienna "plec" - same kobiety.
  • Zmienna "wyksztalcenie" - przewaga osób o wyższym wykształceniu.
  • Zmienna "stan_cywilny" - przewaga osób będących w związku.

Segment 5 to niezamężne klientki banku o średnim wykształceniu. (Od segmentu 4 odróżnia je stan cywilny).

Segment 6.

In [99]:
segment_6 = df[df.segment == "6"]
In [100]:
for column in ['plec', 'wyksztalcenie', 'stan_cywilny']:
    print((segment_6[column].value_counts(normalize = True) * 100).round(2))
    print('')
mezczyzna    100.0
Name: plec, dtype: float64

wyzsze    100.0
Name: wyksztalcenie, dtype: float64

w_zwiazku    100.0
Name: stan_cywilny, dtype: float64

Charakterystyka segmentu:

  • Zmienna "plec" - sami mężczyźni.
  • Zmienna "wyksztalcenie" - tylko wyższe wykształcenie.
  • Zmienna "stan_cywilny" - sami żonaci.

Segment 5 to żonaci klienci banku o wyższym wykształceniu.

9. Pogrupowane wyniki segmentacji.

Zmienna "plec".

In [105]:
((df.groupby(['plec', 'segment'])['segment'].count().unstack().fillna(0)/df['segment'].value_counts())*100).round(2)
Out[105]:
0 1 2 3 4 5 6
plec
kobieta 26.21 45.58 0.0 38.67 100.0 100.0 0.0
mezczyzna 73.79 54.42 100.0 61.33 0.0 0.0 100.0

Zmienna "wyksztalcenie".

In [108]:
((df.groupby(['wyksztalcenie', 'segment'])['segment'].count().unstack().fillna(0)/df['segment'].value_counts())*100).round(2)
Out[108]:
0 1 2 3 4 5 6
wyksztalcenie
inne 0.86 0.0 1.04 0.0 0.75 0.59 0.0
nieznane 2.92 0.0 2.20 0.0 2.58 1.61 0.0
srednie 96.22 0.0 0.00 0.0 0.00 22.81 0.0
wyzsze 0.00 0.0 96.76 0.0 96.66 75.00 100.0
wyzsze_pelne 0.00 100.0 0.00 100.0 0.00 0.00 0.0

Zmienna "stan_cywilny".

In [104]:
((df.groupby(['stan_cywilny', 'segment'])['segment'].count().unstack().fillna(0)/df['segment'].value_counts())*100).round(2)
Out[104]:
0 1 2 3 4 5 6
stan_cywilny
inny 2.61 1.32 2.29 0.0 2.58 0.0 0.0
kawaler_panna 24.28 0.00 97.59 100.0 0.00 100.0 0.0
nieznany 1.05 0.11 0.12 0.0 0.04 0.0 0.0
w_zwiazku 72.06 98.57 0.00 0.0 97.38 0.0 100.0

Podsumowanie

W kolejnych wpisach będę kontynuować temat grupowania i pokażę już ostatni algorytm z rodziny algorytmów iteracyjno-optymalizacyjnych. Będzie to algorytm hybrydowy, będący połączeniem k-średnich i opisywanego w tym wpisie k-modes: k-prototypes. 🙂

Jeśli chcesz zaczerpnąć wiedzy na temat k-modes "u źródła", to zerknij proszę poniżej do sekcji "Linki". Zawarłem w niej odnośniki do źródeł, które kiedyś pomogły mi dogłębnie zrozumieć działanie k-modes i przygotować się do napisania tego wpisu.

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 🙂

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

Dodaj komentarz

Twój adres email nie zostanie opublikowany.


*