Segmentacja pokemonów, czyli wpis z okazji Międzynarodowego Dnia Dziecka

k-prototype, grupowanie, segmentacja, k-średnich, projekt, data science, uczenie maszynowe, sztuczna inteligencja

Dziś w całej Polsce obchodzimy Międzynarodowy Dzień Dziecka. Z tej okazji publikuję nietypowy wpis w którym wykonuję grupowanie Pokemonów. 😉


  1. Wstęp.
  2. Cel projekty.
  3. Założenia dotyczące projektu.
  4. Opis zbioru danych.
  5. Wczytanie danych i niezbędnych bibliotek.
  6. Przygotowanie danych do analizy.
  7. Eksploracyjna analiza danych.
  8. Przygotowanie danych do modelowania.
  9. Modelowanie.
  10. Przygotowanie danych do analizy.
  11. Analiza segmentów.
  12. Opis uzyskanych segmentów
  13. Podsumowanie.

1. Wstęp.

Podejdę do grupowania pokemonów w taki sam sposób, w jaki podszedłbym do grupowania np. klientów banku. Postaram się zachować pełną powagę i profesjonalizm, dlatego proszę Cię o traktowanie tego wpisu z przymrużeniem oka. 😉

2. Cel projektu.

Celem projektu jest wykonanie grupowania zbioru pokemonów, który powinien zostać podzielony na możliwie różne segmenty. Dodatkowym celem jest opisanie każdego z segmentów.

Jakość grupowania (która również posłuży mi do wybrania optymalnej liczby grup) będę mierzył za pomocą "miary odmienności", która jest używana w algorytmie k-ptototypes, którego użyję.

3. Założenia dotyczące projektu.

Przed przystąpieniem do prac nam zbiorem muszę wymienić kilka założeń, które przyjąłem:

  • Użyję algorytmu k-prototype, który do tej pory nie był opisywany szerzej na moim blogu. Wkrótce planuję to nadrobić i zamieszczę link w tym miejscu.
  • Jeśli chodzi o dobór parametrów modelu, to dobiorę jest w sposób ekspercki, gdyż nie będą one najistotniejszym elementem projektu.
  • Dużo ważniejsze od parametrów będzie tu odpowiednie przygotownia danych, dlatego właśnie na tym się skupię. Wykonam, m.in: usunięcie wartości odstających, standaryzacja zmiennych i usunięcie skośności rozkładów.

4. Opis zbioru danych.

Główne informacje o zbiorze:

  • Pokemony.
  • 13 zmiennych, 800 obserwacji/pokemonów.

5. 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.

5.1. Wczytanie bibliotek.
In [1]:
import pandas as pd
import numpy as np
import seaborn as sns
import matplotlib.pyplot as plt
import scipy
import statsmodels.formula.api as sm
from sklearn.preprocessing import StandardScaler
from kmodes.kprototypes import KPrototypes
5.2. Wczytanie zbioru.

Ustawiam formatowanie zmiennych ciągłych w Pandas z precyzją do trzech miejsc po przecinku.

In [2]:
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:

  • nazwy poszczególnych zmiennych,
  • typy niektórych zmiennych.
In [3]:
df = pd.read_csv('data/Pokemon.csv', dtype={'Name':'object', 'Type 1':'object', 'Type 2':'object', 'Legendary':'int64'})
df.columns = ['#', 'nazwa_pokemona', 'typ_1', 'typ_2', 'suma_sil', 'poziom_zycia', 'atak', 'obrona', 'specjalny_atak', 'specjalna_obrona', 'szybkosc', 'generacja', 'legendarny']

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.

In [4]:
print(str(df.shape[0]) + ' wierszy.')
print(str(df.shape[1]) + ' kolumn.')
800 wierszy.
13 kolumn.
In [5]:
df.head()
Out[5]:
# nazwa_pokemona typ_1 typ_2 suma_sil poziom_zycia atak obrona specjalny_atak specjalna_obrona szybkosc generacja legendarny
0 1 Bulbasaur Grass Poison 318 45 49 49 65 65 45 1 0
1 2 Ivysaur Grass Poison 405 60 62 63 80 80 60 1 0
2 3 Venusaur Grass Poison 525 80 82 83 100 100 80 1 0
3 3 VenusaurMega Venusaur Grass Poison 625 80 100 123 122 120 80 1 0
4 4 Charmander Fire NaN 309 39 52 43 60 50 65 1 0
5.3. Wstępne przygotowanie zbioru.

Usuwam zbędną zmienną '#'.

In [6]:
df.drop('#', axis=1, inplace=True)

Sprawdzam jeszcze typy poszczególnych zmiennych.

In [7]:
df.dtypes
Out[7]:
nazwa_pokemona      object
typ_1               object
typ_2               object
suma_sil             int64
poziom_zycia         int64
atak                 int64
obrona               int64
specjalny_atak       int64
specjalna_obrona     int64
szybkosc             int64
generacja            int64
legendarny           int64
dtype: object

Zmiana wartości zmiennej "typ_1" i "typ_2" na podstawie wiedzy eksperckiej. 😉

In [8]:
df.typ_1.unique()
Out[8]:
array(['Grass', 'Fire', 'Water', 'Bug', 'Normal', 'Poison', 'Electric',
       'Ground', 'Fairy', 'Fighting', 'Psychic', 'Rock', 'Ghost', 'Ice',
       'Dragon', 'Dark', 'Steel', 'Flying'], dtype=object)
In [9]:
df.typ_2.unique()
Out[9]:
array(['Poison', nan, 'Flying', 'Dragon', 'Ground', 'Fairy', 'Grass',
       'Fighting', 'Psychic', 'Steel', 'Ice', 'Rock', 'Dark', 'Water',
       'Electric', 'Fire', 'Ghost', 'Bug', 'Normal'], dtype=object)
In [10]:
df.typ_1.replace(df.typ_1.unique(),['Trawiasty', 'Ognisty', 'Wodny', 'Robaczy', 'Normalny', 'Trujący', 'Elektryczny', 'Ziemny', 'Wróżka', 'Walczący', 'Psychiczny', 'Kamienny', 'Duch', 'Lodowy','Smoczy','Mroczny','Stalowy','Powietrzny'], inplace=True)
In [11]:
df.typ_2.replace(df.typ_2.unique(), ['Trujący', 'brak', 'Powietrzny', 'Smoczy', 'Ziemny', 'Wróżka', 'Trawiasty', 'Walczący', 'Psychiczny', 'Stalowy', 'Lodowy', 'Kamienny', 'Mroczny', 'Wodny', 'Elektryczny', 'Ognisty', 'Duch', 'Robaczy', 'Normalny'], inplace=True)
In [12]:
df.legendarny.replace([0, 1], ['legendarny_nie', 'legendarny_tak'], inplace = True)
In [13]:
df.head()
Out[13]:
nazwa_pokemona typ_1 typ_2 suma_sil poziom_zycia atak obrona specjalny_atak specjalna_obrona szybkosc generacja legendarny
0 Bulbasaur Trawiasty Trujący 318 45 49 49 65 65 45 1 legendarny_nie
1 Ivysaur Trawiasty Trujący 405 60 62 63 80 80 60 1 legendarny_nie
2 Venusaur Trawiasty Trujący 525 80 82 83 100 100 80 1 legendarny_nie
3 VenusaurMega Venusaur Trawiasty Trujący 625 80 100 123 122 120 80 1 legendarny_nie
4 Charmander Ognisty brak 309 39 52 43 60 50 65 1 legendarny_nie

Teraz zbiór wygląda zdecydowanie lepiej. 🙂

6. Przygotowanie danych do analizy.

Sprawdzam jeszcze raz typy zmiennych. Dla zmiennych nominalnych potrzebujemy 'objectów'.

In [14]:
df.dtypes
Out[14]:
nazwa_pokemona      object
typ_1               object
typ_2               object
suma_sil             int64
poziom_zycia         int64
atak                 int64
obrona               int64
specjalny_atak       int64
specjalna_obrona     int64
szybkosc             int64
generacja            int64
legendarny          object
dtype: object

Buduję prosty data frame z podstawowymi informacjami o zbiorze.

In [15]:
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
nazwa_pokemona    object  False             0         0.000
typ_1             object  False             0         0.000
typ_2             object  False             0         0.000
suma_sil           int64  False             0         0.000
poziom_zycia       int64  False             0         0.000
atak               int64  False             0         0.000
obrona             int64  False             0         0.000
specjalny_atak     int64  False             0         0.000
specjalna_obrona   int64  False             0         0.000
szybkosc           int64  False             0         0.000
generacja          int64  False             0         0.000
legendarny        object  False             0         0.000

A więc żadna ze zmiennych nie zawiera brakujących wartości (uzupełniłem wszystko w jednym z poprzednich kroków).

7. Eksploracyjna analiza danych.

7.1. Analiza pojedynczych zmiennych.
7.1.1. Analiza zmiennych numerycznych.

W tym punkcie zbadam zmienne numeryczne. Skupię się na analizie wykresów pod kątem ewentualnych anomalii i wykrycia wartości odstających. Dodatkowo zbadam rozkład zmiennych z użyciem biblioteki scipy.

In [16]:
stats = df.select_dtypes(['float', 'int']).describe()
stats = stats.transpose()
stats = stats[['count','std','min','25%','50%','75%','max','mean']]
stats
Out[16]:
count std min 25% 50% 75% max mean
suma_sil 800.000 119.963 180.000 330.000 450.000 515.000 780.000 435.103
poziom_zycia 800.000 25.535 1.000 50.000 65.000 80.000 255.000 69.259
atak 800.000 32.457 5.000 55.000 75.000 100.000 190.000 79.001
obrona 800.000 31.184 5.000 50.000 70.000 90.000 230.000 73.843
specjalny_atak 800.000 32.722 10.000 49.750 65.000 95.000 194.000 72.820
specjalna_obrona 800.000 27.829 20.000 50.000 70.000 90.000 230.000 71.903
szybkosc 800.000 29.060 5.000 45.000 65.000 90.000 180.000 68.278
generacja 800.000 1.661 1.000 2.000 3.000 5.000 6.000 3.324

Zmienna „atak”.

In [17]:
# Histogram
plt.figure(figsize=(13,7))
sns.set(font_scale=1.4, style="whitegrid")
sns.distplot(df['atak'], kde = False, bins = 30, color = '#eb6c6a').set(title = 'Histogram - "atak"', xlabel = 'atak', ylabel = 'liczba obserwacji')
plt.show()

# Wykres gęstości
plt.figure(figsize=(13,7))
sns.set(font_scale=1.4, style="whitegrid")
sns.kdeplot(df['atak'], shade = True, color = '#eb6c6a').set(title = 'Wykres gęstości - "atak"', xlabel = 'atak', ylabel = '')
plt.show()

# Wykres pudełkowy
plt.figure(figsize=(13,7))
sns.set(font_scale=1.4, style="whitegrid")
sns.boxplot(df['atak'], color = '#eb6c6a').set(title = 'Wykres pudełkowy - "atak"', xlabel = 'atak')
plt.show()

# Test na normalność rozkładu
# Zakładany poziom istotności alfa = 0.05.
if(scipy.stats.normaltest(df['atak'])[1] < 0.05):
    print('Odrzucam hipotezę zerową i przyjmuję hipotezę alternatywną: zmienna nie pochodzi z rozkładu normalnego.')
else:
    print('Przyjmuję hipotezę zerową. Zmienna pochodzi z rozkładu normalnego.')
Odrzucam hipotezę zerową i przyjmuję hipotezę alternatywną: zmienna nie pochodzi z rozkładu normalnego.

Rozkład niemalże normalny. Niewiele wartości odstających.

Zmienna „obrona”.

In [18]:
# Histogram
plt.figure(figsize=(13,7))
sns.set(font_scale=1.4, style="whitegrid")
sns.distplot(df['obrona'], kde = False, bins = 30, color = '#eb6c6a').set(title = 'Histogram - "obrona"', xlabel = 'obrona', ylabel = 'liczba obserwacji')
plt.show()

# Wykres gęstości
plt.figure(figsize=(13,7))
sns.set(font_scale=1.4, style="whitegrid")
sns.kdeplot(df['obrona'], shade = True, color = '#eb6c6a').set(title = 'Wykres gęstości - "obrona"', xlabel = 'obrona', ylabel = '')
plt.show()

# Wykres pudełkowy
plt.figure(figsize=(13,7))
sns.set(font_scale=1.4, style="whitegrid")
sns.boxplot(df['obrona'], color = '#eb6c6a').set(title = 'Wykres pudełkowy - "obrona"', xlabel = 'obrona')
plt.show()

# Test na normalność rozkładu
# Zakładany poziom istotności alfa = 0.05.
if(scipy.stats.normaltest(df['obrona'])[1] < 0.05):
    print('Odrzucam hipotezę zerową i przyjmuję hipotezę alternatywną: zmienna nie pochodzi z rozkładu normalnego.')
else:
    print('Przyjmuję hipotezę zerową. Zmienna pochodzi z rozkładu normalnego.')
Odrzucam hipotezę zerową i przyjmuję hipotezę alternatywną: zmienna nie pochodzi z rozkładu normalnego.

Rozkład zdecydowanie prawoskośny. Niewiele outlier-ów.

Zmienna „poziom_zycia”.

In [19]:
# Histogram
plt.figure(figsize=(13,7))
sns.set(font_scale=1.4, style="whitegrid")
sns.distplot(df['poziom_zycia'], kde = False, bins = 30, color = '#eb6c6a').set(title = 'Histogram - "poziom_zycia"', xlabel = 'poziom_zycia', ylabel = 'liczba obserwacji')
plt.show()

# Wykres gęstości
plt.figure(figsize=(13,7))
sns.set(font_scale=1.4, style="whitegrid")
sns.kdeplot(df['poziom_zycia'], shade = True, color = '#eb6c6a').set(title = 'Wykres gęstości - "poziom_zycia"', xlabel = 'poziom_zycia', ylabel = '')
plt.show()

# Wykres pudełkowy
plt.figure(figsize=(13,7))
sns.set(font_scale=1.4, style="whitegrid")
sns.boxplot(df['poziom_zycia'], color = '#eb6c6a').set(title = 'Wykres pudełkowy - "poziom_zycia"', xlabel = 'poziom_zycia', ylabel = 'liczba obserwacji')
plt.show()

# Test na normalność rozkładu
# Zakładany poziom istotności alfa = 0.05.
if(scipy.stats.normaltest(df['poziom_zycia'])[1] < 0.05):
    print('Odrzucam hipotezę zerową i przyjmuję hipotezę alternatywną: zmienna nie pochodzi z rozkładu normalnego.')
else:
    print('Przyjmuję hipotezę zerową. Zmienna pochodzi z rozkładu normalnego.')
Odrzucam hipotezę zerową i przyjmuję hipotezę alternatywną: zmienna nie pochodzi z rozkładu normalnego.

Rozkład prawostronnie skośny i nieco więcej wartości odstających niż w przypadku poprzednich zmiennych.

7.1.2. Zmienne kategoryczne.

Sprawdzam zatem liczbę unikalnych kategorii wchodzących w skład wybranych zmiennych.

In [20]:
df.select_dtypes(exclude = ['float', 'int']).describe()
Out[20]:
nazwa_pokemona typ_1 typ_2 legendarny
count 800 800 800 800
unique 800 18 19 2
top Cradily Wodny brak legendarny_nie
freq 1 112 386 735

Najpopularniejszym typem pokemona jest pokemon wodny i nielegendarny (cokolwiek to oznacza).

Zmienna „typ_1”.

In [21]:
print('Rozkład zmiennej "typ_1"')
print('-------------------------')
print(df['typ_1'].value_counts(normalize = True))
Rozkład zmiennej "typ_1"
-------------------------
Wodny         0.140
Normalny      0.122
Trawiasty     0.087
Robaczy       0.086
Psychiczny    0.071
Ognisty       0.065
Kamienny      0.055
Elektryczny   0.055
Ziemny        0.040
Smoczy        0.040
Duch          0.040
Mroczny       0.039
Trujący       0.035
Walczący      0.034
Stalowy       0.034
Lodowy        0.030
Wróżka        0.021
Powietrzny    0.005
Name: typ_1, dtype: float64

Zmienna „typ_2”.

In [22]:
print('Rozkład zmiennej "typ_2"')
print('-------------------------')
print(df['typ_2'].value_counts(normalize = True))
Rozkład zmiennej "typ_2"
-------------------------
brak          0.482
Powietrzny    0.121
Ziemny        0.044
Trujący       0.043
Psychiczny    0.041
Walczący      0.033
Trawiasty     0.031
Wróżka        0.029
Stalowy       0.028
Mroczny       0.025
Smoczy        0.022
Lodowy        0.018
Duch          0.018
Kamienny      0.018
Wodny         0.018
Ognisty       0.015
Elektryczny   0.007
Normalny      0.005
Robaczy       0.004
Name: typ_2, dtype: float64

Zmienna "legendarny".

In [23]:
print('Rozkład zmiennej "legendarny"')
print(df['legendarny'].value_counts(normalize = True))

plt.figure(figsize=(10,7))
sns.set(font_scale=1.4, style="whitegrid")
sns.countplot(df['legendarny'], palette = ['#eb6c6a', '#f0918f', '#f2a3a2', '#f5b5b4', '#f7c8c7'], order = df['legendarny'].value_counts().index).set(title = 'Wykres częstości - "legendarny"', xlabel = 'legendarny', ylabel = 'liczba obserwacji')
plt.show()
Rozkład zmiennej "legendarny"
legendarny_nie   0.919
legendarny_tak   0.081
Name: legendarny, dtype: float64

A więc pokemon legendarny jest rzadkością.

7.2. Analiza zależności pomiędzy zmiennymi.
7.2.1. Analiza korelacji pomiędzy zmiennymi numerycznymi.
In [24]:
corr_num = pd.DataFrame(scipy.stats.spearmanr(df.select_dtypes(include = ['float', 'int']))[0],
                        columns = df.select_dtypes(include = ['float', 'int']).columns,
                        index = df.select_dtypes(include = ['float', 'int']).columns)

plt.figure(figsize=(15,6))
sns.set(font_scale=1)
sns.heatmap(corr_num.abs(), cmap="Reds", linewidths=.5).set(title='Heatmap-a współczynnika korelacji rang Spearmana')
plt.show()
7.2.2. Analiza zależności pomiędzy zmiennymi kategorycznymi.
In [25]:
def CramersV(tab):
    a = scipy.stats.chi2_contingency(tab)[0]/sum(tab.sum())
    b = min(tab.shape[0]-1, tab.shape[1]-1,)
    return(np.sqrt(a/b))

def CalculateCrammersV(tab):
    ret = []
    for m in tab:
        row = []
        for n in tab:
            cross_tab = pd.crosstab(tab[m].values,tab[n].values)
            row.append(CramersV(cross_tab))
        ret.append(row)
    return pd.DataFrame(ret, columns=tab.columns, index=tab.columns)
In [26]:
crammer = CalculateCrammersV(df[['typ_1', 'typ_2', 'legendarny']])

plt.figure(figsize=(15,6))
sns.set(font_scale=1.4)
sns.heatmap(crammer, cmap="Reds", linewidths=.5).set(title='Heatmap-a współczynnika zależności Crammera')
plt.show()
crammer.drop('legendarny', axis = 0)
Out[26]:
typ_1 typ_2 legendarny
typ_1 1.000 0.248 0.336
typ_2 0.248 1.000 0.198
7.3. Analiza zależności pomiędzy zmiennymi numerycznymi a kategorycznymi.

W przypadku zmiennych numerycznych i kategorycznych nie mówimy już o korelacji, lecz o poziomie zależności. Miarą, która jest najbliższa współczynnikowi korelacji, w tym przypadku jest współczynnik korelacji wielorakiej (współczynnik R w modelach regresji) – użyję jej do badania zależności pomiędzy zmiennymi kategorycznymi (nominalnymi i porządkowymi) i numerycznymi (liczby całkowite i zmiennoprzecinkowe). Analogiczną miarą może być Eta-squared.

In [27]:
# dzielę zmienne na dwie grupy
cat_cols = ['typ_1', 'typ_2', 'legendarny', 'nazwa_pokemona']
num_cols = df.drop(cat_cols, axis = 1).columns
cat_cols.remove('nazwa_pokemona')

# w pętli buduję kolejne modele regresji liniowej
cols = []
for cat in cat_cols:
    rows = []
    for num in num_cols:
        formula = num + '~' +cat
        model = sm.ols(formula=formula,data=df)
        rows.append(np.sqrt(model.fit().rsquared))
    cols.append(rows)
corr_num_cat = pd.DataFrame(cols, index = cat_cols, columns = num_cols)

# wykres zależności
plt.figure(figsize=(15,6))
sns.set(font_scale=1.4)
sns.heatmap(corr_num_cat, cmap="Reds", linewidths=.5).set(title='Heatmap-a współczynnika korelacji wielorakiej')
plt.show()
corr_num_cat
Out[27]:
suma_sil poziom_zycia atak obrona specjalny_atak specjalna_obrona szybkosc generacja
typ_1 0.303 0.247 0.341 0.437 0.446 0.266 0.310 0.252
typ_2 0.300 0.229 0.331 0.383 0.287 0.231 0.346 0.314
legendarny 0.502 0.274 0.345 0.246 0.449 0.364 0.327 0.080

8. Przygotowanie danych do modelowania.

8.1. Usunięcie zbędnych zmiennych.

Usuwam zbędne zmienne, z których już nie będę korzystać.

In [28]:
df.drop(df.drop(['nazwa_pokemona', 'typ_1', 'typ_2', 'legendarny', 'atak', 'obrona', 'poziom_zycia'], axis = 1).columns, axis = 1, inplace = True)
8.2. Zmiana kodowania zmiennych.

Nie wykonuję, gdyż k-prototypów świetnie radzi sobie ze zmiennymi kategorycznymi.

8.3. Usunięcie skośności zmiennych.
In [29]:
df = pd.concat([df.drop(['poziom_zycia', 'atak', 'obrona'], axis = 1), np.log(df.select_dtypes(include = ['float', 'int']))], axis = 1)
8.4. Usunięcie odstających wartości.

Zanim przystąpię do modelowania, muszę usunąć wartości odstające. Reguła 1.5 IQR nie wchodzi w grę – sprawdziłem to i usuwała ona ok. xx% obserwacji. Nie mogę również zastosować reguły 3sigma, gdyż zakłada ona normalny rozkład zmiennych. Usuwam zatem 5% skrajnych wartości.

In [30]:
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})
In [31]:
outliers
Out[31]:
lower_boundary upper_boundary num_of_outliers__lower_boundary num_of_outliers__upper_boundary
poziom_zycia 3.207 5.087 8 5
atak 3.111 5.502 15 0
obrona 3.030 5.381 11 3

W pętli usuwam wszystkie obserwacje, które nie spełniają dwóch warunków.

In [32]:
for row in outliers.iterrows():
    df = df[(df[row[0]] >= row[1]['lower_boundary']) & (df[row[0]] <= row[1]['upper_boundary'])]
8.5. Standaryzacja zmiennych.
In [33]:
scaler = StandardScaler()
scaler.fit(df[['poziom_zycia', 'atak', 'obrona']])
Out[33]:
StandardScaler(copy=True, with_mean=True, with_std=True)
In [34]:
df_std = scaler.transform(df[['poziom_zycia', 'atak', 'obrona']].values)
In [35]:
df = pd.concat([df.drop(['poziom_zycia', 'atak', 'obrona'], axis = 1), pd.DataFrame(df_std, columns = ['poziom_zycia', 'atak', 'obrona'], index = df.index)], axis = 1)

Po wykonaniu kilku transformacji ponownie sprawdzam rozkłady zmiennych i podstawowe statystyki zbioru.

In [36]:
plt.figure(figsize=(10,7))
sns.set(font_scale=1.4, style="whitegrid")
sns.distplot(df.atak, color = '#eb6c6a').set(title = 'Wykres gęstości - zmienna atak')
plt.show()
plt.figure(figsize=(10,7))
sns.set(font_scale=1.4, style="whitegrid")
sns.distplot(df.obrona, color = '#eb6c6a').set(title = 'Wykres gęstości - zmienna obrona')
plt.show()
plt.figure(figsize=(10,7))
sns.set(font_scale=1.4, style="whitegrid")
sns.distplot(df.poziom_zycia, color = '#eb6c6a').set(title = 'Wykres gęstości - zmienna poziom_zycia')
plt.show()
df.agg(['mean', 'std', 'max', 'min']).round(2)
Out[36]:
nazwa_pokemona typ_1 typ_2 legendarny poziom_zycia atak obrona
max Zygarde50% Forme Ziemny brak legendarny_tak 2.740 2.330 2.750
min Abomasnow Duch Duch legendarny_nie -2.970 -2.910 -2.850
mean NaN NaN NaN NaN -0.000 -0.000 0.000
std NaN NaN NaN NaN 1.000 1.000 1.000
8.6. Wnioski przed modelowaniem.

Ok, mam zatem odpowiednio przygotowany zbiór. Mogę zatem przejść dalej. Mój plan na dalszą część wygląda następująco:

  1. Zbadam, jaka jest "optymalna" liczba segmentów dla zbioru pokemonów.
  2. Zbuduję model.
  3. Opiszę uzyskane wyniki - wykonam krótką EDA dla powstałych segmentów i na podstawie uzyskanych danych postaram się słownie opisać każdy segment.

9. Modelowanie.

In [37]:
res = []
for n in range(1, 20):
    km = KPrototypes(n_clusters=n, init='Huang', n_init=10)
    km.fit_predict(df.drop('nazwa_pokemona', axis = 1), categorical = [0, 1, 2])
    res.append([n, km.cost_])
In [38]:
res = pd.DataFrame(res, columns=[0, 'wspolcz_odm']).set_index(0)
In [39]:
diff = [0]
for n in range(0, 18):
    diff.append(((res.iloc[n,]-res.loc[n+2])/res.iloc[n,]*100).values[0])
In [40]:
res = res.assign(zysk_proc = diff)
In [41]:
res
Out[41]:
wspolcz_odm zysk_proc
0
1 2875.500 0.000
2 1816.414 36.831
3 1570.609 13.532
4 1397.880 10.998
5 1298.312 7.123
6 1214.219 6.477
7 1151.661 5.152
8 1096.743 4.769
9 1051.711 4.106
10 1031.404 1.931
11 993.654 3.660
12 971.121 2.268
13 945.773 2.610
14 930.476 1.617
15 900.734 3.196
16 886.787 1.548
17 866.729 2.262
18 860.764 0.688
19 841.486 2.240
In [42]:
plt.figure(figsize=(10,7))
sns.set(font_scale=1.4, style="whitegrid")
sns.lineplot(data = res.drop('zysk_proc', axis = 1), 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 5 segmentów.

In [43]:
km = KPrototypes(n_clusters=5, init='Huang', n_init=10)
In [44]:
clusters = km.fit_predict(df.drop('nazwa_pokemona', axis = 1), categorical = [0, 1, 2])

10. Przygotowane danych do analizy.

In [45]:
df['segment_pokemona'] = km.labels_
In [46]:
df.head()
Out[46]:
nazwa_pokemona typ_1 typ_2 legendarny poziom_zycia atak obrona segment_pokemona
0 Bulbasaur Trawiasty Trujący legendarny_nie -1.160 -1.033 -0.895 3
1 Ivysaur Trawiasty Trujący legendarny_nie -0.276 -0.450 -0.245 4
2 Venusaur Trawiasty Trujący legendarny_nie 0.608 0.243 0.469 0
3 VenusaurMega Venusaur Trawiasty Trujący legendarny_nie 0.608 0.735 1.488 2
4 Charmander Ognisty brak legendarny_nie -1.600 -0.886 -1.233 3
In [47]:
df.segment_pokemona.value_counts()
Out[47]:
0    214
4    175
2    164
3    112
1    105
Name: segment_pokemona, dtype: int64
In [48]:
df.legendarny.value_counts(normalize = True)
Out[48]:
legendarny_nie   0.917
legendarny_tak   0.083
Name: legendarny, dtype: float64

11. Analiza segmentów.

In [49]:
segment_0 = df[df.segment_pokemona == 0]
segment_1 = df[df.segment_pokemona == 1]
segment_2 = df[df.segment_pokemona == 2]
segment_3 = df[df.segment_pokemona == 3]
segment_4 = df[df.segment_pokemona == 4]
11.1. Analiza zmiennej "legendarny".
In [50]:
print('Segment 0.')
print((segment_0.legendarny.value_counts(normalize = True) * 100).round(3))
print('')
print('Segment 1.')
print((segment_1.legendarny.value_counts(normalize = True) * 100).round(3))
print('')
print('Segment 2.')
print((segment_2.legendarny.value_counts(normalize = True) * 100).round(3))
print('')
print('Segment 3.')
print((segment_3.legendarny.value_counts(normalize = True) * 100).round(3))
print('')
print('Segment 4.')
print((segment_4.legendarny.value_counts(normalize = True) * 100).round(3))
Segment 0.
legendarny_nie   93.925
legendarny_tak    6.075
Name: legendarny, dtype: float64

Segment 1.
legendarny_nie   93.333
legendarny_tak    6.667
Name: legendarny, dtype: float64

Segment 2.
legendarny_nie   73.171
legendarny_tak   26.829
Name: legendarny, dtype: float64

Segment 3.
legendarny_nie   100.000
Name: legendarny, dtype: float64

Segment 4.
legendarny_nie   100.000
Name: legendarny, dtype: float64
11.2. Liczności poszczególnych kategorii.
In [51]:
format_dict = {'seg_1':'${0:,.0f}', 'seg_1':'${0:,.0f}','seg_2':'${0:,.0f}','seg_3':'${0:,.0f}'}
In [52]:
df_count_1 = pd.DataFrame({ 'segment_0' : (segment_0.typ_1.value_counts(normalize = True) * 100).round(3),
 'segment_1' : (segment_1.typ_1.value_counts(normalize = True) * 100).round(3),
 'segment_2' : (segment_2.typ_1.value_counts(normalize = True) * 100).round(3),
 'segment_3' : (segment_3.typ_1.value_counts(normalize = True) * 100).round(3),
 'segment_4' : (segment_4.typ_1.value_counts(normalize = True) * 100).round(3)})
In [53]:
df_count_2 = pd.DataFrame({ 'segment_0' : (segment_0.typ_2.value_counts(normalize = True) * 100).round(3),
 'segment_1' : (segment_1.typ_2.value_counts(normalize = True) * 100).round(3),
 'segment_2' : (segment_2.typ_2.value_counts(normalize = True) * 100).round(3),
 'segment_3' : (segment_3.typ_2.value_counts(normalize = True) * 100).round(3),
 'segment_4' : (segment_4.typ_2.value_counts(normalize = True) * 100).round(3)})
In [54]:
df_count_1.fillna(0, inplace = True)
df_count_2.fillna(0, inplace = True)
In [55]:
(df_count_1.style
 .format(format_dict)
 .background_gradient(subset=['segment_0', 'segment_1', 'segment_2', 'segment_3', 'segment_4'], cmap='Reds'))
Out[55]:
segment_0 segment_1 segment_2 segment_3 segment_4
Duch 1.402 7.619 3.049 4.464 5.714
Elektryczny 5.14 6.667 2.439 8.929 6.286
Kamienny 3.271 17.143 7.927 0 3.429
Lodowy 1.869 2.857 4.268 2.679 3.429
Mroczny 5.607 3.81 3.659 3.571 2.857
Normalny 20.093 0 8.537 18.75 7.429
Ognisty 9.346 1.905 6.098 7.143 6.857
Powietrzny 1.402 0 0 0.893 0
Psychiczny 5.607 3.81 6.098 8.929 9.714
Robaczy 4.673 11.429 5.488 13.393 10.286
Smoczy 3.271 0.952 10.976 1.786 2.286
Stalowy 0.467 15.238 3.659 0 1.143
Trawiasty 9.813 5.714 6.098 9.821 12.571
Trujący 4.673 4.762 1.22 3.571 4
Walczący 4.673 2.857 4.268 3.571 1.714
Wodny 14.019 10.476 16.463 8.929 15.429
Wróżka 2.804 0 1.22 1.786 3.429
Ziemny 1.869 4.762 8.537 1.786 3.429
In [56]:
(df_count_2.style
 .format(format_dict)
 .background_gradient(subset=['segment_0', 'segment_1', 'segment_2', 'segment_3', 'segment_4'], cmap='Reds'))
Out[56]:
segment_0 segment_1 segment_2 segment_3 segment_4
Duch 1.869 4.762 0.61 0 1.714
Elektryczny 1.402 0 0.61 0 1.143
Kamienny 0 5.714 3.659 0 0.571
Lodowy 2.336 1.905 3.049 0 1.143
Mroczny 2.804 1.905 4.268 0 2.286
Normalny 0.467 0 0 0.893 1.143
Ognisty 1.402 1.905 1.829 1.786 1.143
Powietrzny 14.953 4.762 12.195 14.286 12.571
Psychiczny 3.738 5.714 5.488 1.786 4
Robaczy 0 0.952 0.61 0 0.571
Smoczy 1.869 1.905 5.488 0.893 1.143
Stalowy 0.467 8.571 6.707 0.893 0
Trawiasty 2.336 4.762 2.439 1.786 5.143
Trujący 4.206 2.857 0.61 8.929 6.286
Walczący 3.738 0.952 8.537 0.893 1.143
Wodny 0.935 4.762 1.829 0.893 1.714
Wróżka 1.402 6.667 1.22 3.571 1.143
Ziemny 3.738 5.714 7.927 0.893 3.429
brak 52.336 36.19 32.927 62.5 53.714
11.3. Statystyki dla całego zbioru - punkt odniesienia dla segmentów.
In [57]:
df[['atak', 'obrona', 'poziom_zycia']].agg(['mean', 'min', 'max'])
Out[57]:
atak obrona poziom_zycia
mean -0.000 0.000 -0.000
min -2.908 -2.854 -2.966
max 2.325 2.746 2.738
In [58]:
df[['typ_1', 'typ_2', 'legendarny']].describe()
Out[58]:
typ_1 typ_2 legendarny
count 770 770 770
unique 18 19 2
top Wodny brak legendarny_nie
freq 105 368 706
11.4. Statystyki dla poszczególnych grup.
In [59]:
grouped = df[['segment_pokemona', 'atak', 'obrona', 'typ_1', 'typ_2', 'legendarny']].groupby('segment_pokemona').agg({"atak": [np.mean], "obrona": [np.mean], 'typ_1' : pd.Series.mode, 'typ_2' : pd.Series.mode, 'legendarny' : pd.Series.mode})
grouped.columns = ["_".join(x) for x in grouped.columns.ravel()]
In [60]:
grouped
Out[60]:
atak_mean obrona_mean typ_1_mode typ_2_mode legendarny_mode
segment_pokemona
0 0.404 -0.072 Normalny brak legendarny_nie
1 -0.022 1.122 Kamienny brak legendarny_nie
2 1.059 0.947 Wodny brak legendarny_nie
3 -1.323 -1.358 Normalny brak legendarny_nie
4 -0.626 -0.603 Wodny brak legendarny_nie
In [61]:
df[['segment_pokemona', 'typ_1', 'typ_2', 'legendarny']].groupby('segment_pokemona').agg(pd.Series.mode)
Out[61]:
typ_1 typ_2 legendarny
segment_pokemona
0 Normalny brak legendarny_nie
1 Kamienny brak legendarny_nie
2 Wodny brak legendarny_nie
3 Normalny brak legendarny_nie
4 Wodny brak legendarny_nie

12. Opis uzyskanych segmentów.

  1. Segment 0 - segment najliczniej reprezentowany przez pokemony normalne o niezłym ataku i przeciętnej offensywie. W tym segmencie odsetek pokemonów legendarnych jest bliski zeru. Nazwa segmentu: "normalne" pokemony.
  2. Segment 1 - tutaj dominują pokemony kamienne o przeciętnym ataku i świetnej obrona. Nazwa segmentu: kamienni twardziele.
  3. Segment 2 - przeciętny pokemon z tej grupy to pokemon wodny, który świetnie atakuje i równie dobrze się broni. Być może wynika to z tego, że segment ten posiada największy odsetek pokemonów legendarnych. Nazwa segmentu: legendarne pokemony wodne.
  4. Segment 3 - segment najliczniej reprezentowany przez pokemony normalne o słabym ataku i słabej offensywie. W tym segmencie odsetek pokemonów legendarnych jest zerowy. Nazwa segmentu: "normalne" pokemony.
  5. Segment 4 - podobnie jak w segmencie 3, tak i tutaj dominują pokemony wodne. To, co odróżnia ten segment od segmentu 3, to kiepski atak i kiepska obrona. Co ciekawe, to w ogóle nie znajdziemy tu pokemonów legendarnych, co może tłumaczyć tak słabe statystyki obrony i ataku. Nazwa segmentu: wodne przeciętniaki (albo kiepściaki ;)).

13. Podsumowanie.

Dziękuję Ci za dobrnięcie do końca. Mam nadzieję, że ten nieco luźniejszy projekt przypadł Ci do gustu. 😉 Jeśli masz jakiekolwiek uwagi, pytania, lub spostrzeżenia, to zapraszam do dyskusji w komentarzach pod wpisem. 🙂

photo: Unsplash.com (Austin Pacheco)

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.


*