5 sposobów na radzenie sobie z dużymi zbiorami danych w Python

błąd, python, duży zbiór, data science, pandas

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 🙂

2 Komentarze

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

  2. 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?

Dodaj komentarz

Twój adres email nie zostanie opublikowany.


*