Image for post
Image for post

Modèle prédictif de la valeur automobile

Wladimir Delenclos
May 24 · 18 min read

Ce document est la version retravaillée sous la forme d’un article d’un document rendu dans le cadre d’un projet d’études.

L’objet de cet article est de présenter toutes les étapes de la conception au déploiement d’un algorithme de machine learning adapté à une estimation d’un bien automobile (un prix) en fonction des caractéristiques du véhicule.

1 — Introduction

L’objectif de ce projet est de visualiser les étapes de mise en place d’un outil d’estimation de véhicules d’occasions. Plus généralement, l’estimation d’un prix de marché d’un produit est une problématique récurrente dans les cas d’usages data.

Ce document décompose le travail d’élaboration du modèle prédictif. Il se structure en plusieurs parties allant du nettoyage du dataset, l’analyse exploratoire des données, l’enrichissement du dataset, la création et l’entrainement du modèle et sa mise à disposition derrière une API Restful en Python.

Il met en oeuvre une approche métier couplée à une approche statistique.

Tout au long de ce document, je vous accompagne avec des commentaires et le code en Python 3.


2 — Import & étude préliminaire

Import

Nous allons commencer par importer les données fournies et étudier à l’oeil le dataset pour observer des premiers grands phénomènes utiles pour ce cas d’usage.

Ces données proviennent d’un site d’annonce de véhicules d’occasion. Elle comporte aussi un ensemble de caractéristiques pour des véhicules mis en vente en France avec leur prix associé, caractéristiques que nous allons observer par la suite.

Dans cette première partie, nous observons les récurrences et tendances globales de notre jeu de donnée.

import numpy as npimport matplotlib.pyplot as pltimport pandasimport reimport unicodedataimport seaborn as sns# Import du datasetdf = pd.read_csv('Data_cars.csv')# On observe un dataset de 166695 lignes et 9 colonnes
df.shape

Variables

Nous commençons par décrire l’ensemble des variables présentes dans le dataset, discrètes et continues.

df.describe(include = 'all')
Image for post
Image for post

Observons d’abord en détail les variables discrètes.

Image for post
Image for post
Image for post
Image for post

Nous n’allons pas décrire dans leur ensemble toutes les variables discrètes dès maintenant car cette observation du dataset nous suffit à voir la qualité du dataset.

La méthode describe() est très utile pour trouver des valeurs aberrantes laissées dans les données, ici on observe que le jeu de donnée est propre mais en partie basé sur du texte inexploitable en l’état. Il faudra vraisemblablement opérer une feature extraction sur ces aspects pour enrichir notre dataset de classes disponibles dans la description.

On rappelle que l’objectif de ce projet est à partir de ce jeu de donnée d’être capable de prédire pour des caractéristiques spécifiques, le prix d’un véhicule.

Tout l’enjeu va alors d’avoir une approche statistique qui complète une approche métier. Cela signifiera donc d’extraire ici de nos colonnes les éléments qui influent le plus sur le prix grâce à la matrice de corrélation sera un bon outil our observer quelles métriques statistique est cohérente par rapport à ce que l’ont sait du métier.

La structure du dataset indique la présence de 9 colonnes pour 166695 lignes :

On va appliquer quelques nettoyages basiques de la donnée tel que le formatage des entiers, la suppression des extensions kilométriques, le découpage des descriptions et vraisemblablement la normalisation des prix puis observer à nouveau notre dataset dans une courte EDA.


3 — Préparation et EDA

Nettoyage basique

On opère une série de nettoyage basiques de sorte à rendre la donnée facilement manipulable de à sortir les aberrations du dataset.

# Vérification des valeurs manquantesdf.isna().sum()
Image for post
Image for post
Image for post
Image for post
Image for post
Image for post

Analyse exploratoire des données

Tendances

Tendance de répartition des prix

Image for post
Image for post
Image for post
Image for post

On observe une distribution asymétrique avec un skew à droite, prenant seulement des valeurs positives allant de 1 à 1486500 que l’on va normaliser pour mieux observer mais qu’on va garder tel quel pour le modèle.

(On se limite à Price 1000 > 200000)

Image for post
Image for post
Image for post
Image for post

On observe ici que les prix des véhicules les plus chers ne sont pas les plus importants confirmant la moyenne à 1.933369e+04 observée plus haut.

Distribution des prix en fonction du kilométrage

La relation entre les entités continues est essentielle dans notre ensemble de données. Sont-elles linéaires ? Ceci est important car la plupart des modèles supposent une relation linéaire entre les entités et la cible.

On peut être tenté de former un graphique de points pour observer une régression entre deux variables uniformes qui nous paraissent logiquement corrélées et ainsi cela nous permettrait de faire une simple régression linéaire pour établir de prix.

Image for post
Image for post

On observe globalement une distribution des kilométrage par rapport au prix selon une aire logarithmique.

La répartition des prix montre l’absence de corrélation suffisante entre le kilométrage et le prix. Néanmoins la donnée reste utile à un modèle plus élaboré.

Distribution des classes

Deux variables discrètes observaient un grand volumes de valeurs dénombrables.

Image for post
Image for post
Image for post
Image for post

On observe d’ordre général une distribution non équilibrée des classes constructeur et modèles dans le dataset. Ceci pourrait être à prendre en compte lors de l’élaboration et l’entrainement du modèle.

Néanmoins, l’hypothèse métier serait que la proportion représentée de constructeur et de modèles corresponde à la proportion de véhicules les plus vendus en France (pays d’origine des données extraites) en proportion des années représentées dans le modèle. Cela se caractériserait par une sur-représentation des véhicules de la Marque Peugeot, Renault et Citroën.

Hors, en effet la comparaison de ces données aux véhicules les plus vendus (https://fr.wikipedia.org/wiki/Voitures_les_plus_vendues_en_France) montre que ces données ne semble pas aberrantes et donc exploitables à un modèles pour des véhicules vendus en France.

Domification des valeurs

On va aussi procéder à un découpage pour les catégories de faible taille Gearbox, Fuel, Model et Make (constructeur) via respectivement deux Domifications et deux OneHotcoding au regard de la taille des tableaux de classes.

Image for post
Image for post

Relation entre les données et matrice de corrélation

Il y a un manque d’informations continues utiles pour notre modèle. il conviendra d’en extraire plus depuis la colonne de description. Néanmoins on peut faire un premier état des lieux notamment pour les caractéristiques de variables de catégorie.

sns.pairplot(df[['Gearbox', 'Fuel', 'Make','Model','Price','Model_year']], diag_kind='kde')
Image for post
Image for post
f = plt.figure(figsize=(12, 12))
sns.set()
sns.heatmap(df.corr().round(2), annot=True, cmap="Blues")
Image for post
Image for post

4 — Préparation avancée

Nous allons procéder de manière à extraire des features du champs description pour améliorer la quantité et la qualité des informations présente dans notre dataset de manière à optimiser notre modèle.

# Structure de base d'une descriptiondf['Description'][6]

Dans cet échantillon on observe après la chaine de caractère “version:” la cylindrée et les chevaux fiscaux. De plus on retrouve de manière systématique ne nombre de porte, les options et la puissance fiscale, ainsi que les options que l’on va tenter par la suite d’extraire en NLP.

Image for post
Image for post

Fonctions d’extraction

Nous allons ici extraire la puissance fiscale, les portes, la couleur, ainsi que le nombre d’options énoncés du véhicule.

Image for post
Image for post

Nous supprimons ensuite les données aberrantes, préalablement on a attribué la valeur 7 à tous les véhicules dont l’extraction du nombre de portes était impossible. On va donc les retirer de notre dataset.

df = df[df.Doors != 7]

Nous appliquons un encodage manuellement pour simplifier cette colonne en mémoire.

# Unique string to int for colorsdf['Color'] = np.unique(df['Color'], return_inverse=True)[1]

Nous allons ensuite extraire l’âge de la voiture à partir de la date de publication de l’article et l’année du modèle. Pour le faire on peut utiliser la librairie datetime et soustraire les chaines converties en entiers.

Image for post
Image for post

On récupère ensuite la puissance en fonction de la puissance fiscale ainsi que le nombre de chevaux réels, qui dont la corrélation visible dans la matrice de corrélation devra être maximisée pour signifier une extraction correcte.

Image for post
Image for post
Image for post
Image for post

Nous poursuivons avec la cylindrée exprimée dans la description sous la forme ‘X.X, elle est facile à extraire mais présente quelques manques car ce format n’est pas systématiquement présent.

Pour ce faire on applique la moyenne des cylindrées par modèle pour les valeurs manquantes manuellement avec pour celle identifiable facilement (les séries fxx et Wxxx de BMW et Mercedes ).

Cette méthode n’est pas optimale, nous pourrions utiliser une régression linéaire de la cylindrée en fonction de la puissance mais cela fera l’affaire pour obtenir des valeurs correctes pour les performances de notre modèle.

Image for post
Image for post
Image for post
Image for post
Image for post
Image for post
Image for post
Image for post

On a perdu environ 28000 véhicules dans le dataset ce qui est assez modéré au regard du nombre initial, mais ceci pourrait être une première source d’optimisation future. On peut observer moins d’aberration dans le dataset sur les colonnes Puissance et Cylindre et l’apparition d’une régression cohérente, ce qui n’était alors pas perceptible jusqu’ici.

Image for post
Image for post
Image for post
Image for post

Jeu de données après extraction

sns.pairplot(df[['Gearbox','Make','Model','Fuel','Price','Model_year','Age','Doors','Power_Fisc','Power','Options','Options_Count','Color', 'Cylinder']], diag_kind='kde')
Image for post
Image for post

On observe beaucoup plus d’informations intéressantes notamment des variables discrètes et continues que nous n’avions pas jusqu’à maintenant.

La matrice de corrélation nous permet à nouveau de voir les différentes relations dans ce dataset.

f = plt.figure(figsize=(14, 14))sns.set()sns.heatmap(df.corr().round(2),cmap="Blues", annot=True)
Image for post
Image for post

Voici à présent un extrait de notre dataset valorisé et prêt pour un premier test de modèle pour exprimer la faisabilité de la modélisation du prix du véhicule en fonction de ces données.

pd.set_option('display.max_columns', 500)df.head()
Image for post
Image for post

5 — Modèles

Notre objectif, le prix, est continu, nous avons donc affaire à un problème de régression.

Dans cette section, je vais tester différentes techniques de régression. Pour ce faire, nos caractéristiques catégorielles doivent être vectorisées.

Pour des raisons de performance nous allons couper une partie du dataset pour tester les différents modèles plus rapidement sans besoin de trop de puissance de calcul. Nous allons le faire ici sur 25000 entrées du CSV. Cela implique de prendre un peu de recule quant à la convergence du modèle.

Vectorisation

Je vectorise à présent le jeu de données pour l’entrainement.

Image for post
Image for post

Je divise ensuite les données en formation et en ensembles de données. Les données d’entraînement seront utilisées pour ajuster le modèle et l’ensemble de test pour évaluer la précision du modèle.

On divise les données d’entraînement en différents sous-ensembles et on les utilise pour entraîner et tester le modèle plusieurs fois. Ensuite, on calculera une estimation de la performance sur les différents entraînements.

X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3)

Nous pouvons effectuer la standardisation (centrage des données autour de la moyenne) de nos fonctionnalités numériques à l'aide de StandardScaler. La standardisation va améliorer la précision de la plupart des modèles, en revanche nous perdons l'interprétation des coefficients du modèle tels que ceux donnés par la régression linéaire. Heureusement, nous pouvons utiliser la méthode de prédiction pour nous donner des prédictions de modèle. Cette étape sera effectuée à l'intérieur d'un pipeline, après la formation et le fractionnement des données de test, pour éviter les fuites de données.

Image for post
Image for post

Test des modèles de régression

Nous allons tester 6 modèles de régressions courants et disponible de la librairie scikit learn: Lasso (Least Absolute Shrinkage and Selection Operator) , Ridge, Elastic Net, Gradient Boosting Regressor, Random Forest Regressor et Extra Trees Regressor.

Image for post
Image for post
Image for post
Image for post

On observe ici la performance des modèle avec 4 indicateurs:

On affiche ensuite les performances de nos différents modèles

Image for post
Image for post
Image for post
Image for post

Choix du modèle

Les deux modèles intéressants ici seront Extra et Random forest.

Les deux méthodes sont à peu près les mêmes, l’Extra Randomized Tree étant un peu moins bon quand il y a un grand nombre de caractéristiques bruyantes (dans les ensembles de données de grande dimension).

Cela dit, à condition que la sélection des fonctionnalités (chez nous manuelle) soit optimale, les performances sont à peu près les mêmes, cependant, les Extra Randomized Tree peuvent être plus rapides en termes de calcul.

Néanmoins les modèles Random Forest réduisent le risque de sur-ajustement en introduisant le hasard en:

Il apparait que le modèle de régression Extra est le plus performant sur ce cas avec 1/5e de notre dataset mais pour les raisons évoquées au dessus, nous allons utiliser Random Forest. A nous d’optimiser à présent ce modèle avec les caractéristiques dont nous disposons pour faire chuter au mieux la valeur de Mean absolute percentage error (MAPE).

mape[5]9.060604327948942

MAPE pour Random Forest: La MAPE sur le Random Forest que nous sélectionnons ici est de environ 9%, ce qui peut être amélioré.

Test initial du modèle

Nous maintenant procéder à l’inférence de notre jeu de donnée de test avec Random Forest pour observer les résultats obtenus et leur cohérence.

La validation croisée, par sa généralisation, va nous donner une première idée du sur-ajustement ou du sous-ajustement. Ensuite, on calcule une estimation de la performance sur les différents entraînements. Mais en cas de corrélation fallacieuse, on ne peut pas faire uniquement confiance à ce procédé pour juger de la qualité de certaines caractéristiques dans le modèle. C’est là où l’approche métier prend tout son sens.

Image for post
Image for post
Image for post
Image for post
Image for post
Image for post

L’erreur de prédiction moyenne dans les prix des voitures est d’environ -R -134. La valeur négative signifie que le modèle a tendance à sous-estimer les prix en général. La moins bonne nouvelle est que l’écart-type des prévisions est assez élevé. Il va falloir donc améliorer les prédictions en augmentant la quantité d’information à forte valeur ajoutée pour le véhicule. En effet, on remarque que plus le prix est élevé plus la prédiction est mauvaise. Nous allons regarder aussi du côté des options qui pour un véhicule à prix élevé coûtent cher.


6- Optimisation du modèle

On le sait, notre modèle est perfectible. Nous avons choisi Random Forest, ce modèle bénéficie de paramètres optimisable, mais à nous de jouer sur les variables fournies à ce modèle pour le renforcer. Nous avons identifié précédemment des pistes liées à l’optimisation des caractéristiques continues et liées à la problématique des options qui semblent avoir une grande valeur ajoutée sur le prix d’un véhicule. Cela va se caractériser par deux aspects, le NLP sur les options et un enrichissement par une variable continue, le prix au neuf du véhicule.

Hyperparamètres

Le random forest a quelques hyperparamètres qui peuvent être réglés pour améliorer la précision globale. Je vais jouer avec n_estimators (le nombre d’estimateurs / arbres) et min_samples_leaf (le nombre minimum d’échantillons pour être dans une branche d’arbre). On pourrait être tenté de tester tous les paramètres, mais les coûts de calcul augmentent rapidement en fonction du nombre de paramètres, des valeurs possibles et des plis de validation croisée:

Image for post
Image for post

Voici les paramètres que nous mettrons dans le modèle, n_estimator correspondant au nombre initial d’arbres dans le modèle et min sample leaf au nombre minimum d’échantillons requis pour diviser un nœud interne.

Enrichissement du dataset via scrapping

Après cet premier modèle on peut revenir aux basiques de l’approche métier pour prendre un peu de recul sur ce que nous avons fait jusque là.

Il parait cohérent que la variable qui manque le plus jusque là est le prix au neuf du véhicule. En effet, on va le vérifier avec la matrice de corrélation par là suite, le prix du véhicule d’occasion est intimement corrélé à son prix au neuf.

Aussi nous allons récupérer les prix des modèles neufs sur Autoplus (merci à eux de fournir une base en ligne facilement itérable !) .

Scrapping

Image for post
Image for post

Il ne nous reste plus qu’à retirer les modèles et version en doublons car nous ne voulons pas de niveau de granularité trop important pour faire de la recherche approximative (fuzzy matching) entre notre dataset et nos contenus puis faire ce fuzzy matching pour agrémenter notre dataset.

Image for post
Image for post
Image for post
Image for post

Fuzzymatching

La recherche approximative est le problème qui consiste à trouver des chaînes de caractères qui correspondent à un motif approximatif plutôt qu’à une correspondance exacte. Voici notre fonction qui va nous permettre d’associer nos deux tableaux.

Image for post
Image for post

Comme précédemment, on enregistre sur le filesystem pour pouvoir reprendre le travail facilement sans avoir à répéter l’opération.

df.to_csv(r'Dataset_rich.csv')dfr = pd.read_csv('./Dataset_rich.csv')# On supprime au passage la colonne inutile
dfr = dfr.drop(['Unnamed: 0'], axis=1)

On affiche une nouvelle fois notre matrice de corrélation pour contrôler ce que l’ont a fait jusqu’ici.

Image for post
Image for post
Image for post
Image for post

On va aussi contrôler la répartition des prix en fonction du prix au neuf pour observer sa cohérence avec la logique métier qui veut qu’on observe un phénomène linaire ( à l’exception des véhicules d’exception et de collection).

Image for post
Image for post
Image for post
Image for post

Et c’est ce qu’on observe sur les véhicule au prix inférieur à 50000€.

Natural Language Processing sur les options et version

Il nous reste encore deux aspects intéressants à traiter par rapport à la cohérence métier du modèle, les options et la version du modèle.

En effet, en fonction des options et certaines version, des options se retrouvent en série et inversement. De même plus le prix du véhicule est important, plus il implique que l’ajout d’options soit cher.

A ce titre l’option nécessite une extraction de caractéristiques plus complexe qu’un simple count() comme effectué précédemment.

Préprocessing

Image for post
Image for post

TF-IDF

Le TF-IDF est une méthode de pondération souvent utilisée en recherche d’information et en particulier dans la fouille de textes. Cette mesure statistique permet d’évaluer l’importance d’un terme contenu dans un document, relativement à une collection ou un corpus. C’est ce que nous allons utiliser ici pour extraire les options.

Image for post
Image for post

Nous avons à présent ajouté des entrées catégorielles (avec beaucoup de bruits) dans notre dataset à passer à notre modèle. Nous espérons que cela améliorera notre MAPE pour notre modèle. Ce qui pourra se vérifier plus tard.

Dans le cas ou celà n’améliore pas cet indicateur, cela signifie qu’on peut rentrer dans un cas de sur-ajustement qui se produit plus souvent que le sous-ajustement. Une des solutions les plus populaires au sur-ajustement est la validation croisée.

Test du modèle optimisé

Image for post
Image for post
Image for post
Image for post
Image for post
Image for post

Nous avons gagné plus d’ 1point de MAPE ce qui est intéressant.

Il serait intéressant ici de voir plusieurs pistes pour aller plus loins dans l’optimisation du modèle:


7 — Prédictions

On observe ainsi des écarts encore importants mais pas impossible à améliorer selon les pistes que nous avons sur le traitement des options, l’utilisation d’Extra et une recherche via l’explicabilité.

Image for post
Image for post
Image for post
Image for post

En attendant, ce modèle est exploitable en l’état avec une MAPE d’environ 8,4% ce qui est correct mais nous pourrions descendre jusqu’à 4%.

Nous allons voir à présent comment mettre en production, de manière simple ce type de modèle. L’exemple qui va suivre ne prends pas en compte les problématique de mise à jour du modèle si de montée en charge et se “contente” de servir un endpoint pour demander une prédiction selon les paramètres d’entrée.


8 — Mise en production

from sklearn.externals import joblib

Vous pouvez utiliser Flask pour créer une API qui peut fournir des prédictions basées sur un ensemble de variables d’entrée à l’aide d’un modèle entrainé.

Le modèle entrainé est prêt à être utilisé. Je vais utiliser le joblib de sklearn pour de déployer

Image for post
Image for post

C’est bon!

Nous avons persisté sur le filesystem notre modèle. Nous pouvons charger ce modèle en mémoire sur une seule ligne.

Nous sommes maintenant prêts à utiliser Flask pour servir notre modèle persisté. Le Framework utilisé est une référence de la communauté, et il est est assez minimaliste.

Voici ce dont vous avez besoin pour démarrer une application Bare Bones Flask (sur le port 8000 pour cet exemple).

Image for post
Image for post

A partir de cet instant il nous reste deux choses à créer et nous aurons fini:

Image for post
Image for post

Les entrées présentes dans json_ doivent être formatées pour correspondre aux entrées du modèle que nous avons entrainé. Il serait intérssant à ce titre de faire un service dédié sur notre API qui convertisse l’objet de l’entrée en un object que l’on passe à notre méthode de génération de Dataframe pour ne pas avoir à faire coller les paramètres envoyées dans la requête aux colonnes de notre dataframe en entrée.

Cet article est terminé, il a eu pour objectif de présenter de manière non exhaustive des techniques simples de modélisation pour un problème d’estimation de prix d’un marché et nous a montré la relative simplicité d’un procédé mélangeant analyse exploratoire de la donnée, compréhension métier et savoir-faire de développement et de mise en oeuvre de Framework Data.

9 — Ressources et liens

R2: https://statistique-et-logiciel-r.com/regression-lineaire-simple-le-r%C2%B2-info-ou-intox/

Mean square error: https://fr.wikipedia.org/wiki/Erreur_quadratique_moyenne

EDA : https://towardsdatascience.com/exploratory-data-analysis-in-python-c9a77dfa39ce

Random Forest Regressor vs Extra Trees : https://www.thekerneltrip.com/statistics/random-forest-vs-extra-tree/

Documentation Sklearn: https://scikit-learn.org/

Overfitting: https://ezako.com/fr/les-concepts-doverfitting-et-underfitting-en-machine-learning/

API Flask: https://towardsdatascience.com/a-flask-api-for-serving-scikit-learn-models-c8bcdaa41daa

Advanced Fuzzy Matching : https://towardsdatascience.com/fuzzy-matching-at-scale-84f2bfd0c536

Binary text classification with TfidfVectorizer: https://datascience.stackexchange.com/questions/38267/binary-text-classification-with-tfidfvectorizer-gives-valueerror-setting-an-arr

Joblib: https://joblib.readthedocs.io/en/latest/