用 Python 实现 LDA

2023-01-31 03:01:41 python lda

原文出处:Jordan Barber

  • LDA 是什么
  • LDA 演练
    • 需要用到的包
    • 导入文档
    • 清洗文档 
      • 分词
      • 移除停用词
      • 词干提取
    • 创建 document-term matrix
    • 应用 LDA 模型
    • 检查结果
    • LDA 原理
    • 完整代码

隐含狄利克雷分布(以下简写为 LDA)是一种主题模型,它基于一组文档中的词频生成主题。对于在给定的文档集中准确合理地找到主题的混合,LDA 是一种非常有效的方法。

这一部分,我会用一个高度简化过的文档集来演练生成一个 LDA 模型的过程。这并不是对 LDA 的全面讲解。这个演练的目的是为大家准备数据,以及用 LDA 模型得到相应输出的核心步骤提供指导。

需要用到的包

该演练当中使用的 python 包有:

  • NLTK, Python 的一个自然语言处理工具包。对于任何一种自然语言的处理都非常有用。
    • Mac/Unix 下使用 pip 安装:$ sudo pip install -U nltk.
  • stop_Words,一个包含停用词的 Python 包。
    • 在 Mac/Unix 下使用 pip 安装:$ sudo pip install stop-words.
  • gensim,包含我们要用到的 LDA 模型的一个主题模型包。 
    • 在 Mac/Unix 下使用 pip 安装:$ sudo pip install gensim.

导入文档

这是我们的文档用例:

doc_a = "Brocolli is Good to eat. My brother likes to eat good brocolli, but not my mother."
doc_b = "My mother spends a lot of time driving my brother around to baseball practice."
doc_c = "Some health experts suggest that driving may cause increased tension and blood pressure."
doc_d = "I often feel pressure to perfORM well at school, but my mother never seems to drive my brother to do better."
doc_e = "Health professionals say that brocolli is good for your health."

# compile sample documents into a list
doc_set = [doc_a, doc_b, doc_c, doc_d, doc_e]  

清洗文档

数据清洗对于生成一个有效的主题模型是极其极其重要的:俗话说,“输入的是垃圾,得到的一定也是垃圾”(Garbage in, garbarge out.)。下面的步骤就是自然语言处理的常见方法:

  • 分词:将文档转化为其原子元素。
  • 停用词处理:移除无意义的词。
  • 词干提取:将同义词合并。

分词

分词即将一个文档分成其原子元素。在这个例子中,我们将其分为单词。分词有很多种方法,我们用的是 NLTK 的 tokenize.regexp 模块:

from nltk.tokenize import RegexpTokenizer
tokenizer = RegexpTokenizer(r'\w+') 

上面的代码会匹配所有单字字符,直到其遇到像空格这样的非单字的字符。这是个很简单的方法,但是会出现一些问题,比如像“don't”这样的单词就会被分成两个词“don”和“t“。NLTK 提供了很多像 nltk.tokenize.simple 这样的预留的特定结构的词。对于特殊用例,最好还是用 regex 和不断的迭代来使你的文档精确地分词。

注意:这个例子是对单个文档调用 tokenize()。你需要创建一个 for 循环来遍历所有文档。用下面这个脚本做个示例。

raw = doc_a.lower()
tokens = tokenizer.tokenize(raw)

>>> print(tokens)
['brocolli', 'is', 'good', 'to', 'eat', 'my', 'brother', 'likes', 'to', 'eat', 'good', 'brocolli', 'but', 'not', 'my', 'mother'] 

文档 doc_a 现在就是一个词的列表了。

停用词

英语中的一些特定组成部分,比如“for”,“or”这样的连词,或是“the”这种词对主题模型毫无意义。这些词叫做停用词,需要从我们的单词列表中移除。

停用词的定义是非常灵活的,在不同种类的文档中对应的停用词的含义是不同的。比如,如果我们要为一系列音乐评论做主题模型,那像“The Who”(英国的一支摇滚乐队)这种词就会有点麻烦,因为“the”通常都会作为一个常见的停用词而被移除。你可以根据实际情况来创建自己的停用词列表,或者用其他的包。

在我们的例子中,我们使用 Pypi 的 stop_words 包,这是一个相对比较保守的列表。我们可以调用 get_stop_words() 来创建一个停用词列表:

from stop_words import get_stop_words

# create English stop words list
en_stop = get_stop_words('en') 

现在,移除停用词只是一个循环遍历我们的单词的工作了,将每一个词都和 en_list 列表作比较。

# remove stop words from tokens
stopped_tokens = [i for i in tokens if not i in en_stop]

>>> print(stopped_tokens)
['brocolli', 'good', 'eat', 'brother', 'likes', 'eat', 'good', 'brocolli', 'mother'] 

词干提取

词干提取是 NLP 的另一个常见技术,它用于将相似的单词去除词缀得到词根。例如:“stemming”,“stemmer”,“stemmed”都有相似的意思;词干提取就是去除这些词的词缀而得到词根“stem”。这对主题模型来说很重要,否则如果将这些单词看做不同的实体,会降低他们在模型中的重要程度。

和停用词一样,词干提取也是非常灵活的,有些方法在特定的情形下可能会出问题。Porter stemming algorithm 是使用最广泛的方法。我们从 NLTK 中引入 Porter Stemmer 模块来实现这个算法

from nltk.stem.porter import PorterStemmer

# Create p_stemmer of class PorterStemmer
p_stemmer = PorterStemmer() 
注意,p_stemmer 要求所有单词的类型都是 str。p_stemmer 以词干的形式返回字符串参数。

# stem token
texts = [p_stemmer.stem(i) for i in stopped_tokens]

>>> print(stemmed_tokens)
['brocolli', 'good', 'eat', 'brother', 'like', 'eat', 'good', 'brocolli', 'mother'] 

构建 document-term matrix

(译注:document-term matrix 是一个描述文档词频的矩阵,每一行对应文档集中的一篇文档,每一列对应一个单词,这个矩阵可以根据实际情况,采用不同的统计方法来构建。)

清洗阶段的结果就是文本(texts),从单个的文档中整理出来的分好词,去除了停用词而且提取了词干的单词列表。假设我们已经循环遍历了所有文档,将每份文档都整理成为了文本。现在,文本就是一个(单词)列表的列表了,每个单词列表就代表一份原文档。

要生成一个 LDA model,我们需要知道每个词在文档中出现的频繁程度。为此我们需要用到一个叫 gensim 的包来构建 document-term matrix:

from gensim import corpora, models

dictionary = corpora.Dictionary(texts) 

Dictionary() 方法遍历所有的文本,为每个不重复的单词分配一个单独的整数 ID,同时收集该单词出现次数以及相关的统计信息。试试用 print(dictionary.token2id) 来查看每个单词的id。

接下来,我们要将 dictionary 转化为一个词袋:

doc2bow() 方法将 dictionary 转化为一个词袋。得到的结果 corpus 是一个向量的列表,向量的个数就是文档数。在每个文档向量中都包含一系列元组。举个例子,print(corpus[0]) 结果如下:

>>> print(corpus[0])
[(0, 2), (1, 1), (2, 2), (3, 2), (4, 1), (5, 1)] 

这个元组列表代表我们的第一个文档 doc_a。元组的形式是(单词 ID,词频)。所以如果 print(dictionary.roken2id) 显示 brocolli 的 id 是 0,那么第一个元组就代表 brocolli 这个词在 doc_a 里出现了两次。只有在文档中出现过的词才会包含在 doc2bow() 中,否则它将不会出现在文档向量之中。

应用 LDA 模型

corpus 是一个 document-term matrix,现在,我们已经为生成一个 LDA 模型做好准备了:

ldamodel = gensim.models.ldamodel.LdaModel(corpus, num_topics=3, id2word = dictionary, passes=20) 

LdaModel 类的详细描述可以在 gensim 文档中查看。我们的实例中用到的参数:

参数:

  • num_topics: 必须。LDA 模型要求用户决定应该生成多少个主题。由于我们的文档集很小,所以我们只生成三个主题。
  • id2word:必须。LdaModel 类要求我们之前的 dictionary 把 id 都映射成为字符串。
  • passes:可选。模型遍历语料库的次数。遍历的次数越多,模型越精确。但是对于非常大的语料库,遍历太多次会花费很长的时间。

检查结果

我们的 LDA 模型已经用 ldamodel 储存好了。我们可以用 print_topic 和 print_topics 方法来查看主题:

>>> print(ldamodel.print_topics(num_topics=3, num_words=3))
['0.141*health + 0.080*brocolli + 0.080*good', '0.060*eat + 0.060*drive + 0.060*brother', '0.059*pressur + 0.059*mother + 0.059*brother'] 

这是什么意思呢?每一个生成的主题都用逗号分隔开。每个主题当中有三个该主题当中最可能出现的单词。即使我们的文档集很小,这个模型依旧是很可靠的。还有一些需要我们考虑的问题:

- health, brocolli 和 good 在一起时有很好的含义。

- 第二个主题有点让人疑惑,如果我们重新查看源文档,可以看到 drive 有很多种含义:driving a car 意思是开车,driving oneself to improve 是激励自己进步。这是我们在结果中需要注意的地方。

- 第三个主题包含 mother 和 brother,这很合理。

调整模型的主题数和遍历次数对于得到一个好的结果是很重要的。两个主题看起来更适合我们的文档。

ldamodel = gensim.models.ldamodel.LdaModel(corpus, num_topics=2, id2word = dictionary, passes=20)

>>> print(ldamodel.print_topics(num_topics=2, num_words=4))
['0.054*pressur + 0.054*drive + 0.054*brother + 0.054*mother', '0.070*brocolli + 0.070*good + 0.070*health + 0.050*eat'] 

LDA 到底做了什么?

这个解释有点长,但是对于理解我们千辛万苦生成的模型非常有帮助。

LDA 假定文档是从主题的混合生成的。这些主题又是由一些单词的特定概率分布而生成的。就像我们演练的模型一样。换句话说,LDA 假定文档以以下步骤生成:

  1. 确定一个文档中的单词数。假设我们的文档有六个单词。 
  2. 确定该文档由哪些主题混合而来,例如,这个文档包含 1/2 的“健康”(health)主题和 1/2 的“蔬菜”(vegetables)主题。
  3. 用每个主题的多项分布生成的单词来填充文档中的单词槽。在我们的例子中,“健康”主题占文档的 1/2,或者说占三个词。“健康”主题有“diet”这个词的可能性是 20%,或者有“execise" 这个词的概率是 15%,单词槽就是基于这些概率来填充的。

基于文档如何生成的假定,LDA 反其道而行之,并尝试找出最初哪些主题会创建这些文档。

完整代码

from nltk.tokenize import RegexpTokenizer
from stop_words import get_stop_words
from nltk.stem.porter import PorterStemmer
from gensim import corpora, models
import gensim

tokenizer = RegexpTokenizer(r'\w+')

# create English stop words list
en_stop = get_stop_words('en')

# Create p_stemmer of class PorterStemmer
p_stemmer = PorterStemmer()
    
# create sample documents
doc_a = "Brocolli is good to eat. My brother likes to eat good brocolli, but not my mother."
doc_b = "My mother spends a lot of time driving my brother around to baseball practice."
doc_c = "Some health experts suggest that driving may cause increased tension and blood pressure."
doc_d = "I often feel pressure to perform well at school, but my mother never seems to drive my brother to do better."
doc_e = "Health professionals say that brocolli is good for your health." 

# compile sample documents into a list
doc_set = [doc_a, doc_b, doc_c, doc_d, doc_e]

# list for tokenized documents in loop
texts = []

# loop through document list
for i in doc_set:
    
    # clean and tokenize document string
    raw = i.lower()
    tokens = tokenizer.tokenize(raw)

    # remove stop words from tokens
    stopped_tokens = [i for i in tokens if not i in en_stop]
    
    # stem tokens
    stemmed_tokens = [p_stemmer.stem(i) for i in stopped_tokens]
    
    # add tokens to list
    texts.append(stemmed_tokens)

# turn our tokenized documents into a id <-> term dictionary
dictionary = corpora.Dictionary(texts)
    
# convert tokenized documents into a document-term matrix
corpus = [dictionary.doc2bow(text) for text in texts]

# generate LDA model
ldamodel = gensim.models.ldamodel.LdaModel(corpus, num_topics=2, id2word = dictionary, passes=20)​ 


关于 LDA 原理的更多分析,推荐两份资料《LDA 数学八卦》和《LDA 漫游指南》。

相关文章