Understanding Stateful LSTM Recurrent Neural Networks in Python with Keras

 

https://machinelearningmastery.com/understanding-stateful-lstm-recurrent-neural-networks-python-keras/ 翻译

(原文更新至2020.08.27)

经过大量的实践人们知道,LSTM(长短期模型网络)是一种有效且流行的RNN。由于其可以在一定程度上克服在几乎所有的RNN中都存在的梯度消失或爆炸的问题,从而能够允许人们使用更深或更大的深度学习网络。

和其他的RNN网络类似,LSTM需要维护状态,因此这也让使用keras进行LSTM的网络实践变得更加复杂,而在本文中,我们将主要介绍在Keras的深度学习框架下LSTM的状态保存过程。总结如下:

  • 如何创建一个简单的LSTM网络来解决序列预测问题
  • 如何在LSTM中通过控制batch、feature来管理网络状态
  • 如何手动控制LSTM中的网络状态来进行状态预测

注:本文所有代码片段的完整代码在原文中均有提供

Learn the Alphabet: prepare the dataset

在本节中,我们将设计并比较几种不同的LSTM模型。问题为通过学习字母表来进行简单的序列预测:通过给出当前的字母,输出字母表上的下一个字母。这一问题比较简单,而且经过改造后可以扩展至时间序列预测、序列分类等问题上。

首先做如下的一些准备:

# 导入需要的库
import numpy
from keras.models import Sequential
from keras.layers import Dense
from keras.layers import LSTM
from keras.utils import np_utils
# 设置numpy random seed来保证每次运行结果相同
numpy.random.seed(7)
# 设置基本的字母表以及相应的转换字典
# define the raw dataset
alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
# create mapping of characters to integers (0-25) and the reverse
char_to_int = dict((c, i) for i, c in enumerate(alphabet))
int_to_char = dict((i, c) for i, c in enumerate(alphabet))

然后准备用来进行训练的输入输出数据集

# prepare the dataset of input to output pairs encoded as integers
seq_length = 1
dataX = []
dataY = []
for i in range(0, len(alphabet) - seq_length, 1):
	seq_in = alphabet[i:i + seq_length]
	seq_out = alphabet[i + seq_length]
	dataX.append([char_to_int[char] for char in seq_in])
	dataY.append(char_to_int[seq_out])
	print(seq_in, '->', seq_out)

此时的dataX为[[0], [1], [2], [3], ..., [24]],dataY为[1, 2, ..., 25],终端上会打印A -> B B -> C...的信息

接下来,我们需要将dataX(即网络的输入数据)转变成符合LSTM网络的形式,也就是[samples, time steps, features]的形式:

  • sample:采样数,当前为25组采样
  • time step:每次采样的时间tick数,类比过去n秒的每秒温度、过去n天每天的收益等等中的n值,当前为1
  • feature:特征数,当前有且只有一个特征

同时,由于大部分的激活函数在(-1, 1)范围附近灵敏度更高,因此许多模型在训练前会将输入数据进行归一化

# reshape X to be [samples, time steps, features]
X = numpy.reshape(dataX, (len(dataX), seq_length, 1))
# 归一标准化
X = X / float(len(alphabet))

对于输出而言,我们可将其当作预测问题(输出一个实数值)或分类问题(因为只有26个字母)显然选择将其作为分类问题的准确率更高。因此我们可以讲训练集中的输出进行编码,这里使用的是独热编码(one-hot encoding),通过keras中的utils类可以很容易完成该工作:

# one hot encode the output variable
y = np_utils.to_categorical(dataY)

数据集便准备完成了

Naive LSTM for Learning One-Char to One-Char Mapping

首先,让我们设计一个简单的LSTM网络来进行字母表预测的操作,我们先进行单输入字母到单输出字母的预测,因为这种问题对于LSTM来说并不友好

我们定义该LSTM网络中有32个单元,输出层使用softmax作为激活函数来进行预测。由于这是一个多类别的分类问题,我们可以使用log loss function(在keras中称为categorical_crossentropy)来作为loss function,并使用adam作为优化函数,初始模型中,batch大小设为1,并进行500次epoch:

# create and fit the model
model = Sequential()
model.add(LSTM(32, input_shape=(X.shape[1], X.shape[2])))
model.add(Dense(y.shape[1], activation='softmax'))
model.compile(loss='categorical_crossentropy', optimizer='adam', metrics=['accuracy'])
model.fit(X, y, epochs=500, batch_size=1, verbose=2)
# summarize performance of the model
scores = model.evaluate(X, y, verbose=0) # 这里原本应该只有一个值(loss),因为在compile时有metrics参数,因此还会评估一个accuracy的参数放到scores里变成(loss, accuracy)的形式
print("Model accuracy: %.2f%%" % (scores[1] * 100))
# 上述summarizing的过程也可以用如下的代码来呈现,流程是一样的:
for pattern in dataX:
    x = numpy.reshape(pattern, (1, 1, 1)) # 同样遵循[samples, time steps, features]的形式
    x = x/float(len(alphabet))
    prediction = model.predict(x, verbose=0)
    index = numpy.argmax(prediction)
    result = int_to_char[index]
    seq_in = ''.join([int_to_char[value] for value in pattern])
    print(seq_in, '->', seq_out)

通过运行如上的这样一段代码进行LSTM网络的训练,我们可以看到训练出来的准确率大概在85%左右,实际上对于这种简单问题而言这样的正确率并不高。其原因在于,这样的数据实际上没有任何的上下文信息;同时,所有的输入-输出数据都是以一种随机的顺序喂给网络训练(虽然dataX和dataY都是顺序的,但实际上在fit时keras会默认打乱数据),因此LSTM也没有办法推测pattern之间的状态变化。这样使得LSTM实际上退化成了普通的多层感知器网络。

那么接下来尝试该问题的其他形式,来给LSTM网络提供更多的上下文信息进行学习。

Naive LSTM for a Three-Char Feature Window to One-Char Mapping

一种常见方法是窗口法(window method),在本节中,使用窗口法来提供更多的输入特征(feature)。首先将原来的seq_length做如下修改:

# prepare the dataset of input to output pairs encoded as integers
seq_length = 3

那么将会得到类似ABC -> D, BCD -> E, ...等数据。

注意由于本节是将特征的窗口拉长了,因此之前reshape应修改为:

# reshape X to be [samples, time steps, features]
X = numpy.reshape(dataX, (len(dataX), 1, seq_length))
# 相应的pattern部分中的小x应修改成:
x = numpy.reshape(pattern, (1, 1, seq_length))

那么重新进行网络训练,可以看到准确率相较于之前的稍微提升了一些,但基本仍未超过90%,因为对于该问题而言,这种窗口的方式来分割问题实际上仍然不够好,我们虽然通过更多特征给了网络更多的上下文(context),但仍不是其想要的更多时间序列(time sequence)

Naive LSTM for a Three-Char Time Step Window to One-Char Mapping

那么在本节中,我们就将以更长时间序列的形式提供更多的上下文信息。我们将X和x的生成做如下修改:

X = numpy.reshape(dataX, (len(dataX), seq_length, 1))
x = numpy.reshape(pattern, (1, seq_length, 1))

这样我们得到了真正适用于LSTM的数据输入形式,经过这样的训练,网络基本能够达到100%的准确率。

但是我们注意到,该网络学习的仍然只是一个简单问题:它学习到了如何通过字母表上连续的三个字母来预测下一个应该是什么,只要给出字母表上的任意连续三个字母,它就能预测出下一个。

复杂一点的问题是,我们希望该网络能够枚举出该字母表来,上述的问题实际上稍微大一点的MLP网络也可以做出来,并没有真正凸显LSTM的优越性。LSTM的优越性在于其是有历史状态的网络,这应该也可以被用来学习字母表的排列,但由于数据输入的随机顺序,网络的状态在batches之间(关于keras实现的LSTM中state的reset时机分析可以见这一问题下的相关讨论)是被重设了的。由于目前设置的batch_size为1,因此每个sample之间LSTM的state状态都被重置了。

LSTM State Within A Batch

接下来在原来已经表现非常好(主要还是问题简单)的模型上做进一步的优化。首先我们回顾一个重点:keras在每个batch训练完后会重置LSTM的state状态。

那么我们是否可以得到:如果我们的batch size足够大,大到能够放下所有的input patterns,同时所有的input patterns都按顺序排列作为输入,那么LSTM是否可以更好利用batch内的上下文信息来更好的学习该序列。

相应的对比实验也比较好设计:将之前单字母预测问题中的batch大小从1改成整个training set的大小,同时将输入顺序告诉keras让其不随机打乱即可。我们可以通过修改之前代码中的一行为如下即可:

model.fit(X, y, epochs=500, batch_size=len(dataX), verbose=2, shuffle=False)

需要注意的是,通过这样的操作,神经网络的确可以学习到batch内序列中的字符映射关系,但这样的上下文信息在真正用来预测时是没有的(因为预测往往只会给一个输入sample,而不是一个batch的sample)。

需要注意的是,除了model.fit()中每个batch结束时reset LSTM state之外,在model.predict()model.evaluate()执行完后也会进行reset state的操作(见该回答)也就是如果你是一次次predict结果,和你将所有测试数据一次性evaluate的结果可能是不同的。

(作者在这里将测试数据打乱/不打乱的分别给了训练好的模型进行预测,结果发现二者对于单字母的预测都达到了100%的准确率,因此好像也没说明出打乱的测试数据是否会对模型预测准确度的影响)

Stateful LSTM for a One-Char to One-Char Mapping

在之前的讨论中我们通过设置更大窗口来提升训练效果,但这种方法需要3个字符作为映射的输入而非1个字符;我们也见到了修改batch大小的方法来提升LSTM的训练效果,但这种训练效果仅在training过程中的效果更好(因为fit、predict和evaluate后会reset state)。

那么我们又希望有更理想化的实现方式,那就是将网络完全暴露给整个序列,让其自己去学习其中跨序列的关系,而不是我们通过指定顺序等方式来告知其关系。那么我们可以在Keras中将LSTM层变得有状态,同时在每个epoch结束后重置state,而这也是LSTM真正应该的使用方式。

那么首先,定义LSTM层时就应采取有状态的定义方式。为了达成这一目的,我们需要特别指定input shape中的batch size,这也意味着在evalute或predict时的输入数据也必须要保持相同的batch size。在当前的问题中,batch size本身就定义为1。

batch_size = 1
model.add(LSTM(50, batch_input_shape=(batch_size, X.shape[1], X.shape[2]), stateful=True)) # 注意这里将stateful指定为True,因此之后的state reset都必须要手动指定,而不是之前默认的reset时机

同时,训练stateful LSTM的另一个重要点在于一次只训练一个epoch,同时在epoch之间重置state状态,并保持输入数据的原有顺序:

for i in range(300):
	model.fit(X, y, epochs=1, batch_size=batch_size, verbose=2, shuffle=False)
	model.reset_states()

同时在评估时指定batch size:

# summarize performance of the model
scores = model.evaluate(X, y, batch_size=batch_size, verbose=0)
model.reset_states()
print("Model Accuracy: %.2f%%" % (scores[1]*100))

那么从字母表的初始开始,predict下一个并将输出作为新的输入继续predict,就可以由一个字母作为输入得到整个字母表了,例如:

# demonstrate a random starting point
seed = [char_to_int[alphabet[0]]]
print("New start: ", letter)
for i in range(0, 5):
	x = numpy.reshape(seed, (1, len(seed), 1))
	x = x / float(len(alphabet))
	prediction = model.predict(x, verbose=0)
	index = numpy.argmax(prediction)
	print(int_to_char[seed[0]], "->", int_to_char[index])
	seed = [index]
model.reset_states()

运行以上的代码即可顺利得到A -> B, B -> C, ...的序列了。但我们可不可以从任意位置开始呢?如果把第一行换成这样呢:

# demonstrate a random starting point
letter = "K"
seed = [char_to_int[letter]]
print("New start: ", letter)
for i in range(0, 5):
	x = numpy.reshape(seed, (1, len(seed), 1))
	x = x / float(len(alphabet))
	prediction = model.predict(x, verbose=0)
	index = numpy.argmax(prediction)
	print(int_to_char[seed[0]], "->", int_to_char[index])
	seed = [index]
model.reset_states()

可以看到并不是如我们所预期的那样输出K -> L, L -> M, ...这样的序列,而是K -> B, B -> C, ...,说明重置state对输出结果产生了影响。

那么为了让上述的真正预测从K开始的字母表,我们需要给网络一些“热身”的数据:也就是先给从A到J的预先输入(从而使得LSTM的状态转移到对K进行预测的状态)。而这也表明,通过预先准备训练数据,我们可以实现和无状态LSTM相似的预测效果。以下是一种训练数据的形式:

-..---a -> b
-..--ab -> c
-..-abc -> d
-..abcd -> e
a..vwxy -> z

其中的输入使用空字符进行prefix padding,那么这也引发了使用变长输入序列作为输入来预测下一字符的问题。

LSTM with Variable-Length Input to One-Char Output

在上一节中,我们发现Keras的有状态的LSTM仅仅最终是体现了一个重复前n个字符的序列输出,而并没有帮助我们去学习通用的字母表预测模型。那么在本节中,我们将去寻找这样一个模型:学习的是随机的字母表子序列,同时预测时给出任意的字母或子序列时都可以预测当前字母表上的下一个字符。

那么首先,我们需要定义这一问题的分片方式。为了简化问题,我们设定给出的输入子序列的最大长度为5。在实际问题中,这个长度可以被设成10,25,26甚至更长(如果有loop back)。同时,我们需要设定生成多少个sample。在这里我们设置1000个:

# prepare the dataset of input to output pairs encoded as integers
num_inputs = 1000
max_len = 5
dataX = []
dataY = []
for i in range(num_inputs):
	start = numpy.random.randint(len(alphabet)-2)
	end = numpy.random.randint(start, min(start+max_len,len(alphabet)-1))
	sequence_in = alphabet[start:end+1]
	sequence_out = alphabet[end + 1]
	dataX.append([char_to_int[char] for char in sequence_in])
	dataY.append(char_to_int[sequence_out])
	print(sequence_in, '->', sequence_out)

运行上述的生成代码可以得到类似如下的训练数据:

PQRST -> U
W -> X
O -> P
OPQ -> R
IJKLM -> N
QRSTU -> V
ABCD -> E
X -> Y
GHIJ -> K

由于生成的输入序列长度在1到max_len不等,因此我们需要对其进行zero-padding,这里可以用keras提供的函数来完成:

X = pad_sequences(dataX, maxlen=max_len, dtype='float32')

使用新的输入数据对stateless LSTM进行训练后我们发现,模型可能目前暂时无法100%准确预测,它仍然可以保持在98%左右的正确率,模型本身也没有经过任何调参(例如更多训练、更大网络等等)

总结

Specifically, you learned:

  • How to develop a naive LSTM network for one-character to one-character prediction.
  • How to configure a naive LSTM to learn a sequence across time steps within a sample.
  • How to configure an LSTM to learn a sequence across samples by manually managing state.