Travaux pratiques - Discrimination de textes avec Spark NLP

Références externes utiles :

Cette séance a pour objectif de montrer l’utilisation de Spark NLP pour encoder (embed) des textes courts et ensuite l’emploi de Spark ML pour construire un modèle décisionnel sur la base de ces encodages. Pour illustrer la mise en œuvre de ces concepts, nous allons tenter de détecter automatiquement les fake news.

Préparation et téléchargement des données

Tout d’abord, commençons par créer le répertoire de travail et télécharger les données :

import sys.process._

// Création du dossier
"mkdir -p tpnlp" !

// Recupération des données
"wget -nc http://cedric.cnam.fr/vertigo/Cours/RCP216/docs/fake500.csv -P tpnlp" !

"wget -nc http://cedric.cnam.fr/vertigo/Cours/RCP216/docs/true500.csv -P tpnlp" !

Afin d’utiliser Spark NLP, nous devons importer le module. Comme il s’agit d’une dépendance externe (c’est-à-dire un module qui n’est pas inclus de base dans Spark), nous devons récupérer le .jar depuis les dépôts en ligne.

%AddDeps com.johnsnowlabs.nlp spark-nlp_2.11 2.7.5 --transitive

Cette commande télécharge automatiquement SparkNLP ainsi que toutes ses dépendances.

Chargement des données

Les données employées proviennent du dataset vraies et fausses nouvelles (Kaggle). La discrimination entre les deux classes est considérée facile car des indices sont présents dans les données (par exemple, l’agence Reuters est une source fiable qui mentionnée dans la plupart des vraies nouvelles). Nous avons extrait une partie seulement de ces données (500 vraies et 500 fausses nouvelles) et éliminé certains de ces indices. De plus, seul le contenu textuel de la nouvelle est conservé. Des étiquettes de classe ont été ajoutées aux nouvelles (1 pour les fausses, à détecter par le modèle à construire, et 0 pour les vraies).

N’hésitez pas à examiner les fichiers fake500.csv et true500.csv pour vous familiariser avec le contenu du jeu de données. Le fichier fake500.csv contient 500 exemples de fausses nouvelles, tandis que le fichier true500.csv contient 500 exemples de vraies informations.

Le code ci-dessous permet de charger les données dans un DataFrame:

import org.apache.spark.sql.functions.lit

// Charge les vraies nouvelles
val trueNews = spark.read.format("csv").option("header", true).load("tpnlp/true500.csv")
                                                              .withColumn("label", lit(0.0))
// Charge les fausses nouvelles
val fakeNews = spark.read.format("csv").option("header", true).load("tpnlp/fake500.csv")
                                                              .withColumn("label", lit(1.0))

Question

Quelle est l’utilité de l’appel à la méthode .withColumn ?

Nous avons deux DataFrame, un pour les vraies nouvelles et un pour les fausses nouvelles. Nous allons les rassembler en un seul DataFrame à l’aide de la méthode .union() (voir la documentation).

val allNews = trueNews.union(fakeNews).select("label", "text")
allNews.printSchema()
allNews.count()

Construction d’encodages de textes à partir d’encodages GloVe pour les mots

Note

Attention, les modèles pré-entraînés employés sont lourds et leur simple téléchargement peut prendre du temps !

Nous allons définir étape par étape le pipeline Spark à appliquer à nos données. Tout d’abord, nous devons créer un DocumentAssembler. Cette transformation convertit les textes au format chaînes de caractères dans le format attendu par SparkNLP :

import com.johnsnowlabs.nlp.base.DocumentAssembler

val documentAssembler = new DocumentAssembler()
      .setCleanupMode("inplace")
      .setInputCol("text")
      .setOutputCol("document")

Pour illustrer la transformation, nous pouvons appliquer le DocumentAssembler sur les cinq premières lignes de notre jeu de données allNews:

val documents = documentAssembler.transform(allNews.limit(5))
documents.show(5, 100)

Ensuite, nous allons appliquer une tokenisation à l’aide de l’objet Tokenizer de SparkNLP:

import com.johnsnowlabs.nlp.annotators.Tokenizer

val tokenizer = new Tokenizer()
  .setInputCols(Array("document"))
  .setOutputCol("token")

Comme précédemment, appliquons cette transformation sur quelques lignes, pour illustrer son effet :

val tokensEch = tokenizer.fit(documents).transform(documents)
tokensEch.select("token").show(5, 100)

Les plongements lexicaux (word embeddings) pourront être calculés sur les tokens. Par défaut, WordEmbeddingsModel.pretrained() utilise un encodage GloVe (Global Vectors, [PSM14]) de dimension 100. Nous nous servirons plutôt du modèle avec des encodages de dimension 300, les bons paramètres d’appel sont WordEmbeddingsModel.pretrained("glove_6B_300", "xx"). Si le modèle proposé met trop de temps à se télécharger, vous pouvez utiliser celui par défaut (avec simplement WordEmbeddingsModel.pretrained()). Vous pouvez trouver les différents modèles disponibles sur le site du projet GloVe. Pour rappel, les encodages GloVe sont produits pour mots entiers et ne peuvent pas représenter des mots « hors vocabulaire ». Le choix fait dans Spark NLP est de représenter les mots hors vocabulaire par des vecteurs nuls. N’hésitez pas à consulter la documentation de SparkNLP sur les WordEmbeddings pour plus d’informations.

import com.johnsnowlabs.nlp.embeddings.WordEmbeddingsModel

val embeddings = WordEmbeddingsModel.pretrained("glove_6B_300", "xx")
                                    .setInputCols("document", "token")
                                    .setOutputCol("glove_embeddings")

À nouveau, une applications sur nos tokens :

val embedsEch = embeddings.transform(tokensEch)
embedsEch.select("glove_embeddings").show(5, 200)

Ces représentations sont obtenues au niveau de chaque token, c’est-à-dire chaque élément du texte. Pour compresser l’information et obtenir une représentation plus efficace du document, nous allons chercher des embeddings de phrase, à l’aide de l’outil SentenceEmbeddings de SparkNLP.

import com.johnsnowlabs.nlp.embeddings.SentenceEmbeddings
val embeddingsSentence = new SentenceEmbeddings()
                     .setInputCols(Array("document", "glove_embeddings"))
                     .setOutputCol("sentence_embeddings_glove")
                     .setPoolingStrategy("AVERAGE")

Dans cas, la stratégie pour créer un embedding de phrase consiste à simplement faire la moyenne de tous les vecteurs représentant chaque token de la phrase ("AVERAGE"). Comme dans le précédent TP sur la fouille de texte, nous allons utiliser un Finisher pour obtenir des résultats plus facilement compréhensibles par un humain :

import com.johnsnowlabs.nlp.EmbeddingsFinisher

val embeddingsFinisher = new EmbeddingsFinisher()
  .setInputCols("sentence_embeddings_glove")
  .setOutputCols("finished_embeddings_glove")
  .setOutputAsVector(true)
  .setCleanAnnotations(false)

Nous pouvons visualiser quelques embeddings de phrase. Ce sont des vecteurs de grande dimension, donc nous ne pouvons pour l’instant pas en dire grand chose :

val sentencesEch = embeddingsSentence.transform(embedsEch)
embeddingsFinisher.transform(sentencesEch).select("finished_embeddings_glove").show(5, 200)

Afin de ne pas avoir à réappliquer toutes ces étapes manuellement, nous consolidons l’ensemble de ces traitements un Pipeline Spark :

import org.apache.spark.ml.Pipeline

val pipelineGlove = new Pipeline()
  .setStages(Array(
    documentAssembler,
    tokenizer,
    embeddings,
    embeddingsSentence,
    embeddingsFinisher
  ))

Nous pouvons obtenir le résultat des embeddings de phrase GloVe sur tout notre jeu de données en une seule commande :

val resultGlove = pipelineGlove.fit(allNews).transform(allNews).cache()
resultGlove.select("finished_embeddings_glove").show(10, 100)

Construction de modèle sur les encodages des documents

Tout ce prétraitement nous a permis d’obtenir une représentation vectorielle de nos documents. Chaque article de presse en ligne est désomais représenté par un vecteur de dimension 300 (ou 100, selon le modèle GloVe utilisé).

Reste maintenant à entraîner un modèle prédictif pour classer les nouvelles en vraies ou fausses. Commençons par partitionner le DataFrame en apprentissage et évaluation (train et test) :

// On ne garde que le vecteur qui décrit le texte et l'étiquette
val allEmbeddings = resultGlove.selectExpr("finished_embeddings_glove[0]", "label")
// Partitionner les données en apprentissage (1%) et test (99%)
// (ces choix inhabituels sont faits car le problème s'avère être très facile)
val partitions = allEmbeddings.randomSplit(Array(0.01, 0.99), seed=1L)
val apprentissage = partitions(0)
val test = partitions(1)

Le problème étant assez facile, nous allons n’utiliser que 1% des données en apprentissage.

Nous construisons ensuite un modèle de type SVM linéaire sur les encodages GloVe pour classer les nouvelles en vraies ou fausses :

import org.apache.spark.ml.classification.LinearSVC

// Définition de l'estimateur SVC linéaire
val linSvcGlove = new LinearSVC().setMaxIter(10)
                                 .setFeaturesCol("finished_embeddings_glove[0]")
                                 .setLabelCol("label")

// Définition de la pipeline (réduite ici au SVM linéaire)
val pipelineSvcGlove = new Pipeline().setStages(Array(linSvcGlove))

Comme la semaine passée, nous devons chercher la bonne valeur du paramètre de régularisation de la SVM. Pour cela, nous réaliser une recherche d’hyperparamètres par grille, avec validation croisée :

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(linSvcGlove.regParam,
                                 Array(0.02, 0.1, 0.2, 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 cvSvcGlove = new CrossValidator().setEstimator(pipelineSvcGlove)
                            .setEstimatorParamMaps(paramGrid)
                            .setNumFolds(5)
                            .setEvaluator(new BinaryClassificationEvaluator())

Nous pouvons désormais lancer l’apprentissage :

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

Question :

Pourquoi cette étape prend aussi longtemps ?

Evaluation du modèle obtenu

Le modèle obtenu peut maintenant être évalué :

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

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

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

// Calculer AUC sur données de test
println(cvSvcGloveModel.getEvaluator.evaluate(resultatsSvcGlove))

Construction d’encodages de textes à partir d’encodages BERT pour les mots

GloVe n’est pas le seul plongement lexical qui existe. Il y a de nombreux autres modèles de langage permettant de convertir du texte en une représentation vectorielle. Nous nous servirons maintenant des encodages BERT (Bidirectional Encoder Representations from Transformers, [DCL18]) des mots. Par défaut c’est le modèle small_bert_L2_768 qui est employé. Vous pouvez examiner les modèles BERT disponibles pour l’anglais. Pour rappel, les encodages BERT sont obtenus pour des parties de mots et il est donc possible de représenter des mots hors vocabulaire (contrairement à GloVe qui n’a pas cette capacité).

Pour éviter de refaire une passe des DocumentAssembler et Tokenizer sur les dcuments de départ, nous travaillerons directement sur le DataFrame resultGlove qui contient les colonnes nécessaires token et document. Nous construisons le pipeline pour la transformation avec BERT de la même façon que précédemment (n’hésitez pas à le reprendre étape par étape si cette construction n’est pas claire).

import com.johnsnowlabs.nlp.embeddings.BertEmbeddings

val embeddings = BertEmbeddings.pretrained("small_bert_L2_128", "en")
                               .setInputCols("token", "document")
                               .setOutputCol("bert_embeddings")

val embeddingsSentence = new SentenceEmbeddings()
                               .setInputCols(Array("document", "bert_embeddings"))
                               .setOutputCol("sentence_embeddings_bert")
                               .setPoolingStrategy("AVERAGE")

val embeddingsFinisher = new EmbeddingsFinisher()
                               .setInputCols("sentence_embeddings_bert")
                               .setOutputCols("finished_embeddings_bert")
                               .setOutputAsVector(true)
                               .setCleanAnnotations(false)

val pipelineBert = new Pipeline()
                      .setStages(Array(embeddings, embeddingsSentence, embeddingsFinisher
                   ))

val result = pipelineBert.fit(resultGlove).transform(resultGlove)
                         .selectExpr("label", "finished_embeddings_glove[0]", "finished_embeddings_bert[0]")
                         .cache()    // très important que ces données soient gardées en mémoire
val resultBert = result.select("label", "finished_embeddings_bert[0]")

Pour approfondir

Question :

En vous inspirant du code ci-dessus, construisez et évaluez un modèle de type SVM linéaire sur les encodages BERT.

val partitions = resultBert.randomSplit(Array(0.01, 0.99), seed=1L)
val apprentissage = partitions(0)
val test = partitions(1)

Question :

Construisez et évaluez un modèle de type forêt aléatoire sur les encodages GloVe et ensuite sur les encodages BERT.



CYK18

Cer, D., Y. Yang, S.-y. Kong, N. Hua, N. Limtiaco, R. St. John, N. Constant, M. Guajardo-Cespedes, S. Yuan, C. Tar, B. Strope, and R. Kurzweil. Universal sentence encoder for English. In Empirical Methods in Natural Language Processing (EMNLP): System Demonstrations, pp 169–174, 2018.

DCL18

Devlin, J., M.-W. Chang, K. Lee, and K. Toutanova. BERT: Pre-training of deep bidirectional transformers for language understanding, 2018.

PSM14

Pennington, J., R. Socher, and C. D. Manning. Glove: Global vectors for word representation. In Empirical Methods in Natural Language Processing (EMNLP), pp 1532–1543, 2014.