0%

向量空间模型求文档相似度

向量空间模型是将文档用向量的方式来表示,每个维度代表一个词,如果这个词在文档中出现,那么对应的值非零。其中,使用最广泛的计算该值的方法为tf-idf法。将文档表示成向量后,可以进一步计算文档间相似度或进行查询。

本项目GitHub链接

词袋模型

词袋模型即将文档以用一个装着这些词的袋子来表示,这种表示方式不考虑文法以及词的顺序。在这个表示方法中,文档被表示成一些词以及这些词出现的次数。

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
class VectorSpaceModel:
def __init__(self, path_to_data, path_to_stopwords=None):
"""Initialize the Vector Space Model
Args:
path_to_data (str): path to the data
path_to_stopwords (str, optional): path to the stopwords. Defaults to None.
"""
self.bag_of_words = [] # 词袋
self.token2id = {}
self.idfs = {}
self.tfs = []
self.docs = load_data(path_to_data, path_to_stopwords) # 读取数据
self.num_docs = len(self.docs)
self.similarity = [[0.0 for i in range(self.num_docs)]
for j in range(self.num_docs)] # 初始化相似度矩阵
self.initialize_bow() # 初试化词袋
self.compute_tfs() # 计算tf
self.compute_idfs() # 计算idf
self.compute_tfidfs() # 计算tf-idf

def initialize_bow(self):
"""Generate the bag of words for given data
"""
for doc in self.docs:
counter = defaultdict(int)
for word in doc:
if word not in self.token2id:
self.token2id[word] = len(self.token2id) # 为词进行编号
counter[self.token2id[word]] += 1
self.bag_of_words.append(counter)

其中,load_data函数将数据集中的每一篇文档进行分词,并返回一个list,每个元素表示一个文档,每个文档是一个list,代表分词后的结果。

我们将词以编号表示,因为我们并不关心词的含义。

计算词频(TF)

词频(term frequency)即词在文档中出现的次数。这里我们需要对其进行归一化,以防止其偏向长文档,即计算其在文档中出现的频率。对任意一个词\(t_i\),其在文档\(d_j\)中的词频为: \[tf_{i,j} = \frac{n_{i,j}}{\sum_k n_{k,j}}\] 其中,\(n_{i,j}\)为词\(t_i\)在文档\(d_j\)中出现的次数,分母为所有词在文档出现次数的总和,也即文档的长度。

1
2
3
4
5
6
7
8
def compute_tfs(self):
"""Compute tfs for given data
"""
for bow in self.bag_of_words:
total_fs = sum(bow.values())
self.tfs.append(
{termid: fs / total_fs
for termid, fs in bow.items()})

计算逆文档频率(IDF)

逆文档频率(inverse document frequency,idf)用以度量一个词在所有文档中出现的次数。一般来说一个词在所有文档中出现的次数越多,其含有的信息量就越少。对于任意词\(t_i\) \[idf_i = \log(\frac{N}{n_i})\] 其中,\(N\)为文档总数,\(n_i\)表示含有词\(t_i\)的文档的数量。本项目使用的是最简单版本的IDF计算公式,没有进行平滑处理,因为本项目是计算文档间相似度,不会出现不存在语料库中的词,因此也不会有除以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
def df2idf(docfreq, totaldocs, log_base=2.0):
"""Compute inverse document frequency(idf) for term t.

Args:
docfreq (int): number of documents where the term t appears
totaldocs (int): total number of documents
log_base (float, optional): Defaults to 2.0.

Returns:
int: idf result
"""
return math.log(float(totaldocs) / docfreq) / math.log(log_base)

def compute_idfs(self):
"""Compute idfs for given data
"""
dfs = {}
for bow in self.bag_of_words:
for termid in bow:
dfs[termid] = dfs.get(termid, 0) + 1
self.idfs = {
termid: df2idf(df, self.num_docs)
for termid, df in dfs.items()
}

计算TF-IDF

\[tfidf_{i,j} = tf_{i,j} * idf_{i}\]

1
2
3
4
5
6
7
def compute_tfidfs(self):
"""Compute tf-idf
"""
self.tfidfs = [{
termid: tf * self.idfs.get(termid)
for termid, tf in adoc_tfs.items()
} for adoc_tfs in self.tfs]

计算余弦相似性

对任意两个文档\(a,b\),我们可以对其计算余弦相似性: \[similarity_{a,b} = \frac{\sum_{w \in a\cap b}tfidf_{w,a}*tfidf_{w,b}}{\sqrt{\sum_{w\in a}tfidf_{w,a}^2\sum_{w\in a}tfidf_{w,a}^2}}\]

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
def compute_dot_product(x, y):
"""Compute the sum of the products of the values with the same key.

Args:
x (dict): input dict
y (dict): input dict

Returns:
dict: dot product
"""
intersection = x.keys() & y.keys()
return sum([x[k] * y[k] for k in intersection])


def norm(x):
"""Compute the norm of the values array of dict x.

Args:
x (dict)

Returns:
float: norm result
"""
return math.sqrt(sum([v * v for v in x.values()]))


def compute_similarity_naive(self):
"""Naive implement of the cosine similarity
"""
for docno_1, idfs_1 in enumerate(self.tfidfs):
for docno_2, idfs_2 in enumerate(self.tfidfs):
dot_product = compute_dot_product(idfs_1, idfs_2) # 分子
self.similarity[docno_1][docno_2] = dot_product / (
norm(idfs_1) * norm(idfs_2))

运行代码后发现,Naive实现的计算时长为219s。

优化

由于相似性矩阵每个元素的计算相对独立,我们可以自然地想到使用多线程/多进程地方法去优化。而python的多线程并不能利用到多核性能,因此我们可以利用python多进程来提升速度。每个进程负责计算部分文档与其他所有文档的相似性,最后把这些进程计算出的结果整合到一起,就可以获得相似性矩阵。

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
def compute_similarity_subprocess(self, processes=1, i=0):
"""Subprocess function for computing the cosine similarity

Args:
processes (int, optional): processes number. Defaults to 1.
i (int, optional): process id. Defaults to 0.
"""
similarity = []
length = math.ceil(self.num_docs / processes)
for idfs_1 in self.tfidfs[length * i:length * (i + 1)]: # 该进程负责运算的范围
similarity.append([])
for idfs_2 in self.tfidfs:
dot_product = compute_dot_product(idfs_1, idfs_2) # 分子
similarity[-1].append(dot_product /
(norm(idfs_1) * norm(idfs_2)))
return similarity

def compute_similarity_multiprocess(self, processes):
"""Multiprocess implement of the cosine similarity

Args:
processes (int): processes number.
"""
res = [] # 记录每个进程运算的结果
with Pool(processes) as pool:
for i in range(processes):
res.append(
pool.apply_async(self.compute_similarity_subprocess,
(processes, i)))
pool.close() # 关闭进程池
pool.join()
similarity = []
for i in range(processes):
similarity += res[i].get() # 整合进程运算结果
self.similarity = similarity

在我的实验环境中,cpu为四核,因此processes值设为4,表示开4个进程。采用多进程优化后,相似度矩阵计算的速度提升到了90s,加速比为2.43。

总结

  • 实现了向量空间模型,用以计算文档间的两两相似度。
  • TF-IDF有很多版本的计算公式,要根据项目情况选择合适的公式。
  • 使用多进程的方法进行优化,提升计算速度。在使用多进程/多线程的方法时,要考虑任务是否有比较好的可并行性,并要根据处理器核心的数量选择进程数/线程数。