"Трубопровод" выводится из одной функции в другую с использованием синтаксиса инфикса Python

Я пытаюсь скопировать, dplyr пакет из R, используя Python/Pandas (в качестве учебного упражнения). То, что я застрял, это функциональность "трубопроводов".

В R/dplyr это выполняется с использованием оператора-оператора %>%, где x %>% f(y) эквивалентно f(x, y). Если возможно, я хотел бы воспроизвести это с помощью синтаксиса infix (см. здесь).

Чтобы проиллюстрировать, рассмотрим две функции ниже.

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')

Для достижения такого же синтаксиса pipe/infix код будет выглядеть следующим образом:

df = df | select('one') \
        | rename(one = 'new_one')

Таким образом, выход из левой части | передается в качестве первого аргумента функции справа. Всякий раз, когда я вижу что-то подобное (здесь), он включает функции лямбда-функции. Возможно ли передать трубку Pandas 'между функциями таким же образом?

Я знаю, что Pandas имеет метод .pipe, но для меня важным является синтаксис примера, который я предоставил. Любая помощь будет оценена по достоинству.

Ответ 1

Трудно реализовать это с помощью побитового оператора or, потому что 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. Многие питоники считают перегрузку оператора разной целью как греховное богохульство.

[обновление]

Пользователь 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 dataframe (аргументы ключевого слова в порядке). Давайте добавим метод __new__ к нашему классу PipeInto внутри docorator, который проверяет, является ли первый аргумент фреймворком данных, и если это так, мы просто вызываем исходную функцию с аргументами:

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

Ответ 2

Хотя я не могу не упомянуть, что использование dplyr в Python может быть самым близким к наличию в dplyr в Python (у него есть оператор rshift, но как трюк), я хотел бы также указать, что оператор трубы может быть необходим только в R из-за использования общих функций, а не методов как атрибутов объектов. Цепь метода дает вам практически то же самое без необходимости переопределять операторы:

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

Замечание об обертывании цепи между парой круглых скобок позволяет разбить ее на несколько строк без необходимости в конце \ в каждой строке.

Ответ 3

Я не мог найти встроенный способ сделать это, поэтому создал класс, который использует оператор __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 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