潜在语义分析(Latent Semantic Analysis)

主题模型(Topic Model) 是以非监督的方式对文档的隐含语义结构(Latent Semantic Structure)进行聚类的统计模型。它主要被用于自然语义处理中的语义分析和文本挖掘问题,例如:按主题对文本进行聚类、降维。

简言之:潜在语义分析就是对于给定的多个文档将其分解到任意多个主题中。

我们可以将文档表示成 TF 向量,或者 TF-IDF 向量,我们可以把 TF-IDF 看作是加权的 TF 向量。也就是说,我们的文档可以表示成由多个 TF 或者 TF-IDF 向量组成的矩阵。

潜在语义分析就是对该文档矩阵进行分解,使得能够提取出文档的潜在语义,从而实现对文档的聚类。它主要是基于奇异值分解(SVD)来实现,我们知道奇异值分解这个数学技术能够把一个矩阵分解成 3个更简单的矩阵,而这 3 个矩阵又具有某种具体的含义。我们可以简单理解下奇异值分解应用 LSA 时的公式的含义:

假设:我们有 m 篇文档,每篇文档的 TF-IDF 向量的维度是 n,p 表示我们设置的主题数,则

  1. U 矩阵是文档-主题矩阵,其中每一列表示每个文档与主题含义的相关性,值越大相关性越强;
  2. V 矩阵是主题-词矩阵,其中每一列表示某个词和某个主题的相关性,值越大相关性越强;
  3. S 矩阵是个对角矩阵,除了对角线上有值,其余位置为 0,为了方便表示,S 值一般都以一维数组返回,它表示每个主题包含原来所有文档的信息量程度,值越大说明对应主题包含的信息越多。

如果我们要将一批文档聚类的话,就可以使用 U 矩阵,U 矩阵中的每一行表示某个文档与某个主题相关性,我们只需要将当前文档归类到相关性最大的主题即可。

当然,我们还有个 V 矩阵,如果我们想对所有的词进行聚类的话,V 矩阵的每一列就表示了某个词与某个主题的相关性,我们把当前这个词归类到相关性最大的主题即可。

这里的 S 矩阵我们暂时不用,这是因为 U 和 V 矩阵已经按照包含的信息程度进行了排序。

接下来,我们举个例子,假设有以下的语料:

赶上华为平板活动分期还送了膜和套子和乐视卡很划算,华为平板速度快不比水果差,和手机操作一样很方便,做工一流
不错的手机,比较流畅,第二次购买了。大屏用着爽。
用了一段时间才来评价,挺不错的,国内平板也不差,希望更多人支持国货。
不错很喜欢就是有点&重礼物一不送点有没有贴膜也不知道
包装一般,外包装较大,平板的本身外包装很小,平板在里跳得很欢。
京东,才发现你是个骗子,服务更是一样的烂,客服是什么都,买东西没问题算万幸,有问题就只服务态度!
好不容易在网上买手机,回来充电器是坏的,诶没谁了
我才买两天就降价*!什么鬼!差评!我一颗星都不想给!
快递服务态度很好。折开看就是没有发票,联系服务,说没发票,我说京东自营没发票,说我没选择,差评!差评!
用了一个月机子就很卡,真心垃圾

我们使用奇异值分解对上面的语料(未标记)根据其潜在的语义进行聚类。先将其表示成 TF-IDF 矩阵,然后使用 numpy 对该矩阵进行奇异值分解,得到 U、S、V 三个矩阵,计算过程如下代码所示:

import numpy as np
import jieba
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.feature_extraction.text import TfidfVectorizer
import logging
import pandas as pd

jieba.setLogLevel(logging.CRITICAL)

pd.set_option('display.max_rows', None)
pd.set_option('display.max_columns', None)
pd.set_option('display.width', 1000) # 显示宽度


if __name__ == '__main__':

    documents = [' '.join(jieba.lcut(line.strip())) for line in open('data/ducuments.txt', encoding='gb18030')]
    print('文档数量:', len(documents))

    stop_words = [word.strip() for word in open('data/stopwords.txt')]
    encoder = TfidfVectorizer(stop_words=stop_words)
    documents_tf = encoder.fit_transform(documents).toarray()
    documents_tf = np.array(documents_tf)
    print('矩阵形状:', documents_tf.shape)

    u, s, vh = np.linalg.svd(documents_tf, full_matrices=False)
    print('\nU 矩阵:')
    print(pd.DataFrame(u).round(2))
    print('\nS 向量:')
    print(pd.DataFrame(s).round(2))
    print('\nV 矩阵:')
    print(pd.DataFrame(vh).round(2))

程序的输出结果:

文档数量: 10
矩阵形状: (10, 65)

U 矩阵:
      0     1     2     3    4    5     6     7     8     9
0 -0.61 -0.00  0.03  0.00 -0.0  0.0 -0.46  0.11  0.64 -0.00
1 -0.23  0.00 -0.67 -0.00  0.0 -0.0 -0.20  0.52 -0.43 -0.00
2 -0.46  0.00  0.21 -0.00  0.0 -0.0  0.77  0.39  0.04 -0.00
3 -0.12  0.00 -0.68  0.00  0.0  0.0  0.38 -0.55  0.29  0.00
4 -0.59 -0.00  0.21  0.00 -0.0  0.0 -0.12 -0.51 -0.57  0.00
5 -0.00 -0.53 -0.00 -0.66 -0.0 -0.0 -0.00 -0.00  0.00  0.53
6  0.00 -0.00  0.00  0.00  1.0  0.0  0.00  0.00  0.00  0.00
7 -0.00 -0.47 -0.00  0.75 -0.0 -0.0 -0.00 -0.00  0.00  0.47
8 -0.00 -0.71 -0.00 -0.00  0.0 -0.0 -0.00 -0.00  0.00 -0.71
9 -0.00 -0.00 -0.00  0.00 -0.0  1.0  0.00  0.00 -0.00  0.00

S 向量:
      0
0  1.12
1  1.11
2  1.06
3  1.00
4  1.00
5  1.00
6  0.96
7  0.94
8  0.90
9  0.87

V 矩阵:
     0    1     2     3     4     5     6     7     8     9     10    11    12    13    14    15    16    17    18    19    20    21    22    23    24   25    26    27    28    29    30    31    32    33    34    35    36    37    38    39    40    41    42    43    44    45    46   47    48    49    50   51    52    53    54    55    56    57    58    59    60    61    62    63    64
0 -0.04 -0.0 -0.13 -0.13 -0.00 -0.00 -0.13 -0.00 -0.11 -0.00 -0.00 -0.13  0.00 -0.00 -0.13  0.00 -0.13 -0.17 -0.26 -0.00 -0.00 -0.04  0.00 -0.13 -0.13 -0.0 -0.33 -0.09 -0.13  0.00 -0.00 -0.00 -0.13 -0.54 -0.17 -0.17 -0.00 -0.19 -0.00 -0.13 -0.13 -0.13 -0.13 -0.00 -0.04 -0.00 -0.00 -0.0 -0.13 -0.13 -0.09 -0.0 -0.04 -0.09  0.00 -0.00 -0.13 -0.09 -0.04 -0.13 -0.17 -0.00 -0.13 -0.00 -0.00
1 -0.00 -0.0  0.00  0.00 -0.19 -0.17  0.00 -0.19  0.00 -0.17 -0.19  0.00 -0.00 -0.27  0.00 -0.00  0.00 -0.00  0.00 -0.17 -0.45 -0.00 -0.00  0.00  0.00 -0.0 -0.00 -0.00  0.00 -0.00 -0.17 -0.42  0.00 -0.00 -0.00 -0.00 -0.15 -0.00 -0.15  0.00  0.00  0.00  0.00 -0.17 -0.00 -0.27 -0.27 -0.0  0.00  0.00 -0.00 -0.0 -0.00 -0.00 -0.00 -0.15  0.00 -0.00 -0.00  0.00 -0.00 -0.15  0.00 -0.19 -0.17
2 -0.27 -0.0  0.06  0.01 -0.00 -0.00  0.06 -0.00 -0.46 -0.00 -0.00  0.01  0.00 -0.00  0.01  0.00  0.01  0.06  0.01 -0.00 -0.00 -0.27  0.00  0.06  0.06 -0.0  0.13 -0.27  0.01  0.00 -0.00 -0.00  0.06  0.15  0.06  0.06 -0.00 -0.23 -0.00  0.06  0.01  0.06  0.06 -0.00 -0.27 -0.00 -0.00 -0.0  0.01  0.01 -0.27 -0.0 -0.27 -0.27  0.00 -0.00  0.06 -0.27 -0.27  0.01  0.06 -0.00  0.01 -0.00 -0.00
3  0.00 -0.0 -0.00 -0.00  0.35 -0.23 -0.00  0.35  0.00 -0.23  0.35 -0.00  0.00 -0.20 -0.00  0.00 -0.00 -0.00 -0.00 -0.23 -0.00 -0.00  0.00 -0.00 -0.00 -0.0 -0.00 -0.00 -0.00  0.00 -0.23  0.29 -0.00  0.00 -0.00 -0.00 -0.00 -0.00 -0.00 -0.00 -0.00 -0.00 -0.00 -0.23 -0.00 -0.20 -0.20 -0.0 -0.00 -0.00 -0.00 -0.0 -0.00 -0.00  0.00 -0.00 -0.00 -0.00 -0.00 -0.00 -0.00 -0.00 -0.00  0.35 -0.23
4  0.00 -0.0  0.00 -0.00 -0.00 -0.00  0.00 -0.00 -0.00 -0.00 -0.00  0.00  0.45 -0.00  0.00  0.45  0.00  0.00  0.00 -0.00  0.00  0.00  0.45  0.00  0.00 -0.0  0.00  0.00  0.00  0.45 -0.00  0.00  0.00 -0.00  0.00  0.00  0.00  0.00  0.00  0.00  0.00  0.00  0.00 -0.00  0.00 -0.00 -0.00 -0.0  0.00  0.00  0.00 -0.0  0.00  0.00  0.45  0.00  0.00  0.00  0.00  0.00  0.00  0.00  0.00 -0.00 -0.00
5  0.00  0.5 -0.00 -0.00 -0.00 -0.00 -0.00 -0.00 -0.00 -0.00 -0.00  0.00  0.00 -0.00  0.00  0.00  0.00  0.00  0.00 -0.00 -0.00  0.00  0.00 -0.00 -0.00  0.5  0.00 -0.00  0.00  0.00 -0.00 -0.00 -0.00  0.00  0.00  0.00 -0.00 -0.00 -0.00 -0.00  0.00 -0.00 -0.00 -0.00  0.00 -0.00 -0.00  0.5  0.00  0.00 -0.00  0.5  0.00 -0.00  0.00 -0.00 -0.00 -0.00  0.00  0.00  0.00 -0.00  0.00 -0.00 -0.00
6  0.16  0.0  0.26 -0.12 -0.00 -0.00  0.26 -0.00  0.06 -0.00 -0.00 -0.12  0.00 -0.00 -0.12  0.00 -0.12 -0.04 -0.23 -0.00 -0.00  0.16  0.00  0.26  0.26  0.0 -0.07 -0.09 -0.12  0.00 -0.00 -0.00  0.26 -0.04 -0.04 -0.04 -0.00 -0.18 -0.00  0.26 -0.12  0.26  0.26 -0.00  0.16 -0.00 -0.00  0.0 -0.12 -0.12 -0.09  0.0  0.16 -0.09  0.00 -0.00  0.26 -0.09  0.16 -0.12 -0.04 -0.00 -0.12 -0.00 -0.00
7 -0.24  0.0  0.14  0.03 -0.00 -0.00  0.14 -0.00 -0.01 -0.00 -0.00  0.03  0.00 -0.00  0.03  0.00  0.03 -0.17  0.06 -0.00 -0.00 -0.24  0.00  0.14  0.14  0.0 -0.34  0.24  0.03  0.00 -0.00 -0.00  0.14 -0.11 -0.17 -0.17 -0.00  0.22 -0.00  0.14  0.03  0.14  0.14 -0.00 -0.24 -0.00 -0.00  0.0  0.03  0.03  0.24  0.0 -0.24  0.24  0.00 -0.00  0.14  0.24 -0.24  0.03 -0.17 -0.00  0.03 -0.00 -0.00
8  0.13 -0.0  0.02  0.17  0.00  0.00  0.02  0.00 -0.06  0.00  0.00  0.17 -0.00  0.00  0.17 -0.00  0.17 -0.20  0.34  0.00  0.00  0.13 -0.00  0.02  0.02 -0.0 -0.40 -0.21  0.17 -0.00  0.00  0.00  0.02 -0.03 -0.20 -0.20  0.00 -0.03  0.00  0.02  0.17  0.02  0.02  0.00  0.13  0.00  0.00 -0.0  0.17  0.17 -0.21 -0.0  0.13 -0.21 -0.00  0.00  0.02 -0.21  0.13  0.17 -0.20  0.00  0.17  0.00  0.00
9 -0.00  0.0 -0.00  0.00  0.25  0.21 -0.00  0.25  0.00  0.21  0.25 -0.00  0.00  0.02 -0.00  0.00 -0.00 -0.00 -0.00  0.21 -0.57 -0.00  0.00 -0.00 -0.00  0.0 -0.00 -0.00 -0.00  0.00  0.21 -0.12 -0.00  0.00 -0.00 -0.00 -0.19 -0.00 -0.19 -0.00 -0.00 -0.00 -0.00  0.21 -0.00  0.02  0.02  0.0 -0.00 -0.00 -0.00  0.0 -0.00 -0.00  0.00 -0.19 -0.00 -0.00 -0.00 -0.00 -0.00 -0.19 -0.00  0.25  0.21

U 矩阵的行表示每个文档,列表示主题,V 矩阵的行表示主题,列表示每个词。注意:可以把使用 numpy 的 svd 函数得到的主题数量为文档的数量,即:输入 10 个文档,输出 10 个主题。

比如,我们接下来,要对文档进行聚类,此时我们要聚成 2 类,就以前 2 个主题为依据,将每个文档放到不同的主题类别中。我们只需要遍历 U 矩阵,将每行的文档归类到相关性最大的主题即可。如下代码所示:

    topic = [[] for _ in range(2)]
    for index in range(len(u)):
        topic_index = np.argmax(u[:, :2][index])
        topic[topic_index].append(''.join(documents[index].split()))

    for index in range(len(topic)):
        print(pd.DataFrame(topic[index]))
        print('-' * 100)

程序输出结果:

# 主题1
                                                   0
0   京东,才发现你是个骗子,服务更是一样的烂,客服是什么都,买东西没问题算万幸,有问题就只服务态度!
1                           好不容易在网上买手机,回来充电器是坏的,诶没谁了
2                         我才买两天就降价*!什么鬼!差评!我一颗星都不想给!
3  快递服务态度很好。折开看就是没有发票,联系服务,说没发票,我说京东自营没发票,说我没选择,差...
4                                    用了一个月机子就很卡,真心垃圾
------------------------------------------------------------------------------------
# 主题2
                                                   0
0  赶上华为平板活动分期还送了膜和套子和乐视卡很划算,华为平板速度快不比水果差,和手机操作一样很...
1                           不错的手机,比较流畅,第二次购买了。大屏用着爽。
2                 用了一段时间才来评价,挺不错的,国内平板也不差,希望更多人支持国货。
3                         不错很喜欢就是有点&重礼物一不送点有没有贴膜也不知道
4                    包装一般,外包装较大,平板的本身外包装很小,平板在里跳得很欢。

因为在选择语料的时候,我根据语料标签,分别选择了好评和差评各 5 条样本。这里咱们再聚类回 2 个主题时,大致可以看到第一个主题都是关于差评的,第二个主题大体都是关于好评的。

我们也可以语料中的关键词进行聚类,此时我们就要使用 V 矩阵提供的信息,先将 V 矩阵转置,使其从【主题-词】矩阵变成【词-主题】矩阵,然后遍历每个词,将其归类到相关性较大的主题中,实现词粒度的聚类,我们这里将其聚类为 2 类。如下代码所示:

    keyword = [[] for _ in range(2)]
    for index in range(len(vh.transpose())):
        keyword_index = np.argmax(vh.transpose()[:, :2][index])
        keyword[keyword_index].append(encoder.get_feature_names_out()[index])

    for index in range(len(keyword)):
        print(index, ' '.join(keyword[index]))

程序的输出结果:

0 一个月 一颗 万幸 不想 东西 两天 买手机 京东 充电器 发现 发票 回来 垃圾 好不容易 客服 差评 快递 折开 更是 服务 服务态度 机子 真心 网上 自营 选择 降价 骗子
1 一不送 一段时间 一流 不差 不错 乐视卡 做工 划算 包装 华为 喜欢 国内 国货 外包装 大屏 套子 希望 平板 很小 很欢 手机 挺不错 操作 支持 更多人 有没有 水果 活动 流畅 礼物 第二次 评价 购买 贴膜 赶上 较大 速度

这两个主题可能并不太容易用个词来表示其主题的主旨了,大体看起来的话,第一个主题里面的词可能都是负面词、以及和负面词经常一起出现的词较多。而第二个主题则是一些积极的词,积极和经常和积极的词一同出现的词较多。

如果我们的语料非常大的话,建议使用 scikit-learn 中的 TruncatedSVD 来完成主题聚类,如下代码所示:

import numpy as np
import jieba
from sklearn.feature_extraction.text import TfidfVectorizer
import logging
import pandas as pd
from sklearn.decomposition import TruncatedSVD

jieba.setLogLevel(logging.CRITICAL)

pd.set_option('display.max_rows', None)
pd.set_option('display.max_columns', None)
pd.set_option('display.width', 1000) # 显示宽度

if __name__ == '__main__':

    documents = [' '.join(jieba.lcut(line.strip())) for line in open('data/ducuments.txt', encoding='gb18030')]
    print('文档数量:', len(documents))

    stop_words = [word.strip() for word in open('data/stopwords.txt')]
    encoder = TfidfVectorizer(stop_words=stop_words)
    documents_tf = encoder.fit_transform(documents).toarray()
    documents_tf = np.array(documents_tf)
    print('矩阵形状:', documents_tf.shape)

    model = TruncatedSVD(n_components=2, n_iter=7, random_state=8)
    result = model.fit_transform(documents_tf)
    print(result)
    document_topic = np.argmax(result, axis=1)
    print(document_topic)
    topic = [[], []]

    for document, topic_index in zip(documents, document_topic):
        topic[topic_index].append(''.join(document.split()))

    for index in range(len(topic)):
        print(pd.DataFrame(topic[index]))
        print('-' * 100)

程序输出结果:

文档数量: 10
矩阵形状: (10, 65)
[[ 6.81214788e-01 -3.29559032e-15]
 [ 2.63475040e-01 -5.72438152e-15]
 [ 5.15907390e-01 -2.54050382e-15]
 [ 1.31581521e-01  1.14409906e-15]
 [ 6.65034949e-01  1.84976522e-15]
 [ 2.14574950e-15  5.91446237e-01]
 [ 1.34538585e-16  2.64693387e-16]
 [ 1.69034032e-15  5.18546669e-01]
 [ 2.61601434e-15  7.86574408e-01]
 [-3.52469698e-16  1.04060895e-15]]
[0 0 0 0 0 1 1 1 1 1]
                                                   0
0  赶上华为平板活动分期还送了膜和套子和乐视卡很划算,华为平板速度快不比水果差,和手机操作一样很...
1                           不错的手机,比较流畅,第二次购买了。大屏用着爽。
2                 用了一段时间才来评价,挺不错的,国内平板也不差,希望更多人支持国货。
3                         不错很喜欢就是有点&重礼物一不送点有没有贴膜也不知道
4                    包装一般,外包装较大,平板的本身外包装很小,平板在里跳得很欢。
--------------------------------------------------------------------------------------
                                                   0
0   京东,才发现你是个骗子,服务更是一样的烂,客服是什么都,买东西没问题算万幸,有问题就只服务态度!
1                           好不容易在网上买手机,回来充电器是坏的,诶没谁了
2                         我才买两天就降价*!什么鬼!差评!我一颗星都不想给!
3  快递服务态度很好。折开看就是没有发票,联系服务,说没发票,我说京东自营没发票,说我没选择,差...
4                                    用了一个月机子就很卡,真心垃圾
--------------------------------------------------------------------------------------

我们上面使用 TruncatedSVD 只输出了 U 矩阵,如果想要拿到 V 矩阵,可以通过 TruncatedSVD 对象的 components_ 属性来获得,如下代码所示:

print(model.components_.shape)
# 输出,行表示主题数量,列表示所有的词
(2, 65)

我们对词也进行一下聚类:

    word_topic = np.argmax(model.components_.transpose(), axis=1)
    words = [[], []]
    for word, word_index in zip(encoder.get_feature_names_out(), word_topic):
        words[word_index].append(word)

    for word_index in range(len(words)):
        print(word_index, ' '.join(words[word_index]))

程序输出结果:

0 一不送 一段时间 一流 不差 不错 乐视卡 做工 划算 包装 华为 喜欢 国内 国货 外包装 大屏 套子 希望 平板 很小 很欢 手机 挺不错 操作 支持 更多人 有没有 水果 活动 流畅 礼物 第二次 评价 购买 贴膜 赶上 较大 速度
1 一个月 一颗 万幸 不想 东西 两天 买手机 京东 充电器 发现 发票 回来 垃圾 好不容易 客服 差评 快递 折开 更是 服务 服务态度 机子 真心 网上 自营 选择 降价 骗子

写到现在的话,我们是不是觉得 LSA 算法可以对输入的文章做文本摘要呢?其步骤如下:

  1. 先将输入的文章分割成一个又一个的句子;
  2. 对句子进行 TF-IDF 编码;
  3. 使用 TruncatedSVD 将句子分割成 N 个主题;
  4. 从每个主题中抽取 N 个句子组成最后的摘要。

从每个主题中抽取句子是因为,我们的摘要想要更全面的 “以偏概全” 整个输入文章的主题。当然,更可以对一篇文章提取关键字,思路也非常简单,将每个主题的前几个词组合在一起称为文章的关键词。

未经允许不得转载:一亩三分地 » 潜在语义分析(Latent Semantic Analysis)