Agrupando Datos

Group By se refiere al proceso que involucra uno o más de los siguientes pasos:

  • Dividir los datos en grupos basados en algún criterio.

  • Aplicar una función a cada uno de los grupos independientemente.

  • Combinar los resultados en una estructura de datos.

La división es el paso principal. Usualmente, el usuario busca dividir la data en grupos y luego hacer algo con estos grupos. En el paso de aplicar se puede:

  • Agregación: Calcular alguna estadística(s) para cada grupo. Por ejemplo, la suma, promedio y/o conteo de cada grupo.

  • Transformación: Algún cálculo específico a cada grupo pero devolviendo un índice similar al original. Por ejemplo, estandarizar o rellenar valores nulos respecto a cada grupo.

  • Filtrado: Descartar grupos de acuerdo a un cálculo grupal que se evalua verdadero o falso. Por ejemplo, descartar los regristros que la cantidad de miembros del grupo es menor a cierto umbral.

La clase de hoy será motivada con el dataset de monstruos de bolsillo favorito de los milleniasl: Pokemon.

import os
import pandas as pd
pkm = (
    pd.read_csv(os.path.join("..", "data", "pokemon.csv"), index_col="#")
    .rename(columns=lambda x: x.replace(" ", "").replace(".", "_").lower())
)
pkm.head()
name type1 type2 hp attack defense sp_atk sp_def speed generation legendary
#
1 Bulbasaur Grass Poison 45 49 49 65 65 45 1 False
2 Ivysaur Grass Poison 60 62 63 80 80 60 1 False
3 Venusaur Grass Poison 80 82 83 100 100 80 1 False
4 Mega Venusaur Grass Poison 80 100 123 122 120 80 1 False
5 Charmander Fire NaN 39 52 43 60 50 65 1 False

Ejemplo: ¿Sabes cuántos pokemones legendarios hay por generación? ¿No? Agrupemos por generación.

pkm.groupby("generation")
<pandas.core.groupby.generic.DataFrameGroupBy object at 0x7f77745cc9d0>
type(pkm.groupby("generation"))
pandas.core.groupby.generic.DataFrameGroupBy

Hacer un groupby nos entrega un objeto groupby, usualmente no nos ayuda mucho, pero ya tiene los grupos separados internamente.

Idea: Iteremos por grupo y contemos!

for name, group in pkm.groupby("generation"):
    print(f"type(name): {type(name)}")
    print(f"type(group): {type(group)}\n")
    print(f"name: {name}")
    print(f"group:")
    display(group)
    break
type(name): <class 'int'>
type(group): <class 'pandas.core.frame.DataFrame'>

name: 1
group:
name type1 type2 hp attack defense sp_atk sp_def speed generation legendary
#
1 Bulbasaur Grass Poison 45 49 49 65 65 45 1 False
2 Ivysaur Grass Poison 60 62 63 80 80 60 1 False
3 Venusaur Grass Poison 80 82 83 100 100 80 1 False
4 Mega Venusaur Grass Poison 80 100 123 122 120 80 1 False
5 Charmander Fire NaN 39 52 43 60 50 65 1 False
... ... ... ... ... ... ... ... ... ... ... ...
162 Dragonite Dragon Flying 91 134 95 100 100 80 1 False
163 Mewtwo Psychic NaN 106 110 90 154 90 130 1 True
164 Mega Mewtwo X Psychic Fighting 106 190 100 154 100 130 1 True
165 Mega Mewtwo Y Psychic NaN 106 150 70 194 120 140 1 True
166 Mew Psychic NaN 100 100 100 100 100 100 1 False

166 rows × 11 columns

for name, group in pkm.groupby("generation"):
    print(f"La generación {name} tiene {group['legendary'].sum()} pokemones legendarios.")
La generación 1 tiene 6 pokemones legendarios.
La generación 2 tiene 5 pokemones legendarios.
La generación 3 tiene 18 pokemones legendarios.
La generación 4 tiene 13 pokemones legendarios.
La generación 5 tiene 15 pokemones legendarios.
La generación 6 tiene 8 pokemones legendarios.

Lo anterior no es lo mejor, porque es secuencial, es decir, debemos esperar a que la i-ésima iteración termine para ejecutar la (i+1)-ésima iteración.

Group By - Aggregation

Una vez el objeto GroupBy ha sido creado, es posible aplicar diferentes métodos para realizar los cálculos requeridos. El más común, es aggregate(), o equivalentemente, agg().

pkm.groupby("generation").agg({"legendary": "sum"})
legendary
generation
1 6
2 5
3 18
4 13
5 15
6 8

Nota que después de aplicado el agg el nombre de la columna se mantiene, esto se puede cambiar de la siguiente forma:

# Nombre de la columna como argumento de agg, tupla con nombre de la columna y operación.
pkm.groupby("generation").agg(legendaries_sum=("legendary", "sum"))
legendaries_sum
generation
1 6
2 5
3 18
4 13
5 15
6 8

Comparemos tiempos

%%timeit
aux = pd.DataFrame().rename_axis(index="generation")
for name, group in pkm.groupby("generation"):
    aux.loc[name, "legendary"] = group['legendary'].sum()
5.06 ms ± 4.29 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
%%timeit
pkm.groupby("generation").agg({"legendary": "sum"})
1.42 ms ± 671 ns per loop (mean ± std. dev. of 7 runs, 1000 loops each)

Si es algo pequeño de una sola columna, puedes acceder directamente a ella.

pkm.groupby("generation")["legendary"].sum()  # Ojo! Devuelve una serie
generation
1     6
2     5
3    18
4    13
5    15
6     8
Name: legendary, dtype: int64

Puedes agrupar por más de una columna.

pkm.groupby(["type1", "type2"]).agg(hp_max=("hp", "max"))
hp_max
type1 type2
Bug Electric 70
Fighting 80
Fire 85
Flying 86
Ghost 1
... ... ...
Water Ice 130
Poison 80
Psychic 95
Rock 100
Steel 84

136 rows × 1 columns

También puedes agregar más de una columna.

(
    pkm.groupby(["type1", "type2"])
    .agg(
        hp_max=("hp", "max"),
        attack_max=("attack", "max")
    )
)
hp_max attack_max
type1 type2
Bug Electric 70 77
Fighting 80 185
Fire 85 85
Flying 86 155
Ghost 1 90
... ... ... ...
Water Ice 130 95
Poison 80 95
Psychic 95 75
Rock 100 108
Steel 84 86

136 rows × 2 columns

Incluso hacer más de una agregación a una misma columna

(
    pkm.groupby(["type1", "type2"])
    .agg({"hp": ["min", "mean", "max"]}
    )
)
hp
min mean max
type1 type2
Bug Electric 50 60.000000 70
Fighting 80 80.000000 80
Fire 55 70.000000 85
Flying 30 63.000000 86
Ghost 1 1.000000 1
... ... ... ... ...
Water Ice 50 90.000000 130
Poison 40 61.666667 80
Psychic 60 87.000000 95
Rock 54 70.750000 100
Steel 84 84.000000 84

136 rows × 3 columns

Si quieres cambiar los nombres es un poco más verboso.

(
    pkm.groupby(["type1", "type2"])
    .agg(
        hp_min=("hp", "min"),
        hp_mean=("hp", "mean"),
        hp_max=("hp", "max"),
    )
)
hp_min hp_mean hp_max
type1 type2
Bug Electric 50 60.000000 70
Fighting 80 80.000000 80
Fire 55 70.000000 85
Flying 30 63.000000 86
Ghost 1 1.000000 1
... ... ... ... ...
Water Ice 50 90.000000 130
Poison 40 61.666667 80
Psychic 60 87.000000 95
Rock 54 70.750000 100
Steel 84 84.000000 84

136 rows × 3 columns

También puedes aplicar tus propias funciones

(
    pkm.groupby(["type1", "type2"])
    .agg(
        hp_range=("hp", lambda x: x.max() - x.min()),
    )
)
hp_range
type1 type2
Bug Electric 20
Fighting 0
Fire 30
Flying 56
Ghost 0
... ... ...
Water Ice 80
Poison 40
Psychic 35
Rock 46
Steel 0

136 rows × 1 columns

Finalmente, si quieres interactuar con más de una columna, necesitas el método apply.

(
    pkm.groupby(["type1", "type2"])
    .apply(lambda df: df["attack"].mean() - df["defense"].mean())
)
type1  type2   
Bug    Electric     7.000000
       Fighting    60.000000
       Fire        12.500000
       Flying       8.571429
       Ghost       45.000000
                     ...    
Water  Ice        -30.000000
       Poison      10.000000
       Psychic    -31.000000
       Rock       -30.000000
       Steel       -2.000000
Length: 136, dtype: float64

Eres libre de definir tu propia función y entregarla a un groupby, por ejemplo, usando el mismo ejemplo anterior:

def attack_minus_defense(df):
    return df["attack"].mean() - df["defense"].mean()
pkm.groupby(["type1", "type2"]).apply(attack_minus_defense)
type1  type2   
Bug    Electric     7.000000
       Fighting    60.000000
       Fire        12.500000
       Flying       8.571429
       Ghost       45.000000
                     ...    
Water  Ice        -30.000000
       Poison      10.000000
       Psychic    -31.000000
       Rock       -30.000000
       Steel       -2.000000
Length: 136, dtype: float64

Nota que no es necesario usar la función lambda, pero si es importante que tu función definida tenga como argumento un dataframe (puedes pensar que en cada grupo se le entregará el dataframe filtrado).

Group By - Transform

Ejemplo: Normalizar cada columna agrupados por generación

(
    pkm.groupby("generation")
    .transform(lambda s: (s - s.mean()) / s.std())
)
hp attack defense sp_atk sp_def speed legendary
#
1 -0.739479 -0.898969 -0.763283 -0.198010 -0.160373 -0.929521 -0.193065
2 -0.206695 -0.476132 -0.274479 0.237542 0.427740 -0.424060 -0.193065
3 0.503685 0.174386 0.423812 0.818277 1.211892 0.249889 -0.193065
4 0.503685 0.759852 1.820395 1.457086 1.996043 0.249889 -0.193065
5 -0.952593 -0.801391 -0.972770 -0.343193 -0.748487 -0.255573 -0.193065
... ... ... ... ... ... ... ...
796 -0.873754 0.829182 2.337149 0.808641 2.496477 -0.639851 3.022779
797 -0.873754 2.885421 1.062058 2.695980 1.166968 1.695510 3.022779
798 0.561116 1.171889 -0.531806 2.381423 1.831723 0.138603 3.022779
799 0.561116 2.885421 -0.531806 3.010536 1.831723 0.527830 3.022779
800 0.561116 1.171889 1.380831 1.752310 0.502214 0.138603 3.022779

800 rows × 7 columns

También se lo puedes aplicar a una sola columna.

(
    pkm.groupby("generation")["attack"]
    .transform(lambda s: (s - s.mean()) / s.std())
)
#
1     -0.898969
2     -0.476132
3      0.174386
4      0.759852
5     -0.801391
         ...   
796    0.829182
797    2.885421
798    1.171889
799    2.885421
800    1.171889
Name: attack, Length: 800, dtype: float64

Personalmente, no suelo utilizar mucho este método, porque prefiero guardar la data original. Suelo agregar nuevas columnas, por ejemplo:

pkm.assign(
    attack_nrm=lambda df: df.groupby("generation")["attack"].transform(lambda s: (s - s.mean()) / s.std())
)
name type1 type2 hp attack defense sp_atk sp_def speed generation legendary attack_nrm
#
1 Bulbasaur Grass Poison 45 49 49 65 65 45 1 False -0.898969
2 Ivysaur Grass Poison 60 62 63 80 80 60 1 False -0.476132
3 Venusaur Grass Poison 80 82 83 100 100 80 1 False 0.174386
4 Mega Venusaur Grass Poison 80 100 123 122 120 80 1 False 0.759852
5 Charmander Fire NaN 39 52 43 60 50 65 1 False -0.801391
... ... ... ... ... ... ... ... ... ... ... ... ...
796 Diancie Rock Fairy 50 100 150 100 150 50 6 True 0.829182
797 Mega Diancie Rock Fairy 50 160 110 160 110 110 6 True 2.885421
798 Hoopa Confined Psychic Ghost 80 110 60 150 130 70 6 True 1.171889
799 Hoopa Unbound Psychic Dark 80 160 60 170 130 80 6 True 2.885421
800 Volcanion Fire Water 80 110 120 130 90 70 6 True 1.171889

800 rows × 12 columns

Group By - Filter

Ejemplo: Filtrar el dataframe de pokemons pero manteniendo solo las generaciones que tengan más de 10 pokemones legendarios.

Como te estás dando cuenta, no se puede hacer con una máscara, porque el criterio no depende de cada registro, depende del grupo al cual pertenece el regirstro.

pkm_filtered = pkm.groupby("generation").filter(lambda df: df["legendary"].sum() > 10)
pkm_filtered.head()
name type1 type2 hp attack defense sp_atk sp_def speed generation legendary
#
273 Treecko Grass NaN 40 45 35 65 55 70 3 False
274 Grovyle Grass NaN 50 65 45 85 65 95 3 False
275 Sceptile Grass NaN 70 85 65 105 85 120 3 False
276 Mega Sceptile Grass Dragon 70 110 75 145 85 145 3 False
277 Torchic Fire NaN 45 60 40 70 50 45 3 False

Veamos cuales son las generaciones que permanecieron luego del filtrado.

pkm_filtered["generation"].unique()
array([3, 4, 5])

Verifiquemos que filtramos correctamente

pkm.groupby("generation")["legendary"].sum()
generation
1     6
2     5
3    18
4    13
5    15
6     8
Name: legendary, dtype: int64

Lo importante del argumento del método filter es que retorne un booleano! Es distinto a cuando uno hace una máscara, donde se obtiene una serie de elementos booleanos.

Por ejemplo, filtrar los pokemones que son de la primera generación generación.

pkm.loc[lambda df: df["generation"] != 1]
name type1 type2 hp attack defense sp_atk sp_def speed generation legendary
#
167 Chikorita Grass NaN 45 49 65 49 65 45 2 False
168 Bayleef Grass NaN 60 62 80 63 80 60 2 False
169 Meganium Grass NaN 80 82 100 83 100 80 2 False
170 Cyndaquil Fire NaN 39 52 43 60 50 65 2 False
171 Quilava Fire NaN 58 64 58 80 65 80 2 False
... ... ... ... ... ... ... ... ... ... ... ...
796 Diancie Rock Fairy 50 100 150 100 150 50 6 True
797 Mega Diancie Rock Fairy 50 160 110 160 110 110 6 True
798 Hoopa Confined Psychic Ghost 80 110 60 150 130 70 6 True
799 Hoopa Unbound Psychic Dark 80 160 60 170 130 80 6 True
800 Volcanion Fire Water 80 110 120 130 90 70 6 True

634 rows × 11 columns

La máscara dentro de loc es una serie de elementos booleanos.

pkm["generation"] != 1
#
1      False
2      False
3      False
4      False
5      False
       ...  
796     True
797     True
798     True
799     True
800     True
Name: generation, Length: 800, dtype: bool

Resumen

  • Agrupar datos por condiciones es una tarea usual.

  • Dependiendo de tu objetivo es posible operar, transformar o filtrar los grupos.