Avec Pandas, mettez au régime vos gros datasets

Les datasets très volumineux sont un calvaire pour les Data Scientists. Fort heureusement les librairies Pandas permettent de les manipuler en optimisant leur poids drastiquement et en rien de temps.

Cedric Soares
France School of AI
7 min readMay 31, 2019

--

Article écrit par

, , et

Pandas permet de mettre au régime les datasets © CC0 Public Domain

Tout Data Scientist se confronte à des jeux de donnés colossaux. Ceux-ci sont souvent constitués de plusieurs millions de d’entrées . Ces DataFrames Pandas consomment énormément de mémoire et de temps de calcul. Il est dès lors critique de les optimiser pour les visualiser, les manipuler et les utiliser dans des modèles de machine learning. Voici comment y remédier en un minimum de manipulations.

Notre démonstration s’appuie sur une portion du dataset des contrôles automobiles réalisés par la police U.S entre 2000 et 2018 . Les données compilées par l’Université de Stranford recensent environ 50 000 sessions de contrôles routiers, classés par État. A lui seul, le jeu de données de l’État de New York utilisé représente près de 8 millions enregistrements.

Visualisation du dataset de l’université de Stanford ©The Stanford open policing project

À la découverte du dataset

Notre fichier csv pèse 1 Go. Pire, à la lecture avec Pandas, le DataFrame consomme 6.5 Go de ressource mémoire. Notons à ce propos qu’une reconnaissance préliminaire pour l’estimation en profondeur de l’occupation mémoire avec la fonction pandas.DataFrame.info() nécessite le transfert du paramètre “deep”

import pandas as pd
df = pd.read_csv("/content/drive/My Drive/ny_police.csv")
df.info(memory_usage='deep')
Mémoire utilisée par colonne.

En y regardant de plus près, notre DataFrame contient 18 colonnes. La majorité sont composées de chaînes de caractères considérées en tant qu’objets par Pandas.

df.head()
Extrait de la visualisation des premières lignes du dataset.

Optimiser ce type de données aboutira à un gain de ressource mémoire substantiel.

Un gain considérable en catégorisant les objets

Pour y parvenir, une méthode efficace consiste à affecter un index à chaque objet fortement redondant. Lors des futures opérations, Pandas utilisera ce dernier, moins coûteux en espace. La méthode n’est viable que si le nombre d’objets uniques représente plus de 50% du nombre total d’objets. Ce qui est le cas dans les données collectées par Stanford.

df_obj = df.select_dtypes(include=['object']).copy()
df_obj.describe()
Pour la majorité des colonnes les valeurs uniques représentent plus de 50% du total des valeurs.

A titre d’exemple, la colonne “subject_sex” ne comporte que des valeurs “female” et “male”.

df_obj.groupby('subject_sex').size()
La colonne subject_sex ne comporte que des valeurs “female” et “male”.

Lors du processus d’optimisation, chaque colonne est testée dans une boucle pour vérifier le seuil de valeurs uniques susceptibles d’enclencher ou non l’indexation.

|converted_obj = pd.DataFrame()for col in df_obj.columns:
if len(df_obj[col].unique()) / len(df_obj[col]) < 0.5:
converted_obj.loc[:,col] = df_obj[col].astype('category')
else:
converted_obj.loc[:,col] = df_obj[col]

Ci-après, une méthode pour tester les gains après chaque optimisation.

def mem_usage(pandas_obj):
if isinstance(pandas_obj,pd.DataFrame):
usage_b = pandas_obj.memory_usage(deep=True).sum()
else:
usage_b = pandas_obj.memory_usage(deep=True)
usage_mb = usage_b / 1024 ** 2 # convertir les bytes en megabytes
return "{:03.2f} MB".format(usage_mb) # afficher sous format nombre (min 3 chiffres) et une précision de deux décimales

Utilisons la une première fois sur les objets catégorisés.

print(mem_usage(df_obj))
print(mem_usage(converted_obj))

On observe que la consommation de mémoire a diminuée de 61%.

Gain de mémoire après catégorisation des colonnes objet.

Pandas bien trop généreux dans l’encodage des valeurs numériques

Enchaînons avec les valeurs numériques. Par défaut Pandas encode les nombres en 64 bits. Dans la majorité des cas, la plage d’encodage est surdimensionnée. En effet, pour les entiers, la norme permet de les encoder dans un intervalle compris entre -9x10¹⁸ et 9x10¹⁸.

De plus par défaut Pandas encode l’ensemble des entiers qu’ils soient positifs ou négatifs. Il en résulte une moitié de la plage d’encodage souvent inutilisée .

Pour y remédier, la librairie propose une fonction pd.to_numeric() pour optimiser l’encodage des valeurs numériques. Celle-ci parcourt le DataFrame, colonne par colonne et sélectionne la plage d’encodage (8, 16, 32, ou 64 bits) la plus efficiente pour chacune d’elle.

Dans notre cas, outre l’absence d’entiers négatifs, nous constatons que l’encodage en 64 bits est excessif.

df_int = df.select_dtypes(include=['int']).copy()
df_int.describe()
df_int.info(memory_usage='deep')
Pandas utilise l’encodage 64 bits par défaut pour les colonnes d’int et de floats
Des valeurs numérique bien inférieures à la limite d’encodage et uniquement positives.

Le paramètre downcast=’unsigned’ permet de définir un encodage exclusif des entiers positifs. La fonction apply() sert quant à elle à itérer le traitement sur toutes les colonnes.

converted_int = df_int.apply(pd.to_numeric, downcast='unsigned')converted_int.info(memory_usage='deep')

Après application de la fonction, les entiers sont maintenant encodés en 32 bits unsigned (sans valeurs négatives). Il en ressort un gain de mémoire de 50%.

print(mem_usage(df_int))
print(mem_usage(converted_int))
Gain de mémoire en, réencodant les entiers.

La même opération peut être réalisée sur les colonnes de float. A ceci près que l’encodage ne peut être optimisé pour ne prendre en compte que les valeurs positives.

df_float = df.select_dtypes(include=['float']).copy()
df_float.info(memory_usage = 'deep')
Les float sont également encodés en 64 bits par défaut
converted_float = df_int.apply(pd.to_numeric, downcast='float')
converted_float.info(memory_usage='deep')
Les float ont été convertis en float32 après application de la fonction pd.to_numeric()

A l’image des entiers, le gain est également de 50% sur les float.

print(mem_usage(df_float))
print(mem_usage(converted_float))
Gain de mémoire en réencodant les floats.

L’étape suivante consiste à remplacer les portions de dataset optimisées dans le DataFrame global (optimized_df).

optimized_df[converted_obj.columns] = converted_obj
optimized_df[converted_int.columns] = converted_int
optimized_df[converted_float.columns] = converted_float

Sans surprise, au vu de la proportion de colonnes d’objets, le gain total de mémoire est également de 61%.

print(mem_usage(df))
print(mem_usage(optimized_df))
Gain total de mémoire après optimisation du dataset.

A ce stade, l’optimisation est achevée. Afin d’automatiser la démarche lors de chaque ouverture du csv, Pandas permet de spécifier, sous forme de dictionnaire, les formats de données à appliquer à chaque colonne.

Des plus, la librairie permet de convertir, à la volée lors de l’ouverture, les colonnes de données temporelles en format datetime. Ce format est plus approprié pour traiter ce type de données. Il est donc judicieux de retirer ces colonnes lors de la construction du dictionnaire.

dtypes = optimized_df.drop(['date', 'time'],axis=1).dtypes
column_types = dict(zip(dtypes.index, [i.name for i in dtypes.values]))
print(column_types)

Voici le dictionnaire une fois constitué.

{'raw_row_number': 'uint32', 'location': 'category', 'county_name': 'category', 'subject_age': 'float32', 'subject_race': 'category', 'subject_sex': 'category', 'type': 'category', 'violation': 'category', 'speed': 'float32', 'posted_speed': 'float32', 'vehicle_color': 'category', 'vehicle_make': 'category', 'vehicle_model': 'category', 'vehicle_type': 'category', 'vehicle_registration_state': 'category', 'vehicle_year': 'float32'}

Celui-ci peut maintenant être réinjecté à l’ouverture du csv comme argument la fonction read_csv(). La conversion en datetime des séries temporelles est obtenue en passant la liste des colonnes à traiter dans l’argument parse_dates.

read_and_optimized = pd.read_csv('/content/drive/My Drive/ny_police.csv',dtype=column_types,parse_dates=[['date', 'time']],infer_datetime_format=True)

A la vérification, Pandas prend bien en compte les types de colonnes spécifiées dans le dictionnaire.

read_and_optimized.info(memory_usage='deep')
Les types de données sont bien pris en compte et l’espace optimisé

L’espace se retrouve donc bien optimisé dès la constitution du dataset.

Au delà des données, l’optimisation passe par le format du fichier

Il est encore possible de rogner un peu d’espace disque et de rendre l’importation des données dans un dataset encore plus rapide en remplaçant le fichier csv par une sauvegarde HDF5. Pandas propose la fonction to_hdf() pout le faire.

from pathlib import Path
path = Path("/content/drive/My Drive")
read_and_optimized.to_hdf(path/'ny_police.h5', key='read_and_optimized', format="table")

La fonction read_hdf() permet d’ouvrir le fichier HDF5.

df_test = pd.read_hdf(path/'ny_police.h5')

En comparaison le fichier csv pèse 1Go alors que le HDF5 n’occupe que 470 Mo.

Mieux, Pandas met 53s pour ouvrir le csv et appliquer les type de données aux colonnes contre seulement 10.3s pour le HDFS.

Temps d’ouverture du csv avec configuration des colonne optimisées.
Temps d’ouverture du fichier HDFS.

Pour résumer, Pandas offre des fonctions permettant d’optimiser la majorité des types de données. Seuls les booléens en sont dépourvus, du fait de leur nature. Ces mécanismes peuvent être utilisés en cours de manipulation d’un dataset voire même dès l’importation des données.

Plus que l’espace de stockage économisé, ces améliorations permettent réduire la durée du traitement lors d’applications de data visualisation ou de machine learning.

Qui plus est, leur mise à profit requiert un coût effort / temps optimal.

Retrouvez le notebook de l’article sur Github. Ce dernier a été réalisé avec Google Colab.

--

--

Cedric Soares
France School of AI

Bercé trop près de la TV, gros fils de pub j’ai toujours été passionné par l’univers des médias et des industries culturelles. Futur développeur d’IA