Type-Hinting DataFrames per l’analisi statica e la validazione in fase di esecuzione

Type-Hinting DataFrames per un'analisi statica e una validazione in fase di esecuzione efficaci

Come StaticFrame consente Hint di Tipi Completi per DataFrame

Foto di autore

Dal momento dell’introduzione degli hint di tipo in Python 3.5, la definizione statica di un DataFrame era generalmente limitata alla specifica del solo tipo:

def process(f: DataFrame) -> Series: ...

Ciò è inadeguato, poiché ignora i tipi contenuti all’interno del contenitore. Un DataFrame potrebbe avere etichette di colonna di tipo stringa e tre colonne di valori intero, stringa e punto decimale; queste caratteristiche definiscono il tipo. Un argomento di funzione con tali hint di tipo fornisce agli sviluppatori, agli analizzatori statici e ai verificatori in esecuzione tutte le informazioni necessarie per comprendere le aspettative dell’interfaccia. StaticFrame 2 (un progetto open-source di cui sono il principale sviluppatore) ora permette questo:

from typing import Anyfrom static_frame import Frame, Index, TSeriesAnydef process(f: Frame[   # tipo del contenitore        Any,            # tipo delle etichette di indice        Index[np.str_], # tipo delle etichette di colonna        np.int_,        # tipo della prima colonna        np.str_,        # tipo della seconda colonna        np.float64,     # tipo della terza colonna        ]) -> TSeriesAny: ...

Ora tutti i contenitori core di StaticFrame supportano specifiche generiche. Pur essendo staticamente verificabili, un nuovo decorators, @CallGuard.check, consente la validazione in fase di esecuzione di questi hint di tipo sulle interfacce delle funzioni. Inoltre, utilizzando i generics Annotated, il nuovo Require class definisce una famiglia di validatori di runtime potenti, consentendo controlli dati per colonna o riga. Infine, ogni contenitore espone una nuova interfaccia via_type_clinic per derivare e validare gli hint di tipo. Insieme, questi strumenti offrono un approccio coeso per l’individuazione dei tipi e la validazione dei DataFrames.

Requisiti di un DataFrame Generico

I tipi generici integrati di Python (ad esempio, tuple o dict) richiedono la specifica dei tipi dei componenti (ad esempio, tuple[int, str, bool] o dict[str, int]). La definizione dei tipi dei componenti consente un’analisi statica più accurata. Anche se lo stesso vale per i DataFrames, sono stati fatti pochi tentativi per definire hint di tipo completi per i DataFrames.

Pandas, anche con il pacchetto pandas-stubs, non permette di specificare i tipi dei componenti di un DataFrame. Il DataFrame di Pandas, che consente una vasta mutabilità sul posto, potrebbe non essere sensibile alla tipizzazione statica. Fortunatamente, i DataFrames immutabili sono disponibili in StaticFrame.

Inoltre, gli strumenti forniti da Python per la definizione di generics, fino a poco tempo fa, non erano adatti ai DataFrames. Il fatto che un DataFrame abbia un numero variabile di tipi di colonne eterogenee rappresenta una sfida per la specifica generica. La specifica di una struttura del genere è diventata più semplice con il nuovo tipo di variabile TypeVarTuple, introdotto in Python 3.11 (e riportato nella libreria typing_extensions).

Un TypeVarTuple consente di definire generics che accettano un numero variabile di tipi. (Vedi PEP 646 per i dettagli.) Con questa nuova variabile di tipo, StaticFrame può definire un generico Frame con un TypeVar per l’indice, un TypeVar per le colonne e un TypeVarTuple per zero o più tipi di colonne.

Un generico Series è definito con un TypeVar per l’indice e un TypeVar per i valori. Anche Index e IndexHierarchy di StaticFrame sono generici, quest’ultimo sfrutta nuovamente TypeVarTuple per definire un numero variabile di componenti Index per ciascun livello di profondità.

StaticFrame utilizza tipi di NumPy per definire i tipi di colonne di un Frame, o i valori di un Series o Index. Ciò consente di specificare in modo preciso tipi numerici dimensionati, come np.uint8 o np.complex128; o di specificare ampiamente categorie di tipi, come np.integer o np.inexact. Poiché StaticFrame supporta tutti i tipi di NumPy, la corrispondenza è diretta.

Interfacce definite con DataFrame generici

Estendendo l’esempio sopra, l’interfaccia di funzione sottostante mostra un Frame con tre colonne trasformate in un dizionario di Serie. Con molte più informazioni fornite dai suggerimenti sul tipo di componente, lo scopo della funzione è quasi ovvio.

from typing import Anyfrom static_frame import Frame, Series, Index, IndexYearMonthdef process(f: Frame[        Any,        Index[np.str_],        np.int_,        np.str_,        np.float64,        ]) -> dict[                int,                Series[                 # tipo del contenitore                        IndexYearMonth, # tipo delle etichette dell'indice                        np.float64,     # tipo dei valori                        ],                ]: ...

Questa funzione elabora una tabella di segnali da un dataset di Asset Pricing Open Source (OSAP) (Caratteristiche a livello di azienda / Individuale / Previsioni). Ogni tabella ha tre colonne: identificativo di sicurezza (etichettato come “permno”), anno e mese (etichettato come “yyyymm”), e il segnale (con un nome specifico per il segnale).

La funzione ignora l’indice del Frame fornito (di tipo Any) e crea gruppi definiti dalla prima colonna “permno” con valori di tipo np.int_. Viene restituito un dizionario indicizzato per “permno”, dove ogni valore è una Serie di valori di tipo np.float64 per quel “permno”; l’indice è un IndexYearMonth creato dalla colonna np.str_ “yyyymm”. (StaticFrame utilizza valori di tipo datetime64 di NumPy per definire indici di tipo unità: IndexYearMonth memorizza etichette di tipo datetime64[M].)

Invece di restituire un dict, la funzione sottostante restituisce una Serie con un indice gerarchico. Il generico IndexHierarchy specifica un Index di componente per ogni livello di profondità; in questo caso, la profondità esterna è un Index[np.int_] (derivato dalla colonna “permno”), la profondità interna è un IndexYearMonth (derivato dalla colonna “yyyymm”).

from typing import Anyfrom static_frame import Frame, Series, Index, IndexYearMonth, IndexHierarchydef process(f: Frame[        Any,        Index[np.str_],        np.int_,        np.str_,        np.float64,        ]) -> Series[                    # tipo del contenitore                IndexHierarchy[          # tipo delle etichette dell'indice                        Index[np.int_],  # tipo dell'indice per profondità 0                        IndexYearMonth], # tipo dell'indice per profondità 1                np.float64,              # tipo dei valori                ]: ...

I suggerimenti sul tipo forniscono un’interfaccia auto-documentante che rende esplicita la funzionalità. Ancora meglio, questi suggerimenti sul tipo possono essere utilizzati per l’analisi statica con Pyright (ora) e Mypy (in attesa del pieno supporto di TypeVarTuple). Ad esempio, chiamare questa funzione con un Frame di due colonne di tipo np.float64 genererà un errore di tipo nel controllo dell’analisi statica o fornirà un avviso in un editor.

Convalida del tipo in fase di esecuzione

La verifica statica del tipo potrebbe non essere sufficiente: la valutazione in fase di runtime fornisce vincoli ancora più forti, specialmente per valori dinamici o per valori con suggerimenti di tipo incompleti (o errati).

Basandosi su un nuovo checker del tipo in fase di runtime chiamato TypeClinic, StaticFrame 2 introduce @CallGuard.check, un decoratore per la convalida in fase di runtime delle interfacce suggerite dal tipo. Sono supportati tutti i generici di StaticFrame e NumPy, e la maggior parte dei tipi incorporati di Python, anche quando sono annidati in modo profondo. La funzione sottostante aggiunge il decoratore @CallGuard.check.

from typing import Anyfrom static_frame import Frame, Series, Index, IndexYearMonth, IndexHierarchy, [email protected] process(f: Frame[        Any,        Index[np.str_],        np.int_,        np.str_,        np.float64,        ]) -> Series[                IndexHierarchy[Index[np.int_], IndexYearMonth],                np.float64,                ]: ...

Ora decorato con @CallGuard.check, se la funzione sopra viene chiamata con un Frame non etichettato di due colonne di np.float64, verrà sollevata un’eccezione ClinicError, illustrando che, dove ci si aspettava tre colonne, ne sono state fornite due, e dove si aspettavano etichette di colonne di tipo stringa, sono state fornite etichette di tipo intero. (Per emettere avvisi invece di sollevare eccezioni, utilizzare il decoratore @CallGuard.warn.)

ClinicError: negli argomenti di (f: Frame[Any, Index[str_], int64, str_, float64]) -> Series[IndexHierarchy[Index[int64], IndexYearMonth], float64]└── Frame[Any, Index[str_], int64, str_, float64]    └── Il Frame atteso ha 3 dtypes, il Frame fornito ha 2 dtypes negli argomenti di (f: Frame[Any, Index[str_], int64, str_, float64]) -> Series[IndexHierarchy[Index[int64], IndexYearMonth], float64]└── Frame[Any, Index[str_], int64, str_, float64]    └── Index[str_]        └── Previsto str_, fornito int64 non valido

Validazione dei dati in fase di esecuzione

Altre caratteristiche possono essere convalidate in fase di esecuzione. Ad esempio, gli attributi shape o name, o la sequenza di etichette sull’indice o sulle colonne. La classe Require di StaticFrame fornisce una famiglia di convalidatori configurabili.

  • Require.Name: Convalida l’attributo “name” del contenitore.
  • Require.Len: Convalida la lunghezza del contenitore.
  • Require.Shape: Convalida l’attributo “shape” del contenitore.
  • Require.LabelsOrder: Convalida l’ordine delle etichette.
  • Require.LabelsMatch: Convalida l’inclusione delle etichette indipendentemente dall’ordine.
  • Require.Apply: Applica una funzione che restituisce un booleano al contenitore.

In linea con una tendenza in crescita, questi oggetti vengono forniti all’interno degli aiuti di tipo come uno o più argomenti aggiuntivi ad un generico Annotated. (Vedere PEP 593 per i dettagli.) Il tipo citato dal primo argomento Annotated è il destinatario dei convalidatori che seguono come argomenti successivi. Ad esempio, se si sostituisce un suggerimento di tipo Index[np.str_] con un suggerimento di tipo Annotated[Index[np.str_], Require.Len(20)], la convalida della lunghezza in fase di esecuzione viene applicata all’indice associato al primo argomento.

Estendendo l’esempio di elaborazione di una tabella di segnali OSAP, potremmo convalidare le nostre aspettative per le etichette di colonna. Il convalidatore Require.LabelsOrder può definire una sequenza di etichette, utilizzando eventualmente ... per regioni contigue di etichette non specificate o più specifiche. Per specificare che le prime due colonne della tabella sono etichettate “permno” e “yyyymm”, mentre la terza etichetta è variabile (a seconda del segnale), il seguente Require.LabelsOrder può essere definito all’interno di un generico Annotated:

from typing import Any, Annotatedfrom static_frame import Frame, Series, Index, IndexYearMonth, IndexHierarchy, CallGuard, [email protected] process(f: Frame[        Any,        Annotated[                Index[np.str_],                Require.LabelsOrder('permno', 'yyyymm', ...),                ],        np.int_,        np.str_,        np.float64,        ]) -> Series[                IndexHierarchy[Index[np.int_], IndexYearMonth],                np.float64,                ]: ...

Se l’interfaccia si aspetta una piccola collezione di tabelle di segnali OSAP, possiamo convalidare la terza colonna con il convalidatore Require.LabelsMatch. Questo convalidatore può specificare le etichette richieste, insiemi di etichette (da cui almeno una deve corrispondere) e pattern di espressioni regolari. Se ci si aspetta solo tabelle da tre file (ossia “Mom12m.csv”, “Mom6m.csv” e “LRreversal.csv”), possiamo convalidare le etichette della terza colonna definendo Require.LabelsMatch con un insieme:

@CallGuard.checkdef process(f: Frame[        Any,        Annotated[                Index[np.str_],                Require.LabelsOrder('permno', 'yyyymm', ...),                Require.LabelsMatch({'Mom12m', 'Mom6m', 'LRreversal'}),                ],        np.int_,        np.str_,        np.float64,        ]) -> Series[                IndexHierarchy[Index[np.int_], IndexYearMonth],                np.float64,                ]: ...

Sia Require.LabelsOrder che Require.LabelsMatch possono associare funzioni alle specifiche dei label per convalidare i valori dei dati. Se il convalidatore viene applicato ai label delle colonne, una Series dei valori delle colonne sarà fornita alla funzione; se il convalidatore viene applicato ai label degli indici, una Series dei valori delle righe sarà fornita alla funzione.

Similmente all’uso di Annotated, il label specifier viene sostituito con una lista in cui il primo elemento è il label specifier, e i rimanenti elementi sono le funzioni di elaborazione delle righe o delle colonne che restituiscono un valore booleano.

Per estendere l’esempio precedente, potremmo convalidare che tutti i valori “permno” siano maggiori di zero e che tutti i valori del segnale (“Mom12m”, “Mom6m”, “LRreversal”) siano maggiori o uguali a -1.

from typing import Any, Annotatedfrom static_frame import Frame, Series, Index, IndexYearMonth, IndexHierarchy, CallGuard, [email protected] process(f: Frame[ Any, Annotated[ Index[np.str_], Require.LabelsOrder( ['permno', lambda s: (s > 0).all()], 'yyyymm', ..., ), Require.LabelsMatch( [{'Mom12m', 'Mom6m', 'LRreversal'}, lambda s: (s >= -1).all()], ), ], np.int_, np.str_, np.float64, ]) -> Series[ IndexHierarchy[Index[np.int_], IndexYearMonth], np.float64, ]: ...

Se una convalida non riesce, @CallGuard.check solleverà un’eccezione. Ad esempio, se la funzione precedente viene chiamata con un Frame che ha un inaspettato label nella terza colonna, verrà sollevata la seguente eccezione:

ClinicError:In args of (f: Frame[Any, Annotated[Index[str_], LabelsOrder(['permno', <lambda>], 'yyyymm', ...), LabelsMatch([{'Mom12m', 'LRreversal', 'Mom6m'}, <lambda>])], int64, str_, float64]) -> Series[IndexHierarchy[Index[int64], IndexYearMonth], float64]└── Frame[Any, Annotated[Index[str_], LabelsOrder(['permno', <lambda>], 'yyyymm', ...), LabelsMatch([{'Mom12m', 'LRreversal', 'Mom6m'}, <lambda>])], int64, str_, float64]    └── Annotated[Index[str_], LabelsOrder(['permno', <lambda>], 'yyyymm', ...), LabelsMatch([{'Mom12m', 'LRreversal', 'Mom6m'}, <lambda>])]        └── LabelsMatch([{'Mom12m', 'LRreversal', 'Mom6m'}, <lambda>])            └── Expected label to match frozenset({'Mom12m', 'LRreversal', 'Mom6m'}), no provided match

Il Potere Espressivo di TypeVarTuple

Come mostrato precedentemente, TypeVarTuple permette di specificare un Frame con zero o più tipi di colonne eterogenee. Ad esempio, possiamo fornire suggerimenti di tipo per un Frame con due tipi float o sei tipi misti:

>>> from typing import Any>>> from static_frame import Frame, Index>>> f1: sf.Frame[Any, Any, np.float64, np.float64]>>> f2: sf.Frame[Any, Any, np.bool_, np.float64, np.int8, np.int8, np.str_, np.datetime64]

Sebbene questo permetta l’inclusione di DataFrames diversi, dare suggerimenti di tipo a DataFrames larghi, come quelli con centinaia di colonne, sarebbe complicato. Python 3.11 introduce una nuova sintassi per fornire un insieme variabile di tipi nei generici di TypeVarTuple: espressioni di asterischi per gli alias generici di tuple. Ad esempio, per dare suggerimenti di tipo a un Frame con un indice di data, label delle colonne di tipo stringa e qualsiasi configurazione di tipi colonnari, possiamo scompattare una tuple di zero o più All:

>>> da typing import Any>>> import static_frame as sf>>> from static_frame import Frame, Index>>> f: sf.Frame[Index[np.datetime64], Index[np.str_], *tuple[All, ...]]

L’espressione stella tuple può essere inserita ovunque in un elenco di tipi, ma ne può essercene solo uno. Ad esempio, l’indicazione di tipo di seguito definisce un Frame che deve iniziare con colonne Booleane e stringhe ma ha una specifica flessibile per qualsiasi numero di colonne np.float64 successive.

>>> from typing import Any>>> from static_frame import Frame>>> f: sf.Frame[Any, Any, np.bool_, np.str_, *tuple[np.float64, ...]]

Utility per l’indicazione dei tipi

Lavorare con indicazioni di tipo così dettagliate può essere difficile. Per aiutare gli utenti, StaticFrame fornisce utility comode per indicazioni di tipo ed il controllo durante l’esecuzione. Tutti i contenitori di StaticFrame 2 presentano un’interfaccia via_type_clinic, che permette una vasta funzionalità di TypeClinic.

Innanzitutto, vengono fornite utility per tradurre un contenitore, ad esempio un Frame completo, in un’indicazione di tipo. La rappresentazione in stringa dell’interfaccia via_type_clinic fornisce una rappresentazione in stringa dell’indicazione di tipo del contenitore; in alternativa, il metodo to_hint() restituisce un oggetto alias generico completo.

>>> f = sf.Frame.from_records(([3, '192004', 0.3], [3, '192005', -0.4]), columns=('permno', 'yyyymm', 'Mom3m'))>>> f.via_type_clinicFrame[Index[int64], Index[str_], int64, str_, float64]>>> f.via_type_clinic.to_hint()static_frame.core.frame.Frame[static_frame.core.index.Index[numpy.int64], static_frame.core.index.Index[numpy.str_], numpy.int64, numpy.str_, numpy.float64]

In secondo luogo, vengono fornite utility per il test di indicazione di tipo durante l’esecuzione. La funzione via_type_clinic.check() permette di convalidare il contenitore rispetto ad una specifica indicazione di tipo fornita.

>>> f.via_type_clinic.check(sf.Frame[sf.Index[np.str_], sf.TIndexAny, *tuple[tp.Any, ...]])ClinicError:In Frame[Index[str_], Index[Any], Unpack[Tuple[Any, ...]]]└── Index[str_]    └── Expected str_, provided int64 invalid

Per supportare la scrittura graduale del codice, StaticFrame definisce diversi alias generici configurati con Any per ogni tipo di componente. Ad esempio, TFrameAny può essere utilizzato per qualsiasi Frame, e TSeriesAny per qualsiasi Series. Come previsto, TFrameAny convalida il Frame creato in precedenza.

>>> f.via_type_clinic.check(sf.TFrameAny)

Conclusione

È giunto il momento di avere indicazioni di tipo migliori per i DataFrames. Con gli strumenti di indicazione dei tipi di Python moderno e un DataFrame basato su un modello dati immutabile, StaticFrame 2 soddisfa questa esigenza, fornendo risorse potenti per gli ingegneri che danno priorità alla manutenibilità e alla verificabilità.