Introduction à l'apprentissage automatique: TP2 - Exercice 2


Regroupement de documents par sujet commun

Le but de cet exercice est de regrouper par sujet les documents d'un corpus portant sur différents sujets. La méthode utilisée (statistiques sur un sac de mots ) admet des variantes plus efficaces que ce que l'on utilise dans ce TP; ces variantes font toujours l'objet de recherches.

Nous allons utiliser un jeu de données de scikit-learn: des messages provenant de newsgroups consacrés à un sujet. Les newsgroups sont les ancêtres des forums.

L'exercice est inspiré de la documentation scikit-learn

Dans ce TP on appelle document un message de la base de données (un texte), terme un mot appartenant à un document, et corpus l'ensemble des documents considérés.

On commence par charger les bibliothèques utiles:

In [ ]:
# "magic function" Jupyter pour l'affichage des graphiques dans le carnet:
%matplotlib inline

import time

from sklearn.datasets import fetch_20newsgroups
from sklearn.cluster import KMeans, MiniBatchKMeans
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn import metrics
from sklearn import cluster

import numpy as np
import matplotlib.pyplot as plt

Ensuite on charge des messages venant de quatre groupes de discussion (categories) provenant du dataset 20 newsgroups (cela peut prendre quelques secondes):

In [ ]:
categories = [
    'misc.forsale',
    'rec.sport.baseball',
    'comp.graphics',
    'sci.space',
]

print("Loading 20newsgroups dataset for categories:")
print(categories)

dataset = fetch_20newsgroups(subset='all', categories=categories)

print("%d documents" % len(dataset.data))
print("%d categories" % len(dataset.target_names))
print()

On voit à l'aide des cellules suivantes que l'objet dataset a en particulier des attributs target (un entier représentant la catégorie du document, ici entre 0 et 3 car on a extrait 4 catégories), target_names (les noms des catégories en anglais, dans l'ordre des entiers de target), et data (le texte des messages):

In [ ]:
print(dir(dataset))   #  dir permet de lister les attributs et méthodes d'un objet
# print(dataset.DESCR)  # description du dataset (à regarder en complément d'information)
print("\n")
print("contenu de l'attribut target: %a" % dataset.target)
print("contenu de l'attribut target_names: %a" % dataset.target_names)

print("\n")
no_doc = 10   # essayez avec d'autres documents parmi les 3929 chargés
print("Document no: %d" % no_doc)
print("numéro de catégorie: %d" % dataset.target[no_doc]) 
print("ce qui correspond au sujet: %s" % dataset.target_names[dataset.target[no_doc]])
print()
print("Le texte est:")
print()
print(dataset.data[no_doc])   # vérifiez que le texte a l'air cohérent avec le sujet

L'objectif est de parvenir à regrouper les documents d'un corpus par sujets, en supposant bien entendu que l'on ne connaisse pas le newsgroup dont il provient. On va donc procéder à une classification non-supervisée sur les documents stockés dans dataset.data. L'information dans dataset.target nous servira uniquement pour vérifier à la fin la cohérence de la classification obtenue.

Les algorithmes de clustering traitent des points dans un espace (vectoriel) muni d'une distance. La première étape est donc de transformer chaque document en un vecteur.

Une approche standard dans le domaine de la fouille de documents est la transformation TF-IDF, décrite sur wikipedia (lisez le début de la page).

On considère un sous-ensemble de taille $N$ des termes présents dans tous les documents à classifier (un "sac de mots", défini ci-dessous), et on va calculer un vecteur de taille $N$ (la représentation TF-IDF) pour chaque document.

Pour déterminer cette représentation vectorielle TF-IDF, il y a deux étapes:

  • pour chaque document du corpus, on calcule la fréquence d'apparition dans ce document de chacun des termes du sac de mots: à ce stade chaque document est représenté par un vecteur de fréquences (étape TF: term frequency ).
  • les vecteurs sont normalisés de manière à ce que les termes présents dans beaucoup de documents du corpus (ce sont donc des termes peu discriminants) aient un poids plus faible dans le vecteur représentant un document (étape IDF: inverse document frequency ). Intuitivement, si un terme apparaît fréquemment dans un document donné, son importance pour le sujet à identifier n'est pas la même s'il est de toute façon présent dans tous les documents (indépendamment de leur sujet) ou non.

Comme vous le constatez à la lecture de la page wikipedia, plusieurs variantes existent pour calculer TF et IDF. Scikit-learn propose des fonctions permettant de déterminer le sac de mots et de calculer la représentation TF-IDF des documents du corpus (plus de détails ici, lecture facultative en complément d'information).

Dans les cellules suivantes, on associe à chaque document du corpus un vecteur TF-IDF. Pour former le "sac de mots" ( bag of words ) de taille $N$, on ne tient pas compte des termes trop courants (max_df: les termes présents dans plus de 50% des documents sont éliminés), ainsi que des termes trop courants en anglais (stop_words, contenus dans une liste pré-définie). De manière à limiter les temps de calcul (et éviter la malédiction de la dimensionalité), on construit des vecteurs de dimension $N$ limitée en ne gardant comme vocabulaire que les $N$ termes les plus fréquents dans tout le corpus. Les termes rares ne seront donc pas considérés dans notre représentation des textes.

Tout d'abord, nous commençons par recharger le jeu de données en éliminant l'en-tête et la citation en bas de page des documents: l'information qu'elles contiennent n'est généralement pas pertinente pour déterminer le sujet d'un document.

In [ ]:
dataset = fetch_20newsgroups(subset='all', categories=categories, remove=('headers', 'footers'))
print("Le texte sans header et footer est:")
print()
print(dataset.data[no_doc])   # vérifiez que headers et footers ont été éliminés

Puis on crée le sac de mots de taille $N=1000$ et le vecteur TF-IDF pour chaque texte, comme indiqué précédemment.

In [ ]:
N=1000

vectorizer = TfidfVectorizer(max_features=N, max_df=0.5, stop_words='english')   
vectors = vectorizer.fit_transform(dataset.data)   # création des représentations TF-IDF

print("nombre de documents représentés %d" %vectors.shape[0])
print("nombre de mots dans le vocabulaire %d" %vectors.shape[1])

A ce stade, vectors[no_doc] est un vecteur TF_IDF de dimension $N$ représentant le document d'indice no_doc.

Remarquons que par défaut, la "vectorisation" TF-IDF est normalisée de manière à ce qu'un document soit représenté par un vecteur de norme euclidienne 1. Cela rend la représentation indépendante de la taille du document.


Question 1. La cellule suivante affiche le sac de mot généré par vectorisation. Que constatez-vous si le sac de mots est généré sans stop_words='english' dans la cellule précédente? Que se passe-t-il si le sac de mot est trop petit?

In [ ]:
print(vectorizer.vocabulary_)

Votre réponse:

A titre illustratif, le code suivant représente le vecteur associé à quatre documents: en ordonnée on voit la statistique TF-IDF, en abscisse le numéro du mot dans le "sac de mots". (pas besoin de comprendre la syntaxe)

Si un mot a un TF-IDF grand, c'est qu'il est à la fois fréquent dans le document considéré (TF grand) et qu'il est présent dans relativement peu de documents du corpus (IDF grand également).

In [ ]:
# l'affichage peut prendre un peu de temps

plt.figure()
no_doc=10
TFIDF=np.asarray(vectors[no_doc].todense())[0]
plt.bar(np.arange(N),TFIDF,width=10);
plt.title("sujet:"+dataset.target_names[dataset.target[no_doc]]);
print("document no: %d  -- sujet: %s" %(no_doc,dataset.target_names[dataset.target[no_doc]]) )
print("les 5 mots avec les statistiques TF-IDF les plus élevées sont:")
for k in range(1,6):
    if TFIDF[TFIDF.argsort()[-k]] > 0.0:   # certains documents (ex: no_doc=67) sont représentés par moins de 5 mots...
        print(vectorizer.get_feature_names()[TFIDF.argsort()[-k]],TFIDF.argsort()[-k])
plt.show();
    
plt.figure();
no_doc=100
TFIDF=np.asarray(vectors[no_doc].todense())[0]
plt.bar(np.arange(N),TFIDF,width=10);
plt.title("sujet:"+dataset.target_names[dataset.target[no_doc]]);
print("document no: %d  -- sujet: %s" %(no_doc,dataset.target_names[dataset.target[no_doc]]) )
print("les 5 mots avec les statistiques TF-IDF les plus élevées sont:")
for k in range(1,6):
    if TFIDF[TFIDF.argsort()[-k]] > 0.0:
        print(vectorizer.get_feature_names()[TFIDF.argsort()[-k]],TFIDF.argsort()[-k])
plt.show();
    
plt.figure()
no_doc=50
TFIDF=np.asarray(vectors[no_doc].todense())[0]
plt.bar(np.arange(N),TFIDF,width=10);
plt.title("sujet:"+dataset.target_names[dataset.target[no_doc]]);
print("document no: %d  -- sujet: %s" %(no_doc,dataset.target_names[dataset.target[no_doc]]) )
print("les 5 mots avec les statistiques TF-IDF les plus élevées sont:")
for k in range(1,6):
    if TFIDF[TFIDF.argsort()[-k]] > 0.0:
        print(vectorizer.get_feature_names()[TFIDF.argsort()[-k]],TFIDF.argsort()[-k])
plt.show()
        
plt.figure()
no_doc=1000
TFIDF=np.asarray(vectors[no_doc].todense())[0]
plt.bar(np.arange(N),TFIDF,width=10);
plt.title("sujet:"+dataset.target_names[dataset.target[no_doc]]);
print("document no: %d  -- sujet: %s" %(no_doc,dataset.target_names[dataset.target[no_doc]]) )
print("les 5 mots avec les statistiques TF-IDF les plus élevées sont:")
for k in range(1,6):
    if TFIDF[TFIDF.argsort()[-k]] > 0.0:
        print(vectorizer.get_feature_names()[TFIDF.argsort()[-k]],TFIDF.argsort()[-k])
plt.show();

Quelques remarques:

  • comme on le voit, les termes dont la statistique TFIDF est la plus élevée ont l'air d'avoir effectivement un rapport avec le sujet: il semble donc raisonnable d'utiliser comme représentation vectorielle d'un document le profil TF-IDF et de faire une classification non-supervisée sur ces profils
  • on constate que, généralement, seuls quelques mots parmi les $N$ sont présents dans un document donné (donc beaucoup de 0 dans le vecteur TF-IDF)
  • le document 1000 correspond à un très long document (faire print(dataset.data[1000])). Beaucoup des mots du sac sont donc présents.


Question 2 : utilisez les algorithmes de classification hiérarchique (single-linkage, Ward) et k-means pour identifier des groupements parmi les vecteurs TF-IDF (donc parmi les documents associés) en quatre groupes. Les labels (valeurs entre 0 et 3) calculés pour chaque vecteur TF-IDF correspondent au numéro du groupe identifié.

Indications :

  • On utilisera AgglomerativeClustering avec les options linkage='single' et linkage='ward' (cf documentation) et MiniBatchKMeans (cf documentation). Pour MiniBatchKMeans, passez l'option n_init=100.
  • Les algorithmes travaillent sur des bases de données sous forme de tableau ( array ): il faudra donc travailler avec vectors.toarray() qui transforme vectors (les vecteurs TFIDF associés au document) en tableau dont la ligne $i$ contient la représentation TFIDF du document no $i$.
  • Sauvegardez les labels identifiés par chaque méthode dans des variables labels_single, labels_ward, et labels_KM.
  • Observez les différences de temps de calcul (obtenus en faisant la différence entre les valeurs retournées par time.time()).
In [ ]:
# votre code ici

Nous cherchons à présent à valider les classifications obtenues, ce qui est possible ici car la base de donnée initiale contient les "vrais" sujets. La difficulté dans une classification non-supervisée est que les labels calculés sont arbitraires: le label 0 d'une classification n'a pas de raison de correspondre au "vrai" label 0.

Question 3: commencez par afficher les labels attribués par chaque méthode aux 30 premiers documents, et comparez-les aux "vrais" labels. Que peut-on déjà dire de single linkage?

In [ ]:
# votre code ici

Votre réponse:

La cellule suivante renumérote les labels attribués de manière à leur associer le "vrai" label majoritaire dans le groupe identifié (fonction mode de scipy). Cette manipulation permet de mieux comprendre quels sont les labels bien identifiés.

In [ ]:
from scipy.stats import mode

labels_single2 = np.zeros_like(labels_single)
labels_ward2 = np.zeros_like(labels_ward)
labels_KM2 = np.zeros_like(labels_KM)
for i in range(len(categories)):
    mask = (labels_single == i)
    labels_single2[mask] = mode(dataset.target[mask])[0]
    mask = (labels_ward == i)
    labels_ward2[mask] = mode(dataset.target[mask])[0]
    mask = (labels_KM == i)
    labels_KM2[mask] = mode(dataset.target[mask])[0]

print("Après renumérotations des labels:\n")
print("vrai labels  : ",dataset.target[0:30])
print("labels single: ",labels_single2[0:30])
print("labels ward  : ",labels_ward2[0:30])
print("labels KM    : ",labels_KM2[0:30])   

On dispose des matrices de confusion (à lire)

Question 4: que peut-on dire des résultats suivants?

In [ ]:
from sklearn.metrics import confusion_matrix

print("matrice de confusion pour 'single-linkage':")
print(confusion_matrix(dataset.target,labels_single2))
plt.figure()
plt.imshow(confusion_matrix(dataset.target,labels_single2))  # représentation visuelle
plt.colorbar()
plt.show();

print()
print("matrice de confusion pour 'Ward':")
print(confusion_matrix(dataset.target,labels_ward2))
plt.figure()
plt.imshow(confusion_matrix(dataset.target,labels_ward2))  # représentation visuelle
plt.colorbar()
plt.show();

print()
print("matrice de confusion pour 'KMeans':")
print(confusion_matrix(dataset.target,labels_KM2))
plt.figure()
plt.imshow(confusion_matrix(dataset.target,labels_KM2))  # représentation visuelle
plt.colorbar()
plt.show();

Votre réponse:

Pour les plus rapides (travail facultatif)

Essayez également d'identifier plus de quatre sujets (en chargeant un plus grand nombre de newsgroups), voire avec l'ensemble du jeu de données 20newsgroups.

Plusieurs newsgroups ont des sujets proches, il serait donc raisonnable de regrouper les documents de ces newsgroups dans le même sujet. Pour KMeans, utilisez la méthode du coude pour identifier un nombre de groupes "optimal" parmi les 20 newsgroups.

Si vous avez eu le temps d'approfondir et avez réussi à obtenir des résultats sur le jeu de données complet, n'hésitez pas à en faire part à votre chargé de TD à la prochaine séance.

In [ ]: