Циклы for в пандах действительно плохи? Когда мне нужно заботиться?

108

Есть forпетли действительно «плохо»? Если нет, то в каких ситуациях они были бы лучше, чем использование более традиционного «векторизованного» подхода? 1

Я знаком с концепцией «векторизации» и с тем, как pandas использует векторизованные методы для ускорения вычислений. Векторизованные функции транслируют операции по всей серии или DataFrame, чтобы добиться гораздо большего ускорения, чем при обычном итерационном просмотре данных.

Однако я весьма удивлен, увидев много кода (в том числе из ответов на Stack Overflow), предлагающего решения проблем, связанных с циклическим просмотром данных с использованием forциклов и составления списков. В документации и API говорится, что циклы «плохие» и что «никогда не следует» перебирать массивы, серии или DataFrames. Итак, почему я иногда вижу пользователей, предлагающих решения на основе цикла?


1. Хотя это правда, что вопрос звучит несколько широко, правда в том, что бывают очень специфические ситуации, когда forциклы обычно лучше, чем традиционный итерационный просмотр данных. Этот пост направлен на то, чтобы запечатлеть это для потомков.

cs95
источник

Ответы:

146

TL; DR; Нет, forпетли одевают «плохо», по крайней мере, не всегда. Вероятно, точнее будет сказать, что некоторые векторизованные операции медленнее, чем итерация , вместо того, чтобы сказать, что итерация быстрее, чем некоторые векторизованные операции. Знание того, когда и почему, является ключом к максимальной производительности вашего кода. Вкратце, это ситуации, в которых стоит рассмотреть альтернативу векторизованным функциям pandas:

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

Давайте рассмотрим эти ситуации индивидуально.


Итерация против векторизации малых данных

Pandas следует подходу «Соглашение вместо конфигурации» в своем дизайне API. Это означает, что один и тот же API был приспособлен для обслуживания широкого диапазона данных и вариантов использования.

Когда вызывается функция pandas, следующие вещи (среди прочего) должны обрабатываться внутри функции, чтобы гарантировать работу

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

Почти каждая функция будет иметь дело с ними в той или иной степени, а это накладные расходы . Накладные расходы меньше для числовых функций (например, 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
)
cs95
источник
pd.Seriesи pd.DataFrameтеперь поддерживаем конструкцию из итераций. Это означает, что можно просто передать генератор Python в функции конструктора, вместо того, чтобы сначала создавать список (с использованием понимания списка), что во многих случаях может быть медленнее. Однако размер выходной мощности генератора нельзя определить заранее. Я не уверен, сколько времени / памяти это вызовет.
GZ0 09
1
@ GZ0 IIRC, прием итераторов - более позднее дополнение к API. Что касается «это означает, что можно просто передать генератор Python в функции конструктора, вместо того, чтобы сначала создавать список», я не согласен. Память может быть, производительности нет. По моему опыту, обычно почти всегда быстрее создать список и передать его. Список композиций FTW.
cs95 09
@ cs95 Почему вы не думаете, что генераторы могут повысить производительность? Или вы сделали какие-нибудь тесты?
GZ0
@ GZ0 Я не говорил, что генераторы не приводят к увеличению производительности, я говорю, что вы не выиграете так много, как при использовании списков. Существуют накладные расходы, связанные с инициализацией генэкспов, поддержанием состояния и т. Д., Которых у списков comps нет. Здесь есть много примеров с пониманием списков, которые вы можете сравнить с пониманием генератора для сравнения. Вот пример, который вы можете запустить прямо сейчас 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 09
1
@ cs95 Обратите внимание , что для итерируемыми известно размера, было бы быстрее , чтобы построить pd.Seriesиз них непосредственно , а не преобразовывать их в списки, например pd.Series(range(10000)), pd.Series("a" * 10000)и pd.Series(pd.Index(range(10000)))будет гораздо быстрее , чем список их аналогов (последний был даже немного быстрее , чем pd.Index.to_series.
GZ0
1

Коротко

  • for loop + iterrowsработает очень медленно. Накладные расходы незначительны для ~ 1k строк, но заметны на 10k + строках.
  • for loop + itertuplesнамного быстрее, чем iterrowsили apply.
  • векторизация обычно намного быстрее, чем itertuples

Контрольный показатель введите описание изображения здесь

Artoby
источник