计算机系统应用教程网站

网站首页 > 技术文章 正文

在浏览器中训练自己的Tensorflow.js模型的18个技巧

btikc 2024-09-03 11:20:55 技术文章 11 ℃ 0 评论

在移植了现有的对象检测模型、人脸检测模型、人脸识别模型等到Tensorflow.js之后,我发现有些模型的性能不太好,而其他模型在浏览器中表现得很好。如果您考虑浏览器内部机器学习的潜力以及tensorflow.js为我们的Web开发人员提供的所有可能性,这实际上是令人惊讶的。

然而,随着深度学习模型直接在浏览器中运行,我们也面临着一些现有模型的新挑战和限制,这些模型可能不是专门为在浏览器中运行客户端而设计的,更不用说在移动浏览器中了。以最先进的目标探测器为例:它们通常需要大量的计算资源才能以合理的fps运行,更不用说以实时速度运行了。此外,在一个简单的web应用程序中,将100MB以上的模型权重交付给客户机浏览器是不可行的。

为Web训练高效的深度学习模型

实际上,我们能够构建和训练相当不错的深度学习模型,这些深度学习模型通过考虑一些基本原则而针对在Web环境中运行进行了优化。我们可以训练相当不错的图像分类 - 甚至物体检测模型:

在本文中,我想给你一些关于开始训练你自己的卷积神经网络(CNN)的一般技巧,这些技巧直接针对训练CNN在浏览器中使用tensorflow.js的网络和移动设备。

现在你可能会想:为什么我要用tensorflow.js来训练我的模型呢?在浏览器中,什么时候我可以简单地用我的机器上的tensorflow来训练他们?当然你可以这么做,前提是你的电脑有NVIDIA 显卡。浏览器内深度学习框架的一个巨大优点是:您不需要NVIDIA GPU来训练模型,底层使用的是WebGL。

因此,如果您的计算机配有英伟达卡,您可以简单地使用标准的tensorflow方法,而忽略特定于浏览器的提示。

网络架构

在开始训练我们自己的图像分类器,对象检测器之前,我们显然必须首先实现网络架构。通常建议选择现有的架构,例如Yolo,SSD,ResNet,MobileNet等。

简单地采用这些体系结构并不适合web,因为我们希望我们的模型在规模上更小,在推理上更快(理想的是实时的),并且尽可能容易训练。

无论您是想适应现有的架构还是从头开始,我都想给您提供以下的技巧,这对我设计高效的CNN web架构有很大帮助:

1.从小型网络架构开始!

请记住,我们的网络越小(在解决我们的问题时仍能达到很好的准确性),那么它在推理时间内的表现就越快并且客户端更容易下载和缓存该模型。此外,较小的模型具有较少的参数,因此在训练时会更快地收敛。

如果您发现当前的网络架构性能不佳,或者达不到准确性水平,您仍然可以逐步增加网络的大小,例如通过增加每层卷积filters 的数量或通过堆叠更多层使您的网络更深。

2.采用深度可分的卷积!

由于我们正在训练一个新模型,我们希望明确使用深度可分离卷积而不是普通2D卷积。深度可分离卷积将常规卷积运算分成深度卷积,然后是逐点(1x1)卷积。与常规卷积操作相比,它们具有更少的参数,这导致更少的浮点操作并且更容易并行化,这意味着推断将更快且资源消耗更少(这可以显着提高移动设备上的性能)。此外,因为它们具有较少的参数,所以训练它们所花费的时间较少。

在MobileNet和Xception中采用了深度可分离卷积的思想,你可以在例如MobileNet和PoseNet的tensorflow.js模型中找到它们。是否深度可分离卷积导致模型不太准确可能是一个公开的辩论,但根据我的经验,它们绝对是网络(和移动)模型的方式。

长话短说:我建议在你的第一层使用常规的conv2d操作,这通常没有那么多的参数,以保留提取的特征中的RGB通道之间的关系。

对于其余的卷积,只需使用深度可分离的卷积。因此,我们将使用3 x 3 x channels_in x 1 深度filter 和1 x 1 x channels_in x channels_out 逐点filter ,而不是单个内核。

因此,不是使用tf.conv2d,核的shape是[3,3,32,64] ,我们将简单地使用tf.separableConv2d,使用的是shape为[3,3,32,1]的depthwise kernel,以及shape为[1,1,32,64]的depthwise kernel。

3.Skip Connections 和Densely Connected Blocks

一旦我决定建立更深的网络,我很快就面临着训练神经网络最常见的问题之一:梯度消失的问题。在一些epochs之后,损失只会在非常小的步骤中减少,这将导致很长的训练时间,或者导致模型根本不收敛。

ResNet和DenseNet中使用的Skip connections允许构建更深层的体系结构,同时减轻梯度消失的问题。我们所要做的就是在应用激活函数之前,将先前层的输出添加到位于网络中更深层的层输入中:

Skip connections work,因为通过快捷方式连接层,我们至少可以学习到恒等函数。这种技术背后的直觉是,梯度不必完全通过卷积层(或全连接层)进行反向传播,这使得梯度一旦到达网络的早期层就会减少。它们更愿意通过Skip connections的添加操作来“skip”层。

显然,要求工作的是,假设您想要将A层与B层连接,A的输出shape必须与B的输入shape相匹配。如果您想构建残差或densely connected blocks,只需确保在该块中的卷积中保持相同数量的filters,并使用相同的padding保持1的步幅。也有不同的方法,它们填充A的输出,使其与输入B的shape匹配,或者连接来自先前层的特征映射,使得连接层的深度再次匹配。

一开始,我在用ResNet之类的方法进行尝试,就像上图所示的那样,在每一层之间引入一个skip connection,但很快我就发现,densely connected blocks工作得更好,大大减少了模型达到收敛所需的时间:

以下是dense block实现的示例,我使用它作为68点face landmark检测器的基本构建块。其中一个块涉及4个可深度分离的卷积层(注意,第一个dense block的第一个卷积是常规卷积),每个块的第一个卷积运算使用2的步幅来缩小输入:

export type DenseBlock4Params = {
 conv0: SeparableConvParams | ConvParams
 conv1: SeparableConvParams
 conv2: SeparableConvParams
 conv3: SeparableConvParams
}
export function denseBlock4(
 x: tf.Tensor4D,
 denseBlockParams: DenseBlock4Params,
 isFirstLayer: boolean = false
): tf.Tensor4D {
 return tf.tidy(() => {
 const out0 = isFirstLayer
 ? convLayer(x, denseBlockParams.conv0 as ConvParams, [2, 2], 'same')
 : depthwiseSeparableConv(x, denseBlockParams.conv0 as SeparableConvParams, [2, 2], 'same')
 as tf.Tensor4D
 
 const in1 = tf.relu(out0) as tf.Tensor4D
 const out1 = depthwiseSeparableConv(in1, denseBlockParams.conv1, [1, 1], 'same')
 // first join
 const in2 = tf.relu(tf.add(out0, out1)) as tf.Tensor4D
 const out2 = depthwiseSeparableConv(in2, denseBlockParams.conv2, [1, 1], 'same')
 // second join
 const in3 = tf.relu(tf.add(out0, tf.add(out1, out2))) as tf.Tensor4D
 const out3 = depthwiseSeparableConv(in3, denseBlockParams.conv3, [1, 1], 'same')
 // final join
 return tf.relu(tf.add(out0, tf.add(out1, tf.add(out2, out3)))) as tf.Tensor4D
 })
}

4.使用ReLU类型激活函数!

除非你有特定的理由使用任何其他类型的激活函数,否则使用tf.relu。原因很简单,ReLU类型激活函数有助于缓解梯度消失的问题。

您还可以尝试ReLU的变体,例如 leaky ReLU,它正在Yolo架构中使用:

export function leakyRelu(x: tf.Tensor, epsilon: number) {
 return tf.tidy(() => {
 const min = tf.mul(x, tf.scalar(epsilon)) 
 return tf.maximum(x, min)
 })
}

或者移动网络使用的ReLU-6:

export function relu6(x: tf.Tensor) {
 return tf.clipByValue(x, 0, 6)
}

训练

一旦我们完成初始架构,我们就可以开始训练我们的深度学习模型。

5.如果有疑问,只需使用Adam Optimizer!

当我第一次开始训练自己的模型时,我想知道哪种优化器最好?我开始使用普通的SGD,它似乎有时会卡在局部最小值中,甚至导致梯度爆炸,导致模型权重无限增长,最终导致NaNs。

我并不是说,Adam是所有问题的最佳选择,但我发现它是训练新模型最简单,最强大的方法,只需使用默认参数和学习率为0.001的 Adam开始:

const optimizer = tf.train.adam(0.001)

6.调整学习率

一旦损失没有进一步显着下降,很可能,我们的模型确实收敛(或卡住),并且无法进一步学习。此时我们不妨停止训练过程,以防止您的模型过度拟合(或尝试不同的架构)。

但是,您也可以调整(降低)此时的学习率。特别是如果在训练集上计算的总损失开始振荡(跳跃上升和下降),这表明尝试降低学习率可能是个好主意。

这是一个示例,显示了在训练face-api.js的68点面部标志模型时整体误差的曲线。在46 epoch,损失值开始振荡。正如你所看到的那样,继续训练从46 epoch的检查点再学习10个以上,学习率为0.0001而不是0.001,我能够进一步降低整体误差:

7.权重初始化

如果您对如何正确初始化模型权重没有任何线索:作为一个简单的经验法则,用零初始化所有偏差(tf.zeros(shape))和你的权重(卷积的核和全连接层的权重)用非零值,从正态分布中得出。例如,您可以简单地使用tf.randomNormal(shape),但是现在我更喜欢使用glorot正态分布,这在tfjs-layers中可用,如下所示:

const initializer = tf.initializers.glorotNormal()
const depthwise_filter = initializer.apply([3, 3, 32, 1])
const pointwise_filter = initializer.apply([1, 1, 32, 64])
const bias = tf.zeros([64])

8.Shuffle 您的输入!

训练神经网络的一个常见建议是通过在每个epoch开始时对它们进行shuffling 来随机化训练样本的出现顺序。方便的是,我们可以使用tf.utils.shuffle来实现这个目的:

/** Shuffles the array using Fisher-Yates algorithm. */
export function shuffle(array: any[]|Uint32Array|Int32Array|Float32Array): void

9.使用FileSaver.js保存模型检查点

由于我们正在浏览器中训练我们的模型,您现在可能会问自己:我们如何在训练时自动保存模型权重的检查点?我们只使用FileSaver.js。该脚本公开了一个名为saveAs的函数,我们可以使用它来存储任意类型的文件,这些文件最终会出现在我们的下载文件夹中。

这样我们就可以保存模型权重:

const weights = new Float32Array([... model weights, flat array])
saveAs(new Blob([weights]), 'checkpoint_epoch1.weights')

或者json文件,例如,为epoch保存累计损失:

const losses = { totalLoss: ... }
saveAs(new Blob([JSON.stringify(losses)]), 'loss_epoch1.json')

故障排除

10.检查输入数据,预处理和后处理逻辑!

如果你将垃圾传递到你的网络,它会把垃圾扔回你身边。因此,请确保您的输入数据标记正确,并确保您的网络输入符合您的预期。特别是如果你已经实现了一些预处理逻辑,如random cropping, padding, squaring, centering, mean subtraction等,请确保在预处理后可视化输入。此外,我强烈建议单元测试这些步骤。同样适合后处理!

11.检查你的损失函数!

现在在大多数情况下,tensorflow.js为您提供了您需要的损失函数。但是,如果你需要实现自己的损失函数,你绝对应该进行单元测试!

12.先装上一个小型数据集!

通常,最好是在训练数据的一小部分上过度拟合,以验证损失是否正在收敛,以及您的模型实际上是在学习一些有用的东西。因此,您应该只选择10到20张训练数据的图像并训练一些epochs。一旦损失收敛,对这10到20张图像进行推断并可视化结果:

这是一个非常重要的步骤,它将帮助您在实现网络、预处理和后处理逻辑时消除各种各样的bug源,因为您的模型不太可能学会使用代码中的大量bug进行所需的预测。

特别是,如果你正在实现自己的损失函数(11.),你肯定要确保,你的模型能够在开始训练之前收敛!

性能

最后,我想给你一些建议,通过考虑一些基本原则,这将有助于你尽可能地减少训练时间并防止浏览器因内存泄漏而崩溃。

13.防止明显的内存泄漏

除非你是tensorflow.js的新手,否则你可能已经知道,我们必须手动处理未使用的张量来释放内存,方法是调用tensor.dispose()或将我们的操作包装在tf.tidy块中。确保由于未正确处理张量而导致没有此类内存泄漏,否则您的应用程序迟早会耗尽内存。

识别这些类型的内存泄漏非常简单。只需记录tf.memory()几次迭代即可验证,每次迭代时张量的数量不会无意中增长:

14.调整画布大小而不是你的张量!

注意,以下语句仅在tfjs-core的当前状态(我目前正在使用tfjs-core版本0.12.14)时有效,直到最终得到修复。

我知道这可能听起来有点奇怪:为什么不使用tf.resizeBilinear,tf.pad等将输入张量reshape 为所需的网络输入shape?tfjs 目前有一个未解决的问题。

TLDR:在调用tf.fromPixels之前,要将画布转换为张量,请调整画布的大小,使其具有网络接受的大小,否则您将快速耗尽GPU内存,具体取决于各种不同的输入大小。您的训练数据中的图像。如果你的训练图像大小都相同,那么这个问题就不那么严重了,但是如果你必须明确调整它们的大小,你可以使用下面的js代码片段:

export function imageToSquare(img: HTMLImageElement | HTMLCanvasElement, inputSize: number): HTMLCanvasElement {
 const dims = img instanceof HTMLImageElement 
 ? { width: img.naturalWidth, height: img.naturalHeight }
 : img 
 const scale = inputSize / Math.max(dims.height, dims.width)
 const width = scale * dims.width
 const height = scale * dims.height
 const targetCanvas = document.createElement('canvas')
 targetCanvas .width = inputSize
 targetCanvas .height = inputSize
 targetCanvas.getContext('2d').drawImage(img, 0, 0, width, height)
 return targetCanvas
}

15.确定最佳批量大小

尝试不同的批量大小并测量反向传播所需的时间。最佳批量大小显然取决于您的GPU统计信息,输入大小以及网络的复杂程度。

在某些情况下,增加批量大小对性能没有任何帮助,但在其他情况下,我可以看到整体加速1.5-2.0倍左右,通过创建大小为16到24的批次,在相当小的网络尺寸下输入图像大小为112 x 112像素。

16.缓存,离线存储,Indexeddb

我们的训练图像(和标签)可能相当大,可能高达1GB甚至更多,具体取决于图像的大小和数量。由于我们不能简单地在浏览器中从磁盘读取图像,我们将使用文件代理(可能是一个简单的快速服务器)来托管我们的训练数据,浏览器将获取每个数据项。

显然,这是非常低效的,但是在浏览器中进行训练时我们必须记住这一点。如果您的数据集足够小,您可能会尝试将整个数据保存在内存中,但这显然也不是很有效。最初,我试图增加浏览器缓存大小以简单地将整个数据缓存在磁盘上,但这在以后的Chrome版本中似乎不再起作用。

最后,我决定只使用Indexeddb,这是一个浏览器数据库,我们可以利用它来存储我们的整个训练和测试数据集。Indexeddb入门非常简单,因为我们基本上只需几行代码即可将整个数据存储和查询为键值存储。使用Indexeddb,我们可以方便地将标签存储为普通的json对象,将我们的图像数据存储为blob。

查询Indexeddb是非常快的,至少我发现查询每个数据项的速度要快一些,而不是一遍又一遍地从代理中获取文件。此外,在将数据移动到Indexeddb之后,技术上的训练现在完全脱机,这意味着我们可能不再需要代理服务器了。

17.异步损失报告

这是一个简单但非常有效的提示,它帮助我减少了训练时的迭代次数。主要的想法是,如果我们想要检索由optimizer.minimize返回的损失张量的值,我们肯定会这样做,因为我们想要在训练时跟踪我们的损失,我们希望避免等待损失返回的loss.data()防止等待CPU和GPU在每次迭代时同步。相反,我们想要执行类似以下的操作来报告迭代的损失值:

const loss = optimizer.minimize(() => {
 const out = net.predict(someInput)
 const loss = tf.losses.meanSquaredError(
 groundTruth,
 out,
 tf.Reduction.MEAN
 )
 return loss
}, true)
loss.data().then(data => {
 const lossValue = data[0]
 window.lossValues[epoch] += (window.lossValues[epoch] || 0) + lossValue
 loss.dispose()
})

我们只需要记住,我们的损失现在是异步报告的,因此,如果我们希望在每个epoch结束时将总的损失保存到一个文件中,那么在这样做之前,我们必须等待最后的解决方案。我通常只是通过使用setTimeout来解决这个问题,在epoch完成后10秒左右的时间内保存总的损失值:

if (epoch !== startEpoch) {
 // ugly hack to wait for loss datas for that epoch to be resolved
 const previousEpoch = epoch - 1
 setTimeout(() => storeLoss(previousEpoch, window.losses[previousEpoch]), 10000)
}

成功训练模型后

18.权重量化

一旦我们完成了对模型的训练并且我们对它的性能感到满意,我建议通过应用权重量化来缩小模型大小。通过量化我们的模型权重,我们可以将模型的大小减小到原始大小的1/4!尽可能减小模型的大小对于将模型权重快速传递到客户端应用程序至关重要。

Tags:

本文暂时没有评论,来添加一个吧(●'◡'●)

欢迎 发表评论:

最近发表
标签列表