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.