Chyba każdy programista Python analizujący duże zbiory danych, chociaż raz spotkał się w swojej pracy z błędem „out of memory”. Jak sobie z nim radzić?
Z tego artykułu dowiesz się: 1. Czemu Pandas ma problem z dużymi zbiorami? 2. Jakie są dobre praktyki pracy z dużymi danymi w Pythonie? 3. Jakie przetwarzać duże zbiory z użyciem Pandas?
Przyczyna błędu jest prozaiczna. Pandas, główna biblioteka używana do analizy danych w Pythonie domyślnie przechowuje zbiór w pamięci RAM. Celem takiego zabiegu jest przyspieszenie wykonywanych obliczeń. W przypadku małych zbiorów nie jest to problem. Schody zaczynają się w chwili, gdy zaczynami analizować naprawdę duże dane.
Poniżej przedstawiam 5 znanych mi sposobów na radzenie sobie z opisanym problemem. Do każdego z nich załączę kod przedstawiający implementację danej metody w praktyce.
Jako źródło danych użyję przykładowego zbioru pobranego z witryny UCI. Zawiera on dane dotyczące konsumpcji energii elektrycznej w gospodarstwach domowych. Składa się on z 9 zmiennych i niemal 2 mln obserwacji. Po niewielkiej obróbce (oryginalnie zawierał niemal 26 tysięcy „pustych” obserwacji) zajmuje on 118MB na dysku, co oczywiście nie powala, ale wydaje się wystarczające na potrzeby tego ćwiczenia 🙂
in: !du -h household_power_consumption_clean.txt
out: 118M household_power_consumption.txt
Jak wygląda zbiór? Podejrzyjmy pierwszych pięć obserwacji:
Date | Time | Global_active_power | Global_reactive_power | Voltage |
---|---|---|---|---|
16/12/2006 | 17:24:00 | 4.216 | 0.418 | 234.840 |
16/12/2006 | 17:25:00 | 5.360 | 0.436 | 233.630 |
16/12/2006 | 17:26:00 | 5.374 | 0.498 | 233.290 |
16/12/2006 | 17:27:00 | 5.388 | 0.502 | 233.740 |
16/12/2006 | 17:28:00 | 3.666 | 0.528 | 235.680 |
Global_intensity | Sub_metering_1 | Sub_metering_2 | Sub_metering_3 |
---|---|---|---|
18.400 | 0.000 | 1.000 | 17.000 |
23.000 | 0.000 | 1.000 | 16.000 |
23.000 | 0.000 | 2.000 | 17.000 |
23.000 | 0.000 | 1.000 | 17.000 |
15.800 | 0.000 | 1.000 | 17.000 |
Jak widać, ma on dosyć prostą strukturę, aczkolwiek są niewielkie błędy:
- data i godzina znajdują się w osobnych zmiennych – z punktu widzenia badacza danych jest to niepotrzebne,
- zmienne całkowitoliczbowe są zapisane z separatorem dziesiętnym – przez to Pandas domyślnie wczytuje je jako 'float’.
Zamiast traktować owe błędy jako problem, spróbuję obrócić je na swoją korzyść 🙂
1. Zadbanie o poprawne wczytanie zbioru.
Optymalizację warto zacząć jeszcze przed wczytywaniem danych. Warto sprawdzić w zbiorze takie rzeczy jak:
- użyty separator dziesiętny,
- użyty separator oddzielający kolejne zmienne,
- oznaczenie brakujące wartości,
- nagłówek pliku.
Pominięcie powyższych elementów może sprawić, że zmienne numeryczne zostaną wczytane jako zmienne tekstowe i w pamięci zbiór zajmie zdecydowanie więcej miejsca, niż powinien. Pamiętajmy, że typem zmiennych, który jest optymalny zarówno pod kątem przechowywania, jak i modelowania danych jest typ numeryczny.
df = pd.read_csv('data/household_power_consumption_clean.csv.gz', decimal= '.', sep = ',', header=0, compression = 'gzip') # sprawdźmy wielkość zbioru df.info(memory_usage = 'deep')
out: memory usage: 365.4 MB
Bez wykonywania żadnych dodatkowych optymalizacji zbiór zajmuje 365.4 MB w pamięci (źle wczytany zajmowałby jeszcze więcej). Zobaczmy, ile jesteśmy w stanie z tego urwać 🙂
DATA SCIENCE SUMMIT 2018 Już 8 czerwca 2018, w Warszawie odbędzie się największa w Polsce konferencja, której głównym tematem jest Data Science. Organizatorzy przygotowali 7 ścieżek tematycznych, z których jedna odbędzie się z moim udziałem :) O godz. 13:40 w ramach ścieżki "Machine Learning & Miscellaneous DS topics" opowiem o problemie scoringu. Będzie to case study z procesu budowy systemu scoringowego. Podczas prezentacji omówię kolejne etapy projektu, począwszy od analizy potrzeby biznesowej, aż do walidacji i przygotowania finalnego modelu. Ciągle trwają zapisy, a liczba dostępnych miejsc jest ograniczona, dlatego warto się spieszyć. Więcej informacji na temat wydarzenia znajdziesz na jego oficjalnej stronie. Serdecznie zapraszam w imieniu swoim i organizatorów. Do zobaczenia! :)
2. Wskazanie typów zmiennych przy wczytywaniu danych.
Jeśli wiemy, jaka jest struktura zbioru, to już na wstępie możemy ją wskazać Pandas-owi. Korzysta on z typów zmiennych dostepnych w numpy. Ich lista, wraz z informacją dotyczącą zakresu, jaki przyjmują, znajduje się tutaj.
Możesz się zastanawiać: „Skąd mam wiedzieć jakie wartości przyjmuje dana zmienna i jaki jest ich zakres?”. Można to sprawdzić na kilka sposobów. Najprostszy z nich, to użycie funkcji MIN(), MAX() i wykonanie podstawowej analizy bezpośrednio na źródle danych, np. bazie SQL.
# przygotowuję parser, który pomoże poprawnie wczytać datę i godzinę dateparse_3 = lambda x: pd.to_datetime(x, format = '%d/%m/%Y %H:%M:%S') # wczytuję ponownie zbiór df = pd.read_csv('data/household_power_consumption_clean.csv.gz', decimal= '.', sep = ',', header=0, compression = 'gzip', dtype = {'Global_active_power':np.float32, 'Global_reactive_power':np.float32, 'Voltage':np.float64 , 'Global_intensity':np.float32, 'Sub_metering_1':np.uint8, 'Sub_metering_2':np.uint8, 'Sub_metering_3':np.uint8}, date_parser = dateparse_3, parse_dates = {'Date_time':['Date', 'Time']})
in: df.info(memory_usage='deep')
out: memory usage: 60.6 MB
Z początkowych 365.4 MB zostało jedynie 60.6 MB. Oznacza to, że poprzez wykonanie powyższego zabiegu udało się obniżyć wykorzystanie pamięci o ok. 83.4% 🙂
3. Użycie iteratora.
Jeśli po zastosowaniu powyższych dwóch sposobów, zbiór ciągle nie mieści się do pamięci, to do jego wczytywania można użyć iteratora i pobrać zbiór w mniejszych paczkach.
df = pd.read_csv('data/household_power_consumption_clean.csv.gz', decimal= '.', sep = ',', header=0, compression = 'gzip', dtype = {'Global_active_power':np.float32, 'Global_reactive_power':np.float32, 'Voltage':np.float64 , 'Global_intensity':np.float32, 'Sub_metering_1':np.uint8, 'Sub_metering_2':np.uint8, 'Sub_metering_3':np.uint8}, date_parser = dateparse_3, parse_dates = {'Date_time':['Date', 'Time']}, iterator = True) # teraz wystarczy pobierać mniejsze paczuszki, które zmieszczą się do pamięci :) df.get_chunk(1000000) # pobiera 1 mln pierwszych obserwacji
Należy pamiętać, że każde wywołanie
df.get_chunk(n)
, pobierze n-kolejnych obserwacji ze zbioru.
4. Wczytywanie zbioru w częściach o wskazanej wielkości.
Alternatywnie do metody numer 3, można użyć wczytywania zbioru w „porcjach” o wskazanej wielkości.
chunks = pd.read_csv('data/household_power_consumption_clean.csv.gz', decimal= '.', sep = ',', header=0, compression = 'gzip', dtype = {'Global_active_power':np.float32, 'Global_reactive_power':np.float32, 'Voltage':np.float64 , 'Global_intensity':np.float32, 'Sub_metering_1':np.uint8, 'Sub_metering_2':np.uint8, 'Sub_metering_3':np.uint8}, date_parser = dateparse_3, parse_dates = {'Date_time':['Date', 'Time']}, chunksize = 1000000) for chunk in chunks: print('Wymiary:', chunk.shape) print(chunk.head(),'\n') # każdy chunk to osobny df
Podobnie jak w przypadku iteratora, scenariusz pracy opierać się będzie na analizie i obróbce mniejszych zbiorów. Przykładowo można się tu skupić na operacjach mających na celu transformację zbioru w takich sposób, by zajmował on jak najmniej miejsca, np.: zmiana kodowania zmiennych kategorycznych, przerzucenie kolejnych części zbioru do tablic
ndarray
i na końcu ich połączenie.
5. Ładowanie całego pliku do lokalnej bazy sql.
Opisane w punkcie 3 i 4 metody mogą być uciążliwe, ponieważ samo operowanie na „kawałkach” zbioru jest dosyć niewygodne. Można jednak temu zaradzić poprzez wczytanie danych do lokalnej bazy sql, a następnie analizę, przekształcenia i ewentualne „uszczuplenie” zbioru z pomocą SQL-a.
# przygotowuję nową bazę sqlite database = create_engine('sqlite:///csv_database.db') # wczytuję zbiór w częściach mieszczących się w pamięci chunks = pd.read_csv('data/household_power_consumption_clean.csv.gz', decimal= '.', sep = ',', header=0, compression = 'gzip', dtype = {'Global_active_power':np.float32, 'Global_reactive_power':np.float32, 'Voltage':np.float64 , 'Global_intensity':np.float32, 'Sub_metering_1':np.uint8, 'Sub_metering_2':np.uint8, 'Sub_metering_3':np.uint8}, date_parser = dateparse_3, parse_dates = {'Date_time':['Date', 'Time']}, chunksize = 1000000) # zapisuję kolejne części do bazy for chunk in chunks: chunk.to_sql('power_consumption', database, if_exists='append') # zawężam zbiór z pomocą zapytania SQL pd.read_sql_query('SELECT * FROM power_consumption limit 0,10', database)
Zastosowanie powyższej metody w praktyce może wyglądać następująco:
# wykonuje dyskretyzację zmiennej 'Voltage' - zasymuluję sytuację ze zmiennę kategoryczną i grupowaniem df['Voltage'] = pd.cut(df.Voltage, bins = [220,238,241,242,np.Inf], labels = ['low', 'medium', 'high', 'extreme']) # zapisuję dane do bazy df.to_sql('power_consumption', csv_database, if_exists='append') # wykonuje przykładową analizę z użyciem języka SQL print(pd.read_sql_query('SELECT Voltage, AVG(Global_active_power), AVG(Global_reactive_power),SUM(Sub_metering_1) FROM power_consumption group by Voltage order by AVG(Global_active_power) DESC', database)) # załóżmy, że mając na uwadze uzyskany wynik, interesują nas tylko "typowe" obserwacje, bez wartości odstających # pobieram więc uszczuplony zbiór do dataframe-u df_l = pd.read_sql_query('SELECT Date_time, Global_active_power, Global_reactive_power, Voltage, Global_intensity, Sub_metering_1, Sub_metering_2, Sub_metering_3 FROM power_consumption WHERE Voltage IN (\'medium\', \'high\')', database) print('Wielkość uszczuplonego zbioru: {}'.format(df_l.shape))
out:
Voltage | AVG(Global_active_power) | AVG(Global_reactive_power) | SUM(Sub_metering_1) | |
---|---|---|---|---|
0 | low | 1.8953427236615088 | 0.13958331521527392 | 1305006 |
1 | medium | 1.2018445272770284 | 0.13204193742880158 | 705799 |
2 | high | 0.8532244535310691 | 0.1228525719275087 | 120849 |
3 | extreme | 0.702385055625531 | 0.10890083041569508 | 167481 |
out: Wielkość uszczuplonego zbioru: (962255, 8)
BONUS: Użycie dataframe z biblioteki Dask.
O Dask-u po raz pierwszy usłyszałem dosyć niedawno. Zarekomendował mi go jeden z czytelników bloga. Wykonałem z użyciem tej biblioteki jedynie krótkie testy i nie mam projektowego doświadczenia w pracy z nią, dlatego też zamieszczam Dask-a jako bonus. Na oficjalnym Githubie projektu autorzy umieścili przykład użycia.
Podsumowanie
Wymienione przeze mnie metody oczywiście nie wyczerpują zagadnienia. Są one jednak czymś, od czego warto zacząć w razie ewentualnych problemów. Ciekaw jestem Twoich doświadczeń w tej dziedzinie. Czy natknąłeś się kiedyś na problem związany ze zbiorem, który nie mieścił się w pamięci? Jak sobie poradziłeś? Jeśli tylko możesz, to proszę podziel się swoimi doświadczeniami 🙂
–
Linki:
PODOBAŁ CI SIĘ TEN ARTYKUŁ?
Jeśli tak, to zarejestruj się, by otrzymywać informacje o nowych wpisach.
Dodatkowo w prezencie wyślę Ci bezpłatny poradnik :-)
Witam, bardzo przydatny wpis. Mam pytanie do 4 punktu w jaki sposób robić obliczenia na wielu wczytanych chunk jako DF. Łączyć DF między sobą jest łatwo, ale jak się nazywają te DF zapisane w chunk? Tu utknąłem
Dzień dobry, bardzo przydatny artykuł.
mam pytanie do:
print(chunk.head(),’\n’) # każdy chunk to osobny df
jak odnieść się do danego df1, df2, df3 z zaimportowanych?