人工智能和机器学习设备开发指南-全-

人工智能和机器学习设备开发指南(全)

原文:zh.annas-archive.org/md5/efe10d42fefe580a82716aedaadb8b1c

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

欢迎来到AI 和机器学习 for On-Device Development。成功的作家们总是告诉我,你能写的最好的书是你想要阅读的书。因此,我写这本书是因为我觉得所有移动开发人员都有必要将机器学习加入到他们的工具箱中。希望您在学习之旅中找到它有帮助。

谁应该阅读这本书?

如果您是一名喜欢编写在 Android 或 iOS 上执行并通过应用或站点让用户喜闻乐见的移动开发人员,但又对如何将机器学习融入工作流程感到好奇的人,那么这本书适合您!本书旨在向您展示如何利用各种框架快速启动并运行,这是有帮助的第一步。它还指导您在想要进一步自定义模型并深入研究机器学习时的方法。

我为什么写这本书

我在 Google 的目标是使所有开发人员都能轻松使用 AI,揭开看似神秘的数学,并确实将 AI 带到每个人手中。其中一个关键是赋予移动开发人员(无论是 Android 还是 iOS)开启利用机器学习的新移动范式的能力。

有一个老笑话说,在互联网早期,人们普遍认为不要和陌生人说话,尤其是绝对不要上陌生人的车。然而,现在,由于范式的转变,我们愉快地从互联网上召唤陌生人,并上了他们的车!这种行为得益于移动互联网连接的计算设备。因此,我们做事情的方式永远改变了。

我们的计算设备下一波能够做的新事物将由机器学习驱动。我只能猜测它们可能是什么!因此,我写这本书是为了帮助您,亲爱的读者,跨过眼花缭乱的选择。而您可能就是能写出能改变一切的应用程序的人。我迫不及待地想看到您如何利用它!

导读本书

您可以自行决定如何阅读它。如果您是一名想要了解机器学习的移动开发人员,您可以从头开始逐步学习。如果您想要了解特定的“入门”技术,比如 ML Kit 或 Create ML,本书也有专门的章节。在书的末尾,我还讨论了您在旅程中更进一步时需要考虑的技术和技巧,例如使用 Firebase 进行多模型托管以及在 AI 中考虑公平性的工具。

您需要理解的技术

这本书将为您提供机器学习(ML)的简明介绍,然后深入探讨如何在移动设备上使用模型。如果您希望更深入地了解 ML,您可以使用我的书AI 和机器学习 for Coders, 也来自 O'Reilly。

本书将引导您通过一些移动开发的示例场景,但不旨在教授 Android 开发使用 Kotlin 或 iOS 开发使用 Swift。在适当的时候,我们会为您指引学习资源。

本书使用的约定

本书使用以下排版约定:

斜体

指示新术语、URL、电子邮件地址、文件名和文件扩展名。

常量宽度

用于程序清单,以及在段落内引用程序元素,如变量或函数名称、数据库、数据类型、环境变量、语句和关键字。

常量宽度粗体

显示用户应该按照文字直接输入的命令或其他文本。

常量宽度斜体

显示应由用户提供的值或根据上下文确定的值的文本。

提示

此元素表示提示或建议。

注意

此元素表示一般注释。

警告

此元素表示警告或注意事项。

使用代码示例

附加材料(例如代码示例、练习等)可在https://github.com/lmoroney/odmlbook下载。

如果您有技术问题或在使用代码示例时遇到问题,请发送电子邮件至bookquestions@oreilly.com

本书的目的在于帮助您完成工作。一般来说,如果本书提供了示例代码,您可以在自己的程序和文档中使用它。除非您复制了大量代码片段,否则无需征得我们的许可。例如,编写一个使用本书多个代码片段的程序不需要许可。销售或分发奥莱利书籍的示例需要许可。引用本书并引用示例代码回答问题不需要许可。将本书大量示例代码整合到产品文档中需要许可。

我们感激但通常不需要署名。署名通常包括书名、作者、出版社和 ISBN 号。例如:“AI and Machine Learning for On-Device Development by Laurence Moroney (O’Reilly). Copyright 2021 Laurence Moroney, 978-1-098-10174-9.”

如果您觉得自己使用的代码示例超出了合理使用范围或上述许可,请随时通过permissions@oreilly.com联系我们。

致谢

有很多人参与了这本书的创作,我想感谢每一位。

致 O’Reilly 团队,首先是丽贝卡·诺瓦克,她相信我足以让我写两本书,我非常感激你们!

吉尔·莱昂纳德从屏幕上的第一个像素到最后一点引导了手稿,并始终保持着愉快的心情,这不仅使我的工作变得轻松而且有趣!

克里斯汀·布朗管理了生产团队;丹尼·埃尔芳鲍姆,是指导最终手稿从我粗略文字到你手中拿到的光滑手稿的生产编辑;查尔斯·鲁梅利奥蒂斯,出色的副本编辑!

致力于挑战我,不断改进书籍、创建更好的代码和构建更好的应用的出色技术审阅团队:Martin Kemka、Laura Uzcátegui、Vishwesh Ravi Shrimali、Jialin Huang、Margaret Maynard-Reid、Su Fu、Darren Richardson、Dominic Monn 和 Pin-Yu Chen。非常感谢你们所做的一切!

我很幸运能与一些 AI 界的伟人一起工作,包括(但不限于)Deeplearning.AI 的安德鲁·吴、Eddy Shu、Ryan Keenan 和 Ortal Arel;Google 的杰夫·迪恩、Kemal El Moujahid、Magnus Hyttsten、Francois Chollet、Sarah Sirajuddin 和 Wolff Dobson;还有许多其他人!

但最重要的是,我要感谢我的家人,是他们让这一切都显得有意义。我的妻子,丽贝卡·莫罗尼,一个有着无限耐心的女人;我的女儿,克劳迪娅,通过她关怀的医疗工作正在改变世界;还有我的儿子,克里斯托弗,一个未来的人工智能明星!

第一章:人工智能与机器学习简介

你可能拿起这本书是因为对人工智能(AI)、机器学习(ML)、深度学习以及所有承诺最新和最伟大突破的新技术感到好奇。欢迎!在本书中,我的目标是解释一些关于 AI 和 ML 如何运作的知识,以及如何利用 TensorFlow Lite、ML Kit 和 Core ML 等技术在你的移动应用中使用它们。我们会从本章开始,轻松地建立起我们在描述人工智能、机器学习、深度学习等时实际上指的是什么。

什么是人工智能?

根据我的经验,人工智能(AI)已经成为历史上最基本误解的技术之一。也许这个误解的原因在于其名称——人工智能唤起了智能人工创造。也许它广泛在科幻小说和流行文化中使用的原因,AI 通常用来描述一个看起来和听起来像人类的机器人。我记得《星际迷航:下一代》中的 Data 角色就是人工智能的典范,他的故事引导他追求成为人类,因为他聪明且自我意识,但缺乏情感。像这样的故事和角色可能塑造了人工智能的讨论。其他如电影和书籍中的邪恶 AI 则导致了对 AI 潜在危险的恐惧。

鉴于 AI 经常被看作这些方式,很容易得出结论,它们定义了 AI。然而,这些都不是人工智能的实际定义或例子,至少在当今的术语中不是。它不是智能的人工创造——而是智能外观的人工创造。当你成为 AI 开发者时,你并不是在构建一个新的生命形式——你正在编写与传统代码不同的代码,它可以非常宽松地模拟智能对某事物的反应方式。这的一个常见例子是使用深度学习进行计算机视觉,在这里,你不是编写试图通过大量的 if...then 规则解析像素来理解图像内容的代码,而是可以通过“观察”大量样本来学习图像的内容是什么。

所以,例如,假设你想编写代码来区分一件 T 恤和一只鞋(图 1-1)。

图 1-1. 一件 T 恤和一只鞋

你会怎么做呢?嗯,你可能想要寻找特定的形状。T 恤上平行的明显垂直线,以及身体轮廓,是它是 T 恤的一个明显信号。靠近底部的粗横线,也就是鞋底,是它是鞋子的一个很好的指示。但是,要编写很多代码来检测这些特征。而且这只是一般情况下的情况——当然,对于非传统设计,比如开衫 T 恤,会有很多例外情况。

如果你要求一个智能生物在鞋子和 T 恤之间做选择,你会怎么做呢?假设它以前从未见过它们,你会向它展示许多鞋子的例子和许多 T 恤的例子,它会自己弄清楚什么让鞋子成为鞋子,什么让 T 恤成为 T 恤。你不需要给它很多规则来告诉它哪个是哪个。人工智能的工作方式与此类似。与其弄清楚所有这些规则并将它们输入计算机以区分它们,你向计算机展示大量 T 恤和鞋子的例子,它会自己弄清楚如何区分它们。

但计算机不会单独完成这项任务。它需要你编写的代码来完成。那些代码看起来和感觉起来与你通常编写的代码非常不同,而计算机用来学习区分的框架并不需要你自己来编写。已经有几个为此目的而存在的框架。在这本书中,你将学习如何使用其中之一,TensorFlow,来创建像我刚提到的那种应用程序!

TensorFlow 是一个端到端开源平台,用于机器学习。在本书中,你将广泛使用它的许多部分,从创建使用 ML 和深度学习的模型,到将它们转换为适合移动设备的格式并在 TensorFlow Lite 上执行,再到使用 TensorFlow-Serving 提供服务。它还支持诸如 ML Kit 之类的技术,提供许多常见模型作为即插即用的场景,并提供围绕移动场景设计的高级 API。

正如你在阅读本书时会看到的那样,AI 的技术并不特别新颖或激动人心。相对的是,以及使当前 AI 技术爆发成为可能的是,增加的低成本计算能力,以及大量数据的可用性。拥有这两者对于使用机器学习构建系统至关重要。但为了演示这个概念,让我们从小处开始,这样更容易理解。

什么是机器学习?

在前述场景中,你可能注意到我提到过智能生物会查看大量 T 恤和鞋子的例子,并试图找出它们之间的区别,这样一来,它就学会了如何区分它们。它以前从未接触过这些,因此通过被告知这些是 T 恤和鞋子来获取了关于它们的新知识。有了这些信息,它就能继续学习新的东西。

在以同样的方式编写计算机程序时,术语machine learning被使用。与人工智能类似,这种术语可能会给人一种错误印象,即计算机是一个像人类一样学习的智能实体,通过学习、评估、理论化、测试,然后记忆。在非常表面的层面上,它确实如此,但它的工作方式比人类大脑的方式要平凡得多。

换句话说,机器学习可以简单地描述为让代码函数自行找出它们自己的参数,而不是由人类程序员提供这些参数。它们通过试验和错误来找出这些参数,并通过智能优化过程来减少总体错误,从而推动模型朝着更高的准确性和性能前进。

现在这有点啰嗦,所以让我们看看实际操作中是什么样子。

从传统编程转向机器学习

要详细了解机器学习编码与传统编码的核心区别,让我们通过一个例子来说明。

考虑描述一条线的函数。你可能还记得这个来自高中几何学的知识:

y = Wx + B

这说明了对于某物体是一条线,线上每个点 y 都可以通过将 x 乘以 W 值(权重)并加上 B 值(偏差)来推导得到。

(注意:人工智能文献往往非常数学化。如果你刚开始接触,这些可能显得有些多余。这是我在本书中使用的极少数数学示例之一!)

现在,假设你给出了这条线上的两个点,假设它们在 x = 2,y = 3 和 x = 3,y = 5。我们如何编写代码来找出描述连接这两点的线的 W 和 B 的值?

让我们从 W 开始,我们称之为权重,但在几何学中,它也被称为斜率(有时候也称为梯度)。参见图 1-2。

图 1-2. 可视化斜率的线段

计算它很容易:

W = (y2-y1)/(x2-x1)

因此,如果我们填写,我们可以看到斜率是:

W = (5-3)/(3-2) = (2)/(1) = 2

或者,在代码中,在这种情况下使用 Python:

def get_slope(p1, p2):
  W = (p2.y - p1.y) / (p2.x - p1.x)
  return W

这个函数有点奇怪。它很天真,因为它忽略了当两个 x 值相同时的除零情况,但现在就这样继续吧。

好的,所以我们现在已经找出了 W 值。为了得到线的函数,我们还需要找出 B 值。回到高中几何学,我们可以使用其中一个点作为例子。

所以,假设我们有:

y = Wx + B

我们还可以说:

B = y - Wx

我们知道当 x = 2,y = 3 时,W = 2,我们可以回填这个函数:

B = 3 - (2*2)

这导致我们得出 B 是 −1。

再次,在代码中,我们将写:

def get_bias(p1, W):
    B = p1.y - (W * p1.x)
    return B

因此,现在,为了确定在给定 x 的线上的任何点,我们可以很容易地说:

def get_y(x, W, B):
  y = (W*x) + B
  return y

或者,对于完整的列表:

def get_slope(p1, p2):
    W = (p2.y - p1.y) / (p2.x - p1.x)
    return W

def get_bias(p1, W):
    B = p1.y - (W * p1.x)
    return B

def get_y(x, W, B):
    y = W*x + B

p1 = Point(2, 3)
p2 = Point(3, 5)

W = get_slope(p1, p2)
B = get_bias(p1, W)

# Now you can get any y for any x by saying:
x = 10
y = get_y(x, W, B)

从这些中,我们可以看到当 x 是 10 时,y 将是 19。

你刚刚经历了一个典型的编程任务。您有一个问题需要解决,而您可以通过弄清楚 规则 并将其表达为代码来解决问题。在给定两个点时,有一个 规则 可以计算出 W,然后您创建了该代码。然后,一旦您弄清楚了 W,再使用 W 和一个单一点来计算 B 时,会产生另一个规则。然后,一旦您有了 W 和 B,您可以编写另一个规则,根据 W、B 和给定的 x 计算 y。

这就是传统编程,现在常常被称为基于规则的编程。我喜欢在 图 1-3 中总结这一点的图解。

图 1-3. 传统编程

在其最高层次上,传统编程涉及创建作用于 数据规则 并为我们提供 答案。在前面的场景中,我们有了数据——线上的两个点。然后,我们找出了作用于这些数据以找出该线方程的规则。然后,给定这些规则,我们可以获得新数据项的答案,以便我们可以,例如,绘制那条线。

在这种情况下,程序员的核心工作是 找出规则。这是您为任何问题带来的价值—将其分解为定义它的规则,然后用编程语言表达这些规则。

但您可能无法轻易表达这些规则。考虑之前的场景,当我们想要区分 T 恤和鞋子时。人们并非总能想出其规则,然后将这些规则表达为代码。这就是机器学习可以帮助的地方,但在我们进入计算机视觉任务的机器学习之前,让我们考虑机器学习如何用于计算这条线的方程,如我们之前拟定的那样。

机器如何学习?

鉴于前面的情景,您作为程序员找出组成线的规则,计算机实现了这些规则,现在让我们看看机器学习方法将会有何不同。

让我们从理解机器学习代码的结构开始。虽然这在很大程度上是一个“Hello World”问题,但代码的整体结构非常类似于您在更复杂的问题中看到的代码。

我喜欢绘制一个高级架构,概述使用机器学习来解决这样的问题。记住,在这种情况下,我们会有 x 和 y 值,所以我们想要弄清楚 W 和 B,以便有一条线性方程;一旦我们有了这个方程式,我们就可以根据 x 来获取新的 y 值。

步骤 1:猜测答案

是的,你没看错。首先,我们不知道答案可能是什么,所以一个猜测与其他任何答案一样好。在实际情况下,这意味着我们将为 W 和 B 选择随机值。稍后我们会以更多的智能回到这一步,所以后续值不会是随机的,但我们将从纯粹的随机开始。因此,例如,让我们假设我们的第一个“猜测”是 W = 10 和 B = 5。

步骤 2: 测量我们猜测的准确性

现在我们有了 W 和 B 的值,我们可以将它们用在我们已知的数据上,看看我们的猜测有多好或多坏。因此,我们可以使用 y = 10x + 5 来计算每个 x 值的 y,将这个 y 与“正确”值进行比较,并据此推断我们的猜测有多好或多坏。显然,在这种情况下,我们的猜测非常糟糕,因为我们的数字会大大偏离。稍后我们会详细介绍这一点,但现在我们意识到我们的猜测非常糟糕,并且我们有一个衡量指标。这通常被称为损失

步骤 3: 优化我们的猜测

现在我们有了一个猜测,并且我们对该猜测结果(或损失)有了了解,这些信息可以帮助我们创建一个新的、更好的猜测。这个过程称为优化。如果你以前看过任何涉及到 AI 编码或训练的内容,而且涉及数学较多,那么很可能你正在看优化过程。在这里,使用一种称为梯度下降的高级微积分过程可以帮助我们进行更好的猜测。虽然我在这里不会详细讨论这些内容,而且了解优化工作原理是一个有用的技能,但事实上,像 TensorFlow 这样的框架已经为你实现了它们,所以你可以直接使用它们。随着时间的推移,你可以深入研究这些内容,以便为更复杂的模型调整它们的学习行为。但是现在,你可以放心地使用内置优化器。完成这一步后,你只需返回步骤 1。重复这个过程,从定义上来说,帮助我们随着时间和许多循环,找出参数 W 和 B。

这就是为什么这个过程被称为机器学习。随着时间的推移,通过做出猜测,找出该猜测有多好或多坏,根据这些情报优化下一个猜测,然后重复这个过程,计算机将“学习”参数 W 和 B(或其他任何东西),然后,它将找出构成我们线条的规则。从视觉上看,这可能看起来像图 1-4。

图 1-4. 机器学习算法

在代码中实现机器学习

这是很多描述,也是很多理论。现在让我们看看这在代码中的样子,这样你就可以亲自看到它运行了。这段代码一开始可能对你来说有些陌生,但随着时间的推移,你会掌握它的。我喜欢把这称为机器学习的“Hello World”,因为你使用一个非常基础的神经网络(稍后我会解释一下)来“学习”给定线上几个点时的参数 W 和 B。

这是代码(此代码示例的完整笔记本可在本书的 GitHub 中找到):

model = Sequential(Dense(units=1, input_shape=[1]))
model.compile(optimizer='sgd', loss='mean_squared_error')

xs = np.array([-1.0, 0.0, 1.0, 2.0, 3.0, 4.0], dtype=float)
ys = np.array([-3.0, -1.0, 1.0, 3.0, 5.0, 7.0], dtype=float)

model.fit(xs, ys, epochs=500)

print(model.predict([10.0]))
注意

这是使用 TensorFlow Keras API 编写的。Keras 是一个开源框架,旨在通过高级 API 使模型的定义和训练更加容易。它在 2019 年与 TensorFlow 2.0 的发布中与 TensorFlow 紧密集成。

让我们逐行探讨一下。

首先是模型的概念。在创建关于数据细节的代码时,我们通常使用术语“模型”来定义结果对象。在这种情况下,模型大致相当于之前编码示例中的get_y()函数。这里不同的是,模型不需要自己提供 W 和 B。它将根据给定的数据自行计算它们,因此你只需向它询问 y 并给它一个 x,它就会给出它的答案。

因此,我们的第一行代码看起来像这样——它正在定义模型:

model = Sequential(Dense(units=1, input_shape=[1]))

但是剩下的代码是什么呢?好吧,让我们从单词Dense开始,你可以在第一组括号内看到它。你可能已经看过类似于图 1-5 的神经网络的图片。

图 1-5. 基础神经网络

你可能会注意到,在图 1-5 中,左侧的每个圆圈(或神经元)都连接到右侧的每个神经元。每个神经元都以密集的方式连接到每个其他神经元。因此得名为Dense。此外,左侧有三个堆叠的神经元,右侧有两个堆叠的神经元,这些形成了序列中非常明显的“层”,其中第一“层”有三个神经元,第二“层”有两个神经元。

所以让我们回到代码中:

model = Sequential(Dense(units=1, input_shape=[1]))

这段代码表示我们想要一系列的层(Sequential),在括号内,我们将定义这些层的序列。序列中的第一个将是Dense,指示一个像图 1-5 中的神经网络。没有定义其他层,因此我们的Sequential只有一层。这一层只有一个单元,由units=1参数表示,该单元的输入形状只是一个单一的值。

因此,我们的神经网络看起来像图 1-6。

图 1-6. 最简单的可能神经网络

这就是为什么我喜欢称之为神经网络的“Hello World”。它只有一个层,该层只有一个神经元。就是这样。因此,通过这行代码,我们已经定义了我们的模型架构。让我们继续下一行:

model.compile(optimizer='sgd', loss='mean_squared_error')

这里我们在指定内置函数来计算损失(记住第 2 步,我们想看看我们的猜测有多好或多坏)和优化器(第 3 步,我们生成一个新的猜测),以便我们可以改进神经元内 W 和 B 的参数。

在这种情况下,'sgd'代表“随机梯度下降”,这超出了本书的范围;总之,它使用微积分和均方误差损失来找出如何最小化损失,一旦损失被最小化,我们应该有准确的参数。

接下来,让我们定义我们的数据。两个点可能不足以,所以我扩展到六个点作为示例:

xs = np.array([-1.0, 0.0, 1.0, 2.0, 3.0, 4.0], dtype=float)
ys = np.array([-3.0, -1.0, 1.0, 3.0, 5.0, 7.0], dtype=float)

np代表“NumPy”,这是一个在数据科学和机器学习中常用的 Python 库,使数据处理非常简单。你可以在https://numpy.org了解更多关于 NumPy 的信息。

我们将创建一个 x 值及其对应的 y 值的数组,这样当 x = −1 时,y 将为−3;当 x 为 0 时,y 为−1,依此类推。快速检查显示,你可以看到 y = 2x − 1 在这些值上成立。

接下来让我们执行之前提到的循环 —— 进行猜测,测量那个损失值有多好或多坏,优化以获得新的猜测,并重复。在 TensorFlow 术语中,这通常被称为拟合 —— 即我们有 x 和 y,我们想要将 x 拟合到 y,或者换句话说,找出给定 x 时正确 y 的规则,使用我们拥有的示例。epochs=500参数简单地表示我们将重复这个循环 500 次:

model.fit(xs, ys, epochs=500)

当你运行像这样的代码(如果你对此不太熟悉,稍后在本章中你会看到如何做到这一点),你将看到如下输出:

Epoch 1/500
1/1 [==============================] - 0s 1ms/step - loss: 32.4543
Epoch 2/500
1/1 [==============================] - 0s 1ms/step - loss: 25.8570
Epoch 3/500
1/1 [==============================] - 0s 1ms/step - loss: 20.6599
Epoch 4/500
1/1 [==============================] - 0s 2ms/step - loss: 16.5646
Epoch 5/500
1/1 [==============================] - 0s 1ms/step - loss: 13.3362

注意loss值。单位并不重要,但重要的是它在变小。记住,损失越低,模型表现越好,其答案也会越接近你的预期。所以第一次猜测的损失为 32.4543,但到第五次猜测时,这个值已降至 13.3362。

如果我们然后查看我们 500 次中的最后 5 个周期,并探索损失:

Epoch 496/500
1/1 [==============================] - 0s 916us/step - loss: 5.7985e-05
Epoch 497/500
1/1 [==============================] - 0s 1ms/step - loss: 5.6793e-05
Epoch 498/500
1/1 [==============================] - 0s 2ms/step - loss: 5.5626e-05
Epoch 499/500
1/1 [==============================] - 0s 1ms/step - loss: 5.4484e-05
Epoch 500/500
1/1 [==============================] - 0s 4ms/step - loss: 5.3364e-05

它小了很多,大约是 5.3 x 10^(-5)的量级。

这表明神经元找出的 W 和 B 的值只有微小的差异。虽然不是零,所以我们不应期望得到确切的正确答案。例如,假设我们给它 x = 10,如下所示:

print(model.predict([10.0]))

答案不会是 19,而是一个非常接近 19 的值,通常大约是 18.98。为什么?原因有两个。首先,像这样的神经网络处理概率而不是确定性,所以它找出的 W 和 B 是高度可能正确但可能不是 100%准确的。第二个原因是我们只给了神经网络六个点。虽然这六个点线性的,但这并不证明我们可能预测的每个其他点都在这条线上。数据可能会偏离这条线……这种情况的概率非常低,但并非零。我们没有告诉计算机这是一条直线,我们只是要求它找出与 x 和 y 相匹配的规则,而它找到的看起来像是一条直线但并不能保证是一条直线。

当处理神经网络和机器学习时,有一点需要注意——你将会处理到这样的概率!

方法名称中也包含了我们模型的提示——请注意,我们没有要求它计算 x = 10.0 时的 y,而是要预测它。在这种情况下,预测(通常称为推断)反映了模型将根据其所知来尝试确定值将会是什么,但它可能并不总是正确的。

比较机器学习与传统编程

回顾图 1-3,描述传统编程的方式是:你为给定的情景找出规则,将其表达为代码,让代码作用于数据,得出答案。机器学习非常类似,只是过程的某些部分是反向的。参见图 1-7。

图 1-7. 从传统编程到机器学习

机器学习的关键区别在于,你不需要弄清楚规则!相反,你提供答案和数据,机器会为你找出规则。在前面的例子中,我们为一些给定的 x 值(即数据)提供了正确的 y 值(即答案),计算机找出了适合将 x 映射到 y 的规则。我们没有进行任何几何、斜率计算、截距或类似的操作。机器找出了符合 x 和 y 的模式。

这是机器学习与传统编程的核心重要区别,也是围绕机器学习所有兴奋的原因,因为它开辟了全新的应用场景。其中一个例子是计算机视觉——正如我们之前讨论的,尝试编写规则来区分 T 恤和鞋子之间的差异将会非常困难。但是让计算机找出如何匹配另一个使这种情景成为可能,并且从那里开始,更重要的场景——如解释 X 光或其他医学扫描图像,检测大气污染等——也变得可能。事实上,研究表明,在许多情况下,使用这些类型的算法和足够的数据已经使计算机在特定任务上与人类一样好,有时甚至更好。为了好玩,可以查看这篇博文,其中谷歌的研究人员使用预先诊断的视网膜图像对神经网络进行训练,并让计算机找出确定每种诊断的因素。随着时间的推移,计算机已经能够像最优秀的专家一样进行不同类型的糖尿病性视网膜病变的诊断!

在移动设备上构建和使用模型

在这里,您看到了一个非常简单的示例,展示了如何从基于规则的编程过渡到机器学习来解决问题。但是,如果您无法将其交付给用户,那么解决问题就没有多大用处了,而借助于在运行 Android 或 iOS 的移动设备上的 ML 模型,您将正好做到这一点!

这是一个复杂而多样的领域,在本书中,我们将通过多种不同的方法来为您简化这一过程。

例如,您可能已经有一个现成的解决方案,可以为您解决问题,而您只是想学习如何做到这一点。我们将覆盖像面部检测这样的场景,其中一个模型将为您检测图片中的面部,而您希望将其集成到您的应用程序中。

此外,还有许多场景不需要从头开始构建模型,设计架构并进行漫长而费力的训练。经常可以使用一种称为迁移学习的场景,这是您可以利用预先存在的模型的部分并重新利用它们的地方。例如,大型科技公司和顶尖大学的研究人员可以访问您可能无法访问的数据和计算能力,并已经利用这些来构建模型。他们与世界分享了这些模型,以便它们可以被重复使用和重新利用。您将在本书中广泛探讨这一点,从第二章开始。

当然,你可能还有需要从头开始构建自己模型的场景。这可以通过 TensorFlow 来完成,但我们在这里只是轻描淡写地提及一下,而是集中于移动场景。这本书的合作伙伴,《面向编程人员的 AI 与机器学习》,重点讲解这种场景,从基本原理开始教授你如何从零开始为各种情况构建模型。

摘要

在这一章中,你将会对人工智能和机器学习有所了解。希望这能帮助你剖析炒作,从程序员的角度看清楚这一切真正的本质,从而识别出 AI 和 ML 可以极其有用和强大的场景。你详细了解了机器学习的工作原理以及计算机如何通过“循环”学习如何将值彼此拟合,匹配模式并“学习”将它们组合在一起的规则。从那里开始,计算机可以有些智能地行动,这使我们得以借用“人工”智能这一术语。你还学习了与成为机器学习或人工智能程序员相关的术语,包括模型、预测、损失、优化、推断等等。

从第三章开始,你将会使用这些示例将机器学习模型实现到移动应用程序中。但首先,让我们探索构建一些更多模型的过程,看看这一切是如何运作的。在第二章中,我们将研究构建一些更复杂的计算机视觉模型!

第二章:计算机视觉简介

虽然本书并非旨在教授你所有架构和训练机器学习模型的基础知识,但我确实想涵盖一些基本情景,以使本书仍然可以作为独立学习的一部分。如果你想了解更多有关使用 TensorFlow 创建模型的过程,请参考我的书籍,AI and Machine Learning for Coders,由 O'Reilly 出版,如果你想深入了解,Aurelien Geron 的优秀著作 Hands-on Machine Learning with Scikit-Learn, Keras, and TensorFlow(O'Reilly)是必读的!

在本章中,我们将超越你在第一章中创建的非常基础的模型,并且探讨两种更复杂的模型,这些模型涉及计算机视觉——即计算机如何“看见”物体。类似于“人工智能”和“机器学习”这些术语,术语“计算机视觉”和“看见”可能会让人误解模型的基本运作方式。

计算机视觉是一个广阔的领域,针对本书和本章的目的,我们将狭义地专注于几个核心情景,在这些情景中,我们将使用技术来解析图像的内容,无论是标记图像的主要内容,还是在图像中找到物品。

这并不真正涉及“视觉”或“看见”,而更多地是使用结构化算法,允许计算机解析图像的像素。当计算机将单词解析为独立字符串时,它并不“理解”图像的含义!

如果我们试图使用传统的基于规则的方法来执行这个任务,即使对于最简单的图像,我们也会得到许多行代码。在这里,机器学习是一个关键角色;正如你将在本章中看到的那样,通过使用我们在第一章中的相同代码模式,但稍微深入一些,我们可以创建能够仅用几行代码解析图像内容的模型。所以让我们开始吧。

使用神经元进行视觉处理

在你在第一章编写的示例中,你看到了神经网络如何在给定线上某些点的示例时,“拟合”自身以符合线性方程的期望参数。从视觉上表示,我们的神经网络可以看起来像图 2-1。

图 2-1. 使用神经网络将 X 拟合到 Y

这是可能的最简单的神经网络,只有一个层,该层只有一个神经元。

注意

事实上,在创建该示例时我稍作作弊,因为密集层中的神经元本质上是线性的,它们学习一个权重和一个偏置,所以一个神经元足以表示线性方程!

但是当我们编写代码时,请回忆我们创建了一个Sequential,并且该Sequential包含一个Dense,就像这样:

model = Sequential(Dense(units=1))

如果我们希望拥有更多层,我们可以使用相同的代码模式;例如,如果我们想要表示像图 2-2 中的神经网络,这将非常容易实现。

图 2-2. 稍微更高级的神经网络

首先,让我们考虑图 2-2 中的图表元素。每个垂直排列的神经元都应被视为一个。图 2-3 向我们展示了该模型中的层。

图 2-3. 神经网络中的层

要编写这些代码,我们只需将它们列在Sequential定义中,更新我们的代码如下:

model = Sequential(
            [Dense(units=2),
             Dense(units=1)])

我们只需将层定义为逗号分隔的列表,并将其放在Sequential中,如你所见,这里有一个包含两个单位的Dense,后跟一个包含一个单位的Dense,我们得到了图 2-3 中展示的架构。

但在这些情况下,我们的输出层只有一个单一值。输出处有一个神经元,鉴于该神经元只能学习一个权重和一个偏差,对于理解图像内容来说并不是很有用,因为即使是最简单的图像也包含了太多内容,无法仅通过单一值来表示。

那么,如果我们在输出上有多个神经元会怎么样?考虑一下图 2-4 中的模型。

图 2-4. 具有多个输出的神经网络

现在我们有多个输入和多个输出。为了设计能够识别和解析图像内容的东西(你会回忆起,这就是我们定义计算机视觉的方式),如果我们将输出神经元分配给我们想要识别的类别会怎么样呢?

我们是什么意思呢?很像学习一门语言,你需要逐字逐句学习,所以在学习如何解析图像时,我们必须限制计算机能够“看到”的事物数量。因此,例如,如果我们想要从简单开始,并让计算机识别猫和狗之间的差异,我们可以创建这样一个包含两种图像类型(猫或狗)的“词汇表”,并为每个类型分配一个输出神经元。这里通常使用术语,请不要与面向对象编程中的类概念混淆。

因为你要让模型识别的“类别”数量是固定的,所以通常使用的术语是分类图像分类,你的模型也可能被称为分类器

所以,要识别猫或狗,我们可以更新图 2-4,使其看起来像图 2-5。

图 2-5. 更新以适应猫或狗

所以我们将一张图片输入神经网络,最后它有两个神经元。这些神经元将分别输出一个数字,我们希望这个数字表示网络认为它“看到”了一只狗或一只猫。这种方法可以扩展到其他类别,所以如果你想识别不同的动物,那么你只需添加表示它们类别的额外输出神经元。但让我们暂时只保持两个。

现在我们的问题变成了,如何表示我们的数据,以便计算机开始将我们的输入图像与我们期望的输出神经元匹配?

一种方法是使用称为 one-hot encoding 的东西。起初,这看起来有点繁琐和浪费,但当你理解其背后的概念以及它如何匹配神经网络架构时,它开始变得合理。这种编码背后的理念是拥有一个大小等于我们类别数的值数组。这个数组中的每个条目都是零,除了表示你想要的类别的那个条目,你将它设置为 1。

所以,例如,如果你看 图 2-5,有两个输出神经元——一个代表猫,一个代表狗。因此,如果我们想要表示“猫的样子”,我们可以表示为 [1,0],类似地,如果我们想要表示“狗的样子”,我们可以编码为 [0,1]。此时,你可能会想到当你识别更多类别时,比如 1,000 个类别,每个数据标签都会有 999 个 0 和一个单独的 1,这看起来是多么浪费。

这绝对不是高效的,但在训练模型时,你只会暂时存储这样的数据图像,而在完成后你可以有效地丢弃它们。你的模型的输出层将有与此编码匹配的神经元,因此当你读取它们时,你会知道哪些代表哪个类别。

所以,如果我们更新我们的图表,从 图 2-5 这里更新了猫和狗的样式编码,然后如果我们输入一张猫的图像,我们希望输出看起来像这些编码,就像 图 2-6。

图 2-6. 使用 one-hot 编码标记一只猫

现在神经网络的行为符合我们的期望。我们输入一张猫的图片,输出神经元响应编码 [1,0] 表示它“看到”了一只猫。这为我们提供了可以用来训练网络的数据表示的基础。因此,例如,如果我们有一堆猫和狗的图像,并相应地标记这些图像,那么随着时间的推移,神经网络可能会“适应”这些输入内容和这些标签,使得未来的图像输出相同。

实际上,输出神经元会输出一个介于 0 和 1 之间的值,而这也恰好是一个概率值。因此,如果你用一个独热编码标签训练图像的神经网络,并输出每个类别一个神经元,你最终会得到一个能解析图像并返回它所能看到的东西概率列表的模型,就像图 2-7 一样。

图 2-7. 解析图像内容

在这里,你可以看到模型确定它正在看一只香蕉的概率为 98.82%,而看到格兰尼史密斯苹果或无花果的概率较小。虽然很明显这是一根香蕉,但当这款应用看图时,它正在从图像中提取特征,其中一些特征可能也存在于苹果中,比如皮肤质地或颜色。

因此,你可以想象,如果你想训练一个能看的模型,你需要大量的图像示例,并且这些图像需要按类别标记。幸运的是,有一些基本数据集限定了范围,使得学习变得简单,接下来我们将从头开始构建一个分类器。

你的第一个分类器:识别衣物

作为我们的第一个例子,让我们考虑如何在图像中识别衣物。例如,考虑图 2-8 中的物品。

图 2-8. 衣物示例

这里展示了多种不同的衣物,你可以认出它们。你知道什么是衬衫,或者外套,或者裙子。但是如果要向从未见过衣物的人解释,你该如何描述鞋子?这张图里有两只鞋,但你要如何描述给别人听?这也是我们之前在第一章提到的基于规则的编程可能失败的另一个领域。有时候用规则来描述某些事物是不可行的。

当然,计算机视觉也不例外。但是想一想你是如何学会认识所有这些物品的——通过看大量不同的例子,并获得它们使用方式的经验。我们能否用同样的方式教会计算机?答案是肯定的,但有限制。让我们来看一个第一个示例,如何教计算机识别衣物,使用一个名为时尚 MNIST 的知名数据集。

数据:时尚 MNIST

用于学习和基准测试算法的基础数据集之一是由 Yann LeCun、Corinna Cortes 和 Christopher Burges 创建的 Modified National Institute of Standards and Technology(MNIST)数据库。该数据集包含了 70,000 张 0 到 9 的手写数字图像。这些图像是 28 × 28 的灰度图像。

时尚 MNIST旨在成为 MNIST 的替代品,记录数、图像尺寸和类别数都与其相同——因此,与数字 0 到 9 的图像不同,时尚 MNIST 包含 10 种不同类型的服装图像。

您可以在 图 2-9 中看到数据集内容的示例。这里,每种服装类型都有三行。

图 2-9. 探索时尚 MNIST 数据集

它包含了各种服装,包括衬衫、裤子、连衣裙和各种类型的鞋子!正如您可能注意到的那样,它是灰度的,因此每张图片由一定数量的像素组成,其值在 0 到 255 之间。这使得数据集更容易管理。

您可以在 图 2-10 中看到数据集中特定图像的放大视图。

图 2-10. 时尚 MNIST 数据集中图像的放大视图

就像任何图像一样,它是一个矩形像素网格。在这种情况下,网格尺寸为 28 × 28,每个像素只是介于 0 到 255 之间的值,如前所述。

用于解析时尚 MNIST 的模型架构

现在让我们看看如何将这些像素值与先前看到的计算机视觉架构结合使用。

您可以在 图 2-11 中看到这一表示。请注意,时尚 MNIST 中有 10 类服装,因此我们需要一个包含 10 个神经元的输出层。请注意,为了更容易适应页面,我已经旋转了架构,使得 10 个神经元的输出层位于底部,而不是右侧。

“上面”的神经元数量,当前设置为 20 以适应页面,可能会随着您编写代码而改变。但是,我们的想法是将图像的像素馈送到这些神经元中。

图 2-11. 用于识别时尚图像的神经网络架构

鉴于我们的图像是矩形的,尺寸为 28 × 28 像素,我们需要以与图层中神经元表示方式相同的方式来表示它,即一维数组,因此我们可以遵循所谓的“展平”图像的过程,这样它就变成了 784 × 1 的数组。然后它与输入神经元具有类似的“形状”,因此我们可以开始将其馈送进去。参见 图 2-12。请注意,由于来自 图 2-10 的踝靴图像在时尚 MNIST 中是类别“9”,我们还将通过说这是应该点亮的神经元来进行训练。我们从 0 开始计数,因此类别 9 的神经元是 图 2-12 中最右侧的第 10 个。现在,“Dense”这个层类型的术语之所以得到这个名称,应该更加直观了!

图 2-12. 使用时尚 MNIST 训练神经网络

鉴于训练集中有 60,000 张图像,在 第 1 章 中提到的训练循环将会发生。首先,网络中的每个神经元将被随机初始化。然后,对于这 60,000 个带标签的图像中的每一个,将进行分类。这次分类的准确性和损失将帮助优化器调整神经元的值,然后我们再次尝试,依此类推。随着时间的推移,神经元内部的权重和偏置将被调整以匹配训练数据。现在让我们在代码中来探索这一点。

编码时尚 MNIST 模型

先前描述的模型架构在这里展示:

model = Sequential(
    [Flatten(input_shape=(28,28)),
     Dense(20, activation=tf.nn.relu),
     Dense(10, activation=tf.nn.softmax)])

就是这么简单!这里有一些新概念,让我们来探索一下。

首先,我们可以看到我们使用了 Sequential。回想一下,这允许我们使用列表定义网络中的层。列表中的每个元素定义了一个层类型(在本例中是一个 Flatten,后跟两个 Dense 层),以及关于层的细节,如神经元数量和激活函数。

第一层是:

Flatten(input_shape=(28,28))

这展示了层的部分功能——你不仅可以使用它们定义模型架构,还可以将功能封装在层内。因此,这里你的输入形状从 28 × 28 扁平化为 784 × 1,以便输入到神经网络中。

在此之后,你有两个层,正如我们在 图 2-12 中展示的,一个具有 20 个神经元的 Dense 层,以及另一个具有 10 个神经元的 Dense 层。

但这里也有一些新东西——activation 参数。这定义了一个激活函数,它在处理层的末尾执行。激活函数可以帮助网络识别更复杂的模式,并改变信息在层与层之间流动的行为,有助于网络更好地学习和更快地学习。

它们是可选的,但它们非常有用,通常建议使用。

在 20 个神经元的层上,激活函数是 tf.nn.relu,其中 relu 代表“修正线性单元”。这是一个相当花哨的术语,实际上等同于——如果值小于零,则将其设置为零;否则保持不变。有点像:

if val<0:
   return 0
else:
   return val

它如何帮助的是,如果任何一个层中的神经元返回一个 值,那可能会取消另一个神经元的 值,从而忽略它所学到的内容。因此,我们在每次迭代中不需要对每个神经元进行大量检查,而是在层上简单地使用激活函数来执行。

同样,输出层具有一个称为softmax的激活函数。这里的想法是,我们的输出层有 10 个神经元。理想情况下,它们应该全部为零,除了其中一个神经元为 1,表示我们的类别。实际情况很少发生,每个神经元都会有一个值。最大的那个值将是我们输入图像分类的最佳候选项。然而,为了报告一个概率,我们希望每个神经元的值加起来为 1,并且它们的值应该经过适当的缩放。我们可以简单地在层上应用softmax激活函数来处理这些,而不需要编写处理代码!

这只是模型架构。现在让我们来探索完整的代码,包括获取数据、编译模型,然后进行训练:

import tensorflow as tf

data = tf.keras.datasets.mnist
(training_images, training_labels), (val_images, val_labels) = data.load_data()

training_images  = training_images / 255.0
val_images = val_images / 255.0

model = tf.keras.models.Sequential(
            [tf.keras.layers.Flatten(input_shape=(28,28)),
            tf.keras.layers.Dense(20, activation=tf.nn.relu),
            tf.keras.layers.Dense(10, activation=tf.nn.softmax)])

model.compile(optimizer='adam',
              loss='sparse_categorical_crossentropy',
              metrics=['accuracy'])

model.fit(training_images, training_labels, epochs=20)

还记得之前我提到过传统编码解析图像内容的方式吗,即使是像 Fashion MNIST 这样简单的图像,也可能需要成千上万行代码来处理,但机器学习只需要几行代码就可以完成?好了,这里它们就是!

首先要获取数据。Fashion MNIST 数据集已经集成在 TensorFlow 中,所以我们可以这样轻松地获取它:

data = tf.keras.datasets.fashion_mnist
(training_images, training_labels), (val_images, val_labels) = data.load_data()

执行完这行代码后,training_images将包含我们的 60,000 个训练图像,training_labels将包含它们对应的标签。此外,val_imagesval_labels将包含 10,000 个图像及其对应的标签。在训练时,我们不会使用它们,这样我们就可以得到一组神经网络以前没有“见过”的数据,从而探索它的有效性。

接下来是这些行:

training_images  = training_images / 255.0
val_images = val_images / 255.0

在 Python 中使用 NumPy 非常强大,如果你将一个数组除以一个值,那么数组中的每个项目都将被该值除以。但是为什么我们要除以 255 呢?

这个过程称为归一化,这是一个相当花哨的术语,意味着将一个值设置为介于 0 和 1 之间的某个值。我们的像素值介于 0 和 255 之间,因此通过除以 255,我们将它们归一化。为什么要归一化呢?在Dense中的数学运算最好在值介于 0 和 1 之间时进行,这样当值较大时错误不会显著增加。你可能还记得在第一章中的 y = 2x − 1 的例子中我们没有进行归一化。那是一个不需要归一化的简单例子,但大多数情况下,在将数据馈送到神经网络之前,你需要对数据进行归一化!

然后,在定义模型架构之后,你编译模型,指定损失函数和优化器:

model.compile(optimizer='adam',
              loss='sparse_categorical_crossentropy',
              metrics=['accuracy'])

这些与您在第一章中使用的sgdmean_squared_error不同。 TensorFlow 有一个这些函数的库,通常可以从中选择进行实验,并看看哪种对您的模型效果最好。 这里有一些约束条件,最显著的是loss函数。 鉴于该模型将具有多个输出神经元,并且这些神经元为我们提供输出的类别或类别,我们将希望使用分类损失函数有效地对它们进行测量,因此我选择了sparse_categorical_crossentropy。 理解这些函数的工作原理超出了本书的范围,但是尝试不同的损失函数和优化器是很好的。 对于优化器,我选择了adam,这是sgd的增强版本,内部调整自身以获得更好的性能。

还要注意,我使用了另一个参数—metrics=['accuracy']—这要求 TensorFlow 在训练时报告准确性。 因为我们正在进行分类模型训练,我们希望分类器告诉我们它认为它看到了什么,因此我们可以使用基本准确性,即它在猜测中有多少训练图像“正确”,并在此基础上报告损失值。 通过在编译时指定指标,TensorFlow 将向我们报告这些。

最后,我们可以将训练值与训练数据拟合:

model.fit(training_images, training_labels, epochs=20)

我将其设置为通过将epochs设置为 20 来进行整个训练循环(进行猜测,评估和测量损失,优化,重复),并要求将训练图像与训练标签拟合。

在训练过程中,您将看到如下输出:

Epoch 1/20
1875/1875 [=====================] - 2s 1ms/step - loss: 0.4214 - accuracy: 0.8844
Epoch 2/20
1875/1875 [=====================] - 2s 1ms/step - loss: 0.2237 - accuracy: 0.9356
Epoch 3/20
1875/1875 [=====================] - 2s 1ms/step - loss: 0.1897 - accuracy: 0.9450

注意准确率:仅经过三个循环,训练集的准确率已经达到 94.5%! 我使用 Google Colab 进行了训练,我们可以看到每个循环,尽管处理了 60,000 张图像,但仅需两秒。 最后,您将看到值为 1875/1875,并且您可能想知道它们是什么? 在训练过程中,您不必一次处理一张图像,TensorFlow 支持批处理以加快速度。 Fashion MNIST 默认每个批次包含 32 张图像,因此每次一批次地进行训练。 这为您提供了 1875 批次的图像,以组成 60,000 张图像(即 60,000 除以 32 = 1875)。

当您达到第 20 个 epoch 时,您将看到准确率已经超过 97%:

Epoch 19/20
1875/1875 [=====================] - 2s 1ms/step - loss: 0.0922 - accuracy: 0.9717
Epoch 20/20
1875/1875 [=====================] - 2s 1ms/step - loss: 0.0905 - accuracy: 0.9722

因此,仅需几行代码和不到一分钟的训练,您现在拥有了一个可以以超过 97%的准确率识别 Fashion MNIST 图像的模型。

还记得之前您还留了 10,000 张图片作为验证数据集吗?现在可以将它们传递给模型,看看模型如何解析它们。请注意,它以前从未见过这些图片,所以这是测试您的模型真实准确性的一个好方法——如果它能够高准确度地分类之前未见过的图片。您可以通过调用model.evaluate来实现这一点,将图片和标签传递给它:

model.evaluate(val_images, val_labels)
313/313 [=====================] - 0s 872us/step - loss: 0.1320 - accuracy: 0.9623

从这里您可以看到,您的模型在之前未见过的数据上准确率达到了 96%,这告诉您您有一个非常好的模型来预测时尚数据。机器学习中的一个概念叫做过拟合,您希望在这里避免它。过拟合是指当您的模型在理解其训练数据方面表现非常出色时,在理解其他数据方面表现不佳。这将通过训练准确率和验证准确率之间的较大差异来指示。可以将其类比为如果您教会一个智能体高跟鞋是什么,但只向它展示过高跟鞋。那么它会“认为”所有鞋子都是高跟鞋,如果随后向它展示一双运动鞋,它就会对高跟鞋过拟合。在神经网络中也要避免这种实践,但我们可以看到我们在这里的训练和验证准确率之间只有很小的差异!

这向您展示了如何创建一个简单的模型来学习如何“看到”图像的内容,但它依赖于非常简单的单色图像,其中数据是图片中唯一的东西,并位于图像框架的中心。识别真实世界图像的模型需要比这个模型复杂得多,但可以使用一种称为“卷积神经网络”的东西来构建它们。详细介绍它们的工作原理超出了本书的范围,但请查阅本章开头提到的其他书籍,以获取更详尽的覆盖范围。

但有一件事情确实可以做,而不必进一步深入了解模型架构类型,那就是所谓的迁移学习,我们将在接下来探讨它。

计算机视觉的迁移学习

考虑之前讨论过的 Fashion MNIST 的架构,如图 2-12 所示。尽管其设计用于分类的数据相对简单,但它已经看起来相当复杂和复杂。然后,将其扩展到更大的图像、更多的类别、颜色和其他复杂程度。您将需要设计非常复杂的架构来处理它们。例如,表 2-1 描述了一种被称为MobileNet的架构的层,正如其名称所示,它旨在对移动设备友好,低耗电高性能。

表 2-1. MobileNet 描述

输入 运算符 t c n s
224² × 3 卷积层 32 1 2
112² × 32 瓶颈层 1 16 1 1
112² × 16 瓶颈层 6 24 2 2
56² × 24 瓶颈层 6 32 3 2
28² × 32 瓶颈层 6 64 4 2
14² × 64 瓶颈层 6 96 3 1
14² × 96 瓶颈层 6 160 3 2
7² × 160 瓶颈层 6 320 1 1
7² × 320 1x1 卷积 1280 1 1
7² × 1280 avgpool 7x7 1
1 × 1 × 1280 1x1 卷积 k

在这里,您可以看到有许多层,主要是“瓶颈”类型(使用卷积),它们接收尺寸为 224 × 224 × 3 的彩色图像(图像为 224 × 224 像素,需要三个字节的颜色),并将其分解为称为“特征向量”的 1,280 个值。然后,这些向量可以馈送到一个分类器中,用于 MobileNet 模型的 1,000 张图像。它被设计用于与为 ImageNet 大规模视觉识别挑战(ILSVRC)创建的一个版本的ImageNet 数据库一起工作,该数据库使用 1,000 类图像。

设计和训练这样的模型是一个非常复杂的任务。

但是,即使您希望将其用于识别不同于其训练识别的 1,000 种图像,重复使用模型和它学到的内容也是可能的。

逻辑是这样的:如果像 MobileNet 这样的模型,经过数十万张图像的训练来识别成千上万个类别,效果非常好,那么它已经非常有效地通常能够识别图像中的内容。如果我们采用它在内部参数中学到的值,并将其应用于不同的图像集合,由于其通用性,它们很可能会表现得非常好。

所以,例如,如果我们回到表 2-1,并且说我们想创建一个仅能识别三种不同图像类别的模型,而不是已经识别的 1,000 个类别,那么我们可以使用 MobileNet 学到的所有内容来获得 1,280 个特征向量,并将它们馈送到我们自己的仅含三个神经元的输出层,用于我们的三类。

幸运的是,由于TensorFlow Hub的存在,这非常容易实现,它是一个预训练模型和模型架构的存储库。

您可以通过导入 TensorFlow Hub 来在您的代码中包含它:

import tensorflow_hub as hub

所以,例如,如果我想使用 MobileNet v2,我可以使用这样的代码:

model_handle =
  "https://tfhub.dev/google/imagenet/mobilenet_v2_035_224/feature_vector/4"

这里我定义我想使用 MobileNet 并获取其特征向量。在 TensorFlow Hub 中,有许多不同类型的 MobileNet 架构,以不同的方式进行调整,导致 URL 中的035_224之类的数字。我在这里不会详细介绍它们,但 224 表示我们要使用的图像尺寸。回到表 2-1,您会看到 MobileNet 图像为 224 × 224。

重要的是,我想加载一个已经从 Hub 训练好的模型。它输出特征向量,我可以对其进行分类,所以我的模型会像这样:

feature_vector = hub.KerasLayer(model_handle, trainable=False,
                               input_shape=(224, 224, 3))

model = tf.keras.models.Sequential([
  feature_vector,
  tf.keras.layers.Dense(3, activation = 'softmax'),
])

请注意第一行中的 trainable=False 设置。这意味着我们将重用模型,但不会对其进行任何编辑,只是使用它已经学到的知识。

因此,我的模型实际上只有两行代码。它是一个 Sequential 模型,包含特征向量,后跟一个包含三个神经元的 Dense 层。在使用 MobileNet 和 ImageNet 数据进行多小时训练后学到的所有内容现在都可以用于我的用途;我不需要重新训练!

使用此方法和豆子数据集,该数据集可以对植物中的三种豆类病进行分类,我现在可以用这个非常简单的代码创建一个分类器,即使识别非常复杂的图像。图 2-13 显示了其输出,获取该输出的代码可以在本书的下载中找到。

图 2-13. 使用迁移学习处理复杂图像

鉴于通过迁移学习快速构建非常复杂模型所带来的强大功能,本书中模型创建的主要重点将是使用迁移学习。希望这是一个有用的介绍!

摘要

在本章中,您将了解计算机视觉的简介,并了解其真正含义——编写代码来帮助计算机解析图像内容。您将学习如何设计神经网络以识别多个类别,在从零开始构建一个可以识别 10 种时尚物品的网络之前。接下来,您将了解到迁移学习的概念,即可以使用已在数百万张图像上预训练过的现有模型来识别多种类别,并使用它们的内部变量来应用到您的场景中。从中您将看到如何从 TensorFlow Hub 下载模型并在极少的代码行中重复使用它们提供的非常复杂的模型。例如,您将看到一个用仅定义了两个层的模型编写的植物豆病分类器!这将是本书中您主要使用的方法论,因为从现在开始的重点将是在移动应用中使用模型。我们将在第三章中开始这段旅程,介绍 ML Kit,这是一个框架,可以帮助您在 Android 和 iOS 上快速原型或使用即插即用的机器学习场景。

第三章:介绍 ML Kit

在这本书的前两章中,你已经了解了机器学习和深度学习的基础,构建了一些基本模型。接下来的书籍内容,你将转变方向,探索如何在移动设备上实现模型。一个强大的工具包,可以让你实现预先存在的模型(即即插即用的场景),以及自定义模型,就是 Google 的 ML Kit。在本章中,你将探索如何使用 ML Kit 在 Android 或 iOS 设备上运行模型。模型将保持在设备上,为用户带来速度和隐私优势。

注意

如果你对如何为 Android 和 iOS 实现附加库不是特别熟悉,我强烈建议你详细阅读本章内容。我会在这里详细讲解,并且后续章节将参考本章。

ML Kit 可以应用于三种主要场景:

  • 即插即用的解决方案中,ML Kit 中已经存在的模型可以实现你需要的功能

  • 使用通用模型快速原型化特定任务;例如,如果你想构建一个视觉应用程序,但是还没有符合你需求的模型,想确定它是否可行于你的设备上

  • 构建使用像我们在第二章中探索的自定义模型的应用程序

在本章中,我将探讨一些即插即用的解决方案,这样你就可以理解如何在应用程序中快速启动和运行 ML 模型。在随后的章节中,我们将探讨如何最初使用 ML Kit 原型化视觉和自然语言处理(NLP)场景,然后构建自定义模型并实现它们。

我认为通过动手操作学习要比讨论大量背景信息更容易,所以让我们直接开始,探索如何使用 ML Kit 构建一些应用程序,首先从 Android 和 iOS 上的人脸检测应用程序开始。

在 Android 上构建人脸检测应用程序

在接下来的几页中,你将学习如何构建一个应用程序,使用一个预训练的 ML 模型进行人脸检测,该模型可以立即使用,无需进一步训练。你可以看到一个例子,在一张图片中检测单个人脸,参见图 3-1。

图 3-1 检测图片中的单个人脸

同样的模型(因此也是简单的应用程序)也可以识别图片中的多个人脸;你可以在图 3-2 中看到这一点。我觉得这特别令人印象深刻,因为如果你看前景中的女性,她的脸转向摄像机,我们只能看到她的侧脸,但模型仍然能够检测到!

图 3-2 检测图像中的多个人脸

让我们看看如何在 Android 上开始创建这样一个应用程序!

步骤 1:使用 Android Studio 创建应用程序

本教程的其余部分将使用 Android Studio,并期望您至少具有基本的工具知识,以及使用 Kotlin 进行 Android 应用程序开发的知识。如果您对此不熟悉,建议您参加 Google 的免费课程 使用 Kotlin 进行 Android 开发。如果您还没有安装 Android Studio,可以从 https://developer.android.com/studio/ 下载。

第一步将是使用 Android Studio 创建一个应用程序。因此,当您使用 File → New 命令时,将会弹出一个对话框,要求您选择一个项目模板(见 图 3-3)。

选择空白活动模板,然后点击下一步。

下一个对话框(配置您的项目)将要求您输入项目的名称、位置和语言。在这里使用任何适合您的选项,但为了使命名空间与我的代码相同,您可能想要使用名称 FD 和包名 com.example.fd,如 图 3-4 中所示。

图 3-3. 在 Android Studio 中开始一个新的应用程序

图 3-4. 配置项目

点击完成,Android Studio 将创建一个带有单个空活动的样板应用程序。我们可以使用这个应用程序来构建人脸检测器。

步骤 2: 添加和配置 ML Kit

Android Studio 允许您使用 Gradle 构建工具 添加外部库。起初可能会有点混乱,因为您的项目中有 两个 Gradle 文件,一个定义了项目的整体构建基础设施,另一个是您的应用程序的构建.gradle 文件。要添加 ML Kit,您使用后者——您的应用程序的 build.gradle。在 IDE 中,您会看到类似 图 3-5 的 Gradle 脚本文件夹,因此请注意,第二个条目是模块:app。

图 3-5. 探索您的 Gradle 脚本

打开 build.gradle 文件,位于模块:app 中,您将看到许多配置条目。在底部右侧,您将看到一个名为 dependencies 的部分。这里包含多个 implementationtestImplementationandroidTestImplementation 条目。您可以在这里添加您的依赖项,例如 ML Kit 人脸检测的实现细节如下所示:

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
    implementation 'androidx.appcompat:appcompat:1.2.0'
    implementation 'androidx.core:core-ktx:1.3.1'
    implementation 'androidx.constraintlayout:constraintlayout:2.0.1'
    testImplementation 'junit:junit:4.12'
    androidTestImplementation 'androidx.test.ext:junit:1.1.2'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
    // Use this dependency to bundle the model with your app
    implementation 'com.google.mlkit:face-detection:16.0.2'
}

版本可能与您的情况有所不同——这些是撰写时的最新版本,并注意,您仅需添加前一清单中的 最后 一行,用于定义 ML Kit 人脸检测的实现。

步骤 3: 定义用户界面

我们将尽可能保持简单,以便尽快进入人脸检测代码!因此,在 Android Studio 中找到 res 文件夹,在其中查看 layout,然后看到 activity_main.xml,如 图 3-6 所示。这是一个 XML 文件,声明了您的用户界面将如何显示!

图 3-6. 查找活动声明

当你打开它时,你可能会看到一个布局编辑器,布局很简单,只包含文本“Hello World”。通过在屏幕右上角选择“code”图标,切换到编辑器的代码视图。然后你应该会看到类似这样的布局的 XML 代码:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout

    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Hello World!"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

删除中间的 TextView 条目,并更新为一个新的 Button 和 ImageView,使得清单看起来像这样:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout

    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <Button
        android:id="@+id/btnTest"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Button" />

    <ImageView
        android:id="@+id/imageFace"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

现在你有一个非常基本的用户界面,包含一个按钮和一个图像。你可以将图像加载到 ImageView 中,当你按下按钮时,它将调用 ML Kit 来检测 ImageView 中图像中的人脸,并绘制指示其位置的矩形。

步骤 4:将图像作为资产添加

Android Studio 默认不为你创建一个资产文件夹,所以你需要手动创建一个来从中加载图像。最简单的方法是在项目的文件结构上进行操作。找到代码所在的文件夹,在app/src/main文件夹内创建一个名为assets的目录。Android Studio 会将其识别为你的资产目录。将一些图片复制到这里(或者直接使用我 GitHub 上的图片),它们就可以使用了。

当你完成配置并且正确设置时,Android Studio 会将该文件夹识别为资产文件夹,你可以浏览它。参见图 3-7。

图 3-7. 设置你的资产

现在你已经准备好开始编码了,让我们从使用默认图片设置用户界面开始。

步骤 5:使用默认图片加载用户界面

在你的MainActivity.kt文件中,你应该看到一个名为onCreate的函数。当活动创建时会调用此函数。在这里,在setContentView行的下面,添加以下代码:

val img: ImageView = findViewById(R.id.imageFace)
// assets folder image file name with extension
val fileName = "face-test.jpg"

// get bitmap from assets folder
val bitmap: Bitmap? = assetsToBitmap(fileName)
bitmap?.apply{
    img.setImageBitmap(this)
}

这将创建一个对你在布局中添加的 ImageView 控件的引用,并将其命名为img。然后它获取名为face-test.jpg的文件,并使用名为assetsToBitmap的帮助函数从资产文件夹加载它,你马上就会看到这个函数。一旦位图准备就绪,它将调用apply,允许你在位图加载时执行代码,并将img的图像位图属性设置为位图,从而将图像加载到 ImageView 中。

从资产文件夹加载位图的帮助函数在这里:

// helper function to get bitmap from assets
fun Context.assetsToBitmap(fileName: String): Bitmap?{
    return try {
        with(assets.open(fileName)){
            BitmapFactory.decodeStream(this)
        }
    } catch (e: IOException) { null }
}
注意

对于这个示例,帮助函数位于活动内部。对于更大的应用程序,良好的编程实践会将类似这样的帮助函数放在一个可用的帮助类中。

这简单地打开资产,并使用BitmapFactory将图像内容流式传输到可为空的Bitmap中。如果你运行应用程序,它现在看起来会像图 3-8。

图 3-8. 运行应用程序

我们可以看到这非常基础,只有一个图像和一个按钮,但至少我们的图像已经加载到了 ImageView 中!接下来我们将编写按钮,并让它调用 ML Kit 的人脸检测器!

第 6 步:调用人脸检测器

人脸检测 API有许多您可以调用的选项,这些选项可通过FaceDetectorOptions对象访问。我不会在这里详细介绍所有可用的选项——您可以查看文档来了解——但在调用 API 之前,您需要设置要使用的选项。这是使用FaceDetectorOptions.Builder()对象完成的,以下是一个示例:

val highAccuracyOpts = FaceDetectorOptions.Builder()
    .setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_FAST)
    .build()

这里有许多选项,您可以设置不同面部的标志性地标或分类,比如睁眼或闭眼,但由于我们只是做一个边界框,我选择了一组简单的选项,只需快速完成!(如果您希望更精确,可以使用PERFORMANCE_MODE_ACCURATE,这通常会花费更长时间,并且在场景中存在多个面时可能表现更好。)

接下来,您将使用这些选项创建检测器的实例,并将其传递给位图。由于位图是可空类型(即Bitmap?),并且InputImage.fromBitmap不会接受可空类型,因此您需要在bitmap后面加上!!以使其识别。以下是代码:

val detector = FaceDetection.getClient(highAccuracyOpts)
val image = InputImage.fromBitmap(bitmap!!, 0)

您可以通过调用detector.process并将其传递给您的图像来获取检测器的结果。如果成功,您将获得其onSuccessListener的回调,其中将包含一系列面部。如果失败,您将获得一个onFailureListener,您可以使用它来跟踪异常:

val result = detector.process(image)
    .addOnSuccessListener { faces ->
        // Task completed successfully
        // ...
        bitmap?.apply{
            img.setImageBitmap(drawWithRectangle(faces))
        }
    }
    .addOnFailureListener { e ->
        // Task failed with an exception
        // ...
    }

onSuccessListener中,您可以再次使用bitmap?.apply调用一个函数,但这次您可以将图像位图设置为调用名为drawWithRectangle的函数的返回值,将面部列表传递给它。这将获取位图并在其上绘制矩形。您将在下一步中看到这一点。

但首先,请将所有这些代码添加到onCreate中,作为按钮的onClickListener的一部分。以下是完整的代码:

val btn: Button = findViewById(R.id.btnTest)
        btn.setOnClickListener {
            val highAccuracyOpts = FaceDetectorOptions.Builder()
                .setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_FAST)
                .build()

            val detector = FaceDetection.getClient(highAccuracyOpts)
            val image = InputImage.fromBitmap(bitmap!!, 0)
            val result = detector.process(image)
                .addOnSuccessListener { faces ->
                    // Task completed successfully
                    // ...
                    bitmap?.apply{
                        img.setImageBitmap(drawWithRectangle(faces))
                    }
                }
                .addOnFailureListener { e ->
                    // Task failed with an exception
                    // ...
                }
        }

第 7 步:添加边界框

成功时,人脸检测 API 将向其调用者返回一系列面部,而在前面的步骤中,您获取了此列表并将其传递给了bitmap?.apply函数,请求将图像位图设置为从名为drawWithRectangle的函数返回的结果。让我们在这里探讨一下:

fun Bitmap.drawWithRectangle(faces: List<Face>):Bitmap?{
    val bitmap = copy(config, true)
    val canvas = Canvas(bitmap)
    for (face in faces){
        val bounds = face.boundingBox
        Paint().apply {
            color = Color.RED
            style = Paint.Style.STROKE
            strokeWidth = 4.0f
            isAntiAlias = true
            // draw rectangle on canvas
            canvas.drawRect(
                bounds,
                this
            )
        }
    }
    return bitmap
}

该函数将复制用于调用它的位图,然后使用该位图初始化一个Canvas。然后,对于面部列表中的每个面部,它可以调用boundingBox属性以获取该面部的矩形对象。真正不错的是,ML Kit 已经将此矩形按比例缩放到您的图像上,您无需进行进一步的解码。

那么,在这种情况下,你可以直接调用一个Paint()对象,使用它的apply方法定义一个矩形,并使用canvas.drawRect来绘制它。Canvas 是用你的位图初始化的,所以矩形将会在其上绘制。

它将为所有其他面部重复此过程,并在完成后返回具有矩形标记的新位图。由于这是用于应用于 ImageView 中的主位图的(参见“第 6 步:绘制边界框”),新位图将被写入 ImageView 并更新 UI。你可以在图 3-9 中看到结果。

图 3-9. 带有边界框的您的应用程序

如果你想尝试其他图片,只需将它们添加到 assets 文件夹中,并将第 5 步中的此行更改为所需工作的图片文件名:

val fileName = "face-test.jpg"

现在,您已经创建了使用即插即用的 ML Kit 模型检测人脸的第一个 Android 应用程序!此 API 还可以执行许多其他操作,包括查找脸部的标志点,如眼睛和耳朵,检测轮廓,并分类眼睛是否睁开,人是否微笑等等。您已经准备好开始了,所以尽情增强这个应用程序吧。

在本章的其余部分,我们将转而看看如何构建相同的应用程序,但是在 iOS 上!

为 iOS 构建人脸检测器应用程序

现在让我们探索如何使用 ML Kit 的人脸检测构建一个非常相似的 iOS 应用程序。对于 iOS,您需要使用 Mac 计算机作为开发者工作站,以及 Xcode 环境进行编码、调试和测试。

第 1 步:在 Xcode 中创建项目

要开始,请启动 Xcode 并选择新项目。你会看到新项目模板对话框,类似于图 3-10

确保在屏幕顶部选择 iOS,然后将 App 作为应用程序类型。点击下一步,会要求你填写一些细节。除了产品名称,保留所有默认设置;在我的案例中,我称之为 firstFace,但你可以使用任何你喜欢的名称。只需确保将界面保持为 Storyboard,生命周期为 UIKit App Delegate,并将语言设置为 Swift,如图 3-11 所示。

点击下一步后,Xcode 将为您创建一个模板项目。此时应关闭 Xcode,因为您需要在 IDE 之外进行一些配置,然后开始编码。您将在下一步看到如何使用 Cocoapods 添加 ML Kit 库。

图 3-10. 新应用模板

图 3-11. 选择项目选项

第 2 步:使用 CocoaPods 和 Podfiles

在 iOS 开发中管理依赖项的常见技术是CocoaPods。这在某种程度上类似于前几章中看到的 Android 部分中的 Gradle 文件,并且旨在尽可能简化处理依赖文件。您将通过一个 Podfile 使用 CocoaPods,该文件定义了要添加到应用程序中的依赖项。

在本章中,我不会详细介绍 CocoaPods,但请确保已安装并准备好使用,否则您将无法继续进行本示例,因为它严重依赖于使用 CocoaPods 将 ML Kit 集成到您的 iOS 应用程序中。

在创建项目时,它存储在一个与项目名称相同的目录中。例如,在我的情况下,我将其称为 firstFace 并将其存储在桌面上。在该文件夹中包含一个名为firstFace.xcodeproj的 Xcode 项目文件和一个名为firstFace的文件夹。

在项目文件夹中,与.xcodeproj文件并列,创建一个名为 Podfile 的新文本文件,没有扩展名。编辑此文件的内容,使其如下所示——将firstFace更改为您的项目名称:

platform :ios, '10.0'

target 'firstFace' do
        pod 'GoogleMLKit/FaceDetection'
        pod 'GoogleMLKit/TextRecognition'
end

然后,使用终端进入项目文件夹(它应包含.xcproject文件),并输入**pod install**。如果执行正确,您应该看到类似图 3-12 的内容。

注意

在编写本书时,基于 M1 芯片的 Mac 相对较少。一些测试者在执行pod install时遇到了问题,并出现了ffi_c.bundle的错误。为了解决这些问题,请确保您使用的是最新版本的 CocoaPods,或者使用已记录的解决方法

这将下载依赖项并将它们安装在名为firstFace.xcworkspace的新工作空间中。今后可以使用它来加载您的工作。因此,现在,您可以在终端中输入**open firstFace.xcworkspace**

图 3-12. 运行pod install

第 3 步:创建用户界面

我们将构建我们可以构建的最简单的应用程序,但它仍然需要一些 UI 元素。为了使其尽可能基本,这些将是一个 ImageView 和一个 Button。因此,打开 Xcode(打开 .xcworkspace 文件,如前一节所示),找到项目中的Main.storyboard文件,并使用 View → Show Library 打开工具库。

您将看到一个 UI 元素列表,如图 3-13 所示。

图 3-13. 添加 UI 元素

使用此方法,并且在编辑器中激活Main.storyboard时,将 ImageView 和 Button 拖放到故事板的设计表面上。完成后,您的 IDE 应该看起来像图 3-14。

图 3-14. 您的主故事板

从编辑器菜单中,您应该看到一个称为 Assistant 的条目,如果选择它,将在视觉编辑器的旁边或下方打开一个代码窗口。

按住 Ctrl 键,将按钮拖动到 class ViewController 下方的代码中,会弹出一个窗口。参见 图 3-15。

图 3-15. 在 Xcode 中连接 UI 与代码

在连接设置中,选择 Action,在名称字段中输入 buttonPressed,然后按 Connect 按钮。

以下代码将为您生成:

@IBAction func buttonPressed(_ sender: Any) {
}

类似地,按住 Ctrl 键,将 UIImageView 控件拖动到代码窗口中,当出现来自 图 3-15 的窗口时,保持连接设置为 Outlet,但将名称设置为 imageView,然后点击连接。这将生成如下代码:

@IBOutlet weak var imageView: UIImageView!

注意这里的 IB 代表“Interface Builder”,因此你已经创建了一个 Interface Builder action,当按钮被按下时将运行,以及一个 Interface Builder outlet,允许你通过 imageView 引用或设置 UIImageView 元素的内容。

接下来,让我们将女士面部的 JPEG 图像作为资源添加到应用程序中。只需将其拖放到 Xcode 项目资源管理器中即可完成。在 Finder 中找到图片,将其拖放到 Xcode 左侧面板的代码文件区域。会弹出一个对话框询问“选择添加这些文件的选项”。接受默认设置并点击完成。

你应该会在项目中看到这个文件(在本例中我称其为 face1.jpg)作为资源。参见 图 3-16。

图 3-16. 将文件添加到项目中

现在可以通过在 viewDidLoad() 函数中添加代码来将图片加载到 imageView 中:

override func viewDidLoad() {
    super.viewDidLoad()
    // Do any additional setup after loading the view.
    imageView.image = UIImage(named: "face1.jpg")

}

现在你可以运行应用程序,它将展示一个非常简单的 UI,只有一个图片和一个按钮。但你会知道它是有效的!

注意

iOS 模拟器提供了一个可以模拟 iOS 设备的环境。在撰写本文时,某些第三方库在 M1 Mac 上的模拟器中不受支持。因此,你可以选择在设备上运行,或者选择“我的 Mac - iPad 设计”运行时。本章其余部分的屏幕截图均来自此目标系统,运行在 M1 Mac Mini 上。

用户界面元素的最后部分将是一个视图,可用于向图像添加注释,例如人脸的边界框。我们将使用代码完成这一步骤,因此,请将以下内容添加到 ViewController.swift 文件中:

/// An overlay view that displays detection annotations.
private lazy var annotationOverlayView: UIView = {
  precondition(isViewLoaded)
  let annotationOverlayView = UIView(frame: .zero)
  annotationOverlayView.translatesAutoresizingMaskIntoConstraints =
      false
  return annotationOverlayView
}()

这将仅仅给我们一个视图,在主视图加载后加载其余 UI 元素(请注意先决条件)。

然后可以通过更新 viewDidLoad 方法,将此覆盖视图加载并激活在 imageView 上层显示,如下所示:

override func viewDidLoad() {
    super.viewDidLoad()
    // Do any additional setup after loading the view.
    imageView.image = UIImage(named: "face1.jpg")
    imageView.addSubview(annotationOverlayView)
    NSLayoutConstraint.activate([
      annotationOverlayView.topAnchor.constraint(equalTo:
imageView.topAnchor),
      annotationOverlayView.leadingAnchor.constraint(equalTo:
imageView.leadingAnchor),
      annotationOverlayView.trailingAnchor.constraint(equalTo:
imageView.trailingAnchor),
      annotationOverlayView.bottomAnchor.constraint(equalTo:
imageView.bottomAnchor),
    ])
}

现在我们已经创建了整个用户界面,包括用于图片的 UIImageView,供用户按下的按钮,以及用于呈现边界框的注释覆盖层,我们准备开始编写将使用 ML Kit 检测面部的逻辑。接下来将看到这一部分。

步骤 4:添加应用程序逻辑

当用户按下按钮时,我们希望调用 ML Kit,将图像传递给它,获取指示人脸位置的边界框的详细信息,然后在视图上呈现它。让我们逐步看如何编写这个过程。

首先,您需要导入 ML Kit 库:

import MLKitFaceDetection
import MLKitVision

接下来,让我们设置 ML Kit 人脸检测器。为此,您首先需要创建一个FaceDetectorOptions对象,并设置一些其属性。在这种情况下,我们只会执行一些基本选项——获取所有人脸轮廓,并要求它提供快速性能:

private lazy var faceDetectorOption: FaceDetectorOptions = {
  let option = FaceDetectorOptions()
  option.contourMode = .all
  option.performanceMode = .fast
  return option
}()

然后,一旦我们有了选项,我们可以使用它们来实例化一个faceDetector

private lazy var faceDetector =
    FaceDetector.faceDetector(options: faceDetectorOption)

在这一点上,您可能会注意到您的代码会因为找不到FaceDetectorOptions而出错。没关系,这只是意味着它们在您尚未引用的库中。您可以通过在代码顶部添加一些导入来修复它。这些将为您提供 ML Kit 人脸检测库以及一般计算机视觉的辅助库:

import MLKitFaceDetection
import MLKitVision

回想一下,您之前创建了一个 Interface Builder 动作,当用户按下按钮时将执行它。让我们从这里开始,并让它在用户触摸按钮时运行一个自定义函数(您马上就会创建)。

@IBAction func buttonPressed(_ sender: Any) {
    runFaceContourDetection(with: imageView.image!)
}

现在,我们可以编写此函数——它的工作是接收图像并将其传递给人脸检测器。一旦人脸检测器完成其工作,它就能处理返回的内容。因此,我们可以使函数非常简单:

func runFaceContourDetection(with image: UIImage) {
  let visionImage = VisionImage(image: image)
  visionImage.orientation = image.imageOrientation
  faceDetector.process(visionImage) { features, error in
    self.processResult(from: features, error: error)
  }
}

到目前为止,您还没有编写processResult函数,所以 Xcode 会给出警告。不用担心——您接下来会实现它。

为了使 ML Kit 能够识别多种不同类型的对象,模式是将所有图像转换为VisionImage对象。此对象支持多种格式的构建,我们在这里从UIImage开始。我们将首先创建一个VisionImage实例,并将其方向设置为与原始图像相同。然后,我们使用之前创建的人脸检测器对象处理图像。这将返回特征和/或错误,因此我们可以将它们传递给名为processResult的函数。

因此,这是processResult的代码(通过删除错误处理进行了缩写,生产应用程序中不应该这样做!),它的工作是从 ML Kit 的人脸检测器获取细节:

func processResult(from faces: [Face]?, error: Error?) {
    guard let faces = faces else {
      return
    }
    for feature in faces {
      let transform = self.transformMatrix()
      let transformedRect = feature.frame.applying(transform)
      self.addRectangle(
        transformedRect,
        to: self.annotationOverlayView,
        color: UIColor.green
      )
    }
}

请注意,由 ML Kit 返回的边界框的坐标与 iOS 用户界面中图像的坐标不同,原因包括它们基于图像的分辨率,而不是屏幕上渲染的像素数量,以及诸如纵横比之类的因素。因此,需要创建一个transformMatrix函数来在 ML Kit 返回的坐标和屏幕上的坐标之间进行转换。这用于创建transformedRect——它将是屏幕坐标中人脸的框架。最后,将向annotationOverlayView添加一个矩形,它将为我们框出人脸。

辅助函数的完整代码——转换矩形坐标并将其应用于覆盖层——在此处:

private func transformMatrix() -> CGAffineTransform {
  guard let image = imageView.image else
    { return CGAffineTransform() }
  let imageViewWidth = imageView.frame.size.width
  let imageViewHeight = imageView.frame.size.height
  let imageWidth = image.size.width
  let imageHeight = image.size.height

  let imageViewAspectRatio = imageViewWidth / imageViewHeight
  let imageAspectRatio = imageWidth / imageHeight
  let scale =
    (imageViewAspectRatio > imageAspectRatio)
    ? imageViewHeight / imageHeight : imageViewWidth / imageWidth

  let scaledImageWidth = imageWidth * scale
  let scaledImageHeight = imageHeight * scale
  let xValue = (imageViewWidth - scaledImageWidth) / CGFloat(2.0)
  let yValue = (imageViewHeight - scaledImageHeight) / CGFloat(2.0)

  var transform = CGAffineTransform.identity.translatedBy(
                                      x: xValue, y: yValue)
  transform = transform.scaledBy(x: scale, y: scale)
  return transform
}

private func addRectangle(_ rectangle: CGRect, to view: UIView, color: UIColor) {
  let rectangleView = UIView(frame: rectangle)
  rectangleView.layer.cornerRadius = 10.0
  rectangleView.alpha = 0.3
  rectangleView.backgroundColor = color
  view.addSubview(rectangleView)
}

这将为您提供所需的所有代码。正如您所见,其中大部分实际上是用于用户界面逻辑——转换坐标、绘制矩形等。人脸检测相对简单——在 runFaceContourDetection 函数中只需三行代码!

运行应用程序并按下按钮应该像在图 3-17 中那样框住脸。

图 3-17. iOS 上的人脸框架

再次强调,这只是一个非常简单的示例,但该模式非常典型,适用于更复杂的应用程序。ML Kit 的目标是尽可能简化应用程序开发中的 ML 部分,希望此应用程序的人脸检测部分能证明这一点。

总结

在本章中,您简要介绍了如何在移动应用程序中使用 ML Kit 进行移动机器学习。您看到了如何在 Android 和 iOS 上使用它来构建一个非常简单的应用程序,该应用程序检测给定图像中的面部并在该面部上绘制一个矩形。重要的是,您看到了如何通过在 Android 上使用 Gradle 和在 iOS 上使用 CocoaPods 来包含 ML Kit。在接下来的几章中,我们将更深入地探讨每个平台上一些常见的场景,首先是在使用 Android 的第四章中进行视觉应用程序。

第四章:计算机视觉应用程序与 ML Kit 在 Android 上

第三章向你介绍了 ML Kit 及其如何在移动应用程序中用于人脸检测。但是 ML Kit 不仅仅如此——它还能让你快速原型化常见的视觉场景、托管自定义模型或实施其他即插即用的解决方案场景,例如条形码检测。在这一章中,我们将探讨 ML Kit 中提供的一些模型,以提供计算机视觉场景,包括图像标签化、分类以及静态和动态图像中的对象检测。我们将在 Android 上使用 Kotlin 作为编程语言进行这些操作。第六章将使用 Swift 来实现 iOS 开发中的相应内容。

图像标签化和分类

图像分类的概念在机器学习领域是众所周知的,并且是计算机视觉的基石。简单来说,图像分类发生在你向计算机展示一幅图像时,它告诉你图像包含的内容。例如,你向它展示一张猫的图片,就像图 4-1 中的那样,它会将其标记为猫。

在 ML Kit 中进行图像标签化会进一步扩展,并为你提供图像中看到的物体列表及其概率级别,因此,与图 4-1 只显示一只猫不同,它可能会显示它看到了猫、花、草、雏菊等等。

让我们来探索如何创建一个非常简单的 Android 应用程序来标记这张图片!我们将使用 Android Studio 和 Kotlin。如果你还没有它们,你可以在 https://developer.android.com/studio/ 下载它们。

图 4-1. 一张猫的图片

第一步:创建应用程序并配置 ML Kit

如果你还没有阅读过第三章,或者对于如何启动并运行 Android 应用程序不太熟悉,我建议你现在去看看!一旦你创建了应用程序,你需要像第三章中演示的那样编辑你的 build.gradle 文件。但在这种情况下,你需要添加图像标签库,而不是添加人脸检测库,如下所示:

dependencies {
    implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
    implementation 'androidx.core:core-ktx:1.2.0'
    implementation 'androidx.appcompat:appcompat:1.2.0'
    implementation 'com.google.android.material:material:1.1.0'
    implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
    testImplementation 'junit:junit:4.+'
    androidTestImplementation 'androidx.test.ext:junit:1.1.2'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
    implementation 'com.google.mlkit:image-labeling:17.0.1'
}

一旦完成这些步骤,Android Studio 可能会要求你同步,因为你的 Gradle 文件已更改。这将触发包含新的 ML Kit 依赖项的构建。

第二步:创建用户界面

我们将为这个应用程序创建一个非常简单的超级简单的用户界面,以便我们可以直接开始使用图像标签。在 Android View 中的 res->layout 目录中,你会看到一个名为 activity_main.xml 的文件。如果这不熟悉,请参考第三章。

更新 UI 以包含一个线性布局,其中包括一个 ImageView、一个 Button 和一个 TextView,就像这样:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout

    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent">

        <ImageView
            android:id="@+id/imageToLabel"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:layout_gravity="center"
            android:adjustViewBounds="true"
        />
        <Button
            android:id="@+id/btnTest"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="Label Image"
            android:layout_gravity="center"/>
        <TextView
            android:id="@+id/txtOutput"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:ems="10"
            android:gravity="start|top" />
    </LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

在运行时,ImageView 将加载一张图像,当用户按下按钮时,ML Kit 将被调用以获取显示图像的图像标签数据。结果将在 TextView 中呈现。稍后您可以在 图 4-3 中看到这一点。

步骤 3: 添加图片作为资源

在您的项目中,您需要一个资产文件夹。如果您对此步骤不熟悉,请返回第三章,在那里您将逐步了解该过程。一旦您有了一个资产文件夹并添加了一些图像,您将在 Android Studio 中看到它们。参见 图 4-2。

图 4-2. 资产文件夹中的图像

步骤 4: 将图像加载到 ImageView 中

现在让我们来写一些代码吧!我们可以转到 MainActivity.kt 文件,并在其中添加一个扩展,使我们能够从资产文件夹加载图像作为位图:

fun Context.assetsToBitmap(fileName: String): Bitmap?{
    return try {
        with(assets.open(fileName)){
            BitmapFactory.decodeStream(this)
        }
    } catch (e: IOException) { null }
}

然后,更新由 Android Studio 为您创建的 onCreate 函数,以根据其 ID 查找 ImageView 控件,并将位于资产文件夹中的图像加载到其中:

val img: ImageView = findViewById(R.id.imageToLabel)
// assets folder image file name with extension
val fileName = "figure4-1.jpg"
// get bitmap from assets folder
val bitmap: Bitmap? = assetsToBitmap(fileName)
bitmap?.apply {
    img.setImageBitmap(this)
}

您现在可以运行应用程序,测试它是否正确加载图像。如果正确,您应该会看到类似于 图 4-3 的内容。

图 4-3. 运行带有加载图像的应用程序

按钮目前不会做任何事情,因为我们还没有编写代码。接下来让我们来做这件事吧!

步骤 5: 编写按钮处理程序代码

让我们从编写代码开始,获取可以表示文本视图(用于输出标签)和按钮本身的变量:

val txtOutput : TextView = findViewById(R.id.txtOutput)
val btn: Button = findViewById(R.id.btnTest)

现在我们有了按钮,我们可以为它创建一个按钮处理程序。这可以通过键入 btn.setOnClickListener 来实现;自动完成将为您创建一个存根函数。然后,您可以使用以下完整代码来进行图像标签处理。接下来我们将逐步讲解它:

btn.setOnClickListener {
          val labeler =
            ImageLabeling.getClient(ImageLabelerOptions.DEFAULT_OPTIONS)
          val image = InputImage.fromBitmap(bitmap!!, 0)
          var outputText = ""
          labeler.process(image).addOnSuccessListener { labels ->
                     // Task completed successfully
                     for (label in labels) {
                          val text = label.text
                          val confidence = label.confidence
                          outputText += "$text : $confidence\n"
                     }
                     txtOutput.text = outputText
          }
       .addOnFailureListener { e ->
                        // Task failed with an exception
                        // ...
       }
}

当用户点击按钮时,此代码将使用默认选项从 ML Kit 中创建一个图像标签处理器,如下所示:

val labeler = ImageLabeling.getClient(ImageLabelerOptions.DEFAULT_OPTIONS)

一旦完成此操作,它将使用此代码从位图(用于显示图像)创建一个图像对象(可以由 ML Kit 理解):

val image = InputImage.fromBitmap(bitmap!!, 0)

标签处理器将被调用来处理图像,并添加了两个监听器。成功 监听器在处理成功时触发,失败 监听器在处理失败时触发。当图像标签处理器成功时,它将返回一个标签列表。这些标签将具有一个文本属性,其中包含描述标签的文本,以及一个置信度属性,其值从 0 到 1,表示标签存在的概率。

因此,在成功监听器中,代码将遍历标签列表,并将文本和置信度添加到名为 outputText 的变量中。完成后,它可以将 TextView 的文本属性(现在称为 txtOutput)设置为 outputText 变量的值:

for (label in labels) {
          val text = label.text
          val confidence = label.confidence
          outputText += "$text : $confidence\n"
}
txtOutput.text = outputText

就是这么简单。使用本章早些时候的猫图像运行应用程序,您将会得到类似于 图 4-4 的输出。

图 4-4. 标记本章前面的图像

下一步

ML Kit 内置的图像标记模型能够识别图像中的超过 400 个类别。在撰写本文时,共有 447 个类别,但这可能会改变。ML Kit 的完整标签映射发布在 https://developers.google.com/ml-kit/vision/image-labeling/label-map. 如果您想训练一个模型来识别不同的类别,您将使用 TensorFlow,在第九章中我们将探讨这一点。

物体检测

上一节向您展示了如何进行图像分类和标记,在这种情况下,计算机能够检测图像中的内容,但不一定能确定物体在图像中的位置。这里使用了对象检测的概念。在这种情况下,当您将图像传递给对象检测器时,您将获得一个包含边界框的对象列表,这些边界框可用于确定物体可能在图像中的位置。ML Kit 的默认对象检测模型非常擅长在图像中检测物体,但它只能分类五种类别之后,您需要使用自定义模型。然而,当与图像标记(前一节)结合使用时,您可以获得图像中各个物体的分类标签!您可以在图 4-5 中看到一个示例。

图 4-5. 执行物体检测

让我们一步一步来看。

步骤 1: 创建应用程序并导入 ML Kit

创建应用程序时要像之前一样创建一个单视图应用程序。我们将尽量保持与您已经构建的图像标记应用程序相似,以便事物看起来更加熟悉。

完成后,请编辑您的 build.gradle 文件,以像这样同时使用物体检测和图像标记:

implementation 'com.google.mlkit:object-detection:16.2.2'
implementation 'com.google.mlkit:image-labeling:17.0.1'
注意

您的版本号可能不同,请检查最新版本信息 https://developers.google.com/ml-kit.

步骤 2: 创建活动布局 XML

该活动的布局文件非常简单,与之前看到的完全相同。您将拥有一个 LinearLayout,用于布局 ImageView、Button 和 TextView。ImageView 将显示图像,Button 将运行物体检测和标记代码,TextView 将呈现标签的结果。而不是在此重新列出代码,只需使用与前面示例相同的布局代码。

步骤 3: 将图像加载到 ImageView 中

与之前一样,您将使用扩展来从资产文件夹加载图像到 ImageView 中。为方便起见,我在此重复了执行此操作的代码:

// extension function to get bitmap from assets
fun Context.assetsToBitmap(fileName: String): Bitmap?{
    return try {
        with(assets.open(fileName)){
            BitmapFactory.decodeStream(this)
        }
    } catch (e: IOException) { null }
}

像之前一样创建一个资产文件夹,并在其中放置一些图像。对于图 4-5 中的屏幕截图,我使用了来自Pixabay的图像,并将其重命名为bird.jpg以便于代码处理。

然后,在onCreate函数中,你可以使用前面的扩展函数从资产中获取图像,并像这样加载到你的位图中:

val img: ImageView = findViewById(R.id.imageToLabel)
// assets folder image file name with extension
val fileName = "bird.jpg"
// get bitmap from assets folder
val bitmap: Bitmap? = assetsToBitmap(fileName)
bitmap?.apply {
    img.setImageBitmap(this)
}

你也可以像这样设置按钮和 TextView 控件:

val txtOutput : TextView = findViewById(R.id.txtOutput)
val btn: Button = findViewById(R.id.btnTest)

第 4 步:设置对象检测选项

在本节中,您将使用多个 ML Kit 类。以下是导入:

import com.google.mlkit.vision.common.InputImage
import com.google.mlkit.vision.label.ImageLabeling
import com.google.mlkit.vision.label.defaults.ImageLabelerOptions
import com.google.mlkit.vision.objects.DetectedObject
import com.google.mlkit.vision.objects.ObjectDetection
import com.google.mlkit.vision.objects.defaults.ObjectDetectorOptions

ML Kit 对象检测器提供了多种进行对象检测的方法,这些方法由ObjectDetectorOptions对象控制。我们将在其最简单的模式之一中使用它,即基于单个图像进行检测并启用在该图像中检测多个对象的功能。

val options =
        ObjectDetectorOptions.Builder()
        .setDetectorMode(ObjectDetectorOptions.SINGLE_IMAGE_MODE)
        .enableMultipleObjects()
        .build()

对象检测器是一个强大的 API,还可以执行诸如在视频流中跟踪对象等操作——从帧到帧检测和维护它们。这超出了我们在本书中所做的范围,但您可以在ML Kit 文档中了解更多信息。

模式选项用于确定此操作,你可以在此示例中了解更多关于SINGLE_IMAGE_MODE的信息https://oreil.ly/WFSZD

此外,对象检测器可以启用以检测场景中最显著的对象或所有对象。我们在这里设置为检测多个对象(使用.enableMultipleObjects()),因此我们可以看到多个项目,如图 4-5 所示。

另一个常见选项是启用分类。由于默认对象检测器只能检测五类对象,并为它们提供非常通用的标签,我在这里没有打开它,而是使用了本章前面讨论的图像标签 API“自己动手”标记对象。如果您想使用超过五类基本对象,可以使用自定义 TensorFlow 模型,我们将在第九章到第十一章中探讨使用自定义模型。

第 5 步:处理按钮交互

当用户触摸按钮时,您将希望调用对象检测器,获取其响应,并从中获取图像中对象的边界框。稍后我们还将使用这些边界框将图像裁剪为由边界框定义的子图像,以便传递给标签器。但现在,让我们先实现对象检测处理程序。它应该看起来像这样:

btn.setOnClickListener {
            val objectDetector = ObjectDetection.getClient(options)
            var image = InputImage.fromBitmap(bitmap!!, 0)
            objectDetector.process(image)
                    .addOnSuccessListener { detectedObjects ->
                        // Task completed successfully
                    }
                    .addOnFailureListener { e ->
                        // Task failed with an exception
                        // ...
                    }
        }

因此,类似于您之前对图像标签化所做的操作,模式是使用选项创建对象检测 API 的实例。然后,您将位图转换为InputImage,并使用对象检测器处理它。

成功时返回检测到的对象列表,或者失败时返回异常对象。

onSuccessListener返回的detectedObjects将包含关于对象的详细信息,包括其边界框。接下来,让我们创建一个函数在图像上绘制边界框。

步骤 6:绘制边界框

最简单的方法是扩展Bitmap对象,使用Canvas在其上绘制矩形。我们将检测到的对象传递给它,以便它可以建立边界框,并从那里在位图的顶部绘制它们。

这是完整的代码:

fun Bitmap.drawWithRectangle(objects: List<DetectedObject>):Bitmap?{
    val bitmap = copy(config, true)
    val canvas = Canvas(bitmap)
    var thisLabel = 0
    for (obj in objects){
        thisLabel++
        val bounds = obj.boundingBox
        Paint().apply {
            color = Color.RED
            style = Paint.Style.STROKE
            textSize = 32.0f
            strokeWidth = 4.0f
            isAntiAlias = true
            // draw rectangle on canvas
            canvas.drawRect(
                    bounds,
                    this
            )
            canvas.drawText(thisLabel.toString(),
                            bounds.left.toFloat(),
                            bounds.top.toFloat(), this )
        }

    }
    return bitmap
}

代码将首先创建位图的副本,并基于它创建一个新的Canvas。然后,它将遍历所有检测到的对象。

ML Kit 返回的对象的边界框位于boundingBox属性中,因此您可以使用以下代码获取其详细信息:

val bounds = obj.boundingBox

然后,可以使用Paint对象在画布上绘制边界框,如下所示:

canvas.drawRect(
       bounds,
           this
)

代码的其余部分只处理诸如矩形的颜色、文本的大小和颜色等事务,文本只包含一个数字,如您在图 4-5 中看到的那样,我们按照检测顺序在框上写下 1、2、3。

然后,您可以像这样在onSuccessListener中调用此函数:

bitmap?.apply{
    img.setImageBitmap(drawWithRectangle(detectedObjects))
}

因此,在 ML Kit 成功返回后,您现在会在图像上看到绘制的边界框。考虑到对象检测器的限制,您将不会为这些框获得非常有用的标签,因此在下一步中,您将看到如何使用图像标签调用来获取边界框内内容的详细信息。

步骤 7:标记对象

简单起见,基本模型仅处理五个非常通用的类别来标记图像内容。您可以使用训练更多类别的自定义模型,或者您可以使用简单的多步解决方案。该过程很简单——您已经有了边界框,所以创建一个的临时图像,其中仅包含边界框内的内容,将其传递给图像标签器,然后获取结果。对于每个边界框(因此每个对象)重复此过程,您将获得每个检测到对象的详细标签!

这是完整的代码:

fun getLabels(bitmap: Bitmap,
              objects: List<DetectedObject>, txtOutput: TextView){
    val labeler = ImageLabeling.getClient(ImageLabelerOptions.DEFAULT_OPTIONS)
    for(obj in objects) {
        val bounds = obj.boundingBox
        val croppedBitmap = Bitmap.createBitmap(
            bitmap,
            bounds.left,
            bounds.top,
            bounds.width(),
            bounds.height()
        )
        var image = InputImage.fromBitmap(croppedBitmap!!, 0)

        labeler.process(image)
            .addOnSuccessListener { labels ->
                // Task completed successfully
                var labelText = ""
                if(labels.count()>0) {
                    labelText = txtOutput.text.toString()
                    for (thisLabel in labels){
                        labelText += thisLabel.text + " , "
                    }
                    labelText += "\n"
                } else {
                    labelText = "Not found." + "\n"
                }
                txtOutput.text = labelText.toString()
            }
    }
}

此代码循环遍历每个检测到的对象,并使用边界框创建一个名为croppedBitmap的新位图。然后,它将使用设置为默认选项的图像标签器(称为labeler)处理该新图像。在成功返回后,它将获得多个标签,然后将这些标签写入以逗号分隔的字符串中,该字符串将在txtOutput中呈现。我注意到,即使成功标记,有时也会返回一个空的标记列表,因此我添加了代码,仅在返回的标签中存在标签时才构造字符串。

要调用此函数,只需将此代码添加到onSuccessListener中,用于对象检测调用,在调用代码设置位图上的矩形之后立即添加:

getLabels(bitmap, detectedObjects, txtOutput)
注意

运行此代码时,您正在进行多个异步调用,首先是对象检测器,然后是图像标签器。因此,按下按钮后,您可能会看到延迟行为。您可能会首先看到绘制的边界框,然后几分钟后更新标签列表。Android 和 Kotlin 提供了许多异步功能,以使用户体验更好,但这超出了本书的范围,因为我想保持示例简单,并专注于 ML Kit 中现有功能的使用。

在视频中检测和跟踪对象

ML Kit 对象检测器还可以在视频流上运行,使您能够在视频中检测对象并在连续视频帧中跟踪该对象。例如,请参阅图 4-6,我在场景中移动相机,Android 小人不仅被检测到,并给出了边界框,还分配了跟踪 ID。虽然对象保持在视野中,但根据新位置,后续帧会获得不同的边界框,但跟踪 ID 保持不变——也就是说,尽管由于放置在帧内和不同的摄像头角度而看起来不同,但它被识别为同一对象。

在本节中,我们将探讨如何使用 ML Kit 构建这样的应用程序。请注意,要测试此功能,您应该使用物理设备——将摄像头移动以跟踪设备的性质不适合使用模拟器。

像这样构建应用程序还有很多步骤不是 ML 特定的,比如处理 CameraX、使用覆盖层以及在帧之间管理绘制框等等,我在本章不会深入讨论,但书籍下载包含了您可以剖析的完整代码。

AIML_0406

图 4-6。使用基于视频的对象检测器

探索布局

自然地,像前述应用程序这样的布局比我们看到的要复杂一些。它需要您绘制相机预览,然后在预览的顶部绘制边界框,这些边界框在您移动相机以跟踪对象时几乎实时更新。在此应用程序中,我使用了 CameraX,这是 Android 中的一个支持库,旨在更轻松地使用相机——确实如此!您可以在https://developer.android.com/training/camerax了解更多关于 CameraX 的信息。

重复前面创建新 Android 应用程序的步骤。准备就绪后,打开布局文件并进行编辑。对于这样的应用程序,您需要使用 FrameLayout,通常只用于单个项目,以阻挡屏幕的特定区域,但我喜欢在像这样的情况下使用它,其中我有两个项目,但一个将完全覆盖另一个:

<FrameLayout android:layout_width="fill_parent"
    android:layout_height="fill_parent"
    android:layout_weight="2"
    android:padding="5dip"
    tools:ignore="MissingConstraints">
    <androidx.camera.view.PreviewView
        android:id="@+id/viewFinder"
        android:layout_width="fill_parent"
        android:layout_height="fill_parent"
        android:layout_weight="1"
        android:layout_gravity="center" />
    <com.odmlbook.liveobjectdetector.GraphicOverlay
        android:id="@+id/graphicOverlay"
        android:layout_gravity="center"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />
</FrameLayout>

在 FrameLayout 内,第一个控件是androidx.camera.view.PreviewView,在此控件上将呈现来自相机的视频流。在其上方是一个名为GraphicOverlay的自定义控件,正如其名称所示,它在预览的顶部提供一个可以绘制图形的覆盖层。这个覆盖层控件已经从开源 ML Kit 示例中进行了适配。

注意,在列表中我称呼了GraphicOverlay com.odmlbook.liveobjectdetector.GraphicOverlay;这是因为前面 Google 示例中的GraphicOverlay直接添加到了我的应用程序中,并且我正在使用我的应用程序命名空间。你可能会有不同的命名空间,因此请确保使用你的 GraphicOverlay 的正确命名。

我将布局保持尽可能简单,以便你可以专注于对象检测的各个方面——所以基本上就是这样——CameraX 的预览,在其上是一个 GraphicOverlay,你可以在这个 GraphicOverlay 上绘制边界框。稍后你会看到更多相关内容。

GraphicOverlay 类

在布局中,你看到了一个自定义的GraphicOverlay类。这个类的作用是管理一组图形对象——包括边界框及其标签,并在画布上绘制它们。需要注意的一点是,通常情况下,你会在相机预览(以相机分辨率显示)和放置在其上方的画布(以屏幕分辨率显示)之间遇到坐标差异,就像在这种情况下一样。因此,可能还需要进行坐标转换,以便在预览上方的适当位置进行绘制。你可以在GraphicOverlay类中找到用于管理逐帧操作时绘制图形性能的代码。边界框,表示为图形对象,将简单地在onDraw事件中添加:

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        synchronized (lock) {
            updateTransformationIfNeeded();

            for (Graphic graphic : graphics) {
                graphic.draw(canvas);
            }
        }
    }

捕获相机

使用 CameraX 时,你会访问一个相机提供者,该提供者允许你在其上设置各种子提供者,包括surface提供者,让你定义预览放置的位置,以及analyzer,让你对来自相机的帧进行处理。这些对我们的需求非常合适——surface 提供者可以提供预览窗口,而 analyzer 可以用于调用 ML Kit 对象检测器。在应用程序的MainActivity中,你会在这段代码中找到这一点(在startCamera()函数中)。

首先,我们设置预览视图(注意,布局列表中的控件称为viewFinder),以渲染来自相机的帧流:

val preview = Preview.Builder()
    .build()
    .also {
        it.setSurfaceProvider(viewFinder.surfaceProvider)
    }

接下来是图像分析器。CameraX 会逐帧调用这个函数,让你能够对图像进行某种处理。这非常适合我们的需求。当你调用setAnalyzer时,你会指定一个处理分析的类。在这里,我指定了一个名为ObjectAnalyzer的类,正如其名称所示,它将使用对象检测 API 与帧一起使用:

val imageAnalyzer = ImageAnalysis.Builder()
    .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
    .build()
    .also {
        it.setAnalyzer(cameraExecutor, ObjectAnalyzer(graphicOverlay))
    }

然后,一旦你拥有了这些,你可以将它们绑定到相机的生命周期中,以便 CameraX 知道要使用它们来渲染预览并管理逐帧处理:

cameraProvider.bindToLifecycle(
    this, cameraSelector, preview, imageAnalyzer
)

你可以在 CameraX 文档中了解有关使用 CameraX 的相机应用程序生命周期的更多信息。我只想在这里强调一下在使用它进行对象检测时的重要部分。

对象分析器类

这个类的完整代码在书籍的代码库中。我建议你克隆它并使用它来理解如何在视频中跟踪对象的对象分析工作。本节只显示代码的重要部分,并不能真正用于编码!

之前你看到,你可以钩入 CameraX 的分析器能力来进行对象检测,并且我们指定了一个叫做ObjectAnalyzer的类来处理它。我们还将对这个类的图形叠加引用传递给了它。

一个分析器类必须重写ImageAnalysis.Analyzer,因此这个类的签名应该看起来像这样:

public class ObjectAnalyzer(graphicOverlay: GraphicOverlay) :
                            ImageAnalysis.Analyzer {}

这个类的工作是进行对象检测,所以我们需要像以前一样创建我们的ObjectDetector实例:

val options =
           ObjectDetectorOptions.Builder()
                   .setDetectorMode(ObjectDetectorOptions.STREAM_MODE)
                   .enableMultipleObjects()
                   .enableClassification()
                   .build()
   val objectDetector = ObjectDetection.getClient(options)

请注意检测器模式设置的不同之处,虽然是ObjectDetectorOptions.STREAM_MODE——现在它正在使用流模式,因为我们将向其传送图像。这将启用我们在图 4-6 中看到的对象跟踪功能,即使由于相机放置的不同而看起来不同,它也会“记住”相同的对象。

当你创建一个像这样的分析器类时,你需要重写函数analyze,它接受一个代表图像的ImageProxy对象。为了使用 CameraX 图像与图像代理,你需要进行一些处理来管理旋转等问题。这里我不会详细说明,但需要重点管理的是,如果相机以横向或纵向模式提供帧,则我们需要通知叠加层有关图像的适当高度和宽度,并在必要时翻转它们——以便 ML Kit API 始终以相同的方向接收图像:

if (rotationDegrees == 0 || rotationDegrees == 180) {
    overlay.setImageSourceInfo(
        imageProxy.width, imageProxy.height, isImageFlipped
    )
} else {
    overlay.setImageSourceInfo(
        imageProxy.height, imageProxy.width, isImageFlipped
    )
}

然后,我们可以将帧传递给对象检测器,如果成功,回调将像以前一样检测到对象。在这一点上,我们应该清除叠加层,然后为每个检测到的对象向叠加层添加新的图形对象。这些图形对象是该应用程序中的自定义类。马上你会看到它们。完成后,我们在叠加层上调用postInvalidate(),这将触发叠加层的重绘:

objectDetector.process(frame)
    .addOnSuccessListener { detectedObjects ->
    overlay.clear()
          for (detectedObject in detectedObjects){
               val objGraphic = ObjectGraphic(this.overlay, detectedObject)
               this.overlay.add(objGraphic)
          }
          this.overlay.postInvalidate()
}

对象图形类

由于边界框由三个元素组成——框本身、标签的文本和标签的背景,所以不仅仅是单独绘制每一个,而是使用一个单一的类来表示每一个。这个类将使用从 ML Kit 返回的 detectedObject 进行初始化,因此我们可以获取跟踪 ID 和边界框的坐标。ObjectGraphic 类管理所有这些——您可以在前面的代码中看到它的使用,其中使用覆盖层和 detectedObject 创建了它的一个新实例。

将所有内容整合

通常情况下,这种应用的工作方式如下。使用 CameraX,您指定预览表面和分析器。分析器调用 ML Kit 对象检测器,并启用流模式。返回的检测到的对象用于创建表示边界框的对象,并将其添加到覆盖层上。这使用了 ML Kit 中的通用模型,因此分类方面并不多,只是检测到一个对象,并为该对象分配了一个 ID。要进一步对每个检测到的对象进行分类,您需要一个自定义模型,在 第九章 中我们将讨论这一点。

摘要

使用 ML Kit 为 Android 构建使用视觉的应用非常简单。在本章中,您探索了几种使用内置通用模型的情景,包括图像分类和标记,其中计算机可以确定单个图像的内容,以及对象检测,其中可以检测到图像中的多个对象,并通过边界框确定它们的位置。您在本章中总结了一个简短的探讨,说明如何将此扩展到视频——不仅可以检测对象,还可以实时跟踪对象。所有这些情景都是基于 ML Kit 中的通用内置模型,但可以轻松扩展为自定义模型。我们将在 第九章 中进一步探讨这一点。

第五章:使用 ML Kit 在 Android 上进行文本处理应用程序

或许机器学习中最大的两个领域是计算机视觉和自然语言处理。在第四章中,您已经了解到一些常见的计算机视觉场景,这些场景已经在 ML Kit 中为您定义好了模型。在本章中,您将探索一些自然语言处理的场景,包括如何从数字墨水中识别文本、对消息进行智能回复以及从文本中提取地址等实体。这些都是特定场景的现成模型。如果您想创建使用其他自然语言处理模型的应用程序,例如文本分类,您将需要使用 TensorFlow Lite 创建自己的模型,然后在移动设备上实现它们。我们将在后面的章节中探讨这一点。

实体提取

在给定大量文本时,从中提取重要信息可能是一项困难的任务。通常,遵循特定结构的信息,例如地址,可能在一个国家是可预测的,但在另一个国家可能有很大不同,因此,采用基于规则的方法获取信息可能需要大量编码工作。

例如,请考虑图 5-1,我已向我的朋友 Nizhoni 发送了一条包含一些细节的消息。作为人类,我们可以从中提取有价值的信息,比如“明天下午 5 点”,理解它是日期和时间。但编写代码来做到这一点确实很困难。试图编写能够理解不同国家日期格式的代码已经很难了,例如,5/2 可能是 5 月 2 日或 2 月 5 日,具体取决于您所在的地区,并且尝试从如“明天”这样的文本中提取信息更加困难!虽然 ML 可能不是这个问题的完美解决方案,但它确实有助于减少您需要编写的用于常见场景的代码量。

图 5-1。从文本中提取实体

正如您在文本下面所看到的那样,生成了一个包含找到的实体的列表。例如,“明天下午 5 点”被提取为日期时间。其他如电话号码和电子邮件地址也被正确提取。通常一个值会匹配多个模式,例如,书的 ISBN 号以三位数开头,这与电话号码的模式匹配,因此被检测为两个实体!

考虑到这一点,ML Kit 具有实体提取 API,可以创建一个可以读取如此数据的应用程序,包括地址、电话号码、电子邮件等。我们将在本节中探讨如何创建这样的应用程序。

开始创建应用程序

我假设您已经按照第三章中所示的步骤创建了一个新应用程序。如果没有,请先从那里开始。与之前一样,使用 Android Studio 创建一个新的单视图应用程序。找到应用程序级别的 build.gradle 文件,并将实体提取库与其一起添加:

implementation 'com.google.mlkit:entity-extraction:16.0.0-beta1'

请注意,在撰写本文时,实体提取是一个测试版产品,可能存在一些错误。此外,如果您正在遵循此文档,请务必查看 ML Kit 文档中实体提取站点的最新版本

创建活动的布局

我们将应用程序保持非常简单,以便专注于实体提取 API,因此您可以从图 5-1 中看到它只有三个控件:一个用于输入文本的,一个用于触发提取的按钮,以及一个用于呈现 API 检测结果的文本字段。

这将使布局的 XML 保持非常简单:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout

    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical">

    <EditText
        android:id="@+id/txtInput"
        android:inputType="textMultiLine"
        android:singleLine="false"
        android:layout_width="match_parent"
        android:layout_height="240dp"/>

    <Button
        android:id="@+id/btnExtract"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Extract Entities" />

    <TextView
        android:id="@+id/txtOutput"
        android:text=""
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>

    </LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

EditText 字段设置为多行(通过使用 singleLine="false"),以便我们可以输入更像文本消息或推文的文本。所有三个控件都封装在 LinearLayout 中,以便我们可以垂直分布它们。

编写实体提取代码

当使用实体提取 API 时,您将遵循四个阶段:

  1. 通过创建客户端来初始化提取器

  2. 通过下载模型来准备提取器

  3. 使用提取器通过文本进行注释

  4. 解析推断出的注释

让我们逐一查看这些。

首先,通过创建客户端来初始化提取器。由于提取器可以跨多种语言工作,因此它被设计为每种语言一个模型,因此在初始化时,您可以通过指定语言来选择正确的模型。例如,如果您想使用英语,您可以使用以下代码:

val entityExtractor = EntityExtraction.getClient(
        EntityExtractorOptions.Builder(EntityExtractorOptions.ENGLISH)
                .build())

对于其他语言,您可以使用内置符号将 EntityExtractorOptions 设置为支持的语言。截至撰写本文时,支持 15 种语言,您可以查看文档来查看完整的设置。

请注意,设置语言不会设置居住地。它们被分开保存,因为同一语言的不同地方可能会有不同的做法。例如,在美国和英国都使用英语,但它们使用日期的方式不同。举个日期的例子,例如在美国,5/2 是 5 月 2 日,而在英国,它是 2 月 5 日。您将在下载模型之后配置这一点。

要下载模型,您将调用 downloadModelIfNeeded() 方法,这是一个异步方法,并将通过成功或失败的监听器回调您。我发现最简单的方法是使用一个布尔值,根据模型下载的成功或失败来设置为 true 或 false。

这里有一个例子:

fun prepareExtractor(){
    entityExtractor.downloadModelIfNeeded().addOnSuccessListener {
        extractorAvailable = true
    }
    .addOnFailureListener {
        extractorAvailable = false
    }
}

一旦您有了提取器,您可以通过使用文本以及任何所需的选项(如语言环境)来构建一个 EntityExtractionParams 对象来使用它。

这是使用默认参数的示例:

val params = EntityExtractionParams.Builder(userText).build()
entityExtractor.annotate(params)
                .addOnSuccessListener { result: List<EntityAnnotation> ->
                ...

或者,如果你愿意,例如在创建参数时设置地区,可以这样做。以下是一个示例:

val locale = Locale("en-uk")
val params = EntityExtractionParams.Builder(userText)
                .setPreferredLocale(locale)
                .build()
注意

你可以在ML Kit 文档网站了解更多关于EntityExtractionParams对象的信息并探索可用的参数。

当你使用给定的参数调用标注方法时,在成功监听器中你会得到一个EntityAnnotation对象的列表作为结果。每个实体标注对象都将包含多个实体,每个实体都将包含一个字符串,其中包含与实体类型匹配的原始文本内的文本,以及实体类型本身。例如,在 Figure 5-1 中的文本“lmoroney@area51.net”,实体提取器将提取该文本,并将其放入类型为“email”的实体中。ML Kit 网站上有许多不同的可用实体类型——你可以在那里查看支持实体的完整列表

所以,例如,我们可以用如下代码处理文本:

entityExtractor.annotate(params)
    .addOnSuccessListener { result: List<EntityAnnotation> ->
        for (entityAnnotation in result) {
            outputString += entityAnnotation.annotatedText
            for (entity in entityAnnotation.entities) {
                outputString += ":" + getStringFor(entity)
            }
            outputString += "\n\n"
        }
        txtOutput.text = outputString
    }

在这里,实体提取器被调用以用参数标注文本;在成功监听器中,每个实体标注将枚举其实体,并对每个实体调用getStringFor助手方法来获取字符串。

这种方法只是创建一个包含实体类型和定义该实体的原始字符串部分的字符串(因此,例如,之前可能会将“lmoroney@area51.net”作为电子邮件切片出来),因此助手方法将生成类似“Type - Email: lmoroney@area51.net”的字符串。

这是代码:

private fun getStringFor(entity: Entity): String{
        var returnVal = "Type - "
        when (entity.type) {
            Entity.TYPE_ADDRESS -> returnVal += "Address"
            Entity.TYPE_DATE_TIME -> returnVal += "DateTime"
            Entity.TYPE_EMAIL -> returnVal += "Email Address"
            Entity.TYPE_FLIGHT_NUMBER -> returnVal += "Flight Number"
            Entity.TYPE_IBAN -> returnVal += "IBAN"
            Entity.TYPE_ISBN -> returnVal += "ISBN"
            Entity.TYPE_MONEY -> returnVal += "Money"
            Entity.TYPE_PAYMENT_CARD -> returnVal += "Credit/Debit Card"
            Entity.TYPE_PHONE -> returnVal += "Phone Number"
            Entity.TYPE_TRACKING_NUMBER -> returnVal += "Tracking Number"
            Entity.TYPE_URL -> returnVal += "URL"
            else -> returnVal += "Address"
        }
        return returnVal
    }

将所有内容整合在一起

唯一剩下的工作就是处理用户界面代码,捕获输入文本,初始化提取器,并在用户按下按钮时调用实体提取。

所以,在你的MainActivity中,你可以像这样更新模块变量和onCreate

val entityExtractor = EntityExtraction.getClient(
        EntityExtractorOptions.Builder(EntityExtractorOptions.ENGLISH)
                .build())
var extractorAvailable:Boolean = false
lateinit var txtInput: EditText
lateinit var txtOutput: TextView
lateinit var btnExtract: Button
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)
    txtInput = findViewById(R.id.txtInput)
    txtOutput = findViewById(R.id.txtOutput)
    btnExtract = findViewById(R.id.btnExtract)
    prepareExtractor()
    btnExtract.setOnClickListener {
        doExtraction()
    }
}

prepareExtractor助手函数只是确保提取器模型可用:

fun prepareExtractor(){
    entityExtractor.downloadModelIfNeeded().addOnSuccessListener {
        extractorAvailable = true
    }
    .addOnFailureListener {
        extractorAvailable = false
    }
}

当用户按下按钮时,将调用doExtraction(),该方法处理提取过程并更新输出:

fun doExtraction(){
        if (extractorAvailable) {
            val userText = txtInput.text.toString()
            val params = EntityExtractionParams.Builder(userText)
                .build()
            var outputString = ""
            entityExtractor.annotate(params)
                .addOnSuccessListener { result: List<EntityAnnotation> ->
                    for (entityAnnotation in result) {
                        outputString += entityAnnotation.annotatedText
                        for (entity in entityAnnotation.entities) {
                            outputString += ":" + getStringFor(entity)
                        }
                        outputString += "\n\n"
                    }
                    txtOutput.text = outputString
                }
                .addOnFailureListener {
                }
        }
    }

对于这个应用就是这样了!这是一个非常简单的应用,我只想让你快速了解和使用实体提取。你可以利用提取出的实体来创建有用的功能——比如使用 Android Intents 在你的设备上启动其他应用程序。例如,当用户点击提取出的地址时启动地图应用,或者启动电话应用进行电话呼叫等。这种类型的实体提取还支持智能助理,如 Google Assistant、Siri 或 Alexa。

手写和其他识别

在触摸设备上识别手写是一个常见的场景,在这种场景中,您可以在表面上绘制笔画,然后将这些笔画转换为文本。因此,例如,请考虑图 5-2,在这里我创建了一个非常简单的应用程序来识别我的可怕的手写。

图 5-2. 使用 ML Kit 识别手写

让我们探讨构建这样一个应用程序所需的步骤。

启动应用程序

与之前一样,使用 Android Studio 创建一个新的单视图应用程序(详细信息请参阅第三章)。编辑应用程序的 build.gradle 文件,添加依赖项以使用 ML Kit 的数字墨水识别库:

implementation 'com.google.mlkit:digital-ink-recognition:16.1.0'

这些库通过单独的模型支持许多不同的语言,因此它们需要下载模型才能供您使用。这意味着您需要更新您的 Android 清单,以允许访问 Internet 和存储,否则应用程序将无法访问模型:

<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

接下来,您将探索如何实现一个表面,您将在其上绘制您的手写。

创建绘图表面

最简单的方法是创建一个自定义视图,其中包含一个 Canvas,用作绘图表面。

注意

我不会在这里详细介绍如何将所有内容组合在一起——代码在本书的 GitHub 中——但重要的是,在屏幕上捕获用户的笔画并在 Canvas 上绘制它们时,您还需要将它们添加到 ML Kit 的笔画构建器对象中,然后可以用于构建 Ink 对象,这是模型将接受和解析的对象。您还可以在https://developer.android.com/guide/topics/ui/custom-components.中了解更多关于自定义视图的信息。

在用户界面上绘制时,通常需要实现三种方法——touchStart(),当用户首次触摸屏幕时;touchMove(),当他们在屏幕上拖动手指或触笔时;以及touchUp(),当他们从屏幕上移开手指或触笔时。这三种方法共同形成一个笔画。所有这三种方法都由视图上的onTouchEvent方法捕获,因此我们可以根据检测到的动作调用它们,例如:

override fun onTouchEvent(event: MotionEvent): Boolean {
    motionTouchEventX = event.x
    motionTouchEventY = event.y
    motionTouchEventT = System.currentTimeMillis()

    when (event.action) {
        MotionEvent.ACTION_DOWN -> touchStart()
        MotionEvent.ACTION_MOVE -> touchMove()
        MotionEvent.ACTION_UP -> touchUp()
    }
    return true
}

因此,当触摸开始时,我们希望做两件事情。首先,启动路径(用于在屏幕上绘制手写笔迹),并将其移动到当前触摸点。然后,我们将在 ML Kit 中创建一个新的strokeBuilder,捕获当前点和当前时间,以便创建一个 ML Kit 可以后续解析的Ink对象:

private fun touchStart() {
    // For drawing on the screen
    path.reset()
    path.moveTo(motionTouchEventX, motionTouchEventY)
    // Initialize the stroke to capture the ink for MLKit
    currentX = motionTouchEventX
    currentY = motionTouchEventY
    strokeBuilder = Ink.Stroke.builder()
    strokeBuilder.addPoint(Ink.Point.create(motionTouchEventX,
                                            motionTouchEventY,
                                            motionTouchEventT))
}

当用户在屏幕上划动手指时,将调用touchMove()函数。首先会更新用于屏幕更新的path变量,然后更新strokeBuilder,以便当前笔画可以转换为 ML Kit 识别的Ink对象:

private fun touchMove() {
    val dx = Math.abs(motionTouchEventX - currentX)
    val dy = Math.abs(motionTouchEventY - currentY)
    if (dx >= touchTolerance || dy >= touchTolerance) {
        path.quadTo(currentX, currentY, (motionTouchEventX + currentX) / 2,
                              (motionTouchEventY + currentY) / 2)
        currentX = motionTouchEventX
        currentY = motionTouchEventY
     // Update the Stroke Builder so ML Kit can understand the ink
        strokeBuilder.addPoint(Ink.Point.create(motionTouchEventX,
                                                motionTouchEventY,
                                                motionTouchEventT))
        extraCanvas.drawPath(path, paint)
    }
    invalidate()
}

最后,当用户从表面上移开手指时,将调用触摸结束事件。在这一点上,我们应该重置路径,这样下次在屏幕上绘制时,我们会重新开始。对于 ML Kit,我们应该通过在用户移开手指的位置添加一个最后的点来结束笔划,然后将完成的笔划(从按下开始,移动时绘制,到放开结束)添加到我们的墨迹中使用 inkBuilder

private fun touchUp() {
    strokeBuilder.addPoint(Ink.Point.create(motionTouchEventX,
                                            motionTouchEventY,
                                            motionTouchEventT))
    inkBuilder.addStroke(strokeBuilder.build())
    path.reset()
}

随着时间的推移,当你在屏幕上创建笔划时,inkBuilder 将在其笔划集合中记录它们。

当你想要从 inkBuilder 获取所有笔划时,可以通过调用其 build 方法来实现,就像这样:

fun getInk(): Ink{
    val ink = inkBuilder.build()
    return ink
}

对于可以下载的代码,我在 CustomDrawingSurface 视图中实现了所有这些功能,然后可以像这样将其添加到活动布局中:

<com.odmlbook.digitalinktest.CustomDrawingSurface
    android:id="@+id/customDrawingSurface"
    android:layout_width="match_parent"
    android:layout_height="300dp" />

使用 ML Kit 解析墨迹

在前面的部分中,你看到了一个自定义的绘图表面,用户可以在上面写字,他们的笔划被捕获到一个 Ink 对象中。然后,可以使用 ML Kit 将这个 Ink 对象解释为文本。具体步骤如下:

  1. 初始化一个模型标识符对象,包含你想要使用的模型的规格,例如,模型能够识别的语言。

  2. 从模型标识符构建一个模型的引用。

  3. 使用远程模型管理器对象下载模型。

  4. 从模型创建一个识别器对象。

  5. 将墨迹传递给识别器并解析返回的结果。

因此,在托管生成墨迹的自定义绘图表面的活动中,你需要完成所有这些步骤。让我们看看这在实践中是什么样子。

首先,initializeRegonition() 函数将创建一个 DigitalInkRecognitionModelIdentifier 实例,并用它构建一个指向模型的引用,然后下载该模型:

fun initializeRecognition(){
    val modelIdentifier: DigitalInkRecognitionModelIdentifier? =
        DigitalInkRecognitionModelIdentifier.fromLanguageTag("en-US")
    model = DigitalInkRecognitionModel.builder(modelIdentifier!!).build()
    remoteModelManager.download(model!!, DownloadConditions.Builder().build())
}

注意 fromLanguageTag 方法,我传递了 en-US 作为语言代码。正如你所期望的,这将实现模型识别英文/美国文本。想要获取完整代码列表,请查看 ML Kit 数字墨水示例应用程序,他们在这里有连接到 ML Kit 的代码,可以下载当前支持的所有代码列表。

一旦远程模型管理器下载了模型,你就可以用它来对你的墨迹笔画进行推断。因此,你首先要通过从 ML Kit 的 DigitalInkRecognition 对象调用 getClient 方法来创建一个识别器,并将你刚刚指定和下载的模型作为构建识别器的所需模型传递进去:

recognizer = DigitalInkRecognition.getClient(
                       DigitalInkRecognizerOptions.builder(model!!).build() )

然后,你可以从之前创建的绘图表面获取墨迹:

val thisInk = customDrawingSurface.getInk()

然后,你可以在你的识别器上调用 recognize 方法,将墨迹传递给它。ML Kit 将通过成功或失败的监听器回调给你结果:

recognizer.recognize(thisInk)
                .addOnSuccessListener { result: RecognitionResult ->
                    var outputString = ""
                    txtOutput.text = ""
                    for (candidate in result.candidates){
                        outputString+=candidate.text + "\n\n"
                    }
                    txtOutput.text = outputString
                }
                .addOnFailureListener { e: Exception ->
                    Log.e("Digital Ink Test", "Error during recognition: $e")
                }

成功后,你将获得一个包含多个结果候选项的“result”对象。在这种情况下,我只是遍历它们并输出它们。它们已按照它们匹配你的笔划的可能性进行了预排序。

因此,参考图 5-2,你可以看到我的笔划很可能是“hello”(小写 h),然后是“Hello”,最后是“hell o”,第二个“l”和“o”之间有一个空格。

鉴于支持的多种语言,这为你提供了一个非常强大的工具,可以理解用户的输入,如果你想为手写创建一个接口的话!

例如,看看我在图 5-3 中尝试写“你好”时的精彩表现,以及应用程序如何将其解析为正确的字符!

图 5-3. 使用中文语言模型

对话的智能回复

另一个你可以使用的即插即用模型示例是智能回复模型。借助它,你可以向模型提供对话的内容,它可以推断出可能的回复。你可能已经在许多网站和应用程序中看到过它的使用,如果你想知道如何实现它,这个 API 将为你提供一个很好的起点。

你可以在图 5-4 中看到它的运行情况。

图 5-4. 使用智能回复

在这里,我模拟了我和朋友的一次对话,我们在讨论早餐。她问我一个问题:“只喝咖啡,还是你想吃点东西?”当我按下生成回复按钮时,推荐的答案是“当然,听起来不错。”虽然它并没有真正回答问题,但作为一个相当不错的回复,因为它捕捉到了我的用语——当被问及是否想见面时,我的回答是“当然,你想要什么?”所以现在生成的短语也以“当然”开头。

让我们看看这个应用是如何构建的。

启动应用程序

如前所述,创建一个带有单个活动的新应用程序。如果你不熟悉,可以参考第三章中的步骤。

完成后,你可以通过将以下内容添加到你的 build.gradle 文件来包含智能回复库:

implementation 'com.google.mlkit:smart-reply:16.1.1'

Gradle 同步后,库将准备就绪,你可以开始编码。

模拟一次对话

智能回复 API 需要传递一个对话,并且对话的最后一个元素不能是你在说话。要创建一个对话,你可以使用TextMessage类型来表示对话中的每个条目,并将它们添加到一个ArrayList中。可以通过调用createForLocalUsercreateForRemoteUser方法来为本地用户(你自己)或远程用户(你的朋友)创建这种类型。非常重要的是调用正确的方法,这样 API 才能区分你和其他人,并且能够基于你的用语生成智能回复。

我写了这样的代码来初始化模拟对话:

// Class level variables
var outputText = ""
var conversation : ArrayList<TextMessage> = ArrayList<TextMessage>()

fun initializeConversation(){
        val friendName: String = "Nizhoni"
        addConversationItem("Hi, good morning!")
        addConversationItem("Oh, hey -- how are you?", friendName)
        addConversationItem("Just got up, thinking of heading out for breakfast")
        addConversationItem("Want to meet up?",friendName)
        addConversationItem("Sure, what do you fancy?")
        addConversationItem("Just coffee, or do you want to eat?", friendName)
        conversationView.text = outputText
    }

    private fun addConversationItem(item: String){
        outputText += "Me : $item\n"
        conversation.add(TextMessage.createForLocalUser(
                                     item, System.currentTimeMillis()))
    }

    private fun addConversationItem(item: String, who: String){
        outputText += who + " : " + item + "\n"
        conversation.add(TextMessage.createForRemoteUser(
                                     item, System.currentTimeMillis(),who))
    }

initializeConversation()方法只需调用addConversationItem并传递字符串,以及一个可选的第二个参数,其中包含我的朋友的名字。然后,我重载了addConversationItem,如果只传递了一个字符串,则添加当前用户的TextMessage,或者如果传递了两个字符串,则添加远程用户的TextMessage

outputText是稍后将添加到 TextView 的对话文本。

现在,我们有了由为本地或远程用户创建的TextMessage组成的对话,我们可以使用它来生成预测的下一条文本。

生成智能回复

在本书的 GitHub 仓库中可以找到图示为 Figure 5-4 的应用程序。在该屏幕截图中,您可以看到一个生成回复按钮——要获得智能回复,您只需在此按钮的OnClickListener中使用SmartReply.getClient()初始化智能回复客户端。

您将您的对话传递给其suggestReplies方法,如果推断成功,您将收到一个结果对象:

val smartReplyGenerator = SmartReply.getClient()

smartReplyGenerator.suggestReplies(conversation)
                    .addOnSuccessListener { result ->
}

此结果对象包含一个建议列表,每个建议都包含一个带有建议文本的text属性。因此,例如,您可以将EditText控件的内容设置为排名最高的回复,如下所示:

txtInput.setText(result.suggestions[0].text.toString())

或者,如果您愿意,您可以遍历每一个并生成某种选择器,用户可以选择他们想要采纳的建议。

总结

在本章中,您了解了如何在多种场景下开始使用已经为您提供了 ML 模型或一组模型的情况下处理文本。您首先查看了如何从完整的字符串中提取常见的实体,如地址和电话号码。然后,您探索了一个应用如何捕捉用户的手写,并且 ML Kit 模型可以将该手写转换为文本。最后,您快速浏览了 Smart Reply,以便您可以创建一个应用程序,该应用程序使用 ML 为对话提供建议的回复!

所有这些都是现成的模型,但它们可以为您的应用程序提供一个非常好的起点,进入机器学习。逻辑的下一步是将其扩展为使用您自己数据创建的自定义模型——我们将在第八章中开始探索这一点。在第六章和第七章中,您将涵盖与之前两章相同的领域,但专注于使用 Swift 在 iOS 上启动相同的视觉和文本场景!

第六章:计算机视觉应用程序与 iOS 上的 ML Kit

第 3 章介绍了 ML Kit 及其在移动应用程序中进行人脸检测的用途。在第 4 章中,我们还介绍了如何在 Android 设备上执行更复杂的场景——图像标签和分类,以及静态图像和视频中的对象检测。在本章中,我们将看到如何在 iOS 中使用 ML Kit 执行相同的场景,使用 Swift 语言。让我们从图像标签和分类开始。

图像标签和分类

计算机视觉的一个核心概念是图像分类,您向计算机提供一张图像,计算机会告诉您图像包含的内容。在最高级别上,您可以给它一张狗的图片,比如图 6-1,它会告诉您图像中包含一只狗。

ML Kit 的图像标签功能进一步扩展了这一点,它将为您提供一张图片中“看到”的物品列表,每个物品都附带概率级别。因此,对于图 6-1 中的图片,它不仅会看到一只狗,还可能看到一只宠物、一个房间、一件夹克等等。在 iOS 上构建这样的应用程序非常简单,让我们一步步来探索。

注意

在撰写本文时,ML Kit 的 pod 在运行 Mac 上的 iOS 模拟器时可能会出现一些问题。应用程序仍然可以在设备上运行,并且还可以在 Xcode 中的“My Mac(专为 iPad 设计)”运行时设置下运行。

图 6-1. iPhone 图像分类的示例图片

步骤 1:在 Xcode 中创建应用程序

您将使用 Xcode 创建应用程序。在 Xcode 中使用应用程序模板,确保您的界面类型为 Storyboard,语言为 Swift。如果您对这些步骤不熟悉,请参阅第 3 章,我们在其中详细介绍了这些步骤。为应用程序取任何您喜欢的名字,但在我的情况下,我使用了 MLKitImageClassifier 作为名称。完成后,关闭 Xcode。在下一步中,您将添加一个 pod 文件,安装后它会提供一个新文件以打开 Xcode。

步骤 2:创建 Podfile

此步骤要求您的开发环境中已安装 CocoaPods。CocoaPods 是一个依赖管理工具,可帮助您轻松向 iOS 应用程序添加第三方库。由于 ML Kit 来自 Google,并未集成到 Xcode 中,因此您需要通过 CocoaPods 将其作为“pod”添加到任何应用程序中。您可以在http://cocoapods.org找到 CocoaPods 的安装说明,并在我们逐步示例中为您提供选择适当 pod 的代码。

在创建项目的目录中,添加一个新文件。命名为podfile,没有扩展名。保存后,您的项目目录结构应如图 6-2 所示。

Figure 6-2

图 6-2. 将 Podfile 添加到项目文件夹中

编辑此文件的内容,使其如下所示:

platform :ios, '10.0'

target 'MLKitImageClassifier' do
        pod 'GoogleMLKit/ImageLabeling'
end

注意以target开头的行。在target后面,应该用引号括起来你的项目名称,前面的案例中是MLKitImageClassifier,但如果你使用了其他名称,请确保修改代码以适应你的项目名称。

完成并保存后,请使用 Terminal 导航到该文件夹。输入命令**pod install**。您应该看到类似图 6-3 的输出。

Figure 6-3

图 6-3. 安装图像分类器 CocoaPod

最后,注意到末尾要求您从现在开始使用.xcworkspace文件。当您在步骤 1 中创建项目时,您将使用.xcproject文件在 Xcode 中打开它。这些文件无法处理通过 pods 包含的外部库,但.xcworkspace可以。因此,今后应该使用该文件。现在使用 Xcode 打开它吧!

第三步:设置 Storyboard

main.storyboard文件将包含您应用程序的用户界面。在其中添加一个 UIImage 视图,并将其属性设置为 Aspect Fit。然后,添加一个按钮控件,并将其文本更改为“Do Inference”。最后,添加一个 UILabel,并使用属性检查器将其 Lines 属性设置为“0”,以确保它将具有多行文本。调整标签的大小以确保有足够的空间。完成后,您的 Storyboard 应该类似于图 6-4。

Figure 6-4

图 6-4. 创建简单的 Storyboard

接下来,您需要为图像和标签控件创建 outlet,并为按钮创建一个 action。您可以通过打开一个独立的窗口,将控件拖动到ViewController.swift文件中来完成此操作。如果您对此不熟悉,请参考第三章中的详细示例。

或许会感到困惑,不知道何时需要 outlet,何时需要 action,所以我喜欢这样思考。如果你想要读取或设置控件的属性,则需要一个“outlet”。因此,例如,我们需要读取图像视图控件的内容以传递给 ML Kit 进行分类。您还需要写入标签的内容以渲染结果。因此,它们都需要设置一个 outlet,并且该 outlet 的名称是您在代码中引用这些控件的方式。当您需要响应用户对控件进行操作,例如按下按钮时,您将需要一个“action”。当您将控件拖放到代码编辑器时,将提供使用 outlet 或 action 的选项,因此现在为 UIImageView 和 Label 创建 outlet,并分别命名为imageViewlblOutput。然后为按钮创建一个 action。您可以将此操作命名为doInference

当您完成时,您的ViewController.swift文件应该如下所示:

import UIKit

class ViewController: UIViewController {

    @IBOutlet weak var imageView: UIImageView!
    @IBOutlet weak var lblOutput: UILabel!
    @IBAction func doInference(_ sender: Any) {
    }
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
    }

}

接下来你将编辑以实现图像分类代码。不过,在此之前,你需要一个要分类的图像!你可以从文件系统中选择任何图像并将其拖到 Xcode 中。将其拖放到你的项目中,与你的 Storyboard 文件位于同一文件夹中。你会得到一个对话框让你选择选项。保持默认设置,但确保“添加到目标”旁边的复选框被选中以确保图像被编译到你的应用程序中,这样在运行时就可以加载它。

第四步:编辑视图控制器代码以使用 ML Kit

现在你的应用程序什么都做不了,图片还没有加载,也没有编写处理按钮按下的代码。让我们在本节中逐步构建该功能。

首先,为了加载图像,你可以在应用程序第一次启动时添加到viewDidLoad函数中的代码。这只需简单地在imageView上设置.image属性即可:

// On view did load we only need to initialize the image,
// so we can do that here
override func viewDidLoad() {
    super.viewDidLoad()
    // Do any additional setup after loading the view.
    imageView.image = UIImage(named:"fig6-1dog.jpg")
}

如果你现在启动应用程序,图片会被渲染,但不会发生太多其他事情。通常用户会按下按钮,你需要对该操作做出响应。你已经设置了doInference动作,所以让它调用一个名为getLabels的函数,使用imageView中的图片作为参数:

@IBAction func doInference(_ sender: Any) {
    getLabels(with: imageView.image!)
}

Xcode 会抱怨因为你还没有实现这个函数。没关系,你接下来会做这件事。这个函数的作用是获取图像的内容并传递给 ML Kit 以获取一组标签。因此,在你编写代码之前,确保你已经引用了相关的 ML Kit 库。在你的ViewController.swift文件顶部添加这些行:

// Import the MLKit Vision and Image Labeling libraries
import MLKitVision
import MLKitImageLabeling

要使用 ML Kit 进行图像标记,您需要完成以下任务:

  • 将图像转换为 VisionImage 类型。

  • 设置图像标签器的选项并使用这些选项进行初始化。

  • 调用图像标签器并捕获异步回调。

因此,首先将图像转换为VisionImage,你将编写如下代码:

    let visionImage = VisionImage(image: image)
    visionImage.orientation = image.imageOrientation

然后,通过设置一些选项来初始化标签器。在这种情况下,你可以简单地使用置信度阈值的选项。虽然它可以标记图像中的许多物体,但目标是返回超过某个概率的标签。例如,图像在 Figure 6-1 中,尽管是一只狗,但实际上有超过 0.4 的概率(即 40%)也是一只猫!因此,你可以将置信度阈值设置为 0.4 来查看:

    let options = ImageLabelerOptions()
    options.confidenceThreshold = 0.4
    let labeler = ImageLabeler.imageLabeler(options: options)

现在,你有了一个标签器和一个以所需格式的图像,你可以将其传递给标签器。这是一个异步操作,因此在处理过程中不会锁定用户界面。相反,你可以指定一个回调函数,在推理完成时调用它。标签器将返回两个对象:“labels”用于推理结果,“error”如果失败。然后,你可以将它们传递给自己的函数(在这种情况下称为processResult)来处理它们:

    labeler.process(visionImage) { labels, error in
        self.processResult(from: labels, error: error)
    }

为方便起见,这里是整个getLabels func

// This is called when the user presses the button
func getLabels(with image: UIImage){
    // Get the image from the UI Image element
    // and set its orientation
    let visionImage = VisionImage(image: image)
    visionImage.orientation = image.imageOrientation

    // Create Image Labeler options, and set the
    // threshold to 0.4 so we will ignore all classes
    // with a probability of 0.4 or less
    let options = ImageLabelerOptions()
    options.confidenceThreshold = 0.4

    // Initialize the labeler with these options
    let labeler = ImageLabeler.imageLabeler(options: options)

    // And then process the image, with the
    // callback going to self.processresult
    labeler.process(visionImage) { labels, error in
        self.processResult(from: labels, error: error)
 }
}

标签器完成其工作后,将调用processResult函数。如果你输入了前面的代码,Xcode 可能会抱怨找不到此函数。因此,让我们接着实现它!

ML Kit 返回的标签集合是一个ImageLabel对象的数组。因此,你需要让你的函数使用该类型作为from参数。这些对象有一个text属性,其中包含标签描述(例如,cat),还有一个置信度属性,表示图像匹配该标签的概率(例如,0.4)。因此,你可以遍历该集合并使用以下代码构建包含这些值的字符串。然后,它将把lblOutput文本设置为你构建的字符串。以下是完整代码:

// This gets called by the labeler's callback
func processResult(from labels: [ImageLabel]?, error: Error?){
    // String to hold the labels
    var labeltexts = ""
    // Check that we have valid labels first
    guard let labels = labels else{
        return
    }
    // ...and if we do we can iterate through
    // the set to get the description and confidence
    for label in labels{
        let labelText = label.text + " : " +
                        label.confidence.description + "\n"
        labeltexts += labelText
    }
    // And when we're done we can update the UI
    // with the list of labels
    lblOutput.text = labeltexts
}

一切就是这样!当你运行应用程序并按下按钮时,你会看到类似于图 6-5 的东西。

你可以看到,虽然 ML Kit 非常确定它在看一只狗,但它也有 40% 的可能性是一只猫!

显而易见,这是一个超级简单的应用程序,但希望它展示了如何在 iOS 上仅使用几行代码快速简便地使用 ML Kit 进行图像标记!

图 6-5. 在狗图片上使用此应用程序进行推断

iOS 中的物体检测与 ML Kit

接下来,让我们探索一个类似的场景,像图像分类一样,我们再进一步。不仅让设备识别图像中的什么,让它也能识别图像中的哪里,使用边界框为用户绘制。

例如,看看图 6-6。这个应用程序看到这张图片,并检测到其中的三个对象。

图 6-6. iOS 中的物体检测应用

步骤 1:开始

创建这个应用程序非常简单。像以前一样创建一个新的 iOS 应用程序。在新项目设置对话框中使用 Swift 和故事板,给它任何你喜欢的名字。在这种情况下,我将我的项目命名为MLKitObjectDetector

在项目目录中,你将创建一个 Podfile,就像在前面的部分中所做的那样,但这次你将指定要使用 ML Kit 的物体检测库,而不是图像标签库。你的 Podfile 应该看起来像这样:

platform :ios, '10.0'
# Comment the next line if you're not using Swift and don't want to use dynamic
# frameworks
use_frameworks!

target 'MLKitObjectDetector' do
        pod 'GoogleMLKit/ObjectDetection'
end

注意,目标设置应为您在 Xcode 中创建的项目名称(在我的情况下,我使用了 MLKitObjectDetector),并且 pod 应为 GoogleMLKit/ObjectDetection

当完成时,运行 **pod install** 下载所需的依赖项,然后将为您创建一个新的 .xcworkspace 文件。打开此文件以继续。

步骤 2: 在 Storyboard 上创建您的用户界面

这个应用比图像标注应用更简单,因为它只有两个用户界面元素——一个您希望进行对象检测的图像,并且您将在其上绘制边界框,以及一个用户按下以触发对象检测的按钮。因此,在 storyboard 中添加一个 UIImageView 和一个按钮。编辑按钮以更改其文本为“检测对象”。当您完成时,您的 storyboard 应该看起来像图 6-7 中的示例。

图 6-7. 对象检测应用的 storyboard

您需要为图像视图创建一个输出口,并为按钮创建一个操作。如果您对这些不熟悉,我建议您返回到第三章,并完成那里的示例,以及本章前面的图像标注示例。

当完成时,您的 ViewController.swift 文件将定义了输出口和操作,并应如下所示:

import UIKit

class ViewController: UIViewController {
    @IBOutlet weak var imageView: UIImageView!
    @IBAction func doObjectDetection(_ sender: Any) {
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
    }

}

在上述代码中,我将 UIImageView 的输出命名为 imageView,按钮按下时的操作命名为 doObjectDetection

您需要向项目中添加一张图像,以便将其加载到 UIImageView 中。在这种情况下,我有一个称为 bird.jpg 的图像,并且加载它的代码在 viewDidLoad() 中看起来像这样:

imageView.image = UIImage(named: "bird.jpg")

步骤 3: 创建注释的子视图

当从 ML Kit 收到检测到的对象时,此应用会在图像上绘制边界框。在进行推理之前,让我们确保可以在图像上进行绘制。为此,您需要在图像顶部绘制一个子视图。该子视图将是透明的,并在其上绘制边界框。由于它位于图像顶部并且除了矩形之外是透明的,因此看起来好像矩形是绘制在图像上的。

ViewController.swift 中将此视图声明为 UIView 类型。使用 Swift,您可以指定如何实例化视图,例如,确保视图已经加载,确保此视图将在包含您图片的 UIImageView 之后加载,使加载更容易!请参见以下示例:

/// An overlay view that displays detection annotations.
private lazy var annotationOverlayView: UIView = {
  precondition(isViewLoaded)
  let annotationOverlayView = UIView(frame: .zero)
  annotationOverlayView
      .translatesAutoresizingMaskIntoConstraints = false
  return annotationOverlayView
}()

声明完成后,您现在可以在 viewDidLoad 函数中实例化和配置它,将其作为 imageView 的子视图添加进去。您还可以使用 NSLayoutConstraint 激活它,以确保它与 imageView 的尺寸匹配:

override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        imageView.image = UIImage(named: "bird.jpg")
        imageView.addSubview(annotationOverlayView)
        NSLayoutConstraint.activate([
          annotationOverlayView.topAnchor.constraint(
              equalTo: imageView.topAnchor),
          annotationOverlayView.leadingAnchor.constraint(
              equalTo: imageView.leadingAnchor),
          annotationOverlayView.trailingAnchor.constraint(
              equalTo: imageView.trailingAnchor),
          annotationOverlayView.bottomAnchor.constraint(
              equalTo: imageView.bottomAnchor),
        ])

    }

步骤 4: 执行对象检测

在您可以使用 ML Kit 的对象检测 API 之前,需要将它们包含在您的代码文件中。您可以通过以下方式实现:

import MLKitVision
import MLKitObjectDetection

然后,在为用户按下按钮创建的操作中,添加以下代码:

runObjectDetection(with: imageView.image!)

Xcode 将抱怨这个函数还没有被创建。没关系,现在就来创建它!在您的ViewController.swift文件中创建runObjectDetection函数:

func runObjectDetection(with image: UIImage){
}

与本章前面的图像标注类似,使用 ML Kit 执行对象检测的过程非常简单:

  • 将您的图像转换为VisionImage

  • 创建一个包含所选选项的选项对象,并使用这些选项实例化一个对象检测器。

  • 将图像传递给对象检测器,并在回调中捕获其响应。

让我们详细了解如何在刚刚创建的函数中执行每个步骤。首先,将您的图像转换为VisionImage

let visionImage = VisionImage(image: image)

然后使用这些选项创建选项对象,并实例化一个对象检测器:

let options = ObjectDetectorOptions()
options.detectorMode = .singleImage
options.shouldEnableClassification = true
options.shouldEnableMultipleObjects = true
let objectDetector = ObjectDetector.objectDetector(
                                      options: options)

探索 ML Kit 文档以获取选项类型;这里使用的选项非常常见。EnableClassification不仅提供边界框,还提供 ML Kit 对对象的分类。基础模型(您在这里使用的模型)只能识别五种非常通用的对象类型,如“时尚物品”或“食品物品”,因此请合理管理您的期望!当设置了EnableMultipleObjects选项时,允许检测多个对象,如图 6-6 中所示,检测到三个物品——鸟和两朵花,并绘制了边界框。

最后,您将传递图像给对象检测器,以便它推断图像中检测到的对象的标签和边界框。这是一个异步函数,因此您需要指定在推断完成时回调的函数。ML Kit 将返回一个detectedObjects列表和一个错误对象,因此您可以简单地将它们传递给下一个将要创建的函数:

objectDetector.process(visionImage)
    { detectedObjects, error in
       self.processResult(from: detectedObjects, error: error)
    }

第 5 步:处理回调

在上一步中,我们定义了回调函数processResult。因此,让我们首先创建它。它以detectedObjects(一个Object数组)作为from:参数,并以Error对象作为error:参数。以下是代码:

func processResult(from detectedObjects: [Object]?,
                   error: Error?){
}

接下来,如果detectedObjects数组为空,我们将退出,以免浪费时间尝试绘制或更新覆盖层:

guard let detectedObjects = detectedObjects else{
    return
}

接下来,您将遍历所有检测到的对象,并绘制检测到的边界框。

处理回调以收集边界框比在本章前面探讨的图像分类场景稍微复杂一些。这是因为屏幕上渲染的图像可能具有与底层图像不同的尺寸。当你将图像传递给 ML Kit 时,你传递的是整个图像,因此返回的边界框是相对于图像的。如果这看起来没有意义,可以这样考虑。想象一下,你的图像是 10000×10000 像素。在你的屏幕上呈现的可能是 600×600 像素。当从 ML Kit 返回边界框时,它们将相对于 10000×10000 像素的图像,因此你需要将它们转换为正确显示图像的坐标。

因此,你将首先为图像计算一个变换矩阵,然后可以应用这个矩阵来获得一个变换后的矩形。

这是完整的代码。我不会详细解释,但主要目的是获取底层图像的尺寸,并将其缩放到 UIImage 控件中渲染的图像大小:

private func transformMatrix() -> CGAffineTransform {
    guard let image = imageView.image else {
            return CGAffineTransform() }
    let imageViewWidth = imageView.frame.size.width
    let imageViewHeight = imageView.frame.size.height
    let imageWidth = image.size.width
    let imageHeight = image.size.height

    let imageViewAspectRatio =
      imageViewWidth / imageViewHeight
    let imageAspectRatio = imageWidth / imageHeight
    let scale =
      (imageViewAspectRatio > imageAspectRatio)
        ? imageViewHeight / imageHeight :
          imageViewWidth / imageWidth

    // Image view's `contentMode` is `scaleAspectFit`,
    // which scales the image to fit the size of the
    // image view by maintaining the aspect ratio.
    //  Multiple by `scale` to get image's original size.
    let scaledImageWidth = imageWidth * scale
    let scaledImageHeight = imageHeight * scale

     let xValue =
      (imageViewWidth - scaledImageWidth) / CGFloat(2.0)

     let yValue =
      (imageViewHeight - scaledImageHeight) / CGFloat(2.0)

    var transform = CGAffineTransform.identity.translatedBy(
                      x: xValue, y: yValue)
    transform = transform.scaledBy(x: scale, y: scale)
    return transform
}

现在你可以转换图像,接下来的事情是迭代每个对象,提取结果并进行相应的转换。你将通过循环遍历检测到的对象,并使用它们的 frame 属性来做到这一点,该属性包含了边界框的框架。

现在,由于你刚刚创建的变换矩阵,这个循环变得很简单:

for obj in detectedObjects{
    let transform = self.transformMatrix()
    let transformedRect = obj.frame.applying(transform)
}

给定你现在有一个与渲染图像匹配的转换矩形,接下来你会想要绘制这个矩形。之前你创建了与图像匹配的注释叠加视图。因此,你可以通过向该叠加视图添加一个矩形子视图来进行绘制。这里是代码:

self.addRectangle(transformedRect,
                  to: self.annotationOverlayView)

你还没有一个addRectangle函数,但现在创建一个非常简单。你只需创建一个具有矩形尺寸的新视图,并将其添加到指定的视图中。在这种情况下,即annotationOverlayView

这里是代码:

private func addRectangle(_ rectangle: CGRect,
                         to view: UIView) {

    let rectangleView = UIView(frame: rectangle)
    rectangleView.layer.cornerRadius = 2.0
    rectangleView.layer.borderWidth = 4
    rectangleView.layer.borderColor = UIColor.red.cgColor
    view.addSubview(rectangleView)
}

就是这样!你现在创建了一个可以识别图像中元素并返回其边界框的应用程序。尝试使用不同的图像进行探索。

它给出的分类非常有限,但是现在你有了边界框,你可以根据边界框裁剪原始图像,并将该裁剪区域传递给图像标签器,以获取更细粒度的描述!下面你将探索这一点。

结合对象检测和图像分类

之前的示例展示了如何进行对象检测,并获取图像中检测到的对象的边界框。ML Kit 的基础模型只能对少数类别进行分类,如“时尚商品”、“美食”、“家居商品”、“地点”或“植物”。然而,它能够检测图像中的不同对象,正如我们在前面的示例中看到的那样,它发现了鸟和两朵花。它只是不能分类它们。

如果您想要这样做,在结合对象检测与图像分类的选项中有一个选项。因为您已经有了图像中项目的边界框,您可以将图像裁剪到它们,然后使用图像标签获取该子图像的详细信息。

因此,您可以更新您的处理结果,使用每个对象的帧属性来裁剪图像,并将裁剪后的图像加载到一个名为croppedImage的新UIImage中,如下所示:

guard let cutImageRef: CGImage =
    theImage?.cgImage?.cropping(to: obj.frame)
    else {break}

let croppedImage: UIImage = UIImage(cgImage: cutImageRef)

图像标签 API 使用一个VisionImage对象,正如您在本章前面看到的那样。因此,表示裁剪图像的UIImage可以转换如下:

let visionImage = VisionImage(image: croppedImage)

首先,您需要实例化一个图像标签器。以下代码将会实例化一个置信阈值为 0.8 的标签器:

let options = ImageLabelerOptions()
options.confidenceThreshold = 0.8
let labeler = ImageLabeler.imageLabeler(options: options)

然后,您只需将VisionImage对象传递给标签器,并指定回调函数来处理结果:

labeler.process(visionImage) {labels, error in
    self.processLabellingResult(from: labels, error: error)
}

您的回调函数可以处理从每个对象推断出的标签,就像本章早些时候所做的那样。

视频中的对象检测和跟踪

虽然本书超出了演示实时视频叠加的范围,但在仓库中的“Chapter6ObjectTracking”示例应用程序为您实现了它。它使用来自 Apple 的 CoreVideo 来实现一个AVCaptureVideoPreviewLayer和一个AVCaptureSession

捕捉然后委托给ViewController的扩展,它将从基于AVCaptureSessionAVCaptureConnection中捕获帧。

这里需要强调的重点是,您可以如何使用此视频的帧,将它们传递给 ML Kit,并获取对象检测结果。然后,您可以使用这些结果在实时视频上叠加边界框。

当您创建一个使用 Apple 的 AVFoundation 进行实时视频预览的应用程序时,您将拥有一个名为captureOutput的委托函数,它接收包含帧详细信息的缓冲区。这可以用来创建一个VisionImage对象,然后可以像这样与 ML Kit 中的对象检测 API 一起使用:

func captureOutput(_ output: AVCaptureOutput,
                   didOutput sampleBuffer: CMSampleBuffer,
                   from connection: AVCaptureConnection) {

  guard let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer)
        else {
            print("Failed to get image buffer from sample buffer.")
            return
        }

  lastFrame = sampleBuffer
  let visionImage = VisionImage(buffer: sampleBuffer)
  let orientation = UIUtilities.imageOrientation(
      fromDevicePosition: .back
  )

  visionImage.orientation = orientation
  let imageWidth = CGFloat(CVPixelBufferGetWidth(imageBuffer))
  let imageHeight = CGFloat(CVPixelBufferGetHeight(imageBuffer))
  let shouldEnableClassification = false
  let shouldEnableMultipleObjects = true
  let options = ObjectDetectorOptions()
  options.shouldEnableClassification = shouldEnableClassification
  options.shouldEnableMultipleObjects = shouldEnableMultipleObjects
  detectObjectsOnDevice(
      in: visionImage,
      width: imageWidth,
      height: imageHeight,
      options: options)
  }

当您从委托获得捕获的输出时,它将包含一个样本缓冲区。然后可以用来获取包含帧的图像缓冲区。

从中,您可以使用以下方法将缓冲区转换为VisionImage类型:

  let visionImage = VisionImage(buffer: sampleBuffer)

要在视频中跟踪对象,您需要禁用对象的分类,并启用多对象检测。您可以通过以下方式实现:

  let shouldEnableClassification = false
  let shouldEnableMultipleObjects = true
  let options = ObjectDetectorOptions()
  options.shouldEnableClassification = shouldEnableClassification
  options.shouldEnableMultipleObjects = shouldEnableMultipleObjects

现在,考虑到您有图像、选项和图像的尺寸,您可以调用一个辅助函数来执行对象检测:

  detectObjectsOnDevice(
      in: visionImage,
      width: imageWidth,
      height: imageHeight,
      options: options)
  }

此函数的作用将是调用 ML Kit 来获取帧中检测到的对象,然后为它们计算边界框,并显示带有跟踪 ID 的这些框。

因此,首先要检测对象:

let detector = ObjectDetector.objectDetector(options: options)
      var objects: [Object]
      do {
        objects = try detector.results(in: image)
      } catch let error {
        print("Failed with error: \(error.localizedDescription).")
        return
      }

然后,一旦它们被检测到,它们的边界框将以object.frame返回。这需要被归一化以绘制在预览叠加层上,它通过将其值除以帧的宽度来简单地归一化:

for object in objects {
    let normalizedRect = CGRect(
          x: object.frame.origin.x / width,
          y: object.frame.origin.y / height,
          width: object.frame.size.width / width,
          height: object.frame.size.height / height
)

预览层提供了一种将归一化矩形转换为与预览层坐标系统匹配的方法,因此您可以像这样将其添加到预览层中:

let standardizedRect = strongSelf.previewLayer.layerRectConverted(
  fromMetadataOutputRect: normalizedRect
).standardized

 UIUtilities.addRectangle(
  standardizedRect,
  to: strongSelf.annotationOverlayView,
  color: UIColor.green
)

UIUtilities是您可以在存储库中找到的辅助工具类。)您可以在 iPhone 上看到它在图 6-8 中的实际应用。

图 6-8. 视频中的物体检测和跟踪

有了这些工具,您应该能够在 iOS 上使用 Swift 创建基本的计算机视觉应用程序,使用 ML Kit。在第十一章中,您将看到如何使用自定义模型,而不是依赖于 ML Kit 的基础模型!

总结

在本章中,您看到了如何使用 ML Kit 的计算机视觉算法,包括图像标注和物体检测。您看到了如何将这些功能组合起来,扩展 ML Kit 提供的基本模型,以便对从物体检测返回的边界框的内容进行分类。您还看到了如何在实时视频中使用物体检测,并获得了如何在实时视频上添加边界框的示例!这为您提供了在构建使用图像的应用程序时继续前进的基础,无论是用于分类还是物体检测的目的。

第七章:在 iOS 上使用 ML Kit 进行文本处理应用程序

在第六章中,您了解了如何在 iOS 应用程序中使用 ML Kit 处理一些计算机视觉场景,包括图像识别和物体检测。也许 ML 应用程序的下一个最大领域是执行自然语言处理任务。因此,在本章中,我们将看一些示例,了解 ML Kit 模型如何为您提供一些常见的机器学习任务,包括从文本中提取实体,例如识别电子邮件地址或日期;执行手写识别,将笔画转换为文本;以及分析对话以生成智能回复。如果您想创建使用自定义模型的其他自然语言处理概念的应用程序,例如分类文本,您需要构建自己的模型,我们将在后面的章节中探讨这一点。

实体提取

您经常需要从文本中提取重要信息。毫无疑问,您已经见过可以确定文本中是否有地址并自动生成该地址地图链接的应用程序,或者了解电子邮件地址并生成链接,让您启动电子邮件应用程序发送邮件至该地址的应用程序。这个概念称为实体提取,在本节中,您将探索一个可以为您执行此操作的即插即用模型。这是 ML 的一个非常酷的实现,因为如果考虑到一个基于规则的方法如何解决这个问题,您会期望编写大量的代码!

因此,请考虑图 7-1,我已向我的朋友 Nizhoni 发送了一条带有一些细节的消息。作为人类阅读这段话时,您会自动从中提取有价值的信息并解析它。您会看到诸如“明天下午 5 点”的词语,并自动推断日期和时间。为此编写代码会有大量的 if...then 语句!

图 7-1. 在 iOS 上运行实体提取

正如你在文本下面所看到的,生成了一个包含找到的实体的列表。例如,“明天下午 5 点”被提取为日期时间。其他如电话号码和电子邮件地址也被正确提取。通常一个值会匹配多个模式;例如,书籍的 ISBN 号以三位数字开头,这与电话号码的模式匹配,因此被检测为两个实体!

现在让我们探索一下如何创建这个应用程序!

步骤 1: 创建应用程序并添加 ML Kit Pods

使用 Xcode 创建一个新的应用程序。完成后,关闭 Xcode 并在 .xcproject 文件所在的目录中创建一个 Podfile。

编辑 Podfile,像这样包括 GoogleMLKit/EntityExtraction pod:

platform :ios, '10.0'
# Comment the next line if you're not using Swift and don't want to use dynamic
# frameworks
use_frameworks!

target 'MLKitEntityExample' do
        pod 'GoogleMLKit/EntityExtraction'
end

target 后面的值应该是你项目的名称,在这个例子中我创建了一个名为 MLKitEntityExample 的项目。

完成后,运行 **pod install**;CocoaPods 将更新您的项目以使用 ML Kit 依赖项,并生成一个 .xcworkspace 文件,您应该打开该文件以进行下一步操作。

第二步:创建带有操作和输出的故事板

正如您在图 7-1 中看到的那样,这个应用的用户界面非常简单。在布局中添加一个 TextView、一个 Button 和一个 Label 控件,布局类似于您在图 7-1 中看到的样子。确保在将其放置在故事板后,通过检查属性检查器中的可编辑框来使 TextView 可编辑。

当您完成时,您的故事板设计器应该类似于图 7-2。

图 7-2. 在故事板编辑器中设计用户界面

接下来,您应该为 TextView 和 Label 创建 outlets,分别命名为 txtInputtxtOutput。还要为按钮创建一个动作,并将其命名为 doExtraction。如果您不熟悉 outlets 和 actions 的过程,请返回第三章和第六章获取更多指导示例。

完成后,您的 ViewController 类应包含以下代码:

@IBOutlet weak var txtInput: UITextView!
@IBOutlet weak var txtOutput: UILabel!
@IBAction func doExtraction(_ sender: Any) {
}

第三步:允许您的视图控制器用于文本输入

当用户点击顶部的文本视图时,他们将能够通过设备键盘编辑其内容。但是,默认情况下,键盘在完成后不会自动消失。要实现此功能,您需要更新 ViewController 以成为 UITextViewDelegate,如下所示:

class ViewController: UIViewController, UITextViewDelegate {

完成后,您可以添加以下函数,以使键盘在用户按下 Enter 键时离开:

func textView(_ textView: UITextView,
              shouldChangeTextIn range: NSRange,
              replacementText text: String) -> Bool {

    if (text == "\n") {
        textView.resignFirstResponder()
        return false
    }
    return true
}

最后,您需要通过将以下代码添加到 viewDidLoad 函数中,告知 iOS,txtInput 控件将 TextView 事件委托给此 ViewController

txtInput.delegate = self

现在,您可以允许用户输入文本了。接下来,让我们看看如何从这段文本中提取实体!

第四步:初始化模型

ML Kit 的实体提取器支持许多语言模型,因此您首先需要通过使用 EntityExtractorOptions 来定义您想要的语言模型。在本例中,我指定要使用英文实体提取器:

var entityExtractor =
  EntityExtractor.entityExtractor(options:
    EntityExtractorOptions(
modelIdentifier:EntityExtractionModelIdentifier.english))

支持多种不同的语言,完整列表请参见 https://developers.google.com/ml-kit/language/entity-extraction.

当用户按下按钮时,不能保证模型已在设备上,因此您可以在 viewDidLoad 中使用以下代码来下载它,并设置一个布尔标志指示它是否可用,稍后将进行检查:

entityExtractor.downloadModelIfNeeded(completion: { error in
    guard error == nil else {
        self.txtOutput.text = "Error downloading model, please restart app."
        return
    }
    self.modelAvailable = true
})

第五步:从文本中提取实体

之前,当您为按钮创建操作时,您获取了一个名为doExtraction的函数。在其中,您希望调用extractEntities,您将很快创建它,但仅当模型可用时。在步骤 3 中下载了模型,并且当完成时将modelAvailable设置为true,因此可以使用以下代码:

@IBAction func doExtraction(_ sender: Any) {
    if(modelAvailable){
        extractEntities()
    } else {
        txtOutput.text = "Model not yet downloaded, please try later."
    }
}

您现在可以创建extractEntities函数,并在其中使用刚刚创建的entityExtractortxtInput中的文本一起获取文本中的实体。

首先创建用于提取实体的代码如下:

func extractEntities(){
    let strText = txtInput.text
    entityExtractor.annotateText(
        strText!,
          completion: {
          }
}

在这里,您将文本传递给entityExtractorannotateText方法。它将在完成时给您一个回调,并且回调将包含结果和错误数据结构。结果将是一组注释的列表,每个注释将是一组实体的列表。

一个实体具有entityType属性,定义了注释类型,例如电子邮件、地址或 ISBN。实体具有一个范围属性,包含文本的位置和长度。因此,如果电子邮件地址位于第 20 个字符,并且长度为 15 个字符,则annotation.range.location将为 20,annotation.range.length将为 15。您可以使用这个来切割字符串以获取所需的文本。

这里是完整的代码:

func extractEntities(){
  let strText = txtInput.text
  entityExtractor.annotateText(strText!,
    completion: {
      results, error in
      var strOutput = ""
      for annotation in results! {
        for entity in annotation.entities{
          strOutput += entity.entityType.rawValue + " : "
          let startLoc = annotation.range.location
          let endLoc = startLoc + annotation.range.length - 1
          let mySubString = strText![startLoc...endLoc]
          strOutput += mySubString + "\n"
        }
      }
      self.txtOutput.text = strOutput
    })
}

Swift 字符串切片比你想象的更复杂!原因在于,攻击应用程序的常见方式是使用字符串和过于天真的字符串切片代码,这可能导致缓冲区下溢或溢出。因此,Swift 设计了Mid()Left()等类型的函数来防范这种天真的字符串切片。在前面的代码中,我们计算了startLocendLoc,然后设置mySubString为从开始到结束的切片。这在 Swift 中是支持的,需要进行扩展才能使其正常工作。请勿在任何生产应用程序中使用此代码,并在发布任何应用程序之前检查如何管理字符串!

这是字符串切片扩展的代码:

extension String {
  subscript(_ i: Int) -> String {
    let idx1 = index(startIndex, offsetBy: i)
    let idx2 = index(idx1, offsetBy: 1)
    return String(self[idx1..<idx2])
  }

  subscript (r: Range<Int>) -> String {
    let start = index(startIndex, offsetBy: r.lowerBound)
    let end = index(startIndex, offsetBy: r.upperBound)
    return String(self[start ..< end])
  }

  subscript (r: CountableClosedRange<Int>) -> String {
    let startIndex =  self.index(self.startIndex,
                           offsetBy: r.lowerBound)
    let endIndex = self.index(startIndex,
                       offsetBy: r.upperBound - r.lowerBound)
    return String(self[startIndex...endIndex])
  }
}

这是在 iOS 上使用 ML Kit 开始实体提取所需的全部内容。这只是初步探索,但希望这能让你了解 ML Kit 可以如何轻松完成这项任务!

手写识别

另一个例子是手写识别,如果用户用笔或手指在屏幕上绘制,你需要将他们的涂鸦转换为文本。幸运的是,ML Kit 也大大简化了这一过程,您将在本节中了解如何实现这一点。例如,考虑图 7-3,我用手指写了一些字母,应用程序检测到它们并将其识别为单词“hello”。

图 7-3. 应用程序,识别手写

使用 ML Kit 创建此类型的应用程序非常简单!让我们来探索一下。

步骤 1:创建应用程序并添加 ML Kit Pods

如前所述,请创建一个简单的单视图应用程序。完成后,在.xcproject相同目录下添加一个 Podfile。编辑 Podfile 以包含 ML Kit Digital Ink 库:

platform :ios, '10.0'
# Comment the next line if you're not using Swift and don't want to use dynamic
# frameworks
use_frameworks!

target 'MLKitInkExample' do
        pod 'GoogleMLKit/DigitalInkRecognition'
end
注意

如果您的项目不称为MLKitInkExample,那么应将您的项目名称指定为target。运行**pod install**,然后打开为您生成的.xcworkspace

步骤 2:创建故事板、操作和出口

绘图表面将是一个 UIImageView,因此请在故事板上绘制一个覆盖大部分屏幕的大图像视图。还添加一个按钮,并将其标签更改为“检测”,如图 7-4 所示。

图 7-4. 创建故事板

完成后,请为图像视图创建一个出口(称为mainImageView),并为按钮创建一个操作(称为recognizeInk)。在操作中添加一个调用名为doRecognition()的函数。如果 Xcode 抱怨该函数尚不存在,请不用担心。您很快会创建它。

您的代码应如下所示:

@IBAction func recognizeInk(_ sender: Any) {
    doRecognition()
}

@IBOutlet weak var mainImageView: UIImageView!

步骤 3:笔画、点和墨水

当使用 ML Kit 识别手写时,您将向其传递一个墨水对象。该对象由多个笔画组成,每个笔画都由多个点组成。例如,考虑字母 Z。它可能由三笔构成:一笔用于顶部线,一笔用于对角线,一笔用于底部线。墨水将是笔画的集合,每笔都将是用户绘制线条时收集的点的数量。

因此,在捕捉用户在屏幕上拖动手指时的输入之前,我们需要为我们设置这些数据结构:

private var strokes: [Stroke] = []
private var points: [StrokePoint] = []

你将看到稍后设置的墨水对象。

步骤 4:捕获用户输入

接下来,您将希望在用户用手指或笔在 UIImage 上绘制时收集用户的输入。这是通过覆盖三个不同函数touchesBegantouchesMovedtouchesEnded来完成的。

第一个是touchesBegan,当用户开始绘制某些内容时触发。他们的手指首先触摸屏幕。在这里,我们希望初始化点数组,并以绘制开始的点开始它:

override func touchesBegan(_ touches: Set<UITouch>,
                          with event: UIEvent?) {

     guard let touch = touches.first else { return }
    lastPoint = touch.location(in: mainImageView)
    let t = touch.timestamp
    points = [StrokePoint.init(x: Float(lastPoint.x),
                               y: Float(lastPoint.y),
                               t: Int(t * 1000))]
}

因为记录笔画和笔画点的顺序对模型很重要,我们还需要一个时间戳,这样您就可以看到它在这里被收集并用于初始化StrokePoint

捕获的下一个事件是touchesMoved事件,当用户的手指在抬起之前在表面上移动时发生。在这种情况下,我们希望从触摸位置获取当前点,但这次将其追加到点数组中,而不是创建新的数组。我们还需要像以前一样的时间戳。drawLine函数将从上一个点(即上一个点)绘制到当前点,然后将上一个点设置为当前点。

这是代码:

override func touchesMoved(_ touches: Set<UITouch>,
                          with event: UIEvent?) {
    guard let touch = touches.first else { return }
    let currentPoint = touch.location(in: mainImageView)
    let t = touch.timestamp
    points.append(StrokePoint.init(x: Float(currentPoint.x),
                                   y: Float(currentPoint.y),
                                   t: Int(t * 1000)))
    drawLine(from: lastPoint, to: currentPoint)
    lastPoint = currentPoint
}

当用户在笔画结束时从屏幕上移开手指时,将触发touchesEnded事件。这里没有“新”点要添加,因此您一直跟踪的lastPoint将成为列表中的最后一个点。您可以使用它创建一个新的StrokePoint,并将其添加到此笔画中的点列表中,从而完成笔画。

然后,通过使用收集自此触摸开始以来的点来初始化一个新的Stroke对象,最终笔画将添加到笔画列表中:

override func touchesEnded(_ touches: Set<UITouch>,
                          with event: UIEvent?) {
  guard let touch = touches.first else { return }
  let t = touch.timestamp
  points.append(StrokePoint.init(x: Float(lastPoint.x),
                                 y: Float(lastPoint.y),
                                 t: Int(t * 1000)))
  strokes.append(Stroke.init(points: points))
  drawLine(from: lastPoint, to: lastPoint)
}

我不会在这里展示绘制线条的代码,但您可以在本书的 GitHub 存储库中找到它。

第 5 步:初始化模型

现在,您已经有一个可以在UIImage上捕捉用户绘图并将其表示为一系列笔画的应用程序,您可以将所有这些传递给模型以获得关于它们的推断,这应该是将手写转换为文本的准确表示!

当然,在此之前,您需要一个模型!在这一步中,您将下载并初始化模型,以便稍后将您的笔画转换为 Ink 并从中推断。

首先,您需要检查模型是否可用。您可以通过指定语言并使用DigitalInkRecognitionModelIdentifier来查看它是否可用。以下是代码:

let languageTag = "en-US"
let identifier = DigitalInkRecognitionModelIdentifier(
                           forLanguageTag: languageTag)

如果此标识符为 nil,则表示存在问题,您需要检查您的设置或互联网连接。还要确保它是受支持的语言;支持的语言列表可以在ML Kit documentation.中找到。

一旦您有一个工作模型,您可以下载它。您可以通过使用标识符初始化DigitalInkRecognitionModel对象,然后使用模型管理器下载它来实现这一点。要设置模型管理器,您需要初始化一个conditions对象,该对象控制模型的下载属性。因此,在这个例子中,我设置了一个允许使用蜂窝数据访问(而不仅仅是 WiFi)以及允许后台下载模型的条件:

let model = DigitalInkRecognitionModel.init(
               modelIdentifier: identifier!)
var modelManager = ModelManager.modelManager()

modelManager.download(model,
      conditions: ModelDownloadConditions.init(
             allowsCellularAccess: true,
             allowsBackgroundDownloading: true))

一旦模型下载完成,您就可以从中创建一个识别器对象。按照熟悉的模式,您定义一个选项对象(DigitalInkRecognizerOptions),在这种情况下,您使用刚刚下载的模型进行初始化。有了这个选项对象后,您就可以使用DigitalInkRecognizer从中实例化一个识别器。

这里是代码:

let options: DigitalInkRecognizerOptions =
                    DigitalInkRecognizerOptions.init(
                                        model: model)

recognizer = DigitalInkRecognizer.digitalInkRecognizer(
                                      options: options)

如果您已经完成到这一步,现在应该已经有一个可用的识别器了。我在这里有点走捷径,只是期望模型下载能够正常工作并在我实例化DigitalInkRecognizerOptions之前完成。当然,这可能会失败(例如,网络条件差),因此这不是最佳模式。最好是进行某种异步回调,仅在成功下载模型后初始化识别器,但出于本教程的目的,我希望保持简单。

步骤 6:进行墨水识别

现在您有了识别器,只需将笔画转换为墨水,将其传递给识别器,并解析您收到的结果。让我们来看看如何编写这段代码。

首先,这是如何将笔画转换为墨水:

let ink = Ink.init(strokes: strokes)

接下来,您可以使用识别器及其识别方法,传递墨水并捕获完成回调:

recognizer.recognize(
  ink: ink,
  completion: {
}

完成回调将包含结果和错误,请务必设置它们以开始您的完成回调。结果将是一个 DigitalInkRecognitionResult

(result: DigitalInkRecognitionResult?, error: Error?) in

有效的结果会有许多候选项,其中包含多个潜在的匹配项。例如,如果您回顾图 7-3,我的“h”可能会被误认为是“n”,最后的“lo”可能会被误认为是“b”。引擎将按优先顺序返回各种候选项,因此可能会有“hello”、“nello”、“helb”、“nelb”等等。为了简单起见,此代码将仅采用第一个候选项,使用 results.candidates.first

if let result = result, let candidate = result.candidates.first {
    alertTitle = "I recognized this:"
    alertText = candidate.text
} else {
    alertTitle = "I hit an error:"
    alertText = error!.localizedDescription
}

alertTitlealertText 的值是将用于设置警报对话框的字符串。您可以在图 7-3 的右侧看到这一点。要注意的重要属性是 candidate.text,它是当前候选项的文本解释。由于我们只取第一个候选项,这是 ML Kit 确定为最有可能匹配的选项。

完成后,您只需显示警报框,清除图像,并重置笔画和点,以便再次尝试:

let alert = UIAlertController(title: alertTitle,
                message: alertText,
                preferredStyle: UIAlertController.Style.alert)

alert.addAction(
  UIAlertAction(
    title: "OK", style: UIAlertAction.Style.default, handler: nil))
self.present(alert, animated: true, completion: nil)
self.mainImageView.image = nil
self.strokes = []
self.points = []

这就是全部!试一试,做些实验!我很想看看它如何与其他语言配合工作!

智能回复对话

另一个有趣的例子是您可以在应用程序中使用的即插即用模型——智能回复模型。您可能已经使用过类似 LinkedIn 的网站,在与某人聊天时,会提供建议的回复。或者,如果您是 Android 用户,许多消息应用程序包括智能回复,正如您在图 7-5 中所看到的那样,我被邀请参加早餐,智能回复给出了一些建议的回复。它还进行了实体提取,获取了日期“明天上午 9:30”并将其转换为链接以创建日历条目!

除此之外,还有“‘当然’、“‘几点’和‘是’”的智能回复选项。这些选项是根据语境(这是一个问题)以及我在过去聊天中使用的俗语生成的。我在被邀请时经常说“当然”!

构建一个应用程序,使用 ML Kit 智能回复 API 来获得类似功能非常简单。让我们探讨如何做到这一点。

图 7-5. Android 即时消息中的智能回复

步骤 1:创建应用程序并集成 ML Kit

与以前一样,使用 Xcode 创建一个简单的单视图应用程序。完成后,将以下 Podfile 放置在与您的 *.xcproject 相同的目录中:

platform :ios, '10.0'

target 'MLKitSmartReplyExample' do
        pod 'GoogleMLKit/SmartReply'
end

在这种情况下,我的项目名为MLKitSmartReplyExample,所以请确保使用你的项目名作为target,而不是我的。 运行**pod install**,完成后打开.xcworkspace继续操作。

步骤 2:创建故事板、出口和操作

为了保持这个应用程序简单,创建一个包含两个标签和一个按钮的故事板。 最上面的标签将包含我和朋友之间的模拟对话。 当用户按下按钮时,智能回复模型将用于生成一个可能的回复。 回复将呈现在第二个标签中。 因此,从故事板的角度来看,您的 UI 应该像图 7-6 这样。

完成后,请为上方和下方的标签创建名为conversationLabeltxtSuggestions的出口。 对于按钮,请创建名为generateReply的操作,并在其中调用函数getSmartReply()。 如果 Xcode 抱怨,不要担心——你很快就会写这个函数。

图 7-6. 创建智能回复视图

完成后,您的代码应如下所示:

@IBOutlet weak var conversationLabel: UILabel!
@IBOutlet weak var txtSuggestions: UILabel!
@IBAction func generateReply(_ sender: Any) {
    getSmartReply()
}

步骤 3:创建模拟对话

看到模型运行的最快方法是进行一个我们可以传递给它的对话,所以让我们在这里创建一个简单的对话。 我创建了一个initializeConversation()函数,它创建了对话项目并将它们添加到TextMessage类型的数组中。

因此,在类级别上,您应初始化数组:

var conversation: [TextMessage] = []

然后,initializeConversation将开始填充数组。 TextMessage类型包含有关消息的详细信息,包括其内容、时间戳、发送者及其是否为本地用户(即您)或远程用户(即其他人)。 因此,为了创建对话,我编写了一个辅助函数,根据消息发送者(是我还是我的朋友)重载addConversationItem。 以下是完整函数:

private func initializeConversation(){
    let friendName = "Nizhoni"
    addConversationItem(item: "Hi, good morning!")
    addConversationItem(item: "Oh, hey -- how are you?",
                        fromUser: friendName)
    addConversationItem(item: "Just got up, thinking of
                               heading out for breakfast")
    addConversationItem(item: "Want to meet up?",
                        fromUser: friendName)
    addConversationItem(item: "Sure, what do you fancy?")
    addConversationItem(item: "Just coffee, or do you want to
                              eat?",
                        fromUser: friendName)
    conversationLabel.text = outputText
}

注意,一些对addConversation的调用有一个fromUser:参数,而另一些则没有。 那些没有的被模拟成来自我,而那些有的则被模拟成来自远程用户。 因此,这里实现了这些的addConversation重载。

首先,我们添加了一条来自我的对话项。 请注意,TextMessage创建时userID"Me",而不是传递给函数的内容,并且isLocalUser属性设置为true

private func addConversationItem(item: String){
    outputText += "Me : \(item)\n"
    let message = TextMessage(text: item,
                      timestamp:Date().timeIntervalSince1970,
                      userID: "Me",
                      isLocalUser: true)

    conversation.append(message)
}

这是设置fromUser:属性时的重载。 在这种情况下,请注意TextMessage是使用从该属性传入的userID创建的,并且isLocalUser设置为false

private func addConversationItem(item: String,
                                 fromUser: String){
    outputText += "\(fromUser) : \(item)\n"
    let message = TextMessage(text: item,
                      timestamp:Date().timeIntervalSince1970,
                      userID: fromUser,
                      isLocalUser: false)

    conversation.append(message)
}

在这两种情况下,conversationLabel将更新消息和用户,对话将更新消息。 您可以在 Figure 7-7 中看到这是什么样子。

图 7-7. 模拟对话

步骤 4:获取智能回复

现在您已经有了一个对话,并且它被存储为一个 TextMessage 类型的数组,您只需调用 SmartReply.smartReply() 并对该对话使用 suggestReplies 方法,以获取一组智能回复。在前面,在按钮动作中,您编写了调用 getSmartReply() 的代码。您现在可以创建该函数,并让它调用智能回复模型:

private func getSmartReply(){
    SmartReply.smartReply().suggestReplies(for: conversation)
    { result, error in
       guard error == nil, let result = result else { return }

这将为您的对话获取建议的回复,如果没有错误,它们将在结果变量中。 result 将是一个建议类型的列表,这些列表包含一个 suggestion.text 属性,其中包含建议的内容。因此,如果您想创建所有建议回复的文本列表,您只需使用以下代码:

var strSuggestedReplies = "Suggested Replies:"
if (result.status == .notSupportedLanguage) {
    // The conversation's language isn't supported, so
    // the result doesn't contain any suggestions.
    // You should output something helpful in this case
} else if (result.status == .success) {
    // Successfully suggested smart replies.
    for suggestion in result.suggestions {
        strSuggestedReplies = strSuggestedReplies +
                                  suggestion.text + "\n"
    }
}
self.txtSuggestions.text = strSuggestedReplies

在这里,如果结果状态是成功的,您可以看到我们循环遍历 result.suggestions,构建建议的回复列表。当您运行应用程序时,您将看到一个建议回复的列表。这在 Figure 7-8 中显示。

图 7-8. 查看建议的回复

在一个真实的应用程序中,您可以将它们制作成一个可选列表,当用户选择其中一个时,它将填充回复框与建议的文本,就像 Android 短信应用程序中显示的 Figure 7-5 一样!

这只是 Smart Reply 可能性的一个简单示例,希望这能展示出将其轻松整合到您的应用程序中有多么简单!

概要

本章介绍了多种情景,您可以在 ML Kit 中使用即插即用模型,在 iOS 应用程序中获得 ML 功能。您从实体检测开始,可以快速简便地解析字符串中的常见实体,如电子邮件或时间/日期。然后,您看了看如何使用数字墨水捕捉用户在屏幕上的笔划,并将其解析为文本——有效地识别手写!最后,您深入了解了 Smart Reply API,帮助您加快建议回复的速度。所有这些模型都在后端使用 TensorFlow Lite 运行(如果您眼尖的话,可能已经在调试器中看到了这方面的提及!),因此在 Chapter 8 中,我们将转换视角,全面了解这项技术如何在移动设备上实现 ML 功能。

第八章:深入了解 TensorFlow Lite

在这本书中你所见过的所有机器学习技术的基础是 TensorFlow。这是一个允许你设计、训练和测试机器学习模型的框架;我们在第一章和第二章中对此进行了介绍。

TensorFlow 模型通常设计为在移动场景下使用,需要考虑大小、电池消耗以及其他可能影响移动用户体验的因素。为此,TensorFlow Lite 有两个主要目标。第一个是能够转换现有的 TensorFlow 模型为更小更紧凑的格式,并优化其在移动设备上的表现。第二个目标是为各种移动平台提供高效的运行时,用于模型推断。在本章中,我们将探讨 TensorFlow Lite,并深入了解可用于转换使用 TensorFlow 训练的模型以及如何使用工具来优化它们的工具。

我们将从简要介绍为什么重要开始,然后我们可以动手去了解其细节和字节...

什么是 TensorFlow Lite?

创建 TensorFlow Lite 的原因是由多个因素驱动的。首先是个人设备数量的激增。运行 iOS 或 Android 的移动设备已经超过了传统的台式机或笔记本电脑,成为主要的计算设备,而嵌入式系统数量更是超过了移动设备。随之而来的是这些设备需要运行机器学习模型的需求也在增长。

但让我们在这里专注于智能手机,以及智能手机的用户体验。如果机器学习模型在服务器端,而没有移动运行时,那么用户必须以某种形式封装其功能,以便移动设备调用。例如,对于图像分类器,移动设备必须将图片发送到服务器,服务器进行推断,然后返回结果。这里显然存在一个延迟问题,除了连接性至关重要外。并非每个地方都有足够快速和便捷上传数兆字节数据的连接。

当然,还有隐私。许多场景涉及使用非常个人化的数据,比如前面提到的照片,要求它们上传到服务器以便功能正常运行可能会侵犯用户的隐私权,因此他们可能会拒绝使用您的应用。

拥有一个框架,使模型能够在设备上运行,这样数据就不会延迟传输到第三方,也不会依赖连接性,同时保护用户的隐私,对于机器学习在移动设备上成为可行方案至关重要。

进入 TensorFlow Lite。如介绍中所述,它专为您将 TensorFlow 模型转换为移动设备的紧凑格式以及在该移动平台上进行推理的运行时而设计。

特别令人兴奋的是,这种方法可以为全新的产品和场景带来全新的可能性。想象一下当新平台出现时会发生什么,以及随之而来的创新。例如,当智能手机首次登场时——一种装载了 GPS 或相机等传感器并连接到互联网的设备。想象一下在使用纸质地图时在一个主要使用不同语言的新地点四处寻找方向是多么困难!现在,您可以使用您的设备精确定位您的位置,告诉它您的目的地,它可以智能地为您规划到达那里的最快路径,并提供逐步指引——即使您在步行,使用增强现实界面来显示您要遵循的路线。虽然如果您能够以某种方式将笔记本电脑连接到互联网的话,您可能也能做到这一点,但实际上是不可行的。随着移动设备上 ML 模型的出现,一个全新的平台正在等待着有趣的场景实施——这些可能是可以不使用 ML 实现的事情,但在不使用模型时很可能过于困难。

例如,请考虑图 8-1,摄像头屏幕上有些我看不懂的中文文字。我在一家餐馆里,有些食物过敏。通过使用设备上的 ML 模型进行翻译,并且使用另一个模型来对摄像头视野中的文字进行识别,现在我可以实时、视觉地翻译我面前的内容了。

图 8-1. 摄像头屏幕上的实时翻译

想象一下如果没有机器学习,要做类似这样的事情是多么困难。如何编写代码来进行任何语言中任何字符的光学字符识别。然后,一旦你完成了这个,如何在不经过翻译服务器的情况下翻译它们。这简直是不可行的。但是机器学习,特别是设备上的机器学习,正在使这成为可能。

TensorFlow Lite 入门指南

要理解 TensorFlow Lite,我认为最简单的方法是立即动手并开始使用它。让我们首先探索将 TensorFlow 模型转换为 TensorFlow Lite 格式的转换器。然后,我们将使用它创建的模型在一个简单的 Android 应用程序中实施它。

在第一章中,我们进行了一些称为机器学习的“Hello World”代码,使用非常简单的线性回归来建立一个模型,该模型可以预测两个数字 x 和 y 之间的关系,即 y = 2x − 1. 简而言之,以下是在 TensorFlow 中训练模型的 Python 代码:

import tensorflow as tf
import numpy as np
from tensorflow.keras import Sequential
from tensorflow.keras.layers import Dense

layer_0 = Dense(units=1, input_shape=[1])
model = Sequential([layer_0])
model.compile(optimizer='sgd', loss='mean_squared_error')

xs = np.array([-1.0, 0.0, 1.0, 2.0, 3.0, 4.0], dtype=float)
ys = np.array([-3.0, -1.0, 1.0, 3.0, 5.0, 7.0], dtype=float)

model.fit(xs, ys, epochs=500)

print(model.predict([10.0]))
print("Here is what I learned: {}".format(layer_0.get_weights()))

在训练了 500 个时期之后,它在打印语句上产生了以下输出:

[[18.984955]]
Here is what I learned: [array([[1.9978193]], dtype=float32),
                         array([-0.99323905], dtype=float32)]

因此,预测如果 x 为 10,则 y 为 18.984955,这非常接近于我们使用 y = 2x − 1 公式预期的 19。这是因为神经网络中的单个神经元学习了权重为 1.9978193 和偏差为−0.99323905。因此,基于非常少量的数据,它推断出了 y = 1.9978193x − 0.99323905 的关系,这非常接近我们期望的 y = 2x − 1。

那么,现在我们能在 Android 上使其工作,而不是在云端或开发者工作站上运行吗?答案当然是肯定的。第一步是保存我们的模型。

保存模型

TensorFlow 使用多种不同的方法来保存模型,但在 TensorFlow 生态系统中最标准的是 SavedModel 格式。这将以.pb(protobuf)文件格式保存模型,作为冻结模型的表示,附带包含任何模型资产或变量的相关目录。这种方式的明显优势在于将架构与状态分离,因此如果需要,可以随时添加其他状态,或者更新模型而无需重新发送任何模型资产,这些资产本身可能相当庞大。

要使用这种格式保存,只需指定输出目录,然后像这样调用tf.saved_model.save()

export_dir = 'saved_model/1'
tf.saved_model.save(model, export_dir)

您可以看到保存的目录结构在图 8-2 中。

图 8-2。保存模型后的目录结构

由于这是一个非常简单的模型,variables文件只有一个分片。更大的模型将被分割为多个分片,因此命名为variables.data-00000-of-00001。此模型不使用任何资产,因此该文件夹将为空。

转换模型

将模型转换为 TFLite 格式只需创建保存模型的转换器实例,并调用其转换方法即可。然后,通过将其字节写入文件流来保存模型。

以下是代码:

converter = tf.lite.TFLiteConverter.from_saved_model(export_dir)
tflite_model = converter.convert()

import pathlib
tflite_model_file = pathlib.Path('model.tflite')
tflite_model_file.write_bytes(tflite_model)

写入字节的过程将返回一个数字,这是写入的字节数。不同版本的转换器可能会更改这个数字,但在撰写本文时,写出了一个名为model.tflite的文件,大小为 896 字节。这包括整个训练过的模型,包括架构和学习的权重。

注意

尽管使用如前所示的 Python API 是执行模型转换的推荐方法,但 TensorFlow 团队还提供了命令行解释器,如果您愿意,也可以使用该解释器。您可以在https://www.tensorflow.org/lite/convert了解更多信息。

使用独立解释器测试模型

在尝试在 iOS 或 Android 上使用该模型之前,您应该先使用 TensorFlow Lite 的独立解释器来检查它是否运行良好。这个解释器在 Python 环境中运行,因此也可以在可以运行 Python 的嵌入式系统上使用,比如基于 Linux 的树莓派!

下一步是将模型加载到解释器中,分配张量以用于将数据输入模型进行预测,并读取模型输出的预测结果。这是在使用 TensorFlow Lite 时,从程序员的角度来看,与使用 TensorFlow 有很大不同的地方。在 TensorFlow 中,您可以直接使用 model.predict(something) 并获取结果,但是由于 TensorFlow Lite 在非 Python 环境中不会有许多 TensorFlow 的依赖项,因此现在您需要更低级别地处理输入和输出张量,格式化您的数据以适应它们,并以对您的设备有意义的方式解析输出。

首先,加载模型并分配张量:

interpreter = tf.lite.Interpreter(model_content=tflite_model)
interpreter.allocate_tensors()

然后,您可以从模型获取输入和输出详细信息,从而开始理解它期望的数据格式以及它将向您提供的数据格式:

input_details = interpreter.get_input_details()
output_details = interpreter.get_output_details()
print(input_details)

您将会得到大量的输出!

首先,让我们检查输入参数。注意形状设置,它是一个类型为 [1,1] 的数组。还请注意类别,它是 numpy.float32。这些设置将决定输入数据的形状及其格式:

[{'name': 'serving_default_dense_input:0', 'index': 0,
  'shape': array([1, 1], dtype=int32),
  'shape_signature': array([1, 1], dtype=int32),
  'dtype': <class 'numpy.float32'>,
  'quantization': (0.0, 0),
  'quantization_parameters': {'scales': array([],
                              dtype=float32),
                              'zero_points': array([], dtype=int32),
                              'quantized_dimension': 0},
  'sparsity_parameters': {}}]

因此,为了格式化输入数据,您需要使用以下代码来定义输入数组的形状和类型,如果您想预测 x = 10.0 时的 y:

to_predict = np.array([[10.0]], dtype=np.float32)
print(to_predict)

围绕 10.0 的双括号可能会引起一些混淆——我在这里使用的记忆方法是说,这里有一个列表,给我们了第一个 [] 的设置,并且该列表只包含一个值,即 [10.0],因此得到 [[10.0]]。也可能令人困惑的是,形状被定义为 dtype=int32,而您使用的是 numpy.float32dtype 参数是定义形状的数据类型,而不是封装在该形状中的列表内容。对于后者,您将使用类别。

您还可以使用 print(output_details) 打印输出详细信息。

这些非常相似,您需要关注的是形状。因为它也是一个类型为 [1,1] 的数组,您可以期望答案会以类似于输入为 [[x]] 的方式为 [[y]]

[{'name': 'StatefulPartitionedCall:0',
  'index': 3,
  'shape': array([1, 1], dtype=int32),
  'shape_signature': array([1, 1], dtype=int32),
  'dtype': <class 'numpy.float32'>,
  'quantization': (0.0, 0),
  'quantization_parameters': {'scales': array([], dtype=float32),
                              'zero_points': array([], dtype=int32),
                              'quantized_dimension': 0},
  'sparsity_parameters': {}}]

要让解释器进行预测,您需要将输入张量设置为要预测的值,并告知它要使用的输入值:

interpreter.set_tensor(input_details[0]['index'], to_predict)
interpreter.invoke()

输入张量是使用输入详细信息数组的索引来指定的。在这种情况下,您有一个非常简单的模型,只有一个输入选项,因此它是 input_details[0],您将在索引处引用它。输入详细信息项 0 只有一个索引,索引为 0,并且它期望的形状如前所述为 [1,1]。因此,您将 to_predict 值放在其中。然后,使用 invoke 方法调用解释器。

您可以通过调用 get_tensor 并提供您想要读取的张量的详细信息来读取预测值:

tflite_results = interpreter.get_tensor(output_details[0]['index'])
print(tflite_results)

再次,只有一个输出张量,因此它将是 output_details[0],您指定索引以获取其下的详细信息,其中将包含输出值。

所以,举例来说,假设你运行以下代码:

to_predict = np.array([[10.0]], dtype=np.float32)
print(to_predict)
interpreter.set_tensor(input_details[0]['index'], to_predict)
interpreter.invoke()
tflite_results = interpreter.get_tensor(output_details[0]['index'])
print(tflite_results)

您应该看到如下输出:

[[10.]]
[[18.975412]]

其中 10 是输入值,18.97 是预测值,非常接近于 19,即当 x=10 时 2x-1。为什么不是 19 的原因,请回顾第一章!

请注意,您可能已经在第一章中看到了稍微不同的结果(例如 18.984),这些结果由于两个主要原因而会发生。首先,神经元从不同的随机初始化状态开始,因此它们的最终值会略有不同。此外,在将模型压缩为 TFLite 模型时,会进行影响最终结果的优化。以后创建更复杂模型时,请记住这一点——重要的是要注意移动转换可能对准确性的影响。

现在,我们已经使用独立解释器测试了模型,并且它看起来表现如预期一样。下一步,让我们构建一个简单的 Android 应用程序,看看在那里使用模型是什么样子!

创建一个 Android 应用程序来托管 TFLite

使用 Android Studio 使用单个活动模板创建一个 Android 应用程序。如果您对此不熟悉,请阅读第三章中的所有步骤。在那里查找它们!

编辑您的build.gradle文件以包含 TensorFlow Lite 运行时:

implementation 'org.tensorflow:tensorflow-lite:2.4.0'

在这里我使用了版本 2.4.0。要获取最新版本,您可以查看Bintray 网站上提供的当前版本。

您还需要在android{}部分中添加一个新的设置,如下所示:

android{
...
    aaptOptions {
        noCompress "tflite"
    }
...
}
注意

此步骤可防止编译器压缩您的.tflite文件。 Android Studio 会编译资源以减小其大小,从而减少从 Google Play 商店下载的时间。但是,如果压缩.tflite文件,则 TensorFlow Lite 解释器将无法识别它。为确保它不会被压缩,您需要为.tflite文件设置aaptOptionsnoCompress。如果您使用了不同的扩展名(有些人只使用.lite),请确保在此处配置。

您现在可以尝试构建您的项目。 TensorFlow Lite 库将被下载和链接。

接下来,更新您的活动文件(您可以在布局目录中找到它)以创建一个简单的用户界面。这将包含一个编辑文本框,您可以在其中输入一个值,并且一个按钮,用于触发推断:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 
    android:orientation="vertical"

    android:layout_height="match_parent"
    android:layout_width="match_parent">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <TextView
            android:id="@+id/lblEnter"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="Enter X:  "
            android:textSize="18sp"></TextView>

        <EditText
            android:id="@+id/txtValue"
            android:layout_width="180dp"
            android:layout_height="wrap_content"
            android:inputType="number"
            android:text="1"></EditText>

        <Button
            android:id="@+id/convertButton"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="Convert">

        </Button>
    </LinearLayout>
</LinearLayout>

在开始编码之前,您需要将 TFLite 文件导入到您的应用程序中。接下来您可以看到如何做到这一点。

导入 TFLite 文件

首先要做的是在您的项目中创建一个assets文件夹。为此,请在项目资源管理器中导航至app/src/main文件夹,在main文件夹上右键单击,然后选择新建文件夹。将其命名为assets。将您在训练模型后下载的.tflite文件拖放到该目录中。如果您之前没有创建此文件,您可以在书籍的 GitHub 代码库中找到它。

如果你收到关于文件不在正确目录中的警告,即模型绑定被禁用,请安全地忽略它。模型绑定是我们稍后将探索的内容,它适用于许多固定场景:它允许你轻松地导入 .tflite 模型,而不需要本示例中所示的许多手动步骤。在这里,我们将深入到如何在 Android Studio 中使用 TFLite 文件的基本操作中。

将资产添加到 Android Studio 后,你的项目资源管理器应该看起来像图 8-3 一样。

图 8-3. 将 TFLite 文件添加为资产文件

现在一切准备就绪,我们可以开始编码了!

编写 Kotlin 代码与模型接口

尽管你正在使用 Kotlin,但你的源文件位于 java 目录下!打开它,你会看到一个与你的包名相对应的文件夹。在这个文件夹中,你会看到 MainActivity.kt 文件。双击此文件以在代码编辑器中打开它。

首先,你需要一个帮助函数,从 assets 目录加载 TensorFlow Lite 模型:

private fun loadModelFile(assetManager: AssetManager,
                          modelPath: String): ByteBuffer {
    val fileDescriptor = assetManager.openFd(modelPath)
    val inputStream = FileInputStream(fileDescriptor.fileDescriptor)
    val fileChannel = inputStream.channel
    val startOffset = fileDescriptor.startOffset
    val declaredLength = fileDescriptor.declaredLength
    return fileChannel.map(FileChannel.MapMode.READ_ONLY,
startOffset, declaredLength)
}

由于 .tflite 文件实际上是解释器将用于构建内部神经网络模型的权重和偏置的二进制 blob,在 Android 术语中它是一个 ByteBuffer。此代码将从 modelPath 加载文件,并将其作为 ByteBuffer 返回。(请注意,早些时候你确保在编译时不压缩此文件类型,以便 Android 能够识别文件的内容。)

接着,在你的活动中,在类级别(即在类声明下方,不在任何类函数内部),你可以添加模型和解释器的声明:

private lateinit var tflite : Interpreter
private lateinit var tflitemodel : ByteBuffer

因此,在这种情况下,执行所有工作的解释器对象将被称为 tflite,而加载到解释器中作为 ByteBuffer 的模型将被称为 tflitemodel

接下来,在 onCreate 方法中,当活动创建时调用,添加一些代码来实例化解释器,并加载 model.tflite

try{
    tflitemodel = loadModelFile(this.assets, "model.tflite")
    tflite = Interpreter(tflitemodel)
} catch(ex: Exception){
    ex.printStackTrace()
}

同样,在 onCreate 中,还要添加两个你将与之交互的控件的代码——EditText 用于输入数值,和 Button 用于执行推断:

var convertButton: Button = findViewById<Button>(R.id.convertButton)
convertButton.setOnClickListener{
    doInference()
}
txtValue = findViewById<EditText>(R.id.txtValue)

此外,在下一个函数中将需要在类级别声明 EditText,以及 tflitetflitemodel,因为它将在其中使用:

private lateinit var txtValue : EditText

最后,是时候进行推断了。你可以使用一个名为 doInference 的新函数来完成:

private fun doInference(){
}

在这个函数中,你可以收集输入数据,传递给 TensorFlow Lite 进行推断,然后显示返回的值。

注意

在这种情况下,推断非常简单。对于复杂模型,可能会是一个长时间运行的过程,可能会阻塞 UI 线程,这是在构建你自己的应用程序时需要牢记的事项。

EditText 控件用于输入数字,将会提供一个字符串,你需要将其转换为浮点数:

var userVal: Float = txtValue.text.toString().toFloat()

正如您在第一章和第二章中回顾的,当将数据输入模型时,通常需要将其格式化为 NumPy 数组。作为 Python 的构造,NumPy 在 Android 上不可用,但在这种情况下您可以使用FloatArray。即使您只输入一个值,它仍然需要在一个数组中,粗略地近似一个张量:

var inputVal: FloatArray = floatArrayOf(userVal)

模型将向您返回一系列字节,需要解释。如您所知,模型输出一个浮点值,考虑到浮点数占据 4 字节,您可以设置一个 4 字节的ByteBuffer来接收输出。字节可以按多种方式排序,但您只需要默认的本地顺序:

var outputVal: ByteBuffer = ByteBuffer.allocateDirect(4)
outputVal.order(ByteOrder.nativeOrder())

要进行推断,您需要在解释器上调用运行方法,传递输入和输出值。然后它将从输入值读取并写入输出值:

tflite.run(inputVal, outputVal)

输出被写入到ByteBuffer中,其指针现在位于缓冲区的末尾。要读取它,您必须将其重置为缓冲区的开头:

outputVal.rewind()

现在,您可以将ByteBuffer的内容作为一个浮点数读取:

var inference:Float = outputVal.getFloat()

如果您想要向用户显示这个信息,可以使用AlertDialog

val builder = AlertDialog.Builder(this)
with(builder)
{
    setTitle("TFLite Interpreter")
    setMessage("Your Value is:$inference")
    setNeutralButton("OK", DialogInterface.OnClickListener {
            dialog, id -> dialog.cancel()
    })
    show()
}

您现在可以运行应用并尝试自己!您可以在图 8-4 中看到结果,我输入了一个值为 10,模型给出了 18.984955 的推断结果,显示在一个警报框中。请注意,由于前面讨论的原因,您的值可能会有所不同。当训练模型时,神经网络开始时是随机初始化的,因此当它收敛时可能是从不同的起点开始的,因此您的模型可能会有稍微不同的结果。

图 8-4. 运行推断

超越基础知识

这个示例非常简单——您有一个模型,它接受单个输入值并提供单个输出值。这两个值都是浮点数,占据 4 个字节的存储空间,因此您可以仅创建每个字节缓冲区中的 4 个字节,并知道它们包含单个值。因此,在使用更复杂的数据时,您需要努力将数据格式化为模型期望的格式,这将需要您进行大量的工程工作。让我们看一个使用图像的示例。在第九章中,我们将看到模型制造器,这是一个在 Android 或 iOS 上使用 TFLite 进行常见场景——包括像这样的图像分类的复杂性抽象的非常有用的工具,但我认为,探索如何管理模型的数据进出对于您超出常见情景的需求仍然是一种有用的练习!

例如,让我们从像图 8-5 中的图片开始,这是一张狗的简单图片,尺寸是 395 × 500 像素。这张图片用于一个可以区分猫和狗的模型。我不会详细介绍如何创建这个模型,但是这本书的仓库中有一个笔记本可以为你完成这部分工作,还有一个样例应用来处理推断。你可以在Chapter8_Lab2.ipynb中找到训练代码,应用程序名为“cats_vs_dogs”。

图 8-5. 解释狗的图片

首先需要做的事情是将其调整为 224 × 224 像素,即模型训练时使用的图片尺寸。在 Android 中可以使用 Bitmap 库来完成这个操作。例如,你可以创建一个新的 224 × 224 位图:

val scaledBitmap = Bitmap.createScaledBitmap(bitmap, 224, 224, false)

(在这种情况下,位图包含由应用程序加载为资源的原始图片。完整的应用程序可以在本书的 GitHub 仓库中找到。)

现在,图片尺寸已经调整到合适大小,接下来需要调整 Android 中的图片结构,使其与模型期望的结构一致。如果你回忆一下,本书前几章在训练模型时,输入的是归一化后的数值张量作为图片。例如,这样一张图片的尺寸为(224, 224, 3):224 × 224 表示图片尺寸,3 表示颜色深度。这些数值也都归一化到了 0 到 1 之间。

因此,总结一下,你需要 224 × 224 × 3 个介于 0 和 1 之间的浮点值来表示这张图片。为了将其存储在一个ByteArray中,其中 4 个字节表示一个浮点数,你可以使用以下代码:

val byteBuffer = ByteBuffer.allocateDirect(4 * 224 * 224 * 3)
byteBuffer.order(ByteOrder.nativeOrder())

另一方面,我们的 Android 图片中,每个像素都以 32 位整数的 ARGB 值存储。一个特定像素可能看起来像 0x0010FF10。前两个值是透明度,可以忽略,其余的用于 RGB(例如,红色为 0x10,绿色为 0xFF,蓝色为 0x10)。到目前为止,你一直在做的简单归一化是将 R、G、B 通道值除以 255,这将得到红色的.06275,绿色的 1,和蓝色的.06275。

因此,为了进行这种转换,首先将我们的位图转换为一个 224 × 224 整数数组,并将像素复制进去。你可以使用getPixels API 来完成这个操作:

val intValues = IntArray(224 * 224)

scaledbitmap.getPixels(intValues, 0, 224, 0, 0, 224, 224)

你可以在Android 开发者文档中找到有关 getPixels API 的详细信息,解释了这些参数。

现在,您需要遍历此数组,逐个读取像素并将其转换为归一化浮点数。您将使用位移操作获取特定的通道。例如,考虑之前的值 0x0010FF10。如果您将其向右移动 16 位,您将得到 0x0010(FF10 被“丢弃”)。然后,“与”0xFF,您将得到 0x10,仅保留底部两个数字。类似地,如果您向右移动 8 位,您将得到 0x0010FF,并在其上执行“和”操作将给出 0xFF。这种技术(通常称为掩码)允许您快速轻松地剥离构成像素的相关位。您可以对整数使用shr操作来执行此操作,其中input.shr(16)读取“将输入向右移动 16 像素”:

var pixel = 0
for (i in 0 until INPUT_SIZE) {
  for (j in 0 until INPUT_SIZE) {
    val input = intValues[pixel++]
    byteBuffer.putFloat(((input.shr(16)  and 0xFF) / 255))
    byteBuffer.putFloat(((input.shr(8) and 0xFF) / 255))
    byteBuffer.putFloat(((input and 0xFF)) / 255))
  }
}

与以前一样,在处理输出时,您需要定义一个数组来保存结果。它不一定要是ByteArray;实际上,如果您知道结果通常是浮点数,您可以定义像FloatArray这样的东西。在这种情况下,使用猫与狗模型,您有两个标签,并且模型体系结构在输出层中定义了两个神经元,包含类别catdog的相应属性。回读结果时,您可以定义一个结构来包含输出张量,如下所示:

val result = Array(1) { FloatArray(2) }

请注意,它是包含两个项目数组的单个数组。还记得在使用 Python 时,您可能会看到像[[1.0 0.0]]这样的值——在这里也是一样的。Array(1)正在定义包含数组[],而FloatArray(2)[1.0 0.0]。这可能会有点令人困惑,但当您编写更多 TensorFlow 应用程序时,我希望您会习惯的!

与以前一样,您使用interpreter.run进行解释:

interpreter.run(byteBuffer, result)

现在,您的结果将是一个包含两个值的数组(分别是图像是猫或狗的概率)。您可以在 Android 调试器中查看它的外观,见图 8-6——在这里,您可以看到这张图片有 0.01 的概率是猫,0.99 的概率是狗!

图 8-6. 解析输出值

当您使用 Android 创建移动应用程序时,这是最复杂的部分之一——除了创建模型之外,当然——您必须考虑到。特别是在 NumPy 的情况下,Python 如何表示值可能与 Android 的方式非常不同。您将不得不创建转换器来重新格式化数据,以便神经网络期望数据输入,并且您必须理解神经网络使用的输出模式,以便解析结果。

创建一个 iOS 应用程序来托管 TFLite

早些时候我们探索了在 Android 上创建一个用于托管简单 y = 2x − 1 模型的应用程序。接下来让我们看看如何在 iOS 上完成相同的操作。如果您想要跟随这个示例,您需要一台 Mac 电脑,因为开发工具是仅在 Mac 上可用的 Xcode。如果您还没有安装它,您可以从 App Store 安装。它将为您提供一切所需的内容,包括可以在上面运行 iPhone 和 iPod 应用程序的 iOS 模拟器,而无需实际设备。

第 1 步:创建一个基本的 iOS 应用程序

打开 Xcode 并选择文件 → 新建项目。您将被要求选择新项目的模板。选择单视图应用程序,这是最简单的模板(参见图 8-7),然后点击下一步。

图 8-7. 在 Xcode 中创建一个新的 iOS 应用程序

然后,您将被要求选择新项目的选项,包括应用程序的名称。将其命名为firstlite,确保语言为 Swift,用户界面为 Storyboard(参见图 8-8)。

图 8-8. 选择新项目的选项

点击下一步创建一个基本的 iOS 应用程序,该应用程序将在 iPhone 或 iPad 模拟器上运行。接下来的步骤是将 TensorFlow Lite 添加到其中。

第 2 步:向您的项目添加 TensorFlow Lite

要向 iOS 项目添加依赖项,您可以使用名为CocoaPods的技术,这是一个具有数千个库的依赖管理项目,可以轻松集成到您的应用程序中。为此,您需要创建一个称为 Podfile 的规范文件,其中包含关于您的项目和要使用的依赖项的详细信息。这是一个名为Podfile的简单文本文件(没有扩展名),应放置在与 Xcode 为您创建的firstlite.xcodeproj文件相同的目录中。其内容应如下所示:

# Uncomment the next line to define a global platform for your project
platform :ios, '12.0'

target 'firstlite' do
  # Pods for ImageClassification
  pod 'TensorFlowLiteSwift'
end

重要的部分是这一行:pod 'TensorFlowLiteSwift',它表示需要将 TensorFlow Lite Swift 库添加到项目中。

接下来,在 Terminal 中,切换到包含 Podfile 的目录,并输入以下命令:

> pod install

依赖项将被下载并添加到您的项目中,存储在一个名为Pods的新文件夹中。您还将添加一个.xcworkspace文件,如图 8-9 所示。将来打开项目时,请使用这个文件而不是.xcodeproj文件。

图 8-9. 运行 pod install 后的文件结构

您现在拥有一个基本的 iOS 应用程序,并已添加了 TensorFlow Lite 的依赖项。下一步是创建用户界面。

第 3 步:创建用户界面

Xcode 故事板编辑器是一个可视化工具,允许您创建用户界面。打开工作区后,您会在左侧看到源文件列表。选择Main.storyboard,并使用控件面板,将控件拖放到 iPhone 屏幕的视图上(在图 8-10 中可见)。

如果找不到控件面板,可以通过点击屏幕右上角的+号(在图 8-10 中突出显示)来访问它。使用它,添加一个标签,并将文本更改为“输入一个数字”。然后再添加一个标签,文本为“结果显示在此处”。添加一个按钮,将其标题更改为“Go”,最后再添加一个文本字段。排列它们的方式类似于图 8-10 中显示的样子。它不一定要很漂亮!

图 8-10. 向故事板添加控件

现在控件已布局好,您希望能够在代码中引用它们。在故事板术语中,您可以使用出口(当您希望访问控件以读取或设置其内容时)或操作(当您希望在用户与控件交互时执行一些代码时)来实现此目的。

将此连接起来的最简单方法是使用分割屏幕,将故事板放在一侧,底层代码ViewController.swift放在另一侧。您可以通过选择分割屏幕控件(在图 8-11 中突出显示),点击一侧选择故事板,然后点击另一侧选择ViewController.swift来实现此目的。

图 8-11. 分割屏幕

一旦完成这一步,您可以开始通过拖放来创建您的出口和操作。该应用程序将让用户在文本字段中输入一个数字,然后按“Go”按钮,然后对他们输入的值进行推断。结果将呈现在标签中,标签上写着“结果显示在此处”。

这意味着您需要读取或写入两个控件,读取文本字段的内容以获取用户输入的内容,并将结果写入“结果显示在此处”的标签中。因此,您需要两个出口。要创建它们,按住 Ctrl 键并将控件拖放到ViewController.swift文件中,将其放置在类定义的正下方。会弹出一个窗口询问您要定义它(见图 8-12)。

图 8-12. 创建一个出口

确保连接类型为出口,并创建一个称为txtUserData的文本字段的出口,以及一个称为txtResult的标签的出口。

接下来,将按钮拖放到ViewController.swift文件中。在弹出窗口中,确保连接类型为操作,事件类型为“Touch Up Inside”。使用此方法来定义一个名为btnGo的操作(见图 8-13)。

图 8-13. 添加一个操作

到目前为止,您的 ViewController.swift 文件应该是这样的——请注意 IBOutletIBAction 代码:

import UIKit

class ViewController: UIViewController {
    @IBOutlet weak var txtUserData: UITextField!

    @IBOutlet weak var txtResult: UILabel!
    @IBAction func btnGo(_ sender: Any) {
    }
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
    }
}

现在 UI 部分已经搞定,下一步将是创建处理推断的代码。您将其放在与 ViewController 逻辑不同的 Swift 文件中。

步骤 4:添加和初始化模型推断类

为了将 UI 与底层模型推断分离,您将创建一个新的 Swift 文件,其中包含一个 ModelParser 类。这是将数据输入模型、运行推断并解析结果的所有工作都将在这里进行。在 Xcode 中,选择 文件 → 新建文件,选择 Swift 文件作为模板类型(图 8-14)。

图 8-14. 添加一个新的 Swift 文件

调用此 ModelParser 并确保将其指向 firstlite 项目的复选框已选中(图 8-15)。

图 8-15. 将 ModelParser.swift 添加到您的项目

这将在您的项目中添加一个 ModelParser.swift 文件,您可以编辑以添加推断逻辑。首先确保文件顶部的导入包括 TensorFlowLite

import Foundation
import TensorFlowLite

您将把一个指向模型文件 model.tflite 的引用传递给这个类——尽管您还没有添加它,但很快会添加:

typealias FileInfo = (name: String, extension: String)

enum ModelFile {
  static let modelInfo: FileInfo = (name: "model", extension: "tflite")
}

这个 typealiasenum 使得代码更加简洁。稍后您将看到它们的使用。接下来,您需要将模型加载到解释器中,因此首先将解释器声明为类的私有变量:

private var interpreter: Interpreter

Swift 要求变量进行初始化,在 init 函数中可以执行此操作。下面的函数将接受两个输入参数。第一个参数 modelFileInfo 是您刚刚声明的 FileInfo 类型。第二个参数 threadCount 是用于初始化解释器的线程数,我们将其设置为 1。在此函数中,您将创建对之前描述的模型文件的引用(model.tflite):

  init?(modelFileInfo: FileInfo, threadCount: Int = 1) {
      let modelFilename = modelFileInfo.name

      guard let modelPath = Bundle.main.path
      (
          forResource: modelFilename,
          ofType: modelFileInfo.extension
      )
      else {
          print("Failed to load the model file")
          return nil
      }

当您将应用程序和资源编译成一个部署到设备上的包时,在 iOS 术语中使用的术语是“bundle”。因此,您的模型需要在 bundle 中,一旦获得模型文件的路径,您就可以加载它:

      do
      {
          interpreter = try Interpreter(modelPath: modelPath)
      }
      catch let error
      {
          print("Failed to create the interpreter")
          return nil
      }

步骤 5:执行推断

ModelParser 类内部,然后可以进行推断。用户将在文本字段中输入一个字符串值,该值将转换为浮点数,因此您需要一个接受浮点数的函数,将其传递给模型,运行推断并解析返回值。

首先创建一个名为 runModel 的函数。您的代码将需要捕获错误,所以请从 do{ 开始:

  func runModel(withInput input: Float) -> Float? {
    do{

接下来,您需要在解释器上分配张量。这将初始化模型并准备进行推断:

try interpreter.allocateTensors()

然后,您将创建输入张量。由于 Swift 没有Tensor数据类型,您需要将数据直接写入UnsafeMutableBufferPointer中的内存。这些内容在苹果的开发者文档中有详细介绍。

您可以指定其类型为 Float,并写入一个值(因为只有一个 float),从名为 data 的变量地址开始。这将有效地将浮点数的所有字节复制到缓冲区中:

      var data: Float = input
      let buffer: UnsafeMutableBufferPointer<Float> =
               UnsafeMutableBufferPointer(start: &data, count: 1)

有了缓冲区中的数据,您可以将其复制到输入 0 的解释器中。您只有一个输入张量,因此可以将其指定为缓冲区:

      try interpreter.copy(Data(buffer: buffer), toInputAt: 0)

要执行推断,您需要调用解释器:

      try interpreter.invoke()

只有一个输出张量,因此您可以通过获取索引为 0 的输出来读取它:

      let outputTensor = try interpreter.output(at: 0)

与输入值时类似,您处理的是低级别内存,被称为unsafe数据。当使用典型数据类型时,它们在内存中的位置受操作系统严格控制,以防止溢出和覆盖其他数据。但是,在这种情况下,您自己直接将数据写入内存,因此存在违反边界的风险(因此称为unsafe)。

它是由Float32值组成的数组(虽然只有一个元素,但仍需视为数组),可以像这样读取:

      let results: [Float32] =
                          Float32 ?? []

如果您对??语法不熟悉,请注意,此语法用于通过将输出张量复制到其中来使结果成为Float32数组,并在失败时(通常是空指针错误)创建一个空数组。为了使此代码正常工作,您需要实现一个Array扩展;其完整代码稍后将会展示。

一旦您将结果存储在数组中,第一个元素将是您的结果。如果失败,只需返回nil

      guard let result = results.first else {
        return nil
      }
      return result
    }

函数以do{开头,因此您需要捕获任何错误,并在这种情况下打印错误消息并返回nil

    catch {
      print(error)
      return nil
    }
  }
}

最后,在ModelParser.swift中,您可以添加处理不安全数据并将其加载到数组中的Array扩展:

extension Array {
  init?(unsafeData: Data) {
    guard unsafeData.count % MemoryLayout<Element>.stride == 0
          else { return nil }
    #if swift(>=5.0)
    self = unsafeData.withUnsafeBytes {
      .init($0.bindMemory(to: Element.self))
    }
    #else
    self = unsafeData.withUnsafeBytes {
      .init(UnsafeBufferPointer<Element>(
        start: $0,
        count: unsafeData.count / MemoryLayout<Element>.stride
      ))
    }
    #endif  // swift(>=5.0)
  }
}

这是一个便捷的助手,如果您想直接从 TensorFlow Lite 模型中解析浮点数,您可以使用它。现在,解析模型的类已完成,下一步是将模型添加到您的应用程序中。

步骤 6:将模型添加到您的应用程序

要将模型添加到您的应用程序中,您需要在应用程序中创建一个models目录。在 Xcode 中,右键单击firstlite文件夹,然后选择新建组(图 8-16)。将新组命名为models

您可以通过训练本章早些时候的简单 y = 2x – 1 样本来获取该模型。如果您尚未拥有它,可以使用书籍 GitHub 存储库中的 Colab。

一旦您有转换后的模型文件(称为model.tflite),您可以将其拖放到刚刚添加的模型组中的 Xcode 中。选择“复制项目(如有需要)”,并确保通过选中旁边的复选框将其添加到firstlite目标中(图 8-17)。

图 8-16. 向应用程序添加一个新组

图 8-17. 将模型添加到你的项目中

模型现在将会在你的项目中,并且可用于推理。最后一步是完成用户界面逻辑,然后你就可以开始了!

第 7 步:添加 UI 逻辑

之前,你创建了包含 UI 描述的 storyboard,并开始编辑包含 UI 逻辑的 ViewController.swift 文件。由于推理工作的大部分现在已经被转移到 ModelParser 类中,UI 逻辑应该非常轻量级。

首先添加一个私有变量,声明 ModelParser 类的一个实例:

private var modelParser: ModelParser? =
    ModelParser(modelFileInfo: ModelFile.modelInfo)

之前,你在按钮上创建了一个名为 btnGo 的操作。当用户触摸按钮时,将调用它。更新为在用户执行该操作时执行名为 doInference 的函数:

@IBAction func btnGo(_ sender: Any) {
  doInference()
}

接下来,你将构建 doInference 函数:

private func doInference() {

用户将输入数据的文本字段称为 txtUserData. 读取这个值,如果它为空,只需将结果设置为 0.00,并且不需要进行任何推理:

  guard let text = txtUserData.text, text.count > 0 else {
    txtResult.text = "0.00"
    return
  }

否则,将其转换为浮点数。如果失败,则退出函数:

  guard let value = Float(text) else {
    return
  }

如果代码已经执行到这一点,你现在可以运行模型,将输入传递给它。ModelParser 将处理剩余的部分,返回一个结果或 nil。如果返回值是 nil,那么你将退出该函数:

  guard let result = self.modelParser?.runModel(withInput: value) else {
    return
  }

最后,如果你已经到达这一点,你有一个结果,所以你可以将它加载到标签中(称为 txtResult),通过将浮点数格式化为字符串:

  txtResult.text = String(format: "%.2f", result)

就是这样!模型加载和推理的复杂性已由 ModelParser 类处理,使你的 ViewController 非常轻量级。为了方便起见,这里是完整的列表:

import UIKit

class ViewController: UIViewController {
  private var modelParser: ModelParser? =
      ModelParser(modelFileInfo: ModelFile.modelInfo)
  @IBOutlet weak var txtUserData: UITextField!

  @IBOutlet weak var txtResult: UILabel!
  @IBAction func btnGo(_ sender: Any) {
    doInference()
  }
  override func viewDidLoad() {
    super.viewDidLoad()
    // Do any additional setup after loading the view.
  }
  private func doInference() {

    guard let text = txtUserData.text, text.count > 0 else {
      txtResult.text = "0.00"
      return
    }
    guard let value = Float(text) else {
      return
    }
    guard let result = self.modelParser?.runModel(withInput: value) else {
      return
    }
    txtResult.text = String(format: "%.2f", result)
  }

}

现在,你已经完成了使应用程序运行所需的一切。运行它,你应该可以在模拟器中看到它。在文本字段中输入一个数字,按按钮,你应该在结果字段中看到一个结果,如图 8-18 所示。

尽管这只是一个非常简单的应用程序的漫长旅程,但它应该为你提供一个很好的模板,帮助你理解 TensorFlow Lite 的工作原理。在这个步骤中,你看到了如何:

  • 使用 pods 添加 TensorFlow Lite 依赖项

  • 将 TensorFlow Lite 模型添加到你的应用程序中

  • 将模型加载到解释器中

  • 访问输入张量并直接写入它们的内存

  • 从输出张量中读取内存,并将其复制到像浮点数组这样的高级数据结构中

  • 使用 storyboard 和 view controller 将所有内容连接起来。

图 8-18. 在 iPhone 模拟器中运行应用程序

在下一节中,你将超越这个简单的场景,看看如何处理更复杂的数据。

超越“Hello World”:处理图像

在前面的示例中,您看到了如何创建一个使用 TensorFlow Lite 进行非常简单推断的完整应用程序。然而,尽管应用程序简单,但是将数据传入模型和解析模型输出的过程可能有些不直观,因为您正在处理低级的位和字节。随着您涉及更复杂的场景,例如管理图像,好消息是该过程并不那么复杂。

考虑创建一个模型来区分猫和狗。在本节中,您将看到如何使用经过训练的模型创建一个 iOS 应用程序(使用 Swift),该应用程序可以根据猫或狗的图像推断出图片内容。完整的应用程序代码可以在本书的 GitHub 存储库中找到,以及一个 Colab 笔记本来训练和将模型转换为 TFLite 格式。

首先,回顾一下图像的张量具有三个维度:宽度、高度和颜色深度。例如,使用基于 MobileNet 架构的猫狗移动样本时,尺寸为 224 × 224 × 3——每个图像为 224 × 224 像素,并具有三个颜色深度通道。请注意,每个像素在归一化后,每个通道的值都介于 0 和 1 之间,表示该像素在红色、绿色和蓝色通道上的强度。

在 iOS 中,图像通常表示为 UIImage 类的实例,该类具有一个有用的 pixelBuffer 属性,返回图像中所有像素的缓冲区。

CoreImage 库中,有一个 CVPixelBufferGetPixelFormatType API 可以返回像素缓冲区的类型:

let sourcePixelFormat = CVPixelBufferGetPixelFormatType(pixelBuffer)

这通常是一个 32 位图像,具有 alpha(即透明度)、红色、绿色和蓝色通道。然而,有多种变体,通常这些通道的顺序不同。您需要确保它是这些格式之一,因为如果图像存储在不同的格式中,其余的代码将无法正常工作:

assert(sourcePixelFormat == kCVPixelFormatType_32ARGB ||
  sourcePixelFormat == kCVPixelFormatType_32BGRA ||
  sourcePixelFormat == kCVPixelFormatType_32RGBA)

由于所需格式是 224 × 224,这是一个方形,接下来最好的做法是使用 centerThumbnail 属性将图像裁剪为其中心的最大方形,然后将其缩放到 224 × 224:

let scaledSize = CGSize(width: 224, height: 224)
guard let thumbnailPixelBuffer =
      pixelBuffer.centerThumbnail(ofSize: scaledSize)
      else {
          return nil
      }

现在您已将图像调整为 224 × 224,接下来的步骤是移除 alpha 通道。请记住,模型是在 224 × 224 × 3 上进行训练的,其中 3 表示 RGB 通道,因此没有 alpha 通道。

现在您有了像素缓冲区,需要从中提取 RGB 数据。这个辅助函数可以帮助您通过找到 alpha 通道并将其切片来实现这一目标:

private func rgbDataFromBuffer(_ buffer: CVPixelBuffer,

                                byteCount: Int) -> Data? {

    CVPixelBufferLockBaseAddress(buffer, .readOnly)
    defer { CVPixelBufferUnlockBaseAddress(buffer, .readOnly) }
    guard let mutableRawPointer =
          CVPixelBufferGetBaseAddress(buffer)
          else {
              return nil
          }

    let count = CVPixelBufferGetDataSize(buffer)
    let bufferData = Data(bytesNoCopy: mutableRawPointer,
                          count: count, deallocator: .none)

    var rgbBytes = Float
    var index = 0

    for component in bufferData.enumerated() {
        let offset = component.offset
        let isAlphaComponent =
              (offset % alphaComponent.baseOffset) ==
        alphaComponent.moduloRemainder

        guard !isAlphaComponent else { continue }

        rgbBytes[index] = Float(component.element) / 255.0
        index += 1
    }

    return rgbBytes.withUnsafeBufferPointer(Data.init)

}

这段代码使用了一个名为 Data 的扩展,它将原始字节复制到数组中:

extension Data {
  init<T>(copyingBufferOf array: [T]) {
    self = array.withUnsafeBufferPointer(Data.init)
  }
}

现在您可以将刚创建的缩略图像素缓冲区传递给 rgbDataFromBuffer

guard let rgbData = rgbDataFromBuffer(
      thumbnailPixelBuffer,
      byteCount: 224 * 224 * 3
      ) else {
          print("Failed to convert the image buffer to RGB data.")
          return nil
      }

现在您有了模型期望的原始 RGB 数据格式,可以直接将其复制到输入张量中:

try interpreter.allocateTensors()
try interpreter.copy(rgbData, toInputAt: 0)

您随后可以调用解释器并读取输出张量:

try interpreter.invoke()
outputTensor = try interpreter.output(at: 0)

在狗与猫的情况下,输出为一个包含两个值的浮点数组,第一个值是图片为猫的概率,第二个值是为狗的概率。这与您之前看到的结果代码相同,并且使用了前面示例中的相同数组扩展:

let results = Float32 ?? []

如您所见,尽管这是一个更复杂的例子,但相同的设计模式适用。您必须理解您的模型架构,原始输入和输出格式。然后,您必须按照模型预期的方式结构化输入数据——这通常意味着将原始字节写入缓冲区,或者至少模拟使用数组。然后,您必须读取从模型出来的原始字节流,并创建一个数据结构来保存它们。从输出的角度来看,这几乎总是像我们在本章中看到的那样——一个浮点数数组。借助您实施的辅助代码,您已经完成了大部分工作!

我们将在第十一章中更详细地讨论这个例子。

探索模型优化

TensorFlow Lite 包含工具,可以使用代表性数据来优化您的模型,以及通过量化等过程。我们将在本节中探讨这些内容。

量化

量化的概念来自于理解模型中的神经元默认使用 float32 作为表示,但通常它们的值落在比 float32 范围小得多的范围内。以图 8-19 为例。

图 8-19. 量化值

在这种情况下,图表底部是特定神经元可能具有的可能值的直方图。它们被归一化,所以它们围绕 0 分布,但最小值远大于 float32 的最小值,最大值远小于 float32 的最大值。如果,而不是拥有所有这些“空间”,直方图可以转换为一个更小的范围——比如从−127 到+127,然后相应地映射值。请注意,通过这样做,您显著减少了可以表示的可能值的数量,因此会冒降低精度的风险。研究表明,尽管可能会损失精度,但损失通常很小,但由于更简单的数据结构,模型大小以及推理时间的好处大大超过风险。

那么,值的范围可以在一个字节(256 个值)中实现,而不是 float32 的 4 个字节。考虑到神经网络可能有许多神经元,通常使用数十万或数百万个参数,仅使用四分之一的空间来存储它们的参数可以显著节省时间。

注意

这也可以与优化为使用 int8 而不是 float32 数据存储的硬件结合使用,从而提供进一步的硬件加速推理优势。

这个过程被称为量化,并且在 TensorFlow Lite 中可用。让我们来探索它是如何工作的。

请注意,有多种量化方法,包括量化感知训练,在模型学习时考虑其中;修剪,用于减少模型中的连接以简化模型;以及我们将在这里探讨的后训练量化,在此过程中,您可以减少模型中权重和偏差的数据存储,如前所述。

在本章的源代码中,有一个名为Chapter8Lab2的笔记本,用于训练神经网络识别猫和狗的差异。我强烈建议您在跟随本节内容时一起进行。如果在您的国家可以访问,您可以使用Colab;否则,您需要在 Python 环境或 Jupyter 笔记本环境中进行工作。

在您进行工作时,您可能会注意到您会得到一个看起来像这样的模型摘要:

Layer (type)                 Output Shape              Param #
=================================================================
keras_layer (KerasLayer)     (None, 1280)              2257984
_________________________________________________________________
dense (Dense)                (None, 2)                 2562
=================================================================
Total params: 2,260,546
Trainable params: 2,562
Non-trainable params: 2,257,984

请注意参数的数量:超过两百万!如果每个参数的存储减少 3 字节,通过量化可以将模型大小减少超过 6 MB!

因此,让我们探索如何对模型进行量化——这非常容易!您之前看到如何从保存的模型实例化转换器如下:

converter = tf.lite.TFLiteConverter.from_saved_model(CATS_VS_DOGS_SAVED_MODEL)

但在转换之前,您可以在转换器上设置一个优化参数,该参数接受指定优化类型的参数。最初设计时有三种不同类型的优化(默认或平衡,大小和速度),但选择量化类型的选项已经被弃用,因此目前您只能使用“默认”量化。

为了兼容性和未来的灵活性,方法签名仍然保留,但您只有一个选项(默认选项),可以像这样使用:

converter.optimizations = [tf.lite.Optimize.DEFAULT]

当我创建了未经优化的猫狗模型时,模型大小为 8.8 Mb。应用优化后,模型缩小为 2.6 Mb,节省了大量空间!

您可能会想知道这对准确性有什么影响。鉴于模型大小的变化,进行调查是很好的。

笔记本中有一些您可以自己尝试的代码,但当我调查时,我发现在 Colab 中未优化版本的模型每秒大约可以进行 37 次迭代(使用 GPU,因此针对浮点运算进行了优化!),而缩小版本每秒大约进行 16 次迭代。没有 GPU 时,性能下降了约一半,但重要的是,速度对于图像分类仍然很好,并且在设备上分类图像时不太可能需要这种性能!

更重要的是精度——在我测试的 100 张图像集中,未优化的模型正确识别了 99 张,而优化后的模型识别了 94 张。这里您将需要在是否以精度为代价优化模型之间做出权衡决定。尝试为自己的模型进行实验!请注意,在这种情况下,我只是进行了基本的量化。还有其他减小模型大小的方法,可能影响较小,所有方法都应该探索。接下来我们来看看使用代表性数据。

使用代表性数据

前面的示例展示了通过将浮点数(float32)的值有效地减少到整数(int8),从数据中有效去除“空白”,来进行量化。但是,该算法通常假设数据在 0 周围均匀分布,因为这是它从训练数据中学到的,如果您的测试或实际数据不是这样表示,可能会导致精度下降。我们看到,用少量测试图像,精度从 99/100 下降到 94/100。

您可以通过提供来自数据集的一些代表性数据来帮助优化过程,以便它更好地预测神经网络将来“看到”的数据类型。这将使您在大小与精度之间进行权衡——由于优化过程不会将所有值从 float32 转换为 int8,检测到数据集中一些值可能涉及过多的丢失数据,因此大小可能会稍大一些。

这样做非常简单——只需将您的数据子集作为代表性数据:

def representative_data_gen():
    for input_value, _ in test_batches.take(100):
        yield [input_value]

然后,将此指定为转换器的代表性数据集:

converter.representative_dataset = representative_data_gen

最后,指定所需的目标操作。通常会使用内置的INT8操作,如下所示:

converter.target_spec.supported_ops = [tf.lite.OpsSet.TFLITE_BUILTINS_INT8]

注意,在撰写本文时,全面支持的操作选择是实验性的。您可以在TensorFlow 网站上找到详细信息。

使用这个过程转换后,模型大小略有增加(至 2.9 Mb,但仍远小于原始的 8.9 Mb),但迭代速度急剧下降(约每秒一个迭代)。然而,准确性提高到 98/100,接近原始模型的水平。

若要了解更多技术,并探索模型优化的结果,请查看https://www.tensorflow.org/lite/performance/model_optimization

请自行尝试,但请注意,Colab 环境可能会有很大变化,您的结果可能与我的不同,特别是如果您使用不同的模型。我强烈建议查看在设备上推断速度和准确性如何,以获取更合适的结果。

总结

本章介绍了 TensorFlow Lite,以及它如何将使用 Python 训练的模型带入到诸如 Android 或 iOS 等移动设备中的工作原理。您看到了工具链和转换脚本,这些工具能够帮助您缩小模型并优化以适配移动设备,然后探索了如何编写 Android/Kotlin 或 iOS/Swift 应用程序来使用这些模型的一些场景。在超越简单模型的同时,您还开始了解作为应用程序开发者在将数据从移动环境的内部表示转换为 TensorFlow 模型中的基于张量的表示时需要考虑的一些因素。最后,您还探索了进一步优化和缩小模型的一些场景。在第九章中,您将学习一些用于创建比“Hello World”更复杂模型的场景,并在第十和十一章中,您将把这些模型引入到 Android 和 iOS 中!

第九章:创建自定义模型

在早期章节中,您了解了如何使用现成的模型进行图像标注、目标检测、实体提取等。但您没有看到的是如何使用您自己创建的模型,以及如何您可能自己创建它们。在本章中,我们将研究创建模型的三种场景,然后在第十章和第十一章中,我们将探讨如何将这些模型整合到 Android 或 iOS 应用程序中。

从头开始创建模型可能会非常困难且非常耗时。这也是纯 TensorFlow 开发的领域,有许多其他书籍涵盖了这一点,比如我的书 AI and Machine Learning for Coders(O’Reilly)。如果您不是从头开始创建,并且特别是如果您专注于移动应用程序,则有一些工具可供您使用,我们将在本章中介绍其中的三个:

  • TensorFlow Lite Model Maker是首选选择如果您正在构建符合 Model Maker 支持场景的应用程序。它是用于构建任何类型模型的通用工具,而是设计用于支持如图像分类、目标检测等常见用例。它涉及的神经网络特定编码几乎为零,因此如果您还不想学习这些内容,这是一个很好的起点!

  • 使用Cloud AutoML创建模型,特别是 Cloud AutoML 中旨在最小化您需编写和维护的代码量的工具。与 TensorFlow Model Maker 类似,这里的场景侧重于核心常见的应用场景,如果您想要超出这些场景,您将需要进行一些自定义模型编码。

  • 使用 TensorFlow 进行迁移学习创建模型。在这种情况下,您不需要从头开始创建模型,而是重用现有模型,为您的场景重新定位部分。当您接近神经网络建模时,这将需要您进行一些神经网络编码。如果您希望尝试一下创建深度学习模型的世界,这是一个绝佳的开始方式;大部分复杂性已经为您实现,但您可以灵活地构建新的模型类型。

另一个仅限 iOS 的场景是使用 Create ML 创建模型,它也使用迁移学习;我们将在第十三章中探讨这一点。我们还将探讨语言模型,除了模型本身,您的移动应用还需要理解关于模型的元数据,例如用于创建模型的单词字典。为了轻松开始,让我们首先探索 TensorFlow Lite Model Maker。

使用 TensorFlow Lite Model Maker 创建模型

TensorFlow Lite Model Maker 的一个核心场景是图像分类,它声称只需四行代码即可帮助您创建基本模型。此外,该模型将与 Android Studio 的导入功能兼容,因此您无需在资源文件夹中进行复杂操作,它还会生成起始代码。正如您将在第十章中看到的,使用 Model Maker 制作的模型在应用程序中使用起来要容易得多,因为将 Android 数据表示转换为模型所需张量的困难任务已被抽象化为帮助类,在使用 Android Studio 导入模型时自动生成。不幸的是,在撰写本文时,Xcode 尚无等效工具可用,因此您需要自己编写代码来处理大部分模型数据的输入/输出,但我们将通过一些示例帮助您在第十一章中开个好头。

使用 Model Maker 创建模型非常简单。您将使用 Python,我已为您创建了一个 Python 笔记本;它可以在本书代码存储库中找到。

首先安装 tflite-model-maker 包:

!pip install -q tflite-model-maker

完成这些步骤后,您可以像这样导入 tensorflownumpy 和 TensorFlow Lite Model Maker 的各种模块:

import numpy as np
import tensorflow as tf

from tflite_model_maker import configs
from tflite_model_maker import ExportFormat
from tflite_model_maker import image_classifier
from tflite_model_maker import ImageClassifierDataLoader
from tflite_model_maker import model_spec

要使用 Model Maker 训练模型,您需要数据,这可以是图像文件夹或 TensorFlow 数据集。因此,在本示例中,有一个可下载的包含五种不同类型花朵图片的文件。下载并解压缩后,将为花朵创建子目录。

下面是代码,其中 url 是包含花朵压缩包的位置。它使用 tf.keras.utils 库来下载和解压文件:

image_path = tf.keras.utils.get_file('flower_photos.tgz',
                                     url, extract=True)

image_path = os.path.join(os.path.dirname(image_path), 'flower_photos')

如果您想检查下载内容,可以执行以下操作:

os.listdir(image_path)

这将输出该路径的内容,包括子目录,应如下所示:

['roses', 'daisy', 'dandelion', 'LICENSE.txt', 'sunflowers', 'tulips']

Model Maker 将使用这些子目录作为标签名称来训练分类器。请记住,在图像分类中,训练数据是有标签的(即这是雏菊,这是玫瑰,这是向日葵等),神经网络会将每个图像的不同特征与标签匹配,以便随着时间的推移学会“看到”这些标记对象的不同之处。这类似于“当你看到这个特征时,它是向日葵;当你看到这个时,它是蒲公英”等。

你不应该用所有的数据来训练模型。一个最佳实践是保留一部分数据,以便验证模型是否真正理解了一般意义上不同类型的花之间的区别,或者它是否专门于它所见到的一切。这听起来有点抽象,可以这样理解:你希望你的模型能够识别出它尚未见过的玫瑰,而不仅仅是根据训练时使用的图像来识别玫瑰。一个简单的技术是只使用你数据的一部分 — 比如说 90% — 来进行训练。在训练过程中,剩余的 10%是不可见的。这 10%可以代表网络将来需要分类的内容,评估它在这方面的有效性比评估它在训练时的有效性更为重要。

要实现这一点,你需要为 Model Maker 创建一个数据加载器,从一个文件夹中获取数据。由于图像已经位于image_page文件夹中,你可以在ImageClassifierDataLoader对象上使用from_folder方法来获取数据集。然后,一旦你有了数据集,可以使用 split 方法将其分割;使用类似以下代码获取包含 90%图像的train_data和另外 10%图像的test_data

data = ImageClassifierDataLoader.from_folder(image_path)
train_data, test_data = data.split(0.9)

你可能会得到以下类似的输出。请注意,3,670 是数据集中的总图像数,而不是用于训练的 90%图像数。标签数是不同类型的标签数 — 在这种情况下是五种 — 对应于五种不同类型的花。

INFO:tensorflow:Load image with size: 3670, num_label: 5,
labels: daisy, dandelion, roses, sunflowers, tulip

现在,为了训练你的模型,你只需要在图像分类器上调用create方法,它会完成其余的工作:

model = image_classifier.create(train_data)

这可能看起来有点太简单了 — 因为很多复杂性,比如定义模型架构、从现有模型进行迁移学习、指定损失函数和优化器,最后进行训练,全部封装在image_classifier对象内部。它是开源的,所以你可以通过查看TensorFlow Lite Model Maker repo来深入了解。

输出会有很多内容,虽然一开始可能看起来有点陌生,但快速查看一下是很有必要的,以便理解。以下是完整的输出:

Model: "sequential_1"
_________________________________________________________________
Layer (type)                 Output Shape              Param #
=================================================================
hub_keras_layer_v1v2_1 (HubK (None, 1280)              3413024
_________________________________________________________________
dropout_1 (Dropout)          (None, 1280)              0
_________________________________________________________________
dense_1 (Dense)              (None, 5)                 6405
=================================================================
Total params: 3,419,429
Trainable params: 6,405
Non-trainable params: 3,413,024
_________________________________________________________________
None
Epoch 1/5
103/103 [====================] - 18s 151ms/step - loss: 1.1293 - accuracy: 0.6060
Epoch 2/5
103/103 [====================] - 15s 150ms/step - loss: 0.6623 - accuracy: 0.8878
Epoch 3/5
103/103 [====================] - 15s 150ms/step - loss: 0.6200 - accuracy: 0.9149
Epoch 4/5
103/103 [====================] - 15s 149ms/step - loss: 0.6011 - accuracy: 0.9219
Epoch 5/5
103/103 [====================] - 15s 149ms/step - loss: 0.5884 - accuracy: 0.9369

输出的第一部分是模型架构,告诉我们模型是如何设计的。这个模型有三层:第一层称为hub_keras_layer_v1v2_1,看起来有点神秘。我们稍后会回到这一点。第二层称为dropout_1,第三层(也是最后一层)称为dense_1。重要的是要注意最后一层的形状——它是(None, 5),在 TensorFlow 术语中表示第一维可以是任何大小,第二维是 5。我们之前在哪里看到过 5?没错,这是数据集中的类别数量——我们有五种花。因此,这个输出将是包含五个元素的每个项目的数量。第一个数字用于处理所谓的批量推理。因此,如果您向模型提供一些图像,比如 20 张,它将为您提供一个 20 × 5 的单一输出,其中包含 20 张图像的推断结果。

你可能会想,为什么有五个值,而不是一个单一的值?回头看一下第二章,特别是猫和狗的例子(图 2-5),你会发现一个识别n类的神经网络的输出有n个神经元,每个神经元表示网络识别该类的概率。因此,在这种情况下,神经元的输出分别是五个类别的概率(即,神经元 1 代表雏菊,神经元 2 代表蒲公英,依此类推)。如果你想知道是什么决定了这个顺序——在这种情况下,它仅仅是按字母顺序排列。

那么其他层呢?好吧,我们从第一层开始,称为hub_keras-layer_v1v2_1。这是一个奇怪的名字!但提示在开头用了“hub”这个词。一种常见的模型训练形式是使用现有的预训练模型,并根据自己的需求进行调整。可以把它想象成子类化现有类并重写某些方法。有许多模型是在数百万张图片上进行训练的,因此它们在从这些图片中提取特征方面非常擅长。因此,与其从头开始,通常更容易使用它们已经学习过的特征,并将其与您的图片匹配。这个过程被称为迁移学习,而 TensorFlow Hub 存储了许多预训练模型或具有已学习特征(也称为特征向量)的模型部分,可以重复使用。这几乎就像是一个现有代码类库,但不是代码,而是已经知道如何执行某些任务的神经网络。因此,这个模型使用 TensorFlow Hub 中的现有模型并使用其特征。该模型的输出将是 1,280 个神经元,表示它尝试在您的图像中“识别”的 1,280 个特征。

这之后是一个“dropout”层,这是神经网络设计中常见的技巧,用于加快网络的训练速度。从高层次来看,它只是意味着“忽略一些神经元”!逻辑是,像花这样简单的东西,只有五类,不需要 1,280 个特征来识别,因此随机忽略其中一些更可靠!听起来有点奇怪,我知道,但当您更深入地了解模型的创建方式时(我的书 AI 和机器学习编程者 是一个很好的起点),这就会有点道理。

如果您已经阅读了本书的第一章和第二章,剩下的输出应该是有意义的。这是五次训练周期,在每个周期中,我们会对图像的类别进行猜测,并在所有图像上重复这个过程。这些猜测的好坏将用来优化网络并准备进行另一次猜测。在五个周期中,我们只执行这个循环五次。为什么这么少?嗯,因为我们使用的是之前描述过的迁移学习。神经网络中的特征并不是从零开始的——它们已经经过良好的训练。我们只需为我们的花朵微调它们,所以我们可以快速地完成。

当我们达到第 5 个周期的末尾时,我们可以看到准确率为 0.9369,所以这个模型在训练集上区分不同类型的花大约达到了 94%的准确度。如果你想知道前面的[===]是什么,以及一个数字,比如 103/103,这表明我们的数据正在被加载到模型中进行批处理训练。与逐个图像训练模型相比,TensorFlow 可以通过图像批处理训练来提高效率。请记住,在这种情况下,我们的数据集中有 3,670 张图像,并且我们用于训练的是其中的 90%?这将给我们训练用的 3,303 张图像。Model Maker 默认每批次 32 张图像,所以在训练过程中我们将获得 103 批次的 32 张图像。这是 3,296 张图像,因此每个周期我们将不使用 7 张图像。请注意,如果批次被随机化,每个周期中的每个批次将具有不同的图像集合,所以这七张图像最终会被使用!如果批处理训练,训练通常会更快,因此采用了这种默认方式。

记住,我们将数据的 10%作为测试集保留,这样我们可以评估模型在该数据上的表现,就像这样:

loss, accuracy = model.evaluate(test_data)

我们会看到一个结果有点像这样:

12/12 [=======================] - 5s 123ms/step - loss: 0.5813 - accuracy: 0.9292

这里的准确率显示,在测试集上——即网络之前未曾见过的图像上——我们的准确率约为 93%。我们有一个非常好的网络,不会过度拟合训练数据!这只是一个高层次的观察,当你深入构建更详细的模型时,你会希望探索更详细的指标——比如探索模型对每个图像类别的准确率(通常称为“混淆矩阵”),这样你就可以确定是否在过度补偿某些类型。这超出了本书的范围,但请查看 O'Reilly 的其他书籍,如我的AI 和机器学习 for Coders,以了解更多有关创建模型的细节。

现在,我们只需要导出训练好的模型。此时,我们有一个在 Python 环境中执行的TensorFlow模型。我们想要一个 TensorFlow Lite模型,可以在移动设备上使用。为此,Model Maker 工具提供了一个可以使用的导出:

model.export(export_dir='/mm_flowers/')

这将把模型转换为 TFLite 格式,类似于我们在第八章中看到的,但会将元数据和标签信息编码到模型中。这为我们提供了一个单一文件解决方案,使得将模型导入到 Android Studio 以供 Android 开发者使用,并生成正确的标签细节变得更加容易。我们将在第十章中看到这一点。不幸的是,这是特定于 Android 的,所以 iOS 开发者也需要有标签文件。我们将在第十一章中探讨这一点。

如果你使用 Colab,你会在文件浏览器中看到model.tflite文件;否则,它将被写入你在 Python 代码中使用的路径。你将在第十章和 11 章中为 Android 和 iOS 应用程序使用它。

在那之前,让我们看看创建模型的其他选项,从 Cloud AutoML 开始。这是一个有趣的场景,因为正如其名称所示,它会自动生成 ML 模型。但除了不需要编写任何代码之外,它还需要一些时间来探索多个模型架构,以找到最适合你数据的模型。因此,使用它创建模型会更慢,但你将得到更精确和更优化的模型。它还支持多种格式输出,包括 TensorFlow Lite。

使用 Cloud AutoML 创建模型

AutoML 的目标是为您提供一套基于云的工具,让您尽可能少的编写代码和专业知识,让您训练自定义机器学习模型。有一些场景的工具可以使用:

  • AutoML Vision为你提供了图像分类或对象检测,并且你可以在云中运行推理或输出可用于设备的模型。

  • AutoML Video Intelligence让你能够检测和跟踪视频中的对象。

  • AutoML Natural Language 允许您理解文本的结构和情感。

  • AutoML Translation 允许您在不同语言之间翻译文本。

  • AutoML Tables 允许您使用结构化数据构建模型。

大多数情况下,这些工具设计用于在后端服务器上运行的模型。唯一的例外是 AutoML Vision,特别是称为 AutoML Vision Edge 的特定子集,它允许您训练能够在设备上运行的图像分类和标记场景。我们将在下面探讨这个。

使用 AutoML Vision Edge

AutoML Vision Edge 使用 Google Cloud 平台(GCP),因此,为了继续,您需要启用计费的 Google Cloud 项目。我不会在这里覆盖这些步骤,但您可以在 GCP 网站 上找到它们。使用该工具需要您按照这些步骤仔细操作。

步骤 1: 启用 API

一旦您有了项目,在 Google Cloud 控制台中打开它。这可通过 console.cloud.google.com 访问。在左上角,您可以展开一个选项菜单,您将看到一个名为 API 和服务 的条目。参见 图 9-1。

图 9-1. 在 Cloud 控制台中选择 API 和服务选项

从这里,选择“库”以获取 Cloud API Library 屏幕。顶部有一个带有搜索栏的瓷砖列表。使用它来搜索“AutoML”,您将找到“Cloud AutoML API” 的条目。

选择这个选项,您将进入 Cloud AutoML API 的首页面。这里会提供有关模型的详细信息,包括定价。如果您希望使用它,请点击“启用”按钮以打开它。

步骤 2: 安装 gcloud 命令行工具

根据您的开发工作站,您可以选择多种安装 Cloud SDK(包括 gcloud 命令行工具)的选项。本章节我使用的是 Mac,所以我会按照那个安装它。(这些说明在 Linux 上也适用。)完整指南请查看 Google Cloud 文档

要使用“交互式”安装程序,该程序会让你选择环境设置,请执行以下操作。在终端中输入以下命令:

curl https://sdk.cloud.google.com | bash

系统会询问您的目录。通常会使用您的主目录,默认情况下应该是这样,所以当询问时您可以直接回答“yes”以继续。还会询问是否将 SDK 命令行工具添加到您的 PATH 环境变量中。如果是,请确保选择“yes”。安装完成后,请重新启动您的终端/Shell。

完成后,在您的终端中发出以下命令:

gcloud init

您将通过一个流程引导来登录您的项目:会生成一个链接,点击链接后,浏览器将打开并要求您登录您的 Google 账号。之后,您将被要求授予 API 的权限。要使用它,您需要授予这些权限。

你还将被要求提供计算引擎资源将在其中运行的区域。你 必须 选择 us-central-1 实例,以便这些 API 正常工作,请确保这样做。请注意,这在将来可能会发生变化,但在撰写本文时,这是唯一支持这些 API 的区域。在选择其他选项之前,请查看Edge 设备模型快速入门指南

如果你有多个 GCP 项目,你还将被要求选择适当的项目。一旦所有操作完成,你就可以准备好使用命令行工具与 AutoML!

第三步:设置一个服务账户

Cloud 支持多种身份验证类型,但 AutoML Edge 支持基于服务账户的身份验证。因此,接下来你需要创建一个,并获取其密钥文件。你可以使用 Cloud 控制台完成这个操作。在菜单中打开 IAM 和管理部分(你可以在图 9-1 中看到它),然后选择服务账户。

屏幕顶部有一个“添加服务账户”的选项。选择它,然后你将被要求填写服务账户的详细信息。

在第一步中为其命名,在第二步中,你将被要求授予该服务账户对项目的访问权限。确保你在这里选择 AutoML 编辑器角色。你可以展开框并搜索该角色。参见图 9-2。

图 9-2. 服务账户详细信息

在第三步中,你将被要求输入服务账户用户和服务账户管理员角色。你可以在这里输入电子邮件地址,如果你只是第一次学习这个,可以为两者都输入你的电子邮件地址。

当你完成后,账户将被创建,并且你将返回到一个包含多个服务账户的页面。如果你是第一次这样做,可能只会有一个。无论如何,选择你刚刚创建的那个(你可以根据其名称找到它),然后你将进入服务账户详细信息页面。

屏幕底部有一个添加密钥按钮。选择它,你将看到多种密钥类型选项。选择 JSON,然后一个包含你的密钥的 JSON 文件将下载到你的计算机上。

返回到你的终端并设置这些凭据:

export GOOGLE_APPLICATION_CREDENTIALS=[[Path to JSON file]]

当你在那里时,这里有一个便捷的快捷方式来设置你的PROJECT_ID环境变量:

export PROJECT_ID=[[whatever your project id is ]]

第四步:设置一个 Cloud Storage 存储桶并将训练数据存储在其中

在使用 AutoML 训练模型时,数据也必须存储在云服务可以访问的地方。你将使用 Cloud Storage 存储桶来完成这项任务。你可以使用gsutil中的mb命令(用于创建存储桶)。之前你已经设置了PROJECT_ID环境变量来指向你的项目,所以这段代码将有效。请注意,你将使用${PROJECT_ID}-vcm的方式命名这个存储桶,与你的项目名称相同,但附加了-vcm

gsutil mb -p ${PROJECT_ID}
          -c regional -l us-central1
          gs://${PROJECT_ID}-vcm/

然后你可以导出一个环境变量来指向这个存储桶:

export BUCKET=${PROJECT_ID}-vcm

我们在本章中一直在使用的花卉照片数据集位于公共云存储桶cloud-ml-data/img/flower_photos中,因此您可以使用此命令将它们复制到您的存储桶:

gsutil -m cp
       -R gs://cloud-ml-data/img/flower_photos/
       gs://${BUCKET}/img/

请注意,如果您是云服务的重度用户,并且在许多角色中使用了同一服务帐户,则可能存在冲突的权限,并且一些人报告说无法写入其 Cloud Storage 存储桶。如果您遇到此情况,请尝试确保您的服务帐户具有storage.adminstorage.objectAdminstorage.objectCreator角色。

当我们在本章的早些时候使用模型制造器或 Keras 时,有工具根据其目录组织图像的标签,但处理像这样的 AutoML 时,它会更加原始。数据集提供了一个包含位置和标签的 CSV 文件,但它指向公共存储桶的 URL,因此您需要将其更新为您自己的 URL。此命令将从公共存储桶下载它,编辑它,并保存为名为all_data.csv的本地文件:

gsutil cat gs://${BUCKET}/img/flower_photos/all_data.csv
| sed "s:cloud-ml-data:${BUCKET}:" > all_data.csv

然后,这将把该文件上传到您的 Cloud Storage 存储桶:

gsutil cp all_data.csv gs://${BUCKET}/csv/

第 5 步:将您的图像转换为数据集并训练模型

此时,您的存储桶中有大量图像。接下来,您将把它们转换为可以用来训练模型的数据集。要做到这一点,请访问 AutoML Vision 仪表板,网址为https://console.cloud.google.com/vision/dashboard.

您将看到卡片以开始使用 AutoML Vision、Vision API 或 Vision Product Search。选择 AutoML Vision 卡并选择开始。参见图 9-3。

Figure 9-3. AutoML Vision options

您将进入数据集列表,如果您没有经常使用 GCP,这里可能是空的,并在屏幕顶部看到一个按钮,上面写着新建数据集。单击它,您将收到一个对话框,要求您创建一个新数据集。可能至少会有三个选项:单标签分类、多标签分类或对象检测。选择单标签分类并选择创建数据集。参见图 9-4。

您将被要求选择要导入的文件。早些时候,您创建了一个包含数据集详细信息的 CSV 文件,因此请选择选择 Cloud Storage 上的 CSV,并在对话框中输入其 URL。它应该是类似gs://project-name-vcm/csv/all_data.csv。参见图 9-5。

Figure 9-4. 创建一个新数据集

Figure 9-5. 从 Cloud Storage 导入 CSV 文件

点击浏览来查找它。在执行此操作并选择继续后,您的文件将开始导入。您可以返回到数据集列表以查看这一点。参见图 9-6。

Figure 9-6. 导入图像到数据集

这可能需要一些时间来完成,所以请留意左侧的状态。对于这个数据集,在完成时,它将给出警告,就像 图 9-7 中所示。

图 9-7. 数据上传完成时

一旦完成,您可以选择它,然后浏览数据集。参见 图 9-8。

图 9-8. 探索花卉数据集

现在数据已导入,训练就像选择“训练”选项卡并选择“开始训练”那样简单。在随后的对话框中,请确保选择“Edge”作为您想要训练的模型类型,然后选择“继续”。参见 图 9-9。

图 9-9. 定义模型

选择此选项后,您可以选择优化您的模型。这将让您选择一个更精确的更大型模型,或者一个更快但可能不那么准确的更小型模型,或者介于两者之间的某种模型。接下来,您可以选择要用来训练模型的计算小时数。默认为四个节点小时。选择这个选项,然后选择“开始训练”。训练可能需要一些时间,在完成时,AutoML 将通过电子邮件向您发送训练的详细信息!

步骤 6: 下载模型

一旦训练完成,您将收到来自 Google Cloud Platform 的电子邮件通知您准备就绪。当您通过邮件中的链接返回控制台时,您将看到训练结果。由于进行了神经架构搜索以找到最佳架构来分类这些花卉,这个训练可能需要相当长的时间——多达两到三个小时——而且您将在结果中看到这一点——精确度达到了 99%。参见 图 9-10。

图 9-10. 训练完成

从这里,您可以转到“测试和使用”选项卡,可以将模型导出为多种格式,包括 TensorFlow Lite、TensorFlow.js 和 Core ML!选择适当的格式(本书大多数情况下使用 TensorFlow Lite,尽管我们将在后面章节中探索一些 Core ML),然后您可以下载它。参见 图 9-11。

除了这两种制作模型的方法——Model Maker 和 Cloud AutoML——这两种方法大多数情况下都避免了编写代码,并且有 API 处理模型训练,还有第三种方法值得探索,您将需要进行一些编码,但是 ML 模型大部分由他人创建,您可以利用他们的架构使用迁移学习。

图 9-11. 模型的导出选项

使用 TensorFlow 和迁移学习创建模型

如前所述,迁移学习的概念可以支持快速开发机器学习模型。该概念是利用已在类似问题上训练过的神经网络的部分,然后覆盖它们以适应您自己的情景。例如,EfficientNet 模型架构是为 ImageNet 情景设计的,其中有 1,000 类图像;它经过数百万张图像的训练。为了自己训练这样一个模型所需的资源将非常昂贵,不论是时间还是金钱。当模型在如此大的数据集上训练时,它可以是一个非常高效的特征选择器。

这是什么意思?简而言之,典型的计算机视觉神经网络使用所谓的卷积神经网络(CNN)架构。 CNN 由许多滤波器组成,其中一旦滤波器应用于图像,它将对其进行转换。随着时间的推移,CNN 将学习有助于区分图像的滤波器。例如,图 9-12 显示了猫狗分类器中的图像示例,说明了 CNN 用来区分这些动物类型的图像区域。在这种情况下很明显,学习到了确定狗眼睛外观的滤波器,以及学习到了确定猫眼睛外观的其他滤波器。在网络的所有滤波器中,决定图片内容可能仅仅是这些滤波器的结果显示的内容。

图 9-12. CNN 滤波器在不同图像中激活不同区域的示例

因此,使用像 EfficientNet 这样的现有网络,如果模型的创建者公开了已学习的滤波器(通常称为特征向量),您可以直接使用它们。逻辑上,如果它具有一组用于选择 1,000 类之间的滤波器,那么相同的滤波器组可能会为您的数据集提供一个合理的分类——在花朵的情况下,EfficientNet 的滤波器可能会被用于选择五类花之间的分类。因此,您不需要训练一个全新的神经网络;只需向现有的网络添加所谓的“分类头”。该头部可以是一个简单的单层,其中包含n个神经元,其中n是您数据中的类数——在花卉的情况下,这将是五个。

实际上,您的整个神经网络可能看起来像这样,并且可以用仅三行代码定义:

model = tf.keras.Sequential([
        feature_extractor,
        tf.keras.layers.Dense(5, activation='softmax')
])

要做到这一点,您必须定义特征提取器并从现有模型(如 EfficientNet)加载它。

这就是 TensorFlow Hub 派上用场的地方。它是一个模型和模型部件的存储库,包括特征提取器。您可以在tfhub.dev找到它。使用屏幕左侧的过滤器,您可以访问不同的模型类型——例如,如果您想要图像特征向量,您可以使用它们来获得一组特征向量

当你拥有一个模型时,它会有一个网址—例如,优化为移动设备并在 ImageNet 上训练的 EfficientNet 模型可以在https://tfhub.dev/tensorflow/efficientnet/lite0/feature-vector/2找到。

您可以使用此 URL 与 TensorFlow Hub Python 库将特征向量下载为神经网络中的一层:

import tensorflow_hub as hub

url = "https://tfhub.dev/tensorflow/
            efficientnet/lite0/feature-vector/2"

feature_extractor =
     hub.KerasLayer(url, input_shape=(224, 224, 3))

就是这样—这就是你需要创建自己的模型架构并利用 EfficientNet 学习特征的所有内容!

通过这种方法,您可以创建使用最先进模型基础的模型!导出模型就像使用相同技术将其转换为 TensorFlow Lite 一样简单,您在第八章中看到了这些技术:

export_dir = 'saved_model/1'
tf.saved_model.save(model, export_dir)

converter =
    tf.lite.TFLiteConverter.from_saved_model(export_dir)

converter.optimizations = [tf.lite.Optimize.DEFAULT]

tflite_model = converter.convert()

import pathlib
tflite_model_file = pathlib.Path('model.tflite')
tflite_model_file.write_bytes(tflite_model)

我们将在第十章和第十一章探讨在 Android 和 iOS 中使用这些模型的情况。

迁移学习是一种强大的技术,我们在这里涵盖的仅仅是一个非常轻量级的介绍。要了解更多信息,请查阅 Aurelien Geron 的Hands-On Machine Learning with Scikit-Learn, Keras, and TensorFlow等书籍,或者 Andrew Ng 的优秀教程,如“Transfer Learning”视频

创建语言模型

在本章中,您看到了如何以多种方式创建模型,以及如何将它们转换为 TensorFlow Lite,以便它们可以部署到移动应用程序中,您将在下一章中看到。一个重要的细节是它们都是基于图像的模型,而对于其他模型类型,可能需要额外的元数据来与 TFLite 模型一起部署,以便您的移动应用程序可以有效地使用它们。在这里我们不会详细介绍训练自然语言处理(NLP)模型的内容—这只是一个关于将它们创建为移动应用程序的概念的高级概述。要了解有关创建和训练语言模型以及 NLP 工作原理的更详细步骤,请查看我的书AI and Machine Learning for Coders

一个这样的例子是当你使用基于语言的模型时。构建文本分类器不是在文本本身上工作,而是在encoded文本上工作,你通常会创建一个单词字典,并使用它们构建分类器。

所以,例如,假设你有一个句子像“今天是个晴天”,而你想要在一个模型中使用它。一个高效的方法是用数字替换单词,这个过程称为tokenizing。然后,如果你也要对编码为“今天是个雨天”的句子进行编码,你可以重复使用一些标记,带有这样的字典:

{'today': 1, 'is': 2, 'a': 3, 'day': 4, 'sunny': 5, 'rainy': 6}

这将使您的句子看起来像这样:

[1, 2, 3, 5, 4] and [1, 2, 3, 6, 4]

所以当您训练您的模型时,您将用这些数据来训练它。

然而,当你稍后想要在你的移动应用程序中进行推理时,你的应用程序将需要相同的字典,否则它将无法将用户输入转换为模型训练时理解的数字序列(即它不会知道“sunny”应该使用令牌 5)。

另外,当你训练一个特定于语言的模型时,尤其是当你想要在语言中建立情感分析时,单词的标记将会映射到向量上,并且这些向量的方向将有助于确定情感。

请注意,这种技术并不限于情感分析。你可以使用这些向量来建立语义,其中具有类似含义的单词(如“cat”和“feline”)可以具有相似的向量,但具有不同含义的单词(如“dog”和“canine”)虽然相似,但与“cat”/“feline”的方向不同。但为了简单起见,我们将探索映射到有标签情感的单词。

考虑这两个句子:“我今天非常高兴”,你将其标记为积极情感,“我今天非常难过”,则为负面情感。

“I”、“am”、“very”和“today”这些单词在两个句子中都存在。单词“happy”出现在被标记为积极的句子中,“sad”出现在被标记为消极的句子中。当使用称为“嵌入”的机器学习层类型时,你所有的单词都将被转换为向量。向量的初始方向由情感决定;随着时间的推移,随着新的句子被输入模型,向量的方向将被微调。但在我们只有这两个句子的非常简单的情况下,这些向量可能看起来像图 9-13。

图 9-13. 建立单词向量

因此,请考虑这个空间,其中向量的“方向”决定了情感。向右指向的向量具有积极情感,向左指向的向量具有消极情感。因为“today”、“I”、“am”和“very”这些单词在两个句子中都出现,它们的情感相互抵消,所以它们不指向任何方向。因为“happy”仅在标记为积极的句子中出现,它指向积极方向;类似地,“sad”指向消极方向。

当一个模型被训练在许多有标签的句子上时,类似这些向量是通过嵌入进行学习的,最终被用来对句子进行分类。

在本章早些时候,当我们探讨图像的迁移学习时,我们能够使用已经从其他模型中学习到的特征提取器。这些模型经过了对数百万张图片的训练,并且有许多标签,因此它们非常擅长学习可以被重复使用的特征。

同样的情况也发生在语言模型中,单词的向量可能已经被预先学习,你只需在你的场景中使用它们。这样可以节省训练模型时的大量时间和复杂性!

在下一节中,您将探讨如何使用 Model Maker 创建一个基于语言的模型,然后可以在 Android 或 iOS 应用程序中使用!

使用 Model Maker 创建语言模型

Model Maker 使使用几行代码创建基于语言的模型变得非常简单。本章的下载中有一个完整的笔记本,我们将在这里介绍亮点。在本例中,我们将使用我创建的一个数据文件,其中包含来自推文的情感。我在此代码列表中缩写了 URL 以使其适合,但完整的 URL 是https://storage.googleapis.com/laurencemoroney-blog.appspot.com/binary-emotion-withheaders.csv

# Download the data CSV
data_url= 'https://storage.googleapis.com/laurencemoroney-blog.appspot.com/
             binary-emotion-withheaders.csv'

data_file = tf.keras.utils.get_file(
                     fname='binary-emotion-withheaders.csv',
                     origin=data_url)

接下来,Model Maker 将为您创建基础模型。它支持几种模型类型,随着时间的推移还会添加更多,但我们将使用的是最简单的模型——它使用从现有单词向量集进行的迁移学习:

spec = model_spec.get('average_word_vec')

可以使用TextClassifierDataLoader中的from_csv方法(在 Model Maker API 中可用)将 CSV 文件加载到训练数据集中,并且您需要指定 CSV 中包含文本的列以及包含标签的列。如果您检查 CSV 文件,您会看到一个名为“label”的列,其中包含负面情绪为 0 和正面情绪为 1 的内容。推文文本位于“tweet”列中。您还需要定义模型规范,以便 Model Maker 可以开始将这些推文中的单词映射到模型使用的嵌入向量。在前面的步骤中,您指定了模型规范使用平均单词向量模板:

# Load the CSV using DataLoader.from_csv to make the training_data
train_data = TextClassifierDataLoader.from_csv(
      filename=os.path.join(os.path.join(data_file)),
      text_column='tweet',
      label_column='label',
      model_spec=spec,
      delimiter=',',
      is_training=True)

现在,构建模型就像调用text_classifier.create一样简单,将数据、模型规范和训练的 epoch 数传递给它:

# Build the model
model = text_classifier.create(train_data, model_spec=spec, epochs=20)

因为您的模型不需要为每个单词学习嵌入,而是使用现有的嵌入,所以训练非常快速——在 Colab 中使用 GPU,我体验到大约每个 epoch 5 秒。经过 20 个 epoch 后,它将显示约 75%的准确性。

一旦模型训练完成,您可以简单地输出 TFLite 模型:

# Save the TFLite converted model
model.export(export_dir='/mm_sarcasm/')

为了方便 Android Studio 用户,这将标签单词字典捆绑到模型文件中。您将在下一章中了解如何使用此模型,包括元数据。对于 iOS 开发人员,没有添加到 Xcode 的插件来处理内置元数据,因此您可以单独导出它,使用:

model.export(export_dir='/mm_sarcasm/',
             export_format=[ExportFormat.LABEL, ExportFormat.VOCAB])

这将生成一个名为labels.txt的文件,其中包含标签规范,另一个名为vocab(无扩展名),其中包含字典的详细信息。

如果您想检查用于创建模型的模型架构,可以通过调用model.summary()来查看:

Layer (type)                 Output Shape              Param #
=================================================================
embedding (Embedding)        (None, 256, 16)           160048
_________________________________________________________________
global_average_pooling1d (Gl (None, 16)                0
_________________________________________________________________
dense (Dense)                (None, 16)                272
_________________________________________________________________
dropout (Dropout)            (None, 16)                0
_________________________________________________________________
dense_1 (Dense)              (None, 2)                 34
=================================================================
Total params: 160,354
Trainable params: 160,354
Non-trainable params: 0

关键的事情是顶部的嵌入,其中 256 表示模型设计用于处理的句子长度—它期望每个句子长达 256 个单词。因此,当传递字符串到模型时,您不仅仅需要将它们编码为单词的标记,还需要将它们填充到 256 个标记。因此,如果您想要使用一个由 5 个单词组成的句子,您将不得不创建一个包含 256 个数字的列表,其中前 5 个是您的 5 个单词的标记,其余为 0。

这里的 16 是单词情感的维度数量。回顾图 9-13,我们展示了两个维度的情感—在这种情况下,为了捕捉更加微妙的含义,向量将会是 16 维的!

总结

在本章中,您看到了几种用于创建模型的工具,包括 TensorFlow Lite Model Maker、Cloud AutoML Edge 和 TensorFlow 与迁移学习。您还探讨了使用基于语言的模型时的一些微妙之处,例如需要一个关联的字典,以便您的移动客户端可以理解单词在模型中的编码方式。

希望这些内容让您对模型创建有所了解。本书的主要重点不是教授如何创建不同类型的模型,您可以查看我的另一本书《AI 和机器学习与编程者》来探索如何做到这一点。在第十章,您将会使用本章学到的模型,并看到如何在 Android 上实现它们,然后在第十一章中使用它们在 iOS 上。

第十章:在 Android 中使用自定义模型

在第九章中,您看到了使用 TensorFlow Lite Model Maker、Cloud AutoML Vision Edge 和带有迁移学习的 TensorFlow 创建自定义模型的各种场景。在本章中,您将探讨如何在您的 Android 应用程序中使用和集成这些模型。不幸的是,将模型简单地放入应用程序中并使其“正常工作”通常并不简单。处理数据时经常会出现复杂情况,因为 Android 会以不同于 TensorFlow 的方式表示诸如图像和字符串等内容,而且模型的输出通常需要从基于张量的输出解析为 Android 中更具代表性的内容。我们将首先探讨这一点,然后再介绍如何在 Android 中使用图像和语言模型的一些示例。

将模型桥接到 Android

创建使用机器学习模型的应用程序时,您将拥有一个扩展名为.tflite的二进制 blob,您将将其合并到您的应用程序中。此二进制期望输入为张量(或其某种仿真),并将输出作为张量给出。这将是第一个挑战。此外,仅当存在关联的元数据时,模型才能正常工作。例如,如果您构建像第九章中的花卉分类器,模型将输出五个概率值,每个概率值与特定的花卉类型相匹配。然而,模型并不会输出像"玫瑰"这样的花卉类型,而是简单地给出一组数字,因此您需要相关的元数据来确定哪个输出值与哪种花卉相匹配。此外,如果您正在使用文本分类的语言模型,还需要理解模型训练时使用的单词字典。我们在本章中也将探讨这一点!

考虑在 Android 应用程序中使用模型的方式,看起来有点像图 10-1。

图像

图 10-1. 在 Android 应用程序中使用模型的高级架构

因此,例如,让我们考虑我们在第八章中使用的简单模型,该模型学习了数字之间的关系为 y = 2x − 1=2X-1,并探索代码。

首先,让我们看看模型的输入。并不像将一个数字输入并得到一个数字输出那样简单。对于输入,模型期望一个 NumPy 数组,但在 Android 中并没有 NumPy。幸运的是,您可以使用低级基本类型的数组替代,并且在使用 Kotlin 时,FloatArray类型可以被解释器解析为浮点数的基本数组。因此,您可以使用以下代码,其中userVal是要输入到模型中的值:

var inputVal: FloatArray = floatArrayOf(userVal)

然后,一旦模型提供了推断,它将其作为一串字节返回。作为安卓开发者,你必须意识到这四个字节代表一个浮点数,并且你需要将它们转换为浮点数。记住,模型的输出在其最原始的形式下并不是一个浮点数;需要你将原始字节重新解释为一个浮点数:

var outputVal: ByteBuffer = ByteBuffer.allocateDirect(4)
outputVal.order(ByteOrder.nativeOrder())
tflite.run(inputVal, outputVal)
outputVal.rewind()
var f:Float = outputVal.getFloat()

因此,在安卓中使用模型时,你需要考虑到这一点,当然,对于像图片和字符串这样的更复杂的输入数据,你需要处理这样的低级细节。有一个例外情况,那就是当你使用 TensorFlow Lite 模型制造器生成元数据时,你可以在将模型导入到 Android Studio 时使用这些元数据,它将为你生成大部分包装器代码。我们将首先研究这一点。

从模型制造器输出构建图像分类应用程序

在 第九章 中,你探索了使用 TensorFlow Lite 模型制造器为五种不同类型的花创建图像分类器。因为你使用了这个工具,它为你生成了元数据——在这种情况下非常简单——因为它只是五种花的相关标签。确保在继续之前下载你使用 Colab 创建的模型并将其可用。

要查看如何将其集成到安卓应用程序中,请启动 Android Studio 并创建一个新应用程序。只需使用一个简单的单活动应用程序即可。

创建完应用程序后,可以通过右键单击 Java 文件夹(即使使用 Kotlin 也是这样命名)并选择 New → Other → TensorFlow Lite Model 添加一个新模块。参见 Figure 10-2。

图 10-2. 添加一个新模块

这将弹出导入 TensorFlow Lite 模型对话框,在其中你需要指定模型的位置。选择你下载的那个,并保持其他所有设置为默认,除了关于添加 TensorFlow Lite GPU 依赖项的底部复选框。确保勾选此项。参见 Figure 10-3。

图 10-3. 导入 TensorFlow Lite 模型

点击完成,模型将被导入,Gradle 文件将被更新并进行同步。完成后,你将看到为你创建的一些示例代码。稍后会用到这些代码。这样可以为你节省许多步骤,例如编辑 Gradle 文件、创建资产文件夹、复制模型等等。

接下来,你可以创建一个简单的布局文件,其中包含几张花的图片。我在下载中放了一个示例,其中包含了六张从资源加载的图片。以下是一个片段:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout

    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:padding="8dp"
    android:background="#50FFFFFF"
    >

    <LinearLayout android:orientation="horizontal"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:gravity="center"
        android:layout_marginBottom="4dp"
        android:layout_weight="1">

        <ImageView
            android:id="@+id/iv_1"
            android:layout_width="0dp"
            android:layout_weight="1"
            android:scaleType="centerCrop"
            android:layout_height="match_parent"
            android:src="@drawable/daisy"
            android:layout_marginEnd="4dp"
            />

        ...
    </LinearLayout>

</LinearLayout>

ImageView 控件被称为 iv_1iv_6。请注意,图像的源是 @drawable/<*something*>,例如 @drawable/daisy。UI 将从 drawable 目录加载具有该名称的图像。本书的 GitHub 包含完整的示例应用程序,包括几张图片。您可以在 drawable 文件夹中查看它们的 Figure 10-4。

Figure 10-4. 将图像添加为可绘制对象

现在,在您的代码中,您可以初始化 ImageView 控件并为每个控件设置点击监听器。同一方法可以用于每一个:

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)
    initViews()
}

private fun initViews() {
    findViewById<ImageView>(R.id.iv_1).setOnClickListener(this)
    findViewById<ImageView>(R.id.iv_2).setOnClickListener(this)
    findViewById<ImageView>(R.id.iv_3).setOnClickListener(this)
    findViewById<ImageView>(R.id.iv_4).setOnClickListener(this)
    findViewById<ImageView>(R.id.iv_5).setOnClickListener(this)
    findViewById<ImageView>(R.id.iv_6).setOnClickListener(this)
}

当您输入模型时,此方法可以实现代码的修改版本。这是整个方法,我们将逐步查看它:

override fun onClick(view: View?) {
    val bitmap = ((view as ImageView).drawable as BitmapDrawable).bitmap
    val model = Model.newInstance(this)

    val image = TensorImage.fromBitmap(bitmap)

    val outputs = model.process(image)
    val probability = outputs.probabilityAsCategoryList
    val bestMatch = probability.maxByOrNull { it -> it.score }
    val label = bestMatch?.label

    model.close()

    runOnUiThread { Toast.makeText(this, label, Toast.LENGTH_SHORT).show() }

首先,请注意 onClick 方法接受一个视图作为参数。这将是用户触摸的 ImageView 控件的引用。然后,它将创建一个 bitmap 变量,其中包含所选视图的内容,如下所示:

val bitmap = ((view as ImageView).drawable as BitmapDrawable).bitmap

将位图转换为张量的过程封装在 TensorImage 类的辅助 API 中,您只需这样做:

val image = TensorImage.fromBitmap(bitmap)

现在,我们将图像加载到张量中后,初始化一个模型并将图像传递给它就是这么简单:

val model = Model.newInstance(this)
val outputs = model.process(image)

记住,模型将返回五个输出——这些是图像包含每种特定类型花朵的概率。它们按字母顺序排列,因此第一个值将是图像包含雏菊的概率。为了得到分类,您必须找到值最高的神经元,然后使用其相应的标签。

模型通过 Model Maker 对标签进行了编码,因此您可以将模型的输出作为概率列表,将该列表排序,使最大值位于顶部,然后使用以下代码获取顶部值的标签:

val probability = outputs.probabilityAsCategoryList
val bestMatch = probability.maxByOrNull { it -> it.score }
val label = bestMatch?.label

现在您有了标签,所以显示它就像使用 Toast 一样简单:

runOnUiThread { Toast.makeText(this, label, Toast.LENGTH_SHORT).show()

真的就这么简单。我强烈建议在可能的情况下使用 Model Maker 来开发基于图像的应用程序,因为这样可以大大简化您的应用程序编码!

请注意,此方法仅适用于使用 TensorFlow Lite Model Maker 构建的基于图像的模型。如果要使用其他模型,例如基于文本的模型,则应改用 TensorFlow Lite 任务库。我们稍后会探讨这些内容。

使用 ML Kit 输出的模型制造者

在 Chapter 4 中,您看到了如何使用 ML Kit 的图像标记 API 作为计算机视觉的简易解决方案。它提供了一个通用的图像分类器,因此如果您向它展示一张花朵的图片,它将为您提供关于该图像的一些详细信息。请参见 Figure 10-5。

正如你所看到的,这告诉我们我们正在看一朵花瓣、一朵花、一棵植物和天空!虽然都准确无误,但如果我们有一个针对刚刚创建的自定义模型的即插即用解决方案,它能识别特定的花并将其标记为雏菊,那就太好了!

幸运的是,这并不太困难,我们只需几行代码就可以更新该应用程序。你可以从本书的 GitHub 页面获取它。

首先,你需要添加 ML Kit 自定义标注 API。所以,除了通过 build.gradle 添加图像标注库外,还简单地添加图像标注自定义库:

// You should have this already
implementation 'com.google.mlkit:image-labeling:17.0.1'
// Just add this
implementation 'com.google.mlkit:image-labeling-custom:16.3.1'

图 10-5. 运行通用图像分类器

在你的应用程序中会有一个资产目录,其中添加了一些你在第四章中使用的示例图像。在那里添加使用 TensorFlow Lite Model Maker 创建的model.tflite文件。你也可以添加一些花的图片。(该应用程序也位于本书的第十章目录下的GitHub 页面。)

接下来,在你的活动的onCreate函数中,你将使用LocalModel.Builder()来创建一个本地模型,你将使用它来替代默认的 ML Kit 模型:

val localModel = LocalModel.Builder()
    .setAssetFilePath("model.tflite")
    .build()

val customImageLabelerOptions =
    CustomImageLabelerOptions.Builder(localModel)
      .setConfidenceThreshold(0.5f)
      .setMaxResultCount(5)
      .build()

对代码的最终更改是使用ImageLabeling.getClient()与你刚刚创建的选项。这在原始应用程序的btn.setOnClickListener中完成,因此你可以直接更新为以下内容:

val labeler = ImageLabeling.getClient(customImageLabelerOptions)

然后一切与原始应用程序相同——你将在图像上调用labeler.process并在其onSuccessListener中捕获输出:

btn.setOnClickListener {
  val labeler = ImageLabeling.getClient(customImageLabelerOptions)
  val image = InputImage.fromBitmap(bitmap!!, 0)
  var outputText = ""
  labeler.process(image)
    .addOnSuccessListener { labels ->
      // Task completed successfully
      for (label in labels) {
        val text = label.text
        val confidence = label.confidence
        outputText += "$text : $confidence\n"
      }
      txtOutput.text = outputText
}

现在,当你使用相同的雏菊图像运行应用程序时,你会看到在图 10-6 中它以接近 97% 的概率将图像分类为雏菊。

图 10-6. 使用自定义模型对雏菊进行分类

使用语言模型

当构建使用语言的模型时,模式与你在图 10-1 中看到的非常相似;这在图 10-7 中展示。

一个主要区别是,使用基于自然语言处理(NLP)的模型的你的应用程序需要与底层模型训练时使用的单词字典相同。回想一下第九章中,句子被分解为单词列表,而单词被赋予数值标记。为这些标记学习到向量以建立该单词的情感。例如,“dog”这个词可能被赋予标记 4,并且像[0, 1, 0, 1]这样的多维向量可以用于标记 4。字典然后可以用来将“dog”映射到你的应用程序中的 4。模型还在固定长度的句子上进行了训练,你的应用程序还需要知道这些数据。

图 10-7. 在应用程序中使用模型进行 NLP

如果您使用 TensorFlow Lite Model Maker 构建模型,则元数据和字典实际上已编译到.tflite文件中,以使您的生活更加轻松。

在本节的其余部分中,假设您有一个使用 Model Maker 训练的 NLP 模型,如在第九章中演示的情感分类器。您还可以在本章的存储库中找到一个示例,其中包含已为您实现的完整应用程序,包括模型。

创建用于语言分类的 Android 应用程序

使用 Android Studio 创建一个新的 Android 应用程序。只需将其制作成一个空活动即可。完成后,编辑 build.gradle 文件以包括 TensorFlow Lite 以及处理文本的 TensorFlow Lite 任务库:

implementation 'org.tensorflow:tensorflow-lite-task-text:0.1.0'
implementation 'org.tensorflow:tensorflow-lite:2.2.0'
implementation 'org.tensorflow:tensorflow-lite-metadata:0.1.0-rc1'
implementation 'org.tensorflow:tensorflow-lite-support:0.1.0-rc1'
implementation 'org.tensorflow:tensorflow-lite-gpu:2.2.0'

Gradle 同步后,您可以导入模型。通过右键单击项目资源管理器中的包名,然后选择新建 → 其他 → TensorFlow Lite 模型来使用与图 10-2 中显示的相同技术。接受所有默认选项,并在完成后如有必要再次进行 Gradle 同步。

创建布局文件

应用程序将具有非常简单的用户界面——一个带有用户输入文本的 EditText,一个触发推断的按钮,以及一个显示推断结果的 TextView。以下是代码:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout

    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">
    <ScrollView
        android:id="@+id/scroll_view"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toTopOf="@+id/input_text">

        <TextView
            android:id="@+id/result_text_view"
            android:layout_width="match_parent"
            android:layout_height="wrap_content" />
    </ScrollView>
    <EditText
        android:id="@+id/input_text"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:hint="Enter Text Here"
        android:inputType="textNoSuggestions"
        app:layout_constraintBaseline_toBaselineOf="@+id/ok_button"
        app:layout_constraintEnd_toStartOf="@+id/ok_button"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintBottom_toBottomOf="parent" />
    <Button
        android:id="@+id/ok_button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="OK"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toEndOf="@+id/input_text"
        />
</androidx.constraintlayout.widget.ConstraintLayout>

注意三个控件的名称——输出被称为result_text_view,输入被称为input_text,按钮被称为ok_button

编写活动代码

在您的主活动中,编写代码非常简单。首先添加用于控制、分类器和模型的变量:

lateinit var outputText: TextView
lateinit var inputText: EditText
lateinit var btnOK: Button
lateinit var classifier: NLClassifier
var MODEL_NAME:String = "emotion-model.tflite"

然后,在您的onCreate中,您将初始化设置为lateinit的变量:

outputText = findViewById(R.id.result_text_view)
inputText = findViewById(R.id.input_text)
btnOK = findViewById(R.id.ok_button)
classifier = NLClassifier.createFromFile(applicationContext, MODEL_NAME);

当用户点击按钮时,您希望读取输入文本并将其传递给分类器。请注意,没有进行字典管理,因为所有内容都已内置到模型中。您只需获取一个字符串,并将其传递给classifier.classify()

btnOK.setOnClickListener{
    val toClassify:String = inputText.text.toString()
    val results:List<Category> = classifier.classify(toClassify)
    showResult(toClassify, results)
}

模型将返回一个Category对象的列表。这些对象包含有关分类的数据,例如分数和标签。在这种情况下,0 是负情绪的标签,1 是正情绪的标签。这些将映射到Category对象中的标签属性,并且每个标签的可能性在分数属性中。由于有两个标签,因此有两个输出,因此您可以检查每个的可能性。

因此,为了显示结果,我们可以遍历列表并将它们打印出来。这在showResult方法中实现:

private fun showResult(toClassify: String, results: List<Category>) {
    // Run on UI thread as we'll updating our app UI
    runOnUiThread {
        var textToShow = "Input: $toClassify\nOutput:\n"
        for (i in results.indices) {
            val result = results[i]
            textToShow += java.lang.String.format(
                    "    %s: %s\n",
                    result.label,
                    result.score
            )
        }
        textToShow += "---------\n"

        outputText.text = textToShow
    }
}

就是这么简单。通过使用 Model Maker,您已将字典嵌入到模型中,并通过使用 Model Maker 的 Android API(包含在您的 build.gradle 文件中),还为您处理了转换到和从张量的复杂性,因此您可以专注于简化您的 Android 应用程序代码。

要看它如何运作,请参见 图 10-8,在那里我输入了“今天过得很美好,我玩得很开心,感觉很愉快!”这段文本。

图 10-8. 具有正面情绪的文本输入

如您所见,这句话是积极的,神经元 0(负面)的值非常低,而神经元 1(正面)的输出得分非常高。如果您输入负面文本,比如,“今天真糟糕,我过得很糟糕,感觉很难过”,那么输出将被反转。参见 图 10-9。

图 10-9. 带有负面情绪的输出

诚然,这只是一个非常简单的例子,但它展示了使用 Model Maker 和语言模型的潜力,以及如何使它们在 Android 中更易于使用。

如果您在训练模型时使用基于 BERT 的规范来使用 Model Maker,那么代码将几乎不需要修改——只需在 Android 代码中使用BERTNLClassifier类代替NLClassifier!BERT 将为您提供更好的文本分类,例如可以减少假阳性和假阴性。但这将以使用更大的模型为代价。

概要

在本章中,您了解了在 Android 应用程序中使用自定义模型的考虑因素。您看到了它并不像简单地将模型放入应用程序并使用那样简单,以及如何管理 Android 数据结构与模型内部使用的张量之间的转换。对于图像和自然语言处理模型的常见场景,Android 开发者的建议是使用 Model Maker 创建您的模型,并使用其关联的 API 处理数据转换。不幸的是,iOS 开发者没有这样的便利,因此他们需要更深入地进行研究。我们将在 第十一章 中深入探讨这一点。

第十一章:在 iOS 中使用自定义模型

在第九章中,您已经查看了使用 TensorFlow Lite Model Maker、Cloud AutoML Vision Edge 和 TensorFlow 使用迁移学习创建自定义模型的各种场景。在本章中,您将看看如何将这些集成到 iOS 应用程序中。我们将专注于两种场景:图像识别和文本分类。如果您在阅读完第十章后来到这里,我们的讨论将非常相似,因为只需将模型放入应用程序中并不总是那么简单并且一切顺利。在 Android 中,使用 TensorFlow Lite Model Maker 创建的模型随附元数据和任务库,使集成变得更加容易。在 iOS 中,您没有同样级别的支持,并且将数据传递到模型并解析其结果将需要您以非常低级的方式处理将内部数据类型转换为模型理解的底层张量。完成本章后,您将了解如何基本完成这一操作,但是您的场景可能因数据而异!唯一的例外是,如果您正在使用 ML Kit 支持的自定义模型类型;我们将探讨如何在 iOS 中使用 ML Kit API 处理自定义模型。

将模型桥接到 iOS

当您训练模型并将其转换为 TensorFlow Lite 的 TFLite 格式时,您将获得一个二进制 blob,将其添加到您的应用程序作为资产。您的应用程序将加载这个二进制 blob 到 TensorFlow Lite 解释器中,您将需要在二进制级别为输入和输出张量编码。因此,例如,如果您的模型接受一个浮点数,您将使用具有该浮点数四个字节的 Data 类型。为了更轻松一些,我已经为本书的代码创建了一些 Swift 扩展。模式看起来会像图 11-1。

图 11-1。在 iOS 应用程序中使用模型

所以,例如,如果您考虑在第八章中使用的简单模型,该模型学习到数字之间的关系是 y = 2x − 1,您将传递一个单个浮点数,它将推断出一个结果。例如,如果您传递值 10,它将返回值 18.98 或接近它。进入的值将是一个浮点数,但实际上,您需要将浮点数的四个字节加载到一个传递给模型的缓冲区中。因此,例如,如果您的输入在变量 data 中,您将使用以下代码将其转换为缓冲区:

let buffer: UnsafeMutableBufferPointer<Float> =
            UnsafeMutableBufferPointer(start: &data, count: 1)

这将创建一个指针,指向存储数据的内存,并且由于使用了通用的 <Float> 并且您说数目是 1,因此缓冲区将是从数据地址起始的四个字节。看到我所说的关于将内存中的字节变得非常底层!

将该缓冲区作为 Data 类型复制到第一个输入张量的解释器中,就像这样:

try interpreter.copy(Data(buffer: buffer), toInputAt: 0)

当你调用解释器时,推断将会发生:

try interpreter.invoke()

而且,如果您想要获取结果,您需要查看输出张量:

let outputTensor = try interpreter.output(at: 0)

你知道outputTensor包含一个Float32作为结果,所以你必须将outputTensor中的数据转换为Float32

let results: [Float32] =
    Float32 ?? []

现在您可以访问结果了。在本例中,这是一个单一的值,非常简单。稍后,您将看到多个神经元输出的情况,例如在图像分类器中。

虽然这个示例非常简单,但对于更复杂的场景,您将使用相同的模式,所以在阅读本章时请牢记这一点。

您将把输入数据转换为底层数据的缓冲区。您将复制此缓冲区到解释器的输入张量中。您将调用解释器。然后,您将从输出张量中作为内存流读取数据,您将需要将其转换为可用的数据类型。如果您想要探索一个使用来自第八章中 y = 2x − 1 模型的迷你应用程序,您可以在本书的存储库中找到它。接下来,我们将看一个更复杂的例子——使用图像。虽然这种情况比您刚讨论的单浮点输入更复杂,但大部分模式是相同的,因为图像中的数据仍然非常结构化,并且读取底层内存的转换并不太困难。您将在本章末尾探索的最后一种模式是创建一个应用程序,该应用程序使用在自然语言处理(NLP)上训练的模型。在这种情况下,模型的输入数据——一个字符串——与模型识别的张量——一组标记化单词列表——完全不同,因此您将在那里更详细地探讨数据转换的方法论。但首先,让我们看一个基于自定义模型识别图像的图像分类器。

自定义模型图像分类器

本书前面(第六章)已经介绍了如何在 iOS 上使用 ML Kit 构建图像分类器。该基础模型预训练用于识别数百类图像,并且它表现良好,可以显示出图像中可能有猫的情况,或者,就像我们用来的狗的图像一样,模型将其同时识别为猫和狗!但对于大多数情况,您可能不希望能够识别通用图像;您需要更具体的内容。您想要构建一个应用程序,可以识别叶子上不同类型的作物疾病。您想要构建一个可以拍摄鸟类并告诉您其鸟类类型的应用程序,等等。

因此,在第八章中,您了解了如何使用 Python 中的 TensorFlow Lite Model Maker 快速训练一个模型,该模型可以从照片中识别五种不同种类的花。我们将以此作为模板,用于识别自定义模型的任何类型的应用程序。

由于这是一个基于图像的模型,使用 ML Kit 的自定义图像加载功能来构建应用程序有一个简单的解决方案,但在我们深入讨论之前,我认为看看在 iOS 和 Swift 中使用 ML Kit 不可用时如何使用模型是很好的。接下来的几个步骤中,您将会接触到低级别的内容,所以让我们做好准备吧!

步骤 1:创建应用程序并添加 TensorFlow Lite Pod

使用 Xcode,使用通常的流程创建一个简单的应用程序。如果您从本章开始阅读本书,请回顾一下 第三章 的过程。创建完应用程序后,关闭 Xcode,并在创建它的文件夹中添加一个名为 podfile(无扩展名)的文本文件,其中包含以下内容:

target 'Chapter11Flowers' do
  # Comment the next line if you're not using Swift and don't want to use dynamic
  # frameworks
  use_frameworks!

  # Pods for Chapter11Flowers
    pod 'TensorFlowLiteSwift'

end

在这种情况下,我的应用程序名称为 Chapter11Flowers,正如您所看到的,我们正在为其添加一个名为 TensorFlowLiteSwift 的 Pod。运行 **pod install** 让 CocoaPods 为您安装依赖项。完成后,您可以重新打开 Xcode 并加载为您创建的 .xcworkspace 文件(不是 .xcproject!)。

步骤 2:创建 UI 和图像资产

您可以看到自定义图像分类在一个具有非常简单用户界面的应用程序中是如何工作的。在 图 11-2 中,我们有一个应用程序运行时的屏幕截图片段。

图 11-2. 带有自定义图像模型的应用程序

该应用程序预装了几种不同类型的花朵,通过按“上一个”和“下一个”按钮,您可以在它们之间导航。按“分类”按钮,它将告诉您模型从图像中推断出的花朵类型。要使应用程序保持简单,我只是预装了一些花朵图像。要设计此内容,您可以打开 Main.storyboard 并设计故事板,使其看起来像 图 11-3。

图 11-3. 设计应用程序的故事板

使用 Ctrl+拖动,您可以将控件拖到 ViewController.swift 上创建输出和操作。

对于三个按钮,请创建名为 prevButtonnextButtonclassifyButton 的操作。

您应该为名为 imageView 的 UIImageView 创建一个输出。您应该为名为 lblOutput 的 UILabel 创建一个输出。

定制模型设计用于识别五种花朵——雏菊、蒲公英、玫瑰、向日葵或郁金香。因此,您可以下载这些花朵的任何图像并嵌入到您的应用程序中。为了简化编码,请确保在将它们放入应用程序之前将图像重命名为 1.jpg2.jpg 等。您还可以使用我在 GitHub 仓库中提供的图像。

要向应用程序添加图像,请打开 Assets.xcassets 文件夹,并将图像拖动到资源导航器中。例如,查看图 11-4。要将图像添加为资产,只需将其拖动到当前显示为 AppIcon 下方的区域,Xcode 将完成其余工作。

图 11-4. 将资产添加到您的应用程序

您可以看到,我有六张图像,我将它们命名为 1.jpg2.jpg 等等,添加后它们变成了命名为资产 1、2 等等的资产。现在,您已经准备好开始编码了。

步骤 3: 加载并浏览图像资产

由于图像资产是编号的,现在通过上一张和下一张按钮加载和浏览它们变得很容易。通过一个称为 currentImage 的类级变量,它由上一张和下一张按钮更改,并且一个名为 loadImage 的函数也从 viewDidLoad 调用,您可以在资产中导航并渲染这些图像:

var currentImage = 1
// The previous button changes the value of the current image.
// If it's <=0, set it to 6 (we have 6 images)
@IBAction func prevButton(_ sender: Any) {
    currentImage = currentImage - 1
    if currentImage<=0 {
        currentImage = 6
    }
    loadImage()
}
// The next button changes the value of the current image.
// If it's >=7, set it to 1 (we have 6 images)
@IBAction func nextButton(_ sender: Any) {
    currentImage = currentImage + 1
    if currentImage>=7 {
        currentImage = 1
    }
    loadImage()
}

override func viewDidLoad() {
    super.viewDidLoad()
    // Do any additional setup after loading the view.
    loadImage()
}

loadImage 函数将只加载与 currentImage 同名的图像资产:

// The load image function takes the image from the bundle.
// The name within the bundle is just "1", "2" etc.
// so all you need to do is UIImage(named: "1") etc. --
// so String(currentImage) will do the trick
func loadImage(){
    imageView.image = UIImage(named: String(currentImage))
}

步骤 4: 加载模型

此时,您需要一个模型。您可以按照第八章中的步骤自己创建一个花朵模型,或者如果您愿意,只需使用我为您创建的一个模型,您可以在此应用的存储库中找到它。它将在此应用程序的文件夹中,我称之为 Chapter11Flowers

要加载模型,您首先需要告诉解释器可以在哪里找到它。模型应包含在您的应用程序包中,因此您可以使用如下代码指定它:

let modelPath = Bundle.main.path(forResource: "flowers_model",
                                 ofType: "tflite")

TensorFlow Lite 解释器是您之前安装的 pods 的一部分,您需要导入其库以使用它:

import TensorFlowLite

然后,要实例化一个解释器,并让它加载您之前指定的模型,您可以使用这样的代码:

var interpreter: Interpreter
do{
    interpreter = try Interpreter(modelPath: modelPath!)
} catch _{
    print("Error loading model!")
    return
}

您现在已经加载了一个解释器到内存中并准备就绪。所以下一步您需要做的是提供一个它可以解释的图像!

步骤 5: 将图像转换为输入张量

这一步骤非常复杂,因此在深入研究代码之前,让我们通过可视化的方式来探索这些概念。参考图 11-1,您会注意到 iOS 可以将图像存储为 UIImage,这与模型训练识别的张量非常不同。因此,首先让我们了解一下图像通常如何存储在内存中。

图像中的每个像素由 32 位或 4 字节表示。这些字节是红色、绿色、蓝色和 alpha 通道的强度。参见图 11-5。

图 11-5. 图像如何存储在内存中

因此,例如,如果您的图像是 1000 × 1000 像素,那么用于存储它的内存将是一百万组并发的 4 字节。此块中的第一组 4 字节将是左上角的像素,下一个像素将是下一组字节,依此类推。

当您用 TensorFlow(Python 中)训练模型以识别图像时,您会使用代表图像的张量来训练模型。这些张量通常仅包含红、绿和蓝通道,而不包含 alpha 通道。此外,这些红、绿和蓝通道不是字节内容,而是归一化的字节内容。例如,在 Figure 11-4 中,突出显示的像素的红通道是 11011011,即 219。有许多方法可以进行归一化处理,但我们选择最简单的方法,即将其除以 255,因为字节的值范围在 0 到 255 之间,因此如果我们希望将其映射到 0 到 1 的范围内,我们只需除以 255。因此,这个像素的红通道将由值为 219/255 的浮点数表示。类似地,绿色和蓝色通道分别由 4/255 和 5/255 表示。(查看 Figure 11-4,您会看到绿色通道是 100,蓝色通道是 101,这分别是 4 和 5 的二进制表示)

但是 iOS 不允许我们像 TensorFlow 那样将数据结构化为张量,因此我们必须将张量的值写入原始内存,并使用Data值类型进行映射。因此,对于图像,您需要逐像素地提取红/绿/蓝通道作为字节,并创建三个并发的浮点数,这些浮点数包含这些字节除以 255 的值。您将对图像中的每个像素执行此操作,并将生成的Data块传递给解释器,然后解释器将把它切片成适当的张量!在此之前,您还需要做的一件事是确保图像是模型所识别的正确尺寸。因此,对于我们的假设性 1000 × 1000 图像,我们需要将其调整大小为模型识别的大小。对于移动模型,通常是 224 × 224。

现在让我们回到代码!首先,您可以从currentImage变量创建一个UIImage

let image = UIImage(named: String(currentImage))

UIImage类型暴露了一个CVPixelBuffer属性,可以让您执行诸如裁剪图像之类的操作,您可以像这样获取它:

var pixelBuffer:CVPixelBuffer
pixelBuffer = image!.pixelBuffer()!

有很多种方法可以将当前图像转换为 224 × 224,包括缩放它,但为了保持简单,我将使用像素缓冲区的centerThumbnail属性,它将在图像中找到最大的正方形,并将其重新缩放为 224 × 224:

// Crops the image to the biggest square in the center and
// scales it down to model dimensions.
let scaledSize = CGSize(width: 224, height: 224)
let thumbnailPixelBuffer =
        pixelBuffer.centerThumbnail(ofSize: scaledSize)

现在我们有了一个 224 × 224 的图像,但每像素仍然是 32 位。我们希望将其拆分为红色、绿色和蓝色通道,并将它们加载到数据缓冲区中。这个缓冲区的大小将是 224 × 224 × 3 字节,因此在下一步中,您将创建一个名为rgbDataFromBuffer的辅助函数,该函数接受像素缓冲区并切片通道,将它们排列为一系列字节。您将调用该函数,并让它返回一个类似于Data的对象:

let rgbData = rgbDataFromBuffer(
    thumbnailPixelBuffer!, byteCount: 1 * 224 * 224 * 3)

现在我们将进入非常低级的地方,请做好准备!接收 CVPixelBuffer 并返回 Data 的辅助函数的签名应如下所示:

private func rgbDataFromBuffer(
    _ buffer: CVPixelBuffer, byteCount: Int) -> Data? {
}

它返回一个 Data?,因为这是解释器希望我们发送的。稍后您将看到这一点。

首先,您需要获取一个指向内存地址的指针(在本例中称为 mutableRawPointer),该内存地址存放缓冲区。请记住,此缓冲区是您创建的图像的 224 × 224 裁剪部分:

CVPixelBufferLockBaseAddress(buffer, .readOnly)
defer { CVPixelBufferUnlockBaseAddress(buffer, .readOnly) }
guard let mutableRawPointer =
            CVPixelBufferGetBaseAddress(buffer)
               else {
                     return nil
               }

您还需要缓冲区的大小,我们将其称为 count。称其为 count 而不是 size 或类似名称可能看起来有点奇怪,但是正如您将在代码的下一行中看到的,当您创建 Data 对象时,它期望一个名为 count 的参数,这个 count 是字节的计数!无论如何,要获取缓冲区的大小,您可以像这样使用 CVPixelBufferGetDataSize

let count = CVPixelBufferGetDataSize(buffer)

现在您有了指向像素缓冲区位置的指针以及其大小,您可以像这样创建一个 Data 对象:

let bufferData = Data(bytesNoCopy: mutableRawPointer,
                      count: count, deallocator: .none)

每个 8 位通道都需要从中提取出来,并转换为浮点数,然后除以 255 进行归一化。因此,对于我们的 rgbData,让我们首先创建一个与图像中的字节数相同大小的 Float 数组(记住它存储在 byteCount 参数中的 224 × 224 × 3 中):

var rgbBytes = Float

现在您可以逐字节查看缓冲区数据。每四个字节中的一个将是 alpha 通道组件,因此您可以忽略它。否则,您可以读取字节,将其值除以 255 进行归一化,然后将归一化后的值存储在当前索引的 rgbBytes 中:

var index = 0
for component in bufferData.enumerated() {
  let offset = component.offset
  let isAlphaComponent = (offset % 4) == 3
  guard !isAlphaComponent else { continue }
  rgbBytes[index] = Float(component.element) / 255.0
  index += 1
}

现在您有了一系列归一化字节序列,对解释器来说看起来像包含图像的张量,您可以像这样返回它作为 Data

return rgbBytes.withUnsafeBufferPointer(Data.init)

下一步将是将此 Data 对象传递给解释器并获得推理结果。

第六步:获取张量的推理结果

到此为止,我们已经将来自图像的数据格式化为一个 Data,其中包含每个像素的红色、绿色和蓝色通道作为包含每个通道归一化数据的 Float。当解释器读取此 Data 时,它将把它识别为输入张量,并逐浮点数读取。首先,让我们初始化解释器并为输入和输出张量分配内存。您将在应用程序中的 getLabelForData 函数中找到此代码:

// Allocate memory for the model's input tensors.
try interpreter.allocateTensors()

解释器将读取原始数据,因此我们必须将数据复制到解释器为其输入张量分配的内存位置:

// Copy the RGB data to the input tensor.
try interpreter.copy(data, toInputAt: 0)

请注意,我们在这里只处理单个图像输入和单个推理输出,这就是为什么我们将其放在输入 0 上的原因。您可以执行批量推理,一次加载多个图像以对它们进行推理,因此您可以将此处的 0 改为第 n 张图像的 n

现在如果我们调用解释器,它将加载数据,对其进行分类,并将结果写入其输出张量:

// Run inference by invoking the `Interpreter`.
try interpreter.invoke()

我们可以通过其.output属性访问解释器的输出张量。与输入类似,在这种情况下,我们一次处理一张图像,因此其输出位于索引 0。如果我们批量处理图像,则第n张图像的推理将位于索引n处。

// Get the output tensor to process the inference results.
outputTensor = try interpreter.output(at: 0)

记住,这个模型是在五种不同类型的花上训练的,因此模型的输出将是五个值,每个值表示图像包含特定花的概率。按字母顺序排列,我们识别的花包括雏菊、蒲公英、玫瑰、向日葵和郁金香,因此这五个值将对应于这些花的概率。例如,第一个输出值将是图像包含雏菊的可能性,依此类推。

这些值是概率,因此它们的取值范围在 0 到 1 之间,并且被表示为浮点数。你可以读取输出张量并将其转换为数组,就像这样:

let resultsArray =
    outputTensor.data.toArray(type: Float32.self)

现在,如果你想确定图像中包含的最可能的花卉,你可以回到纯 Swift,获取最大值,找到该值的索引,并查找与该索引对应的标签!

// Pick the biggest value in the array
let maxVal = resultsArray.max()
// Get the index of the biggest value
let resultsIndex = resultsArray.firstIndex(of: maxVal!)
// Set the result to be the label at that index
let outputString = labels[resultsIndex!]

然后,你可以在用户界面中呈现输出字符串,以展示推理结果,就像我在图 11-2 中所做的那样。

就是这样!虽然在低级内存中使用指针和缓冲区处理起来有些复杂,但这是一个理解在原生类型和张量之间转换数据复杂性的好练习。

如果你不想做得那么底层,但仍在使用图像,还有另一种选择,即使用 ML Kit 并让其使用你的自定义模型而不是其标准模型。这也很容易做到!接下来你将看到。

在 ML Kit 中使用自定义模型

在第 6 章中,你看到了如何构建一个简单的应用程序,该应用程序使用 MLvKit 的图像标签 API 来识别数百种图像类别,但与前面的例子一样,无法处理更具体的事物,如花的类型。为此,你需要一个自定义模型。ML Kit 可以支持这一点,只需进行一些小的调整,你就可以让它加载你的自定义模型并运行推理,而不是使用其基础模型。本书的存储库包含原始应用程序(在第 6 章文件夹中)以及更新为自定义模型的应用程序(在第 11 章文件夹中)。

首先,更新你的 Podfile 以使用GoogleMLKit/ImageLabelingCustom而不是GoogleMLKit/ImageLabeling

platform :ios, '10.0'
# Comment the next line if you're not using Swift and don't want to use dynamic
# frameworks
use_frameworks!

target 'MLKitImageClassifier' do
        pod 'GoogleMLKit/ImageLabelingCustom'
end

运行**pod install**之后,你的应用程序现在将使用ImageLabelingCustom库而不是通用的ImageLabeling库。要使用这些库,你需要导入它们,因此在你的视图控制器顶部,你可以添加:

// Import the MLKit Vision and Image Labeling libraries
import MLKit
import MLKitVision
// Update this to MLKitImageLabelingCustom if you are adapting the base model
// sample
import MLKitImageLabelingCommon
import MLKitImageLabelingCustom

对于自定义模型,你可以使用 MLKit 的LocalModel类型。你可以使用以下代码从包中加载你的自定义模型(flowers_model.tflite,如前面的演示):

// Add this code to use a custom model
let localModelFilePath = Bundle.main.path(
        forResource: "flowers_model", ofType: "tflite")
let localModel = LocalModel(path: localModelFilePath!)

使用基础模型,你需要设置一个ImageLabelerOptions对象。对于自定义模型,你将需要使用CustomImageLabelOptions

// Create Image Labeler options, and set the threshold to 0.4
// to ignore all classes with a probability of 0.4 or less
let options = CustomImageLabelerOptions(
                         localModel: localModel)
options.confidenceThreshold = 0.4

现在,你将使用自定义选项创建ImageLabeler对象,该对象将加载本地模型:

// Initialize the labeler with these options
let labeler = ImageLabeler.imageLabeler(options: options)

其他一切都与之前一样!与以前必须手动将原始图像转换为Data并将其表示为张量的示例相比,你现在使用的代码要少得多,并且你不必读取重新转换为数组以获取结果的输出内存。因此,如果你正在构建图像分类器,我强烈建议你使用 ML Kit(如果可以的话)。如果不能,我希望提供两种方法对你有用!

你可以在图 11-6 中看到更新后的应用程序的屏幕截图。这里我使用了雏菊的图片,ML Kit 的引擎使用我的自定义模型返回了雏菊的推断,概率为 0.96!

图 11-6. 使用自定义花卉模型的 ML Kit 应用程序

当在移动设备上构建基于 ML 的模型时,理解底层数据结构总是很有用的。我们将探索另一个使用自然语言处理的应用程序场景,这样你就可以更深入地了解如何在 Swift 中使用模型。我们将再次采用原始数据的方法,就像我们在图像示例中使用的那样,但这次我们将探索一个设计用于识别文本和文本情感的模型!

使用 Swift 构建自然语言处理应用程序

在着手构建应用程序之前,理解自然语言处理模型的基础工作是很有用的,这样你就能看到在设备上处理文本字符串和模型张量之间的数据交换如何工作。

首先,当你在一组文本(称为语料库)上训练模型时,你会将模型理解的词汇限制在最多这些语料库中的单词。因此,例如,在这个应用程序中使用的模型是在第八章 中使用数千条推文的文本进行训练的。只有这些推文集中使用的单词才会被模型识别。所以,例如,如果你想在你的应用程序中使用“反教会主义”的词来分类一个句子,那么这个词在语料库中并不存在,因此你的模型会忽略它。你需要的第一件事是模型训练时使用的词汇表,即它确实识别的单词集。第八章 中的笔记本中有代码来导出这个词汇表,以便可以下载并在你的应用程序中使用。此外,我说最多是指这些单词,因为如果你考虑一下,语料库中可能只有一两次使用的单词。你通常可以通过忽略那些单词来调整模型,使其更小、更好、更快。这超出了我们在这里所做的范围,因此在这种情况下,请假设词汇表将是语料库中所有单词的整体集合,当然,这应该是所有单词的一个小子集!

其次,模型不是在单词上训练的,而是在代表这些单词的标记上训练的。这些标记是数字,因为神经网络使用数字!它们在词汇表中进行索引,并且 TensorFlow 将按照单词的频率对词汇表进行排序。因此,例如,在 Twitter 语料库中,“今天”这个词是第 42 个最流行的。它将由数字 44 表示,因为标记 0 到 2 保留用于填充和超出词汇表范围的标记。因此,当你试图对用户输入的字符串进行分类时,你将需要将字符串中的每个单词转换为其相关的标记。同样,为此你将需要词典。

第三,由于你的单词将由标记表示,因此你不会向模型传递一个单词字符串,而是一个称为序列的标记列表。你的模型是在固定序列长度上进行训练的,因此如果你的句子比该长度短,你将不得不进行填充以适应。或者如果你的句子更长,你将不得不截断以适应。

这一切都发生在你将标记序列转换为底层张量之前!这里有很多步骤,所以我们将逐步探索它们,当我们构建应用程序时。

图 11-7 展示了实际应用程序的外观。有一个编辑文本字段,用户可以输入类似“今天是一个真正有趣的一天!我感觉很棒!😃”的文本,当用户触摸“分类”按钮时,模型将解析文本以获取情感。结果呈现——在这种情况下,你可以看到负面情感的概率约为 7%,而正面情感约为 93%。

让我们看看构建这样一个应用程序所需的必要步骤!我假设您已经创建了一个应用程序,像前面展示的那样添加了 TensorFlow Lite pod,并为输入添加了一个 UITextView(带有名为 txtInput 的 outlet),为输出添加了一个 UILabel(带有名为 txtOutput 的 outlet),并在按钮上创建了一个名为 classifySentence 的操作。完整的应用程序位于本书的仓库中,所以我只会回顾您需要执行的 NLP 特定编码工作。

图 11-7. 解析情感

第 1 步:加载词汇表

当您使用 Model Maker 创建模型时(请参阅 第八章),您能够从 Colab 环境中下载模型以及一个名为 vocab 的词汇文件。将该词汇文件重命名为 vocab.txt 并添加到您的应用中。确保它包含在包中,否则您的应用在运行时将无法读取它。

然后,要使用词汇表,您需要一个包含键-值对的字典。键是一个字符串(包含单词),值是一个int(包含单词的索引),如下所示:

var words_dictionary = [String : Int]()

然后,要加载字典,您可以编写一个名为 loadVocab() 的辅助函数。让我们探讨它的功能。首先,通过定义 filePath,将 vocab.txt 指定为您要加载的文件:

if let filePath = Bundle.main.path( forResource: "vocab",
                                    ofType: "txt") {}

如果找到了,那么括号内的代码将执行,所以您可以将整个文件加载到一个 String 中:

let dictionary_contents = try String(contentsOfFile: filePath)

然后,您可以按换行符分割这些行以获取一组行:

let lines = dictionary_contents.split(
                                  whereSeparator: \.isNewline)

您可以通过此进行迭代,以每行空格分割。在文件中,您将看到词汇表中每行都有一个单词和其后跟随的标记,用空格分隔。这可以为您提供键和值,因此您可以用它们加载 words_dictionary

for line in lines{
    let tokens = line.components(separatedBy: " ")
    let key = String(tokens[0])
    let value = Int(tokens[1])
    words_dictionary[key] = value
}

为了方便起见,这里是完整的函数:

func loadVocab(){
// This func will take the file at vocab.txt and load it
// into a hash table called words_dictionary. This will
// be used to tokenize the words before passing them
// to the model trained by TensorFlow Lite Model Maker
    if let filePath = Bundle.main.path(
                         forResource: "vocab",
                         ofType: "txt") {
        do {
            let dictionary_contents =
               try String(contentsOfFile: filePath)
            let lines =
               dictionary_contents.split(
                      whereSeparator: \.isNewline)
            for line in lines{
                let tokens = line.components(separatedBy: " ")
                let key = String(tokens[0])
                let value = Int(tokens[1])
                words_dictionary[key] = value
            }
        } catch {
            print("Error vocab could not be loaded")
        }
    } else {
        print("Error -- vocab file not found")
    }
}

现在字典已加载到内存中,下一步是将用户的输入字符串转换为一系列标记。您将在接下来看到这一步。

注意

接下来的几个步骤将使用一些用于处理低级内存的复杂 Swift 扩展。详细介绍这些扩展如何工作已超出本书的范围,通常情况下,这些是您可以在自己的应用程序中以几乎不需要修改的代码进行重用的代码。

第 2 步:将句子转换为序列

正如前面讨论的,在创建语言模型时,您需要对一系列标记进行训练。此序列长度固定,因此如果您的句子更长,您将对其进行修剪至该长度。如果较短,则填充至该长度。

语言模型的输入张量将是一系列 4 字节整数,因此,为了开始创建它,您将初始化您的序列为Int32,所有这些整数都是 0,在词汇表中,0 表示一个未找到的单词,在字典中用 <Pad> 表示(用于填充!)(注:如果您从仓库克隆了它,您将在应用的 convert_sentence 函数中看到此代码。)

var sequence = Int32

这里有一些 Swift 代码,可以将一个字符串拆分为单词,同时去除标点符号和多个空格:

sentence.enumerateSubstrings(
   in: sentence.startIndex..<sentence.endIndex,
   options: .byWords) {(substring, _, _, _) -> ()
                       in words.append(substring!) }

这将给你一个称为 words 的单词列表数据结构。循环遍历这个数据结构非常简单,如果单词作为 words_dictionary 中的键存在,你可以将它的值添加到序列中。请注意将其添加为 Int32

var thisWord = 0
for word in words{
    if (thisWord>=SEQUENCE_LENGTH){
        break
    }
    let seekword = word.lowercased()
    if let val = words_dictionary[seekword]{
        sequence[thisWord]=Int32(val)
        thisWord = thisWord + 1
    }
}

一旦你完成这里,sequence 将会包含你的单词编码为 Int32 的序列。

步骤 3:扩展数组以处理不安全数据

你的序列是一个 Int32 数组,但是 Swift 会围绕这个做一些结构。为了让 TensorFlow Lite 能够读取它,需要按顺序读取原始字节,你可以通过扩展 Array 类型来处理这些不安全的数据。这是 Swift 的一个很好的特性,你可以扩展类型。下面是完整的代码:

extension Array {

init?(unsafeData: Data) {
    guard unsafeData.count % MemoryLayout<Element>.stride == 0 else
      { return nil }
    #if swift(>=5.0)
    self = unsafeData.withUnsafeBytes
      { .init($0.bindMemory(to: Element.self)) }
    #else
    self = unsafeData.withUnsafeBytes {
      .init(UnsafeBufferPointer<Element>(
        start: $0,
        count: unsafeData.count / MemoryLayout<Element>.stride
      ))
    }
    #endif  // swift(>=5.0)
  }
}

我不会详细说明这个函数做了什么,但最终的想法是它将使用 Swift 的 init 功能来初始化一个新的数组,其中包含 Data 构造函数中的 unsafeBytes。在 Swift 5.0+ 中,你可以使用 bindMemory 将底层内存复制到新数组;否则,你将使用 unsafeData.withUnsafeBytes 从原始缓冲区的开头复制,以及 unsafeData 的数量。

要使用你之前创建的序列创建一个输入张量,你可以简单地使用:

let tSequence = Array(sequence)

这将用于创建传递给解释器的 Data 类型。你将在下一步中看到这个。

步骤 4:将数组复制到数据缓冲区

现在你有了一个 Int32 数组,仅使用 Int32 的底层字节;它称为 tSequence。这需要复制到一个 Data 中,以便 TensorFlow 能够解析它。最简单的方法是扩展 Data 来处理你将从中复制的缓冲区。以下是扩展代码:

extension Data {
  init<T>(copyingBufferOf array: [T]) {
    self = array.withUnsafeBufferPointer(Data.init)
  }
}

这将仅通过从输入数组(称为 array)复制不安全的缓冲区数据来初始化 Data。要使用此数据创建新的 Data,可以使用以下代码:

let myData =
    Data(copyingBufferOf: tSequence.map { Int32($0) })

正如你所见,这将通过映射 tSequence,使用 Int32 类型来创建 myData。现在你有了 TensorFlow Lite 可以解释的数据!

步骤 5:对数据进行推理并处理结果

在第 4 步之后,你将得到 myData,它是一个包含组成表示你的句子的序列的 Int32 的原始数据缓冲区。因此,你现在可以通过分配张量来初始化解释器,然后将 myData 复制到第一个输入张量。如果你使用书中的代码,你将在 classify 函数中找到这段代码:

try interpreter.allocateTensors()
try interpreter.copy(myData, toInputAt: 0)

然后你将调用解释器,并获取 outputTensor

try interpreter.invoke()
outputTensor = try interpreter.output(at: 0)

张量将输出一个包含两个值的数组,一个用于负面情感,一个用于正面情感。这些值在 0 到 1 之间,因此你需要将数组转换为 Float32 类型来访问它们:

let resultsArray = outputTensor.data
let results: [Float32] =
      Float32 ?? []

现在相对容易(终于!)通过读取数组中的前两个条目来访问这些值:

let negativeSentimentValue = results[0]
let positiveSentimentValue = results[1]

这些数值随后可以被处理,或者简单输出,就像我在这个应用中所做的那样;你可以在 图 11-7 中看到。

概要

在 iOS 上使用 TensorFlow 的机器学习模型需要你在加载数据到模型进行推断并解析时,进行相当低层次的内存管理。在本章中,你探索了如何处理图像,需要从底层图像中切片通道字节的红、绿和蓝色,对它们进行归一化,并将它们写入作为浮点值的 Data 缓冲区,使用 TensorFlow Lite 解释器加载。你还看到了如何解析模型的输出——以及为什么理解模型架构至关重要,在本例中,该模型具有五个输出神经元,包含了图像是五种不同花卉的概率。相比之下,你看到 ML Kit 通过使用其更高级别的 API,使这种场景变得更加容易,如果你正在构建被 ML Kit 场景覆盖的模型,我强烈推荐这种方法,而不是自己处理原始的位和字节!在另一个数据管理练习中,你还看到了一个简单的自然语言处理应用程序,其中你想要对字符串进行分类,以及如何首先对该字符串进行标记化,然后将其转换为序列,并将该序列的类型映射到一个原始缓冲区,然后将其传递给引擎。ML Kit 或任何其他高级 API 不支持这种情况,因此重要的是要动手探索如何实现!我希望这两个实例及为其创建的扩展能够使你在创建应用程序时更加轻松。在下一章中,我们将摆脱 TensorFlow Lite 转向 Core ML 和 Create ML 的 iOS 特定 API。

第十二章:使用 Firebase 将你的应用产品化

到目前为止,在本书中,你已经探索了如何使用机器学习创建模型,并学习了如何使用多种技术将其集成到 Android 或 iOS 应用中。你可以选择使用 TensorFlow Lite 低级 API 直接使用模型,并处理数据转换的过程。或者,针对多种常见场景,你可以利用 ML Kit 使用高级 API 和异步编程方法来构建更易于开发的响应式应用程序。然而,在所有这些情况下,你只是构建了一个非常简单的应用,在单个活动或视图中进行推断。

当谈到产品化应用时,你当然需要更进一步,而 Firebase 被设计为跨平台解决方案,旨在帮助你构建、增长和从你的应用中赚取收入。

尽管本书不涵盖 Firebase 的全面讨论,但 Firebase 中有一个重要功能在免费(即 Spark)层中可用,你可以真正利用它:自定义模型托管。

为什么使用 Firebase 自定义模型托管?

正如你在整本书中所看到的,为用户解决问题创建 ML 模型并不难。得益于诸如 TensorFlow 或 TensorFlow Lite 模型制作器这样的工具,这相对来说是相当简单的,它可以根据你的数据快速训练一个模型。难的是创建正确的模型,显而易见的假设是,要能够做到这一点,你需要不断地测试和更新你的模型,验证它如何执行,不仅从速度或准确性的角度来看,还要看它如何影响用户使用你的应用。正确的模型是否能带来更好的参与度?错误的模型是否意味着用户会放弃你的应用?它是否会导致与广告或应用内购买的更多互动?

Firebase 的目标是通过分析、A/B 测试、远程配置等手段帮助你回答所有这些问题。

但是在使用 ML 模型时,当然,为了能够有效地提问这些问题,你需要一种方法来部署多个模型,并根据这些模型对你的受众进行分割。你已经创建了一个模型的 v1 版本,并且它运行良好。你从用户那里学到了很多关于它的信息,并收集了新数据来创建一个新模型。你希望将其部署到一些用户那里进行测试,并进行仔细监控的推出。

你将如何继续进行?

那么,对于使用 ML 模型的开发者来说,Firebase 自定义模型托管可以是通往 Firebase 平台上其余服务的入口。在本章中,我们将探讨一个场景,称为“远程配置”,如果你感兴趣,你可以从那里扩展到平台上其他可用的服务。

因此,为了开始,让我们首先创建一个场景,在这个场景中,我们有多个模型,为此,我们将返回到 TensorFlow Lite 模型制作器。

注意

在使用 Firebase 控制台时,您可能会注意到不同 API 的多个瓷砖。实际上,这些是用于 ML Kit 的,我们在之前的章节中已经涵盖过!它曾经是 Firebase 的一部分,然后独立出来,但在控制台中仍然可以找到与之相关的链接。

创建多个模型版本

对于这种情况,您可以使用 TensorFlow Lite Model Maker 创建多个模型的简单测试。不需要使用不同的数据集来查看不同模型的行为,您也可以使用不同的基础规格创建多个模型。由于 Model Maker 在底层使用迁移学习,它是创建不同模型的理想工具,您理论上可以将不同版本部署给不同用户,以查看哪种架构最适合您的情况。

如果我们回到之前章节中的“flowers”示例,我们可以获取我们的数据,并像这样将其分成训练集和验证集:

url = 'https://storage.googleapis.com/download.tensorflow.org/' + \
  'example_images/flower_photos.tgz'

image_path = tf.keras.utils.get_file('flower_photos.tgz', url,
                                     extract=True)
image_path = os.path.join(os.path.dirname(image_path),
                          'flower_photos')
data = DataLoader.from_folder(image_path)
train_data, validation_data = data.split(0.9)

然后,使用 TensorFlow Lite Model Maker,我们可以创建一个图像分类器并导出它,如下所示:

model = image_classifier.create(train_data,
                              validation_data=validation_data)
model.export(export_dir='/mm_flowers1/')

mm_flowers目录中,现在会有一个 TensorFlow Lite 模型和相关元数据,您可以在您的应用程序中下载并使用,如第九章所探讨的。

您会注意到,您只需调用image_classifier.create而不定义任何类型的规格。这将使用 EfficientNet 模型作为默认的底层模型类型创建一个图像分类器模型。选择这种模型架构是因为它被认为是一种先进的图像分类器,适用于非常小的模型,因此非常适合移动设备。您可以在https://tfhub.dev/google/collections/efficientnet/1了解更多关于 EfficientNet 的信息。

然而,有一个被称为 MobileNet 的模型架构系列,正如其名字所示,非常适合移动场景。因此,如果您创建一个使用 MobileNet 作为基础架构的模型,并将其作为第二个模型,那将如何呢?您可以将基于 EfficientNet 的模型部署给一些用户,将基于 MobileNet 的模型部署给另一些用户。然后,您可以衡量这些模型的效果,帮助您决定向所有用户推出哪个版本。

因此,要在 TensorFlow Lite Model Maker 中创建一个 MobileNet 模型,您可以使用 spec 参数来覆盖默认设置,就像这样:

spec=model_spec.get('mobilenet_v2')

model = image_classifier.create(train_data, model_spec=spec,
                              validation_data=validation_data)
model.export(export_dir='/mm_flowers2/')

在模型训练完成后,您现在将会得到另一个基于 MobileNet 的 TFLite 模型,这个模型存在于mm_flowers2目录中。下载它并与第一个模型分开保管。您将在下一节将它们都上传到 Firebase。

使用 Firebase 模型托管

Firebase 模型托管使您能够在 Google 的基础设施中托管模型。这些模型可以被您的应用程序下载和使用,因此,如果您的用户已连接,您可以管理他们使用哪些模型以及如何下载它们。您将在本节中探索这一点,但首先,您需要创建一个项目。

步骤 1:创建 Firebase 项目

要使用 Firebase,您需要使用 Firebase 控制台创建一个 Firebase 项目。要开始使用,请访问http://firebase.google.com。您可以尝试演示并观看有关 Firebase 的视频。准备好后,点击“开始”。参见图 12-1。

图 12-1. 开始使用 Firebase

单击此按钮后,您将进入控制台页面,显示您现有项目的列表。如果这是您第一次使用,您将只看到“添加项目”按钮,如图 12-2 所示。

图 12-2. Firebase 控制台

注意,这些截图是使用 Firebase 控制台的美国版本进行的;您的体验可能略有不同,但基本概念是相同的。

单击“添加项目”按钮,您将进入一个向导,逐步指导您完成项目创建过程。您将从项目名称开始。参见图 12-3。

图 12-3. 给您的项目命名

如您所见,我将其命名为“multi-flowers”,但您可以选择任何您喜欢的名称!点击“继续”,它将询问您是否要为项目启用 Google Analytics。我建议保持默认设置,即启用它们。您可以在图 12-4 中看到这些分析功能的完整列表。

下一步是创建或使用 Google Analytics 帐户,如图 12-5 所示。

图 12-4. 添加 Google Analytics

图 12-5. 配置 Google Analytics

如果您还没有帐户,单击“选择帐户”下拉菜单将显示“创建新帐户”的选项。参见图 12-6。

图 12-6. 创建新的 Google Analytics 帐户

完成此操作后,您可以检查分析设置,接受条款后即可创建项目。参见图 12-7。

图 12-7. Google Analytics 配置选项

可能需要一些时间,但一旦 Firebase 完成操作并创建了您的项目,您将看到类似于图 12-8,但项目名称将替换为“multi-flowers”。

图 12-8. Firebase 完成创建您的项目

您现在可以使用此项目使用 Firebase 了!在下一步中,您将配置模型托管!

步骤 2:使用自定义模型托管

在上一节中,您已经经历了创建新的 Firebase 项目的步骤,可以用来托管多个模型。为此,请首先找到 Firebase 控制台中的机器学习部分。您应该在屏幕右侧看到一个黑色工具栏,其中包含所有 Firebase 工具。其中一个看起来像一个小机器人头像。参见 图 12-9。

图 12-9. 在 Firebase 控制台中找到机器学习入口

选择此选项,您将看到一个“开始使用”的选项。这将带您进入 Firebase 控制台的机器学习页面。屏幕顶部会显示三个标签页:API、自定义和 AutoML。选择自定义以查看 TensorFlow Lite 模型托管屏幕。参见 图 12-10。

图 12-10. 自定义模型托管

在屏幕中央,您将看到一个大蓝色按钮添加自定义模型。单击它,您将被带过一系列步骤来托管您的模型。确保您之前有两个模型,并完成这些步骤。

因此,例如,如果您有基于 EfficientNet 的模型,您可以通过称其为“flowers1”来开始上传。参见 图 12-11。

图 12-11. 开始托管一个模型

点击“继续”,然后您可以将第一个创建的模型拖放到表单上。之后,您将看到一个代码片段,您可以使用它来访问该模型。稍后会用到它。重复此过程以第二个模型,称之为“flowers2”,您将看到类似 图 12-12 的内容。

图 12-12. 托管多个模型

现在您已经拥有了模型,可以开始在您的应用程序中使用它们。在下一步中,您将看到如何将 Firebase 集成到 Android 应用程序中,以便您可以看到如何在应用程序中使用 flowers1。之后,您将通过远程配置进行扩展,以便某些用户获得 flowers1,而其他用户获得 flowers2。

步骤 3:创建一个基本的 Android 应用程序

在此步骤中,您将创建一个简单的 Android 应用程序,该应用程序将使用托管模型对花卉进行基本的模型推理。首先,使用 Android Studio 创建一个新的应用程序,使用空活动模板。将其命名为“multi-flowers”。本章节不会分享应用程序的所有代码,但如果您需要代码,可以在存储库中找到完整的应用程序。

要完成以下示例并展示六种不同的花卉图像,请编辑以下文件(请注意,它与 第十章 的 flowers 示例 相同)。

这里有一个为简洁起见切掉的片段:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout

    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:padding="8dp"
    android:background="#50FFFFFF"
    >

    <LinearLayout android:orientation="horizontal"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:gravity="center"
        android:layout_marginBottom="4dp"
        android:layout_weight="1">

        <ImageView
            android:id="@+id/iv_1"
            android:layout_width="0dp"
            android:layout_weight="1"
            android:scaleType="centerCrop"
            android:layout_height="match_parent"
            android:src="@drawable/daisy"
            android:layout_marginEnd="4dp"
            />

        <ImageView android:layout_width="0dp"
            android:id="@+id/iv_2"
            android:layout_weight="1"
            android:layout_height="match_parent"
            android:scaleType="centerCrop"
            android:layout_marginStart="4dp"
            android:src="@drawable/dandelion"/>

    </LinearLayout>

   ...

</LinearLayout>

您可能会注意到这些 ImageView 控件引用了蒲公英和雏菊等图像。您应将这些图像添加到布局目录中的应用程序中。您可以从存储库获取这些图像 here

如果现在启动应用程序,除了显示花朵之外,不会有太多其他操作。在继续之前,让我们来看看如何将 Firebase 添加到应用程序中!

步骤 4:将 Firebase 添加到应用中

Android Studio 包含 Firebase 集成,使您可以轻松在 Android 应用程序中使用 Firebase 功能。您可以在工具菜单中找到它。参见图 12-13。

aiml_1213.png

图 12-13. 访问 Firebase 工具

选择此选项将带您到屏幕右侧的 Firebase 助手窗格。您将使用它来添加 Firebase 和 Firebase 远程配置到您的应用程序。使用助手查找远程配置。选择“设置 Firebase 远程配置”,如图 12-14 所示。

aiml_1214.png

图 12-14. 使用远程配置

窗格将变成要遵循的一系列步骤,第一步是连接到 Firebase。按下此按钮。您的浏览器将打开并导航至 Firebase 控制台。从那里,您应该选择在本章前面创建的项目。您将看到一个类似图 12-15 的屏幕,显示您的 Firebase Android 应用程序已连接到 Firebase。

aiml_1215.png

图 12-15. 将您的应用连接到 Firebase

点击连接按钮,当准备好时,返回 Android Studio,您将看到您的应用已连接。助手中的第二个选项是“将远程配置添加到您的应用程序。”点击按钮。会弹出一个对话框,告诉您包含远程配置所需的更改。它将向您的 build.gradle 添加条目,然后同步您的 Gradle 文件。

在继续之前,还要将 TensorFlow Lite、Vision 任务库和其他 Firebase 库添加到您的应用级 build.gradle 文件中:

implementation platform('com.google.firebase:firebase-bom:28.0.1')
implementation 'com.google.firebase:firebase-ml-modeldownloader-ktx'

implementation 'org.tensorflow:tensorflow-lite:2.3.0'
implementation 'org.tensorflow:tensorflow-lite-task-vision:0.1.0'

将 Firebase 连接到您的应用程序就是这么简单!

步骤 5:从 Firebase 模型托管获取模型

你之前将模型上传到 Firebase 模型托管中,其中 flowers1 是基于 EfficientNet 的模型的名称,flowers2 是基于 MobileNet 的模型的名称。

此应用程序的完整代码可以在 https://github.com/lmoroney/odmlbook/tree/main/BookSource/Chapter12/MultiFlowers 上找到。

创建一个从 Firebase 模型托管加载模型的函数。在其中,您应该设置一个 CustomModelDownloadConditions 对象,如下所示:

val conditions = CustomModelDownloadConditions.Builder()
    .requireWifi()
    .build()
注意

在 GitHub 存储库的示例应用程序中,此功能称为 loadModel

一旦完成了上述步骤,您可以使用 FirebaseModelDownloader 获取模型。这会公开一个 getModel 方法,允许您传入表示模型的字符串名称(即“flowers1”或“flowers2”),以及根据您之前创建的条件来下载模型。它还公开了一个 addOnSuccessListener,在模型成功下载时调用:

FirebaseModelDownloader.getInstance()
      .getModel(modelName,
                DownloadType.LOCAL_MODEL_UPDATE_IN_BACKGROUND,
                conditions)
            .addOnSuccessListener { model: CustomModel ->
            }

onSuccessListener回调中,您可以实例化一个ImageClassifier,使用模型(ImageClassifier来自您在build.gradle中包含的 TensorFlow Lite 任务库):

val modelFile: File? = model.file
if (modelFile != null) {

    val options: ImageClassifier.ImageClassifierOptions = ImageClassifier.
    ImageClassifierOptions.builder().setMaxResults(1).build()

    imageClassifier = ImageClassifier.createFromFileAndOptions(modelFile, options)

    modelReady = true

    runOnUiThread { Toast.makeText(this, "Model is now ready!",
                    Toast.LENGTH_SHORT).show() }
}

回调返回一个名为modelCustomModel实例,可以将其传递给ImageClassifiercreateFromFileAndOptions以实例化模型。为了使后续编码更容易,我们使用选项仅返回一个结果。完成此操作后,模型已准备就绪,我们可以使用它进行推理。

使用任务 API 进行推理非常简单。我们将图像转换为TensorImage,并将其传递给imageClassifierclassify方法。它将返回一组结果,第一个条目将包含我们的答案,我们可以从中提取标签和分数:

override fun onClick(view: View?) {
  var outp:String = ""
  if(modelReady){
    val bitmap = ((view as ImageView).drawable as
                                     BitmapDrawable).bitmap
    val image = TensorImage.fromBitmap(bitmap)
    val results:List<Classifications> =
              imageClassifier.classify(image)

    val label = results[0].categories[0].label
    val score = results[0].categories[0].score
    outp = "I see $label with confidence $score"
  } else {
    outp = "Model not yet ready, please wait or restart the app."
  }

  runOnUiThread {
      Toast.makeText(this, outp, Toast.LENGTH_SHORT).show() }
}

现在,当您运行应用程序时,您将看到当用户选择一个花朵时,推理结果将弹出Toast。下一步是设置远程配置,以便不同用户获取不同的模型。

步骤 6: 使用远程配置

Firebase 中的(众多)服务之一是远程配置,可用于改进使用机器学习的应用程序。现在让我们来看看如何设置它,以便一些用户将获得flowers1模型,而其他用户将获得flowers2模型。

首先找到 Firebase 控制台中的远程配置部分。它看起来像两个分叉的箭头,如 Figure 12-16 所示。

Figure 12-16. 在 Firebase 控制台中找到远程配置部分

完成此操作后,您将看到“添加参数”的能力,您需要在其中指定参数键和默认值。例如,您可以分别使用“model_name”和“flowers1”,如 Figure 12-17 所示。

Figure 12-17. 初始化远程配置

现在,您可以从远程配置中读取“flowers1”作为模型名称,而不是将其硬编码到应用程序中。但这并不是非常有用的。远程配置真正显示其威力的地方在于,当您选择右上角的“为条件添加值”时。

选择此选项后,您将看到一个“定义新条件”的按钮。选择它,您将获得条件的对话框。参见 Figure 12-18。

Figure 12-18. 定义新条件

完成条件命名后,您可以选择“适用于…”下拉列表,以指定条件。例如,如果您希望特定国家/地区的用户获得不同的值,您可以在“适用于…”对话框中选择国家/地区,并选择您想要的国家。在 Figure 12-19 中,您可以看到我选择了两个国家(爱尔兰和塞浦路斯),并相应地命名了条件。

Figure 12-19. 按国家设置条件

点击“创建条件”后,你将返回到“添加参数”对话框,在那里你可以指定符合该条件的用户的值。例如,请参见图 12-20,我指定了 ireland_and_cyprus_users 队列的用户将得到 flowers2,而其他人将得到 flowers。

图 12-20. 为条件用户添加不同的值

这是一个有些傻乎乎的测试示例,因为我没有任何用户,更别说在爱尔兰或塞浦路斯有用户了。所以让我们稍微改变一下。通过点击条件右边的黑色“x”来删除 ireland_and_cyprus_users 队列。然后点击添加一个新参数。如果需要“发布更改”,则请执行此操作。

发布后,配置远程配置的对话框看起来会有些不同,但没关系,它仍然有效。使用“添加参数”按钮添加一个新参数,并称其为“random_users”。添加一个条件,适用于随机百分位并指定 50%。参见图 12-21。

图 12-21. 在 50%百分位数中添加随机用户

对于这些用户,请确保其值为 flowers2,其余为 flowers1。你的对话框应该看起来像图 12-22。

图 12-22. 给一半用户送花花 2

确保配置已发布,然后你可以继续下一步。

步骤 7:在你的应用程序中读取远程配置

返回你的应用程序并添加以下方法,它将获取远程配置的实例,读取它,然后从中获取模型名称的值。

首先设置一个远程配置对象,本例中仅设置为一小时后超时。然后使用fetchAndActivate方法从远程配置中读取变量。然后在运行时,Firebase 将确定此用户属于哪个队列,并根据远程变量为其分配 flowers1 或 flowers2 的值:

private fun initializeModelFromRemoteConfig(){
  mFirebaseRemoteConfig = FirebaseRemoteConfig.getInstance()
  val configSettings = FirebaseRemoteConfigSettings.Builder()
    .setMinimumFetchIntervalInSeconds(3600)
    .build()

  mFirebaseRemoteConfig.setConfigSettingsAsync(configSettings)
  mFirebaseRemoteConfig.fetchAndActivate()
    .addOnCompleteListener(this) { task ->
      if (task.isSuccessful) {
        val updated = task.result
        Log.d("Flowers", "Config params updated: $updated")
        Toast.makeText(this@MainActivity,
                       "Fetch and activate succeeded",
                       Toast.LENGTH_SHORT).show()

        modelName = mFirebaseRemoteConfig.getString("model_name")
      } else {
        Toast.makeText(this@MainActivity,
                       "Fetch failed - using default value",
                        Toast.LENGTH_SHORT).show()
        modelName = "flowers1"
      }
      loadModel()
      initViews()
    }
  }

完成后,将调用loadModel()initViews()方法。回想一下,你之前在onCreate事件中调用了它们,所以请从那里删除它们,并用调用这个新方法替换它们:

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)
    initializeModelFromRemoteConfig()
}

现在当你启动你的应用程序时,你将随机得到 flowers1 或 flowers2 作为模型的值!

下一步

鉴于现在一半的用户将会收到 flowers1,另一半将会收到 flowers2,例如,你可以添加分析来查看推断的性能并记录下来。哪些用户得到了更快的推断?或者,例如,你还可以检查用户活动,看看是哪些用户从你的应用中退出,以及是否是模型的结果。除了分析,你还可以运行 A/B 测试,根据行为进行预测等等!

虽然每个应用的需求都不同,但希望这能为你在使用 Firebase 扩展 ML 应用时提供一些灵感。如果你需要从能够使用分析、预测和远程配置等功能来扩展应用的案例中获得一些启发,可以查看https://firebase.google.com/use-cases

摘要

在本章中,你了解了如何使用 Firebase 模型托管与 TensorFlow Lite 模型,然后探索了如何利用 Firebase 的其他基础设施,从远程配置开始。通过这些技术的结合,例如,你可以管理不同受众中的多个模型版本或类型,并探索将模型传递给用户的最佳方法。我们只是触及了可能性的表面,我鼓励你探索其他选项!尽管我们刚刚在 Android 上探索了 Firebase,但这些 API 在 iOS 和 Web 上同样适用。

提到 iOS,关于设备端机器学习的书籍如果不涉及 iOS 特有的技术 Core ML 和 Create ML,就显得不完整了,因此你将在第十三章中探索它们!

第十三章:为简单的 iOS 应用程序创建 ML 和 Core ML

到目前为止,您已经看到了将机器学习带到多个设备的技术,以便您可以使用单个 API 来访问 Android、iOS、嵌入式系统、微控制器等。这得益于 TensorFlow 生态系统,特别是 TensorFlow Lite,它支持 ML Kit,您可以将其用作更高级别的 API。虽然我们没有深入讨论嵌入式系统和微控制器,但概念是相同的,除了硬件限制随着尺寸的缩小而变得更小。要了解更多关于这个领域的信息,请参阅 Pete Warden 和 Daniel Situnayake(O’Reilly)的优秀著作 TinyML

但是如果我不至少涵盖 Apple 的 iOS 特定的 Create ML 工具和 Core ML 库,我会觉得遗憾。这些工具旨在让您在为 iOS、iPadOS 或 MacOS 创建应用程序时使用 ML 模型。特别是 Create ML 是一个非常好的可视化工具,让您可以在没有任何先前 ML 编程经验的情况下创建模型。

我们将看几种场景,首先是创建一个类似之前使用 TensorFlow 和 TensorFlow Lite 做的花卉识别模型。

使用 Create ML 构建的 Core ML 图像分类器

我们将从创建我们的模型开始。我们可以使用 Create ML 工具无需编写代码来完成这个过程。您可以通过右键单击 Dock 中的 Xcode,然后在“打开开发者工具”菜单中找到 Create ML。请参阅 图 13-1。

图 13-1. 启动 Create ML

当工具启动时,首先会询问您 想要 存储完成的模型的位置。如果您不习惯这种方式,可能会感到有些突兀,因为通常在选择位置之前会通过模板选择类型。这让我几次以为这是来自另一个打开应用的文件对话框!从对话框中,在左下角选择新文档(图 13-2)。

图 13-2. 使用 Create ML 开始新模型

在选择位置并单击新文档后,您将获得一个模板列表,用于创建 Create ML 的模型类型。请参见 图 13-3。

图 13-3. 选择 Create ML 模板

在这种情况下,我们将进行图像分类模型,因此选择图像分类并单击“下一步”。您将被要求为项目命名,并填写其他细节,如作者、许可证、描述等。填写完毕后,单击“下一步”。

然后再次询问您想要 哪里 存储模型。您可以创建一个新文件夹并放入其中,或者只需单击“创建”。模型设计师将会打开。您可以在 图 13-4 中看到它。

若要在 Create ML 的模型设计师中训练模型,您需要一组图像。您需要将它们组织成每种要分类的特定类型项目的子文件夹(即标签),因此,例如,如果您考虑我们在本书中一直使用的花卉数据集,您的目录结构可能看起来像 图 13-5。如果您从 Google API 目录下载并解压这些花卉,它们已经处于这种结构中。您可以在 https://oreil.ly/RuN2o 找到这些数据。

图 13-4. 模型设计师

图 13-5. 图像存储在带标签的子目录中

因此,在这种情况下,名为daisy的文件夹包含雏菊的图片,dandelion包含蒲公英的图片,依此类推。要训练数据集,请将此文件夹拖放到模型设计师的训练数据部分上。完成后,它应该看起来像 图 13-6。

图 13-6. 将数据添加到设计师

请注意,图 13-5 中显示了五个文件夹,这对应于 图 13-6 中显示的五个类别。在这些类别之间,共有 3,670 张图片。还请注意,该工具将通过从训练数据中分割来自动创建验证数据集。这为您节省了大量工作!在这种情况下,一部分图像将被保留在训练集之外,以便在每个 epoch 中使用先前未见过的图像对模型进行测试。这样,您可以更好地估计其准确性。

请注意,您可以在屏幕底部选择增强选项。这使您可以在训练过程中通过修改来人为扩展数据集的范围。例如,花卉的图片通常是底部为茎,顶部为花瓣的方式拍摄的。如果您的训练数据是这样定向的,那么只有采用相同方向的花朵图片才能准确分类。如果您给它一张侧躺的花朵图片,它可能无法准确分类。因此,与采取大量拍摄其他方向花朵图片的昂贵行动相比,您可以使用增强技术。例如,如果您勾选旋转框,那么在训练过程中,某些图像将被随机旋转,以模拟您拍摄新花朵图片的效果。如果您的模型过度拟合于训练数据——即它在识别看起来像训练数据的数据方面非常擅长,但对于其他图像效果不佳——则值得研究不同的增强设置。但目前您不需要它们。

准备好后,点击屏幕左上角的“训练”按钮。Create ML 将处理图片中的特征,并在几分钟后向你呈现一个训练好的模型。请注意,这里使用的是迁移学习,而不是从头开始训练,类似于 Model Maker,因此训练既准确快速。

当模型在准确度指标上稳定一段时间后,通常被认为已经收敛,即继续训练不太可能使其变得更好,因此会提前停止。Create ML 用于训练模型的默认时代数是 25,但花卉模型可能会在大约 10 次时达到收敛状态,此时你会看到其准确度指标看起来有点像图 13-7。

图 13-7. 模型收敛

你可以点击评估选项卡,查看模型在每个不同类别上的表现。在左侧,你可以选择训练集、验证集或测试集。由于我在这个实例中没有创建测试集,所以我只有前两个,你可以在图 13-8 中看到训练结果。

在这种情况下,你可以看到有 594 张雏菊图片用于训练,39 张用于验证。其他花卉也有类似的分割。有两列,精确度和召回率,其中精确度是分类器正确分类图像的百分比,即在 594 个雏菊中,分类器 95%的时间能正确识别为雏菊。在这种情况下,召回率的值应该非常接近准确率值,通常只有当图片中除了特定花卉之外还有其他元素时才需要注意。由于这个数据集很简单,即一个雏菊图片包含一个雏菊,或者玫瑰图片包含一朵玫瑰,所以可以放心忽略它。你可以在维基百科上了解更多关于精确度和召回率的信息。

图 13-8. 探索训练准确度

你可以转到预览选项卡,并将图片拖放到其中以测试模型。例如,在图 13-9 中,我放入了不属于训练集或验证集的图片,并检查了分类结果。如你所见,它以 99%的置信度正确识别了这些郁金香。

图 13-9. 使用预览测试我的模型

最后,在输出选项卡中,你可以导出模型。你会看到左上角有一个名为“获取”的按钮。点击它,你将有选项保存 MLModel 文件。将其保存为类似flowers.mlmodel这样简单的名称,你将在下一步中在 iOS 应用中使用它。

制作一个使用 Create ML 模型的 Core ML 应用

现在让我们探讨这在应用程序中的表现。您可以在本书的存储库中获取完整的应用程序,因此我不会详细介绍如何设置用户界面。它将有六张存储为资产命名为“1”到“6”的图像,并有按钮允许用户在这些图像间切换,以及一个分类按钮来执行推理。您可以在 第 13-10 图 中看到此故事板。

第 13-10 图。花卉分类器的故事板

添加 MLModel 文件

要添加您使用 Create ML 创建的 MLModel 文件,只需将其拖放到 Xcode 项目窗口中即可。Xcode 将导入该模型 为其创建一个 Swift 封装类。如果您在 Xcode 中选择该模型,您应该可以看到其包括标签列表、版本、作者等的许多详细信息。参见 第 13-11 图。

第 13-11 图。浏览模型

您甚至可以像在 Create ML 中一样在预览选项卡中测试模型!在 Utilities 选项卡中,您还可以对模型进行加密并准备进行云部署。这超出了本书的范围;您可以在 Apple 开发者网站 上找到详细信息。

最后,在屏幕顶部中心的 Model Class 部分,您可以看到自动生成的 Swift 模型类,本例中称为“flower”。您可以点击它查看自动生成的代码。需要注意的重要事项是名称——在本例中是“flower”,因为您稍后会用到它。

运行推理

当用户按下按钮时,我们希望加载当前图像,并将其传递给 Core ML 来调用我们的模型并进行推理。在深入研究这个过程的代码之前,最好先回顾一下使用的编码模式,因为它有些复杂。

Core ML 推理模式

您可以在使用 Core ML 的应用程序中使用此模型。这个 API 已经被设计成在 iOS 应用程序中使用 ML 模型变得很容易,但是在理解使用 Core ML 构建 ML 的整体模式之前,它可能看起来有些复杂。

Core ML 的理念是尽可能确保异步性能,并且模型推理可能是一个瓶颈。由于 Core ML 设计为移动 API,它使用模式确保在进行模型推理时用户体验不会中断或中断。因此,在 Core ML 应用程序中使用像这样的图像模型,您会看到许多异步步骤。您可以在 第 13-12 图 中看到这一点。

第 13-12 图。使用 Core ML 异步推理图像并更新 UI

模式是在调度队列内创建一个处理程序,以确保异步性。这由 Figure 13-12 中较大的向下箭头表示。这个处理程序将是一个VNImageRequestHandler,因为我们正在进行图像分类(VN 代表“VisioN”)。这个处理程序将执行分类请求。

分类请求(类型为VNCoreMLRequest)将初始化模型,并指定一个请求到模型,带有一个处理结果的回调函数。这个回调将在成功的VNCoreMLRequest时发生。

回调通常是异步的,因为它更新 UI,并读取分类结果(作为VNClassificationObservation),并将它们写入 UI。这由 Figure 13-12 中较小的调度队列箭头表示。

编写代码

现在让我们来探索这段代码。当用户执行按按钮的操作时,你将调用一个名为interpretImage的函数来启动推理工作流程,代码如下所示:

func interpretImage(){
    let theImage: UIImage = UIImage(named: String(currentImage))!
    getClassification(for: theImage)
}

这仅仅是从当前选定的图像创建一个 UIImage,并将其传递给名为getClassification的函数。这个函数将实现来自 Figure 13-10 的模式,所以让我们来探索一下。我已经缩短了输出字符串,以使这里打印的代码更易读:

func getClassification(for image: UIImage) {

    let orientation = CGImagePropertyOrientation(
        rawValue: UInt32(image.imageOrientation.rawValue))!
    guard let ciImage = CIImage(image: image)
      else { fatalError("...") }

    DispatchQueue.global(qos: .userInitiated).async {
        let handler = VNImageRequestHandler(
            ciImage: ciImage, orientation: orientation)
        do {
            try handler.perform([self.classificationRequest])
        } catch {
            print("...")
        }
    }
}

代码将首先获取我们的 UIImage,并将其转换为 CIImage。Core ML 是使用 Core Image 构建的,它要求图像以那种格式表示。因此,我们需要从那里开始。

然后,我们将调用我们的第一个 DispatchQueue,这是 Figure 13-10 中较大的外部之一。在其中,我们将创建我们的处理程序,并要求它在classificationRequest上执行其 perform 方法。我们还没有创建它,所以现在让我们来探索一下:

lazy var classificationRequest: VNCoreMLRequest = {
    do {
        let model = try VNCoreMLModel.init(for: flower().model)
        let request = VNCoreMLRequest(model: model,
          completionHandler: { [weak self] request, error in
            self?.processResults(for: request, error: error)
        })
        request.imageCropAndScaleOption = .centerCrop
        return request
    } catch {
        fatalError("...")
    }
}()

classificationRequest是一个VNCoreMLRequest,适用于它内部初始化的模型。请注意,init方法接受一个flower()类型,并从中读取model属性。这是当你导入 MLModel 时自动生成的类。参考 Figure 13-11,你会看到讨论过的自动生成的代码。你注意到了你的类的名称——在我的情况下是 flower——这就是你将在这里使用的。

一旦你有了模型,你可以创建VNCoreMLRequest,指定模型和完成处理函数,在这种情况下是processResults。现在你已经构建了VNCoreMLRequest,这是getClassification函数所需的。如果你回头看那个函数,你会看到它调用了perform方法;这段代码实现了这一点。如果运行成功,将调用processResults回调函数,那么让我们接着看:

func processResults(for request: VNRequest, error: Error?) {
  DispatchQueue.main.async {
    guard let results = request.results else {
            self.txtOutput.text = "..."
            return
        }

    let classifications = results as! [VNClassificationObservation]

    if classifications.isEmpty {
        self.txtOutput.text = "Nothing recognized."
    } else {
        let topClassifications =
            classifications.prefix(self.NUM_CLASSES)
        let descriptions = topClassifications.map
        { classification in

            return String(format: "  (%.2f) %@",
                          classification.confidence,
                          classification.identifier) }
        self.txtOutput.text = "Classification:\n" +
                              descriptions.joined(separator: "\n")
        }
    }
}

此函数以另一个 DispatchQueue 开始,因为它将更新用户界面。它接收来自初始请求的结果,如果它们有效,它可以将它们转换为一组 VNClassificationObservation 对象。然后只需遍历这些对象,获取每个分类的置信度和标识符,并输出它们。这段代码还将它们排序为前几个分类,为每个类别提供概率输出。NUM_CLASSES 是一个表示类别数量的常数,在花卉模型中我将其设置为 5。

就是这样。使用 Create ML 简化了制作模型的过程,而 Xcode 集成,包括类文件生成,使推理过程相对简单。复杂性必须通过尽可能使过程异步化来保持,以避免在运行模型推理时破坏用户体验!

您可以看到应用程序在一张玫瑰图片的推理结果,参见图 13-13。

图 13-13. 花卉的 Core ML 推理

接下来我们将探讨一个自然语言处理(NLP)的例子,首先是使用 Create ML 创建模型。

使用 Create ML 构建文本分类器

Create ML 允许您导入 CSV 文件进行分类,但您的文本必须在名为 “text” 的列中,因此如果您一直在跟随本书并使用情感情绪数据集,您需要稍作修改,或者使用我在本章节中提供的数据集。唯一的修改是将包含文本的第一列命名为 “text”。

在这一点上,您可以按照之前概述的步骤创建一个新的 Create ML 文档,但在这种情况下,请选择文本分类模板。与之前一样,您可以将数据拖放到数据字段中,您会看到有两类(在这种情况下用于正面和负面情感),共有超过 35,000 个条目。您应该像之前一样将验证数据与训练数据分开。

在参数部分,有多种算法选项。我发现选择迁移学习,并选择动态嵌入提取器可以获得非常好的结果。这将会很慢,因为所有嵌入都将从头开始学习,但可以得到非常好的结果。使用这些设置进行训练会很慢——对我来说,在 M1 Mac Mini 上,大约需要一个小时,但完成后,训练精度达到了 89.2%,经过 75 次迭代。

预览选项卡允许您输入一个句子,它将被自动分类!请参见图 13-14,我输入了一个明显是负面句子的例子,我们可以看到它以 98% 的置信度命中标签 0!

图 13-14. 测试负面情感

但当然,这并不是真的。我在写这一章节和玩弄这项技术时过得非常愉快,所以让我看看如果我改变文本来适应会发生什么!参见 Figure 13-15。

图 13-15. 具有正面情感的句子

正如你在那个案例中看到的那样,标签 1 以 94% 的置信度得分。真正酷的是,分类会随着你的输入即时更新!

总之,玩够了。让我们回到工作中来。要构建一个使用这个的应用程序,你首先需要导出你的模型。你可以在输出选项卡中这样做。使用“获取”按钮保存它,并给它一个容易记住的名称。在我的案例中,我称之为 emotion.mlmodel

在应用程序中使用模型

像这样的语言模型在应用程序中使用起来非常简单。创建一个新的应用程序,并添加一个带有名为 txtInput 的输出口的 UITextView,一个带有名为 txtOutput 的输出口的 UILabel,以及一个带有名为 classifyText 的操作的按钮。你的故事板应该看起来像 Figure 13-16。

图 13-16. 一个简单语言应用程序的故事板

classifyText 操作中,添加一个调用 doInference() 的语句。这个函数目前还不存在;你马上会添加它。你的类顶部的代码应该是这样的:

@IBOutlet weak var txtInput: UITextView!
@IBOutlet weak var txtOutput: UILabel!
@IBAction func classifyText(_ sender: Any) {
    doInference()
}

要使用带有自然语言处理的 Core ML,你还应该确保导入这两个库:

import NaturalLanguage
import CoreML

现在你可以进行推断了。将你之前创建的模型拖放到 Xcode 中,它将为你生成一个与保存模型同名的类。在我的案例中,我称之为“emotion”,所以我会有一个同名的类。

你将从使用 emotion 创建一个 mlModel 类型开始,就像这样:

let mlModel = try emotion(
                  Configuration: MLModelConfiguration()).model

一旦你拥有了这个,你可以依次使用它来创建一个 NLModel(NL 代表自然语言):

let sentimentPredictor = try NLModel(mlModel: mlModel)

你可以从 txtInput 中读取输入字符串,并将其传递给 sentimentPredictor 以获取其标签:

let inputText = txtInput.text
let label = sentimentPredictor.predictedLabel(for: inputText!)

此标签将是一个表示类别的字符串。正如你在这个模型的数据中看到的那样,它们是 "0""1"。因此,你可以简单地输出预测结果,如下所示:

if (label=="0"){
    txtOutput.text = "Sentiment: Negative"
} else {
    txtOutput.text = "Sentiment: Positive"
}

就是这样!正如你所看到的,自然语言库使这变得非常简单!你不必处理标记化或嵌入;只需给它一个字符串,你就完成了!

你可以在 Figure 13-17 中看到应用程序的运行情况。

图 13-17. 使用情感分类器

这只是一个非常简单的应用程序,但你可以看到你如何使用它来为你的应用程序创建新功能;例如,检测应用程序是否被用于发送垃圾邮件或有毒消息,并阻止用户发送它们。这可以与后端安全性结合使用,以确保最佳用户体验。

总结

本章向您介绍了 Create ML 中的两个模板——图像分类和文本情感分析,并指导您在没有机器学习经验的情况下训练模型,然后在简单的应用程序中使用它们。您看到了 Create ML 如何为您提供一个工具,可以非常快速地训练模型,通常使用迁移学习,并且其输出可以轻松集成到 Xcode 中,利用代码生成来封装机器学习模型的复杂性,让您专注于用户界面。在进行图像分类时,您经历了一个复杂的交互过程,确保在进行推断时不会破坏用户体验。尽管如此,对您来说编写一些管理推断的东西仍然相当容易;特别是,您无需担心图像格式以及将图像剥离成张量以传递给推断引擎。因此,如果您仅为 iOS 编写,并且不考虑其他平台,Create ML 和 Core ML 是一个很好的选择,绝对值得一试。

第十四章:从移动应用访问基于云的模型

在整本书中,您一直在创建模型并将其转换为 TensorFlow Lite 格式,以便在移动应用中使用。出于本书讨论的原因,这对于希望在移动设备上使用的模型非常有效,例如延迟和隐私。然而,也许有时您不想将模型部署到移动设备上——也许它对移动设备来说太大或太复杂,也许您希望频繁更新它,或者可能您不希望冒风险使其被逆向工程并且您的知识产权被他人使用。

在这些情况下,您将需要将模型部署到服务器上,在那里执行推断,然后由服务器管理客户端的请求,调用模型进行推断,并以结果响应。这一高层视图如图 14-1 所示。

图 14-1。模型服务器架构的高层视图

此架构的另一个好处是管理模型漂移。当您将模型部署到设备时,如果用户无法或不愿更新他们的应用程序以获取最新模型,可能会出现多个模型的情况。考虑一下希望模型漂移的情况;也许拥有更高端硬件的人可以使用更大更精确的模型版本,而其他人则可以获得较小且稍微不太精确的版本。管理这一切可能很困难!但如果模型托管在服务器上,您就不必担心这些问题,因为您可以控制模型运行的硬件平台。服务器端模型推断的另一个优势是,您可以轻松地针对不同受众测试不同的模型版本。参见图 14-2。

图 14-2。使用托管推断管理不同模型

在这里,您可以看到我有两个不同版本的模型(称为 Model v1 和 Model v2),这些模型通过负载均衡器部署到不同的客户端。在图中,我展示了这些模型是由 TensorFlow Serving 管理的,接下来我们将探讨如何安装和使用它,包括对其进行简单模型的训练和部署。

安装 TensorFlow Serving

TensorFlow Serving 可以使用两种不同的服务器架构进行安装。第一种是tensorflow-model-server,这是一个完全优化的服务器,使用特定于平台的编译器选项针对各种架构进行编译。总体而言,这是首选的选项,除非您的服务器机器不支持这些架构。另一种选择是tensorflow-model-server-universal,它使用基本的优化进行编译,应该可以在所有机器上工作,并且在tensorflow-model-server不可用时提供一个很好的备用选项。有多种方法可以安装 TensorFlow Serving,包括使用 Docker 和直接使用apt进行软件包安装。接下来我们将看看这两种选项。

使用 Docker 安装

Docker 是一个工具,允许您将操作系统及其软件依赖项封装到一个简单易用的镜像中。使用 Docker 可能是快速启动并运行的最简单方式。要开始使用,使用 docker pull 命令获取 TensorFlow Serving 软件包:

docker pull tensorflow/serving

完成这些步骤后,从 GitHub 上克隆 TensorFlow Serving 代码:

git clone https://github.com/tensorflow/serving

这包括一些示例模型,包括一个称为 Half Plus Two 的模型,它接受一个值,并返回该值的一半加两。为此,首先设置一个名为 TESTDATA 的变量,其中包含示例模型的路径:

TESTDATA="$(pwd)/serving/tensorflow_serving/servables/tensorflow/testdata"

您现在可以从 Docker 镜像中运行 TensorFlow Serving:

docker run -t --rm -p 8501:8501 \
    -v "$TESTDATA/saved_model_half_plus_two_cpu:/models/half_plus_two" \
    -e MODEL_NAME=half_plus_two \
    tensorflow/serving &

这将在端口 8501 上实例化一个服务器——稍后在本章中您将详细了解如何做到这一点——并在该服务器上执行模型。然后,您可以访问 http://localhost:8501/v1/models/half_plus_two:predict 来访问该模型。

要传递您希望运行推断的数据,您可以将包含这些值的张量 POST 到此 URL。以下是使用 curl 的示例(如果在开发机器上运行,请在另一个终端中运行此命令):

curl -d '{"instances": [1.0, 2.0, 5.0]}' \
    -X POST http://localhost:8501/v1/models/half_plus_two:predict

您可以在 Figure 14-3 中查看结果。

图 14-3. 运行 TensorFlow Serving 的结果

虽然 Docker 镜像确实很方便,但您可能也希望完全控制在本地安装它。接下来您将了解如何操作。

在 Linux 上直接安装

无论您使用 tensorflow-model-server 还是 tensorflow-model-server-universal,软件包名称都是相同的。因此,在开始之前最好删除 tensorflow-model-server,以确保您获取正确的版本。如果您想在自己的硬件上尝试此操作,我在 GitHub 仓库中提供了 一个 Colab notebook 与相关代码:

apt-get remove tensorflow-model-server

然后将 TensorFlow package source 添加到您的系统:

echo "deb http://storage.googleapis.com/tensorflow-serving-apt stable tensorflow-model-server tensorflow-model-server-universal" |
tee /etc/apt/sources.list.d/tensorflow-serving.list && \
curl https://storage.googleapis.com/tensorflow-serving-apt/tensorflow-serving.release.pub.gpg | apt-key add -

如果您需要在本地系统上使用 sudo,可以像这样操作:

sudo echo "deb http://storage.googleapis.com/tensorflow-serving-apt stable tensorflow-model-server tensorflow-model-server-universal" |
sudo tee /etc/apt/sources.list.d/tensorflow-serving.list && \
curl https://storage.googleapis.com/tensorflow-serving-apt/tensorflow-serving.release.pub.gpg | sudo apt-key add -

接下来您需要更新 apt-get

apt-get update

一旦完成这些步骤,您就可以使用 apt 安装模型服务器:

apt-get install tensorflow-model-server

您可以通过以下方式确保您使用的是最新版本:

apt-get upgrade tensorflow-model-server

现在该包应该已经准备好使用了。

构建和服务模型

在本节中,我们将完整演示创建模型、准备模型进行服务、使用 TensorFlow Serving 部署模型,然后运行推理的整个过程。

你将使用我们在整本书中探索的简单的“Hello World”模型:

import numpy as np
import tensorflow as tf
xs = np.array([-1.0,  0.0, 1.0, 2.0, 3.0, 4.0], dtype=float)
ys = np.array([-3.0, -1.0, 1.0, 3.0, 5.0, 7.0], dtype=float)
model = tf.keras.Sequential([tf.keras.layers.Dense(units=1, input_shape=[1])])
model.compile(optimizer='sgd', loss='mean_squared_error')
history = model.fit(xs, ys, epochs=500, verbose=0)
print("Finished training the model")
print(model.predict([10.0]))

这应该会非常快速地训练,并在被询问当 x 是 10.0 时预测 y 时给出约为 18.98 的结果。接下来,需要保存模型。你需要一个临时文件夹来保存它:

export_path = "/tmp/serving_model/1/"
model.save(export_path, save_format="tf")
print('\nexport_path = {}'.format(export_path))

你可以将其导出到任何目录,但我喜欢使用临时目录。注意,这里我将其保存在 /tmp/serving_model/1/ 中,但稍后在服务时我们只会使用 /tmp/serving_model/ ——这是因为 TensorFlow Serving 将根据数字查找模型版本,默认情况下会查找版本 1。

如果目录中有任何你正在保存模型的内容,最好在继续之前将其删除(避免此问题是我喜欢使用临时目录的一个原因!)。

TensorFlow Serving 工具包括一个名为 saved_model_cli 的实用程序,可用于检查模型。可以使用 show 命令调用它,并给出模型的目录以获取完整的模型元数据:

$> saved_model_cli show --dir {export_path} --all

此命令的输出将非常长,但将包含以下详细信息:

signature_def['serving_default']:
  The given SavedModel SignatureDef contains the following input(s):
    inputs['dense_input'] tensor_info:
        dtype: DT_FLOAT
        shape: (-1, 1)
        name: serving_default_dense_input:0
  The given SavedModel SignatureDef contains the following output(s):
    outputs['dense'] tensor_info:
        dtype: DT_FLOAT
        shape: (-1, 1)
        name: StatefulPartitionedCall:0

注意 signature_def 的内容,在这种情况下是 serving_default。稍后会用到这些内容。

还要注意输入和输出具有定义的形状和类型。在本例中,每个都是浮点数,形状为(–1,1)。你可以有效地忽略 –1,并记住模型的输入是浮点数,输出也是浮点数。

要使用命令行运行 TensorFlow 模型服务器,需要一些参数。首先需要在 tensorflow_model_server 命令中指定几个参数。rest_api_port 是你希望服务器运行的端口号。这里设置为 8501。然后,使用 model_name 选项为模型命名——这里我称其为 helloworld。最后,使用 model_base_path 将模型保存路径传递给服务器,该路径存储在 MODEL_DIR 操作系统环境变量中。以下是代码:

$> tensorflow_model_server --rest_api_port=8501 --model_name="helloworld" --
model_base_path="/tmp/serving_model/" > server.log 2>&1

脚本的结尾包含了将结果输出到 server.log 的代码。打开这个文件并查看它——你应该会看到服务器成功启动的消息,并显示它在 localhost:8501 导出 HTTP/REST API:

2021-02-19 08:56:22.271662:
  I tensorflow_serving/core/loader_harness.cc:87] Successfully loaded
  servable version {name: helloworld version: 1}
2021-02-19 08:56:22.303904:
  I tensorflow_serving/model_servers/server.cc:371] Running gRPC ModelServer
  at 0.0.0.0:8500 ...
2021-02-19 08:56:22.315093:
  I tensorflow_serving/model_servers/server.cc:391] Exporting HTTP/REST API
  at:localhost:8501 ...
[evhttp_server.cc : 238] NET_LOG: Entering the event loop ...

如果失败,应该会看到有关失败的通知。如果发生这种情况,可能需要重新启动系统。

如果你想测试服务器,可以在 Python 中执行以下操作:

import json
xs = np.array([[9.0], [10.0]])
data = json.dumps({"signature_name": "serving_default",
                   "instances": xs.tolist()})
print(data)

要将数据发送到服务器,您需要将其转换为 JSON 格式。因此,使用 Python 只需创建一个包含要发送的值的 NumPy 数组—在本例中是两个值的列表,即 9.0 和 10.0。每个值本身就是一个数组,因为正如您之前看到的,输入形状是(-1,1)。单个值应发送到模型,因此如果要发送多个值,应该是一个列表的列表,其中内部列表只包含单个值。

使用 Python 中的 json.dumps 来创建负载,其中包含两个名称/值对。第一个是要调用模型的签名名称,在本例中为 serving_default(正如您之前检查模型时所记得的)。第二个是 instances,这是您要传递给模型的值列表。

请注意,使用服务传递值到模型时,您的输入数据应该是一个值列表,即使只有一个单独的值也是如此。因此,例如,如果您想要使用此模型获取值 9.0 的推断,您仍然必须将其放入列表中,如 [9.0]。如果您想要获取两个值的推断,您可能期望看起来像 [9.0, 10.0],但实际上是错误的!期望两个单独输入的两个推断应该是两个单独的列表,所以 [9.0], [10.0]。然而,您将它们作为单个 批次 传递给模型进行推断,因此批次本身应该是包含您传递给模型的列表的列表—如 [[9.0], [10.0]]。如果您仅传递单个值进行推断,请也牢记这一点。它将是在列表中,并且该列表将在一个列表中,像这样:[ [10.0] ]。

因此,为了让这个模型运行推断两次,并计算 x 值为 9.0 和 10.0 时的 y 值,所需的负载应如下所示:

{"signature_name": "serving_default", "instances": [[9.0], [10.0]]}

您可以使用 requests 库调用服务器执行 HTTP POST。请注意 URL 结构。模型名为 helloworld,您希望运行其预测。POST 命令需要数据,即您刚刚创建的负载,并且需要一个 headers 规范,告诉服务器内容类型是 JSON:

import requests
headers = {"content-type": "application/json"}
json_response = requests.post(
    'http://localhost:8501/v1/models/helloworld:predict',
    data=data, headers=headers)

print(json_response.text)

响应将是一个包含预测的 JSON 负载:

{
    "predictions": [[16.9834747], [18.9806728]]
}

请注意,Python 中的 requests 库还提供了一个 json 属性,您可以使用它来自动解码响应为 JSON dict

从 Android 访问服务器模型

现在您有一个运行中并通过 REST 接口公开模型的服务器后,编写用于在 Android 上使用它的代码非常简单。我们将在这里探讨这个问题,在创建了一个只有单一视图的简单应用程序之后(请回顾 第四章 中几个示例),其中包含一个 EditText,您可以用来输入一个数字,一个标签,将呈现结果,并且一个按钮,用户可以按下以触发推断:

<ScrollView
    android:id="@+id/scroll_view"
    android:layout_width="match_parent"
    android:layout_height="0dp"
    app:layout_constraintTop_toTopOf="parent"
    app:layout_constraintBottom_toTopOf="@+id/input_text">
    <TextView
        android:id="@+id/result_text_view"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />
</ScrollView>

<EditText
    android:id="@+id/input_text"
    android:layout_width="0dp"
    android:layout_height="wrap_content"
    android:hint="Enter Text Here"
    android:inputType="number"
    app:layout_constraintBaseline_toBaselineOf="@+id/ok_button"
    app:layout_constraintEnd_toStartOf="@+id/ok_button"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintBottom_toBottomOf="parent" />
<Button
    android:id="@+id/ok_button"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="OK"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toEndOf="@+id/input_text"
    />

该代码将使用一个名为 Volley 的 HTTP 库,该库可以处理来自服务器的请求和响应的异步处理。要使用此功能,请将以下代码添加到您的应用的 build.gradle 文件中:

implementation 'com.android.volley:volley:1.1.1'

然后,此活动的代码看起来可能会像这样——设置控件并创建一个按钮的onClickListener,该按钮将调用托管在 TensorFlow Serving 上的模型:

    lateinit var outputText: TextView
    lateinit var inputText: EditText
    lateinit var btnOK: Button
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        outputText = findViewById(R.id.result_text_view)
        inputText = findViewById(R.id.input_text)
        btnOK = findViewById(R.id.ok_button)
        btnOK.setOnClickListener {
            val inputValue:String = inputText.text.toString()
            val nInput = inputValue.toInt()
            doPost(nInput)

        }
    }

请记住,模型托管自http://:8501/v1/models/helloworld:predict ——如果你正在使用开发人员框和在 Android 模拟器中运行 Android 代码,则可以使用服务器桥接到 10.0.2.2 而不是 localhost。

因此,当按下按钮时,将读取输入的值,转换为整数,然后传递给名为doPost的函数。让我们探讨一下该函数应该做什么。

首先,你将使用Volley来建立一个异步请求/响应队列:

val requestQueue: RequestQueue = Volley.newRequestQueue(this)

接下来,你需要设置托管服务的 URL。我使用的是 10.0.2.2 的服务器桥接,而不是 localhost,或者服务器名称,因为我正在开发人员框上运行服务器,并在模拟器上运行这个 Android 应用:

val URL = "http://10.0.2.2:8501/v1/models/helloworld:predict"

请记住,如果你想通过 JSON 将值传递给服务器,那么每组输入值都需要放在一个列表中,然后所有的列表都需要存储在另一个列表中,因此传递一个值例如 10 用于推理将会是这样的:[ [10.0] ]

然后 JSON 有效负载将如下所示:

{"signature_name": "serving_default", "instances": [[10.0]]} 

我将包含值的列表称为内部列表,而包含该列表的列表称为外部列表。这两者都将被视为JSONArray类型:

val jsonBody = JSONObject()
jsonBody.put("signature_name", "serving_default")
val innerarray = JSONArray()
val outerarray = JSONArray()
innerarray.put(inputValue)
outerarray.put(innerarray)
jsonBody.put("instances", outerarray)
val requestBody = jsonBody.toString()

然后,为了让requestQueue管理通信,你将创建一个StringRequest对象的实例。在此之内,你将重写getBody()函数,将刚刚创建的requestbody字符串添加到请求中。你还将设置一个Response.listener来捕获异步响应。在该响应中,你可以获取预测数组,你的答案将是该列表中的第一个值:

val stringRequest: StringRequest =
  object : StringRequest(Method.POST, URL,
  Response.Listener { response ->
    val str = response.toString()
    val predictions = JSONObject(str).getJSONArray("predictions")
                                   .getJSONArray(0)
    val prediction = predictions.getDouble(0)
    outputText.text = prediction.toString()
  },
  Response.ErrorListener { error ->
    Log.d("API", "error => $error")
  })
  {
    override fun getBody(): ByteArray {
      return requestBody.toByteArray((Charset.defaultCharset()))
  }
}

requestQueue.add(stringRequest)

接着,Volley 将会完成其余的工作——将请求发送到服务器,并捕获异步响应;在这种情况下,Response.Listener将解析结果,并将值输出到 UI 中。你可以在图 14-4 中看到这一点。

图 14-4. 在 Android 应用中从 TensorFlow Serving 运行推理

请注意,在这种情况下,我们的响应非常简单,所以我们只是解码了一个字符串。对于返回的更复杂的 JSON 数据,最好使用诸如GSON之类的 JSON 解析库。

虽然这是一个非常简单的应用程序,但它提供了一个当运行远程推断时您期望任何 Android 应用程序使用的工作流程。需要记住的关键事项是 JSON 载荷的设计。确保你的数据是 JSON 数组,并且这些数组被托管在另一个数组中,因此即使是单个数字也将被上传为[[10.0]]。同样,模型的返回值将被编码为列表的列表,即使它只是一个单一的值!

请注意,此示例使用了一个未经身份验证的服务器。有各种技术可以用来在后端添加身份验证,然后在 Android 上使用这个。其中之一是Firebase Authentication

从 iOS 访问服务器模型

之前你在 TensorFlow Serving 上创建了一个模型并将其托管在那里,它可以在http://:8501/v1/models/helloworld:predict处使用。在这个例子中,我的服务器在192.168.86.26上,所以我将创建一个简单的 iOS 应用程序,它可以访问服务器,传递数据,并得到一个推断。为了做到这一点,并获得一个单一值的推断,你需要向服务器发布一个看起来像这样的 JSON 载荷:

{"signature_name": "serving_default", "instances": [[10.0]]}

如果成功的话,你会收到一个包含推断结果的载荷返回:

{
    "predictions": [[18.9806728]]
}

因此,我们首先需要一个应用程序将载荷传递给服务器,并解析返回的内容。让我们探讨如何在 Swift 中完成这个操作。你可以在书籍的存储库找到一个完整的工作应用程序。在本节中,我只是探索这个应用程序如何进行远程推断。

首先,在 Swift 中,如果你有等效的结构设置,解码 JSON 值是最容易的。因此,为了解码预测,你可以创建一个像这样的结构体:

struct Results: Decodable {
  let predictions: [[Double]]
}

现在,如果你有存储为双精度的值,你可以创建一个上传到服务器的载荷,像这样:

let json: [String: Any] =
    ["signature_name" : "serving_default", "instances" : [[value]]]

let jsonData = try? JSONSerialization.data(withJSONObject: json)

接下来,你可以将此载荷发布到 URL。你可以通过从 URL 创建一个请求,将请求设置为 POST 请求,并将 JSON 载荷添加到请求的主体中来完成这个操作。

// create post request
let url = URL(string: "http://192.168.86.26:8501/v1/models/helloworld:predict")!

var request = URLRequest(url: url)
request.httpMethod = "POST"

// insert json data to the request
request.httpBody = jsonData

请求/响应是异步的,因此不要在等待响应时锁定线程,而是使用一个任务:

let task = URLSession.shared.dataTask(with: request)
    { data, response, error in

使用前面创建的请求创建URLSession,这是一个向 URL 发送 JSON 主体包含输入数据的 POST 请求。这将给你带来响应载荷的数据,响应本身,以及任何错误信息。

你可以使用结果来解析响应。回想一下之前你创建了一个与 JSON 载荷格式匹配的结果结构体。因此在这里,你可以使用JSONDecoder()解码响应,并将预测加载到results中。由于这包含一个数组的数组,并且内部数组有推断的值,你可以在results.predictions[0][0]中访问它们。由于这是一个任务,并且我们将要更新一个 UI 项,因此必须在DispatchQueue内完成:

let results: Results =
    try! JSONDecoder().decode(Results.self, from: data)

            DispatchQueue.main.async{
                self.txtOutput.text = String(results.predictions[0][0])
            }

就是这样!在 Swift 中这非常简单,因为有用于解析输出的结构体,内部和外部列表可以使用 [String : Any] 格式设置。你可以在 图 14-5 中看到使用这种方法的应用程序样子。

与通过 Python 访问 TensorFlow Serving 模型类似,最重要的是确保输入和输出数据正确。最容易犯的错误是忘记有效载荷是列表的列表,因此在使用更复杂的数据结构时,请确保正确处理这一点!

图 14-5. 在 iOS 上访问 TensorFlow Serving 中的 2x − 1 模型

摘要

在本章中,您已经了解了 TensorFlow Serving 及其如何通过 HTTP 接口提供访问模型的环境。您了解了如何安装和配置 TensorFlow Serving,并将模型部署到其中。然后,您学习了如何通过构建超简单的 Android 和 iOS 应用程序来执行远程推理,这些应用程序接收用户输入,创建 JSON 负载,将其发送到 TensorFlow Serving 实例,并解析包含原始数据上模型推理的返回值。虽然场景非常基础,但它为任何需要通过 POST 请求发送 JSON 负载并解析响应的服务提供了框架。

第十五章:移动应用的伦理、公平和隐私

虽然最近机器学习和人工智能的进展将伦理和公平的概念推向了聚光灯下,但需要注意的是,在计算机系统中,不平等和不公平一直是关注的话题。在我的职业生涯中,我见过许多例子,系统为一个场景而设计,但没有考虑到公平和偏见对整体影响的影响。

考虑这个例子:您的公司拥有客户数据库,并希望针对识别出的增长机会的特定邮政编码区域推出营销活动,以获得更多客户。为此,公司将向那些位于该邮政编码区域的与之建立联系但尚未购买任何东西的人发送折扣券。您可以编写如下 SQL 来识别这些潜在客户:

SELECT * from Customers WHERE ZIP=target_zip AND PURCHASES=0

这可能看起来是完全合理的代码。但考虑一下该邮政编码区域的人口统计数据。如果那里的大多数人属于特定种族或年龄组,会怎么样呢?与均衡增长您的客户群体不同,您可能会过度定位一个人群,或者更糟糕的是,通过向一种种族提供折扣而对另一种族进行歧视。随着时间的推移,持续这样的定位可能导致客户群体对社会人口统计数据偏向,最终将您的公司限制在主要为一个人群服务的困境中。在这种情况下,变量——邮政编码——是明确的,但是具有不那么明确的代理变量的系统在没有仔细监控的情况下仍可能发展为具有偏见的系统。

AI 系统的承诺是,您将能够更快地交付更强大的应用程序...但如果您这样做的代价是不减少系统中的偏见,那么您可能会通过使用 AI加速差距。

理解和在可能的情况下消除这一过程是一个庞大的领域,可以填写很多书籍,因此在这一章中,我们只会对您需要注意潜在偏见问题的地方进行概述,并介绍可以帮助您解决这些问题的方法和工具。

伦理、公平与隐私的责任 AI

构建一个 AI 系统,并将责任作为您 ML 工作流程的一部分,这意味着可以在每个步骤中都纳入负责任的 AI 实践。虽然有许多这样的模式,但我将遵循以下非常一般的步骤:

  1. 定义问题:您的 ML 系统是为谁设计的

  2. 构建和准备您的数据

  3. 构建和训练您的模型

  4. 评估您的模型

  5. 部署和监控您的模型使用情况

让我们看看在您通过这些步骤时可用的一些工具。

负责地定义您的问题

当您创建一个解决问题的应用程序时,考虑应用程序存在可能引发的问题是很重要的。您可能会开发像鸟鸣检测器这样无害的东西,用于根据它们发出的声音对鸟类进行分类。但这可能如何影响您的用户呢?如果您的数据仅限于某一特定地区常见的鸟类,而该地区主要由单一人口统治,那会怎样?您可能会无意识地开发了一个只能供特定人口群体使用的应用程序。这是您想要的吗?对于这样的应用程序,也可能会引起可访问性问题。如果您的概念是您听到一只鸟在唱歌,然后您希望识别它……您假设这个人能够听到鸟叫声,因此您并未考虑到听力受损或无听力的人群。虽然这只是一个非常琐碎的例子,但将这一概念扩展到可以深刻影响某人生活的应用程序或服务。如果您的共享乘车应用程序避开了某些社区,从而排斥了某些人?如果您的应用程序对健康有所帮助,比如帮助管理药物,但对某一特定人群失败了呢?可以很容易地想象您如何通过应用程序造成伤害,即使这些后果是无意识的。因此,非常重要的是要对所有潜在用户保持警惕,并具备帮助指导您的工具。

不可能预测所有可能无意中引入偏见的情景,因此,在这种情况下,谷歌已准备好了人+AI 指南。该指南共有六章,从理解用户需求和定义成功开始,一直到数据准备、模型构建、公平收集反馈等。它在帮助您理解 AI 独特解决问题类型方面特别有用。我强烈建议在开始编写任何应用程序之前参考这本指南!

由创建本书的人们也拥有一套AI 可探索资源,这些资源提供了交互式工作手册,帮助您发现数据中的隐藏偏见等问题。这些资源将引导您理解核心场景,不仅限于数据本身,还包括模型在训练后的行为。这些资源可以帮助您制定上线后模型测试的策略。

一旦您定义和理解了问题,并消除了其中潜在的偏见来源,下一步就是构建和准备您将在系统中使用的数据。同样,这也是可能无意中引入偏见的方式。

注意

通常认为 AI 中的偏见仅仅归因于用于训练模型的数据。虽然数据通常是主要嫌疑人,但并不是唯一的。偏见可以通过特征工程、迁移学习以及其他许多方式渗入。通常你会被“出售”,告诉你通过修复数据来解决偏见,但不能简单地清理数据并宣布胜利。在设计系统时要记住这一点。在本章中我们将重点关注数据,因为这是可能的通用工具所在,但再次强调,避免认为偏见仅仅是通过数据引入的心态!

避免数据中的偏见

并非所有的数据偏见都容易发现。我曾参加过一个学生竞赛,参赛者利用生成对抗网络(GANs)挑战图像生成,预测基于面部上半部分的下半部分面貌。那时还未爆发 COVID-19 疫情,但在日本依然是流感季节,很多人会戴口罩来保护自己和他人。

初始想法是看是否能预测口罩下面的面部。为此,他们需要访问面部数据,因此使用了 IMDb 的数据集,其中包含带有年龄和性别标签的 面部图像。问题在于?考虑到数据源是 IMDb,这个数据集中绝大部分面孔都不是日本人。因此,他们的模型在预测我的面部时表现出色,但却不能预测他们自己的面部。在没有足够数据覆盖的情况下急于推出机器学习解决方案,这些学生产生了一个带有偏见的解决方案。这只是一个展示性的比赛,他们的工作非常出色,但它提醒我们,当并不需要或者没有足够数据来构建正确模型时,急于推出机器学习产品可能导致构建带有偏见的模型并造成未来严重的技术债务。

要发现潜在的偏见并不总是那么容易,市面上有许多工具可以帮助你避免这种情况。接下来我想介绍几款免费的工具。

什么如果工具

我最喜欢的之一是谷歌的 What-If 工具。它的目的是让您无需编写大量代码即可检查机器学习模型。通过这个工具,您可以同时检查数据和模型对数据的输出。它有一个演练,使用的是基于 1994 年美国人口普查数据集的大约 30,000 条记录训练的模型,用于预测一个人的收入可能是多少。例如,假设这被一个抵押贷款公司用来确定一个人是否有能力偿还贷款,从而决定是否向其发放贷款。

工具的一部分允许你选择一个推断值并查看导致该推断的数据点。例如,考虑 图 15-1。

此模型返回一个从 0 到 1 的低收入概率,值低于 0.5 表示高收入,其他表示低收入。此用户得分为 0.528,在我们的假设抵押申请场景中可能因收入太低而被拒绝。使用该工具,您可以实际改变用户的某些数据,例如他们的年龄,然后查看推断结果的影响。在这个例子中,将他们的年龄从 42 岁改变到 48 岁,使他们的得分超过 0.5 的阈值,结果从“拒绝”变为“接受”。请注意,用户的其他信息没有改变——只改变了年龄。这表明模型可能存在年龄偏见。

What-If 工具允许您尝试各种信号,包括性别、种族等细节。为了避免因为一个特定情况而改变整个模型,而实际问题出在某个客户身上而不是模型本身,该工具包括寻找最近的反事实情形的能力——也就是说,它会找到最接近的一组数据,这些数据会导致不同的推断结果,因此您可以开始深入挖掘您的数据(或模型架构)以找出偏见。

我只是在这里初探 What-If 工具的功能表面,但强烈建议您去了解一下。您可以在该网站上找到很多使用示例。其核心功能正如其名,它提供了在部署前测试“假设情景”的工具。因此,我相信它可以成为您机器学习工具箱中不可或缺的一部分。

图 15-1。使用 What-If 工具

Facets

Facets是一个可以与 What-If 工具协同工作的工具,通过可视化技术深入挖掘您数据的工具。Facets 的目标是帮助您理解数据集中特征值的分布情况。如果您的数据被分割成多个子集用于训练、测试、验证或其他用途,这将特别有用。在这种情况下,您可能会发现一个子集中的数据偏向于某个特定特征,从而导致模型出现故障。该工具可以帮助您确定每个子集中每个特征的覆盖是否足够。

例如,使用与前一个示例中相同的美国人口普查数据集和 What-If 工具,稍加检查即可发现训练/测试分割非常良好,但使用资本增益和资本损失特征可能会对训练产生偏倚影响。请注意在 图 15-2 中检查分位数时,除这两个特征外,大的十字交叉点在所有特征上非常平衡。这表明这些值的大多数数据点为零,但数据集中有少量远高于零的值。以资本增益为例,你可以看到训练集中有 91.67% 的数据点为零,其余值接近 100k。这可能会导致训练偏倚,并可视为调试信号。这可能会引入偏向于人群中极小部分的偏见。

图 15-2. 使用 Facets 探索数据集

Facets 还包括一个称为 Facets Dive 的工具,可以根据多个轴向可视化数据集的内容。它可以帮助识别数据集中的错误,甚至是现有的偏见,以便你知道如何处理它们。例如,考虑 图 15-3,我在这里通过目标、教育水平和性别对数据集进行了分割。

红色表示“高收入预测”,从左到右是教育水平。几乎每种情况下男性高收入的概率都大于女性,特别是在更高教育水平下,这种对比变得非常明显。例如看看 13-15 列(相当于学士学位):数据显示相同教育水平下男性高收入的比例远高于女性。虽然模型中还有许多其他因素来确定收入水平,但对高度受教育人群出现这种差异很可能是模型中偏见的一个指标。

图 15-3. 使用 Facets 进行深入分析

为了帮助你识别这类特征,以及使用 What-If 工具,我强烈建议使用 Facets 探索你的数据和模型输出。

TensorFlow 模型卡工具包

如果你打算发布你的模型供他人使用,并希望透明地展示用于构建模型的数据,TensorFlow 模型卡工具包可以帮助你。该工具包的目标是提供关于模型元数据的背景信息和透明度。该工具包完全开源,因此你可以探索其工作原理,地址为 https://github.com/tensorflow/model-card-toolkit

要探索一个模型卡的简单示例,您可能熟悉著名的猫狗计算机视觉训练示例。为此制作的模型卡可能看起来像 Figure 15-4,透明地公布了模型的情况。尽管这个模型非常简单,但卡片显示了数据分割的示例,清楚地显示数据集中狗的数量比猫多,因此引入了偏差。此外,制作该模型的专家还能分享其他伦理考虑,例如它 假设 图像始终包含猫或狗,因此如果传递了不包含二者的图像,可能会有害。例如,它可以用来侮辱人类,将其分类为猫或狗。对我个人而言,这是一个重要的“啊哈!”时刻,因为在教授机器学习时,我从未考虑过这种可能性,现在需要确保它成为我的工作流的一部分!

图 15-4. 猫狗模型卡

可在 GitHub 上找到一个更复杂的模型卡,展示了一个基于人口统计特征预测收入的模型。

在其中,您可以看到关于训练和评估集中人口统计信息的透明度,以及关于数据集的定量分析。因此,使用此模型的人可以 预先警告 关于模型可能引入其工作流的偏见,并可以相应地进行缓解。

TensorFlow 数据验证

如果您使用 TensorFlow Extended(TFX),并且在 TFX 管道中拥有数据,则有 TFX 组件可以分析和转换它。它可以帮助您发现诸如缺失数据(例如具有空标签的特征)或超出您预期范围的值等其他异常情况。深入讨论 TensorFlow 数据验证超出了本书的范围,但您可以通过查阅 TFDV 指南 了解更多信息。

构建和训练您的模型

在探索数据及以上提到的任何模型之外,建立和训练您的模型时可以考虑的因素。再次强调,这些细节非常详细,并非所有内容都适用于您。我不会在这里详细介绍它们,但我会为您提供可以了解更多信息的资源链接。

模型修复

当您创建您的模型时,可能会引入导致模型使用结果偏见的偏见。这其中的一个来源是,您的模型可能在某些数据片段上表现不佳。这可能会造成巨大的伤害。例如,假设您为疾病诊断建立了一个模型,该模型对男性和女性表现非常出色,但对不表达性别或非二元性别的人表现不佳,原因是这些类别的数据较少。通常有三种方法可以解决这个问题——改变输入数据,通过更新架构对模型进行干预,或者对结果进行后处理。可以使用一种称为MinDiff的过程来使数据分布均衡,并在数据的各个片段之间平衡错误率。因此,在训练过程中,可以使分布差异变得更加接近,从而在未来的预测结果中,可以在数据的各个片段之间更加公平地进行。

所以,例如,考虑图 15-5。左边是未在训练过程中应用 MinDiff 算法的两个不同数据片段的预测分数。结果是预测的结果差异很大。右边重叠了相同的预测曲线,但它们彼此更加接近。

图 15-5. 使用 MinDiff

这种技术值得探索,详细的教程可以在TensorFlow 网站找到。

模型隐私

在某些情况下,聪明的攻击者可以利用您的模型推断出模型训练所使用的一些数据。预防此类问题的一种方法是使用差分隐私训练模型。差分隐私的理念是防止观察者利用输出来判断某个特定信息是否用于计算中。例如,在一个模型上,该模型经过训练,可以从人口统计学中推断工资。如果攻击者知道某人在数据集中,他们可以知道该人的人口统计信息,然后将其输入模型中,由于他们的工资在训练集中,可以预期其工资的值非常精确。或者,例如,如果使用健康指标创建了一个模型,攻击者可能知道他们的邻居在训练集中,可以使用数据的一部分来推断关于他们目标的更多数据。

考虑到这一点,TensorFlow 隐私提供了使用差分隐私训练模型的优化器实现。

联邦学习

对移动开发者最感兴趣但目前尚未广泛可用的可能是联合学习。在这种情况下,您可以根据用户的使用方式持续更新和改进您的模型。因此,用户正在与您分享他们的个人数据,以帮助您改进模型。一个这样的用例是使他们的键盘自动预测他们正在输入的单词。每个人都不同,所以如果我开始输入“anti”,我可能在输入抗生素,也可能在输入“反教会主义”,键盘应该足够智能,基于我的先前使用提供建议。考虑到这一点,联合学习技术应运而生。这里的隐私影响显而易见 — 您需要能够以一种不会被滥用的方式提供给用户分享非常个人化的信息 — 例如他们键入的单词。

正如我所提到的,这个 API 目前还不是您可以在应用程序中使用的开放 API,但您可以使用 TensorFlow Federated 进行模拟。

TensorFlow Federated(TFF)是一个开源框架,在模拟服务器环境中提供联合学习功能。目前仍处于实验阶段,但值得关注。TFF 设计有两个核心 API。第一个是联合学习 API,为现有模型添加联合学习和评估能力提供一组接口。例如,它允许您定义受分布式客户端学习值影响的分布式变量。第二个是联合核心 API,在函数式编程环境中实现了联合通信操作。它是现有部署场景的基础,如 Google 键盘Gboard

评估您的模型

除了上述在训练和部署过程中用于评估模型的工具之外,还有几个其他值得探索的工具。

公平性指标

公平性指标工具套件旨在计算和可视化分类模型中常见的公平性指标,如假阳性和假阴性,以比较不同数据切片中的性能。如前所述,它已集成到 What-If 工具中,如果您想要开始使用它,也可以单独使用开源的 fairness-indicators package

所以,例如,当使用公平性指标来探索在基于人类评论标记的模型中的假阴性率时,人类试图标记评论是由男性、女性、跨性别者或其他创作者写的,最低的错误率出现在男性中,最高的则出现在“其他性别”中。参见图 15-6。

图 15-6. 从文本模型中推断性别的公平性指标

当在相同模型和相同数据中查看假阳性时,在图 15-7 中,结果发生了翻转。该模型更有可能对男性或跨性别者做出假阳性判断。

图 15-7. Fairness Indicators 提示的假阳性

使用此工具,您可以探索您的模型并调整架构、学习或数据,以尝试平衡它。您可以在https://github.com/tensorflow/fairness-indicators中自行探索此示例。

TensorFlow 模型分析

TensorFlow 模型分析(TFMA)是一个旨在评估 TensorFlow 模型的库。在撰写本文时,它处于预发布阶段,所以在您阅读本文时可能会有所改变!有关如何使用它和如何入门的详细信息,请访问TensorFlow 网站。它特别有助于您分析训练数据的切片以及模型在这些数据上的表现。

语言可解释性工具包

如果你正在构建使用语言的模型,语言可解释性工具(LIT)将帮助你理解像是你的模型在哪些类型的示例上表现不佳,或是推动预测的信号,帮助你确定不良训练数据或对抗行为。您还可以测试模型的一致性,如果更改文本的风格、动词时态或代词等方面。如何设置和使用它的详细信息请参见https://pair-code.github.io/lit/tutorials/tour

Google 的 AI 原则

TensorFlow 是由 Google 的工程师们创建的,是公司产品和内部系统中许多现有项目的一部分。在它开源后,发现了许多机器学习领域的新方向,ML 和 AI 领域的创新步伐是惊人的。考虑到这一点,Google 决定发布一份公开声明,概述了其关于如何创建和使用 AI 的原则。这些原则是负责任采用的重要指南,值得深入探讨。总结起来,这些原则包括:

对社会有益

AI 的进展是变革性的,随着这种变化的发生,目标是考虑所有社会和经济因素,只有在总体上可能的收益超过可预见的风险和不利因素时才进行。

避免创建或加强不公平的偏见

如本章讨论的那样,偏见可能会悄悄地侵入任何系统中。AI——尤其是在其转变行业的情况下——提供了消除现有偏见以及确保不会产生新偏见的机会。我们应当对此保持警觉。

被构建和测试以确保安全

谷歌继续开发强大的安全和保护实践,以避免人工智能带来的意外伤害。这包括在受限环境中开发 AI 技术,并在部署后持续监控其运行。

对人民负责。

目标是建立受适当人类指导和控制的人工智能系统。这意味着必须始终提供适当的反馈、申诉和相关解释的机会。能够实现这一点的工具将成为生态系统的重要组成部分。

结合隐私设计原则。

人工智能系统必须包含确保充分隐私和告知用户其数据使用方式的保障措施。提供通知和同意的机会应当是明显的。

坚持高标准的科学卓越。

当科技创新以科学严谨和开放调查与合作的承诺进行时,其表现最佳。如果人工智能要帮助揭示关键科学领域的知识,它应当朝着那些领域期望的高科学卓越标准努力。

符合这些原则的使用方式应该是可用的。

尽管这一点可能有点抽象,但重要的是要强调这些原则不是孤立存在的,也不仅仅是给构建系统的人。它们也旨在为您构建的系统如何使用提供指导。要注意某人可能会以您未曾预料的方式使用您的系统,因此为您的用户制定一套原则也很重要!

总结。

这也是您成为移动和 Web AI 和 ML 工程师旅程中的一个航标的结束,但您真正构建可以改变世界的解决方案的旅程可能才刚刚开始。希望这本书对您有所帮助,虽然我们没有深入探讨任何特定主题,但我们能够概括和简化机器学习与移动开发领域之间的复杂性。

我坚信,如果人工智能要实现其全部的积极潜力,关键在于使用低功率、小型模型,专注于解决常见问题。尽管研究越来越大,但我认为每个人都可以利用的真正增长潜力在于越来越小的模型,而这本书为你提供了一个平台,可以看到如何利用这一点!

我期待看到您构建的作品,并且如果您能给我分享的机会,我会非常乐意。在 Twitter 上联系我 @lmoroney。

posted @ 2025-11-22 09:03  绝不原创的飞龙  阅读(0)  评论(0)    收藏  举报