Lancez LDA dans le monde réel. Guide détaillé

Préface


Il existe de nombreux tutoriels sur Internet qui expliquent comment fonctionne le LDA (Latent Dirichlet Allocation) et comment le mettre en pratique. Des exemples de formation LDA sont souvent présentés sur des ensembles de données «exemplaires», tels que «l'ensemble de données 20 groupes de discussion», qui est disponible sur sklearn.


Une caractéristique de la formation sur l'exemple d'ensembles de données "exemplaires" est que les données y sont toujours en ordre et empilées en un seul endroit. Lors de la formation de modèles de production, les données obtenues directement à partir de sources réelles sont généralement le contraire:


  • Beaucoup d'émissions.
  • Balisage incorrect (le cas échéant).
  • Déséquilibres de classe très forts et distributions moches de tous les paramètres de l'ensemble de données.
  • Pour les textes, ce sont: les erreurs grammaticales, un grand nombre de mots rares et uniques, le multilinguisme.
  • Un moyen peu pratique de stocker des données (formats différents ou rares, besoin d'analyse)

Historiquement, j'essaie d'apprendre à partir d'exemples qui se rapprochent le plus possible des réalités de la réalité de production car c'est de cette façon que l'on peut le plus pleinement percevoir les zones problématiques d'un type de tâche particulier. C'était donc avec le LDA, et dans cet article, je veux partager mon expérience - comment exécuter LDA à partir de zéro, sur des données complètement brutes. Une partie de l'article sera consacrée à l'obtention de ces mêmes données, afin que l'exemple devienne un «cas d'ingénierie» à part entière.


Modélisation de sujets et LDA.


Pour commencer, considérez ce que fait le LDA en général et quelles tâches il utilise.
Le plus souvent, LDA est utilisé pour les tâches de modélisation de sujet. De telles tâches signifient les tâches de regroupement ou de classification des textes - de telle manière que chaque classe ou cluster contient des textes ayant des sujets similaires.


Afin d'appliquer la LDA à l'ensemble de données des textes (ci-après dénommé le corps du texte), il est nécessaire de transformer le corps en une matrice terme-document.


Une matrice de document de terme est une matrice qui a une taille N foisW
N est le nombre de documents dans l'affaire, et W est la taille du dictionnaire de l'affaire, c'est-à-dire le nombre de mots (uniques) que l'on retrouve dans notre corpus. Dans la i-ème ligne, la j-ème colonne de la matrice est un nombre - combien de fois dans le i-ème texte le j-ème mot a été trouvé.


Le LDA construit, pour une matrice de document Term donnée et T d'un nombre prédéterminé de thèmes, deux distributions:


  1. La répartition des sujets dans les textes (en pratique, donnée par la matrice de taille N foisT)
  2. La répartition des mots par sujet (matrice de taille T foisW)

Les valeurs des cellules de ces matrices sont, respectivement, les probabilités que ce sujet soit contenu dans ce document (ou la proportion du sujet dans le document, si nous considérons le document comme un mélange de différents sujets) pour la matrice `` Distribution des sujets dans les textes ''.


Pour la matrice `` Distribution des mots par thèmes '', les valeurs sont la probabilité de rencontrer le mot j dans le texte avec le sujet i, qualitativement, nous pouvons considérer ces nombres comme des coefficients caractérisant la façon dont ce mot est typique de ce sujet.


Il faut dire que le mot sujet n'est pas une définition «quotidienne» de ce mot. La LDA attribue T à ceux-ci, mais quel type de sujets sont-ils et s'ils correspondent à des sujets de textes bien connus, tels que: «Sport», «Science», «Politique» - est inconnu. Dans ce cas, il est plus approprié de parler du sujet comme d'une sorte d'entité abstraite, qui est définie par une ligne dans la matrice de distribution des mots par sujet et avec une certaine probabilité correspond à ce texte, si vous pouvez l'imaginer comme une famille d'ensembles caractéristiques de mots se rencontrant avec les probabilités correspondantes (du tableau) dans un certain ensemble de textes.


Si vous souhaitez étudier plus en détail et «dans les formules» comment le LDA est formé et fonctionne, voici quelques documents (qui ont été utilisés par l'auteur):



Nous obtenons des données sauvages


Pour nos «travaux de laboratoire», nous avons besoin d'un ensemble de données personnalisé avec ses propres défauts et fonctionnalités. Vous pouvez l'obtenir à différents endroits: téléchargez des critiques de Kinopoisk, des articles Wikipédia, des nouvelles d'un portail de nouvelles, nous prendrons une option un peu plus extrême - des publications des communautés VKontakte.


Nous ferons ceci comme ceci:


  1. Nous sélectionnons un utilisateur VK.
  2. Nous obtenons une liste de tous ses amis.
  3. Pour chaque ami, nous prenons toute sa communauté.
  4. Pour chaque communauté de chaque ami, nous pompons les n (n = 100) premiers messages de la communauté et les combinons en un seul texte de communauté.

Outils et articles


Pour télécharger des articles, nous utiliserons le module vk pour travailler avec l'API VKontakte, pour Python. L'un des moments les plus complexes lors de l'écriture d'une application à l'aide de l'API VKontakte est l'autorisation, heureusement, le code qui effectue ce travail est déjà écrit et est dans le domaine public, sauf pour vk, j'ai utilisé un petit module d'autorisation - vkauth.


Liens vers les modules et articles utilisés pour étudier l'API VKontakte:



Écrire un code


Et donc, en utilisant vkauth, connectez-vous:


#authorization of app using modules imported. app_id = '6203169' perms = ['photos','friends','groups'] API_ver = '5.68' Auth = VKAuth(perms, app_id, API_ver) Auth.auth() token = Auth.get_token() user_id = Auth.get_user_id() #starting session session = vk.Session(access_token=token) api = vk.API(session) 

Dans le processus, un petit module a été écrit contenant toutes les fonctions nécessaires au téléchargement de contenu au format approprié, elles sont listées ci-dessous, passons en revue:


 def get_friends_ids(api, user_id): ''' For a given API object and user_id returns a list of all his friends ids. ''' friends = api.friends.get(user_id=user_id, v = '5.68') friends_ids = friends['items'] return friends_ids def get_user_groups(api, user_id, moder=True, only_open=True): ''' For a given API user_id returns list of all groups he subscribed to. Flag model to get only those groups where user is a moderator or an admin) Flag only_open to get only public(open) groups. ''' kwargs = {'user_id' : user_id, 'v' : '5.68' } if moder == True: kwargs['filter'] = 'moder' if only_open == True: kwargs['extended'] = 1 kwargs['fields'] = ['is_closed'] groups = api.groups.get(**kwargs) groups_refined = [] for group in groups['items']: cond_check = (only_open and group['is_closed'] == 0) or not only_open if cond_check: refined = {} refined['id'] = group['id'] * (-1) refined['name'] = group['name'] groups_refined.append(refined) return groups_refined def get_n_posts_text(api, group_id, n_posts=50): ''' For a given api and group_id returns first n_posts concatenated as one text. ''' wall_contents = api.wall.get(owner_id = group_id, count=n_posts, v = '5.68') wall_contents = wall_contents['items'] text = '' for post in wall_contents: text += post['text'] + ' ' return text 

Le pipeline final est le suivant:


 #id of user whose friends you gonna get, like: https://vk.com/id111111111 user_id = 111111111 friends_ids = vt.get_friends_ids(api, user_id) #collecting all groups groups = [] for i,friend in tqdm(enumerate(friends_ids)): if i % 3 == 0: sleep(1) friend_groups = vt.get_user_groups(api, friend, moder=False) groups += friend_groups #converting groups to dataFrame groups_df = pd.DataFrame(groups) groups_df.drop_duplicates(inplace=True) #reading content(content == first 100 posts) for i,group in tqdm(groups_df.iterrows()): name = group['name'] group_id = group['id'] #Different kinds of fails occures during scrapping #For examples there are names of groups with slashes #Like: 'The Kaaats / Indie-rock' try: content = vt.get_n_posts_text(api, group_id, n_posts=100) dst_path = join(data_path, name + '.txt') with open(dst_path, 'w+t') as f: f.write(content) except Exception as e: print('Error occured on group:', name) print(e) continue #need it because of requests limitaion in VK API. if i % 3 == 0: sleep(1) 

Échoue


En général, le processus de téléchargement de données n'est pas difficile en soi; vous devez faire attention à seulement deux points:


  1. Parfois, en raison de la confidentialité de certaines communautés, vous recevrez des erreurs d'accès, parfois d'autres erreurs seront résolues en installant try, sauf au bon endroit.
  2. VK a une limite sur le nombre de requêtes par seconde.

Lorsque vous effectuez un grand nombre de demandes, par exemple dans une boucle, nous détectons également des erreurs. Ce problème peut être résolu de plusieurs manières:


  1. Stupidement et sans détour: Stick sommeil (certains) toutes les 3 demandes. Cela se fait sur une seule ligne et ralentit considérablement le déchargement, dans des situations où les volumes de données ne sont pas importants et où il n'y a pas de temps pour des méthodes plus sophistiquées - cela est tout à fait acceptable (implémenté dans cet article).
  2. Comprendre le travail des demandes de sondage long https://vk.com/dev/using_longpoll

Dans cet article, une méthode simple et lente a été choisie, à l'avenir, j'écrirai probablement un micro article sur les moyens de contourner ou d'assouplir les restrictions sur le nombre de requêtes par seconde.


Résumé


Avec la graine «certains» utilisateurs ayant ~ 150 amis, ils ont réussi à obtenir 4 679 textes - chacun caractérise une certaine communauté VK. Les textes varient considérablement en taille et sont écrits dans de nombreuses langues - certains d'entre eux ne conviennent pas à nos fins, mais nous en parlerons un peu plus loin.


Corps principal


image


Passons en revue tous les blocs de notre pipeline - d'abord, sur le obligatoire (idéal), puis sur le reste - ils sont juste du plus grand intérêt.


Countvectorizer


Avant d'enseigner le LDA, nous devons présenter nos documents sous la forme d'une matrice de documents Term. Cela comprend généralement des opérations telles que:


  • Suppression des puttuctions / numéros / jetons inutiles.
  • Tokenisation (présentation sous forme de liste de mots)
  • Compter les mots, compiler une matrice de document thermique.

Toutes ces actions dans sklearn sont commodément implémentées dans le cadre d'une entité de programme - sklearn.feature_extraction.text.CountVectorizer.


Lien vers la documentation


Il vous suffit de:


 count_vect = CountVectorizer(input='filename', stop_words=stopwords, vocabulary=voc) dataset = count_vect.fit_transform(train_names) 

Lda


De même avec CountVectorizer, LDA est parfaitement implémenté dans Sklearn et d'autres frameworks, donc, il n'y a pas beaucoup de sens à consacrer beaucoup d'espace directement à leurs implémentations, dans notre article purement pratique.


Lien vers la documentation


Tout ce dont vous avez besoin pour démarrer LDA est:


 #training LDA lda = LDA(n_components = 60, max_iter=30, n_jobs=6, learning_method='batch', verbose=1) lda.fit(dataset) 

Prétraitement


Si nous prenons juste nos textes immédiatement après les avoir téléchargés et convertis en une matrice Term-document à l'aide de CountVectorizer, avec le tokenizer par défaut intégré, nous obtiendrons une matrice de taille 4679x769801 (sur les données que j'utilise).


La taille de notre dictionnaire sera de 769801. Même si nous supposons que la plupart des mots sont informatifs, il est peu probable que nous obtenions un bon LDA, quelque chose comme "Curses of Dimensions" nous attend, sans mentionner que pour presque tous les ordinateurs, nous allons simplement obstruer toute la RAM. En fait, la plupart de ces mots sont complètement informatifs. La grande majorité d'entre eux sont:


  • Émoticônes, personnages, chiffres.
  • Mots uniques ou très rares (par exemple, mots polonais d'un groupe avec des mèmes polonais, mots mal orthographiés ou en «albanais»).
  • Parties du discours très fréquentes (par exemple, prépositions et pronoms).

De plus, de nombreux groupes dans VK se spécialisent exclusivement dans les images - il n'y a presque pas de texte là-bas - les textes qui leur correspondent sont dégénérés, dans la matrice de document thermique, ils nous donneront presque complètement zéro lignes.


Et donc, trions le tout!
Nous tokenisons tous les textes, en supprimons la ponctuation et les nombres, regardons l'histogramme de la répartition des textes par le nombre de mots:
image


Nous supprimons tous les textes de moins de 100 mots (il y en a 525)


Maintenant le dictionnaire:
Supprimer tous les jetons (mots) qui ne sont pas des lettres, dans le cadre de notre tâche - cela est tout à fait acceptable. Le CountVectorizer le fait seul, même si ce n'est pas le cas, je pense qu'il n'est pas nécessaire de donner des exemples (ils sont dans la version complète du code de l'article).


L'une des procédures les plus courantes pour réduire la taille d'un dictionnaire consiste à supprimer les soi-disant mots vides (mots vides) - des mots qui ne portent pas de charge sémantique et / ou qui n'ont pas de coloration thématique (dans notre cas, la modélisation de sujet). De tels mots dans notre cas sont, par exemple:


  • Pronoms et prépositions.
  • Articles - le, a.
  • Mots communs: «être», «bon», «probablement», etc.

Le module nltk a formé des listes de mots vides en russe et en anglais, mais ils sont plutôt faibles. Sur Internet, vous pouvez également trouver des listes de mots vides pour n'importe quelle langue et les ajouter à ceux de nltk. Nous allons donc faire. Prenez des mots d'arrêt supplémentaires d'ici:



En pratique, lors de la résolution de problèmes spécifiques, les listes de mots vides sont progressivement ajustées et complétées au fur et à mesure que les modèles sont entraînés, car pour chaque jeu de données et problème spécifique, il existe des mots "incohérents" spécifiques. Nous récupérerons également des mots d'arrêt personnalisés après avoir formé notre LDA de première génération.


En soi, la procédure de suppression des mots vides est intégrée au CountVectorizer - nous avons juste besoin d'une liste d'entre eux.


Est-ce que nous en avons fait assez?


image


La plupart des mots qui sont dans notre dictionnaire ne sont pas encore trop informatifs pour apprendre le LDA sur eux et ne sont pas dans la liste des mots vides. Par conséquent, nous appliquons une autre méthode de filtrage à nos données.


idf(t,D)= log frac|D||d enD:t end|



t est un mot du dictionnaire.
D - cas (nombreux textes)
d est l'un des textes du corps.
Nous calculons l'IDF de tous nos mots et coupons les mots avec le plus grand idf (très rare) et avec le plus petit (mots répandus).


 #'training' (tf-)idf vectorizer. tf_idf = TfidfVectorizer(input='filename', stop_words=stopwords, smooth_idf=False ) tf_idf.fit(train_names) #getting idfs idfs = tf_idf.idf_ #sorting out too rare and too common words lower_thresh = 3. upper_thresh = 6. not_often = idfs > lower_thresh not_rare = idfs < upper_thresh mask = not_often * not_rare good_words = np.array(tf_idf.get_feature_names())[mask] #deleting punctuation as well. cleaned = [] for word in good_words: word = re.sub("^(\d+\w*$|_+)", "", word) if len(word) == 0: continue cleaned.append(word) 

Obtenu après les procédures ci-dessus est déjà tout à fait adapté à la formation LDA, mais nous ferons plus de stemming - les mêmes mots se retrouvent souvent dans notre ensemble de données, mais dans des cas différents. Pour le stemming, pymystem3 a été utilisé .


 #Stemming m = Mystem() stemmed = set() voc_len = len(cleaned) for i in tqdm(range(voc_len)): word = cleaned.pop() stemmed_word = m.lemmatize(word)[0] stemmed.add(stemmed_word) stemmed = list(stemmed) print('After stemming: %d'%(len(stemmed))) 

Après avoir appliqué le filtrage ci-dessus, la taille du dictionnaire est passée de 769801 à
13611 et déjà avec de telles données, vous pouvez obtenir un modèle LDA de qualité acceptable.


Test, application et optimisation de LDA


Maintenant que nous avons l'ensemble de données, le prétraitement et les modèles que nous avons formés sur l'ensemble de données traité, il serait bien de vérifier l'adéquation de nos modèles, ainsi que de créer des applications pour eux.


En tant qu'application, pour commencer, envisagez la tâche de générer des mots clés pour un texte donné. Vous pouvez le faire de manière assez simple comme suit:


  1. Nous obtenons de LDA la distribution des sujets pour ce texte.
  2. Choisissez n (par exemple, n = 2) des sujets les plus prononcés.
  3. Pour chaque sujet, choisissez m (par exemple m = 3) les mots les plus caractéristiques.
  4. Nous avons un ensemble de n * m mots caractérisant un texte donné.

Nous allons écrire une classe d'interface simple qui implémentera cette méthode de génération de mots clés:


 #Let\`s do simple interface class class TopicModeler(object): ''' Inteface object for CountVectorizer + LDA simple usage. ''' def __init__(self, count_vect, lda): ''' Args: count_vect - CountVectorizer object from sklearn. lda - LDA object from sklearn. ''' self.lda = lda self.count_vect = count_vect self.count_vect.input = 'content' def __call__(self, text): ''' Gives topics distribution for a given text Args: text - raw text via python string. returns: numpy array - topics distribution for a given text. ''' vectorized = self.count_vect.transform([text]) lda_topics = self.lda.transform(vectorized) return lda_topics def get_keywords(self, text, n_topics=3, n_keywords=5): ''' For a given text gives n top keywords for each of m top texts topics. Args: text - raw text via python string. n_topics - int how many top topics to use. n_keywords - how many top words of each topic to return. returns: list - of m*n keywords for a given text. ''' lda_topics = self(text) lda_topics = np.squeeze(lda_topics, axis=0) n_topics_indices = lda_topics.argsort()[-n_topics:][::-1] top_topics_words_dists = [] for i in n_topics_indices: top_topics_words_dists.append(self.lda.components_[i]) shape=(n_keywords*n_topics, self.lda.components_.shape[1]) keywords = np.zeros(shape=shape) for i,topic in enumerate(top_topics_words_dists): n_keywords_indices = topic.argsort()[-n_keywords:][::-1] for k,j in enumerate(n_keywords_indices): keywords[i * n_keywords + k, j] = 1 keywords = self.count_vect.inverse_transform(keywords) keywords = [keyword[0] for keyword in keywords] return keywords 

Nous appliquons notre méthode à plusieurs textes et voyons ce qui se passe:
Communauté : Agence de voyage "Couleurs du monde"
Mots-clés: ['photo', 'social', 'voyage', 'communauté', 'voyage', 'euro', 'logement', 'prix', 'Pologne', 'départ']
Communauté: Gifs alimentaires
Mots-clés: ['beurre', 'st', 'sel', 'pc', 'pâte', 'cuisine', 'oignon', 'poivre', 'sucre', 'gr']


Les résultats ci-dessus ne sont pas des «choix de cerise» et semblent assez adéquats. En fait, ce sont les résultats d'un modèle déjà configuré. Les premiers LDA qui ont été formés dans le cadre de cet article ont produit des résultats nettement moins bons, parmi les mots clés que vous pouviez souvent voir, par exemple:


  1. Composants composites d'adresses Web: www, http, ru, com ...
  2. Mots communs.
  3. unités: cm, mètre, km ...

Le réglage (réglage) du modèle a été effectué comme suit:


  1. Pour chaque sujet, sélectionnez n (n = 5) mots les plus caractéristiques.
  2. Nous les considérons idf, selon le cas de formation.
  3. Nous apportons 5 à 10% des mots clés les plus répandus.

Un tel «nettoyage» doit être effectué avec soin, en prévoyant ces 10% des mots. Les candidats à la suppression doivent plutôt être choisis de cette manière, puis les mots qui doivent être supprimés doivent être sélectionnés manuellement parmi eux.


Quelque part dans la génération 2-3 des modèles, avec une manière similaire de sélectionner les mots vides, pour les 5% supérieurs des distributions de mots les plus répandues, nous obtenons:
['tout', 'complètement', 'droit', 'facile', 'suivant', 'internet', 'petit', 'moyen', 'difficile', 'humeur', 'tellement', 'set', ' option ',' nom ',' discours ',' programme ',' compétition ',' musique ',' cible ',' film ',' prix ',' jeu ',' système ',' jouer ',' entreprise ' , 'sympa']


Plus d'applications


La première chose qui me vient à l'esprit spécifiquement est d'utiliser la distribution des sujets dans le texte comme des `` incorporations '' de textes, dans cette interprétation, vous pouvez leur appliquer des algorithmes de visualisation ou de clustering, et rechercher les clusters thématiques finaux `` efficaces '' de cette manière.


Faisons ça:


 term_doc_matrix = count_vect.transform(names) embeddings = lda.transform(term_doc_matrix) kmeans = KMeans(n_clusters=30) clust_labels = kmeans.fit_predict(embeddings) clust_centers = kmeans.cluster_centers_ embeddings_to_tsne = np.concatenate((embeddings,clust_centers), axis=0) tSNE = TSNE(n_components=2, perplexity=15) tsne_embeddings = tSNE.fit_transform(embeddings_to_tsne) tsne_embeddings, centroids_embeddings = np.split(tsne_embeddings, [len(clust_labels)], axis=0) 

En sortie, nous obtenons l'image suivante:
image


Les croix sont les centres de gravité (cénroïdes) des grappes.


Dans l'image tSNE des plongements, on peut voir que les clusters sélectionnés à l'aide de KMeans forment des ensembles assez connectés et le plus souvent spatialement séparables.


Tout le reste, à vous de voir.


Lien vers tout le code: https://gitlab.com/Mozes/VK_LDA

Source: https://habr.com/ru/post/fr417167/


All Articles