使用 Keras 开发词语级自然语言模型

使用 LSTM 学习词语序列

Posted by Jason Brownlee on November 20, 2017

翻译自 MachineLearningMastery《How to Develop Word-Based Neural Language Models in Python with Keras》,作者 Jason Brownlee。小昇翻译,部分内容有删改。 语言建模包括根据已经存在的单词序列预测序列中的下一个单词。语言模型是许多自然语言处理模型(如机器翻译和语音识别)中的关键元素。在选择语言模型的框架时需要注意与语言模型的使用目的相匹配。

在本教程中,你将会发现,从童谣生成短序列时,语言模型框架的选择是如何影响模型的性能的。

通过本教程后,你将学习到:

  • 为特定的应用程序开发一个性能优异的词语级语言模型框架所面临的挑战。
  • 如何为词语级语言模型开发基于单个词,两个词和基于行的框架。
  • 如何使用语言模型生成序列。

让我们开始吧。

1

语言建模框架

统计语言模型从原始文本中学习,并且根据给定序列中已经存在的单词预测序列中下一个单词的概率。

语言模型是自然语言处理问题挑战(例如机器翻译和语音识别)中较大模型中的关键组件。 它们也可以作为独立模型开发,用于生成与源文本具有相同统计特性的新序列。

语言模型可以同时学习和预测一个单词。网络的训练包括提供单词的序列作为输入(每次处理一个单词),其中可以对每个输入序列进行预测和学习。同样在进行预测时,该过程以一个或多个单词为基础,然后将预测出的单词收集作为后续预测的输入,以便建立一个生成输出序列。因此,每个模型将涉及将源文本分解为输入和输出序列,使得模型可以学习如何预测单词。

有很多方法可以从源文本中为语言建模构造序列。

在本教程中,我们将探索在 Keras 深度学习库中开发词语级语言模型的三种不同方式。没有说哪个方法最好,它们只是框架不同,适用于不同的应用。

Jack and Jill 童谣

Jack and Jill 是一个简单的童谣。它由 4 行组成,如下所示:

Jack and Jill went up the hill

To fetch a pail of water

Jack fell down and broke his crown

And Jill came tumbling after

我们将用它作为我们的源文本来探索基于单词的语言模型的不同框架。我们可以在 Python 中定义这个文本:

# source text
data = """ Jack and Jill went up the hill\n
		To fetch a pail of water\n
		Jack fell down and broke his crown\n
		And Jill came tumbling after\n """

模型1:单词输入,单词输出序列

我们可以从一个非常简单的模型开始。

给定一个词语作为输入,模型将学习预测序列中的下一个词语。例如:

X,      y
Jack,   and
and,    Jill
Jill,   went
...

第一步是将文本编码化为整数。将源文本中的每个小写词语赋予一个唯一的整数,我们就可以把词语序列转换为整数序列。

Keras 提供了可用于执行此编码的 Tokenizer 类。首先,Tokenizer 通过源文本上建立从词语到唯一整数的映射。然后通过调用 texts_to_sequences() 函数将文本序列转换为整数序列。

# integer encode text
tokenizer = Tokenizer()
tokenizer.fit_on_texts([data])
encoded = tokenizer.texts_to_sequences([data])[0]

我们需要知道词汇表的大小,以便在模型中定义 word embedding 层,并且使用一个 one hot 编码来编码输出的词语。词汇表的大小可以通过访问训练好的 Tokenizer 中的 word_index 属性获得。

# determine the vocabulary size
vocab_size = len(tokenizer.word_index) + 1
print('Vocabulary Size: %d' % vocab_size)

在这个例子中,我们可以看到词汇的大小是 21 个单词。加 1 是因为我们需要将编码词语的整数指定为数组的索引,例如编码 1 到 21 的词语对应的数组编号为 0 到 21(22 个位置)。

接下来,我们需要创建一个词语序列,以一个词语作为输入,一个词语作为输出来训练模型。

# create word -> word sequences
sequences = list()
for i in range(1, len(encoded)):
	sequence = encoded[i-1:i+1]
	sequences.append(sequence)
print('Total Sequences: %d' % len(sequences))

运行这一块显示,我们总共有 24 组输入输出对来训练网络。

Total Sequences: 24

然后,我们可以将这些序列分解为输入 (X) 和输出元素 (y)。这很简单,因为我们只有两列数据。

# split into X and y elements
sequences = array(sequences)
X, y = sequences[:,0],sequences[:,1]

我们将训练我们的模型来预测词汇表中所有单词的概率分布。这意味着我们需要将输出元素从单个整数变成一个 one hot 编码,对于词汇表中的每个单词都有一个 0,对于实际单词来说,该值为 1。Keras 提供了 to_categorical() 函数,我们可以使用该函数将整数转换为一个 one hot 编码,同时指定类的数量作为词汇表大小。

# one hot encode outputs
y = to_categorical(y, num_classes=vocab_size)

我们现在准备好定义神经网络模型了。

该模型在输入层使用一个学习好的 word embedding。词汇表中的每个单词都有一个实值向量,每个单词向量都有一个指定的长度。在本例中,我们将使用一个 10 维投影。输入序列包含一个单词,因此 input_length = 1

该模型具有一个 50 单元的单个隐藏 LSTM 层。这远远超出了需要。输出层由对应词汇表中每个单词的神经元组成,并使用 softmax 激活函数来确保输出被标准化以看起来像一个概率。

# define model
model = Sequential()
model.add(Embedding(vocab_size, 10, input_length=1))
model.add(LSTM(50))
model.add(Dense(vocab_size, activation='softmax'))
print(model.summary())

网络的结构可以概括如下:

_________________________________________________________________
Layer (type)                 Output Shape              Param #
=================================================================
embedding_1 (Embedding)      (None, 1, 10)             220
_________________________________________________________________
lstm_1 (LSTM)                (None, 50)                12200
_________________________________________________________________
dense_1 (Dense)              (None, 22)                1122
=================================================================
Total params: 13,542
Trainable params: 13,542
Non-trainable params: 0
_________________________________________________________________

对于本教程中的每个示例,我们将使用相同的一般网络结构,对已学习的嵌入层进行微小更改。接下来,我们可以在编码的文本数据上编译和训练网络。 从技术上讲,我们正在建模一个多分类问题(预测词汇中的单词),因此使用分类交叉熵损失函数。我们使用高效的 Adam 实现梯度下降,并且在每轮训练结束时查看准确率。该模型训练 500 轮,甚至更多。

网络的配置没有按照实验进行调整;我们选择了超出需要的配置,以确保我们可以专注于语言模型的框架。

# compile network
model.compile(loss='categorical_crossentropy', optimizer='adam', metrics=['accuracy'])
# fit network
model.fit(X, y, epochs=500, verbose=2)

模型拟合后,我们通过词汇表中给定的单词来测试它,让模型预测下一个单词。 这里,我们通过对“Jack”进行编码并调用 model.predict_classes() 来获取预测单词的整数输出。然后查找词表将它汇映射到对应的词语。

# evaluate
in_text = 'Jack'
print(in_text)
encoded = tokenizer.texts_to_sequences([in_text])[0]
encoded = array(encoded)
yhat = model.predict_classes(encoded, verbose=0)
for word, index in tokenizer.word_index.items():
	if index == yhat:
		print(word)

这个过程可以重复多次,以建立一个生成的单词序列。为了使这更容易,我们将这个行为包含在一个函数中,我们可以通过传入我们的模型和种子词来调用这个函数。

# generate a sequence from the model
def generate_seq(model, tokenizer, seed_text, n_words):
	in_text, result = seed_text, seed_text
	# generate a fixed number of words
	for _ in range(n_words):
		# encode the text as integer
		encoded = tokenizer.texts_to_sequences([in_text])[0]
		encoded = array(encoded)
		# predict a word in the vocabulary
		yhat = model.predict_classes(encoded, verbose=0)
		# map predicted word index to word
		out_word = ''
		for word, index in tokenizer.word_index.items():
			if index == yhat:
				out_word = word
				break
		# append to input
		in_text, result = out_word, result + ' ' + out_word
	return result

我们可以把所有这些都放在一起。 下面提供了完整的代码清单。

from numpy import array
from keras.preprocessing.text import Tokenizer
from keras.utils import to_categorical
from keras.models import Sequential
from keras.layers import Dense
from keras.layers import LSTM
from keras.layers import Embedding

# generate a sequence from the model
def generate_seq(model, tokenizer, seed_text, n_words):
	in_text, result = seed_text, seed_text
	# generate a fixed number of words
	for _ in range(n_words):
		# encode the text as integer
		encoded = tokenizer.texts_to_sequences([in_text])[0]
		encoded = array(encoded)
		# predict a word in the vocabulary
		yhat = model.predict_classes(encoded, verbose=0)
		# map predicted word index to word
		out_word = ''
		for word, index in tokenizer.word_index.items():
			if index == yhat:
				out_word = word
				break
		# append to input
		in_text, result = out_word, result + ' ' + out_word
	return result

# source text
data = """ Jack and Jill went up the hill\n
		To fetch a pail of water\n
		Jack fell down and broke his crown\n
		And Jill came tumbling after\n """
# integer encode text
tokenizer = Tokenizer()
tokenizer.fit_on_texts([data])
encoded = tokenizer.texts_to_sequences([data])[0]
# determine the vocabulary size
vocab_size = len(tokenizer.word_index) + 1
print('Vocabulary Size: %d' % vocab_size)
# create word -> word sequences
sequences = list()
for i in range(1, len(encoded)):
	sequence = encoded[i-1:i+1]
	sequences.append(sequence)
print('Total Sequences: %d' % len(sequences))
# split into X and y elements
sequences = array(sequences)
X, y = sequences[:,0],sequences[:,1]
# one hot encode outputs
y = to_categorical(y, num_classes=vocab_size)
# define model
model = Sequential()
model.add(Embedding(vocab_size, 10, input_length=1))
model.add(LSTM(50))
model.add(Dense(vocab_size, activation='softmax'))
print(model.summary())
# compile network
model.compile(loss='categorical_crossentropy', optimizer='adam', metrics=['accuracy'])
# fit network
model.fit(X, y, epochs=500, verbose=2)
# evaluate
print(generate_seq(model, tokenizer, 'Jack', 6))

运行示例打印训练中每轮的损失和准确性。

...
Epoch 496/500
0s - loss: 0.2358 - acc: 0.8750
Epoch 497/500
0s - loss: 0.2355 - acc: 0.8750
Epoch 498/500
0s - loss: 0.2352 - acc: 0.8750
Epoch 499/500
0s - loss: 0.2349 - acc: 0.8750
Epoch 500/500
0s - loss: 0.2346 - acc: 0.8750

我们可以看到,模型并不记住源序列,可能是因为在输入序列中有一些不明确的地方,例如:

jack => and
jack => fell

等等。

在运行结束时,“Jack”被传入并预测产生新的序列。

Jack and jill came tumbling after down

这是一个很好的语言模型,但是没有充分利用 LSTM 处理输入序列和通过使用更广泛的上下文来消除模糊的成对序列的能力。

模型2:逐行序列

另一种方法是逐行分解源文本,然后将每行分解为一系列构建单词。例如:

X,                                  y
_, _, _, _, _, Jack,                and
_, _, _, _, Jack, and               Jill
_, _, _, Jack, and, Jill,           went
_, _, Jack, and, Jill, went,        up
_, Jack, and, Jill, went, up,       the
Jack, and, Jill, went, up, the,     hill

这种方法可以允许模型使用每一行的上下文在某些情况下(例如一个简单的单词输入单词输出模型会造成模糊性)帮助模型。

在这种情况下,这会带来跨行预测单词的误差,但是如果我们只对建模和生成文本行感兴趣,那么现在它可能没什么问题。

请注意,在这种表示中,我们需要填充序列以确保它们符合固定长度。 这是使用 Keras 时的要求。

首先,我们可以使用已经在源文本上训练好的 Tokenizer 来逐行创建整数序列。

# create line-based sequences
sequences = list()
for line in data.split('\n'):
	encoded = tokenizer.texts_to_sequences([line])[0]
	for i in range(1, len(encoded)):
		sequence = encoded[:i+1]
		sequences.append(sequence)
print('Total Sequences: %d' % len(sequences))

接下来,我们可以填充准备好的序列。 我们可以使用 Keras 中提供的 pad_sequences() 函数来做到这一点。 首先涉及找到最长的序列,然后用它作为填充所有其他序列的长度。

# pad input sequences
max_length = max([len(seq) for seq in sequences])
sequences = pad_sequences(sequences, maxlen=max_length, padding='pre')
print('Max Sequence Length: %d' % max_length)

接下来,我们可以将序列分成输入和输出元素,就像以前一样。

# split into input and output elements
sequences = array(sequences)
X, y = sequences[:,:-1],sequences[:,-1]
y = to_categorical(y, num_classes=vocab_size)

然后可以像以前那样定义模型,除了输入序列现在比单个单词长。具体来说,它们的长度是 max_length-1,因为当我们计算序列的最大长度时,它们包括输入和输出元素。

# define model
model = Sequential()
model.add(Embedding(vocab_size, 10, input_length=max_length-1))
model.add(LSTM(50))
model.add(Dense(vocab_size, activation='softmax'))
print(model.summary())
# compile network
model.compile(loss='categorical_crossentropy', optimizer='adam', metrics=['accuracy'])
# fit network
model.fit(X, y, epochs=500, verbose=2)

我们可以像以前一样使用该模型生成新的序列。 generate_seq() 函数可以被更新以通过将预测词语添加到每次迭代的输入词列表来构建输入序列。

# generate a sequence from a language model
def generate_seq(model, tokenizer, max_length, seed_text, n_words):
	in_text = seed_text
	# generate a fixed number of words
	for _ in range(n_words):
		# encode the text as integer
		encoded = tokenizer.texts_to_sequences([in_text])[0]
		# pre-pad sequences to a fixed length
		encoded = pad_sequences([encoded], maxlen=max_length, padding='pre')
		# predict probabilities for each word
		yhat = model.predict_classes(encoded, verbose=0)
		# map predicted word index to word
		out_word = ''
		for word, index in tokenizer.word_index.items():
			if index == yhat:
				out_word = word
				break
		# append to input
		in_text += ' ' + out_word
	return in_text

将所有这些结合在一起,下面提供了完整的代码示例。

from numpy import array
from keras.preprocessing.text import Tokenizer
from keras.utils import to_categorical
from keras.preprocessing.sequence import pad_sequences
from keras.models import Sequential
from keras.layers import Dense
from keras.layers import LSTM
from keras.layers import Embedding

# generate a sequence from a language model
def generate_seq(model, tokenizer, max_length, seed_text, n_words):
	in_text = seed_text
	# generate a fixed number of words
	for _ in range(n_words):
		# encode the text as integer
		encoded = tokenizer.texts_to_sequences([in_text])[0]
		# pre-pad sequences to a fixed length
		encoded = pad_sequences([encoded], maxlen=max_length, padding='pre')
		# predict probabilities for each word
		yhat = model.predict_classes(encoded, verbose=0)
		# map predicted word index to word
		out_word = ''
		for word, index in tokenizer.word_index.items():
			if index == yhat:
				out_word = word
				break
		# append to input
		in_text += ' ' + out_word
	return in_text

# source text
data = """ Jack and Jill went up the hill\n
		To fetch a pail of water\n
		Jack fell down and broke his crown\n
		And Jill came tumbling after\n """
# prepare the tokenizer on the source text
tokenizer = Tokenizer()
tokenizer.fit_on_texts([data])
# determine the vocabulary size
vocab_size = len(tokenizer.word_index) + 1
print('Vocabulary Size: %d' % vocab_size)
# create line-based sequences
sequences = list()
for line in data.split('\n'):
	encoded = tokenizer.texts_to_sequences([line])[0]
	for i in range(1, len(encoded)):
		sequence = encoded[:i+1]
		sequences.append(sequence)
print('Total Sequences: %d' % len(sequences))
# pad input sequences
max_length = max([len(seq) for seq in sequences])
sequences = pad_sequences(sequences, maxlen=max_length, padding='pre')
print('Max Sequence Length: %d' % max_length)
# split into input and output elements
sequences = array(sequences)
X, y = sequences[:,:-1],sequences[:,-1]
y = to_categorical(y, num_classes=vocab_size)
# define model
model = Sequential()
model.add(Embedding(vocab_size, 10, input_length=max_length-1))
model.add(LSTM(50))
model.add(Dense(vocab_size, activation='softmax'))
print(model.summary())
# compile network
model.compile(loss='categorical_crossentropy', optimizer='adam', metrics=['accuracy'])
# fit network
model.fit(X, y, epochs=500, verbose=2)
# evaluate model
print(generate_seq(model, tokenizer, max_length-1, 'Jack', 4))
print(generate_seq(model, tokenizer, max_length-1, 'Jill', 4))

运行该示例可以更好地拟合源数据。 增加的上下文使模型能够消除一些例子的歧义。

有两行文字以“Jack”开头,这对于网络来说可能仍然是个问题。

...
Epoch 496/500
0s - loss: 0.1039 - acc: 0.9524
Epoch 497/500
0s - loss: 0.1037 - acc: 0.9524
Epoch 498/500
0s - loss: 0.1035 - acc: 0.9524
Epoch 499/500
0s - loss: 0.1033 - acc: 0.9524
Epoch 500/500
0s - loss: 0.1032 - acc: 0.9524

在运行结束时,我们会生成两个不同种子词的序列:“Jack”和“Jill”。

第一个生成的行看起来不错,直接匹配源文本。 第二个有点奇怪。 这是可以解释的,因为网络只在输入序列中看到“Jill”,而不是在序列的开头,所以它强制输出使用单词“Jill”,即韵的最后一行。

这是一个很好的例子说明框架是如何导致更好的新行,但是它并不是一段好的输入行。

模型3:双词输入,单词输出序列

我们可以使用一个单词输入和整个句子输入的中间过程,输入一个单词的子序列。这将提供两个框架之间的权衡。

我们将使用 3 个词语作为输入来预测一个词语作为输出。 除了源序列数组中的偏移量不同之外,序列的准备与第一个例子非常相似,如下所示:

# encode 2 words -> 1 word
sequences = list()
for i in range(2, len(encoded)):
	sequence = encoded[i-2:i+1]
	sequences.append(sequence)

完整的例子如下所示:

from numpy import array
from keras.preprocessing.text import Tokenizer
from keras.utils import to_categorical
from keras.preprocessing.sequence import pad_sequences
from keras.models import Sequential
from keras.layers import Dense
from keras.layers import LSTM
from keras.layers import Embedding

# generate a sequence from a language model
def generate_seq(model, tokenizer, max_length, seed_text, n_words):
	in_text = seed_text
	# generate a fixed number of words
	for _ in range(n_words):
		# encode the text as integer
		encoded = tokenizer.texts_to_sequences([in_text])[0]
		# pre-pad sequences to a fixed length
		encoded = pad_sequences([encoded], maxlen=max_length, padding='pre')
		# predict probabilities for each word
		yhat = model.predict_classes(encoded, verbose=0)
		# map predicted word index to word
		out_word = ''
		for word, index in tokenizer.word_index.items():
			if index == yhat:
				out_word = word
				break
		# append to input
		in_text += ' ' + out_word
	return in_text

# source text
data = """ Jack and Jill went up the hill\n
		To fetch a pail of water\n
		Jack fell down and broke his crown\n
		And Jill came tumbling after\n """
# integer encode sequences of words
tokenizer = Tokenizer()
tokenizer.fit_on_texts([data])
encoded = tokenizer.texts_to_sequences([data])[0]
# retrieve vocabulary size
vocab_size = len(tokenizer.word_index) + 1
print('Vocabulary Size: %d' % vocab_size)
# encode 2 words -> 1 word
sequences = list()
for i in range(2, len(encoded)):
	sequence = encoded[i-2:i+1]
	sequences.append(sequence)
print('Total Sequences: %d' % len(sequences))
# pad sequences
max_length = max([len(seq) for seq in sequences])
sequences = pad_sequences(sequences, maxlen=max_length, padding='pre')
print('Max Sequence Length: %d' % max_length)
# split into input and output elements
sequences = array(sequences)
X, y = sequences[:,:-1],sequences[:,-1]
y = to_categorical(y, num_classes=vocab_size)
# define model
model = Sequential()
model.add(Embedding(vocab_size, 10, input_length=max_length-1))
model.add(LSTM(50))
model.add(Dense(vocab_size, activation='softmax'))
print(model.summary())
# compile network
model.compile(loss='categorical_crossentropy', optimizer='adam', metrics=['accuracy'])
# fit network
model.fit(X, y, epochs=500, verbose=2)
# evaluate model
print(generate_seq(model, tokenizer, max_length-1, 'Jack and', 5))
print(generate_seq(model, tokenizer, max_length-1, 'And Jill', 3))
print(generate_seq(model, tokenizer, max_length-1, 'fell down', 5))
print(generate_seq(model, tokenizer, max_length-1, 'pail of', 5))

再次运行该示例可以以 95% 左右的准确度对源文本进行很好的拟合。

...
Epoch 496/500
0s - loss: 0.0685 - acc: 0.9565
Epoch 497/500
0s - loss: 0.0685 - acc: 0.9565
Epoch 498/500
0s - loss: 0.0684 - acc: 0.9565
Epoch 499/500
0s - loss: 0.0684 - acc: 0.9565
Epoch 500/500
0s - loss: 0.0684 - acc: 0.9565

我们看看 4 个的例子,两个从行开始、两个从行中间开始。

Jack and jill went up the hill
And Jill went up the
fell down and broke his crown and
pail of water jack fell down and

第一个从行开始的例子生成正确,但第二个没有。 第二个例子是从第 4 行取出的样例,与第一行的上下文存在歧义。 也许对3个输入单词的进一步扩展会更好。也许进一步扩展为 3 个输入单词会更好。两个从行中间开始生成的例子正确生成,匹配源文本。

我们可以看到,如何选择语言模型框架必须与如何使用模型的要求一致。 通常使用语言模型时需要仔细设计,可能需要通过序列生成的测试来确认模型要求是否得到满足。

扩展阅读

翻译自 MachineLearningMastery《How to Develop Word-Based Neural Language Models in Python with Keras》,作者 Jason Brownlee。小昇翻译,部分内容有删改。