(本期教程需要翻译的内容实在是太多了,将其分割成两期,本期主要讲理论和模型创建,下期主要讲训练、测试、优化等)
背景和简介
本教程将带你过一遍多对多神经网络基础,以及如何在CNTK中实现它。具体来说,我们将实现一个多对多模型用来实现字音转换。我们首先会介绍多对多网络的基本理论、解释数据细节以及如何下载数据。
Andrej Karpathy对常用的物种神经网络结构模式有一个很好的可视化表达,如图:
本教程中,我们将讨论第四个模式:输入和输出的大小不一定要一样的多对多神经网络,也叫sequence-to-sequence networks(其实文章标题应该是这个,但是我不知道怎么翻译)。输入方式一个动态长度的序列,输出方也是一个动态长度的序列。这是对我们之前用于预测类别的多对一模型的扩展,现在我们要预测类别列表。
quence-to-sequence networks的应用场景几乎是无限的。他非常适合机器翻译(比如英语句子是输入序列,法语句子是输出序列);自动提取文本概要(比如完整文章是输入序列,总结概要是输出序列);词语发音模型(比如字符是输入序列,发音是输出序列);以及解析语法分析树的生成(比如规则文本是输入序列,语法树是输出序列)。
基本理论
sequence-to-sequence模型由两个主要部分组成:(1)一个编码器;(2)一个解码器。编码器和解码器都是递归网络层,他们可以由vanilla神经网络实现,也可以由LSTM或者GRU实现(本教程中使用LSTM)。在基本sequence-to-sequence模型中,编码器将文本处理成解码器可用的固定表达方式。解码器使用一些机制将处理过的信息解码成一个输出序列。解码器是一个由编码器用强烈语境增强的语言模,所以解码器产生的所有样本都会再次返回到解码器中用于语境信息。在一个从英语到德语的翻译任务中,大部分基本构架看起来都是这样。
基本的sequence-to-sequence网络在实现解码RNN时会使用编码器的最后隐藏状态作为解码器的初始状态,以此实现信息从编码器传递到解码器。之后的输入数据会是一个“序列开始”的标记(在上图中是)用来指示解码器开始生成输出序列。然后无论解码器生成什么单词(或者音符或者图片),都会在下一步返回到输入序列中。解码器持续产生输出序列,知道遇到“序列结束”标记(上图中的)。
基本sequence-to-sequence神经网络的一个更复杂和强大的版本是使用注意力模型(Attention Model,AM)。虽然上述模型工作良好,不过如果输入的序列太长时,就需要进行分解。在每步运算中,隐藏状态h会根据最新的信息更新,因此在每次处理之后h都会被削弱。进一步说,即使在相对较短的序列中,最后一次运算会使用最后一次的信息,导致语言向量多多稍稍会偏向于最后一个词。为了解决这个问题,我们使用“注意力”机制,让解码器不仅仅能够访问输入数据的隐藏状态,还能够了解在解码的每步中权重最大的隐藏向量。在本教程中我们将实现一个既可以没有注意力模型运行,也可以加入注意力模型运行的sequence-to-sequence神经网络。
上图所示的注意力层保存解码器中隐藏状态的当前值、编码器隐藏状态的所有值以及计算模型使用的隐藏状态的增强版本。更具体的说,编码器隐藏状态的贡献值代表了他的所有隐藏状态的加权和,其中最高的权重对应了解码器在决定下一个词是考虑的增强隐藏状态的最大贡献以及最重要的隐藏状态。
问题:字音转换
字音转换问题是将单词中的字母作为输入序列,然后输出对应的音的转换任务。换句话说,这个系统用于对给定的单词生成清晰的发音。
例子
字母的字素转换成对应的音素:
字素:| T | A | N | G | E | R |
音素:| ~T | ~AE | ~NG | ~ER |
任务和模型结构
如上所述,我们要解决的问题是创建一个模型,输入一些序列,然后根据输入的内容生成和输出序列。模型的工作就是将输出的序列与输入的序列映射起来。编码器的工作是设法将输入数据生成一个合适的表达方式,能够让解码器输出较好的结果。LSTM可以用于编码器和解码器。
注意LSTM是众多可以用于实现RNN的模块中的普通一员,这些模块都是在递归的每步中执行的代码。在CNTK的层级库中,有三个内建模块:(vanilla)RNN、GRU、以及LSTM。不同的程序的输入数据有点不同,他们在不同的任务和神经网络中也有自己的优势和劣势。为了让这些模块循环执行网络中的所有要素,我们创建一个整体的递归。这种操作将RNN层展开成几步操作。
导入CNTK和其他用得着的模块
CNTK是一个Python模块,包含几个子模块比如io,learner,graph等。我们也会大量的用到numpy。
from __future__ import print_function
import numpy as np
import os
import cntk as C
在下面的代码中,我们通过检查在CNTK内部定义的环境变量来选择正确的设备(GPU或者CPU)来运行代码,如果不检查的话,会使用CNTK的默认策略来使用最好的设备(如果GPU可用的话就使用GPU,否则使用CPU)
# Define a test environment
def isTest():
return ('TEST_DEVICE' in os.environ)
# Select the right target device when this notebook is being tested:
if 'TEST_DEVICE' in os.environ:
if os.environ['TEST_DEVICE'] == 'cpu':
C.device.try_set_default_device(C.device.cpu())
else:
C.device.try_set_default_device(C.device.gpu(0))
下载数据
在本教程中我们将使用来自http://www.speech.cs.cmu.edu/cgi-bin/cmudict的简单预处理过的CMUDict(Version 0.7b)数据集。CMUDict数据使用了卡耐基梅隆大学的发音词典,是一个可以被电脑识别的开源美式英语发音字典。数据是CNTK标准文本格式,下面是数据中的一些示例序列组,其中左列输入序列(s0),右列是输出序列。
0 |S0 3:1 |# <s> |S1 3:1 |# <s>
0 |S0 4:1 |# A |S1 32:1 |# ~AH
0 |S0 5:1 |# B |S1 36:1 |# ~B
0 |S0 4:1 |# A |S1 31:1 |# ~AE
0 |S0 7:1 |# D |S1 38:1 |# ~D
0 |S0 12:1 |# I |S1 47:1 |# ~IY
0 |S0 1:1 |# </s> |S1 1:1 |# </s>
下面的代码将下载需要用到的数据文件(训练、测试、用于可视化验证的单独序列以及一个小的词汇文件),然后将他们放入本地文件夹(训练文件大概34M,测试文件大概4M,验证文件和词汇文件都小于1K).
import requests
def download(url, filename):
""" utility function to download a file """
response = requests.get(url, stream=True)
with open(filename, "wb") as handle:
for data in response.iter_content():
handle.write(data)
MODEL_DIR = "."
DATA_DIR = os.path.join('..', 'Examples', 'SequenceToSequence', 'CMUDict', 'Data')
# If above directory does not exist, just use current.
if not os.path.exists(DATA_DIR):
DATA_DIR = '.'
dataPath = {
'validation': 'tiny.ctf',
'training': 'cmudict-0.7b.train-dev-20-21.ctf',
'testing': 'cmudict-0.7b.test.ctf',
'vocab_file': 'cmudict-0.7b.mapping',
}
for k in sorted(dataPath.keys()):
path = os.path.join(DATA_DIR, dataPath[k])
if os.path.exists(path):
print("Reusing locally cached:", path)
else:
print("Starting download:", dataPath[k])
url = "https://github.com/Microsoft/CNTK/blob/v2.0/Examples/SequenceToSequence/CMUDict/Data/%s?raw=true"%dataPath[k]
download(url, path)
print("Download completed")
dataPath[k] = path
数据读取器
为了高校的读取、打乱数据以及将数据传入我们的神经网络,我们使用CNTKTextFormat读取器。我们将创建一个简短的函数用来定义数据流的名称以及如何与原始训练/测试数据对应,我们在测试或者训练的时候调用他。
# Helper function to load the model vocabulary file
def get_vocab(path):
# get the vocab for printing output sequences in plaintext
vocab = [w.strip() for w in open(path).readlines()]
i2w = { i:w for i,w in enumerate(vocab) }
w2i = { w:i for i,w in enumerate(vocab) }
return (vocab, i2w, w2i)
# Read vocabulary data and generate their corresponding indices
vocab, i2w, w2i = get_vocab(dataPath['vocab_file'])
def create_reader(path, is_training):
return MinibatchSource(CTFDeserializer(path, StreamDefs(
features = StreamDef(field='S0', shape=input_vocab_dim, is_sparse=True),
labels = StreamDef(field='S1', shape=label_vocab_dim, is_sparse=True)
)), randomize = is_training, max_sweeps = INFINITELY_REPEAT if is_training else 1)
input_vocab_dim = 69
label_vocab_dim = 69
# Print vocab and the correspoding mapping to the phonemes
print("Vocabulary size is", len(vocab))
print("First 15 letters are:")
print(vocab[:15])
print()
print("Print dictionary with the vocabulary mapping:")
print(i2w)
我们使用上诉的代码创建训练数据的读取器,让我们现在创建他。
# Train data reader
train_reader = create_reader(dataPath['training'], True)
# Validation data reader
valid_reader = create_reader(dataPath['validation'], True)
现在让我们设置模型的超参数
我们有大量的配置参数来空值我们的神经网络:输入数据的大小、类似我们是否使用词向量的选项、是否使用注意力模型等。我们在下个部分创建网络模型时就会用到他们,所以我们先做定义他们。
hidden_dim = 512
num_layers = 2
attention_dim = 128
attention_span = 20
attention_axis = -3
use_attention = True
use_embedding = True
embedding_dim = 200
vocab = ([w.strip() for w in open(dataPath['vocab_file']).readlines()]) # all lines of vocab_file in a list
length_increase = 1.5
我们还将定义两个参数:序列开始的标志(有时候也叫“BOS”)和序列结束的标志(有时候也叫“EOS”)。在本例中,他们分别是和。
开始标记和结束标记在sequence-to-sequenc神经网络中之所以重要因为两个原因。序列开始标记是解码器的入口,换句话说,因为我们生成了一个输出序列,RNN又需要一些输入序列,序列中的开始信息激活了解码器,让解码器生成第一个信息。结束信号之所以重要是因为解码器在序列结束时将输出结束信号。不然神经网络将不知道需要生成多长的序列。下面的代码我们将会把序列开始标记设置成一个常量,这样在之后传入解码器LSTM网络时就能一直是这个初始状态。然后我们设置结束标记,解码器就通过他就可以知道何时停止生产信号。
sentence_start =C.Constant(np.array([w=='<s>' for w in vocab], dtype=np.float32))
sentence_end_index = vocab.index('</s>')
第一步:设置网络输入数据
CNTK中的动态轴
在理解CNTK时的一个重要概念就是两种不同轴的理念:
- 静态轴,变量大小的传统轴
- 动态轴,具有未知大小,直到计算时变量绑定了具体的数据。
动态轴在递归神经网络中尤其重要。CNTK通过自动绑定到取样包实现允许序列长度可变实现尽可能的高效,而不是提前确定最大序列长度、强行将序列填满浪费计算资源。
在我们初始化序列是,有两个重要的动态轴需要考虑。第一个是批次轴,用来表征有多少个序列为一批。第一个动态轴是序列内特有的动态轴。后者之所以属于序列,是因为序列的长度随着输入数据的变化而变化。比如在sequence to sequence神经网络中,我们有两个序列,输入序列和输出(标签)序列,这个神经网络强大的重要一环是输入序列的长度和输入序列的长度不需要彼此一致,所以输入序列和输出序列都需要自己的动态轴。
我们先创建变量inputAxis表示输入序列的动态轴,labelAxis表示输出序列的动态轴。然后然后我们通过这两个轴创建序列来定义模型的输入数据。注意变量InputSequence和LabelSequence看着像定义了一个变量,实际上是申明了一个类型。这表示InputSequence是包含了一个具有inputAxis轴的序列。
# Source and target inputs to the model
inputAxis = C.Axis('inputAxis')
labelAxis = C.Axis('labelAxis')
InputSequence = C.layers.SequenceOver[inputAxis]
LabelSequence = C.layers.SequenceOver[labelAxis]
第二步:定义网络
如前面所说,一个最基本的sequence-to-sequence神经网络是由一个RNN (LSTM)编码器,后面接着一个RNN (LSTM)解码器,然后这跟着一个全连接输出层组成的。我们将使用CNTK的层级库实现编码器和解码器,他们都将被创建成CNTK的函数对象。我们的create_model函数中既创建了编码器函数对象,也创建了解码器函数对象。编码器函数对象会直接被解码器函数对象使用,解码器函数对象将会成为 create_model的返回值。
我们首先将输入数据进行向量化。所以在之后的应用中,无论我们有没有将数据向量化,都可以直接传入由编码器和解码器组成的Sequential模块使用,如果use_embedding参数的值是False,我们就使用identity函数。下面我们定义编码器层。
首先我们将数据通过embed函数(用于向量化),然后将数据持久化。这会给我们的训练带来额外的标量参数,不过会让我们的网络在训练时更快的收敛。然后在编码器中除了最后一层外我们需要的的每个LSTM层,我们都对其进行循环。如果我们不适用注意力模型,最后一个循环将会是一个Fold,因为我们只将最后的隐藏状态传递给解码器。如果我们使用注意力模型,我们会使用另外一个正常的LSTM循环,解码器会把注意力放在后面。
下面我们看看如何对具有注意力模型的sequence-to-sequence神经网络如何分层。如下面的代码中所示,编码器和解码器中每层的输出数据都会被用作上一层的输入数据。注意力模型主要处理编码器的顶层和解码器的第一层。
对于解码器,我们定义了一些子层:用于解码器输入数据的Stabilizer,用于解码器每个层的Recurrence模块,用于LSTM栈输出的Stabilizer以及最后的Dense输出层。如果我们使用注意力模型,我们也需要创建一个注意力模型函数attention_model,用于返回增强版的解码器隐藏状态,里面着重记录了生成下一个输出信号时需要用到的编码器的隐藏状态。
然后我们创建解码器函数对象。装饰器@Function将一个普通的Python函数转换成CNTK特有的函数对象,给定输入参数,得到返回值。解码器在训练和测试时工作有点不同。在训练时,解码器循环的输入由标签值组成。在测试或评估是,解码器的输入会是模型真实的输出值。对于贪婪解码器——在本教程中实现的——的输入值是最后的全连接层的hardmax输出。
解码器函数对象具有两个参数:(1)输入序列,(2)解码历史。首先让输入序列在我们之前构造好的编码函数中运行一遍。然后我们得到了运行历史,如果有需要还要将其向量化。然后再向量化好的数据用于解码器递归之前,先将其持久化。在递归中的每层种,我们都会在循环的LSTM中运行一遍向量化好的历史数据(用r表示)。如果我们不使用注意力模型,我们直接使用它的初始值,也就是编码器最后的隐藏状态运行。如果我们使用注意力模型,我们就会使用额外的输入数据h_att用于attention_model函数,然后将其与输入数据x结合,最后将这种增强过的x用于解码器递归中。
最后,我们将解码器的输出值持久化,将其输入最后的全连接层proj_out,最后使用Label层将输出数据标记,让之后的层可以直接获取。
# create the s2s model
def create_model(): # :: (history*, input*) -> logP(w)*
# Embedding: (input*) --> embedded_input*
embed = C.layers.Embedding(embedding_dim, name='embed') if use_embedding else identity
# Encoder: (input*) --> (h0, c0)
# Create multiple layers of LSTMs by passing the output of the i-th layer
# to the (i+1)th layer as its input
# Note: We go_backwards for the plain model, but forward for the attention model.
with C.layers.default_options(enable_self_stabilization=True, go_backwards=not use_attention):
LastRecurrence = C.layers.Fold if not use_attention else C.layers.Recurrence
encode = C.layers.Sequential([
embed,
C.layers.Stabilizer(),
C.layers.For(range(num_layers-1), lambda:
C.layers.Recurrence(C.layers.LSTM(hidden_dim))),
LastRecurrence(C.layers.LSTM(hidden_dim), return_full_state=True),
(C.layers.Label('encoded_h'), C.layers.Label('encoded_c')),
])
# Decoder: (history*, input*) --> unnormalized_word_logp*
# where history is one of these, delayed by 1 step and <s> prepended:
# - training: labels
# - testing: its own output hardmax(z) (greedy decoder)
with C.layers.default_options(enable_self_stabilization=True):
# sub-layers
stab_in = C.layers.Stabilizer()
rec_blocks = [C.layers.LSTM(hidden_dim) for i in range(num_layers)]
stab_out = C.layers.Stabilizer()
proj_out = C.layers.Dense(label_vocab_dim, name='out_proj')
# attention model
if use_attention: # maps a decoder hidden state and all the encoder states into an augmented state
# :: (h_enc*, h_dec) -> (h_dec augmented)
attention_model = C.layers.AttentionModel(attention_dim,
attention_span,
attention_axis,
name='attention_model')
# layer function
@C.Function
def decode(history, input):
encoded_input = encode(input)
r = history
r = embed(r)
r = stab_in(r)
for i in range(num_layers):
# LSTM(hidden_dim) # :: (dh, dc, x) -> (h, c)
rec_block = rec_blocks[i]
if use_attention:
if i == 0:
@C.Function
def lstm_with_attention(dh, dc, x):
h_att = attention_model(encoded_input.outputs[0], dh)
x = C.splice(x, h_att)
return rec_block(dh, dc, x)
r = C.layers.Recurrence(lstm_with_attention)(r)
else:
r = C.layers.Recurrence(rec_block)(r)
else:
# unlike Recurrence(), the RecurrenceFrom() layer takes the initial hidden state as a data input
# :: h0, c0, r -> h
r = C.layers.RecurrenceFrom(rec_block)(*(encoded_input.outputs + (r,)))
r = stab_out(r)
r = proj_out(r)
r = C.layers.Label('out_proj_out')(r)
return r
return decode
我们上面定义的网络是一个需要被封装之后才能使用的抽象模型,在本教程中,我们首先将使用它创建一个“训练”版本,然后我们将创建一个贪婪“解码”版本,也就是我们上面说的解码器的历史将会是网络的hardmax输出。接下来让我们设置模型的封装器。
欢迎扫码关注我的微信公众号获取最新文章