在写下这篇博客之前,我以为我的毕业论文进展一帆风顺。之前进行了Word2Vec训练、LDA主题建模、关键词提取、K-Means聚类实践,我充满了信心。可是这一次,我倒在了句子相似性计算,整整两个星期,日思夜想,茶饭不香。
我的计划是,在训练好词嵌入后,接下来进行的是句子嵌入,进而可以计算距离,作为K-Means的距离表达式。我的想法很美好,不太想采用一般的方法,将词向量平均,因为这样会使沿语义上无意义的方向具有巨大的分量。经过查阅资料,我看中了一篇文章:A Simple but Tough-to-Beat Baseline for Sentence Embeddings.pdf。看到这个标题,我心中窃喜,简单又无敌,这不是我梦寐以求的吗?
但是,按照作者的方法,我的效果出奇的差,每一次运行结果都惨不忍睹。找了很多原因,但是结果还是不敢恭维。有时候我都怀疑作者的论文效果,但是理性告诉我,一定是我哪里出了问题。就像代码运行不出来,你不能怪电脑编译错误呀!我把它记录下来,一方面用于理清思路,可以经常回过头看看;另一方面,也希望各路大神不吝赐教,救救孩子。
导航
一、文献思路
我把文献的思路写在了另一篇博文:SIF 一种简单却难以打败的句子嵌入方法。
简单来说,SIF只计算句子中单词向量的加权平均值,然后删除平均向量在其第一个主成分上的投影(“公共成分去除”)。我们称它为平滑反频率 ( )。
(
) 单词
的权重表示为a / (a + p(w))
,a
是一个超参数,p(w)
是词频。我们发现,p(w)
越大,单词权重越小。是不是感觉与
原理有些相似,字词的重要性随着它在语料库中出现的频率成反比下降。
( ) 关于第一主成分,由于使用单词向量的平均的方法在沿语义上无意义的方向具有巨大的分量。因此我们删去这些公共成分,留下关键的信息。按照作者的思路,公共主成分主要是与语法相关的一些高频词,例如 “ ” “ ” 等。
作者将代码开源到了Github上,请点击查看。
二、我的做法
看了一篇类似的代码,我又飘了。我想从四个方面来进行句子向量的表示(尴尬),它们分别是:
- 句子词向量的简单平均:常规方法,对句子中每一个词的词向量进行加总,最后除以句子中词的个数。
- 句子词向量的简单平均后去主成分:参考 SIF 的去主成分,在得到句子平均向量之后,再去掉一些公共的无意义成分,例如语法词、连接词等等。
- 未去除主成分前的句子 SIF加权向量:对句子进行加权平均。参考 SIF 的权重,每一个词的权重为
a / (a + p(w))
,p(w)
是语料库中单词的词频。可见,词频越大的词权重越小。本来还想试一下 ,但是实在是心有余而力不足。 - 去除主成分后的句子 SIF 向量:文献中的做法,先加权平均,然后再去除公共部分。这种应该是效果最好的。
三、先前准备
(一) 计算词频
代码很简单,创建一个空集合,对于每一行的每一个词,获取字典中该单词的频次并+1
,若没有,则设为0
。最后将之写到一个文档中,按照单词 词频
的格式保存到每一行。
with open("model/sougou_hotel_cut.txt", encoding="utf-8")as f:
lines = f.readlines()
counts = {} # 定义空集合
for line in lines:
words = line.strip().split()
for word in words:
counts[word] = counts.get(word, 0) + 1 # 获取字典中该单词的频次并加一,若没有,则设为0,
items = list(counts.items()) # 转换为列表
items.sort(key=lambda x: x[1], reverse=True) # 按照第二列(序号为1)排序
with open("frequency/sougou_hotel_cut_fre.txt", 'wb')as wf:
for i in range(len(items)):
word, count = items[i]
wf.write("{0} {1}".format(word, count).encode('utf-8')) # 进行格式转换,词语及频次用空格间隔
wf.write('\n'.encode("utf-8"))
我用的是搜狗新闻数据集,一共 146W+ 文本,效果如下:
|
|
(二) 计算并去除主成分
这一步是计算并且去除句子中所有的主成分。等待后文调用。
# 计算主成分,npc为需要计算的主成分的个数
def compute_pc(X, npc):
svd = TruncatedSVD(n_components=npc, n_iter=5, random_state=0)
svd.fit(X)
return svd.components_
# 去除主成分
def remove_pc(X, npc):
pc = compute_pc(X, npc)
if npc == 1:
XX = X - X.dot(pc.transpose()) * pc
else:
XX = X - X.dot(pc.transpose()).dot(pc)
return XX
(三) 数据集展示
我用的是酒店评论数据集,对它进行了分词、去标点、数字、表情、处理。注意:我并没有进行去停用词处理!这在后面会讲到为什么。
四、正式开始
(一) 类的参数
我把它封装到了一个类中,如下:
def __init__(self, a, dim):
self.a = a # the parameter in the SIF weighting scheme, usually in the range [3e-5, 3e-3]
self.dim = dim # 词向量的维数
self.model_path = 'model/sougou_hotel_cut.model' # 词向量模型的路径
self.weight_file = 'frequency/sougou_hotel_cut_fre.txt' # 词频的路径,每一行由词语及其频率组成 hotel_comments_cut_fre
self.sent_file = 'sentest2.txt' # 句子的路径
self.sent = [] # 句子的列表
self.word2weight = {} # 保存词以及频率的字典
a
是计算权重所需的,dim
是训练的词向量维数。
(二) 加载模型
返回一个训练好的词向量模型:
# 加载Word2Vec模型
def load_Model(self):
self.model = Word2Vec.load(self.model_path)
print("模型加载完成")
return self.model
(三) 读取句子
读取句子组,将之保存到一个列表sent
中。
# 读取句子组,将之保存到一个列表中
def read_sentence(self):
with open(self.sent_file, encoding='utf-8') as sf:
lines = sf.readlines()
for line in lines:
if line:
line = line.strip()
self.sent.append(line.split())
M = len(self.sent)
print('文本数目:%d 个' % M)
print(self.sent)
print('***************' * 3)
return self.sent
(四) 更新权重
首先读取词频文档,保存在word2weight
这个字典中,同时N
为所有词的词频之和。
接下来更新每一个词的SIF权重,在word2weight
这个字典中把计算后的权重值覆盖原来的词频,并且保存在一个文档中。运用公式a / (a + p(w))
:
# 读取词频文档,并保存到字典,并且更新字典每一个词的SIF权重
def save_dict(self):
# 读取词频文档,并保存到字典
with open(self.weight_file, encoding='utf-8') as wf:
lines = wf.readlines()
N = 0 # N为所有词的词频和
for line in lines:
line = line.strip()
if len(line) > 0:
line = line.split()
if len(line) == 2:
self.word2weight[line[0]] = float(line[1]) # line[0]为词,line[1]为词频
N += float(line[1])
else:
print(line)
# 更新字典每一个词的SIF权重
with open('frequency/SIF_weight_hotel_comments_cut.txt', 'wb') as f:
for key, value in self.word2weight.items():
self.word2weight[key] = self.a / (self.a + value / N)
f.write("{0} {1}".format(key, str(self.word2weight.get(key))).encode("utf-8"))
f.write('\n'.encode("utf-8"))
# print(self.word2weight)
# print('***************' * 3)
print("权重更新完成")
return self.word2weight
最后结果如下图所示,我们发现,词频越大的词,权重越小:
(五) 句子词向量的简单平均
接下来首先用的是简单平均。构建一个零矩阵,一共有 * 维, 是数据集的行数, 是词向量的维度。每一行用来保存一个句子的向量。
对于数据集里的每一行句子,如不为空,找到它里面的每一个词的词向量,进行加总求和。最后再除以该句子的单词个数进行平均。
注:如果词向量没有该词,可能是词向量训练时min_count
过滤掉了,详情请看『词向量』用Word2Vec训练中文词向量(一)。因此把该低频次的向量设为0。
def ave_no_rem(self):
self.ave = np.zeros((len(self.sent), self.dim)) # 构建一个零矩阵,每一行用来保存一个句子的向量
for count, sentence in enumerate(self.sent):
if sentence: # 避免这一行为空行
for word in sentence:
try:
w = self.model.wv.__getitem__(word) # 找到w2c模型中对应单词的词向量
except:
w = np.zeros(self.dim) # 如果没有找到该单词的向量,则将它的向量全归零
#print("出错了!问题在第%d个句子,单词为%s" % (count, word))
#print('************************************************'*10)
self.ave[count] = self.ave[count] + w # 对每一个词的权重向量加总
self.ave[count] = self.ave[count] / len(sentence) # 进行平均
return self.ave
(六) 句子词向量的简单平均后去除主成分
方法同上。只不过在计算完平均向量后,对于整个
*
维的矩阵ave_rem
,去除它的第一主成分。npc
是主成分的个数。
def ave_with_rem(self):
self.ave_rem = np.zeros((len(self.sent), self.dim)) # 构建一个零矩阵,每一行用来保存一个句子的向量
for count, sentence in enumerate(self.sent):
if sentence:
for word in sentence:
try:
w = self.model.wv.__getitem__(word) # 找到w2c模型中对应单词的词向量
except:
w = np.zeros(self.dim) # 如果没有找到该单词的向量,则将它的向量全归零
#print("出错了!问题在第%d个句子,单词为%s" % (count, word))
#print('************************************************'*10)
self.ave_rem[count] = self.ave_rem[count] + w # 对每一个词的权重向量加总
self.ave_rem[count] = self.ave_rem[count] / len(sentence) # 进行平均
# 去除主成分
npc = 1 # number of principal component
self.ave_rem = remove_pc(self.ave_rem, npc)
# print(self.ave_rem)
# print('***************' * 3)
return self.ave_rem
(七) 未去除主成分前的句子SIF加权向量
对于一句话中的每一个单词,找到它的权重w
,在word2weight
字典中使用get()
方法找到这个词的权重值word2weight[word]
(找不到则返回 None),向量w
与权重word2weight[word]
相乘加总,最后除以句子中单词的个数进行平均。
def sif_no_rem(self): # 传入的sent是一个句子组,其中每一个句子已经做了分词处理
self.em = np.zeros((len(self.sent), self.dim)) # 构建一个零矩阵,每一行用来保存一个句子的向量
for count, sentence in enumerate(self.sent):
if sentence:
for word in sentence:
try:
w = self.model.wv.__getitem__(word) # 找到w2c模型中对应单词的词向量
except:
w = np.zeros(self.dim) # 如果没有找到该单词的向量,则将它的向量全归零
#print("出错了!问题在第%d个句子,单词为%s" % (count, word))
if self.word2weight.get(word, None):
self.em[count] = self.em[count] + np.dot(self.word2weight[word], w) # 对每一个词的权重向量加总
self.em[count] = self.em[count] / len(sentence) # 进行平均
# print(self.em)
# print('***************' * 3)
return self.em
(八) 去除主成分后的句子SIF向量
方法同上,只不过在最后去除了第一主成分。
def sif_with_rem(self):
self.em_remove = np.zeros((len(self.sent), self.dim))
for count, sentence in enumerate(self.sent):
if sentence:
for word in sentence:
try:
w = self.model.wv.__getitem__(word) # 找到它的词向量
except:
w = np.zeros(self.dim) # 如果没有找到该单词的向量,则将它的向量全归零
#print("出错了!问题在第%d个句子,单词为%s" % (count, word))
if self.word2weight.get(word, None): # 找到它的词的权重
self.em_remove[count] = self.em_remove[count] + np.dot(self.word2weight[word], w) # 对每一个词的权重向量加总
self.em_remove[count] = self.em_remove[count] / len(sentence)
# 去除主成分
npc = 1 # number of principal component
self.em_remove = remove_pc(self.em_remove, npc)
# print(self.em_remove)
# print('***************' * 3)
return self.em_remove
(九) 运行主代码
a
取 0.001,作者推荐的在[3e-5, 3e-3]即可。词向量维度dim
为100。用cosine_similarity
来计算两个向量的余弦值。关于reshape(1, -1)
,它是把位置大小的矩阵转到一行,详情可见 numpy reshape的用法。
ave
、ave_rem
、em
、em_remove
分别是四种方法存放的句子矩阵。ave[1]
与ave[2]
计算的是第一句与第三局的相似度,以此类推。
if __name__ == '__main__':
a = 0.001 # usually in the range [3e-5, 3e-3]
dim = 100 # 词向量的维数
css = CalSentSim(a, dim)
css.load_Model()
css.read_sentence()
css.save_dict()
print("句子词向量平均相似度:")
ave = css.ave_no_rem()
sim_ave = cosine_similarity(ave[1].reshape(1, -1), ave[2].reshape(1, -1))
print(sim_ave)
print('***************' * 3)
print("句子词向量平均去主成分相似度:")
ave_rem = css.ave_with_rem()
sim_ave_rem = cosine_similarity(ave_rem[1].reshape(1, -1), ave_rem[2].reshape(1, -1))
print(sim_ave_rem)
print('***************' * 3)
print("SIF加权平均相似度:")
em = css.sif_no_rem()
sim_no_rem = cosine_similarity(em[1].reshape(1, -1), em[2].reshape(1, -1))
print(sim_no_rem)
print('***************' * 3)
print("SIF加权平均去主成分相似度:")
em_remove = css.sif_with_rem()
sim_with_rem = cosine_similarity(em_remove[1].reshape(1, -1), em_remove[2].reshape(1, -1))
print(sim_with_rem)
print('***************' * 3)
五、效果展示
惨不忍睹警告!!!
首先计算下面两句话的相似程度,应该是不相似的:
地理位置 很 好 对面 就是 鼓浪屿
早餐 很 丰富 前台 热情
这两句话应该是相似度很高:
地理位置 很 好 对面 就是 鼓浪屿
地理位置 优越
六、可能原因分析
我在睡梦中,想到可能是以下几种情况:
-
文本少,词向量模型出现过拟合,词与词之间相似度高
-
评论处理过度(去停用词),导致 SIF 方法矫枉过正(一些名词性质的词成为了语法性质的词)
-
数据集不太好,评论本身较短,去除主成分后句子距离变远
我们逐条来看。
首先第一条,的确是可能的,我当时训练的词向量模型是直接用的酒店数据集,只有一万条,文本领域比较集中,出现了过拟合,词与词之间的相似度挺高。这样句子与句子之间没有区分性。
|
|
|
|
对于第二条,是最有可能的。我当时处理时,进行了去停用词处理。这会导致一些名词性的词,它的词频变得很高,例如“酒店”、“服务”、“不错”等等,因此它们的权重就变得很低。另一方面,在去除主成分的时候,也把这些词当成了高频的语法词进行了去除,导致剩余的低频词相似度很低。
|
|
|
|
第三条,有点碰瓷的嫌疑。不过还是有可能,评论句子比较短,去除主成分后可能只剩单个词,这时候计算相似度的确会较低。亦或是一个短句和一个长句进行计算的时候。但是这绝对不是关键理由。
针对以上的思路,我重新训练了词向量模型,是大文本(一百多万条文本再加上酒店评论);也换语料库,没有进行去停用词处理,重新计算了词频,使得一些无意义词词频很大(详见上图)。但是最终的结果还是不理想,我走投无路,只能将它记录在此,还望各位指教!
救救孩子!
七、完整代码
import numpy as np
from gensim.models import Word2Vec
from sklearn.decomposition import TruncatedSVD
from sklearn.metrics.pairwise import cosine_similarity
# 计算主成分,npc为需要计算的主成分的个数
def compute_pc(X, npc):
svd = TruncatedSVD(n_components=npc, n_iter=5, random_state=0)
svd.fit(X)
return svd.components_
# 去除主成分
def remove_pc(X, npc):
pc = compute_pc(X, npc)
if npc == 1:
XX = X - X.dot(pc.transpose()) * pc
else:
XX = X - X.dot(pc.transpose()).dot(pc)
return XX
class CalSentSim(object):
def __init__(self, a, dim):
self.a = a # the parameter in the SIF weighting scheme, usually in the range [3e-5, 3e-3]
self.dim = dim # 词向量的维数
self.model_path = 'hotel_comments/hotel_comments_cut.model' # 词向量模型的路径 model/sougou_hotel_cut.model
self.weight_file = 'frequency/sougou_hotel_cut_fre.txt' # 词频的路径,每一行由词语及其频率组成 hotel_comments_cut_fre
self.sent_file = 'sentest2.txt' # 句子的路径
self.sent = [] # 句子的列表
self.word2weight = {} # 保存词以及频率的字典
# 加载Word2Vec模型
def load_Model(self):
self.model = Word2Vec.load(self.model_path)
print("模型加载完成")
#print(self.model.wv.__getitem__('富有'))
return self.model
# 读取句子组,将之保存到一个列表中
def read_sentence(self):
with open(self.sent_file, encoding='utf-8') as sf:
lines = sf.readlines()
for line in lines:
if line:
line = line.strip()
self.sent.append(line.split())
M = len(self.sent)
print('文本数目:%d 个' % M)
print(self.sent)
print('***************' * 3)
return self.sent
# 读取词频文档,并保存到字典,并且更新字典每一个词的SIF权重
def save_dict(self):
# 读取词频文档,并保存到字典
with open(self.weight_file, encoding='utf-8') as wf:
lines = wf.readlines()
N = 0 # N为所有词的词频和
for line in lines:
line = line.strip()
if len(line) > 0:
line = line.split()
if len(line) == 2:
self.word2weight[line[0]] = float(line[1]) # line[0]为词,line[1]为词频
N += float(line[1])
else:
print(line)
# 更新字典每一个词的SIF权重
with open('frequency/SIF_weight_hotel_comments_cut.txt', 'wb') as f:
for key, value in self.word2weight.items():
self.word2weight[key] = self.a / (self.a + value / N)
f.write("{0} {1}".format(key, str(self.word2weight.get(key))).encode("utf-8"))
f.write('\n'.encode("utf-8"))
# print(self.word2weight)
# print('***************' * 3)
print("权重更新完成")
return self.word2weight
# 句子词向量的简单平均
def ave_no_rem(self):
self.ave = np.zeros((len(self.sent), self.dim)) # 构建一个零矩阵,每一行用来保存一个句子的向量
for count, sentence in enumerate(self.sent):
if sentence:
for word in sentence:
try:
w = self.model.wv.__getitem__(word) # 找到w2c模型中对应单词的词向量
except:
w = np.zeros(self.dim) # 如果没有找到该单词的向量,则将它的向量全归零
#print("出错了!问题在第%d个句子,单词为%s" % (count, word))
#print('************************************************'*10)
self.ave[count] = self.ave[count] + w # 对每一个词的权重向量加总
self.ave[count] = self.ave[count] / len(sentence) # 进行平均
return self.ave
# 句子词向量的简单平均后去除主成分
def ave_with_rem(self):
self.ave_rem = np.zeros((len(self.sent), self.dim)) # 构建一个零矩阵,每一行用来保存一个句子的向量
for count, sentence in enumerate(self.sent):
if sentence:
for word in sentence:
try:
w = self.model.wv.__getitem__(word) # 找到w2c模型中对应单词的词向量
except:
w = np.zeros(self.dim) # 如果没有找到该单词的向量,则将它的向量全归零
#print("出错了!问题在第%d个句子,单词为%s" % (count, word))
#print('************************************************'*10)
self.ave_rem[count] = self.ave_rem[count] + w # 对每一个词的权重向量加总
self.ave_rem[count] = self.ave_rem[count] / len(sentence) # 进行平均
# 去除主成分
npc = 1 # number of principal component
self.ave_rem = remove_pc(self.ave_rem, npc)
# print(self.ave_rem)
# print('***************' * 3)
return self.ave_rem
# 未去除主成分前的句子SIF向量
def sif_no_rem(self): # 传入的sent是一个句子组,其中每一个句子已经做了分词处理
self.em = np.zeros((len(self.sent), self.dim)) # 构建一个零矩阵,每一行用来保存一个句子的向量
for count, sentence in enumerate(self.sent):
if sentence:
for word in sentence:
try:
w = self.model.wv.__getitem__(word) # 找到w2c模型中对应单词的词向量
except:
w = np.zeros(self.dim) # 如果没有找到该单词的向量,则将它的向量全归零
#print("出错了!问题在第%d个句子,单词为%s" % (count, word))
if self.word2weight.get(word, None):
self.em[count] = self.em[count] + np.dot(self.word2weight[word], w) # 对每一个词的权重向量加总
self.em[count] = self.em[count] / len(sentence) # 进行平均
# print(self.em)
# print('***************' * 3)
return self.em
# 去除主成分后的句子SIF向量
def sif_with_rem(self):
self.em_remove = np.zeros((len(self.sent), self.dim))
for count, sentence in enumerate(self.sent):
if sentence:
for word in sentence:
try:
w = self.model.wv.__getitem__(word) # 找到它的词向量
except:
w = np.zeros(self.dim) # 如果没有找到该单词的向量,则将它的向量全归零
#print("出错了!问题在第%d个句子,单词为%s" % (count, word))
if self.word2weight.get(word, None): # 找到它的词的权重
self.em_remove[count] = self.em_remove[count] + np.dot(self.word2weight[word], w) # 对每一个词的权重向量加总
self.em_remove[count] = self.em_remove[count] / len(sentence)
# 去除主成分
npc = 1 # number of principal component
self.em_remove = remove_pc(self.em_remove, npc)
# print(self.em_remove)
# print('***************' * 3)
return self.em_remove
if __name__ == '__main__':
a = 0.001 # usually in the range [3e-5, 3e-3]
dim = 100 # 词向量的维数
css = CalSentSim(a, dim)
css.load_Model()
css.read_sentence()
css.save_dict()
print("句子词向量平均相似度:")
ave = css.ave_no_rem()
sim_ave = cosine_similarity(ave[1].reshape(1, -1), ave[2].reshape(1, -1))
print(sim_ave)
print('***************' * 3)
print("句子词向量平均去主成分相似度:")
ave_rem = css.ave_with_rem()
sim_ave_rem = cosine_similarity(ave_rem[1].reshape(1, -1), ave_rem[2].reshape(1, -1))
print(sim_ave_rem)
print('***************' * 3)
print("SIF加权平均相似度:")
em = css.sif_no_rem()
sim_no_rem = cosine_similarity(em[1].reshape(1, -1), em[2].reshape(1, -1))
print(sim_no_rem)
print('***************' * 3)
print("SIF加权平均去主成分相似度:")
em_remove = css.sif_with_rem()
sim_with_rem = cosine_similarity(em_remove[1].reshape(1, -1), em_remove[2].reshape(1, -1))
print(sim_with_rem)
print('***************' * 3)