Osnove numpy-ja

Osnovni element - osnovni podatkovni tip - v numpy-ju je tabela (array). Ta je nekoliko podobna Pythonovemu seznamu. (V slovenščini mu nekaterimi pravimo seznam, ker je njegovo uradno angleško ime list, drugi pa to slovenijo v tabela, ker bi bilo to ime iz določenih razlogov dejansko boljše. Tu pa nam pride prav, da je ime tabela še nezasedeno in ga lahko uporabimo za numpy-jeve array-e.)

Med numpyjevimi tabelami in Pythonovimi seznami je več pomembnih razlik. Za prvo srečanje z numpyjem se bomo posvetili dvema: razlikam v načinu indeksiranja in razlikam med tem, kako nanju delujejo različne operacije.

Pripravimo si dva seznama v Pythonu.

p = [3, 8, 9, 2]
r = [8, 0, 1, -3]

Z indeksiranjem lahko pridemo do posamičnih elementov. Indeksi morajo biti seveda cela števila (int).

p[2]
9

Sezname lahko tudi "seštevamo". Besedo seštevanje pravzaprav uporabljamo zgolj zato, ker uporabimo operator +. V resnici ne gre za seštevanje (v matematičnem pomenu), temveč za stikanje.

p + r
[3, 8, 9, 2, 8, 0, 1, -3]

Ker + v resnici ni seštevanje, odštevanje seznamov pa bi sploh nimelo (zakaj slovenščina nima te besede?!) nobenega smisla.

p - r
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[4], line 1
----> 1 p - r

TypeError: unsupported operand type(s) for -: 'list' and 'list'

Prav tako nima smisla k seznamu prišteti 1, saj ne moremo stakniti seznama in števila.

p + 1
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[5], line 1
----> 1 p + 1

TypeError: can only concatenate list (not "int") to list

Pač pa lahko seznam pomnožimo s celim številom; rezultat je tak, kot da bi seznam tolikokrat sešteli.

p * 3
[3, 8, 9, 2, 3, 8, 9, 2, 3, 8, 9, 2]

Uvažanje modula numpy

Če hočemo modul uporabiti, ga moramo najprej uvoziti. Modula numpy navadno ne uvažamo z import numpy, ker bomo njegove funkcije uporabljali tako pogosto, da bi bil ves program poln numpy. -- ime numpy je preprosto predolgo. Po drugi strani ne uvažamo posamičnih funkcij from numpy import hstack, sum, max, ker tipično potrebujemo preveč različnih funkcij, poleg tega pa imajo nekatere enaka imena kot vdelane Pythonove funkcije (na primer sum in max; funkcije, uvožene iz numpy bi jih izpodrinile in povzročile težave, saj pričakujejo drugačne podatke, delajo malo drugače in vračajo drugačne rezultate kot Pythonove.

numpy zato običajno uvozimo z

import numpy as np

To uvozi modul, vendar ga potem ne vidimo pod imenom numpy, temveč pod krajšim, prijaznejšim imenom np.

Zdaj pa naredimo dve tabeli. Najpreprosteje bo poklicati funkcijo array in ji podati seznam elementov.

a = np.array([3, 8, 9, 2])
b = np.array([8, 0, 1, -3])

Tako.

a
array([3, 8, 9, 2])

Indeksiranje tabel

Tabele indeksiramo tako kot sezname. Z indeksi, ki morajo biti cela števila.

a[2]
9

Delujejo tudi rezine in vse, kar smo se naučili v zvezi z njimi.

a[2:4]
array([9, 2])
a[1:]
array([8, 9, 2])
a[:-1]
array([3, 8, 9])

Prva razlika primerjavi s seznami: če potrebujemo več elementov, lahko podamo več indeksov. Ne kar tako, da jih naštejemo (s tem bomo dosegli nekaj drugega), temveč tako, da kot indeks podamo seznam ali tabelo indeksov.

Če imamo torej

a
array([3, 8, 9, 2])

je

a[[2, 0, 1, 0, 0, 1]]
array([9, 3, 8, 3, 3, 8])

tabela, ki vsebuje drugi, ničti, prvi, potem še dvakrat ničti in spet prvi element tabele a.

Namesto seznama int-ov, lahko podamo seznam (ali tabelo) bool-ov. Ta seznam mora biti enako dolg kot tabela, ki jo indeksiramo, saj pove, katere elemente bi radi in katerih ne. Takole, recimo, dobimo prvi in zadnji element tabele:

a[[True, False, False, True]]
array([3, 2])

Oboje - predvsem zadnje - je videti ... ne preveč uporabno. V resnici bo fenomenalno uporabno. Počakajmo na primer.

Operacije nad tabelami

Imamo torej

a
array([3, 8, 9, 2])
b
array([ 8,  0,  1, -3])

Seštejmo ju.

a + b
array([11,  8, 10, -1])

Da, to je v resnici seštevanje. Potemtakem lahko tudi v resnici odštevamo.

a - b
array([-5,  8,  8,  5])

In množimo.

a * b
array([24,  0,  9, -6])

Vse operacije nad tabelami, delujejo po elementih. +, -, * ... vsaka operacija se izvede na vsakem elementu posebej. Zato lahko tabele množimo tudi s števili, jim prištevamo števila...

a
array([3, 8, 9, 2])
a + 1
array([ 4,  9, 10,  3])
a * 2.5
array([ 7.5, 20. , 22.5,  5. ])
np.array([2]) ** range(10)
array([  1,   2,   4,   8,  16,  32,  64, 128, 256, 512])

Še več. Celo operacije, kot so < delujejo nad posameznimi elementi.

b
array([ 8,  0,  1, -3])
b > 0
array([ True, False,  True, False])

Pa funkcije tudi!

np.abs(b)
array([8, 0, 1, 3])
np.sqrt(a)
array([1.73205081, 2.82842712, 3.        , 1.41421356])

Kjer je to smiselno, jasno. Funkcije, kot so sum in max bodo seveda seštevale in množile.

np.sum(a)
22
np.max(a)
9

Tule smo uporabljali numpy-jeve funkcije abs, sqrt, sum in max. Namesto nekaterih od njih bi lahko uporabili tudi Pythonove funkcije. sum zna seštevati, kar jima pride pod roko (točneje: vse, čez kar lahko nažene zanko for), pa tudi max in min nista izbirčna.

sum(a)
22

Vendar: ko delamo s tabelami, se nam vedno splača uporabiti numpy-jeve ekvivalente funkcije. Če ne drugega, bodo hitrejše, včasih pa vdelane Pythonove funkcije ne bodo delovale pravilno ali pa ne sploh (sqrt(a) javi napako). Poleg tega imajo numpy-jeve funkcije pogosto dodatne argumente, specifične za delo s tabelami.

Vektorske operacije

Ko delamo z numpy-jem, se poskušamo predvsem izogniti pisanju zank.

Kako bi dobili vsoto vseh pozitivnih elementov b-ja? Najprej sestavimo "masko", tabelo bool-ov, ki vsebuje True na mestih, kjer ima b pozitivne elemente.

b > 0
array([ True, False,  True, False])

S to masko lahko izberemo pozitivne elemente.

b[b > 0]
array([8, 1])

Ker nas zanima vsota, jih seštejemo.

np.sum(b[b > 0])
9

Če bi nas zanimalo samo, koliko pozitivnih elementov ima b, bi preprosto sešteli masko, saj tudi v numpy velja, da je True toliko kot 1, False pa toliko kot 0.

np.sum(b > 0)
2

Če bi bili še manj zahtevni in bi nas zanimalo le, ali ima b kakšen pozitiven element, bi uporabili any (spet vzamemo numpy-je ekvivalent in ne Pyhonovega vdelanega any, ki smo ga spoznali prejšnjo uro):

np.any(b > 0)
True

Da b nima samih pozitivnih elementov, pa nam pove all:

np.all(b > 0)
False

Podobno preprosto je poiskati (šteti, preverjati) sode elemente a-ja.

a[a % 2 == 0]
array([8, 2])

Ta, zadnji primer je kar zanimivo prebrati: a % 2 == 0. Kar ta formula pravi o a-ju, se v bistvu nanaša na vsak element a-ja. Ko rečemo a % 2 == 0 dobimo to, kar bi v golem Pythonu, s seznami, dosegli z [x % 2 == 0 for x in a]. Pogovor o izpeljanih seznamih je bil - če ne zaradi drugega - potreben zato, da lažje razumemo idejo "vektorskih operacij" - operacij, ki se, popolnoma enake, zgodijo na vsakem elementu tabele. Nekje znotraj numpy-ja se seveda še vedno skriva neka zanka, vendar je zaradi načina, na katerega je numpy narejen takšna zanka veliko (kjer "veliko" zlahka pomeni petdesetkrat ali celo stokrat) hitrejša, kot če bi zapisali zanko v Pythonu.

Primer: višinske razlike

Kolesar je na vsakih sto metrov (neke izgleda kar razgibane :) vožnje zabeležil svojo nadmorsko višino.

h = np.array([345, 355, 360, 364, 378, 370, 360, 355, 360, 361])

Zdaj ga, kot vsakega kolesarja, ki počne takšne stvari, zanima skupni dvig.

Za začetek je potrebno dobiti seznam sprememb višine med zaporednimi pari meritev. S seznami bi napisali nekaj takšnega:

d = [x - y for x, y in zip(h[1:], h)]

d
[10, 5, 4, 14, -8, -10, -5, 5, 1]

Vendar se želimo izogniti zanki. Operator - želimo uporabiti na tabeli, ne na njenih posamičnih elementih. Gornji zip - oziroma njegovi argument - so že pokazali, kaj je potrebno odšteti.

print(h[1:])
print(h)
[355 360 364 378 370 360 355 360 361]
[345 355 360 364 378 370 360 355 360 361]

Odšteti moramo, preprosto drugo tabelo od prve.

h[1:] - h
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[43], line 1
----> 1 h[1:] - h

ValueError: operands could not be broadcast together with shapes (9,) (10,) 

Da, mali detajl: ker - deluje po elementih, morata biti tabeli enako dolgi. Druga je za en element predolga. Zadnji element zato odrežemo.

d = h[1:] - h[:-1]

d
array([ 10,   5,   4,  14,  -8, -10,  -5,   5,   1])

Če to seštejemo, bomo seveda dobili 0, saj je kolesar končal, kjer je začel.

np.sum(d)
16

Vsota spustov je pač enaka vsoti dvigov. Kolesarja zanimajo le dvigi,

d[d > 0]
array([10,  5,  4, 14,  5,  1])

To je torej tisto, kar želimo sešteti. Za bonus navrzimo še največji dvig. In zložimo vse skupaj, da bomo imeli pravi vtis, kako kratek program smo napisali.

h = np.array([345, 355, 360, 364, 378, 370, 360, 355, 360, 361, 345])
d = h[1:] - h[:-1]
print(np.max(d))
print(np.sum(d[d > 0]))
14
39

Primer: Numpy na dražbi

Zdaj pa se spomnimo domače naloge Dražba: rešili jo bomo s numpy-ja. Videli bomo, kako bo pokazal mišice!

Za začetek uvozimo podatke. O funkcijah, kot je genfromtxt se bomo pogovarjali prihodnjič. Tu jo le pokličimo.

cene = np.genfromtxt("../domace-naloge/02-drazba/drazba.txt", dtype=int)
cene
array([11, 17, 24, 30, -1, 13, 27, 33, -1, 12, 27, 34, 40, -1,  9, -1,  8,
       20, 30, 31, -1])

Na prvi dve vprašanji - koliko izdelkov so prodali in koliko je stal najdražji, lahko že odgovorimo.

Prodali so toliko izdelkov, kolikor je -1. S cene == -1 dobimo tabelo False in True; True, kjer so -1.

cene == -1
array([False, False, False, False,  True, False, False, False,  True,
       False, False, False, False,  True, False,  True, False, False,
       False, False,  True])

Ker je True enak 1 in False enak 0, elemente seznama preprosto seštejemo.

np.sum(cene == -1)
5

Cena najdražjega izdelka je kar največja številka v tabeli.

np.max(cene)
40

Odgovor na zadnje vprašanje je pravilen, z estetskega vidika pa nekoliko zmoti, da smo računali maksimum prek vsega, vključno z vmesnimi ponudbami in celo -1. Nalogo bomo brez težav rešili tudi elegantneje, saj nas bo v to prisililo naslednje vprašanje: kakšna je vsota cen prodanih izdelkov.

Funkcija np.flatnonzero nam vrne tabelo z indeksi ne-ničelnih elementov. Če gre za tabelo True-jev in False-ov, vrne indekse True-jev. V našem primeru bodo to ravno indeksi elementov z vrednostjo -1.

indeksi = np.flatnonzero(cene == -1)

indeksi
array([ 4,  8, 13, 15, 20])

Hitro se prepričamo, da so na teh indeksih ravno -1-ke.

cene[indeksi]
array([-1, -1, -1, -1, -1])

To je nezanimivo: zanimajo nas ravno elementi pred njimi. Od indeksov torej odštejemo 1.

koncne = cene[indeksi - 1]

koncne
array([30, 33, 40,  9, 31])

Zdaj lahko ponovno rešimo prvi dve nalogi in še tretjo:

print(f"Število prodanih predmetov: {len(koncne)}")
print(f"Cena najdražjega predmeta: {np.max(koncne)}")
print(f"Vsota končnih cen: {np.sum(koncne)}")
Število prodanih predmetov: 5
Cena najdražjega predmeta: 40
Vsota končnih cen: 143

Zadnji vprašanji sprašujeta, koliko predmetov je kupila Ana in koliko Berta ter koliko je zapravila katera od njiju.

Na dražbi sta le onidve in prva ponudba je vedno Anina. Za odgovor na vprašanji moramo prešteti, za koliko predmetov je bilo število ponudb liho in za koliko sodo. Za to pa moramo za začetek prešteti število ponudb za vsak predmet. Pravilni odgovor bo [4, 3, 4, 1, 4].

Število ponudb je (skoraj) enako razlikam med indeksi.

indeksi
array([ 4,  8, 13, 15, 20])

Za prvi predmet so bile dane štiri ponudbe. Naprej gledamo razlike: indeks druge -1 je 8, indeks prve pa 4; vmes so bile 8 - 4 - 1 = 3 ponudbe. (Še 1 je potrebno odšteti, ker imamo v tabeli poleg cen še vmesne elemente -1.) Naslednja indeksa sta 13 in 8; 13 - 8 - 1 = 4.

Število ponudb za posamični predmet bomo izvedeli, če od

indeksi
array([ 4,  8, 13, 15, 20])

odštejemo

np.hstack(([-1], indeksi[:-1]))
array([-1,  4,  8, 13, 15])

in še -1. Na začetek smo dodali -1. Na ta način bomo po odštevanju dobili ravno pravo številko za nesrečni, posebni prvi predmet, saj bomo imeli 4 - (-1) - 1 = 4. Ne spreglejte tudi dvojnih oklepajev: funkciji np.hstack kot argument podamo terko s tabelama, ki jo želimo speti skupaj.

ponudb = indeksi - np.hstack(([-1], indeksi[:-1])) - 1

ponudb
array([4, 3, 4, 1, 4])

Natančno, kar potrebujemo.

Ana je kupila tiste predmete, za katere je bilo število ponudb liho; Berta tiste, za katere je bilo sodo.

ana = ponudb % 2 == 1
berta = ponudb % 2 == 0

ana
array([False,  True, False,  True, False])
print(f"Ana je kupila {np.sum(ana)}, Berta pa {np.sum(berta)} reči.")
Ana je kupila 2, Berta pa 3 reči.

In koliko je zapravila katera? Tole so cene reči, ki so končale pri Ani:

koncne[ana]
array([33,  9])

Torej, očitno,

print(f"Ana je zapravila {np.sum(koncne[ana])}, Berta pa {np.sum(koncne[berta])}.")
Ana je zapravila 42, Berta pa 101.

Vse skupaj

Da se zavemo, kako elegantno kratko je vse skupaj, napišimo celoten program v kosu.

import numpy as np

cene = np.genfromtxt("../domace-naloge/02-drazba/drazba.txt", dtype=int)
indeksi = np.flatnonzero(cene == -1)
koncne = cene[indeksi - 1]
ponudb = indeksi - np.hstack(([-1], indeksi[:-1])) - 1
ana = ponudb % 2 == 1
berta = ponudb % 2 == 0

print(f"Število prodanih predmetov: {len(koncne)}")
print(f"Cena najdražjega predmeta: {np.max(koncne)}")
print(f"Vsota končnih cen: {np.sum(koncne)}")
print(f"Ana je kupila {np.sum(ana)}, Berta pa {np.sum(berta)} reči.")
print(f"Ana je zapravila {np.sum(koncne[ana])}, Berta pa {np.sum(koncne[berta])}.")
Število prodanih predmetov: 5
Cena najdražjega predmeta: 40
Vsota končnih cen: 143
Ana je kupila 2, Berta pa 3 reči.
Ana je zapravila 42, Berta pa 101.