TLDR; Ні, for
петлі не ковдри «погані», принаймні, не завжди. Напевно, точніше сказати, що деякі векторизовані операції повільніші, ніж ітераційні , проти того, що кажуть, що ітерація швидша, ніж деякі векторизовані операції. Знання, коли і чому є ключовим для отримання максимальної продуктивності вашого коду. Коротше кажучи, це такі ситуації, коли варто розглянути альтернативу векторизованим функціям панди:
- Коли ваші дані невеликі (... залежно від того, що ви робите),
- При роботі з
object
/ змішаними типами
- При використанні
str
функцій аксесуара / regex
Давайте розглянемо ці ситуації окремо.
Ітерація v / s векторизація на малих даних
У дизайні API Pandas дотримується підходу "Конвенція про конфігурацію" . Це означає, що той самий API був пристосований для обслуговування широкого кола даних та випадків використання.
Коли викликається функція панди, функція повинна внутрішньо обробляти наступні речі (серед інших), щоб забезпечити роботу
- Вирівнювання індексу / осі
- Обробка змішаних типів даних
- Обробка відсутніх даних
Практично кожна функція повинна мати справу з цими різними масштабами, і це означає накладні витрати . Накладні витрати менше для числових функцій (наприклад, 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
пакет для запуску всіх тестів часу в цій публікації. Терміни виконання операцій вище:
Зрозуміння списку перевершує query
середній розмір N, і навіть перевершує векторизований, що не дорівнює порівнянню для крихітного Н. На жаль, розуміння списку масштабується лінійно, тому воно не пропонує великого збільшення продуктивності для більшого N.
Примітка.
Варто зазначити, що більша частина переваг розуміння списку пов'язана з тим, що не потрібно турбуватися про вирівнювання індексу, але це означає, що якщо ваш код буде залежати від вирівнювання індексів, це порушиться. У деяких випадках векторизовані операції над базовими масивами NumPy можна вважати такими, що приносять "найкраще з обох світів", що дозволяє векторизувати без усіх зайвих накладних функцій панди. Це означає, що ви можете переписати операцію вище як
df[df.A.values != df.B.values]
Що перевершує панди та еквіваленти списку розуміння:
Векторизація NumPy виходить за рамки цієї публікації, але це, безумовно, варто врахувати, якщо продуктивність має значення.
Значення рахунків
Беручи ще один приклад - на цей раз, з іншою конструкцією ванільного пітона, яка швидше, ніж для циклу - 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!
Зрозуміло, що звідси випливає, що ефективність залежить від ваших даних та випадку використання. Суть цих прикладів - переконати вас не виключати ці рішення як законні варіанти. Якщо вони все ще не дають тобі потрібної продуктивності, завжди є цитон і нумба . Додамо цей тест в суміш.
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 з коду пітону на дуже потужний векторизований код. Розуміння того, як зробити роботу numba, передбачає криву навчання.
Операції зі змішаними / object
dtypes
Порівняння на основі рядків
Перегляньте приклад фільтрування з першого розділу, що робити, якщо стовпці, що порівнюються, є рядками? Розгляньте ті ж самі три функції, але з вхідним 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
розуміння списку вкладених списків є чистою конструкцією пітона, і масштаб набагато краще, ніж stack
рішення.
Ці терміни є яскравим свідченням того, що панди не облаштовані для роботи зі змішаними типами, і що ви, мабуть, повинні утриматися від використання цього для цього. По можливості, дані повинні бути подані у вигляді скалярних значень (ints / floats / string) в окремих стовпцях.
Нарешті, застосовність цих рішень значною мірою залежить від ваших даних. Отже, найкраще зробити це перевірити ці операції на своїх даних, перш ніж вирішити, що робити. Зауважте, як я не приурочував apply
ці рішення, тому що це перекривить графік (так, це так повільно).
Операції Regex та .str
методи аксесуарів
Панди можуть застосовувати регекс-операції, такі як str.contains
, str.extract
і str.extractall
, а також інші "векторизовані" рядкові операції (такі як str.split
, str.find ,
str.translate` тощо) на стовпці рядків. Ці функції повільніше, ніж розуміння списків, і призначені для функцій зручнішого, ніж будь-що інше.
Зазвичай набагато швидше заздалегідь скласти шаблон зразка та повторити свої дані за допомогою re.compile
(також див. Чи варто використовувати re.compile Python? ). Список відповідає еквіваленту 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
атрибут об'єкта 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 перевершує пітон:
Крім того, іноді просто робота над базовими масивами через, .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 функціям конструктора, а не потрібно спочатку складати список (використовуючи розуміння списку), що може бути повільніше у багатьох випадках. Однак розмір вихідної потужності генератора заздалегідь не можна визначити. Я не впевнений, скільки часу / пам’яті накладні витрати, що це може спричинити.