Podstawy pakietu Numpy - część 2

Wstęp

Ciąg dalszy

W poprzednim wpisie na temat podstaw NumPy omówiliśmy podstawy podstaw wykorzystania tego jakże przydatnego pakietu. Pora teraz, aby rozszerzyć nieco przedstawione tam zagadnienia oraz omówić pokrótce co właściwie sprawia, że ndarrays mają tak dużą przewagę nad python list.

Pierwszą część wpisu znaleść można tutaj.

NumPy ndarray vs. wbudowane w Python listy

Jak już było wspomniane w poprzednim wpisie, ndarrays mają nad listami bardzo wiele przewag. Zacznijmy jednak od wydajności. Operacje na ndarrays są znacznie szybsze i mniej zasobożerne. Jest to spowodowane sposobem zapisu danych w pamięci. W przypadku list poszczególne elementy nie koniecznie muszą być zapisane w pamięci jeden po drugim. Tak naprawdę lista posiada zapisane referencje (wskaźniki do miejsc w pamięci, gdzie znajdują się realne dane). Przy każdym pobieraniu danych z listy Python robi dwie rzeczy: pobiera wskaźnik, a następnie na jego podstawie pobiera z pamięci wartość.

W listach nie jest wymagane, aby elementy były, chociażby tego samego typu, skoro referencja może wskazywać na dowolne miejsce w pamięci, o dowolnym rozmiarze.

Sytuacja ma się inaczej w przypadku ndarrays. Tutaj w momencie tworzenia każdy element jest zapisywany w pamięci jeden po drugim. Wymóg tego samego typu danych w cały array'u zapewnia jednakową ilość pamięci potrzebną na zapisanie każdego elementu, przez co wszystkie bloki są jednakowe. Znając typ danych a ndarray system od razu wie, gdzie kończy się jeden, a zaczyna kolejny element array. Dzięki tej kontynuacji elementów iterowanie po tablicy jest znacznie wydajniejsze, gdyż nie trzeba szukać w pamięci po wskaźnikach.

Duży wpływ na wyższość ndarrays nad listami ma również możliwość przeprowadzania operacji element po elemencie. Mnożenie czy dodawanie dwóch ndarray powoduje wykonanie operacji pomiędzy każdym odpowiadającym elementem tablicy. Niesie to za sobą wymóg zgodności wymiarów tablicy. Występują od niego jednak wyjątki, o których poniżej.

Broadcasting

Broadcasting możemy opisać jako mechanizm wykonywania operacji arytmetycznych element po elemencie między dwoma tablicami, w przypadku, gdy tablice nie posiadają tych samych wymiarów. W bardzo dużym uproszczeniu można to opisać, jako rozciąganie mniejszej tablicy (kopiowanie kolumn czy wierszy), do momentu, aż wymiary obu tablic będą się zgadzać. Oczywiście NumPy nie robi żadnego kopiowania, a cały mechanizm dzieje się dzięki iteracją w języku C na zwektoryzowanych tablicach.

Aby rozciąganie było możliwe, poszczególne wymiary dwóch tablic muszą być kompatybilne. Kompatybilność zachowana jest tylko wtedy, gdy ten sam wymiar (dla dwuwymiarowej tablicy wiersze bądź kolumny) w dwóch tablicach jest albo równy, albo wynosi 1.

In [52]:
a = np.arange(4)
a
Out[52]:
array([0, 1, 2, 3])
In [53]:
a ** 2
Out[53]:
array([0, 1, 4, 9])

Gdy wykonujemy operację arytmetyczną pomiędzy tablicą a wartością skalarną, powyższe reguły są zachowane. Skalar, mimo iż nie jest tablicą ndarray, jest rozumiany jako tablica o wymiarach (1, 1), stąd też niezależnie od wymiarów drugiej tablicy, można go rozciągnąć do jej wymiarów i przeprowadzić operację element po elemencie.

Gdy jednak tablice nie są ze sobą kompatybilne, jak w poniższym przypadku, dostajemy ValueError: operands could not be broadcast together with shapes (4,) (3,). Oznacza to, że NumPy nie wył w stanie dostosować żadnego z wymiarów, aby pasował do drugiej tabeli.

In [54]:
b = np.zeros(3)
b
Out[54]:
array([0., 0., 0.])
In [55]:
a + b
  ---------------------------------------------------------------------------
  ValueError                                Traceback (most recent call last)
  <ipython-input-55-ca730b97bf8a> in <module>
  ----> 1 a+b

ValueError: operands could not be broadcast together with shapes (4,) (3,)

Aby broadcasting się powiódł, musimy odwrócić jedną z tablic, tak aby miała jedną kolumnę i kilka wierszy. Na poniższym przykładzie tablica a została odwrócona, teraz główny wymiar (liczba wierszy) a wynosi 4, natomiast w b wynosi 1, możemy więc 'rozciągnąć' tablicę a, aby miała 3 jednakowe kolumny. Podobnie rozciągamy' tablicę b aby miała 4 jednakowe wiersze. Teraz już można dodać do siebie tak zdefiniowane tablice.

In [56]:
a.resize(4,1)
a
Out[56]:
array([[0],
       [1],
       [2],
       [3]])
In [57]:
a + b
Out[57]:
array([[0., 0., 0.],
       [1., 1., 1.],
       [2., 2., 2.],
       [3., 3., 3.]])

To tylko bardzo prosty przykład tego, jak działa broadcasting w NumPy, po więcej możliwości użycia kieruję do dokumentacji pakietu.

Fancy indexing

Kolejnym bardziej zaawansowanym tematem przy pracy z pakietem NumPy jest tak zwane fancy indexing, czyli indeksowanie przy pomocy tablic indeksów, bądź tablic wartości logicznych True/False.

Przy pomocy tablic indeksów

Najprostszym sposobem użycia fancy indexing jest przekazanie w nawiasach kwadratowych, w miejscu wybieranego indeksu czy wycinka, całej listy indeksów. W ten sposób w nowopowstałej tablicy zostaną zawarte elementy starej, o indeksach odpowiadających wartością przekazanej tablicy.

In [58]:
a = np.arange(8)*2
a
Out[58]:
array([ 0,  2,  4,  6,  8, 10, 12, 14])
In [59]:
i1 = np.array([2, 5, 0])
In [60]:
a[i1]
Out[60]:
array([ 4, 10,  0])

Gdy przekazana jako wartość indeksu tablica będzie tablicą wielowymiarową, wynikowa tablica będzie dokładnie odzwierciedlać jej wymiary.

In [61]:
i2 = np.array([[0, 7], [1, 3], [4, 1]])
In [62]:
a[i2]
Out[62]:
array([[ 0, 14],
       [ 2,  6],
       [ 8,  2]])

Sprawa wygląda nieco trudniej, gdy chcemy w ten sposób indeksować tablicę dwuwymiarową. Musimy do tego celu stworzyć dwie tablice indeksujące, obie posiadające dokładnie wymiary oczekiwanej tablicy wynikowej. Pierwsza z nich odpowiada indeksom głównej osi (przy dwuwymiarowej tablicy - osi wierszy), a druga indeksom osi podrzędnej.

Przy indeksowanych tablicach o większej liczbie wymiarów, należy adekwatnie przekazać więcej tablic indeksujących. Odpowiednio jedna na każdy wymiar.

In [63]:
a = np.arange(9).reshape(3,3)
a
Out[63]:
array([[0, 1, 2],
       [3, 4, 5],
       [6, 7, 8]])
In [64]:
row = np.array([[0, 2], [1, 1]])
row
Out[64]:
array([[0, 2],
       [1, 1]])
In [65]:
col = np.array([[0, 1], [2, 0]])
col
Out[65]:
array([[0, 1],
       [2, 0]])
In [66]:
a[row, col]
Out[66]:
array([[0, 7],
       [5, 3]])

Poza tworzeniem niestandardowych wycinków z tablic metoda ta bardzo dobrze sprawdza się do nadpisywania wartości określonych elementów. Wystarczy, przekazując w nawiasach kwadratowych tablicę indeksów przypisać takiemu wyrażeniu jakąś określoną wartość, a otrzymają ją wszystkie wybrane elementy.

In [67]:
a = np.arange(4)
a
Out[67]:
array([0, 1, 2, 3])
In [68]:
a[[1, 2]] = 99
a
Out[68]:
array([ 0, 99, 99,  3])

Przy pomocy tablic wartości logicznych

Bardzo przydatną funkcjonalnością NumPy jest możliwość indeksowania tablic za pomocą tablic wartości logicznych. Aby to jednak było możliwe, potrzebujemy tablicy, o tych samych wymiarach co tablica, którą chcemy indeksować oraz wypełnionej tylko wartościami logicznymi. Na szczęście z pomocą przychodzą nam tutaj broadcasting i proste operacje na tablicach. Gdy wykonamy operację logiczną na całej tablicy (jak poniżej), operacja ta wykona się element po elemencie, a wynikiem będzie właśnie tablica wartości logicznych.

Przekazując tak powstałą tablicę jako indeks innej tablicy, spowodujemy wybranie z niej tylko elementów, dla których w tablicy indeksującej występowało True. Pamiętać trzeba, że nawet jeśli pierwotnie tablica miała więcej niż jeden wymiar, wynik zostanie zwrócony jako tablica jednowymiarowa.

In [69]:
a = np.arange(12).reshape(3, 4)
a
Out[69]:
array([[ 0,  1,  2,  3],
       [ 4,  5,  6,  7],
       [ 8,  9, 10, 11]])
In [70]:
b = a % 2 == 0
b
Out[70]:
array([[ True, False,  True, False],
       [ True, False,  True, False],
       [ True, False,  True, False]])
In [71]:
a[b]
Out[71]:
array([ 0,  2,  4,  6,  8, 10])

W bardzo prosty sposób można tak przypisać określoną, wspólną wartość pewnej grupie elementów, wybranych na podstawie operacji logicznej.

In [72]:
a[b] = 99
a
Out[72]:
array([[99,  1, 99,  3],
       [99,  5, 99,  7],
       [99,  9, 99, 11]])

Oczywiście powyższe operacje można wykonać, stosując znacznie prostszy zapis, gdzie bezpośrednio jako indeks tablicy wstawiamy operację logiczną wykonywaną na tej samej tablicy. Bardzo często przy pracy na NumPy, będziemy widzieć ten rodzaj operacji zapisany właśnie w ten sposób.

In [73]:
a[a < 10] = 0
a
Out[73]:
array([[99,  0, 99,  0],
       [99,  0, 99,  0],
       [99,  0, 99, 11]])

Zrozumieć co jest kopią a co referencją

Często w NumPy na początku pracy natrafiamy na problem z wycinkami. Gdy przypisujemy do zmiennej inną zmienną, która zawiera tablicę ndarray, NumPy nie tworzy kopi tej tablicy, a obie zmienne wskazują dokładnie na tę samą tablicę, czyli na ten sam obiekt w pamięci. Może to prowadzić do nieoczekiwanych zachowań podczas edycji tablic, zwłaszcza przypisywań wartości.

In [74]:
a = np.arange(0,9).reshape(3,3)
a
Out[74]:
array([[0, 1, 2],
       [3, 4, 5],
       [6, 7, 8]])
In [75]:
b = a
In [76]:
b[-1,-1] = 100
b
Out[76]:
array([[  0,   1,   2],
       [  3,   4,   5],
       [  6,   7, 100]])
In [77]:
a
Out[77]:
array([[  0,   1,   2],
       [  3,   4,   5],
       [  6,   7, 100]])

Dokładnie to widać, gdy sprawdzimy dwie zmienne operatorami is. Porównanie pokazuje, że są one, a dokładniej wskazują na to samo.

In [78]:
b is a
Out[78]:
True

Podobne zachowanie występuje również, gdybyśmy do zmiennej b przypisali tylko wycinek tablicy a. Również wtedy zmiany w b spowodowałyby bezpośrednie zmiany w tablicy a.

Aby zapobiec takiemu zachowaniu, dobrą praktyką jest tworzenie kopi tablicy za pomocą metody ndarray.copy(). Tworzy ona tak zwaną głęboką kopię, co oznacza, że tworzony jest nowy obiekt w pamięci, więc referencja obu zmiennych nie wskazuje już na to samo miejsce. Możemy dzięki temu spokojnie wprowadzać zmiany, nie martwiąc się, że zmienimy coś w pierwotnej tablicy.

In [79]:
c = a.copy()
In [80]:
c is a
Out[80]:
False
In [81]:
c[0,0] = 999
c
Out[81]:
array([[999,   1,   2],
       [  3,   4,   5],
       [  6,   7, 100]])
In [82]:
a
Out[82]:
array([[  0,   1,   2],
       [  3,   4,   5],
       [  6,   7, 100]])

Łączenie tablic

Ostatnia część tego wpisu nie jest może zbyt trudna, ale aby ograniczyć długość części pierwszej, znalazł się tutaj. Mowa o składaniu tablic. W NumPy występują dwie podstawowe metody pozwalające na połączenie ze sobą dwóch lub więcej tablic, a mianowicie .hstack() - od horizontal stacking oraz .vstack() od vertical stacking. Obie przyjmuję jako argumenty tuplę z tablicami do złożenia. Tablice zostaną ze sobą złączone w kolejności przekazanej w tupli.

In [83]:
a = np.ones((2,2))
a
Out[83]:
array([[1., 1.],
       [1., 1.]])
In [84]:
b = np.zeros((2, 2))
b
Out[84]:
array([[0., 0.],
       [0., 0.]])
In [85]:
np.vstack((a, b))
Out[85]:
array([[1., 1.],
       [1., 1.],
       [0., 0.],
       [0., 0.]])
In [86]:
np.hstack((a, b))
Out[86]:
array([[1., 1., 0., 0.],
       [1., 1., 0., 0.]])

Podsumowanie

Na tym koniec wpisów na temat podstaw pakietu NumPy. Pora przejść do bardziej zaawansowanych i częściej używanych bibliotek związanych z przetwarzaniem i wizualizacją danych jak Pandas czy Matplotlib. Jednak informacje zdobyte w tych dwóch wpisach zdecydowanie się nam przydadzą, gdyż niemal każde z w przyszłości omawianych narzędzi jest oparte lub korzysta z *NumPy.