Pandas — Series et DataFrame#

Hide code cell source

import matplotlib.pyplot as plt
import matplotlib.patches as patches
import numpy as np
import pandas as pd
import seaborn as sns

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)
rng = np.random.default_rng(42)

Pandas est la bibliothèque incontournable de la manipulation de données tabulaires en Python. Son nom est un acronyme de Panel Data, un terme économétrique désignant des données qui combinent des dimensions temporelles et transversales. Depuis sa création par Wes McKinney en 2008, Pandas s’est imposé comme le standard pour charger, inspecter, nettoyer et transformer les données structurées dans des notebooks et des pipelines de production.

La force de Pandas réside dans son concept d”index : contrairement aux tableaux NumPy qui s’adressent uniquement par position entière, les structures Pandas associent à chaque ligne et à chaque colonne des étiquettes explicites. Ces étiquettes permettent l”alignement automatique des données lors des opérations arithmétiques ou des fusions de tables — une propriété qui élimine une catégorie entière de bugs difficiles à tracer dans les manipulations manuelles de tableaux.

Ce chapitre couvre les deux structures de données fondamentales de Pandas — Series et DataFrame — ainsi que les mécanismes d’indexation, de slicing, de lecture et d’écriture de fichiers, et les outils d’inspection initiale d’un jeu de données.

La Series#

Series

Une Series est un tableau unidimensionnel ordonné dont chaque élément est associé à une étiquette appelée index. Elle peut être vue comme un dictionnaire ordonné : les clés sont les étiquettes de l’index, et les valeurs sont les données. Tous les éléments d’une Series partagent le même type (dtype), et la Series peut contenir des valeurs manquantes (NaN ou pd.NA selon le type).

import pandas as pd
import numpy as np

# Création à partir d'une liste — index entier par défaut (0, 1, 2, …)
s1 = pd.Series([10, 20, 30, 40, 50])
print("Series avec index par défaut :")
print(s1)
print(f"\ndtype : {s1.dtype}  |  shape : {s1.shape}")
print()

# Création avec un index explicite
temperatures = pd.Series(
    [12.5, 14.2, 18.7, 24.1, 28.3, 27.8, 22.4],
    index=['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'],
    name='Température (°C)'
)
print("Températures hebdomadaires :")
print(temperatures)

# Création à partir d'un dictionnaire
population = pd.Series({'Paris': 2161000, 'Lyon': 522000,
                         'Marseille': 862000, 'Toulouse': 479000})
print("\nPopulations :")
print(population)
Series avec index par défaut :
0    10
1    20
2    30
3    40
4    50
dtype: int64

dtype : int64  |  shape : (5,)

Températures hebdomadaires :
Lun    12.5
Mar    14.2
Mer    18.7
Jeu    24.1
Ven    28.3
Sam    27.8
Dim    22.4
Name: Température (°C), dtype: float64

Populations :
Paris        2161000
Lyon          522000
Marseille     862000
Toulouse      479000
dtype: int64

Les opérations arithmétiques sur une Series sont vectorisées et alignées sur l’index. Si deux Series n’ont pas le même index, Pandas aligne les valeurs par étiquette et introduit des NaN pour les étiquettes manquantes.

# Alignement automatique sur l'index
s_a = pd.Series([1, 2, 3], index=['a', 'b', 'c'])
s_b = pd.Series([10, 20, 40], index=['a', 'b', 'd'])

print("s_a + s_b (alignement automatique) :")
print(s_a + s_b)
s_a + s_b (alignement automatique) :
a    11.0
b    22.0
c     NaN
d     NaN
dtype: float64

Le DataFrame#

DataFrame

Un DataFrame est une structure de données bidimensionnelle avec un index de lignes et un index de colonnes. Il peut être vu comme un dictionnaire ordonné de Series partageant le même index de lignes. Les colonnes peuvent avoir des types différents : une colonne peut être numérique, une autre catégorielle, une autre encore de type date. Conceptuellement, le DataFrame est l’équivalent Pandas d’une feuille de tableur ou d’une table de base de données relationnelle.

Création d’un DataFrame#

# Création à partir d'un dictionnaire de listes (le plus courant)
df = pd.DataFrame({
    'Prénom':      ['Alice', 'Bob', 'Carole', 'David', 'Émilie'],
    'Âge':         [28, 34, 29, 42, 31],
    'Département': ['R&D', 'Marketing', 'R&D', 'Direction', 'Marketing'],
    'Salaire':     [48000, 55000, 51000, 72000, 53000],
    'Actif':       [True, True, False, True, True],
})
print(df)
print(f"\nShape : {df.shape}  |  dtypes :\n{df.dtypes}")
   Prénom  Âge Département  Salaire  Actif
0   Alice   28         R&D    48000   True
1     Bob   34   Marketing    55000   True
2  Carole   29         R&D    51000  False
3   David   42   Direction    72000   True
4  Émilie   31   Marketing    53000   True

Shape : (5, 5)  |  dtypes :
Prénom           str
Âge            int64
Département      str
Salaire        int64
Actif           bool
dtype: object

Un DataFrame peut également être créé depuis d’autres sources :

# Depuis une liste de dictionnaires (une ligne = un dict)
lignes = [
    {'ville': 'Paris', 'population': 2161000, 'superficie': 105},
    {'ville': 'Lyon',  'population':  522000, 'superficie': 48},
]
df_villes = pd.DataFrame(lignes)

# Depuis un tableau NumPy
arr = np.random.default_rng(0).integers(0, 100, size=(4, 3))
df_arr = pd.DataFrame(arr, columns=['X', 'Y', 'Z'])

# Depuis une autre Series (colonne unique)
s = pd.Series([1, 2, 3], name='valeur')
df_from_series = s.to_frame()

Indexation : loc et iloc#

L’indexation est l’opération la plus fondamentale sur un DataFrame. Pandas propose deux mécanismes d’accès aux données qui couvrent des besoins complémentaires.

loc et iloc

loc effectue une indexation par étiquette : df.loc[étiquette_ligne, étiquette_colonne]. Les étiquettes sont inclusives aux deux extrémités dans les slices.

iloc effectue une indexation par position entière : df.iloc[i, j]. Les positions suivent la convention Python, exclusive à droite pour les slices (comme list[0:3]).

La distinction est fondamentale : si l’index du DataFrame est [2, 5, 7], alors df.loc[2] retourne la ligne étiquetée 2, tandis que df.iloc[0] retourne la première ligne quelle que soit son étiquette.

df2 = pd.DataFrame({
    'nom':    ['Alice', 'Bob', 'Carole', 'David'],
    'score':  [85, 92, 78, 88],
    'grade':  ['B', 'A', 'C', 'B'],
}, index=[10, 20, 30, 40])  # index non consécutif

print("DataFrame avec index non consécutif :")
print(df2)
print()

# loc : accès par étiquette
print("df2.loc[20] — ligne étiquetée 20 :")
print(df2.loc[20])
print()

print("df2.loc[10:30, 'nom':'score'] — slice par étiquettes :")
print(df2.loc[10:30, 'nom':'score'])
print()

# iloc : accès par position
print("df2.iloc[1] — deuxième ligne (position 1) :")
print(df2.iloc[1])
print()

print("df2.iloc[0:2, 0:2] — slice par positions :")
print(df2.iloc[0:2, 0:2])
DataFrame avec index non consécutif :
       nom  score grade
10   Alice     85     B
20     Bob     92     A
30  Carole     78     C
40   David     88     B

df2.loc[20] — ligne étiquetée 20 :
nom      Bob
score     92
grade      A
Name: 20, dtype: object

df2.loc[10:30, 'nom':'score'] — slice par étiquettes :
       nom  score
10   Alice     85
20     Bob     92
30  Carole     78

df2.iloc[1] — deuxième ligne (position 1) :
nom      Bob
score     92
grade      A
Name: 20, dtype: object

df2.iloc[0:2, 0:2] — slice par positions :
      nom  score
10  Alice     85
20    Bob     92

Sélection de colonnes#

La sélection d’une colonne par son nom avec df['colonne'] retourne une Series. La sélection de plusieurs colonnes avec df[['col1', 'col2']] retourne un DataFrame.

# Sélection d'une colonne → Series
scores = df2['score']
print(f"Type : {type(scores).__name__}")
print(scores)
print()

# Sélection de plusieurs colonnes → DataFrame
sous_df = df2[['nom', 'grade']]
print(sous_df)
Type : Series
10    85
20    92
30    78
40    88
Name: score, dtype: int64

       nom grade
10   Alice     B
20     Bob     A
30  Carole     C
40   David     B

Sélection conditionnelle (masque booléen)#

La sélection par condition est l’une des opérations les plus puissantes de Pandas. Un masque booléen est une Series de valeurs True/False de même longueur que le DataFrame, obtenue par une comparaison.

# Masque booléen
masque_score = df2['score'] >= 85
print("Masque (score >= 85) :")
print(masque_score)
print()

# Application du masque
print("Lignes avec score >= 85 :")
print(df2[masque_score])
print()

# Combinaison de conditions
print("score >= 85 ET grade == 'B' :")
print(df2[(df2['score'] >= 85) & (df2['grade'] == 'B')])
Masque (score >= 85) :
10     True
20     True
30    False
40     True
Name: score, dtype: bool

Lignes avec score >= 85 :
      nom  score grade
10  Alice     85     B
20    Bob     92     A
40  David     88     B

score >= 85 ET grade == 'B' :
      nom  score grade
10  Alice     85     B
40  David     88     B

Note

Lors de la combinaison de conditions booléennes, il faut impérativement utiliser les opérateurs bit à bit & (ET), | (OU) et ~ (NON), et non les opérateurs logiques Python and, or, not. Ces derniers ne sont pas définis pour les Series et lèvent une exception. Chaque condition doit être entourée de parenthèses car la priorité de & est supérieure à celle de == en Python.

Slicing et accès rapide#

Au-delà de loc et iloc, Pandas offre des raccourcis d’accès utiles en pratique.

df.at[étiquette_ligne, étiquette_colonne] et df.iat[i, j] sont des équivalents scalaires de loc et iloc, optimisés pour l’accès à une seule valeur et environ deux fois plus rapides pour cet usage.

# Accès à une valeur scalaire
val = df2.at[20, 'score']   # étiquette → 92
val = df2.iat[1, 1]          # position  → 92

# Modifier une valeur
df2.at[20, 'grade'] = 'A+'

La méthode query() offre une syntaxe alternative plus lisible pour les sélections conditionnelles, en acceptant une chaîne de caractères décrivant la condition :

# Équivalent de df2[(df2['score'] >= 85) & (df2['grade'] == 'B')]
df2.query("score >= 85 and grade == 'B'")

# On peut référencer des variables Python avec @
seuil = 85
df2.query("score >= @seuil")

Types de données#

Chaque colonne d’un DataFrame a un type (dtype). La connaissance et la gestion des types est essentielle car ils impactent la mémoire utilisée, les opérations disponibles et les performances.

Types Pandas courants

Les types principaux sont :

  • int64, int32, int8 : entiers signés (la largeur influe sur la mémoire et la plage de valeurs)

  • float64, float32 : nombres à virgule flottante

  • object : type générique pour les chaînes de caractères ou les colonnes hétérogènes

  • bool : booléen

  • datetime64[ns] : horodatage nanoseconde

  • category : type catégoriel (valeurs prises dans un ensemble fini), économe en mémoire

  • Types étendus (depuis Pandas 1.0) : Int64, Float64, boolean, string — versions nullables des types de base

df3 = pd.DataFrame({
    'nom':      pd.array(['Alice', 'Bob', 'Carole'], dtype='string'),
    'âge':      pd.array([28, 34, None], dtype='Int64'),
    'note':     [15.5, 12.0, 18.0],
    'niveau':   pd.Categorical(['junior', 'senior', 'junior'],
                               categories=['junior', 'confirmé', 'senior'],
                               ordered=True),
    'date_rh':  pd.to_datetime(['2022-03-15', '2020-07-01', '2023-01-10']),
})

print(df3)
print()
print(df3.dtypes)
print()
print(f"Mémoire : {df3.memory_usage(deep=True).sum() / 1024:.1f} Ko")
      nom   âge  note  niveau    date_rh
0   Alice    28  15.5  junior 2022-03-15
1     Bob    34  12.0  senior 2020-07-01
2  Carole  <NA>  18.0  junior 2023-01-10

nom                string
âge                 Int64
note              float64
niveau           category
date_rh    datetime64[us]
dtype: object

Mémoire : 0.4 Ko

La conversion de types s’effectue avec astype() :

df['age'] = df['age'].astype('Int32')           # entier nullable
df['ville'] = df['ville'].astype('category')     # économise la mémoire
df['date'] = pd.to_datetime(df['date_str'])      # conversion en datetime
df['montant'] = pd.to_numeric(df['montant_str'], errors='coerce')  # coerce → NaN si invalide

Lecture et écriture de fichiers#

L’une des forces de Pandas est la richesse de ses fonctions d’entrée-sortie. Elle couvre les formats les plus courants du monde de la data.

CSV#

Le format CSV (Comma-Separated Values) est le format d’échange le plus universel. pd.read_csv() est probablement la fonction Pandas la plus utilisée dans les projets réels.

# Lecture basique
df = pd.read_csv('donnees.csv')

# Avec options avancées
df = pd.read_csv(
    'donnees.csv',
    sep=';',                      # séparateur (parfois ';' ou '\t')
    encoding='utf-8',             # encodage (ou 'latin-1' pour certains fichiers Windows)
    index_col='id',               # colonne à utiliser comme index
    usecols=['nom', 'age', 'score'],  # lire seulement certaines colonnes
    dtype={'age': 'Int32'},       # spécifier le type de certaines colonnes
    parse_dates=['date_naissance'], # parser automatiquement les colonnes date
    na_values=['N/A', 'MANQUANT', '-'],  # valeurs à traiter comme NaN
    nrows=1000,                   # lire seulement les 1000 premières lignes
)

# Sauvegarde
df.to_csv('resultat.csv', index=False, encoding='utf-8', sep=',')

Note

Le paramètre encoding est souvent source d’erreurs. Les fichiers produits par Excel en France sont souvent encodés en latin-1 ou cp1252 et non en utf-8. En cas d’erreur UnicodeDecodeError, essayer encoding='latin-1' ou encoding='cp1252'. La bibliothèque chardet (installable avec uv pip install chardet) peut détecter automatiquement l’encodage d’un fichier inconnu.

Excel#

# Lecture
df = pd.read_excel('rapport.xlsx', sheet_name='Données 2023',
                   header=1,          # ligne d'en-tête (0-indexé)
                   skiprows=[2, 3])   # sauter des lignes spécifiques

# Lire toutes les feuilles en une seule fois
sheets = pd.read_excel('rapport.xlsx', sheet_name=None)  # dict {nom: DataFrame}

# Sauvegarde
with pd.ExcelWriter('sortie.xlsx', engine='openpyxl') as writer:
    df_ventes.to_excel(writer, sheet_name='Ventes', index=False)
    df_charges.to_excel(writer, sheet_name='Charges', index=False)

JSON#

# Lecture depuis un fichier ou une URL
df = pd.read_json('data.json', orient='records')
# orient peut être : 'records', 'split', 'index', 'columns', 'values', 'table'

# Depuis une chaîne JSON
import json
json_str = '[{"nom": "Alice", "age": 28}, {"nom": "Bob", "age": 34}]'
df = pd.read_json(json_str)

# Sauvegarde
df.to_json('sortie.json', orient='records', force_ascii=False, indent=2)

Lecture d’une API REST JSON avec Pandas

Pandas peut lire directement des données issues d’une API HTTP via pd.read_json() ou en combinaison avec la bibliothèque requests :

import requests
import pandas as pd

# Appel à une API publique
response = requests.get('https://api.example.com/data?limit=100')
response.raise_for_status()  # lève une exception si erreur HTTP

# Normaliser un JSON imbriqué en DataFrame plat
from pandas import json_normalize
data = response.json()
df = json_normalize(data['results'],
                    record_path='items',
                    meta=['id', 'date_creation'])

La fonction json_normalize() est particulièrement utile pour aplatir des structures JSON imbriquées en un DataFrame tabulaire.

Formats hautes performances#

Pour les volumes importants de données, les formats binaires offrent des performances de lecture et d’écriture bien supérieures au CSV.

# Parquet — format colonne, compression efficace, recommandé en production
df.to_parquet('data.parquet', compression='snappy', index=False)
df = pd.read_parquet('data.parquet', columns=['nom', 'date', 'montant'])

# Feather — lecture/écriture très rapide, idéal pour les échanges intermédiaires
df.to_feather('data.feather')
df = pd.read_feather('data.feather')

Inspection d’un nouveau jeu de données#

Lorsqu’on charge un jeu de données inconnu, un enchaînement systématique de commandes d’inspection permet de comprendre rapidement sa structure, son volume et la qualité des données.

Hide code cell source

# Génération d'un jeu de données synthétique pour l'illustration
n = 200
np.random.seed(0)
villes = rng.choice(['Paris', 'Lyon', 'Marseille', 'Bordeaux', 'Lille'], n)
secteurs = rng.choice(['Tech', 'Finance', 'Santé', 'Industrie', np.nan], n,
                      p=[0.30, 0.25, 0.20, 0.15, 0.10])
df_demo = pd.DataFrame({
    'entreprise':  [f'Société_{i:04d}' for i in range(n)],
    'ville':       villes,
    'secteur':     secteurs,
    'ca_m€':       rng.exponential(scale=5.0, size=n).round(2),
    'effectif':    rng.integers(10, 5000, size=n),
    'fondation':   pd.to_datetime(rng.integers(1980, 2023, n).astype(str) + '-01-01'),
    'cotée':       rng.choice([True, False], n, p=[0.2, 0.8]),
})
# Introduire quelques valeurs manquantes
idx_na = rng.choice(n, 15, replace=False)
df_demo.loc[idx_na, 'ca_m€'] = np.nan

df_demo.head()
entreprise ville secteur ca_m€ effectif fondation cotée
0 Société_0000 Paris nan 5.48 3669 1992-01-01 False
1 Société_0001 Bordeaux Santé 0.96 2606 2021-01-01 False
2 Société_0002 Bordeaux Tech 5.83 1351 2006-01-01 False
3 Société_0003 Marseille nan 0.83 4001 2013-01-01 False
4 Société_0004 Marseille Industrie 4.95 1237 1999-01-01 False
# 1. Dimensions
print(f"Shape : {df_demo.shape[0]} lignes × {df_demo.shape[1]} colonnes")
print()

# 2. Aperçu des premières/dernières lignes
print("--- Premières lignes ---")
print(df_demo.head(3))
print()

# 3. Types et valeurs manquantes
print("--- info() ---")
df_demo.info()
Shape : 200 lignes × 7 colonnes

--- Premières lignes ---
     entreprise     ville secteur  ca_m€  effectif  fondation  cotée
0  Société_0000     Paris     nan   5.48      3669 1992-01-01  False
1  Société_0001  Bordeaux   Santé   0.96      2606 2021-01-01  False
2  Société_0002  Bordeaux    Tech   5.83      1351 2006-01-01  False

--- info() ---
<class 'pandas.DataFrame'>
RangeIndex: 200 entries, 0 to 199
Data columns (total 7 columns):
 #   Column      Non-Null Count  Dtype         
---  ------      --------------  -----         
 0   entreprise  200 non-null    str           
 1   ville       200 non-null    str           
 2   secteur     200 non-null    str           
 3   ca_m€       185 non-null    float64       
 4   effectif    200 non-null    int64         
 5   fondation   200 non-null    datetime64[us]
 6   cotée       200 non-null    bool          
dtypes: bool(1), datetime64[us](1), float64(1), int64(1), str(3)
memory usage: 14.8 KB
# 4. Statistiques descriptives — colonnes numériques
print("--- describe() — numériques ---")
print(df_demo.describe().round(2))
print()

# 5. Statistiques descriptives — colonnes de type object/catégoriel
print("--- describe() — object ---")
print(df_demo.describe(include='str'))
--- describe() — numériques ---
        ca_m€  effectif            fondation
count  185.00    200.00                  200
mean     5.37   2569.02  2001-10-30 19:19:12
min      0.04     46.00  1980-01-01 00:00:00
25%      1.47   1515.00  1992-01-01 00:00:00
50%      3.52   2563.00  2002-01-01 00:00:00
75%      7.09   3770.00  2013-01-01 00:00:00
max     30.85   4991.00  2022-01-01 00:00:00
std      5.47   1409.47                  NaN

--- describe() — object ---
          entreprise     ville secteur
count            200       200     200
unique           200         5       5
top     Société_0000  Bordeaux    Tech
freq               1        49      68
# 6. Valeurs manquantes
print("--- Valeurs manquantes par colonne ---")
print(df_demo.isna().sum().to_frame('nb_manquants').assign(
    pct=lambda d: (d['nb_manquants'] / len(df_demo) * 100).round(1)
))
print()

# 7. Cardinalité des colonnes catégorielles
print("--- Cardinalité ---")
for col in df_demo.select_dtypes(include='str').columns:
    print(f"  {col:15s} : {df_demo[col].nunique()} valeurs uniques")
--- Valeurs manquantes par colonne ---
            nb_manquants  pct
entreprise             0  0.0
ville                  0  0.0
secteur                0  0.0
ca_m€                 15  7.5
effectif               0  0.0
fondation              0  0.0
cotée                  0  0.0

--- Cardinalité ---
  entreprise      : 200 valeurs uniques
  ville           : 5 valeurs uniques
  secteur         : 5 valeurs uniques

Note

La méthode info() est souvent plus informative que describe() comme premier regard sur un jeu de données : elle affiche le nombre de valeurs non nulles par colonne (détecter les colonnes creuses), le type de chaque colonne (détecter les types incorrects — par exemple une colonne de dates lue comme object) et la consommation mémoire totale. C’est la première commande à exécuter systématiquement après avoir chargé un fichier inconnu.

Résumé#

Ce chapitre a établi les fondements de la manipulation de données avec Pandas :

  • La Series est un tableau unidimensionnel indexé : un vecteur de données avec des étiquettes. Elle supporte l’alignement automatique sur l’index lors des opérations arithmétiques.

  • Le DataFrame est la structure centrale de Pandas : un tableau bidimensionnel hétérogène avec un index de lignes et un index de colonnes, conceptuellement équivalent à une table de base de données.

  • L”indexation par loc (étiquettes) et iloc (positions entières) est la distinction fondamentale à maîtriser. La sélection conditionnelle par masque booléen ou query() est l’opération de filtrage standard.

  • La gestion des types (dtype) est essentielle pour la mémoire, les performances et la correction des données. astype(), pd.to_datetime() et pd.to_numeric() sont les outils de conversion courants.

  • Pandas lit et écrit les formats les plus courants : CSV (read_csv/to_csv), Excel (read_excel/to_excel), JSON (read_json/to_json), et les formats haute performance Parquet et Feather pour les volumes importants.

  • L”inspection initiale d’un jeu de données inconnu suit une séquence systématique : shape, head(), info(), describe(), comptage des valeurs manquantes et cardinalité des colonnes catégorielles.