Cours Python · Module 8: Data science
Le feature engineering - transformer un carnet en données que le modèle comprend
Dans cette leçon20
Ton carnet est plein. Pendant une demi-saison tu y as inscrit les comptages faits aux points d'eau, puis tu as bouché les trous, tracé des graphiques et vérifié par des tests lesquels des écarts étaient réels. Hier, un message radio est arrivé de la centrale : construis un modèle capable de prédire si une espèce est menacée. Tu passes le carnet tel quel, tu appuies sur entrée, et tu reçois une seule phrase :
could not convert string to float: 'Lion'
Le modèle ne sait pas lire le mot "Lion". Il ne sait pas lire "Savanna", il ne sait lire ni une date ni la phrase qui décrit une piste. Le modèle ne voit que des nombres rangés en tableau, et rien d'autre. Et le jour où tu ne lui donnes que des nombres, un second problème apparaît : la colonne des effectifs va de 85 à 5000, si bien qu'un seul point d'eau traversé par un troupeau en migration écrase toutes les autres mesures, exactement comme il écrasait la moyenne dans la leçon précédente.
Ces deux problèmes se règlent par le même travail, et c'est ce travail qui occupe la journée d'aujourd'hui.
La variable, c'est ce que le modèle reçoit en entrée
Une variable (en anglais feature, parfois traduite par "caractéristique") est une colonne de valeurs que tu donnes au modèle. L'effectif d'un troupeau est une variable. La superficie d'un territoire est une variable. L'espèce - pas encore, parce que c'est du texte, mais dans un instant nous en tirerons trois variables numériques.
Le feature engineering consiste à créer de nouvelles variables à partir de données brutes pour les modèles d'apprentissage automatique. Tu prends ce que tu as ramené du terrain et tu le transformes en colonnes que le modèle sait lire et dont il peut apprendre quelque chose. Parfois tu reportes une ancienne colonne sur une autre échelle, parfois tu éclates une colonne en plusieurs, et parfois - c'est la partie la plus amusante - tu inventes une colonne qui n'a jamais figuré dans le carnet.
Retiens cette définition mot pour mot, car on la confond facilement avec trois voisines. Le feature engineering ne consiste pas à supprimer toutes les variables des données : ce serait absurde, puisqu'une fois toutes les colonnes supprimées il ne reste plus rien dont le modèle puisse apprendre. Il existe un domaine cousin, la sélection de variables, qui écarte les colonnes inutiles, mais il en écarte certaines, jamais toutes, et c'est une étape distincte. Le feature engineering n'est pas non plus l'entraînement du modèle : l'entraînement, c'est l'étape d'après, celle de l'appel à fit, et elle commence quand les variables sont déjà prêtes. Enfin, le feature engineering n'est pas une méthode de visualisation des données : pour dessiner, tu as Matplotlib et Seaborn de la leçon sur les graphiques, alors qu'ici il ne sortira pas une seule image, seulement de nouvelles colonnes de nombres.
Le carnet sur lequel on travaille
On part des notes brutes. Deux bibliothèques déjà connues suffisent : pandas, abrégée en pd, qui tient les tableaux, et NumPy, abrégée en np, qui calcule sur des tableaux de nombres. Le tableau de données, je le construis avec la fonction pd.DataFrame à partir d'un dictionnaire où la clé est le nom de la colonne et la valeur la liste des mesures. Je l'appelle sobrement df, parce que c'est ce nom-là que tu retrouveras dans les exercices pratiques. Le carnet contient cinq observations et six colonnes : l'espèce, l'habitat, l'effectif, la superficie du territoire en kilomètres carrés, le niveau de danger relevé sur le terrain, et le statut de protection écrit en toutes lettres, "Yes" ou "No".
1import pandas as pd
2import numpy as np
3
4df = pd.DataFrame({
5 'species': ['Lion', 'Elephant', 'Cheetah', 'Lion', 'Elephant'],
6 'habitat': ['Savanna', 'Forest', 'Savanna', 'Forest', 'Savanna'],
7 'population': [120, 450, 85, 200, 5000],
8 'area_km2': [100, 500, 50, 200, 10000],
9 'danger': ['High', 'Low', 'High', 'Medium', 'Low'],
10 'endangered': ['Yes', 'No', 'Yes', 'No', 'No']
11})
12
13print(df)
14# species habitat population area_km2 danger endangered
15# 0 Lion Savanna 120 100 High Yes
16# 1 Elephant Forest 450 500 Low No
17# 2 Cheetah Savanna 85 50 High Yes
18# 3 Lion Forest 200 200 Medium No
19# 4 Elephant Savanna 5000 10000 Low NoCinq lignes, six colonnes, et pas une variable de plus. Quatre de ces colonnes sont du texte pur : pour le modèle, elles n'existent pas. Les deux autres sont bien des nombres, mais sur une échelle qui fait plus de mal que de bien. Tout le reste de la leçon consiste à tirer de ces six colonnes une quinzaine de colonnes que le modèle acceptera vraiment.
L'original reste intact
Avant d'ajouter quoi que ce soit, une règle de camp : on ne rature jamais le carnet d'origine. Si une nouvelle variable se révèle absurde, tu veux pouvoir revenir aux notes de terrain sans recharger le fichier. Pour obtenir une copie indépendante du tableau, il y a la méthode copy, appelée sur le tableau sous la forme df.copy(). Elle rend un nouveau tableau contenant les mêmes données mais logé à une autre adresse en mémoire : ajouter une colonne à la copie ne touche pas l'original. Cette copie, je l'appelle features, puisque c'est elle qui recevra toutes les variables fabriquées aujourd'hui.
1features = df.copy()
2
3print(features.shape)
4# (5, 6)Pour l'instant la copie est le sosie de l'original : les mêmes cinq lignes et les mêmes six colonnes, ce que confirme l'attribut shape. Rien n'a été calculé et rien n'a changé - c'est seulement un second exemplaire du carnet, celui sur lequel on a le droit de gribouiller. Retiens bien ce df.copy(), j'y reviendrai dans quelques paragraphes à propos d'une fonction que les débutants prennent justement pour une copie de tableau.
Quand un seul nombre écrase tous les autres
Regarde encore la colonne population : 120, 450, 85, 200, et soudain 5000. La plus grande valeur est presque cinquante-neuf fois plus grande que la plus petite. Pour la plupart des modèles, cela veut dire que les quatre premiers points d'eau se confondent en un unique point collé à zéro, tandis que toute l'attention part sur le cinquième. Une distribution de cette forme, on la dit asymétrique - tu l'as croisée dans la leçon sur les statistiques sous le nom d'asymétrie positive, c'est-à-dire une longue queue à droite.
Le remède est le logarithme. Le logarithme croît de plus en plus lentement, donc il comprime les grands nombres plus fort que les petits et aplatit cette queue. NumPy propose une fonction toute prête, log1p, dont le nom se lit "logarithme de un plus x" : elle reçoit un nombre, lui ajoute un, et ne calcule le logarithme naturel qu'ensuite. Ce un ajouté n'est pas un caprice. Le np.log ordinaire rend moins l'infini pour zéro, or un point d'eau où, ce jour-là, aucun animal ne s'est présenté est une ligne parfaitement normale du carnet. np.log1p(0) rend un tranquille zéro et rien ne casse.
L'appel se compose de six morceaux dans un ordre figé : d'abord le raccourci de la bibliothèque np, puis le point, puis le nom de la fonction log1p, puis la parenthèse ouvrante, puis ce qu'il faut transformer, c'est-à-dire df['population'], et enfin la parenthèse fermante. Le point dit à Python "va chercher à l'intérieur de NumPy", les parenthèses disent "exécute maintenant". Le seul nom np.log1p sans parenthèses ne calculerait rien du tout : ce serait une simple désignation de la fonction. Le résultat d'un logarithme traîne une dizaine de décimales, alors pour l'affichage j'accroche la méthode round, qui coupe les nombres au nombre de décimales indiqué et ne change rien aux données.
1features['log_population'] = np.log1p(df['population'])
2
3print(features[['population', 'log_population']].round(2))
4# population log_population
5# 0 120 4.80
6# 1 450 6.11
7# 2 85 4.45
8# 3 200 5.30
9# 4 5000 8.52Le rapport entre la plus grande et la plus petite valeur est tombé de 58.8 à 1.9 : le troupeau en migration ne couvre plus le reste de la réserve. Plus important encore, l'ordre des lignes n'a pas bougé - le plus petit point d'eau garde la plus petite valeur, le plus grand garde la plus grande, parce que le logarithme est une fonction croissante et ne permute jamais les données. La colonne population n'a pas changé non plus, ni quoi que ce soit dans le tableau df : dans features, une septième colonne s'est simplement ajoutée aux six anciennes.
Le même mètre pour toutes les colonnes : la standardisation
Le logarithme a corrigé la forme d'une colonne, mais il n'a pas réglé le second souci : l'effectif se compte en centaines, la superficie en milliers, et la densité sortira en fractions. Un modèle qui calcule des distances entre observations traitera alors la colonne aux plus grands nombres comme la plus importante, alors que personne ne l'a déclarée telle. Il faut ramener toutes les variables au même mètre.
La façon la plus courante est la standardisation, appelée aussi score Z. De chaque valeur tu retranches la moyenne de la colonne, puis tu divises le résultat par l'écart-type de cette même colonne. Ces deux notions te viennent de la leçon précédente : la moyenne se calcule avec la méthode mean, l'écart-type avec la méthode std, toutes deux appelées directement sur la colonne. Le résultat se lit très simplement : zéro veut dire exactement la moyenne, un veut dire "un écart-type au-dessus de la moyenne", moins deux veut dire "deux écarts-types en dessous".
1mean = df['population'].mean()
2std = df['population'].std()
3
4features['pop_standardized'] = (df['population'] - mean) / std
5
6print(features[['population', 'pop_standardized']].round(2))
7# population pop_standardized
8# 0 120 -0.49
9# 1 450 -0.34
10# 2 85 -0.51
11# 3 200 -0.45
12# 4 5000 1.78Les quatre points d'eau ordinaires ont atterri juste sous zéro, et le troupeau de gnous à 1.78 écart-type au-dessus de la moyenne. Remarque que la soustraction et la division ont travaillé sur la colonne entière d'un seul coup, sans la moindre boucle : c'est ce comportement de NumPy que tu connais, où une opération arithmétique se répand sur tous les éléments. Là encore, ni l'ordre des lignes ni leur nombre n'ont changé - la standardisation déplace et met à l'échelle un axe, elle ne touche pas aux données.
Que l'axe se soit bien recalé sous la moyenne, tu peux le vérifier à la main. La nouvelle colonne a par construction une moyenne de zéro et un écart-type de un, quelle que soit l'unité dans laquelle les nombres d'origine étaient mesurés.
1print(round(features['pop_standardized'].mean(), 10))
2# -0.0
3print(round(features['pop_standardized'].std(), 10))
4# 1.0La moyenne est sortie à moins zéro, ce qui n'est qu'un déchet de virgule flottante : le vrai résultat est un nombre de l'ordre de dix puissance moins dix-sept, et l'arrondi à la dixième décimale n'en laisse que le signe moins. L'écart-type, lui, vaut exactement 1.0. Désormais la colonne pop_standardized parle la même langue que n'importe quelle autre colonne standardisée du tableau, et le modèle peut les comparer honnêtement.
Une échelle de zéro à un : la normalisation Min-Max
La standardisation n'offre aucune garantie sur l'intervalle : les valeurs peuvent sortir à moins trois comme à plus dix. Il y a des situations où tu as besoin de bornes dures, par exemple quand tu alimentes un réseau de neurones, ou quand tu veux montrer aux guides une barre de progression de zéro à cent pour cent. Là, tu prends la normalisation Min-Max.
La formule a trois ingrédients et tous viennent de la même colonne : de la valeur tu retranches le minimum de la colonne, et tu divises le résultat par l'étendue, c'est-à-dire la différence entre le maximum et le minimum. On l'écrit (x - min) / (max - min). Le minimum est donné par la méthode min, le maximum par la méthode max, toutes deux appelées sur la colonne exactement comme mean ; je les range dans les variables lo et hi pour que la formule se lise d'un seul coup d'oeil. L'effet est toujours le même et découle directement de l'arithmétique : la plus petite mesure donne un numérateur nul, donc exactement 0.0 ; la plus grande donne un numérateur égal au dénominateur, donc exactement 1.0 ; tout le reste tombe quelque part entre les deux.
1lo = df['population'].min()
2hi = df['population'].max()
3
4features['pop_normalized'] = (df['population'] - lo) / (hi - lo)
5
6print(features[['population', 'pop_normalized']].round(3))
7# population pop_normalized
8# 0 120 0.007
9# 1 450 0.074
10# 2 85 0.000
11# 3 200 0.023
12# 4 5000 1.000Le point d'eau à 85 animaux a reçu zéro, celui à 5000 a reçu un, et les trois autres se sont tassés dans les huit centièmes du bas de l'intervalle. C'est là le plus gros défaut de la méthode : une seule valeur aberrante mange toute l'échelle et entasse le reste des données contre zéro. La standardisation de la section précédente répartissait ces mêmes nombres bien plus lisiblement. Voilà pourquoi je te recommande la standardisation comme choix par défaut, et la normalisation Min-Max seulement quand l'intervalle de zéro à un est une exigence ferme du destinataire - de préférence après avoir passé au logarithme une colonne asymétrique. Ce qui, ici non plus, n'a pas changé : l'ordre des lignes et la colonne population d'origine, vers laquelle tu peux toujours revenir.
Des nombres qui préfèrent être des catégories
Parfois l'effectif exact n'est pas du tout ce que tu veux donner au modèle. La direction de la réserve ne demande pas s'il y avait 120 ou 130 animaux au point d'eau : elle demande si le troupeau était petit, moyen, grand ou énorme. Transformer un nombre en intervalle s'appelle le binning (de l'anglais bin, le panier), et dans pandas c'est la fonction pd.cut qui s'en charge.
pd.cut reçoit trois choses. La première est la colonne à découper. La deuxième est bins, la liste des bornes des paniers : avec quatre bornes tu obtiens trois intervalles, avec cinq bornes quatre intervalles. La troisième est labels, la liste des noms de ces intervalles, et elle doit compter exactement un élément de moins que la liste des bornes. La dernière borne, je la mets à np.inf, l'infini de NumPy, pour que le panier le plus haut accepte un troupeau aussi grand soit-il. Les intervalles sont par défaut ouverts à gauche et fermés à droite, si bien que (0, 100] contient bien la centaine, mais pas le zéro.
1features['pop_category'] = pd.cut(
2 df['population'],
3 bins=[0, 100, 300, 1000, np.inf],
4 labels=['small', 'medium', 'large', 'huge']
5)
6
7print(features[['population', 'pop_category']])
8# population pop_category
9# 0 120 medium
10# 1 450 large
11# 2 85 small
12# 3 200 medium
13# 4 5000 hugeCinq nombres sont devenus cinq étiquettes : 85 est tombé dans small, 120 et 200 dans medium, 450 dans large, et le troupeau de gnous dans huge. Vient maintenant la phrase la plus importante de cette section, celle qui dit ce qui ne s'est pas produit. Pas une ligne n'a disparu, il y en a toujours cinq. Pas une colonne n'a disparu. Aucune copie du tableau n'a été créée. pd.cut découpe des données numériques en catégories, c'est-à-dire en intervalles, et en tire une nouvelle colonne, en laissant tout le reste tranquille.
Cela mérite d'être souligné, parce que le mot anglais cut souffle trois idées fausses à la fois. La copie d'un tableau, c'est df.copy(), vue plus haut, et c'est son résultat que nous avons appelé features. La suppression de colonnes, c'est df.drop(columns=['area_km2']) de la leçon sur pandas, et là les colonnes disparaissent pour de bon. Le découpage des lignes qui remplissent une condition, c'est un masque booléen, une écriture du genre df[df['population'] > 100], qui rend un tableau plus court. pd.cut ne fait aucune de ces trois choses : elle se contente de coller des étiquettes d'intervalle sur des nombres.
Des paniers également remplis plutôt qu'également larges
pd.cut découpe l'axe des nombres aux endroits que tu désignes toi-même, si bien que les paniers peuvent être terriblement inégaux en nombre d'observations : il peut arriver qu'un seul point d'eau atterrisse dans huge et quarante dans medium. Quand c'est l'effet inverse que tu cherches, des paniers de remplissage comparable, tu prends pd.qcut. Le "q" est celui des quantiles, connus de la leçon sur les statistiques. Au lieu des bornes, tu passes le paramètre q avec le nombre de groupes : q=4 donne les quartiles, q=10 les déciles. Les noms des groupes se donnent comme dans pd.cut, par labels.
1features['pop_quartile'] = pd.qcut(
2 df['population'],
3 q=4,
4 labels=['Q1', 'Q2', 'Q3', 'Q4']
5)
6
7print(features[['population', 'pop_quartile']])
8# population pop_quartile
9# 0 120 Q1
10# 1 450 Q3
11# 2 85 Q1
12# 3 200 Q2
13# 4 5000 Q4Les bornes se sont calculées toutes seules, à partir des données, et personne n'a eu à deviner où finit un "petit" troupeau. Cinq observations ne se répartissent pas également en quatre groupes, donc Q1 a reçu deux points d'eau et les autres groupes un chacun ; sur des jeux plus grands, ce déséquilibre disparaît. Remarque que 5000 a atterri dans Q4 tout comme 450 dans Q3 : pour pd.qcut seule compte la place dans la file, jamais la distance, ce qui en fait une méthode résistante aux valeurs aberrantes. La colonne population est, une fois de plus, restée intacte.
Une variable qui n'a jamais figuré dans le carnet
Tout ce que nous avons fait jusqu'ici retravaillait des colonnes existantes. Vient maintenant la meilleure partie du feature engineering : inventer des colonnes que personne n'a mesurées. Regarde deux nombres du carnet, l'effectif et la superficie du territoire. Chacun pris à part dit peu de chose : 5000 animaux, c'est impressionnant, jusqu'à ce que tu demandes sur quelle étendue. Ce qui devient intéressant, c'est la densité, le nombre d'animaux par kilomètre carré. Elle naît d'une simple division d'une colonne par une autre et s'appelle une variable de rapport.
1features['density'] = df['population'] / df['area_km2']
2
3print(features[['species', 'population', 'area_km2', 'density']])
4# species population area_km2 density
5# 0 Lion 120 100 1.2
6# 1 Elephant 450 500 0.9
7# 2 Cheetah 85 50 1.7
8# 3 Lion 200 200 1.0
9# 4 Elephant 5000 10000 0.5Lis ce tableau attentivement, parce qu'il retourne les conclusions. La plus grande population, ces 5000 animaux qui écrasaient tout, a la densité la plus faible de la réserve : 0.5 animal au kilomètre carré. La plus petite population, les 85 guépards, a la densité la plus forte : 1.7. Aucune des colonnes sources ne le disait à elle seule - l'information se cachait dans la relation entre les deux et n'est apparue qu'après la division. Au passage, la nouvelle colonne n'a plus de problème d'échelle, puisque toutes ses valeurs tiennent entre 0.5 et 1.7. Les deux colonnes sources sont restées telles quelles dans le tableau et tu peux continuer à t'en servir.
Carrés, racines et produits
Le rapport n'est pas la seule façon de façonner de nouvelles variables. Quand tu soupçonnes que la relation n'est pas une ligne droite - parce qu'un territoire deux fois plus grand ne nourrit pas deux fois plus d'animaux - tu ajoutes des variables polynomiales : une colonne élevée au carré avec l'opérateur **, ou sa racine carrée avec la fonction np.sqrt. Et quand tu soupçonnes que ce sont deux choses ensemble qui comptent, tu crées une variable d'interaction, c'est-à-dire le produit de deux colonnes.
1features['area_sqrt'] = np.sqrt(df['area_km2'])
2features['pop_x_area'] = df['population'] * df['area_km2']
3
4print(features[['area_km2', 'area_sqrt', 'pop_x_area']].round(2))
5# area_km2 area_sqrt pop_x_area
6# 0 100 10.00 12000
7# 1 500 22.36 225000
8# 2 50 7.07 4250
9# 3 200 14.14 40000
10# 4 10000 100.00 50000000La racine a comprimé dix mille kilomètres carrés en une centaine et a égalisé l'échelle à peu près comme le logarithme. Le produit est parti dans l'autre sens : il grimpe à cinquante millions, donc une variable de ce genre demande à être standardisée avant d'arriver au modèle. Et un avertissement de camp : puisque toute paire de colonnes peut être multipliée, divisée et élevée au carré, six colonnes deviennent facilement cent. Ne le fais pas. Crée les variables que tu sais justifier avec des mots, comme la densité de la section précédente ; le reste n'est que du bruit dans lequel le modèle perd la piste. Les colonnes d'origine, bien entendu, dorment toujours intactes dans le tableau.
Le modèle ne lira pas le mot "Lion"
Revenons à l'erreur du début de la leçon. Quatre colonnes du carnet sont du texte, et le modèle n'accepte que des nombres. Il faut les encoder, et la méthode d'encodage la plus importante s'appelle le One-Hot Encoding.
L'idée est la suivante : chaque valeur possible de la catégorie reçoit sa propre colonne, et dans cette colonne on écrit un quand la ligne appartient à cette catégorie, zéro quand elle n'y appartient pas. Le nom vient de l'électronique : dans chaque ligne, exactement une colonne est "chaude", allumée, et toutes les autres sont éteintes. Dans pandas, c'est la fonction pd.get_dummies qui fait ce travail, dummy désignant une colonne de substitution. Commençons par une seule colonne, pour voir le mécanisme à nu.
1print(pd.get_dummies(df['species']))
2# Cheetah Elephant Lion
3# 0 False False True
4# 1 False True False
5# 2 True False False
6# 3 False False True
7# 4 False True FalseUne colonne de texte est devenue trois colonnes booléennes, une par espèce présente dans les données. Dans chaque ligne, exactement une valeur vaut True : la ligne zéro est un lion, donc la colonne Lion s'est allumée. Par défaut, pandas rend ici des valeurs booléennes True et False plutôt que des uns et des zéros, mais pour le modèle c'est la même information, puisque True compte pour 1 et False pour 0. Si tu préfères voir des chiffres, tu ajoutes l'argument dtype=int.
1print(pd.get_dummies(df['habitat'], dtype=int))
2# Forest Savanna
3# 0 0 1
4# 1 1 0
5# 2 0 1
6# 3 1 0
7# 4 0 1Deux habitats, deux colonnes binaires, un seul un par ligne. Remarque qu'avec deux catégories la seconde colonne n'apporte rien de neuf : si Forest vaut zéro, alors Savanna vaut forcément un. Pour certains modèles cette redondance est nuisible, aussi pd.get_dummies accepte-t-elle l'argument drop_first=True, qui supprime la première colonne de chaque jeu. Ce qui n'a pas changé ici : la colonne habitat d'origine, car l'affichage ne fait que montrer un résultat sans le ranger nulle part.
Tout le tableau d'un coup
En pratique, tu encodes rarement les colonnes une par une. pd.get_dummies accepte un tableau entier et y trouve toute seule tout ce qui est du texte. L'appel se compose de six morceaux dans un ordre fixe : le raccourci pd, le point, le nom get_dummies, la parenthèse ouvrante, le nom du tableau df, la parenthèse fermante.
1encoded = pd.get_dummies(df)
2
3print(encoded.shape)
4# (5, 12)Six colonnes sont devenues douze et le nombre de lignes n'a pas bougé : c'est tout le coeur de l'opération. Les quatre colonnes de texte se sont dépliées en dix colonnes binaires - trois espèces, deux habitats, trois niveaux de danger et deux statuts de protection. Les deux colonnes numériques, population et area_km2, ont traversé pd.get_dummies sans le moindre changement : la fonction ne touche qu'aux catégories, jamais aux nombres.
Cette dernière phrase mérite d'être retenue, car plusieurs malentendus tenaces circulent autour du One-Hot Encoding. Il ne sert pas à normaliser les données numériques : la normalisation, c'est la formule (x - min) / (max - min) vue plus haut, ou la standardisation, toutes deux travaillant sur des nombres ; le fait que l'encodage produise des zéros et des uns n'est qu'une ressemblance de surface, car ces zéros et ces uns n'ont été mis à aucune échelle, ils ont été allumés. Il ne sert pas non plus à supprimer les valeurs aberrantes : les mesures aberrantes se repèrent avec la règle de l'écart interquartile de la leçon sur l'analyse exploratoire, et le One-Hot ne supprime pas une seule ligne, il ajoute des colonnes. Enfin, il ne sert pas à fusionner des DataFrames : pour recoller des tableaux il y a pd.merge et pd.concat de la leçon sur pandas, tandis que pd.get_dummies travaille sur un seul tableau et n'en voit jamais un second. Le One-Hot Encoding fait une chose et une seule : il convertit des variables catégorielles en colonnes binaires.
La tentation de numéroter les catégories
Il existe un chemin plus court pour encoder les espèces : les numéroter à la suite. C'est le travail de LabelEncoder, qui vient de la bibliothèque scikit-learn, le paquet qu'on importe sous le nom sklearn, et plus précisément de son sous-module preprocessing, où logent tous les outils de préparation des données. Tu crées un objet LabelEncoder(), puis tu appelles dessus la méthode fit_transform, qui d'un seul geste apprend la liste des catégories et les remplace par des nombres. Regardons ce que ça donne, parce que le résultat est instructif.
1from sklearn.preprocessing import LabelEncoder
2
3le = LabelEncoder()
4features['species_encoded'] = le.fit_transform(df['species'])
5
6print(features[['species', 'species_encoded']])
7# species species_encoded
8# 0 Lion 2
9# 1 Elephant 1
10# 2 Cheetah 0
11# 3 Lion 2
12# 4 Elephant 1Une colonne au lieu de trois : ça a l'air économe. Le problème, c'est que les numéros ont été attribués dans l'ordre alphabétique - Cheetah a eu 0, Elephant 1, Lion 2. Un modèle qui reçoit cette colonne y lira des choses que personne n'a écrites : que le lion est plus grand que l'éléphant, que l'éléphant se tient exactement à mi-chemin entre le guépard et le lion, et que la moyenne d'un guépard et d'un lion donne un éléphant. Les espèces n'ont pas d'ordre, donc chacun de ces nombres est un mensonge injecté dans les données. C'est pourquoi, pour les colonnes d'entrée sans ordre naturel, je te recommande uniquement pd.get_dummies, et je réserve LabelEncoder à l'encodage de la colonne à prédire, où le modèle traite les valeurs comme des noms de classes et non comme des grandeurs.
Quand l'ordre existe vraiment
Il arrive pourtant que les catégories portent un ordre inscrit dans leur définition même. La colonne danger prend les valeurs "Low", "Medium" et "High", et là un danger élevé est réellement supérieur à un danger faible. On appelle ces catégories ordinales (ordinal en anglais), et leur attribuer des nombres croissants devient alors honnête. Cela se fait avec un dictionnaire et la méthode map, qui parcourt la colonne et remplace chaque valeur par ce qu'elle trouve sous cette clé dans le dictionnaire.
1danger_map = {'Low': 0, 'Medium': 1, 'High': 2}
2features['danger_level'] = df['danger'].map(danger_map)
3
4print(features[['danger', 'danger_level']])
5# danger danger_level
6# 0 High 2
7# 1 Low 0
8# 2 High 2
9# 3 Medium 1
10# 4 Low 0L'ordre des nombres reflète maintenant un ordre qui existe dans la réalité, donc le modèle peut légitimement calculer qu'un cran de danger en plus change quelque chose. Attention à un piège : map ne prévient pas devant une valeur inconnue. Si quelqu'un notait "Extreme" dans le carnet, valeur absente du dictionnaire, la colonne résultante contiendrait un silencieux NaN - c'est pourquoi il vaut la peine, après chaque mapping, de vérifier qu'aucun trou n'est apparu. La colonne danger sous sa forme textuelle, elle, est restée intacte dans le tableau.
Oui et non dans une seule colonne
Reste la dernière colonne de texte, endangered, avec ses "Yes" et ses "No". Avec deux valeurs possibles, il n'y a besoin ni d'un encodage un parmi n, ni d'un dictionnaire : une comparaison suffit. L'écriture df['endangered'] == 'Yes' vérifie la condition sur la colonne entière d'un coup et rend une colonne de valeurs booléennes, exactement le masque que tu utilisais pour filtrer dans la leçon sur pandas. La méthode astype change le type d'une colonne, et astype(int) transforme True en 1 et False en 0.
1features['endangered_binary'] = (df['endangered'] == 'Yes').astype(int)
2
3print(features[['endangered', 'endangered_binary']])
4# endangered endangered_binary
5# 0 Yes 1
6# 1 No 0
7# 2 Yes 1
8# 3 No 0
9# 4 No 0Les parenthèses autour de la comparaison sont indispensables : sans elles, Python essaierait d'abord d'appeler astype sur la chaîne 'Yes'. Le résultat est exactement ce que donnerait un One-Hot Encoding avec drop_first=True sur une colonne à deux catégories - une colonne au lieu de deux, sans perte d'information. Remarque que la colonne d'origine est toujours là, juste à côté : dans le rapport pour la direction tu peux montrer un "Yes" lisible, et donner 1 au modèle.
Une date, ce n'est pas un fait mais six
Pour le modèle, une colonne de dates est encore pire que du texte : même broyée en un nombre de secondes, elle donne un compteur qui monte à l'infini et dont on ne peut lire ni la saison ni le rythme de la semaine. Une date, il faut la décomposer. Pandas range ces morceaux derrière l'accesseur dt, disponible sur toute colonne de type date. Tu y accèdes par l'écriture colonne.dt.nom : dt.year pour l'année, dt.month pour le numéro du mois, dt.quarter pour le trimestre, dt.dayofweek pour le jour de la semaine compté de zéro pour lundi à six pour dimanche, et dt.dayofyear pour le numéro du jour dans l'année. Les dates elles-mêmes, je les lis avec la fonction pd.to_datetime, qui convertit des chaînes de caractères en véritables dates.
1dates = pd.DataFrame({
2 'observation_date': pd.to_datetime([
3 '2023-01-14', '2023-03-15', '2023-07-04', '2023-12-31'
4 ])
5})
6
7dates['year'] = dates['observation_date'].dt.year
8dates['month'] = dates['observation_date'].dt.month
9dates['day_of_week'] = dates['observation_date'].dt.dayofweek
10dates['quarter'] = dates['observation_date'].dt.quarter
11dates['day_of_year'] = dates['observation_date'].dt.dayofyear
12
13print(dates)
14# observation_date year month day_of_week quarter day_of_year
15# 0 2023-01-14 2023 1 5 1 14
16# 1 2023-03-15 2023 3 2 1 74
17# 2 2023-07-04 2023 7 1 3 185
18# 3 2023-12-31 2023 12 6 4 365D'une colonne il en est sorti six, et chacune porte un signal différent : l'année capte la tendance pluriannuelle, le mois et le trimestre captent la saison sèche et la saison des pluies, le numéro du jour de la semaine capte le rythme de travail du camp. Vérifie sur la deuxième ligne : le 15 mars 2023 est un mercredi, donc day_of_week vaut 2, avec lundi compté comme zéro. La dernière ligne est le 31 décembre, soit le 365e jour de l'année et le quatrième trimestre. La colonne observation_date elle-même est restée dans le tableau - décomposer une date ne lui enlève rien.
De ces morceaux, on peut aussitôt assembler une variable plus utile que chacun pris à part. La méthode isin vérifie si une valeur appartient à une liste donnée et rend un masque booléen, et cinq et six, ce sont justement samedi et dimanche. Le astype(int) accroché derrière convertit ce masque en zéros et en uns.
1dates['is_weekend'] = dates['day_of_week'].isin([5, 6]).astype(int)
2
3print(dates[['observation_date', 'day_of_week', 'is_weekend']])
4# observation_date day_of_week is_weekend
5# 0 2023-01-14 5 1
6# 1 2023-03-15 2 0
7# 2 2023-07-04 1 0
8# 3 2023-12-31 6 1Deux observations sont tombées le week-end et ont reçu un, deux en semaine et ont reçu zéro. Il est bien plus facile pour un modèle de saisir une colonne nette de zéros et de uns que de deviner que les valeurs 5 et 6 de la colonne day_of_week ont quelque chose en commun. La même astuce fonctionne pour la saison sèche, la saison des migrations ou les jours qui suivent une averse. Et, comme toujours, aucune des colonnes déjà présentes n'a été touchée.
Décembre et janvier sont voisins
Avec le mois écrit comme un nombre de 1 à 12, il y a un problème qu'on ne voit pas au premier regard. Pour le modèle, décembre, c'est-à-dire 12, se trouve à onze unités de janvier, c'est-à-dire 1 : plus loin que de juin. Dans la nature c'est l'inverse - entre une observation de décembre et une de janvier il s'écoule un mois et la météo est presque la même. L'année est un cercle, alors qu'un nombre de 1 à 12 est un segment.
La solution est l'encodage cyclique : au lieu d'un nombre, tu en donnes deux au modèle, qui sont la position du mois sur un cercle. Ce sont les fonctions np.sin et np.cos qui les calculent, et la formule est la même dans les deux cas - tu divises le numéro du mois par 12 pour obtenir une fraction d'année, et tu multiplies par l'angle complet, c'est-à-dire deux fois np.pi, où pi est la constante pi telle que NumPy la stocke. Les numéros de mois, je les range dans la structure pd.Series connue de la leçon sur pandas, c'est-à-dire une colonne isolée, parce que quatre mois d'exemple n'ont pas besoin d'un tableau entier.
1months = pd.Series([1, 2, 6, 12])
2
3month_sin = np.sin(2 * np.pi * months / 12)
4month_cos = np.cos(2 * np.pi * months / 12)
5
6print(pd.DataFrame({
7 'month': months,
8 'month_sin': month_sin.round(3),
9 'month_cos': month_cos.round(3)
10}))
11# month month_sin month_cos
12# 0 1 0.500 0.866
13# 1 2 0.866 0.500
14# 2 6 0.000 -1.000
15# 3 12 -0.000 1.000Compare maintenant les paires de points sur ce cercle. Janvier est assis en (0.5, 0.866), décembre en (0.0, 1.0), février en (0.866, 0.5). La distance entre janvier et décembre vaut 0.518, exactement la même que celle entre janvier et février : la paire décembre-janvier est enfin aussi proche qu'elle l'est dans la réalité. Juin, éloigné d'une demi-année, se trouve à 1.932 de janvier, de l'autre côté du cercle. Le signe moins devant le zéro de la dernière ligne n'est là encore qu'un déchet de virgule flottante - la vraie valeur du sinus pour un angle complet est un nombre de l'ordre de dix puissance moins seize. La même astuce marche pour l'heure de la journée et pour le jour de l'année.
La description du terrain est une donnée elle aussi
Reste le dernier élément du carnet, le moins semblable à un tableau : les phrases que les guides ajoutent au crayon à côté d'une observation. On ne peut pas les donner telles quelles au modèle, mais on peut en extraire des nombres. Pandas met les opérations sur le texte derrière l'accesseur str, qui travaille sur la colonne entière : str.len donne la longueur de la chaîne en caractères, str.split éclate la phrase en liste de mots, et str.contains vérifie si un fragment donné apparaît dans le texte et rend le masque booléen que tu connais déjà. En accrochant encore un .str.len() derrière str.split(), tu obtiens le nombre de mots de chaque phrase.
1notes = pd.DataFrame({
2 'description': [
3 'Large male lion spotted near river',
4 'Small elephant calf with mother',
5 'Fast cheetah hunting gazelle'
6 ]
7})
8
9notes['text_length'] = notes['description'].str.len()
10notes['word_count'] = notes['description'].str.split().str.len()
11notes['has_river'] = notes['description'].str.contains('river').astype(int)
12
13print(notes[['text_length', 'word_count', 'has_river']])
14# text_length word_count has_river
15# 0 34 6 1
16# 1 31 5 0
17# 2 28 4 0Trois phrases sont devenues trois colonnes de nombres, et chacune porte un signal différent : la longueur d'une description mesure parfois à quel point l'observation sortait de l'ordinaire, et has_river est une variable binaire toute prête qui dit que l'animal a été vu près de l'eau. La colonne description est restée intacte et se laisse toujours lire par des humains. Il existe bien sûr des méthodes qui prennent tout le vocabulaire d'un corpus et pèsent l'importance de chaque mot - la plus connue s'appelle TF-IDF - mais elles relèvent du traitement automatique des langues et traînent trop de notions propres pour tenir dans cette leçon. Pour l'instant, trois colonnes inventées à la main suffisent largement.
Dernier camp : calcule les statistiques là où c'est permis
Reste un avertissement, sans lequel tout le travail ci-dessus peut te tromper en silence. Quand nous avons calculé la standardisation, la moyenne et l'écart-type se sont calculés sur les cinq lignes. Si une partie d'entre elles étaient des données de test, sur lesquelles le modèle ne doit être évalué que plus tard, une information du test aurait fui dans la préparation des données et les résultats seraient sortis trop beaux. C'est pourquoi, dans les vrais projets, on décrit les transformations à l'avance et on ne les exécute qu'ensuite sur le bon morceau de données.
C'est le rôle de ColumnTransformer, du sous-module compose de scikit-learn : il reçoit une liste de tâches, chaque tâche étant un triplet - un nom que tu choisis, l'outil de transformation, et la liste des colonnes sur lesquelles il doit travailler. StandardScaler est la standardisation toute prête que tu as calculée plus haut à la main, et OneHotEncoder est l'encodage un parmi n tout prêt, avec l'argument drop='first' qui correspond au drop_first déjà connu. La méthode fit_transform fait deux choses d'un coup : elle apprend les statistiques nécessaires à partir des données fournies, et elle les transforme dans la foulée.
1from sklearn.compose import ColumnTransformer
2from sklearn.preprocessing import StandardScaler, OneHotEncoder
3
4preprocessor = ColumnTransformer([
5 ('num', StandardScaler(), ['population', 'area_km2']),
6 ('cat', OneHotEncoder(drop='first'), ['habitat'])
7])
8
9matrix = preprocessor.fit_transform(df)
10
11print(matrix.shape)
12# (5, 3)Du tableau est sorti un simple tableau NumPy de cinq lignes et trois colonnes : deux colonnes numériques standardisées, plus une colonne binaire pour l'habitat, puisqu'avec deux catégories et drop='first' il n'en reste qu'une. Les colonnes que tu n'as citées dans aucune tâche ne sont tout simplement pas arrivées dans le résultat - ColumnTransformer ne laisse passer que ce qu'on lui a désigné, et le df d'origine, lui, n'a pas bougé d'un pouce. Les valeurs standardisées diffèrent un peu de celles que nous avions calculées à la main - de quelques centièmes sur les quatre petites lignes et d'environ deux dixièmes sur celle du troupeau de gnous - parce que scikit-learn divise par l'écart-type de la population entière alors que pandas divise par défaut par celui de l'échantillon. Ce même preprocessor peut ensuite devenir la première étape d'un objet Pipeline, qui soude la préparation des données et le modèle en un seul bloc : les statistiques ne se calculent alors que sur les données d'entraînement, et la fuite devient impossible.
Ce que tu remportes du camp
- Le feature engineering, c'est créer de nouvelles variables à partir de données brutes pour les modèles ML. Ce n'est pas supprimer toutes les variables des données, ce n'est pas l'entraînement du modèle (ça, c'est
fit) et ce n'est pas une méthode de visualisation des données (pour ça, il y a Matplotlib et Seaborn). - Une colonne asymétrique s'aplatit au logarithme, dans l'ordre
np,.,log1p,(,df['population'],). La variantenp.log1pplutôt quenp.logsurvit aux zéros. - La standardisation, c'est
(x - mean) / std, et elle donne une moyenne de 0 et un écart-type de 1. La normalisation Min-Max, c'est(x - min) / (max - min), et elle enferme les données exactement dans l'intervalle de 0.0 à 1.0. Par défaut je recommande la standardisation, parce qu'une seule valeur aberrante ne lui mange pas toute son échelle. pd.cutdécoupe des données numériques en catégories, c'est-à-dire en intervalles. Elle ne copie pas le tableau (ça, c'estdf.copy()), elle ne supprime pas de colonnes (ça, c'estdf.drop) et elle ne coupe pas les lignes qui remplissent une condition (ça, c'est un masque booléen).pd.qcutfait la même chose, mais avec des paniers également remplis.- Les variables les plus précieuses naissent de la relation entre colonnes : le rapport de l'effectif à la superficie a montré que la plus grande population avait la plus faible densité de la réserve.
- Le One-Hot Encoding convertit des variables catégorielles en colonnes binaires. Il ne supprime pas les valeurs aberrantes, il ne normalise pas les données numériques et il ne sert pas à fusionner des DataFrames - pour fusionner, il y a
pd.mergeetpd.concat. - L'appel de l'encodage tient en six morceaux :
pd,.,get_dummies,(,df,). Les colonnes numériques le traversent sans le moindre changement. LabelEncodernumérote les catégories dans l'ordre alphabétique et fait croire au modèle à un ordre qui n'existe pas. Ne numérote que les catégories réellement ordinales, avec un dictionnaire et la méthodemap.- Une date se décompose avec l'accesseur
dten année, mois, trimestre, jour de la semaine et jour de l'année, et la cyclicité se sauve avec la pairenp.sinetnp.cos. - Le texte devient des nombres avec
str.len,str.splitetstr.contains, et les statistiques de mise à l'échelle se calculent en dernier, viaColumnTransformer, pour qu'elles ne fuient pas depuis les données de test.
Le modèle ne verra jamais ce que toi tu as vu au point d'eau - il ne verra que les colonnes que tu lui auras façonnées, et c'est donc toi qui décides désormais tout ce qu'on peut encore lire de la réserve.
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 Feature Engineering ?
2. À quoi sert le One-Hot Encoding ?
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
Calcule les valeurs normalisées avec la formule (x - min) / (max - min).
- Tri horizontal
Remets dans l'ordre le code pour créer une transformation log d'une colonne :
- Clique dans l'ordre
Clique sur les boutons pour effectuer un One-Hot Encoding de la colonne 'species' :