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 ГБ оперативной памяти.

Ключ: г = grouper, v = value_counter, с = 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 Это не опечатка.

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