Używamy cookies, żeby zwiększyć Twoje doświadczenia na stronie
CodeWorlds

NumPy - tablica, która liczy całą kolumną naraz

Wracasz z objazdu pięciu rezerwatów, @name, i masz w notesie pięć liczb: 120, 450, 85, 200 oraz 330 sztuk. Pytania, które zaraz zada kierownik ekspedycji, są co sezon te same. Ile średnio przypada na rezerwat? O ile urośnie każda populacja, jeśli przyrost wyniesie dziesięć procent? Które stada zeszły poniżej progu stu sztuk? Zwykła lista Pythona przechowa te liczby bez najmniejszego problemu, ale na żadne z tych pytań nie odpowie wprost.

Zanim sięgniemy po nowe narzędzie, warto zobaczyć, gdzie dokładnie stare przestaje wystarczać. Wbudowana funkcja

sum
doda elementy listy, a
len
policzy, ile ich jest - jedno podzielone przez drugie daje średnią i tu wszystko gra. Gorzej z przeliczaniem: mnożenie listy przez liczbę całkowitą nie mnoży wartości, tylko powiela całą listę, a mnożenie przez ułamek w ogóle nie przechodzi i kończy się wyjątkiem
TypeError
.

1populations = [120, 450, 85, 200, 330]
2
3print(sum(populations) / len(populations))  # 237.0
4
5print(populations * 2)
6# [120, 450, 85, 200, 330, 120, 450, 85, 200, 330]
7
8# populations * 1.1
9# TypeError: can't multiply sequence by non-int of type 'float'

Trzy linie i trzy zupełnie różne zachowania. Średnia wyszła poprawna, bo policzyliśmy ją ręcznie, krok po kroku. Drugi wydruk pokazuje, że dla listy znak mnożenia znaczy "powtórz zawartość", więc zamiast podwojonych liczebności dostaliśmy dziesięć elementów zamiast pięciu. Trzecia linia jest zakomentowana nie bez powodu: gdybyś ją uruchomił, program zatrzymałby się na błędzie, bo Python nie umie powielić listy ułamek raza. Zwróć uwagę na to, czego tu nie ma: ani jednej liczby przeliczonej o dziesięć procent. Żeby ją uzyskać, musiałbyś napisać pętlę, a przy pięciu tysiącach odczytów z obroży GPS pętla w Pythonie robi się boleśnie wolna.

NumPy, czyli Numerical Python, to biblioteka, która załatwia oba problemy naraz. Wprowadza własny typ danych - tablicę - w której wszystkie elementy są tego samego rodzaju i leżą w pamięci jeden przy drugim. Dzięki temu działanie arytmetyczne wykonuje się na całej tablicy naraz, bez pętli, a same obliczenia wykonuje skompilowany kod w języku C, a nie interpreter Pythona. NumPy to również fundament, na którym stoi cała reszta ekwipunku badacza danych: Pandas, Matplotlib i biblioteki do uczenia maszynowego liczą pod spodem właśnie tablicami NumPy.

Czego się nauczysz

  • Instalować NumPy i wprowadzać go do programu pod przyjętym w całym świecie skrótem.
  • Budować tablice z własnych pomiarów oraz zamawiać gotowe tablice wypełnione zerami, jedynkami i równymi ciągami liczb.
  • Odczytywać metryczkę tablicy: jej kształt, liczbę wymiarów, liczbę elementów i typ danych.
  • Liczyć statystyki całego stada jednym wywołaniem: średnią, medianę, odchylenie, minimum, maksimum i sumę.
  • Przeliczać wszystkie pomiary naraz, bez pętli, i wybierać z nich tylko te, które spełniają warunek.
  • Wycinać fragmenty tablic, przestawiać ich kształt i wykonywać działania na macierzach.

Instalacja i skrót
np

NumPy nie wchodzi w skład standardowej biblioteki Pythona, więc trzeba go najpierw doinstalować. Robi to

pip
, czyli menedżer pakietów Pythona - program, który pobiera bibliotekę z sieci i odkłada ją w Twoim środowisku. Polecenie wpisujesz w terminalu, a nie w pliku z kodem, i wykonujesz je tylko raz na środowisko, a nie przy każdym uruchomieniu skryptu. Jeśli pracujesz w Google Colab albo w dystrybucji Anaconda, NumPy jest już na miejscu i ten krok możesz spokojnie pominąć.

1pip install numpy

Po tym poleceniu pakiet leży na dysku, ale żaden Twój plik z kodem jeszcze o nim nie wie. Do wprowadzenia biblioteki do programu służy osobne polecenie, tym razem w samym Pythonie. Słowo kluczowe

import
wciąga bibliotekę do pamięci, a słowo kluczowe
as
nadaje jej alias, czyli drugą, krótszą nazwę na dokładnie tę samą rzecz. Przyjęło się skracać NumPy do dwóch liter
np
i jest to 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 ją.

1import numpy as np

Ta jedna linia składa się z czterech części i kolejność jest w niej sztywna: najpierw słowo

import
, potem pełna nazwa pakietu
numpy
, potem słowo
as
, a na końcu skrót
np
. Zapamiętaj tę czwórkę, bo będziesz ją pisał na początku każdego skryptu z analizą. Nic poza tym się nie wydarzyło - nie powstała żadna tablica i nic nie zostało policzone. Wariant
import numpy
też jest poprawny, tylko wtedy przy każdym wywołaniu musisz pisać pełne
numpy.array
, co przy trzydziestu wywołaniach w skrypcie robi się męczące. Nie zadziała za to
import np
: pakiet nazywa się
numpy
, więc Python zakończy błędem
ModuleNotFoundError: No module named 'np'
. Skrót nadajesz sam, w tej właśnie linii, i dopiero od niej zaczyna on istnieć.

Pierwsza tablica:
np.array

Skoro biblioteka jest już pod ręką, zamieńmy notes na tablicę. Funkcja, która to robi, nazywa się

array
, czyli po prostu "tablica", i przyjmuje jeden argument: listę wartości. Zwraca obiekt klasy
ndarray
- to skrót od n-dimensional array, czyli tablicy o dowolnej liczbie wymiarów. Sprawdzimy to od razu wbudowaną funkcją
type
, która odpowiada na pytanie "jakiego rodzaju jest ten obiekt". Nazwę
np.array
warto wbić sobie do głowy dokładnie w tej postaci, bo to jedyna poprawna droga z listy do tablicy.

1import numpy as np
2
3arr = np.array([1, 2, 3, 4, 5])
4
5print(arr)        # [1 2 3 4 5]
6print(type(arr))  # <class 'numpy.ndarray'>

Pierwsza rzecz, która rzuca się w oczy w wydruku, to brak przecinków. Lista Pythona wypisuje się jako

[1, 2, 3, 4, 5]
, a tablica NumPy jako
[1 2 3 4 5]
- elementy rozdzielają same spacje. To najprostszy sposób, żeby na pierwszy rzut oka odróżnić jedno od drugiego w konsoli. Drugi wydruk potwierdza, że dostaliśmy nowy rodzaj obiektu, a nie ulepszoną listę. I rzecz równie ważna: lista, którą podałeś w nawiasach, nie zmieniła się ani trochę. NumPy skopiował z niej wartości do własnej struktury i zostawił oryginał w spokoju.

Wokół tworzenia tablic krąży kilka nazw, które brzmią sensownie, a nie istnieją. Nie ma funkcji

np.list
, bo lista to typ Pythona, a nie NumPy. Nie ma
np.create
ani
np.new
, mimo że w innych bibliotekach takie nazwy bywają spotykane. Nie ma też
numpy.new
, i to z dwóch niezależnych powodów naraz: takiej funkcji w pakiecie nie ma, a po
import numpy as np
nazwa
numpy
nie jest w Twoim programie w ogóle zdefiniowana. Możesz to sprawdzić bez wiary na słowo, funkcją
hasattr
, która odpowiada na pytanie "czy ten obiekt ma w środku nazwę o takim brzmieniu".

1print(hasattr(np, "array"))   # True
2print(hasattr(np, "list"))    # False
3print(hasattr(np, "create"))  # False
4print(hasattr(np, "new"))     # False

Jedna prawda i trzy fałsze, czarno na białym. Wywołanie

np.list([1, 2, 3])
kończy się komunikatem
AttributeError: module 'numpy' has no attribute 'list'
i tak samo skończą się dwa pozostałe warianty. Zapamiętaj więc jedną drogę: tablicę z listy tworzy
np.array
i tylko
np.array
. Samo wywołanie
hasattr
niczego przy okazji nie zbudowało ani nie zmieniło - sprawdziło tylko, co w bibliotece jest, a czego nie ma. Warto też pamiętać o nawiasach kwadratowych w środku:
np.array
przyjmuje jedną listę, więc
np.array(1, 2, 3)
da błąd o zbyt wielu argumentach.

Jedna tablica, jeden typ

Tablica NumPy różni się od listy nie tylko wydrukiem. Lista Pythona może pomieścić obok siebie liczbę, tekst i wartość logiczną, a tablica musi mieć jeden wspólny typ dla wszystkich elementów - i to właśnie ten warunek daje jej szybkość, bo komputer wie z góry, ile bajtów zajmuje każdy element. Typ tablicy odczytasz z atrybutu

dtype
, czyli data type. Zobaczmy, co się stanie, gdy do samych liczb całkowitych dorzucimy jeden ułamek.

1whole = np.array([1, 2, 3])
2mixed = np.array([1, 2, 3.5])
3
4print(whole.dtype)  # int64
5print(mixed.dtype)  # float64
6print(mixed)        # [1.  2.  3.5]

Pierwsza tablica dostała typ

int64
, czyli liczby całkowite zapisane na sześćdziesięciu czterech bitach. Druga dostała
float64
, czyli liczby zmiennoprzecinkowe, choć wpisałeś do niej dwie liczby całkowite i tylko jeden ułamek. NumPy wybrał typ, który pomieści wszystkie wartości bez straty, i cicho przerobił jedynkę oraz dwójkę na
1.0
i
2.0
- widać to w ostatnim wydruku po kropkach bez cyfr. Twoje dane nie zostały uszkodzone, ale zmieniły postać, więc warto na to zerkać. Drobna uwaga praktyczna: na niektórych systemach, głównie na Windowsie, domyślnym typem całkowitym bywa
int32
zamiast
int64
. To ta sama liczba w mniejszym pudełku i nie zmienia niczego w Twoim kodzie.

Ta zasada jednego typu wygląda niewinnie, dopóki liczysz same pomiary. W następnej lekcji przekonasz się, że dla notesu terenowego, w którym obok liczebności stoi nazwa gatunku i informacja o zagrożeniu, jest ona twardym ograniczeniem - i właśnie dlatego powstała biblioteka Pandas.

Metryczka tablicy:
shape
,
ndim
,
size

Tablica nie musi być pojedynczym szeregiem liczb. Podając listę list, dostajesz tablicę dwuwymiarową, czyli macierz: pierwsza lista wewnętrzna staje się pierwszym wierszem, druga drugim, i tak dalej. Do opisania takiej struktury służą trzy atrybuty. Atrybut

shape
zwraca krotkę, czyli niezmienny ciąg wartości zapisywany w nawiasach okrągłych, w kolejności wiersze, kolumny. Atrybut
ndim
mówi, ile wymiarów ma tablica, a
size
- ile w sumie ma elementów. Wszystkie trzy są atrybutami, a nie metodami, więc piszesz je bez nawiasów.

1arr = np.array([[1, 2, 3], [4, 5, 6]])
2
3print(arr.shape)  # (2, 3)
4print(arr.ndim)   # 2
5print(arr.size)   # 6
6print(arr.dtype)  # int64

Cztery liczby, które łatwo pomylić, więc przeczytajmy je powoli. Kształt to

(2, 3)
, co czyta się zawsze tak samo: dwa wiersze na trzy kolumny, nigdy odwrotnie. Podałeś dwie listy wewnętrzne, po trzy liczby każda, więc wierszy jest dwa, a kolumn trzy - odpowiedź
(3, 2)
opisywałaby zupełnie inną tablicę, taką o trzech wierszach i dwóch kolumnach. Liczba
6
to
size
, czyli łączna liczba elementów, a nie kształt. Zwróć też uwagę na nawiasy: kształt wraca w nawiasach okrągłych, bo to krotka, a nie w kwadratowych, bo nie jest listą. Sama tablica po tych czterech wydrukach jest dokładnie taka sama jak przed nimi - odczyt atrybutu niczego nie zmienia.

Skoro kształt jest krotką, można się do niego odwoływać po pozycji, dokładnie tak jak do listy. Zapis

arr.shape[0]
daje liczbę wierszy, a
arr.shape[1]
liczbę kolumn, i tego pierwszego będziesz używał bez końca, żeby sprawdzić, ile obserwacji masz w zbiorze.

1print(type(arr.shape))  # <class 'tuple'>
2print(arr.shape[0])     # 2
3print(arr.shape[1])     # 3
4
5# arr.shape()
6# TypeError: 'tuple' object is not callable

Ostatnie dwie linie są zakomentowane celowo i warto je zapamiętać jako ostrzeżenie. Dopisanie nawiasów do

shape
kończy się błędem, bo próbujesz wywołać jak funkcję coś, co jest gotową krotką. To najczęstsza pomyłka przy pierwszym kontakcie z NumPy: metody wołasz z nawiasami, atrybuty czytasz bez. Trzy pierwsze wydruki potwierdzają natomiast, że w
shape
naprawdę siedzi zwykła krotka Pythona, ze wszystkim, co z tego wynika - możesz ją rozpakować do dwóch zmiennych albo porównać z inną krotką.

Tablice na zamówienie:
zeros
i
ones

Nie każda tablica bierze się z pomiarów. Bardzo często potrzebujesz pustego arkusza o zadanym rozmiarze, który dopiero wypełnisz wynikami - na przykład siatki trzech gatunków na cztery kwartały. Funkcja

np.zeros
tworzy taką tablicę wypełnioną zerami, a jej jedynym argumentem jest krotka z wymiarami, znowu w kolejności wiersze, kolumny. To ważny szczegół: cała krotka
(3, 4)
jest jednym argumentem, a nie dwoma, dlatego widzisz w wywołaniu dwie pary nawiasów.

1grid = np.zeros((3, 4))
2
3print(grid)
4# [[0. 0. 0. 0.]
5#  [0. 0. 0. 0.]
6#  [0. 0. 0. 0.]]
7
8print(grid.shape)  # (3, 4)
9print(grid.dtype)  # float64
10print(type(grid))  # <class 'numpy.ndarray'>

Wynik to macierz o trzech wierszach i czterech kolumnach, czyli dwanaście zer ustawionych w prostokąt. Przeczytaj wydruk jeszcze raz i policz: trzy linie po cztery zera każda. Odwrotny odczyt, czyli macierz cztery na trzy, opisywałby inną tablicę, o czterech liniach po trzy zera. Nie jest to też lista trzech list Pythona, mimo że kwadratowe nawiasy wyglądają podobnie - ostatni wydruk pokazuje czarno na białym klasę

numpy.ndarray
. Nie ma tu również żadnego błędu: podanie krotki jako jednego argumentu jest dokładnie tym, czego
np.zeros
oczekuje. Zwróć jeszcze uwagę na kropki przy zerach i na typ
float64
: zera są zmiennoprzecinkowe, bo taki jest domyślny typ tych funkcji.

Bliźniacza funkcja

np.ones
działa identycznie, tylko wypełnia tablicę jedynkami, i przydaje się wtedy, gdy budujesz mnożniki albo wagi. Osobno stoi
np.eye
, która tworzy macierz jednostkową: kwadratową tablicę z jedynkami na przekątnej i zerami wszędzie indziej. Ta ostatnia bierze jedną liczbę, bo macierz jednostkowa z definicji ma tyle samo wierszy co kolumn.

1print(np.ones((2, 3)))
2# [[1. 1. 1.]
3#  [1. 1. 1.]]
4
5print(np.eye(3))
6# [[1. 0. 0.]
7#  [0. 1. 0.]
8#  [0. 0. 1.]]

Dwa wiersze po trzy jedynki oraz kwadrat trzy na trzy z przekątną jedynek - dokładnie tak, jak zapowiadała proza. Macierz jednostkowa gra w algebrze tę samą rolę, co liczba jeden w zwykłym mnożeniu: pomnożenie przez nią niczego nie zmienia, dlatego wykorzystuje się ją jako punkt wyjścia przy odwracaniu macierzy. Warto zauważyć, że

np.eye
przyjmuje samą liczbę, bez krotki, bo drugi wymiar wynika z pierwszego. Żadna z tych funkcji nie potrzebuje Twoich danych - wszystkie budują tablicę od zera, na podstawie samego rozmiaru.

Ciągi liczb:
arange
i
linspace

Do opisania osi czasu albo numerów kolejnych obserwacji potrzebujesz nie zer, lecz rosnącego ciągu liczb. NumPy daje na to dwie funkcje i różnica między nimi jest źródłem wielu pomyłek. Funkcja

np.arange
działa jak wbudowany
range
: podajesz początek, koniec i krok, a ona wypełnia tablicę wartościami co tyle, ile podałeś, pomijając koniec zakresu. Funkcja
np.linspace
pyta o coś innego: początek, koniec i liczbę punktów, a sama dobiera odstęp tak, żeby równo podzielić odcinek, przy czym koniec włącza do wyniku.

1print(np.arange(0, 10, 2))     # [0 2 4 6 8]
2print(np.linspace(0, 1, 5))    # [0.   0.25 0.5  0.75 1.  ]

Pierwsza tablica ma pięć elementów i kończy się na ósemce, bo dziesiątka jako koniec zakresu do wyniku nie wchodzi. Druga też ma pięć elementów, ale ostatnim jest równa jedynka, bo

linspace
traktuje koniec jako wartość docelową, a nie granicę. Trzeci argument znaczy w obu funkcjach zupełnie co innego: w
arange
to odstęp między wartościami, w
linspace
to liczba wartości. Moja rada, @name: kiedy znasz krok i chcesz numerować pomiary, bierz
arange
, a kiedy potrzebujesz dokładnie stu punktów rozłożonych równo od zera do jedynki, bierz
linspace
- inaczej skończysz z ręcznym dzieleniem odcinka i błędem zaokrąglenia na końcu.

Losowe dane:
np.random.rand

Zanim spłyną prawdziwe odczyty z obroży, przydaje się zbiór wymyślony, na którym można przetestować cały skrypt. Losowe liczby generuje moduł

np.random
, a najprostsza z jego funkcji to
rand
, zwracająca wartości z przedziału od zera do jedynki. Jej wymiary podaje się jako osobne argumenty, a nie jako krotkę - to wyjątek od reguły, którą poznałeś przy
np.zeros
, i łatwo się na nim potknąć. Żeby wyniki dało się powtórzyć, ustawiamy wcześniej ziarno generatora funkcją
np.random.seed
, a do skrócenia wydruku używamy metody
round
, która zaokrągla wszystkie wartości tablicy naraz.

1np.random.seed(42)
2sample = np.random.rand(2, 3)
3
4print(sample.round(2))
5# [[0.37 0.95 0.73]
6#  [0.6  0.16 0.16]]

Sześć losowych liczb ułożonych w dwa wiersze po trzy kolumny, każda mniejsza od jedynki. Dzięki ziarnu ustawionemu na czterdzieści dwa dostaniesz dokładnie te same wartości na swoim komputerze - to nie magia, tylko powtarzalny generator, i o to właśnie chodzi przy testowaniu. Bez wywołania

seed
liczby byłyby inne przy każdym uruchomieniu, a Ty nie umiałbyś odtworzyć wyniku sprzed tygodnia. Zwróć uwagę, że
round
niczego nie popsuła w
sample
- zwróciła nową, zaokrągloną tablicę, a oryginał trzyma dalej pełne rozwinięcia. W nowych projektach polecam nowszy zapis
rng = np.random.default_rng(42)
, bo trzyma stan generatora w osobnym obiekcie zamiast globalnie, ale wersję z
seed
musisz znać, bo spotkasz ją w tysiącach istniejących skryptów.

Statystyki jednym wywołaniem

Wracamy do notesu z pierwszej strony lekcji. Pięć liczebności, jedna tablica i komplet pytań, na które trzeba odpowiedzieć. NumPy ma na każde z nich osobną funkcję.

np.mean
liczy średnią arytmetyczną,
np.median
medianę, czyli wartość środkową po ustawieniu liczb w kolejności, a
np.std
odchylenie standardowe, mówiące, jak mocno pomiary rozjeżdżają się wokół średniej. Do tego dochodzą trzy oczywiste:
np.min
,
np.max
i
np.sum
. Każda z nich przyjmuje tablicę i zwraca pojedynczą liczbę.

1populations = np.array([120, 450, 85, 200, 330])
2
3print(np.mean(populations))    # 237.0
4print(np.median(populations))  # 200.0
5print(np.std(populations))     # 135.77923257994942
6print(np.min(populations))     # 85
7print(np.max(populations))     # 450
8print(np.sum(populations))     # 1185

Sześć liczb, które opisują całe stado lepiej niż sam wykaz. Średnia dwieście trzydzieści siedem, a mediana dwieście - różnica bierze się stąd, że słoniowe czterysta pięćdziesiąt ciągnie średnią w górę, a mediany nie rusza, bo ta patrzy tylko na to, co stoi w środku szeregu. Odchylenie standardowe około stu trzydziestu sześciu przy średniej dwustu trzydziestu siedmiu to sygnał, że rezerwaty bardzo się od siebie różnią. Jedna uwaga dla porządku:

np.std
domyślnie liczy odchylenie populacyjne, dzieląc przez liczbę pomiarów, a Pandas w tym samym miejscu liczy odchylenie próby, dzieląc przez tę liczbę pomniejszoną o jeden. Dla tych samych pięciu liczb da to 151.81 zamiast 135.78, więc nie zdziw się, gdy zobaczysz dwie różne wartości. Sama tablica
populations
po tych sześciu wydrukach ma nadal te same pięć liczb.

Wywołanie średniej warto rozłożyć na części, bo będziesz je pisał częściej niż jakiekolwiek inne. Składa się z sześciu elementów w sztywnej kolejności:

np
,
.
,
mean
,
(
,
arr
,
)
. Najpierw skrót biblioteki, potem kropka, potem nazwa funkcji, nawias otwierający, nazwa tablicy i nawias zamykający. Istnieje też druga forma, w której średnia jest metodą samej tablicy.

1arr = np.array([1, 2, 3, 4, 5])
2
3print(np.mean(arr))  # 3.0
4print(arr.mean())    # 3.0

Ten sam wynik dwiema drogami i to nie przypadek - to dosłownie ta sama operacja zapisana inaczej. Forma

np.mean(arr)
ma jednak przewagę, którą polecam wykorzystywać: działa również na zwykłej liście Pythona, więc
np.mean([1, 2, 3, 4, 5])
też zwróci trójkę, podczas gdy
[1, 2, 3, 4, 5].mean()
skończy się błędem, bo lista takiej metody nie ma. Przy pisaniu skryptu, który dostaje dane z różnych miejsc, ta odporność bywa bezcenna. Żadne z tych wywołań nie zmienia tablicy - obliczenie statystyki to czysty odczyt.

Wektoryzacja: jedno działanie na całą tablicę

Teraz wracamy do pytania, na którym potknęła się lista: o ile urosną populacje przy dziesięcioprocentowym przyroście. W NumPy zapisuje się to dokładnie tak, jak to brzmi po polsku - mnożysz tablicę przez 1.1. Taki zapis nazywa się wektoryzacją: pojedyncza liczba zostaje rozciągnięta na wszystkie elementy tablicy i działanie wykonuje się osobno dla każdego z nich, bez żadnej pętli. NumPy nazywa ten mechanizm broadcasting, czyli rozgłaszanie.

1growth = populations * 1.1
2
3print(growth)      # [132.  495.   93.5 220.  363. ]
4print(populations) # [120 450  85 200 330]
5print(growth.dtype)  # float64

Pięć nowych liczebności z jednej linii kodu, bez pętli i bez indeksów. Drugi wydruk jest tu najważniejszy:

populations
trzyma nadal pierwotne pomiary, bo działanie arytmetyczne zwraca nową tablicę, a nie poprawia starej. Trzeci wydruk pokazuje skutek uboczny mnożenia przez ułamek - wynik przeszedł na typ
float64
, bo dziewięćdziesiąt trzy i pół nie mieści się w liczbach całkowitych. Biologicznie pół geparda to bezsens, ale matematycznie tak właśnie działa mnożenie, a od zaokrąglenia jest
round
. Tak samo zachowają się pozostałe działania:
populations + 10
doda dziesiątkę do każdej wartości, a
populations / 2
podzieli każdą na pół.

Wektoryzacja obsłuży też wyrażenie złożone z kilku kroków naraz. Klasycznym przykładem jest normalizacja, czyli sprowadzenie pomiarów do wspólnej skali: od każdej wartości odejmujesz średnią i dzielisz przez odchylenie standardowe. Wynik mówi, o ile odchyleń dana populacja leży powyżej lub poniżej przeciętnej, więc da się go porównywać między zupełnie różnymi wielkościami - liczebnością stada i opadem w milimetrach.

1normalized = (populations - np.mean(populations)) / np.std(populations)
2
3print(normalized.round(2))  # [-0.86  1.57 -1.12 -0.27  0.68]
4print(np.mean(normalized).round(10))  # 0.0
5print(np.std(normalized).round(10))   # 1.0

Pięć liczb, tym razem po obu stronach zera. Wartości ujemne to rezerwaty poniżej średniej, dodatnie powyżej, a słoń z wynikiem 1.57 leży półtora odchylenia nad przeciętną. Dwa ostatnie wydruki potwierdzają, że przekształcenie zadziałało zgodnie z definicją: znormalizowany zbiór ma średnią zero i odchylenie jeden. Cała ta operacja to trzy wywołania i jedno dzielenie na całej tablicy naraz - w czystym Pythonie zajęłaby pętlę i dwa przebiegi po danych. Oryginalne

populations
znów pozostało nietknięte, bo każde z tych działań zbudowało nową tablicę obok.

Indeksowanie i wycinki

Do pojedynczych wartości sięgasz tak samo, jak w liście Pythona: podając pozycję w nawiasach kwadratowych, licząc od zera. Indeks ujemny liczy od końca, więc minus jeden to element ostatni. Wycinek, czyli slice, zapisujesz dwiema liczbami rozdzielonymi dwukropkiem: pierwsza to pozycja początkowa, druga to pozycja, przed którą wycinek się kończy - koniec zakresu nie wchodzi do wyniku. Pominięcie liczby przed dwukropkiem znaczy "od początku", a po dwukropku "do końca". Trzecia liczba, po drugim dwukropku, to krok.

1arr = np.array([10, 20, 30, 40, 50])
2
3print(arr[0])     # 10
4print(arr[-1])    # 50
5print(arr[1:4])   # [20 30 40]
6print(arr[:3])    # [10 20 30]
7print(arr[::2])   # [10 30 50]

Pięć zapisów i pięć różnych odpowiedzi z tej samej tablicy. Wycinek

arr[1:4]
daje trzy elementy, a nie cztery, bo pozycja czwarta stanowi granicę i sama do wyniku nie wchodzi - to reguła, która myli każdego na początku. Zapis
arr[:3]
bierze pozycje zero, jeden i dwa, czyli trzy pierwsze wartości. Ostatni,
arr[::2]
, przechodzi przez całą tablicę co drugi element i dlatego zwraca dziesiątkę, trzydziestkę i pięćdziesiątkę. Warto zauważyć, że wszystkie te wydruki są tablicami NumPy, a nie listami, więc możesz na nich od razu policzyć średnią.

Jest jednak jedna różnica wobec list Pythona, która potrafi kosztować godzinę szukania błędu. Wycinek listy tworzy kopię, a wycinek tablicy NumPy tworzy widok, czyli okno na te same dane w pamięci. Zmiana wartości w widoku zmienia oryginał. Sprawdźmy to na osobnej tablicy, żeby zobaczyć efekt bez wątpliwości.

1readings = np.array([10, 20, 30, 40, 50])
2window = readings[1:4]
3window[0] = 999
4
5print(readings)  # [ 10 999  30  40  50]
6
7safe = readings[1:4].copy()
8safe[0] = 111
9
10print(readings)  # [ 10 999  30  40  50]
11print(safe)      # [111  30  40]

Pierwsza część kodu psuje dane po cichu: przypisanie do

window
podmieniło wartość w
readings
, choć nikt tej tablicy wprost nie dotykał. Druga część pokazuje lekarstwo - metoda
copy
tworzy niezależną kopię wycinka i od tej chwili obie tablice żyją własnym życiem, co widać po tym, że
readings
nie zmieniło się po podstawieniu
111
. Moja rada jest tu jednoznaczna, @name: jeśli zamierzasz cokolwiek zapisywać do wycinka, dopisz
copy
od razu, bez zastanawiania się, czy w tym konkretnym miejscu widok zaszkodzi. Przy samym odczytywaniu widok jest lepszy, bo nie kopiuje pamięci i działa błyskawicznie.

Maska logiczna: wybieranie po warunku

Pytanie o stada poniżej stu sztuk rozwiązuje się w NumPy dwoma krokami i oba warto zobaczyć osobno. Porównanie tablicy z liczbą nie zwraca jednego

True
, tylko wykonuje porównanie dla każdego elementu z osobna i oddaje tablicę wartości logicznych o tej samej długości. Taką tablicę nazywamy maską logiczną. Włożona z powrotem w nawiasy kwadratowe wybiera wyłącznie te elementy, którym odpowiada
True
.

1print(populations < 100)
2# [False False  True False False]
3
4print(populations[populations < 100])  # [85]
5print(populations)  # [120 450  85 200 330]

Pierwszy wydruk to sama decyzja: pięć odpowiedzi tak albo nie, w tej samej kolejności co pomiary, i ani jednej liczebności - maska nie przechowuje danych, tylko werdykt o każdym elemencie. Drugi wydruk stosuje ją i zostawia jedyny rezerwat poniżej progu, czyli gepardzie osiemdziesiąt pięć. Trzeci wydruk mówi rzecz najważniejszą:

populations
ma nadal wszystkie pięć wartości, bo filtrowanie zwróciło nową tablicę. Ten sam mechanizm, dosłownie ten sam, zobaczysz w następnej lekcji przy filtrowaniu tabel w Pandas - warto go więc zrozumieć teraz, na pięciu liczbach, a nie później, na trzech tysiącach wierszy.

Tablice dwuwymiarowe pod lupą

W macierzy jedna pozycja nie wystarczy, bo trzeba wskazać wiersz i kolumnę. Podajesz je w jednych nawiasach kwadratowych, rozdzielone przecinkiem, zawsze w kolejności wiersz, kolumna. Sam dwukropek w miejscu jednego z nich znaczy "wszystkie" - dzięki temu

[:, 0]
czyta się jako "wszystkie wiersze, kolumna zerowa", czyli cała pierwsza kolumna.

1matrix = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]])
2
3print(matrix[0, 1])  # 2
4print(matrix[:, 0])  # [1 4 7]
5print(matrix[1, :])  # [4 5 6]

Trzy zapytania i trzy różne kształty odpowiedzi. Pierwsze zwraca pojedynczą liczbę, bo wskazałeś dokładnie jedną komórkę: wiersz zerowy, kolumna pierwsza, czyli dwójka. Drugie zwraca tablicę trzech liczb pobranych z góry na dół - to kolumna, więc w wyniku widzisz wartości z trzech różnych wierszy. Trzecie zwraca cały wiersz środkowy. Działa też zapis

matrix[0][1]
, bo najpierw wyciąga wiersz, a potem z niego element, ale polecam wersję z przecinkiem: jest krótsza, nie tworzy po drodze pośredniej tablicy i pozwala wycinać oba wymiary naraz, na przykład
matrix[0:2, 1:3]
.

Zmiana kształtu:
reshape
,
flatten
i
T

Te same liczby można ułożyć na różne sposoby i NumPy pozwala to robić bez przepisywania danych. Metoda

reshape
przestawia tablicę na zadany kształt, pod jednym warunkiem: iloczyn nowych wymiarów musi się zgadzać z liczbą elementów. Metoda
flatten
robi rzecz odwrotną - spłaszcza dowolną tablicę do jednego szeregu. Atrybut
T
to transpozycja, czyli zamiana wierszy z kolumnami, i tak jak
shape
piszemy go bez nawiasów.

1arr = np.arange(12)
2matrix = arr.reshape(3, 4)
3
4print(matrix)
5# [[ 0  1  2  3]
6#  [ 4  5  6  7]
7#  [ 8  9 10 11]]
8
9print(matrix.flatten())  # [ 0  1  2  3  4  5  6  7  8  9 10 11]
10print(matrix.T.shape)    # (4, 3)

Dwanaście liczb od zera do jedenastu ułożyło się w trzy wiersze po cztery, wypełniane wierszami od lewej do prawej. Spłaszczenie wróciło do jednego szeregu w tej samej kolejności, a transpozycja odwróciła kształt z

(3, 4)
na
(4, 3)
- to kolejna okazja, żeby przypomnieć sobie, że pierwsza liczba kształtu to zawsze wiersze. Wywołanie
arr.reshape(5, 4)
zakończyłoby się błędem
ValueError: cannot reshape array of size 12 into shape (5,4)
, bo pięć razy cztery to dwadzieścia, a elementów jest dwanaście. Jest na to wygodny skrót: podaj minus jeden zamiast jednego z wymiarów, a NumPy sam go policzy, więc
arr.reshape(3, -1)
da ten sam kształt
(3, 4)
. Sama
arr
po tych operacjach jest nadal płaskim ciągiem dwunastu liczb.

Działania na macierzach

Przy dwóch macierzach dochodzi rozróżnienie, które w matematyce jest fundamentalne, a w kodzie sprowadza się do jednego znaku. Dodawanie i mnożenie zapisane zwykłymi znakami działa element po elemencie: NumPy zestawia komórki leżące na tych samych pozycjach. Mnożenie macierzowe, znane z algebry liniowej, zapisuje się operatorem

@
albo funkcją
np.dot
i liczy zupełnie inaczej: każdy element wyniku powstaje z przemnożenia wiersza przez kolumnę i zsumowania iloczynów.

1A = np.array([[1, 2], [3, 4]])
2B = np.array([[5, 6], [7, 8]])
3
4print(A + B)
5# [[ 6  8]
6#  [10 12]]
7
8print(A * B)
9# [[ 5 12]
10#  [21 32]]
11
12print(A @ B)
13# [[19 22]
14#  [43 50]]

Porównaj drugi i trzeci wynik, bo to jedna z najczęstszych pułapek w kodzie uczącym maszyny. Mnożenie gwiazdką dało pięć w lewym górnym rogu, czyli jeden razy pięć - proste zestawienie komórek. Mnożenie operatorem

@
dało w tym samym rogu dziewiętnaście, bo policzyło jeden razy pięć plus dwa razy siedem. Oba są poprawnymi działaniami, tylko odpowiadają na różne pytania, a program nie zgłosi błędu, jeśli wybierzesz nie to, o które Ci chodziło. Zapamiętaj: gwiazdka to działanie na parach komórek,
@
to mnożenie macierzy. Funkcja
np.dot(A, B)
zwraca dokładnie to samo co
A @ B
, ale operator jest czytelniejszy i to jego polecam w nowym kodzie.

Algebra liniowa: moduł
linalg

Poważniejsze narzędzia algebry siedzą w podmodule

np.linalg
, czyli linear algebra. Funkcja
det
liczy wyznacznik macierzy - jedną liczbę, która mówi między innymi, czy macierz da się odwrócić. Funkcja
inv
zwraca macierz odwrotną, czyli taką, która pomnożona przez oryginał daje macierz jednostkową, tę z jedynkami na przekątnej. Wyznacznik równy zeru znaczy, że odwrotności nie ma, więc w praktyce sprawdza się go przed odwracaniem.

1A = np.array([[1, 2], [3, 4]])
2
3print(np.linalg.det(A))         # -2.0000000000000004
4print(round(np.linalg.det(A), 2))  # -2.0
5print(np.linalg.inv(A))
6# [[-2.   1. ]
7#  [ 1.5 -0.5]]

Pierwszy wydruk to dobra lekcja pokory wobec komputera: wyznacznik tej macierzy wynosi dokładnie minus dwa, a NumPy pokazuje minus dwa i jeszcze odrobinę. To nie błąd biblioteki, tylko skutek zapisu ułamków w postaci dwójkowej, w której wiele liczb nie ma dokładnego odpowiednika. Dlatego wyników zmiennoprzecinkowych nie porównuje się znakiem równości, tylko zaokrągla albo sprawdza z tolerancją - drugi wydruk pokazuje najprostszy sposób. Macierz odwrotna wyszła zgodnie z podręcznikiem, a

A
po obu wywołaniach jest nadal tą samą macierzą, którą wpisaliśmy. W tym samym module mieszka jeszcze
np.linalg.eig
, zwracająca parę wyników naraz: wartości własne i wektory własne macierzy. Spotkasz ją, gdy dojdziesz do redukcji wymiarów w uczeniu maszynowym.

Dlaczego to jest szybkie

Warto na koniec zrozumieć, skąd bierze się przewaga NumPy, bo to tłumaczy wszystkie jego dziwactwa. Lista Pythona przechowuje wskaźniki do obiektów rozrzuconych po pamięci, a każdy z tych obiektów niesie własną informację o typie. Tablica NumPy trzyma surowe liczby jeden przy drugim, w jednym ciągłym bloku pamięci, i typ opisuje raz dla całej tablicy - to właśnie ten

dtype
, na który patrzyliśmy wcześniej. Dzięki temu pętla po elementach wykonuje się w skompilowanym kodzie C, a procesor może przetwarzać po kilka liczb w jednym takcie. Stąd wymóg jednego typu, stąd sztywny kształt i stąd widoki zamiast kopii przy wycinkach: każda z tych zasad kupuje szybkość. Przy pięciu liczbach różnicy nie zauważysz, przy pięciu milionach jest to różnica między sekundą a kwadransem.

Podsumowanie

  • Bibliotekę wprowadzasz czterema słowami w stałej kolejności:
    import
    ,
    numpy
    ,
    as
    ,
    np
    .
  • Tablicę z listy tworzy wyłącznie
    np.array
    - funkcji
    np.list
    ,
    np.create
    ani
    np.new
    w NumPy nie ma.
  • np.zeros((3, 4))
    zwraca tablicę
    ndarray
    o trzech wierszach i czterech kolumnach, wypełnioną zerami. Krotka wymiarów to jeden argument, więc żadnego błędu tu nie ma.
  • Kształt czytasz z atrybutu
    shape
    , bez nawiasów, a wynik jest krotką w kolejności wiersze, kolumny: dla
    np.array([[1,2,3], [4,5,6]])
    jest to
    (2, 3)
    . Liczba
    6
    to
    size
    , a nie kształt.
  • Średnią liczysz sześcioma częściami:
    np
    ,
    .
    ,
    mean
    ,
    (
    ,
    arr
    ,
    )
    . Obok niej stoją
    median
    ,
    std
    ,
    min
    ,
    max
    i
    sum
    .
  • Działanie arytmetyczne na tablicy wykonuje się na wszystkich elementach naraz i zwraca nową tablicę, zostawiając oryginał nietknięty.
  • Maska logiczna, na przykład
    populations < 100
    , zwraca tablicę wartości
    True
    i
    False
    , a włożona w nawiasy kwadratowe filtruje dane.
  • Wycinek tablicy to widok na te same dane, a nie kopia - gdy zamierzasz do niego pisać, dopisz
    copy
    .
  • Gwiazdka mnoży element po elemencie, a
    @
    wykonuje mnożenie macierzowe. To dwa różne działania, nie dwa zapisy tego samego.

W następnej lekcji dołożymy do tych liczb nazwy kolumn i różne typy w jednej tabeli, bo notes terenowy to coś więcej niż kolumna pomiarów. Na razie zapamiętaj jedno, @name: tablica NumPy to karabińczyk tropiciela - jeden typ, jeden kształt, żadnych ozdobników, za to całe stado przeliczone jednym ruchem.

Przejdź do CodeWorlds