NLP & fastai | Sentiment Classification
Ce post concerne les vidéos 4 à 5 du cours fastai de Rachel Thomas sur NLP (A code-first introduction to NLP) et la seconde partie de la vidéo 10 (notes de cours) du cours de Jeremy Howard sur Introduction to Machine Learning for Coders. Son objectif est d’expliquer les concepts clés du Sentiment Classification (Naïve Bayes, Logistic Regression, NGrams) présentés dans cette vidéo et son notebook associé.
Autres posts de la série NLP & fastai: Topic Modeling | Language Model | Transfer Learning | ULMFiT | MultiFit | French Language Model | Portuguese Model Language | RNN | LSTM & GRU | SentencePiece | Sequence-to-Sequence Model (seq2seq) | Attention Mechanism | Transformer Model | GPT-2
Motivation
De nombreuses applications NLP nécessitent d’utiliser une technique de Text Classification comme par exemple la classification de spams, d’emails par sujet, de produits/catégories suivant leur description ou encore de sentiments (Sentiment Classification) pour des commentaires sur les réseaux sociaux, avis sur des produits, critiques sur des films, etc.
L’objet de ce post est de présenter les 2 techniques de cette partie du cours de Rachel Thomas sur le Sentiment Classification: Naïve Bayes et Logistic Regression avec des NGrams variants de 1 à 3.
Il est à noter que comme pour les techniques SVD et NMF vues sur le Topic Modeling (cf. post), ces techniques reposent sur la fréquence d’occurrence des mots dans le corpus (ou au moins de la présence ou non des mots dans la version binaire de ces techniques) et sont donc statistiques, pas sémantiques: dans leurs principes, elles ne cherchent pas à comprendre les textes, même si leur finalité est de nous aider à meilleure compréhension sémantique.
Vue d’ensemble des 2 techniques de Sentiment Classification
Les 2 techniques étudiées issues ici s’appliquent sur une matrice Document-Term précédemment calculée mais au lieu d’utiliser une matrice Document-Term qui ne prend en compte que des tokens représentant une entité (1 mot, 1 chiffre, 1 symbole de ponctuation, etc.), on peut aussi avoir des tokens de 2 entités et plus. Le notebook du cours va jusqu’à 3 (trigrammes).
1. Classification naïve Bayésienne
En Machine Learning, les classificateurs naïfs Bayésiens appartiennent à la famille des classificateurs probabilistes (classificateurs qui calculent une probabilité d’appartenance à une classe).
Il sont basés sur le théorème de Bayes du mathématicien et pasteur Thomas Bayes avec une hypothèse d’indépendance entre les paramètres (features) des données, cad que le poids (influence) d’un paramètre sur la décision de classification ne dépend pas des autres paramètres.
Appliquer à la classification textuelle, par exemple de sentiment négatif/positif, un classificateur naïf Bayésien 1Gram fait l’hypothèse que chaque mot (paramètre) dans la classification a une valeur intrinsèque indépendante des autres mots. Il suffit donc de calculer son poids dans la classification à partir d’un corpus étiqueté pour pouvoir alors classifier de nouveaux textes. Il est à noter que ces poids sont calculés selon la fréquence de leur occurrence sauf pour un classificateur naïf Bayésien Binaire où les poids (0 ou 1) ne montrent que la présence ou non d’un mot.
Même si cette hypothèse d’indépendance est fausse, les classificateurs naïfs Bayésiens donnent étonnement de bons résultats en classification textuelle (mais moins bon que les algorithmes boosted trees ou random forests utilisés depuis 2006) et ne nécessitent pas beaucoup de données d’apprentissage pour donner des résultats utilisables.
2. Régression Logistique
La classification par régression logistique consiste à utiliser un modèle de perceptron (classificateur linéaire) dont les poids vont être appris et dont la valeur de sortie est mise en entrée d’une fonction logistique (sigmoïd), ce qui la transforme ainsi en probabilité (ie, valeur entre 0 et 1) permettant le classement.
La notion de régression exprime quant à elle qu’il y a apprentissage des valeurs des poids du classificateur par utilisation d’un algorithme d’apprentissage (Gradient Descent) et à partir des données étiquetées: à partir de données connues, on “régresse” (ou: on apprend/trouve) vers les valeurs correspondantes des poids du classificateur. A lire: très bonne Introduction to Logistic Regression.
Implémentation en Python
Le code ci-dessous utilise Python et différentes bibliothèques dans un Jupyter Notebook. Nous n’y utilisons pas toutes les options possibles de codage des techniques de Sentiment Classification afin d’attirer l’attention du lecteur sur les points essentiels.
Les 4 étapes basiques d’un code de Sentiment Classification que nous détaillons ci-après sont ainsi les suivantes:
- Initialisation (importation des bibliothèques de notre environnement de travail)
- Données: importation et vérification
- Tokénisation, Exploration et Matrice Document-Term (tokénisation des données, Exploration puis Modélisation des données en une matrice de tokens qui pourra être utilisée par les algorithmes de Sentiment Classification)
- Naïve Bayes et Logistic Regression avec NGrams de 1 et 3 (application des algorithmes de Sentiment Classification)
Voici à présent des explications détaillées sur ces 4 étapes.
1. Initialisation
Il s’agit de construire notre environnement de travail en important les bibliothèques que nous utiliserons dans le Jupyter Notebook.
Nous allons importer la bibliothèque fastai v1 (et son module fastai.text) qui permet de manipuler des corpus de texte (importation, séparation en datasets train et valid, obtention des classes négatif/positif, création du vocabulaire des tokens, création du DataBunch) et imposer l’autoreload des fichiers de la bibliothèque ainsi que l’affichage des graphiques matplotlib dans le notebook.
Note: pour l’installation de fastai v1 sur votre ordinateur Windows 10, lire “How to install fastai v1 on Windows 10”.
Enfin, voici le code correspondant à implémenter:
# import fastai and fastai text
from fastai import *
from fastai.text import *# import
import sklearn.feature_extraction.text as sklearn_text# reload all modules before running a python code
%reload_ext autoreload
%autoreload 2# plot graphics in the notebook
%matplotlib inline
2. Données: importation et vérification
Il nous faut importer nos données textuelles puis, avant de les manipuler, il s’agit de vérifier leur bonne importation.
Note: comme bonne pratique, il est recommandé d’importer d’abord un échantillon des données afin de développer notre code avant de l’appliquer à l’ensemble des données.
Nous utilisons ici le grand jeu de données de critiques de films d’IMDB (100 000 movie reviews). Nous utiliserons la version hébergée dans le cadre des jeux de données fast.ai sur AWS Open Datasets.
Le jeu de données contient un nombre identique de critiques positives et négatives qui sont les plus polarisées: un avis négatif a un score ≤ 4 sur 10, et un avis positif a un score ≥ 7 sur 10 (les avis neutres ne sont pas inclus dans le jeu de données). Le jeu de données est divisé en 2 datasets train et test dont chacun contient 25 000 critiques étiquetées (négatif/positif), plus 25 000 critiques non étiquetées.
Importation
# sample
path = untar_data(URLs.IMDB_SAMPLE)# full dataset
path = untar_data(URLs.IMDB)
Vérification
df = pd.read_csv(path/'texts.csv')
df.head()
3. Tokénisation, Exploration et Matrice Document-Term
Afin de pouvoir appliquer les techniques de Sentiment Classification à nos données textuelles, il nous faut avant toute chose les tokéniser (obtention également du vocabulaire de tokens du corpus), comprendre cette tokénisation par exploration, puis les modéliser en une matrice Document-Term.
Tokénisation
Nous utilisons ici la classe TextList de la libraire fastai.text.
movie_reviews = (TextList.from_csv(path, 'texts.csv', cols='text')
.split_from_df(col=2)
.label_from_df(cols=0))
Exploration
# get train and valid datasets tokenized by fastai, vocab of tokens and classes list
train = movie_reviews.train
valid = movie_reviews.valid
vocab = movie_reviews.vocab
classes = movie_reviews.classes# number of reviews in train and valid
len(train.x), len(valid.x)# size of vocab
len(vocab.itos), len(vocab.stoi)# display vocab
vocab.itos# display the first train review and its label
review = train.x[0]
label = train.y[0]
review, label# display the token list of the train review 0
tokens = review.data; tokens# from a token list, display the corresponding text
' '.join(np.array(movie_reviews.vocab.itos)[tokens])
Résultat de la tokénisation d’une review:
Text xxbos xxmaj this very funny xxmaj british comedy shows what might happen if a section of xxmaj london , in this case xxmaj xxunk , were to xxunk itself independent from the rest of the xxup uk and its laws , xxunk & post - war xxunk . xxmaj merry xxunk is what would happen .
La tokénisation a en fait inséré des tokens spécifiques dont la liste est en ligne. Par exemple, xxunk (unknown) remplace un mot ou ensemble de caractères non retenu dans le vocabulaire du corpus (du fait, par exemple, de sa faible fréquence d’occurrence).
Note: nous pouvons obtenir un token à partir de son index (liste vocab.itos) et inversement, nous pouvons obtenir l’index du token de tout mot ou ensemble de caractères (dictionnaire vocab.stoi).
Matrice Document-Term
Au lieu d’utiliser la classe CountVectorizer de scikit-learn (cf. Annexe en fin de post sur le Topic Modeling pour une explication détaillée sur l’utilisation de la classe CountVectorizer), nous allons créer notre propre fonction get_term_doc_matrix() pour obtenir les matrices Document-Term de train et valid à partir de la tokenisation réalisée avec la librairie fastai.text (cf. Annexe | Exercices >> Exercice 1 pour le code de cette fonction).
trn_term_doc = get_term_doc_matrix(train.x,len(vocab.itos))
val_term_doc = get_term_doc_matrix(valid.x,len(vocab.itos))
4. Naïve Bayes et Logistic Regression avec NGrams de 1 et 3
Il s’agit à présent que nous avons notre corpus sous la forme d’une matrice Document-Term d’appliquer des algorithmes de classification de sentiment et de comparer leurs performances.
Note: le code ci-dessous concerne des matrices Document-Term 1Gram. Pour savoir comment créer des matrices trigrammes, merci de consulter le notebook.
4.1a Naïve Bayes
Nous avons rappelé dans le paragraphe “Vue d’ensemble des 3 techniques de Sentiment Classification” en début de post, ce qu’est un classificateur naïf Bayésien.
A présent que nous disposons de la matrice Document-Term de notre corpus, cad de la fréquence d’occurrence de chaque token dans chaque review, nous pouvons calculer pour chaque token son coefficient r en prenant le logarithme du rapport entre les valeurs moyennes de la fréquence d’occurrence du token dans les reviews positives et celle dans les reviews négatives. Ce calcul donne le ratio log-count r.
Calcul de r
# get Document-Term matrice, reviews lalels, classes indices
x = trn_term_doc
y = train.y
negative, positive = y.c2i["negative"], y.c2i["positive"]# calculation of the global tokens frequency in negative/positive reviews
p1 = np.squeeze(np.asarray(x[y.items==positive].sum(0)))
p0 = np.squeeze(np.asarray(x[y.items==negative].sum(0)))# calculation of average global tokens frequency
pr1 = (p1+1) / ((y.items==positive).sum() + 1)
pr0 = (p0+1) / ((y.items==negative).sum() + 1)# get r
r = np.log(pr1/pr0); r
Prédictions
b = np.log((y.items==positive).mean() / (y.items==negative).mean())
preds = (val_term_doc @ r + b) > 0
(preds == valid.y.items).mean()
4.1b Naïve Bayes Binaire
Au lieu d’utiliser dans la matrice Document-Term la fréquence d’occurrence des tokens dans les reviews, on indique uniquement sa présence (1) ou non (0). Il suffit donc de transformer la matrice Document-Term initiale en utilisant la fonction sign() en Python et tout le reste du code est identique pour obtenir les prédictions.
x = trn_term_doc.sign()
4.2 Logistic Regression
Nous utilisons la fonction LogisticRegression de scikit-learn avec dual=True qui accélère le calcul et un coefficient de régularisation 1/C avec C = 0.1.
from sklearn.linear_model import LogisticRegressionm = LogisticRegression(C=0.1, dual=True)
m.fit(trn_term_doc, y.items.astype(int))
preds = m.predict(val_term_doc)
(preds==val_y.items).mean()
Comparatif des performances
Voici ci-après les performances sur le dataset valid des modèles de Sentiment Classification codés dans le notebook de ce cours. Nous pouvons constater que pour ce jeu de données de critiques de films la version binaire de la matrice Document-Term donne presque tout le temps les meilleures prédictions sur le dataset valid. La raison est peut-être la taille relativement courte de ces textes.
IMDB Echantillon
- Naïve Bayes: 0.645 / (Binarized) 0.665
- Logistic Regression: 0.8 / (Binarized) 0.83
- Trigrams Naïve Bayes: 0.76 / (Binarized) 0.735
- Trigrams Logistic Regression: 0.675 / (Binarized) 0.83
IMDB Complet
- Naïve Bayes: 0.80864 / (Binarized) 0.82908
- Logistic Regression: 0.769 / (Binarized) 0.88528
Annexe | Exercices
Exercice 1 | Fonction de création d’une matrice Document-Term
Nous créons cette fonction get_term_doc_matrix() alors qu’elle existe déjà par exemple dans scikit-learn (CountVectorizer) car nous avons fait la tokenisation avec fastai.text qui a transformé les reviews en un objet fastai.data_block.LabelList. Il nous faut donc une fonction pouvant prendre en entrée un objet de ce type et produire en sortie une matrice Document-Term.
Note: cette fonction repose sur la compréhension de l’objet Counter en Python et du format CSR (compressed Sparse Row) qui est utilisé pour stocker une matrice Document-Term en Python.
# for example, label_list can be movie_reviews.train.x
def get_term_doc_matrix(label_list, vocab_len):
j_indices = []
indptr = []
values = []
indptr.append(0) for i, doc in enumerate(label_list):
feature_counter = Counter(doc.data)
j_indices.extend(feature_counter.keys())
values.extend(feature_counter.values())
indptr.append(len(j_indices)) return scipy.sparse.csr_matrix((values, j_indices, indptr),
shape=(len(indptr)-1, vocab_len),
dtype=int
)
Exercice 2 | Obtenir la liste des reviews avec un token et un sentiment donnés
Voici les 4 étapes à suivre:
- Obtenir l’id du token dans le vocabulaire.
- Obtenir la liste des reviews avec le token id à partir de la matrice Document-Term.
- Obtenir la liste des reviews avec un sentiment particulier (ex: positif) à partir de la matrice Document-Term et de la liste des étiquettes des reviews.
- Obtenir la liste désirée par l’intersection entre les 2 listes précédentes.
# get dataset from fastai.text
movie_reviews = TextList.from_csv()# get train review, labels, vocab, classes
reviews = movie_review.train.x
y = movie_reviews.train.y
vocab = movie_reviews.vocab
classes = movie.reviews.y.classes
positive = y.c2i['positive']
negative = y.c2i['negative']# get Document-Term matrix
x = get_term_doc_matrix(reviews, len(vocab.itos))# the 4 steps
token_id = vocab.stoi[token]
a = np.argwhere(x[:,token_id] > 0)[:,0]
b = np.argwhere(y.items==positive)[:,0]
set(a).intersection(set(b))
Exercice 3 | Obtenir les 10 tokens qui contribuent le plus au sentiment positif d’une review
Nous utilisons pour cela le ratio r calculé pour le classificateur naïf Bayésien.
biggest = np.argpartition(r, -10)[-10:]
[vocab.itos[k] for k in biggest]
Annexe | Sauvegarder une matrice Document-Term
En fonction de la quantité de textes dans le corpus train et/ou valid, la création des matrices Document-Term peuvent prendre du temps. Il est donc recommander de les sauvegarder en utilisation les fonctions save_npz() et load_npz() de scipy.sparse.
# save
scipy.sparse.save_npz("trn_term_doc.npz", trn_term_doc)# load
trn_term_doc = scipy.sparse.load_npz("trn_term_doc.npz")
Annexe | Commandes numpy sur les indices d’un np.array
np.argsort()
- Résultat: renvoie un np.array des indices des éléments d’un np.array ordonnées par valeur croissante.
- Application: permet d’obtenir la liste ordonnées des éléments de plus petite ou grande valeur.
Exemple:
x = np.array([3,4,9,2,1,7,8,10,11,5,15,12,14,6,13])
np.argsort(x)# result
# array([ 4, 3, 0, 1, 9, 13, 5, 6, 2, 7, 8, 11, 14, 12, 10], dtype=int64)
np.argmin() , np.argmax()
- Résultat: renvoie l’indice de l’élément le plus petit (np.argmin()) ou le plus grand (np.argmax()) d’un np.array
Exemple:
x = np.array([3,4,9,2,1,7,8,10,11,5,15,12,14,6,13])
np.argmin(x), np.argmax(x)# result
# (4, 10)
np.argpartition()
- Résultat: renvoie un np.array des indices du np.array partitionné par np.partition() (cf. explication).
- Application: obtenir 2 groupes d’éléments dont le premier (ie, début du np.array obtenu) contient les indices des éléments de valeur inférieure à celle de l’élément choisi pour partitionner et le second groupe (ie, fin du np.array obtenu) contient les indices des éléments restants (ie, éléments avec une valeur supérieure). Note: les éléments des 2 groupes ne sont pas ordonnés.
- Observation: les indices renvoyés ne correspondent pas à des éléments ordonnés à la différence de np.argsort().
Exemple:
# r is the naïve Bayes matrix of the document-Term matrix x
r = np.log(pr1/pr0)
biggest = np.argpartition(r, -10)[-10:]
smallest = np.argpartition(r, 10)[:10]# display the 10 tokens most
[vocab.itos[idx] for idx in biggest]
np.argwhere()
- Résultat: renvoie un np.array des indices ligne/colonne des éléments d’un np.array différents de zéro (ou de False).
- Application: permet d’obtenir les numéros des reviews (numéros des lignes dans la matrice Document-Term) qui vérifient une règle booléenne.
Exemple:
# get reviews indices with at least one time the token id = token_id
# x = document_term matrice
np.argwhere(x[:,token_id] > 0)[:,0]
Annexe | Divers en numpy
np.sign()
- Résultat: renvoie un np.array avec -1 quand un élément est négatif, 0 pour 0 et 1 s’il est strictement positif.
- Application: permet d’obtenir une matrice Document-Term montrant la présence ou non des tokens (0 si un token n’apparaît pas dans une review et 1 sinon) à partir de la matrice Document-Term montrant la fréquence d’occurrence des tokens.
Exemple:
x=trn_term_doc.sign()
À propos de l’auteur: Pierre Guillou est consultant en Intelligence Artificielle au Brésil et en France. Merci de le contacter via son profil Linkedin.