Manipulation de données avec pandas (exercices)

pandas est la librairie incontournable pour manipuler les données. Elle permet de manipuler aussi bien les données sous forme de tables qu’elle peut récupérer ou exporter en différents formats. Elle permet également de créer facilement des graphes.

[1]:
%matplotlib inline

Enoncé

La librairie pandas implémente la classe DataFrame. C’est une structure de table, chaque colonne porte un nom et contient un seul type de données. C’est très similaire au langage SQL.

Création d’un dataframe

Il existe une grande variété pour créer un DataFrame. Voici les deux principaux. Le premier : une liste de dictionnaires. Chaque clé est le nom de la colonne.

[2]:
from pandas import DataFrame

rows = [{"col1": 0.5, "col2": "schtroumph"}, {"col1": 0.6, "col2": "schtroumphette"}]
DataFrame(rows)
[2]:
col1 col2
0 0.5 schtroumph
1 0.6 schtroumphette

La lecture depuis un fichier :

[3]:
text = """col1,col2
0.5,alpha
0.6,beta
"""

with open("data.csv", "w", encoding="utf-8") as f:
    f.write(text)
[4]:
from pandas import read_csv

df = read_csv("data.csv")
df
[4]:
col1 col2
0 0.5 alpha
1 0.6 beta

La maîtrise des index

Les index fonctionnent à peu près comme numpy mais offre plus d’options puisque les colonnes mais aussi les lignes ont un nom.

Accès par colonne

[5]:
df
[5]:
col1 col2
0 0.5 alpha
1 0.6 beta
[6]:
df["col1"]
[6]:
0    0.5
1    0.6
Name: col1, dtype: float64

Les colonnes disponibles :

[7]:
df.columns
[7]:
Index(['col1', 'col2'], dtype='object')
[8]:
df[["col1", "col2"]]
[8]:
col1 col2
0 0.5 alpha
1 0.6 beta

Accès par ligne (uniquement avec :). On se sert principalement de l’opérateur : pour les lignes.

[9]:
df[:1]
[9]:
col1 col2
0 0.5 alpha

Accès par positions avec loc.

[10]:
df.loc[0, "col1"]
[10]:
0.5

Accès par positions entières avec iloc.

[11]:
df.iloc[0, 0]
[11]:
0.5

La maîtrise des index des lignes

La création d’un dataframe donne l’impression que les index des lignes sont des entiers mais cela peut être changer

[12]:
df
[12]:
col1 col2
0 0.5 alpha
1 0.6 beta
[13]:
dfi = df.set_index("col2")
dfi
[13]:
col1
col2
alpha 0.5
beta 0.6
[14]:
dfi.loc["alpha", "col1"]
[14]:
0.5

Il faut se souvenir de cette particularité lors de la fusion de tables.

La maîtrise des index des colonnes

Les colonnes sont nommées.

[15]:
df.columns
[15]:
Index(['col1', 'col2'], dtype='object')

On peut les renommer.

[16]:
df.columns = ["valeur", "nom"]
df
[16]:
valeur nom
0 0.5 alpha
1 0.6 beta

L’opérateur : peut également servir pour les colonnes.

[17]:
df.loc[:, "valeur":"nom"]
[17]:
valeur nom
0 0.5 alpha
1 0.6 beta

Lien vers numpy

pandas utilise numpy pour stocker les données. Il est possible de récupérer des matrices depuis des DataFrame avec values.

[18]:
df.values
[18]:
array([[0.5, 'alpha'],
       [0.6, 'beta']], dtype=object)
[19]:
df[["valeur"]].values
[19]:
array([[0.5],
       [0.6]])

La maîtrise du nan

nan est une convention pour désigner une valeur manquante.

[20]:
rows = [{"col1": 0.5, "col2": "schtroumph"}, {"col2": "schtroumphette"}]
DataFrame(rows)
[20]:
col1 col2
0 0.5 schtroumph
1 NaN schtroumphette

La maîtrise des types

Un dataframe est défini par ses dimensions et chaque colonne a un type potentiellement différent.

[21]:
df.dtypes
[21]:
valeur    float64
nom        object
dtype: object

On peut changer un type, donc convertir toutes les valeurs d’une colonne vers un autre type.

[22]:
import numpy

df["valeur"].astype(numpy.float32)
[22]:
0    0.5
1    0.6
Name: valeur, dtype: float32
[23]:
import numpy

df["valeur"].astype(numpy.int32)
[23]:
0    0
1    0
Name: valeur, dtype: int32

Création de colonnes

On peut facilement créer de nouvelles colonnes.

[24]:
df["sup055"] = df["valeur"] >= 0.55
df
[24]:
valeur nom sup055
0 0.5 alpha False
1 0.6 beta True
[25]:
df["sup055"] = (df["valeur"] >= 0.55).astype(numpy.int64)
df
[25]:
valeur nom sup055
0 0.5 alpha 0
1 0.6 beta 1
[26]:
df["sup055+"] = df["valeur"] + df["sup055"]
df
[26]:
valeur nom sup055 sup055+
0 0.5 alpha 0 0.5
1 0.6 beta 1 1.6

Modifications de valeurs

On peut les modifier une à une en utilisant les index. Les notations sont souvent intuitives. Elles ne seront pas toutes détaillées. Ci-dessous un moyen de modifer certaines valeurs selon une condition.

[27]:
df.loc[df["nom"] == "alpha", "sup055+"] += 1000
df
[27]:
valeur nom sup055 sup055+
0 0.5 alpha 0 1000.5
1 0.6 beta 1 1.6

Une erreur ou warning fréquent

[28]:
rows = [{"col1": 0.5, "col2": "schtroumph"}, {"col1": 1.5, "col2": "schtroumphette"}]
df = DataFrame(rows)
df
[28]:
col1 col2
0 0.5 schtroumph
1 1.5 schtroumphette
[29]:
df1 = df[df["col1"] > 1.0]
df1
[29]:
col1 col2
1 1.5 schtroumphette
[30]:
try:
    df1["col3"] = df1["col1"] + 1.0
except Exception as e:
    # un warning ou une exception selon la version de pandas installée
    print(e)
df1
/tmp/ipykernel_5653/164517544.py:2: SettingWithCopyWarning:
A value is trying to be set on a copy of a slice from a DataFrame.
Try using .loc[row_indexer,col_indexer] = value instead

See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy
  df1["col3"] = df1["col1"] + 1.0
[30]:
col1 col2 col3
1 1.5 schtroumphette 2.5

A value is trying to be set on a copy of a slice from a DataFrame. : Par défaut, l’instruction df[df['col1'] > 1.] ne crée pas un nouveau DataFrame, elle crée ce qu’on appelle une vue pour éviter de copier les données. Le résultat ne contient que l’index des lignes qui ont été sélectionnées et un lien vers le dataframe original. L’avertissement stipule que pandas ne peut pas modifier le dataframe original mais qu’il doit effectuer une copie.

La solution pour faire disparaître ce warning est de copier le dataframe.

[31]:
df2 = df1.copy()
df2["col3"] = df2["col1"] + 1.0

La maîtrise des fonctions

Les fonctions de pandas créent par défaut un nouveau dataframe plutôt que de modifier un dataframe existant. Cela explique pourquoi parfois la mémoire se retrouve congestionnée. La page 10 minutes to pandas est un bon début.

On récupère les données du COVID par région et par âge et premier graphe

A cette adresse : Données hospitalières relatives à l’épidémie de COVID-19

[32]:
# https://www.data.gouv.fr/en/datasets/r/63352e38-d353-4b54-bfd1-f1b3ee1cabd7
from pandas import read_csv

url = "https://www.data.gouv.fr/en/datasets/r/08c18e08-6780-452d-9b8c-ae244ad529b3"
covid = read_csv(url, sep=";")
covid.tail()
[32]:
reg cl_age90 jour hosp rea HospConv SSR_USLD autres rad dc
219577 94 59 2023-03-31 1 0 0.0 1.0 0.0 387 14
219578 94 69 2023-03-31 16 1 8.0 5.0 2.0 536 48
219579 94 79 2023-03-31 29 1 14.0 13.0 1.0 810 129
219580 94 89 2023-03-31 35 0 24.0 11.0 0.0 888 199
219581 94 90 2023-03-31 18 0 6.0 12.0 0.0 388 121
[33]:
covid.dtypes
[33]:
reg           int64
cl_age90      int64
jour         object
hosp          int64
rea           int64
HospConv    float64
SSR_USLD    float64
autres      float64
rad           int64
dc            int64
dtype: object

Les dates sont considérées comme des chaînes de caractères. Il est plus simple pour réaliser des opérations de convertir la colonne sous forme de dates.

[34]:
from pandas import to_datetime

covid["jour"] = to_datetime(covid["jour"])
covid.tail()
[34]:
reg cl_age90 jour hosp rea HospConv SSR_USLD autres rad dc
219577 94 59 2023-03-31 1 0 0.0 1.0 0.0 387 14
219578 94 69 2023-03-31 16 1 8.0 5.0 2.0 536 48
219579 94 79 2023-03-31 29 1 14.0 13.0 1.0 810 129
219580 94 89 2023-03-31 35 0 24.0 11.0 0.0 888 199
219581 94 90 2023-03-31 18 0 6.0 12.0 0.0 388 121
[35]:
covid.dtypes
[35]:
reg                  int64
cl_age90             int64
jour        datetime64[ns]
hosp                 int64
rea                  int64
HospConv           float64
SSR_USLD           float64
autres             float64
rad                  int64
dc                   int64
dtype: object

On supprime les colonnes relatives aux régions et à l’âge puis on aggrège par jour.

[36]:
agg_par_jour = covid.drop(["reg", "cl_age90"], axis=1).groupby("jour").sum()
agg_par_jour.tail()
[36]:
hosp rea HospConv SSR_USLD autres rad dc
jour
2023-03-27 26110 1452 14586.0 9311.0 761.0 1717663 271176
2023-03-28 26239 1435 14707.0 9336.0 761.0 1718630 271254
2023-03-29 26255 1465 14704.0 9323.0 763.0 1719634 271322
2023-03-30 26253 1460 14680.0 9344.0 769.0 1720370 271394
2023-03-31 26174 1413 14658.0 9354.0 749.0 1721380 271456
[37]:
agg_par_jour.plot(title="Evolution des hospitalisations par jour", figsize=(14, 4));
../_images/c_data_nb_pandas_63_0.png

Avec échelle logarithmique.

[38]:
agg_par_jour.plot(
    title="Evolution des hospitalisations par jour", figsize=(14, 4), logy=True
);
../_images/c_data_nb_pandas_65_0.png

Q1 : refaire le graphique précédent pour votre classe d’âge

[ ]:

Q2 : faire de même avec les séries différenciées

[ ]:

Q3 : faire de même avec des séries lissées sur sur 7 jours

[ ]:

Q4 : fusion de tables par départements

[ ]:

Réponses

Q1 : refaire le graphique précédent pour votre classe d’âge

[39]:
set(covid["cl_age90"])
[39]:
{0, 9, 19, 29, 39, 49, 59, 69, 79, 89, 90}
[40]:
covid49 = covid[covid.cl_age90 == 49]
agg_par_jour49 = covid49.drop(["reg", "cl_age90"], axis=1).groupby("jour").sum()
agg_par_jour49.tail()
[40]:
hosp rea HospConv SSR_USLD autres rad dc
jour
2023-03-27 372 29 180.0 110.0 53.0 57846 1554
2023-03-28 377 29 184.0 111.0 53.0 57859 1554
2023-03-29 374 32 177.0 109.0 56.0 57877 1554
2023-03-30 375 34 175.0 111.0 55.0 57889 1554
2023-03-31 373 33 177.0 109.0 54.0 57900 1554
[41]:
agg_par_jour49.plot(
    title="Evolution des hospitalisations par jour\nage=49", figsize=(14, 4), logy=True
);
../_images/c_data_nb_pandas_78_0.png

Q2 : faire de même avec les séries différenciées

[42]:
covid.tail()
[42]:
reg cl_age90 jour hosp rea HospConv SSR_USLD autres rad dc
219577 94 59 2023-03-31 1 0 0.0 1.0 0.0 387 14
219578 94 69 2023-03-31 16 1 8.0 5.0 2.0 536 48
219579 94 79 2023-03-31 29 1 14.0 13.0 1.0 810 129
219580 94 89 2023-03-31 35 0 24.0 11.0 0.0 888 199
219581 94 90 2023-03-31 18 0 6.0 12.0 0.0 388 121
[43]:
diff = covid.drop(["reg", "cl_age90"], axis=1).groupby(["jour"]).sum().diff()
diff.tail(n=2)
[43]:
hosp rea HospConv SSR_USLD autres rad dc
jour
2023-03-30 -2.0 -5.0 -24.0 21.0 6.0 736.0 72.0
2023-03-31 -79.0 -47.0 -22.0 10.0 -20.0 1010.0 62.0
[44]:
diff.plot(title="Séries différenciées", figsize=(14, 4));
../_images/c_data_nb_pandas_82_0.png

Q3 : faire de même avec des séries lissées sur sur 7 jours

[45]:
diff.rolling(7)
[45]:
Rolling [window=7,center=False,axis=0,method=single]
[46]:
roll = diff.rolling(7).mean()
roll.tail(n=2)
[46]:
hosp rea HospConv SSR_USLD autres rad dc
jour
2023-03-30 45.0 -2.000000 34.857143 4.142857 8.000000 701.571429 52.857143
2023-03-31 12.0 -12.142857 18.142857 3.142857 2.857143 719.571429 57.428571
[47]:
roll.plot(title="Séries différenciées lissées", figsize=(14, 4));
../_images/c_data_nb_pandas_86_0.png

Petit aparté

On veut savoir combien de temps les gens restent à l’hôpital avant de sortir, en supposant que le temps de guérison est à peu près identique au temps passé lorsque l’issue est tout autre. Je pensais calculer les corrélations entre la série des décès et celles de réanimations décalées de plusieurs jours en me disant qu’un pic de corrélation pourrait indiquer une sorte de durée moyenne de réanimation.

[48]:
data = agg_par_jour49.diff().rolling(7).mean()
data.tail(n=2)
[48]:
hosp rea HospConv SSR_USLD autres rad dc
jour
2023-03-30 1.714286 0.142857 -1.000000 0.714286 1.857143 11.285714 0.285714
2023-03-31 1.571429 0.285714 -0.285714 0.000000 1.571429 10.714286 0.285714
[49]:
data_last = data.tail(n=90)
cor = []
for i in range(0, 35):
    ts = DataFrame(
        dict(
            rea=data_last.rea,
            dc=data_last.dc,
            dclag=data_last["dc"].shift(i),
            realag=data_last["rea"].shift(i),
        )
    )
    ts_cor = ts.corr()
    cor.append(dict(delay=i, corr_dc=ts_cor.iloc[1, 3], corr_rea=ts_cor.iloc[0, 3]))
DataFrame(cor).set_index("delay").plot(title="Corrélation entre décès et réanimation");
../_images/c_data_nb_pandas_89_0.png

Il apparaît que ces corrélations sont très différentes selon qu’on les calcule sur les dernières données et les premières semaines. Cela semblerait indiquer que les données médicales sont très différentes. On pourrait chercher plusieurs jours mais le plus simple serait sans de générer des données artificielles avec un modèle SIR et vérifier si ce raisonnement tient la route sur des données propres.

Q4 : fusion de tables par départements

On récupère deux jeux de données : * Données hospitalières relatives à l’épidémie de COVID-19 * Indicateurs de suivi de l’épidémie de COVID-19

[50]:
hosp = read_csv(
    "https://www.data.gouv.fr/en/datasets/r/63352e38-d353-4b54-bfd1-f1b3ee1cabd7",
    sep=";",
)
hosp.tail()
[50]:
dep sexe jour hosp rea HospConv SSR_USLD autres rad dc
338240 976 0 2023-03-31 0 0 0.0 0.0 0.0 1766 163
338241 976 1 2023-03-31 0 0 0.0 0.0 0.0 739 100
338242 976 2 2023-03-31 0 0 0.0 0.0 0.0 1002 61
338243 978 0 2023-03-31 0 0 0.0 0.0 0.0 0 0
338244 978 1 2023-03-31 0 0 0.0 0.0 0.0 0 0
[51]:
indic = read_csv(
    "https://www.data.gouv.fr/fr/datasets/r/4acad602-d8b1-4516-bc71-7d5574d5f33e",
    encoding="ISO-8859-1",
)
indic.tail()
/tmp/ipykernel_5653/1911493942.py:1: DtypeWarning: Columns (1) have mixed types. Specify dtype option on import or set low_memory=False.
  indic = read_csv(
[51]:
extract_date departement region libelle_reg libelle_dep tx_incid R taux_occupation_sae tx_pos tx_incid_couleur R_couleur taux_occupation_sae_couleur tx_pos_couleur nb_orange nb_rouge
90390 2020-08-22 84 93 Provence Alpes Côte d'Azur Vaucluse 44.21 NaN 6.3 3.721489 orange NaN vert vert 1 0
90391 2020-08-23 84 93 Provence Alpes Côte d'Azur Vaucluse 44.21 NaN 6.7 3.719256 orange NaN vert vert 1 0
90392 2020-08-28 84 93 Provence Alpes Côte d'Azur Vaucluse 60.61 1.37 9.3 4.524887 rouge orange vert vert 1 1
90393 2020-08-29 84 93 Provence Alpes Côte d'Azur Vaucluse 61.14 NaN 9.1 4.566028 rouge NaN vert vert 0 1
90394 2020-08-30 84 93 Provence Alpes Côte d'Azur Vaucluse 61.50 NaN 9.1 4.570747 rouge NaN vert vert 0 1

Le code suivant explique comment trouver la valeur ISO-8859-1.

[52]:
# import chardet
# with open("indicateurs-covid19-dep.csv", "rb") as f:
#     content = f.read()
# chardet.detect(content)  # {'encoding': 'ISO-8859-1', 'confidence': 0.73, 'language': ''}
[ ]:

Q5 : une carte ?

Tracer une carte n’est jamais simple. Il faut tout d’abord récupérer les coordonnées des départements : Contours des départements français issus d’OpenStreetMap. Ensuite… de ces fichiers ont été extraits les barycentres de chaque département français : departement_french_2018.csv. Ce qui suit est une approximation de carte : on suppose que là où se trouve, les coordonnées longitude et latitude ne sont pas trop éloignées de ce qu’elles pourraient être si elles étaient projetées sur une sphère.

[53]:
dep_pos = read_csv(
    "https://github.com/sdpython/teachpyx/raw/main/_data/departement_french_2018.csv"
)
dep_pos.tail()
[53]:
code_insee nom nuts3 wikipedia surf_km2 DEPLONG DEPLAT
97 56 Morbihan FR524 fr:Morbihan 6870.0 -2.812320 47.846846
98 25 Doubs FR431 fr:Doubs (département) 5256.0 6.362722 47.165964
99 39 Jura FR432 fr:Jura (département) 5049.0 5.697361 46.729368
100 07 Ardèche FR712 fr:Ardèche (département) 5566.0 4.425582 44.752771
101 30 Gard FR812 fr:Gard 5875.0 4.179861 43.993601
[54]:
last_extract_date = max(set(indic.extract_date))
last_extract_date
[54]:
'2022-08-29'
[55]:
indic_last = indic[indic.extract_date == last_extract_date]
merge = indic_last.merge(dep_pos, left_on="departement", right_on="code_insee")
final = merge[["code_insee", "nom", "DEPLONG", "DEPLAT", "taux_occupation_sae", "R"]]
metro = final[final.DEPLAT > 40]
metro
[55]:
code_insee nom DEPLONG DEPLAT taux_occupation_sae R
0 01 Ain 5.348764 46.099799 10.9 NaN
1 03 Allier 3.187644 46.393637 10.9 NaN
2 07 Ardèche 4.425582 44.752771 10.9 NaN
3 15 Cantal 2.669045 45.051247 10.9 NaN
4 26 Drôme 5.167364 44.685239 10.9 NaN
... ... ... ... ... ... ...
67 23 Creuse 2.018230 46.090620 18.7 NaN
68 24 Dordogne 0.741203 45.104948 18.7 NaN
69 33 Gironde -0.575870 44.823614 18.7 NaN
70 40 Landes -0.783793 43.965855 18.7 NaN
71 47 Lot-et-Garonne 0.460747 44.367964 18.7 NaN

67 rows × 6 columns

[56]:
import matplotlib.pyplot as plt

fig, ax = plt.subplots(1, 2, figsize=(14, 4))
bigR1 = metro.R >= 1
bigR2 = metro.R >= 1.4
ax[0].scatter(
    metro.loc[bigR2, "DEPLONG"], metro.loc[bigR2, "DEPLAT"], c="red", label="R>=1.4"
)
ax[0].scatter(
    metro.loc[bigR1 & ~bigR2, "DEPLONG"],
    metro.loc[bigR1 & ~bigR2, "DEPLAT"],
    c="orange",
    label="1.3>=R>=1",
)
ax[0].scatter(
    metro.loc[~bigR1, "DEPLONG"], metro.loc[~bigR1, "DEPLAT"], c="blue", label="R<1"
)
ax[0].legend()

bigR1 = metro.taux_occupation_sae >= 25
bigR2 = metro.taux_occupation_sae >= 45
ax[1].scatter(
    metro.loc[bigR2, "DEPLONG"], metro.loc[bigR2, "DEPLAT"], c="red", label="SAE>=45"
)
ax[1].scatter(
    metro.loc[bigR1 & ~bigR2, "DEPLONG"],
    metro.loc[bigR1 & ~bigR2, "DEPLAT"],
    c="orange",
    label="45>SAE>=25",
)
ax[1].scatter(
    metro.loc[~bigR1, "DEPLONG"], metro.loc[~bigR1, "DEPLAT"], c="blue", label="SAE<25"
)
ax[1].legend();
../_images/c_data_nb_pandas_101_0.png
[57]:
metro[metro.nom == "Ardennes"]
[57]:
code_insee nom DEPLONG DEPLAT taux_occupation_sae R
31 08 Ardennes 4.640751 49.616226 13.5 NaN
[ ]:


Notebook on github