文章目录
- 0写在前面
- 1数据准备
- 2CBOW模型结构的实现
- 3交叉熵损失函数的前向计算
- 3.1关于cross_entropy_error的计算
- 3.2关于softmax
0写在前面
- 代码都位于:nlp;
- 其他相关内容详见专栏:深度学习自然语言处理基础_骑着蜗牛环游深度学习世界的博客-CSDN博客;
1数据准备
-
输入是上下文,目标是中间的单词
-
因此对于下图所示的小的语料库来说,可以构建上下文以及对应的目标词:
- 对语料库中的所有单词都执行该操作(两端的单词 除外)
-
接下来需要将单词转换为神经网络能够处理的输入
-
需要根据前面所学习的内容,从语料库构建单词-ID之间的映射【使用
nlp\2-基于计数的方法.py
中写好的preprocess
方法】 -
根据构建的映射,将输入和输出从单词转换为索引;由于这里不是一个单词了,而是所有的输入输出,因此需要写一个函数来统一处理;
def create_contexts_targets(corpus, window_size=1): ''' :param corpus: 序列化的语料库列表 :param window_size: 上下文大小;主要用于去除掉不同时具备上下文的开头或者末尾的单词 :return: ''' target = corpus[window_size:-window_size] # 获取需要预测的词列表;开头和末尾是不算的,因为他们不同时具有上下文 contexts = [] # 从第一个有上下文的单词开始,到最后一个有上下文的单词结束 for i in range(window_size, len(corpus) - window_size): cs = [] # 当前单词的上下文列表 for t in range(-window_size, window_size + 1): if t == 0: # 跳过单词本身 continue cs.append(corpus[i + t]) contexts.append(cs) return np.array(contexts), np.array(target)
-
然后将单词索引转换为独热编码;注意维度的变化;
def convert_one_hot(corpus, vocab_size): '''转换为one-hot表示 :param corpus: 单词ID列表(一维或二维的NumPy数组) :param vocab_size: 词汇个数 :return: one-hot表示(二维或三维的NumPy数组) ''' N = corpus.shape[0] # 获取数据的数量;即输入的个数(输出的个数) if corpus.ndim == 1: # 维度数量为1,即是目标词数组 one_hot = np.zeros((N, vocab_size), dtype=np.int32) for idx, word_id in enumerate(corpus): one_hot[idx, word_id] = 1 elif corpus.ndim == 2: # 维度数量为2,即上下文数组 C = corpus.shape[1] # 窗口的大小(上下文的大小) one_hot = np.zeros((N, C, vocab_size), dtype=np.int32) for idx_0, word_ids in enumerate(corpus): # word_ids是某个目标词对应的上下文ID向量 for idx_1, word_id in enumerate(word_ids): one_hot[idx_0, idx_1, word_id] = 1 return one_hot
-
2CBOW模型结构的实现
-
构建
SimpleCBOW
类-
初始化:初始化权重、构建两个输入层,一个输出层,一个损失计算层;并将所有的权重和梯度整理到一起;
class SimpleCBOW(): def __init__(self, vocab_size, hidden_size): ''' :param vocab_size: 输入侧和输出侧神经元的个数 :param hidden_size: 中间层神经元个数 ''' V, H = vocab_size, hidden_size # 乘上0.01使得初始化的权重是一些比较小的浮点数 W_in = 0.01 * np.random.randn(V, H).astype('f') # 维度为[V,H] W_out = 0.01 * np.random.randn(H, V).astype('f') # 维度为[H,V] # 构建层 self.in_layer_0 = MatMul(W_in) # 输入层 self.in_layer_1 = MatMul(W_in) # 输入层 self.out_layer = MatMul(W_out) # 输出层 self.loss_layer = SoftmaxWithLoss() # 损失计算层 # 将所有的权重的参数和梯度数据存在一个变量中 layers = [self.in_layer_0, self.in_layer_1, self.out_layer] self.params, self.grads = [], [] for layer in layers: self.params += layer.params self.grads += layer.grads # 将单词的分布式表示记录为这个模型类的成员变量;这样模型训练好之后权重被更新 # "在 Python 中,对象和变量实际上是对内存中的值的引用。当你创建一个变量并将其设置为某个对象时,你实际上是在创建一个指向该对象的引用" self.wordvec = W_in
-
实现CBOW模型的前向计算(关于这里的损失计算,后面再专门讲)
- 这里由于
contexts
的维度为[6,2,7]
,因此不再是一个单词的上下文向量;因此传入输入层进行计算时是一次性计算了6条数据的全连接结果;因而我们可以发现,矩阵天然可以支持批量数据的处理; - 也就是说,输入是
[6,7]
,输入层的权重维度为[7,3]
,得到中间层是[6,3]
,不再是原来的[1,3]
;
def forward(self, contexts, target): ''' :param contexts: 输入 :param target: 真实标签;[6,7] :return:损失值 ''' # 输入层到中间层 h0 = self.in_layer_0.forward(contexts[:, 0]) # 结果是[6,3] h1 = self.in_layer_1.forward(contexts[:, 1]) h = 0.5 * (h0 + h1) # 中间层到输出层 score = self.out_layer.forward(h) # 输出的维度是[6,7] # 计算损失 loss = self.loss_layer.forward(score, target) # 将得分与真实标签传入损失计算函数;score在计算损失时会被施加softmax转换为概率的 return loss
- 这里由于
-
梯度反向传播;计算过程为【关于反向传播的细节,需要深入去了解】:
- 先计算损失函数的导数,从而得到损失函数输入的梯度
- 然后是输出层矩阵乘法的导数
- 然后
0.5h
的导数计算;这个直接对这个y=0.5h
求导,对h
求导;梯度是0.5
;因此根据梯度的链式传递法则,在传过来的时候是梯度需要乘上0.5
; - 然后是两个输入层结果相加的操作,这一步的梯度是分别原样传递到各个分支;简答理解:
y=x1+x2
;当对x1
求导时,x2
是当做常数的;x2
同理;- 因此相加操作处的梯度就直接分别传递给两个输入层
- 最后,两个输入层再传播梯度【这里传递梯度的计算公式在之前有分享一个博客:【深度学习】7-矩阵乘法运算的反向传播求梯度_矩阵梯度公式-CSDN博客】
def backward(self, dout=1): ds = self.loss_layer.backward(dout) da = self.out_layer.backward(ds) da = da * 0.5 self.in_layer_0.backward(da) # 输入层计算完最终梯度之后会将梯度保存在梯度列表里面;因此这里就不需要返回值了 self.in_layer_1.backward(da)
-
3交叉熵损失函数的前向计算
当调用
SimpleCBOW
类的forward
方法之后,当执行到self.loss_layer.forward
语句时,将进入到SoftmaxWithLoss
类的forward
函数,进行损失的计算;
-
首先,将模型输出的得分通过softmax函数转换为了概率分布;维度保持不变,依然是
[6,7]
; -
这里的真实标签是独热编码形式,因此根据独热编码提取正确的解标签(即,每条数据要预测单词的真实ID);此时标签变成一个维度:
(6,)
; -
然后进行交叉损失的计算,并返回损失值。
def forward(self, x, t): self.t = t # 维度为[6,7];是独热编码的形式 self.y = softmax(x) # 首先将得分转换为概率;维度为[6,7] # 在监督标签为one-hot向量的情况下,转换为正确解标签的索引 # 在监督标签为one-hot向量的情况下,它与模型输出的得分维度相同 if self.t.size == self.y.size: self.t = self.t.argmax(axis=1) # 从独热编码转换为标签;变成一个维度:(6,) loss = cross_entropy_error(self.y, self.t) return loss
3.1关于cross_entropy_error的计算
-
代码如下;接下来对其进行解释;
def cross_entropy_error(y, t): ''' :param y: 模型输出;已经转换为概率了 :param t: 真实标签;不再是独热编码 :return: ''' if y.ndim == 1: #1-1 # 默认不执行;这种情况是当批处理大小为1时可能进行的 t = t.reshape(1, t.size) y = y.reshape(1, y.size) # 在监督标签为one-hot-vector的情况下,转换为正确解标签的索引 # 当前示例下,监督标签已经在上一步转换成了真实标签索引了 if t.size == y.size: #1-2 t = t.argmax(axis=1) batch_size = y.shape[0] return -np.sum(np.log(y[np.arange(batch_size), t] + 1e-7)) / batch_size
-
我们这个小例子输入的数据维度是
[6,7]
,是包含多条数据的;如果只有一条数据,则可能存在维度是1
的情况;当这种情况发生时,要先增加一个维度;对标签也是如此;因此需要执行reshape(1, t.size)
和reshape(1, y.size)
; -
执行
#1-2
时存在以下情况:-
真实标签和模型输出一般是
[6,7]
,即两个维度;那么在SimpleCBOW
类的forward
方法中,真实标签就会从独热编码变成真实标签ID数组,真实标签的维度就已经是一维的了;此时#1-2
就不会执行了; -
如果是一条数据的情况,模型输出一般还是[1,7]两个维度;真实标签数组(独热编码形式)维度可能是
[1,7]
,也可能是(7,)
;如果是[1,7]
,则和多条数据时的情况一样;如果是(7,)
,则在SimpleCBOW
类的forward
方法中不会将标签从独热编码转换为ID索引,那么在cross_entropy_error
这里,最好将#1-1
改成如下的样子;然后通过执行#1-2
将真实标签从独热编码转换为ID索引;if t.ndim == 1: #1-1 # 默认不执行;这种情况是当批处理大小为1时可能进行的 t = t.reshape(1, t.size) # 以下是一个维度变换的小例子 import numpy as np c = np.array([1, 0, 0, 0, 0, 0, 0]).astype('f') print(c.size) #7 print(c.shape) # (7,) print(c.reshape(1,c.size)) # c:[[1. 0. 0. 0. 0. 0. 0.]]
-
-
然后计算交叉熵损失
-
本例子要预测的单词所属类别是
7
(因为对于每个待预测的单词,有7种可能),因此是一个多分类问题;对于多分类问题,交叉熵损失公式为: L = − ∑ [ y i ∗ l o g ( p i ) ] L=-\sum[y_i*log(p_i)] L=−∑[yi∗log(pi)];对于一次处理多条数据的情况,一般累加每个样本的值,然后求和再平均; -
如何理解这个损失:假设这里的真实标签
y
是独热编码的形式,模型预测如果很准确,则独热编码中元素1
就拥有更大的概率;概率越大,log函数值越接近0
,取相反数之后,值越接近0
就相当于是值越小;我们是期望这个值越小的,因为这意味着正确解标签具有更大的概率,模型就更准确; -
实际代码计算时,我们不需要完全按照公式来一步步计算;而是直接取正确解标签在模型预测输出中对应位置的概率来计算就可以了;
y
是二维的,[6,7]
;通过y[np.arange(batch_size), t]
从y
中选择对应的行和列,即选择了每条数据真实解标签对应的概率值;不能写成y[:, t]
;y[np.arange(batch_size), t]
的结果为(6,)
,其中是每个样本的正确解标签的概率值;对每个值计算对数;+1e-7
防止对数里的值为0- 然后对每个样本的对数值求和,并求平均,作为损失值;这个损失值应该越小越好;
-
这样就能把损失值计算出来了:
-
3.2关于softmax
-
代码如下:
def softmax(x): if x.ndim == 2: x = x - x.max(axis=1, keepdims=True) x = np.exp(x) x /= x.sum(axis=1, keepdims=True) elif x.ndim == 1: x = x - np.max(x) x = np.exp(x) / np.sum(np.exp(x)) return x
-
我们的输入一般是二维的;即
[6,7]
; -
执行
x - x.max
可以将数值约束到负数;这样指数的值就在0、1
之间;可以有效防止数值过大溢出的问题; -
然后使用
np.exp
对x
的每个元素计算指数;得到新的x
; -
然后用当前
x
的某个值除以x
的和得到概率;