《动手学深度学习》第二十六天---循环神经网络的从零开始实现

(一)one-hot向量

mxnet.ndarray.one_hot(indices=None, depth=_Null, on_value=_Null, off_value=_Null, dtype=_Null, out=None, name=None, **kwargs)

返回一个one-hot向量,由索引表示的位置取值为on_value,而所有其他位置取值为off_value。具有形状(i0,i1)和深度d的指数的one_hot操作将产生形状(i0,i1,d)的输出阵列,其具有:
在这里插入图片描述
如何理解这个输出的数组呢?看一下例子:
在这里插入图片描述
首先我们输入的数组是一个1×4的向量[1,0,2,0],按照定义,输出应该为一个1×4×3的向量,并且在(0,0,1),(0,1,0),(0,2,2),(0,3,0)处取为有效值。可以看到这些位置都满足了(i,j,indices[i,j])
比如下面分别展示了索引为0和2的one-hot向量,向量长度等于词典大小:

nd.one_hot(nd.array([0, 2]), vocab_size)

输入为2的向量[0,2],所以只有在(0,0)和(1,2)位置为有效值
在这里插入图片描述
我们每次采样的小批量的形状是(批量大小即每个批量内样本数, 时间步数)。下面的函数将这样的小批量变换成数个可以输入进网络的形状为(批量大小, 词典大小)的矩阵,矩阵个数等于时间步数。也就是说,时间步t的输入为Xt∈Rn×d,其中n为批量大小,d为输入个数,即one-hot向量长度(词典大小)。

def to_onehot(X, size):   # X的形状是原小批量形状(批量大小,时间步长)
    return [nd.one_hot(x, size) for x in X.T]   # x为批量大小,size为词典大小,返回(批量大小,词典大小)

X = nd.arange(10).reshape((2, 5))
inputs = to_onehot(X, vocab_size)   # X为数据库
len(inputs), inputs[0].shape  # 输出转换后的inputs的长度(时间步长),形状(批量大小,词典大小)

在这里插入图片描述

(二)初始化模型参数

num_inputs, num_hiddens, num_outputs = vocab_size, 256, vocab_size  # 隐藏单元个数 num_hiddens是一个超参数
ctx = d2l.try_gpu()
print('will use', ctx)

def get_params():
    def _one(shape):
        return nd.random.normal(scale=0.01, shape=shape, ctx=ctx)

    # 隐藏层参数
    W_xh = _one((num_inputs, num_hiddens))
    W_hh = _one((num_hiddens, num_hiddens))
    b_h = nd.zeros(num_hiddens, ctx=ctx)
    # 输出层参数
    W_hq = _one((num_hiddens, num_outputs))
    b_q = nd.zeros(num_outputs, ctx=ctx)
    # 附上梯度
    params = [W_xh, W_hh, b_h, W_hq, b_q]
    for param in params:  #  对于params数组里面所有的元素
        param.attach_grad()   #  调用attach_grad函数来申请存储梯度所需的内存 
    return params

(三)定义模型

定义init_rnn_state函数来返回初始化的隐藏状态。
rnn函数定义了在一个时间步里如何计算隐藏状态和输出。

先看一下python中关于元组tuple的知识:

一、列表和元组的区别

  1. 列表是动态数组,它们不可变且可以重设长度(改变其内部元素的个数)。
  2. 元组是静态数组,它们不可变,且其内部数据一旦创建便无法改变。
  3. 元组缓存于Python运行时环境,这意味着我们每次使用元组时无须访问内核去分配内存。
  4. 列表可被用于保存多个互相独立对象的数据集合
  5. 元组用于描述一个不会改变的事务的多个属性
def init_rnn_state(batch_size, num_hiddens, ctx):
    return (nd.zeros(shape=(batch_size, num_hiddens), ctx=ctx), )
    #  它返回由一个形状为(批量大小, 隐藏单元个数)的值为0的NDArray组成的元组
    #  使用元组是为了处理隐藏状态中有多个NDArray的情况

def rnn(inputs, state, params):
    # inputs和outputs皆为num_steps个形状为(batch_size, vocab_size)的矩阵
    
    W_xh, W_hh, b_h, W_hq, b_q = params
    
    #  params返回的数组形式是 params = [W_xh, W_hh, b_h, W_hq, b_q]
    #  形状:其中W_xh是(词典大小,隐藏层个数),W_hh是(隐藏层个数,隐藏层个数),b_h是(隐藏层个数)
    #  W_hq是(隐藏层个数,输出个数),b_q是(输出个数)

    H, = state  #  state为包含一个NDArray的元组,得到的H是NDArray
    outputs = []
    for X in inputs:
        H = nd.tanh(nd.dot(X, W_xh) + nd.dot(H, W_hh) + b_h)
        #  H 的形状是(批量大小,隐藏层个数),采用的是tanh的激活函数
        Y = nd.dot(H, W_hq) + b_q
        outputs.append(Y)  # 把每个步长的数组处理后同样以数组的形式追加
    return outputs, (H,)  #输出同样是num_steps个形状为(batch_size,vocab_size)的矩阵

做个简单的测试来观察输出结果的个数(时间步数),以及第一个时间步的输出层输出的形状和隐藏状态的形状。

state = init_rnn_state(X.shape[0], num_hiddens, ctx)
#  X是形状为(批量大小,时间步长)的数组,num_hiddens是超参数,隐藏层个数
#  利用函数init_rnn_state得到的是初始化的隐藏层状态
inputs = to_onehot(X.as_in_context(ctx), vocab_size)
#  得到one-hot向量(批量大小,词典大小)作为inputs
params = get_params()
#  得到初始化的隐藏层和输出层的各参数
outputs, state_new = rnn(inputs, state, params)
#  得到经过五个步长后的输出和最后得到隐藏层状态
len(outputs), outputs[0].shape, state_new[0].shape
#  检验输出结果数目,输出层形状和隐藏层状态形状

Out:
(5, (2, 1027), (2, 256))

(四)定义预测函数

def predict_rnn(prefix, num_chars, rnn, params, init_rnn_state,
                num_hiddens, vocab_size, ctx, idx_to_char, char_to_idx):
                #  参数prefix---含有数个字符的字符串,num_chars为接下来需要预测的数个字符
                #  rnn计算输出和更新隐藏状态,params是初始化参数,init_rnn_state是初始化隐藏状态
                #  num_hiddens是隐藏层数目,vocab_size是词典大小,ctx为选择的cpu或者gpu加速
                #  idx_to_char是词典由索引到字符的对应
    state = init_rnn_state(1, num_hiddens, ctx)
    #  初始化隐藏状态是(1,num_hiddens)的数组
    output = [char_to_idx[prefix[0]]]
    #  找到给出的第一个字符的索引,形成数组的第一个元素
    for t in range(num_chars + len(prefix) - 1):
        # 将上一时间步的输出作为当前时间步的输入
        X = to_onehot(nd.array([output[-1]], ctx=ctx), vocab_size)
        # 计算输出和更新隐藏状态
        (Y, state) = rnn(X, state, params)
        # 下一个时间步的输入是prefix里的字符或者当前的最佳预测字符
        if t < len(prefix) - 1:
            output.append(char_to_idx[prefix[t + 1]])
        else:
            output.append(int(Y[0].argmax(axis=1).asscalar()))  #追加的是Y[0]数组的沿着第二维的索引的最大值
    return ''.join([idx_to_char[i] for i in output])  #  由索引得到字符

先测试一下predict_rnn函数。我们将根据前缀“分开”创作长度为10个字符(不考虑前缀长度)的一段歌词。因为模型参数为随机值,所以预测结果也是随机的。

predict_rnn('分开', 10, rnn, params, init_rnn_state, num_hiddens, vocab_size,
            ctx, idx_to_char, char_to_idx)

分布分析一下预测的过程:
首先超参 predix=‘分开’,num_chars=10
初始化隐藏状态为
state= [[0. 0. 0. 0. 0. 0. 0. 0. 0. 0.]]
<NDArray 1x10 @gpu(0)>
predix[0]=‘分’,output=[215]
for t in range(10+2-1=11) ,t从0到10:
nd.array([output[-1]], ctx=ctx) 为 [215] , 所以经过to_onehot函数以后得到one_hot向量X
len(X)=1,X[0].shape=(1,1027)
在这里插入图片描述
经过rnn函数得到输出Y
在这里插入图片描述
由于t=0< (2-1),所以output追加为[215,530]
然后t=1
nd.array([output[-1]], ctx=ctx)是[530]
得到的X仍然如上,但是由于之前的state更新了,并且参数是随机值,所以得到的Y不一样
在这里插入图片描述
这时得到的t=1(其实就是predix在output数组中追加完了)
接下来对output数组的追加由于params的随机,所以得到的都是随机的,并且不受前面的已存的影响。
在这里插入图片描述

(五)裁剪梯度(clip gradient)

深度神经网络训练的时候,采用的是反向传播方式,该方式背后其实是链式求导,计算每层梯度的时候会涉及一些连乘操作。
因此如果网络过深,那么如果连乘的因子大部分小于1,最后乘积的结果可能趋于0,也就是梯度消失,后面的网络层的参数不发生变化,后面的层学不到东西;
那么如果连乘的因子大部分大于1,最后乘积可能趋于无穷,这就是梯度爆炸
梯度爆炸,其实就是偏导数很大的意思。回想我们使用梯度下降方法更新参数:
在这里插入图片描述
损失函数的值沿着梯度的方向呈下降趋势,然而,如果梯度(偏导数)很大话,就会出现函数值跳来跳去,收敛不到最值的情况,其中一种解决方法是,将学习率αα设小一点,如0.0001。
这里介绍梯度裁剪(Gradient Clipping)的方法,对梯度的L2范数进行裁剪,也就是所有参数偏导数的平方和再开方。
在这里插入图片描述
裁剪后的梯度
在这里插入图片描述
的L2范数不超过θ。

def grad_clipping(params, theta, ctx):  # 对于裁剪梯度函数的定义
    norm = nd.array([0], ctx)  # 定义正则
    for param in params:
        norm += (param.grad ** 2).sum()  #对所有参数求导然后参数元素求平方后求和,再把参数平方和相加
    norm = norm.sqrt().asscalar()  # 开方
    if norm > theta:
        for param in params:
            param.grad[:] *= theta / norm  # 根据得到的正则条件处理裁剪梯度

(五)困惑度(perplexity)

在自然语言处理中,对于一个语言模型,一般用困惑度来衡量它的好坏,困惑度越低,说明语言模型面对一句话感到困惑的程度越低,语言模型就越好。
困惑度是对交叉熵损失函数做指数运算后得到的值。特别地,

  1. 最佳情况下,模型总是把标签类别的概率预测为1,此时困惑度为1;
  2. 最坏情况下,模型总是把标签类别的概率预测为0,此时困惑度为正无穷;
  3. 基线情况下,模型总是预测所有类别的概率都相同,此时困惑度为类别个数。

显然,任何一个有效模型的困惑度必须小于类别个数。

(六)定义模型训练函数

def train_and_predict_rnn(rnn, get_params, init_rnn_state, num_hiddens,
                          vocab_size, ctx, corpus_indices, idx_to_char,
                          char_to_idx, is_random_iter, num_epochs, num_steps,
                          lr, clipping_theta, batch_size, pred_period,
                          pred_len, prefixes):
    if is_random_iter:
        data_iter_fn = d2l.data_iter_random  # 采用随机采样
    else:
        data_iter_fn = d2l.data_iter_consecutive  # 采用相邻采样
    params = get_params()  # 获取随机参数
    loss = gloss.SoftmaxCrossEntropyLoss()  #使用交叉熵损失函数

    for epoch in range(num_epochs):
        if not is_random_iter:  # 如使用相邻采样,在epoch开始时初始化隐藏状态
            state = init_rnn_state(batch_size, num_hiddens, ctx)
        l_sum, n, start = 0.0, 0, time.time()
        data_iter = data_iter_fn(corpus_indices, batch_size, num_steps, ctx)
        for X, Y in data_iter:
            if is_random_iter:  # 如使用随机采样,在每个小批量更新前初始化隐藏状态
                state = init_rnn_state(batch_size, num_hiddens, ctx)
            else:  # 否则需要使用detach函数从计算图分离隐藏状态
                for s in state:
                    s.detach()
            with autograd.record():
                inputs = to_onehot(X, vocab_size)
                #  X为每个批量里面(batch_size,num_steps)的矩阵,经过to_onehot函数后得到有num_steps个形状为(batch_size, vocab_size)的矩阵inputs
                (outputs, state) = rnn(inputs, state, params)  # outputs有num_steps个形状为(batch_size, vocab_size)的矩阵
               outputs = nd.concat(*outputs, dim=0)
                # 沿着第一维拼接,所以把num_steps*batch_size作为第一维的大小
                # 拼接之后形状为(num_steps * batch_size, vocab_size)
               y = Y.T.reshape((-1,))
                # reshape(-1,) 中有参数为-1时,表示这个维的数目由别的维决定
                # Y的形状是(batch_size, num_steps),转置后再变成长度为batch * num_steps 的向量,这样跟输出的行一一对应 
                l = loss(outputs, y).mean()  # 使用交叉熵损失计算平均分类误差
                #  由于我们需要得到的一个词后面是什么词,所以以原歌词中的的X+1作为原输出,outputs作为训练输出
            l.backward()  # 小批量的损失对模型参数求梯度
            grad_clipping(params, clipping_theta, ctx)  # 裁剪梯度
            d2l.sgd(params, lr, 1)  # 使用小批量随机梯度下降迭代模型参数
            # 因为误差已经取过均值,梯度不用再做平均,所以batch_size设为1
            l_sum += l.asscalar() * y.size  # 总损失
            n += y.size  #所有参与训练的字的数目

        if (epoch + 1) % pred_period == 0:  # 每隔对于对于预测长度可以整除的训练周期就输出一次预测
            print('epoch %d, perplexity %f, time %.2f sec' % (
                epoch + 1, math.exp(l_sum / n), time.time() - start))  # 输出当前训练周期,困惑度,训练时间
            for prefix in prefixes:
                print(' -', predict_rnn(
                    prefix, pred_len, rnn, params, init_rnn_state,
                    num_hiddens, vocab_size, ctx, idx_to_char, char_to_idx))

(七)训练模型并创作歌词

num_epochs, num_steps, batch_size, lr, clipping_theta = 250, 35, 32, 1e2, 1e-2
# 手动设计训练周期,时间步长,批量大小,学习率,裁剪梯度参数
pred_period, pred_len, prefixes = 50, 50, ['分开', '不分开']
# 预测周期,预测字数,前缀

采用随机采样:

train_and_predict_rnn(rnn, get_params, init_rnn_state, num_hiddens,
                      vocab_size, ctx, corpus_indices, idx_to_char,
                      char_to_idx, True, num_epochs, num_steps, lr,
                      clipping_theta, batch_size, pred_period, pred_len,
                      prefixes)

采用相邻采样:

train_and_predict_rnn(rnn, get_params, init_rnn_state, num_hiddens,
                      vocab_size, ctx, corpus_indices, idx_to_char,
                      char_to_idx, False, num_epochs, num_steps, lr,
                      clipping_theta, batch_size, pred_period, pred_len,
                      prefixes)
  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值