Inicie o LDA no mundo real. Guia detalhado

Prefácio


Existem muitos tutoriais na Internet que explicam como o LDA funciona (alocação de diretórios latentes) e como colocá-lo em prática. Exemplos de treinamento de LDA são frequentemente demonstrados em conjuntos de dados "exemplares", como o "conjunto de dados de 20 grupos de notícias", disponível no sklearn.


Uma característica do treinamento no exemplo de conjuntos de dados "exemplares" é que os dados sempre estão em ordem e convenientemente empilhados em um só lugar. Ao treinar modelos de produção, os dados obtidos diretamente de fontes reais são geralmente o oposto:


  • Muitas emissões.
  • Marcação incorreta (se houver).
  • Desequilíbrios de classe muito fortes e distribuições feias de quaisquer parâmetros do conjunto de dados.
  • Para textos, são eles: erros gramaticais, um grande número de palavras raras e únicas, multilinguismo.
  • Uma maneira inconveniente de armazenar dados (formatos diferentes ou raros, necessidade de análise)

Historicamente, tento aprender com exemplos o mais próximo possível das realidades da realidade da produção, porque é dessa maneira que se pode sentir mais plenamente as áreas problemáticas de um determinado tipo de tarefa. O mesmo aconteceu com o LDA e, neste artigo, quero compartilhar minha experiência - como executar o LDA do zero, com dados completamente brutos. Alguma parte do artigo será dedicada à obtenção desses mesmos dados, para que o exemplo se torne um 'caso de engenharia' completo.


Modelagem de tópicos e LDA.


Para começar, considere o que o LDA faz em geral e quais tarefas ele usa.
Na maioria das vezes, o LDA é usado para tarefas de modelagem de tópicos. Tais tarefas significam as tarefas de agrupar ou classificar textos - de maneira que cada classe ou agrupamento contenha textos com tópicos semelhantes.


Para aplicar a LDA ao conjunto de dados de textos (doravante denominado corpo do texto), é necessário transformar o corpo em uma matriz termo-documento.


Um termo matriz de documentos é uma matriz que tem um tamanho N vezesWonde
N é o número de documentos no caso e W é o tamanho do dicionário do caso, ou seja, o número de palavras (únicas) encontradas em nosso corpus. Na i-ésima linha, a j-ésima coluna da matriz é um número - quantas vezes no i-ésima texto a j-ésima palavra foi encontrada.


A LDA constrói, para um determinado termo, matriz de documentos e T de um número predeterminado de tópicos, duas distribuições:


  1. A distribuição dos tópicos nos textos (na prática, dada pela matriz de tamanhos N vezesT)
  2. A distribuição das palavras por tópico (matriz de tamanho T vezesW)

Os valores das células dessas matrizes são, respectivamente, as probabilidades de que este tópico esteja contido neste documento (ou a proporção do tópico no documento, se considerarmos o documento como uma mistura de tópicos diferentes) para a matriz 'Distribuição de tópicos em textos'.


Para a matriz 'Distribuição de palavras por temas', os valores são a probabilidade de encontrar a palavra j no texto com o tópico i, qualitativamente, podemos considerar esses números como coeficientes que caracterizam como essa palavra é típica para este tópico.


Deve-se dizer que a palavra tópico não é uma definição "cotidiana" dessa palavra. O LDA aloca T a esses, mas que tipo de tópicos são esses e se correspondem a algum tópico conhecido dos textos, como: 'Esporte', 'Ciência', 'Política' - é desconhecido. Nesse caso, é mais apropriado falar sobre o tópico como um tipo de entidade abstrata, que é definida por uma linha na matriz de distribuição de palavras por tópico e com alguma probabilidade corresponde a este texto, se você pode imaginá-lo como uma família de conjuntos de palavras característicos reunidos com probabilidades correspondentes (da tabela) em um determinado conjunto de textos.


Se você estiver interessado em estudar mais detalhadamente e 'em fórmulas' como o LDA é treinado e funciona, aqui estão alguns materiais (que foram usados ​​pelo autor):



Obtemos dados selvagens


Para nosso 'trabalho de laboratório', precisamos de um conjunto de dados personalizado com suas próprias falhas e recursos. Você pode obtê-lo em diferentes lugares: faça o download de análises do Kinopoisk, artigos da Wikipedia, notícias de algum portal de notícias, teremos uma opção um pouco mais extrema - publicações das comunidades VKontakte.


Faremos isso assim:


  1. Nós selecionamos algum usuário VK.
  2. Temos uma lista de todos os amigos dele.
  3. Para cada amigo, levamos toda a sua comunidade.
  4. Para cada comunidade de cada amigo, distribuímos as primeiras n (n = 100) postagens da comunidade e as combinamos em um conteúdo de texto da comunidade.

Ferramentas e artigos


Para baixar posts, usaremos o módulo vk para trabalhar com a API VKontakte, para Python. Um dos momentos mais complicados ao escrever um aplicativo usando a API do VKontakte é a autorização, felizmente, o código que executa esse trabalho já está escrito e está em domínio público, exceto pelo vk, usei um pequeno módulo de autorização - vkauth.


Links para os módulos e artigos usados ​​para estudar a API do VKontakte:



Escrevendo um código


E assim, usando vkauth, efetue login:


#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) 

No processo, um pequeno módulo foi escrito contendo todas as funções necessárias para o download de conteúdo no formato apropriado; elas estão listadas abaixo, vamos passar por elas:


 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 

O pipeline final é o seguinte:


 #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) 

Falha


Em geral, o processo de download de dados não é difícil por si só; você deve prestar atenção apenas a dois pontos:


  1. Às vezes, devido à privacidade de algumas comunidades, você receberá erros de acesso; outras vezes, outros erros serão resolvidos com a instalação do try, exceto no lugar certo.
  2. VK tem um limite no número de solicitações por segundo.

Ao fazer um grande número de solicitações, por exemplo em um loop, também capturaremos erros. Esse problema pode ser resolvido de várias maneiras:


  1. Estupidamente e sem rodeios: durma (alguns) a cada 3 solicitações. Isso é feito em uma linha e diminui bastante a descarga, em situações em que os volumes de dados não são grandes e não há tempo para métodos mais sofisticados - isso é bastante aceitável (implementado neste artigo).
  2. Compreenda o trabalho dos pedidos de pesquisa longa https://vk.com/dev/using_longpoll

Neste artigo, um método simples e lento foi escolhido; no futuro, provavelmente escreverei um micro artigo sobre maneiras de contornar ou facilitar as restrições no número de solicitações por segundo.


Sumário


Com o "alguns" usuários iniciais com cerca de 150 amigos, eles conseguiram 4.679 textos - cada um caracteriza uma determinada comunidade VK. Os textos variam muito em tamanho e são escritos em vários idiomas - alguns deles não são adequados para nossos propósitos, mas falaremos um pouco mais sobre isso.


Corpo principal


imagem


Vamos analisar todos os blocos do nosso pipeline - primeiro, no obrigatório (Ideal), depois no resto - eles são do maior interesse.


Countvectorizer


Antes de ensinar a LDA, precisamos apresentar nossos documentos na forma de uma matriz de documentos de termos. Isso geralmente inclui operações como:


  • Remoção de puttuctions / números / tokens desnecessários.
  • Tokenização (apresentação como uma lista de palavras)
  • Contando palavras, compilando uma matriz térmica de documentos.

Todas essas ações no sklearn são convenientemente implementadas na estrutura de uma entidade do programa - sklearn.feature_extraction.text.CountVectorizer.


Link da documentação


Tudo que você precisa fazer é:


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

Lda


Da mesma forma que com o CountVectorizer, o LDA é perfeitamente implementado no Sklearn e em outras estruturas; portanto, não faz muito sentido dedicar muito espaço diretamente às suas implementações, em nosso artigo puramente prático.


Link da documentação


Tudo o que você precisa para iniciar o LDA é:


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

Pré-processamento


Se apenas pegarmos nossos textos imediatamente após baixá-los e convertê-los em uma matriz Term-document usando o CountVectorizer, com o tokenizer padrão embutido, obteremos uma matriz de tamanho 4679x769801 (nos dados que eu uso).


O tamanho do nosso dicionário será 769801. Mesmo se assumirmos que a maioria das palavras é informativa, ainda não é possível obter um bom LDA, algo como "Maldições das Dimensões" nos espera, sem mencionar que, para quase qualquer computador, vamos entupir toda a RAM. De fato, a maioria dessas palavras é completamente pouco informativa. A grande maioria deles são:


  • Emoticons, personagens, números.
  • Palavras únicas ou muito raras (por exemplo, palavras polonesas de um grupo com memes poloneses, palavras escritas incorretamente ou em 'albanês').
  • Partes do discurso muito frequentes (por exemplo, preposições e pronomes).

Além disso, muitos grupos no VK se especializam exclusivamente em imagens - quase não há postagens de texto - os textos correspondentes a eles são degenerados; na matriz de documentos térmicos, eles nos fornecerão quase zero linhas.


E então, vamos resolver tudo!
Nós tokenizamos todos os textos, removemos pontuação e números deles, observamos o histograma da distribuição dos textos pelo número de palavras:
imagem


Removemos todos os textos com menos de 100 palavras (existem 525)


Agora o dicionário:
Remover todos os tokens (palavras) que não são letras, na estrutura de nossa tarefa - isso é bastante aceitável. O CountVectorizer faz isso por conta própria, mesmo que não, então acho que não há necessidade de dar exemplos (eles estão na versão completa do código do artigo).


Um dos procedimentos mais comuns para reduzir o tamanho de um dicionário é remover as chamadas palavras de parada (palavras de parada) - palavras que não carregam uma carga semântica e / ou não têm cores temáticas (no nosso caso, Modelagem de Tópicos). Tais palavras em nosso caso são, por exemplo:


  • Pronomes e preposições.
  • Artigos - a, a.
  • Palavras comuns: 'ser', 'bom', 'provavelmente' etc.

O módulo nltk formou listas de palavras irrelevantes em russo e inglês, mas elas são bastante fracas. Na Internet, você também pode encontrar listas de palavras irrelevantes para qualquer idioma e adicioná-las às do nltk. Então vamos fazer. Tome palavras-chave adicionais a partir daqui:



Na prática, ao resolver problemas específicos, as listas de palavras irrelevantes são gradualmente ajustadas e suplementadas à medida que os modelos são treinados, pois para cada conjunto de dados e problema específicos existem palavras "inconsistentes" específicas. Também pegaremos palavras de ordem personalizadas após o treinamento de nossa primeira geração de LDA.


Por si só, o procedimento para remover palavras irrelevantes é incorporado ao CountVectorizer - precisamos apenas de uma lista delas.


O que fizemos foi suficiente?


imagem


A maioria das palavras que estão em nosso dicionário ainda não é muito informativa para aprender LDA sobre elas e não está na lista de palavras irrelevantes. Portanto, aplicamos outro método de filtragem aos nossos dados.


idf(t,D)= log frac|D||d inD:t ind|


onde
t é uma palavra do dicionário.
D - caso (muitos textos)
d é um dos textos do corpo.
Nós calculamos o IDF de todas as nossas palavras e cortamos as palavras com o maior IDF (muito raro) e com o menor (palavras comuns).


 #'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) 

Obtidos após os procedimentos acima já são bastante adequados para o treinamento de LDA, mas faremos mais esforços - as mesmas palavras são frequentemente encontradas em nosso conjunto de dados, mas em casos diferentes. Para o stemming, foi utilizado o pymystem3 .


 #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))) 

Depois de aplicar a filtragem acima, o tamanho do dicionário diminuiu de 769801 para
13611 e já com esses dados, você pode obter um modelo de LDA de qualidade aceitável.


Testando, aplicando e ajustando LDA


Agora que temos o conjunto de dados, o pré-processamento e os modelos que treinamos no conjunto de dados processado, seria bom verificar a adequação de nossos modelos e criar alguns aplicativos para eles.


Como um aplicativo, para iniciantes, considere a tarefa de gerar palavras-chave para um determinado texto. Você pode fazer isso de uma maneira bastante simples da seguinte maneira:


  1. Obtemos da LDA a distribuição de tópicos para este texto.
  2. Escolha n (por exemplo, n = 2) dos tópicos mais pronunciados.
  3. Para cada tópico, escolha m (por exemplo m = 3) as palavras mais características.
  4. Temos um conjunto de n * m palavras que caracterizam um determinado texto.

Escreveremos uma classe de interface simples que implementará este método de geração de palavras-chave:


 #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 

Aplicamos nosso método a vários textos e vemos o que acontece:
Comunidade : Agência de viagens "Colors of the World"
Palavras-chave: ['foto', 'social', 'viagem', 'comunidade', 'viagem', 'euro', 'acomodação', 'preço', 'Polônia', 'partida']
Comunidade: Food Gifs
Palavras-chave: ['manteiga', 'st', 'sal', 'pc', 'massa', 'cozinhar', 'cebola', 'pimenta', 'açúcar', 'gr']


Os resultados acima não são 'cherry pick' e parecem bastante adequados. De fato, esses são os resultados de um modelo já configurado. Os primeiros LDAs treinados como parte deste artigo produziram resultados significativamente piores, entre as palavras-chave que você costumava ver, por exemplo:


  1. Componentes compostos de endereços da web: www, http, ru, com ...
  2. Palavras comuns.
  3. unidades: cm, metro, km ...

O ajuste (ajuste) do modelo foi realizado da seguinte forma:


  1. Para cada tópico, selecione n (n = 5) palavras mais características.
  2. Nós os consideramos idf, de acordo com o caso de treinamento.
  3. Trazemos palavras-chave de 5 a 10% das mais difundidas.

Essa "limpeza" deve ser realizada com cuidado, pré-visualizando aquelas 10% das palavras. Em vez disso, os candidatos à exclusão devem ser escolhidos dessa maneira e, em seguida, as palavras que devem ser excluídas devem ser selecionadas manualmente.


Em algum momento da geração 2-3 dos modelos, com uma maneira semelhante de selecionar palavras irrelevantes, para os 5% principais das distribuições generalizadas de palavras principais, obtemos:
['qualquer', 'completamente', 'certo', 'fácil', 'próximo', 'internet', 'pequeno', 'caminho', 'difícil', 'humor', 'tanto', 'conjunto', ' opção ',' nome ',' discurso ',' programa ',' competição ',' música ',' alvo ',' filme ',' preço ',' jogo ',' sistema ',' jogar ',' empresa ' , 'legal']


Mais aplicações


A primeira coisa que me vem à mente especificamente é usar a distribuição de tópicos no texto como 'incorporação' de textos; nessa interpretação, você pode aplicar algoritmos de visualização ou clustering a eles e procurar os agrupamentos temáticos 'efetivos' finais dessa maneira.


Vamos fazer o seguinte:


 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) 

Na saída, temos a seguinte imagem:
imagem


Cruzes são os centros de gravidade (cenroids) dos aglomerados.


Na imagem tSNE de incorporações, pode-se ver que os clusters selecionados usando o KMeans formam conjuntos bastante conectados e, na maioria das vezes, separáveis ​​espacialmente.


Tudo o resto, depende de você.


Link para todo o código: https://gitlab.com/Mozes/VK_LDA

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


All Articles