Prédire le défaut de paiement par carte de crédit
- Étude de cas 3
- 120 min
- Facultative · 1 étude de cas requise pour le certificat
Étude de cas 3 sur 7 · Apprentissage automatique : du problème à un modèle fiablePrédire le défaut de paiement par carte de crédit
1. Comprendre l'apprentissage automatique
2. Cadrer le problème de prédiction
3. Évaluer les modèles supervisés
4. Créer les premiers modèles
5. Essayer d'autres familles de modèles
6. Régler et finaliser le modèle
7. Comprendre et choisir le modèle
8. Préparer la passation du modèle
Ce que vous saurez faire
Créez un modèle de risque de crédit, des données réelles jusqu’à un résultat testé que vous pouvez présenter. Vous choisirez le seuil selon le coût de chaque erreur. Vous vérifierez aussi que les scores représentent bien ce qu’ils annoncent. Vous découvrirez également si le modèle traite les jeunes clients différemment, même si l’âge ne fait pas partie des entrées.
Le carnet (notebook) Jupyter exécute chaque étape de cette page, avec le même code et les mêmes résultats. Il ajoute quatre tâches avec des vérifications. Ses cellules de préparation installent pandas et scikit-learn et téléchargent les données (environ 1 Mo). Vous n’avez donc rien à préparer localement. Il s’exécute en environ une minute.
Avant de commencerCe qu’il faut savoir et avoir sous la main
Ce que vous devez savoir
Cette étude de cas utilise tout le cours. Ces leçons sont les plus importantes :
- La précision, le rappel et la matrice de confusion, dans Les métriques de classification, et la précision moyenne, dans Le compromis entre précision et rappel.
- Le pipeline avec encodage un à un et mise à l’échelle, dans Prétraiter avec un pipeline.
- Le journal d’expériences, dans L’apprentissage automatique est itératif.
- Les seuils, la calibration et les données de test utilisées une seule fois, dans Seuils et calibration.
- Les sous-groupes avec leurs effectifs, dans L’analyse par sous-groupes, et les contributions pour un billet de soutien, dans Expliquer les prédictions.
- Pourquoi les prêteurs utilisent des modèles compréhensibles, dans Quand un modèle simple est meilleur.
Ce qu’il vous faut sur votre ordinateur (seulement pour l’exécution locale; le carnet n’a besoin de rien de tout cela)
- Un dossier avec un environnement virtuel contenant pandas 3.0.6, scikit-learn 1.9.1 et matplotlib, comme dans Définir une référence de base : préparer votre projet. Le projet
ticket-modelconvient; cette étude de cas ne modifie pas ses fichiers. - Le carnet, téléchargé à partir du lien ci-dessus, dans VS Code ou JupyterLab. Sa cellule de préparation télécharge les données dans un nouveau dossier
credit-default. Pour recommencer, supprimez ce dossier.
Testé sur macOS avec Python 3.14.6, pandas 3.0.6 et scikit-learn 1.9.1, dans une nouvelle session Jupyter. Windows et Linux n’ont pas été testés.
La situation
Section intitulée « La situation »Mei-Ling Chen dirige l’équipe du risque lié aux cartes de crédit d’une banque à Taïwan. Chaque mois, quelques jours avant le prochain relevé, son équipe choisit les clients qui recevront un appel d’aide préventive. Un agent appelle le client, propose un plan de paiement et suspend toute augmentation de la limite de crédit. L’équipe ne refuse ni ne ferme quoi que ce soit. L’appel offre de l’aide et un rappel, avant que le client prenne du retard.
Chaque choix peut entraîner deux types d’erreurs. Mei-Ling a estimé un coût pour chacune :
- Une fausse alerte : l’équipe appelle un client qui aurait payé. Cela prend du temps à l’agent et dérange un bon client. Elle compte 1 unité.
- Un défaut manqué : un client qui ne paiera pas ne reçoit aucun appel. La banque ne récupère jamais une partie de cette dette. Elle compte 5 unités.
Un défaut manqué coûte donc cinq fois plus qu’une fausse alerte. Aujourd’hui, l’équipe appelle tous les clients qui étaient en retard le mois dernier. Mei-Ling demande mieux à son analyste, Arjun : « Qui risque le plus de manquer le prochain paiement? Et combien de clients devrions-nous appeler? »
Dans cette étude de cas, défaut signifie « n’a pas effectué le prochain paiement ». C’est un fait concernant le remboursement, pas un jugement sur l’honnêteté ou les intentions de la personne.
Ce que vous allez créer
Section intitulée « Ce que vous allez créer »Vous créerez un pipeline de régression logistique qui donne à chaque client un score de risque de défaut le mois prochain. Vous choisirez ensuite un seuil qui transforme le score en « appeler » ou « ne pas appeler », au coût total minimal. Vous tiendrez un journal d’expériences et comparerez les scores à la réalité. Vous expliquerez aussi le score d’un client et vérifierez le modèle pour différents groupes de clients. À la fin, vous le testerez une seule fois et rédigerez une présentation pour votre portfolio.
Les compétences viennent de tout le cours : cadrage (Traduire un besoin), références de base (Définir une référence de base), divisions fiables (Données d’entraînement, de validation et de test), pipelines, journal d’expériences, seuils et calibration, sous-groupes et explications, et fiche du modèle (Documenter les limites d’utilisation).
Les données
Section intitulée « Les données »Les données proviennent du jeu de données Default of Credit Card Clients, du UCI Machine Learning Repository. Il contient 30 000 clients réels et anonymes d’une banque à Taïwan. Pour chaque client, il contient l’historique d’avril à septembre 2005 et indique si le client a payé en octobre.
La cellule de préparation du carnet télécharge le fichier et vérifie sa somme de contrôle. Arjun le charge et examine sa taille et ses colonnes :
raw = pd.read_csv("credit_default_taiwan.csv.gz")print(raw.shape)print(raw.columns.tolist())Le résultat :
(30000, 25)['ID', 'LIMIT_BAL', 'SEX', 'EDUCATION', 'MARRIAGE', 'AGE', 'PAY_0', 'PAY_2', 'PAY_3', 'PAY_4', 'PAY_5', 'PAY_6', 'BILL_AMT1', 'BILL_AMT2', 'BILL_AMT3', 'BILL_AMT4', 'BILL_AMT5', 'BILL_AMT6', 'PAY_AMT1', 'PAY_AMT2', 'PAY_AMT3', 'PAY_AMT4', 'PAY_AMT5', 'PAY_AMT6', 'default']Ce que cela nous apprend. Une ligne représente un client. À part un ID (identifiant), les colonnes contiennent la limite de crédit, quatre renseignements personnels et trois groupes de six colonnes mensuelles. Dans chaque groupe, les mois vont à reculons, de septembre à avril. La dernière colonne est la cible.
Le dictionnaire des données
Section intitulée « Le dictionnaire des données »Voici seulement les colonnes utilisées dans cette étude de cas, expliquées en mots simples. Les montants sont en nouveaux dollars taïwanais ($ NT).
| Colonne | Signification | Utilisation |
|---|---|---|
LIMIT_BAL |
La limite de crédit de la carte | Entrée |
EDUCATION |
1 études supérieures, 2 université, 3 secondaire, 4 autres | Entrée |
SEX, MARRIAGE, AGE |
Sexe (1 homme, 2 femme), état matrimonial (1 marié, 2 célibataire, 3 autres), âge en années | Pas des entrées : seulement pour les vérifications d’équité |
PAY_0, PAY_2 … PAY_6 |
État du remboursement en septembre, août … avril : −1 payé à temps, 1 à 9 mois de retard | Entrées |
BILL_AMT1 … BILL_AMT6 |
La facture de septembre … avril | Entrées |
PAY_AMT1 … PAY_AMT6 |
Le montant payé en septembre … avril | Entrées |
default |
1 si le client n’a pas payé en octobre | Cible |
Premières vérifications
Section intitulée « Premières vérifications »Avant de créer un modèle, Arjun vérifie les valeurs manquantes, les doublons et la proportion de défauts :
print("missing values:", int(raw.isna().sum().sum()))print("repeated IDs:", int(raw["ID"].duplicated().sum()))print("same row, other ID:", int(raw.drop(columns="ID").duplicated().sum()))print("defaults:", int(raw["default"].sum()), "of", len(raw), f"({raw['default'].mean():.1%})")Le résultat :
missing values: 0repeated IDs: 0same row, other ID: 35defaults: 6636 of 30000 (22.1%)Ce que cela nous apprend. Il n’y a aucune valeur manquante. 35 lignes répètent une autre ligne dans toutes les colonnes sauf l’identifiant. La plupart représentent des clients sans aucune facture ni aucun paiement. Leurs identifiants diffèrent : ce sont probablement des personnes différentes avec le même historique vide. Arjun les conserve. Environ un client sur cinq fait défaut. C’est assez rare pour que l’exactitude soit trompeuse. Un modèle qui répond toujours « paie » a raison 77,9 % du temps et n’aide personne. C’est aussi assez fréquent pour qu’un modèle puisse apprendre.
Des codes inhabituels
Section intitulée « Des codes inhabituels »La source documente les codes d’éducation de 1 à 4 et d’état matrimonial de 1 à 3. Pour l’état du remboursement, elle documente −1 (payé à temps) et 1 à 9 (mois de retard). Arjun vérifie si le fichier respecte ces codes :
for column in ["EDUCATION", "MARRIAGE", "PAY_0", "PAY_2"]: print(column, raw[column].value_counts().sort_index().to_dict())print("negative September bills:", int((raw["BILL_AMT1"] < 0).sum()))Le résultat :
EDUCATION {0: 14, 1: 10585, 2: 14030, 3: 4917, 4: 123, 5: 280, 6: 51}MARRIAGE {0: 54, 1: 13659, 2: 15964, 3: 323}PAY_0 {-2: 2759, -1: 5686, 0: 14737, 1: 3688, 2: 2667, 3: 322, 4: 76, 5: 26, 6: 11, 7: 9, 8: 19}PAY_2 {-2: 3782, -1: 6050, 0: 15730, 1: 28, 2: 3927, 3: 326, 4: 99, 5: 25, 6: 12, 7: 20, 8: 1}negative September bills: 590Ce que cela nous apprend. Il y a quatre problèmes, et chacun modifie une étape à venir :
- Des codes que personne n’a documentés. L’éducation contient 0, 5 et 6 (345 clients), et l’état matrimonial contient 0 (54 clients). Arjun regroupe les trois codes d’éducation dans « autres » (4). L’état matrimonial n’est pas une entrée, donc il le laisse tel quel.
- Les deux codes d’état les plus fréquents ne sont pas documentés. −2 et 0 couvrent 17 496 clients en septembre. On les interprète souvent comme « aucune utilisation de la carte » et « minimum payé », mais la source ne le confirme pas. Ils sont trop fréquents pour les supprimer et ne représentent clairement pas des « mois de retard ». Arjun traite donc chaque code d’état comme une catégorie, et non comme un nombre.
- Septembre utilise les codes différemment. Le code 1 (un mois de retard) apparaît 3 688 fois en septembre et seulement 28 fois en août. Le même nombre peut avoir un sens différent selon le mois. C’est une raison de plus pour donner aux codes de chaque mois leurs propres catégories.
- Les noms sautent un numéro. Les colonnes d’état sont
PAY_0, PAY_2, …, et il n’y a pas dePAY_1. Arjun renommePAY_0enPAY_1, pour que 1 signifie septembre dans chaque groupe.
Les 590 factures négatives appartiennent à des clients qui ont payé plus que leur dette. Elles sont réelles, donc il les conserve.
Cadrer le problème
Section intitulée « Cadrer le problème »Traduire un besoin pose quelques questions avant de créer un modèle. Voici les réponses d’Arjun :
- L’unité : un client, à la date du relevé de septembre 2005.
- La cible :
default, 1 si le client n’a pas effectué le paiement d’octobre. - Le moment de la prédiction : avant l’échéance du paiement d’octobre, quand le relevé de septembre est connu.
- Les entrées : la limite de crédit, l’éducation et les six mois d’états, de factures et de paiements. Toutes sont connues au moment de la prédiction, donc aucune information d’octobre ne fuit.
- Les colonnes sensibles : le sexe, l’état matrimonial et l’âge. Ces renseignements personnels ne devraient pas déterminer qui reçoit un appel. Dans de nombreux pays, les lois limitent leur utilisation dans les décisions de crédit. Arjun les garde hors des entrées. Il les garde dans les données, car sans elles, il ne peut pas vérifier si le modèle traite ces groupes différemment.
prepare() effectue les deux corrections, et les listes de colonnes définissent chaque rôle :
def prepare(raw): """Rename PAY_0 to PAY_1 (September), and put the undocumented education codes 0, 5 and 6 into 4 (others).""" clients = raw.rename(columns={"PAY_0": "PAY_1"}) clients["EDUCATION"] = clients["EDUCATION"].replace({0: 4, 5: 4, 6: 4}) return clients
clients = prepare(raw)STATUS = [f"PAY_{m}" for m in range(1, 7)] # repayment status, 1 = September ... 6 = AprilAMOUNTS = ["LIMIT_BAL"] + [f"BILL_AMT{m}" for m in range(1, 7)] + [f"PAY_AMT{m}" for m in range(1, 7)]CATEGORIES = ["EDUCATION"] + STATUSFEATURES = CATEGORIES + AMOUNTSSENSITIVE = ["SEX", "MARRIAGE", "AGE"] # never inputs: only for slice checksTARGET = "default"print(len(FEATURES), "features | kept out of the inputs:", SENSITIVE)Le résultat :
20 features | kept out of the inputs: ['SEX', 'MARRIAGE', 'AGE']Ce que cela nous apprend. Le modèle verra 20 colonnes. Les trois colonnes sensibles sont nommées au même endroit pour qu’aucune étape ultérieure ne les ajoute par accident.
Explorer ce qu’il faut pour décider
Section intitulée « Explorer ce qu’il faut pour décider »Arjun n’examine pas toutes les colonnes. Il pose deux questions nécessaires à la décision. Première question : comment le risque varie-t-il selon l’état le plus récent?
by_status = clients.groupby(clients["PAY_1"].clip(upper=3))[TARGET].agg(clients="size", default_rate="mean")print(by_status.round(3))Le résultat :
clients default_ratePAY_1-2 2759 0.132-1 5686 0.168 0 14737 0.128 1 3688 0.339 2 2667 0.691 3 463 0.719Description textuelle du graphique
Un graphique à colonnes avec six colonnes, une par code d’état de septembre. −2 (2 759 clients) : 13 %. −1, payé à temps (5 686) : 17 %. 0 (14 737) : 13 %. 1, un mois de retard (3 688) : 34 %. 2, deux mois de retard (2 667) : 69 %. 3 mois de retard ou plus (463) : 72 %. Les trois premières colonnes sont basses et semblables; les trois dernières montent fortement.
Ce que cela nous apprend. Un retard en septembre est le signe individuel le plus fort. Parmi les clients ayant deux mois de retard, environ 7 sur 10 font défaut. Les trois codes −2, −1 et 0 ont des taux faibles et semblables, et celui de 0 est le plus bas. Si le modèle lisait les codes comme des nombres, il supposerait que le risque augmente de −2 à 0. Les données montrent le contraire, ce qui confirme le choix des catégories.
Deuxième question : à quoi ressemblent les montants, et la limite de crédit a-t-elle de l’importance?
print(clients[["LIMIT_BAL", "BILL_AMT1", "PAY_AMT1"]].agg(["median", "mean", "max"]).round(0))limit_band = pd.cut(clients["LIMIT_BAL"], [0, 50_000, 140_000, 240_000, 1_000_000], labels=["up to 50k", "50k-140k", "140k-240k", "over 240k"])print(clients.groupby(limit_band, observed=True)[TARGET].agg(clients="size", default_rate="mean").round(3))Le résultat :
LIMIT_BAL BILL_AMT1 PAY_AMT1median 140000.0 22382.0 2100.0mean 167484.0 51223.0 5664.0max 1000000.0 964511.0 873552.0 clients default_rateLIMIT_BALup to 50k 7676 0.31850k-140k 7614 0.247140k-240k 7643 0.173over 240k 7067 0.140Ce que cela nous apprend. Les montants ont une distribution très asymétrique. Le paiement médian de septembre est de 2 100 $ NT. Le plus élevé est de 873 552 $ NT, soit plus de 400 fois ce montant. Dans un modèle linéaire, quelques valeurs énormes influenceraient fortement les poids. Le pipeline applique donc d’abord un logarithme aux montants. La limite de crédit compte aussi. Les clients dont la limite ne dépasse pas 50 000 $ NT font défaut dans 31,8 % des cas. C’est plus du double du taux pour les limites supérieures à 240 000 $ NT. Gardez cela en tête. La limite n’est pas un fait concernant seulement le client, car la banque l’a fixée. Elle reviendra dans les vérifications d’équité.
Division et référence de base
Section intitulée « Division et référence de base »Arjun divise les clients en trois ensembles, comme dans Données d’entraînement, de validation et de test. L’entraînement (60 %) sert à ajuster le modèle, la validation (20 %) à choisir et le test (20 %) à vérifier une dernière fois.
Kevin, analyste dans une autre équipe, demande : « Pourquoi ne pas diviser selon le temps, comme Larkfield? » Parce qu’il n’y a pas de périodes distinctes pour le faire. Les 30 000 clients viennent des mêmes mois, et rien n’indique quel relevé est arrivé en premier. Le modèle évaluera les clients du mois prochain de la même banque, qui ressemblent à ceux-ci. Une division aléatoire, stratifiée selon la cible, correspond donc à l’utilisation : chaque ensemble reçoit la même proportion de défauts. En contrepartie, ces données ne montrent pas ce qui se passe quand l’économie change. Arjun l’ajoute aux limites.
from sklearn.model_selection import train_test_split
rest, test = train_test_split(clients, test_size=0.2, stratify=clients[TARGET], random_state=0)train, valid = train_test_split(rest, test_size=0.25, stratify=rest[TARGET], random_state=0)for name, part in [("train", train), ("valid", valid), ("test", test)]: print(f"{name:5s} {len(part):6d} clients, {part[TARGET].sum():5d} defaults ({part[TARGET].mean():.1%})")Le résultat :
train 18000 clients, 3982 defaults (22.1%)valid 6000 clients, 1327 defaults (22.1%)test 6000 clients, 1327 defaults (22.1%)Ce que cela nous apprend. Les trois ensembles ont le même taux de défaut. Les données de test sont maintenant mises de côté jusqu’à la fin.
Le coût d’une décision
Section intitulée « Le coût d’une décision »La décision de l’équipe a un coût, donc l’évaluation le mesure. report() compare les indicateurs oui/non à ce qui s’est produit. La fonction donne les effectifs des Les métriques de classification, la précision, le rappel et le coût total : 5 pour chaque défaut manqué, plus 1 pour chaque fausse alerte. Arjun évalue ensuite trois références de base sur les données de validation. Il compare « n’appeler personne », « appeler tout le monde » et la règle actuelle : « appeler chaque client en retard en septembre ».
MISS_COST, ALARM_COST = 5, 1
def report(y, flags): """Flags against the real answers: counts, precision, recall and the cost.""" y, flags = np.asarray(y, dtype=bool), np.asarray(flags, dtype=bool) caught, false_alarms, missed = int((flags & y).sum()), int((flags & ~y).sum()), int((~flags & y).sum()) cost = MISS_COST * missed + ALARM_COST * false_alarms return {"flagged": round(flags.mean(), 3), "caught": caught, "false alarms": false_alarms, "missed": missed, "precision": round(caught / max(caught + false_alarms, 1), 3), "recall": round(caught / y.sum(), 3), "cost": cost, "cost per 1,000": round(1000 * cost / len(y))}
y_valid = valid[TARGET]baselines = pd.DataFrame([report(y_valid, np.zeros(len(valid))), report(y_valid, np.ones(len(valid))), report(y_valid, valid["PAY_1"] >= 1)], index=["call nobody", "call everybody", "rule: late in September"])print(baselines.to_string())Le résultat :
flagged caught false alarms missed precision recall cost cost per 1,000call nobody 0.000 0 0 1327 0.000 0.000 6635 1106call everybody 1.000 1327 4673 0 0.221 1.000 4673 779rule: late in September 0.228 678 689 649 0.496 0.511 3934 656Ce que cela nous apprend. N’appeler personne coûte 6 635 (1 327 défauts manqués × 5). Appeler tout le monde coûte moins cher, soit 4 673, car les défauts manqués coûtent très cher. La règle actuelle est la meilleure des trois. Elle appelle 22,8 % des clients, détecte environ la moitié des défauts (rappel de 0,511) et coûte 3 934. C’est le nombre à battre. Si un modèle ne peut pas battre une règle d’une ligne, l’équipe devrait garder la règle.
Le pipeline
Section intitulée « Le pipeline »Le pipeline suit Prétraiter avec un pipeline, avec deux choix issus des vérifications précédentes. Les codes de statut et le niveau de scolarité passent par un encodage un à un, ou one-hot encoding. Chaque code a donc sa propre colonne. Un code présent chez moins de 50 clients d’entraînement va dans une colonne commune pour les codes « peu fréquents ». Ainsi, un code rare ne peut pas recevoir un poids excessif. Les montants passent par un logarithme signé : log(1 + |x|), avec le signe de x. Il réduit les grands montants et garde les factures négatives sous zéro. Les montants sont ensuite mis à l’échelle.
from sklearn.compose import ColumnTransformerfrom sklearn.linear_model import LogisticRegressionfrom sklearn.pipeline import Pipelinefrom sklearn.preprocessing import FunctionTransformer, OneHotEncoder, StandardScaler
def signed_log(values): """log(1 + |x|) with the sign of x: it shrinks large amounts and keeps negative bills negative.""" return np.sign(values) * np.log1p(np.abs(values))
def build_pipeline(categories=CATEGORIES, amounts=AMOUNTS, log=True, C=1.0): """Status codes one-hot (rare codes grouped), amounts log-scaled and standardised, then logistic regression.""" amount_steps = [("log", FunctionTransformer(signed_log, feature_names_out="one-to-one"))] if log else [] amount_steps.append(("scale", StandardScaler())) prepare_step = ColumnTransformer([ ("status", OneHotEncoder(handle_unknown="infrequent_if_exist", min_frequency=50), categories), ("amounts", Pipeline(amount_steps), amounts), ]) return Pipeline([("prepare", prepare_step), ("model", LogisticRegression(max_iter=2000, C=C))])Pourquoi commencer par la régression logistique? Elle donne un score de 0 à 1, souvent proche d’une probabilité réelle. Le score de chaque client se décompose aussi en raisons. Une banque qui doit expliquer ses actions à un client a besoin des deux (voir Quand un modèle simple est meilleur).
Le journal des expériences
Section intitulée « Le journal des expériences »Chaque expérience est évaluée de la même façon : la précision moyenne sur les mêmes 5 plis stratifiés des données d’entraînement. C’est la méthode présentée dans L’apprentissage automatique est itératif. Pourquoi la précision moyenne? Les modèles changent, mais le seuil n’est pas encore choisi. La précision moyenne mesure si les scores placent les défauts de paiement en tête de liste, pour tous les seuils. Les 78 % de clients qui paient ne peuvent pas gonfler cette mesure, contrairement à l’exactitude. Le seuil vient plus tard, à partir des coûts. Le carnet (notebook) Jupyter définit cv_ap() pour un pipeline, rule_ap() pour une règle fixe, et log_experiment(). Cette dernière fonction ajoute une ligne à experiment_log.csv avec les colonnes de ce cours : id, date, change, cv_ap, cv_std, valid_ap, decision et reason.
Les trois premières lignes représentent le minimum (un même score pour chaque client), la règle actuelle comme score de 0 ou 1, et un modèle à une colonne :
log_experiment("E01", "Baseline: one score for every client (the train rate)", rule_ap(lambda part: np.full(len(part), train[TARGET].mean())), "baseline", "The floor.")log_experiment("E02", "Baseline: the rule 'late in September' as a 0/1 score", rule_ap(lambda part: (part["PAY_1"] >= 1).astype(int)), "baseline", "What the team does today.")log_experiment("E03", "Logistic regression, PAY_1 only (one-hot)", cv_ap(build_pipeline(["PAY_1"], []), ["PAY_1"]), "reject", "Above the rule, but it ignores five months of history.")Le résultat :
E01 CV AP 0.221 ± 0.000 baseline: Baseline: one score for every client (the train rate)E02 CV AP 0.367 ± 0.010 baseline: Baseline: the rule 'late in September' as a 0/1 scoreE03 CV AP 0.453 ± 0.012 reject: Logistic regression, PAY_1 only (one-hot)Ce que cela nous apprend. Le minimum est de 0,221, soit la proportion de défauts de paiement. La règle atteint 0,367. Une seule colonne bien utilisée, avec un risque distinct pour chaque code, atteint déjà 0,453. Le modèle sait que « 2 mois de retard » est bien pire que « 1 mois de retard ». La règle les traite de la même façon.
Viennent ensuite les 20 variables, dans trois versions. L’une contient une erreur volontaire : les codes de statut sont lus comme des nombres.
log_experiment("E04", "All 20 features, status codes as numbers", cv_ap(build_pipeline(["EDUCATION"], AMOUNTS + STATUS), FEATURES), "reject", "The codes -2, -1, 0 are not amounts; as numbers they lose information.")log_experiment("E05", "All 20 features, amounts without the log", cv_ap(build_pipeline(log=False), FEATURES), "reject", "Lower than E06 on 4 of 5 folds.")log_experiment("E06", "All 20 features, status one-hot, signed log of amounts", cv_ap(build_pipeline(), FEATURES), "keep", "Best so far; train AP close to CV, so it does not overfit.")Le résultat :
E04 CV AP 0.495 ± 0.015 reject: All 20 features, status codes as numbersE05 CV AP 0.536 ± 0.007 reject: All 20 features, amounts without the logE06 CV AP 0.542 ± 0.007 keep: All 20 features, status one-hot, signed log of amountsCe que cela nous apprend. Traiter les codes comme des catégories apporte 0,047 de précision moyenne (0,495 contre 0,542). C’est bien plus que le bruit de 0,007 à 0,015. C’est le prix d’un code non documenté lu de la mauvaise façon. Le logarithme ajoute seulement 0,006, mais le résultat est meilleur sur 4 des 5 plis et ne coûte rien. E06 devient donc le modèle à battre.
Erreur courante
Codes lus comme des nombres
Symptôme : Kevin donne les colonnes de statut au modèle comme des nombres, parce que « ce sont déjà des nombres ». Il n’y a aucune erreur. La précision moyenne en validation croisée est de 0,495 au lieu de 0,542.
Cause : un modèle linéaire donne un seul poids à une colonne numérique. Il suppose donc que chaque passage de −2 à −1, puis à 0 et à 1 ajoute le même risque. Les données disent autre chose : −2 a un taux de défaut de 0,132, −1 de 0,168 et 0 de 0,128. Ces codes sont des étiquettes qui sont simplement écrites avec des chiffres.
Correction : lisez la documentation et affichez le taux pour chaque code avant de choisir un encodage. Les codes passent par un encodage un à un, ou dans un arbre, qui peut séparer les valeurs n’importe où.
Conséquence dans le travail en IA : cette erreur se cache sous vos yeux. Les codes postaux, de produits, d’erreurs et de diagnostics ressemblent tous à des nombres. Un modèle qui les lit comme des nombres s’entraîne quand même et donne encore des scores. Ils sont simplement moins bons, et rien ne vous avertit.
Arjun essaie ensuite sa propre idée : deux ratios, le taux d’utilisation (facture de septembre ÷ limite) et la part payée (paiement de septembre ÷ facture d’août). Dans le carnet, c’est votre tâche. Le résultat :
E07 CV AP 0.541 ± 0.008 reject: E06 + utilisation and paid shareCe que cela nous apprend. Il n’y a aucun gain. Le modèle voit déjà les factures, les paiements et la limite. Les ratios n’ajoutent donc rien de nouveau. Une variable qui semble astucieuse n’est pas toujours utile. Deux colonnes de plus demanderaient des explications pour rien.
Vient ensuite le paramètre de régularisation C (voir Régler les hyperparamètres), avec une petite grille sur les mêmes plis :
from sklearn.model_selection import GridSearchCV
search = GridSearchCV(build_pipeline(), {"model__C": [0.01, 0.1, 1, 10]}, cv=cv, scoring="average_precision")search.fit(train[FEATURES], train[TARGET])grid = pd.DataFrame(search.cv_results_)[["param_model__C", "mean_test_score", "std_test_score"]]print(grid.round(4).to_string(index=False))log_experiment("E08", "E06 with C = 10, the best of the grid", cv_ap(build_pipeline(C=10), FEATURES), "reject", "C = 0.1, 1 and 10 tie (0.541 to 0.542); keep the default C = 1 (E06).")Le résultat :
param_model__C mean_test_score std_test_score 0.01 0.5349 0.0084 0.10 0.5413 0.0070 1.00 0.5416 0.0069 10.00 0.5417 0.0068E08 CV AP 0.542 ± 0.007 reject: E06 with C = 10, the best of the gridCe que cela nous apprend. Pour C = 0,1, 1 et 10, les résultats diffèrent de moins de 0,0004, bien en dessous du bruit. Avec 18 000 clients et 55 colonnes préparées, le modèle a peu de marge pour le surapprentissage. La valeur par défaut reste donc en place.
Deux expériences qui testent une politique
Section intitulée « Deux expériences qui testent une politique »Deux autres lignes testent des questions qui ne concernent pas seulement le score. D’abord, qu’ajoutent les renseignements personnels? Ensuite, un modèle plus complexe mérite-t-il sa place?
from sklearn.ensemble import HistGradientBoostingClassifier
log_experiment("E09", "E06 + SEX, MARRIAGE and AGE as inputs", cv_ap(build_pipeline(CATEGORIES + ["SEX", "MARRIAGE"], AMOUNTS + ["AGE"]), FEATURES + SENSITIVE), "reject", "Policy: personal facts are not inputs; the gain (0.001) is smaller than the noise.")e10_folds = cv_ap(HistGradientBoostingClassifier(random_state=0), FEATURES)print("E10 - E06 by fold:", (e10_folds - cv_ap(build_pipeline(), FEATURES)).round(3))Le résultat :
E09 CV AP 0.543 ± 0.007 reject: E06 + SEX, MARRIAGE and AGE as inputsE10 - E06 by fold: [ 0.005 0.014 0.013 0.013 -0.004]Ce que cela nous apprend. Les renseignements personnels ajoutent 0,001, ce qui est négligeable : six mois d’historique portent déjà le signal. Arjun les rejetterait même avec un gain, car la banque a décidé de ne pas les utiliser comme entrées. Le journal consigne cette raison. Le gradient boosting (amplification par gradient) est meilleur sur 4 des 5 plis, d’environ 0,008. C’est faible, mais constant. Arjun vérifie donc les deux modèles sur les données de validation, une seule fois :
from sklearn.metrics import brier_score_loss, roc_auc_score
pipeline = build_pipeline().fit(train[FEATURES], train[TARGET])scores = pipeline.predict_proba(valid[FEATURES])[:, 1]booster = HistGradientBoostingClassifier(random_state=0).fit(train[FEATURES], train[TARGET])booster_scores = booster.predict_proba(valid[FEATURES])[:, 1]for name, s in [("logistic regression (E06)", scores), ("boosting (E10)", booster_scores)]: print(f"{name:26s} ROC AUC {roc_auc_score(y_valid, s):.3f} AP {average_precision_score(y_valid, s):.3f}")Le résultat :
logistic regression (E06) ROC AUC 0.775 AP 0.555boosting (E10) ROC AUC 0.783 AP 0.559Ce que cela nous apprend. Le boosting classe un peu mieux : +0,008 d’aire sous la courbe ROC. En argent, au seuil le moins coûteux de chaque modèle, il économiserait 56 unités de coût sur 6 000 clients. C’est environ 9 par 1 000 clients, ou 1,7 % du coût. Par contre, le boosting ne peut pas décomposer le score d’un client en raisons exactes. L’équipe doit pourtant expliquer aux clients pourquoi ils ont reçu l’appel. Quand un modèle simple est meilleur décrit ce compromis. Ici, un gain de 1,7 % ne compense pas cette perte. Le boosting reste dans le journal comme modèle concurrent : à réessayer si les données augmentent ou si le gain devient plus important.
Voici le journal après dix expériences :
id change cv_ap cv_std valid_ap decisionE01 Baseline: one score for every client (the train rate) 0.221 0.000 NaN baselineE02 Baseline: the rule 'late in September' as a 0/1 score 0.367 0.010 NaN baselineE03 Logistic regression, PAY_1 only (one-hot) 0.453 0.012 NaN rejectE04 All 20 features, status codes as numbers 0.495 0.015 NaN rejectE05 All 20 features, amounts without the log 0.536 0.007 NaN rejectE06 All 20 features, status one-hot, signed log of amounts 0.542 0.007 0.555 keepE07 E06 + utilisation and paid share 0.541 0.008 NaN rejectE08 E06 with C = 10, the best of the grid 0.542 0.007 NaN rejectE09 E06 + SEX, MARRIAGE and AGE as inputs 0.543 0.007 NaN rejectE10 Gradient boosting (HistGradientBoosting, defaults), 20 features 0.550 0.006 0.559 rejectCe que cela nous apprend. Dix essais ont donné un modèle à retenir, avec une raison pour chaque rejet. La compréhension des données (E03 à E06) a apporté la majeure partie du gain. Le réglage (E08) et un algorithme plus puissant (E10) ont moins contribué. C’est courant.
Métrique et point de fonctionnement
Section intitulée « Métrique et point de fonctionnement »Un seuil déterminé par les coûts
Section intitulée « Un seuil déterminé par les coûts »Le modèle donne des scores, mais l’équipe a besoin de « appeler » ou « ne pas appeler ». Chez Larkfield, la capacité de l’équipe déterminait le seuil. Ici, l’équipe peut appeler autant de clients que nécessaire. Les coûts déterminent donc le seuil. Arjun essaie chaque seuil de 0,01 à 0,99 sur les données de validation et garde le moins coûteux :
THRESHOLDS = np.round(np.arange(0.01, 1.0, 0.01), 2)costs = pd.Series([report(y_valid, scores >= t)["cost"] for t in THRESHOLDS], index=THRESHOLDS)threshold = float(costs.idxmin())print("cheapest threshold:", threshold, "| cost:", int(costs.min()))table = pd.DataFrame([report(y_valid, scores >= t) for t in [0.5, 0.3, 0.2, threshold, 0.1]], index=[0.5, 0.3, 0.2, threshold, 0.1])print(table.to_string())Le résultat :
cheapest threshold: 0.17 | cost: 3355 flagged caught false alarms missed precision recall cost cost per 1,0000.50 0.115 478 213 849 0.692 0.360 4458 7430.30 0.206 687 547 640 0.557 0.518 3747 6240.20 0.325 851 1100 476 0.436 0.641 3480 5800.17 0.399 946 1450 381 0.395 0.713 3355 5590.10 0.778 1240 3425 87 0.266 0.934 3860 643Description textuelle du graphique
Un graphique en courbe. L’axe horizontal représente le seuil de 0,05 à 0,60; l’axe vertical, le coût total de 3 000 à 5 200. La courbe commence haut à gauche, car un seuil très bas fait appeler presque tout le monde. Elle descend jusqu’à son minimum à 0,17 (3 355). De 0,14 (3 375) à 0,17, elle est presque plate. À droite, elle monte : 3 480 à 0,20, 3 747 à 0,30 et 4 458 au seuil par défaut de 0,50. C’est parce que de plus en plus de défauts passent inaperçus.
Ce que cela nous apprend. Le seuil le moins coûteux est 0,17. À ce seuil, l’équipe appelle 39,9 % des clients et repère 946 des 1 327 défauts (rappel de 0,713). Le coût est de 3 355. C’est 14,7 % de moins que la règle actuelle (3 934). C’est aussi 24,7 % de moins que le même modèle au seuil par défaut de 0,5. À 0,5, le modèle semble précis (0,692), mais il manque presque deux défauts sur trois : précis et coûteux.
Pourquoi le seuil est-il si bas? Un défaut manqué coûte cinq fois plus qu’une fausse alerte. Un appel vaut donc la peine même quand la probabilité de défaut est bien inférieure à la moitié. Supposons qu’un score soit une vraie probabilité p. Le coût attendu d’un appel est alors (1 − p) × 1, et celui de ne pas appeler est p × 5. L’appel coûte moins cher quand p dépasse 1 ÷ (1 + 5) = 0,167. La recherche sur les données a trouvé 0,17. La théorie et les données concordent donc. Cette concordance dépend d’une condition, que la prochaine section vérifie.
Le seuil dépend des coûts, pas du modèle. Dans la tâche « changer une seule chose » du carnet, un défaut manqué coûte 3 au lieu de 5. Le seuil le moins coûteux monte alors à 0,25, comme la théorie le prédit (1 ÷ 4). Si les coûts de Mei-Ling changent, le seuil change, mais le modèle reste le même.
Calibration
Section intitulée « Calibration »La règle « appeler au-dessus de 0,167 » suppose qu’un score de 0,17 signifie une probabilité de défaut d’environ 17 %. Cette propriété est la calibration (Seuils et calibration). Arjun regroupe les scores de validation en intervalles :
bins = [0, 0.1, 0.2, 0.3, 0.5, 0.7, 1]calibration = pd.DataFrame({"score": scores, "default": y_valid.to_numpy()}).groupby( pd.cut(scores, bins), observed=True).agg(clients=("default", "size"), mean_score=("score", "mean"), default_rate=("default", "mean"))print(calibration.round(3))print("Brier:", round(brier_score_loss(y_valid, scores), 4), "| mean score", round(scores.mean(), 3), "| default rate", round(y_valid.mean(), 3))Le résultat :
clients mean_score default_rate(0.0, 0.1] 1335 0.081 0.065(0.1, 0.2] 2714 0.138 0.143(0.2, 0.3] 717 0.239 0.229(0.3, 0.5] 543 0.375 0.385(0.5, 0.7] 366 0.605 0.628(0.7, 1.0] 325 0.789 0.763Brier: 0.1346 | mean score 0.223 | default rate 0.221- Clients de validation
- Calibration parfaite (pointillés)
Description textuelle du graphique
Un graphique carré. L’axe horizontal représente le score moyen d’un intervalle, et l’axe vertical, le taux de défaut observé. Les deux vont de 0 à 0,85, avec une diagonale en pointillés pour la calibration parfaite. Six points sont proches de la diagonale : (0,081, 0,065) avec 1 335 clients, (0,138, 0,143) avec 2 714, (0,239, 0,229) avec 717, (0,375, 0,385) avec 543, (0,605, 0,628) avec 366 et (0,789, 0,763) avec 325. Le plus grand écart est dans l’intervalle supérieur : 2,6 points de pourcentage.
Ce que cela nous apprend. Dans chaque intervalle, le score moyen diffère du taux de défaut réel de moins de 2,6 points de pourcentage. En moyenne, le modèle annonce 22,3 %, et 22,1 % des clients font défaut. Les scores peuvent donc être lus comme des probabilités sur ces données, et la règle fondée sur les coûts est solide. Comparez avec Larkfield, dont les scores étaient trop bas après un changement de politique. Là-bas, les données d’entraînement et de validation provenaient de mois différents. Ici, elles proviennent des mêmes mois. Cette vérification ne peut donc pas détecter un changement futur. Le plan de surveillance doit en guetter un.
Les raisons pour un client
Section intitulée « Les raisons pour un client »Un client qui reçoit l’appel peut demander « pourquoi moi? », et l’agent a besoin d’une réponse honnête. En régression logistique, un score se décompose en contributions : coefficient multiplié par valeur préparée (Expliquer les prédictions). Vingt colonnes avec des codes encodés un à un donnent 55 petits nombres, trop pour un agent. Donc, contributions() les additionne en six groupes de raisons. Arjun compare ensuite un client au client moyen des données d’entraînement :
REASON_OF = {"PAY_1": "late status in September", "LIMIT_BAL": "credit limit", "EDUCATION": "education", **{f"PAY_{m}": "late status, April to August" for m in range(2, 7)}, **{f"BILL_AMT{m}": "bill amounts" for m in range(1, 7)}, **{f"PAY_AMT{m}": "payments made" for m in range(1, 7)}}
def contributions(pipeline, part): """Coefficient times prepared value, summed into the six reason groups, one row per client.""" prepare_step, model = pipeline.named_steps["prepare"], pipeline.named_steps["model"] values = np.asarray(prepare_step.transform(part[FEATURES])) * model.coef_[0] names = [n.split("__", 1)[1] for n in prepare_step.get_feature_names_out()] columns = [next(c for c in FEATURES if n == c or n.startswith(c + "_")) for n in names] table = pd.DataFrame(values, columns=[REASON_OF[c] for c in columns], index=part.index) return table.T.groupby(level=0).sum().T
average = contributions(pipeline, train).mean()client = valid.assign(score=scores)[valid["ID"] == 3008]print(client[["ID", "LIMIT_BAL"] + STATUS + ["PAY_AMT1", "PAY_AMT2", "score"]].to_string(index=False))reasons = (contributions(pipeline, client).iloc[0] - average).sort_values(ascending=False)print(reasons.round(2).to_string())Le résultat :
ID LIMIT_BAL PAY_1 PAY_2 PAY_3 PAY_4 PAY_5 PAY_6 PAY_AMT1 PAY_AMT2 score3008 10000 1 2 2 2 2 2 0 1600 0.7283late status, April to August 0.87payments made 0.72credit limit 0.46late status in September 0.37education 0.04bill amounts 0.01Ce que cela nous apprend. Le client 3008 a un score de 0,73, bien au-dessus du seuil. Par rapport à un client moyen, les trois principales raisons sont le statut d’avril à août, les paiements et la limite. Le client avait deux mois de retard chacun de ces mois, n’a rien payé en septembre et a une limite très basse (10 000 $ NT). L’agent peut dire : « Vous aviez deux mois de retard pendant chacun des cinq derniers mois, et aucun paiement n’est arrivé en septembre. Nous aimerions vous proposer un plan de paiement. » C’est précis et fidèle au modèle. Un autre fait : ce client a bien payé en octobre. Les raisons expliquent le score, pas l’avenir. Cet appel était une fausse alerte, faite avec courtoisie.
Erreurs et sous-groupes
Section intitulée « Erreurs et sous-groupes »Où le modèle échoue
Section intitulée « Où le modèle échoue »Au seuil de 0,17, le modèle manque 381 défauts de paiement et produit 1 450 fausses alertes sur les données de validation. Arjun examine les clients concernés :
errors = valid.assign(score=scores, flag=scores >= threshold)missed = errors[~errors["flag"] & (errors[TARGET] == 1)]false_alarms = errors[errors["flag"] & (errors[TARGET] == 0)]print("missed defaults:", len(missed), "| no delay in any of the 6 months:", int((missed[STATUS] <= 0).all(axis=1).sum()), "| median score", round(missed["score"].median(), 3))print("false alarms:", len(false_alarms), "| late in September:", int((false_alarms["PAY_1"] >= 1).sum()), "| limit up to 50,000:", int((false_alarms["LIMIT_BAL"] <= 50_000).sum()))Le résultat :
missed defaults: 381 | no delay in any of the 6 months: 350 | median score 0.126false alarms: 1450 | late in September: 639 | limit up to 50,000: 568Ce que cela nous apprend. Parmi les 381 défauts de paiement manqués, 350 n’avaient aucun retard pendant les six mois. Ces clients semblaient être de bons payeurs jusqu’au jour où ils n’ont pas payé. Une perte d’emploi ou une maladie pourrait l’expliquer, mais aucune colonne ne contient cette information. Aucun modèle utilisant ces colonnes ne peut les repérer. La promesse honnête à Mei-Ling est donc « environ 7 défauts de paiement sur 10 », et non « tous ». Les fausses alertes sont de deux types. Certains clients avaient un retard en septembre, mais l’ont rattrapé (639). D’autres ont une faible limite de crédit (568 ont une limite de 50 000 $ NT ou moins). Le deuxième type soulève la question de l’équité.
Sous-groupes et équité
Section intitulée « Sous-groupes et équité »Le modèle ne voit jamais le sexe, l’état matrimonial ou l’âge. Cela ne prouve pas qu’il traite ces groupes de la même façon. D’autres colonnes peuvent transmettre la même information. Arjun vérifie donc chaque groupe, comme dans L’analyse par sous-groupes, en indiquant toujours le nombre de clients. Pour l’équité, la colonne la plus importante est le taux de fausses alertes. Parmi les clients qui auraient payé, c’est la proportion qui reçoit quand même un appel. Ces personnes subissent le coût des erreurs du modèle.
AGE_BANDS, AGE_LABELS = [20, 25, 30, 40, 50, 80], ["21-25", "26-30", "31-40", "41-50", "51+"]
def slice_table(part, part_scores, column, threshold=threshold): """Clients, default rate, flag rate, recall, false-alarm rate and ROC AUC per group.""" frame = part.assign(score=part_scores, flag=part_scores >= threshold) rows = {} for group, g in frame.groupby(column, observed=True): good, bad = g[g[TARGET] == 0], g[g[TARGET] == 1] rows[group] = {"clients": len(g), "default rate": round(g[TARGET].mean(), 3), "flagged": round(g["flag"].mean(), 3), "recall": round(bad["flag"].mean(), 3), "false-alarm rate": round(good["flag"].mean(), 3), "ROC AUC": round(roc_auc_score(g[TARGET], g["score"]), 3) if g[TARGET].nunique() == 2 else None} return pd.DataFrame(rows).T
valid_bands = valid.assign(age_band=pd.cut(valid["AGE"], AGE_BANDS, labels=AGE_LABELS))for column in ["SEX", "MARRIAGE", "age_band"]: print(slice_table(valid_bands, scores, column).to_string(), end="\n\n")Le résultat :
clients default rate flagged recall false-alarm rate ROC AUC1 2438.0 0.246 0.420 0.718 0.323 0.7702 3562.0 0.204 0.385 0.708 0.302 0.777
clients default rate flagged recall false-alarm rate ROC AUC0 8.0 0.125 0.625 1.000 0.571 0.5711 2709.0 0.237 0.401 0.697 0.309 0.7662 3208.0 0.209 0.396 0.726 0.308 0.7853 75.0 0.213 0.480 0.750 0.407 0.739
clients default rate flagged recall false-alarm rate ROC AUC21-25 822.0 0.260 0.523 0.794 0.428 0.78726-30 1394.0 0.196 0.392 0.758 0.302 0.80031-40 2102.0 0.212 0.363 0.649 0.286 0.75441-50 1220.0 0.234 0.382 0.719 0.279 0.78851+ 462.0 0.238 0.413 0.682 0.330 0.734Ce que cela nous apprend. Par sexe (1 pour les hommes, 2 pour les femmes), les taux de fausses alertes sont proches : 0,323 et 0,302. La qualité du classement l’est aussi (aire sous la courbe ROC de 0,770 et 0,777). Pour l’état matrimonial, les deux petits groupes sont trop petits pour tirer une conclusion : 8 clients avec le code 0, et 75 dans « autres ». Quelques clients de plus ou de moins changeraient beaucoup leurs taux.
Pour l’âge, c’est différent. Le modèle signale les clients de 21 à 25 ans dans 52,3 % des cas. Pour les autres tranches d’âge, ce taux va de 36 % à 41 %. Une partie de cet écart est réelle, car ces jeunes clients font plus souvent défaut : 26,0 %, contre 21,2 % pour les 31 à 40 ans. Mais regardez les bons payeurs. Un client de 21 à 25 ans qui aurait payé reçoit un appel dans 42,8 % des cas. Pour un bon payeur de 31 à 40 ans, c’est 28,6 %. C’est 1,5 fois plus souvent, et l’âge n’est pas une entrée du modèle. Le modèle doit obtenir cette information ailleurs.
La variable de substitution
Section intitulée « La variable de substitution »Le tableau des limites de crédit ci-dessus donne un indice : les faibles limites vont avec des taux élevés de défaut de paiement. Arjun vérifie les limites selon l’âge, puis les taux de fausses alertes pour une même tranche de limite :
print(valid_bands.groupby("age_band", observed=True)["LIMIT_BAL"].median())low = (valid_bands["LIMIT_BAL"] <= 50_000).to_numpy()print("\nlimit up to 50,000:\n", slice_table(valid_bands[low], scores[low], "age_band")[["clients", "default rate", "false-alarm rate"]])print("\nlimit over 50,000:\n", slice_table(valid_bands[~low], scores[~low], "age_band")[["clients", "default rate", "false-alarm rate"]])Le résultat :
age_band21-25 50000.026-30 140000.031-40 180000.041-50 160000.051+ 100000.0Name: LIMIT_BAL, dtype: float64
limit up to 50,000: clients default rate false-alarm rate21-25 481.0 0.304 0.57326-30 301.0 0.302 0.51431-40 339.0 0.336 0.52041-50 301.0 0.336 0.48551+ 155.0 0.271 0.478
limit over 50,000: clients default rate false-alarm rate21-25 341.0 0.199 0.24926-30 1093.0 0.167 0.25431-40 1763.0 0.188 0.24941-50 919.0 0.200 0.22351+ 307.0 0.221 0.259- Tous les clients
- Limite de 50 000 $ NT ou moins
- Limite de plus de 50 000 $ NT
Description textuelle du graphique
Un graphique linéaire présente cinq tranches d’âge sur l’axe horizontal (21–25, 26–30, 31–40, 41–50, 51+). L’axe vertical montre le taux de fausses alertes, de 0 % à 60 %. La ligne pleine pour tous les clients commence à 43 % pour les 21–25 ans. Elle descend à 30 %, 29 % et 28 %, puis remonte à 33 % pour les 51 ans et plus. La ligne pointillée pour les limites de 50 000 $ NT ou moins est élevée et assez plate : 57 %, 51 %, 52 %, 49 %, 48 %. Celle des limites de plus de 50 000 $ NT est basse et plate : 25 %, 25 %, 25 %, 22 %, 26 %. La valeur élevée de la ligne pleine pour les 21–25 ans vient de la composition du groupe. La plupart des jeunes clients ont de faibles limites.
Ce que cela nous apprend. Il s’agit d’une variable de substitution : une colonne qui remplace une colonne exclue. La limite médiane est de 50 000 $ NT pour les clients de 21 à 25 ans. Pour les autres, elle va de 100 000 $ NT à 180 000 $ NT. Dans la tranche la plus jeune, 59 % ont une limite de 50 000 $ NT ou moins. Le modèle a appris qu’une faible limite signifie un risque. Dans une même tranche de limite, les tranches d’âge sont traitées beaucoup plus pareil : les lignes pointillées sont presque plates. La majeure partie de l’écart selon l’âge passe donc par la limite.
Et cette limite a été fixée par la banque. Si la banque a donné de faibles limites aux jeunes parce qu’ils étaient jeunes, le modèle reproduit maintenant cette ancienne décision. Il la présente comme une prédiction. Retirer une colonne des entrées n’élimine pas son effet lorsqu’une autre colonne le transmet.
Ce que l’équipe peut faire
Section intitulée « Ce que l’équipe peut faire »Il n’y a pas une seule bonne réponse. Certaines options soulèvent des questions juridiques qu’un analyste ne peut pas trancher. Arjun présente donc les options avec les résultats, pour que Mei-Ling et l’équipe de conformité décident.
- Mesurer le coût du retrait de la variable de substitution. Dans l’exercice du carnet (notebook) Jupyter, Arjun entraîne de nouveau le modèle sans
LIMIT_BAL. Le taux de fausses alertes des bons payeurs de 21 à 25 ans passe de 0,428 à 0,373. Le coût total passe de 3 355 à 3 425, soit environ 2 % de plus. L’écart diminue, mais ne disparaît pas, car l’historique des paiements varie aussi selon l’âge. Le script de calcul consigne ce résultat sous E11 (AP en validation croisée de 0,540, contre 0,542). - Rendre une fausse alerte peu coûteuse et bienveillante. L’appel offre de l’aide et suspend les hausses de limite; il ne refuse rien. Si le texte de l’appel est respectueux et que la suspension prend fin après quelques bons mois, une fausse alerte coûte peu au client. Une mesure plus sévère, comme une baisse de limite, rendrait le même écart beaucoup plus grave.
- Revoir les limites elles-mêmes. La question de fond est de savoir pourquoi les jeunes clients reçoivent de faibles limites. Elle concerne la politique de limites de la banque, pas ce modèle.
- Ne pas « corriger » le problème avec l’âge. Ajouter l’âge aux entrées, ou choisir un seuil par tranche d’âge, utilise directement l’âge dans une décision concernant une personne. Dans le script de calcul, l’ajout de l’âge déplace les fausses alertes des jeunes vers les clients de plus de 50 ans : 0,385 pour les 21–25 ans, 0,406 pour les 51 ans et plus. Cela n’élimine pas l’écart; cela le déplace. Dans de nombreux pays, les lois sur le crédit et l’égalité de traitement limitent l’utilisation de l’âge, parfois avec des exceptions.
- Surveiller les résultats. Quelle que soit la décision de la banque, présentez chaque mois le taux de fausses alertes par tranche d’âge. Indiquez les nombres de clients à côté du coût.
Arjun recommande les options 2 et 5 maintenant. Il laisse Mei-Ling décider de l’option 1, avec les chiffres devant elle. Le test ci-dessous utilise le modèle tel quel, car aucun changement n’a été convenu avant le test.
Le test final, une seule fois
Section intitulée « Le test final, une seule fois »Tous les choix sont faits : le modèle E06, le seuil de 0,17, les coûts de 5 et de 1. Arjun ouvre les données de test une seule fois, comme dans Seuils et calibration. Il compare le modèle à la règle sur les mêmes clients :
y_test = test[TARGET]test_scores = pipeline.predict_proba(test[FEATURES])[:, 1]print("ROC AUC", round(roc_auc_score(y_test, test_scores), 3), "| AP", round(average_precision_score(y_test, test_scores), 3), "| Brier", round(brier_score_loss(y_test, test_scores), 4))final = pd.DataFrame([report(y_test, test_scores >= threshold), report(y_test, test["PAY_1"] >= 1), report(y_test, np.zeros(len(test)))], index=[f"model, threshold {threshold}", "rule: late in September", "call nobody"])print(final.to_string())Le résultat :
ROC AUC 0.77 | AP 0.549 | Brier 0.1349 flagged caught false alarms missed precision recall cost cost per 1,000model, threshold 0.17 0.382 930 1361 397 0.406 0.701 3346 558rule: late in September 0.224 688 657 639 0.512 0.518 3852 642call nobody 0.000 0 0 1327 0.000 0.000 6635 1106Ce que cela nous apprend. Sur 6 000 clients qu’aucune étape n’a utilisés, le modèle coûte 3 346, contre 3 852 pour la règle. C’est 13,1 % de moins. Il repère 930 des 1 327 défauts de paiement (rappel de 0,701), contre 688 pour la règle. Il appelle 38,2 % des clients. L’aire sous la courbe ROC (0,770) et l’AP (0,549) sont légèrement inférieures aux résultats de validation (0,775 et 0,555). C’est normal, car la validation a servi aux choix. Ce sont les chiffres à présenter, et rien ne change après leur lecture.
Le sous-groupe d’âge sur les données de test raconte la même histoire. Les bons payeurs de 21 à 25 ans reçoivent un appel dans 40,7 % des cas. Pour chaque tranche plus âgée, ce taux va de 26,9 % à 28,4 %. Le script de calcul cs03_credit.py affiche le tableau complet. Ce résultat est présenté, mais ne sert pas à modifier le modèle.
Recommandations
Section intitulée « Recommandations »Arjun écrit à Mei-Ling dans ses mots à elle, pas dans ceux du modèle :
- Utilisez le modèle pour choisir les appels d’aide précoce. Appelez chaque client dont le score est de 0,17 ou plus. Pour de nouveaux clients, cela coûte environ 13 % de moins que « appeler tous ceux en retard le mois dernier ». Cela rejoint environ 7 clients sur 10 qui manqueront le prochain paiement, contre 5 sur 10 aujourd’hui.
- Prévoyez le volume. Cela signifie appeler environ 38 % des clients chaque mois, contre 22 % aujourd’hui. Si l’équipe ne peut pas faire autant d’appels, commencez par les scores les plus élevés. Le coût augmente à mesure que la liste raccourcit.
- Gardez l’appel utile. Environ 6 clients appelés sur 10 auraient payé de toute façon. Le texte de l’appel doit offrir de l’aide, pas avertir de conséquences.
- Utilisez les raisons. Chaque appel peut inclure les trois principales raisons du client, comme « deux mois de retard pendant cinq mois ».
- Décidez quoi faire de la limite de crédit. Les jeunes clients qui paient sont appelés 1,5 fois plus souvent que les clients de 31 à 40 ans. C’est parce que leurs limites sont plus faibles. Retirer la limite du modèle réduit l’écart pour un coût supplémentaire d’environ 2 %. Cette décision relève des activités de la banque et de la conformité.
Et voici à quoi ne pas l’utiliser :
- Pas pour refuser du crédit, réduire des limites ou fixer des prix. Les coûts, le seuil et la vérification de l’équité ont été établis pour un appel de soutien. Une mesure plus sévère exige une nouvelle analyse et, probablement, une nouvelle révision.
- Pas pour juger une personne. Un score de 0,7 signifie « environ 7 clients semblables sur 10 n’ont pas payé en octobre 2005 ». Il ne signifie pas « ce client n’est pas fiable ».
- Pas pour une autre banque, un autre pays ou une autre année sans d’abord le vérifier sur les données correspondantes.
Limites et prochaines étapes
Section intitulée « Limites et prochaines étapes »- Une banque, un endroit, 2005. Les données proviennent de Taiwan, durant une période d’endettement élevé par carte de crédit. Les taux et les tendances de défaut de paiement peuvent être très différents aujourd’hui ou ailleurs.
- Aucune vérification dans le temps. Tous les clients proviennent des mêmes mois. Le test ne montre donc pas ce qui se passe quand l’économie change. Les premiers mois d’utilisation sont le vrai test. Surveillez le taux de signalement, le score moyen comparé au taux de défaut, et le taux de fausses alertes par tranche d’âge. Suivez Planifier les vérifications en production.
- Codes non documentés. Les codes de statut −2 et 0 et trois codes de scolarité n’ont aucune signification officielle. Si la banque peut les documenter, vérifiez si le modèle les utilise de façon logique.
- Les coûts sont approximatifs. Les valeurs 5 et 1 sont les estimations de Mei-Ling, et le seuil varie avec elles (0,25 si un défaut manqué coûte 3). Revoyez-les avec les coûts réels de recouvrement et d’appel.
- Les défauts soudains sont invisibles. Parmi les 381 défauts manqués, 350 avaient un historique sans retard. De nouvelles données, comme les changements de revenu ou d’autres dettes, pourraient aider; ces colonnes ne le peuvent pas.
- Le modèle concurrent. Le boosting (amélioration par entraînement successif de modèles) réduisait le coût d’environ 1,7 %. Essayez-le de nouveau quand vous aurez plus de données. Faites-le seulement si l’équipe peut encore donner des raisons honnêtes à chaque client.
Faites-en un projet de portfolio
Section intitulée « Faites-en un projet de portfolio »Une telle étude de cas montre à un employeur que vous pouvez passer des données à une décision pour un problème réel. Appropriez-vous le projet avec un changement : d’autres coûts, une limite de capacité d’appels ou la même étude sur un autre jeu de données ouvert sur le crédit. Rendez-le ensuite public et facile à lire en cinq minutes.
Quoi mettre dans le dépôt :
README.md, la page que les gens lisent en premier (modèle ci-dessous).- Le carnet Jupyter, exécuté du début à la fin, avec ses résultats.
experiment_log.csv, avec chaque expérience et sa raison.MODEL_CARD.md, comme dans Documenter les limites d’utilisation : utilisation prévue (l’appel d’aide précoce), utilisations exclues (refus, baisses de limites, tarification), données, évaluation, constat sur l’âge, limites.model_info.json, produit par la dernière cellule du carnet : les variables, le seuil, les coûts et la somme de contrôle des données. Excluezdefault_model.joblib, sauf si vous indiquez aussi quelles versions permettent de le charger. Le lecteur peut le reconstruire à partir du carnet.- La source et la licence des données, avec le texte d’attribution de l’encadré ci-dessus. CC BY 4.0 permet de partager les données en attribuant leur source, mais un lien et une somme de contrôle suffisent.
Un modèle de README :
# Predicting credit-card default for early-help calls
## ProblemA card-risk team calls clients who may miss the next payment. A missed default costsabout 5 units, an unneeded call about 1. Today's rule: call everyone late last month.
## Data and licenceUCI "Default of Credit Card Clients" (Yeh, 2009), 30,000 clients, Taiwan 2005,CC BY 4.0, https://doi.org/10.24432/C55S3H. Changes: renamed PAY_0 to PAY_1,grouped undocumented education codes.
## ApproachRandom stratified split 60/20/20. Logistic regression pipeline (status codes one-hot,signed log of amounts). 10 experiments in experiment_log.csv. Threshold chosen onvalidation by expected cost. Sex, marital status and age kept out of the inputs andused for slice checks.
## Results (test set, used once)Cost 3,346 vs 3,852 for the current rule (-13%); recall 0.70 vs 0.52; ROC AUC 0.77.
## Fairness findingGood payers aged 21-25 are called 1.5x as often, through the credit limit (a proxy).Removing the limit narrows the gap for +2% cost.
## LimitsOne bank, one period, no time check; rough costs; undocumented codes.Comment le décrire en entrevue, en deux phrases : « J’ai créé un modèle de régression logistique qui choisit les clients à appeler pour offrir une aide précoce, avec un seuil fixé selon le coût de chaque erreur; sur des données de test réservées, il coûtait 13 % de moins que la règle actuelle de la banque. J’ai aussi constaté que les jeunes clients étaient appelés plus souvent à cause de leurs limites de crédit plus faibles, mesuré le coût du retrait de cette variable de substitution et documenté le tout pour une décision d’affaires. »
Allez plus loin :
- Entraînez
CalibratedClassifierCVet comparez le graphique de fiabilité. Prédisez d’abord si cela changera beaucoup les résultats ici, et dites pourquoi. - Ajoutez un intervalle par bootstrap (rééchantillonnage) pour la différence de coût entre le modèle et la règle sur les données de test.
- Essayez le boosting avec des contraintes monotones (le risque ne peut qu’augmenter avec les mois de retard). Comparez son coût et ses raisons.
- Rédigez le texte destiné aux clients pour chacun des six groupes de raisons. Testez-le avec une personne sans connaissances techniques.
Vérification des connaissances
Section intitulée « Vérification des connaissances »Vérifiez votre compréhension
Le seuil le moins coûteux sur les données de validation est 0,17, pas 0,5. Expliquez pourquoi. Dites ce qui changerait si un défaut de paiement manqué coûtait autant qu'une fausse alerte.
Afficher la réponse
Un défaut de paiement manqué coûte 5, et une fausse alerte coûte 1. Un appel est donc rentable même si la probabilité de défaut est bien inférieure à la moitié. Avec des scores calibrés, l’appel coûte moins cher lorsque la probabilité p dépasse 1 ÷ (1 + 5) = 0,167. La recherche sur les données a trouvé 0,17. Si les deux erreurs coûtaient autant, la probabilité d’équilibre serait de 1 ÷ (1 + 1) = 0,5. Le seuil monterait à environ 0,5, et l’équipe ferait beaucoup moins d’appels. Le seuil vient des coûts, pas du modèle ni de la valeur par défaut de la bibliothèque.
Votre réponse contient-elle les points principaux de la nôtre ?
Connectez-vous et inscrivez-vous au cours pour enregistrer vos réponses. Chaque vérification compte pour le certificat.
Vérifiez votre compréhension
L'âge n'est pas une entrée, mais les bons payeurs de 21 à 25 ans sont appelés 1,5 fois plus souvent que ceux de 31 à 40 ans. Comment est-ce possible, et comment l'étude de cas l'a-t-elle montré?
Afficher la réponse
Une autre entrée transmet l’information : la limite de crédit. Les jeunes clients ont des limites beaucoup plus faibles. Leur médiane est de 50 000 $ NT, contre 180 000 $ NT pour les 31–40 ans. Le modèle a appris qu’une faible limite signifie un risque. L’étude de cas l’a montré en comparant les taux de fausses alertes dans une même tranche de limite. Les tranches d’âge étaient alors proches : de 0,478 à 0,573 jusqu’à 50 000 $ NT, et de 0,223 à 0,259 au-dessus. Une colonne qui remplace une colonne retirée est une variable de substitution. Exclure une colonne sensible des entrées ne prouve pas que le modèle l’ignore. Seule une vérification par sous-groupes peut le montrer.
Votre réponse contient-elle les points principaux de la nôtre ?
Connectez-vous et inscrivez-vous au cours pour enregistrer vos réponses. Chaque vérification compte pour le certificat.
Vérifiez votre compréhension
Le boosting a une aire sous la courbe ROC supérieure (0,783 contre 0,775) et réduirait le coût de validation de 56 unités. Mei-Ling demande pourquoi l'équipe ne l'utilise pas. Que répondez-vous?
Afficher la réponse
Le gain est faible : 56 unités sur 6 000 clients, soit environ 1,7 % du coût. La validation croisée n’a montré qu’environ 0,008 d’AP supplémentaire. En revanche, l’équipe doit expliquer à chaque client pourquoi elle l’appelle. La régression logistique donne des raisons exactes par client, contrairement au boosting. La banque a aussi besoin d’un modèle qu’une personne chargée de la révision peut vérifier. Le modèle simple est donc le meilleur compromis. Le boosting reste dans le journal comme modèle concurrent. Si davantage de données augmente le gain, l’équipe pourra le réévaluer.
Votre réponse contient-elle les points principaux de la nôtre ?
Connectez-vous et inscrivez-vous au cours pour enregistrer vos réponses. Chaque vérification compte pour le certificat.
Récapitulatif
- Vous avez cadré une décision de risque de crédit avec des coûts : 5 pour un défaut manqué, 1 pour une fausse alerte. Vous avez exclu le sexe, l’état matrimonial et l’âge des entrées, et traité les codes non documentés comme des catégories.
- Le journal d’expériences a testé dix approches. La majeure partie du gain venait de la compréhension des données; le réglage et le boosting ont peu ajouté.
- Le seuil venait des coûts : 0,17, ce qui correspond à la théorie, 1 ÷ (1 + 5), car les scores sont calibrés.
- Les sous-groupes ont révélé une variable de substitution : les jeunes bons payeurs sont appelés 1,5 fois plus souvent, par l’intermédiaire de la limite de crédit. Vous avez mesuré une réponse possible (sans la limite : de 0,428 à 0,373, pour 2 % de coût supplémentaire). Vous avez laissé la décision aux responsables des activités et de la conformité.
- Le test, utilisé une seule fois : un coût inférieur de 13,1 % à celui de la règle actuelle, un rappel de 0,701.
Ensuite, une décision différente : un centre d’appels qui ne peut appeler que 10 % d’une liste, avec un arbre de décision que l’équipe peut lire.
Leçon suivante : Classer une liste d'appels