Am petrecut ultimul bloc de lecții în interiorul DataFrame-urilor — pandas, Polars, lumea tabulară. Sub toate astea, la baza fiecărui stack numeric Python pe care îl vei atinge vreodată, stă NumPy. Pandas își stochează cele mai multe coloane ca array-uri NumPy. scikit-learn primește array-uri NumPy ca input. PyTorch și JAX implementează amândouă un API compatibil NumPy peste tensorii lor, ca lumea să nu fie nevoită să reînvețe bazele. Chiar și Polars, care folosește Apache Arrow intern, îți oferă .to_numpy() pentru că toți cei din aval se așteaptă la asta.
Așa că înainte să deschidem Modulul 8 cum se cuvine, cu plotting și SciPy, ne trebuie o lecție curată despre NumPy în sine. Nu o scufundare adâncă — ar fi un curs separat — ci suficient cât să citești cod, să scrii operațiile de care vei avea nevoie și să înțelegi de ce broadcasting-ul e mișcarea conceptuală care face ca toată biblioteca să merite învățată.
ndarray-ul
Obiectul central al NumPy este ndarray, un array n-dimensional. Din afară arată ca o listă Python de numere. Pe dinăuntru e ceva complet diferit:
- Memorie contiguă. Toate elementele trăiesc unul lângă altul într-un singur bloc de RAM, așa cum face un array C. Listele Python sunt array-uri de pointeri către obiecte împrăștiate; array-urile NumPy sunt blocuri plate de octeți.
- Dtype fix. Fiecare element e de același tip —
float64,int32,bool, oricare — și tipul e ștampilat pe array, nu pe fiecare element. - Shape și strides. Același bloc de memorie 1-D poate fi interpretat ca un vector 1-D, o matrice 2-D sau un tensor de dimensiune mai mare schimbând cum NumPy se plimbă prin el.
Acel layout e întregul motiv pentru care NumPy e rapid. Când faci arr * 2, NumPy nu iterează în Python: dispecherizează către o buclă C care rulează peste octeții contigui fără overhead-ul interpretorului, adesea vectorizată la instrucțiuni SIMD de către compilator. Aceeași operație pe o listă Python e cam de 50-100x mai lentă, iar diferența crește cu dimensiunea array-ului.
import numpy as np
a = np.array([1, 2, 3, 4, 5])
print(a.dtype) # int64 on most platforms
print(a.shape) # (5,)
print(a.ndim) # 1
Crearea de array-uri
Cinci constructori acoperă majoritatea a ceea ce vei face:
np.array([1, 2, 3]) # from a list
np.zeros((3, 4)) # 3x4 of zeros, dtype float64
np.ones((2, 2), dtype=np.int32) # 2x2 of ones, integer
np.arange(0, 10, 2) # [0, 2, 4, 6, 8] — like range()
np.linspace(0, 1, 5) # [0., 0.25, 0.5, 0.75, 1.] — N evenly-spaced points
arange îți dă o mărime de pas, linspace îți dă un număr — asta e singura diferență care merită reținută. Pentru date aleatoare: np.random.default_rng(seed=42).normal(size=(1000, 3)) e API-ul modern; vechiul stil np.random.randn încă funcționează, dar ruta default_rng e ce recomandă acum documentația.
Reshape-ul mută aceeași memorie într-un layout diferit:
a = np.arange(12) # shape (12,)
b = a.reshape((3, 4)) # shape (3, 4), same data
c = a.reshape((2, 2, 3)) # shape (2, 2, 3), same data
-1 într-un reshape înseamnă „deduce această dimensiune”: a.reshape((3, -1)) pe un array de 12 elemente îți dă (3, 4). Folosește-l constant.
Vectorizarea e ideea
Primul lucru de internalizat: nu scrii bucle peste array-uri NumPy. Fiecare operație care arată ca și cum ar trebui să fie o buclă e deja una, scrisă în C, numită ufunc:
prices = np.array([10.0, 20.0, 35.5, 7.99])
with_vat = prices * 1.22 # element-wise multiplication
total = prices.sum() # scalar reduction
log_prices = np.log(prices) # element-wise log
Niciun for. Nicio list comprehension. Doar operatori și funcții cu nume, aplicate pe tot array-ul deodată. Asta e ce vrea lumea să spună prin „cod vectorizat”. Când te trezești scriind o buclă Python peste elementele unui ndarray, oprește-te și caută echivalentul vectorizat: aproape mereu există.
Broadcasting
Broadcasting-ul e regula care zice: când faci o operație pe două array-uri de forme diferite, NumPy încearcă să le întindă la forme compatibile înainte de a face operația element-cu-element. E feature-ul care transformă „trebuie să scad acest vector 1-D din fiecare rând al unei matrice” dintr-o buclă într-o singură linie.
Regulile, aliniate la dreapta (compari dimensiunile începând de la dreapta):
- Dacă un array are mai puține dimensiuni, tratează-le pe cele lipsă ca având mărimea 1.
- Două dimensiuni sunt compatibile dacă sunt egale sau dacă una dintre ele e 1.
- O dimensiune de mărime 1 e întinsă ca să se potrivească cu cealaltă.
- Dacă o dimensiune e incompatibilă, primești un
ValueError.
Exemplul clasic: scade mediile pe coloană dintr-o matrice 2-D.
X = np.array([
[1.0, 2.0, 3.0],
[4.0, 5.0, 6.0],
[7.0, 8.0, 9.0],
])
col_means = X.mean(axis=0) # shape (3,) — [4., 5., 6.]
centered = X - col_means # shape (3, 3) - shape (3,) → broadcasts
X are forma (3, 3). col_means are forma (3,). Aliniază la dreapta:
X: 3 x 3
col_means: 3
Dimensiunile finale sunt amândouă egale cu 3, deci sunt compatibile. Dimensiunea inițială lipsă din col_means e tratată ca 1, apoi întinsă la 3. Rezultatul e același ca și cum ai fi repetat col_means de trei ori de-a lungul axei rândurilor — dar nu se face efectiv nicio copie; broadcasting-ul e o operație la nivel de view.
Dacă vrei să scazi mediile pe rând, trebuie să păstrezi axa rândurilor:
row_means = X.mean(axis=1, keepdims=True) # shape (3, 1) instead of (3,)
centered_rows = X - row_means
(3, 1) se aliniază cu (3, 3): 1-ul se întinde la 3 de-a lungul coloanelor. Fără keepdims=True, ai obține (3,) și broadcasting-ul ar încerca să-l alinieze contra coloanelor lui X, făcând în tăcere altceva decât trebuie dacă matricea ta e pătrată. Acesta e bug-ul de broadcasting pe care toată lumea îl pățește o dată.
Slicing și boolean indexing
Slicing-ul NumPy arată ca slicing-ul de listă Python extins la mai multe dimensiuni, cu o diferență importantă: slice-urile sunt views, nu copii. Modificarea slice-ului modifică originalul.
arr = np.arange(20).reshape((4, 5))
arr[1:3, 0] # rows 1 and 2, column 0 — shape (2,)
arr[:, :3] # all rows, first three columns — shape (4, 3)
arr[-1, :] # last row — shape (5,)
Boolean indexing — alegerea elementelor unde o condiție e adevărată — e calul de povară:
arr = np.array([1, -2, 3, -4, 5, -6])
arr[arr > 0] # array([1, 3, 5])
arr[arr > 0] = 0 # zero out positives in place
Combinat cu np.where pentru înlocuire condiționată:
np.where(arr < 0, 0, arr) # replace negatives with 0, leave the rest
Parametrul axis
Fiecare reducere (sum, mean, min, max, std, argmax, …) primește un argument axis. Asta e al doilea lucru pe care lumea îl greșește, după broadcasting.
X = np.arange(12).reshape((3, 4))
X.sum() # 66 — reduce over everything, scalar
X.sum(axis=0) # shape (4,) — sum down each column
X.sum(axis=1) # shape (3,) — sum across each row
Mnemotehnica ce mi-a rămas în cele din urmă: axis= e dimensiunea care dispare. axis=0 colapsează axa rândurilor, lăsându-te cu un număr per coloană. axis=1 colapsează axa coloanelor, lăsând un număr per rând. La fel pentru orice caz de dimensiune mai mare.
Câteva alte funcții care merită știute
np.concatenate([a, b], axis=0)— lipește array-uri de-a lungul unei axe existente.np.stack([a, b], axis=0)— lipește array-uri de-a lungul unei axe noi (creează o nouă dimensiune).np.unique(arr, return_counts=True)— valori distincte și cât de des apare fiecare.arr.astype(np.float32)— schimbă dtype-ul.np.allclose(a, b)— egalitate aproximativă element-cu-element, modul corect de a compara float-uri.
Layout-ul memoriei (pe scurt)
Două fapte de ținut în buzunarul din spate. Array-urile NumPy sunt în mod implicit C-contiguous: rândurile trăiesc unul lângă altul în memorie, așa cum așază C un array 2-D. Fortran-contiguous e celălalt layout (coloanele unele lângă altele), ce folosesc nativ MATLAB și Fortran. Aproape niciodată nu trebuie să te gândești la asta, în afară de două locuri: când treci array-uri către o extensie C sau PyTorch (unele kernel-uri cer input contiguu — arr.contiguous() sau np.ascontiguousarray(arr) rezolvă), și când storci ultima fărâmă de performanță dintr-o operație column-major.
NumPy 2.x e lumea de acum
NumPy 2.0 a fost lansat la mijlocul lui 2024, iar linia 2.x e unde trăiește totul în 2026. Schimbarea mare a fost o curățare a subsistemului de dtype: tipuri de string mai consistente, reguli de promoție mai curate (int + float nu te mai surprinde în cazuri marginale) și o grămadă de API-uri deprecated în sfârșit eliminate. Dacă citești cod mai vechi de 2024 s-ar putea să vezi lucruri precum np.int (dispărut — folosește int sau np.int64) sau np.product (dispărut — folosește np.prod). Avertismentele de deprecare din era 1.20-1.26 au spus tuturor că vine asta; 2.0 doar a livrat-o.
Pentru cod nou, nu te gândi la asta. Fixează numpy>=2 în pyproject.toml-ul tău, instalează cu uv, mergi mai departe.
Views, copii și bug-ul pe care îl pățește toată lumea
Încă un lucru înainte să încheiem: NumPy distinge cu grijă între views și copii. Slicing-ul returnează un view — aceeași memorie, descriptor diferit de shape. Boolean indexing-ul și fancy indexing-ul (trecerea unui array de indici) returnează copii. Asta contează când începi să muți:
arr = np.arange(10)
view = arr[2:5]
view[0] = 999
print(arr) # [0, 1, 999, 3, 4, 5, 6, 7, 8, 9] — original was modified
arr2 = np.arange(10)
copy = arr2[arr2 > 3]
copy[0] = 999
print(arr2) # [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] — original unchanged
Regula generală: dacă te îndoiești, apelează .copy() explicit. Costul de performanță e de obicei neglijabil în comparație cu costul de a debug-a un bug de aliasing la 11 noaptea înainte de un termen.
np.shares_memory(a, b) îți va spune dacă două array-uri sunt views unul al altuia. Util când urmărești „de ce schimbarea lui X a schimbat și Y?”
Când să NU folosești NumPy direct
Iată adevărul incomod la sfârșitul unei lecții despre NumPy: cea mai mare parte a muncii cu date în 2026 nu începe efectiv cu np.array(...). Dacă datele tale sunt tabulare — coloane cu nume, tipuri amestecate, valori lipsă — pandas, Polars sau PyArrow îți dau toate tot ce dă NumPy plus etichete, plus o gestionare mai bună a string-urilor și datelor, plus I/O mai bun. Apelează la NumPy când ai date cu adevărat numerice, omogene, n-dimensionale: o imagine, o matrice de feature-uri pentru un model, o grilă de simulație, o serie temporală de măsurători.
Celălalt adevăr incomod e că pentru multe sarcini de ML biblioteca de array-uri pe care o vrei efectiv e PyTorch (sau JAX). Amândouă implementează un API compatibil NumPy peste tipurile lor de tensori, și amândouă rulează pe GPU. torch.tensor(arr) face round-trip cu NumPy în microsecunde, așa că workflow-ul „preprocesează în NumPy, apoi mută în torch pentru model” e ce arată majoritatea pipeline-urilor în realitate. Vestea bună: tot ce ai învățat în această lecție — broadcasting, axe, reshape-uri, slicing — se transferă direct. Cele două biblioteci au divergat în API acum un deceniu și converg liniștit de atunci.
Următoarele două lecții presupun că ai aceste numere. Lecția 44 le plotează; lecția 45 rulează statistici, optimizare și prelucrare de semnal pe ele. NumPy e substratul de sub ambele.
Referință: documentația NumPy, consultat 2026-05-01. Notele de lansare NumPy 2.x pentru schimbările de dtype și API-urile eliminate.