Ответ 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. Основным примером этой ловушки, которую вы обычно видите, является использование встроенной sum
Python в массиве numpy: sum(np.arange(10**5))
на самом деле немного медленнее, чем чистая sum(range(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
связано с некоторыми накладными расходами, по крайней мере, в связи с этой проблемой, поскольку есть некоторые 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
, если это то, что вы хотите в качестве конечного объекта.