Travaux pratiques - SVM linéaires

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

Références externes utiles :

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 niveau de revenu annuel dépasse 50 k$. Regardez ce site pour une description des variables présentes.

Ouvrez une fenêtre terminal et récupérez les données dans un format adapté :

$ cd
$ mkdir -p tpsvm/data
$ cd tpsvm/data
$ wget http://cedric.cnam.fr/vertigo/Cours/RCP216/docs/a7.zip
$ unzip a7.zip

SVM linéaires : apprentissage et évaluation

Positionnez-vous dans le répertoire ~/tpsvm (pour cela, entrez dans une fenêtre terminal cd ~/tpsvm si ce répertoire est dans votre répertoire $HOME) et lancez spark-shell. Si la commande spark-shell n’est pas trouvée, entrez d’abord export PATH="$PATH:/opt/spark/bin" (si vous êtes sur un ordinateur de la salle de travaux pratiques) et ensuite spark-shell.

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 entrez :

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

// Partitionner les données en apprentissage (50%) et test (50%)
scala> val partitions = donnees.randomSplit(Array(0.5, 0.5))
scala> val apprentissage = partitions(0).cache()  // conservé en mémoire
scala> 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.

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

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

// Définition du pipeline (réduit ici au SVM linéaire)
scala> 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 la normalisation des variables décrivant les observations est réalisée 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).

scala> import org.apache.spark.ml.tuning.{CrossValidator, ParamGridBuilder}
scala> 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
scala> 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
scala> 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
scala> val cvSVCmodel = cv.fit(apprentissage)

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

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

// Enregistrement du meilleur modèle
scala> 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
scala> val resApp = cvSVCmodel.transform(apprentissage)

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

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

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

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

// Calculer AUC sur données de test
scala> 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 de la normalisation des données, dans le pipeline.

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

scala> apprentissage.printSchema()

// Définition du scaler pour normaliser les variables initiales
scala> val scaler = new StandardScaler().setInputCol("features")
                                        .setOutputCol("scaledFeatures")
                                        .setWithStd(true)
                                        .setWithMean(true)

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

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

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

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

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

scala> 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 :

scala> val cvSVCmodelA = cvA.fit(apprentissage)

scala> cvSVCmodelA.avgMetrics

scala> cvSVCmodelA.getEstimatorParamMaps.zip(cvSVCmodelA.avgMetrics).maxBy(_._2)._1

scala> cvSVCmodel.write.save("cvSVCmodelA")

scala> val resultatsA = cvSVCmodelA.transform(test)

scala> 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 ?