算法复杂度包含两个方面,时间复杂度和空间复杂度,也就是常称的计算复杂度和内存空间占用两方面。下面先说主题模型的计算复杂度,再说内存占用。
本来这个问题挺简单的,但是有搞统计的同学不知道CS里面的复杂度是怎么计算,就写到这里吧。本文比较三个topic model时间复杂度的计算方法,分别是LDA,Biterm,和hdLDA。
时间复杂度的计算很好掌握,就是把算法中的最基本操作作为单元操作,设定其时间复杂度为O(1)。最简单的可以是赋值、加减乘除等操作;复杂的也可以将之前文章中讲到的一次Gibbs sampling抽样(一个变量t+1时刻的值,由其它变量t时刻的值抽得),作为一个单元操作。最后看该操作总共执行了多少次,若执行了M次,则算法的时间复杂度就是O(M)。
在Topic Model中,单元操作可看作是一次Gibbs抽样(其实可以更细粒度到加减乘除运算,但是本次所对比的LDA和Biterm模型的单次抽样复杂度相同,所以不需要比较更细粒度的运算)。那么计算时间复杂度就是计算该抽样所执行的次数。所以接下来就是看一下套在单元计算外面的计算步骤。
int last_iter = liter;
for (liter = last_iter + 1; liter <= niters + last_iter; liter++) {
printf("Iteration %d ...\n", liter);
for (int m = 0; m < M; m++) {
for (int n = 0; n < ptrndata->docs[m]->length; n++) {
int topic = sampling(m, n);
z[m][n] = topic;
}
}
}
由上面代码可以看出,最外层是一个迭代次数 Niter , 内层是文档个数 M ,也即 ND ,再内层是相应文档的长度(不重复单词的个数)。由于每一篇文档的长度不同,因此取平均值 l¯ 。然后再看一下sampling()代码:
int model::sampling(int m, int n) {
// remove z_i from the count variables
int topic = z[m][n];
int w = ptrndata->docs[m]->words[n];
nw[w][topic] -= 1;
nd[m][topic] -= 1;
nwsum[topic] -= 1;
ndsum[m] -= 1;
double Vbeta = V * beta;
double Kalpha = K * alpha;
// do multinomial sampling via cumulative method
for (int k = 0; k < K; k++) {
p[k] = (nw[w][k] + beta) / (nwsum[k] + Vbeta) *
(nd[m][k] + alpha) / (ndsum[m] + Kalpha);
}
// cumulate multinomial parameters
for (int k = 1; k < K; k++) {
p[k] += p[k - 1];
}
double u = ((double)random() / RAND_MAX) * p[K - 1];
for (topic = 0; topic < K; topic++) {
if (p[topic] > u) {
break;
}
}
nw[w][topic] += 1;
nd[m][topic] += 1;
nwsum[topic] += 1;
ndsum[m] += 1;
return topic;
}
可以看到核心计算过程也就是在为p[k]赋值的过程,而k一共有K个。所以最终的复杂度也就是: O(NiterNDKl¯) 。
那么计算Biterm topic model也就很简单了。由于该模型的主题分布不在文档上,而是在biterm上,而biterm的计算是 l¯(l¯−1)/2 。因此时间复杂度就是 O(NiterKl¯(l¯−1)/2) 。
对于hdLDA模型来说,其抽样步骤比较繁琐。具体代码为:
printf("Sampling %d iterations!\n", niters);
int last_iter = liter;
for (liter = last_iter + 1; liter <= niters + last_iter; liter++) {
for (int m = 0; m < M; m++) {
for (int n = 0; n < ptrndata->contents[m]->length; n++) {
int topic = sampling_step_one(m, n);
z[m][n] = topic;
}
}
for (int m = 0; m < M; m++) {
for (int c = 0; c < ptrndata->comments[m]->C; c++) {
xdc[m][c] = sampling_step_two_x(m, c);
ydc[m][c] = sampling_step_two_y(m, c);
}
}
for (int m = 0; m < M; m++) {
for (int c = 0; c < ptrndata->comments[m]->C; c++) {
sampling_step_three(m, c);
}
}
}
可以看到每一个sampling step在外层都共享了 NiterND 次循环(其中用 ND代表M )。而sampling_step_one()接下来的循环次数为:
ptrndata->contents[m]->length
这与LDA代码部分相似。
而sampling_step_two()和sampling_step_three()则各自循环下面次数,也就是每篇正文的跟帖条数:
ptrndata->comments[m]->C
由于不是每篇正文的跟帖数目都是相同的,因此这里也取平均值,记作 C¯ 。
接下来再看看每一个抽样步骤中的时间复杂度。sampling_step_one()的代码是这样的:
int model::sampling_step_one(int m, int n) {
int topic = z[m][n];
int w = ptrndata->contents[m]->words[n];
ndk[m][topic] -= 1;
nkv[topic][w] -= 1;
ndksum[m] -= 1;
nkvsum[topic] -= 1;
for (int k = 0; k < K; k++) {
p[k] = exp(log(ndk[m][k] + gdk[m][k] + alpha) - log((ndksum[m] + gdksum[m] + Kalpha)) + log((nkv[k][w] + gkv[k][w] + betaFM[k][w])) - log((nkvsum[k] + gkvsum[k] + VbetaF[k])));
}
// cumulate multinomial parameters
for (int k = 1; k < K; k++) {
p[k] += p[k - 1];
}
// scaled sample because of unnormalized p[]
double u = ((double)rand() / RAND_MAX) * p[K - 1];
for (topic = 0; topic < K; topic++) {
if (p[topic] >= u) {
break;
}
}
if (topic == K){
topic = K - 1;
}
ndk[m][topic] += 1;
nkv[topic][w] += 1;
ndksum[m] += 1;
nkvsum[topic] += 1;
return topic;
}
可以看到其中核心计算步骤迭代了K次,与LDA和Biterm相同。因此对于sampling_step_one()步来说,其具有复杂度 NiterNDl¯ 。
对于sampling_step_two_x()和sampling_step_two_y()来说,其代码非常相似,节省篇幅起见,下面只给出step_two_x()的代码:
int model::sampling_step_two_x(int m, int c){
int topic = 0;
for (int i = 0; i < ptrndata->comments[m]->commlines[c]->length; i++) {
if(I[m][c][i]) {
topic = xdc[m][c];
gdk[m][topic] -= 1;
gdksum[m] -= 1;
gkv[topic][ptrndata->comments[m]->commlines[c]->words[i]] -= 1;
gkvsum[topic] -= 1;
}
}
for (int k = 0; k < K; k++) {
p[k] = log(1.0);
double left = log(ndk[m][k] + gdk[m][k] + alpha) - log(ndksum[m] + gdksum[m] + Kalpha);
for (int i = 0; i < ptrndata->comments[m]->commlines[c]->length; i++) {
if (I[m][c][i]){
int w = ptrndata->comments[m]->commlines[c]->words[i];
p[k] += (log(nkv[k][w] + gkv[k][w] + betaFM[k][w]) - log(nkvsum[k] + gkvsum[k] + VbetaF[k]));
}
}
p[k] += left;
p[k] = exp(p[k]);
}
// cumulate multinomial parameters
for (int k = 1; k < K; k++) {
p[k] += p[k - 1];
}
double u = ((double)rand() / RAND_MAX) * p[K - 1];
for (topic = 0; topic < K; topic++) {
if (p[topic] >= u) {
break;
}
}
if (topic == K){
topic = K - 1;
}
for (int i = 0; i < ptrndata->comments[m]->commlines[c]->length; i++) {
if(I[m][c][i]) {
gkv[topic][ptrndata->comments[m]->commlines[c]->words[i]] += 1;
gkvsum[topic] += 1;
gdk[m][topic] += 1;
gdksum[m] += 1;
}
}
return topic;
}
可以看出这一步中,计算时间复杂度为 C¯lc¯ ,其中 lc¯ 为跟帖的平均长度。同理sampling_step_two_y()也具有相同的复杂度。
而sampling_step_three()的复杂度为 lc¯ 。因此hdLDA的时间复杂度为: NiterND(Kl¯+2KC¯lc¯+C¯lc¯)
空间复杂度就是程序在计算时,需要放在内存中用于存储中间结果的矩阵、向量等数据类型所占的空间。一般省略掉无关重要的变量,如标记变量等。而只关注与算法核心相关的内存空间占用(好像更贴近伪代码)。
在LDA中,算法需要维护三个矩阵: θ,ϕ 和字典映射矩阵。它们分别为文档在主题上的分布矩阵,规模为 ND∗K ;主题在词上的分布,规模为 W∗K 和文档次的编号映射矩阵,规模为 ND∗l¯ 。因此其空间复杂度就是 NDK+WK+ND∗l¯ 了。
Biterm因为没有了文档的概念,没有了矩阵 θ , 而 ϕ 又变成了主题在biterm上的分布,因而复杂度相对于LDA有一点调整,是 K+WK+NDl¯(l¯−1)/2 。
hdLDA所需要的不重要变量特别多,但是如果不计入核心算法的话,有 ϕ 矩阵,规模为 WK ; θ 矩阵,规模为 NDK ; ψ 矩阵,规模为 JW ,其中 K 表示formal topic 的个数, J 表示leisure topic 的个数;以及文档保存矩阵ptrndata(结构体),规模为 NDC¯lc¯ 因此空间复杂度为 WK+NDK+JW+NDC¯lc¯ 。
发现好简单啊。