Podstawy pakietu Pandas - część 1

Wstęp

Czym jest Pandas?

Pandas jest w tym momencie podstawowym pakietem służącym do przetwarzania i analizy danych w języku Python. Oparty na NumPy, poznanym we wcześniejszych wpisach, dziedziczy po nim wiele funkcjonalności i zasad działania. Wiele metod i podejść, jak optymalizacja operacji element po elemencie na całych tablicach, sposoby indeksowania czy też wykorzystanie uniwersalnych funkcji (unfunc), działają zazwyczaj tak samo, lub są tylko rozwinięciem tego, co znamy z NumPY.

Pandas świetnie nadaje się do pracy ze wszelkiego rodzaju samymi tabelarycznymi. Nie jesteśmy tu ograniczeni wymogiem znanym z NumPy, aby wszystkie dane były tego samego typu. W tym pakiecie wystarczy, aby wszystkie dane w jednej kolumnie posiadały ten sam tym. Dzięki temu usprawnieniu Pandas z powodzeniem pozwala zapisać dane o strukturze tablic SQL, arkuszy Excel'a, ciągów czasowych czy ogólnie wszelkiego rodzaju danych pomiarowych, czy statystycznych.

Struktury danych dostępne w Pandas

Podstawowymi dwoma strukturami zapisu danych w PandasSeries dla danych jednowymiarowych oraz DataFrame dla dwuwymiarowych. Aby zacząć opis struktur danych, musimy w pierwszej kolejności zaimportować biblioteki. Poza pandas zaimportujmy również numpy, gdyż w bardzo wielu przykładach będziemy korzystać również z tej biblioteki, zwłaszcza do budowy Series i DataFrames na podstawie ndarrays.

In [1]:
import pandas as pd
import numpy as np

Gdy w naszym jupyter notebook'u mamy poprawne importy, możemy przejść do pierwszej, prostszej w opisie struktury, a mianowicie Series.

Series

Seria w Pandas jest najprostszym obiektem przechowującym dane. Jest strukturą jednowymiarową, posiadającą etykiety każdego elementu. Etykiety (labels) możemy w uproszczeniu rozumieć jak indeksy jednowymiarowej tabeli czy tablicy ndarray, z tą jednak różnicą, że możemy nadać serii dowolne etykiety.

Najprostszym sposobem stworzenia serii w pandas jest przekazanie do konstruktora iterowalnego obiektu, takiego jak lista, tupla czy ndarray.

In [2]:
pd.Series([1, 3.2, 0.1, 2.])
Out[2]:
0    1.0
1    3.2
2    0.1
3    2.0
dtype: float64
In [3]:
s = pd.Series(np.random.randn(4), index=['a', 'b', 'c', 'd'])
s
Out[3]:
a    1.356471
b    0.042698
c    0.015426
d   -0.913081
dtype: float64

W wypadku, gdy nie sprecyzujemy jakie etykiety mają być, staną się nimi kolejne liczny całkowite numerowane od zera. Gdy jednak chcemy określić dokładnie, jakie chcemy etykiety nadać naszej serii. Zrobimy to, definiując je w wywołaniu pd.Series() parametrem index. Możemy do niego przekazać dowolnym obiekt listopodobny z tym tylko zastrzeżeniem, że musi być tej samej długości co dane. Każda seria posiada również atrybut index, przy pomocy którego możemy pobrać jej etykiety.

In [4]:
s.index
Out[4]:
Index(['a', 'b', 'c', 'd'], dtype='object')

Kolejnym powszechnym sposobem tworzenia serii w Pandas jest przekazanie słownika. W takim wypadku nie jest konieczne precyzowanie etykiet, gdyż zostaną za nie przypisane klucze słownika. Gdy jednak przekażemy etykiety w parametrze index, nadpiszą one te wygenerowane na podstawie kluczy.

In [5]:
pd.Series({'f':1, 'g':5, 'h':3})
Out[5]:
f    1
g    5
h    3
dtype: int64

Jesteśmy również w stanie utworzyć serię na podstawie wartości skalarnej. W tym jednak wypadku musimy zdefiniować listę z etykietami, gdyż na tej podstawie Pandas będzie wiedzieć, ile elementów z tą samą wartością skalarną ma się znaleźć w serii.

In [6]:
pd.Series(1., index=['a', 'b', 'c'])
Out[6]:
a    1.0
b    1.0
c    1.0
dtype: float64

O seriach pakietu Pandas możemy z powodzeniem myśleć jak o Pythonowych listach. Zadziała indeksowanie czy tworzenie elementów na podstawie numeru kolejnego elementu, nawet jeśli etykiety nie są kolejnymi liczbami całkowitymi. Pandas zrozumie nas, kiedy będziemy, dla przykładu, chcieli pobrać pierwszy, bądź dwa ostatnie elementy korzystając z zapisu znanego z indeksowania pythonowych list czy ndarrays.

In [7]:
s[0]
Out[7]:
1.356471223935805
In [8]:
s[2:]
Out[8]:
c    0.015426
d   -0.913081
dtype: float64

Jeśli chcemy, możemy również przekształcić serię właśnie w ndarray za pomocą metody pd.Series.to_numpy(). Spowoduje to utratę etykiet, zapisany zostanie tylko ciąg wartości.

In [9]:
s.to_numpy()
Out[9]:
array([ 1.35647122,  0.04269826,  0.01542582, -0.91308132])

O pd.Series możemy również myśleć jak o swego rodzaju słowniku. Tak jak wartość słownika możemy uzyskać, korzystając z zapisu dictionary['key'], tak w serii przekazując do tego samego zapisu nazwę etykiety, otrzymamy odpowiadającą jej wartość. Również przypisanie wartości do etykiety nieistniejącej w serii spowoduje dodanie tej wartości wraz z etykietą.

Do tego, tak samo, jak w przypadku słowników możemy sprawdzić, czy dana etykieta znajduje się w serii za pomocą operatora is.

In [10]:
s['a']
Out[10]:
1.356471223935805
In [11]:
s['e'] = 10
s
Out[11]:
a     1.356471
b     0.042698
c     0.015426
d    -0.913081
e    10.000000
dtype: float64
In [12]:
'e' in s
Out[12]:
True
In [13]:
'f' in s
Out[13]:
False

O seriach możemy również myśleć jak o ndarrays. Wszystkie metody pakietu NumPy przyjmujące tablicę, zadziałają również dla serii Pandas. Na seriach można również wykonywać operacje arytmetycznie podobnie jak na tablicach. Wykonają się one analogicznie, element po elemencie, zwracając serię o tej samej długości.

In [14]:
np.sin(s)
Out[14]:
a    0.977120
b    0.042685
c    0.015425
d   -0.791391
e   -0.544021
dtype: float64
In [15]:
s*10
Out[15]:
a     13.564712
b      0.426983
c      0.154258
d     -9.130813
e    100.000000
dtype: float64

DataFrame

Kolejną obiektem przechowującym dane w Pandas jest DataFrame. Jest to zdecydowanie najczęściej wykorzystywana struktura w Pandas i ogólnie w data science opartym o Python. DataFrames są to dwuwymiarowe tabelaryczne obiekty, składające się z kolumn, gdzie kolumny mogą się różnic między sobą typem danych.

Zazwyczaj zmienną przechowującą DataFrame nazywamy w skrócie od pierwszych liter nazwy obiektu df.

Najbardziej bezpośrednim sposobem na utworzenie DataFrame jest przekazanie do konstruktora słownika, którego wartościami są Series. W ten sposób każda seria stanie się kolumną tablicy, a klucze słownika staną się nazwami kolumn.

In [16]:
d = {'fst': pd.Series(np.random.rand(3), index=list('abc')),
     'snd': pd.Series(np.random.rand(4), index=list('abcd'))}
df = pd.DataFrame(d)
df
Out[16]:
fst snd
a 0.891356 0.662332
b 0.861472 0.204189
c 0.080433 0.562178
d NaN 0.876908

Jeśli obiekty, z których mają zostać zbudowane kolumny tabeli, w tym wypadku pd.Series(), są różnej długości, lub ich indeksy się różnią, kolumny będą zawierały wszystkie elementy, a w wypadku braku odpowiadającego indeksu w którejś z nich, zostanie wstawiona wartość np.NaN.

Każda z tablic posiada atrybuty index (podobnie jak serie) oraz columns, za pomocą których możemy otrzymać nazwy kolumn i etykiet (wierszy) tabeli.

In [17]:
df.columns
Out[17]:
Index(['fst', 'snd'], dtype='object')
In [18]:
df.index
Out[18]:
Index(['a', 'b', 'c', 'd'], dtype='object')

Podobnie jak w przypadku pd.Series() jeśli nie przekażemy za pomocą atrybutu index listy etykiet, za etykiety (indeksy) przypisany zostanie ciąg liczby całkowitych zaczynających się od zera. Gdy natomiast któraś wartość tablicy indeksów nie będzie miała odzwierciedlenia w kolumnie, nie zostanie ona pominięta, jedynie w tym miejscu zostaje wstawione np.NaN. Również, gdy indeks nie występuje w żadnej z kolumn.

In [19]:
pd.DataFrame(d, index=list('bcde'))
Out[19]:
fst snd
b 0.861472 0.204189
c 0.080433 0.562178
d NaN 0.876908
e NaN NaN

W wypadku budowy pd.DataFrame() na podstawie słownika Pandas automatycznie przypisuje klucze słownika jako nazwy kolumn. Jeśli jednak chcemy zmienić ich kolejność albo wykorzystać tylko część z nich, możemy przekazać w parametrze columns listę wybranych kluczy słownika, w wybranej przez nas kolejności.

In [20]:
pd.DataFrame(d, columns=['snd', 'fst'])
Out[20]:
snd fst
a 0.662332 0.891356
b 0.204189 0.861472
c 0.562178 0.080433
d 0.876908 NaN

DataFrame nie potrzebuje słownika serii, w zupełności wystarczy, aby słownik jako wartości zawierał pythonowe obiekty, po których można iterować, typu listy, tuple czy ndarray. W tym wypadku, gdy nie przekażemy listy z indeksami, zostaną przypisane kolejne liczby całkowite.

In [21]:
d = {'one': [1, 2, 3, 4], 'two': [11, 12, 13, 14]}
pd.DataFrame(d)
Out[21]:
one two
0 1 11
1 2 12
2 3 13
3 4 14
In [22]:
pd.DataFrame(d, index=list('abcd'))
Out[22]:
one two
a 1 11
b 2 12
c 3 13
d 4 14

Jeśli w momencie tworzenia pd.DataFrame() nie zdefiniowaliśmy indeksów, wartości im można również przypisać korzystając z atrybutu pd.DataFrame.index, przypisując do niego listę z indeksami.

df.index = list('abcd')

Oczywiście również w tym przypadku długość listy indeksów musi być zgodna z długością kolumny tablicy.

Wygodnym sposobem tworzenia tablic DataFrame jest też przekazanie listy słowników. W takim wypadku każdy słownik odpowiada jednemu wierszowi tablicy, a każdy klucz w słowniku nazwie kolumny, do której zostanie przypisana jego odpowiadająca wartość.

W przypadku, gdy w którymś słowniku brakuje jakiegoś klucza występującego w innych słownikach, do tego pola zostanie przypisane np.NaN.

In [23]:
l = [{'a': 1, 'b': 2, 'c': 3}, {'a': 11, 'b': 12}]
pd.DataFrame(l)
Out[23]:
a b c
0 1 2 3.0
1 11 12 NaN

Ostatnim z najbardziej popularnych sposobów 'ręcznego' tworzenia tabel DataFrame jest przekazanie dwuwymiarowej tablicy ndarray. Pamiętać jednak należy, że bez dodatkowego przekazania parametrów index i columns, tabela taka zarówno w miejscu indeksów, jak i nazw kolumn będzie posiadała kolejne liczby całkowite liczone od zera.

In [24]:
arr = np.random.rand(4,4)
pd.DataFrame(arr)
Out[24]:
0 1 2 3
0 0.996088 0.899782 0.474018 0.355677
1 0.393186 0.411065 0.247293 0.073719
2 0.016501 0.472189 0.438220 0.065040
3 0.630939 0.642452 0.808070 0.154420
In [25]:
pd.DataFrame(arr, columns=['A', 'B', 'C', 'D'])
Out[25]:
A B C D
0 0.996088 0.899782 0.474018 0.355677
1 0.393186 0.411065 0.247293 0.073719
2 0.016501 0.472189 0.438220 0.065040
3 0.630939 0.642452 0.808070 0.154420

Na temat indeksowania czy wybierania wycinków tabel DataFrame powiemy sobie dużo więcej w kolejnym wpisie. Tutaj jedynie chciałbym przybliżyć najprostszy sposób pobierania oraz tworzenia i usuwania kolumn.

Jak zobaczyliśmy wyżej, jednym z najprostszych sposobów tworzenia DataFrames jest przekazanie do konstruktora słownika, który jako wartości zawiera Series. Również, gdy chcemy operować na kolumnach, możemy w uproszczeniu myśleć o nich jak o elementach słownika.

In [26]:
df
Out[26]:
fst snd
a 0.891356 0.662332
b 0.861472 0.204189
c 0.080433 0.562178
d NaN 0.876908

Pobieranie pojedynczej kolumny z DataFrame znając jej nazwę, wygląda dokładnie tak samo, jak pobieranie wartości ze słownika na podstawie klucza.

Wartość z DataFrame można również pobrać korzystając z metody odpowiadającej nazwie kolumny, dla poniższego przypadku będzie to:

df.fst

Każda kolumna ma przypisaną w namespace swoją nazwę do metody, taki zapis jednak może sprawiać problemy, gdy kolumny nazywają się podobnie lub nawet tak samo, jak wbudowane w DataFrame inne metody. Z tego też względu pozostaniemy przy zapisie podobnym do tego znanego ze słowników.

In [27]:
df['fst']
Out[27]:
a    0.891356
b    0.861472
c    0.080433
d         NaN
Name: fst, dtype: float64

Tworzenie nowej kolumny zapisem także przypomina dodawanie elementu do słownika. Jako że kolumny możemy traktować jako Series, działania arytmetyczne na nich wykonywane są jak w ndarray element po elemencie. Możemy więc dzięki temu tworzyć nowe kolumny jako wyniki operacji matematycznych na innych kolumnach, czy za pomocą funkcji uniwersalnych znanych z NumPy. Wystarczy tylko jako nazwę kolumny wybrać nazwę niewystępującą w liście df.columns.

In [28]:
df['trd'] = df['fst'] + df['snd']
df
Out[28]:
fst snd trd
a 0.891356 0.662332 1.553687
b 0.861472 0.204189 1.065661
c 0.080433 0.562178 0.642611
d NaN 0.876908 NaN

Również usuwanie kolumn możemy wykonać dokładnie tak samo, jak w przypadku słowników Python.

In [29]:
del df['trd']
df
Out[29]:
fst snd
a 0.891356 0.662332
b 0.861472 0.204189
c 0.080433 0.562178
d NaN 0.876908

Nic nie stoi na przeszkodzie, aby utworzyć kolumnę zawierającą ciąg tych samych wartości. Wystarczy do nowo utworzonej kolumny przekazać tę właśnie wartość. Jej długość zostanie dostosowana do długości DataFrame.

In [30]:
df['ones'] = 1.0
df
Out[30]:
fst snd ones
a 0.891356 0.662332 1.0
b 0.861472 0.204189 1.0
c 0.080433 0.562178 1.0
d NaN 0.876908 1.0

Wszystkie omawiane wyżej sposoby dodawania kolumn powodują dodanie nowej kolumny jako ostatniej w DataFrame. Gdy jednak chcemy, aby kolumna znalazła się w innym miejscu, możemy skorzystać z metody pd.DataFrame.insert(), która przyjmuje trzy podstawowe argumenty:

  • miejsce, w którym kolumna ma zostać wstawiona (rozumiane jako indeks w tabeli df.columns);
  • nazwę kolumny;
  • zawartość kolumny.
In [31]:
df.insert(0, 'zero', [1, 2, 3, 4])
df
Out[31]:
zero fst snd ones
a 1 0.891356 0.662332 1.0
b 2 0.861472 0.204189 1.0
c 3 0.080433 0.562178 1.0
d 4 NaN 0.876908 1.0

Rozdział ten nie wyczerpuje oczywiście tematu powstawania obiektów Pandas, jeszcze w tym wpisie omówimy tworzenie DataFrames na podstawie lokalnych plików w różnych, popularnych formatach. W kolejnych wpisach zostaną też przedstawione bardziej zoptymalizowane metody tworzenia wycinków czy przetwarzania danych w Pandas.

Prezentacja danych

Zanim zaczniemy zajmować się większymi tabelami, gdzie niemożliwe jest wyświetlenie całości na ekranie komputera w czytelny sposób, wart opisać pokrótce sposoby prezentacji fragmentów danych. Dwie podstawowe metody służące do tego to df.head() i df.tail(). Jak wskazują nazwy pierwsza z nich, zwraca górę tabeli ('head'), a druga dół ('tail'), czyli ostatnie wiersze. W obu z nich możemy zdefiniować, ile wierszy ma zostać zwróconych, przekazując do metody ich liczbę jako parametr. Gdy nie przekażemy nic, zostanie zwróconych, odpowiednio pięć pierwszych bądź ostatnich wierszy.

In [32]:
df = pd.DataFrame(np.random.rand(8, 4), columns=list('ABCD'))
In [33]:
df.head()
Out[33]:
A B C D
0 0.931382 0.932909 0.707267 0.838872
1 0.245372 0.989694 0.761508 0.175945
2 0.669344 0.239195 0.043260 0.156429
3 0.076387 0.889954 0.459333 0.445329
4 0.758040 0.262711 0.831163 0.227569
In [34]:
df.tail(3)
Out[34]:
A B C D
5 0.901528 0.820576 0.417482 0.960585
6 0.328117 0.333883 0.813958 0.866697
7 0.476947 0.039930 0.325881 0.949937

Odczytywanie danych z pliku

Ostatnim tematem poruszanym w tym wpisie jest tworzenie obiektów DataFrame zaczytując do naszego programu dane z plików lokalnych. Możliwości czytania danych z plików w Pandas jest bardzo dużo, my skupimy się jednak na trzech podstawowych i najprostszych formatach:

  • .csv - pliki tekstowe z wartościami oddzielonymi przecinkiem
  • .txt - również pliki textowe, ale z wartościami oddzielonymi znakiem tabulacji
  • .xlsx - pliki excel

Zacznijmy od tych, które zwłaszcza na początku pracy z data science będą pojawiać się najczęściej. Jest to format .csv, czyli 'comma separated values'. Większość treningowych zbiorów danych, które można znaleźć w sieci, jest właśnie w tym formacie, stąd umiejętność wczytania tego typu danych do DataFrame jest tak przydatna.

Przykładowy zbiór danych, podobny do tego, z którego tu korzystamy, można znaleźć na stronie www.kaggle.com, a dokładnie tutaj.

Dodam, że strona ta jest świetnym źródłem przykładowych zbiorów danych i wiedzy na temat data science.

Aby pobrać zbiór danych w formacie .csv z pliku, wystarczy do metody pd.read_csv() przekazać względną ścieżkę do tego pliku. W moim przypadku znajduje się on w tym samym folderze co plik jupyter notbooka, do którego go zaczytuje. Jeśli plik jest rzeczywiście tekstem oddzielonym przecinkami, nie musimy robić nic więcej.

In [35]:
df_csv = pd.read_csv('iris.csv')
df_csv.head()
Out[35]:
sepal_length sepal_width petal_length petal_width species
0 5.1 3.5 1.4 0.2 setosa
1 4.9 3.0 1.4 0.2 setosa
2 4.7 3.2 1.3 0.2 setosa
3 4.6 3.1 1.5 0.2 setosa
4 5.0 3.6 1.4 0.2 setosa

Gdy jednak chcemy zaczytać dane z pliku tekstowego .txt, musimy mieć pewność, czym oddzielone są od siebie poszczególne wartości. Zazwyczaj jest to znak tabulacji. Takie dane również zaczytujemy metodą pd.read_csv(), z tą jednak różnicą, że musimy przekazać parametr delimiter='\t', gdzie '\t' oznacza właśnie znak tabulacji.

In [36]:
df_txt = pd.read_csv('iris.txt', delimiter='\t')
df_txt.head()
Out[36]:
sepal_length sepal_width petal_length petal_width species
0 5.1 3.5 1.4 0.2 setosa
1 4.9 3.0 1.4 0.2 setosa
2 4.7 3.2 1.3 0.2 setosa
3 4.6 3.1 1.5 0.2 setosa
4 5.0 3.6 1.4 0.2 setosa

Ostatnim formatem, który chcemy tu omówić, są pliki programu Excel. Pandas posiada dedykowaną dla nich metodę, mianowicie pd.read_excel(). Podobnie jak w powyższej metodzie, musimy przekazać względną ścieżkę do pliku, a do tego nazwę arkusza, z którego zaczytać dane, gdyż jak wiemy, plik .xlsx może mieć wiele arkuszy z tabelarycznymi danymi.

In [37]:
df_xls = pd.read_excel('iris.xlsx', sheet_name='Sheet1')
df_xls.head()
Out[37]:
sepal_length sepal_width petal_length petal_width species
0 5.1 3.5 1.4 0.2 setosa
1 4.9 3.0 1.4 0.2 setosa
2 4.7 3.2 1.3 0.2 setosa
3 4.6 3.1 1.5 0.2 setosa
4 5.0 3.6 1.4 0.2 setosa

Przy odczycie z plików Excel możemy napotkać na błąd związany z nieposiadaniem w naszej instalacji Pythona silnika niezbędnego do odczytu plików .xlsx i podobnych. Komunikat o błędzie poinformuje nas jakiego pakietu brakuje, u mnie było to xlrd. Wystarczy ten pakiet doinstalować za pomocą pip i nie powinniśmy mieć już żadnych problemów z odczytywaniem tego typu danych.

pip install xlrd

Skoro możemy odczytywać pliki Pandas daje nam również możliwość zapisu danych do plików. Często używamy tego, aby zapisać wynikowe datasety, bądź tylko checkpointy, do których możemy wrócić, gdy coś pójdzie nie tak w naszym przetwarzaniu.

Do zapisu plików tekstowych będziemy używać pd.to_csv(), wystarczy do metody przekazać nazwę pliku, który chcemy utworzyć. Gdy dodamy samą nazwę, plik zostanie utworzony w tym samym folderze, co nasz program (w tym wypadku jupyter notebook). Automatycznie metoda zapisze plik rozdzielony przecinkami. Aby nadpisać tę opcję, musimy przekazać w parametrze sep, czym wartości mają być rozdzielone.

In [38]:
df_csv.to_csv('iris2.csv', index=False)
In [39]:
df_txt.to_csv('iris2.txt', sep='\t', index=False)

Aby Pandas nie dodał do naszego zbioru danych kolumny z indeksami na początku, musimy zdefiniować to w parametrze index=False.

Podsumowanie

Pierwszy wpis na temat pakietu Pandas uważam za zakończony. Omówiliśmy sposoby tworzenia tabel i serii na podstawie innych struktur danych znanych Pythona bądź z NumPy, ale również na podstawie danych z lokalnych plików. W kolejnych wpisach poznamy metody przetwarzania danych, które udostępnia nam Pandas, oraz bardziej zaawansowane struktury danych, jak Index, MultiIndex, TimeSeries, czy Pivot Tables.