Tablice ndarray¶
Podstawowym typem NumPy jest tablica ndarray (ang. n-dimensional array): ciąg liczb jednego typu, ułożony w jednym ciągłym bloku pamięci, o dowolnej liczbie wymiarów. Lista z rozdziału 5 przechowuje referencje do dowolnych obiektów; tablica przechowuje same liczby, a wszystkie operacje na niej wykonuje kod w C — stąd oszczędność pamięci oraz szybkość, którą zmierzyliśmy w rozdziale 13. Wydajność i optymalizacja. W tym podrozdziale poznajemy tworzenie tablic, ich atrybuty, indeksowanie z maskami, różnicę między widokiem a kopią oraz zapis i odczyt z plików.
Od listy do tablicy¶
Funkcja np.array() buduje tablicę z listy — także zagnieżdżonej, co daje tablicę dwuwymiarową. Różnicę w zachowaniu widać przy pierwszym operatorze arytmetycznym:
import numpy as np
lista = [1, 2, 3, 4]
tablica = np.array(lista)
print(type(tablica), tablica)
print(lista * 2)
print(tablica * 2)
print(tablica + tablica)
print([x * 2 for x in lista])
print(np.array([[1, 2, 3], [4, 5, 6]]))
<class 'numpy.ndarray'> [1 2 3 4]
[1, 2, 3, 4, 1, 2, 3, 4]
[2 4 6 8]
[2 4 6 8]
[2, 4, 6, 8]
[[1 2 3]
[4 5 6]]
Mnożenie listy przez liczbę powtarza elementy (rozdział 5); mnożenie tablicy mnoży każdy element. Tę własność — operator działa na całej tablicy, element po elemencie, bez pętli w kodzie Pythona — nazywamy wektoryzacją (ang. vectorization), a złożenie listowe z przedostatniego wiersza listingu jest jej ręcznym odpowiednikiem. Wydruk tablicy różni się od wydruku listy brakiem przecinków; repr() pokazuje pełny zapis array([1, 2, 3, 4]).
Tablica ma jeden typ elementów. Jeśli lista miesza typy, NumPy dobiera wspólny — liczby całkowite z ułamkami dają float64, a liczby z napisami dają tablicę napisów, nie liczb (<U21 to napisy Unicode o długości do 21 znaków):
>>> import numpy as np
>>> np.array([1, 2.5]).dtype
dtype('float64')
>>> np.array([1, "a"])
array(['1', 'a'], dtype='<U21')
Tworzenie tablic¶
Poza np.array() tablice tworzą funkcje wypełniające i funkcje generujące zakresy:
import numpy as np
print(np.zeros(3))
print(np.ones((2, 3)))
print(np.full((2, 2), 7))
print(np.eye(3))
print(np.arange(0, 1, 0.25))
print(np.linspace(0, 1, 5))
print(np.arange(0, 1, 0.1).size, np.linspace(0, 1, 11).size)
[0. 0. 0.]
[[1. 1. 1.]
[1. 1. 1.]]
[[7 7]
[7 7]]
[[1. 0. 0.]
[0. 1. 0.]
[0. 0. 1.]]
[0. 0.25 0.5 0.75]
[0. 0.25 0.5 0.75 1. ]
10 11
Kształt podajemy krotką: (2, 3) to dwa wiersze i trzy kolumny. np.zeros() i np.ones() tworzą tablice liczb zmiennoprzecinkowych (kropka po zerze w wydruku), np.full() przyjmuje typ od podanej wartości, a np.eye() daje macierz jednostkową. Funkcja np.arange() działa jak range() z rozdziału 4, ale przyjmuje krok ułamkowy i nie obejmuje końca przedziału; np.linspace() dzieli przedział domknięty na zadaną liczbę punktów — stąd ostatni wiersz wydruku: dziesięć elementów i jedenaście punktów. Dla kroków ułamkowych dokumentacja NumPy zaleca linspace(): błędy zaokrągleń w arange() mogą zmienić liczbę elementów o jeden, a przy linspace() liczba punktów jest zadana wprost.
Atrybuty i typy elementów¶
Każda tablica zna swój kształt i typ elementów:
import numpy as np
m = np.array([[1, 2, 3], [4, 5, 6]])
print(m.ndim, m.shape, m.size)
print(m.dtype, m.itemsize, m.nbytes)
print(np.array([1.5, 2]).dtype, np.array([True, False]).dtype)
male = np.array([100, 120], dtype=np.int8)
print(male * 2)
print(male.astype(np.int64) * 2)
print(np.array([1.7, 2.2]).astype(int))
Atrybut ndim to liczba wymiarów, shape — krotka z rozmiarem każdego wymiaru, size — liczba wszystkich elementów; dtype opisuje typ elementów, itemsize — jego rozmiar w bajtach, a nbytes — rozmiar całej tablicy. Sześć liczb całkowitych zajmuje 48 bajtów; lista sześciu liczb potrzebowałaby osobnego obiektu int na każdą z nich — po 28 bajtów, jak zmierzyliśmy w rozdziale 3. Nazwy i typy.
Domyślny typ całkowity to int64 (na Windows od NumPy 2.0 — wcześniej int32), zmiennoprzecinkowy — float64, czyli ten sam format, którego używa typ float z rozdziału 3. Nazwy i typy. Typ można zadać argumentem dtype= albo zmienić metodą astype(), która domyślnie tworzy nową tablicę; konwersja z ułamka na liczbę całkowitą obcina część ułamkową. Typy o stałej szerokości mają ograniczony zakres: int8 mieści liczby od −128 do 127, więc podwojenie wartości 100 i 120 daje −56 i −16 — NumPy nie zgłasza błędu przy operacjach na tablicach, tylko „zawija” wynik, jak procesor — to przepełnienie (ang. overflow); dla pojedynczych liczb, jak np.int8(100) * 2, wypisuje ostrzeżenie RuntimeWarning. To pierwsza różnica wobec Pythona, w którym int rośnie bez ograniczeń.
| Typ | Zakres lub precyzja | Typowe użycie |
|---|---|---|
np.int8, np.int16, np.int32, np.int64 |
liczby całkowite ze znakiem, od 1 do 8 bajtów | liczniki, indeksy, dane całkowite |
np.uint8 |
0–255 | obrazy (jeden bajt na kanał piksela) |
np.float32, np.float64 |
ok. 7 i ok. 15–16 cyfr znaczących | pomiary, obliczenia; float32 oszczędza połowę pamięci |
np.complex128 |
dwie liczby float64 |
liczby zespolone |
np.bool (starsza nazwa np.bool_ nadal działa) |
True/False |
maski logiczne |
Różnicę precyzji między float32 a float64 pokazuje eksperyment rozwijający porównania z rozdziału 7. Moduły, pakiety i biblioteka standardowa — sumowanie liczb 0.1, 0.2 i 0.3 wczytanych jako napisy, czterema typami:
from decimal import Decimal
import numpy as np
napisy = "0.1 0.2 0.3".split()
suma_float = sum(float(s) for s in napisy)
suma_decimal = sum(Decimal(s) for s in napisy)
suma_f32 = sum(np.float32(s) for s in napisy)
suma_f64 = sum(np.float64(s) for s in napisy)
print(f"float: {suma_float!r}")
print(f"Decimal: {suma_decimal!r}")
print(f"float32: {suma_f32!r}")
print(f"float64: {suma_f64!r}")
print(f"wprost: {0.1 + 0.2 + 0.3!r}")
print(suma_f32 == np.float32(0.6), suma_f64 == 0.6, np.isclose(suma_f64, 0.6))
float: 0.6
Decimal: Decimal('0.6')
float32: np.float32(0.6)
float64: np.float64(0.6000000000000001)
wprost: 0.6000000000000001
True False True
Wyniki float i float64 różnią się, choć to ten sam format binarny, a np.float64 jest podklasą float. Różnica bierze się z funkcji sum(): od Pythona 3.12 sumuje ona zwykłe liczby float z kompensacją błędów zaokrągleń (algorytm Neumaiera), lecz obiekty np.float64 — jako podklasę — dodaje zwyczajnie, tak samo jak wyrażenie 0.1 + 0.2 + 0.3 wpisane wprost. Decimal z rozdziału 7 liczy dokładnie. Ciekawy jest przypadek float32: suma wychodzi „dokładnie” 0.6, ale nie dlatego, że typ jest lepszy — jest mniej precyzyjny, więc błąd reprezentacji ginie w zaokrągleniu do siedmiu cyfr. Ostatni wiersz wydruku pokazuje, że suma_f64 == 0.6 jest fałszem; porównania liczb zmiennoprzecinkowych nadal wykonujemy przez bliskość: math.isclose() z rozdziału 7 dla pojedynczych liczb, np.isclose() i np.allclose() dla tablic.
Indeksowanie i wycinki¶
Indeksy i wycinki jednowymiarowe działają jak w listach; tablice dwuwymiarowe indeksujemy krotką [wiersz, kolumna], w której każdy element może być wycinkiem. Dochodzą dwa mechanizmy nieznane listom — indeksowanie listą indeksów i maską logiczną:
import numpy as np
a = np.array([10, 20, 30, 40, 50])
print(a[0], a[-1], a[1:4], a[::2], a[::-1])
print(a[[0, 2, 4]])
print(a[a > 25])
print((a > 15) & (a < 45), a[(a > 15) & (a < 45)])
print(a[~(a > 25)])
m = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]])
print(m[0, 1], m[1], m[:, 0])
print(m[0:2, 1:])
print(m[m % 2 == 0])
10 50 [20 30 40] [10 30 50] [50 40 30 20 10]
[10 30 50]
[30 40 50]
[False True True True False] [20 30 40]
[10 20]
2 [4 5 6] [1 4 7]
[[2 3]
[5 6]]
[2 4 6 8]
Zapis m[0, 1] to wywołanie __getitem__ z krotką (0, 1) — mechanizm z rozdziału 11. Model danych; m[1] daje cały wiersz, m[:, 0] — całą kolumnę, a m[0:2, 1:] — podmacierz. Indeksowanie listą (ang. fancy indexing) wybiera elementy o podanych indeksach w podanej kolejności. Maska logiczna (ang. boolean mask) to tablica wartości True/False, najczęściej tego samego kształtu co tablica indeksowana; porównanie a > 25 tworzy ją elementowo, a a[maska] zwraca elementy, dla których maska jest prawdziwa. Maska o kształcie całej tablicy daje zawsze tablicę jednowymiarową, także dla macierzy m. Maska jednowymiarowa o długości równej liczbie wierszy wybiera natomiast całe wiersze: m[m[:, 0] > 2] zwraca wiersze, których pierwszy element jest większy od 2, czyli tablicę dwuwymiarową o kształcie (2, 3). Maski łączymy operatorami &, | i ~ — bitowymi z rozdziału 3, które NumPy przeciąża do działania elementowego. Porównania ujmujemy w nawiasy, bo operatory bitowe wiążą silniej niż porównania. Słowa and, or i not nie działają:
>>> a[(a > 15) and (a < 45)]
Traceback (most recent call last):
...
ValueError: The truth value of an array with more than one element is ambiguous. Use a.any() or a.all()
Operator and wywołuje bool() na tablicy (rozdział 11), a wartość logiczna tablicy wieloelementowej nie jest określona — stąd błąd i podpowiedź o any() i all(). Maski przydają się też do przypisań: a[a < 0] = 0 zeruje wszystkie elementy ujemne bez pętli.
Widok a kopia¶
Wycinek listy jest kopią (rozdział 5. Typy złożone); wycinek tablicy jest widokiem (ang. view) — nową tablicą odwołującą się do tego samego bloku pamięci. Zmiana widoku zmienia oryginał:
import numpy as np
a = np.array([1, 2, 3, 4, 5])
b = a[1:4]
b[0] = 99
print(a, b.base is a)
c = a[1:4].copy()
c[0] = -1
print(a, c.base is None)
d = a[[0, 2, 4]]
d[0] = 0
print(a, d.base is None)
lista = [1, 2, 3, 4, 5]
wycinek = lista[1:4]
wycinek[0] = 99
print(lista)
Atrybut base wskazuje tablicę, której pamięć widok współdzieli. Dla kopii utworzonych przez copy() i przez indeksowanie listą tablicy jednowymiarowej, jak c i d w programie, jest równy None, nie jest to jednak reguła ogólna: wynik bywa widokiem pomocniczej tablicy, którą NumPy tworzy podczas kopiowania, i wtedy base wskazuje tę tablicę, choć z oryginałem nie współdzieli pamięci. Tak jest dla macierzy m = np.arange(6).reshape(2, 3) w przypadku kopii m[:, [0, 2]] i m.T.reshape(-1). Wspólną pamięć dwóch tablic sprawdza funkcja np.shares_memory(): w programie np.shares_memory(a, b) daje True, a np.shares_memory(a, c) i np.shares_memory(a, d) — False. Widoki są zamierzoną cechą biblioteki — pozwalają pracować na fragmencie dużej tablicy bez kopiowania — ale bywają źródłem trudnych błędów, gdy funkcja modyfikuje otrzymany wycinek. Gdy potrzebna jest niezależna tablica, wywołujemy copy(). Funkcje kształtu z tabeli — reshape(), T, ravel(), flatten() — omawiamy w następnym podrozdziale.
| Widok (wspólna pamięć) | Kopia (osobna pamięć) |
|---|---|
wycinek a[1:4], a[:, 0] |
indeksowanie listą a[[0, 2]] i maską a[a > 0] |
reshape() (dla tablic ciągłych w pamięci), T |
copy(), flatten(), astype() |
ravel() — dla tablic ciągłych w pamięci |
operacje arytmetyczne a * 2, a + b |
Zapis i odczyt¶
Tablice zapisujemy w binarnym formacie NumPy (.npy, jedna tablica; .npz, kilka tablic pod nazwami) albo tekstowo. Do plików CSV z samymi liczbami wystarczy np.loadtxt(); plik w formacie CSV z rozdziału 9. Wejście, wyjście i pliki wczytujemy z pominięciem nagłówka:
from pathlib import Path
import numpy as np
dane = np.loadtxt("pomiary.csv", delimiter=",", skiprows=1)
print(dane.shape, dane.dtype)
print(f"średnia temperatura: {dane[:, 1].mean():.2f}")
np.save("dane.npy", dane)
print(np.load("dane.npy")[-1])
np.savez("zestaw.npz", czas=dane[:, 0], temperatura=dane[:, 1])
with np.load("zestaw.npz") as zestaw:
print(zestaw["temperatura"].max())
np.savetxt("odchylenia.txt", dane[:, 1] - dane[:, 1].mean(), fmt="%.2f")
print(Path("odchylenia.txt").read_text(encoding="utf-8"))
np.loadtxt() domyślnie oczekuje pliku w pełni liczbowego; kolumny tekstowe, brakujące wartości i nazwy kolumn to zadanie dla modułu csv z rozdziału 9 albo — wygodniej — dla biblioteki pandas, która buduje na tablicach NumPy tabele z nazwanymi kolumnami; omawiamy ją w części „Python Zastosowania” w rozdziale o tabelach pandas. Format .npy zachowuje typ i kształt tablicy bez strat i wczytuje się wielokrotnie szybciej niż tekst, ale czytają go tylko programy z NumPy; savetxt() z argumentem fmt= daje plik czytelny dla arkusza kalkulacyjnego.