Avant de découvrir cet article dont mon article s’est largement inspiré, j’étais plutôt convaincu qu’itertuples étant la solution la plus rapide. Dans cet article, je vais vous donner la meilleure façon d’itérer sur les lignes d’un DataFrame Pandas, sans code supplémentaire. Il ne s’agit pas seulement de performances : il s’agit aussi de comprendre ce qui se passe sous le capot pour devenir un meilleur data scientist.
Nous allons voir que la méthode la plus rapide trouvée est 1900 fois plus rapide que la méthode la plus naïve.
Voici le dataset choisi pour tester ces itérations sur des lignes :
import pandas as pd
import numpy as np
df = pd.read_csv('https://raw.githubusercontent.com/mlabonne/how-to-data-science/main/data/nslkdd_test.txt')
df
Cet ensemble de données comporte 22 000 lignes et 43 colonnes avec une combinaison de valeurs catégoriques et numériques. Chaque ligne décrit une connexion entre deux ordinateurs.
Disons que nous voulons créer une nouvelle caractéristique : le nombre total d’octets dans la connexion. Il nous suffit de faire la somme de deux caractéristiques existantes : src_bytes et dst_bytes. Voyons différentes méthodes pour calculer cette nouvelle caractéristique.
Iterrows
Selon la documentation officielle, iterrows() itère “sur les lignes d’un DataFrame Pandas sous forme de paires (index, Series)”. Elle convertit chaque ligne en un objet Series, ce qui pose deux problèmes :
- Elle peut changer le type de vos données (dtypes) ;
- La conversion dégrade fortement les performances. Pour ces raisons, la mal nommée iterrows() est la PIRE méthode possible pour itérer sur les lignes.
# Iterrows
total = []
for index, row in df.iterrows():
total.append(row['src_bytes'] + row['dst_bytes'])
Résultat : 1,07s en prenant le meilleur de 5 essais
Boucle for avec .loc ou .iloc (vitesse x3)
C’est ce que je faisais quand j’ai commencé : une boucle for de base pour sélectionner les lignes par index (avec .loc ou .iloc). Pourquoi est-ce mauvais ? Parce que les DataFrames ne sont pas conçus pour cet usage. Comme avec la méthode précédente, les lignes sont converties en objets Pandas Series, ce qui dégrade les performances. Il est intéressant de noter que .iloc est plus rapide que .loc. C’est logique puisque Python n’a pas à vérifier les étiquettes définies par l’utilisateur et regarde directement où la ligne est stockée en mémoire.
# For loop with .loc
total = []
for index in range(len(df)):
total.append(df['src_bytes'].loc[index] + df['dst_bytes'].loc[index])
Résultat : 600 ms en prenant le meilleur de 10 essais
# For loop with .iloc
total = []
for index in range(len(df)):
total.append(df['src_bytes'].iloc[index] + df['dst_bytes'].iloc[index])
Résultat : 377 ms en prenant le meilleur de 10 essais
Apply (vitesse x4)
La méthode apply() est un autre choix populaire pour itérer sur les lignes. Elle crée un code facile à comprendre, mais a un coût : les performances sont presque aussi mauvaises que celles de la boucle for précédente.
C’est pourquoi je vous conseille vivement d’éviter cette fonction pour cet usage spécifique (elle est parfaite pour d’autres applications). Notez que je convertis le DataFrame en une liste en utilisant la méthode to_list() pour obtenir des résultats identiques.
# Apply
df.apply(lambda row: row['src_bytes'] + row['dst_bytes'], axis=1).to_list()
Résultat : 282 ms en prenant le meilleur de 10 essais
La méthode apply() est une boucle for déguisée, ce qui explique pourquoi les performances ne s’améliorent pas tant que ça : elle n’est que 4 fois plus rapide que la première technique.
Itertuples (vitesse x10)
La méthode que j’avais l’habitude d’utiliser !
Si vous connaissez iterrows(), vous connaissez probablement itertuples(). Selon la documentation officielle, cette fonction permet d’itérer “sur les lignes d’un DataFrame en tant que tuples nommés des valeurs”. En pratique, cela signifie que les lignes sont converties en tuples, qui sont des objets beaucoup plus légers que les séries Pandas.
C’est pourquoi itertuples() est une meilleure version de iterrows(). Le seul problème est que nous devons sélectionner les colonnes en fonction de leur index, ce qui n’est pas aussi convivial que les techniques précédentes.
# Itertuples
total = []
for row in df.itertuples():
total.append(row[5] + row[6])
Résultat : 99,3 ms en prenant le meilleur de 10 essais
Liste en compréhension (vitesse x200)
Les compréhensions de listes sont une façon originale d’itérer sur une liste en une seule ligne. Par exemple, [print(i) for i in range(10)] imprime les nombres de 0 à 9 sans boucle for explicite. Je dis “explicite” parce que Python le traite en fait comme une boucle for si l’on regarde le bytecode. Alors pourquoi est-ce plus rapide ? Tout simplement parce que nous n’appelons pas la méthode .append() dans cette version.
# List comprehension
[src + dst for src, dst in zip(df['src_bytes'], df['dst_bytes'])]
Résultat : 5,54 ms en prenant le meilleur de 100 essais
Pandas vectorization (vitesse x1500)
Jusqu’à présent, toutes les techniques utilisées se contentaient d’additionner des valeurs individuelles. Au lieu d’additionner des valeurs individuelles, pourquoi ne pas les regrouper en vecteurs pour les additionner ? La différence entre l’addition de deux nombres ou de deux vecteurs n’est pas significative pour un CPU, ce qui devrait accélérer les choses.
En outre, Pandas peut traiter les objets de la série en parallèle, en utilisant chaque cœur de processeur disponible !
La syntaxe est également la plus simple que l’on puisse imaginer : cette solution est extrêmement intuitive. Sous le capot, Pandas se charge de vectoriser nos données avec un code C optimisé utilisant des blocs de mémoire contigus.
# Vectorization
(df['src_bytes'] + df['dst_bytes']).to_list()
Résultat : 734 µs en prenant le meilleur de 1 000 essais
Numpy vectorization (vitesse x1900)
NumPy est conçu pour gérer le calcul scientifique. Il a moins de surcharge que les méthodes Pandas puisque les lignes et les dataframes deviennent tous des np.array. Il s’appuie sur les mêmes optimisations que la vectorisation de Pandas.
Il existe deux façons de convertir une série en un tableau np.array : en utilisant .values ou .to_numpy(). La première a été dépréciée depuis des années, c’est pourquoi nous allons utiliser .to_numpy() dans cet exemple.
# Numpy vectorization
(df['src_bytes'].to_numpy() + df['dst_bytes'].to_numpy()).tolist()
Résultat : 575 µs en prenant le meilleur de 1 000 essais
Et voici notre grand gagnant ! 1900 fois plus rapide que la méthode la plus lente.
Voici les résultats en fonction du nombre de lignes selon la méthode :
