PythonJiří ZnamenáčekNumPy – tvorba a vlastnosti polí2016-08-03
Nepřekvapivě jsou (vícerozměrná) pole (tedy objekty typu ndarray) zcela základní a zdaleka nejdůležitější datovou strukturou v NumPy. Vlastně se kolem nich točí prakticky úplně všechno.
Co ale možná překvapí, tak pole v NumPy nejsou omezena pouze na číselné typy. A také jsou k dispozici některé velmi užitečné nadstavby nad základním polem, jako třebas masked arrays pro práci s daty obsahujícími nepovolené hodnoty nebo chararray pro vektorizované operace nad řetězci.
A protože jsou pole tak důležitá, existuje hafo způsobů jak je vytvořit nebo načíst a dvojté hafo jak s nimi pracovat. Aniž bych se pokoušel o – ostatně zcela zbytečnou – úplnost, pokusím se v této sérii přednášek podat nejnutnější (a občas i méně nutné) základy práce s poli v NumPy.
PS: V příkladech budu používat modul numpy naimportovaný podle zvyku jako:
import numpy as np
Byť výroba polí ručně nebude asi ani zdaleka nejčastější způsob použití NumPy, samozřejmě to jde. (A mnoha způsoby.) Příslušný konstruktor se chová tak, jak jsme z Python'u zvyklí, tzn. dokáže zpracovat nejrůznější smyslupné vstupy:
# vstup jako seznam
>>> np.array([1, 3, 5, 7, 9])
array([1, 3, 5, 7, 9])
# vstup jako n-tice
>>> np.array((1, 3, 5, 7, 9))
array([1, 3, 5, 7, 9])
# vstup jako výsledek jiné operace
>>> np.array( range(5) )
array([0, 1, 2, 3, 4])
Dvourozměrná pole se zadávají ještě celkem dobře a relativně přehledně:
Kdybyste to potřebovali, tak existující numpy-pole můžete převést na pythoní seznam seznamů pomocí jeho metody tolist():
>>> x = np.array([[1, 2, 3], [4, 5, 6]])
>>> x
array([[1, 2, 3],
[4, 5, 6]])
>>> x.tolist()
[[1, 2, 3], [4, 5, 6]]
Ono se sice numpy-pole při výpise tváří jako pythoní seznam a v mnoha ohledech se s ním i stejně zachází, nicméně to skutečně seznam není. Proto tato funkce.
Máte-li k dispozici pole, zajímá vás samozřejmě, jak toto pole vypadá. NumPy disponuje armádou pomocníků určených právě k tomuto účelu, z nichž ty pro běžného uživatele užitečné jsou především asi následující:
shape – tvar pole;
ndim – počet dimenzí pole;
size – celkový počet prvků pole.
V 1D:
>>> x = np.array([1, 2, 3])
>>> x
array([1, 2, 3])
>>> x.shape
(3,)
>>> x.ndim
1
>>> x.size
3
Velikost jednoho prvku pole v bajtech, tak jak je uložen v paměti počítače, sděluje atribut itemsize. Přitom ve výchozím nastavení se prvky generují typicky v přesnosti 64 bitů (float aka float64 a další), takže itemsize je 8:
Potřebujete-li projít pole po jednotlivých prvcích, jsou k dispozici především následující možnosti:
# a) plochý pohled s kopií do nového seznamu
>>> x.flat
<numpy.flatiter object at 0x014BD968>
>>> [i for i in x.flat]
[1, 2, 3, 4, 5, 6]
# b1) kopie prvků vždy
>>> y1 = x.flatten()
>>> y1
[1, 2, 3, 4, 5, 6]
# b2) kopie prvků pouze je-li nutno
>>> y2 = x.ravel()
>>> y2
[1, 2, 3, 4, 5, 6]
Podíváme-li se pomocí __array_interface__['data'][0] na umístění jednotlivých polí v paměti, zjistíme, že metoda ravel() v tomto případě kopii nevytvořila, zatímco metoda flatten() ano:
Podobnou informaci můžeme zjistit pomocí atributu base, který pro „základní“ pole vrací None a pro pole z něj odvozená, ale nad stejným místem v paměti, vrací právě toto základní pole:
>>> x.base
None
>>> y1.base
None
>>> y2.base
array([[1, 2, 3],
[4, 5, 6]])
>>> y2.base is x
True
Když už jsme u uložení dat, tak numpyovské pole můžete převést na jeho binární reprezentaci – v podstatě přímou bajtovou kopii dat. Ve výchozím nastavení jako v Céčku po řádcích, ale jako vždy můžete přepnout i na Fortran:
>>> x = np.array([[1, 2, 3], [4, 5, 6]])
>>> x
array([[1, 2, 3],
[4, 5, 6]])
>>> x.tobytes()
b'\x01\x00\x00\x00\x00\x00\x00\x00\x02\x00\x00\x00\x00\x00\x00\x00\x03\x00\x00\x00\x00\x00\x00\x00\x04\x00\x00\x00\x00\x00\x00\x00\x05\x00\x00\x00\x00\x00\x00\x00\x06\x00\x00\x00\x00\x00\x00\x00'
>>> x.tobytes(order='C') == x.tobytes()
True
>>> x.tobytes('F') == x.tobytes()
False
A nakolik se výsledku týká, tak stejnou funkci – s uspořádáním podle C – zastane pythoní funkce bytes(x).
Je to užitečné zvláště při potřebě uložit velké množství zpracovávaných dat pro pozdější použití v jiném prostředí, protože kupodivu neexistuje spolehlivá metoda, která by je zase načetla zpátky. Chcete-li téhož dosáhnout přenositelně přímo v Numpy, použijte metod save() a load() pro nativní formát .npy.
Většina zbývajících atributů dostupných na objektu ndarray se týká vnitřní implementace polí, nicméně i tak je mnoho z nich velmi užitečných:
>>> x = np.array([[1, 2, 3], [4, 5, 6]])
>>> x
array([[1, 2, 3],
[4, 5, 6]])
# pohled na transponované pole
>>> x.T
array([[1, 4],
[2, 5],
[3, 6]])
# typ prvků pole
>>> x.dtype
dtype('int64')
# převod pole na binární reprezentaci
>>> x.tobytes()
(b'\x01\x00\x00\x00\x00\x00\x00\x00\x02\x00\x00\x00\x00\x00\x00\x00'
b'\x03\x00\x00\x00\x00\x00\x00\x00\x04\x00\x00\x00\x00\x00\x00\x00'
b'\x05\x00\x00\x00\x00\x00\x00\x00\x06\x00\x00\x00\x00\x00\x00\x00')
# informace o vnitřní struktuře pole
>>> x.flags
C_CONTIGUOUS : True
F_CONTIGUOUS : False
OWNDATA : True
WRITEABLE : True
ALIGNED : True
UPDATEIFCOPY : False
Častěji budete načítat data do polí z externího souboru (binárního, textového…), na což NumPy obsahuje několik pomocných funkcí.
Podporováno je načítání z textových i binárních souborů, odpovídajících bafrů, ale s rozšířeními v balíku SciPy třebas i z obrázků a dalších.
Ukažme si jednu funkci z této třídy metod – načítání textových dat à la CSV pomocí funkce loadtxt():
Tato metoda je poměrně mocná – můžete předepsat oddělovač polí, konverzní funkce pro jednotlivé záznamy, které sloupce načítat, zda přeskočit první (typicky hlavičkový) řádek nebo jakým způsobem reprezentovat záznamy na jednom řádku (pomocí parametru dtypes).
Někdy vám to bude stačit, jindy budete muset použít některou z ještě obecnějších metod (např. getfromtxt(), která si poradí i s chybějícími daty) a ještě jindy prostě budete muset naparsovat data v čistém Python'u pěkně bokem dopředu.
Často se hodí mít možnost vyrobit pole určitého tvaru a s určitými hodnotami bez jejich explicitního zadávání prvek po prvku a/nebo načítání z externích dat. Podívejme se nyní na několik speciálních funkcí, které právě toto umí.
PS: Již jsem zmiňoval, že NumPy je obrovský balík, proto ukážu jen pár vybraných funkcí. Pro ostatní konzultujte oficiální dokumentaci.
Začněmež přípravou pole s nulovými prvky, na které si ukážeme i další společné možnosti (abych je dále už nemusel kopírovat):
PS: Většina těchto vyráběcích metod má nepovinný parametr order s výchozí hodnotou C, který říká, že pole budou uložena v paměti tak, jak je zvykem v Céčku, tedy po řádcích. Druhou možností je F, nepřekvapivě to znamená Fortran a uložení je pak po sloupcích.
>>> np.full([2, 3], 7)
array([[ 7, 7, 7],
[ 7, 7, 7]])
>>> np.full([2, 3], np.inf)
array([[ inf, inf, inf],
[ inf, inf, inf]])
Typ hodnot ve výsledku závisí na verzi NumPy – starší vrací reálná čísla, novější už celá (konkrétně int64).
Výsledkem této operace je vektor, tedy jednorozměrné pole.
Následující dvě metody také vrací vektory:
Lineárně rozložené hodnoty v daném rozsahu a o daném počtu:
>>> np.linspace(2, 4, num=5)
array([ 2. , 2.5, 3. , 3.5, 4. ])
Výchozí počet hodnot je 50.
Logaritmicky rozložené hodnoty v daném rozsahu, o daném počtu a při daném základu:
>>> np.logspace(1, 2, num=5, base=2)
array([ 2. , 2.37841423, 2.82842712, 3.36358566, 4. ])
Rozsah tedy od 21 po 22.
Výchozí základ je 10, výchozí počet hodnot také 50.
Funkce reshape() umožňuje změnit tvar pole při zachování hodnot. Přitom je-li to možné, místo kopírování budou pouze vytvořeny nové pohledy na původní pole. Příklad bude názornější:
# původní vektor
>>> a = np.arange(8)
>>> a
array([0, 1, 2, 3, 4, 5, 6, 7])
# jiný pohled na něj
>>> b = a.reshape(2, 4)
>>> b
array([[0, 1, 2, 3],
[4, 5, 6, 7]])
# a ještě jiný pohled
>>> c = b.reshape(2, 2, 2)
>>> c
array([[[0, 1],
[2, 3]],
[[4, 5],
[6, 7]]])
# ale pořád je to stejné základní pole
>>> c[0, 0, 0] = 9
>>> c
array([[[9, 1],
[2, 3]],
[[4, 5],
[6, 7]]])
>>> b
array([[9, 1, 2, 3],
[4, 5, 6, 7]])
>>> a
array([9, 1, 2, 3, 4, 5, 6, 7])
Měnit tvar se samozřejmě dá libovolným směrem, pokud to jde rozumně provést. Plus je možno jednu z dimenzí nechat dopočítat automaticky z nového rozměru a počtu prvků – stačí ji označit kódem -1.
Bez ohledu na počet rozměrů používaného pole je interní reprezentace v paměti pochopitelně jednodimenzionální, přičemž Numpy provádí převod indexů do pole na skutečné umístění v paměti (ve formě odsazení od začátku pole) pomocí tzv. kroků (strides), které udávají počet bajtů v tom kterém rozměru pole.
Například pro dotaz do 2D-pole m[i, j] platí pro toto odsazení (aneb offset):
m.strides[0] * i + m.strides[1] * j
Při operacích s poli pak Numpy dává – je-li to pro daný případ možné – přednost změně těchto krokových parametrů před čímkoliv jiným:
Při všem tom zmatku ale máme k dispozici generování náhodných čísel podle vybraného rozložení a volbu násady (numpy.random.seed()), takže se s tím dá pracovat.
Obojakou funkci zastává metoda diag(), které podle použití může buď vytvářet matici se zadanou diagonálou nebo naopak z existující matice vybranou diagonálu extrahovat.
Je-li vstupem 1D-vektor, použije ho jako diagonálu pro vytvářenou matici:
Na první pohled možná trochu překvapivě, ale už na ten druhý zcela jasně, umožňuje Numpy vytvářet nová pole také skládáním polí již existujících do jednoho výsledného..
Velmi zajímavá metoda block() dokáže vybudovat výstupní pole z jednotlivých podmatic. K jejich skládání přitom dochází postupně „zevnitř“ po posledních osách směrem „ven“ (a neplatí klasická broadcastovací pravidla).
Funkce split() dokáže rozdělit zadané pole podle vybrané osy (výchozí je jako vždy ta první) na podpole velikosti určené indexy do něj:
# A) jednoduché dělení
>>> x = np.arange(9)
>>> x
array([0, 1, 2, 3, 4, 5, 6, 7, 8])
>>> np.split(x, 3)
[array([0, 1, 2]), array([3, 4, 5]), array([6, 7, 8])]
# složitější dělení (neexistující index vrátí [])
>>> np.split(x, [3, 5, 10])
[array([0, 1, 2]), array([3, 4]), array([5, 6, 7, 8]), array([], dtype=int64)]
# B) složitější tvar pole a dělení
>>> x = np.arange(9).reshape(3, 3)
>>> x
array([[0, 1, 2],
[3, 4, 5],
[6, 7, 8]])
# výchozí osa
>>> np.split(x, [2])
[array([[0, 1, 2],
[3, 4, 5]]),
array([[6, 7, 8]])]
# jiná osa
>>> np.split(x, [2], axis=1)
[array([[0, 1],
[3, 4],
[6, 7]]),
array([[2],
[5],
[8]])]
PS: Podobná metoda array_split() provádí rozklad pomocí dělení %, takže tvar výstupních polí nemusí úplně odpovídat rozkladu původního podle zadaných indexů.
Numpy umožňuje vybírat prvky z polí (a vracet tak nová pole) mnoha různými způsoby, přičemž s tím nejpřirozenějším – a bohužel často i nejpomalejším – jsme se už setkali v podobě tzv. fancy indexing. Mezi další důležité výběrové metody patří především:
where() – výběr prvků podmínkou (i z více polí naráz);
choose() – výběr prvků z více polí na základě indexů.
Kriticky i nekriticky je ovšem třeba přiznat, že místo where() a nonzero() je asi tak tisíckrát čitelnější volba použít fancy indexing, je-li paměťově a časově průchozí.
Metoda where() sice dělá to, co by od ní člověk podle názvu očekával – výběr prvků pole na základě podmínky, nicméně není na první pohled úplně průhledná. Čemuž nepomáhá, že se dá zavolat dvěma dost rozdílnými způsoby.
Při volání pouze s podmínkou vrací indexy prvků pole, které uvedenou podmínku splňují. Nicméně je vrací postupně indexy všech příslušných os, což je ještě zcela jasné v 1D..
Při volání se dvěma polemi vrací prvky z příslušné pozice buď z prvního pole, je-li podmínka splněna, nebo naopak z druhého, když splněna není:
>>> ca = [[True, False], [True, True]]
>>> xa = [[1, 2], [3, 4]]
>>> ya = [[9, 8], [7, 6]]
>>> np.where(ca, xa, ya)
array([[1, 8],
[3, 4]])
Nebo-li v 1D je ekvivalentní pravděpodobně podstatně průhlednějšímu volání [x if c else y for (c,x,y) in zip(condition,xa,ya)] .
Komu by to připadalo nezajímavé, může při aplikaci uplatnit i automatický broadcasting a vystačí si jenom s jedním polem:
Ovšem vzhledem k tomu, že False je vlastně také nula, tak se to dá aplikovat v podstatě jako úplně obecná podmínka:
>>> xa = np.arange(9).reshape(3, 3)
>>> xa
array([[0, 1, 2],
[3, 4, 5],
[6, 7, 8]])
# podmínka
>>> xa > 3
array([[False, False, False],
[False, True, True],
[ True, True, True]])
# dvě možnosti volání – funkce Numpy vs metoda pole
>>> np.nonzero(xa > 3)
(array([1, 1, 2, 2, 2]), array([1, 2, 0, 1, 2]))
>>> (xa > 3).nonzero()
(array([1, 1, 2, 2, 2]), array([1, 2, 0, 1, 2]))
# indexy přehledněji
>>> np.transpose( (xa > 3).nonzero() )
array([[1, 1],
[1, 2],
[2, 0],
[2, 1],
[2, 2]])
Metoda choose(INDEXY, POLE-K-VÝBĚRU, CHOVÁNÍ) vybírá z dostupných polí postupně prvky podle indexového pole, přičemž je schopna indexy přepočítávat podle zvoleného způsobu chování (výchozí raise pro neexistující index vyhodí výjimku, wrap ho přepočítá jako mode n a clip přečnívající ořeže do dostupného intervalu):
# pole k výběru prvků
>>> choices = [
[0, 1, 2, 3], # index 0
[10, 11, 12, 13], # 1
[20, 21, 22, 23], # 2
[30, 31, 32, 33] # 3
]
# Indexové pole je čtyřprvkové ⇒ výstupní pole bude mít také 4 prvky:
# ~ první prvek výstupního pole je prvním prvkem ve (2+1). poli k výběru
# ~ druhý prvek výstupního pole je druhým prvkem ve (3+1). poli k výběru
# ~ třetí prvek výstupního pole je třetím prvkem ve (1+1). poli k výběru
# ~ čtvrtý prvek výstupního pole je čtvrtým prvkem v (0+1). poli k výběru
>>> np.choose([2, 3, 1, 0], choices)
array([20, 31, 12, 3])
# wrap: 4 > 3 ⇒ 4 mod 3 = 1
>>> np.choose([2, 4, 1, 0], choices, mode='wrap')
array([20, 1, 12, 3])
# clip: 4 > 3 ⇒ 3
>>> np.choose([2, 4, 1, 0], choices, mode='clip')
array([20, 31, 12, 3])
# clip: -2 < 3 ⇒ 0
>>> np.choose([2, -3, 1, 0], choices, mode='clip')
array([20, 1, 12, 3])
Upraveno podle oficiální dokumentace.
Možná trochu překvapivě se Numpy neomezuje na striktně číselná pole. Prvky polí tak mohou býti i úplně jiné typy.
Zatímco typu Boolean se asi nikdo moc divit nebude..
..u řetězců by se nějaké to pozdvihnuté obočí už objevit mohlo:
>>> np.array(["Graham", "John", "Terry", "Eric", "Terry", "Michael"]).reshape(2, 3)
array([['Graham', 'John', 'Terry'],
['Eric', 'Terry', 'Michael']],
dtype='<U7')
dtype nám zde hlásí, že prvky pole jsou unicodové řetězce (zaznamenané v pořadí little-endian) o maximální délce sedmi znaků.
Atributu dtype je však možno předepsat i mnohem složitější strukturu, která určuje tzv. komplexní (strukturovaný) typ prvků daného pole:
Když nahlédnete do oficiální dokumentace, tak zjistíte, že způsobů vytvoření vlastních typů je vpravdě neuvěřitelná hromada O_o Tak jen drobná ochutnávka:
>>> dt = np.dtype("a3, 3u8, (3,4)i1")
>>> dt
dtype([('f0', 'S3'), ('f1', '<u8', (3,)), ('f2', 'i1', (3, 4))])
~ typ složený ze tří složek (postupně automaticky pojmenovaných f0, f1 a f2) – tříznakový řetězec, tříprvkový vektor typů uint64 a matice 3×4 celých čísel uint8
>>> dt = np.dtype([('R','u1'), ('G','u1'), ('B','u1'), ('A','u1')])
>>> dt
dtype([('R', 'u1'), ('G', 'u1'), ('B', 'u1'), ('A', 'u1')])
~ reprezentace RGB-barev s hloubkou 8 bitů (jména R, G, B) plus taktéž osmibitový alfa-kanál (jméno A)
…
Dokonce můžete pro jednotlivé prvky nastavit i odsazení v bajtech od začátku struktury. Jako (ne zrovna asi moc použitelný) příklad variace na předchozí příklad, jen zadaná úplně jinak a hodnoty pro zelenou barvu sdílejí paměťový prostor s barvou červenou (zabírají tudíž bajty dva, přičemž vyšší hodnoty jsou vlastně určené červenou):
# zavedení
>>> dt = np.dtype({
'names': ['R', 'G' , 'B'],
'formats': [np.uint8, np.uint16, np.uint8],
'offsets': [0, 0, 2],
'titles': ['red pixel', 'green pixel', 'blue pixel']
})
>>> dt
dtype({'names':['R','G','B'], 'formats':['u1','<u2','u1'], 'offsets':[0,0,2], 'titles':['red pixel','green pixel','blue pixel'], 'itemsize':3})
# použití
>>> x = np.array(b'\x01\x02\x03', dtype=dt)
>>> x
array((1, 513, 3),
dtype={'names':['R','G','B'], 'formats':['u1','<u2','u1'], 'offsets':[0,0,2], 'titles':['red pixel','green pixel','blue pixel'], 'itemsize':3})
>>> x.tobytes()
b'\x01\x02\x03'
>>> x['R']
array(1, dtype=uint8)
>>> x['G']
array(513, dtype=uint8)
>>> x['B']
array(3, dtype=uint8)
A to proto, že dvoubajtová hodnota b'\x01\x02' bude zapsána nikoli jako 00000001|00000010, ale opačně 10000000|01000000, a následně pak vyhodnocena (opět v původním směru) jako int('0000001000000001', base=2), což je právě 513.
Při použití vlastního dtypepro dvourozměrná pole tak vlastně zavádíte pojmenování pro sloupečky.
Bohužel pro řádky přímo v Numpy nic podobného neexistuje :-(