rwthlm源码分析(六)之输入层以及训练框架

这篇介绍rwthlm输入层的结构,以及整个网络训练的框架。对于rwthlm的rnn结构部分在隐层我觉的还是比较常见的实现方式了,如果在训练rwthlm时指定了用rnn来训练,那么输入层的结构也会带有循环部分,关于这一点,在代码中我会说明。仍然是如果有任何错误,欢迎看到的朋友指出,再次谢过~

输入层的实现在tablelookup.cc里面,在第一次看这个包时,看文件名大概就知道哪些文件属于神经网络的哪些部分了,比如lstm.cc, output.cc,找了很久没找到输入层,后面才知道输入层就是这个tablelookup, 至于为啥叫tablelookup, 我想应该是输入层的word作为输入时需要对应的映射实数向量,而这个映射的实数向量在一个矩阵里面,就相当于查表一样。这里的实现方式和Bengio在其03年那篇文章里面是一致的,输入的编码并未使用1-of-V的方式。先看一下头文件的一些变量吧:

<span style="font-family:Microsoft YaHei;">  //is_feedforward这个含义在后面具体解释,其值不同就是训练算法的不同
  const bool is_feedforward_;
  
  //order_指定训练的历史数,这里并不是语言模型的阶数,而是语言模型的阶数减1
  //word_dimension_是指一个word映射为实数向量的维度
  const size_t order_, word_dimension_;
  //histories_存放着网络的输入信息
  std::vector<std::vector<int>> histories_;
  Real *b_, *b_t_, *delta_, *delta_t_, *weights_, *bias_;
  //输入层的循环结构部分
  RecurrencyPointer recurrency_; </span>

先解析一下容器histories_是如何作为网络的输入的,histories_容器的更新实现代码如下:

<span style="font-family:Microsoft YaHei;">void TableLookup::UpdateHistories(const size_t size, const Real x[]) {
 // std::cout << "This is UpdateHistories\n";
  if (histories_.empty()) {
    for (size_t i = 0; i < size; ++i)
      //std::vector<int>(order_, static_cast<int>(x[i]))
      //的含义是构造order_个值为x[i]的元素,放入容器
      histories_.push_back(std::vector<int>(order_, static_cast<int>(x[i])));
  //  std::cout << "push into empty:\n";
  //  PrintHistories();
  } else {
    for (size_t i = 0; i < size; ++i) {
      std::vector<int> &history(histories_[i]);
      //新来的数加入到第一个位置上
      history.insert(history.begin(), static_cast<int>(x[i]));
      //删除最后一个元素
      history.pop_back();
    }
 //   PrintHistories();
  }
}</span>

先不看其他实现部分,举一个例子,看下histories_的工作方式,这样在看下面的代码时就能非常快的明白了。比如现在需要处理的batch是下面这样的:



实际上在data容器中是不会直接存储字符串的,而是字符串对应的索引,如下:



那么假设现在整个网络运作起来了,它的输入历史数假设是3,根据上面histories_更新的实现代码,知道初始化的内容为<sb>。

第一次更新histories_:

3     3     3

3     3     3

3     3     3

这个时候网络的期望输出是23, 13, 10


第二次更新histories_:

23    3      3

12    3      3

10    3      3

此时网络的期望输出是29, 25, 2


如此反复,知道最后一次更新历史:

4    14    28

4    15    18

注意此时只有两行了,因为最后一行(即第三个句子)已经在前一次完成了本批次的输入了,这个时候网络的期望输出时3, 3


无循环结构的输入层的结构类似于下面的图:


当is_recurrent == true下面看一下输入层的结构变化,这里我不知道自己是否理解错了没,因为以前没见过rnn结构在输入层做循环的,一般都是在隐层,如果明白的朋友看到希望普及一下知识,也希望我这里的代码分析没弄错。当设定为循环网络时,输入层也会有循环的部分,结构图如下:



输入层的核心实现代码及注释如下:

<span style="font-family:Microsoft YaHei;">TableLookup::TableLookup(const int input_dimension,
                         const int output_dimension,
                         const int max_batch_size,
                         const int max_sequence_length,
                         const int order,
                         const bool is_recurrent,
                         const bool use_bias,
                         const bool is_feedforward,
                         ActivationFunctionPointer activation_function)
    : Function(input_dimension,
               output_dimension,
               max_batch_size,
               max_sequence_length),
      order_(order),	//历史数
      is_feedforward_(is_feedforward),
      word_dimension_(output_dimension / order),	//一个word对应的维度
      activation_function_(std::move(activation_function)) {
  assert(order == 0 || output_dimension == word_dimension_ * order);
  //和前面结构类似,这里表示输入层的输出
  b_ = FastMalloc(output_dimension * max_batch_size * max_sequence_length);
  //误差
  delta_ = FastMalloc(output_dimension * max_batch_size * max_sequence_length);
  //input_dimension即voc的大小(包含<sb>标签),这里weights_就是所谓的词向量
  weights_ = FastMalloc(word_dimension_ * input_dimension);
  bias_ = use_bias ? FastMalloc(word_dimension_) : nullptr;
  
  //循环结构
  if (is_recurrent) {
    recurrency_ = RecurrencyPointer(new Recurrency(output_dimension,
                                                   max_batch_size,
                                                   max_sequence_length,
                                                   b_,
                                                   b_t_,
                                                   delta_,
                                                   delta_t_));
  }
 //清空历史信息
  ResetHistories();
}

const Real *TableLookup::Evaluate(const Slice &slice, const Real x[]) {

 //std::cout << "This is Evaluate\n";
  Real *result = b_t_;
  //更新历史,即新的x元素进入history第一个位置,末尾的删掉
  UpdateHistories(slice.size(), x);
  if (bias_) {
   //把偏置复制到b_t_,下一步相当于加上了偏置
    for (size_t i = 0; i < slice.size() * order_; ++i)
      FastCopy(bias_, word_dimension_, b_t_ + i * word_dimension_);
  }
  
//#pragma omp parallel for

  for (int i = 0; i < slice.size() * order_; ++i) {
    //下面的代码就是在遍历histories_,遍历的顺序是
    //for (int i=0; i<histories_.size(); i++) {
    //    for (int j=0; j<histories_[i].size(); j++) histories_[i][j];
    //}
    //std::cout << i / order_ << "\t" << i % order_ << std::endl;
    const int word = histories_[i / order_][i % order_];
    //这里关于weights_的结构如图,对于一个word,它在weights中的定位是:
    //word * word_dimension_,然后word_dimension_个长度单元的值都属于它
    //这里计算当前历史所形成的输入
    FastAdd(&weights_[word * word_dimension_],
            word_dimension_,
            b_t_ + i * word_dimension_,
            b_t_ + i * word_dimension_);
  }
 //如果是循环网络结构
  if (recurrency_)
    //则计算前一段的历史信息乘以权值加上本次历史信息的和作为输入
    //这个循环结构我第一次看到,在word往输入层上映射时加了一层循环
    //我做了图,不知道这里理解是否正确。那么这样的话,我个人理解如果对网络的组合是历史数n(n>1) + 循环结构
    //效果应该会比较混乱吧,因为历史数一旦大于1,再利用这种循环结构,输入层就会出现历史重叠,而标准的rnnlm都是历史数为1
    //后面补充:看到代码后面的框架时,发现如果是循环网络,历史数只能是1,程序是规定了的
    recurrency_->Evaluate(slice, x);
  //计算输入层的输出
  activation_function_->Evaluate(output_dimension(), slice.size(), result);
  b_t_ = result + GetOffset();
  return result;
}

void TableLookup::ComputeDelta(const Slice &slice, FunctionPointer f) {
 // std::cout << "This is ComputeDelta\n";
  //从末时刻往0走
  b_t_ -= GetOffset();
  //获得隐层流过来的误差
  f->AddDelta(slice, delta_t_);
  if (recurrency_)
    //t+1时刻输入层流向t时刻的误差
    recurrency_->ComputeDelta(slice, f);
  //计算输入层的误差信号
  activation_function_->MultiplyDerivative(output_dimension(), slice.size(),
                                           b_t_, delta_t_);
  delta_t_ += GetOffset();
}

void TableLookup::AddDelta(const Slice &slice, Real delta_t[]) {
  // there is no layer prior to the table lookup layer
}

const Real *TableLookup::UpdateWeights(const Slice &slice,
                                       const Real learning_rate,
                                       const Real x[]) {
 // std::cout << "This is UpdateWeights\n";
  delta_t_ -= GetOffset();
  //注意下面的is_feedforward_的意思不是指判断是否为循环结构还是前向的结构,官网上看的介绍说是打开这个开关
  //则是用的标准BP算法,否在使用epochwise backpropagation through time
  //有点不太明白它啥意思
  //自己的理解是:这里UpdateHistories应该是必须要执行的,因为后面会更新word所在的实数向量
  //就必须找到word对应的向量,而word存在历史中,Evaluate函数的反复执行中,历史在histories_中最终
  //只存下了句子结尾处的信息,所以需要清空histories_后,像原来加入histories_那样再执行一遍,这样
  //才能找到对应的word,并执行下面的更新

  //补充:看了后面的代码算是明白了,is_feedforward的训练方式是网络输入一次,更新一次
  //而默认情况下是网络输入多次,然后一起来更新。如果是is_feedforward则没必要执行UpdateHistories()
  if (!is_feedforward_)
    UpdateHistories(slice.size(), x);
  if (bias_) {
   //计算对bias的改变量
    for (size_t i = 0; i < slice.size() * order_; ++i) {
      FastMultiplyByConstantAdd(-learning_rate,
                                delta_t_ + i * word_dimension_,
                                word_dimension_,
                                bias_);
    }
  }

  //word映射为word_dimension_维度的实数向量,这里更新它
  for (size_t i = 0; i < slice.size() * order_; ++i) {
    const int word = histories_[i / order_][i % order_];
    FastMultiplyByConstantAdd(
        -learning_rate,
        delta_t_ + i * word_dimension_,
        word_dimension_,
        weights_ + word_dimension_ * word);
  }
  //更新输入层自连的权值
  if (recurrency_)
    recurrency_->UpdateWeights(slice, learning_rate, x);
  const Real *result = b_t_;
  //从0到t时刻走
  b_t_ += GetOffset();
  return result;
}

void TableLookup::UpdateMomentumWeights(const Real momentum) {
  if (recurrency_)
    recurrency_->UpdateMomentumWeights(momentum);
}

void TableLookup::ResetMomentum() {
  if (recurrency_)
    recurrency_->ResetMomentum();
}

void TableLookup::UpdateHistories(const size_t size, const Real x[]) {
 // std::cout << "This is UpdateHistories\n";
  if (histories_.empty()) {
    for (size_t i = 0; i < size; ++i)
      //std::vector<int>(order_, static_cast<int>(x[i]))
      //的含义是构造order_个值为x[i]的元素,放入容器
      histories_.push_back(std::vector<int>(order_, static_cast<int>(x[i])));
  //  std::cout << "push into empty:\n";
  //  PrintHistories();
  } else {
    for (size_t i = 0; i < size; ++i) {
      std::vector<int> &history(histories_[i]);
      //新来的数加入到第一个位置上
      history.insert(history.begin(), static_cast<int>(x[i]));
      //删除最后一个元素
      history.pop_back();
    }
 //   PrintHistories();
  }
}</span>


上面便是输入部分的结构,下面在看一下训练的框架,下面的部分直接贴注释了,因为注释内容比较详细。

在train.cc里面,几个核心的训练函数代码如下:

<span style="font-family:Microsoft YaHei;">//这个函数负责对语料训练一遍
void Trainer::TrainEpoch() {
  Real log_probability = 0.;
  int64_t num_running_words = 0;
  //batch遍历data,就是一次一个batch
  for (const Batch &batch : *training_data_) {
    net_->Reset(false);
    net_->ResetHistories();
    bp::ptime time;
    if (verbose_)
      time = bp::microsec_clock::local_time();
    //默认情况is_feedforward_ == false的

    //下面训练一个batch
    if (is_feedforward_)
      TrainBatchFeedforward(batch, &log_probability, &num_running_words);
    else
      TrainBatch(batch, &log_probability, &num_running_words);
    if (verbose_) {
      std::cout << "training perplexity = " << std::fixed <<
                   std::setprecision(2) << exp(-log_probability /
                   num_running_words) << std::endl;
      std::cout << "time = " << std::fixed << std::setprecision(3) <<
                   (bp::microsec_clock::local_time() - time).
                   total_milliseconds() / 1000. << " seconds" << std::endl;
    }
  }
}

void Trainer::TrainBatch(const Batch &batch,
                         Real *log_probability,
                         int64_t *num_running_words) {
  // forward pass

  //previous_slice是一个vector,存放是int类型的
  //previous_slice最开始指示着<sb>  
  auto previous_slice(*batch.Begin(0));
  //PrintSlice(previous_slice);

 //next_slice初始值和previous_slice不同,它是从句子第一个word开始的
 //这里previous_slice表示网络的输入(对于含有历史信息的,再加上历史),next_slice表示网络的期望输出
  for (auto next_slice : batch) {

    //Caster(previous_slice).Cast()将previous_slice转换为real类型,并且返回一个指针指向它
    //结果返回值到x,x就是网络的输出
    const Real *x = net_->Evaluate(next_slice, Caster(previous_slice).Cast());
   
   //ComputeLogProbability返回以e为底的对数概率值,这个是在一个slice上面对数概率累加
   //*log_probability记录的就是一个batch的累加值
    *log_probability += net_->ComputeLogProbability(next_slice, x, false);

   //统计训练的word数目
    *num_running_words += next_slice.size();
    previous_slice = next_slice;
  }

  // backward pass

 //现在的slice是指向batch最后一个word的后面,就是什么都没有
 //--slice后,指向最后一个word
  auto slice = batch.End(1);
  do {
    --slice;
    //这里的slice表示期望的输出
    net_->ComputeDelta(*slice, FunctionPointer());
  } while (slice != batch.Begin(1));
  //这段循环一直走到slice在句子第一个word,然后计算误差后结束循环

  //主要是输入层清空历史,必须要清空的原因是需要重建历史
  //因为要准备更新,输入层的历史word对应的实数向量是有具体的位置的
  net_->ResetHistories();

  // weight update
  //这里计算需要更新的量,并未真正进行更新
  //previous_slice仍然表示网络当前的输入
  previous_slice = *batch.Begin(0);
  //next_slice仍然表示网络期望的输出
  for (auto next_slice : batch) {
    net_->UpdateWeights(next_slice, Caster(previous_slice).Cast());
    previous_slice = next_slice;
  }
  //这一步更新参数
  net_->UpdateMomentumWeights();
}

void Trainer::TrainBatchFeedforward(const Batch &batch,
                                    Real *log_probability,
                                    int64_t *num_running_words) {
  // forward pass
  //这里的过程和前面差不多,更新的方式是输入一次,更新一次
  //前面是输入多次,后累加起来,然后一并更新
  auto previous_slice(*batch.Begin(0));
  for (auto next_slice : batch) {
    net_->Reset(false);
    const Real *x = net_->Evaluate(next_slice, Caster(previous_slice).Cast());
    *log_probability += net_->ComputeLogProbability(next_slice, x, false);
    *num_running_words += next_slice.size();
    net_->ComputeDelta(next_slice, FunctionPointer());
    net_->UpdateWeights(next_slice, Caster(previous_slice).Cast());
    previous_slice = next_slice;
    net_->UpdateMomentumWeights();
  }
}</span>


其中里面调用的函数时net.cc提供的,比如Evaluate函数,更新函数等,net.cc核心代码如下:

<span style="font-family:Microsoft YaHei;">//f里面装入的层顺序是:输入,隐层,输出层。这样的,这个函数的功能
//相当于计算整个网络的输出,返回的x即使网络的输出,经过softmax后的
const Real *Net::Evaluate(const Slice &slice, const Real x[]) {
  for (FunctionPointer f : functions_)
    //这里Evaluate返回的是当前层的输出,再下一次迭代时,就作为上层的输入
    x = f->Evaluate(slice, x);
  return x;
}

//计算误差,恰好是反着的,从输出层开始往输入层计算
void Net::ComputeDelta(const Slice &slice, FunctionPointer f) {
  //因为是functions_是将输入到输出顺序装入的,所以这里用reverse
  for (FunctionPointer g : boost::adaptors::reverse(functions_)) {
    //这里的f是表示g的前面一层,在计算输出层的ComputeDelta时,f无用处
    //在后面中间层时,计算误差需要计算输出层传过来的误差,也就需要上一层的指针
    g->ComputeDelta(slice, f);
    f = g;
  }
}

const Real *Net::UpdateWeights(const Slice &slice, const Real x[]) {
  return UpdateWeights(slice, learning_rate(), x);
}

//更新权值,下面的函数应该只是计算需要更新的量
const Real *Net::UpdateWeights(const Slice &slice,
                               const Real learning_rate,
                               const Real x[]) {
//f里面装入的层顺序是:输入,隐层,输出层
//更新的顺序是从输入->输出的
  for (FunctionPointer f : functions_)
    //这里的x表示前层的输出,UpdateWeights返回值是该更新层的输出
    x = f->UpdateWeights(slice, learning_rate, x);
  return x;
}</span>

好啦,对rwthlm的学习就到此为止了,从3月份开始从rnnlm一直到lstmlm这段学习也要告一段落了。未来继续学习,加油吧,少年!

猜你喜欢

转载自blog.csdn.net/a635661820/article/details/45417557