Panda: da disordinati a belli.  Ecco come creare il codice del tuo panda… |  di Anna Zawadzka |  Marzo 2024

 | Intelligenza-Artificiale

Scripting attorno a un panda DataFrame può trasformarsi in un imbarazzante mucchio di (non così) buon vecchio codice spaghetti. Io e i miei colleghi usiamo molto questo pacchetto e mentre cerchiamo di attenerci alle buone pratiche di programmazione, come la suddivisione del codice in moduli e il test unitario, a volte ci intralciamo ancora producendo codice confuso.

Ho raccolto alcuni suggerimenti e insidie ​​da evitare per rendere il codice panda pulito e infallibile. Spero che li troverai utili anche tu. Riceveremo aiuto dal classico “Codice pulito” di Robert C. Martin specifico per il contesto del pacchetto panda. TL;DR alla fine.

Cominciamo osservando alcuni modelli difettosi ispirati alla vita reale. Successivamente proveremo a riformulare quel codice in modo da favorire leggibilità e controllo.

Mutabilità

Panda DataFrames sono valore-mutevole (2, 3) oggetti. Ogni volta che modifichi un oggetto mutabile, influisce esattamente sulla stessa istanza che hai creato originariamente e la sua posizione fisica in memoria rimane invariata. Al contrario, quando modifichi un file immutabile oggetto (ad esempio una stringa), Python va a creare un oggetto completamente nuovo in una nuova posizione di memoria e scambia il riferimento con quello nuovo.

Questo è il punto cruciale: in Python gli oggetti vengono passati alla funzione per incarico (4, 5). Guarda il grafico: il valore di df è stato assegnato alla variabile in_df quando è stato passato alla funzione come argomento. Entrambi gli originali df e il in_df all'interno della funzione puntano alla stessa posizione di memoria (valore numerico tra parentesi), anche se hanno nomi di variabile diversi. Durante la modifica dei suoi attributi, la posizione dell'oggetto mutabile rimane invariata. Ora anche tutti gli altri oscilloscopi possono vedere le modifiche: raggiungono la stessa posizione di memoria.

Modifica di un oggetto mutabile nella memoria Python.

In realtà, poiché abbiamo modificato l'istanza originale, è ridondante restituire il file DataFrame e assegnarlo alla variabile. Questo codice ha esattamente lo stesso effetto:

Modifica di un oggetto mutabile nella memoria Python, assegnazione ridondante rimossa.

Attenzione: la funzione ora ritorna Nonequindi fai attenzione a non sovrascrivere il file df con None se esegui il compito: df = modify_df(df).

Al contrario, se l'oggetto è immutabile, cambierà la posizione della memoria durante la modifica proprio come nell'esempio seguente. Poiché la stringa rossa non può essere modificata (le stringhe sono immutabili), la stringa verde viene creata sopra quella vecchia, ma come un oggetto completamente nuovo, che rivendica una nuova posizione nella memoria. La stringa restituita non è la stessa stringa, mentre quella restituita DataFrame era esattamente lo stesso DataFrame.

Modifica di un oggetto immutabile nella memoria Python.

Il punto è mutare DataFrames le funzioni interne hanno a effetto globale. Se non lo tieni a mente, puoi:

  • modificare o rimuovere accidentalmente parte dei tuoi dati, pensando che l'azione si svolga solo all'interno dell'ambito della funzione – non è così,
  • perdere il controllo su ciò che viene aggiunto al tuo DataFrame e quando viene aggiunto, ad esempio nelle chiamate di funzioni annidate.

Argomenti di output

Risolveremo il problema più tardi, ma eccone un altro don't prima di passare a do'S

Il disegno della sezione precedente è in realtà un anti-modello chiamato argomento di output (1 pag.45). Tipicamente, input di una funzione verrà utilizzata per creare un file produzione valore. Se l'unico scopo di passare un argomento a una funzione è modificarlo, in modo che l'argomento di input cambi il suo stato, allora sta sfidando le nostre intuizioni. Tale comportamento si chiama effetto collaterale (1 p.44) di una funzione e questi dovrebbero essere ben documentati e ridotti al minimo perché costringono il programmatore a ricordare le cose che vanno in background, rendendo quindi lo script soggetto a errori.

Quando leggiamo una funzione, siamo abituati all'idea che le informazioni entrino nella funzione attraverso gli argomenti e escano attraverso il valore restituito. Di solito non ci aspettiamo che le informazioni escano attraverso le discussioni. (1 pag.41)

Le cose peggiorano ancora se la funzione ha una doppia responsabilità: modificare l'input E per restituire un output. Considera questa funzione:

def find_max_name_length(df: pd.DataFrame) -> int:
df("name_len") = df("name").str.len() # side effect
return max(df("name_len"))

Restituisce un valore come previsto, ma modifica anche in modo permanente l'originale DataFrame. L'effetto collaterale ti coglie di sorpresa: nulla nella firma della funzione indicava che i nostri dati di input sarebbero stati influenzati. Nel passaggio successivo vedremo come evitare questo tipo di progettazione.

Ridurre le modifiche

Per eliminare l'effetto collaterale, nel codice seguente abbiamo creato una nuova variabile temporanea invece di modificare l'originale DataFrame. La notazione lengths: pd.Series indica il tipo di dati della variabile.

def find_max_name_length(df: pd.DataFrame) -> int:
lengths: pd.Series = df("name").str.len()
return max(lengths)

Questa progettazione di funzioni è migliore in quanto incapsula lo stato intermedio invece di produrre un effetto collaterale.

Un altro avvertimento: si prega di prestare attenzione alle differenze tra copia profonda e superficiale (6) di elementi da DataFrame. Nell'esempio sopra abbiamo modificato ogni elemento dell'originale df("name") Seriesquindi il vecchio DataFrame e la nuova variabile non ha elementi condivisi. Tuttavia, se assegni direttamente una delle colonne originali a una nuova variabile, gli elementi sottostanti avranno ancora gli stessi riferimenti in memoria. Vedi gli esempi:

df = pd.DataFrame({"name": ("bert", "albert")})

series = df("name") # shallow copy
series(0) = "roberta" # <-- this changes the original DataFrame

series = df("name").copy(deep=True)
series(0) = "roberta" # <-- this does not change the original DataFrame

series = df("name").str.title() # not a copy whatsoever
series(0) = "roberta" # <-- this does not change the original DataFrame

È possibile stampare il DataFrame dopo ogni passaggio per osservare l'effetto. Ricorda che la creazione di una copia profonda allocherà nuova memoria, quindi è bene riflettere se il tuo script deve essere efficiente in termini di memoria.

Raggruppare operazioni simili

Forse per qualsiasi motivo desideri memorizzare il risultato del calcolo della lunghezza. Non è ancora una buona idea aggiungerlo a DataFrame all'interno della funzione a causa di effetto collaterale violazione nonché l'accumulo di molteplici responsabilità all'interno di un'unica funzione.

mi piace il Un livello di astrazione per funzione regola che dice:

Dobbiamo assicurarci che le istruzioni all'interno della nostra funzione siano tutte allo stesso livello di astrazione.

Mescolare livelli di astrazione all'interno di una funzione crea sempre confusione. I lettori potrebbero non essere in grado di capire se una particolare espressione è un concetto essenziale o un dettaglio. (1 pag.36)

Utilizziamo anche il Principio di responsabilità unica (1 p.138) da OOP, anche se al momento non ci stiamo concentrando sul codice orientato agli oggetti.

Perché non preparare i tuoi dati in anticipo? Dividiamo la preparazione dei dati e il calcolo vero e proprio in funzioni separate:

def create_name_len_col(series: pd.Series) -> pd.Series:
return series.str.len()

def find_max_element(collection: Collection) -> int:
return max(collection) if len(collection) else 0

df = pd.DataFrame({"name": ("bert", "albert")})
df("name_len") = create_name_len_col(df.name)
max_name_len = find_max_element(df.name_len)

Il compito individuale di creare il name_len la colonna è stata esternalizzata a un'altra funzione. Non modifica l'originale DataFrame e funziona un compito alla volta. Successivamente recuperiamo l'elemento max passando la nuova colonna ad un'altra funzione dedicata. Notare come la funzione di aggregazione sia generica Collections.

Rispolveriamo il codice con i seguenti passaggi:

  • Potremmo usare concat funzione ed estrarlo in una funzione separata chiamata prepare_datache raggrupperebbe tutte le fasi di preparazione dei dati in un unico posto,
  • Potremmo anche utilizzare il file apply metodo e lavorare invece su singoli testi Series di testi,
  • Ricordiamoci di utilizzare la copia superficiale o quella profonda, a seconda che i dati originali debbano o meno essere modificati:
def compute_length(word: str) -> int:
return len(word)

def prepare_data(df: pd.DataFrame) -> pd.DataFrame:
return pd.concat((
df.copy(deep=True), # deep copy
df.name.apply(compute_length).rename("name_len"),
...
), axis=1)

Riutilizzabilità

Il modo in cui abbiamo suddiviso il codice rende davvero semplice tornare allo script in un secondo momento, prendere l'intera funzione e riutilizzarla in un altro script. Ci piace!

C'è un'altra cosa che possiamo fare per aumentare il livello di riusabilità: passare i nomi delle colonne come parametri alle funzioni. Il refactoring è un po' esagerato, ma a volte paga per motivi di flessibilità o riusabilità.

def create_name_len_col(df: pd.DataFrame, orig_col: str, target_col: str) -> pd.Series:
return df(orig_col).str.len().rename(target_col)

name_label, name_len_label = "name", "name_len"
pd.concat((
df,
create_name_len_col(df, name_label, name_len_label)
), axis=1)

Testabilità

Hai mai scoperto che la tua preelaborazione era difettosa dopo settimane di esperimenti sul set di dati preelaborato? NO? Sei fortunato. In realtà ho dovuto ripetere una serie di esperimenti a causa di annotazioni interrotte, cosa che avrebbe potuto essere evitata se avessi testato solo un paio di funzioni di base.

Gli script importanti dovrebbero essere testato (1 p. 121, 7). Anche se lo script è solo un aiuto, ora provo a testare almeno le funzioni cruciali, quelle più di basso livello. Rivisitiamo i passaggi che abbiamo eseguito dall'inizio:

1. Non sono felice nemmeno di pensare di testarlo, è molto ridondante e abbiamo risolto gli effetti collaterali. Testa anche una serie di funzionalità diverse: il calcolo della lunghezza del nome e l'aggregazione del risultato per l'elemento max. Inoltre fallisce, l'avevi previsto?

def find_max_name_length(df: pd.DataFrame) -> int:
df("name_len") = df("name").str.len() # side effect
return max(df("name_len"))

@pytest.mark.parametrize("df, result", (
(pd.DataFrame({"name": ()}), 0), # oops, this fails!
(pd.DataFrame({"name": ("bert")}), 4),
(pd.DataFrame({"name": ("bert", "roberta")}), 7),
))
def test_find_max_name_length(df: pd.DataFrame, result: int):
assert find_max_name_length(df) == result

2. Così è molto meglio: ci siamo concentrati su un singolo compito, quindi il test è più semplice. Inoltre, non dobbiamo fissarci sui nomi delle colonne come facevamo prima. Tuttavia, penso che il formato dei dati ostacoli la verifica della correttezza del calcolo.

def create_name_len_col(series: pd.Series) -> pd.Series:
return series.str.len()

@pytest.mark.parametrize("series1, series2", (
(pd.Series(()), pd.Series(())),
(pd.Series(("bert")), pd.Series((4))),
(pd.Series(("bert", "roberta")), pd.Series((4, 7)))
))
def test_create_name_len_col(series1: pd.Series, series2: pd.Series):
pd.testing.assert_series_equal(create_name_len_col(series1), series2, check_dtype=False)

3. Qui abbiamo ripulito la scrivania. Testiamo la funzione di calcolo alla rovescia, lasciando dietro di noi la sovrapposizione dei panda. È più facile trovare casi limite quando ti concentri su una cosa alla volta. Ho capito che mi piacerebbe fare un test None valori che possono apparire nel file DataFrame e alla fine ho dovuto migliorare la mia funzione affinché il test passasse. Un bug catturato!

def compute_length(word: Optional(str)) -> int:
return len(word) if word else 0

@pytest.mark.parametrize("word, length", (
("", 0),
("bert", 4),
(None, 0)
))
def test_compute_length(word: str, length: int):
assert compute_length(word) == length

4. Ci manca solo il test per find_max_element:

def find_max_element(collection: Collection) -> int:
return max(collection) if len(collection) else 0

@pytest.mark.parametrize("collection, result", (
((), 0),
((4), 4),
((4, 7), 7),
(pd.Series((4, 7)), 7),
))
def test_find_max_element(collection: Collection, result: int):
assert find_max_element(collection) == result

Un ulteriore vantaggio dei test unitari che non dimentico mai di menzionare è che è un modo di documentare il tuo codicecome qualcuno che non lo sa (tipo Voi dal futuro) possono facilmente individuare gli input e gli output attesi, compresi i casi limite, semplicemente osservando i test. Doppio guadagno!

Questi sono alcuni trucchi che ho trovato utili durante la codifica e la revisione del codice di altre persone. Sono lungi dal dirti che l'uno o l'altro modo di codificare sia l'unico corretto: prendi quello che vuoi da esso, decidi se hai bisogno di un rapido graffio o di una base di codice altamente rifinita e testata. Spero che questo pensiero ti aiuti a strutturare i tuoi script in modo da essere più felice con loro e più sicuro della loro infallibilità.

Se ti è piaciuto questo articolo, mi piacerebbe saperlo. Buona programmazione!

TL;DR

Non esiste un solo e unico modo corretto di codificare, ma ecco alcune ispirazioni per lo scripting con i panda:

Non fare:

– non mutare il tuo DataFrame troppe funzioni interne, perché potresti perdere il controllo su cosa e dove viene aggiunto/rimosso da esso,

– non scrivere metodi che mutano a DataFrame e non restituire nulla perché crea confusione.

Cosa fare:

– creare nuovi oggetti invece di modificare la fonte DataFrame e ricordati di fare una copia approfondita quando necessario,

– eseguire solo operazioni di livello simile all'interno di una singola funzione,

– funzioni di progettazione per flessibilità e riusabilità,

– testa le tue funzioni perché questo ti aiuta a progettare un codice più pulito, a proteggerlo da bug e casi limite e a documentarlo gratuitamente.

I grafici sono stati creati da me utilizzando Mirò. Anche l'immagine di copertina è stata creata da me utilizzando il file Titanico set di dati e GIMP (effetto sbavatura).

Fonte: towardsdatascience.com

Lascia un commento

Il tuo indirizzo email non sarà pubblicato. I campi obbligatori sono contrassegnati *