Python, de la zero Lecția 45 / 60

SciPy: trusa de scule pe care majoritatea o uita

Statistica, optimizare, prelucrare de semnal, matrice rare: biblioteca standard a Python-ului stiintific.

Dacă NumPy este fundația Python-ului științific, iar matplotlib desenează imaginile, SciPy este trusa de scule dintre ele. E acolo din 2001, e enormă și majoritatea dezvoltatorilor Python care lucrează zilnic ating doar unul sau două dintre submodulele ei înainte să uite că există. Păcat, fiindcă în clipa în care ai nevoie de un test t, de o ajustare de curbă, de o matrice rară sau de un FFT, răspunsul e aproape mereu deja în scipy.ceva, iar documentația e excelentă.

Lecția asta e un tur. Nu o referință (aia ar fi o carte) ci suficientă hartă cât să știi ce submodul să deschizi când dai de o problemă pe care NumPy singur nu o acoperă. Vom intra în profunzime în cele trei submodule care apar cel mai des (stats, optimize, sparse) și le vom numi rapid pe celelalte.

Cum este organizat SciPy

SciPy este o colecție de submodule, fiecare concentrat pe un domeniu. Aproape niciodată nu faci import scipy și îl folosești direct; importi submodulul de care ai nevoie:

from scipy import stats
from scipy import optimize
from scipy import sparse
from scipy import signal
from scipy import interpolate
from scipy import spatial

Biblioteca este la versiunea 1.x în 2026: e pe linia 1.x din 2017, iar API-ul e solid ca stânca. Lucrurile se strică rar între versiuni. Instalezi cu uv add scipy; pe majoritatea sistemelor aduce automat și NumPy.

scipy.stats: probabilități și teste statistice

Acesta e submodulul pe care îl deschid cel mai des. Are două piese principale: obiecte de distribuții de probabilitate și teste statistice.

Distribuțiile sunt obiecte care împachetează PDF-ul, CDF-ul, eșantionarea și estimarea parametrilor pentru o distribuție cunoscută:

from scipy import stats
import numpy as np

# Normala standard
stats.norm.pdf(0)              # 0.3989... (vârful clopotului)
stats.norm.cdf(1.96)           # 0.975, faimoasa graniță 95%
stats.norm.ppf(0.975)          # 1.959..., CDF inversă, îți dă cuantila
stats.norm.rvs(size=1000)      # 1000 de eșantioane aleatoare

# Nu e standard? Pasează loc și scale
stats.norm(loc=100, scale=15).rvs(size=1000)   # distribuție tip IQ

# Alte distribuții urmează aceeași interfață
stats.binom(n=10, p=0.3).pmf(3)        # P(X=3) pentru Binomial(10, 0.3)
stats.poisson(mu=4.5).cdf(6)           # P(X<=6) pentru Poisson(4.5)
stats.t(df=20).ppf(0.975)              # valoare critică pentru un t cu 20 df

Faptul că fiecare distribuție are aceeași interfață .pdf / .cdf / .ppf / .rvs / .fit este lucrul plăcut și insuficient celebrat al modulului. Odată ce știi tiparul, poți folosi oricare dintre zecile de distribuții fără să înveți un API nou.

Testele statistice sunt locul în care vin câștigurile practice. Cele care își merită locul în munca reală cu date:

# Test t pentru două eșantioane: analiza A/B clasică
control = np.array([2.1, 2.3, 1.9, 2.5, 2.0, 2.2])
treatment = np.array([2.4, 2.6, 2.5, 2.7, 2.3, 2.5])
result = stats.ttest_ind(control, treatment)
print(result.statistic, result.pvalue)

# Mann-Whitney U: alternativa neparametrică atunci când datele nu sunt normale
stats.mannwhitneyu(control, treatment)

# Test chi-pătrat de independență: pentru tabele de contingență
table = np.array([[50, 30], [20, 40]])
chi2, p, dof, expected = stats.chi2_contingency(table)

# Corelație Pearson și Spearman
stats.pearsonr(control, treatment)
stats.spearmanr(control, treatment)

Când contează asta în munca zilnică cu date: analiza testelor A/B (test t sau Mann-Whitney în funcție de forma datelor), ajustarea de distribuții ca să-ți dai seama cu ce fel de variabilă ai de-a face, detectarea de outlieri pe bază de cuantile (stats.norm.ppf(0.99) ca să găsești coada superioară de 1% a unei distribuții ajustate) și ocazional un chi-pătrat când compari grupuri categoriale.

Există și stats.describe(arr) care îți dă media, varianța, asimetria, kurtoza și min/max într-un singur apel: la îndemână pentru o privire rapidă asupra formei unei coloane.

scipy.optimize: minimizare, găsire de rădăcini, ajustare de curbe

Trei sarcini: găsirea minimului unei funcții, găsirea punctelor în care o funcție este zero și ajustarea parametrilor unui model la date.

Minimizarea este cea mai generală:

from scipy import optimize

# Minimizează o parabolă simplă
def f(x):
    return (x - 3) ** 2 + 1

result = optimize.minimize(f, x0=0.0)
print(result.x)            # [3.], minimul
print(result.fun)          # 1.0, valoarea la minim
print(result.success)      # True

x0 este ipoteza de pornire. Pentru funcții convexe, punctul de pornire abia contează; pentru cele neconvexe contează enorm. optimize.minimize acceptă o funcție N-dimensională (x0 poate fi un array de orice mărime) și alege un solver implicit rezonabil (BFGS pentru fără constrângeri, L-BFGS-B dacă pasezi limite, SLSQP pentru constrângeri).

Ajustarea de curbe este cea pe care oamenii care lucrează cu date o folosesc efectiv, chiar dacă au uitat că trăiește aici:

# Ajustează o curbă logistică pe date de creștere
def logistic(t, L, k, t0):
    return L / (1 + np.exp(-k * (t - t0)))

t = np.array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
y = np.array([0.1, 0.15, 0.3, 0.6, 1.1, 1.8, 2.5, 2.9, 3.1, 3.15, 3.18])

popt, pcov = optimize.curve_fit(logistic, t, y, p0=[3.0, 1.0, 4.0])
L_fit, k_fit, t0_fit = popt
print(f"L={L_fit:.2f}, k={k_fit:.2f}, t0={t0_fit:.2f}")

curve_fit ia funcția modelului (primul argument este variabila independentă, restul sunt parametri), datele x și y și un p0 opțional ca ipoteză inițială. Returnează parametrii cei mai buni și matricea de covarianță (a cărei diagonală îți dă erorile standard ale parametrilor). Așa ajustezi curbe logistice pe creșterea unui produs, exponențiale pe descompuneri, Gompertz pe răspândirea bolilor, orice ai unde cunoști forma funcțională și vrei coeficienții.

Găsirea rădăcinilor pentru când vrei să rezolvi f(x) = 0:

optimize.brentq(lambda x: x**3 - 2*x - 5, 1, 3)   # rădăcină în [1, 3]

brentq este robust și aproape mereu alegerea corectă atunci când ai un interval de încadrare în care funcția își schimbă semnul.

scipy.sparse: matrice care sunt majoritar zero

Al treilea submodul care își merită banii. O matrice rară stochează doar intrările nenule. Pentru o matrice de un milion pe un milion în care 99,99% din intrări sunt zero (extrem de comun în NLP, sisteme de recomandare, probleme de grafuri), stocarea rară este diferența între „încape în 2 GB de RAM” și „ar avea nevoie de 8 TB”.

from scipy import sparse

# Construiește o matrice rară din triplete de coordonate
rows = np.array([0, 1, 2, 0])
cols = np.array([0, 1, 2, 2])
vals = np.array([1.0, 2.0, 3.0, 4.0])

M = sparse.csr_matrix((vals, (rows, cols)), shape=(3, 3))
print(M.toarray())
# [[1. 0. 4.]
#  [0. 2. 0.]
#  [0. 0. 3.]]

Cele patru formate pe care le vei vedea: csr_matrix (compressed sparse row, slicing rapid pe rânduri, produs matrice-vector rapid), csc_matrix (versiunea pe coloane a aceluiași lucru), coo_matrix (formatul cu triplete, bun pentru construirea incrementală a unei matrice rare apoi conversie) și lil_matrix (cea de folosit când trebuie să muți intrări). Tiparul: construiești în COO sau LIL, convertești la CSR pentru calcul.

Folosirea clasică: matrice termen-document. Un corpus cu 100k documente și un vocabular de 50k cuvinte e, în formă densă, o matrice cu 5 miliarde de celule. Ca csr_matrix rară, e câteva sute de megabiți. TfidfVectorizer din scikit-learn returnează direct un csr_matrix; îl poți pasa direct unui LogisticRegression și întreaga pipeline rămâne rară de la cap la coadă.

Restul trusei

Submodulele în care n-am intrat, dar pe care ar trebui să știi că există după nume:

  • scipy.interpolate: când ai puncte de date și vrei o estimare continuă între ele. interp1d pentru 1-D, RegularGridInterpolator pentru n-D pe grilă, spline-uri prin UnivariateSpline. Submodulul „am date de tip lookup-table și am nevoie să le evaluez în puncte arbitrare”.
  • scipy.signal: prelucrare de semnal. Proiectarea și aplicarea de filtre digitale (butter, filtfilt), calcularea de FFT-uri (scipy.fft mai vechi este casa modernă pentru asta), corelații, convoluții, găsirea de vârfuri (find_peaks). Dacă lucrezi cu date de senzori, audio sau orice serie de timp în care contează conținutul de frecvență, ăsta e submodulul tău.
  • scipy.spatial: metrici de distanță și structuri de date spațiale. cKDTree pentru interogări rapide de cel mai apropiat vecin (mult mai rapide decât forța brută pe mai mult de câteva mii de puncte), distance.cdist pentru distanțe între toate perechile din două seturi de puncte.
  • scipy.cluster: clusterizare ierarhică (linkage, dendrogram, fcluster) și un k-means de bază. Pentru majoritatea muncii de clusterizare în 2026, lumea apelează la scikit-learn în loc, dar funcțiile ierarhice din scipy sunt încă standardul pentru acel algoritm specific.
  • scipy.integrate: integrare numerică (quad pentru integrale 1-D) și solvere de ODE (solve_ivp).
  • scipy.linalg: algebră liniară. În mare un superset al numpy.linalg cu extras: factorizări de matrice, solvere speciale, lstsq-ul pe care toată lumea îl folosește pentru cele mai mici pătrate.
  • scipy.ndimage: prelucrare de imagini n-dimensionale. Filtre, morfologie, etichetare. Lucrul pe care e construit scikit-image.

Tiparul cu toate astea: nu le memorezi. Treci o dată prin lecția asta, îți amintești „a, da, e o chestie SciPy”, iar când dai de problemă peste două luni deschizi scipy.org/doc/scipy/reference/ și găsești funcția în cinci minute.

Un exemplu real: test A/B, de la cap la coadă

Punem stats la treabă pe ceva concret. Ai rulat un test A/B pe un buton de checkout. Grupul A a văzut designul vechi, grupul B l-a văzut pe cel nou. Ai logat timpii de conversie în secunde pentru utilizatorii care au finalizat checkout-ul. A făcut noul design oamenii mai rapizi?

import numpy as np
from scipy import stats

rng = np.random.default_rng(42)
group_a = rng.lognormal(mean=2.0, sigma=0.5, size=500)
group_b = rng.lognormal(mean=1.85, sigma=0.5, size=500)

# Descriptive rapide
print(f"A: median={np.median(group_a):.2f}, mean={group_a.mean():.2f}")
print(f"B: median={np.median(group_b):.2f}, mean={group_b.mean():.2f}")

# Test t pe valori brute, dar stai, datele lognormale sunt asimetrice
t_result = stats.ttest_ind(group_a, group_b)
print(f"t-test:    p={t_result.pvalue:.4f}")

# Mai bine: log-transformăm întâi, fiindcă datele subiacente sunt lognormale
log_a, log_b = np.log(group_a), np.log(group_b)
t_log = stats.ttest_ind(log_a, log_b)
print(f"log t-test: p={t_log.pvalue:.4f}")

# Sau sărim peste presupunerea parametrică complet
mw = stats.mannwhitneyu(group_a, group_b, alternative="greater")
print(f"Mann-Whitney: p={mw.pvalue:.4f}")

Trei răspunsuri la aproximativ aceeași întrebare, fiecare cu presupuneri diferite. La asta este scipy.stats: să ai uneltele să întrebi „e real?” în două sau trei moduri și să vezi dacă sunt de acord.

Un al doilea exemplu: interpolarea citirilor de senzori

Un al doilea tipar pe care îl întâlnesc constant. Ai un senzor care logează la intervale neregulate. Vrei o citire curată în fiecare minut pentru procesare ulterioară. scipy.interpolate.interp1d face treaba:

from scipy import interpolate

# Marcaje de timp neregulate (în secunde de la start) și valorile senzorului la fiecare
t_irregular = np.array([0, 7, 23, 41, 58, 79, 102, 130, 165])
values = np.array([20.1, 20.3, 21.0, 22.4, 23.1, 22.8, 21.9, 20.5, 19.8])

# Construiește interpolatorul (liniar implicit; "cubic" pentru netezire)
f = interpolate.interp1d(t_irregular, values, kind="cubic")

# Eșantionează pe o grilă curată
t_regular = np.arange(0, 165, 5)
values_regular = f(t_regular)

f este acum un callable: dă-i orice timp din intervalul original și îți returnează o valoare interpolată. kind="linear" este implicit și alegerea corectă când ai date zgomotoase; kind="cubic" este mai netedă, dar poate oscila în jurul outlierilor. Pentru date din afara intervalului original, vei vrea fill_value="extrapolate" sau bounds_error=False: doar fii conștient că extrapolarea dincolo de datele de antrenare este o capcană de încredere.

Pentru 2-D și mai sus, RegularGridInterpolator este API-ul modern care înlocuiește mai vechiul, depreciat griddata pentru date structurate pe grilă.

Încheierea modulului

Cu asta s-a terminat Modulul 8. Lecția 43 ți-a dat modelul de array și broadcasting-ul din NumPy. Lecția 44 a acoperit cele trei biblioteci de plotare. Lecția 45 (asta) a arătat spre restul trusei științifice.

Tiparul de-a lungul tuturor celor trei lecții este același: un nucleu mic de operații acoperă marea majoritate a muncii reale, iar bibliotecile sunt suficient de mari încât nimeni nu memorează totul. Înveți forma trusei o dată, faci bookmark la documentație și mergi să cauți lucrurile când ai nevoie de ele. Acel obicei („știu că SciPy are o chestie pentru asta, hai s-o găsesc”) este mai valoros decât memorarea semnăturilor de funcții, pe care oricum le pot furniza la cerere și IDE-ul, și LLM-ul.

Modulul 9 reia firul și începe să folosească tot asta pentru muncă reală de ML: scikit-learn pentru modele clasice, apoi un pivot spre stack-ul modern (bazele PyTorch, hugging face pentru NLP, bucățile de MLOps care nu sunt groaznice). Fundația numerică pe care tocmai am construit-o e ceea ce face posibil orice din toate astea.


Referință: documentația SciPy, consultată 2026-05-01.

Caută