---
jupytext:
  text_representation:
    extension: .md
    format_name: myst
    format_version: 0.13
    jupytext_version: 1.16.0
kernelspec:
  name: python3
  display_name: Python 3
---

# Pandas — Series et DataFrame

```{code-cell} python
:tags: [hide-input]

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

```{admonition} Series
:class: tip
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).
```

```{code-cell} python
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)
```

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.

```{code-cell} python
# 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)
```

## Le DataFrame

```{admonition} DataFrame
:class: tip
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

```{code-cell} python
# 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}")
```

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

```python
# 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.

```{admonition} loc et iloc
:class: tip
**`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.
```

```{code-cell} python
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])
```

### 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`.

```{code-cell} python
# 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)
```

### 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.

```{code-cell} python
# 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')])
```

```{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.

```python
# 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 :

```python
# É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.

```{admonition} Types Pandas courants
:class: tip
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
```

```{code-cell} python
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")
```

La conversion de types s'effectue avec `astype()` :

```python
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.

```python
# 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

```python
# 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

```python
# 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)
```

```{admonition} Lecture d'une API REST JSON avec Pandas
:class: note
Pandas peut lire directement des données issues d'une API HTTP via `pd.read_json()` ou en combinaison avec la bibliothèque `requests` :

```python
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.

```python
# 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.

```{code-cell} python
:tags: [hide-input]

# 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()
```

```{code-cell} python
# 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()
```

```{code-cell} python
# 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'))
```

```{code-cell} python
# 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")
```

```{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.
