Pandas groupby.size vs series.value_counts vs collections.Counter с несколькими сериями

Есть много вопросов (1, 2, 3), касающихся подсчета значений в одиночная серия.

Однако возникает меньше вопросов о том, как лучше всего подсчитать комбинации двух и более серий. Решения представлены (1, 2), но когда и почему следует использовать каждое, не обсуждается.

Ниже приведен сравнительный анализ трех потенциальных методов. У меня два конкретных вопроса:

  1. Почему grouper более эффективен, чем count? Я ожидал, что count будет более эффективным, поскольку он реализован в C. Превосходная производительность grouper сохраняется даже при увеличении количества столбцов с 2 до 4.
  2. Почему value_counter так сильно уступает grouper? Связано ли это со стоимостью построения списка или ряда из списка?

Я понимаю, что результаты разные, и это также должно информировать выбор. Например, фильтрация по количеству более эффективна с непрерывными массивами numpy, чем с пониманием словаря:

x, z = grouper(df), count(df)
%timeit x[x.values > 10]                        # 749µs
%timeit {k: v for k, v in z.items() if v > 10}  # 9.37ms

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

Код эталонного тестирования

import pandas as pd
import numpy as np
from collections import Counter

np.random.seed(0)

m, n = 1000, 100000

df = pd.DataFrame({'A': np.random.randint(0, m, n),
                   'B': np.random.randint(0, m, n)})

def grouper(df):
    return df.groupby(['A', 'B'], sort=False).size()

def value_counter(df):
    return pd.Series(list(zip(df.A, df.B))).value_counts(sort=False)

def count(df):
    return Counter(zip(df.A.values, df.B.values))

x = value_counter(df).to_dict()
y = grouper(df).to_dict()
z = count(df)

assert (x == y) & (y == z), "Dictionary mismatch!"

for m, n in [(100, 10000), (1000, 10000), (100, 100000), (1000, 100000)]:

    df = pd.DataFrame({'A': np.random.randint(0, m, n),
                       'B': np.random.randint(0, m, n)})

    print(m, n)

    %timeit grouper(df)
    %timeit value_counter(df)
    %timeit count(df)

Результаты сравнительного анализа

Запускаем на python 3.6.2, pandas 0.20.3, numpy 1.13.1

Технические характеристики компьютера: 64-разрядная версия Windows 7, двухъядерный процессор 2,5 ГГц, 4 ГБ ОЗУ.

Ключ: g = grouper, v = value_counter, c = count.

m           n        g        v       c
100     10000     2.91    18.30    8.41
1000    10000     4.10    27.20    6.98[1]
100    100000    17.90   130.00   84.50
1000   100000    43.90   309.00   93.50

1 Это не опечатка.

небольшая боковая панель - pd.Series(list(zip(df.A, df.B))).value_counts(sort=False) улучшает маленький - поэтому я предполагаю, что сортировка вносит вклад в качестве накладных расходов в дополнение к приведению list

Vivek Kalyanarangan 14.05.2018 12:54

Я совсем не удивлен, что функция, специально разработанная для этого конкретного варианта использования, работает лучше всего. pandas знает о структуре своих данных гораздо больше, чем Counter. кроме того, pandas, вероятно, потребляет гораздо меньше памяти, поскольку знает, как повторно использовать имеющуюся память.

BallpointBen 17.05.2018 19:16

@BallpointBen, с философской точки зрения ваш комментарий имеет смысл. Можете ли вы указать конкретные основные причины (например, хеширование, стоимость итерации и т. д.) Со ссылкой на исходный код?

jpp 17.05.2018 19:17

Кроме того, для еще более производительной версии groupby передайте sort=False в groupby.

BallpointBen 17.05.2018 19:24

@Parfait, обновлено (а) np.random.seed(0), (б) более поздними версиями Python / numpy / pandas + включены спецификации машины, (в) sort=False для методов pandas.

jpp 17.05.2018 21:31
Почему в Python есть оператор "pass"?
Почему в Python есть оператор "pass"?
Оператор pass в Python - это простая концепция, которую могут быстро освоить даже новички без опыта программирования.
Некоторые методы, о которых вы не знали, что они существуют в Python
Некоторые методы, о которых вы не знали, что они существуют в Python
Python - самый известный и самый простой в изучении язык в наши дни. Имея широкий спектр применения в области машинного обучения, Data Science,...
Основы Python Часть I
Основы Python Часть I
Вы когда-нибудь задумывались, почему в программах на Python вы видите приведенный ниже код?
LeetCode - 1579. Удаление максимального числа ребер для сохранения полной проходимости графа
LeetCode - 1579. Удаление максимального числа ребер для сохранения полной проходимости графа
Алиса и Боб имеют неориентированный граф из n узлов и трех типов ребер:
Оптимизация кода с помощью тернарного оператора Python
Оптимизация кода с помощью тернарного оператора Python
И последнее, что мы хотели бы показать вам, прежде чем двигаться дальше, это
Советы по эффективной веб-разработке с помощью Python
Советы по эффективной веб-разработке с помощью Python
Как веб-разработчик, Python может стать мощным инструментом для создания эффективных и масштабируемых веб-приложений.
33
5
5 275
1
Перейти к ответу Данный вопрос помечен как решенный

Ответы 1

Ответ принят как подходящий

На самом деле в zip(df.A.values, df.B.values) есть немного скрытых накладных расходов. Ключ здесь сводится к тому, что массивы numpy хранятся в памяти принципиально другим способом, чем объекты Python.

Массив numpy, такой как np.arange(10), по существу хранится как непрерывный блок памяти, а не как отдельные объекты Python. И наоборот, список Python, такой как list(range(10)), хранится в памяти как указатели на отдельные объекты Python (т.е. целые числа 0-9). Это различие является причиной того, почему массивы numpy меньше в памяти, чем списки эквивалентов Python, и почему вы можете выполнять более быстрые вычисления с массивами numpy.

Итак, поскольку Counter использует zip, связанные кортежи должны быть созданы как объекты Python. Это означает, что Python необходимо извлечь значения кортежа из numpy данных и создать соответствующие объекты Python в памяти. Это связано с заметными накладными расходами, поэтому вы должны быть очень осторожны при объединении чистых функций Python с numpy данными. Базовый пример этой ловушки, которую вы обычно можете увидеть, - это использование встроенного Python sum в массиве numpy: sum(np.arange(10**5)) на самом деле немного медленнее, чем чистый Python sum(range(10**5)), и оба из них, конечно, значительно медленнее, чем np.sum(np.arange(10**5)).

См. это видео для более подробного обсуждения этой темы.

В качестве примера, относящегося к этому вопросу, обратите внимание на следующие моменты времени, сравнивая производительность Counter на заархивированных массивах numpy с соответствующими заархивированными списками Python.

In [2]: a = np.random.randint(10**4, size=10**6)
   ...: b = np.random.randint(10**4, size=10**6)
   ...: a_list = a.tolist()
   ...: b_list = b.tolist()

In [3]: %timeit Counter(zip(a, b))
455 ms ± 4.7 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

In [4]: %timeit Counter(zip(a_list, b_list))
334 ms ± 4.2 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

Разница между этими двумя временами дает вам разумную оценку накладных расходов, о которых говорилось ранее.

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

Рассмотрим следующие тайминги (используя предложение @ BallpointBen sort=False), которые соответствуют спектру нескольких больших групп <--> многих малых групп:

def grouper(df):
    return df.groupby(['A', 'B'], sort=False).size()

def count(df):
    return Counter(zip(df.A.values, df.B.values))

for m, n in [(10, 10**6), (10**3, 10**6), (10**7, 10**6)]:

    df = pd.DataFrame({'A': np.random.randint(0, m, n),
                       'B': np.random.randint(0, m, n)})

    print(m, n)

    %timeit grouper(df)
    %timeit count(df)

Это дает мне следующую таблицу:

m       grouper   counter
10      62.9 ms    315 ms
10**3    191 ms    535 ms
10**7    514 ms    459 ms

Конечно, любой выигрыш от Counter будет компенсирован обратным преобразованием в Series, если это то, что вы хотите в качестве конечного объекта.

Отличный ответ и дополнительные сроки, спасибо. Один вопрос, у вас есть ссылка на when materializing the zip you're creating tuples of Python objects? Я думал, что объекты кортежей создаются только тогда, когда вы вызываете list, next и т. д. Но я не знал, что tuples создаются внутри перед тем, как будут использованы Counter.

jpp 18.05.2018 00:29

Непонятная формулировка с моей стороны, я имел в виду, что, поскольку Counter потребляет zip, соответствующие кортежи должны быть созданы в памяти. Таким образом, создаются кортежи. пока потребляется Counter. Обычно Counter выполняет итерацию по zip в цикле for, поэтому во время каждой итерации цикла необходимо создавать связанный кортеж из zip. Эта функция _count_elements (или ее эквивалент в языке C) по сути является тем, как Counter считает вещи.

root 18.05.2018 00:59

Другие вопросы по теме