Ответ 1
TL;DR; Нет, for
циклы не являются "плохими", по крайней мере, не всегда. Вероятно, правильнее будет сказать, что некоторые векторизованные операции медленнее, чем итерация, вместо того, чтобы говорить, что итерация быстрее, чем некоторые векторизованные операции. Знание того, когда и почему является ключом к максимальной производительности вашего кода. В двух словах, это ситуации, когда стоит рассмотреть альтернативу векторизованным функциям панд:
- Когда ваши данные небольшие (... в зависимости от того, что вы делаете),
- При работе с
object
/смешанными типами - При использовании функций доступа
str
/regex
Давайте рассмотрим эти ситуации индивидуально.
Итерация v/s Векторизация на малых данных
Pandas придерживается подхода "Соглашение о конфигурации" в своем дизайне API. Это означает, что один и тот же API был приспособлен для обслуживания широкого спектра данных и вариантов использования.
Когда вызывается функция pandas, следующие функции (среди прочего) должны быть внутренне обработаны функцией, чтобы обеспечить работу
- Индекс/выравнивание оси
- Обработка смешанных типов данных
- Обработка пропущенных данных
Почти каждая функция должна иметь дело с этим в разной степени, и это накладные расходы. Series.add
меньше для числовых функций (например, Series.add
), тогда как они более выражены для строковых функций (например, Series.str.replace
).
for
петель, с другой стороны, быстрее, чем вы думаете. Что еще лучше, это списки (которые создают списки for
циклов) еще быстрее, поскольку они оптимизированы итеративными механизмами для создания списков.
Список пониманий следует шаблону
[f(x) for x in seq]
Где seq
- серия панд или столбец 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 могут рассматриваться как привносящие "лучшее из обоих миров", позволяющее векторизацию без всех ненужных накладных расходов на функции панд. Это означает, что вы можете переписать вышеуказанную операцию как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
. Сила питона!
Конечно, отсюда следует, что производительность зависит от ваших данных и варианта использования. Смысл этих примеров в том, чтобы убедить вас не исключать эти решения в качестве законных вариантов. Если они по-прежнему не дают нужной вам производительности, всегда есть 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
Сравнение на основе строк
Возвращаясь к примеру фильтрации из первого раздела, что если сравниваемые столбцы являются строками? Рассмотрим те же 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
доступа 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)
При реконструкции серии.
Сглаживание списка
Последний пример - выравнивание списков. Это еще одна распространенная проблема, и она демонстрирует, насколько мощный чистый Python здесь.
# 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
.
Эти сроки являются ярким свидетельством того, что панды не оборудованы для работы со смешанными типами, и что вам, вероятно, следует воздерживаться от использования этого для этого. Везде, где это возможно, данные должны быть представлены в виде скалярных значений (целые числа/числа с плавающей запятой/строки) в отдельных столбцах.
Наконец, применимость этих решений в значительной степени зависит от ваших данных. Поэтому лучше всего протестировать эти операции над вашими данными, прежде чем решать, что делать. Обратите внимание, как я не рассчитывал apply
эти решения, потому что это исказило бы график (да, это так медленно).
Операции Regex и методы .str
Pandas может применять операции регулярного выражения, такие как str.contains
, str.extract
и str.extractall
, а также другие "векторизованные" строковые операции (такие как str.split
, str.find ,
str.translate 'и т.д.) В отношении строковые столбцы. Эти функции медленнее, чем списки, и предназначены для того, чтобы быть более удобными функциями, чем что-либо еще.
Обычно гораздо быстрее предварительно скомпилировать шаблон регулярного выражения и re.compile
итерации по вашим данным с помощью 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]]
Список comp, эквивалентный 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
атрибута объекта matcher.
Для 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
)