M55 - Data Science & Scientific Computing 5
  1. Cours
  2. 5  Data Frames
  • 1  M55 - Data Science and Scientific Computing 5
  • Introduction
    • 2  Syllabus

  • Cours
    • 3  Introduction
    • 4  Series
    • 5  Data Frames
    • 6  🚢 Une analyse complète : le Titanic

  • Exercices
    • 7  Premières manipulations
    • 8  📊 Notes L3 Math
    • 9  🛒 Ventes et ☔ pluie
    • 10  🏘️ Population France
    • 11  🏭 Émissions de CO₂ des communes
    • 12  📺 Netflix User
    • 13  🗳️ Présidentielles USA 2016

  • Downloads
    • 14  Fichiers sources

Table des matières

  • 6 Création d’un DataFrame
    • 6.1 À partir d’un tableau NumPy à deux dimensions
    • 6.2 Colonne par colonne à partir …
      • 6.2.1 … d’une seule Series
      • 6.2.2 … à partir d’un dictionnaire de Series {label_1: serie_1, label_2: serie_2, etc} ou de listes de valeurs {label_1: [val_1, val_2, etc], label_2: [val_1, val_2, etc], etc}
    • 6.3 Ligne par ligne à partir …
      • 6.3.1 … d’une liste de Series [serie_1, serie_2, etc] ou d’une liste de dictionnaires [dico_1, dico_2, etc]
    • 6.4 À partir d’un fichier : read_csv
      • 6.4.1 Les options à connaître
      • 6.4.2 Un cas réel : l’espace insécable
      • 6.4.3 Exporter : to_csv
      • 6.4.4 Bonus : manipuler des dates
  • 7 Accés aux éléments
    • 7.1 Selectionner une colonne : df['nom_colonne'] ou df.nom_colonne
    • 7.2 Selectionner une ligne : loc et iloc
      • 7.2.1 Tableau comparatif entre numpy.ndarray et pandas.DataFrame
      • 7.2.2 Résumé des principales syntaxes d’accès aux éléments d’un DataFrame :
    • 7.3 Ajout de lignes/colonnes
      • 7.3.1 Ajout d’une colonne
      • 7.3.2 Ajout d’une ligne
    • 7.4 Réordonner les observations
    • 7.5 Suppression de lignes/colonnes
  • 8 Aperçu des données
    • 8.1 Afficher les premières/dernières lignes
    • 8.2 Attributs d’un DataFrame
    • 8.3 Les valeurs manquantes
      • 8.3.1 Repérer les valeurs manquantes
      • 8.3.2 Comment les calculs les traitent
      • 8.3.3 Supprimer : dropna
      • 8.3.4 Remplacer : fillna
    • 8.4 Statistiques descriptives
    • 8.5 Comptage : valeurs uniques, dénombrement de valeurs et appartenance
  • 9 Filtres et opérations
    • 9.1 Opérations sur les DataFrames
    • 9.2 Filtrer un DataFrame : masque booléen vs query()
      • 9.2.1 Filtre “à la NumPy” (masques booléens)
      • 9.2.2 Filtre avec query()
  • 10 Aggregations
    • 10.1 groupby : analyse par groupes
    • 10.2 pivot_table : tableaux croisés
    • 10.3 crosstab : tableaux croisés
    • 10.4 Bonus : melt, du format large au format long
    • 10.5 Arithmétique et alignement des données : différences avec Numpy
    • 10.6 L’indexation des DataFrames
      • 10.6.1 Bonus : set_index et reset_index
    • 10.7 Copie vs vue
      • 10.7.1 Pourquoi ? Ce qui se passe réellement
      • 10.7.2 Ce qui change avec le Copy-on-Write
  • 11 Combiner des tables
    • 11.1 Empiler des tables : pd.concat
    • 11.2 Joindre deux tables : pd.merge
      • 11.2.1 Les quatre types de jointure : l’argument how
      • 11.2.2 Quand les colonnes clés n’ont pas le même nom
      • 11.2.3 Vérifier une jointure
      • 11.2.4 join : la variante qui travaille sur l’index
      • 11.2.5 En résumé
  1. Cours
  2. 5  Data Frames

5  Introduction aux structures de données pandas : les DataFrames (tableaux bidimensionnels)

Auteur·rice

Gloria FACCANONI

Date de publication

30 octobre 2026

Commençons par importer la bibliothèque Pandas (et la bibliotèque NumPy) avec les alias classiques pd et np :

import pandas as pd
import numpy as np

# Pour l'affichage dans les notebooks
from IPython.display import display, Markdown

La deuxième structure fondamentale de Pandas est le DataFrame. Elle peut être considérée comme une généralisation d’une matrice NumPy, où les lignes et les colonnes sont identifiées par des étiquettes, ou encore comme un dictionnaire de Series partageant le même index.

Contrairement à une matrice, les colonnes peuvent être de types différents.

Un DataFrame est composé des éléments suivants :

  • l’indice de la ligne ;
  • le nom de la colonne ;
  • la valeur de la donnée ;

DataFrame Pandas
# ?pd.DataFrame

6 Création d’un DataFrame

On peut créer un DataFrame

  • à partir d’une matrice NumPy (en précisant éventuellement les noms des colonnes et des lignes)

  • à partir d’un fichier CSV, Excel, SQL, etc.

  • colonne par colonne à partir

    • d’une seule Series
    • d’un dictionnaire de Series ou d’un dictionnaire de listes (chaque élément du dictionnaire est une colonne)
  • ligne par ligne à partir

    • d’une liste de Series ou d’une liste de dictionnaires (chaque élément de la liste est une ligne)

6.1 À partir d’un tableau NumPy à deux dimensions

On peut aussi créer un DataFrame à partir d’un tableau NumPy à deux dimensions.

ARRAY = np.array([ [100,   50,  20, 100,  50], 
                   [1000, 300, 400,  50, 200], 
                   [10,    20,   5, 200,  25] ])

df3 = pd.DataFrame( data=ARRAY )
df3
0 1 2 3 4
0 100 50 20 100 50
1 1000 300 400 50 200
2 10 20 5 200 25

On peut spécifier les noms des colonnes et des lignes :

ARRAY = np.array([ [100,   50,  20, 100,  50], 
                   [1000, 300, 400,  50, 200], 
                   [10,    20,   5, 200,  25] ])
columns = ["Facebook", "Twitter", "Instagram", "Linkedin", "Snapchat"]
indices = ["Budget", "Audience", "CPM"]

df3 = pd.DataFrame( data=ARRAY, 
                    columns=columns, 
                    index=indices
                    )
df3
Facebook Twitter Instagram Linkedin Snapchat
Budget 100 50 20 100 50
Audience 1000 300 400 50 200
CPM 10 20 5 200 25
df4 = df3.T
df4 
Budget Audience CPM
Facebook 100 1000 10
Twitter 50 300 20
Instagram 20 400 5
Linkedin 100 50 200
Snapchat 50 200 25

6.2 Colonne par colonne à partir …

6.2.1 … d’une seule Series

population = pd.Series( data=[8.516, 67.06, 328.2, 1_386],           
                        index=["Suisse", "France", "USA", "Chine"] # les index seront les labels des lignes
                        )

display( population )
# df0 = pd.DataFrame( data=population )
df0 = pd.DataFrame( data=population, 
                    columns=["Population"] 
                    )
display(df0)
Suisse       8.516
France      67.060
USA        328.200
Chine     1386.000
dtype: float64
Population
Suisse 8.516
France 67.060
USA 328.200
Chine 1386.000
display(df0.values) # un array 2D numpy
display(df0.index) # un objet de type Index
display(df0.columns) # un objet de type Index
display(df0["Population"]) # une Series
display(df0["Population"]["Suisse"]) # DataFrame["colonne"]["ligne"]
array([[   8.516],
       [  67.06 ],
       [ 328.2  ],
       [1386.   ]])
Index(['Suisse', 'France', 'USA', 'Chine'], dtype='object')
Index(['Population'], dtype='object')
Suisse       8.516
France      67.060
USA        328.200
Chine     1386.000
Name: Population, dtype: float64
8.516

Si les éléments index et columns d’un DataFrame ont leurs attributs name définis, ceux-ci seront également affichés :

df0.index.name = "États"
df0
Population
États
Suisse 8.516
France 67.060
USA 328.200
Chine 1386.000
df0.columns.name = "Caractéristiques"
df0
Caractéristiques Population
États
Suisse 8.516
France 67.060
USA 328.200
Chine 1386.000
df0.T # transposée, comme en numpy
États Suisse France USA Chine
Caractéristiques
Population 8.516 67.06 328.2 1386.0

6.2.2 … à partir d’un dictionnaire de Series {label_1: serie_1, label_2: serie_2, etc} ou de listes de valeurs {label_1: [val_1, val_2, etc], label_2: [val_1, val_2, etc], etc}

Chaque Series devient une colonne du DataFrame.

Les clés du dictionnaire sont les noms des colonnes et les valeurs associées sont les Series ou listes de valeurs pour chaque colonne. 
Les index peuvent être passés ou générés automatiquement.
Depuis Python 3.7, les dictionnaires conservent l’ordre d’insertion des clés : les colonnes du DataFrame apparaissent donc dans l’ordre où elles ont été écrites dans le dictionnaire.

population = pd.Series([8.516, 67.06, 328.2, 1_386],            index=["Suisse", "France", "USA", "Chine"])
area       = pd.Series([41_285, 551_695, 3_796_742, 9_596_961], index=["Suisse", "France", "USA", "Chine"])

df1 = pd.DataFrame( { "Population" : population, 
                      "Area" : area } )
df1
Population Area
Suisse 8.516 41285
France 67.060 551695
USA 328.200 3796742
Chine 1386.000 9596961
dico = { "RS"      : ["Facebook", "Twitter", "Instagram", "Linkedin", "Snapchat"],
         "Budget"  : [100, 50, 20, 100, 50],
         "Audience": [1000, 300, 400, 50, 200],
         "CPM"     : [10, 20, 5, 200, 25] }
df1bis = pd.DataFrame(dico)
df1bis
RS Budget Audience CPM
0 Facebook 100 1000 10
1 Twitter 50 300 20
2 Instagram 20 400 5
3 Linkedin 100 50 200
4 Snapchat 50 200 25
display(df1bis["RS"][2]) # dangereux : est-ce un label ou une position ?

# solution : utiliser .loc pour les labels ou .iloc pour les positions, on retrouve
# l'ordre habituel de numpy
# display(df1bis.loc[2, "RS"])
# display(df1bis.iloc[2, 0])
'Instagram'

6.3 Ligne par ligne à partir …

6.3.1 … d’une liste de Series [serie_1, serie_2, etc] ou d’une liste de dictionnaires [dico_1, dico_2, etc]

Chaque Series/Dictionnaire devient une ligne du DataFrame.

population = pd.Series(data=[8.516, 67.06, 328.2, 1_386],            index=["Suisse", "France", "USA", "Chine"])
area       = pd.Series(data=[41_285, 551_695, 3_796_742, 9_596_961], index=["Suisse", "France", "USA", "Chine"])
df1 = pd.DataFrame( data=[population, area], 
                    index=["Population", "Area"])
df1
Suisse France USA Chine
Population 8.516 67.06 328.2 1386.0
Area 41285.000 551695.00 3796742.0 9596961.0
population = {"Suisse":8.516, "France":67.06, "USA":328.2, "Chine":1_386}
area       = {"Suisse":41_285, "France":551_695, "USA":3_796_742, "Chine":9_596_961}
df2 = pd.DataFrame( data=[population, area], 
                    index=["Population", "Area"])
df2
Suisse France USA Chine
Population 8.516 67.06 328.2 1386
Area 41285.000 551695.00 3796742.0 9596961

Si les deux dictionnaires ne contiennent pas les mêmes clés, les valeurs manquantes sont remplacées par NaN :

population = {"Suisse":8.516, "France":67.06, "USA":328.2}
area       = {"Suisse":41_285, "France":551_695, "Chine":9_596_961}
df2bis = pd.DataFrame( data=[population, area], 
                       index=["Population", "Area"])
df2bis
Suisse France USA Chine
Population 8.516 67.06 328.2 NaN
Area 41285.000 551695.00 NaN 9596961.0
display(df2bis.values) # tableau numpy à 2 dimensions
display(df2bis.index) # objet de type Index
display(df2bis.columns) # objet de type Index
display(df2bis["France"]) # DataFrame["colonne"] renvoie une Series !!!! NB
display(df2bis["France"]["Population"]) # DataFrame["colonne"]["ligne"] !!! NB l'ordre
array([[8.516000e+00, 6.706000e+01, 3.282000e+02,          nan],
       [4.128500e+04, 5.516950e+05,          nan, 9.596961e+06]])
Index(['Population', 'Area'], dtype='object')
Index(['Suisse', 'France', 'USA', 'Chine'], dtype='object')
Population        67.06
Area          551695.00
Name: France, dtype: float64
67.06
df2bis.T # transposée, comme en numpy
Population Area
Suisse 8.516 41285.0
France 67.060 551695.0
USA 328.200 NaN
Chine NaN 9596961.0

6.4 À partir d’un fichier : read_csv

Dans la pratique, on construit rarement un DataFrame à la main : on le lit depuis un fichier. Pandas sait lire de très nombreux formats : read_excel, read_json, read_sql, read_parquet, read_html… : et chacun a sa fonction d’écriture correspondante (to_excel, to_json, etc.). Nous nous limiterons au format le plus répandu, le CSV.

Un fichier CSV (Comma Separated Values) est un simple fichier texte : une ligne par observation, des champs séparés par un caractère convenu. C’est à la fois son grand avantage : n’importe quel tableur, n’importe quel langage sait le lire : et sa grande faiblesse : rien n’y est normalisé, ni le séparateur, ni le marqueur décimal, ni l’encodage. Lire un CSV correctement, c’est donc d’abord regarder à quoi il ressemble.

Fabriquons-en un pour l’exemple :

contenu = """ville;population;superficie;recensement
Toulon;176 198;42,84;2021-01-01
Marseille;873 076;240,62;2021-01-01
Nice;342 669;71,92;2021-01-01
Hyères;57 635;132,38;
La Seyne-sur-Mer;65 691;22,02;2021-01-01
"""

with open("villes_demo.csv", "w", encoding="utf-8") as f:
    f.write(contenu)

display(contenu)
'ville;population;superficie;recensement\nToulon;176 198;42,84;2021-01-01\nMarseille;873 076;240,62;2021-01-01\nNice;342 669;71,92;2021-01-01\nHyères;57 635;132,38;\nLa Seyne-sur-Mer;65 691;22,02;2021-01-01\n'

Ce petit fichier cumule les habitudes françaises : séparateur ;, virgule décimale, espace comme séparateur de milliers, et une date manquante. Voyons ce que donne une lecture naïve.

pd.read_csv("villes_demo.csv")
ville;population;superficie;recensement
Toulon;176 198;42 84;2021-01-01
Marseille;873 076;240 62;2021-01-01
Nice;342 669;71 92;2021-01-01
Hyères;57 635;132 38;
La Seyne-sur-Mer;65 691;22 02;2021-01-01

Une seule colonne ! C’est normal : read_csv attend par défaut la virgule comme séparateur. On le corrige avec sep :

villes_csv = pd.read_csv("villes_demo.csv", sep=";")

display(villes_csv)
display(villes_csv.dtypes)
ville population superficie recensement
0 Toulon 176 198 42,84 2021-01-01
1 Marseille 873 076 240,62 2021-01-01
2 Nice 342 669 71,92 2021-01-01
3 Hyères 57 635 132,38 NaN
4 La Seyne-sur-Mer 65 691 22,02 2021-01-01
ville          object
population     object
superficie     object
recensement    object
dtype: object

Le tableau a la bonne forme, mais regardez les types : population et superficie sont de type object, c’est-à-dire du texte. Impossible d’en calculer la moyenne. Deux raisons : l’espace des milliers et la virgule décimale, que pandas ne devine pas. On les déclare :

villes_csv = pd.read_csv("villes_demo.csv", sep=";", thousands=" ", decimal=",")

display(villes_csv)
display(villes_csv.dtypes)
ville population superficie recensement
0 Toulon 176198 42.84 2021-01-01
1 Marseille 873076 240.62 2021-01-01
2 Nice 342669 71.92 2021-01-01
3 Hyères 57635 132.38 NaN
4 La Seyne-sur-Mer 65691 22.02 2021-01-01
ville           object
population       int64
superficie     float64
recensement     object
dtype: object

Il reste la colonne recensement, toujours lue comme du texte. parse_dates la convertit en véritables dates : et la valeur absente devient NaT (Not a Time), l’équivalent de NaN pour les dates :

villes_csv = pd.read_csv("villes_demo.csv", sep=";", thousands=" ", decimal=",",
                         parse_dates=["recensement"])

display(villes_csv)
display(villes_csv.dtypes)
ville population superficie recensement
0 Toulon 176198 42.84 2021-01-01
1 Marseille 873076 240.62 2021-01-01
2 Nice 342669 71.92 2021-01-01
3 Hyères 57635 132.38 NaT
4 La Seyne-sur-Mer 65691 22.02 2021-01-01
ville                  object
population              int64
superficie            float64
recensement    datetime64[ns]
dtype: object

Enfin, index_col désigne directement la colonne qui servira d’index :

villes_csv = pd.read_csv("villes_demo.csv", sep=";", thousands=" ", decimal=",",
                         parse_dates=["recensement"], index_col="ville")
villes_csv
population superficie recensement
ville
Toulon 176198 42.84 2021-01-01
Marseille 873076 240.62 2021-01-01
Nice 342669 71.92 2021-01-01
Hyères 57635 132.38 NaT
La Seyne-sur-Mer 65691 22.02 2021-01-01

6.4.1 Les options à connaître

Option Rôle
sep caractère séparant les champs (, par défaut, souvent ; en France)
decimal marqueur décimal (. par défaut, , en France)
thousands séparateur de milliers à supprimer
encoding "utf-8" par défaut ; essayez "latin-1" sur un vieux fichier Windows
index_col colonne à utiliser comme index
usecols liste des colonnes à charger : précieux sur un gros fichier
na_values valeurs à interpréter comme manquantes ("N/A", "-", "inconnu"…)
parse_dates colonnes à convertir en dates
dtype forcer le type d’une ou plusieurs colonnes
nrows ne lire que les premières lignes, pour inspecter un gros fichier
skiprows ignorer des lignes d’en-tête parasites
header numéro de la ligne d’en-tête, ou None s’il n’y en a pas

Le réflexe à prendre. Ouvrez d’abord le fichier dans un éditeur de texte et regardez ses premières lignes ; écrivez l’appel à read_csv ensuite. Puis vérifiez systématiquement .dtypes : une colonne numérique lue comme object est de loin le symptôme le plus fréquent d’un mauvais paramétrage, et toutes vos statistiques en dépendent.

6.4.2 Un cas réel : l’espace insécable

Les exports de l’INSEE séparent les milliers par un espace insécable, un caractère invisible qui ressemble à une espace mais n’en est pas une (son code Unicode est 160). Vous le rencontrerez dans l’exercice sur la population des communes.

NBSP = chr(160)   # l'espace insécable

with open("insee_demo.csv", "w", encoding="utf-8") as f:
    f.write(f"commune;population\nToulon;176{NBSP}198\nNice;342{NBSP}669\n")

brut = pd.read_csv("insee_demo.csv", sep=";")

display(brut.dtypes)
display(Markdown("valeur brute : `" + repr(brut["population"][0]) + "`"))   # le \xa0 est bien là
commune       object
population    object
dtype: object

valeur brute : '176\xa0198'

# thousands ne connaît que l'espace ORDINAIRE : il ne règle pas le problème
display(pd.read_csv("insee_demo.csv", sep=";", thousands=" ").dtypes)

# il faut nettoyer la colonne à la main, puis convertir le type
propre = pd.read_csv("insee_demo.csv", sep=";")
propre["population"] = propre["population"].str.replace(chr(160), "", regex=False).astype(int)

display(propre.dtypes)
display(propre)
commune       object
population    object
dtype: object
commune       object
population     int64
dtype: object
commune population
0 Toulon 176198
1 Nice 342669

6.4.3 Exporter : to_csv

L’écriture suit la même logique, avec les mêmes options :

villes_csv.to_csv("villes_export.csv", sep=";", decimal=",", encoding="utf-8")

display(Markdown("```\n" + open("villes_export.csv", encoding="utf-8").read() + "\n```"))
ville;population;superficie;recensement
Toulon;176198;42,84;2021-01-01
Marseille;873076;240,62;2021-01-01
Nice;342669;71,92;2021-01-01
Hyères;57635;132,38;
La Seyne-sur-Mer;65691;22,02;2021-01-01

Par défaut l’index est écrit dans le fichier. Si c’est une simple numérotation sans signification, on l’omet avec index=False :

villes_csv.reset_index().to_csv("villes_export.csv", sep=";", decimal=",", index=False)

display(Markdown("```\n" + open("villes_export.csv", encoding="utf-8").read() + "\n```"))
ville;population;superficie;recensement
Toulon;176198;42,84;2021-01-01
Marseille;873076;240,62;2021-01-01
Nice;342669;71,92;2021-01-01
Hyères;57635;132,38;
La Seyne-sur-Mer;65691;22,02;2021-01-01

6.4.4 Bonus : manipuler des dates

Bonus : hors séance. Les dates sont omniprésentes dans les jeux de données ouverts ; cette section vous servira dès que vous en rencontrerez.

parse_dates convertit les dates au moment de la lecture. Quand la colonne a déjà été chargée comme du texte, pd.to_datetime fait la conversion après coup.

Une fois la colonne de type datetime64, l’accesseur .dt donne accès à l’année, au mois, au jour de la semaine : exactement comme .str donne accès aux méthodes de chaînes.

inscriptions = pd.Series(["2021-01-15", "2021-06-30", "2022-03-08"])
display(inscriptions.dtype)      # object : c'est du texte

inscriptions = pd.to_datetime(inscriptions)
display(inscriptions.dtype)      # datetime64[ns]
display(inscriptions)
dtype('O')
dtype('<M8[ns]')
0   2021-01-15
1   2021-06-30
2   2022-03-08
dtype: datetime64[ns]
display(inscriptions.dt.year)
display(inscriptions.dt.month)
display(inscriptions.dt.day_name())
0    2021
1    2021
2    2022
dtype: int32
0    1
1    6
2    3
dtype: int32
0       Friday
1    Wednesday
2      Tuesday
dtype: object

Si le format n’est pas reconnu automatiquement : typiquement le format français jj/mm/aaaa : on le précise avec format :

pd.to_datetime(pd.Series(["15/01/2021", "30/06/2021"]), format="%d/%m/%Y")
0   2021-01-15
1   2021-06-30
dtype: datetime64[ns]

On peut alors filtrer et regrouper sur le temps :

df_dates = pd.DataFrame({ "date"    : inscriptions,
                          "montant" : [10, 25, 40] })

display(df_dates[ df_dates["date"] >= "2021-06-01" ])
display(df_dates.groupby(df_dates["date"].dt.year)["montant"].sum())
date montant
1 2021-06-30 25
2 2022-03-08 40
date
2021    35
2022    40
Name: montant, dtype: int64

7 Accés aux éléments

population = pd.Series([8.516, 67.06, 328.2, 1_386],            index=["Suisse", "France", "USA", "Chine"])
area       = pd.Series([41_285, 551_695, 3_796_742, 9_596_961], index=["Suisse", "France", "USA", "Chine"])

df1 = pd.DataFrame( { "Population" : population, 
                      "Area" : area } )
display(df1) 
Population Area
Suisse 8.516 41285
France 67.060 551695
USA 328.200 3796742
Chine 1386.000 9596961
display(df1.values)   # tableau numpy à 2 dimensions
display(df1.index)    # objet de type Index
display(df1.columns)  # objet de type Index
array([[8.516000e+00, 4.128500e+04],
       [6.706000e+01, 5.516950e+05],
       [3.282000e+02, 3.796742e+06],
       [1.386000e+03, 9.596961e+06]])
Index(['Suisse', 'France', 'USA', 'Chine'], dtype='object')
Index(['Population', 'Area'], dtype='object')

7.1 Selectionner une colonne : df['nom_colonne'] ou df.nom_colonne

Pour séléctionner la colonne col1 d’un DataFrame on peut utiliser df.col1, mais on préfèrera généralement df["col1"]. En effet, la première syntaxe ne fonctionne pas si le nom de la colonne contient des espaces ou des caractères spéciaux, ou si le nom de la colonne est identique à une méthode ou un attribut du DataFrame.
Le résultat est une Series.

# La colonne "Population" : c'est une Series
display(df1["Population"])

# Les colonnes "Population" et "Area" : c'est un DataFrame
display(df1[["Population", "Area"]])
Suisse       8.516
France      67.060
USA        328.200
Chine     1386.000
Name: Population, dtype: float64
Population Area
Suisse 8.516 41285
France 67.060 551695
USA 328.200 3796742
Chine 1386.000 9596961

Attention :

  • dans un tableau numpy à 2 dimensions, A[0] renvoi la première ligne du tableau A
  • pour un objet DataFrame, l’écriture A[0] renvoi la première colonne.

C’est pourquoi il vaut mieux considérer un objet Dataframe comme un dictionnaire dont les clés sont les labels des colonnes plutôt que comme un tableau NumPy.

Une fois une colonne selectionnée (c’est une série), on peut selecionner un élément :

# La valeur de la cellule "Population" de la ligne "France"
col1 = "Population"
row1 = "France"
num_row1 = 1

display( df1[col1][row1] )
# display( df1[col1][num_row1] ) # deprecated
display( df1[col1].loc[row1] )
display( df1[col1].iloc[num_row1] )
67.06
67.06
67.06

Puisque les colonnes sont des Series, on peut utiliser les méthodes de Series sur les colonnes d’un DataFrame. En particulier, tout ce que l’on a dit sur le slicing des Series est valable pour les colonnes d’un DataFrame.

#  SLICING
# =========

# Slicing sur les lignes: les valeurs de la colonne "Population" des lignes de "France" à "USA" inclus
display(df1["Population"]["France":"USA"])

# La colonne "Population", toutes les lignes
display(df1["Population"][:]) # idem que df1["Population"]

# Les valeurs de la ligne France
# ERROR : on ne peut pas extraire une ligne avec [:] !!!
# display(df1[:]["France"]) 
France     67.06
USA       328.20
Name: Population, dtype: float64
Suisse       8.516
France      67.060
USA        328.200
Chine     1386.000
Name: Population, dtype: float64

7.2 Selectionner une ligne : loc et iloc

Si on veut selectionner une ligne, on utilise

  • soit la méthode .loc[label] qui utilise les labels et est la méthode recommandée ;
  • soit la méthode .iloc[indice] qui utilise les indices de position (de \(0\) à \(N\) où \(N\) est égal à df.shape[0]) et qui est deconsillée car les indices peuvent changer si on modifie le DataFrame.

Dans les deux cas, on obtient une Series dont les index sont les noms des colonnes. Ensuite, on pourra utiliser les méthodes de Series pour accéder aux éléments de la ligne.

NB Avec loc ou iloc, on peut accéder à un élément d’un DataFrame avec la syntaxe df.loc[label_ligne, nom_colonne] ou df.iloc[indice_ligne, indice_colonne] et retrouver ainsi l’ordre d’indexation des lignes et des colonnes de numpy : .iloc[i,j] renvoi l’élément de la ligne i et de la colonne j.

df1
Population Area
Suisse 8.516 41285
France 67.060 551695
USA 328.200 3796742
Chine 1386.000 9596961
# Selectionner avec .loc (label de ligne)
# ========================================================

# Une ligne
display(df1.loc["France"]) # la ligne "France"

# Une cellule
display(df1.loc["France", "Population"]) # la valeur de la cellule "Population" de la ligne "France"

# Un slicing de lignes, une colonne
display(df1.loc["France":"USA", "Population"]) # les valeurs de la colonne "Population" des lignes de "France" à "USA" inclus

# Un slicing de lignes, plusieurs colonnes
display(df1.loc["France":"USA", ["Population", "Area"]]) # les valeurs des colonnes "Population" et "Area" des lignes de "France" à "USA" inclus

# Toutes les valeurs d'une colonne
display(df1.loc[:, "Population"]) # toutes les valeurs de la colonne "Population", idem que df1["Population"]
display(df1.iloc[:, 0]) # toutes les valeurs de la colonne "Population", idem que df1["Population"]
Population        67.06
Area          551695.00
Name: France, dtype: float64
67.06
France     67.06
USA       328.20
Name: Population, dtype: float64
Population Area
France 67.06 551695
USA 328.20 3796742
Suisse       8.516
France      67.060
USA        328.200
Chine     1386.000
Name: Population, dtype: float64
Suisse       8.516
France      67.060
USA        328.200
Chine     1386.000
Name: Population, dtype: float64
# Selectionner avec .iloc (indices de position)
# ========================================================

# Une ligne
display(df1.iloc[1])   # la ligne d'indice 1, toutes les colonnes
display(df1.iloc[1,:]) # la ligne d'indice 1, toutes les colonnes

# Une cellule
display(df1.iloc[1, 0]) # la valeur de la cellule d'indice 1, 0

# Un slicing de lignes, une colonne
display(df1.iloc[1:3, 0]) # les valeurs de la colonne "Population" des lignes d'indice 1 à 3 exclus

# Un slicing de lignes, plusieurs colonnes
display(df1.iloc[1:3, [0, 1]]) # les valeurs des colonnes "Population" et "Area" des lignes d'indice 1 à 3 exclus

# Toutes les valeurs d'une colonne
display(df1.iloc[:, 0]) # toutes les valeurs de la colonne "Population"  idem que df1["Population"]
Population        67.06
Area          551695.00
Name: France, dtype: float64
Population        67.06
Area          551695.00
Name: France, dtype: float64
67.06
France     67.06
USA       328.20
Name: Population, dtype: float64
Population Area
France 67.06 551695
USA 328.20 3796742
Suisse       8.516
France      67.060
USA        328.200
Chine     1386.000
Name: Population, dtype: float64

On peut alors utiliser les masques de numpy etc.

display(df1)

# les lignes dont la Population est supérieure à 100, les colonnes "Population" et "Area"
# df1.loc[ df1["Population"] > 100, ["Population", "Area"] ] 
mask = df1["Population"] > 100
display(mask) # une Series de booléens
df1.loc[ mask, : ] 
Population Area
Suisse 8.516 41285
France 67.060 551695
USA 328.200 3796742
Chine 1386.000 9596961
Suisse    False
France    False
USA        True
Chine      True
Name: Population, dtype: bool
Population Area
USA 328.2 3796742
Chine 1386.0 9596961

7.2.1 Tableau comparatif entre numpy.ndarray et pandas.DataFrame

Aspect numpy.ndarray pandas.DataFrame
Nature Tableau N-D homogène Tableau 2D étiqueté (lignes + colonnes)
Types des données ✔️ Un seul type par array ✔️ Un type par colonne
Ordonnancement ✔️ Ordonné ✔️ Ordonné (index + colonnes)
Labels ❌ Aucun ✔️ Noms de colonnes + index
Accès positionnel ✔️ arr[i, j] ✔️ df.iloc[i, j]
Accès par label ❌ ✔️ df.loc[row_label, col_label] ou df.at[row_label, col_label]
Accès par colonne ❌ ✔️ df["nom_colonne"] (retourne une Series)
Accès par ligne ❌ ✔️ df.iloc[i] (par position), df.loc[row_label] (par label)
Slicing lignes ✔️ arr[1:4] ✔️ df.iloc[1:4] ou df.loc["y":"z"]
Slicing colonnes ✔️ arr[:, 1:3] ✔️ df[["col1","col2"]] ou df.loc[:, "col1":"col3"]
Vectorisation ✔️ Très rapide ✔️ Repose sur NumPy

7.2.2 Résumé des principales syntaxes d’accès aux éléments d’un DataFrame :

Syntaxe Description
df[eti_col] Sélectionne une colonne ou une séquence de colonnes. Peut aussi servir pour filtres booléens ou slices.
df.loc[eti_ligne] Sélectionne une ou plusieurs lignes par leur étiquette de ligne (eti_ligne).
df.loc[:, eti_col] Sélectionne une ou plusieurs colonnes par leurs étiquettes de colonnes (eti_col).
df.loc[eti_ligne, eti_col] Sélectionne lignes (eti_ligne) et colonnes (eti_col) par leurs étiquettes.
df.iloc[pos] Sélectionne une ou plusieurs lignes par leur position entière (pos).
df.iloc[:, pos] Sélectionne une ou plusieurs colonnes par leur position entière (pos).
df.iloc[pos_i, pos_j] Sélectionne lignes (pos_i) et colonnes (pos_j) par leurs positions entières.
df.at[eti_ligne, eti_col] Accède à une valeur scalaire unique par étiquette de ligne (eti_ligne) et de colonne (eti_col).
df.iat[i, j] Accède à une valeur scalaire unique par position entière de ligne (i) et de colonne (j).
Méthode reindex Réorganise ou sélectionne lignes/colonnes par leurs étiquettes (eti_ligne et/ou eti_col).
Méthodes get_value / set_value Accède ou modifie une valeur unique par étiquette (obsolètes, utilisez at/iat).

7.3 Ajout de lignes/colonnes

7.3.1 Ajout d’une colonne

On ajoute une colonne à un DataFrame en utilisant la notation df['nom_colonne'] = serie, eventuellement en utilisant une colonne existante pour calculer la nouvelle colonne.

df1 = pd.DataFrame( { "Population" : population, 
                      "Area" : area } )
df1
Population Area
Suisse 8.516 41285
France 67.060 551695
USA 328.200 3796742
Chine 1386.000 9596961
df1["Density"] = df1["Population"] / df1["Area"]
df1
Population Area Density
Suisse 8.516 41285 0.000206
France 67.060 551695 0.000122
USA 328.200 3796742 0.000086
Chine 1386.000 9596961 0.000144

Les colonnes sont toujours ajoutées à la fin du DataFrame. Si on veut spécifier une position (et décaler les autres colonnes) on utilise la méthode .insert() :

# ajout d'une colonne à une position donnée
df1.insert( 0, "DensityBIS", df1["Population"] / df1["Area"] )
df1
DensityBIS Population Area Density
Suisse 0.000206 8.516 41285 0.000206
France 0.000122 67.060 551695 0.000122
USA 0.000086 328.200 3796742 0.000086
Chine 0.000144 1386.000 9596961 0.000144

7.3.2 Ajout d’une ligne

df1 = pd.DataFrame( { "Population" : population, 
                      "Area" : area } )
df1
Population Area
Suisse 8.516 41285
France 67.060 551695
USA 328.200 3796742
Chine 1386.000 9596961

Pour ajouter une ligne à un DataFrame, on utilise la méthode .loc[label] = serie/liste/dict :

df1.loc["Italie"] = [60.36, 301338]
df1
Population Area
Suisse 8.516 41285.0
France 67.060 551695.0
USA 328.200 3796742.0
Chine 1386.000 9596961.0
Italie 60.360 301338.0

7.4 Réordonner les observations

La méthode sort_values permet de réordonner les observations d’un DataFrame, en laissant l’ordre des colonnes identiques. On peut trier selon une ou plusieurs colonnes, dans l’ordre croissant ou décroissant.

df1_sorted_pop = df1.sort_values(by="Population")
df1_sorted_pop
Population Area
Suisse 8.516 41285.0
Italie 60.360 301338.0
France 67.060 551695.0
USA 328.200 3796742.0
Chine 1386.000 9596961.0
df1_sorted_area = df1.sort_values(by="Area", ascending=False)
df1_sorted_area
Population Area
Chine 1386.000 9596961.0
USA 328.200 3796742.0
France 67.060 551695.0
Italie 60.360 301338.0
Suisse 8.516 41285.0

7.5 Suppression de lignes/colonnes

Pour supprimer une ligne ou une colonne on utilise la méthode .drop() :

  • df.drop( nom_ligne, axis=0) ou df.drop(labels = nom_ligne) pour supprimer une ligne
  • df.drop( nom_colonne, axis=1) ou df.drop( columns = nom_colonne) pour supprimer une colonne

On ajoute l’option inplace=True pour modifier le DataFrame, inplace=False pour renvoyer un nouveau DataFrame sans modifier l’original.

Attention, si la la ligne ou la colonne n’existe pas, une erreur sera levée. Pour éviter cela, on peut utiliser l’option errors='ignore'.

# Création du DataFrame

df1 = pd.DataFrame( { "Population" : population, 
                      "Area" : area } )
df1.loc["Italie"] = [ 60.36, 301338]

df1["Density"] = df1["Population"] / df1["Area"]
df1.insert( 0, "DensityBIS", df1["Population"] / df1["Area"] )

df1
DensityBIS Population Area Density
Suisse 0.000206 8.516 41285.0 0.000206
France 0.000122 67.060 551695.0 0.000122
USA 0.000086 328.200 3796742.0 0.000086
Chine 0.000144 1386.000 9596961.0 0.000144
Italie 0.000200 60.360 301338.0 0.000200
# Supprimoons une colonne

df1.drop( columns="DensityBIS", inplace=True ) # suppression d'une colonne
# del df1["Density"]  # autre méthode pour supprimer une colonne

df1
Population Area Density
Suisse 8.516 41285.0 0.000206
France 67.060 551695.0 0.000122
USA 328.200 3796742.0 0.000086
Chine 1386.000 9596961.0 0.000144
Italie 60.360 301338.0 0.000200
# Supprimons une ligne

df1.drop(labels=["Chine"], inplace=True) # suppression d'une ligne
display(df1)
Population Area Density
Suisse 8.516 41285.0 0.000206
France 67.060 551695.0 0.000122
USA 328.200 3796742.0 0.000086
Italie 60.360 301338.0 0.000200

8 Aperçu des données

Créons d’abord un DataFrame un peu plus richement rempli (il pourrait tout aussi bien provenir d’un fichier CSV, comme vu plus haut) :

data = pd.DataFrame( {  "foo" : ["one", "one", "one", "two", "two", "two"],
                        "bar" : ["A", "A", "B", "A", "B", "B"],
                        "baz" : [1, 2, 3, 4, 5, 6], 
                        "zoo" : ["x", "y", "z", "q", "w", 't'] } )

data
foo bar baz zoo
0 one A 1 x
1 one A 2 y
2 one B 3 z
3 two A 4 q
4 two B 5 w
5 two B 6 t

8.1 Afficher les premières/dernières lignes

Un bon réflexe à adopter après la création/l’importation d’un DataFrale, et après toute transformation importante, est de visualiser le jeu de données, ou du moins quelques lignes, afin de vérifier que tout s’est correctement déroulé. Pour cela, il existe deux méthodes principales :

  • la méthode .head() permettant de sélectionner par défaut les 5 premières lignes du data frame. Il est possible de préciser entre parenthèses le nombre de lignes à afficher ;
  • la méthode .tail() permettant de sélectionner par défaut les 5 dernières lignes du data frame. Il est également possible de préciser entre parenthèses le nombre de lignes à afficher.

Il n’est pas possible (par défaut) d’afficher plus de 60 lignes d’un data frame, afin de ne pas surcharger inutilement le notebook. De façon plus globale, chercher à visualiser l’ensemble d’un data frame n’est généralement pas une bonne pratique. Si cela est tout à fait envisageable avec quelques dizaines de lignes, ça devient vite impossible avec plusieurs millions de lignes ! Si vous cherchez à afficher plus de 60 lignes, vous aurez finalement comme résultat les 5 premières et dernières lignes du data frame.

# afficher les 5 premières lignes
display(data.head())
foo bar baz zoo
0 one A 1 x
1 one A 2 y
2 one B 3 z
3 two A 4 q
4 two B 5 w
# afficher les 2 dernières lignes
display(data.tail(2))
foo bar baz zoo
4 two B 5 w
5 two B 6 t

8.2 Attributs d’un DataFrame

Combien de lignes comporte le DataFrame ? Et combien de colonnes ? Tout comme avec les arrays NumPy, il est possible de répondre à ces questions via l’attribut .shape. Le résultat est un tuple : le premier élément correspond au nombre de lignes, et le second au nombre de colonnes. On peut naturellement stocker le résultat de cet attribut dans une variable pour réutiliser ces éléments ultérieurement.

display(Markdown("**`ndim` : nombre de dimensions (2 pour un DataFrame)**"))
display( data.ndim)

display(Markdown("**`shape` : (nombre de lignes, nombre de colonnes)**"))
display( data.shape )

display(Markdown("**`size` : nombre total d'éléments (lignes * colonnes)**"))
display( data.size )

display(Markdown("**`axes` : liste des axes (index des lignes, index des colonnes)**"))
display( data.axes)

display(Markdown("**`index`, `columns`, `values` : accès aux composants du DataFrame**"))
display( data.index ) 
display( data.columns ) 
display( data.values ) # tableau numpy 2D des valeurs

ndim : nombre de dimensions (2 pour un DataFrame)

2

shape : (nombre de lignes, nombre de colonnes)

(6, 4)

size : nombre total d’éléments (lignes * colonnes)

24

axes : liste des axes (index des lignes, index des colonnes)

[RangeIndex(start=0, stop=6, step=1),
 Index(['foo', 'bar', 'baz', 'zoo'], dtype='object')]

index, columns, values : accès aux composants du DataFrame

RangeIndex(start=0, stop=6, step=1)
Index(['foo', 'bar', 'baz', 'zoo'], dtype='object')
array([['one', 'A', 1, 'x'],
       ['one', 'A', 2, 'y'],
       ['one', 'B', 3, 'z'],
       ['two', 'A', 4, 'q'],
       ['two', 'B', 5, 'w'],
       ['two', 'B', 6, 't']], dtype=object)

On peut avoir envie de connaître les types de chacune de nos variables (un type par colonne). On peut accéder à cela très simplement à partir de l’attribut .dtypes. On obtient un objet Series :les index de cette série sont les étiquettes des colonnes, et les valeurs de cette série sont les types de données. Vous noterez que le type de foo est objet, alors que nous avons pourtant des chaînes de caractères. C’est une chose à connaître, mais le type objet de Pandas correspond en fait à une colonne de type chaîne de caractères.

data.dtypes
foo    object
bar    object
baz     int64
zoo    object
dtype: object

Les jeux de données réels sont rarement complets. Les valeurs manquantes sont affichées NaN et repérées par la méthode .isnull(), qui renvoie un tableau de booléens de même forme que le DataFrame.

Ici, le DataFrame data est complet : toutes les cases valent False. Nous verrons un peu plus bas, dans la section Les valeurs manquantes, comment les compter, les supprimer ou les remplacer.

data.isnull()
foo bar baz zoo
0 False False False False
1 False False False False
2 False False False False
3 False False False False
4 False False False False
5 False False False False

Ces informations peuvent être combinées pour obtenir un résumé complet des données avec la méthode .info().

data.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 6 entries, 0 to 5
Data columns (total 4 columns):
 #   Column  Non-Null Count  Dtype 
---  ------  --------------  ----- 
 0   foo     6 non-null      object
 1   bar     6 non-null      object
 2   baz     6 non-null      int64 
 3   zoo     6 non-null      object
dtypes: int64(1), object(3)
memory usage: 324.0+ bytes
data['baz'].mean()
3.5

8.3 Les valeurs manquantes

Les jeux de données réels sont rarement complets. Une valeur manquante peut recouvrir des réalités très différentes : information non collectée, question sans objet pour cet individu, erreur de saisie, perte lors d’un transfert. Pandas les représente toutes de la même façon, par NaN (Not a Number).

Le DataFrame data utilisé jusqu’ici n’en contient aucune. Construisons-en un qui en contient :

releve = pd.DataFrame({
    "station"     : ["Toulon", "Nice", "Marseille", "Ajaccio", "Bastia"],
    "temperature" : [18.2, np.nan, 21.0, 19.5, np.nan],
    "humidite"    : [65, 70, np.nan, 58, 62],
    "vent"        : [12, 8, 15, np.nan, 20] })

releve
station temperature humidite vent
0 Toulon 18.2 65.0 12.0
1 Nice NaN 70.0 8.0
2 Marseille 21.0 NaN 15.0
3 Ajaccio 19.5 58.0 NaN
4 Bastia NaN 62.0 20.0

8.3.1 Repérer les valeurs manquantes

.isnull() (ou son alias .isna()) renvoie un DataFrame de booléens de même forme. Combiné à .sum() il donne le nombre de valeurs manquantes par colonne ; combiné à .mean(), leur proportion.

display(releve.isnull())
display(releve.isnull().sum())
display((releve.isnull().mean() * 100).round(1))
station temperature humidite vent
0 False False False False
1 False True False False
2 False False True False
3 False False False True
4 False True False False
station        0
temperature    2
humidite       1
vent           1
dtype: int64
station         0.0
temperature    40.0
humidite       20.0
vent           20.0
dtype: float64

.info() donne la même information sous une autre forme : le nombre de valeurs non nulles de chaque colonne.

releve.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 5 entries, 0 to 4
Data columns (total 4 columns):
 #   Column       Non-Null Count  Dtype  
---  ------       --------------  -----  
 0   station      5 non-null      object 
 1   temperature  3 non-null      float64
 2   humidite     4 non-null      float64
 3   vent         4 non-null      float64
dtypes: float64(3), object(1)
memory usage: 292.0+ bytes

Et pour examiner les lignes incomplètes :

masque = releve.isnull().any(axis=1)   # au moins une valeur manquante sur la ligne
releve[masque]
station temperature humidite vent
1 Nice NaN 70.0 8.0
2 Marseille 21.0 NaN 15.0
3 Ajaccio 19.5 58.0 NaN
4 Bastia NaN 62.0 20.0

NaN n’est égal à rien, pas même à lui-même. Un test == np.nan renvoie toujours False, et ne détecte donc jamais rien. Utilisez toujours .isnull() / .isna().

display(Markdown(f"np.nan == np.nan : **{np.nan == np.nan}**"))

display(releve["temperature"] == np.nan)   # que des False : inutilisable
display(releve["temperature"].isnull())    # la bonne façon de faire

np.nan == np.nan : False

0    False
1    False
2    False
3    False
4    False
Name: temperature, dtype: bool
0    False
1     True
2    False
3    False
4     True
Name: temperature, dtype: bool

8.3.2 Comment les calculs les traitent

Les fonctions d’agrégation ignorent les valeurs manquantes. Ce n’est pas anodin : la moyenne est calculée sur les seules valeurs disponibles, et le dénominateur n’est donc pas celui que vous croyez.

display(releve["temperature"].mean())    # moyenne sur 3 valeurs, pas sur 5
display(releve["temperature"].sum())     # somme des 3 valeurs présentes
display(releve["temperature"].count())   # nombre de valeurs NON manquantes
19.566666666666666
58.7
3

En revanche, une opération arithmétique propage le NaN : dès qu’une des deux valeurs manque, le résultat manque.

releve["temperature"] + releve["vent"]
0    30.2
1     NaN
2    36.0
3     NaN
4     NaN
dtype: float64

8.3.3 Supprimer : dropna

Par défaut, .dropna() supprime toute ligne comportant au moins une valeur manquante. Les arguments utiles :

Argument Effet
axis=1 supprimer les colonnes plutôt que les lignes
how="all" ne supprimer que si toutes les valeurs manquent
subset=[...] n’examiner que certaines colonnes
thresh=n conserver les lignes ayant au moins n valeurs renseignées
display(releve.dropna())                         # ne reste que les lignes complètes
display(releve.dropna(subset=["temperature"]))   # on n'exige que la température
display(releve.dropna(axis=1))                   # on supprime les colonnes incomplètes
station temperature humidite vent
0 Toulon 18.2 65.0 12.0
station temperature humidite vent
0 Toulon 18.2 65.0 12.0
2 Marseille 21.0 NaN 15.0
3 Ajaccio 19.5 58.0 NaN
station
0 Toulon
1 Nice
2 Marseille
3 Ajaccio
4 Bastia

8.3.4 Remplacer : fillna

display(releve.fillna(0))                                   # par une constante
display(releve.fillna(releve.mean(numeric_only=True)))      # par la moyenne de chaque colonne
station temperature humidite vent
0 Toulon 18.2 65.0 12.0
1 Nice 0.0 70.0 8.0
2 Marseille 21.0 0.0 15.0
3 Ajaccio 19.5 58.0 0.0
4 Bastia 0.0 62.0 20.0
station temperature humidite vent
0 Toulon 18.200000 65.00 12.00
1 Nice 19.566667 70.00 8.00
2 Marseille 21.000000 63.75 15.00
3 Ajaccio 19.500000 58.00 13.75
4 Bastia 19.566667 62.00 20.00

Pour des données ordonnées (une série temporelle, par exemple), on peut propager la dernière valeur connue avec .ffill(), ou la suivante avec .bfill() :

display(releve.ffill())   # forward fill  : on reprend la valeur du dessus
display(releve.bfill())   # backward fill : on reprend la valeur du dessous
station temperature humidite vent
0 Toulon 18.2 65.0 12.0
1 Nice 18.2 70.0 8.0
2 Marseille 21.0 70.0 15.0
3 Ajaccio 19.5 58.0 15.0
4 Bastia 19.5 62.0 20.0
station temperature humidite vent
0 Toulon 18.2 65.0 12.0
1 Nice 21.0 70.0 8.0
2 Marseille 21.0 58.0 15.0
3 Ajaccio 19.5 58.0 20.0
4 Bastia NaN 62.0 20.0

Traiter les valeurs manquantes n’est pas un choix technique, c’est un choix méthodologique.

  • Supprimer les lignes incomplètes biaise l’échantillon dès que les données ne manquent pas « au hasard » : si les personnes à hauts revenus refusent plus souvent de répondre, les supprimer fait baisser artificiellement le revenu moyen.
  • Remplacer par la moyenne préserve cette moyenne, mais écrase la variance et invente des observations qui n’ont jamais été faites.

Il n’existe pas de bonne réponse générique. La seule règle vraiment solide : regarder combien de lignes sont concernées avant de décider, puis dire explicitement ce que vous avez fait et pourquoi.

8.4 Statistiques descriptives

On peut calculer la somme, la plus petite/grande valeur, la moyenne, la médiane, l’écart-type des élements de chaque colonne avec les méthodes .sum(), .min(), .max(), .mean(), .median() et .std().
Rappel : la somme entre chaînes de caractères est une concaténation.

# Elles renvoient des Series dont les Index sont les colonnes du DataFrame

display(Markdown("**Opérations sur les DataFrames**"))

display(Markdown("**Somme**"))
display(data.sum()) # idem que data.sum(axis=0) ou data.sum(axis='rows') 
display(Markdown("**Minimum**"))
display(data.min())
display(Markdown("**Maximum**"))
display(data.max())
display(Markdown("**Moyenne**"))
# display(data.mean())
display(data.mean(numeric_only=True))
display(Markdown("**Médiane**"))
# display(data.median())
display(data.median(numeric_only=True))
display(Markdown("**Écart-type**"))
# display(data.std())
display(data.std(numeric_only=True))
display(Markdown("**Comptage : nombre de valeurs non manquants par colonne**"))
display(data.count())

Opérations sur les DataFrames

Somme

foo    oneoneonetwotwotwo
bar                AABABB
baz                    21
zoo                xyzqwt
dtype: object

Minimum

foo    one
bar      A
baz      1
zoo      q
dtype: object

Maximum

foo    two
bar      B
baz      6
zoo      z
dtype: object

Moyenne

baz    3.5
dtype: float64

Médiane

baz    3.5
dtype: float64

Écart-type

baz    1.870829
dtype: float64

Comptage : nombre de valeurs non manquants par colonne

foo    6
bar    6
baz    6
zoo    6
dtype: int64

Pour obtenir un résumé statistique des variables numériques, on peut utiliser la méthode .describe() :

data.describe()
baz
count 6.000000
mean 3.500000
std 1.870829
min 1.000000
25% 2.250000
50% 3.500000
75% 4.750000
max 6.000000

Cette méthode renvoie un objet de type DataFrame, où les statistiques descriptives sont calculées pour chaque colonne numérique. Pour les variables catégorielles, on peut obtenir le nombre de valeurs uniques, la valeur la plus fréquente et sa fréquence :

data[['foo', 'bar', 'zoo']].describe()
foo bar zoo
count 6 6 6
unique 2 2 6
top one A x
freq 3 3 1
data.describe(include="all")
foo bar baz zoo
count 6 6 6.000000 6
unique 2 2 NaN 6
top one A NaN x
freq 3 3 NaN 1
mean NaN NaN 3.500000 NaN
std NaN NaN 1.870829 NaN
min NaN NaN 1.000000 NaN
25% NaN NaN 2.250000 NaN
50% NaN NaN 3.500000 NaN
75% NaN NaN 4.750000 NaN
max NaN NaN 6.000000 NaN

On peut modifier le type de données d’une colonne avec la méthode .astype() et demander à Pandas d’y associer des codes numériques. Si on ne veut pas ecraser le DataFrame initial, on peut utiliser la méthode .copy().

data_bis = data.copy()
for col in data_bis.select_dtypes(include='object').columns:
    data_bis[col] = data_bis[col].astype('category').cat.codes

display(data)
display(data_bis)
foo bar baz zoo
0 one A 1 x
1 one A 2 y
2 one B 3 z
3 two A 4 q
4 two B 5 w
5 two B 6 t
foo bar baz zoo
0 0 0 1 3
1 0 0 2 4
2 0 1 3 5
3 1 0 4 0
4 1 1 5 2
5 1 1 6 1
data_bis.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 6 entries, 0 to 5
Data columns (total 4 columns):
 #   Column  Non-Null Count  Dtype
---  ------  --------------  -----
 0   foo     6 non-null      int8 
 1   bar     6 non-null      int8 
 2   baz     6 non-null      int64
 3   zoo     6 non-null      int8 
dtypes: int64(1), int8(3)
memory usage: 198.0 bytes
data_bis.describe()
foo bar baz zoo
count 6.000000 6.000000 6.000000 6.000000
mean 0.500000 0.500000 3.500000 2.500000
std 0.547723 0.547723 1.870829 1.870829
min 0.000000 0.000000 1.000000 0.000000
25% 0.000000 0.000000 2.250000 1.250000
50% 0.500000 0.500000 3.500000 2.500000
75% 1.000000 1.000000 4.750000 3.750000
max 1.000000 1.000000 6.000000 5.000000

8.5 Comptage : valeurs uniques, dénombrement de valeurs et appartenance

Une autre catégorie de méthodes connexes permet d’extraire des informations sur les valeurs contenues dans une série unidimensionnelle. Il s’agit des méthodes .count(), .unique(), .nunique() et .value_counts().

data
foo bar baz zoo
0 one A 1 x
1 one A 2 y
2 one B 3 z
3 two A 4 q
4 two B 5 w
5 two B 6 t
# Combien de valuers : ici , on peut également utiliser .size(), puisqu'il n'y a pas de valeurs manquantes    
data['foo'].count()
6

Pour déterminer le nombre de valeurs uniques d’une variable, plutôt que chercher à écrire soi-même une fonction, on utilise la méthode nunique.

# On affiche combien de valeurs uniques par colonne :
data.nunique()
foo    2
bar    2
baz    6
zoo    6
dtype: int64
# Pour chaque colonne selectionnée, on affiche les valeurs uniques :
for col in data.columns:
    print((f"\nValeurs uniques dans la colonne {col} :"))
    print(data[col].unique())

Valeurs uniques dans la colonne foo :
['one' 'two']

Valeurs uniques dans la colonne bar :
['A' 'B']

Valeurs uniques dans la colonne baz :
[1 2 3 4 5 6]

Valeurs uniques dans la colonne zoo :
['x' 'y' 'z' 'q' 'w' 't']
# On peut obtenir ces deux informations en une seule instruction :
for col in data.columns:
    print((f"\nValeurs et occurrences dans la colonne {col} :"))
    print(data[col].value_counts())

Valeurs et occurrences dans la colonne foo :
foo
one    3
two    3
Name: count, dtype: int64

Valeurs et occurrences dans la colonne bar :
bar
A    3
B    3
Name: count, dtype: int64

Valeurs et occurrences dans la colonne baz :
baz
1    1
2    1
3    1
4    1
5    1
6    1
Name: count, dtype: int64

Valeurs et occurrences dans la colonne zoo :
zoo
x    1
y    1
z    1
q    1
w    1
t    1
Name: count, dtype: int64

9 Filtres et opérations

L’opération de sélection de lignes s’appelle Filtrer. Elle s’utilise en fonction d’une condition logique, on sélectionne les données sur une condition logique.

Il existe plusieurs méthodes en Pandas. La plus simple est d’utiliser les boolean mask, déjà vus en numpy.

La plupart de ce qu’on a pu voir avec les arrays de NumPy est applicable aux objets Series et DataFrames de Pandas. On peut par exemple effectuer des opérations arithmétiques sur les objets, ou encore appliquer des fonctions mathématiques.

9.1 Opérations sur les DataFrames

On peut appliquer des opérations arithmétiques sur les DataFrames, de la même manière que pour les Series. Les opérations sont effectuées élément par élément, en alignant les lignes et les colonnes par leurs étiquettes respectives.

df1['NewCol1'] = df1['Population'] * 2
df1['NewCol2'] = df1['Population']/df1['Area']
df1
Population Area Density NewCol1 NewCol2
Suisse 8.516 41285.0 0.000206 17.032 0.000206
France 67.060 551695.0 0.000122 134.120 0.000122
USA 328.200 3796742.0 0.000086 656.400 0.000086
Italie 60.360 301338.0 0.000200 120.720 0.000200

9.2 Filtrer un DataFrame : masque booléen vs query()

Pandas propose deux façons principales de filtrer un DataFrame : les masques booléens “à la NumPy”, très flexibles, et la méthode query(), plus lisible et proche du SQL.

Voici un exemple de DataFrame que nous allons utiliser pour illustrer ces deux approches :

data
foo bar baz zoo
0 one A 1 x
1 one A 2 y
2 one B 3 z
3 two A 4 q
4 two B 5 w
5 two B 6 t

9.2.1 Filtre “à la NumPy” (masques booléens)

Avec cette méthode, on crée un masque booléen en appliquant une condition logique sur une ou plusieurs colonnes du DataFrame. On utilise ensuite ce masque pour sélectionner les lignes correspondantes.

  • Avantages
    • Très flexible (méthodes pandas/NumPy, fonctions Python).
    • Compatible avec tous les noms de colonnes.
  • Inconvénients
    • Syntaxe moins intuitive car & | ~ au lieu de and or not et parenthèses obligatoires.
mask = data["bar"]=="A"
mask
0     True
1     True
2    False
3     True
4    False
5    False
Name: bar, dtype: bool
data[mask]
foo bar baz zoo
0 one A 1 x
1 one A 2 y
3 two A 4 q
# `&` pour le `and` 
# `|` pour le `or` 
# `~` pour le `not` 
# parenthèses obligatoires
mask = (data["bar"]=="A") & (data["baz"]>=2) 
data[mask]
foo bar baz zoo
1 one A 2 y
3 two A 4 q

Remarque : dans l’exemple ci-dessous, on utilise la méthode str.startswith('t') pour vérifier si les chaînes de caractères de la colonne ‘foo’ commencent par la lettre ‘t’. .str. est une méthode particulière qui permet de traiter chaque valeur d’une colonne comme un string natif en Python sur lequel appliquer une méthode ultérieure (en l’occurrence startswith).

mask = data['foo'].str.startswith('t')
data[mask]
foo bar baz zoo
3 two A 4 q
4 two B 5 w
5 two B 6 t

9.2.2 Filtre avec query()

  • Avantages
    • Syntaxe type SQL : plus lisible.
    • Pas de parenthèses autour des conditions.
  • Inconvénients
    • Limitations si noms de colonnes avec espaces ou fonctions Python complexes.
    • Expression passée en chaîne de caractères.
data.query("bar=='A'")
foo bar baz zoo
0 one A 1 x
1 one A 2 y
3 two A 4 q
data.query("bar=='A' & baz>=2") 
# idem que 
# data[(data["bar"]=="A") & (data["baz"]>=2)]
foo bar baz zoo
1 one A 2 y
3 two A 4 q

10 Aggregations

10.1 groupby : analyse par groupes

La méthode groupby permet de grouper les données suivant certains critères.

Commençons par un exemple simple :

df = pd.DataFrame({'key': ['A', 'B', 'C', 'A', 'B', 'C'],
                   'data': range(6)}, columns=['key', 'data'])

display(Markdown("**DataFrame initial**"))
display(df)
display(Markdown("**GroupBy sum sur la colonne 'key'**"))
display(df.groupby('key').sum())

DataFrame initial

key data
0 A 0
1 B 1
2 C 2
3 A 3
4 B 4
5 C 5

GroupBy sum sur la colonne ‘key’

data
key
A 3
B 5
C 7
df = pd.DataFrame({'key': ['A', 'B', 'C', 'A', 'B', 'C'],
                   'data': range(6),
                   'toto': ['a','a','b','b','a','b']}, 
                   columns=['key', 'data', 'toto'])

display(Markdown("**DataFrame initial**"))
display(df)
display(Markdown("**GroupBy sum sur la colonne 'key'**"))
display(df.groupby('key').sum())

DataFrame initial

key data toto
0 A 0 a
1 B 1 a
2 C 2 b
3 A 3 b
4 B 4 a
5 C 5 b

GroupBy sum sur la colonne ‘key’

data toto
key
A 3 ab
B 5 aa
C 7 bb

C’est ce que l’on appelle le paradigme « split-apply-combine » (diviser-appliquer-combiner) : d’abord on génère un groupe de DataFrame en fonction de la valeur de la clé spécifiée. À chaque groupe, on applique ensuite une fonction d’agrégation (ici la somme) pour obtenir une seule ligne. Enfin on fusionne les lignes obtenues dans un nouveau DataFrame.

Passons à un exemple plus riche.

data = pd.DataFrame( {  "foo" : ["one", "one", "one", "two", "two", "two"],
                        "bar" : ["A", "A", "B", "A", "B", "B"],
                        "baz" : [1, 2, 3, 4, 5, 6], 
                        "zoo" : ["x", "y", "z", "q", "w", 't'] } )

data
foo bar baz zoo
0 one A 1 x
1 one A 2 y
2 one B 3 z
3 two A 4 q
4 two B 5 w
5 two B 6 t

On va regrouper les données en fonction de la colonne foo.

data_gbfoo = data.groupby("foo")
data_gbfoo
<pandas.core.groupby.generic.DataFrameGroupBy object at 0x7f29dba4faa0>

On obtient un objet DataFrameGroupBy, c’est-à-dire une sorte de dictionnaire de DatFrame dont les clés sont les valeurs uniques de la colonne foo et les valeurs sont les DataFrame correspondant à chaque groupe :

for name, group in data_gbfoo:
    display(Markdown(f"**Nom du groupe : {name}**"))
    display(group)

Nom du groupe : one

foo bar baz zoo
0 one A 1 x
1 one A 2 y
2 one B 3 z

Nom du groupe : two

foo bar baz zoo
3 two A 4 q
4 two B 5 w
5 two B 6 t

On peut regrouper les données en fonction de plusieurs colonnes. Par exemple, on peut regrouper les données en fonction des colonnes foo et bar :

data_gbfoobar =  data.groupby(['foo', 'bar'])
for name, group in data_gbfoobar:
    display(Markdown(f"**Nom du groupe : {name}**"))
    display(group)

Nom du groupe : (‘one’, ‘A’)

foo bar baz zoo
0 one A 1 x
1 one A 2 y

Nom du groupe : (‘one’, ‘B’)

foo bar baz zoo
2 one B 3 z

Nom du groupe : (‘two’, ‘A’)

foo bar baz zoo
3 two A 4 q

Nom du groupe : (‘two’, ‘B’)

foo bar baz zoo
4 two B 5 w
5 two B 6 t

On peut appliquer une même opération sur chaque groupe en même temps. Par exemple, on peut extraire une seule colonne de cet objet, et on obtient un objet SeriesGroupBy :

data_gbfoo_baz = data_gbfoo["baz"]
data_gbfoo_baz
<pandas.core.groupby.generic.SeriesGroupBy object at 0x7f29dba4ee10>

On obtient ainsi un objet SeriesGroupBy, qui est une sorte de dictionnaire de Series. Chaque Series correspond à un groupe.

for name, group in data_gbfoo_baz:
    display(Markdown(f"**Nom du groupe : {name}**"))
    display(group)

Nom du groupe : one

0    1
1    2
2    3
Name: baz, dtype: int64

Nom du groupe : two

3    4
4    5
5    6
Name: baz, dtype: int64

Pour ne pas itérer sur les groupes mais juste connaître des informations sur les groupes, on peut utiliser :

  • ngroups permet de connaître le nombre de groupes obtenus (ici 2 car on a deux valeurs uniques dans la colonne foo)
  • size combien d’éléments sont dans chaque groupe
  • groups les indices des lignes de chaque groupe avec la méthode.
# Combien de groupes ?
nb_groupe = data_gbfoo.ngroups
display(nb_groupe)

# Combien d'éléments par groupe ?
nb_element_par_groupe = data_gbfoo.size()
display(nb_element_par_groupe)

# Quelles sont les indices des lignes de chaque groupe ?
group_indices = data_gbfoo.groups
display(group_indices)
2
foo
one    3
two    3
dtype: int64
{'one': [0, 1, 2], 'two': [3, 4, 5]}

L’object DataFrameGroupBy peut être immaginé comme un dictionnaire de DataFrames où chaque clé est un nom de groupe et chaque valeur est le DataFrame correspondant à ce groupe. Cependant, ce c’est pas un vrai dictionnaire, en particulier on ne peut pas accéder aux groupes avec la syntaxe des dictionnaires data_gbfoo['one'].

Pour récuperer les dataframes on doit utiliser la méthode get_group() :

data_one = data_gbfoo.get_group("one") # les lignes du groupe "one" constitue un dataframe
data_two = data_gbfoo.get_group("two") # les lignes du groupe "two" constitue un dataframe
display(data_one, data_two)
foo bar baz zoo
0 one A 1 x
1 one A 2 y
2 one B 3 z
foo bar baz zoo
3 two A 4 q
4 two B 5 w
5 two B 6 t

L’utilisation principale de groupby nest pas la simple création de sous-DataFrames. Généralement on applique des fonctions d’agrégation sur les groupes obtenus. On obtient un unique DataFrame avec une ligne par groupe.

Par exemple, on peut calculer la somme des éléments de chaque groupe (on se rappelle que l’operation “somme” entre chaînes de caractères correspond à la concaténation de ces chaînes) :

data_gbfoo.sum()
bar baz zoo
foo
one AAB 6 xyz
two ABB 15 qwt
data_gbfoo['baz'].sum()
foo
one     6
two    15
Name: baz, dtype: int64

On peut aussi regrouper par plusieurs colonnes, puis appliquer une fonction d’agrégation.

Exemple : regroupons les données de data en utilisant les colonnes foo et bar, puis calculons la somme de la colonne baz pour chaque groupe unique de foo et bar.

Voici les étapes décomposées :

  1. data_grfoobar = data.groupby(['foo', 'bar']) : regroupe le DataFrame par les colonnes foo et bar, en obtenant autant de groupes que de couples différents ;
  2. data_grfoobar['baz'].sum() : calcule la somme de baz pour chaque groupe.
  3. Facultatif : .unstack() qui transforme le résultat en une table pivotée, en déplaçant les valeurs uniques de bar en colonnes. Ainsi, pour chaque valeur de foo, on obtient les sommes de baz réparties par les différentes valeurs de bar.

Le résultat est un DataFrame avec les valeurs de foo en index et celles de bar en colonnes, où chaque cellule contient la somme correspondante de baz.

# on regroupe les couples (foo, bar) 
data_grfoobar = data.groupby(['foo', 'bar'])

display(data_grfoobar.get_group(('one', 'A'))) # df pour qui foo == 'one' et bar == 'A'
display(data_grfoobar.get_group(('one', 'B'))) # df pour qui foo == 'one' et bar == 'B'
display(data_grfoobar.get_group(('two', 'A'))) # df pour qui foo == 'two' et bar == 'A'
display(data_grfoobar.get_group(('two', 'B'))) # df pour qui foo == 'two' et bar == 'B'
foo bar baz zoo
0 one A 1 x
1 one A 2 y
foo bar baz zoo
2 one B 3 z
foo bar baz zoo
3 two A 4 q
foo bar baz zoo
4 two B 5 w
5 two B 6 t
# Pour chaque groupe, on somme les valeurs de la colonne "baz"
data_gbfoobarbaz = data_grfoobar['baz'].sum() 
data_gbfoobarbaz # c'est une Series avec un MultiIndex
foo  bar
one  A       3
     B       3
two  A       4
     B      11
Name: baz, dtype: int64
data_gbfoobarbaz.index
MultiIndex([('one', 'A'),
            ('one', 'B'),
            ('two', 'A'),
            ('two', 'B')],
           names=['foo', 'bar'])

Avec .unstack(), on peut transformer la Series MultiIndex en DataFrame pour une meilleure lisibilité :

data_gbfoobarbaz.unstack()
bar A B
foo
one 3 3
two 4 11

10.2 pivot_table : tableaux croisés

Le résultat obtenu avec l’instruction :

data.groupby(['col1', 'col2'])['col3'].sum().unstack()

est un tableau croisé dynamique : à chaque couple (col1, col2), on associe la somme des valeurs de col3.

Si le DataFrame data contient les colonnes col1, col2 et col3, on peut imaginer que chaque ligne du DataFrame correspond à un triplet (x_i, y_i, z_i). On peut alors representer cela comme un point en 3D :

  • col1 est la coordonnée x_i
  • col2 est la coordonnée y_i
  • col3 est la coordonnée z_i

On peut imaginer que l’on cherche à représenter une fonction de deux variables \(z = f(x,y)\), où \(x\) et \(y\) sont les coordonnées dans le plan horizontal, et \(z\) est la hauteur associée à chaque point \((x,y)\). Problème : si le couple (x_i, y_i) est unique dans le DataFrame, on a un seul point pour cette position. Mais si plusieurs lignes \(j\) ont le même couple (x_i, y_i) mais des valeurs différentes (z_i)_j, cela correspond à plusieurs point empilés verticalement, ce qui ne représente plus une fonction de \(\mathbb R^2\) vers \(\mathbb R\). Dans ce cas, pour obtenir une valeur unique \(\tilde z_i\), il faut agréger ces différentes valeurs. C’est là qu’intervient l’agrégation (ici la somme) :

\[ (x_i, y_i) \mapsto \tilde z_i = \sum_{j} (z_i)_j \]

On pourrait également utiliser d’autres fonctions d’agrégation comme mean, max, min, etc.

C’est une opération très courante en analyse de données. Pandas propose donc une méthode dédiée : pivot_table.

pivot = data.pivot_table(
    index='col1',
    columns='col2',
    values='col3',
    aggfunc='sum'
)

On applique la méthode pivot_table sur le DataFrame data en précisant :

  • index : le nom de la colonne dans de DataFrame qui sera la ligne du nouveau tableau
  • columns : le nom de la colonne dans le DataFrame qui sera la colonne du nouveau tableau
  • values : le nom de la colonne sur laquelle appliquer l’agrégation
  • aggfunc : la fonction d’agrégation (sum, mean, count, min, max, etc.). Si ce n’est pas spécifié, par défaut on calcule la moyenne (mean) des valeurs.

Avec pivot_table, on obtient un résultat équivalent à

groupby(...).agg(...).unstack()

mais plus lisible et directement structuré sous forme de tableau croisé.

data
foo bar baz zoo
0 one A 1 x
1 one A 2 y
2 one B 3 z
3 two A 4 q
4 two B 5 w
5 two B 6 t
data.pivot_table(index='foo', columns='bar', values='baz', aggfunc='sum')
bar A B
foo
one 3 3
two 4 11

Lorsqu’on a besoin de calculer des sous-totaux, on peut utiliser l’option margins=True :

data.pivot_table(index='foo', 
                 columns='bar', 
                 values='baz', 
                 aggfunc='sum', 
                 margins=True)
bar A B All
foo
one 3 3 6
two 4 11 15
All 7 14 21

10.3 crosstab : tableaux croisés

crosstab est une fonction principalement utilisée pour compter les occurrences ou les fréquences des combinaisons de valeurs de deux colonnes.

pd.crosstab(data['col1'], data['col2'])

Cette fonction crée un tableau croisé indipendamment d’un DataFrame, en utilisant deux séries ou colonnes comme entrées :

  • index : les données stockées dans une séries ou une colonne d’un DataFrame qui sera la ligne du nouveau tableau
  • columns : les données stockées dans une séries ou une colonne d’un DataFrame qui sera la colonne du nouveau tableau

Pour chaque combinaison unique de valeurs dans col1 et col2, crosstab compte le nombre d’occurrences et remplit le tableau avec ces comptes.

crosstab peut également agréger les valeurs d’une colonne supplémentaire, mais la syntaxe est différente de pivot_table :

pd.crosstab(
    index=data['col1'],
    columns=data['col2'],
    values=data['col3'],
    aggfunc=sum
)
  • index et columns : comme précédemment
  • values : les données stockées dans une séries ou une colonne d’un DataFrame qui sera la colonne à agréger
  • aggfunc : fonction d’agrégation (par défaut count pour compter les occurrences, mais on peut utiliser sum, mean, etc.)

Enfin, crosstab propose un argument très pratique, normalize, qui permet d’afficher les résultats en pourcentages (par rapport à la somme totale, aux lignes ou aux colonnes) en une seule instruction, sans calcul supplémentaire.

data
foo bar baz zoo
0 one A 1 x
1 one A 2 y
2 one B 3 z
3 two A 4 q
4 two B 5 w
5 two B 6 t

La commande pd.crosstab(data['foo'], data['bar']) crée un tableau croisé (ou “contingence”) en comptant les occurrences de chaque combinaison unique de valeurs dans les colonnes foo et bar du DataFrame data.

  1. pd.crosstab(data['foo'], data['bar']) : compte le nombre de fois que chaque paire de valeurs (foo,bar) apparaît dans le DataFrame.
  2. Le résultat est un nouveau DataFrame avec :
    • Les valeurs uniques de foo comme index (lignes).
    • Les valeurs uniques de bar comme colonnes.
    • Les cellules contiennent le nombre d’occurrences de chaque combinaison (foo, bar).
pd.crosstab(data['foo'], data['bar'])
bar A B
foo
one 2 1
two 1 2
# idem que
data.pivot_table(values='baz', index='foo', columns='bar', aggfunc='count')
bar A B
foo
one 2 1
two 1 2

C’est utile pour obtenir une vue d’ensemble des fréquences d’association entre les deux variables. Pour obtenir les fréquences au lieu des comptes bruts dans un tableau croisé de pandas, on ajoute l’argument normalize=True, chaque valeur est alors divisée par le total de toutes les combinaisons. Cela affichera chaque valeur comme une proportion du total, ce qui correspond aux fréquences relatives.

pd.crosstab(data['foo'], data['bar'], normalize=True)
bar A B
foo
one 0.333333 0.166667
two 0.166667 0.333333

On peut aussi normaliser par ligne ou par colonne :

  • Par ligne (chaque ligne totalisera 1): normalize='index'
  • Par colonne (chaque colonne totalisera 1): normalize='columns'
pd.crosstab(data['foo'], data['bar'], normalize='index')
bar A B
foo
one 0.666667 0.333333
two 0.333333 0.666667

10.4 Bonus : melt, du format large au format long

Bonus : hors séance. Cette section n’est pas nécessaire pour la suite du cours, mais elle vous servira dès que vous voudrez tracer des données groupées avec Seaborn ou Plotly.

pivot_table transforme un tableau long (une observation par ligne) en tableau large (une modalité par colonne). melt réalise l’opération inverse.

Pourquoi en aurait-on besoin ? Parce que Seaborn et Plotly attendent presque toujours le format long : une colonne qui dit de quelle variable il s’agit, une colonne qui dit quelle en est la valeur.

notes_large = pd.DataFrame({ "etudiant" : ["Alice", "Bob", "Chloé"],
                             "algebre"  : [12, 15, 9],
                             "analyse"  : [14, 8, 16] })
notes_large
etudiant algebre analyse
0 Alice 12 14
1 Bob 15 8
2 Chloé 9 16
notes_long = notes_large.melt( id_vars    = "etudiant",
                               var_name   = "matiere",
                               value_name = "note" )
notes_long
etudiant matiere note
0 Alice algebre 12
1 Bob algebre 15
2 Chloé algebre 9
3 Alice analyse 14
4 Bob analyse 8
5 Chloé analyse 16
  • id_vars : les colonnes à conserver telles quelles (ici l’identifiant de l’étudiant) ;
  • var_name : le nom de la nouvelle colonne qui recevra les anciens noms de colonnes ;
  • value_name : le nom de la nouvelle colonne qui recevra les valeurs.

Le format long se prête directement à une visualisation groupée :

import seaborn as sns

sns.barplot(data=notes_long, x="etudiant", y="note", hue="matiere");

Et pivot_table permet de revenir au format large :

notes_long.pivot_table(index="etudiant", columns="matiere", values="note")
matiere algebre analyse
etudiant
Alice 12.0 14.0
Bob 15.0 8.0
Chloé 9.0 16.0

10.5 Arithmétique et alignement des données : différences avec Numpy

Il existe une différence très importante : lorsque vous faites une opération entre deux objets Series, l’opération se fait terme à terme, mais les termes sont identifiés par leur index. Si les index ne correspondent pas, le résultat sera un objet Series avec un index qui est l’union des deux index initiaux. Les valeurs correspondant à des index qui n’étaient pas présents dans les deux objets initiaux seront des valeurs manquantes, notées NaN (pour Not a Number).

ma_serie4 = pd.Series( np.random.randn(5), index=["A","B","C","D","E"] )
display(ma_serie4)
ma_serie5 = pd.Series( np.random.randn(4), index=["A","B","C","F"] )
display(ma_serie5)
ma_serie_somme = ma_serie4 + ma_serie5
display(ma_serie_somme)
A   -0.405428
B   -0.741871
C   -0.080453
D    0.626063
E   -1.865330
dtype: float64
A    0.286204
B    0.812590
C    0.220832
F   -0.737247
dtype: float64
A   -0.119224
B    0.070718
C    0.140379
D         NaN
E         NaN
F         NaN
dtype: float64

On voit ici que les deux Series ont en commun A, B et C. La somme donne bien une valeur qui est la somme des deux objets Series. Pour D, E et F, on obtient une valeur manquante car l’un des deux objets Series ne comprend pas d’index D, E ou F. On a donc la somme d’une valeur manquante et d’une valeur présente qui logiquement donne une valeur manquante. Si vous voulez faire une somme en supposant que les données manquantes sont équivalentes à 0, il faut utiliser : s1.add(s2, fill_value=0):

ma_serie_somme_BIS = ma_serie4.add(ma_serie5, fill_value=0)
display(ma_serie_somme_BIS)
A   -0.119224
B    0.070718
C    0.140379
D    0.626063
E   -1.865330
F   -0.737247
dtype: float64

10.6 L’indexation des DataFrames

Vous pouvez avoir besoin de modifier les index de vos données. Pour cela, différentes options s’offrent à vous :

  • .reindex() permet de sélectionner des colonnes et de réordonner les colonnes et les lignes. Si une colonne ou une ligne n’est pas présente dans le DataFrame initial, elle sera ajoutée avec des valeurs manquantes.
df5 = pd.DataFrame( np.random.randn(5,2), index=["a","b","c","d","e"], columns=["Col 1","Col 2"])
df5
Col 1 Col 2
a 1.108168 0.632504
b 1.556843 1.267146
c 1.032881 -1.404079
d -0.025248 0.105628
e -1.924212 1.302465
# on ne garde que les lignes "c","d","e" mais on les réordonne
# on garde toutes les colonnes mais on les réordonne aussi
df6 = df5.reindex(index=["e","c","d"], columns=["Col 2","Col 1"])
df6
Col 2 Col 1
e 1.302465 -1.924212
c -1.404079 1.032881
d 0.105628 -0.025248
  • .rename() permet de renommer les colonnes ou les lignes.
df7 = df6.rename(index={"e":"E", "c":"C"}, columns={"Col 1":"Premiere", "Col 2":"Seconde"})
df7
Seconde Premiere
E 1.302465 -1.924212
C -1.404079 1.032881
d 0.105628 -0.025248
df8 = df7.rename(index=str.upper, columns=str.title)
df8
Seconde Premiere
E 1.302465 -1.924212
C -1.404079 1.032881
D 0.105628 -0.025248
df9 = df8.rename(mapper=lambda x: x.upper(), axis=1)
df9
SECONDE PREMIERE
E 1.302465 -1.924212
C -1.404079 1.032881
D 0.105628 -0.025248

10.6.1 Bonus : set_index et reset_index

Bonus : hors séance, mais très utile en pratique : groupby produit constamment des index qu’il faut ensuite remettre en colonnes.

  • .set_index("col") promeut une colonne au rang d’index ;
  • .reset_index() fait l’inverse : il remet l’index dans une colonne et renumérote les lignes.
villes = pd.DataFrame({ "ville"      : ["Toulon", "Nice", "Marseille"],
                        "dep"        : ["83", "06", "13"],
                        "population" : [176198, 342669, 873076] })

display(villes)
display(villes.set_index("ville"))
ville dep population
0 Toulon 83 176198
1 Nice 06 342669
2 Marseille 13 873076
dep population
ville
Toulon 83 176198
Nice 06 342669
Marseille 13 873076

Une fois la colonne devenue index, elle est utilisable avec loc :

villes_idx = villes.set_index("ville")
villes_idx.loc["Nice"]
dep               06
population    342669
Name: Nice, dtype: object

reset_index() est l’opération réciproque. C’est le réflexe à avoir après un groupby, dont le résultat a pour index la clé de regroupement :

resume = villes.groupby("dep")["population"].sum()

display(resume)                # une Series indexée par le département
display(resume.reset_index())  # un DataFrame à deux colonnes, plus commode à réutiliser
dep
06    342669
13    873076
83    176198
Name: population, dtype: int64
dep population
0 06 342669
1 13 873076
2 83 176198

Enfin, reset_index(drop=True) jette l’ancien index au lieu d’en faire une colonne : pratique après un filtrage qui a laissé des trous dans la numérotation.

filtre = villes[ villes["population"] > 200_000 ]

display(filtre)                         # les index 1 et 2 d'origine ont été conservés
display(filtre.reset_index(drop=True))  # renumérotation de 0 à n-1
ville dep population
1 Nice 06 342669
2 Marseille 13 873076
ville dep population
0 Nice 06 342669
1 Marseille 13 873076

10.7 Copie vs vue

Lorsqu’on crée un objet à partir d’un autre : df2 = df1, df2 = df1[["col"]], ou un DataFrame construit sur un tableau NumPy : pandas ne duplique pas systématiquement les données. Tantôt vous obtenez une copie indépendante, tantôt une vue qui partage la même zone mémoire que l’original : modifier l’une modifie alors l’autre, parfois à votre insu.

La règle à retenir. Si vous voulez un objet réellement indépendant de l’original, dites-le explicitement :

df2 = df1.copy()
df_origine = pd.DataFrame({ "a" : [1, 2, 3],
                            "b" : [4, 5, 6] })

df_copie = df_origine.copy()
df_copie.loc[0, "a"] = 999

display(df_origine)   # intact
display(df_copie)     # seul le second a changé
a b
0 1 4
1 2 5
2 3 6
a b
0 999 4
1 2 5
2 3 6

10.7.1 Pourquoi ? Ce qui se passe réellement

Un DataFrame construit à partir d’un tableau NumPy partage la mémoire de ce tableau. Modifier le DataFrame modifie donc aussi le tableau d’origine :

arr1 = np.arange(6).reshape(3, 2)
df1  = pd.DataFrame(arr1)

print("Avant modification")
display(arr1)
display(df1)

df1.iloc[1, 1] = 22

print("Après modification d'une valeur du DataFrame")
display(df1)
display(arr1)   # ici, l'array a été modifié lui aussi
Avant modification
array([[0, 1],
       [2, 3],
       [4, 5]])
0 1
0 0 1
1 2 3
2 4 5
Après modification d'une valeur du DataFrame
0 1
0 0 1
1 2 22
2 4 5
array([[ 0,  1],
       [ 2, 22],
       [ 4,  5]])

Le même phénomène explique l’avertissement SettingWithCopyWarning, que vous rencontrerez tôt ou tard : pandas vous prévient qu’il ne sait pas si l’objet que vous modifiez est une vue ou une copie, et donc si votre modification atteindra l’original ou se perdra.

10.7.2 Ce qui change avec le Copy-on-Write

Depuis pandas 2.0, un mode appelé Copy-on-Write (CoW) est disponible ; il devient le comportement par défaut à partir de pandas 3.0. Sa règle est simple et uniforme : toute opération se comporte comme si elle renvoyait une copie. Le partage de mémoire continue d’exister en interne, pour la performance, mais il n’est plus jamais observable : et l’avertissement SettingWithCopyWarning disparaît.

Activons-le le temps d’une démonstration, avec pd.option_context qui restaure automatiquement le réglage à la sortie du bloc :

with pd.option_context("mode.copy_on_write", True):

    arr2 = np.arange(6).reshape(3, 2)
    df2  = pd.DataFrame(arr2)
    df2.iloc[1, 1] = 22

    display(df2)    # le DataFrame est bien modifié
    display(arr2)   # ici, en revanche, l'array n'est PLUS modifié

display(Markdown(f"Copy-on-Write actif en dehors du bloc ? **{pd.options.mode.copy_on_write}**"))
0 1
0 0 1
1 2 22
2 4 5
array([[0, 1],
       [2, 3],
       [4, 5]])

Copy-on-Write actif en dehors du bloc ? False

pandas 2.x (comportement actuel) Copy-on-Write (défaut en pandas 3.0)
Modification « à distance » possible, et silencieuse impossible
SettingWithCopyWarning fréquent n’existe plus
Rôle de .copy() indispensable pour s’isoler explicite votre intention

Dans les deux cas, la règle du début de section reste la bonne : si vous voulez une copie, écrivez .copy().

11 Combiner des tables

Jusqu’ici nous avons travaillé sur un seul DataFrame. En pratique, l’information est presque toujours répartie dans plusieurs tables qu’il faut rapprocher. Deux situations, très différentes, se présentent.

  • Empiler des tables qui décrivent les mêmes variables pour des individus différents : les ventes de janvier et celles de février, les étudiants du groupe A et ceux du groupe B. C’est le rôle de pd.concat.
  • Joindre des tables qui décrivent des variables différentes pour les mêmes individus, en s’appuyant sur une colonne commune appelée clé : une table des communes et une table des départements, reliées par le code du département. C’est le rôle de pd.merge.

Retenez la distinction : empiler ajoute des lignes, joindre ajoute des colonnes.

11.1 Empiler des tables : pd.concat

pd.concat prend une liste de DataFrame et les empile. Par défaut l’empilement est vertical (axis=0) : les lignes du second viennent sous celles du premier, et les colonnes sont appariées par leur nom.

ventes_janvier = pd.DataFrame({ "produit"  : ["stylo", "cahier", "gomme"],
                                "quantite" : [120, 80, 45] })

ventes_fevrier = pd.DataFrame({ "produit"  : ["stylo", "cahier", "règle"],
                                "quantite" : [95, 110, 30] })

display(ventes_janvier, ventes_fevrier)
produit quantite
0 stylo 120
1 cahier 80
2 gomme 45
produit quantite
0 stylo 95
1 cahier 110
2 règle 30
ventes = pd.concat([ventes_janvier, ventes_fevrier])
ventes
produit quantite
0 stylo 120
1 cahier 80
2 gomme 45
0 stylo 95
1 cahier 110
2 règle 30

Remarquez les index dupliqués : chaque DataFrame a conservé le sien (0, 1, 2 puis 0, 1, 2). C’est rarement souhaitable. Deux solutions :

  • ignore_index=True renumérote les lignes de \(0\) à \(n-1\) ;
  • keys=[...] conserve la provenance de chaque ligne, dans un index à deux niveaux.
pd.concat([ventes_janvier, ventes_fevrier], ignore_index=True)
produit quantite
0 stylo 120
1 cahier 80
2 gomme 45
3 stylo 95
4 cahier 110
5 règle 30
ventes_par_mois = pd.concat([ventes_janvier, ventes_fevrier], keys=["Janvier", "Février"])
ventes_par_mois
produit quantite
Janvier 0 stylo 120
1 cahier 80
2 gomme 45
Février 0 stylo 95
1 cahier 110
2 règle 30

Si les deux tables n’ont pas exactement les mêmes colonnes, concat fait l’union des colonnes et comble les trous par des NaN :

ventes_mars = pd.DataFrame({ "produit"  : ["stylo", "trousse"],
                             "quantite" : [60, 12],
                             "remise"   : [0.1, 0.2] })   # colonne supplémentaire

pd.concat([ventes_janvier, ventes_mars], ignore_index=True)
produit quantite remise
0 stylo 120 NaN
1 cahier 80 NaN
2 gomme 45 NaN
3 stylo 60 0.1
4 trousse 12 0.2

Avec axis=1, l’empilement devient horizontal : les colonnes sont juxtaposées et les lignes appariées par leur index, exactement selon la règle d’alignement vue plus haut.

prix  = pd.DataFrame({ "prix"  : [1.2, 2.5, 0.6] }, index=["stylo", "cahier", "gomme"])
stock = pd.DataFrame({ "stock" : [300, 150, 80]  }, index=["stylo", "cahier", "gomme"])

pd.concat([prix, stock], axis=1)
prix stock
stylo 1.2 300
cahier 2.5 150
gomme 0.6 80

Attention : pd.concat(..., axis=1) apparie les lignes par leur index, et non par leur ordre d’apparition. Si les index ne coïncident pas, vous obtiendrez des NaN sans le moindre message d’erreur.

stock_desordre = pd.DataFrame({ "stock" : [80, 300, 150] }, index=["gomme", "stylo", "cahier"])

# L'ordre des lignes diffère, mais l'appariement se fait sur l'index : le résultat est correct
display(pd.concat([prix, stock_desordre], axis=1))

stock_partiel = pd.DataFrame({ "stock" : [300, 150] }, index=["stylo", "règle"])

# Ici "cahier" et "gomme" n'ont pas de stock, "règle" n'a pas de prix : des NaN apparaissent
display(pd.concat([prix, stock_partiel], axis=1))
prix stock
stylo 1.2 300
cahier 2.5 150
gomme 0.6 80
prix stock
stylo 1.2 300.0
cahier 2.5 NaN
gomme 0.6 NaN
règle NaN 150.0

11.2 Joindre deux tables : pd.merge

Changeons de situation. Nous disposons maintenant de deux tables qui décrivent des choses différentes :

  • une table des communes, avec leur population et le code de leur département ;
  • une table des départements, qui donne le nom du département et sa région.

La colonne dep est présente dans les deux : c’est la clé de la jointure.

communes = pd.DataFrame({
    "commune"    : ["Toulon", "Hyères", "Nice", "Marseille", "Lyon"],
    "dep"        : ["83", "83", "06", "13", "69"],
    "population" : [176198, 57635, 342669, 873076, 522250] })

departements = pd.DataFrame({
    "dep"             : ["06", "13", "69", "83"],
    "nom_departement" : ["Alpes-Maritimes", "Bouches-du-Rhône", "Rhône", "Var"],
    "region"          : ["PACA", "PACA", "Auvergne-Rhône-Alpes", "PACA"] })

display(communes, departements)
commune dep population
0 Toulon 83 176198
1 Hyères 83 57635
2 Nice 06 342669
3 Marseille 13 873076
4 Lyon 69 522250
dep nom_departement region
0 06 Alpes-Maritimes PACA
1 13 Bouches-du-Rhône PACA
2 69 Rhône Auvergne-Rhône-Alpes
3 83 Var PACA
pd.merge(communes, departements, on="dep")
commune dep population nom_departement region
0 Toulon 83 176198 Var PACA
1 Hyères 83 57635 Var PACA
2 Nice 06 342669 Alpes-Maritimes PACA
3 Marseille 13 873076 Bouches-du-Rhône PACA
4 Lyon 69 522250 Rhône Auvergne-Rhône-Alpes

Que s’est-il passé ? Pour chaque ligne de la table de gauche, pandas a cherché dans la table de droite les lignes ayant la même valeur de clé, puis a recollé les colonnes. La table des départements comptait 4 lignes, celle des communes 5 ; le résultat en compte 5, car plusieurs communes partagent le même département.

On peut écrire indifféremment pd.merge(communes, departements, on="dep") ou communes.merge(departements, on="dep").

11.2.1 Les quatre types de jointure : l’argument how

Que faire des lignes qui n’ont aucune correspondance dans l’autre table ? C’est tout l’objet de l’argument how :

how Lignes conservées Équivalent SQL
"inner" (défaut) seulement les clés présentes dans les deux tables INNER JOIN
"left" toutes celles de la table de gauche LEFT JOIN
"right" toutes celles de la table de droite RIGHT JOIN
"outer" toutes, des deux côtés FULL OUTER JOIN

Les colonnes sans correspondance sont remplies par des NaN.

# On ajoute une commune dont le département est absent de la table des départements (Ajaccio, 2A),
# et un département dans lequel aucune commune n'est listée (Paris, 75).

communes2 = pd.concat(
    [communes, pd.DataFrame({"commune":["Ajaccio"], "dep":["2A"], "population":[71361]})],
    ignore_index=True)

departements2 = pd.concat(
    [departements, pd.DataFrame({"dep":["75"], "nom_departement":["Paris"], "region":["Île-de-France"]})],
    ignore_index=True)

display(communes2, departements2)
commune dep population
0 Toulon 83 176198
1 Hyères 83 57635
2 Nice 06 342669
3 Marseille 13 873076
4 Lyon 69 522250
5 Ajaccio 2A 71361
dep nom_departement region
0 06 Alpes-Maritimes PACA
1 13 Bouches-du-Rhône PACA
2 69 Rhône Auvergne-Rhône-Alpes
3 83 Var PACA
4 75 Paris Île-de-France
for methode in ["inner", "left", "right", "outer"]:
    display(Markdown(f"**`how = \"{methode}\"`**"))
    display(pd.merge(communes2, departements2, on="dep", how=methode))

how = "inner"

commune dep population nom_departement region
0 Toulon 83 176198 Var PACA
1 Hyères 83 57635 Var PACA
2 Nice 06 342669 Alpes-Maritimes PACA
3 Marseille 13 873076 Bouches-du-Rhône PACA
4 Lyon 69 522250 Rhône Auvergne-Rhône-Alpes

how = "left"

commune dep population nom_departement region
0 Toulon 83 176198 Var PACA
1 Hyères 83 57635 Var PACA
2 Nice 06 342669 Alpes-Maritimes PACA
3 Marseille 13 873076 Bouches-du-Rhône PACA
4 Lyon 69 522250 Rhône Auvergne-Rhône-Alpes
5 Ajaccio 2A 71361 NaN NaN

how = "right"

commune dep population nom_departement region
0 Nice 06 342669.0 Alpes-Maritimes PACA
1 Marseille 13 873076.0 Bouches-du-Rhône PACA
2 Lyon 69 522250.0 Rhône Auvergne-Rhône-Alpes
3 Toulon 83 176198.0 Var PACA
4 Hyères 83 57635.0 Var PACA
5 NaN 75 NaN Paris Île-de-France

how = "outer"

commune dep population nom_departement region
0 Toulon 83 176198.0 Var PACA
1 Hyères 83 57635.0 Var PACA
2 Nice 06 342669.0 Alpes-Maritimes PACA
3 Marseille 13 873076.0 Bouches-du-Rhône PACA
4 Lyon 69 522250.0 Rhône Auvergne-Rhône-Alpes
5 Ajaccio 2A 71361.0 NaN NaN
6 NaN 75 NaN Paris Île-de-France

Comparez attentivement ces quatre tableaux :

  • inner supprime silencieusement Ajaccio (département inconnu) et Paris (aucune commune) ;
  • left conserve Ajaccio, avec NaN pour le nom du département et la région ;
  • right conserve Paris, avec NaN pour la commune et la population ;
  • outer conserve les deux.

Le choix n’est pas anodin : inner, la valeur par défaut, est précisément celle qui perd des données sans prévenir.

11.2.2 Quand les colonnes clés n’ont pas le même nom

Les deux tables ne nomment pas toujours la clé de la même façon. On précise alors left_on et right_on :

departements_bis = departements.rename(columns={"dep": "code_departement"})

pd.merge(communes, departements_bis,
         left_on="dep", right_on="code_departement")
commune dep population code_departement nom_departement region
0 Toulon 83 176198 83 Var PACA
1 Hyères 83 57635 83 Var PACA
2 Nice 06 342669 06 Alpes-Maritimes PACA
3 Marseille 13 873076 13 Bouches-du-Rhône PACA
4 Lyon 69 522250 69 Rhône Auvergne-Rhône-Alpes

La clé apparaît alors en double (dep et code_departement) ; on supprime la colonne redondante :

pd.merge(communes, departements_bis,
         left_on="dep", right_on="code_departement").drop(columns="code_departement")
commune dep population nom_departement region
0 Toulon 83 176198 Var PACA
1 Hyères 83 57635 Var PACA
2 Nice 06 342669 Alpes-Maritimes PACA
3 Marseille 13 873076 Bouches-du-Rhône PACA
4 Lyon 69 522250 Rhône Auvergne-Rhône-Alpes

11.2.3 Vérifier une jointure

Une jointure réussit toujours : y compris quand elle ne fait pas ce que vous croyez. Deux réflexes :

  1. comparer le nombre de lignes avant et après ;
  2. utiliser indicator=True, qui ajoute une colonne _merge indiquant l’origine de chaque ligne.
resultat = pd.merge(communes2, departements2, on="dep", how="outer", indicator=True)

display(resultat)
display(resultat["_merge"].value_counts())
commune dep population nom_departement region _merge
0 Toulon 83 176198.0 Var PACA both
1 Hyères 83 57635.0 Var PACA both
2 Nice 06 342669.0 Alpes-Maritimes PACA both
3 Marseille 13 873076.0 Bouches-du-Rhône PACA both
4 Lyon 69 522250.0 Rhône Auvergne-Rhône-Alpes both
5 Ajaccio 2A 71361.0 NaN NaN left_only
6 NaN 75 NaN Paris Île-de-France right_only
_merge
both          5
left_only     1
right_only    1
Name: count, dtype: int64

Les deux pièges classiques de merge

  1. Des lignes disparaissent. Avec how="inner" (le défaut), toute clé absente d’un côté fait disparaître la ligne, sans message. Vérifiez systématiquement le nombre de lignes avant et après.
  2. Des lignes se dupliquent. Si la clé n’est pas unique dans la table de droite, chaque ligne de gauche est répétée autant de fois qu’il y a de correspondances : et tous les totaux calculés ensuite sont faux.
# La clé "83" apparaît deux fois à droite : la table des départements est mal construite
departements_doublon = pd.concat(
    [departements, pd.DataFrame({"dep":["83"], "nom_departement":["Var (doublon)"], "region":["PACA"]})],
    ignore_index=True)

jointure = pd.merge(communes, departements_doublon, on="dep")

display(Markdown(f"communes          : {len(communes)} lignes"))
display(Markdown(f"après la jointure : **{len(jointure)}** lignes, "
                 "car Toulon et Hyères ont été dupliquées"))
display(jointure)

communes : 5 lignes

après la jointure : 7 lignes, car Toulon et Hyères ont été dupliquées

commune dep population nom_departement region
0 Toulon 83 176198 Var PACA
1 Toulon 83 176198 Var (doublon) PACA
2 Hyères 83 57635 Var PACA
3 Hyères 83 57635 Var (doublon) PACA
4 Nice 06 342669 Alpes-Maritimes PACA
5 Marseille 13 873076 Bouches-du-Rhône PACA
6 Lyon 69 522250 Rhône Auvergne-Rhône-Alpes

L’argument validate permet de déclarer la relation attendue. Pandas lève alors une erreur si l’hypothèse est fausse, au lieu de produire silencieusement un résultat faux :

# On déclare : plusieurs communes pour un même département, donc "many_to_one"
try:
    pd.merge(communes, departements_doublon, on="dep", validate="many_to_one")
except Exception as e:
    display(Markdown(f"{type(e).__name__} : {e}"))

MergeError : Merge keys are not unique in right dataset; not a many-to-one merge

11.2.4 join : la variante qui travaille sur l’index

La méthode .join() fait la même chose que merge, mais en utilisant par défaut l’index des deux tables plutôt qu’une colonne. C’est commode lorsque la clé est déjà l’index :

communes_idx     = communes.set_index("dep")
departements_idx = departements.set_index("dep")

communes_idx.join(departements_idx)
commune population nom_departement region
dep
06 Nice 342669 Alpes-Maritimes PACA
13 Marseille 873076 Bouches-du-Rhône PACA
69 Lyon 522250 Rhône Auvergne-Rhône-Alpes
83 Toulon 176198 Var PACA
83 Hyères 57635 Var PACA

11.2.5 En résumé

Objectif Outil
Ajouter des lignes (mêmes colonnes) pd.concat([df1, df2])
Ajouter des colonnes, appariées par l’index pd.concat([df1, df2], axis=1)
Ajouter des colonnes, appariées par une clé commune pd.merge(df1, df2, on="cle", how=...)
Ajouter des colonnes, appariées par l’index df1.join(df2)

Et le réflexe à garder : après chaque jointure, vérifiez le nombre de lignes.

4  Series
6  🚢 Une analyse complète : le Titanic

© Copyright 2026, Gloria Faccanoni

 

This page is built with ❤️ and Quarto.