在现实世界中启动LDA。 详细指南

前言


Internet上有很多教程,它们解释了LDA的工作原理(潜在的狄利克雷分配)以及如何将其付诸实践。 LDA训练的示例通常在sklearn可用的“示例性”数据集(例如“ 20个新闻组数据集”)上进行演示。


在“示例性”数据集示例上进行训练的一个特征是,那里的数据总是有序且方便地堆叠在一个地方。 在训练生产模型时,直接从真实来源获得的数据通常是相反的:


  • 大量排放。
  • 标记不正确(如果有)。
  • 非常严重的类不平衡和任何数据集参数的难看分布。
  • 对于文本,这些是:语法错误,大量稀有和独特的单词,多语言。
  • 一种不方便的数据存储方式(不同或稀有格式,需要解析)

从历史上看,我尝试从尽可能接近实际生产现实的示例中学习,因为通过这种方式,人们可以最充分地感知特定类型任务的问题领域。 LDA也是如此,在本文中,我想分享我的经验-如何在完全原始的数据上从头运行LDA。 本文的某些部分将专门获取这些数据,因此该示例将成为完整的“工程案例”。


主题建模和LDA。


首先,请考虑LDA的一般功能以及使用的任务。
LDA通常用于主题建模任务。 这样的任务意味着对文本进行聚类或分类的任务-以每个类或聚类包含具有相似主题的文本的方式。


为了将LDA应用于文本数据集(以下称为文本主体),有​​必要将主体转换为术语文档矩阵。


术语文档矩阵是具有大小的矩阵 N\乘W在哪里
N是案件的文件数,W是案件字典的大小,即 在我们的语料库中找到的单词(唯一)的数量。 在第i行中,矩阵的第j列是一个数字-第i个单词在第i个文本中找到了多少次。


对于给定的术语文档矩阵和预定数量的主题的T,LDA构造两个分布:


  1. 课文中主题的分布。(实际上,由大小矩阵给出 N\乘T
  2. 按主题分布的单词(大小矩阵) T\乘W

这些矩阵的像元值分别是此主题包含在本文档中的概率(或主题在文档中所占的比例,如果我们将文档视为不同主题的混合)则表示矩阵``主题在文本中的分布''。


对于矩阵“按主题分布单词”,其值是与主题i在文本中满足单词j的概率,定性地,我们可以将这些数字视为表征该单词在该主题中的典型特征的系数。


应该说主题一词不是这个词的“日常”定义。 LDA将T分配给这些主题,但是这些主题是什么样的主题以及它们是否与文本的任何知名主题相对应,例如:“体育”,“科学”,“政治”-尚不清楚。 在这种情况下,将主题作为一种抽象实体来讨论是更合适的,如果您可以将其想象成单词的特征集的集合,并且满足相应的概率,则该抽象实体由主题在单词分布矩阵中的一条线定义,并且与该文本相对应。 (从表格中)以一组特定的文本表示。


如果您有兴趣更详细地研究“公式化” LDA的方式和工作方式,请参考以下材料(作者使用):



我们得到了疯狂的数据


对于我们的“实验室工作”,我们需要一个具有自身缺陷和功能的自定义数据集。 您可以在不同的地方获得它:从Kinopoisk,Wikipedia文章,一些新闻门户网站的新闻下载评论,我们将采取一种更为极端的选择-VKontakte社区的帖子。


我们将这样做:


  1. 我们选择一些VK用户。
  2. 我们得到了他所有朋友的名单。
  3. 对于每个朋友,我们将带走他的整个社区。
  4. 对于每个朋友的每个社区,我们抽出前n(n = 100)个社区帖子,并将它们合并为一个社区文本内容。

工具和文章


要下载帖子,我们将使用vk模块与VKontakte API(适用于Python)一起使用。 使用VKontakte API编写应用程序时,最复杂的时刻之一就是授权,幸运的是,执行此工作的代码已经编写并且在公共领域,除了vk,我使用了一个小的授权模块-vkauth。


链接到用于研究VKontakte API的模块和文章:



编写代码


因此,使用vkauth登录:


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

在此过程中,编写了一个小模块,其中包含以适当格式下载内容所必需的所有功能,下面列出了这些功能,让我们对其进行遍历:


 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 

最终的管道如下:


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

失败


通常,下载数据本身并不困难;您应该仅注意两点:


  1. 有时,由于某些社区的隐私,您将收到访问错误,有时,除非在正确的位置,否则通过安装try可以解决其他错误。
  2. VK对每秒的请求数有限制

当发出大量请求时(例如在循环中),我们还将捕获错误。 此问题可以通过几种方式解决:


  1. 愚蠢而直率:每3个请求就坚持睡眠(一些)。 它在一行中完成,并且在数据量不大且没有时间使用更复杂的方法的情况下,极大地降低了卸载速度-这是完全可以接受的(在本文中实现)
  2. 了解Long Poll请求的工作https://vk.com/dev/using_longpoll

在本文中,选择了一种简单而缓慢的方法,将来,我可能会写一篇微型文章,介绍绕过或缓解每秒请求数限制的方法。


总结


最初的“一些”用户拥有约150个朋友,他们设法获得了4,679条文本-每个文本都代表了某个VK社区。 这些文本的大小相差很大,并且以多种语言编写-其中一些不适合我们的目的,但是我们将进一步讨论。


主体


图片


让我们仔细研究一下管道的所有部分-首先是强制性的(理想),然后是其余的-它们才是最大的利益。


Countvectorizer


在教授LDA之前,我们需要以术语文档矩阵的形式展示文档。 通常包括以下操作:


  • 删除推论/数字/不必要的标记。
  • 标记化(表示为单词列表)
  • 数词,编制热文档矩阵。

sklearn中的所有这些动作都可以在一个程序实体sklearn.feature_extraction.text.CountVectorizer的框架内方便地实现。


文档链接


您需要做的只是:


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

Lda


与CountVectorizer相似,LDA可以在Sklearn和其他框架中完美实现,因此,在我们的纯粹实用文章中,将大量空间直接用于其实现没有太多意义。


文档链接


启动LDA所需要做的就是:


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

前处理


如果我们在下载文本后立即使用文本,并使用CountVectorizer将它们转换为Term文档矩阵,并使用内置的默认标记生成器,则将获得大小为4679x769801的矩阵(基于我使用的数据)。


我们字典的大小将为769801。即使我们假设大多数单词都具有参考价值,我们仍然不太可能获得良好的LDA,我们会找到类似“尺寸曲线”的内容,更不用说对于几乎所有计算机,我们只会阻塞所有RAM。 实际上,这些词中的大多数都是完全无用的。 其中绝大多数是:


  • 表情符号,字符,数字。
  • 独特或非常稀有的单词(例如,带有波兰语模因的一组单词中的波兰语单词,拼写错误或使用“阿尔巴尼亚语”的单词)。
  • 语音中非常频繁的部分(例如介词和代词)。

此外,VK中的许多小组专门研究图像-那里几乎没有文字帖子-与它们相对应的文字是简并的,在Thermal文档矩阵中,它们几乎给我们零线。


因此,让我们整理一下吧!
我们将所有文本标记化,从中删除标点符号和数字,然后根据单词数查看文本分布的直方图:
图片


我们删除了所有少于100个字的文本(其中有525个)


现在的字典:
在我们的任务框架中删除所有不是字母的标记(单词)-这是完全可以接受的。 CountVectorizer可以自己执行此操作,即使不是,也可以这样做,那么我认为无需在此处给出示例(它们在本文代码的完整版本中)。


减少字典大小的最常见过程之一是删除所谓的停用词(停用词),停用词是没有语义负荷和/或没有主题色彩的词(在我们的示例中为主题建模)。 在我们的案例中,这些词例如是:


  • 代词和介词。
  • 文章-a。
  • 常用词:“ be”,“ good”,“ probably”等。

nltk模块已经形成了俄语和英语停用词列表,但它们的作用很弱。 在Internet上,您还可以找到任何语言的停用词列表,并将其添加到nltk中。 所以我们会做。 从此处获取其他停用词:



在实践中,当解决特定问题时,停用词列表会随着模型的训练而逐渐调整和补充,因为对于每个特定的数据集和问题,都有特定的“不一致”词。 在训练了第一代LDA之后,我们还将挑选自定义停用词。


CountVectorizer内置了删除停用词的过程-我们只需要它们的列表即可。


我们做的够了吗?


图片


我们词典中的大多数单词对于学习LDA而言仍然不太有用,并且不在停用词列表中。 因此,我们对数据应用了另一种过滤方法。


idftD= log frac|D||d inDt ind|


在哪里
t是字典中的一个单词。
D-案例(许多文本)
d是正文之一。
我们计算所有单词的IDF,并切掉IDF最大(非常少见)和最小(扩展单词)的单词。


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

通过上述步骤获得的结果已经非常适合LDA训练,但是我们将做更多的工作-在我们的数据集中经常会找到相同的单词,但是情况有所不同。 为了阻止,使用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))) 

应用上述过滤后,字典大小从769801减小为
13611,并且已经有了此类数据,您可以获得具有可接受质量的LDA模型。


测试,应用和调整LDA


现在我们有了在处理后的数据集上训练的数据集,预处理和模型,现在很好地检查我们的模型是否适当,以及为它们构建一些应用程序。


作为应用程序,对于初学者来说,请考虑为给定文本生成关键字的任务。 您可以按照以下非常简单的方式执行此操作:


  1. 我们从LDA获得了本文主题的分布。
  2. 选择n个(例如n = 2)最明显的主题。
  3. 对于每个主题,选择m个(例如m = 3)最具特征的单词。
  4. 我们有一组n * m个单词来描述给定的文本。

我们将编写一个简单的接口类,它将实现此生成关键字的方法:


 #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 

我们将我们的方法应用于几个文本,看看会发生什么:
社区旅行社“世界的色彩”
关键字: [“照片”,“社交”,“旅行”,“社区”,“旅行”,“欧元”,“住宿”,“价格”,“波兰”,“出发”]
社区:食物的礼物
关键字: [“黄油”,“ st”,“盐”,“ pc”,“面团”,“烹饪”,“洋葱”,“辣椒”,“糖”,“ gr”]


上面的结果不是“樱桃精选”,看起来很合适。 实际上,这些是来自已配置模型的结果。 在您经常看到的关键字中,本文中训练的第一批LDA产生了明显较差的结果,例如:


  1. 网址的复合组成部分:www,http,ru,com ...
  2. 常用词。
  3. 单位:厘米,米,公里...

模型的调整(调整)如下:


  1. 对于每个主题,请选择n(n = 5)个最具特征的单词。
  2. 根据培训案例,我们将它们视为idf。
  3. 我们引入了最广泛的关键字的5-10%。

应该仔细地进行这种“清理”,并预先查看这10%的单词。 相反,应该以这种方式选择要删除的候选词,然后应该从中手动选择要删除的单词。


在2-3代模型中的某个地方,采用类似的选择停用词的方式,对于广泛使用的最高关键字分布的前5%,我们得到:
[“任何”,“完全”,“正确”,“简单”,“下一个”,“互联网”,“小”,“方式”,“困难”,“心情”,“太多”,“设置”,“选项','名称','演讲','节目','比赛','音乐','目标','电影','价格','游戏','系统','播放','公司' ,'nice']


更多应用


我首先想到的第一件事是使用文本中主题的分布作为文本的“嵌入”,在这种解释中,您可以对它们应用可视化或聚类算法,并以此方式寻找最终的“有效”主题聚类。


让我们这样做:


 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) 

在输出中,我们得到以下图像:
图片


十字是群集的重心(质心)。


在嵌入的tSNE图像中,可以看出,使用KMeans选择的聚类形成了非常连通的且最常在空间上可分离的集合。


其他一切,由您决定。


链接到所有代码: https : //gitlab.com/Mozes/VK_LDA

Source: https://habr.com/ru/post/zh-CN417167/


All Articles