Pr贸logo

Recientemente me un铆 al servidor de Discord de Python en Espa帽ol al igual que otras mil personas, mas o menos. Es una comunidad activa especialmente en los canales de #ayuda donde mucha gente comparte problemas que tienen con su c贸digo y varias personas les ofrecen soluciones. Entre estas soluciones existe mucha variaci贸n ya que python existe como un lenguage de programaci贸n muy general y sirve para casi todo entonces todxs en el servidor tenemos experiencias distintas. Yo, por ejemplo, vengo de el campo de Econom铆a y Relaciones Internacionales (que estudi茅 en la universidad) - ciencias sociales. No empec茅 a programar profesionalmente hasta despu茅s de graduarme. Escribo python para an谩lisis de datos, antes de esto escrib铆a Stata. Conozco suficiente de R para defenderme y JavaScript para presumir una que otra visualizaci贸n de d3 en Observable. Esto significa que cuando escribo python tengo ciertos sesgos y abordo problemas de cierta manera. Esta manera, parece ser, es un poco distinta a la manera que otras programadorxs resuelven problemas.

Un poco de historia

El creador de la biblioteca se llama Wes McKinney comenz贸 a trabajar en ella el 6 de abril del 2008 porque quer铆a una herramienta flexible y r谩pida para el an谩lisis de datos en su trabajo como analista en AQR (una empresa que maneja capital para inversionistas). El nombre pandas viene del termino de econometr铆a Panel Data. Yo creo que es importante tener en mente esta historia cuando trabajamos con pandas ya que muchas veces pandas no funciona como otras bibliotecas de python creadas por y para desarrolladorxs. He visto que a muchas personas les cuesta trabajo "pensar" en pandas si vienen con experiencia exclusivamente de programadorxs.

Si les interesa, esta entrevista para el podcast Python.init esta buena :eyes:

pandas.DataFrame.apply()

Los DataFrames de pandas tienen el m茅todo .apply() el cual te permite aplicar una funci贸n a lo largo de un eje del DataFrame. Es decir, tienes una funci贸n que hace algo, le quieres pasar cada una de las filas o columnas de tu DataFrame para que les haga ese "algo" y te las regrese "transformadas."

.apply() puedes aplicarlo a cada celda de una columna (axis = 0) o a cada celda de una fila (axis = 1). Tambi茅n recibe otros argumentos como raw (que puedes cambiar a True si quieres que el objeto al que le apliques tu funci贸n sea un ndarray de NumPy en lugar de una Serie de pandas), result_type (que cambia el resultado de tu funci贸n expandiendo listas a columnas, por ejemplo), y args (los argumentos extras que le quieres pasar a tu funci贸n que vas a aplicar a las celdas de tu DataFrame), ademas de los benditos **kwds que son keyword arguments u otros argumentos que le pasar谩s a tu funci贸n.

Te menciono este m茅todo del DataFrame ya que es la funci贸n .apply() m谩s extensiva pero usualmente la funci贸n .apply() que vemos es la de las Series de pandas!

pandas.Series.apply()

La cual es muy parecida pero recibe menos argumentos y su documentaci贸n oficial dice "invoca una funci贸n en los valores de una Series" lo cual es un tantito distinto a la .apply() de los DataFrames.

La .apply() de las series recibe un valor de una Series de pandas, le pasa ese valor a una funci贸n y te regresa ese valor "transformado." Recibe los mismos argumentos que la de DataFrames excepto por raw ya que le estas pasando la misma Series.

Un ejemplo extremadamente b谩sico

Digamos que tienes un DataFrame como el siguiente y quieres transformar una de las columnas:

import pandas as pd
ejemplo_1_datos = pd.read_csv("https://raw.githubusercontent.com/chekos/datasets/master/data/pib.csv")
ejemplo_1_datos.head()
periodo poblacion PIB
0 1960 34923129 1.304000e+10
1 1970 48225238 3.552000e+10
2 1980 66846833 1.943480e+11
3 1990 81249645 2.627100e+11
4 1995 91158290 3.600740e+11

Imagina que eres nueva en esto de la programaci贸n y no conoces mucho las bibliotecas de pandas y NumPy y decides crear tu propia funci贸n para dividir el PIB (producto interno bruto) en un mill贸n (para cambiar la unidad).

def entre_un_millon(numero):
    """Recibe un n煤mero y lo divide por un mill贸n."""
    return numero / 1_000_000 

:bulb: Nota: A partir de python 3.6 puedes separar n煤meros grandes con _ para que sean m谩s facil de leer.

Ya creada tu funci贸n puedes aplicarla a una columna de la siguiente manera

ejemplo_1_datos['PIB'].apply(entre_un_millon)
0      13040.0
1      35520.0
2     194348.0
3     262710.0
4     360074.0
5     707907.0
6     877476.0
7    1057800.0
8    1169620.0
Name: PIB, dtype: float64

Esencialmente esto es lo que hace .apply(). Y puede que estes pensando ok.. 驴cu谩l es el problema? En realidad, .apply() no es el problema que he notado en el mundo de python. El problema no es problema, dijo Arjona :joy:.

El m茅todo .apply() es 煤til y te sirve para extender las funcionalidades de pandas y NumPy. El 茅nfasis esta en extender.

Este ejemplo es probablemente poco com煤n y la es poco probable que ver铆as este tipo de c贸digo en "la vida real." Claro que si eres alguien que apenas esta comenzando y/o est谩s practicando crear funciones, o algo as铆, esto es completamente v谩lido.

Eventualmente, si trabajas con pandas o NumPy, aprender谩s que las Series son vectores y puedes aplicarles funciones matem谩ticas de manera mucho m谩s eficiente de otras maneras. Por ejemplo,

ejemplo_1_datos['PIB'] / 1_000_000
0      13040.0
1      35520.0
2     194348.0
3     262710.0
4     360074.0
5     707907.0
6     877476.0
7    1057800.0
8    1169620.0
Name: PIB, dtype: float64

pandas (gracias a que utiliza ndarrays de NumPy para sus Series) simplemente entiende que si divides una columna por un valor lo que quieres es dividir cada uno de los valores de esa columna por ese valor.

Aqu铆 es donde se pone interesante la cosa. Si tienes 8 columnas, crear tu propia funci贸n que puede que ya exista en pandas puede ahorrarte el tiempo de buscarla en la documentaci贸n y obtengas los mismos resultados en una cantidad menor de tiempo. Pero, t茅cnicamente, .apply() toma m谩s tiempo que las funciones nativas de NumPy y pandas ya que estas est谩n optimizadas para trabajar con vectores (o ndarrays). Veamos,

%%timeit
ejemplo_1_datos['PIB'].apply(entre_un_millon)
154 碌s 卤 6.33 碌s per loop (mean 卤 std. dev. of 7 runs, 10000 loops each)
%%timeit
ejemplo_1_datos['PIB'] / 1_000_000
93.6 碌s 卤 8.48 碌s per loop (mean 卤 std. dev. of 7 runs, 10000 loops each)

Utilizando .apply() te tardas mas o menos 190 卤 30.3 microsegundos y al dividir utilizando solo el s铆mbolo / te tardas 91.7 卤 2.74 microsegundos. O te ahorras un poco mas de la mitad de tiempo.

Obviamente, el hecho de que este "ahorro" de tiempo sea en microsegundos es muy importante: Tu cerebro es incapaz de notar la diferencia. Ese "ahorro" del 52% de tiempo solo importa si te vas a tardar 29 minutos en lugar de una hora o uno en lugar de dos d铆as ejecutando tu c贸digo. Esto solo sucede si tienes una cantidad enorme de datos y si estas trabajando con tantos datos probablemente estes haciendo calculos m谩s complicados que dividir entre un mill贸n.

As铆 que la primera conclusi贸n de este blog es que la verdad es mucho mas importante la legibilidad de tu c贸digo a que sea la manera 贸ptima de trabajar con tus datos si est谩s trabajando con pocos datos. Pocos es relativo, 8 filas es poco pero yo he trabajado con conjuntos de datos que tienen 8 millones de filas y mi computadora los maneja muy bien as铆 que podr铆a decirse que tambi茅n eso es poco. "Pocos" datos son los que puedes manipular libremente en tu computadora sin que se congele o se vuela lenta mientras trabajas con ellos.

Ejemplo 2: Re-etiquetar datos

Un proceso com煤n en el an谩lisis de datos es "re-etiquetar" columnas. En mi caso, como cient铆fico social trabajo mucho con ciertos atributos de poblaciones (sexo, edad, raza/etnia, etc.) y es muy com煤n que tengamos que agrupar personas y "re-etiquetar" estos nuevos grupos. Aqu铆 dos ejemplos.

Agrupar edades

Imagina que tenemos un DataFrame como el siguiente:

import pandas as pd
import numpy as np

# Vamos a ponerle este "seed" para que puedas reproducir esto si as铆 lo deseas
np.random.seed(13)
n_observaciones = 5000
raza_etnias = ['Mexican', 'Cuban', 'Puerto Rican', 'African-American', 'Chinese', 'Japanese', 'White', 'Native American', 'Other']
ejemplo_2_datos = pd.DataFrame({"edad": np.random.randint(0, 100, n_observaciones), "raza_etnia": np.random.choice(raza_etnias, n_observaciones) })
ejemplo_2_datos.head()
edad raza_etnia
0 82 Japanese
1 48 Chinese
2 74 African-American
3 16 Mexican
4 98 Mexican

Yo trabajo mucho con datos del censo de Estados Unidos y siempre es un problema trabajar con las variables "race" y "ethinicity" ya que es un concepto complejo. Para concentrarnos en el .apply() estoy simplificando esto un poco y creando una sola columna llamada raza_etnia que vamos a re-agrupar.

Pero primero, la edad. Veamos su distribuci贸n:

import altair as alt

alt.Chart(ejemplo_2_datos).mark_bar().encode(
    x = "edad",
    y = "count()"
)

Puedes ver que estos datos son muy granulares - yo como investigador quiero saber ciertos atributos de las personas que tienen entre 20 y 30 a帽os, por ejemplo. No de las personas que tienen 21 o 25 o 27. Entonces necesito agrupar esta columna en bins o cubetas. En lugar de que cada observaci贸n (fila) en mi DataFrame tenga una etiqueta con su edad quiero que tenga una etiqueta con el grupo al que pertenece. Si alguien tiene 34 a帽os, esta nueva columna tiene que decir "Entre 30 y 40" no 34.

Una de las maneras que he visto mucha gente lograr esto es con .apply() y una funci贸n simple con varios if-else. As铆:

def etiquetas_edad(numero):
    """Agrupa n煤meros a su decena. Recibe un n煤mero y retorna la etiqueta correspondiente."""
    
    if 0 <= numero <= 10:
        return "Entre 0 y 10"
    elif 10 < numero <= 20:
        return "Entre 10 y 20"
    elif 20 < numero <= 30:
        return "Entre 20 y 30"
    elif 30 < numero <= 40:
        return "Entre 30 y 40"
    elif 40 < numero <= 50:
        return "Entre 40 y 50"
    elif 50 < numero <= 60:
        return "Entre 50 y 60"
    elif 60 < numero <= 70:
        return "Entre 60 y 70"
    elif 70 < numero <= 80:
        return "Entre 70 y 80"
    elif 80 < numero <= 90:
        return "Entre 80 y 90"
    elif 90 < numero <= 100:
        return "Entre 90 y 100"
    else:
        return "Error"

Existen maneras mas "elegantes" de hacerlo pero cuando uno sabe los n煤meros que esperas (no esperas a nadie con edad negativa o mayor a 100, en este caso) y tiene prisa, esta soluci贸n, pues, funciona.

ejemplo_2_datos['edad'].apply(etiquetas_edad)
0        Entre 80 y 90
1        Entre 40 y 50
2        Entre 70 y 80
3        Entre 10 y 20
4       Entre 90 y 100
             ...      
4995     Entre 60 y 70
4996     Entre 60 y 70
4997    Entre 90 y 100
4998    Entre 90 y 100
4999     Entre 60 y 70
Name: edad, Length: 5000, dtype: object

Pero, pandas tiene la funci贸n pandas.cut() que justo hace esto (documentaci贸n).

pandas.cut() recibe una matriz, las bins o "cubetas" (grupos), y las etiquetas de estos grupos (entre otros argumentos) para agrupar una columna "continua" (como lo es la edad) y crear grupos discretos en una variable Categorical que tiene otras funcionalidades 煤tiles.

grupos_de_edades = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100]
etiquetas_de_edades = [
    "Entre 0-10", 
    "Entre 10-20", 
    "Entre 20-30", 
    "Entre 30-40", 
    "Entre 40-50", 
    "Entre 50-60", 
    "Entre 60-70", 
    "Entre 70-80", 
    "Entre 80-90", 
    "Entre 90-100"
]

pd.cut(ejemplo_2_datos['edad'], grupos_de_edades, labels = etiquetas_de_edades)
0        Entre 80-90
1        Entre 40-50
2        Entre 70-80
3        Entre 10-20
4       Entre 90-100
            ...     
4995     Entre 60-70
4996     Entre 60-70
4997    Entre 90-100
4998    Entre 90-100
4999     Entre 60-70
Name: edad, Length: 5000, dtype: category
Categories (10, object): ['Entre 0-10' < 'Entre 10-20' < 'Entre 20-30' < 'Entre 30-40' ... 'Entre 60-70' < 'Entre 70-80' < 'Entre 80-90' < 'Entre 90-100']

Se logra "lo mismo" pero con pandas.cut() recibes una columna de tipo Categorical y "ordenada" lo cual significa que pandas entiende que "Entre 20-30" es un valor menor a "Entre 30-40". Esto te permite crear filtros como

ejemplo_2_datos['edad_con_pd_cut'] > "Entre 30-40"

Y esto filtrar铆a a todas las personas menores de 30 a帽os. Si no estas usando Categoricals tendr铆as que crear un filtro para cada una de las etiquetas. Pero eso para otro blog.

Pero bueno, comparemos estos dos m茅todos:

%%timeit
ejemplo_2_datos['edad'].apply(etiquetas_edad)
1.81 ms 卤 32.1 碌s per loop (mean 卤 std. dev. of 7 runs, 1000 loops each)
%%timeit
pd.cut(ejemplo_2_datos['edad'], grupos_de_edades, labels = etiquetas_de_edades)
1.01 ms 卤 108 碌s per loop (mean 卤 std. dev. of 7 runs, 1000 loops each)

Como puedes ver, pandas.cut() es una vez mas un poco m谩s r谩pida y te ahorras otra vez casi la mitad del tiempo. Esto, de nuevo, es imperceptible en realidad - la diferencia es de menos de un milisegundo.

En mi caso, prefiero pandas.cut() porque no solo es un poquit铆n mas r谩pida sino que tiene la ventaja de regresarme una columna Categorical y puedo usar eso a mi ventaja en mis an谩lisis.

:bulb: Bonus: el buen @io_exception comparti贸 un ejemplo de como hacer esto mas elegantemente en el discord en un Google Colab notebook

ejemplo_2_datos['edad_cut'] = pd.cut(ejemplo_2_datos['edad'], grupos_de_edades, labels = etiquetas_de_edades)

# puedo utilizar l贸gica con estas etiquetas como si fueran valores num茅ricos
filtro_edad = ejemplo_2_datos['edad_cut'] >= "Entre 30-40"

alt.Chart(ejemplo_2_datos[filtro_edad]).mark_bar().encode(
    x = alt.X("edad", scale = alt.Scale(domain = [0,100])),
    y = "count()"
)

Agrupar raza/etnia

Al igual como agrupamos nuestra columna de edad podemos agrupar nuestra columna raza_etnia re-etiquetando los valores de la siguiente manera.

def etiquetas_raza_etnia(raza_etnia):
    """Re-etiqueta un valor de la columna raza_etnia a otro."""

    if raza_etnia == "Mexican":
        return "Latino"
    elif raza_etnia == "Cuban":
        return "Latino"
    elif raza_etnia == "Puerto Rican":
        return "Latino"
    elif raza_etnia == "African-American":
        return "Black"
    elif raza_etnia == "Chinese":
        return "Asian"
    elif raza_etnia == "Japanese":
        return "Asian"
    elif raza_etnia == "White": 
        return "White"
    elif raza_etnia == "Native American":
        return "Native American"
    else:
        return "Other"

Esto nos permite agrupar personas que tienen el valor "Mexican", "Cuban" o "Puerto Rican" bajo la etiqueta "Latino".

ejemplo_2_datos['raza_etnia'].apply(etiquetas_raza_etnia)
0        Asian
1        Asian
2        Black
3       Latino
4       Latino
         ...  
4995    Latino
4996    Latino
4997    Latino
4998     Black
4999     Asian
Name: raza_etnia, Length: 5000, dtype: object

Pero este proceso de re-etiquetado es tan com煤n que las Series de pandas tienen el m茅todo .map() para llevar a cabo lo mismo. Esta funci贸n recibe un diccionario de python donde las claves (keys) son los valores que esperas ver en tu Serie y los valores son los valores que substituir谩n esas claves.

valores_nuevos = {
    "Mexican": "Latino",
    "Cuban": "Latino",
    "Puerto Rican": "Latino",
    "African-American": "Black",
    "Chinese": "Asian",
    "Japanese": "Asian",
    "White": "White",
    "Native American": "Native American",
}
ejemplo_2_datos['raza_etnia'].map(valores_nuevos)
0        Asian
1        Asian
2        Black
3       Latino
4       Latino
         ...  
4995    Latino
4996    Latino
4997    Latino
4998     Black
4999     Asian
Name: raza_etnia, Length: 5000, dtype: object

pandas.Series.map() recibe el diccionario con los valores y el argumento na_action (que puede ser None o "ignore") que le dice a .map() que hacer con los valores nulos NaN. Algo que me gusta de .map() es que si en lugar de darle un diccionario le das un defaultdict (un diccionario con un valor default) pandas utiliza ese valor default para cualquier valor original de tu columna que no aparezca en las claves del diccionario.

Te habr谩s dado cuenta que no tenemos el "Other" en nuestro ejemplo con .map(). Si vemos nuestra nueva serie podemos ver que tiene algunos valores NaN ya que la columna original tiene valores que no aparecen en nuestro diccionario valores_nuevos.

ejemplo_2_datos['raza_etnia'].map(valores_nuevos).isna().sum() # cuenta cuantos valores `NaN` existen en la Serie
532

Estos valores NaN deber铆an ser "Other" y podemos hacerlo encadenando m茅todos o utilizando el defaultdict

ejemplo_2_datos['raza_etnia'].map(valores_nuevos).fillna("Other") # llenamos los NaN con "Other"
0        Asian
1        Asian
2        Black
3       Latino
4       Latino
         ...  
4995    Latino
4996    Latino
4997    Latino
4998     Black
4999     Asian
Name: raza_etnia, Length: 5000, dtype: object
ejemplo_2_datos['raza_etnia'].map(valores_nuevos).fillna("Other").isna().sum() # contamos los NaNs para asegurarnos que no hay
0

驴Cual es m谩s r谩pida?

%%timeit
ejemplo_2_datos['raza_etnia'].apply(etiquetas_raza_etnia)
1.44 ms 卤 69.3 碌s per loop (mean 卤 std. dev. of 7 runs, 1000 loops each)
%%timeit
ejemplo_2_datos['raza_etnia'].map(valores_nuevos).fillna("Other")
1.34 ms 卤 49.6 碌s per loop (mean 卤 std. dev. of 7 runs, 1000 loops each)

Incluyendo el encadenamiento de m茅todos (.map().fillna()) estas funciones nativas de pandas son un poco m谩s r谩pidas. Pero ahora estas utilizando dos m茅todos. Dependiendo de que prefieras, crear tu funci贸n que tome en cuenta que hacer con valores que no esperas encontrar en tu columna (el default de "Other") o encadenar 2 m茅todos de pandas, es la decisi贸n que debes tomar.

Ejemplo 3: Limpiando strings

Otro uso muy com煤n para .apply() que he visto es el de trabajar con texto especialmente cuando se quiere trabajar con expresiones regulares. Dos casos particulares son 1) extraer cosas del texto y 2) transformarlo o limpiarlo.

Extraer texto con expresiones regulares

Utilicemos un dataset con tweets para trabajar con este tipo de datos. Primero veamos una funci贸n sencilla que extrae usuarios etiquetados en el tweet. O sea el @ y la siguiente palabra.

ejemplo_3_datos = pd.read_csv("https://raw.githubusercontent.com/zfz/twitter_corpus/master/full-corpus.csv")

ejemplo_3_datos.head()
Topic Sentiment TweetId TweetDate TweetText
0 apple positive 126415614616154112 Tue Oct 18 21:53:25 +0000 2011 Now all @Apple has to do is get swype on the i...
1 apple positive 126404574230740992 Tue Oct 18 21:09:33 +0000 2011 @Apple will be adding more carrier support to ...
2 apple positive 126402758403305474 Tue Oct 18 21:02:20 +0000 2011 Hilarious @youtube video - guy does a duet wit...
3 apple positive 126397179614068736 Tue Oct 18 20:40:10 +0000 2011 @RIM you made it too easy for me to switch to ...
4 apple positive 126395626979196928 Tue Oct 18 20:34:00 +0000 2011 I just realized that the reason I got into twi...

Este ejemplo, vamos a hacer algo distinto. No te voy a mostrar las maneras que he visto personas utilizar el .apply(), mejor te mostrar茅 las funciones nativas de pandas para trabajar con texto ya que son varias y este blog ya esta muy largo :sweat_smile:

La manera de trabajar con texto en una columna de un DataFrame es con el accessor .str - esta es una manera de acceder a m茅todos espec铆ficamente dise帽ados para strings en pandas. Tambi茅n existen otros accessors como .dt para trabajar con datos de tipo datetime o fechas y .cat para trabajar con Categoricals. Para una lista completa de los m茅todos que el accessor .str te da puedes visitar la documentaci贸n oficial: https://pandas.pydata.org/pandas-docs/stable/reference/series.html#string-handling.

Vamos a utilizar .str.findall() para encontrar los usuarios etiquetados en tweets.

:bulb: Nota: existe el m茅todo .str.find() que hace algo un poco distinto. Eso para otro blog.

ejemplo_3_datos['TweetText'].str.findall(r"@\w+")
0                 [@Apple]
1                 [@Apple]
2       [@youtube, @apple]
3           [@RIM, @Apple]
4                 [@apple]
               ...        
5108                    []
5109                    []
5110                    []
5111        [@flaviasansi]
5112                    []
Name: TweetText, Length: 5113, dtype: object

Con el accessor .str puedes hacer casi todo lo que puedes hacer nativamente en python con strings

ejemplo_3_datos['Topic'].str.capitalize()
0         Apple
1         Apple
2         Apple
3         Apple
4         Apple
         ...   
5108    Twitter
5109    Twitter
5110    Twitter
5111    Twitter
5112    Twitter
Name: Topic, Length: 5113, dtype: object
ejemplo_3_datos['Sentiment'].str.upper()
0         POSITIVE
1         POSITIVE
2         POSITIVE
3         POSITIVE
4         POSITIVE
           ...    
5108    IRRELEVANT
5109    IRRELEVANT
5110    IRRELEVANT
5111    IRRELEVANT
5112    IRRELEVANT
Name: Sentiment, Length: 5113, dtype: object

Y como es pandas podemos encadenar estos m茅todos pero como son m茅todos dentro del accessor de .str tienes que repetir el .str entre cada m茅todo.

ejemplo_3_datos['TweetText'].str.findall(r"@\w+").str.join(", ")
0                 @Apple
1                 @Apple
2       @youtube, @apple
3           @RIM, @Apple
4                 @apple
              ...       
5108                    
5109                    
5110                    
5111        @flaviasansi
5112                    
Name: TweetText, Length: 5113, dtype: object

:rotating_light::rotating_light::rotating_light: Este post esta en construcci贸n 隆o sea que todav铆a no acaba! Estamos public谩ndolo hoy 23 de abril del 2021 como adelanto. En la semana lo terminamos.