web-dev-qa-db-fra.com

Pandas groupby.size vs series.value_counts vs collections.Counter with multiple series

Il existe de nombreuses questions ( 1 , 2 , ) traitant des valeurs de comptage dans une série unique .

Cependant, il y a moins de questions sur la meilleure façon de compter les combinaisons de deux séries ou plus . Des solutions sont présentées ( 1 , 2 ), mais la question de savoir quand et pourquoi utiliser chacune d'elles n'est pas discutée.

Vous trouverez ci-dessous une analyse comparative de trois méthodes potentielles. J'ai deux questions spécifiques:

  1. Pourquoi grouper est-il plus efficace que count? Je m'attendais à ce que count soit le plus efficace, car il est implémenté en C. Les performances supérieures de grouper persistent même si le nombre de colonnes est passé de 2 à 4.
  2. Pourquoi value_counter underperform grouper par tant? Est-ce dû au coût de construction d'une liste ou d'une série à partir d'une liste?

Je comprends que les sorties sont différentes, et cela devrait également éclairer le choix. Par exemple, le filtrage par nombre est plus efficace avec des tableaux numpy contigus par rapport à une compréhension de dictionnaire:

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

Cependant, l'objectif de ma question est la performance de la construction de résultats comparables dans une série par rapport au dictionnaire. Mes connaissances en C sont limitées, mais j'apprécierais toute réponse pouvant indiquer la logique sous-jacente à ces méthodes.

Code de référence

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)

Résultats de l'analyse comparative

Exécuter sur python 3.6.2, pandas 0.20.3, numpy 1.13.1

Spécifications de la machine: Windows 7 64 bits, double cœur 2,5 GHz, 4 Go de RAM.

Clé: 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 Ce n'est pas une faute de frappe.

30
jpp

Il y a en fait un peu de surcharge cachée dans Zip(df.A.values, df.B.values). La clé ici se résume à des tableaux numpy stockés en mémoire d'une manière fondamentalement différente de celle des objets Python.

Un tableau numpy, tel que np.arange(10), est essentiellement stocké en tant que bloc de mémoire contigu et non en tant qu'objets Python individuels. Inversement, une liste Python, telle que list(range(10)), est stockée en mémoire en tant que pointeurs vers des objets Python individuels (c'est-à-dire des nombres entiers de 0 à 9). Cette différence explique pourquoi les tableaux numpy sont plus petits en mémoire que les listes équivalentes Python, et pourquoi vous pouvez effectuer des calculs plus rapides sur les tableaux numpy.

Donc, comme Counter consomme Zip, les tuples associés doivent être créés en tant qu'objets Python. Cela signifie que Python doit extraire les valeurs de Tuple des données numpy et créer des objets Python correspondants en mémoire. Il y a une surcharge notable à cela, c'est pourquoi vous voulez être très prudent lorsque vous combinez des fonctions Python pures avec des données numpy. Un exemple de base de cet écueil que vous pourriez souvent voir est l'utilisation du Python sum intégré sur un tableau numpy: sum(np.arange(10**5)) est en fait un peu plus lent que le pur Python sum(range(10**5)), et les deux sont bien sûr beaucoup plus lents que np.sum(np.arange(10**5)).

Voir cette vidéo pour une discussion plus approfondie de ce sujet.

À titre d'exemple spécifique à cette question, observez les synchronisations suivantes en comparant les performances de Counter sur des tableaux numpy zippés par rapport aux listes zippées Python correspondantes.

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)

La différence entre ces deux horaires vous donne une estimation raisonnable des frais généraux discutés précédemment.

Ce n'est pas tout à fait la fin de l'histoire. La construction d'un objet groupby dans pandas implique également une surcharge, au moins en ce qui concerne ce problème, car il existe des métadonnées groupby qui ne sont pas strictement nécessaires simplement pour obtenir size, tandis que Counter fait la seule chose qui vous tient à cœur. Habituellement, cette surcharge est bien inférieure à la surcharge associée à Counter, mais à partir d'une expérimentation rapide, j'ai constaté que vous pouvez réellement obtenir des performances légèrement meilleures de Counter lorsque la majorité de vos groupes se composent simplement d'éléments uniques.

Considérez les horaires suivants (en utilisant la suggestion de @ BallpointBen sort=False) Qui vont dans le spectre de quelques grands groupes <--> de nombreux petits groupes:

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)

Ce qui me donne le tableau suivant:

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

Bien sûr, tout gain provenant de Counter serait compensé par une reconversion en Series, si c'est ce que vous voulez comme objet final.

18
root