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

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

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

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


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

Ответ 1

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

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

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


Итерация v/s Векторизация на малых данных

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

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

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

Почти каждая функция должна иметь дело с этим в разной степени, и это накладные расходы. 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 в этом посте. Сроки для операций выше приведены ниже:

enter image description here

Понимание списка превосходит query для N среднего размера и даже превосходит векторизованное сравнение не равно для крошечного N. К сожалению, понимание списка масштабируется линейно, поэтому оно не дает большого прироста производительности для больших N.

Заметка
Стоит отметить, что большая часть понимания списка состоит в том, что вам не нужно беспокоиться о выравнивании индекса, но это означает, что если ваш код зависит от выравнивания индексации, это сломается. В некоторых случаях векторизованные операции над базовыми массивами NumPy могут рассматриваться как привносящие "лучшее из обоих миров", позволяющее векторизацию без всех ненужных накладных расходов на функции панд. Это означает, что вы можете переписать вышеуказанную операцию как

df[df.A.values != df.B.values]

Который превосходит как панды, так и списочные эквиваленты:
m9hyc.png
Векторизация 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

enter image description here

Результаты более выражены, 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

enter image description here

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

enter image description here

Итак, что изменилось? Здесь следует отметить, что строковые операции по своей природе трудно векторизовать. Pandas рассматривает строки как объекты, и все операции над объектами возвращаются к медленной, зацикленной реализации.

Теперь, поскольку эта зацикленная реализация окружена всеми упомянутыми выше издержками, между этими решениями существует постоянная разница величин, даже если они масштабируются одинаково.

Когда дело доходит до операций с изменяемыми/сложными объектами, сравнение не проводится. Понимание списка превосходит все операции, связанные с диктовками и списками.

Доступ к значениям словаря по ключу
Вот время для двух операций, которые извлекают значение из столбца словарей: map и понимание списка. Настройка находится в Приложении, под заголовком "Фрагменты кода".

# Dictionary value extraction.
ser.map(operator.itemgetter('value'))     # map
pd.Series([x.get('value') for x in ser])  # list comprehension

enter image description here

Индекс позиционного списка
Времена для 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)

При реконструкции серии.

enter image description here

Сглаживание списка
Последний пример - выравнивание списков. Это еще одна распространенная проблема, и она демонстрирует, насколько мощный чистый 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

enter image description here

И 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

enter image description here

Больше примеров
Полное раскрытие - я являюсь автором (частично или полностью) этих постов, перечисленных ниже.


Заключение

Как показано в приведенных выше примерах, итерация сияет при работе с небольшими строками 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
)