5 7 23 61 = ?
将每个数字乘以相应位置并不像你们中的一些人可能想的那样简单;因为涉及到乘法和加法。计算左上角的值将是 91 x 1 + 82 x 33 + 13 x 7 = 2888。现在对新矩阵的每个索引重复八次这样的计算。计算这种简单乘法的 JavaScript 并不完全琐碎。
张量具有数学上的好处。我不必编写任何代码来执行以前的计算。虽然编写自定义代码不会很复杂,但会是非优化和冗余的。有用的、可扩展的数学运算是内置的。TensorFlow.js 使线性代数对于张量等结构变得易于访问和优化。我可以用以下代码快速得到以前矩阵的答案:
const mat1 = [
[91, 82, 13],
[15, 23, 62],
[25, 66, 63]
]
const mat2 = [
[1, 23, 83],
[33, 12, 5],
[7, 23, 61]
]
tf.matMul(mat1, mat2).print()
在第二章中,毒性检测器下载了用于每个分类计算的大量数字。在毫秒内处理这些大量计算的行为是张量背后的力量。虽然我们将继续扩展张量计算的好处,但 TensorFlow.js 的整个原因是这样一个大量计算的复杂性是框架的领域,而不是程序员的领域。
推荐张量
凭借你迄今学到的技能,你可以构建一个简单的示例,展示 TensorFlow.js 如何处理真实场景的计算。以下示例被选择为张量的力量的一个例证,它欢迎精英和数学避免者。
注意
这一部分可能是你会接触到的最深的数学内容。如果你想深入了解支持机器学习的线性代数和微积分,我推荐一个由斯坦福大学提供、由吴恩达教授的免费在线课程。
让我们用一些张量数据构建一些真实的东西。你将进行一系列简单的计算,以确定一些用户的偏好。这些系统通常被称为推荐引擎。你可能熟悉推荐引擎,因为它们建议你应该购买什么,下一部电影你应该看什么等等。这些算法是数字产品巨头如 YouTube、亚马逊和 Netflix 的核心。推荐引擎在任何销售任何东西的业务中都非常受欢迎,可能可以单独填写一本书。我们将实现一个简单的“基于内容”的推荐系统。发挥你的想象力,因为在生产系统中,这些张量要大得多。
在高层次上,你将做以下事情:
-
要求用户对乐队进行评分,从1到10。
-
任何未知的乐队得到0。
-
乐队和音乐风格将是我们的“特色”。
-
使用矩阵点积来确定每个用户喜欢的风格!
让我们开始创建一个推荐系统!这个小数据集将作为你所需的示例。正如你所注意到的,你在代码中混合了 JavaScript 数组和张量。将标签保留在 JavaScript 中,将计算推入张量是非常常见的。这不仅使张量专注于数字;还有国际化张量结果的好处。标签是这个操作中唯一依赖语言的部分。你会看到这个主题在本书中的几个示例和实际机器学习的真实世界中持续存在。
以下是数据:
const users = ['Gant', 'Todd', 'Jed', 'Justin'] // ①
const bands = [ // ②
'Nirvana',
'Nine Inch Nails',
'Backstreet Boys',
'N Sync',
'Night Club',
'Apashe',
'STP'
]
const features = [ // ③
'Grunge',
'Rock',
'Industrial',
'Boy Band',
'Dance',
'Techno'
]
// User votes // ④
const user_votes = tf.tensor([
[10, 9, 1, 1, 8, 7, 8],
[6, 8, 2, 2, 0, 10, 0],
[0, 2, 10, 9, 3, 7, 0],
[7, 4, 2, 3, 6, 5, 5]
])
// Music Styles 5
const band_feats = tf.tensor([
[1, 1, 0, 0, 0, 0],
[1, 0, 1, 0, 0, 0],
[0, 0, 0, 1, 1, 0],
[0, 0, 0, 1, 0, 0],
[0, 0, 1, 0, 0, 1],
[0, 0, 1, 0, 0, 1],
[1, 1, 0, 0, 0, 0]
])
①
这四个名称标签只是简单地存储在一个普通的 JavaScript 数组中。
②
你已经要求我们的用户对七支乐队进行评分。
③
一些简单的音乐流派可以用来描述我们的七支乐队,同样存储在一个 JavaScript 数组中。
④
这是我们的第一个张量,一个二级描述,每个用户的投票从1到10,其中“我不认识这支乐队”为0。
⑤
这个张量也是一个二维张量,用于识别与每个给定乐队匹配的流派。每行索引代表了可以分类为真/假的流派的编码。
现在你已经拥有了张量中所需的所有数据。快速回顾一下,你可以看到信息的组织方式。通过阅读user_votes变量,你可以看到每个用户的投票。例如,你可以看到用户0,对应 Gant,给 Nirvana 评了10分,Apashe 评了7分,而 Jed 给了 Backstreet Boys10分。
band_feats变量将每个乐队映射到它们满足的流派。例如,索引1处的第二个乐队是 Nine Inch Nails,对 Grunge 和工业音乐风格有积极评分。为了简单起见,你使用了每种流派的二进制1和0,但在这里也可以使用一种标准化的数字比例。换句话说,[1, 1, 0, 0, 0, 0]代表了 Grunge 和 Rock 对于第 0 个乐队,也就是 Nirvana。
接下来,你将根据他们的投票计算每个用户最喜欢的流派:
// User's favorite styles
const user_feats = tf.matMul(user_votes, band_feats)
// Print the answers
user_feats.print()
现在user_feats包含用户在每个乐队的特征上的点积。我们打印的结果将如下所示:
Tensor
[[27, 18, 24, 2 , 1 , 15],
[14, 6 , 18, 4 , 2 , 10],
[2 , 0 , 12, 20, 10, 10],
[16, 12, 15, 5 , 2 , 11]]
这个张量显示了每个用户特征(在本例中是流派)的价值。用户0,对应 Gant,其最高价值在索引0处为27,这意味着他们在调查数据中最喜欢的流派是 Grunge。这些数据看起来相当不错。使用这个张量,你可以确定每个用户的喜好。
虽然数据以张量形式存在,但你可以使用一个叫做topk的方法来帮助我们识别每个用户的前k个值。要获取前k个张量或者仅仅通过识别它们的索引来确定前k个值的位置,你可以调用带有所需张量和大小的函数topk。在这个练习中,你将把k设置为完整特征集大小。
最后,让我们把这些数据带回 JavaScript。编写这段代码可以这样写:
// Let's make them pretty
const top_user_features = tf.topk(user_feats, features.length)
// Back to JavaScript
const top_genres = top_user_features.indices.arraySync() // ①
// print the results
users.map((u, i) => {
const rankedCategories = top_genres[i].map(v => features[v]) // ②
console.log(u, rankedCategories)
})
①
你将索引张量返回到一个二维 JavaScript 数组以获取结果。
②
你正在将索引映射回音乐流派。
结果日志如下所示:
Gant
[
"Grunge",
"Industrial",
"Rock",
"Techno",
"Boy Band",
"Dance"
]
Todd
[
"Industrial",
"Grunge",
"Techno",
"Rock",
"Boy Band",
"Dance"
]
Jed
[
"Boy Band",
"Industrial",
"Dance",
"Techno",
"Grunge",
"Rock"
]
Justin
[
"Grunge",
"Industrial",
"Rock",
"Techno",
"Boy Band",
"Dance"
]
在结果中,你可以看到 Todd 应该多听工业音乐,而 Jed 应该加强对男孩乐队的了解。两者都会对他们的推荐感到满意。
你刚刚做了什么?
你成功地将数据加载到张量中,这样做是有意义的,然后你对整个集合应用了数学计算,而不是对每个人进行迭代式的处理。一旦你得到了答案,你对整个集合进行了排序,并将数据带回 JavaScript 进行推荐!
你还能做更多吗?
你可以做很多事情。从这里开始,你甚至可以使用每个用户投票中的 0 来确定用户从未听过的乐队,并按照最喜欢的流派顺序推荐给他们!有一种非常酷的数学方法可以做到这一点,但这有点超出了我们第一个张量练习的范围。不过,恭喜你实现了在线销售中最受欢迎和流行的功能之一!
章节回顾
在本章中,你不仅仅是浅尝辄止地了解了张量。你深入了解了 TensorFlow.js 的基本结构,并掌握了根本。你正在掌握在 JavaScript 中应用机器学习的方法。张量是一个贯穿所有机器学习框架和基础知识的概念。
章节挑战:你有何特别之处?
现在你不再是一个张量新手,你可以像专业人士一样管理张量,让我们尝试一个小练习来巩固你的技能。在撰写本文时,JavaScript 没有内置的方法来清除数组中的重复项。虽然其他语言如 Ruby 已经有了uniq方法超过十年,JavaScript 开发人员要么手动编写解决方案,要么导入像 Lodash 这样的流行库。为了好玩,让我们使用 TensorFlow.js 来解决唯一值的问题。作为一个学到的教训的练习,思考一下这个问题:
给定这个美国电话号码数组,删除重复项。
// Clean up the duplicates
const callMeMaybe = tf.tensor([8367677, 4209111, 4209111, 8675309, 8367677])
确保您的答案是一个 JavaScript 数组。如果您在这个练习中遇到困难,可以查阅TensorFlow.js 在线文档。搜索关键术语的文档将指引您正确方向。
您可以在附录 B 中找到这个挑战的答案。
复习问题
让我们回顾一下您在本章编写的代码中学到的教训。花点时间回答以下问题:
-
我们为什么要使用张量?
-
以下哪一个不是张量数据类型?
-
Int32
-
Float32
-
对象
-
布尔值
-
六维张量的秩是多少?
-
方法dataSync的返回数组的维数是多少?
-
当您将一个三维张量传递给tf.tensor1d时会发生什么?
-
在张量形状方面,rank和size之间有什么区别?
-
张量tf.tensor([1])的数据类型是什么?
-
张量的输入数组维度总是结果张量维度吗?
-
如何确定内存中张量的数量?
-
tf.tidy能处理异步函数吗?
-
如何在tf.tidy内部创建的张量?
-
我可以用console.log看到张量的值吗?
-
tf.topk方法是做什么的?
-
张量是为批量还是迭代计算进行优化的?
-
推荐引擎是什么?
这些练习的解决方案可以在附录 A 中找到。
第四章:图像张量
“但是那些不敢抓住荆棘的人
永远不应该渴望玫瑰。”
— 安妮·勃朗特
在上一章中,你创建并销毁了简单的张量。然而,我们的数据很小。正如你可能猜到的,打印张量只能带你走到这么远,而且在这么多维度上。你需要学会如何处理更常见的大张量。当然,在图像世界中这是真实的!这是一个令人兴奋的章节,因为你将开始处理真实数据,我们将能够立即看到你的张量操作的效果。
我们还将利用一些现有的最佳实践。正如你回忆的,在上一章中,你将一个井字棋游戏转换为张量。在这个简单的 3 x 3 网格的练习中,你确定了一种转换游戏状态的方法,但另一个人可能会想出完全不同的策略。我们需要确定一些常见的做法和行业诀窍,这样你就不必每次都重新发明轮子。
我们将:
-
识别张量是什么使其成为图像张量
-
手动构建一些图像
-
使用填充方法创建大张量
-
将现有图像转换为张量,然后再转换回来
-
以有用的方式操作图像张量
当你完成本章时,你将能够自信地处理真实世界的图像数据,而这些知识很多都适用于一般张量的管理。
视觉张量
你可能会假设当图像转换为张量时,得到的张量将是二阶的。如果你忘记了二阶张量是什么样子,请查看第三章。很容易将一个 2D 图像想象成一个 2D 张量,只是像素颜色通常不能存储为单个数字。二阶张量仅适用于灰度图像。彩色像素的最常见做法是将其表示为三个独立的值。那些从小就接触颜料的人被教导使用红色、黄色和蓝色,但我们这些书呆子更喜欢红色、绿色、蓝色(RGB)系统。
注意
RGB 系统是艺术模仿生活的另一个例子。人眼使用 RGB,这是基于“加法”颜色系统——一种发射光的系统,就像计算机屏幕一样。你的美术老师可能用黄色覆盖绿色来帮助淡化随着添加更多而变暗的颜料的颜色,这是一种“减法”颜色系统,就像纸上的颜料一样。
一个像素通常是由红色、绿色和蓝色的有序量来着色,这些量在一个字节内。这个0-255值数组看起来像[255, 255, 255]对于整数,对于大多数寻求相同三个值的十六进制版本的网站来说,看起来像#FFFFFF。当我们的张量是数据类型int32时,这是使用的解释方法。当我们的张量是float32时,假定值在0-1范围内。因此,一个整数[255, 255, 255]代表纯白,但在浮点形式中等价的是[1, 1, 1]。这也意味着[1, 1, 1]在float32张量中是纯白的,并且在int32张量中被解释为接近黑色。
根据张量数据类型的不同,从一个像素编码为[1, 1, 1],你会得到两种颜色极端,如图 4-1 所示。
![颜色取决于张量类型]()
图 4-1。相同数据的显著颜色差异
这意味着要存储图像,你将需要一个三维张量。你需要将每个三值像素存储在给定的宽度和高度上。就像你在井字棋问题中看到的那样,你将不得不确定最佳的格式来做到这一点。在 TensorFlow 和 TensorFlow.js 中,将 RGB 值存储在张量的最后一个维度是一种常见做法。也习惯性地将值沿着高度、宽度,然后颜色维度进行存储。这对于图像来说可能看起来有点奇怪,但引用行然后列是矩阵的经典组织参考顺序。
警告
大多数人会按照宽度乘以高度来提及图像尺寸。一个 1024 x 768 的图像宽度为1024px,高度为768px,但正如我们刚刚所述,TensorFlow 图像张量首先存储高度,这可能有点令人困惑。同样的图像将是一个[768, 1024, 3]张量。这经常会让对视觉张量新手的开发人员感到困惑。
因此,如果你想要制作一个 4 x 3 的像素棋盘,你可以手动创建一个形状为[3, 4, 3]的 3D 数组。
代码将会是以下简单的形式:
const checky = tf.tensor([
[
[1, 1, 1],
[0, 0, 0],
[1, 1, 1],
[0, 0, 0]
],
[
[0, 0, 0],
[1, 1, 1],
[0, 0, 0],
[1, 1, 1]
],
[
[1, 1, 1],
[0, 0, 0],
[1, 1, 1],
[0, 0, 0]
],
])
一个 4 x 3 像素的图像可能会很小,但如果我们放大几百倍,我们将能够看到我们刚刚创建的像素。生成的图像看起来会像图 4-2。
![一个简单的 4 x 3 图像]()
图 4-2。4 x 3 的 TensorFlow.js 棋盘图像
你不仅限于 RGB,正如你可能期望的那样;在张量的 RGB 维度中添加第四个值将添加一个 alpha 通道。就像在 Web 颜色中一样,#FFFFFF00将是白色的零不透明度,具有红色、绿色、蓝色、alpha(RGBA)值为[1, 1, 1, 0]的张量像素也将是类似透明的。一个带有透明度的 1024 x 768 图像将存储在一个形状为[768, 1024, 4]的张量中。
作为前述两个系统的推论,如果最终通道只有一个值而不是三个或四个,生成的图像将是灰度的。
我们之前的黑白棋盘图案示例可以通过使用最后的知识大大简化。现在我们可以用张量构建相同的图像,代码如下:
const checkySmalls = tf.tensor([
[[1],[0],[1],[0]],
[[0],[1],[0],[1]],
[[1],[0],[1],[0]]
])
是的,如果你简单地去掉那些内部括号并将其移动到一个简单的 2D 张量中,那也是可以的!
快速图像张量
我知道有一大群人在你的门口排队逐个像素地手绘图像,所以你可能会惊讶地发现有些人觉得写一些小的 1 和 0 很烦人。当然,你可以使用Array.prototype.fill创建数组,然后使用它来填充数组以创建可观的 3D 张量构造器,但值得注意的是,TensorFlow.js 已经内置了这个功能。
创建具有预填充值的大张量是一个常见的需求。实际上,如果你继续从第三章的推荐系统中工作,你将需要利用这些确切的功能。
现在,你可以使用tf.ones、tf.zeros和tf.fill方法手动创建大张量。tf.ones和tf.zeros都接受一个形状作为参数,然后构造该形状,每个值都等于1或0。因此,代码tf.zeros([768, 1024, 1])将创建一个 1024 x 768 的黑色图像。可选的第二个参数将是生成的张量的数据类型。
提示
通常,你可以通过使用tf.zeros创建一个空图像,通过模型预先分配内存。结果会立即被丢弃,后续调用会快得多。这通常被称为模型预热,当开发人员在等待网络摄像头或网络数据时寻找要分配的内容时,你可能会看到这种加速技巧。
正如你所想象的,tf.fill接受一个形状,然后第二个参数是用来填充该形状的值。你可能会想要将一个张量作为第二个参数传递,从而提高生成的张量的秩,但重要的是要注意这样做是行不通的。关于什么有效和无效的对比,请参见表 4-1。
表 4-1。填充参数:标量与向量
| 这有效 |
|
这无效 |
tf.fill([2, 2], 1) |
|
tf.fill([2, 2], [1, 1, 1]) |
你的第二个参数必须是一个单一值,用来填充你给定形状的张量。这个非张量值通常被称为标量。总之,代码tf.fill([200, 200, 4], 0.5)将创建一个 200 x 200 的灰色半透明正方形,如图 4-3 所示。
![一个填充为 0.5 的图像]()
图 4-3. 带背景的 Alpha 通道图像张量
如果您对不能用优雅的颜色填充张量感到失望,那么我有一个惊喜给您!我们下一个创建大张量的方法不仅可以让您用张量填充,还可以让您用图案填充。
让我们回到您之前制作的 4 x 3 的方格图像。您手工编码了 12 个像素值。如果您想制作一个 200 x 200 的方格图像,那将是 40,000 个像素值用于简单的灰度。相反,我们将使用.tile方法来扩展一个简单的 2 x 2 张量。
// 2 x 2 checker pattern
const lil = tf.tensor([ // ①
[[1], [0]],
[[0], [1]]
]);
// tile it
const big = lil.tile([100, 100, 1]) // ②
①
方格图案是一个二维的黑白张量。这可以是任何优雅的图案或颜色。
②
瓷砖大小为 100 x 100,因为重复的图案是 2 x 2,这导致了一个 200 x 200 的图像张量。
对于人眼来说,方格像素很难看清楚。不放大的情况下,方格图案可能看起来灰色。就像印刷点组成杂志的多种颜色一样,一旦放大,您就可以清楚地看到方格图案,就像在图 4-4 中一样。
![使用瓷砖的结果]()
图 4-4. 10 倍放大的 200 x 200 方格张量
最后,如果所有这些方法对您的口味来说都太结构化,您可以释放混乱!虽然 JavaScript 没有内置方法来生成随机值数组,但 TensorFlow.js 有各种各样的方法可以精确地做到这一点。
简单起见,我最喜欢的是.randomUniform。这个张量方法接受一个形状,还可以选择一个最小值、最大值和数据类型。
如果您想构建一个 200 x 200 的灰度颜色的随机静态图像,您可以使用tf.randomUniform([200, 200, 1])或者tf.randomUniform([200, 200, 1], 0, 255, 'int32')。这两者将产生相同的(尽可能相同的)结果。
图 4-5 显示了一些示例输出。
![200 x 200 随机]()
图 4-5. 200 x 200 随机值填充的张量
JPG、PNG 和 GIF,哦我的天啊!
好的,甘特!您已经谈论了一段时间的图像,但我们看不到它们;我们只看到张量。张量如何变成实际可见的图像?而对于机器学习来说,现有的图像如何变成张量?
正如您可能已经直觉到的那样,这将根据 JavaScript 运行的位置(特别是客户端和服务器)而有很大不同。要在浏览器上将图像解码为张量,然后再转换回来,您将受到浏览器内置功能的限制和赋予的力量。相反,在运行 Node.js 的服务器上的图像将不受限制,但缺乏易于的视觉反馈。
不要害怕!在本节中,您将涵盖这两个选项,这样您就可以自信地将 TensorFlow.js 应用于图像,无论媒介如何。
我们将详细审查以下常见情况:
-
浏览器:张量到图像
-
浏览器:图像到张量
-
Node.js:张量到图像
-
Node.js:图像到张量
浏览器:张量到图像
为了可视化、修改和保存图像,您将利用 HTML 元素和画布。让我们从给我们一种可视化我们学到的所有图形课程的方法开始。我们将在浏览器中将一个张量渲染到画布上。
首先,创建一个 400 x 400 的随机噪声张量,然后在浏览器中将张量转换为图像。为了实现这一点,您将使用tf.browser.toPixels。该方法将张量作为第一个参数,可选地为第二个参数提供一个画布以绘制。它返回一个在渲染完成时解析的 Promise。
注意
乍一看,将 canvas 作为可选参数是相当令人困惑的。值得注意的是,promise 将以Uint8ClampedArray的形式解析为张量作为参数,因此这是一个很好的方式来创建一个“准备好的 canvas”值,即使您没有特定的 canvas 在脑海中。随着OffscreenCanvas 的概念从实验模式转变为实际支持的 Web API,它可能会减少实用性。
要设置我们的第一个画布渲染,您需要在我们的 HTML 中有一个带有 ID 的画布,以便您可以引用它。对于那些熟悉 HTML 加载顺序复杂性的人来说,您需要在尝试从 JavaScript 中访问它之前使画布存在之前(或者遵循您网站的任何最佳实践,比如检查文档准备就绪状态):
<canvas id="randomness"></canvas>
现在您可以通过 ID 访问此画布,并将其传递给我们的browser.toPixels方法。
const bigMess = tf.randomUniform([400, 400, 3]); // ①
const myCanvas = document.getElementById("randomness"); // ②
tf.browser.toPixels(bigMess, myCanvas).then(() => { // ③
// It's not bad practice to clean up and make sure we got everything
bigMess.dispose();
console.log("Make sure we cleaned up", tf.memory().numTensors);
});
①
创建一个 RGB 400 x 400 图像张量
②
在文档对象模型(DOM)中获取对我们画布的引用
③
使用我们的张量和画布调用browser.toPixels
如果此代码在异步函数中运行,您可以简单地等待browser.toPixels调用,然后清理。如果不使用 promise 或异步功能,dispose几乎肯定会赢得可能的竞争条件并导致错误。
浏览器:图像到张量
正如您可能已经猜到的,browser.toPixels有一个名为browser.fromPixels的对应方法。此方法将获取图像并将其转换为张量。对于我们来说,browser.fromPixels的输入非常动态。您可以传入各种元素,从 JavaScript ImageData 到 Image 对象,再到 HTML 元素如<img>、<canvas>,甚至<video>。这使得将任何图像编码为张量变得非常简单。
作为第二个参数,您甚至可以确定您想要的图像通道数(1、3、4),因此您可以优化您关心的数据。例如,如果您要识别手写,那么就没有真正需要 RGB。您可以立即从我们的张量转换中获得灰度张量!
要设置我们的图像到张量转换,您将探索两种最常见的输入。您将转换一个 DOM 元素,也将转换一个内存元素。内存元素将通过 URL 加载图像。
警告
如果到目前为止您一直在本地打开.html文件,那么这里将停止工作。您需要实际使用像 200 OK!这样的 Web 服务器或其他提到的托管解决方案来访问通过 URL 加载的图像。如果遇到困难,请参阅第二章。
要从 DOM 加载图像,您只需要在 DOM 上引用该项。在与本书相关的源代码中,我设置了一个示例来访问两个图像。跟随的最简单方法是阅读GitHub 上的第四章。
让我们用一个简单的img标签和id设置我们的 DOM 图像:
<img id="gant" src="/gant.jpg" />
是的,那是我决定使用的一张奇怪的图片。我有可爱的狗,但它们很害羞,拒绝签署发布协议成为我书中的模特。作为一个爱狗人士可能会很“艰难”。现在您有了一张图片,让我们写一个简单的 JavaScript 来引用所需的图像元素。
提示
在尝试访问图像元素之前,请确保document已经加载完成。否则,您可能会收到类似“源宽度为 0”的神秘消息。这在没有 JavaScript 前端框架的实现中最常见。在没有任何东西等待 DOM 加载事件的情况下,我建议在尝试访问 DOM 之前订阅window的加载事件。
在img放置并 DOM 加载完成后,您可以调用browser.fromPixels获取结果:
// Simply read from the DOM
const gantImage = document.getElementById('gant') // ①
const gantTensor = tf.browser.fromPixels(gantImage) // ②
console.log( // ③
`Successful conversion from DOM to a ${gantTensor.shape} tensor`
)
①
获取对img标签的引用。
②
从图像创建张量。
③
记录证明我们现在有了一个张量!这将打印以下内容:
Successful conversion from DOM to a 372,500,3 tensor
警告
如果您遇到类似于 Failed to execute 'getImageData' on 'CanvasRenderingContext2D': The canvas has been tainted by cross-origin data. 的错误,这意味着您正在尝试从另一个服务器加载图像而不是本地。出于安全原因,浏览器会防止这种情况发生。查看下一个示例以加载外部图像。
完美!但是如果我们的图像不在页面的元素中怎么办?只要服务器允许跨域加载 (Access-Control-Allow-Origin "*"),您就可以动态加载和处理外部图像。这就是 JavaScript 图像对象示例 的用武之地。我们可以这样将图像转换为张量:
// Now load an image object in JavaScript
const cake = new Image() // ①
cake.crossOrigin = 'anonymous' // ②
cake.src = '/cake.jpg' // ③
cake.onload = () => { // ④
const cakeTensor = tf.browser.fromPixels(cake) // ⑤
console.log( // ⑥
`Successful conversion from Image() to a ${cakeTensor.shape} tensor`
)
}
①
创建一个新的 Image web API 对象。
②
这在这里是不必要的,因为文件在服务器上,但通常需要设置此选项以访问外部 URL。
③
给出图像的路径。
④
等待图像完全加载到对象中,然后再尝试将其转换为张量。
⑤
将图像转换为张量。
⑥
打印我们的张量形状以确保一切按计划进行。这将打印以下内容:从 Image() 成功转换为 578,500,3 张量。
通过结合两种先前的方法,您可以创建一个单页面,其中显示一个图像元素并将两个张量的值打印到控制台(参见 图 4-6)。
![工作代码的截图]()
图 4-6. 两个图像变成张量的控制台日志
通过图像的日志,您可以看到它们都是 500 像素宽的 RGB 图像。如果修改第二个参数,您可以轻松地将这些图像中的任何一个转换为灰度或 RGBA。您将在本章后面修改我们的图像张量。
Node:张量到图像
在 Node.js 中,没有用于渲染的画布,只有安静高效地写文件。您将使用 tfjs-node 保存一个随机的 400 x 400 RGB。虽然图像张量是逐像素的值,但典型的图像格式要小得多。JPG 和 PNG 具有各种压缩技术、头部、特性等。生成的文件内部看起来与我们漂亮的 3D 图像张量完全不同。
一旦张量转换为它们的编码文件格式,您将使用 Node.js 文件系统库 (fs) 将文件写出。现在您已经有了一个计划,让我们探索保存张量到 JPG 和 PNG 的功能和设置。
编写 JPG
要将张量编码为 JPG,您将使用一个名为 node.encodeJpeg 的方法。此方法接受图像的 Int32 表示和一些选项,并返回一个包含结果数据的 promise。
您可能注意到的第一个问题是,输入张量 必须 是具有值 0-255 的 Int32 编码,而浏览器可以处理浮点和整数值。也许这是一个优秀的开源贡献者的绝佳机会!?
提示
任何具有值 0-1 的 Float32 张量都可以通过将其乘以 255 然后转换为 int32 的代码来转换为新的张量,例如:myTensor.mul(255).asType('int32')。
从张量中写入 JPG,就像在*GitHub 的第四章节中的 chapter4/node/node-encode中发现的那样,可以简单地这样做:
const bigMess = tf.randomUniform([400, 400, 3], 0, 255); // ①
tf.node.encodeJpeg(bigMess).then((f) => { // ②
fs.writeFileSync("simple.jpg", f); // ③
console.log("Basic JPG 'simple.jpg' written");
});
①
创建一个 400 x 400 的图像张量,其中包含随机的 RGB 像素。
②
使用张量输入调用 node.encodeJpeg。
③
生成的数据将使用文件系统库写入。
因为您要写入的文件是 JPG,您可以启用各种配置选项。让我们再写入另一张图片,并在此过程中修改默认设置:
const bigMess = tf.randomUniform([400, 400, 3], 0, 255);
tf.node
.encodeJpeg(
bigMess,
"rgb", // ①
90, // ②
true, // ③
true, // ④
true, // ⑤
"cm", // ⑥
250, // ⑦
250, // ⑧
"Generated by TFJS Node!" // ⑨
)
.then((f) => {
fs.writeFileSync("advanced.jpg", f);
console.log("Full featured JPG 'advanced.jpg' written");
});
①
format:您可以使用grayscale或rgb覆盖默认的颜色通道,而不是匹配输入张量。
②
quality:调整 JPG 的质量。较低的数字会降低质量,通常是为了减小文件大小。
③
progressive:JPG 具有从上到下加载或逐渐清晰的渐进加载能力。将其设置为 true 可以启用渐进加载格式。
④
optimizeSize:花费一些额外的周期来优化图像大小,而不会修改质量。
⑤
chromaDownsampling:这是一个技巧,其中照明比颜色更重要。它修改了数据的原始分布,使其对人眼更清晰。
⑥
densityUnit:选择每英寸或每厘米的像素;一些奇怪的人反对公制系统。
⑦
xDensity:设置 x 轴上的像素密度单位。
⑧
yDensity:设置 y 轴上的像素密度单位。
⑨
xmpMetadata:这是一个非可见的消息,存储在图像元数据中。通常,这是为许可和寻宝活动保留的。
根据您写入 JPG 的原因,您可以充分配置或忽略这些选项来自 Node.js!图 4-7 显示了您刚刚创建的两个 JPG 文件的文件大小差异。
![两个 JPG 文件大小]()
图 4-7. 我们两个示例的文件大小
写入 PNG
写入 PNG 的功能明显比 JPG 有限得多。正如您可能猜到的那样,我们将有一个友好的方法来帮助我们,它被称为node.encodePng。就像我们的朋友 JPG 一样,该方法期望我们的张量的整数表示,值范围在0-255之间。
我们可以轻松地写入 PNG 如下:
const bigMess = tf.randomUniform([400, 400, 3], 0, 255);
tf.node.encodePng(bigMess).then((f) => {
fs.writeFileSync("simple.png", f);
console.log("Basic PNG 'simple.png' written");
});
PNG 参数并不那么先进。您只有一个新参数,而且它是一个神秘的参数!node.encodePng的第二个参数是一个压缩设置。该值可以在-1和9之间任意取值。默认值为1,表示轻微压缩,而9表示最大压缩。
提示
您可能认为-1表示无压缩,但通过实验,0表示无压缩。实际上,-1激活了最大压缩。因此,-1 和 9 实际上是相同的。
由于 PNG 在压缩随机性方面表现糟糕,您可以将第二个参数设置为9,得到与默认设置大小相近的文件:
tf.node.encodePng(bigMess, 9).then((f) => {
fs.writeFileSync("advanced.png", f);
console.log("Full featured PNG 'advanced.png' written");
});
如果您想看到实际的文件大小差异,请尝试打印一些易于压缩的内容,比如tf.zeros。无论如何,您现在可以轻松地从张量生成 PNG 文件。
注意
如果您的张量使用了 alpha 通道,您不能使用 JPG 等格式;您将不得不保存为 PNG 以保留这些数据。
Node:图像到张量
Node.js 是一个出色的工具,用于训练机器学习模型,因为它具有直接的文件访问和解码图像的速度。在 Node.js 上将图像解码为张量与编码过程非常相似。
Node 提供了解码 BMP、JPG、PNG 甚至 GIF 文件格式的功能。但是,正如您可能期望的那样,还有一个通用的node.decodeImage方法,能够自动进行简单的识别查找和转换。您现在将使用decodeImage,并留下decodeBMP等待您需要时查看。
对于图像的最简单解码是直接将文件传递给命令。为此,您可以使用标准的 Node.js 库fs和path。
这个示例代码依赖于一个名为cake.jpg的文件进行加载和解码为张量。此演示中使用的代码和图像资源可在 GitHub 的第四章chapter4/node/node-decode中找到。
import * as tf from '@tensorflow/tfjs-node'
import * as fs from 'fs'
import * as path from 'path'
const FILE_PATH = 'files'
const cakeImagePath = path.join(FILE_PATH, 'cake.jpg')
const cakeImage = fs.readFileSync(cakeImagePath) // ①
tf.tidy(() => {
const cakeTensor = tf.node.decodeImage(cakeImage) // ②
console.log(`Success: local file to a ${cakeTensor.shape} tensor`)
const cakeBWTensor = tf.node.decodeImage(cakeImage, 1) // ③
console.log(`Success: local file to a ${cakeBWTensor.shape} tensor`)
})
①
您使用文件系统库将指定的文件加载到内存中。
②
您将图像解码为与导入图像的颜色通道数量相匹配的张量。
③
您将此图像解码为灰度张量。
正如我们之前提到的,解码过程还允许解码 GIF 文件。一个明显的问题是,“GIF 的哪一帧?”为此,您可以选择所有帧或动画 GIF 的第一帧。node.decodeImage方法有一个标志,允许您确定您的偏好。
注意
物理学家经常争论第四维是时间还是不是时间。不管关于 4D 闵可夫斯基时空是否是现实的争论,对于动画 GIF 来说,这是一个已被证明的现实!为了表示动画 GIF,您使用一个四阶张量。
这个示例代码解码了一个动画 GIF。您将要使用的示例 GIF 是一个 500 x 372 的动画 GIF,有 20 帧:
const gantCakeTensor = tf.node.decodeImage(gantCake, 3, 'int32', true)
console.log(`Success: local file to a ${gantCakeTensor.shape} tensor`)
对于node.decodeImage参数,您提供图像数据,接着是三个颜色通道,作为一个int32结果张量,最后一个参数是true。
传递true让方法知道展开动画 GIF 并返回一个 4D 张量,而false会将其剪裁为 3D。
我们的结果张量形状,正如您可能期望的那样,是[20, 372, 500, 3]。
常见的图像修改
将图像导入张量进行训练是强大的,但很少是直接的。当图像用于机器学习时,它们通常有一些常见的修改。
常见的修改包括:
-
被镜像以进行数据增强
-
调整大小以符合预期的输入大小
-
裁剪出脸部或其他所需部分
您将在机器学习中执行许多这些操作,并且您将在接下来的两章中看到这些技能被使用。第十二章的毕业项目将大量依赖这项技能。让我们花点时间来实现一些这些日常操作,以完善您对图像张量的舒适度。
镜像图像张量
如果您正在尝试训练一个识别猫的模型,您可以通过镜像您现有的猫照片来使数据集翻倍。微调训练图像以增加数据集是一种常见做法。
要为图像翻转张量数据,您有两个选项。一种是以一种方式修改图像张量的数据,使图像沿宽度轴翻转。另一种方法是使用tf.image.flipLeftRight,这通常用于图像批次。让我们两者都做一下。
要翻转单个图像,您可以使用tf.reverse并指定您只想翻转包含图像宽度像素的轴。正如您已经知道的,这是图像的第二个轴,因此您将传递的索引是1。
在本章的相应源代码中,您显示一幅图像,然后在旁边的画布上镜像该图像。您可以在 GitHub 的simple/simple-image-manipulation/mirror.html中访问此示例。此操作的完整代码如下:
// Simple Tensor Flip
const lemonadeImage = document.getElementById("lemonade");
const lemonadeCanvas = document.getElementById("lemonadeCanvas");
const lemonadeTensor = tf.browser.fromPixels(lemonadeImage);
const flippedLemonadeTensor = tf.reverse(lemonadeTensor, 1) // ①
tf.browser.toPixels(flippedLemonadeTensor, lemonadeCanvas).then(() => {
lemonadeTensor.dispose();
flippedLemonadeTensor.dispose();
})
①
reverse 函数将轴索引1翻转以反转图像。
因为您了解底层数据,将此转换应用于您的图像是微不足道的。您可以尝试沿高度或甚至 RGB 轴翻转。任何数据都可以被反转。
Figure 4-8 显示了在轴1上使用tf.reverse的结果。
![翻转单个轴]()
图 4-8。tf.reverse 用于轴设置为 1 的 lemonadeTensor
提示
反转和其他数据操作方法并不局限于图像。您可以使用这些方法来增强非视觉数据集,如井字棋和类似的游戏。
我们还应该回顾另一种镜像图像的方法,因为这种方法可以处理一组图像的镜像,并且在处理图像数据时暴露了一些非常重要的概念。毕竟,我们的目标是尽可能依赖张量的优化,并尽量远离 JavaScript 的迭代循环。
第二种镜像图像的方法是使用tf.image.flipLeftRight。这种方法旨在处理一组图像,并且一组 3D 张量基本上是 4D 张量。对于我们的演示,您将取一张图像并将其制作成一组一张的批次。
要扩展单个 3D 图像的维度,您可以使用tf.expandDims,然后当您想要反转它(丢弃不必要的括号)时,您可以使用tf.squeeze。这样,您可以将 3D 图像移动到 4D 以进行批处理,然后再次缩小。对于单个图像来说,这似乎有点愚蠢,但这是一个很好的练习,可以帮助您理解批处理和张量维度变化的概念。
因此,一个 200 x 200 的 RGB 图像起始为[200, 200, 3],然后您扩展它,实质上使其成为一个堆叠。结果形状变为[1, 200, 200, 3]。
您可以使用以下代码在单个图像上执行tf.image.flipLeftRight:
// Batch Tensor Flip
const cakeImage = document.getElementById("cake");
const cakeCanvas = document.getElementById("cakeCanvas");
const flipCake = tf.tidy(() => {
const cakeTensor = tf.expandDims( // ①
tf
.browser.fromPixels(cakeImage) // ②
.asType("float32") // ③
);
return tf
.squeeze(tf.image.flipLeftRight(cakeTensor)) // ④
.asType("int32"); // ⑤
})
tf.browser.toPixels(flipCake, cakeCanvas).then(() => {
flipCake.dispose();
});
①
张量的维度被扩展。
②
将 3D 图像导入为张量。
③
在撰写本节时,image.flipLeftRight期望图像是一个float32张量。这可能会在未来发生变化。
④
翻转图像批次,然后在完成后将其压缩回 3D 张量。
⑤
image.flipLeftRight返回0-255的值,因此您需要确保发送给browser.toPixels的张量是int32,这样它才能正确渲染。
这比我们使用tf.reverse更复杂一些,但每种策略都有其自身的优点和缺点。在可能的情况下,充分利用张量的速度和巨大计算能力是至关重要的。
调整图像张量的大小
许多 AI 模型期望特定的输入图像尺寸。这意味着当您的用户上传 700 x 900 像素的图像时,模型正在寻找一个尺寸为 256 x 256 的张量。调整图像大小是处理图像输入的核心。
注意
调整图像张量的大小以用于输入是大多数模型的常见做法。这意味着任何与期望输入严重不成比例的图像,如全景照片,当调整大小以用于输入时可能表现糟糕。
TensorFlow.js 有两种优秀的方法用于调整图像大小,并且两者都支持图像批处理:image.resizeNearestNeighbor和image.resizeBilinear。我建议您在进行任何视觉调整时使用image.resizeBilinear,并将image.resizeNearestNeighbor保留用于当图像的特定像素值不能被破坏或插值时。速度上有一点小差异,image.resizeNearestNeighbor比image.resizeBilinear快大约 10 倍,但差异仍然以每次调整的毫秒数来衡量。
直白地说,resizeBilinear会模糊,而resizeNearestNeighbor会像素化,当它们需要为新数据进行外推时。让我们使用这两种方法放大图像并进行比较。您可以在simple/simple-image-manipulation/resize.html中查看此示例。
// Simple Tensor Flip
const newSize = [768, 560] // 4x larger // ①
const littleGantImage = document.getElementById("littleGant");
const nnCanvas = document.getElementById("nnCanvas");
const blCanvas = document.getElementById("blCanvas");
const gantTensor = tf.browser.fromPixels(littleGantImage);
const nnResizeTensor = tf.image.resizeNearestNeighbor( // ②
gantTensor,
newSize,
true // ③
)
tf.browser.toPixels(nnResizeTensor, nnCanvas).then(() => {
nnResizeTensor.dispose();
})
const blResizeTensor = tf.image.resizeBilinear( // ④
gantTensor,
newSize,
true // ⑤
)
const blResizeTensorInt = blResizeTensor.asType('int32') // ⑥
tf.browser.toPixels(blResizeTensorInt, blCanvas).then(() => {
blResizeTensor.dispose();
blResizeTensorInt.dispose();
})
// All done with ya
gantTensor.dispose();
①
将图像大小增加 4 倍,以便您可以看到这两者之间的差异。
②
使用最近邻算法调整大小。
③
第三个参数是alignCorners;请始终将其设置为 true。¹
④
使用双线性算法调整大小。
⑤
始终将此设置为true(参见3)。
⑥
截至目前,resizeBilinear返回一个float32,你需要进行转换。
如果你仔细观察图 4-9 中的结果,你会看到最近邻的像素呈现锐利的像素化,而双线性的呈现柔和的模糊效果。
![调整大小方法]()
图 4-9. 使用调整大小方法的表情符号(有关图像许可证,请参见附录 C)
警告
使用最近邻算法调整大小可能会被恶意操纵。如果有人知道你最终的图像尺寸,他们可以构建一个看起来只在那个调整大小时不同的邪恶图像。这被称为对抗性预处理。更多信息请参见https://scaling-attacks.net。
如果你想看到鲜明对比,你应该尝试使用两种方法调整本章开头创建的 4 x 3 图像的大小。你能猜到哪种方法会在新尺寸上创建一个棋盘格,哪种方法不会吗?
裁剪图像张量
在我们最后一轮的基本图像张量任务中,我们将裁剪一幅图像。我想指出,就像我们之前的镜像练习一样,有一种适用于批量裁剪大量图像的版本,称为image.cropAndResize。知道这种方法的存在,你可以利用它来收集和规范化图像的部分用于训练,例如,抓取照片中检测到的所有人脸并将它们调整到相同的输入尺寸以供模型使用。
目前,你只需从 3D 张量中裁剪出一些张量数据的简单示例。如果你想象这在空间中,就像从一个更大的矩形蛋糕中切出一个小矩形薄片。
通过给定切片的起始位置和大小,你可以在任何轴上裁剪出你想要的任何部分。你可以在 GitHub 上的simple/simple-image-manipulation/crop.html找到这个例子。要裁剪单个图像,请使用以下代码:
// Simple Tensor Crop
const startingPoint = [0, 40, 0]; // ①
const newSize = [265, 245, 3]; // ②
const lemonadeImage = document.getElementById("lemonade");
const lemonadeCanvas = document.getElementById("lemonadeCanvas");
const lemonadeTensor = tf.browser.fromPixels(lemonadeImage);
const cropped = tf.slice(lemonadeTensor, startingPoint, newSize) // ③
tf.browser.toPixels(cropped, lemonadeCanvas).then(() => {
cropped.dispose();
})
lemonadeTensor.dispose();
①
从下方0像素开始,向右40像素,并且在红色通道上。
②
获取接下来的265像素高度,245像素宽度,以及所有三个 RGB 值。
③
将所有内容传入tf.slice方法。
结果是原始图像的精确裁剪,你可以在图 4-10 中看到。
![使用切片裁剪张量]()
图 4-10. 使用tf.slice裁剪单个图像张量
新的图像工具
你刚刚学会了三种最重要的图像操作方法,但这并不意味着你的能力有所限制。新的 AI 模型将需要新的图像张量功能,因此,TensorFlow.js 和辅助库不断添加用于处理和处理图像的方法。现在,你可以更加自如地在单个和批量形式中利用和依赖这些工具。
章节回顾
从可编辑张量中编码和解码图像使你能够进行逐像素的操作,这是很少有人能做到的。当然,你已经学会了为了我们在 AI/ML 中的目标而学习视觉张量,但事实上,如果你愿意,你可以尝试各种疯狂的图像操作想法。如果你愿意,你可以做以下任何一种:
-
铺设一个你自己设计的像素图案
-
从另一幅图像中减去一幅图像以进行艺术设计
-
通过操纵像素值在图像中隐藏一条消息
-
编写分形代码或其他数学可视化
-
去除背景图像颜色,就像绿幕一样
在本章中,你掌握了创建、加载、渲染、修改和保存大型结构化数据张量的能力。处理图像张量不仅简单,而且非常有益。你已经准备好迎接任何挑战。
章节挑战:排序混乱
使用您在本章和之前章节学到的方法,您可以用张量做一些非常令人兴奋和有趣的事情。虽然这个挑战没有我能想到的特定实用性,但它是对您所学内容的有趣探索。作为对所学课程的练习,请思考以下问题:
如何生成一个随机的 400 x 400 灰度张量,然后沿一个轴对随机像素进行排序?
如果您完成了这个挑战,生成的张量图像将会像图 4-11 那样。
![一个随机噪声张量排序]()
图 4-11. 沿宽度轴排序的 400 x 400 随机性
您可以使用本书中学到的方法来解决这个问题。如果遇到困难,请查阅TensorFlow.js 在线文档。在文档中搜索关键词将指引您正确方向。
您可以在附录 B 中找到这个挑战的答案。
复习问题
让我们回顾一下您在本章编写的代码中学到的知识。请花点时间回答以下问题:
-
如果一个图像张量包含值0-255,为了正确渲染它需要什么类型的数据?
-
一个 2 x 2 的红色Float32在张量形式中会是什么样子?
-
tf.fill([100, 50, 1], 0.2)会创建什么样的图像张量?
-
真或假:要保存一个 RGBA 图像,您必须使用一个四阶图像张量。
-
真或假:randomUniform如果给定相同的输入,将会创建相同的输出。
-
在浏览器中将图像转换为张量应该使用什么方法?
-
在 Node.js 中对 PNG 进行编码时,第二个参数应该使用什么数字以获得最大压缩?
-
如果您想要将图像张量上下翻转,您该如何做?
-
哪个更快?
-
循环遍历一组图像并调整它们的大小
-
将一组图像作为四阶张量进行批处理并调整整个张量的大小
-
以下结果的秩和大小是多少:
[.keep-together]#`tf.slice(myTensor, [0,0,0], [20, 20, 3])`?#
这些练习的解决方案可以在附录 A 中找到。
¹ TensorFlow 对alignCorners的实现存在错误,可能会有问题。
第五章:介绍模型
“他从哪里弄来那些美妙的玩具?”
—杰克·尼科尔森(蝙蝠侠)
现在您已经进入大联盟。在第二章中,您访问了一个完全训练好的模型,但您根本不需要了解张量。在这里的第五章,您将能够利用您的张量技能直接与您的模型一起工作,没有训练轮。
最后,您将开始利用大多数机器学习的大脑。模型可能看起来像黑匣子。通常,它们期望特定的张量形状输入,并输出特定的张量形状。例如,假设您已经训练了一个狗或猫分类器。输入可能是一个 32 x 32 的 3D RGB 张量,输出可能是一个从零到一的单个张量值,表示预测。即使您不了解这种设备的内部工作原理,至少使用具有定义结构的模型应该是简单的。
我们将:
-
利用训练好的模型来预测各种答案
-
识别我们现有张量操作技能的好处
-
了解谷歌的 TFHub.dev 托管
-
了解对象定位
-
学习如何叠加边界框以识别图像的某些方面
本章将教您直接访问模型。您不会依赖于可爱的包装库来照顾。如果愿意,您甚至可以围绕现有的 TensorFlow.js 模型编写自己的包装库。掌握了本章的技能,您可以开始将突破性的机器学习模型应用于任何网站。
加载模型
我们知道我们需要将模型加载到内存中,最好是加载到像张量这样的 GPU 加速内存中,但是从哪里加载?作为一种祝福和诅咒,答案是“任何地方!”在软件中加载文件是很常见的,因此在 TensorFlow.js 中有各种答案。
为了加剧这个问题,TensorFlow.js 支持两种不同的模型格式。幸运的是,这些选项的组合并不复杂。您只需要知道需要哪种类型的模型以及从哪里访问它。
目前,TensorFlow.js 中有两种模型类型,每种类型都有其自己的优缺点。最简单且最可扩展的模型称为层模型。这种模型格式允许您检查、修改甚至拆解模型以进行调整。该格式非常适合重新调整和调整。另一种模型格式是图模型。图模型通常更加优化和计算效率更高。使用图模型的成本是模型更加“黑匣子”,由于其优化,更难以检查或修改。
模型类型很简单。如果要加载层模型,您需要使用方法loadLayersModel,如果要加载 GraphDef 模型,则需要使用方法loadGraphModel。这两种模型类型各有利弊,但这超出了本章的范围。关键是加载所需模型类型几乎没有复杂性;只是一个问题是哪种类型,然后使用相应的方法。最重要的方面是第一个参数,即模型数据的位置。
提示
在本书结束时,您将对层模型和图模型类型之间的关键差异有相当扎实的理解。每次引入一个模型时,请注意使用了哪种模型。
本节解释了模型位置的多样性选项以及将它们绑定在一起的简单统一 URI 语法。
通过公共 URL 加载模型
使用公共 URL 加载模型是在 TensorFlow.js 中访问模型的最常见方法。正如您在第二章中记得的那样,当您加载毒性检测模型时,您从公共网络下载了文件的几个片段,每个片段大小为 4 MB,可以缓存。模型知道要下载文件的位置。这是通过单个 URL 到单个文件完成的。最初请求的模型文件是一个简单的 JavaScript 对象表示(JSON)文件,随后的文件是从该 JSON 文件中识别出的神经网络的权重。
从 URL 加载 TensorFlow.js 模型需要主动托管相邻的模型文件(相同的相对文件夹)。这意味着一旦您为模型的 JSON 文件提供路径,它通常会引用同一目录级别的连续文件中的权重。期望的结构如下:
Site
├─── Example Folder
├─── index.html
├─── Model Folder
│ ├─── model.json
│ └─── group1-shard1of3
│ └─── group1-shard2of3
│ └─── group1-shard3of3
...
移动或拒绝访问这些额外文件将导致您的模型无法使用并出现错误。根据服务器环境的安全性和配置,这可能是一个难点。因此,您应始终验证每个文件是否具有适当的 URL 访问权限。
注意
到目前为止,我们已经介绍了三种运行 TensorFlow.js 的主要方法。它们是简单的 200 OK!托管、使用 Parcel 打包的 NPM 和使用 Node.js 托管的服务器。在我们告诉您如何为这些情况正确加载模型之前,您能否确定哪种方法会出现问题?
200 OK!Chrome 的 Web 服务器示例不会出现问题,因为文件夹中的所有内容都是无优化或安全性地托管的。Parcel 为我们提供了一些功能,如转换、错误日志记录、HMR 和捆绑。有了这些功能,我们的 JSON 和权重文件不会被传递到分发文件夹,也就是dist文件夹,除非进行一些调整。
在 Parcel.js 2.0 中(在撰写本文时尚未正式发布),您将有更多选项用于静态文件,但目前,有一个简单的解决方案适用于我们将使用的 Parcel 1.x。您可以安装一个名为parcel-plugin-static-files-copy的插件,以允许本地静态托管模型文件。本书相关存储库中使用的代码利用了这个插件。
该插件通过有效地使放置在static目录中的任何文件从根 URL 公开访问。例如,放置在static/model中的model.json文件将可以作为localhost:1234/model/model.json访问。
无论您使用哪种 Web 解决方案,都需要验证模型文件的安全性和捆绑是否适合您。对于未受保护的公共文件夹,只需将所有文件上传到像 Amazon Web Services(AWS)和 Simple Storage Service(S3)这样的服务即可。您需要使整个存储桶公开,或者每个相邻文件都必须明确公开。验证您可以访问 JSON 和 BIN 文件是很重要的。缺少或受限制的模型片段的错误消息令人困惑。您会看到一个404,但错误会继续到第二个更加难以理解的错误,就像图 5-1 中所示的那样。
![缺少 bin 文件的错误截图。]()
图 5-1. 错误:JSON 可用但没有 bin 文件
提示
Create React App 是一个用于简单 React 网站的流行工具。如果您使用 Create React App,public文件夹中的文件将默认从根 URL 访问。将public视为我们 Parcel 解决方案的static文件夹。两者都非常有效,并已经为模型托管进行了测试。
从其他位置加载模型
模型不必位于公共 URL 中。TensorFlow 有方法允许您从本地浏览器存储、IndexedDB 存储以及在 Node.js 中,本地文件系统访问模型文件。
其中一个重要的好处是,您可以从公共 URL 加载的模型在本地缓存,以便您的应用程序可以脱机准备。其他原因包括速度、安全性,或者仅仅是因为您可以。
浏览器文件
本地浏览器存储和 IndexedDB 存储是两种用于保存指定页面的文件的 Web API。与存储小数据片段(如单个变量)的 cookie 不同,Window.localStorage和 IndexedDB API 是客户端存储,能够处理文件等其他重要结构化数据跨浏览器会话。
公共 URL 具有http和https方案;但是,这些方法在 URI 中使用不同的方案。要从本地存储加载模型,您将使用localstorage://model-name URI,要从 IndexedDB 加载模型,您将使用indexeddb://model-name URI。
除了提供的方法外,您可以存储和检索 TensorFlow.js 模型的位置没有限制。归根结底,您只需要数据,因此您可以使用任何自定义的IOHandler加载模型。例如,甚至已经有将模型完全转换为 JSON 文件的概念验证工作,权重已编码,因此您可以根据需要从任何位置调用require,甚至通过捆绑器。
文件系统文件
要访问文件系统中的文件,您需要使用一个具有权限获取所需文件的 Node.js 服务器。浏览器被沙箱化,目前无法使用此功能。
幸运的是,这与以前的 API 类似。使用file:方案来标识给定文件的路径,就像这样:file://path/to/model.json。就像在浏览器示例中一样,辅助文件必须位于同一文件夹中并且可访问。
我们第一个使用的模型
现在您熟悉了将模型加载到内存的机制,您可以在项目中使用模型。当您在第二章中使用毒性模型时,这对您进行了自动化,但是现在,您熟悉了张量和模型访问,可以处理一个模型,而无需所有保护包代码。
您需要一个简单的模型用于第一个示例。正如您所记得的,您在第三章中将井字棋棋盘编码为练习。让我们从您现有知识的基础上构建,不仅编码一个井字棋比赛,还将该信息传递到训练模型进行分析。训练模型将预测并返回最佳下一步的答案。
本节的目标是询问 AI 模型推荐哪些移动,这些移动在图 5-2 中有所说明。
![三个示例游戏状态]()
图 5-2。三个游戏状态
这些游戏中的每一个处于不同的情况:
情景 A
这是空白的,允许 AI 进行第一步。
情景 B
现在轮到 O 走棋了,我们期望 AI 通过在右上角的方格中下棋来阻止潜在的失败。
情景 C
现在轮到 X 走棋了,我们期望 AI 在顶部中间移动并取得胜利!
让我们看看 AI 推荐什么,通过对这三种状态进行编码并打印模型的输出。
加载、编码和询问模型
您将使用简单的 URL 来加载模型。这个模型将是一个 Layers 模型。这意味着您将使用tf.loadLayersModel和路径到本地托管模型文件来加载。在本例中,模型文件将托管在model/ttt_model.json。
注意
本示例的训练井字棋模型可以在本书的相关GitHub中访问。JSON 文件大小为 2 KB,权重文件(ttt_model.weights.bin)大小为 22 KB。对于一个井字棋求解器来说,这 24 KB 的负载并不算太大!
为了转录游戏棋盘状态,编码会有一点差异。你需要告诉 AI 它是为哪个团队在玩。你还需要一个可以对 X 和 O 无动于衷的 AI。因为情景 B 是在询问 AI 关于 O 而不是 X 的建议,我们需要一个灵活的编码系统。不要让 X 总是代表 1,将 AI 分配为 1,对手分配为-1。这样我们可以让 AI 处于玩 X 或 O 的情况。表 5-1 显示了查找每种可能值的情况。
表 5-1. 网格到数字的转换
所有三个游戏需要被编码,然后堆叠成一个单一的张量传递给 AI 模型。然后模型提供三个答案,每种情况一个。
这是完整的过程:
-
加载模型。
-
编码三个单独的游戏状态。
-
将状态堆叠成一个单一的张量。
-
要求模型打印结果。
将输入堆叠到模型是一种常见的做法,可以让你的模型处理加速内存中的任意数量的预测。
堆叠增加了结果的维度。在 1D 张量上执行这个操作会创建一个 2D 张量,依此类推。在这种情况下,你有三个用 1D 张量表示的棋盘状态,所以堆叠它们将创建一个[3, 9]的二阶张量。大多数模型支持对它们的输入进行堆叠或批处理,输出将类似地堆叠,并与输入索引匹配的答案。
这段代码可以在 GitHub 仓库的chapter5/simple/simple-ttt-model找到,看起来是这样的:
tf.ready().then(() => { // ①
const modelPath = "model/ttt_model.json" // ②
tf.tidy(() => {
tf.loadLayersModel(modelPath).then(model => { // ③
// Three board states
const emptyBoard = tf.zeros([9]) // ④
const betterBlockMe = tf.tensor([-1, 0, 0, 1, 1, -1, 0, 0, -1]) // ⑤
const goForTheKill = tf.tensor([1, 0, 1, 0, -1, -1, -1, 0, 1]) // ⑥
// Stack states into a shape [3, 9]
const matches = tf.stack([emptyBoard, betterBlockMe, goForTheKill]) // ⑦
const result = model.predict(matches) // ⑧
// Log the results
result.reshape([3, 3, 3]).print() // ⑨
})
})
})
①
使用tf.ready,当 TensorFlow.js 准备好时解析。不需要 DOM 访问。
②
虽然模型是两个文件,但只需要识别 JSON 文件。它了解并加载任何额外的模型文件。
③
loadLayersModel模型解析为完全加载的模型。
④
一个空棋盘是九个零,代表情景 A。
⑤
编码为 X 等于-1代表情景 B。
⑥
编码为 X 等于1代表情景 C。
⑦
使用tf.stack将三个 1D 张量组合成一个 2D 张量。
⑧
使用.predict来要求模型识别最佳的下一步。
⑨
原始输出将被形状化为[3, 9],但这是一个很好的情况,通过重新塑造输出使其更易读。打印结果在三个 3 x 3 的网格中,这样我们可以像游戏棋盘一样阅读它们。
警告
当使用loadLayersModel甚至loadGraphModel时,TensorFlow.js 库依赖于fetch web API 的存在。如果在 Node.js 中使用这种方法,你需要使用像node-fetch这样的包来填充fetch。
前述代码成功地将三场比赛转换为 AI 模型期望的张量格式,并通过模型的predict()方法运行这些值进行分析。结果将打印到控制台,并看起来像我们在图 5-3 中看到的样子。
![井字棋模型的结果]()
图 5-3. 我们代码生成的结果为[3, 3, 3]形状的张量
这个神奇的方法是模型的predict()函数。该函数让模型知道为给定的输入生成输出预测。
解释结果
对于一些人来说,这个结果张量完全有意义,对于其他人,你可能需要一点上下文。结果再次是下一步最佳移动的概率。最高的数字获胜。
为了得到一个正确的概率,答案需要相加得到 100%,而它们确实相加得到了。让我们看看在这里显示的空井字棋板结果在情景 1 中:
[
[0.2287459, 0.0000143, 0.2659601],
[0.0000982, 0.0041204, 0.0001773],
[0.2301052, 0.0000206, 0.270758 ]
],
如果你像我这样傻乎乎地把这九个值输入到你的计算器(TI-84 Plus CE 永远是我的最爱!),它们会相加得到数字 1。这意味着每个对应的值都是该位置的百分比投票。我们可以看到四个角都有一个显著(接近 25%)的结果。这是有道理的,因为在井字棋中,从一个角开始是最好的策略,其次是中间,它有次高的价值。
因为底部右侧有 27%的投票,这将是 AI 最有可能的移动。让我们看看 AI 在另一个情景中的表现。如果你还记得,在图 5-2 的情景 B 中,AI 需要移动到右上角来阻止。AI 的结果张量在情景 2 中显示:
[
[0.0011957, 0.0032045, 0.9908957],
[0.000263 , 0.0006491, 0.0000799],
[0.0010194, 0.0002893, 0.0024035],
],
顶部右侧的值为 99%,所以模型正确地阻止了给定的威胁。机器学习模型的一个有趣之处是其他移动仍然有值,包括已经被占据的空格。
最后一个情景是一个编码的张量,用来查看模型是否能够获胜井字棋。预测批次的结果在情景 3 中显示:
[
[0.0000056, 0.9867876, 0.0000028],
[0.0003809, 0.0001524, 0.0011258],
[0.0000328, 0.0114983, 0.0000139]
],
结果是 99%(四舍五入)确定顶部中间是最佳移动,这是正确的。其他移动甚至都不接近。所有三个预测结果似乎不仅是有效的移动,而且是给定状态下的正确移动。
你已经成功地加载并与一个模型进行交互,让它提供结果。凭借你刚刚获得的技能,你可以编写自己的井字棋游戏应用。我想互联网上对井字棋游戏的需求不会很大,但如果提供了相同结构的训练模型,你可以使用 AI 制作各种游戏!
提示
大多数模型都会有一些相关的文档,帮助你识别正确的输入和输出,但 Layers 模型有一些属性,如果需要帮助,你可以访问这些属性。期望的输入形状可以在model.input.shape中看到,输出可以在model.outputShape中看到。这些属性在 Graph 模型上不存在。
清理棋盘后
在这个例子中,TensorFlow.js 模型被包装在一个tidy中,并且在代码完成后会自动释放内存。在大多数情况下,你不会这么快完成你的模型。重要的是要注意,你必须像处理张量一样调用.dispose()来处理模型。模型被加速处理方式相同,因此它们有相同的清理成本。
重新加载网页通常会清除张量,但长时间运行的 Node.js 服务器将不得不监视和验证张量和模型是否被处理。
我们的第一个 TensorFlow Hub 模型
现在你已经正确地编码、加载和处理了少量数据通过一个自定义模型,你应该花一点时间挑战自己。在这一部分,你将加载一个规模更大的模型从 TensorFlow Hub,并处理一张图片。井字棋是九个值的输入,而大多数图片是包含数千个值的张量。
你将要加载的模型是目前最大和最令人印象深刻的模型之一,Inception v3。Inception 模型是一个令人印象深刻的网络,最初在 2015 年创建。这第三个版本已经训练了数十万张图片。这个模型有 91.02 MB,可以对 1,001 种不同的对象进行分类。来自第二章的 Chapter Challenge 中的 MobileNet-wrapped NPM 包很棒,但不像你即将使用的模型那样强大。
探索 TFHub
Google 已经开始免费托管像 Inception v3 这样的模型在其自己的 CDN 上。对于这种大型模型大小的情况,拥有一个可靠且令人印象深刻的版本化 CDN 对于像我们经常为 JavaScript 做的模型非常有用。您可以在 https://tfhub.dev 上访问数百个经过训练并准备就绪的 TensorFlow 和 TensorFlow.js 模型。TensorFlow.js 有一种特殊的方式来识别您的模型是否托管在 TFHub 上;我们只需在确定了模型 URL 后,在配置中添加 { fromTFHub: true }。
当您浏览 TFHub 时,您可以看到各种发布者和每个模型的解释。这些解释很关键,因为正如我们已经确定的那样,模型对于输入和输出的期望是非常具体的。您可以在 与 Inception v3 相关的 TFHub 页面 上了解更多信息。这个模型是由 Google 构建的,提供的版本经过了广泛的训练。如果您渴望获取更多信息,不妨浏览一下 关于该模型的发表论文。
在 TFHub 页面上,您可以获得使用模型的这两个关键见解。首先,预期的输入图片尺寸应为 299 x 299,值应为 0-1,并且应该像我们在之前的井字棋示例中一样进行批处理。其次,模型返回的结果是一个具有 1,001 个值的单维张量,最大的值最有可能(类似于井字棋返回的九个值)。这可能听起来有点混乱,但该页面使用了一些基于统计的术语来表达这一点:
输出是一批 logits 向量。Logits 中的索引是原始训练中分类的 num_classes = 1001 个类。
返回一个数值结果是有用的,但是像往常一样,我们需要将其映射回一个有用的标签。在井字棋中,我们将索引映射到棋盘上的位置,而在这种情况下,我们将值的索引映射到相应的标签,这些标签遵循相同的索引。TFHub 页面 分享了一个 TXT 文件,其中包含了所有必要标签的正确顺序,您将使用这些标签创建一个数组来解释预测结果。
连接 Inception v3
现在您知道 Inception v3 模型可以对照片进行分类,并且您已经了解了输入和输出规范。这就像是井字棋问题的一个更大版本。然而,会有新的障碍。例如,打印 1,001 个数字并不会提供有用的信息。您需要使用 topk 将巨大的张量解析回一个有用的上下文中。
以下代码可在 GitHub 仓库的 chapter5/simple/simple-tfhub 文件夹中找到。该代码依赖于一个具有 id mystery 的神秘图片。理想情况下,AI 可以为我们解决这个谜题:
tf.ready().then(() => {
const modelPath =
"https://tfhub.dev/google/tfjs-model/imagenet/inception_v3/classification/3/default/1"; // ①
tf.tidy(() => {
tf.loadGraphModel(modelPath, { fromTFHub: true }).then((model) => { // ②
const mysteryImage = document.getElementById("mystery");
const myTensor = tf.browser.fromPixels(mysteryImage);
// Inception v3 expects an image resized to 299x299
const readyfied = tf.image
.resizeBilinear(myTensor, [299, 299], true) // ③
.div(255) // ④
.reshape([1, 299, 299, 3]); // ⑤
const result = model.predict(readyfied); // ⑥
result.print(); // ⑦
const { values, indices } = tf.topk(result, 3); // ⑧
indices.print(); // ⑨
// Let's hear those winners
const winners = indices.dataSync();
console.log(` 10
First place ${INCEPTION_CLASSES[winners[0]]},
Second place ${INCEPTION_CLASSES[winners[1]]},
Third place ${INCEPTION_CLASSES[winners[2]]}
`);
});
});
});
①
这是 Inception 模型的 TFHub 的 URL。
②
加载图模型并将 fromTFHub 设置为 true。
③
图片被调整为 299 x 299。
④
将 fromPixels 的结果转换为介于 0 和 1 之间的值(对数据进行归一化)。
⑤
将 3D 张量转换为单批次 4D 张量,就像模型期望的那样。
⑥
对图片进行预测。
⑦
打印内容太多被截断了。
⑧
恢复前三个值作为我们的猜测。
⑨
打印前三个预测索引。
⑩
将索引映射到它们的标签并打印出来。INCEPTION_CLASSES 是一个标签数组,映射到模型输出。
在本章的相关代码中,您会发现三幅图片,您可以将其设置为本节中的神秘图片。Inception v3 令人印象深刻地正确识别了所有三幅图片。查看 图 5-4 中捕获的结果。
![Inception 正确识别磁带播放器]()
图 5-4. Inception v3 图像的分类结果
从照片中可以看出,Inception 的第一个选择是“磁带播放器”,我认为这非常准确。其次,它看到了一个“磁带播放器”,老实说我不知道这和“磁带播放器”有什么不同,但我不是超级模型。最后,第三高的值是“收音机”,这就是我会说的。
你通常不需要像这样的大型模型,但随着新模型被添加到 TFHub,你知道你有选择。偶尔浏览现有模型。你会看到很多关于图像分类的模型。对图像进行分类是 AI 入门中比较令人印象深刻的任务之一,但为什么要止步于此呢?
我们的第一个叠加模型
到目前为止,你一直在处理简单的输出模型。井字棋识别你的下一步,Inception 对照片进行分类,为了全面,你将在电影中展示 AI 的经典视觉效果,即在照片中识别物体的边界框。AI 不是对整个照片进行分类,而是在照片中突出显示特定的边界框,就像图 5-5 中那样。
![气球图像的边界框]()
图 5-5. 边界框叠加
通常,模型的边界框输出相当复杂,因为它处理各种类别和重叠框。通常,模型会让你使用一些数学方法来正确清理结果。与其处理这些,不如专注于在 TensorFlow.js 中绘制预测输出中的单个矩形。有时这被称为对象定位。
这个最终练习的模型将是一个宠物脸部检测器。该模型将尽力为我们提供一个边界坐标集,指示它认为宠物脸部位于何处。通常不难说服人们看可爱的狗和猫,但这个模型可能有各种应用。一旦你有了宠物脸部的位置,你可以使用这些数据来训练额外的模型,比如识别宠物或检查它们可爱的鼻子是否需要 boop。你懂的...科学!
定位模型
这个模型是在一个名为Oxford-IIIT 宠物数据集上训练的。这个小巧的、大约 2MB 的模型期望一个 256 x 256 的Float32输入 RGB 宠物图像,并输出四个数字来识别围绕宠物脸部的边界框。1D 张量中的四个数字是左上角点和右下角点。
这些点表示为 0 到 1 之间的值,作为图像的百分比。你可以使用模型结果信息定义一个矩形,如图 5-6 所示。
![4 个值如何变成两个点的显示]()
图 5-6. 四个值变成两个点
代码的开头将与之前的代码类似。你将首先将图像转换为张量,然后通过模型运行。以下代码可以在 GitHub 仓库的chapter5/simple/simple-object-localization中找到。
const petImage = document.getElementById("pet");
const myTensor = tf.browser.fromPixels(petImage);
// Model expects 256x256 0-1 value 3D tensor
const readyfied = tf.image
.resizeNearestNeighbor(myTensor, [256, 256], true)
.div(255)
.reshape([1, 256, 256, 3]);
const result = model.predict(readyfied);
// Model returns top left and bottom right
result.print();
标记检测
现在你可以将结果坐标绘制为图像上的矩形。在 TensorFlow.js 中绘制检测是一个常见的任务。在图像上绘制张量结果的基本方法需要你将图像放在一个容器中,然后在图像上方放置一个绝对位置的画布。现在当你在画布上绘制时,你将在图像上绘制。¹ 从侧面看,布局将类似于图 5-7。
![DOM 的 3D 视图]()
图 5-7. 画布的堆叠视图
对于这节课,CSS 已经直接嵌入到 HTML 中以方便。图像和画布布局如下:
<div style="position: relative; height: 80vh"> <!-- ① -->
<img id="pet" src="/dog1.jpg" height="100%" />
<canvas
id="detection"
style="position: absolute; left: 0;"
><canvas/> <!-- ② -->
</div>
①
包含的div是相对定位的,并且锁定在页面高度的 80%处。
②
画布以绝对位置放置在图像上。
对于简单的矩形,您可以使用画布上下文的strokeRect方法。strokeRect方法不像模型返回的那样需要两个点。它需要一个起点,然后是宽度和高度。要将模型点转换为宽度和高度,您只需减去每个顶点以获得距离。图 5-8 显示了这种计算的可视化表示。
![计算宽度和高度的解释]()
图 5-8。宽度和高度是 X 和 Y 之间的差异计算
使用起点、覆盖矩形的宽度和高度,您可以用几行代码在画布上按比例绘制它。记住,张量输出是一个百分比,需要在每个维度上进行缩放。
// Draw box on canvas
const detection = document.getElementById("detection");
const imgWidth = petImage.width;
const imgHeight = petImage.height;
detection.width = imgWidth; // ①
detection.height = imgHeight;
const box = result.dataSync(); // ②
const startX = box[0] * imgWidth; // ③
const startY = box[1] * imgHeight;
const width = (box[2] - box[0]) * imgWidth; // ④
const height = (box[3] - box[1]) * imgHeight;
const ctx = detection.getContext("2d");
ctx.strokeStyle = "#0F0";
ctx.lineWidth = 4;
ctx.strokeRect(startX, startY, width, height); // ⑤
①
使检测画布与其所覆盖的图像大小相同。
②
获取边界框结果。
③
将起点 X 和 Y 缩放回图像。
④
通过从 X[1]减去 X[2]来找到框的宽度,然后通过图像宽度进行缩放。Y[1]和 Y[2]也是如此。
⑤
现在使用画布的 2D 上下文来绘制所需的矩形。
结果是在给定点处完美放置的边界框。自己看看图 5-9。
![识别宠物脸部位置]()
图 5-9。宠物脸部定位
在运行此项目时的一个误解可能是您经历的检测和绘制很慢。这是错误的。很明显,当页面加载时,边界框出现之前会有延迟;然而,您正在经历的延迟包括加载模型并将其加载到某种加速内存中(有时称为模型预热)。尽管这有点超出了本章的目标,但如果您调用model.predict并再次绘制,您会在微秒内看到结果。您在本节中创建的画布+TensorFlow.js 结构可以轻松支持桌面计算机上每秒 60 帧以上。
具有大量边界框和标签的模型使用类似的strokeRect调用来勾画识别对象的位置。有各种各样的模型,它们各自识别图像的各个方面。在 TensorFlow.js 世界中,修改画布以在图像上绘制信息的实践非常有用。
章节回顾
了解模型的输入和输出是关键。在本章中,您最终看到了数据的全部过程。您转换了输入,将其传递给经过训练的模型,并解释了结果。模型可以接受各种各样的输入并提供同样广泛的输出。现在,无论模型需要什么,您都有一些令人印象深刻的经验可以借鉴。
章节挑战:可爱的脸
想象一下,我们的宠物脸部定位是更大过程中的第一步。假设您正在识别宠物脸部,然后将宠物脸部传递给另一个模型,该模型将寻找舌头以查看宠物是否发热和喘气。通常会像这样在管道中组织多个模型,每个模型都调整到自己特定的目的。
根据上一段代码中宠物脸部的位置,编写额外的代码来提取宠物的脸并为需要 96 x 96 图像输入的模型做准备。您的答案将是一个批量裁剪,如图 5-10。
![只有狗的脸]()
图 5-10。目标[1, 96, 96, 3]张量,只包含脸部
尽管这个练习是为了裁剪宠物的脸以供第二个模型使用,但它也可以很容易地成为一个“宠物匿名化器”,需要您模糊宠物的脸。浏览器中的人工智能应用是无限的。
你可以在附录 B 中找到这个挑战的答案。
复习问题
让我们回顾一下你在本章编写的代码中学到的教训。花点时间回答以下问题:
-
在 TensorFlow.js 中可以加载哪些类型的模型?
-
你需要知道一个模型被分成了多少个碎片吗?
-
除了公共 URL 之外,还有哪些地方可以加载模型?
-
loadLayersModel 返回什么?
-
如何清除已加载模型的内存?
-
Inception v3 模型的预期输入形状是什么?
-
使用哪个画布上下文方法可以绘制一个空矩形?
-
从 TFHub 加载模型时,你必须向加载方法传递哪个参数?
这些问题的解决方案可以在附录 A 中找到。
¹ 你不一定要使用画布;如果你愿意,你可以移动一个 DOM 对象,但是画布提供了简单和复杂的动画,速度很快。
第六章:高级模型和 UI
“做到之前总是看似不可能。”
—纳尔逊·曼德拉
您已经有了理解模型的基线。您已经消化和利用了模型,甚至在叠加中显示了结果。看起来可能无限制。但是,您已经看到模型往往以各种复杂的方式返回信息。对于井字棋模型,您只想要一个移动,但它仍然返回所有九个可能的框,留下了一些清理工作,然后您才能利用模型的输出。随着模型变得更加复杂,这个问题可能会加剧。在本章中,我们将选择一个广泛和复杂的模型类型进行对象检测,并通过 UI 和概念来全面了解可能遇到的任务。
让我们回顾一下您当前的工作流程。首先,您选择一个模型。确定它是一个 Layers 模型还是 Graph 模型。即使您没有这些信息,您也可以通过尝试以某种方式加载它来弄清楚。
接下来,您需要确定模型的输入和输出,不仅是形状,还有数据实际代表的内容。您批处理数据,对模型调用predict,然后输出就可以了,对吗?
不幸的是,您还应该知道一些其他内容。一些最新和最伟大的模型与您所期望的有显著差异。在许多方面,它们要优越得多,但在其他方面,它们更加繁琐。不要担心,因为您已经在上一章中建立了张量和画布叠加的坚实基础。通过一点点指导,您可以处理这个新的高级模型世界。
我们将:
-
深入了解理论如何挑战您的张量技能
-
了解高级模型特性
-
学习许多新的图像和机器学习术语
-
确定绘制多个框以进行对象检测的最佳方法
-
学习如何在画布上为检测绘制标签
当您完成本章时,您将对实现高级 TensorFlow.js 模型的理论要求有深刻的理解。本章作为对您今天可以使用的最强大模型之一的认知演练,伴随着大量的学习。这不会很难,但请做好学习准备,并不要回避复杂性。如果您遵循本章中解释的逻辑,您将对机器学习的核心理论和实践有深刻的理解和掌握。
再谈 MobileNet
当您在TFHub.dev上浏览时,您可能已经看到我们的老朋友 MobileNet 以许多不同的风格和版本被提及。一个版本有一个简单的名字,ssd_mobilenet_v2,用于图像对象检测(请参见图 6-1 中的突出显示部分)。
多么令人兴奋!看起来您可以从之前的 TensorFlow Hub 示例中获取代码,并将模型更改为查看一组边界框及其相关类,对吗?
![MobileNet SSD on TFHub]()
图 6-1. 用于对象检测的 MobileNet
这样做后,您立即收到一个失败消息,要求您使用model.executeAsync而不是model.predict(请参见图 6-2)。
![MobileNet Predict Error]()
图 6-2. 预测不起作用
那么出了什么问题?到目前为止,您可能有一大堆问题。
警告
在 Parcel 中,您可能会收到关于regeneratorRuntime未定义的错误。这是由于 Babel polyfill 中的弃用。如果您遇到此错误,您可以添加core-js和regenerator-runtime包并在主文件中导入它们。如果您遇到此问题,请参见本章的相关GitHub 代码。
这是一个需要更多信息、理论和历史来理解的高级模型的完美例子。现在也是学习一些我们为方便起见而保留的概念的好时机。通过本章的学习,您将准备好处理一些新术语、最佳实践和复杂模型的特性。
SSD MobileNet
到目前为止,书中已经提到了两种模型的名称,但没有详细说明。MobileNet 和 Inception 是由谷歌 AI 团队创建的已发布的模型架构。在下一章中,您将设计自己的模型,但可以肯定地说,它们不会像这两个知名模型那样先进。每个模型都有一组特定的优点和缺点。准确性并不总是模型的唯一度量标准。
MobileNet 是一种用于低延迟、低功耗模型的特定架构。这使得它非常适合设备和网络。尽管基于 Inception 的模型更准确,但 MobileNet 的速度和尺寸使其成为边缘设备上分类和对象检测的标准工具。
查看由谷歌发布的性能和延迟图表,比较设备上的模型版本。您可以看到,尽管 Inception v2 的大小是 MobileNetV2 的几倍,需要更多计算才能进行单个预测,但 MobileNetV2 速度更快,虽然准确性不如 Inception,但仍然接近。MobileNetV3 甚至有望在尺寸略微增加的情况下更准确。这些模型的核心研究和进展使它们成为经过良好测试的资源,具有已知的权衡。正是因为这些原因,您会看到相同的模型架构在新问题中反复使用。
前面提到的这两种架构都是由谷歌用数百万张图片进行训练的。MobileNet 和 Inception 可以识别的经典 1,001 类来自一个名为ImageNet的知名数据集。因此,在云中的许多计算机上进行长时间训练后,这些模型被调整为立即使用。虽然这些模型是分类模型,但它们也可以被重新用于检测对象。
就像建筑物一样,模型可以稍作修改以处理不同的目标。例如,一个剧院可以从最初用于举办现场表演的目的进行修改,以便支持 3D 特色电影。是的,需要进行一些小的更改,但整体架构是可以重复使用的。对于从分类到对象检测重新用途的模型也是如此。
有几种不同的方法可以进行对象检测。一种方法称为基于区域的卷积神经网络(R-CNN)。不要将 R-CNN 与 RNN 混淆,它们是完全不同的,是机器学习中的真实事物。基于区域的卷积神经网络听起来可能像《哈利波特》中的咒语,但实际上只是通过查看图像的补丁来检测对象的一种流行方法,使用滑动窗口(即重复采样图像的较小部分,直到覆盖整个图像)。R-CNN 通常速度较慢,但非常准确。慢速方面与网站和移动设备不兼容。
检测对象的第二种流行方法是使用另一个时髦词汇,“完全卷积”方法(有关卷积的更多信息,请参阅第十章)。这些方法没有深度神经网络,这就是为什么它们避免需要特定的输入尺寸。没错,您不需要为完全卷积方法调整图像大小,而且它们也很快。
这就是 SSD MobileNet 中的“SSD”之所以重要的地方。它代表单次检测器。是的,您和我可能一直在想固态驱动器,但命名事物可能很困难,所以我们将数据科学放过。SSD 模型类型被设计为完全卷积模型,一次性识别图像的特征。这种“单次检测”使 SSD 比 R-CNN 快得多。不深入细节,SSD 模型有两个主要组件,一个骨干模型,它了解如何识别对象,以及一个SSD 头部,用于定位对象。在这种情况下,骨干是快速友好的 MobileNet。
结合 MobileNet 和 SSD 需要一点魔法,称为控制流,它允许您在模型中有条件地运行操作。这就是使predict方法从简单变得需要异步调用executeAsync的原因。当模型实现控制流时,同步的predict方法将无法工作。
条件逻辑通常由本地语言处理,但这会显著减慢速度。虽然大多数 TensorFlow.js 可以通过利用 GPU 或 Web Assembly(WASM)后端进行优化,但 JavaScript 中的条件语句需要卸载优化张量并重新加载它们。SSD MobileNet 模型为您隐藏了这个头疼的问题,只需使用控制流操作的低成本。虽然实现控制流超出了本书的范围,但使用这些高级功能的模型并不是。
由于这个模型的现代性,它不是为处理图像批次而设置的。这意味着输入的唯一限制不是图像大小,而是批量大小。但是,它确实期望一个批量为 1,因此一个 1,024×768 的 RGB 图像将以[1, 768, 1024, 3]的形式输入到该模型中,其中1是批量的堆栈大小,768是图像高度,1024是图像宽度,3是每个像素的 RGB 值。
深入了解您将处理的输入和输出类型非常重要。值得注意的是,模型的输出边界框遵循输入的经典高度和宽度架构,与宠物面部检测器不同。这意味着边界框将是[y1, x1, y2, x2]而不是[x1, y1, x2, y2]。如果不注意到这些小问题,可能会非常令人沮丧。您的边界框看起来会完全错乱。每当您实现一个新模型时,重要的是您从所有可用的文档中验证规范。
在深入代码之前还有一个注意事项。根据我的经验,生产中的目标检测很少用于识别成千上万种不同的类别,就像您在 MobileNet 和 Inception 中看到的那样。这样做有很多很好的理由,因此目标检测通常在少数类别上进行测试和训练。人们用于目标检测训练的一个常见组标记数据是Microsoft Common Objects in Context(COCO)数据集。这个 SSD MobileNet 使用了该数据集来教会模型看到 80 种不同的类别。虽然 80 种类别比 1,001 种可能的类别要少很多,但仍然是一个令人印象深刻的集合。
现在您对 SSD MobileNet 的了解比大多数使用它的人更多。您知道它是一个使用控制流将 MobileNet 速度与 80 个类别的 SSD 结果联系起来的目标检测模型。这些知识将帮助您以后解释模型的结果。
边界框输出
现在您了解了模型,可以获得结果。在这个模型中,executeAsync返回的值是两个张量堆栈的普通 JavaScript 数组。第一个张量堆栈是检测到的内容,第二个张量堆栈是每个检测的边界框堆栈,换句话说,分数和它们的框。
阅读模型输出
你可以通过几行代码查看图像的结果。以下代码就是这样做的,也可以在本章的源代码中找到:
tf.ready().then(() => {
const modelPath =
"https://tfhub.dev/tensorflow/tfjs-model/ssd_mobilenet_v2/1/default/1"; // '// ①
tf.tidy(() => {
tf.loadGraphModel(modelPath, { fromTFHub: true }).then((model) => {
const mysteryImage = document.getElementById("mystery");
const myTensor = tf.browser.fromPixels(mysteryImage);
// SSD Mobilenet batch of 1
const singleBatch = tf.expandDims(myTensor, 0); // ②
model.executeAsync(singleBatch).then((result) => {
console.log("First", result[0].shape); // ③
result[0].print();
console.log("Second", result[1].shape); // ④
result[1].print();
});
});
});
});
①
这是 JavaScript 模型的 TFHub URL。
②
输入在秩上扩展为一个批次,形状为[1, 高度, 宽度, 3]。
③
得到的张量是[1, 1917, 90],其中返回了 1,917 个检测结果,每行中的 90 个概率值加起来为 1。
④
张量的形状为[1, 1917, 4],为 1,917 个检测提供了边界框。
图 6-3 显示了模型的输出。
![控制台中的 SSD MobileNet 输出]()
图 6-3。前一段代码的输出
注
你可能会惊讶地看到 90 个值而不是 80 个可能的类别。仍然只有 80 个可能的类别。该模型中的十个结果索引未被使用。
虽然看起来你已经完成了,但还有一些警告信号。正如你可能想象的那样,绘制 1,917 个边界框不会有用或有效,但是尝试一下看看。
显示所有输出
是时候编写代码来绘制多个边界框了。直觉告诉我们,1,917 个检测结果太多了。现在是时候编写一些代码来验证一下了。由于代码变得有点过于依赖 promise,现在是时候切换到 async/await 了。这将阻止代码进一步缩进,并提高可读性。如果你不熟悉在 promise 和 async/await 之间切换,请查看 JavaScript 的相关部分。
绘制模型检测的完整代码可以在书籍源代码文件too_many.html中找到。这段代码使用了与上一章节中对象定位部分描述的相同技术,但参数顺序已调整以适应模型的预期输出。
const results = await model.executeAsync(readyfied);
const boxes = await results[1].squeeze().array();
// Prep Canvas
const detection = document.getElementById("detection");
const ctx = detection.getContext("2d");
const imgWidth = mysteryImage.width;
const imgHeight = mysteryImage.height;
detection.width = imgWidth;
detection.height = imgHeight;
boxes.forEach((box, idx) => {
ctx.strokeStyle = "#0F0";
ctx.lineWidth = 1;
const startY = box[0] * imgHeight;
const startX = box[1] * imgWidth;
const height = (box[2] - box[0]) * imgHeight;
const width = (box[3] - box[1]) * imgWidth;
ctx.strokeRect(startX, startY, width, height);
});
无论模型的置信度如何,绘制每个检测并不困难,但结果输出完全无法使用,如图 6-4 所示。
![检测结果过多]()
图 6-4。1,917 个边界框,使图像无用
在图 6-4 中看到的混乱表明有大量的检测结果,但没有清晰度。你能猜到是什么导致了这种噪音吗?导致你看到的噪音有两个因素。
检测清理
对结果边界框的第一个批评是没有质量或数量检查。代码没有检查检测值的概率或过滤最有信心的值。你可能不知道,模型可能只有 0.001%的确信度,那么微小的检测就不值得绘制边界框。清理的第一步是设置检测分数的最小阈值和最大边界框数量。
其次,在仔细检查后,绘制的边界框似乎一遍又一遍地检测到相同的对象,只是略有变化。稍后将对此进行验证。最好是当它们识别出相同类别时,它们的重叠应该受到限制。如果两个重叠的框都检测到一个人,只保留检测分数最高的那个。
模型在照片中找到了东西(或没有找到),现在轮到你来进行清理了。
质量检查
你需要最高排名的预测。你可以通过抑制低于给定分数的任何边界框来实现这一点。通过一次调用topk来识别整个检测系列中的最高分数,如下所示:
const prominentDetection = tf.topk(results[0]);
// Print it to be sure
prominentDetection.indices.print()
prominentDetection.values.print()
对所有检测结果调用topk将返回一个仅包含最佳结果的数组,因为k默认为1。每个检测的索引对应于类别,值是检测的置信度。输出看起来会像图 6-5。
![整个批次的 Topk 检测日志]()
图 6-5。topk调用适用于整个批次
如果显著检测低于给定阈值,您可以拒绝绘制框。然后,您可以限制绘制的框,仅绘制前 N 个预测。我们将把这个练习的代码留给章节挑战,因为它无法解决第二个问题。仅仅进行质量检查会导致在最强预测周围出现一堆框,而不是单个预测。结果框看起来就像您的检测系统喝了太多咖啡(参见图 6-6)。
![框重叠]()
图 6-6。绘制前 20 个预测会产生模糊的边框
幸运的是,有一种内置的方法来解决这些模糊的框,并为您的晚餐派对提供一些新术语。
IoUs 和 NMS
直到现在,您可能认为 IoUs 只是由 Lloyd Christmas 支持的一种获得批准的法定货币,但在目标检测和训练领域,它们代表交集与并集。交集与并集是用于识别对象检测器准确性和重叠的评估指标。准确性部分对于训练非常重要,而重叠部分对于清理重叠输出非常重要。
IoU 是用于确定两个框在重叠中共享多少面积的公式。如果框完全重叠,IoU 为 1,而它们的适合程度越低,数字就越接近零。标题“IoU”来自于这个计算公式。框的交集面积除以框的并集面积,如图 6-7 所示。
![IoU 图]()
图 6-7。交集与并集
现在您有一个快速的公式来检查边界框的相似性。使用 IoU 公式,您可以执行一种称为非极大值抑制(NMS)的算法来消除重复。NMS 会自动获取得分最高的框,并拒绝任何 IoU 超过指定水平的相似框。图 6-8 展示了一个包含三个得分框的简单示例。
![非极大值抑制图]()
图 6-8。只有最大值存活;其他得分较低的框被移除
如果将 NMS 的 IoU 设置为 0.5,则任何与得分更高的框共享 50%面积的框将被删除。这对于消除与同一对象重叠的框非常有效。但是,对于彼此重叠并应该有两个边界框的两个对象可能会出现问题。这对于具有不幸角度的真实对象是一个问题,因为它们的边界框将相互抵消,您只会得到两个实际对象的一个检测。对于这种情况,您可以启用一个名为Soft-NMS的 NMS 的高级版本,它将降低重叠框的分数而不是删除它们。如果它们在被降低后的分数仍然足够高,检测结果将存活并获得自己的边界框,即使 IoU 非常高。图 6-9 使用 Soft-NMS 正确识别了与极高交集的两个对象。
![Soft-NMS 示例]()
图 6-9。即使是真实世界中重叠的对象也可以使用 Soft-NMS 进行检测
Soft-NMS 最好的部分是它内置在 TensorFlow.js 中。我建议您为所有目标检测需求使用这个 TensorFlow.js 函数。在这个练习中,您将使用内置的方法,名为tf.image.nonMaxSuppressionWithScoreAsync。TensorFlow.js 内置了许多 NMS 算法,但tf.image.nonMaxSuppressionWithScoreAsync具有两个优点,使其非常适合使用:
-
WithScore提供 Soft-NMS 支持。
-
Async可以阻止 GPU 锁定 UI 线程。
在使用非异步高级方法时要小心,因为它们可能会锁定整个 UI。如果出于任何原因想要移除 Soft-NMS 方面,可以将最后一个参数(Soft-NMS Sigma)设置为零,然后您就得到了传统的 NMS。
const nmsDetections = await tf.image.nonMaxSuppressionWithScoreAsync(
justBoxes, // shape [numBoxes, 4]
justValues, // shape [numBoxes]
maxBoxes, // Stop making boxes when this number is hit
iouThreshold, // Allowed overlap value 0 to 1
detectionThreshold, // Minimum detection score allowed
1 // 0 is normal NMS, 1 is max Soft-NMS
);
只需几行代码,您就可以将 SSD 结果澄清为几个清晰的检测结果。
结果将是一个具有两个属性的对象。selectedIndices属性将是一个张量,其中包含通过筛选的框的索引,selectedScores将是它们对应的分数。您可以循环遍历所选结果并绘制边界框。
const chosen = await nmsDetections.selectedIndices.data(); // ①
chosen.forEach((detection) => {
ctx.strokeStyle = "#0F0";
ctx.lineWidth = 4;
const detectedIndex = maxIndices[detection]; // ②
const detectedClass = CLASSES[detectedIndex]; // ③
const detectedScore = scores[detection];
const dBox = boxes[detection];
console.log(detectedClass, detectedScore); // ④
// No negative values for start positions
const startY = dBox[0] > 0 ? dBox[0] * imgHeight : 0; // ⑤
const startX = dBox[1] > 0 ? dBox[1] * imgWidth : 0;
const height = (dBox[2] - dBox[0]) * imgHeight;
const width = (dBox[3] - dBox[1]) * imgWidth;
ctx.strokeRect(startX, startY, width, height);
});
①
从结果中高得分的框的索引创建一个普通的 JavaScript 数组。
②
从先前的topk调用中获取最高得分的索引。
③
将类别作为数组导入以匹配给定的结果索引。这种结构就像上一章中 Inception 示例中的代码一样。
④
记录在画布中被框定的内容,以便验证结果。
⑤
禁止负数,以便框至少从帧开始。否则,一些框将从左上角被切断。
返回的检测数量各不相同,但受限于 NMS 中设置的规格。示例代码导致了五个正确的检测结果,如图 6-10 所示。
![非最大抑制结果]()
图 6-10。干净的 Soft-NMS 检测结果
循环中的控制台日志打印出五个检测结果分别为三个“人”检测、一个“酒杯”和一个“餐桌”。将图 6-11 中的五个日志与图 6-10 中的五个边界框进行比较。
![非最大抑制结果日志]()
图 6-11。结果日志类别和置信水平
UI 已经取得了很大进展。覆盖层应该能够识别检测和它们的置信度百分比是合理的。普通用户不知道要查看控制台以查看日志。
添加文本覆盖
您可以以各种花式方式向画布添加文本,并使其识别相关的边界框。在此演示中,我们将回顾最简单的方法,并将更美观的布局留给读者作为任务。
可以使用画布 2D 上下文的fillText方法向画布绘制文本。您可以通过重复使用绘制框时使用的X, Y坐标将文本定位在每个框的左上角。
有两个绘制文本时需要注意的问题:
幸运的是,这两个问题都很容易解决。
解决低对比度
创建可读标签的典型方法是绘制一个背景框,然后放置文本。正如您所知,strokeRect创建一个没有填充颜色的框,所以不应该感到意外的是fillRect绘制一个带有填充颜色的框。
矩形应该有多大?一个简单的答案是将矩形绘制到检测框的宽度,但不能保证框足够宽,当框非常宽时,这会在结果中创建大的阻挡条。唯一有效的解决方案是测量文本并相应地绘制框。文本高度可以通过利用上下文font属性来设置,宽度可以通过measureText确定。
最后,您可能需要考虑从绘图位置减去字体高度,以便将文本绘制在框内而不是在框上方,但上下文已经有一个属性可以设置以保持简单。context.textBaseline属性有各种选项。图 6-12 显示了每个可能属性选项的起始点。
![文本基线选项]()
图 6-12。将textBaseline设置为top可以保持文本在 X 和 Y 坐标内
现在你知道如何绘制一个填充矩形到适当的大小并将标签放在内部。您可以将这些方法结合在您的forEach循环中,您在其中绘制检测结果。标签绘制在每个检测的左上角,如图 6-13 所示。
![显示绘制结果]()
图 6-13。标签与每个框一起绘制
重要的是文本在背景框之后绘制,否则框将覆盖文本。对于我们的目的,标签将使用略有不同颜色的绿色绘制,而不是边界框。
// Draw the label background.
ctx.fillStyle = "#0B0";
ctx.font = "16px sans-serif"; // ①
ctx.textBaseline = "top"; // ②
const textHeight = 16;
const textPad = 4; // ③
const label = `${detectedClass} ${Math.round(detectedScore * 100)}%`;
const textWidth = ctx.measureText(label).width;
ctx.fillRect( // ④
startX,
startY,
textWidth + textPad,
textHeight + textPad
);
// Draw the text last to ensure it's on top.
ctx.fillStyle = "#000000"; // ⑤
ctx.fillText(label, startX, startY); // ⑥
①
设置标签使用的字体和大小。
②
设置textBaseline如上所述。
③
添加一点水平填充以在fillRect渲染中使用。
④
使用相同的startX和startY绘制矩形,这与绘制边界框时使用的相同。
⑤
将fillStyle更改为黑色以进行文本渲染。
⑥
最后,绘制文本。这可能也应该略微填充。
现在每个检测都有一个几乎可读的标签。但是,根据您的图像,您可能已经注意到了一些问题,我们现在将解决。
解决绘制顺序
尽管标签是绘制在框的上方,但框是在不同的时间绘制的,可以轻松重叠一些现有标签文本,使它们难以阅读甚至不可能阅读。如您在图 6-14 中所见,由于重叠检测,餐桌百分比很难阅读。
![上下文重叠问题]()
图 6-14。上下文绘制顺序重叠问题
解决这个问题的一种方法是遍历检测结果并绘制框,然后再进行第二次遍历并绘制文本。这将确保文本最后绘制,但代价是需要在两个连续循环中遍历检测结果。
作为替代方案,您可以使用代码处理这个问题。您可以设置上下文globalCompositeOperation来执行各种令人惊奇的操作。一个简单的操作是告诉上下文在现有内容的上方或下方渲染,有效地设置 z 顺序。
strokeRect调用可以设置为globalCompositeOperation为destination-over。这意味着任何存在于目标中的像素将获胜并放置在添加的内容上方。这有效地在任何现有内容下绘制。
然后,在绘制标签时,将globalCompositionOperation返回到其默认行为,即source-over。这会将新的源像素绘制在任何现有绘图上。如果在这两种操作之间来回切换,您可以确保您的标签是最优先的,并在主循环内处理所有内容。
总的来说,绘制边界框、标签框和标签的单个循环如下所示:
chosen.forEach((detection) => {
ctx.strokeStyle = "#0F0";
ctx.lineWidth = 4;
ctx.globalCompositeOperation='destination-over'; // ①
const detectedIndex = maxIndices[detection];
const detectedClass = CLASSES[detectedIndex];
const detectedScore = scores[detection];
const dBox = boxes[detection];
// No negative values for start positions
const startY = dBox[0] > 0 ? dBox[0] * imgHeight : 0;
const startX = dBox[1] > 0 ? dBox[1] * imgWidth : 0;
const height = (dBox[2] - dBox[0]) * imgHeight;
const width = (dBox[3] - dBox[1]) * imgWidth;
ctx.strokeRect(startX, startY, width, height);
// Draw the label background.
ctx.globalCompositeOperation='source-over'; // ②
ctx.fillStyle = "#0B0";
const textHeight = 16;
const textPad = 4;
const label = `${detectedClass} ${Math.round(detectedScore * 100)}%`;
const textWidth = ctx.measureText(label).width;
ctx.fillRect(
startX,
startY,
textWidth + textPad,
textHeight + textPad
);
// Draw the text last to ensure it's on top.
ctx.fillStyle = "#000000";
ctx.fillText(label, startX, startY);
});
①
在任何现有内容下绘制。
②
在任何现有内容上绘制。
结果是一个动态的人类可读结果,您可以与您的朋友分享(参见图 6-15)。
![完全工作的目标检测]()
图 6-15。使用destination-over修复重叠问题
连接到网络摄像头
所有这些速度的好处是什么?正如前面提到的,选择 SSD 而不是 R-CNN,选择 MobileNet 而不是 Inception,以及一次绘制画布而不是两次。当你加载页面时,它看起来相当慢。似乎至少需要四秒才能加载和渲染。
是的,把一切都放在适当的位置需要一点时间,但在内存分配完毕并且模型下载完成后,你会看到一些相当显著的速度。是的,足以在你的网络摄像头上运行实时检测。
加快流程的关键是运行设置代码一次,然后继续运行检测循环。这意味着你需要将这节课的庞大代码库分解;否则,你将得到一个无法使用的界面。为简单起见,你可以按照示例 6-1 中所示分解项目。
示例 6-1。分解代码库
async function doStuff() {
try {
const model = await loadModel() // ①
const mysteryVideo = document.getElementById('mystery') // ②
const camDetails = await setupWebcam(mysteryVideo) // ③
performDetections(model, mysteryVideo, camDetails) // ④
} catch (e) {
console.error(e) // ⑤
}
}
①
加载模型时最长的延迟应该首先发生,且仅发生一次。
②
为了效率,你可以一次捕获视频元素,并将该引用传递到需要的地方。
③
设置网络摄像头应该只发生一次。
④
performDetections方法可以在检测网络摄像头中的内容并绘制框时无限循环。
⑤
不要让所有这些awaits吞没错误。
从图像到视频的转换
从静态图像转换为视频实际上并不复杂,因为将所见内容转换为张量的困难部分由tf.fromPixels处理。tf.fromPixels方法可以读取画布、图像,甚至视频元素。因此,复杂性在于将img标签更改为video标签。
你可以通过更换标签来开始。原始的img标签:
<img id="mystery" src="/dinner.jpg" height="100%" />
变成以下内容:
<video id="mystery" height="100%" autoplay></video>
值得注意的是,视频元素的宽度/高度属性稍微复杂,因为有输入视频的宽度/高度和实际客户端的宽度/高度。因此,所有使用width的计算都需要使用clientWidth,同样,height需要使用clientHeight。如果使用错误的属性,框将不对齐,甚至可能根本不显示。
激活网络摄像头
为了我们的目的,我们只会设置默认的网络摄像头。这对应于示例 6-1 中的第四点。如果你对getUserMedia不熟悉,请花点时间分析视频元素如何连接到网络摄像头。这也是你可以将画布上下文设置移动到适应视频元素的时间。
async function setupWebcam(videoRef) {
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
const webcamStream = await navigator.mediaDevices.getUserMedia({ // ①
audio: false,
video: {
facingMode: 'user',
},
})
if ('srcObject' in videoRef) { // ②
videoRef.srcObject = webcamStream
} else {
videoRef.src = window.URL.createObjectURL(webcamStream)
}
return new Promise((resolve, _) => { // ③
videoRef.onloadedmetadata = () => { // ④
// Prep Canvas
const detection = document.getElementById('detection')
const ctx = detection.getContext('2d')
const imgWidth = videoRef.clientWidth // ⑤
const imgHeight = videoRef.clientHeight
detection.width = imgWidth
detection.height = imgHeight
ctx.font = '16px sans-serif'
ctx.textBaseline = 'top'
resolve([ctx, imgHeight, imgWidth]) // ⑥
}
})
} else {
alert('No webcam - sorry!')
}
}
①
这些是网络摄像头用户媒体配置约束。这里可以应用几个选项,但为简单起见,保持得很简单。
②
这个条件检查是为了支持不支持新的srcObject配置的旧浏览器。根据你的支持需求,这可能会被弃用。
③
在视频加载完成之前无法访问视频,因此该事件被包装在一个 promise 中,以便等待。
④
这是你需要等待的事件,然后才能将视频元素传递给tf.fromPixels。
⑤
在设置画布时,注意使用clientWidth而不是width。
⑥
promise 解析后,你将需要将信息传递给检测和绘制循环。
绘制检测结果
最后,您执行检测和绘图的方式与对图像执行的方式相同。在每次调用的开始时,您需要删除上一次调用的所有检测;否则,您的画布将慢慢填满旧的检测。清除画布很简单;您可以使用clearRect来删除指定坐标的任何内容。传递整个画布的宽度和高度将擦除所有内容。
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height)
在每次绘制检测结束时,不要在清理中处理模型,因为您需要在每次检测中使用它。然而,其他所有内容都可以和应该被处理。
在示例 6-1 中确定的performDetections函数应该在无限循环中递归调用自身。该函数的循环速度可能比画布绘制速度更快。为了确保不浪费循环,使用浏览器的requestAnimationFrame来限制这一点:
// Loop forever
requestAnimationFrame(() => {
performDetections(model, videoRef, camDetails)
})
就是这样。通过一些逻辑调整,您已经从静态图像转移到了实时速度的视频输入。在我的电脑上,我看到大约每秒 16 帧。在人工智能领域,这已经足够快,可以处理大多数用例。我用它来证明我至少是 97%的人,如图 6-16 所示。
![在浏览器中运行的 SSD MobileNet 的屏幕截图]()
图 6-16。具有 SSD MobileNet 的完全功能网络摄像头
章节回顾
祝贺您挑战了 TensorFlow Hub 上存在的最有用但也最复杂的模型之一。虽然用 JavaScript 隐藏这个模型的复杂性很简单,但您现在熟悉了一些最令人印象深刻的物体检测和澄清概念。机器学习背负着快速解决问题的概念,然后解决后续代码以将 AI 的壮丽属性附加到给定领域。您可以期待任何显著先进模型和领域都需要大量研究。
章节挑战:顶级侦探
NMS 简化了排序和消除检测。假设您想解决识别顶级预测然后将它们从高到低排序的问题,以便您可以创建类似图 6-6 的图形。与其依赖 NMS 来找到您最可行和最高值,您需要自己解决最高值问题。将这个小但类似的分组视为整个检测数据集。想象这个[1, 6, 5]的张量检测集合是您的result[0],您只想要具有最高置信度值的前三个检测。您如何解决这个问题?
const t = tf.tensor([[
[1, 2, 3, 4, 5],
[1.1, 2.1, 3.1, 4.1, 5.1],
[1.2, 2.2, 3.2, 4.2, 5.2],
[1.2, 12.2, 3.2, 4.2, 5.2],
[1.3, 2.3, 3.3, 4.3, 5.3],
[1, 1, 1, 1, 1]
]])
// Get the top-three most confident predictions.
您的最终解决方案应该打印[3, 4, 2],因为索引为 3 的张量具有最大值(12.2),其次是索引为 4(包含 5.3),然后是索引为 2(5.2)。
您可以在附录 B 中找到此挑战的答案。
复习问题
让我们回顾您在本章编写的代码中学到的知识。花点时间回答以下问题:
-
在物体检测机器学习领域,SSD 代表什么?
-
您需要使用哪种方法来预测使用动态控制流操作的模型?
-
SSD MobileNet 预测多少类别和多少值?
-
去重相同对象的检测的方法是什么?
-
使用大型同步 TensorFlow.js 调用的缺点是什么?
-
您应该使用什么方法来识别标签的宽度?
-
globalCompositeOperation会覆盖画布上现有的内容吗?
这些问题的解决方案可以在附录 A 中找到。
第七章:模型制作资源
“通过寻找和失误我们学习。”
—约翰·沃尔夫冈·冯·歌德
你不仅限于来自 TensorFlow Hub 的模型。每天都有新的令人兴奋的模型被推文、发表和在社区中受到关注。这些模型和想法在谷歌认可的中心之外分享,有时甚至超出了 TensorFlow.js 的范围。
你开始超越园墙,与野外的模型和数据一起工作。这一章专门旨在为你提供制作现有模型的新方法,并让你面对收集和理解数据的挑战。
我们将:
-
介绍模型转换
-
介绍 Teachable Machine
-
训练一个计算机视觉模型
-
回顾训练数据的来源
-
涵盖一些关键的训练概念
当你完成这一章时,你将掌握几种制作模型的方法,并更好地理解使用数据制作机器学习解决方案的过程。
网络外模型购物
TensorFlow.js 并没有存在很长时间。因此,可用的模型数量有限,或者至少比其他框架少。这并不意味着你没有机会。你通常可以将在其他框架上训练过的模型转换为 TensorFlow.js。将现有模型转换为在新环境中工作的新模型是一种发现最近开发的资源并创建令人兴奋和现代的模型的好方法。
模型动物园
从机器学习世界中出现的一个有点可爱的术语是,有时将一组模型称为动物园。这些模型动物园是各种给定框架的任务的模型宝库,就像 TensorFlow Hub 一样。
模型动物园是一个绝佳的地方,可以找到独特的模型,这些模型可能会激发或满足你的需求。动物园经常链接到已发表的作品,解释了为模型架构和用于创建它们的数据所做的选择。
真正的好处来自于这样一个原则,一旦你学会了如何将这些模型转换为 TensorFlow.js,你可能会转换很多模型。
值得花一点时间回顾转换模型,这样你就能理解每个模型动物园或已发表模型对 TensorFlow.js 可能有多容易访问。
转换模型
许多用 Python 编程的 TensorFlow 模型以一种称为 Keras HDF5 的格式保存。HDF5 代表分层数据格式 v5,但通常被称为 Keras 或仅为 h5 文件。这种文件格式是一个带有 h5 扩展名的文件。Keras 文件格式中包含大量数据:
-
指定模型层的架构
-
一组权重值,类似于 bin 文件
-
模型的优化器和损失指标
这是更受欢迎的模型格式之一,更重要的是,即使它们是用 Python 训练的,它们也很容易转换为 TensorFlow.js。
注意
有了能够转换 TensorFlow Keras 模型的知识,这意味着你找到的任何 TensorFlow 教程都可以作为一个教程来阅读,最终产品很可能可以在 TensorFlow.js 中使用。
运行转换命令
要从 h5 转换为 TensorFlow.js model.json和 bin 文件,你需要tfjs-converter。tfjs-converter还可以转换除 HDF5 之外的其他 TensorFlow 模型类型,因此它是处理任何 TensorFlow 到 TensorFlow.js 格式的绝佳工具。
转换器要求你的计算机已设置 Python。使用pip安装转换器。pip命令是 Python 的软件包安装程序,类似于 JavaScript 中的npm。如果你的计算机还没有准备好,有大量关于安装 Python 和pip的教程。安装了pip和 Python 后,你可以运行tfjs-converter。
这是转换器的安装命令:
$ pip install tensorflowjs[wizard]
这将安装两个东西:一个无废话的转换器,您可以在自动化中使用(tensorflowjs_converter),以及一个通过键入tensorflowjs_wizard来运行的向导转换器。对于我们的目的,我建议使用向导界面进行转换,这样您可以利用新功能。
您可以通过在命令行中调用您新安装的tensorflowjs_wizard命令来运行向导,然后您将被提示类似于您在图 7-1 中看到的问题。
![示例转换向导]()
图 7-1. 向导开始询问问题
这个向导将询问您的输入模型格式和所需的输出模型格式。根据您的答案,它还会询问一些问题。虽然向导将继续更新,但在选择所需设置时,这里有一些概念您应该牢记:
在图/层模型之间进行选择
请记住,图模型更快,但缺少一些内省和自定义属性,这些属性由层模型提供。
压缩(通过量化)
这将使您的模型从存储 32 位精度权重降至 16 位甚至 8 位精度权重值。使用更少的位数意味着您的模型在可能牺牲精度的情况下更小。量化后,您应该重新测试您的模型。大多数情况下,这种压缩对于客户端模型是值得的。
分片大小
建议的分片大小是为了优化您的模型以用于客户端浏览器缓存。除非您不在客户端浏览器中使用该模型,否则应保持推荐的大小。
注意
量化仅影响磁盘上的模型大小。这为网站提供了显著的网络传输优势,但当模型加载到 RAM 中时,值将返回到当前 TensorFlow.js 中的 32 位变量。
功能将继续出现在向导界面中。如果出现了一个让您困惑的新功能,请记住,转换模型的文档将在tfjs-converter README 源代码中提供。您的体验将类似于图 7-2。
![示例转换]()
图 7-2. Windows 上的示例向导演练
生成的文件夹包含一个转换后的 TensorFlow.js 模型,准备就绪。h5 文件现在是model.json,而可缓存的 bin 文件以块形式存在。您可以在图 7-3 中看到转换结果。
![示例转换]()
图 7-3. TensorFlow.js 模型结果
中间模型
如果您找到一个想要转换为 TensorFlow.js 的模型,现在可以检查是否有转换器将该模型移动到 Keras HDF5 格式,然后您就可以将其转换为 TensorFlow.js。值得注意的是,目前正在大力努力将模型转换为和从一个称为开放神经网络交换(ONNX)的格式标准化。目前,微软和许多其他合作伙伴正在努力正确地转换模型进出 ONNX 格式,这将允许一个独立于框架的模型格式。
如果您找到了一个已发布的模型,想要在 TensorFlow.js 中使用,但它并不是在 TensorFlow 中训练的,不要放弃希望。您应该检查该模型类型是否有 ONNX 支持。
有些模型无法直接转换为 TensorFlow,因此您可能需要通过其他转换服务进行更多的迂回路线。除了 TensorFlow 之外,另一个受欢迎的框架库是 PyTorch,大多数机器学习爱好者使用。虽然 ONNX 每天都在变得更接近,但目前从 PyTorch 转换为 TensorFlow.js 的最佳方法是通过一系列工具进行转换,如图 7-4 所示。
![转换模型的流程图]()
图 7-4. 转换模型
虽然对模型进行转换可能看起来是一项艰巨的工作,但将现有格式的模型转换为 TensorFlow.js 可以节省您几天甚至几周的时间,而不必重新创建和重新训练模型以适应已发布的数据。
您的第一个定制模型
如果只需下载现有模型,那么您就完成了。但我们不能等待谷歌发布能够分类我们所需内容的模型。您可能有一个需要 AI 对糕点有深入了解的想法。即使是谷歌的 Inception v3,如果您需要区分单个领域中各种物品之间的区别,可能也不够强大。
幸运的是,有一个技巧可以让我们利用现有模型的成果。一些模型可以稍作调整,以分类新事物!我们不需要重新训练整个模型,只需训练最后几层以寻找不同的特征。这使我们能够将像 Inception 或 MobileNet 这样的高级模型转变为识别我们想要的东西的模型。作为一个额外的好处,这种方法允许我们用极少量的数据重新训练模型。这被称为迁移学习,是在新类别上(重新)训练模型的最常见方法之一。
我们将在第十一章中介绍迁移学习的代码,但现在您也可以体验它。谷歌为人们尝试训练模型构建了一个完整的迁移学习 UI。
见见 Teachable Machine
首先,您将使用谷歌提供的一个名为 Teachable Machine 的工具。这个工具是由 TensorFlow.js 提供支持的简单网站,它允许您上传图像、上传音频,甚至使用网络摄像头进行训练、捕获数据和创建 TensorFlow.js 模型。这些模型直接在您的浏览器中进行训练,然后为您托管,以便您立即尝试您的代码。您得到的模型是 MobileNet、PoseNet 或其他实用模型的迁移学习版本,适合您的需求。由于它使用迁移学习,您根本不需要太多数据。
警告
使用少量数据创建的模型似乎效果神奇,但存在显著的偏见。这意味着它们在训练时表现良好,但在背景、光线或位置变化时会出错。
训练模型的网站位于teachablemachine.withgoogle.com。访问该网站后,您可以开始各种项目,如音频、图像,甚至身体姿势。虽然您可以并且应该尝试每一个,但本书将介绍图像项目选项。这是图 7-5 中显示的第一个选项。
![Teachable Machine UI]()
图 7-5. 令人惊叹的 Teachable Machine 选项
在生成的页面上,您可以选择上传或使用网络摄像头收集每个类别的样本图像。
以下是一些您可以使用的想法,用于创建您的第一个分类器:
-
竖起大拇指还是竖起食指?
-
我在喝水吗?
-
这是哪只猫?
-
秘密手势解锁某物?
-
书还是香蕉!?
发挥您的创造力!您创建的任何模型都可以轻松展示给朋友和社交媒体,或者可以转变为一个帮助您的网页。例如,“我在喝水吗?”分类器可以连接到一个计时器,用于您的自我补水项目。只要您用一些样本训练模型,您可以想出各种有趣的项目。
就我个人而言,我将训练一个“爸爸在工作吗?”分类器。你们中的许多人可能在远程工作环境中遇到了家庭问题。如果我坐在桌子前,门是关着的,你会认为这会告诉别人我在工作,对吧?但如果门是开着的,“请进!”我会让 Teachable Machine 使用我的网络摄像头来分类我在工作时的样子和我不工作时的样子。
很酷的一点是,由于检测器将与网站绑定,“爸爸在工作吗?”可以扩展到做各种令人惊叹的事情。它可以发送短信,打开“不可用”灯,甚至告诉我的亚马逊 Echo 设备在被问及我是否在工作时回答“是”。只要我能制作一个快速可靠的 AI 图像分类器,就有无限的机会。
从头开始训练是一个可扩展的解决方案,但目前的任务是训练我在办公室的存在,为此我们将使用 Teachable Machine。
使用 Teachable Machine
让我们快速浏览一下使用 Teachable Machine 创建模型的用户界面。用户界面设置为网络图,信息从左到右自上而下填写。使用该网站很容易。跟随我们一起查看图 7-6。
![Teachable Machine 用户界面导览]()
图 7-6。图像项目用户界面导览
-
这个上部标题应该很小,在较大的监视器上不会妨碍。从标题中,您可以使用 Google Drive 来管理您的数据和结果,这样您就可以从上次离开的地方继续或与他人分享您的模型训练。
-
顶部项目称为“类别 1”,表示您分类的一个类别。当然,您可以重命名它!我已将我的重命名为“工作”。在这个工作流卡中,您可以提供访问您的网络摄像头或上传符合此分类的图像文件。
-
这个第二个工作流卡是任何第二类。在我的示例中,这可能是“免费”或“不工作”。在这里,您提供适合您的次要分类的数据。
-
所有类别都进入训练工作流程。当您有要构建的示例时,您可以点击“训练模型”按钮并积极训练模型。当我们到达高级选项卡时,我们将深入了解这是如何进行的。
-
预览部分立即显示模型实时分类的结果。
收集数据和训练
您可以按住网络摄像头的“按住录制”按钮,立即提供数百张示例数据的图像。在您的数据集中尽可能评估和包含变化是至关重要的。例如,如果您正在做“竖起大拇指还是竖下大拇指”,重要的是您在屏幕周围移动手部,捕捉不同角度,并将手放在面部、衬衫和任何其他复杂背景前。
对我来说,我调整了我的照明,因为有时我有一个摄像头主灯,有时我有背光。几秒钟内,我有了数百种不同条件的办公室门开着和关着的照片。我甚至拍了一些照片,我的门是关着的,但我没有坐在桌子前。
Teachable Machine 的一个很棒的地方是它可以在浏览器中快速给出结果,因此如果模型需要更多数据,您可以随时回来并立即添加更多数据。
一旦您有几百张照片,您可以点击“训练模型”按钮,您将看到一个“训练…”进度图(参见图 7-7)。
![训练截图]()
图 7-7。Teachable Machine 活动训练
那么现在发生了什么?简而言之,Teachable Machine 正在使用您的图像执行迁移学习来重新训练 MobileNet 模型。您的数据中随机选择了 85%用于训练模型,另外 15%用于测试模型的性能。
点击高级选项卡查看此特定配置的详细信息。这将暴露出通常称为超参数的机器学习训练的一些内容(参见图 7-8)。这些超参数是可调整的模型训练参数。
![训练的可调参数]()
图 7-8。Teachable Machine 超参数
在这里,您将看到一些新术语。虽然现在学习这些术语并不是必要的,但您最终需要学习它们,因此我们将快速介绍它们。当您开始编写自己的模型时,每个概念都会出现在第八章中。
纪元
如果您来自编码背景,尤其是 JavaScript 编码,那么纪元是 1970 年 1 月 1 日。这 不是 在这个领域中纪元的含义。在机器学习训练中,一个纪元是对训练数据的完整遍历。在一个纪元结束时,AI 至少已经看到了所有的训练数据一次。五十个纪元意味着模型将不得不看到数据 50 次。一个很好的类比是单词卡。这个数字表示您与模型一起浏览整个单词卡组的次数,以便它学习。
批量大小
模型是以加载到内存中的批次进行训练的。有了几百张照片,您可以轻松处理所有图像,但最好按合理的增量进行批处理。
学习率
学习率影响机器学习模型在每次预测时应该如何调整。您可能会认为更高的学习率总是更好,但您会错。有时,特别是在微调迁移学习模型时,关键在于细节(如第十一章中所述)。
卡片底部还有一个带有“Under the hood”文本的按钮,点击它将为您提供有关训练模型进度的详细信息。随时查看报告。您将在以后实施这些指标。
验证模型
一旦 Teachable Machine 完成,它会立即将模型连接到您的网络摄像头,并显示模型的预测结果。这是一个很好的机会让您测试模型的结果。
对我来说,当我坐在办公桌前,门是关着的时候,模型预测我正在工作。万岁!我有一个可用的模型准备好了。两个类的表现都非常出色,如图 7-9 所示。
![显示模型工作的预览部分]()
图 7-9。模型运行
理想情况下,您的训练进展顺利。现在,检索训练好的模型是至关重要的,这样它就可以在您更广泛的项目中实施。如果您想与朋友分享您的模型,可以在预览中点击“导出模型”按钮,您将获得各种选项。新的模态窗口提供了在 TensorFlow、TensorFlow Lite 和 TensorFlow.js 中应用您的模型的路径。甚至还有一个选项可以免费托管您的训练模型,而不是自己下载和托管模型。我们提供了所有这些友好的选项以及一些巧妙的复制粘贴代码,让您快速实施这些模型。导出代码屏幕应该类似于图 7-10。
![Teachable Machine 导出对话框。]()
图 7-10。Teachable Machine 导出选项
当您的模型被下载或发布时,您的数据不会随之发布。要保存数据集,您需要将项目保存在 Google Drive 中。如果您计划随着时间推进模型或扩大数据集,请记住这一点。识别和处理边缘情况是数据科学过程的一部分。
在 Teachable Machine 的复制粘贴部分提供的免费代码隐藏了名为@teachablemachine/image的 NPM 包中的网络摄像头和张量的细节。虽然这对于不了解网络摄像头和张量的人来说很好,但对于最终产品来说却毫无用处。您从第六章中获得的高级 UI 技能使您的创造潜力远远超过了复制粘贴代码选项。
提示
每个 Teachable Machine 模型都是不同的;您刚刚训练的视觉模型是建立在我们的老朋友 MobileNet 分类器之上的。因此,当您实施模型时,将输入调整为 224 x 224。
你刚刚训练了你的第一个模型。然而,我们尽可能地省略了很多步骤。使用用户界面训练模型将成为机器学习的一个重要部分,它可以帮助每个新手获得一个出色的开始。但像你这样的张量巫师可以训练一个更加动态的模型。你显然希望通过编写一些 JavaScript 命令你的机器。所以让我们开始通过编写一些 JavaScript 来训练一个模型。
机器学习陷阱
在编码时,任何开发人员可能会面临各种问题。尽管编程语言各不相同,但有一组核心的陷阱会延续到每个基础设施。机器学习也不例外。虽然可能会有特定于任何选择的类型和问题的问题,但早期识别这些问题很重要,这样你就可以发现数据驱动算法中最常见的一些复杂问题。
我们现在将快速阐述一些概念,但每个概念在本书的其余部分涉及到工作时都会重新讨论:
让我们回顾一下这些,这样我们就可以在接下来的章节中留意它们。
少量数据
有人给我提出了一个关于机器学习解决方案的绝妙想法,他们有三个标记样本。这个世界上很少有东西能从这么小的训练集中受益。当数据是你训练算法的方式时,你需要相当数量的数据。有多少?没有一个适用于每个问题的答案,但你应该倾向于更多的数据而不是更少。
糟糕的数据
有些人的生活干净、有序,但在现实世界中,数据不会无意中变得如此。如果你的数据缺失、标记错误,或者完全不合理,它可能会在训练中造成问题。很多时候,数据需要被清理,异常值需要被移除。准备好数据只是一个重要且关键的步骤。
数据偏差
你的数据可能被清晰地标记,每个细节都在正确的位置,但它可能缺少使其在实际情况下工作的信息。在某些情况下,这可能会引起严重的道德问题,而在其他情况下,这可能会导致你的模型在各种条件下表现不佳。例如,我之前训练的“爸爸在工作吗?”模型(图 7-9)可能不适用于其他人的办公室配置,因为数据只针对我的办公室。
过拟合
有时模型被训练到只在训练集数据上表现良好。在某些情况下,一个更直接但得分较低的准确性可能更好地适应新数据点。
看看这个分离图在图 7-11 中是如何过拟合数据的?虽然它完美地解决了给定的问题,但当添加新的从未见过的点时,它可能会变得更慢并失败。
![过拟合图]()
图 7-11. 过拟合数据
有时你会听到过拟合被称为高方差,这意味着你在训练数据中的波动会导致模型在新数据上随机失败。
如果你的目标是让你的模型在新的、以前从未见过的数据上工作,过拟合可能是一个真正的问题。幸运的是,我们有测试和验证集来帮助。
欠拟合
如果你的模型没有被充分训练,或者它的结构无法适应数据,解决方案可能会失败,甚至完全偏离任何外推或额外数据。这是过拟合的反面,但在同样的意义上,它会产生一个糟糕的模型。
看看图 7-12 中的分离图是如何欠拟合数据的?
![欠拟合图]()
图 7-12. 欠拟合数据
当模型欠拟合时,就说模型具有高偏差,因为对数据的基本假设实际上是错误的。虽然类似,但不要将这个术语与之前讨论的数据偏差混淆。
数据集购物
现在你明白为什么拥有多样化的数据是至关重要的。虽然 Teachable Machine 的“爸爸在工作吗?”模型对我很有用,但远远不够多样化,无法用于其他办公室。令人高兴的是,机器学习社区最令人印象深刻的一点是大家都愿意分享他们辛苦获得的数据集。
在收集数据之前,研究一下其他人是否已经发布了可用的标记数据是很有帮助的。了解专家机器学习数据集是如何组织的也是有益的。
数据集就像 JavaScript 库:一开始可能看起来很独特,但过一段时间后,你会发现同样的数据集一再被引用。世界各地的大学都有出色的有用数据集目录,甚至谷歌也有一个类似 TensorFlow Hub 的数据集托管服务,但没有一个能与 Kaggle 这个数据集居所相提并论。
Kaggle拥有大量各种类型的数据集。从鸟鸣到 IMDb 评论,你可以使用 Kaggle 提供的各种数据训练各种模型。图 7-13 展示了一个友好且可搜索的数据集界面。
![你的章节挑战]()
图 7-13。Kaggle 提供了超过 60,000 个免费数据集
无论你是在研究用于训练模型的数据,还是在寻找使用机器学习制作新奇事物的想法,Kaggle 都能满足你的需求。
注意
Kaggle 不仅提供数据集,还是一个分享、竞争和赢取奖品的社区。
如果你对 Kaggle 的课外活动不感兴趣,你通常可以使用谷歌的数据集搜索网站,很可能会找到你的 Kaggle 数据集和其他数据集:https://datasetsearch.research.google.com。
流行数据集
虽然数据集的列表每天都在增长,但很长一段时间内可供选择的数据集并不多。已发布的数据集很少,因此一些数据集成为训练示例的基础。其他数据集作为其类别的第一个发布,并不知不觉地成为某种机器学习的品牌大使。就像秘密口令一样,这些流行数据集在演讲和文档中被随意使用。了解一些最常见和著名的数据集是很有益的:
ImageNet
ImageNet 被用来训练一些最流行的计算机视觉模型。这个大型图像数据集一直被学术研究人员用来评估模型。
MNIST
这是一个 28 x 28 灰度手写数字的集合,用于训练一个读取数字的模型。它通常是计算机视觉模型的“Hello World”。其名称来源于其来源,即来自国家标准与技术研究所的修改数据集。
Iris
1936 年,罗纳德·费舍尔发现可以通过三个物理测量来识别鸢尾花的属种。这个数据集是非视觉分类的经典。
波士顿房价
这个数据集包含了中位数房屋价值及其相关属性,用于解决最佳拟合线(线性回归)模型。
泰坦尼克号
这是“不沉”RMS 泰坦尼克号于 1912 年 4 月 15 日沉没的乘客日志。我们将使用这个数据集在第九章中创建一个模型。
葡萄酒质量
对于酿酒师和手工艺者来说,利用机器学习来识别什么使一种美味饮料是令人振奋的。这个数据集包含每种葡萄酒的物理化学性质及其评分。
Pima 印第安人糖尿病患者
有很多健康护理数据集可用。这是一个基于患者病史的小型且易接近的糖尿病数据集。
CIFAR
虽然 ImageNet 是一个金标准,但有点难以接近和复杂。CIFAR 数据集是一个低分辨率且友好的图像集合,用于分类。
亚马逊评论
这是来自 Amazon.com 多年来的产品评论集合。该数据集已被用来训练文本的情感倾向,因为你有用户的评论和他们的评分。与此相近的是 IMDb 评论数据集。
COCO
这是一个大规模的目标检测、分割和字幕数据集。
这 10 个是标准参考数据集的很好起点。机器学习爱好者会在推特、演讲和博客文章中随意引用这些数据集。
章节复习
当然,你没有金星火山的各种照片。你怎么可能有呢?这并不意味着你不能拿一个为此训练过的模型,移植到你的新浏览器游戏中。只需从 Kaggle 下载数据集并上传图片到 Teachable Machine,创建一个体面的“火山还是非火山”天文模型。就像 TensorFlow.js 将你带入机器学习轨道一样,这些现有模型和数据集为你的应用程序掌握打下了基础。
像 Web 开发一样,机器学习包含各种专业化。机器学习依赖于数据、模型、训练和张量等多种技能。
章节挑战:再见 MNIST
现在轮到你将一个模型从 Keras HDF5 转换到 TensorFlow.js 了。在与本书相关的代码中,你会找到一个mnist.h5文件,其中包含了用于识别手写数字的模型。
-
创建一个图形 TensorFlow.js 模型。
-
用uint8量化模型使其变小。
-
使用通配符访问模型中的所有权重。
-
将分片大小设置为 12,000。
-
保存到一个文件夹./minist(min因为它被量化了,明白吗!)。
回答以下问题:
-
生成了多少个二进制文件和组?
-
最终输出大小是多少?
-
如果你使用默认的分片大小,会生成多少个二进制文件?
您可以在附录 B 中找到这个挑战的答案。
复习问题
让我们回顾一下你在本章编写的代码中学到的教训。花点时间回答以下问题:
-
如果你为特定任务提供了大量数据,训练之前你会有哪些担忧和想法?
-
如果一个模型经过训练,准确率达到了 99%,但是当你在现场使用它时,表现很糟糕,你会说发生了什么?
-
谷歌创建的帮助您训练自己模型的网站的名称是什么?
-
使用谷歌网站的缺点是什么?
-
用来训练 MobileNet 和其他流行的机器学习模型的图像数据集是什么?
这些练习的解决方案可以在附录 A 中找到。
第八章:训练模型
“不要求负担更轻,而要求更宽广的肩膀。”
—犹太谚语
尽管令人印象深刻的模型和数据的供应将继续增长并溢出,但你可能希望做的不仅仅是消费 TensorFlow.js 模型。你将想出以前从未做过的想法,那天不会有现成的选择。现在是时候训练你自己的模型了。
是的,这是世界上最优秀的头脑竞争的任务。虽然关于训练模型的数学、策略和方法论可以写成一本书,但核心理解将至关重要。你必须熟悉使用 TensorFlow.js 训练模型的基本概念和好处,以充分利用这个框架。
我们将:
-
用 JavaScript 代码训练你的第一个模型
-
提升对模型架构的理解
-
回顾如何在训练过程中跟踪状态
-
涵盖一些训练的基本概念
当你完成这一章时,你将掌握几种训练模型的方法,并更好地理解使用数据制定机器学习解决方案的过程。
训练 101
现在是时候揭开魔法,用 JavaScript 训练一个模型了。虽然 Teachable Machine 是一个很好的工具,但它有限。要真正赋予机器学习力量,你需要确定你想要解决的问题,然后教会机器找到解决方案的模式。为了做到这一点,我们将通过数据的眼睛看问题。
在写任何代码之前,看看这个信息的例子,看看你能否确定这些数字之间的相关性。你有一个函数f,它接受一个数字并返回一个数字。以下是数据:
-
给定-1,结果为-4。
-
给定 0,结果为-2。
-
给定 1,结果为 0。
-
给定 2,结果为 2。
-
给定 3,结果为 4。
-
给定 4,结果为 6。
你能确定 5 的答案是什么吗?你能推断出 10 的解决方案吗?在继续之前花点时间评估数据。你们中的一些人可能已经找到了解决方案:答案 = 2x - 2。
函数f是一条简单的线,如图 8-1 所示。知道这一点后,你可以快速解出输入为 10 时的结果为 18。
![显示线性方程]()
图 8-1。X = 10 意味着 Y = 18
从给定数据解决这个问题正是机器学习可以做到的。让我们准备并训练一个 TensorFlow.js 模型来解决这个简单的问题。
要应用监督学习,你需要做以下事情:
-
收集你的数据(输入和期望解决方案)。
-
创建和设计模型架构。
-
确定模型应该如何学习和衡量错误。
-
训练模型并确定训练时间。
数据准备
为了准备一台机器,你将编写代码来提供输入张量,即值[-1, 0, 1, 2, 3, 4]及其对应的答案[-4, -2, 0, 2, 4, 6]。问题的索引必须与预期答案的索引匹配,这在思考时是有意义的。因为我们给模型所有值的答案,这就是为什么这是一个监督学习问题。
在这种情况下,训练集有六个例子。机器学习很少会用在这么少的数据上,但问题相对较小且简单。正如你所看到的,没有任何训练数据被保留用于测试模型。幸运的是,你可以尝试这个模型,因为你知道最初用来创建数据的公式。如果你对训练和测试数据集的定义不熟悉,请查看第一章中的“常见 AI/ML 术语”。
设计模型
设计模型的想法可能听起来很繁琐,但诚实的答案是,这是理论、试验和错误的混合。在设计师了解架构的性能之前,模型可能需要经过数小时甚至数周的训练。整个研究领域可能致力于模型设计。您将为本书创建的 Layers 模型将为您提供良好的基础。
设计模型的最简单方法是使用 TensorFlow.js 的 Layers API,这是一个高级 API,允许您按顺序定义每个层。实际上,要启动您的模型,您将从代码tf.sequential();开始。您可能会听到这被称为“Keras API”,因为这种模型定义风格的起源。
您将创建的模型来解决您正在尝试解决的简单问题将只有一个层和一个神经元。当您考虑到一条线的公式时,这是有道理的;这不是一个非常复杂的方程。
注意
当你熟悉密集网络的基本方程时,就会惊讶地发现为什么在这种情况下单个神经元会起作用,因为一条线的公式是 y = mx + b,而人工神经元的公式是 y = Wx + b。
要向模型添加一层,您将使用model.add,然后定义您的层。使用 Layers API,每个添加的层都会自行定义并根据model.add调用的顺序自动连接,就像推送到数组一样。您将在第一层中定义模型的预期输入,并且您添加的最后一层将定义模型的输出(参见示例 8-1)。
示例 8-1。构建一个假设模型
model.add(ALayer)
model.add(BLayer)
model.add(CLayer)
// Currently, model is [ALayer, BLayer, CLayer]
示例 8-1 中的模型将有三层。ALayer将负责识别预期的模型输入和自身。BLayer不需要识别其输入,因为可以推断输入将是ALayer。因此,BLayer只需要定义自身。CLayer将识别自身,并且因为它是最后一个,这将确定模型的输出。
让我们回到您试图编码的模型。当前问题的架构模型目标只有一个具有一个神经元的层。当您编写该单个层时,您将定义您的输入和输出。
// The entire inner workings of the model
model.add(
tf.layers.dense({
inputShape: 1, // one value 1D tensor
units: 1 // one neuron - output tensor
})
);
结果是一个简单的神经网络。在绘制图时,网络有两个节点(参见图 8-2)。
![2 nodes 1 edge nn]()
图 8-2。一个输入和一个输出
通常,层具有更多的人工神经元(图节点),但也更复杂,并具有其他要配置的属性。
识别学习指标
接下来,您需要告诉您的模型如何识别进展以及如何变得更好。这些概念并不陌生;它们在软件中只是看起来有点奇怪。
每当我试图将激光指示器对准某物时,我通常会错过。但是,我可以看到我稍微偏左或偏右,然后进行调整。机器学习也是如此。它可能会随机开始,但算法会自我纠正,并且需要知道您希望它如何做到这一点。最符合我的激光指示器示例的方法将是梯度下降。优化激光指示器的最平滑迭代方法称为随机梯度下降。这就是我们将在这种情况下使用的方法,因为它效果很好,并且对于您下次晚宴听起来相当酷。
至于测量错误,您可能认为简单的“对”和“错”会起作用,但是差几个小数点和错上千有很大的区别。因此,通常依赖损失函数来帮助您确定 AI 预测猜测的错误程度。有很多衡量错误的方法,但在这种情况下,均方误差(MSE)是一个很好的衡量标准。对于那些需要了解数学的人,MSE 是估计值(y)和实际值(带有小帽的 y)之间的平均平方差。如果您熟悉常见的数学符号,可以将其表示如下:
为什么您喜欢这个公式而不是简单的原始答案距离?MSE 中蕴含了一些数学优势,有助于将方差和偏差作为正误差分数进行整合。不深入统计学,它是解决拟合数据线的最常见损失函数之一。
提示
随机梯度下降和均方误差散发着数学起源的气息,对于一个实用的开发人员来说,这些都无法告诉他们的目的。在这种情况下,最好吸收这些术语的含义,如果您感到有冒险精神,可以观看大量视频,详细解释它们。
当您准备告诉模型使用特定的学习指标并且添加完所有层到模型后,这一切都包含在 .compile 调用中。TensorFlow.js 足够聪明,了解梯度下降和均方误差。您可以通过它们的批准字符串等效项来识别它们,而不是编写这些函数:
model.compile({
optimizer: "sgd",
loss: "meanSquaredError"
});
使用框架的一个巨大好处是,随着机器学习世界发明新的优化器如“Adagrad”和“Adamax”,只需在模型架构中简单更改一个字符串¹,就可以尝试并调用它们。将“sgd”切换为“adamax”对于开发人员来说几乎不需要时间,可能会显著提高模型训练时间,而无需阅读有关随机优化的已发表论文。
在不了解函数的具体情况下识别函数,提供了一种苦乐参半的好处,类似于更改文件类型而无需了解每种类型的完整结构。对于每种的优缺点有一点了解是很有帮助的,但您不需要记住规范。在架构时花点时间阅读可用内容是值得的。
不用担心。您会看到相同的名称一遍又一遍地使用,所以很容易掌握它们。
此时,模型已创建。如果要求它预测任何内容,它将失败,因为它没有进行任何训练。架构中的权重完全是随机的,但您可以通过调用 model.summary() 来查看层。输出直接显示在控制台上,看起来有点像 示例 8-2。
示例 8-2. 在 Layers 模型上调用 model.summary() 打印层
_________________________________________________________________
Layer (type) Output shape Param #
=================================================================
dense_Dense6 (Dense) [null,1] 2
=================================================================
Total params: 2
Trainable params: 2
Non-trainable params: 0
_________________________________________________________________
层dense_Dense6是在 TensorFlow.js 后端中引用此层的自动 ID。您的 ID 可能会有所不同。这个模型有两个可训练参数,因为一条线是 y = mx + b,对吧?从视觉上看,这个模型的参数是可训练的。我们将在后面介绍不可训练的参数。这个单层模型已经准备就绪。
训练模型的任务
训练模型的最后一步是将输入组合到架构中,并指定训练的持续时间。如前所述,这通常以 epochs 来衡量,即模型将多少次查看带有正确答案的闪卡,然后完成后停止训练。您应该使用的 epochs 数量取决于问题的规模、模型以及“足够好”的正确性。在某些模型中,再获得半个百分点值得几小时的训练,而在我们的情况下,模型足够准确,可以在几秒内得到正确答案。
训练集是一个具有六个值的 1D 张量。如果 epochs 设置为 1,000,那么模型将有效地训练 6,000 次迭代,这将在任何现代计算机上最多花费几秒钟。将一条线拟合到点的微不足道的问题对于计算机来说非常简单。
将所有内容整合在一起
现在您已经熟悉了高级概念,您可能迫不及待地想用代码解决这个问题。以下是用数据训练模型的代码,然后立即要求模型为值10提供答案,如前所述。
// Inputs
const xs = tf.tensor([-1, 0, 1, 2, 3, 4]); // ①
// Answers we want from inputs
const ys = tf.tensor([-4, -2, 0, 2, 4, 6]);
// Create a model
const model = tf.sequential(); // ②
model.add( // ③
tf.layers.dense({
inputShape: 1,
units: 1
})
);
model.compile({ // ④
optimizer: "sgd",
loss: "meanSquaredError"
});
// Print out the model structure
model.summary();
// Train
model.fit(xs, ys, { epochs: 300 }).then(history => { // ⑤
const inputTensor = tf.tensor([10]);
const answer = model.predict(inputTensor); // ⑥
console.log(`10 results in ${Math.round(answer.dataSync())}`);
// cleanup
tf.dispose([xs, ys, model, answer, inputTensor]); // ⑦
});
①
数据准备在具有输入和期望输出的张量中完成。
②
开始一个顺序模型。
③
添加唯一的具有一个输入和一个输出的层,如前所述。
④
使用给定的优化器和损失函数完成顺序模型。
⑤
模型被告知用fit进行 300 个 epochs 的训练。这是一个微不足道的时间量,当fit完成时,它返回的承诺会被解决。
⑥
要求训练模型为输入张量10提供答案。您需要四舍五入答案以获得整数结果。
⑦
在获得答案后立即处理所有内容。
恭喜!您已经用代码从头开始训练了一个模型。您刚刚解决的问题被称为线性回归问题。它有各种用途,是预测房价、消费者行为、销售预测等的常用工具。一般来说,在现实世界中,点并不完全落在一条直线上,但现在您有能力将分散的线性数据转化为预测模型。因此,当您的数据看起来像图 8-3 时,您可以像图 8-4 中所示解决问题。
![图上的分散线性数据]()
图 8-3. 分散的线性数据
![预测最佳拟合线的图表]()
图 8-4. 使用 TensorFlow.js 预测最佳拟合线
现在您已经熟悉了训练的基础知识,您可以扩展您的流程,了解解决更复杂模型所需的步骤。训练模型在很大程度上取决于架构以及数据的质量和数量。
非线性训练 101
如果每个问题都基于线性,那么就不需要机器学习了。统计学家从 19 世纪初就开始解决线性回归问题。不幸的是,一旦您的数据是非线性的,这种方法就会失败。如果您让 AI 解决 Y = X²会发生什么?
![平方函数]()
图 8-5. 简单的 Y = X²
更复杂的问题需要更复杂的模型架构。在本节中,您将学习基于层的模型的新属性和特性,以及处理数据的非线性分组。
您可以向神经网络添加更多节点,但它们仍然存在于线性函数的分组中。为了打破线性,现在是时候添加激活函数了。
激活函数类似于大脑中的神经元。是的,这个比喻又来了。当一个神经元在电化学上接收到信号时,并不总是激活。在神经元发射动作电位之前,需要一个阈值。同样地,神经网络具有一定程度的偏见和类似于开关的动作电位,当它们达到由于传入信号而引起的阈值时发生(类似于去极化电流)。简而言之,激活函数使神经网络能够进行非线性预测。²
注意
如果您知道您的解决方案需要是二次的,那么有更聪明的方法来解决二次函数。在本节中,您将为 X²解决问题,这是专门为了更多地了解 TensorFlow.js 而编排的,而不是为了解决简单的数学函数。
是的,这个练习可以很容易地在不使用 AI 的情况下解决,但那样有什么乐趣呢?
收集数据
指数函数可能返回一些非常大的数字,加快模型训练速度的一个技巧是保持数字及其之间的距离较小。您会一次又一次地看到这一点。对于我们的目的,模型的训练数据将是 0 到 10 之间的数字。
const jsxs = [];
const jsys = [];
const dataSize = 10;
const stepSize = 0.001;
for (let i = 0; i < dataSize; i = i + stepSize) {
jsxs.push(i);
jsys.push(i * i);
}
// Inputs
const xs = tf.tensor(jsxs);
// Answers we want from inputs
const ys = tf.tensor(jsys);
这段代码准备了两个张量。xs张量是 10,000 个值的分组,ys是这些值的平方。
向神经元添加激活
为给定层中的神经元选择激活函数以及您的模型大小本身就是一门科学。这取决于您的目标、您的数据和您的知识。就像编码一样,您可以提出几种几乎同样有效的解决方案。经验和实践将帮助您找到适合的解决方案。
在添加激活时,重要的是要注意 TensorFlow.js 中内置了许多激活函数。其中最流行的激活函数之一被称为 ReLU,代表修正线性单元。正如您可能从名称中推断出的那样,它来自科学术语的核心,而不是机智的 NPM 软件包名称。有各种文献讨论了在某些模型中使用 ReLU 相对于其他激活函数的好处。您必须知道 ReLU 是激活函数的一个流行选择,开始使用它应该没问题。随着您对模型架构的了解越来越多,您应该随意尝试其他激活函数。与许多其他选择相比,ReLU 有助于模型更快地训练。
在上一个模型中,您只有一个节点和一个输出。现在增加网络的大小变得重要。没有一个固定的大小公式可供使用,因此每个问题的第一阶段通常需要一些试验。为了我们的目的,我们将增加一个包含 20 个神经元的密集层。密集层意味着该层中的每个节点都连接到其之前和之后的每个节点。生成的模型看起来像图 8-6。
![当前神经网络形状和层]()
图 8-6. 神经网络架构(20 个神经元)
从左到右浏览图 8-6 中显示的架构,一个数字进入网络,20 个神经元的层被称为隐藏层,最终值输出在最后一层。隐藏层是输入和输出之间的层。这些隐藏层添加了可训练的神经元,并使模型能够处理更复杂的模式。
要添加这一层并为其提供激活函数,您将在序列中指定一个新的密集层:
model.add(
tf.layers.dense({
inputShape: 1, // ①
units: 20, // ②
activation: "relu" // ③
})
);
model.add(
tf.layers.dense({
units: 1 // ④
})
);
①
第一层将输入张量定义为一个单一数字。
②
指定层应该有 20 个节点。
③
为您的层指定一个花哨的激活函数。
④
添加最终的单单元层以获取输出值。
如果您编译模型并打印摘要,您将看到类似于示例 8-3 的输出。
示例 8-3。调用model.summary()以获取当前结构
_________________________________________________________________
Layer (type) Output shape Param #
=================================================================
dense_Dense1 (Dense) [null,20] 40
_________________________________________________________________
dense_Dense2 (Dense) [null,1] 21
=================================================================
Total params: 61
Trainable params: 61
Non-trainable params: 0
_________________________________________________________________
此模型架构有两个与先前层创建代码匹配的层。null部分代表批量大小,由于它可以是任何数字,因此留空。例如,第一层表示为[null,20],因此四个值的批次将为模型提供输入[4, 20]。
您会注意到模型共有 61 个可调参数。如果您查看图 8-6 中的图表,您可以绘制线条和节点以获取参数。第一层有 20 个节点和 20 条线连接到它们,这就是为什么它有 40 个参数。第二层有 20 条线都连接到一个单个节点,这就是为什么只有 21 个参数。您的模型已准备好训练,但这次要大得多。
如果您进行这些更改并开始训练,您可能会听到您的 CPU/GPU 风扇启动并看到一堆无用的东西。听起来计算机可能正在训练,但肯定很好看到某种进展。
观看训练
TensorFlow.js 拥有各种令人惊奇的工具,可帮助您识别训练进度。特别是,fit配置的一个属性称为callbacks。在callbacks对象内部,您可以连接到训练模型的某些生命周期,并运行任何您想要的代码。
由于您已经熟悉一个 epoch(对训练数据的完整运行),这是您在本例中将使用的时刻。这是一个简洁但有效的获取某种控制台消息的方法。
const printCallback = { // ①
onEpochEnd: (epoch, log) => { // ②
console.log(epoch, log); // ③
}
};
①
创建包含您想要连接的所有生命周期方法的回调对象。
②
onEpochEnd是训练支持的许多已识别的生命周期回调之一。其他枚举在框架的fit部分文档中。
③
打印审查的值。通常,您会对这些信息进行更深入的处理。
注意
通过在fit配置中设置stepsPerEpoch数字,可以重新定义一个 epoch。使用此变量,一个 epoch 可以成为任何数量的训练数据。默认情况下,这设置为null,因此一个 epoch 设置为您的训练集中唯一样本的数量除以批量大小。
剩下要做的就是将您的对象传递给模型的fit配置,同时传递您的 epochs,您应该在模型训练时看到日志。
await model.fit(xs, ys, {
epochs: 100,
callbacks: printCallback
});
onEpochEnd回调会打印到您的控制台,显示训练正在进行。在图 8-7 中,您可以看到您的 epoch 和日志对象。
![训练进度日志]()
图 8-7。epochs 19 到 26 的onEpochEnd日志
能够看到模型实际上正在训练,甚至能够知道它所处的 epoch,这真是一种清新的感觉。但是,日志值是怎么回事?
模型日志
模型通过损失函数告知如何定义损失。您希望在每个 epoch 中看到的是损失下降。损失不仅仅是“对还是错?”它是关于模型有多错,以便它可以学习。每个 epoch 之后,模型都会报告损失,在一个良好的模型架构中,这个数字会迅速下降。
您可能对查看准确性感兴趣。大多数情况下,准确性是一个很好的指标,我们可以在日志中启用准确性。但是,对于这样的模型,准确性并不是一个很好的指标。例如,如果您问模型预测[7]的输出应该是多少,而模型回答49.0676842而不是49,那么它的准确性为零,因为它是错误的。虽然接近的结果在四舍五入后会有较低的损失并且准确,但从技术上讲,它是错误的,模型的准确性评分会很差。让我们在它更有效时再启用准确性。
改进训练
损失值相当高。什么是高损失值?具体而言,这取决于问题。但是,当您看到错误值为 800+时,通常可以说训练尚未完成。
Adam 优化器
幸运的是,您不必让计算机训练几周。目前,优化器设置为随机梯度下降(sgd)的默认值。您可以修改sgd预设,甚至选择不同的优化器。最受欢迎的优化器之一称为 Adam。如果您有兴趣尝试 Adam,您不必阅读2015 年发表的 Adam 论文;您只需将sgd的值更改为adam,然后您就可以开始了。这是您可以享受框架优势的地方。只需更改一个小字符串,整个模型架构就已更改。Adam 对解决某些类型的问题具有显著的好处。
更新后的编译代码如下:
model.compile({
optimizer: "adam",
loss: "meanSquaredError"
});
使用新的优化器,损失在几个时期内降至 800 以下,甚至降至 1 以下,如您在图 8-8 中所见。
![训练进度日志]()
图 8-8。时期 19 到 26 的onEpochEnd日志
经过 100 个时期,模型对我来说仍在取得进展,但在损失值为0.03833026438951492时停止。每次运行都会有所不同,但只要损失很小,模型就会正常工作。
提示
修改和调整模型架构以便为特定问题更快地训练或收敛是经验和实验的结合。
情况看起来不错,但还有一个功能我们应该添加,有时可以显著缩短训练时间。在一台相当不错的机器上,这 100 个时期大约需要 100 秒才能运行。您可以通过一行批处理数据来加快训练速度。当您将batchSize属性分配给fit配置时,训练速度会大大加快。尝试在 fit 调用中添加批处理大小:
await model.fit(xs, ys, {
epochs: 100,
callbacks: printCallback,
batchSize: 64 // ①
});
①
对于我的机器,64 的batchSize将训练时间从 100 秒减少到 50 秒。
注意
批处理大小是效率和内存之间的权衡。如果批处理太大,这将限制能够运行训练的机器。
您有一个在合理时间内训练的模型,几乎没有额外的成本。但是,增加批处理大小是一个您可以并且应该审查的选项。
更多节点和层
整个时间内,模型的形状和大小一直是相同的:一个包含 20 个节点的“隐藏”层。不要忘记,您可以随时添加更多层。作为一个实验,添加另一个包含 20 个节点的层,这样您的模型架构看起来像图 8-9。
![当前神经网络形状和层]()
图 8-9。神经网络架构(20×20 隐藏节点)
使用 Layers 模型架构,您可以通过添加一个新层来构建这个模型。请参阅以下代码:
model.add(
tf.layers.dense({
inputShape: 1,
units: 20,
activation: "relu"
})
);
model.add(
tf.layers.dense({
units: 20,
activation: "relu"
})
);
model.add(
tf.layers.dense({
units: 1
})
);
结果模型训练速度较慢,这是有道理的,但也收敛速度更快,这也是有道理的。这个更大的模型在 20 秒的训练时间内只需 30 个时期就为输入[7]生成了正确的值。
将所有内容放在一起,您的结果代码执行以下操作:
-
创建一个重要的数据集
-
创建几个深度连接的层,使用 ReLU 激活
-
将模型设置为使用先进的 Adam 优化
-
使用 64 块数据训练模型,并在途中打印进度
从头到尾的整个源代码如下:
const jsxs = [];((("improving training", "adding more neurons and layers")))
const jsys = [];
// Create the dataset
const dataSize = 10;
const stepSize = 0.001;
for (let i = 0; i < dataSize; i = i + stepSize) {
jsxs.push(i);
jsys.push(i * i);
}
// Inputs
const xs = tf.tensor(jsxs);
// Answers we want from inputs
const ys = tf.tensor(jsys);
// Print the progress on each epoch
const printCallback = {
onEpochEnd: (epoch, log) => {
console.log(epoch, log);
}
};
// Create the model
const model = tf.sequential();
model.add(
tf.layers.dense({
inputShape: 1,
units: 20,
activation: "relu"
})
);
model.add(
tf.layers.dense({
units: 20,
activation: "relu"
})
);
model.add(
tf.layers.dense({
units: 1
})
);
// Compile for training
model.compile({
optimizer: "adam",
loss: "meanSquaredError"
});
// Train and print timing
console.time("Training");
await model.fit(xs, ys, {
epochs: 30,
callbacks: printCallback,
batchSize: 64
});
console.timeEnd("Training");
// evaluate the model
const next = tf.tensor([7]);
const answer = model.predict(next);
answer.print();
// Cleanup!
answer.dispose();
xs.dispose();
ys.dispose();
model.dispose();
打印的结果张量与49非常接近。训练成功。虽然这是一次有点奇怪的冒险,但它突出了模型创建和验证过程的一部分。构建模型是你随着时间实验各种数据及其相关解决方案而获得的技能之一。
在接下来的章节中,你将解决更复杂但有益的问题,比如分类。你在这里学到的一切将成为你工作台上的一种工具。
章节回顾
你已经进入了训练模型的世界。层模型结构不仅是一个可理解的视觉,现在它是你可以理解和按需构建的东西。机器学习与普通软件开发非常不同,但你正在逐渐理解 TensorFlow.js 所提供的差异和好处。
章节挑战:模型架构师
现在轮到你通过规范构建一个 Layers 模型了。这个模型做什么?没人知道!它不会用任何数据进行训练。在这个挑战中,你将被要求构建一个具有各种你可能不理解的属性的模型,但你应该足够熟悉以至少设置好模型。这个模型将是你迄今为止创建的最大模型。你的模型将有五个输入和四个输出,它们之间有两层。它看起来像图 8-10。
![你的章节挑战]()
图 8-10. 章节挑战模型
在你的模型中做以下操作:
-
输入层应该有 5 个单元。
-
下一层应该有 10 个单元并使用sigmoid激活。
-
下一层应该有 7 个单元并使用 ReLU 激活。
-
最后一层应该有 4 个单元并使用softmax激活。
-
模型应该使用 Adam 优化。
-
模型应该使用损失函数categoricalCrossentropy。
在构建这个模型并查看摘要之前,你能计算出最终模型将有多少可训练参数吗?这是从图 8-10 中的总行数和圆圈数,不包括输入。
你可以在附录 B 中找到这个挑战的答案。
回顾问题
让我们回顾一下你在本章编写的代码中学到的经验。花点时间回答以下问题:
-
为什么章节挑战模型不适用于本章的训练数据?
-
你可以调用哪个方法来记录和审查模型的结构?
-
为什么要向层添加激活函数?
-
你如何为 Layers 模型指定输入形状?
-
sgd代表什么?
-
什么是一个 epoch?
-
如果一个模型有一个输入,然后是两个节点的一层,和两个节点的输出,那么有多少隐藏层?
这些练习的解决方案可以在附录 A 中找到。
¹ 支持的优化器列在 tfjs-core 的optimizers 文件夹中。
² 从 Andrew Ng 了解更多关于激活函数的知识。
第九章:分类模型和数据分析
“先见之明,后事之师。”
—Amelia Barr
你不仅仅是把数据丢进模型中是有原因的。神经网络以极快的速度运行并执行复杂的计算,就像人类可以瞬间做出反应一样。然而,对于人类和机器学习模型来说,反应很少包含合理的上下文。处理脏乱和令人困惑的数据会导致次优的模型,甚至什么都不会得到。在这一章中,你将探索识别、加载、清理和优化数据的过程,以提高 TensorFlow.js 模型的训练准确性。
我们将:
-
确定如何创建分类模型
-
学习如何处理 CSV 数据
-
了解 Danfo.js 和 DataFrames
-
确定如何将混乱的数据输入训练中(整理你的数据)
-
练习绘制和分析数据
-
了解机器学习笔记本
-
揭示特征工程的核心概念
当你完成这一章时,你将有信心收集大量数据,分析数据,并通过使用上下文来创建有助于模型训练的特征来测试你的直觉。
在这一章中,你将构建一个Titanic生死分类器。30 岁的 Kate Connolly 小姐,持有三等舱票,会生还吗?让我们训练一个模型来获取这些信息,并给出生还的可能性。
分类模型
到目前为止,你训练了一个输出数字的模型。你消化的大多数模型与你创建的模型有些不同。在第八章中,你实现了线性回归,但在这一章中,你将实现一个分类模型(有时称为逻辑回归)。
毒性、MobileNet,甚至井字棋模型输出一种选择,从一组选项中选择。它们使用一组总和为一的数字,而不是一个没有范围的单个数字。这是分类模型的常见结构。一个旨在识别三种不同选项的模型将给出与每个选项对应的数字。
试图预测分类的模型需要一种将输出值映射到其相关类别的方法。到目前为止,在分类模型中,最常见的方法是输出它们的概率。要创建一个执行此操作的模型,你只需要在最终层实现特殊的激活函数:
提示
记住,激活函数帮助你的神经网络以非线性方式运行。每个激活函数使得一个层以所需的非线性方式运行,最终层的激活直接转化为输出。重要的是确保你学会了哪种激活函数会给你所需的模型输出。
在这本书中使用的模型中,你一遍又一遍看到的激活函数被称为softmax激活。这是一组值,它们的总和为一。例如,如果你的模型需要一个 True/False 输出,你会期望模型输出两个值,一个用于识别true的概率,另一个用于false。例如,对于这个模型,softmax 可能输出[0.66, 0.34],经过一些四舍五入。
这可以扩展到 N 个值的 N 个分类只要类别是互斥的。在设计模型时,你会在最终层强制使用 softmax,并且输出的数量将是你希望支持的类别数量。为了实现 True 或 False 的结果,你的模型架构将在最终层上使用 softmax 激活,有两个输出。
// Final layer softmax True/False
model.add(
tf.layers.dense({
units: 2,
activation: "softmax"
})
);
如果您试图从输入中检测几件事情会发生什么?例如,胸部 X 光可能同时对肺炎和肺气肿呈阳性。在这种情况下,Softmax 不起作用,因为输出必须总和为一,对一个的信心必须与另一个对抗。在这种情况下,有一种激活可以强制每个节点的值在零和一之间,因此您可以实现每个节点的概率。这种激活称为 sigmoid 激活。这可以扩展到 N 个值,用于 N 个不相互排斥的分类。这意味着您可以通过具有 sigmoid 的单个输出来实现真/假模型(二元分类),其中接近零为假,接近一为真:
// Final layer sigmoid True/False
model.add(
tf.layers.dense({
units: 1,
activation: "sigmoid",
})
);
是的,这些激活名称很奇怪,但它们并不复杂。您可以通过研究这些激活函数的工作原理背后的数学,在 YouTube 的兔子洞中轻松度过一天。但最重要的是,了解它们在分类中的用法。在 表 9-1 中,您将看到一些示例。
表 9-1. 二元分类示例
| 激活 |
输出 |
结果分析 |
| sigmoid |
[0.999999] |
99% 确定是真的 |
| softmax |
[0.99, 0.01] |
99% 确定是真的 |
| sigmoid |
[0.100000] |
10% 确定是真的(因此 90% 是假的) |
| softmax |
[0.10, 0.90] |
90% 确定是假的 |
当您处理真/假时,您使用 softmax 与 sigmoid 的区别消失。在选择最终层的激活时,您选择哪种激活没有真正的区别,因为没有一种可以排除另一种。在本章中,我们将在最后一层使用 sigmoid 以简化。
如果您试图对多个事物进行分类,您需要在 sigmoid 或 softmax 之间做出明智的选择。本书将重申和澄清这些激活函数的使用情况。
泰坦尼克号
1912 年 4 月 15 日,“不沉的” RMS 泰坦尼克号(请参见 图 9-1)沉没了。这场悲剧在历史书籍中广为流传,充满了傲慢的故事,甚至有一部由莱昂纳多·迪卡普里奥和凯特·温丝莱特主演的电影。这场悲剧充满了一丝令人敬畏的死亡好奇。如果您在拉斯维加斯卢克索的 泰坦尼克号 展览中,您的门票会分配给您一位乘客的名字,并告诉您您的票价、舱位等等关于您生活的几件事。当您浏览船只和住宿时,您可以通过您门票上的人的眼睛体验它。在展览结束时,您会发现您门票上印刷的人是否幸存下来。
![泰坦尼克号概况]()
图 9-1. RMS 泰坦尼克号
谁生存谁没有是 100%随机的吗?熟悉历史或看过电影的人都知道这不是一个抛硬币的事情。也许您可以训练一个模型来发现数据中的模式。幸运的是,客人日志和幸存者名单可供我们使用。
泰坦尼克数据集
与大多数事物一样,数据现在已转录为数字格式。 泰坦尼克 名单以逗号分隔的值(CSV)形式可用。这种表格数据可以被任何电子表格软件读取。有很多副本的 泰坦尼克 数据集可用,并且它们通常具有相同的信息。我们将使用的 CSV 文件可以在本章的相关代码中的 额外文件夹 中找到。
这个 泰坦尼克 数据集包含在 表 9-2 中显示的列数据。
表 9-2. 泰坦尼克数据
| 列 |
定义 |
图例 |
| 生存 |
生存 |
0 = 否,1 = 是 |
| pclass |
票类 |
1 = 1 等,2 = 2 等,3 = 3 等 |
| 性别 |
性别 |
|
| 年龄 |
年龄 |
|
| 兄弟姐妹或配偶数量 |
兄弟姐妹或配偶在船上的数量 |
|
| 父母或子女数量 |
父母或子女在船上的数量 |
|
| 票号 |
票号 |
|
| 票价 |
乘客票价 |
|
| 船舱 |
船舱号码 |
|
| embarked |
登船港口 |
C = 瑟堡, Q = 昆士敦, S = 南安普敦 |
那么如何将这些 CSV 数据转换为张量形式呢?一种方法是读取 CSV 文件,并将每个输入转换为张量表示进行训练。当您试图尝试哪些列和格式对训练模型最有用时,这听起来是一个相当重要的任务。
在 Python 社区中,一种流行的加载、修改和训练数据的方法是使用一个名为Pandas的库。这个开源库在数据分析中很常见。虽然这对 Python 开发人员非常有用,但 JavaScript 中存在类似工具的需求很大。
Danfo.js
Danfo.js是 Pandas 的 JavaScript 开源替代品。Danfo.js 的 API 被故意保持与 Pandas 接近,以便利用信息体验共享。甚至 Danfo.js 中的函数名称都是snake_case而不是标准的 JavaScriptcamelCase格式。这意味着您可以在 Danfo.js 中最小地进行翻译,利用 Pandas 的多年教程。
我们将使用 Danfo.js 来读取Titanic CSV 并将其修改为 TensorFlow.js 张量。要开始,您需要将 Danfo.js 添加到项目中。
要安装 Danfo.js 的 Node 版本,您将运行以下命令:
$ npm i danfojs-node
如果您使用简单的 Node.js,则可以require Danfo.js,或者如果您已经配置了代码以使用 ES6+,则可以import:
const dfd = require("danfojs-node");
注意
Danfo.js 也可以在浏览器中运行。本章依赖于比平常更多的打印信息,因此利用完整的终端窗口并依赖 Node.js 的简单性来访问本地文件是有意义的。
Danfo.js 在幕后由 TensorFlow.js 提供支持,但它提供了常见的数据读取和处理实用程序。
为泰坦尼克号做准备
机器学习最常见的批评之一是它看起来像一个金鹅。您可能认为接下来的步骤是将模型连接到 CSV 文件,点击“训练”,然后休息一天,去公园散步。尽管每天都在努力改进机器学习的自动化,但数据很少以“准备就绪”的格式存在。
本章中的Titanic数据包含诱人的 Train 和 Test CSV 文件。然而,使用 Danfo.js,我们很快就会看到提供的数据远未准备好加载到张量中。本章的目标是让您识别这种形式的数据并做好适当的准备。
读取 CSV
CSV 文件被加载到一个称为 DataFrame 的结构中。DataFrame 类似于带有可能不同类型列和适合这些类型的行的电子表格,就像一系列对象。
DataFrame 有能力将其内容打印到控制台,以及许多其他辅助函数以编程方式查看和编辑内容。
让我们回顾一下以下代码,它将 CSV 文件读入 DataFrame,然后在控制台上打印几行:
const df = await dfd.read_csv("file://../../extra/titanic data/train.csv"); // ①
df.head().print(); // ②
①
read_csv方法可以从 URL 或本地文件 URI 中读取。
②
DataFrame 可以限制为前五行,然后打印。
正在加载的 CSV 是训练数据,print()命令将 DataFrame 的内容记录到控制台。结果显示在控制台中,如图 9-2 所示。
![Head printout]()
图 9-2。打印 CSV DataFrame 头
在检查数据内容时,您可能会注意到一些奇怪的条目,特别是在Cabin列中,显示为NaN。这些代表数据集中的缺失数据。这是您不能直接将 CSV 连接到模型的原因之一:重要的是要确定如何处理缺失信息。我们将很快评估这个问题。
Danfo.js 和 Pandas 有许多有用的命令,可以帮助您熟悉加载的数据。一个流行的方法是调用.describe(),它试图分析每列的内容作为报告:
// Print the describe data
df.describe().print();
如果打印 DataFrame 的describe数据,您将看到您加载的 CSV 有 891 个条目,以及它们的最大值、最小值、中位数等的打印输出,以便您验证信息。打印的表格看起来像图 9-3。
![描述打印输出]()
图 9-3。描述 DataFrame
一些列已从图 9-3 中删除,因为它们包含非数字数据。这是您将在 Danfo.js 中轻松解决的问题。
调查 CSV
这个 CSV 反映了数据的真实世界,其中经常会有缺失信息。在训练之前,您需要处理这个问题。
您可以使用isna()找到所有缺失字段,它将为每个缺失字段返回true或false。然后,您可以对这些值进行求和或计数以获得结果。以下是将报告数据集的空单元格或属性的代码:
// Count of empty spots
empty_spots = df.isna().sum();
empty_spots.print();
// Find the average
empty_rate = empty_spots.div(df.isna().count());
empty_rate.print();
通过结果,您可以看到以下内容:
-
空的Age数值:177(20%)
-
空的Cabin数值:687(77%)
-
空的Embarked数值:2(0.002%)
从对缺失数据量的简短查看中,您可以看到您无法避免清理这些数据。解决缺失值问题将至关重要,删除像PassengerId这样的无用列,并最终对您想保留的非数字列进行编码。
为了不必重复操作,您可以将 CSV 文件合并、清理,然后创建两个准备好用于训练和测试的新 CSV 文件。
目前,这些是步骤:
-
合并 CSV 文件。
-
清理 DataFrame。
-
从 DataFrame 重新创建 CSV 文件。
合并 CSV
要合并 CSV 文件,您将创建两个 DataFrame,然后沿着轴连接它们,就像对张量一样。您可能会感觉到张量训练引导您管理和清理数据的路径,并且这并非偶然。尽管术语可能略有不同,但您从前几章积累的概念和直觉将对您有所帮助。
// Load the training CSV
const df = await dfd.read_csv("file://../../extra/titanic data/train.csv");
console.log("Train Size", df.shape[0]) // ①
// Load the test CSV
const dft = await dfd.read_csv("file://../../extra/titanic data/test.csv");
console.log("Test Size", dft.shape[0]) // ②
const mega = dfd.concat({df_list: [df, dft], axis: 0})
mega.describe().print() // ③
①
打印“训练集大小为 891”
②
打印“测试集大小为 418”
③
显示一个包含 1,309 的表
使用熟悉的语法,您已经加载了两个 CSV 文件,并将它们合并成一个名为mega的 DataFrame,现在您可以对其进行清理。
清理 CSV
在这里,您将处理空白并确定哪些数据实际上是有用的。您需要执行三个操作来正确准备用于训练的 CSV 数据:
-
修剪特征。
-
处理空白。
-
迁移到数字。
修剪特征意味着删除对结果影响很小或没有影响的特征。为此,您可以尝试实验、绘制数据图表,或者简单地运用您的个人直觉。要修剪特征,您可以使用 DataFrame 的.drop函数。.drop函数可以从 DataFrame 中删除整个列或指定的行。
对于这个数据集,我们将删除对结果影响较小的列,例如乘客的姓名、ID、票和舱位。您可能会认为其中许多特征可能非常重要,您是对的。但是,我们将让您在本书之外的范围内研究这些特征。
// Remove feature columns that seem less useful
const clean = mega.drop({
columns: ["Name", "PassengerId", "Ticket", "Cabin"],
});
要处理空白,您可以填充或删除行。填充空行是一种称为插补的技术。虽然这是一个很好的技能可以深入研究,但它可能会变得复杂。在本章中,我们将采取简单的方法,仅删除任何具有缺失值的行。要删除任何具有空数据的行,我们可以使用dropna()函数。
警告
这是在删除列之后之后完成的至关重要。否则,Cabin列中 77%的缺失数据将破坏数据集。
您可以使用以下代码删除所有空行:
// Remove all rows that have empty spots
const onlyFull = clean.dropna();
console.log(`After mega-clean the row-count is now ${onlyFull.shape[0]}`);
此代码的结果将数据集从 1,309 行减少到 1,043 行。将其视为一种懒惰的实验。
最后,您剩下两列是字符串而不是数字(Embarked和Sex)。这些需要转换为数字。
Embarked的值,供参考,分别是:C = 瑟堡,Q = 昆士敦,S = 南安普敦。有几种方法可以对其进行编码。一种方法是用数字等价物对其进行编码。Danfo.js 有一个LabelEncoder,它可以读取整个列,然后将值转换为数字编码的等价物。LabelEncoder将标签编码为介于0和n-1之间的值。要对Embarked列进行编码,您可以使用以下代码:
// Handle embarked characters - convert to numbers
const encode = new dfd.LabelEncoder(); // ①
encode.fit(onlyFull["Embarked"]); // ②
onlyFull["Embarked"] = encode.transform(onlyFull["Embarked"].values); // ③
onlyFull.head().print(); // ④
①
创建一个新的LabelEncoder实例。
②
适合对Embarked列的内容进行编码的实例。
③
将列转换为值,然后立即用生成的列覆盖当前列。
④
打印前五行以验证替换是否发生。
您可能会对像第 3 步那样覆盖 DataFrame 列的能力感到惊讶。这是处理 DataFrame 而不是张量的许多好处之一,尽管 TensorFlow.js 张量在幕后支持 Danfo.js。
现在您可以使用相同的技巧对male / female字符串进行编码。(请注意,出于模型目的和乘客名单中可用数据的考虑,我们将性别简化为二进制。)完成后,您的整个数据集现在是数字的。如果在 DataFrame 上调用describe,它将呈现所有列,而不仅仅是几列。
保存新的 CSV 文件
现在您已经创建了一个可用于训练的数据集,您需要返回两个 CSV 文件,这两个文件进行了友好的测试和训练拆分。
您可以使用 Danfo.js 的.sample重新拆分 DataFrame。.sample方法会从 DataFrame 中随机选择 N 行。从那里,您可以将剩余的未选择值创建为测试集。要删除已抽样的值,您可以按索引而不是整个列删除行。
DataFrame 对象具有to_csv转换器,可选择性地接受要写入的文件参数。to_csv命令会写入参数文件并返回一个 promise,该 promise 解析为 CSV 内容。重新拆分 DataFrame 并写入两个文件的整个代码可能如下所示:
// 800 random to training
const newTrain = onlyFull.sample(800)
console.log(`newTrain row count: ${newTrain.shape[0]}`)
// The rest to testing (drop via row index)
const newTest = onlyFull.drop({index: newTrain.index, axis: 0})
console.log(`newTest row count: ${newTest.shape[0]}`)
// Write the CSV files
await newTrain.to_csv('../../extra/cleaned/newTrain.csv')
await newTest.to_csv('../../extra/cleaned/newTest.csv')
console.log('Files written!')
现在您有两个文件,一个包含 800 行,另一个包含 243 行用于测试。
泰坦尼克号数据的训练
在对数据进行训练之前,您需要处理最后一步,即经典的机器学习标记输入和预期输出(X 和 Y,分别)。这意味着您需要将答案(Survived列)与其他输入分开。为此,您可以使用iloc声明要创建新 DataFrame 的列的索引。
由于第一列是Survived列,您将使 X 跳过该列并抓取其余所有列。您将从 DataFrame 的索引一到末尾进行识别。这写作1:。您可以写1:9,这将抓取相同的集合,但1:表示“从索引零之后的所有内容”。iloc索引格式表示您为 DataFrame 子集选择的范围。
Y 值,或答案,是通过抓取Survived列来选择的。由于这是单列,无需使用iloc。不要忘记对测试数据集执行相同操作。
机器学习模型期望张量,而由于 Danfo.js 建立在 TensorFlow.js 上,将 DataFrame 转换为张量非常简单。最终,您可以通过访问.tensor属性将 DataFrame 转换为张量。
// Get cleaned data
const df = await dfd.read_csv("file://../../extra/cleaned/newTrain.csv");
console.log("Train Size", df.shape[0]);
const dft = await dfd.read_csv("file://../../extra/cleaned/newTest.csv");
console.log("Test Size", dft.shape[0]);
// Split train into X/Y
const trainX = df.iloc({ columns: [`1:`] }).tensor;
const trainY = df["Survived"].tensor;
// Split test into X/Y
const testX = dft.iloc({ columns: [`1:`] }).tensor;
const testY = dft["Survived"].tensor;
这些值已准备好被馈送到一个用于训练的模型中。
我在这个问题上使用的模型经过很少的研究后是一个具有三个隐藏层和一个具有 Sigmoid 激活的输出张量的序列层模型。
模型的组成如下:
model.add(
tf.layers.dense({
inputShape,
units: 120,
activation: "relu", // ①
kernelInitializer: "heNormal", // ②
})
);
model.add(tf.layers.dense({ units: 64, activation: "relu" }));
model.add(tf.layers.dense({ units: 32, activation: "relu" }));
model.add(
tf.layers.dense({
units: 1,
activation: "sigmoid", // ③
})
);
model.compile({
optimizer: "adam",
loss: "binaryCrossentropy", // ④
metrics: ["accuracy"], // ⑤
});
①
每一层都使用 ReLU 激活,直到最后一层。
②
这一行告诉模型根据算法初始化权重,而不是简单地将模型的初始权重设置为完全随机。这有时可以帮助模型更接近答案。在这种情况下并不是关键,但这是 TensorFlow.js 的一个有用功能。
③
最后一层使用 Sigmoid 激活来打印一个介于零和一之间的数字(生存或未生存)。
④
在训练二元分类器时,最好使用一个与二元分类一起工作的花哨命名的函数来评估损失。
⑤
这显示了日志中的准确性,而不仅仅是损失。
当您将模型fit到数据时,您可以识别测试数据,并获得模型以前从未见过的数据的结果。这有助于防止过拟合:
await model.fit(trainX, trainY, {
batchSize: 32,
epochs: 100,
validationData: [testX, testY] // ①
})
①
提供模型应该在每个 epoch 上验证的数据。
注意
在前面的fit方法中显示的训练配置没有利用回调。如果您在tfjs-node上训练,您将自动看到训练结果打印到控制台。如果您使用tfjs,您需要添加一个onEpochEnd回调来打印训练和验证准确性。这两者的示例都在相关的本章源代码中提供。
在训练了 100 个 epoch 后,这个模型在训练数据上的准确率为 83%,在测试集的验证上也是 83%。从技术上讲,每次训练的结果会有所不同,但它们应该几乎相同:acc=0.827 loss=0.404 val_acc=0.831 val_loss=0.406。
该模型已经识别出一些模式,并击败了纯粹的机会(50%准确率)。很多人在这里停下来庆祝创造一个几乎没有努力就能工作 83%的模型。然而,这也是一个很好的机会来认识 Danfo.js 和特征工程的好处。
特征工程
如果你在互联网上浏览一下,80%是Titanic数据集的一个常见准确率分数。我们已经超过了这个分数,而且没有真正的努力。然而,仍然有改进模型的空间,这直接来源于改进数据。
抛出空白数据是一个好选择吗?存在可以更好强调的相关性吗?模式是否被正确组织为模型?您能预先处理和组织数据得越好,模型就越能找到和强调模式。许多机器学习的突破都来自于在将模式传递给神经网络之前简化模式的技术。
这是“只是倾倒数据”停滞不前的地方,特征工程开始发展。Danfo.js 让您通过分析模式和强调关键特征来提升您的特征。您可以在交互式的 Node.js 读取求值打印循环(REPL)中进行这项工作,或者甚至可以利用为评估和反馈循环构建的网页。
让我们尝试通过确定并向数据添加特征来提高上述模型的准确率至 83%以上,使用一个名为 Dnotebook 的 Danfo.js Notebook。
Dnotebook
Danfo 笔记本,或Dnotebook,是一个交互式网页,用于使用 Danfo.js 实验、原型设计和定制数据。Python 的等价物称为 Jupyter 笔记本。您可以通过这个笔记本实现的数据科学将极大地帮助您的模型。
我们将使用 Dnotebook 来创建和共享实时代码,以及利用内置的图表功能来查找泰坦尼克号数据集中的关键特征和相关性。
通过创建全局命令来安装 Dnotebook:
$ npm install -g dnotebook
当您运行$ dnotebook时,将自动运行本地服务器并打开一个页面到本地笔记本站点,它看起来有点像图 9-4。
![Dnotebook 新鲜截图]()
图 9-4。正在运行的新鲜 Dnotebook
每个 Dnotebook 单元格可以是代码或文本。文本采用 Markdown 格式。代码可以打印输出,并且未使用const或let初始化的变量可以在单元格之间保留。请参见图 9-5 中的示例。
![Dnotebook 演示截图]()
图 9-5。使用 Dnotebook 单元格
图 9-5 中的笔记本可以从本章的extra/dnotebooks文件夹中的explaining_vars.json文件中下载并加载。这使得它适合用于实验、保存和共享。
泰坦尼克号视觉
如果您可以在数据中找到相关性,您可以将其作为训练数据中的附加特征强调,并在理想情况下提高模型的准确性。使用 Dnotebook,您可以可视化数据并在途中添加评论。这是分析数据集的绝佳资源。我们将加载两个 CSV 文件并将它们组合,然后直接在笔记本中打印结果。
您可以创建自己的笔记本,或者可以从相关源代码加载显示的笔记本的 JSON。只要您能够跟上图 9-6 中显示的内容,任何方法都可以。
![指导性代码截图]()
图 9-6。加载 CSV 并在 Dnotebook 中组合它们
load_csv命令类似于read_csv命令,但在加载 CSV 内容时在网页上显示友好的加载动画。您可能还注意到了table命令的使用。table命令类似于 DataFrame 的print(),只是它为笔记本生成了输出的 HTML 表格,就像您在图 9-6 中看到的那样。
现在您已经有了数据,让我们寻找可以强调的重要区别,以供我们的模型使用。在电影《泰坦尼克号》中,当装载救生艇时他们大声喊着“妇女和儿童优先”。那真的发生了吗?一个想法是检查男性与女性的幸存率。您可以通过使用groupby来做到这一点。然后您可以打印每个组的平均值。
grp = mega_df.groupby(['Sex'])
table(grp.col(['Survived']).mean())
而且哇啊!您可以看到 83%的女性幸存下来,而只有 14%的男性幸存下来,正如图 9-7 中所示。
![幸存率截图]()
图 9-7。女性更有可能幸存
您可能会想知道也许只是因为泰坦尼克号上有更多女性,这就解释了倾斜的结果,所以您可以快速使用count()来检查,而不是像刚才那样使用mean():
survival_count = grp.col(['Survived']).count()
table(survival_count)
通过打印的结果,您可以看到尽管幸存比例偏向女性,但幸存的男性要多得多。这意味着性别是幸存机会的一个很好的指标,因此应该强调这一特征。
使用 Dnotebook 的真正优势在于它利用了 Danfo.js 图表。例如,如果我们想看到幸存者的直方图,而不是分组用户,您可以查询所有幸存者,然后绘制结果。
要查询幸存者,您可以使用 DataFrame 的 query 方法:
survivors = mega_df.query({column: "Survived", is: "==", to: 1 })
然后,要在 Dnotebooks 中打印图表,您可以使用内置的viz命令,该命令需要一个 ID 和回调函数,用于填充笔记本中生成的 DIV。
直方图可以使用以下方式创建:
viz(`agehist`, x => survivors["Age"].plot(x).hist())
然后笔记本将显示生成的图表,如图 9-8 所示。
![存活直方图的屏幕截图]()
图 9-8. 幸存者年龄直方图
在这里,您可以看到儿童的显着存活率高于老年人。再次,确定每个年龄组的数量和百分比可能值得,但似乎特定的年龄组或区间比其他年龄组表现更好。这给了我们可能改进模型的第二种方法。
让我们利用我们现在拥有的信息,再次尝试打破 83%准确率的记录。
创建特征(又称预处理)
在成长过程中,我被告知激活的神经元越多,记忆就会越强烈,因此请记住气味、颜色和事实。让我们看看神经网络是否也是如此。我们将乘客性别移动到两个输入,并创建一个经常称为分桶或分箱的年龄分组。
我们要做的第一件事是将性别从一列移动到两列。这通常称为独热编码。目前,Sex具有数字编码。乘客性别的独热编码版本将0转换为[1, 0],将1转换为[0, 1],成功地将值移动到两列/单元。转换后,您删除Sex列并插入两列,看起来像图 9-9。
![Danfo One-Hot Coded]()
图 9-9. 描述性别独热编码
要进行独热编码,Danfo.js 和 Pandas 都有一个get_dummies方法,可以将一列转换为多个列,其中只有一个列的值为 1。在 TensorFlow.js 中,进行独热编码的方法称为oneHot,但在 Danfo.js 中,get_dummies是向二进制变量致敬的方法,统计学中通常称为虚拟变量。编码结果后,您可以使用drop和addColumn进行切换:
// Handle person sex - convert to one-hot
const sexOneHot = dfd.get_dummies(mega['Sex']) // ①
sexOneHot.head().print()
// Swap one column for two
mega.drop({ columns: ['Sex'], axis: 1, inplace: true }) // ②
mega.addColumn({ column: 'male', value: sexOneHot['0'] }) // ③
mega.addColumn({ column: 'female', value: sexOneHot['1'] })
①
使用get_dummies对列进行编码
②
在Sex列上使用inplace删除
③
添加新列,将标题切换为男性/女性
接下来,您可以使用apply方法为年龄创建桶。apply方法允许您在整个列上运行条件代码。根据我们的需求,我们将定义一个在我们的图表中看到的重要年龄组的函数,如下所示:
// Group children, young, and over 40yrs
function ageToBucket(x) {
if (x < 10) {
return 0
} else if (x < 40) {
return 1
} else {
return 2
}
}
然后,您可以使用您定义的ageToBucket函数创建并添加一个完全新的列来存储这些桶:
// Create Age buckets
ageBuckets = mega['Age'].apply(ageToBucket)
mega.addColumn({ column: 'Age_bucket', value: ageBuckets })
这添加了一个值范围从零到二的整列。
最后,我们可以将我们的数据归一化为介于零和一之间的数字。缩放值会使值之间的差异归一化,以便模型可以识别模式和缩放原始数字中扭曲的差异。
注意
将归一化视为一种特征。如果您正在处理来自各个国家的 10 种不同货币,可能会感到困惑。归一化会缩放输入,使它们具有相对影响的大小。
const scaler = new dfd.MinMaxScaler()
scaledData = scaler.fit(featuredData)
scaledData.head().print()
从这里,您可以为训练编写两个 CSV 文件并开始!另一个选项是您可以编写一个单独的 CSV 文件,而不是使用特定的 X 和 Y 值设置validationData,您可以设置一个名为validationSplit的属性,该属性将为验证数据拆分出一定比例的数据。这样可以节省我们一些时间和麻烦,所以让我们使用validationSplit来训练模型,而不是显式传递validationData。
生成的fit如下所示:
await model.fit(trainX, trainY, {
batchSize: 32,
epochs: 100,
// Keep random 20% for validation on the fly.
// The 20% is selected at the beginning of the training session.
validationSplit: 0.2,
})
模型使用新数据进行 100 个时代的训练,如果您使用tfjs-node,即使没有定义回调函数,也可以看到打印的结果。
特征工程训练结果
上次,模型准确率约为 83%。现在,使用相同的模型结构但添加了一些特征,我们达到了 87%的训练准确率和 87%的验证准确率。具体来说,我的结果是acc=0.867 loss=0.304 val_acc=0.871 val_loss=0.370。
准确性提高了,损失值低于以前。真正了不起的是,准确性和验证准确性都是对齐的,因此模型不太可能过拟合。这通常是神经网络在泰坦尼克号数据集中的较好得分之一。对于这样一个奇怪的问题,创建一个相当准确的模型已经达到了解释如何从数据中提取有用信息的目的。
审查结果
解决泰坦尼克号问题以达到 87%的准确率需要一些技巧。您可能仍然在想结果是否可以改进,答案肯定是“是”,因为其他人已经在排行榜上发布了更令人印象深刻的分数。在没有排行榜的情况下,评估是否有增长空间的常见方法是与一个受过教育的人在面对相同问题时的得分进行比较。
如果您是一个高分狂热者,章节挑战将有助于改进我们已经创建的令人印象深刻的模型。一定要练习工程特征,而不是过度训练,从而使模型过度拟合以基本上记住答案。
查找重要值、归一化特征和强调显著相关性是机器学习训练中的一项有用技能,现在您可以使用 Danfo.js 来实现这一点。
章节回顾
那么在本章开始时我们识别的那个个体发生了什么?凯特·康诺利小姐,一个 30 岁的持有三等舱票的女人,确实幸存了泰坦尼克号事故,模型也认同。
我们是否错过了一些提高机器学习模型准确性的史诗机会?也许我们应该用-1填充空值而不是删除它们?也许我们应该研究一下泰坦尼克号的船舱结构?或者我们应该查看parch、sibsp和pclass,为独自旅行的三等舱乘客创建一个新列?“我永远不会放手!”
并非所有数据都可以像泰坦尼克号数据集那样被清理和特征化,但这对于机器学习来说是一次有用的数据科学冒险。有很多 CSV 文件可用,自信地加载、理解和处理它们对于构建新颖模型至关重要。像 Danfo.js 这样的工具使您能够处理这些海量数据,现在您可以将其添加到您的机器学习工具箱中。
注意
如果您已经是其他 JavaScript 笔记本的粉丝,比如ObservableHQ.com,Danfo.js 也可以导入并与这些笔记本轻松集成。
处理数据是一件复杂的事情。有些问题更加明确,根本不需要对特征进行任何调整。如果您感兴趣,可以看看像帕尔默企鹅这样的更简单的数据集。这些企鹅根据它们的嘴的形状和大小明显地区分为不同的物种。另一个简单的胜利是第七章中提到的鸢尾花数据集。
章节挑战:船只发生了什么
您知道在泰坦尼克号沉没中没有一个牧师幸存下来吗?像先生、夫人、小姐、牧师等这样的头衔桶/箱可能对模型的学习有用。这些敬称——是的,就是它们被称为的——可以从被丢弃的Name列中收集和分析。
在这个章节挑战中,使用 Danfo.js 识别在泰坦尼克号上使用的敬称及其相关的生存率。这是一个让您熟悉 Dnotebooks 的绝佳机会。
您可以在附录 B 中找到这个挑战的答案。
审查问题
让我们回顾一下你在本章编写的代码中学到的教训。花点时间回答以下问题:
-
对于一个石头-剪刀-布分类器,你会使用什么样的激活函数?
-
在一个 sigmoid“狗还是不是狗”模型的最终层中会放置多少个节点?
-
加载一个具有内置 Danfo.js 的交互式本地托管笔记本的命令是什么?
-
如何将具有相同列的两个 CSV 文件的数据合并?
-
你会使用什么命令将单个列进行独热编码成多个列?
-
你可以使用什么来将 DataFrame 的所有值在 0 和 1 之间进行缩放?
这些练习的解决方案可以在附录 A 中找到。
第十章:图像训练
“一切都应该尽可能简单。但不要过于简单。”
—阿尔伯特·爱因斯坦
我将向您描述一个数字。我希望您根据其特征的描述来猜出这个数字。我想的数字顶部是圆的,右侧只有一条线,底部有一个重叠的环状物。花点时间,心理上映射我刚刚描述的数字。有了这三个特征,你可能可以猜出来。
视觉数字的特征可能会有所不同,但聪明的描述意味着您可以在脑海中识别数字。当我说“圆顶”时,您可能会立即排除一些数字,同样的情况也适用于“只有右侧有一条线”。这些特征组成了数字的独特之处。
如果您按照图 10-1 中显示的数字描述,并将其描述放入 CSV 文件中,您可能可以通过训练好的神经网络在这些数据上获得 100%的准确性。整个过程都很顺利,只是依赖于人类描述每个数字的顶部、中部和底部。如何自动化这个人类方面呢?如果您可以让计算机识别图像的独特特征,如环、颜色和曲线,然后将其输入神经网络,机器就可以学习将描述分类为图像所需的模式。
![数字二的三部分。]()
图 10-1。如果您发现这是数字二,恭喜您。
幸运的是,通过出色的计算机视觉技巧,解决了图像特征工程的问题。
我们将:
完成本章后,您将能够创建自己的图像分类模型。
理解卷积
卷积来自于数学世界中表达形状和函数的概念。您可以深入研究卷积在数学中的概念,然后从头开始将这些知识重新应用到数字图像信息的收集概念。如果您是数学和计算机图形的爱好者,这是一个非常令人兴奋的领域。
然而,当你有像 TensorFlow.js 这样的框架时,花费一周学习卷积操作的基础并不是必要的。因此,我们将专注于卷积操作的高级优势和特性,以及它们在神经网络中的应用。始终鼓励您在这个快速入门之外深入研究卷积的深层历史。
让我们看看您应该从非数学解释的卷积中获得的最重要概念。
图 10-2 中的两个数字二的图像是完全相同的数字,只是它们在边界框中从左到右移动。将这两个数字转换为张量后,会创建出两个明显不同的不相等张量。然而,在本章开头描述的特征系统中,这些特征将是相同的。
![卷积特征与无特征]()
图 10-2。卷积简化了图像的本质
对于视觉张量,图像的特征比每个像素的确切位置更重要。
卷积快速总结
卷积操作用于提取高级特征,如边缘、梯度、颜色等。这些是分类给定视觉的关键特征。
那么应该提取哪些特征呢?这并不是我们实际决定的。您可以控制在查找特征时使用的滤波器数量,但最好定义可用模式的实际特征是在训练过程中定义的。这些滤波器从图像中突出和提取特征。
例如,看一下图 10-3 中的照片。南瓜灯是多种颜色,几乎与模糊但略带明暗的背景形成对比。作为人类,您可以轻松识别出照片中的内容。
![南瓜灯艺术的照片]()
图 10-3. 南瓜灯艺术
现在这是同一图像,通过 3 x 3 的边缘检测滤波器卷积在像素上。注意结果在图 10-4 中明显简化和更明显。
不同的滤波器突出显示图像的不同方面,以简化和澄清内容。不必止步于此;您可以运行激活以强调检测到的特征,甚至可以在卷积上运行卷积。
结果是什么?您已经对图像进行了特征工程。通过各种滤波器对图像进行预处理,让您的神经网络看到原本需要更大、更慢和更复杂模型才能看到的模式。
![前一图像的卷积结果]()
图 10-4. 卷积结果
添加卷积层
感谢 TensorFlow.js,添加卷积层与添加密集层一样简单,但称为conv2d,并具有自己的属性。
tf.layers.conv2d({
filters: 32, // ①
kernelSize: 3, // ②
strides: 1, // ③
padding: 'same', // ④
activation: 'relu', // ⑤
inputShape: [28, 28, 1] // ⑥
})
①
确定要运行多少个滤波器。
②
kernelSize控制滤波器的大小。这里的3表示 3 x 3 的滤波器。
③
小小的 3 x 3 滤波器不适合您的图像,因此需要在图像上滑动。步幅是滤波器每次滑动的像素数。
④
填充允许卷积在strides和kernelSize不能均匀地分割成图像宽度和高度时决定如何处理。当将填充设置为same时,会在图像周围添加零,以保持生成的卷积图像的大小不变。
⑤
然后将结果通过您选择的激活函数运行。
⑥
输入是一个图像张量,因此输入图像是模型的三维形状。这不是卷积的必需限制,正如您在第六章中学到的那样,但如果您不是在制作完全卷积模型,则建议这样做。
不要被可能的参数列表所压倒。想象一下自己必须编写所有这些不同设置。您可以像现有模型那样配置您的卷积,也可以使用数字进行调整以查看其对结果的影响。调整这些参数并进行实验是 TensorFlow.js 等框架的好处。最重要的是,随着时间的推移建立您的直觉。
重要的是要注意,这个conv2d层是用于图像的。同样,您将在线性序列上使用conv1d,在处理 3D 空间对象时使用conv3d。大多数情况下,使用 2D,但概念并不受限制。
理解最大池化
通过卷积层使用滤波器简化图像后,您在过滤后的图形中留下了大量空白空间。此外,由于所有图像滤波器,输入参数的数量显着增加。
最大池化是简化图像中识别出的最活跃特征的一种方法。
最大池化快速总结
为了压缩生成的图像大小,您可以使用最大池化来减少输出。简单地说,最大池化是将窗口中最活跃的像素保留为该窗口中所有像素块的表示。然后您滑动窗口并取其中的最大值。只要窗口的步幅大于 1,这些结果就会汇总在一起,以生成一个更小的图像。
以下示例通过取每个子方块中的最大数来将图像的大小分成四分之一。研究图 10-5 中的插图。
![最大池化演示]()
图 10-5。2 x 2 核和步幅为 2 的最大池
在图 10-5 中的kernelSize为 2 x 2。因此,四个左上角的方块一起进行评估,从数字[12, 5, 11, 7]中,最大的是12。这个最大数传递给结果。步幅为 2,核窗口的方块完全移动到前一个方块的相邻位置,然后重新开始使用数字[20, 0, 12, 3]。这意味着每个窗口中最强的激活被传递下去。
你可能会觉得这个过程会切割图像并破坏内容,但你会惊讶地发现,生成的图像是相当容易识别的。最大池化甚至强调了检测,并使图像更容易识别。参见图 10-6,这是在之前的南瓜灯卷积上运行最大池的结果。
最大池化强调检测
图 10-6。卷积的 2 x 2 核最大池结果
虽然图 10-4 和图 10-6 在插图目的上看起来相同大小,但后者由于池化过程而稍微清晰一些,并且是原始大小的四分之一。
添加最大池化层
类似于conv2d,最大池化被添加为一层,通常紧跟在卷积之后:
tf.layers.maxPooling2d({
poolSize: 2, // ①
strides: 2 // ②
})
①
poolSize是窗口大小,就像kernelSize一样。之前的例子都是 2(代表 2 x 2)。
②
strides是在每次操作中向右和向下移动窗口的距离。这也可以写成strides: [2, 2]。
通常,阅读图像的模型会有几层卷积,然后池化,然后再次卷积和池化。这会消耗图像的特征,并将它们分解成可能识别图像的部分。²
训练图像分类
经过几层卷积和池化后,你可以将结果滤波器展平或序列化成一个单一链,并将其馈送到一个深度连接的神经网络中。这就是为什么人们喜欢展示 MNIST 训练示例;它是如此简单,以至于你实际上可以在一个图像中观察数据。
看一下使用卷积和最大池化对数字进行分类的整个过程。图 10-7 应该从底部向顶部阅读。
![MNIST 逐层输出]()
图 10-7。MNIST 处理数字五
如果你跟随图 10-7 中显示的这幅图像的过程,你会看到底部的输入,然后是该输入与六个滤波器的卷积直接在其上方。接下来,这六个滤波器被最大池化或“下采样”,你可以看到它们变小了。然后再进行一次卷积和池化,然后将它们展平到一个完全连接的密集网络层。在展平的层上方是一个密集层,顶部的最后一个小层是一个具有 10 个可能解的 softmax 层。被点亮的是“五”。
从鸟瞰视角看,卷积和池化看起来像魔术,但它将图像的特征消化成神经元可以识别的模式。
在分层模型中,这意味着第一层通常是卷积和池化风格的层,然后它们被传递到神经网络中。图 10-8 展示了这个过程的高层视图。以下是三个阶段:
-
输入图像
-
特征提取
-
深度连接的神经网络
![CNN 流程图]()
图 10-8。CNN 的三个基本阶段
处理图像数据
使用图像进行训练的一个缺点是数据集可能非常庞大且难以处理。数据集通常很大,但对于图像来说,它们通常是巨大的。这也是为什么相同的视觉数据集一遍又一遍地被使用的另一个原因。
即使图像数据集很小,当加载到内存张量形式时,它可能占用大量内存。你可能需要将训练分成张量的块,以处理庞大的图像集。这可能解释了为什么像 MobileNet 这样的模型被优化为今天标准下被认为相对较小的尺寸。在所有图像上增加或减少一个像素会导致指数级的尺寸差异。由于数据的本质,灰度张量在内存中是 RGB 图像的三分之一大小,是 RGBA 图像的四分之一大小。
分拣帽
现在是时候进行你的第一个卷积神经网络了。对于这个模型,你将训练一个 CNN 来将灰度绘画分类为 10 个类别。
如果你是 J.K.罗琳的流行书系列《哈利·波特》的粉丝,这将是有意义且有趣的。然而,如果你从未读过一本《哈利·波特》的书或者看过任何一部电影,这仍然是一个很好的练习。在书中,魔法学校霍格沃茨有四个学院,每个学院都有与之相关联的动物。你将要求用户画一幅图片,并使用该图片将它们分类到各个学院。我已经准备了一个数据集,其中的绘画在某种程度上类似于每个组的图标和动物。
我准备的数据集是从Google 的 Quick, Draw!数据集中的一部分绘画中制作的。类别已经缩减到 10 个,并且数据已经经过了大幅清理。
与本章相关的代码可以在chapter10/node/node-train-houses找到,你会发现一个包含数万个 28 x 28 绘画的 ZIP 文件,包括以下内容:
-
鸟类
-
猫头鹰
-
鹦鹉
-
蛇
-
蜗牛
-
狮子
-
老虎
-
浣熊
-
松鼠
-
头巾
这些绘画变化很大,但每个类别的特征是可辨认的。这里是一些涂鸦的随机样本,详见图 10-9。一旦你训练了一个模型来识别这 10 个类别中的每一个,你就可以使用该模型将类似特定动物的绘画分类到其相关联的学院。鸟类去拉文克劳,狮子和老虎去格兰芬多,等等。
![霍格沃茨学院绘画网格形式]()
图 10-9. 绘画的 10 个类别
处理这个问题的方法有很多,但最简单的方法是使用 softmax 对最终层进行模型分类。正如你记得的那样,softmax 会给我们 N 个数字,它们的总和都为一。例如,如果一幅图是 0.67 的鸟,0.12 的猫头鹰,和 0.06 的鹦鹉,因为它们都代表同一个学院,我们可以将它们相加,结果总是小于一。虽然你熟悉使用返回这种结果的模型,但这将是你从头开始创建的第一个 softmax 分类模型。
入门
有几种方法可以使用 TensorFlow.js 来训练这个模型。将几兆字节的图像加载到浏览器中可以通过几种方式完成:
-
你可以使用后续的 HTTP 请求加载每个图像。
-
你可以将训练数据合并到一个大的精灵表中,然后使用你的张量技能来提取和堆叠每个图像到 X 和 Y 中。
-
你可以将图像加载到 CSV 中,然后将它们转换为张量。
-
你可以将图像进行 Base64 编码,并从单个 JSON 文件加载它们。
你在这里看到的一个常见问题是,你必须做一些额外的工作,将数据加载到浏览器的沙盒中。因此,最好使用 Node.js 进行具有大规模数据集的图像训练。我们将在本书后面讨论这种情况不那么重要的情况。
与本章相关的 Node.js 代码包含了你需要的训练数据。你会在存储库中看到一个接近 100MB 的文件(GitHub 对单个文件的限制),你需要解压到指定位置(见图 10-10)。
![解压文件.zip 截图]()
图 10-10. 将图像解压缩到文件夹中
现在你有了图片,也知道如何在 Node.js 中读取图片,训练这个模型的代码会类似于示例 10-1。
示例 10-1. 理想的设置
// Read images
const [X, Y] = await folderToTensors() // ①
// Create layers model
const model = getModel() // ②
// Train
await model.fit(X, Y, {
batchSize: 256,
validationSplit: 0.1,
epochs: 20,
shuffle: true, // ③
})
// Save
model.save('file://model_result/sorting_hat') // ④
// Cleanup!
tf.dispose([X, Y, model])
console.log('Tensors in memory', tf.memory().numTensors)
①
创建一个简单的函数将图片加载到所需的 X 和 Y 张量中。
②
创建一个适合的 CNN 层模型。
③
使用shuffle属性,该属性会对当前批次进行混洗。
④
将生成的训练模型保存在本地。
注意
示例 10-1 中的代码没有提及设置任何测试数据。由于这个项目的性质,真正的测试将在绘制图像并确定每个笔画如何使图像更接近或更远离期望目标时进行。在训练中仍将使用验证集。
转换图像文件夹
folderToTensors函数需要执行以下操作:
-
识别所有 PNG 文件路径。
-
收集图像张量和答案。
-
随机化两组数据。
-
归一化和堆叠张量。
-
清理并返回结果。
要识别和访问所有图像,可以使用类似glob的库,它接受一个给定的路径,如files/**/.png*,并返回一个文件名数组。/**会遍历该文件夹中的所有子文件夹,并找到每个文件夹中的所有 PNG 文件。
你可以通过以下方式使用 NPM 安装glob:
$ npm i glob
现在节点模块可用,可以被要求或导入:
const glob = require('glob')
// OR
import { default as glob } from 'glob'
由于 glob 是通过回调来操作的,你可以将整个函数包装在 JavaScript promise 中,以将其转换为异步/等待。如果你对这些概念不熟悉,可以随时查阅相关资料或仅仅学习本章提供的代码。
在收集了一组文件位置之后,你可以加载文件,将其转换为张量,甚至通过查看图像来自哪个文件夹来确定每个图像的“答案”或“y”。
记住,每次需要修改张量时都会创建一个新张量。因此,最好将张量保存在 JavaScript 数组中,而不是在进行归一化和堆叠张量时逐步进行。
将每个字符串读入这两个数组的过程可以通过以下方式完成:
files.forEach((file) => {
const imageData = fs.readFileSync(file)
const answer = encodeDir(file)
const imageTensor = tf.node.decodeImage(imageData, 1)
// Store in memory
YS.push(answer)
XS.push(imageTensor)
})
encodeDir函数是我编写的一个简单函数,用于查看每个图像的路径并返回一个相关的预测数字:
function encodeDir(filePath) {
if (filePath.includes('bird')) return 0
if (filePath.includes('lion')) return 1
if (filePath.includes('owl')) return 2
if (filePath.includes('parrot')) return 3
if (filePath.includes('raccoon')) return 4
if (filePath.includes('skull')) return 5
if (filePath.includes('snail')) return 6
if (filePath.includes('snake')) return 7
if (filePath.includes('squirrel')) return 8
if (filePath.includes('tiger')) return 9
// Should never get here
console.error('Unrecognized folder')
process.exit(1)
}
一旦将图片转换为张量形式,你可能会考虑堆叠和返回它们,但在此之前至关重要的是在混洗之前对它们进行混洗。如果不混合数据,你的模型将会以最奇怪的方式快速训练。请容我用一个奇特的比喻。
想象一下,如果我让你在一堆形状中指出我在想的形状。你很快就会发现我总是在想圆形,然后你开始获得 100%的准确率。在我们的第三次测试中,我开始说,“不,那不是正方形!你错得很离谱。”于是你开始指向正方形,再次获得 100%的准确率。每三次测试,我都会改变形状。虽然你的分数超过 99%的准确率,但你从未学会选择哪个形状的实际指标。因此,当形状每次都在变化时,你就会失败。你从未学会指标,因为数据没有被混洗。
未经混洗的数据将产生相同的效果:训练准确率接近完美,而验证和测试分数则很差。即使你对每个数据集进行混洗,大部分时间你只会对相同的 256 个值进行混洗。
要对 X 和 Y 进行相同的排列混洗,可以使用tf.utils.shuffleCombo。我听说向 TensorFlow.js 添加此功能的人非常酷。
// Shuffle the data (keep XS[n] === YS[n])
tf.util.shuffleCombo(XS, YS)
由于这是对 JavaScript 引用进行混洗,因此在此混洗中不会创建新的张量。
最后,您将希望将答案从整数转换为独热编码。独热编码是因为您的模型将使用 softmax,即 10 个值相加为 1,其中正确答案是唯一的主导值。
TensorFlow.js 有一个名为oneHot的方法,它将数字转换为独热编码的张量值。例如,从 5 个可能类别中的数字3将被编码为张量[0,0,1,0,0]。这就是我们希望格式化答案以匹配分类模型预期输出的方式。
现在,您可以将 X 和 Y 数组值堆叠成一个大张量,并通过除以255来将图像归一化为值0-1。堆叠和编码看起来像这样:
// Stack values
console.log('Stacking')
const X = tf.stack(XS)
const Y = tf.oneHot(YS, 10)
console.log('Images all converted to tensors:')
console.log('X', X.shape)
console.log('Y', Y.shape)
// Normalize X to values 0 - 1
const XNORM = X.div(255)
// cleanup
tf.dispose([XS, X])
由于处理数千个图像,您的计算机可能会在每个日志之间暂停。代码打印以下内容:
Stacking
Images all converted to tensors:
X [ 87541, 28, 28, 1 ]
Y [ 87541, 10 ]
现在我们有了用于训练的 X 和 Y,它们的形状是我们将创建的模型的输入和输出形状。
CNN 模型
现在是创建卷积神经网络模型的时候了。该模型的架构将是三对卷积和池化层。在每个新的卷积层上,我们将使滤波器的数量加倍。然后我们将将模型展平为一个具有 128 个单元的单个密集隐藏层,具有tanh激活,并以具有 softmax 激活的 10 个可能输出的最终层结束。如果您对为什么使用 softmax 感到困惑,请回顾我们在第九章中介绍的分类模型的结构。
您应该能够仅通过描述编写模型层,但这里是创建所描述的顺序模型的代码:
const model = tf.sequential()
// Conv + Pool combo
model.add(
tf.layers.conv2d({
filters: 16,
kernelSize: 3,
strides: 1,
padding: 'same',
activation: 'relu',
kernelInitializer: 'heNormal',
inputShape: [28, 28, 1],
})
)
model.add(tf.layers.maxPooling2d({ poolSize: 2, strides: 2 }))
// Conv + Pool combo
model.add(
tf.layers.conv2d({
filters: 32,
kernelSize: 3,
strides: 1,
padding: 'same',
activation: 'relu',
})
)
model.add(tf.layers.maxPooling2d({ poolSize: 2, strides: 2 }))
// Conv + Pool combo
model.add(
tf.layers.conv2d({
filters: 64,
kernelSize: 3,
strides: 1,
padding: 'same',
activation: 'relu',
})
)
model.add(tf.layers.maxPooling2d({ poolSize: 2, strides: 2 }))
// Flatten for connecting to deep layers
model.add(tf.layers.flatten())
// One hidden deep layer
model.add(
tf.layers.dense({
units: 128,
activation: 'tanh',
})
)
// Output
model.add(
tf.layers.dense({
units: 10,
activation: 'softmax',
})
)
这个新的用于非二进制分类数据的最终层意味着您需要将损失函数从binaryCrossentropy更改为categoricalCrossentropy。因此,现在model.compile的代码看起来像这样:
model.compile({
optimizer: 'adam',
loss: 'categoricalCrossentropy',
metrics: ['accuracy'],
})
让我们通过我们学到的关于卷积和最大池化的知识来审查model.summary()方法,以确保我们已经正确构建了一切。您可以在示例 10-2 中看到结果的打印输出。
示例 10-2。model.summary()的输出
_________________________________________________________________
Layer (type) Output shape Param #
=================================================================
conv2d_Conv2D1 (Conv2D) [null,28,28,16] 160 // ①
_________________________________________________________________
max_pooling2d_MaxPooling2D1 [null,14,14,16] 0 // ②
_________________________________________________________________
conv2d_Conv2D2 (Conv2D) [null,14,14,32] 4640 // ③
_________________________________________________________________
max_pooling2d_MaxPooling2D2 [null,7,7,32] 0
_________________________________________________________________
conv2d_Conv2D3 (Conv2D) [null,7,7,64] 18496 // ④
_________________________________________________________________
max_pooling2d_MaxPooling2D3 [null,3,3,64] 0
_________________________________________________________________
flatten_Flatten1 (Flatten) [null,576] 0 // ⑤
_________________________________________________________________
dense_Dense1 (Dense) [null,128] 73856 // ⑥
_________________________________________________________________
dense_Dense2 (Dense) [null,10] 1290 // ⑦
=================================================================
Total params: 98442
Trainable params: 98442
Non-trainable params: 0
_________________________________________________________________
①
第一个卷积层的输入为[stacksize, 28, 28, 1],卷积输出为[stacksize, 28, 28, 16]。大小相同是因为我们使用了padding: 'same',而 16 是当我们指定filters: 16时得到的 16 个不同的滤波器结果。您可以将其视为每个堆栈中的每个图像的 16 个新滤波图像。这为网络提供了 160 个新参数进行训练。可训练参数的计算方式为(图像数量) * (卷积核窗口) * (输出图像) + (输出图像),计算结果为1 * (3x3) * 16 + 16 = 160。
②
最大池化将滤波后的图像行和列大小减半,从而将像素分成四分之一。由于算法是固定的,因此此层没有任何可训练参数。
③
卷积和池化再次发生,并且在每个级别都使用更多的滤波器。图像的大小正在缩小,可训练参数的数量迅速增长,即16 * (3x3) * 32 + 32 = 4,640。
④
在这里,有一个最终的卷积和池化。池化奇数会导致大于 50%的减少。
⑤
将 64 个 3 x 3 图像展平为一个由 576 个单元组成的单层。
⑥
这 576 个单元中的每一个都与 128 个单元的层密切连接。使用传统的线+节点计算,这将得到(576 * 128) + 128 = 73,856个可训练参数。
⑦
最后,最后一层有 10 个可能的值对应每个类别。
您可能想知道为什么我们评估model.summary()而不是检查正在发生的事情的图形表示。即使在较低的维度,图形表示正在发生的事情也很难说明。我已经尽力在图 10-11 中创建了一个相对详尽的插图。
![CNN 神经网络]()
图 10-11. 每一层的可视化
与以往的神经网络图不同,CNN 的可视化解释可能有些局限性。堆叠在一起的滤波图像只能提供有限的信息。卷积过程的结果被展平并连接到一个深度连接的神经网络。您已经达到了一个复杂性的程度,summary()方法是理解内容的最佳方式。
提示
如果您想要一个动态的可视化,并观看每个训练滤波器在每一层的激活结果,数据科学 Polo Club 创建了一个美丽的CNN 解释器在 TensorFlow.js 中。查看交互式可视化器。
你已经到了那里。您的结果[3, 3, 64]在连接到神经网络之前展平为 576 个人工神经元。您不仅创建了图像特征,还简化了一个[28, 28, 1]图像的输入,原本需要 784 个密集连接的输入。有了这种更先进的架构,您可以从folderToTensors()加载数据并创建必要的模型。您已经准备好训练了。
训练和保存
由于这是在 Node.js 中进行训练,您将不得不直接在机器上设置 GPU 加速。这通常是通过 NVIDIA CUDA 和 CUDA 深度神经网络(cuDNN)完成的。如果您想使用@tensorflow/tfjs-node-gpu进行训练并获得比普通tfjs-node更快的速度提升,您将不得不正确设置 CUDA 和 cuDNN 以与您的 GPU 一起工作。请参阅图 10-12。
![CUDA GPU 截图]()
图 10-12. 使用 GPU 可以提高 3-4 倍的速度
在 20 个时代之后,生成的模型在训练中的准确率约为 95%,在验证集中的准确率约为 90%。生成模型的文件大小约为 400 KB。您可能已经注意到训练集的准确率不断上升,但验证集有时会下降。不管好坏,最后一个时代将是保存的模型。如果您想确保最高可能的验证准确性,请查看最后的章节挑战。
注意
如果您对这个模型运行了太多时代,模型将过拟合,并接近 100%的训练准确率,而验证准确率会降低。
测试模型
要测试这个模型,您需要用户的绘图。您可以在网页上创建一个简单的绘图表面,使用一个画布。画布可以订阅鼠标按下时、鼠标沿着画布移动时以及鼠标释放时的事件。使用这些事件,您可以从一个点绘制到另一个点。
构建一个草图板
您可以使用这三个事件构建一个简单的可绘制画布。您将使用一些新方法来移动画布路径和绘制线条,但这是相当易读的。以下代码设置了一个:
const canvas = document.getElementById("sketchpad");
const context = canvas.getContext("2d");
context.lineWidth = 14;
context.lineCap = "round";
let isIdle = true;
function drawStart(event) {
context.beginPath();
context.moveTo(
event.pageX - canvas.offsetLeft,
event.pageY - canvas.offsetTop
);
isIdle = false;
}
function drawMove(event) {
if (isIdle) return;
context.lineTo(
event.pageX - canvas.offsetLeft,
event.pageY - canvas.offsetTop
);
context.stroke();
}
function drawEnd() { isIdle = true; }
// Tie methods to events
canvas.addEventListener("mousedown", drawStart, false);
canvas.addEventListener("mousemove", drawMove, false);
canvas.addEventListener("mouseup", drawEnd, false);
这些图纸是由一堆较小的线条制成的,线条的笔画宽度为 14 像素,并且在边缘自动圆润。您可以在图 10-13 中看到一个测试绘图。
![示例绘图]()
图 10-13. 运行得足够好
当用户在画布上单击鼠标时,任何移动都将从一个点绘制到新点。每当用户停止绘制时,将调用drawEnd函数。您可以添加一个按钮来对画布进行分类,或者直接将其连接到drawEnd函数并对图像进行分类。
阅读草图板
当你在画布上调用 tf.browser.fromPixels 时,你会得到 100% 的黑色像素。为什么会这样?答案是画布的某些地方没有绘制任何内容,而其他地方是黑色像素。当画布转换为张量值时,它会将空白转换为黑色。画布可能看起来有白色背景,但实际上是透明的,会显示底部的任何颜色或图案(参见 图 10-14)。
![一个空的画布]()
图 10-14. 一个画布是透明的,所以空白像素为零
为了解决这个问题,你可以添加一个清除函数,在画布上绘制一个大的白色正方形,这样黑色线条就会在白色背景上,就像训练图像一样。这也是你可以在绘画之间清除画布的函数。要用白色背景填充画布,你可以使用 fillRect 方法,就像你在 第六章 中用来勾画标签的方法一样。
context.fillStyle = "#fff";
context.fillRect(0, 0, canvas.clientWidth, canvas.clientHeight);
一旦画布用白色背景初始化,你就可以对画布绘制进行预测了。
async function makePrediction(canvas, model) {
const drawTensor = tf.browser.fromPixels(canvas, 1) // ①
const resize = tf.image.resizeNearestNeighbor(drawTensor, [28,28], true) // ②
const batched = resize.expandDims() // ③
const results = await model.predict(batched)
const predictions = await results.array()
// Display
displayResults(predictions[0]) // ④
// Cleanup
tf.dispose([drawTensor, resize, batched, results])
}
①
当你读取画布时,不要忘记标识你只对单个通道感兴趣;否则,你需要在继续之前将 3D 张量转换为 1D 张量。
②
使用最近邻算法将图像调整为 28 x 28 的大小,以输入到模型中。最近邻引起的像素化在这里并不重要,所以这是一个明智的选择,因为它比 resizeBilinear 更快。
③
模型期望一个批次数据,所以准备数据作为一个批次的数据。这将创建一个 [1, 28, 28, 1] 的输入张量。
④
预测结果已经作为一个包含 10 个数字的批次返回到 JavaScript。想出一种创造性的方式来展示结果。
现在你已经得到了结果,你可以以任何你喜欢的格式展示答案。我个人是按照房间组织了分数,并用它们来设置标签的不透明度。这样,你可以在每画一条线时得到反馈。标签的不透明度取决于值 0-1,这与 softmax 预测结果非常契合。
function displayResults(predictions) {
// Get Scores
const ravenclaw = predictions[0] + predictions[2] + predictions[3]
const gryffindor = predictions[1] + predictions[9]
const hufflepuff = predictions[4] + predictions[8]
const slytherin = predictions[6] + predictions[7]
const deatheater = predictions[5]
document.getElementById("ravenclaw").style.opacity = ravenclaw
document.getElementById("gryffindor").style.opacity = gryffindor
document.getElementById("hufflepuff").style.opacity = hufflepuff
document.getElementById("slytherin").style.opacity = slytherin
// Harry Potter fans will enjoy this one
if (deatheater > 0.9) {
alert('DEATH EATER DETECTED!!!')
}
}
你可能会想知道拉文克劳是否有轻微的数学优势,因为它由更多类别组成,你是对的。在所有条件相同的情况下,一组完全随机的线更有可能被分类为拉文克劳,因为它拥有大多数类别。然而,当图像不是随机的时,这在统计上是不显著的。如果你希望模型只有九个类别,可以移除 bird 并重新训练,以创建最平衡的分类谱。
提示
如果你有兴趣确定哪些类别可能存在问题或混淆,你可以使用视觉报告工具,如混淆矩阵或 t-SNE 算法。这些工具对于评估训练数据特别有帮助。
我强烈建议你从chapter10/simple/simplest-draw加载本章的代码,并测试一下你的艺术技能!我的鸟类绘画将我分类到了拉文克劳,如 图 10-15 所示。
![网页正确识别出一只鸟]()
图 10-15. 一个 UI 和绘画杰作
我能够糟糕地画出并被正确分类到其他可能的房间中。然而,我不会再用我的“艺术”来惩罚你。
章节回顾
你已经在视觉数据上训练了一个模型。虽然这个数据集仅限于灰度图,但你所学到的经验可以适用于任何图像数据集。有很多优秀的图像数据集可以与你创建的模型完美配合。我们将在接下来的两章中详细介绍。
我为本章中的绘画识别特色创建了一个更加复杂的页面。
章节挑战:保存魔法
如果您最感兴趣的是获得最高验证准确性模型,那么您的最佳模型版本很可能不是最后一个版本。例如,如果您查看图 10-16,90.3%的验证准确性会丢失,最终验证模型为 89.6%。
对于本章挑战,与其保存模型的最终训练版本,不如添加一个回调函数,当验证准确性达到新的最佳记录时保存模型。这种代码非常有用,因为它允许您运行多个时期。随着模型过拟合,您将能够保留最佳的通用模型用于生产。
![验证与训练准确性]()
图 10-16。评估哪个准确性更重要
您可以在附录 B 中找到此挑战的答案。
复习问题
让我们回顾一下你在本章编写的代码中学到的教训。花点时间回答以下问题:
-
卷积层有许多可训练的什么,可以帮助提取图像的特征?
-
控制卷积窗口大小的属性名称是什么?
-
如果你希望卷积结果与原始图像大小相同,应该将填充设置为什么?
-
真或假?在将图像插入卷积层之前,必须将其展平。
-
在 81 x 81 图像上,步幅为 3 的最大池 3 x 3 的输出大小将是多少?
-
如果您要对数字 12 进行独热编码,您是否有足够的信息来这样做?
这些练习的解决方案可以在附录 A 中找到。
¹ YouTube 上的3Blue1Brown的视频和讲座是任何想要深入了解卷积的人的绝佳起点。
² TensorFlow.js 中还有其他可用于实验的池化方法。
第十一章:迁移学习
“向他人的错误学习。你活不到足够长的时间来犯所有的错误。”
—埃莉诺·罗斯福
拥有大量数据、经过实战检验的模型结构和处理能力可能是具有挑战性的。能不能简单点?在第七章中,您可以使用 Teachable Machine 将训练好的模型的特质转移到新模型中,这是非常有用的。事实上,这是机器学习世界中的一个常见技巧。虽然 Teachable Machine 隐藏了具体细节,只提供了一个模型,但您可以理解这个技巧的原理,并将其应用于各种酷炫的任务。在本章中,我们将揭示这个过程背后的魔法。虽然我们将简单地以 MobileNet 为例,但这可以应用于各种模型。
迁移学习是指将训练好的模型重新用于第二个相关任务。
使用迁移学习为您的机器学习解决方案带来一些可重复的好处。大多数项目出于以下原因利用一定程度的迁移学习:
-
重复使用经过实战检验的模型结构
-
更快地获得解决方案
-
通过较少的数据获得解决方案
在本章中,您将学习几种迁移学习策略。您将专注于 MobileNet 作为一个基本示例,可以以各种方式重复使用来识别各种新类别。
我们将:
-
回顾迁移学习的工作原理
-
看看如何重复使用特征向量
-
将模型切割成层并重构新模型
-
了解 KNN 和延迟分类
完成本章后,您将能够将长时间训练并具有大量数据的模型应用于您自己的需求,即使只有较小的数据集。
迁移学习是如何工作的?
一个经过不同数据训练的模型如何突然对您的新数据起作用?听起来像奇迹,但这在人类中每天都发生。
您花了多年时间识别动物,可能看过数百只骆驼、天竺鼠和海狸的卡通、动物园和广告。现在我将向您展示一种您可能很少见到甚至从未见过的动物。图 11-1 中的动物被称为水豚(Hydrochoerus hydrochaeris)。
![水豚的侧面]()
图 11-1。水豚
对于你们中的一些人,这可能是第一次(或者是少数几次)看到水豚的照片。现在,看看图 11-2 中的阵容。你能找到水豚吗?
![三种哺乳动物测验]()
图 11-2。哪一个是水豚?
一张单独的照片的训练集足以让您做出选择,因为您一生中一直在区分动物。即使是新的颜色、角度和照片尺寸,您的大脑可能也能绝对确定地检测到动物 C 是另一只水豚。您多年的经验学习到的特征帮助您做出了明智的决定。同样地,具有丰富经验的强大模型可以被教导从少量新数据中学习新事物。
迁移学习神经网络
让我们暂时回到 MobileNet。MobileNet 模型经过训练,可以识别区分一千种物品之间的特征。这意味着有卷积来检测毛发、金属、圆形物体、耳朵以及各种关键的差异特征。所有这些特征在被压缩和简化之前都被吸收到一个神经网络中,各种特征的组合形成了分类。
MobileNet 模型可以识别不同品种的狗,甚至可以区分马耳他犬和西藏犬。如果您要制作一个“狗还是猫”分类器,那么在您更简单的模型中,大多数这些高级特征是可以重复使用的。
先前学习的卷积滤波器在识别全新分类的关键特征方面非常有用,就像我们在图 11-2 中的水豚示例一样。关键是将模型的特征识别部分提取出来,并将自己的神经网络应用于卷积输出,如图 11-3 所示。
![更改 NN 流程图]()
图 11-3。CNN 迁移学习
那么如何分离和重新组合先前训练模型的这些部分呢?您有很多选择。再次,我们将学习更多关于图和层模型的知识。
简单的 MobileNet 迁移学习
幸运的是,TensorFlow Hub已经有一个与任何神经网络断开连接的 MobileNet 模型。它为您提供了一半的模型用于迁移学习。一半的模型意味着它还没有被绑定到最终的 softmax 层来进行分类。这使我们可以让 MobileNet 推导出图像的特征,然后为我们提供张量,然后我们可以将这些张量传递给我们自己训练的网络进行分类。
TFHub 将这些模型称为图像特征向量模型。您可以缩小搜索范围,只显示这些模型,或者通过查看问题域标签来识别它们,如图 11-4 所示。
![正确标签的截图]()
图 11-4。图像特征向量的问题域标签
您可能会注意到 MobileNet 的小变化,并想知道差异是什么。一旦您了解了一些诡计术语,每个模型描述都会变得非常可读。
例如,我们将使用示例 11-1。
示例 11-1。图像特征向量模型之一
imagenet/mobilenet_v2_130_224/feature_vector
imagenet
这个模型是在 ImageNet 数据集上训练的。
mobilenet_v2
该模型的架构是 MobileNet v2。
130
该模型的深度乘数为 1.30。这会产生更多的特征。如果您想加快速度,可以选择“05”,这将减少一半以下的特征输出并提高速度。这是一个微调选项,当您准备好修改速度与深度时可以使用。
224
该模型的预期输入尺寸为 224 x 224 像素的图像。
feature_vector
我们已经从标签中了解到,但这个模型输出的张量是为了作为图像特征的第二个模型来解释。
现在我们有一个经过训练的模型,可以识别图像中的特征,我们将通过 MobileNet 图像特征向量模型运行我们的训练数据,然后在输出上训练一个模型。换句话说,训练图像将变成一个特征向量,我们将训练一个模型来解释该特征向量。
这种策略的好处是实现起来很简单。主要缺点是当您准备使用新训练的模型时,您将不得不加载两个模型(一个用于生成特征,一个用于解释)。创造性地,可能有一些情况下“特征化”图像然后通过多个神经网络运行可能非常有用。无论如何,让我们看看它的实际效果。
TensorFlow Hub 检查,对手!
我们将使用 MobileNet 进行迁移学习,以识别像图 11-5 中所示的国际象棋棋子。
![桌子上的国际象棋骑士的图像]()
图 11-5。简单的国际象棋棋子分类器
您只会有每个国际象棋棋子的几张图像。通常这是不够的,但通过迁移学习的魔力,您将得到一个高效的模型。
加载国际象棋图像
为了这个练习,我已经编译了一个包含 150 张图像的集合,并将它们加载到 CSV 文件中以便快速使用。在大多数情况下,我不建议这样做,因为这对于处理和磁盘空间是低效的,但它可以作为一种简单的向量,用于一些快速的浏览器训练。现在加载这些图像的代码是微不足道的。
注意
你可以访问象棋图像和将它们转换为 CSV 文件的代码在chapter11/extra/node-make-csvs文件夹中。
文件 chess_labels.csv 和 chess_images.csv 可以在与本课程相关的chess_data.zip文件中找到。解压这个文件并使用 Danfo.js 加载内容。
许多浏览器可能会在同时读取所有 150 个图像时出现问题,所以我限制了演示只处理 130 个图像。与并发数据限制作斗争是机器学习中常见的问题。
注意
一旦图像被提取特征,它所占用的空间就会少得多。随意尝试批量创建特征,但这超出了本章的范围。
图像已经是 224 x 224,所以你可以用以下代码加载它们:
console.log("Loading huge CSV - this will take a while");
const numImages = 130; // between 1 and 150 // Get Y values const labels = await dfd.read_csv("chess_labels.csv", numImages); // ①
const Y = labels.tensor; // ②
// Get X values (Chess images) const chessImages = await dfd.read_csv("chess_images.csv", numImages);
const chessTensor = chessImages.tensor.reshape([
labels.shape[0], 224, 224, 3, // ③
]);
console.log("Finished loading CSVs", chessTensor.shape, Y.shape);
①
read_csv的第二个参数限制了行数到指定的数字。
②
然后将 DataFrames 转换为张量。
③
图像被展平以变成序列化,但现在被重新塑造成一个四维的 RGB 图像批次。
经过一段时间,这段代码会打印出 130 个准备好的图像和编码的 X 和 Y 形状:
Finished loading CSVs (4) [130, 224, 224, 3] (2) [130, 6]
如果你的计算机无法处理 130 个图像,你可以降低numImages变量,仍然可以继续。然而,CSV 文件的加载时间始终是恒定的,因为整个文件必须被处理。
提示
象棋棋子这样的图像非常适合进行图像增强,因为扭曲棋子永远不会导致一个棋子被误认为是另一个。如果你需要更多的图像,你可以镜像整个集合,有效地将你的数据翻倍。存在整个库来镜像、倾斜和扭曲图像,这样你就可以创建更多数据。
加载特征模型
你可以像加载 TensorFlow Hub 中的任何模型一样加载特征模型。你可以通过模型进行预测,结果将是numImages个预测。代码看起来像示例 11-2。
示例 11-2. 加载和使用特征向量模型
// Load feature model
const tfhubURL =
"https://oreil.ly/P2t2k";
const featureModel = await tf.loadGraphModel(tfhubURL, {
fromTFHub: true,
});
const featureX = featureModel.predict(chessTensor);
// Push data through feature detection
console.log(`Features stack ${featureX.shape}`);
控制台日志的输出是
Features stack 130,1664
每个 130 个图像已经变成了一组 1,664 个浮点值,这些值对图像的特征敏感。如果你改变模型以使用不同的深度,特征的数量也会改变。1,664 这个数字是 MobileNet 1.30 深度版本独有的。
如前所述,1,664 个Float32特征集比每个图像的224*224*3 = 150,528个Float32输入要小得多。这将加快训练速度,并对计算机内存更友好。
创建你自己的神经网络
现在你有了一组特征,你可以创建一个新的完全未经训练的模型,将这 1,664 个特征与你的标签相匹配。
示例 11-3. 一个包含 64 层的小模型,最后一层是 6
// Create NN const transferModel = tf.sequential({
layers: [ // ①
tf.layers.dense({
inputShape: [featureX.shape[1]], // ②
units: 64,
activation: "relu",
}),
tf.layers.dense({ units: 6, activation: "softmax" }),
],
});
①
这个 Layers 模型使用了一个与你习惯的略有不同的语法。而不是调用.add,所有的层都被呈现在初始配置的数组中。这种语法对于像这样的小模型很好。
②
模型的inputShape被动态设置为1,664,以防你想通过更新模型 URL 来改变模型的深度乘数。
训练结果
在训练代码中没有什么新的。模型基于特征输出进行训练。由于特征输出与原始图像张量相比非常小,训练速度非常快。
transferModel.compile({
optimizer: "adam",
loss: "categoricalCrossentropy",
metrics: ["accuracy"],
});
await transferModel.fit(featureX, Y, {
validationSplit: 0.2,
epochs: 20,
callbacks: { onEpochEnd: console.log },
});
几个周期后,模型的准确率就会非常出色。查看图 11-6。
![迁移学习结果]()
图 11-6. 从 50%到 96%的验证准确率在 20 个周期内
在 TensorFlow Hub 上使用现有模型进行迁移学习可以减轻架构方面的困扰,并为你带来高准确性。但这并不是你实现迁移学习的唯一方式。
利用 Layers 模型进行迁移学习
之前的方法存在一些明显和不那么明显的限制。首先,特征模型无法进行训练。你所有的训练都是在一个消耗图模型特征的新模型上进行的,但卷积层和大小是固定的。你可以使用卷积网络模型的小变体,但无法更新或微调它。
之前来自 TensorFlow Hub 的模型是一个图模型。图模型被优化用于速度,并且无法修改或训练。另一方面,Layers 模型则适用于修改,因此你可以将它们重新连接以进行迁移学习。
此外,在之前的示例中,每次需要对图像进行分类时,实际上都在处理两个模型。你需要加载两个 JSON 模型,并将图像通过特征模型和新模型以对图像进行分类。这并不是世界末日,但通过组合 Layers 模型可以实现单一模型。
让我们再次解决同样的国际象棋问题,但这次使用 Layers 版本的 MobileNet,这样我们可以检查差异。
在 MobileNet 上修剪层
在这个练习中,你将使用一个设置为 Layers 模型的 MobileNet v1.0 版本。这是 Teachable Machine 使用的模型,虽然对于小型探索性项目来说已经足够了,但你会注意到它不如深度为 1.30 的 MobileNet v2 准确。你已经熟悉了使用向导转换模型的方法,就像你在第七章中学到的那样,所以在需要时你可以创建一个更大、更新的 Layers 模型。准确性是一个重要的指标,但在寻找迁移模型时,它远非唯一的评估指标。
MobileNet 有大量的层,其中一些是你以前从未见过的。让我们来看一下。加载与本章相关联的 MobileNet 模型,并使用model.summary()来查看层的摘要。这会打印出一个庞大的层列表。不要感到不知所措。当你从底部向上阅读时,最后两个带有激活的卷积层被称为conv_preds和conv_pw_13_relu:
...
conv_pw_13 (Conv2D) [null,7,7,256] 65536
_________________________________________________________________
conv_pw_13_bn (BatchNormaliz [null,7,7,256] 1024
_________________________________________________________________
conv_pw_13_relu (Activation) [null,7,7,256] 0
_________________________________________________________________
global_average_pooling2d_1 ( [null,256] 0
_________________________________________________________________
reshape_1 (Reshape) [null,1,1,256] 0
_________________________________________________________________
dropout (Dropout) [null,1,1,256] 0
_________________________________________________________________
conv_preds (Conv2D) [null,1,1,1000] 257000
_________________________________________________________________
act_softmax (Activation) [null,1,1,1000] 0
_________________________________________________________________
reshape_2 (Reshape) [null,1000] 0
=================================================================
Total params: 475544
Trainable params: 470072
Non-trainable params: 5472
最后一个卷积层conv_preds作为将特征展平到 1,000 个可能类别的flatten层。这在一定程度上是特定于模型训练的类别,因此因此我们将跳到第二个卷积层(conv_pw_13_relu)并在那里裁剪。
MobileNet 是一个复杂的模型,即使你不必理解所有的层来用它进行迁移学习,但在决定移除哪些部分时还是需要一些技巧。在更简单的模型中,比如即将到来的章节挑战中的模型,通常会保留整个卷积工作流程,并在 flatten 层进行裁剪。
你可以通过知道其唯一名称来裁剪到一个层。示例 11-4 中显示的代码在GitHub 上可用。
示例 11-4。
const featureModel = await tf.loadLayersModel('mobilenet/model.json')
console.log('ORIGINAL MODEL')
featureModel.summary()
const lastLayer = featureModel.getLayer('conv_pw_13_relu')
const shavedModel = tf.model({
inputs: featureModel.inputs,
outputs: lastLayer.output,
})
console.log('SHAVED DOWN MODEL')
shavedModel.summary()
示例 11-4 中的代码打印出两个大模型,但关键区别在于第二个模型突然在conv_pw_13_relu处停止。
现在最后一层是我们确定的那一层。当你查看修剪后模型的摘要时,它就像一个特征提取器。有一个应该注意的关键区别。最后一层是一个卷积层,因此你构建的迁移模型的第一层应该将卷积输入展平,以便可以与神经网络进行密集连接。
层特征模型
现在你可以将修剪后的模型用作特征模型。这将为你带来与 TFHub 相同的双模型系统。你的第二个模型需要读取conv_pw_13_relu的输出:
// Create NN
const transferModel = tf.sequential({
layers: [
tf.layers.flatten({ inputShape: featureX.shape.slice(1) }),
tf.layers.dense({ units: 64, activation: 'relu' }),
tf.layers.dense({ units: 6, activation: 'softmax' }),
],
})
我们正在设置由中间特征定义的形状。这也可以直接与修剪模型的输出形状相关联(shavedModel.outputs[0].shape.slice(1))。
从这里开始,您又回到了 TFHub 模型的起点。基础模型创建特征,第二个模型解释这些特征。
使用这两个层进行训练可以实现大约 80%以上的准确率。请记住,我们使用的是完全不同的模型架构(这是 MobileNet v1)和较低的深度乘数。从这个粗糙模型中至少获得 80%是不错的。
统一模型
就像特征向量模型一样,您的训练只能访问几层,并且不会更新卷积层。现在您已经训练了两个模型,可以将它们的层再次统一到一个单一模型中。您可能想知道为什么在训练后而不是之前将模型合并。在训练新层时,将您的特征层锁定或“冻结”到其原始权重是一种常见做法。
一旦新层得到训练,通常可以“解冻”更多层,并一起训练新的和旧的。这个阶段通常被称为微调模型。
那么现在如何统一这两个模型呢?答案出奇地简单。创建第三个顺序模型,并使用model.add添加两个模型。代码如下:
// combine the models
const combo = tf.sequential()
combo.add(shavedModel)
combo.add(transferModel)
combo.compile({
optimizer: 'adam',
loss: 'categoricalCrossentropy',
metrics: ['accuracy'],
})
combo.summary()
新的combo模型可以下载或进一步训练。
如果在训练新层之前将模型合并,您可能会看到您的模型过度拟合数据。
无需训练
值得注意的是,有一种巧妙的方法可以使用两个模型进行零训练的迁移学习。诀窍是使用第二个模型来识别相似性中的距离。
第二个模型称为 K-最近邻(KNN)¹模型,它将数据元素与特征空间中 K 个最相似的数据元素分组在一起。成语“物以类聚”是 KNN 的前提。
在图 11-7 中,X 将被识别为兔子,因为特征中的三个最近示例也是兔子。
![特征距离]()
图 11-7。在特征空间中识别邻居
KNN 有时被称为基于实例的学习或惰性学习,因为你将所有必要的处理移动到数据分类的时刻。这种不同的模型很容易更新。您可以始终动态地添加更多图像和类别,以定义边缘情况或新类别,而无需重新训练。成本在于特征图随着添加的每个示例而增长,而不像单个训练模型的固定空间。您向 KNN 解决方案添加的数据点越多,伴随模型的特征集就会变得越大。
此外,由于没有训练,相似性是唯一的度量标准。这使得这个系统对于某些问题来说并不理想。例如,如果您试图训练一个模型来判断人们是否戴着口罩,那么您需要一个模型专注于单个特征而不是多个特征的集合。穿着相同的两个人可能具有更多相似之处,因此可能会被放在 KNN 中的同一类别中。要使 KNN 在口罩上起作用,您的特征向量模型必须是面部特定的,训练模型可以学习区分模式。
简单的 KNN:兔子对运动汽车
KNN,就像 MobileNet 一样,由 Google 提供了一个 JS 包装器。我们可以通过隐藏所有复杂细节,使用 MobileNet 和 KNN NPM 包快速实现 KNN 迁移学习,以制作一个快速的迁移学习演示。
我们不仅要避免运行任何训练,还要使用现有库来避免深入研究 TensorFlow.js。我们将为一个引人注目的演示而这样做,但如果您决定使用这些模型构建更健壮的东西,您可能应该考虑避免使用您无法控制的抽象包。您已经了解了迁移学习的所有内部工作原理。
为了进行这个快速演示,您将导入三个 NPM 模块:
<script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@2.7.0/dist/tf.min.js">
</script>
<script src="https://cdn.jsdelivr.net/npm/@tensorflow-models/mobilenet@2.0">
</script>
<script
src="https://cdn.jsdelivr.net/npm/@tensorflow-models/knn-classifier@1.2.2">
</script>
为了简单起见,本章的示例代码中所有的图像都在页面上,因此您可以直接引用它们。现在您可以使用mobileNet = await mobilenet.load();加载 MobileNet,并使用knnClassifier.create();加载 KNN 分类器。
KNN 分类器需要每个类别的示例。为了简化这个过程,我创建了以下辅助函数:
// domID is the DOM element ID // classID is the unique class index function addExample(domID, classID) {
const features = mobileNet.infer( // ①
document.getElementById(domID), // ②
true // ③
);
classifier.addExample(features, classID);
}
①
infer方法返回值,而不是富 JavaScript 对象的检测。
②
页面上的图像id将告诉 MobileNet 要调整大小和处理哪个图像。张量逻辑被 JavaScript 隐藏,但本书的许多章节已经解释了实际发生的事情。
③
MobileNet 模型返回图像的特征(有时称为嵌入)。如果未设置,则返回 1,000 个原始值的张量(有时称为对数)。
现在您可以使用这个辅助方法为每个类别添加示例。您只需命名图像元素的唯一 DOM ID 以及应与之关联的类别。添加三个示例就像这样简单:
// Add examples of two classes
addExample('bunny1', 0)
addExample('bunny2', 0)
addExample('bunny3', 0)
addExample('sport1', 1)
addExample('sport2', 1)
addExample('sport3', 1)
最后,预测的系统是相同的。获取图像的特征,并要求分类器根据 KNN 识别输入基于哪个类。
// Moment of truth const testImage = document.getElementById('test')
const testFeature = mobileNet.infer(testImage, true);
const predicted = await classifier.predictClass(testFeature)
if (predicted.classIndex === 0) { // ①
document.getElementById("result").innerText = "A Bunny" // ②
} else {
document.getElementById("result").innerText = "A Sports Car"
}
①
classIndex是作为addExample中传递的数字。如果添加第三个类别,那么新的索引将成为可能的输出。
②
网页文本从“???”更改为结果。
结果是 AI 可以通过与六个示例进行比较来识别新图像的正确类别,如图 11-8 所示。
![AI 页面的截图]()
图 11-8. 仅有每个类别三张图像,KNN 模型预测正确
您可以动态地添加更多类别。KNN 是一种令人兴奋且可扩展的方式,通过迁移学习利用先进模型的经验。
章节回顾
因为本章已经解释了使用 MobileNet 进行迁移学习的神秘,现在您可以将这种增强功能应用于您可以在一定程度上理解的任何现有模型。也许您想调整宠物面孔模型以查找卡通或人脸。您不必从头开始!
迁移学习为您的 AI 工具箱增加了新的实用功能。现在,当您在野外找到一个新模型时,您可以问自己如何直接使用它,以及如何将其用于类似的迁移学习。
章节挑战:光速学习
上一章中的霍格沃茨分选模型在卷积层中有数千张黑白绘画图像的经验。不幸的是,这些数千张图像仅限于动物和头骨。它们与星际迷航无关。不要担心;只需使用大约 50 张新图像,您就可以重新训练上一章的模型,以识别图 11-9 中显示的三个星际迷航符号。
![几个时代内的完美验证准确性]()
图 11-9. 星际迷航符号
将相位设置为有趣,并使用本章学到的方法来获取您在第十章中训练的 Layers 模型(或从相关的书源代码下载已训练的模型),并训练一个新模型,可以从仅有几个示例中识别这些图像。
新的训练图像数据可以在相关书籍源代码中以 CSV 形式找到。训练图像数据已经放在 CSV 中,因此您可以使用 Danfo.js 轻松导入它。文件是images.csv和labels.csv。
您可以在附录 B 中找到这个挑战的答案。
复习问题
让我们回顾一下您在本章编写的代码中学到的教训。花点时间回答以下问题:
-
KNN 代表什么?
-
每当您有一个小的训练集时,存在什么危险?
-
当您在 TensorFlow Hub 上寻找 CNN 模型的卷积部分时,您要寻找哪个标签?
-
哪个深度乘数会产生更广泛的特征输出,0.50 还是 1.00?
-
您可以调用 MobileNet NPM 模块的哪种方法来收集图像的特征嵌入?
-
您应该先组合您的转移模型部分然后训练,还是先训练然后组合您的模型?
-
当您在卷积层上切割模型时,在将该信息导入神经网络的密集层之前,您需要做什么?
这些练习的解决方案可以在附录 A 中找到。
¹ KNN 是由 Evelyn Fix 和 Joseph Hodges 于 1951 年开发的。
第十二章:骰子化:顶点项目
“每个人都有一个计划,直到他们被打在嘴巴上。”
—铁拳迈克·泰森
你的所有训练使你通过各种理论和练习。现在,你已经知道足够多,可以提出一个计划,在 TensorFlow.js 中为机器学习构建新的创意用途。在这一章中,你将开发你的顶点项目。与其用 TensorFlow.js 学习另一个机器学习方面,不如在这一章开始时接受一个挑战,并利用你现有的技能构建一个可行的解决方案。从构思到完成,这一章将指导你解决问题的执行。无论这是你第一本机器学习书籍还是第十本,这个顶点项目是你展现才华的时刻。
我们将:
-
研究问题
-
创建和增强数据
-
训练一个能解决问题的模型
-
在网站中实施解决方案
当你完成这一章时,你将运用从头到尾的技能来解决一个有趣的机器学习项目。
一个具有挑战性的任务
我们将利用你新发现的技能来模糊艺术和科学之间的界限。工程师们多年来一直在利用机器进行令人印象深刻的视觉壮举。最值得注意的是,暗箱相机技术(如图 12-1 所示)让疯狂的科学家们可以用镜头和镜子追踪实景。¹
![人看着黑匣子相机暗箱]()
图 12-1。相机暗箱
如今,人们正在用最奇怪的东西制作艺术品。在我的大学,艺术系用便利贴像素创造了一个完整的《超级马里奥兄弟》场景。虽然我们中有些人有艺术的神启,但其他人可以通过发挥他们的其他才能制作类似的作品。
你的挑战,如果你选择接受并从这本书中学到尽可能多的东西,就是教会人工智能如何使用骰子绘画。通过排列六面骰子并选择正确的数字显示,你可以复制任何图像。艺术家们会购买数百个骰子,并利用他们的技能重新创作图像。在这一章中,你将运用你学到的所有技能,教会人工智能如何将图像分解成骰子艺术,如图 12-2 所示。
![图像转换为骰子版本]()
图 12-2。将图形转换为骰子
一旦你的人工智能能够将黑白图像转换为骰子,你可以做很多事情,比如创建一个酷炫的网络摄像头滤镜,制作一个出色的网站,甚至为自己打印一个有趣的手工艺项目的说明。
在继续之前花 10 分钟,策划如何利用你的技能从零开始构建一个体面的图像到骰子转换器。
计划
理想情况下,你想到了与我类似的东西。首先,你需要数据,然后你需要训练一个模型,最后,你需要创建一个利用训练模型的网站。
数据
虽然骰子并不是非常复杂,但每个像素块应该是什么并不是一个现有的数据集。你需要生成一个足够好的数据集,将图像的一个像素块映射到最适合的骰子。你将创建像图 12-3 中那样的数据。
![垂直线转换为骰子中的数字三]()
图 12-3。教 AI 如何选择哪个骰子适用
一些骰子可以旋转。数字二、三和六将需要在数据集中重复出现,因此它们对每种配置都是特定的。虽然它们在游戏中是可互换的,但在艺术中不是。图 12-4 展示了这些数字如何在视觉上镜像。
![三个骰子和三个旋转]()
图 12-4。角度很重要;这两个不相等
这意味着你需要总共九种可能的配置。那就是六个骰子,其中三个旋转了 90 度。图 12-5 展示了你平均六面游戏骰子的所有可能配置。
![用实际骰子说明的六面骰子的九种可能配置]()
图 12-5。九种可能的配置
这些是用一种必须平放的骰子风格重新创建任何图像的可用模式。虽然这对于直接表示图像来说并不完美,但随着数量和距离的增加,分辨率会提高。
训练
在设计模型时,会有两个重要问题:
-
是否有什么东西对迁移学习有用?
-
模型应该有卷积层吗?
首先,我从未见过类似的东西。在创建模型时,我们需要确保有一个验证和测试集来验证模型是否训练良好,因为我们将从头开始设计它。
其次,模型应该避免使用卷积。卷积可以帮助您提取复杂的特征,而不考虑它们的位置,但这个模型非常依赖位置。两个像素块可以是一个 2 或一个旋转的 2。对于这个练习,我将不使用卷积层。
直到完成后我们才知道跳过卷积是否是一个好计划。与大多数编程不同,机器学习架构中有一层实验。我们可以随时回去尝试其他架构。
网站
一旦模型能够将一小块像素分类为相应的骰子,您将需要激活您的张量技能,将图像分解成小块以进行转换。图像的片段将被堆叠,预测并与骰子的图片重建。
注意
由于本章涵盖的概念是先前解释的概念的应用,本章将讨论高层次的问题,并可能跳过解决这个毕业项目的代码细节。如果您无法跟上,请查看先前章节以获取概念和相关源代码的具体信息。本章不会展示每一行代码。
生成训练数据
本节的目标是创建大量数据以用于训练模型。这更多是一门艺术而不是科学。我们希望有大量的数据。为了生成数百张图像,我们可以轻微修改现有的骰子像素。对于本节,我创建了 12 x 12 的骰子印刷品,使用简单的二阶张量。可以通过一点耐心创建九种骰子的配置。查看示例 12-1,注意代表骰子黑点的零块。
示例 12-1。骰子一和二的数组表示
[
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
],
[
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
],
您可以使用tf.ones创建一个[9, 12, 12]的全为1的浮点数,然后手动将位置转换为0,以制作每个骰子的黑点。
一旦您拥有所有九种配置,您可以考虑图像增强以创建新数据。标准图像增强库在这里无法使用,但您可以利用您的张量技能编写一个函数,稍微移动每个骰子位置一个像素。这种小变异将一个骰子变成九种变体。然后您的数据集中将有九种骰子的九种变体。
在代码中实现这一点,想象一下增加骰子的大小,然后在周围滑动一个 12 x 12 的窗口,稍微偏离中心剪切图像的新版本:这是一种填充和裁剪增强。
const pixelShift = async (inputTensor, mutations = []) => {
// Add 1px white padding to height and width
const padded = inputTensor.pad( // ①
[[1, 1],[1, 1],],
1
)
const cutSize = inputTensor.shape
for (let h = 0; h < 3; h++) {
for (let w = 0; w < 3; w++) { // ②
mutations.push(padded.slice([h, w], cutSize)) // ③
}
}
padded.dispose()
return mutations
}
①
.pad为现有张量添加一个值为1的白色边框。
②
为了生成九个新的移位值,每次都会移动切片位置的起点。
③
切片的子张量每次都会成为一个新的 12 x 12 值,起点不同。
pixelShift的结果创建了一些小变化,这些变化应该仍然可以用原始骰子解决。图 12-6 显示了移动像素的视觉表示。
![从一个骰子生成九个新图像]()
图 12-6。移动像素创建新的骰子
虽然每个骰子有九个版本比一个好,但数据集仍然非常小。您必须想出一种方法来创建新数据。
您可以通过随机组合这九个移位图像来创建新的变体。有很多方法可以组合这些图像中的任意两个。一种方法是使用tf.where,并将两个图像中较小的保留在它们的新组合图像中。这样可以保留任意两个移位骰子的黑色像素。
// Creates combinations take any two from array // (like Python itertools.combinations) const combos = async (tensorArray) => {
const startSize = tensorArray.length
for (let i = 0; i < startSize - 1; i++) {
for (let j = i + 1; j < startSize; j++) {
const overlay = tf.tidy(() => {
return tf.where( // ①
tf.less(tensorArray[i], tensorArray[j]), // ②
tensorArray[i], // ③
tensorArray[j] // ④
)
})
tensorArray.push(overlay)
}
}
}
①
tf.where就像在每个元素上运行条件。
②
当第一个参数小于第二个参数时,tf.less返回 true。
③
如果where中的条件为 true,则返回arrCopy[i]中的值。
④
如果where中的条件为 false,则返回arrCopy[j]中的值。
当您重叠这些骰子时,您会得到看起来像之前骰子的小变异的新张量。骰子上的 4 x 4 个点被组合在一起,可以创建相当多的新骰子,可以添加到您的数据集中。
甚至可以对变异进行两次。变异的变异仍然可以被人眼区分。当您查看图 12-7 中生成的四个骰子时,仍然可以明显看出这些骰子是从显示值为一的一面生成的。即使它们是由虚构的第二代变异组合而成,新骰子仍然在视觉上与所有其他骰子组合明显不同。
![通过组合以前的骰子进行变异以制作新骰子]()
图 12-7。通过骰子组合的四种变异
正如您可能已经猜到的那样,在创建这些类似俄罗斯方块的形状时,会有一些意外的重复。与其试图避免重复配置,不如通过调用tf.unique来删除重复项。
警告
目前 GPU 不支持tf.unique,因此您可能需要将后端设置为 CPU 来调用unique。之后,如果您愿意,可以将后端返回到 GPU。
在高层次上,对生成的骰子图像进行移位和变异,从单个骰子生成了两百多个骰子。以下是高层次的总结:
-
将图像在每个方向上移动一个像素。
-
将移位后的张量组合成所有可能的组合。
-
对先前集合执行相同的变异组合。
-
仅使用唯一结果合并数据。
现在,对于每种九种可能的组合,我们有两百多个张量。考虑到刚才只有九个张量,这还不错。两百张图片足够吗?我们需要测试才能找出答案。
您可以立即开始训练,或者将数据保存到文件中。本章相关的代码会写入一个文件。本节的主要功能可以用以下代码概括:
const createDataObject = async () => {
const inDice = require('./dice.json').data
const diceData = {}
// Create new data from each die
for (let idx = 0; idx < inDice.length; idx++) {
const die = inDice[idx]
const imgTensor = tf.tensor(die)
// Convert this single die into 200+ variations
const results = await runAugmentation(imgTensor, idx)
console.log('Unique Results:', idx, results.shape)
// Store results
diceData[idx] = results.arraySync()
// clean
tf.dispose([results, imgTensor])
}
const jsonString = JSON.stringify(diceData)
fs.writeFile('dice_data.json', jsonString, (err) => {
if (err) throw err
console.log('Data written to file')
})
}
训练
现在您总共有将近两千张图片,可以尝试训练您的模型。数据应该被堆叠和洗牌:
const diceImages = [].concat( // ①
diceData['0'],
diceData['1'],
diceData['2'],
diceData['3'],
diceData['4'],
diceData['5'],
diceData['6'],
diceData['7'],
diceData['8'],
)
// Now the answers to their corresponding index const answers = [].concat(
new Array(diceData['0'].length).fill(0), // ②
new Array(diceData['1'].length).fill(1),
new Array(diceData['2'].length).fill(2),
new Array(diceData['3'].length).fill(3),
new Array(diceData['4'].length).fill(4),
new Array(diceData['5'].length).fill(5),
new Array(diceData['6'].length).fill(6),
new Array(diceData['7'].length).fill(7),
new Array(diceData['8'].length).fill(8),
)
// Randomize these two sets together tf.util.shuffleCombo(diceImages, answers) // ③
①
通过连接单个数据数组来创建大量数据数组。
②
然后,您创建与每个数据集大小完全相同的答案数组,并使用Array的.fill来填充它们。
③
然后,您可以将这两个数组一起随机化。
从这里,您可以拆分出一个测试集,也可以不拆分。如果您需要帮助,可以查看相关代码。一旦您按照自己的意愿拆分了数据,然后将这两个 JavaScript 数组转换为正确的张量:
const trainX = tf.tensor(diceImages).expandDims(3) // ①
const trainY = tf.oneHot(answers, numOptions) // ②
①
创建堆叠张量,并为简单起见,通过在索引三处扩展维度将其返回为三维图像。
②
然后,将数字答案进行独热编码为张量,以适应 softmax 模型输出。
该模型采用了简单而小型的设计。您可能会找到更好的结构,但对于这个,我选择了两个隐藏层。随时回来并尝试使用架构进行实验,看看您可以获得什么样的速度和准确性。
const model = tf.sequential()
model.add(tf.layers.flatten({ inputShape }))
model.add(tf.layers.dense({
units: 64,
activation: 'relu',
}))
model.add(tf.layers.dense({
units: 8,
activation: 'relu',
}))
model.add(tf.layers.dense({
units: 9,
kernelInitializer: 'varianceScaling',
activation: 'softmax',
}))
该模型首先通过将图像输入展平以将它们连接到神经网络,然后有一个64和一个8单元层。最后一层是九种可能的骰子配置。
这个模型在几个时代内就能达到近乎完美的准确率。这对于生成的数据来说是很有希望的,但在下一节中,我们将看到它在实际图像中的表现如何。
网站界面
现在您已经有了一个经过训练的模型,是时候用非生成数据进行测试了。肯定会有一些错误,但如果模型表现得不错,这将是相当成功的!
您的网站需要告诉需要使用多少个骰子,然后将输入图像分成相同数量的块。这些块将被调整大小为 12 x 12 的输入(就像我们的训练数据),然后在图像上运行模型进行预测。在图 12-8 中显示的示例中,一个 X 的图像被告知要转换为四个骰子。因此,图像被切割成四个象限,然后对每个象限进行预测。它们应该理想地将骰子对齐以绘制 X。
![将 TensorFlow 标志切割成 32 x 32 块之前和之后]()
图 12-8。将 TensorFlow 标志切割成 32 x 32 块
一旦您获得了预测结果,您可以重建一个由指定图像张量组成的新张量。
注意
这些图像是在 0 和 1 上进行训练的。这意味着,为了期望得到一个体面的结果,您的输入图像也应该由 0 和 1 组成。颜色甚至灰度都会产生虚假的结果。
应用程序代码的核心应该看起来像这样:
const dicify = async () => {
const modelPath = '/dice-model/model.json'
const dModel = await tf.loadLayersModel(modelPath)
const grid = await cutData("input")
const predictions = await predictResults(dModel, grid)
await displayPredictions(predictions)
tf.dispose([dModel, predictions])
tf.dispose(grid)
}
结果的预测是您经典的“数据输入,数据输出”模型行为。最复杂的部分将是cutData和displayPredictions方法。在这里,您的张量技能将大放异彩。
切成块
cutData方法将利用tf.split,它沿着一个轴将张量分割为 N 个子张量。您可以通过使用tf.split沿着每个轴将图像分割成一个补丁或图像网格来进行预测。
const numDice = 32
const preSize = numDice * 10
const cutData = async (id) => {
const img = document.getElementById(id)
const imgTensor = tf.browser.fromPixels(img, 1) // ①
const resized = tf.image.resizeNearestNeighbor( // ②
imgTensor, [preSize,preSize]
)
const cutSize = numDice
const heightCuts = tf.split(resized, cutSize) // ③
const grid = heightCuts.map((sliver) => // ④
tf.split(sliver, cutSize, 1))
return grid
}
①
您只需要将图像的灰度版本从像素转换过来。
②
图像被调整大小,以便可以被所需数量的骰子均匀分割。
③
图像沿着第一个轴(高度)被切割。
④
然后将这些列沿着宽度轴切割,以创建一组张量。
grid变量现在包含一个图像数组。在需要时,您可以调整图像大小并堆叠它们进行预测。例如,图 12-9 是一个切片网格,因为 TensorFlow 标志的黑白切割将创建许多较小的图像,这些图像将被转换为骰子。
![将 TensorFlow 标志切割成 27x27 块]()
图 12-9。黑白 TensorFlow 标志的切片
重建图像
一旦您有了预测结果,您将想要重建图像,但您将希望将原始块替换为它们预测的骰子。
从预测答案重建和创建大张量的代码可能如下所示:
const displayPredictions = async (answers) => {
tf.tidy(() => {
const diceTensors = diceData.map( // ①
(dt) => tf.tensor(dt)
)
const { indices } = tf.topk(answers)
const answerIndices = indices.dataSync()
const tColumns = []
for (let y = 0; y < numDice; y++) {
const tRow = []
for (let x = 0; x < numDice; x++) {
const curIndex = y * numDice + x // ②
tRow.push(diceTensors[answerIndices[curIndex]])
}
const oneRow = tf.concat(tRow, 1) // ③
tColumns.push(oneRow)
}
const diceConstruct = tf.concat(tColumns) // ④
// Print the reconstruction to the canvas
const can = document.getElementById('display')
tf.browser.toPixels(diceConstruct, can) // ⑤
})
}
①
要绘制的diceTensors从diceData中加载并转换。
②
要从 1D 返回到 2D,需要为每一行计算索引。
③
行是通过沿着宽度轴进行连接而创建的。
④
列是通过沿着默认(高度)轴连接行来制作的。
⑤
哒哒!新构建的张量可以显示出来了。
如果你加载了一个黑白图像并处理它,现在是真相的时刻。每个类别生成了大约 200 张图像是否足够?
我将numDice变量设置为 27。一个 27 x 27 的骰子图像是相当低分辨率的,需要在亚马逊上花费大约 80 美元。让我们看看加上 TensorFlow 标志会是什么样子。图 12-10 展示了结果。
![TensorFlow 标志转换为 27 x 27 骰子之前和之后]()
图 12-10。TensorFlow 标志转换为 27 x 27 骰子
它有效!一点也不错。你刚刚教会了一个 AI 如何成为一个艺术家。如果你增加骰子的数量,图像会变得更加明显。
章节回顾
使用本章的策略,我训练了一个 AI 来处理红白骰子。我没有太多耐心,所以我只为一个朋友制作了一个 19x19 的图像。结果相当令人印象深刻。我花了大约 30 分钟将所有的骰子放入图 12-11 中显示的影子盒中。如果没有印刷说明,我想我不会冒这个风险。
![19 x 19 成品图像。]()
图 12-11。完成的 19 x 19 红白骰子带背光
你可以走得更远。哪个疯狂的科学家没有自己的肖像?现在你的肖像可以由骰子制成。也许你可以教一个小机器人如何为你摆放骰子,这样你就可以建造满是数百磅骰子的巨大画框(见图 12-12)。
![一个人看着一堵用骰子做成的图像的墙]()
图 12-12。完美的疯狂科学肖像
你可以继续改进数据并获得更好的结果,你不仅仅局限于普通的黑白骰子。你可以利用你的 AI 技能用装饰性骰子、便利贴、魔方、乐高积木、硬币、木片、饼干、贴纸或其他任何东西来绘画。
虽然这个实验对于 1.0 版本来说是成功的,但我们已经确定了无数个实验,可以让你改进你的模型。
章节挑战:简单如 01、10、11
现在你有了一个强大的新模型,可以成为由黑色0和白色1像素组成的任何照片的艺术家。不幸的是,大多数图像,即使是灰度图像,也有中间值。如果有一种方法可以高效地将图像转换为黑白就好了。
将图像转换为二进制黑白被称为二值化。计算机视觉领域有各种各样的令人印象深刻的算法,可以最好地将图像二值化。让我们专注于最简单的方法。
在这个章节挑战中,使用tf.where方法来检查像素是否超过给定的阈值。使用该阈值,你可以将灰度图像的每个像素转换为1或0。这将为你的骰子模型准备正常的图形输入。
通过几行代码,你可以将成千上万种光的变化转换为黑白像素,如图 12-13 所示。
![一个头骨被转换成黑白像素。]()
图 12-13。二值化的头骨
你可以在附录 B 中找到这个挑战的答案。
复习问题
让我们回顾一下你在本章编写的代码中学到的知识。花点时间回答以下问题:
-
TensorFlow.js 的哪个方法允许你将张量分解为一组相等的子张量?
-
用于创建数据的稍微修改的替代品以扩大数据集的过程的名称是什么?
-
为什么 Gant Laborde 如此了不起?
这些练习的解决方案可以在附录 A 中找到。
¹如果你想了解更多关于暗箱的知识,请观看纪录片Tim's Vermeer。
结语
“那么为什么要尝试预测未来,如果这是如此困难,几乎不可能呢?因为做出预测是一种在我们看到自己朝着危险方向漂移时发出警告的方式。因为预测是指出更安全、更明智的途径的有用方式。最重要的是,我们的明天是我们今天的孩子。”
—Octavia E. Butler
构建和撰写这样一个鼓舞人心的框架是一种绝对的乐趣。我简直无法相信我已经在为这本书写后记了,我想您可能对阅读完这本书的感觉也是如此。此刻,我们在这本书中的探索已经结束。然而,您在 TensorFlow.js 中的冒险现在已经开始。
在这本书中,您涵盖了许多机器学习的基础和视觉方面。如果您对机器学习不熟悉,现在您可以深入了解更高级的模型架构,如声音、文本和生成模型。虽然您已经掌握了 TensorFlow.js 的许多基础知识,但还有许多团队与您一起探索着整个可能性的宇宙。
从这里,您可以订阅频道和信息,帮助您成长,连接您所需的人,并将您带入令人惊叹的 TensorFlow.js 项目,您可以在其中构建令人惊叹的产品。
社交
要了解 TensorFlow.js 的最新动态,我强烈建议您进行社交连接。Twitter 标签#MadeWithTFJS经常用于标记 TensorFlow.js 中的新颖和独特的创作。Google 的 TensorFlow.js 社区领导者Jason Mayes在他的展示和讲解活动中帮助推广这个标签,这些活动都在 TensorFlow YouTube 频道上展示。
我强烈建议您在这个频道上与所有过去的演讲者社交,包括本人。社交是一个很好的方式,可以提问,看见想法,并获得进入更多社区的途径。
如果您更喜欢阅读而不是写作,那么连接到 TensorFlow.js 的时代精神仍然很重要。我在https://ai-fyi.com管理一个通讯,我将始终发布 TensorFlow.js 及更多发现的最新和最伟大的内容。
更多书籍
如果您是书籍爱好者,并正在寻找下一个机器学习冒险,那就不要再找了。
*Laurence Maroney(O’Reilly)的《面向程序员的人工智能和机器学习》是一本书,将帮助您将您的 TensorFlow 思维应用到一个新的可能性世界。您将学习如何在各种平台上处理 TensorFlow,以及将您的知识推进到计算机视觉以外的领域。
*Aurélien Géron(O’Reilly)的《使用 Scikit-Learn、Keras 和 TensorFlow 进行实践机器学习》是一个更基础的方法,可以帮助您加强机器学习知识的概念和工具。
*Shanqing Cai 等人(Manning)的《使用 JavaScript 进行深度学习》是有关 TensorFlow.js 和机器学习概念的权威信息来源。
其他选择
在线活动正在飞速增长。搜索您感兴趣的话题的活动,并确保查看 O’Reilly 提供的在线活动。
在线课程是互动培训和认证的绝佳机会。查看 O’Reilly Media 提供的在线课程以及许多作者创建的课程。
如果您正在寻找在 TensorFlow.js 中演讲或咨询,我建议您联系我,我将尽力帮助您联系。
更多 TensorFlow.js 代码
那里有越来越多的优秀 TensorFlow.js 项目。如果您正在寻找灵感,这里有一堆我创建的资源和项目:
谢谢
感谢您,读者。您是这本书存在的原因。请与我分享喜欢的时刻,这样我们可以一起享受。您可以在 Twitter 上找到我,用户名为@GantLaborde,或者访问我的网站GantLaborde.com。
![]()
附录 A. 章节复习答案
第一章:AI 是魔术
-
机器学习是 AI 的一个子集,专注于从数据中学习以提高性能的系统。
-
您可以建议获得结果的最佳方法是收集一组带标签的数据,这样您可以执行监督或半监督训练,或者您可以提供无监督或基于强化的方法。
-
强化学习最适合将机器学习应用于游戏。
-
不,机器学习是人工智能的一个子集。
-
不,模型包含结构和数字,但通常比它看到的训练数据小得多。
-
数据通常被分成训练集和测试集,有些人使用验证集。训练数据集始终是最大的。
第二章:介绍 TensorFlow.js
-
不,TensorFlow 直接与 Python 一起工作。您需要 TensorFlow.js 在浏览器中运行 AI。
-
是的,TensorFlow.js 通过 WebGL 可以访问浏览器 GPU,如果加载tensorflow/tfjs-node-gpu,则可以通过 CUDA 访问服务器 GPU。
-
不,TensorFlow.js 原始版和 Node.js 版本都不需要 CUDA。
-
您将获得该库的最新版本,其中可能包含对您网站的破坏性更改。
-
分类器返回一个违规数组及其真实可能性的百分比。
-
阈值是可以传递给模型的load调用的可选参数。
-
不,毒性模型代码需要模型的网络权重,并且在调用load时会从 TFHub 下载此文件。
-
我们不直接进行任何张量操作;库处理所有 JavaScript 原语到张量的转换和反向转换。
第三章:介绍张量
-
张量使我们能够以优化的速度处理大量数据和计算,这对于机器学习至关重要。
-
没有对象数据类型。
-
一个六阶张量将是六阶的。
-
dataSync和data都会产生一维类型数组。
-
您将收到一个错误。
-
张量的size是其形状的乘积,其中rank是张量的形状长度。
- 例如,张量
tf.tensor([[1,2], [1,2], [1,2]])的形状是[3,2],大小为 6,秩为 2。
-
数据类型将是float32。
-
不,第二个参数是张量的首选形状,不必与输入匹配。
-
使用tf.memory().numTensors。
-
不,tidy必须传递一个普通函数。
-
您可以通过使用tf.keep或从封装函数返回张量来保留在tidy内部创建的张量。
-
这些值在传统的console.log中不可见,但如果用户使用.print,它们将被记录。
-
topk函数找到最后一个维度上k个最大条目的值和索引。
-
张量被优化用于批量操作。
-
有时被称为推荐系统,它是一种寻求预测用户偏好的过滤系统。
第四章:图像张量
-
对于值0-255,可以使用int32。
-
tf.tensor([[1, 0, 0],[1, 0, 0]],[[1, 0, 0],[1, 0, 0]])
-
一个 50 x 100 的灰度图像,其中 20%是白色。
-
错误。3D 张量应该具有大小为 4 的 RGBA 通道,但形状将是三阶的,即[?, ?, 4]。
-
错误。输出将在输入约束内随机化。
-
您可以使用tf.browser.fromPixels。
-
您将设置值为9。
-
您可以使用tf.reverse并提供高度轴,如tf.reverse(myImageTensor, 0)。
-
对于四阶张量进行批处理会更快。
-
结果形状将是[20, 20, 3]。
第五章:介绍模型
-
您可以在 TensorFlow.js 中加载图形和层模型,它们对应的加载方法是tf.loadGraphModel和tf.loadLayersModel。
-
不,JSON 文件知道相应的分片,并且只要有访问权限,它们将被加载。
-
您可以从 IndexedDB、本地存储、本地文件系统以及任何其他方式加载模型,以便将它们加载到内存中供 JavaScript 项目使用。
-
函数loadLayersModel返回一个解析为模型的 promise。
-
可以使用.dispose清除模型。
-
Inception v3 模型期望一个四维批次,大小为 299 x 299 的 3D RGB 像素,值在 0 到 1 之间。
-
您可以使用 2D 上下文的strokeRect方法在画布上绘制边界框。
-
第二个参数应该是一个配置对象,带有fromTFHub: true。
第六章:高级模型和 UI
-
SSD 代表“单次检测器”,指的是用于目标检测的完全卷积方法。
-
您可以在这些模型上使用executeAsync。
-
SSD MobileNet 模型识别 80 个类别,但每个检测的张量输出形状为 90。
-
非极大值抑制(NMS)和软 NMS 用于利用 IoU 去重检测。
-
大型同步的 TensorFlow.js 调用可能会记录 UI。通常期望您使用异步或甚至将 TensorFlow.js 后端转换为 CPU,以避免引起 UI 问题。
-
画布上下文measureText(label).width测量标签宽度。
-
将globalCompositeOperation设置为source-over将覆盖现有内容。这是绘制到画布的默认设置。
第七章:模型制作资源
-
虽然数据量很大,但评估数据的质量和有效特征很重要。一旦数据经过清理并删除了不重要的特征,您可以将其分解为训练、测试和验证集。
-
模型过度拟合训练数据,显示出高方差。您应该评估模型在测试集上的表现,并确保它正确学习以便泛化。
-
该网站是 Teachable Machine,网址为https://teachablemachine.withgoogle.com。
-
模型是根据您的特定数据进行训练的,可能不会很好地泛化。您应该使数据集多样化,以避免出现严重偏差。
-
ImageNet 是用于训练 MobileNet 的数据集。
第八章:训练模型
-
本章的训练数据具有一个秩为一且大小为一的输入,输出为秩为一且大小为一。章节挑战要求输入为五个秩为一的输出为四个数字的秩为一张量。
-
您可以使用model.summary()查看 Layers 模型的层和可训练参数。
-
激活函数创建非线性预测。
-
第一个指定的层标识出其所需的inputShape。
-
sgd是一种用于学习的优化方法,代表随机梯度下降。
-
一个时期是通过整个训练数据集进行训练的一次迭代。
-
描述的模型有一个隐藏层(参见图 A-1)。
![描述的模型]()
图 A-1。一个隐藏层
第九章:分类模型和数据分析
-
您将在最后一层使用 softmax,带有三个单元,因为这三个手势是互斥的。
-
您将在最后一层使用一个带有 sigmoid 的单个节点/单元。
-
您可以通过键入$ dnotebook来运行 Dnotebook。
-
您可以使用 Danfo.js 的concat将它们组合,并将它们列在df_list属性中作为数组。
-
您将使用 Danfo.js 的get_dummies方法。
-
您可以使用dfd.MinMaxScaler()来缩放您的模型。
第十章:图像训练
-
卷积层有许多可训练的滤波器。
-
卷积窗口大小为kernelSize。
-
为了保持卷积结果的大小不变,您需要通过将层的padding属性设置为'same'来填充卷积。
-
错误。卷积层可以处理多维输入。在将它们连接到密集神经网络之前,您必须展平一组卷积的输出。
-
一个 3 x 3 的步幅为三的卷积会将每个维度减少三分之一。因此,结果图像将变为更小的 27 x 27。
-
不,你需要知道 12 以外存在多少可能的值,这样函数才能添加所需的零。
第十一章:迁移学习
-
KNN 代表 K-最近邻算法。
-
即使使用迁移学习,小数据集也容易过拟合或具有高方差。
-
图像特征向量标记模型是经过训练的卷积。
-
1.00 将比 0.50 具有 2 倍的特征。
-
将第二个参数设置为true的.infer方法将返回嵌入。
-
你已经添加到已经训练模型的初始层训练得非常差,你应该确保在训练新层时不要修改已经训练好的层。一切就绪后,你可以结合并进行“微调”训练。
-
你应该将输入数据展平,以便后续网络的密集层能够正确处理。
第十二章:Dicify:毕业项目
-
你可以使用tf.split将张量沿着给定轴分割成相等的子张量。
-
这个过程被称为数据增强。
-
科学家们多年来一直在研究这个问题,虽然尚未确定来源,但它已被普遍接受为科学事实。
附录 B. 章节挑战答案
第二章:卡车警报!
MobileNet 模型可以检测各种不同类型的卡车。您可以通过查看可识别卡车的列表来解决这个问题,或者您可以简单地在给定的类名列表中搜索truck这个词。为简单起见,提供的答案选择了后者。
包含 HTML 和 JavaScript 的整个解决方案在这里:
<!DOCTYPE html>
<html>
<head>
<script
src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@2.7.0/dist/tf.min.js">
</script>
<script
src="https://cdn.jsdelivr.net/npm/@tensorflow-models/mobilenet@1.0.0">
</script> <!-- ① -->
<script>
mobilenet.load().then(model => {
const img = document.getElementById('myImage'); <!-- ② -->
// Classify the image
model.classify(img).then(predictions => {
console.log('Predictions: ', predictions);
// Was there a truck?
let foundATruck
predictions.forEach(p => {
foundATruck = foundATruck || p.className.includes("truck") <!-- ③ -->
})
// TRUCK ALERT!
if (foundATruck) alert("TRUCK DETECTED!") <!-- ④ -->
});
});
</script>
</head>
<body>
<h1>Is this a truck?</h1>
<img id="myImage" src="truck.jpg" width="100%"></img>
</body>
</html>
①
从 CDN 加载 MobileNet 模型。
②
通过 ID 访问 DOM 上的图像。由于等待模型加载,DOM 可能已经加载了一段时间。
③
如果在任何预测中检测到truck这个词,将foundATruck设置为 true。
④
真相时刻!只有在foundATruck为 true 时才会弹出警报。
这个带有卡车图像的章节挑战答案可以在本书的GitHub源代码中找到。
第三章:你有什么特别之处?
这个简单的练习是关于查找 TensorFlow.js tf.unique方法。一旦找到这个友好的方法,就很容易构建一个解决方案,就像这样:
const callMeMaybe = tf.tensor([8367677, 4209111, 4209111, 8675309, 8367677])
const uniqueTensor = tf.unique(callMeMaybe).values
const result = uniqueTensor.arraySync()
console.log(`There are ${result.length} unique values`, result)
不要忘记将此代码包装在tf.tidy中以进行自动张量清理!
第四章:混乱排序
一种优雅的解决方案是对randomUniform创建的张量使用topk进行排序。由于randomUniform创建的值在0和1之间,并且topk沿着最后一个轴对值进行排序,您可以使用以下代码完成这个练习:
const rando = tf.randomUniform([400, 400]) // ①
const sorted = tf.topk(rando, 400).values // ②
const answer = sorted.reshape([400, 400, 1]) // ③
①
创建一个 2D 的 400 x 400 张量,其中包含介于0和1之间的随机值。
②
使用topk对最后一个维度(宽度)进行排序,并返回所有 400 个值。
③
可选:将张量重塑为 3D 值。
先前的解决方案非常冗长,可以压缩为一行代码:
tf.topk(tf.randomUniform([400, 400]), 400).values
第五章:可爱的脸
现在,第一个模型已经给出了脸部的坐标,一个张量裁剪将提供这些像素。这几乎与strokeRect完全相同,因为您提供了一个起始位置和所需的大小。然而,我们之前的所有测量对于这个裁剪都不起作用,因为它们是在图像的调整版本上计算的。您需要在原始张量数据上进行类似的计算,以便提取正确的信息。
提示
如果您不想重新计算位置,可以将张量调整大小以匹配petImage的宽度和高度。这将允许您重用相同的startX、startY、width和height变量进行裁剪。
以下代码可能引用原始人脸定位代码中创建的一些变量,特别是原始的fromPixels张量myTensor:
// Same bounding calculations but for the tensor
const tHeight = myTensor.shape[0] // ①
const tWidth = myTensor.shape[1]
const tStartX = box[0] * tWidth
const tStartY = box[1] * tHeight
const cropLength = parseInt((box[2] - box[0]) * tWidth, 0) // ②
const cropHeight = parseInt((box[3] - box[1]) * tHeight, 0)
const startPos = [tStartY, tStartX, 0]
const cropSize = [cropHeight, cropLength, 3]
const cropped = tf.slice(myTensor, startPos, cropSize)
// Prepare for next model input
const readyFace = tf.image
.resizeBilinear(cropped, [96, 96], true)
.reshape([1, 96, 96, 3]); // ③
①
请注意,张量的顺序是高度然后宽度。它们的格式类似于数学矩阵,而不是图像特定的宽度乘以高度的标准。
②
减去比率可能会留下浮点值;您需要将这些值四舍五入到特定的像素索引。在这种情况下,答案是使用parseInt来去除任何小数。
③
显然,批处理,然后取消批处理,然后重新批处理是低效的。在可能的情况下,您应该将所有操作保持批处理,直到绝对必要。
现在,您已经成功地准备好将狗脸张量传递到下一个模型中,该模型将返回狗在喘气的可能性百分比。
结果模型的输出从未指定,但您可以确保它将是一个两值的一维张量,索引 0 表示不 panting,索引 1 表示 panting,或者是一个一值的一维张量,表示从零到一的 panting 可能性。这两种情况都很容易处理!
第六章:顶级侦探
使用topk的问题在于它仅在特定张量的最终维度上起作用。因此,您可以通过两次调用topk来找到两个维度上的最大值。第二次您可以将结果限制为前三名。
const { indices, values } = tf.topk(t)
const topvals = values.squeeze()
const sorted = tf.topk(topvals, 3)
// prints [3, 4, 2]
sorted.indices.print()
然后,您可以循环遍历结果并从topvals变量中访问前几个值。
第七章:再见,MNIST
通过向导您可以选择所有所需的设置;您应该已经创建了一些有趣的结果。结果应该如下:
第八章:模型架构师
您被要求创建一个符合给定规格的 Layers 模型。该模型的输入形状为五,输出形状为四,中间有几个具有指定激活函数的层。
构建模型的代码应该如下所示:
const model = tf.sequential();
model.add(
tf.layers.dense({
inputShape: 5,
units: 10,
activation: "sigmoid"
})
);
model.add(
tf.layers.dense({
units: 7,
activation: "relu"
})
);
model.add(
tf.layers.dense({
units: 4,
activation: "softmax"
})
);
model.compile({
optimizer: "adam",
loss: "categoricalCrossentropy"
});
可训练参数的数量计算为进入一个层的行数 + 该层中的单元数。您可以使用每个层的计算layerUnits[i] * layerUnits[i - 1] + layerUnits[i]来解决这个问题。model.summary()的输出将验证您的数学计算。将您的摘要与示例 B-1 进行比较。
示例 B-1. 模型摘要
_________________________________________________________________
Layer (type) Output shape Param #
=================================================================
dense_Dense33 (Dense) [null,10] 60
_________________________________________________________________
dense_Dense34 (Dense) [null,7] 77
_________________________________________________________________
dense_Dense35 (Dense) [null,4] 32
=================================================================
Total params: 169
Trainable params: 169
Non-trainable params: 0
_________________________________________________________________
第九章:船出事了
当然,有很多获取这些信息的方法。这只是其中一种方式。
要提取每个名称的敬语,您可以使用.apply并通过空格分割。这将让您很快得到大部分答案。但是,一些名称中有“von”之类的内容,这会导致额外的空格并稍微破坏您的代码。为此,一个好的技巧是使用正则表达式。我使用了/,\s(.*?)\./,它查找逗号后跟一个空格,然后匹配直到第一个句点。
您可以应用这个方法创建一个新行,按该行分组,然后使用.mean()对幸存者的平均值进行表格化。
mega_df['Name'] = mega_df['Name'].apply((x) => x.split(/,\s(.*?)\./)[1])
grp = mega_df.groupby(['Name'])
table(grp.col(['Survived']).mean())
mega_df['Name']被替换为有用的内容,然后进行分组以进行验证。然后可以轻松地对其进行编码或进行分箱处理以用于您的模型。
图 B-1 显示了在 Dnotebook 中显示的分组代码的结果。
![Dnotebook 解决方案的屏幕截图]()
图 B-1. 敬语和生存平均值
第十章:保存魔法
为了保存最高的验证准确性,而不是最后的验证准确性,您可以在时期结束回调中添加一个条件保存。这可以避免您意外地陷入过拟合时期的困扰。
// initialize best at zero
let best = 0
//...
// In the callback object add the onEpochEnd save condition
onEpochEnd: async (_epoch, logs) => {
if (logs.val_acc > best) {
console.log("SAVING")
model.save(savePath)
best = logs.val_acc
}
}
还有earlyStopping预打包回调,用于监视和防止过拟合。将您的回调设置为callbacks: tf.callbacks.earlyStopping({monitor: 'val_acc'})将在验证准确性回退时停止训练。
第十一章:光速学习
您现在知道很多解决这个问题的方法,但我们将采取快速简单的方式。解决这个问题有四个步骤:
-
加载新的图像数据
-
将基础模型削减为特征模型
-
创建读取特征的新层
-
训练新层
加载新的图像数据:
const dfy = await dfd.read_csv('labels.csv')
const dfx = await dfd.read_csv('images.csv')
const Y = dfy.tensor
const X = dfx.tensor.reshape([dfx.shape[0], 28, 28, 1])
将基础模型削减为特征模型:
const model = await tf.loadLayersModel('sorting_hat/model.json')
const layer = model.getLayer('max_pooling2d_MaxPooling2D3')
const shaved = tf.model({
inputs: model.inputs,
outputs: layer.output
})
// Run data through shaved model to get features
const XFEATURES = shaved.predict(X)
创建读取特征的新层:
transferModel = tf.sequential({
layers: [
tf.layers.flatten({ inputShape: shaved.outputs[0].shape.slice(1) }),
tf.layers.dense({ units: 128, activation: 'relu' }),
tf.layers.dense({ units: 3, activation: 'softmax' }),
],
})
transferModel.compile({
optimizer: 'adam',
loss: 'categoricalCrossentropy',
metrics: ['accuracy'],
})
训练新层:
await transferModel.fit(XFEATURES, Y, {
epochs: 10,
validationSplit: 0.1,
callbacks: {
onEpochEnd: console.log,
},
})
结果在 10 个时期内训练到了很高的准确性,如图 B-2 所示。
![在几个时期内达到完美的验证准确性]()
图 B-2. 仅从 150 张图像训练
这个挑战的完整答案可以在本章的相关源代码中找到,这样你就可以查看代码,甚至与结果进行交互。
第十二章:简单如 01, 10, 11
将图像转换为灰度很容易。一旦你这样做了,你可以在图像上使用 tf.where 来用白色或黑色像素替换每个像素。
以下代码将具有 input ID 的图像转换为一个二值化图像,该图像显示在同一页上名为 output 的画布上:
// Simply read from the DOM
const inputImage = document.getElementById('input')
const inTensor = tf.browser.fromPixels(inputImage, 1)
// Binarize
const threshold = 50
const light = tf.onesLike(inTensor).asType('float32')
const dark = tf.zerosLike(inTensor)
const simpleBinarized = tf.where(
tf.less(inTensor, threshold),
dark, // False Case: place zero
light, // True Case: place one
)
// Show results
const myCanvas = document.getElementById('output')
tf.browser.toPixels(simpleBinarized, myCanvas)
本章挑战答案的完全运行示例可以在本章的相关源代码中找到。
有更高级和更健壮的方法来对图像进行二值化。如果你想处理更多的图像,请查看二值化算法。
附录 C. 权利和许可
Unsplash 许可
Unsplash 授予您不可撤销的、非独占的、全球性的版权许可,允许您免费下载、复制、修改、分发、执行和使用 Unsplash 的照片,包括商业用途,无需征得摄影师或 Unsplash 的许可。该许可不包括从 Unsplash 编译照片以复制类似或竞争性服务的权利。
在此许可下的图像:
第二章
图 2-5:Milovan Vudrag 拍摄的照片
第五章
图 5-9:Karsten Winegeart 拍摄的照片
图 5-4:Dave Weatherall 拍摄的照片
第六章
图 6-15:Kelsey Chance 拍摄的照片
第十一章
图 11-2,骆驼:Wolfgang Hasselmann 拍摄的照片
图 11-2,天竺鼠:Jack Catalano 拍摄的照片
图 11-2,水豚:Dušan Veverkolog 拍摄的照片
图 11-8,兔子 1:Satyabrata sm 拍摄的照片
图 11-8,兔子 2:Gary Bendig 拍摄的照片
图 11-8,兔子 3:Gavin Allanwood 拍摄的照片
图 11-8,汽车 1:Sam Pearce-Warrilow 拍摄的照片
图 11-8,汽车 2:Cory Rogers 拍摄的照片
图 11-8,汽车 3:Kevin Bhagat 拍摄的照片
图 11-8,测试兔子:Christopher Paul High 拍摄的照片
第十二章
图 12-12,修改后的 Igor Miske 拍摄的照片
图 12-13:Gant Laborde 拍摄的照片
Apache 许可证 2.0
版权所有 2017 © Google
根据 Apache 许可证第 2.0 版(“许可证”)许可,除非符合许可证的规定,否则您不得使用此文件。您可以在http://www.apache.org/licenses/LICENSE-2.0获取许可证的副本。
除非适用法律要求或书面同意,根据许可证分发的软件是基于“原样”分发的,没有任何明示或暗示的担保或条件。请查看许可证以了解许可证下的权限和限制的具体语言。
在此许可下的图像:
在此许可下的代码:
-
第二章:毒性模型 NPM
-
第二章:MobileNet 模型 NPM
-
第五章:Inception v3 模型
公共领域
在此许可下的图像:
WTFPL
根据此许可的数据](http://www.wtfpl.net):
-
第五章:井字游戏模型
-
第五章:宠物面孔模型
-
第十章:分拣帽模型
知识共享署名-相同方式共享 4.0 国际许可协议(CC BY-SA 4.0)
根据此许可的数据](https://creativecommons.org/licenses/by-sa/4.0):
在此许可下的图像:
知识共享署名 4.0 国际许可协议(CC BY 4.0)
根据此许可证的数据:
Gant Laborde 和 O’Reilly
除了在附录 C 中明确标识的图像外,所有其他图像均由 O’Reilly 或作者 Gant Laborde 拥有,用于此出版作品的明确使用。
TensorFlow 和 TensorFlow.js 标志
TensorFlow、TensorFlow 标志和任何相关标记均为 Google Inc. 的商标。