1 Ixv8k8g0ccxnhxsepgqsw.png

Guiones alrededor de un panda DataFrame puede convertirse en una incómoda pila de (no tan) buen código de espagueti antiguo. Mis colegas y yo usamos mucho este paquete y, aunque intentamos seguir buenas prácticas de programación, como dividir el código en módulos y pruebas unitarias, a veces todavía nos interponemos unos a otros al producir código confuso.

He reunido algunos consejos y errores que debo evitar para que el código Pandas sea limpio e infalible. Esperemos que usted también los encuentre útiles. Recibiremos ayuda del clásico “Código limpio” de Robert C. Martin específicamente para el contexto del paquete pandas. TL; DR al final.

Comencemos observando algunos patrones defectuosos inspirados en la vida real. Más adelante intentaremos reformular ese código para favorecer la legibilidad y el control.

Mutabilidad

pandas DataFrames son valor-mudable [2, 3] objetos. Cada vez que modifica un objeto mutable, afecta exactamente a la misma instancia que creó originalmente y su ubicación física en la memoria permanece sin cambios. Por el contrario, cuando modificas un inmutable objeto (por ejemplo, una cadena), Python crea un objeto completamente nuevo en una nueva ubicación de memoria e intercambia la referencia por la nueva.

Este es el punto crucial: en Python, los objetos se pasan a la función por asignación [4, 5]. Vea el gráfico: el valor de df ha sido asignado a la variable in_df cuando se pasó a la función como argumento. Tanto el original df y el in_df dentro de la función apuntan a la misma ubicación de memoria (valor numérico entre paréntesis), incluso si tienen nombres de variables diferentes. Durante la modificación de sus atributos, la ubicación del objeto mutable permanece sin cambios. Ahora todos los demás ámbitos también pueden ver los cambios: llegan a la misma ubicación de memoria.

Modificación de un objeto mutable en la memoria de Python.

En realidad, dado que hemos modificado la instancia original, es redundante devolver el DataFrame y asignarlo a la variable. Este código tiene exactamente el mismo efecto:

Modificación de un objeto mutable en la memoria de Python, se eliminó la asignación redundante.

Aviso: la función ahora regresa Noneasí que tenga cuidado de no sobrescribir el df con None si realiza la tarea: df = modify_df(df).

Por el contrario, si el objeto es inmutable, cambiará la ubicación de la memoria durante la modificación, como en el siguiente ejemplo. Dado que la cadena roja no se puede modificar (las cadenas son inmutables), la cadena verde se crea encima de la anterior, pero como un objeto completamente nuevo, reclamando una nueva ubicación en la memoria. La cadena devuelta no es la misma cadena, mientras que la cadena devuelta DataFrame era exactamente lo mismo DataFrame.

Modificación de un objeto inmutable en la memoria de Python.

El punto es, mutar DataFrames funciones internas tiene un efecto global. Si no tienes esto en cuenta, puedes:

  • modificar o eliminar accidentalmente parte de sus datos, pensando que la acción solo se lleva a cabo dentro del alcance de la función; no es así,
  • perder el control sobre lo que se agrega a su DataFrame y cuando se agrega, por ejemplo, en llamadas a funciones anidadas.

Argumentos de salida

Arreglaremos ese problema más tarde, pero aquí hay otro. don't antes de pasar a do‘s

El diseño de la sección anterior es en realidad un antipatrón llamado argumento de salida [1 p.45]. Típicamente, entradas de una función se utilizará para crear una producción valor. Si el único objetivo de pasar un argumento a una función es modificarlo, de modo que el argumento de entrada cambie su estado, entonces está desafiando nuestras intuiciones. Tal comportamiento se llama efecto secundario [1 p.44] de una función y deben estar bien documentados y minimizados porque obligan al programador a recordar las cosas que suceden en segundo plano, lo que hace que el script sea propenso a errores.

Cuando leemos una función, estamos acostumbrados a la idea de que la información ingresa a la función a través de argumentos y sale a través del valor de retorno. Generalmente no esperamos que salga información a través de los argumentos. [1 p.41]

La cosa empeora aún más si la función tiene una doble responsabilidad: modificar la entrada y para devolver una salida. Considere esta función:

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

Devuelve un valor como era de esperar, pero también modifica permanentemente el original. DataFrame. El efecto secundario te toma por sorpresa: nada en la firma de la función indicaba que nuestros datos de entrada fueran a verse afectados. En el siguiente paso, veremos cómo evitar este tipo de diseño.

Reducir modificaciones

Para eliminar el efecto secundario, en el código siguiente hemos creado una nueva variable temporal en lugar de modificar la original. DataFrame. la notación lengths: pd.Series indica el tipo de datos de la variable.

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

Este diseño de función es mejor porque encapsula el estado intermedio en lugar de producir un efecto secundario.

Otro aviso: tenga en cuenta las diferencias entre copia profunda y superficial [6] de elementos de la DataFrame. En el ejemplo anterior hemos modificado cada elemento del original. df["name"] Seriesentonces el viejo DataFrame y la nueva variable no tienen elementos compartidos. Sin embargo, si asigna directamente una de las columnas originales a una nueva variable, los elementos subyacentes seguirán teniendo las mismas referencias en la memoria. Vea los ejemplos:

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

Puedes imprimir el DataFrame después de cada paso para observar el efecto. Recuerde que la creación de una copia profunda asignará nueva memoria, por lo que es bueno reflexionar si su secuencia de comandos necesita ser eficiente en memoria.

Agrupar operaciones similares

Quizás por alguna razón desee almacenar el resultado de ese cálculo de longitud. Todavía no es una buena idea agregarlo al DataFrame dentro de la función debido a la efecto secundario incumplimiento así como la acumulación de múltiples responsabilidades dentro de una sola función.

me gusta el Un nivel de abstracción por función regla que dice:

Necesitamos asegurarnos de que las declaraciones dentro de nuestra función estén todas en el mismo nivel de abstracción.

Mezclar niveles de abstracción dentro de una función siempre resulta confuso. Es posible que los lectores no puedan distinguir si una expresión particular es un concepto esencial o un detalle. [1 p.36]

También empleemos el Principio de responsabilidad única [1 p.138] de programación orientada a objetos, aunque no nos estamos centrando en el código orientado a objetos en este momento.

¿Por qué no preparar sus datos de antemano? Dividamos la preparación de datos y el cálculo real en funciones separadas:

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)

La tarea individual de crear el name_len La columna se ha subcontratado a otra función. No modifica el original. DataFrame y se desempeña una tarea a la vez. Luego recuperamos el elemento máximo pasando la nueva columna a otra función dedicada. Observe cómo la función de agregación es genérica para Collections.

Repasemos el código con los siguientes pasos:

  • podríamos usar concat función y extraerla a una función separada llamada prepare_dataque agruparía todos los pasos de preparación de datos en un solo lugar,
  • También podríamos hacer uso de la apply método y trabajar en textos individuales en lugar de Series de textos,
  • Recordemos usar copia superficial versus copia profunda, dependiendo de si los datos originales deben modificarse o no:
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)

Reutilizabilidad

La forma en que hemos dividido el código realmente hace que sea más fácil volver al script más tarde, tomar la función completa y reutilizarla en otro script. ¡Nos gusta eso!

Hay una cosa más que podemos hacer para aumentar el nivel de reutilización: pasar nombres de columnas como parámetros a funciones. La refactorización es un poco exagerada, pero a veces vale la pena en aras de la flexibilidad o la reutilización.

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)

Capacidad de prueba

¿Alguna vez se dio cuenta de que su preprocesamiento era defectuoso después de semanas de experimentos en el conjunto de datos preprocesado? ¿No? Eres afortunado. De hecho, tuve que repetir una serie de experimentos debido a anotaciones rotas, lo que podría haberse evitado si hubiera probado solo un par de funciones básicas.

Los guiones importantes deben ser probado [1 p.121, 7]. Incluso si el script es sólo una ayuda, ahora intento probar al menos las funciones cruciales y de bajo nivel. Repasemos los pasos que hicimos desde el principio:

1. No estoy feliz de siquiera pensar en probar esto, es muy redundante y hemos superado el efecto secundario. También prueba un montón de características diferentes: el cálculo de la longitud del nombre y la agregación del resultado para el elemento máximo. Además falla, ¿lo veías venir?

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. Esto es mucho mejor: nos hemos centrado en una sola tarea, por lo que la prueba es más sencilla. Tampoco tenemos que fijarnos en los nombres de las columnas como lo hacíamos antes. Sin embargo, creo que el formato de los datos obstaculiza la verificación de la exactitud del cálculo.

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. Aquí hemos limpiado el escritorio. Probamos la función de cálculo al revés, dejando atrás la superposición de pandas. Es más fácil encontrar casos extremos cuando te concentras en una cosa a la vez. Descubrí que me gustaría hacer la prueba None valores que pueden aparecer en el DataFrame y eventualmente tuve que mejorar mi función para pasar esa prueba. ¡Un error atrapado!

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. Sólo nos falta la prueba de 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 beneficio adicional de las pruebas unitarias que nunca olvido mencionar es que es una forma de documentando su códigocomo alguien que no lo sabe (como del futuro) puede determinar fácilmente las entradas y salidas esperadas, incluidos los casos extremos, con solo mirar las pruebas. ¡Doble ganancia!

Estos son algunos trucos que encontré útiles mientras codificaba y revisaba el código de otras personas. Estoy lejos de decirte que una u otra forma de codificación es la única correcta: tomas lo que quieres de ella, decides si necesitas un borrador rápido o una base de código altamente pulida y probada. Espero que este artículo de reflexión te ayude a estructurar tus guiones para que estés más satisfecho con ellos y tengas más confianza en su infalibilidad.

Si te gustó este artículo, me encantaría saberlo. ¡Feliz codificación!

TL;DR

No existe una única forma correcta de codificar, pero aquí hay algunas inspiraciones para crear secuencias de comandos con pandas:

Lo que no debes hacer:

– no mutes tu DataFrame demasiadas funciones internas, porque puede perder el control sobre qué y dónde se agrega o elimina,

– no escribas métodos que muten un DataFrame y no devolver nada porque eso es confuso.

Qué hacer:

– crear nuevos objetos en lugar de modificar la fuente DataFrame y recuerda hacer una copia profunda cuando sea necesario,

– realizar sólo operaciones de nivel similar dentro de una única función,

– funciones de diseño para flexibilidad y reutilización,

– Pruebe sus funciones porque esto le ayuda a diseñar un código más limpio, protegerlo contra errores y casos extremos y documentarlo de forma gratuita.

Los gráficos fueron creados por mí usando Miró. La imagen de portada también fue creada por mí usando el Titánico conjunto de datos y GIMP (efecto difuminado).