手上有一大堆文档,想自动按主题归类,或者搜一个词就能把意思相关却字面不重合的文档找出来,这是文本挖掘每天都在面对的问题。直接数词频有个硬伤,两句话可能一个共同的词都没有,意思却很接近,比如手机和平板、平板和电脑,字面不重叠但都在说消费电子。潜在语义分析(LSA)就是冲着这个来的,它不从字面匹配,而是从文档里挖出隐藏的主题,把词和文档都摆进同一个语义空间。
这一节我们讲清楚 LSA 怎么先用 TF-IDF 把文档变成矩阵,再靠奇异值分解把这个大矩阵拆成几个有实际含义的小矩阵,中间配一个手算得清的小例子和完整代码。读之前只需要知道矩阵相乘是怎么回事,对特征值有个模糊印象就行,真正的分解交给 numpy。
1. 词频成矩阵
要让计算机处理文档,第一步是把文字变成数。常用的做法是 TF-IDF,它给每个词在每篇文档里算一个权重,词在本篇出现得多、在全部文档里出现得少,权重就高。把一篇文档看成一个由这些权重组成的向量,m 篇文档摞在一起,就得到一个 m 行 n 列的矩阵,m 是文档数,n 是词表大小,这就是词-文档矩阵。矩阵的每一行是一篇文档,每一列是一个词。
问题也跟着来了。这个矩阵又高又稀,同义词和多义词还让它显得更乱。LSA 的想法是,真正决定一篇文档属于哪个主题的,并不是成千上万列词,而是背后屈指可数的几个主题方向。它要做的就是把这个巨大的矩阵压缩,提取出隐藏的主题结构。
2. 矩阵做分解
压缩靠的是奇异值分解,也就是常说的 SVD。数学上它能把一个任意形状的矩阵 M,拆成三个更小矩阵的乘积,M 等于 U 乘 S 乘 V 的转置。这三个矩阵不是随便拆的,各有各的语义,这正是 LSA 能用它做主题分析的原因。设我们有 m 篇文档、词表 n 个、想挖 p 个主题:
U 矩阵是文档到主题的关系,每一行是一篇文档,每一列是一个主题,数值越大说明这篇文档和这个主题越相关。V 的转置,也就是代码里的 vh,是主题到词的关系,每一行是一个主题,每一列是一个词,数值越大说明这个词对这个主题越重要。S 是一个对角矩阵,对角线上的数叫奇异值,从大到小排好,每个数代表对应那个主题抓得住多少原始信息,越大的主题越重要。
为什么只取前几个主题就够了?因为 S 里的奇异值排得很开,前几个大,后面迅速变小,那些很小的奇异值对应的方向,基本是噪声和冗余。把小的砍掉,只留前 p 个奇异值对应的 U、S、V,就得到一个低维近似,文档和词被映射到 p 个主题上,聚类和检索就在这个低维空间里做。
3. 小例看分解
拿一个能手算核对的小矩阵走一遍,三个矩阵各自的含义立刻就清楚了。假设有 4 篇文档、3 个词,词依次是苹果、香蕉、橙汁,数一下每篇文档里各词出现的次数,得到下面这个 4 行 3 列的矩阵:
| 文档 | 苹果 | 香蕉 | 橙汁 |
|---|---|---|---|
| D1 | 4 | 3 | 0 |
| D2 | 3 | 2 | 0 |
| D3 | 0 | 0 | 5 |
| D4 | 0 | 0 | 4 |
肉眼就能看出 D1、D2 在说水果,D3、D4 在说饮料。让 numpy 对这个矩阵做 SVD,奇异值算出来是 6.40、6.16、0.16。第三个比前两个小一个数量级,说明这个矩阵本质上只有两个主题,第三个方向几乎是噪声。拆出来的 vh 每一行对应一个主题在三个词上的权重:
import numpy as np
M = np.array([[4, 3, 0],
[3, 2, 0],
[0, 0, 5],
[0, 0, 4]], dtype=float)
u, s, vh = np.linalg.svd(M, full_matrices=False)
print(np.round(s, 2)) # [6.4 6.16 0.16]
print(np.round(u, 2))
print(np.round(vh, 2))
| 主题 | 苹果 | 香蕉 | 橙汁 | 含义 |
|---|---|---|---|---|
| 主题1(奇异值 6.40) | 0.00 | 0.00 | -1.00 | 饮料 |
| 主题2(奇异值 6.16) | -0.81 | -0.58 | 0.00 | 水果 |
| 主题3(奇异值 0.16) | 0.58 | -0.81 | 0.00 | 噪声,丢弃 |
vh 的两行大权重直接说出了主题,主题1 全压在橙汁上,主题2 压在苹果和香蕉上。再看 u 矩阵,它是每篇文档在这几个主题上的坐标,只保留前两个主题:
| 文档 | 主题1坐标(饮料) | 主题2坐标(水果) | 归入 |
|---|---|---|---|
| D1 | 0.00 | -0.81 | 水果 |
| D2 | 0.00 | -0.58 | 水果 |
| D3 | -0.78 | 0.00 | 饮料 |
| D4 | -0.62 | 0.00 | 饮料 |
每篇文档看自己在哪个主题上的绝对值更大,就归到那个主题。D1、D2 落在水果方向,D3、D4 落在饮料方向,和肉眼判断完全一致。这里有个细节要提醒,坐标是正还是负并不重要,SVD 给坐标轴的朝向是任意的,判断一篇文档属于哪个主题,看的是哪个分量的绝对值更大,不是看正负号。
4. 文档聚类法
小例子看懂了,换成真实语料就是同一套流程。下面这段代码读入一批中文评论,用 jieba 分词,套 TF-IDF 变成矩阵,再调 numpy 的 SVD 拆出 u、s、vh。原文用的是 10 条评论、65 个词,矩阵形状是 (10, 65):
import numpy as np
import jieba
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.txt 里每行一条评论,stopwords.txt 每行一个停用词
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('奇异值:', np.round(s, 2))
要把文档聚成 2 类,就只取 u 的前两列,逐行找出最大的那个主题号,把文档塞进去。作者这份语料的符号方向恰好一致,直接按最大数值归类就分对了,换成别的语料更稳妥的做法是先取绝对值再比,道理和上一节讲的一致。这个语料原本是好评、差评各 5 条,跑完之后一个主题几乎收进全部好评,另一个主题几乎收进全部差评,说明 LSA 在没人告诉它标签的情况下,自己把两种情绪的文档分开了:
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)
想对词聚类就换用 vh。vh 的每一行是主题、每一列是词,转置之后每一行就成了一个词在各主题上的坐标,同样取前两个主题、找最大绝对值,把词归进去。跑出来一类词偏负面,比如骗子、差评、客服、垃圾,另一类偏正面,比如不错、流畅、划算、华为,和文档聚类对得上。
5. 大数据降维
语料一大,numpy 那个 SVD 就扛不住了,它要把整个 m 乘 n 的矩阵全拆完,内存和时间都吃不消。好在主题我们只取前几个,没必要把全部奇异值算出来。scikit-learn 里的 TruncatedSVD 就是干这个的,它只算你指定的前 p 个奇异方向,能直接吃稀疏矩阵,是工业界跑 LSA 的标准做法:
from sklearn.decomposition import TruncatedSVD model = TruncatedSVD(n_components=2, n_iter=7, random_state=8) result = model.fit_transform(documents_tf) # 这就是截断后的 u document_topic = np.argmax(result, axis=1) print(document_topic) # [0 0 0 0 0 1 1 1 1 1],前 5 条好评归 0,后 5 条差评归 1
fit_transform 返回的就是截断后的文档到主题矩阵,直接逐行取最大的那一列号就是聚类结果。原文跑出来是 [0,0,0,0,0,1,1,1,1,1],和手工 SVD 的分组一致,只是主题编号换了个号,这很正常,编号本身没有意义。想要主题到词的矩阵,也就是前面那个 vh,去 TruncatedSVD 对象的 components_ 属性拿就行,它的形状是 (2, 65),两行两个主题,65 列对应全部词。
6. 自动做摘要
顺着聚类的思路再走一步,LSA 还能给一篇文章自动写摘要。做法是先把整篇文章切成一句一句,把每个句子当成一篇小文档,照样做 TF-IDF 编码,再用 TruncatedSVD 把这些句子投影到几个主题上。接下来从每个主题里挑出那个在该主题上权重最大的句子,几个主题各取几句凑在一起,就是摘要。
为什么每个主题都要抽几句?因为一篇文章通常同时讲好几件事,只挑权重最大的那一句,容易漏掉其他侧面,每个主题各取代表句,摘要才能覆盖全文的主要意思。提取关键词是同一个思路,把每个主题下权重最高的那几个词拿出来拼在一起,就是这篇文章的关键词。
7. 常见误区
第一个坑,把 LSA 当成 LDA。两者都在挖主题,但 LSA 靠 SVD 做线性分解,是代数方法,LDA 是带概率假设的生成模型,原理和用法都不同,别因为都叫主题模型就混为一谈。
第二个坑,以为拆出来的主题编号有固定含义。每次分解坐标轴朝向和顺序都可能变,这次 0 号主题是好评,换个随机种子可能就变成差评,判断内容永远要看那个主题下的词,不是看编号。
第三个坑,盲目取太多主题。主题数 p 要靠奇异值下降的拐点来定,小例子里第三个奇异值直接掉到 0.16,取 2 个就够。p 取得太大,又把噪声当主题请了回来,聚类反而散。
回到开头的问题。LSA 把文档表示成 TF-IDF 矩阵,再用 SVD 拆成文档到主题的 U、主题到词的 Vh,以及排成一列的奇异值 S。保留前几个大奇异值对应的方向,就把高维稀疏矩阵压到了几个主题上,文档和词都能按主题归类。小例子里三个奇异值 6.40、6.16、0.16 一摆,该留两个主题一目了然。语料大就用 TruncatedSVD 只算前几个方向。它解决的是字面不重合但语义相近的问题,是文本聚类和检索里的经典一招。




冀公网安备13050302001966号