Cours Python · Module 8: Data science

Pandas - le carnet de terrain qui compte tout seul

40 min de lecture
Dans cette leçon20

Tu rentres de ta tournée et ton carnet contient quatre lignes. Lion : 120 individus, savane, espèce menacée. Éléphant : 450, forêt, non menacée. Guépard : 85, savane, menacée. Girafe : 200, savane, non menacée. Dans une seule ligne cohabitent trois sortes d'informations totalement différentes : le nom de l'espèce, donc du texte, l'effectif, donc un nombre, et une réponse par oui ou par non. NumPy, que tu as rencontré à la leçon précédente, calculera la moyenne de ces effectifs plus vite que tu ne cligneras des yeux, mais avant cela il faut lui remettre uniquement des nombres. Or un carnet de terrain n'est pas une colonne de nombres, c'est un tableau.

Pandas est la bibliothèque qui transforme Python en outil de travail pour ce genre de tableaux. Elle ne remplace pas NumPy : elle est bâtie dessus et, en dessous, elle calcule avec exactement les mêmes tableaux. Elle ajoute en revanche deux choses que NumPy n'a pas : chaque colonne reçoit un nom, et chaque colonne peut avoir son propre type de données. C'est exactement la différence qui sépare une mesure brute d'un journal d'expédition tenu correctement.

Pourquoi le tableau NumPy ne suffit plus ici

Avant de sortir un nouvel outil du sac, mieux vaut voir de tes propres yeux où l'ancien s'arrête. Essayons de faire entrer deux lignes du carnet dans un tableau NumPy, comme nous l'avons fait avec des nombres à la leçon précédente. La fonction np.array accepte une liste de listes sans broncher, parce que formellement il ne se passe rien de grave. Ce qui nous intéresse, c'est ce qui ressort de l'autre côté : l'attribut dtype indique par quel type unique NumPy a décrit l'ensemble du contenu du tableau, car dans NumPy tous les éléments doivent partager un seul et même type.

1import numpy as np
2
3raw = np.array([["Lion", 120], ["Elephant", 450]])
4
5print(raw)
6# [['Lion' '120']
7#  ['Elephant' '450']]
8
9print(raw.dtype)  # <U21

Regarde bien les apostrophes autour des nombres dans l'affichage : '120' et non 120. Puisque le tableau doit avoir un type unique et que "Lion" ne peut pas devenir un nombre, NumPy a pris l'autre chemin et a transformé les nombres en texte. L'abréviation <U21 signifie précisément "chaîne de caractères d'au plus vingt et un caractères". Tes mesures ont cessé d'être des nombres : les additionner reviendrait à coller des morceaux de texte bout à bout, et la moyenne ne passerait tout simplement pas. À cela s'ajoute un second ennui, plus discret mais tout aussi pénible : les colonnes n'ont pas de nom, donc jusqu'à la fin de l'analyse tu dois te souvenir que l'effectif se cache derrière le numéro un. Pandas règle les deux problèmes d'un coup, et c'est pour cela qu'il existe.

Installation et le raccourci pd

Pandas ne fait pas partie de la bibliothèque standard de Python, il faut donc l'installer. C'est le travail de pip, le gestionnaire de paquets de Python, avec la même commande que celle qui t'a servi pour les bibliothèques précédentes. L'installation entraînera NumPy derrière elle si tu ne l'as pas encore, car Pandas ne démarre pas sans NumPy : c'est une dépendance, pas une suggestion. La commande s'exécute dans le terminal et non dans un fichier de code, et une seule fois par environnement. Si tu travailles dans Google Colab ou dans la distribution Anaconda, Pandas est déjà en place et tu peux sauter cette étape.

1pip install pandas

Après l'installation, rien ne s'est encore produit dans ton programme : le paquet a atterri sur le disque, mais aucun fichier de code n'est au courant de son existence. C'est une instruction distincte, dans Python lui-même, qui s'en charge. L'usage veut qu'on donne à la bibliothèque le raccourci pd à l'aide du mot-clé as, qui crée un alias, c'est-à-dire un second nom, plus court, pour la même chose. Ce n'est qu'une convention, mais une convention si répandue que chaque exemple de la documentation, chaque réponse trouvée en ligne et chaque exercice de ce module supposent pd.

1import pandas as pd

Cette ligne unique charge la bibliothèque en mémoire et lui accroche l'étiquette pd. Rien n'a été lu, calculé ni affiché : les données n'arriveront qu'aux instructions suivantes. La variante import pandas est correcte elle aussi, sauf qu'il te faudrait alors écrire pandas.DataFrame en entier devant chaque fonction, ce qui devient épuisant au bout de trente appels dans un script. En revanche import pd ne marchera jamais : le paquet s'appelle pandas, alors Python s'arrête sur ModuleNotFoundError: No module named 'pd'. Le raccourci, c'est toi qui le donnes, dans cette ligne précise, et il ne commence à exister qu'à partir d'elle.

Series - une colonne avec des étiquettes

Commençons par le plus petit morceau du carnet : une seule colonne. La liste [120, 450, 85, 200] conserve quatre effectifs, mais elle ne dit pas un mot de celui à qui appartiennent les quatre-vingt-cinq. Pandas a une structure pour cela, qui s'appelle Series. Une Series est unidimensionnelle : c'est une suite unique de valeurs, et non un tableau avec des lignes et des colonnes à la fois. Aux valeurs elle ajoute un index, c'est-à-dire un jeu d'étiquettes, une par valeur, ainsi qu'un nom facultatif pour la suite entière, que tu passes par le paramètre name. Retiens cette définition avec précision, car dans un instant nous allons distinguer la Series d'une structure qui, elle, est vraiment un tableau.

1populations = pd.Series([120, 450, 85, 200], name="population")
2
3print(populations)
4# 0    120
5# 1    450
6# 2     85
7# 3    200
8# Name: population, dtype: int64

À gauche de l'affichage, tu vois un index que tu n'as fourni nulle part : Pandas l'a fabriqué tout seul, sous forme de nombres qui se suivent à partir de zéro. C'est important, car une Series a toujours un index, même quand tu n'en demandes pas. À droite se tiennent tes valeurs, en dessous le nom de la série et dtype: int64, le type commun à tous les éléments. Sur ce point, une Series se comporte comme un tableau NumPy, et ce n'est pas un hasard : à l'intérieur, il y a réellement un tableau NumPy. La liste que tu as passée, elle, n'a été modifiée en rien - Pandas s'est contenté d'en recopier les valeurs dans une structure neuve.

L'index n'est pas obligé d'être une suite de nombres, et c'est justement là que réside sa force. Tu peux fournir tes propres étiquettes par le paramètre index, en lui passant une liste aussi longue que la liste des valeurs. Dès lors, au lieu de te rappeler que quatre-vingt-cinq est la troisième mesure, tu vas chercher la valeur par le nom de l'espèce, entre crochets, exactement comme un élément de dictionnaire.

1species = pd.Series(
2    [120, 450, 85, 200],
3    index=["Lion", "Elephant", "Cheetah", "Giraffe"],
4    name="population",
5)
6
7print(species["Lion"])     # 120
8print(species["Cheetah"])  # 85

Les étiquettes ont remplacé les numéros à partir de zéro, mais les nombres sont restés les mêmes et dans le même ordre : l'index décrit les valeurs, il ne les mélange pas. Remarque bien que les noms d'espèces ne forment pas une deuxième colonne. C'est toujours une seule colonne de nombres, à ceci près que chacun porte une étiquette agrafée. Si tu voulais garder l'espèce et l'effectif comme deux colonnes de même rang, il te faudrait une autre structure, et tu la découvriras à la section suivante.

Puisqu'une Series contient un tableau NumPy, les mêmes tours que dans la leçon précédente fonctionnent sur elle. La comparaison species > 100 ne renvoie pas un unique True : elle effectue la comparaison séparément pour chaque valeur et rend une Series remplie de booléens. Une telle série s'appelle un masque booléen. Remise entre les crochets, elle ne sélectionne que les éléments pour lesquels le verdict est True.

1print(species > 100)
2# Lion         True
3# Elephant     True
4# Cheetah     False
5# Giraffe      True
6# Name: population, dtype: bool
7
8print(species[species > 100])
9# Lion        120
10# Elephant    450
11# Giraffe     200
12# Name: population, dtype: int64

Le guépard a disparu du résultat, parce que quatre-vingt-cinq ne dépasse pas la centaine, mais il n'a pas disparu de species : le filtrage renvoie une nouvelle série et l'original reste intact. Remarque aussi que les étiquettes ont voyagé avec les valeurs : dans le résultat, "Elephant" se tient toujours à côté de quatre cent cinquante, alors qu'il ne reste qu'une ligne sur trois. Ce comportement, nous allons le transposer dans un instant à des tableaux entiers, et il sera la base du filtrage des données dans Pandas.

DataFrame - le tableau tout entier

Une Series, c'est une colonne, et ton carnet en compte quatre. Il te faut donc une structure qui tient les lignes et les colonnes à la fois, et dans Pandas cette structure s'appelle DataFrame. Un DataFrame est bidimensionnel : il a autant de lignes que d'observations et autant de colonnes que de caractéristiques décrites. Chaque colonne prise isolément est une Series, si bien que les deux structures ne se font pas concurrence : l'une est faite de l'autre. La façon la plus simple de construire un tableau consiste à passer un dictionnaire : la clé devient le nom de la colonne et la valeur, c'est-à-dire une liste, remplit cette colonne de haut en bas. Toutes les listes doivent avoir la même longueur, sinon les lignes ne se refermeraient pas.

1safari_data = pd.DataFrame({
2    "species": ["Lion", "Elephant", "Cheetah", "Giraffe"],
3    "population": [120, 450, 85, 200],
4    "habitat": ["Savanna", "Forest", "Savanna", "Savanna"],
5    "endangered": [True, False, True, False],
6})
7
8print(safari_data)
9#     species  population  habitat  endangered
10# 0      Lion         120  Savanna        True
11# 1  Elephant         450   Forest       False
12# 2   Cheetah          85  Savanna        True
13# 3   Giraffe         200  Savanna       False

Les quatre clés du dictionnaire sont devenues les en-têtes des colonnes, dans l'ordre exact où tu les as écrites. À gauche, l'index à partir de zéro est réapparu, fabriqué automatiquement comme dans la Series. Le plus important, pourtant, c'est ce qu'on ne voit pas ici : les nombres ne se sont pas changés en texte. La colonne population contient des entiers, species et habitat du texte, endangered des booléens, et ces trois types cohabitent dans un seul et même tableau. C'est précisément ce dont le tableau NumPy du début de la leçon était incapable. Rien d'autre ne s'est produit : Pandas n'a rien trié, rien calculé, rien corrigé.

Trois noms qui ne désignent pas un tableau

Autour du DataFrame circulent quelques noms faciles à confondre et qui pourtant désignent tout autre chose. Series, tu le sais maintenant, est une structure unidimensionnelle : une colonne munie d'un index, pas un tableau. Panel a été jadis, dans Pandas, une structure tridimensionnelle, c'est-à-dire une pile de plusieurs tableaux posés les uns sur les autres, mais il a été retiré et depuis la version 1.0 il n'existe tout bonnement plus ; tenter d'utiliser pd.Panel se termine par une AttributeError. Table, de son côté, n'a jamais existé dans Pandas : une classe de ce nom se rencontre dans d'autres bibliothèques, par exemple pour manipuler des fichiers astronomiques, et n'a aucun rapport avec notre tableau. Une liste de dictionnaires imbriqués n'en est pas un non plus : c'est une structure Python ordinaire, que tu peux certes passer à pd.DataFrame, mais qui par elle-même n'est ni une Series ni aucune structure de Pandas. Tu peux le vérifier sans me croire sur parole, avec la fonction hasattr, qui répond à la question "cet objet contient-il un nom pareil".

1print(hasattr(pd, "DataFrame"))  # True
2print(hasattr(pd, "Series"))     # True
3print(hasattr(pd, "Panel"))      # False
4print(hasattr(pd, "Table"))      # False

Deux vrais et deux faux, noir sur blanc. La bibliothèque Pandas héberge exactement deux structures de données fondamentales : la Series unidimensionnelle et le DataFrame bidimensionnel. Quand quelqu'un parle, dans Pandas, d'un tableau avec des lignes et des colonnes, il parle d'un DataFrame - et c'est la seule réponse correcte à cette question. L'appel à hasattr lui-même n'a rien créé ni modifié au passage ; il a seulement vérifié ce qui existe et ce qui n'existe pas.

Les données du terrain arrivent dans un fichier CSV

Le dictionnaire convient pour quatre lignes. Avec trois mille relevés de colliers GPS, personne ne recopiera les données à la main : tu les reçois sous forme de fichier CSV, pour comma-separated values, des valeurs séparées par des virgules. C'est un simple fichier texte à la structure très dépouillée : la première ligne est un en-tête avec les noms des colonnes, et chacune des suivantes est une observation. Ouvre un tel fichier dans n'importe quel éditeur et tu verras ceci.

1species,population,habitat,endangered
2Lion,120,Savanna,True
3Elephant,450,Forest,False
4Cheetah,85,Savanna,True
5Giraffe,200,Savanna,False

Cinq lignes de texte : un en-tête et quatre observations, exactement les mêmes données que celles que nous venons de saisir avec un dictionnaire. Aucun type, aucune parenthèse, aucune structure en dehors des virgules. La transformation de ce fichier en tableau prêt à l'emploi est le travail de la fonction read_csv. Tu lui donnes le nom du fichier sous forme de texte, elle te rend un DataFrame. Le résultat, nous l'affectons à la variable df : c'est l'abréviation traditionnelle de DataFrame et c'est ce nom que tu verras dans presque tous les exemples en ligne.

1df = pd.read_csv("safari_species.csv")
2
3print(df.shape)                 # (4, 4)
4print(df["population"].sum())   # 855

Quatre lignes et quatre colonnes, et la somme des effectifs est sortie comme un nombre et non comme un texte recollé : cela signifie que read_csv a reconnu tout seul le type de chaque colonne. L'en-tête du fichier est devenu les noms des colonnes, les lignes de texte sont devenues les lignes du tableau, et tu n'as pas écrit une seule ligne de conversion. Apprends cet appel caractère par caractère, tu vas l'écrire tous les jours : pd, ., read_csv, (, 'data.csv', ). L'ordre est rigide - d'abord le raccourci de la bibliothèque, puis le point, le nom de la fonction, la parenthèse ouvrante, le nom du fichier entre guillemets et la parenthèse fermante. La fonction appartient au module pd et non au tableau, donc df.read_csv(...) n'a aucun sens : au moment de la lecture, tu n'as encore aucun df. Les fonctions jumelles pd.read_excel et pd.read_json fonctionnent de la même manière pour les feuilles de calcul et les fichiers JSON, à ceci près que la première réclame l'installation d'un paquet supplémentaire pour Excel.

Premier coup d'oeil : head et tail

Tu as chargé le fichier et la première chose que tu veux faire, c'est regarder dedans. Afficher un DataFrame entier de trois mille lignes va noyer ton terminal sans rien t'expliquer. Pour jeter un oeil au début du tableau, il y a la méthode head, qui renvoie les premières lignes, et pour la fin, la méthode tail. Toutes deux acceptent un nombre de lignes facultatif ; si tu ne le donnes pas, elles en prennent cinq. Notre tableau n'en compte que quatre, donc df.head() tout seul l'afficherait en entier : pour que la différence saute aux yeux, demandons-en explicitement deux.

1print(df.head(2))
2#     species  population  habitat  endangered
3# 0      Lion         120  Savanna        True
4# 1  Elephant         450   Forest       False
5
6print(df.tail(2))
7#    species  population  habitat  endangered
8# 2  Cheetah          85  Savanna        True
9# 3  Giraffe         200  Savanna       False

Deux lignes par le haut et deux par le bas, chacune complète, avec ses quatre colonnes. Et ici, attention à trois malentendus qui viennent du nom anglais "head". Cette méthode ne renvoie pas les en-têtes des colonnes, même si le mot "header" lui ressemble : les noms de colonnes, c'est df.columns. Elle ne renvoie pas non plus le premier élément de chaque colonne ; la première ligne en tant que telle, c'est df.iloc[0] qui la sort, nous y venons dans un instant. Et elle ne trie rien du tout : l'ordre des lignes du résultat est exactement celui du fichier, et le tri relève d'une méthode à part, sort_values, que tu verras en fin de leçon. head renvoie un nouveau DataFrame contenant une tranche des données, et df reste inchangé.

La fiche technique du jeu de données : info et describe

L'aperçu de quelques lignes dit à quoi ressemblent les données, mais pas combien il y en a ni s'il manque des valeurs quelque part. Une fois les premières lignes parcourues, cette fiche technique s'obtient avec la méthode info. Elle affiche la classe de l'objet, l'étendue de l'index, puis un petit tableau d'une ligne par colonne : le numéro, le nom, le nombre de valeurs non vides dans la colonne Non-Null Count et le type dans la colonne Dtype. Elle termine par un récapitulatif des types et l'occupation mémoire. C'est la commande que je lance sur tout jeu de données neuf dès que j'en ai vu les premières lignes.

1df.info()          # affiche la fiche technique du tableau
2
3print(df.info())   # la meme fiche, avec un None en plus dessous

Ces deux lignes font presque la même chose, et c'est justement pour cela qu'elles se tiennent côte à côte. info fait partie des rares méthodes qui affichent d'elles-mêmes et ne renvoient aucun résultat, ou plus exactement renvoient None. L'envelopper dans un print n'améliore rien : cela ajoute seulement à la fin un None solitaire qui, chaque année, égare quelques apprentis. Retiens donc ceci : info s'appelle nue, sans print. La méthode ne change rien dans le tableau, c'est une lecture pure, tout comme head.

Une fois que tu sais combien tu as de lignes et de quels types, vient le tour des nombres. La méthode describe calcule pour chaque colonne numérique une série complète de statistiques descriptives : l'effectif count, la moyenne mean, l'écart type std, le minimum, les trois quartiles notés 25%, 50% et 75%, et le maximum. L'appel se compose de cinq morceaux et il vaut la peine de les connaître par coeur : df, ., describe, (, ).

1print(df.describe())
2#        population
3# count    4.000000
4# mean   213.750000
5# std    164.690366
6# min     85.000000
7# 25%    111.250000
8# 50%    160.000000
9# 75%    262.500000
10# max    450.000000

Huit lignes de statistiques, mais une seule colonne dans le résultat. Ce n'est pas une erreur : par défaut, describe ne prend en compte que les colonnes numériques, et dans notre tableau il n'y a que population. Les colonnes textuelles species et habitat ainsi que la colonne booléenne endangered ont été laissées de côté et sont restées intactes dans le tableau. S'il n'y avait pas une seule colonne numérique dans le jeu de données, describe basculerait sur des statistiques de texte : le nombre de valeurs, le nombre de valeurs uniques et la plus fréquente. Une moyenne de deux cent treize et trois quarts face à un maximum de quatre cent cinquante te souffle immédiatement qu'une espèce dépasse largement les autres.

La forme du tableau : shape, columns et dtypes

Parfois tu n'as pas besoin de toute la fiche, seulement d'un chiffre précis : par exemple combien de lignes ont survécu au filtrage. Trois attributs servent à cela, c'est-à-dire des propriétés de l'objet et non des méthodes. L'attribut shape renvoie un tuple dans l'ordre lignes, colonnes. L'attribut columns rend la liste des noms de colonnes, et dtypes le type de chaque colonne séparément. Comme ce sont des attributs et non des méthodes, tu les écris sans parenthèses : df.shape, et non df.shape(). Cette seconde version se termine sur TypeError: 'tuple' object is not callable, parce que tu essaies d'appeler comme une fonction un tuple déjà constitué.

1print(df.shape)    # (4, 4)
2print(df.columns)
3# Index(['species', 'population', 'habitat', 'endangered'], dtype='str')
4print(df.dtypes)
5# species         str
6# population    int64
7# habitat         str
8# endangered     bool
9# dtype: object

Le tuple (4, 4) se lit toujours de la même façon : quatre lignes sur quatre colonnes, jamais l'inverse, donc df.shape[0] est le nombre d'observations. Les noms de colonnes reviennent emballés dans un objet Index : c'est la même classe que celle qui gère les étiquettes de lignes, car dans Pandas les en-têtes de colonnes forment eux aussi un index. Si tu préfères une liste Python ordinaire, ajoute .tolist(). Un avertissement au sujet de l'affichage des types : la mention str en face des colonnes textuelles apparaît dans Pandas à partir de la version 3.0, et dans les versions antérieures tu verras object à cet endroit. C'est la même chose sous son ancien nom, et cela ne change rien à ton code.

Choisir des colonnes : une paire de crochets ou deux

Une analyse porte rarement sur le tableau entier : le plus souvent, tu en extrais une tranche. Une colonne se désigne par son nom entre crochets, et c'est là que t'attend le premier piège, car le nombre de crochets décide du type du résultat. Une paire de crochets avec un nom unique renvoie une Series, cette colonne unidimensionnelle du début de la leçon. Deux paires de crochets renvoient un DataFrame, parce qu'à l'intérieur tu passes une liste Python ordinaire de noms de colonnes : de là vient la seconde paire, il n'y a aucune magie. La différence se lit le mieux dans l'attribut shape, qui pour une structure unidimensionnelle contient un seul nombre et pour une structure bidimensionnelle deux.

1populations = df["population"]
2subset = df[["species", "population"]]
3
4print(populations.shape)  # (4,)
5print(subset.shape)       # (4, 2)

Le même jeu de nombres, deux formes différentes. Le tuple (4,) à valeur unique est la signature d'une Series : quatre valeurs alignées en un seul rang. Le tuple (4, 2) est la signature d'un DataFrame : quatre lignes sur deux colonnes. L'ordre des noms dans la liste décide de l'ordre des colonnes du résultat, donc df[["population", "species"]] donnerait le même contenu dans la disposition inverse. Les deux variables sont de nouveaux objets - df possède toujours ses quatre colonnes au complet et aucune de ces deux lignes ne l'a amputé.

Choisir des lignes : iloc et loc

Les colonnes se choisissent par leur nom, et les lignes ? Pour les lignes, il existe deux outils, et les confondre est l'erreur classique du débutant. iloc est l'abréviation de integer location, donc la position comptée à partir de zéro, exactement comme dans une liste Python. loc va chercher l'étiquette de l'index, c'est-à-dire ce que tu vois à gauche de l'affichage. Dans notre tableau, l'index est la suite des nombres de zéro à trois, donc les deux écritures se ressemblent, mais elles se comportent différemment sur les intervalles : iloc exclut la fin de l'intervalle, comme les tranches de listes, tandis que loc l'inclut, parce que des étiquettes ne sont pas des positions. Aux deux outils, tu peux donner deux arguments séparés par une virgule : d'abord les lignes, ensuite les colonnes.

1print(df.iloc[0])
2# species          Lion
3# population        120
4# habitat       Savanna
5# endangered       True
6# Name: 0, dtype: object
7
8print(df.iloc[0, 1])                    # 120
9print(df.iloc[0:3].shape)               # (3, 4)
10print(df.loc[0:2, "species"].tolist())  # ['Lion', 'Elephant', 'Cheetah']

Le premier affichage montre ce qu'est réellement une ligne dans Pandas : une Series dont l'index est formé des noms de colonnes et dont les valeurs sont de types différents, d'où le dtype: object en bas. Le deuxième va chercher la ligne zéro et la colonne un, en comptant à partir de zéro, et tombe donc sur l'effectif du lion. Le troisième et le quatrième illustrent la différence promise : iloc[0:3] donne trois lignes, car le trois tombe hors de l'intervalle, et loc[0:2] donne lui aussi trois lignes, car le deux tient à l'intérieur. L'écriture df.iloc[0] te resservira comme contre-exemple : c'est elle qui renvoie le premier élément de chaque colonne, ce qu'on attend parfois à tort de head.

Filtrer : le masque booléen sur le tableau entier

Voici maintenant la compétence la plus importante de cette leçon. La question qui vient du terrain est rarement "montre-moi la ligne numéro deux" et presque toujours "montre-moi les espèces qui comptent plus de cent individus". La réponse tient en deux étapes que tu as déjà vues sur une Series isolée. Tu construis d'abord un masque booléen : la comparaison df["population"] > 100 vérifie la condition séparément pour chaque ligne et renvoie une Series de True et de False exactement aussi longue que le tableau.

1mask = df["population"] > 100
2
3print(mask)
4# 0     True
5# 1     True
6# 2    False
7# 3     True
8# Name: population, dtype: bool

Quatre lignes de tableau ont donné quatre verdicts booléens, et l'index est resté le même : c'est lui qui relie le masque aux lignes. Remarque qu'il n'y a plus le moindre effectif dans le résultat : le masque ne conserve pas les données, seulement la décision prise sur chaque ligne. À lui seul, il n'a encore rien filtré, et après cette ligne df compte toujours quatre lignes. Ce n'est qu'en glissant le masque entre les crochets du tableau que l'on garde les lignes en face desquelles se trouve True.

1print(df[df["population"] > 100])
2#     species  population  habitat  endangered
3# 0      Lion         120  Savanna        True
4# 1  Elephant         450   Forest       False
5# 3   Giraffe         200  Savanna       False

Trois lignes au lieu de quatre, et le guépard est tombé, car quatre-vingt-cinq ne remplit pas la condition. Regarde l'index à gauche : il reste les numéros 0, 1 et 3, avec un trou à la place du deux. Pandas ne renumérote pas les lignes après un filtrage, ce qui te permet de toujours savoir d'où vient une ligne dans l'original. Toute la construction s'assemble en quatre étapes dont l'ordre mérite d'être retenu : d'abord df[, puis df['population'], puis > 100, et enfin ]. Autrement dit : tu ouvres le crochet sur le tableau, à l'intérieur tu bâtis la condition sur la colonne, et tu refermes le crochet. Et de nouveau la phrase la plus importante : après cette opération, le df d'origine compte toujours quatre lignes, parce que le filtrage renvoie un nouveau tableau.

Les conditions se combinent, à condition de retenir deux détails de syntaxe. À la place des mots and et or, on utilise les signes & et |, parce que Python compare des séries entières et non des valeurs isolées. Et chaque condition doit se tenir dans ses propres parenthèses rondes, car & se lie plus fort que > et, sans parenthèses, Python calculerait tout cela dans le mauvais ordre.

1print(df[(df["population"] > 100) & (df["endangered"] == False)])
2#     species  population  habitat  endangered
3# 1  Elephant         450   Forest       False
4# 3   Giraffe         200  Savanna       False

Il reste deux espèces : nombreuses et non menacées. Le signe & signifie "les deux conditions à la fois", tandis que | signifierait "au moins une des deux". Si tu écrivais and à cet endroit, tu récolterais ValueError: The truth value of a Series is ambiguous - Python ne sait pas si une série de quatre booléens est vraie ou fausse, et il refuse à juste titre de deviner. Le résultat est, comme toujours, un nouveau tableau ; après ces trois blocs, df est exactement le même qu'au sortir du fichier.

Nouvelles colonnes et la méthode apply

La reconnaissance de terrain ne consiste pas seulement à choisir parmi ce que tu as déjà, mais aussi à ajouter des informations. Une nouvelle colonne se crée par une simple affectation à un nom qui n'existe pas encore dans le tableau. À droite, tu peux écrire une opération sur une colonne existante, et Pandas l'exécutera pour chaque ligne séparément, sans la moindre boucle : c'est la même vectorisation que celle découverte avec les tableaux NumPy.

1df["population_thousands"] = df["population"] / 1000
2
3print(df["population_thousands"].tolist())
4# [0.12, 0.45, 0.085, 0.2]

Le tableau compte désormais cinq colonnes au lieu de quatre, et c'est l'une des rares opérations qui modifient df sur place, sans qu'on ait à réaffecter le résultat. Range bien cela dans ta tête : une affectation à df["quelque_chose"] modifie le tableau, alors que le filtrage, head ou describe se contentent de renvoyer de nouveaux objets. La colonne population n'a pas souffert - elle contient toujours des entiers, et les nouvelles valeurs décimales ont atterri à côté, dans une colonne distincte.

Toutes les transformations ne s'écrivent pas avec une seule opération arithmétique. Quand tu veux appliquer ta propre logique à chaque valeur, tu prends la méthode apply, qui reçoit une fonction et la lance l'une après l'autre sur chaque élément de la colonne. On lui passe d'ordinaire une fonction lambda, c'est-à-dire une fonction sans nom écrite sur une seule ligne : après le mot lambda vient le nom de l'argument, un deux-points, puis l'expression dont le résultat sera renvoyé.

1df["status"] = df["population"].apply(
2    lambda x: "endangered" if x < 100 else "stable"
3)
4
5print(df["status"].tolist())
6# ['stable', 'stable', 'endangered', 'stable']

La fonction a reçu tour à tour cent vingt, quatre cent cinquante, quatre-vingt-cinq et deux cents, et elle a renvoyé quatre textes dont Pandas a bâti une nouvelle colonne. L'argument x est une valeur unique et non la colonne entière, car apply appelée sur une Series travaille élément par élément. La colonne population elle-même est de nouveau restée intacte : apply n'écrase rien dedans, elle se contente de lire. Remarque que le guépard, avec ses quatre-vingt-cinq individus, a reçu le statut endangered alors que la colonne endangered portait déjà True : ce sont deux sources indépendantes de la même information et, dans un vrai jeu de données, il vaut la peine de vérifier qu'elles concordent.

Une colonne peut aussi être écrasée, en mettant à gauche un nom qui existe déjà. Je le fais à contrecoeur et je te le déconseille, car après une telle affectation tu ne récupéreras plus les mesures d'origine : il ne reste en mémoire que le résultat. Quand j'ai besoin d'une version recalculée, je préfère faire une copie du tableau avec la méthode copy et travailler sur la copie. Comme le produit de nombres à virgule traîne toujours de minuscules imprécisions binaires, j'arrondis en passant à une décimale avec la méthode round, qui prend le nombre de décimales à garder. Cela coûte une ligne et évite de tout recharger depuis le début.

1projection = df.copy()
2projection["population"] = (projection["population"] * 1.1).round(1)
3
4print(projection["population"].tolist())  # [132.0, 495.0, 93.5, 220.0]
5print(df["population"].tolist())          # [120, 450, 85, 200]

La projection d'une croissance de dix pour cent a atterri dans projection, tandis que df conserve les nombres mesurés sur le terrain - et c'était bien le but. Remarque au passage qu'après la multiplication par un nombre décimal, les valeurs ont cessé d'être entières : quatre-vingt-treize animaux et demi est évidemment une absurdité biologique, mais mathématiquement la multiplication fonctionne ainsi et Pandas a fait passer la colonne en virgule flottante. Si tu avais sauté le copy et affecté le résultat directement à df, la colonne population de l'original aurait elle aussi changé de type et de valeurs.

Supprimer des colonnes : drop et l'axe

Tôt ou tard, une colonne qui n'a servi qu'un instant traînera dans ton tableau. Pour jeter, il y a la méthode drop, mais elle cache un piège : la même instruction supprime aussi bien des lignes que des colonnes, donc il faut lui dire de quelle dimension il s'agit. C'est le rôle du paramètre axis, c'est-à-dire l'axe. Retiens-le une fois pour toutes : axis=0 désigne les lignes, axis=1 désigne les colonnes. Cette numérotation n'est pas arbitraire, on la retrouve dans le tuple shape, où la position zéro porte les lignes et la position un les colonnes.

1df["temp"] = 1
2clean = df.drop("temp", axis=1)
3
4print("temp" in clean.columns)  # False
5print("temp" in df.columns)     # True
6
7df = df.drop("temp", axis=1)
8print("temp" in df.columns)     # False

Le plus instructif ici, c'est le deuxième affichage. La colonne temp a disparu de clean, mais elle est restée dans df, parce que drop ne supprime rien dans l'original : elle renvoie un nouveau tableau privé de la colonne indiquée. Seule la réaffectation du résultat à df, comme à la cinquième ligne, rend le changement définitif. Il existe bien un paramètre inplace=True qui modifie le tableau sur place, mais je te recommande l'affectation : on voit alors à l'oeil nu que la variable a reçu un nouveau contenu, et les appels peuvent s'enchaîner les uns après les autres.

Trois variantes de cette instruction ne fonctionneront pas, et je vois les trois dans les copies. L'écriture df.drop('temp', axis=0) passe la syntaxe, mais se termine sur KeyError: "['temp'] not found in axis", parce qu'avec l'axe zéro Pandas cherche une ligne étiquetée temp, et cette ligne n'existe pas. L'écriture df.delete(column='temp') donne une AttributeError, car le DataFrame n'a pas de méthode delete : on la confond avec la fonction np.delete de NumPy, qui travaille sur des tableaux. L'écriture df.remove('temp') se termine elle aussi sur une AttributeError, parce que remove est une méthode des listes Python et non des tableaux Pandas. Une seule forme est correcte : df.drop('temp', axis=1). Pour être complet, j'ajoute qu'il existe aussi l'écriture équivalente df.drop(columns='temp'), plus lisible dans les longs scripts, mais tu dois connaître la version avec axis=1, car tu la croiseras dans toute la documentation. La méthode df.rename(columns={"population": "count_2024"}) fonctionne de la même façon : elle renomme les colonnes et rend elle aussi une copie au lieu de corriger l'original.

Les trous dans les données

Les données du terrain sont parfois trouées : le collier a lâché, l'observateur n'est pas arrivé, la page a pris la pluie. Pandas note une valeur manquante NaN, pour not a number. Pour construire en Python un tableau troué de ce genre, on utilise None, que Pandas convertit lui-même en NaN. Les emplacements des trous se trouvent avec la méthode isnull, qui renvoie un tableau de booléens de la même forme que les données, où True signifie "il y a un trou ici". En ajoutant .sum(), on additionne ces valeurs colonne par colonne et, comme True compte pour un, on obtient le nombre de manques dans chaque colonne.

1notes = pd.DataFrame({
2    "species": ["Lion", "Elephant", "Cheetah", "Giraffe"],
3    "population": [120, None, 85, 200],
4    "habitat": ["Savanna", "Forest", None, "Savanna"],
5})
6
7print(notes.isnull().sum())
8# species       0
9# population    1
10# habitat       1
11# dtype: int64
12
13print(notes["population"].tolist())  # [120.0, nan, 85.0, 200.0]

Un trou dans les effectifs, un dans les habitats, rien dans les noms d'espèces - et tout cela en une seule ligne de code. Le dernier affichage montre un effet de bord facile à oublier : la colonne population a cessé d'être entière et cent vingt s'affiche désormais 120.0. Cela vient de ce que NaN est un nombre à virgule flottante, donc la colonne entière a dû basculer vers ce type. Pour le reste, les données n'ont pas bougé d'un millimètre : isnull ne répare rien, elle pointe du doigt.

La réaction la plus simple face aux manques consiste à jeter les observations incomplètes avec la méthode dropna. Par défaut, elle supprime la ligne entière dès qu'une seule valeur y manque et, comme drop, elle renvoie un nouveau tableau au lieu de corriger l'ancien.

1print(notes.dropna().shape)  # (2, 3)
2print(notes.shape)           # (4, 3)

Des quatre observations, il n'en reste que deux, car l'éléphant a perdu son effectif et le guépard son habitat. Vois un peu ce que coûte cette propreté : tu as effacé la moitié du jeu de données pour boucher deux cases, et avec elles sont partis des noms d'espèces corrects et l'effectif correct du guépard. Sur un grand jeu de données parsemé de trous isolés, dropna convient très bien ; sur un petit, elle est capable de te couper l'herbe sous le pied. Le notes d'origine compte toujours quatre lignes, donc rien n'est perdu pour de bon.

La seconde issue consiste à combler les trous avec la méthode fillna, à laquelle tu fournis une valeur de remplacement. Le choix de cette valeur est une décision de fond, pas une décision technique. Mettre zéro dans une colonne d'effectifs revient à affirmer "cette espèce n'est pas là", ce qui est tout bonnement faux et tirera vers le bas toutes les moyennes suivantes. C'est pourquoi, pour des mesures, je te recommande de remplir avec la moyenne des autres observations : on n'invente alors aucune information nouvelle, on insère une valeur typique.

1average = notes["population"].mean()
2print(average)  # 135.0
3
4print(notes["population"].fillna(0).tolist())        # [120.0, 0.0, 85.0, 200.0]
5print(notes["population"].fillna(average).tolist())  # [120.0, 135.0, 85.0, 200.0]

La moyenne vaut cent trente-cinq, c'est-à-dire une somme de quatre cent cinq divisée par trois et non par quatre : les méthodes statistiques de Pandas ignorent les NaN dans leurs calculs. Compare les deux résultats : la version avec zéro enfonce au milieu du jeu de données une valeur plus basse que toutes les autres, tandis que la version avec la moyenne insère un nombre qui ne déforme pas le tableau d'ensemble. Aucun de ces appels n'a modifié notes, car fillna, comme dropna, renvoie une copie : pour conserver la correction, il faut affecter le résultat à la colonne.

Regrouper : groupby

Jusqu'ici, nous avons regardé les lignes une par une. Les vraies questions de la réserve sonnent pourtant autrement : non pas "combien y a-t-il de lions", mais "quelle zone entretient les plus grosses populations, la savane ou la forêt". C'est une question sur des groupes, et la méthode groupby y répond ; tu lui donnes le nom de la colonne selon laquelle découper. L'appel se compose de six morceaux : df, ., groupby, (, 'habitat', ). À lui seul, il ne renvoie encore aucun tableau, seulement un objet de regroupement, autrement dit un plan de découpe. Le résultat n'apparaîtra qu'une fois qu'on lui aura ajouté une opération, par exemple mean, la moyenne.

1by_habitat = df.groupby("habitat")
2
3print(by_habitat["population"].mean())
4# habitat
5# Forest     450.0
6# Savanna    135.0
7# Name: population, dtype: float64

Deux habitats, deux moyennes. Le plus important est ce qui est arrivé à l'index : les noms d'habitats ont quitté leur colonne pour devenir les étiquettes de lignes du résultat, car après un regroupement ce sont eux qui identifient la ligne. L'écriture by_habitat["population"] dit "calcule uniquement sur la colonne des effectifs", et elle vaut la peine d'être utilisée, car chercher la moyenne d'une colonne de texte n'a aucun sens. Le df lui-même est inchangé après l'opération : le regroupement ne déplace ni ne supprime rien dans l'original, il le lit et construit un récapitulatif à côté.

L'objet de regroupement peut être interrogé de plusieurs manières et tu n'as pas à le reconstruire à chaque fois. La méthode sum additionne les valeurs du groupe, tandis que size compte les lignes, c'est-à-dire qu'elle répond à la question "combien d'espèces sont tombées dans chaque zone".

1print(by_habitat["population"].sum())
2# habitat
3# Forest     450
4# Savanna    405
5# Name: population, dtype: int64
6
7print(by_habitat.size())
8# habitat
9# Forest     1
10# Savanna    3
11# dtype: int64

La somme de la forêt dépasse celle de la savane, alors que la moyenne disait la même chose bien plus nettement - et c'est justement pour cela qu'il vaut mieux regarder les deux nombres ensemble. L'affichage de size explique d'où vient l'écart : la forêt abrite une espèce et la savane trois, donc quatre cent cinquante éléphants battent trois populations plus petites sur la somme, mais l'écart des moyennes est de plus du double. Remarque que size n'a pas besoin qu'on lui désigne une colonne, puisqu'elle compte des lignes et non des valeurs.

Quand tu veux plusieurs statistiques d'un coup, plutôt que d'appeler les méthodes l'une après l'autre, tu passes à la méthode agg un dictionnaire : la clé est un nom de colonne et la valeur une liste de noms d'opérations écrits sous forme de texte.

1stats = df.groupby("habitat").agg({
2    "population": ["mean", "sum", "count"],
3    "endangered": "sum",
4})
5
6print(stats)
7#         population            endangered
8#               mean  sum count        sum
9# habitat
10# Forest       450.0  450     1          0
11# Savanna      135.0  405     3          2

Quatre statistiques dans un seul petit tableau, et l'en-tête est devenu à deux niveaux : la ligne du haut indique de quelle colonne provient le calcul, celle du bas quelle opération a été appliquée. La dernière colonne cache une astuce qui mérite d'être retenue : additionner des booléens donne le nombre d'occurrences de True, donc endangered avec une somme de deux signifie deux espèces menacées dans la savane. Toute cette opération n'a de nouveau fait que lire les données : df conserve les mêmes lignes et les mêmes colonnes qu'avant le regroupement.

Trier : sort_values

Les récapitulatifs répondent aux questions sur les groupes, mais souvent un simple classement suffit : quelle espèce est la plus nombreuse. Pour mettre les lignes en ordre, il y a la méthode sort_values, à laquelle tu donnes un nom de colonne. Par défaut, elle trie en ordre croissant, et le paramètre ascending=False retourne l'ordre en décroissant. Tu peux aussi lui passer une liste de colonnes : Pandas trie alors d'abord sur la première, puis sur la seconde en cas d'égalité.

1ranking = df.sort_values("population", ascending=False)
2
3print(ranking["species"].tolist())  # ['Elephant', 'Giraffe', 'Lion', 'Cheetah']
4print(ranking.index.tolist())       # [1, 3, 0, 2]
5print(df["species"].tolist())       # ['Lion', 'Elephant', 'Cheetah', 'Giraffe']

Le classement de l'éléphant au guépard s'est mis en place en une ligne. Le deuxième affichage montre une chose qui surprend tout le monde la première fois : l'index a voyagé avec les lignes, si bien qu'en haut du tableau se trouve maintenant la ligne étiquetée 1 et non 0. C'est volontaire, car cela permet de savoir où chaque ligne se trouvait dans l'original. Le troisième affichage est la phrase la plus importante de cette section : l'ordre dans df n'a pas bougé d'un iota, parce que sort_values renvoie une copie triée. Il existe encore sort_index, qui range le tableau selon les étiquettes de lignes et sert précisément à revenir à l'état de départ après un tri.

Réunir deux carnets : merge et concat

En expédition, il est rare que tout tienne dans un seul cahier. Admettons qu'à côté du tableau des espèces tu disposes d'un second tableau décrivant les habitats : leur nom et leur superficie. Tu veux les rapprocher en ajoutant à chaque animal la superficie de sa zone. Pour réunir deux tableaux par une colonne commune, il y a la fonction pd.merge, familière à quiconque a vu une instruction JOIN en SQL. Tu lui donnes deux tableaux, dans le paramètre on le nom de la colonne commune, et dans how la manière de réunir. La valeur "left" signifie "conserve toutes les lignes du tableau de gauche", donc dans notre cas tous les animaux.

1habitats = pd.DataFrame({
2    "habitat": ["Savanna", "Forest", "Wetland"],
3    "area_km2": [1200, 800, 300],
4})
5
6merged = pd.merge(df, habitats, on="habitat", how="left")
7
8print(merged[["species", "habitat", "area_km2"]])
9#     species  habitat  area_km2
10# 0      Lion  Savanna      1200
11# 1  Elephant   Forest       800
12# 2   Cheetah  Savanna      1200
13# 3   Giraffe  Savanna      1200

Chaque animal a reçu la superficie de sa zone, et la valeur mille deux cents s'est répétée trois fois, car trois espèces habitent la savane : la fusion recopie les données du plus petit tableau partout où elles correspondent. Regarde ce qui manque dans le résultat : les marécages du tableau habitats n'apparaissent nulle part, parce qu'aucune espèce n'y vit dans df et que la fusion "left" ne regarde que le tableau de gauche. Avec how="outer", une cinquième ligne s'ajouterait, celle des marécages, avec des NaN dans les colonnes qui décrivent les animaux. La variante "inner" ne garderait que les habitats présents dans les deux tableaux, et "right" serait l'image en miroir de "left".

Un problème tout à fait différent se pose quand deux tableaux ont les mêmes colonnes et des lignes différentes, par exemple lorsque deux campements ont envoyé des carnets de structure identique. Là, tu ne les réunis pas par une clé : tu poses l'un sous l'autre avec la fonction pd.concat, à laquelle tu donnes une liste de tableaux.

1north = pd.DataFrame({"species": ["Lion", "Zebra"], "population": [120, 300]})
2south = pd.DataFrame({"species": ["Hyena", "Impala"], "population": [60, 540]})
3
4both = pd.concat([north, south], ignore_index=True)
5
6print(both["species"].tolist())  # ['Lion', 'Zebra', 'Hyena', 'Impala']
7print(both.index.tolist())       # [0, 1, 2, 3]

Quatre lignes venues de deux campements dans un seul tableau, dans l'ordre donné par la liste. Le paramètre ignore_index=True compte davantage qu'il n'y paraît : sans lui, l'index du résultat ressemblerait à 0, 1, 0, 1, car chaque tableau apporterait ses propres étiquettes et des doublons apparaîtraient. Par défaut, concat ajoute des lignes, c'est-à-dire qu'elle travaille le long de l'axe zéro ; avec le paramètre axis=1, elle poserait des colonnes côte à côte. Les deux tableaux sources restent inchangés - north a toujours ses deux lignes.

Enregistrer les résultats

Une analyse qui meurt avec la fermeture du terminal n'est pas encore un résultat d'expédition. Le tableau terminé s'enregistre avec la méthode to_csv, à laquelle tu donnes un nom de fichier. Un paramètre y est pratiquement obligatoire : index=False. Sans lui, Pandas écrit aussi les étiquettes de lignes dans le fichier, en première colonne et sans en-tête ; à la relecture, read_csv la prendra pour une donnée ordinaire et l'appellera Unnamed: 0. Après quelques tours de ce genre, le fichier se couvre de colonnes que personne n'a commandées.

1report = df[["species", "population", "status"]]
2report.to_csv("safari_report.csv", index=False)
3
4with open("safari_report.csv") as f:
5    print(f.read())
6# species,population,status
7# Lion,120,stable
8# Elephant,450,stable
9# Cheetah,85,endangered
10# Giraffe,200,stable

Le fichier ressemble exactement à celui par lequel nous avions commencé la lecture : un en-tête et quatre lignes séparées par des virgules. Seules les trois colonnes choisies ont été enregistrées, car to_csv s'appelle sur ce qu'on lui donne, et nous lui avons donné une tranche : les autres colonnes sont toujours dans df et n'ont pas souffert. La méthode ne renvoie rien et ne modifie pas le tableau, elle en dépose simplement une copie sur le disque. Ses jumelles to_excel et to_json enregistrent le même DataFrame dans d'autres formats, la première réclamant, comme à la lecture, l'installation du paquet dédié aux feuilles de calcul.

Résumé

  • Le DataFrame est le tableau à lignes et colonnes - la seule structure de ce genre dans Pandas. La Series est unidimensionnelle : une colonne munie d'un index. Panel a été supprimé en version 1.0, et la classe Table n'a jamais existé dans Pandas.
  • Un tableau se construit à partir d'un dictionnaire avec pd.DataFrame({...}), où la clé est le nom de la colonne et la valeur une liste.
  • Un fichier se lit en six morceaux : pd, ., read_csv, (, 'data.csv', ).
  • df.head() renvoie les cinq premières lignes du tableau. Elle ne renvoie pas les en-têtes (c'est df.columns), ni le premier élément de chaque colonne (c'est df.iloc[0]), et elle ne trie rien (c'est sort_values).
  • df.info() s'appelle sans print, puisqu'elle affiche d'elle-même et renvoie None. df.describe() ne calcule des statistiques que pour les colonnes numériques et s'écrit en cinq morceaux : df, ., describe, (, ).
  • Le filtrage tient en quatre étapes : df[, df['population'], > 100, ]. Les conditions se combinent avec & et |, chacune dans ses propres parenthèses.
  • Une colonne se supprime avec df.drop('temp', axis=1), car axis=1 désigne les colonnes et axis=0 les lignes. Les méthodes delete et remove n'existent pas dans un DataFrame.
  • Le regroupement s'écrit en six morceaux : df, ., groupby, (, 'habitat', ), et le résultat n'arrive qu'une fois l'opération ajoutée, par exemple mean().
  • L'ordre de travail habituel ne change pas d'une expédition à l'autre : charger avec pd.read_csv, nettoyer avec dropna et fillna, analyser et transformer, puis visualiser. Un premier survol d'un jeu de données neuf suit la même logique : import pandas as pd, pd.read_csv(...), head() pour voir à quoi cela ressemble, describe() pour les statistiques, info() pour les types de colonnes.
  • Presque toutes les méthodes de Pandas renvoient un nouveau tableau et laissent l'original tranquille. L'exception est l'affectation à df["colonne"], qui modifie le tableau sur place.

À la prochaine leçon, nous transformerons ces mêmes colonnes en graphiques et tu verras comment un seul coup d'oeil sur un dessin remplace la lecture de cent lignes. Pour l'instant, retiens une chose : Pandas ne collecte pas les données à ta place et ne corrige pas les erreurs de l'observateur - il te donne seulement un carnet où chaque colonne porte un nom, possède son propre type et garde tout le tableau sous la main au moment où tu poses une question.

Tu as repéré une erreur dans cette leçon ?

Teste tes connaissances

Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.

  1. 1. Quelle structure de données Pandas est un tableau avec des lignes et des colonnes ?

  2. 2. Qu'est-ce qu'une Series Pandas ?

Voici 2 des 4 questions de cette leçon. Réponds aux autres dans le jeu.

Tâches pratiques dans le jeu

  • Tri horizontal

    Remets dans l'ordre le code pour lire un fichier CSV :

  • Éditeur de code

    Crée un DataFrame 'safari_data' à partir d'un dictionnaire contenant 'species' et 'population'.

  • Tri vertical

    Remets dans l'ordre les étapes pour filtrer un DataFrame selon la condition population > 100 :

  • Clique dans l'ordre

    Clique sur les boutons pour regrouper les données par la colonne 'habitat' :

Articles utiles