Czyszczenie danych#
Czyszczenie danych jest to proces polegający na identyfikacji i naprawie różnego rodzaju błędów w danych.
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
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#
Rodzaje wartości brakujących
MCAR - Missing Completely At Random
MAR - Missing At Random
MNAR - Missing Not At Random
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.
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.
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: >

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: >

Znalezieniu relacji może służyć również heatmap macierzy korelacji.
missingno.heatmap(df, cmap='rainbow')
<Axes: >

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#
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.
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ą
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
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
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
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 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.
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'>

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'>

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

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'>

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'>

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'>

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'>

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'>

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'>

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: >

Tym sposobem otrzymujemy oczyszczony szereg czasowy.
df_ts["SLSU"] = df_ts["SLSU"].fillna(df_ts["prediction"])
df_ts["SLSU"].plot(figsize=(15,9))
<Axes: >
