Cours Python · Module 9: Machine learning
Gradient Boosting - XGBoost, LightGBM, CatBoost
Dans cette leçon11
Dans la leçon sur scikit-learn tu as construit une forêt aléatoire : cent arbres, cent pisteurs, chacun avec son propre échantillon de spécimens et son propre jeu de mesures, et à la fin tout le monde vote. L'idée fonctionne à merveille tant que les pisteurs se trompent à des endroits différents - les erreurs se compensent alors, et la majorité tombe juste.
Mais il existe un carré de savane où la forêt entière s'effondre. Les jeunes hyènes pèsent parfois exactement le poids d'une lionne amaigrie, la circonférence de leur patte est comparable et elles empruntent le même couloir de passage. Cent pisteurs indépendants dont aucun n'a jamais parlé aux autres y commettront cent fois la même erreur. Le vote n'y changera rien, puisque la majorité se trompe ensemble. Et personne, dans cette forêt, ne dira après la battue : "regardez, vingt spécimens finissent encore dans la mauvaise colonne du registre, occupons-nous d'eux".
Ce qui te manque, c'est un mécanisme qui, après chaque pisteur, prend la liste de ses erreurs et envoie le suivant précisément sur ces erreurs. Pas une voix indépendante de plus, mais la correction de ce que le précédent n'a pas su faire. Ce mécanisme s'appelle le gradient boosting, et c'est lui qui se cache derrière la majorité des solutions gagnantes dans les compétitions sur données tabulaires - c'est-à-dire sur des données qui ressemblent à ton carnet de terrain, où chaque ligne est un animal et chaque colonne une mesure.
La file d'attente plutôt que le conseil
Retiens une phrase, car elle est à elle seule la définition de la technique : le gradient boosting combine des modèles faibles de façon séquentielle, et chacun corrige les erreurs du précédent.
Décomposons-la en trois morceaux. Un modèle faible est un modèle à peine meilleur que le hasard - en pratique un arbre de décision très peu profond, parfois littéralement une seule question et deux réponses. Une telle souche ne vaut rien toute seule, mais elle est rapide et elle a beaucoup de mal à surapprendre. Séquentiellement veut dire l'un après l'autre, à la queue leu leu, et non en parallèle : le deuxième modèle ne peut pas naître avant que le premier ait terminé son travail, car c'est seulement à ce moment-là que l'on sait ce qui a mal tourné. Enfin, chacun corrige les erreurs du précédent : le modèle suivant n'apprend pas sur les réponses d'origine, mais sur l'écart commis par son prédécesseur.
Cet écart porte un nom : le résidu (residual en anglais), c'est-à-dire la vraie valeur moins ce que le modèle a prédit. Le mot "gradient" vient de ce que le résidu est mathématiquement la direction dans laquelle l'erreur décroît le plus vite - l'arbre suivant part donc exactement là où l'effort rapporte le plus. Bonne nouvelle : pour se servir du boosting, ces mathématiques ne sont pas nécessaires. Ce qu'il faut, c'est l'intuition de la file d'attente, et cette intuition se construit le mieux de ses propres mains.
Le boosting à la main : deux souches et une liste d'erreurs
Nous allons faire du boosting manuellement, sur six lions dont l'âge et le poids sont connus, pour que tu voies le mécanisme sans aucune bibliothèque en dehors de celle que tu connais déjà. Nous utiliserons DecisionTreeRegressor de scikit-learn - l'arbre de décision en version "prédire un nombre", autrement dit le cousin de DecisionTreeClassifier rencontré dans la leçon sur scikit-learn. Le paramètre max_depth=1 le limite à une seule question, ce qui nous donne exactement la souche dont il vient d'être question. Les méthodes te sont familières : fit entraîne, predict prédit.
Le premier pisteur reçoit les données brutes : l'âge en années et le poids en kilogrammes. Tu vas voir tout de suite ce qu'il a prédit et de combien il est passé à côté de la vérité.
1import numpy as np
2from sklearn.tree import DecisionTreeRegressor
3
4age = np.array([[1], [2], [3], [4], [5], [6]])
5poids_kg = np.array([40.0, 70.0, 100.0, 150.0, 170.0, 190.0])
6
7souche_1 = DecisionTreeRegressor(max_depth=1)
8souche_1.fit(age, poids_kg)
9
10predit = souche_1.predict(age)
11residus = poids_kg - predit
12
13print(predit) # [ 70. 70. 70. 170. 170. 170.]
14print(residus) # [-30. 0. 30. -20. 0. 20.]
15print(np.abs(residus).sum()) # 100.0La seule question posée par la souche était "ce lion a-t-il moins de trois ans et demi". À tous les jeunes elle attribue 70 kg, à tous les plus âgés 170 kg - et c'est vraiment tout ce que sait faire un modèle de profondeur un. C'est la deuxième ligne qui devient intéressante. Au lion d'un an elle a ajouté 30 kg de trop ; celui de trois ans, elle l'a sous-estimé de 30 kg, et celui de quatre ans de 20 kg. La somme des erreurs en valeur absolue vaut 100 kg, et c'est notre point de référence. Remarque que le tableau poids_kg n'a été touché d'aucune façon - les résidus ont été calculés à côté, par une simple soustraction de tableaux NumPy, exactement comme tu en faisais dans le module sur le calcul numérique.
Vient maintenant le geste le plus important de toute la leçon. La deuxième souche reçoit les mêmes âges, mais à la place des poids elle reçoit la liste des erreurs de la première. Elle n'apprend pas combien pèse un lion. Elle apprend où son prédécesseur a manqué sa cible, et dans quel sens.
1souche_2 = DecisionTreeRegressor(max_depth=1)
2souche_2.fit(age, residus)
3
4correction = souche_2.predict(age)
5total = predit + correction
6
7print(correction) # [-30. 6. 6. 6. 6. 6.]
8print(total) # [ 40. 76. 76. 176. 176. 176.]
9print(np.abs(poids_kg - total).sum()) # 76.0La deuxième souche a remarqué qu'un spécimen s'écarte du lot plus que tous les autres, et elle a posé la question "est-ce le lion d'un an". À celui-là elle retire 30 kg, à tous les autres elle ajoute 6 kg. Une fois les deux prédictions additionnées, l'erreur totale est tombée de 100 kg à 76 kg - et nous n'avons pourtant ajouté qu'une seule question supplémentaire.
Et voici la phrase la plus précieuse de cette section : la première souche n'a été ni modifiée ni réentraînée. Elle prédit toujours 70 et 170, exactement comme il y a un instant. age et poids_kg n'ont pas bougé non plus - les données d'entrée sont intactes. La seule chose qui a changé, c'est qu'un deuxième modèle s'est installé à côté du premier et que le résultat est désormais la somme de leurs prédictions. La deuxième souche n'a jamais vu le vrai poids d'un seul lion. Elle n'a vu que les erreurs d'autrui. C'est exactement ce que fait XGBoost, à ceci près qu'au lieu de deux arbres il en aligne cent ou mille dans la file, et qu'avant d'ajouter chaque correction il la multiplie par un petit nombre : le learning_rate. Si notre correction était entrée avec un poids de 0,1, elle aurait été dix fois plus prudente et il aurait fallu beaucoup d'arbres de ce genre pour arriver au but. C'est précisément pour cela que les réglages par défaut de ces bibliothèques prévoient des centaines d'arbres peu profonds plutôt que trois arbres profonds : à petits pas, on dépasse moins facilement la cible.
Ce que le gradient boosting n'est pas
Le nom sonne mystérieux, alors quelques malentendus circulent autour de lui. Réglons-les maintenant, avant d'entrer dans le code.
Ce n'est pas un type de réseau de neurones. Dans le boosting il n'y a pas un seul neurone, pas une couche, pas un poids de connexion, et il n'y a pas non plus de rétropropagation. Il y a des arbres de décision, c'est-à-dire des chaînes de questions oui-non, rangées en file. Tu découvriras les réseaux de neurones dans la leçon suivante, avec PyTorch, et tu verras que c'est une construction complètement différente - et, sur données tabulaires, généralement plus faible que le boosting.
Ce n'est pas une méthode de sélection aléatoire de caractéristiques. Tirer au sort des colonnes et des échantillons, c'est l'astuce de la forêt aléatoire : là-bas, chaque arbre reçoit un morceau différent des données précisément pour que les arbres soient indépendants les uns des autres. Le boosting possède lui aussi des paramètres subsample et colsample_bytree, qui permettent de limiter au hasard les spécimens et les colonnes de chaque arbre, mais ce n'est qu'un supplément contre le surapprentissage, pas la définition de la technique. Un tirage au sort n'exploite les erreurs de personne, alors que tout le boosting repose sur le fait que l'arbre suivant voit les erreurs du précédent.
Ce n'est enfin pas un algorithme de clustering. Le clustering relève de l'apprentissage non supervisé : il n'y a aucun y, et l'algorithme regroupe lui-même les spécimens semblables sans que personne lui dise comment ils s'appellent. Le boosting est de l'apprentissage supervisé à l'état pur - sans colonne de bonnes réponses, impossible de calculer le moindre résidu, donc rien à corriger. Tu te souviens, depuis la leçon sur l'apprentissage supervisé, que tout commence par des couples mesure-réponse ? Ici, cette règle vaut double.
Trois bibliothèques, une seule idée
La même idée a été réalisée indépendamment par trois équipes, et il en est sorti les trois bibliothèques dont parle cette leçon. XGBoost est la plus ancienne et la plus reconnaissable, le choix par défaut pour démarrer. LightGBM, de Microsoft, a été écrite avec la vitesse sur les grands jeux de données comme obsession. CatBoost, de Yandex, se spécialise dans les colonnes textuelles, c'est-à-dire catégorielles. Toutes les trois parlent avec les verbes de scikit-learn que tu connais - fit, predict, score - si bien qu'une fois la première maîtrisée, tu reconnaîtras les deux autres au vol.
Aucune ne fait partie de scikit-learn, il faut donc les installer en plus. Une seule commande dans le terminal règle le sort des trois d'un coup.
1pip install xgboost lightgbm catboostL'installation télécharge des paquets déjà compilés : elle prend un moment et ne réclame pas de compilateur. Fais attention au fait que les noms des paquets à installer ne coïncident pas toujours avec les noms à importer : le paquet lightgbm s'importe sous l'alias lgb, et du paquet catboost on fait venir la classe CatBoostClassifier. C'est une source d'erreurs fréquente au premier lancement. Si, sur macOS, tu vois un message signalant la bibliothèque libomp manquante, installe-la avec brew install libomp - XGBoost s'en sert pour calculer sur plusieurs cœurs.
Le carnet de terrain sur lequel nous allons travailler
Il nous faut des données. Nous allons les fabriquer nous-mêmes, à l'aide du générateur de nombres aléatoires de NumPy que tu connais depuis le module sur le calcul numérique : np.random.default_rng(42) crée un générateur avec une graine fixée, si bien que chaque exécution produit les mêmes nombres. La méthode normal tire des mesures autour d'une moyenne donnée avec une dispersion donnée - comme dans la nature, où deux lions ne pèsent jamais exactement pareil. Nous rangeons les résultats dans un DataFrame de Pandas, car des colonnes nommées rendront les graphiques d'importance lisibles. La fonction pd.concat colle les tableaux les uns sous les autres, et np.repeat fabrique la colonne des étiquettes : deux cents zéros, deux cents uns et deux cents deux.
Une colonne est glissée là par malice. heure_observation est l'heure à laquelle le pisteur a pris la photo - tirée au hasard pour chaque espèce, elle ne porte donc aucune information sur qui figure sur le cliché. Je la laisse exprès, car nous allons vérifier dans un instant si le modèle s'en aperçoit.
1import numpy as np
2import pandas as pd
3
4rng = np.random.default_rng(42)
5
6def troupeau(n, poids, vitesse, queue, morsure):
7 return pd.DataFrame({
8 "poids": rng.normal(poids, poids * 0.30, n),
9 "vitesse": rng.normal(vitesse, vitesse * 0.25, n),
10 "longueur_queue": rng.normal(queue, queue * 0.30, n),
11 "force_morsure": rng.normal(morsure, morsure * 0.35, n),
12 "heure_observation": rng.uniform(5, 19, n),
13 })
14
15X = pd.concat([
16 troupeau(200, 190, 58, 85, 650), # lion
17 troupeau(200, 55, 110, 75, 475), # guepard
18 troupeau(200, 60, 65, 30, 1100), # hyene
19], ignore_index=True)
20
21y = np.repeat([0, 1, 2], 200) # 0 = lion, 1 = guepard, 2 = hyene
22
23print(X.shape, y.shape) # (600, 5) (600,)Nous voilà avec six cents spécimens décrits par cinq nombres et six cents étiquettes - les formes sont confirmées par shape. Les espèces se chevauchent volontairement : la hyène pèse à peine plus que le guépard, et sa force de morsure atteint parfois celle du lion. Aucune mesure prise isolément ne les sépare, et c'est justement ce qui en fait un bon terrain pour le boosting. Remarque que X est un DataFrame Pandas alors que y est un simple tableau NumPy - les trois bibliothèques acceptent ce mélange sans conversion, puisque sous le capot elles ne lisent de toute façon que des valeurs numériques.
XGBoost - quatre étapes, toujours dans le même ordre
Travailler avec XGBoost tient en quatre étapes, et leur ordre n'est pas une affaire de goût.
Première étape : l'import. Il n'y a rien avec quoi construire un modèle tant que la bibliothèque n'est pas chargée. Deuxième étape : les hyperparamètres, c'est-à-dire les nombres qui décrivent l'allure du modèle - tu les inscris dans le constructeur au moment de créer l'objet, il faut donc les connaître avant. Troisième étape : l'entraînement par la méthode fit, car c'est seulement maintenant que le modèle regarde les données. Quatrième étape : l'importance des caractéristiques, qui ne peut se lire que sur un modèle déjà entraîné - avant fit il n'y a rien à demander, puisqu'aucun arbre n'existe encore.
Commençons par la première étape. L'import comporte quatre morceaux : le mot-clé import, le nom du paquet xgboost, le mot as et l'alias xgb dont nous nous servirons ensuite. Cet alias est la convention adoptée dans toute la documentation, alors tiens-t'y. Nous en profitons pour séparer les données en une partie d'entraînement et une partie de test avec la fonction train_test_split découverte lors de l'apprentissage supervisé : test_size=0.2 met un spécimen sur cinq de côté pour l'examen, random_state=42 rend le découpage reproductible, et stratify=y veille à ce que les proportions d'espèces soient identiques dans les deux parties.
1import xgboost as xgb
2from sklearn.model_selection import train_test_split
3
4X_train, X_test, y_train, y_test = train_test_split(
5 X, y, test_size=0.2, random_state=42, stratify=y
6)
7
8print(X_train.shape, X_test.shape) # (480, 5) (120, 5)Quatre cent quatre-vingts animaux partent à l'entraînement, cent vingt restent sous clé pour l'examen. Note que train_test_split renvoie quatre objets dans un ordre imposé : d'abord les deux parties des caractéristiques, ensuite les deux parties des étiquettes. Intervertir cet ordre est l'erreur classique après laquelle le modèle apprend sur les étiquettes au lieu des mesures. Le DataFrame X lui-même n'a pas été altéré - la fonction a renvoyé de nouveaux objets, et l'original reste intact à sa place.
La deuxième étape, ce sont les hyperparamètres. La classe XGBClassifier en accepte plusieurs dizaines, mais trois décident de presque tout. n_estimators est la longueur de la file, c'est-à-dire le nombre d'arbres qui se succéderont. max_depth est la profondeur d'un arbre isolé, c'est-à-dire le nombre de questions qu'il a le droit d'enchaîner ; plus elle est grande, plus l'arbre est puissant tout seul, mais plus le surapprentissage guette. learning_rate est le poids avec lequel chaque correction entre dans la somme - ce nombre-là même dont nous parlions avec les souches. S'y ajoutent deux paramètres de prudence : subsample=0.8 donne à chaque arbre 80 pour cent des spécimens tirés au sort, et colsample_bytree=0.8 en fait autant avec les colonnes. Enfin random_state=42, car sans graine fixée ce tirage ferait changer le résultat à chaque exécution.
1model = xgb.XGBClassifier(
2 n_estimators=100, # longueur de la file d'arbres
3 max_depth=6, # combien de questions pour un seul arbre
4 learning_rate=0.1, # poids de chaque correction
5 subsample=0.8, # part des specimens par arbre
6 colsample_bytree=0.8, # part des colonnes par arbre
7 random_state=42,
8)
9
10print(model.n_estimators, model.max_depth) # 100 6Le modèle existe, mais il n'a encore vu aucun animal - le constructeur n'a enregistré que des réglages. Tu peux les relire comme de simples attributs de l'objet, ce que nous venons de faire, et c'est là tout le contenu du modèle à ce stade. Si tu appelais predict maintenant, tu obtiendrais une exception, car il n'existe pas encore le moindre arbre.
Troisième étape, la plus courte : l'entraînement. fit construit toute la file d'arbres, l'un après l'autre, chacun sur les résidus des précédents. Ensuite score calcule le taux de bonnes réponses sur le jeu de test - c'est la même métrique d'exactitude (accuracy) que tu as rencontrée avec l'apprentissage supervisé. predict renvoie les numéros d'espèce pour de nouveaux spécimens.
1model.fit(X_train, y_train)
2
3print(f"Exactitude : {model.score(X_test, y_test):.2%}")
4print(model.predict(X_test)[:8])L'exactitude se situera aux alentours de 97 pour cent - je ne donne pas le chiffre exact, car il dépend de la version de la bibliothèque : lance le code et vois par toi-même. Plus important encore est ce que fit n'a pas fait. Il n'a pas renvoyé un nouveau modèle à ranger dans une variable, il a modifié l'objet model sur place, exactement comme dans scikit-learn. Il n'a pas non plus touché à X_train ni à y_train - cent arbres ont emménagé à l'intérieur du modèle, et tes données sont là où elles étaient. Une dernière chose : la file est construite une fois pour toutes. Rappeler fit n'ajoute pas d'arbres aux existants, il reconstruit tout depuis le début.
Quand arrêter d'ajouter des arbres
Ces cent arbres, nous les avons écrits au doigt mouillé, et personne ne garantit que c'est le bon nombre. Avec une file trop courte, le modèle n'a pas le temps d'apprendre ; avec une file trop longue, il se met à apprendre le bruit des données d'entraînement. La solution s'appelle early stopping : tu prévois une file largement surdimensionnée, et XGBoost vérifie après chaque arbre le résultat obtenu sur un jeu de validation, puis interrompt la construction quand rien ne s'améliore pendant un nombre de tours donné. Ce nombre, c'est le paramètre early_stopping_rounds - depuis XGBoost 2.0 il s'écrit dans le constructeur et non dans fit, ce qui explique la quantité d'exemples périmés que l'on trouve sur le web.
Il faut donc un jeu sur lequel le modèle se surveille, et attention au piège : ce ne peut pas être le jeu de test. Si XGBoost choisissait le nombre d'arbres en regardant l'examen, cet examen cesserait d'être honnête - le modèle se réglerait sur les réponses qu'il est censé deviner ensuite. Nous découpons donc un morceau des données d'entraînement pour en faire un jeu de validation séparé. Ce jeu de validation se transmet à fit par le paramètre eval_set, sous la forme d'une liste de couples. Le paramètre verbose=False fait taire le rapport arbre par arbre, et best_iteration indique après coup à quel arbre le modèle s'est arrêté.
1X_fit, X_val, y_fit, y_val = train_test_split(
2 X_train, y_train, test_size=0.2, random_state=42, stratify=y_train
3)
4
5model_es = xgb.XGBClassifier(
6 n_estimators=500,
7 max_depth=6,
8 learning_rate=0.1,
9 early_stopping_rounds=10,
10 random_state=42,
11)
12model_es.fit(X_fit, y_fit, eval_set=[(X_val, y_val)], verbose=False)
13
14print("Meilleur arbre :", model_es.best_iteration)
15print(f"Exactitude : {model_es.score(X_test, y_test):.2%}")Nous avions commandé cinq cents arbres, et le modèle s'est arrêté bien avant, sans rien perdre en exactitude - le numéro exact dépend de la version, je te renvoie donc là encore à ta propre exécution. Fais attention à une chose qui n'a pas changé : le jeu de test n'a participé à rien. Ni fit ni le mécanisme d'arrêt ne l'ont vu, et le résultat de score reste donc un examen honnête. Remarque aussi que X_fit est né d'un découpage de X_train, et non d'un découpage des données entières - l'examen avait été mis de côté plus tôt et il demeure intouché.
Quelles mesures travaillent vraiment
Quatrième étape du rituel. Le modèle est entraîné, on peut donc enfin lui demander sur quoi il a fondé ses décisions. L'attribut feature_importances_ - avec un tiret bas final, comme tout ce que scikit-learn calcule pendant l'entraînement - renvoie un nombre par colonne, et l'ensemble s'additionne pour donner un. Plus le nombre est élevé, plus la mesure a servi souvent et efficacement à séparer les espèces dans les arbres. L'ordre des nombres suit l'ordre des colonnes de X_train, nous les apparierons donc avec la fonction zip que tu connais depuis les bases de Python.
1for nom, importance in zip(X_train.columns, model.feature_importances_):
2 print(f"{nom:>20} : {importance:.3f}")Tout en bas de la liste atterrit heure_observation avec une valeur proche de zéro - exactement la colonne que nous avions glissée par malice. Le modèle a découvert tout seul que l'heure du cliché ne dit rien de l'espèce et il ne s'en est pratiquement pas servi. Voilà la valeur pratique de cette étape : tu obtiens une indication sur les mesures qu'il ne vaut même pas la peine de collecter sur le terrain. Attention toutefois à l'interprétation - une importance élevée dit que le modèle s'est servi de la colonne, pas que cette colonne est la cause de quoi que ce soit. Si un numéro de cage attribué dans l'ordre croissant par espèce s'était glissé dans le carnet, il trônerait en tête de cette liste, et cela ne signifierait rien d'autre que le fait que le pisteur cataloguait espèce après espèce.
Une liste de nombres se compare plus facilement à l'œil qu'à la lecture, et c'est pourquoi XGBoost possède sa propre fonction de tracé. L'appel comporte six morceaux : l'alias de la bibliothèque xgb, un point, le nom de la fonction plot_importance, une parenthèse ouvrante, le model entraîné et une parenthèse fermante. Rien de plus n'est nécessaire - la fonction ira chercher elle-même les noms de colonnes dans le modèle et triera les barres. Le graphique s'affiche avec la commande plt.show vue dans le module sur la visualisation.
1import matplotlib.pyplot as plt
2
3xgb.plot_importance(model)
4plt.show()Tu obtiendras un diagramme en barres horizontales avec la caractéristique la plus importante en haut. Si tu as plusieurs dizaines de caractéristiques, ajoute l'argument max_num_features pour couper le graphique aux quinze meilleures - mais avec cinq colonnes, cela n'a aucun intérêt. Petite surprise pour finir : par défaut, plot_importance mesure l'importance autrement que feature_importances_, car elle compte combien de fois la colonne est apparue dans les questions des arbres. L'ordre peut donc différer légèrement de celui de l'affichage précédent, et ce n'est pas un bug : c'est une autre mesure du même phénomène. Notre colonne malicieuse illustre bien la nuance - elle apparaît malgré tout dans un bon nombre de questions, mais chacune de ces questions ne fait presque rien reculer l'erreur.
LightGBM - la même idée, une machine plus rapide
XGBoost se débrouille très bien tant que le carnet tient en mémoire. Quand l'expédition grossit jusqu'à des millions de lignes - et c'est à quoi ressemble le travail avec des relevés de capteurs ou les clichés de centaines de pièges photo - le temps d'entraînement commence à peser, d'autant plus que le réglage t'oblige à entraîner le modèle des dizaines de fois. C'est alors qu'on se tourne vers LightGBM, qui est généralement plus rapide qu'XGBoost sur les très grands jeux de données.
D'où vient cet avantage ? De deux décisions de conception. D'abord, LightGBM convertit les mesures continues en compartiments d'histogramme : au lieu d'envisager chaque valeur de seuil possible, elle en envisage quelques centaines, et elle le fait une seule fois plutôt qu'à chaque arbre. Ensuite, elle fait croître l'arbre feuille par feuille : au lieu de compléter sagement tout un niveau, elle choisit la feuille qui réduira le plus l'erreur et ne développe que celle-là. À budget de calcul égal, elle fait donc tomber l'erreur plus vite.
Deux choses méritent d'être dites franchement, car la conclusion fausse est vite tirée. Il est faux que les deux bibliothèques soient aussi rapides l'une que l'autre - sur de grands jeux de données l'écart se compte en multiples et il se mesure sans difficulté. Il est tout aussi faux que l'une d'elles ne prendrait pas en charge les grandes données : toutes deux ont été créées précisément pour les grandes données et toutes deux savent travailler sur des millions de lignes. Le fait est simplement que, sur les très grands volumes, LightGBM arrive au but plus tôt. N'essaie pas de mesurer cela sur nos six cents animaux - à cette taille, les coûts de démarrage dominent et le chronomètre te désignera un vainqueur au hasard. L'avantage de LightGBM se révèle à partir de centaines de milliers, voire de millions de lignes.
La classe s'appelle LGBMClassifier et elle accepte les mêmes trois hyperparamètres de base qu'XGBoost. Deux réglages qui lui sont propres s'y ajoutent, conséquence de la croissance feuille par feuille. num_leaves limite le nombre de feuilles d'un arbre et c'est lui, et non max_depth, qui constitue ici la molette principale de la complexité. min_child_samples indique combien de spécimens doivent au minimum atterrir dans une feuille pour qu'on ait le droit de la créer - une protection contre les feuilles bâties sur deux animaux de passage. J'ajoute encore verbose=-1 pour que la bibliothèque ne noie pas la console sous les messages d'entraînement.
1import lightgbm as lgb
2
3model_lgb = lgb.LGBMClassifier(
4 n_estimators=100,
5 max_depth=6,
6 learning_rate=0.1,
7 num_leaves=31, # nombre maximal de feuilles dans un arbre
8 min_child_samples=20, # minimum de specimens pour creer une feuille
9 random_state=42,
10 verbose=-1,
11)
12model_lgb.fit(X_train, y_train)
13
14print(f"LightGBM : {model_lgb.score(X_test, y_test):.2%}")Le résultat sera très proche de celui d'XGBoost, et c'est bien ainsi - il s'agit du même algorithme, implémenté autrement. Regarde le code une seconde fois et vois ce qui n'a pas changé : fit a reçu exactement les mêmes X_train et y_train, score a calculé exactement la même métrique, et les noms n_estimators, max_depth et learning_rate sonnent à l'identique. Un import a changé, ainsi qu'un nom de classe. C'est cela, la langue commune dont il était question avec scikit-learn : passer d'une bibliothèque de boosting à l'autre coûte deux lignes. LightGBM possède d'ailleurs son propre plot_importance dans le module lgb, qui fonctionne comme la version d'XGBoost.
CatBoost - quand le carnet contient des mots
XGBoost comme LightGBM ne lisent que des nombres. Or un vrai carnet de terrain est plein de mots : l'habitat vaut "savane", "brousse" ou "riviere", le régime vaut "viande" ou "charogne". Jusqu'ici tu t'en sortais par l'encodage - OneHotEncoder transformait chaque valeur en une colonne séparée de zéros et de uns. Avec trois habitats, rien de terrible ; mais avec une colonne contenant cinq cents espèces de plantes, tu récoltes cinq cents colonnes nouvelles et le modèle commence à s'y perdre.
CatBoost résout ce problème autrement : il accepte les colonnes textuelles telles quelles et les convertit lui-même en nombres, en calculant pour chaque catégorie une statistique tirée des étiquettes - et il le fait d'une manière qui l'empêche de lorgner les réponses. C'est pour cette raison qu'il est le choix par défaut lorsque les catégories dominent les données.
Ajoutons donc deux colonnes textuelles à notre carnet. Dans soixante-dix pour cent des cas, l'habitat correspondra à l'espèce, et dans les autres il tombera au hasard - un carnet de ce genre est plus proche de la réalité qu'un carnet parfait. Le régime alimentaire, lui, isolera les hyènes, puisque ce sont elles qui se nourrissent de charognes. Pour choisir entre deux tableaux, on emploie np.where, que tu connais depuis le module sur NumPy : il prend une condition et deux valeurs, et retient l'une des deux pour chaque élément.
1rng_cat = np.random.default_rng(7)
2terrain = np.array(["savane", "brousse", "riviere"])
3
4coherent = rng_cat.random(len(y)) < 0.7
5aleatoire = rng_cat.integers(0, 3, len(y))
6
7X_cat = X.copy()
8X_cat["habitat"] = terrain[np.where(coherent, y, aleatoire)]
9X_cat["alimentation"] = np.where(y == 2, "charogne", "viande")
10
11print(X_cat[["habitat", "alimentation"]].head(3))Le tableau compte désormais sept colonnes : cinq numériques et deux textuelles. En comptant les coups de chance du tirage aléatoire, l'habitat colle finalement à l'espèce dans environ quatre lignes sur cinq. Mais l'essentiel est ailleurs, dans ce qui n'a pas changé : X.copy() a fabriqué une copie, si bien que le X d'origine possède toujours cinq colonnes et que les modèles précédents ne savent rien de cet ajout. Si nous avions omis copy, nous aurions écrit ces colonnes dans le tableau même sur lequel XGBoost a été entraîné, et le code précédent se casserait à la prochaine exécution.
Passons au modèle. La classe CatBoostClassifier utilise ses propres noms pour deux notions que tu connais déjà : iterations est la longueur de la file d'arbres, l'équivalent de n_estimators, et depth est la profondeur de l'arbre, l'équivalent de max_depth. La clé de tout, c'est cat_features : la liste des noms de colonnes à traiter comme catégorielles. Et voici l'erreur la plus fréquente des débutants - donner des noms de colonnes ne fonctionne que si tu entraînes sur un DataFrame Pandas contenant ces colonnes. Si tu passes des noms tout en fournissant un tableau NumPy, CatBoost s'arrêtera sur une erreur en expliquant que des données numériques ne peuvent pas être considérées comme catégorielles. C'est pourquoi nous découpons X_cat et non X. Le paramètre verbose=False fait taire le rapport de chaque itération, et random_seed joue le rôle du random_state des autres bibliothèques.
1from catboost import CatBoostClassifier
2
3Xc_train, Xc_test, yc_train, yc_test = train_test_split(
4 X_cat, y, test_size=0.2, random_state=42, stratify=y
5)
6
7model_cat = CatBoostClassifier(
8 iterations=100,
9 depth=6,
10 learning_rate=0.1,
11 cat_features=["habitat", "alimentation"],
12 verbose=False,
13 random_seed=42,
14)
15model_cat.fit(Xc_train, yc_train)
16
17print(f"CatBoost : {model_cat.score(Xc_test, yc_test):.2%}")Le modèle s'est entraîné sur des colonnes textuelles sans une seule ligne d'encodage de notre part - et c'est toute la promesse de CatBoost. Note que le découpage a été fait avec le même random_state=42 et le même stratify, si bien que ce sont exactement les mêmes animaux qu'auparavant qui ont atterri dans le jeu de test. La comparaison avec XGBoost garde donc un sens : la différence vient du modèle et des colonnes ajoutées, pas d'un autre tirage.
L'importance des caractéristiques, CatBoost l'expose par une méthode et non par un attribut - elle s'appelle get_feature_importance et renvoie un tableau de nombres dans l'ordre des colonnes. L'échelle diffère de celle d'XGBoost, car les valeurs s'additionnent pour donner cent : tu les lis directement comme des pourcentages.
1for nom, importance in zip(Xc_train.columns, model_cat.get_feature_importance()):
2 print(f"{nom:>20} : {importance:.2f}")Sur cette liste, tu verras côte à côte des colonnes numériques et textuelles, évaluées dans la même monnaie - habitat et alimentation reçoivent leur place au classement comme n'importe quelle autre mesure. L'alimentation atterrit tout en haut, parce qu'elle désigne les hyènes sans se tromper, et l'habitat très bas, parce qu'il se trompe environ une fois sur cinq. Tires-en une conclusion plus générale que CatBoost lui-même : une seule colonne catégorielle bien choisie peut valoir davantage que quatre mesures prises au mètre ruban. Fais encore attention aux parenthèses de get_feature_importance() : c'est une méthode, il faut donc l'appeler, à la différence de feature_importances_ d'XGBoost, qui est un simple attribut et n'accepte aucune parenthèse.
Le réglage des hyperparamètres - qui va cliquer tout cela
Revenons aux nombres que nous écrivions au doigt mouillé. Pourquoi max_depth devrait valoir six et non trois ? Pourquoi learning_rate vaut-il 0,1 et non 0,3 ? La réponse honnête est : on n'en sait rien. Ces valeurs dépendent des données, et la seule façon fiable de les choisir est de les essayer. À la main, cela signifie des dizaines de lancements, des résultats notés sur un bout de papier et une erreur de recopie à la moitié.
C'est à cela que sert GridSearchCV, du module sklearn.model_selection. Sa seule et unique mission est le réglage automatique des hyperparamètres du modèle : tu fournis une grille de valeurs à essayer, elle entraîne le modèle sur chaque combinaison et te rend la meilleure. Disons tout de suite ce que GridSearchCV n'est pas, car son nom ne le laisse deviner à personne. Elle ne sert pas à la visualisation des données - les graphiques, c'est l'affaire de matplotlib et de seaborn dans le module sur la visualisation, et GridSearchCV ne dessine rien du tout. Elle ne sert pas au nettoyage des données - les valeurs manquantes et les doublons, tu les as éliminés avec Pandas bien avant l'entraînement, et ici les données doivent déjà être propres. Et elle ne crée surtout pas de réseaux de neurones - les réseaux forment une tout autre famille de modèles, que tu découvriras avec PyTorch ; GridSearchCV pourra tout au plus leur choisir des hyperparamètres, mais elle ne construira jamais le moindre réseau.
Voici comment elle fonctionne : param_grid est un dictionnaire dont la clé est le nom d'un hyperparamètre et la valeur la liste des candidats. cv=3 est le nombre de plis de la validation croisée rencontrée lors de l'apprentissage supervisé - chaque combinaison est évaluée trois fois, sur des découpages différents, pour que le résultat ne soit pas une loterie. scoring="accuracy" dit avec quelle métrique on mesure la qualité, et n_jobs=-1 autorise l'usage de tous les cœurs du processeur. Après l'entraînement, best_params_ rend le jeu gagnant, best_score_ sa note moyenne sur les plis, et best_estimator_ un modèle prêt à l'emploi, déjà entraîné.
1from sklearn.model_selection import GridSearchCV
2
3param_grid = {
4 "max_depth": [3, 6],
5 "learning_rate": [0.05, 0.1, 0.3],
6 "n_estimators": [50, 100],
7}
8
9grid = GridSearchCV(
10 xgb.XGBClassifier(random_state=42),
11 param_grid,
12 cv=3,
13 scoring="accuracy",
14 n_jobs=-1,
15)
16grid.fit(X_train, y_train)
17
18print(len(grid.cv_results_["params"])) # 12
19print(grid.best_params_)
20print(f"Meilleur score : {grid.best_score_:.2%}")
21
22best_model = grid.best_estimator_Douze combinaisons, car deux fois trois fois deux - et c'est un nombre qu'il vaut la peine de calculer de tête avant de lancer quoi que ce soit. Avec trois plis, cela signifie trente-six entraînements. Si tu ajoutais un quatrième paramètre à quatre valeurs et que tu montais cv à cinq, tu en serais déjà à deux cent quarante, car la grille croît par multiplication et non par addition. Remarque encore une chose : nous passons à GridSearchCV un modèle avant l'entraînement et nous n'appelons nulle part fit sur lui nous-mêmes. L'objet grid entraîne toutes les copies tout seul, et le vainqueur prêt à servir attend dans best_estimator_ sans nécessiter le moindre réentraînement.
Puisque la grille grossit si vite, il existe une alternative : au lieu d'essayer toutes les combinaisons, en tirer un nombre fixé au hasard. C'est le travail de RandomizedSearchCV, du même module, qui accepte des lois de probabilité de scipy.stats au lieu de listes de valeurs. randint(3, 10) tire un entier entre 3 et 9 inclus, tandis que uniform a des paramètres surprenants : le premier est le début de l'intervalle, mais le second est sa largeur et non sa fin. uniform(0.01, 0.29) tire donc dans l'intervalle allant de 0,01 à 0,30 - c'est l'une des confusions les plus fréquentes en matière de réglage. Le paramètre n_iter dit combien de combinaisons tirer, et c'est lui, et non la taille de la grille, qui décide du temps de calcul.
1from sklearn.model_selection import RandomizedSearchCV
2from scipy.stats import randint, uniform
3
4param_dist = {
5 "max_depth": randint(3, 10),
6 "learning_rate": uniform(0.01, 0.29),
7 "n_estimators": randint(50, 300),
8}
9
10random_search = RandomizedSearchCV(
11 xgb.XGBClassifier(random_state=42),
12 param_dist,
13 n_iter=20,
14 cv=3,
15 random_state=42,
16 n_jobs=-1,
17)
18random_search.fit(X_train, y_train)
19
20print(random_search.best_params_)Vingt tirages au lieu d'un balayage complet, et le résultat s'écarte rarement du meilleur de la grille. Je ne donne pas les valeurs trouvées, car elles dépendent du tirage et de la version de la bibliothèque - et c'est ici une leçon plus importante que les chiffres eux-mêmes : dans une recherche aléatoire, random_state cesse d'être une coquetterie et devient la condition de reproductibilité de l'expérience.
Ma recommandation est sans ambiguïté : commence par RandomizedSearchCV avec de larges intervalles, et ne sers-toi de GridSearchCV qu'à la fin, pour peaufiner un intervalle étroit autour du vainqueur. La raison est simple - la plupart des hyperparamètres ne changent pas grand-chose, si bien qu'un balayage complet gaspille une énorme partie du temps sur des combinaisons perdues d'avance. Une recherche aléatoire en vingt essais tombe généralement plus près de l'optimum qu'une grille de coût équivalent, parce qu'elle essaie davantage de valeurs différentes pour chaque paramètre.
Quand cela ne suffira plus, tu te tourneras vers la bibliothèque Optuna. Elle se distingue par le fait qu'elle ne tire pas à l'aveugle : elle retient les résultats des essais précédents et choisit les combinaisons suivantes là où elle espère une amélioration. Tu y définis une fonction objectif qui, pour un jeu de paramètres donné, renvoie la note du modèle, et Optuna l'appelle des centaines de fois en resserrant elle-même la recherche. C'est un outil pour plus tard - maîtrise d'abord les deux précédents, car dans neuf cas sur dix ils suffiront.
Retiens une chose de cette leçon : la forêt aléatoire est un conseil de pisteurs qui ne s'écoutent pas les uns les autres, tandis que le boosting est une caravane où chacun suit la piste des erreurs de celui qui le précède - et c'est pour cela qu'elle va plus loin, alors que chacun de ses membres voit moins.
Tu as repéré une erreur dans cette leçon ?
Teste tes connaissances
Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.
1. Qu'est-ce que le Gradient Boosting ?
2. Quelle bibliothèque est généralement plus rapide sur de très grands jeux de données ?
Voici 2 des 3 questions de cette leçon. Réponds aux autres dans le jeu.
Tâches pratiques dans le jeu
- Éditeur de code
Crée un XGBClassifier avec les paramètres : n_estimators=100, max_depth=6, learning_rate=0.1.
- Tri vertical
Range les étapes dans le bon ordre :
- Tri horizontal
Range l'import de XGBoost :
- Éditeur de code
Crée un LGBMClassifier avec n_estimators=100 et max_depth=6, entraîne-le et évalue-le.
- Clique dans l'ordre
Clique sur les boutons pour afficher l'importance des caractéristiques :