Combiner des DataFrames — empiler avec concat, faire correspondre avec merge
Rassembler des tableaux : empiler des mois de mêmes données avec concat, et ajouter des informations issues d'un autre tableau avec merge — choisir le type de fusion, repérer les lignes sans correspondance et intercepter la clé en double qui multiplie discrètement les lignes.
Comment voulez-vous apprendre ?
Le problème que nous résolvons
Les données de la boutique ne résident pas dans un seul fichier. Ce n'est jamais le cas.
Les commandes de mars se trouvent dans orders.csv, le tableau que vous utilisez depuis le chapitre quatre. Les commandes d'avril sont arrivées cette semaine sous la forme d'un export distinct, orders_april.csv. Ce que chaque produit coûte à la boutique — et quel fournisseur le vend — est conservé par l'équipe des achats dans son propre fichier, products.csv. Personne n'a inclus le coût dans le fichier des commandes, car le coût est une caractéristique d'un produit, pas d'une commande.
À présent, la propriétaire pose une question : quel bénéfice chaque fournisseur a-t-il généré sur mars et avril ?
Aucun fichier ne peut à lui seul y répondre. Le bénéfice nécessite la quantité et le prix de vente (dans les commandes), le coût (dans le fichier des produits) et le fournisseur (également dans le fichier des produits). Et les commandes sont réparties sur deux mois.
Dans un tableur, vous copieriez les lignes d'avril sous celles de mars, puis vous écririez une formule VLOOKUP dans une nouvelle colonne pour récupérer le coût de chaque ligne, vous l'étireriez vers le bas, et vous espéreriez que tout se passe bien. Pandas accomplit ces deux mêmes tâches en deux lignes :
- empiler des tableaux contenant le même type de lignes — mars et avril — en un seul tableau plus long :
pd.concat - faire correspondre chaque ligne d'un tableau à la bonne ligne d'un autre selon une colonne partagée — chaque commande à son produit — et importer les colonnes de l'autre tableau :
pd.merge
Les lignes de code sont courtes. Ce qui rend ce chapitre essentiel, c'est ce qui peut mal tourner sans la moindre erreur : une fusion peut supprimer silencieusement des commandes dont le produit est absent de la table de correspondance, ou dupliquer silencieusement des commandes lorsque la table de correspondance liste un produit deux fois. Dans les deux cas, on obtient un total qui semble parfaitement raisonnable et qui est pourtant faux. La majeure partie de ce chapitre consiste donc à savoir, avant et après la fusion, exactement combien de lignes vous devez obtenir — et à le vérifier.
À la fin de ce chapitre, vous saurez
- Distinguer les deux opérations : empiler des lignes (
concat) et faire correspondre sur une clé (merge) - Empiler des tableaux avec
pd.concat, corriger l'index répété avecignore_index=True, et observer le comportement des colonnes discordantes - Joindre deux tableaux sur une clé avec
pd.merge, et choisir délibérémenthow="inner","left","right"ou"outer" - Auditer une fusion avec
indicator=Trueet la protéger avecvalidate="many_to_one" - Repérer et corriger l'explosion du nombre de lignes causée par des doublons de clés
- Fusionner sur des clés aux noms différents avec
left_on/right_on, et gérer les conflits de noms de colonnes avecsuffixes - Reconnaître les erreurs de fusion — types de clés incompatibles, colonnes clés manquantes — et les échecs silencieux qui ne lèvent rien
Prérequis : Ajout de colonnes.
Créer les fichiers d'abord
Ce chapitre utilise quatre petits fichiers. Placez-les à côté de votre script.
orders.csv — mars, le même fichier que dans les chapitres précédents :
order_id,date,branch,product,category,quantity,price
1001,2024-03-01,north,pen,stationery,12,15.0
1002,2024-03-01,south,notebook,stationery,5,60.0
1003,2024-03-02,north,bag,accessories,2,850.0
1004,2024-03-02,east,bottle,accessories,7,120.0
1005,2024-03-03,north,eraser,stationery,30,8.0
1006,2024-03-03,south,bag,accessories,1,850.0
1007,2024-03-04,east,pen,stationery,20,15.0
1008,2024-03-04,north,bottle,accessories,4,120.0orders_april.csv — avril, mêmes colonnes. Notez l'agrafeuse : la boutique a commencé à la vendre en avril.
order_id,date,branch,product,category,quantity,price
1009,2024-04-01,north,pen,stationery,15,15.0
1010,2024-04-01,east,notebook,stationery,3,60.0
1011,2024-04-02,south,stapler,stationery,6,95.0
1012,2024-04-02,north,bag,accessories,1,850.0products.csv — une ligne par produit, géré par les achats. Deux éléments sont délibérément « incorrects » ici, comme ce serait le cas dans la vraie vie : la nouvelle agrafeuse n'a pas encore été ajoutée, et il y a un feutre que la boutique n'a jamais vendu.
product,cost,supplier
pen,9.0,Alpha
notebook,40.0,Alpha
bag,600.0,Bravo
bottle,80.0,Bravo
eraser,5.0,Alpha
marker,25.0,Alphabranches.csv — qui dirige chacune des trois succursales de la boutique. La colonne s'appelle branch_name, et non branch, car une autre personne a créé ce fichier.
branch_name,manager
north,Mira
south,Omar
east,LenaAvant d'écrire le code
Combiner des tableaux est l'étape où une rapide ligne de code produit le plus souvent une réponse fausse avec une grande assurance. La planification passe donc en premier, et il s'agit principalement de questions auxquelles vous répondez en observant vos données.
1. Formulez la question en une phrase
« Le bénéfice par fournisseur, pour mars et avril réunis. » Cette phrase vous indique déjà la forme du tableau final : une ligne par fournisseur, un nombre par ligne. Deux fournisseurs existent, Alpha et Bravo, la réponse devrait donc compter environ deux lignes. Si elle en compte trois, ou une seule, quelque chose a dérapé en cours de route.
2. Décidez de l'opération nécessaire pour chaque paire de tableaux
Il n'existe que deux manières de combiner des données, et le test est simple : les deux tableaux contiennent-ils le même type de lignes, ou des informations différentes sur un même sujet ?
- Mêmes colonnes, lignes différentes — commandes de mars + commandes d'avril. Utilisez
pd.concat([a, b]). Le résultat s'agrandit vers le bas : plus de lignes, les mêmes colonnes. - Une colonne clé partagée, informations différentes — commandes + produits, reliés par
product. Utilisezpd.merge(a, b, on="product"). Le résultat s'agrandit vers la droite : les mêmes lignes, plus de colonnes.
Mars et avril sont tous deux des « commandes » — mêmes colonnes, plus de lignes — ils sont donc empilés. Les commandes et les produits sont des entités différentes reliées par le nom du produit, ils sont donc mis en correspondance.
3. Examinez chaque tableau avant de le combiner
Vous avez appris au chapitre trois à examiner un fichier dès sa lecture. Avec plusieurs fichiers, cette habitude n'est pas négociable : vous avez besoin de la forme (shape) de chacun pour prédire la forme du résultat.
import pandas as pd
march = pd.read_csv("orders.csv")
april = pd.read_csv("orders_april.csv")
products = pd.read_csv("products.csv")
for name, df in [("march", march), ("april", april), ("products", products)]:
print(name, df.shape, list(df.columns))march (8, 7) ['order_id', 'date', 'branch', 'product', 'category', 'quantity', 'price']
april (4, 7) ['order_id', 'date', 'branch', 'product', 'category', 'quantity', 'price']
products (6, 3) ['product', 'cost', 'supplier']Mars et avril ont exactement les sept mêmes colonnes, dans le même ordre — parfait pour un empilement. products partage exactement un nom de colonne avec eux : product. C'est la clé, la colonne sur laquelle portera la mise en correspondance.
Vérifiez que la clé a le même type des deux côtés. Une clé sous forme de nombre dans un tableau et de texte dans l'autre ne peut pas correspondre :
print(march["product"].dtype, products["product"].dtype)str strTous deux str. Ils peuvent être comparés.
4. Vérifiez que la clé est unique du côté de la table de correspondance
C'est la vérification la plus importante de tout le chapitre. Chaque commande a besoin d'un coût. Si products.csv liste pen deux fois, chaque commande de pen trouvera deux correspondances et apparaîtra deux fois dans le résultat.
print(products["product"].is_unique)
print(products["product"].duplicated().sum())True
0is_unique vaut True et il y a zéro doublon : une ligne par produit. Chaque commande peut correspondre à au plus une ligne de produit.
5. Vérifiez quelles clés ne trouveront pas de correspondance
Avant de fusionner, demandez-vous : y a-t-il des commandes dont le produit est absent de products.csv, et des produits que personne n'a commandés ? isin (vue dans le chapitre sur le filtrage) répond aux deux :
orders = pd.concat([march, april], ignore_index=True)
no_cost = ~orders["product"].isin(products["product"])
print(orders.loc[no_cost, ["order_id", "product"]])
never_sold = ~products["product"].isin(orders["product"])
print(products.loc[never_sold, "product"].tolist())order_id product
10 1011 stapler
['marker']Une commande — l'agrafeuse, 1011 — n'a aucun coût enregistré. Un produit, le feutre (marker), n'a jamais été vendu. Vous savez désormais avant la fusion à quoi celle-ci devra faire face.
6. Prédisez le résultat, puis décidez
Notez votre prédiction sous forme de nombres :
orderscompte 8 + 4 = 12 lignes.productsa une ligne par produit, donc la mise en correspondance ne peut pas démultiplier les commandes.- Si chaque commande est conservée, le tableau fusionné compte 12 lignes, et l'une d'entre elles (l'agrafeuse) n'a pas de coût.
- Si seules les commandes avec correspondance sont conservées, il en compte 11.
Que souhaitez-vous ? Pour un rapport de bénéfices, supprimer discrètement la commande d'agrafeuses sous-estimerait les ventes. Il vaut mieux la conserver, constater que son coût est manquant et décider de la marche à suivre — ce qui est exactement le choix opéré par l'argument how=. Après la fusion, la toute première chose que vous affichez est la forme (shape), et vous la comparez à cette prédiction.
Voilà tout le plan : formulation en une phrase, choix de l'opération par paire, examen de chaque tableau, unicité de la clé, clés sans correspondance, nombre de lignes prédit. Le reste du chapitre présente les outils pour le mettre en œuvre.
Empiler des lignes avec pd.concat
pd.concat prend une liste de tableaux et les place les uns sous les autres :
import pandas as pd
march = pd.read_csv("orders.csv")
april = pd.read_csv("orders_april.csv")
both = pd.concat([march, april])
print(both.shape)
print(both[["order_id", "date", "product", "quantity"]])(12, 7)
order_id date product quantity
0 1001 2024-03-01 pen 12
1 1002 2024-03-01 notebook 5
2 1003 2024-03-02 bag 2
3 1004 2024-03-02 bottle 7
4 1005 2024-03-03 eraser 30
5 1006 2024-03-03 bag 1
6 1007 2024-03-04 pen 20
7 1008 2024-03-04 bottle 4
0 1009 2024-04-01 pen 15
1 1010 2024-04-01 notebook 3
2 1011 2024-04-02 stapler 6
3 1012 2024-04-02 bag 1Douze lignes, les sept mêmes colonnes. Pandas a aligné les colonnes par nom, et non par position — si le fichier d'avril avait listé les colonnes dans un ordre différent, elles se retrouveraient tout de même sous les bons intitulés.
Mais regardez le bord gauche. L'index va de 0 à 7, puis recommence à 0. concat a conservé l'index propre à chaque tableau, de sorte que les étiquettes 0 à 3 apparaissent désormais deux fois. Ce n'est pas un simple problème esthétique :
print(both.loc[0, ["order_id", "date"]])order_id date
0 1001 2024-03-01
0 1009 2024-04-01Vous avez demandé la ligne 0 et obtenu deux lignes. Tout code qui suppose qu'une étiquette désigne une seule ligne — loc, une fusion ultérieure sur l'index, la mise à jour d'une seule cellule — fait désormais quelque chose que vous n'aviez pas prévu. Vous pouvez le vérifier :
print(both.index.is_unique)Falseignore_index=True — renuméroter les lignes
Lorsque l'ancien index n'était qu'un simple comptage de lignes (un RangeIndex, comme avec chaque appel à read_csv jusqu'ici), il n'a aucune signification particulière ; oubliez-le et laissez concat numéroter le résultat à partir de zéro :
orders = pd.concat([march, april], ignore_index=True)
print(orders.index.is_unique)
print(orders[["order_id", "date", "product"]].tail(5))True
order_id date product
7 1008 2024-03-04 bottle
8 1009 2024-04-01 pen
9 1010 2024-04-01 notebook
10 1011 2024-04-02 stapler
11 1012 2024-04-02 bagDésormais, les étiquettes vont de 0 à 11, chacune apparaissant exactement une fois. Faites de ignore_index=True votre réflexe par défaut lors de l'empilement de fichiers ; ne l'omettez que lorsque l'index a un sens (par exemple, après set_index("order_id"), où les identifiants de commande sont déjà uniques).
Garder une trace de l'origine de chaque ligne
Après l'empilement, rien dans le tableau n'indique à quel mois appartient une ligne. Ici, la date vous l'apprend, mais ce n'est souvent pas le cas. Ajoutez une colonne avant de concaténer — la technique vue au chapitre précédent — et l'information suivra les lignes :
march["month"] = "March"
april["month"] = "April"
orders = pd.concat([march, april], ignore_index=True)
print(orders["month"].value_counts())month
March 8
April 4
Name: count, dtype: int64Ces effectifs font office de vérification : 8 + 4 = 12, rien n'a été perdu.
Quand les colonnes ne correspondent pas
Concat aligne les colonnes par nom. Une colonne présente dans certains tableaux seulement apparaît tout de même dans le résultat — et les lignes des tableaux qui ne la possédaient pas y reçoivent NaN. Supposons qu'une commande ponctuelle ait été saisie à la main, avec une colonne discount et sans date, product, category ni price :
manual = pd.DataFrame({
"order_id": [2001],
"branch": ["north"],
"quantity": [3],
"discount": [0.1],
})
mixed = pd.concat([march.head(2), manual], ignore_index=True)
print(mixed[["order_id", "branch", "price", "discount"]])
print(mixed.isna().sum())order_id branch price discount
0 1001 north 15.0 NaN
1 1002 south 60.0 NaN
2 2001 north NaN 0.1
order_id 0
date 1
branch 0
product 1
category 1
quantity 0
price 1
month 1
discount 2
dtype: int64Aucune erreur. Le résultat contient l'union de toutes les colonnes, et les vides sont comblés par NaN. C'est pourquoi un empilement mérite le même isna().sum() que celui appris au chapitre sur les données manquantes : un nom de colonne orthographié différemment dans un fichier (Quantity au lieu de quantity) n'échoue pas, il produit deux colonnes à moitié vides. Si le nombre de colonnes après un concat est supérieur à celui de n'importe quel tableau en entrée, quelque chose ne s'est pas aligné correctement.
axis=1 — et pourquoi vous ne le voulez généralement pas
pd.concat([a, b], axis=1) place plutôt les tableaux côte à côte, en faisant correspondre les lignes par étiquette d'index. C'est tentant pour « ajouter les colonnes de produit aux commandes », mais cela ne regarde pas du tout le nom du produit — la ligne 0 d'un tableau se retrouve à côté de la ligne 0 de l'autre, quel que soit leur contenu. Pour faire correspondre sur une colonne, utilisez merge.
Faire correspondre des lignes avec pd.merge
merge prend deux tableaux et une clé. Pour chaque ligne du tableau de gauche, elle recherche les lignes du tableau de droite ayant la même valeur de clé, et colle leurs colonnes ensemble :
products = pd.read_csv("products.csv")
merged = pd.merge(orders, products, on="product")
print(merged.shape)
print(merged[["order_id", "product", "quantity", "price", "cost", "supplier"]])(11, 10)
order_id product quantity price cost supplier
0 1001 pen 12 15.0 9.0 Alpha
1 1002 notebook 5 60.0 40.0 Alpha
2 1003 bag 2 850.0 600.0 Bravo
3 1004 bottle 7 120.0 80.0 Bravo
4 1005 eraser 30 8.0 5.0 Alpha
5 1006 bag 1 850.0 600.0 Bravo
6 1007 pen 20 15.0 9.0 Alpha
7 1008 bottle 4 120.0 80.0 Bravo
8 1009 pen 15 15.0 9.0 Alpha
9 1010 notebook 3 60.0 40.0 Alpha
10 1012 bag 1 850.0 600.0 BravoChaque commande a désormais son coût et son fournisseur à côté d'elle, sans boucle et sans que l'ordre des lignes de l'un ou l'autre fichier n'ait d'importance. La colonne clé product n'apparaît qu'une fois, car elle est identique des deux côtés.
Comparez maintenant la forme (shape) avec le plan. Nous avions prédit 12 lignes si tout était conservé — et nous en avons 11. La commande d'agrafeuses, 1011, a disparu. Aucun avertissement, aucune erreur.
Ce n'est pas un bug. C'est le comportement par défaut de la fusion, how="inner", qui fait exactement ce pour quoi elle est définie : ne conserver que les clés présentes dans les deux tableaux. L'agrafeuse n'est pas dans products, sa commande ne survit donc pas ; le feutre n'est pas dans orders, il n'apparaît donc pas non plus. Si vous n'aviez pas prédit 12 lignes, vous n'auriez jamais remarqué la vente manquante.
Les quatre types de fusion
L'argument how= décide du sort des lignes dont la clé n'a aucun partenaire de l'autre côté. Imaginez les deux ensembles de clés :
how="inner"(par défaut) ne conserve que les clés présentes dans les deux tableaux. Les lignes sans correspondance de part et d'autre sont supprimées. Utilisez-le lorsque vous ne voulez que des enregistrements complets.how="left"conserve chaque ligne du tableau de gauche. Les lignes de gauche sans partenaire reçoiventNaNdans les colonnes du tableau de droite ; les lignes de droite sans correspondance sont supprimées. Utilisez-le pour enrichir un tableau principal — c'est le choix le plus courant.how="right"est l'image miroir : chaque ligne du tableau de droite est conservée, les lignes de gauche sans correspondance sont supprimées.how="outer"conserve chaque clé de part et d'autre, en comblant les vides avecNaN. Utilisez-le pour auditer deux listes l'une par rapport à l'autre.
La question à se poser est : quel est le tableau dont les lignes ne doivent pas disparaître ? Ici, les commandes. Chaque commande est une vraie vente ; le fichier des produits est une table de correspondance. La bonne fusion est donc une fusion left, avec les commandes à gauche :
merged = pd.merge(orders, products, on="product", how="left")
print(merged.shape)
print(merged[["order_id", "product", "cost", "supplier"]])(12, 10)
order_id product cost supplier
0 1001 pen 9.0 Alpha
1 1002 notebook 40.0 Alpha
2 1003 bag 600.0 Bravo
3 1004 bottle 80.0 Bravo
4 1005 eraser 5.0 Alpha
5 1006 bag 600.0 Bravo
6 1007 pen 9.0 Alpha
7 1008 bottle 80.0 Bravo
8 1009 pen 9.0 Alpha
9 1010 notebook 40.0 Alpha
10 1011 stapler NaN NaN
11 1012 bag 600.0 BravoDouze lignes, comme prévu. La commande d'agrafeuses est toujours là, avec NaN pour cost et supplier — ce qui est honnête : le coût est inconnu, et vous pouvez voir qu'il l'est. Une valeur manquante visible est bien préférable à une ligne manquante invisible. À partir de là, les outils du chapitre sur les données manquantes s'appliquent : vous pouvez compter les vides avec isna().sum(), renseigner le coût si le service des achats vous le communique, ou signaler la commande séparément.
Une fusion right est l'image miroir — chaque produit est conservé, le feutre sans aucune commande :
right = pd.merge(orders, products, on="product", how="right")
print(right.shape)
print(right[["order_id", "product", "cost"]].tail(3))(12, 10)
order_id product cost
9 1008.0 bottle 80.0
10 1005.0 eraser 5.0
11 NaN marker 25.0Remarquez que order_id est passé d'entiers à 1008.0, 1005.0 et ainsi de suite. La ligne du feutre n'a pas d'identifiant de commande, la colonne contient donc désormais un NaN — et, comme vous l'avez vu au chapitre sur les données manquantes, une colonne d'entiers qui reçoit un NaN devient float64. Si les identifiants se mettent soudain à arborer un .0 après une fusion, c'est pour cette raison : certaines lignes n'ont trouvé aucun partenaire.
indicator=True — voir d'où provient chaque ligne
Une fusion outer conserve tout des deux côtés. Ajoutez indicator=True et pandas ajoute une colonne, _merge, qui indique pour chaque ligne si sa clé a été trouvée dans les deux tableaux, seulement à gauche ou seulement à droite :
audit = pd.merge(orders, products, on="product", how="outer", indicator=True)
print(audit[["order_id", "product", "cost", "_merge"]])
print(audit["_merge"].value_counts())order_id product cost _merge
0 1003.0 bag 600.0 both
1 1006.0 bag 600.0 both
2 1012.0 bag 600.0 both
3 1004.0 bottle 80.0 both
4 1008.0 bottle 80.0 both
5 1005.0 eraser 5.0 both
6 NaN marker 25.0 right_only
7 1002.0 notebook 40.0 both
8 1010.0 notebook 40.0 both
9 1001.0 pen 9.0 both
10 1007.0 pen 9.0 both
11 1009.0 pen 9.0 both
12 1011.0 stapler NaN left_only
_merge
both 11
left_only 1
right_only 1
Name: count, dtype: int64C'est le bulletin de notes de la fusion. Onze lignes ont trouvé une correspondance ; une commande n'a pas de ligne de produit (left_only : l'agrafeuse) ; un produit n'a aucune commande (right_only : le feutre). C'est la même information qu'à l'étape 5 du plan, produite par la fusion elle-même.
La fusion outer a également trié les lignes selon la clé — bag, bottle, eraser, … — plutôt que de conserver l'ordre des commandes. Les fusions inner et left conservent l'ordre du tableau de gauche ; outer ne le garantit pas. Si l'ordre importe, triez après coup, comme au chapitre sept.
La colonne _merge est une donnée ordinaire, vous pouvez donc filtrer dessus. Lister uniquement les problèmes se fait en une ligne :
problems = audit[audit["_merge"] != "both"]
print(problems[["order_id", "product", "_merge"]])order_id product _merge
6 NaN marker right_only
12 1011.0 stapler left_onlyExécutez ce genre d'audit chaque fois que vous fusionnez des données que vous n'avez pas créées vous-même. Lorsque la liste des problèmes est vide, vous avez la preuve que les deux fichiers concordent.
Le danger silencieux : les doublons de clés
Jusqu'ici, products comptait une ligne par produit. Supposons que le service des achats ajoute un second fournisseur de stylos à un coût légèrement supérieur et se contente d'ajouter une ligne, de sorte que pen apparaisse deux fois :
products_dup = pd.concat(
[products, pd.DataFrame({"product": ["pen"], "cost": [10.0], "supplier": ["Gamma"]})],
ignore_index=True,
)
print(products_dup["product"].is_unique)
exploded = pd.merge(march, products_dup, on="product", how="left")
print(march.shape, "->", exploded.shape)
print(exploded[["order_id", "product", "quantity", "cost", "supplier"]])False
(8, 8) -> (10, 10)
order_id product quantity cost supplier
0 1001 pen 12 9.0 Alpha
1 1001 pen 12 10.0 Gamma
2 1002 notebook 5 40.0 Alpha
3 1003 bag 2 600.0 Bravo
4 1004 bottle 7 80.0 Bravo
5 1005 eraser 30 5.0 Alpha
6 1006 bag 1 600.0 Bravo
7 1007 pen 20 9.0 Alpha
8 1007 pen 20 10.0 Gamma
9 1008 bottle 4 80.0 BravoMars comptait 8 commandes ; la fusion a renvoyé 10 lignes. Les commandes 1001 et 1007 — les deux commandes de stylos — apparaissent chacune deux fois, une fois par ligne pen. Rien n'a planté. Mais calculez la somme des quantités maintenant, et les stylos sont comptés deux fois : 32 stylos supplémentaires qui n'ont jamais été vendus.
print(march["quantity"].sum(), exploded["quantity"].sum())81 11381 face à 113. C'est ce qu'on appelle une explosion de lignes (row explosion), et c'est l'erreur de fusion la plus coûteuse car le résultat semble normal. La règle sous-jacente : une fusion associe chaque ligne correspondante de gauche avec chaque ligne correspondante de droite. Une relation un-à-un ou plusieurs-à-un conserve le nombre de lignes ; une clé répétée des deux côtés le multiplie.
Une fusion qui ajoute des lignes ne vous prévient jamais. Le seul symptôme est un total trop élevé, et un total trop élevé paraît rarement suspect. Comparez len() avant et après chaque fusion — c'est l'unique vérification qui permet d'intercepter cela.validate= — laisser pandas vérifier pour vous
Vous pouvez spécifier la relation attendue, et pandas refusera la fusion si les données l'enfreignent. De nombreuses commandes partagent un même produit, cette fusion doit donc être de type plusieurs-à-un (many-to-one) :
try:
pd.merge(march, products_dup, on="product", how="left", validate="many_to_one")
except Exception as error:
print(type(error).__name__ + ":", error)MergeError: Merge keys are not unique in right dataset; not a many-to-one merge
Duplicates in right:
product
pen ...Une MergeError, qui énumère même la clé coupable. Les autres valeurs possibles sont "one_to_one", "one_to_many" et "many_to_many". Écrire validate= ne coûte rien et transforme une réponse fausse et silencieuse en une erreur explicite, mettez-le donc systématiquement sur chaque fusion avec une table de correspondance.
Résoudre le problème : décider quel doublon est le bon
L'erreur vous signale qu'un problème existe ; décider de ce qui est correct relève de votre rôle. Observez d'abord les doublons :
print(products_dup[products_dup["product"].duplicated(keep=False)])product cost supplier
0 pen 9.0 Alpha
6 pen 10.0 Gammaduplicated(keep=False) signale chaque exemplaire, pas seulement le second. Il faut maintenant prendre une décision métier. Si la ligne la plus récente remplace l'ancienne, conservez le dernier exemplaire :
products_clean = products_dup.drop_duplicates(subset="product", keep="last")
fixed = pd.merge(march, products_clean, on="product", how="left", validate="many_to_one")
print(fixed.shape)
print(fixed.loc[fixed["product"] == "pen", ["order_id", "cost", "supplier"]])(8, 10)
order_id cost supplier
0 1001 10.0 Gamma
6 1007 10.0 GammaRetour à 8 lignes, une par commande. Si au contraire les deux fournisseurs vendent réellement des stylos et qu'une commande enregistre lequel a été choisi, alors supplier a également sa place dans les commandes, et la clé de fusion devient une liste de deux colonnes : on=["product", "supplier"]. Le code ne peut pas faire ce choix ; la planification le peut.
Quand les colonnes clés ont des noms différents
branches.csv nomme sa colonne branch_name, tandis que les commandes la nomment branch. on= exige le même nom des deux côtés ; indiquez donc chaque côté séparément avec left_on= et right_on= :
branches = pd.read_csv("branches.csv")
with_manager = pd.merge(orders, branches, left_on="branch", right_on="branch_name", how="left")
print(list(with_manager.columns))['order_id', 'date', 'branch', 'product', 'category', 'quantity', 'price', 'month', 'branch_name', 'manager']Les deux colonnes clés sont conservées, car pandas ne peut pas savoir que vous les considérez comme une seule et même colonne. branch et branch_name contiennent désormais des valeurs identiques, supprimez-en donc une :
with_manager = with_manager.drop(columns="branch_name")
print(with_manager[["order_id", "branch", "manager"]].head(4))order_id branch manager
0 1001 north Mira
1 1002 south Omar
2 1003 north Mira
3 1004 east LenaL'autre solution consiste à renommer avant la fusion — branches.rename(columns={"branch_name": "branch"}) — puis à utiliser simplement on="branch". Les deux approches sont valables ; renommer garde le résultat propre sans étape de nettoyage ultérieure.
Quand les deux tableaux ont une colonne du même nom
Si une colonne qui n'est pas la clé existe dans les deux tableaux, pandas ne peut pas placer deux colonnes nommées price dans un seul tableau ; il les renomme donc avec les suffixes _x (gauche) et _y (droite). Imaginez un tableau de prix catalogue qui nomme également sa colonne price :
list_prices = pd.DataFrame({"product": ["pen", "bag"], "price": [14.0, 800.0]})
compared = pd.merge(march, list_prices, on="product")
print(compared[["order_id", "product", "price_x", "price_y"]])order_id product price_x price_y
0 1001 pen 15.0 14.0
1 1003 bag 850.0 800.0
2 1006 bag 850.0 800.0
3 1007 pen 15.0 14.0price_x et price_y fonctionnent, mais dans trois semaines personne ne se souviendra de laquelle est laquelle. Donnez-leur un nom explicite vous-même avec suffixes= :
compared = pd.merge(march, list_prices, on="product", suffixes=("_sold", "_list"))
print(compared[["order_id", "price_sold", "price_list"]])order_id price_sold price_list
0 1001 15.0 14.0
1 1003 850.0 800.0
2 1006 850.0 800.0
3 1007 15.0 14.0Désormais, les colonnes indiquent clairement ce qu'elles représentent, et la remise accordée sur chaque commande s'obtient simplement par price_list - price_sold. (Un code qui demanderait plus tard compared["price"] obtiendrait une KeyError, car après la fusion aucune colonne ne porte exactement ce nom — une raison supplémentaire de choisir ces noms délibérément.)
join — fusionner par l'index, en bref
Les DataFrames possèdent également une méthode .join(). Il s'agit d'une fusion qui utilise l'index du tableau de droite comme clé — très pratique lorsque la table de correspondance est déjà indexée par la clé :
by_product = products.set_index("product")
joined = orders.join(by_product, on="product")
print(joined[["order_id", "product", "cost", "supplier"]].head(3))order_id product cost supplier
0 1001 pen 9.0 Alpha
1 1002 notebook 40.0 Alpha
2 1003 bag 600.0 Bravojoin utilise par défaut how="left". Elle effectue le même travail que pd.merge(..., how="left") ; merge est l'outil le plus généraliste, et celui utilisé dans la suite de ce cours.
Un exemple complet
Revenons à la question de la propriétaire : le bénéfice par fournisseur sur mars et avril, en tenant compte de chaque commande.
supplier_profit.py :
import pandas as pd
march = pd.read_csv("orders.csv")
april = pd.read_csv("orders_april.csv")
products = pd.read_csv("products.csv")
# 1. Stack the two months, remembering where each row came from.
march["month"] = "March"
april["month"] = "April"
orders = pd.concat([march, april], ignore_index=True)
print("orders:", orders.shape)
# 2. The lookup key must be unique, or the merge will duplicate orders.
assert products["product"].is_unique
# 3. Attach cost and supplier to every order. Keep every order.
sales = pd.merge(
orders, products, on="product", how="left",
validate="many_to_one", indicator=True,
)
print("after merge:", sales.shape)
assert len(sales) == len(orders)
# 4. Report the orders that found no product row, then set them aside.
unmatched = sales[sales["_merge"] == "left_only"]
print("no cost on file:", unmatched["order_id"].tolist(), unmatched["product"].tolist())
sales = sales[sales["_merge"] == "both"].drop(columns="_merge")
# 5. Profit per row, then per supplier and month.
sales["profit"] = sales["quantity"] * (sales["price"] - sales["cost"])
report = (
sales.groupby(["supplier", "month"], as_index=False)["profit"]
.sum()
.sort_values(["supplier", "profit"], ascending=[True, False])
.reset_index(drop=True)
)
print()
print(report)
print()
print(sales.groupby("supplier")["profit"].sum().sort_values(ascending=False))orders: (12, 8)
after merge: (12, 11)
no cost on file: [1011] ['stapler']
supplier month profit
0 Alpha March 382.0
1 Alpha April 150.0
2 Bravo March 1190.0
3 Bravo April 250.0
supplier
Bravo 1440.0
Alpha 532.0
Name: profit, dtype: float64Pourquoi c'est écrit de cette façon :
- Chaque étape de combinaison est suivie d'une vérification de forme (shape).
orders: (12, 8)confirme l'empilement (8 + 4 lignes, 7 colonnes +month).after merge: (12, 11)confirme que rien n'a été supprimé ni dupliqué — 3 nouvelles colonnes :cost,supplier,_merge. Les dimensions constituent la preuve la plus simple et la plus directe que chaque étape a bien suivi le plan. - Les hypothèses sont écrites sous forme de code.
assert products["product"].is_uniqueetvalidate="many_to_one"protègent toutes deux contre l'explosion de lignes ;assert len(sales) == len(orders)vérifie la prédiction établie dans le plan. Si quelqu'un envoie le fichier de produits du mois prochain avec un doublon, le script s'arrête au lieu d'afficher un bénéfice gonflé. how="left"conserve la commande d'agrafeuses, etindicator=Truela désigne nommément. Le script ne la supprime pas discrètement ; il affiche[1011] ['stapler'], de sorte que la propriétaire sait qu'il manque une vente au total d'avril en attendant que les achats fournissent un coût. Cette ligne fait partie de la réponse, ce n'est pas une simple trace de débogage.- La colonne
montha été ajoutée avantconcat. Dans le cas contraire, la ventilation par mois dans le rapport aurait nécessité d'analyser et de convertir les dates — faisable, mais demandant plus de travail pour le même résultat. - Le bénéfice est calculé après la fusion, car il nécessite des colonnes issues des deux fichiers. C'est l'ordre naturel des opérations : combiner, puis ajouter des colonnes (chapitre neuf), puis regrouper (chapitre huit), puis trier (chapitre sept).
La réponse : Bravo a généré 1 440 de bénéfice et Alpha 532, avec une vente d'avril toujours en attente de son coût. Bravo vend peu d'articles, mais les sacs et les bouteilles offrent une marge par article bien plus importante que les stylos et les gommes.
Quand ça plante
ValueError: You are trying to merge on int64 and str columns for key 'order_id'. If you wish to proceed you should use pd.concat
La clé a un type différent de chaque côté. Voici un fichier de livraisons dont les identifiants de commande ont été exportés avec un # au début. Enregistrez-le sous le nom deliveries.csv — il est également utilisé dans « À vous de jouer ». (Si vous avez encore le journal des livreurs du même nom issu du chapitre sur les données manquantes, écrasez-le ; il s'agit d'un fichier différent.)
order_id,status
#1001,delivered
#1002,delivered
#1003,returned
#1005,delivered
#1006,pendingimport pandas as pd
march = pd.read_csv("orders.csv")
deliveries = pd.read_csv("deliveries.csv")
print(march["order_id"].dtype, deliveries["order_id"].dtype)
try:
pd.merge(march, deliveries, on="order_id", how="left")
except ValueError as error:
print("ValueError:", error)int64 str
ValueError: You are trying to merge on int64 and str columns for key 'order_id'. If you wish to proceed you should use pd.concat1001 et "#1001" sont des valeurs différentes, et pandas refuse de deviner. (La suggestion concernant pd.concat s'applique à une autre situation ; ignorez-la.) La solution consiste à donner aux clés le même type et le même texte — retirez le #, puis convertissez :
deliveries["order_id"] = deliveries["order_id"].str.removeprefix("#").astype("int64")
status = pd.merge(march, deliveries, on="order_id", how="left")
print(status[["order_id", "product", "status"]])order_id product status
0 1001 pen delivered
1 1002 notebook delivered
2 1003 bag returned
3 1004 bottle NaN
4 1005 eraser delivered
5 1006 bag pending
6 1007 pen NaN
7 1008 bottle NaNLes commandes sans enregistrement de livraison reçoivent NaN pour status — la fusion left les conserve, ce qui est exactement ce que vous voulez lorsque la question est : « quelles commandes n'ont pas été livrées ? »
MergeError: Merge keys are not unique in right dataset; not a many-to-one merge Vous avez utilisé validate="many_to_one" et le tableau de droite répète une clé. Le message énumère les doublons. Ne supprimez pas validate pour faire disparaître l'erreur — observez les lignes en double avec duplicated(keep=False) et déterminez laquelle est la bonne, comme dans la section sur l'explosion de lignes.
KeyError: 'branch' La colonne désignée dans on= est absente de l'un des tableaux :
branches = pd.read_csv("branches.csv")
try:
pd.merge(march, branches, on="branch")
except KeyError as error:
print("KeyError:", error)KeyError: 'branch'branches la nomme branch_name. Affichez list(df.columns) pour les deux tableaux ; utilisez left_on="branch", right_on="branch_name", ou renommez l'un des côtés. Un espace en fin de chaîne dans un nom de colonne ("branch ") provoque la même erreur et n'est visible que sous forme de liste.
La fusion s'est exécutée, mais les nouvelles colonnes sont toutes à NaN Aucune erreur, et pourtant chaque ligne est sans correspondance. Les clés semblent identiques mais ne le sont pas : "north " avec un espace final, "South" avec une majuscule, ou le même identifiant sous forme de texte dans un fichier et avec un préfixe dans l'autre. Vérifiez avec isin avant de fusionner :
branches_messy = pd.DataFrame({"branch_name": ["north ", "South", "east"], "manager": ["Mira", "Omar", "Lena"]})
print(march["branch"].isin(branches_messy["branch_name"]).sum(), "of", len(march), "orders match")
branches_messy["branch_name"] = branches_messy["branch_name"].str.strip().str.lower()
print(march["branch"].isin(branches_messy["branch_name"]).sum(), "of", len(march), "orders match")2 of 8 orders match
8 of 8 orders matchDeux sur huit avant nettoyage, huit sur huit après. Nettoyez la clé de la même façon des deux côtés — str.strip() et une casse uniforme — avant de fusionner.
Le nombre de lignes a augmenté après une fusion Le tableau de droite comporte une clé en double ; voir « Le danger silencieux » ci-dessus. Comparez len() avant et après chaque fusion, et utilisez validate=.
Des lignes ont disparu après une fusion Vous avez utilisé la valeur par défaut how="inner", et certaines clés n'avaient aucun partenaire. Utilisez how="left" avec le tableau principal à gauche, et indicator=True pour repérer quelles lignes étaient sans correspondance.
TypeError: concat() takes 1 positional argument but 2 were given
try:
pd.concat(march, deliveries)
except TypeError as error:
print("TypeError:", error)TypeError: concat() takes 1 positional argument but 2 were givenconcat prend un seul argument, une liste de tableaux. Écrivez pd.concat([march, april]) — tout est dans les crochets.
Un index dupliqué après concat, et loc renvoie deux lignes Chaque entrée a conservé ses propres 0, 1, 2, …. Ajoutez ignore_index=True.
Étape 4 sur 6 — Prédire
Vérifiez votre compréhension
Mars compte 8 commandes et avril 4. Qu'est-ce qui est affiché ?
from io import StringIO
import pandas as pd
# Stand in for orders.csv, orders_april.csv and products.csv,
# so this snippet runs on its own.
MARCH = """order_id,date,branch,product,category,quantity,price
1001,2024-03-01,north,pen,stationery,12,15.0
1002,2024-03-01,south,notebook,stationery,5,60.0
1003,2024-03-02,north,bag,accessories,2,850.0
1004,2024-03-02,east,bottle,accessories,7,120.0
1005,2024-03-03,north,eraser,stationery,30,8.0
1006,2024-03-03,south,bag,accessories,1,850.0
1007,2024-03-04,east,pen,stationery,20,15.0
1008,2024-03-04,north,bottle,accessories,4,120.0
"""
APRIL = """order_id,date,branch,product,category,quantity,price
1009,2024-04-01,north,pen,stationery,15,15.0
1010,2024-04-01,east,notebook,stationery,3,60.0
1011,2024-04-02,south,stapler,stationery,6,95.0
1012,2024-04-02,north,bag,accessories,1,850.0
"""
PRODUCTS = """product,cost,supplier
pen,9.0,Alpha
notebook,40.0,Alpha
bag,600.0,Bravo
bottle,80.0,Bravo
eraser,5.0,Alpha
marker,25.0,Alpha
"""
march = pd.read_csv(StringIO(MARCH))
april = pd.read_csv(StringIO(APRIL))
products = pd.read_csv(StringIO(PRODUCTS))
both = pd.concat([march, april])
print(both.shape, both.index[-1])- A(12, 7) 11
- B(12, 14) 3
- C(12, 7) 3
- D(8, 7) 7
Quelqu'un a ajouté une deuxième ligne pour pen à la liste des produits. Qu'est-ce qui est affiché ?
from io import StringIO
import pandas as pd
# Stand in for orders.csv, orders_april.csv and products.csv,
# so this snippet runs on its own.
MARCH = """order_id,date,branch,product,category,quantity,price
1001,2024-03-01,north,pen,stationery,12,15.0
1002,2024-03-01,south,notebook,stationery,5,60.0
1003,2024-03-02,north,bag,accessories,2,850.0
1004,2024-03-02,east,bottle,accessories,7,120.0
1005,2024-03-03,north,eraser,stationery,30,8.0
1006,2024-03-03,south,bag,accessories,1,850.0
1007,2024-03-04,east,pen,stationery,20,15.0
1008,2024-03-04,north,bottle,accessories,4,120.0
"""
APRIL = """order_id,date,branch,product,category,quantity,price
1009,2024-04-01,north,pen,stationery,15,15.0
1010,2024-04-01,east,notebook,stationery,3,60.0
1011,2024-04-02,south,stapler,stationery,6,95.0
1012,2024-04-02,north,bag,accessories,1,850.0
"""
PRODUCTS = """product,cost,supplier
pen,9.0,Alpha
notebook,40.0,Alpha
bag,600.0,Bravo
bottle,80.0,Bravo
eraser,5.0,Alpha
marker,25.0,Alpha
"""
march = pd.read_csv(StringIO(MARCH))
april = pd.read_csv(StringIO(APRIL))
products = pd.read_csv(StringIO(PRODUCTS))
products = pd.concat([products, pd.DataFrame({"product": ["pen"], "cost": [10.0], "supplier": ["Charlie"]})])
report = pd.merge(march, products, on="product", how="left")
print(len(march), len(report))- A8 6
- B8 8
- CUne `MergeError` concernant des clés en double
- D8 10 — chaque commande de pen a correspondu aux deux lignes pen et a été doublée, sans aucune erreur
Chaque commande de mars a besoin du coût de son produit, et aucune commande ne doit être perdue, même si son produit est absent de la liste. Quel how= utilisez-vous ?
- A`how="outer"`
- B`how="right"`
- C`how="inner"`
- D`how="left"`, avec les commandes comme tableau de gauche
Répondre nécessite un compte
Connectez-vous pour vérifier vos réponses
Les questions sont ci-dessus, et les résoudre de tête est ce qui compte vraiment. Connectez-vous pour voir les réponses, les explications et les indices à trois niveaux.
À vous de jouer
L'équipe de livraison a envoyé deliveries.csv (présenté dans « Quand ça plante ») — une ligne par commande pour laquelle elle dispose d'un enregistrement, avec des identifiants de commande écrits sous la forme #1001. La propriétaire souhaite un rapport de livraison couvrant mars et avril réunis.
Écrivez delivery_report.py de sorte que :
- Chaque commande des deux mois soit conservée — exactement une ligne par commande, vérifiée avec un
assertsur le nombre de lignes, et la fusion protégée parvalidate=et auditée avecindicator=True. - Les clés correspondent réellement — la clé de livraison est nettoyée pour avoir le même type que
order_id, et le script s'arrête si une commande apparaît deux fois dans le fichier des livraisons. - Manquant signifie « aucun enregistrement » — les commandes sans ligne de livraison reçoivent le statut
"no record", jamais unNaNsilencieux. - Il affiche, dans cet ordre : la forme (shape) après empilement, la forme après fusion, les effectifs de
_merge, le nombre de commandes par statut, les commandes sans enregistrement (order_id,branch,product, triées parorder_id), et le nombre de commandes livrées par succursale.
Lorsque tout est correct, la sortie est exactement :
orders: (12, 7)
after merge: (12, 9)
_merge
left_only 7
both 5
right_only 0
Name: count, dtype: int64
status
no record 7
delivered 3
returned 1
pending 1
Name: count, dtype: int64
order_id branch product
3 1004 east bottle
6 1007 east pen
7 1008 north bottle
8 1009 north pen
9 1010 east notebook
10 1011 south stapler
11 1012 north bag
branch
north 2
south 1
dtype: int64Avant d'écrire la moindre ligne de code, expliquez chaque nombre de cette sortie à partir des seuls fichiers : pourquoi 12 lignes après l'empilement, pourquoi toujours 12 après la fusion, et pourquoi 7 commandes sans enregistrement. Si vous n'y parvenez pas, c'est que le plan n'est pas encore terminé.
Ensuite, provoquez volontairement deux pannes :
- Supprimez
.str.removeprefix("#"). Quelle erreur obtenez-vous, et quelle ligne la déclenche ? - Ajoutez une deuxième ligne pour
#1003dansdeliveries.csv(par exemple,#1003,delivered). Que faitvalidate=maintenant ? Qu'aurait affiché le rapport sans lui ?
Solution
Le plan. La question : « pour chaque commande de mars et avril, quel est son statut de livraison ? » — le résultat compte donc une ligne par commande, soit 12 lignes. Mars et avril ont des colonnes identiques : empilez-les avec concat et ignore_index=True (8 + 4 = 12). Le fichier des livraisons a pour clé l'identifiant de commande, mais sous forme de texte avec un #, la clé a donc besoin d'être nettoyée avant de pouvoir correspondre à l'entier order_id. Chaque commande doit y figurer au plus une fois, la relation est donc de type un-à-un (chaque commande a au plus une ligne de livraison, chaque ligne de livraison correspond à une commande). Chaque commande doit survivre, les commandes vont donc à gauche et la fusion est de type how="left". Il existe cinq lignes de livraison, toutes pour mars, donc 12 − 5 = 7 commandes doivent se retrouver sans enregistrement.
delivery_report.py :
import pandas as pd
march = pd.read_csv("orders.csv")
april = pd.read_csv("orders_april.csv")
deliveries = pd.read_csv("deliveries.csv")
# 1. Stack the months.
orders = pd.concat([march, april], ignore_index=True)
print("orders:", orders.shape)
# 2. Clean the key, then check it.
deliveries["order_id"] = deliveries["order_id"].str.removeprefix("#").astype("int64")
assert deliveries["order_id"].is_unique, "an order appears twice in deliveries.csv"
# 3. Keep every order.
report = pd.merge(
orders, deliveries, on="order_id", how="left",
validate="one_to_one", indicator=True,
)
print("after merge:", report.shape)
assert len(report) == len(orders)
print(report["_merge"].value_counts())
# 4. Missing status means the delivery team has no record.
report["status"] = report["status"].fillna("no record")
# 5. Orders per status.
print()
print(report["status"].value_counts())
# 6. Orders with no record.
missing = report.loc[report["status"] == "no record", ["order_id", "branch", "product"]]
print()
print(missing.sort_values("order_id"))
# 7. Delivered orders per branch.
delivered = report[report["status"] == "delivered"]
print()
print(delivered.groupby("branch").size())orders: (12, 7)
after merge: (12, 9)
_merge
left_only 7
both 5
right_only 0
Name: count, dtype: int64
status
no record 7
delivered 3
returned 1
pending 1
Name: count, dtype: int64
order_id branch product
3 1004 east bottle
6 1007 east pen
7 1008 north bottle
8 1009 north pen
9 1010 east notebook
10 1011 south stapler
11 1012 north bag
branch
north 2
south 1
dtype: int64Toutes les prédictions se confirment : 12 lignes après l'empilement, 12 après la fusion, 7 sans enregistrement. Les effectifs de _merge le confirment de façon indépendante — 5 both, 7 left_only, 0 right_only (une fusion left ne produit jamais de right_only).
Remarques sur les choix effectués :
validate="one_to_one", et non"many_to_one". Le tableau des commandes possède également des identifiants de commande uniques, les deux côtés sont donc uniques. Cette vérification plus stricte intercepterait également un identifiant de commande dupliqué dans les commandes — par exemple, si le fichier d'avril répétait par erreur une commande de mars.- L'
assertsuris_uniqueest placé avant la fusion, avec un message, de sorte que l'échec indique ce qui ne va pas en termes clairs plutôt qu'en jargon de fusion. fillna("no record")est appliqué après la fusion, jamais avant : les statuts manquants n'existent qu'une fois que les commandes sans ligne de livraison ont été conservées par la fusion left.- La succursale east ne compte aucune commande livrée, elle n'apparaît donc pas du tout dans le dernier tableau. Un regroupement ne produit que les groupes qui existent dans les données — si la propriétaire a besoin d'un 0 pour east, il s'agirait d'une fusion de la liste des succursales avec ce résultat,
how="left", suivie d'unfillna(0).
Les deux pannes. Sans removeprefix, la ligne astype("int64") échoue en premier avec ValueError: invalid literal for int() with base 10: '#1001', car "#1001" ne peut pas être converti en nombre — l'erreur pointe sur la ligne de nettoyage, où doit précisément se trouver la correction. Si vous retirez également le astype, la fusion elle-même lève ValueError: You are trying to merge on int64 and str columns for key 'order_id'. Avec une deuxième ligne #1003, l'assert interrompt le script avec AssertionError: an order appears twice in deliveries.csv ; si vous retirez l'assert, validate="one_to_one" lève MergeError: Merge keys are not unique in right dataset; not a one-to-one merge. Sans aucun de ces deux garde-fous, le rapport aurait affiché 13 lignes pour 12 commandes et compté la commande 1003 deux fois — une fois returned, une fois delivered — et chaque total en dessous aurait été discrètement faussé.
Étape 6 sur 6
Défi — le quiz du chapitre
Dix questions, de la plus facile à la plus difficile. Les dernières sont difficiles exprès.
Connectez-vous pour faire le quiz