Производительность Pandas применяется vs np.vectorize для создания нового столбца из существующих столбцов

Я использую Pandas dataframes и хочу создать новый столбец как функцию существующих столбцов. Я не видел хорошего обсуждения разницы в скорости между df.apply() и np.vectorize(), поэтому я подумал, что попрошу здесь.

Функция Pandas apply() работает медленно. Из того, что я измерил (показано ниже в некоторых экспериментах), использование np.vectorize() на 25 раз быстрее (или больше), чем использование функции DataFrame apply(), по крайней мере, на моем MacBook Pro 2016. Является ли это ожидаемым результатом и почему?

Например, предположим, что у меня есть следующий фреймворк с N строками:

N = 10
A_list = np.random.randint(1, 100, N)
B_list = np.random.randint(1, 100, N)
df = pd.DataFrame({'A': A_list, 'B': B_list})
df.head()
#     A   B
# 0  78  50
# 1  23  91
# 2  55  62
# 3  82  64
# 4  99  80

Предположим далее, что я хочу создать новый столбец как функцию двух столбцов A и B В приведенном ниже примере я буду использовать простую функцию divide(). Чтобы применить эту функцию, я могу использовать либо df.apply() либо np.vectorize():

def divide(a, b):
    if b == 0:
        return 0.0
    return float(a)/b

df['result'] = df.apply(lambda row: divide(row['A'], row['B']), axis=1)

df['result2'] = np.vectorize(divide)(df['A'], df['B'])

df.head()
#     A   B    result   result2
# 0  78  50  1.560000  1.560000
# 1  23  91  0.252747  0.252747
# 2  55  62  0.887097  0.887097
# 3  82  64  1.281250  1.281250
# 4  99  80  1.237500  1.237500

Если я np.vectorize() N до размеров реального мира, таких как 1 миллион или более, я np.vectorize() что np.vectorize() на 25 раз быстрее или больше, чем df.apply().

Ниже приведен полный код бенчмаркинга:

import pandas as pd
import numpy as np
import time

def divide(a, b):
    if b == 0:
        return 0.0
    return float(a)/b

for N in [1000, 10000, 100000, 1000000, 10000000]:    

    print ''
    A_list = np.random.randint(1, 100, N)
    B_list = np.random.randint(1, 100, N)
    df = pd.DataFrame({'A': A_list, 'B': B_list})

    start_epoch_sec = int(time.time())
    df['result'] = df.apply(lambda row: divide(row['A'], row['B']), axis=1)
    end_epoch_sec = int(time.time())
    result_apply = end_epoch_sec - start_epoch_sec

    start_epoch_sec = int(time.time())
    df['result2'] = np.vectorize(divide)(df['A'], df['B'])
    end_epoch_sec = int(time.time())
    result_vectorize = end_epoch_sec - start_epoch_sec


    print 'N=%d, df.apply: %d sec, np.vectorize: %d sec' % \
            (N, result_apply, result_vectorize)

    # Make sure results from df.apply and np.vectorize match.
    assert(df['result'].equals(df['result2']))

Результаты показаны ниже:

N=1000, df.apply: 0 sec, np.vectorize: 0 sec

N=10000, df.apply: 1 sec, np.vectorize: 0 sec

N=100000, df.apply: 2 sec, np.vectorize: 0 sec

N=1000000, df.apply: 24 sec, np.vectorize: 1 sec

N=10000000, df.apply: 262 sec, np.vectorize: 4 sec

Если np.vectorize() обычно всегда быстрее df.apply(), то почему np.vectorize() больше не упоминается? Я только вижу сообщения StackOverflow, связанные с df.apply(), например:

pandas создает новый столбец на основе значений из других столбцов

Как использовать функцию приложения Pandas для нескольких столбцов?

Как применить функцию к двум столбцам кадра данных Pandas

Ответы

Ответ 1

Начну с того, что мощь массивов Pandas и NumPy получена из высокопроизводительных векторизованных вычислений на числовых массивах. 1 Вся точка векторизованных вычислений состоит в том, чтобы избежать петель уровня, перемещая вычисления в высоко оптимизированный код С и используя смежные блоки памяти. 2

Петли уровня Python

Теперь мы можем посмотреть некоторые тайминги. Ниже приведены все циклы уровня Python, которые производят либо pd.Series, np.ndarray либо объекты list содержащие одни и те же значения. Для целей присвоения серии в рамках кадра данных результаты сопоставимы.

# Python 3.6.5, NumPy 1.14.3, Pandas 0.23.0

np.random.seed(0)
N = 10**5

%timeit list(map(divide, df['A'], df['B']))                                   # 43.9 ms
%timeit np.vectorize(divide)(df['A'], df['B'])                                # 48.1 ms
%timeit [divide(a, b) for a, b in zip(df['A'], df['B'])]                      # 49.4 ms
%timeit [divide(a, b) for a, b in df[['A', 'B']].itertuples(index=False)]     # 112 ms
%timeit df.apply(lambda row: divide(*row), axis=1, raw=True)                  # 760 ms
%timeit df.apply(lambda row: divide(row['A'], row['B']), axis=1)              # 4.83 s
%timeit [divide(row['A'], row['B']) for _, row in df[['A', 'B']].iterrows()]  # 11.6 s

Некоторые вынос:

  1. Методы tuple -based (первые 4) являются более эффективными, чем pd.Series -based (последние 3).
  2. np.vectorize, понимание списка + методы zip и map, т.е. верхние 3, имеют примерно такую же производительность. Это связано с тем, что они используют tuple и обходят некоторые из pd.DataFrame.itertuples с помощью pd.DataFrame.itertuples.
  3. Существует значительное улучшение скорости с использованием raw=True с pd.DataFrame.apply и без него. Этот параметр подает массивы NumPy в пользовательскую функцию вместо объектов pd.Series.

pd.DataFrame.apply: просто еще один цикл

Чтобы точно увидеть объекты, которые проходит Панда, вы можете изменить свою функцию тривиально:

def foo(row):
    print(type(row))
    assert False  # because you only need to see this once
df.apply(lambda row: foo(row), axis=1)

Вывод: <class 'pandas.core.series.Series'>. Создание, передача и запрос объекта серии Pandas несет значительные накладные расходы по сравнению с массивами NumPy. Это не должно удивлять: серия Pandas включает приличное количество лесов для хранения индекса, значений, атрибутов и т.д.

Повторите то же упражнение с raw=True и вы увидите <class 'numpy.ndarray'>. Все это описано в документах, но, видимо, это более убедительно.

np.vectorize: поддельная векторизация

Документы для np.vectorize имеют следующее примечание:

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

"Правила вещания" здесь неактуальны, поскольку входные массивы имеют одинаковые размеры. Параллель с map поучительна, так как версия map выше имеет почти идентичную производительность. Исходный код показывает, что происходит: np.vectorize преобразует вашу входную функцию в универсальную функцию ("ufunc") через np.frompyfunc. Существует некоторая оптимизация, например кэширование, что может привести к некоторому повышению производительности.

Короче говоря, np.vectorize делает то, что должен делать цикл уровня Python, но pd.DataFrame.apply добавляет короткие накладные расходы. Там нет JIT-компиляции, которую вы видите с numba (см. Ниже). Это просто удобство.

Истинная векторизация: что вы должны использовать

Почему не упоминаются вышеперечисленные различия? Поскольку выполнение действительно векторных вычислений делает их несущественными:

%timeit np.where(df['B'] == 0, 0, df['A'] / df['B'])       # 1.17 ms
%timeit (df['A'] / df['B']).replace([np.inf, -np.inf], 0)  # 1.96 ms

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

numba.njit: большая эффективность

Когда циклы считаются жизнеспособными, они, как правило, оптимизируются с помощью numba с базовыми массивами NumPy, чтобы как можно больше перемещаться на C.

Действительно, numba повышает производительность до микросекунд. Без какой-либо громоздкой работы, будет сложно получить гораздо более эффективную, чем это.

from numba import njit

@njit
def divide(a, b):
    res = np.empty(a.shape)
    for i in range(len(a)):
        if b[i] != 0:
            res[i] = a[i] / b[i]
        else:
            res[i] = 0
    return res

%timeit divide(df['A'].values, df['B'].values)  # 717 µs

Использование @njit(parallel=True) может обеспечить дополнительный импульс для больших массивов.


1 Числовые типы включают: int, float, datetime, bool, category. Они исключают object dtype и могут удерживаться в смежных блоках памяти.

2 Существует как минимум 2 причины, по которым операции NumPy эффективны по сравнению с Python:

  • Все в Python - это объект. Это включает, в отличие от чисел C. Поэтому типы Python имеют накладные расходы, которые не существуют с родными типами C.
  • Методами NumPy обычно являются C -based. Кроме того, по возможности используются оптимизированные алгоритмы.

Ответ 2

Чем сложнее ваши функции, тем лучше, чем меньше numpy может перейти к своим внутренним numpy, тем больше вы увидите, что производительность не будет такой разной. Например:

name_series = pd.Series(np.random.choice(['adam', 'chang', 'eliza', 'odom'], replace=True, size=100000))

def parse_name(name):
    if name.lower().startswith('a'):
        return 'A'
    elif name.lower().startswith('e'):
        return 'E'
    elif name.lower().startswith('i'):
        return 'I'
    elif name.lower().startswith('o'):
        return 'O'
    elif name.lower().startswith('u'):
        return 'U'
    return name

parse_name_vec = np.vectorize(parse_name)

Выполнение некоторых таймингов:

Использование приложения

%timeit name_series.apply(parse_name)

Результаты:

76.2 ms ± 626 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)

Использование np.vectorize

%timeit parse_name_vec(name_series)

Результаты:

77.3 ms ± 216 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)

Numpy пытается превратить функции python в объекты numpy ufunc при вызове np.vectorize. Как он это делает, я действительно не знаю - вам придется больше копать в внутренности numpy, чем я готов к ATM. Тем не менее, похоже, что это лучше работает на простых числовых функциях, чем здесь.

Сокращение размера до 1 000 000:

name_series = pd.Series(np.random.choice(['adam', 'chang', 'eliza', 'odom'], replace=True, size=1000000))

apply

%timeit name_series.apply(parse_name)

Результаты:

769 ms ± 5.88 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

np.vectorize

%timeit parse_name_vec(name_series)

Результаты:

794 ms ± 4.85 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

Лучший (векторизованный) способ с np.select:

cases = [
    name_series.str.lower().str.startswith('a'), name_series.str.lower().str.startswith('e'),
    name_series.str.lower().str.startswith('i'), name_series.str.lower().str.startswith('o'),
    name_series.str.lower().str.startswith('u')
]
replacements = 'A E I O U'.split()

Тайминги:

%timeit np.select(cases, replacements, default=name_series)

Результаты:

67.2 ms ± 683 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)