Grupowanie zmiennych ciągłych – k-średnich vs k-modes

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

Postanowiłem zrobić mały eksperyment i porównać działanie dwóch algorytmów na tym samym zbiorze. Czy k-średnich poradzi sobie lepiej ze zmiennymi ciągłymi, niz k-modes z kategoryzowanymi zmiennymi ciągłymi?

Z tego artykułu dowiesz się: 1. Jak sprawdzić potencjał zbioru przed wykonaniem grupowania? 2. Jak użyć algorytmu k-modes do zmiennych ciągłych? 3. Który z algorytmów sprawdzi się lepiej na zadanym zbiorze: k-średnich, czy k-modes?

Oba algorytmy były już dosyć szczegółowo opisywane na blogu. (k-średnich, k-modes). W tym wpisie sprawdzę ich działanie na tym samym zbiorze składającym się z kilku zmiennych ciągłych. Zaczynamy!

Główne założenia eksperymentu:

  1. Oba algorytmy przetestuję na tym samym zbiorze, na tym samym zestawie zmiennych.
  2. Na podstawie zbioru "Credit Approval - UCI" przygotuję dwa zbiory dostosowane do wymagań obu algorytmów.
  3. Użyję sześciu wybranych zmiennych: 'Wiek', 'Zadłużenie', 'Lata_pracy', 'Score_kredytowy', 'Saldo_konta', 'Przychody'.
  4. Przetestuję jeden zbiór w 6 wariantach:
    • surowe zmienne ciągłe,
    • zmienne ciągłe poddane procesom standaryzacji i usuwania skośności,
    • surowe zmienne ciągłe poddane procesowi dyskretyzacji decylowej,
    • surowe zmienne ciągłe poddane procesowi dyskretyzacji kwartylowej,
    • zmienne ciągłe poddane procesom standaryzacji i usuwania skośności, a następnie dyskretyzacji decylowej,
    • zmienne ciągłe poddane procesom standaryzacji i usuwania skośności, a następnie dyskretyzacji kwartylowej.
  5. Potencjał zbiorów przed wykonaniem grupowania sprawdzę za pomocą statystyki Hopkinsa, która sprawdza się zarówno dla zmiennych ciągłych, jak i dyskretnych.
  6. Liczbę grup wybiorę z użyciem wykresu łokcia (6 wykresów i wybranie średniej liczby segmentów).
  7. Jakoś grupowania zweryfikuję za pomocą kilku statystyk:
    • Silhouette Score.
    • Calinski-Harabasz Score.
    • Davies Bouldin Score.
  8. O finalnej ocenie zadecyduje suma głosów poszczególnych statystyk:
    • na etapie oceny potencjału zbioru,
    • po wykonaniu grupowania.

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

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.
1. Wczytuję kilka niezbędnych bibliotek.
In [218]:
import numpy as np
import pandas as pd
import seaborn as sns
import sklearn.metrics
import matplotlib.pyplot as plt
from kmodes.kmodes import KModes
from sklearn.cluster import KMeans
from sklearn.preprocessing import StandardScaler, Normalizer
In [3]:
pd.options.display.max_columns = 50
2. Wczytuję zbiór.
In [5]:
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'})
3. Ograczam zbiór do czterech zmiennych, którymi się posłużę.
In [7]:
df = df[['Wiek', 'Zadłużenie', 'Lata_pracy', 'Score_kredytowy', 'Saldo_konta', 'Przychody']]
4. Usuwam obserwacje zawierające brakujące wartości.
In [9]:
df.dropna(inplace=True)
5. Podgląd podstawowych statystyk zbioru.
In [13]:
df.head()
Out[13]:
Wiek Zadłużenie Lata_pracy Score_kredytowy Saldo_konta Przychody
0 30.83 0.000 1.25 1 202.0 0
1 58.67 4.460 3.04 6 43.0 560
2 24.50 0.500 1.50 0 280.0 824
3 27.83 1.540 3.75 5 100.0 3
4 20.17 5.625 1.71 0 120.0 0
In [14]:
df.describe()
Out[14]:
Wiek Zadłużenie Lata_pracy Score_kredytowy Saldo_konta Przychody
count 666.000000 666.000000 666.000000 666.000000 666.000000 666.000000
mean 31.569054 4.798078 2.222320 2.459459 182.115616 998.584084
std 11.920174 5.005309 3.347599 4.929794 171.477919 5202.975198
min 13.750000 0.000000 0.000000 0.000000 0.000000 0.000000
25% 22.602500 1.010000 0.165000 0.000000 75.250000 0.000000
50% 28.500000 2.750000 1.000000 0.000000 160.000000 5.000000
75% 38.250000 7.207500 2.585000 3.000000 271.000000 399.000000
max 80.250000 28.000000 28.500000 67.000000 2000.000000 100000.000000

Zbiór zawiera wartości zerowe, co nieco utrudni proces przygotowania zmiennych.

6. Przygotowanie zbiorów.
6.1. Zbiór 1 - surowe zmienne ciągłe.
In [15]:
df_1 = df.copy()
6.2. Zbiór 2 - zmienne ciągłe poddane procesom standaryzacji i usuwania skośności.
In [18]:
df_2 = np.log(df+0.01)
In [19]:
scaler = StandardScaler()
scaler.fit(df_2)
df_2 = pd.DataFrame(scaler.transform(df_2.values), columns = df_2.columns)
6.3. Zbiór 3 - surowe zmienne ciągłe poddane procesowi dyskretyzacji decylowej.
In [101]:
df_3 = df.copy()
for col in df.columns:
    df_3[col] = pd.qcut(df_3[col], 10, duplicates = "drop")
    df_3[col].replace(df_3[col].unique(), np.arange(1, df_3[col].unique().shape[0]+1), inplace = True)
    df_3[col] = df_3[col].astype(str)
6.4. Zbiór 4 - surowe zmienne ciągłe poddane procesowi dyskretyzacji kwartylowej.
In [100]:
df_4 = df.copy()
for col in df.columns:
    df_4[col] = pd.qcut(df_4[col], 4, duplicates = "drop")
    df_4[col].replace(df_4[col].unique(), np.arange(1, df_4[col].unique().shape[0]+1), inplace = True)
    df_4[col] = df_4[col].astype(str)
6.5. Zbiór 5 - zmienne ciągłe poddane procesom standaryzacji i usuwania skośności, a następnie dyskretyzacji decylowej.
In [99]:
df_5 = df_2.copy()
for col in df.columns:
    df_5[col] = pd.qcut(df_5[col], 10, duplicates = "drop")
    df_5[col].replace(df_5[col].unique(), np.arange(1, df_5[col].unique().shape[0]+1), inplace = True)
    df_5[col] = df_5[col].astype(str)
6.6. Zbiór 6 - zmienne ciągłe poddane procesom standaryzacji i usuwania skośności, a następnie dyskretyzacji kwartylowej.
In [95]:
df_6 = df_2.copy()
for col in df.columns:
    df_6[col] = pd.qcut(df_6[col], 4, duplicates = "drop")
    df_6[col].replace(df_6[col].unique(), np.arange(1, df_6[col].unique().shape[0]+1), inplace = True)
    df_6[col] = df_6[col].astype(str)
7. Ocena potencjału poszczególnych zbiorów - statystyka Hopkinsa.

Do oceny potencjały zbiorów użyje statystyki Hopkinsa, której opis znajduje się tutaj. Jej implementacja nie jest dostępna w żadnej znanej mi bibliotece Python-a, lecz na szczęście ktoś udostępnił jej implementację na GitHub, a kolejna osoba udoskonaliła ją i opublikowała na swoim blogu.

To, co nas będzie interesować, to końcowy wynik. Statystyka przyjmuje wartości z przedziału [0, 1]. Im bliżej 1, tym większy potencjał drzemie w danych pod kątem segmentacji.

Eksperyment ten pozwoli ocenić wpływ przygotowania danych na potencjał zbioru.

In [64]:
from sklearn.neighbors import NearestNeighbors
from random import sample
from numpy.random import uniform
import numpy as np
from math import isnan

def hopkins(X):
    d = X.shape[1]
    n = len(X)
    m = int(0.1 * n)
    nbrs = NearestNeighbors(n_neighbors=1).fit(X.values)
 
    rand_X = sample(range(0, n, 1), m)
 
    ujd = []
    wjd = []
    for j in range(0, m):
        u_dist, _ = nbrs.kneighbors(uniform(np.amin(X,axis=0),np.amax(X,axis=0),d).reshape(1, -1), 2, return_distance=True)
        ujd.append(u_dist[0][1])
        w_dist, _ = nbrs.kneighbors(X.iloc[rand_X[j]].values.reshape(1, -1), 2, return_distance=True)
        wjd.append(w_dist[0][1])
 
    H = sum(ujd) / (sum(ujd) + sum(wjd))
    if isnan(H):
        print(ujd, wjd)
        H = 0

    return H
In [104]:
datasets = [df_1, df_2, df_3, df_4, df_5, df_6]
for i in range(0,6):
    print("Zbiór numer {}. Osiągnięty wynik: {}.".format(i+1, hopkins(datasets[i]).round(3)))
Zbiór numer 1. Osiągnięty wynik: 0.982.
Zbiór numer 2. Osiągnięty wynik: 0.754.
Zbiór numer 3. Osiągnięty wynik: 0.587.
Zbiór numer 4. Osiągnięty wynik: 0.72.
Zbiór numer 5. Osiągnięty wynik: 0.609.
Zbiór numer 6. Osiągnięty wynik: 0.654.

Dwa najlepsze wyniki, to zbiory zawierające zmienne numeryczne. Najlepszy wynik osiągnął zbiór numer 1, czyli "surowe" zmienne numeryczne, przed standaryzacją i usuwaniem skośności. Drugi najlepszy rezultat, to zmienne surowe poddane procesowi standaryzacji i usuwania skośności.

Dalsze miejsca zajmują zbiory składające się ze zmiennych kategorycznych. Da się zauważyć ciekawą zależność: kategoryzacja kwartylowa > kategoryzacja decylowa. Dodatkowo najgorszy wynik osiągnął zbiór składający się ze zmiennych kategorycznych zbudowanych na podstawie surowych zmiennych ciągłych.

Jest to dosyć mała próba, by wyciągać dalekosiężne wnioski, dlatego powyższe wyniki nie muszą być aplikowalne dla wszystkich przypadków. Ja pozwolę sobie na kilka punktów podsumowania wpływu przygotowania danych na potencjał zbioru przed segmentacją:

  • zmienne numeryczne > zmienne kategoryczne (zbudowane na podstawie zmiennych numerycznych w procesie kategoryzacji),
  • proces standaryzacji i usuwania skośności negatywnie wpływa na potencjał zbioru pod kątem segmentacji (uwaga, korzystając z większości algorytmów i tak powinniśmy wykonywać te procesy - dzięki nim dostosowujemy zbiór do wymagań i założeń algorytmów grupujących),
  • kategoryzacja kwartylowa > kategoryzacja decylowa,
  • poddanie zmiennych ciągłych procesom standaryzacji i usuwania skośności bez wykonania kategoryzacji negatywnie wpływa na wynik.
8. Sprawdzam na ile grup podzielić zbiór.
In [126]:
for i in range(0,2):
    res = []
    for n in range(1, 21):
        # clustering
        kmeans = KMeans(n_clusters=n)
        kmeans.fit(datasets[i])
        res.append([n, kmeans.inertia_])
    res = pd.DataFrame(res, columns=[0, 'wspolcz_odm']).set_index(0)
        
    # plotting results
    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 - zbiór {}.".format(i+1))
    plt.show()

Dla pierwszego zbioru "łokieć" wskazuje 4 segmenty. Dla drugiego chyba tyle samo.

In [127]:
for i in range(2,6):
    res = []
    for n in range(1, 21):
        # clustering
        km = KModes(n_clusters=n, init='Huang', n_init=20, n_jobs=4)
        km.fit_predict(df)
        res.append([n, km.cost_])
    res = pd.DataFrame(res, columns=[0, 'wspolcz_odm']).set_index(0)
        
    # plotting results
    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 - zbiór {}.".format(i+1))
    plt.show()

Dla czterech powyższych zbiorów bardzo ciężko wskazać "łokieć", lecz jest to z pewnością wartość bliższa 10, niż 4. Uśrednijmy zatem do 7. 🙂

ps. ciekawe, że dla zmiennych kategoryzowanych na podstawie zmiennych standaryzowanych wyniki są nieco bardziej "wygładzone" i stabilniejsze.

9. Grupowanie.
In [162]:
for i in range(0,2):
    kmeans = KMeans(n_clusters=7)
    kmeans.fit(datasets[i])
    df['cluster_{}'.format(i+1)] = kmeans.labels_
In [163]:
for i in range(2,6):
    km = KModes(n_clusters=7, init='Huang', n_init=10, n_jobs=4)
    df['cluster_{}'.format(i+1)] = km.fit_predict(datasets[i])
10. Weryfikacja jakości grupowania.
In [245]:
res = []
for i in range(1,7):
    cluster_metrics = [metrics.calinski_harabaz_score(df[['Wiek', 'Zadłużenie', 'Lata_pracy', 'Score_kredytowy', 'Saldo_konta', 'Przychody']], df['cluster_{}'.format(i)]),
    metrics.silhouette_score(df[['Wiek', 'Zadłużenie', 'Lata_pracy', 'Score_kredytowy', 'Saldo_konta', 'Przychody']], df['cluster_{}'.format(i)]),
    metrics.davies_bouldin_score(df[['Wiek', 'Zadłużenie', 'Lata_pracy', 'Score_kredytowy', 'Saldo_konta', 'Przychody']], df['cluster_{}'.format(i)])]
    res.append(cluster_metrics)
In [171]:
results = pd.DataFrame(res, columns = ['Calinski_Harabasz', 'Silhouette_Score', 'Davies_Bouldin'])
In [202]:
ranking = results.copy()
ranking['Calinski_Harabasz'] = ranking['Calinski_Harabasz'].rank(ascending = False)
ranking['Silhouette_Score'] = ranking['Silhouette_Score'].rank(ascending = False)
ranking['Davies_Bouldin'] = ranking['Davies_Bouldin'].rank(ascending = True)
In [208]:
ranking['score'] = ranking.sum(axis = 1)
ranking['place'] = ranking.score.rank(ascending = True)
In [214]:
ranking
Out[214]:
Calinski_Harabasz Silhouette_Score Davies_Bouldin score place
0 1.0 1.0 1.0 3.0 1.0
1 2.0 5.0 4.0 11.0 3.5
2 5.0 6.0 6.0 17.0 6.0
3 3.0 2.0 2.0 7.0 2.0
4 6.0 3.0 5.0 14.0 5.0
5 4.0 4.0 3.0 11.0 3.5

Są dwa trzecie miejsca. Wykonam zaten normalizację, zsumuję wyniki i ponownie wykonam ranking.

In [237]:
ranking = results.copy()
normalizer = Normalizer()
ranking = pd.DataFrame(normalizer.fit_transform(results), columns = results.columns)
In [239]:
ranking['Davies_Bouldin'] = -ranking['Davies_Bouldin']
In [242]:
ranking['score'] = ranking.sum(axis = 1)
ranking['place'] = ranking.score.rank(ascending = False)
In [243]:
ranking
Out[243]:
Calinski_Harabasz Silhouette_Score Davies_Bouldin score place
0 1.000000 0.000075 -0.000031 1.000044 1.0
1 0.209602 -0.026128 -0.977438 -0.793964 3.0
2 0.055935 -0.018450 -0.998264 -0.960779 6.0
3 0.290192 -0.032166 -0.956428 -0.698402 2.0
4 0.074160 -0.021455 -0.997016 -0.944310 5.0
5 0.164381 -0.027385 -0.986017 -0.849021 4.0

Finalne wyniki wyglądają następująco:

  • Miejsce 1 - zbiór numer 1 - surowe zmienne ciągłe.
  • Miejsce 2 - zbiór numer 4 - surowe zmienne ciągłe poddane procesowi dyskretyzacji kwartylowej.
  • Miejsce 3 - zbiór numer 2 - zmienne ciągłe poddane procesom standaryzacji i usuwania skośności.
  • Miejsce 4 - zbiór numer 6 - zmienne ciągłe poddane procesom standaryzacji i usuwania skośności, a następnie dyskretyzacji kwartylowej.
  • Miejsce 5 - zbiór numer 5 - zmienne ciągłe poddane procesom standaryzacji i usuwania skośności, a następnie dyskretyzacji decylowej.
  • Miejsce 6 - zbiór numer 3 - surowe zmienne ciągłe poddane procesowi dyskretyzacji decylowej.

Dla przypomnienia ocena potencjału zbiorów wyglądała następująco:

  • Miejsce 1 - zbiór numer 1 - surowe zmienne ciągłe.
  • Miejsce 2 - zbiór numer 2 - zmienne ciągłe poddane procesom standaryzacji i usuwania skośności.
  • Miejsce 3 - zbiór numer 4 - surowe zmienne ciągłe poddane procesowi dyskretyzacji kwartylowej.
  • Miejsce 4 - zbiór numer 6 - zmienne ciągłe poddane procesom standaryzacji i usuwania skośności, a następnie dyskretyzacji kwartylowej.
  • Miejsce 5 - zbiór numer 5 - zmienne ciągłe poddane procesom standaryzacji i usuwania skośności, a następnie dyskretyzacji decylowej.
  • Miejsce 6 - zbiór numer 3 - surowe zmienne ciągłe poddane procesowi dyskretyzacji decylowej.

W obu rankingach jedyna różnica, to zamiana miejsc pomiędzy zbiorami 2 i 4.

Kilka dodatkowych wniosków na podstawie powyższych wyników:

  • kategoryzacja kwartylowa > kategoryzacja decylowa - kategoryzacja decylowa nie jest najlepszym pomysłem,
  • poddanie zmiennych ciągłych procesom standaryzacji i usuwania skośności negatywnie wpływa na wynik.

Podsumowanie

Mam nadzieję, że powyższy eksperyment przypadł Ci do gustu i być może okaże się pomocny w Twojej pracy.

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.


*