3 façons simples de traiter de plus gros ensembles de données avec Pandas
Les types que nous choisissons pour représenter nos données ont un impact sur la taille sous-jacente de leur représentation. Il existe une multitude de types de données, chacun ayant des précisions différentes et une consommation de mémoire différente. Pour être aussi économe en mémoire que possible, vous devez comprendre vos données et choisir les types les plus précis possibles pour les représenter.
Trouvez le code sur GitHub.
Concept
Pandas est incroyable pour traiter des données sur une seule machine, sur un seul cœur et lorsque cela tient en mémoire. Si vous travaillez avec de gros ensembles de données, vous pouvez facilement dépasser ces paramètres et devrez peut-être vous tourner vers d'autres outils pour gérer vos données. Avant de passer à l'informatique distribuée, avec des technologies comme Spark, il existe quelques optimisations simples qui vous aideront à mettre à l'échelle des ensembles de données plus importants.
Le principal facteur limitant est la quantité de mémoire nécessaire pour charger les données en mémoire. Si nous réduisons la taille des données, nous pouvons charger plus de données, les traiter plus rapidement... Considérez un simple fichier CSV avec deux colonnes : âge et emploi.
Lorsque vous chargez des données en mémoire, vous devez définir les types pour chaque colonne de votre ensemble de données, ce qui est souvent fait par défaut. Pandas utilise des tableaux numpy pour représenter chaque colonne de votre dataframe. Ces tableaux numpy ont un dtype, qui est le type sous-jacent de tous les éléments du tableau. Lorsque vous utilisez pd.read_csv
, Pandas devine les types de données de chaque colonne et attribue des tableaux numpy avec des types par défaut. Cependant, les types par défaut sont larges, utilisant plus d'espace que nécessaire pour représenter les différentes colonnes.
Dans notre cas, nous obtiendrions une colonne d'âge avec un type int64
et une colonne d'emploi avec un type object
. Cela n'est pas optimal car un âge a une plage [0, 100], n'ayant besoin que de 8 bits au lieu de 64. De plus, un emploi n'est pas « n'importe quelle chaîne », mais il est catégorique, nous avons très probablement une liste limitée d'emplois possibles et chaque emploi est un élément de cette liste.
Utilisez le bon dtype pour les colonnes numériques
Pandas n'infère pas le meilleur type pour nos colonnes numériques. Dans notre cas, nous pourrions représenter un âge avec un type uint8
, car un âge est non signé (toujours positif) et sa plage est [0, 100]. Cependant, par défaut, Pandas utilise un int64
qui consomme 8 fois plus de mémoire.
La première étape consiste à forcer l'utilisation de types optimisés pour les colonnes numériques.
Utilisez le bon dtype pour les colonnes catégoriques
Lorsqu'une colonne est catégorique avec une faible cardinalité (le nombre de valeurs uniques est inférieur au nombre total de lignes -> beaucoup de répétitions), la représentation en chaîne de caractères est la pire. La chaîne de caractères est un type coûteux, selon le format (utf-8, ascii, ...) que nous utilisons entre 1 et 4 octets par caractère. Elle consomme rapidement une énorme quantité de mémoire lorsqu'elle est répétée des milliers de fois.
Mais il y a une meilleure façon. Il est plus efficace d'avoir un dictionnaire associant chaque valeur unique à un index avec un petit type, par exemple 1 octet. Ensuite, nous ne répétons qu'un seul octet des milliers de fois au lieu de la chaîne complète qui est beaucoup plus volumineuse. Heureusement, Pandas dispose d'un dtype pour gérer cela automatiquement pour vous, il vous suffit d'utiliser le dtype category
.
Cela apporte généralement la plus grande amélioration de la consommation de mémoire. Le traitement des données catégoriques avec des chaînes de caractères simples est une énorme perte de ressources. Le type str
est lourd au lieu d'une correspondance avec des indices répétés avec un type catégorique approprié.
Dans le calcul ci-dessus, nous supposons que le plus petit morceau de mémoire que nous pouvons allouer est de 1 octet (8 bits). En effet, nous n'avons que deux valeurs différentes pour le vocabulaire, Data Scientist et Student. Pour représenter deux valeurs différentes, nous n'avons besoin que de 1 bit : soit 0 ou 1. Mais comme nous avons supposé que nous pouvons allouer un entier d'1 octet au minimum, nous obtenons 1 octet pour chaque entrée.
Chargez uniquement les colonnes pertinentes
Il est probable que vous n'utilisiez pas toutes les colonnes de votre ensemble de données pour mener une analyse spécifique. En utilisant l'argument usecols, vous pouvez charger uniquement les colonnes qui vous intéressent. C'est une astuce simple, mais elle vous aide à gérer facilement des ensembles de données volumineux.
Les dtypes de Pandas contiennent tous les dtypes de numpy et des types spécifiques à Pandas. Vous pouvez trouver la liste détaillée de tous les types : pour Pandas et pour numpy.
En pratique
Passons à un véritable ensemble de données pour démontrer l'importance des types de données. Nous utiliserons un ensemble de données public provenant de Kaggle intitulé : Resale Flat Prices in Sigapore (entre 1990 et 1999).
Tout d'abord, importons les bibliothèques nécessaires et construisons le chemin d'accès à l'ensemble de données.
from pathlib import Path
import numpy as np
import pandas as pd
# Source de données :
# <https://www.kaggle.com/sveneschlbeck/resale-flat-prices-in-singapore>
data_path = Path(__file__).parents[0] / "flat-prices.zip"
Ensuite, définissons deux fonctions d'aide :
Nous utilisons df.memory_usage(deep=True)
pour obtenir la taille mémoire par colonne et la sommons pour obtenir la taille totale. L'argument deep=True
est important pour obtenir la quantité réelle de mémoire consommée.
def to_mb(octets: float) -> float:
"""Fonction pour convertir des octets en méga-octets (Mo)"""
return octets / 1024 ** 2
def analyze(df: pd.DataFrame, title: str) -> float:
"""Affiche la mémoire utilisée par le dataframe en Mo et renvoie les
octets"""
octets = df.memory_usage(deep=True).sum()
mo = to_mb(octets)
print(f"{title} : {mo:.2f} Mo")
print(df.dtypes)
return mo
Ensuite, chargeons les données en utilisant pd.read_csv
sans aucune optimisation :
def sans_optimisation() -> pd.DataFrame:
"""Charge les données sans aucune optimisation"""
df = pd.read_csv(data_path)
return df
...
if __name__ == "__main__":
# Nous commençons d'abord sans aucune optimisation
df_sans_optim = sans_optimisation()
mo_sans_optim = analyze(df_sans_optim, "Sans aucune optimisation")
Sans aucune optimisation : 131,36 Mo
mois object
cite object
type_appart object
bloc object
nom_rue object
plage_etages object
surface_habitable_de_l_appart float64
modele_appart object
annee_location int64
prix_revente int64
dtype: object
Le dataframe prend 131.36 Mo en mémoire. Nous examinons également les types de colonnes et remarquons que certains types numériques peuvent être améliorés. Intéressons-nous aux colonnes surface_habitable_de_l_appart
et prix_revente
:
surface_habitable_de_l_appart prix_revente
31.0 9000
31.0 6000
31.0 8000
31.0 6000
73.0 47200
...
142.0 456000
142.0 408000
146.0 469000
146.0 440000
145.0 484000
La surface_habitable_de_l_appart
peut s'adapter à une colonne float de 16 bits (avec une valeur maximale de 6,55 × 10e4) et peut être représentée par un float16
. prix_revente
peut s'insérer dans un int non signé de 32 bits (avec une valeur maximale de 4,29e+09) et peut être représenté par un uint32
.
Vous pouvez consulter le nombre, le minimum, le maximum et d'autres statistiques pour chaque colonne de votre ensemble de données à l'aide de df.describe()
.
Cela est particulièrement utile pour trouver la plage des colonnes numériques et la cardinalité (comptage des valeurs uniques) d'une colonne catégorique.
- Les colonnes numériques avec une petite plage peuvent être optimisées avec un type plus petit
- Le type
category
pour une colonne de faible cardinalité permet de réaliser un gain de mémoire considérable
Appliquons maintenant nos optimisations de types numériques. Nous utilisons l'argument dtypes
dans pd.read_csv
pour spécifier un type par colonne.
def avec_types_numeriques() -> pd.DataFrame:
"""Charge les données avec les types numériques appropriés"""
df = pd.read_csv(
data_path,
dtype={
"surface_habitable_de_l_appart": np.float16,
"prix_revente": np.uint32,
},
)
return df
La taille du dataframe en mémoire est maintenant de 128.62 Mo ce qui correspond à une réduction légère de 2.09%.
Maintenant, intéressons-nous aux colonnes catégoriques. modele_appart
, type_appart
, plage_etages
, bloc
et cite
sont de bons candidats pour des colonnes catégoriques. Ils ont une faible cardinalité, ce qui indique un grand potentiel d'amélioration de la mémoire.
Appliquons ces optimisations :
def avec_types_numeriques_et_catégoriques() -> pd.DataFrame:
"""Charge les données avec les types numériques et catégoriques appropriés"""
df = pd.read_csv(
data_path,
dtype={
"surface_habitable_de_l_appart": np.float16,
"prix_revente": np.uint32,
"modele_appart": "category",
"type_appart": "category",
"plage_etages": "category",
"bloc": "category",
"cite": "category",
},
)
return df
La taille du dataframe en mémoire est maintenant de 42.54 Mo ce qui correspond à une énorme réduction de 67.61% par rapport au cas sans aucune optimisation !
def avec_types_numeriques_et_catégoriques_sans_colonnes_inutilisées() -> pd.DataFrame:
"""Charge les données avec les types numériques et catégoriques appropriés
et sans les colonnes inutilisées"""
dtype = {
"surface_habitable_de_l_appart": np.float16,
"prix_revente": np.uint32,
"modele_appart": "category",
"type_appart": "category",
"plage_etages": "category",
"bloc": "category",
"cite": "category",
}
df = pd.read_csv(data_path, dtype=dtype, usecols=list(dtype.keys()))
return df
La taille du dataframe en mémoire est maintenant de 3.39 Mo ce qui correspond à une énorme réduction de 97.42% par rapport au cas sans aucune optimisation !
Parfois, tout ce dont vous avez besoin est d'être attentif aux types de vos données et d'être clair sur les colonnes dont vous avez vraiment besoin.
from pathlib import Path
import numpy as np
import pandas as pd
# Source de données :
# <https://www.kaggle.com/sveneschlbeck/resale-flat-prices-in-singapore>
data_path = Path(__file__).parents[0] / "flat-prices.zip"
def to_mb(octets: float) -> float:
"""Fonction pour convertir des octets en méga-octets (Mo)"""
return octets / 1024 ** 2
def sans_optimisation() -> pd.DataFrame:
"""Charge les données sans aucune optimisation"""
df = pd.read_csv(data_path)
return df
def avec_types_numeriques() -> pd.DataFrame:
"""Charge les données avec les types numériques appropriés"""
df = pd.read_csv(
data_path,
dtype={
"surface_habitable_de_l_appart": np.float16,
"prix_revente": np.uint32,
},
)
return df
def avec_types_numeriques_et_catégoriques() -> pd.DataFrame:
"""Charge les données avec les types numériques et catégoriques appropriés"""
df = pd.read_csv(
data_path,
dtype={
"surface_habitable_de_l_appart": np.float16,
"prix_revente": np.uint32,
"modele_appart": "category",
"type_appart": "category",
"plage_etages": "category",
"bloc": "category",
"cite": "category",
},
)
return df
def avec_types_numeriques_et_catégoriques_sans_colonnes_inutilisées() -> pd.DataFrame:
"""Charge les données avec les types numériques et catégoriques appropriés
et sans les colonnes inutilisées"""
dtype = {
"surface_habitable_de_l_appart": np.float16,
"prix_revente": np.uint32,
"modele_appart": "category",
"type_appart": "category",
"plage_etages": "category",
"bloc": "category",
"cite": "category",
}
df = pd.read_csv(data_path, dtype=dtype, usecols=list(dtype.keys()))
return df
def analyze(df: pd.DataFrame, title: str) -> float:
"""Affiche la mémoire utilisée par le dataframe en Mo et renvoie les
octets"""
octets = df.memory_usage(deep=True).sum()
mo = to_mb(octets)
print(f"{title} : {mo:.2f} Mo")
print(df.dtypes)
return mo
if __name__ == "__main__":
# Nous commençons d'abord sans aucune optimisation
df_sans_optim = sans_optimisation()
mo_sans_optim = analyze(df_sans_optim, "Sans aucune optimisation")
print()
# Ensuite, nous utilisons les types numériques appropriés
df_types_appropriés = avec_types_numeriques()
mo_types_appropriés = analyze(df_types_appropriés, "Types numériques appropriés")
réduction = (mo_sans_optim - mo_types_appropriés) / mo_sans_optim * 100
print(f"Taille réduite de : {réduction:.2f}%")
print()
# Ensuite, nous utilisons les types numériques et catégoriques appropriés
df_catégoriques = avec_types_numeriques_et_catégoriques()
mo_catégoriques = analyze(df_catégoriques, "Types numériques et catégoriques appropriés")
réduction = (mo_sans_optim - mo_catégoriques) / mo_sans_optim * 100
print(f"Taille réduite de : {réduction:.2f}%")
print()
# Pas besoin de charger des colonnes inutilisées. Nous utilisons souvent un sous-ensemble des
# colonnes, pertinentes pour notre analyse.
# Nous supposons que nous ne sommes intéressés que par les colonnes où nous
# avons défini les types (numériques et catégoriques)
df_sans_colonnes_inutilisées = (
avec_types_numeriques_et_catégoriques_sans_colonnes_inutilisées()
)
mo_sans_colonnes_inutilisées = analyze(
df_sans_colonnes_inutilisées,
"Types numériques et catégoriques appropriés, sans colonnes inutilisées",
)
réduction = (mo_sans_optim - mo_sans_colonnes_inutilisées) / mo_sans_optim * 100
print(f"Taille réduite de : {réduction:.2f}%")