“鱼书”深度学习进阶笔记(4)第五章

最近在看斋藤康毅的《深度学习进阶:自然语言处理》,以下按章节做一点笔记。
这本书是《深度学习入门:基于Python的理论与实现》的续作,针对自然语言处理和时序数据处理。如对“鱼书”第一本的笔记感兴趣,可看我之前做的笔记。

01 第五章: RNN

我们看到的神经网络都是前馈型神经网络。
前馈(feedforward)是指网络的传播方向是单向的。即,先将输入信号传给下一层(隐藏层)​,接收到信号的层也同样传给下一层,然后再传给下一层……像这样,信号仅在一个方向上传播。
这种网络不能很好地处理时间序列数据(以下简称为“时序数据”​)​。并且,单纯的前馈网络无法充分学习时序数据的性质(模式),所以引入RNN(Recurrent Neural Network,循环神经网络)。

1.1 引子

马尔可夫性”​(或者“马尔可夫模型”​“马尔可夫链”​),是指未来的状态仅依存于当前状态。此外,当某个事件的概率仅取决于其前面的N个事件时,称为“N阶马尔可夫链”​。
例如,若下一个单词仅取决于前面2个单词的模型,则称为“2阶马尔可夫链”​。

在下面的图片例子中,根据该语境(上下文)​,正确答案应该是Mary向Tom(或者“him”​)打招呼。
例子
这里要获得正确答案,就必须将“?”前面第18个单词处的Tom记住。
如果CBOW模型(前几章学习的模型)的上下文大小是10,则这个问题将无法被正确回答。
我们当然可以通过增大CBOW模型的上下文大小(比如变为20或30)来解决此问题,但是CBOW模型还存在忽视了上下文中单词顺序的问题

CBOW是Continuous Bag-Of-Words的简称。
Bag-Of-Words是“一袋子单词”的意思,从定义就意味着袋子中单词的顺序被忽视了。

如何理解CBOW会忽略单词顺序呢?
当在上下文是2个单词的情况下,CBOW模型的中间层是那2个单词向量的和。
因此上下文的单词顺序会被忽视。比如,​(you, say)和(say, you)会被作为相同的内容进行处理。
如图5-5左图。

图片
想要考虑了上下文中单词顺序的模型。可以像图5-5右图,在中间层“拼接”​(concatenate)上下文的单词向量。
“Neural Probabilistic Language Model”中提出的模型就采用了这个方法,但采用拼接的方法,权重参数的数量将与上下文大小成比例地增加。

1.2 RNN

RNN(Recurrent Neural Network),直译为“复发神经网络”或者“循环神经网络”​。

1.2.1 循环的神经网络

RNN的特征就在于拥有一个环路(或回路)​。这个环路可以使数据不断循环。
通过数据的循环,RNN一边记住过去的数据,一边更新到最新的数据。

可将结构简单理解为如下,x为输入,h为输出,输出的一部分成为输入,和x一起作用于RNN:
结构

1.2.2 展开循环

RNN层的循环展开:
展开
某一时刻的h输出,可计算为:
表达式
提问,为什么这里激活函数用tanh?
公式
注意,许多文献中将RNN的输出ht称为隐藏状态(hidden state)或隐藏状态向量(hidden state vector)​,本书中也这样定义。

图的画法的比较,如下:
比较

1.2.3 Backpropagation Through Time

将RNN层展开后,就可以视为在水平方向上延伸的神经网络,因此RNN的学习可以用与普通神经网络的学习相同的方式进行。
结构
因为这里的误差反向传播法是“按时间顺序展开的神经网络的误差反向传播法”​,所以称为Backpropagation Through Time(基于时间的反向传播)​,简称BPTT。

但是,要基于BPTT求梯度,必须在内存中保存各个时刻的RNN层的中间数据(RNN层的反向传播将在后文中说明)​。
因此,随着时序数据变长,计算机的内存使用量(不仅仅是计算量)也会增加。

1.2.4 Truncated BPTT

在处理长时序数据时,通常的做法是将网络连接截成适当的长度。
Truncated是“被截断”的意思。Truncated BPTT是指按适当长度截断的误差反向传播法。

即,将时间轴方向上过长的网络在合适的位置进行截断,从而创建多个小型网络,然后对截出来的小型网络执行误差反向传播法,这个方法称为Truncated BPTT(截断的BPTT)​。
严格地讲,只是网络的反向传播的连接被截断,正向传播的连接依然被维持。即,正向传播的信息没有中断地传播。

假设我们要处理一个长度为1000的序列数据,如果展开RNN层,它将成为在水平方向上排列有1000个层的网络。
因此,考虑在水平方向上以适当的长度截断网络的反向传播的连接:
插入详细解释
上图中,截断了反向传播的连接,以使学习可以以10个RNN层为单位进行。
像这样,只要将反向传播的连接截断,就不需要再考虑块范围以外的数据了,以各个块为单位(和其他块没有关联)完成误差反向传播法。

1.2.5 Truncated BPTT的mini-batch学习

之前探讨Truncated BPTT时,并没有考虑mini-batch学习,或者说批大小为1的情况。
为了执行mini-batch学习,需要考虑批数据,为了保证也能按顺序输入数据,要在输入数据的开始位置,在各个批次中进行“偏移”​。

如何理解上面提到的“偏移”呢?
在之前,对长度为1000的时序数据,是以时间长度10为单位进行截断。若将批大小设为2进行学习:
作为RNN层的输入数据,第1笔样本数据从头开始按顺序输入,第2笔数据从第500个数据开始按顺序输入。也就是说,将开始位置平移500
展示

1.3 RNN的实现

1.3.1 RNN层的实现

先来实现RNN单步处理的RNN类:

import numpy as np

class RNN:
    def __init__(self, Wx, Wh, b):
        self.params = [Wx, Wh, b]
        self.grads = [np.zeros_like(Wx), np.zeros_like(Wh), np.zeros_like(b)]
        self.cache = None  # cache 用来保存前向过程中的中间变量,供反向传播使用

    def forward(self, x, h_prev):
        Wx, Wh, b = self.params
        t = np.dot(h_prev, Wh) + np.dot(x, Wx) + b
        h_next = np.tanh(t)

        self.cache = (x, h_prev, h_next)
        return h_next

    def backward(self, dh_next):
        Wx, Wh, b = self.params
        x, h_prev, h_next = self.cache

        dt = dh_next * (1 - h_next ** 2)  # tan h(t)对t求导=1-tanh(t)^2
        db = np.sum(dt, axis=0)
        dWh = np.dot(h_prev.T, dt)
        dh_prev = np.dot(dt, Wh.T)
        dWx = np.dot(x.T, dt)
        dx = np.dot(dt, Wx.T)

        # 把计算得到的梯度写入 self.grads,用于优化器更新参数
        self.grads[0][...] = dWx
        self.grads[1][...] = dWh
        self.grads[2][...] = db

        return dx, dh_prev

为什么用self.grads[0][…] = dWx的语句,而不用self.grads[0]= dWx,是为了保证原地址不变
现在的计算结构就变成了:
计算结构
反向传播示意图是这样的:
反向传播示意图

1.3.2 Time RNN 层的实现

Time RNN层由T个RNN层构成。
Time RNN层将隐藏状态h保存在成员变量中,以在块之间继承隐藏状态。
实现代码为:

class TimeRNN:
    def __init__(self, Wx, Wh, b, stateful=False):  # stateful=False 表示默认不保留上一次序列的隐藏状态
        self.params = [Wx, Wh, b]
        self.grads = [np.zeros_like(Wx), np.zeros_like(Wh), np.zeros_like(b)]
        self.layers = None

        self.h, self.dh = None, None
        self.stateful = stateful  # 这个参数来控制是否继承隐藏状态,=false表示每个minibatch都重新初始化隐藏状态;=true表示连续batch之间沿用上一次的隐藏状态

    def forward(self, xs):  # 计算RNN层各个时刻的隐藏状态,并存放在hs的对应索引(时刻)中
        Wx, Wh, b = self.params
        N, T, D = xs.shape  # xs: 输入数据,形状 [N, T, D],N=batch,T=时间步,D=输入维度)
        D, H = Wx.shape  # H 是隐藏状态的维度

        self.layers = []
        hs = np.empty((N, T, H), dtype='f')  # 初始化一个空数组 hs 来保存每个时间步的输出隐藏状态

        if not self.stateful or self.h is None:  # 如果不使用 stateful ,或者是第一次 forward,就把隐藏状态初始化为 0
            self.h = np.zeros((N, H), dtype='f')

        for t in range(T):
            layer = RNN(*self.params)  # 创建一个RNN单元
            self.h = layer.forward(xs[:, t, :], self.h)
            hs[:, t, :] = self.h  # 保存本时间步输出
            self.layers.append(layer)  # 把该时间步的RNN存起来(为了反传)

        return hs

    def backward(self, dhs):
        Wx, Wh, b = self.params
        N, T, H = dhs.shape
        D, H = Wx.shape

        dxs = np.empty((N, T, D), dtype='f')
        dh = 0
        grads = [0, 0, 0]
        for t in reversed(range(T)):  #reversed(range(T))是反向遍历的写法,会把 range(T) 产生的序列 反过来 依次迭代
            layer = self.layers[t]  #  # 取倒数第t时刻的RNN
            dx, dh = layer.backward(dhs[:, t, :] + dh)  # 当前时间步的反向梯度 = 当前输出的梯度 + 下一时间步传回来的梯度
            dxs[:, t, :] = dx

            for i, grad in enumerate(layer.grads):
                grads[i] += grad  # 累加每个时间步计算出的参数梯度

        for i, grad in enumerate(grads):
            self.grads[i][...] = grad  # 把总梯度写入 self.grads
        self.dh = dh  # 记录最前面的隐藏状态梯度(传给更早的序列)

        return dxs

    def set_state(self, h):
        self.h = h  # 手动设置隐藏状态(例如接着上一个 mini batch 继续)

    def reset_state(self):
        self.h = None  # 把隐藏状态清空(例如开始新的序列)

反向传播部分,如下:
反向传播
将从上游(输出侧的层)传来的梯度记为dhs,将流向下游的梯度记为dxs。
这里进行的是Truncated BPTT,所以不需要流向这个块上一时刻的反向传播。
不过,我们将流向上一时刻的隐藏状态的梯度存放在成员变量dh中。

则,Time RNN的反向传播(第t个RNN层)的逻辑被修正为:
逻辑图
从上方传来的梯度dht和从将来的层传来的梯度dhnext会传到第t个RNN层。
按与正向传播相反的方向,调用RNN层的backward(​)方法,求得各个时刻的梯度dx,并存放在dxs的对应索引处。

1.4 处理时序数据的层的实现

基于RNN的语言模型称为RNNLM(RNN Language Model,RNN语言模型)。

1.4.1 RNNLM 结构

下图为最简单的RNNLM的网络,其中左图显示了RNNLM的层结构,右图显示了在时间轴上展开后的网络。
结构
第1层是Embedding层,该层将单词ID转化为单词的分布式表示(单词向量)​。然后,这个单词向量被输入到RNN层。
RNN层向下一层(上方)输出隐藏状态,同时也向下一时刻的RNN层(右侧)输出隐藏状态。
RNN层向上方输出的隐藏状态经过Affine层,传给Softmax层。

以前面提到的词汇库“you say goodbye and i say hello”为例。

  1. 我们关注第1个时刻。作为第1个单词,单词ID为0的you被输入。此时,查看Softmax层输出的概率分布,当权重较好时,say的概率最高,这表明正确预测出了you后面出现的单词为say。
  2. 接着,我们关注第2个单词say。这里需要注意的是, RNN层“记忆”了“you say”这一上下文。更准确地说,RNN将“you say”这一过去的信息保存为了简短的隐藏状态向量。RNN层的工作是将这个信息传送到上方的Affine层和下一时刻的RNN层。通过Affine层+Softmax层 预测say后面应跟的单词。
  3. 同理。RNNLM可以“记忆”目前为止输入的单词,并以此为基础预测接下来会出现的单词。RNN层通过从过去到现在继承并传递数据,使得编码和存储过去的信息成为可能。

1.4.2 Time层的实现

实现为了Time RNN层,别的层同样需要对Time进行考虑,如应使用Time Embedding层、Time Affine层等来实现整体处理时序数据。
这部分实现的代码为:


class Embedding:
    def __init__(self, W):
        self.params = [W]
        self.grads = [np.zeros_like(W)]
        self.idx = None  # 用来保存前向传播时输入的索引(单词ID),初始化为 None

    def forward(self, idx):
        W, = self.params
        self.idx = idx  # 保存索引,方便后向传播用。
        out = W[idx]  # 根据索引选出对应的词向量。例如 idx 是 [3, 7],就选 W 中第3行和第7行,形成对应的词向量输出。
        return out

    def backward(self, dout):
        dW, = self.grads  # 用逗号解包 dW, = ...,拿到的是这个梯度数组的引用(不是拷贝).所以 dW 和 self.grads[0] 指向同一个内存地址
        dW[...] = 0  # 数组所有元素替换为 0,但保留原数组的内存地址
        np.add.at(dW, self.idx, dout)  # 把 dout 对应的梯度,累加到 dW 中对应索引行的位置
        return None

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

class TimeEmbedding:
    def __init__(self, W):
        self.params = [W]
        self.grads = [np.zeros_like(W)]
        self.layers = None
        self.W = W

    def forward(self, xs):
        N, T = xs.shape
        V, D = self.W.shape

        out = np.empty((N, T, D), dtype='f')
        self.layers = []

        for t in range(T):
            layer = Embedding(self.W)
            out[:, t, :] = layer.forward(xs[:, t])
            self.layers.append(layer)

        return out

    def backward(self, dout):
        N, T, D = dout.shape

        grad = 0
        for t in range(T):
            layer = self.layers[t]
            layer.backward(dout[:, t, :])
            grad += layer.grads[0]

        self.grads[0][...] = grad
        return None


class TimeAffine:
    def __init__(self, W, b):
        self.params = [W, b]
        self.grads = [np.zeros_like(W), np.zeros_like(b)]
        self.x = None

    def forward(self, x):
        N, T, D = x.shape
        W, b = self.params

        rx = x.reshape(N*T, -1)
        out = np.dot(rx, W) + b
        self.x = x
        return out.reshape(N, T, -1)

    def backward(self, dout):
        x = self.x
        N, T, D = x.shape
        W, b = self.params

        dout = dout.reshape(N*T, -1)
        rx = x.reshape(N*T, -1)

        db = np.sum(dout, axis=0)
        dW = np.dot(rx.T, dout)
        dx = np.dot(dout, W.T)
        dx = dx.reshape(*x.shape)

        self.grads[0][...] = dW
        self.grads[1][...] = db

        return dx


class TimeSoftmaxWithLoss:
    def __init__(self):
        self.params, self.grads = [], []
        self.cache = None
        self.ignore_label = -1

    def forward(self, xs, ts):
        N, T, V = xs.shape

        if ts.ndim == 3:  # 在监督标签为one-hot向量的情况下
            ts = ts.argmax(axis=2)

        mask = (ts != self.ignore_label)

        # 按批次大小和时序大小进行整理(reshape)
        xs = xs.reshape(N * T, V)
        ts = ts.reshape(N * T)
        mask = mask.reshape(N * T)

        ys = softmax(xs)
        ls = np.log(ys[np.arange(N * T), ts])
        ls *= mask  # 与ignore_label相应的数据将损失设为0
        loss = -np.sum(ls)
        loss /= mask.sum()

        self.cache = (ts, ys, mask, (N, T, V))
        return loss

    def backward(self, dout=1):
        ts, ys, mask, (N, T, V) = self.cache

        dx = ys
        dx[np.arange(N * T), ts] -= 1
        dx *= dout
        dx /= mask.sum()
        dx *= mask[:, np.newaxis]  # 与ignore_label相应的数据将梯度设为0

        dx = dx.reshape((N, T, V))

        return dx

1.5 RNNLM的学习和评价

1.5.1 RNNLM的实现

先实现一个simpleENNLM的结构:
新结构
假设使用Truncated BPTT进行学习,将Time RNN层的stateful设置为True,如此Time RNN层就可以继承上一时刻的隐藏状态。
实现代码为:

class SimpleRnnlm:
    def __init__(self, vocab_size, wordvec_size, hidden_size):
        V, D, H = vocab_size, wordvec_size, hidden_size
        rn = np.random.randn

        # 初始化权重
        embed_W = (rn(V, D) / 100).astype('f')
        rnn_Wx = (rn(D, H) / np.sqrt(D)).astype('f')
        rnn_Wh = (rn(H, H) / np.sqrt(H)).astype('f')
        rnn_b = np.zeros(H).astype('f')
        affine_W = (rn(H, V) / np.sqrt(H)).astype('f')
        affine_b = np.zeros(V).astype('f')

        # 生成层
        self.layers = [
            TimeEmbedding(embed_W),
            TimeRNN(rnn_Wx, rnn_Wh, rnn_b, stateful=True),
            TimeAffine(affine_W, affine_b)
        ]
        self.loss_layer = TimeSoftmaxWithLoss()
        self.rnn_layer = self.layers[1]

        # 将所有的权重和梯度整理到列表中
        self.params, self.grads = [], []
        for layer in self.layers:
            self.params += layer.params
            self.grads += layer.grads

    def forward(self, xs, ts):
        for layer in self.layers:
            xs = layer.forward(xs)
        loss = self.loss_layer.forward(xs, ts)
        return loss

    def backward(self, dout=1):
        dout = self.loss_layer.backward(dout)
        for layer in reversed(self.layers):
            dout = layer.backward(dout)
        return dout

    def reset_state(self):
        self.rnn_layer.reset_state()

关于初始值的设定,原书中的解释如下:
解释
(对RNN而言,权重的初始值也很重要。通过设置好的初始值,学习的进展和最终的精度都会有很大变化。)
本书此后都将使用Xavier初始值作为权重的初始值。另外,在语言模型的相关研究中,经常使用**0.01 * np.random.uniform(…)**这样的经过缩放的均匀分布。

1.5.2 语言模型的评价

语言模型基于给定的已经出现的单词(信息)输出将要出现的单词的概率分布。
困惑度(perplexity)常被用作评价语言模型的预测性能的指标。
简单地说,困惑度表示“概率的倒数”​(这个解释在数据量为1时严格一致)​。

以“you say goodbye and i say hello.”这个预料库为例。
假设在向语言模型“模型1”传入单词you时,出现的单词是say的概率为0.8,计算出困惑度为1/0.8=1.25;
在“模型2”中,预测出say的概率是0.2,则计算出困惑度为1/0.2=5。
困惑度越小越好,则可看出模型1优于模型2。

如何直观地解释值1.25和5.0呢?
它们可以解释为“分叉度”​。
所谓分叉度,是指下一个可以选择的选项的数量(下一个可能出现的单词的候选个数)​。
在刚才的例子中,好的预测模型的分叉度是1.25,这意味着下一个要出现的单词的候选个数可以控制在1个左右。而在差的模型中,下一个单词的候选个数有5个。

以上都是输入数据为1个时的困惑度。那么,在输入数据为多个的情况下,结果会怎样呢?
利用下面的式子进行计算:
困惑度
其中的参数解释为:
参数解释
在信息论领域,困惑度也称为“平均分叉度”​。这可以解释为,数据量为1时的分叉度是数据量为N时的分叉度的平均值。

1.5.3 RNNLM的学习代码

这里使用PTB数据集进行学习,不过这里仅使用PTB数据集(训练数据)的前1000个单词。
这是因为在本节实现的RNNLM中,即便使用所有的训练数据,也得不出好的结果。
下一章我们将对它进行改进。

实现代码为:

import pickle
import sys,os
import urllib.request

sys.path.append('..')
url_base = 'https://raw.githubusercontent.com/tomsercu/lstm/master/data/'
key_file = {  # 原始文本文件名映射(训练/测试/验证)
    'train':'ptb.train.txt',
    'test':'ptb.test.txt',
    'valid':'ptb.valid.txt'
}
save_file = {  # 将处理后的 numpy 数组保存成 .npy 的文件名映射
    'train':'ptb.train.npy',
    'test':'ptb.test.npy',
    'valid':'ptb.valid.npy'
}
vocab_file = 'ptb.vocab.pkl'  # 保存词表映射(word_to_id, id_to_word)的文件名

dataset_dir = os.path.dirname(os.path.abspath(__file__))  # 当前脚本所在目录


def _download(file_name):
    file_path = dataset_dir + '/' + file_name  # 构建本地目标文件路径
    if os.path.exists(file_path):  # 如果已经存在就直接返回,不重复下载
        return

    print('Downloading ' + file_name + ' ... ')  # 提示用户开始下载

    try:
        urllib.request.urlretrieve(url_base + file_name, file_path)  # 尝试直接从 GitHub 下载
    except urllib.error.URLError:
        import ssl
        ssl._create_default_https_context = ssl._create_unverified_context  # 忽略 SSL 验证(必要时)
        urllib.request.urlretrieve(url_base + file_name, file_path)  # 再次下载(有时企业/环境的证书会阻止)
    print('Done')


def load_vocab():
    vocab_path = dataset_dir + '/' + vocab_file

    if os.path.exists(vocab_path):
        with open(vocab_path, 'rb') as f:
            word_to_id, id_to_word = pickle.load(f)
        return word_to_id, id_to_word

    word_to_id = {}  # 新建空字典:词->id
    id_to_word = {}  # 新建空字典:id->词
    data_type = 'train'  # 用训练集文本来构建词表(通常是最大的语料)
    file_name = key_file[data_type]  # 对应的文件名
    file_path = dataset_dir + '/' + file_name

    _download(file_name)  # 确保原始文本在本地(会跳过已存在的情况)

    words = open(file_path).read().replace('\n', '<eos>').strip().split()
    # 打开训练文本,读取为字符串:
    # 1) 把换行符替换成特殊标记 '<eos>'(end-of-sentence)
    # 2) strip() 去掉首尾空白
    # 3) split() 把字符串按空白分割成词列表

    for i, word in enumerate(words):
        if word not in word_to_id:
            tmp_id = len(word_to_id)
            word_to_id[word] = tmp_id
            id_to_word[tmp_id] = word

    with open(vocab_path, 'wb') as f:
        pickle.dump((word_to_id, id_to_word), f)  # 把构建好的词表保存到本地,方便下次直接加载

    return word_to_id, id_to_word


def load_data(data_type='train'):
    '''
        :param data_type: 数据的种类:'train' or 'test' or 'valid (val)'
        :return:
    '''
    if data_type == 'val': data_type = 'valid'
    save_path = dataset_dir + '/' + save_file[data_type]

    word_to_id, id_to_word = load_vocab()

    if os.path.exists(save_path):
        corpus = np.load(save_path)
        return corpus, word_to_id, id_to_word

    file_name = key_file[data_type]
    file_path = dataset_dir + '/' + file_name
    _download(file_name)

    words = open(file_path).read().replace('\n', '<eos>').strip().split()
    corpus = np.array([word_to_id[w] for w in words])

    np.save(save_path, corpus)
    return corpus, word_to_id, id_to_word


class SGD:
    '''
    随机梯度下降法(Stochastic Gradient Descent)
    '''

    def __init__(self, lr=0.01):
        self.lr = lr

    def update(self, params, grads):
        for i in range(len(params)):
            params[i] -= self.lr * grads[i]


# 设定超参数
batch_size = 10
wordvec_size = 100
hidden_size = 100
time_size = 5  # Truncated BPTT的时间跨度大小
lr = 0.1
max_epoch = 100

# 读入训练数据(缩小了数据集)
corpus, word_to_id, id_to_word = load_data('train')
corpus_size = 1000
corpus = corpus[:corpus_size]
vocab_size = int(max(corpus) + 1)

xs = corpus[:-1]  # 输入
ts = corpus[1:]  # 输出(监督标签)
data_size = len(xs)
print('corpus size: %d, vocabulary size: %d' % (corpus_size, vocab_size))

# 学习用的参数
max_iters = data_size // (batch_size * time_size)
time_idx = 0
total_loss = 0
loss_count = 0
ppl_list = []

# 生成模型
model = SimpleRnnlm(vocab_size, wordvec_size, hidden_size)
optimizer = SGD(lr)

# 计算读入mini-batch的各笔样本数据的开始位置
jump = (corpus_size - 1) // batch_size
offsets = [i * jump for i in range(batch_size)]

for epoch in range(max_epoch):
    for iter in range(max_iters):
        # 获取mini-batch
        batch_x = np.empty((batch_size, time_size), dtype='i')
        batch_t = np.empty((batch_size, time_size), dtype='i')
        for t in range(time_size):
            for i, offset in enumerate(offsets):
                batch_x[i, t] = xs[(offset + time_idx) % data_size]
                batch_t[i, t] = ts[(offset + time_idx) % data_size]
            time_idx += 1

        # 计算梯度,更新参数
        loss = model.forward(batch_x, batch_t)
        model.backward()
        optimizer.update(model.params, model.grads)
        total_loss += loss
        loss_count += 1

    # 各个epoch的困惑度评价
    ppl = np.exp(total_loss / loss_count)
    print('| epoch %d | perplexity %.2f'
          % (epoch+1, ppl))
    ppl_list.append(float(ppl))
    total_loss, loss_count = 0, 0

# 绘制图形
x = np.arange(len(ppl_list))
plt.plot(x, ppl_list, label='train')
plt.xlabel('epochs')
plt.ylabel('perplexity')
plt.show()

得到困惑度的图像:
图像
现实中,当语料库增大时,现在的模型根本无法招架。
下一章将指出当前RNNLM存在的问题,并进行改进。

也可将刚学习的RNNLM进行封装,再调用训练:

class RnnlmTrainer:
    def __init__(self, model, optimizer):
        self.model = model
        self.optimizer = optimizer
        self.time_idx = None
        self.ppl_list = None
        self.eval_interval = None
        self.current_epoch = 0

    def get_batch(self, x, t, batch_size, time_size):
        batch_x = np.empty((batch_size, time_size), dtype='i')
        batch_t = np.empty((batch_size, time_size), dtype='i')

        data_size = len(x)
        jump = data_size // batch_size
        offsets = [i * jump for i in range(batch_size)]  # mini-batch的各笔样本数据的开始位置

        for time in range(time_size):
            for i, offset in enumerate(offsets):
                batch_x[i, time] = x[(offset + self.time_idx) % data_size]
                batch_t[i, time] = t[(offset + self.time_idx) % data_size]
            self.time_idx += 1
        return batch_x, batch_t

    def fit(self, xs, ts, max_epoch=10, batch_size=20, time_size=35,
            max_grad=None, eval_interval=20):
        data_size = len(xs)
        max_iters = data_size // (batch_size * time_size)
        self.time_idx = 0
        self.ppl_list = []
        self.eval_interval = eval_interval
        model, optimizer = self.model, self.optimizer
        total_loss = 0
        loss_count = 0

        start_time = time.time()
        for epoch in range(max_epoch):
            for iters in range(max_iters):
                batch_x, batch_t = self.get_batch(xs, ts, batch_size, time_size)

                # 计算梯度,更新参数
                loss = model.forward(batch_x, batch_t)
                model.backward()
                params, grads = remove_duplicate(model.params, model.grads)  # 将共享的权重整合为1个
                if max_grad is not None:
                    clip_grads(grads, max_grad)
                optimizer.update(params, grads)
                total_loss += loss
                loss_count += 1

                # 评价困惑度
                if (eval_interval is not None) and (iters % eval_interval) == 0:
                    ppl = np.exp(total_loss / loss_count)
                    elapsed_time = time.time() - start_time
                    print('| epoch %d |  iter %d / %d | time %d[s] | perplexity %.2f'
                          % (self.current_epoch + 1, iters + 1, max_iters, elapsed_time, ppl))
                    self.ppl_list.append(float(ppl))
                    total_loss, loss_count = 0, 0

            self.current_epoch += 1

    def plot(self, ylim=None):
        x = np.arange(len(self.ppl_list))
        if ylim is not None:
            plt.ylim(*ylim)
        plt.plot(x, self.ppl_list, label='train')
        plt.xlabel('iterations (x' + str(self.eval_interval) + ')')
        plt.ylabel('perplexity')
        plt.show()

def clip_grads(grads, max_norm):
    total_norm = 0
    for grad in grads:
        total_norm += np.sum(grad ** 2)
    total_norm = np.sqrt(total_norm)

    rate = max_norm / (total_norm + 1e-6)
    if rate < 1:
        for grad in grads:
            grad *= rate

def remove_duplicate(params, grads):
    '''
    将参数列表中重复的权重整合为1个,
    加上与该权重对应的梯度
    '''
    params, grads = params[:], grads[:]  # copy list

    while True:
        find_flg = False
        L = len(params)

        for i in range(0, L - 1):
            for j in range(i + 1, L):
                # 在共享权重的情况下
                if params[i] is params[j]:
                    grads[i] += grads[j]  # 加上梯度
                    find_flg = True
                    params.pop(j)
                    grads.pop(j)
                # 在作为转置矩阵共享权重的情况下(weight tying)
                elif params[i].ndim == 2 and params[j].ndim == 2 and \
                     params[i].T.shape == params[j].shape and np.all(params[i].T == params[j]):
                    grads[i] += grads[j].T
                    find_flg = True
                    params.pop(j)
                    grads.pop(j)

                if find_flg: break
            if find_flg: break

        if not find_flg: break

    return params, grads

# 设定超参数
batch_size = 10
wordvec_size = 100
hidden_size = 100  # RNN的隐藏状态向量的元素个数
time_size = 5  # RNN的展开大小
lr = 0.1
max_epoch = 100

# 读入训练数据
corpus, word_to_id, id_to_word = load_data('train')
corpus_size = 1000  # 缩小测试用的数据集
corpus = corpus[:corpus_size]
vocab_size = int(max(corpus) + 1)
xs = corpus[:-1]  # 输入
ts = corpus[1:]  # 输出(监督标签)

# 生成模型
model = SimpleRnnlm(vocab_size, wordvec_size, hidden_size)
optimizer = SGD(lr)
trainer = RnnlmTrainer(model, optimizer)

trainer.fit(xs, ts, max_epoch, batch_size, time_size)
trainer.plot()


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

阿群今天学习了吗

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值