TL; DR; Нет, for
петли одевают «плохо», по крайней мере, не всегда. Вероятно, точнее будет сказать, что некоторые векторизованные операции медленнее, чем итерация , вместо того, чтобы сказать, что итерация быстрее, чем некоторые векторизованные операции. Знание того, когда и почему, является ключом к максимальной производительности вашего кода. Вкратце, это ситуации, в которых стоит рассмотреть альтернативу векторизованным функциям pandas:
- Когда у вас мало данных (... в зависимости от того, что вы делаете),
- При работе с
object
/ смешанными типами dtypes
- При использовании
str
функций доступа / regex
Давайте рассмотрим эти ситуации индивидуально.
Итерация против векторизации малых данных
Pandas следует подходу «Соглашение вместо конфигурации» в своем дизайне API. Это означает, что один и тот же API был приспособлен для обслуживания широкого диапазона данных и вариантов использования.
Когда вызывается функция pandas, следующие вещи (среди прочего) должны обрабатываться внутри функции, чтобы гарантировать работу
- Выравнивание индекса / оси
- Обработка смешанных типов данных
- Обработка недостающих данных
Почти каждая функция будет иметь дело с ними в той или иной степени, а это накладные расходы . Накладные расходы меньше для числовых функций (например, Series.add
), в то время как они более выражены для строковых функций (например, Series.str.replace
).
for
петли, с другой стороны, быстрее, чем вы думаете. Что еще лучше, понимание списков (которые создают списки с помощью for
циклов) еще быстрее, поскольку они являются оптимизированными итерационными механизмами для создания списков.
Составление списков соответствует шаблону
[f(x) for x in seq]
Где seq
находится серия pandas или столбец DataFrame. Или при работе с несколькими столбцами
[f(x, y) for x, y in zip(seq1, seq2)]
Где seq1
и seq2
находятся столбцы.
Числовое сравнение
Рассмотрим простую логическую операцию индексирования. Метод понимания списка был приурочен к Series.ne
( !=
) и query
. Вот функции:
# Boolean indexing with Numeric value comparison.
df[df.A != df.B] # vectorized !=
df.query('A != B') # query (numexpr)
df[[x != y for x, y in zip(df.A, df.B)]] # list comp
Для простоты я использовал этот perfplot
пакет для запуска всех тестов timeit в этом посте. Сроки выполнения вышеуказанных операций указаны ниже:
Понимание списка превосходит query
для среднего размера N и даже превосходит векторизованное не равное сравнение для крошечного N. К сожалению, понимание списка масштабируется линейно, поэтому оно не дает большого прироста производительности для большего N.
Примечание.
Стоит упомянуть, что большая часть преимуществ понимания списка заключается в том, что вам не нужно беспокоиться о выравнивании индекса, но это означает, что если ваш код зависит от выравнивания индекса, это сломается. В некоторых случаях векторизованные операции над базовыми массивами NumPy можно рассматривать как привнесение «лучшего из обоих миров», позволяющее векторизовать без всех ненужных накладных расходов функций pandas. Это означает, что вы можете переписать описанную выше операцию как
df[df.A.values != df.B.values]
Что превосходит как панды, так и эквиваленты понимания списка:
векторизация NumPy выходит за рамки этого поста, но ее определенно стоит рассмотреть, если производительность имеет значение.
Подсчет значений
Возьмем другой пример - на этот раз с другой ванильной конструкцией Python, которая быстрее, чем цикл for - collections.Counter
. Обычное требование - вычислить количество значений и вернуть результат в виде словаря. Это делается с value_counts
, np.unique
и Counter
:
# Value Counts comparison.
ser.value_counts(sort=False).to_dict() # value_counts
dict(zip(*np.unique(ser, return_counts=True))) # np.unique
Counter(ser) # Counter
Результаты более выражены, Counter
превосходит оба векторизованных метода для большего диапазона малых N (~ 3500).
Примечание.
Еще мелочи (любезно предоставлено @ user2357112). Counter
Реализуется с помощью C ускорителя , так что в то время как он все еще должен работать с Python объектов вместо базовых типов данных C, это еще быстрее , чем for
цикл. Сила Python!
Конечно, отсюда следует, что производительность зависит от ваших данных и варианта использования. Смысл этих примеров - убедить вас не исключать эти решения как законные. Если они по-прежнему не дают нужной производительности, всегда есть cython и numba . Давайте добавим этот тест в микс.
from numba import njit, prange
@njit(parallel=True)
def get_mask(x, y):
result = [False] * len(x)
for i in prange(len(x)):
result[i] = x[i] != y[i]
return np.array(result)
df[get_mask(df.A.values, df.B.values)] # numba
Numba предлагает JIT-компиляцию цикличного кода Python в очень мощный векторизованный код. Понимание того, как заставить Numba работать, требует обучения.
Операции со смешанными object
типами / dtypes
Сравнение на основе строк.
Возвращаясь к примеру фильтрации из первого раздела, что, если сравниваемые столбцы являются строками? Рассмотрим те же 3 функции выше, но с преобразованием входного DataFrame в строку.
# Boolean indexing with string value comparison.
df[df.A != df.B] # vectorized !=
df.query('A != B') # query (numexpr)
df[[x != y for x, y in zip(df.A, df.B)]] # list comp
Итак, что изменилось? Здесь следует отметить, что операции со строками сложно векторизовать. Pandas обрабатывает строки как объекты, и все операции с объектами возвращаются к медленной, зацикленной реализации.
Теперь, поскольку эта зацикленная реализация окружена всеми упомянутыми выше накладными расходами, между этими решениями существует постоянная разница в величине, даже если они масштабируются одинаково.
Когда дело доходит до операций с изменяемыми / сложными объектами, сравнения нет. Понимание списков превосходит все операции с словарями и списками.
Доступ к значениям словаря по ключу
Вот время для двух операций, которые извлекают значение из столбца словарей: map
и понимание списка. Настройка находится в Приложении под заголовком «Фрагменты кода».
# Dictionary value extraction.
ser.map(operator.itemgetter('value')) # map
pd.Series([x.get('value') for x in ser]) # list comprehension
Позиционный Список Индексация
тайминги для 3 операций , которые извлекают 0 - й элемент из списка столбцов (обработка исключений), map
, str.get
аксессор метод , и список понимания:
# List positional indexing.
def get_0th(lst):
try:
return lst[0]
# Handle empty lists and NaNs gracefully.
except (IndexError, TypeError):
return np.nan
ser.map(get_0th) # map
ser.str[0] # str accessor
pd.Series([x[0] if len(x) > 0 else np.nan for x in ser]) # list comp
pd.Series([get_0th(x) for x in ser]) # list comp safe
Примечание.
Если индекс имеет значение, вам нужно сделать:
pd.Series([...], index=ser.index)
При реконструкции сериала.
Сглаживание
списков Последний пример - сглаживание списков. Это еще одна распространенная проблема, демонстрирующая, насколько мощным является чистый питон.
# Nested list flattening.
pd.DataFrame(ser.tolist()).stack().reset_index(drop=True) # stack
pd.Series(list(chain.from_iterable(ser.tolist()))) # itertools.chain
pd.Series([y for x in ser for y in x]) # nested list comp
И то, itertools.chain.from_iterable
и другое, а также вложенный список являются чистыми конструкциями Python и масштабируются намного лучше, чем stack
решение.
Эти тайминги убедительно свидетельствуют о том, что pandas не приспособлен для работы со смешанными типами данных, и что вам, вероятно, следует воздержаться от его использования для этого. По возможности данные должны быть представлены в виде скалярных значений (целые числа / числа с плавающей запятой / строки) в отдельных столбцах.
Наконец, применимость этих решений во многом зависит от ваших данных. Итак, лучше всего было бы протестировать эти операции на ваших данных, прежде чем решать, что делать. Обратите внимание, как я не рассчитал время apply
для этих решений, потому что это исказило бы график (да, это так медленно).
Операции с регулярными выражениями и .str
методы доступа
Pandas может применять операции регулярных выражений, такие как str.contains
, str.extract
и str.extractall
, а также другие «векторизованные» строковые операции (например str.split
, str.find ,
str.translate` и т. Д.) К строковым столбцам. Эти функции работают медленнее, чем составление списков, и предназначены для большего удобства, чем какие-либо другие.
Обычно намного быстрее предварительно скомпилировать шаблон регулярного выражения и перебрать ваши данные с помощью re.compile
(также см. Стоит ли использовать Python re.compile? ). Список comp, эквивалентный, str.contains
выглядит примерно так:
p = re.compile(...)
ser2 = pd.Series([x for x in ser if p.search(x)])
Или,
ser2 = ser[[bool(p.search(x)) for x in ser]]
Если вам нужно обрабатывать NaN, вы можете сделать что-то вроде
ser[[bool(p.search(x)) if pd.notnull(x) else False for x in ser]]
Составление списка, эквивалентное str.extract
(без групп), будет выглядеть примерно так:
df['col2'] = [p.search(x).group(0) for x in df['col']]
Если вам нужно обрабатывать несоответствия и NaN, вы можете использовать пользовательскую функцию (еще быстрее!):
def matcher(x):
m = p.search(str(x))
if m:
return m.group(0)
return np.nan
df['col2'] = [matcher(x) for x in df['col']]
matcher
Функция очень растяжимая. При необходимости его можно настроить для вывода списка для каждой группы захвата. Просто извлеките запрос group
или groups
атрибут объекта сопоставления.
Для str.extractall
измените p.search
на p.findall
.
Извлечение строки
Рассмотрим простую операцию фильтрации. Идея состоит в том, чтобы извлечь 4 цифры, если им предшествует заглавная буква.
# Extracting strings.
p = re.compile(r'(?<=[A-Z])(\d{4})')
def matcher(x):
m = p.search(x)
if m:
return m.group(0)
return np.nan
ser.str.extract(r'(?<=[A-Z])(\d{4})', expand=False) # str.extract
pd.Series([matcher(x) for x in ser]) # list comprehension
Дополнительные примеры
Полное раскрытие - я являюсь автором (частично или полностью) этих сообщений, перечисленных ниже.
Вывод
Как показано из приведенных выше примеров, итерация эффективна при работе с небольшими строками DataFrames, смешанными типами данных и регулярными выражениями.
Ускорение, которое вы получаете, зависит от ваших данных и вашей проблемы, поэтому ваш пробег может отличаться. Лучше всего тщательно запустить тесты и посмотреть, стоит ли выплата затраченных усилий.
«Векторизованные» функции блистают своей простотой и удобочитаемостью, поэтому, если производительность не критична, вы определенно должны предпочесть их.
Еще одно замечание: некоторые строковые операции имеют дело с ограничениями, которые способствуют использованию NumPy. Вот два примера, когда тщательная векторизация NumPy превосходит python:
Кроме того, иногда просто работа с базовыми массивами через, .values
а не с Series или DataFrames может предложить достаточно существенное ускорение для большинства обычных сценариев (см. Примечание в разделе « Сравнение числовых значений » выше). Так, например, df[df.A.values != df.B.values]
будет показано мгновенное повышение производительности df[df.A != df.B]
. Использование .values
может быть уместным не во всех ситуациях, но это полезно знать.
Как упоминалось выше, вам решать, стоит ли внедрять эти решения.
Приложение: фрагменты кода
import perfplot
import operator
import pandas as pd
import numpy as np
import re
from collections import Counter
from itertools import chain
# Boolean indexing with Numeric value comparison.
perfplot.show(
setup=lambda n: pd.DataFrame(np.random.choice(1000, (n, 2)), columns=['A','B']),
kernels=[
lambda df: df[df.A != df.B],
lambda df: df.query('A != B'),
lambda df: df[[x != y for x, y in zip(df.A, df.B)]],
lambda df: df[get_mask(df.A.values, df.B.values)]
],
labels=['vectorized !=', 'query (numexpr)', 'list comp', 'numba'],
n_range=[2**k for k in range(0, 15)],
xlabel='N'
)
# Value Counts comparison.
perfplot.show(
setup=lambda n: pd.Series(np.random.choice(1000, n)),
kernels=[
lambda ser: ser.value_counts(sort=False).to_dict(),
lambda ser: dict(zip(*np.unique(ser, return_counts=True))),
lambda ser: Counter(ser),
],
labels=['value_counts', 'np.unique', 'Counter'],
n_range=[2**k for k in range(0, 15)],
xlabel='N',
equality_check=lambda x, y: dict(x) == dict(y)
)
# Boolean indexing with string value comparison.
perfplot.show(
setup=lambda n: pd.DataFrame(np.random.choice(1000, (n, 2)), columns=['A','B'], dtype=str),
kernels=[
lambda df: df[df.A != df.B],
lambda df: df.query('A != B'),
lambda df: df[[x != y for x, y in zip(df.A, df.B)]],
],
labels=['vectorized !=', 'query (numexpr)', 'list comp'],
n_range=[2**k for k in range(0, 15)],
xlabel='N',
equality_check=None
)
# Dictionary value extraction.
ser1 = pd.Series([{'key': 'abc', 'value': 123}, {'key': 'xyz', 'value': 456}])
perfplot.show(
setup=lambda n: pd.concat([ser1] * n, ignore_index=True),
kernels=[
lambda ser: ser.map(operator.itemgetter('value')),
lambda ser: pd.Series([x.get('value') for x in ser]),
],
labels=['map', 'list comprehension'],
n_range=[2**k for k in range(0, 15)],
xlabel='N',
equality_check=None
)
# List positional indexing.
ser2 = pd.Series([['a', 'b', 'c'], [1, 2], []])
perfplot.show(
setup=lambda n: pd.concat([ser2] * n, ignore_index=True),
kernels=[
lambda ser: ser.map(get_0th),
lambda ser: ser.str[0],
lambda ser: pd.Series([x[0] if len(x) > 0 else np.nan for x in ser]),
lambda ser: pd.Series([get_0th(x) for x in ser]),
],
labels=['map', 'str accessor', 'list comprehension', 'list comp safe'],
n_range=[2**k for k in range(0, 15)],
xlabel='N',
equality_check=None
)
# Nested list flattening.
ser3 = pd.Series([['a', 'b', 'c'], ['d', 'e'], ['f', 'g']])
perfplot.show(
setup=lambda n: pd.concat([ser2] * n, ignore_index=True),
kernels=[
lambda ser: pd.DataFrame(ser.tolist()).stack().reset_index(drop=True),
lambda ser: pd.Series(list(chain.from_iterable(ser.tolist()))),
lambda ser: pd.Series([y for x in ser for y in x]),
],
labels=['stack', 'itertools.chain', 'nested list comp'],
n_range=[2**k for k in range(0, 15)],
xlabel='N',
equality_check=None
)
# Extracting strings.
ser4 = pd.Series(['foo xyz', 'test A1234', 'D3345 xtz'])
perfplot.show(
setup=lambda n: pd.concat([ser4] * n, ignore_index=True),
kernels=[
lambda ser: ser.str.extract(r'(?<=[A-Z])(\d{4})', expand=False),
lambda ser: pd.Series([matcher(x) for x in ser])
],
labels=['str.extract', 'list comprehension'],
n_range=[2**k for k in range(0, 15)],
xlabel='N',
equality_check=None
)
pd.Series
иpd.DataFrame
теперь поддерживаем конструкцию из итераций. Это означает, что можно просто передать генератор Python в функции конструктора, вместо того, чтобы сначала создавать список (с использованием понимания списка), что во многих случаях может быть медленнее. Однако размер выходной мощности генератора нельзя определить заранее. Я не уверен, сколько времени / памяти это вызовет.