Travaux pratiques - SVM linéaires

(la version précédente de cette séance, utilisant l’API RDD)

Références externes utiles :

L’objectif de cette séance de TP est de présenter l’emploi des SVM linéaires dans Spark, y compris la définition de pipeline et la recherche de valeurs pour les hyperparamètres (grid search) en utilisant la validation croisée. La même méthodologie doit être employée pour la construction de tout modèle décisionnel avec Spark.

Les exemples ci-dessous reprennent, en partie, ceux des documentations en ligne.

Récupération des fichiers nécessaires

Nous travaillerons dans cette séance de travaux pratiques sur un découpage apprentissage / test particulier d’une partie des données Adult de la base UCI. Les données ont été employées pour prédire si le revenu annuel d’un individu dépasse 50 k$ (en 1996). Regardez ce site pour une description des variables présentes. Les données que vous récupérez ne correspondent pas directement aux 14 variables indiquées dans la description car un pré-traitement a été effectué. Il est décrit sur la page LIBSVM Tools : « les six variables continues sont discrétisées en quantiles, chaque quantile étant ensuite représenté par une variable binaire. Une variable catégorielle à m modalités est convertie en m caractéristiques binaires ». Cependant, l’ordre des variables initiales et le nombre de quantiles pour chaque variable initiale n’est pas précisé.

Commençons par télécharger le fichier contenant les données décrites ci-dessus :

import sys.process._

// Création d'un dossier pour ce TP
"mkdir -p tpsvm/data" !

// Téléchargement des données
"wget -nc http://cedric.cnam.fr/vertigo/Cours/RCP216/docs/a7 -P tpsvm/data/" !

SVM linéaires : apprentissage et évaluation

Spark MLlib propose une implémentation des machines à vecteurs de support (Support Vector Machines, SVM) avec noyau linéaire (adapté aux données linéairement séparables), voir la documentation en ligne.

Pour lire les données et les séparer en ensemble d’apprentissage et ensemble de test, dans spark-shell ou Jupyter, entrez :

// Lire les données dans le format approprié
val donnees = spark.read.format("libsvm").load("tpsvm/data/a7")
donnees.show(2, false)  // visualiser les 2 premières lignes du DataFrame

Pour tester la capacité de notre modèle prédictif à généraliser sur des données non-vues, nous divisons notre jeu de données en deux: une partie sera utilisée pour l’apprentissage, l’autre partie sera conservée pour l’évaluation.

// Partitionner les données en apprentissage (50%) et test (50%)
val partitions = donnees.randomSplit(Array(0.5, 0.5))
val apprentissage = partitions(0).cache()  // conservé en mémoire
val test = partitions(1)

Construction de pipeline

En général, différents traitements préalables sont appliqués aux données avant la modélisation décisionnelle. Ces traitements peuvent être regroupés dans un pipeline, comme nous l’avons expliqué et ensuite illustré dans cet exemple.

Nous construisons ici encore un pipeline qui dans un premier temps se réduit à la SVM et, dans cette section optionnelle inclut en plus une réduction de dimension par ACP. L’utilisation de :paste dans spark-shell peut vous faciliter les choses pour la suite du TP.

import org.apache.spark.ml.classification.LinearSVC
import org.apache.spark.ml.{Pipeline, PipelineModel}

// Définition de l'estimateur SVC linéaire
val linSvc = new LinearSVC().setMaxIter(10)
                            .setFeaturesCol("features")
                            .setLabelCol("label")

// Définition du pipeline (réduit ici au SVM linéaire)
val pipeline = new Pipeline().setStages(Array(linSvc))

Il est utile de regarder la documentation de l’API Spark en scala pour les SVM linéaires. Un des paramètres de LinearSVC est setStandardization() avec True par défaut, donc le centrage et la réduction des variables décrivant les observations sont réalisés par défaut. Aussi, vous pouvez noter la présence d’un paramètre setWeightCol() qui permet de pondérer des observations.

Recherche des valeurs optimales des hyperparamètres

Un modèle décisionnel obtenu comme résultat d’une procédure de modélisation dépend des données (observations) d’apprentissage, mais également de la famille paramétrique dans laquelle il est recherché et des valeurs d’un ou plusieurs hyperparamètres (comme par ex. la constante de régularisation pour une SVM). Afin d’obtenir le « meilleur » modèle (pour un ensemble de données et dans une famille paramétrique fixée, comme les SVM linéaires) il est nécessaire d’explorer l’espace des valeurs des hyperparamètres. Cette exploration fait en général appel à une grille multidimensionnelle (une dimension par hyperparamètre) et porte dans ce cas le nom grid-search. Chaque point de la grille correspond à une combinaison particulière de valeurs pour les différents hyperparamètres. Chaque combinaison sera caractérisée par le score de validation croisée obtenu pour les modèles qui possèdent ces valeurs des hyperparamètres (avec la validation croisée k-fold, chacun de ces modèles apprend sur k-1 parties des données et est évalué sur la k-ème). Il est utile de revoir cette section du cours. Les explications de la mise en oeuvre dans Spark se trouvent ici.

Parmi les différentes combinaisons de la grille sera retenue celle qui présente le meilleur score de validation croisée. Ensuite, un nouveau modèle est appris sur la totalité des données d’apprentissage, en utilisant pour les hyperparamètres la meilleure combinaison trouvée par grid-search. Les performances de généralisation de ce modèle peuvent être estimées sur des données de test mises de côté au départ.

Nous définissons d’abord une grille de recherche pour les hyperparamètres en nous servant de ParamGridBuilder(). Pour chaque hyperparamètre exploré il est nécessaire d’indiquer avec .addGrid() les valeurs à inclure dans la grille.

Est ensuite définie l’instance de CrossValidator() qui est employée pour exlorer les combinaisons de valeurs de la grille. Il est nécessaire d’indiquer à quel estimateur l’appliquer (ici c’est au pipeline qui contient seulement une SVM linéaire), avec quelles valeurs pour les (hyper)paramètres (ici la grille paramGrid), quel type de validation croisée employer (ici k-fold avec k = 5) et comment évaluer les résultats (ici avec une instance de BinaryClassificationEvaluator qui utilise par défaut la métrique areaUnderROC (AUC), l’aire sous la courbe ROC).

import org.apache.spark.ml.tuning.{CrossValidator, ParamGridBuilder}
import org.apache.spark.ml.evaluation.BinaryClassificationEvaluator

// Construction de la grille de (hyper)paramètres utilisée pour grid search
// Une composante est ajoutée avec .addGrid() pour chaque (hyper)paramètre à explorer
val paramGrid = new ParamGridBuilder()
                           .addGrid(linSvc.regParam,
                                    Array(0.02, 0.05, 0.1, 0.2, 0.3, 0.4, 0.5))
                           .build()

// Définition de l'instance de CrossValidator : à quel estimateur l'appliquer,
//  avec quels (hyper)paramètres, combien de folds, comment évaluer les résultats
val cv = new CrossValidator().setEstimator(pipeline)
                             .setEstimatorParamMaps(paramGrid)
                             .setNumFolds(5)
                             .setEvaluator(new BinaryClassificationEvaluator())

// Construction et évaluation par validation croisée des modèles correspondant
//   à toutes les combinaisons de valeurs de (hyper)paramètres de paramGrid
val cvSVCmodel = cv.fit(apprentissage)

Une fois l’entraînement terminé, il est possible de récupérer les valeurs d’aire sous la courbe ROC pour tous les modèles :

// Afficher les valeurs AUC obtenues pour les combinaisons de la grille
cvSVCmodel.avgMetrics

Et également d’afficher la meilleure combinaison d’hyperparamètres :

// Afficher les meilleures valeurs pour les hyperparamètres
cvSVCmodel.getEstimatorParamMaps.zip(cvSVCmodel.avgMetrics).maxBy(_._2)._1

Le meilleur modèle peut être sauvegardé pour être réutilisé par la suite :

// Enregistrement du meilleur modèle
cvSVCmodel.write.save("cvSVCmodel")

L’affichage avec .avgMetrics des valeurs AUC obtenues pour toutes les combinaisons de la grille est fait dans l’ordre dans lequel la grille des hyperparamètres a été déclarée. Il est possible de voir la valeur la plus élevée, qui correspond à la meilleure combinaison de valeurs pour les hyperparamètres.

La description du meilleur modèle obtenu et enregistré avec .write.save() est visible dans cvSVCmodel/bestModel/stages/0_linearsvc_.../metadata/part-00000 (remplacer les ... par la séquence de caractère qui complète le nom du répertoire sur votre ordinateur). Il est utile de lire cette description, regarder notamment la valeur de "regParam".

Évaluation du modèle obtenu avec les valeurs optimales des hyper-paramètres

Le meilleur modèle (obtenu avec la meilleure combinaison de valeurs pour les hyperparamètres après un nouvel apprentissage sur la totalité des données d’apprentissage) est accessible dans cvSVCmodel. Nous pouvons évaluer ses performances sur les données d’apprentissage et sur les données de test. Pour cela, il est d’abord nécessaire de s’en servir avec .transform() pour obtenir les prédictions de ce modèle sur les deux ensembles de données.

// Calculer les prédictions sur les données d'apprentissage
val resApp = cvSVCmodel.transform(apprentissage)

// Calculer les prédictions sur les données de test
val resultats = cvSVCmodel.transform(test)

// Afficher le schéma du DataFrame resultats
resultats.printSchema()

// Afficher 10 lignes complètes de résultats (sans la colonne features)
resultats.select("label", "rawPrediction", "prediction").show(10, false)

Pour réaliser l’évaluation des modèles, il faut passer par l’objet Evaluator accessible via getEvaluator. Cet objet expose simplement une méthode evaluate :

// Calculer AUC sur données d'apprentissage
println(cvSVCmodel.getEvaluator.evaluate(resApp))

// Calculer AUC sur données de test
println(cvSVCmodel.getEvaluator.evaluate(resultats))

Question :

La valeur de AUC obtenue sur les données d’apprentissage avec cvSVCmodel.getEvaluator.evaluate(resApp) est-elle la même que la valeur la plus élevée de la liste issue de .avgMetrics ? Pourquoi ?

Question :

Réduisez la partition d’apprentissage par rapport à celle de test et examinez l’impact sur l’aire sous la courbe ROC du meilleur modèle sur les données de test.

Apprentissage et prédiction après réduction de la dimension des données

Cette section est optionnelle.

Nous cherchons à déterminer si une réduction de la dimension des données (quelle est la dimension des données d’origine ?) obtenue par une analyse en composantes principales (ACP, voir la séance de travaux pratiques sur l’ACP) a un impact sur les résultats. Pour cela nous pouvons inclure l’ACP, précédée du centrage et de la réduction des données, dans le pipeline.

import org.apache.spark.ml.feature.StandardScaler
import org.apache.spark.ml.feature.PCA

apprentissage.printSchema()

// Définition du scaler pour centrer et réduire les variables initiales
val scaler = new StandardScaler().setInputCol("features")
                                        .setOutputCol("scaledFeatures")
                                        .setWithStd(true)
                                        .setWithMean(true)

// Construction et application d'une nouvelle instance d'estimateur PCA
val pca = new PCA().setInputCol("scaledFeatures").setOutputCol("pcaFeatures")

// Définition de l'estimateur SVC linéaire
val linSvc = new LinearSVC().setMaxIter(10)
                                   .setFeaturesCol("pcaFeatures")
                                   .setLabelCol("label")

// Définition du pipeline (réduit ici au SVM linéaire)
val pipelineA = new Pipeline().setStages(Array(scaler, pca, linSvc))

Question

À quelles opérations mathématiques correspondent les objets scaler, pca et linSvc ?

La grille doit être modifiée afin d’inclure des valeurs pour le nombre de composantes principales à retenir :

val paramGridA = new ParamGridBuilder()
                            .addGrid(pca.k, Array(100, 20, 2))
                            .addGrid(linSvc.regParam,
                                    Array(0.05, 0.1, 0.3, 0.5))
                            .build()

val cvA = new CrossValidator().setEstimator(pipelineA)
                              .setEstimatorParamMaps(paramGridA)
                              .setNumFolds(5)
                              .setEvaluator(new BinaryClassificationEvaluator())

Nous avons réduit ici le nombre de valeurs différentes à explorer pour chaque hyperparamètre afin d’accélérer les calculs durant la séance de TP.

Il est maintenant possible de refaire la recherche des meilleures valeurs pour les hyperparamètres et d’évaluer le modèle obtenu :

val cvSVCmodelA = cvA.fit(apprentissage)

cvSVCmodelA.avgMetrics
cvSVCmodelA.getEstimatorParamMaps.zip(cvSVCmodelA.avgMetrics).maxBy(_._2)._1
// Sauvegarde du meilleur modèle
cvSVCmodel.write.save("cvSVCmodelA")
// Calcul des résultats de test
val resultatsA = cvSVCmodelA.transform(test)

// Évalution des métriques de test
cvSVCmodelA.getEvaluator.evaluate(resultatsA)

Question :

Combien de modèles sont appris au total durant cette procédure ?

Question :

Quel est ici l’impact de la réduction de la dimension des données avec une ACP ?