Неужели петли в пандах действительно плохи?Когда я должен заботиться? - PullRequest
0 голосов
/ 03 января 2019

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

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

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


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

1 Ответ

0 голосов
/ 03 января 2019

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

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

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


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

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

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

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

Почти каждая функция должна иметь дело с этим в различной степени, и это представляет накладные расходы .Затраты меньше для числовых функций (например, Series.add), в то время как они более выражены для строковых функций (например, Series.str.replace).

* 1044С другой стороны, циклы *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]

, которая превосходит как панды, так и эквиваленты понимания списка:
image
NumPy vectorization is out of the scope of this post, but it is definitely worth considering, if performance matters.

Value Counts
Taking another example - this time, with another vanilla python construct that is faster than a for loop - 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 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

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 метод доступа и понимание списка:

# 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 Методы доступа

Панды могут применять операции регулярного выражения, такие как str.contains, str.extract и str.extractall, а также другие «векторизованные» строковые операции (например, str.split, str.find , str.translate` и т. д.) для строковых столбцов. Эти функции медленнее, чем списки, и предназначены для того, чтобы быть более удобными функциями, чем что-либо еще.

Обычно гораздо быстрее предварительно скомпилировать шаблон регулярного выражения и выполнить итерации по вашим данным с помощью re.compile (также см. Стоит ли использовать Python re.compile? ). Компонент списка, эквивалентный 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

enter image description here

Дополнительные примеры
Полное раскрытие - я являюсь автором (частично или полностью) перечисленных ниже сообщений.


Заключение

Как видно изВ приведенных выше примерах итерация светит при работе с небольшими строками DataFrames, смешанными типами данных и регулярными выражениями.

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

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

Еще одно примечание: некоторые строковые операции имеют дело с ограничениями, которые поддерживают использование NumPy.Вот два примера, где аккуратная векторизация NumPy превосходит python:

Кроме того, иногда просто работаем с базовыми массивами через .values, в отличие отна Series или DataFrames может предложить достаточно здоровое ускорение для большинства обычных сценариев (см. Note в разделе Числовое сравнение выше).Так, например, 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
)
...