- 本篇笔记对应的视频链接为:
- 3-基于计数的方法表示单词-将文字转换成编号的预处理工作_哔哩哔哩_bilibili;
- 4-基于计数的方法表示单词-使用共现矩阵进行单词的分布式表示_哔哩哔哩_bilibili;
- 5-基于计数的方法表示单词-单词之间相似度计算_哔哩哔哩_bilibili;
- 6-基于计数的方法表示单词-计算点互信息_哔哩哔哩_bilibili;
- 7-基于计数的方法表示单词-降维_哔哩哔哩_bilibili;
- 8-基于计数的方法表示单词-在PTB数据集上对单词进行评价_哔哩哔哩_bilibili;
0比较
同义词词典是人为构建了一个词典,然后我们基于这个词典进行一些查询,相似度的计算等操作
这里基于计数的方法开始,纯粹是使用语料库,即一些自然语言,无需手动构建词典, 采用统计的方法,对语料库中的单词进行相关统计。
1语料库的预处理
自然语言的分布式表示之前说到它是要将单词进行向量化,在实际进行向量化之前,需要对自然语言的语料库进行一些处理,主要包括:
- 分词
- 构建单词与单词索引的映射
这里就和书上保持一致, 仅仅使用一条句子作为语料库:
text="you say goodbye and I say hello."
1.1分词
- 单词的大小写不影响单词本身的含义,因此这里首先将语料库中的单词全部小写化。
- 在真实的语料库中肯定不只有一个句子,因此每一个句子的句号不能省略。为了将句号也进行分割,这里首先在句号和最后一个单词中间插入一个空格,以保证句号能够被分词。
- 然后使用非常直接的方式,即直接以空格进行划分。将一个句子拆分成一个一个单词。
text = text.lower()
print('text.lower():', text)
text = text.replace('.', ' .')
print('text.replace.:', text)
words = text.split() # 直接按照空格进行分隔
print('words:', words)
1.2构建单词及其编号之间的映射
-
用字典来完成
-
# 构建单词及其编号之间的映射 word_to_id = {} # word是关键字 id_to_word = {} # id是关键字 for word in words: if word not in word_to_id.keys(): new_id = len(word_to_id.keys()) word_to_id[word] = new_id id_to_word[new_id] = word print('word_to_id:', word_to_id) print('id_to_word:', id_to_word)
-
封装成预处理函数
2分布式表示的概念
- 将单词转换成一个向量,这个向量是固定长度的,这个向量能够一定程度上反映单词的含义;
- 一个例子就是颜色的例子,使用RGB三种颜色来表示各种各样的颜色组合,因此在向量化之后,只需要一个三维的向量,通过改变每一个维度的元素值来反映不同的颜色
3分布式假设
-
一个单词的含义,由他周围的单词形成;即 如果一个单词是孤零零的,他就不存在有含义这么一说
-
上下文: 表示单词周围的一些单词
-
上下文的大小称为窗口大小,如果上下文的大小为1,说明,我们只需要去关注这个单词前面和后面的一个单词,以此类推【本书中是左右两边相同数量的单词作为上下文】
-
例如如果只看单词之后的一个单词,I drink milk和we drink water,milk和water都是日常喝的东西,那么如果将drink替换成一个我们不认识的单词,那么我们也能够大致猜出来这个单词是什么意思; 因此,我们进行单词的分布式表示也是想要计算机理解这样一个过程
-
下图是书上关于上下文的例子
-
4使用共现矩阵对单词进行分布式表示
基于计数的方法:根据指定的上下文的大小,直接对当前单词周围出现了多少次什么样子的单词进行计数;将结果存放到共现矩阵中
-
以上面的
text="you say goodbye and I say hello."
为例构建共现矩阵 -
总共包含7个单词,上下文大小设置为1, 计算每个单词的上下文所包含的单词的频数
-
例如you和say的上下文单词的频数可以用一个向量表示:
-
共现矩阵的代码实现
-
def create_co_matrix(corpus, vocab_size, window_size=1): corpus_size = len(corpus) co_matrix = np.zeros((vocab_size, vocab_size), dtype=np.int32) for idx, word_id in enumerate(corpus): # 获取了当前单词 for i in range(1, window_size + 1): left_idx = idx - i right_idx = idx + i if left_idx >= 0: left_word_id = corpus[left_idx] co_matrix[word_id, left_word_id] += 1 if right_idx < corpus_size: right_word_id = corpus[right_idx] co_matrix[word_id, right_word_id] += 1 return co_matrix
5基于共现矩阵计算单词相似度
-
每一个单词现在都可以用共现矩阵中的一个行向量来表示
-
计算向量之间的相似度来表示单词之间的相似度现在成为可能;使用常见的余弦相似度
-
可能存在某一个单词对应的向量全部为0,此时模长为0,无法计算除法,因此还需要在模长上加一个很小的数(如1e-8);
-
向量的模长即为向量的2范数, 当向量的2范数不为零时,这个很小的数会被范数给吸收掉,简单理解就是这个微小的数可以忽略不计
-
涉及到的函数
-
np.sum() np.dot() np.sqrt()
-
-
实际在计算相似度时,是先对每个向量进行规范化(即除以它的2范数);无论是按照公式来计算还是使用这个方式来算,结果都是一样的
-
向量的规范化是一种将向量缩放到单位长度的过程;因此将上面相似度公式的x和y拆开看,就是先对x和y进行规范化(如下图所示,除以模长之后的向量变成了单位长度),然后再执行向量点积的操作
-
def cos_similarity(x:np.ndarray, y:np.ndarray, eps=1e-8):
nx = x / (np.sqrt(np.sum(x ** 2)) + eps) # 规范化之后的x
ny = y / (np.sqrt(np.sum(y ** 2)) + eps)
return np.dot(nx, ny)
def cos_similarity2(x, y, eps=1e-8):
ret = np.dot(x, y)
return ret / ((np.sqrt(np.sum(x ** 2)) + eps) * (np.sqrt(np.sum(y ** 2)) + eps))
6给单词相似度排序
给定一个单词,计算其他单词与这个单词的相似度,然后输出与这个单词最相似的前几个单词及其相似度
- 实现思路
- 取出查询词的单词向量
- 分别求得查询词的单词向量和其他所有单词向量的余弦相似度
- 基于余弦相似度的结果,按降序显示它们的值
- 取出查询词的单词向量时,需要先判断这个单词是否存在
- 排序时书上用到了
argsort()
函数:对numpy数组升序排序,且返回的是升序排序的索引,不是数组的元素argsort()
函数不改变数组本身的值,只是返回一个排序后的下标的数组
7改进之点互信息
7.1基于计数方法的问题分析
- 计数方法基于共现矩阵,每个元素表示一个单词出现在了另一个单词的上下文中
- 对于冠词the,他会经常出现在car前面;而drive car是一个比较固定搭配的短语,但是drive不一定要drive car,所以drive和car共现的次数会比the、car共现的次数低,就造成the和car对应的单词向量更相似,即模型会判断出the和car的相关性更强,这显然是不太符合直觉的
7.2点互信息
-
点互信息:pointwise mutual information,简称PMI,公式如下:
- 其中, x x x和 y y y表示随机变量, P ( x ) P(x) P(x)和 P ( y ) P(y) P(y)表示 x x x和 y y y单独出现的概率; P ( x , y ) P(x,y) P(x,y)表示 x x x和 y y y共现的概率
- PMI值越大,说明越相关
-
使用共现矩阵来重写点互信息,公式如下图所示:
- 其中,共现矩阵表示为 C C C,将单词 x x x和 y y y的共现次数表示为 C ( x , y ) C(x,y) C(x,y),将单词 x x x和 y y y的出现次数分别表示为 C ( x ) C(x) C(x)、 C ( y ) C(y) C(y),将语料库的单词数量记为 N N N;
- 关于
C
(
x
)
C(x)
C(x)、
C
(
y
)
C(y)
C(y)的计算方法
- C ( x ) = ∑ i C ( i , x ) \boldsymbol{C}(x)=\sum_i\boldsymbol{C}(i,x) C(x)=∑iC(i,x)、 C ( y ) = ∑ i C ( i , y ) \boldsymbol{C}(y)=\sum_i\boldsymbol{C}(i,y) C(y)=∑iC(i,y);
- 即共现矩阵对应的单词所在列的次数和(不是行是因为行表示某个单词上下文中出现的其他单词,不能用来表示这个单词出现的次数,可结合共现矩阵的示例来看)
- 关于单词数量记为
N
N
N:统计时应该是不去重的,毕竟之后要统计
C
(
x
)
C(x)
C(x);
- 本书中 N = ∑ i ∑ j C ( i , j ) N=\sum_i\sum_j\boldsymbol{C}(i,j) N=∑i∑jC(i,j);即共现矩阵中所有元素的和;
- 因为共现矩阵对应的单词所在列的次数和表示这个单词出现的次数,因此把所有的列的和加在一起就得到总的单词数量
- 以上
N
N
N、
C
(
x
)
C(x)
C(x)、
C
(
y
)
C(y)
C(y)的计算方法都是近似;
- 因为按列来计算单词单独出现的次数,如果c这个单词夹在a和b之间,上下文大小为1,则计算a和b的单词向量时会两次检测到c,则按列计算c出现的次数时会认为是出现了2次,而实际上在句子中c只出现了1次
- 本书这么做,是为了仅从共现矩阵求 PPMI 矩阵
-
以点互信息为依据,计算下面的示例:
- 虽然the和car共现的次数大,但是the单独出现的次数也多,这样分母同样会变大
- 这样就不会因为the和car共现次数多而导致结果过大,因为分母牵制了结果的大小
-
正的点互信息(PPMI)
-
原因:当两个单词的共现次数为 0 时 log 2 0 = − ∞ \log_20=-\infty log20=−∞;
-
因此,当PMI值为负数时,将其视为0:这样一来,避免了负无穷的情况,同时,就可以将单词间 的相关性表示为大于等于 0 的实数,也比较符合直觉;
P P M I ( x , y ) = max ( 0 , P M I ( x , y ) ) \mathrm{PPMI}(x,y)=\max(0,\mathrm{PMI}(x,y)) \nonumber PPMI(x,y)=max(0,PMI(x,y))
-
-
代码实现
-
np.zeros_like np.sum(co_matrix) np.sum(co_matrix, axis=0) np.set_printoptions(precision=3) # 设置np数组打印时的有效位数
-
8改进之降维
8.1问题分析
-
上述ppmi的改进方法依然是在共现矩阵的基础上进行计算的,能够缓解高频冠词这些看似贡献频率高实则没有意义的问题的出现
-
但是既然是在共现矩阵的基础上进一步计算的,那么最终的结果的维度仍然和共现矩阵一样,那么很多单词之间并没有共现,那么在ppmi矩阵中,依然会出现很多0元素,即太稀疏了;如下图所示:
-
那么随着语料库的增加,这个矩阵的维度会非常大,非常不利于计算机的处理;
-
而这矩阵里面很多0元素,表明很多的向量(比如行向量)没有多大意义(因为一行里面含有的非0元素太少了,而且就算有,可能这个非0元素又很小);另外,这样的向量也容易受到噪声影响,稳健性差,具体可以表现为:
-
例如,受到噪声的影响,原先 C ( x , y ) = 0 C(x,y)=0 C(x,y)=0的现在大于0了,那么分子 C ( x , y ) ∗ N C(x,y)*N C(x,y)∗N就不再是0,而且还突然变得挺大的,根据对数函数曲线,对数值会有一定的增长;并且,看上图中ppmi矩阵中的值,如果因为噪声导致原先元素值从0变突然变成2.807355,这个变动还是比较大的;
-
-
因此,面对如此多的0元素,就有了接下来的降维
8.2基于奇异值分解对向量进行降维
8.2.1降维的理解
-
从字面意思来说,降维就是减少向量维度,但又不是直接减少一些列(因为对于某一列,他不是全为0,他对有的单词有作用,对有些单词没有作用)
-
降维的准则是保留重要信息的基础上使维度减少
-
以书上二维的数据为例,这些数据可视化之后其实都是分布在一个新的轴上的,因此将这些数据投影到一个新的轴上之后,维度从2变为1,但是仍然可以用一个轴来表示这些数据的分布差异
-
多维数据也是基于这样的思想,只是随着维度的增加,我们无法像二维三维这样直观的进行展现。
8.2.2奇异值分解的简单理解
- 详细的理解需要深入学习奇异值分解;这里只是在书中解释的基础上稍微进一步解释了一下
-
对于矩阵A,经过奇异值分解,得到
- U可以作为词的密集表示;U是正交矩阵,每个列向量都是正交的,即互不相干,可以用作基向量,来表示其他的向量;可以将U理解为词在奇异值分解后的新空间中的坐标,即U的每一行表示一个单词在每个列(轴)上的坐标;
- 对角阵的对角元素(奇异值)可以作为不同成分(或者说不同轴、不同列向量)的权重;
- 一般用U乘上对角阵来表示词的密集向量表示;而且是选择前k个较大的奇异值以及U中对应的前k个列向量,这样就起到了维度降低的效果;本书上应该是为了简单分析而直接用U来代表词;
-
代码实现就很简单了,直接调用对应的奇异值分解函数,然后直接选择U作为词的表示;
-
# 计算svd U,S,V=np.linalg.svd(ppmi) # 在对应坐标位置上标注单词文本 plt.annotate(word,(U[word_id,0],U[word_id,1])) # 画散点图 plt.scatter(U[:,0],U[:,1],alpha=0.5) # alpha表示透明度
-
计算完之后进行可视化,如下图所示;可以看到密集向量表示减少了维度,但同时还是可以反映单词分布的差异:
goodbye
和hello
位置较近,you
和i
位置更近;
-
9在PTB数据集中进行上述过程
代码详见:
pycharmProject/nlp/3-使用PTB数据集计算单词的密集表示.py
;
9.1 PTB数据集准备
- 主要包括数据集的下载、单词ID列表的构建、词汇表(单词与ID之间的映射字典)的构建
- 这里只对训练数据部分进行上述几个内容的构建
-
一些值得记录的知识
dataset_dir = os.path.dirname(os.path.abspath(__file__)) # 获取当前文件所在目录 if os.path.exists(file_path): # 判断文件是否存在 # 从指定的 URL 下载文件并保存到本地路径;如果文件不存在则创建; # 注意这里会自动创建文件,但是文件之前的目录必须都是存在的 urllib.request.urlretrieve(url_base + file_name, file_path) # open方法直接读取文本文件全部内容 words = open(file_path).read().replace('\n', '<eos>').strip().split() # words就是单词列表 # numpy存储和读取数组 corpus = np.array([word_to_id[w] for w in words]) np.save(save_path, corpus) corpus = np.load(save_path) # pickle包进行文件的写和读 with open(vocab_path, 'rb') as f: # 二进制读模式 word_to_id, id_to_word = pickle.load(f) # 以二进制写的方式写入到本地文件 with open(vocab_path, 'wb') as f: # 将数据序列化并保存到文件中 pickle.dump((word_to_id, id_to_word), f)
-
关于
pickle
的一个简单介绍:
9.2基于PTB数据集的评价
包括构建共现矩阵、ppmi矩阵、SVD降维得到单词的密集向量表示、基于向量表示计算单词间相似度
-
共现矩阵、 ppmi矩阵、相似度计算的代码都来自前面的叙述;
-
这里只是将SVD计算方法换成了更快地randomized_svd
- 该方法通过使用了随机数的 Truncated SVD,仅对奇异值较大的部分进行 计算,计算速度比常规的 SVD 快;
- 需要安装sklearn机器学习包
- 关于该函数的参数的简单介绍详见代码中
-
计算出单词的密集向量表示后,遵照书上的过程,计算了几个单词最相似的单词列表,如下图所示:
- 与书上结果不完全相同,因为SVD分解时random_state=None,所以每次结果不一样
- 但是最终的结论还是相似的:
- 对于you,i、anybody、we都是与之比较相似的;这符合常理;
- 对于year,month、quarter等也比较相似;
10总结
- 以上就是对单词进行密集向量表示,然后计算单词间相似度的主要过程,可以概括为:
- 首先创建单词的共现矩阵,将其转化为 PPMI 矩阵,再基于 SVD 降 维以提高稳健性,最后获得每个单词的分布式表示。另外,我们已经确认 过,这样的分布式表示具有在含义或语法上相似的单词在向量空间上位置相 近的性质,因为从计算与每个单词最相似的单词有哪些的过程可以证实这一点。