Czyszczenie danych#

Definicja

Czyszczenie danych jest to proces polegający na identyfikacji i naprawie różnego rodzaju błędów w danych.

Info

Czyszczenie danych obejmuje zazwyczaj następujące elementy:

  • czyszczenie formatów danych

  • czyszczenie kodowania zmiennych kategorycznych

  • czyszczenie nieprawidłowych wartości zmiennych numerycznych

  • czyszczenie wartości odstających

  • usuwanie duplikatów

  • imputacja wartości brakujących

Uwaga!

Czyszczenie danych stanowi jeden z ważniejszych etapów w procesie budowy modeli uczenia maszynowego, dlatego należy mu poświęcić dużo uwagi. Zazwyczaj stanowi też jedną z najbardziej czasochłonnych faz i może wymagać czasem wielu iteracji polegających na identyfikacji i naprawie kolejnych nieprawidłowości, a następnie analizy impaktu zmian na wyniki modelu. Należy również pamiętać, że czyszczenie danych musi być poprzedzone ich dobrym zrozumieniem, inaczej nie będzie możliwe uzyskanie danych poprawnych pod kątem logiki biznesowej.

W tym notebooku przedstawione zostaną 2 przypadki - czyszczenie danych tabularycznych oraz czyszczenie szeregu czasowego. Najpierw jednak import bibliotek i wygenerowanie danych.

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

Wygenerowanie danych#

Posłużymy się tutaj dwoma przykładami danych wymagających wyczyszczenia - danymi tabularycznymi reprezentującymi dane pacjentów oraz szeregiem czasowym pokazującym sprzedaż produktu o silnej sezonowości.

def generate_ts_data(month_coef=100, week_coef=10, day_coef=1, random_coef =10000):
    dates = pd.date_range(start="2016-01-01", end="2020-12-31", freq ="D")
    df = pd.DataFrame(dates, columns=["SalesDate"])
    df["Month"] = df.SalesDate.dt.month
    df["Week"] = df.SalesDate.dt.isocalendar().week
    df["Year"] = df.SalesDate.dt.year
    df["WeekDay"] = df.SalesDate.dt.dayofweek+1
    df["SLSU"] = ((np.power(6.5-df.Month,2))*month_coef + (np.power(26.5-df.Week,2))*week_coef +df.WeekDay*day_coef)*np.sqrt(df["Year"]-df["Year"].min()+1)
    df["SLSU"] = np.where((df["SalesDate"]>="2019-11-01")&(df["SalesDate"]<="2020-03-01"), 0, (df["SLSU"]))
    df["SLSU"] = df["SLSU"] + np.random.choice(a=[0,df["SLSU"].max()*0.2], size=len(df),p=[0.998, 0.002])
    return df.loc[:,["SalesDate","SLSU"]]
def generate_tabular_data(len_data=300):
    id = range(len_data)
    age = np.random.randint(low=5,high=100,size=len_data)
    weight = np.round(np.random.normal(loc=60,scale=10,size=len_data))
    height = np.round(np.random.normal(loc=160,scale=12,size=len_data))
    bmi = np.round(weight/(height/100)**2,1)
    city = np.random.choice(["Gdańsk","Gdynia", "Wejherowo", "Kościerzyna","Gdansk", "Koscierzyna"], p=[0.4, 0.2, 0.1, 0.1, 0.1, 0.1], size=len_data)
    num_covid_tests = np.random.randint(low=0,high=5,size=len_data)
    num_positive_tests = (num_covid_tests - num_covid_tests *np.random.choice(np.arange(6),p=[0.65, 0.2, 0.1, 0.02, 0.02, 0.01], size=len_data)).astype(int) + np.random.choice(a=[0,1], size=len_data,p=[0.9, 0.1])
    sex = np.random.choice(["M", "F", "N"], size=len_data, p=[0.45, 0.45, 0.1])
    dict_data = {"unique_id":id, "age":age, "weight":weight, "height":height, "bmi":bmi, "city":city,
                "num_covid_tests":num_covid_tests, "num_positive_tests":num_positive_tests, "sex":sex}
    df = pd.DataFrame(dict_data)
    df["num_covid_tests"] = np.where(df["num_covid_tests"]==0, np.nan, df["num_covid_tests"])
    df["weight"] = df["weight"].astype(str)
    df["height"] = df["height"] * np.random.choice([1,2], p =[0.99, 0.01], size=len_data)
    df["bmi"] = df["bmi"] * np.random.choice([1,np.random.rand()], p =[0.9, 0.1], size=len_data)
    df["age"] = np.where(df["age"]>65,np.nan, df["age"])
    df["city"] = np.select([df["bmi"]>30,df["bmi"]<=20, df["bmi"]<=30],[df["city"].str.lower(), df["city"].str.upper(), df["city"]])
    return pd.concat([df, df.sample(frac=0.05)])

Czyszczenie danych tabularycznych#

df =generate_tabular_data(len_data=300)

Pierwszym krokiem w czyszczeniu danych powinno zawsze być przyjrzenie się wycinkowi danych oraz podstawowym informacjom o naszej ramce danych:

df.head()
unique_id age weight height bmi city num_covid_tests num_positive_tests sex
0 0 NaN 76.0 154.0 32.0 gdynia 2.0 -2 M
1 1 NaN 78.0 153.0 33.3 gdańsk NaN 1 F
2 2 42.0 72.0 151.0 31.6 kościerzyna 2.0 -7 M
3 3 NaN 65.0 147.0 30.1 gdynia 3.0 3 F
4 4 32.0 56.0 157.0 22.7 Gdansk 3.0 3 F
df.info()
<class 'pandas.core.frame.DataFrame'>
Index: 315 entries, 0 to 89
Data columns (total 9 columns):
 #   Column              Non-Null Count  Dtype  
---  ------              --------------  -----  
 0   unique_id           315 non-null    int64  
 1   age                 203 non-null    float64
 2   weight              315 non-null    object 
 3   height              315 non-null    float64
 4   bmi                 315 non-null    float64
 5   city                315 non-null    object 
 6   num_covid_tests     260 non-null    float64
 7   num_positive_tests  315 non-null    int32  
 8   sex                 315 non-null    object 
dtypes: float64(4), int32(1), int64(1), object(3)
memory usage: 23.4+ KB

Formaty danych#

Najczęściej występującym problemem jest formatowanie zmiennych numerycznych jako kategorycznych, tutaj widzimy to np. dla zmiennej weight, może to wynikać również z nieprawidłowych wartości występujących w takich kolumnach.

df.select_dtypes("object")
weight city sex
0 76.0 gdynia M
1 78.0 gdańsk F
2 72.0 kościerzyna M
3 65.0 gdynia F
4 56.0 Gdansk F
... ... ... ...
260 72.0 gdynia M
153 68.0 gdynia F
137 46.0 GDANSK F
58 70.0 Kościerzyna M
89 68.0 Gdansk M

315 rows × 3 columns

df.select_dtypes("float64")
age height bmi num_covid_tests
0 NaN 154.0 32.000000 2.0
1 NaN 153.0 33.300000 NaN
2 42.0 151.0 31.600000 2.0
3 NaN 147.0 30.100000 3.0
4 32.0 157.0 22.700000 3.0
... ... ... ... ...
260 NaN 154.0 30.400000 2.0
153 44.0 145.0 32.300000 2.0
137 NaN 157.0 14.686927 3.0
58 35.0 157.0 28.400000 3.0
89 35.0 171.0 23.300000 2.0

315 rows × 4 columns

Ponadto niektóre z powyższych zmiennych np. num_covid_tests wydają się bardziej pasować do formatu int niż float, jednak należy pamiętać, że zmienne zawierające brakujące wartości są automatycznie konwertowane na format float, dlatego na razie ograniczymy się do konwersji dla zmiennej weight.

columns_to_convert = ["weight"]
df[columns_to_convert] = df[columns_to_convert].astype("float64")
df.info()
<class 'pandas.core.frame.DataFrame'>
Index: 315 entries, 0 to 89
Data columns (total 9 columns):
 #   Column              Non-Null Count  Dtype  
---  ------              --------------  -----  
 0   unique_id           315 non-null    int64  
 1   age                 203 non-null    float64
 2   weight              315 non-null    float64
 3   height              315 non-null    float64
 4   bmi                 315 non-null    float64
 5   city                315 non-null    object 
 6   num_covid_tests     260 non-null    float64
 7   num_positive_tests  315 non-null    int32  
 8   sex                 315 non-null    object 
dtypes: float64(5), int32(1), int64(1), object(2)
memory usage: 23.4+ KB

Atrybuty zmiennych kategorycznych#

Zmienne kategoryczne są szczególnie narażone na błędy wynikające z literówek lub nieprawidłowego kodowania, co prowadzi do błędnego zwiększenia liczby ich unikalnych atrybutów. Można to zaobserwować dla przedstawionych poniżej zmiennych:

for column in df.select_dtypes("object").columns.tolist():
    print("*"*20)
    print(column.upper())
    print("*"*20)
    print(df[column].value_counts())
********************
CITY
********************
city
Gdańsk         71
GDAŃSK         44
Gdynia         33
Gdansk         24
Kościerzyna    19
gdańsk         18
Koscierzyna    16
GDYNIA         16
KOSCIERZYNA    15
Wejherowo      14
gdynia         12
GDANSK          9
WEJHEROWO       6
gdansk          4
kościerzyna     4
KOŚCIERZYNA     4
koscierzyna     4
wejherowo       2
Name: count, dtype: int64
********************
SEX
********************
sex
F    143
M    141
N     31
Name: count, dtype: int64

Jak widać zmienna city jest obarczona licznymi błędami wynikającymi z literówek oraz używania róznej wielkości liter. Tutaj błędy mogą stosunkowo łatwo zostać naprawione, jednak najczęściej do takiej poprawy niezbędna jest wiedza biznesowa odnośnie tego jakie atrybuty powinna dana zmienna posiadać i co one oznaczają.

df["city"] = df["city"].str.capitalize()
df["city"] = df["city"].replace({"Gdansk":"Gdańsk", "Koscierzyna":"Kościerzyna"})
df["city"].value_counts()
city
Gdańsk         170
Kościerzyna     62
Gdynia          61
Wejherowo       22
Name: count, dtype: int64

W przypadku zmiennej oznaczającej płeć pacjenta poza oczekiwanymi atrybutami M i F występuje jeszcze jedno oznaczenie - N. Przy założeniu, że nie ma żadnych wytycznych biznesowych określających, że taka wartość w tej kolumnie oznacza np. brak danych, musimy potraktować tą wartość jako błędną i oznaczyć jako brakującą. Tutaj zamienimy tę wartość na pustą, a wypełnieniem jej zajmiemy się w kolejnych sekcjach.

df["sex"] = np.where(df["sex"]=="N",np.nan, df["sex"])
df["sex"].value_counts(dropna=False)
sex
F      143
M      141
NaN     31
Name: count, dtype: int64

Weryfikacja duplikatów#

Kolejnym istotnym krokiem jest weryfikacja czy w danych nie występują duplikaty. Aby zbadać występowanie duplikatów dla danych niezbędna jest wiedza jakie kolumny wyznaczają unikalne kombinacje. W tym wypadku zakładamy, że odpowiada za to kolumna unique_id, zatem wystarczy porównać liczbę jej unikalnych wartości z liczebnością ramki danych.

df["unique_id"].nunique(), df.shape[0]
(300, 315)

Jak widać w danych występują duplikaty, do ich usunięcia najprościej wykorzystać funkcję drop_duplicates, wybierając jako subset unique_id. Funkcja daje również możliwość wyboru, który rekord zachować przy wykryciu duplikatów, załóżmy, że tutaj interesuje nas ostatni rekord bo reprezentuje np. najnowszy wpis w danych.

df = df.drop_duplicates(subset=["unique_id"], keep='last')
df.info()
<class 'pandas.core.frame.DataFrame'>
Index: 300 entries, 0 to 89
Data columns (total 9 columns):
 #   Column              Non-Null Count  Dtype  
---  ------              --------------  -----  
 0   unique_id           300 non-null    int64  
 1   age                 194 non-null    float64
 2   weight              300 non-null    float64
 3   height              300 non-null    float64
 4   bmi                 300 non-null    float64
 5   city                300 non-null    object 
 6   num_covid_tests     247 non-null    float64
 7   num_positive_tests  300 non-null    int32  
 8   sex                 271 non-null    object 
dtypes: float64(5), int32(1), int64(1), object(2)
memory usage: 22.3+ KB

Sprawdzanie zmiennych numerycznych#

Kolejnym krokiem jest czyszczenie zmiennych numerycznych, co obejmować będzie identyfikację i naprawianie wartości nieprawidłowych z punktu widzenia logiki biznesowej oraz wartości odstających.

df.describe()
unique_id age weight height bmi num_covid_tests num_positive_tests
count 300.000000 194.000000 300.000000 300.000000 300.000000 247.000000 300.000000
mean 149.500000 35.932990 60.126667 163.023333 23.307506 2.461538 0.746667
std 86.746758 17.292085 9.922305 23.733247 5.664738 1.114182 2.625287
min 0.000000 5.000000 33.000000 126.000000 11.400000 1.000000 -16.000000
25% 74.750000 21.250000 53.750000 152.000000 19.231673 1.000000 0.000000
50% 149.500000 35.000000 60.000000 160.500000 22.350000 2.000000 1.000000
75% 224.250000 51.000000 66.000000 169.000000 26.925000 3.000000 2.250000
max 299.000000 65.000000 93.000000 354.000000 42.800000 4.000000 5.000000

Analizując powyższe statystyki dla zmiennych numerycznych możemy łatwo zauważyć występowanie wartości nieprawidłowych w postaci ujemnych wartości dla zmiennej num_positive_tests, która z założenia powinna być nieujemna. Ponadto można tutaj łatwo zauważyć występowanie wartości odstających dla zmiennej height. Dokładny proces identyfikacji wartości odstających został bardziej szczegółowo omówiony w osobnym notebooku, zatem tutaj ograniczymy się do oznaczenia takich wartosci jako brakujące, przyjmując arbitralnie wielkość progu.

df["height"] = np.where(df["height"]>=230,np.nan, df["height"])
df["num_positive_tests"] = np.where(df["num_positive_tests"]<0,np.nan, df["num_positive_tests"])
df.describe()
unique_id age weight height bmi num_covid_tests num_positive_tests
count 300.000000 194.000000 300.000000 295.000000 300.000000 247.000000 257.000000
mean 149.500000 35.932990 60.126667 160.383051 23.307506 2.461538 1.536965
std 86.746758 17.292085 9.922305 12.153177 5.664738 1.114182 1.476243
min 0.000000 5.000000 33.000000 126.000000 11.400000 1.000000 0.000000
25% 74.750000 21.250000 53.750000 152.000000 19.231673 1.000000 0.000000
50% 149.500000 35.000000 60.000000 160.000000 22.350000 2.000000 1.000000
75% 224.250000 51.000000 66.000000 168.000000 26.925000 3.000000 3.000000
max 299.000000 65.000000 93.000000 203.000000 42.800000 4.000000 5.000000

Relacje pomiędzy zmiennymi#

Oprócz analizy pojedynczych zmiennych czyszczenie danych powinno obejmować również analizę relacji logicznych pomiędzy poszczególnymi zmiennymi, wynikające z logiki biznesowej. Takie relacje mogą obejmować zarówno zestawienie wartości pojedynczych zmiennych jak i weryfikacje zmiennych wyliczanych na ich podstawie.

Przykładem takiej relacji w poniższych danych są zmienne num_covid_tests i num_positive_tests, za nieprawidłowe należy uznać wszystkie rekordy, dla których ta pierwsza zmienna jest mniejsza od drugiej.

df[df["num_covid_tests"]<df["num_positive_tests"]]
unique_id age weight height bmi city num_covid_tests num_positive_tests sex
16 16 50.0 62.0 138.0 32.6 Gdańsk 2.0 3.0 F
62 62 32.0 73.0 133.0 41.3 Gdańsk 2.0 3.0 F
65 65 25.0 43.0 149.0 19.4 Gdańsk 3.0 4.0 F
74 74 30.0 67.0 150.0 29.8 Gdańsk 1.0 2.0 F
102 102 49.0 60.0 140.0 30.6 Gdańsk 3.0 4.0 M
135 135 59.0 46.0 169.0 16.1 Kościerzyna 2.0 3.0 M
151 151 42.0 61.0 161.0 23.5 Kościerzyna 3.0 4.0 NaN
194 194 NaN 70.0 154.0 29.5 Kościerzyna 3.0 4.0 M
232 232 NaN 54.0 149.0 24.3 Gdańsk 1.0 2.0 F
242 242 26.0 62.0 174.0 20.5 Gdańsk 1.0 2.0 F
258 258 NaN 65.0 NaN 20.7 Gdańsk 4.0 5.0 M
269 269 64.0 72.0 159.0 28.5 Kościerzyna 1.0 2.0 M
277 277 57.0 70.0 NaN 28.4 Gdynia 1.0 2.0 F
297 297 50.0 78.0 152.0 33.8 Gdańsk 4.0 5.0 F
14 14 NaN 63.0 172.0 21.3 Gdańsk 3.0 4.0 F

Przy założeniu, że wiemy iż bardziej wiarygodne są zapisy wartości zmiennej num_covid_tests można dokonać poprawy jak poniżej, alternatywą byłoby przypisanie takim przypadkom wartości pustej dla zmiennej num_positive_tests, ewentualnie usunięcie takich rekordów.

df["num_positive_tests"] = np.where(df["num_positive_tests"]>df["num_covid_tests"], df["num_covid_tests"], df["num_positive_tests"])
df[df["num_covid_tests"]<df["num_positive_tests"]]
unique_id age weight height bmi city num_covid_tests num_positive_tests sex

Zmienną wyliczoną na podstawie innych zmiennych reprezentuje tutaj bmi, znając formułę można zweryfikować czy wszędzie jest ona wyliczona prawidłowo:

df.loc[np.round(df["bmi"])!=np.round(df["weight"]/(df["height"]/100)**2)]
unique_id age weight height bmi city num_covid_tests num_positive_tests sex
15 15 NaN 86.0 153.0 28.824077 Kościerzyna 4.0 0.0 M
20 20 23.0 50.0 178.0 12.409276 Gdańsk 1.0 1.0 F
25 25 NaN 59.0 159.0 18.299755 Kościerzyna 3.0 3.0 F
31 31 39.0 52.0 155.0 16.964579 Gdańsk 2.0 2.0 NaN
32 32 NaN 57.0 161.0 17.278738 Gdańsk 4.0 4.0 M
34 34 49.0 79.0 174.0 20.498867 Gdańsk 1.0 NaN M
41 41 53.0 55.0 168.0 19.500000 Gdynia 2.0 0.0 F
43 43 53.0 48.0 164.0 13.980070 Gdańsk 1.0 0.0 M
49 49 11.0 69.0 160.0 21.205724 Gdynia 2.0 0.0 F
61 61 49.0 69.0 176.0 17.514357 Wejherowo NaN 0.0 F
67 67 NaN 56.0 175.0 14.372769 Gdynia 2.0 2.0 F
69 69 NaN 66.0 182.0 15.629404 Gdańsk 4.0 4.0 F
75 75 NaN 65.0 176.0 16.493341 Kościerzyna NaN 0.0 M
76 76 NaN 56.0 NaN 18.064135 Gdynia 4.0 4.0 F
86 86 55.0 75.0 138.0 30.944649 Gdańsk 2.0 2.0 M
91 91 18.0 66.0 135.0 28.431378 Wejherowo 1.0 1.0 M
97 97 NaN 50.0 149.0 22.500000 Kościerzyna 1.0 1.0 F
101 101 24.0 59.0 163.0 17.435818 Gdańsk 2.0 0.0 M
107 107 51.0 46.0 146.0 16.964579 Gdańsk 1.0 1.0 M
119 119 12.0 50.0 149.0 22.500000 Gdynia 4.0 4.0 NaN
121 121 NaN 57.0 167.0 16.022103 Gdańsk 3.0 3.0 M
122 122 11.0 65.0 163.0 19.242231 Kościerzyna NaN 0.0 M
127 127 19.0 59.0 168.0 16.414801 Wejherowo 3.0 NaN F
131 131 59.0 71.0 165.0 20.498867 Gdynia 2.0 2.0 M
134 134 NaN 62.0 161.0 18.770993 Kościerzyna 4.0 4.0 M
140 140 47.0 61.0 148.0 21.834042 Gdańsk 4.0 1.0 M
154 154 NaN 60.0 145.0 28.500000 Gdańsk 3.0 3.0 M
165 165 NaN 68.0 154.0 22.540899 Wejherowo 2.0 2.0 M
195 195 NaN 76.0 NaN 32.900000 Gdańsk 2.0 NaN NaN
225 225 NaN 55.0 153.0 23.500000 Gdańsk 3.0 NaN M
243 243 NaN 39.0 142.0 15.158166 Gdynia 2.0 2.0 F
251 251 NaN 43.0 172.0 14.500000 Wejherowo 1.0 0.0 F
253 253 36.0 74.0 146.0 27.253282 Gdańsk 4.0 NaN NaN
258 258 NaN 65.0 NaN 20.700000 Gdańsk 4.0 4.0 M
277 277 57.0 70.0 NaN 28.400000 Gdynia 1.0 1.0 F
278 278 62.0 62.0 170.0 21.500000 Gdańsk 3.0 NaN F
279 279 NaN 64.0 140.0 25.682488 Kościerzyna 2.0 2.0 F
54 54 13.0 62.0 145.0 29.500000 Kościerzyna 3.0 NaN F
117 117 64.0 66.0 NaN 27.500000 Gdynia NaN 0.0 F
103 103 5.0 65.0 166.0 18.535374 Gdańsk 4.0 0.0 NaN
137 137 NaN 46.0 157.0 14.686927 Gdańsk 3.0 3.0 F

Jak widać istnieją rekordy, dla których wartość nie zgadza się z formułą, wprowadzamy zatem poprawki:

df["bmi"] = df["weight"]/(df["height"]/100)**2

Imputacja wartości brakujących#

Info

Rodzaje wartości brakujących

  • MCAR - Missing Completely At Random

  • MAR - Missing At Random

  • MNAR - Missing Not At Random

Definicja

MCAR wartości brakujące powstały w sposób całkowicie losowy, co oznacza, że nie istnieje związek pomiędzy występowaniem wartości brakujących w danej zmiennej, a ich występowaniem w pozostałych zmiennych ani pomiędzy wartościami jakiejkolwiek zmiennej, a wartościami brakującymi rozważanej zmiennej.

Definicja

MAR wartości brakujące występują losowo, ale można się dopatrzeć związków pomiędzy ich występowaniem, a wartościami którejś z pozostałych zmiennych.

Definicja

MNAR wartości brakujące występują w sposób systematyczny, co ma związek np. ze sposobem zbierania danych, pozostałe zmienne nie mówią nic na podstawie czego można by dokonać imputacji tych wartości, natomiast prawdopodobieństwo wystąpienia braku danych jest wprost związane z wartościami omawianej zmiennej.

Na początek importujemy bibliotekę missingno, która posiada szereg przydatnych wizualizacji do zrozumienia natury wartości brakujących.

import missingno

Podstawowa wizualizacja daje intuicję na temat liczby wartości brakujących, a co więcej pokazuje też relacje pomiędzy ich występowanie w poszczególnych zmiennych.

missingno.matrix(df)
<Axes: >
../_images/51fede895c3fbe7feb44a5ac80ea342c2a99611343d4027ee85bd38acce84da8.png

Tutaj można zauważyć relację między zmiennymi height i bmi, co jest oczywiście konsekwencją tego, że bmi jest liczone w oparciu o height. Ponadto widać też, że wartości brakujące zdecydowanie najczęściej występują dla zmiennej age.

Można również zaobserwować, że dla zmiennej age nie występują wartości wyższe niż 65 lat, zatem sposób potraktowania wartości brakujących zależy od istnienia informacji o maksymalnym wieku pacejentów w tym zbiorze danych - gdyby np okazało, że próba obejmuje również starsze osoby, ale przez jakiś błąd przy wprowadzaniu danych ich wiek się nie zapisał, należałoby potraktować taki przypadek jako MNAR i zastąpić wartością specjalną lub oznaczyć w osobnej kolumnie, w przeciwnym razie będzie to MCAR lub MAR.

Lepszemu wglądowi w to czy wartości brakujące dla danej zmiennej zależą od innych zmiennych może służyć również przedstawienie powyższej wizualizacji po posortowaniu wg wybranej zmiennej

missingno.matrix(df.sort_values("weight"))
<Axes: >
../_images/900268dee4a7c52d7039ec89f0a543a557afea8006fec417943633a4ff227f79.png

Znalezieniu relacji może służyć również heatmap macierzy korelacji.

missingno.heatmap(df, cmap='rainbow')
<Axes: >
../_images/16b88082a9e9b7e37eed70d4e7abc556bece270487d3af99fa2c42a26f16a7f6.png

Przyjmijmy, że faktycznie wystąpił systematyczny błąd przy zapisywaniu zmiennej age zatem reprezentuje ona MNAR, zmienna bmi w oczywisty sposób będzie MAR jako, że odwołuje się teraz wprost do innej zmiennej, pozostałe zmienne jeśli nie mamy jakiejś dodatkowej informacji co do natury powstania wartości brakujących należy uznać za MCAR

Metody imputacji brakujących wartości#

Uwaga!

Wszystkie przekształcenia wykorzystujące informacje o rozkładach cech należy implementować najpierw na zbiorze treningowym, a następnie w oparciu o rozkład ze zbioru treningowego - na zbiorze testowym. Inaczej wykorzystujemy informacje ze zbioru testowego i przestaje on być niezależny.

imputacja wartością specjalną
  • każdej brakującej obserwacji danej zmiennej przypisujemy np -1, -999 lub “N/A”, świadomie stosując wartość niewystępującą w obserwacjach bez brakującej wartości

  • przede wszystkim dla typu MNAR, gdzie zazwyczaj jest to jedyne prawidłowe podejście

  • można zastosować tutaj również 0 jeśli w naturalny sposób oznacza ono brak danego zjawiska i logika biznesowa nakazuje tak interpretować wartość brakującą

imputacja wartością stałą
  • każdej brakującej obserwacji danej zmiennej przypisujemy tę samą wartość, w odróżnieniu od poprzedniej metody jest ona wyliczona w oparciu o dane treningowe

  • imputacja średnią - zachowuje średnią z danych, ale zaniża wariancję, może być narażona na wpływ wartości odstających

  • imputacja medianą - wolna od wpływu wartości odstających, ale wpływa na średnią i wariancję

  • imputacja modą - najczęściej stosowana dla zmiennych kategorycznych, jeśli nie mają charakteru MNAR

imputacja wg grup
  • podobna do opisanej wyżej imputacji wartością stałą lecz realizowana osobno dla każdej zdefiniowanej w danych grupy

  • to jak dobrać grupy może wynikać z wiedzy biznesowej lub z EDA

Imputacja wartością losową
  • w odróżnieniu od omówienych wcześniej metod, nie używamy tu tej samej wartości do wypełniania wszystkich obserwacji

  • może polegać na losowaniu wartości spośród niepustych obserwacji danej zmiennej lub dopasowaniu do niej rozkładu statystycznego i losowaniu z rozkładu

Imputacja na podstawie kolejności
  • do stosowania w szeregach czasowych

  • może obejmować zastąpienie brakujących obserwacji za pomocą poprzedniej wartości lub kolejnej wartości

  • można stosować również metody oparte o większą liczbę poprzednich obserwacji np średnia krocząca

  • dobrze może się sprawdzać również interpolacja

imputacja wartością specjalną

Imputacja z wykorzystaniem modelu

  • obejmuje przewidywanie brakujących wartości zmiennej na podstawie pozostałych zmiennych lub ich podgrupy

  • stosowane są tu zarówno algorytmy regresji jak i KNN lub modele drzewiaste

Przykłady imputacji#

Imputacja wartością specjalną#

Zmienną age uznaliśmy za MNAR, co sugeruje, że nie można się posłużyć statystykami z próbki, tylko należy użyć wartości specjalnej. Jeśli wiemy, że wartości brakujące reprezentują starsze osoby wstawienie tutaj wartości np. -1 może nie być najlepszym rozwiązaniem, ponieważ takie podejście może zaburzyć monotoniczność relacji tej zmiennej ze zmienną celu, zamiast tego można spróbować wartości większej niż maksimum z obecnych danych np 70

df["age"] = df["age"] .fillna(70)
df.info()
<class 'pandas.core.frame.DataFrame'>
Index: 300 entries, 0 to 89
Data columns (total 9 columns):
 #   Column              Non-Null Count  Dtype  
---  ------              --------------  -----  
 0   unique_id           300 non-null    int64  
 1   age                 300 non-null    float64
 2   weight              300 non-null    float64
 3   height              295 non-null    float64
 4   bmi                 295 non-null    float64
 5   city                300 non-null    object 
 6   num_covid_tests     247 non-null    float64
 7   num_positive_tests  257 non-null    float64
 8   sex                 271 non-null    object 
dtypes: float64(6), int64(1), object(2)
memory usage: 23.4+ KB

Imputacja wartością stałą#

Pozostałe zmienne z brakującymi wartościami należy wypełnić w oparciu o wartości z danych treningowych, zanim do tego przystąpimy należy podzielić próbkę, co będzie reprezentowało rzeczywistą sytuację, gdy na podstawie dostępnych danych będziemy uzupełniać te przyszłe.

from sklearn.model_selection import train_test_split
X_train, X_test = train_test_split(df, test_size =0.25, random_state=42)

Tutaj dla uproszczenia stosujemy tylko prosty podział na zbiór treningowy i testowy, generalnie najlepszą praktyką jest wydzielenie osobnego zbioru testowego reprezentującego zdolność modelu do generalizacji na nowych, niewidzianych wcześniej danych i dobór najlepszego zestawu parametrów i transformacji stosując crosswalidację na zbiorze treningowym.

Imputacja zmiennych kategorycznych#

W przypadku zmiennych kategorycznych, gdy nie stosujemy imputacji wartością specjalną najczęściej spotykaną strategią jest wypełnianie najczęściej występującą wartością w danych treningowych.

X_train_sex_mode = X_train["sex"].dropna().mode()[0]
X_train["sex"] = X_train["sex"].fillna(X_train_sex_mode)
X_test["sex"] = X_test["sex"].fillna(X_train_sex_mode)

Alternatywnie można też skorzystać z klasy SimpleImputer z biblioteki sklearn. Użycie tego typu transformerów stanowi dobrą praktykę, ponieważ można je potem łączyć w pipeline co ułatwia development i zmniejsza ryzyko popełnienia błędów takich jak np. data leakage. Poniżej zaprezentujemy ponowny podział na zbiór treningowy i testowy oraz wypełnienie tej samej zmiennej z użyciem klasy SimpleImputer.

from sklearn.impute import SimpleImputer
X_train, X_test = train_test_split(df, test_size =0.25, random_state=42)
si_mode = SimpleImputer(strategy="most_frequent")
si_mode.fit(X_train["sex"].values.reshape(-1, 1))
X_train["sex"] = si_mode.transform(X_train["sex"].values.reshape(-1, 1)).reshape(-1)
X_test["sex"] = si_mode.transform(X_test["sex"].values.reshape(-1, 1)).reshape(-1)
X_train.info()
<class 'pandas.core.frame.DataFrame'>
Index: 225 entries, 297 to 109
Data columns (total 9 columns):
 #   Column              Non-Null Count  Dtype  
---  ------              --------------  -----  
 0   unique_id           225 non-null    int64  
 1   age                 225 non-null    float64
 2   weight              225 non-null    float64
 3   height              221 non-null    float64
 4   bmi                 221 non-null    float64
 5   city                225 non-null    object 
 6   num_covid_tests     189 non-null    float64
 7   num_positive_tests  198 non-null    float64
 8   sex                 225 non-null    object 
dtypes: float64(6), int64(1), object(2)
memory usage: 17.6+ KB

Powyżej stosujemy SimpleImputer na tylko jednej kolumnie, inaczej wszystkie zmienne z brakującymi wartościami zostałyby wypełnione swoją najczęstszą wartością. Standardową praktyką jest użycie klasy ColumnTransformer do przypisywania odpowiednich transformacji poszczególnym grupom zmiennych, co zostanie zaprezentowane później.

Imputacja zmiennych numerycznych#

Załóżmy, że chcemy zainputować zmienne numeryczne korzystając z mediany, użycie mediany może być dobrą alternatywą dla używania średniej jeśli obawiamy się wpływu wartości odstających na średnią.

si_median = SimpleImputer(strategy="median")
X_train_num = X_train.select_dtypes(exclude="object")
X_test_num = X_test.select_dtypes(exclude="object")
si_median.fit(X_train_num)
X_train_num = pd.DataFrame(si_median.transform(X_train_num), columns =X_train_num.columns)
X_test_num = pd.DataFrame(si_median.transform(X_test_num), columns =X_test_num.columns)
X_train = pd.concat([X_train_num, X_train.select_dtypes(include="object")],axis=1)
X_test = pd.concat([X_test_num, X_test.select_dtypes(include="object")],axis=1)
X_train.info()
<class 'pandas.core.frame.DataFrame'>
Index: 280 entries, 0 to 284
Data columns (total 9 columns):
 #   Column              Non-Null Count  Dtype  
---  ------              --------------  -----  
 0   unique_id           225 non-null    float64
 1   age                 225 non-null    float64
 2   weight              225 non-null    float64
 3   height              225 non-null    float64
 4   bmi                 225 non-null    float64
 5   num_covid_tests     225 non-null    float64
 6   num_positive_tests  225 non-null    float64
 7   city                225 non-null    object 
 8   sex                 225 non-null    object 
dtypes: float64(7), object(2)
memory usage: 21.9+ KB

Łączenie różnych metod imputacji z wykorzystaniem pipeline#

Jak już wspomniano najlepszą praktykę przy stosowaniu imputacji stanowi łączenie poszczególnych transformacji w Pipeline będący ciągiem transformacji, który może też na końcu zawierać model predykcyjny. Tutaj pokażemy pipeline łączący wszystkie wcześniej stosowane operacje imputacji danych.

from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline

# przywrócenie danych do postaci sprzed imputacji
df["age"] = np.where(df["age"]>65, np.nan, df["age"])
X_train, X_test = train_test_split(df, test_size =0.25, random_state=42)

columns_to_impute_special_value =["age"]
columns_to_impute_mode = X_train.select_dtypes(include="object").columns.tolist()
columns_to_impute_median = list(set(X_train.select_dtypes(exclude="object").columns.tolist()) -set(columns_to_impute_special_value))

pipeline_sv = Pipeline(steps=[("special_value_imputer", SimpleImputer(strategy="constant", fill_value=70))])
pipeline_mode = Pipeline(steps=[("mode_imputer",SimpleImputer(strategy="most_frequent"))])
pipeline_num = Pipeline(steps=[("median_imputer",SimpleImputer(strategy="median"))])

column_transformer = ColumnTransformer(
                        transformers=[
                         ('special_value_imputation', pipeline_sv, columns_to_impute_special_value),
                         ('mode_imputation', pipeline_mode, columns_to_impute_mode),
                         ('median_imputation', pipeline_num, columns_to_impute_median)
                         ])
imputation_pipeline = Pipeline(steps=[("imputation", column_transformer)])

imputation_pipeline.fit(X_train)
X_train = pd.DataFrame(imputation_pipeline.transform(X_train), columns=X_train.columns)
X_test = pd.DataFrame(imputation_pipeline.transform(X_test), columns=X_test.columns)
X_test.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 75 entries, 0 to 74
Data columns (total 9 columns):
 #   Column              Non-Null Count  Dtype 
---  ------              --------------  ----- 
 0   unique_id           75 non-null     object
 1   age                 75 non-null     object
 2   weight              75 non-null     object
 3   height              75 non-null     object
 4   bmi                 75 non-null     object
 5   city                75 non-null     object
 6   num_covid_tests     75 non-null     object
 7   num_positive_tests  75 non-null     object
 8   sex                 75 non-null     object
dtypes: object(9)
memory usage: 5.4+ KB

Imputacja wg grupy#

Wróćmy na chwilę do zmiennej age, załóżmy, że jej braki danych nie są jednak wynikiem systematycznego błędu lecz występują losowo, a co więcej wiemy, że każde z miast ze zmiennej city charakteryzuje się specyficznym rozkładem dla zmiennej age. Wówczas imputacja mogłaby tutaj wyglądać następująco

X_train, X_test = train_test_split(df, test_size =0.25, random_state=42)
mean_age_by_city =X_train.groupby("city")["age"].mean().reset_index()
X_train = X_train.merge(mean_age_by_city, on ="city",suffixes=("","_mean"))
X_test = X_test.merge(mean_age_by_city, on ="city",suffixes=("","_mean"))
X_train["age"] = X_train["age"].fillna(X_train["age_mean"])
X_test["age"] = X_test["age"].fillna(X_test["age_mean"])
X_train.drop("age_mean", axis=1, inplace=True)
X_test.drop("age_mean", axis=1, inplace=True)
X_test.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 75 entries, 0 to 74
Data columns (total 9 columns):
 #   Column              Non-Null Count  Dtype  
---  ------              --------------  -----  
 0   unique_id           75 non-null     int64  
 1   age                 75 non-null     float64
 2   weight              75 non-null     float64
 3   height              74 non-null     float64
 4   bmi                 74 non-null     float64
 5   city                75 non-null     object 
 6   num_covid_tests     58 non-null     float64
 7   num_positive_tests  59 non-null     float64
 8   sex                 69 non-null     object 
dtypes: float64(6), int64(1), object(2)
memory usage: 5.4+ KB

Imputacja z wykorzystaniem modelu#

Poniżej pokażemy jeszcze jak można do imputacji wykorzystać model ML na przykładzie klasy KNNImputer, która wykonuje imputację na podstawie wartości najbliższych obserwacji.

from sklearn.impute import KNNImputer
X_train, X_test = train_test_split(df, test_size =0.25, random_state=42)
X_train_num = X_train.select_dtypes(exclude="object")
X_test_num = X_test.select_dtypes(exclude="object")
knn = KNNImputer(n_neighbors=5)
knn.fit(X_train_num)
X_train_num = pd.DataFrame(knn.transform(X_train_num), columns= X_train_num.columns)
X_test_num = pd.DataFrame(knn.transform(X_test_num), columns= X_test_num.columns)
X_train = pd.concat([X_train_num, X_train.select_dtypes(include="object")],axis=1)
X_test = pd.concat([X_test_num, X_test.select_dtypes(include="object")],axis=1)
X_train.info()
<class 'pandas.core.frame.DataFrame'>
Index: 280 entries, 0 to 284
Data columns (total 9 columns):
 #   Column              Non-Null Count  Dtype  
---  ------              --------------  -----  
 0   unique_id           225 non-null    float64
 1   age                 225 non-null    float64
 2   weight              225 non-null    float64
 3   height              225 non-null    float64
 4   bmi                 225 non-null    float64
 5   num_covid_tests     225 non-null    float64
 6   num_positive_tests  225 non-null    float64
 7   city                225 non-null    object 
 8   sex                 202 non-null    object 
dtypes: float64(7), object(2)
memory usage: 21.9+ KB

Metody usuwania brakujących wartości#

Drugim oprócz imputacji sposobem radzenia sobie z wartościami brakującymi jest ich usuwanie. Wybór pomiędzy usuwaniem, a imputacją zależy w dużym stopniu od ilości dostępnych danych oraz poziomu koncentracji wartości brakujących, a także tego jak istotne są dla nas dane zmienne lub obserwacje.

Uwaga!

Należy pamiętać, żeby w przypadku stosowania usuwania brakujących obserwacji zacząć od usunięcia wszystkich niepotrzebnych kolumn, ponieważ mogą one potem niepotrzebnie wpłynąć na usuwanie obserwacji.

Najwygodniejszą opcją usuwania w bibliotece pandas jest funkcja dropna, kilka przykładów poniżej

# usuwanie wszystkich obserwacji z jakimikolwiek wartościami brakującymi
df_nonull = df.dropna(axis=0, how="any")
df_nonull.info()
<class 'pandas.core.frame.DataFrame'>
Index: 111 entries, 4 to 89
Data columns (total 9 columns):
 #   Column              Non-Null Count  Dtype  
---  ------              --------------  -----  
 0   unique_id           111 non-null    int64  
 1   age                 111 non-null    float64
 2   weight              111 non-null    float64
 3   height              111 non-null    float64
 4   bmi                 111 non-null    float64
 5   city                111 non-null    object 
 6   num_covid_tests     111 non-null    float64
 7   num_positive_tests  111 non-null    float64
 8   sex                 111 non-null    object 
dtypes: float64(6), int64(1), object(2)
memory usage: 8.7+ KB
# usuwanie wszystkich obserwacji z progiem co najmniej 2 wartości brakujących
df_nonull = df.dropna(axis=0, thresh=df.shape[1]-2)
df_nonull.info()
<class 'pandas.core.frame.DataFrame'>
Index: 292 entries, 0 to 89
Data columns (total 9 columns):
 #   Column              Non-Null Count  Dtype  
---  ------              --------------  -----  
 0   unique_id           292 non-null    int64  
 1   age                 193 non-null    float64
 2   weight              292 non-null    float64
 3   height              291 non-null    float64
 4   bmi                 291 non-null    float64
 5   city                292 non-null    object 
 6   num_covid_tests     243 non-null    float64
 7   num_positive_tests  251 non-null    float64
 8   sex                 268 non-null    object 
dtypes: float64(6), int64(1), object(2)
memory usage: 22.8+ KB
# usuwanie wszystkich obserwacji z progiem co najmniej 1 wartością brakującą dla kolumn age, num_covid_tests i num_positive_tests
df_nonull = df.dropna(axis=0, thresh=2, subset=["age", "num_covid_tests", "num_positive_tests"])
df_nonull.info()
<class 'pandas.core.frame.DataFrame'>
Index: 270 entries, 2 to 89
Data columns (total 9 columns):
 #   Column              Non-Null Count  Dtype  
---  ------              --------------  -----  
 0   unique_id           270 non-null    int64  
 1   age                 194 non-null    float64
 2   weight              270 non-null    float64
 3   height              266 non-null    float64
 4   bmi                 266 non-null    float64
 5   city                270 non-null    object 
 6   num_covid_tests     233 non-null    float64
 7   num_positive_tests  241 non-null    float64
 8   sex                 246 non-null    object 
dtypes: float64(6), int64(1), object(2)
memory usage: 21.1+ KB
# usuwanie kolumn mających co najmniej 10% wartości brakujących
df_nonull = df.dropna(axis=1, thresh=0.9*df.shape[0])
df_nonull.info()
<class 'pandas.core.frame.DataFrame'>
Index: 300 entries, 0 to 89
Data columns (total 6 columns):
 #   Column     Non-Null Count  Dtype  
---  ------     --------------  -----  
 0   unique_id  300 non-null    int64  
 1   weight     300 non-null    float64
 2   height     295 non-null    float64
 3   bmi        295 non-null    float64
 4   city       300 non-null    object 
 5   sex        271 non-null    object 
dtypes: float64(3), int64(1), object(2)
memory usage: 16.4+ KB

Czyszczenie szeregów czasowych#

df_ts = generate_ts_data()
df_ts.set_index("SalesDate").plot(figsize=(15,9))
<Axes: xlabel='SalesDate'>
../_images/1dac57b438a29d390273ae1627f87339f0f7e0953852000564be73f36c86acab.png

W czyszczeniu danych o charakterze szeregu czasowego bardzo użyteczna jest wizualna analiza danych. Analizując powyższy wykres można dostrzec dość regularny szereg czasowy z rocznym okresem sezonowości i trendem rosnącym. Widać również zaburzenia w postaci nieregularnie rozlokowanych pików w pojedynczych dniach oraz silnie zaburzony okres na przełomie 2019 i 2020.

df_ts.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 1827 entries, 0 to 1826
Data columns (total 2 columns):
 #   Column     Non-Null Count  Dtype         
---  ------     --------------  -----         
 0   SalesDate  1827 non-null   datetime64[ns]
 1   SLSU       1827 non-null   float64       
dtypes: datetime64[ns](1), float64(1)
memory usage: 28.7 KB

Przed przejściem do dalszej analizy należy jeszcze upewnić się, że dane mają prawidłowe formaty i są odpowiednio posortowane, inaczej łatwo o błędne wnioski.

df_ts["SLSU"] = df_ts["SLSU"].astype("float64")
df_ts.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 1827 entries, 0 to 1826
Data columns (total 2 columns):
 #   Column     Non-Null Count  Dtype         
---  ------     --------------  -----         
 0   SalesDate  1827 non-null   datetime64[ns]
 1   SLSU       1827 non-null   float64       
dtypes: datetime64[ns](1), float64(1)
memory usage: 28.7 KB
df_ts = df_ts.sort_values("SalesDate")
df_ts = df_ts.set_index("SalesDate")
df_ts.plot(figsize=(15,9))
<Axes: xlabel='SalesDate'>
../_images/1dac57b438a29d390273ae1627f87339f0f7e0953852000564be73f36c86acab.png
df_ts.index.nunique(), df_ts.shape[0]
(1827, 1827)

Dane nie mają duplikatów, są posortowane a formaty są już poprawne, możemy się zatem skupić na oznaczaniu i imputacji wartości zaburzonych.

Info

Podstawą do oznaczenia danego zjawiska jako zaburzonego, a następnie jego imputacji powinna być zawsze wiedza biznesowa odnośnie tego czy dane zjawisko ma charakter jednorazowy i już się nie powtórzy w analogicznym okresie.

Istnieje wiele metod oznaczania anomalii w szeregach czasowych, mogą one też być mocno zależne od wiedzy na tematy powodu wystąpienia takiej anomali. Tutaj najpierw spróbujemy automatycznie wykryć pojedyncze zaburzenia w oparciu o porównanie z medianą z sąsiednich obserwacji.

df_ts["moving_SLSU_median_3"] = df_ts["SLSU"].rolling(3, center=True, min_periods=1).median()
df_ts.plot(figsize=(15,9))
<Axes: xlabel='SalesDate'>
../_images/ea3de22539ca2e4bdfffa09a17910384b022c0c373c3f874806bc162f0c82e56.png

jak widać takie podejście dobrze oddziela naturalne obserwacje od pojedynczych zaburzeń, można za jego pomocą oznaczyć wartości odstające jeśli np zauważymy, że różnica pomiędzy wyliczoną medianą a wartością pierwotną wynosi ponad 15%

len(df_ts.loc[np.abs(df_ts["moving_SLSU_median_3"]-df_ts["SLSU"])>0.15*df_ts["SLSU"] ])
2
df_ts["SLSU"] =np.where(np.abs(df_ts["moving_SLSU_median_3"]-df_ts["SLSU"])>0.15*df_ts["SLSU"], np.nan, df_ts["SLSU"])
df_ts["SLSU"].plot(figsize=(15,9))
<Axes: xlabel='SalesDate'>
../_images/099e68f59fc0f9b1f79895c760b8d5e07b532c6f4a69a1c68ffd617c68c8e5d3.png
Uwaga!

Należy mieć świadomość, że przedstawiona powyżej metoda może również oznaczyć jako anomalia zupełnie naturalnie występujące obserwacje, wszystko zależy od konkretnej potrzeby przy czyszczeniu szeregu czasowego.

Obserwacje oznaczone jako puste można następnie zaimputować za pomocą wyliczonej wcześniej mediany lub dokonać interpolacji liniowej

df_ts["SLSU"] = df_ts["SLSU"].interpolate(method="linear")
df_ts["SLSU"].plot(figsize=(15,9))
<Axes: xlabel='SalesDate'>
../_images/449f1361ee9f76156003eb5965a90ff59b6e1dc93aaf97a8284631dcd81ba0e1.png

Jak widać z poniższego wykresu, interpolacja liniowa nie będzie jednak użyteczna w celu wypełnienia dłuższych zaburzeń, w szczególności w przypadku gdy przypadają one w okresie sezonowego szczytu. Zamiast tego spróbujmy dokonać prognozy modelem szeregu czasowego w oparciu o wcześniejsze, niezaburzone dane.

df_ts["SLSU"] = np.where(df_ts["SLSU"]==0, np.nan, df_ts["SLSU"])
df_ts["SLSU"].interpolate(method="linear").plot(figsize=(15,9))
<Axes: xlabel='SalesDate'>
../_images/f9dd1aa9380a63ab4dbe8d7fb803213ccd6dae13e635b17696c13dbafa638a2f.png
df_ts["SLSU"]  = df_ts["SLSU"].fillna(0)

Automatycznym sposobem na oznaczenie wartości odstających przy założeniu, że nie można tutaj po prostu patrzeć na obserwacje z wartością 0 jest porównanie z wartością sprzed roku a jeszcze lepiej z wygładzoną wartością, więc wygodnie jest użyć wyliczonej już wcześniej mediany kroczącej sprzed roku.

df_ts["moving_SLSU_median_3_lag_1y"] = df_ts["moving_SLSU_median_3"].shift(365)
df_ts[["SLSU", "moving_SLSU_median_3_lag_1y"]].plot(figsize=(15,9))
<Axes: xlabel='SalesDate'>
../_images/856890fb1d87fb3b6c47974952a4ea02f7c6b96feb6689929ced92030794a5c0.png

Następnie można wyliczyć różnice pomiędzy wartością SLSU, a medianą i oznaczyć jako puste wartości odstające wyznaczone np metodą IQR, przyjmując dodatkowo warunek, że interesują nas tylko wartości większe od IQR, aby nie wyzerować wartości rok po zaburzeniu.

df_ts["moving_SLSU_median_3_lag_1y_DIFF_VS_SLSU"] =df_ts["moving_SLSU_median_3_lag_1y"] - df_ts["SLSU"] 
df_ts[["SLSU", "moving_SLSU_median_3_lag_1y", "moving_SLSU_median_3_lag_1y_DIFF_VS_SLSU"]].plot(figsize=(15,9))
<Axes: xlabel='SalesDate'>
../_images/f8ef30570e7e932efb2ded0e1ecd4147c1c20d69e6d2d20f904ff6c7a677f3c0.png
df_ts["IQR"] = (df_ts["moving_SLSU_median_3_lag_1y_DIFF_VS_SLSU"].quantile(0.75) - df_ts["moving_SLSU_median_3_lag_1y_DIFF_VS_SLSU"].quantile(0.25))*1.5
df_ts.loc[df_ts["moving_SLSU_median_3_lag_1y_DIFF_VS_SLSU"] >df_ts["IQR"] ]
SLSU moving_SLSU_median_3 moving_SLSU_median_3_lag_1y moving_SLSU_median_3_lag_1y_DIFF_VS_SLSU IQR
SalesDate
2019-11-01 0.0 0.0 8818.736687 8818.736687 2249.224099
2019-11-02 0.0 0.0 8820.468738 8820.468738 2249.224099
2019-11-03 0.0 0.0 8822.200788 8822.200788 2249.224099
2019-11-04 0.0 0.0 8823.932839 8823.932839 2249.224099
2019-11-05 0.0 0.0 9437.078825 9437.078825 2249.224099
... ... ... ... ... ...
2020-02-26 0.0 0.0 10179.000000 10179.000000 2249.224099
2020-02-27 0.0 0.0 10181.000000 10181.000000 2249.224099
2020-02-28 0.0 0.0 10181.000000 10181.000000 2249.224099
2020-02-29 0.0 0.0 8587.000000 8587.000000 2249.224099
2020-03-01 0.0 0.0 8587.000000 8587.000000 2249.224099

122 rows × 5 columns

df_ts["SLSU"] = np.where(df_ts["moving_SLSU_median_3_lag_1y_DIFF_VS_SLSU"] >df_ts["IQR"],np.nan, df_ts["SLSU"])
df_ts["SLSU"].plot(figsize=(15,9))
<Axes: xlabel='SalesDate'>
../_images/aa82c6a4e4a6f58a1c49607f69c4e92a42371bcc9e810019d02ba4dc40370d2d.png

Następnie można uzupełnić dane dokonując predykcji np. w oparciu od dane do połowy roku 2019, jakość predykcji ocenić można przez porównanie z niezaburzonymi obserwacjami, Do predykcji użyjemy tutaj modelu STLForecast, który dekomponuje szereg czasowy na trend, sezonowość i czynnik losowy i wykonuje predykcję odsezonowionych danych z użyciem modelu ARIMA

from statsmodels.tsa.api import STLForecast
from statsmodels.tsa.arima.model import ARIMA
test_start = "2019-07-01"
df_ts.index = pd.DatetimeIndex(df_ts.index.values, freq=df_ts.index.inferred_freq) # ustawienie kroku
df_ts_train = df_ts.loc[df_ts.index<test_start,"SLSU"]
df_ts_test = df_ts.loc[df_ts.index>=test_start, "SLSU"]
model = STLForecast(df_ts_train, ARIMA, model_kwargs={"order": (3,2,3), "enforce_invertibility": False}, period=365)
stl = model.fit()
df_ts.loc[df_ts.index>=test_start, "prediction"] =stl.forecast(len(df_ts_test))
df_ts[["SLSU", "prediction"]].plot(figsize=(15,9))
<Axes: >
../_images/e5ed5764a0e87ed6d57cbac2fdc5fac2c02f9c26b2d10fd124ed9528f92a8ff9.png

Tym sposobem otrzymujemy oczyszczony szereg czasowy.

df_ts["SLSU"] = df_ts["SLSU"].fillna(df_ts["prediction"])
df_ts["SLSU"].plot(figsize=(15,9))
<Axes: >
../_images/182e9ea0459943f557a026460cde7c7f08002ed328c64659ccf58c8cf2fddeb4.png