Cómo la especificación de tipos genéricos permite un análisis estático y una validación en tiempo de ejecución potentes
A medida que las herramientas para las anotaciones de tipo de Python (o sugerencias) han evolucionado, se pueden tipificar estructuras de datos más complejas, lo que mejora la capacidad de mantenimiento y el análisis estático. Las matrices y los marcos de datos, como contenedores complejos, solo recientemente han admitido anotaciones de tipo completas en Python. NumPy 1.22 introdujo la especificación genérica de matrices y tipos de datos. Basándose en la base de NumPy, Marco estático La versión 2.0 introdujo la especificación completa de tipos de DataFrames, empleando primitivas NumPy y genéricos variádicos. Este artículo demuestra enfoques prácticos para la inclusión total de sugerencias de tipos en matrices y DataFrames, y muestra cómo las mismas anotaciones pueden mejorar la calidad del código con análisis estático y validación en tiempo de ejecución.
StaticFrame es una biblioteca DataFrame de código abierto de la que soy autor.
Sugerencias de tipo (ver PEP484) mejoran la calidad del código de varias maneras. En lugar de usar nombres de variables o comentarios para comunicar tipos, las anotaciones de tipos basadas en objetos de Python proporcionan herramientas expresivas y fáciles de mantener para la especificación de tipos. Estas anotaciones de tipos se pueden probar con verificadores de tipos como mypy
o pyright
descubriendo rápidamente errores potenciales sin ejecutar código.
Las mismas anotaciones se pueden utilizar para la validación en tiempo de ejecución. Si bien en Python es común confiar en el tipado de patos en lugar de la validación en tiempo de ejecución, la validación en tiempo de ejecución se necesita con más frecuencia con estructuras de datos complejas, como matrices y DataFrames. Por ejemplo, una interfaz que espera un argumento DataFrame, si se le da una serie, podría no necesitar una validación explícita, ya que es probable que se produzca un error al usar el tipo incorrecto. Sin embargo, una interfaz que espera una matriz 2D de flotantes, si se le da una matriz de booleanos, podría beneficiarse de la validación, ya que es posible que no se produzca un error al usar el tipo incorrecto.
Muchas utilidades de tipeo importantes solo están disponibles con las versiones más recientes de Python. Afortunadamente, typing-extensions
El paquete incorpora utilidades de biblioteca estándar para versiones anteriores de Python. Un desafío relacionado es que los verificadores de tipos pueden tardar un tiempo en implementar soporte completo para nuevas características: muchos de los ejemplos que se muestran aquí requieren al menos mypy
1.9.0.
Sin anotaciones de tipo, la firma de una función Python no da ninguna indicación de los tipos esperados. Por ejemplo, la función que se muestra a continuación podría tomar y devolver cualquier tipo:
def process0(v, q): ... # no type information
Al agregar anotaciones de tipo, la firma informa a los lectores sobre los tipos esperados. Con Python moderno, se pueden usar clases definidas por el usuario e incorporadas para especificar tipos, con recursos adicionales (como Any
, Iterator
, cast()
y Annotated
) que se encuentra en la biblioteca estándar typing
módulo. Por ejemplo, la interfaz que se muestra a continuación mejora la anterior al hacer explícitos los tipos esperados:
def process0(v: int, q: bool) -> list(float): ...
Cuando se utiliza con un verificador de tipos como mypy
El código que viola las especificaciones de las anotaciones de tipo generará un error durante el análisis estático (que se muestra como comentarios a continuación). Por ejemplo, proporcionar un entero cuando se requiere un booleano es un error:
x = process0(v=5, q=20)
# tp.py: error: Argument "q" to "process0"
# has incompatible type "int"; expected "bool" (arg-type)
El análisis estático solo puede validar tipos definidos estáticamente. La gama completa de entradas y salidas en tiempo de ejecución suele ser más diversa, lo que sugiere alguna forma de validación en tiempo de ejecución. Lo mejor de ambos mundos es posible reutilizando las anotaciones de tipo para la validación en tiempo de ejecución. Si bien existen bibliotecas que hacen esto (por ejemplo, typeguard
y beartype
), StaticFrame ofrece CallGuard
una herramienta especializada para la validación integral de anotaciones de tipos de matrices y DataFrame.
Un decorador de Python es ideal para aprovechar las anotaciones para la validación en tiempo de ejecución. CallGuard
ofrece dos decoradores: twitter.com/CallGuard" rel="noopener ugc nofollow" target="_blank">@CallGuard.check
lo que plantea un interrogante informativo Exception
por error, o twitter.com/CallGuard" rel="noopener ugc nofollow" target="_blank">@CallGuard.warn
lo que emite una advertencia.
Ampliando aún más la process0
función anterior con twitter.com/CallGuard" rel="noopener ugc nofollow" target="_blank">@CallGuard.check
se pueden utilizar las mismas anotaciones de tipo para generar una Exception
(se muestra nuevamente como comentarios) cuando los objetos de tiempo de ejecución violan los requisitos de las anotaciones de tipo:
import static_frame as sf@sf.CallGuard.check
def process0(v: int, q: bool) -> list(float):
return (x * (0.5 if q else 0.25) for x in range(v))
z = process0(v=5, q=20)
# static_frame.core.type_clinic.ClinicError:
# In args of (v: int, q: bool) -> list(float)
# └── Expected bool, provided int invalid
Si bien las anotaciones de tipo deben ser válidas en Python, son irrelevantes en tiempo de ejecución y pueden ser incorrectas: es posible tener tipos verificados correctamente que no reflejen la realidad en tiempo de ejecución. Como se muestra arriba, reutilizar las anotaciones de tipo para las verificaciones en tiempo de ejecución garantiza que las anotaciones sean válidas.
Las clases de Python que permiten la especificación del tipo de componente son “genéricas”. Los tipos de componentes se especifican con “variables de tipo” posicionales. Una lista de números enteros, por ejemplo, se anota con list(int)
; un diccionario de flotantes codificados por tuplas de números enteros y cadenas está anotado dict(tuple(int, str), float)
.
Con NumPy 1.20, ndarray
y dtype
volverse genérico. El genérico ndarray
requiere dos argumentos, una forma y una dtype
Como el uso del primer argumento aún está en desarrollo, Any
se utiliza comúnmente. El segundo argumento, dtype
es en sí mismo un genérico que requiere una variable de tipo para un tipo NumPy como np.int64
NumPy también ofrece tipos genéricos más generales como np.integer(Any)
.
Por ejemplo, una matriz de valores booleanos está anotada np.ndarray(Any, np.dtype(np.bool_))
; una matriz de cualquier tipo de entero está anotada np.ndarray(Any, np.dtype(np.integer(Any)))
.
Como las anotaciones genéricas con especificaciones de tipo de componente pueden volverse muy extensas, resulta práctico almacenarlas como alias de tipo (aquí con el prefijo “T”). La siguiente función especifica dichos alias y luego los utiliza en una función.
from typing import Any
import numpy as npTNDArrayInt8 = np.ndarray(Any, np.dtype(np.int8))
TNDArrayBool = np.ndarray(Any, np.dtype(np.bool_))
TNDArrayFloat64 = np.ndarray(Any, np.dtype(np.float64))
def process1(
v: TNDArrayInt8,
q: TNDArrayBool,
) -> TNDArrayFloat64:
s: TNDArrayFloat64 = np.where(q, 0.5, 0.25)
return v * s
Como antes, cuando se utiliza con mypy
El código que viola las anotaciones de tipo generará un error durante el análisis estático. Por ejemplo, proporcionar un entero cuando se requiere un valor booleano es un error:
v1: TNDArrayInt8 = np.arange(20, dtype=np.int8)
x = process1(v1, v1)
# tp.py: error: Argument 2 to "process1" has incompatible type
# "ndarray(Any, dtype(floating(_64Bit)))"; expected "ndarray(Any, dtype(bool_))" (arg-type)
La interfaz requiere números enteros con signo de 8 bits (np.int8
); intentar utilizar un número entero de tamaño diferente también es un error:
TNDArrayInt64 = np.ndarray(Any, np.dtype(np.int64))
v2: TNDArrayInt64 = np.arange(20, dtype=np.int64)
q: TNDArrayBool = np.arange(20) % 3 == 0
x = process1(v2, q)
# tp.py: error: Argument 1 to "process1" has incompatible type
# "ndarray(Any, dtype(signedinteger(_64Bit)))"; expected "ndarray(Any, dtype(signedinteger(_8Bit)))" (arg-type)
Si bien algunas interfaces pueden beneficiarse de especificaciones de tipos numéricos tan estrechas, es posible realizar especificaciones más amplias con los tipos genéricos de NumPy, como np.integer(Any)
, np.signedinteger(Any)
, np.float(Any)
etc. Por ejemplo, podemos definir una nueva función que acepte números enteros con signo de cualquier tamaño. El análisis estático ahora pasa con ambos TNDArrayInt8
y TNDArrayInt64
matrices.
TNDArrayIntAny = np.ndarray(Any, np.dtype(np.signedinteger(Any)))
def process2(
v: TNDArrayIntAny, # a more flexible interface
q: TNDArrayBool,
) -> TNDArrayFloat64:
s: TNDArrayFloat64 = np.where(q, 0.5, 0.25)
return v * sx = process2(v1, q) # no mypy error
x = process2(v2, q) # no mypy error
Tal como se muestra arriba con los elementos, las matrices NumPy especificadas genéricamente se pueden validar en tiempo de ejecución si se decoran con CallGuard.check
:
@sf.CallGuard.check
def process3(v: TNDArrayIntAny, q: TNDArrayBool) -> TNDArrayFloat64:
s: TNDArrayFloat64 = np.where(q, 0.5, 0.25)
return v * sx = process3(v1, q) # no error, same as mypy
x = process3(v2, q) # no error, same as mypy
v3: TNDArrayFloat64 = np.arange(20, dtype=np.float64) * 0.5
x = process3(v3, q) # error
# static_frame.core.type_clinic.ClinicError:
# In args of (v: ndarray(Any, dtype(signedinteger(Any))),
# q: ndarray(Any, dtype(bool_))) -> ndarray(Any, dtype(float64))
# └── ndarray(Any, dtype(signedinteger(Any)))
# └── dtype(signedinteger(Any))
# └── Expected signedinteger, provided float64 invalid
StaticFrame proporciona utilidades para extender la validación en tiempo de ejecución más allá de la verificación de tipos. typing
Módulos Annotated
clase (ver PEP593), podemos ampliar la especificación de tipo con uno o más StaticFrame Require
objetos. Por ejemplo, para validar que una matriz tiene una forma unidimensional de (24,)
podemos reemplazar TNDArrayIntAny
con Annotated(TNDArrayIntAny, sf.Require.Shape(24))
Para validar que una matriz flotante no tiene NaN, podemos reemplazar TNDArrayFloat64
con Annotated(TNDArrayFloat64, sf.Require.Apply(lambda a: ~a.insna().any()))
.
Al implementar una nueva función, podemos requerir que todas las matrices de entrada y salida tengan la forma (24,)
Al llamar a esta función con las matrices creadas previamente se genera un error:
from typing import Annotated@sf.CallGuard.check
def process4(
v: Annotated(TNDArrayIntAny, sf.Require.Shape(24)),
q: Annotated(TNDArrayBool, sf.Require.Shape(24)),
) -> Annotated(TNDArrayFloat64, sf.Require.Shape(24)):
s: TNDArrayFloat64 = np.where(q, 0.5, 0.25)
return v * s
x = process4(v1, q) # types pass, but Require.Shape fails
# static_frame.core.type_clinic.ClinicError:
# In args of (v: Annotated(ndarray(Any, dtype(int8)), Shape((24,))), q: Annotated(ndarray(Any, dtype(bool_)), Shape((24,)))) -> Annotated(ndarray(Any, dtype(float64)), Shape((24,)))
# └── Annotated(ndarray(Any, dtype(int8)), Shape((24,)))
# └── Shape((24,))
# └── Expected shape ((24,)), provided shape (20,)
Al igual que un diccionario, un DataFrame es una estructura de datos compleja compuesta por muchos tipos de componentes: las etiquetas de índice, las etiquetas de columna y los valores de columna son todos tipos distintos.
Un desafío de especificar de manera genérica un DataFrame es que un DataFrame tiene una cantidad variable de columnas, donde cada columna puede ser de un tipo diferente. Python TypeVarTuple
especificador genérico variádico (ver PEP646), lanzado por primera vez en Python 3.11, permite definir una cantidad variable de variables de tipo de columna.
Con StaticFrame 2.0, Frame
, Series
, Index
y los contenedores relacionados se vuelven genéricos. El soporte para definiciones de tipos de columnas variables se proporciona mediante TypeVarTuple
retroportado con la implementación en typing-extensions
para compatibilidad hasta Python 3.9.
Un genérico Frame
requiere dos o más variables de tipo: el tipo de índice, el tipo de columnas y cero o más especificaciones de tipos de valores de columnas especificados con tipos NumPy. Un genérico Series
requiere dos variables de tipo: el tipo del índice y un tipo NumPy para los valores. Index
es en sí mismo genérico y también requiere un tipo NumPy como variable de tipo.
Con especificación genérica, una Series
de flotantes, indexados por fechas, se pueden anotar con sf.Series(sf.IndexDate, np.float64)
. A Frame
con fechas como etiquetas de índice, cadenas como etiquetas de columna y valores de columna de números enteros y flotantes que se pueden anotar con sf.Frame(sf.IndexDate, sf.Index(np.str_), np.int64, np.float64)
.
Dado un complejo Frame
derivar la anotación puede resultar difícil. StaticFrame ofrece la via_type_clinic
Interfaz para proporcionar una especificación genérica completa para cualquier componente en tiempo de ejecución:
>>> v4 = sf.Frame.from_fields((range(5), np.arange(3, 8) * 0.5),
columns=('a', 'b'), index=sf.IndexDate.from_date_range('2021-12-30', '2022-01-03'))
>>> v4
a b <
2021-12-30 0 1.5
2021-12-31 1 2.0
2022-01-01 2 2.5
2022-01-02 3 3.0
2022-01-03 4 3.5
# get a string representation of the annotation
>>> v4.via_type_clinic
Frame(IndexDate, Index(str_), int64, float64)
Como se muestra con las matrices, almacenar anotaciones como alias de tipo permite la reutilización y firmas de funciones más concisas. A continuación, se define una nueva función con un carácter genérico. Frame
y Series
argumentos completamente anotados. cast
es necesario ya que no todas las operaciones pueden resolver estáticamente su tipo de retorno.
TFrameDateInts = sf.Frame(sf.IndexDate, sf.Index(np.str_), np.int64, np.int64)
TSeriesYMBool = sf.Series(sf.IndexYearMonth, np.bool_)
TSeriesDFloat = sf.Series(sf.IndexDate, np.float64)def process5(v: TFrameDateInts, q: TSeriesYMBool) -> TSeriesDFloat:
t = v.index.iter_label().apply(lambda l: q(l.astype('datetime64(M)'))) # type: ignore
s = np.where(t, 0.5, 0.25)
return cast(TSeriesDFloat, (v.via_T * s).mean(axis=1))
Estas interfaces anotadas más complejas también se pueden validar con mypy
Abajo, una Frame
sin que se pasen los tipos de valores de columna esperados, lo que provoca mypy
a error (mostrado como comentarios, abajo).
TFrameDateIntFloat = sf.Frame(sf.IndexDate, sf.Index(np.str_), np.int64, np.float64)
v5: TFrameDateIntFloat = sf.Frame.from_fields((range(5), np.arange(3, 8) * 0.5),
columns=('a', 'b'), index=sf.IndexDate.from_date_range('2021-12-30', '2022-01-03'))q: TSeriesYMBool = sf.Series((True, False),
index=sf.IndexYearMonth.from_date_range('2021-12', '2022-01'))
x = process5(v5, q)
# tp.py: error: Argument 1 to "process5" has incompatible type
# "Frame(IndexDate, Index(str_), signedinteger(_64Bit), floating(_64Bit))"; expected
# "Frame(IndexDate, Index(str_), signedinteger(_64Bit), signedinteger(_64Bit))" (arg-type)
Para utilizar las mismas sugerencias de tipo para la validación en tiempo de ejecución, el sf.CallGuard.check
Se puede aplicar un decorador. A continuación, se muestra un Frame
Se proporciona una columna de tres números enteros donde Frame
Se espera de dos columnas.
# a Frame of three columns of integers
TFrameDateIntIntInt = sf.Frame(sf.IndexDate, sf.Index(np.str_), np.int64, np.int64, np.int64)
v6: TFrameDateIntIntInt = sf.Frame.from_fields((range(5), range(3, 8), range(1, 6)),
columns=('a', 'b', 'c'), index=sf.IndexDate.from_date_range('2021-12-30', '2022-01-03'))x = process5(v6, q)
# static_frame.core.type_clinic.ClinicError:
# In args of (v: Frame(IndexDate, Index(str_), signedinteger(_64Bit), signedinteger(_64Bit)),
# q: Series(IndexYearMonth, bool_)) -> Series(IndexDate, float64)
# └── Frame(IndexDate, Index(str_), signedinteger(_64Bit), signedinteger(_64Bit))
# └── Expected Frame has 2 dtype, provided Frame has 3 dtype
Puede que no sea práctico anotar cada columna de cada Frame
:es común que las interfaces funcionen con Frame
de tamaños de columna variables. TypeVarTuple
Esto se apoya mediante el uso de *tuple()
expresiones (introducidas en Python 3.11, retroportadas con el Unpack
Por ejemplo, la función anterior podría definirse para tomar cualquier número de columnas enteras con esa anotación. Frame(IndexDate, Index(np.str_), *tuple(np.int64, ...))
dónde *tuple(np.int64, ...))
significa cero o más columnas de números enteros.
La misma implementación se puede anotar con una especificación mucho más general de tipos de columnas. A continuación, los valores de columna se anotan con np.number(Any)
(permitiendo cualquier tipo de tipo numérico NumPy) y un *tuple()
expresión (permitiendo cualquier número de columnas): *tuple(np.number(Any), …)
. Ahora tampoco mypy
ni CallGuard
errores con cualquiera de los creados previamente Frame
.
TFrameDateNums = sf.Frame(sf.IndexDate, sf.Index(np.str_), *tuple(np.number(Any), ...))@sf.CallGuard.check
def process6(v: TFrameDateNums, q: TSeriesYMBool) -> TSeriesDFloat:
t = v.index.iter_label().apply(lambda l: q(l.astype('datetime64(M)'))) # type: ignore
s = np.where(t, 0.5, 0.25)
return tp.cast(TSeriesDFloat, (v.via_T * s).mean(axis=1))
x = process6(v5, q) # a Frame with integer, float columns passes
x = process6(v6, q) # a Frame with three integer columns passes
Al igual que con las matrices NumPy, Frame
Las anotaciones pueden envolver Require
especificaciones en Annotated
genéricos, lo que permite la definición de validaciones en tiempo de ejecución adicionales.
Si bien StaticFrame podría ser la primera biblioteca DataFrame en ofrecer una especificación genérica completa y una solución unificada tanto para el análisis de tipos estáticos como para la validación de tipos en tiempo de ejecución, otras bibliotecas de matrices y DataFrame ofrecen utilidades relacionadas.
Ni el Tensor
clase en PyTorch (2.4.0), ni la Tensor
La clase de TensorFlow (2.17.0) admite especificaciones de formas o tipos genéricos. Si bien ambas bibliotecas ofrecen una TensorSpec
objeto que se puede utilizar para realizar validación de tipo y forma en tiempo de ejecución, verificación de tipo estático con herramientas como mypy
No es compatible.
A partir de Pandas 2.2.2, ni Pandas Series
ni DataFrame
Admite especificaciones de tipos genéricos. Varios paquetes de terceros han ofrecido soluciones parciales. pandas-stubs
La biblioteca, por ejemplo, proporciona anotaciones de tipo para la API de Pandas, pero no realiza las Series
o DataFrame
Clases genéricas. La biblioteca Pandera permite definir DataFrameSchema
Clases que se pueden utilizar para la validación en tiempo de ejecución de Pandas DataFrames. Para el análisis estático con mypy
Pandera ofrece alternativas DataFrame
y Series
subclases que permiten la especificación genérica con el mismo DataFrameSchema
clases. Este enfoque no permite las oportunidades expresivas de usar tipos genéricos de NumPy o el operador de desempaquetado para proporcionar expresiones genéricas variádicas.
Las anotaciones de tipos de Python pueden hacer que el análisis estático de tipos sea una valiosa comprobación de la calidad del código, ya que permite descubrir errores incluso antes de que se ejecute el código. Hasta hace poco, una interfaz podía utilizar una matriz o un DataFrame, pero no era posible especificar los tipos contenidos en esos contenedores. Ahora, es posible especificar por completo los tipos de componentes en NumPy y StaticFrame, lo que permite realizar un análisis estático de tipos más potente.
Proporcionar anotaciones de tipo correctas es una inversión. Reutilizar esas anotaciones para verificaciones en tiempo de ejecución ofrece lo mejor de ambos mundos. CallGuard
El verificador de tipos en tiempo de ejecución está especializado para evaluar correctamente tipos NumPy genéricos completamente especificados, así como todos los contenedores StaticFrame genéricos.
<script async src="//platform.twitter.com/widgets.js” charset=”utf-8″>