Создание архитектур кода с помощью функциональных операторов

Введение

Из всех парадигм программирования для науки о данных лучше всего подходит функциональное программирование (ФП). Ключевая концепция функционального программирования — это функция. Отсюда же произошло и название парадигмы. Каждая функция принимает данные на вход, а на выходе возвращает их измененную версию. Например, функция mean берет серию чисел и возвращает их среднее значение. Главное здесь то, что функция не имеет побочных эффектов. То есть результат функции не меняет состояние за пределами функции, а внешнее состояние не влияет на результат. Все это делает функции ФП очень предсказуемыми: при определенных входных значениях результат всегда одинаков.

Так что, по идее, наши блокноты могут ограничиться двумя компонентами: данными и функциями, которые выполняют действия с данными. В большинстве случаев этих пунктов достаточно для создания блокнотов. Однако при написании более сложного кода (скажем, библиотеки sklearn) нам пригодятся и другие концепции ФП. Наша статья посвящена одной из таких концепций, а именно — функциональным операторам, которые являются функциями высшего порядка. Функциональный оператор принимает одну или несколько функций на вход, а на выходе возвращает новую функцию. Наглядным примером служит оператор индикатора прогресса. Этот оператор добавляет информацию о ходе процесса в любую функцию, обрабатывающую данные. Такие функциональные операторы расширяют наши возможности, позволяя создавать гибкий и повторно используемый код ФП.

Основная тема статьи — это создание функционального оператора vectorize, который может векторизовать любую существующую невекторизованную функцию. Вы узнаете:

  • как создавать функциональные операторы в Python через замыкание;
  • как передавать любые входные параметры из одной функции в другую через *args и **kwargs;
  • как создавать функциональный оператор для векторизации;
  • как пользоваться операторами для создания четкой иерархии функций и функциональных операторов(по аналогии с иерархией классов в объектно-ориентированном программировании);
  • как использование функциональных операторов помогает писать чистый код в блокнотах.

В первой части статьи мы создадим простой функциональный оператор и расширим его до векторизатора. В завершении статьи я поделюсь своими соображениями о том, как пользоваться функциональными операторами для создания архитектуры кода.

Создаем свой первый функциональный оператор

Чтобы облегчить себе задачу, создадим очень простой функциональный оператор. Он добавляет счетчик в функцию ввода и подсчитывает частоту вызова этой функции:

def do_nothing():
pass

def count_times_called(input_function):
number_of_times_called = 0
def internal_function(*args, **kwargs):
nonlocal number_of_times_called, input_function
print(number_of_times_called, end='.')
number_of_times_called += 1
return input_function(*args, **kwargs)
return internal_function

do_nothing_times_called = count_times_called(do_nothing)
nothing = [do_nothing_times_called() for i in range(10)]

Главная фишка в том, чтобы обернуть input_function в internal_function. При возвращении internal_function происходит вызов функции ввода, поэтому результат новой функции равен результату input_function. Поведение функции отличается лишь в паре строк: на экран выводится частота вызова функции, а счетчик увеличивается на единицу. Обратите внимание, что переменная number_of_times_called определяется вне области видимости internal_function. При использовании нелокальной перемененной мы можем получить к ней доступ за пределами функции. number_of_times_called является постоянной при всех вызовах функции, поскольку internal_function «помнит» свою начальную область видимости — count_times_called. В функциональном программировании такая техника называется замыканием.

Теперь у нас есть два класса функций: функция, которая выполняет операцию (do_nothing), и функциональный оператор, который изменяет свое поведение (count_times_called). Такой набор операторных функций и функциональных операторов позволяет выстраивать довольно сложную и гибкую иерархию функций, которую можно смешивать и сопоставлять при создании блокнотов. Наглядные примеры возможных функциональных операторов:

  • индикатор прогресса. Для завершения операции берет функцию и количество ее вызовов. Например, у вас есть 25 файлов, которые нужно прочесть, и вы хотите видеть, сколько файлов уже прочитано. Кстати, такой функциональный оператор уже реализован в пакете tqdm;
  • замедляющий функциональный оператор. Само по себе замедление кода кажется чем-то нелогичным. Но эта опция оказывается как нельзя кстати при изменении функции, которая вызывает API с ограниченным количеством вызовов в минуту;
  • функциональный оператор кэширования. Он хранит комбинации входных и выходных значений и возвращает кэшированную версию входного значения, если в кэше уже сохранен результат этой функции. Такой процесс называется мемоизацией;
  • оптимизация гиперпараметров через функциональный оператор перекрестной проверки (кросс-валидации). Такой оператор оборачивает подходящую функцию и ищет оптимальное значение заданных параметров. По сути, этим уже занимается GridSearchCV в sklearn.

Создание функционального оператора векторизации

При векторизации вы можете передать в качестве входного аргумента функции вектор, и эта функция выполнит нужное действие. Например, если вы передадите список файлов в функцию, которая поддерживает CSV, то она распознает все файлы. К сожалению, в pandas.read_csv такой поддержки нет. В данном разделе мы создадим функциональный оператор векторизации, который позволит нам обновить pandas.read_csv для передачи векторных файлов на вход.

Для начала создадим базовую версию функционального оператора векторизации. Во многом он будет похож на R-функцию Vectorize:

def vectorize(input_function, which_arg):
def internal_function(**kwargs):
nonlocal input_function, which_arg
arg_vector = kwargs[which_arg]
del kwargs[which_arg]
return [input_function(**{which_arg: vector_element}, **kwargs) for vector_element in arg_vector]
return internal_function

Важнейшая часть кода — это генератор списка в конце internal_function. Именно он векторизует input_function за счет перебора входного значения в виде списка. Обратите внимание, что для создания правильного генератора списка, мы должны четко понимать, какие входные переменные необходимо векторизовать. Поэтому сначала берем подходящий аргумент из входного словаря (kwargs), а затем удаляем его из списка через del. Это позволяет нам корректно прописать вызов input_function с помощью генератора списка.

Применяем процедуру к read_csv:

import glob
import pandas as pd

read_csv_vec = vectorize(pd.read_csv, 'filepath_or_buffer')
read_csv_vec(filepath_or_buffer = glob.glob('data/*'))

В этом коде мы получаем версию pandas.read_csv, в которую можно передать список файлов, а функция вернет список DataFrame с содержимым шести CSV-файлов. В отличие от Vectorize на R, здесь есть ряд ограничений:

  • результат функции поддерживает только именованные аргументы. Это важно, поскольку аргумент для векторизации выбирается по названию;
  • функция не может векторизовать сразу несколько аргументов;
  •  агрегация выходных значений не производится. Например, для автоматической конкатенации списка DataFrame в один большой фрейм данных.

Код ниже добавляет этот последний пункт:

def simplifier_pandas_DataFrame(list_of_dataframes):
return pd.concat(list_of_dataframes)

def try_to_simplify(results):
if all([isinstance(el, pd.DataFrame) for el in results]):
return simplifier_pandas_DataFrame(results)
else:
return results

def vectorize(input_function, which_arg, simplify=True):
def internal_function(**kwargs):
nonlocal input_function, which_arg
arg_vector = kwargs[which_arg]
del kwargs[which_arg]
results = [input_function(**{which_arg: vector_element}, **kwargs) for vector_element in arg_vector]
if simplify:
return try_to_simplify(results)
else:
return results
return internal_function

# новый векторизованный код
read_csv_vec(filepath_or_buffer = glob.glob('data/*'))
# vs
pd.concat([pd.read_csv(path) for path in glob.glob('data/*')])

Теперь наш функциональный оператор добавляет векторизацию и агрегацию результата в один большой DataFrame. На мой взгляд, это придает большую выразительность pandas.read_csv: тот же результат, но меньше кода. Такая выразительность делает код в блокнотах более читабельным и точным.

Как вы уже заметили, код выше — куда более подробный, чем это требуется по канону. С тем же успехом я мог бы запросто воспользоваться pd.concat напрямую без функции try_to_simplify. Но текущая форма кода поддерживает различное упрощение, в зависимости от того, что возвращает функция.

Создание архитектуры кода через функциональный оператор

Использование данных, функций, выполняющих операции с данными, и функциональных операторов, выполняющих операции с функциями, помогает нам создавать весьма сложные архитектуры кода. Например, у нас есть функция read_csv, читающая данные, и функциональный оператор vectorize, который позволяет читать сразу несколько файлов. А если применить функциональный оператор индикатора прогресса к векторизованной функции read_csv, то можно следить за прогрессом чтения большого количества CSV-файлов:

read_data = add_progress_bar(vectorize(pd.read_csv))

В этом примере read_data является сложной функцией, которая сочетает в себе все свойства функции и функциональных операторов. Из большого набора функций и функциональных операторов можно создавать сложные функции. Это позволяет нам создавать простые и понятные компоненты, которые можно объединять в более сложный код. Архитектура кода, основанная на функциональных операторах, служит мощным инструментом в науке о данных.

Просмотреть код из статьи можно на странице GitHub

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Paul Hiemstra: Advanced functional programming for data science: building code architectures with function operators

Предыдущая статьяГенерация API-документации из docstrings на Python
Следующая статьяКак PyPy ускоряет Python до уровня C?