Travaux pratiques - Discrimination de textes avec Spark NLP

(une variante de ce TP utilisant le langage Scala)

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 des fake news.

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

Pour préparer les répertoires et télécharger les données employées dans les exemples de cette séance, ouvrez une fenêtre terminal et entrez les commandes suivantes :

%%bash
mkdir -p tpdisctxt/data
wget -nc http://cedric.cnam.fr/vertigo/Cours/RCP216/docs/fake500.csv -P tpdisctxt/data
wget -nc http://cedric.cnam.fr/vertigo/Cours/RCP216/docs/true500.csv -P tpdisctxt/data
# Lancer pyspark avec utilisation de SparkNLP :
pyspark --packages com.johnsnowlabs.nlp:spark-nlp_2.12:4.2.0

Sous Windows, créez le répertoire tpdisctxt et le sous-répertoire data, téléchargez ce premier fichier de données et ce second fichier de données dans le sous-répertoire data, ouvrez une fenêtre invite de commandes et lancez pyspark --packages com.johnsnowlabs.nlp:spark-nlp_2.12:4.2.0.

Note

Les solutions exposées nécessitent un accès Internet durant toute la session Spark qui emploie Spark NLP car différentes parties sont téléchargées lors de la session, suivant les besoins. Il est également possible de télécharger les ressources de Spark NLP en amont et d’exécuter la session Spark hors connexion, comme décrit dans cette documentation de John Snow LABS.

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:

from pyspark.sql.functions import lit
spark = SparkSession.builder.getOrCreate()

# Charge les vraies nouvelles, création d'une colonne label avec des 1.0
trueNews = spark.read.format("csv").option("header", True) \
                     .load("tpdisctxt/data/true500.csv") \
                     .withColumn("label", lit(0.0))
# Charge les fausses nouvelles, création d'une colonne label avec des 0.0
fakeNews = spark.read.format("csv").option("header", True) \
                     .load("tpdisctxt/data/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).

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 définissons é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 sparknlp
from sparknlp.base import *
from sparknlp.annotator import *

documentAssembler = 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:

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

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

tokenizer = Tokenizer() \
                  .setInputCols(["document"]) \
                  .setOutputCol("token")

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

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 les 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.

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

À nouveau, une application sur nos tokens :

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 :

embeddingsSentence = SentenceEmbeddings() \
                           .setInputCols(["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 employons un Finisher pour obtenir des résultats plus facilement compréhensibles par un humain :

embeddingsFinisher = 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 :

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 :

from pyspark.ml import Pipeline

pipelineGlove = Pipeline() \
                      .setStages([ \
                         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 :

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
allEmbeddings = resultGlove.selectExpr("finished_embeddings_glove[0]", "label")

# Partitionner les données en apprentissage (1%) et test (99%)
# (ces choix inhabituels sont faits car avec ces données
#            le problème s'avère être très facile)
partitions = allEmbeddings.randomSplit([0.01, 0.99], seed=1)
apprentissage = partitions[0]
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 :

from pyspark.ml.classification import LinearSVC

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

# Définition de la pipeline (réduite ici au SVM linéaire)
pipelineSvcGlove = Pipeline().setStages([linSvcGlove])

Comme dans la séance de TP sur les SVM linéaires, 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 :

from pyspark.ml.tuning import CrossValidator, ParamGridBuilder
from pyspark.ml.evaluation import 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
paramGrid = ParamGridBuilder().addGrid(linSvcGlove.regParam, \
                                 [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
cvSvcGlove = CrossValidator().setEstimator(pipelineSvcGlove) \
                             .setEstimatorParamMaps(paramGrid) \
                             .setNumFolds(5) \
                             .setEvaluator(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
cvSvcGloveModel = cvSvcGlove.fit(apprentissage)

Question :

Pourquoi cette étape prend aussi longtemps ?

Correction :

C’est lors de cette étape que tous les calculs sont réalisés, y compris ceux pour obtenir les encodages dans resultGlove et result.

Evaluation du modèle obtenu

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

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

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

# Calculer AUC sur données d'apprentissage
print("AUC sur train : ", cvSvcGloveModel.getEvaluator().evaluate(resultatsAppSvcGlove))

# Calculer AUC sur données de test
print("AUC sur test : ", 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 documents 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).

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

embeddingsSentence = SentenceEmbeddings() \
                           .setInputCols(["document", "bert_embeddings"]) \
                           .setOutputCol("sentence_embeddings_bert") \
                           .setPoolingStrategy("AVERAGE")

embeddingsFinisher = EmbeddingsFinisher() \
                           .setInputCols("sentence_embeddings_bert") \
                           .setOutputCols("finished_embeddings_bert") \
                           .setOutputAsVector(True) \
                           .setCleanAnnotations(False)

pipelineBert = Pipeline() \
                     .setStages([embeddings, embeddingsSentence, embeddingsFinisher \
                   ])

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
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.

partitions = resultBert.randomSplit([0.01, 0.99], seed=1)
apprentissage = partitions[0]
test = partitions[1]

Correction :

linSvcBert = LinearSVC().setMaxIter(10) \
                        .setFeaturesCol("finished_embeddings_bert[0]") \
                        .setLabelCol("label")

pipelineSvcBert = Pipeline().setStages([linSvcBert])
paramGrid = ParamGridBuilder() \
                        .addGrid(linSvcBert.regParam,  \
                                 [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
cvSvcBert = CrossValidator().setEstimator(pipelineSvcBert) \
                            .setEstimatorParamMaps(paramGrid) \
                            .setNumFolds(5) \
                            .setEvaluator(BinaryClassificationEvaluator())

cvSvcBertModel = cvSvcBert.fit(apprentissage)

resultatsAppSvcBert = cvSvcBertModel.transform(apprentissage)
resultatsSvcBert = cvSvcBertModel.transform(test)
print("AUC sur train : ", cvSvcBertModel.getEvaluator().evaluate(resultatsAppSvcBert))
print("AUC sur test : ", cvSvcBertModel.getEvaluator().evaluate(resultatsSvcBert))

Question :

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

Correction :

from pyspark.ml.classification import RandomForestClassificationModel, RandomForestClassifier

rfBert = RandomForestClassifier() \
              .setFeaturesCol("finished_embeddings_bert[0]") \
              .setLabelCol("label")

pipelineRfBert = Pipeline().setStages([rfBert])
paramGrid = ParamGridBuilder() \
                .addGrid(rfBert.maxDepth, [2, 5, 10]) \
                .addGrid(rfBert.numTrees, [10, 20]) \
                .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
cvRfBert = CrossValidator().setEstimator(pipelineRfBert) \
                           .setEstimatorParamMaps(paramGrid) \
                           .setNumFolds(5) \
                           .setEvaluator(BinaryClassificationEvaluator())

cvRfBertModel = cvRfBert.fit(apprentissage)

resultatsAppRfBert = cvRfBertModel.transform(apprentissage)
resultatsRfBert = cvRfBertModel.transform(test)
print("AUC sur train : ", cvRfBertModel.getEvaluator().evaluate(resultatsAppRfBert))
print("AUC sur test : ", cvRfBertModel.getEvaluator().evaluate(resultatsRfBert))


[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.