Чи справді погані петлі в пандах справді погані? Коли я повинен піклуватися?


106

Чи forсправді петлі "погані"? Якщо ні, то в яких ситуаціях вони були б кращими, ніж використання більш звичайного "векторного" підходу? 1

Мені знайоме поняття "векторизація" та те, як панди використовують векторизовані методи для прискорення обчислень. Векторизовані функції транслюють операції протягом усієї серії або DataFrame для досягнення прискорень, значно більших, ніж умовно ітерації даних.

Однак я дуже здивований, коли бачу багато коду (в тому числі з відповідей на переповнення стека), що пропонує рішення проблем, які передбачають прокручування даних за допомогою forциклів та розуміння списків. Документація та API говорять про те, що петлі "погані", і що "ніколи" не слід повторювати масиви, серії або DataFrames. Отже, як я іноді бачу користувачів, які пропонують рішення на основі циклу?


1 - Хоча це правда, що питання звучить дещо широко, правда полягає в тому, що існують дуже специфічні ситуації, коли forпетлі, як правило, краще, ніж звичайно повторювані дані. Цей пост має на меті зафіксувати це для нащадків.

Відповіді:


143

TLDR; Ні, forпетлі не ковдри «погані», принаймні, не завжди. Напевно, точніше сказати, що деякі векторизовані операції повільніші, ніж ітераційні , проти того, що кажуть, що ітерація швидша, ніж деякі векторизовані операції. Знання, коли і чому є ключовим для отримання максимальної продуктивності вашого коду. Коротше кажучи, це такі ситуації, коли варто розглянути альтернативу векторизованим функціям панди:

  1. Коли ваші дані невеликі (... залежно від того, що ви робите),
  2. При роботі з object/ змішаними типами
  3. При використанні strфункцій аксесуара / regex

Давайте розглянемо ці ситуації окремо.


Ітерація v / s векторизація на малих даних

У дизайні API Pandas дотримується підходу "Конвенція про конфігурацію" . Це означає, що той самий API був пристосований для обслуговування широкого кола даних та випадків використання.

Коли викликається функція панди, функція повинна внутрішньо обробляти наступні речі (серед інших), щоб забезпечити роботу

  1. Вирівнювання індексу / осі
  2. Обробка змішаних типів даних
  3. Обробка відсутніх даних

Практично кожна функція повинна мати справу з цими різними масштабами, і це означає накладні витрати . Накладні витрати менше для числових функцій (наприклад, 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, передбачає криву навчання.


Операції зі змішаними / objectdtypes

Порівняння на основі рядків
Перегляньте приклад фільтрування з першого розділу, що робити, якщо стовпці, що порівнюються, є рядками? Розгляньте ті ж самі три функції, але з вхідним 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 функціям конструктора, а не потрібно спочатку складати список (використовуючи розуміння списку), що може бути повільніше у багатьох випадках. Однак розмір вихідної потужності генератора заздалегідь не можна визначити. Я не впевнений, скільки часу / пам’яті накладні витрати, що це може спричинити.
GZ0

@ GZ0 IIRC, прийняття ітераторів є останнім доповненням до API. Щодо "це означає, що можна просто передавати генератор Python конструкторським функціям, а не потрібно спочатку будувати список", я не погоджуюся. Пам’ять, можливо, виступу немає. На мій досвід, зазвичай майже завжди швидше генерувати список та передавати його далі. Список компресів FTW.
cs95

@ cs95 Чому ви не вважаєте, що генератори можуть призвести до підвищення продуктивності? Або ви зробили якісь тести на це?
GZ0

@ GZ0 Я не сказав, що генератори не призводять до підвищення продуктивності, я кажу, що ви не отримуєте стільки, скільки ви використовуєте розуміння списку. Є накладні витрати, пов’язані з ініціалізацією генексів, підтримкою стану тощо, у списках яких немає. Тут є чимало прикладів із розуміннями списку, за допомогою яких можна порівняти розуміння генераторів для порівняння. Ось приклад, який ви можете запустити прямо зараз ser = pd.Series([['a', 'b', 'c'], ['d', 'e'], ['f', 'g']] * 10000); %timeit pd.Series(y for x in ser for y in x); %timeit pd.Series([y for x in ser for y in x])
cs95

@ cs95 Ви, мабуть, праві. Я просто швидко ознайомився з кодом конструктора, pd.Seriesі виявиться, що якщо розмір ітерабельного виводу неможливо визначити, список буде побудований з ітерабельного в будь-якому випадку. Тож у більшості випадків використання генераторів не було б жодних вигод. Однак, коли я запускав приклади, які ви дали генератору, насправді повільніше. Я не впевнений, звідки походить різниця.
GZ0

1

Коротко

  • для циклу + iterrowsнадзвичайно повільно. Накладні витрати не суттєві на ~ 1k рядків, але помітні на 10k + рядках.
  • для циклу + itertuplesнабагато швидше ніж iterrowsабо apply.
  • векторизація зазвичай набагато швидше, ніж itertuples

Орієнтир введіть тут опис зображення

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.