Skip to content
Snippets Groups Projects
Commit ed0963fd authored by SUR Frederic's avatar SUR Frederic
Browse files

2024-2025

parent 46cf7d02
No related branches found
No related tags found
No related merge requests found
%% Cell type:markdown id: tags:
# Introduction à l'apprentissage automatique: TP2 - Exercice 3
<br>
## 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](https://scikit-learn.org/stable/auto_examples/text/plot_document_clustering.html)
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:
%% Cell type:code id: tags:
``` python
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
%matplotlib notebook
%matplotlib inline
```
%% Cell type:markdown id: tags:
Ensuite on charge des messages venant de quatre groupes de discussion (`categories`) provenant du dataset _20 newsgroups_ (cela peut prendre quelques secondes):
%% Cell type:code id: tags:
``` python
categories = [
'misc.forsale',
'rec.sport.baseball',
'comp.graphics',
'sci.space',
]
print("Chargement du dataset 20newsgroups pour les catégories:")
print(categories)
dataset = fetch_20newsgroups(subset='all', categories=categories)
# si le chargement du dataset ne fonctionne pas,
# commentez la ligne précédente, téléchargez le fichier sur Arche, sauvegardez-le dans votre répertoire de travail, et décommentez la ligne suivante:
#dataset = fetch_20newsgroups(data_home="./", subset='all', categories=categories)
print(f"{len(dataset.data)} documents")
print(f"{len(dataset.target_names)} catégories")
print()
```
%% Cell type:markdown id: tags:
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):
%% Cell type:code id: tags:
``` python
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(f"contenu de l'attribut target: {dataset.target}")
print(f"contenu de l'attribut target_names: {dataset.target_names}")
print("\n")
no_doc = 10 # essayez avec d'autres documents parmi les 3929 chargés
no_doc = 20 # essayez avec d'autres documents parmi les 3929 chargés
print(f"Document no: {no_doc}")
print(f"numéro de catégorie: {dataset.target[no_doc]}")
print(f"ce qui correspond au sujet: {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
```
%% Cell type:markdown id: tags:
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 un document provient. On va donc procéder à un partitionnement (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 du partitionnement obtenu.
Les algorithmes de _clustering_ traitent généralement 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](https://en.wikipedia.org/wiki/TF-IDF) (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](https://scikit-learn.org/stable/modules/feature_extraction.html#text-feature-extraction), 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 dimension, 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 (après avoir ignoré les termes "trop fréquents"). Les termes rares ne seront donc pas considérés dans notre représentation des documents.
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.
Tout d'abord, nous commençons par recharger le jeu de données en éliminant l'en-tête (_header_) et la signature / citation en bas de page (_footer_) des documents: l'information qu'elles contiennent n'est généralement pas pertinente pour déterminer le sujet d'un document.
%% Cell type:code id: tags:
``` python
dataset = fetch_20newsgroups(subset='all', categories=categories, remove=('headers', 'footers'))
dataset = fetch_20newsgroups(subset='all', categories=categories, remove=('headers', 'footers')) # si vous avez chargé le fichier sur Arche, ajoutez l'option data_home="./" comme précédemment
print("Le texte sans header et footer est:")
print()
print(dataset.data[no_doc]) # vérifiez que headers et footers ont été éliminés
```
%% Cell type:markdown id: tags:
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.
%% Cell type:code id: tags:
``` python
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])
```
%% Cell type:markdown id: tags:
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.
<br>
__Récapitulons.__ Le sac de mot / vocabulaire est défini sur l'ensemble des documents: c'est le même pour tous les documents. La statistique TF-IDF est calculée pour chaque document, et est représentée par un vecteur de taille égale à la taille du sac de mot (1000 ici). Vectors contient les TF-IDF: c'est un tableau à 3929 lignes (une par document) et 1000 colonne (une par mot du sac).
<br>
__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?
%% Cell type:code id: tags:
``` python
print(vectorizer.vocabulary_)
```
%% Cell type:markdown id: tags:
<font color=red>
_Votre réponse_:
</font>
%% Cell type:markdown id: tags:
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).
%% Cell type:code id: tags:
``` python
# 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_out()[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_out()[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_out()[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_out()[TFIDF.argsort()[-k]],TFIDF.argsort()[-k])
plt.show();
```
%% Cell type:markdown id: tags:
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 un partitionnement de ces profils
* comme on le voit, les termes dont la statistique TF-IDF 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 un partitionnement de 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.
%% Cell type:markdown id: tags:
<br>
__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](https://scikit-learn.org/stable/modules/generated/sklearn.cluster.AgglomerativeClustering.html)), ainsi que `KMeans` (cf [documentation](https://scikit-learn.org/stable/modules/generated/sklearn.cluster.KMeans.html), faire `n_init=10`). Si vous avez le temps, vous pouvez essayer `MiniBatchKMeans` (cf [documentation](https://scikit-learn.org/stable/modules/generated/sklearn.cluster.MiniBatchKMeans.html)).
* 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$.
* On utilisera `AgglomerativeClustering` (voir exercice précédent) avec les options `linkage='single'` et `linkage='ward'` (cf [documentation](https://scikit-learn.org/stable/modules/generated/sklearn.cluster.AgglomerativeClustering.html)), ainsi que `KMeans` (cf [documentation](https://scikit-learn.org/stable/modules/generated/sklearn.cluster.KMeans.html), faire `n_init=10`). Si vous avez le temps, vous pouvez essayer `MiniBatchKMeans` (cf [documentation](https://scikit-learn.org/stable/modules/generated/sklearn.cluster.MiniBatchKMeans.html)).
* 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 TF-IDF associés au document) en tableau dont la ligne $i$ contient la représentation TF-IDF 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()`).
%% Cell type:code id: tags:
``` python
# votre code ici
```
%% Cell type:markdown id: tags:
Nous cherchons à présent à valider les partitionnements obtenus, ce qui est possible ici car la base de donnée initiale contient les "vrais" sujets. La difficulté dans un partitionnement est que les labels calculés sont arbitraires: le label 0 d'un partitionnement 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`?
%% Cell type:code id: tags:
``` python
# votre code ici
```
%% Cell type:markdown id: tags:
<font color=red>
_Votre réponse_:
</font>
%% Cell type:markdown id: tags:
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`).
La cellule suivante renumérote les labels attribués de manière à associer à un groupe le "vrai label" majoritaire dans ce groupe (fonction `mode` de `scipy`).
Cette manipulation permet de mieux comprendre quels sont les labels bien identifiés en comparant les labels obtenus aux "vrais" labels, qui doivent être les mêmes si le partitionnement s'est bien déroulé.
%% Cell type:code id: tags:
``` python
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])
```
%% Cell type:markdown id: tags:
On dispose des [matrices de confusion](https://scikit-learn.org/stable/modules/generated/sklearn.metrics.confusion_matrix.html) (à lire)
__Question 4__: que peut-on dire des résultats suivants?
%% Cell type:code id: tags:
``` python
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();
```
%% Cell type:markdown id: tags:
<font color=red>
_Votre réponse_:
</font>
%% Cell type:markdown id: tags:
### 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, et donc ne pas forcément chercher à identifier autant de groupes qu'il y a de newsgroups. 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é.e de TD à la prochaine séance, ou à lui envoyer un petit rapport.
%% Cell type:code id: tags:
``` python
```
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment