3 metody wykrywania obserwacji odstających w Python

wykrywanie outlierów

W tym wpisie przedstawiam 3 sprawdzone metody wykrywania obserwacji odstających w Python. Zapraszam do przeczytania. 🙂

Czym są obserwacje odstające?

Już sama definicja "obserwacji odstającej" może przysporzyć sporo problemów. Według definicji dostępnej na Wikipedii jest to "obserwacja relatywnie odległa od pozostałych elementów próby". Co oznacza owa relatywność i ile wynosi? To jest już kwestia umowna i zmienia się w zależności od metody i definicji, na której dana metoda bazuje.

Po co wykrywać obserwacje odstające?

Wiele metod używanych w statystyce i uczeniu maszynowym jest wrażliwych na występowanie obserwacji odstających, tzw. outlierów. Poprzez wrażliwość należy rozumieć możliwość zaburzenia i zniekształcenia wyników uzyskanych za pomocą danych metod. Przykładem "wrażliwców" mogą być: regresja liniowa i sieci neuronowe.

Jak wykryć obserwacje odstające?

O tym, czy dana obserwacja jest odstająca decydować może wiele czynników. Najważniejszym z nich jest rozkład. W zależności od rozkładu zmiennej dobiera się odpowiednią metodę. W tym wpisie skupiam się na trzech podstawowych, które w dosłownie kilka minut pozwalają stwierdzić, czy dana zmienna zawiera wartości odstające, oraz ew. ile ich jest. 🙂

W części praktycznej mojej analizy skorzystam ze zbioru, którego używałem również w poprzednim wpisie dotyczącym badania normalności rozkładu.

Najważniejsze informacje o zbiorze:

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. Analiza obserwacji odstających.

4.1. Weryfikacja z użyciem podstawowych statystyk.

Pierwsze przesłanki można odczytać, spoglądając na statystyki dotyczące:

  • odchylenia standardowego,
  • mediany,
  • kwartyli,
  • percentyli.
In [7]:
wines.agg(['std'])
Out[7]:
fixed acidity volatile acidity citric acid residual sugar chlorides free sulfur dioxide total sulfur dioxide density pH sulphates alcohol quality
std 1.296434 0.164636 0.145318 4.757804 0.035034 17.7494 56.521855 0.002999 0.160787 0.148806 1.192712 0.873255
In [8]:
wines.quantile([0.01, 0.25, 0.5, 0.75, 0.99])
Out[8]:
fixed acidity volatile acidity citric acid residual sugar chlorides free sulfur dioxide total sulfur dioxide density pH sulphates alcohol quality
0.01 5.1 0.12 0.00 0.9 0.02100 4.0 11.0 0.98892 2.89 0.30 8.7 4.0
0.25 6.4 0.23 0.25 1.8 0.03800 17.0 77.0 0.99234 3.11 0.43 9.5 5.0
0.50 7.0 0.29 0.31 3.0 0.04700 29.0 118.0 0.99489 3.21 0.51 10.3 6.0
0.75 7.7 0.40 0.39 8.1 0.06500 41.0 156.0 0.99699 3.32 0.60 11.3 6.0
0.99 12.0 0.88 0.74 18.2 0.18616 77.0 238.0 1.00060 3.64 0.99 13.4 8.0

Doskonale widać, czego możemy się spodziewać po poszczególnych zmiennych. Dla przykładu zmienna "fixed acidity" osiąga odstające wartości na "górze", a więc w okolicach 99 percentyla jest znacznie dalej od mediany, niż po stronie przeciwnej (1 percentyl). Świadczy to o prawostronnie skośnym rozkładzie. Jeśli dodamy do tego:

  • odległości od mediany (1.9 dla 1 percentyla i 5 dla 99 percentyla),
  • odchylenie standardowe = 1.296,
  • rozstęp międzykwartylowy = 1.3,

to śmiało możemy stwierdzić, że znaczna część wartości odstających znajduje się w jej przypadku "u góry". Skąd moja pewność? Jak można sprawdzić to w sposób obiektywny? Przejdźmy do drugiej metody. 🙂

4.2. Reguła 1.5 wartości rozstępu międzykwartylowego - metoda analityczna oparta na statystykach.

Metoda ta jest aplikowalna dla dowolnej zmiennej numerycznej bez względu na rozkład. U jej podstaw leży założenie, że wszystkie "typowe" obserwacje leżą pomiędzy punktami wyznaczonymi przez odległość 1.5 IQR (ang. interquartile range):

  • "na lewo" od granicy pomiędzy pierwszym i drugim kwartylem,
  • "na prawo" od granicy pomiędzy trzecim i czwartym kwartylem.

Czym jest rozstęp międzykwartylowy?

Jest to różnica między pierwszym a trzecim kwartylem. Im większa różnica, tym większe zróżnicowanie względem danej zmiennej.

Wyznaczenie statystyk niezbędnych do aplikacji metody

Będziemy potrzebować trzech elementów: wartości zmiennej na granicy pierwszego i drugiego kwartyla, wartości zmiennej na granicy trzeciego i czwartego kwartyla oraz rozstępu.

In [9]:
q1 = wines.quantile(0.25) # wartości zmiennej na granicy pierwszego i drugiego kwartyla
q3 = wines.quantile(0.75) # wartości zmiennej na granicy trzeciego i czwartego kwartyla
iqr = q3 - q1 # rozstęp międzykwartylowy

Podejrzyjmy wartości rozstępu międzykwartylowego.

In [10]:
iqr
Out[10]:
fixed acidity            1.30000
volatile acidity         0.17000
citric acid              0.14000
residual sugar           6.30000
chlorides                0.02700
free sulfur dioxide     24.00000
total sulfur dioxide    79.00000
density                  0.00465
pH                       0.21000
sulphates                0.17000
alcohol                  1.80000
quality                  1.00000
dtype: float64

Teraz dzięki możliwościom jakie daje Pandas, zbudujmy tabelę z podsumowaniem.

In [11]:
low_boundary = (q1 - 1.5 * iqr)
upp_boundary = (q3 + 1.5 * iqr)
num_of_outliers_L = (wines[iqr.index] < low_boundary).sum()
num_of_outliers_U = (wines[iqr.index] > upp_boundary).sum()
outliers_15iqr = pd.DataFrame({'lower_boundary':low_boundary, 'upper_boundary':upp_boundary,'num_of_outliers_L':num_of_outliers_L, 'num_of_outliers_U':num_of_outliers_U})
In [12]:
outliers_15iqr
Out[12]:
lower_boundary upper_boundary num_of_outliers_L num_of_outliers_U
fixed acidity 4.450000 9.650000 7 350
volatile acidity -0.025000 0.655000 0 377
citric acid 0.040000 0.600000 279 230
residual sugar -7.650000 17.550000 0 118
chlorides -0.002500 0.105500 0 286
free sulfur dioxide -19.000000 77.000000 0 62
total sulfur dioxide -41.500000 274.500000 0 10
density 0.985365 1.003965 0 3
pH 2.795000 3.635000 7 66
sulphates 0.175000 0.855000 0 191
alcohol 6.800000 14.000000 0 3
quality 3.500000 7.500000 30 198

Jak czytać tabelę?

  • lower_boundary i upper_boundary - są to wartości graniczne, czyli wcześniej wyznaczone q1 i q3,
  • num_of_outliers_L - jest to liczba wartości odstających poniżej granicy wyznaczonej przez q1 - iqr (przyrostek L od ang. lower),
  • num_of_outliers_U - jest to liczba wartości odstających powyżej granicy wyznaczonej przez q3 + iqr (przyrostek U od ang. upper),

Zgodnie z moją intuicją i wartościami z punktu 4.1. potwierdza się fakt, że znaczna część wartości odstających dla zmiennej "fixed acidity" leży powyżej trzeciego kwartyla.

4.3. Reguła 1.5 wartości rozstępu międzykwartylowego - metoda wizualna.

Regułę 1.5 IQR łatwo można zobrazować z pomocą wykresu pudełkowego. Podłużna skrzynka na jego środku to obszar pomiędzy pierwszym a trzecim kwartylem. Jej rozmiar odnosi się do rozstępu międzykwartylowego. 🙂 Dalej od środka widoczne są wąsy, których zakończeniem są granice dla typowych obserwacji. Wszystko, co znajduje się na zewnątrz poza granicami to zgodnie z regułą 1.5 IQR obserwacje odstające, które zostały oznaczone kropkami.

Czemu długość wąsów nie jest równa?

Przyczyna jest prosta: w momencie, gdy nie w zbiorze nie ma żadnej obserwacji odstającej (dla danej zmiennej i po wybranej stronie), wąs kończy się na największej, bądź najmniejszej zaobserwowanej wartości.

Wizualizacja zmiennych na wykresie pudełkowym

Skala, na jakiej umieszczone są poszczególne zmienne, znacznie się różni (np średnia 'volatile acidity' wynosi ok 0.33, a średnia 'total sulfur dioxide' wynosi ok. 116), dlatego jest dosyć trudno zobrazować rozkład zmiennych na jednym wykresie. Decyduję się więc podzielić zmienne na trzy grupy i oddzielne wykresy.

In [13]:
melted_wines_df_1 = pd.melt(wines, value_vars=wines.drop(['total sulfur dioxide', 'free sulfur dioxide', 'residual sugar', 'fixed acidity', 'alcohol', 'quality'], axis = 1).columns, var_name=['feature_name'], value_name = 'value')
melted_wines_df_2 = pd.melt(wines, value_vars=wines[['fixed acidity', 'alcohol', 'quality', 'residual sugar', 'fixed acidity', 'alcohol']].columns, var_name=['feature_name'], value_name = 'value')
melted_wines_df_3 = pd.melt(wines, value_vars=wines[['total sulfur dioxide', 'free sulfur dioxide']].columns, var_name=['feature_name'], value_name = 'value')
In [14]:
plt.figure(figsize=(8,5))
sns.set(font_scale=1.4)
sns.boxplot(data = melted_wines_df_1, y = 'feature_name', x = 'value', palette = 'Blues_d').set(title = 'Rozkład zmiennych - grupa 1', ylabel = 'nazwy zmiennych')
plt.show()
In [15]:
plt.figure(figsize=(8,5))
sns.set(font_scale=1.4)
sns.boxplot(data = melted_wines_df_2, y = 'feature_name', x = 'value', palette = 'Blues_d').set(title = 'Rozkład zmiennych - grupa 2', ylabel = 'nazwy zmiennych')
plt.show()
In [16]:
plt.figure(figsize=(8,5))
sns.set(font_scale=1.4)
sns.boxplot(data = melted_wines_df_3, y = 'feature_name', x = 'value', palette = 'Blues_d').set(title = 'Rozkład zmiennych - grupa 3', ylabel = 'nazwy zmiennych')
plt.show()

Na powyższych wykresach widać, że każda zmienna zawiera jakieś obserwacje odstające. Jedne są mniej, a inne mniej widoczne. W niektórych przypadkach, jak np. "residual sugar", widać outlier-a, który wręcz wydaje się błędem w próbie.

4.4. Reguła trzech sigm.

Metoda ta jest aplikowalna tylko dla zmiennych pochodzących z rozkładu normalnego. W poprzednim wpisie ustaliłem, że żadna ze zmiennych nie cechuje się rozkładem normalnym. Normalność rozkładu nie jest jednak cechą zero-jedynkową. Nawet najlepsze przykłady zmiennych o rozkładzie normalnym nie cechują się 100% normalnością - np. wzrost człowieka, który nigdy nie będzie ujemny. 🙂

Na potrzeby tej analizy założę, że zmienne: "total sulfur dioxide" i "pH" posiadają rozkład normalny.

Sigma odnosi się do litery z greckiego alfabetu, poprzez którą oznaczane jest odchylenie standardowe. Reguła trzech sigm mówi, że 99,7% wartości danej zmiennej leży w odległości mniejszej lub równej wartości trzech odchyleń standardowych od wartości oczekiwanej. Wszystkie obserwacje nie spełniające tej reguły możemy zatem nazwać obserwacjami nietypowymi. Spróbujmy zatem zrobić podobne podsumowanie jak dla reguły 1.5 IQR. 🙂

In [17]:
zmienne = ['total sulfur dioxide', 'pH']
sigma = wines[zmienne].std()
srednia = wines[zmienne].mean()
In [18]:
sigma
Out[18]:
total sulfur dioxide    56.521855
pH                       0.160787
dtype: float64
In [19]:
srednia
Out[19]:
total sulfur dioxide    115.744574
pH                        3.218501
dtype: float64
In [20]:
low_boundary = (srednia - 3 * sigma)
upp_boundary = (srednia + 3 * sigma)
num_of_outliers_L = (wines[zmienne] < low_boundary).sum()
num_of_outliers_U = (wines[zmienne] > upp_boundary).sum()
outliers_3sigma = pd.DataFrame({'lower_boundary':low_boundary, 'upper_boundary':upp_boundary,'num_of_outliers_L':num_of_outliers_L, 'num_of_outliers_U':num_of_outliers_U})
In [21]:
outliers_3sigma
Out[21]:
lower_boundary upper_boundary num_of_outliers_L num_of_outliers_U
total sulfur dioxide -53.820989 285.310138 0 8
pH 2.736139 3.700862 1 32

Przeczytaj również wpis, dotyczący analizy normalności rozkładu w Python: 3 metody analizy normalności rozkładu w Python.

5. Usuwanie obserwacji odstających ze zbioru.

W części przypadków będziemy chcieli pozbyć się obserwacji odstających ze zbioru. Poniżej przedstawia Ci sposób, w jaki mozna to zrobić korzystając z przygotowanych tabel pomocniczych: outliers_15iqr, outliers_3sigma.

In [22]:
wines_without_outliers = wines.copy()
In [23]:
print('Rozmiar zbioru z obserwacjami odstającymi:', wines_without_outliers.shape[0])
Rozmiar zbioru z obserwacjami odstającymi: 6497
In [24]:
for row in outliers_15iqr.iterrows():
    wines_without_outliers = wines_without_outliers[(wines_without_outliers[row[0]] >= row[1]['lower_boundary']) & (wines_without_outliers[row[0]] <= row[1]['upper_boundary'])]
In [25]:
for row in outliers_3sigma.iterrows():
    wines_without_outliers = wines_without_outliers[(wines_without_outliers[row[0]] >= row[1]['lower_boundary']) & (wines_without_outliers[row[0]] <= row[1]['upper_boundary'])]
In [26]:
print('Rozmiar zbioru po usunięciu obserwacji odstających:', wines_without_outliers.shape[0])
Rozmiar zbioru po usunięciu obserwacji odstających: 4840

6. Podsumowanie.

To by było na tyle, jeśli chodzi o analizę obserwacji odstających. 🙂 Oczywiście powyższe metody nie wyczerpują tematu. W zasadzie to są dopiero wstępem do zagadnienia znacznie obszerniejszego: wykrywania zjawisk rzadkich. Za jakiś czas chciałbym również i ten temat poruszyć na blogu.

Mam nadzieję, że wpis przypadł Ci do gustu, a metody filtracji obserwacji odstających oparte o tabele pomocnicze nieco zautomatyzują Twoją pracę. 🙂

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 🙂

4 Komentarze

  1. Fantastyczny wpis. Swoją drogą, jakiś czas temu sam korzystałem z metody 1.5 IQR przy wykrywaniu obserwacji odstających dla danych HR-ych.

    Oj, coś czuję, że ten blog zagości na mojej liście codziennie przeglądanych serwisów. Dobrze jest poczytać „wypociny” zdolnych ludzi. Pozdrawiam!

Dodaj komentarz

Twój adres email nie zostanie opublikowany.


*