.. _chapter_pandas:
******
Pandas
******
Python et son écosystème sont parfaitement adaptés au traitement des données (Data Science) au point d'en être devenu le standard.
Le package de référence est `Pandas `_.
Il s'installe avec la commande::
$ python -m pip install pandas
Ce qui va suivre est très largement inspiré de la `documentation officielle `_. Il s'agit d'une introduction au concept de data frame dans l'environnement Python. Pour une documentation exhaustive, on consultera le `User Guide `_.
Pour démarrer, la première chose à faire est d'importer le module pandas. L'alias ``pd`` est traditionnellement utilisé::
import pandas as pd
Lecture / écriture de données
=============================
:mod:`pandas` est capable de lire un grand nombre de données structurées avec la méthode :meth:`read_*()`. L'écriture de données dans un fichier utilise la méthode :meth:`to_*()`.
.. image:: ../images/14_io_readwrite1.svg
:width: 75 %
:align: center
Pour cette découverte, nous allons travailler avec les `Iris de Fisher `_. Le jeu de données comprend 50 échantillons de chacune des trois espèces d'iris (Iris setosa, Iris virginica et Iris versicolor). Quatre caractéristiques ont été mesurées à partir de chaque échantillon : la longueur et la largeur des sépales et des pétales.
Les données stockées au format `csv `_ sont disponibles dans le fichier :download:`iris.csv <../data/iris.csv>`. La lecture se fait avec la méthode :meth:`~pandas.read_csv()` qui prend en argument un fichier ``csv`` et retourne une data frame::
>>> iris = pd.read_csv("iris.csv")
>>> type(iris)
La :class:`DataFrame`
=====================
Pandas permet l'utilisation de structures de données bi dimensionnelles, de type tableur. Ces structures s'appellent des :class:`~pandas.DataFrame`.
.. image:: ../images/14_table_dataframe1.svg
:width: 75 %
:align: center
La méthode :meth:`~pandas.DataFrame.describe()` renseigne sur les données contenues dans cette data frame. Les statistiques descriptives sont utilisées pour les données numériques::
>>> iris.describe()
sepal.length sepal.width petal.length petal.width
count 150.000000 150.000000 150.000000 150.000000
mean 5.843333 3.057333 3.758000 1.199333
std 0.828066 0.435866 1.765298 0.762238
min 4.300000 2.000000 1.000000 0.100000
25% 5.100000 2.800000 1.600000 0.300000
50% 5.800000 3.000000 4.350000 1.300000
75% 6.400000 3.300000 5.100000 1.800000
max 7.900000 4.400000 6.900000 2.500000
L'attribut :meth:`~pandas.DataFrame.values` retourne l'ensemble structuré des valeurs de la data frame sous la forme d'un :class:`numpy.ndarray`.
>>> v = iris.values
>>> type(v)
En utilisant l'opérateur d'indexation::
>>> v[0:10]
array([[5.1, 3.5, 1.4, 0.2, 'Setosa'],
[4.9, 3.0, 1.4, 0.2, 'Setosa'],
[4.7, 3.2, 1.3, 0.2, 'Setosa'],
[4.6, 3.1, 1.5, 0.2, 'Setosa'],
[5.0, 3.6, 1.4, 0.2, 'Setosa'],
[5.4, 3.9, 1.7, 0.4, 'Setosa'],
[4.6, 3.4, 1.4, 0.3, 'Setosa'],
[5.0, 3.4, 1.5, 0.2, 'Setosa'],
[4.4, 2.9, 1.4, 0.2, 'Setosa'],
[4.9, 3.1, 1.5, 0.1, 'Setosa']], dtype=object)
L'attribut :meth:`~pandas.DataFrame.dtypes` renseigne sur le type des données contenues dans la data frame.
>>> iris.dtypes
sepal.length float64
sepal.width float64
petal.length float64
petal.width float64
variety object
dtype: object
L'attribut :meth:`~pandas.DataFrame.axes` contient la nature et la dimension des axes de la data frame.
>>> iris.axes
[RangeIndex(start=0, stop=150, step=1), Index(['sepal.length', 'sepal.width', 'petal.length', 'petal.width',
'variety'],
dtype='object')]
Les attributs :meth:`~pandas.DataFrame.ndim`, :meth:`~pandas.DataFrame.size` et :meth:`~pandas.DataFrame.shape` renseignent également sur la structure de la dataframe.
>>> iris.ndim, iris.size, iris.shape
(2, 750, (150, 5))
Création
--------
Les données sont le plus souvent contenues dans des fichiers externes que l'on lit avec la méthode :meth:`read_*()`. Il est quelquefois nécessaire de créer la data frame à partir de données calculées.
Une façon simple est d'utiliser le constructeur auquel on passe un dictionnaire pour lequel:
- les clés sont les variables d'observation
- les valeurs sont les observations structurées sous forme de séquences.
>>> df = pd.DataFrame({
... "Name": ["Braund, Mr. Owen Harris",
... "Allen, Mr. William Henry",
... "Bonnell, Miss. Elizabeth"],
... "Age": [22, 35, 58],
... "Sex": ["male", "male", "female"]}
... )
>>> df
Name Age Sex
0 Braund, Mr. Owen Harris 22 male
1 Allen, Mr. William Henry 35 male
2 Bonnell, Miss. Elizabeth 58 female
La variable entière qui précède chaque ligne est appelée ``index`` ou ``label``.
Les :class:`Series`
===================
Dans la terminologie Pandas, chaque colonne est une :class:`~pandas.Series` et est accessible avec l'opérateur d'indexation. Contrairement à une simple liste Python, une :class:`~pandas.Series` est une structure de données unidimensionnelle pour laquelle tous les éléments ont le même type (:class:`dtype` est unique: int64, float64, object, ...), ce qui rend les opérations d'accès et de calcul très efficaces. En fait Pandas est construit sur le module `Numpy `__.
Une data frame peut être vue comme une juxtaposition de :class:`~pandas.Series` de même dimension.
.. image:: ../images/14_table_series.svg
:width: 40 %
:align: center
Extrayons une colonne de la data frame :class:`iris`.
>>> pl = iris["petal.length"]
>>> pl
0 1.4
1 1.4
2 1.3
3 1.5
4 1.4
...
145 5.2
146 5.0
147 5.2
148 5.4
149 5.1
Name: petal.length, Length: 150, dtype: float64
La colonne est bien de type :class:`~pandas.Series`.
>>> type(pl)
Une :class:`~pandas.Series` est un objet à part entière et, comme la data frame peut être éventuellement créé avec le constructeur.
>>> ages = pd.Series([22, 35, 58], name="Age")
>>> ages
0 22
1 35
2 58
Name: Age, dtype: int64
>>> type(ages)
Les attributs
-------------
Les attributs renseignent sur la taille, le type, le contenu, etc... de la :class:`~pandas.Series`.
>>> pl.size, pl.dtype
(150, dtype('float64'))
>>> pl.values
array([1.4, 1.4, 1.3, 1.5, 1.4, 1.7, 1.4, 1.5, 1.4, 1.5, 1.5, 1.6, 1.4,
1.1, 1.2, 1.5, 1.3, 1.4, 1.7, 1.5, 1.7, 1.5, 1. , 1.7, 1.9, 1.6,
1.6, 1.5, 1.4, 1.6, 1.6, 1.5, 1.5, 1.4, 1.5, 1.2, 1.3, 1.4, 1.3,
1.5, 1.3, 1.3, 1.3, 1.6, 1.9, 1.4, 1.6, 1.4, 1.5, 1.4, 4.7, 4.5,
4.9, 4. , 4.6, 4.5, 4.7, 3.3, 4.6, 3.9, 3.5, 4.2, 4. , 4.7, 3.6,
4.4, 4.5, 4.1, 4.5, 3.9, 4.8, 4. , 4.9, 4.7, 4.3, 4.4, 4.8, 5. ,
4.5, 3.5, 3.8, 3.7, 3.9, 5.1, 4.5, 4.5, 4.7, 4.4, 4.1, 4. , 4.4,
4.6, 4. , 3.3, 4.2, 4.2, 4.2, 4.3, 3. , 4.1, 6. , 5.1, 5.9, 5.6,
5.8, 6.6, 4.5, 6.3, 5.8, 6.1, 5.1, 5.3, 5.5, 5. , 5.1, 5.3, 5.5,
6.7, 6.9, 5. , 5.7, 4.9, 6.7, 4.9, 5.7, 6. , 4.8, 4.9, 5.6, 5.8,
6.1, 6.4, 5.6, 5.1, 5.6, 6.1, 5.6, 5.5, 4.8, 5.4, 5.6, 5.1, 5.1,
5.9, 5.7, 5.2, 5. , 5.2, 5.4, 5.1])
Les opérations vectorisées
--------------------------
:mod:`pandas` autorise la "vectorisation" des opérations sur les séquences, ce qui assure:
- une écriture compacte et lisible
- une grande efficacité algorithmique
Une liste exhaustive de ce type d'opération `ici `__.
Pour illustrer le concept d'opération vectorisée, on souhaite normaliser les données (cm) pour les rendres compatibles avec le `Système International `_, il faut les passer en mètres. La vectorisation évite d'itérer sur l'ensemble des éléments.
>>> pl_m = pl.mul(0.01)
>>> pl_m
0 0.014
1 0.014
2 0.013
3 0.015
4 0.014
...
145 0.052
146 0.050
147 0.052
148 0.054
149 0.051
Name: petal.length, Length: 150, dtype: float64
La conversion de type
---------------------
La lecture de données hétérogènes conduit parfois à un type qui n'est pas représentatif de la nature de ces données. Un cas courant est de récupérer des données numériques sous la forme de chaines de caractères qui ne se prêtent donc pas aux opérations numériques.
Pour :mod:`pandas`, une chaine de caractères est de type ``object``.
>>> ages = pd.Series(["22", "35", "58"], name="Age")
>>> ages
0 22
1 35
2 58
Name: Age, dtype: object
Les données ont été lues comme des chaînes de caractères et ne sont pas éligibles à un traitement numérique. Pire, l'opération peut se dérouler sans erreur et retourner une valeur aberrante :
>>> ages.mean()
74519.33333333333
Avant traitement, il faut donc convertir le type :class:`object` en un type numérique.
Les opérations de conversion sont décrites `ici `__.
>>> ages = ages.astype('int64')
>>> ages
0 22
1 35
2 58
Name: Age, dtype: int64
Le type des données a bien été modifié et maintenant les opérations numériques sont légitimes:
>>> ages.mean()
38.333333333333336
Indexation et itération
-----------------------
L'indexation est l'opération qui permet de sélectionner un sous ensemble des données d'une :class:`~pandas.Series`. L'itération est l'opération qui permet de parcourir tout ou partie des éléments d'une :class:`~pandas.Series`.
Les opérations d'indexation et d'itération sont décrites `ici `__.
>>> pl.at[100]
6.0
Pour cet exemple les index et les positions se confondent et les opérations de type :meth:`~pandas.Series.at` ou :meth:`~pandas.Series.iat` conduisent au même résultat.
On peut également accéder à un sous ensemble de valeurs. Attention, la convention de slice utilisée ici est différente de celle de Python : le dernier indice est inclus.
>>> pl.loc[40:50]
40 1.3
41 1.3
42 1.3
43 1.6
44 1.9
45 1.4
46 1.6
47 1.4
48 1.5
49 1.4
50 4.7
Name: petal.length, dtype: float64
:meth:`~pandas.Series.items` retourne un objet :class:`zip` constitué de paires (index, value) sur lequel on peut itérer:
>>> for index, value in pl.loc[40:50].items():
... print(index, '-', value)
...
40 - 1.3
41 - 1.3
42 - 1.3
43 - 1.6
44 - 1.9
45 - 1.4
46 - 1.6
47 - 1.4
48 - 1.5
49 - 1.4
50 - 4.7
:meth:`~pandas.Series.keys` retourne la séquence d'index.
>>> pl.keys()
RangeIndex(start=0, stop=150, step=1)
Application d'une fonction
--------------------------
:meth:`~pandas.Series.apply` permet d'appliquer la fonction passée en argument à chaque élément de la :class:`~pandas.Series`.
>>> import math
>>> pl.loc[40:50].apply(math.sqrt)
40 1.140175
41 1.140175
42 1.140175
43 1.264911
44 1.378405
45 1.183216
46 1.264911
47 1.183216
48 1.224745
49 1.183216
50 2.167948
Name: petal.length, dtype: float64
Maths & Stats
-------------
La vectorisation est disponible pour les opérations mathématiques et statistiques.
Les opérations mathématiques et statistiques sont décrites `ici `__.
>>> pl.sum()
563.7
>>> pl.cumsum()
0 1.4
1 2.8
2 4.1
3 5.6
4 7.0
...
145 543.0
146 548.0
147 553.2
148 558.6
149 563.7
Name: petal.length, Length: 150, dtype: float64
>>> pl.max(), pl.min()
(6.9, 1.0)
>>> pl.mean(), pl.median()
(3.7580000000000005, 4.35)
Sélection de données
====================
Le traitement des données implique des mécanismes de sélection d'un sous ensemble de celles ci. Une information complète sur ce mécanisme est fournie `ici `__.
Sélection de variables
----------------------
.. image:: ../images/14_subset_columns.svg
:width: 75 %
:align: center
Il s'agit ici de sélectionner une ou plusieurs variables (les colonnes de la dataframe) pour l'ensemble des observations.
La sélection par colonne utilise l'opérateur d'indexation. On passe le nom de colonne (ou la séquence de noms) en argument. ::
>>> iris["petal.length"]
0 1.4
1 1.4
2 1.3
3 1.5
4 1.4
...
145 5.2
146 5.0
147 5.2
148 5.4
149 5.1
Name: petal.length, Length: 150, dtype: float64
>>> iris[ ["petal.length", "petal.width"]]
petal.length petal.width
0 1.4 0.2
1 1.4 0.2
2 1.3 0.2
3 1.5 0.2
4 1.4 0.2
.. ... ...
145 5.2 2.3
146 5.0 1.9
147 5.2 2.0
148 5.4 2.3
149 5.1 1.8
[150 rows x 2 columns]
On peut forger une séquence de noms de colonnes avec un prédicat. La ``list comprehension`` de Python est particulièrement efficace :
>>> c = [ name for name in iris.columns if "petal" in name ]
>>> c
['petal.length', 'petal.width']
>>> iris[c]
petal.length petal.width
0 1.4 0.2
1 1.4 0.2
2 1.3 0.2
3 1.5 0.2
4 1.4 0.2
.. ... ...
145 5.2 2.3
146 5.0 1.9
147 5.2 2.0
148 5.4 2.3
149 5.1 1.8
[150 rows x 2 columns]
Sélection d'observations
------------------------
.. image:: ../images/14_subset_rows.svg
:width: 75 %
:align: center
Il s'agit ici de sélectionner les observations (les lignes de la dataframe) qui correspondent à un ou plusieurs critères sur les variables.
L'opérateur d'indexation est utilisé. Pour une sélection inconditionnelle, on passe le range d'index en argument.
>>> iris[::2]
sepal.length sepal.width petal.length petal.width variety
0 5.1 3.5 1.4 0.2 Setosa
2 4.7 3.2 1.3 0.2 Setosa
4 5.0 3.6 1.4 0.2 Setosa
6 4.6 3.4 1.4 0.3 Setosa
8 4.4 2.9 1.4 0.2 Setosa
.. ... ... ... ... ...
140 6.7 3.1 5.6 2.4 Virginica
142 5.8 2.7 5.1 1.9 Virginica
144 6.7 3.3 5.7 2.5 Virginica
146 6.3 2.5 5.0 1.9 Virginica
148 6.2 3.4 5.4 2.3 Virginica
[75 rows x 5 columns]
Pour une sélection conditionnelle, on passe un prédicat en argument. Ici on sélectionne toutes les observations (les lignes) pour lesquelles la longueur du pétale est supérieure à ``6`` :
>>> iris[ iris["petal.length"] > 6 ]
sepal.length sepal.width petal.length petal.width variety
105 7.6 3.0 6.6 2.1 Virginica
107 7.3 2.9 6.3 1.8 Virginica
109 7.2 3.6 6.1 2.5 Virginica
117 7.7 3.8 6.7 2.2 Virginica
118 7.7 2.6 6.9 2.3 Virginica
122 7.7 2.8 6.7 2.0 Virginica
130 7.4 2.8 6.1 1.9 Virginica
131 7.9 3.8 6.4 2.0 Virginica
135 7.7 3.0 6.1 2.3 Virginica
Un prédicat complexe peut être utilisé. Ici on sélectionne toutes les observations (les lignes) pour lesquelles la longueur du pétale est supérieure à ``6`` et la largeur du sépale inférieure à ``3`` :
>>> iris[ (iris["petal.length"] > 6) & (iris["sepal.width"] < 3) ]
sepal.length sepal.width petal.length petal.width variety
107 7.3 2.9 6.3 1.8 Virginica
118 7.7 2.6 6.9 2.3 Virginica
122 7.7 2.8 6.7 2.0 Virginica
130 7.4 2.8 6.1 1.9 Virginica
On peut imaginer grouper la sélection des lignes et des colonnes en une seule opération. Mais attention, le faire sans précaution peut conduire à un message d'alerte, même si le résultat est correct:
>>> iris.loc[110:130][ iris["petal.length"] > 6 ]
__main__:1: UserWarning: Boolean Series key will be reindexed to match DataFrame index.
sepal.length sepal.width petal.length petal.width variety
117 7.7 3.8 6.7 2.2 Virginica
118 7.7 2.6 6.9 2.3 Virginica
122 7.7 2.8 6.7 2.0 Virginica
130 7.4 2.8 6.1 1.9 Virginica
L'origine de ce warning est que les tailles des structures de données mises en jeu diffèrent.
L'expression ``iris["petal.length"] > 6`` est un masque booléen de taille la longueur de la data frame ``iris``::
>>> mask = iris["petal.length"] > 6
>>> mask.dtype
dtype('bool')
>>> len(mask)
150
L'expression ``iris.loc[110:130]`` est un sous ensemble (de taille différente) de la data frame ``iris``::
>>> len(iris.loc[110:130])
21
On préfèrera traiter le problème en séparant distinctement les deux opérations ::
>>> subset = iris.loc[110:130]
>>> subset[subset["petal.length"] > 6]
sepal.length sepal.width petal.length petal.width variety
117 7.7 3.8 6.7 2.2 Virginica
118 7.7 2.6 6.9 2.3 Virginica
122 7.7 2.8 6.7 2.0 Virginica
130 7.4 2.8 6.1 1.9 Virginica
La méthode :meth:`~pandas.DataFrame.query`
------------------------------------------
La sélection d'observations est parfaitement réalisée avec l'opérateur d'indexation comme on l'a vu précédemment. Mais la syntaxe est lourde :
>>> iris[ (iris["petal.length"] > 6) & (iris["sepal.width"] < 3) ]
sepal.length sepal.width petal.length petal.width variety
107 7.3 2.9 6.3 1.8 Virginica
118 7.7 2.6 6.9 2.3 Virginica
122 7.7 2.8 6.7 2.0 Virginica
130 7.4 2.8 6.1 1.9 Virginica
On lui préférera l'utilisation de la méthode :meth:`~pandas.DataFrame.query` à laquelle on passe le prédicat exprimé dans une chaîne de caractères.
La chaine de caractère passée en argument est interprétée comme une expression Python. Ici le nom des variables originales est un problème puisque le ``.`` séparateur a une signification en Python. Evaluer l'expression ``petal.length > 6`` signifierait un accès à l'attribut ``length`` de l'objet ``petal`` et conduirait à une erreur.
Renommons les variables (les colonnes) de façon non ambigües :
>>> iris.rename(columns={ "sepal.length": "sepal_length",
... "sepal.width": "sepal_width",
... "petal.length": "petal_length",
... "petal.width": "petal_width"},
inplace = true)
Le paramètre ``inplace`` permet de créer une nouvelle :class:`~pandas.DataFrame` lorsqu'il est ``False``. C'est la valeur par défaut. S'il est ``True`` la :class:`~pandas.DataFrame` sur laquelle la méthode :meth:`~pandas.DataFrame.query` est appelée est modifiée.
>>> iris
sepal_length sepal_width petal_length petal_width variety
0 5.1 3.5 1.4 0.2 Setosa
1 4.9 3.0 1.4 0.2 Setosa
2 4.7 3.2 1.3 0.2 Setosa
3 4.6 3.1 1.5 0.2 Setosa
4 5.0 3.6 1.4 0.2 Setosa
.. ... ... ... ... ...
145 6.7 3.0 5.2 2.3 Virginica
146 6.3 2.5 5.0 1.9 Virginica
147 6.5 3.0 5.2 2.0 Virginica
148 6.2 3.4 5.4 2.3 Virginica
149 5.9 3.0 5.1 1.8 Virginica
[150 rows x 5 columns]
La sélection d'observation est maintenant plus compacte, donc plus lisible :
>>> iris.query(" petal_length > 6 & sepal_width < 3")
sepal_length sepal_width petal_length petal_width variety
107 7.3 2.9 6.3 1.8 Virginica
118 7.7 2.6 6.9 2.3 Virginica
122 7.7 2.8 6.7 2.0 Virginica
130 7.4 2.8 6.1 1.9 Virginica
Par la suite on privilégiera donc la méthode :meth:`~pandas.DataFrame.query` à l'opérateur d'indexation.
.. note::
l'utilisation de variables est autorisée à l'intérieur de la chaine qui exprime la requête en préfixant celle ci de ``@``.
Création de colonne
===================
Il est parfois nécessaire de construire des données additionnelles à celles lues avec la méthode :meth:`read_*` et d'intégrer celles ci à la data frame initiale. Ici encore l'opérateur d'indexation est utilisé.
A titre d'exemple on peut ajouter un `rapport de forme `_ pour les pétales et les sépales :
>>> iris["sepal_rf"] = iris["sepal_length"] / iris["sepal_width"]
>>> iris
sepal_length sepal_width petal_length petal_width variety sepal_rf
0 5.1 3.5 1.4 0.2 Setosa 1.457143
1 4.9 3.0 1.4 0.2 Setosa 1.633333
2 4.7 3.2 1.3 0.2 Setosa 1.468750
3 4.6 3.1 1.5 0.2 Setosa 1.483871
4 5.0 3.6 1.4 0.2 Setosa 1.388889
.. ... ... ... ... ... ...
145 6.7 3.0 5.2 2.3 Virginica 2.233333
146 6.3 2.5 5.0 1.9 Virginica 2.520000
147 6.5 3.0 5.2 2.0 Virginica 2.166667
148 6.2 3.4 5.4 2.3 Virginica 1.823529
149 5.9 3.0 5.1 1.8 Virginica 1.966667
[150 rows x 6 columns]
On peut également renommer une ou plusieurs colonnes avec la méthode :meth:`~pandas.DataFrame.rename`.
Statistiques descriptives
=========================
Les méthodes statistiques évoquées pour les :class:`~pandas.Series` s'appliquent évidemment aux data frames.
Lorsqu'on sélectionne une seule colonne, on retrouve le comportement évoqué plus haut :
>>> iris["petal_length"].mean()
3.7580000000000005
Lorsqu'on sélectionne plusieurs colonnes, la méthode statistique est appliquée à chacune :
>>> iris[ ["petal_length", "petal_width"] ].mean()
petal_length 3.758000
petal_width 1.199333
dtype: float64
Rappelons que la méthode :meth:`~pandas.DataFrame.describe` applique un ensemble de méthodes statistiques sur les valeurs numériques d'une data frame, ou d'une portion de cette data frame.
>>> iris[ ["petal_length", "petal_width"] ].describe()
petal_length petal_width
count 150.000000 150.000000
mean 3.758000 1.199333
std 1.765298 0.762238
min 1.000000 0.100000
25% 1.600000 0.300000
50% 4.350000 1.300000
75% 5.100000 1.800000
max 6.900000 2.500000
La méthode :meth:`~pandas.DataFrame.agg` permet de personnaliser les opérations appliquées sur chacune des colonnes :
>>> iris.agg( { "petal_length" : [ 'min', 'max'],
... "petal_width" : [ 'mean', 'median']})
petal_length petal_width
max 6.9 NaN
mean NaN 1.199333
median NaN 1.300000
min 1.0 NaN
On note ici l'utilisation de ``NaN`` (Not a Number) lorsque les résultats ne sont pas disponibles. Il est employé par exemple lorsqu'une donnée est manquante. ``NaN`` fait l'objet d'un traitement particulier dans les opérations sur les data frames. On peut par exemple exclure les observations qui contiennent des données manquantes avec la méthode :meth:`~pandas.DataFrame.dropna`.
Groupement par catégories
=========================
Le dataset ``iris`` contient des données pour trois catégories : Setosa, Versicolor et Virginica. Il est intéressant de disposer d'un mécanisme permettant de faire aisément quelques opérations statistiques sur des groupements de données.
On utilise pour cela la méthode :meth:`~pandas.DataFrame.groupby` en lui passant en argument la variable catégorielle utilisée pour le groupement. Si l'argument est une séquence de variables catégorielles, toutes les combinaisons de groupements sont générées.
>>> iris.groupby("variety").mean()
sepal_length sepal_width petal_length petal_width sepal_rf
variety
Setosa 5.006 3.428 1.462 0.246 1.470188
Versicolor 5.936 2.770 4.260 1.326 2.160402
Virginica 6.588 2.974 5.552 2.026 2.230453
La méthode :meth:`~pandas.DataFrame.groupby` regroupe en fait trois opérations courantes dans le traitement des données (split-apply-combine):
- Split sépare les données en sous groupes
- Apply applique une fonction indépendamment à chaque groupe
- Combine regroupe les résultats dans une data frame
On peut évidemment sélectionner un sous ensemble des données produites par :meth:`~pandas.DataFrame.groupby` avant application de la méthode statistique :
>>> iris.groupby("variety")[ ["petal_length", "petal_width"] ].mean()
petal_length petal_width
variety
Setosa 1.462 0.246
Versicolor 4.260 1.326
Virginica 5.552 2.026
Compter le nombre d'observations
================================
Il est souvent nécessaires de dénombrer le nombre d'observations relatives à une catégorie.
La méthode :meth:`~pandas.Series.value_counts` est utilisée.
>>> iris["variety"].value_counts()
Setosa 50
Virginica 50
Versicolor 50
Name: variety, dtype: int64
Restructurer la data frame
==========================
Pour faciliter le traitement, il est parfois utile de modifier l'ordonnancement des observations dans une data frame ou de changer sa structure.
La méthode :meth:`~pandas.DataFrame.sort_values` peut être utilisée pour modifier l'ordonnancement sans modifier la structure.
>>> iris.sort_values(by="sepal_width")
sepal_length sepal_width petal_length petal_width variety sepal_rf
60 5.0 2.0 3.5 1.0 Versicolor 2.500000
62 6.0 2.2 4.0 1.0 Versicolor 2.727273
119 6.0 2.2 5.0 1.5 Virginica 2.727273
68 6.2 2.2 4.5 1.5 Versicolor 2.818182
41 4.5 2.3 1.3 0.3 Setosa 1.956522
.. ... ... ... ... ... ...
16 5.4 3.9 1.3 0.4 Setosa 1.384615
14 5.8 4.0 1.2 0.2 Setosa 1.450000
32 5.2 4.1 1.5 0.1 Setosa 1.268293
33 5.5 4.2 1.4 0.2 Setosa 1.309524
15 5.7 4.4 1.5 0.4 Setosa 1.295455
[150 rows x 6 columns]
On peut vouloir également modifier la structure de la data frame. Il existe deux formats de data frame.
Format ``long``
---------------
Le format ``long`` présente les données avec une seule observation par ligne. Il est bien adapté pour le traitement automatisé mais n'est pas très pratique pour l'affichage car il contient un nombre de lignes plus important que le format ``wide`` utilisé jusqu'à présent dans ce cours.
Ce format est requis par certains packages de visualisation pour faciliter l'affichage.
Le passage du format ``wide`` au format ``long`` se fait avec la fonction :func:`~pandas.melt` en passant dans l'argument ``id_vars`` le nom de la colonne à inclure. Les autres colonnes fournissent les paires (variable, value).
Cette opération crée un nouvel index et la relation entre les 4 variables et le specimen de fleur est perdu. La taille de la data frame créée valide la procédure.
>>> iris_long = pd.melt(iris, id_vars=['variety'])
>>> iris_long
variety variable value
0 Setosa sepal_length 5.100000
1 Setosa sepal_length 4.900000
2 Setosa sepal_length 4.700000
3 Setosa sepal_length 4.600000
4 Setosa sepal_length 5.000000
.. ... ... ...
745 Virginica sepal.rf 2.233333
746 Virginica sepal.rf 2.520000
747 Virginica sepal.rf 2.166667
748 Virginica sepal.rf 1.823529
749 Virginica sepal.rf 1.966667
[750 rows x 3 columns]
On peut souhaiter conserver l'index initial comme une variable d'observation et conserver la relation entre les 4 variables de taille.
>>> iris_long = pd.melt(iris.reset_index(), id_vars=['index'])
>>> iris_long
index variable value
0 0 sepal_length 5.1
1 1 sepal_length 4.9
2 2 sepal_length 4.7
3 3 sepal_length 4.6
4 4 sepal_length 5
.. ... ... ...
895 145 sepal_rf 2.23333
896 146 sepal_rf 2.52
897 147 sepal_rf 2.16667
898 148 sepal_rf 1.82353
899 149 sepal_rf 1.96667
[900 rows x 3 columns]
Format ``wide``
---------------
Le format ``wide`` présente les données avec plusieurs observations par ligne. C'est le cas du dataset initial ``iris``. C'est une présentation compacte adaptée à la présentation textuelle des données.
Le passage du format ``long`` au format ``wide`` se fait avec la méthode :meth:`~pandas.DataFrame.pivot` en passant dans l'argument ``index`` le nom de la colonne utilisée comme index, dans l'argument ``columns`` le nom des colonnes à créer et dans l'argument ``values`` le nom de la colonne contenant les valeurs.
>>> iris_long.pivot(index='index', columns="variable", values="value")
variable petal_length petal_width sepal_length sepal_rf sepal_width variety
index
0 1.4 0.2 5.1 1.45714 3.5 Setosa
1 1.4 0.2 4.9 1.63333 3 Setosa
2 1.3 0.2 4.7 1.46875 3.2 Setosa
3 1.5 0.2 4.6 1.48387 3.1 Setosa
4 1.4 0.2 5 1.38889 3.6 Setosa
... ... ... ... ... ... ...
145 5.2 2.3 6.7 2.23333 3 Virginica
146 5 1.9 6.3 2.52 2.5 Virginica
147 5.2 2 6.5 2.16667 3 Virginica
148 5.4 2.3 6.2 1.82353 3.4 Virginica
149 5.1 1.8 5.9 1.96667 3 Virginica
[150 rows x 6 columns]
Application
===========
Utiliser Pandas pour traiter le :ref:`test-python`.
A la lecture des données, utiliser l'argument ``dtype`` pour forcer le type de chaque colonne à ``pd.StringDtype()``, excepté pour le comptage de population où le type sera ``pd.Int64Dtype()``.
Les fonctions :func:`build_list_departements` et :func:`build_list_communes` seront construites à partir d'une seule fonction :func:`build_list` qui prendra en argument la dataframe et le nom de la colonne à lire. On pourra utiliser un mapping global pour simplifier l'accès aux colonnes. Vérifier que la taille des objets retournés est cohérente.
.. code-block:: python
print(build_list(df, NOD))
print(build_list(df, NOCAM))
..
def build_list(data, key):
return sorted(data[key].dropna().unique())
La fonction :func:`get_pop_commune` utilisera la méthode :meth:`query`.
.. code-block:: python
>>> print(get_pop_commune(data, "Limoges"))
('Limoges', 133742, '2018')
..
def get_pop_commune(data, commune):
query_key = NOCAM
print(query_key)
result = data\
.dropna()\
.query(f"`{NOCAM}` == @commune")\
.sort_values(by=AR, ascending=[False])\
.iloc[0]
return ( result[NOCAM], # Nom de la commune
int(result[PT]), # Population totale en int
result[AR]) # Année de recensement en str
La fonction :func:`stat_by_dpt` utilisera la méthode d'aggrégation.
.. code-block:: python
>>> print(stat_by_dpt(df, "Haute-Vienne", '2018'))
Population totale 380092
dtype: int64
..
def stat_by_dpt(data, dpt):
result = data\
.dropna()\
.query(f"`{NOD}` == @dpt")\
.query(f"`{AR}` == @year")\
.agg({PT: 'sum'}) # Sum population for all communes
return result
Ecrire une requête permettant d'identifier les 5 premières (par ordre alphabétique) communes du département de l'Isère avec pour chacune la ``Population totale`` maximale constatée et l'``Année de recensement`` correspondante.
.. list-table:: Résultat de la requête
:widths: 20 20 20 20 20
:header-rows: 1
* - Index
- Nom Officiel Département
- Nom Officiel Commune / Arrondissement Municipal
- Population totale
- Année de recensement
* - 89540
- Isère
- Agnin
- 1148
- 2018
* - 94818
- Isère
- Allemond
- 1032
- 2015
* - 92443
- Isère
- Allevard
- 4249
- 2016
* - 71899
- Isère
- Ambel
- 28
- 2017
* - 112212
- Isère
- Anjou
- 1036
- 2017
.. return df.loc[df.groupby(NOCAM)[PT].idxmax()]
Utiliser la méthode :meth:`query` pour écrire des requêtes simples, permettant de filtrer sur chacune des colonnes de la dataframe. Par exemple, les communes de Haute-Vienne dont la population totale en 2018 est comprise entre 5000 et 10000 habitants.
.. list-table:: Résultat de la requête
:widths: 20 20 20 20 20
:header-rows: 1
* - Index
- Nom Officiel Département
- Nom Officiel Commune / Arrondissement Municipal
- Population totale
- Année de recensement
* - 3784
- Haute-Vienne
- Feytiat
- 6195
- 2018
* - 18857
- Haute-Vienne
- Couzeix
- 9507
- 2018
* - 25633
- Haute-Vienne
- Le Palais-sur-Vienne
- 6102
- 2018
* - 46501
- Haute-Vienne
- Ambazac
- 5704
- 2018
* - 50327
- Haute-Vienne
- Aixe-sur-Vienne
- 5898
- 2018
* - 66078
- Haute-Vienne
- Verneuil-sur-Vienne
- 5101
- 2018
* - 100839
- Haute-Vienne
- Condat-sur-Vienne
- 5212
- 2018
* - 110443
- Haute-Vienne
- Isle
- 7929
- 2018
* - 113234
- Haute-Vienne
- Saint-Yrieix-la-Perche
- 7161
- 2018
..
dpt = "Haute-Vienne"
result = df.query(f" `{NOD}` == @dpt") \
.query(f" `{AR}` == '2018'") \
.query(f" `{PT}` > 5000 \
and `{PT}` < 10000 \
")
display(result)
Ecrire une requête plus complexe pour identifier les 5 communes de Gironde dont la population a le plus augmenté en pourcentage entre 2015 et 2018.
..
dpt = "Gironde"
ref_year = '2015'
curr_year = '2018'
# Filter DataFrame for only the dpt, the reference year and the current year
# and pivot it to include data for both years
result = df.query(f"`{AR}` == @ref_year or `{AR}` == @curr_year") \
.query(f" `{NOD}` == @dpt") \
.pivot(index=[COCAM, NOCAM], columns=AR, values=PT)
# Compute the difference between current year and reference year
result['pop_change'] = result[curr_year] - result[ref_year]
result['pop_change_percent'] = (result[curr_year] - result[ref_year]) / result[ref_year] * 100
# Query for cities where population increased
result = result.query("pop_change_percent > 0") \
.sort_values(by='pop_change_percent', ascending=False)
print(result[[ref_year, curr_year, 'pop_change_percent']].head())
.. list-table:: Résultat de la requête
:widths: 20 20 20 20 20
:header-rows: 1
* - Code Officiel Commune / Arrondissement Municipal
- Nom Officiel Commune / Arrondissement Municipal
- 2015
- 2018
- Evolution en %
* - 33494
- Salaunes
- 936
- 1141
- 21.90
* - 33070
- Brach
- 615
- 746
- 21.30
* - 33084
- Cambes
- 1465
- 1698
- 15.90
* - 33212
- Labescau
- 107
- 124
- 15.89
* - 33452
- Saint-Michel-de-Rieufret
- 716
- 827
- 15.50
.. note::
Les structures de données de base de Python utilisées dans le :ref:`test-python` ne sont pas adaptée à un requêtage complexe.