dan的深度神经网络

翻译:[email protected]

时间:2015年5月

Introduction

这个文档主要介绍在kaldi里Dan Povey版本的深度神经网络代码。对kaldi中所有的深度神经网络代码可以看Deep Neural Networks in Kaldi,对于Karel的版本,可以看Karel's DNN implementation.

这里(有些草草准备)关于DNN的介绍包括Looking at the scriptsUse of GPUs or CPUsTuning the neural network training和dnn2_preconditioning。

Looking at the scripts

如果你想理解神经网络训练的深层描述,最直接的方式就是去看脚本。在一些标准的例子里egs/rm/s5,egs/wsj/s5和 egs/swbd/s5b,最顶层的脚本是run.sh。这个脚本是调用脚本local/run_nnet2.sh。这对于Dan的代码来说是一个顶层的脚本。在local/run_nnet2.sh里,有许多不同的脚本来展示不同的例子,和之前最原始的那些脚本。除了运行脚本local/run_nnet2.sh,我们建议你应该花点时间去运行之前最原始的脚本。这里是一个p-norm网络(看this paper)。

Top-level training script

注意:之前最顶层的训练脚本steps/nnet2/train_pnorm.sh,现在我们已经放弃了这个脚本,和你现在应该使用脚本steps/nnet2/train_pnorm_fast.sh。这个脚本可以并行训练,接下来将解释这个。

Input features to the neural net.

神经网络的输入特征在某种程度上是可以配置的,但是默认的是采用通常的步骤,即在语音识别中GMM模型的自适应特征是MFCC(spliced)+LDA+MLLT+fMLLR,40维的特征(译者注:就是39维MFCC+1维Pitch)。神经网络使用这些特征的一个窗,默认中间帧的左右两边各3帧,总共7帧。由于神经网络很难从相关的特征中学习,这里需要使用一个固定的变换来去掉这种相关性,也就是这个变换乘以40*7维的特征。使用这个变换是由训练脚本来完成;可以去调用脚本steps/nnet2/get_lda.sh。主要根据这篇文章this paper来做的,但是这个变换的真实代码不是真正的LDA:默认情况下,它更像LDA变换的一种非降维形式,由于类间方差很小,可以对输出特征的方差的维度做一个降维(这个没有被发表;可以去看代码)。这个脚本支持的其他类型的特征都是未处理的特征,比如MFCC特征;这些可以通过选–feat-type来设置,并通过选项–egs-opts和–lda-opts传递到脚本get_egs.sh和get_lda.sh。

既然在脚本里寻找选项,最好的方式就是仅仅搜索用下划线代替破折号的选项名:在这种情况下,就变为feat_type,egs_opts,和 lda_opts。脚本utils/parse_options.sh自动解释那些设置对应变量的命令行参数。

Dumping training examples to disk

假设最顶层的脚本(比如steps/nnet2/train_pnorm.sh)创建一个模型,在exp/nnet5d/。这个脚本做的第一件事情就是去调用steps/nnet2/get_egs.sh。这就放置了很多数据在exp/nnet5d/egs/。这个与输入的帧层随机化有关,这个对于随机梯度下降训练是需要的。我们仅仅做一次随机化,以至于在真正的训练中,我们可以顺序的访问这个数据。也就是说,每一轮迭代,我们有必要以相同的顺序访问数据;意味着对磁盘和网络是友好的。(事实上我们可以每次迭代时使用一个不同的种子和使用一小段内存来做随机化,但是这仅仅在本地上改变了顺序)。

如果你去看exp/nnet5d/egs/,你会看到很多文件名为egs.1.1.ark, egs.1.2.ark等等。这些archives文件含有一个叫NnetTrainingExample的许多例子。对于一个单独的帧,这个类含有标签信息,和对这帧来说,有足够多的特征窗来做神经网络计算。除了在神经网络外部做帧切分,神经网络训练代码有一个时间的概念和需要知道上下文特征是多少(看函数RightContext()和LeftContext())。在这个文件名中的2个整数索引分别是任务索引和迭代索引。任务索引对应我们有多少个并行的任务。例如如果我们使用CPUs运行,用16台机器并行(这里每个机器有很多不相关的线程),然后任务索引就是从1到16,或者你使用GPUs,使用8个GPUs并行,然后任务索引就是从1到8。迭代索引的范围主要根据你有多少数据来决定的。默认情况下,每个archive有200,000个样本数。迭代索引的数量是由你有多少数据和你有多少任务来决定的。一般很多轮(比如:20)跑一次训练,和每一轮我们需要做很多次迭代(对于像rm这样的小数据量就是1,对于大的数据集可以为10)。

目录exp/nnet5d/egs/也包含一些其他的文件:iters_per_epoch, num_jobs_nnet和sample_per_iter,这些文件里包含一些数;在一个rm数据集上的例子里,它们分别为1,16和85493。它也包含valid_diagnostic.egs,这是用来诊断的,在held-out集上的一些小例子(看e.g. exp/nnet5d/log/compute_prob_valid..log),和 train_diagnostic.egs,是除了held-out外的valid_diagnostic.egs;为了诊断,可以看exp/nnet5d/log/compute_prob_valid..log。文件combine.egs是在训练的结束用来计算神经网络的联合权重的一个稍微大点的训练数据集。

Neural net initialization

我们使用一个单隐含层来初始化这个神经网络;我们将在稍后的训练中增加隐含层的数量到一个设定的数字(通常是2到5这个范围)。这个脚本创建了一个叫exp/nnet4d/nnet.config的config文件,这将传递给初始化模型的程序,名字叫nnet-am-init。对于rm数据集,对于p-norm部分的一些config文件的例子如下:

SpliceComponent input-dim=40 left-context=4 right-context=4 const-component-dim=0
FixedAffineComponent matrix=exp/nnet4d/lda.mat
AffineComponentPreconditionedOnline input-dim=360 output-dim=1000 alpha=4.0 num-samples-history=2000 update-period=4 rank-in=20 rank-out=80 max-change-per-sample=0.075 learning-rate=0.02 param-stddev=0.0316227766016838 bias-stddev=0.5
PnormComponent input-dim=1000 output-dim=200 p=2
NormalizeComponent dim=200
AffineComponentPreconditionedOnline input-dim=200 output-dim=1475 alpha=4.0 num-samples-history=2000 update-period=4 rank-in=20 rank-out=80 max-change-per-sample=0.075 learning-rate=0.02 param-stddev=0 bias-stddev=0
SoftmaxComponent dim=1475

FixedAffineComponent是我们之前提到的像LDA去相关变换。AffineComponentPreconditionedOnline是AffineComponent的改良。AffineComponent是由标准的神经网络(权重矩阵和偏移项)构成的,在标准的随机梯度下降法训练中使用。AffineComponentPreconditionedOnline就是AffineComponent,但是训练步骤使用的不仅仅是一个单独的全局学习率,而是一个矩阵值学习率来预处理梯度下降。我们接下来将介绍更多(看dnn2_preconditioning)。PnormComponent是非线性的;对于一个更传统的神经网络,将使用TanhComponent来代替。 instead。NormalizeComponent是我们用来稳定p-norm网络训练而添加的。它也是固定的,非训练的非线性,但是它的作用不是一个单独的激活函数,而是对于一个单独的帧的整个向量,来重新归一化他们的单位标准偏差。SoftmaxComponent是最终的非线性,它是在输出上产生归一化的概率。

这个脚本也产生一个名叫hidden.config的config文件,这对应到我们添加的和我们介绍的一个新的隐含层;这个例子如下:

AffineComponentPreconditionedOnline input-dim=200 output-dim=1000 alpha=4.0 num-samples-history=2000 update-period=4 rank-in=20 rank-out=80 max-change-per-sample=0.075 learning-rate=0.02 param-stddev=0.0316227766016838 bias-stddev=0.5
PnormComponent input-dim=1000 output-dim=200 p=2
NormalizeComponent dim=200

直到第一对训练后,它才被使用。

脚本接下来做的就是调用nnet-train-transitions。它将计算在解码(似乎与神经网络本身无关)中使用的HMMs的转移概率,和计算这些目标(成千上万个上下文独立的状态)的先验概率。然后,当我们解码时,我们用神经网络得到的后验概率除以这些先验得到"pseudo-likelihoods";这个比原来的后验概率更加与HMM框架兼容。

Neural net training

接下来我们将来到最主要的训练阶段。这是一个在迭代计算器x,范围从0到num_iters - 1的一个循环。迭代的次数num_iters是我们训练的轮数乘以每一轮我们迭代的次数。我们训练的轮数是num_epochs(default: 15)加上 num_epochs_extra(default:5)的和。这个与学习率进程有关:默认的,我们从最开始的学习率initial_learning_rate(default: 0.04)下降到15轮后的final_learning_rate (default: 0.004)和在后面的5轮保持为常数。每一轮的迭代次数存储在一个文件叫egs/nnet5d/egs/iters_per_epoch中;它取决于我们的数据量的大小和我们并行运行时的训练任务数,和一般是从0到几十范围内。

在每一次迭代中,我们做的第一件事情就是计算一些诊断:在训练和交叉集上的目标函数(对于第十次迭代,可以看egs/nnet5d/log/compute_prob_valid.10.log和egs/nnet5d/log/compute_prob_train.10.log)。在文件egs/nnet5d/log/progress.10.log中你将看到每一层参数是改变的,和训练集目标函数的改变对每一层的改变影响有多大。

下面是在某个特定目录中一个诊断的例子

grep LOG exp/nnet4d/log/compute_prob_*.10.log 
exp/nnet4d/log/compute_prob_train.10.log:LOG<snip> Saw 4000 examples, average probability is -0.894922 and accuracy is 0.729 with total weight 4000
exp/nnet4d/log/compute_prob_valid.10.log:LOG<snip> Saw 4000 examples, average probability is -1.16311 and accuracy is 0.6585 with total weight 4000

你可以看到在训练集(-0.89)的目标函数比交叉集(-1.16)上的要好。这是一个交叉熵,表示每一帧在这个正确类上的平均log概率。在训练集和交叉集上的目标函数相差很大是正常的,因为神经网络有一个很强大的学习能力:对于一个调好参的系统上仅仅几个小时的数据,它们会把它们一分为二(但是当你含有很多训练数据使就差点)。 如果你在训练目标函数上添加更多的参数将会改善,但是交叉集上的性能将下降。然而,对交叉集目标函数上调参不是一个好主意,因为它会导致系统含有很少的参数。如果对Word Error Rates添加参数,即使它在一定程度上降低了交叉集上的性能。

在这样的一个文件exp/nnet4d/log/progress.10.log里,你将寻找更多的诊断:

LOG <snip> Total diff per component is  [ 0.00133411 0.0020857 0.00218908 ]
LOG <snip> Parameter differences per layer are  [ 0.925833 1.03782 0.877185 ]
LOG <snip> Relative parameter differences per layer are  [ 0.016644 0.0175719 0.00496279 ]

第一行"Total diff per component"在训练集上目标函数的下降是由其他层的共同影响,和其他行中说明的是不同层的参数改变有多大。

主要训练任务的log文件可以在exp/nnet5a/log/train...log被找到。第一个索引就是迭代的次数和第二个索引就是我们运行的并行任务数,4或者16(这个数字是由–num-jobs-nnet参数传递到脚本的)。接下来是其中一个训练任务的例子:

#> cat exp/nnet4d/log/train.10.1.log 
# Running on a11
# Started at Sat Mar 15 16:32:08 EDT 2014
# nnet-shuffle-egs --buffer-size=5000 --srand=10 ark:exp/nnet4d/egs/egs.1.0.ark ark:- | \
  nnet-train-parallel --num-threads=16 --minibatch-size=128 --srand=10 exp/nnet4d/10.mdl \
<snip>
LOG (nnet-shuffle-egs:main():nnet-shuffle-egs.cc:100) Shuffled order of 79100 neural-network \
 training examples using a buffer (partial randomization)
LOG (nnet-train-parallel:DoBackpropParallel():nnet-update-parallel.cc:256) Did backprop on \
  79100 examples, average log-prob per frame is -1.4309
LOG (nnet-train-parallel:main():nnet-train-parallel.cc:104) Finished training, processed \
   79100 training examples (weighted).  Wrote model to exp/nnet4d/11.1.mdl
# Accounting: time=18 threads=16
# Finished at Sat Mar 15 16:32:26 EDT 2014 with status 0

这个特定的任务是没用使用GPU,而是使用16 CPU并行运行的,和仅仅使用18秒完成了。这里最主要的任务就是nnet-train-parallel,它是做随机梯度下降的,与Hogwild(例如:没有锁)有点相似的并行化, 每个线程使用的块(minibatch)大小为128。这个模型的输出是11.1.mdl。在exp/nnet4d/log/average.10.log中,你可以看到一个程序叫nnet-am-average的输出log文件,这个程序为这次迭代对所有的SGD训练的模型做平均化。 它通过我们的学习率进度来修改学习率,这个是指数级下降的(具体可以看论文"An Empirical study of learning rates in deep neural networks for speech recognition" by Andrew Senior et. al.,你会发现对于语音识别这个非常有效)。注意:在我们例子里的最后二层中,我们使用tanh这个函数,学习率需要减半;看脚本train_tanh.sh里的选项–final-learning-rate-factor。

对于几十万的样本来说,最基本的并行方法就是,在不同的任务中使用不同的数据来使用随机梯度下降法来训练,然后对这些模型进行平均化。在这个参数上目标函数是非凸的,你也许会惊讶这个是可行的,但是从经验上看,凸函数这个性质在这里似乎不是一个问题。注意:我们接下来描述的去做"preconditioned update",这个看起来更重要点;我们通过大量的实验证明,这个对于我们并行化方法的成功至关重要。同时也要注意在最近的训练脚本(train_norm_fast.sh和train_tanh_fast.sh),我们没有做并行化和对迭代做平均,因为这里或者仅仅是初始化模型或者仅仅添加新的一层。这是因为在这些情况下,由于缺少凸性,做平均化有时候一点好处都没有(例如,在给定平均化模型的目标函数比单独目标函数的平均化要)。

Final model combination

如果你继续往里面看,举例来说,exp/nnet4d/log/combine.log,你会看到一个最终的神经网络被创建,名字叫"final.mdl"。这是联合最后N次迭代的模型的参数得到的,这里的N对应脚本里的参数–num-iters-final(默认地:20)。最基本的想法就是通过对若干次迭代上做平均,这样可以减少估计的偏差。我们不能很容易的证明这个就比仅仅取最终的模型好(因为这是一个非凸问题),但是在实践中就是这样的。事实上,"combine.log"不仅仅是对这些参数取平均。这里使用训练数据样本(在这种情况下,从exp/nnet4d/egs/combine.egs得到)的一个子集来优化这些权重, 这个没有约束为正的。这里的目标函数是在这个数据集上的正常的目录函数(log-probability),和优化方法是L-BFGS,这里我们就不赘述这个特殊的预处理方法。对每个成分和每次迭代都有单独的权重,所以在这种情况下我们有学习(20*3=60)的权重。在这种方法的最初始版本,我们使用交叉集数据来估计这个参数,但是我们发现为了这个目的使用训练数据的一个随机子集,它可能表现的很好。

#> cat exp/nnet4d/log/combine.log
<snip>
    Scale parameters are  [
  -0.109349 -0.365521 -0.760345 
  0.124764 -0.142875 -1.02651 
  0.117608 0.334453 -0.762045 
  -0.186654 -0.286753 -0.522608 
  -0.697463 0.0842729 -0.274787 
  -0.0995975 -0.102453 -0.154562 
  -0.141524 -0.445594 -0.134846 
  -0.429088 -1.86144 -0.165885 
  0.152729 0.380491 0.212379 
  0.178501 -0.0663124 0.183646 
  0.111049 0.223023 0.51741 
  0.34404 0.437391 0.666507 
  0.710299 0.737166 1.0455 
  0.859282 1.9126 1.97164 ]

LOG <snip> Combining nnets, objf per frame changed from -1.05681 to -0.989872
LOG <snip> Finished combining neural nets, wrote model to exp/nnet4a2/final.mdl

联合权重作为一个矩阵被打印出来,它的行索引对应迭代的次数,列索引对应层数。你将看到,联合权重在后面的迭代中是正的,在之前的迭代中是负的,我们可以解释为尝试对模型在这个方向上做更深的研究。我们使用训练数据集,而不是交叉数据集,因为我们发现训练数据集效果更好,尽管使用交叉数据集是一个更自然的想法;我们认为这个原因可能与在语音识别中的一个不准确的"dividing-by-the prior"归一化有关。

Mixing-up

如果使用程nnet-am-info来打印关于exp/nnet4d/final.mdl的信息,你将看到在输出层之前有一个大小为4000的层,这个输出层的大小是1483,因为决策树有1483个分支:

#> nnet-am-info exp/nnet4d/final.mdl
num-components 11
num-updatable-components 3
left-context 4
right-context 4
input-dim 40
output-dim 1483
parameter-dim 1366000
component 0 : SpliceComponent, input-dim=40, output-dim=360, context=4/4
component 1 : FixedAffineComponent, input-dim=360, output-dim=360, linear-params-stddev=0.0386901, bias-params-stddev=0.0315842
component 2 : AffineComponentPreconditioned, input-dim=360, output-dim=1000, linear-params-stddev=0.988958, bias-params-stddev=2.98569, learning-rate=0.004, alpha=4, max-change=10
component 3 : PnormComponent, input-dim = 1000, output-dim = 200, p = 2
component 4 : NormalizeComponent, input-dim=200, output-dim=200
component 5 : AffineComponentPreconditioned, input-dim=200, output-dim=1000, linear-params-stddev=0.998705, bias-params-stddev=1.23249, learning-rate=0.004, alpha=4, max-change=10
component 6 : PnormComponent, input-dim = 1000, output-dim = 200, p = 2
component 7 : NormalizeComponent, input-dim=200, output-dim=200
component 8 : AffineComponentPreconditioned, input-dim=200, output-dim=4000, linear-params-stddev=0.719869, bias-params-stddev=1.69202, learning-rate=0.004, alpha=4, max-change=10
component 9 : SoftmaxComponent, input-dim=4000, output-dim=4000
component 10 : SumGroupComponent, input-dim=4000, output-dim=1483
prior dimension: 1483, prior sum: 1, prior min: 7.96841e-05
LOG (nnet-am-info:main():nnet-am-info.cc:60) Printed info about baseline/exp/nnet4d/final.mdl

softmax层的维度是4000,然后由SumGroupComponent减少到1483.你使用命令nnet-am-copy把它转化为文本格式,然后你可以看到一些信息:

#> nnet-am-copy --binary=false baseline/exp/nnet4d/final.mdl - | grep SumGroup
nnet-am-copy --binary=false baseline/exp/nnet4d/final.mdl - 
<SumGroupComponent> <Sizes> [ 6 3 3 3 2 3 3 3 2 3 2 2 3 3 3 3 2 3 3 3 3 \
3 3 4 2 1 2 3 3 3 2 2 2 3 2 2 3 3 3 3 2 4 2 3 2 3 3 3 4 2 2 3 3 2 4 3 3 \
<snip>
4 3 3 2 3 3 2 2 2 3 3 3 3 3 1 2 3 1 3 2 ]

softmax成分产生比我们需要更多的后验概率(4000 instead of 1483),这些小组的后验概率求和产生输出的维度是1483,其中小组的大小范围在这里例子里是1到6。我们把"mixing up"和语音识别里的混合高斯模型训练中的mix up做类比,这里我们把高斯分成2份和影响均值。这种情况下,我们把最终权重矩阵的行分成2份和影响它们。这种额外的目标在训练过程中将增加一半。相关的log文件如下:

cat exp/nnet4d/log/mix_up.31.log 
# Running on a11
# Started at Sat Mar 15 15:00:23 EDT 2014
# nnet-am-mixup --min-count=10 --num-mixtures=4000 exp/nnet4d/32.mdl exp/nnet4d/32.mdl 
nnet-am-mixup --min-count=10 --num-mixtures=4000 exp/nnet4d/32.mdl exp/nnet4d/32.mdl 
LOG (nnet-am-mixup:GiveNnetCorrectTopology():mixup-nnet.cc:46) Adding SumGroupComponent to neural net.
LOG (nnet-am-mixup:MixUp():mixup-nnet.cc:214) Mixed up from dimension of 1483 to 4000 in the softmax layer.
LOG (nnet-am-mixup:main():nnet-am-mixup.cc:77) Mixed up neural net from exp/nnet4d/32.mdl and wrote it to exp/nnet4d/32.mdl
# Accounting: time=0 threads=1
# Finished at Sat Mar 15 15:00:23 EDT 2014 with status 0

Model "shrinking" and "fixing"

事实上没有在最原始例子里使用的p-norm网络上使用"Shrinking"和"fixing",但是在使用脚本steps/nnet2/train_tanh.sh训练的神经网络中需要使用"Shrinking"和"fixing",或者任何含有sigmoid激活函数的其他网络中也要使用。我们尝试解决的是发生在这些类型激活函数里的问题,这些神经元在过多的训练数据上将过饱和(也就是说,it gets outside the part of the activation that has a substantial slope)和训练将变的很慢。

对于shrinking,我们来看其中的一个log文件,首先:

#> cat exp/nnet4c/log/shrink.10.log 
# Running on a14
# Started at Sat Mar 15 14:25:43 EDT 2014
# nnet-subset-egs --n=2000 --randomize-order=true --srand=10 ark:exp/nnet4c/egs/train_diagnostic.egs ark:- | \
  nnet-combine-fast --use-gpu=no --num-threads=16 --verbose=3 --minibatch-size=125 exp/nnet4c/11.mdl \
  ark:- exp/nnet4c/11.mdl 
<snip>
LOG <snip> Scale parameters are  [
  0.976785 1.044 1.1043 ]
LOG <snip> Combining nnets, objf per frame changed from -1.01129 to -1.00195
LOG <snip> Finished combining neural nets, wrote model to exp/nnet4c/11.mdl

当使用nnet-combine-fast时,但是仅仅把它作为神经网络的输入,所以我们可以优化的唯一一件事情就是神经网络在不同层数的参数的尺度。这次尺度都接近于1,和有些大于1,所以在这种情况下, shrinking也许是用词不当。那些"shrinking"是有用的情况里,但是可能在这种情况下也没有多大区别。

接下来,我们看一下"fixing"的log文件,当我们不做"shrinking"时,每次迭代都需要做'fixing':

#> cat exp/nnet4c/log/fix.1.log 
nnet-am-fix exp/nnet4c/2.mdl exp/nnet4c/2.mdl 
LOG (nnet-am-fix:FixNnet():nnet-fix.cc:94) For layer 2, decreased parameters for 0 indexes, \
   and increased them for 0 out of a total of 375
LOG (nnet-am-fix:FixNnet():nnet-fix.cc:94) For layer 4, decreased parameters for 1 indexes, \
   and increased them for 0 out of a total of 375
LOG (nnet-am-fix:main():nnet-am-fix.cc:82) Copied neural net from exp/nnet4c/2.mdl to exp/nnet4c/2.mdl

这里做的就是根据训练数据,计算tanh激活函数的导数的平均值。对于tanh来说,在任何数据点上这个导数都不能超过1.0。对于某个特定的神经元来说,如果它的平均值比这个值小很多(默认的我们设置门限值为0.1),也就是我们过饱和了和我们通过对这个输入的神经元降低2倍来补偿权重和偏移项。就像你在log文件里看到的那样,这个仅仅在这次迭代某个神经元上发生,意思也就是说对于这次特定的运行,这不是一个很大的问(如果你使用很大的学习率,它将经常发生)。

Use of GPUs or CPUs

无论是GPU还是CPU,这个代码都可以保证平等透明的训练。注意如果你想使用GPU来运行,你必须使用GPU支持的编译,也就是说在src/下,你需要在一个含有 NVidia CUDA toolkit的机器上运行"configure"和"make"(也就是,在这台机器上,命令"nvcc"可以被执行)。如果kaldi是使用GPU支持的编译,神经网络训练代码是可以使用GPU来训练的。你可使用命令"ldd"来确定kaldi是否使用GPU编译的,和检查libcublas是否编译过,例如:

src#> ldd nnet2bin/nnet-train-simple | grep cu
libcublas.so.4 => /home/dpovey/libs/libcublas.so.4 (0x00007f1fa135e000)
libcudart.so.4 => /home/dpovey/libs/libcudart.so.4 (0x00007f1fa1100000)

当使用GPU来训练时,你将看到一些像 train...log的文件:

LOG (nnet-train-simple:IsComputeExclusive():cu-device.cc:209) CUDA setup operating \
    under Compute Exclusive Mode.
LOG (nnet-train-simple:FinalizeActiveGpu():cu-device.cc:174) The active GPU is [0]: \
 Tesla K10.G2.8GB    free:3516M, used:66M, total:3583M, free/total:0.981389 version 3.0

一些命令行程序有一个选项–use-gpu,这个选项可以取的值为"yes", "no"或者"optional",和意思就是你是否使用GPU(如果你设定为"optional",如果仅仅有一个GPU可用,那么将使用GPU)。 但是事实上我们在脚本里不使用这种机制,因为对于GPU和CPU训练来说,我们有两种不同的代码。CPU版本的代码是nnet-train-parallel,和因为它支持多线程,所以它这么命名。当我们使用一个CPU时,我们将使用16个线程。这就是去做没有任何锁的多核随机梯度下降,有时候我们可以把它看成是Hogwild的一种形式。顺便,当使用多线程更新时,我们建议minibatch的大小不超过128,因为这个会导致不稳定性。 We consider that "effective minibatch size" as equal to the minibatch size times the number of threads, and if this gets too large the updates can diverge. Note that we have formulated the stochastic gradient descent so that the gradients get summed over the members of the minibatch, not averaged. Also note that the only reason why we can't just use nnet-train-parallel with one thread for GPU-based training is that nnet-train-parallel uses two threads even if configured with –num-threads=1 (because one thread is dedicated to I/O), and CUDA does not work easily with multi-threaded programs because the GPU context is tied to a single thread.

Switching between GPU and CPU use

当你借助像train_tanh.sh和train_pnorm.sh脚本来在使用CPU和GPU之间切换时,有一些你不得不改的步骤(也许这不是理想的)。这些程序有一个选项–parallel-opts, 它是由传递到queue.pl(或者类似的脚本)里的额外的标示构成的。这里假设queue.pl是借助GridEngine和参数将传递到GridEngine。 –parallel-opts的默认值是使用一个16线程的CPU,和就是"-pe smp 16 -l ram_free=1G,mem_free=1G"。这仅仅影响我们从queue里获取哪些资源,和不影响真实运行的那些脚本;我们将不得不告诉脚本事实上使用的是16个线程,是通过选项–num-threads(默认的是16)。 选项"ram_free=1G" 也许全部的queue无关,为了内存使用,我们人工把它作为一个资源添加到我们的queue里;如果你的电脑没有这些资源,你可以删除它。默认的设置是使用16个线程的CPU;如果你想使用一个GPU,你就得借助脚本的选项像

--num-threads 1 --parallel-opts "-l gpu=1"

这里我们强调的是,选项"gpu=1"仅仅表示我们在一个特定的集群上借助GPU,和其他的集群将不同,因为一个GPU不能够认为GridEngine– queues将被使用者以不同的方式编译。 Basically the string needs to be whatever options you need to give to "qstat" so that it will request a GPU. 如果所有的都使用一个没有GridEngine的单机来运行,你仅仅使用 run.pl来运行任务,然后parallel-opts仅仅是一个空的字符串。如果你借助脚本并使用–num-threads=1,它会调用nnet-train-simple,当你使用GPUs编译时,默认地将使用一个GPU。如果 –num-threads超过1,它将调用nnet-train-parallel,而它不是使用一个GPU。

Tuning the number of jobs

接下来我们描述如何在CPU训练和GPU训练中切换的一些重要点。你也许注意到一些样例脚本中(比如:对这一对脚本做对比local/nnet2/run_4c.sh和local/nnet2/run_4c_gpu.sh),选项–num-jobs-nnet的值在GPU脚本和CPU脚本中是不一样的,比如在CPU版本是8,GPU版本是4。和–minibatch-size有时在这两个版本中也不同,举例来说, 在GPU中是512,在CPU中是128,学习率有时也不同。

这里将解释这些不同的原因。首先,不考虑minibatch的大小。你应该知道我们的SGD中梯度的计算不是通过对minbatch平均相加的。 在我们看来,当minbatch大小改变时,就需要去最大限度地改变学习率。一般而言,矩阵相乘在大的minbatch,比如512时比较快(每个样本)。我们使用的预处理方法,就是接下来讨论的Preconditioned Stochastic GradientDescent,当使用大点的minbatch效果会好点。所以当minbatch大小是512或者1024时,训练会更快收敛。然而,这里有个限制,minbatch的大小与SGD更新的不稳定相关(和参数起伏不定和不可控)。如果minbatch太大,更新会不稳定。一旦这种不稳定性变大,它将受到选项–max-change的限制,对于每个minbatch,它会影响参数允许的改变范围,所以一般不会使得训练集的概率一直到负无穷大,但是它们也许会降得特别快。如果你在compute_prob_train.*.log里看到目标函数低于我们系统里叶子节点数目的负自然对数(一般大约为-7,你也可以在compute_prob_train.0.log看到这个值),意思就是神经网络比chance要差,这是因为不稳定性导致的一些设置。这个问题的解决方法一般是降低学习率或者minibatch大小。

接下来我们讨论多线程更新时的不稳定的问题。当我们使用多线程更新时,为了稳定性这个目的,对minbatch的大小乘以线程数,所以我们让minbatch的大小低于设定的值。当我们使用CPU多线程训练时,一般minbatch的大小为128。(我们应该注意到,考虑到多线程CPU更新,我们尝试做单线程训练和允许使用BLAS来实现使用多线程,但是我们发现在相同的参数上,它比单线程独立地做SGD要快)。

接下来,不考虑选项–num-jobs-nnet:相对于GPU的例子,CPU的例子一般使用更多的任务数(8或者16)。原因很简单,因为在运行样例时,我们不想有CPU那么多的GPU。GPU训练一般比CPU快20%-50%。我们感觉我们使用更少的任务来达到相同的训练时间。但是一般情况任务数是独立的,无论我们使用CPU或者GPU。

最后一个修改就是学习率(选项–initial-learning-rate和 –final-learning-rate),和这个与任务数有关(–num-jobs-nnet)。 一般而言,如果我们增加任务数,我们也应该以相同的因子来增加学习率。 因为这里的并行方法是对并行SGD跑出来的神经网络求平均得到的,我们把整个学习过程中的有效学习率等价的看成学习率除以任务数。所以当把任务数加倍时,如果我们也加倍学习率,但我们保持的有效学习率不变。但是这里有一些限制。如果学习率变高将导致不稳定,参数的更新将会发散。因此当最初的学习率变高,我们警惕它会增加很快。增加多少的依赖于我们的例子。

Tuning the neural network training

一般而言,当调整神经网络训练参数时,你应该从样例目录egs///local/nnet2/里的一些脚本开始,以某个方式来改变参数。这里假设你已经运行了train_tanh.sh或者train_pnorm.sh。

Number of parameters (hidden layers and hidden layer size)

最重要的参数就是隐含层的层数(–num-hidden-layers)。 对于tanh网络来说,它一般在2到5之间(如果数据越多,它的值越大),和对于p-norm网络来说,它的值在2和3或者4之间。当修改隐含层的层数时,我们一般把隐含层节点数固定(512,或者1024,或者其他)。

对于tanh网络,你也可以改变隐含层的维度–hidden-layer-dim ;它是隐含层节点的数目。如果有更多的数据,这个值一般会越大,但是需要注意的是当它增大时,参数的数目将以2倍增长,所以你添加更多的数据时,它的增长应该小于0.5次方(比如:如果你的数据增加了10倍,你隐含层大小增加1倍将是有意义的)。我们不会用到2048或者更大的。对于一个很大的网络,我们一般使用1024个隐含层节点。

对于p-norm网络来说,它是没有参数–hidden-layer-dim;取代它的是其他两个参数,–pnorm-output-dim和–pnorm-input-dim。它们各自的默认值为(3000, 300)。 output-dim必须是input-dim的一个整数倍;我们一般使用时5倍或者10倍。这个会影响参数的数目;如果是大数据集,这个值将变大,但是跟tanh网络的隐含层大小一样,它的增大跟数据量的多少仅仅是缓慢变化的。

与参数的数目相关的其他选项是–mix-up。 它负责为每个叶子节点提供多重虚拟的目标,最后的softmax层的大小是随着决策树里的叶子节点的数目而变化(叶子节点的数目可以通过am-info函数,对神经网络训练里的输入目录里的final.mdl来得到);通常它的值为几千。参数 –mix-up一般是叶子节点的2倍左右,但是一般错误率对其不是很敏感。

Learning rates

另一个重要的参数就是学习率。这里有两个主要的参数:–initial-learning-rate和–final-learning-rate。 它们默认的值分别为0.04和0.004。我们一般设定最终的学习率是最初学习率的五分之一或者十分之一。默认值0.04和0.004仅仅对小数据集是合适的,比如rm数据集,大约3个小时。如果数据量越大,你将训练更长时间,所以不需要这么大的学习率。 对于几百小时的数据集来说,这个学习率的十分之一也许是合适的。下面将介绍学习率和任务数之间的关系。

如果不画出目标函数在时间轴上的曲线图,我们很难去判断学习率是太高还是太低。如果学习率很大,目标函数很快就收敛,但是得不到一个很好的目标函数值(就像被噪声梯度隐藏起来了)。但你可能使参数波动,将会得到一个非常差的目标函数值(如果minbatch的大小太大或者你使用多线程,这种情况将最可能发生)。如果学习率太低,目标函数将更新缓慢和将花费很长的时间到达最值。

你可能不需要调整的一个学习率参数是在脚本train_tanh.sh里的一个配置值–final-learning-rate-factor,默认设置为0.5。在最后2层中,将使用给定学习率的一半(比如:softmax层和最后的隐含层的参数)。脚本train_pnorm.sh script支持一个相同的配置值 –soft-max-learning-rate-factor,它将影响最后的softmax层之前的参数,但是它的默认值为1.0。

Minibatch size

另一个可调整的参数就是minibatch的大小。我们一般使用2的次方,典型的有128,256或者512。一般一个大点的minbatch会更有效,因为它可以更好的与矩阵相乘代码里使用到的优化进行交互,尤其在使用GPU时,但是它如果太大(和如果学习率太大),在更新时会导致不稳定。对于基于CPU训练时,使用多线程的Hogwild!形式更新,如果minbatch的大小太大,更新将变不稳定。对于多线程的CPU训练时,minbatch的大小一般为128;对于基于GPU的训练,minbatch的大小为512。这就不需要再去调整了。我们需要注意的是,minibatch的大小跟接下来讨论的–max-change选项有关,如果minbatch越大,也就意味着–max-change越大。

Max-change

在脚本train_tanh.sh和train_pnorm.sh里有个选项–max-change,这个值将传递给包含权重矩阵的那些成分的初始化(它们是类型AffineComponent或者AffineComponentPreconditioned)。–max-change 选项限制了每个minibatch允许多少参数来修改,以l2范数来衡量,比如这个矩阵表示在任何给定的minibatch,任何给定层的参数的改变都不能超过这个值。为了做到这一点,我们使用一个临时矩阵来存储这些参数的变化。这是浪费的,因为事实上这个界是这个minbatch所有成员的l2范数贡献之和。如果这个超过了"max-change",我们就为这个minbatch的学习率乘以一个常数,以使得它不超过这个限制。如果选项max-change约束被激活,你将在train.*.log里看到如下y的一些信息:

LOG <snip> Limiting step size to 40 using scaling factor 0.189877, for component index 8
LOG <snip> Limiting step size to 40 using scaling factor 0.188353, for component index 8

(事实上,这个因子比正常的要小——打印出来的这些因子通常是接近这个。 也许对于这个特定的迭代,学习率太高了。–max-change是一个故障安全机制,如果学习率太高(或者minibatch大小太大),它将不会导致不稳定。–max-change可以减慢训练中学习过快的问题,特别是对最后的一层或者2层;稍后再训练过程中,这个约束将不再起作用,和在训练的结束中你不需要去看logs文件里的这些信息。这个参数将不再是重要的。如果minbatch的大小是512时,我们通常设置它为40(比如:当使用GPU时),和如果minbatch的大小为128时,它为10(比如:当使用CPU时)。 这个是有意义的,因为限制这个数量跟minbatch样本的数量是成正比的。

Number of epochs, etc.

训练的迭代次数这里有两个配置变量:–num-epochs (默认为15),和 –num-epochs-extra (默认为5)。 训练的迭代次数–num-epochs的准则就是从–initial-learning-rate从几何学来减少学习率到–final-learning-rate,和对于迭代次数为 –num-epochs-extra,我们最后保持–final-learning不变。一般情况下,改变迭代次数是没必要的,除了有时是小数据集时,我们训练的迭代次数为20+5,而不是15+5。同时,如果数据量很大,和我们的计算环境不是很强大, 你也许通过减少训练迭代次数来节省时间。这样也许会轻微地降低最终性能。

有时候,这个也与参数–num-iters-final有关。这个决定了我们最终的模型组合的迭代次数,在训练的最后时(看Final model combination)。我们坚信这不是一个非常重要的参数。

Feature splicing width.

这里有个选项–splice-width,默认的值为4,它将影响我们分配多少帧的特征给输入。这个会影响神经网络的初始化,和样例的生成。这个值4意思是在这个中间帧的左边和右边各4帧,总共9帧数据。 参数–splice-width事实上是一个相当重要的参数,但是对于正常的全处理特征来说(比如:从MFCC+splice+LDA+MLLT+fMLLR里得到的40维特征),4就是一个最佳值。注意LDA+MLLT特征就是根据中间帧的每一边都是3或者4帧,也就是表示整个有效的声学上下文的神经网络可以看到每一边都是7或者8帧。如果你使用"raw" MFCC或者log-filterbank-energy特征(看脚本get_egs.sh和get_lda.sh里的选项"--feat-type raw"), 你也许可以把–splice-width设置大一点,比如为5或者6。

许多人问我们,为什么使用超过4帧的上下文会不好?如果我们的目标是获得最好目标函数和去分每一个隔离的帧,或者你在对像没有使用语言模型里的TIMIT数据库的解码,整个答案是肯定的。问题就是如果使用更多的上下文,会降低我们整个系统的性能。我们认为HMMs基于state-conditional帧独立的假设,系统很难与其交互好。总之,无论什么原因,它看起来都没有起作用。

Configuration values relating to the LDA transform.

在训练神经网络前,我们对特征使用了一个去相关变换。这个变换事实上称为神经网络的一部分—— 我们在之前固定的FixedAffineComponent类型,它是不需要训练的。我们称它为LDA变换,但是它跟传统的LDA是不一样的,因为这里我们对变换的行进行尺度拉伸。 这个部分将处理影响这个变换的配置值。这些通过选项–lda-opts ""传递到脚本get_lda.sh。

注意这里除了对数据进行去相关,我们还得使数据是零均值;这也许是因为输出是一个仿射变换(linear term plus bias),表示一个d*(d+1)的矩阵,而不是d*d(这里的d表示特征的维度,一般为40*9)。 默认地,这个变换是一个非降维形式的LDA,比如:我们保持整个维度。这也许听起来有点奇怪,因为LDA的作用就是降维。但是我们这里将它用来对数据去相关。

在传统的LDA,大多数会这么去做,对数据进行归一化,以使得类内方差为单位矩阵,类间方差是对类与类之间的方差按从最大到最小排列的对角矩阵。所以在这个变换之后,整个方差(类内和类间)在第i个对角上的值为1.0 + b(i),这里的b(i)是与数据无关的,和随i降低的。我们修改的LDA,不是真正的LDA,采用这个变换和对每一行乘以,这里默认的 within-class-factor为0.0001。这个方差的影响是这个因子的平方根,所以方差的第i个元素的影响因子默认地不是1.0 + b(i),而是 0.0001 + b(i)。基本上,我们缩减那些无信息的维度,因为我们的经验是添加无信息的数据到神经网络的输入会使性能变差,和简单地通过缩小它,可以使SGD训练在大多数情况下忽略它,这个是有用的。 我们怀疑如果对神经网络做一个简单的假设,比如它仅仅是一个逻辑斯特回归或者更简单的一个,这样的方案有时候是最佳的(也是使用0而不是0.0001)。不管怎么说,这个仅仅是一个技巧。

这里的配置参数–lda-dim有时候是用来强制这个变换来降维,而不是使所有的维度都通过。当处理一个输入维度特别大,我们会常常使用它,但是也不一定是有用的。

Other miscellaneous configuration values

对于脚本train_tanh.sh,有个选项–shrink-interval(默认为5),它决定我们多久对模型做一个收缩(看Model "shrinking" and "fixing"),我们使用训练数据集的一小部分来优化不同层参数的拉伸。这个不是很重要的。

选项–add-layers-period(默认为2)控制了在添加层之间我们需要等待的迭代次数,训练一开始我们就添加新的层。 这个可能是有差别的,但是我们一般不去调整它。

Preconditioned Stochastic Gradient Descent

这里我们不使用传统的Stochastic Gradient Descent (SGD),而使用一个特殊的预处理形式的SGD。这就意味着,不是使用一个通常的学习率,而是使用一个矩阵值的学习率。这个矩阵有一个特殊的结构,所以实际上是每个仿射成分的输入维度的一个矩阵和输出维度上的一个矩阵。如果你想把它看成一个单个大矩阵,它将是一个对角块结构的矩阵,其中每个块是两个矩阵的Kronecker积 (一个是输入维度和一个对应仿射成分的输出维度)。除此之外,每个minibatch中矩阵被估计。最基本的思想就是当衍生物有一个高的方差就降低学习率;这将趋去控制不稳定性和在任何一个方向上因为太快而停止参数更新。

这里我们还没有足够的时间来做一个详细的总结,但是可以看源文件nnet2/nnet-precondition.hnnet2/nnet-precondition-online.h的说明,这里将描述的更详细。我们需要注意的是nnet2/nnet-precondition.h包含这个方法最原始的版本,对每一个minibatch来估计预处理矩阵。而 nnet2/nnet-precondition-online.h包含这个方法的最新版本,这里的预处理矩阵是一个特殊的低秩加单元矩阵的结构和通过在线估计;如果使用GPU来实现会更加有效,因为旧的方法依赖对称矩阵求逆,而且是在一个相当高的维度上做的(比如:512),在一个GPU上很难做到是有效的,然后就变成了一个瓶颈。