(本文翻译自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,我们将这些姿势分别定义为“上”,“下”,“左” ,“右” 。
关于游戏
游戏分为三个阶段。
- 数据收集:玩家将摄像头的图像与上下左右四个类别中的某个相关联。
- 训练:训练一个神经网络,根据输入图像预测它的类别。
- 推理/玩:使用我们训练的模型,通过网络摄像头的数据进行上下左右的预测,并将它们输入到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并做一些修改,则可能需要更改模型参数才能完成您的任务。