如何避免在pandas DataFrame中的assign和apply方法链中使用过多的lambda函数

5

我正在尝试将在R中对数据框进行的一系列操作转换为其Python等效项。管道的基本示例如下,其中包括几个 mutatefilter 调用:

library(tidyverse)

calc_circle_area <- function(diam) pi / 4 * diam^2
calc_cylinder_vol <- function(area, length) area * length

raw_data <- tibble(cylinder_name=c('a', 'b', 'c'), length=c(3, 5, 9), diam=c(1, 2, 4))

new_table <- raw_data %>% 
  mutate(area = calc_circle_area(diam)) %>% 
  mutate(vol = calc_cylinder_vol(area, length)) %>% 
  mutate(is_small_vol = vol < 100) %>% 
  filter(is_small_vol)

我可以在pandas中很容易地复制这个操作,但是发现使用assignapply时涉及一些嵌套的lambda调用(首先是数据帧调用者作为参数,然后是数据帧行作为参数)。这往往会遮蔽assign操作的含义,如果可能的话,我想要指定更直接明了的内容(像R版本那样)。

import pandas as pd
import math

calc_circle_area = lambda diam: math.pi / 4 * diam**2
calc_cylinder_vol = lambda area, length: area * length

raw_data = pd.DataFrame({'cylinder_name': ['a', 'b', 'c'], 'length': [3, 5, 9], 'diam': [1, 2, 4]})

new_table = (
    raw_data
        .assign(area=lambda df: df.diam.apply(lambda r: calc_circle_area(r.diam), axis=1))
        .assign(vol=lambda df: df.apply(lambda r: calc_cylinder_vol(r.area, r.length), axis=1))
        .assign(is_small_vol=lambda df: df.vol < 100)
        .loc[lambda df: df.is_small_vol]
)

我知道可以将 .assign(area=lambda df: df.diam.apply(calc_circle_area)) 写成 .assign(area=raw_data.diam.apply(calc_circle_area)),但这仅适用于原始数据帧中已存在 diam 列的情况,而这并非总是如此。

我也意识到这里的 calc_... 函数是可向量化的,这意味着我也可以进行以下操作:

.assign(area=lambda df: calc_circle_area(df.diam))
.assign(vol=lambda df: calc_cylinder_vol(df.area, df.length))

但是,由于大多数函数不能进行向量化处理,因此在大多数情况下,这种方法不起作用。

TL;DR我想知道是否有一种更干净的方法可以“改变”数据框的列,而不涉及像以下内容中双重嵌套的 lambda 语句:

.assign(vol=lambda df: df.apply(lambda r: calc_cylinder_vol(r.area, r.length), axis=1))

这种类型的应用程序是否有最佳实践,或者在方法链接的上下文中这是最好的方法吗?
2个回答

7
最佳实践是矢量化操作。原因在于性能,因为apply非常慢。在R代码中已经利用了矢量化,并且您应该在Python中继续这样做。由于这种性能考虑,您会发现大多数您需要的函数实际上都是可以向量化的。这将消除内部lambda表达式。对于df上的外部lambda表达式,我认为您所拥有的模式最清晰。另一种选择是反复重新分配给raw_data变量或其他中间变量,但这不符合您正在要求的方法链接风格。还有一些Python包,例如dfply,旨在在Python中模仿dplyr的感觉。如果您想采用这种方法,请记住它们不会像核心pandas那样获得同等级别的支持。或者,如果您只想节省输入一点时间,并且所有函数仅针对列,则可以创建一个粘合函数,为您展开列并将其传递。
def df_apply(col_fn, *col_names):
    def inner_fn(df):
        cols = [df[col] for col in col_names]
        return col_fn(*cols)
    return inner_fn

然后使用看起来像这样:

new_table = (
    raw_data
        .assign(area=df_apply(calc_circle_area, 'diam'))
        .assign(vol=df_apply(calc_cylinder_vol, 'area', 'length'))
        .assign(is_small_vol=lambda df: df.vol < 100)
        .loc[lambda df: df.is_small_vol]
)

如果需要的话,也可以不利用向量化来编写这个代码。

def df_apply_unvec(fn, *col_names):
    def inner_fn(df):
        def row_fn(row):
            vals = [row[col] for col in col_names]
            return fn(*vals)
        return df.apply(row_fn, axis=1)
    return inner_fn

为了更清晰明了,我使用了命名函数。但是可以使用lambda函数将其压缩成类似于您原始格式的通用形式。


谢谢你的帮助,我真的很喜欢这种方法。我想知道你对在将函数传递给 df.apply 之前使用 numpy.vectorize 的想法如何;这样做比使用 df.apply 更快吗? - teepee
1
这可能取决于函数。我会尝试两种方法并观察结果!在IPython和Jupyter中,%time和%timeit魔法命令非常适合进行此类分析。 - mcskinner

3
正如@mcskinner所指出的,向量操作更好、更快。如果您的操作无法向量化并且仍然想应用函数,您可以使用pipe方法,该方法应该允许更干净的方法链接: pipe
import math

def area(df):
    df['area'] = math.pi/4*df['diam']**2
    return df

def vol(df):
    df['vol'] = df['area'] * df['length']
    return df

new_table = (raw_data
             .pipe(area)
             .pipe(vol)
             .assign(is_small_vol = lambda df: df.vol < 100)
             .loc[lambda df: df.is_small_vol]
             )

new_table

    cylinder_name   length  diam    area     vol    is_small_vol
0       a             3      1    0.785398  2.356194    True
1       b             5      2    3.141593  15.707963   True

我喜欢这种简洁的外观,但不幸的是无法将其作为方法链合并。我假设这些函数是预先定义的通用函数,这意味着它们必须被调整为仅接受数据框作为参数的形式。我可以接受这一点,但很遗憾pandas数据框默认情况下不会以矢量化方式应用函数(就像在R中一样)。我想这就是游戏规则。谢谢你的帮助。 - teepee

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接