Wracasz z obchodu, @name, i masz w notesie cztery wiersze. Lew: 120 sztuk, sawanna, gatunek zagrożony. Słoń: 450, las, niezagrożony. Gepard: 85, sawanna, zagrożony. Żyrafa: 200, sawanna, niezagrożony. W jednym wierszu siedzą trzy zupełnie różne rodzaje informacji: nazwa gatunku, czyli tekst, liczebność, czyli liczba, oraz odpowiedź tak albo nie. NumPy z poprzedniej lekcji policzy średnią z liczb szybciej, niż zdążysz mrugnąć, ale zanim to zrobi, musisz podać mu same liczby. A notes terenowy to nie kolumna liczb, tylko tabela.
Pandas to biblioteka, która zamienia Pythona w narzędzie do pracy z takimi tabelami. Nie zastępuje NumPy - jest na nim zbudowana i pod spodem liczy dokładnie tymi samymi tablicami. Dokłada natomiast dwie rzeczy, których w NumPy nie ma: każda kolumna dostaje nazwę i każda kolumna może mieć własny typ danych. To dokładnie ta różnica, która dzieli surowy pomiar od porządnie prowadzonego dziennika ekspedycji.
Zanim sięgniemy po nowe narzędzie, warto zobaczyć na własne oczy, gdzie stare przestaje działać. Spróbujmy wcisnąć dwa wiersze notesu do tablicy NumPy, tak jak robiliśmy to z liczbami w poprzedniej lekcji. Funkcja
np.array przyjmie listę list bez mrugnięcia okiem, bo formalnie nic złego się nie dzieje. Interesuje nas to, co wyjdzie po drugiej stronie: atrybut dtype mówi, jakim jednym typem NumPy opisał całą zawartość tablicy, bo w NumPy wszystkie elementy muszą dzielić jeden typ.1import numpy as np
2
3raw = np.array([["Lion", 120], ["Elephant", 450]])
4
5print(raw)
6# [['Lion' '120']
7# ['Elephant' '450']]
8
9print(raw.dtype) # <U21Zwróć uwagę na apostrofy wokół liczb w wydruku:
'120' zamiast 120. Skoro tablica musi mieć jeden typ, a "Lion" nie da się zamienić na liczbę, to NumPy poszedł w drugą stronę i zamienił liczby na tekst. Skrót <U21 znaczy właśnie "łańcuch znaków o długości do dwudziestu jeden". Twoje pomiary przestały być liczbami: dodawanie ich skończy się sklejeniem napisów, a średnia w ogóle nie przejdzie. Do tego dochodzi drugi kłopot, cichszy, ale równie dotkliwy - kolumny nie mają nazw, więc do końca analizy musisz pamiętać, że populacja siedzi pod numerem jeden. Pandas rozwiązuje oba problemy naraz i po to powstał.pdPandas nie wchodzi w skład standardowej biblioteki Pythona, więc trzeba go doinstalować. Robi to
pip, czyli menedżer pakietów Pythona, tym samym poleceniem, którym instalowałeś wcześniej inne biblioteki. Instalacja pociągnie za sobą NumPy, jeśli jeszcze go nie masz, bo Pandas bez NumPy nie ruszy - to zależność, nie sugestia. Polecenie wykonujesz w terminalu, a nie w pliku z kodem, i tylko raz na środowisko. Jeśli pracujesz w Google Colab albo w dystrybucji Anaconda, Pandas jest już na miejscu i możesz ten krok pominąć.1pip install pandasPo instalacji nic się jeszcze nie wydarzyło w Twoim programie: pakiet wylądował na dysku, ale żaden plik z kodem o nim nie wie. Do tego służy osobne polecenie w samym Pythonie. Przyjęło się nadawać bibliotece skrót
pd za pomocą słowa kluczowego as, które tworzy alias, czyli drugą, krótszą nazwę na tę samą rzecz. To wyłącznie konwencja, ale konwencja tak powszechna, że każdy przykład w dokumentacji, każda odpowiedź w sieci i każde zadanie w tym module zakładają właśnie pd.1import pandas as pdTa jedna linia wciąga bibliotekę do pamięci i wiesza na niej etykietę
pd. Nic nie zostało wczytane, policzone ani wyświetlone - dane pojawią się dopiero w kolejnych poleceniach. Wariant import pandas też jest poprawny, tylko wtedy do każdej funkcji musiałbyś pisać pełne pandas.DataFrame, co przy trzydziestu wywołaniach w skrypcie robi się męczące. Nie zadziała za to import pd: pakiet nazywa się pandas, więc Python zakończy błędem ModuleNotFoundError: No module named 'pd'. Skrót nadajesz sam, w tej właśnie linii, i dopiero od niej zaczyna on istnieć.Zacznijmy od najmniejszego kawałka notesu: jednej kolumny. Lista
[120, 450, 85, 200] przechowuje cztery liczebności, ale nie mówi ani słowa o tym, do kogo należy osiemdziesiąt pięć. Pandas ma na to strukturę o nazwie Series. Series jest jednowymiarowa: to pojedynczy ciąg wartości, a nie tabela z wierszami i kolumnami naraz. Do wartości dokłada indeks, czyli zestaw etykiet, po jednej na wartość, oraz opcjonalną nazwę całego ciągu, którą podajesz parametrem name. Zapamiętaj tę definicję dokładnie, bo za chwilę odróżnimy Series od struktury, która naprawdę jest tabelą.1populations = pd.Series([120, 450, 85, 200], name="population")
2
3print(populations)
4# 0 120
5# 1 450
6# 2 85
7# 3 200
8# Name: population, dtype: int64Po lewej stronie wydruku widzisz indeks, którego nigdzie nie podawałeś: Pandas dorobił go sam jako kolejne liczby od zera. To ważne, bo Series ma indeks zawsze, nawet gdy o niego nie prosisz. Po prawej stoją Twoje wartości, pod spodem nazwa serii i
dtype: int64, czyli typ wspólny dla wszystkich elementów. Pod tym względem Series zachowuje się jak tablica NumPy i nie ma w tym przypadku: w środku naprawdę siedzi tablica NumPy. Sama lista, którą podałeś, nie została w żaden sposób zmieniona - Pandas skopiował z niej wartości do nowej struktury.Indeks nie musi być ciągiem liczb i właśnie w tym tkwi jego siła. Możesz podać własne etykiety parametrem
index, przekazując listę tak samo długą jak lista wartości. Wtedy zamiast pamiętać, że osiemdziesiąt pięć to trzeci pomiar, sięgasz po wartość nazwą gatunku, w nawiasach kwadratowych, dokładnie tak jak po elemencie słownika.1species = pd.Series(
2 [120, 450, 85, 200],
3 index=["Lion", "Elephant", "Cheetah", "Giraffe"],
4 name="population",
5)
6
7print(species["Lion"]) # 120
8print(species["Cheetah"]) # 85Etykiety zastąpiły numery od zera, ale liczby zostały te same i w tej samej kolejności - indeks opisuje wartości, a nie miesza nimi. Zwróć uwagę, że nazwy gatunków nie są drugą kolumną. To wciąż jedna kolumna liczb, tyle że każda ma przypiętą karteczkę z nazwą. Gdybyś chciał trzymać gatunek i liczebność jako dwie równorzędne kolumny, potrzebujesz innej struktury i poznasz ją w następnej sekcji.
Skoro w Series siedzi tablica NumPy, to działają na niej te same sztuczki co w poprzedniej lekcji. Porównanie
species > 100 nie zwraca jednego True, tylko wykonuje porównanie osobno dla każdej wartości i oddaje Series wypełnioną wartościami logicznymi. Taką serię nazywamy maską logiczną. Włożona z powrotem w nawiasy kwadratowe wybiera tylko te elementy, dla których wypadło True.1print(species > 100)
2# Lion True
3# Elephant True
4# Cheetah False
5# Giraffe True
6# Name: population, dtype: bool
7
8print(species[species > 100])
9# Lion 120
10# Elephant 450
11# Giraffe 200
12# Name: population, dtype: int64Gepard zniknął z wyniku, bo osiemdziesiąt pięć nie przekracza setki, ale nie zniknął z
- filtrowanie zwraca nową serię, a oryginał zostaje nietknięty. Zauważ też, że etykiety pojechały razem z wartościami: w wyniku dalej stoi "Elephant" obok czterystu pięćdziesięciu, choć w środku został tylko co trzeci wiersz. To zachowanie przeniesiemy za moment na całe tabele i będzie ono podstawą filtrowania danych w Pandas.species
Jedna Series to jedna kolumna, a Twój notes ma ich cztery. Potrzebujesz struktury, która trzyma wiersze i kolumny naraz, i taką strukturą w Pandas jest DataFrame. DataFrame jest dwuwymiarowy: ma tyle wierszy, ile obserwacji, i tyle kolumn, ile cech opisujesz. Każda pojedyncza kolumna DataFrame jest Series, więc obie struktury nie konkurują ze sobą, tylko składają się jedna z drugiej. Najprostszy sposób zbudowania tabeli to podanie słownika: klucz zostaje nazwą kolumny, a wartość, czyli lista, wypełnia tę kolumnę od góry do dołu. Wszystkie listy muszą mieć tę samą długość, bo inaczej wiersze by się nie domknęły.
1safari_data = pd.DataFrame({
2 "species": ["Lion", "Elephant", "Cheetah", "Giraffe"],
3 "population": [120, 450, 85, 200],
4 "habitat": ["Savanna", "Forest", "Savanna", "Savanna"],
5 "endangered": [True, False, True, False],
6})
7
8print(safari_data)
9# species population habitat endangered
10# 0 Lion 120 Savanna True
11# 1 Elephant 450 Forest False
12# 2 Cheetah 85 Savanna True
13# 3 Giraffe 200 Savanna FalseCztery klucze słownika stały się nagłówkami kolumn, w takiej kolejności, w jakiej je wpisałeś. Po lewej stronie znów pojawił się indeks od zera, dorobiony automatycznie tak samo jak w Series. Najważniejsze jest jednak to, czego tu nie widać: liczby nie zamieniły się w tekst. Kolumna
population trzyma liczby całkowite, species i habitat tekst, a endangered wartości logiczne, i wszystkie trzy typy żyją obok siebie w jednej tabeli. Dokładnie tego nie potrafiła tablica NumPy z początku lekcji. Nic ponadto się nie stało - Pandas niczego nie posortował, nie policzył i nie poprawił.Wokół DataFrame krąży kilka nazw, które łatwo pomylić, a które oznaczają coś zupełnie innego. Series to, jak już wiesz, struktura jednowymiarowa - jedna kolumna z indeksem, nie tabela. Panel był kiedyś w Pandas strukturą trójwymiarową, czyli stosem kilku tabel ułożonych jedna na drugiej, ale został wycofany i od wersji 1.0 po prostu nie istnieje; próba użycia
pd.Panel kończy się błędem AttributeError. Table z kolei nigdy w Pandas nie było - taka klasa występuje w innych bibliotekach, na przykład do obsługi plików astronomicznych, i do naszej tabeli nie ma żadnego stosunku. Nie jest nią też lista zagnieżdżonych słowników: to zwykła struktura Pythona, którą owszem, możesz przekazać do pd.DataFrame, ale sama z siebie nie jest ani Series, ani żadną strukturą Pandas. Możesz to sprawdzić bez wiary na słowo, funkcją hasattr, która odpowiada na pytanie "czy ten obiekt ma taką nazwę w środku".1print(hasattr(pd, "DataFrame")) # True
2print(hasattr(pd, "Series")) # True
3print(hasattr(pd, "Panel")) # False
4print(hasattr(pd, "Table")) # FalseDwie prawdy i dwa fałsze, czarno na białym. W bibliotece Pandas mieszkają dokładnie dwie podstawowe struktury danych: jednowymiarowa
Series i dwuwymiarowa DataFrame. Kiedy ktoś mówi o tabeli z wierszami i kolumnami w Pandas, mówi o DataFrame - i to jedyna poprawna odpowiedź na to pytanie. Samo wywołanie hasattr niczego przy okazji nie utworzyło ani nie zmieniło; sprawdziło tylko, co jest, a czego nie ma.Słownik nadaje się do czterech wierszy. Przy trzech tysiącach odczytów z obroży GPS nikt nie będzie przepisywał danych ręcznie - dostajesz je jako plik CSV, czyli comma-separated values, wartości rozdzielone przecinkami. To zwykły plik tekstowy o bardzo prostej budowie: pierwszy wiersz to nagłówek z nazwami kolumn, a każdy następny to jedna obserwacja. Otwórz taki plik w dowolnym edytorze, a zobaczysz coś takiego.
1species,population,habitat,endangered
2Lion,120,Savanna,True
3Elephant,450,Forest,False
4Cheetah,85,Savanna,True
5Giraffe,200,Savanna,FalsePięć wierszy tekstu: jeden nagłówek i cztery obserwacje, dokładnie te same dane, które przed chwilą wpisywaliśmy słownikiem. Żadnych typów, żadnych nawiasów, żadnej struktury poza przecinkami. Zamianą tego pliku na gotową tabelę zajmuje się funkcja
read_csv. Podajesz jej nazwę pliku jako tekst, a dostajesz z powrotem DataFrame. Wynik przypisujemy do zmiennej df - to tradycyjny skrót od DataFrame i taką nazwę zobaczysz w niemal każdym przykładzie w sieci.1df = pd.read_csv("safari_species.csv")
2
3print(df.shape) # (4, 4)
4print(df["population"].sum()) # 855Cztery wiersze i cztery kolumny, a suma liczebności wyszła jako liczba, nie jako sklejony napis - to znaczy, że
read_csv sam rozpoznał typy kolumn. Nagłówek z pliku stał się nazwami kolumn, wiersze tekstu stały się wierszami tabeli, a Ty nie napisałeś ani jednej linii konwersji. Zapamiętaj to wywołanie znak po znaku, bo będziesz je pisał codziennie: pd, ., read_csv, (, 'data.csv', ). Kolejność jest sztywna - najpierw skrót biblioteki, potem kropka, nazwa funkcji, nawias otwierający, nazwa pliku w cudzysłowie i nawias zamykający. Funkcja należy do modułu pd, a nie do tabeli, więc df.read_csv(...) nie ma sensu: w chwili wczytywania nie masz jeszcze żadnego df. Bliźniacze funkcje pd.read_excel i pd.read_json działają tak samo dla arkuszy i plików JSON, tyle że pierwsza wymaga doinstalowania dodatkowego pakietu do obsługi Excela.head i tailWczytałeś plik i pierwsze, co chcesz zrobić, to zajrzeć do środka. Wypisanie całego DataFrame przy trzech tysiącach wierszy zaleje Ci terminal i niczego nie wyjaśni. Od zerknięcia na początek tabeli jest metoda
head, która zwraca pierwsze wiersze, a od zerknięcia na koniec metoda tail. Obie przyjmują opcjonalną liczbę wierszy, a jeśli jej nie podasz, biorą pięć. Nasza tabela ma tylko cztery wiersze, więc samo df.head() pokazałoby ją w całości - żeby różnica była widoczna, poprosimy wprost o dwa.1print(df.head(2))
2# species population habitat endangered
3# 0 Lion 120 Savanna True
4# 1 Elephant 450 Forest False
5
6print(df.tail(2))
7# species population habitat endangered
8# 2 Cheetah 85 Savanna True
9# 3 Giraffe 200 Savanna FalseDwa wiersze z góry i dwa z dołu, każdy w komplecie, ze wszystkimi czterema kolumnami. I tu uwaga na trzy nieporozumienia, które biorą się z angielskiej nazwy "head". Ta metoda nie zwraca nagłówków kolumn, mimo że słowo "header" brzmi podobnie - od nazw kolumn jest
df.columns. Nie zwraca też pierwszego elementu każdej kolumny; pierwszy wiersz jako taki wyciąga df.iloc[0], o czym za moment. I na pewno niczego nie sortuje: kolejność wierszy w wyniku jest dokładnie taka, jak w pliku, a od sortowania jest osobna metoda sort_values z końcowej części lekcji. head zwraca nowy DataFrame z wycinkiem danych, a df zostaje bez zmian.info i describePodgląd kilku wierszy mówi, jak dane wyglądają, ale nie mówi, ile ich jest ani czy gdzieś nie brakuje wartości. Od takiej metryczki jest metoda
info. Wypisuje ona klasę obiektu, zakres indeksu, a potem tabelkę z jednym wierszem na kolumnę: numer, nazwę, liczbę wartości niepustych w kolumnie Non-Null Count i typ w kolumnie Dtype. Na końcu dokłada podsumowanie typów i zużycie pamięci. To polecenie warte uruchomienia przy każdym nowym zbiorze, jaki bierzesz do ręki.1df.info() # wypisuje metryczke tabeli
2
3print(df.info()) # ta sama metryczka, a pod nia dodatkowe NoneTe dwie linie robią prawie to samo i właśnie dlatego stoją obok siebie.
info należy do nielicznych metod, które same drukują i nie zwracają żadnego wyniku, a dokładniej zwracają None. Owinięcie jej w print niczego nie poprawia, tylko dokłada na końcu samotne słowo None, które co roku myli kilkoro uczniów. Zapamiętaj więc: info wołasz gołą, bez print. Metoda niczego w tabeli nie zmienia - to czysty odczyt, tak samo jak head.Kiedy już wiesz, ile masz wierszy i jakich typów, przychodzi pora na liczby. Metoda
describe liczy dla każdej kolumny liczbowej komplet statystyk opisowych: liczbę wartości count, średnią mean, odchylenie standardowe std, minimum, trzy kwartyle oznaczone 25%, 50% i 75% oraz maksimum. Wywołanie składa się z pięciu części i warto je znać na pamięć: df, ., describe, (, ).1print(df.describe())
2# population
3# count 4.000000
4# mean 213.750000
5# std 164.690366
6# min 85.000000
7# 25% 111.250000
8# 50% 160.000000
9# 75% 262.500000
10# max 450.000000Osiem wierszy statystyk, ale tylko jedna kolumna w wyniku. To nie błąd:
describe domyślnie bierze pod uwagę wyłącznie kolumny liczbowe, a w naszej tabeli taka jest tylko population. Tekstowe species i habitat oraz logiczna endangered zostały pominięte i pozostały w tabeli nietknięte. Gdyby w zbiorze nie było ani jednej kolumny liczbowej, describe przełączyłby się na statystyki tekstowe: liczbę wartości, liczbę unikatów i wartość najczęstszą. Średnia dwieście trzynaście i trzy czwarte przy maksimum czterysta pięćdziesiąt od razu podpowiada, że jeden gatunek mocno wystaje ponad resztę.shape, columns i dtypesCzasem nie potrzebujesz całej metryczki, tylko jednej konkretnej liczby - na przykład tego, ile wierszy przetrwało filtrowanie. Służą do tego trzy atrybuty, czyli właściwości obiektu, a nie metody. Atrybut
shape zwraca krotkę w kolejności wiersze, kolumny. Atrybut columns oddaje listę nazw kolumn, a dtypes typ każdej kolumny z osobna. Ponieważ to atrybuty, a nie metody, piszesz je bez nawiasów: df.shape, nie df.shape(). Ta druga wersja kończy się błędem TypeError: 'tuple' object is not callable, bo próbujesz wywołać gotową krotkę jak funkcję.1print(df.shape) # (4, 4)
2print(df.columns)
3# Index(['species', 'population', 'habitat', 'endangered'], dtype='str')
4print(df.dtypes)
5# species str
6# population int64
7# habitat str
8# endangered bool
9# dtype: objectKrotka
(4, 4) czyta się zawsze tak samo: cztery wiersze na cztery kolumny, nigdy odwrotnie, więc df.shape[0] to liczba obserwacji. Nazwy kolumn wracają opakowane w obiekt Index - to ta sama klasa, która obsługuje etykiety wierszy, bo w Pandas nagłówki kolumn też są indeksem. Jeśli wolisz zwykłą listę Pythona, dopisz .tolist(). Jedno ostrzeżenie co do wydruku typów: końcówka str przy kolumnach tekstowych pojawia się w Pandas od wersji 3.0, a we wcześniejszych zobaczysz w tym miejscu object. To ta sama rzecz nazwana po staremu i nie zmienia niczego w Twoim kodzie.Analiza rzadko dotyczy całej tabeli naraz - najczęściej wyciągasz z niej wycinek. Kolumnę wskazujesz jej nazwą w nawiasach kwadratowych i tu czeka pierwsza pułapka, bo od liczby nawiasów zależy typ wyniku. Jedna para nawiasów z pojedynczą nazwą zwraca Series, czyli tę jednowymiarową kolumnę z początku lekcji. Dwie pary nawiasów zwracają DataFrame, bo w środku podajesz zwykłą listę Pythona z nazwami kolumn - stąd druga para nawiasów, to nie żadna magia. Różnicę widać najlepiej w atrybucie
shape, który dla struktury jednowymiarowej ma jedną liczbę, a dla dwuwymiarowej dwie.1populations = df["population"]
2subset = df[["species", "population"]]
3
4print(populations.shape) # (4,)
5print(subset.shape) # (4, 2)Ten sam zbiór liczb, dwa różne kształty. Krotka
(4,) z pojedynczą wartością to podpis Series: cztery wartości ustawione w jednym szeregu. Krotka (4, 2) to podpis DataFrame: cztery wiersze na dwie kolumny. Kolejność nazw na liście decyduje o kolejności kolumn w wyniku, więc df[["population", "species"]] dałoby tę samą treść w odwrotnym ustawieniu. Obie zmienne są nowymi obiektami - df dalej ma komplet czterech kolumn i żadna z tych linii go nie okroiła.iloc i locKolumny wybiera się nazwą, a wiersze? Do wierszy służą dwa narzędzia i mylenie ich to klasyczny błąd początkującego.
iloc to skrót od integer location, czyli pozycja liczona od zera, zupełnie jak w liście Pythona. loc sięga po etykietę z indeksu, czyli po to, co widzisz po lewej stronie wydruku. W naszej tabeli indeks to liczby od zera do trzech, więc oba zapisy wyglądają podobnie, ale zachowują się inaczej przy zakresach: iloc pomija koniec zakresu, tak jak wycinki list, a loc koniec włącza, bo etykiety nie są pozycjami. Obu narzędziom możesz podać dwa argumenty po przecinku - najpierw wiersze, potem kolumny.1print(df.iloc[0])
2# species Lion
3# population 120
4# habitat Savanna
5# endangered True
6# Name: 0, dtype: object
7
8print(df.iloc[0, 1]) # 120
9print(df.iloc[0:3].shape) # (3, 4)
10print(df.loc[0:2, "species"].tolist()) # ['Lion', 'Elephant', 'Cheetah']Pierwsza linia pokazuje, czym naprawdę jest wiersz w Pandas: to Series, w której indeksem są nazwy kolumn, a wartości mają różne typy. Druga sięga po wiersz zero i kolumnę jeden, licząc od zera, więc trafia w liczebność lwa. Trzecia i czwarta to obiecana różnica:
iloc[0:3] daje trzy wiersze, bo trójka wypada poza zakresem, a loc[0:2] też daje trzy wiersze, bo dwójka mieści się w środku. Zapis df.iloc[0] przyda Ci się jeszcze jako kontrprzykład - to on zwraca pierwszy element każdej kolumny, czego czasem błędnie oczekuje się po head.Teraz najważniejsza umiejętność tej lekcji. Pytanie z terenu brzmi rzadko "pokaż mi wiersz numer dwa", a prawie zawsze "pokaż mi gatunki liczniejsze niż sto sztuk". Odpowiedź składa się z dwóch kroków, które poznałeś już na pojedynczej Series. Najpierw budujesz maskę logiczną: porównanie
df["population"] > 100 sprawdza warunek osobno dla każdego wiersza i zwraca Series wartości True i False o dokładnie takiej samej długości jak tabela.1mask = df["population"] > 100
2
3print(mask)
4# 0 True
5# 1 True
6# 2 False
7# 3 True
8# Name: population, dtype: boolCztery wiersze tabeli dały cztery odpowiedzi logiczne, a indeks pozostał ten sam - to on wiąże maskę z wierszami. Zwróć uwagę, że w wyniku nie ma ani jednej liczebności: maska nie przechowuje danych, tylko decyzję o każdym wierszu. Sama w sobie niczego jeszcze nie odfiltrowała i
df po tej linii ma wciąż cztery wiersze. Dopiero włożenie maski do nawiasów kwadratowych tabeli zostawia te wiersze, przy których stoi True.1print(df[df["population"] > 100])
2# species population habitat endangered
3# 0 Lion 120 Savanna True
4# 1 Elephant 450 Forest False
5# 3 Giraffe 200 Savanna FalseTrzy wiersze zamiast czterech, a gepard wypadł, bo osiemdziesiąt pięć nie spełnia warunku. Spójrz na indeks po lewej: zostały numery 0, 1 i 3, z dziurą po dwójce. Pandas nie przenumerowuje wierszy po filtrowaniu, dzięki czemu zawsze wiesz, skąd wiersz pochodzi w oryginale. Całą konstrukcję układa się w czterech krokach i warto zapamiętać ich kolejność: najpierw
df[, potem df['population'], potem > 100, a na końcu ]. Innymi słowy: otwierasz nawias na tabeli, w środku budujesz warunek na kolumnie i zamykasz nawias. I znów najważniejsze zdanie: oryginalny df ma po tej operacji nadal cztery wiersze, bo filtrowanie zwraca nową tabelę.Warunki można łączyć, tylko trzeba pamiętać o dwóch szczegółach składni. Zamiast słów
and i or używa się znaków & oraz |, bo Python porównuje całe serie, a nie pojedyncze wartości. Każdy warunek musi też stać we własnych nawiasach okrągłych, bo & wiąże mocniej niż > i bez nawiasów Python policzyłby to w złej kolejności.1print(df[(df["population"] > 100) & (df["endangered"] == False)])
2# species population habitat endangered
3# 1 Elephant 450 Forest False
4# 3 Giraffe 200 Savanna FalseZostały dwa gatunki: liczne i niezagrożone. Znak
& znaczy "oba warunki naraz", a | znaczyłby "przynajmniej jeden". Gdybyś napisał w tym miejscu and, dostałbyś ValueError: The truth value of a Series is ambiguous - Python nie wie, czy seria czterech wartości logicznych to prawda, czy fałsz, i słusznie odmawia zgadywania. Wynik jest, jak zawsze, nową tabelą; df po tych trzech blokach jest dokładnie taki sam jak po wczytaniu z pliku.applyZwiad terenowy to nie tylko wybieranie tego, co już masz, ale też dokładanie nowych informacji. Nową kolumnę tworzysz przez zwykłe przypisanie do nazwy, której w tabeli jeszcze nie ma. Po prawej stronie możesz napisać działanie na istniejącej kolumnie, a Pandas wykona je dla każdego wiersza z osobna, bez żadnej pętli - to ta sama wektoryzacja, którą poznałeś przy tablicach NumPy.
1df["population_thousands"] = df["population"] / 1000
2
3print(df["population_thousands"].tolist())
4# [0.12, 0.45, 0.085, 0.2]Tabela ma od tej chwili pięć kolumn zamiast czterech i to jedna z nielicznych operacji, które zmieniają
na miejscu, bez przypisywania wyniku z powrotem. Warto to sobie ułożyć w głowie: przypisanie do df
df["cos"] modyfikuje tabelę, natomiast filtrowanie, head czy describe tylko zwracają nowe obiekty. Kolumna population nie ucierpiała - dalej trzyma liczby całkowite, a nowe wartości ułamkowe wylądowały obok, w osobnej kolumnie.Nie każde przekształcenie da się zapisać jednym działaniem arytmetycznym. Gdy dla każdej wartości chcesz wykonać własną logikę, sięgasz po metodę
apply, która przyjmuje funkcję i uruchamia ją po kolei dla każdego elementu kolumny. Zwykle podaje się jej funkcję lambda, czyli funkcję bez nazwy, zapisaną w jednej linii: po słowie lambda idzie nazwa argumentu, dwukropek, a potem wyrażenie, którego wynik zostanie zwrócony.1df["status"] = df["population"].apply(
2 lambda x: "endangered" if x < 100 else "stable"
3)
4
5print(df["status"].tolist())
6# ['stable', 'stable', 'endangered', 'stable']Funkcja dostała po kolei sto dwadzieścia, czterysta pięćdziesiąt, osiemdziesiąt pięć i dwieście, a zwróciła cztery napisy, z których Pandas zbudował nową kolumnę. Argument
x to pojedyncza wartość, a nie cała kolumna, bo apply wywołane na Series pracuje element po elemencie. Sama kolumna population znów pozostała nietknięta - apply niczego w niej nie nadpisuje, tylko czyta. Zwróć uwagę, że gepard z liczebnością osiemdziesiąt pięć dostał status endangered, choć w kolumnie endangered i tak stało już True; to dwa niezależne źródła tej samej informacji i w prawdziwym zbiorze warto sprawdzić, czy się zgadzają.Kolumnę można też nadpisać, podając po lewej stronie nazwę, która już istnieje. Robię to niechętnie i Tobie też odradzam, bo po takim przypisaniu oryginalnych pomiarów już nie odzyskasz - w pamięci zostaje tylko wynik. Kiedy potrzebuję przeliczonej wersji, wolę zrobić kopię tabeli metodą
copy i przeliczać kopię. Kosztuje to jedną linię, a ratuje przed powtarzaniem całego wczytywania od zera.1projection = df.copy()
2projection["population"] = projection["population"] * 1.1
3
4print(projection["population"].round(1).tolist()) # [132.0, 495.0, 93.5, 220.0]
5print(df["population"].tolist()) # [120, 450, 85, 200]Prognoza dziesięcioprocentowego wzrostu wylądowała w
projection, a df trzyma nadal liczby zmierzone w terenie - i o to właśnie chodziło. Zauważ przy okazji, że po pomnożeniu przez liczbę ułamkową wartości przestały być całkowite: dziewięćdziesiąt trzy i pół zwierzęcia to oczywiście bezsens biologiczny, ale matematycznie tak działa mnożenie i Pandas zmienił typ kolumny na zmiennoprzecinkowy. Wywołanie round w wydruku też nie stoi tam bez powodu: bez niego zobaczyłbyś 495.00000000000006, bo komputer trzyma ułamki w postaci dwójkowej, a jedna dziesiąta nie ma w niej dokładnego zapisu. Gdybyś pominął copy i przypisał wynik wprost do df, kolumna population w oryginale też zmieniłaby typ i wartości.drop i ośPrędzej czy później w tabeli wyląduje kolumna, która była potrzebna tylko na chwilę. Do wyrzucania służy metoda
drop, ale ma ona pewien haczyk: tym samym poleceniem usuwa się wiersze i kolumny, więc musisz powiedzieć, o który wymiar chodzi. Robi to parametr axis, czyli oś. Zapamiętaj raz na zawsze: axis=0 to wiersze, axis=1 to kolumny. Ta numeracja nie jest przypadkowa - wraca w niej krotka shape, w której na pozycji zerowej stoją wiersze, a na pierwszej kolumny.1df["temp"] = 1
2clean = df.drop("temp", axis=1)
3
4print("temp" in clean.columns) # False
5print("temp" in df.columns) # True
6
7df = df.drop("temp", axis=1)
8print("temp" in df.columns) # FalseNajciekawszy jest tu drugi wydruk. Kolumna
temp zniknęła z clean, ale w df została, bo drop nie usuwa niczego z oryginału - zwraca nową tabelę bez wskazanej kolumny. Dopiero przypisanie wyniku z powrotem do df, jak w piątej linii, utrwala zmianę. Istnieje wprawdzie parametr inplace=True, który modyfikuje tabelę na miejscu, ale polecam Ci przypisanie: widać wtedy gołym okiem, że zmienna dostała nową zawartość, a wywołania da się łączyć jedno po drugim.Trzy warianty tego polecenia nie zadziałają i wszystkie trzy widuję w pracach uczniów. Zapis
df.drop('temp', axis=0) przechodzi przez składnię, ale kończy się błędem KeyError: "['temp'] not found in axis", bo z osią zero Pandas szuka wiersza o etykiecie temp, a takiego wiersza nie ma. Zapis df.delete(column='temp') daje AttributeError, bo DataFrame nie ma metody delete - myli się ona z funkcją np.delete z NumPy, która działa na tablicach. Zapis df.remove('temp') również kończy się AttributeError, bo remove to metoda list Pythona, nie tabel Pandas. Poprawna jest jedna forma: df.drop('temp', axis=1). Dla porządku dodam, że istnieje też równoważny zapis df.drop(columns='temp'), czytelniejszy w długich skryptach, ale wersję z axis=1 musisz znać, bo spotkasz ją w każdej dokumentacji. Podobnie działa df.rename(columns={"population": "count_2024"}), które zmienia nazwy kolumn i tak samo zwraca kopię zamiast poprawiać oryginał.Dane z terenu bywają dziurawe: obroża padła, obserwator nie dojechał, kartka zamokła. Pandas zapisuje brakującą wartość jako NaN, czyli not a number. Do zbudowania takiej dziurawej tabeli w Pythonie używa się
None, które Pandas sam zamienia na NaN. Miejsca braków znajdziesz metodą isnull, zwracającą tabelę wartości logicznych o tym samym kształcie co dane, gdzie True znaczy "tu jest dziura". Dopisanie .sum() sumuje te wartości kolumnami, a ponieważ True liczy się jako jeden, dostajesz liczbę braków w każdej kolumnie.1notes = pd.DataFrame({
2 "species": ["Lion", "Elephant", "Cheetah", "Giraffe"],
3 "population": [120, None, 85, 200],
4 "habitat": ["Savanna", "Forest", None, "Savanna"],
5})
6
7print(notes.isnull().sum())
8# species 0
9# population 1
10# habitat 1
11# dtype: int64
12
13print(notes["population"].tolist()) # [120.0, nan, 85.0, 200.0]Jedna dziura w liczebnościach, jedna w siedliskach, komplet w nazwach gatunków - i to wszystko z jednej linii kodu. Ostatni wydruk pokazuje skutek uboczny, o którym łatwo zapomnieć: kolumna
population przestała być całkowita i sto dwadzieścia wyświetla się jako 120.0. Dzieje się tak, bo NaN jest liczbą zmiennoprzecinkową, więc cała kolumna musiała przejść na ten typ. Same dane poza tym nie ruszyły się o milimetr - isnull niczego nie naprawia, tylko wskazuje palcem.Najprostsza reakcja na braki to wyrzucenie niepełnych obserwacji metodą
dropna. Domyślnie usuwa ona cały wiersz, w którym brakuje choćby jednej wartości, i tak samo jak drop zwraca nową tabelę zamiast poprawiać starą.1print(notes.dropna().shape) # (2, 3)
2print(notes.shape) # (4, 3)Z czterech obserwacji zostały dwie, bo słoń stracił liczebność, a gepard siedlisko. Zwróć uwagę, jak drogo kosztuje ta czystość: usunąłeś połowę zbioru, żeby załatać dwie komórki, a razem z nimi wyleciały poprawne nazwy gatunków i poprawna liczebność geparda. Przy dużym zbiorze i pojedynczych dziurach
dropna jest w porządku, przy małym potrafi wyciąć Ci dane spod nóg. Oryginalne notes ma dalej cztery wiersze, więc nic nie przepadło na zawsze.Drugie wyjście to wypełnienie dziur metodą
fillna, której podajesz wartość zastępczą. Wybór tej wartości to decyzja merytoryczna, nie techniczna. Wstawienie zera do kolumny z liczebnością oznacza "tego gatunku tu nie ma", co jest zwyczajnie nieprawdą i zaniży każdą późniejszą średnią. Dlatego przy pomiarach polecam wypełnianie średnią z pozostałych obserwacji: nie zmyśla wtedy nowej informacji, tylko wstawia wartość typową.1average = notes["population"].mean()
2print(average) # 135.0
3
4print(notes["population"].fillna(0).tolist()) # [120.0, 0.0, 85.0, 200.0]
5print(notes["population"].fillna(average).tolist()) # [120.0, 135.0, 85.0, 200.0]Średnia wyszła sto trzydzieści pięć, czyli suma czterystu pięciu podzielona przez trzy, a nie przez cztery - metody statystyczne w Pandas pomijają NaN przy liczeniu. Porównaj oba wyniki: wersja z zerem wciska w środek zbioru wartość niższą od wszystkich pozostałych, wersja ze średnią wstawia liczbę, która nie zaburza obrazu. Żadne z tych wywołań nie zmieniło
notes, bo fillna, tak jak dropna, zwraca kopię - żeby zachować poprawkę, musisz przypisać wynik do kolumny.groupbyDo tej pory patrzyliśmy na wiersze pojedynczo. Prawdziwe pytania z rezerwatu brzmią jednak inaczej: nie "ile jest lwów", tylko "która strefa utrzymuje większe populacje - sawanna czy las". To pytanie o grupy, a odpowiada na nie metoda
groupby, której podajesz nazwę kolumny do podziału. Wywołanie składa się z sześciu części: df, ., groupby, (, 'habitat', ). Samo w sobie nie zwraca jeszcze żadnej tabeli, tylko obiekt grupujący, czyli plan podziału. Wynik pojawi się dopiero, gdy dopiszesz do niego działanie - na przykład mean, czyli średnią.1by_habitat = df.groupby("habitat")
2
3print(by_habitat["population"].mean())
4# habitat
5# Forest 450.0
6# Savanna 135.0
7# Name: population, dtype: float64Dwa siedliska, dwie średnie. Najważniejsze jest to, co stało się z indeksem: nazwy siedlisk przeniosły się z kolumny do etykiet wierszy wyniku, bo po grupowaniu to one identyfikują wiersz. Zapis
by_habitat["population"] mówi "policz tylko na kolumnie liczebności" i warto go stosować, bo próba policzenia średniej z kolumny tekstowej nie ma sensu. Sam df po tej operacji jest bez zmian: grupowanie niczego nie przenosi ani nie usuwa z oryginału, tylko czyta go i buduje podsumowanie obok.Obiekt grupujący da się odpytać na wiele sposobów i nie musisz go budować od nowa za każdym razem. Metoda
sum sumuje wartości w grupie, a size zlicza wiersze, czyli odpowiada na pytanie "ile gatunków wpadło do każdej strefy".1print(by_habitat["population"].sum())
2# habitat
3# Forest 450
4# Savanna 405
5# Name: population, dtype: int64
6
7print(by_habitat.size())
8# habitat
9# Forest 1
10# Savanna 3
11# dtype: int64Suma w lesie wypadła wyżej niż w sawannie, choć średnia mówiła to samo dużo dobitniej - i właśnie dlatego warto patrzeć na obie liczby naraz. Wydruk
size tłumaczy, skąd ta różnica: w lesie żyje jeden gatunek, a w sawannie trzy, więc czterysta pięćdziesiąt słoni bije trzy mniejsze populacje na sumie, ale różnica w średniej jest ponaddwukrotna. Zauważ, że size nie potrzebuje wskazania kolumny, bo liczy wiersze, a nie wartości.Kiedy chcesz kilka statystyk naraz, zamiast wywoływać metody jedna po drugiej, podajesz metodzie
agg słownik: klucz to nazwa kolumny, a wartość to lista nazw działań zapisanych jako teksty.1stats = df.groupby("habitat").agg({
2 "population": ["mean", "sum", "count"],
3 "endangered": "sum",
4})
5
6print(stats)
7# population endangered
8# mean sum count sum
9# habitat
10# Forest 450.0 450 1 0
11# Savanna 135.0 405 3 2Cztery statystyki w jednej tabelce, a nagłówek zrobił się dwupoziomowy: górny wiersz mówi, z której kolumny liczono, dolny - jakie działanie zastosowano. Ostatnia kolumna kryje drobną sztuczkę wartą zapamiętania: sumowanie wartości logicznych daje liczbę wystąpień
True, więc endangered z sumą dwa oznacza dwa zagrożone gatunki w sawannie. Cała ta operacja znów tylko czytała dane - df ma dalej te same wiersze i kolumny co przed grupowaniem.sort_valuesPodsumowania odpowiadają na pytania o grupy, ale często wystarczy zwykły ranking: który gatunek jest najliczniejszy. Do porządkowania wierszy służy metoda
sort_values, której podajesz nazwę kolumny. Domyślnie sortuje rosnąco, a parametr ascending=False odwraca kolejność na malejącą. Możesz też podać listę kolumn i wtedy Pandas sortuje najpierw po pierwszej, a przy remisach po drugiej.1ranking = df.sort_values("population", ascending=False)
2
3print(ranking["species"].tolist()) # ['Elephant', 'Giraffe', 'Lion', 'Cheetah']
4print(ranking.index.tolist()) # [1, 3, 0, 2]
5print(df["species"].tolist()) # ['Lion', 'Elephant', 'Cheetah', 'Giraffe']Ranking od słonia do geparda ustawił się w jednej linii. Drugi wydruk pokazuje rzecz, która zaskakuje wszystkich za pierwszym razem: indeks pojechał razem z wierszami, więc na górze tabeli stoi teraz wiersz o etykiecie 1, a nie 0. To celowe, bo dzięki temu wiadomo, gdzie dany wiersz leżał w oryginale. Trzeci wydruk to najważniejsze zdanie tej sekcji: kolejność w
df nie zmieniła się ani trochę, bo sort_values zwraca posortowaną kopię. Istnieje jeszcze sort_index, który porządkuje tabelę po etykietach wierszy i przydaje się właśnie po to, żeby po sortowaniu wrócić do stanu wyjściowego.merge i concatW ekspedycji rzadko wszystko mieści się w jednym zeszycie. Załóżmy, że obok tabeli gatunków masz drugą, opisującą siedliska: ich nazwy i powierzchnię. Chcesz je zestawić, dopisując do każdego zwierzęcia powierzchnię jego strefy. Do łączenia tabel po wspólnej kolumnie służy funkcja
pd.merge, znana każdemu, kto widział polecenie JOIN w SQL. Podajesz jej dwie tabele, w parametrze on nazwę wspólnej kolumny, a w how sposób łączenia. Wartość "left" znaczy "zachowaj wszystkie wiersze z lewej tabeli", czyli w naszym przypadku wszystkie zwierzęta.1habitats = pd.DataFrame({
2 "habitat": ["Savanna", "Forest", "Wetland"],
3 "area_km2": [1200, 800, 300],
4})
5
6merged = pd.merge(df, habitats, on="habitat", how="left")
7
8print(merged[["species", "habitat", "area_km2"]])
9# species habitat area_km2
10# 0 Lion Savanna 1200
11# 1 Elephant Forest 800
12# 2 Cheetah Savanna 1200
13# 3 Giraffe Savanna 1200Każde zwierzę dostało powierzchnię swojej strefy, a wartość tysiąc dwieście powtórzyła się trzy razy, bo trzy gatunki mieszkają w sawannie - łączenie kopiuje dane z mniejszej tabeli tam, gdzie pasują. Zwróć uwagę na to, czego w wyniku nie ma: mokradła z tabeli
habitats nie pojawiły się nigdzie, bo w df nie mieszka tam żaden gatunek, a łączenie "left" patrzy tylko na lewą tabelę. Gdybyś dał how="outer", doszedłby piąty wiersz z mokradłami i pustymi wartościami NaN w kolumnach o zwierzętach. Wariant "inner" zostawiłby wyłącznie siedliska obecne w obu tabelach, a "right" byłby lustrzanym odbiciem "left".Zupełnie inny problem pojawia się, gdy dwie tabele mają te same kolumny, a różne wiersze - na przykład dwa obozy przysłały notesy o identycznej budowie. Wtedy nie łączysz ich po kluczu, tylko dostawiasz jeden pod drugim funkcją
pd.concat, której podajesz listę tabel.1north = pd.DataFrame({"species": ["Lion", "Zebra"], "population": [120, 300]})
2south = pd.DataFrame({"species": ["Hyena", "Impala"], "population": [60, 540]})
3
4both = pd.concat([north, south], ignore_index=True)
5
6print(both["species"].tolist()) # ['Lion', 'Zebra', 'Hyena', 'Impala']
7print(both.index.tolist()) # [0, 1, 2, 3]Cztery wiersze z dwóch obozów w jednej tabeli, w kolejności podanej na liście. Parametr
ignore_index=True jest tu ważniejszy, niż wygląda: bez niego indeks wyniku wyglądałby jak 0, 1, 0, 1, bo każda tabela przyniosłaby swoje własne etykiety i powstałyby duplikaty. Domyślnie concat dokłada wiersze, czyli działa wzdłuż osi zero; z parametrem axis=1 dostawiałby kolumny bok w bok. Obie tabele źródłowe zostają bez zmian - north ma dalej swoje dwa wiersze.Analiza, która ginie razem z zamknięciem terminala, nie jest jeszcze wynikiem ekspedycji. Gotową tabelę zapisujesz metodą
to_csv, podając nazwę pliku. Jeden parametr jest przy tym praktycznie obowiązkowy: index=False. Bez niego Pandas zapisze do pliku również etykiety wierszy, jako pierwszą kolumnę bez nagłówka, a przy ponownym wczytaniu read_csv potraktuje ją jako zwykłe dane i nazwie Unnamed: 0. Po kilku takich rundach plik obrasta w kolumny, których nikt nie zamawiał.1report = df[["species", "population", "status"]]
2report.to_csv("safari_report.csv", index=False)
3
4with open("safari_report.csv") as f:
5 print(f.read())
6# species,population,status
7# Lion,120,stable
8# Elephant,450,stable
9# Cheetah,85,endangered
10# Giraffe,200,stablePlik wygląda dokładnie tak jak ten, od którego zaczynaliśmy wczytywanie: nagłówek i cztery wiersze rozdzielone przecinkami. Zapisały się tylko trzy wybrane kolumny, bo
to_csv woła się na tym, co mu podasz, a podaliśmy wycinek - reszta kolumn siedzi dalej w df i nie ucierpiała. Metoda niczego nie zwraca i nie zmienia tabeli, po prostu odkłada jej kopię na dysk. Bliźniacze to_excel i to_json zapisują ten sam DataFrame w innych formatach, przy czym pierwsza, tak jak przy czytaniu, wymaga doinstalowania pakietu do obsługi arkuszy.Panel został usunięty w wersji 1.0, a klasy Table w Pandas nigdy nie było.pd.DataFrame({...}), gdzie klucz to nazwa kolumny, a wartość to lista.pd, ., read_csv, (, 'data.csv', ).df.head() zwraca pierwsze pięć wierszy tabeli. Nie zwraca nagłówków (to df.columns), nie zwraca pierwszego elementu każdej kolumny (to df.iloc[0]) i niczego nie sortuje (to sort_values).df.info() wołasz bez print, bo sam drukuje i zwraca None. df.describe() liczy statystyki wyłącznie dla kolumn liczbowych.df[, df['population'], > 100, ]. Warunki łączysz znakami & i |, każdy w osobnych nawiasach.df.drop('temp', axis=1), bo axis=1 to kolumny, a axis=0 to wiersze. Metody delete i remove w DataFrame nie istnieją.df, ., groupby, (, 'habitat', ), a wynik dostajesz dopiero po dopisaniu działania, na przykład mean().df["kolumna"], które zmienia tabelę na miejscu.W następnej lekcji te same kolumny zamienimy na wykresy i zobaczysz, jak jedno spojrzenie na rysunek zastępuje przeglądanie stu wierszy. Na razie zapamiętaj jedno, @name: Pandas nie zbiera danych za Ciebie i nie poprawia błędów obserwatora - daje Ci tylko notes, w którym każda kolumna ma nazwę, własny typ i całą tabelę pod ręką, gdy zadajesz pytanie.