Введение
Из всех парадигм программирования для науки о данных лучше всего подходит функциональное программирование (ФП). Ключевая концепция функционального программирования — это функция. Отсюда же произошло и название парадигмы. Каждая функция принимает данные на вход, а на выходе возвращает их измененную версию. Например, функция 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
Читайте также:
- 4 типа архитектуры программного обеспечения
- 5 ведущих шаблонов проектирования распределенных систем
- Шаблоны функционального программирования. Рецепты
Читайте нас в Telegram, VK и Дзен
Перевод статьи Paul Hiemstra: Advanced functional programming for data science: building code architectures with function operators