Dans ce TP; on changera un peu des bases classiques d'apprentissage statistique, pour traiter un type de données différentes : le texte.
Pour des raisons de temps, et comme il n'y a qu'une seule séance prévue sur ce sujet, on se focalisera sur les outils deep learning, et l'on ne verra pas les algorithmes type LDA/etc.
En texte, les données sont rarement aussi propres que dans les bases utilisées jusqu'à maintenant. Pour ce TP, on téléchargera une base de données de 50 000 descriptions de films.
movies_metadata.csv
avec pandas.Apprendre des embeddings sur le corpus des descriptions des films non traité. Que remarquez-vous?
Mettre en place le preprocessing adapté pour réduire la taille du vocabulaire et générer des embeddings de bonne qualité
movies_metadata.csv
avec pandas.¶Nota : il faut utiliser un compte kaggle.
Ouvrons la base et regardons les différentes colonnes :
import pandas as pd
df = pd.read_csv('movies_metadata.csv', encoding='utf-8')
df.head()
df.columns
Les variables intéressantes à prédire sont les suivantes :
popularity
revenue
vote_average
En effet, si l'on produit un film, on peut être intéressé par ses futurs résultats au box office, tant en terme financiers qu'en termes de popularité, ou de notes attribuées par les utilisateurs.
On pourrait aussi vouloir prévoir les genres
d'un film. Si un film était classé dans un unique genre, on serait dans le cadre d'un problème de classification classique, comme vu en cours. Si les films étaient classés dans un nombre fixe de genres (trois genres pour chaque film, par exemple), on serait dans un contexte de classification multi-label. On pourrait entraîner un prédicteur pour le premier, un prédicteur pour le deuxième et un prédicteur pour le troisième genre, par exemple, ou bien utiliser des méthodes de classification multi-label plus sophistiquées.
Dans notre cas, les films ont un nombre de genres variable, ce qui rend complexe la modélisation de la tâche d'apprentissage. On laissera cette variable de côté pour ce TP.
Commençons par conserver uniquement les films qui ont une description (pour la suite), et des valeurs de popularity, revenue, vote_average
, et étudions les caractéristiques de quelques variables.
df = df.dropna(subset=['overview', 'popularity', 'revenue', 'vote_average'])
print('Number of non-missing values : \n')
print(df.count())
En enlevant les NaNs sur les variables à prédire et sur l'overview, il nous reste 44.506 films dans la base de donnée. Regardons la taille des overview
:
print(df.overview.apply(lambda x: len(x)).mean())
print(df.overview.apply(lambda x: len(x)).std())
import matplotlib.pyplot as plt
%matplotlib inline
df.overview.apply(lambda x: len(x)).hist(bins=20)
Intéressons nous maintenant aux variables à prédire : type, histogramme, etc...
var = df[['revenue', 'popularity', 'vote_average']]
var.head(3)
var.revenue.hist(bins=50)
var['popularity'] = var.popularity.astype('float')
var.popularity.dropna().hist(bins=50)
var.vote_average.dropna().hist(bins=20)
On observe une répartition des valeurs en décroissance exponentielle pour le revenue
et la popularity
.
Quelques rares films font de très gros bénéfices, et le reste n'en fait quasiment pas.
Sur ces deux variables, on pourra donc anticiper que les modèles auront un pouvoir prédictif faible : si le modèle prédit la moyenne du revenue
ou de la popularity
pour tout nouveau film, il aura probablement des métriques correctes. Pour vérifier cela, calculons le R2 et la MSE d'un modèle qui prédit la moyenne, la médiane, et 0, qui seront nos baselines :
from sklearn.metrics import r2_score, mean_squared_error
import swifter
y_true = df['revenue'].astype('float')
mean = y_true.mean()
median = y_true.median()
y_pred_mean = df['revenue'].swifter.apply(lambda x : mean )
y_pred_median = df['revenue'].swifter.apply(lambda x : median)
y_pred_zero = df['revenue'].apply(lambda x : 0)
m_rev = {'R2_mean' : r2_score(y_true, y_pred_mean),
'MSE_mean' : mean_squared_error(y_true, y_pred_mean),
'R2_median' : r2_score(y_true, y_pred_median),
'MSE_median' : mean_squared_error(y_true, y_pred_median),
'R2_zero' : r2_score(y_true, y_pred_zero),
'MSE_zero' : mean_squared_error(y_true, y_pred_zero)}
print(m_rev)
from sklearn.metrics import r2_score, mean_squared_error
import swifter
y_true = df['popularity'].astype('float')
mean = y_true.mean()
median = y_true.median()
y_pred_mean = df['popularity'].swifter.apply(lambda x : mean )
y_pred_median = df['popularity'].swifter.apply(lambda x : median)
y_pred_zero = df['popularity'].apply(lambda x : 0)
m_pop = {'R2_mean' : r2_score(y_true, y_pred_mean),
'MSE_mean' : mean_squared_error(y_true, y_pred_mean),
'R2_median' : r2_score(y_true, y_pred_median),
'MSE_median' : mean_squared_error(y_true, y_pred_median),
'R2_zero' : r2_score(y_true, y_pred_zero),
'MSE_zero' : mean_squared_error(y_true, y_pred_zero)}
print(m_pop)
Les métriques sont, de manière contre-intuitive, très mauvaises. Elles nous serviront de baseline pour la suite.
Regardons maintenant le vote_average
. Sa répartition, hormis les 0, ressemble vaguement à une gaussienne, ce qui semblerait en faire une variable plus facile à prédire (d'autant qu'elle est bornée). On verra dans la suite que ce n'est pas le cas.
Déterminons notre baseline à battre sur le vote_average
.
from sklearn.metrics import r2_score, mean_squared_error
import swifter
y_true = df['vote_average'].astype('float')
mean = y_true.mean()
median = y_true.median()
y_pred_mean = df['vote_average'].swifter.apply(lambda x : mean )
y_pred_median = df['vote_average'].swifter.apply(lambda x : median)
y_pred_zero = df['vote_average'].apply(lambda x : 0)
m_vot = {'R2_mean' : r2_score(y_true, y_pred_mean),
'MSE_mean' : mean_squared_error(y_true, y_pred_mean),
'R2_median' : r2_score(y_true, y_pred_median),
'MSE_median' : mean_squared_error(y_true, y_pred_median),
'R2_zero' : r2_score(y_true, y_pred_zero),
'MSE_zero' : mean_squared_error(y_true, y_pred_zero)}
print(m_vot)
Aucune de nos baselines naïve ne semble parvenir à saisir le phénomène. Essayons de prédire les 3 variables avec un modèle de machine learning classique.
Dans cette partie, on va directement appliquer ce que l'on a fait dans les dernier TP : du boosting, avec des hyperparamètres optimaux, grâce à GridSearchCV.
Nous regarderons comment notre modèle performe par rapport à nos baselines.
from sklearn.model_selection import GridSearchCV
from sklearn.ensemble import AdaBoostRegressor
df.columns
Sur l'ensemble de ces colonnes, on va sélectionner les features suivantes :
df['popularity'] = df.popularity.astype('float') # Preprocessing de popularity : on le repasse en float.
df['budget'] = df.budget.astype('float')
dataset = dataset[['adult', 'budget', 'runtime', 'original_language', 'popularity', 'vote_average', 'revenue']]
dataset.head()
Transformons efficacement le langage original et la variable adult
en des variables numériques :
from collections import Counter
dic_language = Counter(dataset.original_language)
dic_language = {list(dic_language.keys())[i]: i for i in range(len(dic_language))}
dataset['adult'] = dataset.adult.apply(lambda x: 1 if x==True else 0)
dataset['original_language'] = dataset.original_language.apply(lambda x: dic_language[x])
dataset.head()
Prenons les hyperparamètres d'AdaBoot, en régression, pour écrire notre param_grid
AdaBoostRegressor().get_params()
from sklearn.tree import DecisionTreeRegressor
params_grid = {'base_estimator': [DecisionTreeRegressor(max_depth=2),
DecisionTreeRegressor(max_depth=3),
DecisionTreeRegressor(max_depth=10)],
'learning_rate': [0.01, 0.1, 1.0, 10],
'n_estimators': [10, 50]}
import numpy as np
rg = GridSearchCV(AdaBoostRegressor(), param_grid=params_grid, scoring='r2', verbose=0)
rg.fit(dataset[['adult', 'budget', 'runtime', 'original_language']], np.ravel(dataset[['popularity']]))
pd.DataFrame(rg.cv_results_).sort_values('rank_test_score').head()
rg = GridSearchCV(AdaBoostRegressor(), param_grid=params_grid, scoring='r2')
rg.fit(dataset[['adult', 'budget', 'runtime', 'original_language']], np.ravel(dataset[['vote_average']]))
pd.DataFrame(rg.cv_results_).sort_values('rank_test_score').head()
rg = GridSearchCV(AdaBoostRegressor(), param_grid=params_grid, scoring='r2')
rg.fit(dataset[['adult', 'budget', 'runtime', 'original_language']], np.ravel(dataset[['revenue']]))
pd.DataFrame(rg.cv_results_).sort_values('rank_test_score').head()
Essayons une forêt aléatoire, sans hyperparameter tuning :
from sklearn.ensemble import RandomForestRegressor
from sklearn.model_selection import cross_val_score
print(cross_val_score(RandomForestRegressor(),
dataset[['adult', 'budget', 'runtime', 'original_language']],
np.ravel(dataset[['revenue']]),
scoring='r2').mean())
print(cross_val_score(RandomForestRegressor(),
dataset[['adult', 'budget', 'runtime', 'original_language']],
np.ravel(dataset[['popularity']]),
scoring='r2').mean())
print(cross_val_score(RandomForestRegressor(),
dataset[['adult', 'budget', 'runtime', 'original_language']],
np.ravel(dataset[['vote_average']]),
scoring='r2').mean())
Pour résumer, jusqu'à maintenant, nous avons, en score R2 :
Revenue | Popularity | Vote Average | |
---|---|---|---|
AdaBoost | 60.10% | 16% | -3.09% |
Random Forest | 50.87% | -66.27% | -11% |
Baseline - mean | 0% | 0% | 0% |
Le revenue
est de loin la variable la plus facile à prédire, contre toute attente si l'on se fiait aux histogrammes de répartition des valeurs des variables à prédire.
Dans la partie qui suit, on va ajouter l'information sémantique contenue dans les overview
. Pour cela, on va introduire le concept d'embedding, et décrire le réseau de neurones utilisé pour le Word2Vec, qui permet de générer des embeddings relativement puissant, simplement.
Avant de décrire le Word2Vec, nous allons poser quelquesbases du deep learning : les réseaux de neurones denses.
L'idée de copier et de simplifier le neurone a été explorée pour la première fois par Rosenblatt, avec son Perceptron (1957). Certains changements ont été apportés depuis cette première modélisation, mais elle ressemble encore un peu au modèle biologique d'un neurone : un neurone biologique reçoit des signaux d'autres neurones et émet un signal en réponse, en fonction de certaines règles d'activation. Un neurone artificiel fonctionne de manière similaire : il prend en entrée $n$ signaux $(x_1, x_2, ...., x_n)$, et émet le signal $y = f(w_1 x_1 + ... + w_n x_n)$, où $f$ est appelé la fonction d'activation, et $w_1, ..., w_n$ sont appelés les poids du neurone.
Si $f = Id$, le neurone effectue une régression linéaire de $y$ sur $(x_i)_{i=1, ...., n}$ en ajustant les poids grâce aux données d'apprentissage. Plus généralement, les fonctions d'activation choisies sont non linéraires, comme la fonction ReLU, et souvent bornées, comme la fonction sigmoïde ($\sigma$), la tangente hyperbolique ($\tanh$) :
\begin{eqnarray*} \forall x \in \mathbb{R}, \sigma(x) &=& \frac{1}{1+e^{-x}} \\ \forall x \in \mathbb{R}, \tanh(x) &=& \frac{e^{2x}-1}{e^{2x}+1} \\ \forall x \in \mathbb{R}, \text{ReLU}(x) &=& \max(0, x) \end{eqnarray*}Cependant, la modélisation des données avec un seul neurone n'apporte pas d'innovation : c'est comme si l'on utilisait des régressions linéaires, des régressions logistiques ou des modèles de censure. Par conséquent, les neurones ne sont jamais utilisés seuls : les neurones sont collés en plusieurs couches, formant un réseau. Expliquons maintenant le modèle de réseau de neurones le plus simple : le réseau dense.
Les réseaux denses parallélisent et mettent en série un ensemble de neurones artificiels, comme schématisé en Figure 5. Dans un réseau à $L\in \mathbb{N}$ couches cachées de tailles $n_1, n_2, ..., n_L$ respectivement, les inputs sont d'abord passés aux $n_1$ neurones de la première couche. Chaque neurone a ses propres poids et calcule la réponse qu'il va donner au signal. Les réponses générées par chacun des neurones de la première couche sont transmis comme inputs aux $n_2$ neurones de la seconde couche, etc.
Plus formellement, définissons les notations suivantes: soit $L$ le nombre de couches cachées, et $n_l$ le nombre de neurones présents dans la couche $l$. Le $i$-ième neurone de la $l$-ième couche cachée est noté $N_i^{(l)}$.
$N_i^{(l)}$ a $n_{l-1}$ poids : notons $w_{k, i}^{(l)}$ le poids de l'arrête reliant $N_{k}^{(l-1)}$ à $N_i^{(l)}$. En outre, si l'on note $z_i^{(l)}$ le signal sortant du neurone $N_i^{(l)}$, on a:
\begin{align*} z_i^{(l)} = f \left ( \sum_{k=1}^{n_{l-1}} w_{k, i}^{(l)} z_{k}^{(l-1)} \right ) \end{align*}Où $f$ est la fonction d'application de $N_i^{(l)}$ (par soucis de clarté, et pour éviter des notations trop lourdes nous prendrons $f$ identique pour tous les neurones).
En notant $Z^{(l)} = (z_i^{(l)})_{i=1, ..., n}$, on peut écrire l'équation précedente sous forme matricielle, pour la $l$-ième couche cachée: \begin{align*} Z^{(l)} = f \left ( W^{(l)} Z^{(l-1)} \right ) \end{align*} Où $f$ est évidemment appliquée sur chacune des coordonnées de $W^{(l)} Z^{(l-1)}$, et où $W^{(l)} \in \mathbb{R}^{n_{l}\times n_{l-1}} $ : \begin{align*} W^{(l)} = \left ( \begin{array}{ccc} w_{1, 1}^{(l)} & ... & w_{n_{l-1}, 1}^{(l)} \\ w_{1, 2}^{(l)} & ... & w_{n_{l-1}, 2}^{(l)} \\ \vdots & & \vdots \\ w_{1, n_l}^{(l)} & ... & w_{n_{l-1}, n_l}^{(l)} \\ \end{array} \right ) \end{align*} L'ensemble des matrices contenant les poids de chacune des couches cachées sera appelé $\mathcal{W} := {W^{(l)}}_{l=1, ...L}$ pour toute la suite.
La dernière couche du réseau, celle qui doit produire le signal de sortie, reste cependant à traiter. Si le problème est un problème de régression, c'est à dire que le signal de sortie doit être continu, alors on peut utiliser les fonctions d'activation classiques, comme dans les autres couches. Cependant, pour un problème de classification, il faut adapter la fonction d'activation. En général, pour une classification à $K$ classes, on choisit de prédire les probabilités d'apartenir à chacune des classes. Pour cela, on dimensionne la dernière couche pour qu'elle ait autant de neurones que de classes : $N^{(l)} = K$. À la manière d'une régression logistique multiclasses, on choisit souvent la fonction "softmax" comme fonction d'activation (car elle normalise tous les poids, qui se somment à 1, imitant une distribution de probabilité): \begin{align*} \forall i = 1, ..., K , z_{i}^{(L)} &:= \frac{e^{a_i^{(L)}}}{\sum_{k=1}^{K} e^{a_k^{(L)}}} \end{align*} Où $\forall i = 1, ..., K, a_i^{(L)} := \sum_{k=1}^{n_{l-1}} w_{k, i}^{(l)} z_{k}^{(l-1)}$
Cette fonction représente donc la probabilité qu'un input $x$ appartienne à la $i$-ième classe.
L'architecture, les poids de chaque couche, et les fonctions d'activation définissent à eux seuls un réseau dense. Afin d'obtenir des prédictions utiles, les poids sont considérés comme des paramètres à entraîner, ce que nous introduirons, sans donner de détail. L'architecture et les fonctions d'activation peuvent être considérés comme des hyperparamètres, à optimiser avec d'autres algorithmes que nous ne détaillerons pas ici.
Mettons nous dans le paradigme classique de l'apprentissage statistique: soit $(x_i, y_i)_{i=1, ..., n}$ notre échantillon d'apprentissage, i.i.d., suivant une loi de probabilité $P$. Lorsque qu'un input $x_i$ est donné au réseau, il passe à travers toutes les couches et le réseau produit un output $\hat{y}_i = \text{FFNN}(x_i|\mathcal{W})$. Le but est d'apprendre les poids $\mathcal{W}$ à partir de l'échantillon d'apprentissage, en minimisant l'erreur commise en produisant $\hat{y}_i$ au lieu de $y_i$ . Le problème se résume au programme d'optimisation suivant, avec $\mathcal{L}$ la fonction de perte quantifiant l'erreur commise par le réseau : \begin{align*} \mathcal{W}* \in \arg \underset{\mathcal{W}}{\min} \ \mathcal{L}(\mathcal{W}) + \lambda \Phi(\mathcal{W}) \end{align*} Avec $\lambda\Phi$ le terme de régularisation.
En régression, les fonctions de perte classiques s'appliquent : "mean squared error" (MSE), fonction de perte $\mathcal{L}_2$ , "mean absolute error", fonction de perte L1 $\mathcal{L}_1$ , etc... En classification, les fonctions de perte les plus classiques sont l'entropie croisée,le "negative logarithmic likelihood", "hinge loss", etc. Dans la suite, on s'intéressere plus particulièrement à l'entropie croisée. Cette fonction de perte fonctionne particulièrement bien dans les cas de classification, après un softmax et plus généralement toute fonction d'activation renvoyant des probabilités d'appartenance à des classes. Cette fonction vient du "negative logarithmic likelihood": \begin{align*} \mathcal{L}((x_i, y_i)_{i=1, ..., n}) = -\sum_{i=1}^n \ln p(y_i|x_i, \mathcal{W}) \end{align*}
Il faut préciser ce qu'est vraiment $p(y_i|x_i, \mathcal{W})$. Comme nous sommes dans un contexte de classification, on peut écrire les $(y_i)_i$ comme des vecteurs "one-hot": si $y_i$ appartient à la $j$-ième classe, alors $y_i$ est un vecteur constitué de 0, excepté d'un 1 sur sa $j$-ième ligne. En notant $C_k$ la $k$-ième classe, on a: \begin{align*} p(y_i |x_i, \mathcal{W}) = \prod_{k=1}^K p(C_k|x_i)^{y_{i, k}} \end{align*} où $y_{i, k}$ est la $k$-ième ligne de $y_i$. Comme on suppose que l'on a mis un softmax sur la dernière couche, et que $\hat{y}_{i, k}:= p(C_k|x_i) $, on peut réecrire :
\begin{align*} \mathcal{L}(x_i, y_i) = -\sum_{k=1}^K y_{i,k}\ln (\hat{y}_{i, k}) \end{align*}L'équation au dessus est la définition de l'entropie croisée.
Maintenant que nous avons choisi une fonction de perte permettant de quantifier l'erreur faite par le réseau, on aimerait pouvoir différencier celle ci par rapport à chacun des poids de chacune des matrices de $\mathcal{W}$: \begin{align*} \frac{\partial \mathcal{L}((x_i, y_i)_{i=1, ..., n})}{\partial \mathcal{W}} = \sum_{i=1}^n \frac{\partial \mathcal{L}(x_i, y_i)}{\partial \mathcal{W}} \end{align*}
Sans surprise, la perte $\mathcal{L}(x_i, y_i)$ n'est pas évidente à différencier par rapport à $\mathcal{W}$, sachant que $\mathcal{L}(x_i, y_i) = -\sum_{k=1}^K y_{i,k}\ln (\hat{y}_{i, k})$ et $\hat{y}_{i, k} = z_{i, k}^{(l)}$ sont produits par un calcul complexe dans tout le réseau. L'algorithme de backpropagation apporte une solution à ce problème, mais nous ne le décrirons pas ici.
Word2Vec est un mot générique pour les deux architectures de réseaux de neurones, Skip-Gram et Continuous Bag-of-Words (CBOW), proposées par Mikolov et al, 2013. Ces réseaux doivent réaliser une fausse tâche : prédire les mots voisins (un contexte d'une taille fixée) entourant un mot donné pour le Skip-Gram, et prédire un mot à partir de son contexte pour le CBOW. Après avoir étés entraînés sur ces fausses tâches, on utilise les poids du modèle comme représentation (embedding) de chaque mot du vocabulaire. Cette représentation a l'avantage d'être de faible dimension (en général $d=200$), et dense. Elle a par ailleurs des propriétés linguistiques intéressantes et de bonnes performances sur de nombreuses tâches de NLP.
Le modèle Skip-Gram est un modèle à l'architecture assez simple: c'est un réseau à une seule couche cachée dont l'input est un mot donné dans une phrase issue d'un corpus, et l'output doit être, par exemple, les 2 mots précédant le mot cible dans la phrase, et les 2 mots suivant le mot cible.
On suppose que l'on a à disposition un corpus de textes, duquel on peut extraire un vocabulaire. On notera $V \in \mathbb{N}$ le nombre de mots uniques dans le vocabulaire. Les mots sont représentés par des vecteurs "one-hot", 0-1 de taille $V$. La couche cachée, avec $N$ neurones ($N$ est un paramètre) a pour fonction d'activation l'identité. La couche de sortie a $C$ neurones, avec $C$ la taille de la fenêtre de contexte (le nombre de mots de contexte qui doivent être prédits par l'algorithme, , $C=2$ par exemple), et sa fonction d'activation est un softmax, qui à chacun des $C$ mots associe un vecteur de taille $V$ contenant les probabilités que ce mot soit chacun des mots du dictionnaire.
Dans le contexte du Word2Vec, nous ne nous intéressons qu'aux poids qui relient les inputs à la couche cachée à $d$ neurones. Ce sont eux qui vont nous permettre d'obtenir une représentation vectorielle dense de dimension $N$ pour chacun des mots de $V$. Si $X$ est le vecteur 0-1 qui représente un mot et $W$ la matrice des poids reliant les inputs à la couche cachée, alors on peut définir $X_emb$, l'embedding en dimension $N$ de $X$, par:
\begin{align} X_{\text{emb}} = X^{T} W \label{eq:embeddinform} \end{align}\begin{align*} X = \left ( \begin{array}{c} 0 \\ \vdots \\ 0 \\ 1 \\ 0 \\ \vdots \\ 0 \end{array} \right ) \in \{ 0, 1\}^{V} , \ \ W = \left ( \begin{array}{ccc} w_{1,1} & \dots & w_{1, N} \\ \vdots & & \vdots \\ w_{V,1} & \dots & w_{V, N} \\ \end{array} \right ) \in \mathbb{R}^{(V, N)}, \ \ X_{\text{emb}} \in \mathbb{R}^{N} \end{align*}La valeur linguistique du Skip-Gram est sous-tendue par une hypothèse linguistique fondamentale, appelée "hypothèse de distribution". Selon cette hypothèse, des mots qui apparaissent dans des contextes similaires sont plus susceptibles d'avoir la même signification. Cette idée est résumée par une citation très populaire de Firth, 1957 : "Un mot est caractérisé par la compagnie qu'il a". Avec le modèle Skip-Gram, les mots qui apparaissent dans le même contexte sont plus susceptibles d'avoir des représentations vectorielles similaires, et ainsi, si l'hypothèse de distribution de Firth est vraie, les mots avec une représentation proche (au sens vectoriel) aura probablement le même sens. C'est ce que les résultats empiriques montrent en général.
On va voir cette valeur linguistique dans la pratique, sur le corpus des overviews.
Pour cela, il faut d'abord nettoyer le corpus en:
.split(' ')
en raison de la présence de ponctuation ('They're nice.'
doit devenir ["They", "'", "re", "nice", "."]
, ou à la limite ["They're", "nice", "."]
mais pas ["They're", "nice."]
. Pour cette tâche, on utilisera et comparera des tokenizer dans NLTKNUM
Chat
et chat
comptent comme deux mots différents). Si l'on est sur un corpus avec beaucoup de noms propres, cependant, il peut être intéressant de laisser à chaque mot ses majuscules. Ensuite, on utilisera gensim pour générer des embeddings.
# from nltk.tokenize import ToktokTokenizer
# toktok = ToktokTokenizer()
from nltk.tokenize import word_tokenize
import nltk
nltk.download('punkt')
def preprocessor(x):
tokens = word_tokenize(x)
for i in range(len(tokens)):
try:
float(tok[i])
tokens[i] = 'NUM'
except:
tokens[i] = tokens[i].lower()
return tokens
df['overview_clean'] = df.overview.swifter.apply(preprocessor)
sentences = df.overview_clean.array # Création de notre corpus, pour gensim : liste de listes de tokens.
On définit un modèle Word2Vec avec les paramètres suivants:
sentences
servira à définir le vocabulaire.size
correspond à la dimension des embeddingswindow
correspond à la fenêtre de contexte pour le Word2Vecworkers
permet de paralléliser les calculs sur le nombre de coeurs logiques voulu.Ensuite, on entraîne avec les paramètres suivants:
sentences
est le corpus sur lequel les embeddings sont entraînésepochs
est le nombre de fois que le réseau de neurones verra le dataset complet (i.e. 3 epochs = toutes les phrases du dataset sont passées 3 fois dans le réseau). total_examples
est un paramètre pour Gensim mais il n'est pas d'intérêt ici. from gensim.models import Word2Vec
model = Word2Vec(sentences, size=100, window=5, workers=4)
model.train(sentences, epochs=100, total_examples=len(sentences))
Maintenant que le modèle est entraîné, on peut accéder à nos embeddings, et calculer des distances entre les mots :
# model.wv['american'] (pour accéder au vecteur)
model.wv.most_similar(['american'])
model.wv.most_similar(['german'])
model.wv.most_similar(['woman'])
model.wv.most_similar(['man'])
model.wv.most_similar(['september'])
model.wv.most_similar(['computer'])
On peut assez facilement voir les biais sociaux du dataset.
Essayons maintenant d'aggréger ces embeddings, en utilisant la moyenne sur chaque overview
: si $e_x$ désigne l'embedding du mot $x$, et que notre overview est constituée des mots $x_1, ..., x_t$, l'embedding de l'overview est:
$$\frac{1}{t} \sum_{i=1}^t e_{x_i}$$
Cette aggrégation permet de surmonter le fait que les overview sont de tailles variables. Cependant, elle implique que les overview sont des bag-of-words : l'ordre des mots dans la phrase n'a aucune importance. Dans la littérature, on utilise plutôt des réseaux de neurones récurrents ou des mécanismes d'attention pour aggréger des séquences de taille variable, mais cela sort du cadre de ce cours.
dataset = df[['adult', 'budget', 'runtime', 'original_language', 'overview', 'popularity', 'vote_average', 'revenue']]
dic_language = Counter(dataset.original_language)
dic_language = {list(dic_language.keys())[i]: i for i in range(len(dic_language))}
dataset['adult'] = dataset.adult.swifter.apply(lambda x: 1 if x==True else 0)
dataset['original_language'] = dataset.original_language.swifter.apply(lambda x: dic_language[x])
dataset['overview'] = dataset.overview.swifter.apply(preprocessor)
def mean_emb(x):
acc = np.array([0]*100)
n = len(acc)
for elt in x:
try:
acc = acc + model.wv[str(elt)]
except:
acc = acc + np.array([0]*100)
return acc/n
dataset['overview_emb'] = dataset.overview.swifter.apply(mean_emb)
for i in range(100):
dataset['embedding'+str(i)] = dataset.overview_emb.apply(lambda x: x[i])
dataset.head()
Regardons si cet embedding aggrégé améliore notre modèle :
dataset = dataset.drop(['overview_emb', 'overview'], axis=1)
X = dataset.drop(['revenue', 'popularity', 'vote_average'], axis=1)
y = np.ravel(dataset['revenue'])
cross_val_score(RandomForestRegressor(), X, y, scoring='r2').mean()
from sklearn.linear_model import LinearRegression
cross_val_score(LinearRegression(), X, y, scoring='r2').mean()
from sklearn.linear_model import Lasso
cross_val_score(Lasso(), X, y, scoring='r2').mean()
y = np.ravel(dataset['popularity'])
print(cross_val_score(RandomForestRegressor(), X, y, scoring='r2').mean())
print(cross_val_score(LinearRegression(), X, y, scoring='r2').mean())
print(cross_val_score(Lasso(), X, y, scoring='r2').mean())
y = np.ravel(dataset['vote_average'])
print(cross_val_score(RandomForestRegressor(), X, y, scoring='r2').mean())
print(cross_val_score(LinearRegression(), X, y, scoring='r2').mean())
print(cross_val_score(Lasso(), X, y, scoring='r2').mean())
L'embedding aggrégé n'améliore malheureusement pas vraiment nos modèles.