C’è un momento nella vita di ogni utente di pandas in cui un job che ieri girava su 1 milione di righe oggi muore su 50 milioni. Il grafico della memoria sale, lo swap si riempie, il kernel ammazza il processo, e tu inizi a googlare “pandas out of memory”. Nove volte su dieci, il problema non è che i dati sono davvero troppo grossi; è che le stringhe sono memorizzate come oggetti Python, una per riga, e stai pagando 50 byte per carattere per quello che dovrebbe essere un codice intero da 4 byte. Sistemare i dtype trasforma lo stesso dataset da 8 GB a 800 MB.
Oggi vediamo i due dtype che fanno la maggior parte del lavoro: category e string[pyarrow]. Entrambi sono facili da applicare, entrambi sono enormi vittorie sui dati giusti, ed entrambi hanno una situazione specifica in cui sono la scelta sbagliata.
Perché il dtype object è costoso
Per default, pandas memorizza le colonne di stringhe come dtype object. Sotto il cofano, è un array NumPy di puntatori a oggetti Python: un puntatore per riga, ognuno che punta a un oggetto str Python separato sull’heap. Ogni stringa Python porta con sé circa 50 byte di overhead prima dei suoi dati di caratteri effettivi. Una colonna da 50 milioni di righe di, diciamo, codici paese ("IT", "US", "DE", ecc.) finisce per usare:
- 50M x 8 byte per i puntatori = 400 MB
- 50M x ~55 byte per gli oggetti stringa = ~2,75 GB
- Più header dei dizionari, overhead dell’allocatore, frammentazione…
Sfondi i 3 GB su una colonna che semanticamente contiene circa 200 valori unici. Non c’è scusa una volta che conosci le alternative.
Il dtype categorical
Il dtype category risolve il caso a bassa cardinalità. Internamente, pandas costruisce un piccolo dizionario di valori unici e memorizza i dati come codici interi che puntano nel dizionario. Per una colonna con 200 paesi unici e 50 milioni di righe:
- 200 x ~55 byte per le voci del dizionario = ~11 KB
- 50M x 1 byte (o 2, se superi le 256 categorie) per i codici = ~50 MB
Circa 60 MB totali invece di 3 GB. I risparmi non sono sottili.
La conversione è una riga sola:
df["country"] = df["country"].astype("category")
La nuova colonna si comporta come una colonna di stringhe per quasi tutto: confronti, uguaglianza, metodi .str, groupby, ma usa una frazione della memoria. Come bonus, groupby su una colonna categorical è notevolmente più veloce, perché pandas può usare i codici interi direttamente invece di fare hash di stringhe.
I posti dove brilla:
- Codici paese / valuta / lingua: piccoli insiemi fissi, usati ovunque.
- Enum di stato:
"pending","shipped","delivered","cancelled". - Categorie di prodotto: di solito al massimo qualche centinaio.
- Stringhe boolean-ish che qualcuno ha insistito a memorizzare come
"yes"/"no"invece diTrue/False.
La trappola: colonne ad alta cardinalità. Se la colonna ha grosso modo tanti valori unici quanti righe (ID utente, hash di transazioni, commenti in testo libero, URL), categorical è peggio di object. Paghi il costo del dizionario senza ottenere alcun risparmio, e le operazioni su di essa sono più lente perché pandas deve cercare i codici in un dizionario che è grande quanto i dati comunque. Regola del pollice: se il conteggio dei valori unici è più di circa il 50% del conteggio delle righe, non usare categorical.
Come controllare prima di decidere:
unique_ratio = df["col"].nunique() / len(df)
# < 0.05 -> categorical e' una vittoria chiara
# 0.05 a 0.5 -> probabilmente vale la pena; fai benchmark
# > 0.5 -> lasciala stare (o usa string[pyarrow] per il testo)
Categorical ordinati
A volte un categorical non è solo un insieme, è una sequenza: taglie di T-shirt (S < M < L < XL), valutazioni a stelle (1 < 2 < 3 < 4 < 5), gradi di credito (AAA < AA < A < BBB…). Pandas lo supporta direttamente:
import pandas as pd
sizes = pd.Categorical(
["M", "L", "S", "XL", "M"],
categories=["S", "M", "L", "XL"],
ordered=True,
)
df["size"] = sizes
Ora i confronti rispettano l’ordine: df["size"] >= "L" restituisce [False, True, False, True, False]. L’ordinamento funziona correttamente senza che tu debba definire una chiave di ordinamento. min e max significano quello che ti aspetteresti.
Questo è il modo giusto di gestire dati ordinali in pandas. L’alternativa, definire una colonna separata per l’ordine di sort o ricordarsi di passare key= a ogni sort, è il tipo di cosa che si rompe nel momento in cui un collega scrive una query senza saperlo.
Il dtype string[pyarrow]
Per le colonne di testo ad alta cardinalità dove categorical non aiuta, la risposta è string[pyarrow]. È un dtype string vero e proprio, supportato dallo storage stringhe colonnare di Apache Arrow, ed è strettamente migliore di object dtype per quasi ogni uso testuale:
df["description"] = df["description"].astype("string[pyarrow]")
Cosa ottieni:
- Meno memoria: Arrow memorizza le stringhe come un singolo buffer di byte contiguo con offset, senza overhead Python per stringa. 30-50% più piccolo per testo tipico.
- Operazioni stringa più veloci:
.str.contains,.str.lower,.str.replacee amici girano come codice C vettorizzato sul buffer Arrow. Su object dtype, ogni chiamata itera oggetti Python uno alla volta. - Valori mancanti corretti:
pd.NAinvece del casinoNaN/Noneche hanno le colonne object.
La differenza di velocità su .str.contains su milioni di righe è tipicamente 5-10 volte. Se il tuo codice fa qualsiasi pattern matching di testo su scala, questo dtype da solo giustifica il cambiamento.
In pandas 3.0+ (quando uscirà), le stringhe Arrow-backed saranno il default; c’è un flag opt-in nella 2.x:
import pandas as pd
pd.options.future.infer_string = True
# ora read_csv ecc. producono string[pyarrow] per le colonne stringa
Attivare quel flag in cima ai nuovi script è una mossa senza rimpianti. Ottieni il default futuro oggi, e il tuo codice è pronto per la 3.0 senza modifiche.
Metodi stringa vettorizzati
Qualunque sia il dtype string in cui ti trovi, l’accessor .str è il modo giusto di manipolare il testo. Applica l’operazione all’intera colonna a velocità C (o vicina), invece di iterare in 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+)")
Quello che non dovresti fare, mai, è:
# DA NON FARE
df["clean"] = df["email"].apply(lambda x: x.lower().strip())
apply con una lambda Python itera riga per riga attraverso Python. Su un milione di righe è cento volte più lento di .str.lower().str.strip(). Il percorso vettorizzato gestisce anche i valori mancanti in modo pulito; la lambda esplode al primo None che incontri.
L’API completa .str rispecchia la maggior parte dei metodi str di Python più helper per regex (.str.contains, .str.extract, .str.findall). Vale dieci minuti scrollare il riferimento dei metodi stringa di pandas una volta, solo per sapere cosa c’è a disposizione prima di scrivere una lambda one-off.
Scegliere il dtype giusto: una checklist
Per ogni colonna stringa in un dataset che ti sta dando problemi di memoria o velocità:
- Calcola
nunique() / len(df). - Rapporto sotto ~5%? Converti a
category. Vittoria di memoria enorme, groupby più veloci. - Rapporto 5-50% e la colonna è codici corti (ID che si ripetono un po’)? Fai benchmark di entrambi: di solito categorical vince ancora, ma non di tanto.
- Alta cardinalità, testo libero, URL, email? Converti a
string[pyarrow]. Non si restringerà in modo così drammatico come categorical, ma ottieni operazioni.strvettorizzate e gestione corretta dei NA. - Dati ordinali (taglie, gradi, valutazioni)?
pd.Categorical(..., ordered=True). - Stringhe che sembrano numeriche (
"1234","56.7")? Non sono stringhe, sono numeri.astype("int64")oastype("float64[pyarrow]")e smetti di fingere.
Fai questo una volta per dataset, non colonna per colonna a istinto. La vittoria viene dalle colonne a cui non stai pensando.
Un esempio reale: un log file da 50M di righe
Un log di accessi web: 50 milioni di righe, colonne per timestamp, ID utente, URL path, metodo HTTP, status code, user agent, codice paese, response time. Caricamento ingenuo:
df = pd.read_parquet("access.parquet")
df.memory_usage(deep=True).sum() / 1e9
# ~ 8.2 GB
La maggior parte è la stringa user agent, l’URL path e il codice paese memorizzati come oggetti. Dopo l’ottimizzazione dei dtype:
df = pd.read_parquet("access.parquet")
# Bassa cardinalita' -> category
df["country"] = df["country"].astype("category")
df["method"] = df["method"].astype("category")
df["status"] = df["status"].astype("category")
# Testo ad alta cardinalita' -> string[pyarrow]
df["user_agent"] = df["user_agent"].astype("string[pyarrow]")
df["path"] = df["path"].astype("string[pyarrow]")
# Numerico
df["response_ms"] = df["response_ms"].astype("int32") # era int64; max ~30s, ci sta tranquillo
df.memory_usage(deep=True).sum() / 1e9
# ~ 0.8 GB
Una riduzione di 10 volte. Lo stesso script che andava in swap su disco e moriva ora gira comodamente su un portatile. E poiché i nuovi dtype sono anche più veloci (groupby categorical, .str.contains vettorizzato per trovare il traffico bot), l’analisi stessa gira in una frazione del tempo.
Questa è la differenza tra “ci serve una macchina più grossa” e “dobbiamo leggere la documentazione dei dtype per mezz’ora”. Quasi sempre, è la seconda.
Cosa viene dopo
Questo chiude il modulo 6. Il modulo 7 inizia con groupby vero e proprio: il trio apply / transform / aggregate, i pattern che vengono fuori in continuazione nel codice analitico, e gli angoli di performance che vale la pena conoscere. Dopo due mesi di pandas scriverai codice più corto, più veloce, e molto più piacevole di quello con cui hai iniziato.
Letture di approfondimento
- pandas: Categorical data: il riferimento completo, incluse le parti sui categorical ordinati.
- pandas: Working with text data: le opzioni di dtype string e l’API
.strcompleta. - PyArrow: String arrays: il formato di storage sotto
string[pyarrow], brevemente.
Ci vediamo venerdì.