Travaux pratiques - Fouille de textes 2 : GloVe vs BERT avec SparkNLP¶
Références externes utiles :
L’objectif de cette séance est de comparer, sur une tâche d’analyse de sentiments, deux représentations vectorielles de textes dans SparkNLP : les plongements lexicaux GloVe (vus dans le TP précédent) et les plongements contextuels BERT.
Nous utilisons le jeu IMDb Large Movie Review [ML11], constitué de 25 000 critiques de films positives et 25 000 négatives. Ce problème est difficile : les deux classes partagent le même vocabulaire et le même style d’écriture, seule la tonalité change. Des phénomènes comme l’ironie, la négation longue portée (« ce film n’est pas sans défauts ») ou la polysémie de certains adjectifs évaluatifs rendent la tâche sensible à la qualité de la représentation contextuelle.
Note
Les téléchargements BERT sont volumineux (~400 Mo pour bert_base_cased), le téléchargement peut prendre un peu de temps.
Mise en place¶
%%bash
mkdir -p tpFouilleTexteLLM/data
# IMDb : format tar.gz disponible sur le site Stanford
wget -q -nc https://ai.stanford.edu/~amaas/data/sentiment/aclImdb_v1.tar.gz -P tpFouilleTexteLLM/data/
tar -xzf tpFouilleTexteLLM/data/aclImdb_v1.tar.gz -C tpFouilleTexteLLM/data/
pyspark --packages com.johnsnowlabs.nlp:spark-nlp_2.12:6.4.2
Chargement et exploration des données¶
Le jeu IMDb est disponible sous la forme d’un répertoire de fichiers texte (un fichier par critique). La structure est aclImdb/{train,test}/{pos,neg}/.
from pyspark.sql import SparkSession
from pyspark.sql.functions import lit, col, length
from pyspark.sql.types import DoubleType
spark = SparkSession.builder.getOrCreate()
def charger_imdb(repertoire, label):
"""Charge un répertoire de critiques et ajoute une étiquette."""
return spark.read.text(repertoire) \
.withColumnRenamed("value", "text") \
.withColumn("label", lit(label).cast(DoubleType()))
train_pos = charger_imdb("tpFouilleTexteLLM/data/aclImdb/train/pos", 1.0)
train_neg = charger_imdb("tpFouilleTexteLLM/data/aclImdb/train/neg", 0.0)
test_pos = charger_imdb("tpFouilleTexteLLM/data/aclImdb/test/pos", 1.0)
test_neg = charger_imdb("tpFouilleTexteLLM/data/aclImdb/test/neg", 0.0)
train_data = train_pos.union(train_neg).cache()
test_data = test_pos.union(test_neg).cache()
print(f"Entraînement : {train_data.count()} | Test : {test_data.count()}")
print(f"Etiquettes dans train :")
train_data.groupBy("label").count().show()
Question :
Quelle est la distribution des classes ? Le jeu est-il équilibré ? Calculez la longueur moyenne des critiques (en caractères). Y a-t-il une différence notable de longueur entre critiques positives et négatives ?
Pour garder des temps de calcul raisonnables nous travaillons sur un échantillon de 4 000 critiques en entraînement (2 000 par classe) et évaluons sur la totalité des 25 000 critiques de test :
fractions = {0.0: 2000/12500, 1.0: 2000/12500}
train_ech = train_data.stat.sampleBy("label", fractions, seed=42).cache()
print(f"Échantillon d'entraînement : {train_ech.count()} critiques")
Partie 1 : Baseline avec GloVe¶
Nous construisons d’abord une baseline avec GloVe, en reprenant la méthodologie du TP précédent.
import time
from pyspark.ml import Pipeline
from sparknlp.base import DocumentAssembler, EmbeddingsFinisher
from sparknlp.annotator import (Tokenizer, StopWordsCleaner,
WordEmbeddingsModel, SentenceEmbeddings)
# Pipeline GloVe (identique au TP précédent)
documentAssembler = DocumentAssembler() \
.setInputCol("text").setOutputCol("document") \
.setCleanupMode("shrink")
tokenizer = Tokenizer() \
.setInputCols(["document"]).setOutputCol("token")
stopWords = StopWordsCleaner.pretrained("stopwords_en", "en") \
.setInputCols(["token"]).setOutputCol("token_clean")
# Si les traitements suivants prennent trop de temps avec glove_6B_300,
# employer glove_100d (à comparer ensuite à small_bert_L2_128)
#glove = WordEmbeddingsModel.pretrained("glove_100d", "en") \
# .setInputCols(["document", "token_clean"]) \
# .setOutputCol("glove_embeddings")
glove = WordEmbeddingsModel.pretrained("glove_6B_300", "xx") \
.setInputCols(["document", "token_clean"]) \
.setOutputCol("glove_embeddings")
sentenceEmb = SentenceEmbeddings() \
.setInputCols(["document", "glove_embeddings"]) \
.setOutputCol("sentence_embeddings") \
.setPoolingStrategy("AVERAGE")
finisher = EmbeddingsFinisher() \
.setInputCols(["sentence_embeddings"]) \
.setOutputCols(["features"]) \
.setOutputAsVector(True) \
.setCleanAnnotations(False)
pipeline_glove = Pipeline(stages=[
documentAssembler, tokenizer, stopWords,
glove, sentenceEmb, finisher
])
t0 = time.time()
modele_glove_pipe = pipeline_glove.fit(train_ech)
train_glove = modele_glove_pipe.transform(train_ech) \
.select("label", col("features")[0].alias("features")).cache()
test_glove = modele_glove_pipe.transform(test_data) \
.select("label", col("features")[0].alias("features")).cache()
train_glove.count(); test_glove.count()
t_glove_emb = time.time() - t0
print(f"Embeddings GloVe calculés en {t_glove_emb:.1f} s")
Question :
Pourquoi supprime-t-on les stop words avant de calculer la moyenne des vecteurs GloVe ? Cette suppression pourrait-elle être préjudiciable pour l’analyse de sentiments (pensez aux expressions comme « not bad », « not at all disappointing »).
Classification GloVe¶
from pyspark.ml.classification import LogisticRegression
from pyspark.ml.tuning import CrossValidator, ParamGridBuilder
from pyspark.ml.evaluation import BinaryClassificationEvaluator
import numpy as np
# Régression logistique (binaire)
lr_glove = LogisticRegression(
featuresCol="features", labelCol="label",
maxIter=50, family="binomial")
grid_glove = ParamGridBuilder() \
.addGrid(lr_glove.regParam, [0.01, 0.1]) \
.build()
evaluateur = BinaryClassificationEvaluator(
labelCol="label", metricName="areaUnderROC")
cv_glove = CrossValidator(
estimator=lr_glove,
estimatorParamMaps=grid_glove,
evaluator=evaluateur,
numFolds=5, seed=42)
t0 = time.time()
modele_glove = cv_glove.fit(train_glove)
t_glove_clf = time.time() - t0
print(f"Classification GloVe : {t_glove_clf:.1f} s")
print(f"Meilleur regParam : "
f"{modele_glove.getEstimatorParamMaps()[np.argmax(modele_glove.avgMetrics)][lr_glove.regParam]}")
glove_predictions = modele_glove.transform(test_glove).cache()
auc_glove = evaluateur.evaluate(glove_predictions)
print(f"AUC GloVe sur test : {auc_glove:.4f}")
Question :
Pourquoi utilise-t-on l’AUC (aire sous la courbe ROC) plutôt que accuracy pour évaluer ce modèle ? Quelle valeur d’AUC correspondrait à un classifieur aléatoire ? Et à un classifieur parfait ?
Partie 2 : Embeddings contextuels BERT¶
Nous remplaçons maintenant GloVe par BERT. La différence fondamentale est que BERT produit une représentation différente pour chaque token selon son contexte dans la phrase.
Tokenisation BERT¶
BERT emploie une tokenisation par sous-mots, différente de la tokenisation classique. SparkNLP fournit un annotateur BertEmbeddings qui gère cette tokenisation en interne. Si la construction des embeddings de textes dure trop longtemps avec bert_base_cased, utiliser plutôt small_bert_L2_128 en limitant le nombre de tokens par critique à 128 (en anglais, 128 tokens BERT ~ 90-100 mots) avec .setMaxSentenceLength(128).
from sparknlp.annotator import BertEmbeddings, SentenceDetector
# Modèle BERT léger : small_bert_L2_128 (2 couches, dimension 128)
#bert = BertEmbeddings.pretrained("small_bert_L2_128", "en") \
# .setInputCols(["document", "token"]) \
# .setOutputCol("bert_embeddings") \
# .setMaxSentenceLength(128) # tronquer à 128 tokens ~ 90-100 mots
# Modèle BERT : bert_base_cased (12 couches, dimension 768)
bert = BertEmbeddings.pretrained("bert_base_cased", "en") \
.setInputCols(["document", "token"]) \
.setOutputCol("bert_embeddings") \
.setMaxSentenceLength(512) # tronquer à 512 tokens
sentenceEmbBert = SentenceEmbeddings() \
.setInputCols(["document", "bert_embeddings"]) \
.setOutputCol("sentence_embeddings_bert") \
.setPoolingStrategy("AVERAGE")
finisherBert = EmbeddingsFinisher() \
.setInputCols(["sentence_embeddings_bert"]) \
.setOutputCols(["features"]) \
.setOutputAsVector(True) \
.setCleanAnnotations(False)
# Pas de stop words supprimés pour BERT : la séquence entière est importante
pipeline_bert = Pipeline(stages=[
documentAssembler, tokenizer, # réutilisation depuis Partie 1
bert, sentenceEmbBert, finisherBert
])
Question :
Pourquoi ne supprime-t-on pas les stop words dans le pipeline BERT, alors qu’on les supprimait dans le pipeline GloVe ?
Question :
Le paramètre setMaxSentenceLength(n) tronque les critiques à n tokens (sous-mots). Une critique IMDb moyenne fait environ 230 mots. Quel est l’impact attendu de cette troncature sur les performances ? Comment pourrions-nous atténuer cet effet ?
t0 = time.time()
modele_bert_pipe = pipeline_bert.fit(train_ech)
train_bert = modele_bert_pipe.transform(train_ech) \
.select("label", col("features")[0].alias("features")).cache()
test_bert = modele_bert_pipe.transform(test_data) \
.select("label", col("features")[0].alias("features")).cache()
train_bert.count(); test_bert.count()
t_bert_emb = time.time() - t0
print(f"Embeddings BERT calculés en {t_bert_emb:.1f} s")
Classification BERT¶
lr_bert = LogisticRegression(
featuresCol="features", labelCol="label",
maxIter=50, family="binomial")
grid_bert = ParamGridBuilder() \
.addGrid(lr_bert.regParam, [0.01, 0.1]) \
.build()
cv_bert = CrossValidator(
estimator=lr_bert,
estimatorParamMaps=grid_bert,
evaluator=evaluateur,
numFolds=5, seed=42)
t0 = time.time()
modele_bert = cv_bert.fit(train_bert)
t_bert_clf = time.time() - t0
print(f"Classification BERT : {t_bert_clf:.1f} s")
print(f"Meilleur regParam : "
f"{modele_bert.getEstimatorParamMaps()[np.argmax(modele_bert.avgMetrics)][lr_bert.regParam]}")
bert_predictions = modele_bert.transform(test_bert).cache()
auc_bert = evaluateur.evaluate(bert_predictions)
print(f"AUC BERT sur test : {auc_bert:.4f}")
Partie 3 : Comparaison et analyse qualitative¶
Tableau comparatif¶
print(f"{'Méthode':<20} {'AUC test':>10} {'Temps emb. (s)':>16} {'Temps clf. (s)':>16}")
print("-" * 80)
print(f"{'GloVe':<20} {auc_glove:>10.4f} {t_glove_emb:>16.1f} {t_glove_clf:>16.1f}")
print(f"{'BERT':<20} {auc_bert:>10.4f} {t_bert_emb:>16.1f} {t_bert_clf:>16.1f}")
Question :
Interprétez les différences observées entre GloVe et BERT sur les aspects suivants : qualité de la représentation (à travers l’AUC), coût de calcul (temps de embedding), dimension du vecteur. Le gain de BERT justifie-t-il son coût supplémentaire ?
Analyse qualitative : exemples différenciateurs¶
Identifions des critiques positives où les deux modèles divergent : cas où BERT réussit (score > 0.5) mais GloVe échoue (score < 0.5), et inversement.
from pyspark.sql.functions import abs as F_abs
from pyspark.ml.functions import vector_to_array
# Récupérer les scores de confiance (probabilité de classe positive) pour chaque modèle
preds_glove = glove_predictions.select("label", vector_to_array(col("probability"))[1].alias("score_glove"))
preds_bert = bert_predictions.select("label", vector_to_array(col("probability"))[1].alias("score_bert"))
# Joindre sur l'indice (les DataFrames ont le même ordre car même split)
from pyspark.sql.functions import monotonically_increasing_id
preds_glove = preds_glove.withColumn("id", monotonically_increasing_id())
preds_bert = preds_bert.withColumn("id", monotonically_increasing_id())
comparaison = preds_glove.drop("label").join(preds_bert, "id") \
.withColumn("delta", F_abs(
col("score_bert") - col("score_glove")))
# Cas où BERT prédit correctement (p > 0.5) et GloVe se trompe (p < 0.5) sur une critique positive
bert_gagne = comparaison.filter(
(col("label") == 1.0) &
(col("score_bert") > 0.5) &
(col("score_glove") < 0.5)
).orderBy("delta", ascending=False).cache()
print(f"Nombre cas où BERT prédit bien et GloVe se trompe sur une critique positive : {bert_gagne.count()} ")
# Récupérer les textes correspondants
test_avec_id = test_data.withColumn("id", monotonically_increasing_id())
bert_gagne_texte = bert_gagne.join(test_avec_id, "id") \
.select("text", "score_glove", "score_bert")
bert_gagne_texte.show(truncate=200)
Le même type d’analyse peut être réalisé pour les critiques négatives.
Question :
Examinez les critiques où BERT réussit et GloVe échoue. Identifiez les constructions linguistiques en cause : ironie, négation, comparaison, polysémie. Expliquez pourquoi l’attention de BERT permet de mieux traiter ces constructions par rapport à la moyenne des vecteurs GloVe.
# Cas inverses : GloVe réussit, BERT échoue
glove_gagne = comparaison.filter(
(col("label") == 1.0) &
(col("score_glove") > 0.5) &
(col("score_bert") < 0.5)
).orderBy("delta", ascending=False).cache()
print(f"Nombre cas où GloVe prédit bien et BERT se trompe sur une critique positive : {glove_gagne.count()} ")
glove_gagne_texte = glove_gagne.join(test_avec_id, "id") \
.select("text", "score_glove", "score_bert")
glove_gagne_texte.show(truncate=200)
Question :
Trouvez-vous des cas où GloVe surpasse BERT ? Si oui, proposez une explication.
Courbes ROC¶
import matplotlib
#matplotlib.use('Agg')
import matplotlib.pyplot as plt
from sklearn.metrics import roc_auc_score, roc_curve
glove_y_score = glove_predictions.select(vector_to_array("probability")[1]).rdd.keys().collect()
glove_y_true = glove_predictions.select("label").rdd.keys().collect()
glove_fpr, glove_tpr, glove_thresholds = roc_curve(glove_y_true, glove_y_score)
bert_y_score = bert_predictions.select(vector_to_array("probability")[1]).rdd.keys().collect()
bert_y_true = bert_predictions.select("label").rdd.keys().collect()
bert_fpr, bert_tpr, bert_thresholds = roc_curve(bert_y_true, bert_y_score)
fig, ax = plt.subplots(figsize=(7, 6))
plt.plot([0, 1], [0, 1], "k--", label="Aléatoire")
plt.plot(glove_fpr, glove_tpr,
label=f"GloVe (AUC={roc_auc_score(glove_y_true, glove_y_score):.3f})")
plt.plot(bert_fpr, bert_tpr,
label=f"BERT (AUC={roc_auc_score(bert_y_true, bert_y_score):.3f})")
plt.xlabel("Taux de faux positifs (1 - spécificité)")
plt.ylabel("Taux de vrais positifs (sensibilité)")
plt.title("Courbes ROC pour IMDb Sentiment Analysis")
plt.legend()
plt.tight_layout()
plt.savefig("tpFouilleTexteLLM/roc_glove_vs_bert.png", dpi=150)
plt.show()
print("Figure sauvegardée dans tpFouilleTexteLLM/roc_glove_vs_bert.png")
Pour obtenir les courbes ROC sur les données de test nous avons opté ici pour l’utilisation de fonctions issues de Scikit-learn. Le modèle de régression logistique qui a été appris peut fournir les tableaux de FPR et TPR permettant de tracer la courbe ROC (modele_glove.bestModel.summary.roc.select("FPR", "TPR").toPandas()), mais pour les données d’apprentissage !
Question (bilan) :
Résumez les différences fondamentales entre GloVe et BERT en termes de (1) nature de la représentation (statique vs contextuelle), (2) gestion des mots hors vocabulaire, (3) capacité à capturer les relations syntaxiques, (4) coût de calcul. Pour quels types de tâches et de contraintes préféreriez-vous l’un ou l’autre ?
Références¶
Maas, A. L., R. E. Daly, P. T. Pham, D. Huang, A. Y. Ng, and C. Potts. Learning word vectors for sentiment analysis. In Proc. 49th Annual Meeting of the Association for Computational Linguistics, pages 142–150, 2011.
Devlin, J., M.-W. Chang, K. Lee, and K. Toutanova. BERT: Pre-training of deep bidirectional transformers for language understanding. In Proc. NAACL-HLT 2019, pages 4171–4186, 2019.
Pennington, J., R. Socher, and C. D. Manning. GloVe: Global vectors for word representation. In EMNLP, pages 1532–1543, 2014.
Vaswani, A., N. Shazeer, N. Parmar, J. Uszkoreit, L. Jones, A. N. Gomez, L. Kaiser, and I. Polosukhin. Attention is all you need. Advances in Neural Information Processing Systems 30, 2017.