最近在看斋藤康毅的《深度学习进阶:自然语言处理》,以下按章节做一点笔记。
这本书是《深度学习入门:基于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个单词,单词ID为0的you被输入。此时,查看Softmax层输出的概率分布,当权重较好时,say的概率最高,这表明正确预测出了you后面出现的单词为say。
- 接着,我们关注第2个单词say。这里需要注意的是, RNN层“记忆”了“you say”这一上下文。更准确地说,RNN将“you say”这一过去的信息保存为了简短的隐藏状态向量。RNN层的工作是将这个信息传送到上方的Affine层和下一时刻的RNN层。通过Affine层+Softmax层 预测say后面应跟的单词。
- 同理。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()