Función agregada Pandas DataFrame usando múltiples columnas

80

¿Hay alguna forma de escribir una función de agregación como se usa en el DataFrame.aggmétodo, que tendría acceso a más de una columna de los datos que se están agregando? Los casos de uso típicos serían funciones de desviación estándar ponderada, promedio ponderado.

Me gustaría poder escribir algo como

def wAvg(c, w):
    return ((c * w).sum() / w.sum())

df = DataFrame(....) # df has columns c and w, i want weighted average
                     # of c using w as weight.
df.aggregate ({"c": wAvg}) # and somehow tell it to use w column as weights ...
usuario1444817
fuente
Buen artículo que aborda esta pregunta específica de SO: pbpython.com/weighted-average.html
ptim

Respuestas:

104

Si; use la .apply(...)función, que será llamada en cada sub- DataFrame. Por ejemplo:

grouped = df.groupby(keys)

def wavg(group):
    d = group['data']
    w = group['weights']
    return (d * w).sum() / w.sum()

grouped.apply(wavg)
Wes McKinney
fuente
Puede ser más eficiente dividir esto en algunas operaciones de la siguiente manera: (1) crear una columna de ponderaciones, (2) normalizar las observaciones por sus ponderaciones, (3) calcular la suma agrupada de observaciones ponderadas y una suma agrupada de ponderaciones , (4) normalizar la suma ponderada de observaciones por la suma de pesos.
Kalu
4
¿Qué pasa si queremos calcular wavg de muchas variables (columnas), por ejemplo, todo excepto df ['pesos']?
CPBL
2
@Wes, ¿hay alguna manera de poder hacer esto con agg()y lambdaconstruido alrededor np.average(...weights=...), o cualquier nuevo soporte nativo en pandas para medios ponderados desde que apareció esta publicación?
sparc_spread
4
@Wes McKinney: En su libro que sugiere este enfoque: get_wavg = lambda g: np.average(g['data'], weights = g['weights']); grouped.apply(wavg) ¿Son los dos intercambiables?
robroc
9

Mi solución es similar a la solución de Nathaniel, solo que es para una sola columna y no copio en profundidad todo el marco de datos cada vez, lo que podría ser prohibitivamente lento. La ganancia de rendimiento sobre la solución groupby (...). Apply (...) es aproximadamente 100x (!)

def weighted_average(df, data_col, weight_col, by_col):
    df['_data_times_weight'] = df[data_col] * df[weight_col]
    df['_weight_where_notnull'] = df[weight_col] * pd.notnull(df[data_col])
    g = df.groupby(by_col)
    result = g['_data_times_weight'].sum() / g['_weight_where_notnull'].sum()
    del df['_data_times_weight'], df['_weight_where_notnull']
    return result
ErnestScribbler
fuente
Sería más legible si usara PEP8 de manera constante y eliminara la dellínea superflua .
MERose
¡Gracias! La dellínea en realidad no es superflua, ya que cambio el DataFrame de entrada en el lugar para mejorar el rendimiento, así que tengo que limpiar.
ErnestScribbler
Pero devuelve el resultado en la siguiente línea que finaliza la función. Una vez finalizada la función, todos los objetos internos se purgan de todos modos.
MERose el
1
Pero observe que df no es un objeto interno. Es un argumento para la función, y siempre que nunca le asigne ( df = something), permanece como una copia superficial y se cambia en el lugar. En este caso, las columnas se agregarían al DataFrame. Intente copiar y pegar esta función y ejecutarla sin la dellínea, y vea que cambia el DataFrame dado agregando columnas.
ErnestScribbler
Esto no responde a la pregunta, porque el promedio ponderado solo sirve como ejemplo para cualquier agregado en múltiples columnas.
user__42
8

Es posible devolver cualquier número de valores agregados de un objeto groupby con apply. Simplemente, devuelva una Serie y los valores del índice se convertirán en los nuevos nombres de columna.

Veamos un ejemplo rápido:

df = pd.DataFrame({'group':['a','a','b','b'],
                   'd1':[5,10,100,30],
                   'd2':[7,1,3,20],
                   'weights':[.2,.8, .4, .6]},
                 columns=['group', 'd1', 'd2', 'weights'])
df

  group   d1  d2  weights
0     a    5   7      0.2
1     a   10   1      0.8
2     b  100   3      0.4
3     b   30  20      0.6

Defina una función personalizada a la que se le pasará apply. Acepta implícitamente un DataFrame, lo que significa que el dataparámetro es un DataFrame. Observe cómo usa múltiples columnas, lo cual no es posible con el aggmétodo groupby:

def weighted_average(data):
    d = {}
    d['d1_wa'] = np.average(data['d1'], weights=data['weights'])
    d['d2_wa'] = np.average(data['d2'], weights=data['weights'])
    return pd.Series(d)

Llame al applymétodo groupby con nuestra función personalizada:

df.groupby('group').apply(weighted_average)

       d1_wa  d2_wa
group              
a        9.0    2.2
b       58.0   13.2

Puede obtener un mejor rendimiento calculando previamente los totales ponderados en nuevas columnas DataFrame como se explica en otras respuestas y evitar el uso por applycompleto.

Ted Petrou
fuente
4

Lo siguiente (basado en la respuesta de Wes McKinney) logra exactamente lo que estaba buscando. Me encantaría saber si hay una forma más sencilla de hacer esto dentro pandas.

def wavg_func(datacol, weightscol):
    def wavg(group):
        dd = group[datacol]
        ww = group[weightscol] * 1.0
        return (dd * ww).sum() / ww.sum()
    return wavg


def df_wavg(df, groupbycol, weightscol):
    grouped = df.groupby(groupbycol)
    df_ret = grouped.agg({weightscol:sum})
    datacols = [cc for cc in df.columns if cc not in [groupbycol, weightscol]]
    for dcol in datacols:
        try:
            wavg_f = wavg_func(dcol, weightscol)
            df_ret[dcol] = grouped.apply(wavg_f)
        except TypeError:  # handle non-numeric columns
            df_ret[dcol] = grouped.agg({dcol:min})
    return df_ret

La función df_wavg()devuelve un marco de datos que está agrupado por la columna "groupby" y que devuelve la suma de los pesos de la columna de pesos. Otras columnas son los promedios ponderados o, si no son numéricos, la min()función se utiliza para la agregación.

dslack
fuente
4

Hago esto mucho y encontré lo siguiente bastante útil:

def weighed_average(grp):
    return grp._get_numeric_data().multiply(grp['COUNT'], axis=0).sum()/grp['COUNT'].sum()
df.groupby('SOME_COL').apply(weighed_average)

Esto calculará el promedio ponderado de todas las columnas numéricas en el dfy eliminará las no numéricas.

santon
fuente
¡Esto es increíblemente rápido! ¡Gran trabajo!
Shay Ben-Sasson
Esto es realmente bueno si tiene varias columnas. ¡Agradable!
Chris
@santon, gracias por la respuesta. ¿Podría dar un ejemplo de su solución? Recibí un error 'KeyError:' COUNT 'mientras intentaba usar su solución.
Allen
@Allen Debe usar el nombre de la columna que tenga los recuentos que desea usar para el promedio ponderado.
santon
4

Lograr esta vía groupby(...).apply(...)no es efectivo. Aquí hay una solución que uso todo el tiempo (esencialmente usando la lógica de kalu).

def grouped_weighted_average(self, values, weights, *groupby_args, **groupby_kwargs):
   """
    :param values: column(s) to take the average of
    :param weights_col: column to weight on
    :param group_args: args to pass into groupby (e.g. the level you want to group on)
    :param group_kwargs: kwargs to pass into groupby
    :return: pandas.Series or pandas.DataFrame
    """

    if isinstance(values, str):
        values = [values]

    ss = []
    for value_col in values:
        df = self.copy()
        prod_name = 'prod_{v}_{w}'.format(v=value_col, w=weights)
        weights_name = 'weights_{w}'.format(w=weights)

        df[prod_name] = df[value_col] * df[weights]
        df[weights_name] = df[weights].where(~df[prod_name].isnull())
        df = df.groupby(*groupby_args, **groupby_kwargs).sum()
        s = df[prod_name] / df[weights_name]
        s.name = value_col
        ss.append(s)
    df = pd.concat(ss, axis=1) if len(ss) > 1 else ss[0]
    return df

pandas.DataFrame.grouped_weighted_average = grouped_weighted_average
Nathaniel
fuente
1
Cuando dices no performante. Cuanto es la diferencia? ¿Lo has medido?
Bouncner