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 41.0 64.0 141.0 32.2 wejherowo 1.0 1 M
1 1 39.0 48.0 184.0 14.2 GDAŃSK 4.0 4 F
2 2 58.0 63.0 179.0 19.7 GDAŃSK 1.0 -1 N
3 3 NaN 41.0 175.0 13.4 GDANSK NaN 0 F
4 4 NaN 54.0 191.0 14.8 GDAŃSK 1.0 0 M
df.info()
<class 'pandas.core.frame.DataFrame'>
Int64Index: 315 entries, 0 to 208
Data columns (total 9 columns):
 #   Column              Non-Null Count  Dtype  
---  ------              --------------  -----  
 0   unique_id           315 non-null    int64  
 1   age                 218 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     248 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 64.0 wejherowo M
1 48.0 GDAŃSK F
2 63.0 GDAŃSK N
3 41.0 GDANSK F
4 54.0 GDAŃSK M
... ... ... ...
252 62.0 Gdańsk M
231 58.0 Wejherowo M
229 57.0 Kościerzyna M
295 55.0 WEJHEROWO N
208 65.0 Kościerzyna F

315 rows × 3 columns

df.select_dtypes("float64")
age height bmi num_covid_tests
0 41.0 141.0 32.2 1.0
1 39.0 184.0 14.2 4.0
2 58.0 179.0 19.7 1.0
3 NaN 175.0 13.4 NaN
4 NaN 191.0 14.8 1.0
... ... ... ... ...
252 52.0 158.0 24.8 4.0
231 20.0 152.0 25.1 1.0
229 55.0 155.0 23.7 4.0
295 33.0 179.0 17.2 3.0
208 51.0 169.0 22.8 3.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'>
Int64Index: 315 entries, 0 to 208
Data columns (total 9 columns):
 #   Column              Non-Null Count  Dtype  
---  ------              --------------  -----  
 0   unique_id           315 non-null    int64  
 1   age                 218 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     248 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
********************
Gdańsk         66
GDAŃSK         50
Gdynia         31
Wejherowo      20
GDYNIA         19
Kościerzyna    17
Gdansk         15
gdańsk         15
WEJHEROWO      15
GDANSK         13
KOŚCIERZYNA    12
Koscierzyna    12
gdynia         11
KOSCIERZYNA     8
wejherowo       4
gdansk          3
kościerzyna     3
koscierzyna     1
Name: city, dtype: int64
********************
SEX
********************
F    146
M    130
N     39
Name: sex, 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()
Gdańsk         162
Gdynia          61
Kościerzyna     53
Wejherowo       39
Name: city, 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)
F      146
M      130
NaN     39
Name: sex, 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'>
Int64Index: 300 entries, 0 to 208
Data columns (total 9 columns):
 #   Column              Non-Null Count  Dtype  
---  ------              --------------  -----  
 0   unique_id           300 non-null    int64  
 1   age                 205 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     236 non-null    float64
 7   num_positive_tests  300 non-null    int32  
 8   sex                 264 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 205.000000 300.000000 300.000000 300.000000 236.000000 300.000000
mean 149.500000 36.512195 59.726667 163.090000 21.686225 2.559322 1.040000
std 86.746758 18.061607 10.080543 23.571323 7.833113 1.126413 2.532412
min 0.000000 5.000000 33.000000 123.000000 2.858319 1.000000 -16.000000
25% 74.750000 21.000000 53.000000 152.000000 18.050000 2.000000 0.000000
50% 149.500000 38.000000 60.000000 161.000000 22.500000 3.000000 1.000000
75% 224.250000 52.000000 66.250000 169.250000 26.600000 4.000000 3.000000
max 299.000000 65.000000 87.000000 324.000000 41.000000 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 205.000000 300.000000 295.000000 300.000000 236.000000 270.000000
mean 149.500000 36.512195 59.726667 160.505085 21.686225 2.559322 1.603704
std 86.746758 18.061607 10.080543 12.704884 7.833113 1.126413 1.569453
min 0.000000 5.000000 33.000000 123.000000 2.858319 1.000000 0.000000
25% 74.750000 21.000000 53.000000 152.000000 18.050000 2.000000 0.000000
50% 149.500000 38.000000 60.000000 161.000000 22.500000 3.000000 1.000000
75% 224.250000 52.000000 66.250000 169.000000 26.600000 4.000000 3.000000
max 299.000000 65.000000 87.000000 194.000000 41.000000 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 podstwie.

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
13 13 44.0 76.0 150.0 33.800000 Gdańsk 3.0 4.0 F
33 33 NaN 55.0 153.0 23.500000 Gdańsk 1.0 2.0 M
62 62 32.0 61.0 147.0 28.200000 Kościerzyna 4.0 5.0 F
85 85 11.0 66.0 151.0 28.900000 Gdańsk 4.0 5.0 M
89 89 9.0 82.0 179.0 25.600000 Gdańsk 4.0 5.0 M
96 96 64.0 65.0 149.0 29.300000 Gdańsk 3.0 4.0 F
100 100 14.0 48.0 139.0 24.800000 Kościerzyna 4.0 5.0 M
126 126 NaN 59.0 173.0 19.700000 Gdańsk 4.0 5.0 F
135 135 NaN 56.0 157.0 22.700000 Kościerzyna 2.0 3.0 F
169 169 9.0 61.0 157.0 24.700000 Kościerzyna 1.0 2.0 M
191 191 52.0 52.0 194.0 13.800000 Gdańsk 2.0 3.0 F
192 192 20.0 62.0 162.0 4.380281 Gdynia 2.0 3.0 M
195 195 NaN 51.0 152.0 4.101874 Kościerzyna 1.0 2.0 NaN
199 199 NaN 77.0 169.0 27.000000 Gdańsk 1.0 2.0 F
210 210 NaN 55.0 166.0 20.000000 Gdańsk 4.0 5.0 M
248 248 NaN 53.0 172.0 17.900000 Gdańsk 2.0 3.0 F
231 231 20.0 58.0 152.0 25.100000 Wejherowo 1.0 2.0 M

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
18 18 38.0 49.0 167.0 3.266650 Gdańsk 1.0 1.0 F
21 21 44.0 71.0 NaN 27.100000 Wejherowo 3.0 3.0 F
27 27 NaN 68.0 NaN 25.900000 Gdynia NaN 0.0 F
33 33 NaN 55.0 153.0 23.500000 Gdańsk 1.0 1.0 M
35 35 NaN 50.0 145.0 4.417402 Kościerzyna 3.0 3.0 F
45 45 48.0 51.0 132.0 5.438231 Gdańsk 2.0 2.0 M
54 54 27.0 73.0 160.0 28.500000 Wejherowo 1.0 1.0 M
64 64 63.0 67.0 NaN 29.800000 Gdańsk 3.0 3.0 NaN
68 68 36.0 51.0 138.0 4.974218 Gdańsk 2.0 0.0 F
73 73 19.0 44.0 151.0 3.582179 Kościerzyna 2.0 0.0 F
83 83 63.0 65.0 145.0 5.735199 Gdańsk 3.0 3.0 F
84 84 NaN 87.0 166.0 5.865122 Gdańsk 1.0 1.0 F
86 86 NaN 58.0 149.0 4.844294 Gdańsk 3.0 3.0 NaN
99 99 NaN 50.0 164.0 3.452256 Wejherowo 2.0 0.0 F
112 112 64.0 57.0 161.0 4.083313 Kościerzyna 2.0 2.0 M
120 120 NaN 68.0 147.0 31.500000 Gdańsk 3.0 3.0 M
132 132 63.0 66.0 147.0 30.500000 Gdynia 2.0 0.0 F
146 146 NaN 55.0 153.0 23.500000 Gdańsk 3.0 3.0 M
156 156 14.0 69.0 150.0 5.698078 Gdańsk 4.0 4.0 F
167 167 NaN 68.0 182.0 20.500000 Gdańsk 4.0 0.0 NaN
171 171 NaN 53.0 NaN 21.000000 Gdańsk 1.0 1.0 F
172 172 64.0 72.0 146.0 6.273454 Kościerzyna 2.0 2.0 M
176 176 NaN 57.0 NaN 23.400000 Wejherowo NaN 1.0 M
182 182 16.0 52.0 174.0 3.192408 Gdańsk NaN 0.0 M
183 183 NaN 62.0 165.0 4.231797 Kościerzyna 2.0 2.0 F
187 187 NaN 51.0 166.0 18.500000 Gdańsk 1.0 1.0 M
192 192 20.0 62.0 162.0 4.380281 Gdynia 2.0 2.0 M
193 193 57.0 64.0 158.0 4.751492 Gdynia NaN 0.0 F
195 195 NaN 51.0 152.0 4.101874 Kościerzyna 1.0 1.0 NaN
198 198 52.0 53.0 164.0 3.656421 Wejherowo NaN 0.0 M
215 215 19.0 80.0 173.0 4.955657 Gdańsk 4.0 4.0 M
216 216 51.0 53.0 152.0 4.250358 Gdynia 4.0 4.0 F
224 224 NaN 49.0 163.0 3.415135 Kościerzyna NaN 0.0 M
235 235 27.0 40.0 131.0 4.324600 Wejherowo NaN 0.0 F
236 236 NaN 74.0 165.0 5.048460 Gdańsk 1.0 1.0 M
239 239 NaN 57.0 144.0 27.500000 Gdynia 4.0 4.0 M
245 245 40.0 58.0 161.0 4.157555 Kościerzyna 4.0 NaN M
254 254 13.0 54.0 152.0 4.343160 Gdańsk 2.0 2.0 F
260 260 58.0 61.0 135.0 33.500000 Gdynia 4.0 NaN F
280 280 21.0 59.0 169.0 3.842026 Gdańsk 3.0 0.0 M
282 282 12.0 56.0 160.0 4.064753 Gdańsk 3.0 0.0 F
293 293 46.0 46.0 173.0 2.858319 Wejherowo 2.0 NaN F
234 234 NaN 62.0 158.0 4.603007 Gdańsk 4.0 NaN M
87 87 5.0 72.0 161.0 5.159823 Gdańsk 2.0 NaN M

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 a 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)
<AxesSubplot:>
../_images/Czyszczenie danych_56_1.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"));
../_images/Czyszczenie danych_60_0.png

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

missingno.heatmap(df, cmap='rainbow');
../_images/Czyszczenie danych_62_0.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

iImputacja 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'>
Int64Index: 300 entries, 0 to 208
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     236 non-null    float64
 7   num_positive_tests  270 non-null    float64
 8   sex                 264 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)
C:\Users\KNAJMA~1\AppData\Local\Temp/ipykernel_13396/1313340584.py:2: SettingWithCopyWarning: 
A value is trying to be set on a copy of a slice from a DataFrame.
Try using .loc[row_indexer,col_indexer] = value instead

See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy
  X_train["sex"] = X_train["sex"].fillna(X_train_sex_mode)
C:\Users\KNAJMA~1\AppData\Local\Temp/ipykernel_13396/1313340584.py:3: SettingWithCopyWarning: 
A value is trying to be set on a copy of a slice from a DataFrame.
Try using .loc[row_indexer,col_indexer] = value instead

See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy
  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))
X_test["sex"] = si_mode.transform(X_test["sex"].values.reshape(-1, 1))
X_train.info()
<class 'pandas.core.frame.DataFrame'>
Int64Index: 225 entries, 297 to 103
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              220 non-null    float64
 4   bmi                 220 non-null    float64
 5   city                225 non-null    object 
 6   num_covid_tests     177 non-null    float64
 7   num_positive_tests  203 non-null    float64
 8   sex                 225 non-null    object 
dtypes: float64(6), int64(1), object(2)
memory usage: 17.6+ KB
C:\Users\KNAJMA~1\AppData\Local\Temp/ipykernel_13396/2045433211.py:5: SettingWithCopyWarning: 
A value is trying to be set on a copy of a slice from a DataFrame.
Try using .loc[row_indexer,col_indexer] = value instead

See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy
  X_train["sex"] = si_mode.transform(X_train["sex"].values.reshape(-1, 1))
C:\Users\KNAJMA~1\AppData\Local\Temp/ipykernel_13396/2045433211.py:6: SettingWithCopyWarning: 
A value is trying to be set on a copy of a slice from a DataFrame.
Try using .loc[row_indexer,col_indexer] = value instead

See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy
  X_test["sex"] = si_mode.transform(X_test["sex"].values.reshape(-1, 1))

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'>
Int64Index: 280 entries, 0 to 299
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'>
Int64Index: 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              75 non-null     float64
 4   bmi                 75 non-null     float64
 5   city                75 non-null     object 
 6   num_covid_tests     59 non-null     float64
 7   num_positive_tests  67 non-null     float64
 8   sex                 70 non-null     object 
dtypes: float64(6), int64(1), object(2)
memory usage: 5.9+ 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'>
Int64Index: 280 entries, 0 to 299
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                 194 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'>
Int64Index: 128 entries, 0 to 208
Data columns (total 9 columns):
 #   Column              Non-Null Count  Dtype  
---  ------              --------------  -----  
 0   unique_id           128 non-null    int64  
 1   age                 128 non-null    float64
 2   weight              128 non-null    float64
 3   height              128 non-null    float64
 4   bmi                 128 non-null    float64
 5   city                128 non-null    object 
 6   num_covid_tests     128 non-null    float64
 7   num_positive_tests  128 non-null    float64
 8   sex                 128 non-null    object 
dtypes: float64(6), int64(1), object(2)
memory usage: 10.0+ 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'>
Int64Index: 294 entries, 0 to 208
Data columns (total 9 columns):
 #   Column              Non-Null Count  Dtype  
---  ------              --------------  -----  
 0   unique_id           294 non-null    int64  
 1   age                 204 non-null    float64
 2   weight              294 non-null    float64
 3   height              293 non-null    float64
 4   bmi                 293 non-null    float64
 5   city                294 non-null    object 
 6   num_covid_tests     234 non-null    float64
 7   num_positive_tests  264 non-null    float64
 8   sex                 261 non-null    object 
dtypes: float64(6), int64(1), object(2)
memory usage: 23.0+ 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'>
Int64Index: 269 entries, 0 to 208
Data columns (total 9 columns):
 #   Column              Non-Null Count  Dtype  
---  ------              --------------  -----  
 0   unique_id           269 non-null    int64  
 1   age                 205 non-null    float64
 2   weight              269 non-null    float64
 3   height              266 non-null    float64
 4   bmi                 266 non-null    float64
 5   city                269 non-null    object 
 6   num_covid_tests     229 non-null    float64
 7   num_positive_tests  246 non-null    float64
 8   sex                 235 non-null    object 
dtypes: float64(6), int64(1), object(2)
memory usage: 21.0+ 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'>
Int64Index: 300 entries, 0 to 208
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   num_positive_tests  270 non-null    float64
dtypes: float64(4), int64(1), object(1)
memory usage: 16.4+ KB

Czyszczenie szeregów czasowych

df_ts = generate_ts_data()
df_ts.set_index("SalesDate").plot(figsize=(15,9));
../_images/Czyszczenie danych_100_0.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))
<AxesSubplot:xlabel='SalesDate'>
../_images/Czyszczenie danych_106_1.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ć pojedyńcze 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));
../_images/Czyszczenie danych_111_0.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));
../_images/Czyszczenie danych_114_0.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))
<AxesSubplot:xlabel='SalesDate'>
../_images/Czyszczenie danych_117_1.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))
<AxesSubplot:xlabel='SalesDate'>
../_images/Czyszczenie danych_119_1.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));
../_images/Czyszczenie danych_122_0.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));
../_images/Czyszczenie danych_124_0.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));
../_images/Czyszczenie danych_126_0.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_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)}, 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))
C:\Users\knajmajer\.conda\envs\politechnika\lib\site-packages\statsmodels\tsa\base\tsa_model.py:524: ValueWarning: No frequency information was provided, so inferred frequency D will be used.
  warnings.warn('No frequency information was'
C:\Users\knajmajer\.conda\envs\politechnika\lib\site-packages\statsmodels\tsa\base\tsa_model.py:524: ValueWarning: No frequency information was provided, so inferred frequency D will be used.
  warnings.warn('No frequency information was'
C:\Users\knajmajer\.conda\envs\politechnika\lib\site-packages\statsmodels\tsa\base\tsa_model.py:524: ValueWarning: No frequency information was provided, so inferred frequency D will be used.
  warnings.warn('No frequency information was'
C:\Users\knajmajer\.conda\envs\politechnika\lib\site-packages\statsmodels\tsa\statespace\sarimax.py:978: UserWarning: Non-invertible starting MA parameters found. Using zeros as starting parameters.
  warn('Non-invertible starting MA parameters found.'
<AxesSubplot:xlabel='SalesDate'>
../_images/Czyszczenie danych_128_2.png

tym sposobem otrzymujemy oczyszczony szereg czasowy

df_ts["SLSU"] = df_ts["SLSU"].fillna(df_ts["prediction"])
df_ts["SLSU"].plot(figsize=(15,9))
<AxesSubplot:xlabel='SalesDate'>
../_images/Czyszczenie danych_130_1.png