Aller au contenu
English

Caractéristiques et moment de la cible

  • Leçon 13
  • 50 min
  • Module 5 : Préparer la modélisation sans risque

Ce que vous saurez faire

Créez une table de modélisation où chaque colonne était connue au moment de l’arrivée du billet. Une colonne provenant du futur donne d’excellents résultats en test, mais rend le modèle inutile en pratique, sans aucune erreur.

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

Le carnet (notebook) Jupyter contient tous les scripts, tâches et vérifications de cette leçon, avec les instructions. Ses cellules de préparation installent pandas, téléchargent les données d’exercice et recréent les tables nettoyées du module 4. Il fonctionne donc sans le projet local. 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)

  • Le dossier ~/projects/ticket-data avec larkfield.db et q.py, selon Préparer une base de données d’exercice : créer la base de données.
  • L’environnement .venv avec pandas 3.0.6, actif dans un terminal ouvert dans ~/projects/ticket-data. Vous l’avez créé dans Profiler avant de modifier : le même profil dans pandas. Si python -c "import pandas" échoue, activez de nouveau .venv (source .venv/bin/activate sur macOS et Linux, .venv\Scripts\Activate.ps1 sur Windows).
  • Les tables nettoyées data/clean/tickets.csv (1 398 lignes) et data/clean/outcomes.csv (1 834 lignes). Vous les avez créées avec python scripts/clean.py dans Traiter les valeurs désordonnées : exercice guidé : terminer clean.py.
  • Si les tables nettoyées manquent ou sont incorrectes, utilisez les scripts terminés du cours dans C04/pipeline. Téléchargez queries/extract_tickets.sql, queries/extract_outcomes.sql, scripts/extract.py et scripts/clean.py dans les mêmes dossiers de votre projet. Par exemple : curl -fsSL -o scripts/clean.py https://raw.githubusercontent.com/Nextia-Intelligence/nextia-learning-labs/main/C04/pipeline/scripts/clean.py (curl.exe sur Windows). Exécutez ensuite python scripts/extract.py et python scripts/clean.py.
  • Cette leçon ajoute scripts/build_features.py, data/model/ et queries/50-prior-tickets.sql.

Testé sur macOS avec Python 3.14.6 et pandas 3.0.6. Les commandes Windows ne sont pas testées. Le carnet a été testé dans Jupyter.

Larkfield, la boutique en ligne de produits pour la maison et le jardin, veut confier rapidement les billets difficiles à un agent principal. À l’arrivée d’un billet de soutien, un modèle donne un score : quelle est la probabilité que ce billet nécessite un agent principal? Ce cours prépare les données pour ce modèle. Les données sont inventées pour ce cours; elles ne concernent aucune personne réelle.

Les mots importants sont à l’arrivée d’un billet. C’est le moment de la prédiction : le moment où le modèle doit donner sa réponse. Tout ce que le modèle utilise doit exister à ce moment.

Examinez le parcours du billet T-10033. Chaque événement remplit d’autres colonnes :

Heure (9 janvier 2026) Ce qui se passe Colonnes remplies Connues à l’arrivée du billet?
8 h 31 min 54 s Le client envoie un formulaire Web au sujet d’un paiement. created_at, channel = web_form, team = payment, priority = 3, word_count = 61, order_value = 66,25 Oui
9 h 24 min 54 s Un agent répond pour la première fois, après 53 minutes. first_reply_minutes = 53 Non
10 h 45 min 45 s Un agent principal prend le billet (événement de résultat O-00026, escalated (transféré à un niveau supérieur)). priority_now passe de 3 à 1 Non
22 h 32 min 2 s Le billet est résolu par un remboursement (O-00033). closed_at, resolution_code, puis peut-être csat Non

À 8 h 31 min 54 s, le modèle ne voit que la première ligne. Il ne peut pas savoir que la première réponse prendra 53 minutes. Il ne sait pas non plus qu’un agent principal prendra le billet deux heures plus tard. L’exportation du service de soutien contient les quatre lignes, car elle date du 1er juillet 2026, plusieurs mois plus tard. Voilà le piège : l’exportation connaît l’avenir de chaque billet, mais le modèle utilisé en pratique ne le connaît pas.

Trois termes pour la suite du cours :

  • Une variable est une colonne que le modèle utilise comme entrée. Ici : des faits sur le billet au moment de la prédiction, comme team et word_count.
  • La cible est la colonne que le modèle apprend à prédire. Certaines personnes l’appellent l’étiquette. Ici : un agent principal a-t-il pris le billet dans les 72 heures?
  • Une fuite de données est une information dans les variables que le modèle n’aurait pas au moment de la prédiction. Elle concerne souvent la cible elle-même. Une variable avec fuite rend le score d’évaluation trop élevé. En pratique, cette information manque, et le modèle fait moins bien que ce que son score annonçait.

« Transféré à un niveau supérieur » n’est pas assez précis. Transféré quand? Mei, l’ingénieure de données, définit la cible avec Grace, la responsable du soutien :

escalated_72h = 1 si le billet a un événement de résultat escalated dans les 72 heures après created_at; sinon 0.

Grace explique le choix des 72 heures : « Un agent principal prend la plupart des billets difficiles le premier jour. Un transfert après trois jours concerne habituellement un autre problème, par exemple un client qui revient mécontent. » Pour T-10033, le transfert a eu lieu 2 h et 14 minutes après la création, donc escalated_72h = 1.

Dans les données nettoyées, 224 billets ont un événement escalated. Parmi eux, 30 ont été transférés plus de 72 heures après leur création; leur cible vaut donc 0. Une définition précise change des lignes réelles.

L’instantané date du 1er juillet 2026 à 6 h. Un billet créé le 30 juin à 20 h 04 n’avait qu’environ 10 heures pour être transféré avant l’exportation. S’il n’a pas encore d’événement de transfert, sa cible n’est pas 0. Elle est inconnue : les 72 heures ne sont pas écoulées.

La cible d’un billet est donc définitive seulement si sa création précède l’instantané d’au moins 72 heures :

snapshot − 72 hours = 2026-07-01 06:00 − 72 hours = 2026-06-28 06:00

Parmi les billets nettoyés, 29 ont été créés après le 28 juin 2026 à 6 h. Six ont déjà été transférés. Les 23 autres semblent valoir 0, mais certains pourraient encore être transférés. Mei retire les 29 billets de la table de modélisation. Si elle les gardait, les billets les plus récents auraient trop de zéros. Le modèle apprendrait que les billets récents sont moins souvent transférés. C’est faux; leur parcours n’est simplement pas terminé.

Mei classe chaque colonne des billets nettoyés dans l’un de trois groupes. Pour chaque colonne, elle pose une question : quand cette valeur est-elle écrite?

Colonne Moment où la valeur est écrite Utilisation
ticket_id, customer_id À la création Identifiants, pas des variables. Ils désignent la ligne.
created_at À la création Sert à créer des variables et à séparer les données selon le temps. Ce n’est pas une variable en soi.
channel, team, priority, order_value, word_count À la création Variables.
segment, region, joined_on Avant la création (dans la table des clients) Variables (joined_on devient customer_tenure_days).
first_reply_minutes Après la première réponse Jamais une variable.
priority_now Change pendant le parcours du billet. Prend la valeur 1 lors du transfert à un niveau supérieur. Jamais une variable.
closed_at À la résolution Jamais une variable.
Événements de résultat : status, resolution_code, csat Après la création Seulement pour la cible, jamais comme variables.

Regardez priority et priority_now. Leurs noms sont presque identiques et leurs valeurs vont de 1 à 3. L’une décrit un fait à la création. L’autre garde une trace de ce qui s’est passé ensuite. Le nom d’une colonne ne dit pas quand une valeur a été écrite. Demandez à la personne qui connaît le système, ici Grace.

L’historique d’un client peut aider le modèle. Un client qui a écrit cinq fois ce mois-ci peut être mécontent. Mais les fuites de données se cachent facilement dans l’historique.

Le client C-0094 a six billets dans la table de modélisation. L’un d’eux, T-11014, a été transféré à un niveau supérieur en mai. Comparez deux façons de décrire ce client pour chaque billet :

Billet Création prior_tickets_90d (billets des 90 jours précédents) Tous les billets du client Taux de transfert du client, tous les billets
T-10072 2026-01-14 0 6 1 sur 6 = 0,17
T-10350 2026-02-19 1 6 0,17
T-10464 2026-03-04 2 6 0,17
T-11014 2026-05-13 2 6 0,17
T-11085 2026-05-22 2 6 0,17
T-11241 2026-06-11 2 6 0,17

Les deux colonnes de droite contiennent des fuites. Le 14 janvier, personne ne savait que C-0094 créerait six billets ni que l’un serait transféré en mai. La valeur 0,17 de T-10072 contient de l’information du futur. Pire encore, le taux de transfert contient la cible de T-11014 lui-même.

prior_tickets_90d compte seulement les billets du même client créés dans les 90 jours avant celui-ci. Pour T-11241, créé le 11 juin, les 90 jours commencent le 13 mars. T-11014 et T-11085 comptent donc, et la valeur est 2. Chaque valeur était connue au moment de l’arrivée de son billet. Dans la leçon 3, vous mesurez à quel point le taux de transfert avec fuite gonfle un score.

Mei rassemble ces décisions dans un script. Il lit les fichiers nettoyés, crée la cible, retire les billets trop récents et ajoute les variables.

scripts/build_features.py
"""Build the modelling table: one row per ticket, with only the facts known
when the ticket is created, and the target, escalated_72h."""
import json
from pathlib import Path
import pandas as pd
IN = Path("data/clean")
OUT = Path("data/model")
SNAPSHOT = pd.Timestamp("2026-07-01 06:00:00") # from data/extract/manifest.json
WINDOW = pd.Timedelta(hours=72)
TIME_FORMAT = "%Y-%m-%d %H:%M:%S"
# Columns filled in after the ticket is created. They describe the future of
# the ticket, so they must not be features.
AFTER_CREATION = ["priority_now", "first_reply_minutes", "closed_at"]
FEATURES = ["channel", "team", "priority", "segment", "region", "order_value", "word_count",
"customer_tenure_days", "prior_tickets_90d", "created_hour"]
def main():
tickets = pd.read_csv(IN / "tickets.csv", parse_dates=["created_at", "joined_on"],
dtype={"customer_id": "string"}, keep_default_na=False, na_values=[""])
outcomes = pd.read_csv(IN / "outcomes.csv", parse_dates=["recorded_at"])
# Target: an escalation within 72 hours of creation.
first_escalation = outcomes[outcomes["status"] == "escalated"].groupby("ticket_id")["recorded_at"].min()
tickets["escalated_at"] = tickets["ticket_id"].map(first_escalation)
tickets["escalated_72h"] = (tickets["escalated_at"] <= tickets["created_at"] + WINDOW).astype(int)
# A ticket's 72 hours must be over before the snapshot, or its label is
# not final yet.
eligible = tickets["created_at"] <= SNAPSHOT - WINDOW
counts = {"clean_tickets": len(tickets), "too_recent_for_label": int((~eligible).sum())}
df = tickets[eligible].copy()
# Features known at creation time.
df["customer_tenure_days"] = (df["created_at"] - df["joined_on"]).dt.days
df["created_hour"] = df["created_at"].dt.hour
df = df.sort_values(["created_at", "ticket_id"])
df["prior_tickets_90d"] = prior_count(df, tickets)
columns = ["ticket_id", "customer_id", "created_at"] + FEATURES + ["escalated_72h"]
df = df[columns]
OUT.mkdir(parents=True, exist_ok=True)
df.to_csv(OUT / "tickets_model.csv", index=False, date_format=TIME_FORMAT)
counts.update({"rows": len(df), "escalated": int(df["escalated_72h"].sum()),
"escalated_rate": round(df["escalated_72h"].mean(), 3)})
(OUT / "build_summary.json").write_text(json.dumps(counts, indent=2) + "\n", encoding="utf-8")
print(json.dumps(counts, indent=2))
def prior_count(df, all_tickets):
"""For each ticket: how many tickets the same customer created in the 90
days before it. Only earlier tickets count, so the value is known at
creation time. Guests have no history: 0."""
history = all_tickets.dropna(subset=["customer_id"]).groupby("customer_id")["created_at"].apply(sorted)
result = []
for customer, created in zip(df["customer_id"], df["created_at"]):
if pd.isna(customer):
result.append(0)
continue
earlier = [t for t in history[customer] if created - pd.Timedelta(days=90) <= t < created]
result.append(len(earlier))
return result
if __name__ == "__main__":
main()

Les lignes importantes :

Lignes Pourquoi
groupby("ticket_id")["recorded_at"].min() sur les événements escalated Le moment du premier transfert de chaque billet. Un billet sans transfert reçoit une valeur manquante (NaT, « pas une date ou une heure »).
escalated_at <= created_at + WINDOW 1 si le premier transfert a eu lieu dans les 72 heures. Une comparaison avec NaT donne False (faux), donc un billet sans transfert reçoit 0.
eligible = created_at <= SNAPSHOT - WINDOW Garde seulement les billets dont les 72 heures étaient écoulées avant l’instantané. Le nombre de lignes retirées figure dans le résumé; rien ne disparaît sans indication.
columns = [...] La sortie a une liste explicite de colonnes. priority_now, first_reply_minutes et closed_at n’y figurent pas et ne peuvent donc pas atteindre le modèle par accident.
prior_count(df, tickets) Compte l’historique dans tous les billets nettoyés, y compris les 29 récents. Ceux-ci font réellement partie du passé pour les lignes suivantes. La condition t < created garde seulement les billets antérieurs.
customer_tenure_days Nombre de jours entre joined_on et la création. Les invités et les clients supprimés n’ont pas de joined_on; la valeur est donc manquante. La leçon 3 la remplit.

Mei exécute le script :

Terminal
python scripts/build_features.py
Sortie
{
"clean_tickets": 1398,
"too_recent_for_label": 29,
"rows": 1369,
"escalated": 188,
"escalated_rate": 0.137
}

Comptez chaque ligne : 1 398 billets nettoyés − 29 trop récents = 1 369 lignes. Parmi eux, 188 ont été transférés dans les 72 heures. Le taux de transfert est donc de 188 ÷ 1 369 = 13,7 %. Les mêmes nombres sont enregistrés dans data/model/build_summary.json, à côté de tickets_model.csv.

  1. Créez le fichier scripts/build_features.py avec le code ci-dessus et enregistrez-le.

  2. Exécutez-le depuis le dossier du projet :

    Terminal
    python scripts/build_features.py

    Vous devriez voir les mêmes cinq nombres que Mei : 1 398, 29, 1 369, 188 et 0,137.

  3. Examinez une ligne. Créez le fichier check_row.py dans le dossier du projet :

    check_row.py
    import pandas as pd
    model = pd.read_csv("data/model/tickets_model.csv")
    print(model.shape)
    print(model[model["ticket_id"] == "T-11241"].T)
  4. Exécutez python check_row.py. .T transforme la ligne en colonne pour faciliter la lecture :

    Sortie
    (1369, 14)
    1236
    ticket_id T-11241
    customer_id C-0094
    created_at 2026-06-11 17:53:05
    channel email
    team warranty
    priority 3
    segment home
    region south
    order_value 59.85
    word_count 92
    customer_tenure_days 2506.0
    prior_tickets_90d 2
    created_hour 17
    escalated_72h 0

    La table a 14 colonnes : trois identifiants et repères temporels, 10 variables et la cible. priority_now, first_reply_minutes et closed_at n’y sont pas.

  5. Vérifiez prior_tickets_90d = 2 avec SQL, sans utiliser pandas. Enregistrez cette requête dans queries/50-prior-tickets.sql :

    queries/50-prior-tickets.sql
    SELECT ticket_id, created_at
    FROM tickets
    WHERE customer_id = 'C-0094'
    AND created_at >= datetime('2026-06-11 17:53:05', '-90 days')
    AND created_at < '2026-06-11 17:53:05'
    ORDER BY created_at;
  6. Exécutez-la avec python q.py queries/50-prior-tickets.sql :

    Sortie
    ticket_id created_at
    --------- -------------------
    T-11014 2026-05-13 11:25:10
    T-11085 2026-05-22 10:58:45
    2 rows

    Deux billets antérieurs dans les 90 jours, comme dans le script. La requête utilise larkfield.db, l’exportation brute. Cela convient pour C-0094, qui n’a aucune ligne en double.

Pourquoi la requête utilise-t-elle datetime(..., '-90 days') et <?

datetime('2026-06-11 17:53:05', '-90 days') renvoie 2026-03-13 17:53:05, le début des 90 jours. >= inclut un billet créé à cette seconde précise, comme le script (created - 90 days <= t). < '2026-06-11 17:53:05' exclut le billet lui-même et tout ce qui vient après. Si vous écrivez <=, T-11241 se compte lui-même et la valeur devient 3.

Omar, le responsable produit, demande : « Et si on acheminait plutôt les billets transférés dans les 48 heures? » Changez une chose et examinez le résultat.

  1. Copiez le script dans scripts/build_features_48h.py.
  2. Dans la copie, définissez WINDOW = pd.Timedelta(hours=48).
  3. Dans la copie, changez les noms des sorties pour tickets_model_48h.csv et build_summary_48h.json. La suite du module utilise les fichiers de 72 heures.
  4. Prédisez : y aura-t-il plus ou moins de billets trop récents? Le nombre de billets transférés augmentera-t-il ou diminuera-t-il?
  5. Exécutez la copie et notez les cinq nombres.
Afficher la réponse

La date limite passe au 29 juin 2026 à 6 h. Il y a donc moins de billets trop récents : 22 au lieu de 29. La sortie est :

Sortie
{
"clean_tickets": 1398,
"too_recent_for_label": 22,
"rows": 1376,
"escalated": 190,
"escalated_rate": 0.138
}

La cible de 48 heures pose une question légèrement différente. Certains transferts entre 48 et 72 heures comptent maintenant comme 0, mais la table contient aussi 7 lignes récentes supplémentaires. Le nombre de billets transférés passe donc de 188 à 190, au lieu de diminuer. Les deux changements agissent en sens opposés; seule l’exécution révèle le résultat. Supprimez les deux fichiers _48h et la copie du script quand vous avez terminé. Ils ne risqueront ainsi pas de créer de confusion dans la prochaine leçon.

Erreur courante

Une variable qui fonctionne trop bien

Symptôme : Tomás, l’ingénieur en IA, examine les billets nettoyés. « Utilisez priority_now. Ça fonctionne vraiment bien. » Il a raison : cela fonctionne. Voici les 1 369 lignes de modélisation, avec priority_now ajouté par jointure depuis le fichier nettoyé :

check_priority_now.py
import pandas as pd
clean = pd.read_csv("data/clean/tickets.csv")
model = pd.read_csv("data/model/tickets_model.csv")
both = model.merge(clean[["ticket_id", "priority_now"]], on="ticket_id", validate="one_to_one")
print(pd.crosstab(both["priority_now"], both["escalated_72h"]))
print(pd.crosstab(both["priority"], both["escalated_72h"]))
Sortie
escalated_72h 0 1
priority_now
1 138 188
2 431 0
3 612 0
escalated_72h 0 1
priority
1 117 64
2 444 67
3 620 57

Chacun des 188 billets transférés a priority_now = 1. Aucun billet avec priority_now à 2 ou 3 n’a été transféré. La colonne sans fuite, priority, est beaucoup moins révélatrice : on trouve des billets transférés à chaque niveau de priorité.

Cause : priority_now est écrit après le transfert. Le service de soutien lui donne la valeur 1 quand un agent principal prend le billet. La colonne ne prédit donc pas le transfert; elle l’enregistre. Au moment de la prédiction, chaque nouveau billet a un priority_now égal à priority. Le lien fort n’existe pas encore.

Correction : demandez quand chaque valeur est écrite et gardez la réponse dans le code. Le script de Mei inscrit la colonne dans AFTER_CREATION et l’exclut des colonnes de sortie. Une variable qui sépare presque parfaitement les classes justifie une vérification des fuites, pas une célébration.

Conséquence dans le travail en IA : avec priority_now, un modèle obtient un score très élevé en test (la leçon 3 le mesure). En pratique, la colonne ne contient aucune information sur le futur et l’acheminement échoue. L’équipe fait confiance au modèle grâce au score de test élevé. Trouver le problème peut donc prendre des semaines.

Vérifiez votre compréhension

Lesquelles de ces colonnes peuvent servir de caractéristiques au modèle de transfert au niveau supérieur : team, csat, word_count, closed_at, first_reply_minutes, segment? Expliquez pourquoi chacune des autres ne convient pas.

Afficher la réponse

team (équipe), word_count (nombre de mots) et segment peuvent servir de caractéristiques : on les connaît à l’arrivée du billet de soutien. csat (satisfaction de la clientèle) est enregistré après la résolution du billet. closed_at (date de fermeture) est écrit à la fermeture du billet. On connaît first_reply_minutes (minutes avant la première réponse) seulement après la première réponse. Ces trois valeurs décrivent l’avenir du billet. Elles constituent donc une fuite de données.

Vérifiez votre compréhension

Tomás dit : « Gardez les 29 billets récents. Ceux sans événement de transfert au niveau supérieur ont escalated_72h = 0, donc la cible est correcte. » A-t-il raison?

Afficher la réponse

Non. Les 72 heures n’étaient pas écoulées au moment de l’instantané. L’absence de transfert signifie donc « pas encore connu », et non « non ». Certains des 23 zéros pourraient devenir des 1 après l’instantané. Si on les garde, les billets les plus récents semblent moins susceptibles d’être transférés au niveau supérieur. Le modèle apprend alors une tendance qui n’est pas réelle. Retirez-les et comptez-les, comme le fait build_features.py.

Vérifiez votre compréhension

Priya propose une caractéristique : le nombre de billets créés par le client dans les 30 jours entourant le billet. Cela comprend les 15 jours avant et les 15 jours après. Est-ce sans risque? Comment la modifieriez-vous?

Afficher la réponse

Ce n’est pas sans risque. Au moment de la prédiction, les 15 jours après le billet sont dans l’avenir. La caractéristique entraîne donc une fuite de données. Un client dont le billet est transféré au niveau supérieur peut écrire de nouveau dans les jours suivants. La fuite est donc aussi liée à la cible. Utilisez plutôt les billets des 30 jours avant le billet, avec t < created, comme le fait prior_tickets_90d.

Récapitulatif

  • Le moment de la prédiction est celui où le modèle doit répondre : ici, à la création du billet. Chaque caractéristique doit exister à ce moment.
  • La cible escalated_72h (transfert au niveau supérieur dans les 72 heures) est précise : un événement escalated (transféré au niveau supérieur) dans les 72 heures suivant la création. Les billets créés dans les 72 dernières heures avant l’instantané n’ont pas encore de cible définitive. On en retire donc 29 et on les compte.
  • Demandez quand chaque valeur est écrite. priority_now (priorité actuelle), first_reply_minutes, closed_at et toutes les données des événements de résultat ne servent jamais de caractéristiques.
  • L’historique du client doit provenir uniquement de billets antérieurs. prior_tickets_90d (billets antérieurs sur 90 jours) respecte cette règle; un total de tous les billets d’un client ne la respecte pas.
  • build_features.py écrit 1 369 lignes, dont 188 avec un transfert au niveau supérieur (13,7 %), et 10 caractéristiques.

Ensuite, vous décidez quelles lignes serviront à l’apprentissage du modèle et lesquelles serviront à le tester.

Leçon suivante : Choisir une séparation