在浏览器中进行深度学习:TensorFlow.js (七)递归神经网络 (RNN) - naughty的个人页面 - 开源中国

【新睿云 】云服务器0元/年 >>>   

介绍

上一篇博客我们讨论了CNN,卷积神经网络。CNN广泛应用于图像相关的深度学习场景中。然而CNN也有一些限制:

  • 很难应用于序列数据
  • 输入数据和输出数据都是固定长度
  • 不理解上下文

这些问题就可以由RNN来处理了。

神经网络除了CNN之外的另一个常见的类别是RNN,递归/循环神经网络。这里的R其实是两种神经网络,Recurrent:时间递归 , Recusive:结构递归。时间递归神经网络的神经元间连接构成有向图,而结构递归神经网络利用相似的神经网络结构递归构造更为复杂的深度网络。我们大部分时间讲的RNN指的是前一种,时间递归神经网络。

RNN的结构如上图所示,为了解决上下文的问题,RNN网路中,输入Xt,输出ht,而且输出ht回反馈回处理单元A。上图右边是随时间展开的序列图。tn时间的输出hn反馈成为tn+1时间的输入,hn和Xn+1一起成为tn+1时间的输入。这样也就保留了上下文对模型的影响,以更好的针对时间序列建模。

如下图所示,RNN可以支持不同的输入输出序列。

RNN有一些变体,常见的是LSTM和GRU

LSTM即Long Short Memory Network,长短时记忆网络。它其实是属于RNN的一种变种,可以说它是为了克服RNN无法很好处理远距离依赖而提出的。

GRU即Gated Recurrent Unit,是LSTM的一个变体。GRU保持了LSTM的效果同时又使结构更加简单,所以它也非常流行。

RNN可以有效的应用在以下的领域中:

  • 音乐作曲
  • 图像捕捉
  • 语音识别
  • 时序异常处理
  • 股价预测
  • 文本翻译

例子:用RNN实现加法运算

我们这里介绍一个利用RNN来实现加法运算的例子,源代码在这里,或者去我的Codepen运行我的例子。这个例子最早源自keras

科学世界的论证(reasoning)方式有两种,演绎(deduction)和归纳(induction)。

所谓的演绎就是根据已有的理论,通过逻辑推导,得出结论。经典的就是欧几里得的几何原理,利用基本的公设和公理演绎出了整个欧氏几何的大厦。而机器学习则是典型的归纳法,数据先行,现有观测数据,然后利用数学建模,找到最能够解释当前观察数据的公式。这就像是理论物理学家和实验物理学家,理论物理学家利用演绎,根据理论推出万物运行的道理,实验物理学家通过实验数据,反推理论,证实或者否定理论。当然两种方法是相辅相成的,都是科学的利器。

好了我们回到加法的例子,这里我们要用机器学习的方法来教会计算机加法,记得用归纳而不是演绎。因为计算机是很擅长演绎的,加法的演绎是所有计算的基础之一,定义0,1,2=1+1,然后演绎出所有的加法。这里用归纳,然计算机算法通过已有的加法例子数据找到如何计算加法。这样做当然不是最有效的,但是很有趣。

我们来看例子吧。

首先是一个需要一个字符表的类来管理字符到张量的映射:

class CharacterTable {
  /**
   * Constructor of CharacterTable.
   * @param chars A string that contains the characters that can appear
   *   in the input.
   */
  constructor(chars) {
    this.chars = chars;
    this.charIndices = {};
    this.indicesChar = {};
    this.size = this.chars.length;
    for (let i = 0; i < this.size; ++i) {
      const char = this.chars[i];
      if (this.charIndices[char] != null) {
        throw new Error(`Duplicate character '${char}'`);
      }
      this.charIndices[this.chars[i]] = i;
      this.indicesChar[i] = this.chars[i];
    }
  }

  /**
   * Convert a string into a one-hot encoded tensor.
   *
   * @param str The input string.
   * @param numRows Number of rows of the output tensor.
   * @returns The one-hot encoded 2D tensor.
   * @throws If `str` contains any characters outside the `CharacterTable`'s
   *   vocabulary.
   */
  encode(str, numRows) {
    const buf = tf.buffer([numRows, this.size]);
    for (let i = 0; i < str.length; ++i) {
      const char = str[i];
      if (this.charIndices[char] == null) {
        throw new Error(`Unknown character: '${char}'`);
      }
      buf.set(1, i, this.charIndices[char]);
    }
    return buf.toTensor().as2D(numRows, this.size);
  }

  encodeBatch(strings, numRows) {
    const numExamples = strings.length;
    const buf = tf.buffer([numExamples, numRows, this.size]);
    for (let n = 0; n < numExamples; ++n) {
      const str = strings[n];
      for (let i = 0; i < str.length; ++i) {
        const char = str[i];
        if (this.charIndices[char] == null) {
          throw new Error(`Unknown character: '${char}'`);
        }
        buf.set(1, n, i, this.charIndices[char]);
      }
    }
    return buf.toTensor().as3D(numExamples, numRows, this.size);
  }

  /**
   * Convert a 2D tensor into a string with the CharacterTable's vocabulary.
   *
   * @param x Input 2D tensor.
   * @param calcArgmax Whether to perform `argMax` operation on `x` before
   *   indexing into the `CharacterTable`'s vocabulary.
   * @returns The decoded string.
   */
  decode(x, calcArgmax = true) {
    return tf.tidy(() => {
      if (calcArgmax) {
        x = x.argMax(1);
      }
      const xData = x.dataSync(); // TODO(cais): Performance implication?
      let output = "";
      for (const index of Array.from(xData)) {
        output += this.indicesChar[index];
      }
      return output;
    });
  }
}

这个类存储了加法运算所能用到的所有字符,“0123456789+ ”,其中空格是占位符,两位数的2会变成“ 2”。

为了实现字符到索引的双向映射, 这个类保存了两个表,charIndices是字符到索引,indicesChar是索引到字符。

encode方法把一个加法字符串映射为一个one hot的tensor:

this.charTable.encode("1+2",3).print();

Tensor
    [[0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
     [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0],
     [0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0]]

this.charTable.encode("3",1).print()

Tensor
     [[0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0],]

例如对于“1+2”等于“3”,输入和输出的张量如上所示。

decode是上述encode方法的逆向操作,把张量映射为字符串。

然后进行数据生成:

function generateData(digits, numExamples, invert) {
  const digitArray = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"];
  const arraySize = digitArray.length;

  const output = [];
  const maxLen = digits + 1 + digits;

  const f = () => {
    let str = "";
    while (str.length < digits) {
      const index = Math.floor(Math.random() * arraySize);
      str += digitArray[index];
    }
    return Number.parseInt(str);
  };

  const seen = new Set();
  while (output.length < numExamples) {
    const a = f();
    const b = f();
    const sorted = b > a ? [a, b] : [b, a];
    const key = sorted[0] + "`" + sorted[1];
    if (seen.has(key)) {
      continue;
    }
    seen.add(key);

    // Pad the data with spaces such that it is always maxLen.
    const q = `${a}+${b}`;
    const query = q + " ".repeat(maxLen - q.length);
    let ans = (a + b).toString();
    // Answer can be of maximum size `digits + 1`.
    ans += " ".repeat(digits + 1 - ans.length);

    if (invert) {
      throw new Error("invert is not implemented yet");
    }
    output.push([query, ans]);
  }
  return output;
}

生成测试数据的方法,输入是加法的位数和生成多少例子。对于两位数的加法,输入补齐为5个字符,输出补齐到3个字符,空位用空格。

generateData(2,10,false);

["24+38", "62 "]
["2+0  ", "2  "]
["86+62", "148"]
["36+91", "127"]
["66+51", "117"]
["47+40", "87 "]
["97+96", "193"]
["98+83", "181"]
["45+30", "75 "]
["88+75", "163"]

下一步需要把生成的数据转化成张量:

function convertDataToTensors(data, charTable, digits) {
  const maxLen = digits + 1 + digits;
  const questions = data.map(datum => datum[0]);
  const answers = data.map(datum => datum[1]);
  return [
    charTable.encodeBatch(questions, maxLen),
    charTable.encodeBatch(answers, digits + 1)
  ];
}

生成的数据是一个2个元素的列表,第一个元素是问题张量,第二个元素是答案张量。

数据生成好了,下一步就是创建神经网络模型:

function createAndCompileModel(
  layers,
  hiddenSize,
  rnnType,
  digits,
  vocabularySize
) {
  const maxLen = digits + 1 + digits;

  const model = tf.sequential();
  switch (rnnType) {
    case "SimpleRNN":
      model.add(
        tf.layers.simpleRNN({
          units: hiddenSize,
          recurrentInitializer: "glorotNormal",
          inputShape: [maxLen, vocabularySize]
        })
      );
      break;
    case "GRU":
      model.add(
        tf.layers.gru({
          units: hiddenSize,
          recurrentInitializer: "glorotNormal",
          inputShape: [maxLen, vocabularySize]
        })
      );
      break;
    case "LSTM":
      model.add(
        tf.layers.lstm({
          units: hiddenSize,
          recurrentInitializer: "glorotNormal",
          inputShape: [maxLen, vocabularySize]
        })
      );
      break;
    default:
      throw new Error(`Unsupported RNN type: '${rnnType}'`);
  }
  model.add(tf.layers.repeatVector({ n: digits + 1 }));
  switch (rnnType) {
    case "SimpleRNN":
      model.add(
        tf.layers.simpleRNN({
          units: hiddenSize,
          recurrentInitializer: "glorotNormal",
          returnSequences: true
        })
      );
      break;
    case "GRU":
      model.add(
        tf.layers.gru({
          units: hiddenSize,
          recurrentInitializer: "glorotNormal",
          returnSequences: true
        })
      );
      break;
    case "LSTM":
      model.add(
        tf.layers.lstm({
          units: hiddenSize,
          recurrentInitializer: "glorotNormal",
          returnSequences: true
        })
      );
      break;
    default:
      throw new Error(`Unsupported RNN type: '${rnnType}'`);
  }
  model.add(
    tf.layers.timeDistributed({
      layer: tf.layers.dense({ units: vocabularySize })
    })
  );
  model.add(tf.layers.activation({ activation: "softmax" }));
  model.compile({
    loss: "categoricalCrossentropy",
    optimizer: "adam",
    metrics: ["accuracy"]
  });
  return model;
}

这里的几个主要的参数是:

  • rnnType, RNN的网络类型,这里有三种,SimpleRNNGRULSTM
  • hiddenSize,隐藏层的Size,决定了隐藏层神经单元的规模,
  • digits,参与加法运算的数位
  • vocabularySize, 字符表的大小,我们的例子里应该是12, 也就是sizeof(“0123456789+ ”)

网络的构成如下图, 图中 digits=2,hiddenSize=128:

repeatVector层把第一个RNN层的输入重复digits+1次,增加一个维数,输出适配到要预测的size上。这里是构建RNN网络的一个需要设计的点。

之后跟着的是另一个RNN层。

然后是一个有12(vocabularySize)个单元全联接层,使用timeDistributed对RNN的输出打包,得到的输出是的形状为 [digits+1,12] 。TimeDistributed层的作用就是把Dense层应用到128个具体的向量上,对每一个向量进行了一个Dense操作。RNN之所以能够进行多对多的映射,也是利用了个这个功能。

最后是一个激活activate层,使用softmax。因为这个网络本质上是一个分类,也就是把所有的输入分类到 digit+1 * 12 的分类。 表示的digit+1位的数字。也就是说两个n位数字的加法,结果是n+1位数字。

最后一步,使用“Adam”算法作为优化器,交叉熵作为损失函数,编译整个模型。

模型构建好了,接下来就可以进行训练了。

训练的代码如下:

class AdditionRNN {
  constructor(digits, trainingSize, rnnType, layers, hiddenSize) {
    // Prepare training data.
    const chars = '0123456789+ ';
    this.charTable = new CharacterTable(chars);
    console.log('Generating training data');
    const data = generateData(digits, trainingSize, false);
    const split = Math.floor(trainingSize * 0.9);
    this.trainData = data.slice(0, split);
    this.testData = data.slice(split);
    [this.trainXs, this.trainYs] =
        convertDataToTensors(this.trainData, this.charTable, digits);
    [this.testXs, this.testYs] =
        convertDataToTensors(this.testData, this.charTable, digits);
    this.model = createAndCompileModel(
        layers, hiddenSize, rnnType, digits, chars.length);
  }
  
  async train(iterations, batchSize, numTestExamples) {
    console.log("training started!");
    const lossValues = [];
    const accuracyValues = [];
    const examplesPerSecValues = [];
    for (let i = 0; i < iterations; ++i) {
      console.log("training iter " + i);
      const beginMs = performance.now();
      const history = await this.model.fit(this.trainXs, this.trainYs, {
        epochs: 1,
        batchSize,
        validationData: [this.testXs, this.testYs],
        yieldEvery: 'epoch'
      });
      const elapsedMs = performance.now() - beginMs;
      const examplesPerSec = this.testXs.shape[0] / (elapsedMs / 1000);
      const trainLoss = history.history['loss'][0];
      const trainAccuracy = history.history['acc'][0];
      const valLoss = history.history['val_loss'][0];
      const valAccuracy = history.history['val_acc'][0];
      
      document.getElementById('trainStatus').textContent =
          `Iteration ${i}: train loss = ${trainLoss.toFixed(6)}; ` +
          `train accuracy = ${trainAccuracy.toFixed(6)}; ` +
          `validation loss = ${valLoss.toFixed(6)}; ` +
          `validation accuracy = ${valAccuracy.toFixed(6)} ` +
          `(${examplesPerSec.toFixed(1)} examples/s)`;

      lossValues.push({'epoch': i, 'loss': trainLoss, 'set': 'train'});
      lossValues.push({'epoch': i, 'loss': valLoss, 'set': 'validation'});
      accuracyValues.push(
          {'epoch': i, 'accuracy': trainAccuracy, 'set': 'train'});
      accuracyValues.push(
          {'epoch': i, 'accuracy': valAccuracy, 'set': 'validation'});
      examplesPerSecValues.push({'epoch': i, 'examples/s': examplesPerSec});
    }
  }
}

AdditionRNN类实现了模型训练的主要逻辑。

在构造函数重生成训练数据,其中百分之九十的数据用于训练,百分之十用于测试验证。

在训练中,循环调用model.fit方法进行训练。

训练好完毕,我们就可以使用该模型进行预测了。

const input = demo.charTable.encode("10+20",5).expandDims(0);
const result = model.predict(input);
result.print()
console.log("10+20 = " + demo.charTable.decode(result.as2D(result.shape[1], result.shape[2])));

Tensor
    [[[0.0010424, 0.0037433, 0.2403527, 0.4702294, 0.2035268, 0.0607058, 0.0166195, 0.0021113, 0.0012174, 0.0000351, 0.0000088, 0.0004075],
      [0.3456545, 0.0999702, 0.1198046, 0.0623895, 0.0079124, 0.0325381, 0.2000451, 0.0856998, 0.0255273, 0.0050597, 0.000007 , 0.0153919],
      [0.0002507, 0.0000023, 0.0000445, 0.0002062, 0.0000298, 0.0000679, 0.0000946, 0.0000056, 7e-7     , 2e-7     , 1e-7     , 0.9992974]]]
10+20 = 40 

使用charTable的encode方法把“10+20”编码转换为Tensor,因为输入为多个数据,所以用expandDims方法把它增加一个维度,变成只有一个数据的Batch

对于预测结果,只要看每一个Tensor行中最大的数据,就能找到对应的预测数据了。例如上面的例子对应的结果是:“30空格”。当然这次模型的训练数据比较小,没能正确预测也很正常。

最后我们看看这个RNN网络到底好不好用。使用digits=2,hiddenSize=128,trainIterations=300,batchSize=128

在这个例子中,当训练数据达到2000的时候,LSTM和GRU都能取得比较好的训练结果。2000意味着大概20%的两位数加法的数据。也就是说当掌握了大概20%的数据后,我们就能够比较有把握的预测其它的两位数的加法了。当训练数据是100的时候(1%),SimpleRNN也居然有43%的准确率,可以说是相当不错的模型了。

好了,但是为什么呢?为什么RNN可以用来预测加法呢?这个和时间序列又有什么关系呢?如果你和我有同样的疑问,请阅读这两篇论文:LEARNING TO EXECUTESequence to Sequence Learning with Neural Networks 

参考:


Original url: Access
Created at: 2018-10-18 11:51:35
Category: default
Tags: none

请先后发表评论
  • 最新评论
  • 总共0条评论