这部分实际上做了两件事情,首先建立一个 RNN,然后以此 RNN 为基础,训练一个模型来完成图片 caption 的工作。我感觉作业中的代码先后顺序有些混乱,这里依照自己的理解,把内容重新组织一下。
train 和 val 使用的是 Coco2014,从打印出来的 data 信息来大概浏览一下数据的构成。
train_captions <class 'numpy.ndarray'> (400135, 17) int32
train_image_idxs <class 'numpy.ndarray'> (400135,) int32
train_features <class 'numpy.ndarray'> (82783, 512) float32
idx_to_word <class 'list'> 1004
word_to_idx <class 'dict'> 1004
train_urls <class 'numpy.ndarray'> (82783,) <U63
train dataset 中有 82783 张图片,每一张图片对应多个 caption,共有 400135 个caption,每一个 caption 最多包含 17 个整形数字,每一个数字通过 idx_to_word 对应到一个单词。idx_to_word 是一个 list,每一个位置上对应一个单词,其中位置0-3分别是特殊字符 \, \,\,\。所有的 caption 都是以 \ 起,以 \ 止,如果不足17个单词,那么在 \ 以后补 \,不在 idx_to_word 中的记为 \。
print(data['train_captions'][1])
print(decode_captions(data['train_captions'][1], data['idx_to_word']))
[ 1 4 3 172 6 4 62 10 317 6 114 612 2 0 0 0 0]
a view of a kitchen and all of its appliances
train feature 是直接取自 VGG16 的第 fc7 层,也就是从 4096 映射到 1000 个 class 的前一层,所以是 4096 维的。这里为了减少计算量,使用 PCA 将维度减小到 512 维。
RNN 一次处理一个长度为 T 的时间序列 x(0), x(t), … , x(T),其中,隐状态 h(t) 由该时刻输入 x(t) 和上一时刻隐状态 h(t-1) 共同决定:
作业中将 RNN 中的隐状态转移和 loss 计算分成了两部分,首先按时序先将每一时刻的隐状态表示出来,然后在所有隐状态已知的情况下,将各个时刻的 loss 一步求出。这样做在计算 backprop 时要特别注意某一时刻隐状态的梯度是由(此时刻 loss 的梯度 + 下一隐状态的梯度)两部分组成。而所有要更新的参数 Wx W x , Wh W h , bh b h , W W 和 b b ,其所对应的梯度值,要将各个时刻计算得到的值全部叠加起来。
forward 的计算是根据公式:
用维度分析法来推导,这里就不赘述了,要注意的是 Wh W h 是形如 (H, H) 的,不要忘记也要做转置。
这一步是将隐状态映射为 score,根据公式:
与先前 softmax 的计算没什么大的差别,要注意的是由于输出的 caption 的长度不相同的,所以标记为 \ 的单词不计算入总的 loss。因此要引入一个 mask。
forward 没什么好说的,在所有时刻一步步调用 rnn_step_forward 即可。
backward 要强调的是它的输入参数,原作业中用的是 dh,这里为了更清楚,记为 dLossdh,它是形如 (N, T, H) 的,表示从某一时刻的 loss 传递到该时刻隐状态的梯度值;而此时刻隐状态的梯度值还包括另外一部分,即从后一时刻隐状态传递到该时刻隐状态的梯度值,即:
dcurrent_h = dLossdh[:, i, :] + dprev_h
backprop 从最后一个时刻 h(T) 开始,往 h(0) 计算,而最后一个隐状态没有下一时刻,所以 dprev_h 的初始值应该为 0 :
dprev_h = np.zeros_like(prev_h)
另外需要注意的是,在 RNN 的一个 time capsule 中,所有参数都是 不进行更新的,而每一个时刻对参数都计算一个梯度,最后要将每一时刻的这些梯度都加起来:
dx[:, i, :], dprev_h, dWx_, dWh_, dbh_ = rnn_step_backward(dcurrent_h, cache)
dWx += dWx_
dWh += dWh_
dbh += dbh_
顺带又加了一种实现方式,即在每一时刻,不但计算出下一时刻的隐状态,同时计算出该时刻的 loss。
word embedding 的作用实际上是一个空间的映射,它将用 int 型数字编码的单词,映射到一个 D 维度的 float 型数字编码的空间。具体来说,将一个单词用一个整型数字来表示,所有的单词组成了一个词汇表,这个词汇表的词汇量大小是 V,词汇表中每一个单词的编码值应该在 [0, V) 范围内。然后定义一个形如 (V, D) 的映射关系 W。W 中的每一行是一个 D 维的 float 向量,对应一个单词在词汇表中的 offset。这样,就将一个单词由一个整型数字表示映射到一个 D 维的 float 向量。
举个例子,单词 cat 在词汇表中的 offset 为 10,在 W 中,第10行的向量是 [0.21428571,0.28571429,0.35714286] [ 0.21428571 , 0.28571429 , 0.35714286 ] ,那么单词 cat 就映射成了一个三维的向量 [0.21428571,0.28571429,0.35714286] [ 0.21428571 , 0.28571429 , 0.35714286 ] 。
在这里,单词在词汇表中的 offset 是固定的,而映射关系 W 是通过学习而来的参数。
N, T = x.shape
V, D = W.shape
out = np.zeros((N, T, D))
for n in range(N):
for t in range(T):
out[n, t, :] = W[x[n, t], :]
以上是 naive 的实现,如果用 python 的 indexing 的话,一行就够了
out = W[x, :]
N, T = x.shape
V, D = W.shape
dW = np.zeros_like(W)
for v in range(V):
for n in range(N):
for t in range(T):
if x[n, t] == v:
dW[x[n, t], :] += dout[n, t, :]
同样,先写 naive 形式,用 np.add.at 的话
dW = np.zeros_like(W)
np.add.at(dW, x, dout)
np.add.at 在 numpy 的教程里解释的也不多,这里究竟是如何 indexing 的,对照 naive 方法自己体会吧。
训练一个 RNN 来做 image caption 需要输入图片的 feature 和 caption,并用同样的 caption 做 label。整个过程包含三个学习过程,需要训练三组参数。分别为:1.图片的 feature 向 h(0) 的 projection;2. RNN;3. 单词的 projection。
输入的图片 feature 是从 VGG 的 FC7 层截取的,原始值是 4096 维的,为了减小计算量,通过 PCA 降维到 512 维,而 RNN 的 h(0) 是 H 维的。从形如 (N, D) 的 feature 映射到 (N, H) 的隐状态,需要一组形如 (D, H) 的参数
self.params['W_feature'] = np.random.randn((input_dim, hidden_dim)) / np.sqrt(input_dim)
self.params['b_feature'] = np.zeros(hidden_dim, )
一个 RNN 需要两组参数,一组是隐状态之间的转换参数 Wx W x 和 Wh W h ;另一组是隐状态向 score 的转换参数 Ws W s 。各个参数的形状已经在上面的图中标注的很清楚了。
self.params['Wx'] = np.random.randn((wordvec_dim, hidden_dim)) / np.sqrt(wordvec_dim)
self.params['Wh'] = np.random.randn((hidden_dim, hidden_dim)) / np.sqrt(hidden_dim)
self.params['bh'] = np.zeros(hidden_dim, )
self.params['Ws'] = np.random.randn((hidden_dim, vocab_size)) / np.sqrt(hidden_dim)
self.params['bs'] = np.zeros(vocab_size, )
进行 word embedding 的参数形如 (V, W)
self.params['W_word'] = np.random.randn((vocab_size, wordvec_dim)) / 100
这里要注意的是如何将同一个 caption 拆分成输入的 data 和 label,并且要注意一个 RNN time capsule 中的时序问题。
首先,Coco 的一个 caption 有 17 位,以 \ 始,以 \ 或者是 \ 止。这里,作为输入的 caption_in 是取 caption 的前16位 [0, T-1],所以必定是以 \ 开始的,去掉了最后一个单词(无论是 \ 还是 \);而作为 label 的 caption_out 取后16位 [1, T],去掉了开头的 \。所以整个 RNN 所有时刻的输入输出对应关系如图所示。
需要注意的是,h(0) 作为初始状态,是不参与到计算 caption 的输出的。
RNN 在 test 与 train 不同,首先依旧是根据图片的 feature 映射出初始状态 h(0),同样,h(0) 作为初始状态,是不参与到计算 caption 的输出的。隐状态 h(1) 的输入是 x(0): \,输出 caption 的第一个单词 x(1),然后以 x(1) 作为第二个隐状态的输入,以此类推直至到序列允许的最大长度结束。
需要注意的是,这里序列允许的最大长度 max_length 和训练时一个 time capsule 的时序最大长度 T 没有任何关系。
Inline Question 1
In our current image captioning setup, our RNN language model produces a word at every timestep as its output. However, an alternate way to pose the problem is to train the network to operate over characters (e.g. ‘a’, ‘b’, etc.) as opposed to words, so that at it every timestep, it receives the previous character as input and tries to predict the next character in the sequence. For example, the network might generate a caption like
‘A’, ’ ‘, ‘c’, ‘a’, ‘t’, ’ ‘, ‘o’, ‘n’, ’ ‘, ‘a’, ’ ‘, ‘b’, ‘e’, ‘d’
Can you describe one advantage of an image-captioning model that uses a character-level RNN? Can you also describe one disadvantage? HINT: there are several valid answers, but it might be useful to compare the parameter space of word-level and character-level models.
以单词为单位的 RNN,词汇表可以很大,而且每次的输出至少能够保证是有意义的单词;而以字母为单位的 RNN,词汇表是固定大小,但是输出的范围几乎是无穷的,并且不能保证输出的组合是有意义的单词。所以一字母为单位的 RNN 效果应该不如一单词为单位的 RNN。