Pandas Avancé

Maîtriser les Internals de Pandas : Performance, Mémoire et Optimisations Avancées

Plongez dans l'architecture interne de Pandas pour débloquer des performances exponentielles et comprendre les mécanismes cachés qui gouvernent la manipulation de données massives. Découvrez comment les index, la gestion mémoire et les opérations vectorisées façonnent vos analyses data.

Preparetoi.academy 30 min

1. Architecture Interne de Pandas et Modèle de Données

Définition

L'architecture interne de Pandas repose sur deux structures fondamentales : le DataFrame (tableau 2D avec index et colonnes) et la Series (vecteur 1D avec index). Ces structures s'appuient sur NumPy pour les calculs vectorisés et utilisent BlockManager pour gérer efficacement la mémoire en regroupant les données par type.

Explication Détaillée

Pandas n'est pas simplement une enveloppe autour de NumPy. Il utilise une architecture complexe appelée BlockManager qui optimise la disposition en mémoire. Chaque DataFrame stocke les données par blocs homogènes (colonnes de même type). Cette organisation améliore les performances des opérations intra-type mais peut créer des surcoûts lors de conversions de types. Les index ne sont pas simplement des étiquettes : ce sont des structures de données sophistiquées (Index, RangeIndex, MultiIndex, DatetimeIndex) qui permettent des recherches O(1) et des opérations d'alignement automatiques.

import pandas as pd
import numpy as np
from pandas.core.internals import BlockManager
import sys

# Inspection de l'architecture interne
df = pd.DataFrame({
    'A': np.arange(1000000),
    'B': np.random.randn(1000000),
    'C': ['texte'] * 1000000
})

# Accès au BlockManager
print("BlockManager structure:")
print(df._mgr)
print(f"Nombre de blocs: {len(df._mgr.blocks)}")
for i, block in enumerate(df._mgr.blocks):
    print(f"Bloc {i}: dtype={block.dtype}, shape={block.shape}")

# Taille mémoire détaillée
print(f"\nTaille totale DataFrame: {df.memory_usage(deep=True).sum() / 1024**2:.2f} MB")
print(f"Taille par colonne:\n{df.memory_usage(deep=True)}")

# Démonstration de l'alignement d'index
s1 = pd.Series([1, 2, 3], index=['a', 'b', 'c'])
s2 = pd.Series([4, 5, 6], index=['b', 'c', 'd'])
result = s1 + s2  # Alignement automatique basé sur l'index
print(f"\nRésultat avec alignement:\n{result}")

# Inspection des types d'index
df_idx = pd.DataFrame(
    np.random.randn(5, 3),
    index=pd.date_range('2024-01-01', periods=5)
)
print(f"\nType d'index: {type(df_idx.index)}")
print(f"Index est monotone: {df_idx.index.is_monotonic_increasing}")

Tableau Récapitulatif

Aspect Description Impact Mémoire Performance
BlockManager Agrégation par type de donnée Réduit fragmentation +30% pour opérations typées
Index Structure de recherche optimisée Minimal O(1) pour lookup
Dtype inférence Détection automatique du type Variable Peut doubler la mémoire
Copy-on-write Évite copie lors de slicing -50% mémoire +40% opérations

Astuce d'Expert

Utilisez infer_objects() après des opérations qui pourraient générer des dtypes non optimaux. Cette méthode convertit les colonnes object vers les types numériques ou category appropriés, réduisant la consommation mémoire de 60-80% sur les gros datasets.

⚠️ Attention Critique

Ne jamais modifier df._mgr directement : l'architecture interne est volatile entre versions. Les optimisations de BlockManager peuvent changer sans avertissement. Préférez toujours les API publiques pour garantir la compatibilité future.


2. Optimisation Mémoire et Gestion des Types de Données

Définition

L'optimisation mémoire consiste à choisir les dtypes appropriés pour minimiser la consommation RAM sans sacrifier la précision ou la capacité de calcul. Pandas propose des types catégoriques, des entiers avec valeurs manquantes (Int64), et des formats compressés pour réduire l'empreinte mémoire de 10x.

Explication Détaillée

Un DataFrame avec un million de lignes peut consommer entre 8 MB et plusieurs GB selon les choix de dtype. Par défaut, les colonnes numériques utilisent float64 (8 bytes) et les colonnes texte utilisent object (pointeurs Python, très coûteux). En utilisant category pour les colonnes à faible cardinalité, Int32 au lieu de Int64, et string au lieu d'object, on économise drastiquement. Les valeurs manquantes (NaN) doivent aussi être gérées : pandas convertit automatiquement int64 en float64 pour représenter NaN, ce qui double la mémoire. Utilisez Int64 (nullable integer) pour éviter cette conversion.

import pandas as pd
import numpy as np

# Comparaison des dtypes
np.random.seed(42)
n = 1000000

# Situation problématique : dtypes mal choisis
df_bad = pd.DataFrame({
    'id': np.arange(n),  # int64 par défaut
    'category': np.random.choice(['A', 'B', 'C', 'D'], n),  # object = str
    'score': np.random.randint(0, 100, n),  # int64 (overkill)
    'flag': np.random.choice([True, False], n),  # bool OK
    'price': np.random.rand(n)  # float64
})

print("AVANT optimisation:")
print(f"Mémoire utilisée: {df_bad.memory_usage(deep=True).sum() / 1024**2:.2f} MB")
print(f"Dtypes:\n{df_bad.dtypes}\n")

# Optimisation : réduction drastique
df_good = pd.DataFrame({
    'id': pd.array(np.arange(n), dtype='uint32'),  # uint32 suffisant
    'category': pd.Categorical(np.random.choice(['A', 'B', 'C', 'D'], n)),  # category
    'score': pd.array(np.random.randint(0, 100, n), dtype='uint8'),  # uint8 pour 0-255
    'flag': np.random.choice([True, False], n),  # bool
    'price': np.float32(np.random.rand(n))  # float32 pour moins de précision
})

print("APRÈS optimisation:")
print(f"Mémoire utilisée: {df_good.memory_usage(deep=True).sum() / 1024**2:.2f} MB")
print(f"Dtypes:\n{df_good.dtypes}\n")
print(f"Réduction: {(1 - df_good.memory_usage(deep=True).sum() / df_bad.memory_usage(deep=True).sum()) * 100:.1f}%")

# Gestion des valeurs manquantes avec nullable integers
df_nullable = pd.DataFrame({
    'age': pd.array([25, 30, None, 35, 28], dtype='Int64'),  # Int64 nullable
    'salary': pd.array([50000, None, 70000, 80000, 60000], dtype='Int32')
})
print(f"\nDataFrame avec Int64 nullable:\n{df_nullable}")
print(f"Dtypes: {df_nullable.dtypes.to_dict()}")
print(f"Mémoire: {df_nullable.memory_usage(deep=True).sum()} bytes")

# Fonction utilitaire d'optimisation automatique
def optimize_dtypes(df):
    """Optimise automatiquement les dtypes d'un DataFrame"""
    for col in df.columns:
        col_type = df[col].dtype
        
        if col_type == 'object':
            # Conversion en category si peu de valeurs uniques
            n_unique = df[col].nunique()
            if n_unique / len(df) < 0.05:
                df[col] = df[col].astype('category')
            # Sinon, essayer de convertir en numérique
            elif pd.to_numeric(df[col], errors='coerce').notna().sum() > 0.95 * len(df):
                df[col] = pd.to_numeric(df[col], errors='coerce')
        
        elif col_type in ['int64', 'int32', 'int16', 'int8']:
            # Réduire la précision si possible
            max_val = df[col].max()
            min_val = df[col].min()
            if min_val >= 0 and max_val <= 255:
                df[col] = df[col].astype('uint8')
            elif min_val >= 0 and max_val <= 65535:
                df[col] = df[col].astype('uint16')
            elif min_val >= -128 and max_val <= 127:
                df[col] = df[col].astype('int8')
        
        elif col_type == 'float64':
            # Réduire à float32 si acceptable
            if df[col].notna().sum() > 0:
                df[col] = df[col].astype('float32')
    
    return df

df_test = optimize_dtypes(df_bad.copy())
print(f"\nAprès optimisation automatique: {df_test.memory_usage(deep=True).sum() / 1024**2:.2f} MB")

Tableau Récapitulatif

Dtype Original Dtype Optimisé Cas d'Usage Réduction
object (string) category <1000 valeurs uniques -95%
int64 uint8 Valeurs 0-255 -87.5%
float64 float32 Précision limitée -50%
int64 avec NaN Int64 (nullable) Entiers avec manquants -25%
datetime64[ns] datetime64[ms] Précision ms suffisante -25%

Astuce d'Expert

Utilisez pd.read_csv(..., dtype={...}) ou df.astype({...}) avec dictionnaire pour appliquer les optimisations dès la lecture. Cela évite la double allocation mémoire. Pour les très gros fichiers, lisez par chunks avec chunksize et appliquez les conversions immédiatement.

⚠️ Attention Critique

Les conversions de dtype peuvent perdre de la précision (float64→float32) ou causer des débordements. Testez toujours les conversions sur un sous-ensemble avant d'appliquer à 1 milliard de lignes. Les category sont immutables après création : les modifications sont plus lentes.


3. Indexation Avancée et Alignement de Données

Définition

L'indexation avancée en Pandas englobe boolean indexing, fancy indexing, MultiIndex et les opérations d'alignement automatique. Elle permet des sélections complexes O(1) et des jointures intelligentes basées sur les index sans expliciter le critère de jointure.

Explication Détaillée

Pandas offre plusieurs mécanismes d'indexation : .loc (basé label, inclusif des deux extrêmes), .iloc (basé position entière, exclusif de la fin), .at/.iat (accès scalaire rapide), et [] (syntaxe simplifiée mais ambigu). Le MultiIndex crée une hiérarchie d'index permettant des opérations groupby implicites et des slices multi-niveaux. L'alignement automatique signifie que deux Series avec des index différents s'additionnent après fusion par index : c'est puissant mais cache des opérations coûteuses. Les index non triés ralentissent les lookups de O(1) à O(n). Les index dupliqués causent des retours en liste au lieu de Series simple.

import pandas as pd
import numpy as np
import time

# 1. Indexation basique : loc vs iloc vs at
df = pd.DataFrame({
    'A': np.arange(1000000),
    'B': np.random.randn(1000000),
    'C': ['x', 'y', 'z'] * 333333 + ['x']
}, index=pd.date_range('2020-01-01', periods=1000000))

# loc : label-based, lent pour de grands datasets
start = time.time()
result_loc = df.loc['2020-06-01':'2020-06-10']
print(f"loc (range): {(time.time()-start)*1000:.3f}ms")

# iloc : position-based, généralement plus rapide
start = time.time()
result_iloc = df.iloc[150000:150010]
print(f"iloc (range): {(time.time()-start)*1000:.3f}ms")

# at : scalaire, très rapide
start = time.time()
val = df.at[df.index[500000], 'B']
print(f"at (scalaire): {(time.time()-start)*1000:.6f}ms")

# 2. Boolean indexing avec optimisations
mask = df['A'] > 500000
start = time.time()
filtered = df[mask]
print(f"\nBoolean indexing: {(time.time()-start)*1000:.3f}ms")

# Boolean avec query (plus rapide pour expressions complexes)
start = time.time()
filtered2 = df.query('A > 500000 and B > 0')
print(f"Query (complexe): {(time.time()-start)*1000:.3f}ms")

# 3. MultiIndex : hiérarchie puissante
arrays = [
    ['A', 'A', 'B', 'B', 'C', 'C'],
    ['one', 'two', 'one', 'two', 'one', 'two']
]
index = pd.MultiIndex.from_arrays(arrays, names=['first', 'second'])
df_multi = pd.DataFrame(np.random.randn(6, 3), index=index, columns=['X', 'Y', 'Z'])

print(f"\nMultiIndex DataFrame:\n{df_multi}")

# Slicing multi-niveau (partial indexing)
print(f"\ndf_multi.loc['A']:\n{df_multi.loc['A']}")
print(f"\ndf_multi.loc[('A', 'one')]:\n{df_multi.loc[('A', 'one')]}")

# 4. Alignement automatique (puissant mais dangereux)
s1 = pd.Series([1, 2, 3], index=['a', 'b', 'c'])
s2 = pd.Series([4, 5, 6], index=['b', 'c', 'd'])

print(f"\ns1:\n{s1}")
print(f"\ns2:\n{s2}")
print(f"\ns1 + s2 (alignement automatique):\n{s1 + s2}")  # NaN où indices ne matchent pas

# 5. Index trié vs non-trié (performance)
df_unsorted = pd.DataFrame({'val': np.arange(100000)}, 
                            index=np.random.permutation(100000))
df_sorted = df_unsorted.sort_index()

print(f"\nIndex trié: {df_sorted.index.is_monotonic_increasing}")
print(f"Index non-trié: {df_unsorted.index.is_monotonic_increasing}")

start = time.time()
_ = df_sorted.loc[50000]
print(f"Lookup index trié: {(time.time()-start)*1000:.6f}ms")

start = time.time()
_ = df_unsorted.loc[50000]
print(f"Lookup index non-trié: {(time.time()-start)*1000:.6f}ms")

# 6. Gestion des index dupliqués
df_dup = pd.DataFrame({'A': [1, 2, 3, 4]}, index=['x', 'y', 'x', 'z'])
print(f"\nAccès avec index dupliqué df_dup.loc['x']:")
print(df_dup.loc['x'])  # Retourne un DataFrame, pas une Series!

# 7. Setting avec loc (assign + broadcast)
df_copy = df.copy()
df_copy.loc[df_copy['A'] > 999990, 'C'] = 'MODIFIED'
print(f"\nAprès modification avec loc: {df_copy[df_copy['C'] == 'MODIFIED'].shape[0]} lignes")

Tableau Récapitulatif

Méthode Base Vitesse Inclusivité Cas d'Usage
.loc Label Lente [start:stop] inclus Sélection par label
.iloc Position Rapide [start:stop] exclusif Sélection par position
.at Label Très rapide Scalaire Accès valeur unique
.iat Position Très rapide Scalaire Accès rapide scalaire
[] Ambigu Moyen Variable Sélection colonne
.query() Expression Rapide Variable Filtres complexes

Astuce d'Expert

Triez toujours les index non-triés avec .sort_index() avant des opérations répétées. Utilisez .set_index() pour créer un index approprié avant les boucles. Pour MultiIndex, préférez .xs() aux sélections multiples avec .loc[].

⚠️ Attention Critique

Les index dupliqués causent des bugs subtils : df.loc['x'] retourne DataFrame au lieu de Series. L'alignement automatique peut masquer des NaN : toujours vérifier avec .isna().sum(). Les slices avec .loc sont inclusifs des deux côtés contrairement à Python classique.


4. Opérations Vectorisées et Évitement des Boucles Python

Définition

Les opérations vectorisées appliquent une fonction à tous les éléments simultanément via NumPy/Pandas, contrairement aux boucles Python explicites. Elles sont 100-1000x plus rapides car elles évitent le surcoût Python et utilisent du code C compilé. Les alternatives incluent .apply(), .map(), .applymap() et les opérations NumPy natives.

Explication Détaillée

NumPy et Pandas sont écrits principalement en C/Cython. Une opération vectorisée df['A'] + df['B'] exécute l'addition 1 million de fois en C pur. Une boucle for i in range(1000000): result[i] = df['A'][i] + df['B'][i] appelle l'interpréteur Python 1 million de fois. L'overhead Python (vérification de type, GC) rend les boucles exponentiellement plus lentes. Cependant, certaines opérations ne peuvent pas être vectorisées naïvement : elles requièrent .apply() (Python, lent), .map() (dictionnaire lookup, rapide), ou .values pour accès NumPy. Le profiling est essential : ce qui semble logique peut être 100x plus lent.

import pandas as pd
import numpy as np
import time
from numba import jit

# Setup : grand dataset
np.random.seed(42)
n = 1000000
df = pd.DataFrame({
    'A': np.random.rand(n),
    'B': np.random.rand(n),
    'category': np.random.choice(['X', 'Y', 'Z'], n),
    'value': np.random.randint(0, 1000, n)
})

# 1. Vectorisé vs Boucle Python
print("=== COMPARAISON VECTORISÉ vs BOUCLE ===\n")

# Vectorisé : très rapide
start = time.time()
result_vec = df['A'] + df['B']
t_vec = time.time() - start
print(f"Vectorisé (df['A'] + df['B']): {t_vec*1000:.2f}ms")

# Boucle Python : extrêmement lent
start = time.time()
result_loop = np.zeros(n)
for i in range(n):
    result_loop[i] = df['A'].iloc[i] + df['B'].iloc[i]
t_loop = time.time() - start
print(f"Boucle Python (iloc): {t_loop*1000:.2f}ms")
print(f"Ratio: {t_loop/t_vec:.0f}x plus lent\n")

# 2. apply vs map vs values (pour opérations complexes)
print("=== APPLY vs MAP vs VALUES ===\n")

def complex_func(x):
    """Fonction complexe qu'on ne peut pas vectoriser simplement"""
    if x > 0.5:
        return x ** 2
    else:
        return x ** 0.5

# apply : pythonic mais lent
start = time.time()
result_apply = df['A'].apply(complex_func)
t_apply = time.time() - start
print(f"apply(func): {t_apply*1000:.2f}ms")

# Vectorisé avec np.where : beaucoup plus rapide
start = time.time()
result_where = np.where(df['A'] > 0.5, df['A']**2, df['A']**0.5)
t_where = time.time() - start
print(f"np.where vectorisé: {t_where*1000:.2f}ms")
print(f"Ratio: {t_apply/t_where:.0f}x plus rapide\n")

# 3. map pour catégories (très rapide)
print("=== MAP POUR CATÉGORIES ===\n")

mapping = {'X': 1, 'Y': 2, 'Z': 3}

# map : optimisé pour lookups
start = time.time()
result_map = df['category'].map(mapping)
t_map = time.time() - start
print(f"map(dict): {t_map*1000:.2f}ms")

# apply : pour comparaison
start = time.time()
result_apply2 = df['category'].apply(lambda x: mapping[x])
t_apply2 = time.time() - start
print(f"apply(lambda): {t_apply2*1000:.2f}ms")
print(f"map est {t_apply2/t_map:.0f}x plus rapide\n")

# 4. Opérations groupby vectorisées (crucial!)
print("=== GROUPBY VECTORISÉ ===\n")

# Approche lente : boucler sur groupes
start = time.time()
result_loop_gb = []
for cat in df['category'].unique():
    mask = df['category'] == cat
    result_loop_gb.append(df[mask]['value'].sum())
t_loop_gb = time.time() - start
print(f"Boucle sur groupes: {t_loop_gb*1000:.2f}ms")

# Approche vectorisée : groupby
start = time.time()
result_groupby = df.groupby('category')['value'].sum()
t_groupby = time.time() - start
print(f"groupby vectorisé: {t_groupby*1000:.2f}ms")
print(f"Ratio: {t_loop_gb/t_groupby:.0f}x plus rapide\n")

# 5. Numba JIT compilation pour opérations très complexes
print("=== NUMBA JIT POUR EXTRÊME PERFORMANCE ===\n")

@jit(nopython=True)
def complex_calculation_jit(a, b):
    """Compilée en C par Numba"""
    result = np.zeros(len(a))
    for i in range(len(a)):
        if a[i] > 0.5:
            result[i] = a[i] ** 2 + b[i]
        else:
            result[i] = a[i] ** 0.5 - b[i]
    return result

start = time.time()
result_numba = complex_calculation_jit(df['A'].values, df['B'].values)
t_numba = time.time() - start
print(f"Numba JIT (boucle compilée): {t_numba*1000:.2f}ms")

# 6. eval pour expressions sur DataFrames (rapide)
print("\n=== EVAL/QUERY ===\n")

# Approche lente : opérations successives
start = time.time()
result_slow = ((df['A'] + df['B']) * (df['A'] - df['B'])) / (df['A'] + 1)
t_slow = time.time() - start

# eval : compile l'expression
start = time.time()
result_eval = pd.eval("((df['A'] + df['B']) * (df['A'] - df['B'])) / (df['A'] + 1)")
t_eval = time.time() - start

print(f"Opérations successives: {t_slow*1000:.2f}ms")
print(f"pd.eval: {t_eval*1000:.2f}ms")

# 7. Réductions optimisées
print("\n=== RÉDUCTIONS OPTIMISÉES ===\n")

# Lent : sum() vs .sum()
start = time.time()
s = sum(df['value'])
print(f"sum() Python: {(time.time()-start)*1000:.3f}ms")

start = time.time()
s = df['value'].sum()
print(f".sum() Pandas: {(time.time()-start)*1000:.3f}ms")

# 8. Checklist pour déboguer performance
print("\n=== PROFILING ===\n")

def timing_decorator(func):
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        elapsed = (time.time() - start) * 1000
        print(f"{func.__name__}: {elapsed:.2f}ms")
        return result
    return wrapper

@timing_decorator
def slow_operation():
    return sum([x**2 for x in df['value']])

@timing_decorator
def fast_operation():
    return (df['value']**2).sum()

slow_operation()
fast_operation()

Tableau Récapitulatif

Approche Performance Lisibilité Cas d'Usage
Vectorisé NumPy 1000x+ rapide Bonne Opérations simples
.apply() 10-100x lent Très bonne Logique complexe
.map() Rapide Bonne Lookups catégories
.eval()/.query() 2-5x rapide Excellente Expressions complexes
Numba JIT 100-1000x rapide Mauvaise Boucles scientifiques
Boucle Python Très lent Excellente À ÉVITER

Astuce d'Expert

Profiler toujours avec %%timeit en Jupyter avant d'optimiser. Utilisez .values ou .to_numpy() pour accès NumPy pur si vous allez boucler. Pour .apply() inévitable, utilisez raw=True ou réécrivez en NumPy vectorisé.

⚠️ Attention Critique

.apply() sur grandes données peut causer des ralentissements dramatiques et dépassements mémoire. Numba ne supporte qu'un sous-ensemble de NumPy. Les expressions complexes avec pd.eval() sont moins évidentes à déboguer : testez sur petits échantillons d'abord.


5. Gestion des Données Manquantes et Techniques Avancées de Nettoyage

Définition

La gestion des valeurs manquantes inclut la détection, l'imputation intelligente et les stratégies de suppression. Pandas représente les manquants avec NaN (float), None (object), pd.NA (nullable), ou pd.NaT (datetime). Chaque méthode a des implications de performance et de précision radicalement différentes.

Explication Détaillée

Les données manquantes causent 90% des problèmes en ML. Ignorer les NaN peut biaiser les modèles. Les supprimer réduit la puissance statistique. Les imputer mal crée des biais. Pandas offre plusieurs stratégies : .dropna() (suppression), .fillna() (remplissage statique), .interpolate() (interpolation temporelle), .bfill()/.ffill() (propagation), et des méthodes avancées (KNN, régression, EM). Les NaN se propagent : df['A'].sum() ignore les NaN mais df['A'].mean() peut retourner NaN si toute la colonne est NaN. Les opérations groupby avec NaN dans l'index créent des groupes distincts.

import pandas as pd
import numpy as np
from sklearn.impute import SimpleImputer, KNNImputer
from sklearn.experimental import enable_iterative_imputer
from sklearn.impute import IterativeImputer
import matplotlib.pyplot as plt

# Setup : données avec manquants réalistes
np.random.seed(42)
n = 1000
data = {
    'age': np.random.randint(20, 80, n),
    'salary': np.random.randint(30000, 150000, n),
    'years_exp': np.random.randint(0, 40, n),
    'bonus': np.random.rand(n) * 10000
}
df = pd.DataFrame(data)

# Créer des manquants MCAR (Missing Completely At Random)
missing_rate = 0.2
for col in df.columns:
    missing_indices = np.random.choice(n, int(n * missing_rate), replace=False)
    df.loc[missing_indices, col] = np.nan

print("=== EXPLORATION DES MANQUANTS ===\n")
print(df.head(15))
print(f"\nNombre de manquants par colonne:\n{df.isna().sum()}")
print(f"\nPourcentage manquants:\n{(df.isna().sum() / len(df) * 100).round(1)}")

# Matrice de manquants pour visualiser les patterns
missing_matrix = df.isna()
print(f"\nPatterns de manquants (5 premières lignes):\n{missing_matrix.head()}")

# 1. DÉTECTION AVANCÉE DES MANQUANTS
print("\n=== DÉTECTION DES MANQUANTS ===\n")

# Manquants
Accédez à des centaines d'examens QCM — Découvrir les offres Premium