文章目录
- 0引言
- 1 CBOW模型的重构
- 1.1模型初始化
- 1.2模型的前向计算
- 1.3模型的反向传播
- 2总结
0引言
- 前面讲述了对word2vec高速化的改进:
- 改进输入侧的计算,变成Embedding,即从权重矩阵中选取特定的行;
- 改进输出侧的计算,包含两点
- 改进输出侧矩阵乘法,改为Embedding_dot层,Embedding部分其实与输入侧一样;dot部分就是将中间层的结果与Embedding部分的结果做内积得到一个值;
- 化多分类为二分类,将softmax改进为sigmoid,并引入负采样方法;损失函数依然使用交叉熵损失,只不过是二分类的;
- 接下来,将这两块的改进应用到CBOW模型上,重新构建CBOW模型以及学习代码。
1 CBOW模型的重构
代码位于:
improved_CBOW/CBOW.py
;代码文件链接:https://1drv.ms/u/s!AvF6gzVaw0cNjqNRnWXdF3J6J0scCA?e=3mfDlx;
1.1模型初始化
-
截止模型初始化,程序入口的代码如下:
if __name__ == "__main__": text = "you say goodbye and I say hello." # 构建单词与编号之间的映射并将句子向量化 corpus, word_to_id, id_to_word = preprocess(text) # contexts是一个维度为[6,2]的numpy数组 contexts = np.array([[0, 2], [1, 3], [2, 4], [3, 1], [4, 5], [1, 6]]) # (6,2) target = np.array([1, 2, 3, 4, 1, 5]) # (6,) vocab_size = len(word_to_id) hidden_size = 3 window_size = 1 CBOW_model = CBOW(vocab_size, hidden_size, window_size, corpus)
-
改进之后CBOW模型的初始化代码如下:
class CBOW: def __init__(self, vocab_size, hidden_size, window_size, corpus): V, H = vocab_size, hidden_size # 初始化权重 W_in = 0.01 * np.random.randn(V, H).astype('f') # (7,3) # 因为W_out这里将使用embedding层,计算时需要转置, # 所以这里索性初始化就直接是转置后的 W_out = 0.01 * np.random.randn(V, H).astype('f') # (7,3) # 生成层 self.in_layers = [] for i in range(2 * window_size): layer = Embedding(W_in) # 使用Embedding层 self.in_layers.append(layer) self.ns_loss = NegativeSamplingLoss(W_out, corpus, power=0.75, sample_size=3) # 将所有的权重和梯度整理到列表中 layers = self.in_layers + [self.ns_loss] self.params, self.grads = [], [] for layer in layers: self.params += layer.params self.grads += layer.grads # 将单词的分布式表示设置为成员变量 self.word_vecs = W_in
-
关于初始化的代码,做如下解释:
- 因为
W_out
这里将使用Embedding层,前面的笔记中说过,计算时需要转置,所以这里索性初始化就直接是转置后的;因此从代码上来看,输入侧的权重和输出侧的权重维度相同,在学习的过程中分别去优化; - 和之前一样,根据上下文窗口的大小,生成相应数量的输入层;只是这里改进之后,创建的是相应数量的Embedding层
- 根据负采样的
sample_size
,为每个负例创建相应的sigmoid层以及交叉熵损失计算层;为正例创建一个sigmoid层以及交叉熵损失计算层
- 因为
-
再来看一下初始化的结果:
-
如下图:创建的
CBOW_model
包含输入层in_layers
、输出侧的Embedding_dot层ns_loss.embed_dot_layers
、sigmoid&交叉熵损失计算层ns_loss.loss_layers
; -
每一个Embedding_dot层都包含一个Embedding层,其中的参数维度都是
(vocab_size,hidden_size)
;如下图所示: -
经过整理,所有的参数和梯度都被整理到一块,如下图所示;前两个是输入侧的两个权重矩阵的参数(因为上下文窗口大小为
1
),后面四个是输出侧一个正例和三个负例的权重矩阵的参数;梯度跟参数对应,这里就不列了;
-
1.2模型的前向计算
-
为了进行前向计算,程序入口增加的代码如下:
loss = CBOW_model.forward(contexts, target) # contexts:(6,2);target:(6,)
-
前向计算的代码如下:
def forward(self, contexts, target): ''' @param contexts: 目标词的上下文;(batch_size, 2*window_size);e.g. (6,2) @param target: 目标词;(batch_size,);e.g. (6,)''' h = 0 for i, layer in enumerate(self.in_layers): h += layer.forward(contexts[:, i]) # h:(6,3) h *= 1 / len(self.in_layers) # 对h进行平均;window_size不一定是1,所以取决于self.in_layers loss = self.ns_loss.forward(h, target) return loss
-
关于输入侧的计算:
- 每次计算一个mini-batch的上下文的某一个单词的前向计算结果,因此每次传入的是
contexts[:, i]
,维度是(6,)
;这是一个mini-batch的单词ID,forward
方法会从该layer
的权重矩阵中抽取对应的行,返回的结果就是(6,3)
的h
; - 由于我们只改变了输入侧的计算方法,输入侧的计算结果仍然像之前一样,求平均;因此需要对所有输入层的中间结果求平均得到总的
h
;
- 每次计算一个mini-batch的上下文的某一个单词的前向计算结果,因此每次传入的是
-
接着,在
self.ns_loss.forward
中,首先进行负例采样;根据传入的target
,为其中每一个样本抽取sample_size
个负例样本对应的单词ID,得到negative_sample
,维度为(batch_size,sample_size)
; -
接着,在
self.ns_loss.forward
中,进行正例的前向计算;将这一个mini-batch的正例从输出侧的权重矩阵中抽取对应的行,并于对应的中间结果做内积,得到这个mini-batch的得分,维度为(batch_size,)
;例如(6,)
;然后将这个得分和真实标签一起送入sigmoid&损失计算层,计算交叉熵损失得到损失值;这个损失值是一个标量,是一个mini-batch损失的平均值; -
接着,在
self.ns_loss.forward
中,进行负例的前向计算;计算过程与正例一样;但因为每个样本的有sample_size
个负例,因此一次同时处理一个mini-batch的某一个负例;然后将所有负例的损失累加的正例的损失中,作为最终的前向计算的损失值; -
输出以及损失侧的计算步骤较多,这里再贴出来
loss = self.ns_loss.forward(h, target)
的具体过程:def forward(self, h, target): ''' @param h: 中间层的结果,维度为(batch_size,hidden_dim); e.g. (6,3) @param target: 正确解标签;维度为(batch_size,); e.g. (6,)''' batch_size = target.shape[0] # 获取self.sample_size个负例解标签 negative_sample = self.sampler.get_negative_sample(target) # (batch_size,sample_size); e.g. (6,3) # 正例的正向传播 score = self.embed_dot_layers[0].forward(h, target) # (batch_size,) e.g. (6,) correct_label = np.ones(batch_size, dtype=np.int32) # 正例的真实标签自然是1;维度为(batch_size,) e.g. (6,) loss = self.loss_layers[0].forward(score, correct_label) # 损失标量 # 负例的正向传播 negative_label = np.zeros(batch_size, dtype=np.int32) # 负例的真实标签自然是0;维度为(batch_size,) e.g. (6,) for i in range(self.sample_size): # 对一个mini-batch的每一个负例样本,依次计算损失并累加到正例的损失上去 negative_target = negative_sample[:, i] # (batch_size,) e.g. (6,) score = self.embed_dot_layers[1 + i].forward(h, negative_target) # (batch_size,) loss += self.loss_layers[1 + i].forward(score, negative_label) return loss
1.3模型的反向传播
-
为了进行反向传播,程序入口增加的代码如下:
CBOW_model.backward()
-
先进行输出侧的反向传播:
-
输出侧由一个正例+
sample_size
个负例组成,根据计算图,它们求得的输出层的输入侧的梯度需要进行累加; -
因此遍历所有的
loss_layers
和embed_dot_layers
,然后先进行loss_layer
的反向传播,再进行embed_dot_layer
的反向传播;- 因为前向计算时每个损失是累加起来作为最终损失的,因此反向传播时传到每个损失里面的
dout=1
;于是就根据之前推导的结果,sigmoid+交叉熵损失的梯度是y-t
,计算传递至loss_layer
输入侧的梯度; - 然后计算
embed_dot_layer
的反向传播;计算对dtarget_w
的梯度以更新这个Embedding_dot层的权重参数;计算dh
以将梯度传递至下游;过程在前面的笔记中讲解过;
- 因为前向计算时每个损失是累加起来作为最终损失的,因此反向传播时传到每个损失里面的
-
由于过程较多,因此这里贴出来代码供查看;另外注释也更新了;
# 输出侧损失反向传播入口 dout = self.ns_loss.backward(dout) # 输出侧损失反向传播入口对应的反向传播函数 def backward(self, dout=1): dh = 0 # 中间层结果h到输出侧是进入了多个分支,因此反向传播时梯度需要累加 for l0, l1 in zip(self.loss_layers, self.embed_dot_layers): # 依次对正例和每个负例所在的网络结构进行反向传播 dscore = l0.backward(dout) # 损失函数(sigmoid和交叉熵损失)的反向传播,即y-t的结果;维度为(batch_size,); e.g. (6,) dh += l1.backward(dscore) # Embedding_dot的反向传播,包含保存各自权重矩阵对应的行的梯度 return dh # sigmoid函数的反向传播 def backward(self, dout=1): '''本质上是对sigmoid函数的输入求梯度''' batch_size = self.t.shape[0] dx = (self.y - self.t) * dout / batch_size # 这里将梯度平均了;维度为(batch_size,); e.g. (6,) return dx # Embedding_dot层的反向传播 def backward(self,dout): ''' @param dout: 上游损失函数的梯度;形状为(batch_size,);e.g. (6,)''' h,target_w=self.cache dout=dout.reshape(dout.shape[0],1) # 这里是为了保证dout的形状与h的形状一致;形状为(batch_size,1);e.g. (6,1) dtarget_w=dout*h # 对应元素相乘;dout:[batch_size,1];h:[batch_size,hid_dim];所以会进行广播;形状为(batch_size,hidden_dim);e.g. (6,3) self.embed.backward(dtarget_w) # 把梯度更新到权重矩阵的梯度矩阵的对应行;先前在执行self.embed.forward(idx)时已经保存了使用的idx dh=dout*target_w # 对应元素相乘;会进行广播;形状为(batch_size,hidden_dim);e.g. (6,3) return dh
-
-
然后是中间层的梯度,因为前向计算时,是对window_size个输入层的输出结果平均了,才得到的h;所以执行如下语句计算中间层的梯度;
dout *= 1 / len(self.in_layers)
-
最后,计算window_size个输入层的梯度,即Embedding层;由于只是从输入侧权重矩阵中选取了特定行,因此梯度的传播仅仅是将上游传递来的梯度值放到对应的梯度矩阵中;代码如下:
for layer in self.in_layers: layer.backward(dout)
2总结
- 几点注意
- 关于这里使用的batch_size的含义:个人理解,这里的批处理大小并不是指通常意义的样本数(or句子数),CBOW模型每次的输入就是目标词的上下文单词;一个目标词对应的上下文单词构成mini-batch里面的一条数据;
- 改进之前,输入和输出都是使用的独热编码,改进之后,不再使用;