Aller au contenu
English

Transformer avec Python

  • Leçon 11
  • 50 min
  • Module 4 : Nettoyer et transformer les données

Ce que vous saurez faire

Chargez l’extrait dans pandas pour que chaque colonne ait le type choisi, et non un type deviné par pandas. Puis joignez et modifiez les données par étapes qui se vérifient elles-mêmes et comptent leurs changements.

Carnet JupyterFaites cette leçon dans un carnet JupyterColab · Kaggle · GitHub · en anglais

Le carnet (notebook) Jupyter contient chaque script, tâche et vérification de cette leçon, avec les instructions. Il montre le même comportement de pandas 3. Ses cellules de préparation installent pandas, créent la base de données et produisent l’extrait. Il ne nécessite donc aucune préparation locale. Lisez les explications ici et exécutez le code dans le carnet.

Le carnet est en anglais. Colab et Kaggle ouvrent votre propre copie. Il faut un compte Google ou Kaggle gratuit. Vous êtes bloqué ? Consultez le carnet de solution.

Avant de commencerCe qu’il faut savoir et avoir sous la mainEnviron 50 minutes

Ce que vous devez savoir

Ce qu’il vous faut sur votre ordinateur (seulement pour le travail local; le carnet n’en a pas besoin)

Testé sur macOS avec Python 3.14.6 et pandas 3.0.6. pandas 3 a changé certains comportements de pandas 2. Cette leçon montre le comportement de pandas 3. Le carnet a été testé dans Jupyter.

Un fichier CSV est du texte. Quand pd.read_csv le lit, pandas examine les valeurs de chaque colonne et devine un type. La plupart de ses choix sont bons. Certains sont incorrects et ne provoquent pas d’erreur. Regardez ces trois billets de soutien fictifs. Le billet T-2 n’a pas de priorité. Sa région est NA, un nouveau code pour l’Amérique du Nord :

tiny_types.py
import io
import pandas as pd
TINY = """ticket_id,priority,region,created_at
T-1,1,north,2026-03-04 09:15:00
T-2,,NA,2026-03-04 10:02:00
T-3,3,,2026-03-05 16:40:00
"""
guessed = pd.read_csv(io.StringIO(TINY))
print(guessed)
print(guessed.dtypes)

io.StringIO(TINY) permet à read_csv de lire le texte comme un fichier. Faites d’abord une prédiction : quel type reçoit priority?

Sortie
ticket_id priority region created_at
0 T-1 1.0 north 2026-03-04 09:15:00
1 T-2 NaN NaN 2026-03-04 10:02:00
2 T-3 3.0 NaN 2026-03-05 16:40:00
ticket_id str
priority float64
region str
created_at str
dtype: object

Trois changements silencieux :

  1. priority est devenu un nombre décimal, de type float64 : 1,0 et 3,0. Le type entier standard ne peut pas contenir de valeur manquante. pandas a donc choisi un type qui le peut (NaN).
  2. La région NA est devenue une valeur manquante. Par défaut, pandas considère certains textes comme des valeurs manquantes : NA, N/A, null, None, nan et d’autres. Le vrai code NA est perdu. Maintenant, T-2 ressemble à T-3, qui n’a pas de région.
  3. created_at est du texte (str), pas une date. Une comparaison comme « avant le 1er février » compare des lettres, pas des moments.

La solution consiste à indiquer le type de chaque colonne. Ajoutez ceci au même fichier :

tiny_types.py (continued)
typed = pd.read_csv(
io.StringIO(TINY),
dtype={"ticket_id": "string", "priority": "Int64", "region": "string"},
keep_default_na=False,
na_values=[""],
)
typed["created_at"] = pd.to_datetime(typed["created_at"], format="%Y-%m-%d %H:%M:%S")
print(typed)
print(typed.dtypes)
Sortie
ticket_id priority region created_at
0 T-1 1 north 2026-03-04 09:15:00
1 T-2 <NA> NA 2026-03-04 10:02:00
2 T-3 3 <NA> 2026-03-05 16:40:00
ticket_id string
priority Int64
region string
created_at datetime64[us]
dtype: object
Paramètre Son effet
dtype={...} Donne le type de chaque colonne que vous nommez. pandas ne devine pas le type de ces colonnes.
"Int64" (I majuscule) Un type entier qui peut contenir une valeur manquante. Il l’affiche sous la forme <NA>. Le type int64, avec un i minuscule, ne le peut pas.
"Float64" (F majuscule) La même idée pour les nombres décimaux.
"string" Un type texte dont la valeur manquante est <NA>, comme pour Int64.
keep_default_na=False Désactive la liste par défaut des textes représentant des valeurs manquantes. NA reste le texte NA.
na_values=[""] Seule une cellule vide est une valeur manquante. Dans cet export, une cellule vide représente le NULL de SQL dans un fichier CSV.
pd.to_datetime(..., format=...) Convertit le texte en date et heure, selon le format fourni. Le type est datetime64[us] (précision à la microseconde).

L’export de Larkfield ne contient pas de région NA aujourd’hui. Vous définissez quand même ces options. Le prochain export pourrait en contenir une, et pandas se tromperait alors sans produire d’erreur.

Le format indique où se trouve chaque partie d’une date. %Y représente l’année à 4 chiffres, %m le mois, %d le jour, et %H:%M:%S les heures, minutes et secondes. Pour 2026-03-05 16:40:00, le format est %Y-%m-%d %H:%M:%S.

Que se passe-t-il si le format est incorrect? Mei inverse le jour et le mois par erreur :

dates.py
import pandas as pd
times = pd.Series(["2026-03-04 09:15:00", "2026-03-05 16:40:00", "2026-03-13 08:05:00"])
print(pd.to_datetime(times, format="%Y-%m-%d %H:%M:%S"))
print(pd.to_datetime(times[:2], format="%Y-%d-%m %H:%M:%S"))
print(pd.to_datetime(times, format="%Y-%d-%m %H:%M:%S"))
Sortie
0 2026-03-04 09:15:00
1 2026-03-05 16:40:00
2 2026-03-13 08:05:00
dtype: datetime64[us]
0 2026-04-03 09:15:00
1 2026-05-03 16:40:00
dtype: datetime64[us]
Traceback (most recent call last):
...
ValueError: time data "2026-03-13 08:05:00" doesn't match format "%Y-%d-%m %H:%M:%S". You might want to try:
- passing `format` if your strings have a consistent format;
...
  • Avec le bon format, chaque valeur est correcte.
  • Avec le mauvais format et seulement les deux premières valeurs, pandas lit le 4 mars comme le 3 avril, sans erreur. Tous les jours sont inférieurs ou égaux à 12. Chaque valeur est donc une date possible.
  • Avec la troisième valeur, le jour 13 ne peut pas être un mois. pandas s’arrête donc avec une erreur. Cette erreur est une bonne nouvelle : elle révèle la faute.

Sans format, pandas devine le format à partir de la première valeur. Pour les horodatages de Larkfield, son choix est correct. Pour les dates courtes comme 04/03/2026, ce n’est pas sûr. pandas 3.0.6 lit 04/03/2026 comme le 3 avril, avec le mois en premier. Au Royaume-Uni, le même texte signifie le 4 mars. Seule la source des données peut vous dire quelle interprétation est correcte. Écrivez cette réponse dans le code sous forme de format.

Mei commence scripts/clean.py. La leçon 3 y ajoutera les étapes de nettoyage. Aujourd’hui, il charge l’extrait avec un type pour chaque colonne :

scripts/clean.py
"""Clean the extracted tickets and outcomes. Every input row ends in exactly
one place: the clean table, the quarantine table, or the count of removed
exact duplicates. The original values stay next to the corrected ones."""
import json
from pathlib import Path
import pandas as pd
IN = Path("data/extract")
OUT = Path("data/clean")
REPORTS = Path("reports")
TICKET_TYPES = {
"ticket_id": "string", "customer_id": "string", "channel": "string", "team": "string",
"priority": "Int64", "order_value": "Float64", "word_count": "Int64",
"first_reply_minutes": "Int64", "priority_now": "Int64",
"segment": "string", "region": "string",
}
TIME_FORMAT = "%Y-%m-%d %H:%M:%S"
def load_tickets():
df = pd.read_csv(IN / "tickets.csv", dtype=TICKET_TYPES, keep_default_na=False, na_values=[""])
df["created_at"] = pd.to_datetime(df["created_at"], format=TIME_FORMAT)
df["closed_at"] = pd.to_datetime(df["closed_at"], format=TIME_FORMAT)
df["joined_on"] = pd.to_datetime(df["joined_on"], format="%Y-%m-%d")
return df
def main():
tickets = load_tickets()
print(f"Loaded {len(tickets)} ticket rows")
print(tickets.dtypes)
print("created_at:", tickets["created_at"].min(), "to", tickets["created_at"].max())
print("missing closed_at (open tickets):", tickets["closed_at"].isna().sum())
if __name__ == "__main__":
main()

json, OUT et REPORTS ne servent pas encore. La leçon 3 les utilise. main() est une vérification temporaire : la leçon 3 la remplace.

Terminal
python scripts/clean.py
Sortie
Loaded 1416 ticket rows
ticket_id string
customer_id string
created_at datetime64[us]
channel string
team string
priority Int64
order_value Float64
word_count Int64
first_reply_minutes Int64
priority_now Int64
closed_at datetime64[us]
segment string
region string
joined_on datetime64[us]
dtype: object
created_at: 2026-01-05 07:30:24 to 2026-06-30 20:04:36
missing closed_at (open tickets): 12

Mei vérifie trois points par rapport au profil. Il y a 1 416 lignes, comme dans l’extrait. La plage de created_at reste dans la sélection de l’extrait, du 1er janvier au 30 juin. Et 12 valeurs de closed_at manquent : les mêmes 12 billets ouverts. Donc, pd.to_datetime n’a créé aucune nouvelle valeur manquante.

Joindre des tables dans pandas, avec une règle sur les clés

Section intitulée « Joindre des tables dans pandas, avec une règle sur les clés »

En SQL, une jointure combine les lignes de deux tables à l’aide d’une clé. Dans pandas, la méthode est merge. L’extrait a déjà joint tickets à customers en SQL. Mei refait cette jointure dans pandas à partir des fichiers bruts. Elle veut examiner deux paramètres que SQL n’offre pas :

scripts/check_join.py
"""Repeat the extract's customer join in pandas, and count every row."""
import pandas as pd
READ = {"keep_default_na": False, "na_values": [""]}
tickets = pd.read_csv("data/raw/tickets.csv", dtype={"customer_id": "string"}, **READ)
customers = pd.read_csv("data/raw/customers.csv", dtype="string", **READ)
print(f"tickets: {len(tickets)} rows, customers: {len(customers)} rows")
joined = tickets.merge(
customers,
on="customer_id",
how="left",
validate="many_to_one",
indicator=True,
)
print(f"joined: {len(joined)} rows")
print(joined["_merge"].value_counts())
no_match = joined[joined["_merge"] == "left_only"]
print(f"no customer_id: {no_match['customer_id'].isna().sum()}")
print(f"customer_id not in customers: {no_match['customer_id'].notna().sum()}")

**READ transmet les deux paramètres du dictionnaire READ aux deux appels de read_csv. Ils ne peuvent donc pas différer. Faites d’abord une prédiction : combien de lignes une jointure gauche renvoie-t-elle?

Sortie
tickets: 1417 rows, customers: 240 rows
joined: 1417 rows
_merge
both 1396
left_only 21
right_only 0
Name: count, dtype: int64
no customer_id: 12
customer_id not in customers: 9
Paramètre Son effet
how="left" Garde chaque ligne de la table de gauche (tickets), comme le fait LEFT JOIN.
validate="many_to_one" Une règle : plusieurs billets peuvent avoir le même client. Mais chaque customer_id doit apparaître une seule fois dans customers. Sinon, merge s’arrête avec une erreur avant que les lignes se multiplient.
indicator=True Ajoute la colonne _merge : both (correspondance), left_only (aucune correspondance) ou right_only (à droite seulement).

Les nombres concordent avec ceux du module 2. Le résultat contient 1 417 lignes, comme la table de gauche. Il y a 1 396 lignes correspondantes, le nombre obtenu par la jointure interne. 21 billets n’ont aucune ligne client : 12 invités et 9 clients supprimés.

La règle sur les clés est l’élément important. Mei joint maintenant les événements de résultat aux billets de l’extrait pour ajouter team à chaque événement. Chaque événement appartient à un seul billet. La règle est donc many_to_one :

Interactive Python
>>> import pandas as pd
>>> tickets = pd.read_csv("data/extract/tickets.csv", dtype="string", keep_default_na=False, na_values=[""])
>>> events = pd.read_csv("data/extract/outcomes.csv", dtype="string", keep_default_na=False, na_values=[""])
>>> len(events)
1869
>>> len(events.merge(tickets[["ticket_id", "team"]], on="ticket_id", how="left"))
1892
>>> events.merge(tickets[["ticket_id", "team"]], on="ticket_id", how="left", validate="many_to_one")
Traceback (most recent call last):
...
pandas.errors.MergeError: Merge keys are not unique in right dataset; not a many-to-one merge
Duplicates in right:
ticket_id
T-10031
T-10156
T-10260
T-10337
T-10347 ...

Sans validate, la jointure renvoie 1 892 lignes à partir de 1 869 événements. 23 événements apparaissent maintenant deux fois, car leur billet apparaît deux fois dans l’extrait. Rien n’échoue. Avec validate, pandas s’arrête et nomme les clés dupliquées. Ce sont les 14 doublons exacts du profil. Après drop_duplicates(), la règle est respectée. Vous l’essayez dans la variante ci-dessous.

Une transformation est un changement aux valeurs d’une colonne. Mei suit quatre règles pour chacune :

  1. Une étape nommée par problème, avec un commentaire qui explique pourquoi.
  2. Garder l’original dans une colonne voisine, par exemple channel_raw.
  3. Compter les lignes que l’étape modifie.
  4. Modifier les lignes avec .loc, en une seule instruction.

Voici la correction des graphies des canaux, tirée du profil. Mei l’essaie dans un petit script avant de l’ajouter à clean.py :

scripts/try_channel.py
import pandas as pd
from clean import load_tickets
tickets = load_tickets().drop_duplicates()
print(f"rows: {len(tickets)}")
channel = tickets["channel"].str.strip().str.lower().replace({"webform": "web_form"})
changed = channel != tickets["channel"]
print(changed.value_counts(dropna=False))
print(f"respelled: {changed.fillna(False).sum()}")
tickets["channel_raw"] = tickets["channel"]
tickets["channel"] = channel
print(tickets.loc[changed.fillna(False), ["ticket_id", "channel_raw", "channel"]].drop_duplicates("channel_raw"))
print(tickets["channel"].value_counts(dropna=False))

.str.strip() enlève les espaces au début et à la fin. .str.lower() met toutes les lettres en minuscules. .replace(...) remplace le seul mot incorrect.

Sortie
rows: 1402
channel
False 1325
True 39
<NA> 38
Name: count, dtype: Int64
respelled: 39
ticket_id channel_raw channel
2 T-10003 Email email
12 T-10013 webform web_form
111 T-10111 EMAIL email
124 T-10124 chat chat
channel
email 564
chat 415
web_form 203
phone 182
<NA> 38
Name: count, dtype: Int64
  • 1 402 lignes : les 14 doublons exacts ont été retirés.
  • 39 lignes modifiées. Dans le profil, 40 lignes avaient d’autres graphies. Une ligne EMAIL était un doublon. Il en reste donc 39.
  • Dans la ligne T-10124, channel_raw vaut chat, avec un espace au début. Le tableau ne montre pas l’espace, mais la comparaison le détecte.
  • 38 lignes valent <NA> : un canal manquant reste manquant. L’étape n’invente pas de valeur. La leçon 3 décide quoi en faire.

pandas 3 utilise la copie à l’écriture (copy-on-write). Quand vous sélectionnez des lignes, vous obtenez une nouvelle table qui se comporte comme une copie. Modifier la copie ne modifie jamais la table d’origine. C’est important pour une forme de code courante :

cow.py
import pandas as pd
df = pd.DataFrame({
"ticket_id": ["T-1", "T-2", "T-3"],
"team": ["account", "delivery", "returns"],
"order_value": [None, 40.0, -1.0],
})
df[df["order_value"] == -1]["order_value"] = None
print(df)
df.loc[df["order_value"] == -1, "order_value"] = None
print(df)
Sortie
.../cow.py:9: 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.
...
ticket_id team order_value
0 T-1 account NaN
1 T-2 delivery 40.0
2 T-3 returns -1.0
ticket_id team order_value
0 T-1 account NaN
1 T-2 delivery 40.0
2 T-3 returns NaN

La première ligne sélectionne les lignes, puis définit une colonne dans cette sélection. Ce sont deux étapes : une affectation chaînée. Dans pandas 3, elle ne modifie jamais df. T-3 contient encore -1.0. pandas affiche un avertissement, mais le programme continue. La forme .loc[rows, column] = value fait le changement en une étape, directement dans df. (Dans pandas 2, la première forme fonctionnait parfois, avec un autre avertissement. Le code d’anciens tutoriels peut ne rien faire dans pandas 3.)

  1. Créez scripts/clean.py avec le code de l’exemple détaillé.

  2. Exécutez python scripts/clean.py. Vérifiez la sortie : Loaded 1416 ticket rows (1 416 lignes de billets chargées), et created_at de 2026-01-05 07:30:24 à 2026-06-30 20:04:36.

  3. Créez scripts/try_channel.py et exécutez python scripts/try_channel.py. Vérifiez : respelled: 39 (39 graphies corrigées).

  4. Créez scripts/try_str.py avec le code ci-dessous. Il corrige les mêmes graphies, mais charge le fichier sans dtype. Prédisez d’abord le nombre.

    scripts/try_str.py
    import pandas as pd
    plain = pd.read_csv("data/extract/tickets.csv").drop_duplicates()
    print(plain["channel"].dtype)
    respelled = plain["channel"].str.strip().str.lower().replace({"webform": "web_form"})
    print("changed:", (respelled != plain["channel"]).sum())
  5. Exécutez python scripts/try_str.py.

  6. Ajoutez votre résultat à reports/profile.md sous « Categories » : 39 lignes aux graphies corrigées après le retrait des doublons.

Ce que montre l’étape 5
Sortie
str
changed: 77

77 = 39 lignes aux graphies corrigées + 38 canaux manquants. Avec le type str deviné, une valeur manquante est NaN, et NaN != NaN vaut True. Chaque canal manquant compte donc comme « modifié ». Avec "string", la comparaison donne <NA> pour ces lignes. fillna(False) rend la décision visible dans le code. Les deux scripts s’exécutent sans erreur. Seul le nombre montre la différence.

À vous de jouer : charger et joindre les événements de résultat

Section intitulée « À vous de jouer : charger et joindre les événements de résultat »

Chargez data/extract/outcomes.csv avec des types : csat en Int64, les autres colonnes en "string", et recorded_at en date avec TIME_FORMAT. Puis joignez les événements aux billets sans doublons avec validate="many_to_one" et indicator=True. Prédisez d’abord le nombre de lignes et les nombres de _merge.

Afficher un exemple de réponse
scripts/try_events.py
import pandas as pd
from clean import IN, TIME_FORMAT, load_tickets
events = pd.read_csv(
IN / "outcomes.csv",
dtype={"outcome_id": "string", "ticket_id": "string", "status": "string",
"resolution_code": "string", "csat": "Int64"},
keep_default_na=False,
na_values=[""],
)
events["recorded_at"] = pd.to_datetime(events["recorded_at"], format=TIME_FORMAT)
print(events.dtypes)
tickets = load_tickets().drop_duplicates()
print(f"events: {len(events)}, tickets: {len(tickets)}")
joined = events.merge(
tickets[["ticket_id", "team"]], on="ticket_id", how="left",
validate="many_to_one", indicator=True,
)
print(f"joined: {len(joined)}")
print(joined["_merge"].value_counts())
Sortie
outcome_id string
ticket_id string
recorded_at datetime64[us]
status string
resolution_code string
csat Int64
dtype: object
events: 1869, tickets: 1402
joined: 1869
_merge
both 1869
left_only 0
right_only 0
Name: count, dtype: int64

La règle est respectée après drop_duplicates() : 1 869 événements en entrée, 1 869 lignes en sortie. Chaque événement a trouvé son billet. L’extrait a exclu les 4 événements orphelins en SQL. left_only vaut donc 0.

Erreur courante

Les dates semblent correctes dans un échantillon, mais sont incorrectes

Symptôme : Mei teste un format de date sur les 50 premières lignes de l’extrait. Il n’y a aucune erreur. Plus tard, un graphique montre des billets de mai à octobre, mais l’export se termine en juin :

Interactive Python
>>> import pandas as pd
>>> sample = pd.read_csv("data/extract/tickets.csv", nrows=50)
>>> created = pd.to_datetime(sample["created_at"], format="%Y-%d-%m %H:%M:%S")
>>> sample["created_at"].iloc[0], created.iloc[0]
('2026-01-05 07:30:24', Timestamp('2026-05-01 07:30:24'))
>>> created.min(), created.max()
(Timestamp('2026-05-01 07:30:24'), Timestamp('2026-10-01 15:36:08'))

Cause : le format place le jour et le mois aux mauvais endroits. Les 50 premiers billets datent du 5 au 12 janvier. Tous les jours sont donc inférieurs ou égaux à 12, et chaque valeur est une date possible. Sur le fichier complet, pandas s’arrête à 2026-01-13 09:57:09 avec doesn't match format (ne correspond pas au format).

Solution :

  1. Écrivez le format à partir des données, partie par partie : 2026-01-05 suit l’ordre année, mois, jour, donc %Y-%m-%d.
  2. Analysez la colonne complète, pas un échantillon.
  3. Après l’analyse, vérifiez les dates minimale et maximale par rapport au profil. L’extrait va du 5 janvier 2026 au 30 juin 2026.

Dans le travail en IA : la date détermine quelles lignes servent à l’entraînement et lesquelles servent aux tests (module 5). Des dates incorrectes placent des billets de janvier en octobre. Le modèle s’entraîne donc sur des lignes qui semblent venir du futur.

Vérifiez votre compréhension

Vous lisez un fichier CSV sans dtype. La colonne priority contient une cellule vide. Quel type pandas lui donne-t-il, et à quoi ressemblent les valeurs?

Afficher la réponse

float64 : les valeurs ressemblent à 1.0 et 3.0, et la cellule vide est NaN. Le type entier standard ne peut pas contenir de valeur manquante. pandas change donc le type. Avec dtype={"priority": "Int64"}, les valeurs restent 1 et 3, et la cellule vide est <NA>.

Vérifiez votre compréhension

Une jointure de 1 869 événements aux billets renvoie 1 892 lignes. Que fait validate="many_to_one" dans ce cas, et pourquoi est-ce mieux?

Afficher la réponse

Il arrête la jointure avec une MergeError. Elle nomme les valeurs de ticket_id qui apparaissent plusieurs fois dans la table des billets. Sans ce paramètre, 23 événements sont comptés deux fois. Tous les nombres et moyennes calculés après la jointure sont incorrects, sans erreur. La règle rend visible l’erreur silencieuse.

Vérifiez votre compréhension

Dans pandas 3, que modifie df[df.team == "account"]["order_value"] = 0 dans df?

Afficher la réponse

Rien. C’est une affectation chaînée. La première sélection donne une table qui se comporte comme une copie (copie à l’écriture). L’affectation ne modifie que cette copie. pandas affiche un avertissement ChainedAssignmentError. Utilisez df.loc[df.team == "account", "order_value"] = 0.

Récapitulatif

  • Donnez un type à chaque colonne avec dtype=. Utilisez Int64, Float64 et "string" quand des valeurs peuvent manquer.
  • keep_default_na=False, na_values=[""] : seule une cellule vide est une valeur manquante. Une vraie valeur comme NA est donc conservée.
  • Analysez les dates avec un format explicite, puis vérifiez les dates minimale et maximale. Un mauvais format peut donner des dates valides, mais incorrectes.
  • merge(..., validate="many_to_one", indicator=True) vérifie la règle sur les clés et montre quelles lignes correspondent. Comptez les lignes avant et après.
  • Une transformation est une étape nommée qui garde la colonne d’origine et compte ses changements. Dans pandas 3, modifiez les lignes avec .loc en une seule instruction.

Ensuite, vous décidez quoi faire de chaque problème du profil : supprimer, corriger ou garder comme valeur manquante. Vous rendez compte de chaque ligne.

Leçon suivante : Traiter les valeurs désordonnées