目录
随时间反向传播
实践
模型的使用
脏数据
“未知”词条的处理
字符级建模(英文)
生成聊天文章
进一步生成文本
文本生成的问题:内容不受控
其他记忆机制
更深的网络
尽管在序列数据中,循环神经网络为对各种语言关系建模(因此也可能是因果关系)提供了诸多便利,但是存在一个主要缺陷:当传递了两个词条后,前面词条几乎完全失去了它的作用。第一个节点对第三个节点(第一个时刻再过两个时刻后)的所有作用都将被中间时刻引入的新数据彻底抹平。这对网络的基本结构很重要,但却和人类语言中的常见情景相违背,实际中即使词条在句子中相隔很远,也可能是深度关联的。
如果名词和动词在序列中相隔多个时刻,那么在这个更长的句子中,循环神经网络很难理解主语和主动词之间的关系。对于新句子,循环网络可能会过于强调动词和主语之间的联系,而低估主语与谓语主动词之间的联系。也就是说,在这里失去了句子的主语和谓语动词之间的关联性。在循环网络中,当我们遍历每个句子时,权重会衰减得过快。
这里面临的挑战是建立一个网络,其在新句子中都能“领悟”到相同的核心思想。我们需要的是能够在整个输入序列中记住过去的方法。长短期记忆(LSTM)则是我们需要的一种方法。
长短期记忆网络的现代版本通常使用一种特殊的神经网络单元,称为门控循环单元(GRU)。门控循环单元可以有效地保持长、短期记忆,使LSTM能够更精确地处理长句子或文档。事实上,LSTM工作的非常好,它在几乎所有涉及时间序列、离散序列和NLP领域问题的应用中都取代了循环神经网络。
LSTM对于循环网络的每一层都引入了状态的概念。状态作为网络的记忆。可以把上述过程看成是在面向对象编程中为类添加属性。每个训练样本都会更新记忆状态的属性。
在LSTM中,管理存储在状态(记忆)中信息的规则就是经过训练的神经网络本身。它们可以通过训练来学习要记住什么,同时循环网络的其余部分会学习预测目标标签。随着记忆和状态的引入,我们可以开始学习依赖关系,这些依赖关系不仅可以扩展到一两个词条,甚至还可以扩展到每个数据样本的整体。有了这些长期依赖关系,就可以开始考虑超越文字本身的关于语言更深层次的东西。
有了LSTM,模型可以开始学习人类习以为常和在潜意识层面上处理的语言模式。有了这些模式,不仅可以更精确地预测样本类别,还可以开始使用这些语言模型生成新的文本。
如上图,就像在一般的循环网络中一样,记忆状态受输入的影响,同时也影响层的输出。但是,这种记忆状态在时间序列(句子或文档)的所有时刻会持续存在。因此,每个输入都会对记忆状态和隐藏层的输出产生影响。记忆状态的神奇之处在于,它在学习(使用标准的反向传播)需要记住的信息的同时,还学习输出信息。
首先我们展开一个标准的循环神经网络,并添加记忆单元,下图中,看起来与一般的循环神经网络相似。但是,除了向下一个时刻提供激活函数的输出,这里还添加了一个也经过网络各时刻的记忆状态。在每个时刻的迭代中,隐藏层循环单元都可以访问记忆单元。这个记忆单元的添加,以及与其交互的机制,使它与传统的神经网络层有很大的不同。LSTM层只是一个极为特例化的循环神经网络。
下面仔细看其中的一个元胞:现在,每个元胞不再是一系列输入权重和应用于这些权重的激活函数,而是稍微复杂的结构。与前面一样,到每一层(或元胞)的输入是当前时刻输入和前一个时刻输出的组合。当信息流入这个元胞而不是权重向量时,它现在需经过3个门:遗忘门、输入/候选门和输出门。(如下图:)
这些门中的每一个都由一个前馈网络层和一个激活函数构成,其中的前馈网络包含将要学习的一系列权重。从技术上讲,其中一个门由两个前向路径组成,因此在这个层中将有4组权重需要学习。权重和激活函数的旨在控制信息以不同数量流经元胞,同时也控制信息到达元胞状态(或记忆)的所有路径。
下面是开发示例,使用之前循环神经网络的例子,将SimpleRNN替换成LSTM:
maxlen=200
batch_size=32
embedding_dims=300
epochs=2
import numpy as np
X_train=pad_turnc(X_train,maxlen)
X_test=pad_turnc(X_test,maxlen)
X_train=np.reshape(X_train,(len(X_train),maxlen,embedding_dims))
y_train=np.array(y_train)
X_test=np.reshape(X_test,(len(X_test),maxlen,embedding_dims))
y_test=np.array(y_test)
from keras.api.models import Sequential
from keras.api.layers import Dense,Dropout,Flatten,SimpleRNN,LSTM
num_neurons=50
model=Sequential()
model.add(LSTM(
num_neurons,return_sequences=True,
input_shape=(maxlen,embedding_dims)
))
model.add(Dropout(0.2))
model.add(Flatten())
model.add(Dense(1,activation='sigmoid'))
model.compile('rmsprop','binary_crossentropy',metrics=['accuracy'])
print(model.summary())
虽然只修改了导入库的部分和其中一行Keras代码,但有很多事情都发生了改变。
从模型摘要中可以看到,对应相同的神经元,LSTM需要训练的参数比SimpleRNN中的要多的多。
在LSTM中,记忆将由一个向量来表示,这个向量与元胞中神经元的元素数量相同。上面的例子只有50个神经元,因此记忆单元将是一个由50个元素长的浮点数向量。
如上图,是门在网络中的运行情况。通过元胞的“旅程”不是一条单一的道路,它有多个分支。
我们从第一个样本中获取第一个词条,并将其300个元素的向量表示传递到第一个LSTM元胞。在进入元胞的过程中,数据的向量表示与前一个时刻的向量输出(第一个时刻的向量为0)拼接起来。在本例中,我们将得到一个长度为300+50个元素的向量。有时我们会看到向量后面加一个代表偏置项的1。因为偏置项在传递到激活函数之前总是将其相关权重乘以值1,所以有时会从输入向量表示中省略该输入,以使图更易理解。
在道路的第一个分叉处,我们将拼接起来的输入向量的副本传递到似乎会预示厄运的遗忘门,遗忘门的目标是:根据给定的输入,学习要遗忘元胞的多少记忆:
想要遗忘和想要记住的想法一样重要。作为人类,当我们从文本中获取某些信息时,例如名词是单数还是复数,我们想要保留这些信息,以便在之后的句子中能识别出与之匹配的正确的动词词形变换或形容词形式。在罗曼斯语序中,我们也必须识别一个名称的性别,然后在句子中使用它。但是输入序列会经常的从一个名词装换到另一个名词,因为输入序列可以由多个短语、句子甚至是文档组成。由于新的思想是在后面的语句中表达的,名词是复数的事实可能与后面不相关的文本没有任何关系。
A thinker sees his own actions as experiments and questions--as attempts to find out something. Success and failure are for him answers above all.
在这句中,动词“see”与名词“thinker”搭配,我们遇到的下一个主动动词时第二个句子中的“to be”。这时“be”动词变形成“are”,与“Success and failure”匹配。如果把它和句子中的第一个名词“thinker”搭配起来,就会使用错误的动词形式“is”,因此LSTM不仅必须对序列中的长期依赖关系建模,而且同样重要的是,还必须随着新依赖关系的出现而忘记长期依赖关系,这就是遗忘门的作用,在我们的记忆元胞中为相关的记忆腾出空间。
网络并不基于这类显式表示进行工作。网络试图找到一组权重,用它们乘以来自词条序列的输入,以便以最小化误差的方式更新记忆元胞和输出。令人惊讶的是它们竟然能工作,并且工作得很好。
遗忘门本身只是一个前馈网络,它由n个神经元组成,每个神经元的权重个数为m+n+1,在本例中,遗忘门由50个神经元,每个神经元有351(300+50+1)个权重。因我们希望遗忘门中的每个神经元的输出值在0到1之间,所以遗忘门的激活函数是Sigmoid函数。
遗忘门的输出向量是某种掩码(多孔掩码),它会遗忘记忆向量的某些元素,当遗忘门输出值接近于1时,对于该时刻,关联元素中更多的记忆知识会被保留,它越接近于0,遗忘的记忆知识就越多:
通过检查核对,上述模型的遗忘门能够主动忘记一些东西。我们最好学会记住一些新的事物,否则它很快就会被遗忘的。就会在“遗忘门”中原因,我们将使用一个小网络,根据两件事来学习需要假如多少记忆:到目前为止的输入和上一个时刻的输出。这是在候选门中发生的事情。
候选门内部有两个独立的神经元,它们做两件事:
- 决定哪些输入向量元素值得记住(类似于遗忘门中的掩码)
- 将记住的输入元素按规定路线放置到正确的记忆“槽”
候选门的第一部分是一个具有Sigmoid激活函数的神经元,其目标是学习要更新记忆向量的哪些输入值。这个神经元很像遗忘门中的掩码。
这个门的第二部分决定使用多大的值来更新记忆。第二部分使用一个tanh激活函数,它强制输出值在-1到1之间,这两个向量的输出是按元素相乘,然后将相乘得到的结果向量按元素加到记忆寄存器,从而记住新的细节:
这个门同时学习要提取哪些值以及这些特定值的大小。掩码和大小称为添加到记忆状态的值,与遗忘门一样,候选门会学习在将不适合信息添加到元胞的记忆之前屏蔽掉它们。
所以我们希望旧的、不相关的信息被遗忘,而新的信息能够被记住,然后我们就到达了元胞的最后一个门:输出门。
到目前为止,在穿越元胞的过程中,只向元胞的记忆写入了内容,现在是时候利用整个结构了。输出门接收输入(这仍然是t时刻元胞的输入和t-1时刻元胞的输出的拼接),并将其传递到输出门。
拼接的输入被传递到n个神经元的权重中,然后使用Sigmoid激活函数来传递一个n维浮点数向量,就像SimpleRNN的输出一样。但是不同于通过细胞壁来传递信息,在网络中,我们通过暂停部分输出来传递信息。
现在,够级的记忆结构已经准备完成了,它将对我们应该输出什么进行权衡,这将是通过使用记忆创建最后一个掩码来判断的。这个掩码也是一种门,但是要尽量避免使用门这个术语,吟哦我这个掩码没有任何学习过的参数,这有别于前面描述的3个门。
由记忆创建的掩码是对记忆状态的每个元素使用tanh函数,它提供了一个在-1和1之间的n维浮点数向量。然后将掩码向量与输入门第一步中计算的原始向量按元素相乘。得到的n维结果向量作为元胞在t时刻的整数输出最终从元胞中传出:
因此,在获得了t时刻的输入和t-1时刻的输出,以及输入序列中的所有细节之后,元胞的记忆就知道在t时刻,最后一个词输出什么是最重要的。
随时间反向传播
LSTM的学习与其他神经网络一样,也是通过反向传播算法。基本RNN易于受到梯度消失影响是因为在任意给定时刻,导数都是权重的一个决定因素,因此,当我们结合不同的学习率,往之前时刻(词条)传播时,经过几次迭代之后,权重(和学习率)可能会将梯度缩小到0。在反向传播结束时(相当于序列的开始),对权重的更新要么很小要么就为0,当权重稍大时也会出现类似的问题:梯度爆炸并不与网络增大成比例。
LSTM通过记忆状态避免了这个问题。每个门中的神经元都是通过它们输入进的函数的导数来更新的,即那些在前向传递时更新记忆状态的函数。所以在任何给定的时刻,当一般的链式法则反向应用于前向传播时,对神经元的更新只依赖于当前时刻和前一个时刻的记忆状态。这样,整个函数的误差在每个时刻都能“更接近”神经元,这就是所谓的误差传播。
实践
对之前的例子,将Keras的SimpleRNN层替换成Keras的LSTM层分类器的其他所有部分都将保持不变。
将序列填充/截断为200个词条:
#收集数据并做好准备
dataset=pre_process_data('C:\\Users\\86185\\PycharmProjects\\pythonProject\\NLP\\aclImdb\\train')
vectorized_data=tokenize_and_vectorize(dataset)
excepted=collect_excepted(dataset)
split_point=int(len(vectorized_data)*0.8)
#将数据划分为训练集和测试集
X_train=vectorized_data[:split_point]
y_train=excepted[:split_point]
X_test=vectorized_data[split_point:]
y_test=excepted[split_point:]
#声明超参数
maxlen=200
#在反向传播和更新权重之前需要传递给网络的样本数
batch_size=32
#需要传递进Convert的词条向量的长度
embedding_dims=300
epochs=2
import numpy as np
#进一步准备数据,使每个序列的长度相等
X_train=pad_turnc(X_train,maxlen)
X_test=pad_turnc(X_test,maxlen)
X_train=np.reshape(X_train,(len(X_train),maxlen,embedding_dims))
y_train=np.array(y_train)
X_test=np.reshape(X_test,(len(X_test),maxlen,embedding_dims))
y_test=np.array(y_test)
然后使用新的LSTM层构建模型:
from keras.api.models import Sequential
from keras.api.layers import Dense,Dropout,Flatten,SimpleRNN,LSTM
num_neurons=50
model=Sequential()
model.add(LSTM(
num_neurons,return_sequences=True,
input_shape=(maxlen,embedding_dims)
))
model.add(Dropout(0.2))
model.add(Flatten())
model.add(Dense(1,activation='sigmoid'))
model.compile('rmsprop','binary_crossentropy',metrics=['accuracy'])
print(model.summary())
训练并保存模型:
model.fit(X_train,y_train,
batch_size=batch_size,
epochs=epochs,
validation_data=(X_test,y_test))
model_structure=model.to_json()
with open("lstm_model1.json","w") as json_file:
json_file.write(model_structure)
model.save_weights("lstm_weight1.weights.h5")
与简单的RNN相比,验证精确率有巨大的提升。当词条之间的关系非常重要时,可以看到通过为模型提供记忆可以获得巨大的收益。该算法的优势在于,它可以学习看到的词条之间的关系。网络现在能够对这些关系建模,尤其是在提供的代价函数的上下文中。
模型的使用
有了训练好的模型,就可以开始尝试各种样本短语,并查看模型的表现。
尝试欺骗这个模型:在负向的语境中使用快乐的词,尝试长短语、短短语、矛盾短语:
重新加载LSTM模型:
from keras.api.models import model_from_json
with open("lstm_model1.json","r") as json_file:
json_string=json_file.read()
model=model_from_json(json_string)
model.load_weights('lstm_weight1.weights.h5')
使用模型预测一个样本:
sample_1="""
I hate that the dismal weather had me down for so long,when will it break! Ugh,when does happiness return? The sun is blinding and the puffy clouds are too thin. I can't wait for the weekend.
"""
vec_list=tokenize_and_vectorize([(1,sample_1)])
test_vec_list=pad_turnc(vec_list,maxlen=maxlen)
test_vec=np.reshape(test_vec_list,(len(test_vec_list),maxlen,embedding_dims))
print("Sample's sentinemnt ,1-pos,2-neg:{}".format(model.predict_tpye(test_vec)))
print("Raw output of sigmoid function:{}".format(model.predict(test_vec)))
当尝试各种可能性时,除离散的情感分类之外,还需要观察Sigmoid函数的原始输出。predict()方法在设置阈值之前显示原始的Sigmoid激活函数输出结果,因此可以看到0到1之间的一个连续值。任何输出值大于0.5的语句都归为正向类,小于0.5的都归为负向类。当尝试不同样本时,我们将了解模型对其预测的信息有多强,这将有助于分析我们的抽查结果。
要密切关注分类错误的样本(正向的和负向的)。如果Sigmoid输出接近0.5,就意味着对于这个样本,模型只是随机抛硬币。然后,我们可以查看为什么这个短语对模型来说是模糊的,但不要用人类的思维看待它的表现,而是从统计学的角度思考。试想模型“看到了”什么文档,这个被分类错误的样本中出现的词是否罕见?它们是在语料库中罕见还是为我们训练语言模型的语料库中罕见?该样本中的所有词是否都存在于模型的词汇表中?
通过这个过程来检查概率,并输入预测错误的数据,这将有助于我们建立机器学习的直觉,这样我们就可以在未来构建更好的NLP流水线。这是通过人脑“反向传播”来解决模型调优问题的办法。
脏数据
之前在同样的数据上,用不同的神经网络模型以完全相同的方式进行处理,虽然可以看到模型类型的变化以及在给定数据集上的性能表现,但也确实做出了一些损害数据完整的选择,或者说“弄脏了”数据。
将每个样本填充或截断到400(或其他特定数字)个词条对卷积神经网络非常重要,这样过滤器就可以“扫描”长度一致的向量。卷积网络也能输出一个长度一致的向量。对输出来说,保持维数的一致是很重要的,因为在链的末端,输出将进入一个全连接的前馈层,这个前馈层需要一个固定长度的向量作为输入。
类似的,循环神经网络的实现,包括简单的RNN和LSTM,都在努力构造一个固定长度的思想向量,我们可以将其传递到一个前馈层进行分类。一个对象的固定长度的向量表示,如思想向量,通常也被称为嵌入。因此,思想向量的大小是相同的,我们必须将网络展开至相同的时刻(词条)数。下面看网络占到为400个时刻的选择如何:
def test_len(data,maxlen):
total_len=truncated=exact=padded=0
for sample in data:
total_len=total_len+len(sample)
if len(sample)>maxlen:
truncated=truncated+1
elif len(sample)<maxlen:
padded=padded+1
else:
exact=exact+1
print('Paddes:{}'.format(padded))
print('Equal:{}'.format(exact))
print('Truncated:{}'.format(truncated))
print('Avg length:{}'.format(total_len/len(data)))
dataset=pre_process_data('C:\\Users\\86185\\PycharmProjects\\pythonProject\\NLP\\aclImdb\\train')
vectorized_data=tokenize_and_vectorize(dataset)
test_len(vectorized_data,400)
从结果来看,400是一个偏高的数字,现在将maxlen天回到140,并让LSTM再尝试一次:
import numpy as np
#声明超参数
maxlen=140
#在反向传播和更新权重之前需要传递给网络的样本数
batch_size=32
#需要传递进Convert的词条向量的长度
embedding_dims=300
epochs=2
num_neurons=50
#收集数据并做好准备
dataset=pre_process_data('C:\\Users\\86185\\PycharmProjects\\pythonProject\\NLP\\aclImdb\\train')
vectorized_data=tokenize_and_vectorize(dataset)
excepted=collect_excepted(dataset)
split_point=int(len(vectorized_data)*0.8)
#将数据划分为训练集和测试集
X_train=vectorized_data[:split_point]
y_train=excepted[:split_point]
X_test=vectorized_data[split_point:]
y_test=excepted[split_point:]
X_train=pad_turnc(X_train,maxlen)
X_test=pad_turnc(X_test,maxlen)
X_train=np.reshape(X_train,(len(X_train),maxlen,embedding_dims))
y_train=np.array(y_train)
X_test=np.reshape(X_test,(len(X_test),maxlen,embedding_dims))
y_test=np.array(y_test)
运行优化后的LSTM:
model=Sequential()
model.add(LSTM(
num_neurons,return_sequences=True,
input_shape=(maxlen,embedding_dims)
))
model.add(Dropout(0.2))
model.add(Flatten())
model.add(Dense(1,activation='sigmoid'))
model.compile('rmsprop','binary_crossentropy',metrics=['accuracy'])
print(model.summary())
训练一个更小的LSTM:
model.fit(X_train,y_train,
batch_size=batch_size,
epochs=epochs,
validation_data=(X_test,y_test))
model_structure=model.to_json()
with open("lstm_model2.json","w") as json_file:
json_file.write(model_structure)
model.save_weights("lstm_weight2.weights.h5")
这样训练的速度更快,验证准确率也几乎没有下降(74.2%对比75.2%)。只使用1/3时刻的样本,但训练时间将减少一大半以上。只有1/3的LSTM时刻需要计算,并且在前馈层中只有一小半的权重需要学习。但重要的是,反向传播每次只需走一半的距离。
但是,精确率变低了,因为在这两个模型中都包含一个dropout层。dropout层有助于防止过拟合,因此当我们减小模型的自由度或减少训练周期数时,验证精确率只会变得更低。
由于神经网络的强大功能以及它们学习复杂模式的能力,人们时常忘记,一个设计良好的神经网络善于学习丢弃噪声和系统偏差。我们把所有那些零向量都加进来,无意中给数据带来了很大的偏差。即使所有输入都为零向量,每个节点的偏置项元素也仍然会给它一些信号。但最终网络将学会完全忽略这些元素(会将偏置项元素的权重特别调整为零),从而专注于样本中包含有意义信息的部分。
所以优化后的LSTM虽然没能学到更多信息,但是它可以学得更快。但是,这里最重要的一点是要注意测试集样本的长度与训练集样本的长度有关。如果训练集是由数千个词条长的文档组成的,那么将只有3个词条长度的文档填充到1000个词条就可能无法得到一个精确的分类结果。反之,将一个1000个词条的文档截断到3个词条,对于在3个词条长的文档中训练的小模型同样也会造成困扰。
“未知”词条的处理
在数据处理方面,直接丢弃“未知”词条可能会带来麻烦。“未知”词条基本就是在预训练的Word2vec模型中找不到的词,其列表非常大。直接丢弃这么多数据,尤其是试图对词序列建模时,通常会造成很大的问题。
当词嵌入词汇中不包含“不(don't)”这个词时,类似与 I dont't like this movie 的句子,可能会变成 I like this movie。
Word2vec中忽略了许多词条,这些词条可能很重要,也可能不重要。丢弃这些未知词条是一种处理策略,但是还有其他策略可选。我们可以使用或训练一个词嵌入模型,该模型中的每一个词条都会对应一个向量,但是这样做会付出昂贵的代价。
有两种常见的方法可以在不增加计算需求的情况下提供更好的结果。这两种方法都涉及用新的向量表示替代未知的词条。第一种方法有些反直觉:对于没有由向量建模的每个词条,从现有词嵌入模型中随机选择一个向量并使用它。我们可以很容易的看出,这会使人类感到困惑。
模型会解决一些小问题,就像在之前的示例中不管它们一样。要记住我们并不是要显式地对训练集中的每个语句建模,我们的目的是在训练集中创建一种通用的语言模型。这样就会存在一些异常值,但我们不希望存在太多的异常值以至于描述主要语言模型时偏离模型。
第二种也是更常见的方法是,在重构原始输入时,用一个特定的词条替换词向量库中没有的所有词条,这个特定的词条通常称为“UNK”(未知词条)。这个向量本身要么是在对原始嵌入建模时选择的,要么是随机选择的(理想情况是远离空间中已知的向量)。
与填充一样,网络可以学习如何绕过这些未知的词条,并围绕它们得出自己的结论。
字符级建模(英文)
词是有含义的。用这些基本的模块来对自然语言建模看起来很自然,使用这些模型从原子结构的角度来描述含义、情感、意图和其他一切似乎也很自然。但是,英文中词不是原子性的,它们是由单位更小的词、词干、音素等组成,但更重要的一点是它们更基本的也都是由一系列字符构成的。
在对语言建模时,许多含义隐藏在字符里面。语音语调、头韵、韵律——如果我们把它们分解到字符级别,可以对所有这些建模。人类不需要分解得如此细致就可以为语言建模,但是从建模中产生的定义非常复杂,并不容易传授给机器,这就是为什么要给字符建模。对于我们见过的字符,当我们查看文本中哪个字符出现在哪个字符后面时,可以发现文本中的许多固有的模式。
在这个范式中,空格、逗号、句号都变成了另一个字符,当网络从序列中学习含义时,如果把它们分解成单个的字符,模型就会被迫的来寻找这些更低层级的模式。当注意到有一些音节后面时重复的,这可能是押韵的后缀,可能是一种带有意义的模式,或许代表着愉快或嘲笑的情感,随着学习了足够大的训练集,这些模式开始显现。因为英语中不同的字母要比词要少得多,所以需要关心的输入向量相对也就少了。
然而,在字符级别训练模型仍是很棘手的。在字符级别发现的模式和长期以来关系在不同的语调中可能会有很大的差异,我们或许可以找到这些模式,但它们可能不具有泛化性。
下面在同一样本数据集中,在字符级别上尝试LSTM。首先需要用不通过的方式处理数据,获取数据并通过标签排序:
#收集数据并做好准备
dataset=pre_process_data('C:\\Users\\86185\\PycharmProjects\\pythonProject\\NLP\\aclImdb\\train')
excepted=collect_excepted(dataset)
然后需要决定将网络展开至多远,所以需要观察数据样本中平均有多少个字符:
def avg_len(data):
total_len=0
for sample in data:
total_len=total_len+len(sample[1])
return total_len/len(data)
print(avg_len(dataset))
可以看到,平均字符数是1325,这代表网络将要被展开的很远,需要等很长时间才能训练完成这个模型。
接下来,清除一些与文本的自然语言无关的词条数据。此函数过滤出了数据集中HTML标签中的一些无用字符。实际上,数据应该被更彻底地清洗:
def clean_data(data):
new_data=[]
VALID='abcdefghijklmnopqrstuvwxyz0123456789"\'?!.,:; '
for sample in data:
new_sample=[]
for char in sample[1].lower():
if char in VALID:
new_sample.append(char)
else:
new_sample.append('UNK')
new_data.append(new_sample)
return new_data
listified_data=clean_data(dataset)
这里使用了‘UNK’表示列表中所有不出现在VALID列表中的单个字符。
然后,将样本填充或截断到指定的maxlen长度。这里,我们引入了另一用于填充的“单字符”——‘PAD’:
def char_pad_trunc(data,maxlen=1500):
new_dataset=[]
for sample in data:
if len(sample) > maxlen:
new_data=sample[:maxlen]
elif len(sample) < maxlen:
pads=maxlen-len(sample)
new_data=sample+['PAD']*pads
else:
new_data=sample
new_dataset.append(new_data)
return new_dataset
这里选择1500作为maxlen来获得比平均样本长度略多的样本数据,但是应该尽量避免使用会带来过多噪声的PAD。考虑词的长度会帮助我们做出选择。在固定的字符长度下,与完全由简单的单音节词组成的样本相比,具有大量长词的样本可能被欠采样。与所有机器学习问题一样,了解数据集和它的输入、输出非常重要。
下面,使用独热编码字符,而不是使用Word2vec。因此,需要创建一个词条(字符)字典,该字典被映射到一个整数索引。我们还将创建一个字典来映射相反的内容:
def create_dicts(data):
chars=set()
for sample in data:
chars.update(set(sample))
char_indices=dict((c,i) for i,c in enumerate(chars))
indices_char=dict((i,c) for i,c in enumerate(chars))
return char_indices,indices_char
然后可以使用该字典来建立索引的输入向量,而不是词条本身:
def onehot_encode(dataset,char_indices,maxlen=1500):
X=np.zeros((len(dataset),maxlen,len(char_indices.keys())))
for i,sentence in enumerate(dataset):
for t,char in enumerate(sentence):
X[i,t,char_indices[char]]=1
return X
加载和预处理IMDB数据:
dataset=pre_process_data('C:\\Users\\86185\\PycharmProjects\\pythonProject\\NLP\\aclImdb\\train')
excepted=collect_excepted(dataset)
listified_data=clean_data(dataset)
common_length_data=char_pad_trunc(listified_data,maxlen=1500)
char_indices,indices_char=create_dicts(common_length_data)
encode_data=onehot_encode(common_length_data,char_indices,1500)
将数据集划分为训练集和测试集,比例分别占80%、20%:
split_point=int(len(encode_data)*0.8)
X_train=encode_data[:split_point]
y_train=excepted[:split_point]
X_test=encode_data[split_point:]
y_test=excepted[split_point:]
建立基于字符的LSTM网络:
from keras.api.models import Sequential
from keras.api.layers import Dense,Dropout,Embedding,Flatten,LSTM
num_neurons=40
maxlen=1500
model=Sequential()
model.add(LSTM(
num_neurons,
return_sequences=True,
input_shape=(maxlen,len(char_indices.keys()))
))
model.add(Dropout(0.2))
model.add(Flatten())
model.add(Dense(1,activation='sigmoid'))
model.compile('rmsprop','binary_crossentropy',metrics=['accuracy'])
print(model.summary())
这样,在构建LSTM模型方面就变得更高效。这个最新的基于字符的模型只需要训练7.4万个参数,而优化后的基于词的LSTM需要训练8万个参数。这个简单的模型应该训练得更快,并能更好地推广到新文本,因为它具有较小的过拟合自由度。
现在尝试一下,看基于字符的LSTM模型需要提供什么参数:
保存模型:
model_structure=model.to_json()
with open("char_lstm_model3.json","w") as json_file:
json_file.write(model_structure)
model.save_weights("char_lstm_weights3.weights.h5")
最终的到的是85%的训练集精确率和59%的验证精确率,说明模型出现了过拟合。模型开始缓慢地学习训练集的情感。虽然耗时是很久的,但验证精确率却没有比随机猜测提高很多,在后期的训练周期中,甚至可能变得更差。
这可能是很多情况导致的,对数据集来说,模型可能过于强大,这意味着它有足够的参数,可以为训练集的20000个样本特有的模式进行建模,但对于关注情感的通用语言模型则没有用处。如果LSTM网络层的dropout百分率更高或神经元更少,这一问题可能会得到缓解。如果没有认定模型定义的参数量过于庞大,那么更多的标注数据也会有所帮助。但是高质量的标注数据通常是最难获得的。
与词级LSTM模型,甚至卷积神经网络相比,这个回报有限的模型在硬件和时间上有巨大的开销。这是因为如果有更多、更广泛的数据集,则字符级模型会非常擅长对语言建模。或者说,在提供一套专项领域的训练集时,它能为一种特定的语言类型建模。
生成聊天文章
如果能以特定的“风格”或“看法”生成新的文本,我们肯定会拥有一个非常有趣的聊天机器人。当然,能够生成具有给定风格的新文本并不能保证聊天机器人会谈论我们希望它谈论的事情。但是,我们可以使用这种方法在给定的一组参数中生成大量文本(例如相应某类用户的风格),然后对于一个给定的查询,可以基于这个新的、更大的文本语料库索引和搜索最有可能的回复。
就像一个马尔科夫链,根据出现在1-gram、2-gram或者n-gram后的词,预测序列将要出现的下一个词,LSTM模型也可以基于它刚刚看到的词,学习预测下一个词出现的频率。这就是记忆带来的好处。马尔科夫链只使用n-gram以及在n-gram之后出现的词的频率信息来进行搜索。RNN模型也做了类似的事情,它基于前几项的下一项的信息进行编码,但是有了LSTM的记忆状态,模型可以在更大的上下文中判断最合适的下一项。而且我们还可以根据之前文档出现过的字符来预测下一个字符。这种粒度级别超出了基本的马尔科夫链。
LSTM模型学习的真正核心是LSTM元胞本身,但我们确实在围绕特定分类任务的成功和失败来训练模型,这种方法不一定能帮助我们的模型学习语言的一般表现形式,我们训练它只关注那些包含了强烈情感的序列。
因此,我们应该使用训练样本本身,而不是使用训练集的情感标签来作为学习的目标。对于样本中的每个词条,我们希望LSTM模型能学会预测下一个词条(下左图)。这与词向量嵌入方法非常相似,只是我们将通过2-gram而不是skip-gram来训练网络。以这种方法训练的词生成器模型可以很好的工作,我们使用这个方法可以直接获得字符级别的表示(下右图)。
这里将关心每一个时刻的输出,而不是最后一个时刻输出得到的思想向量。误差仍然会由每一个时刻随时间反向传播回到开始时刻,但是误差是有每个时刻级别的输出确定的。在某种意义上,其他LSTM分类器中也是这样的,但在其他分类器中,直到序列末尾才确定误差。只有在序列的末尾,才有一个聚合的输出用来输入链末端的前馈层。尽管如此,反向传播仍然以相同的方式工作者——通过调整序列末尾的所有权来聚合误差。
所以要做的第一件事就是调整训练集的标签。输出向量对比的不是给定的分类标签,而是序列中下一个字符的独热编码。
我们可以回到更简单的模型,这次不是试图预测每个后续字符,而是预测给定序列的下一个字符。如果去掉关键词参数return_sequences=True,这与其他LSTM层相同,这样做将使LSTM模型聚焦于序列中最后一个时刻的返回值:
进一步生成文本
简单的字符级建模是通向更复杂模型的必经之路——这些模型不仅可以获取拼写等细节,还可以获取语法和标点符号。而且当这些模型学习这些语法细节时,它们也开始学习文本的节奏和韵律。
Keras文档提供了一个很好的例子。对于寻找像音调和词选择这样深奥的概念,电影评论数据集有两个难以解决的问题:它是多样化的,由许多作者写成,每个作者都有自己的写作风格和个性,找到它们之间的共同点十分困难;对于学习基于字符的通用语言模型,它是一个非常小的数据集。为了解决上述问题,我们需要一个在样本风格和音调更一致的数据集,或者一个大得多的数据集,这里选择前者。这里选择莎士比亚作品的样本:
from nltk.corpus import gutenberg
print(gutenberg.fileids())
上面给出的是莎士比亚的3部戏剧。我们将获取它们的资源,并将它们拼接到一个大字符串中:
text=""
for txt in gutenberg.fileids():
if "shakespeare" in txt:
text=text+gutenberg.raw(txt).lower()
chars=sorted(list(set(text)))
char_indices=dict((c,i) for i,c in enumerate(chars))
indices_char=dict((i,c) for i,c in enumerate(chars))
print('长度:{},chars:{}'.format(len(text),len(chars)))
格式样式:
print(text[:500])
接下来,把原始文本分层数据样本,每个样本都有固定的maxlen个字符。为了增加数据集的大小并关注它们一致的语言模式,Keras示例过采样了数据并细分为半冗余块。从开头处取40个字符,从开头移到第3个字符,从那里取40个字符,移到第6个字符……以此类推。
要记住这个模型的目标是在给定前40个字符的情况下,学习预测任意序列中的第41个字符。因此我们将构建一组半冗余序列的训练集,每个序列有40个字符长:
maxlen=40
step=3
sentences=[]
next_chars=[]
for i in range(0,len(text)-maxlen,step):
sentences.append(text[i:i+maxlen])
next_chars.append(text[i+maxlen])
print('nb sequences:',len(sentences))
所以现在拥有了123168个训练样本和在它们之后的字符,即模型的目标标签:
X=np.zeros((len(sentences),maxlen,len(chars)),dtype=np.bool)
y=np.zeros((len(sentences),len(chars)),dtype=np.bool)
for i,sentence in enumerate(sentences):
for t,char in enumerate(sentence):
X[i,t,char_indices[char]]=1
y[i,char_indices[next_chars[i]]]=1
然后,在数据集中对每个样本的每个字符进行独热编码,并将其存储在列表X中,我们还会将独热编码的“答案”存储在列表y中,然后构造模型:
from keras.api.models import Sequential
from keras.api.layers import Dense,Activation
from keras.api.layers import LSTM
from keras.api.optimizers import RMSprop
model=Sequential()
model.add(LSTM(
128,
input_shape=(maxlen,len(chars))
))
model.add(Dense(len(chars)))
model.add(Activation('softmax'))
optimizer=RMSprop(learning_rate=0.01)
model.compile(loss='categorical_crossentropy',optimizer=optimizer)
print(model.summary())
这和之前看起来有些不同,在本例中,LSTM元胞隐藏层中的num_neuron为128。虽然128比分类器中使用的要多很多,但是我们是在试图为在复现给定文本的音调中更复杂的行为进行建模。然后,优化器是通过一个变量定义的,这也是到目前为止一直在使用的。因为学习率参数从其默认值(0.001)调整为现在的值,这里将其分开。值得注意的是RMSProp的工作原理是通过使用“该权重最近梯度大小的平均值”,来调整学习率以更新各个权重。
下一个不同之处是我们试图最小化的损失函数。到目前为止,它一直是binary_crossentropy。我们只需要确定单个神经元的阈值,但是在这里,我们已经将最后一次中的Dense(1)替换成Dense(len(chars))。因此,网络在每个时刻的输出将是一个50维的向量len(chars)==50。我们将使用softmax作为激活函数,因此输出向量将等效为整个50维向量上的概率分布(该向量中的值之和总是为1)。使用categorical_crossentropy试图使结果的概率分布与独热编码预期字符之间的差异最小化。
最后一个主要的变化是没有dropout。因为我们要对这个数据集进行特定的建模,而没有兴趣将其推广到其他问题,所以过拟合不仅是可以的,还是理想的:
epochs=6
batch_size=128
model_structure=model.to_json()
with open("shake_lstm_model.json","w") as json_file:
json_file.write(model_structure)
for i in range(5):
model.fit(
X,y,
batch_size=batch_size,
epochs=epochs
)
model.save_weights("shake_lstm_weights_{}.weights.h5".format(i))
这里设置每隔6个训练周期保存模型一次,并继续训练。如果它的损失不再减少,就不需要继续训练,那么我们可以安全的停止这个过程,并在之前的几个训练周期里保存好权重集。我们发现需要20-30个训练周期才能从这个数据集中获得还不错的结果。我们可以查看扩展数据集。莎士比亚的作品是可以公开获取的,如果是从不同的来源获得的,则需要通过适当的预处理来确保作品的一致性。还好基于字符的模型不比担心分词器和分句器的不一致,但是保持字符一致性的方法选择会很重要。
因为输出向量是描述50个可能的输出字符丧的概率分布的50维向量,所以可以从该分布中采样。Keras示例有一个辅助函数来完成这一任务:
import random
def sample(preds,temperature=1.0):
preds=np.asarray(preds).astype('float64')
preds=np.log(preds)/temperature
exp_preds=np.exp(preds)
preds=exp_preds/np.sum(exp_preds)
probas=np.random.multinomial(1,preds,1)
return np.argmax(probas)
由于网络的最后一次是softmax,因此输出向量将是网络所有可能输出的概率分布。通过查看输出向量中的最大值,我们可以看到网络认为出现概率最高的下一个字符。用更清楚的话来说,输出向量最大值的索引(该值介于0到1 之间)将与预期词条的独热编码的索引相关联。
但是在这里,我们并不是要精确地重新创建输入文本,而是要重新创建接下来可能出现的文本,就像在马尔科夫链中一样,下一个词条是根据下一个词条的概率随机选择的,而不是最常出现的下一个词条。
log函数除以temperature的效果是使概率分布变平(temperature>1)或变尖(temperature<1)。因此,小于1的temperature(或称调用参数中的多样性)倾向于试图更严格的重新创建原始文本。而大于temperature会产生更多样化的结果,但是随着分布变平,学习到的模式开始被遗忘,我们就会回到胡言乱语的状态。多样性越高就会越有趣。
numpy随机函数multinomial(num_samples,probability_list,size)将从分布中生成num_samples个样本,其可能的结果由probability_list描述,它将输出一个长度为size的列表,该列表等于实验运行的次数。在这种情况下,我们只从概率分布中抽取一次,我们只需要一个样本。
当我们进行预测时,Keras示例有一个遍历不同的temperature值的循环,因此每个预测都将看到一系列不同的输出,而这些输出基于sample函数从概率分布进行采样所使用的temperature值:
import sys
start_index=random.randint(0,len(text)-maxlen-1)
for diversity in [0.2,0.5,1.0]:
print()
print('---- diversity',diversity)
generated=""
sentence=text[start_index:start_index+maxlen]
generated=generated+sentence
print('---- Generateing with seed: "'+sentence+'"')
sys.stdout.write(generated)
for i in range(400):
x=np.zeros((1,maxlen,len(chars)))
for t,char in enumerate(sentence):
x[0,t,char_indices[char]]=1
preds=model.predict(x,verbose=0)[0]
next_index=sample(preds,diversity)
next_char=indices_chat[next_index]
generated=generated+next_char
sentence=sentence[1:]+next_char
sys.stdout.write(next_char)
sys.stdout.flush()
print()
从源文本中提取40(maxlen)个字符的随机块,并预测接下来会出现什么字符。然后将预测的字符追加到输入句子中,删除第一个字符,并肩这40个字符作为新的输入再次预测。每次将预测的字符写入控制台并执行flush()函数,以便字符即刻进入控制台。如果预测的字符恰好是一个换行符,那么这一行的文本就结束了,但是生成器将继续工作,从它刚刚输出的前40个字符预测下一行。
我们将会得到像下面这样的结果:
---- diversity 0.2
---- Generateing with seed: "cb. well then,
now haue you consider'd o"
cb. well then,
now haue you consider'd oobu0,h'exg.9t;e5m;&luk.g3y];!w[rw4?q9')ym2?w92ææebv?t2n&g q'o,3&)qx?s3r'tv43wlsi z1æs-r'fdx,p06[w-[ruoæxs 1rlkwcq2pt,a)c]wazoh3(? m:fo:ur,oe09ei49z229aunnjæ[g3s6![00rskx9;jm;al'urxe2j-æhq1d)fh!æ:3?exmæl]tbk4r,o
xbk1m: '4)6sb3 w5
w.s;,a5sl'yzg[s55yn5r5(ca1f?,i2b&qa5h]jk3mbr49
y]s?&[w)mtu u??'g?'-g&yf5 bsr,x0,y!em,y,obi(pe60-9kn-
b3ez14(j549z.ki
æ43mdi&uin6t
0[mayu5&xu9yle[vo.5'x-0yntmk3!]f.
fx,9m&
---- diversity 0.5
---- Generateing with seed: "cb. well then,
now haue you consider'd o"
cb. well then,
now haue you consider'd os,)9
:maqvvta.3pp3r1dq;5x'mscqrase,d[ilm])w05et0o3scv!e,0,smi(p;]'er.(zx! ci h?wu'fk-?x,jk-4z
t3æ)z2ts](.vg- dnq]z
4yelj(qos]4y?)).3;w,syk('?:c440dk':5?4b!ni&s5](r]l-iph4qcni-ewp.1fkrbi&5):cb9)[l0-(,l9
!fzn&[up25q.xu;:j
6r)4
9)9.?e.o)2a;?!6æ;6ir](ne5d0cwb:)-tærps05y)td]mu!?(: rdylh
ca0&c;[w;bhfdwh614'd(iz[wgæ9j3i:[h9yr,1r2rk1;l)em)!;5
æw2tm.p0[z)-jhbew3-9 -ei0s([!cm,g(c?bf;[b42d!]l[qyq9rt;c;4n99x6
---- diversity 1.0
---- Generateing with seed: "cb. well then,
now haue you consider'd o"
cb. well then,
now haue you consider'd oew(a
.q;lppm&gil:iv0:n1;nk-y.4'f.geq4'yysp'w[dtrddt-00e c w)ybcl44)udo-u-l
)ciyz[05490nxf[u!'mc!&!fp,b[g2!,;q1me1a?:h(r.m'æa&aj!h0[r96!&-isi
ao:n!6v,
lg ,-rmihcf]i2:0rf9 ]5rc4d,i'65x
t:n:)dnlmu)hy6slmrv1n3bx-?kekf?t.tx3wvt3
u56]n-bbs;0;':]3o1
(.u4
4[qqyjx[i?qiæ
jw[wk-a36b
s.3qtgzbmu]py920.c4a4
egp)-j,udba-æe.ue3wd
i[2niri4mw;v]rwrie;hr'yaæ.6;m,r-,ojcss;v-gs)ohv)09pf;q ]][q.q6r q:p;6c3h1cku]52l sk
文本生成的问题:内容不受控
我们仅仅基于示例文本来生成新文本,并且我们还可以学习如何从示例文本中提取出其写作风格。但是,我们却无法控制生成的文本内容,这有些反直觉。上下文内容受限于原始数据,如果没有其他内容,那么将会限制模型的词汇量。但还是,如果给定一个输入,我们就可以按照我们认为原作者或作者可能会说的话进行训练。从这种模型中能够期待的最好结果是它们的说话方式,特别是它们怎样从一个种子句开始把话说完。这个种子句不一定来自训练文本本身。因为这个模型是针对字符进行训练的,所以我们可以使用全新的词作为种子,并获得有趣的结果。
其他记忆机制
LSTM是循环神经网络基本概念的一种扩展,同样也存在其他各种形式的扩展。所有这些扩展无外乎都是元胞内门的数量或运算的一些微调。例如,门控循环单元将遗忘门和候选门中的候选选择分支组合成一个更新门。这个门减少了需要学习的参数数量,并且已经被证实可以与标准LSTM相媲美,同时计算开销也要小得多。Keras提供了一个GRU层,我们可以像使用LSTM一样使用它:
from keras.api.models import Sequential
from keras.api.layers import GRU
model=Sequential()
model.add(GRU(num_neurons,return_sequences=True,
input_shape=X[0].shape))
另一种技术是使用具有窥视孔连接的LSTM。Keras没有直接实现的代码,但是晚上的几个示例通过扩展Keras的LSTM类来实现这一点。其思想是,标准LSTM元胞中的每个门都可以直接访问当前记忆状态,并将其作为输入的一部分。所有的门包含与记忆状态相同维度的额外权重。然后,每个门的输入是该时刻元胞的输入、前一个时刻元胞的输出和记忆状态本身的拼装。在时间序列数据中,这使得时间序列事件建模更加精确。虽然它们并没有专门在NLP领域工作,但这个概念在这里也是有效的。
更深的网络
将记忆单元看作是对名词/动词对货句子与句子之间动词时态引用的特点表示进行编码非常方便,但这并不是实际发行的事情。假设训练顺利的话,这不过刚好是网络学习的语言模式的一个副产品。与所有神经网络一样,分层允许模型在训练数据中形成更复杂的模式表示。我们也可以轻松的堆叠LSTM层:
训练堆叠层在计算上代价非常昂贵,但在Keras中把它们堆叠起来只需要几秒:
from keras.api.models import Sequential
from keras.api.layers import LSTM
model=Sequential()
model.add(LSTM(
num_neurons,return_sequences=True,
input_shape=X[0].shape
))
model.add(LSTM(num_neurons_2,return_sequences=True))
要注意的是,加入要正确的构建模型,需要在第一层和中间层使用参数return_sequences=True。这个要求是有意义的,因为每个时刻的输出都需要作为下一层时刻的输入。
但是,要记住创建一个能够表示比训练数据中存在的更复杂的关系的模型可能会导致奇怪的结果。简单地在模型上叠加层,虽然很有趣,但很少是构建最有用的模型的解决方案。