最近看了不少关于主题模型的东西,要说起主题模型,现在最火的当然是LDA, LDA全称是Latent Dirichlet Allocation(隐狄利克雷分布), 而不是Linear Discriminant Analysis, 相信大家很多都对lda的理解感到痛苦不已,因为里面涉及到的数学推导实在是太多了,从gamma函数,beta分布,狄利克雷分布,马尔可夫蒙特卡洛模型,看到都觉得反胃,不过今天,我们不从这些来说,就跟大家分析一下怎么从工程上去实现整个LDA
实现之前还是来说一下LDA的起源:
先上一张来自Blei大师之手的图,简单的说一下吧, theta代表文档-主题分布,在工程上可以理解为一个矩阵,如果整个文档语料库包含的词是|W|,包含的文档数是|D|,那么矩阵的大小就是|D| * |W|,直观的来说,这个矩阵中存储的值theta[d][z]表示的是文档d中被分派给主题z的词的个数,更具体的,我们可以认为它就是p(z|d)
主题模型是一种生成模型,什么是生成模型呢,比如我们在构思一篇文档:(1)我们要选择文章的主题,一个主题中可能有多个词; (2)我们现在就要从这个主题中选择我们想要的词;第一个部分的概率就是p(z|d),表示在给定文档d,出现主题z的概率;
举一个例子(例子来源于 Rich jin的LDA数学八卦):
我们平时在构造一篇自然语言处理的文章时,可能会有40%的概率谈论语言学,30%的概率谈论概率统计,20%的概率谈论计算机,还有10%谈论其他主题;选定了主题之后,我们执行第二部,选词,那正常情况下,我们是怎么选词的呢?
class LDAModel:
alpha = float #超参数alpha
beta = float #超参数beta
D = int #文档数目
K = int #主题个数
W = int #词的个数
NumberOfIterations = int #迭代次数
SaveStep = int #存储的步数
Dictionary = object #整个语料的词典
Z = object # D * doc.size()大小的矩阵,Z[i][j]表示第i文档的第j个词背分配的主题
W = object # D * doc.size()大小的矩阵, W[i][j]表示第i文档的第j个词
IDListSet = object # D * doc.size()大小的矩阵, IDListSet[i][j]表示第i篇文档的第j个词在词典中的编号
nw = object # W * K 大小的矩阵, nw[w][z]表示词w被分配到主题z的次数
nd = object # D * K 大小的矩阵,nd[d][z]文档d中被分配为主题z的词的个数
nwsum = object # K * 1 大小的向量,nwsum[z]表示主题z中包含的词的个数
ndsum = object # D * 1 大小的向量,ndsum[d]表示文档d中包含的词的个数
theta = object # D * K 大小的矩阵,p(z|d) = theta[d][z]
phi = object # K * V 大小的矩阵,p(w|z) = phi[z][w]
具体我就不说这些成员的意思,注释上都有,
def __init__(self, alpha, beta, NumberOfIterations, SaveStep, K):
self.alpha = alpha
self.beta = beta
self.NumberOfIterations = NumberOfIterations
self.SaveStep = SaveStep
self.K = K
#初始化大小为K * 1的向量,初始值为0
self.nwsum = ListUtil.Initial(self.K)
有一些列表工具类的方法我已经打包了,先列出来
#ListUtil.py
import string
def Normalize(list, smoother=0.0):
"""
对向量list进行归一化处理,得到每个元素出现的概率
:param list: 向量
:param smoother: 平滑值,缺省值为0; 为了防止0概率的出现
"""
sum = Sum(list)
K = len(list)
newlist = []
if sum > 0:
newlist = [float((item + smoother) / (sum + K * smoother)) for item in list]
return newlist
def Sum(list):
"""
计算list中所有元素的和
"""
res = 0
for item in list:
res += item
return res
def Initial(size, data=0):
"""
生成一个大小为size, 所有元素都为data的列表
:param size: 列表大小
:param data: 列表元素
"""
list = []
for i in xrange(size):
list.append(data)
return list
def InitialMat(M, N, data=0):
"""
初始化大小为M * N的矩阵,所有元素初始化为data
:param M:
:param N:
:param data: 矩阵元素
"""
mat = []
for i in xrange(M):
row = Initial(N, data)
mat.append(row)
return mat
def InitialEmptyMat(rows):
"""
初始化一个空的matrix
:param rows:
"""
mat = []
for i in xrange(rows):
tmp = [] #代表每一个文档包含的词,初始化为空
mat.append(tmp)
return mat
def toString(list):
"""
将list中的元素拼接成字符串
方便用作文件操作
:param list: 列表元素
"""
listStr = ""
count = 0
for ele in list:
if type(ele) == int:
eleStr = str(ele)
elif type(ele) == float:
#浮点数转换为字符串,保留8位小数
eleStr = str("%.10f"%ele)
elif type(ele) == str or type(ele) == unicode:
eleStr = ele
if count != len(list) - 1:
eleStr += " "
count += 1
listStr += eleStr
listStr += "\n"
return listStr
def StringToFloatList(SS):
"""
string 转换为float
:param SS: 从文件中读取的字符串
"""
res = [string.atof(item) for item in SS.split(" ")]
return res
def AssignList(LL):
"""
将LL中的值拷贝到另一个list中
:param LL: 字符串
"""
newLL = []
for ele in LL:
newLL.append(ele)
return newLL
def FindMax(LL):
"""
返回列表LL中最大的元素
"""
LL.sort()
return LL[len(LL) - 1]
好,接着我们刚才的定义
def ModelInit(self, filename):
"""
读取文档,文本预处理,构造词典,构造语料库
"""
Docs = LoadData.LoadDataFromFile(os.getcwd() + "/" + filename)
self.D = len(Docs)
print "Load ", self.D, " docs from the file"
#读取停用词表
StopWordList = LoadData.LoadStopWords()
#对输入文本进行预处理:去标点符号,去停用词,词干化,然后每篇文档生成一个词的列表
WordListSet = [Preprocess.PreprocessText(doc, StopWordList) for doc in Docs if type(doc) != unicode]
#通过词表集构造词典
self.Dictionary = Preprocess.ConstructDictionary(WordListSet)
self.W = len(self.Dictionary)
print "Total number of words is: ", self.W
print "Begin to save the dictionary..."
self.SaveDictionary()
print "Done!!"
#IDListSet 大小 D * doc.size()
print "Begin to map the word to ID"
self.IDListSet = []
for wdl in WordListSet:
IdList = Preprocess.Word2Id(wdl, self.Dictionary)
self.IDListSet.append(IdList)
print "Done!!"
#ndsum[d] 文档d中包含的词的个数
self.ndsum = ListUtil.Initial(self.D)
#初始化一个 D * K的矩阵
self.theta = ListUtil.InitialMat(self.D, self.K, 0.0)
self.phi = ListUtil.InitialMat(self.K, self.W, 0.0)
#nd[d][z] 文档d中被分配给主题z的词数
self.nd = ListUtil.InitialMat(self.D, self.K, 0)
#nw[w][z] 主题z中包含的词w的个数
self.nw = ListUtil.InitialMat(self.W, self.K, 0)
#Z[d][w] 文档d的第w个词的主题
self.Z = []
print "Begin to initialize the LDA model..."
#初始化计数向量和计数矩阵
self.RandomAssignTopic()
print "Topic assignment done!!"
首先是从文件中读取文档,LoadData同样是我定义的工具类;然后用辅助类Preprocess去完成文本的预处理(包括去标点符号,去停用词,词干化,构造词典等等),初始化完成之后,再为每个词赋一个初始的topic,你可能要问,LDA中文档主题的选择应该要服从狄利克雷先验分布,但是为什么可以随机赋值,其实这要从马尔可夫链开始说起了,简单的来说,马尔可夫链就是对象的一系列状态的集合,并且对象的当前状态仅仅跟它的上一个状态有关,来看一个具体的例子
#Preprocess.py
import string
import nltk
from gensim import corpora
def PreprocessText(text, StopWordList):
"""
预处理一篇文本:剔除标点符号,词干化,去停用词
:param text: 传入的文本,类型为字符串
:param StopWordList: 停用词表
"""
WordList = DelPunctuation(text)
StemmeredWordList = Stemmer(WordList)
FilteredWordList = FilterStopWords(StemmeredWordList, StopWordList)
return FilteredWordList
def DelPunctuation(text):
"""
剔除文本中的标点符号
:param text:需要剔除标点符号的文本,类型为字符串
return:返回文本中的词的序列
"""
delset = string.punctuation
#将标点符号转换为空格
newText = text.encode('utf8').translate(None, delset)
#文本中的词的列表
WordList = [word for word in newText.split(" ") if word != '' and word != ' ']
return WordList
def FilterStopWords(WordList, StopWordList):
"""
返回去停用词后的词表
:param WordList:
:param StopWordList:
"""
FilteredWordList = filter(lambda x: x.lower() not in StopWordList, WordList)
return FilteredWordList
def Stemmer(WordList):
"""
对文档的词表进行词干化
:param WordList:
"""
stemmer = nltk.LancasterStemmer()
StemmeredWordList = [stemmer.stem(w) for w in WordList]
return StemmeredWordList
def ConstructDictionary(WordListSet):
"""
根据输入文档集texts构造词典
:rtype : object
:param WordListSet: 文档集对应的词表,WordListSet[i]表示第i篇文档中的词
"""
print "Begin to construct the dictionary"
res = corpora.Dictionary(WordListSet)
print "Total number of words is: ", len(res)
return res
def Word2Id(WordList, Dictionary):
"""
将词表转换为词典dictionary中的ID
:param WordList:
"""
IDList = []
for word in WordList:
#遍历字典查找目标项
for k, v in Dictionary.items():
if v == word:
IDList.append(k)
return IDList
在文本与处理时,用到了nltk这个强大的自然语言处理的库,程序中使用其中的LancasterStemmer()进行词干化;然后也用到了gensim库,在这个类中,主要是用corpora来构造训练文档集的词典
#LoadData.py
import os
import string
def LoadDataFromFile(path):
"""
:param path:短文本存放路径
"""
#转换为绝对路径
fp = open(path, 'r')
Docs = []
for line in fp:
#去掉结尾换行符
ll = line.strip('\n').strip('\r')
Docs.append(ll)
fp.close()
print "Done, load ", len(Docs), " docs from the file"
return Docs
def LoadStopWords():
"""
从指定路径读取停用词表
return:停用词列表
"""
path = os.getcwd()
path += "/StopWords.txt"
fp = open(path, 'r')
#获取停用词列表
StopWordsList = [line.strip('\n') for line in fp]
fp.close()
return StopWordsList
def LoadDictionary():
"""
从指定路径加载训练词典
"""
path = os.getcwd() + "/dictionary.txt"
fp = open(path, 'r')
Dictionary = dict()
for line in fp:
elements = line.strip('\n').split(" ")
#词的id
k = string.atoi(elements[0])
#词本身
v = elements[1]
Dictionary[k] = v
fp.close()
return Dictionary
这个类我就不多解释了,学过python的小伙伴应该都能看懂,只是涉及文件操作的路径名你们可以自己diy,我用的是我自己电脑上的文件名
def RandomAssignTopic(self):
"""
随机为文档中的词分配主题
更新计数向量ndsum, nwsum, 计数矩阵nd, nw的值
"""
for d in xrange(self.D):
DocSize = len(self.IDListSet[d])
row = ListUtil.Initial(DocSize)
self.Z.append(row)
for w in xrange(DocSize):
#从主题编号0-K-1中随机抽取一个
topic = Sample.UniSample(self.K)
#获取词的ID
wid = self.IDListSet[d][w]
self.Z[d][w] = topic
#被分派给topic的词w的数目自增1
self.nw[wid][topic] += 1
#文档d中被分配给主题topic的词的个数
self.nd[d][topic] += 1
#主题topic中包含的总的词数
self.nwsum[topic] += 1
self.ndsum[d] = DocSize
lda的训练过程主要就是吉布斯抽样的过程,具体的来说,吉布斯抽样就是将抽样的一个词w从当前的分布中抽出,然后通过抽出这个词之后的主题分布theta和词的分布phi,来计算这个词被分派到其他主题的概率,先上代码
def sampling(self, d, w):
"""
Gibbs Sampling为当前词重新分配主题
:param d: 文档编号
:param w: 词在文档中的编号
"""
topic = self.Z[d][w]
#对应位置上的词的ID
wid = self.IDListSet[d][w]
self.nw[wid][topic] -= 1
self.nd[d][topic] -= 1
self.nwsum[topic] -= 1
self.ndsum[d] -= 1
#p为马尔可夫链传递概率,p[z]表示当前词被分配到主题z的概率
p = self.ComputeTransProb(d, w)
#从多项分布中抽取新的主题
newtopic = Sample.MultSample(p)
self.nw[wid][newtopic] += 1
self.nd[d][newtopic] += 1
self.nwsum[newtopic] += 1
self.ndsum[d] += 1
return newtopic
def ComputeTransProb(self, d, w):
"""
对第d篇文档的第w个词
计算Gibbs Sampling过程中的传递概率
:param d: 文档编号
:param w: 词在文档中的编号
"""
#用于平滑
Wbeta = self.W * self.beta
Kalpha = self.K * self.alpha
#第d篇文档,第w个词对应的id
wid = self.IDListSet[d][w]
p = ListUtil.Initial(self.K, 0.0)
for k in xrange(self.K):
#p[k] = p(w|k)*p(k|d) k为主题
p[k] = (float(self.nw[wid][k]) + self.beta) / (float(self.nwsum[k]) + Wbeta) * (float(self.nd[d][k]) + self.alpha) / (float(self.ndsum[d]) + Kalpha)
return p
其实上面的计算法则就是p(z(i)=k|z', w, alpha,beta) = p(z'(i)=k|d)*p(w|z'(i)=k),z'就是代表将当前词w剔除之后的主题分布,z(i)对应当前词w的主题,这里就跟文章开头的生成模型的原理呼应上了,我们先以一定的概率选择主题(p(topic|doc)),然后在从主题包含的词中抽取相应的词(p(word|topic)),吉布斯抽样也是沿着doc->topic->word这样的方向进行的,给一张图大家可能更好理解,theta[m][k]表示第m篇文档,第k个主题出现的概率;phi[k][t]表示主题k中,词t出现的概率
def UniSample(K):
"""
产生从O到K-1的整数
:param K: 主题个数
"""
return RandomNumber.RandInt(0, K - 1)
def MultSample(ProbList):
"""
从多项分布ProbList中采样, ProbList表示剔除当前词之后的主题分布
:param ProbList: 多项分布
"""
size = len(ProbList)
for i in xrange(1, size):
ProbList[i] += ProbList[i - 1]
#随机产生一个[0,1)的小数
u = RandomNumber.RandFloat()
res = 0
for k in xrange(size):
if ProbList[k] >= u * ProbList[size - 1]:
#抽样结果
res = k
break
#res为抽样后的主题编号
return res
其实吉布斯抽样的目的就是为乐得到在 图模型中的theta和phi,那要怎么样计算theta和phi呢?其实很简单的,吉布斯抽样中,doc-topic, topic-word矩阵的计数是变化的,当抽样收敛之后,我们就得到了最后的计数,通过这些计数来计算频率就好了
def ComputTheta(self):
"""
计算p(z|d)矩阵
size:D * K
p(z|d) = theta[d][z]
"""
for d in xrange(self.D):
for k in xrange(self.K):
self.theta[d][k] = (float(self.nd[d][k]) + self.alpha) / (float(self.ndsum[d]) + self.K * self.alpha)
def ComputePhi(self):
"""
计算p(w|z)
size:K * W
p(w|z) = phi[z][w]
"""
for k in xrange(self.K):
for w in xrange(self.W):
self.phi[k][w] = (self.nw[w][k] + self.beta) / (self.nwsum[k] + self.W * self.beta)
为了防止0概率的出现,我们分别用alpha和beta做了平滑
def estimate(self):
"""
LDA参数估计
"""
for i in xrange(1, self.NumberOfIterations + 1):
for d in xrange(self.D):
for w in xrange(len(self.IDListSet[d])):
newtopic = self.sampling(d, w)
#为当前词分派新主题
self.Z[d][w] = newtopic
if i % self.SaveStep == 0:
#计算当前的迭代结果
self.ComputTheta()
self.ComputePhi()
self.SaveTempRes(i)