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 2-1 Catégorisation des Produits Cdiscount avec Scikit-learn de

Le principal objectif est de comparer les performances: temps de calcul, qualité des résultats, des principales technologies; ici Python avec la librairie Scikit-Learn. Il s'agit d'un problème de fouille de texte qui enchaîne nécessairement plusieurs étapes et le choix de la meilleure stratégie est fonction de l'étape:

  • Spark pour la préparation des données: nettoyage, racinisaiton
  • Python Scikit-learn pour la transformaiton suivante (TF-IDF) et l'apprentissage notamment avec la régresison logistique qui conduit aux meilleurs résultats.

L'objectif est ici de comparer les performances des méthodes et technologies en fonction de la taille de la base d'apprentissage. La stratégie de sous ou sur échantillonnage des catégories qui permet d'améliorer la prévision n'a pas été mise en oeuvre.

  • L'exemple est présenté avec la possibilité de sous-échantillonner afin de réduire les temps de calcul.
  • L'échantillon réduit peut encore l'être puis, après "nettoyage", séparé en 2 parties: apprentissage et test.
  • Les données textuelles de l'échantillon d'apprentissage sont, "racinisées", "hashées", "vectorisées" avant modélisation.
  • Les mêmes transformations, notamment (hashage et TF-IDF) évaluées sur l'échantillon d'apprentissage sont appliquées à l'échantillon test.
  • Un seul modèle est estimé par régression logistique "multimodal", plus précisément et implicitement, un modèle par classe.
  • Différents paramètres: de vectorisation (hashage, TF-IDF), paramètres de la régression logistique (pénalisation L1) pourraient encore être optimisés.

In [ ]:
#Importation des librairies utilisées
import unicodedata 
import time
import pandas as pd
import numpy as np
import random
import nltk
import collections
import itertools
import csv
import warnings

from sklearn.cross_validation import train_test_split

1. Importation des données

Définition du répertoir de travail, des noms des différents fichiers utilisés et des variables globales.

Dans un premier temps, il vous faut télécharger les fichiers Categorie_reduit.csv et lucene_stopwords.txt disponible dans le corpus de données de wikistat.

Une fois téléchargées, placez ces données dans le repertoire de travail de votre choix et préciser la direction de ce repertoir dans la variable DATA_DIR


In [ ]:
# Répertoire de travail
DATA_DIR = ""
# Nom des fichiers
training_reduit_path = DATA_DIR + "data/cdiscount_train.csv.zip"
# Variable Globale
HEADER_TEST = ['Description','Libelle','Marque']
HEADER_TRAIN =['Categorie1','Categorie2','Categorie3','Description','Libelle','Marque']

In [ ]:
## Si nécessaire (première exécution) chargement de nltk, librairie pour la suppression 
## des mots d'arrêt et la racinisation
# nltk.download()

Read & Split Dataset

Fonction permettant de lire le fichier d'apprentissage et de créer deux DataFrame Pandas, un pour l'apprentissage, l'autre pour la validation. La première méthode créée un DataFrame en lisant entièrement le fichier. Puis elle scinde le DataFrame en deux grâce à la fonction dédiée de sklearn.


In [ ]:
def split_dataset(input_path, nb_line, tauxValid,columns):
    time_start = time.time()
    data_all = pd.read_csv(input_path,sep=",",names=columns,nrows=nb_line)
    data_all = data_all.fillna("")
    data_train, data_valid = train_test_split(data_all, test_size = tauxValid)
    time_end = time.time()
    print("Split Takes %d s" %(time_end-time_start))
    return data_train, data_valid

nb_line=20000  # part totale extraite du fichier initial ici déjà réduit
tauxValid=0.10 # part totale extraite du fichier initial ici déjà réduit
data_train, data_valid = split_dataset(training_reduit_path, nb_line, tauxValid, HEADER_TRAIN)
# Cette ligne permet de visualiser les 5 premières lignes de la DataFrame 
data_train.head(5)

2. Nettoyage des données

Afin de limiter la dimension de l'espace des variables ou features, tout en conservant les informations essentielles, il est nécessaire de nettoyer les données en appliquant plusieurs étapes:

  • Chaque mot est écrit en minuscule.
  • Les termes numériques, de ponctuation et autres symboles sont supprimés.
  • 155 mots-courants, et donc non informatifs, de la langue française sont supprimés (STOPWORDS). Ex: le, la, du, alors, etc...
  • Chaque mot est "racinisé", via la fonction STEMMER.stem de la librairie nltk. La racinisation transforme un mot en son radical ou sa racine. Par exemple, les mots: cheval, chevaux, chevalier, chevalerie, chevaucher sont tous remplacés par "cheva".

Importation des librairies et fichier pour le nettoyage des données.


In [ ]:
# Librairies 
from bs4 import BeautifulSoup #Nettoyage d'HTML
import re # Regex
import nltk # Nettoyage des données

## listes de mots à supprimer dans la description des produits
## Depuis NLTK
nltk_stopwords = nltk.corpus.stopwords.words('french') 
## Depuis Un fichier externe.
lucene_stopwords =open(DATA_DIR+"data/lucene_stopwords.txt","r").read().split(",") #En local
## Union des deux fichiers de stopwords 
stopwords = list(set(nltk_stopwords).union(set(lucene_stopwords)))

## Fonction de setmming de stemming permettant la racinisation
stemmer=nltk.stem.SnowballStemmer('french')

Fonction de nettoyage de texte

Fonction qui prend en intrée un texte et retourne le texte nettoyé en appliquant successivement les étapes suivantes: Nettoyage des données HTML, conversion en texte minuscule, encodage uniforme, suppression des caractéres non alpha numérique (ponctuations), suppression des stopwords, racinisation de chaque mot individuellement.


In [ ]:
# Fonction clean générale
def clean_txt(txt):
    ### remove html stuff
    txt = BeautifulSoup(txt,"html.parser",from_encoding='utf-8').get_text()
    ### lower case
    txt = txt.lower()
    ### special escaping character '...'
    txt = txt.replace(u'\u2026','.')
    txt = txt.replace(u'\u00a0',' ')
    ### remove accent btw
    txt = unicodedata.normalize('NFD', txt).encode('ascii', 'ignore').decode("utf-8")
    ###txt = unidecode(txt)
    ### remove non alphanumeric char
    txt = re.sub('[^a-z_]', ' ', txt)
    ### remove french stop words
    tokens = [w for w in txt.split() if (len(w)>2) and (w not in stopwords)]
    ### french stemming
    tokens = [stemmer.stem(token) for token in tokens]
    ### tokens = stemmer.stemWords(tokens)
    return ' '.join(tokens)

def clean_marque(txt):
    txt = re.sub('[^a-zA-Z0-9]', '_', txt).lower()
    return txt

Nettoyage des DataFrames

Applique le nettoyage sur toutes les lignes de la DataFrame


In [ ]:
# fonction de nettoyage du fichier(stemming et liste de mots à supprimer)
def clean_df(input_data, column_names= ['Description', 'Libelle', 'Marque']):
    #Test if columns entry match columns names of input data
    column_names_diff= set(column_names).difference(set(input_data.columns))
    if column_names_diff:
        warnings.warn("Column(s) '"+", ".join(list(column_names_diff)) +"' do(es) not match columns of input data", Warning)
    
    nb_line = input_data.shape[0]
    print("Start Clean %d lines" %nb_line)
    
    # Cleaning start for each columns
    time_start = time.time()
    clean_list=[]
    for column_name in column_names:
        column = input_data[column_name].values
        if column_name == "Marque":
            array_clean = np.array(list(map(clean_marque,column)))
        else:
            array_clean = np.array(list(map(clean_txt,column)))
        clean_list.append(array_clean)
    time_end = time.time()
    print("Cleaning time: %d secondes"%(time_end-time_start))
    
    #Convert list to DataFrame
    array_clean = np.array(clean_list).T
    data_clean = pd.DataFrame(array_clean, columns = column_names)
    return data_clean

In [ ]:
# Take approximately 2 minutes fors 100.000 rows
data_valid_clean = clean_df(data_valid)
data_train_clean = clean_df(data_train)

Affiche les 5 premières lignes de la DataFrame d'apprentissage après nettoyage.


In [ ]:
data_train_clean.head(5)

3 Construction des caractéristiques ou features (TF-IDF)

Introduction

La vectorisation, c'est-à-dire la construction des caractéristiques à partir de la liste des mots se fait en 2 étapes:

  • 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

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

Fonction de Vectorisation


In [ ]:
## Création d’une matrice indiquant
## les fréquences des mots contenus dans chaque description
## de nombreux paramètres seraient à tester
from sklearn.feature_extraction.text import TfidfVectorizer, TfidfTransformer
from sklearn.feature_extraction import FeatureHasher


def vectorizer_train(df, columns=['Description', 'Libelle', 'Marque'], nb_hash=None, stop_words=None):
    
    # Hashage
    if nb_hash is None:
        data_hash = map(lambda x : " ".join(x), df[columns].values)
        feathash = None
            # TFIDF
        vec = TfidfVectorizer(
            min_df = 1,
            stop_words = stop_words,
            smooth_idf=True,
            norm='l2',
            sublinear_tf=True,
            use_idf=True,
            ngram_range=(1,2)) #bi-grams
        tfidf = vec.fit_transform(data_hash)
    else:
        df_text = map(lambda x : collections.Counter(" ".join(x).split(" ")), df[columns].values)
        feathash = FeatureHasher(nb_hash)
        data_hash = feathash.fit_transform(map(collections.Counter,df_text))
        
        vec =  TfidfTransformer(use_idf=True,
                            smooth_idf=True, sublinear_tf=False)
        tfidf =  vec.fit_transform(data_hash)

    return vec, feathash, tfidf



def apply_vectorizer(df, vec, columns =['Description', 'Libelle', 'Marque'], feathash = None ):
    
    #Hashage
    if feathash is None:
        data_hash = map(lambda x : " ".join(x), df[columns].values)
    else:
        df_text = map(lambda x : collections.Counter(" ".join(x).split(" ")), df[columns].values)
        data_hash = feathash.transform(df_text)
    
    # TFIDF
    tfidf=vec.transform(data_hash)
    return tfidf

In [ ]:
vec, feathash, X = vectorizer_train(data_train_clean, nb_hash=60)
Y = data_train['Categorie1'].values

Xv = apply_vectorizer(data_valid_clean, vec, feathash=feathash)
Yv=data_valid['Categorie1'].values

4. Modélisation et performances


In [ ]:
# Regression Logistique 
## estimation
from sklearn.linear_model import LogisticRegression
cla = LogisticRegression(C=100)
cla.fit(X,Y)
score=cla.score(X,Y)
print('# training score:',score)

In [ ]:
## erreur en validation
scoreValidation=cla.score(Xv,Yv)
print('# validation score:',scoreValidation)

In [ ]:
#Méthode  CART
from sklearn import tree
clf = tree.DecisionTreeClassifier()
time_start = time.time()
clf = clf.fit(X, Y)
time_end = time.time()
print("CART Takes %d s" %(time_end-time_start) )
score=clf.score(X,Y)
print('# training score :',score)

In [ ]:
scoreValidation=clf.score(Xv,Yv)
print('# validation score :',scoreValidation)

In [ ]:
# Random forest
from sklearn.ensemble import RandomForestClassifier
rf = RandomForestClassifier(n_estimators=100,n_jobs=-1,max_features=24)
time_start = time.time()
rf = rf.fit(X, Y)
time_end = time.time()
print("RF Takes %d s" %(time_end-time_start) )
score=rf.score(X,Y)
print('# training score :',score)

In [ ]:
scoreValidation=rf.score(Xv,Yv)
print('# validation score :',scoreValidation)