SQLite i Python – czy warto?

Nie da się za dużo mówić (i pisać) o danych bez poruszenia tematu baz danych. Faktem jest, że sama koncepcja baz danych jest równie stara, jak koncepcja uczenia maszynowego. Jest też w mojej skromnej opinii równie, jeśli nie bardziej rozbudowana. Może warto więc zastanowić się, jak możemy wykorzystać bazę danych w projektach data science, które będziemy realizować?

Tak jak wspomniałem powyżej, ponad 50-letnia historia baz danych dostarczyła tylu zagadnień, że można by poświęcić karierę ich badaniu i stosowaniu. Ja jednak mam inną propozycję. Wykorzystajmy absolutne minimum „z baz danych” i sprawdźmy, jak możemy się do niego dobrać z poziomu Pythona. Oczywiście, może się zdarzyć, że od wielu lat pracujesz już z bazami danych i SQLem i potrafisz przy ich pomocy zrobić bardzo wiele ciekawych rzeczy i podany przeze mnie poniżej przykład może okazać się trywialny. Jednakże z perspektywy osób, które zaczynają od Pythona, plików csv i ramek danych, bazy danych to odległy i nie zawsze intuicyjny byt.

Baza danych – dlaczego warto?

Po co nam w ogóle może być baza danych? Jednym z argumentów może być to, że już używamy baz danych w naszym przedsięwzięciu. Bazy danych całkiem dobrze sobie radzą z danymi, więc nawet jeśli nie stosowaliśmy żadnej analityki i modeli predykcyjnych, pewnie trzymaliśmy nasze dane właśnie tam.

Drugim argumentem jest to, że bazy danych wymagają trzymania się standardów. Oczywiście możemy sobie stworzyć tabelę z całą masą kolumn „na zapas”, które mogą być dowolne. Wtedy do takiej tabeli ładujemy wszystko jak popadnie i nie przejmujemy się tworzeniem standardów. Jest to jednak antywzorzec. Dobrym pomysłem jest natomiast zastanowienie się, jaki typy danych będziemy przechowywać w poszczególnych kolumnach. Wtedy, jeśli coś się poknoci w programie, to jest szansa, że baza danych powie „zły typ danych, nie biorę” i będziemy mieć problem do rozwiązania (co jest akurat dobrą informacją, bo gorzej byłoby, gdybyśmy ładowali śmieci do bazy i problemu, by nie było).

Trzecim argumentem, który przychodzi mi do głowy to fakt, że dobrze zaprojektowane bazy danych są szybkie. Wynika to z tego, że bazy danych są obsługiwane przez silniki baz danych, które są optymalizowane praktycznie od początku istnienia baz danych. Jednym z zadań silników jest takie ogarnięcie danych i zapytań SQL żeby dać odpowiedź jak najszybciej. Zapytanie więc o znalezienie najmniejszej wartości w kolumnie może zostać zrealizowane o kilka(-naście) rzędów wielkości szybciej niż przy użyciu ramki danych w „zwykłym” języku programowania. Nie mówiąc już o tym, że może zdarzyć się, że przy pomocy ramki danych w ogóle się za to nie zabierzemy, bo nie będziemy mogli zmieścić wszystkich danych w pamięci RAM.

Czwartym elementem pokazującym sensowność baz danych jest to, że komunikacja z najbardziej popularnymi rodzajami i implementacjami baz danych odbywa się za pomocą języka SQL. I to jest bardzo dobra wiadomość. Wystarczy bowiem, że raz dobrze zajmiemy się przyswojeniem tego języka i mamy niejako z głowy uczenie się innych języków, a nie tracimy z oczu aż tak dużo horyzontu baz danych. Fakt, SQL cały czas ewoluuje, a niektóre firmy wzbogacają go własnymi dodatkami, więc żeby być na bieżąco, trzeba czasem zerknąć do dokumentacji i zobaczyć, z jakimi nowinkami albo bonusami mamy do czynienia.

Bazy danych – dlaczego nie warto?

Jeżeli nasza praca polega na analizie danych i budowaniu modeli predykcyjnych to warto zastanowić się, jak bardzo chcemy zanurzać się w koncepcjach baz danych. No bo jeśli zaczynamy dzień od wykonania zapytania SQL (które mamy gdzieś w notatkach) i wrzucenia jego wyników do jakiejś zmiennej w Pythonie/R i dopiero wtedy zaczynamy pracę, to chyba nie warto. Szczególnie jeśli mamy w zespole/firmie eksperta, który zajmuje się właśnie bazami danych. Wtedy, nawet jeśli natrafimy na problem, to ten ekspert pewnie wymyśli coś o wiele sensowniejszego niż my. Może więc nie mieś sensu uczenie się SQL „na wszelki wypadek”.

Może też się zdarzyć, że pracujemy w zespole badawczym, który tych danych za wiele nie ma. Nie jest na przykład niczym zaskakującym w projektach z zakresu nauk przyrodniczych, jeśli eksperyment, na który poświęciliśmy pół roku, daje nam 20-300 punktów pomiarowych. Dane te często mają również mało wymiarów, więc trzymanie ich w plikach csv nie jest wcale takim złym pomysłem. Zawsze można się do nich dobrać nawet z edytora tekstowego, łatwo się je kopiuje i kontroluje wersję. No i nie trzeba przeszkolić całego zespołu z obsługi baz danych.

Innym argumentem przemawiającym przeciwko bazie danych jest dołożenie kolejnego elementu do naszego zestawu narzędzi IT. No bo poza kilkoma wyjątkami (np. tytułowy SQLite), silniki baz danych wymagają jakiejś uwagi. Czasem uda nam się je postawić i korzystać z domyślnymi ustawieniami, a czasem jest to niemożliwe. Może więc się zdarzyć, że nakłady pracy „żeby w ogóle zadziałało” będą większe niż praca z danymi. Warto więc zastanowić się i nad tym.

Baza danych w projekcie data science

Być może zapomniałem o jakimś oczywistym argumencie za lub przeciw używaniu bazy danych w projektach data science. Być może namieszałem Ci też trochę w głowie albo (mam nadzieję) rozwiałem trochę Twoich wątpliwości. Czas więc chyba na zastanowienie się jak najszybciej i sensowniej możemy użyć bazy danych w projekcie. Aktualnie do głowy przychodzą mi trzy zastosowania:

  • wejście
  • przetwarzanie
  • wyjście

Zacznijmy może przewrotnie od przetwarzania. Możemy dyskutować, czy najpopularniejszy język zapytań baz danych ma cechę Kompletność Turinga. Jeśli ma, oznacza to, że przy jego pomocy możemy (teoretycznie) zrobić wszystko to, co przy pomocy innych języków. Nawet jeśli nie ma, to i tak wielu rzeczy zdecydowanie nie warto byłoby jego pomocy implementować. Ale z racji, że bazy danych w pewnych zastosowaniach są diabelnie szybkie, pewne problemy mogą zostać rozwiązane przy ich pomocy bardzo szybko. Wszelakie agregacje, grupowania, filtrowania, statystyki, operacje typu map – reduce mogą dziać się tu w mgnieniu oka, gdzie np. ramka danych z Pandas będzie potrzebować kilku sekund na wykonanie. Fakt, na tym etapie potrzebujemy sensownie działającego silnika baz danych. Ale jeśli go mamy, możemy zastanowić się, czy wspomnianych powyżej działań nie warto wykonać z poziomu SQL, a w Pythonie nie ogarniać już gotowego rezultatu.

Najbardziej sensownym zastosowaniem, które możemy uzyskać niejako z marszu, jest przechowywanie uzyskanych przez nas danych. Jeśli śledzisz uważnie ten blog, to zauważyłeś pewnie, że do tej pory tego nie robiliśmy. Zawsze zaczynaliśmy od pobierania danych, ich przetwarzania a na końcu interpretacji. Wyniki tego przetwarzania trzymaliśmy w pamięci więc gdy kończyliśmy pracę, ulegały one zniszczeniu. Na potrzeby przykładów takie podejście było wystarczające. Jednak w prawdziwym życiu nie ma to sensu. Możemy na przykład zapisywać dane (a w zasadzie całe obiekty) w plikach pickle/dill. Jednakże powstałe pliki dość mocno związane są z Pythonem (i wersjami modułów), używanie ich więc poza środowiskiem projektowym będzie trudne. Możemy też używać plików csv, ale jeśli naszych danych będzie dużo, albo będziemy chcieli je dodawać i usuwać „na żądanie”, to będzie to dość trudne i niewygodne. I w tym miejscu pojawia się baza danych. Jeżeli chcemy przechować np. całą ramkę danych, po zakończonej pracy, to wystarczy, że wrzucimy ją jednym strzałem do bazy danych przy pomocy metody to_sql(). A jeżeli nasze dane wpadają „na żywo” to możemy je sobie wrzucać do bazy danych, wtedy kiedy się pojawiają.

No, a jeśli już nasze dane są w bazie danych, to naturalne, że będziemy je stamtąd pobierać, jeśli będziemy chcieli kontynuować pracę. Tutaj nie mam nic więcej sensownego do powiedzenia.

SQLite

Ok, czas na konkrety. SQLite to silnik do relacyjnej bazy danych i jednocześnie biblioteka do jego obsługi. Ma to taką zaletę, że nie musimy instalować i uruchamiać niczego ekstra w systemie – po prostu korzystamy z biblioteki, która sama wszystko ogarnia. Z racji tego, że nie mamy żadnej usługi działającej w systemie, nie mamy odgórnie zdefiniowanego miejsca na przechowywanie bazy danych, tylko przechowujemy ją w pliku. Dla wielu osób może to być zaskakujące, ale okazuje się, że całkiem nieźle sprawuje się to np. w systemie Android. Fakt, z racji tego, że w SQLite w ten sposób przechowuje bazy danych, może powodować nieco wolniejsze jej działanie. Ale jest to efekt odczuwalny wyłącznie w porównaniu z „pełnymi” bazami danych takimi jak MySQL i PostgreSQL. Jeżeli więc nie przerzucamy terabajtów danych, to nie musimy się zbytnio tym przejmować. A dzięki temu, że SQLIte jest dostępny jako całość z poziomu biblioteki, nie będziemy musieli nigdy „opuszczać” Pythona, żeby z niego korzystać.

Przykłady

Jeszcze zanim zaczniesz przerabiać przygotowane przeze mnie przykłady, dobrze by było, gdybyś zaopatrzył się w przeglądarkę bazy danych SQLite. Będziesz wtedy mógł podglądać, co faktycznie się dzieje w bazie danych poza Pythonem. Jest to bardzo przydatne narzędzie do namierzania potencjalnych błędów. Najsensowniejsze narzędzie, które namierzyłem i używam to DB Browser for SQLite.

Zacznijmy od importów modułów, które używam w poniższych przykładach. Wtedy będzie Ci łatwiej odtworzyć przykład, jeśli będziesz kopiował kod ze strony.

import sqlite3
import random
import time
import pandas as pd
import numpy as np

W drugim kroku wepnijmy się w bazę danych. Jako że SQLite używa plików na dysku, wystarczy, że podamy tu ścieżkę, która nas interesuje. Uwaga: cała ścieżka do pliku musi istnieć. Z racji tego, że ja zawsze uruchamiam swoje skrypty w katalogu workspace i trzymam wyniki w katalogu output, który jest „obok” na dysku, będziesz musiał stworzyć odpowiednie katalogi, jeśli będziesz szedł metodą kopiuj-wklej ;-). Plik z bazą danych zostanie utworzony automatycznie, jeśli odpowiednia ścieżka będzie dostępna.

conn = sqlite3.connect('../output/example.db')
c = conn.cursor()

Okej, teraz będziemy musieli stworzyć odpowiednią tabelę (tylko jeśli jeszcze nie istnieje) do pracy w naszej bazie danych. Dla przejrzystości przykładu stworzymy jedną z najprostszych możliwych struktur. Będą to trzy kolumny: jedna tekstowa, a dwie z liczbami rzeczywistymi.

c.execute('''CREATE TABLE IF NOT EXISTS readings (date text, PM25 real, PM10 real)''')

Wspominałem powyżej, że z bazą danych możemy pracować „na bieżąco”. Żeby to przetestować, przygotowałem pętlę, która będzie wytwarzać dwie wartości, które będą udawać odczyty z sensorów. Będziemy zapisywać te odczyty i aktualną datę z czasem jako jeden wiersz w bazie danych. Czyli utworzymy sobie nową obserwację.

counter = 0
while True:
    readings = {}
    readings["PM25"] = round(random.uniform(0, 121),1)
    readings["PM10"] = round(random.uniform(0, 201),1)
    readings["datetime"] = time.strftime('%Y-%m-%d %H:%M:%S')
    
    c.execute("INSERT INTO readings VALUES (?,?,?)", (readings["datetime"], readings["PM25"], readings["PM10"]))
    
    conn.commit()
    
    counter += 1
    if counter == 10:
        break

Najważniejsze elementy powyższego kodu to metody execute i commit. Pierwsza metoda (execute) jest zapytaniem SQL, które ma zostać wykonane. W naszym wypadku jest to umieszczenie wartości w odpowiednich kolumnach tabel. Nasz silnik od tej pory trzyma je niejako w pamięci podręcznej. I jeśli będziemy chcieli, to możemy je nawet próbować odczytać, mimo iż „technicznie” nie ma ich jeszcze w bazie. Żeby zaś faktycznie znalazły się w bazie, musimy wykonać drugą metodę (commit). W naszej sytuacji wystarczy, że sobie zapamiętamy, że jeśli wrzucamy coś do bazy „na żywo” to powinniśmy potem używać metody commit.

Skoro możemy zapisywać dane „na żywo” to możemy też odpytywać naszą bazę danych i dostawać od niej odpowiedzi w podobny sposób. Powiedzmy, że chcemy uzyskać obserwacje, które posiadają 10 najwyższych wartości „PM25”. Możemy zlecić bazie danych ogarnięcie odnalezienia tych wartości i pobrać wyniki:

for row in c.execute('SELECT * FROM readings ORDER BY PM25 DESC LIMIT 10'):
        print(row)

W moim przypadku wyglądało to tak:

('2018-12-16 11:18:08', 120.2, 53.4)
('2018-12-16 11:18:07', 117.2, 14.6)
('2018-12-16 09:22:54', 116.6, 186.9)
('2018-12-16 12:25:49', 109.8, 189.4)
('2018-12-16 09:22:54', 108.8, 77.6)
('2018-12-16 11:18:07', 100.5, 160.5)
('2018-12-16 12:25:50', 99.5, 10.6)
('2018-12-16 09:24:15', 92.9, 27.4)
('2018-12-16 09:22:53', 87.1, 180.8)
('2018-12-16 11:18:08', 85.6, 40.4)

Z racji, że dostawaliśmy te obserwacje linijka po linijce, możemy je sobie przetwarzać właśnie w taki sposób. Prawie zawsze jednak będziemy zainteresowani otrzymaniem całej gotowej ramki danych. I do tego użyjemy funkcji read_sql_query() dostarczonej przez Pandas. Możemy tam podawać różne konstrukcje SQL, ale równie dobrze może nas interesować pobranie całej tabeli (którą uzupełnialiśmy powyżej). Będzie to wyglądać w następujący sposób:

df = pd.read_sql_query("select * from readings", conn)

Przyjmijmy, że dokonujemy jakiejś magii data science na tej ramce danych i chcemy ją wrzucić do bazy danych. Chcemy ją wrzucić do osobnej tabeli, ale nie chcemy się bawić w definiowanie jej wyglądu. Możemy do tego użyć metody to_sql(). Po wrzuceniu zmodyfikowanej ramki danych od razu wyświetlamy nową tabelę.

df["additional"] = np.where(df["PM10"] > 100, "Alert!", None)
df.to_sql("readings_modified", conn, if_exists="replace", index=False)
for row in c.execute('SELECT * FROM readings_modified ORDER BY PM25 DESC LIMIT 10'):
        print(row)
('2018-12-16 11:18:08', 120.2, 53.4, None)
('2018-12-16 11:18:07', 117.2, 14.6, None)
('2018-12-16 09:22:54', 116.6, 186.9, 'Alert!')
('2018-12-16 12:25:49', 109.8, 189.4, 'Alert!')
('2018-12-16 09:22:54', 108.8, 77.6, None)
('2018-12-16 11:18:07', 100.5, 160.5, 'Alert!')
('2018-12-16 12:25:50', 99.5, 10.6, None)
('2018-12-16 09:24:15', 92.9, 27.4, None)
('2018-12-16 09:22:53', 87.1, 180.8, 'Alert!')
('2018-12-16 11:18:08', 85.6, 40.4, None)

Jeżeli nasz program ma zakończyć działanie, to powinniśmy zakończyć również połączenia z naszą bazą danych. Jeśli tego nie zrobimy, to istnieje całkiem spore ryzyko, że nie będziemy mogli modyfikować bazy danych, bo silnik będzie myślał, że inny program też dokonuje modyfikacji. Aby zamknąć połączenie do bazy, wystarczy, że użyjemy odpowiedniej funkcji:

conn.close()

Podsumowanie

Trochę ponarzekałem, trochę spekulowałem, ale też znalazłem trochę zalet baz danych. Prawda jest jednak taka, że jeśli pracujemy w IT, to warto mieć jakąkolwiek umiejętność korzystania z baz danych. A szczególnie jeśli zajmujemy się data science. Jaki to powinien być poziom umiejętności, będzie już zależeć od kontekstu. Moim celem było natomiast pokazanie, że z bazami możemy zacząć działać już niejako mimochodem realizowania projektów, które zupełnie nie są z nimi związane. A więc życzę udanego i efektywnego eksperymentowania i używana baz danych ;-).

Pełny kod użyty w przykładach znajduje się tutaj.

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *