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

81

Я использую фреймы данных Pandas и хочу создать новый столбец как функцию существующих столбцов. Я не видел хорошего обсуждения разницы в скорости между 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

Если я увеличиваюсь Nдо реальных размеров, таких как 1 миллион или больше, то вижу, что 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

stackoverflowuser2010
источник
Я не вдавался в подробности вашего вопроса, но np.vectorizeв основном это forцикл Python (это удобный метод), а applyлямбда также находится во времени Python
roganjosh
«Если np.vectorize () в целом всегда быстрее, чем df.apply (), то почему np.vectorize () больше не упоминается?» Потому что вы не должны использовать applyпострочно, если только вам не нужно, и, очевидно, векторизованная функция будет превосходить не векторизованную.
PMende
1
@PMende, но np.vectorizeне векторизован. Это известное неправильное название
roganjosh
1
@PMende, Конечно, другого не имел в виду. Вы не должны строить свое мнение о реализации по таймингу. Да, они проницательны. Но они могут заставить вас предполагать неправду.
jpp 05
3
@PMende поиграйте с .strаксессуарами pandas . Во многих случаях они медленнее, чем понимание списков. Мы слишком много предполагаем.
roganjosh 05

Ответы:

115

Я начну с того, что мощь массивов Pandas и NumPy проистекает из высокопроизводительных векторизованных вычислений над числовыми массивами. 1 Вся суть векторизованных вычислений состоит в том, чтобы избежать петель на уровне Python, перемещая вычисления в высокооптимизированный код C и используя непрерывные блоки памяти. 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основанные методы (первые 4) являются фактор более эффективным , чем pd.Seriesоснованные методы (последние 3).
  2. np.vectorize, понимание списка + zipи mapметоды, т. е. тройка лучших, имеют примерно одинаковую производительность. Это потому, что они используют tuple и обходят некоторые накладные расходы Pandas из pd.DataFrame.itertuples.
  3. Существует значительное улучшение скорости от использования raw=Trueс pd.DataFrame.applyпротив без. Этот параметр передает массивы NumPy пользовательской функции вместо pd.Seriesобъектов.

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

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

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последовательные кортежи входных массивов, такие как функция карты 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. Кроме того, там, где это возможно, используются оптимизированные алгоритмы.
jpp
источник
1
@jpp: использование декоратора с parallelаргументом @njit(parallel=True)дает мне дальнейшее улучшение по сравнению с just @njit. Возможно, вы тоже можете это добавить.
Шелдор 06
1
У вас есть двойная проверка на b [i]! = 0. Обычное поведение Python и Numba - проверить на 0 и выдать ошибку. Это, вероятно, нарушает любую векторизацию SIMD и обычно сильно влияет на скорость выполнения. Но вы можете изменить это в Numba на @njit (error_model = 'numpy'), чтобы избежать двойной проверки деления на 0. Также рекомендуется выделить память с помощью np.empty и установить результат в 0 в операторе else.
max9111 07
1
error_model numpy использует то, что дает процессор при делении на 0 -> NaN. По крайней мере, в Numba 0.41dev обе версии используют SIMD-векторизацию. Вы можете проверить это, как описано здесь numba.pydata.org/numba-doc/dev/user/faq.html (1.16.2.3. Почему мой цикл не векторизован?) Я бы просто добавил оператор else к вашей функции (res [ i] = 0.) и allcocate память с np.empty. Это должно в сочетании с error_model = 'numpy' повысить производительность примерно на 20%. На более старых версиях Numba было большее влияние на производительность ...
max9111 07
2
@ stackoverflowuser2010, универсального ответа "для произвольных функций" нет. Вы должны выбрать правильный инструмент для правильной работы, которая является частью понимания программирования / алгоритмов.
jpp
1
Счастливых праздников!
cs95
5

Чем сложнее становятся ваши функции (т. Е. Чем меньше 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)

Делаем некоторые тайминги:

Использование Apply

%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 в ufuncобъекты numpy, когда вы вызываете np.vectorize. Как это происходит, я на самом деле не знаю - вам придется копаться во внутренностях numpy больше, чем я хочу в банкомате. Тем не менее, похоже, что он лучше справляется с простыми числовыми функциями, чем эта строковая функция здесь.

Прокрутка размера до 1000000:

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)
PMende
источник
Что, если вы увеличите это число до size=1000000(1 миллиона)?
stackoverflowuser2010
2
Я почти уверен, что ваши утверждения здесь неверны. Я не могу пока подкрепить это утверждение кодом, надеюсь, может кто-то другой
roganjosh
@ stackoverflowuser2010 Я обновил его вместе с фактическим векторизованным подходом.
PMende
0

Я новичок в питоне. Но в приведенном ниже примере «применить», кажется, работает быстрее, чем «векторизация», или я что-то упустил.

 import numpy as np
 import pandas as pd

 B = np.random.rand(1000,1000)
 fn = np.vectorize(lambda l: 1/(1-np.exp(-l)))
 print(fn(B))

 B = pd.DataFrame(np.random.rand(1000,1000))
 fn = lambda l: 1/(1-np.exp(-l))
 print(B.apply(fn))
fordlab22
источник