Python, de la zero Lecția 33 / 60

Tipuri categorical si string: castiguri de memorie si viteza

Cand conversia unei coloane in categorical sau string[pyarrow] face diferenta intre un job care ruleaza si unul care ramane fara memorie.

Există un moment în viața fiecărui utilizator pandas când un job care ieri funcționa pe 1 milion de rânduri moare astăzi pe 50 de milioane. Graficul de memorie urcă, fișierul de swap se umple, kernelul omoară procesul, iar tu începi să cauți pe Google „pandas out of memory”. De nouă ori din zece, problema nu e că datele sunt cu adevărat prea mari, ci că string-urile sunt stocate ca obiecte Python, câte unul pe rând, și plătești 50 de bytes per caracter pentru ceea ce ar trebui să fie un cod întreg de 4 bytes. Repararea dtype-urilor transformă același dataset din 8 GB în 800 MB.

Astăzi acoperim cele două dtype-uri care fac cea mai mare parte a muncii: category și string[pyarrow]. Ambele sunt ușor de aplicat, ambele sunt câștiguri uriașe pe datele potrivite, iar ambele au o situație specifică în care sunt alegerea greșită.

De ce dtype-ul object este costisitor

În mod implicit, pandas stochează coloanele de string-uri ca dtype object. În culise, asta e un array NumPy de pointeri către obiecte Python, câte un pointer pe rând, fiecare indicând către un obiect Python str separat de pe heap. Fiecare string Python aduce cu sine cam 50 de bytes de overhead înainte de datele propriu-zise ale caracterelor. O coloană de 50 de milioane de rânduri cu, să zicem, coduri de țară ("IT", "US", "DE", etc.) ajunge să folosească:

  • 50M × 8 bytes pentru pointeri = 400 MB
  • 50M × ~55 bytes pentru obiectele string = ~2.75 GB
  • Plus header-ele de dicționar, overhead-ul allocator-ului, fragmentarea…

Treci de 3 GB pe o coloană care semantic deține cam 200 de valori unice. Nu există scuză pentru asta odată ce cunoști alternativele.

Dtype-ul categorical

Dtype-ul category rezolvă cazul cardinalității scăzute. Intern, pandas construiește un mic dicționar de valori unice și stochează datele ca coduri întregi care indică în dicționar. Pentru o coloană cu 200 de țări unice și 50 de milioane de rânduri:

  • 200 × ~55 bytes pentru intrările din dicționar = ~11 KB
  • 50M × 1 byte (sau 2, dacă depășești 256 de categorii) pentru coduri = ~50 MB

Aproximativ 60 MB în total în loc de 3 GB. Economiile nu sunt subtile.

Conversia e o singură linie:

df["country"] = df["country"].astype("category")

Noua coloană se comportă ca o coloană de string-uri pentru aproape orice (comparații, egalitate, metode .str, groupby) dar folosește o fracțiune din memorie. Ca bonus, groupby pe o coloană categorical este semnificativ mai rapid, fiindcă pandas poate folosi direct codurile întregi în loc să facă hashing pe string-uri.

Locurile în care strălucește:

  • Coduri de țară / monedă / limbă seturi mici și fixe, folosite peste tot.
  • Enum-uri de status / stare "pending", "shipped", "delivered", "cancelled".
  • Categorii de produse de obicei cel mult câteva sute.
  • String-uri de tip boolean pe care cineva a insistat să le stocheze ca "yes"/"no" în loc de True/False.

Capcana: coloane cu cardinalitate ridicată. Dacă o coloană are cam tot atâtea valori unice câte rânduri (ID-uri de utilizator, hash-uri de tranzacții, comentarii în text liber, URL-uri), categorical e mai rău decât object. Plătești costul dicționarului fără să primești vreo economie, iar operațiile pe el sunt mai lente fiindcă pandas trebuie să caute coduri într-un dicționar care e oricum de dimensiunea datelor. Regulă empirică: dacă numărul de valori unice depășește cam 50% din numărul de rânduri, nu folosi categorical.

Cum verifici înainte de a decide:

unique_ratio = df["col"].nunique() / len(df)
# < 0.05 -> categorical e o victorie clara
# 0.05 pana la 0.5 -> probabil merita; benchmark
# > 0.5 -> las-o in pace (sau foloseste string[pyarrow] pentru text)

Categorical-uri ordonate

Uneori un categorical nu e doar un set, ci o secvență: mărimi de tricou (S < M < L < XL), evaluări cu stele (1 < 2 < 3 < 4 < 5), grade de credit (AAA < AA < A < BBB…). Pandas suportă asta direct:

import pandas as pd

sizes = pd.Categorical(
    ["M", "L", "S", "XL", "M"],
    categories=["S", "M", "L", "XL"],
    ordered=True,
)
df["size"] = sizes

Acum comparațiile respectă ordinea: df["size"] >= "L" returnează [False, True, False, True, False]. Sortarea funcționează corect fără să fii nevoit să definești o cheie de sortare. min și max înseamnă ce te-ai aștepta.

Asta e modul corect de a gestiona date ordinale în pandas. Alternativa (definirea unei coloane separate cu ordinea de sortare sau ținerea minte să dai key= la fiecare sort) e genul de lucru care se rupe în clipa în care un coleg scrie un query fără să știe de el.

Dtype-ul string[pyarrow]

Pentru coloanele de text cu cardinalitate ridicată unde categorical nu ajută, răspunsul e string[pyarrow]. E un dtype propriu pentru string-uri susținut de stocarea columnară de string-uri din Apache Arrow și e strict mai bun decât dtype-ul object pentru aproape orice caz de utilizare cu text:

df["description"] = df["description"].astype("string[pyarrow]")

Ce primești:

  • Mai puțină memorie Arrow stochează string-urile ca un singur buffer continuu de bytes cu offset-uri, fără overhead Python per string. Cu 30-50% mai mic pentru text tipic.
  • Operații pe string-uri mai rapide .str.contains, .str.lower, .str.replace și prietenii rulează ca cod C vectorizat peste buffer-ul Arrow. Pe dtype-ul object, fiecare apel iterează prin obiecte Python unul câte unul.
  • Valori lipsă proprii pd.NA în loc de mizeria NaN/None pe care o au coloanele object.

Diferența de viteză la .str.contains peste milioane de rânduri e tipic de 5-10x. Dacă codul tău face vreo potrivire de text la scară, doar acest dtype justifică schimbarea.

În pandas 3.0+ (când va fi lansat), string-urile susținute de Arrow vor fi default-ul; există un flag opt-in în 2.x:

import pandas as pd
pd.options.future.infer_string = True
# acum read_csv etc. produc string[pyarrow] pentru coloanele de string-uri

Activarea acelui flag în partea de sus a scripturilor noi e o mișcare fără regrete. Primești default-ul din viitor astăzi, iar codul tău e gata pentru 3.0 fără modificări.

Metode vectorizate pe string-uri

Indiferent pe ce dtype de string-uri ești, accesorul .str e modul corect de a manipula text. Aplică operația pe întreaga coloană la viteză C (sau aproape) în loc să itereze în Python:

df["email"] = df["email"].str.lower().str.strip()
df["clean_phone"] = df["phone"].str.replace(r"\D", "", regex=True)
df["domain"] = df["email"].str.split("@").str[1]
df["is_corporate"] = df["email"].str.endswith(("@example.com", "@example.org"))
df["name_first_word"] = df["name"].str.extract(r"^(\S+)")

Ce nu trebuie să faci, niciodată, este:

# NU
df["clean"] = df["email"].apply(lambda x: x.lower().strip())

apply cu o lambda Python iterează rând cu rând prin Python. Pe un milion de rânduri e de o sută de ori mai lent decât .str.lower().str.strip(). Calea vectorizată gestionează curat și valorile lipsă; lambda explodează la primul None pe care îl întâlnește.

API-ul .str complet oglindește majoritatea metodelor str din Python plus helper-i pentru regex (.str.contains, .str.extract, .str.findall). Merită zece minute de scrolling prin referința metodelor de string-uri din pandas o singură dată, doar ca să știi ce e disponibil înainte să scrii o lambda one-off.

Alegerea dtype-ului potrivit: o listă de verificare

Pentru fiecare coloană de string-uri dintr-un dataset care îți dă bătăi de cap cu memoria sau viteza:

  1. Calculează nunique() / len(df).
  2. Raport sub ~5%? Convertește la category. Câștig masiv de memorie, groupby-uri mai rapide.
  3. Raport 5-50% și coloana conține coduri scurte (ID-uri care se repetă pe alocuri)? Fă benchmark pe ambele; de obicei categorical câștigă tot, dar nu cu aceeași marjă.
  4. Cardinalitate ridicată, text liber, URL-uri, email-uri? Convertește la string[pyarrow]. Nu se va micșora la fel de dramatic ca la categorical, dar primești operații .str vectorizate și gestionare proprie a NA.
  5. Date ordinale (mărimi, grade, evaluări)? pd.Categorical(..., ordered=True).
  6. String-uri care arată numerice ("1234", "56.7")? Nu sunt string-uri, sunt numere. astype("int64") sau astype("float64[pyarrow]") și încetează prefăcătoria.

Rulează asta o dată per dataset, nu cu instinct coloană cu coloană. Câștigul vine din coloanele la care nu te gândești.

Un exemplu real: un fișier de log de 50M rânduri

Un log de access web: 50 de milioane de rânduri, coloane pentru timestamp, ID utilizator, cale URL, metodă HTTP, cod de status, user agent, cod de țară, timp de răspuns. Încărcare naivă:

df = pd.read_parquet("access.parquet")
df.memory_usage(deep=True).sum() / 1e9
# ~ 8.2 GB

Cea mai mare parte e string-ul user agent, calea URL și codul de țară stocate ca obiecte. După optimizarea dtype-urilor:

df = pd.read_parquet("access.parquet")

# Cardinalitate scazuta -> category
df["country"] = df["country"].astype("category")
df["method"] = df["method"].astype("category")
df["status"] = df["status"].astype("category")

# Text cu cardinalitate ridicata -> string[pyarrow]
df["user_agent"] = df["user_agent"].astype("string[pyarrow]")
df["path"] = df["path"].astype("string[pyarrow]")

# Numeric
df["response_ms"] = df["response_ms"].astype("int32")  # era int64; max ~30s, intra usor

df.memory_usage(deep=True).sum() / 1e9
# ~ 0.8 GB

O reducere de 10x. Același script care făcea swap pe disc și murea acum rulează confortabil pe un laptop. Și fiindcă noile dtype-uri sunt și mai rapide (groupby-uri pe categorical, .str.contains vectorizat pentru găsirea traficului de boți), analiza în sine rulează într-o fracțiune din timp.

Asta e diferența dintre „avem nevoie de o mașină mai mare” și „avem nevoie să citim docs-urile de dtype-uri o jumătate de oră”. Aproape întotdeauna, e a doua.

Ce urmează

Asta închide Modulul 6. Modulul 7 începe cu groupby propriu-zis: trio-ul apply / transform / aggregate, pattern-urile care apar iar și iar în codul analitic și colțurile de performanță care merită cunoscute. După două luni de pandas vei scrie cod mai scurt, mai rapid și mult mai plăcut decât ce-ai pornit.

Lecturi suplimentare

Ne vedem vineri.

Caută