Вступление

В этой статье я объясню, почему itertuples() функция pandas работает быстрее, чем iterrows(). Что еще более важно, я поделюсь инструментами и методами, которые я использовал для обнаружения источника узкого места в iterrows(). К концу этой статьи вы будете оснащены основными инструментами для профилирования и оптимизации вашего кода Python.

Код для воспроизведения результатов, описанных в этой статье, доступен здесь. Я предполагаю, что у читателя есть приличный опыт написания кода Python для производственного использования.

Мотивация

Представьте, что вы попали в такой сценарий:

Вы - специалист по анализу данных, которому поручено создать веб-API, чтобы определить, содержит ли изображение кота, исходя из набора изображений. Вы решаете использовать Django для создания компонента API и, чтобы упростить задачу, также встраиваете код классификатора изображений в ту же базу кода. Вы проводите пару недель, работая над этим проектом, и обнаруживаете, что ваше веб-приложение слишком медленное для производственного использования. Вы обращаетесь за советом к своему коллеге, который является инженером-программистом. Этот коллега говорит вам, что Python медленный и что для всего, что связано с API, Go - лучший инструмент.

Вы все переписываете на Go (включая изучение нового веб-фреймворка) или пытаетесь систематически определять, что вызывает медленную работу вашего кода Python?

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

Постановка задачи

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

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

тогда содержимое словаря будет выглядеть так:

Ключ словаря может быть кортежем (col1, col2) или другим словарем, где первый ключ - col1, а второй ключ - col2. Точные детали реализации не имеют значения. Дело в том, что вам нужен словарь, который отслеживает количество всех возможных пар в столбцах col1 и col2.

Решение

Iterrows () Решение

Вот как могло бы выглядеть iterrows() решение с учетом постановки проблемы, описанной в предыдущем разделе:

big_df - это фрейм данных, содержимое которого аналогично показанному на рисунке 1, за исключением того, что в нем 3 миллиона строк вместо 5.

На моей машине это решение заняло почти 12 минут.

Itertuples () Решение

Вот что нужно itertuples() решению:

Это решение заняло всего 8,68 секунды, что примерно в 83 раза быстрее, чем решение iterrows().

Анализ

Так почему itertuples() намного быстрее по сравнению с iterrows()?

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

Python поставляется со встроенным профилировщиком, который можно удобно вызвать из записной книжки Jupyter с помощью магии %%prun ячеек.

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

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

Согласно рисунку 5, решение itertuples() выполнило 3935 вызовов функций за 0,003 секунды для обработки 1000 строк. Больше всего времени на выполнение заняла функция _make, которая вызывалась 1000 раз, потребляя 0,001 секунды времени выполнения. Эта функция принадлежит модулю collections и определена здесь.

_make просто создает кортеж из итерируемого объекта, и, поскольку у нас есть 1000 строк, имеет смысл, что эта функция вызывается 1000 раз (итерация в каждом вызове является строкой в ​​нашем фреймворке данных). Отметив, что общее время, которое заняло это решение, составило 0,003 секунды, а остальные функции заняли 0 секунд, давайте перейдем к анализу выходных данных на рисунке 6.

На рисунке 6 показано, что решение iterrows() выполнило 295 280 вызовов функций за 0,254 секунды. По сравнению с решением itertuples(), все 10 функций в решении iterrows() имеют ненулевые tottime значения. Более того, фактический вызов iterrows() даже не входит в список из 10 основных функций, выполнение которых заняло больше всего времени. Напротив, вызов itertuples() в решении itertuples() занимает позицию 7 на рисунке 5.

Это говорит о том, что вызов iterrows() связан с большими накладными расходами. Глядя на список вызываемых функций, мы видим, что эти накладные расходы относятся к коду проверки типов, например is_instance и _check в первой и второй строке соответственно. Вы можете проверить, что это так, вручную выполнив iterrows() с помощью отладчика.

Вот и все. Причина, по которой iterrows() работает медленнее, чем itertuples(), заключается в том, что iterrows() выполняет множество проверок типа в течение времени жизни его вызова. Теперь давайте посмотрим, что мы можем сделать с этим пониманием.

Применение: создание более быстрого решения

Предположим, мы не знали, что функция itertuples() существует. Что мы можем сделать, чтобы улучшить производительность итерации строк? Что ж, в предыдущем разделе мы определили, что узкое место связано с чрезмерной проверкой типов, поэтому хорошей первой попыткой решения является создание структуры данных, которая не выполняет проверки типов. Вот пример:

Строка 3 на рисунке 7 показывает, что мы создаем наши строки для перебора, просто архивируя соответствующие столбцы. Это решение заняло всего 5 секунд, чтобы выполнить более 3 миллионов строк, что почти в два раза быстрее, чем решение itertuples(). Давайте назовем наше решение custom solution и профилируем его, чтобы увидеть, сможем ли мы определить источник ускорения.

Вот 10 основных функций, выполнение которых в нашем custom решении потребовало больше всего времени для фрейма данных из 1000 строк:

На рисунке 8 поражает то, что на нем показано, что пользовательское решение выполнило только 233 вызова функций за 0,002 секунды. Это удивительно для меня, поскольку я ожидал минимум 1000 вызовов, поскольку мы все еще повторяем более 1000 строк.

Давайте посмотрим, какая функция вызывается чаще всего, отсортировав столбец ncalls в порядке убывания:

На рисунке 9 показано, что самой вызываемой функцией является isinstance, которая вызывалась только 39 раз. Это по-прежнему не дает никакой полезной информации, чтобы выяснить, как выполнялась итерация с в общей сложности менее 1000 вызовов функций.

Другой полезный метод профилирования - это профилирование строк нашего кода, то есть увидеть, сколько раз выполняется каждая строка и сколько времени это заняло. У Jupyter есть линейная магия под названием %lprun, которая поставляется с пакетом line_profile.

Вот как выглядит профиль линии для нашего custom решения:

Как и ожидалось, мы видим, что итерация действительно повторяется 1000 раз (строка 12). Это говорит о том, что повторение n строк обязательно означает необходимость вызова функции n раз. Итак, следующий логичный вопрос, который следует задать: кто звонит _make на Рисунке 5 1000 раз и есть ли способ избежать / уменьшить количество звонков?

К счастью для нас, Python поставляется с модулем pstats, который позволяет нам глубже изучить вывод выполнения профиля функции. Я отсылаю читателя к коду, прилагаемому к этой статье, чтобы узнать, как получить эту информацию. В любом случае, вот все функции, которые вызывали _make:

В этом случае вывод вообще бесполезен (<string:1(<module>) относится к коду верхнего уровня для «сценария», переданного профилировщику, который является содержимым всей ячейки, реализующей решение itertuples()).

Другой способ выяснить, кто вызывает _make, - это вставить точку останова внутри _make и затем выполнить решение внутри отладчика. Когда достигается точка останова, мы можем отслеживать кадры, чтобы увидеть цепочку вызовов, которые привели к _make.

Это показывает, что 1000 вызовов _make исходят от вызова itertuples(), как показано здесь. На следующем рисунке показана наиболее интересная часть itertuples():

На рисунке 12 показано, что существует 1000 вызовов _make, потому что строка 927 возвращает карту, которая в основном вызывает _make для каждой строки в фрейме данных. Интересная часть этого фрагмента заключается в том, что вызов map вложен в оператор if, где одно из условий состоит в том, что параметр name в itertuples() не должен быть None. Если это так, то он вернет итератор, который выполняет итерацию по сжатым столбцам в фрейме данных… что то же самое, что и наше custom solution!

В документации itertuples() сказано, что если параметр name является строкой, то он вернет именованные кортежи с заданным name. Если name равно None, вместо этого он вернет обычные кортежи. Наш код будет работать так же хорошо, независимо от типа возвращаемого значения itertuples(). Итак, давайте предпочтем обычные кортежи, а не именованные, чтобы мы могли пропустить 1000 вызовов _make. Вот что происходит, когда мы устанавливаем параметр name в itertuples() на None:

Решение itertuples(name=None) конкурирует с нашим решением custom. Для перебора 3 миллионов строк потребовалось 5,18 секунды, тогда как наше custom решение заняло всего 4,92 секунды.

Заключение

В этой статье читателю показано, как использовать блокнот Jupyter, чтобы:

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

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

Я надеюсь, что вы рассмотрите возможность применения методов, описанных в этой статье, в следующий раз, когда столкнетесь с «медленным» кодом Python. Дайте мне знать в комментариях, если у вас возникнут вопросы.

использованная литература

Документация по модулю профиля Python