本教程展示了如何实现一个递归神经网络来处理文本,为航空出行信息服务(ATIS)数据提供分词标记任务(将不同的词分到各自的类中,分类由训练数据集提供)。我们从文字线型降维开始,然后训练和使用LSTM神经网络。这将被扩展到相邻的单词并且双向运行。最后我们将完成一个意图分类器。
本教程中你将实践到的技术包括:
- 通过组合层块的方式描述模型,这事一种不需要编写公式的组合模型的方式。
- 创建你自己的层块
- 在同一网络中使用不同序列长度的变量
- 训练神经网络
我们设定本教程的读者对深度学习比较熟悉,并且了解以下概念:
- 递归神经网络
- 词向量
前提条件
我们设定你已经安装好了CNTK 2.x,我们强烈建议你的电脑CPU支持CUDA,没有GPU的深度学习一点都不好玩。
下载数据
在本教程中,我们将使用经过简单预处理之后的数据版本。你可以通过直接运行下面的代码或者执行部分指令来下载。
下载ATIS训练数据文件和测试文件,将它们放到脚本所在的文件夹。如果你想了解模型如何预测新句子,你还需要语料文件用来查询和分词。
# Use a function definition from future version (say 3.x from 2.7 interpreter)
from __future__ import print_function
import requests
import os
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)
locations = ['Tutorials/SLUHandsOn', 'Examples/LanguageUnderstanding/ATIS/BrainScript']
data = {
'train': { 'file': 'atis.train.ctf', 'location': 0 },
'test': { 'file': 'atis.test.ctf', 'location': 0 },
'query': { 'file': 'query.wl', 'location': 1 },
'slots': { 'file': 'slots.wl', 'location': 1 }
}
for item in data.values():
location = locations[item['location']]
path = os.path.join('..', location, item['file'])
if os.path.exists(path):
print("Reusing locally cached:", item['file'])
# Update path
item['file'] = path
elif os.path.exists(item['file']):
print("Reusing locally cached:", item['file'])
else:
print("Starting download:", item['file'])
url = "https://github.com/Microsoft/CNTK/blob/v2.0/%s/%s?raw=true"%(location, item['file'])
download(url, item['file'])
print("Download completed")
导入CNTK和其他需要用到的模块
CNTK为Python提供的模块包括几个子模块,比如io,learner和layers等等。我们在有些情况下也需要使用Numpy,因为CNTK的运算结果像Numpy数组一样运算。
import math
import numpy as np
import cntk as C
在下面的代码中,我们通过检查在CNTK内部定义的环境变量来选择正确的设备(GPU或者CPU)来运行代码,如果不检查的话,会使用CNTK的默认策略来使用最好的设备(如果GPU可用的话就使用GPU,否则使用CPU)
# 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))
任务和模型结构
我们本教程的任务是建立定位标签。我们使用ATIS数据,数据包含来自航空旅游信息服务的人机查询数据,我们的任务是标记出一个查询词汇是否属于特定的信息箱,如果属于,那是哪一个。
我们实践中使用到的数据已经被转换长CNTK标准文本格式了,让我们看看测试数据集文件atis.test.ctf中的一段数据样本。
19 |S0 178:1 |# BOS |S1 14:1 |# flight |S2 128:1 |# O
19 |S0 770:1 |# show |S2 128:1 |# O
19 |S0 429:1 |# flights |S2 128:1 |# O
19 |S0 444:1 |# from |S2 128:1 |# O
19 |S0 272:1 |# burbank |S2 48:1 |# B-fromloc.city_name
19 |S0 851:1 |# to |S2 128:1 |# O
19 |S0 789:1 |# st. |S2 78:1 |# B-toloc.city_name
19 |S0 564:1 |# louis |S2 125:1 |# I-toloc.city_name
19 |S0 654:1 |# on |S2 128:1 |# O
19 |S0 601:1 |# monday |S2 26:1 |# B-depart_date.day_name
19 |S0 179:1 |# EOS |S2 128:1 |# O
数据有7列:
- 序列ID(19),样本所示数据中有11个条目是该序列ID,这表明序列19由11个标记组成。
- s0列,包含单词的数字索引。输入数据编码成一位有效码矢量。词汇表总共包含943个词,所以每个单词都是一个有943个要素的矢量,其中一个是1,位置表示改单词的索引,其他的都是0。比如,单词”from”用一个第444位为1,其他位都是0的的矢量,单词”monday”用一个第601位是1,其他位都是0的矢量表示。
- 以#号开头的注释列,是为了给我们看的,让我们指导上面的单词数字索引代表什么。注释列在系统里面会被忽视掉。BOS和EOS分别表示一句话的开头和结尾。
- S1列是一个意图列,只会用在本教程的最后一部分。
- 下面的注释列是对上面特殊列的注释,给我们看而不是给计算机读的。
- S2列是定位标签,用一个数字表示
- 最后一个注释列表示定位标签的意思,给我们来读的
我们创建的神经网络的任务是为了通过查询(S0)来预测标签(S2)。如上面的数据所示,每一个单词都制订了一个标签,要就是空标签0,要就是以B-开头的一个分类标签,其中I-表示与上一个词同类相连的词。
在我们的模型中,我们会使用一个递归模型,包含一个嵌入曾,一个递归LSTM单元和一个全连接层来计算最后的概率。
或者,作为一个CNTK的教程,你需要去浏览一下在CNTK中这些层是如何实现的,这点可以在网络层接口中看到。
# number of words in vocab, slot labels, and intent labels
vocab_size = 943 ; num_labels = 129 ; num_intents = 26
# model dimensions
input_dim = vocab_size
label_dim = num_labels
emb_dim = 150
hidden_dim = 300
# Create the containers for input feature (x) and the label (y)
x = C.sequence.input_variable(vocab_size)
y = C.sequence.input_variable(num_labels)
def create_model():
with C.layers.default_options(initial_state=0.1):
return C.layers.Sequential([
C.layers.Embedding(emb_dim, name='embed'),
C.layers.Recurrence(C.layers.LSTM(hidden_dim), go_backwards=False),
C.layers.Dense(num_labels, name='classify')
])
现在我们已创建好了模型。
这模型可以完全使用Python创建,第一层embed,是嵌入曾,我们直接使用CNTK Embedding()函数中的默认参数,也就是线性嵌入,这是一个大小是输入字符编码×输出投影尺寸的的简单矩阵,你可以访问他的参数E(嵌入存储的位置),就像访问其他Python对象的属性一样。他的大小里有一个-1,表示这个参数还没有完全初始化。他的输出大小设为emb_dim,本教程中指定成150。
我们还要查看一下全连接层classify的偏移量矢量。全连接层是是多层感知机中的基础组成单元。全连接层的每一层都有权重参数和偏移量参数。我们的教程中偏移量参数初始值都是0。当创建模型之后,我们应该给层级组件命名,之后就可以查看他的参数了。
# peek
z = create_model()
print(z.embed.E.shape)
print(z.classify.b.value)
我们输入长度为943的一位有效码适量,输出长度为emb_dim,本例中设为150.下面的代码中,我们将输入变量x传入模型z,这就将模型和特定大小的输入数据绑定起来了。在本例中,输入大小就是输入词汇库的大小。通过这种设定,嵌入层返回的参数大小是(943,150)。注意,你可以使用预计算矢量比如Word2Vec或者GloVe初始化嵌入矩阵。
# Pass an input and check the dimension
z = create_model()
print(z(x).embed.E.shape)
CNTK配置
为了在CNTK中训练和测试模型,我们需要创建一个模型,明确如何读取数据,最后运行训练和测试程序。
为了训练我们需要明确:
- 如何读取数据
- 模型函数和他的输入参数和返回参数
- 确定训练器的超参数,比如学习速率
数据和数据读取
我们上面已经看了数据,但是如何构造这种格式的数据呢。为了读取数据,本教程中使用CNTKTextFormatReader。
在本教程中,我们通过两步创建数据:
- 将一行行的数据转化成纯文本,其中使用Tab分隔列,空格分隔文本。比如:
BOS show flights from burbank to st. louis on monday EOS (TAB) flight (TAB) O O O O B-fromloc.city_name O B-toloc.city_name I-toloc.city_name O B-depart_date.day_name O
这意味着与粘贴命令输出相同
- 使用如下命令将文本转换成CNTK标准文本格式
python [CNTK root]/Scripts/txt2ctf.py –map query.wl intent.wl slots.wl –annotated True –input atis.test.txt –output atis.test.ctf
其中的三个.wl文件以纯文本的方式给出了词汇库,每一个词一行。
在这个CTF文件中,我们的列分别标记为S0,S1和S2。下面的读取器定义中就将神经网络的输入数据和CTF文件中的一行行连接起来:
def create_reader(path, is_training):
return C.io.MinibatchSource(C.io.CTFDeserializer(path, C.io.StreamDefs(
query = C.io.StreamDef(field='S0', shape=vocab_size, is_sparse=True),
intent_unused = C.io.StreamDef(field='S1', shape=num_intents, is_sparse=True),
slot_labels = C.io.StreamDef(field='S2', shape=num_labels, is_sparse=True)
)), randomize=is_training, max_sweeps = C.io.INFINITELY_REPEAT if is_training else 1)
# peek
reader = create_reader(data['train']['file'], is_training=True)
reader.streams.keys()
训练器
我们也需要定义成本函数,以及误差追踪。在大多数教程中,我们知道输入数据的大小和其对应的标签,因此我们这样hi及诶创建成本函数和误差函数。在本教程中我们我们做的也差不多,不过我们需要首先介绍一下占位符,这个概念会在第三个任务中用到。
学习笔记:占位符介绍:注意我们所写的代码实际上并不执行什么大量的计算,这些代码只是指定我们在训练和测试的时候需要使用的函数。同样,我们在编程的时候也很方便的使用一个名词来代表一个参数,这个名词就是占位符。实际上,在有些语言中在调用函数时会使用已知数量的参数来替换这些占位符,以此将正确的值绑定到参数。
具体来说,上面创建的输入变量x=C.sequence.input_variable(vocab_size)保存由vocab_size预定义的参数。在这种没法直接实例化的情况下,使用占位符是合理的选择。使用占位符只是在当你有数据的时候,再来进行数据指定。
下面是一个占位符的例子:
def create_criterion_function(model):
labels = C.placeholder(name='labels')
ce = C.cross_entropy_with_softmax(model, labels)
errs = C.classification_error (model, labels)
return C.combine ([ce, errs]) # (features, labels) -> (loss, metric)
criterion = create_criterion_function(create_model())
criterion.replace_placeholders({criterion.placeholders[0]: C.sequence.input_variable(num_labels)})
上面的部分在创建网络时定义输入参数时运行良好,只不过牺牲了部分可读性。因此我们以如下的方式创建函数:
def create_criterion_function_preferred(model, labels):
ce = C.cross_entropy_with_softmax(model, labels)
errs = C.classification_error (model, labels)
return ce, errs # (model, labels) -> (loss, error metric)
def train(reader, model_func, max_epochs=10):
# Instantiate the model function; x is the input (feature) variable
model = model_func(x)
# Instantiate the loss and error function
loss, label_error = create_criterion_function_preferred(model, y)
# training config
epoch_size = 18000 # 18000 samples is half the dataset size
minibatch_size = 70
# LR schedule over epochs
# In CNTK, an epoch is how often we get out of the minibatch loop to
# do other stuff (e.g. checkpointing, adjust learning rate, etc.)
# (we don't run this many epochs, but if we did, these are good values)
lr_per_sample = [0.003]*4+[0.0015]*24+[0.0003]
lr_per_minibatch = [lr * minibatch_size for lr in lr_per_sample]
lr_schedule = C.learning_rate_schedule(lr_per_minibatch, C.UnitType.minibatch, epoch_size)
# Momentum schedule
momentum_as_time_constant = C.momentum_as_time_constant_schedule(700)
# We use a the Adam optimizer which is known to work well on this dataset
# Feel free to try other optimizers from
# https://www.cntk.ai/pythondocs/cntk.learner.html#module-cntk.learner
learner = C.adam(parameters=model.parameters,
lr=lr_schedule,
momentum=momentum_as_time_constant,
gradient_clipping_threshold_per_sample=15,
gradient_clipping_with_truncation=True)
# Setup the progress updater
progress_printer = C.logging.ProgressPrinter(tag='Training', num_epochs=max_epochs)
# Uncomment below for more detailed logging
#progress_printer = ProgressPrinter(freq=100, first=10, tag='Training', num_epochs=max_epochs)
# Instantiate the trainer
trainer = C.Trainer(model, (loss, label_error), learner, progress_printer)
# process minibatches and perform model training
C.logging.log_number_of_parameters(model)
t = 0
# loop over epochs
for epoch in range(max_epochs):
epoch_end = (epoch+1) * epoch_size
# loop over minibatches on the epoch
while t < epoch_end:
data = reader.next_minibatch(minibatch_size, input_map={ # fetch minibatch
x: reader.streams.query,
y: reader.streams.slot_labels
})
# update model with it
trainer.train_minibatch(data)
# samples so far
t += data[y].num_samples
trainer.summarize_training_progress()
运行
代码如下:
def do_train():
global z
z = create_model()
reader = create_reader(data['train']['file'], is_training=True)
train(reader, z)
do_train()
训练过程输出:
Training 721479 parameters in 6 parameter tensors.
Learning rate per minibatch: 0.21
Finished Epoch[1 of 10]: [Training] loss = 0.787482 * 18010, metric = 15.61% * 18010 5.471s (3291.9 samples/s);
Finished Epoch[2 of 10]: [Training] loss = 0.223525 * 18051, metric = 5.25% * 18051 4.958s (3640.8 samples/s);
Finished Epoch[3 of 10]: [Training] loss = 0.154852 * 17941, metric = 3.68% * 17941 4.933s (3636.9 samples/s);
Finished Epoch[4 of 10]: [Training] loss = 0.106380 * 18059, metric = 2.64% * 18059 4.796s (3765.4 samples/s);
Learning rate per minibatch: 0.105
Finished Epoch[5 of 10]: [Training] loss = 0.069279 * 17957, metric = 1.65% * 17957 4.706s (3815.8 samples/s);
Finished Epoch[6 of 10]: [Training] loss = 0.061887 * 18021, metric = 1.50% * 18021 4.919s (3663.5 samples/s);
Finished Epoch[7 of 10]: [Training] loss = 0.054078 * 17980, metric = 1.29% * 17980 4.862s (3698.1 samples/s);
Finished Epoch[8 of 10]: [Training] loss = 0.050230 * 18025, metric = 1.30% * 18025 4.760s (3786.8 samples/s);
Finished Epoch[9 of 10]: [Training] loss = 0.030962 * 17956, metric = 0.86% * 17956 4.775s (3760.4 samples/s);
Finished Epoch[10 of 10]: [Training] loss = 0.033263 * 18039, metric = 0.90% * 18039 4.715s (3825.9 samples/s);
上述输出显示在每一轮训练是如何运行的。比如,在第4轮训练完成之后,约18000个样本的成本值(交叉熵成本函数)达到了0.11,错误率约是2.6%。
每轮训练的个数是指通过模型的样本数,以词向量计算而不是以句子计算。
训练完成之后,你可以看到如下输出:
Finished Epoch [10]: [Training] loss = 0.033263 * 18039, metric = 0.9% * 18039
其中成本值和误差值是指最后一轮的平均值。
当在只有CPU的机器上训练时,会比使用GPU的机器慢4倍或者更多,你可以尝试如下设置:
emb_dim = 50
hidden_dim = 100
来减少训练时间,但是训练效果不会像数值更大时好。
评估模型
与train()函数类似,我们也需要定义一个函数通过计算测试数据集的误差值来衡量模型的精度。对于读取少量的样本来测试,我们可以设置一个取样包的大小,然后运行test_minibatch函数。如果要看如何测试单个序列,我们会在后面给出示例。
def evaluate(reader, model_func):
# Instantiate the model function; x is the input (feature) variable
model = model_func(x)
# Create the loss and error functions
loss, label_error = create_criterion_function_preferred(model, y)
# process minibatches and perform evaluation
progress_printer = C.logging.ProgressPrinter(tag='Evaluation', num_epochs=0)
while True:
minibatch_size = 500
# fetch minibatch
data = reader.next_minibatch(minibatch_size, input_map={
x: reader.streams.query,
y: reader.streams.slot_labels
})
# until we hit the end
if not data:
break
evaluator = C.eval.Evaluator(loss, progress_printer)
evaluator.test_minibatch(data)
evaluator.summarize_test_progress()
我们现在可以使用上面evaluate函数中的训练器的test_minibatch方法跑一边测试数据集中的样本来衡量模型精度了。
def do_test():
reader = create_reader(data['test']['file'], is_training=False)
evaluate(reader, z)
do_test()
z.classify.b.value
下面的代码展示了如何评估单个序列。当然同事也展示了如何将Numpy数组数据传入模型。
# load dictionaries
query_wl = [line.rstrip('\n') for line in open(data['query']['file'])]
slots_wl = [line.rstrip('\n') for line in open(data['slots']['file'])]
query_dict = {query_wl[i]:i for i in range(len(query_wl))}
slots_dict = {slots_wl[i]:i for i in range(len(slots_wl))}
# let's run a sequence through
seq = 'BOS flights from new york to seattle EOS'
w = [query_dict[w] for w in seq.split()] # convert to word indices
print(w)
onehot = np.zeros([len(w),len(query_dict)], np.float32)
for t in range(len(w)):
onehot[t,w[t]] = 1
#x = C.sequence.input_variable(vocab_size)
pred = z(x).eval({x:[onehot]})[0]
print(pred.shape)
best = np.argmax(pred,axis=1)
print(best)
list(zip(seq.split(),[slots_wl[s] for s in best]))
改进模型
下面,你将会收到一些改进模型的任务,解决方案会在本文档的最后,不过,请尝试自己去解决他。
Sequential()函数介绍
在进入任务之前,我们先反过来看看我们之前运行的模型,这个模型使用如下的函数组合形式。
Sequential([
Embedding(emb_dim),
Recurrence(LSTM(hidden_dim), go_backwards=False),
Dense(num_labels)
])
你也许在其他的神经网络组件中也见过sequential。Sequential()是一个非常强大的函数,他将神经网络层的输入输出组合起来,可以非常紧凑的表达比较常见的神经网络。Sequential()以一系列的函数作为参数,返回一个按顺序执行所有函数的新的函数,把上一个函数的输出值当下一个函数的输入值。举例来说
FGH = Sequential ([F,G,H])
y = FGH (x)
等同于
y = H(G(F(x)))
这就是函数组合形式,非常适合用于如下形式的神经网络
换成我们的教程中的问题,这种表达方式就是
任务1:添加批规范化
上期刚说过的,不解释,注意:批量规范化只支持GPU。
任务2:向前看
我们的递归模型有个结构上的缺陷:当递归从左到右运行时,下一个词没有参与分类决策,这个模型有点不公平。你的任务是改进这个模型,让递归不止包括当前的词,也包括下一个词。
你的方案应该是函数组合形式的,所以你需要写一个函数,功能如下:
- 获取输入参数
- 创建一个占位符变量
- 使用sequence.future_value()计算序列中的下一个值
- 使用splice()将当前值和下一个值放入一个大小是当前向量两倍的向量中
任务3: 双向递归模型
所以看了上面的任务,为什么我们只向前看一个词,而不是使用一个反向的递归向前一直看到这个句子完呢。让我们来创建一个双向递归模型。
你的任务是实现一个新的网络层,对我们的数据执行向前和向后的递归,然后将两个输出矢量组合起来。
注意,与上面一个任务不同的是,双向递归层包含了需要训练的参数。在使用函数组合形式时,实现一个具有模型参数的网络层的形式是通过写一个工厂函数来创建函数对象。
(函数对象介绍略)
你需要创建一个工厂函数来创建两个递归层,然后定义一个函数将两个层的实例运用于同一个输入数据,然后将输出数据组合起来。
试试吧,想要了解反向的递归函数在CNTK里面如何实现,只要看看正向的递归函数是如何实现的就可以了。请做一下的工作。
- 移除上面的任务中的函数;
- 在每个LSTM中使用hidden_dim/2,保证模型参数不会太多
解决方案1:添加批规范化
def create_model():
with C.layers.default_options(initial_state=0.1):
return C.layers.Sequential([
C.layers.Embedding(emb_dim),
C.layers.BatchNormalization(),
C.layers.Recurrence(C.layers.LSTM(hidden_dim), go_backwards=False),
C.layers.BatchNormalization(),
C.layers.Dense(num_labels)
])
do_train()
do_test()
解决方案2:向前看
def OneWordLookahead():
x = C.placeholder()
apply_x = C.splice(x, C.sequence.future_value(x))
return apply_x
def create_model():
with C.layers.default_options(initial_state=0.1):
return C.layers.Sequential([
C.layers.Embedding(emb_dim),
OneWordLookahead(),
C.layers.Recurrence(C.layers.LSTM(hidden_dim), go_backwards=False),
C.layers.Dense(num_labels)
])
do_train()
do_test()
解决方案3: 双向递归模型
def BiRecurrence(fwd, bwd):
F = C.layers.Recurrence(fwd)
G = C.layers.Recurrence(bwd, go_backwards=True)
x = C.placeholder()
apply_x = C.splice(F(x), G(x))
return apply_x
def create_model():
with C.layers.default_options(initial_state=0.1):
return C.layers.Sequential([
C.layers.Embedding(emb_dim),
BiRecurrence(C.layers.LSTM(hidden_dim//2),
C.layers.LSTM(hidden_dim//2)),
C.layers.Dense(num_labels)
])
do_train()
do_test()
欢迎扫码关注我的微信公众号获取最新文章