Traitement Naturel du Langage (NLP) : Catégorisation de Produits Cdiscount

Il s'agit d'une version simplifiée du concours proposé par Cdiscount et paru sur le site datascience.net. Les données d'apprentissage sont accessibles sur demande auprès de Cdiscount mais les solutions de l'échantillon test du concours ne sont pas et ne seront pas rendues publiques. Un échantillon test est donc construit pour l'usage de ce tutoriel. L'objectif est de prévoir la catégorie d'un produit à partir de son descriptif (text mining). Seule la catégorie principale (1er niveau, 47 classes) est prédite au lieu des trois niveaux demandés dans le concours. L'objectif est plutôt de comparer les performances des méthodes et technologies en fonction de la taille de la base d'apprentissage ainsi que d'illustrer sur un exemple complexe le prétraitement de données textuelles.

Le jeux de données complet (15M produits) permet un test en vrai grandeur du passage à l'échelle volume des phases de préparation (munging), vectorisation (hashage, TF-IDF) et d'apprentissage en fonction de la technologie utilisée.

La synthèse des résultats obtenus est développée par Besse et al. 2016 (section 5).

Partie 1-2 : Construction des caractéristiques ou features, Vectorisation et Word embedding.

Les données textuelles ne peuvent pas être utilisés directement dans les différents algorithmes d'apprentissage statistiques. Nous allons voir dans ce tutoriel plusieurs techniques permettant de traduire les données textuelles sous formes de vecteur numérique :

Fonction de vectorisation présente dans la librairie scikit-learn :

  • One-Hot-Encoder
  • Tf-Idf
  • Hashing

Word Embedding présente dans la librairie gensim :

  • Word2Vec

Téléchargement des librairies


In [ ]:
#Importation des librairies utilisées
import time
import pandas as pd
import numpy as np
import collections
import itertools
import os
import warnings
warnings.filterwarnings('ignore')


from sklearn.cross_validation import train_test_split

Téléchargement des données

On télécharge les données de train et de validation néttoyé et racinisé dans le notebook précédent.


In [ ]:
data_valid_clean_stem = pd.read_csv("data/cdiscount_valid_clean_stem.csv").fillna("")
data_train_clean_stem = pd.read_csv("data/cdiscount_train_clean_stem.csv").fillna("")

On créé un dossier dans lequel nous allons sauvegarder les DataFrame constitués des features que l'on va construire dans ce notebook


In [ ]:
DATA_OUTPUT_DIR = "data/features"
if not(os.path.isdir("data/features")):
    os.mkdir("data/features")

Dans un premier temps, en guise d'exemple et pour réduire le temps de calcul, on ne considère que la colonne Description de nos DataFrame générés dans le calepin précédent.


In [ ]:
train_array = data_train_clean_stem["Description"].values
valid_array = data_valid_clean_stem["Description"].values

Vectorisaton


In [ ]:
train_array[0]

One-Hot-Encoding

  • One-Hot-Encoding

L'encodage One-Hot-Encoding des données peut être effectué grâce à la classe CountVectorizerde scikit-learn


In [ ]:
from sklearn.feature_extraction.text import CountVectorizer

extr_cv = CountVectorizer(binary=False)
data_train_OHE = extr_cv.fit_transform(train_array)

Q A quoi sert l'argument binary de la classe?

Q Sous quel format sont stockées les vecteurs OHE? Pourquoi ce format est-il choisi?

La fonction get_feature_names permet d'avoir accès à la liste des mots présents dans l'ensemble des lignes de l'array converti.


In [ ]:
vocabulary = extr_cv.get_feature_names()
N_vocabulary = len(vocabulary)
print("Nombre de mots : %d" %N_vocabulary )

Exercice Pour la première ligne de votre dataset train. retrouvez l'ensemble des mots constituant cette ligne à partir de l'objet data_train_OHEet de vocabulary ainsi que le nombre d'occurence de chacun de ces mots dans la ligne.


In [ ]:
# %load solution/2_1.py

La même transformation est appliqué sur l'échantillon de validation.


In [ ]:
data_valid_OHE = extr_cv.transform(valid_array)
data_valid_OHE

Q Que se passe-til pour les mots présents dans le dataset de validation mais qui ne sont pas présent dans le dataset d'apprentissage?

Q Pourquoi on ne 're-fit' pas la Classe CountVectorizerl'échantillon de validation.

TF-IDF

  • TF-IDF. Le TF-IDF permet de faire ressortir l'importance relative de chaque mot $m$ (ou couples de mots consécutifs) dans un texte-produit ou un descriptif $d$, par rapport à la liste entière des produits. La fonction $TF(m,d)$ compte le nombre d'occurences du mot $m$ dans le descriptif $d$. La fonction $IDF(m)$ mesure l'importance du terme dans l'ensemble des documents ou descriptifs en donnant plus de poids aux termes les moins fréquents car considérés comme les plus discriminants (motivation analogue à celle de la métrique du chi2 en anamlyse des correspondance). $IDF(m,l)=\log\frac{D}{f(m)}$ où $D$ est le nombre de documents, la taille de l'échantillon d'apprentissage, et $f(m)$ le nombre de documents ou descriptifs contenant le mot $m$. La nouvelle variable ou features est $V_m(l)=TF(m,l)\times IDF(m,l)$.

  • Comme pour les transformations des variables quantitatives (centrage, réduction), la même transformation c'est-à-dire les mêmes pondérations, est calculée sur l'achantillon d'apprentissage et appliquée à celui de test.

On utiliser la fonction TfidfVectorizer qui permet de parser également le texte

Dans un premier temps, on fixe le paramètre norm = False afin de rendre les résultats plus explicite et analyser les sorties


In [ ]:
from sklearn.feature_extraction.text import TfidfVectorizer
vec = TfidfVectorizer(ngram_range=(1,1), norm = False)
data_train_TFIDF = vec.fit_transform(train_array)

Q A quoi sert l'argument ngram_range?

Constatez que data_train_TFIDFest stocké sous le même format que data_train_OHE et que la taile du vocabulaire est la même.


In [ ]:
vocabulary = vec.get_feature_names()
N_vocabulary = len(vocabulary)
N_vocabulary

Exercice Pour la première ligne de votre dataset train. retrouvez l'ensemble des mots constituant cette ligne à partir de l'objet data_train_TFIDFet de vocabulary ainsi que la valeur de l'idf, du tf et du poids tfidf de chacun de ces mots dans la ligne


In [ ]:
# %load solution/2_2.py

Q Commentez les valeurs de l'idf pour chacun des mots.

Q Comment evolue les poids en changeant les paramètre smooth idf et sublinear_tf de la méthode TfidfVectorizer?

Exercice Changez l'argument ngram_range de la méthode TfidfVectorizer et re-affichez les résultats.

On applique maintenant le vectorizer sur le jeu de données de validation


In [ ]:
data_valid_TFIDF = vec.transform(valid_array)
data_valid_TFIDF

ATENTION Si le tf est recalculé pour chaque ligne, le même idf est utilisé


In [ ]:
# %load solution/2_2bis.py

Hashing

Le Hashage. Il permet de réduire l'espace des variables (taille du dictionnaire) en un nombre limité et fixé a priori n_hash de caractéristiques. Il repose sur la définition d'une fonction de hashage, $h$ qui à un indice $j$ défini dans l'espace des entiers naturels, renvoie un indice $i=h(j)$ dans dans l'espace réduit (1 à n_hash) des caractéristiques. Ainsi le poids de l'indice $i$, du nouvel espace, est l'association de tous les poids d'indice $j$ tels que $i=h(j)$ de l'espace originale. Ici, les poids sont associés d'après la méthode décrite par Weinberger et al. (2009).

N.B. $h$ n'est pas généré aléatoirement. Ainsi pour un même fichier d'apprentissage (ou de test) et pour un même entier n_hash, le résultat de la fonction de hashage est identique

Au contraire des classe CountVectorizeret TfidfVectorizer, la classe FeatureHasher prend en entré un dictionnaire d'occurence des mots.


In [ ]:
train_dict_array  = list(map(lambda x : collections.Counter(x.split(" ")), train_array))
train_dict_array[0]

In [ ]:
from sklearn.feature_extraction import FeatureHasher
nb_hash = 300
feathash = FeatureHasher(nb_hash)
data_train_hash = feathash.fit_transform(train_dict_array)

Constatez que data_train_hash est stocké sous le même format que data_train_OHE ou data_train_TFIDF.

Q Que dire cependant de sa dimension?

La cellule suivante permet d'afficher le poids de chacun des indices dans le nouvel espace.


In [ ]:
ir = 0
rw = data_train_hash.getrow(ir)
print("Liste des tokens racinisé de la première ligne : " + train_array[0])
pd.DataFrame([(v, k)  for k,v in zip(rw.data,rw.indices)], columns=["indices","weight"])

Q Que pouvez-vous dire des poids?

La taille de la matrice a donc été très réduit par rapport au vectorizer, TFIDF ou One-Hot-Enconding. Cependant, il n'y a pas de fonction inverse transform ce qui peut rendre le résultat moin compréhensible.

Il est possible de combiner le FeatureHasheravec un autre vectorizer comme le TFIDF.

C'est cette fois la classe TFIDFTransformer qui est utilisé. Celle ci ne ne considère pas des string en entré mais l'array en sortie de l'étape de Hashage. Les mots sont les nb_hashindices sélectionnés et le tf pour chaque individu sont les poids calculé par la fonction de hasage.


In [ ]:
from sklearn.feature_extraction.text import TfidfTransformer

vec =  TfidfTransformer(norm = False)
data_train_HTfidf = vec.fit_transform(data_train_hash)
data_train_HTfidf

In [ ]:
ir = 0

rw = data_train_HTfidf.getrow(ir)
print(train_array[ir])
pd.DataFrame([(ind, vec.idf_[ind], w/vec.idf_[ind], w)  for w,ind in zip(rw.data, rw.indices)], columns=["indices","idf","tf","weight"])

Build and Save Vectorize Vector

Afin de comparer l'effet de ces différentes vectorisation sur l'apprentissage, nous allons sauvegarder ces dernières sur les machines.

De nombreux paramètres sont à régler ce qui entraine donc un un très grand nombre de combinaison.

ici nous allons créer 4 jeu de données avec CountVectorizer et TFIDF chacun avec et sans hashage de taille 300.

Il est evidemment possible de tester d'autre combinaison. Libre à vous de les tester ;)

Fonction de Vectorisation

on créé deux fonctions vectorizer_train and apply_vectorizer afin de générer automatiquement différent dataframe d'apprentissage et de validation vectorisé.


In [ ]:
def vectorizer_train(df, columns=['Description'], nb_hash=None, nb_gram = 1, vectorizer = "tfidf" , binary = False):
    
    data_array = [" ".join(line) for line in df[columns].values]
    
    # Hashage
    if nb_hash is None:
        feathash = None
        if vectorizer == "tfidf":
            vec = TfidfVectorizer(ngram_range=(1,nb_gram))
            data_vec = vec.fit_transform(data_array)
        else:
            vec = CountVectorizer(binary=binary)
            data_vec = vec.fit_transform(data_array)
    else:
        data_dic_array = [collections.Counter(line.split(" ")) for line in data_array]
        feathash = FeatureHasher(nb_hash)
        data_hash = feathash.fit_transform(data_dic_array)
        
        if vectorizer=="tfidf":
            vec =  TfidfTransformer()
            data_vec =  vec.fit_transform(data_hash)
        else:
            vec = None
            data_vec = data_hash

    return vec, feathash, data_vec



def apply_vectorizer(df, vec, feathash, columns =['Description', 'Libelle', 'Marque']):
    
    data_array = [" ".join(line) for line in df[columns].values]
    
    #Hashage
    if feathash is None:
        data_hash = data_array
    else:
        data_dic_array = [collections.Counter(line.split(" ")) for line in data_array]
        data_hash = feathash.transform(data_dic_array)
    
    if vec is None:
        data_vec = data_hash
    else:
        data_vec = vec.transform(data_hash)
    return data_vec

In [ ]:
parameters = [[None, "count"],
              [300, "count"],
              [None, "tfidf"],
              [300, "tfidf"]]

from scipy import sparse

for nb_hash, vectorizer in parameters:
    ts = time.time()
    vec, feathash, data_train_vec = vectorizer_train(data_train_clean_stem, nb_hash=nb_hash, vectorizer = vectorizer)
    data_valid_vec = apply_vectorizer(data_valid_clean_stem, vec, feathash)
    te = time.time()
    
    print("nb_hash : " + str(nb_hash) + ", vectorizer : " + str(vectorizer))
    print("Runing time for vectorization : %.1f seconds" %(te-ts))
    print("Train shape : " + str(data_train_vec.shape))
    print("Valid shape : " + str(data_valid_vec.shape))

    
    sparse.save_npz(DATA_OUTPUT_DIR +"/vec_train_nb_hash_" + str(nb_hash) + "_vectorizer_" + str(vectorizer), data_train_vec)
    sparse.save_npz(DATA_OUTPUT_DIR +"/vec_valid_nb_hash_" + str(nb_hash) + "_vectorizer_" + str(vectorizer), data_valid_vec)

Word2Vec

Dans cette partie, des modèles Word2VecSeront créés à l'aide de la librairie gensim.


In [ ]:
import gensim

Build Word2Vec model

La fonction gensim.models.Word2Vecqui permet de construire des modèle Word2Vec prend en entrée une liste de tokens. On tranformer donc nos données dans un premier temps


In [ ]:
train_array_token = [line.split(" ") for line in train_array]
valid_array_token = [line.split(" ") for line in valid_array]
train_array_token[0]

Cette fonction contient un grand nombre d' arguments. Le but de ce TP n'est pas d'optimiser les paramètres de ce modèle mais de les comprendre. Nous allons donc fixer quelques arguments par défault :

  • Features_dimension = 300 : Dimension de l'espace des features (d'embedding) qui sera crée.
  • hs = 0
  • negative = 10

Q A quoi servent les arguments hs et negative? Quels influences ces arguments ont sur le modèle avec les valeurs définies ici?


In [ ]:
Features_dimension = 300
hs = 0
negative = 10

Nous allons créer deux modèles :

  • Un modèle skip-sgram, sg = 1
  • Un modèle CBOW, sg = 0

In [ ]:
sg = 1
print("Start learning skip-gram Word2Vec")
ts = time.time()
model_sg = gensim.models.Word2Vec(train_array_token, sg=sg, hs=hs, negative=negative, min_count=1, size=Features_dimension)
te = time.time()
t_learning = te-ts
print("Learning time : %.2f Word2Vec" %t_learning)


sg = 0
print("Start learning CBOW Word2Vec")
ts = time.time()
model_cbow = gensim.models.Word2Vec(train_array_token, sg=sg, hs=hs, negative=negative, min_count=1, size=Features_dimension)
te = time.time()
t_learning = te-ts
print("Learning time : %.2f Word2Vec" %t_learning)

Q Que dire du temps d'apprentissage de ces deux modèles? D'ou vient cette différence?

Pre-Trained Model

Comme pour les réseaux de convolution, des modèles pré-entrainés de Word2Vec éxistent également. Le plus célèbre et le plus utilisé étant GoogleNewsVectors appris sur plus de 100 milliard de mots à partir des articles de GoogleNews. Cependant ce modèle est en anglais, et n'est donc )as utile ici.

On utilisera des modèle appris dans le projet suivant https://github.com/Kyubyong/wordvectors appris sur 1Giga d'articles de wikipedia en mode *Skip-Gram

Vous pouvez télécharger ce modèle en suivant ce lien. Dezipez-le puis téléchargez le modèle en indiquant la direction du fichier "fr/bin"


In [ ]:
model_online_dir = "data/fr/fr.bin"
#model_online_dir = "ACOMPLETER/fr.bin"
model_online = gensim.models.Word2Vec.load(model_online_dir)

Propriété du modèle

Nous allons maintenant comparer quelques propriétés de chacun des trois modèles à notre disposition (CBOW, Skip-Gram et le modèle online)

Les modèles que nous avons appris l'ont été sur les mots racinisé. Ainsi, nous allons avoir besoin de la racine des mots pour tester les différentes propriétés du modèle.


In [ ]:
import nltk 
stemmer=nltk.stem.SnowballStemmer('french')

Most similar world

La fonction most_similar de gensim permet de retrouver les mots les plus proches à un ou une combinaison de mots données en argument.

Q A l'aide de la documentation répondez aux questions suivantes. Quelle est la mesure de similarité utilisée? Dans quel espace est-elle utilisé? Comment fonctionne la fonction lorsque plusieurs mots sont passés en paramètres?

1 mot

Exercice Pour chacun de ces trois modèles, affichez les sorties de la fonction 'most_similar' pour le mot homme.


In [ ]:
# %load solution/2_3.py

Q Comparez la qualité de prévision des modèles que nous avons entrainés sur le jeu de données 'Cdiscount' avec celui appris online. Que pouvez-vous en dire?

Q Comparez les prévisions des deux modèles que nous avons entrainés. Que pouvez-vous en dire?

Exercice Affichez maintenant les sorties de la fonction 'most_similar' pour le mot femme.

Exercice Affichez les sorties de la fonction 'most_similar' pour des mots propre au jeu de données (ex. xbox, pantalon,..)

Combinaison de mots

Exercice Pour chacun de ces trois modèles, affichez les sorties de la fonction 'most_similar' pour l'opération suivante : roi + femme - homme à l'aide des arguments positive et negative de la fonction. Commentez encore une fois la qualité de sortie des différents modèle


In [ ]:
# %load solution/2_4.py

Exercice Testez d'autres combinaisons si vous le souhaitez.

Predict output word

La fonction predict_output_word de gensim permet de retrouver les mots prédit par le modèle à partir d'un mot ou d'une combinaison de mots données en argument.

Exercice Affichez la prédiction des trois modèles pour des mots/combinaisons de mots communs (homme, femme) ou plus propre au jeu de données étudiés. (coque-de-téléphone)...


In [ ]:
# %load solution/2_5.py

Build Features

Nous allons maintenant créer des matrices de features à partir des modèles de Word2Vec générés précédemment dans un but de prédiction.

La modèles créés permettent d'obtenir pour chaque mot x, sa représentation dans l'espace d'embeddings de la manière suivante :

x_feature = model_word2vec[x]

Dans notre problématique, les individus que nous cherchons à classer sont des descriptions représentées par une liste de token à l'issue du nettoyage du calepin précédent. Il exsite donc plusieurs façon de représenter ces individus à l'aide de modèle Word2Vec.

  1. Moyenne des vecteurs dans l'espace des features des différents mots/token de la description.
  2. Moyenne pondérées des vecteurs dans l'espace des features des différents mots/token de la description en fonction de l'occurence de chacun de ces mots/tokens dans la description.
  3. Moyenne pondérées par des poids calculés à l'aide du TF-IDF.
  4. etc...

C'est la seconde solution qui sera utilisée ici.

Les fonctions détaillées ci-dessous permettent de :

  • get_features_mean : retourne le vecteur moyen dans l'espace d'embedding, des projections des mots/tokens composant lines
  • get_matrix_features_means : applique la fonction get_features_mean sur tous les éléments de la matrice X.

In [ ]:
def get_features_mean(lines, model, f_size):
    features = [model[x] for x  in lines if x in model]
    if features == []:   
        fm =np.ones(f_size)
    else :
        fm = np.mean(features,axis=0)
    return fm

def get_matrix_features_means(X, model, f_size):
    X_embedded_ = list(map(lambda x : get_features_mean(x, model, f_size), X))
    X_embedded = np.vstack(X_embedded_)
    return X_embedded

Cbow


In [ ]:
ts = time.time()
X_embedded_train_cbow = get_matrix_features_means(train_array_token, model_cbow, Features_dimension)
te = time.time()
t_build = te-ts
#np.save(embedded_train_dir, X_embedded_train)
print("Time conversion : %d seconds"%t_build)
print("Shape Matrix : (%d,%d)"%X_embedded_train_cbow.shape)
np.save(DATA_OUTPUT_DIR +"/embedded_train_cbow", X_embedded_train_cbow)

ts = time.time()
X_embedded_valid_cbow = get_matrix_features_means(valid_array_token, model_cbow, Features_dimension)
te = time.time()
t_build = te-ts
#np.save(embedded_train_dir, X_embedded_train)
print("Time conversion : %d seconds"%t_build)
print("Shape Matrix : (%d,%d)"%X_embedded_valid_cbow.shape)
np.save(DATA_OUTPUT_DIR +"/embedded_valid_cbow", X_embedded_valid_cbow)

Skip-Gram


In [ ]:
ts = time.time()
X_embedded_train_sg = get_matrix_features_means(train_array_token, model_sg, Features_dimension)
te = time.time()
t_build = te-ts
#np.save(embedded_train_dir, X_embedded_train)
print("Time conversion : %d seconds"%t_build)
print("Shape Matrix : (%d,%d)"%X_embedded_train_sg.shape)
np.save(DATA_OUTPUT_DIR +"/embedded_train_sg", X_embedded_train_sg)

ts = time.time()
X_embedded_valid_sg = get_matrix_features_means(valid_array_token, model_sg, Features_dimension)
te = time.time()
t_build = te-ts
#np.save(embedded_train_dir, X_embedded_train)
print("Time conversion : %d seconds"%t_build)
print("Shape Matrix : (%d,%d)"%X_embedded_valid_sg.shape)
np.save(DATA_OUTPUT_DIR +"/embedded_valid_sg", X_embedded_valid_sg)

Online model


In [ ]:
data_valid_clean = pd.read_csv("data/cdiscount_valid_clean.csv").fillna("")
data_train_clean = pd.read_csv("data/cdiscount_train_clean.csv").fillna("")

train_array_token_nostem = [line.split(" ") for line in data_train_clean["Description"].values]
valid_array_token_nostem = [line.split(" ") for line in data_valid_clean["Description"].values]

In [ ]:
ts = time.time()
X_embedded_train_online = get_matrix_features_means(train_array_token_nostem, model_online, Features_dimension)
te = time.time()
t_build = te-ts
#np.save(embedded_train_dir, X_embedded_train)
print("Time conversion : %d seconds"%t_build)
print("Shape Matrix : (%d,%d)"%X_embedded_train_online.shape)
np.save(DATA_OUTPUT_DIR +"/embedded_train_online", X_embedded_train_online)

ts = time.time()
X_embedded_valid_online = get_matrix_features_means(valid_array_token_nostem, model_online, Features_dimension)
te = time.time()
t_build = te-ts
#np.save(embedded_train_dir, X_embedded_train)
print("Time conversion : %d seconds"%t_build)
print("Shape Matrix : (%d,%d)"%X_embedded_valid_online.shape)
np.save(DATA_OUTPUT_DIR +"/embedded_valid_online", X_embedded_valid_online)

In [ ]: