Ajouter des colonnes — créer les chiffres absents du fichier
Construire de nouvelles colonnes à partir de celles déjà présentes : l'arithmétique sur colonnes entières et pourquoi l'index décide de la destination de chaque valeur, assign, np.where et np.select, map, pd.cut, .str et .dt, transform pour comparer une ligne à son groupe, et apply en dernier recours.
Comment voulez-vous apprendre ?
Le problème que nous résolvons
La propriétaire de la boutique vous envoie un message : « Pour chaque commande, je veux voir combien d'argent elle a rapporté, s'il s'agissait d'une grosse commande, quel responsable en a la charge et quel jour de la semaine c'était. Et pour chaque commande, quelle part des recettes de sa succursale cela représente-t-il ? »
Vous ouvrez orders.csv. Il contient une colonne quantity et une colonne price, une colonne branch (laquelle des trois boutiques) et une colonne date. Aucune des informations demandées ne figure dans le fichier. Chacune d'elles doit être créée à partir des colonnes déjà présentes.
Si vous venez du Python classique, vos mains savent déjà quoi taper — une boucle sur les lignes :
import pandas as pd
orders = pd.read_csv("orders.csv")
revenues = []
for i in range(len(orders)):
revenues.append(orders.loc[i, "quantity"] * orders.loc[i, "price"])
orders["revenue"] = revenues
print(orders[["product", "quantity", "price", "revenue"]])product quantity price revenue
0 pen 12 15.0 180.0
1 notebook 5 60.0 300.0
2 bag 2 850.0 1700.0
3 bottle 7 120.0 840.0
4 eraser 30 8.0 240.0
5 bag 1 850.0 850.0
6 pen 20 15.0 300.0
7 bottle 4 120.0 480.0Cela fonctionne. C'est aussi l'habitude que ce chapitre a pour but de remplacer, pour trois raisons qui n'apparaissent que plus tard.
C'est lent : Python exécute le corps de la boucle une fois par ligne, donc un fichier d'un million de lignes représente un million de passages dans l'interpréteur. C'est long : quatre lignes pour une seule multiplication, et la liste demandée compte encore cinq colonnes supplémentaires. Et c'est fragile d'une façon difficile à déceler. La boucle compte les positions 0, 1, 2, … puis les recherche comme des étiquettes avec .loc. Cela ne fonctionne que tant que les deux coïncident. Filtrez d'abord le tableau — seulement les commandes de la succursale north, comme au chapitre cinq — et ce n'est plus le cas :
north = orders[orders["branch"] == "north"]
print(north.index.tolist())
revenues = []
for i in range(len(north)):
revenues.append(north.loc[i, "quantity"] * north.loc[i, "price"])[0, 2, 4, 7]
KeyError: 1Les lignes de north conservent leurs étiquettes d'origine 0, 2, 4, 7. La boucle demande l'étiquette 1, qui est une commande de south et n'existe pas dans north, donc elle plante. Des variantes encore plus pernicieuses de ce bug ne plantent pas du tout — elles lisent discrètement la mauvaise ligne.
Pandas aborde les choses autrement : on ne calcule pas une colonne ligne par ligne ; on la calcule en une seule fois, pour la colonne entière. C'est précisément l'objet de ce chapitre, et une fois ce principe assimilé, chaque colonne demandée par la propriétaire s'écrit en une seule ligne.
À la fin de ce chapitre, vous saurez
- Créer une nouvelle colonne par arithmétique sur des colonnes entières, et expliquer pourquoi aucune boucle n'est nécessaire
- Prédire comment pandas aligne les valeurs selon l'index lorsque vous assignez une
Series, et éviter lesNaNcausés par un désalignement - Ajouter des colonnes sans modifier le tableau d'origine grâce à
assign() - Choisir entre deux valeurs ou plus par ligne avec
np.whereetnp.select - Rechercher des valeurs avec
.map(), découper des nombres en intervalles avecpd.cut, et extraire des morceaux de texte et de dates avec.stret.dt - Comparer chaque ligne avec son groupe à l'aide de
groupby(...).transform(...) - Reconnaître quand
apply(axis=1)est justifié, et pourquoi il s'agit du dernier recours - Expliquer ce qu'implique le Copy-on-Write dans pandas 3 lors de l'ajout de colonnes à un tableau filtré
Prérequis : Regroupement et agrégation.
Créer le fichier d'abord
Chaque exemple de ce chapitre lit orders.csv, le même fichier utilisé depuis le chapitre quatre. Si vous ne l'avez pas, créez-le dans le dossier de votre projet avec exactement ces lignes :
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.0Certains exemples utilisent également NumPy, qui a été installé avec pandas — import numpy as np suffit.
Avant d'écrire le code
Une nouvelle colonne est un petit programme. Cinq minutes de planification évitent l'heure passée à se demander d'où vient un NaN.
1. Formulez la question en une phrase, par colonne. Chaque colonne demandée par la propriétaire devient une ligne du plan :
revenue— combien d'argent la commande a rapporté. Créée à partir dequantity×price.size—"big"si le chiffre d'affaires est d'au moins 500, sinon"small". Créée à partir derevenue.manager— qui dirige la succursale d'où provient la commande. Créée à partir debranch, via une table de correspondance.weekday— le jour de la semaine où la commande a été passée. Créée à partir dedate.branch_share— le chiffre d'affaires de cette commande en proportion du total de sa succursale. Créée à partir derevenueetbranch.
Remarquez le point commun qu'elles partagent toutes : une valeur par ligne. Une nouvelle colonne est toujours exactement aussi longue que le tableau. C'est la différence avec le chapitre huit, où groupby(...).sum() donnait une valeur par groupe. Si l'élément que vous calculez a moins de valeurs que le tableau n'a de lignes, ce n'est pas encore une colonne.
2. Observez les données d'entrée avant d'y toucher. Les outils que vous pouvez utiliser dépendent des types des colonnes de départ :
import pandas as pd
orders = pd.read_csv("orders.csv")
print(orders.shape)
print(orders.dtypes)(8, 7)
order_id int64
date str
branch str
product str
category str
quantity int64
price float64
dtype: objectHuit lignes ; ce nombre doit toujours être de huit à la fin. quantity est de type int64 et price est de type float64, leur multiplication effectuera donc une véritable opération arithmétique. date est de type str — du texte qui ressemble à une date. Le texte n'a pas de jour de la semaine, il faudra donc le convertir avant de pouvoir utiliser .dt dessus. Noter cela dès maintenant permet d'éviter l'AttributeError plus tard.
3. Esquissez le résultat attendu et calculez une ligne à la main. Avant d'exécuter quoi que ce soit, déterminez ce que deux des lignes doivent donner — une ligne ordinaire et une ligne extrême :
product quantity price revenue size
pen 12 15.0 180.0 small
bag 2 850.0 1700.0 big12 × 15.0 = 180.0, et 180 est inférieur à 500, donc small. Une ligne vérifiée à la main constitue un test : une fois le code exécuté, si la ligne 0 n'affiche pas 180.0 et small, le code est faux, même s'il paraît tout à fait raisonnable.
4. Choisissez l'outil adapté à la forme de la règle. Presque chaque nouvelle colonne entre dans l'une de ces catégories :
- Arithmétique sur des colonnes ou avec un nombre →
+ - * /sur des colonnes entières. Exemple :quantity * price. - Une valeur parmi deux, selon une condition →
np.where. Exemple :"big"ou"small". - Une valeur parmi plusieurs, selon des conditions vérifiées dans l'ordre →
np.select. Exemple :"bulk","normal","few". - Une valeur recherchée dans une table fixe →
.map(dict). Exemple : branch → manager. - L'intervalle dans lequel se situe un nombre →
pd.cut. Exemple : quantity → band. - Un morceau de texte → les méthodes de
.str. Exemple : les premières lettres de la catégorie. - Un morceau d'une date →
pd.to_datetime, puis.dt. Exemple : le jour de la semaine. - Une comparaison avec le groupe de la ligne →
groupby(...).transform(...). Exemple : la part dans le total de la succursale. - Rien de tout cela →
apply(axis=1). Exemple : une règle trop irrégulière pour les autres solutions.
Lisez la liste de haut en bas et arrêtez-vous à la première ligne qui convient. L'ordre est délibéré : les huit premières travaillent sur des colonnes entières d'un coup ; la dernière revient à un appel Python par ligne.
5. Décidez de ce que vous vérifierez après coup. Trois vérifications, à chaque fois : le nombre de lignes n'a pas changé ; la nouvelle colonne a le type attendu ; et son isna().sum() vaut zéro, ou exactement le nombre que vous savez expliquer. Plus la ligne vérifiée à la main.
L'arithmétique sur des colonnes entières
Voici la colonne de chiffre d'affaires à la façon pandas :
import pandas as pd
orders = pd.read_csv("orders.csv")
orders["revenue"] = orders["quantity"] * orders["price"]
print(orders[["product", "quantity", "price", "revenue"]])product quantity price revenue
0 pen 12 15.0 180.0
1 notebook 5 60.0 300.0
2 bag 2 850.0 1700.0
3 bottle 7 120.0 840.0
4 eraser 30 8.0 240.0
5 bag 1 850.0 850.0
6 pen 20 15.0 300.0
7 bottle 4 120.0 480.0La ligne 0 affiche 180.0 — la valeur vérifiée à la main. Pas de boucle, pas d'arithmétique sur les index.
Pourquoi cela fonctionne. orders["quantity"] est une Series de huit nombres ; orders["price"] aussi. Lorsque vous placez * entre deux Series, pandas associe leurs valeurs par étiquette d'index — l'étiquette 0 avec l'étiquette 0, l'étiquette 1 avec l'étiquette 1 — et multiplie chaque paire. La boucle a toujours lieu, mais au sein du code compilé de pandas, pas en Python. C'est pourquoi c'est rapide, et c'est aussi pourquoi cela ne peut pas lire la mauvaise ligne : il n'y a aucun compteur susceptible de se décaler.
Ensuite, orders["revenue"] = ... effectue l'une de deux opérations, selon le nom :
- si
revenuen'existe pas, il ajoute une nouvelle colonne à l'extrémité droite ; - s'il existe, il le remplace — silencieusement. Il n'y a pas de message « êtes-vous sûr ? ».
Le type du résultat découle de l'opération arithmétique : int64 × float64 donne float64, c'est pourquoi revenue affiche 180.0 plutôt que 180.
Une colonne peut également être combinée avec un nombre unique. Le nombre est utilisé pour chaque ligne — pandas appelle cela le broadcasting :
orders["price_with_vat"] = (orders["price"] * 1.15).round(2)
print(orders[["product", "price", "price_with_vat"]].head(4))product price price_with_vat
0 pen 15.0 17.25
1 notebook 60.0 69.00
2 bag 850.0 977.50
3 bottle 120.0 138.00Les parenthèses comptent : .round(2) s'applique à l'ensemble du résultat de la multiplication, qui est à nouveau une Series. Chaque étape ici prend une colonne et renvoie une colonne, vous pouvez donc continuer à enchaîner.
L'index décide de la destination de chaque valeur
Cette association par étiquette ne concerne pas seulement l'arithmétique. Assigner une Series à une colonne aligne également les valeurs par étiquette. La plupart du temps, vous ne le remarquez jamais, car les nouvelles valeurs proviennent du même tableau et les étiquettes correspondent exactement. Vous vous en rendez compte le jour où ce n'est plus le cas.
Supposons que vous vouliez une colonne contenant la quantité uniquement pour les commandes de la succursale north :
north = orders[orders["branch"] == "north"]
orders["north_qty"] = north["quantity"]
print(orders[["branch", "quantity", "north_qty"]])branch quantity north_qty
0 north 12 12.0
1 south 5 NaN
2 north 2 2.0
3 east 7 NaN
4 north 30 30.0
5 south 1 NaN
6 east 20 NaN
7 north 4 4.0north possède les étiquettes 0, 2, 4, 7. Pandas a placé chaque valeur en face de la ligne ayant la même étiquette, et toutes les autres lignes ont reçu NaN — « aucune valeur pour cette étiquette ». Et comme NaN est un flottant, la colonne est devenue float64 bien que les quantités soient des nombres entiers. C'est peut-être exactement ce que vous vouliez, ou le début d'une longue recherche de données manquantes que vous avez vous-même créées. Pour le savoir, il faut connaître la règle : l'assignation associe des étiquettes, pas des positions.
La même règle vous protège. Le tri (chapitre sept) réordonne une Series mais conserve chaque valeur avec son étiquette, de sorte que réassigner une Series triée replace chaque valeur dans sa ligne d'origine :
by_revenue = orders["revenue"].sort_values()
print(by_revenue.head(3))
orders["revenue_copy"] = by_revenue
print(orders[["product", "revenue", "revenue_copy"]].head(3))
print((orders["revenue_copy"] == orders["revenue"]).all())0 180.0
4 240.0
6 300.0
Name: revenue, dtype: float64
product revenue revenue_copy
0 pen 180.0 180.0
1 notebook 300.0 300.0
2 bag 1700.0 1700.0
TrueLa Series triée suit l'ordre des étiquettes 0, 4, 6 — la deuxième valeur, 240.0, appartient à la ligne 4, et non à la ligne 1. Pourtant, revenue_copy est identique à revenue ligne par ligne, ce que confirme la dernière ligne pour l'ensemble des huit lignes. Pandas a utilisé les étiquettes et ignoré l'ordre. S'il s'était basé sur la position, la ligne 1 (le carnet, 300.0) aurait reçu 240.0.
Une simple liste Python n'a pas d'étiquettes, elle s'utilise donc par position — et dans ce cas, sa longueur doit correspondre exactement :
try:
orders["rating"] = [5, 4, 3]
except ValueError as error:
print("ValueError:", error)ValueError: Length of values (3) does not match length of index (8)Trois valeurs pour huit lignes : l'opération est refusée plutôt que devinée au hasard. C'est le comportement le plus sûr possible, et cela vaut la peine de le voir une fois pour que le message vous soit familier.
Nous n'avons plus besoin des colonnes d'expérimentation. drop(columns=...) renvoie le tableau sans ces colonnes :
orders = orders.drop(columns=["north_qty", "revenue_copy", "price_with_vat"])
print(list(orders.columns))['order_id', 'date', 'branch', 'product', 'category', 'quantity', 'price', 'revenue']assign() : de nouvelles colonnes sans modifier l'original
orders["revenue"] = ... modifie orders lui-même. C'est souvent ce que vous souhaitez. Parfois non — vous voulez essayer quelques colonnes, ou construire un tableau de rapport, et conserver l'original exactement tel qu'il a été lu. assign() renvoie un nouveau DataFrame avec les colonnes supplémentaires et ne touche pas à l'original :
import numpy as np
import pandas as pd
orders = pd.read_csv("orders.csv")
report = orders.assign(
revenue=orders["quantity"] * orders["price"],
vat=lambda d: (d["revenue"] * 0.15).round(2),
)
print(report[["product", "revenue", "vat"]].head(3))
print("revenue" in orders.columns)product revenue vat
0 pen 180.0 27.0
1 notebook 300.0 45.0
2 bag 1700.0 255.0
FalseTrois éléments à remarquer. (Le import numpy as np en haut servira pour la section suivante.)
Les noms des arguments deviennent les noms des colonnes — revenue=... crée une colonne appelée revenue. C'est pourquoi les noms de colonnes doivent être des identifiants Python valides lorsque vous utilisez assign().
vat est écrit sous la forme d'un lambda. Cela est obligatoire : vat a besoin de revenue, et revenue n'existe pas sur orders — il n'existe que sur le nouveau tableau en cours de construction. Un lambda d: ... reçoit ce nouveau tableau sous le nom d, donc d["revenue"] s'y trouve déjà. Au sein d'un même assign(), les colonnes sont ajoutées de haut en bas, et chaque lambda voit celles qui se trouvent au-dessus de lui.
Et la dernière ligne affiche False : orders ne possède aucune colonne revenue. Rien n'a été modifié à votre insu.
np.where : une valeur parmi deux
« big si le chiffre d'affaires est d'au moins 500, sinon small » est un si/sinon pour chaque ligne. Écrire un if obligerait à revenir à une boucle, car un if a besoin d'un seul True ou False, alors que la comparaison d'une colonne entière en fournit huit (c'est l'erreur « truth value of a Series is ambiguous » du chapitre cinq).
La fonction where de NumPy est un si/sinon qui s'applique à une colonne entière : là où la condition vaut True, retenir la première valeur ; ailleurs, la seconde.
report["size"] = np.where(report["revenue"] >= 500, "big", "small")
print(report[["product", "revenue", "size"]])product revenue size
0 pen 180.0 small
1 notebook 300.0 small
2 bag 1700.0 big
3 bottle 840.0 big
4 eraser 240.0 small
5 bag 850.0 big
6 pen 300.0 small
7 bottle 480.0 smallLa ligne 0 vaut small, la ligne 2 vaut big — les deux correspondent à l'esquisse. La condition est le même type de masque booléen que celui construit au chapitre cinq ; np.where transforme simplement chaque True/False en une valeur. La valeur frontière mérite attention : >= 500 signifie qu'une commande d'exactement 500 est big. Décidez-le délibérément, et non par hasard.
Plus de deux valeurs : np.select
Trois paliers selon la quantité — 20 ou plus correspond à "bulk", 5 ou plus à "normal", tout le reste à "few". np.select prend une liste de conditions et une liste de valeurs correspondantes, ainsi qu'une valeur default pour les lignes qui ne correspondent à aucune condition :
conditions = [
report["quantity"] >= 20,
report["quantity"] >= 5,
]
labels = ["bulk", "normal"]
report["tier"] = np.select(conditions, labels, default="few")
print(report[["product", "quantity", "tier"]])product quantity tier
0 pen 12 normal
1 notebook 5 normal
2 bag 2 few
3 bottle 7 normal
4 eraser 30 bulk
5 bag 1 few
6 pen 20 bulk
7 bottle 4 fewLa première condition qui vaut True l'emporte. C'est pourquoi la condition la plus stricte figure en premier. Inversez l'ordre et observez ce qui arrive à la commande de 30 gommes :
wrong = np.select(
[report["quantity"] >= 5, report["quantity"] >= 20],
["normal", "bulk"],
default="few",
)
print(wrong)['normal' 'normal' 'few' 'normal' 'normal' 'few' 'normal' 'few']Chaque commande est normal ou few ; rien n'est bulk, car 30 et 20 sont aussi >= 5 et cette condition a été testée en premier. Aucune erreur, simplement une colonne erronée — d'où l'importance d'inclure une valeur extrême dans la ligne vérifiée à la main de votre plan.
.map() : rechercher des valeurs
Le nom du responsable n'est calculé à partir de rien. C'est un fait que vous connaissez : Asha gère la succursale north, Omar south, Lina east. Quand la règle est « chercher dans une table », écrivez la table de correspondance sous la forme d'un dictionnaire et faites passer la colonne par .map() :
manager_of = {
"north": "Asha",
"south": "Omar",
"east": "Lina",
}
report["manager"] = report["branch"].map(manager_of)
print(report[["branch", "manager"]])
print(report["manager"].isna().sum())branch manager
0 north Asha
1 south Omar
2 north Asha
3 east Lina
4 north Asha
5 south Omar
6 east Lina
7 north Asha
0Chaque succursale a été remplacée par sa valeur dans le dictionnaire — le nom du responsable. Le deuxième print est la vérification prévue dans le plan, et elle est plus importante qu'il n'y paraît en raison de ce que fait .map() face à une clé introuvable. Imaginez que le dictionnaire ait été rédigé avant l'ouverture de la succursale east :
old_managers = {"north": "Asha", "south": "Omar"}
print(report["branch"].map(old_managers).isna().sum())2Aucune erreur. Les deux commandes de east ont simplement reçu NaN. Sur huit lignes, vous le repéreriez ; sur quatre-vingt mille, vous ne le verriez pas, et un groupby("manager") ultérieur exclurait discrètement ces commandes (chapitre huit : les clés NaN sont éliminées). Chaque .map() doit être suivi immédiatement d'un isna().sum().
pd.cut : dans quel intervalle se situe un nombre
La boutique souhaite catégoriser chaque commande par tranche de quantité : jusqu'à 5 articles, c'est small ; de 6 à 15, c'est medium ; plus de 15, c'est large. Vous pourriez écrire cela avec np.select, mais « dans quel intervalle » dispose de son propre outil. pd.cut prend les bornes des intervalles et une étiquette pour chaque intervalle :
report["band"] = pd.cut(
report["quantity"],
bins=[0, 5, 15, np.inf],
labels=["small", "medium", "large"],
)
print(report[["product", "quantity", "band"]])product quantity band
0 pen 12 medium
1 notebook 5 small
2 bag 2 small
3 bottle 7 medium
4 eraser 30 large
5 bag 1 small
6 pen 20 large
7 bottle 4 smallQuatre bornes forment trois intervalles : (0, 5], (5, 15] et (15, ∞]. La parenthèse signifie « non inclus », le crochet signifie « inclus » — chaque intervalle inclut donc sa borne droite. C'est pourquoi la commande de carnets valant exactement 5 est small, et non medium. np.inf représente l'infini, une borne supérieure qu'aucune quantité ne peut dépasser.
Le résultat a un type que vous n'avez pas encore rencontré :
print(report["band"].dtype)
print(report["band"].cat.categories.tolist())category
['small', 'medium', 'large']Une colonne de type category se souvient que small < medium < large. Trier selon band donne donc cet ordre plutôt que l'ordre alphabétique — ce qui est généralement ce que l'on recherche dans un rapport.
Texte et dates : .str et .dt
Morceaux de texte
Le chapitre cinq utilisait .str.contains pour filtrer. Le même accesseur .str permet de construire des colonnes : toutes les méthodes de chaînes que vous connaissez en Python s'appliquent à toutes les valeurs en une seule fois. Supposons que chaque commande nécessite un code court du type STA-1001 — les trois premières lettres de la catégorie en majuscules, un tiret, puis le numéro de commande :
report["code"] = (
report["category"].str[:3].str.upper()
+ "-"
+ report["order_id"].astype(str)
)
print(report[["category", "order_id", "code"]].head(3))category order_id code
0 stationery 1001 STA-1001
1 stationery 1002 STA-1002
2 accessories 1003 ACC-1003.str[:3] découpe chaque valeur, .str.upper() met chaque valeur en majuscules, et + entre des colonnes de texte les concatène ligne par ligne. order_id est un nombre, et un nombre ne peut pas être additionné à du texte, donc .astype(str) le transforme d'abord en texte.
Morceaux d'une date
Le plan avait déjà prévenu que date était de type str. Convertissez-le avec pd.to_datetime, et l'accesseur .dt devient accessible :
report["date"] = pd.to_datetime(report["date"])
print(report["date"].dtype)
report["weekday"] = report["date"].dt.day_name()
print(report[["date", "weekday"]].head(4))datetime64[us]
date weekday
0 2024-03-01 Friday
1 2024-03-01 Friday
2 2024-03-02 Saturday
3 2024-03-02 SaturdayLa colonne est désormais de type datetime64 — une vraie date, pas du texte. (La mention [us] indique la précision choisie par pandas 3 ; vous pouvez l'ignorer.) À partir d'une vraie date, .dt met à disposition .dt.day_name(), .dt.month, .dt.year, .dt.day et bien d'autres. Et la conversion n'est faite qu'une seule fois, réassignée à date, de sorte que chaque étape suivante dispose d'une date et non d'une chaîne.
transform : comparer une ligne avec son groupe
La dernière demande — la part de chaque commande dans le chiffre d'affaires de sa succursale — nécessite deux choses sur la même ligne : le chiffre d'affaires de la commande et le total de sa succursale. Le chapitre huit nous a appris à obtenir ces totaux :
print(report.groupby("branch")["revenue"].sum())branch
east 1140.0
north 2600.0
south 1150.0
Name: revenue, dtype: float64Mais cela donne trois valeurs, une par succursale, alors que le tableau compte huit lignes. Vous ne pouvez pas diviser huit nombres par trois. Ce dont vous avez besoin, c'est du total de la succursale répété sur chaque ligne de cette succursale. C'est précisément ce que renvoie transform :
branch_total = report.groupby("branch")["revenue"].transform("sum")
print(branch_total)0 2600.0
1 1150.0
2 2600.0
3 1140.0
4 2600.0
5 1150.0
6 1140.0
7 2600.0
Name: revenue, dtype: float64Même regroupement, même "sum", mais le résultat compte huit valeurs reprenant les étiquettes propres au tableau — chaque ligne de north porte 2600, chaque ligne de south 1150. Il s'agit désormais d'une simple opération arithmétique sur des colonnes :
report["branch_share"] = (report["revenue"] / branch_total).round(3)
print(report[["branch", "product", "revenue", "branch_share"]])branch product revenue branch_share
0 north pen 180.0 0.069
1 south notebook 300.0 0.261
2 north bag 1700.0 0.654
3 east bottle 840.0 0.737
4 north eraser 240.0 0.092
5 south bag 850.0 0.739
6 east pen 300.0 0.263
7 north bottle 480.0 0.185Vérifiez-le : la commande de sacs dans la succursale north donne 1700 / 2600 = 0.654. Et les proportions au sein de chaque succursale doivent totaliser 1 :
print(report.groupby("branch")["branch_share"].sum())branch
east 1.0
north 1.0
south 1.0
Name: branch_share, dtype: float64La règle à retenir : agg/sum réduisent un groupe à une seule ligne ; transform renvoie une valeur pour chaque ligne. Utilisez le premier pour un tableau de synthèse, le second pour une nouvelle colonne.
apply(axis=1) : le dernier recours
Parfois, la règle est réellement irrégulière. Les frais de livraison sont les suivants : pour la succursale north — la principale, à côté de l'entrepôt — gratuit dès que le chiffre d'affaires atteint au moins 500, sinon 60 ; pour les autres succursales, 120, mais gratuit au-delà de 1000. Vous pouvez écrire cela sous la forme d'une fonction Python ordinaire et l'appliquer (apply) à chaque ligne :
def delivery_fee(row):
if row["branch"] == "north":
return 0 if row["revenue"] >= 500 else 60
return 0 if row["revenue"] > 1000 else 120
report["fee"] = report.apply(delivery_fee, axis=1)
print(report[["branch", "revenue", "fee"]])branch revenue fee
0 north 180.0 60
1 south 300.0 120
2 north 1700.0 0
3 east 840.0 120
4 north 240.0 60
5 south 850.0 120
6 east 300.0 120
7 north 480.0 60axis=1 signifie « donner à la fonction une ligne à la fois ». À l'intérieur, row est une Series dont les étiquettes sont les noms des colonnes, donc row["branch"] fonctionne.
C'est facile à lire, et c'est là tout son attrait. Le prix à payer est que l'on retrouve la boucle du début de chapitre : pandas construit une Series pour chaque ligne et appelle votre fonction Python une fois par ligne. Sur huit lignes, ce n'est rien ; sur un million de lignes, c'est la différence entre un battement de paupières et une pause café.
Alors, avant de vous précipiter sur apply, essayez d'exprimer la règle avec les outils de colonnes entières. Cette règle correspond en réalité à « trois cas, examinés dans l'ordre » — soit np.select :
in_north = report["branch"] == "north"
fee_fast = np.select(
[in_north & (report["revenue"] >= 500), in_north, report["revenue"] > 1000],
[0, 60, 0],
default=120,
)
print((fee_fast == report["fee"]).all())TrueRésultat identique, sans aucun appel Python par ligne. Réservez apply(axis=1) aux règles qui ne peuvent véritablement pas s'écrire ainsi — appeler une fonction externe, analyser une chaîne complexe en plusieurs étapes — et considérez l'envie de l'utiliser comme un signal vous invitant à réfléchir une fois de plus.
Ajouter une colonne à un tableau filtré : le Copy-on-Write
Vous filtrerez souvent d'abord avant d'ajouter une colonne au résultat. Dans pandas 3, c'est simple, et la raison de cette simplicité mérite d'être comprise car les anciens tutoriels affirment le contraire.
import pandas as pd
orders = pd.read_csv("orders.csv")
orders["revenue"] = orders["quantity"] * orders["price"]
north = orders[orders["branch"] == "north"]
north["revenue_k"] = north["revenue"] / 1000
print(north[["product", "revenue", "revenue_k"]])
print(list(orders.columns))product revenue revenue_k
0 pen 180.0 0.18
2 bag 1700.0 1.70
4 eraser 240.0 0.24
7 bottle 480.0 0.48
['order_id', 'date', 'branch', 'product', 'category', 'quantity', 'price', 'revenue']Aucun avertissement, et orders n'a pas reçu de colonne revenue_k. Pandas 3 utilise le Copy-on-Write : chaque résultat de filtrage ou de sélection se comporte comme son propre tableau indépendant. Modifier north ne peut jamais modifier orders.
Si vous lisez du code ancien ou des réponses en ligne, vous verrez ce modèle exact suivi d'un SettingWithCopyWarning (« A value is trying to be set on a copy of a slice from a DataFrame »), accompagné du conseil d'écrire orders[...].copy(). Cet avertissement existait parce que les anciennes versions de pandas partageaient parfois réellement des données entre les deux tableaux, sans que personne ne puisse facilement prédire quand. Pandas 3 a levé l'incertitude, et supprimé l'avertissement avec elle. Écrire .copy() est désormais inutile, bien qu'inoffensif.
Ce que le Copy-on-Write n'autorise pas, c'est de modifier orders par un enchaînement de deux étapes. Si vous voulez modifier des valeurs dans le tableau d'origine pour certaines lignes, ceci ne fait rien :
orders[orders["branch"] == "north"]["revenue"] = 0
print(orders["revenue"].tolist())[180.0, 300.0, 1700.0, 840.0, 240.0, 850.0, 300.0, 480.0]
ChainedAssignmentError: A value is being set on a copy of a DataFrame or Series through chained assignment.
Such chained assignment never works to update the original DataFrame or Series, because the intermediate object on which we are setting values always behaves as a copy (due to Copy-on-Write).
Try using '.loc[row_indexer, col_indexer] = value' instead, to perform the assignment in a single step.
See the documentation for a more detailed explanation: https://pandas.pydata.org/pandas-docs/stable/user_guide/copy_on_write.html#chained-assignmentLa première paire de crochets crée un nouveau tableau, la seconde paire de crochets affecte des valeurs dans ce nouveau tableau, et ce nouveau tableau est jeté. Pandas vous avertit par une erreur ChainedAssignmentError, et la liste montre que revenue est inchangé — les commandes de north affichent toujours 180.0, 1700.0, 240.0 et 480.0. (Les avertissements sont dirigés vers le flux d'erreur, donc dans votre terminal l'avertissement peut apparaître au-dessus de la liste plutôt qu'en dessous.) Pour modifier l'original, faites-le en une seule étape avec .loc[rows, column] :
orders.loc[orders["branch"] == "north", "note"] = "main branch"
print(orders[["branch", "note"]])branch note
0 north main branch
1 south NaN
2 north main branch
3 east NaN
4 north main branch
5 south NaN
6 east NaN
7 north main branchLes lignes correspondantes reçoivent la valeur ; la colonne est créée si nécessaire ; les lignes sans correspondance reçoivent NaN. Une seule étape, le tableau d'origine, aucun avertissement.
Un exemple complet
enrich.py répond à l'intégralité du message de la propriétaire de la boutique, en suivant le plan : lire, vérifier, ajouter les colonnes, vérifier à nouveau.
import numpy as np
import pandas as pd
MANAGER_OF = {
"north": "Asha",
"south": "Omar",
"east": "Lina",
}
orders = pd.read_csv("orders.csv")
rows_before = len(orders)
# 1. Conversions first, so later steps get the right types.
orders["date"] = pd.to_datetime(orders["date"])
# 2. New columns, one rule each.
orders["revenue"] = orders["quantity"] * orders["price"]
orders["size"] = np.where(orders["revenue"] >= 500, "big", "small")
orders["manager"] = orders["branch"].map(MANAGER_OF)
orders["weekday"] = orders["date"].dt.day_name()
branch_total = orders.groupby("branch")["revenue"].transform("sum")
orders["branch_share"] = (orders["revenue"] / branch_total).round(3)
# 3. Checks: same rows, nothing missing, the hand-worked row is right.
new_columns = ["revenue", "size", "manager", "weekday", "branch_share"]
assert len(orders) == rows_before
assert orders[new_columns].isna().sum().sum() == 0
assert orders.loc[0, "revenue"] == 180.0
print(orders[["order_id", "branch", "manager", "weekday", "revenue", "size", "branch_share"]])
print()
print(orders[new_columns].dtypes)order_id branch manager weekday revenue size branch_share
0 1001 north Asha Friday 180.0 small 0.069
1 1002 south Omar Friday 300.0 small 0.261
2 1003 north Asha Saturday 1700.0 big 0.654
3 1004 east Lina Saturday 840.0 big 0.737
4 1005 north Asha Sunday 240.0 small 0.092
5 1006 south Omar Sunday 850.0 big 0.739
6 1007 east Lina Monday 300.0 small 0.263
7 1008 north Asha Monday 480.0 small 0.185
revenue float64
size str
manager str
weekday str
branch_share float64
dtype: objectPourquoi c'est écrit ainsi :
- La conversion vient en premier.
dateest transformé en une vraie date avant que quoi que ce soit ne l'utilise, de sorte que.dtne puisse pas échouer plus tard et que personne en aval n'ait à se souvenir de le convertir. - Une règle par ligne. Chaque ligne répond à une ligne du plan, ainsi lorsqu'un nombre semble erroné, vous savez quelle ligne précise l'a produit.
- La table de correspondance est une constante nommée en haut. Quand la boutique ouvrira une succursale west, la modification se fera en une seule ligne à un endroit évident, et la vérification
isnaéchouera bruyamment si quelqu'un oublie. - Les vérifications sont des
assert, pas des prints. Si le nombre de lignes change, si un.map()oublie une succursale, ou si la ligne 0 cesse de valoir 180, le programme s'arrête net au lieu d'afficher un rapport erroné qui paraît correct en surface. dtypesest affiché à la fin parce que le type fait partie intégrante de la réponse :revenuedoit être un float,branch_shareun float, et les colonnes de texte de typestr.
Quand ça plante
KeyError: 'Price' Un nom de colonne du côté droit est mal orthographié ou a la mauvaise casse. Affichez list(orders.columns) et copiez le nom directement depuis là. Surveillez aussi le côté gauche : orders["revenu"] = ... ne lève rien du tout — cela crée une nouvelle colonne mal orthographiée à côté de celle voulue. Si une colonne que vous pensiez avoir « mise à jour » n'a pas bougé, cherchez une colonne jumelle comportant une faute de frappe.
ValueError: Length of values (3) does not match length of index (8) Vous avez assigné une liste (ou un tableau) dont la longueur ne correspond pas au nombre de lignes. Une liste ne possède pas d'étiquettes, pandas ne peut donc pas l'aligner et refuse l'opération. Construisez plutôt les valeurs à partir des colonnes du tableau lui-même, ou vérifiez d'abord len(values) == len(df).
La nouvelle colonne est remplie de NaN sur certaines lignes, et son type est devenu float64 Vous avez assigné une Series dont les étiquettes ne correspondent que partiellement au tableau — généralement construite à partir d'un tableau filtré. L'assignation aligne les étiquettes, pas les positions. Construisez la nouvelle colonne à partir du tableau complet (avec np.where ou .loc[mask, "col"] = ...) plutôt qu'à partir d'un sous-ensemble.
revenue contient des éléments comme 15.015.015.0…, ou TypeError: can't multiply sequence by non-int of type 'float' L'une des colonnes est du texte, pas un nombre. Multiplier un int par une chaîne répète la chaîne — la règle Python 3 * "ab" == "ababab" — et pandas l'applique ligne par ligne sans sourciller :
import pandas as pd
from io import StringIO
RAW = """product,quantity,price
pen,12,15.0
bag,2,"1,200"
"""
bad = pd.read_csv(StringIO(RAW))
print(bad.dtypes)
print(bad["quantity"] * bad["price"])product str
quantity int64
price str
dtype: object
0 15.015.015.015.015.015.015.015.015.015.015.015.0
1 1,2001,200
dtype: strLa virgule des milliers dans "1,200" a rendu toute la colonne price de type str. C'est pourquoi le plan préconise d'observer dtypes avant les calculs arithmétiques. Corrigez le problème dès la lecture avec thousands=",", ou convertissez avec pd.to_numeric :
good = pd.read_csv(StringIO(RAW), thousands=",")
print(good.dtypes["price"])
print(good["quantity"] * good["price"])float64
0 180.0
1 2400.0
dtype: float64AttributeError: Can only use .dt accessor with datetimelike values La colonne est toujours du texte. Convertissez-la d'abord avec df["date"] = pd.to_datetime(df["date"]) puis utilisez .dt par la suite.
KeyError: 'quantity' provenant de apply Vous avez oublié axis=1. Sans lui, apply transmet des colonnes entières à votre fonction, donc row["quantity"] cherche une ligne étiquetée quantity à l'intérieur d'une colonne et échoue. Ajoutez axis=1 — et demandez-vous ensuite si vous avez réellement besoin de apply.
IntCastingNaNError: Cannot convert non-finite values (NA or inf) to integer Vous avez appelé .astype(int) sur une colonne contenant des NaN. Un entier NumPy n'a aucun moyen de stocker une valeur « manquante ». Comblez d'abord les manques (chapitre six), ou utilisez le type d'entier nullable de pandas, .astype("Int64") avec un I majuscule, capable d'accueillir des valeurs manquantes.
ChainedAssignmentError: A value is being set on a copy of a DataFrame or Series through chained assignment. Vous avez écrit df[mask]["col"] = value. Sous Copy-on-Write, la première étape crée un tableau distinct, l'original n'est donc jamais modifié. Écrivez l'opération en une seule étape : df.loc[mask, "col"] = value.
Une colonne issue de .map() présente des NaN là où vous attendiez une valeur Une clé de la colonne n'est pas présente dans le dictionnaire — une nouvelle succursale, une orthographe différente, un espace en trop à la fin. Repérez-les avec df.loc[df["new"].isna(), "key_column"].unique(), puis corrigez le dictionnaire ou les données.
Étape 4 sur 6 — Prédire
Vérifiez votre compréhension
Une colonne construite à partir des commandes de north est assignée au tableau complet. Qu'est-ce qui est affiché ?
from io import StringIO
import pandas as pd
# Stands in for orders.csv, so this snippet runs on its own.
RAW = """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
"""
orders = pd.read_csv(StringIO(RAW))
north = orders[orders["branch"] == "north"]
orders["north_qty"] = north["quantity"]
print(orders["north_qty"].isna().sum(), orders["north_qty"].dtype)- A4 int64
- B0 int64
- C0 float64
- D4 float64
L'objectif est d'ajouter une colonne revenue à orders. Qu'est-ce qui est affiché ?
from io import StringIO
import pandas as pd
# Stands in for orders.csv, so this snippet runs on its own.
RAW = """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
"""
orders = pd.read_csv(StringIO(RAW))
orders.assign(revenue=orders["quantity"] * orders["price"])
print("revenue" in orders.columns)- A`False` — `assign` renvoie un nouveau tableau et laisse `orders` inchangé, et le résultat n'a pas été conservé
- BUne `KeyError: 'revenue'`
- CUne `ChainedAssignmentError`
- D`True`
Vous avez besoin d'une colonne qui, sur chaque ligne, contient la part de cette commande dans la quantité totale de sa succursale. Quelle ligne donne une valeur par ligne ?
- Aorders["quantity"] / orders.groupby("branch")["quantity"].sum()
- Borders["quantity"] / orders.groupby("branch")["quantity"].transform("sum")
- Corders.groupby("branch")["quantity"].sum() / orders["quantity"]
- Dorders["quantity"] / orders["quantity"].sum()
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'aide du même fichier orders.csv, écrivez enrich_task.py qui fournit à la propriétaire de la boutique un rapport sur les réductions. Planifiez d'abord — notez la liste des nouvelles colonnes et calculez la ligne 0 à la main — puis ajoutez ces colonnes, dans cet ordre :
revenue=quantity×pricediscount= 10 % derevenuelorsquequantityvaut 10 ou plus, sinon 0net=revenue−discountmanager— d'après la succursale, à l'aide du dictionnaire de ce chapitreband— d'aprèsnet: jusqu'à 300 correspond à"low", strictement au-dessus de 300 et jusqu'à 800 à"mid", au-dessus de 800 à"high"weekday— d'aprèsdatecategory_share— le montantnetde chaque commande en proportion dunettotal de sa catégorie, arrondi à 2 décimales
Affichez la taille (shape) avant et après, puis order_id, product, quantity, net, band, weekday et category_share, triés par net du plus élevé au plus faible.
Votre programme est correct lorsque :
- Il affiche exactement la sortie ci-dessous.
- Il n'y a aucune boucle
forni aucunapplynulle part — chaque colonne est créée à partir de colonnes entières en une seule fois. - Si une succursale manque dans le dictionnaire, le programme s'arrête avec une
AssertionErrorau lieu de poursuivre avec desNaN. - Le nombre de lignes est de 8 avant comme après.
Sortie attendue :
before: (8, 7)
after: (8, 14)
order_id product quantity net band weekday category_share
2 1003 bag 2 1700.0 high Saturday 0.44
5 1006 bag 1 850.0 high Sunday 0.22
3 1004 bottle 7 840.0 high Saturday 0.22
7 1008 bottle 4 480.0 mid Monday 0.12
1 1002 notebook 5 300.0 low Friday 0.32
6 1007 pen 20 270.0 low Monday 0.28
4 1005 eraser 30 216.0 low Sunday 0.23
0 1001 pen 12 162.0 low Friday 0.17Avant de l'exécuter, essayez de prédire : quelle commande a le net le plus élevé, et dans quelle tranche se trouve la commande de 30 gommes ? Vérifiez ensuite par rapport à la sortie.
Solution
Le plan. La ligne 0 à la main : la commande de stylos, 12 × 15.0 = 180.0 ; la quantité est 12, donc la remise est de 18.0 et le net est de 162.0 ; 162 est au maximum de 300, donc low. La commande de gommes : 30 × 8.0 = 240.0, remise de 24.0, net de 216.0 — également low, bien qu'il s'agisse de la commande la plus importante en quantité. Le net le plus élevé devrait être la commande de 2 sacs : 1700.0, sans remise.
revenueetnet— arithmétique sur les colonnes : multiplication et soustraction directes.discount—np.where: une valeur parmi deux par ligne.manager—.map(dict), suivi deisna().sum(): une table de correspondance, vérifiée.band—pd.cutavec les bornes[0, 300, 800, inf]: intervalles. Chaque intervalle inclut sa borne droite, donc exactement 300 donnelowet exactement 800 donnemid.weekday—pd.to_datetime, puis.dt.day_name(): les éléments de date nécessitent une vraie date.category_share—groupby("category")["net"].transform("sum"): une valeur par ligne, comparée avec son groupe.
enrich_task.py :
import numpy as np
import pandas as pd
MANAGER_OF = {
"north": "Asha",
"south": "Omar",
"east": "Lina",
}
orders = pd.read_csv("orders.csv")
print("before:", orders.shape)
orders["revenue"] = orders["quantity"] * orders["price"]
orders["discount"] = np.where(orders["quantity"] >= 10, orders["revenue"] * 0.10, 0.0)
orders["net"] = orders["revenue"] - orders["discount"]
orders["manager"] = orders["branch"].map(MANAGER_OF)
assert orders["manager"].isna().sum() == 0
orders["band"] = pd.cut(
orders["net"],
bins=[0, 300, 800, np.inf],
labels=["low", "mid", "high"],
)
orders["date"] = pd.to_datetime(orders["date"])
orders["weekday"] = orders["date"].dt.day_name()
category_total = orders.groupby("category")["net"].transform("sum")
orders["category_share"] = (orders["net"] / category_total).round(2)
print("after: ", orders.shape)
print()
columns = ["order_id", "product", "quantity", "net", "band", "weekday", "category_share"]
print(orders[columns].sort_values("net", ascending=False))before: (8, 7)
after: (8, 14)
order_id product quantity net band weekday category_share
2 1003 bag 2 1700.0 high Saturday 0.44
5 1006 bag 1 850.0 high Sunday 0.22
3 1004 bottle 7 840.0 high Saturday 0.22
7 1008 bottle 4 480.0 mid Monday 0.12
1 1002 notebook 5 300.0 low Friday 0.32
6 1007 pen 20 270.0 low Monday 0.28
4 1005 eraser 30 216.0 low Sunday 0.23
0 1001 pen 12 162.0 low Friday 0.17Comparaison avec le plan :
- La taille (
shape) est passée de 7 colonnes à 14 et est restée à 8 lignes : sept nouvelles colonnes, aucune ligne gagnée ou perdue. - La ligne 0 (commande 1001) a un
netde162.0et la tranchelow— les valeurs calculées à la main. - Le
netle plus élevé correspond à la commande 1003, le sac, à1700.0, comme prévu. La commande de gommes 1005 estlowà216.0: le plus grand nombre d'articles, mais des articles bon marché. np.wherea pris une colonne comme valeur pour « vrai » —orders["revenue"] * 0.10— ainsi chaque ligne a reçu ses propres 10 %, et non un nombre unique. Les deux branches denp.wherepeuvent être des colonnes ou des valeurs simples.- La limite d'un intervalle est bien visible. La commande de carnets a un
netd'exactement300.0et se classe danslow, car(0, 300]inclut 300. Si la propriétaire de la boutique avait voulu dire « à partir de 300, c'est mid », les bornes auraient dû changer — ce qui relève d'une décision, pas d'un détail. - Les proportions totalisent 1 au sein de chaque catégorie. Accessories :
0.44 + 0.22 + 0.22 + 0.12 = 1.00. Stationery :0.32 + 0.28 + 0.23 + 0.17 = 1.00. Si les parts d'une catégorie ne totalisaient pas 1, letransformaurait été groupé selon la mauvaise colonne.
Provoquez ensuite une erreur volontaire, une fois pour chaque cas : retirez "east" du dictionnaire et observez l'assert interrompre le programme ; modifiez bins pour commencer à 200 au lieu de 0 et regardez quelles lignes reçoivent NaN dans band, et pourquoi.
É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