[附课程学习笔记]CS231N assignment 3#1 _ RNN 学习笔记 & 解析

发布时间 2023-05-07 10:17:08作者: 360MEMZ

欢迎来到assignment3

从现在开始, 网上的博客数量就少了很多. 毕竟从现在, 我们开始了更具体网络的学习.

这里的组织形式可能会比较怪, 我会将RNN相关的课程内容和代码混在一起, 这样也可以同时作为学习笔记, 也是考虑到RNN之后没有官方讲义之后自己概括性的评说, 感觉比较好组织.

因为最近时间减少的关系, 所以更新速度慢了很多. 不过这个坑我一定会填的hh

COCO数据集和数据格式

COCO数据集是一个CV深度学习中应用广泛的数据集. 因为原始的图像大小为20G, 所以在这个课程中存储的实际上是图片经过全连接层之后的向量, 并按照HDF5文件格式存储. 除此之外, 数据集内还附带了图片的原始链接和captioning. 解说文本是一句话, 有数个单词, 但是这里不处理字符串, 而是将所有单词对应一个数字, 并存储在相应的json文件内,例如<START> => 1, <END> => 2.

因为没有包含图片,所以读取必须从网络获取. 如果出现"另一进程无法访问"的错误, 是因为下载的临时文件删除的冲突问题, 可以将删除的代码注释掉:

这样我们就可以看到几个示例数据了.

RNN的定义和实现

关于RNN, 大家可能在网络上看到最多的图片就是这个:

我们记忆现在的状态和目前正在发生的事情和过去的记忆有关, 而我们根据记忆会做出对应的决策. 我们设状态为ht, ht和当前的输入和过去状态h_t-1有关, 而ht经过一系列变换就得到的输出. 也就是这个图象:

但是, 实际上输入和输出的关系却不止一种, 根据应用场景的不同我们可以抽象不同的输入输出关系:

这五个情况分别对应了:

  • 传统的神经网络
  • 定长输入, 可变输出, 比如根据图片生成文本
  • 可变输入, 定长输出, 例如文本情感分析这样对变长数据的概括性内容
  • 多对多, 输出晚于全部输入, 适用于事后对变长数据的分析, 例如文本翻译
  • 多对多, 但是输入输出同时对应, 适用于实时分析,

在课件中, 除了我们看到上面图片的思维定势, 还有其他的一些巧妙思路. 例如我们不断移动绿色的框, 这样识别就变成了一个动态的过程, 虽然还是分类,:

通用的示意图如下, 对于一对多的模型, 可以直接将x抹去:

 

有了这个准备, 我们就写出了前向推导的代码, 目前我们仅仅是针对一个隐含层块的处理而已:

next_h,cache = None,None
    # 维度: x (N,D) h (N,H) Wx (D,H) Wh (H,H) b (H,)
    next_h = np.tanh(prev_h.dot(Wh)+x.dot(Wx)+b) 
    cache = (next_h,Wh,Wx,prev_h,x)

 需要注意, 这是一个很通用的推导函数, 我们应当根据具体的前向过程进行具体的操作.

梯度下降的部分这里暂时不表, 请参考下面的内容.

在实际的处理当中, 我们处理的应当是一个长度为T的序列, 这样, 我们只能写循环来逐步完成递推的过程. 在整个前向推导过程中, 我们使用相同的权重矩阵, 因此滑动的只有x和对应的h. 为此, 我们维护两个变量prev_h和next_h, 做逐步的循环:

    N, T, _ = x.shape
    H = b.shape[0]
    h = np.zeros((N, T, H))
    prev_h = h0  # 维护变量, 初始的prev_h
    for i in range(T):  # T个时间步
        next_h, cache_ = rnn_step_forward(x[:, i, :], prev_h, Wx, Wh, b) # 单步
        # cache这里用不到, 因为W不变, 而h整个存储就行了
        prev_h = next_h # 迭代
        h[:, i, :] = prev_h
    cache = (h, Wh, Wx, b, x, h0)

梯度下降

首先我们需要知道tanh的导数形式: (σ => sigmoid)

随后我们来看看对于长时域的梯度倒退过程:

其中, h0,h1可以形成一个完整的梯度流过程, 所以每一个h均可以反馈到. 但是这里只要求对最初的h进行梯度计算.

随后我们倒退过tanh层, 因为已知内层为prev_h.dot(Wh)+x.dot(Wx)+b, 所以求导也很简单, 注意规模就可以了, 而b还是老规矩, 广播的反向是求和.

# 结果(N,H) x(N,D)... 剩下的参见前面 
    (next_h, Wh, Wx, prev_h, x) = cache
    dtanh = dnext_h * (1 - next_h * next_h)
    dx = dtanh.dot(Wx.T)
    dprev_h = dtanh.dot(Wh.T)
    dWx = x.T.dot(dtanh)
    dWh = prev_h.T.dot(dtanh)
    db = np.sum(dtanh, axis=0)

 而如果将这个推广到整个时域, 也就是T层矩阵, 除了h之外, 权重矩阵是定值, 直接对每个时间点的梯度进行求和, x每个部分都会有梯度. 

这里需要注意, "dh"是我们从y -> h 计算得到的梯度, 因为我们ht有两个走向: 转化为输出y和下一个h_t+1, dh实际上是从y而来的梯度, 实际上ht的变化源于ht+1和yt,所以在rnn_backward中相加即可.剩下的直接循环就可以了.

(h, Wh, Wx, b, x, h0) = cache
    N, T, H = dh.shape
    _, _, D = x.shape
    dx = np.zeros((N, T, D)) # 初始化
    dh0 = np.zeros((N, H)) # 输出梯度
    dWx = np.zeros((D, H))
    dWh = np.zeros((H, H))
    db = np.zeros(H)
    dprev_h_ = np.zeros((N, H)) # 梯度流类似初始化变量

    for i in range(T - 1, -1, -1):  # 从后往前计算
        prev_h = h0 if i == 0 else h[:, i - 1, :]
        cachei = (h[:, i, :], Wh, Wx, prev_h, x[:, i, :])
        dx[:, i, :], dprev_h_, dWx_, dWh_, db_ = rnn_step_backward(dh[:, i, :] + dprev_h_, cachei)
        dWh += dWh_
        dWx += dWx_
        db += db_
    dh0 = dprev_h_

词嵌入

所谓词嵌入(word embedding)是NLP里面的概念, 意即将文本转化成向量. 

在这里, 我们怎么理解这里的x和W呢? 这里实质上完成的应当是从输出数字到单词的转换, 比如我们原本的输出是2(不过实际上应该是第三个元素更大的向量, 经由softmax转化为one-hot,但是其可以被坍缩为数字2,所以这么说了), 就把2作为x, 对应的输出向量可能是[0=> ..., 1=> <START> , 2=> <END>].  代码看起来很简单:

out = W[x]
cache = (x, W.shape)

但是很让人摸不着头脑. 为什么是这样呢? 比如题目中输入的x和W就是:

我们可以看作这里的单词数量共有5个, 原本输出X是两个句子: 第一句话是单词0, 单词3, 单词1, 单词2. 而W记录了5个单词分别代表什么. 因此输出翻译成句子就是直接对矩阵切片. numpy的切片很强大, 我们直接W[0]得到的就是第一个单词的内容, 直接W[x]就会直接将x内的每一个元素替换成W内的元素.

现在我们从输出得到了dout, 需要得到dW. 因为x其实本质上只是对W进行切片, 所以dout切片也应该可以得到dW. 我们已经知道, 输出的格式为(N,T,D). dout的每一个向量都应该被加总到dW的对应位置, 而这个位置存储在x里面. 最终看起来也是大道至简:

    x, W_shape = cache
    dW = np.zeros(W_shape)
    np.add.at(dW, x, dout)

其中np.add.at是干什么的呢? 下面有个例子:

add函数类似于+=这个符号, 而add.at函数第二个参数表明加的位置, 第三个参数表明加的数量. 这个函数的作用就是对dW按照x的指示加上dout的特定部分.

随后需要将h转化成输出向量和softmax输出, 这点可以参照之前的博客, 并未要求我们完成. 此外, 在softmax层中, 也确实完成了我们前面说的探索过程.

实现RNN captioning

下面我们在rnn.py内完成我们的过程. 首先根据注释我们看出它的处理方式是: 因为我们要计算误差, 所以需要将字幕同步, 但是因为第二个时刻开始才有有效输出, 所以caption_in从<START>开始, 掐掉了最后的<END>, 而caption_out仅仅缺失<START>, <END>理论就在最后一个输出的下一个位置 ,随后就是一系列参数的读取.

这个计算loss过程和我最初想法很不一样, 即: 将原始图像特征直接经过全连接层转化为h的初始值, 随后输入真实字幕的一个单词, 让它来预测下一个, 也就是我专注于训练这个网络模型从这个单词推断下一个单词的能力, 但是在测试过程中我们就应当直接输入上一个的单词输出了.

# 将图像的特征转化为h
        h_i, cache_proj = affine_forward(features, W_proj, b_proj)
        # caption_in => 词嵌入向量
        captions_embedding, cache_embedding = word_embedding_forward(captions_in, W_embed)
        # 推演
        if self.cell_type == 'rnn':
            h, cache_rnn = rnn_forward(captions_embedding, h_i, Wx, Wh, b)
        else: # LSTM暂时没有实现
            h, cache_rnn = None, None
        #  转化成y
        scores, cache_out = temporal_affine_forward(h, W_vocab, b_vocab)
        # 计算损失. 这实质上就是前面给你的函数用法
        loss, dscores = temporal_softmax_loss(scores, captions_out, mask)

        # 反向传播求梯度
        dh, grads["W_vocab"], grads["b_vocab"] = temporal_affine_backward(dscores, cache_out)
        if self.cell_type == 'rnn':
            dembedding, dh0, grads["Wx"], grads["Wh"], grads["b"] = rnn_backward(dh, cache_rnn)
        else:
            dembedding, dh0 = None, None
        grads["W_embed"] = word_embedding_backward(dembedding, cache_embedding)
        # dfeatures,grads["W_proj"],grads["b_proj"] = affine_relu_backward(dh0,cache_proj)
        dfeatures, grads["W_proj"], grads["b_proj"] = affine_backward(dh0, cache_proj)

        return loss, grads

下面就是直接小批量数据过拟合来验证我们的正确性.  自然效果是很好的,我也就不展示结果了.

问题和解答

回答: 因为我们从目标上就是要做连续推断, 所以很显然, word比char可以给出的信息会更多, 所以word相对不容易出现错误, 但是word会比char编码上复杂, 所以嵌入矩阵等也更难, 而且我们不能无限穷举. 不过还是那句话, 二者没有绝对优劣之分, char也可以有很好的效果, 但是必须建立在良好的网络架构和优质的训练数据的基础上.