Есть много вопросов (1, 2, 3), касающихся подсчета значений в одиночная серия.
Однако возникает меньше вопросов о том, как лучше всего подсчитать комбинации двух и более серий. Решения представлены (1, 2), но когда и почему следует использовать каждое, не обсуждается.
Ниже приведен сравнительный анализ трех потенциальных методов. У меня два конкретных вопроса:
grouper более эффективен, чем count? Я ожидал, что count будет более эффективным, поскольку он реализован в C. Превосходная производительность grouper сохраняется даже при увеличении количества столбцов с 2 до 4.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 Это не опечатка.
Я совсем не удивлен, что функция, специально разработанная для этого конкретного варианта использования, работает лучше всего. pandas знает о структуре своих данных гораздо больше, чем Counter. кроме того, pandas, вероятно, потребляет гораздо меньше памяти, поскольку знает, как повторно использовать имеющуюся память.
@BallpointBen, с философской точки зрения ваш комментарий имеет смысл. Можете ли вы указать конкретные основные причины (например, хеширование, стоимость итерации и т. д.) Со ссылкой на исходный код?
Кроме того, для еще более производительной версии groupby передайте sort=False в groupby.
@Parfait, обновлено (а) np.random.seed(0), (б) более поздними версиями Python / numpy / pandas + включены спецификации машины, (в) sort=False для методов pandas.






На самом деле в 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.
Непонятная формулировка с моей стороны, я имел в виду, что, поскольку Counter потребляет zip, соответствующие кортежи должны быть созданы в памяти. Таким образом, создаются кортежи. пока потребляется Counter. Обычно Counter выполняет итерацию по zip в цикле for, поэтому во время каждой итерации цикла необходимо создавать связанный кортеж из zip. Эта функция _count_elements (или ее эквивалент в языке C) по сути является тем, как Counter считает вещи.
небольшая боковая панель -
pd.Series(list(zip(df.A, df.B))).value_counts(sort=False)улучшает маленький - поэтому я предполагаю, что сортировка вносит вклад в качестве накладных расходов в дополнение к приведениюlist