TensorFlow.js入门教程(6)迁移学习

(本文翻译自Transfer learning – Train a neural network to predict from webcam data

(未经同意,请勿转载)

在开始之前,我们强烈推荐观看 Demo

core concepts tutorial中文版)中,我们学习了如何使用张量和操作来执行基本的线性代数。

convolutional image classifier tutoria中文版)教程中,我们学习了如何构建一个卷积图像分类器来识别MNIST数据集的手写数字。

Importing a Keras model tutorial中文版) 中,我们学习了如何将预训练的Keras模型移植到浏览器进行推理。

在本教程中,我们将使用迁移学习从网络摄像头数据(姿势,物体,面部表情等)预测用户定义的类。为了玩游戏Pac-Man,我们将这些姿势分别定义为“上”,“下”,“左” ,“右” 。

关于游戏

游戏分为三个阶段。

  1. 数据收集:玩家将摄像头的图像与上下左右四个类别中的某个相关联。
  2. 训练:训练一个神经网络,根据输入图像预测它的类别。
  3. 推理/玩:使用我们训练的模型,通过网络摄像头的数据进行上下左右的预测,并将它们输入到Pac-man游戏!

关于模型

为了在合理的时间内能学习到对网络摄像头中的不同类别进行分类,我们将使用内部激活(来自MobileNet内部层的输出)作为我们新模型的输入,对预训练的 MobileNet 模型进行再训练或微调。

要做到这一点,我们实际在页面上有两个模型。

第一个是为了输出内部激活而被截断的预训练好的MobileNet模型,我们称之为“截断的MobileNet模型”。加载到浏览器后,此模型不会再被训练。

第二个模型将截断的MobileNet模型的内部激活的输出作为输入,并预测上,下,左,右这四个输出类别中的每一个的概率。这是我们实际在浏览器中训练的模型。

通过使用MobileNet的内部激活,我们可以重新使用MobileNet在学习预测ImageNet的1000个类别时已经学到的特征,用相对较少的再训练次数实现。

关于本教程

您可以通过克隆repo然后生成demo来运行示例的代码:

git clone https://github.com/tensorflow/tfjs-examples
cd tfjs-examples/webcam-transfer-learning
yarn
yarn watch

上面的 tfjs-examples/webcam-transfer-learning 目录是完全独立的,因此您可以复制它以开始自己的项目。

注意:这种方法与 Teachable Machine的方法不同。 Teachable Machine根据预训练的SqueezeNet模型的预测,使用K-nearest neighbors(KNN)进行分类,而我们的方法根据MobileNet的内部激活来训练第二个神经网络实现分类。 KNN图像分类器在数据量较小的情况下效果更好,但使用迁移学习的神经网络通用性更好。你可以去观看这两种方法的demo,然后去探讨这两种不同的预测方法有着怎样的差异。

数据

在我们可以训练模型之前,我们需要一种从网络摄像头获取张量的方法。

我们在webcam.js中提供了一个名为Webcam的类,它从<video>标签中读取图像并返回一个TensorFlow.js的张量。

我们来看看Webcam类上的capture方法。

capture() {
  return tf.tidy(() => {
    const webcamImage = tf.fromPixels(this.webcamElement);
    const croppedImage = this.cropImage(webcamImage);
    const batchedImage = croppedImage.expandDims(0);

    return batchedImage.toFloat().div(oneTwentySeven).sub(one);
  });
}

让我们来分析这些代码:

const webcamImage = tf.fromPixels(this.webcamElement);

该行代码从摄像头的<video>元素中读取一帧,并返回形状为[高度,宽度,3]的张量。 最内部的维度3对应于RGB三个通道。

有关该函数的参数所支持的HTML元素的类型,请参阅 tf.fromPixels 的文档。

const croppedImage = this.cropImage(webcamImage);

摄像头的video元素被设置为方形,而从摄像头输送来的数据本来的长宽比是矩形的(浏览器将在矩形图像周围放置空白区域以使其为正方形)。

然而,MobileNet模型需要一个方形的输入图像。 这行代码从摄像头的video元素中截取出一个尺寸为[224,224]的居中的方形块。 请注意,Webcam类中有关于增加video元素的大小使得我们可以裁剪一个正方形[224,224]块而不会出现白色填充的更多代码。

const batchedImage = croppedImage.expandDims(0);

expandDims创建一个新的大小为1的外部维度。这里,我们从摄像头读取的裁剪图像的形状为[224,224,3],调用expandDims(0)将该张量的形状变为[1,224,224,3],它表示每批有一个图像。 MobileNet的输入是以批为单位的。

batchedImage.toFloat().div(tf.scalar(127)).sub(tf.scalar(1));

在这一行代码中,我们将图像转换为浮点并把值的范围归一化到-1和1之间。 我们知道默认情况下,图像中的值在0-255之间,因此为了归一化后的值在-1和1之间,我们将图像数据除以127并减去1。

return tf.tidy(() => {
  ...
});

通过调用tf.tidy(),我们告诉TensorFlow.js销毁我们在capture()中分配的中间张量的内存。 有关内存管理和tf.tidy()的更多信息,请参阅 Core Concepts tutorial中文版)。

加载mobilenet

在建立模型之前,我们需要将一个预训练的MobileNet加载到网页中。从这个模型中,我们将构建一个新模型,它从MobileNet输出一个内部激活。

这是完成上述内容的代码:

async function loadMobilenet() {
  const mobilenet = await tf.loadModel(
      'https://storage.googleapis.com/tfjs-models/tfjs/mobilenet_v1_0.25_224/model.json');

  // 返回输出内部激活的模型.
  const layer = mobilenet.getLayer('conv_pw_13_relu');
  return tf.model({inputs: model.inputs, outputs: layer.output});
});

通过调用getLayer(’conv_pw_13_relu’),我们得到了预训练的MobileNet模型的内部层,并构建了一个新模型,其中输入与MobileNet的相同,但输出的是MobileNet中间层,名为conv_pw_13_relu。

注意:我们根据经验选择了这一层 – 它在我们的任务中表现良好。一般来说,接近预训练模型末端的层将在迁移学习任务中表现更好,因为它包含输入的更高级语义特征。你可以尝试选择另一个层,看看它是如何影响模型质量的!您可以使用model.layers来打印模型的层。

注意:关于如何将Keras模型移植到TensorFlow.js的详细信息,请查阅Importing a Keras model中文版)。

阶段1:收集数据

游戏的第一阶段是数据收集阶段。用户将保存网络摄像头的帧并将它们与上,下,左,右这4个类别中的某一个相关联。

当我们从网络摄像头采集帧时,我们将立即传送它们通过截断的MobileNet模型并保存激活张量。我们不需要保存从网络摄像头捕获的原始图像,因为我们模型的训练只需要这些激活作为输入。之后,当我们通过对摄像头的数据做预测,实际玩游戏时,我们首先把帧数据传输通过截断的MobileNet模型,然后把截断的Mobilenet模型的输出输送通过第二个模型。

我们提供了一个ControllerDataset类,它可以保存这些激活信息,以供训练阶段使用。 ControllerDataset类有一个addExample方法,它的参数是截断的MobileNet的输出激活张量和与之相关的标签(以数字形式表示)。

当新样本添加完成,我们将保存两个代表整个数据集的张量——xs和ys,作为训练模型的输入。

xs代表截断的MobileNet对所有收集的数据的激活,ys代表所有收集的数据的标签,他用“one-hot”形式表示。当我们训练模型时,我们将给模型提供代表整个数据集的xs和ys。

有关one-hot编码的更多详细信息,请查看MLCC glossary.

我们来看看实现。

addExample(example, label) {
  const y = tf.tidy(() => tf.oneHot(tf.tensor1d([label]), this.numClasses));

  if (this.xs == null) {
    this.xs = tf.keep(example);
    this.ys = tf.keep(y);
  } else {
    const oldX = this.xs;
    this.xs = tf.keep(oldX.concat(example, 0));

    const oldY = this.ys;
    this.ys = tf.keep(oldY.concat(y, 0));

    oldX.dispose();
    oldY.dispose();
    y.dispose();
  }
}

让我们来分析代码:

const y = tf.tidy(() => tf.oneHot(tf.tensor1d([label]), this.numClasses));

该行代码将与标签对应的整数转换为one-hot表示形式。

例如,如果label = 1对应于“left”类,则它的one-hot表示为[0,1,0,0]。 这种转换是为了可以用一个概率分布来表示它。“左”,类别1有100%的概率。

if (this.xs == null) {
  this.xs = tf.keep(example);
  this.ys = tf.keep(y);
}

当添加第一个样本到数据集时,我们只需简单地保存给定的值。

我们在输入张量上调用tf.keep(),这样它们就不会被任何tf.tidy()处理(addExample有可是在tf.tidy()内调用的)。 有关内存管理的更多信息,请参阅 Core Concepts

} else {
  const oldX = this.xs;
  this.xs = tf.keep(oldX.concat(example, 0));

  const oldY = this.ys;
  this.ys = tf.keep(oldY.concat(y, 0));

  oldX.dispose();
  oldY.dispose();
  y.dispose();
}

当数据集中已经添加了一个样本时,我们通过调用concat并将轴参数设置为0来将新样本连接到现有样本集。这会将输入激活堆叠到xs中,并将相应的标签堆叠到ys中。 然后,dispose() 所有旧的xs和ys值。

例如,如果我们的第一个标签(1)是这样的:

[[0,1,0,0]]

然后在第二次调用addExample和label = 2之后,ys将如下所示:

[[0,1,0,0],

[0,0,1,0]]

xs将具有相似的形状,但具有更高的维度,因为我们使用三维的激活(xs是4维的,其中最外面的维度是样本数量)。

现在回到定义了核心逻辑的index.js,我们已经定义了:

ui.setExampleHandler(label => {
  tf.tidy(() => {
    const img = webcam.capture();
    controllerDataset.addExample(mobilenet.predict(img), label);
    // ...
  });
});

在这个代码块中,我们向UI注册一个处理程序,以便在按下上,下,左,右按钮之一时进行处理,其中标签对应于类索引:0,1,2,3。

在这个处理程序中,我们从网络摄像头捕获一帧,它被输送通过截断的MobileNet。该MobileNet会生成一个内部激活,然后将其保存在ControllerDataset对象中。

阶段2:训练模型

当用户把所有样本收集完毕后,便开始进行模型训练!

首先,我们来构建模型的拓扑结构。 我们将创建一个2层的密集(全连接)模型,并在第一个密集层之后添加relu激活函数。

model = tf.sequential({
  layers: [
    //把输入“压扁“成一个向量,以便可以在密集层中使用。
    //虽然这实际上是一个层,但它只是进行了一次变形(并没有任何训练数据)
    tf.layers.flatten({inputShape: [7, 7, 256]}),
    tf.layers.dense({
      units: ui.getDenseUnits(),
      activation: 'relu',
      kernelInitializer: 'varianceScaling',
      useBias: true
    }),

    //最后一层的单元数量应该与需要预测的类别数量一致
    tf.layers.dense({
      units: NUM_CLASSES,
      kernelInitializer: 'varianceScaling',
      useBias: false,
      activation: 'softmax'
    })
  ]
});

注意到模型的第一层实际上是一个flatten层。 我们需要将输入“压扁”为一个向量,以便我们可以在密集层中使用它们。 flatten层的inputShape参数与截断MobileNet的激活形状一致。

我们要添加的下一层是一个密集层, 它的参数设置如下:units是用户从UI中选择的单元数量,激活函数是relu ,内核初始化器是varianceScaling,并添加偏差项。

我们添加的最后一层是另一个密集层。 我们用与想要预测的类别数量相同的单元数来初始化它。 我们使用softmax激活函数,这意味着将最后一层的输出解释为可能的类的概率分布。

查看API reference 以了解层构造函数参数的详细信息,或查看 convolutional MNIST tutorial

const optimizer = tf.train.adam(ui.getLearningRate());
model.compile({optimizer: optimizer, loss: 'categoricalCrossentropy'});

在这里我们构建优化器,定义损失函数,并编译模型以使其准备好接受训练。

我们在这里使用了Adam优化器,它在这个任务中表现良好。 损失函数categoricalCrossentropy测量4个类别的预测概率分布与真实标签(one-hot编码标签)之间的误差。

const batchSize =
    Math.floor(controllerDataset.xs.shape[0] * ui.getBatchSizeFraction());

由于我们的数据集是动态的(由用户定义要收集的数据集有多大),我们相应地调整批量大小。 用户可能不会收集数千个样本,所以批量大小可能不会太大。

现在我们来训练模型!

model.fit(controllerDataset.xs, controllerDataset.ys, {
  batchSize,
  epochs: ui.getEpochs(),
  callbacks: {
    onBatchEnd: async (batch, logs) => {
      // Log the cost for every batch that is fed.
      ui.trainStatus('Cost: ' + logs.loss.toFixed(5));
      await tf.nextFrame();
    }
  }
});

model.fit将代表整个数据集的xs和ys作为输入,xs和ys来自controller dataset。

epoch的值可以在用户界面里设置,允许用户定义训练模型的时间。

我们还注册了一个onBatchEnd回调函数,它在fit的内部训练循环完成一个批量的训练后被调用,从而允许我们在模型训练时向用户显示中间的cost值。await tf.nextFrame()允许UI在训练过程中进行更新。

请参阅 convolutional MNIST tutorial 以获得有关此损失函数的更多详细信息的教程。

阶段3:玩Pac-man

一旦完成模型训练,而且cost值已经下降,我们就可以对摄像头的数据做出预测!

这是预测循环:

while (isPredicting) {
  const predictedClass = tf.tidy(() => {
    const img = webcam.capture();
    const act = mobilenet.predict(img);
    const predictions = model.predict(act);
    return predictions.as1D().argMax();
  });

  const classId = (await predictedClass.data())[0];

  ui.predictClass(classId);
  await tf.nextFrame();
}

让我们来分析代码:

const img = webcam.capture();

正如之前所见,这行代码从摄像头捕捉一帧数据并用一个张量表示。

const activation = mobilenet.predict(img);

现在,给截断的MobileNet模型提供摄像头的帧数据以获得MobileNet的内部激活。

const logits = model.predict(act);

现在,把激活输入到训练模型,从而获得一组预测数据。它是输出类别的概率分布(此预测向量中的4个值中的每一个表示该类别的概率)。

predictions.as1D().argMax();

最后,“压扁”输出数据并调用argMax,它将返回具有最高值(概率)的索引,其对应于预测的类别。

const classId = (await predictedClass.data())[0];
 ui.predictClass(classId);

现在有了一个表示预测结果的张量标量,把它下载并显示在用户界面吧!

总结

大功告成!你现在已经学会了如何训练一个神经网络来预测一组用户定义的类。图像永远不会跑出浏览器!

如果您想fork这个demo并做一些修改,则可能需要更改模型参数才能完成您的任务。

    原文作者:深度智能
    原文地址: https://zhuanlan.zhihu.com/p/35901025
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞