使用Python中缀语法将一个函数的输出“管道”传递给另一个函数

35
我正在尝试使用Python/Pandas(作为学习练习)粗略地复制dplyr包中的内容。有一些功能我无法实现,其中之一是"piping"功能。
在R/dplyr中,使用管道操作符%>% 来完成这个功能,其中x %>% f(y)等价于f(x, y)。如果可能的话,我想使用中缀语法来复制它(参见here)。
例如,考虑下面的两个函数。
import pandas as pd

def select(df, *args):
    cols = [x for x in args]
    df = df[cols]
    return df

def rename(df, **kwargs):
    for name, value in kwargs.items():
        df = df.rename(columns={'%s' % name: '%s' % value})
    return df

第一个函数接受一个数据框,并仅返回给定的列。第二个函数接受一个数据框,并重命名给定的列。例如:
d = {'one' : [1., 2., 3., 4., 4.],
     'two' : [4., 3., 2., 1., 3.]}

df = pd.DataFrame(d)

# Keep only the 'one' column.
df = select(df, 'one')

# Rename the 'one' column to 'new_one'.
df = rename(df, one = 'new_one')

要使用管道/中缀语法实现相同的效果,代码将如下所示:
df = df | select('one') \
        | rename(one = 'new_one')

因此,左侧|的输出将作为右侧函数的第一个参数传递。每当我看到像这样的操作(例如here),它都涉及lambda函数。是否可以以同样的方式在函数之间传递Pandas的数据框?

我知道Pandas有.pipe方法,但对我来说,提供的示例的语法很重要。任何帮助将不胜感激。

7个回答

34

由于 pandas.DataFrame 已经实现了按位或运算,因此使用按位或运算符来实现这一点很困难。如果您不介意用 >> 替换 |,可以尝试以下方法:

import pandas as pd

def select(df, *args):
    cols = [x for x in args]
    return df[cols]


def rename(df, **kwargs):
    for name, value in kwargs.items():
        df = df.rename(columns={'%s' % name: '%s' % value})
    return df


class SinkInto(object):
    def __init__(self, function, *args, **kwargs):
        self.args = args
        self.kwargs = kwargs
        self.function = function

    def __rrshift__(self, other):
        return self.function(other, *self.args, **self.kwargs)

    def __repr__(self):
        return "<SinkInto {} args={} kwargs={}>".format(
            self.function, 
            self.args, 
            self.kwargs
        )

df = pd.DataFrame({'one' : [1., 2., 3., 4., 4.],
                   'two' : [4., 3., 2., 1., 3.]})

然后你可以执行:

>>> df
   one  two
0    1    4
1    2    3
2    3    2
3    4    1
4    4    3

>>> df = df >> SinkInto(select, 'one') \
            >> SinkInto(rename, one='new_one')
>>> df
   new_one
0        1
1        2
2        3
3        4
4        4

在Python 3中,您可以滥用Unicode:

>>> print('\u01c1')
ǁ
>>> ǁ = SinkInto
>>> df >> ǁ(select, 'one') >> ǁ(rename, one='new_one')
   new_one
0        1
1        2
2        3
3        4
4        4

[更新]

感谢您的回复。是否可以为每个函数创建一个单独的类(例如 SinkInto),以避免将函数作为参数传递?

那装饰器怎么样?

def pipe(original):
    class PipeInto(object):
        data = {'function': original}

        def __init__(self, *args, **kwargs):
            self.data['args'] = args
            self.data['kwargs'] = kwargs

        def __rrshift__(self, other):
            return self.data['function'](
                other, 
                *self.data['args'], 
                **self.data['kwargs']
            )

    return PipeInto


@pipe
def select(df, *args):
    cols = [x for x in args]
    return df[cols]


@pipe
def rename(df, **kwargs):
    for name, value in kwargs.items():
        df = df.rename(columns={'%s' % name: '%s' % value})
    return df

现在您可以装饰任何以 DataFrame 作为第一个参数的函数:

>>> df >> select('one') >> rename(one='first')
   first
0      1
1      2
2      3
3      4
4      4

Python 太棒了!

我知道像 Ruby 这样的语言是“如此表达式”,它鼓励人们将每个程序都编写成新的 DSL,但在 Python 中,这种做法有点不受欢迎。许多 Python 爱好者认为,将运算符重载用于不同目的是一种罪孽深重的亵渎。

[更新]

用户 OHLÁLÁ 并不满意:

使用这种解决方案的问题在于当你试图调用函数而不是管道时。-OHLÁLÁ

您可以实现 dunder-call 方法:

def __call__(self, df):
    return df >> self

然后:

>>> select('one')(df)
   one
0  1.0
1  2.0
2  3.0
3  4.0
4  4.0

看起来满足OHLÁLÁ不容易:

在这种情况下,您需要显式调用对象:
select('one')(df) 有什么方法可以避免这种情况吗?- OHLÁLÁ

好的,我能想到一个解决办法,但是有一个注意事项:您的原始函数不能有第二个位置参数,它必须是一个pandas数据帧(关键字参数是可以的)。让我们在装饰器中为我们的PipeInto类添加一个__new__方法,以检测第一个参数是否为数据帧,如果是,则只需使用参数调用原始函数:

def __new__(cls, *args, **kwargs):
    if args and isinstance(args[0], pd.DataFrame):
        return cls.data['function'](*args, **kwargs)
    return super().__new__(cls)

看起来它能够工作,但可能有一些我没有注意到的缺陷。

>>> select(df, 'one')
   one
0  1.0
1  2.0
2  3.0
3  4.0
4  4.0

>>> df >> select('one')
   one
0  1.0
1  2.0
2  3.0
3  4.0
4  4.0

谢谢您的回复。是否有可能为每个函数创建一个单独的类(例如SinkInto),以避免将函数作为参数传递? - Malthus
太棒了!看起来完美无瑕,但不幸的是我遇到了一个错误。这是我的代码链接:link。不确定我错过了什么。 - Malthus
抱歉,我发帖前确实测试过了,但现在我能够重现你遇到的同样错误。我已经更新了修饰器的可用版本。 - Paulo Scardine
@OHLÁLÁ,通过添加一个__call__方法应该很容易修复。 - Paulo Scardine
@PauloScardine 如果是这样,你需要显式地调用对象:pipe = select(df,'one') pipe() 有没有避免这种情况的方法? - Mokus
显示剩余3条评论

12

虽然我不得不提到,在Python中使用dplyr可能是在Python中拥有dplyr最接近的方式(它具有rshift运算符,但作为噱头),我还想指出,管道运算符可能仅在R中必要,因为它使用通用函数而不是方法作为对象属性。 方法链接可以让您基本上获得相同的效果,而无需覆盖运算符:

dataf = (DataFrame(mtcars).
         filter('gear>=3').
         mutate(powertoweight='hp*36/wt').
         group_by('gear').
         summarize(mean_ptw='mean(powertoweight)'))

在一对括号中包裹链可以让你将其分成多行,而不需要在每行末尾添加一个\


3
很抱歉,这很好,但我必须使用基本的Python函数/对象进行工作,它们不能像这样工作。这就是为什么我正在寻找一个适当的管道系统。 - CoderGuy123
1
@Deleet请查看https://github.com/sspipe/sspipe。它适用于任何Python对象。如果满足您的要求,请在下面给我的回答点赞。 - mhsekhavat

8
你可以使用sspipe库,并使用以下语法:

您可以使用sspipe库,并使用以下语法:

from sspipe import p
df = df | p(select, 'one') \
        | p(rename, one = 'new_one')

sspiple是否适用于普通Python,还是只适用于Pandas数据框? - alancalvitti
@alancalvitti 它支持通用的Python。 - mhsekhavat
“p”是否必要?一个真正的管道操作符应该允许这种语法:df | select('one') | rename(one='new_one'),至少在selectrename是柯里化的情况下。 - alancalvitti
1
这是不可能的。因为Python需要线索来区分pipe语义和(按位或)[https://wiki.python.org/moin/BitwiseOperators]语义。当你只写`x | y`时,Python不知道该使用哪一个。 - mhsekhavat
仅仅因为在Unix shell中使用了|管道符号并不重要——可以使用不同的符号。其他编程语言也可以使用不同的语法进行管道操作,例如在R中是%>%,在Wolfram语言中是/* - alancalvitti
显示剩余2条评论

6

我强烈反对这种做法或任何此处提出的答案,建议直接在标准Python代码中实现pipe函数,而不采用运算符诡计、装饰器或其他技巧:

def pipe(first, *args):
  for fn in args:
    first = fn(first)
  return first

关于更多背景信息,请参见我的答案: https://dev59.com/nV4c5IYBdhLWcg3wJnf8#60621554

过载运算符、涉及外部库等等不利于代码的可读性、可维护性、可测试性和Python风格。 如果我想在Python中使用某种管道,我只需要做类似这样的操作: pipe(input, fn1, fn2, fn3)。这是我能想到的最可读、最健壮的解决方案。 如果我们公司有人为了做一个管道而提交了重载运算符或者新的依赖项到生产环境,他们会被判定为犯罪行为并被安排在接下来一周内进行QA检查 :D 如果你真的非常非常非常需要使用某种运算符来实现管道操作,那么可能你还有更大的问题,Python并不适合你的用例...


你的for循环中的赋值语句是函数式编程的大忌。 - alancalvitti
1
a) 在我看来,易读性总是胜过草率的“规则”。 b) 要将功能实用程序带入非功能语言,恐怕您必须在某个时候使用非功能概念。只需看看像ramdajs这样的库即可。话虽如此,从我的解决方案中可以清楚地看出,递归替代方案是可行的: pipe(first, *args): return pipe(args[0](first), args[1:]) if args else first 纯粹主义者可能会认为条件语句不是函数式的,但由于Python不是函数式的,因此它不支持模式匹配或替代。 - jramm
1
我使用一个类似于你上面的管道的函数,但作为运算符,因此它是一个应用于数据的lambda函数,这样它们可以被递归地进行管道处理:def right_compose(*fn): return lambda x: functools.reduce(lambda f,g: g(f), list(fn),x) - 不需要分配。 - alancalvitti
关于条件语句,它们也可以以函数式的方式表示,例如使用带有模式匹配和重写的 lambda 的 select 或 cases 语句。问题不在于函数式与否,而在于符号化与否。在 Mathematica 中,可以匹配符号表达式树的子表达式。这在 Python 中并不容易实现,但也许 Google pyglove 可以提供一些见解。 - alancalvitti

1

我一直在将数据包(dplyr,tidyr,tibble等)从R移植到Python:

https://github.com/pwwang/datar

如果您熟悉R中的这些包,并想在Python中应用它们,那么这里为您提供:

from datar.all import *

d = {'one' : [1., 2., 3., 4., 4.],
     'two' : [4., 3., 2., 1., 3.]}
df = tibble(one=d['one'], two=d['two'])

df = df >> select(f.one) >> rename(new_one=f.one)
print(df)

输出:

   new_one
0      1.0
1      2.0
2      3.0
3      4.0
4      4.0

0

我找不到内置的方法来实现这个,所以我创建了一个类,它使用__call__运算符,因为它支持*args/**kwargs

class Pipe:
    def __init__(self, value):
        """
        Creates a new pipe with a given value.
        """
        self.value = value
    def __call__(self, func, *args, **kwargs):
        """
        Creates a new pipe with the value returned from `func` called with
        `args` and `kwargs` and it's easy to save your intermedi.
        """
        value = func(self.value, *args, **kwargs)
        return Pipe(value)

语法需要一些时间来适应,但它允许进行管道操作。

def get(dictionary, key):
    assert isinstance(dictionary, dict)
    assert isinstance(key, str)
    return dictionary.get(key)

def keys(dictionary):
    assert isinstance(dictionary, dict)
    return dictionary.keys()

def filter_by(iterable, check):
    assert hasattr(iterable, '__iter__')
    assert callable(check)
    return [item for item in iterable if check(item)]

def update(dictionary, **kwargs):
    assert isinstance(dictionary, dict)
    dictionary.update(kwargs)
    return dictionary


x = Pipe({'a': 3, 'b': 4})(update, a=5, c=7, d=8, e=1)
y = (x
    (keys)
    (filter_by, lambda key: key in ('a', 'c', 'e', 'g'))
    (set)
    ).value
z = x(lambda dictionary: dictionary['a']).value

assert x.value == {'a': 5, 'b': 4, 'c': 7, 'd': 8, 'e': 1}
assert y == {'a', 'c', 'e'}
assert z == 5

0

这是一个老问题,但对我仍然很有兴趣(来自R语言)。所以尽管纯粹主义者的反对,这里是一个受http://tomerfiliba.com/blog/Infix-Operators/启发的简短代码。

class FuncPipe:
    class Arg:
        def __init__(self, arg):
            self.arg = arg
        def __or__(self, func):
            return func(self.arg)

    def __ror__(self, arg):
        return self.Arg(arg)
pipe = FuncPipe()

那么

1 |pipe| \
  (lambda x: return x+1) |pipe| \
  (lambda x: return 2*x)

返回

4 

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