3 metody analizy normalności rozkładu w Python

rozkład normalny, analiza, badanie, data science

Chciałbym Ci przedstawić 3 skuteczne metody analizy normalności rozkładu w Python. Każda z nich ma inne zalety i możesz używać ich w zależności od potrzeb i kontekstu wykonywanej analizy.

Metod możesz używać rozłącznie lub razem, gdyż się wzajemnie uzupełniają:

  1. Pierwsza z nich bazuje na statystykach opisowych i parametrach rozkładu normalnego wynikających z jego definicji. Daje dosyć ogólny pogląd na to, czego możemy się po zbiorze spodziewać.
  2. Druga również jest metodą subiektywną, oparta o wizualizację.
  3. Trzecia jest w 100% obiektywna i bazuje na teście statystycznym.

Być może zadajesz sobie pytanie: w zasadzie to, po co badać zmienne pod kątem normalności? Odpowiedź na to pytanie jest relatywnie prosta: w większości przypadków nie musisz tego robić. 🙂

W statystyce i uczeniu maszynowym jest masa metod, które nie mają założeń dotyczących rozkładu zmiennych - tzw. metody nieparametryczne. Są jednak też takie, które mają pewne założenia i których to powinniśmy przestrzegać, tzw. metody parametryczne. To dla części metod z drugiej grupy analiza normalności rozkładu ma sens (ps. przynajmniej w części przypadków, ale nie chcę zbyt głęboko wchodzić w szczegóły ;)).

Zanim zaczniemy, poniżej przedstawiam informacje o zbiorze danych z którego będę korzystać:

1. Wczytanie niezbędnych bibliotek.

In [1]:
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt
import scipy.stats

2. Wczytanie zbioru.

In [2]:
white_wines = pd.read_csv('data/winequality-white.csv', sep = ';')
red_wines = pd.read_csv('data/winequality-red.csv', sep = ';')

Na potrzeby dalszej analizy łącze oba zbiory w jeden.

In [3]:
wines = pd.concat([white_wines, red_wines], axis = 0)
3. Podgląd scalonego zbioru.
In [4]:
wines.head()
Out[4]:
fixed acidity volatile acidity citric acid residual sugar chlorides free sulfur dioxide total sulfur dioxide density pH sulphates alcohol quality
0 7.0 0.27 0.36 20.7 0.045 45.0 170.0 1.0010 3.00 0.45 8.8 6
1 6.3 0.30 0.34 1.6 0.049 14.0 132.0 0.9940 3.30 0.49 9.5 6
2 8.1 0.28 0.40 6.9 0.050 30.0 97.0 0.9951 3.26 0.44 10.1 6
3 7.2 0.23 0.32 8.5 0.058 47.0 186.0 0.9956 3.19 0.40 9.9 6
4 7.2 0.23 0.32 8.5 0.058 47.0 186.0 0.9956 3.19 0.40 9.9 6
In [5]:
print('Zbiór zawiera {} obserwacji i {} zmiennych.'.format(wines.shape[0], wines.shape[1]))
Zbiór zawiera 6497 obserwacji i 12 zmiennych.
In [6]:
print('Lista zmiennych dostępnych w zbiorze: {}'.format(list(wines.columns)))
Lista zmiennych dostępnych w zbiorze: ['fixed acidity', 'volatile acidity', 'citric acid', 'residual sugar', 'chlorides', 'free sulfur dioxide', 'total sulfur dioxide', 'density', 'pH', 'sulphates', 'alcohol', 'quality']

Chcesz pozbyć się skośności i wartości odstających ze zbioru? Sprawdź wpisy o kategoryzacji zmiennych ciągłych i transformacji z użyciem metody WoE.

4. Metody analizy normalności rozkładu.

4.1. Weryfikacja z użycie podstawowych statystyk.

Metoda pierwsza. Nie jest ona bardzo dokładna, lecz prosta, szybka, intuicyjna i daje pewne przesłanki na temat zmiennych.

Zgodnie z definicją, parametry rozkładu normalnego powinny wynosić:

  • kurtoza = 0
  • skośność = 0.

Wyznaczenie skośności rozkładu

Za pomocą metody agg dostępnej w bibliotece Pandas wyznaczamy dwie statystyki: średnią i medianę. Zgodnie z założeniami rozkładu normalnego wartość średnia i mediana powinny być do siebie bardzo zbliżone (najlepiej równe). Wtedy mamy szanse podejrzewać rozkład zmiennej o brak skośności.

Różnice w wartościach świadczą o skośności rozkładu. Kolejno:

  • średnia mniejsza od mediany - rozkład lewostronnie skośny (wydłużone lewe ramię rozkładu),
  • średniej większa od mediany - rozkład prawostronnie skośny (wydłużone prawe ramię rozkładu).

Dla przypomnienia, poniżej przedstawiam przykład rozkładu lewostronnie (ujemny współczynnik skośności) i prawostronnie skośnego (dodatni współczynnik skośności).

In [7]:
wines.agg(['mean', 'median'])
Out[7]:
fixed acidity volatile acidity citric acid residual sugar chlorides free sulfur dioxide total sulfur dioxide density pH sulphates alcohol quality
mean 7.215307 0.339666 0.318633 5.443235 0.056034 30.525319 115.744574 0.994697 3.218501 0.531268 10.491801 5.818378
median 7.000000 0.290000 0.310000 3.000000 0.047000 29.000000 118.000000 0.994890 3.210000 0.510000 10.300000 6.000000

W zbiorze jest jedna zmienna, której średnia jest nieco wyższa niż mediana ("residual sugar"). Może to wskazywać na skośność rozkładu. Należy się jednak dodatkowo upewnić. Można to zrobić w jednej linii kodu, poprzez wyznaczenie współczynnika skośności rozkładu, który bierze pod uwagę również odchylenie standardowe danej zmiennej.

In [8]:
wines.skew()
Out[8]:
fixed acidity           1.723290
volatile acidity        1.495097
citric acid             0.471731
residual sugar          1.435404
chlorides               5.399828
free sulfur dioxide     1.220066
total sulfur dioxide   -0.001177
density                 0.503602
pH                      0.386839
sulphates               1.797270
alcohol                 0.565718
quality                 0.189623
dtype: float64

Uzyskane wyniki interpretujemy w sposób następujący:

  • Współczynnik o wartości 0 to rozkład symetryczny.
  • Współczynnik o wartości ujemnej to rozkład lewostronnie skośny (wydłużone lewe ramię rozkładu; średnia mniejsza od mediany).
  • Współczynnik o wartości dodatniej to rozkład prawostronnie skośny (wydłużone prawe ramię rozkładu; średniej większa od mediany).

Wyznaczenie kurtozy

Kurtoza jest miarą spłaszczenia rozkładu. Mówi o tym, jak bardzo względem rozkładu normalnego jest spłaszczony (ujemne wartości kurtozy) lub wydęty (dodatnie wartości kurtozy) rozkład badanej zmiennej.

Dla porządku wyznaczę najpierw wartość bezwzględną, a później posortuję wartości.

In [9]:
wines.kurtosis().abs().sort_values()
Out[9]:
quality                  0.232322
pH                       0.367657
total sulfur dioxide     0.371664
alcohol                  0.531687
citric acid              2.397239
volatile acidity         2.825372
residual sugar           4.359272
fixed acidity            5.061161
density                  6.606067
free sulfur dioxide      7.906238
sulphates                8.653699
chlorides               50.898051
dtype: float64

Pandas daje nam możliwość połączenia podsumowania obu statystyk w jedną ramke danych. 🙂

In [10]:
wines.agg(['kurtosis', 'skew']).T
Out[10]:
kurtosis skew
fixed acidity 5.061161 1.723290
volatile acidity 2.825372 1.495097
citric acid 2.397239 0.471731
residual sugar 4.359272 1.435404
chlorides 50.898051 5.399828
free sulfur dioxide 7.906238 1.220066
total sulfur dioxide -0.371664 -0.001177
density 6.606067 0.503602
pH 0.367657 0.386839
sulphates 8.653699 1.797270
alcohol -0.531687 0.565718
quality 0.232322 0.189623

Na podstawie powyższych informacji o skośności i kurtozie można stwierdzić, że najbliżej normalności są zmienne: "total sulfur dioxide", "quality", "pH".

4.2. Wizualizacja rozkładów z użyciem wykresu gęstości.

Metoda numer dwa. W 100% subiektywna. Dla każdej zmiennej wykonam wykres gęstości i metodą "na oko" postaram się określić, które ze zmiennych cechują się rozkładem normalnym. 😉 Przy okazji zweryfikuje moje typy z poprzedniego punktu: "total sulfur dioxide", "quality" i "pH".

Dla przypomnienia rozkład normalny zgodny z definicją powinien wyglądać tak (ze zilustrowaną regułą trzech sigm):

In [11]:
print('Liczba zmiennych w zbiorze:', wines.shape[1])
Liczba zmiennych w zbiorze: 12

Cały zbiór ma 12 zmiennych, więc wykonam wizualizację czwórkami.

Grupa 1.

In [12]:
f, axes = plt.subplots(2, 2, figsize=(15, 10))
sns.distplot(wines['fixed acidity'], color='skyblue', ax=axes[0, 0])
sns.distplot(wines['volatile acidity'], color='olive', ax=axes[0, 1])
sns.distplot(wines['citric acid'], color='gold', ax=axes[1, 0])
sns.distplot(wines['residual sugar'], color='teal', ax=axes[1, 1])
plt.show()

Z tej grupy najbliżej rozkładu normalnego jest zdecydowanie "fixed acidity".

Grupa 2.

In [13]:
f, axes = plt.subplots(2, 2, figsize=(15, 10))
sns.distplot(wines['chlorides'], color='skyblue', ax=axes[0, 0])
sns.distplot(wines['free sulfur dioxide'], color='olive', ax=axes[0, 1])
sns.distplot(wines['total sulfur dioxide'], color='gold', ax=axes[1, 0])
sns.distplot(wines['density'], color='teal', ax=axes[1, 1])
plt.show()

Z grupy numer 2 wszystkie zmienne wyglądają porównywalnie kiepsko. Widoczny tu jest jeden z moich typów: "total sulfur dioxide".

Grupa 3.

In [14]:
f, axes = plt.subplots(2, 2, figsize=(15, 10))
sns.distplot(wines['pH'], color='skyblue', ax=axes[0, 0])
sns.distplot(wines['sulphates'], color='olive', ax=axes[0, 1])
sns.distplot(wines['alcohol'], color='gold', ax=axes[1, 0])
sns.distplot(wines['quality'], color='teal', ax=axes[1, 1])
plt.show()

Spośród tej pozostałych zmiennych najlepiej wyglądają "quality" i "pH". Ta ostatnia w mojej ocenie jest "czarnym koniem" konkursu. 😀

4.3. Test na normalność rozkładu.

Metoda numer trzy. W 100% obiektywna, co nie znaczy, że w 100% skuteczna. Bazuje na teście statystycznym obarczonym pewnymi założenia dotyczącymi sposobu badania normalności. W przypadku tego testu użyte są statystyki skośności i kurtozy.

In [15]:
help(scipy.stats.normaltest)
Help on function normaltest in module scipy.stats.stats:

normaltest(a, axis=0, nan_policy='propagate')
    Test whether a sample differs from a normal distribution.
    
    This function tests the null hypothesis that a sample comes
    from a normal distribution.  It is based on D'Agostino and
    Pearson's [1]_, [2]_ test that combines skew and kurtosis to
    produce an omnibus test of normality.
    
    Parameters
    ----------
    a : array_like
        The array containing the sample to be tested.
    axis : int or None, optional
        Axis along which to compute test. Default is 0. If None,
        compute over the whole array `a`.
    nan_policy : {'propagate', 'raise', 'omit'}, optional
        Defines how to handle when input contains nan.
        The following options are available (default is 'propagate'):
    
          * 'propagate': returns nan
          * 'raise': throws an error
          * 'omit': performs the calculations ignoring nan values
    
    Returns
    -------
    statistic : float or array
        ``s^2 + k^2``, where ``s`` is the z-score returned by `skewtest` and
        ``k`` is the z-score returned by `kurtosistest`.
    pvalue : float or array
       A 2-sided chi squared probability for the hypothesis test.
    
    References
    ----------
    .. [1] D'Agostino, R. B. (1971), "An omnibus test of normality for
           moderate and large sample size", Biometrika, 58, 341-348
    
    .. [2] D'Agostino, R. and Pearson, E. S. (1973), "Tests for departure from
           normality", Biometrika, 60, 613-622
    
    Examples
    --------
    >>> from scipy import stats
    >>> pts = 1000
    >>> np.random.seed(28041990)
    >>> a = np.random.normal(0, 1, size=pts)
    >>> b = np.random.normal(2, 1, size=pts)
    >>> x = np.concatenate((a, b))
    >>> k2, p = stats.normaltest(x)
    >>> alpha = 1e-3
    >>> print("p = {:g}".format(p))
    p = 3.27207e-11
    >>> if p < alpha:  # null hypothesis: x comes from a normal distribution
    ...     print("The null hypothesis can be rejected")
    ... else:
    ...     print("The null hypothesis cannot be rejected")
    The null hypothesis can be rejected

Zgodnie z opisem testu: "This function tests the null hypothesis that a sample comes from a normal distribution.". A więc:

  • H0 = brak podstaw na odrzucenie hipotezy zerowej - przyjmuję, że zmienna pochodzi z rozkładu normalnego,
  • H1 = odrzucam hipotezę zerową - przyjmuję hipotezę alternatywn - zmienna nie pochodzi z rozkładu normalnego
In [16]:
results = []
for feature in wines.columns:
    alpha = 0.05
    p_value = scipy.stats.normaltest(wines[feature])[1]
    results.append([feature, p_value])
    if(p_value < alpha):
        print('Dla zmiennej \'' + feature +'\' odrzucam hipotezę zerową. Zmienna NIE POCHODZI z rozkładu normalnego. P-value:', p_value)
    else:
        print('Dla zmiennej \'' + feature +'\' nie wykryto podstaw do odrzucenia hipitezy zerowej. Zmienna POCHODZI z rozkładu normalnego. P-value:', p_value)
Dla zmiennej 'fixed acidity' odrzucam hipotezę zerową. Zmienna NIE POCHODZI z rozkładu normalnego. P-value: 0.0
Dla zmiennej 'volatile acidity' odrzucam hipotezę zerową. Zmienna NIE POCHODZI z rozkładu normalnego. P-value: 0.0
Dla zmiennej 'citric acid' odrzucam hipotezę zerową. Zmienna NIE POCHODZI z rozkładu normalnego. P-value: 2.6841653491247353e-128
Dla zmiennej 'residual sugar' odrzucam hipotezę zerową. Zmienna NIE POCHODZI z rozkładu normalnego. P-value: 0.0
Dla zmiennej 'chlorides' odrzucam hipotezę zerową. Zmienna NIE POCHODZI z rozkładu normalnego. P-value: 0.0
Dla zmiennej 'free sulfur dioxide' odrzucam hipotezę zerową. Zmienna NIE POCHODZI z rozkładu normalnego. P-value: 0.0
Dla zmiennej 'total sulfur dioxide' odrzucam hipotezę zerową. Zmienna NIE POCHODZI z rozkładu normalnego. P-value: 4.2111031353418164e-13
Dla zmiennej 'density' odrzucam hipotezę zerową. Zmienna NIE POCHODZI z rozkładu normalnego. P-value: 3.398816644391042e-245
Dla zmiennej 'pH' odrzucam hipotezę zerową. Zmienna NIE POCHODZI z rozkładu normalnego. P-value: 1.947508707989521e-39
Dla zmiennej 'sulphates' odrzucam hipotezę zerową. Zmienna NIE POCHODZI z rozkładu normalnego. P-value: 0.0
Dla zmiennej 'alcohol' odrzucam hipotezę zerową. Zmienna NIE POCHODZI z rozkładu normalnego. P-value: 1.9599603929050154e-98
Dla zmiennej 'quality' odrzucam hipotezę zerową. Zmienna NIE POCHODZI z rozkładu normalnego. P-value: 1.1606148581928246e-11

Jak się okazuje, w zbiorze żadna ze zmiennych nie cechuje się rozkładem normalnym. Spójrzmy jeszcze na szczegółowe statystyki testu.

In [17]:
podsumowanie = pd.DataFrame(results)
podsumowanie.columns = ['nazwa_zmiennej', 'p_value']
podsumowanie.set_index('nazwa_zmiennej', inplace = True)
podsumowanie.sort_values('p_value', ascending = False, inplace = True)
In [18]:
podsumowanie
Out[18]:
p_value
nazwa_zmiennej
quality 1.160615e-11
total sulfur dioxide 4.211103e-13
pH 1.947509e-39
alcohol 1.959960e-98
citric acid 2.684165e-128
density 3.398817e-245
fixed acidity 0.000000e+00
volatile acidity 0.000000e+00
residual sugar 0.000000e+00
chlorides 0.000000e+00
free sulfur dioxide 0.000000e+00
sulphates 0.000000e+00

Założony przeze mnie poziom istotności wynosi alpha = 0.05. Dla zmiennych o p_value > alpha możemy mówić o normalności. Wszystkie zmienne są bardzo daleko od granicy 0.05. Najbliżej jest zmienna quality, później total sulfur dioxide i pH, którą namaściłem cichym faworytem.

Przeczytaj również dwuczęściowy wpis, o tym jakich narzędzi używam w swojej pracy: część pierwsza, część druga.

5. Podsumowanie.

Pierwsze dwie metody pokazują, jak trudnym zadaniem jest określenie typu rozkładu bez dostępu do tetsu statystycznego. Pomimo pewnych przesłanek, żadna ze zmiennych nie okazała się cechować rozkładem normalnym.

W praktyce niestety sytuacja zazwyczaj wygląda podobnie. Niezbędne są testy, a następnie przekształcenia zmiennych mające na celu usuwanie skośności rozkładu i eliminację wartości odstających. ps. Ten ostatni temat przedstawię w kolejnym wpisie. 🙂

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 🙂

2 Komentarze

Dodaj komentarz

Twój adres email nie zostanie opublikowany.


*