Ответ 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, если это то, что вы хотите в качестве конечного объекта.