Tinyml-TensorFlow-Lite-深度学习-全-
Tinyml:TensorFlow Lite 深度学习(全)
原文:Tinyml: Machine Learning with Tensorflow Lite
译者:飞龙
前言
长久以来,电子产品一直吸引着我的想象力。我们学会了从地球中挖掘岩石,以神秘的方式对其进行精炼,并生产出令人眼花缭乱的微小组件,我们根据神秘的法则将它们组合在一起,赋予它们一些生命的本质。
在我八岁的时候,电池、开关和灯丝灯已经足够迷人了,更不用说我家里家用电脑内部的处理器了。随着岁月的流逝,我对电子和软件原理有了一些了解,使这些发明能够运行。但总让我印象深刻的是,一系列简单的元素如何组合在一起创造出一个微妙而复杂的东西,而深度学习真的将这一点推向了新的高度。
这本书的一个例子是一个深度学习网络,从某种意义上说,它懂得如何看。它由成千上万个虚拟的“神经元”组成,每个神经元都遵循一些简单的规则并输出一个数字。单独来看,每个神经元并不能做太多事情,但是结合起来,并且通过训练,给予一点人类知识,它们可以理解我们复杂的世界。
这个想法中有一些魔力:简单的算法在由沙子、金属和塑料制成的微型计算机上运行,可以体现出人类理解的一部分。这就是 TinyML 的本质,这是 Pete 创造的一个术语,将在第一章中介绍。在本书的页面中,您将找到构建这些东西所需的工具。
感谢您成为我们的读者。这是一个复杂的主题,但我们努力保持简单并解释您需要的所有概念。我们希望您喜欢我们所写的内容,我们很期待看到您创造的东西!
Daniel Situnayake
本书中使用的约定
本书中使用了以下排版约定:
斜体
指示新术语、URL、电子邮件地址、文件名和文件扩展名。
常量宽度
用于程序清单,以及在段落中引用程序元素,如变量或函数名称、数据库、数据类型、环境变量、语句和关键字。
常量宽度粗体
显示用户应该按原样输入的命令或其他文本。
常量宽度斜体
显示应该用用户提供的值或由上下文确定的值替换的文本。
提示
这个元素表示提示或建议。
注意
这个元素表示一般注释。
警告
这个元素表示警告或注意。
使用代码示例
补充材料(代码示例、练习等)可在https://tinymlbook.com/supplemental下载。
如果您有技术问题或在使用代码示例时遇到问题,请发送电子邮件至bookquestions@oreilly.com。
这本书旨在帮助您完成工作。一般来说,如果本书提供了示例代码,您可以在您的程序和文档中使用它。除非您复制了大量代码的部分,否则无需联系我们请求许可。例如,编写一个使用本书中几个代码块的程序不需要许可。销售或分发 O’Reilly 书籍中的示例需要许可。通过引用本书回答问题并引用示例代码不需要许可。将本书中大量示例代码合并到产品文档中需要许可。
我们感谢,但通常不要求署名。署名通常包括标题、作者、出版商和 ISBN。例如:“TinyML by Pete Warden and Daniel Situnayake (O’Reilly). Copyright Pete Warden and Daniel Situnayake, 978-1-492-05204-3.”
如果您觉得您对代码示例的使用超出了合理使用范围或上述给出的许可,请随时联系我们,邮箱为permissions@oreilly.com。
致谢
我们要特别感谢 Nicole Tache 出色的编辑工作,Jennifer Wang 的启发性魔杖示例,以及 Neil Tan 在 uTensor 库中进行的开创性嵌入式 ML 工作。没有 Rajat Monga 和 Sarah Sirajuddin 的专业支持,我们无法完成这本书的写作。我们还要感谢我们的合作伙伴 Joanne Ladolcetta 和 Lauren Ward 的耐心。
这本书是来自硬件、软件和研究领域数百人的努力成果,特别是来自 TensorFlow 团队。虽然我们只能提及一部分人,对于我们遗漏的每个人表示歉意,我们要感谢:Mehmet Ali Anil,Alasdair Allan,Raziel Alvarez,Paige Bailey,Massimo Banzi,Raj Batra,Mary Bennion,Jeff Bier,Lukas Biewald,Ian Bratt,Laurence Campbell,Andrew Cavanaugh,Lawrence Chan,Vikas Chandra,Marcus Chang,Tony Chiang,Aakanksha Chowdhery,Rod Crawford,Robert David,Tim Davis,Hongyang Deng,Wolff Dobson,Jared Duke,Jens Elofsson,Johan Euphrosine,Martino Facchin,Limor Fried,Nupur Garg,Nicholas Gillian,Evgeni Gousev,Alessandro Grande,Song Han,Justin Hong,Sara Hooker,Andrew Howard,Magnus Hyttsten,Advait Jain,Nat Jeffries,Michael Jones,Mat Kelcey,Kurt Keutzer,Fredrik Knutsson,Nick Kreeger,Nic Lane,Shuangfeng Li,Mike Liang,Yu-Cheng Ling,Renjie Liu,Mike Loukides,Owen Lyke,Cristian Maglie,Bill Mark,Matthew Mattina,Sandeep Mistry,Amit Mittra,Laurence Moroney,Boris Murmann,Ian Nappier,Meghna Natraj,Ben Nuttall,Dominic Pajak,Dave Patterson,Dario Pennisi,Jahnell Pereira,Raaj Prasad,Frederic Rechtenstein,Vikas Reddi,Rocky Rhodes,David Rim,Kazunori Sato,Nathan Seidle,Andrew Selle,Arpit Shah,Marcus Shawcroft,Zach Shelby,Suharsh Sivakumar,Ravishankar Sivalingam,Rex St. John,Dominic Symes,Olivier Temam,Phillip Torrone,Stephan Uphoff,Eben Upton,Lu Wang,Tiezhen Wang,Paul Whatmough,Tom White,Edd Wilder-James 和 Wei Xiao。
第一章:介绍
本书的目标是展示任何具有基本命令行终端和代码编辑器使用经验的开发人员如何开始构建自己的项目,运行嵌入式设备上的机器学习(ML)。
当我 2014 年首次加入谷歌时,我发现了许多我之前不知道存在的内部项目,但最令人兴奋的是 OK Google 团队正在进行的工作。他们运行的神经网络只有 14 千字节(KB)!它们需要如此小是因为它们在大多数 Android 手机中的数字信号处理器(DSP)上运行,持续监听“OK Google”唤醒词,而这些 DSP 只有几十 KB 的 RAM 和闪存。团队必须使用 DSP 来完成这项工作,因为主 CPU 已关闭以节省电池,而这些专用芯片只使用几毫瓦(mW)的功率。
从深度学习的图像方面来看,我从未见过如此小的网络,以及使用低功耗芯片来运行神经模型的想法一直留在我心中。当我努力让 TensorFlow 和后来的 TensorFlow Lite 在 Android 和 iOS 设备上运行时,我仍然被与简单芯片合作的可能性所吸引。我了解到在音频领域还有其他开创性的项目(如 Pixel 的 Music IQ)用于预测性维护(如 PsiKick),甚至在视觉领域(高通的 Glance 相机模块)也有类似的项目。
对我来说,很明显有一类全新的产品正在涌现,其关键特征是它们利用机器学习来理解嘈杂的传感器数据,可以使用电池或能量收集器运行多年,成本仅为一两美元。我反复听到的一个术语是“剥离和粘贴传感器”,用于不需要更换电池的设备,可以应用于环境中的任何地方并被遗忘。要使这些产品变为现实,需要将原始传感器数据转化为可操作信息的方式,本地在设备上进行处理,因为传输数据流的能量成本被证明太高,以至于不切实际。
这就是 TinyML 的概念所在。与行业和学术界同事长时间的交谈导致了一个粗略的共识,即如果你能以低于 1 毫瓦的能量成本运行神经网络模型,那么将会有许多全新的应用变得可能。这个数字可能看起来有点随意,但如果你将其转化为具体的术语,那就意味着一个运行在硬币电池上的设备可以使用一年。这将导致一个产品足够小,可以适应任何环境,并能够在没有任何人为干预的情况下运行一段有用的时间。
注
我将直接使用一些技术术语来讨论本书将涵盖的内容,但如果其中一些对您不熟悉,不要担心;我们在第一次使用它们时会定义它们的含义。
此时,你可能会想到像树莓派或 NVIDIA 的 Jetson 开发板这样的平台。这些设备非常棒,我自己经常使用,但即使是最小的树莓派也类似于手机的主 CPU,因此需要数百毫瓦的电力。即使只是让它运行几天,也需要类似于智能手机的电池,这使得构建真正无线的体验变得困难。NVIDIA 的 Jetson 基于强大的 GPU,当以全速运行时,我们看到它使用了高达 12 瓦的功率,因此即使没有大型外部电源供应,也更难以使用。这通常在汽车或机器人应用中不是问题,因为机械部件本身需要大功率源,但这确实使得在我最感兴趣的那些需要在没有有线电源的情况下运行的产品上使用这些平台变得困难。幸运的是,使用它们时,由于缺乏资源限制,通常可以使用像 TensorFlow、TensorFlow Lite 和 NVIDIA 的 TensorRT 这样的框架,因为它们通常基于 Linux 兼容的 Arm Cortex-A CPU,具有数百兆字节的内存。本书不会专注于描述如何在这些平台上运行,原因就是刚才提到的,但如果你感兴趣,有很多资源和文档可用;例如,请参阅TensorFlow Lite 的移动文档。
我关心的另一个特征是成本。最便宜的树莓派 Zero 面向制造商的价格是 5 美元,但很难以那个价格大量购买这类芯片。Zero 的购买通常受到数量限制,而工业购买的价格并不透明,但很明显 5 美元绝对是不寻常的。相比之下,最便宜的 32 位微控制器每个成本远低于一美元。这种低价使得制造商能够用软件定义的替代方案替换传统的模拟或电机控制电路,从玩具到洗衣机的一切。我希望我们可以利用这些设备中微控制器的普及性,通过软件更新引入人工智能,而无需对现有设计进行大量更改。这也应该使得能够在建筑物或野生动物保护区等环境中部署大量智能传感器,而不会使成本超过收益或可用资金。
嵌入式设备
TinyML 的定义是指能耗低于 1 毫瓦,这意味着我们需要寻找嵌入式设备作为硬件平台。直到几年前,我自己对它们并不熟悉——它们对我来说充满了神秘感。传统上它们是 8 位设备,使用晦涩和专有的工具链,因此开始使用任何一个都显得非常令人生畏。一个重要的进步是当 Arduino 推出了用户友好的集成开发环境(IDE)以及标准化的硬件。从那时起,32 位 CPU 已经成为标准,这在很大程度上要归功于 Arm 的 Cortex-M 系列芯片。几年前我开始原型设计一些机器学习实验时,我惊讶地发现开发过程变得相对简单。
嵌入式设备仍然受到一些严格的资源限制。它们通常只有几百千字节的 RAM,有时甚至更少,并且具有类似数量的闪存用于持久性程序和数据存储。时钟速度只有几十兆赫是很常见的。它们肯定不会有完整的 Linux(因为那需要内存控制器和至少一兆字节的 RAM),如果有操作系统,很可能不会提供您期望的所有或任何 POSIX 或标准 C 库函数。许多嵌入式系统避免使用像new或malloc()这样的动态内存分配函数,因为它们被设计为可靠且长时间运行,如果有一个可以被碎片化的堆,要确保这一点是极其困难的。您可能还会发现使用调试器或其他来自桌面开发的熟悉工具会有些棘手,因为您将使用的接口非常专业化。
然而,当我学习嵌入式开发时,也有一些令人惊喜的地方。拥有没有其他进程来中断您的程序的系统可以使构建对发生的事情的心理模型变得非常简单,而没有分支预测或指令流水线的处理器的直接性质使手动汇编优化比在更复杂的 CPU 上更容易。我也发现,在一个可以平衡在指尖上的微型计算机上看到 LED 灯亮起,知道它每秒运行数百万条指令来理解周围世界,这带来了一种简单的喜悦。
变化的景观
直到最近,我们才能在微控制器上运行机器学习,这个领域非常年轻,这意味着硬件、软件和研究都在非常快速地变化。这本书基于 2019 年的世界快照,这一领域意味着一些部分在我们完成最后一章的写作之前就已经过时了。我们努力确保我们依赖的硬件平台将长期可用,但设备很可能会继续改进和演变。我们使用的 TensorFlow Lite 软件框架具有稳定的 API,我们将继续支持文本中提供的示例,但我们还提供了所有示例代码和文档的最新版本的网页链接。例如,您可以期望看到覆盖更多用例的参考应用程序被添加到 TensorFlow 存储库中。我们还致力于专注于调试、模型创建和开发对深度学习工作原理的理解等技能,即使您使用的基础设施发生变化,这些技能也将保持有用。
我们希望这本书能为您提供开发嵌入式机器学习产品所需的基础,以解决您关心的问题。希望我们能够帮助您开始建立一些令人兴奋的新应用程序,我相信在未来几年内这个领域将涌现出一些新的应用程序。
皮特·沃登
第二章:入门
在本章中,我们将介绍如何开始在低功耗设备上构建和修改机器学习应用程序所需的知识。所有软件都是免费的,硬件开发套件的价格不到 30 美元,因此最大的挑战可能是开发环境的陌生。为了帮助解决这个问题,在整个章节中,我们推荐了一套我们发现可以很好地配合使用的工具。
这本书的目标读者是谁?
要构建一个 TinyML 项目,您需要了解一些机器学习和嵌入式软件开发的知识。这两者都不是常见的技能,很少有人是这两者的专家,因此本书将假设您对这两者都没有背景。唯一的要求是您对在终端中运行命令(或 Windows 上的命令提示符)有一定的熟悉度,并且能够将程序源文件加载到编辑器中,进行修改并保存。即使听起来令人生畏,我们会逐步引导您完成我们讨论的每一步,就像一个好的食谱一样,包括许多情况下的截图(和在线屏幕录像),因此我们希望尽可能地使这本书对广大读者更易接近。
我们将向您展示如何在嵌入式设备上应用机器学习的一些实际应用,例如简单的语音识别、使用运动传感器检测手势以及使用摄像头传感器检测人员。我们希望让您熟悉自己构建这些程序,然后扩展它们以解决您关心的问题。例如,您可能想修改语音识别以检测狗吠声而不是人类讲话,或者识别狗而不是人类,我们会给您一些关于如何自行解决这些修改的想法。我们的目标是为您提供开始构建您关心的令人兴奋的应用程序所需的工具。
需要哪些硬件?
您需要一台带有 USB 端口的笔记本电脑或台式电脑。这将是您的主要编程环境,您将在其中编辑和编译在嵌入式设备上运行的程序。您将使用 USB 端口和一个专用适配器将此计算机连接到嵌入式设备,具体取决于您使用的开发硬件。主计算机可以运行 Windows、Linux 或 macOS。对于大多数示例,我们在云中训练我们的机器学习模型,使用Google Colab,因此不用担心是否拥有专门配备的计算机。
您还需要一个嵌入式开发板来测试您的程序。要做一些有趣的事情,您需要连接麦克风、加速度计或摄像头,并且您需要一个足够小的电池,可以构建成一个逼真的原型项目。当我们开始写这本书时,这是很困难的,所以我们与芯片制造商 Ambiq 和创客零售商 SparkFun 合作,生产了价值 15 美元的 SparkFun Edge 板。本书的所有示例都可以在此设备上运行。
提示
SparkFun Edge 板的第二次修订版,SparkFun Edge 2,在这本书出版后将发布。本书中的所有项目都保证可以与新板卡配合使用。然而,这里打印的代码和部署说明将略有不同。不用担心,每个项目章节都链接到一个包含部署每个示例到 SparkFun Edge 2 的最新说明的README.md。
我们还提供了如何在 Arduino 和 Mbed 开发环境中运行许多项目的说明。我们推荐使用Arduino Nano 33 BLE Sense板和Mbed 的 STM32F746G Discovery kit开发板,尽管所有项目都应该适用于其他设备,只要您能够以所需格式捕获传感器数据。表 2-1 显示了我们在每个项目章节中包含的设备。
表 2-1。每个项目中涉及的设备
| 项目名称 | 章节 | SparkFun Edge | Arduino Nano 33 BLE Sense | STM32F746G Discovery kit |
|---|---|---|---|---|
| Hello world | 第五章 | 包括 | 包括 | 包括 |
| 唤醒词检测 | 第七章 | 包括 | 包括 | 包括 |
| 人员检测 | 第九章 | 包括 | 包括 | 不包括 |
| 魔杖 | 第十一章 | 包括 | 包括 | 不包括 |
除了人员检测需要相机模块外,这些项目都不需要任何额外的电子组件。如果您使用的是 Arduino,您将需要Arducam Mini 2MP Plus。如果您使用的是 SparkFun Edge,您将需要 SparkFun 的Himax HM01B0 breakout。
您需要哪些软件?
本书中的所有项目都基于 TensorFlow Lite for Microcontrollers 框架。这是 TensorFlow Lite 框架的一个变体,旨在在仅有几十千字节可用内存的嵌入式设备上运行。所有这些项目都作为库中的示例包含在内,它是开源的,您可以在GitHub上找到它。
注意
由于本书中的代码示例是一个活跃的开源项目的一部分,随着我们添加优化、修复错误和支持其他设备,它们将不断变化和发展。您可能会发现书中打印的代码与 TensorFlow 存储库中最新代码之间存在一些差异。尽管代码可能随着时间的推移而有所变化,但您在这里学到的基本原则将保持不变。
您需要某种编辑器来检查和修改您的代码。如果您不确定应该使用哪种编辑器,微软的免费VS Code 应用程序是一个很好的起点。它适用于 macOS、Linux 和 Windows,并具有许多方便的功能,如语法高亮和自动完成。如果您已经有喜欢的编辑器,可以使用它,我们不会为任何项目进行大量修改。
您还需要一个输入命令的地方。在 macOS 和 Linux 上,这被称为终端,您可以在应用程序文件夹中找到它。在 Windows 上,它被称为命令提示符,您可以在开始菜单中找到它。
还将有额外的软件,您需要与嵌入式开发板通信,但这将取决于您使用的设备。如果您使用的是 SparkFun Edge 开发板或 Mbed 设备,您需要安装 Python 用于一些构建脚本,然后您可以在 Linux 或 macOS 上使用 GNU Screen,或者在 Windows 上使用Tera Term来访问调试日志控制台,显示来自嵌入式设备的文本输出。如果您有 Arduino 开发板,您所需的一切都已安装在 IDE 中,因此您只需要下载主要软件包。
我们希望您学到什么?
本书的目标是帮助更多应用程序在这个新领域中出现。目前没有一个“杀手级应用程序”适用于 TinyML,也许永远不会有,但我们从经验中知道,世界上有很多问题可以通过它提供的工具箱来解决。我们希望让您熟悉可能的解决方案。我们希望带领农业、空间探索、医学、消费品等领域的专家了解如何自己解决问题,或者至少了解这些技术可以解决哪些问题。
考虑到这一点,我们希望当您完成这本书时,您将对目前嵌入式系统上使用机器学习的可能性有一个良好的概述,同时也对未来几年可能实现的可能性有一些想法。我们希望您能够构建和修改一些使用时间序列数据(如音频或加速度计输入)以及低功耗视觉的实际示例。我们希望您对整个系统有足够的理解,至少能够有意义地参与与专家讨论新产品设计,并希望能够自己原型早期版本。
由于我们希望看到完整的产品问世,我们从整个系统的角度来看待我们讨论的一切。通常,硬件供应商会关注他们正在销售的特定组件的能耗,但不考虑其他必要部分如何增加所需的功率。例如,如果您有一个只消耗 1 mW 的微控制器,但它所使用的唯一摄像头传感器需要 10 mW 才能运行,那么您用它的任何基于视觉的产品都无法利用处理器的低能耗。这意味着我们不会深入研究不同领域的基本工作原理;相反,我们专注于您需要了解的内容,以便使用和修改涉及的组件。
例如,当您在 TensorFlow 中训练模型时,我们不会详细讨论发生在幕后的细节,比如梯度和反向传播的工作原理。相反,我们向您展示如何从头开始运行训练以创建模型,您可能会遇到的常见错误以及如何处理它们,以及如何定制流程来构建模型以解决您自己的问题与新数据集。
第三章:快速了解机器学习
在技术领域中,很少有像机器学习和人工智能(AI)周围那样神秘的领域。即使您是另一个领域的经验丰富的工程师,机器学习也可能看起来是一个需要大量先验知识的复杂主题。许多开发人员在开始阅读有关机器学习的内容时会感到沮丧,因为这些解释涉及学术论文、晦涩的 Python 库和高级数学。甚至知道从哪里开始都可能感到令人生畏。
实际上,机器学习很容易理解,任何人都可以通过文本编辑器访问。学习了一些关键思想后,您可以轻松地在自己的项目中使用它。在所有神秘感之下,是一套解决各种问题的有用工具。有时候可能会感觉像魔术,但其实只是代码,您不需要博士学位来使用它。
这本书是关于如何在微型设备上使用机器学习的。在本章的其余部分,您将学习所有开始所需的机器学习知识。我们将涵盖基本概念,探索一些工具,并训练一个简单的机器学习模型。我们的重点是微型硬件,因此我们不会花太多时间讨论深度学习背后的理论,或者使其运作的数学。后面的章节将更深入地探讨工具和如何优化嵌入式设备的模型。但是在本章结束时,您将熟悉关键术语,了解一般工作流程,并知道去哪里学习更多。
在本章中,我们涵盖以下内容:
-
机器学习实际上是什么
-
它可以解决的问题类型
-
关键术语和思想
-
使用深度学习解决问题的工作流程,这是机器学习中最流行的方法之一
提示
有许多书籍和课程解释深度学习背后的科学,所以我们不会在这里做这个。尽管如此,这是一个迷人的主题,我们鼓励您去探索!我们在“学习机器学习”中列出了一些我们喜欢的资源。但请记住,您不需要所有的理论来开始构建有用的东西。
机器学习实际上是什么
想象您拥有一台制造小部件的机器。有时它会出故障,修复起来很昂贵。也许如果您在机器运行期间收集数据,您可能能够预测何时会出现故障,并在损坏发生之前停止运行。例如,您可以记录其生产速率、温度和振动情况。也许这些因素的某种组合表明即将出现问题。但是您如何找出呢?
这是机器学习旨在解决的问题类型的示例。从根本上讲,机器学习是一种利用计算机根据过去观察来预测事物的技术。我们收集有关工厂机器性能的数据,然后创建一个计算机程序来分析这些数据,并用它来预测未来状态。
创建机器学习程序与编写代码的传统过程不同。在传统软件中,程序员设计一个算法,该算法接受输入,应用各种规则,并返回输出。程序员计划算法的内部操作,并通过代码行明确实现。要预测工厂机器的故障,程序员需要了解数据中哪些测量值表示问题,并编写代码来有意识地检查它们。
这种方法对许多问题都有效。例如,我们知道水在海平面上沸腾的温度是 100°C,因此可以轻松编写一个程序,根据当前温度和海拔高度来预测水是否正在沸腾。但在许多情况下,很难知道哪些因素的确切组合预测了给定状态。继续以我们的工厂机器示例为例,可能有各种不同的生产速率、温度和振动水平的组合可能表明问题,但从数据中看不出来。
为了创建一个机器学习程序,程序员将数据输入到一种特殊类型的算法中,让算法发现规则。这意味着作为程序员,我们可以创建基于复杂数据的预测程序,而不必完全理解所有复杂性。机器学习算法基于我们提供的数据构建系统的模型,通过我们称之为训练的过程。模型是一种计算机程序。我们通过这个模型运行数据来进行预测,这个过程称为推理。
机器学习有许多不同的方法。其中最流行的之一是深度学习,它基于人类大脑可能如何工作的简化想法。在深度学习中,一组模拟神经元(由数字数组表示)被训练来模拟各种输入和输出之间的关系。不同的架构或模拟神经元的排列对不同的任务很有用。例如,一些架构擅长从图像数据中提取含义,而其他架构最适合预测序列中的下一个值。
本书中的示例侧重于深度学习,因为它是解决适合微控制器的问题类型的灵活且强大的工具。也许令人惊讶的是,深度学习甚至可以在内存和处理能力有限的设备上运行。事实上,在本书的过程中,您将学习如何创建一些非常惊人的深度学习模型,但这些模型仍然符合微型设备的限制。
下一节解释了创建和使用深度学习模型的基本工作流程。
深度学习工作流程
在前一节中,我们概述了使用深度学习来预测工厂机器何时可能会发生故障的场景。在本节中,我们介绍了使这一情况发生所需的工作。
这个过程将涉及以下任务:
-
确定目标
-
收集数据集
-
设计模型架构
-
训练模型
-
转换模型
-
运行推理
-
评估和故障排除
让我们逐一走过它们。
确定目标
当您设计任何类型的算法时,重要的是首先明确您希望它做什么。机器学习也不例外。您需要决定您想要预测什么,以便确定要收集哪些数据以及使用哪种模型架构。
在我们的例子中,我们想要预测我们的工厂机器是否即将发生故障。我们可以将这表示为一个分类问题。分类是一个机器学习任务,它接受一组输入数据,并返回这些数据符合一组已知类别的概率。在我们的例子中,我们可能有两个类别:“正常”,表示我们的机器正常运行,没有问题,“异常”,表示我们的机器显示出可能很快会发生故障的迹象。
这意味着我们的目标是创建一个将我们的输入数据分类为“正常”或“异常”的模型。
收集数据集
我们的工厂可能有大量可用数据,从机器的运行温度到某一天食堂提供的食物类型。鉴于我们刚刚建立的目标,我们可以开始确定我们需要的数据。
选择数据
深度学习模型可以学会忽略嘈杂或无关的数据。也就是说,最好只使用与解决问题相关的信息来训练模型。由于今天的食堂食物不太可能影响我们机器的运行,我们可能可以将其从数据集中排除。否则,模型将需要学会否定那些无关的输入,并且可能容易学习到虚假的关联——也许我们的机器总是在提供比萨的日子出故障。
在决定是否包含数据时,您应该始终尝试将领域专业知识与实验相结合。您还可以使用统计技术来尝试识别哪些数据是重要的。如果您仍然不确定是否包含某个数据源,您可以始终训练两个模型,看哪个效果最好!
假设我们已经确定了最有前途的数据为生产速率、温度和振动。我们的下一步是收集一些数据,以便我们可以训练一个模型。
提示
选择的数据在您想要进行预测时也是可用的非常重要。例如,由于我们决定用温度读数训练我们的模型,当我们进行推理时,我们将需要提供来自完全相同物理位置的温度读数。这是因为模型学习了如何理解其输入如何预测其输出。如果我们最初在机器内部的温度数据上训练模型,那么在当前室温上运行模型可能不起作用。
收集数据
要训练有效的模型需要多少数据是很难确定的。这取决于许多因素,例如变量之间的关系复杂性、噪音量以及类别之间的区分程度。然而,有一个经验法则始终成立:数据越多,越好!
你应该努力收集代表系统中可能发生的所有条件和事件的数据。如果我们的机器可能以几种不同的方式出现故障,我们应该确保捕获每种类型故障周围的数据。如果一个变量随着时间自然变化,收集代表整个范围的数据是很重要的。例如,如果机器在温暖的日子里温度升高,你应该确保包括冬天和夏天的数据。这种多样性将帮助你的模型代表每种可能的情况,而不仅仅是一些特定的情况。
我们收集关于工厂的数据可能会被记录为一组时间序列,意味着定期收集的一系列读数。例如,我们可能每分钟记录一次温度,每小时记录一次生产速率,每秒记录一次振动水平。在收集数据后,我们需要将这些时间序列转换为适合我们模型的形式。
标记数据
除了收集数据,我们还需要确定哪些数据代表“正常”和“异常”操作。我们将在训练过程中提供这些信息,以便我们的模型学习如何对输入进行分类。将数据与类别相关联的过程称为标记,而“正常”和“异常”类别是我们的标签。
注意
这种训练方式,即在训练期间指导算法数据的含义,被称为监督学习。生成的分类模型将能够处理传入的数据并预测其可能属于哪个类别。
为了标记我们收集到的时间序列数据,我们需要记录机器工作和故障的时间段。我们可能会假设机器故障前的时间段通常代表异常操作。然而,由于我们不能从数据的表面看出异常操作,正确地获取这些信息可能需要一些实验!
在我们决定如何标记数据之后,我们可以生成一个包含标签的时间序列,并将其添加到我们的数据集中。
我们的最终数据集
表 3-1 列出了我们在工作流程中此刻已经收集的数据源。
表 3-1. 数据源
| 数据源 | 间隔 | 样本读数 |
|---|---|---|
| 生产速率 | 每 2 分钟一次 | 100 个单位 |
| 温度 | 每分钟一次 | 30°C |
| 振动(典型值的百分比) | 每 10 秒一次 | 23% |
| 标签(“正常”或“异常”) | 每 10 秒一次 | 正常 |
表格显示了每个数据源的时间间隔。例如,温度每分钟记录一次。我们还生成了一个包含数据标签的时间序列。我们的标签间隔是每 10 秒 1 次,与其他时间序列的最小间隔相同。这意味着我们可以轻松确定数据中每个数据点的标签。
现在我们已经收集了数据,是时候用它来设计和训练模型了。
设计模型架构
有许多类型的深度学习模型架构,旨在解决各种问题。在训练模型时,您可以选择设计自己的架构或基于研究人员开发的现有架构。对于许多常见问题,您可以在网上找到免费的预训练模型。
在本书的过程中,我们将向您介绍几种不同的模型架构,但除了这里介绍的内容外,还有大量可能性。设计模型既是一门艺术也是一门科学,模型架构是一个重要的研究领域。每天都会有新的架构被发明。
在决定架构时,您需要考虑您试图解决的问题类型、您可以访问的数据类型以及在将数据馈送到模型之前可以对数据进行的转换方式(我们将很快讨论数据转换)。事实上,由于最有效的架构取决于您正在处理的数据类型,您的数据和模型架构是紧密相连的。尽管我们在这里分开标题介绍它们,但它们总是会被一起考虑。
您还需要考虑将在其上运行模型的设备的约束,因为微控制器通常具有有限的内存和较慢的处理器,较大的模型需要更多内存并需要更长时间运行 - 模型的大小取决于它包含的神经元数量以及这些神经元的连接方式。此外,一些设备配备了硬件加速功能,可以加快某些类型的模型架构的执行速度,因此您可能希望根据您考虑的设备的优势来定制您的模型。
在我们的情况下,我们可能首先通过几层神经元训练一个简单模型,然后通过迭代过程优化架构,直到获得有用的结果。您将在本书的后面看到如何做到这一点。
深度学习模型接受输入并生成张量形式的输出。对于本书的目的,¹ 张量本质上是一个可以包含数字或其他张量的列表;您可以将其视为类似于数组。我们的假设简单模型将以张量作为输入。以下小节描述了我们如何将数据转换为这种形式。
从数据生成特征
我们已经确定我们的模型将接受某种张量作为输入。但正如我们之前讨论的,我们的数据以时间序列的形式呈现。我们如何将时间序列数据转换为可以传递到模型中的张量呢?
我们现在的任务是决定如何从我们的数据中生成特征。在机器学习中,术语特征指的是模型训练的特定类型信息。不同类型的模型训练在不同类型的特征上。例如,一个模型可能接受一个单一标量值作为其唯一输入特征。
但是输入可能比这更复杂:一个设计用于处理图像的模型可能接受一个多维张量的图像数据作为输入,而一个设计用于基于多个特征进行预测的模型可能接受一个包含多个标量值的向量,每个特征对应一个值。
回想一下,我们决定我们的模型应该使用生产速率、温度和振动来进行预测。以它们的原始形式,作为具有不同间隔的时间序列,这些数据将不适合传递给模型。下一节将解释原因。
窗口化
在下图中,我们的时间序列中的每个数据都用一个星号表示。当前标签包含在数据中,因为标签是训练所必需的。我们的目标是训练一个模型,可以根据当前条件在任何给定时刻预测机器是正常运行还是异常运行:
Production: * * (every 2 minutes)
Temperature: * * * (every minute)
Vibration: * * * * * * * * * * * * * * * * * (every 10 seconds)
Label: * * * * * * * * * * * * * * * * * (every 10 seconds)
然而,由于我们的时间序列具有不同的间隔(比如每分钟一次,或者每 10 秒一次),如果我们只传入给定时刻可用的数据,可能不包括我们可用的所有数据类型。例如,在下图中突出显示的时刻,只有振动数据可用。这意味着我们的模型在尝试进行预测时只有振动信息:
┌─┐
Production: * * │ │
Temperature: * * * │ │
Vibration: * * * * * * * * * * * * * * * *│*│
Label: * * * * * * * * * * * * * * * *│*│
└─┘
解决这个问题的一个方法可能是选择一个时间窗口,并将该窗口内的所有数据合并为一组数值。例如,我们可以决定使用一个一分钟的时间窗口,并查看其中包含的所有数值:
┌───────────┐
Production: * │ * │
Temperature: * * │ * │
Vibration: * * * * * * * * * * *│* * * * * *│
Label: * * * * * * * * * * *│* * * * * *│
└───────────┘
如果我们对每个时间序列的窗口中的所有值求平均,并对当前窗口中缺少数据点的任何值取最近的值,我们最终得到一组单一值。我们可以根据窗口中是否存在任何“异常”标签来决定如何标记这个快照。如果窗口中有任何“异常”存在,窗口应该被标记为“异常”。如果没有,应该被标记为“正常”:
┌───────────┐
Production: * │ * │ Average: 102
Temperature: * * │ * │ Average: 34°C
Vibration: * * * * * * * * * * *│* * * * * *│ Average: 18%
Label: * * * * * * * * * * *│* * * * * *│ Label: "normal"
└───────────┘
这三个非标签数值是我们的特征!我们可以将它们作为一个向量传递给我们的模型,每个时间序列有一个元素:
[102 34 .18]
在训练过程中,我们可以为每 10 秒的数据计算一个新的窗口,并将其传递给我们的模型,使用标签来通知训练算法我们期望的输出。在推断过程中,每当我们想要使用模型来预测异常行为时,我们只需查看我们的数据,计算最近的窗口,将其通过模型运行,并接收一个预测。
这是一个简单的方法,实际上可能并不总是有效,但这是一个很好的起点。您很快会发现,机器学习就是试错的过程!
在我们继续训练之前,让我们再谈一下关于输入数值的最后一点。
归一化
通常,您向神经网络提供的数据将以填充有浮点值或浮点数的张量形式呈现。浮点数是一种用于表示具有小数点的数字的数据类型。为了让训练算法有效地工作,这些浮点数值需要在大小上相似。事实上,如果所有数值都表示为 0 到 1 范围内的数字,那将是理想的。
让我们再次看一下上一节中的输入张量:
[102 34 .18]
这些数值在非常不同的尺度上:温度超过 100,而振动表示为 1 的分数。为了将这些值传递给我们的网络,我们需要对它们进行归一化,使它们都在一个类似的范围内。
一种方法是计算数据集中每个特征的平均值,并从值中减去。这样做的效果是将数字压缩到接近零。这里有一个例子:
Temperature series:
[108 104 102 103 102]
Mean:
103.8
Normalized values, calculated by subtracting 103.8 from each temperature:
[ 4.2 0.2 -1.8 -0.8 -1.8 ]
您经常会遇到归一化的情况之一是当图像被输入神经网络时,以不同的方式实现。计算机通常将图像存储为 8 位整数的矩阵,其值范围从 0 到 255。为了使这些值归一化,使它们都在 0 到 1 之间,每个 8 位值都乘以1/255。这里有一个示例,其中包含一个 3×3 像素的灰度图像,其中每个像素的值表示其亮度:
Original 8-bit values:
[[255 175 30]
[0 45 24]
[130 192 87]]
Normalized values:
[[1\. 0.68627451 0.11764706]
[0\. 0.17647059 0.09411765]
[0.50980392 0.75294118 0.34117647]]
用机器学习思考
到目前为止,我们已经学会了如何开始用机器学习解决问题。在我们的工厂场景中,我们已经决定了一个合适的目标,收集和标记了适当的数据,设计了要传递到模型中的特征,并选择了一个模型架构。无论我们试图解决什么问题,我们都会使用相同的方法。重要的是要注意,这是一个迭代过程,我们经常在 ML 工作流程的各个阶段之间来回,直到我们找到一个有效的模型,或者决定任务太困难。
例如,想象一下我们正在构建一个预测天气的模型。我们需要决定我们的目标(例如,预测明天是否会下雨),收集和标记数据集(例如过去几年的天气报告),设计我们将传递给模型的特征(也许是过去两天的平均条件),并选择适合这种数据类型和我们要运行的设备的模型架构。我们会想出一些初始想法,测试它们,并调整我们的方法,直到获得良好的结果。
我们工作流程中的下一步是训练,我们将在以下部分中探讨。
训练模型
训练是模型学习为给定的输入集合产生正确输出的过程。它涉及将训练数据输入模型,并对其进行小的调整,直到它能够做出最准确的预测。
正如我们之前讨论的,模型是由排列成层的数字数组表示的模拟神经元网络。这些数字被称为权重和偏置,或者统称为网络的参数。
当数据被输入网络时,它会通过每一层中的权重和偏置进行连续的数学运算进行转换。模型的输出是通过这些操作运行输入的结果。图 3-1 显示了一个具有两层的简单网络。
模型的权重从随机值开始,偏置通常从值 0 开始。在训练过程中,将数据的批次输入模型,并将模型的输出与期望输出(在我们的情况下是正确的标签“正常”或“异常”)进行比较。一种称为反向传播的算法逐渐调整权重和偏置,以使随着时间的推移,模型的输出越来越接近期望值。训练以周期(意味着迭代)来衡量,直到我们决定停止为止。

图 3-1。一个具有两层的简单深度学习网络
通常情况下,当一个模型的性能停止改善时,我们会停止训练。当它开始做出准确的预测时,就说它已经收敛。为了确定一个模型是否已经收敛,我们可以分析其在训练过程中的性能图表。两个常见的性能指标是损失和准确性。损失指标给出了一个数值估计,表明模型离产生预期答案有多远,而准确性指标告诉我们它选择正确预测的百分比。一个完美的模型将具有 0.0 的损失和 100%的准确性,但真实模型很少是完美的。
图 3-2 显示了深度学习网络在训练过程中的损失和准确性。您可以看到随着训练的进行,准确性增加,损失减少,直到达到一个模型不再改善的点。
为了尝试改善模型的性能,我们可以改变模型的架构,调整用于设置模型和调节训练过程的各种值。这些值被统称为超参数,它们包括诸如要运行的训练周期数和每个层中的神经元数等变量。每次我们进行更改时,我们可以重新训练模型,查看指标,并决定是否进一步优化。希望,时间和迭代将产生一个具有可接受准确性的模型!

图 3-2。显示训练过程中模型收敛的图表
注意
重要的是要记住,并没有保证你能够达到足够好的准确性来解决你正在尝试解决的问题。数据集中并不总是包含足够的信息来进行准确的预测,有些问题甚至无法解决,即使使用最先进的深度学习技术也不行。也就是说,即使模型不是 100%准确,它也可能是有用的。在我们的工厂示例中,即使只能部分时间预测异常操作也可能会大有帮助。
欠拟合和过拟合
模型无法收敛的两个最常见原因是欠拟合和过拟合。
神经网络学习拟合其在数据中识别的模式。如果一个模型被正确拟合,它将为给定的输入产生正确的输出。当一个模型欠拟合时,它还没有能够学习到足够强的这些模式的表示形式,以便能够做出良好的预测。这可能由于各种原因导致,最常见的是架构太小,无法捕捉应该建模的系统的复杂性,或者没有足够的数据进行训练。
当一个模型过拟合时,它已经对其训练数据学习得太好了。模型能够准确预测其训练数据的细微之处,但它无法将其学习推广到以前没有见过的数据。通常情况下,这是因为模型已经完全记住了训练数据,或者它已经学会依赖于训练数据中存在但现实世界中不存在的一种捷径。
例如,想象一下,你正在训练一个模型来将照片分类为包含狗或猫。如果你的训练数据中所有的狗照片都是在室外拍摄的,而所有的猫照片都是在室内拍摄的,你的模型可能会学会作弊,并利用每张照片中天空的存在来预测是哪种动物。这意味着如果未来的狗自拍照片恰好是在室内拍摄的话,它可能会错误分类。
有许多方法来对抗过拟合。一种可能性是减小模型的大小,使其没有足够的容量来学习其训练集的精确表示。一组称为正则化的技术可以在训练过程中应用,以减少过拟合的程度。为了充分利用有限的数据,可以使用一种称为数据增强的技术,通过切片和切块现有数据来生成新的人工数据点。但是,打败过拟合的最佳方法,如果可能的话,是获得一个更大更多样化的数据集。更多的数据总是有帮助的!
训练、验证和测试
要评估模型的性能,我们可以看看它在训练数据上的表现如何。然而,这只告诉我们故事的一部分。在训练过程中,模型学会尽可能紧密地拟合其训练数据。正如我们之前看到的,在某些情况下,模型将开始过拟合训练数据,这意味着它在训练数据上表现良好,但在现实生活中却不行。
要了解何时发生这种情况,我们需要使用新数据验证模型,这些数据在训练中没有使用。将数据集分成三部分——训练、验证和测试是常见的。典型的分割是 60%的训练数据,20%的验证数据和 20%的测试数据。这种分割必须这样做,以便每个部分包含相同的信息分布,并以保持数据结构的方式进行。例如,由于我们的数据是时间序列,我们可以将其潜在地分成三个连续的时间段。如果我们的数据不是时间序列,我们可以随机抽样数据点。
在训练过程中,训练数据集用于训练模型。定期,来自验证数据集的数据被馈送到模型中,并计算损失。因为模型以前没有见过这些数据,所以它的损失分数是模型表现的更可靠指标。通过比较训练和验证损失(以及准确性,或其他可用的指标),您可以看到模型是否过拟合。
图 3-3 显示了一个过拟合的模型。您可以看到随着训练损失的降低,验证损失却上升了。这意味着模型在预测训练数据方面变得更好,但失去了对新数据的泛化能力。

图 3-3. 显示模型在训练过程中过拟合的图表
当我们调整我们的模型和训练过程以提高性能并避免过拟合时,我们希望看到我们的验证指标得到改善。
然而,这个过程有一个不幸的副作用。通过优化以改善验证指标,我们可能只是在推动模型朝着过拟合训练数据和验证数据的方向!我们所做的每一个调整都会使模型稍微更好地适应验证数据,最终,我们可能会遇到与之前相同的过拟合问题。
为了验证这种情况没有发生,我们在训练模型的最后一步是在我们的测试数据上运行它,并确认它的表现与验证期间一样好。如果没有,我们已经优化了我们的模型以过拟合我们的训练和验证数据。在这种情况下,我们可能需要回到起点,提出一个新的模型架构,因为如果我们继续调整以提高在测试数据上的表现,我们也会过拟合到那里。
当我们有一个在训练、验证和测试数据上表现良好的模型后,这个过程的训练部分就结束了。接下来,我们准备好在设备上运行我们的模型!
转换模型
在整本书中,我们使用 TensorFlow 来构建和训练模型。一个 TensorFlow 模型本质上是一组指令,告诉一个解释器如何转换数据以产生输出。当我们想要使用我们的模型时,我们只需将其加载到内存中,并使用 TensorFlow 解释器执行它。
然而,TensorFlow 的解释器是设计用于在强大的台式计算机和服务器上运行模型的。由于我们将在微型微控制器上运行我们的模型,我们需要一个专为我们的用例设计的不同解释器。幸运的是,TensorFlow 提供了一个解释器和相关工具,用于在小型、低功耗设备上运行模型。这套工具称为 TensorFlow Lite。
在 TensorFlow Lite 可以运行模型之前,首先必须将其转换为 TensorFlow Lite 格式,然后保存到磁盘上作为文件。我们使用一个名为TensorFlow Lite Converter的工具来完成这个过程。转换器还可以应用特殊优化,旨在减小模型的大小并帮助其运行更快,通常不会牺牲性能。
在第十三章中,我们深入探讨了 TensorFlow Lite 的细节以及它如何帮助我们在微小设备上运行模型。目前,你只需要知道你需要转换你的模型,并且转换过程快速简单。
运行推断
模型转换后,就可以部署了!我们现在将使用 TensorFlow Lite for Microcontrollers C++ 库来加载模型并进行预测。
由于这是我们的模型与应用代码相遇的部分,我们需要编写一些代码,从传感器获取原始输入数据并将其转换为模型训练的相同形式。然后,我们将这些转换后的数据传递给我们的模型并运行推断。
这将导致包含预测的输出数据。在我们的分类器模型的情况下,输出将是每个类别“正常”和“异常”的得分。对于分类数据的模型,通常所有类别的得分将总和为 1,得分最高的类别将是预测。得分之间的差异越大,对预测的置信度就越高。表 3-2 列出了一些示例输出。
表 3-2. 示例输出
| 正常得分 | 异常得分 | 解释 |
|---|---|---|
| 0.1 | 0.9 | 在异常状态下有高置信度 |
| 0.9 | 0.1 | 在正常状态下有高置信度 |
| 0.7 | 0.3 | 对正常状态有轻微置信度 |
| 0.49 | 0.51 | 结果不确定,因为两种状态都没有明显领先 |
在我们的工厂机器示例中,每个单独的推断仅考虑数据的一个快照——它告诉我们在过去 10 秒内出现异常状态的概率,基于各种传感器读数。由于现实世界的数据通常混乱,机器学习模型并不完美,因此可能会出现临时故障导致错误分类的情况。例如,由于临时传感器故障,我们可能会看到温度值的突然上升。这种瞬态、不可靠的输入可能导致输出分类短暂地不符合现实。
为了防止这些瞬时故障导致问题,我们可能会在一段时间内对模型的所有输出取平均值。例如,我们可以每 10 秒在当前数据窗口上运行我们的模型,并取最后 6 个输出的平均值,以给出每个类别的平滑得分。这意味着瞬时问题被忽略,我们只对一致的行为采取行动。我们使用这种技术来帮助唤醒词检测在第七章。
在为每个类别得分后,由我们的应用代码决定如何处理。也许如果连续检测到异常状态一分钟,我们的代码将发送信号关闭机器并通知维护团队。
评估和故障排除
在我们部署了模型并在设备上运行后,我们将开始看到其真实世界的性能是否达到我们的期望。即使我们已经证明我们的模型在测试数据上做出了准确的预测,但在实际问题上的表现可能会有所不同。
出现这种情况可能有很多原因。例如,训练中使用的数据可能并不完全代表实际操作中可用的数据。也许由于当地气候,我们机器的温度通常比我们收集数据集的那台机器要凉爽。这可能会影响我们模型的预测,使其不再像预期的那样准确。
另一个可能性是我们的模型可能已经过度拟合我们的数据集,而我们没有意识到。在“训练模型”中,我们学到了当数据集恰好包含模型可以学习识别的额外信号时,这种情况可能会发生。
如果我们的模型在生产中不起作用,我们需要进行一些故障排除。首先,我们排除可能影响到达我们模型的数据的任何硬件问题(如故障传感器或意外噪音)。其次,我们从部署模型的设备中捕获一些数据,并将其与我们的原始数据集进行比较,以确保它在同一范围内。如果不是,也许环境条件或传感器特性存在差异,而我们没有预料到。如果数据检查通过,可能过拟合是问题所在。
在排除硬件问题后,解决过拟合问题的最佳方法通常是使用更多数据进行训练。我们可以从部署的硬件中捕获额外的数据,将其与原始数据集结合起来,然后重新训练我们的模型。在这个过程中,我们可以应用正则化和数据增强技术,以帮助充分利用我们拥有的数据。
要达到良好的实际性能,有时可能需要对模型、硬件和相关软件进行一些迭代。如果遇到问题,要像处理其他技术问题一样对待。采用科学方法进行故障排除,排除可能的因素,并分析数据以找出问题所在。
总结
现在您已经熟悉了机器学习从业者使用的基本工作流程,我们准备在 TinyML 冒险中迈出下一步。
在第四章中,我们将构建我们的第一个模型并将其部署到一些微型硬件上!
这个对于“张量”一词的定义与数学和物理学对该词的定义不同,但在数据科学中已成为常态。
第四章:TinyML 的“Hello World”:构建和训练模型
在第三章中,我们学习了机器学习的基本概念以及机器学习项目遵循的一般工作流程。在本章和下一章中,我们将开始将我们的知识付诸实践。我们将从头开始构建和训练一个模型,然后将其集成到一个简单的微控制器程序中。
在这个过程中,您将通过一些强大的开发者工具亲自动手,这些工具每天都被尖端机器学习从业者使用。您还将学习如何将机器学习模型集成到 C++程序中,并将其部署到微控制器以控制电路中的电流。这可能是您第一次尝试混合硬件和机器学习,应该很有趣!
您可以在 Mac、Linux 或 Windows 机器上测试我们在这些章节中编写的代码,但要获得完整的体验,您需要其中一个嵌入式设备,如“需要哪些硬件?”中提到的。
创建我们的机器学习模型,我们将使用 Python、TensorFlow 和 Google 的 Colaboratory,这是一个基于云的交互式笔记本,用于尝试 Python 代码。这些是真实世界中机器学习工程师最重要的工具之一,而且它们都是免费使用的。
注意
想知道本章标题的含义吗?在编程中,引入新技术通常会附带演示如何做一些非常简单的事情的示例代码。通常,这个简单的任务是使程序输出“Hello, world.”这些词。在机器学习中没有明确的等价物,但我们使用术语“hello world”来指代一个简单、易于阅读的端到端 TinyML 应用程序的示例。
在本章的过程中,我们将执行以下操作:
-
获取一个简单的数据集。
-
训练一个深度学习模型。
-
评估模型的性能。
-
将模型转换为在设备上运行。
-
编写代码执行设备推断。
-
将代码构建成二进制文件。
-
将二进制部署到微控制器。
我们将使用的所有代码都可以在TensorFlow 的 GitHub 存储库中找到。
我们建议您逐步阅读本章的每个部分,然后尝试运行代码。沿途会有如何操作的说明。但在我们开始之前,让我们讨论一下我们要构建的内容。
我们正在构建什么
在第三章中,我们讨论了深度学习网络如何学习模拟其训练数据中的模式,以便进行预测。现在我们将训练一个网络来模拟一些非常简单的数据。您可能听说过正弦函数。它在三角学中用于帮助描述直角三角形的性质。我们将使用的数据是正弦波,这是通过绘制随时间变化的正弦函数的结果得到的图形(请参见图 4-1)。
我们的目标是训练一个模型,可以接受一个值x,并预测其正弦值y。在实际应用中,如果您需要x的正弦值,您可以直接计算它。然而,通过训练一个模型来近似结果,我们可以演示机器学习的基础知识。
我们项目的第二部分将是在硬件设备上运行这个模型。从视觉上看,正弦波是一个愉悦的曲线,从-1 平稳地运行到 1,然后返回。这使得它非常适合控制一个视觉上令人愉悦的灯光秀!我们将使用我们模型的输出来控制一些闪烁的 LED 或图形动画的时间,具体取决于设备的功能。

图 4-1. 正弦波
在线,您可以看到这段代码闪烁 SparkFun Edge 的 LED 的动画 GIF。图 4-2 是来自此动画的静止图像,显示了设备的几个 LED 灯亮起。这可能不是机器学习的特别有用的应用,但在“hello world”示例的精神中,它简单,有趣,并将有助于演示您需要了解的基本原则。
在我们的基本代码运行后,我们将部署到三种不同的设备:SparkFun Edge,Arduino Nano 33 BLE Sense 和 ST Microelectronics STM32F746G Discovery 套件。
注意
由于 TensorFlow 是一个不断发展的积极开发的开源项目,您可能会注意到此处打印的代码与在线托管的代码之间存在一些细微差异。不用担心,即使有几行代码发生变化,基本原则仍然保持不变。

图 4-2。在 SparkFun Edge 上运行的代码
我们的机器学习工具链
为了构建这个项目的机器学习部分,我们正在使用真实世界机器学习从业者使用的相同工具。本节向您介绍这些工具。
Python 和 Jupyter 笔记本
Python 是机器学习科学家和工程师最喜欢的编程语言。它易于学习,适用于许多不同的应用程序,并且有大量用于涉及数据和数学的有用任务的库。绝大多数深度学习研究都是使用 Python 进行的,研究人员经常发布他们创建的模型的 Python 源代码。
Python 与一种称为Jupyter 笔记本结合使用时特别好。这是一种特殊的文档格式,允许您混合编写、图形和代码,可以在点击按钮时运行。Jupyter 笔记本被广泛用作描述、解释和探索机器学习代码和问题的一种方式。
我们将在 Jupyter 笔记本中创建我们的模型,这使我们能够在开发过程中对我们的数据进行可视化。这包括显示显示我们模型准确性和收敛性的图形。
如果您有一些编程经验,Python 易于阅读和学习。您应该能够在没有任何困难的情况下跟随本教程。
谷歌 Colaboratory
为了运行我们的笔记本,我们将使用一个名为Colaboratory的工具,简称为Colab。Colab 由谷歌制作,它提供了一个在线环境来运行 Jupyter 笔记本。它作为一个免费工具提供,以鼓励机器学习中的研究和开发。
传统上,您需要在自己的计算机上创建一个笔记本。这需要安装许多依赖项,如 Python 库,这可能会让人头疼。与其他人分享结果笔记本也很困难,因为他们可能有不同版本的依赖项,这意味着笔记本可能无法按预期运行。此外,机器学习可能需要大量计算,因此在开发计算机上训练模型可能会很慢。
Colab 允许您在谷歌强大的硬件上免费运行笔记本。您可以从任何网络浏览器编辑和查看您的笔记本,并与其他人分享,他们在运行时保证获得相同的结果。您甚至可以配置 Colab 在专门加速的硬件上运行您的代码,这样可以比普通计算机更快地进行训练。
TensorFlow 和 Keras
TensorFlow是一套用于构建、训练、评估和部署机器学习模型的工具。最初由谷歌开发,TensorFlow 现在是一个由全球数千名贡献者构建和维护的开源项目。它是最受欢迎和广泛使用的机器学习框架。大多数开发人员通过其 Python 库与 TensorFlow 进行交互。
TensorFlow 可以做很多不同的事情。在本章中,我们将使用Keras,这是 TensorFlow 的高级 API,使构建和训练深度学习网络变得容易。我们还将使用TensorFlow Lite,这是一组用于在移动和嵌入式设备上部署 TensorFlow 模型的工具,以在设备上运行我们的模型。
第十三章将更详细地介绍 TensorFlow。现在,只需知道它是一个非常强大和行业标准的工具,将在您从初学者到深度学习专家的过程中继续满足您的需求。
构建我们的模型
现在我们将逐步介绍构建、训练和转换模型的过程。我们在本章中包含了所有的代码,但您也可以在 Colab 中跟着进行并运行代码。
首先,加载笔记本。页面加载后,在顶部,单击“在 Google Colab 中运行”按钮,如图 4-3 所示。这将把笔记本从 GitHub 复制到 Colab,允许您运行它并进行编辑。

图 4-3. “在 Google Colab 中运行”按钮
默认情况下,除了代码外,笔记本还包含您在运行代码时应该看到的输出样本。由于我们将在本章中运行代码,让我们清除这些输出,使笔记本处于原始状态。要做到这一点,在 Colab 的菜单中,单击“编辑”,然后选择“清除所有输出”,如图 4-4 所示。

图 4-4. “清除所有输出”选项
干得好。我们的笔记本现在已经准备好了!
提示
如果您已经熟悉机器学习、TensorFlow 和 Keras,您可能想直接跳到我们将模型转换为 TensorFlow Lite 使用的部分。在书中,跳到“将模型转换为 TensorFlow Lite”。在 Colab 中,滚动到“转换为 TensorFlow Lite”标题下。
导入依赖项
我们的第一个任务是导入我们需要的依赖项。在 Jupyter 笔记本中,代码和文本被安排在单元格中。有代码单元格,其中包含可执行的 Python 代码,以及文本单元格,其中包含格式化的文本。
我们的第一个代码单元格位于“导入依赖项”下面。它设置了我们需要训练和转换模型的所有库。以下是代码:
# TensorFlow is an open source machine learning library
!pip install tensorflow==2.0
import tensorflow as tf
# NumPy is a math library
import numpy as np
# Matplotlib is a graphing library
import matplotlib.pyplot as plt
# math is Python's math library
import math
在 Python 中,import语句加载一个库,以便我们的代码可以使用它。您可以从代码和注释中看到,这个单元格执行以下操作:
-
使用
pip安装 TensorFlow 2.0 库,pip是 Python 的软件包管理器 -
导入 TensorFlow、NumPy、Matplotlib 和 Python 的
math库
当我们导入一个库时,我们可以给它一个别名,以便以后容易引用。例如,在前面的代码中,我们使用import numpy as np导入 NumPy,并给它别名np。当我们在代码中使用它时,可以将其称为np。
代码单元格中的代码可以通过单击出现在左上角的按钮来运行,当单元格被选中时会出现该按钮。在“导入依赖项”部分,单击第一个代码单元格的任何位置,使其被选中。图 4-5 显示了选定单元格的外观。

图 4-5. “导入依赖项”单元格处于选定状态
要运行代码,请单击左上角出现的按钮。当代码正在运行时,按钮将以圆圈的形式显示动画,如图 4-6 所示。
依赖项将开始安装,并会看到一些输出。最终您应该看到以下行,表示库已成功安装:
Successfully installed tensorboard-2.0.0 tensorflow-2.0.0 tensorflow-estimator-2.0.0

图 4-6. “导入依赖项”单元格处于运行状态
在 Colab 中运行一个单元格后,当它不再被选中时,您会看到左上角显示一个1,如图 4-7 所示。这个数字是一个计数器,每次运行单元格时都会递增。

图 4-7. 左上角的单元格运行计数器
您可以使用这个来了解哪些单元格已经运行过,以及运行了多少次。
生成数据
深度学习网络学习对底层数据的模式进行建模。正如我们之前提到的,我们将训练一个网络来模拟由正弦函数生成的数据。这将导致一个模型,可以接受一个值x,并预测它的正弦值y。
在继续之前,我们需要一些数据。在现实世界的情况下,我们可能会从传感器和生产日志中收集数据。然而,在这个例子中,我们使用一些简单的代码来生成数据集。
接下来的单元格就是这样的。我们的计划是生成 1,000 个代表正弦波上随机点的值。让我们看一下图 4-8 来提醒自己正弦波是什么样子的。
波的每个完整周期称为它的周期。从图中,我们可以看到每隔大约六个单位在x轴上完成一个完整周期。事实上,正弦波的周期是 2 × π,或 2π。
为了训练完整的正弦波数据,我们的代码将生成从 0 到 2π的随机x值。然后将计算每个这些值的正弦值。

图 4-8. 一个正弦波
这是这个单元格的完整代码,它使用 NumPy(我们之前导入的np)生成随机数并计算它们的正弦值:
# We'll generate this many sample datapoints
SAMPLES = 1000
# Set a "seed" value, so we get the same random numbers each time we run this
# notebook. Any number can be used here.
SEED = 1337
np.random.seed(SEED)
tf.random.set_seed(SEED)
# Generate a uniformly distributed set of random numbers in the range from
# 0 to 2π, which covers a complete sine wave oscillation
x_values = np.random.uniform(low=0, high=2*math.pi, size=SAMPLES)
# Shuffle the values to guarantee they're not in order
np.random.shuffle(x_values)
# Calculate the corresponding sine values
y_values = np.sin(x_values)
# Plot our data. The 'b.' argument tells the library to print blue dots.
plt.plot(x_values, y_values, 'b.')
plt.show()
除了我们之前讨论的内容,这段代码中还有一些值得指出的地方。首先,您会看到我们使用np.random.uniform()来生成我们的x值。这个方法返回指定范围内的随机数数组。NumPy 包含许多有用的方法,可以操作整个值数组,这在处理数据时非常方便。
其次,在生成数据后,我们对数据进行了洗牌。这很重要,因为深度学习中使用的训练过程取决于以真正随机的顺序提供数据。如果数据是有序的,那么生成的模型将不够准确。
接下来,请注意我们使用 NumPy 的sin()方法来计算正弦值。NumPy 可以一次为所有x值执行此操作,返回一个数组。NumPy 太棒了!
最后,您会看到一些神秘的代码调用plt,这是我们对 Matplotlib 的别名:
# Plot our data. The 'b.' argument tells the library to print blue dots.
plt.plot(x_values, y_values, 'b.')
plt.show()
这段代码是做什么的?它绘制了我们数据的图表。Jupyter 笔记本的一个最好的地方是它们能够显示代码运行输出的图形。Matplotlib 是一个从数据创建图表的优秀工具。由于可视化数据是机器学习工作流程的重要部分,这将在我们训练模型时非常有帮助。
要生成数据并将其呈现为图表,请运行单元格中的代码。代码单元格运行完成后,您应该会看到一个漂亮的图表出现在下面,就像图 4-9 中显示的那样。

图 4-9. 我们生成数据的图表
这就是我们的数据!这是沿着一个漂亮、平滑的正弦曲线的随机点的选择。我们可以使用这个来训练我们的模型。然而,这样做太容易了。深度学习网络的一个令人兴奋的地方是它们能够从噪音中提取模式。这使它们能够在训练混乱的真实世界数据时进行预测。为了展示这一点,让我们向我们的数据点添加一些随机噪音并绘制另一个图表:
# Add a small random number to each y value
y_values += 0.1 * np.random.randn(*y_values.shape)
# Plot our data
plt.plot(x_values, y_values, 'b.')
plt.show()
运行这个单元格,看看结果,如图 4-10 所示。
更好了!我们的点现在已经随机化,因此它们代表了围绕正弦波的分布,而不是平滑的完美曲线。这更加反映了现实世界的情况,其中数据通常相当混乱。

图 4-10。我们的数据添加了噪声
分割数据
从上一章,您可能记得数据集通常分为三部分:训练、验证和测试。为了评估我们训练的模型的准确性,我们需要将其预测与真实数据进行比较,并检查它们的匹配程度。
这种评估发生在训练期间(称为验证)和训练之后(称为测试)。在每种情况下,使用的数据都必须是新鲜的,不能已经用于训练模型。
为了确保我们有数据用于评估,我们将在开始训练之前留出一些数据。让我们将我们的数据的 20%保留用于验证,另外 20%用于测试。我们将使用剩下的 60%来训练模型。这是训练模型时常用的典型分割。
以下代码分割我们的数据,然后将每个集合绘制为不同的颜色:
# We'll use 60% of our data for training and 20% for testing. The remaining 20%
# will be used for validation. Calculate the indices of each section.
TRAIN_SPLIT = int(0.6 * SAMPLES)
TEST_SPLIT = int(0.2 * SAMPLES + TRAIN_SPLIT)
# Use np.split to chop our data into three parts.
# The second argument to np.split is an array of indices where the data will be
# split. We provide two indices, so the data will be divided into three chunks.
x_train, x_validate, x_test = np.split(x_values, [TRAIN_SPLIT, TEST_SPLIT])
y_train, y_validate, y_test = np.split(y_values, [TRAIN_SPLIT, TEST_SPLIT])
# Double check that our splits add up correctly
assert (x_train.size + x_validate.size + x_test.size) == SAMPLES
# Plot the data in each partition in different colors:
plt.plot(x_train, y_train, 'b.', label="Train")
plt.plot(x_validate, y_validate, 'y.', label="Validate")
plt.plot(x_test, y_test, 'r.', label="Test")
plt.legend()
plt.show()
为了分割我们的数据,我们使用另一个方便的 NumPy 方法:split()。这个方法接受一个数据数组和一个索引数组,然后在提供的索引处将数据分割成部分。
运行此单元格以查看我们分割的结果。每种类型的数据将由不同的颜色表示(或者如果您正在阅读本书的打印版本,则为不同的阴影),如图 4-11 所示。

图 4-11。我们的数据分为训练、验证和测试集
定义基本模型
现在我们有了数据,是时候创建我们将训练以适应它的模型了。
我们将构建一个模型,该模型将接受一个输入值(在本例中为x)并使用它来预测一个数值输出值(x的正弦)。这种类型的问题称为回归。我们可以使用回归模型来处理各种需要数值输出的任务。例如,回归模型可以尝试根据来自加速度计的数据预测一个人的每小时英里数。
为了创建我们的模型,我们将设计一个简单的神经网络。它使用神经元层来尝试学习训练数据中的任何模式,以便进行预测。
实际上,执行此操作的代码非常简单。它使用Keras,TensorFlow 的用于创建深度学习网络的高级 API:
# We'll use Keras to create a simple model architecture
from tf.keras import layers
model_1 = tf.keras.Sequential()
# First layer takes a scalar input and feeds it through 16 "neurons." The
# neurons decide whether to activate based on the 'relu' activation function.
model_1.add(layers.Dense(16, activation='relu', input_shape=(1,)))
# Final layer is a single neuron, since we want to output a single value
model_1.add(layers.Dense(1))
# Compile the model using a standard optimizer and loss function for regression
model_1.compile(optimizer='rmsprop', loss='mse', metrics=['mae'])
# Print a summary of the model's architecture
model_1.summary()
首先,我们使用 Keras 创建一个Sequential模型,这意味着每个神经元层都堆叠在下一个层上,就像我们在图 3-1 中看到的那样。然后我们定义两个层。这是第一层的定义:
model_1.add(layers.Dense(16, activation='relu', input_shape=(1,)))
第一层有一个单一的输入—我们的x值—和 16 个神经元。这是一个Dense层(也称为全连接层),意味着在推断时,当我们进行预测时,输入将被馈送到每一个神经元中。然后每个神经元将以一定程度被激活。每个神经元的激活程度基于其在训练期间学习到的权重和偏差值,以及其激活函数。神经元的激活作为一个数字输出。
激活是通过一个简单的公式计算的,用 Python 显示。我们永远不需要自己编写这个代码,因为它由 Keras 和 TensorFlow 处理,但随着我们深入学习,了解这个公式将会很有帮助:
activation = activation_function((input * weight) + bias)
为了计算神经元的激活,它的输入被权重相乘,偏差被加到结果中。计算出的值被传递到激活函数中。得到的数字是神经元的激活。
激活函数是用于塑造神经元输出的数学函数。在我们的网络中,我们使用了一个称为修正线性单元或ReLU的激活函数。这在 Keras 中由参数activation=relu指定。
ReLU 是一个简单的函数,在 Python 中显示如下:
def relu(input):
return max(0.0, input)
ReLU 返回较大的值:它的输入或零。如果输入值为负,则 ReLU 返回零。如果输入值大于零,则 ReLU 返回不变。
图 4-12 显示了一系列输入值的 ReLU 输出。

图 4-12。从-10 到 10 的输入的 ReLU 图
没有激活函数,神经元的输出将始终是其输入的线性函数。这意味着网络只能模拟x和y之间的比率在整个值范围内保持不变的线性关系。这将阻止网络对我们的正弦波进行建模,因为正弦波是非线性的。
由于 ReLU 是非线性的,它允许多层神经元联合起来模拟复杂的非线性关系,其中y值并不是每个x增量都增加相同的量。
注意
还有其他激活函数,但 ReLU 是最常用的。您可以在Wikipedia 关于激活函数的文章中看到其他选项。每个激活函数都有不同的权衡,机器学习工程师会进行实验,找出哪些选项对于给定的架构最有效。
来自我们第一层的激活数字将作为输入传递给我们的第二层,该层在以下行中定义:
model_1.add(layers.Dense(1))
因为这一层是一个单个神经元,它将接收 16 个输入,每个输入对应前一层中的一个神经元。它的目的是将前一层的所有激活组合成一个单一的输出值。由于这是我们的输出层,我们不指定激活函数,我们只想要原始结果。
因为这个神经元有多个输入,所以它有对应的每个输入的权重值。神经元的输出是通过以下公式计算的,如 Python 中所示:
# Here, `inputs` and `weights` are both NumPy arrays with 16 elements each
output = sum((inputs * weights)) + bias
输出值是通过将每个输入与其对应的权重相乘,对结果求和,然后加上神经元的偏差来获得的。
网络的权重和偏差在训练期间学习。在本章前面显示的代码中的compile()步骤配置了一些在训练过程中使用的重要参数,并准备好模型进行训练:
model_1.compile(optimizer='rmsprop', loss='mse', metrics=['mae'])
optimizer参数指定了在训练期间调整网络以模拟其输入的算法。有几种选择,找到最佳选择通常归结为实验。您可以在Keras 文档中了解选项。
loss参数指定了在训练期间使用的方法,用于计算网络预测与现实之间的距离。这种方法称为损失函数。在这里,我们使用mse,或均方误差。这种损失函数用于回归问题,我们试图预测一个数字。Keras 中有各种损失函数可用。您可以在Keras 文档中看到一些选项。
metrics参数允许我们指定一些额外的函数,用于评估我们模型的性能。我们指定mae,或平均绝对误差,这是一个有用的函数,用于衡量回归模型的性能。这个度量将在训练期间进行测量,我们将在训练结束后获得结果。
在编译模型后,我们可以使用以下行打印关于其架构的一些摘要信息:
# Print a summary of the model's architecture
model_1.summary()
在 Colab 中运行单元格以定义模型。您将看到以下输出打印:
Model: "sequential"
_________________________________________________________________
Layer (type) Output Shape Param #
=================================================================
dense (Dense) (None, 16) 32
_________________________________________________________________
dense_1 (Dense) (None, 1) 17
=================================================================
Total params: 49
Trainable params: 49
Non-trainable params: 0
_________________________________________________________________
这个表格显示了网络的层、它们的输出形状以及它们的参数数量。网络的大小——它占用的内存量——主要取决于它的参数数量,即其总权重和偏差的数量。在讨论模型大小和复杂性时,这可能是一个有用的指标。
对于像我们这样简单的模型,权重的数量可以通过计算模型中神经元之间的连接数来确定,假设每个连接都有一个权重。
我们刚刚设计的网络由两层组成。我们的第一层有 16 个连接——一个连接到每个神经元的输入。我们的第二层有一个神经元,也有 16 个连接——一个连接到第一层的每个神经元。这使得连接的总数为 32。
由于每个神经元都有一个偏差,网络有 17 个偏差,这意味着它总共有 32 + 17 = 49 个参数。
我们现在已经走完了定义我们模型的代码。接下来,我们将开始训练过程。
训练我们的模型
定义了我们的模型之后,就是训练它,然后评估其性能,看看它的工作效果如何。当我们看到指标时,我们可以决定是否足够好,或者是否应该对设计进行更改并重新训练。
在 Keras 中训练模型,我们只需调用其fit()方法,传递所有数据和一些其他重要参数。下一个单元格中的代码显示了如何:
history_1 = model_1.fit(x_train, y_train, epochs=1000, batch_size=16,
validation_data=(x_validate, y_validate))
运行单元格中的代码开始训练。您将看到一些日志开始出现:
Train on 600 samples, validate on 200 samples
Epoch 1/1000
600/600 [==============================] - 1s 1ms/sample - loss: 0.7887 - mae: 0.7848 - val_loss: 0.5824 - val_mae: 0.6867
Epoch 2/1000
600/600 [==============================] - 0s 155us/sample - loss: 0.4883 - mae: 0.6194 - val_loss: 0.4742 - val_mae: 0.6056
我们的模型现在正在训练。这将需要一些时间,所以在等待时,让我们详细了解我们对fit()的调用:
history_1 = model_1.fit(x_train, y_train, epochs=1000, batch_size=16,
validation_data=(x_validate, y_validate))
首先,您会注意到我们将fit()调用的返回值分配给一个名为history_1的变量。这个变量包含了关于我们训练运行的大量信息,我们稍后将使用它来调查事情的进展。
接下来,让我们看一下fit()函数的参数:
x_train,y_train
fit()的前两个参数是我们训练数据的x和y值。请记住,我们的数据的部分被保留用于验证和测试,因此只有训练集用于训练网络。
epochs
下一个参数指定在训练期间整个训练集将通过网络运行多少次。时期越多,训练就越多。您可能会认为训练次数越多,网络就会越好。然而,一些网络在一定数量的时期后会开始过拟合其训练数据,因此我们可能希望限制我们进行的训练量。
此外,即使没有过拟合,网络在一定数量的训练后也会停止改进。由于训练需要时间和计算资源,最好不要在网络没有变得更好的情况下进行训练!
我们开始使用 1,000 个时期进行训练。训练完成后,我们可以深入研究我们的指标,以发现这是否是正确的数量。
batch_size
batch_size参数指定在测量准确性并更新权重和偏差之前要向网络提供多少训练数据。如果需要,我们可以指定batch_size为1,这意味着我们将在单个数据点上运行推断,测量网络预测的损失,更新权重和偏差以使下次预测更准确,然后继续这个循环直到处理完所有数据。
因为我们有 600 个数据点,每个时期会导致网络更新 600 次。这是很多计算量,所以我们的训练会花费很长时间!另一种选择可能是选择并对多个数据点运行推断,测量总体损失,然后相应地更新网络。
如果将batch_size设置为600,每个批次将包括所有训练数据。现在,我们每个时代只需要对网络进行一次更新,速度更快。问题是,这会导致模型的准确性降低。研究表明,使用大批量大小训练的模型对新数据的泛化能力较差,更容易过拟合。
妥协的方法是使用一个介于中间的批量大小。在我们的训练代码中,我们使用批量大小为 16。这意味着我们会随机选择 16 个数据点,对它们进行推断,计算总体损失,并每批次更新一次网络。如果我们有 600 个训练数据点,网络将在每个时代更新大约 38 次,这比 600 次要好得多。
在选择批量大小时,我们在训练效率和模型准确性之间做出妥协。理想的批量大小会因模型而异。最好从批量大小为 16 或 32 开始,并进行实验以找出最佳工作方式。
验证数据
这是我们指定验证数据集的地方。来自该数据集的数据将在整个训练过程中通过网络运行,并且网络的预测将与预期值进行比较。我们将在日志中看到验证结果,并作为history_1对象的一部分。
训练指标
希望到目前为止,培训已经结束。如果没有,请等待一段时间以完成培训。
我们现在将检查各种指标,以查看我们的网络学习情况如何。首先,让我们查看训练期间编写的日志。这将显示网络如何从其随机初始状态改进。
这是我们第一个和最后一个时代的日志:
Epoch 1/1000
600/600 [==============================] - 1s 1ms/sample - loss: 0.7887 - mae: 0.7848 - val_loss: 0.5824 - val_mae: 0.6867
Epoch 1000/1000
600/600 [==============================] - 0s 124us/sample - loss: 0.1524 - mae: 0.3039 - val_loss: 0.1737 - val_mae: 0.3249
损失,mae,val_loss和val_mae告诉我们各种事情:
损失
这是我们损失函数的输出。我们使用均方误差,它表示为正数。通常,损失值越小,越好,因此在评估网络时观察这一点是一个好方法。
比较第一个和最后一个时代,网络在训练过程中显然有所改进,从约 0.7 的损失到更小的约 0.15。让我们看看其他数字,以确定这种改进是否足够!
mae
这是我们训练数据的平均绝对误差。它显示了网络预测值与训练数据中预期y值之间的平均差异。
可以预期我们的初始误差会非常糟糕,因为它基于未经训练的网络。这当然是事实:网络的预测平均偏差约为 0.78,这是一个很大的数字,当可接受值的范围仅为-1 到 1 时!
然而,即使在训练之后,我们的平均绝对误差仍然约为 0.30。这意味着我们的预测平均偏差约为 0.30,这仍然相当糟糕。
val_loss
这是我们验证数据上损失函数的输出。在我们的最后一个时代中,训练损失(约 0.15)略低于验证损失(约 0.17)。这暗示我们的网络可能存在过拟合问题,因为它在未见过的数据上表现更差。
val_mae
这是我们验证数据的平均绝对误差。值为约 0.32,比我们训练集上的平均绝对误差更糟糕,这是网络可能存在过拟合的另一个迹象。
绘制历史数据
到目前为止,很明显我们的模型并没有做出准确的预测。我们现在的任务是找出原因。为此,让我们利用我们history_1对象中收集的数据。
下一个单元格从历史对象中提取训练和验证损失数据,并将其绘制在图表上:
loss = history_1.history['loss']
val_loss = history_1.history['val_loss']
epochs = range(1, len(loss) + 1)
plt.plot(epochs, loss, 'g.', label='Training loss')
plt.plot(epochs, val_loss, 'b', label='Validation loss')
plt.title('Training and validation loss')
plt.xlabel('Epochs')
plt.ylabel('Loss')
plt.legend()
plt.show()
history_1对象包含一个名为history_1.history的属性,这是一个记录训练和验证期间指标值的字典。我们使用这个来收集我们要绘制的数据。对于我们的 x 轴,我们使用时期数,通过查看损失数据点的数量来确定。运行单元格,您将在图 4-13 中看到图形。

图 4-13。训练和验证损失的图形
正如您所看到的,损失量在前 50 个时期内迅速减少,然后趋于稳定。这意味着模型正在改进并产生更准确的预测。
我们的目标是在模型不再改进或训练损失小于验证损失时停止训练,这意味着模型已经学会如此好地预测训练数据,以至于无法推广到新数据。
损失在最初几个时期急剧下降,这使得其余的图表非常难以阅读。让我们通过运行下一个单元格来跳过前 100 个时期:
# Exclude the first few epochs so the graph is easier to read
SKIP = 100
plt.plot(epochs[SKIP:], loss[SKIP:], 'g.', label='Training loss')
plt.plot(epochs[SKIP:], val_loss[SKIP:], 'b.', label='Validation loss')
plt.title('Training and validation loss')
plt.xlabel('Epochs')
plt.ylabel('Loss')
plt.legend()
plt.show()
图 4-14 展示了此单元格生成的图形。

图 4-14。跳过前 100 个时期的训练和验证损失图
现在我们已经放大了,您可以看到损失继续减少直到大约 600 个时期,此时它基本稳定。这意味着可能没有必要训练我们的网络那么长时间。
但是,您还可以看到最低的损失值仍然约为 0.15。这似乎相对较高。此外,验证损失值始终更高。
为了更深入地了解我们模型的性能,我们可以绘制更多数据。这次,让我们绘制平均绝对误差。运行下一个单元格来执行:
# Draw a graph of mean absolute error, which is another way of
# measuring the amount of error in the prediction.
mae = history_1.history['mae']
val_mae = history_1.history['val_mae']
plt.plot(epochs[SKIP:], mae[SKIP:], 'g.', label='Training MAE')
plt.plot(epochs[SKIP:], val_mae[SKIP:], 'b.', label='Validation MAE')
plt.title('Training and validation mean absolute error')
plt.xlabel('Epochs')
plt.ylabel('MAE')
plt.legend()
plt.show()
图 4-15 显示了结果图形。

图 4-15。训练和验证期间的平均绝对误差图
这个平均绝对误差图给了我们一些进一步的线索。我们可以看到,平均而言,训练数据显示的误差比验证数据低,这意味着网络可能已经过拟合,或者学习了训练数据,以至于无法对新数据做出有效预测。
此外,平均绝对误差值相当高,约为 0.31 左右,这意味着模型的一些预测至少有 0.31 的错误。由于我们的预期值的范围仅为-1 到+1,0.31 的误差意味着我们离准确建模正弦波还有很大距离。
为了更深入了解发生了什么,我们可以将网络对训练数据的预测与预期值绘制在一起。
这发生在以下单元格中:
# Use the model to make predictions from our validation data
predictions = model_1.predict(x_train)
# Plot the predictions along with the test data
plt.clf()
plt.title('Training data predicted vs actual values')
plt.plot(x_test, y_test, 'b.', label='Actual')
plt.plot(x_train, predictions, 'r.', label='Predicted')
plt.legend()
plt.show()
通过调用model_1.predict(x_train),我们对训练数据中的所有x值进行推断。该方法返回一个预测数组。让我们将这个绘制在图上,与我们训练集中的实际y值一起。运行单元格,您将在图 4-16 中看到图形。

图 4-16。我们训练数据的预测与实际值的图形
哦,亲爱的!图表清楚地表明我们的网络已经学会以非常有限的方式逼近正弦函数。预测非常线性,只是非常粗略地拟合数据。
这种拟合的刚性表明模型没有足够的容量来学习正弦波函数的全部复杂性,因此它只能以过于简单的方式逼近它。通过使我们的模型更大,我们应该能够提高其性能。
改进我们的模型
凭借我们的原始模型太小无法学习数据的复杂性的知识,我们可以尝试改进它。这是机器学习工作流程的正常部分:设计模型,评估其性能,并进行更改,希望看到改进。
扩大网络的简单方法是添加另一层神经元。每一层神经元代表输入的转换,希望能使其更接近预期的输出。网络有更多层神经元,这些转换就可以更复杂。
运行以下单元格以重新定义我们的模型,方式与之前相同,但在中间增加了 16 个神经元的额外层:
model_2 = tf.keras.Sequential()
# First layer takes a scalar input and feeds it through 16 "neurons." The
# neurons decide whether to activate based on the 'relu' activation function.
model_2.add(layers.Dense(16, activation='relu', input_shape=(1,)))
# The new second layer may help the network learn more complex representations
model_2.add(layers.Dense(16, activation='relu'))
# Final layer is a single neuron, since we want to output a single value
model_2.add(layers.Dense(1))
# Compile the model using a standard optimizer and loss function for regression
model_2.compile(optimizer='rmsprop', loss='mse', metrics=['mae'])
# Show a summary of the model
model_2.summary()
正如您所看到的,代码基本上与我们第一个模型相同,但增加了一个Dense层。让我们运行这个单元格来查看summary()结果:
Model: "sequential_1"
_________________________________________________________________
Layer (type) Output Shape Param #
=================================================================
dense_2 (Dense) (None, 16) 32
_________________________________________________________________
dense_3 (Dense) (None, 16) 272
_________________________________________________________________
dense_4 (Dense) (None, 1) 17
=================================================================
Total params: 321
Trainable params: 321
Non-trainable params: 0
_________________________________________________________________
有了 16 个神经元的两层,我们的新模型要大得多。它有(1 * 16) + (16 * 16) + (16 * 1) = 288 个权重,加上 16 + 16 + 1 = 33 个偏差,总共是 288 + 33 = 321 个参数。我们的原始模型只有 49 个总参数,因此模型大小增加了 555%。希望这种额外的容量将有助于表示数据的复杂性。
接下来的单元格将训练我们的新模型。由于我们的第一个模型改进得太快,这次让我们训练更少的时代——只有 600 个。运行这个单元格开始训练:
history_2 = model_2.fit(x_train, y_train, epochs=600, batch_size=16,
validation_data=(x_validate, y_validate))
训练完成后,我们可以查看最终日志,快速了解事情是否有所改善:
Epoch 600/600
600/600 [==============================] - 0s 150us/sample - loss: 0.0115 - mae: 0.0859 - val_loss: 0.0104 - val_mae: 0.0806
哇!您可以看到我们已经取得了巨大的进步——验证损失从 0.17 降至 0.01,验证平均绝对误差从 0.32 降至 0.08。这看起来非常有希望。
为了了解情况如何,让我们运行下一个单元格。它设置为生成我们上次使用的相同图表。首先,我们绘制损失的图表:
# Draw a graph of the loss, which is the distance between
# the predicted and actual values during training and validation.
loss = history_2.history['loss']
val_loss = history_2.history['val_loss']
epochs = range(1, len(loss) + 1)
plt.plot(epochs, loss, 'g.', label='Training loss')
plt.plot(epochs, val_loss, 'b', label='Validation loss')
plt.title('Training and validation loss')
plt.xlabel('Epochs')
plt.ylabel('Loss')
plt.legend()
plt.show()
图 4-17 显示了结果。
接下来,我们绘制相同的损失图,但跳过前 100 个时代,以便更好地看到细节:
# Exclude the first few epochs so the graph is easier to read
SKIP = 100
plt.clf()
plt.plot(epochs[SKIP:], loss[SKIP:], 'g.', label='Training loss')
plt.plot(epochs[SKIP:], val_loss[SKIP:], 'b.', label='Validation loss')
plt.title('Training and validation loss')
plt.xlabel('Epochs')
plt.ylabel('Loss')
plt.legend()
plt.show()

图 4-17。训练和验证损失的图表
图 4-18 展示了输出。
最后,我们绘制相同一组时代的平均绝对误差:
plt.clf()
# Draw a graph of mean absolute error, which is another way of
# measuring the amount of error in the prediction.
mae = history_2.history['mae']
val_mae = history_2.history['val_mae']
plt.plot(epochs[SKIP:], mae[SKIP:], 'g.', label='Training MAE')
plt.plot(epochs[SKIP:], val_mae[SKIP:], 'b.', label='Validation MAE')
plt.title('Training and validation mean absolute error')
plt.xlabel('Epochs')
plt.ylabel('MAE')
plt.legend()
plt.show()

图 4-18。训练和验证损失的图表,跳过前 100 个时代
图 4-19 描述了图表。

图 4-19。训练和验证期间的平均绝对误差图
很棒的结果!从这些图表中,我们可以看到两个令人兴奋的事情:
-
验证的指标比训练的要好,这意味着网络没有过拟合。
-
总体损失和平均绝对误差比我们之前的网络要好得多。
您可能想知道为什么验证的指标比训练的好,而不仅仅是相同的。原因是验证指标是在每个时代结束时计算的,而训练指标是在训练时代仍在进行时计算的。这意味着验证是在一个训练时间稍长的模型上进行的。
根据我们的验证数据,我们的模型似乎表现很好。然而,为了确保这一点,我们需要进行最后一次测试。
测试
之前,我们留出了 20%的数据用于测试。正如我们讨论过的,拥有单独的验证和测试数据非常重要。由于我们根据验证性能微调我们的网络,存在一个风险,即我们可能会意外地调整模型以过度拟合其验证集,并且可能无法推广到新数据。通过保留一些新鲜数据并将其用于对模型的最终测试,我们可以确保这种情况没有发生。
在使用了我们的测试数据之后,我们需要抵制进一步调整模型的冲动。如果我们为了提高测试性能而进行更改,可能会导致过拟合测试集。如果这样做了,我们将无法知道,因为我们没有剩余的新数据来进行测试。
这意味着如果我们的模型在测试数据上表现不佳,那么是时候重新考虑了。我们需要停止优化当前模型,并提出全新的架构。
考虑到这一点,接下来的单元将评估我们的模型与测试数据的表现:
# Calculate and print the loss on our test dataset
loss = model_2.evaluate(x_test, y_test)
# Make predictions based on our test dataset
predictions = model_2.predict(x_test)
# Graph the predictions against the actual values
plt.clf()
plt.title('Comparison of predictions and actual values')
plt.plot(x_test, y_test, 'b.', label='Actual')
plt.plot(x_test, predictions, 'r.', label='Predicted')
plt.legend()
plt.show()
首先,我们使用测试数据调用模型的evaluate()方法。这将计算并打印损失和平均绝对误差指标,告诉我们模型的预测与实际值的偏差有多大。接下来,我们进行一组预测,并将其与实际值一起绘制在图表上。
现在我们可以运行单元,了解我们的模型表现如何!首先,让我们看看evaluate()的结果:
200/200 [==============================] - 0s 71us/sample - loss: 0.0103 - mae: 0.0718
这显示有 200 个数据点被评估,这是我们整个测试集。模型每次预测需要 71 微秒。损失指标为 0.0103,非常出色,并且非常接近我们的验证损失 0.0104。我们的平均绝对误差为 0.0718,也非常小,与验证中的 0.0806 相当接近。
这意味着我们的模型运行良好,没有过拟合!如果模型过拟合了验证数据,我们可以预期测试集上的指标会明显比验证结果差。
我们的预测与实际值的图表,显示在图 4-20 中,清楚地展示了我们的模型表现如何。

图 4-20。我们的测试数据的预测与实际值的图表
你可以看到,大部分情况下,代表预测值的点形成了一个平滑的曲线,沿着实际值的分布中心。我们的网络已经学会了近似正弦曲线,即使数据集很嘈杂!
然而,仔细观察,你会发现一些不完美之处。我们预测的正弦波的峰值和谷值并不完全平滑,像真正的正弦波那样。我们模型学习了训练数据的变化,这些数据是随机分布的。这是过拟合的轻微情况:我们的模型没有学习到平滑的正弦函数,而是学会了复制数据的确切形状。
对于我们的目的,这种过拟合并不是一个主要问题。我们的目标是让这个模型轻轻地控制 LED 的亮度,不需要完全平滑才能实现这一目标。如果我们认为过拟合的程度有问题,我们可以尝试通过正则化技术或获取更多的训练数据来解决。
现在我们对模型满意了,让我们准备在设备上部署它!
将模型转换为 TensorFlow Lite
在本章的开头,我们简要提到了 TensorFlow Lite,这是一组用于在“边缘设备”上运行 TensorFlow 模型的工具。
第十三章详细介绍了用于微控制器的 TensorFlow Lite。目前,我们可以将其视为具有两个主要组件:
TensorFlow Lite 转换器
这将 TensorFlow 模型转换为一种特殊的、节省空间的格式,以便在内存受限设备上使用,并且可以应用优化,进一步减小模型大小,并使其在小型设备上运行更快。
TensorFlow Lite 解释器
这将使用给定设备的最有效操作来运行适当转换为 TensorFlow Lite 模型。
在使用 TensorFlow Lite 之前,我们需要将模型转换。我们使用 TensorFlow Lite 转换器的 Python API 来完成这个任务。它将我们的 Keras 模型写入磁盘,以FlatBuffer的形式,这是一种专门设计的节省空间的文件格式。由于我们要部署到内存有限的设备,这将非常有用!我们将在第十二章中更详细地了解 FlatBuffers。
除了创建 FlatBuffer 外,TensorFlow Lite 转换器还可以对模型应用优化。这些优化通常会减小模型的大小、运行时间,或者两者兼而有之。这可能会导致准确度降低,但降低通常是小到足以值得的。您可以在第十三章中了解更多关于优化的信息。
最有用的优化之一是量化。默认情况下,模型中的权重和偏置以 32 位浮点数存储,以便在训练期间进行高精度计算。量化允许您减少这些数字的精度,使其适合于 8 位整数——大小减小四倍。更好的是,因为 CPU 更容易使用整数而不是浮点数进行数学运算,量化模型将运行得更快。
量化最酷的一点是,它通常会导致准确度的最小损失。这意味着在部署到低内存设备时,几乎总是值得的。
在下一个单元格中,我们使用转换器创建并保存我们模型的两个新版本。第一个转换为 TensorFlow Lite FlatBuffer 格式,但没有任何优化。第二个是量化的。
运行单元格将模型转换为这两种变体:
# Convert the model to the TensorFlow Lite format without quantization
converter = tf.lite.TFLiteConverter.from_keras_model(model_2)
tflite_model = converter.convert()
# Save the model to disk
open("sine_model.tflite," "wb").write(tflite_model)
# Convert the model to the TensorFlow Lite format with quantization
converter = tf.lite.TFLiteConverter.from_keras_model(model_2)
# Indicate that we want to perform the default optimizations,
# which include quantization
converter.optimizations = [tf.lite.Optimize.DEFAULT]
# Define a generator function that provides our test data's x values
# as a representative dataset, and tell the converter to use it
def representative_dataset_generator():
for value in x_test:
# Each scalar value must be inside of a 2D array that is wrapped in a list
yield [np.array(value, dtype=np.float32, ndmin=2)]
converter.representative_dataset = representative_dataset_generator
# Convert the model
tflite_model = converter.convert()
# Save the model to disk
open("sine_model_quantized.tflite," "wb").write(tflite_model)
为了创建一个尽可能高效运行的量化模型,我们需要提供一个代表性数据集——一组数字,代表了模型训练时数据集的全部输入值范围。
在前面的单元格中,我们可以使用测试数据集的x值作为代表性数据集。我们定义一个函数representative_dataset_generator(),使用yield操作符逐个返回这些值。
为了证明这些模型在转换和量化后仍然准确,我们使用它们进行预测,并将结果与我们的测试结果进行比较。鉴于这些是 TensorFlow Lite 模型,我们需要使用 TensorFlow Lite 解释器来执行此操作。
由于 TensorFlow Lite 解释器主要设计用于效率,因此使用起来比 Keras API 稍微复杂一些。要使用我们的 Keras 模型进行预测,我们只需调用predict()方法,传递一个输入数组即可。而对于 TensorFlow Lite,我们需要执行以下操作:
-
实例化一个
Interpreter对象。 -
调用一些为模型分配内存的方法。
-
将输入写入输入张量。
-
调用模型。
-
从输出张量中读取输出。
这听起来很多,但现在不要太担心;我们将在第五章中详细介绍。现在,运行以下单元格,使用两个模型进行预测,并将它们与原始未转换的模型的结果一起绘制在图表上:
# Instantiate an interpreter for each model
sine_model = tf.lite.Interpreter('sine_model.tflite')
sine_model_quantized = tf.lite.Interpreter('sine_model_quantized.tflite')
# Allocate memory for each model
sine_model.allocate_tensors()
sine_model_quantized.allocate_tensors()
# Get indexes of the input and output tensors
sine_model_input_index = sine_model.get_input_details()[0]["index"]
sine_model_output_index = sine_model.get_output_details()[0]["index"]
sine_model_quantized_input_index = sine_model_quantized.get_input_details()[0]["index"]
sine_model_quantized_output_index = \
sine_model_quantized.get_output_details()[0]["index"]
# Create arrays to store the results
sine_model_predictions = []
sine_model_quantized_predictions = []
# Run each model's interpreter for each value and store the results in arrays
for x_value in x_test:
# Create a 2D tensor wrapping the current x value
x_value_tensor = tf.convert_to_tensor([[x_value]], dtype=np.float32)
# Write the value to the input tensor
sine_model.set_tensor(sine_model_input_index, x_value_tensor)
# Run inference
sine_model.invoke()
# Read the prediction from the output tensor
sine_model_predictions.append(
sine_model.get_tensor(sine_model_output_index)[0])
# Do the same for the quantized model
sine_model_quantized.set_tensor\
(sine_model_quantized_input_index, x_value_tensor)
sine_model_quantized.invoke()
sine_model_quantized_predictions.append(
sine_model_quantized.get_tensor(sine_model_quantized_output_index)[0])
# See how they line up with the data
plt.clf()
plt.title('Comparison of various models against actual values')
plt.plot(x_test, y_test, 'bo', label='Actual')
plt.plot(x_test, predictions, 'ro', label='Original predictions')
plt.plot(x_test, sine_model_predictions, 'bx', label='Lite predictions')
plt.plot(x_test, sine_model_quantized_predictions, 'gx', \
label='Lite quantized predictions')
plt.legend()
plt.show()
运行此单元格将产生图 4-21 中的图表。

图 4-21。比较模型预测与实际值的图表
从图表中我们可以看到,原始模型、转换模型和量化模型的预测都非常接近,几乎无法区分。情况看起来很不错!
由于量化使模型变小,让我们比较两个转换后的模型,看看大小上的差异。运行以下单元格计算它们的大小并进行比较:
import os
basic_model_size = os.path.getsize("sine_model.tflite")
print("Basic model is %d bytes" % basic_model_size)
quantized_model_size = os.path.getsize("sine_model_quantized.tflite")
print("Quantized model is %d bytes" % quantized_model_size)
difference = basic_model_size - quantized_model_size
print("Difference is %d bytes" % difference)
您应该看到以下输出:
Basic model is 2736 bytes
Quantized model is 2512 bytes
Difference is 224 bytes
我们的量化模型比原始版本小 224 字节,这很好,但大小只有轻微减小。在约 2.4 KB 左右,这个模型已经非常小,权重和偏差只占整体大小的一小部分。除了权重,模型还包含构成我们深度学习网络架构的所有逻辑,称为计算图。对于真正微小的模型,这可能比模型的权重占用更多的空间,这意味着量化几乎没有效果。
更复杂的模型有更多的权重,这意味着量化带来的空间节省将更高。对于大多数复杂模型,可以预期接近四倍。
无论其确切大小如何,我们的量化模型执行起来都比原始版本快,这对于微小微控制器非常重要。
转换为 C 文件
为了让我们的模型能够与 TensorFlow Lite for Microcontrollers 一起使用的最后一步是将其转换为一个可以包含在我们应用程序中的 C 源文件。
在本章中,我们一直在使用 TensorFlow Lite 的 Python API。这意味着我们可以使用Interpreter构造函数从磁盘加载我们的模型文件。
然而,大多数微控制器没有文件系统,即使有,从磁盘加载模型所需的额外代码也会在有限的空间下是浪费的。相反,作为一个优雅的解决方案,我们提供了一个可以包含在我们的二进制文件中并直接加载到内存中的 C 源文件中的模型。
在文件中,模型被定义为一个字节数组。幸运的是,有一个方便的 Unix 工具名为xxd,能够将给定文件转换为所需的格式。
以下单元格在我们的量化模型上运行xxd,将输出写入名为sine_model_quantized.cc的文件,并将其打印到屏幕上:
# Install xxd if it is not available
!apt-get -qq install xxd
# Save the file as a C source file
!xxd -i sine_model_quantized.tflite > sine_model_quantized.cc
# Print the source file
!cat sine_model_quantized.cc
输出非常长,所以我们不会在这里全部复制,但这里有一个片段,包括开头和结尾:
unsigned char sine_model_quantized_tflite[] = {
0x1c, 0x00, 0x00, 0x00, 0x54, 0x46, 0x4c, 0x33, 0x00, 0x00, 0x12, 0x00,
0x1c, 0x00, 0x04, 0x00, 0x08, 0x00, 0x0c, 0x00, 0x10, 0x00, 0x14, 0x00,
// ...
0x00, 0x00, 0x08, 0x00, 0x0a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x09,
0x04, 0x00, 0x00, 0x00
};
unsigned int sine_model_quantized_tflite_len = 2512;
要在项目中使用这个模型,您可以复制粘贴源代码,或者从笔记本中下载文件。
总结
有了这个,我们构建我们的模型就完成了。我们已经训练、评估并转换了一个 TensorFlow 深度学习网络,可以接收 0 到 2π之间的数字,并输出其正弦的良好近似值。
这是我们第一次使用 Keras 训练微小模型。在未来的项目中,我们将训练仍然微小但远远更复杂的模型。
现在,让我们继续第五章,在那里我们将编写代码在微控制器上运行我们的模型。
第五章:TinyML 的“Hello World”:构建一个应用程序
模型只是机器学习应用程序的一部分。单独来看,它只是一块信息;它几乎什么都做不了。要使用我们的模型,我们需要将其包装在代码中,为其设置必要的运行环境,提供输入,并使用其输出生成行为。图 5-1 显示了模型在右侧如何适配到基本 TinyML 应用程序中。
在本章中,我们将构建一个嵌入式应用程序,使用我们的正弦模型创建一个微小的灯光秀。我们将设置一个连续循环,将一个x值输入模型,运行推断,并使用结果来开关 LED 灯,或者控制动画,如果我们的设备有 LCD 显示器的话。
这个应用程序已经编写好了。这是一个 C++ 11 程序,其代码旨在展示一个完整的 TinyML 应用程序的最小可能实现,避免任何复杂的逻辑。这种简单性使它成为学习如何使用 TensorFlow Lite for Microcontrollers 的有用工具,因为您可以清楚地看到需要哪些代码,以及很少的其他内容。它也是一个有用的模板。阅读完本章后,您将了解 TensorFlow Lite for Microcontrollers 程序的一般结构,并可以在自己的项目中重用相同的结构。
本章将逐步介绍应用程序代码并解释其工作原理。下一章将提供详细的构建和部署说明,适用于多种设备。如果您不熟悉 C++,不要惊慌。代码相对简单,我们会详细解释一切。到最后,您应该对运行模型所需的所有代码感到满意,甚至可能在学习过程中学到一些 C++知识。

图 5-1。基本 TinyML 应用程序架构
提示
请记住,由于 TensorFlow 是一个积极开发的开源项目,这里打印的代码与在线代码之间可能存在一些细微差异。不用担心,即使有一些代码行发生变化,基本原则仍然保持不变。
测试步骤
在处理应用程序代码之前,编写一些测试通常是一个好主意。测试是演示特定逻辑片段的短代码片段。由于它们由可工作的代码组成,我们可以运行它们来证明代码是否按预期运行。编写完测试后,通常会自动运行测试,以持续验证项目是否仍然按照我们的期望运行,尽管我们可能对其代码进行了任何更改。它们也非常有用,作为如何执行操作的工作示例。
hello_world示例有一个测试,在hello_world_test.cc中定义,加载我们的模型并使用它运行推断,检查其预测是否符合我们的期望。它包含了执行此操作所需的确切代码,没有其他内容,因此这将是学习 TensorFlow Lite for Microcontrollers 的绝佳起点。在本节中,我们将逐步介绍测试,并解释其中的每个部分的作用。阅读完代码后,我们可以运行测试以证明其正确性。
现在让我们逐节来看一下。如果您在电脑上,打开hello_world_test.cc并跟着走可能会有帮助。
包含依赖项
第一部分,在许可证标题下方(指定任何人都可以在Apache 2.0开源许可下使用或共享此代码),如下所示:
#include "tensorflow/lite/micro/examples/hello_world/sine_model_data.h"
#include "tensorflow/lite/micro/kernels/all_ops_resolver.h"
#include "tensorflow/lite/micro/micro_error_reporter.h"
#include "tensorflow/lite/micro/micro_interpreter.h"
#include "tensorflow/lite/micro/testing/micro_test.h"
#include "tensorflow/lite/schema/schema_generated.h"
#include "tensorflow/lite/version.h"
#include指令是 C++代码指定其依赖的其他代码的一种方式。当使用#include引用代码文件时,它定义的任何逻辑或变量将可供我们使用。在本节中,我们使用#include导入以下项目:
tensorflow/lite/micro/examples/hello_world/sine_model_data.h
我们训练的正弦模型,使用xxd转换并转换为 C++
tensorflow/lite/micro/kernels/all_ops_resolver.h
一个允许解释器加载我们模型使用的操作的类
tensorflow/lite/micro/micro_error_reporter.h
一个可以记录错误并输出以帮助调试的类
- tensorflow/lite/micro/micro_interpreter.h *
将运行我们模型的 TensorFlow Lite for Microcontrollers 解释器
tensorflow/lite/micro/testing/micro_test.h
一个用于编写测试的轻量级框架,允许我们将此文件作为测试运行
tensorflow/lite/schema/schema_generated.h
定义了 TensorFlow Lite FlatBuffer 数据结构的模式,用于理解sine_model_data.h中的模型数据
tensorflow/lite/version.h
模式的当前版本号,以便我们可以检查模型是否使用兼容版本定义
当我们深入代码时,我们将更多地讨论其中一些依赖关系。
注意
按照惯例,设计用于与#include指令一起使用的 C++代码通常编写为两个文件:一个.cc文件,称为源文件,以及一个.h文件,称为头文件。头文件定义了允许代码连接到程序其他部分的接口。它们包含变量和类声明等内容,但几乎没有逻辑。源文件实现了执行计算和使事情发生的实际逻辑。当我们#include一个依赖项时,我们指定其头文件。例如,我们正在讨论的测试包括micro_interpreter.h。如果我们查看该文件,我们可以看到它定义了一个类,但没有包含太多逻辑。相反,它的逻辑包含在micro_interpreter.cc中。
设置测试
代码的下一部分用于 TensorFlow Lite for Microcontrollers 测试框架。看起来像这样:
TF_LITE_MICRO_TESTS_BEGIN
TF_LITE_MICRO_TEST(LoadModelAndPerformInference) {
在 C++中,您可以定义特殊命名的代码块,可以通过在其他地方包含它们的名称来重用。这些代码块称为宏。这里的两个语句,TF_LITE_MICRO_TESTS_BEGIN和TF_LITE_MICRO_TEST,是宏的名称。它们在文件micro_test.h中定义。
这些宏将我们代码的其余部分包装在必要的装置中,以便通过 TensorFlow Lite for Microcontrollers 测试框架执行。我们不需要担心这是如何工作的;我们只需要知道我们可以使用这些宏作为设置测试的快捷方式。
第二个名为TF_LITE_MICRO_TEST的宏接受一个参数。在这种情况下,传入的参数是LoadModelAndPerformInference。这个参数是测试名称,当运行测试时,它将与测试结果一起输出,以便我们可以看到测试是通过还是失败。
准备记录数据
文件中剩余的代码是我们测试的实际逻辑。让我们看一下第一部分:
// Set up logging
tflite::MicroErrorReporter micro_error_reporter;
tflite::ErrorReporter* error_reporter = µ_error_reporter;
在第一行中,我们定义了一个MicroErrorReporter实例。MicroErrorReporter类在micro_error_reporter.h中定义。它提供了在推理期间记录调试信息的机制。我们将调用它来打印调试信息,而 TensorFlow Lite for Microcontrollers 解释器将使用它来打印遇到的任何错误。
注意
您可能已经注意到每个类型名称之前的tflite::前缀,例如tflite::MicroErrorReporter。这是一个命名空间,只是帮助组织 C++代码的一种方式。TensorFlow Lite 在命名空间tflite下定义了所有有用的内容,这意味着如果另一个库恰好实现了具有相同名称的类,它们不会与 TensorFlow Lite 提供的类发生冲突。
第一个声明看起来很简单,但是第二行看起来有点奇怪,带有*和&字符?为什么我们要声明一个ErrorReporter当我们已经有一个MicroErrorReporter了?
tflite::ErrorReporter* error_reporter = µ_error_reporter;
解释这里发生的事情,我们需要了解一些背景信息。
MicroErrorReporter是ErrorReporter类的一个子类,为 TensorFlow Lite 中这种调试日志机制应该如何工作提供了一个模板。MicroErrorReporter覆盖了ErrorReporter的一个方法,用专门为在微控制器上使用而编写的逻辑替换它。
在前面的代码行中,我们创建了一个名为error_reporter的变量,它的类型是ErrorReporter。它也是一个指针,其声明中使用了*表示。
指针是一种特殊类型的变量,它不是保存一个值,而是保存一个引用,指向内存中的一个值。在 C++中,一个类的指针(比如ErrorReporter)可以指向它的一个子类(比如MicroErrorReporter)的值。
正如我们之前提到的,MicroErrorReporter覆盖了ErrorReporter的一个方法。不详细讨论,覆盖这个方法的过程会导致一些其他方法被隐藏。
为了仍然可以访问ErrorReporter的未覆盖方法,我们需要将我们的MicroErrorReporter实例视为实际上是ErrorReporter。我们通过创建一个ErrorReporter指针并将其指向micro_error_reporter变量来实现这一点。赋值语句中&在micro_error_reporter前面的意思是我们正在分配它的指针,而不是它的值。
哎呀!听起来很复杂。如果你觉得难以理解,不要惊慌;C++可能有点难以掌握。对于我们的目的,我们只需要知道我们应该使用error_reporter来打印调试信息,并且它是一个指针。
映射我们的模型
我们立即建立打印调试信息的机制的原因是为了记录代码中发生的任何问题。我们在下一段代码中依赖于这一点:
// Map the model into a usable data structure. This doesn't involve any
// copying or parsing, it's a very lightweight operation.
const tflite::Model* model = ::tflite::GetModel(g_sine_model_data);
if (model->version() != TFLITE_SCHEMA_VERSION) {
error_reporter->Report(
"Model provided is schema version %d not equal "
"to supported version %d.\n",
model->version(), TFLITE_SCHEMA_VERSION);
return 1;
}
在第一行中,我们将我们的模型数据数组(在文件sine_model_data.h中定义)传递给一个名为GetModel()的方法。这个方法返回一个Model指针,被赋值给一个名为model的变量。正如你可能预料的那样,这个变量代表我们的模型。
类型Model是一个struct,在 C++中与类非常相似。它在schema_generated.h中定义,它保存我们模型的数据并允许我们查询有关它的信息。
一旦model准备好,我们调用一个检索模型版本号的方法:
if (model->version() != TFLITE_SCHEMA_VERSION) {
然后我们将模型的版本号与TFLITE_SCHEMA_VERSION进行比较,这表示我们当前使用的 TensorFlow Lite 库的版本。如果数字匹配,我们的模型是使用兼容版本的 TensorFlow Lite Converter 转换的。检查模型版本是一个好习惯,因为版本不匹配可能导致难以调试的奇怪行为。
注意
在前一行代码中,version()是属于model的一个方法。注意箭头(->)从model指向version()。这是 C++的箭头运算符,当我们想要访问一个对象的成员时使用。如果我们有对象本身(而不仅仅是一个指针),我们将使用点(.)来访问它的成员。
如果版本号不匹配,我们仍然会继续,但我们会使用我们的error_reporter记录一个警告:
error_reporter->Report(
"Model provided is schema version %d not equal "
"to supported version %d.\n",
model->version(), TFLITE_SCHEMA_VERSION);
我们调用error_reporter的Report()方法来记录这个警告。由于error_reporter也是一个指针,我们使用->运算符来访问Report()。
Report()方法的设计类似于一个常用的 C++方法printf(),用于记录文本。作为它的第一个参数,我们传递一个我们想要记录的字符串。这个字符串包含两个%d格式说明符,它们充当变量在消息记录时插入的占位符。我们传递的下两个参数是模型版本和 TensorFlow Lite 模式版本。这些将按顺序插入到字符串中,以替换%d字符。
注意
Report()方法支持不同的格式说明符,用作不同类型变量的占位符。%d应该用作整数的占位符,%f应该用作浮点数的占位符,%s应该用作字符串的占位符。
创建一个 AllOpsResolver
到目前为止一切顺利!我们的代码可以记录错误,我们已经将模型加载到一个方便的结构中,并检查它是否是兼容的版本。鉴于我们一路上在回顾一些 C++概念,我们进展有点慢,但事情开始变得清晰起来了。接下来,我们创建一个AllOpsResolver的实例:
// This pulls in all the operation implementations we need
tflite::ops::micro::AllOpsResolver resolver;
这个类在all_ops_resolver.h中定义,它允许 TensorFlow Lite for Microcontrollers 解释器访问操作。
在第三章中,您了解到机器学习模型由各种数学运算组成,这些运算按顺序运行,将输入转换为输出。AllOpsResolver类知道 TensorFlow Lite for Microcontrollers 可用的所有操作,并能够将它们提供给解释器。
定义张量区域
我们几乎已经准备好创建一个解释器所需的所有要素。我们需要做的最后一件事是分配一个工作内存区域,我们的模型在运行时将需要这个内存区域:
// Create an area of memory to use for input, output, and intermediate arrays.
// Finding the minimum value for your model may require some trial and error.
const int tensor_arena_size = 2 × 1024;
uint8_t tensor_arena[tensor_arena_size];
正如注释所说,这个内存区域将用于存储模型的输入、输出和中间张量。我们称之为我们的张量区域。在我们的情况下,我们分配了一个大小为 2,048 字节的数组。我们用表达式2 × 1024来指定这一点。
那么,我们的张量区域应该有多大呢?这是一个很好的问题。不幸的是,没有一个简单的答案。不同的模型架构具有不同大小和数量的输入、输出和中间张量,因此很难知道我们需要多少内存。数字不需要精确——我们可以保留比我们需要的更多的内存——但由于微控制器的 RAM 有限,我们应该尽可能保持它小,以便为程序的其余部分留出空间。
我们可以通过试错来完成这个过程。这就是为什么我们将数组大小表示为n × 1024:这样可以很容易地通过改变n来扩大或缩小数字(保持为 8 的倍数)。要找到正确的数组大小,从一个相对较高的数字开始,以确保它有效。本书示例中使用的最大数字是70 × 1024。然后,减少数字直到您的模型不再运行。最后一个有效的数字就是正确的数字!
创建解释器
现在我们已经声明了tensor_arena,我们准备设置解释器。下面是具体步骤:
// Build an interpreter to run the model with
tflite::MicroInterpreter interpreter(model, resolver, tensor_arena,
tensor_arena_size, error_reporter);
// Allocate memory from the tensor_arena for the model's tensors
interpreter.AllocateTensors();
首先,我们声明一个名为interpreter的MicroInterpreter。这个类是 TensorFlow Lite for Microcontrollers 的核心:一个神奇的代码片段,将在我们提供的数据上执行我们的模型。我们将迄今为止创建的大部分对象传递给它的构造函数,然后调用AllocateTensors()。
在前一节中,我们通过定义一个名为tensor_arena的数组来设置了一个内存区域。AllocateTensors()方法遍历模型定义的所有张量,并为每个张量从tensor_arena中分配内存。在尝试运行推理之前,我们必须调用AllocateTensors(),因为否则推理将失败。
检查输入张量
在我们创建了一个解释器之后,我们需要为我们的模型提供一些输入。为此,我们将我们的输入数据写入模型的输入张量:
// Obtain a pointer to the model's input tensor
TfLiteTensor* input = interpreter.input(0);
要获取输入张量的指针,我们调用解释器的input()方法。由于一个模型可以有多个输入张量,我们需要向input()方法传递一个指定我们想要的张量的索引。在这种情况下,我们的模型只有一个输入张量,所以它的索引是0。
在 TensorFlow Lite 中,张量由TfLiteTensor结构表示,该结构在c_api_internal.h中定义。这个结构提供了一个 API 来与张量进行交互和了解张量。在下一段代码中,我们使用这个功能来验证我们的张量看起来和感觉正确。因为我们将经常使用张量,让我们通过这段代码来熟悉TfLiteTensor结构的工作方式:
// Make sure the input has the properties we expect
TF_LITE_MICRO_EXPECT_NE(nullptr, input);
// The property "dims" tells us the tensor's shape. It has one element for
// each dimension. Our input is a 2D tensor containing 1 element, so "dims"
// should have size 2.
TF_LITE_MICRO_EXPECT_EQ(2, input->dims->size);
// The value of each element gives the length of the corresponding tensor.
// We should expect two single element tensors (one is contained within the
// other).
TF_LITE_MICRO_EXPECT_EQ(1, input->dims->data[0]);
TF_LITE_MICRO_EXPECT_EQ(1, input->dims->data[1]);
// The input is a 32 bit floating point value
TF_LITE_MICRO_EXPECT_EQ(kTfLiteFloat32, input->type);
你会注意到的第一件事是一对宏:TFLITE_MICRO_EXPECT_NE和TFLITE_MICRO_EXPECT_EQ。这些宏是 TensorFlow Lite for Microcontrollers 测试框架的一部分,它们允许我们对变量的值进行断言,证明它们具有某些期望的值。
例如,宏TF_LITE_MICRO_EXPECT_NE旨在断言它所调用的两个变量不相等(因此其名称中的_NE部分表示不相等)。如果变量不相等,代码将继续执行。如果它们相等,将记录一个错误,并标记测试为失败。
我们首先检查的是我们的输入张量是否实际存在。为了做到这一点,我们断言它不等于nullptr,这是一个特殊的 C++值,表示一个指针实际上没有指向任何数据:
TF_LITE_MICRO_EXPECT_NE(nullptr, input);
我们接下来检查的是我们输入张量的形状。如第三章中讨论的,所有张量都有一个形状,这是描述它们维度的一种方式。我们模型的输入是一个标量值(表示一个单个数字)。然而,由于Keras 层接受输入的方式,这个值必须提供在一个包含一个数字的 2D 张量中。对于输入 0,它应该是这样的:
[[0]]
请注意,输入标量 0 被包裹在两个向量中,使其成为一个 2D 张量。
TfLiteTensor结构包含一个dims成员,描述张量的维度。该成员是一个类型为TfLiteIntArray的结构,也在c_api_internal.h中定义。它的size成员表示张量的维度数。由于输入张量应该是 2D 的,我们可以断言size的值为2:
TF_LITE_MICRO_EXPECT_EQ(2, input->dims->size);
我们可以进一步检查dims结构,以确保张量的结构是我们期望的。它的data变量是一个数组,每个维度有一个元素。每个元素是一个表示该维度大小的整数。因为我们期望一个包含每个维度一个元素的 2D 张量,我们可以断言两个维度都包含一个单一元素:
TF_LITE_MICRO_EXPECT_EQ(1, input->dims->data[0]);
TF_LITE_MICRO_EXPECT_EQ(1, input->dims->data[1]);
我们现在可以确信我们的输入张量具有正确的形状。最后,由于张量可以由各种不同类型的数据组成(比如整数、浮点数和布尔值),我们应该确保我们的输入张量具有正确的类型。
张量结构体的type变量告诉我们张量的数据类型。我们将提供一个 32 位浮点数,由常量kTfLiteFloat32表示,我们可以轻松地断言类型是正确的:
TF_LITE_MICRO_EXPECT_EQ(kTfLiteFloat32, input->type);
完美——我们的输入张量现在已经保证是正确的大小和形状,适用于我们的输入数据,这将是一个单个浮点值。我们准备好进行推理了!
在输入上运行推理
要运行推理,我们需要向我们的输入张量添加一个值,然后指示解释器调用模型。之后,我们将检查模型是否成功运行。这是它的样子:
// Provide an input value
input->data.f[0] = 0.;
// Run the model on this input and check that it succeeds
TfLiteStatus invoke_status = interpreter.Invoke();
if (invoke_status != kTfLiteOk) {
error_reporter->Report("Invoke failed\n");
}
TF_LITE_MICRO_EXPECT_EQ(kTfLiteOk, invoke_status);
TensorFlow Lite 的TfLiteTensor结构有一个data变量,我们可以用来设置输入张量的内容。你可以在这里看到它被使用:
input->data.f[0] = 0.;
data变量是一个TfLitePtrUnion——它是一个union,是一种特殊的 C++数据类型,允许您在内存中的同一位置存储不同的数据类型。由于给定张量可以包含多种不同类型的数据(例如浮点数、整数或布尔值),union 是帮助我们存储它的完美类型。
TfLitePtrUnion联合在c_api_internal.h中声明。这是它的样子:
// A union of pointers that points to memory for a given tensor.
typedef union {
int32_t* i32;
int64_t* i64;
float* f;
TfLiteFloat16* f16;
char* raw;
const char* raw_const;
uint8_t* uint8;
bool* b;
int16_t* i16;
TfLiteComplex64* c64;
int8_t* int8;
} TfLitePtrUnion;
您可以看到有一堆成员,每个代表一种特定类型。每个成员都是一个指针,可以指向内存中应存储数据的位置。当我们像之前那样调用interpreter.AllocateTensors()时,适当的指针被设置为指向为张量分配的内存块,以存储其数据。因为每个张量有一个特定的数据类型,所以只有相应类型的指针会被设置。
这意味着为了存储数据,我们可以在TfLitePtrUnion中使用适当的指针。例如,如果我们的张量是kTfLiteFloat32类型,我们将使用data.f。
由于指针指向一块内存块,我们可以在指针名称后使用方括号([])来指示程序在哪里存储数据。在我们的例子中,我们这样做:
input->data.f[0] = 0.;
我们分配的值写为0.,这是0.0的简写。通过指定小数点,我们让 C++编译器清楚地知道这个值应该是一个浮点数,而不是整数。
您可以看到我们将这个值分配给data.f[0]。这意味着我们将其分配为我们分配的内存块中的第一个项目。鉴于只有一个值,这就是我们需要做的。
设置完输入张量后,是时候运行推理了。这是一个一行代码:
TfLiteStatus invoke_status = interpreter.Invoke();
当我们在interpreter上调用Invoke()时,TensorFlow Lite 解释器会运行模型。该模型由数学运算图组成,解释器执行这些运算以将输入数据转换为输出。这个输出存储在模型的输出张量中,我们稍后会深入研究。
Invoke()方法返回一个TfLiteStatus对象,让我们知道推理是否成功或是否有问题。它的值可以是kTfLiteOk或kTfLiteError。我们检查是否有错误,并在有错误时报告:
if (invoke_status != kTfLiteOk) {
error_reporter->Report("Invoke failed\n");
}
最后,我们断言状态必须是kTfLiteOk,以便我们的测试通过:
TF_LITE_MICRO_EXPECT_EQ(kTfLiteOk, invoke_status);
就这样——推理已经运行!接下来,我们获取输出并确保它看起来不错。
阅读输出
与输入一样,我们模型的输出通过TfLiteTensor访问,获取指向它的指针同样简单:
TfLiteTensor* output = interpreter.output(0);
输出与输入一样,是一个嵌套在 2D 张量中的浮点标量值。为了测试,我们再次检查输出张量的预期大小、维度和类型:
TF_LITE_MICRO_EXPECT_EQ(2, output->dims->size);
TF_LITE_MICRO_EXPECT_EQ(1, input->dims->data[0]);
TF_LITE_MICRO_EXPECT_EQ(1, input->dims->data[1]);
TF_LITE_MICRO_EXPECT_EQ(kTfLiteFloat32, output->type);
是的,一切看起来都很好。现在,我们获取输出值并检查它,确保它符合我们的高标准。首先,我们将其分配给一个float变量:
// Obtain the output value from the tensor
float value = output->data.f[0];
每次运行推理时,输出张量将被新值覆盖。这意味着如果您想在程序中保留一个输出值,同时继续运行推理,您需要从输出张量中复制它,就像我们刚刚做的那样。
接下来,我们使用TF_LITE_MICRO_EXPECT_NEAR来证明该值接近我们期望的值:
// Check that the output value is within 0.05 of the expected value
TF_LITE_MICRO_EXPECT_NEAR(0., value, 0.05);
正如我们之前看到的,TF_LITE_MICRO_EXPECT_NEAR断言其第一个参数和第二个参数之间的差异小于其第三个参数的值。在这个语句中,我们测试输出是否在 0 的数学正弦输入 0 的 0.05 范围内。
注意
我们期望得到一个接近我们想要的数字的原因有两个,但不是一个精确值。第一个原因是我们的模型只是近似真实的正弦值,所以我们知道它不会完全正确。第二个原因是因为计算机上的浮点计算有一个误差范围。误差可能因计算机而异:例如,笔记本电脑的 CPU 可能会产生与 Arduino 稍有不同的结果。通过具有灵活的期望,我们更有可能使我们的测试在任何平台上通过。
如果这个测试通过,情况看起来很好。其余的测试会再次运行推理几次,只是为了进一步证明我们的模型正在工作。要再次运行推理,我们只需要为我们的输入张量分配一个新值,调用 interpreter.Invoke(),并从输出张量中读取输出:
// Run inference on several more values and confirm the expected outputs
input->data.f[0] = 1.;
interpreter.Invoke();
value = output->data.f[0];
TF_LITE_MICRO_EXPECT_NEAR(0.841, value, 0.05);
input->data.f[0] = 3.;
interpreter.Invoke();
value = output->data.f[0];
TF_LITE_MICRO_EXPECT_NEAR(0.141, value, 0.05);
input->data.f[0] = 5.;
interpreter.Invoke();
value = output->data.f[0];
TF_LITE_MICRO_EXPECT_NEAR(-0.959, value, 0.05);
请注意我们如何重复使用相同的 input 和 output 张量指针。因为我们已经有了指针,所以我们不需要再次调用 interpreter.input(0) 或 interpreter.output(0)。
在我们的测试中,我们已经证明了 TensorFlow Lite for Microcontrollers 可以成功加载我们的模型,分配适当的输入和输出张量,运行推理,并返回预期的结果。最后要做的是使用宏指示测试的结束:
}
TF_LITE_MICRO_TESTS_END
有了这些,我们完成了测试。接下来,让我们运行它们!
运行测试
尽管这段代码最终将在微控制器上运行,但我们仍然可以在开发计算机上构建和运行我们的测试。这样做可以更轻松地编写和调试代码。与微控制器相比,个人计算机具有更方便的日志记录工具和代码调试工具,这使得更容易找出任何错误。此外,将代码部署到设备需要时间,因此仅在本地运行代码会更快。
构建嵌入式应用程序(或者说,任何类型的软件)的一个好的工作流程是尽可能多地在可以在普通开发计算机上运行的测试中编写逻辑。总会有一些部分需要实际硬件才能运行,但你在本地测试的越多,你的生活就会变得更容易。
实际上,这意味着我们应该尝试在一组测试中编写预处理输入、使用模型运行推理以及处理任何输出的代码,然后再尝试在设备上使其正常工作。在第七章中,我们将介绍一个比这个示例复杂得多的语音识别应用程序。你将看到我们为其每个组件编写了详细的单元测试。
获取代码
到目前为止,在 Colab 和 GitHub 之间,我们一直在云端进行所有操作。为了运行我们的测试,我们需要将代码下载到我们的开发计算机并进行编译。
为了做到这一切,我们需要以下软件工具:
-
终端仿真器,如 macOS 中的终端
-
一个 bash shell(在 macOS Catalina 之前和大多数 Linux 发行版中是默认的)
-
Git(在 macOS 和大多数 Linux 发行版中默认安装)
-
Make,版本 3.82 或更高版本
在你拥有所有工具之后,打开一个终端并输入以下命令来下载 TensorFlow 源代码,其中包括我们正在使用的示例代码。它将在你运行它的任何位置创建一个包含源代码的目录:
git clone https://github.com/tensorflow/tensorflow.git
接下来,切换到刚刚创建的 tensorflow 目录:
cd tensorflow
太棒了 - 我们现在准备运行一些代码!
使用 Make 运行测试
从我们的工具列表中可以看到,我们使用一个名为Make的程序来运行测试。Make 是一个用于自动化软件构建任务的工具。它自 1976 年以来一直在使用,从计算术语来看几乎是永远的。开发人员使用一种特殊的语言,在名为Makefiles的文件中编写,指示 Make 如何构建和运行代码。TensorFlow Lite for Microcontrollers 在micro/tools/make/Makefile中定义了一个 Makefile;在第十三章中有更多关于它的信息。
要使用 Make 运行我们的测试,我们可以发出以下命令,确保我们是从使用 Git 下载的tensorflow目录的根目录运行。我们首先指定要使用的 Makefile,然后是target,即我们要构建的组件:
make -f tensorflow/lite/micro/tools/make/Makefile test_hello_world_test
Makefile 被设置为为了运行测试,我们提供一个以test_为前缀的目标,后面跟着我们想要构建的组件的名称。在我们的情况下,该组件是hello_world_test,因此完整的目标名称是test_hello_world_test。
尝试运行这个命令。您应该开始看到大量输出飞过!首先,将下载一些必要的库和工具。接下来,我们的测试文件以及所有依赖项将被构建。我们的 Makefile 已经指示 C++编译器构建代码并创建一个二进制文件,然后运行它。
您需要等待一段时间才能完成这个过程。当文本停止飞过时,最后几行应该是这样的:
Testing LoadModelAndPerformInference
1/1 tests passed
~~~ALL TESTS PASSED~~~
很好!这个输出显示我们的测试按预期通过了。您可以看到测试的名称LoadModelAndPerformInference,如其源文件顶部所定义。即使它还没有在微控制器上,我们的代码也成功地运行了推断。
要查看测试失败时会发生什么,让我们引入一个错误。打开测试文件hello_world_test.cc。它将位于相对于目录根目录的路径:
tensorflow/lite/micro/examples/hello_world/hello_world_test.cc
为了使测试失败,让我们为模型提供不同的输入。这将导致模型的输出发生变化,因此检查我们输出值的断言将失败。找到以下行:
input->data.f[0] = 0.;
更改分配的值,如下所示:
input->data.f[0] = 1.;
现在保存文件,并使用以下命令再次运行测试(记得要从tensorflow目录的根目录运行):
make -f tensorflow/lite/micro/tools/make/Makefile test_hello_world_test
代码将被重建,测试将运行。您看到的最终输出应该如下所示:
Testing LoadModelAndPerformInference
0.0486171 near value failed at tensorflow/lite/micro/examples/hello_world/\
hello_world_test.cc:94
0/1 tests passed
~~~SOME TESTS FAILED~~~
输出包含有关测试失败原因的一些有用信息,包括失败发生的文件和行号(hello_world_test.cc:94)。如果这是由于真正的错误引起的,这个输出将有助于追踪问题。
项目文件结构
借助我们的测试,您已经学会了如何使用 TensorFlow Lite for Microcontrollers 库在 C++中运行推断。接下来,我们将浏览一个实际应用程序的源代码。
如前所述,我们正在构建的程序由一个连续循环组成,该循环将一个x值输入模型,运行推断,并使用结果生成某种可见输出(如闪烁 LED 的模式),具体取决于平台。
因为应用程序很复杂,涉及多个文件,让我们看看它的结构以及它们如何相互配合。
应用程序的根目录在tensorflow/lite/micro/examples/hello_world中。它包含以下文件:
BUILD
一个列出可以使用应用程序源代码构建的各种内容的文件,包括主应用程序二进制文件和我们之前讨论过的测试。在这一点上,我们不需要太担心它。
Makefile.inc
一个包含有关应用程序内部构建目标信息的 Makefile,包括hello_world_test,这是我们之前运行的测试,以及hello_world,主应用程序二进制文件。它定义了它们的哪些源文件是其中的一部分。
README.md
一个包含构建和运行应用程序说明的自述文件。
constants.h, constants.cc
一对包含各种常量(在程序生命周期中不会改变的变量)的文件,这些常量对于定义程序行为很重要。
create_sine_model.ipynb
在上一章中使用的 Jupyter 笔记本。
hello_world_test.cc
一个使用我们模型运行推断的测试。
main.cc
程序的入口点,在应用部署到设备时首先运行。
main_functions.h, main_functions.cc
一对文件,定义了一个setup()函数,执行我们程序所需的所有初始化,以及一个loop()函数,包含程序的核心逻辑,并设计为在循环中重复调用。这些函数在程序启动时由main.cc调用。
output_handler.h, output_handler.cc
一对文件,定义了一个函数,我们可以用它来显示每次运行推断时的输出。默认实现在output_handler.cc中,将结果打印到屏幕上。我们可以覆盖这个实现,使其在不同设备上执行不同的操作。
output_handler_test.cc
一个证明output_handler.h和output_handler.cc中的代码正常工作的测试。
sine_model_data.h, sine_model_data.cc
一对文件,定义了一个表示我们模型的数据数组,这些数据是在本章的第一部分中使用xxd导出的。
除了这些文件外,目录中还包含以下子目录(可能还有更多):
-
arduino/
-
disco_f76ng/
-
sparkfun_edge/
因为不同的微控制器平台具有不同的功能和 API,我们的项目结构允许我们提供设备特定版本的源文件,如果应用程序为该设备构建,则将使用这些版本而不是默认版本。例如,arduino目录包含了定制版本的main.cc、constants.cc和output_handler.cc,以使应用程序能够与 Arduino 兼容。我们稍后会深入研究这些定制实现。
源代码解析
现在我们知道了应用程序源代码的结构,让我们深入代码。我们将从main_functions.cc开始,这里发生了大部分的魔法,并从那里扩展到其他文件。
注意
这段代码中很多内容在hello_world_test.cc中会看起来很熟悉。如果我们已经涵盖了某些内容,我们不会深入讨论它的工作原理;我们更愿意主要关注您之前没有见过的内容。
从 main_functions.cc 开始
这个文件包含了我们程序的核心逻辑。它从一些熟悉的#include语句和一些新的语句开始:
#include "tensorflow/lite/micro/examples/hello_world/main_functions.h"
#include "tensorflow/lite/micro/examples/hello_world/constants.h"
#include "tensorflow/lite/micro/examples/hello_world/output_handler.h"
#include "tensorflow/lite/micro/examples/hello_world/sine_model_data.h"
#include "tensorflow/lite/micro/kernels/all_ops_resolver.h"
#include "tensorflow/lite/micro/micro_error_reporter.h"
#include "tensorflow/lite/micro/micro_interpreter.h"
#include "tensorflow/lite/schema/schema_generated.h"
#include "tensorflow/lite/version.h"
我们在hello_world_test.cc中看到了很多这样的内容。新出现的是constants.h和output_handler.h,我们在前面的文件列表中了解到了这些。
文件的下一部分设置了将在main_functions.cc中使用的全局变量:
namespace {
tflite::ErrorReporter* error_reporter = nullptr;
const tflite::Model* model = nullptr;
tflite::MicroInterpreter* interpreter = nullptr;
TfLiteTensor* input = nullptr;
TfLiteTensor* output = nullptr;
int inference_count = 0;
// Create an area of memory to use for input, output, and intermediate arrays.
// Finding the minimum value for your model may require some trial and error.
constexpr int kTensorArenaSize = 2 × 1024;
uint8_t tensor_arena[kTensorArenaSize];
} // namespace
您会注意到这些变量被包裹在一个namespace中。这意味着即使它们可以在main_functions.cc中的任何地方访问,但在项目中的其他文件中是无法访问的。这有助于防止如果两个不同的文件恰好定义了相同名称的变量时出现问题。
所有这些变量应该在测试中看起来很熟悉。我们设置变量来保存所有熟悉的 TensorFlow 对象,以及一个tensor_arena。唯一新的是一个保存inference_count的int,它将跟踪我们的程序执行了多少次推断。
文件的下一部分声明了一个名为setup()的函数。这个函数将在程序首次启动时调用,但之后不会再次调用。我们用它来做所有需要在开始运行推断之前发生的一次性工作。
setup()的第一部分几乎与我们的测试中的相同。我们设置日志记录,加载我们的模型,设置解释器并分配内存:
void setup() {
// Set up logging.
static tflite::MicroErrorReporter micro_error_reporter;
error_reporter = µ_error_reporter;
// Map the model into a usable data structure. This doesn't involve any
// copying or parsing, it's a very lightweight operation.
model = tflite::GetModel(g_sine_model_data);
if (model->version() != TFLITE_SCHEMA_VERSION) {
error_reporter->Report(
"Model provided is schema version %d not equal "
"to supported version %d.",
model->version(), TFLITE_SCHEMA_VERSION);
return;
}
// This pulls in all the operation implementations we need.
static tflite::ops::micro::AllOpsResolver resolver;
// Build an interpreter to run the model with.
static tflite::MicroInterpreter static_interpreter(
model, resolver, tensor_arena, kTensorArenaSize, error_reporter);
interpreter = &static_interpreter;
// Allocate memory from the tensor_arena for the model's tensors.
TfLiteStatus allocate_status = interpreter->AllocateTensors();
if (allocate_status != kTfLiteOk) {
error_reporter->Report("AllocateTensors() failed");
return;
}
到目前为止都是熟悉的领域。然而,在这一点之后,事情有点不同。首先,我们获取输入张量和输出张量的指针:
// Obtain pointers to the model's input and output tensors.
input = interpreter->input(0);
output = interpreter->output(0);
你可能想知道在运行推断之前我们如何与输出交互。请记住,TfLiteTensor只是一个结构体,它有一个成员data,指向一个已分配用于存储输出的内存区域。即使还没有写入任何输出,结构体及其data成员仍然存在。
最后,为了结束setup()函数,我们将我们的inference_count变量设置为0:
// Keep track of how many inferences we have performed.
inference_count = 0;
}
此时,我们所有的机器学习基础设施都已经设置好并准备就绪。我们拥有运行推断并获得结果所需的所有工具。接下来要定义的是我们的应用逻辑。程序实际上要做什么?
我们的模型经过训练,可以预测从 0 到 2π的任何数字的正弦值,这代表正弦波的完整周期。为了展示我们的模型,我们可以只输入这个范围内的数字,预测它们的正弦值,然后以某种方式输出这些值。我们可以按顺序执行这些操作,以展示模型在整个范围内的工作。这听起来是一个不错的计划!
为了做到这一点,我们需要编写一些在循环中运行的代码。首先,我们声明一个名为loop()的函数,接下来我们将逐步介绍。我们放在这个函数中的代码将被重复运行,一遍又一遍:
void loop() {
首先在我们的loop()函数中,我们必须确定要传递给模型的值(让我们称之为我们的x值)。我们使用两个常量来确定这一点:kXrange,它指定最大可能的x值为 2π,以及kInferencesPerCycle,它定义了我们希望在从 0 到 2π的步骤中执行的推断数量。接下来的几行代码计算x值:
// Calculate an x value to feed into the model. We compare the current
// inference_count to the number of inferences per cycle to determine
// our position within the range of possible x values the model was
// trained on, and use this to calculate a value.
float position = static_cast<float>(inference_count) /
static_cast<float>(kInferencesPerCycle);
float x_val = position * kXrange;
前两行代码只是将inference_count(到目前为止我们已经做的推断次数)除以kInferencesPerCycle,以获得我们在范围内的当前“位置”。下一行将该值乘以kXrange,它代表范围中的最大值(2π)。结果x_val是我们将传递给模型的值。
注意
static_cast<float>()用于将inference_count和kInferencesPerCycle(两者都是整数值)转换为浮点数。我们这样做是为了能够正确执行除法。在 C++中,如果你将两个整数相除,结果将是一个整数;结果的任何小数部分都会被舍弃。因为我们希望我们的x值是一个包含小数部分的浮点数,所以我们需要将被除数转换为浮点数。
我们使用的两个常量,kInferencesPerCycle和kXrange,在文件constants.h和constants.cc中定义。在使用这些常量时,C++的惯例是在常量名称前加上k,这样它们在代码中使用时很容易识别为常量。将常量定义在单独的文件中可能很有用,这样它们可以在需要的任何地方被包含和使用。
我们代码的下一部分应该看起来很熟悉;我们将我们的x值写入模型的输入张量,运行推断,然后从输出张量中获取结果(让我们称之为我们的y值):
// Place our calculated x value in the model's input tensor
input->data.f[0] = x_val;
// Run inference, and report any error
TfLiteStatus invoke_status = interpreter->Invoke();
if (invoke_status != kTfLiteOk) {
error_reporter->Report("Invoke failed on x_val: %f\n",
static_cast<double>(x_val));
return;
}
// Read the predicted y value from the model's output tensor
float y_val = output->data.f[0];
现在我们有了一个正弦值。由于对每个数字运行推断需要一点时间,并且这段代码在循环中运行,我们将随时间生成一系列正弦值。这将非常适合控制一些闪烁的 LED 或动画。我们的下一个任务是以某种方式输出它。
以下一行调用了在output_handler.cc中定义的HandleOutput()函数:
// Output the results. A custom HandleOutput function can be implemented
// for each supported hardware target.
HandleOutput(error_reporter, x_val, y_val);
我们传入我们的x和y值,以及我们的ErrorReporter实例,我们可以用它来记录事情。要查看接下来会发生什么,让我们来探索output_handler.cc。
使用 output_handler.cc 处理输出
文件output_handler.cc定义了我们的HandleOutput()函数。它的实现非常简单:
void HandleOutput(tflite::ErrorReporter* error_reporter, float x_value,
float y_value) {
// Log the current X and Y values
error_reporter->Report("x_value: %f, y_value: %f\n", x_value, y_value);
}
这个函数所做的就是使用ErrorReporter实例来记录x和y的值。这只是一个最基本的实现,我们可以用来测试应用程序的基本功能,例如在开发计算机上运行它。
然而,我们的目标是将此应用程序部署到几种不同的微控制器平台上,使用每个平台的专用硬件来显示输出。对于我们计划部署到的每个单独平台,例如 Arduino,我们提供一个自定义替换output_handler.cc,使用平台的 API 来控制输出,例如点亮一些 LED。
如前所述,这些替换文件位于具有每个平台名称的子目录中:arduino/、disco_f76ng/和sparkfun_edge/。我们将稍后深入研究特定于平台的实现。现在,让我们跳回main_functions.cc。
结束 main_functions.cc
我们在loop()函数中做的最后一件事是增加我们的inference_count计数器。如果它已经达到了在kInferencesPerCycle中定义的每个周期的最大推理次数,我们将其重置为 0:
// Increment the inference_counter, and reset it if we have reached
// the total number per cycle
inference_count += 1;
if (inference_count >= kInferencesPerCycle) inference_count = 0;
下一次循环迭代时,这将使我们的x值沿着一步移动或者如果它已经达到范围的末尾,则将其包装回 0。
我们现在已经到达了我们的loop()函数的末尾。每次运行时,都会计算一个新的x值,运行推理,并由HandleOutput()输出结果。如果loop()不断被调用,它将对范围为 0 到 2π的x值进行推理,然后重复。
但是是什么让loop()函数一遍又一遍地运行?答案在main.cc文件中。
理解 main.cc
C++标准规定每个 C++程序都包含一个名为main()的全局函数,该函数将在程序启动时运行。在我们的程序中,这个函数在main.cc文件中定义。这个main()函数的存在是main.cc代表我们程序入口点的原因。每当微控制器启动时,main()中的代码将运行。
文件main.cc非常简短而简洁。首先,它包含了一个main_functions.h的#include语句,这将引入那里定义的setup()和loop()函数:
#include "tensorflow/lite/micro/examples/hello_world/main_functions.h"
接下来,它声明了main()函数本身:
int main(int argc, char* argv[]) {
setup();
while (true) {
loop();
}
}
当main()运行时,它首先调用我们的setup()函数。它只会执行一次。之后,它进入一个while循环,将不断调用loop()函数,一遍又一遍。
这个循环将无限运行。天啊!如果您来自服务器或 Web 编程背景,这可能听起来不是一个好主意。循环将阻塞我们的单个执行线程,并且没有退出程序的方法。
然而,在为微控制器编写软件时,这种无休止的循环实际上是相当常见的。因为没有多任务处理,只有一个应用程序会运行,循环继续进行并不重要。只要微控制器连接到电源,我们就继续进行推理并输出数据。
我们现在已经走完了整个微控制器应用程序。在下一节中,我们将通过在开发计算机上运行应用程序代码来尝试该应用程序代码。
运行我们的应用程序
为了给我们的应用程序代码进行测试运行,我们首先需要构建它。输入以下 Make 命令以为我们的程序创建一个可执行二进制文件:
make -f tensorflow/lite/micro/tools/make/Makefile hello_world
当构建完成后,您可以使用以下命令运行应用程序二进制文件,具体取决于您的操作系统:
# macOS:
tensorflow/lite/micro/tools/make/gen/osx_x86_64/bin/hello_world
# Linux:
tensorflow/lite/micro/tools/make/gen/linux_x86_64/bin/hello_world
# Windows
tensorflow/lite/micro/tools/make/gen/windows_x86_64/bin/hello_world
如果找不到正确的路径,请列出tensorflow/lite/micro/tools/make/gen/中的目录。
在运行二进制文件之后,您应该希望看到一堆输出滚动过去,看起来像这样:
x_value: 1.4137159*2¹, y_value: 1.374213*2^-2
x_value: 1.5707957*2¹, y_value: -1.4249528*2^-5
x_value: 1.7278753*2¹, y_value: -1.4295994*2^-2
x_value: 1.8849551*2¹, y_value: -1.2867725*2^-1
x_value: 1.210171*2², y_value: -1.7542461*2^-1
非常令人兴奋!这些是output_handler.cc中HandleOutput()函数写入的日志。每次推理都有一个日志,x_value逐渐增加,直到达到 2π,然后回到 0 并重新开始。
一旦你体验到足够的刺激,你可以按 Ctrl-C 来终止程序。
注意
你会注意到这些数字以二的幂次方的形式输出,比如1.4137159*2¹。这是在微控制器上记录浮点数的高效方式,因为这些设备通常没有浮点运算的硬件支持。
要获得原始值,只需拿出你的计算器:例如,1.4137159*2¹计算结果为2.8274318。如果你感兴趣,打印这些数字的代码在debug_log_numbers.cc中。
总结
我们现在已经确认程序在我们的开发机器上运行正常。在下一章中,我们将让它在一些微控制器上运行!
第六章:TinyML 的“Hello World”:部署到微控制器
现在是时候动手了。在本章的过程中,我们将代码部署到三种不同的设备上:
我们将逐个讨论每个设备的构建和部署过程。
注意
TensorFlow Lite 定期添加对新设备的支持,因此如果您想使用的设备未在此处列出,值得查看示例的README.md。
如果在按照这些步骤时遇到问题,您也可以在那里查看更新的部署说明。
每个设备都有自己独特的输出能力,从一组 LED 到完整的 LCD 显示器,因此示例包含每个设备的HandleOutput()的自定义实现。我们还将逐个讨论这些,并谈谈它们的逻辑如何工作。即使您没有所有这些设备,阅读这段代码也应该很有趣,因此我们强烈建议您查看一下。
什么是微控制器?
根据您的过去经验,您可能不熟悉微控制器如何与其他电子组件交互。因为我们即将开始玩硬件,所以在继续之前介绍一些概念是值得的。
在像 Arduino、SparkFun Edge 或 STM32F746G Discovery kit 这样的微控制器板上,实际的微控制器只是连接到电路板的许多电子组件之一。图 6-1 显示了 SparkFun Edge 上的微控制器。

图 6-1。SparkFun Edge 板上突出显示其微控制器
微控制器使用引脚连接到其所在的电路板。典型的微控制器有数十个引脚,它们有各种用途。一些引脚为微控制器提供电源;其他连接到各种重要组件。一些引脚专门用于由运行在微控制器上的程序输入和输出数字信号。这些被称为GPIO引脚,代表通用输入/输出。它们可以作为输入,确定是否向其施加电压,或作为输出,提供可以为其他组件供电或通信的电流。
GPIO 引脚是数字的。这意味着在输出模式下,它们就像开关,可以完全打开或完全关闭。在输入模式下,它们可以检测由其他组件施加在它们上的电压是高于还是低于某个阈值。
除了 GPIO,一些微控制器还具有模拟输入引脚,可以测量施加在它们上的电压的确切水平。
通过调用特殊函数,运行在微控制器上的程序可以控制特定引脚是输入模式还是输出模式。其他函数用于打开或关闭输出引脚,或读取输入引脚的当前状态。
现在您对微控制器有了更多了解,让我们更仔细地看看我们的第一个设备:Arduino。
Arduino
有各种各样的Arduino板,具有不同的功能。并非所有板都能运行 TensorFlow Lite for Microcontrollers。我们推荐本书使用的板是Arduino Nano 33 BLE Sense。除了与 TensorFlow Lite 兼容外,它还包括麦克风和加速度计(我们将在后面的章节中使用)。我们建议购买带有引脚排针的板,这样可以更容易地连接其他组件而无需焊接。
大多数 Arduino 板都带有内置 LED,这就是我们将用来可视化输出正弦值的内容。图 6-2 显示了一个 Arduino Nano 33 BLE Sense 板,其中突出显示了 LED。

图 6-2。Arduino Nano 33 BLE Sense 板上突出显示的 LED
在 Arduino 上处理输出
因为我们只有一个 LED 可供使用,所以我们需要进行创造性思考。一种选择是根据最近预测的正弦值来改变 LED 的亮度。鉴于该值范围为-1 到 1,我们可以用完全关闭的 LED 表示 0,用完全亮起的 LED 表示-1 和 1,用部分调暗的 LED 表示任何中间值。当程序在循环中运行推断时,LED 将重复地变暗和变亮。
我们可以使用kInferencesPerCycle常量在完整正弦波周期内执行的推断数量。由于一个推断需要一定的时间,调整constants.cc中定义的kInferencesPerCycle将调整 LED 变暗的速度。
在hello_world/arduino/constants.cc中有一个特定于 Arduino 的版本的此文件。该文件与hello_world/constants.cc具有相同的名称,因此在为 Arduino 构建应用程序时将使用它来代替原始实现。
为了调暗我们的内置 LED,我们可以使用一种称为脉宽调制(PWM)的技术。如果我们非常快速地打开和关闭一个输出引脚,那么引脚的输出电压将成为处于关闭和打开状态之间所花时间比率的因素。如果引脚在每种状态中花费的时间占 50%,则其输出电压将是其最大值的 50%。如果它在打开状态花费 75%的时间,关闭状态花费 25%的时间,则其电压将是其最大值的 75%。
PWM 仅在某些 Arduino 设备的某些引脚上可用,但使用起来非常简单:我们只需调用一个设置所需输出电平的函数。
用于 Arduino 输出处理的代码位于hello_world/arduino/output_handler.cc中,该代码用于替代原始文件hello_world/output_handler.cc。
让我们来看一下源代码:
#include "tensorflow/lite/micro/examples/hello_world/output_handler.h"
#include "Arduino.h"
#include "tensorflow/lite/micro/examples/hello_world/constants.h"
首先,我们包含一些头文件。我们的output_handler.h指定了此文件的接口。Arduino.h提供了 Arduino 平台的接口;我们使用它来控制板。因为我们需要访问kInferencesPerCycle,所以我们还包括constants.h。
接下来,我们定义函数并指示它第一次运行时要执行的操作:
// Adjusts brightness of an LED to represent the current y value
void HandleOutput(tflite::ErrorReporter* error_reporter, float x_value,
float y_value) {
// Track whether the function has run at least once
static bool is_initialized = false;
// Do this only once
if (!is_initialized) {
// Set the LED pin to output
pinMode(LED_BUILTIN, OUTPUT);
is_initialized = true;
}
在 C++中,函数内声明为static的变量将在函数的多次运行中保持其值。在这里,我们使用is_initialized变量来跟踪以下if (!is_initialized)块中的代码是否曾经运行过。
初始化块调用 Arduino 的pinMode()函数,该函数指示微控制器给定的引脚应该处于输入模式还是输出模式。在使用引脚之前,这是必要的。该函数使用 Arduino 平台定义的两个常量调用:LED_BUILTIN和OUTPUT。LED_BUILTIN表示连接到板上内置 LED 的引脚,OUTPUT表示输出模式。
将内置 LED 的引脚配置为输出模式后,将is_initialized设置为true,以便此代码块不会再次运行。
接下来,我们计算 LED 的期望亮度:
// Calculate the brightness of the LED such that y=-1 is fully off
// and y=1 is fully on. The LED's brightness can range from 0-255.
int brightness = (int)(127.5f * (y_value + 1));
Arduino 允许我们将 PWM 输出的电平设置为 0 到 255 之间的数字,其中 0 表示完全关闭,255 表示完全打开。我们的y_value是-1 到 1 之间的数字。前面的代码将y_value映射到 0 到 255 的范围,因此当y = -1时,LED 完全关闭,当y = 0时,LED 亮度为一半,当y = 1时,LED 完全亮起。
下一步是实际设置 LED 的亮度:
// Set the brightness of the LED. If the specified pin does not support PWM,
// this will result in the LED being on when y > 127, off otherwise.
analogWrite(LED_BUILTIN, brightness);
Arduino 平台的analogWrite()函数接受一个介于 0 和 255 之间的值的引脚号(我们提供LED_BUILTIN)和我们在前一行中计算的brightness。当调用此函数时,LED 将以该亮度点亮。
注意
不幸的是,在某些型号的 Arduino 板上,内置 LED 连接的引脚不支持 PWM。这意味着我们对analogWrite()的调用不会改变其亮度。相反,如果传递给analogWrite()的值大于 127,则 LED 将打开,如果小于等于 126,则 LED 将关闭。这意味着 LED 将闪烁而不是渐变。虽然不够酷,但仍然展示了我们的正弦波预测。
最后,我们使用ErrorReporter实例记录亮度值:
// Log the current brightness value for display in the Arduino plotter
error_reporter->Report("%d\n", brightness);
在 Arduino 平台上,ErrorReporter被设置为通过串行端口记录数据。串行是微控制器与主机计算机通信的一种非常常见的方式,通常用于调试。这是一种通信协议,其中数据通过开关输出引脚一次一个位来传输。我们可以使用它发送和接收任何内容,从原始二进制数据到文本和数字。
Arduino IDE 包含用于捕获和显示通过串行端口接收的数据的工具。其中一个工具是串行绘图器,可以显示通过串行接收的值的图形。通过从我们的代码输出一系列亮度值,我们将能够看到它们的图形。图 6-3 展示了这一过程。

图 6-3. Arduino IDE 的串行绘图器
我们将在本节后面提供如何使用串行绘图器的说明。
注意
您可能想知道ErrorReporter如何通过 Arduino 的串行接口输出数据。您可以在micro/arduino/debug_log.cc中找到代码实现。它替换了micro/debug_log.cc中的原始实现。就像output_handler.cc被覆盖一样,我们可以通过将它们添加到以平台名称命名的目录中,为 TensorFlow Lite for Microcontrollers 中的任何源文件提供特定于平台的实现。
运行示例
我们的下一个任务是为 Arduino 构建项目并将其部署到设备上。
提示
建议检查README.md以获取最新的指导,因为自本书编写以来构建过程可能已经发生变化。
我们需要的一切如下:
-
支持的 Arduino 板(我们推荐 Arduino Nano 33 BLE Sense)
-
适当的 USB 电缆
-
Arduino IDE(您需要下载并安装此软件才能继续)
本书中的项目作为 TensorFlow Lite Arduino 库中的示例代码可用,您可以通过 Arduino IDE 轻松安装,并从工具菜单中选择管理库。在弹出的窗口中,搜索并安装名为Arduino_TensorFlowLite的库。您应该能够使用最新版本,但如果遇到问题,本书测试过的版本是1.14-ALPHA。
注意
您还可以从.zip文件安装库,您可以从 TensorFlow Lite 团队下载,或者使用 TensorFlow Lite for Microcontrollers Makefile 自动生成。如果您更喜欢这样做,请参见附录 A。
安装完库后,hello_world示例将显示在文件菜单下的 Examples→Arduino_TensorFlowLite 中,如图 6-4 所示。
单击“hello_world”加载示例。它将显示为一个新窗口,每个源文件都有一个选项卡。第一个选项卡中的文件hello_world相当于我们之前讨论过的main_functions.cc。

图 6-4. 示例菜单
要运行示例,请通过 USB 连接您的 Arduino 设备。确保在工具菜单中从板下拉列表中选择正确的设备类型,如图 6-5 所示。

图 6-5。板下拉列表
如果您的设备名称未出现在列表中,则需要安装其支持包。要执行此操作,请单击“Boards Manager”。在出现的窗口中,搜索您的设备并安装相应支持包的最新版本。
接下来,请确保在“端口”下拉列表中选择了设备的端口,也在工具菜单中,如图 6-6 所示。

图 6-6。端口下拉列表
最后,在 Arduino 窗口中,单击上传按钮(在图 6-7 中用白色突出显示)来编译并将代码上传到您的 Arduino 设备。

图 6-7。上传按钮,一个右箭头
上传成功完成后,您应该看到 Arduino 板上的 LED 开始淡入淡出或闪烁,具体取决于其连接的引脚是否支持 PWM。
恭喜:您正在设备上运行 ML!
注意
不同型号的 Arduino 板具有不同的硬件,并且将以不同的速度运行推断。如果您的 LED 要么闪烁要么保持完全开启,您可能需要增加每个周期的推断次数。您可以通过arduino_constants.cpp中的kInferencesPerCycle常量来实现这一点。
“进行您自己的更改”向您展示如何编辑示例代码。
您还可以查看绘制在图表上的亮度值。要执行此操作,请在工具菜单中选择 Arduino IDE 的串行绘图器,如图 6-8 所示。

图 6-8。串行绘图器菜单选项
绘图器显示随时间变化的值,如图 6-9 所示。

图 6-9。串行绘图器绘制值
要查看从 Arduino 串行端口接收的原始数据,请从工具菜单中打开串行监视器。您将看到一系列数字飞过,就像图 6-10 中所示。

图 6-10。显示原始数据的串行监视器
进行您自己的更改
现在您已经部署了应用程序,可能会很有趣地玩耍并对代码进行一些更改。您可以在 Arduino IDE 中编辑源文件。保存时,您将被提示在新位置重新保存示例。完成更改后,您可以在 Arduino IDE 中单击上传按钮来构建和部署。
要开始进行更改,您可以尝试以下几个实验:
-
通过调整每个周期的推断次数来使 LED 闪烁速度变慢或变快。
-
修改output_handler.cc以将基于文本的动画记录到串行端口。
-
使用正弦波来控制其他组件,如额外的 LED 或声音发生器。
SparkFun Edge
SparkFun Edge开发板专门设计为在微型设备上进行机器学习实验的平台。它具有功耗高效的 Ambiq Apollo 3 微控制器,带有 Arm Cortex M4 处理器核心。
它具有四个 LED 的一组,如图 6-11 所示。我们使用这些 LED 来直观输出我们的正弦值。

图 6-11。SparkFun Edge 的四个 LED
在 SparkFun Edge 上处理输出
我们可以使用板上的一组 LED 制作一个简单的动画,因为没有什么比blinkenlights更能展示尖端人工智能了。
LED(红色、绿色、蓝色和黄色)在以下顺序中物理排列:
[ R G B Y ]
以下表格表示我们将如何为不同的y值点亮 LED:
| 范围 | 点亮的 LED |
|---|---|
0.75 <= y <= 1 |
[ 0 0 1 1 ] |
0 < y < 0.75 |
[ 0 0 1 0 ] |
y = 0 |
[ 0 0 0 0 ] |
-0.75 < y < 0 |
[ 0 1 0 0 ] |
-1 <= y <= 0.75 |
[ 1 1 0 0 ] |
每次推断需要一定的时间,所以调整kInferencesPerCycle,在constants.cc中定义,将调整 LED 循环的速度。
图 6-12 显示了程序运行的一个静态图像,来自一个动画.gif。

图 6-12。来自 SparkFun Edge LED 动画的静态图像
实现 SparkFun Edge 的输出处理的代码在hello_world/sparkfun_edge/output_handler.cc中,用于替代原始文件hello_world/output_handler.cc。
让我们开始逐步进行:
#include "tensorflow/lite/micro/examples/hello_world/output_handler.h"
#include "am_bsp.h"
首先,我们包含一些头文件。我们的output_handler.h指定了此文件的接口。另一个文件am_bsp.h来自一个叫做Ambiq Apollo3 SDK的东西。Ambiq 是 SparkFun Edge 微控制器的制造商,称为 Apollo3。SDK(软件开发工具包)是一组源文件,定义了可以用来控制微控制器功能的常量和函数。
因为我们计划控制板上的 LED,所以我们需要能够打开和关闭微控制器的引脚。这就是我们使用 SDK 的原因。
注意
当我们最终构建项目时,Makefile 将自动下载 SDK。如果你感兴趣,可以在SparkFun 的网站上阅读更多关于它的信息或下载代码进行探索。
接下来,我们定义HandleOutput()函数,并指示在其第一次运行时要执行的操作:
void HandleOutput(tflite::ErrorReporter* error_reporter, float x_value,
float y_value) {
// The first time this method runs, set up our LEDs correctly
static bool is_initialized = false;
if (!is_initialized) {
// Set up LEDs as outputs
am_hal_gpio_pinconfig(AM_BSP_GPIO_LED_RED, g_AM_HAL_GPIO_OUTPUT_12);
am_hal_gpio_pinconfig(AM_BSP_GPIO_LED_BLUE, g_AM_HAL_GPIO_OUTPUT_12);
am_hal_gpio_pinconfig(AM_BSP_GPIO_LED_GREEN, g_AM_HAL_GPIO_OUTPUT_12);
am_hal_gpio_pinconfig(AM_BSP_GPIO_LED_YELLOW, g_AM_HAL_GPIO_OUTPUT_12);
// Ensure all pins are cleared
am_hal_gpio_output_clear(AM_BSP_GPIO_LED_RED);
am_hal_gpio_output_clear(AM_BSP_GPIO_LED_BLUE);
am_hal_gpio_output_clear(AM_BSP_GPIO_LED_GREEN);
am_hal_gpio_output_clear(AM_BSP_GPIO_LED_YELLOW);
is_initialized = true;
}
哦,这是很多的设置!我们使用am_hal_gpio_pinconfig()函数,由am_bsp.h提供,来配置连接到板上内置 LED 的引脚,将它们设置为输出模式(由g_AM_HAL_GPIO_OUTPUT_12常量表示)。每个 LED 的引脚号由一个常量表示,比如AM_BSP_GPIO_LED_RED。
然后我们使用am_hal_gpio_output_clear()清除所有输出,以便所有 LED 都关闭。与 Arduino 实现一样,我们使用名为is_initialized的static变量,以确保此块中的代码仅运行一次。接下来,我们确定如果y值为负时应点亮哪些 LED:
// Set the LEDs to represent negative values
if (y_value < 0) {
// Clear unnecessary LEDs
am_hal_gpio_output_clear(AM_BSP_GPIO_LED_GREEN);
am_hal_gpio_output_clear(AM_BSP_GPIO_LED_YELLOW);
// The blue LED is lit for all negative values
am_hal_gpio_output_set(AM_BSP_GPIO_LED_BLUE);
// The red LED is lit in only some cases
if (y_value <= -0.75) {
am_hal_gpio_output_set(AM_BSP_GPIO_LED_RED);
} else {
am_hal_gpio_output_clear(AM_BSP_GPIO_LED_RED);
}
首先,如果y值刚刚变为负数,我们清除用于指示正值的两个 LED。接下来,我们调用am_hal_gpio_output_set()来打开蓝色 LED,如果值为负数,它将始终点亮。最后,如果值小于-0.75,我们打开红色 LED。否则,我们关闭它。
接下来,我们做同样的事情,但是对于y的正值:
// Set the LEDs to represent positive values
} else if (y_value > 0) {
// Clear unnecessary LEDs
am_hal_gpio_output_clear(AM_BSP_GPIO_LED_RED);
am_hal_gpio_output_clear(AM_BSP_GPIO_LED_BLUE);
// The green LED is lit for all positive values
am_hal_gpio_output_set(AM_BSP_GPIO_LED_GREEN);
// The yellow LED is lit in only some cases
if (y_value >= 0.75) {
am_hal_gpio_output_set(AM_BSP_GPIO_LED_YELLOW);
} else {
am_hal_gpio_output_clear(AM_BSP_GPIO_LED_YELLOW);
}
}
LED 就是这样。我们最后要做的是将当前的输出值记录到串口上正在监听的人:
// Log the current X and Y values
error_reporter->Report("x_value: %f, y_value: %f\n", x_value, y_value);
注意
我们的ErrorReporter能够通过 SparkFun Edge 的串行接口输出数据,这是由于micro/sparkfun_edge/debug_log.cc的自定义实现取代了mmicro/debug_log.cc中的原始实现。
运行示例
现在我们可以构建示例代码并将其部署到 SparkFun Edge 上。
提示
构建过程可能会有变化,因为这本书写作时,所以请查看README.md获取最新的指导。
要构建和部署我们的代码,我们需要以下内容:
-
一个 SparkFun Edge 板
-
一个 USB 编程器(我们推荐 SparkFun Serial Basic Breakout,可在micro-B USB和USB-C变体中获得)
-
一根匹配的 USB 电缆
-
Python 3 和一些依赖项
首先,打开一个终端,克隆 TensorFlow 存储库,然后切换到其目录:
git clone https://github.com/tensorflow/tensorflow.git
cd tensorflow
接下来,我们将构建二进制文件,并运行一些命令,使其准备好下载到设备中。为了避免一些打字错误,您可以从README.md中复制并粘贴这些命令。
构建二进制文件
以下命令下载所有必需的依赖项,然后为 SparkFun Edge 编译一个二进制文件:
make -f tensorflow/lite/micro/tools/make/Makefile \
TARGET=sparkfun_edge hello_world_bin
注意
二进制文件是包含程序的文件,可以直接由 SparkFun Edge 硬件运行。
二进制文件将被创建为.bin文件,位置如下:
tensorflow/lite/micro/tools/make/gen/ \
sparkfun_edge_cortex-m4/bin/hello_world.bin
要检查文件是否存在,您可以使用以下命令:
test -f tensorflow/lite/micro/tools/make/gen/ \
sparkfun_edge_cortex-m4/bin/hello_world.bin \
&& echo "Binary was successfully created" || echo "Binary is missing"
如果运行该命令,您应该看到二进制文件已成功创建打印到控制台。
如果看到二进制文件丢失,则构建过程中出现问题。如果是这样,很可能您可以在make命令的输出中找到一些出错的线索。
对二进制文件进行签名
必须使用加密密钥对二进制文件进行签名,以便部署到设备上。现在让我们运行一些命令,对二进制文件进行签名,以便将其刷写到 SparkFun Edge。这里使用的脚本来自 Ambiq SDK,在运行 Makefile 时下载。
输入以下命令设置一些虚拟的加密密钥,供开发使用:
cp tensorflow/lite/micro/tools/make/downloads/AmbiqSuite-Rel2.0.0/ \
tools/apollo3_scripts/keys_info0.py \
tensorflow/lite/micro/tools/make/downloads/AmbiqSuite-Rel2.0.0/ \
tools/apollo3_scripts/keys_info.py
接下来,运行以下命令创建一个已签名的二进制文件。如有必要,将python3替换为python:
python3 tensorflow/lite/micro/tools/make/downloads/ \
AmbiqSuite-Rel2.0.0/tools/apollo3_scripts/create_cust_image_blob.py \
--bin tensorflow/lite/micro/tools/make/gen/ \
sparkfun_edge_cortex-m4/bin/hello_world.bin \
--load-address 0xC000 \
--magic-num 0xCB -o main_nonsecure_ota \
--version 0x0
这将创建文件main_nonsecure_ota.bin。现在运行以下命令以创建文件的最终版本,您可以使用该文件通过下一步中将使用的脚本刷写设备:
python3 tensorflow/lite/micro/tools/make/downloads/ \
AmbiqSuite-Rel2.0.0/tools/apollo3_scripts/create_cust_wireupdate_blob.py \
--load-address 0x20000 \
--bin main_nonsecure_ota.bin \
-i 6 \
-o main_nonsecure_wire \
--options 0x1
您现在应该在运行命令的目录中有一个名为main_nonsecure_wire.bin的文件。这是您将要刷写到设备上的文件。
刷写二进制文件
SparkFun Edge 将当前运行的程序存储在其 1 兆字节的闪存中。如果要让板运行新程序,您需要将其发送到板上,板将其存储在闪存中,覆盖先前保存的任何程序。
这个过程称为刷写。让我们一步步走过这些步骤。
将编程器连接到板上
要将新程序下载到板上,您将使用 SparkFun USB-C 串行基本串行编程器。该设备允许您的计算机通过 USB 与微控制器通信。
要将此设备连接到您的板上,请执行以下步骤:
-
在 SparkFun Edge 的一侧,找到六针排针。
-
将 SparkFun USB-C 串行基本插入这些引脚,确保每个设备上标有 BLK 和 GRN 的引脚正确对齐。
您可以在图 6-13 中看到正确的排列方式。

图 6-13. 连接 SparkFun Edge 和 USB-C 串行基本(由 SparkFun 提供)
将编程器连接到计算机
接下来,通过 USB 将板连接到计算机。要对板进行编程,您需要确定计算机给设备的名称。最好的方法是在连接设备之前和之后列出计算机的所有设备,然后查看哪个设备是新的。
警告
有些人报告了使用编程器的操作系统默认驱动程序出现问题,因此我们强烈建议在继续之前安装驱动程序。
在通过 USB 连接设备之前,请运行以下命令:
# macOS:
ls /dev/cu*
# Linux:
ls /dev/tty*
这应该输出一个类似以下内容的附加设备列表:
/dev/cu.Bluetooth-Incoming-Port
/dev/cu.MALS
/dev/cu.SOC
现在,将编程器连接到计算机的 USB 端口,并再次运行命令:
# macOS:
ls /dev/cu*
# Linux:
ls /dev/tty*
您应该在输出中看到一个额外的项目,如下例所示。您的新项目可能有不同的名称。这个新项目是设备的名称:
/dev/cu.Bluetooth-Incoming-Port
/dev/cu.MALS
/dev/cu.SOC
/dev/cu.wchusbserial-1450
这个名称将用于引用设备。但是,它可能会根据编程器连接到的 USB 端口而改变,因此如果您从计算机断开板然后重新连接,可能需要再次查找其名称。
提示
一些用户报告列表中出现了两个设备。如果看到两个设备,正确的设备名称应以“wch”开头;例如,“/dev/wchusbserial-14410”。
确定设备名称后,将其放入一个 shell 变量以供以后使用:
export DEVICENAME=<*your device name here*>
这是一个变量,您可以在后续过程中运行需要设备名称的命令时使用。
运行脚本以刷写您的板子
要刷写板子,您需要将其放入特殊的“引导加载程序”状态,以准备接收新的二进制文件。然后可以运行脚本将二进制文件发送到板子上。
首先创建一个环境变量来指定波特率,即数据发送到设备的速度:
export BAUD_RATE=921600
现在将以下命令粘贴到您的终端中——但不要按 Enter 键!命令中的${DEVICENAME}和${BAUD_RATE}将被替换为您在前面部分设置的值。如果需要,请记得将python3替换为python:
python3 tensorflow/lite/micro/tools/make/downloads/ \
AmbiqSuite-Rel2.0.0/tools/apollo3_scripts/ \
uart_wired_update.py -b ${BAUD_RATE} \
${DEVICENAME} -r 1 -f main_nonsecure_wire.bin -i 6
接下来,您将重置板子到引导加载程序状态并刷写板子。在板子上,找到标记为RST和14的按钮,如图 6-14 所示。

图 6-14。SparkFun Edge 的按钮
执行以下步骤:
-
确保您的板子连接到编程器,并且整个设备通过 USB 连接到计算机。
-
在板子上,按住标记为
14的按钮。继续按住它。 -
在继续按住标记为
14的按钮的同时,按下标记为RST的按钮重置板子。 -
在计算机上按 Enter 键运行脚本。继续按住按钮
14。
现在您应该在屏幕上看到类似以下内容的东西:
Connecting with Corvette over serial port /dev/cu.usbserial-1440...
Sending Hello.
Received response for Hello
Received Status
length = 0x58
version = 0x3
Max Storage = 0x4ffa0
Status = 0x2
State = 0x7
AMInfo =
0x1
0xff2da3ff
0x55fff
0x1
0x49f40003
0xffffffff
[...lots more 0xffffffff...]
Sending OTA Descriptor = 0xfe000
Sending Update Command.
number of updates needed = 1
Sending block of size 0x158b0 from 0x0 to 0x158b0
Sending Data Packet of length 8180
Sending Data Packet of length 8180
[...lots more Sending Data Packet of length 8180...]
继续按住按钮14,直到看到发送数据包长度为 8180。在看到这个之后可以释放按钮(但如果您继续按住也没关系)。
程序将继续在终端上打印行。最终您会看到类似以下内容的东西:
[...lots more Sending Data Packet of length 8180...]
Sending Data Packet of length 8180
Sending Data Packet of length 6440
Sending Reset Command.
Done.
这表明刷写成功。
提示
如果程序输出以错误结束,请检查是否打印了发送复位命令。如果是,则刷写可能成功,尽管有错误。否则,刷写可能失败。尝试再次运行这些步骤(您可以跳过设置环境变量)。
测试程序
现在应该已经将二进制文件部署到设备上。按下标记为RST的按钮重新启动板子。您应该看到设备的四个 LED 按顺序闪烁。干得好!
查看调试数据
在程序运行时,板子会记录调试信息。要查看它,我们可以使用波特率 115200 监视板子的串行端口输出。在 macOS 和 Linux 上,以下命令应该有效:
screen ${DEVICENAME} 115200
您会看到大量的输出飞过!要停止滚动,请按下 Ctrl-A,紧接着按 Esc。然后您可以使用箭头键浏览输出,其中包含对各种x值运行推断的结果:
x_value: 1.1843798*2², y_value: -1.9542645*2^-1
要停止使用screen查看调试输出,请按下 Ctrl-A,紧接着按下 K 键,然后按下 Y 键。
注意
程序screen是一个有用的连接到其他计算机的实用程序程序。在这种情况下,我们使用它来监听 SparkFun Edge 板通过其串行端口记录的数据。如果您使用 Windows,可以尝试使用程序CoolTerm来做同样的事情。
进行您自己的更改
现在您已经部署了基本应用程序,请尝试玩耍并进行一些更改。您可以在tensorflow/lite/micro/examples/hello_world文件夹中找到应用程序的代码。只需编辑并保存,然后重复之前的说明以将修改后的代码部署到设备上。
以下是您可以尝试的一些事情:
-
通过调整每个周期的推断次数使 LED 的闪烁速度变慢或变快。
-
修改output_handler.cc以将基于文本的动画记录到串行端口。
-
使用正弦波来控制其他组件,如额外的 LED 或声音发生器。
ST Microelectronics STM32F746G Discovery Kit
STM32F746G是一个带有相对强大的 Arm Cortex-M7 处理器核心的微控制器开发板。
这块板运行 Arm 的Mbed OS,这是一个专为构建和部署嵌入式应用程序而设计的嵌入式操作系统。这意味着我们可以使用本节中的许多指令来为其他 Mbed 设备构建。
STM32F746G 配备了一个附加的 LCD 屏幕,这将使我们能够构建一个更加复杂的视觉显示。
在 STM32F746G 上处理输出
现在我们有一个整个 LCD 可以玩耍,我们可以绘制一个漂亮的动画。让我们使用屏幕的x轴表示推断的数量,y轴表示我们预测的当前值。
我们将在这个值应该的地方绘制一个点,当我们循环遍历 0 到 2π的输入范围时,它将在屏幕上移动。图 6-15 展示了这个的线框图。
每个推断需要一定的时间,因此调整kInferencesPerCycle,在constants.cc中定义,将调整点的运动速度和平滑度。
图 6-16 显示了程序运行的动画.gif的静止画面。

图 6-15。我们将在 LCD 显示屏上绘制的动画
图 6-16 显示了程序运行的动画.gif的静止画面。

图 6-16。在 STM32F746G Discovery kit 上运行的代码,带有 LCD 显示屏
实现 STM32F746G 的输出处理的代码位于hello_world/disco_f746ng/output_handler.cc中,该文件用于替代原始文件hello_world/output_handler.cc。
让我们来看一下:
#include "tensorflow/lite/micro/examples/hello_world/output_handler.h"
#include "LCD_DISCO_F746NG.h"
#include "tensorflow/lite/micro/examples/hello_world/constants.h"
首先,我们有一些头文件。我们的output_handler.h指定了这个文件的接口。由开发板制造商提供的LCD_DISCO_F74NG.h声明了我们将用来控制其 LCD 屏幕的接口。我们还包括constants.h,因为我们需要访问kInferencesPerCycle和kXrange。
接下来,我们设置了大量变量。首先是LCD_DISCO_F746NG的一个实例,它在LCD_DISCO_F74NG.h中定义,并提供了我们可以用来控制 LCD 的方法:
// The LCD driver
LCD_DISCO_F746NG lcd;
有关LCD_DISCO_F746NG类的详细信息可在Mbed 网站上找到。
接下来,我们定义一些控制视觉外观和感觉的常量:
// The colors we'll draw
const uint32_t background_color = 0xFFF4B400; // Yellow
const uint32_t foreground_color = 0xFFDB4437; // Red
// The size of the dot we'll draw
const int dot_radius = 10;
颜色以十六进制值提供,如0xFFF4B400。它们的格式为AARRGGBB,其中AA表示 alpha 值(或不透明度,FF表示完全不透明),RR、GG和BB表示红色、绿色和蓝色的量。
提示
通过练习,您可以学会从十六进制值中读取颜色。0xFFF4B400是完全不透明的,有很多红色和一定量的绿色,这使得它成为一个漂亮的橙黄色。
您也可以通过快速的谷歌搜索查找这些值。
然后我们声明了一些变量,定义了动画的形状和大小:
// Size of the drawable area
int width;
int height;
// Midpoint of the y axis
int midpoint;
// Pixels per unit of x_value
int x_increment;
在变量之后,我们定义了HandleOutput()函数,并告诉它在第一次运行时要做什么:
// Animates a dot across the screen to represent the current x and y values
void HandleOutput(tflite::ErrorReporter* error_reporter, float x_value,
float y_value) {
// Track whether the function has run at least once
static bool is_initialized = false;
// Do this only once
if (!is_initialized) {
// Set the background and foreground colors
lcd.Clear(background_color);
lcd.SetTextColor(foreground_color);
// Calculate the drawable area to avoid drawing off the edges
width = lcd.GetXSize() - (dot_radius * 2);
height = lcd.GetYSize() - (dot_radius * 2);
// Calculate the y axis midpoint
midpoint = height / 2;
// Calculate fractional pixels per unit of x_value
x_increment = static_cast<float>(width) / kXrange;
is_initialized = true;
}
里面有很多内容!首先,我们使用属于lcd的方法来设置背景和前景颜色。奇怪命名的lcd.SetTextColor()设置我们绘制的任何东西的颜色,不仅仅是文本:
// Set the background and foreground colors
lcd.Clear(background_color);
lcd.SetTextColor(foreground_color);
接下来,我们计算实际可以绘制到屏幕的部分,以便知道在哪里绘制我们的圆。如果我们搞错了,我们可能会尝试绘制超出屏幕边缘,导致意想不到的结果:
width = lcd.GetXSize() - (dot_radius * 2);
height = lcd.GetYSize() - (dot_radius * 2);
之后,我们确定屏幕中间的位置,我们将在其下方绘制负y值。我们还计算屏幕宽度中表示一个单位x值的像素数。请注意我们如何使用static_cast确保获得浮点结果:
// Calculate the y axis midpoint
midpoint = height / 2;
// Calculate fractional pixels per unit of x_value
x_increment = static_cast<float>(width) / kXrange;
与之前一样,使用名为is_initialized的static变量确保此块中的代码仅运行一次。
初始化完成后,我们可以开始输出。首先,清除任何先前的绘图:
// Clear the previous drawing
lcd.Clear(background_color);
接下来,我们使用x_value来计算我们应该在显示器的x轴上绘制点的位置:
// Calculate x position, ensuring the dot is not partially offscreen,
// which causes artifacts and crashes
int x_pos = dot_radius + static_cast<int>(x_value * x_increment);
然后我们对y值执行相同的操作。这有点复杂,因为我们希望在midpoint上方绘制正值,在下方绘制负值:
// Calculate y position, ensuring the dot is not partially offscreen
int y_pos;
if (y_value >= 0) {
// Since the display's y runs from the top down, invert y_value
y_pos = dot_radius + static_cast<int>(midpoint * (1.f - y_value));
} else {
// For any negative y_value, start drawing from the midpoint
y_pos =
dot_radius + midpoint + static_cast<int>(midpoint * (0.f - y_value));
}
一旦确定了它的位置,我们就可以继续绘制点:
// Draw the dot
lcd.FillCircle(x_pos, y_pos, dot_radius);
最后,我们使用我们的ErrorReporter将x和y值记录到串行端口:
// Log the current X and Y values
error_reporter->Report("x_value: %f, y_value: %f\n", x_value, y_value);
注意
由于自定义实现,ErrorReporter可以通过 STM32F746G 的串行接口输出数据,micro/disco_f746ng/debug_log.cc,取代了micro/debug_log.cc中的原始实现。
运行示例
接下来,让我们构建项目!STM32F746G 运行 Arm 的 Mbed OS,因此我们将使用 Mbed 工具链将我们的应用程序部署到设备上。
提示
建议检查README.md以获取最新说明,因为构建过程可能会有所变化。
在开始之前,我们需要以下内容:
-
一个 STM32F746G Discovery kit 板
-
一个迷你 USB 电缆
-
Arm Mbed CLI(请参阅Mbed 设置指南)
-
Python 3 和
pip
与 Arduino IDE 类似,Mbed 要求源文件以特定方式结构化。 TensorFlow Lite for Microcontrollers Makefile 知道如何为我们做到这一点,并且可以生成适用于 Mbed 的目录。
为了这样做,请运行以下命令:
make -f tensorflow/lite/micro/tools/make/Makefile \
TARGET=mbed TAGS="CMSIS disco_f746ng" generate_hello_world_mbed_project
这将导致创建一个新目录:
tensorflow/lite/micro/tools/make/gen/mbed_cortex-m4/prj/ \
hello_world/mbed
该目录包含所有示例的依赖项,以适合 Mbed 构建。
首先,切换到目录,以便您可以在其中运行一些命令:
cd tensorflow/lite/micro/tools/make/gen/mbed_cortex-m4/prj/ \
hello_world/mbed
现在,您将使用 Mbed 下载依赖项并构建项目。
要开始,请使用以下命令指定 Mbed 当前目录是 Mbed 项目的根目录:
mbed config root .
接下来,指示 Mbed 下载依赖项并准备构建:
mbed deploy
默认情况下,Mbed 将使用 C++98 构建项目。但是,TensorFlow Lite 需要 C++11。运行以下 Python 片段修改 Mbed 配置文件,以便使用 C++11。您可以直接在命令行中键入或粘贴:
python -c 'import fileinput, glob;
for filename in glob.glob("mbed-os/tools/profiles/*.json"):
for line in fileinput.input(filename, inplace=True):
print(line.replace("\"-std=gnu++98\"","\"-std=c++11\", \"-fpermissive\""))'
最后,运行以下命令进行编译:
mbed compile -m DISCO_F746NG -t GCC_ARM
这应该会在以下路径生成一个二进制文件:
cp ./BUILD/DISCO_F746NG/GCC_ARM/mbed.bin
使用 Mbed 启动的一个好处是,像 STM32F746G 这样的 Mbed 启用板的部署非常简单。要部署,只需将 STM 板插入并将文件复制到其中。在 macOS 上,您可以使用以下命令执行此操作:
cp ./BUILD/DISCO_F746NG/GCC_ARM/mbed.bin /Volumes/DIS_F746NG/
或者,只需在文件浏览器中找到DIS_F746NG卷并将文件拖放过去。复制文件将启动闪存过程。完成后,您应该在设备屏幕上看到动画。
除了这个动画之外,当程序运行时,板上还会记录调试信息。要查看它,请使用波特率为 9600 的串行连接与板建立串行连接。
在 macOS 和 Linux 上,当您发出以下命令时,设备应该会列出:
ls /dev/tty*
它看起来会像下面这样:
/dev/tty.usbmodem1454203
确定设备后,请使用以下命令连接到设备,将</dev/tty.devicename>替换为设备名称,该名称显示在/dev中:
screen /<*dev/tty.devicename*> 9600
您会看到很多输出飞过。要停止滚动,请按 Ctrl-A,然后立即按 Esc。然后,您可以使用箭头键浏览输出,其中包含在各种x值上运行推断的结果:
x_value: 1.1843798*2², y_value: -1.9542645*2^-1
要停止使用screen查看调试输出,请按 Ctrl-A,紧接着按 K 键,然后按 Y 键。
进行您自己的更改
现在您已经部署了应用程序,可以尝试玩耍并进行一些更改!您可以在tensorflow/lite/micro/tools/make/gen/mbed_cortex-m4/prj/hello_world/mbed文件夹中找到应用程序的代码。只需编辑并保存,然后重复之前的说明以将修改后的代码部署到设备上。
以下是您可以尝试的一些事情:
-
通过调整每个周期的推理次数来使点移动更慢或更快。
-
修改output_handler.cc以将基于文本的动画记录到串行端口。
-
使用正弦波来控制其他组件,比如 LED 或声音发生器。
总结
在过去的三章中,我们已经经历了训练模型、将其转换为 TensorFlow Lite、围绕其编写应用程序并将其部署到微型设备的完整端到端旅程。在接下来的章节中,我们将探索一些更复杂和令人兴奋的示例,将嵌入式机器学习投入实际应用。
首先,我们将构建一个使用微小的、18 KB 模型识别口头命令的应用程序。
第七章:唤醒词检测:构建一个应用程序
TinyML 可能是一个新现象,但它最广泛的应用可能已经在你的家中、你的汽车中,甚至在你的口袋里工作。你能猜到它是什么吗?
过去几年见证了数字助手的崛起。这些产品提供了一个旨在提供即时访问信息而无需屏幕或键盘的语音用户界面(UI)。在谷歌助手、苹果的 Siri 和亚马逊的 Alexa 之间,这些数字助手几乎无处不在。几乎每部手机都内置了某种变体,从旗舰型号到专为新兴市场设计的语音优先设备。它们也存在于智能音箱、计算机和车辆中。
在大多数情况下,语音识别、自然语言处理和生成用户查询响应的繁重工作是在云端完成的,运行大型 ML 模型的强大服务器上。当用户提出问题时,它会作为音频流发送到服务器。服务器会弄清楚这意味着什么,查找所需的任何信息,并发送适当的响应。
但助手吸引人的部分是它们总是在线的,随时准备帮助你。通过说“嘿,谷歌”或“Alexa”,你可以唤醒你的助手并告诉它你需要什么,而无需按下按钮。这意味着它们必须时刻倾听你的声音,无论你是坐在客厅里,驾驶在高速公路上,还是在户外手持手机。
尽管在服务器上进行语音识别很容易,但将设备捕获的音频不间断地发送到数据中心是不可行的。从隐私的角度来看,将每秒捕获的音频发送到远程服务器将是一场灾难。即使这样做没问题,也需要大量的带宽,并且会在几小时内耗尽移动数据套餐。此外,网络通信会消耗能量,不间断地发送数据流将迅速耗尽设备的电池。更重要的是,每个请求都要经过服务器来回传输,助手会感觉迟钝和反应迟缓。
助手实际上只需要在唤醒词之后立即跟随的音频(例如“嘿,谷歌”)。如果我们能够在不发送数据的情况下检测到这个词,但在听到它时开始流式传输呢?我们将保护用户隐私,节省电池寿命和带宽,并在不等待网络的情况下唤醒助手。
这就是 TinyML 的用武之地。我们可以训练一个微小的模型来监听唤醒词,并在低功耗芯片上运行它。如果我们将其嵌入到手机中,它可以一直监听唤醒词。当它听到魔法词时,它会通知手机的操作系统(OS),后者可以开始捕获音频并将其发送到服务器。
唤醒词检测是 TinyML 的完美应用。它非常适合提供隐私、效率、速度和离线推理。这种方法,即一个微小、高效的模型“唤醒”一个更大、更耗资源的模型,被称为级联。
在本章中,我们将探讨如何使用预训练的语音检测模型,利用微型微控制器提供始终开启的唤醒词检测。在第八章中,我们将探讨模型是如何训练的,以及如何创建我们自己的模型。
我们要构建什么
我们将构建一个嵌入式应用程序,使用一个 18 KB 的模型,该模型在语音命令数据集上进行训练,用于分类口头音频。该模型经过训练,可以识别“是”和“否”这两个词,还能够区分未知词和沉默或背景噪音。
我们的应用程序将通过麦克风监听周围环境,并在检测到一个词时通过点亮 LED 或在屏幕上显示数据来指示。理解这段代码将使你能够通过语音命令控制任何电子项目。
注意
与第五章一样,此应用程序的源代码可在TensorFlow GitHub 存储库中找到。
我们将按照第五章的类似模式,先浏览测试,然后是应用代码,接着是使样本在各种设备上工作的逻辑。
我们提供了将应用程序部署到以下设备的说明:
注意
TensorFlow Lite 定期添加对新设备的支持,所以如果您想要使用的设备不在此列表中,请查看示例的README.md。如果您在按照这些步骤操作时遇到问题,也可以在那里查看更新的部署说明。
这是一个比“hello world”示例复杂得多的应用程序,所以让我们从浏览其结构开始。
应用程序架构
在前几章中,您已经了解到机器学习应用程序执行以下一系列操作:
-
获取输入
-
预处理输入以提取适合输入模型的特征
-
对处理后的输入进行推断运行
-
后处理模型的输出以理解它
-
使用生成的信息来实现一些事情
“hello world”示例以非常直接的方式遵循了这些步骤。它接受一个由简单计数器生成的单个浮点数作为输入。其输出是另一个浮点数,我们直接用来控制可视输出。
由于以下原因,我们的唤醒词应用程序将更加复杂:
-
它将音频数据作为输入。正如您将看到的,这需要在输入模型之前进行大量处理。
-
其模型是一个分类器,输出类别概率。我们需要解析并理解这个输出。
-
它旨在持续进行推断,对实时数据进行处理。我们需要编写代码来理解一系列推断。
-
该模型更大更复杂。我们将推动我们的硬件极限。
因为很多复杂性是由我们将要使用的模型造成的,让我们多了解一些关于它的信息。
介绍我们的模型
正如我们之前提到的,本章中使用的模型经过训练,可以识别“yes”和“no”这两个词,同时也能够区分未知词和沉默或背景噪音。
该模型是在一个名为Speech Commands dataset的数据集上进行训练的。该数据集包含 65,000 个 30 个短单词的一秒长话语,是在线众包的。
尽管数据集包含 30 个不同的单词,但模型只训练来区分四个类别:单词“yes”和“no”,“未知”单词(指数据集中的其他 28 个单词)以及沉默。
该模型每次接受一秒钟的数据。它输出四个概率分数,分别对应这四个类别,预测数据代表其中一个类别的可能性有多大。
然而,该模型不接受原始音频样本数据。相反,它使用频谱图,这是由频率信息片段组成的二维数组,每个片段来自不同的时间窗口。
图 7-1 是从一个说“yes”的一秒音频片段生成的频谱图的可视表示。图 7-2 展示了同样的内容,但是是“no”这个词。

图 7-1. “yes”的频谱图

图 7-2. “no”的频谱图
通过在预处理过程中隔离频率信息,我们让模型的生活变得更轻松。在训练过程中,它不需要学习如何解释原始音频数据;相反,它可以使用一个更高层次的抽象来处理最有用的信息。
我们将在本章后面看到如何生成频谱图。现在,我们只需要知道模型将频谱图作为输入。因为频谱图是一个二维数组,我们将其作为 2D 张量输入到模型中。
有一种神经网络架构专门设计用于处理多维张量,其中信息包含在相邻值组之间的关系中。它被称为卷积神经网络(CNN)。
这种类型数据最常见的例子是图像,其中一组相邻的像素可能代表一个形状、图案或纹理。在训练过程中,CNN 能够识别这些特征并学习它们代表什么。
它可以学习简单图像特征(如线条或边缘)如何组合成更复杂的特征(如眼睛或耳朵),以及这些特征如何组合形成输入图像,比如人脸的照片。这意味着 CNN 可以学会区分不同类别的输入图像,比如区分人的照片和狗的照片。
尽管它们通常应用于图像,即像素的 2D 网格,但 CNN 可以与任何多维向量输入一起使用。事实证明,它们非常适合处理频谱图数据。
在第八章中,我们将看看这个模型是如何训练的。在那之前,让我们回到讨论我们应用程序的架构。
所有的组件
如前所述,我们的唤醒词应用程序比“hello world”示例更复杂。图 7-3 显示了组成它的组件。

图 7-3。我们唤醒词应用程序的组件
让我们来研究每个部分的功能:
主循环
与“hello world”示例一样,我们的应用程序在一个连续循环中运行。所有后续的过程都包含在其中,并且它们会持续执行,尽可能快地运行,即每秒多次。
音频提供者
音频提供者从麦克风捕获原始音频数据。由于不同设备捕获音频的方法各不相同,这个组件可以被覆盖和定制。
特征提供者
特征提供者将原始音频数据转换为我们模型所需的频谱图格式。它作为主循环的一部分以滚动方式提供,为解释器提供一系列重叠的一秒窗口。
TF Lite 解释器
解释器运行 TensorFlow Lite 模型,将输入的频谱图转换为一组概率。
模型
模型包含在数据数组中,并由解释器运行。该数组位于tiny_conv_micro_features_model_data.cc中。
命令识别器
由于推断每秒运行多次,RecognizeCommands类聚合结果并确定是否平均听到了一个已知的单词。
命令响应器
如果听到了一个命令,命令响应器将使用设备的输出功能让用户知道。根据设备的不同,这可能意味着闪烁 LED 或在 LCD 显示器上显示数据。它可以被覆盖以适应不同的设备类型。
GitHub 上的示例文件包含了每个组件的测试。我们将逐步学习它们是如何工作的。
测试过程
就像第五章中一样,我们可以使用测试来了解应用程序的工作原理。我们已经涵盖了很多 C++和 TensorFlow Lite 的基础知识,因此不需要解释每一行代码。相反,让我们专注于每个测试的最重要部分,并解释发生了什么。
我们将探讨以下测试,您可以在GitHub 存储库中找到:
展示如何对频谱图数据进行推断并解释结果
展示如何使用音频提供程序
展示如何使用特征提供程序,使用模拟(虚假)音频提供程序的实现来传递虚假数据
展示如何解释模型的输出以决定是否找到了命令
展示如何调用命令响应器以触发输出
示例中还有许多其他测试,但是探索这些测试将使我们了解关键的移动部分。
基本流程
测试micro_speech_test.cc遵循我们从“hello world”示例中熟悉的基本流程:加载模型,设置解释器并分配张量。
然而,有一个显著的区别。在“hello world”示例中,我们使用AllOpsResolver来引入可能需要运行模型的所有深度学习操作。这是一种可靠的方法,但是它是浪费的,因为给定模型可能并不使用所有数十个可用操作。当部署到设备时,这些不必要的操作将占用宝贵的内存,因此最好只包含我们需要的操作。
为此,我们首先在测试文件的顶部定义我们的模型将需要的操作:
namespace tflite {
namespace ops {
namespace micro {
TfLiteRegistration* Register_DEPTHWISE_CONV_2D();
TfLiteRegistration* Register_FULLY_CONNECTED();
TfLiteRegistration* Register_SOFTMAX();
} // namespace micro
} // namespace ops
} // namespace tflite
接下来,我们设置日志记录并加载我们的模型,正常进行:
// Set up logging.
tflite::MicroErrorReporter micro_error_reporter;
tflite::ErrorReporter* error_reporter = µ_error_reporter;
// Map the model into a usable data structure. This doesn't involve any
// copying or parsing, it's a very lightweight operation.
const tflite::Model* model =
::tflite::GetModel(g_tiny_conv_micro_features_model_data);
if (model->version() != TFLITE_SCHEMA_VERSION) {
error_reporter->Report(
"Model provided is schema version %d not equal "
"to supported version %d.\n",
model->version(), TFLITE_SCHEMA_VERSION);
}
加载模型后,我们声明一个MicroMutableOpResolver并使用其方法AddBuiltin()来添加我们之前列出的操作:
tflite::MicroMutableOpResolver micro_mutable_op_resolver;
micro_mutable_op_resolver.AddBuiltin(
tflite::BuiltinOperator_DEPTHWISE_CONV_2D,
tflite::ops::micro::Register_DEPTHWISE_CONV_2D());
micro_mutable_op_resolver.AddBuiltin(
tflite::BuiltinOperator_FULLY_CONNECTED,
tflite::ops::micro::Register_FULLY_CONNECTED());
micro_mutable_op_resolver.AddBuiltin(tflite::BuiltinOperator_SOFTMAX,
tflite::ops::micro::Register_SOFTMAX());
您可能想知道我们如何知道为给定模型包含哪些操作。一种方法是尝试使用MicroMutableOpResolver运行模型,但完全不调用AddBuiltin()。推断将失败,并且随附的错误消息将告诉我们缺少哪些操作需要添加。
注意
MicroMutableOpResolver在tensorflow/lite/micro/micro_mutable_op_resolver.h中定义,您需要将其添加到您的include语句中。
设置好MicroMutableOpResolver后,我们就像往常一样继续,设置解释器及其工作内存:
// Create an area of memory to use for input, output, and intermediate arrays.
const int tensor_arena_size = 10 * 1024;
uint8_t tensor_arena[tensor_arena_size];
// Build an interpreter to run the model with.
tflite::MicroInterpreter interpreter(model, micro_mutable_op_resolver, tensor_arena,
tensor_arena_size, error_reporter);
interpreter.AllocateTensors();
在我们的“hello world”应用程序中,我们仅为tensor_arena分配了 2 * 1,024 字节的空间,因为模型非常小。我们的语音模型要大得多,它处理更复杂的输入和输出,因此需要更多空间(10 1,024)。这是通过试错确定的。
接下来,我们检查输入张量的大小。但是,这次有点不同:
// Get information about the memory area to use for the model's input.
TfLiteTensor* input = interpreter.input(0);
// Make sure the input has the properties we expect.
TF_LITE_MICRO_EXPECT_NE(nullptr, input);
TF_LITE_MICRO_EXPECT_EQ(4, input->dims->size);
TF_LITE_MICRO_EXPECT_EQ(1, input->dims->data[0]);
TF_LITE_MICRO_EXPECT_EQ(49, input->dims->data[1]);
TF_LITE_MICRO_EXPECT_EQ(40, input->dims->data[2]);
TF_LITE_MICRO_EXPECT_EQ(1, input->dims->data[3]);
TF_LITE_MICRO_EXPECT_EQ(kTfLiteUInt8, input->type);
因为我们的输入是频谱图,所以输入张量具有更多维度——总共四个。第一维只是包含单个元素的包装器。第二和第三代表我们的频谱图的“行”和“列”,恰好有 49 行和 40 列。输入张量的第四个、最内部的维度,大小为 1,保存频谱图的每个单独的“像素”。我们稍后将更详细地查看频谱图的结构。
接下来,我们获取一个“yes”样本频谱图,存储在常量g_yes_micro_f2e59fea_nohash_1_data中。该常量在文件micro_features/yes_micro_features_data.cc中定义,该文件被此测试包含。频谱图存在为 1D 数组,我们只需迭代它将其复制到输入张量中:
// Copy a spectrogram created from a .wav audio file of someone saying "Yes"
// into the memory area used for the input.
const uint8_t* yes_features_data = g_yes_micro_f2e59fea_nohash_1_data;
for (int i = 0; i < input->bytes; ++i) {
input->data.uint8[i] = yes_features_data[i];
}
在输入被分配之后,我们运行推断并检查输出张量的大小和形状:
// Run the model on this input and make sure it succeeds.
TfLiteStatus invoke_status = interpreter.Invoke();
if (invoke_status != kTfLiteOk) {
error_reporter->Report("Invoke failed\n");
}
TF_LITE_MICRO_EXPECT_EQ(kTfLiteOk, invoke_status);
// Get the output from the model, and make sure it's the expected size and
// type.
TfLiteTensor* output = interpreter.output(0);
TF_LITE_MICRO_EXPECT_EQ(2, output->dims->size);
TF_LITE_MICRO_EXPECT_EQ(1, output->dims->data[0]);
TF_LITE_MICRO_EXPECT_EQ(4, output->dims->data[1]);
TF_LITE_MICRO_EXPECT_EQ(kTfLiteUInt8, output->type);
我们的输出有两个维度。第一个只是一个包装器。第二个有四个元素。这是保存每个四个类别(静音、未知、“是”和“否”)匹配概率的结构。
接下来的代码块检查概率是否符合预期。输出张量的每个元素始终代表一个特定的类别,因此我们知道要检查每个类别的哪个索引。这个顺序在训练期间定义:
// There are four possible classes in the output, each with a score.
const int kSilenceIndex = 0;
const int kUnknownIndex = 1;
const int kYesIndex = 2;
const int kNoIndex = 3;
// Make sure that the expected "Yes" score is higher than the other classes.
uint8_t silence_score = output->data.uint8[kSilenceIndex];
uint8_t unknown_score = output->data.uint8[kUnknownIndex];
uint8_t yes_score = output->data.uint8[kYesIndex];
uint8_t no_score = output->data.uint8[kNoIndex];
TF_LITE_MICRO_EXPECT_GT(yes_score, silence_score);
TF_LITE_MICRO_EXPECT_GT(yes_score, unknown_score);
TF_LITE_MICRO_EXPECT_GT(yes_score, no_score);
我们传入了一个“是”频谱图,因此我们期望变量yes_score包含的概率高于silence_score、unknown_score和no_score。
当我们对“是”满意时,我们用“否”频谱图做同样的事情。首先,我们复制一个输入并运行推断:
// Now test with a different input, from a recording of "No".
const uint8_t* no_features_data = g_no_micro_f9643d42_nohash_4_data;
for (int i = 0; i < input->bytes; ++i) {
input->data.uint8[i] = no_features_data[i];
}
// Run the model on this "No" input.
invoke_status = interpreter.Invoke();
if (invoke_status != kTfLiteOk) {
error_reporter->Report("Invoke failed\n");
}
TF_LITE_MICRO_EXPECT_EQ(kTfLiteOk, invoke_status);
推断完成后,我们确认“no”获得了最高分数:
// Make sure that the expected "No" score is higher than the other classes.
silence_score = output->data.uint8[kSilenceIndex];
unknown_score = output->data.uint8[kUnknownIndex];
yes_score = output->data.uint8[kYesIndex];
no_score = output->data.uint8[kNoIndex];
TF_LITE_MICRO_EXPECT_GT(no_score, silence_score);
TF_LITE_MICRO_EXPECT_GT(no_score, unknown_score);
TF_LITE_MICRO_EXPECT_GT(no_score, yes_score);
我们完成了!
要运行此测试,请从 TensorFlow 存储库的根目录发出以下命令:
make -f tensorflow/lite/micro/tools/make/Makefile \
test_micro_speech_test
接下来,让我们看看所有音频数据的来源:音频提供程序。
音频提供程序
音频提供程序是将设备的麦克风硬件连接到我们的代码的部分。每个设备都有不同的机制来捕获音频。因此,audio_provider.h为请求音频数据定义了一个接口,开发人员可以为他们想要支持的任何平台编写自己的实现。
提示
示例包括 Arduino、STM32F746G、SparkFun Edge 和 macOS 的音频提供程序实现。如果您希望此示例支持新设备,可以阅读现有的实现以了解如何实现。
音频提供程序的核心部分是一个名为GetAudioSamples()的函数,在audio_provider.h中定义。它看起来像这样:
TfLiteStatus GetAudioSamples(tflite::ErrorReporter* error_reporter,
int start_ms, int duration_ms,
int* audio_samples_size, int16_t** audio_samples);
如audio_provider.h中所述,该函数应返回一个 16 位脉冲编码调制(PCM)音频数据数组。这是数字音频的一种非常常见的格式。
该函数被调用时带有一个ErrorReporter实例、一个开始时间(start_ms)、一个持续时间(duration_ms)和两个指针。
这些指针是GetAudioSamples()提供数据的机制。调用者声明适当类型的变量,然后在调用函数时将指针传递给它们。在函数的实现内部,指针被解引用,并设置变量的值。
第一个指针audio_samples_size将接收音频数据中 16 位样本的总数。第二个指针audio_samples将接收一个包含音频数据本身的数组。
通过查看测试,我们可以看到这一点。audio_provider_test.cc中有两个测试,但我们只需要查看第一个来学习如何使用音频提供程序:
TF_LITE_MICRO_TEST(TestAudioProvider) {
tflite::MicroErrorReporter micro_error_reporter;
tflite::ErrorReporter* error_reporter = µ_error_reporter;
int audio_samples_size = 0;
int16_t* audio_samples = nullptr;
TfLiteStatus get_status =
GetAudioSamples(error_reporter, 0, kFeatureSliceDurationMs,
&audio_samples_size, &audio_samples);
TF_LITE_MICRO_EXPECT_EQ(kTfLiteOk, get_status);
TF_LITE_MICRO_EXPECT_LE(audio_samples_size, kMaxAudioSampleSize);
TF_LITE_MICRO_EXPECT_NE(audio_samples, nullptr);
// Make sure we can read all of the returned memory locations.
int total = 0;
for (int i = 0; i < audio_samples_size; ++i) {
total += audio_samples[i];
}
}
测试展示了如何使用一些值和一些指针调用GetAudioSamples()。测试确认在调用函数后指针被正确赋值。
注意
您会注意到一些常量的使用,kFeatureSliceDurationMs和kMaxAudioSampleSize。这些是在模型训练时选择的值,您可以在micro_features/micro_model_settings.h中找到它们。
audio_provider.cc的默认实现只返回一个空数组。为了证明它的大小是正确的,测试只是简单地循环遍历它以获取预期数量的样本。
除了GetAudioSamples()之外,音频提供程序还包含一个名为LatestAudioTimestamp()的函数。这个函数旨在返回音频数据最后捕获的时间,以毫秒为单位。特征提供程序需要这些信息来确定要获取哪些音频数据。
要运行音频提供程序测试,请使用以下命令:
make -f tensorflow/lite/micro/tools/make/Makefile \
test_audio_provider_test
音频提供程序被特征提供程序用作新鲜音频样本的来源,所以让我们接着看一下。
特征提供程序
特征提供者将从音频提供者获取的原始音频转换为可以输入到我们模型中的频谱图。它在主循环中被调用。
其接口在feature_provider.h中定义,如下所示:
class FeatureProvider {
public:
// Create the provider, and bind it to an area of memory. This memory should
// remain accessible for the lifetime of the provider object, since subsequent
// calls will fill it with feature data. The provider does no memory
// management of this data.
FeatureProvider(int feature_size, uint8_t* feature_data);
~FeatureProvider();
// Fills the feature data with information from audio inputs, and returns how
// many feature slices were updated.
TfLiteStatus PopulateFeatureData(tflite::ErrorReporter* error_reporter,
int32_t last_time_in_ms, int32_t time_in_ms,
int* how_many_new_slices);
private:
int feature_size_;
uint8_t* feature_data_;
// Make sure we don't try to use cached information if this is the first call
// into the provider.
bool is_first_run_;
};
要查看它的使用方式,我们可以看一下feature_provider_mock_test.cc中的测试。
为了使特征提供者能够处理音频数据,这些测试使用了一个特殊的假版本音频提供者,称为模拟,它被设置为提供音频数据。它在audio_provider_mock.cc中定义。
注意
在测试的构建说明中,模拟音频提供者被真实的东西替代,您可以在Makefile.inc中的FEATURE_PROVIDER_MOCK_TEST_SRCS下找到。
文件feature_provider_mock_test.cc包含两个测试。这是第一个:
TF_LITE_MICRO_TEST(TestFeatureProviderMockYes) {
tflite::MicroErrorReporter micro_error_reporter;
tflite::ErrorReporter* error_reporter = µ_error_reporter;
uint8_t feature_data[kFeatureElementCount];
FeatureProvider feature_provider(kFeatureElementCount, feature_data);
int how_many_new_slices = 0;
TfLiteStatus populate_status = feature_provider.PopulateFeatureData(
error_reporter, /* last_time_in_ms= */ 0, /* time_in_ms= */ 970,
&how_many_new_slices);
TF_LITE_MICRO_EXPECT_EQ(kTfLiteOk, populate_status);
TF_LITE_MICRO_EXPECT_EQ(kFeatureSliceCount, how_many_new_slices);
for (int i = 0; i < kFeatureElementCount; ++i) {
TF_LITE_MICRO_EXPECT_EQ(g_yes_micro_f2e59fea_nohash_1_data[i],
feature_data[i]);
}
}
要创建一个FeatureProvider,我们调用它的构造函数,传入feature_size和feature_data参数:
FeatureProvider feature_provider(kFeatureElementCount, feature_data);
第一个参数指示频谱图中应该有多少总数据元素。第二个参数是一个指向我们希望用频谱图数据填充的数组的指针。
频谱图中的元素数量是在模型训练时决定的,并在micro_features/micro_model_settings.h中定义为kFeatureElementCount。
为了获取过去一秒钟的音频特征,会调用feature_provider.PopulateFeatureData():
TfLiteStatus populate_status = feature_provider.PopulateFeatureData(
error_reporter, /* last_time_in_ms= */ 0, /* time_in_ms= */ 970,
&how_many_new_slices);
我们提供一个ErrorReporter实例,一个表示上次调用此方法的时间的整数(last_time_in_ms),当前时间(time_in_ms)以及一个指向整数的指针,该指针将更新为我们接收到多少个新的特征切片(how_many_new_slices)。切片只是频谱图的一行,代表一段时间。
因为我们总是想要最后一秒钟的音频,特征提供者将比较上次调用时的时间(last_time_in_ms)和当前时间(time_in_ms),从那段时间内捕获的音频创建频谱数据,然后更新feature_data数组以添加任何额外的切片并删除超过一秒钟的旧切片。
当PopulateFeatureData()运行时,它将从模拟音频提供者请求音频。模拟将提供代表“yes”的音频,特征提供者将处理它并提供结果。
在调用PopulateFeatureData()之后,我们检查其结果是否符合预期。我们将生成的数据与由模拟音频提供者提供的“yes”输入的已知频谱图进行比较:
TF_LITE_MICRO_EXPECT_EQ(kTfLiteOk, populate_status);
TF_LITE_MICRO_EXPECT_EQ(kFeatureSliceCount, how_many_new_slices);
for (int i = 0; i < kFeatureElementCount; ++i) {
TF_LITE_MICRO_EXPECT_EQ(g_yes_micro_f2e59fea_nohash_1_data[i],
feature_data[i]);
}
模拟音频提供者可以根据传入的开始和结束时间提供“yes”或“no”的音频。feature_provider_mock_test.cc中的第二个测试与第一个测试完全相同,但表示“no”的音频。
运行测试时,请使用以下命令:
make -f tensorflow/lite/micro/tools/make/Makefile \
test_feature_provider_mock_test
特征提供者如何将音频转换为频谱图
特征提供者在feature_provider.cc中实现。让我们来看看它是如何工作的。
正如我们讨论过的,它的工作是填充一个代表一秒钟音频的频谱图的数组。它被设计为在循环中调用,因此为了避免不必要的工作,它只会为现在和上次调用之间的时间生成新的特征。如果它在不到一秒钟之前被调用,它将保留一些先前的输出并仅生成缺失的部分。
在我们的代码中,每个频谱图都表示为一个二维数组,有 40 列和 49 行,其中每一行代表一个 30 毫秒(ms)的音频样本,分成 43 个频率桶。
为了创建每一行,我们通过快速傅立叶变换(FFT)算法运行 30 毫秒的音频输入。这种技术分析了样本中音频的频率分布,并创建了一个由 256 个频率桶组成的数组,每个值从 0 到 255。这些被平均成六组,留下 43 个桶。
执行此操作的代码位于文件micro_features/micro_features_generator.cc中,并由特征提供程序调用。
为了构建整个二维数组,我们将在 49 个连续的 30 毫秒音频片段上运行 FFT 的结果组合在一起,每个片段与上一个片段重叠 10 毫秒。图 7-4 展示了这是如何发生的。
您可以看到 30 毫秒的样本窗口每次向前移动 20 毫秒,直到覆盖完整的一秒样本。生成的频谱图准备传递到我们的模型中。
我们可以在feature_provider.cc中了解这个过程是如何发生的。首先,它根据上次调用PopulateFeatureData()的时间确定实际需要生成哪些片段:
// Quantize the time into steps as long as each window stride, so we can
// figure out which audio data we need to fetch.
const int last_step = (last_time_in_ms / kFeatureSliceStrideMs);
const int current_step = (time_in_ms / kFeatureSliceStrideMs);
int slices_needed = current_step - last_step;

图 7-4。正在处理的音频样本的图表
如果它以前没有运行过,或者它在一秒钟前运行过,它将生成最大数量的片段:
if (is_first_run_) {
TfLiteStatus init_status = InitializeMicroFeatures(error_reporter);
if (init_status != kTfLiteOk) {
return init_status;
}
is_first_run_ = false;
slices_needed = kFeatureSliceCount;
}
if (slices_needed > kFeatureSliceCount) {
slices_needed = kFeatureSliceCount;
}
*how_many_new_slices = slices_needed;
生成的数字被写入how_many_new_slices。
接下来,它计算应保留多少现有片段,并将数组中的数据移动以为任何新片段腾出空间:
const int slices_to_keep = kFeatureSliceCount - slices_needed;
const int slices_to_drop = kFeatureSliceCount - slices_to_keep;
// If we can avoid recalculating some slices, just move the existing data
// up in the spectrogram, to perform something like this:
// last time = 80ms current time = 120ms
// +-----------+ +-----------+
// | data@20ms | --> | data@60ms |
// +-----------+ -- +-----------+
// | data@40ms | -- --> | data@80ms |
// +-----------+ -- -- +-----------+
// | data@60ms | -- -- | <empty> |
// +-----------+ -- +-----------+
// | data@80ms | -- | <empty> |
// +-----------+ +-----------+
if (slices_to_keep > 0) {
for (int dest_slice = 0; dest_slice < slices_to_keep; ++dest_slice) {
uint8_t* dest_slice_data =
feature_data_ + (dest_slice * kFeatureSliceSize);
const int src_slice = dest_slice + slices_to_drop;
const uint8_t* src_slice_data =
feature_data_ + (src_slice * kFeatureSliceSize);
for (int i = 0; i < kFeatureSliceSize; ++i) {
dest_slice_data[i] = src_slice_data[i];
}
}
}
注意
如果您是经验丰富的 C++作者,您可能会想知道为什么我们不使用标准库来做诸如数据复制之类的事情。原因是我们试图避免不必要的依赖关系,以保持我们的二进制文件大小较小。因为嵌入式平台的内存非常有限,较小的应用程序二进制文件意味着我们有空间容纳更大更准确的深度学习模型。
在移动数据之后,它开始一个循环,每次迭代一次,它都需要一个新的片段。在这个循环中,它首先使用GetAudioSamples()从音频提供程序请求该片段的音频:
for (int new_slice = slices_to_keep; new_slice < kFeatureSliceCount;
++new_slice) {
const int new_step = (current_step - kFeatureSliceCount + 1) + new_slice;
const int32_t slice_start_ms = (new_step * kFeatureSliceStrideMs);
int16_t* audio_samples = nullptr;
int audio_samples_size = 0;
GetAudioSamples(error_reporter, slice_start_ms, kFeatureSliceDurationMs,
&audio_samples_size, &audio_samples);
if (audio_samples_size < kMaxAudioSampleSize) {
error_reporter->Report("Audio data size %d too small, want %d",
audio_samples_size, kMaxAudioSampleSize);
return kTfLiteError;
}
要完成循环迭代,它将数据传递给GenerateMicroFeatures(),在micro_features/micro_features_generator.h中定义。这是执行 FFT 并返回音频频率信息的函数。
它还传递了一个指针new_slice_data,指向新数据应写入的内存位置:
uint8_t* new_slice_data = feature_data_ + (new_slice * kFeatureSliceSize);
size_t num_samples_read;
TfLiteStatus generate_status = GenerateMicroFeatures(
error_reporter, audio_samples, audio_samples_size, kFeatureSliceSize,
new_slice_data, &num_samples_read);
if (generate_status != kTfLiteOk) {
return generate_status;
}
}
在每个片段完成这个过程之后,我们有了整整一秒的最新频谱图。
提示
生成 FFT 的函数是GenerateMicroFeatures()。如果您感兴趣,您可以在micro_features/micro_features_generator.cc中阅读其定义。
如果您正在构建自己的应用程序并使用频谱图,您可以直接重用此代码。在训练模型时,您需要使用相同的代码将数据预处理为频谱图。
一旦我们有了频谱图,我们就可以使用模型对其进行推理。发生这种情况后,我们需要解释结果。这项任务属于我们接下来要探讨的类RecognizeCommands。
命令识别器
在我们的模型输出了一组概率,表明在音频的最后一秒中说出了一个已知的单词之后,RecognizeCommands类的工作就是确定这是否表示成功的检测。
这似乎很简单:如果给定类别中的概率超过某个阈值,那么该单词已被说出。然而,在现实世界中,事情变得有点复杂。
正如我们之前建立的,我们每秒运行多个推理,每个推理在一秒钟的数据窗口上运行。这意味着我们将在任何给定单词上多次运行推理,在多个窗口上。
在图 7-5 中,您可以看到单词“noted”被说出的波形,周围有一个代表被捕获的一秒窗口的框。

图 7-5。在我们的窗口中捕获到的单词“noted”
我们的模型经过训练,可以检测到“no”一词,并且它知道“noted”一词不是同一回事。如果我们在这一秒钟的窗口上进行推理,它将(希望)输出“no”一词的低概率。但是,如果窗口稍早出现在音频流中,如图 7-6 中所示,会发生什么呢?

图 7-6。在我们的窗口中捕获到“noted”一词的部分
在这种情况下,“noted”一词的唯一部分出现在窗口中的是它的第一个音节。因为“noted”的第一个音节听起来像“no”,所以模型很可能会将其解释为“no”的概率很高。
这个问题,以及其他问题,意味着我们不能依赖单个推理来告诉我们一个单词是否被说出。这就是RecognizeCommands的作用所在!
识别器计算每个单词在过去几次推理中的平均分数,并决定是否高到足以被视为检测。为了做到这一点,我们将每个推理结果传递给它。
你可以在recognize_commands.h中看到它的接口,这里部分重现:
class RecognizeCommands {
public:
explicit RecognizeCommands(tflite::ErrorReporter* error_reporter,
int32_t average_window_duration_ms = 1000,
uint8_t detection_threshold = 200,
int32_t suppression_ms = 1500,
int32_t minimum_count = 3);
// Call this with the results of running a model on sample data.
TfLiteStatus ProcessLatestResults(const TfLiteTensor* latest_results,
const int32_t current_time_ms,
const char** found_command, uint8_t* score,
bool* is_new_command);
类RecognizeCommands被定义,以及一个构造函数,为一些默认值进行了定义:
-
平均窗口的长度(
average_window_duration_ms) -
作为检测的最低平均分数(
detection_threshold) -
在识别第二个命令之前听到命令后等待的时间量(
suppression_ms) -
在窗口中需要的最小推理数量,以便结果计数(
3)
该类有一个方法,ProcessLatestResults()。它接受一个指向包含模型输出的TfLiteTensor的指针(latest_results),并且必须在当前时间(current_time_ms)下调用。
此外,它还接受三个指针用于输出。首先,它给出了任何被检测到的单词的名称(found_command)。它还提供了命令的平均分数(score)以及命令是新的还是在一定时间内的先前推理中已经听到过(is_new_command)。
对多次推理结果进行平均是处理时间序列数据时的一种有用且常见的技术。在接下来的几页中,我们将逐步介绍recognize_commands.cc中的代码,并了解一些它的工作原理。你不需要理解每一行,但了解一些可能对你自己的项目有帮助的工具是有益的。
首先,我们确保输入张量的形状和类型是正确的:
TfLiteStatus RecognizeCommands::ProcessLatestResults(
const TfLiteTensor* latest_results, const int32_t current_time_ms,
const char** found_command, uint8_t* score, bool* is_new_command) {
if ((latest_results->dims->size != 2) ||
(latest_results->dims->data[0] != 1) ||
(latest_results->dims->data[1] != kCategoryCount)) {
error_reporter_->Report(
"The results for recognition should contain %d elements, but there are "
"%d in an %d-dimensional shape",
kCategoryCount, latest_results->dims->data[1],
latest_results->dims->size);
return kTfLiteError;
}
if (latest_results->type != kTfLiteUInt8) {
error_reporter_->Report(
"The results for recognition should be uint8 elements, but are %d",
latest_results->type);
return kTfLiteError;
}
接下来,我们检查current_time_ms以验证它是否在我们的平均窗口中最近的结果之后:
if ((!previous_results_.empty()) &&
(current_time_ms < previous_results_.front().time_)) {
error_reporter_->Report(
"Results must be fed in increasing time order, but received a "
"timestamp of %d that was earlier than the previous one of %d",
current_time_ms, previous_results_.front().time_);
return kTfLiteError;
}
之后,我们将最新的结果添加到我们将要进行平均的结果列表中:
// Add the latest results to the head of the queue.
previous_results_.push_back({current_time_ms, latest_results->data.uint8});
// Prune any earlier results that are too old for the averaging window.
const int64_t time_limit = current_time_ms - average_window_duration_ms_;
while ((!previous_results_.empty()) &&
previous_results_.front().time_ < time_limit) {
previous_results_.pop_front();
如果我们的平均窗口中的结果少于最小数量(由minimum_count_定义,默认为3),我们无法提供有效的平均值。在这种情况下,我们将输出指针设置为指示found_command是最近的顶级命令,分数为 0,并且该命令不是新的:
// If there are too few results, assume the result will be unreliable and
// bail.
const int64_t how_many_results = previous_results_.size();
const int64_t earliest_time = previous_results_.front().time_;
const int64_t samples_duration = current_time_ms - earliest_time;
if ((how_many_results < minimum_count_) ||
(samples_duration < (average_window_duration_ms_ / 4))) {
*found_command = previous_top_label_;
*score = 0;
*is_new_command = false;
return kTfLiteOk;
}
否则,我们继续通过平均窗口中的所有分数:
// Calculate the average score across all the results in the window.
int32_t average_scores[kCategoryCount];
for (int offset = 0; offset < previous_results_.size(); ++offset) {
PreviousResultsQueue::Result previous_result =
previous_results_.from_front(offset);
const uint8_t* scores = previous_result.scores_;
for (int i = 0; i < kCategoryCount; ++i) {
if (offset == 0) {
average_scores[i] = scores[i];
} else {
average_scores[i] += scores[i];
}
}
}
for (int i = 0; i < kCategoryCount; ++i) {
average_scores[i] /= how_many_results;
}
现在我们有足够的信息来确定哪个类别是我们的赢家。建立这一点是一个简单的过程:
// Find the current highest scoring category.
int current_top_index = 0;
int32_t current_top_score = 0;
for (int i = 0; i < kCategoryCount; ++i) {
if (average_scores[i] > current_top_score) {
current_top_score = average_scores[i];
current_top_index = i;
}
}
const char* current_top_label = kCategoryLabels[current_top_index];
最后一部分逻辑确定结果是否是有效检测。为了做到这一点,它确保其分数高于检测阈值(默认为 200),并且它没有在上次有效检测之后发生得太快,这可能是一个错误结果的指示:
// If we've recently had another label trigger, assume one that occurs too
// soon afterwards is a bad result.
int64_t time_since_last_top;
if ((previous_top_label_ == kCategoryLabels[0]) ||
(previous_top_label_time_ == std::numeric_limits<int32_t>::min())) {
time_since_last_top = std::numeric_limits<int32_t>::max();
} else {
time_since_last_top = current_time_ms - previous_top_label_time_;
}
if ((current_top_score > detection_threshold_) &&
((current_top_label != previous_top_label_) ||
(time_since_last_top > suppression_ms_))) {
previous_top_label_ = current_top_label;
previous_top_label_time_ = current_time_ms;
*is_new_command = true;
} else {
*is_new_command = false;
}
*found_command = current_top_label;
*score = current_top_score;
如果结果有效,is_new_command被设置为true。这是调用者可以用来确定一个单词是否真正被检测到。
测试(在recognize_commands_test.cc中)对存储在平均窗口中的各种不同输入和结果进行了测试。
让我们走一遍RecognizeCommandsTestBasic中的一个测试,演示了如何使用RecognizeCommands。首先,我们只是创建了该类的一个实例:
TF_LITE_MICRO_TEST(RecognizeCommandsTestBasic) {
tflite::MicroErrorReporter micro_error_reporter;
tflite::ErrorReporter* error_reporter = µ_error_reporter;
RecognizeCommands recognize_commands(error_reporter);
接下来,我们创建一个包含一些虚假推理结果的张量,这将由ProcessLatestResults()使用来决定是否听到了命令:
TfLiteTensor results = tflite::testing::CreateQuantizedTensor(
{255, 0, 0, 0}, tflite::testing::IntArrayFromInitializer({2, 1, 4}),
"input_tensor", 0.0f, 128.0f);
然后,我们设置一些变量,这些变量将被ProcessLatestResults()的输出设置:
const char* found_command;
uint8_t score;
bool is_new_command;
最后,我们调用ProcessLatestResults(),提供这些变量的指针以及包含结果的张量。我们断言该函数将返回kTfLiteOk,表示输入已成功处理:
TF_LITE_MICRO_EXPECT_EQ(
kTfLiteOk, recognize_commands.ProcessLatestResults(
&results, 0, &found_command, &score, &is_new_command));
文件中的其他测试执行了一些更详尽的检查,以确保函数的正确执行。您可以阅读它们以了解更多信息。
要运行所有测试,请使用以下命令:
make -f tensorflow/lite/micro/tools/make/Makefile \
test_recognize_commands_test
一旦我们确定是否检测到命令,就是与世界分享我们的结果的时候了(或者至少是我们的板载 LED)。命令响应器就是让这一切发生的原因。
命令响应器
我们谜题的最后一块,命令响应器,是产生输出的部分,让我们知道检测到了一个单词。
命令响应器被设计为针对每种类型的设备进行覆盖。我们将在本章后面探讨特定设备的实现。
现在,让我们看一下它非常简单的参考实现,它只是将检测结果记录为文本。您可以在文件command_responder.cc中找到它:
void RespondToCommand(tflite::ErrorReporter* error_reporter,
int32_t current_time, const char* found_command,
uint8_t score, bool is_new_command) {
if (is_new_command) {
error_reporter->Report("Heard %s (%d) @%dms", found_command, score,
current_time);
}
}
就是这样!该文件只实现了一个函数:RespondToCommand()。作为参数,它期望一个error_reporter,当前时间(current_time),上次检测到的命令(found_command),它收到的分数(score)以及命令是否是新听到的(is_new_command)。
重要的是要注意,在我们程序的主循环中,每次执行推理时都会调用此函数,即使没有检测到命令。这意味着我们应该检查is_new_command以确定是否需要执行任何操作。
在command_responder_test.cc中对此函数的测试同样简单。它只是调用该函数,因为它无法测试生成正确的输出:
TF_LITE_MICRO_TEST(TestCallability) {
tflite::MicroErrorReporter micro_error_reporter;
tflite::ErrorReporter* error_reporter = µ_error_reporter;
// This will have external side-effects (like printing to the debug console
// or lighting an LED) that are hard to observe, so the most we can do is
// make sure the call doesn't crash.
RespondToCommand(error_reporter, 0, "foo", 0, true);
}
要运行此测试,请在终端中输入以下内容:
make -f tensorflow/lite/micro/tools/make/Makefile \
test_command_responder_test
就是这样!我们已经走过了应用程序的所有组件。现在,让我们看看它们如何在程序中结合在一起。
监听唤醒词
您可以在main_functions.cc中找到以下代码,该代码定义了我们程序的核心setup()和loop()函数。让我们一起阅读一下!
因为您现在是一个经验丰富的 TensorFlow Lite 专家,所以这些代码对您来说应该很熟悉。让我们试着专注于新的部分。
首先,我们列出要使用的操作:
namespace tflite {
namespace ops {
namespace micro {
TfLiteRegistration* Register_DEPTHWISE_CONV_2D();
TfLiteRegistration* Register_FULLY_CONNECTED();
TfLiteRegistration* Register_SOFTMAX();
} // namespace micro
} // namespace ops
} // namespace tflite
接下来,我们设置全局变量:
namespace {
tflite::ErrorReporter* error_reporter = nullptr;
const tflite::Model* model = nullptr;
tflite::MicroInterpreter* interpreter = nullptr;
TfLiteTensor* model_input = nullptr;
FeatureProvider* feature_provider = nullptr;
RecognizeCommands* recognizer = nullptr;
int32_t previous_time = 0;
// Create an area of memory to use for input, output, and intermediate arrays.
// The size of this will depend on the model you're using, and may need to be
// determined by experimentation.
constexpr int kTensorArenaSize = 10 * 1024;
uint8_t tensor_arena[kTensorArenaSize];
} // namespace
请注意,除了通常的 TensorFlow 组件外,我们还声明了一个FeatureProvider和一个RecognizeCommands。我们还声明了一个名为g_previous_time的变量,用于跟踪我们接收到新音频样本的最近时间。
接下来,在setup()函数中,我们加载模型,设置解释器,添加操作并分配张量:
void setup() {
// Set up logging.
static tflite::MicroErrorReporter micro_error_reporter;
error_reporter = µ_error_reporter;
// Map the model into a usable data structure. This doesn't involve any
// copying or parsing, it's a very lightweight operation.
model = tflite::GetModel(g_tiny_conv_micro_features_model_data);
if (model->version() != TFLITE_SCHEMA_VERSION) {
error_reporter->Report(
"Model provided is schema version %d not equal "
"to supported version %d.",
model->version(), TFLITE_SCHEMA_VERSION);
return;
}
// Pull in only the operation implementations we need.
static tflite::MicroMutableOpResolver micro_mutable_op_resolver;
micro_mutable_op_resolver.AddBuiltin(
tflite::BuiltinOperator_DEPTHWISE_CONV_2D,
tflite::ops::micro::Register_DEPTHWISE_CONV_2D());
micro_mutable_op_resolver.AddBuiltin(
tflite::BuiltinOperator_FULLY_CONNECTED,
tflite::ops::micro::Register_FULLY_CONNECTED());
micro_mutable_op_resolver.AddBuiltin(tflite::BuiltinOperator_SOFTMAX,
tflite::ops::micro::Register_SOFTMAX());
// Build an interpreter to run the model with.
static tflite::MicroInterpreter static_interpreter(
model, micro_mutable_op_resolver, tensor_arena, kTensorArenaSize,
error_reporter);
interpreter = &static_interpreter;
// Allocate memory from the tensor_arena for the model's tensors.
TfLiteStatus allocate_status = interpreter->AllocateTensors();
if (allocate_status != kTfLiteOk) {
error_reporter->Report("AllocateTensors() failed");
return;
}
在分配张量之后,我们检查输入张量是否具有正确的形状和类型:
// Get information about the memory area to use for the model's input.
model_input = interpreter->input(0);
if ((model_input->dims->size != 4) || (model_input->dims->data[0] != 1) ||
(model_input->dims->data[1] != kFeatureSliceCount) ||
(model_input->dims->data[2] != kFeatureSliceSize) ||
(model_input->type != kTfLiteUInt8)) {
error_reporter->Report("Bad input tensor parameters in model");
return;
}
接下来是有趣的部分。首先,我们实例化一个FeatureProvider,将其指向我们的输入张量:
// Prepare to access the audio spectrograms from a microphone or other source
// that will provide the inputs to the neural network.
static FeatureProvider static_feature_provider(kFeatureElementCount,
model_input->data.uint8);
feature_provider = &static_feature_provider;
然后我们创建一个RecognizeCommands实例并初始化我们的previous_time变量:
static RecognizeCommands static_recognizer(error_reporter);
recognizer = &static_recognizer;
previous_time = 0;
}
接下来,是我们的loop()函数的时间了。就像前面的例子一样,这个函数将被无限次调用。在循环中,我们首先使用特征提供程序创建一个频谱图:
void loop() {
// Fetch the spectrogram for the current time.
const int32_t current_time = LatestAudioTimestamp();
int how_many_new_slices = 0;
TfLiteStatus feature_status = feature_provider->PopulateFeatureData(
error_reporter, previous_time, current_time, &how_many_new_slices);
if (feature_status != kTfLiteOk) {
error_reporter->Report("Feature generation failed");
return;
}
previous_time = current_time;
// If no new audio samples have been received since last time, don't bother
// running the network model.
if (how_many_new_slices == 0) {
return;
}
如果自上次迭代以来没有新数据,我们就不会运行推理。
当我们有了输入后,我们只需调用解释器:
// Run the model on the spectrogram input and make sure it succeeds.
TfLiteStatus invoke_status = interpreter->Invoke();
if (invoke_status != kTfLiteOk) {
error_reporter->Report("Invoke failed");
return;
}
现在,模型的输出张量已经填充了每个类别的概率。为了解释它们,我们使用我们的RecognizeCommands实例。我们获取输出张量的指针,然后设置一些变量来接收ProcessLatestResults()的输出:
// Obtain a pointer to the output tensor
TfLiteTensor* output = interpreter->output(0);
// Determine whether a command was recognized based on the output of inference
const char* found_command = nullptr;
uint8_t score = 0;
bool is_new_command = false;
TfLiteStatus process_status = recognizer->ProcessLatestResults(
output, current_time, &found_command, &score, &is_new_command);
if (process_status != kTfLiteOk) {
error_reporter->Report("RecognizeCommands::ProcessLatestResults() failed");
return;
}
最后,我们调用命令响应器的RespondToCommand()方法,以便它可以通知用户是否检测到了一个单词:
// Do something based on the recognized command. The default implementation
// just prints to the error console, but you should replace this with your
// own function for a real application.
RespondToCommand(error_reporter, current_time, found_command, score,
is_new_command);
}
就是这样!调用RespondToCommand()是我们循环中的最后一件事。从特征生成开始,一直到之后的所有内容都将无限重复,检查已知单词的音频并在确认时产生一些输出。
我们的setup()和loop()函数是由我们的main()函数调用的,该函数在main.cc中定义,当应用程序启动时开始循环:
int main(int argc, char* argv[]) {
setup();
while (true) {
loop();
}
}
运行我们的应用程序
示例包含一个与 macOS 兼容的音频提供程序。如果您有 Mac,可以在开发机器上运行示例。首先,使用以下命令构建它:
make -f tensorflow/lite/micro/tools/make/Makefile micro_speech
构建完成后,您可以使用以下命令运行示例:
tensorflow/lite/micro/tools/make/gen/osx_x86_64/bin/micro_speech
您可能会看到一个弹出窗口询问麦克风访问权限。如果是这样,请授予权限,程序将开始运行。
尝试说“是”和“否”。您应该看到类似以下的输出:
Heard yes (201) @4056ms
Heard no (205) @6448ms
Heard unknown (201) @13696ms
Heard yes (205) @15000ms
Heard yes (205) @16856ms
Heard unknown (204) @18704ms
Heard no (206) @21000ms
每个检测到的单词后面都是它的得分。默认情况下,命令识别器组件仅在其得分超过 200 时才将匹配视为有效,因此您看到的所有得分都至少为 200。
得分后面的数字是自程序启动以来的毫秒数。
如果您没有看到任何输出,请确保 Mac 的内置麦克风在 Mac 的声音菜单中被选中,并且其输入音量足够高。
我们已经确定程序在 Mac 上运行正常。现在,让我们在一些嵌入式硬件上运行它。
部署到微控制器
在本节中,我们将代码部署到三种不同的设备上:
对于每个设备,我们将详细介绍构建和部署过程。
因为每个设备都有自己的捕获音频机制,所以对于每个设备都有一个单独的audio_provider.cc实现。输出也是如此,因此每个设备也有一个command_responder.cc的变体。
audio_provider.cc的实现是复杂的,与设备相关,并且与机器学习没有直接关系。因此,在本章中我们不会详细介绍它们。然而,在附录 B 中有 Arduino 变体的详细说明。如果您需要在自己的项目中捕获音频,欢迎在您自己的代码中重用这些实现。
除了部署说明,我们还将为每个设备详细介绍command_responder.cc的实现。首先,是 Arduino 的时间。
Arduino
截至目前,唯一具有内置麦克风的 Arduino 板是Arduino Nano 33 BLE Sense,因此我们将在本节中使用它。如果您使用不同的 Arduino 板并连接自己的麦克风,您将需要实现自己的audio_provider.cc。
Arduino Nano 33 BLE Sense 还具有内置 LED,这是我们用来指示已识别单词的 LED。
图 7-7 显示了板上的 LED 的图片。

图 7-7。Arduino Nano 33 BLE Sense 板上的 LED 高亮显示
现在让我们看看如何使用这个 LED 来指示已经检测到一个单词。
在 Arduino 上响应命令
每个 Arduino 板都有一个内置 LED,并且有一个方便的常量叫做LED_BUILTIN,我们可以使用它来获取其引脚号,这在各个板上是不同的。为了保持代码的可移植性,我们将限制自己只使用这个单个 LED 进行输出。
我们要做的是这样的。为了显示推理正在运行,我们将通过每次推理切换 LED 的开关状态来闪烁 LED。但是,当我们听到“yes”这个词时,我们会将 LED 打开几秒钟。
那么“no”这个词呢?嗯,因为这只是一个演示,我们不会太担心它。但是,我们会将所有检测到的命令记录到串行端口,这样我们就可以连接到设备并查看每个匹配项。
Arduino 的替代命令响应器位于arduino/command_responder.cc。让我们浏览一下它的源代码。首先,我们包含命令响应器头文件和 Arduino 平台的库头文件:
#include "tensorflow/lite/micro/examples/micro_speech/command_responder.h"
#include "Arduino.h"
接下来,我们开始实现我们的函数:
// Toggles the LED every inference, and keeps it on for 3 seconds if a "yes"
// was heard
void RespondToCommand(tflite::ErrorReporter* error_reporter,
int32_t current_time, const char* found_command,
uint8_t score, bool is_new_command) {
我们的下一步是将内置 LED 的引脚设置为输出模式,以便我们可以打开和关闭它。我们在一个if语句中执行此操作,该语句仅运行一次,这要归功于名为is_initialized的static bool。请记住,static变量在函数调用之间保留其状态:
static bool is_initialized = false;
if (!is_initialized) {
pinMode(LED_BUILTIN, OUTPUT);
is_initialized = true;
}
接下来,我们设置另外两个static变量来跟踪上次检测到“yes”的时间以及已执行的推理次数:
static int32_t last_yes_time = 0;
static int count = 0;
现在是有趣的部分。如果is_new_command参数为true,我们知道我们听到了什么,因此我们使用ErrorReporter实例记录它。但是如果我们听到的是“yes”——我们通过检查found_command字符数组的第一个字符来确定——我们存储当前时间并打开 LED:
if (is_new_command) {
error_reporter->Report("Heard %s (%d) @%dms", found_command, score,
current_time);
// If we heard a "yes", switch on an LED and store the time.
if (found_command[0] == 'y') {
last_yes_time = current_time;
digitalWrite(LED_BUILTIN, HIGH);
}
}
接下来,我们实现了在几秒钟后关闭 LED 的行为——确切地说是三秒:
// If last_yes_time is non-zero but was >3 seconds ago, zero it
// and switch off the LED.
if (last_yes_time != 0) {
if (last_yes_time < (current_time - 3000)) {
last_yes_time = 0;
digitalWrite(LED_BUILTIN, LOW);
}
// If it is non-zero but <3 seconds ago, do nothing.
return;
}
当 LED 关闭时,我们还将last_yet_time设置为0,这样我们在下次听到“yes”之前不会进入这个if语句。return语句很重要:如果我们最近听到“yes”,它会阻止任何进一步的输出代码运行,因此 LED 会保持亮起状态。
到目前为止,我们的实现将在听到“yes”时打开 LED 约三秒钟。接下来的部分将通过每次推理切换 LED 的开关状态,除了在“yes”模式下,我们通过前面提到的return语句阻止到达这一点。
以下是最终的代码块:
// Otherwise, toggle the LED every time an inference is performed.
++count;
if (count & 1) {
digitalWrite(LED_BUILTIN, HIGH);
} else {
digitalWrite(LED_BUILTIN, LOW);
}
通过为每次推理增加count变量,我们跟踪我们执行的总推理次数。在if条件中,我们使用&运算符对count变量和数字1进行二进制 AND 运算。
通过对count和1进行 AND 运算,我们过滤掉count的所有位,除了最小的位。如果最小位是0,表示count是一个奇数,结果将是0。在 C++ 的if 语句中,这将评估为false。
否则,结果将是1,表示一个偶数。因为1被评估为true,我们的 LED 将在偶数值时打开,并在奇数值时关闭。这就是它的切换功能。
就是这样!我们现在已经为 Arduino 实现了命令响应器。让我们运行它,以便看到它的运行情况。
运行示例
要部署此示例,我们需要:
-
一个 Arduino Nano 33 BLE Sense 开发板
-
一个 micro-USB 电缆
-
Arduino IDE
提示
由于这本书编写时可能已经发生了变化,因此请查看README.md获取最新的说明。
本书中的项目作为 TensorFlow Lite Arduino 库中的示例代码可用。如果您尚未安装该库,请打开 Arduino IDE 并从工具菜单中选择管理库。在出现的窗口中,搜索并安装名为 Arduino_TensorFlowLite 的库。您应该能够使用最新版本,但如果遇到问题,本书测试过的版本是 1.14-ALPHA。
注意
您还可以从.zip文件安装库,您可以从 TensorFlow Lite 团队下载,或者使用 TensorFlow Lite for Microcontrollers Makefile 自动生成。如果您更喜欢后者,请参阅附录 A。
安装完库后,micro_speech示例将显示在文件菜单下的示例→Arduino_TensorFlowLite 中,如图 7-8 所示。
单击“micro_speech”加载示例。它将显示为一个新窗口,每个源文件都有一个选项卡。第一个选项卡中的文件micro_speech相当于我们之前讨论过的main_functions.cc。

图 7-8。示例菜单
注意
“运行示例”已经解释了 Arduino 示例的结构,所以我们不会在这里再次涉及。
要运行示例,请通过 USB 连接您的 Arduino 设备。确保从“工具”菜单中的板下拉列表中选择了正确的设备类型,如图 7-9 所示。

图 7-9。板下拉列表
如果您的设备名称未出现在列表中,则需要安装其支持包。要执行此操作,请单击“Boards Manager”。在弹出的窗口中,搜索您的设备,然后安装相应支持包的最新版本。接下来,请确保设备的端口已在“工具”菜单中的端口下拉列表中选择,如图 7-10 所示。

图 7-10。端口下拉列表
最后,在 Arduino 窗口中,单击上传按钮(在图 7-11 中用白色突出显示)来编译和上传代码到您的 Arduino 设备。

图 7-11。上传按钮,一个向右的箭头
上传成功后,您应该看到 Arduino 板上的 LED 开始闪烁。
要测试程序,请尝试说“yes”。当它检测到“yes”时,LED 将保持稳定点亮约三秒钟。
提示
如果程序无法识别您的“yes”,请尝试连续说几次。
您还可以通过 Arduino 串行监视器查看推断结果。要执行此操作,请从“工具”菜单中打开串行监视器。现在,请尝试说“yes”、“no”和其他单词。您应该看到类似图 7-12 的内容。

图 7-12。串行监视器显示一些匹配项
注意
我们使用的模型很小且不完美,您可能会注意到它在检测“yes”方面比“no”更好。这是优化微小模型大小可能导致准确性问题的一个例子。我们在第八章中涵盖了这个主题。
进行您自己的更改
现在您已部署了应用程序,请尝试玩弄代码!您可以在 Arduino IDE 中编辑源文件。保存时,您将被提示在新位置重新保存示例。在进行更改后,您可以单击 Arduino IDE 中的上传按钮来构建和部署。
以下是您可以尝试的一些想法:
-
将示例切换为在说“no”时点亮 LED,而不是“yes”时,
-
使应用程序响应特定的“yes”和“no”命令序列,如秘密代码短语。
-
使用“yes”和“no”命令来控制其他组件,如额外的 LED 或伺服。
SparkFun Edge
SparkFun Edge 既有麦克风,又有一排四个彩色 LED 灯—红色、蓝色、绿色和黄色—这将使显示结果变得容易。图 7-13 显示了高亮显示了 SparkFun Edge 的 LED 灯。

图 7-13。SparkFun Edge 的四个 LED
响应 SparkFun Edge 上的命令
为了清楚地表明我们的程序正在运行,让我们在每次推理时切换蓝色 LED 的开关。当听到“是”时,我们将打开黄色 LED,当听到“否”时,我们将打开红色 LED,当听到未知命令时,我们将打开绿色 LED。
SparkFun Edge 的命令响应器实现在sparkfun_edge/command_responder.cc中。该文件以一些包含开始:
#include "tensorflow/lite/micro/examples/micro_speech/command_responder.h"
#include "am_bsp.h"
command_responder.h包含了这个文件对应的头文件。am_bsp.h是 Ambiq Apollo3 SDK,在上一章中已经看到。
在函数定义内部,我们首先将连接到 LED 的引脚设置为输出:
// This implementation will light up the LEDs on the board in response to
// different commands.
void RespondToCommand(tflite::ErrorReporter* error_reporter,
int32_t current_time, const char* found_command,
uint8_t score, bool is_new_command) {
static bool is_initialized = false;
if (!is_initialized) {
am_hal_gpio_pinconfig(AM_BSP_GPIO_LED_RED, g_AM_HAL_GPIO_OUTPUT_12);
am_hal_gpio_pinconfig(AM_BSP_GPIO_LED_BLUE, g_AM_HAL_GPIO_OUTPUT_12);
am_hal_gpio_pinconfig(AM_BSP_GPIO_LED_GREEN, g_AM_HAL_GPIO_OUTPUT_12);
am_hal_gpio_pinconfig(AM_BSP_GPIO_LED_YELLOW, g_AM_HAL_GPIO_OUTPUT_12);
is_initialized = true;
}
我们从 Apollo3 SDK 中调用am_hal_gpio_pinconfig()函数,将所有四个 LED 引脚设置为输出模式,表示为常量g_AM_HAL_GPIO_OUTPUT_12。我们使用is_initialized static变量确保我们只执行一次!
接下来是将切换蓝色 LED 打开和关闭的代码。我们使用一个count变量来执行此操作,方式与 Arduino 实现相同:
static int count = 0;
// Toggle the blue LED every time an inference is performed.
++count;
if (count & 1) {
am_hal_gpio_output_set(AM_BSP_GPIO_LED_BLUE);
} else {
am_hal_gpio_output_clear(AM_BSP_GPIO_LED_BLUE);
}
此代码使用am_hal_gpio_output_set()和am_hal_gpio_output_clear()函数来切换蓝色 LED 的引脚开关。
通过在每次推理时递增count变量,我们可以跟踪我们执行的推理总数。在if条件内部,我们使用&运算符对count变量和数字1进行二进制 AND 操作。
通过对count和1进行 AND 运算,我们过滤掉count的所有位,除了最小的位。如果最小位是0,表示count是奇数,结果将是0。在 C++的if 语句中,这将评估为false。
否则,结果将是1,表示偶数。因为1评估为true,所以我们的 LED 将在偶数值时打开,并在奇数值时关闭。这就是它的切换原理。
接下来,根据刚刚听到的单词点亮适当的 LED。默认情况下,我们清除所有 LED,因此如果最近没有听到单词,则所有 LED 将熄灭:
am_hal_gpio_output_clear(AM_BSP_GPIO_LED_RED);
am_hal_gpio_output_clear(AM_BSP_GPIO_LED_YELLOW);
am_hal_gpio_output_clear(AM_BSP_GPIO_LED_GREEN);
然后,我们使用一些简单的if语句根据听到的命令点亮适当的 LED:
if (is_new_command) {
error_reporter->Report("Heard %s (%d) @%dms", found_command, score,
current_time);
if (found_command[0] == 'y') {
am_hal_gpio_output_set(AM_BSP_GPIO_LED_YELLOW);
}
if (found_command[0] == 'n') {
am_hal_gpio_output_set(AM_BSP_GPIO_LED_RED);
}
if (found_command[0] == 'u') {
am_hal_gpio_output_set(AM_BSP_GPIO_LED_GREEN);
}
}
正如我们之前看到的,只有当RespondToCommand()被调用时传入了一个真正的新命令,is_new_command才为true,因此如果没有听到新命令,LED 将保持关闭。否则,我们使用am_hal_gpio_output_set()函数打开适当的 LED。
运行示例
我们已经详细介绍了如何在 SparkFun Edge 上点亮 LED 的示例代码。接下来,让我们启动并运行示例。
提示
建议查看README.md以获取最新指令,因为构建过程可能会有变化。
要构建和部署我们的代码,我们需要以下内容:
-
一个 SparkFun Edge 板
-
一个 USB 编程器(我们推荐 SparkFun Serial Basic Breakout,可在micro-B USB和USB-C变体中获得)
-
匹配的 USB 电缆
-
Python 3 和一些依赖项
接下来
第六章展示了如何确认您是否安装了正确版本的 Python。如果您已经这样做了,太好了。如果没有,请翻回到“运行示例”查看一下。
在您的终端中,克隆 TensorFlow 存储库,然后切换到其目录:
git clone https://github.com/tensorflow/tensorflow.git
cd tensorflow
接下来,我们将构建二进制文件并运行一些命令,使其准备好下载到设备中。为了避免一些打字,您可以从README.md中复制并粘贴这些命令。
构建二进制文件
以下命令下载所有所需的依赖项,然后为 SparkFun Edge 编译一个二进制文件:
make -f tensorflow/lite/micro/tools/make/Makefile \
TARGET=sparkfun_edge TAGS=cmsis-nn micro_speech_bin
二进制文件被创建为.bin文件,在以下位置:
tensorflow/lite/micro/tools/make/gen/ \
sparkfun_edge_cortex-m4/bin/micro_speech.bin
要检查文件是否存在,可以使用以下命令:
test -f tensorflow/lite/micro/tools/make/gen/ \
sparkfun_edge_cortex-m4/bin/micro_speech.bin \
&& echo "Binary was successfully created" || echo "Binary is missing"
如果运行该命令,您应该看到二进制文件已成功创建打印到控制台。如果看到二进制文件丢失,则构建过程中出现问题。如果是这样,make命令的输出中可能有一些指示出了问题的线索。
签署二进制文件
必须使用加密密钥对二进制文件进行签名,以部署到设备。现在让我们运行一些命令来对二进制文件进行签名,以便将其刷写到 SparkFun Edge。这里使用的脚本来自 Ambiq SDK,在运行 Makefile 时下载。
输入以下命令设置一些虚拟加密密钥,以供开发使用:
cp tensorflow/lite/micro/tools/make/downloads/AmbiqSuite-Rel2.0.0/ \
tools/apollo3_scripts/keys_info0.py \
tensorflow/lite/micro/tools/make/downloads/AmbiqSuite-Rel2.0.0/ \
tools/apollo3_scripts/keys_info.py
接下来,运行以下命令创建一个已签名的二进制文件。如有必要,用python3替换python:
python3 tensorflow/lite/micro/tools/make/downloads/ \
AmbiqSuite-Rel2.0.0/tools/apollo3_scripts/create_cust_image_blob.py \
--bin tensorflow/lite/micro/tools/make/gen/ \
sparkfun_edge_cortex-m4/bin/micro_speech.bin \
--load-address 0xC000 \
--magic-num 0xCB -o main_nonsecure_ota \
--version 0x0
这将创建文件main_nonsecure_ota.bin。现在运行此命令以创建文件的最终版本,该文件可用于使用下一步中将使用的脚本刷写设备:
python3 tensorflow/lite/micro/tools/make/downloads/ \
AmbiqSuite-Rel2.0.0/tools/apollo3_scripts/create_cust_wireupdate_blob.py \
--load-address 0x20000 \
--bin main_nonsecure_ota.bin \
-i 6 -o main_nonsecure_wire \
--options 0x1
现在应该在运行命令的目录中有一个名为main_nonsecure_wire.bin的文件。这是您将要刷写到设备的文件。
刷写二进制文件
SparkFun Edge 将当前运行的程序存储在其 1 兆字节的闪存中。如果要让板运行新程序,需要将其发送到板上,板将将其存储在闪存中,覆盖以前保存的任何程序。
将编程器连接到板上
要将新程序下载到板上,您将使用 SparkFun USB-C 串行基础串行编程器。此设备允许计算机通过 USB 与微控制器通信。
要将此设备连接到板上,请执行以下步骤:
-
在 SparkFun Edge 的侧面,找到六针排针。
-
将 SparkFun USB-C 串行基础插入这些引脚,确保每个设备上标有 BLK 和 GRN 的引脚正确对齐,如图 7-14 所示。

图 7-14. 连接 SparkFun Edge 和 USB-C 串行基础(由 SparkFun 提供)
将编程器连接到计算机
通过 USB 将板连接到计算机。要对板进行编程,您需要找出计算机分配给设备的名称。最佳方法是在连接之前和之后列出所有计算机设备,然后查看哪个设备是新的。
警告
一些人报告了他们操作系统的默认驱动程序与编程器存在问题,因此我们强烈建议在继续之前安装驱动程序。
在通过 USB 连接设备之前,运行以下命令:
# macOS:
ls /dev/cu*
# Linux:
ls /dev/tty*
这应该输出一个附加设备列表,看起来类似于以下内容:
/dev/cu.Bluetooth-Incoming-Port
/dev/cu.MALS
/dev/cu.SOC
现在,将编程器连接到计算机的 USB 端口,并再次运行命令:
# macOS:
ls /dev/cu*
# Linux:
ls /dev/tty*
您应该看到输出中有一个额外的项目,如下例所示。您的新项目可能有不同的名称。这个新项目是设备的名称:
/dev/cu.Bluetooth-Incoming-Port
/dev/cu.MALS
/dev/cu.SOC
/dev/cu.wchusbserial-1450
此名称将用于引用设备。但是,它可能会根据编程器连接到的 USB 端口而更改,因此如果您从计算机断开板然后重新连接,可能需要再次查找其名称。
提示
一些用户报告说列表中出现了两个设备。如果看到两个设备,则应使用以“wch”开头的正确设备;例如,“/dev/wchusbserial-14410”。
确定设备名称后,将其放入 shell 变量以供以后使用:
export DEVICENAME=<*your device name here*>
这是在后续过程中运行需要设备名称的命令时可以使用的变量。
运行脚本刷写板
要刷写板,必须将其置于特殊的“引导加载程序”状态,以准备接收新的二进制文件。然后运行一个脚本将二进制文件发送到板上。
首先创建一个环境变量来指定波特率,即数据发送到设备的速度:
export BAUD_RATE=921600
现在将以下命令粘贴到你的终端中,但不要立即按回车!命令中的${DEVICENAME}和${BAUD_RATE}将被替换为你在前面部分设置的值。如果需要,请记得将python3替换为python:
python3 tensorflow/lite/micro/tools/make/downloads/ \
AmbiqSuite-Rel2.0.0/tools/apollo3_scripts/uart_wired_update.py \
-b ${BAUD_RATE} ${DEVICENAME} \
-r 1 -f main_nonsecure_wire.bin \
-i 6
接下来,你将重置板子到引导加载程序状态并刷写板子。在板子上,找到标有RST和14的按钮,如图 7-15 所示。执行以下步骤:
-
确保你的板子连接到编程器,并且整个东西通过 USB 连接到你的计算机。
-
在板子上,按住标有
14的按钮。继续按住。 -
在继续按住标有
14的按钮的同时,按下标有RST的按钮来重置板子。 -
按下计算机上的回车键来运行脚本。继续按住按钮
14。
现在你应该在屏幕上看到类似以下内容的东西:
Connecting with Corvette over serial port /dev/cu.usbserial-1440...
Sending Hello.
Received response for Hello
Received Status
length = 0x58
version = 0x3
Max Storage = 0x4ffa0
Status = 0x2
State = 0x7
AMInfo =
0x1
0xff2da3ff
0x55fff
0x1
0x49f40003
0xffffffff
[...lots more 0xffffffff...]
Sending OTA Descriptor = 0xfe000
Sending Update Command.
number of updates needed = 1
Sending block of size 0x158b0 from 0x0 to 0x158b0
Sending Data Packet of length 8180
Sending Data Packet of length 8180
[...lots more Sending Data Packet of length 8180...]

图 7-15。SparkFun Edge 的按钮
继续按住按钮14直到看到发送数据包长度为 8180。在看到这个之后你可以释放按钮(但如果继续按住也没关系)。程序将继续在终端上打印行。最终,你会看到类似以下内容的东西:
[...lots more Sending Data Packet of length 8180...]
Sending Data Packet of length 8180
Sending Data Packet of length 6440
Sending Reset Command.
Done.
这表示刷写成功。
提示
如果程序输出以错误结束,请检查是否打印了发送重置命令。。如果是这样,尽管有错误,刷写可能是成功的。否则,刷写可能失败了。尝试再次运行这些步骤(你可以跳过设置环境变量)。
测试程序
为了确保程序正在运行,按下RST按钮。现在你应该看到蓝色 LED 在闪烁。
要测试程序,尝试说“yes”。当它检测到“yes”时,橙色 LED 会闪烁。该模型还经过训练以识别“no”,以及当说出未知单词时。红色 LED 应该会为“no”闪烁,绿色 LED 为未知。
如果程序无法识别你的“yes”,尝试连续几次说:“yes, yes, yes”。
我们正在使用的模型很小且不完美,你可能会注意到它在检测“yes”方面比“no”更好,而“no”经常被识别为“unknown”。这是一个优化微小模型大小可能导致准确性问题的例子。我们在第八章中涵盖了这个主题。
查看调试数据
程序还将成功识别记录到串行端口。要查看这些数据,我们可以使用波特率为 115200 监视板子的串行端口输出。在 macOS 和 Linux 上,以下命令应该有效:
screen ${DEVICENAME} 115200
你应该最初看到类似以下内容的输出:
Apollo3 Burst Mode is Available
Apollo3 operating in Burst Mode (96MHz)
尝试通过说“yes”或“no”来发出一些命令。你应该看到板子为每个命令打印调试信息:
Heard yes (202) @65536ms
要停止使用screen查看调试输出,按下 Ctrl-A,然后立即按 K 键,然后按 Y 键。
制作你自己的更改
现在你已经部署了基本应用程序,尝试玩耍并做一些更改。你可以在tensorflow/lite/micro/examples/micro_speech文件夹中找到应用程序的代码。只需编辑并保存,然后重复前面的说明来将修改后的代码部署到设备上。
以下是一些你可以尝试的事情:
-
RespondToCommand()的score参数显示了预测分数。使用 LED 作为一个表明匹配强度的仪表。 -
使应用程序响应特定的“yes”和“no”命令序列,就像一个秘密代码短语一样。
-
使用“yes”和“no”命令来控制其他组件,比如额外的 LED 或伺服。
ST 微电子 STM32F746G Discovery Kit
因为 STM32F746G 配备了一个漂亮的 LCD 显示屏,我们可以用它来展示检测到的唤醒词,如图 7-16 所示。

图 7-16. STM32F746G 显示“否”
响应 STM32F746G 上的命令
STM32F746G 的 LCD 驱动程序提供了一些方法,我们可以使用这些方法将文本写入显示器。在这个练习中,我们将使用这些方法显示以下消息之一,具体取决于听到的命令:
-
“听到了是!”
-
“听到了否 😦”
-
“听到了未知”
-
“听到了沉默”
我们还将根据听到的命令设置不同的背景颜色。
首先,我们包含一些头文件:
#include "tensorflow/lite/micro/examples/micro_speech/command_responder.h"
#include "LCD_DISCO_F746NG.h"
第一个是command_responder.h,只是声明了此文件的接口。第二个是LCD_DISCO_F74NG.h,为我们提供了控制设备 LCD 显示的接口。您可以在Mbed 网站上阅读更多信息。
接下来,我们实例化一个LCD_DISCO_F746NG对象,其中包含我们用来控制 LCD 的方法:
LCD_DISCO_F746NG lcd;
在接下来的几行中,声明了RespondToCommand()函数,并检查是否已使用新命令调用它:
// When a command is detected, write it to the display and log it to the
// serial port.
void RespondToCommand(tflite::ErrorReporter *error_reporter,
int32_t current_time, const char *found_command,
uint8_t score, bool is_new_command) {
if (is_new_command) {
error_reporter->Report("Heard %s (%d) @%dms", found_command, score,
current_time);
当我们知道这是一个新命令时,我们使用error_reporter将其记录到串行端口。
接下来,我们使用一个大的if语句来确定每个命令被找到时会发生什么。首先是“是”:
if (*found_command == 'y') {
lcd.Clear(0xFF0F9D58);
lcd.DisplayStringAt(0, LINE(5), (uint8_t *)"Heard yes!", CENTER_MODE);
我们使用lcd.Clear()来清除屏幕上的任何先前内容并设置新的背景颜色,就像刷了一层新油漆。颜色0xFF0F9D58是一种漂亮的深绿色。
在我们的绿色背景上,我们使用lcd.DisplayStringAt()来绘制一些文本。第一个参数指定x坐标,第二个指定y。为了将文本大致定位在显示器中间,我们使用一个辅助函数LINE()来确定对应于屏幕上第五行文本的y坐标。
第三个参数是我们将显示的文本字符串,第四个参数确定文本的对齐方式;在这里,我们使用常量CENTER_MODE来指定文本是居中对齐的。
我们继续if语句以涵盖剩下的三种可能性,“否”,“未知”和“沉默”(由else块捕获):
} else if (*found_command == 'n') {
lcd.Clear(0xFFDB4437);
lcd.DisplayStringAt(0, LINE(5), (uint8_t *)"Heard no :(", CENTER_MODE);
} else if (*found_command == 'u') {
lcd.Clear(0xFFF4B400);
lcd.DisplayStringAt(0, LINE(5), (uint8_t *)"Heard unknown", CENTER_MODE);
} else {
lcd.Clear(0xFF4285F4);
lcd.DisplayStringAt(0, LINE(5), (uint8_t *)"Heard silence", CENTER_MODE);
}
就是这样!因为 LCD 库为我们提供了如此简单的高级控制显示的方法,所以不需要太多代码来输出我们的结果。让我们部署示例以查看所有这些操作。
运行示例
现在我们可以使用 Mbed 工具链将我们的应用程序部署到设备上。
提示
建议检查README.md以获取最新说明,因为构建过程可能会在编写本书时发生变化。
在开始之前,我们需要以下内容:
-
一个 STM32F746G Discovery kit 开发板
-
一个迷你 USB 电缆
-
使用 Arm Mbed CLI(请参考Mbed 设置指南)
-
Python 3 和
pip
与 Arduino IDE 类似,Mbed 要求源文件以特定方式结构化。TensorFlow Lite for Microcontrollers Makefile 知道如何为我们做这个,并可以生成适合 Mbed 构建的目录。
为此,请运行以下命令:
make -f tensorflow/lite/micro/tools/make/Makefile \
TARGET=mbed TAGS="cmsis-nn disco_f746ng" generate_micro_speech_mbed_project
这将创建一个新目录:
tensorflow/lite/micro/tools/make/gen/mbed_cortex-m4/prj/ \
micro_speech/mbed
该目录包含了所有示例的依赖项,以 Mbed 能够构建它的正确方式结构化。
首先,切换到目录,以便您可以在其中运行一些命令:
cd tensorflow/lite/micro/tools/make/gen/mbed_cortex-m4/prj/micro_speech/mbed
接下来,您将使用 Mbed 下载依赖项并构建项目。
首先,使用以下命令告诉 Mbed 当前目录是 Mbed 项目的根目录:
mbed config root .
接下来,指示 Mbed 下载依赖项并准备构建:
mbed deploy
默认情况下,Mbed 使用 C++98 构建项目。然而,TensorFlow Lite 需要 C++11。运行以下 Python 片段修改 Mbed 配置文件,以便使用 C++11。您可以直接在命令行中键入或粘贴:
python -c 'import fileinput, glob;
for filename in glob.glob("mbed-os/tools/profiles/*.json"):
for line in fileinput.input(filename, inplace=True):
print(line.replace("\"-std=gnu++98\"","\"-std=c++11\", \"-fpermissive\""))'
最后,运行以下命令进行编译:
mbed compile -m DISCO_F746NG -t GCC_ARM
这应该会在以下路径生成一个二进制文件:
./BUILD/DISCO_F746NG/GCC_ARM/mbed.bin
STM32F746G 开发板的一个好处是部署非常容易。要部署,只需将 STM 板插入并将文件复制到其中。在 macOS 上,您可以使用以下命令来执行此操作:
cp ./BUILD/DISCO_F746NG/GCC_ARM/mbed.bin /Volumes/DIS_F746NG/
或者,只需在文件浏览器中找到DIS_F746NG卷,并将文件拖放过去。
复制文件会启动闪存过程。
测试程序
完成后,尝试说“yes”。您应该看到适当的文本出现在显示屏上,背景颜色也会改变。
如果程序无法识别您的“yes”,请尝试连续几次说出来,比如“yes, yes, yes”。
我们正在使用的模型很小且不完美,您可能会注意到它更擅长检测“yes”而不是“no”,后者通常被识别为“unknown”。这是一个示例,说明为微小模型大小进行优化可能会导致准确性问题。我们在第八章中涵盖了这个主题。
查看调试数据
该程序还会将成功识别记录到串行端口。要查看输出,请使用波特率 9600 建立与板的串行连接。
在 macOS 和 Linux 上,当您发出以下命令时,设备应该会列出:
ls /dev/tty*
它看起来会像下面这样:
/dev/tty.usbmodem1454203
在识别设备后,使用以下命令连接到设备,将<*/dev/tty.devicename*>替换为设备在/dev中显示的名称:
screen /dev/<*tty.devicename 9600*>
尝试通过说“yes”或“no”来发出一些命令。您应该看到板子为每个命令打印调试信息:
Heard yes (202) @65536ms
要停止使用screen查看调试输出,请按 Ctrl-A,紧接着按 K 键,然后按 Y 键。
注意
如果您不确定如何在您的平台上建立串行连接,您可以尝试CoolTerm,它适用于 Windows、macOS 和 Linux。该板应该会出现在 CoolTerm 的端口下拉列表中。确保将波特率设置为 9600。
进行自己的更改
现在您已经部署了应用程序,可以尝试玩耍并进行一些更改。您可以在tensorflow/lite/micro/tools/make/gen/mbed_cortex-m4/prj/micro_speech/mbed文件夹中找到应用程序的代码。只需编辑、保存,然后重复前面的说明,将修改后的代码部署到设备上。
以下是您可以尝试的一些事项:
-
RespondToCommand()函数的score参数显示了预测得分。在 LCD 显示屏上创建一个得分的可视指示器。 -
使应用程序响应特定的“yes”和“no”命令序列,如秘密代码短语。
-
使用“yes”和“no”命令来控制其他组件,如额外的 LED 或伺服。
总结
我们已经讨论过的应用程序代码主要涉及从硬件捕获数据,然后提取适合推理的特征。实际上将数据提供给模型并运行推理的部分相对较小,并且与第六章中涵盖的示例非常相似。
这在机器学习项目中是相当典型的。模型已经训练好了,因此我们的工作只是不断为其提供适当类型的数据。作为一个使用 TensorFlow Lite 的嵌入式开发人员,您将花费大部分编程时间捕获传感器数据,将其处理为特征,并响应模型的输出。推理部分本身快速且简单。
但嵌入式应用程序只是整个包的一部分,真正有趣的部分是模型。在第八章中,您将学习如何训练自己的语音模型以侦听不同的单词。您还将了解更多关于它是如何工作的。
第八章:唤醒词检测:训练模型
在第七章中,我们围绕一个训练有能力识别“是”和“否”的模型构建了一个应用程序。在本章中,我们将训练一个新模型,可以识别不同的单词。
我们的应用代码相当通用。它只是捕获和处理音频,将其输入到 TensorFlow Lite 模型中,并根据输出执行某些操作。它大多数情况下不关心模型正在寻找哪些单词。这意味着如果我们训练一个新模型,我们可以直接将其放入我们的应用程序中,它应该立即运行。
在训练新模型时,我们需要考虑以下事项:
输入
新模型必须在与我们的应用代码相同形状和格式的输入数据上进行训练,具有相同的预处理。
输出
新模型的输出必须采用相同的格式:每个类别一个概率张量。
训练数据
无论我们选择哪些新单词,我们都需要很多人说这些单词的录音,这样我们才能训练我们的新模型。
优化
模型必须经过优化,以在内存有限的微控制器上高效运行。
幸运的是,我们现有的模型是使用由 TensorFlow 团队发布的公开可用脚本进行训练的,我们可以使用这个脚本来训练一个新模型。我们还可以访问一个免费的口语音频数据集,可以用作训练数据。
在下一节中,我们将详细介绍使用此脚本训练模型的过程。然后,在“在我们的项目中使用模型”中,我们将把新模型整合到我们现有的应用程序代码中。之后,在“模型的工作原理”中,您将了解模型的实际工作原理。最后,在“使用您自己的数据进行训练”中,您将看到如何使用您自己的数据集训练模型。
训练我们的新模型
我们正在使用的模型是使用 TensorFlow Simple Audio Recognition脚本进行训练的,这是一个示例脚本,旨在演示如何使用 TensorFlow 构建和训练用于音频识别的模型。
该脚本使训练音频识别模型变得非常容易。除其他事项外,它还允许我们执行以下操作:
-
下载一个包含 20 个口语单词的音频数据集。
-
选择要训练模型的单词子集。
-
指定在音频上使用哪种类型的预处理。
-
选择几种不同类型的模型架构。
-
使用量化将模型优化为微控制器。
当我们运行脚本时,它会下载数据集,训练模型,并输出代表训练模型的文件。然后我们使用其他工具将此文件转换为适合 TensorFlow Lite 的正确形式。
注意
模型作者通常会创建这些类型的训练脚本。这使他们能够轻松地尝试不同变体的模型架构和超参数,并与他人分享他们的工作。
运行训练脚本的最简单方法是在 Colaboratory(Colab)笔记本中进行,我们将在下一节中进行。
在 Colab 中训练
Google Colab 是一个很好的训练模型的地方。它提供了云中强大的计算资源,并且设置了我们可以用来监视训练过程的工具。而且它完全免费。
在本节中,我们将使用 Colab 笔记本来训练我们的新模型。我们使用的笔记本可以在 TensorFlow 存储库中找到。
打开笔记本并单击“在 Google Colab 中运行”按钮,如图 8-1 所示。

图 8-1。在 Google Colab 中运行按钮
提示
截至目前,GitHub 存在一个错误,导致在显示 Jupyter 笔记本时出现间歇性错误消息。如果尝试访问笔记本时看到消息“抱歉,出了点问题。重新加载?”,请按照“构建我们的模型”中的说明操作。
本笔记本将指导我们完成训练模型的过程。它将按照以下步骤进行:
-
配置参数
-
安装正确的依赖项
-
使用称为 TensorBoard 的工具监视训练
-
运行训练脚本
-
将训练输出转换为我们可以使用的模型
启用 GPU 训练
在第四章中,我们在少量数据上训练了一个非常简单的模型。我们现在正在训练的模型要复杂得多,具有更大的数据集,并且需要更长时间来训练。在一台普通的现代计算机 CPU 上,训练它需要三到四个小时。
为了缩短训练模型所需的时间,我们可以使用一种称为GPU 加速的东西。GPU,即图形处理单元。它是一种旨在帮助计算机快速处理图像数据的硬件部件,使其能够流畅地渲染用户界面和视频游戏等内容。大多数计算机都有一个。
图像处理涉及并行运行许多任务,训练深度学习网络也是如此。这意味着可以使用 GPU 硬件加速深度学习训练。通常情况下,使用 GPU 运行训练比使用 CPU 快 5 到 10 倍是很常见的。
我们训练过程中需要的音频预处理意味着我们不会看到如此巨大的加速,但我们的模型在 GPU 上仍然会训练得更快 - 大约需要一到两个小时。
幸运的是,Colab 支持通过 GPU 进行训练。默认情况下未启用,但很容易打开。要这样做,请转到 Colab 的运行时菜单,然后单击“更改运行时类型”,如图 8-2 所示。

图 8-2. 在 Colab 中的“更改运行时类型”选项
选择此选项后,将打开图 8-3 中显示的“笔记本设置”框。

图 8-3. “笔记本设置”框
从“硬件加速器”下拉列表中选择 GPU,如图 8-4 所示,然后单击保存。

图 8-4. “硬件加速器”下拉列表
Colab 现在将在具有 GPU 的后端计算机(称为运行时)上运行其 Python。
下一步是配置笔记本,以包含我们想要训练的单词。
配置训练
训练脚本通过一系列命令行标志进行配置,这些标志控制从模型架构到将被训练分类的单词等所有内容。
为了更容易运行脚本,笔记本的第一个单元格将一些重要值存储在环境变量中。当运行这些脚本时,这些值将被替换为脚本的命令行标志。
第一个是WANTED_WORDS,允许我们选择要训练模型的单词:
os.environ["WANTED_WORDS"] = "yes,no"
默认情况下,选定的单词是“yes”和“no”,但我们可以提供以下单词的任何组合,这些单词都出现在我们的数据集中:
-
常见命令:yes、no、up、down、left、right、on、off、stop、go、backward、forward、follow、learn
-
数字零到九:zero、one、two、three、four、five、six、seven、eight、nine
-
随机单词:bed、bird、cat、dog、happy、house、Marvin、Sheila、tree、wow
要选择单词,我们只需将它们包含在逗号分隔的列表中。让我们选择单词“on”和“off”来训练我们的新模型:
os.environ["WANTED_WORDS"] = "on,off"
在训练模型时,未包含在列表中的任何单词将在模型训练时归为“未知”类别。
注意
在这里选择超过两个单词是可以的;我们只需要稍微调整应用代码。我们提供了在“在我们的项目中使用模型”中执行此操作的说明。
还要注意TRAINING_STEPS和LEARNING_RATE变量:
os.environ["TRAINING_STEPS"]="15000,3000"
os.environ["LEARNING_RATE"]="0.001,0.0001"
在第三章中,我们了解到模型的权重和偏差会逐渐调整,以便随着时间的推移,模型的输出越来越接近所期望的值。TRAINING_STEPS指的是训练数据批次通过网络运行的次数,以及其权重和偏差的更新次数。LEARNING_RATE设置调整速率。
使用高学习率,权重和偏差在每次迭代中调整更多,意味着收敛速度快。然而,这些大幅跳跃意味着更难以达到理想值,因为我们可能会一直跳过它们。使用较低的学习率,跳跃较小。需要更多步骤才能收敛,但最终结果可能更好。对于给定模型的最佳学习率是通过试错确定的。
在上述变量中,训练步骤和学习率被定义为逗号分隔的列表,定义了每个训练阶段的学习率。根据我们刚刚查看的值,模型将进行 15,000 步的训练,学习率为 0.001,然后进行 3,000 步的训练,学习率为 0.0001。总步数将为 18,000。
这意味着我们将使用高学习率进行一系列迭代,使网络快速收敛。然后我们将使用低学习率进行较少的迭代,微调权重和偏差。
现在,我们将保持这些值不变,但知道它们是什么是很好的。运行单元格。您将看到以下输出打印:
Training these words: on,off
Training steps in each stage: 15000,3000
Learning rate in each stage: 0.001,0.0001
Total number of training steps: 18000
这提供了我们的模型将如何训练的摘要。
安装依赖项
接下来,我们获取一些运行脚本所必需的依赖项。
运行下面的两个单元格来执行以下操作:
-
安装包含训练所需操作的特定版本的 TensorFlow
pip软件包。 -
克隆 TensorFlow GitHub 存储库的相应版本,以便我们可以访问训练脚本。
加载 TensorBoard
为了监视训练过程,我们使用TensorBoard。这是一个用户界面,可以向我们显示图表、统计数据和其他关于训练进展的见解。
当训练完成时,它将看起来像图 8-5 中的截图。您将在本章后面了解所有这些图表的含义。

图 8-5。训练完成后的 TensorBoard 截图
运行下一个单元格以加载 TensorBoard。它将出现在 Colab 中,但在我们开始训练之前不会显示任何有趣的内容。
开始训练
以下单元格运行开始训练的脚本。您可以看到它有很多命令行参数:
!python tensorflow/tensorflow/examples/speech_commands/train.py \
--model_architecture=tiny_conv --window_stride=20 --preprocess=micro \
--wanted_words=${WANTED_WORDS} --silence_percentage=25 --unknown_percentage=25 \
--quantize=1 --verbosity=WARN --how_many_training_steps=${TRAINING_STEPS} \
--learning_rate=${LEARNING_RATE} --summaries_dir=/content/retrain_logs \
--data_dir=/content/speech_dataset --train_dir=/content/speech_commands_train
其中一些,如--wanted_words=${WANTED_WORDS},使用我们之前定义的环境变量来配置我们正在创建的模型。其他设置脚本的输出,例如--train_dir=/content/speech_commands_train,定义了训练模型将保存的位置。
保持参数不变,运行单元格。您将开始看到一些输出流过。在下载语音命令数据集时,它将暂停一段时间:
>> Downloading speech_commands_v0.02.tar.gz 18.1%
完成后,会出现更多输出。可能会有一些警告,只要单元格继续运行,您可以忽略它们。此时,您应该向上滚动到 TensorBoard,希望它看起来像图 8-6。如果您看不到任何图表,请单击 SCALARS 选项卡。

图 8-6。训练开始时的 TensorBoard 截图
万岁!这意味着训练已经开始。您刚刚运行的单元将继续执行,训练将需要最多两个小时才能完成。该单元将不会输出更多日志,但有关训练运行的数据将出现在 TensorBoard 中。
您可以看到 TensorBoard 显示了两个图形,“准确度”和“交叉熵”,如图 8-7 所示。两个图形都显示了 x 轴上的当前步骤。“准确度”图显示了模型在 y 轴上的准确度,这表明它能够正确检测单词的时间有多少。“交叉熵”图显示了模型的损失,量化了模型预测与正确值之间的差距。

图 8-7。"准确度"和"交叉熵"图
注意
交叉熵是衡量机器学习模型损失的常见方法,用于执行分类,目标是预测输入属于哪个类别。
图形上的锯齿线对应于训练数据集上的性能,而直线反映了验证数据集上的性能。验证定期进行,因此图上的验证数据点较少。
新数据将随着时间的推移出现在图形中,但要显示它,您需要调整它们的比例以适应。您可以通过单击每个图形下面的最右边的按钮来实现这一点,如图 8-8 所示。

图 8-8。单击此按钮以调整图形的比例,以适应所有可用数据
您还可以单击图 8-9 中显示的按钮,使每个图形变大。

图 8-9。单击此按钮以放大图形
除了图形外,TensorBoard 还可以显示输入传入模型。单击 IMAGES 选项卡,显示类似于图 8-10 的视图。这是在训练期间输入到模型中的频谱图的示例。

图 8-10。TensorBoard 的 IMAGES 选项卡
等待训练完成
训练模型将需要一到两个小时,所以我们现在的工作是耐心等待。幸运的是,我们有 TensorBoard 漂亮的图形来娱乐我们。
随着训练的进行,您会注意到指标在一定范围内跳动。这是正常的,但它使图形看起来模糊且难以阅读。为了更容易看到训练的进展,我们可以使用 TensorFlow 的平滑功能。
图 8-11 显示了应用默认平滑度的图形;请注意它们有多模糊。

图 8-11。默认平滑度的训练图
通过调整图 8-12 中显示的平滑滑块,我们可以增加平滑度,使趋势更加明显。

图 8-12。TensorBoard 的平滑滑块
图 8-13 显示了具有更高平滑度级别的相同图形。原始数据以较浅的颜色可见,在下面。

图 8-13。增加平滑度的训练图
保持 Colab 运行
为了防止废弃的项目占用资源,如果 Colab 没有被积极使用,它将关闭您的运行时。因为我们的训练需要一段时间,所以我们需要防止这种情况发生。我们需要考虑一些事情。
首先,如果我们没有在与 Colab 浏览器标签进行活动交互,Web 用户界面将与后端运行时断开连接,训练脚本正在执行的地方。几分钟后会发生这种情况,并且会导致您的 TensorBoard 图表停止更新最新的训练指标。如果发生这种情况,无需恐慌—您的训练仍在后台运行。
如果您的运行时已断开连接,您将在 Colab 的用户界面中看到一个重新连接按钮,如图 8-14 所示。点击此按钮以重新连接您的运行时。

图 8-14. Colab 的重新连接按钮
断开连接的运行时并不是什么大问题,但 Colab 的下一个超时需要一些注意。如果您在 90 分钟内不与 Colab 进行交互,您的运行时实例将被回收。这是一个问题:您将丢失所有的训练进度,以及实例中存储的任何数据!
为了避免这种情况发生,您只需要每 90 分钟至少与 Colab 进行一次交互。打开标签页,确保运行时已连接,并查看您美丽的图表。只要在 90 分钟过去之前这样做,连接就会保持打开状态。
警告
即使您关闭了 Colab 标签页,运行时也会在后台继续运行长达 90 分钟。只要在浏览器中打开原始 URL,您就可以重新连接到运行时,并继续之前的操作。
然而,当标签页关闭时,TensorBoard 将消失。如果在重新打开标签页时训练仍在进行,您将无法查看 TensorBoard,直到训练完成。
最后,Colab 运行时的最长寿命为 12 小时。如果您的训练时间超过 12 小时,那就倒霉了—Colab 将在训练完成之前关闭并重置您的实例。如果您的训练可能持续这么长时间,您应该避免使用 Colab,并使用“其他运行脚本的方法”中描述的替代方案之一。幸运的是,训练我们的唤醒词模型不会花费那么长时间。
当您的图表显示了 18000 步的数据时,训练就完成了!现在我们必须运行几个命令来准备我们的模型进行部署。不用担心—这部分要快得多。
冻结图表
正如您在本书中早些时候学到的,训练是一个迭代调整模型权重和偏差的过程,直到它产生有用的预测。训练脚本将这些权重和偏差写入检查点文件。每一百步写入一个检查点。这意味着如果训练在中途失败,可以从最近的检查点重新启动而不会丢失进度。
train.py脚本被调用时带有一个参数,--train_dir,用于指定这些检查点文件将被写入的位置。在我们的 Colab 中,它被设置为/content/speech_commands_train。
您可以通过打开 Colab 的左侧面板来查看检查点文件,该面板具有一个文件浏览器。要这样做,请点击图 8-15 中显示的按钮。

图 8-15. 打开 Colab 侧边栏的按钮
在此面板中,点击“文件”选项卡以查看运行时的文件系统。如果您打开speech_commands_train/目录,您将看到检查点文件,如图 8-16 所示。每个文件名中的数字表示保存检查点的步骤。

图 8-16. Colab 的文件浏览器显示检查点文件列表
一个 TensorFlow 模型由两个主要部分组成:
-
训练产生的权重和偏差
-
将模型的输入与这些权重和偏差结合起来产生模型的输出的操作图
此时,我们的模型操作在 Python 脚本中定义,并且其训练的权重和偏差在最新的检查点文件中。我们需要将这两者合并为一个具有特定格式的单个模型文件,以便我们可以用来运行推断。创建此模型文件的过程称为冻结——我们正在创建一个具有冻结权重的图的静态表示。
为了冻结我们的模型,我们运行一个脚本。您将在下一个单元格中找到它,在“冻结图”部分。脚本的调用如下:
!python tensorflow/tensorflow/examples/speech_commands/freeze.py \
--model_architecture=tiny_conv --window_stride=20 --preprocess=micro \
--wanted_words=${WANTED_WORDS} --quantize=1 \
--output_file=/content/tiny_conv.pb \
--start_checkpoint=/content/speech_commands_train/tiny_conv. \
ckpt-${TOTAL_STEPS}
为了指向正确的操作图以冻结的脚本,我们传递了一些与训练中使用的相同参数。我们还传递了最终检查点文件的路径,该文件的文件名以训练步骤的总数结尾。
运行此单元格以冻结图。冻结的图将输出到名为tiny_conv.pb的文件中。
这个文件是完全训练过的 TensorFlow 模型。它可以被 TensorFlow 加载并用于运行推断。这很棒,但它仍然是常规 TensorFlow 使用的格式,而不是 TensorFlow Lite。我们的下一步是将模型转换为 TensorFlow Lite 格式。
转换为 TensorFlow Lite
转换是另一个简单的步骤:我们只需要运行一个命令。现在我们有一个冻结的图文件可以使用,我们将使用toco,TensorFlow Lite 转换器的命令行界面。
在“转换模型”部分,运行第一个单元格:
!toco
--graph_def_file=/content/tiny_conv.pb --output_file= \
/content/tiny_conv.tflite \
--input_shapes=1,49,40,1 --input_arrays=Reshape_2
--output_arrays='labels_softmax' \
--inference_type=QUANTIZED_UINT8 --mean_values=0 --std_dev_values=9.8077
在参数中,我们指定要转换的模型,TensorFlow Lite 模型文件的输出位置,以及一些取决于模型架构的其他值。因为模型在训练期间被量化,我们还提供了一些参数(inference_type,mean_values和std_dev_values),指导转换器如何将其低精度值映射到实数。
您可能想知道为什么input_shape参数在宽度、高度和通道参数之前有一个前导1。这是批处理大小;为了在训练期间提高效率,我们一次发送很多输入,但当我们在实时应用中运行时,我们每次只处理一个样本,这就是为什么批处理大小固定为1。
转换后的模型将被写入tiny_conv.tflite。恭喜!这是一个完全成型的 TensorFlow Lite 模型!
查看这个模型有多小,在下一个单元格中运行以下代码:
import os
model_size = os.path.getsize("/content/tiny_conv.tflite")
print("Model is %d bytes" % model_size)
输出显示模型非常小:模型大小为 18208 字节。
我们的下一步是将这个模型转换为可以部署到微控制器的形式。
创建一个 C 数组
回到“转换为 C 文件”中,我们使用xxd命令将 TensorFlow Lite 模型转换为 C 数组。我们将在下一个单元格中做同样的事情:
# Install xxd if it is not available
!apt-get -qq install xxd
# Save the file as a C source file
!xxd -i /content/tiny_conv.tflite > /content/tiny_conv.cc
# Print the source file
!cat /content/tiny_conv.cc
输出的最后部分将是文件的内容,其中包括一个 C 数组和一个保存其长度的整数,如下所示(您看到的确切值可能略有不同):
unsigned char _content_tiny_conv_tflite[] = {
0x1c, 0x00, 0x00, 0x00, 0x54, 0x46, 0x4c, 0x33, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x0e, 0x00, 0x18, 0x00, 0x04, 0x00, 0x08, 0x00, 0x0c, 0x00,
// ...
0x00, 0x09, 0x06, 0x00, 0x08, 0x00, 0x07, 0x00, 0x06, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x04
};
unsigned int _content_tiny_conv_tflite_len = 18208;
这段代码也被写入一个文件tiny_conv.cc,您可以使用 Colab 的文件浏览器下载。因为您的 Colab 运行时将在 12 小时后到期,现在将此文件下载到您的计算机是一个好主意。
接下来,我们将把这个新训练过的模型与micro_speech项目集成起来,以便我们可以将其部署到一些硬件上。
在我们的项目中使用模型
要使用我们的新模型,我们需要做三件事:
-
在micro_features/tiny_conv_micro_features_model_data.cc中,用我们的新模型替换原始模型数据。
-
在micro_features/micro_model_settings.cc中用我们的新“on”和“off”标签更新标签名称。
-
更新特定设备的command_responder.cc以执行我们对新标签的操作。
替换模型
要替换模型,请在文本编辑器中打开micro_features/tiny_conv_micro_features_model_data.cc。
注意
如果你正在使用 Arduino 示例,该文件将显示为 Arduino IDE 中的一个选项卡。它的名称将是micro_features_tiny_conv_micro_features_model_data.cpp。如果你正在使用 SparkFun Edge,你可以直接在本地的 TensorFlow 存储库副本中编辑文件。如果你正在使用 STM32F746G,你应该在 Mbed 项目目录中编辑文件。
tiny_conv_micro_features_model_data.cc文件包含一个看起来像这样的数组声明:
const unsigned char
g_tiny_conv_micro_features_model_data[] DATA_ALIGN_ATTRIBUTE = {
0x18, 0x00, 0x00, 0x00, 0x54, 0x46, 0x4c, 0x33, 0x00, 0x00, 0x0e, 0x00,
0x18, 0x00, 0x04, 0x00, 0x08, 0x00, 0x0c, 0x00, 0x10, 0x00, 0x14, 0x00,
//...
0x00, 0x09, 0x06, 0x00, 0x08, 0x00, 0x07, 0x00, 0x06, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x04};
const int g_tiny_conv_micro_features_model_data_len = 18208;
需要替换数组的内容以及常量g_tiny_conv_micro_features_model_data_len的值,如果已经更改。
为此,打开你在上一节末尾下载的tiny_conv.cc文件。复制并粘贴数组的内容,但不包括定义,到tiny_conv_micro_features_model_data.cc中定义的数组中。确保你正在覆盖数组的内容,但不是它的声明。
在tiny_conv.cc的底部,你会找到_content_tiny_conv_tflite_len,一个变量,其值表示数组的长度。回到tiny_conv_micro_features_model_data.cc,用这个变量的值替换g_tiny_conv_micro_features_model_data_len的值。然后保存文件;你已经完成了更新。
更新标签
接下来,打开micro_features/micro_model_settings.cc。这个文件包含一个类标签的数组:
const char* kCategoryLabels[kCategoryCount] = {
"silence",
"unknown",
"yes",
"no",
};
为了调整我们的新模型,我们可以简单地将“yes”和“no”交换为“on”和“off”。我们按顺序将标签与模型的输出张量元素匹配,因此重要的是按照它们提供给训练脚本的顺序列出这些标签。
以下是预期的代码:
const char* kCategoryLabels[kCategoryCount] = {
"silence",
"unknown",
"on",
"off",
};
如果你训练了一个具有两个以上标签的模型,只需将它们全部添加到列表中。
我们现在已经完成了切换模型的工作。唯一剩下的步骤是更新使用标签的任何输出代码。
更新 command_responder.cc
该项目包含针对 Arduino、SparkFun Edge 和 STM32F746G 的不同设备特定实现的command_responder.cc。我们将在以下部分展示如何更新每个设备。
Arduino
位于arduino/command_responder.cc中的 Arduino 命令响应器在听到“yes”时会点亮 LED 3 秒钟。让我们将其更新为在听到“on”或“off”时点亮 LED。在文件中,找到以下if语句:
// If we heard a "yes", switch on an LED and store the time.
if (found_command[0] == 'y') {
last_yes_time = current_time;
digitalWrite(LED_BUILTIN, HIGH);
}
if语句测试命令的第一个字母是否为“y”,表示“yes”。如果我们将这个“y”改为“o”,LED 将点亮“on”或“off”,因为它们都以“o”开头:
if (found_command[0] == 'o') {
last_yes_time = current_time;
digitalWrite(LED_BUILTIN, HIGH);
}
完成这些代码更改后,部署到你的设备并尝试一下。
SparkFun Edge
位于sparkfun_edge/command_responder.cc中的 SparkFun Edge 命令响应器会根据听到的“yes”或“no”点亮不同的 LED。在文件中,找到以下if语句:
if (found_command[0] == 'y') {
am_hal_gpio_output_set(AM_BSP_GPIO_LED_YELLOW);
}
if (found_command[0] == 'n') {
am_hal_gpio_output_set(AM_BSP_GPIO_LED_RED);
}
if (found_command[0] == 'u') {
am_hal_gpio_output_set(AM_BSP_GPIO_LED_GREEN);
}
很容易更新这些,使得“on”和“off”分别点亮不同的 LED:
if (found_command[0] == 'o' && found_command[1] == 'n') {
am_hal_gpio_output_set(AM_BSP_GPIO_LED_YELLOW);
}
if (found_command[0] == 'o' && found_command[1] == 'f') {
am_hal_gpio_output_set(AM_BSP_GPIO_LED_RED);
}
if (found_command[0] == 'u') {
am_hal_gpio_output_set(AM_BSP_GPIO_LED_GREEN);
}
因为这两个命令都以相同的字母开头,我们需要查看它们的第二个字母来消除歧义。现在,当说“on”时,黄色 LED 将点亮,当说“off”时,红色 LED 将点亮。
完成更改后,部署并运行代码,使用与“运行示例”中遵循的相同过程。
STM32F746G
位于disco_f746ng/command_responder.cc中的 STM32F746G 命令响应器会根据听到的命令显示不同的单词。在文件中,找到以下if语句:
if (*found_command == 'y') {
lcd.Clear(0xFF0F9D58);
lcd.DisplayStringAt(0, LINE(5), (uint8_t *)"Heard yes!", CENTER_MODE);
} else if (*found_command == 'n') {
lcd.Clear(0xFFDB4437);
lcd.DisplayStringAt(0, LINE(5), (uint8_t *)"Heard no :(", CENTER_MODE);
} else if (*found_command == 'u') {
lcd.Clear(0xFFF4B400);
lcd.DisplayStringAt(0, LINE(5), (uint8_t *)"Heard unknown", CENTER_MODE);
} else {
lcd.Clear(0xFF4285F4);
lcd.DisplayStringAt(0, LINE(5), (uint8_t *)"Heard silence", CENTER_MODE);
}
很容易更新以便响应“on”和“off”:
if (found_command[0] == 'o' && found_command[1] == 'n') {
lcd.Clear(0xFF0F9D58);
lcd.DisplayStringAt(0, LINE(5), (uint8_t *)"Heard on!", CENTER_MODE);
} else if (found_command[0] == 'o' && found_command[1] == 'f') {
lcd.Clear(0xFFDB4437);
lcd.DisplayStringAt(0, LINE(5), (uint8_t *)"Heard off", CENTER_MODE);
} else if (*found_command == 'u') {
lcd.Clear(0xFFF4B400);
lcd.DisplayStringAt(0, LINE(5), (uint8_t *)"Heard unknown", CENTER_MODE);
} else {
lcd.Clear(0xFF4285F4);
lcd.DisplayStringAt(0, LINE(5), (uint8_t *)"Heard silence", CENTER_MODE);
}
同样,因为这两个命令都以相同的字母开头,我们需要查看它们的第二个字母来消除歧义。现在我们为每个命令显示适当的文本。
运行脚本的其他方法
如果你无法使用 Colab,有两种其他推荐的训练模型的方法:
-
在一个带有 GPU 的云虚拟机(VM)中
-
在你的本地工作站上
进行基于 GPU 的训练所需的驱动程序仅在 Linux 上可用。没有 Linux,训练将需要大约四个小时。因此,建议使用带有 GPU 的云虚拟机或类似配置的 Linux 工作站。
设置您的虚拟机或工作站超出了本书的范围。但是,我们有一些建议。如果您使用虚拟机,可以启动一个Google Cloud 深度学习虚拟机镜像,该镜像预先配置了所有您进行 GPU 训练所需的依赖项。如果您使用 Linux 工作站,TensorFlow GPU Docker 镜像包含了您所需的一切。
要训练模型,您需要安装 TensorFlow 的夜间版本。要卸载任何现有版本并替换为已确认可用的版本,请使用以下命令:
pip uninstall -y tensorflow tensorflow_estimator
pip install -q tf-estimator-nightly==1.14.0.dev2019072901 \
tf-nightly-gpu==1.15.0.dev20190729
接下来,打开命令行并切换到用于存储代码的目录。使用以下命令克隆 TensorFlow 并打开一个已确认可用的特定提交:
git clone -q https://github.com/tensorflow/tensorflow
git -c advice.detachedHead=false -C tensorflow checkout 17ce384df70
现在您可以运行train.py脚本来训练模型。这将训练一个能识别“是”和“不”的模型,并将检查点文件输出到/tmp:
python tensorflow/tensorflow/examples/speech_commands/train.py \
--model_architecture=tiny_conv --window_stride=20 --preprocess=micro \
--wanted_words="on,off" --silence_percentage=25 --unknown_percentage=25 \
--quantize=1 --verbosity=INFO --how_many_training_steps="15000,3000" \
--learning_rate="0.001,0.0001" --summaries_dir=/tmp/retrain_logs \
--data_dir=/tmp/speech_dataset --train_dir=/tmp/speech_commands_train
训练后,运行以下脚本来冻结模型:
python tensorflow/tensorflow/examples/speech_commands/freeze.py \
--model_architecture=tiny_conv --window_stride=20 --preprocess=micro \
--wanted_words="on,off" --quantize=1 --output_file=/tmp/tiny_conv.pb \
--start_checkpoint=/tmp/speech_commands_train/tiny_conv.ckpt-18000
接下来,将模型转换为 TensorFlow Lite 格式:
toco
--graph_def_file=/tmp/tiny_conv.pb --output_file=/tmp/tiny_conv.tflite \
--input_shapes=1,49,40,1 --input_arrays=Reshape_2 \
--output_arrays='labels_softmax' \
--inference_type=QUANTIZED_UINT8 --mean_values=0 --std_dev_values=9.8077
最后,将文件转换为 C 源文件,以便编译到嵌入式系统中:
xxd -i /tmp/tiny_conv.tflite > /tmp/tiny_conv_micro_features_model_data.cc
模型的工作原理
现在您知道如何训练自己的模型了,让我们探讨一下它是如何工作的。到目前为止,我们将机器学习模型视为黑匣子——我们将训练数据输入其中,最终它会找出如何预测结果。要使用模型并不一定要理解底层发生了什么,但这对于调试问题可能有帮助,而且本身也很有趣。本节将为您提供一些关于模型如何进行预测的见解。
可视化输入
图 8-17 说明了实际输入神经网络的内容。这是一个具有单个通道的 2D 数组,因此我们可以将其可视化为单色图像。我们使用 16 KHz 音频样本数据,那么我们如何从源数据得到这种表示?这个过程是机器学习中所谓的“特征生成”的一个示例,目标是将更难处理的输入格式(在本例中是代表一秒音频的 16,000 个数值)转换为机器学习模型更容易理解的内容。如果您之前研究过深度学习的机器视觉用例,您可能没有遇到这种情况,因为图像通常相对容易让网络接受而无需太多预处理;但在许多其他领域,如音频和自然语言处理,仍然常见在将输入馈入模型之前对其进行转换。

图 8-17。TensorBoard 的 IMAGES 选项卡
为了对我们的模型为什么更容易处理预处理输入有直觉,让我们看一下一些音频录音的原始表示,如图 8-18 到 8-21 所示。

图 8-18。一个人说“是”的音频录音的波形

图 8-19。一个人说“不”的音频录音的波形

图 8-20。一个人说“是”的音频录音的另一个波形

图 8-21。一个人说“不”的音频录音的另一个波形
如果没有标签,你会很难区分哪些波形对应相同的单词。现在看看图 8-22 到 8-25,展示了将相同的一秒录音通过特征生成处理后的结果。

图 8-22。一个人说“是”时的谱图

图 8-23。一个人说“否”时的谱图

图 8-24。一个人说“是”时的另一个谱图

图 8-25。一个人说“否”时的另一个谱图
这些仍然不容易解释,但希望你能看出“是”谱图的形状有点像倒置的 L,而“否”特征显示出不同的形状。我们可以更容易地辨别谱图之间的差异,希望直觉告诉你,对于模型来说做同样的事情更容易。
另一个方面是生成的谱图比样本数据要小得多。每个谱图由 1,960 个数值组成,而波形有 16,000 个。它们是音频数据的摘要,减少了神经网络必须进行的工作量。事实上,一个专门设计的模型,比如DeepMind 的 WaveNet,可以将原始样本数据作为输入,但结果模型往往涉及比我们使用的神经网络加手工设计特征组合更多的计算,因此对于资源受限的环境,如嵌入式系统,我们更喜欢这里使用的方法。
特征生成是如何工作的?
如果你有处理音频的经验,你可能熟悉像梅尔频率倒谱系数(MFCCs)这样的方法。这是一种常见的生成我们正在使用的谱图的方法,但我们的示例实际上使用了一种相关但不同的方法。这是谷歌在生产中使用的相同方法,这意味着它已经得到了很多实际验证,但它还没有在研究文献中发表。在这里,我们大致描述了它的工作原理,但对于详细信息,最好的参考是代码本身。
该过程开始通过为给定时间片段生成傅立叶变换(也称为快速傅立叶变换或 FFT)-在我们的情况下是 30 毫秒的音频数据。这个 FFT 是在使用汉宁窗口过滤的数据上生成的,汉宁窗口是一个钟形函数,减少了 30 毫秒窗口两端样本的影响。傅立叶变换为每个频率产生具有实部和虚部的复数,但我们只关心总能量,因此我们对两个分量的平方求和,然后应用平方根以获得每个频率桶的幅度。
给定N个样本,傅立叶变换提供N/2 个频率的信息。以每秒 16,000 个样本的速率的 30 毫秒需要 480 个样本,因为我们的 FFT 算法需要二的幂输入,所以我们用零填充到 512 个样本,给我们 256 个频率桶。这比我们需要的要大,因此为了缩小它,我们将相邻频率平均到 40 个降采样桶中。然而,这种降采样不是线性的;相反,它使用基于人类感知的梅尔频率刻度,以便更多地为低频率分配权重,从而为它们提供更多的桶,而高频率则合并到更广泛的桶中。图 8-26 展示了该过程的图表。

图 8-26。特征生成过程的图表
这个特征生成器的一个不寻常之处是它包含了一个降噪步骤。这通过保持每个频率桶中的值的运行平均值,然后从当前值中减去这个平均值来实现。其思想是背景噪音随时间保持相对恒定,并显示在特定频率上。通过减去运行平均值,我们有很大机会去除一些噪音的影响,保留我们感兴趣的更快变化的语音。棘手的部分是特征生成器确实保留状态以跟踪每个桶的运行平均值,因此如果您尝试为给定输入重现相同的频谱图输出——就像我们尝试的那样进行测试——您将需要将该状态重置为正确的值。
噪音降低的另一个部分最初让我们感到惊讶的是它对奇数和偶数频率桶使用不同系数。这导致了您可以在最终生成的特征图像中看到的独特的梳齿图案(图 8-22 至 8-25)。最初我们以为这是一个错误,但在与原始实施者交谈后,我们了解到这实际上是有意为之,以帮助性能。在Yuxuan Wang 等人的“用于强健和远场关键词检测的可训练前端”的第 4.3 节中对这种方法进行了详细讨论,该论文还包括了进入此特征生成流程的其他设计决策的背景。我们还通过我们的模型进行了实证测试,去除奇数和偶数桶处理差异确实会显着降低评估的准确性。
然后我们使用每通道幅度归一化(PCAN)自动增益,根据运行平均噪音来增强信号。最后,我们对所有桶值应用对数尺度,以便相对较大的频率不会淹没频谱中较安静的部分——这种归一化有助于后续模型处理这些特征。
这个过程总共重复了 49 次,每次之间以 30 毫秒的窗口向前移动 20 毫秒,以覆盖完整的一秒音频输入数据。这产生了一个 40 个元素宽(每个频率桶一个)和 49 行高(每个时间片一个)的值的 2D 数组。
如果这一切听起来很复杂,不用担心。因为实现它的代码都是开源的,您可以在自己的音频项目中重用它。
理解模型架构
我们正在使用的神经网络模型被定义为一组操作的小图。您可以在create_tiny_conv_model()函数中找到定义它的代码,并且图 8-27 展示了结果的可视化。
该模型由一个卷积层、一个全连接层和最后的 softmax 层组成。在图中,卷积层标记为“DepthwiseConv2D”,但这只是 TensorFlow Lite 转换器的一个怪癖(事实证明,具有单通道输入图像的卷积层也可以表示为深度卷积)。您还会看到一个标记为“Reshape_1”的层,但这只是一个输入占位符,而不是一个真正的操作。

图 8-27。语音识别模型的图形可视化,由Netron 工具提供
卷积层用于在输入图像中发现 2D 模式。每个滤波器是一个值的矩形数组,它作为一个滑动窗口在输入上移动,输出图像表示输入和滤波器在每个点匹配程度。您可以将卷积操作视为在图像上移动一系列矩形滤波器,每个滤波器在每个像素处的结果对应于滤波器与图像中该补丁的相似程度。在我们的情况下,每个滤波器宽 8 像素,高 10 像素,总共有 8 个。图 8-28 到 8-35 显示它们的外观。

图 8-28。第一个滤波器图像

图 8-29。第二个滤波器图像

图 8-30。第三个滤波器图像

图 8-31。第四个滤波器图像

图 8-32。第五个滤波器图像

图 8-33。第六个滤波器图像

图 8-34。第七个滤波器图像

图 8-35。第八个滤波器图像
您可以将这些滤波器中的每一个视为输入图像的一个小补丁。该操作试图将此小补丁与看起来相似的输入图像部分进行匹配。当图像与补丁相似时,高值将被写入输出图像的相应部分。直观地说,每个滤波器都是模型已经学会在训练输入中寻找的模式,以帮助它区分不同类别。
因为我们有八个滤波器,所以将有八个不同的输出图像,每个对应于相应滤波器的匹配值,当它在输入上滑动时。这些滤波器输出实际上被合并为一个具有八个通道的单个输出图像。我们已将步幅设置为两个方向,这意味着每次我们将每个滤波器向前滑动两个像素,而不仅仅是一个像素。因为我们跳过每个其他位置,这意味着我们的输出图像是输入大小的一半。
您可以看到在可视化中,输入图像高 49 像素,宽 40 像素,具有单个通道,这是我们在前一节中讨论的特征频谱图所期望的。因为我们在水平和垂直方向上滑动卷积滤波器时跳过每个其他像素,所以卷积的输出是一半大小,即高 25 像素,宽 20 像素。然而有八个滤波器,所以图像变为八个通道深。
下一个操作是全连接层。这是一种不同的模式匹配过程。与在输入上滑动一个小窗口不同,这里为输入张量中的每个值都有一个权重。结果是指示输入与权重匹配程度的指标,在比较每个值之后。您可以将其视为全局模式匹配,其中您有一个理想的结果,您期望作为输入获得,输出是理想值(保存在权重中)与实际输入之间的接近程度。我们模型中的每个类都有自己的权重,因此“静音”,“未知”,“是”和“否”都有一个理想模式,并生成四个输出值。输入中有 4,000 个值(25 * 20 * 8),因此每个类由 4,000 个权重表示。
最后一层是一个 softmax 层。这有效地增加了最高输出和其最近竞争对手之间的差异,这不会改变它们的相对顺序(从全连接层产生最大值的类仍将保持最高),但有助于产生一个更有用的分数。这个分数通常非正式地被称为“概率”,但严格来说,如果没有更多关于输入数据实际混合的校准,你不能可靠地像那样使用它。例如,如果检测器中有更多的单词,那么像“反对建立教会主义”这样的不常见单词可能不太可能出现,而像“好的”这样的单词可能更有可能出现,但根据训练数据的分布,这可能不会反映在原始分数中。
除了这些主要层外,还有偏差被添加到全连接和卷积层的结果中,以帮助调整它们的输出,并在每个之后使用修正线性单元(ReLU)激活函数。ReLU 只是确保没有输出小于零,将任何负结果设置为零的最小值。这种类型的激活函数是使深度学习变得更加有效的突破之一:它帮助训练过程比网络本来会更快地收敛。
理解模型输出
模型的最终结果是 softmax 层的输出。这是四个数字,分别对应“沉默”,“未知”,“是”和“否”。这些值是每个类别的分数,具有最高分数的类别是模型的预测,分数代表模型对其预测的信心。例如,如果模型输出是[10, 4, 231, 80],它预测第三个类别“是”是最可能的结果,得分为 231。 (我们以它们的量化形式给出这些值,介于 0 和 255 之间,但因为这些只是相对分数,通常不需要将它们转换回它们的实值等价物。)
有一件棘手的事情是,这个结果是基于分析音频的最后一秒。如果我们每秒只运行一次,可能会得到一个话语,一半在上一秒,一半在当前秒。当模型只听到部分单词时,任何模型都不可能很好地识别单词,因此在这种情况下,单词识别会失败。为了克服这个问题,我们需要比每秒运行模型更频繁,以尽可能高的概率在我们的一秒窗口内捕捉到整个单词。实际上,我们发现我们必须每秒运行 10 到 15 次才能取得良好的结果。
如果我们得到这些结果如此迅速,我们如何决定何时得分足够高?我们实现了一个后处理类,它会随着时间平均分数,并仅在短时间内同一个单词的得分高时触发识别。您可以在RecognizeCommands 类中看到这个实现。这个类接收模型的原始结果,然后使用累积和平均算法来确定是否有任何类别已经超过了阈值。然后将这些后处理结果传递给CommandResponder以根据平台的输出能力采取行动。
模型参数都是从训练数据中学习的,但命令识别器使用的算法是手动创建的,所以所有的阈值——比如触发识别所需的得分值,或者需要的正结果时间窗口——都是手动选择的。这意味着不能保证它们是最佳的,所以如果在您自己的应用中看到不佳的结果,您可能希望尝试自己调整它们。
更复杂的语音识别模型通常使用能够接收流数据的模型(如递归神经网络),而不是我们在本章中展示的单层卷积网络。将流式处理嵌入到模型设计中意味着您无需进行后处理即可获得准确的结果,尽管这确实使训练变得更加复杂。
使用您自己的数据进行训练
您要构建的产品很可能不仅需要回答“是”和“否”,因此您需要训练一个对您关心的音频敏感的模型。我们之前使用的训练脚本旨在让您使用自己的数据创建自定义模型。这个过程中最困难的部分通常是收集足够大的数据集,并确保它适用于您的问题。我们在第十六章中讨论了数据收集和清理的一般方法,但本节涵盖了一些您可以训练自己的音频模型的方法。
语音命令数据集
train.py脚本默认下载了 Speech Commands 数据集。这是一个开源集合,包含超过 10 万个一秒钟的 WAV 文件,涵盖了许多不同说话者的各种短单词。它由 Google 分发,但话语是从世界各地的志愿者那里收集的。Aakanksha Chowdhery 等人的“Visual Wake Words Dataset”提供了更多细节。
除了“是”和“否”之外,数据集还包括另外八个命令词(“打开”,“关闭”,“上”,“下”,“左”,“右”,“停止”和“前进”),以及从“零”到“九”的十个数字。每个单词都有几千个示例。还有其他单词,比如“Marvin”,每个单词的示例要少得多。命令词旨在有足够的话语,以便您可以训练一个合理的模型来识别它们。其他单词旨在用于填充“未知”类别,因此模型可以发现当发出未经训练的单词时,而不是将其误认为是一个命令。
由于训练脚本使用了这个数据集,您可以轻松地训练一个模型,结合一些有很多示例的命令词。如果您使用训练集中存在的单词的逗号分隔列表更新--wanted_words参数,并从头开始运行训练,您应该会发现您可以创建一个有用的模型。需要注意的主要事项是,您要限制自己只使用这 10 个命令词和/或数字,否则您将没有足够的示例进行准确训练,并且如果您有超过两个想要的单词,则需要将--silence_percentage和--unknown_percentage值调低。这两个参数控制训练过程中混合了多少无声和未知样本。无声示例实际上并不是完全的沉默;相反,它们是从数据集的background文件夹中的 WAV 文件中随机选择的一秒钟的录制背景噪音片段。未知样本是从训练集中的任何单词中挑选出来的话语,但不在wanted_words列表中。这就是为什么数据集中有一些杂项单词,每个单词的话语相对较少;这让我们有机会认识到很多不同的单词实际上并不是我们正在寻找的单词。这在语音和音频识别中是一个特别的问题,因为我们的产品通常需要在可能从未在训练中遇到的环境中运行。仅在常见英语中就可能出现成千上万个不同的单词,为了有用,模型必须能够忽略那些它没有经过训练的单词。这就是为什么未知类别在实践中如此重要。
以下是使用现有数据集训练不同单词的示例:
python tensorflow/examples/speech_commands/train.py \
--model_architecture=tiny_conv --window_stride=20 --preprocess=micro \
--wanted_words="up,down,left,right" --silence_percentage=15 \
--unknown_percentage=15 --quantize=1
在您自己的数据集上训练
训练脚本的默认设置是使用 Speech Commands,但如果您有自己的数据集,可以使用--data_dir参数来使用它。您指向的目录应该像 Speech Commands 一样组织,每个包含一组 WAV 文件的类别都有一个子文件夹。您还需要一个特殊的background子文件夹,其中包含您的应用程序预计会遇到的背景噪音类型的较长的 WAV 录音。如果默认的一秒持续时间对您的用例不起作用,您还需要选择一个识别持续时间,并通过--sample_duration_ms参数指定。然后,您可以使用--wanted_words参数设置要识别的类别。尽管名称如此,这些类别可以是任何类型的音频事件,从玻璃破碎到笑声;只要您有足够的每个类别的 WAV 文件,训练过程应该与语音一样有效。
如果您在根目录/tmp/my_wavs中有名为glass和laughter的 WAV 文件夹,这是如何训练您自己的模型的:
python tensorflow/examples/speech_commands/train.py \
--model_architecture=tiny_conv --window_stride=20 --preprocess=micro \
--data_url="" --data_dir=/tmp/my_wavs/ --wanted_words="laughter,glass" \
--silence_percentage=25 --unknown_percentage=25 --quantize=1
通常最困难的部分是找到足够的数据。例如,事实证明,真实的玻璃破碎声与我们在电影中听到的声音效果非常不同。这意味着你需要找到现有的录音,或者安排自己录制一些。由于训练过程可能需要每个类别的成千上万个示例,并且它们需要涵盖在真实应用中可能发生的所有变化,这个数据收集过程可能令人沮丧、昂贵且耗时。
对于图像模型,一个常见的解决方案是使用迁移学习,即使用已经在大型公共数据集上训练过的模型,并使用其他数据对不同类别进行微调。这种方法在次要数据集中不需要像从头开始训练那样多的示例,而且通常会产生高准确度的结果。不幸的是,语音模型的迁移学习仍在研究中,但请继续关注。
如何录制您自己的音频
如果您需要捕捉您关心的单词的音频,如果您有一个提示说话者并将结果拆分为标记文件的工具,那将会更容易。Speech Commands 数据集是使用Open Speech Recording app录制的,这是一个托管应用程序,允许用户通过大多数常见的网络浏览器录制话语。作为用户,您将看到一个网页,首先要求您同意被录制,带有默认的谷歌协议,这是可以轻松更改的。同意后,您将被发送到一个具有录音控件的新页面。当您按下录制按钮时,单词将作为提示出现,您说的每个单词的音频将被记录。当所有请求的单词都被记录时,您将被要求将结果提交到服务器。
README 中有在 Google Cloud 上运行的说明,但这是一个用 Python 编写的 Flask 应用程序,因此您应该能够将其移植到其他环境中。如果您使用 Google Cloud,您需要更新app.yaml文件,指向您自己的存储桶,并提供您自己的随机会话密钥(这仅用于哈希,因此可以是任何值)。要自定义记录的单词,您需要编辑客户端 JavaScript中的一些数组:一个用于频繁重复的主要单词,一个用于次要填充词。
记录的文件以 OGG 压缩音频的形式存储在 Google Cloud 存储桶中,但训练需要 WAV 文件,因此您需要将它们转换。而且很可能您的一些录音包含错误,比如人们忘记说单词或说得太轻,因此在可能的情况下自动过滤出这些错误是有帮助的。如果您已经在BUCKET_NAME变量中设置了您的存储桶名称,您可以通过使用以下 bash 命令将文件复制到本地机器开始:
mkdir oggs
gsutil -m cp gs://${BUCKET_NAME}/* oggs/
压缩的 OGG 格式的一个好处是安静或无声的音频会生成非常小的文件,因此一个很好的第一步是删除那些特别小的文件,比如:
find ${BASEDIR}/oggs -iname "*.ogg" -size -5k -delete
我们发现将 OGG 转换为 WAV 的最简单方法是使用FFmpeg 项目,它提供了一个命令行工具。以下是一组命令,可以将一个目录中的所有 OGG 文件转换为我们需要的格式:
mkdir -p ${BASEDIR}/wavs
find ${BASEDIR}/oggs -iname "*.ogg" -print0 | \
xargs -0 basename -s .ogg | \
xargs -I {} ffmpeg -i ${BASEDIR}/oggs/{}.ogg -ar 16000 ${BASEDIR}/wavs/{}.wav
开放语音录制应用程序为每个单词记录超过一秒的音频。这确保了用户的话语被捕捉到,即使他们的时间比我们预期的早或晚一点。训练需要一秒钟的录音,并且最好是单词位于每个录音的中间。我们创建了一个小型开源实用程序,用于查看每个录音随时间的音量,以便正确居中并修剪音频,使其仅为一秒钟。在终端中输入以下命令来使用它:
git clone https://github.com/petewarden/extract_loudest_section \
/tmp/extract_loudest_section_github
pushd /tmp/extract_loudest_section_github
make
popd
mkdir -p ${BASEDIR}/trimmed_wavs
/tmp/extract_loudest_section/gen/bin/extract_loudest_section \
${BASEDIR}'/wavs/*.wav' ${BASEDIR}/trimmed_wavs/
这将为您提供一个格式正确且所需长度的文件夹,但训练过程需要将 WAV 文件按标签组织到子文件夹中。标签编码在每个文件的名称中,因此我们有一个示例 Python 脚本,它使用这些文件名将它们分类到适当的文件夹中。
数据增强
数据增强是另一种有效扩大训练数据并提高准确性的方法。在实践中,这意味着对记录的话语应用音频变换,然后再用于训练。这些变换可以包括改变音量、混入背景噪音,或者轻微修剪片段的开头或结尾。训练脚本默认应用所有这些变换,但您可以使用命令行参数调整它们的使用频率和强度。
警告
这种增强确实有助于使小数据集发挥更大作用,但它不能创造奇迹。如果你应用变换太强烈,可能会使训练输入变形得无法被人识别,这可能导致模型错误地开始触发与预期类别毫不相似的声音。
以下是如何使用其中一些命令行参数来控制增强:
python tensorflow/examples/speech_commands/train.py \
--model_architecture=tiny_conv --window_stride=20 --preprocess=micro \
--wanted_words="yes,no" --silence_percentage=25 --unknown_percentage=25 \
--quantize=1 --background_volume=0.2 --background_frequency=0.7 \
--time_shift_ms=200
模型架构
我们之前训练的“是”/“否”模型旨在小而快速。它只有 18 KB,并且执行一次需要 400,000 次算术运算。为了符合这些约束条件,它牺牲了准确性。如果您正在设计自己的应用程序,您可能希望做出不同的权衡,特别是如果您试图识别超过两个类别。您可以通过修改models.py文件指定自己的模型架构,然后使用--model_architecture参数。您需要编写自己的模型创建函数,例如create_tiny_conv_model0,但要指定您想要的模型中的层。然后,您可以更新create_model0中的if语句,为您的架构命名,并在通过命令行传递架构参数时调用您的新创建函数。您可以查看一些现有的创建函数以获取灵感,包括如何处理辍学。如果您已添加了自己的模型代码,以下是如何调用它的方法:
python tensorflow/examples/speech_commands/train.py \
--model_architecture=my_model_name --window_stride=20 --preprocess=micro \
--wanted_words="yes,no" --silence_percentage=25 \--unknown_percentage=25 \
--quantize=1
总结
识别具有小内存占用的口语是一个棘手的现实世界问题,解决它需要我们与比简单示例更多的组件一起工作。大多数生产机器学习应用程序需要考虑问题,如特征生成、模型架构选择、数据增强、找到最适合的训练数据,以及如何将模型的结果转化为可操作信息。
根据产品的实际需求,需要考虑很多权衡,希望您现在了解一些选项,以便在从训练转向部署时使用。
在下一章中,我们将探讨如何使用不同类型的数据进行推断,尽管这种数据看起来比音频更复杂,但实际上却很容易处理。
第九章:人员检测:构建一个应用程序
如果你问人们哪种感官对他们的日常生活影响最大,很多人会回答视觉。
视觉是一种极其有用的感觉。它使无数自然生物能够在环境中导航,找到食物来源,并避免遇到危险。作为人类,视觉帮助我们认识朋友,解释象征性信息,并理解我们周围的世界,而无需过于接近。
直到最近,视觉的力量并不可用于机器。我们大多数的机器人只是用触摸和接近传感器在世界中探索,通过一系列碰撞获取其结构的知识。一眼之间,一个人可以向你描述一个物体的形状、属性和目的,而无需与之互动。机器人就没有这样的运气。视觉信息只是太混乱、无结构和难以解释了。
随着卷积神经网络的发展,构建能够“看到”的程序变得容易。受到哺乳动物视觉皮层结构的启发,CNN 学会了理解我们的视觉世界,将一个极其复杂的输入过滤成已知模式和形状的地图。这些部分的精确组合可以告诉我们在给定数字图像中存在的实体。
如今,视觉模型被用于许多不同的任务。自动驾驶车辆使用视觉来发现道路上的危险。工厂机器人使用摄像头捕捉有缺陷的零件。研究人员已经训练出可以从医学图像中诊断疾病的模型。而且你的智能手机很有可能在照片中识别出人脸,以确保它们焦点完美。
具有视觉的机器可以帮助改变我们的家庭和城市,自动化以前无法实现的家务。但视觉是一种亲密的感觉。我们大多数人不喜欢自己的行为被记录,或者我们的生活被实时传输到云端,这通常是 ML 推断的地方。
想象一下一个可以通过内置摄像头“看到”的家用电器。它可以是一个可以发现入侵者的安全系统,一个知道自己被遗弃的炉灶,或者一个在房间里没有人时自动关闭的电视。在这些情况下,隐私至关重要。即使没有人观看录像,互联网连接的摄像头嵌入在始终开启的设备中的安全隐患使它们对大多数消费者不吸引人。
但所有这些都随着 TinyML 而改变。想象一下一个智能炉灶,如果长时间不被注意就会关闭它的燃烧器。如果它可以“看到”附近有一个使用微型微控制器的厨师,而没有任何与互联网的连接,我们就可以获得智能设备的所有好处,而不会有任何隐私方面的妥协。
更重要的是,具有视觉功能的微型设备可以进入以前没有敢去的地方。基于微控制器的视觉系统由于其微小的功耗,可以在一个小电池上运行数月甚至数年。这些设备可以在丛林或珊瑚礁中计算濒危动物的数量,而无需在线。
同样的技术使得构建一个视觉传感器作为一个独立的电子组件成为可能。传感器输出 1 表示某个物体在视野中,输出 0 表示不在视野中,但它从不分享摄像头收集的任何图像数据。这种类型的传感器可以嵌入各种产品中,从智能家居系统到个人车辆。你的自行车可以在你后面有车时闪光灯。你的空调可以知道有人在家。而且因为图像数据从未离开独立的传感器,即使产品连接到互联网,也可以保证安全。
本章探讨的应用程序使用一个预训练的人体检测模型,在连接了摄像头的微控制器上运行,以知道何时有人在视野中。在第十章中,您将了解这个模型是如何工作的,以及如何训练自己的模型来检测您想要的内容。
阅读完本章后,您将了解如何在微控制器上处理摄像头数据,以及如何使用视觉模型运行推断并解释输出。您可能会惊讶于这实际上是多么容易!
我们正在构建什么
我们将构建一个嵌入式应用程序,该应用程序使用模型对摄像头捕获的图像进行分类。该模型经过训练,能够识别摄像头输入中是否存在人物。这意味着我们的应用程序将能够检测人物的存在或缺席,并相应地产生输出。
这本质上是我们稍早描述的智能视觉传感器。当检测到人物时,我们的示例代码将点亮 LED 灯—但您可以扩展它以控制各种项目。
注意
与我们在第七章中开发的应用程序一样,您可以在TensorFlow GitHub 存储库中找到此应用程序的源代码。
与之前的章节一样,我们首先浏览测试和应用程序代码,然后是使示例在各种设备上运行的逻辑。
我们提供了将应用程序部署到以下微控制器平台的说明:
注意
TensorFlow Lite 定期添加对新设备的支持,因此如果您想要使用的设备未在此处列出,请查看示例的README.md。如果在按照这些步骤操作时遇到问题,您也可以在那里查找更新的部署说明。
与之前的章节不同,您需要一些额外的硬件来运行这个应用程序。因为这两个开发板都没有集成摄像头,我们建议购买一个摄像头模块。您将在每个设备的部分中找到这些信息。
让我们从了解应用程序的结构开始。它比您想象的要简单得多。
应用程序架构
到目前为止,我们已经确定了嵌入式机器学习应用程序执行以下一系列操作:
-
获取输入。
-
对输入进行预处理,提取适合输入模型的特征。
-
对处理后的输入运行推断。
-
对模型的输出进行后处理以理解其含义。
-
使用得到的信息来实现所需的功能。
在第七章中,我们看到这种方法应用于唤醒词检测,其输入是音频。这一次,我们的输入将是图像数据。这听起来可能更复杂,但实际上比音频更容易处理。
图像数据通常表示为像素值数组。我们将从嵌入式摄像头模块获取图像数据,所有这些模块都以这种格式提供数据。我们的模型也期望其输入是像素值数组。因此,在将数据输入模型之前,我们不需要进行太多的预处理。
鉴于我们不需要进行太多的预处理,我们的应用程序将会相当简单。它从摄像头中获取数据快照,将其输入模型,并确定检测到了哪个输出类。然后以一种简单的方式显示结果。
在我们继续之前,让我们更多地了解一下我们将要使用的模型。
介绍我们的模型
在第七章中,我们了解到卷积神经网络是专门设计用于处理多维张量的神经网络,其中信息包含在相邻值组之间的关系中。它们特别适合处理图像数据。
我们的人体检测模型是一个卷积神经网络,训练于Visual Wake Words 数据集。该数据集包含 115,000 张图像,每张图像都标记了是否包含人体。
该模型大小为 250 KB,比我们的语音模型大得多。除了占用更多内存外,这种额外的大小意味着运行单个推断需要更长的时间。
该模型接受 96×96 像素的灰度图像作为输入。每个图像都以形状为(96, 96, 1)的 3D 张量提供,其中最后一个维度包含一个表示单个像素的 8 位值。该值指定像素的阴影,范围从 0(完全黑色)到 255(完全白色)。
我们的摄像头模块可以以各种分辨率返回图像,因此我们需要确保它们被调整为 96×96 像素。我们还需要将全彩图像转换为灰度图像,以便与模型配合使用。
您可能认为 96×96 像素听起来像是一个很小的分辨率,但它将足以让我们在每个图像中检测到一个人。处理图像的模型通常接受令人惊讶地小的分辨率。增加模型的输入尺寸会带来递减的回报,而网络的复杂性会随着输入规模的增加而大幅增加。因此,即使是最先进的图像分类模型通常也只能处理最大为 320×320 像素的图像。
模型输出两个概率:一个指示输入中是否存在人的概率,另一个指示是否没有人的概率。概率范围从 0 到 255。
我们的人体检测模型使用了MobileNet架构,这是一个为移动手机等设备设计的用于图像分类的经过广泛测试的架构。在第十章中,您将学习如何将该模型适配到微控制器上,并且如何训练您自己的模型。现在,让我们继续探索我们的应用程序是如何工作的。
所有的组件
图 9-1 显示了我们人体检测应用程序的结构。

图 9-1。我们人体检测应用程序的组件
正如我们之前提到的,这比唤醒词应用程序要简单得多,因为我们可以直接将图像数据传递到模型中,无需预处理。
另一个让事情简单的方面是我们不对模型的输出进行平均。我们的唤醒词模型每秒运行多次,因此我们必须对其输出进行平均以获得稳定的结果。我们的人体检测模型更大,推断时间更长。这意味着不需要对其输出进行平均。
代码有五个主要部分:
主循环
与其他示例一样,我们的应用程序在一个连续循环中运行。然而,由于我们的模型更大更复杂,因此推断的运行时间会更长。根据设备的不同,我们可以预期每隔几秒进行一次推断,而不是每秒进行多次推断。
图像提供者
该组件从摄像头捕获图像数据并将其写入输入张量。捕获图像的方法因设备而异,因此该组件可以被覆盖和自定义。
TensorFlow Lite 解释器
解释器运行 TensorFlow Lite 模型,将输入图像转换为一组概率。
模型
该模型作为数据数组包含在内,并由解释器运行。250 KB 的模型太大了,无法提交到 TensorFlow GitHub 存储库。因此,在构建项目时,Makefile 会下载它。如果您想查看,可以自行下载tf_lite_micro_person_data_grayscale.zip。
检测响应器
检测响应器接收模型输出的概率,并使用设备的输出功能来显示它们。我们可以为不同的设备类型进行覆盖。在我们的示例代码中,它将点亮 LED,但您可以扩展它以执行几乎任何操作。
为了了解这些部分如何配合,我们将查看它们的测试。
通过测试
这个应用程序非常简单,因为只有几个测试需要进行。您可以在GitHub 存储库中找到它们:
展示如何对表示单个图像的数组运行推断
展示如何使用图像提供程序捕获图像
展示如何使用检测响应器输出检测结果
让我们从探索person_detection_test.cc开始,看看如何对图像数据运行推断。因为这是我们走过的第三个示例,这段代码应该感觉相当熟悉。您已经在成为嵌入式 ML 开发人员的道路上取得了很大进展!
基本流程
首先是person_detection_test.cc。我们首先引入模型需要的操作:
namespace tflite {
namespace ops {
namespace micro {
TfLiteRegistration* Register_DEPTHWISE_CONV_2D();
TfLiteRegistration* Register_CONV_2D();
TfLiteRegistration* Register_AVERAGE_POOL_2D();
} // namespace micro
} // namespace ops
} // namespace tflite
接下来,我们定义一个适合模型大小的张量区域。通常情况下,这个数字是通过试错确定的:
const int tensor_arena_size = 70 * 1024;
uint8_t tensor_arena[tensor_arena_size];
然后我们进行典型的设置工作,准备解释器运行,包括使用MicroMutableOpResolver注册必要的操作:
// Set up logging.
tflite::MicroErrorReporter micro_error_reporter;
tflite::ErrorReporter* error_reporter = µ_error_reporter;
// Map the model into a usable data structure. This doesn't involve any
// copying or parsing, it's a very lightweight operation.
const tflite::Model* model = ::tflite::GetModel(g_person_detect_model_data);
if (model->version() != TFLITE_SCHEMA_VERSION) {
error_reporter->Report(
"Model provided is schema version %d not equal "
"to supported version %d.\n",
model->version(), TFLITE_SCHEMA_VERSION);
}
// Pull in only the operation implementations we need.
tflite::MicroMutableOpResolver micro_mutable_op_resolver;
micro_mutable_op_resolver.AddBuiltin(
tflite::BuiltinOperator_DEPTHWISE_CONV_2D,
tflite::ops::micro::Register_DEPTHWISE_CONV_2D());
micro_mutable_op_resolver.AddBuiltin(tflite::BuiltinOperator_CONV_2D,
tflite::ops::micro::Register_CONV_2D());
micro_mutable_op_resolver.AddBuiltin(
tflite::BuiltinOperator_AVERAGE_POOL_2D,
tflite::ops::micro::Register_AVERAGE_POOL_2D());
// Build an interpreter to run the model with.
tflite::MicroInterpreter interpreter(model, micro_mutable_op_resolver,
tensor_arena, tensor_arena_size,
error_reporter);
interpreter.AllocateTensors();
我们的下一步是检查输入张量。我们检查它是否具有预期数量的维度,以及其维度是否适当:
// Get information about the memory area to use for the model's input.
TfLiteTensor* input = interpreter.input(0);
// Make sure the input has the properties we expect.
TF_LITE_MICRO_EXPECT_NE(nullptr, input);
TF_LITE_MICRO_EXPECT_EQ(4, input->dims->size);
TF_LITE_MICRO_EXPECT_EQ(1, input->dims->data[0]);
TF_LITE_MICRO_EXPECT_EQ(kNumRows, input->dims->data[1]);
TF_LITE_MICRO_EXPECT_EQ(kNumCols, input->dims->data[2]);
TF_LITE_MICRO_EXPECT_EQ(kNumChannels, input->dims->data[3]);
TF_LITE_MICRO_EXPECT_EQ(kTfLiteUInt8, input->type);
从中我们可以看到,输入技术上是一个 5D 张量。第一个维度只是包含一个元素的包装器。接下来的两个维度表示图像像素的行和列。最后一个维度保存用于表示每个像素的颜色通道的数量。
告诉我们预期维度的常量kNumRows、kNumCols和kNumChannels在model_settings.h中定义。它们看起来像这样:
constexpr int kNumCols = 96;
constexpr int kNumRows = 96;
constexpr int kNumChannels = 1;
如您所见,模型预计接受一个 96×96 像素的位图。图像将是灰度的,每个像素有一个颜色通道。
接下来在代码中,我们使用简单的for循环将测试图像复制到输入张量中:
// Copy an image with a person into the memory area used for the input.
const uint8_t* person_data = g_person_data;
for (int i = 0; i < input->bytes; ++i) {
input->data.uint8[i] = person_data[i];
}
存储图像数据的变量g_person_data由person_image_data.h定义。为了避免向存储库添加更多大文件,数据本身会在首次运行测试时作为tf_lite_micro_person_data_grayscale.zip的一部分与模型一起下载。
在我们填充了输入张量之后,我们运行推断。这和以往一样简单:
// Run the model on this input and make sure it succeeds.
TfLiteStatus invoke_status = interpreter.Invoke();
if (invoke_status != kTfLiteOk) {
error_reporter->Report("Invoke failed\n");
}
TF_LITE_MICRO_EXPECT_EQ(kTfLiteOk, invoke_status);
现在我们检查输出张量,确保它具有预期的大小和形状:
TfLiteTensor* output = interpreter.output(0);
TF_LITE_MICRO_EXPECT_EQ(4, output->dims->size);
TF_LITE_MICRO_EXPECT_EQ(1, output->dims->data[0]);
TF_LITE_MICRO_EXPECT_EQ(1, output->dims->data[1]);
TF_LITE_MICRO_EXPECT_EQ(1, output->dims->data[2]);
TF_LITE_MICRO_EXPECT_EQ(kCategoryCount, output->dims->data[3]);
TF_LITE_MICRO_EXPECT_EQ(kTfLiteUInt8, output->type);
模型的输出有四个维度。前三个只是包装器,围绕第四个维度,其中包含模型训练的每个类别的一个元素。
类别的总数作为常量kCategoryCount可用,它位于model_settings.h中,还有一些其他有用的值:
constexpr int kCategoryCount = 3;
constexpr int kPersonIndex = 1;
constexpr int kNotAPersonIndex = 2;
extern const char* kCategoryLabels[kCategoryCount];
正如kCategoryCount所示,输出中有三个类别。第一个恰好是一个未使用的类别,我们可以忽略。“人”类别排在第二位,我们可以从常量kPersonIndex中存储的索引中看到。“不是人”类别排在第三位,其索引由kNotAPersonIndex显示。
还有一个类别标签数组kCategoryLabels,在model_settings.cc中实现:
const char* kCategoryLabels[kCategoryCount] = {
"unused",
"person",
"notperson",
};
接下来的代码块记录“人”和“非人”分数,并断言“人”分数更高——因为我们传入的是一个人的图像:
uint8_t person_score = output->data.uint8[kPersonIndex];
uint8_t no_person_score = output->data.uint8[kNotAPersonIndex];
error_reporter->Report(
"person data. person score: %d, no person score: %d\n", person_score,
no_person_score);
TF_LITE_MICRO_EXPECT_GT(person_score, no_person_score);
由于输出张量的唯一数据内容是表示类别分数的三个uint8值,第一个值未使用,我们可以通过output->data.uint8[kPersonIndex]和output->data.uint8[kNotAPersonIndex]直接访问分数。作为uint8类型,它们的最小值为 0,最大值为 255。
注意
如果“人”和“非人”分数相似,这可能意味着模型对其预测不太有信心。在这种情况下,您可能选择考虑结果不确定。
接下来,我们测试没有人的图像,由g_no_person_data持有:
const uint8_t* no_person_data = g_no_person_data;
for (int i = 0; i < input->bytes; ++i) {
input->data.uint8[i] = no_person_data[i];
}
推理运行后,我们断言“非人”分数更高:
person_score = output->data.uint8[kPersonIndex];
no_person_score = output->data.uint8[kNotAPersonIndex];
error_reporter->Report(
"no person data. person score: %d, no person score: %d\n", person_score,
no_person_score);
TF_LITE_MICRO_EXPECT_GT(no_person_score, person_score);
正如您所看到的,这里没有什么花哨的东西。我们可能正在输入图像而不是标量或频谱图,但推理过程与我们以前看到的类似。
运行测试同样简单。只需从 TensorFlow 存储库的根目录发出以下命令:
make -f tensorflow/lite/micro/tools/make/Makefile \
test_person_detection_test
第一次运行测试时,将下载模型和图像数据。如果您想查看已下载的文件,可以在tensorflow/lite/micro/tools/make/downloads/person_model_grayscale中找到它们。
接下来,我们检查图像提供程序的接口。
图像提供程序
图像提供程序负责从摄像头获取数据,并以适合写入模型输入张量的格式返回数据。文件image_provider.h定义了其接口:
TfLiteStatus GetImage(tflite::ErrorReporter* error_reporter, int image_width,
int image_height, int channels, uint8_t* image_data);
由于其实际实现是特定于平台的,因此在person_detection/image_provider.cc中有一个返回虚拟数据的参考实现。
image_provider_test.cc中的测试调用此参考实现以展示其用法。我们的首要任务是创建一个数组来保存图像数据。这发生在以下行中:
uint8_t image_data[kMaxImageSize];
常量kMaxImageSize来自我们的老朋友model_settings.h。
设置了这个数组后,我们可以调用GetImage()函数从摄像头捕获图像:
TfLiteStatus get_status =
GetImage(error_reporter, kNumCols, kNumRows, kNumChannels, image_data);
TF_LITE_MICRO_EXPECT_EQ(kTfLiteOk, get_status);
TF_LITE_MICRO_EXPECT_NE(image_data, nullptr);
我们使用ErrorReporter实例、我们想要的列数、行数和通道数以及指向我们的image_data数组的指针来调用它。该函数将把图像数据写入此数组。我们可以检查函数的返回值来确定捕获过程是否成功;如果有问题,它将设置为kTfLiteError,否则为kTfLiteOk。
最后,测试通过返回的数据以显示所有内存位置都是可读的。即使图像在技术上具有行、列和通道,但实际上数据被展平为一维数组:
uint32_t total = 0;
for (int i = 0; i < kMaxImageSize; ++i) {
total += image_data[i];
}
要运行此测试,请使用以下命令:
make -f tensorflow/lite/micro/tools/make/Makefile \
test_image_provider_test
我们将在本章后面查看image_provider.cc的特定于设备的实现;现在,让我们看一下检测响应器的接口。
检测响应器
我们的最终测试展示了检测响应器的使用方式。这是负责传达推理结果的代码。其接口在detection_responder.h中定义,测试在detection_responder_test.cc中。
接口非常简单:
void RespondToDetection(tflite::ErrorReporter* error_reporter,
uint8_t person_score, uint8_t no_person_score);
我们只需使用“人”和“非人”类别的分数调用它,它将根据情况决定要做什么。
detection_responder.cc中的参考实现只是记录这些值。detection_responder_test.cc中的测试调用该函数几次:
RespondToDetection(error_reporter, 100, 200);
RespondToDetection(error_reporter, 200, 100);
要运行测试并查看输出,请使用以下命令:
make -f tensorflow/lite/micro/tools/make/Makefile \
test_detection_responder_test
我们已经探索了所有测试和它们所练习的接口。现在让我们走一遍程序本身。
检测人员
应用程序的核心功能位于main_functions.cc中。它们简短而简洁,我们在测试中已经看到了它们的大部分逻辑。
首先,我们引入模型所需的所有操作:
namespace tflite {
namespace ops {
namespace micro {
TfLiteRegistration* Register_DEPTHWISE_CONV_2D();
TfLiteRegistration* Register_CONV_2D();
TfLiteRegistration* Register_AVERAGE_POOL_2D();
} // namespace micro
} // namespace ops
} // namespace tflite
接下来,我们声明一堆变量来保存重要的移动部件:
tflite::ErrorReporter* g_error_reporter = nullptr;
const tflite::Model* g_model = nullptr;
tflite::MicroInterpreter* g_interpreter = nullptr;
TfLiteTensor* g_input = nullptr;
之后,我们为张量操作分配一些工作内存:
constexpr int g_tensor_arena_size = 70 * 1024;
static uint8_t tensor_arena[kTensorArenaSize];
在setup()函数中,在任何其他操作发生之前运行,我们创建一个错误报告器,加载我们的模型,设置一个解释器实例,并获取模型输入张量的引用:
void setup() {
// Set up logging.
static tflite::MicroErrorReporter micro_error_reporter;
g_error_reporter = µ_error_reporter;
// Map the model into a usable data structure. This doesn't involve any
// copying or parsing, it's a very lightweight operation.
g_model = tflite::GetModel(g_person_detect_model_data);
if (g_model->version() != TFLITE_SCHEMA_VERSION) {
g_error_reporter->Report(
"Model provided is schema version %d not equal "
"to supported version %d.",
g_model->version(), TFLITE_SCHEMA_VERSION);
return;
}
// Pull in only the operation implementations we need.
static tflite::MicroMutableOpResolver micro_mutable_op_resolver;
micro_mutable_op_resolver.AddBuiltin(
tflite::BuiltinOperator_DEPTHWISE_CONV_2D,
tflite::ops::micro::Register_DEPTHWISE_CONV_2D());
micro_mutable_op_resolver.AddBuiltin(tflite::BuiltinOperator_CONV_2D,
tflite::ops::micro::Register_CONV_2D());
micro_mutable_op_resolver.AddBuiltin(
tflite::BuiltinOperator_AVERAGE_POOL_2D,
tflite::ops::micro::Register_AVERAGE_POOL_2D());
// Build an interpreter to run the model with.
static tflite::MicroInterpreter static_interpreter(
model, micro_mutable_op_resolver, tensor_arena, kTensorArenaSize,
error_reporter);
interpreter = &static_interpreter;
// Allocate memory from the tensor_arena for the model's tensors.
TfLiteStatus allocate_status = interpreter->AllocateTensors();
if (allocate_status != kTfLiteOk) {
error_reporter->Report("AllocateTensors() failed");
return;
}
// Get information about the memory area to use for the model's input.
input = interpreter->input(0);
}
代码的下一部分在程序的主循环中被不断调用。它首先使用图像提供程序获取图像,通过传递一个输入张量的引用,使图像直接写入其中:
void loop() {
// Get image from provider.
if (kTfLiteOk != GetImage(g_error_reporter, kNumCols, kNumRows, kNumChannels,
g_input->data.uint8)) {
g_error_reporter->Report("Image capture failed.");
}
然后运行推理,获取输出张量,并从中读取“人”和“无人”分数。这些分数被传递到检测响应器的RespondToDetection()函数中:
// Run the model on this input and make sure it succeeds.
if (kTfLiteOk != g_interpreter->Invoke()) {
g_error_reporter->Report("Invoke failed.");
}
TfLiteTensor* output = g_interpreter->output(0);
// Process the inference results.
uint8_t person_score = output->data.uint8[kPersonIndex];
uint8_t no_person_score = output->data.uint8[kNotAPersonIndex];
RespondToDetection(g_error_reporter, person_score, no_person_score);
}
在RespondToDetection()完成输出结果后,loop()函数将返回,准备好被程序的主循环再次调用。
循环本身在程序的main()函数中定义,该函数位于main.cc中。它一次调用setup()函数,然后重复调用loop()函数,直到无限循环:
int main(int argc, char* argv[]) {
setup();
while (true) {
loop();
}
}
这就是整个程序!这个例子很棒,因为它表明与复杂的机器学习模型一起工作可以出奇地简单。模型包含了所有的复杂性,我们只需要提供数据给它。
在我们继续之前,您可以在本地运行程序进行尝试。图像提供程序的参考实现只返回虚拟数据,因此您不会得到有意义的识别结果,但至少可以看到代码在运行。
首先,使用以下命令构建程序:
make -f tensorflow/lite/micro/tools/make/Makefile person_detection
构建完成后,您可以使用以下命令运行示例:
tensorflow/lite/micro/tools/make/gen/osx_x86_64/bin/ \
person_detection
您会看到程序的输出在屏幕上滚动,直到按下 Ctrl-C 终止它:
person score:129 no person score 202
person score:129 no person score 202
person score:129 no person score 202
person score:129 no person score 202
person score:129 no person score 202
person score:129 no person score 202
在接下来的部分中,我们将详细介绍特定设备的代码,该代码将捕获摄像头图像并在每个平台上输出结果。我们还展示了如何部署和运行此代码。
部署到微控制器
在这一部分中,我们将代码部署到两个熟悉的设备上:
这次有一个很大的不同:因为这两个设备都没有内置摄像头,我们建议您为您使用的任何设备购买摄像头模块。每个设备都有自己的image_provider.cc实现,它与摄像头模块进行接口,以捕获图像。detection_responder.cc中还有特定于设备的输出代码。
这很简单,所以它将是一个很好的模板,用来创建你自己的基于视觉的 ML 应用程序。
让我们开始探索 Arduino 的实现。
Arduino
作为 Arduino 板,Arduino Nano 33 BLE Sense 可以访问大量兼容的第三方硬件和库的生态系统。我们使用了一个专为与 Arduino 配合使用而设计的第三方摄像头模块,以及一些 Arduino 库,这些库将与我们的摄像头模块进行接口,并理解其输出的数据。
要购买哪种摄像头模块
这个例子使用Arducam Mini 2MP Plus摄像头模块。它很容易连接到 Arduino Nano 33 BLE Sense,并且可以由 Arduino 板的电源供应提供电力。它有一个大镜头,能够捕获高质量的 200 万像素图像 - 尽管我们将使用其内置的图像重缩放功能来获得较小的分辨率。它并不特别节能,但其高质量的图像使其非常适合构建图像捕获应用程序,比如用于记录野生动物。
在 Arduino 上捕获图像
我们通过一些引脚将 Arducam 模块连接到 Arduino 板。为了获取图像数据,我们从 Arduino 板向 Arducam 发送一个命令,指示它捕获图像。Arducam 将执行此操作,将图像存储在其内部数据缓冲区中。然后,我们发送进一步的命令,允许我们从 Arducam 的内部缓冲区中读取图像数据并将其存储在 Arduino 的内存中。为了执行所有这些操作,我们使用官方的 Arducam 库。
Arducam 相机模块具有一颗 200 万像素的图像传感器,分辨率为 1920×1080。我们的人体检测模型的输入尺寸仅为 96×96,因此我们不需要所有这些数据。事实上,Arduino 本身没有足够的内存来容纳一张 200 万像素的图像,其大小将达到几兆字节。
幸运的是,Arducam 硬件具有将输出调整为更小分辨率的能力,即 160×120 像素。我们可以通过在代码中仅保留中心的 96×96 像素来轻松将其裁剪为 96×96。然而,为了复杂化问题,Arducam 的调整大小输出使用了 JPEG,这是一种常见的图像压缩格式。我们的模型需要一个像素数组,而不是一个 JPEG 编码的图像,这意味着我们需要在使用之前解码 Arducam 的输出。我们可以使用一个开源库来实现这一点。
我们的最后任务是将 Arducam 的彩色图像输出转换为灰度,这是我们的人体检测模型所期望的。我们将灰度数据写入我们模型的输入张量。
图像提供程序实现在arduino/image_provider.cc中。我们不会解释其每个细节,因为代码是特定于 Arducam 相机模块的。相反,让我们以高层次的方式来看一下发生了什么。
GetImage()函数是图像提供程序与外部世界的接口。在我们的应用程序主循环中调用它以获取一帧图像数据。第一次调用时,我们需要初始化相机。这通过调用InitCamera()函数来实现,如下所示:
static bool g_is_camera_initialized = false;
if (!g_is_camera_initialized) {
TfLiteStatus init_status = InitCamera(error_reporter);
if (init_status != kTfLiteOk) {
error_reporter->Report("InitCamera failed");
return init_status;
}
g_is_camera_initialized = true;
}
InitCamera()函数在image_provider.cc中进一步定义。我们不会在这里详细介绍它,因为它非常特定于设备,如果您想在自己的代码中使用它,只需复制粘贴即可。它配置 Arduino 的硬件以与 Arducam 通信,然后确认通信正常工作。最后,它指示 Arducam 输出 160×120 像素的 JPEG 图像。
GetImage()函数调用的下一个函数是PerformCapture():
TfLiteStatus capture_status = PerformCapture(error_reporter);
我们也不会详细介绍这个函数。它只是向相机模块发送一个命令,指示其捕获图像并将图像数据存储在其内部缓冲区中。然后,它等待确认图像已被捕获。此时,Arducam 的内部缓冲区中有图像数据,但 Arduino 本身还没有任何图像数据。
接下来我们调用的函数是ReadData():
TfLiteStatus read_data_status = ReadData(error_reporter);
ReadData()函数使用更多的命令从 Arducam 获取图像数据。函数运行后,全局变量jpeg_buffer将填充从相机检索到的 JPEG 编码图像数据。
当我们有 JPEG 编码的图像时,我们的下一步是将其解码为原始图像数据。这发生在DecodeAndProcessImage()函数中:
TfLiteStatus decode_status = DecodeAndProcessImage(
error_reporter, image_width, image_height, image_data);
该函数使用一个名为 JPEGDecoder 的库来解码 JPEG 数据,并直接将其写入模型的输入张量。在此过程中,它裁剪图像,丢弃一些 160×120 的数据,使剩下的只有 96×96 像素,大致位于图像中心。它还将图像的 16 位颜色表示减少到 8 位灰度。
在图像被捕获并存储在输入张量中后,我们准备运行推理。接下来,我们展示模型的输出是如何显示的。
在 Arduino 上响应检测
Arduino Nano 33 BLE Sense 内置了 RGB LED,这是一个包含独立红色、绿色和蓝色 LED 的单一组件,您可以分别控制它们。检测响应器的实现在每次推理运行时闪烁蓝色 LED。当检测到人时,点亮绿色 LED;当未检测到人时,点亮红色 LED。
实现在arduino/detection_responder.cc中。让我们快速浏览一下。
RespondToDetection()函数接受两个分数,一个用于“人”类别,另一个用于“非人”。第一次调用时,它设置蓝色、绿色和黄色 LED 为输出:
void RespondToDetection(tflite::ErrorReporter* error_reporter,
uint8_t person_score, uint8_t no_person_score) {
static bool is_initialized = false;
if (!is_initialized) {
pinMode(led_green, OUTPUT);
pinMode(led_blue, OUTPUT);
is_initialized = true;
}
接下来,为了指示推理刚刚完成,我们关闭所有 LED,然后非常简要地闪烁蓝色 LED:
// Note: The RGB LEDs on the Arduino Nano 33 BLE
// Sense are on when the pin is LOW, off when HIGH.
// Switch the person/not person LEDs off
digitalWrite(led_green, HIGH);
digitalWrite(led_red, HIGH);
// Flash the blue LED after every inference.
digitalWrite(led_blue, LOW);
delay(100);
digitalWrite(led_blue, HIGH);
您会注意到,与 Arduino 内置 LED 不同,这些 LED 使用LOW打开,使用HIGH关闭。这只是 LED 连接到板上的方式的一个因素。
接下来,我们根据哪个类别的分数更高来打开和关闭适当的 LED:
// Switch on the green LED when a person is detected,
// the red when no person is detected
if (person_score > no_person_score) {
digitalWrite(led_green, LOW);
digitalWrite(led_red, HIGH);
} else {
digitalWrite(led_green, HIGH);
digitalWrite(led_red, LOW);
}
最后,我们使用error_reporter实例将分数输出到串行端口:
error_reporter->Report("Person score: %d No person score: %d", person_score,
no_person_score);
}
就是这样!函数的核心是一个基本的if语句,您可以轻松使用类似的逻辑来控制其他类型的输出。将如此复杂的视觉输入转换为一个布尔输出“人”或“非人”是非常令人兴奋的事情。
运行示例
运行此示例比我们其他 Arduino 示例更复杂,因为我们需要将 Arducam 连接到 Arduino 板。我们还需要安装和配置与 Arducam 接口并解码其 JPEG 输出的库。但不用担心,这仍然非常简单!
要部署此示例,我们需要以下内容:
-
一个 Arduino Nano 33 BLE Sense 板
-
一个 Arducam Mini 2MP Plus
-
跳线(和可选的面包板)
-
一根 Micro-USB 电缆
-
Arduino IDE
我们的第一个任务是使用跳线连接 Arducam 到 Arduino。这不是一本电子书,所以我们不会详细介绍使用电缆的细节。相反,表 9-1 显示了引脚应该如何连接。每个设备上都标有引脚标签。
表 9-1。Arducam Mini 2MP Plus 到 Arduino Nano 33 BLE Sense 的连接
| Arducam 引脚 | Arduino 引脚 |
|---|---|
| CS | D7(未标记,紧挨 D6 右侧) |
| MOSI | D11 |
| MISO | D12 |
| SCK | D13 |
| GND | GND(任何一个标记为 GND 的引脚都可以) |
| VCC | 3.3 V |
| SDA | A4 |
| SCL | A5 |
设置硬件后,您可以继续安装软件。
提示
建立过程可能会有所变化,所以请查看README.md获取最新说明。
本书中的项目作为 TensorFlow Lite Arduino 库中的示例代码可用。如果您尚未安装该库,请打开 Arduino IDE 并从“工具”菜单中选择“管理库”。在弹出的窗口中,搜索并安装名为Arduino_TensorFlowLite的库。您应该能够使用最新版本,但如果遇到问题,本书测试过的版本是 1.14-ALPHA。
注意
您还可以从.zip文件安装库,您可以从 TensorFlow Lite 团队下载或使用 TensorFlow Lite for Microcontrollers Makefile 自动生成。如果您更喜欢后者,请参阅附录 A。
安装完库后,person_detection示例将显示在“文件”菜单下的“示例→Arduino_TensorFlowLite”中,如图 9-2 所示。

图 9-2。示例菜单
点击“person_detection”加载示例。它将显示为一个新窗口,每个源文件都有一个选项卡。第一个选项卡中的文件person_detection相当于我们之前介绍的main_functions.cc。
注意
“运行示例”已经解释了 Arduino 示例的结构,所以我们这里不再重复覆盖。
除了 TensorFlow 库,我们还需要安装另外两个库:
-
Arducam 库,以便我们的代码可以与硬件进行交互
-
JPEGDecoder 库,以便我们可以解码 JPEG 编码的图像
Arducam Arduino 库可从GitHub获取。要安装它,请下载或克隆存储库。接下来,将其ArduCAM子目录复制到Arduino/libraries目录中。要找到您机器上的libraries目录,请在 Arduino IDE 的首选项窗口中检查 Sketchbook 位置。
下载库后,您需要编辑其中一个文件,以确保为 Arducam Mini 2MP Plus 进行配置。为此,请打开Arduino/libraries/ArduCAM/memorysaver.h。
您会看到一堆#define语句。确保它们都被注释掉,除了#define OV2640_MINI_2MP_PLUS,如此处所示:
//Step 1: select the hardware platform, only one at a time
//#define OV2640_MINI_2MP
//#define OV3640_MINI_3MP
//#define OV5642_MINI_5MP
//#define OV5642_MINI_5MP_BIT_ROTATION_FIXED
#define OV2640_MINI_2MP_PLUS
//#define OV5642_MINI_5MP_PLUS
//#define OV5640_MINI_5MP_PLUS
保存文件后,您已经完成了 Arducam 库的配置。
提示
示例是使用 Arducam 库的提交#e216049 开发的。如果您在使用库时遇到问题,可以尝试下载这个特定的提交,以确保您使用的是完全相同的代码。
下一步是安装 JPEGDecoder 库。您可以在 Arduino IDE 中完成这个操作。在工具菜单中,选择管理库选项并搜索 JPEGDecoder。您应该安装库的 1.8.0 版本。
安装完库之后,您需要配置它以禁用一些与 Arduino Nano 33 BLE Sense 不兼容的可选组件。打开Arduino/libraries/JPEGDecoder/src/User_Config.h,确保#define LOAD_SD_LIBRARY和#define LOAD_SDFAT_LIBRARY都被注释掉,如文件中的摘录所示:
// Comment out the next #defines if you are not using an SD Card to store
// the JPEGs
// Commenting out the line is NOT essential but will save some FLASH space if
// SD Card access is not needed. Note: use of SdFat is currently untested!
//#define LOAD_SD_LIBRARY // Default SD Card library
//#define LOAD_SDFAT_LIBRARY // Use SdFat library instead, so SD Card SPI can
// be bit bashed
保存文件后,安装库就完成了。现在您已经准备好运行人员检测应用程序了!
首先,通过 USB 将 Arduino 设备插入。确保在工具菜单中从板下拉列表中选择正确的设备类型,如图 9-3 所示。

图 9-3. 板下拉列表
如果您的设备名称不在列表中显示,您需要安装其支持包。要做到这一点,请点击 Boards Manager。在弹出的窗口中搜索您的设备并安装相应支持包的最新版本。
在工具菜单中,还要确保设备的端口在端口下拉列表中被选中,如图 9-4 所示。

图 9-4. 端口下拉列表
最后,在 Arduino 窗口中,点击上传按钮(在图 9-5 中用白色标出)来编译并上传代码到您的 Arduino 设备。

图 9-5. 上传按钮
一旦上传成功完成,程序将运行。
要测试它,首先将设备的摄像头对准明显不是人的东西,或者只是遮住镜头。下次蓝色 LED 闪烁时,设备将从摄像头捕获一帧并开始运行推理。由于我们用于人员检测的视觉模型相对较大,这将需要很长时间的推理——在撰写本文时大约需要 19 秒,尽管自那时起 TensorFlow Lite 可能已经变得更快。
当推断完成时,结果将被翻译为另一个 LED 被点亮。您将相机对准了一个不是人的东西,所以红色 LED 应该点亮。
现在,尝试将设备的相机对准自己!下次蓝色 LED 闪烁时,设备将捕获另一幅图像并开始运行推断。大约 19 秒后,绿色 LED 应该亮起。
请记住,在每次推断之前,图像数据都会被捕获为快照,每当蓝色 LED 闪烁时。在那一刻相机对准的东西将被馈送到模型中。在下一次捕获图像时,相机对准的位置并不重要,当蓝色 LED 再次闪烁时,图像将被捕获。
如果您得到看似不正确的结果,请确保您处于光线良好的环境中。您还应确保相机的方向正确,引脚朝下,以便捕获的图像是正确的方式——该模型没有经过训练以识别颠倒的人。此外,值得记住这是一个微小的模型,它以小尺寸换取准确性。它工作得非常好,但并非 100%准确。
您还可以通过 Arduino 串行监视器查看推断的结果。要做到这一点,请从“工具”菜单中打开串行监视器。您将看到一个详细的日志,显示应用程序运行时发生的情况。还有一个有趣的功能是勾选“显示时间戳”框,这样您就可以看到每个过程需要多长时间:
14:17:50.714 -> Starting capture
14:17:50.714 -> Image captured
14:17:50.784 -> Reading 3080 bytes from ArduCAM
14:17:50.887 -> Finished reading
14:17:50.887 -> Decoding JPEG and converting to greyscale
14:17:51.074 -> Image decoded and processed
14:18:09.710 -> Person score: 246 No person score: 66
从这个日志中,我们可以看到从相机模块捕获和读取图像数据大约需要 170 毫秒,解码 JPEG 并将其转换为灰度需要 180 毫秒,运行推断需要 18.6 秒。
进行自己的更改
现在您已部署了基本应用程序,请尝试玩耍并对代码进行一些更改。只需在 Arduino IDE 中编辑文件并保存,然后重复之前的说明以将修改后的代码部署到设备上。
以下是您可以尝试的几件事:
-
修改检测响应器,使其忽略模糊的输入,即“人”和“无人”得分之间没有太大差异的情况。
-
使用人员检测的结果来控制其他组件,如额外的 LED 或伺服。
-
构建一个智能安全摄像头,通过存储或传输图像来实现,但仅限于包含人物的图像。
SparkFun Edge
SparkFun Edge 板经过优化,以实现低功耗。当与同样高效的相机模块配对时,它是构建视觉应用程序的理想平台,这些应用程序将在电池供电时运行。通过板上的排线适配器轻松插入相机模块。
要购买哪种相机模块
此示例使用 SparkFun 的Himax HM01B0 分支相机模块。它基于一个 320×320 像素的图像传感器,当以每秒 30 帧的速度捕获时,消耗极少的功率:不到 2 mW。
在 SparkFun Edge 上捕获图像
要开始使用 Himax HM01B0 相机模块捕获图像,我们首先必须初始化相机。完成此操作后,我们可以在需要新图像时从相机读取一帧。一帧是一个表示相机当前所看到的内容的字节数组。
使用相机将涉及大量使用 Ambiq Apollo3 SDK 和 HM01B0 驱动程序,后者作为构建过程的一部分下载,位于sparkfun_edge/himax_driver中。
图像提供程序实现在sparkfun_edge/image_provider.cc中。我们不会解释其每个细节,因为代码是针对 SparkFun 板和 Himax 相机模块的。相反,让我们以高层次的方式来看看发生了什么。
GetImage() 函数是图像提供程序与世界的接口。它在我们的应用程序的主循环中被调用以获取一帧图像数据。第一次调用时,我们需要初始化摄像头。这通过调用 InitCamera() 函数来实现,如下所示:
// Capture single frame. Frame pointer passed in to reduce memory usage. This
// allows the input tensor to be used instead of requiring an extra copy.
TfLiteStatus GetImage(tflite::ErrorReporter* error_reporter, int frame_width,
int frame_height, int channels, uint8_t* frame) {
if (!g_is_camera_initialized) {
TfLiteStatus init_status = InitCamera(error_reporter);
if (init_status != kTfLiteOk) {
am_hal_gpio_output_set(AM_BSP_GPIO_LED_RED);
return init_status;
}
如果 InitCamera() 返回除了 kTfLiteOk 状态之外的任何内容,我们会打开板上的红色 LED(使用 am_hal_gpio_output_set(AM_BSP_GPIO_LED_RED))来指示问题。这对于调试很有帮助。
InitCamera() 函数在 image_provider.cc 中进一步定义。我们不会在这里详细介绍它,因为它非常特定于设备,如果您想在自己的代码中使用它,只需复制粘贴即可。
它调用一堆 Apollo3 SDK 函数来配置微控制器的输入和输出,以便它可以与摄像头模块通信。它还启用了中断,这是摄像头用来发送新图像数据的机制。当这一切设置完成后,它使用摄像头驱动程序打开摄像头,并配置它开始持续捕获图像。
摄像头模块具有自动曝光功能,它会在捕获帧时自动校准曝光设置。为了让它有机会在我们尝试执行推理之前校准,GetImage() 函数的下一部分使用摄像头驱动程序的 hm01b0_blocking_read_oneframe_scaled() 函数捕获几帧图像。我们不对捕获的数据做任何处理;我们只是为了让摄像头模块的自动曝光功能有一些材料可以使用:
// Drop a few frames until auto exposure is calibrated.
for (int i = 0; i < kFramesToInitialize; ++i) {
hm01b0_blocking_read_oneframe_scaled(frame, frame_width, frame_height,
channels);
}
g_is_camera_initialized = true;
}
设置完成后,GetImage() 函数的其余部分非常简单。我们只需调用 hm01b0_blocking_read_oneframe_scaled() 来捕获一幅图像:
hm01b0_blocking_read_oneframe_scaled(frame, frame_width, frame_height,
channels);
当应用程序的主循环中调用 GetImage() 时,frame 变量是指向我们输入张量的指针,因此数据直接由摄像头驱动程序写入到为输入张量分配的内存区域。我们还指定了我们想要的宽度、高度和通道数。
通过这个实现,我们能够从我们的摄像头模块中捕获图像数据。接下来,让我们看看如何响应模型的输出。
在 SparkFun Edge 上响应检测
检测响应器的实现与我们的唤醒词示例的命令响应器非常相似。每次运行推理时,它会切换设备的蓝色 LED。当检测到一个人时,它会点亮绿色 LED,当没有检测到一个人时,它会点亮黄色 LED。
实现在 sparkfun_edge/detection_responder.cc 中。让我们快速浏览一下。
RespondToDetection() 函数接受两个分数,一个用于“人”类别,另一个用于“非人”。第一次调用时,它会为蓝色、绿色和黄色 LED 设置输出:
void RespondToDetection(tflite::ErrorReporter* error_reporter,
uint8_t person_score, uint8_t no_person_score) {
static bool is_initialized = false;
if (!is_initialized) {
// Setup LED's as outputs. Leave red LED alone since that's an error
// indicator for sparkfun_edge in image_provider.
am_hal_gpio_pinconfig(AM_BSP_GPIO_LED_BLUE, g_AM_HAL_GPIO_OUTPUT_12);
am_hal_gpio_pinconfig(AM_BSP_GPIO_LED_GREEN, g_AM_HAL_GPIO_OUTPUT_12);
am_hal_gpio_pinconfig(AM_BSP_GPIO_LED_YELLOW, g_AM_HAL_GPIO_OUTPUT_12);
is_initialized = true;
}
因为该函数每次推理调用一次,所以下面的代码片段会导致它在每次执行推理时切换蓝色 LED 的开关:
// Toggle the blue LED every time an inference is performed.
static int count = 0;
if (++count & 1) {
am_hal_gpio_output_set(AM_BSP_GPIO_LED_BLUE);
} else {
am_hal_gpio_output_clear(AM_BSP_GPIO_LED_BLUE);
}
最后,如果检测到一个人,它会点亮绿色 LED,如果没有检测到一个人,它会点亮蓝色 LED。它还使用 ErrorReporter 实例记录分数:
am_hal_gpio_output_clear(AM_BSP_GPIO_LED_YELLOW);
am_hal_gpio_output_clear(AM_BSP_GPIO_LED_GREEN);
if (person_score > no_person_score) {
am_hal_gpio_output_set(AM_BSP_GPIO_LED_GREEN);
} else {
am_hal_gpio_output_set(AM_BSP_GPIO_LED_YELLOW);
}
error_reporter->Report("person score:%d no person score %d", person_score,
no_person_score);
就是这样!函数的核心是一个基本的 if 语句,你可以很容易地使用类似的逻辑来控制其他类型的输出。将如此复杂的视觉输入转换为一个布尔输出“人”或“非人”是非常令人兴奋的事情。
运行示例
现在我们已经看到了 SparkFun Edge 实现的工作原理,让我们开始运行它。
提示
由于本书编写时可能已更改构建过程,因此请查看 README.md 获取最新说明。
要构建和部署我们的代码,我们需要以下内容:
-
带有 Himax HM01B0 breakout 的 SparkFun Edge 开发板
-
一个 USB 编程器(我们推荐 SparkFun 串行基础分支,可在micro-B USB和USB-C变种中获得)
-
一根匹配的 USB 电缆
-
Python 3 和一些依赖项
注意
如果您不确定是否安装了正确版本的 Python,请参考“运行示例”中的说明进行检查。
在终端中,克隆 TensorFlow 存储库并切换到其目录:
git clone https://github.com/tensorflow/tensorflow.git
cd tensorflow
接下来,我们将构建二进制文件并运行一些命令,使其准备好下载到设备上。为了避免一些打字,您可以从README.md中复制并粘贴这些命令。
构建二进制文件
以下命令下载所有必需的依赖项,然后为 SparkFun Edge 编译一个二进制文件:
make -f tensorflow/lite/micro/tools/make/Makefile \
TARGET=sparkfun_edge person_detection_bin
二进制文件被创建为.bin文件,位于以下位置:
tensorflow/lite/micro/tools/make/gen/
sparkfun_edge_cortex-m4/bin/person_detection.bin
要检查文件是否存在,可以使用以下命令:
test -f tensorflow/lite/micro/tools/make/gen \
/sparkfun_edge_cortex-m4/bin/person_detection.bin \
&& echo "Binary was successfully created" || echo "Binary is missing"
当您运行该命令时,您应该看到Binary was successfully created打印到控制台。
如果看到Binary is missing,则构建过程中出现问题。如果是这样,很可能在make命令的输出中有一些线索指出出了什么问题。
对二进制文件进行签名
必须使用加密密钥对二进制文件进行签名,才能部署到设备上。现在让我们运行一些命令,对二进制文件进行签名,以便可以刷写到 SparkFun Edge 上。这里使用的脚本来自 Ambiq SDK,在运行 Makefile 时下载。
输入以下命令设置一些虚拟的加密密钥,供开发使用:
cp tensorflow/lite/micro/tools/make/downloads/AmbiqSuite-Rel2.0.0 \
/tools/apollo3_scripts/keys_info0.py \
tensorflow/lite/micro/tools/make/downloads/AmbiqSuite-Rel2.0.0 \
/tools/apollo3_scripts/keys_info.py
接下来,运行以下命令创建一个已签名的二进制文件。如果需要,将python3替换为python:
python3 tensorflow/lite/micro/tools/make/downloads/ \
AmbiqSuite-Rel2.0.0/tools/apollo3_scripts/create_cust_image_blob.py \
--bin tensorflow/lite/micro/tools/make/gen/ \
sparkfun_edge_cortex-m4/bin/person_detection.bin \
--load-address 0xC000 \
--magic-num 0xCB \
-o main_nonsecure_ota \
--version 0x0
这将创建文件main_nonsecure_ota.bin。现在运行此命令创建文件的最终版本,您可以使用该文件刷写设备,使用下一步中将使用的脚本:
python3 tensorflow/lite/micro/tools/make/downloads/ \
AmbiqSuite-Rel2.0.0/tools/apollo3_scripts/create_cust_wireupdate_blob.py \
--load-address 0x20000 \
--bin main_nonsecure_ota.bin \
-i 6 \
-o main_nonsecure_wire \
--options 0x1
现在您应该在运行命令的目录中有一个名为main_nonsecure_wire.bin的文件。这是您将要刷写到设备的文件。
刷写二进制文件
SparkFun Edge 将当前运行的程序存储在其 1 兆字节的闪存中。如果您希望板运行新程序,您需要将其发送到板上,该程序将存储在闪存中,覆盖先前保存的任何程序。
正如我们在本书中早些时候提到的,这个过程被称为刷写。
将编程器连接到板上
要下载新程序到板上,您将使用 SparkFun USB-C 串行基础串行编程器。该设备允许您的计算机通过 USB 与微控制器通信。
将此设备连接到您的板上,执行以下步骤:
-
在 SparkFun Edge 的一侧,找到六针排针。
-
将 SparkFun USB-C 串行基础插入这些引脚,确保每个设备上标记为 BLK 和 GRN 的引脚正确对齐,如图 9-6 所示。

图 9-6. 连接 SparkFun Edge 和 USB-C 串行基础(由 SparkFun 提供)
将编程器连接到计算机
通过 USB 将板连接到计算机。要对板进行编程,您需要找出计算机给设备的名称。最好的方法是在连接设备之前和之后列出所有计算机的设备,然后查看哪个设备是新的。
警告
一些人报告了他们操作系统的默认驱动程序与编程器存在问题,因此我们强烈建议在继续之前安装驱动程序。
在通过 USB 连接设备之前,运行以下命令:
# macOS:
ls /dev/cu*
# Linux:
ls /dev/tty*
这应该输出一个附加设备列表,看起来像以下内容:
/dev/cu.Bluetooth-Incoming-Port
/dev/cu.MALS
/dev/cu.SOC
现在,将编程器连接到计算机的 USB 端口,并再次运行以下命令:
# macOS:
ls /dev/cu*
# Linux:
ls /dev/tty*
您应该在输出中看到一个额外的项目,如下例所示。您的新项目可能有不同的名称。这个新项目是设备的名称:
/dev/cu.Bluetooth-Incoming-Port
/dev/cu.MALS
/dev/cu.SOC
/dev/cu.wchusbserial-1450
这个名称将用于引用设备。但是,它可能会根据编程器连接的 USB 端口而变化,因此如果您将板子从计算机断开然后重新连接,可能需要再次查找其名称。
提示
一些用户报告列表中出现了两个设备。如果看到两个设备,则要使用的正确设备以“wch”开头;例如,/dev/wchusbserial-14410.
在确定设备名称后,将其放入一个 shell 变量以备后用:
export DEVICENAME=<*your device name here*>
这是在后续过程中运行需要设备名称的命令时可以使用的变量。
运行脚本以刷写您的板子
要刷写板子,您需要将其置于特殊的“引导加载程序”状态,以准备接收新的二进制文件。然后,您将运行一个脚本将二进制文件发送到板子。
首先创建一个环境变量来指定波特率,即数据发送到设备的速度:
export BAUD_RATE=921600
现在将以下命令粘贴到终端中,但不要立即按 Enter!命令中的${DEVICENAME}和${BAUD_RATE}将被替换为您在前面部分设置的值。如有必要,请记得将python3替换为python。
python3 tensorflow/lite/micro/tools/make/downloads/ \
AmbiqSuite-Rel2.0.0/tools/apollo3_scripts/uart_wired_update.py -b \
${BAUD_RATE} ${DEVICENAME} -r 1 -f main_nonsecure_wire.bin -i 6
接下来,您将重置板子到引导加载程序状态并刷写板子。在板子上,找到标有RST和14的按钮,如图 9-7 所示。
执行以下步骤:
-
确保您的板子连接到编程器,并且整个设备通过 USB 连接到计算机。
-
在板子上,按住标有
14的按钮。继续按住它。 -
在仍按住标有
14的按钮的情况下,按下标有RST的按钮重置板子。 -
在计算机上按 Enter 运行脚本。继续按住按钮
14。

图 9-7. SparkFun Edge 的按钮
您现在应该在屏幕上看到类似以下内容:
Connecting with Corvette over serial port /dev/cu.usbserial-1440...
Sending Hello.
Received response for Hello
Received Status
length = 0x58
version = 0x3
Max Storage = 0x4ffa0
Status = 0x2
State = 0x7
AMInfo =
0x1
0xff2da3ff
0x55fff
0x1
0x49f40003
0xffffffff
[...lots more 0xffffffff...]
Sending OTA Descriptor = 0xfe000
Sending Update Command.
number of updates needed = 1
Sending block of size 0x158b0 from 0x0 to 0x158b0
Sending Data Packet of length 8180
Sending Data Packet of length 8180
[...lots more Sending Data Packet of length 8180...]
继续按住按钮14,直到看到Sending Data Packet of length 8180。在看到此信息后可以释放按钮(但如果继续按住也没关系)。
程序将继续在终端上打印行。最终,您会看到类似以下内容:
[...lots more Sending Data Packet of length 8180...]
Sending Data Packet of length 8180
Sending Data Packet of length 6440
Sending Reset Command.
Done.
这表示刷写成功。
提示
如果程序输出以错误结束,请检查是否打印了Sending Reset Command.。如果是,则尽管出现错误,刷写可能已成功。否则,刷写可能失败。尝试再次运行这些步骤(您可以跳过设置环境变量)。
测试程序
首先按下RST按钮,确保程序正在运行。
当程序运行时,蓝色 LED 将交替闪烁,每次推理一次。由于我们用于人员检测的视觉模型相对较大,运行推理需要很长时间——总共约 6 秒。
首先将设备的摄像头对准绝对不是人的东西,或者只是遮住镜头。下一次蓝色 LED 切换时,设备将从摄像头捕获一帧并开始运行推理。大约 6 秒后,推理结果将被转换为另一个 LED 点亮。鉴于您将摄像头对准的不是人,橙色 LED 应该点亮。
现在,尝试将设备的摄像头对准自己。下一次蓝色 LED 切换时,设备将捕获另一帧并开始运行推理。这次,绿色 LED 应该点亮。
请记住,在每次推理之前,图像数据都会被捕获为快照,每当蓝色 LED 切换时。在那一刻摄像头对准的东西将被输入模型。在下一次捕获帧时,摄像头对准的位置并不重要,蓝色 LED 将再次切换。
如果您得到看似不正确的结果,请确保您处于光线良好的环境中。还要记住,这是一个小模型,它以精度换取了小尺寸。它工作得非常好,但并非始终 100%准确。
查看调试数据
该程序将检测结果记录到串行端口。要查看它们,我们可以使用波特率为 115200 监视板的串行端口输出。在 macOS 和 Linux 上,以下命令应该有效:
screen ${DEVICENAME} 115200
您应该最初看到类似以下内容的输出:
Apollo3 Burst Mode is Available
Apollo3 operating in Burst Mode (96MHz)
当板捕获帧并运行推断时,您应该看到它打印调试信息:
Person score: 130 No person score: 204
Person score: 220 No person score: 87
要停止使用screen查看调试输出,请按 Ctrl-A,紧接着按 K 键,然后按 Y 键。
进行您自己的更改
现在您已经部署了基本应用程序,请尝试玩耍并进行一些更改。您可以在tensorflow/lite/micro/examples/person_detection文件夹中找到应用程序的代码。只需编辑并保存,然后重复前面的说明以将修改后的代码部署到设备上。
以下是您可以尝试的一些事项:
-
修改检测响应器,使其忽略模糊的输入,即“人”和“无人”得分之间没有太大差异的情况。
-
利用人员检测结果来控制其他组件,如额外的 LED 或伺服。
-
构建一个智能安全摄像头,通过存储或传输图像来检测只包含人的图像。
总结
我们在本章中使用的视觉模型是一件了不起的事情。它接受原始且混乱的输入,无需预处理,并为我们提供一个非常简单的输出:是,有人在场,还是,没有人在场。这就是机器学习的魔力:它可以从噪音中过滤信息,留下我们关心的信号。作为开发者,我们可以轻松使用这些信号为用户构建令人惊叹的体验。
在构建机器学习应用程序时,很常见使用像这样的预训练模型,这些模型已经包含执行任务所需的知识。模型大致相当于代码库,封装了特定功能,并且可以在项目之间轻松共享。您经常会发现自己在探索和评估模型,寻找适合您任务的合适模型。
在第十章中,我们将探讨人员检测模型的工作原理。您还将学习如何训练自己的视觉模型来识别不同类型的对象。
在2018 年 YouGov 民意调查中,70%的受访者表示,如果失去视力,他们会最怀念视觉。
第十章:人物检测:训练模型
在第九章中,我们展示了如何部署一个用于识别图像中人物的预训练模型,但我们没有解释该模型来自何处。如果您的产品有不同的要求,您将希望能够训练自己的版本,本章将解释如何做到这一点。
选择一台机器
训练这个图像模型需要比我们之前的示例更多的计算资源,因此如果您希望训练在合理的时间内完成,您需要使用一台配备高端图形处理单元(GPU)的机器。除非您期望运行大量训练作业,我们建议您首先租用云实例而不是购买特殊的机器。不幸的是,我们在之前章节中用于较小模型的免费 Colaboratory 服务将不起作用,您需要支付访问机器的费用。有许多优秀的提供商可供选择,但我们的说明将假定您正在使用谷歌云平台,因为这是我们最熟悉的服务。如果您已经在使用亚马逊网络服务(AWS)或微软 Azure,它们也支持 TensorFlow,并且训练说明应该是相同的,但您需要按照它们的教程设置机器。
设置谷歌云平台实例
您可以从谷歌云平台租用一个预安装了 TensorFlow 和 NVIDIA 驱动程序的虚拟机,并支持 Jupyter Notebook 网络界面,这可能非常方便。不过,设置这一点可能有点复杂。截至 2019 年 9 月,以下是您需要执行的步骤来创建一台机器:
-
登录console.cloud.google.com。如果您还没有谷歌账号,您需要创建一个,您还需要设置计费来支付您创建的实例。如果您还没有项目,您需要创建一个。
-
在屏幕的左上角,打开汉堡菜单(带有三条水平线的主菜单图标,如#ai_platform_menu 所示),向下滚动直到找到人工智能部分。
-
在此部分中,选择 AI 平台→笔记本,如#ai_platform_menu 所示。
![AI 平台菜单]()
图 10-1。AI 平台菜单
-
您可能会看到一个提示,要求您启用计算引擎 API 以继续,如#api_enable 所示;请继续批准。这可能需要几分钟的时间。
![计算引擎 API]()
图 10-2。计算引擎 API 界面
-
将打开一个“笔记本实例”屏幕。在顶部的菜单栏中,选择 NEW INSTANCE。在打开的子菜单中,选择“自定义实例”,如#new_instance 所示。
![实例创建菜单]()
图 10-3。实例创建菜单
-
在“新笔记本实例”页面上,在“实例名称”框中,为您的机器命名,如#instance_naming 所示,然后向下滚动设置环境。
![实例命名界面]()
图 10-4。命名界面
-
截至 2019 年 9 月,选择的正确 TensorFlow 版本是 TensorFlow 1.14。推荐的版本可能在您阅读本文时已经增加到 2.0 或更高,但可能存在一些不兼容性,因此如果可能的话,请从选择 1.14 或 1.x 分支的其他版本开始。
-
在“机器配置”部分,选择至少 4 个 CPU 和 15GB 的 RAM,如#system_setup 所示。
![CPU 和版本界面]()
图 10-5。CPU 和版本界面
-
选择正确的 GPU 将在训练速度上产生最大的差异。这可能有些棘手,因为并非所有区域都提供相同类型的硬件。在我们的情况下,我们使用“us-west1(俄勒冈州)”作为地区,“us-west-1b”作为区域,因为我们知道它们目前提供高端 GPU。您可以使用Google Cloud Platform 的定价计算器获取详细的定价信息,但在本例中,我们选择了一块 NVIDIA Tesla V100 GPU,如图 10-6 所示。这每月花费 1300 美元,但可以让我们在大约一天内训练人员检测器模型,因此模型训练成本约为 45 美元。
![GPU 选择界面]()
图 10-6。GPU 选择界面
提示
这些高端机器的运行成本很高,因此请确保在不使用训练时停止实例。否则,您将为一个空闲的机器付费。
-
自动安装 GPU 驱动程序会让生活变得更轻松,因此请确保选择该选项,如图 10-7 所示。
![GPU 驱动程序界面]()
图 10-7。GPU 驱动程序界面
-
因为您将在这台机器上下载数据集,我们建议将引导磁盘大小调整为比默认的 100GB 大一些;也许大到 500GB,如图 10-8 所示。
![引导磁盘大小]()
图 10-8。增加引导磁盘大小
-
当您设置好所有这些选项后,在页面底部点击 CREATE 按钮,这将返回到“笔记本实例”屏幕。列表中应该有一个新的实例,名称与您给机器的名称相同。在实例设置完成时,列表旁边会有旋转器几分钟。设置完成后,点击打开 JUPYTERLAB 链接,如图 10-9 所示。
![实例屏幕]()
图 10-9。实例屏幕
-
在打开的屏幕中,选择创建一个 Python 3 笔记本(参见图 10-10)。
![笔记本选择屏幕]()
图 10-10。笔记本选择屏幕
这为您提供了一个连接到实例的 Jupyter 笔记本。如果您不熟悉 Jupyter,它为您提供了一个漂亮的 Web 界面,用于运行在计算机上的 Python 解释器,并将命令和结果存储在一个可以共享的笔记本中。要开始使用它,在右侧面板中键入
**print("Hello World!")**,然后按 Shift+Return。您应该会看到“Hello World!”打印在下方,如图 10-11 所示。如果是这样,您已成功设置了机器实例。我们将使用这个笔记本作为本教程其余部分输入命令的地方。

图 10-11。"hello world"示例
接下来的许多命令假定您是从 Jupyter 笔记本中运行的,因此它们以!开头,表示它们应该作为 shell 命令而不是 Python 语句运行。如果您直接从终端运行(例如,在打开安全外壳连接后与实例通信),可以删除初始的!。
训练框架选择
Keras 是在 TensorFlow 中构建模型的推荐接口,但在创建人员检测模型时,它还不支持我们需要的所有功能。因此,我们向您展示如何使用tf.slim训练模型,这是一个较旧的接口。它仍然被广泛使用,但已被弃用,因此未来版本的 TensorFlow 可能不支持这种方法。我们希望将来在线发布 Keras 说明;请查看tinymlbook.com/persondetector获取更新。
Slim 的模型定义是TensorFlow 模型存储库的一部分,因此要开始,您需要从 GitHub 下载它:
! cd ~
! git clone https://github.com/tensorflow/models.git
注意
以下指南假定您已经从您的主目录中完成了这些操作,因此模型存储库代码位于~/models,并且除非另有说明,否则所有命令都是从主目录运行的。您可以将存储库放在其他位置,但您需要更新所有对它的引用。
要使用 Slim,您需要确保 Python 可以找到其模块并安装一个依赖项。以下是如何在 iPython 笔记本中执行此操作:
! pip install contextlib2
import os
new_python_path = (os.environ.get("PYTHONPATH") or '') + ":models/research/slim"
%env PYTHONPATH=$new_python_path
通过像这样的EXPORT语句更新PYTHONPATH仅适用于当前的 Jupyter 会话,因此如果您直接使用 bash,您应该将其添加到持久性启动脚本中,运行类似于这样的内容:
echo 'export PYTHONPATH=$PYTHONPATH:models/research/slim' >> ~/.bashrc
source ~/.bashrc
如果在运行 Slim 脚本时看到导入错误,请确保PYTHONPATH已正确设置并且已安装contextlib2。您可以在存储库的 README中找到有关tf.slim的更多一般信息。
构建数据集
为了训练我们的人员检测模型,我们需要一个大量的图像集,这些图像根据它们是否有人员进行了标记。用于训练图像分类器的 ImageNet 1,000 类数据集不包括人员的标签,但幸运的是COCO 数据集包括这些标签。
数据集设计用于训练本地化模型,因此图像没有用“人”、“非人”类别标记,我们希望对其进行训练。相反,每个图像都附带一个包含其包含的所有对象的边界框列表。“人”是这些对象类别之一,因此为了获得我们想要的分类标签,我们需要寻找带有人边界框的图像。为了确保它们不会太小而无法识别,我们还需要排除非常小的边界框。Slim 包含一个方便的脚本,可以同时下载数据并将边界框转换为标签:
! python download_and_convert_data.py \
--dataset_name=visualwakewords \
--dataset_dir=data/visualwakewords
这是一个大型下载,大约 40 GB,所以需要一段时间,您需要确保您的驱动器上至少有 100 GB 的空闲空间以供解压和进一步处理。如果整个过程需要大约 20 分钟才能完成,请不要感到惊讶。完成后,您将在data/visualwakewords中拥有一组 TFRecords,其中包含带有标记的图像信息。此数据集由 Aakanksha Chowdhery 创建,被称为Visual Wake Words 数据集。它旨在用于基准测试和测试嵌入式计算机视觉,因为它代表了我们需要在严格的资源约束下完成的非常常见的任务。我们希望看到它推动更好的模型用于这个和类似的任务。
训练模型
使用tf.slim处理训练的好处之一是我们通常需要修改的参数可用作命令行参数,因此我们只需调用标准的train_image_classifier.py脚本来训练我们的模型。您可以使用此命令构建我们在示例中使用的模型:
! python models/research/slim/train_image_classifier.py \
--train_dir=vww_96_grayscale \
--dataset_name=visualwakewords \
--dataset_split_name=train \
--dataset_dir=data/visualwakewords \
--model_name=mobilenet_v1_025 \
--preprocessing_name=mobilenet_v1 \
--train_image_size=96 \
--use_grayscale=True \
--save_summaries_secs=300 \
--learning_rate=0.045 \
--label_smoothing=0.1 \
--learning_rate_decay_factor=0.98 \
--num_epochs_per_decay=2.5 \
--moving_average_decay=0.9999 \
--batch_size=96 \
--max_number_of_steps=1000000
在单 GPU V100 实例上完成所有一百万步骤需要几天的时间,但如果您想要尽早进行实验,几个小时后您应该能够获得一个相当准确的模型。以下是一些额外的考虑事项:
-
检查点和摘要将保存在
--train_dir参数中给定的文件夹中。这是您需要查看结果的地方。 -
--dataset_dir参数应该与您从 Visual Wake Words 构建脚本中保存 TFRecords 的目录匹配。 -
我们使用的架构由
--model_name参数定义。mobilenet_v1前缀指示脚本使用 MobileNet 的第一个版本。我们尝试过后续版本,但这些版本对于其中间激活缓冲区使用了更多的 RAM,因此目前我们仍然坚持使用原始版本。025是要使用的深度乘数,这主要影响权重参数的数量;这个低设置确保模型适合在 250 KB 的闪存内。 -
--preprocessing_name控制输入图像在被馈送到模型之前如何修改。mobilenet_v1版本将图像的宽度和高度缩小到--train_image_size中给定的大小(在我们的例子中为 96 像素,因为我们想要减少计算需求)。它还将像素值从 0 到 255 的整数缩放为范围为 -1.0 到 +1.0 的浮点数(尽管我们将在训练后对其进行量化)。 -
我们在 SparkFun Edge 板上使用的 HM01B0 相机 是单色的,因此为了获得最佳结果,我们需要对黑白图像进行模型训练。我们传入
--use_grayscale标志以启用该预处理。 -
--learning_rate、--label_smoothing、--learning_rate_decay_factor、--num_epochs_per_decay、--moving_average_decay和--batch_size参数都控制训练过程中权重如何更新。训练深度网络仍然是一种黑暗的艺术,所以这些确切的值是通过对这个特定模型进行实验找到的。您可以尝试调整它们以加快训练速度或在准确性上获得一点提升,但我们无法为如何进行这些更改提供太多指导,而且很容易出现训练准确性永远不收敛的组合。 -
--max_number_of_steps定义了训练应该持续多久。没有好的方法来提前确定这个阈值;您需要进行实验来确定模型的准确性何时不再提高,以知道何时停止。在我们的情况下,我们默认为一百万步,因为对于这个特定模型,我们知道这是一个停止的好时机。
启动脚本后,您应该看到类似以下的输出:
INFO:tensorflow:global step 4670: loss = 0.7112 (0.251 sec/step)
I0928 00:16:21.774756 140518023943616 learning.py:507] global step 4670: loss
= 0.7112 (0.251 sec/step)
INFO:tensorflow:global step 4680: loss = 0.6596 (0.227 sec/step)
I0928 00:16:24.365901 140518023943616 learning.py:507] global step 4680: loss
= 0.6596 (0.227 sec/step)
不要担心行重复:这只是 TensorFlow 日志打印与 Python 交互的副作用。每行都包含关于训练过程的两个关键信息。全局步骤是我们进行训练的进度计数。因为我们将限制设置为一百万步,所以在这种情况下我们已经完成了近 5%。连同每秒步数的估计,这是有用的,因为您可以用它来估计整个训练过程的大致持续时间。在这种情况下,我们每秒完成大约 4 步,所以一百万步将需要大约 70 小时,或者 3 天。另一个关键信息是损失。这是部分训练模型的预测与正确值之间的接近程度的度量,较低的值更好。这将显示很多变化,但如果模型正在学习,平均情况下应该在训练过程中减少。因为它非常嘈杂,这些数量会在短时间内反弹很多次,但如果事情进展顺利,您应该在等待一个小时左右并返回检查时看到明显的下降。这种变化在图表中更容易看到,这也是尝试 TensorBoard 的主要原因之一。
TensorBoard
TensorBoard 是一个 Web 应用程序,允许您查看来自 TensorFlow 训练会话的数据可视化,它默认包含在大多数云实例中。如果您正在使用 Google Cloud AI Platform,您可以通过在笔记本界面的左侧选项卡中打开命令面板,然后向下滚动选择“创建一个新的 TensorBoard”来启动一个新的 TensorBoard 会话。然后会提示您输入摘要日志的位置。输入您在训练脚本中用于 --train_dir 的路径——在上一个示例中,文件夹名称是 vww_96_grayscale。要注意的一个常见错误是在路径末尾添加斜杠,这将导致 TensorBoard 无法找到目录。
如果您在不同环境中从命令行启动 TensorBoard,您需要将此路径作为 --logdir 参数传递给 TensorBoard 命令行工具,并将浏览器指向 http://localhost:6006(或者您正在运行它的机器的地址)。
在导航到 TensorBoard 地址或通过 Google Cloud 打开会话后,您应该看到一个类似于图 10-12 的页面。鉴于脚本仅每五分钟保存一次摘要,可能需要一段时间才能在图表中找到有用的内容。图 10-12 显示了训练一天以上后的结果。最重要的图表称为“clone_loss”;它显示了与日志输出中显示的相同损失值的进展。正如您在此示例中所看到的,它波动很大,但随着时间的推移总体趋势是向下的。如果在训练几个小时后没有看到这种进展,这表明您的模型没有收敛到一个好的解决方案,您可能需要调试数据集或训练参数出现的问题。
TensorBoard 在打开时默认显示 SCALARS 选项卡,但在训练期间可能有用的另一个部分是 IMAGES(图 10-13)。这显示了模型当前正在训练的图片的随机选择,包括任何扭曲和其他预处理。在图中,您可以看到图像已被翻转,并且在馈送到模型之前已被转换为灰度。这些信息并不像损失图表那样重要,但可以用来确保数据集符合您的期望,并且在训练进行时看到示例更新是有趣的。

图 10-12. TensorBoard 中的图表

图 10-13. TensorBoard 中的图片
评估模型
损失函数与模型训练的好坏相关,但它不是一个直接可理解的度量标准。我们真正关心的是模型正确检测到多少人,但要让它计算这一点,我们需要运行一个单独的脚本。您不需要等到模型完全训练,可以检查--train_dir文件夹中任何检查点的准确性。要执行此操作,请运行以下命令:
! python models/research/slim/eval_image_classifier.py \
--alsologtostderr \
--checkpoint_path=vww_96_grayscale/model.ckpt-698580 \
--dataset_dir=data/visualwakewords \
--dataset_name=visualwakewords \
--dataset_split_name=val \
--model_name=mobilenet_v1_025 \
--preprocessing_name=mobilenet_v1 \
--use_grayscale=True \
--train_image_size=96
您需要确保--checkpoint_path指向有效的检查点数据集。检查点存储在三个单独的文件中,因此值应为它们的公共前缀。例如,如果您有一个名为model.ckpt-5179.data-00000-of-00001的检查点文件,则前缀将是model.ckpt-5179。脚本应该生成类似于以下内容的输出:
INFO:tensorflow:Evaluation [406/406]
I0929 22:52:59.936022 140225887045056 evaluation.py:167] Evaluation [406/406]
eval/Accuracy[0.717438412]eval/Recall_5[1]
这里的重要数字是准确率。它显示了被正确分类的图片的比例,在这种情况下是 72%,转换为百分比后。如果按照示例脚本进行,您应该期望完全训练的模型在一百万步后达到约 84%的准确率,并显示约 0.4 的损失。
将模型导出到 TensorFlow Lite
当模型训练到您满意的准确度时,您需要将结果从 TensorFlow 训练环境转换为可以在嵌入式设备上运行的形式。正如我们在之前的章节中看到的,这可能是一个复杂的过程,而tf.slim也添加了一些自己的特点。
导出到 GraphDef Protobuf 文件
Slim 每次运行其脚本时都会从model_name生成架构,因此要在 Slim 之外使用模型,需要将其保存为通用格式。我们将使用 GraphDef protobuf 序列化格式,因为 Slim 和 TensorFlow 的其余部分都能理解它:
! python models/research/slim/export_inference_graph.py \
--alsologtostderr \
--dataset_name=visualwakewords \
--model_name=mobilenet_v1_025 \
--image_size=96 \
--use_grayscale=True \
--output_file=vww_96_grayscale_graph.pb
如果成功,您应该在主目录中有一个新的vww_96_grayscale_graph.pb文件。这包含了模型中操作的布局,但尚未包含任何权重数据。
冻结权重
将训练好的权重与操作图一起存储的过程称为冻结。这将所有图中的变量转换为常量,加载它们的值后从检查点文件中。接下来的命令使用了百万次训练步骤的检查点,但您可以提供任何有效的检查点路径。图冻结脚本存储在主 TensorFlow 存储库中,因此在运行此命令之前,您需要从 GitHub 下载这个脚本:
! git clone https://github.com/tensorflow/tensorflow
! python tensorflow/tensorflow/python/tools/freeze_graph.py \
--input_graph=vww_96_grayscale_graph.pb \
--input_checkpoint=vww_96_grayscale/model.ckpt-1000000 \
--input_binary=true --output_graph=vww_96_grayscale_frozen.pb \
--output_node_names=MobilenetV1/Predictions/Reshape_1
之后,您应该看到一个名为vww_96_grayscale_frozen.pb的文件。
量化和转换为 TensorFlow Lite
量化是一个棘手而复杂的过程,仍然是一个活跃的研究领域,因此将我们迄今为止训练的浮点图转换为 8 位实体需要相当多的代码。您可以在第十五章中找到更多关于量化是什么以及它是如何工作的解释,但在这里我们将向您展示如何在我们训练的模型中使用它。大部分代码是准备示例图像以馈送到训练网络中,以便测量典型使用中激活层的范围。我们依赖TFLiteConverter类来处理量化并将其转换为我们需要用于推理引擎的 TensorFlow Lite FlatBuffer 文件:
import tensorflow as tf
import io
import PIL
import numpy as np
def representative_dataset_gen():
record_iterator = tf.python_io.tf_record_iterator
(path='data/visualwakewords/val.record-00000-of-00010')
count = 0
for string_record in record_iterator:
example = tf.train.Example()
example.ParseFromString(string_record)
image_stream = io.BytesIO
(example.features.feature['image/encoded'].bytes_list.value[0])
image = PIL.Image.open(image_stream)
image = image.resize((96, 96))
image = image.convert('L')
array = np.array(image)
array = np.expand_dims(array, axis=2)
array = np.expand_dims(array, axis=0)
array = ((array / 127.5) - 1.0).astype(np.float32)
yield([array])
count += 1
if count > 300:
break
converter = tf.lite.TFLiteConverter.from_frozen_graph \
('vww_96_grayscale_frozen.pb', ['input'], ['MobilenetV1/Predictions/ \
Reshape_1'])
converter.inference_input_type = tf.lite.constants.INT8
converter.inference_output_type = tf.lite.constants.INT8
converter.optimizations = [tf.lite.Optimize.DEFAULT]
converter.representative_dataset = representative_dataset_gen
tflite_quant_model = converter.convert()
open("vww_96_grayscale_quantized.tflite", "wb").write(tflite_quant_model)
转换为 C 源文件
转换器会写出一个文件,但大多数嵌入式设备没有文件系统。为了从我们的程序中访问序列化数据,我们必须将其编译到可执行文件中并存储在闪存中。最简单的方法是将文件转换为 C 数据数组,就像我们在之前的章节中所做的那样:
# Install xxd if it is not available
! apt-get -qq install xxd
# Save the file as a C source file
! xxd -i vww_96_grayscale_quantized.tflite > person_detect_model_data.cc
现在,您可以用您训练过的版本替换现有的person_detect_model_data.cc文件,并能够在嵌入式设备上运行您自己的模型。
为其他类别训练
COCO 数据集中有 60 多种不同的对象类型,因此自定义模型的一种简单方法是在构建训练数据集时选择其中一种而不是person。以下是一个查找汽车的示例:
! python models/research/slim/datasets/build_visualwakewords_data.py \
--logtostderr \
--train_image_dir=coco/raw-data/train2014 \
--val_image_dir=coco/raw-data/val2014 \
--train_annotations_file=coco/raw-data/annotations/instances_train2014.json \
--val_annotations_file=coco/raw-data/annotations/instances_val2014.json \
--output_dir=coco/processed_cars \
--small_object_area_threshold=0.005 \
--foreground_class_of_interest='car'
您应该能够按照与人员检测器相同的步骤进行操作,只需在以前的data/visualwakewords路径处用新的coco/processed_cars路径替换。
如果您感兴趣的对象类型在 COCO 中不存在,您可能可以使用迁移学习来帮助您在您收集的自定义数据集上进行训练,即使它很小。虽然我们还没有分享这方面的示例,但您可以查看tinymlbook.com以获取有关这种方法的更新。
理解架构
MobileNets是一系列旨在提供尽可能少的权重参数和算术运算的良好准确性的架构。现在有多个版本,但在我们的情况下,我们使用原始的 v1,因为它在运行时需要的 RAM 最少。该架构背后的核心概念是深度可分离卷积。这是经典 2D 卷积的一种变体,以更高效的方式工作,而几乎不损失准确性。常规卷积根据在输入的所有通道上应用特定大小的滤波器来计算输出值。这意味着每个输出中涉及的计算数量是滤波器的宽度乘以高度,再乘以输入通道的数量。深度卷积将这个大计算分解为不同的部分。首先,每个输入通道通过一个或多个矩形滤波器进行滤波,以产生中间值。然后使用逐点卷积来组合这些值。这显著减少了所需的计算量,并且在实践中产生了与常规卷积类似的结果。
MobileNet v1 是由 14 个这些深度可分离卷积层堆叠而成,其中包括一个平均池化层,然后是一个全连接层,最后是一个 softmax 层。我们指定了一个宽度乘数为 0.25,这样可以将每次推断的计算量减少到约 6000 万次,通过将每个激活层中的通道数量缩减 75%与标准模型相比。本质上,它在操作上与普通的卷积神经网络非常相似,每一层都在学习输入中的模式。较早的层更像是边缘识别滤波器,识别图像中的低级结构,而较后的层将这些信息合成为更抽象的模式,有助于最终的对象分类。
总结
使用机器学习进行图像识别需要大量的数据和大量的处理能力。在本章中,您学习了如何从头开始训练模型,只提供数据集,并将该模型转换为适用于嵌入式设备的形式。
这种经验应该为您解决产品所需解决的机器视觉问题奠定了良好的基础。计算机能够看到并理解周围世界仍然有些神奇,所以我们迫不及待地想看看您会有什么创意!
第十一章:魔术棒:构建一个应用程序
到目前为止,我们的示例应用程序已经处理了人类可以轻松理解的数据。我们的大脑有整个区域专门用于理解语音和视觉,因此对于我们来说,解释视觉或音频数据并形成对正在发生的事情的想法并不困难。
然而,许多数据并不容易理解。机器及其传感器生成大量信息流,这些信息流不容易映射到我们的人类感官上。即使以视觉方式表示,我们的大脑也可能难以理解数据中的趋势和模式。
例如,图 11-1 和图 11-2 中呈现的两个图表显示了放置在做运动的人前口袋中的手机捕获的传感器数据。所涉及的传感器是加速度计,它测量三个维度的加速度(我们稍后会详细讨论这些)。图 11-1 显示了一个正在慢跑的人的加速度数据,而图 11-2 显示了同一个人下楼梯时的数据。
正如您所看到的,即使数据代表一种简单且易于理解的活动,也很难区分这两种活动。想象一下试图区分复杂工业机器的运行状态,该机器可能有数百个传感器测量各种晦涩的属性。
通常可以编写手工制作的算法来理解这种类型的数据。例如,人类步态专家可能会认出上楼梯的特征迹象,并能够将这种知识表达为代码中的函数。这种类型的函数称为启发式,在各种应用程序中广泛使用,从工业自动化到医疗设备。

图 11-1。显示慢跑者数据的图表(MotionSense 数据集)

图 11-2。显示下楼梯的人的数据图(MotionSense 数据集)
要创建一个启发式方法,您需要两样东西。第一是领域知识。启发式算法表达了人类的知识和理解,因此要编写一个,您需要已经理解数据的含义。要理解这一点,想象一个根据体温确定一个人是否发烧的启发式方法。创建它的人必须了解指示发烧的体温变化。
构建启发式方法的第二个要求是编程和数学专业知识。尽管很容易确定某人的体温是否过高,但其他问题可能要复杂得多。根据多个数据流中的复杂模式辨别系统的状态可能需要一些高级技术的知识,比如统计分析或信号处理。例如,想象一下根据加速度计数据区分走路和跑步的启发式方法。为了构建这个方法,您可能需要知道如何在数学上过滤加速度计数据以获得步频的估计。
虽然启发式方法可能非常有用,但它们需要领域知识和编程专业知识,这意味着它们可能难以构建。首先,领域知识并不总是可获得的。例如,一家小公司可能没有资源进行必要的基础研究,以了解什么指示一个状态与另一个状态。同样,即使具有领域知识,也不是每个人都具备设计和实现启发式算法所需的专业知识。
机器学习为我们提供了一种捷径,一个在标记数据上训练的模型可以学会识别指示一个类别或另一个类别的信号,这意味着不需要深入的领域知识。例如,一个模型可以学会识别表明发烧的人体温度波动,而无需告诉它哪些具体温度是重要的——它只需要带有“发烧”或“非发烧”标签的温度数据。此外,与可能需要实现复杂启发式算法所需的工程技能相比,学习机器学习可能更容易获得。
机器学习开发人员不再需要从头设计启发式算法,而是可以找到合适的模型架构,收集和标记数据集,并通过训练和评估迭代地创建模型。领域知识仍然非常有帮助,但可能不再是使某些东西运作的先决条件。在某些情况下,最终的模型实际上可能比最佳的手工编码算法更准确。
事实上,最近的一篇论文显示,一个简单的卷积神经网络能够从单个心跳中以 100%的准确率检测出患者的充血性心力衰竭。这比任何先前的诊断技术表现都要好。即使你不理解每个细节,这篇论文也是一篇引人入胜的阅读。
通过训练一个深度学习模型来理解复杂数据并将其嵌入微控制器程序中,我们可以创建智能传感器,能够理解其环境的复杂性并告诉我们,高层次上正在发生什么。这对数十个领域都有巨大的影响。以下只是一些潜在的应用:
-
在网络连接质量差的偏远地区进行环境监测
-
自动调整以实时解决问题的工业流程
-
对复杂外部刺激做出反应的机器人
-
无需医疗专业人员进行疾病诊断
-
理解物理运动的计算机界面
在本章中,我们将构建一个属于最终类别的项目:一个数字“魔杖”,其所有者可以挥舞以施放各种咒语。作为其输入,它接收复杂的、多维的传感器数据,这对人类来说是难以理解的。其输出将是一个简单的分类,提醒我们最近是否发生了几种运动类别中的一种。我们将看看深度学习如何将奇怪的数字数据转化为有意义的信息——产生魔法效果。
我们正在构建的东西
我们的“魔杖”可以用来施放几种类型的咒语。持有者只需挥动魔杖进行三种手势中的一种,即“翅膀”、“环”和“斜坡”,如图 11-3 所示。

图 11-3。三个魔杖手势
魔杖将通过点亮 LED 来对每个咒语做出反应。如果电灯的魔法不够令人兴奋,它还将输出信息到其串行端口,该信息可用于控制连接的计算机。
为了理解物理手势,魔杖应用程序使用设备的加速度计收集有关其在空间中运动的信息。加速度计测量当前正在经历的加速度的程度。例如,想象一下,我们已经将一个加速度计连接到一辆停在红灯前准备启动的汽车上。
当灯变绿时,汽车开始向前移动,速度逐渐增加直到达到速度限制。在此期间,加速度计将输出一个值,指示汽车的加速度。汽车达到稳定速度后,它不再加速,因此加速度计将输出零。
SparkFun Edge 和 Arduino Nano 33 BLE Sense 板都配备有三轴加速度计,这些加速度计包含在焊接到每个板上的组件中。它们在三个方向上测量加速度,这意味着它们可以用来跟踪设备在 3D 空间中的运动。为了制作我们的魔杖,我们将把微控制器板固定在一根棍子的末端,这样就可以以巫术般的方式挥动它。然后,我们将加速度计的输出输入到一个深度学习模型中,该模型将执行分类,告诉我们是否进行了已知手势。
我们提供了将此应用程序部署到以下微控制器平台的说明:
因为ST Microelectronics STM32F746G Discovery kit不包括加速度计(并且太大无法附在魔杖的末端),我们不会在这里展示它。
注意
TensorFlow Lite 定期为新设备添加支持,因此,如果您想使用的设备没有在这里列出,值得检查示例的README.md。如果遇到问题,您也可以在那里查看更新的部署说明。
在下一节中,我们将看一下我们应用程序的结构,并了解更多关于它的模型是如何工作的。
应用程序架构
我们的应用程序将再次遵循现在熟悉的模式,获取输入,运行推断,处理输出,并使用生成的信息来实现一些操作。
三轴加速度计输出三个值,表示设备的 x、y 和 z 轴上的加速度量。SparkFun Edge 板上的加速度计每秒可以做到这一点 25 次(25 Hz 的速率)。我们的模型直接将这些值作为输入,这意味着我们不需要进行任何预处理。
在数据被捕获并推断运行之后,我们的应用程序将确定是否检测到了有效的手势,向终端打印一些输出,并点亮 LED。
介绍我们的模型
我们的手势检测模型是一个卷积神经网络,大小约为 20 KB,它接受原始加速度计值作为输入。它一次接收 128 组x、y和z值,以 25 Hz 的速率计算,相当于超过五秒钟的数据。每个值是一个 32 位浮点数,表示该方向上的加速度量。
该模型是在许多人执行的四个手势上进行训练的。它为四个类别输出概率分数:分别代表每个手势(“翅膀”、“戒指”和“斜坡”),以及代表未识别手势的一个。概率分数总和为 1,得分高于 0.8 被认为是有信心的。
因为我们将每秒运行多次推断,我们需要确保在执行手势时单个错误的推断不会使结果产生偏差。我们的机制是只有在经过一定数量的推断确认后才认为检测到了手势。鉴于每个手势执行所需的时间不同,对于每个手势,所需的推断次数也不同,最佳数量是通过实验确定的。同样,不同设备上推断的运行速率也不同,因此这些阈值也是根据设备设置的。
在第十二章中,我们将探讨如何在我们自己的手势数据上训练模型,并深入了解模型的工作原理。在那之前,让我们继续走过我们的应用程序。
所有的移动部件
图 11-4 显示了我们魔杖应用程序的结构。
正如您所看到的,这几乎和我们的人体检测应用程序一样简单。我们的模型接受原始加速度计数据,这意味着我们不需要进行任何预处理。
代码的六个主要部分遵循与我们的人员检测示例相似的结构。让我们依次浏览它们:
主循环
我们的应用程序在一个连续循环中运行。由于其模型小而简单,且不需要预处理,我们可以每秒运行多个推理。
加速度计处理程序
该组件从加速度计捕获数据并将其写入模型的输入张量。它使用缓冲区来保存数据。
TF Lite 解释器
解释器运行 TensorFlow Lite 模型,就像我们之前的示例一样。
模型
该模型作为数据数组包含在内,并由解释器运行。它很小,仅 19.5 KB。
手势预测器
该组件获取模型的输出,并根据概率和连续正预测的数量的阈值决定是否检测到手势。
输出处理程序
输出处理程序点亮 LED 并根据识别到的手势在串行端口上打印输出。

图 11-4。我们魔杖应用程序的组件
浏览测试
您可以在GitHub 存储库中找到应用程序的测试:
展示如何对加速度计数据样本运行推理
展示如何使用加速度计处理程序获取新鲜数据
展示如何使用手势预测器解释推理结果
展示如何使用输出处理程序显示推理结果
让我们从magic_wand_test.cc开始,这将向我们展示使用我们的模型进行推理的端到端过程。
基本流程
我们在magic_wand_test.cc中逐步进行基本流程。
首先,我们列出模型将需要的操作:
namespace tflite {
namespace ops {
namespace micro {
TfLiteRegistration* Register_DEPTHWISE_CONV_2D();
TfLiteRegistration* Register_MAX_POOL_2D();
TfLiteRegistration* Register_CONV_2D();
TfLiteRegistration* Register_FULLY_CONNECTED();
TfLiteRegistration* Register_SOFTMAX();
} // namespace micro
} // namespace ops
} // namespace tflite
测试本身(通常)通过设置推理所需的所有内容并获取模型输入张量的指针开始:
// Set up logging
tflite::MicroErrorReporter micro_error_reporter;
tflite::ErrorReporter* error_reporter = µ_error_reporter;
// Map the model into a usable data structure. This doesn't involve any
// copying or parsing, it's a very lightweight operation.
const tflite::Model* model =
::tflite::GetModel(g_magic_wand_model_data);
if (model->version() != TFLITE_SCHEMA_VERSION) {
error_reporter->Report(
"Model provided is schema version %d not equal "
"to supported version %d.\n",
model->version(), TFLITE_SCHEMA_VERSION);
}
static tflite::MicroMutableOpResolver micro_mutable_op_resolver;
micro_mutable_op_resolver.AddBuiltin(
tflite::BuiltinOperator_DEPTHWISE_CONV_2D,
tflite::ops::micro::Register_DEPTHWISE_CONV_2D());
micro_mutable_op_resolver.AddBuiltin(
tflite::BuiltinOperator_MAX_POOL_2D,
tflite::ops::micro::Register_MAX_POOL_2D());
micro_mutable_op_resolver.AddBuiltin(
tflite::BuiltinOperator_CONV_2D,
tflite::ops::micro::Register_CONV_2D());
micro_mutable_op_resolver.AddBuiltin(
tflite::BuiltinOperator_FULLY_CONNECTED,
tflite::ops::micro::Register_FULLY_CONNECTED());
micro_mutable_op_resolver.AddBuiltin(tflite::BuiltinOperator_SOFTMAX,
tflite::ops::micro::Register_SOFTMAX());
// Create an area of memory to use for input, output, and intermediate arrays.
// Finding the minimum value for your model may require some trial and error.
const int tensor_arena_size = 60 * 1024;
uint8_t tensor_arena[tensor_arena_size];
// Build an interpreter to run the model with
tflite::MicroInterpreter interpreter(model, micro_mutable_op_resolver, tensor_arena,
tensor_arena_size, error_reporter);
// Allocate memory from the tensor_arena for the model's tensors
interpreter.AllocateTensors();
// Obtain a pointer to the model's input tensor
TfLiteTensor* input = interpreter.input(0);
然后,我们检查输入张量以确保其预期形状:
// Make sure the input has the properties we expect
TF_LITE_MICRO_EXPECT_NE(nullptr, input);
TF_LITE_MICRO_EXPECT_EQ(4, input->dims->size);
// The value of each element gives the length of the corresponding tensor.
TF_LITE_MICRO_EXPECT_EQ(1, input->dims->data[0]);
TF_LITE_MICRO_EXPECT_EQ(128, input->dims->data[1]);
TF_LITE_MICRO_EXPECT_EQ(3, input->dims->data[2]);
TF_LITE_MICRO_EXPECT_EQ(1, input->dims->data[3]);
// The input is a 32 bit floating point value
TF_LITE_MICRO_EXPECT_EQ(kTfLiteFloat32, input->type);
我们的输入形状是(1, 128, 3, 1)。第一个维度只是第二个维度的包装器,其中包含 128 个三轴加速度计读数。每个读数有三个值,分别对应每个轴,每个值都包含在一个单元素张量中。输入都是 32 位浮点值。
在我们确认输入形状后,我们向输入张量写入一些数据:
// Provide an input value
const float* ring_features_data = g_circle_micro_f9643d42_nohash_4_data;
error_reporter->Report("%d", input->bytes);
for (int i = 0; i < (input->bytes / sizeof(float)); ++i) {
input->data.f[i] = ring_features_data[i];
}
常量g_circle_micro_f9643d42_nohash_4_data在circle_micro_features_data.cc中定义;它包含一个浮点值数组,表示一个人尝试执行圆形手势的数据。在for循环中,我们遍历这些数据,并将每个值写入输入。我们只写入与输入张量容量相符的float值。
接下来,我们以熟悉的方式运行推理:
// Run the model on this input and check that it succeeds
TfLiteStatus invoke_status = interpreter.Invoke();
if (invoke_status != kTfLiteOk) {
error_reporter->Report("Invoke failed\n");
}
TF_LITE_MICRO_EXPECT_EQ(kTfLiteOk, invoke_status);
之后,我们调查输出张量以确保它的形状符合我们的预期:
// Obtain a pointer to the output tensor and make sure it has the
// properties we expect.
TfLiteTensor* output = interpreter.output(0);
TF_LITE_MICRO_EXPECT_EQ(2, output->dims->size);
TF_LITE_MICRO_EXPECT_EQ(1, output->dims->data[0]);
TF_LITE_MICRO_EXPECT_EQ(4, output->dims->data[1]);
TF_LITE_MICRO_EXPECT_EQ(kTfLiteFloat32, output->type);
它应该具有两个维度:一个单元素包装器和一组四个值,表示我们的四个概率(“翅膀”,“环”,“斜坡”和未知)。每个值都是 32 位浮点数。
然后,我们可以测试我们的数据,以确保推理结果符合我们的预期。我们传入了圆形手势的数据,因此我们期望“环”得分最高:
// There are four possible classes in the output, each with a score.
const int kWingIndex = 0;
const int kRingIndex = 1;
const int kSlopeIndex = 2;
const int kNegativeIndex = 3;
// Make sure that the expected "Ring" score is higher than the other
// classes.
float wing_score = output->data.f[kWingIndex];
float ring_score = output->data.f[kRingIndex];
float slope_score = output->data.f[kSlopeIndex];
float negative_score = output->data.f[kNegativeIndex];
TF_LITE_MICRO_EXPECT_GT(ring_score, wing_score);
TF_LITE_MICRO_EXPECT_GT(ring_score, slope_score);
TF_LITE_MICRO_EXPECT_GT(ring_score, negative_score);
然后我们为“斜坡”手势重复整个过程:
// Now test with a different input, from a recording of "Slope".
const float* slope_features_data = g_angle_micro_f2e59fea_nohash_1_data;
for (int i = 0; i < (input->bytes / sizeof(float)); ++i) {
input->data.f[i] = slope_features_data[i];
}
// Run the model on this "Slope" input.
invoke_status = interpreter.Invoke();
if (invoke_status != kTfLiteOk) {
error_reporter->Report("Invoke failed\n");
}
TF_LITE_MICRO_EXPECT_EQ(kTfLiteOk, invoke_status);
// Make sure that the expected "Slope" score is higher than the other classes.
wing_score = output->data.f[kWingIndex];
ring_score = output->data.f[kRingIndex];
slope_score = output->data.f[kSlopeIndex];
negative_score = output->data.f[kNegativeIndex];
TF_LITE_MICRO_EXPECT_GT(slope_score, wing_score);
TF_LITE_MICRO_EXPECT_GT(slope_score, ring_score);
TF_LITE_MICRO_EXPECT_GT(slope_score, negative_score);
就是这样!我们已经看到了如何对原始加速度计数据运行推理。与之前的示例一样,我们可以避免预处理,使事情变得简单明了。
要运行此测试,请使用以下命令:
make -f tensorflow/lite/micro/tools/make/Makefile test_magic_wand_test
加速度计处理程序
我们的下一个测试展示了加速度计处理程序的接口。该组件的任务是为每次推理填充输入张量的加速度计数据。
由于这两个因素取决于设备的加速计工作方式,因此为每个单独的设备提供了不同的加速计处理程序实现。我们稍后将介绍这些实现,但现在,位于accelerometer_handler_test.cc中的测试将展示我们应该如何调用处理程序。
第一个测试非常简单:
TF_LITE_MICRO_TEST(TestSetup) {
static tflite::MicroErrorReporter micro_error_reporter;
TfLiteStatus setup_status = SetupAccelerometer(µ_error_reporter);
TF_LITE_MICRO_EXPECT_EQ(kTfLiteOk, setup_status);
}
SetupAccelerometer()函数执行一次性设置,以便从加速计获取值。测试展示了如何调用该函数(带有指向ErrorReporter的指针),以及它返回一个指示设置成功的TfLiteStatus。
下一个测试展示了如何使用加速计处理程序填充输入张量的数据:
TF_LITE_MICRO_TEST(TestAccelerometer) {
float input[384] = {0.0};
tflite::MicroErrorReporter micro_error_reporter;
// Test that the function returns false before insufficient data is available
bool inference_flag =
ReadAccelerometer(µ_error_reporter, input, 384, false);
TF_LITE_MICRO_EXPECT_EQ(inference_flag, false);
// Test that the function returns true once sufficient data is available to
// fill the model's input buffer (128 sets of values)
for (int i = 1; i <= 128; i++) {
inference_flag =
ReadAccelerometer(µ_error_reporter, input, 384, false);
}
TF_LITE_MICRO_EXPECT_EQ(inference_flag, true);
}
首先,我们准备一个名为input的float数组来模拟模型的输入张量。因为有 128 个三轴读数,所以总大小为 384 字节(128 * 3)。我们将数组中的每个值初始化为0.0。
然后我们调用ReadAccelerometer()。我们提供一个ErrorReporter实例,我们希望数据写入的数组(input),以及我们想要获取的数据总量(384 字节)。最后一个参数是一个布尔标志,指示ReadAccelerometer()在读取更多数据之前是否清除缓冲区,这需要在成功识别手势后执行。
当调用ReadAccelerometer()函数时,会尝试将 384 字节的数据写入传递给它的数组。如果加速计刚开始收集数据,可能还没有完整的 384 字节可用。在这种情况下,函数将不执行任何操作,并返回一个值false。我们可以利用这一点,避免在没有数据可用时运行推理。
位于accelerometer_handler.cc中的加速计处理程序的虚拟实现模拟每次调用时都有另一个读数可用。通过调用它额外 127 次,我们确保它将累积足够的数据开始返回true。
要运行这些测试,请使用以下命令:
make -f tensorflow/lite/micro/tools/make/Makefile \
test_gesture_accelerometer_handler_test
手势预测器
推理完成后,我们的输出张量将填充概率,告诉我们哪种手势(如果有的话)被执行。然而,由于机器学习并非精确科学,任何单个推理都有可能导致误报。
为了减少误报的影响,我们可以规定要识别手势,必须在至少一定数量的连续推理中检测到。鉴于我们每秒运行多次推理,我们可以快速确定结果是否有效。这是手势预测器的工作。
它定义了一个名为PredictGesture()的函数,以模型的输出张量作为输入。为了确定是否检测到手势,该函数执行两项操作:
-
检查手势的概率是否达到最小阈值
-
检查手势是否在一定数量的推理中被一致地检测到
所需的最小推理次数因手势而异,因为有些手势执行时间较长。它还因设备而异,因为更快的设备能够更频繁地运行推理。为 SparkFun Edge 开发板调整的默认值位于constants.cc中:
const int kConsecutiveInferenceThresholds[3] = {15, 12, 10};
这些值的定义顺序与模型输出张量中手势出现的顺序相同。其他平台,如 Arduino,有包含针对其性能调整的设备特定版本的文件。
让我们浏览一下gesture_predictor.cc中的代码,看看这些是如何使用的。
首先,我们定义一些变量,用于跟踪上次看到的手势以及连续记录的相同手势数量:
// How many times the most recent gesture has been matched in a row
int continuous_count = 0;
// The result of the last prediction
int last_predict = -1;
接下来,我们定义PredictGesture()函数,并确定最近推断中是否有任何手势类别的概率大于 0.8:
// Return the result of the last prediction
// 0: wing("W"), 1: ring("O"), 2: slope("angle"), 3: unknown
int PredictGesture(float* output) {
// Find whichever output has a probability > 0.8 (they sum to 1)
int this_predict = -1;
for (int i = 0; i < 3; i++) {
if (output[i] > 0.8) this_predict = i;
}
我们使用this_predict来存储预测到的手势的索引。
变量continuous_count用于跟踪最近发现的手势连续预测的次数。如果没有手势类别满足 0.8 的概率阈值,我们通过将continuous_count设置为0和last_predict设置为3(“未知”类别的索引)来重置任何正在进行的检测过程,表示最近的结果不是已知手势:
// No gesture was detected above the threshold
if (this_predict == -1) {
continuous_count = 0;
last_predict = 3;
return 3;
}
接下来,如果最近的预测与之前的预测一致,我们增加continuous_count。否则,我们将其重置为0。我们还将最近的预测存储在last_predict中:
if (last_predict == this_predict) {
continuous_count += 1;
} else {
continuous_count = 0;
}
last_predict = this_predict;
在PredictGesture()的下一部分中,我们使用should_continuous_count来检查当前手势是否已经达到其阈值。如果没有,我们返回一个3,表示一个未知手势:
// If we haven't yet had enough consecutive matches for this gesture,
// report a negative result
if (continuous_count < kConsecutiveInferenceThresholds[this_predict]) {
return 3;
}
如果我们通过了这一点,这意味着我们确认了一个有效的手势。在这种情况下,我们重置所有变量:
// Otherwise, we've seen a positive result, so clear all our variables
// and report it
continuous_count = 0;
last_predict = -1;
return this_predict;
}
函数通过返回当前预测结束。这将由我们的主循环传递给输出处理程序,显示结果给用户。
手势预测器的测试位于gesture_predictor_test.cc中。第一个测试展示了一个成功的预测:
TF_LITE_MICRO_TEST(SuccessfulPrediction) {
// Use the threshold from the 0th gesture
int threshold = kConsecutiveInferenceThresholds[0];
float probabilities[4] = {1.0, 0.0, 0.0, 0.0};
int prediction;
// Loop just too few times to trigger a prediction
for (int i = 0; i <= threshold - 1; i++) {
prediction = PredictGesture(probabilities);
TF_LITE_MICRO_EXPECT_EQ(prediction, 3);
}
// Call once more, triggering a prediction
// for category 0
prediction = PredictGesture(probabilities);
TF_LITE_MICRO_EXPECT_EQ(prediction, 0);
}
PredictGesture()函数被提供了一组概率,强烈表明第一个类别应该匹配。然而,在它被调用threshold次数之前,它返回一个3,表示一个“未知”结果。在它被调用threshold次数之后,它为类别0返回一个正面预测。
接下来的测试展示了如果一个类别的连续高概率运行被另一个类别的高概率中断会发生什么:
TF_LITE_MICRO_TEST(FailPartWayThere) {
// Use the threshold from the 0th gesture
int threshold = kConsecutiveInferenceThresholds[0];
float probabilities[4] = {1.0, 0.0, 0.0, 0.0};
int prediction;
// Loop just too few times to trigger a prediction
for (int i = 0; i <= threshold - 1; i++) {
prediction = PredictGesture(probabilities);
TF_LITE_MICRO_EXPECT_EQ(prediction, 3);
}
// Call with a different prediction, triggering a failure
probabilities[0] = 0.0;
probabilities[2] = 1.0;
prediction = PredictGesture(probabilities);
TF_LITE_MICRO_EXPECT_EQ(prediction, 3);
}
在这种情况下,我们输入了一组连续的类别0高概率,但不足以满足阈值。然后我们改变概率,使类别2最高,这导致类别3的预测,表示一个“未知”手势。
最终的测试展示了PredictGesture()如何忽略低于其阈值的概率。在循环中,我们输入了恰好正确数量的预测以满足类别0的阈值。然而,尽管类别0的概率最高,但其值为 0.7,低于PredictGesture()的内部阈值 0.8。这导致类别3的“未知”预测:
TF_LITE_MICRO_TEST(InsufficientProbability) {
// Use the threshold from the 0th gesture
int threshold = kConsecutiveInferenceThresholds[0];
// Below the probability threshold of 0.8
float probabilities[4] = {0.7, 0.0, 0.0, 0.0};
int prediction;
// Loop the exact right number of times
for (int i = 0; i <= threshold; i++) {
prediction = PredictGesture(probabilities);
TF_LITE_MICRO_EXPECT_EQ(prediction, 3);
}
}
要运行这些测试,请使用以下命令:
make -f tensorflow/lite/micro/tools/make/Makefile \
test_gesture_predictor_test
输出处理程序
输出处理程序非常简单;它只是获取PredictGesture()返回的类索引,并将结果显示给用户。在output_handler_test.cc中展示了它的接口:
TF_LITE_MICRO_TEST(TestCallability) {
tflite::MicroErrorReporter micro_error_reporter;
tflite::ErrorReporter* error_reporter = µ_error_reporter;
HandleOutput(error_reporter, 0);
HandleOutput(error_reporter, 1);
HandleOutput(error_reporter, 2);
HandleOutput(error_reporter, 3);
}
要运行此测试,请使用以下命令:
make -f tensorflow/lite/micro/tools/make/Makefile \
test_gesture_output_handler_test
检测手势
所有这些组件都在main_functions.cc中汇聚,其中包含我们程序的核心逻辑。首先设置通常的变量,以及一些额外的变量:
namespace tflite {
namespace ops {
namespace micro {
TfLiteRegistration* Register_DEPTHWISE_CONV_2D();
TfLiteRegistration* Register_MAX_POOL_2D();
TfLiteRegistration* Register_CONV_2D();
TfLiteRegistration* Register_FULLY_CONNECTED();
TfLiteRegistration* Register_SOFTMAX();
} // namespace micro
} // namespace ops
} // namespace tflite
// Globals, used for compatibility with Arduino-style sketches.
namespace {
tflite::ErrorReporter* error_reporter = nullptr;
const tflite::Model* model = nullptr;
tflite::MicroInterpreter* interpreter = nullptr;
TfLiteTensor* model_input = nullptr;
int input_length;
// Create an area of memory to use for input, output, and intermediate arrays.
// The size of this will depend on the model you're using, and may need to be
// determined by experimentation.
constexpr int kTensorArenaSize = 60 * 1024;
uint8_t tensor_arena[kTensorArenaSize];
// Whether we should clear the buffer next time we fetch data
bool should_clear_buffer = false;
} // namespace
input_length变量存储模型输入张量的长度,should_clear_buffer变量是一个标志,指示加速度计处理程序的缓冲区在下次运行时是否应该被清除。在成功检测结果之后清除缓冲区,以便为后续推断提供一个干净的基础。
接下来,setup()函数执行所有通常的清理工作,以便我们准备好运行推断:
void setup() {
// Set up logging. Google style is to avoid globals or statics because of
// lifetime uncertainty, but since this has a trivial destructor it's okay.
static tflite::MicroErrorReporter micro_error_reporter; //NOLINT
error_reporter = µ_error_reporter;
// Map the model into a usable data structure. This doesn't involve any
// copying or parsing, it's a very lightweight operation.
model = tflite::GetModel(g_magic_wand_model_data);
if (model->version() != TFLITE_SCHEMA_VERSION) {
error_reporter->Report(
"Model provided is schema version %d not equal "
"to supported version %d.",
model->version(), TFLITE_SCHEMA_VERSION);
return;
}
// Pull in only the operation implementations we need.
// This relies on a complete list of all the ops needed by this graph.
// An easier approach is to just use the AllOpsResolver, but this will
// incur some penalty in code space for op implementations that are not
// needed by this graph.
static tflite::MicroMutableOpResolver micro_mutable_op_resolver; // NOLINT
micro_mutable_op_resolver.AddBuiltin(
tflite::BuiltinOperator_DEPTHWISE_CONV_2D,
tflite::ops::micro::Register_DEPTHWISE_CONV_2D());
micro_mutable_op_resolver.AddBuiltin(
tflite::BuiltinOperator_MAX_POOL_2D,
tflite::ops::micro::Register_MAX_POOL_2D());
micro_mutable_op_resolver.AddBuiltin(
tflite::BuiltinOperator_CONV_2D,
tflite::ops::micro::Register_CONV_2D());
micro_mutable_op_resolver.AddBuiltin(
tflite::BuiltinOperator_FULLY_CONNECTED,
tflite::ops::micro::Register_FULLY_CONNECTED());
micro_mutable_op_resolver.AddBuiltin(tflite::BuiltinOperator_SOFTMAX,
tflite::ops::micro::Register_SOFTMAX());
// Build an interpreter to run the model with
static tflite::MicroInterpreter static_interpreter(model,
micro_mutable_op_resolver,
tensor_arena,
kTensorArenaSize,
error_reporter);
interpreter = &static_interpreter;
// Allocate memory from the tensor_arena for the model's tensors
interpreter->AllocateTensors();
// Obtain pointer to the model's input tensor
model_input = interpreter->input(0);
if ((model_input->dims->size != 4) || (model_input->dims->data[0] != 1) ||
(model_input->dims->data[1] != 128) ||
(model_input->dims->data[2] != kChannelNumber) ||
(model_input->type != kTfLiteFloat32)) {
error_reporter->Report("Bad input tensor parameters in model");
return;
}
input_length = model_input->bytes / sizeof(float);
TfLiteStatus setup_status = SetupAccelerometer(error_reporter);
if (setup_status != kTfLiteOk) {
error_reporter->Report("Set up failed\n");
}
}
更有趣的事情发生在loop()函数中,这仍然非常简单:
void loop() {
// Attempt to read new data from the accelerometer
bool got_data = ReadAccelerometer(error_reporter, model_input->data.f,
input_length, should_clear_buffer);
// Don't try to clear the buffer again
should_clear_buffer = false;
// If there was no new data, wait until next time
if (!got_data) return;
// Run inference, and report any error
TfLiteStatus invoke_status = interpreter->Invoke();
if (invoke_status != kTfLiteOk) {
error_reporter->Report("Invoke failed on index: %d\n", begin_index);
return;
}
// Analyze the results to obtain a prediction
int gesture_index = PredictGesture(interpreter->output(0)->data.f);
// Clear the buffer next time we read data
should_clear_buffer = gesture_index < 3;
// Produce an output
HandleOutput(error_reporter, gesture_index);
}
首先,我们尝试从加速度计中读取一些值。尝试之后,我们将should_clear_buffer设置为false,以确保暂时停止尝试清除它。
如果获取新数据失败,ReadAccelerometer() 将返回一个 false 值,然后我们将从 loop() 函数返回,以便下次调用时再次尝试。
如果 ReadAccelerometer() 返回的值是 true,我们将在我们新填充的输入张量上运行推断。我们将结果传递给 PredictGesture(),它会给出检测到的手势的索引。如果索引小于 3,则手势有效,因此我们设置 should_clear_buffer 标志,以便在下次调用 ReadAccelerometer() 时清除缓冲区。然后我们调用 HandleOutput() 报告任何结果给用户。
在 main.cc 中,main() 函数启动我们的程序,运行 setup(),并在循环中调用 loop() 函数:
int main(int argc, char* argv[]) {
setup();
while (true) {
loop();
}
}
就是这样!要在开发计算机上构建程序,请使用以下命令:
make -f tensorflow/lite/micro/tools/make/Makefile magic_wand
然后,要运行程序,请输入以下内容:
./tensorflow/lite/micro/tools/make/gen/osx_x86_64/bin/magic_wand
该程序不会产生任何输出,因为没有可用的加速度计数据,但您可以确认它构建并运行。
接下来,我们将逐个平台的代码,该代码捕获加速度计数据并生成输出。我们还展示如何部署和运行应用程序。
部署到微控制器
在本节中,我们将将代码部署到两个设备:
让我们从 Arduino 实现开始。
Arduino
Arduino Nano 33 BLE Sense 具有三轴加速度计以及蓝牙支持,体积小巧轻便,非常适合制作魔杖。
让我们逐步了解一些应用程序关键文件的 Arduino 特定实现。
Arduino 常量
常量 kConsecutiveInferenceThresholds 在文件 arduino/constants.cc 中重新定义:
// The number of expected consecutive inferences for each gesture type.
// Established with the Arduino Nano 33 BLE Sense.
const int kConsecutiveInferenceThresholds[3] = {8, 5, 4};
正如本章前面提到的,此常量存储了每个手势被视为检测到所需的连续正推断数量。该数字取决于每秒运行多少次推断,这取决于设备。因为默认数字是为 SparkFun Edge 校准的,Arduino 实现需要自己的一组数字。您可以修改这些阈值,使推断更难或更容易触发,但将它们设置得太低将导致误报。
在 Arduino 上捕获加速度计数据
Arduino 加速度计处理程序位于 arduino/accelerometer_handler.cc。它的任务是从加速度计捕获数据并将其写入模型的输入缓冲区。
我们使用的模型是使用 SparkFun Edge 板的数据进行训练的。Edge 的加速度计以每秒 25 次的速率提供一组读数。为了正常工作,它需要提供以相同速率捕获的数据。事实证明,Arduino Nano 33 BLE Sense 板上的加速度计以每秒 119 次的速率返回测量值。这意味着除了捕获数据外,我们还需要对其进行 下采样 以适应我们的模型。
尽管听起来非常技术化,但下采样实际上非常简单。为了降低信号的采样率,我们可以丢弃一些数据。我们将在以下代码中看到这是如何工作的。
首先,实现包括自己的头文件,以及一些其他文件:
#include "tensorflow/lite/micro/examples/magic_wand/
accelerometer_handler.h"
#include <Arduino.h>
#include <Arduino_LSM9DS1.h>
#include "tensorflow/lite/micro/examples/magic_wand/constants.h"
文件 Arduino.h 提供对 Arduino 平台一些基本功能的访问。文件 Arduino_LSM9DS1.h 是 Arduino_LSM9DS1 库的一部分,我们将使用它与板的加速度计通信。
接下来,我们设置一些变量:
// A buffer holding the last 200 sets of 3-channel values
float save_data[600] = {0.0};
// Most recent position in the save_data buffer
int begin_index = 0;
// True if there is not yet enough data to run inference
bool pending_initial_data = true;
// How often we should save a measurement during downsampling
int sample_every_n;
// The number of measurements since we last saved one
int sample_skip_counter = 1;
这些包括我们将用数据填充的缓冲区 save_data,以及一些用于跟踪缓冲区中当前位置和是否有足够数据开始运行推断的变量。最有趣的两个变量 sample_every_n 和 sample_skip_counter 用于下采样过程。我们稍后将更仔细地看一下这个。
接下来在文件中,程序的主循环调用SetupAccelerometer()函数,准备好捕获数据的板:
TfLiteStatus SetupAccelerometer(tflite::ErrorReporter* error_reporter) {
// Wait until we know the serial port is ready
while (!Serial) {
}
// Switch on the IMU
if (!IMU.begin()) {
error_reporter->Report("Failed to initialize IMU");
return kTfLiteError;
}
因为我们将输出一条消息来指示一切都准备就绪,它首先确保设备的串行端口准备就绪。然后打开惯性测量单元(IMU),这是包含加速度计的电子组件。IMU对象来自 Arduino_LSM9DS1 库。
下一步是开始考虑降采样。我们首先查询 IMU 库以确定板的采样率。当我们得到这个数字后,我们将其除以我们的目标采样率,该目标采样率在constants.h中的kTargetHz中定义:
// Determine how many measurements to keep in order to
// meet kTargetHz
float sample_rate = IMU.accelerationSampleRate();
sample_every_n = static_cast<int>(roundf(sample_rate / kTargetHz));
我们的目标速率是 25 Hz,板的采样率是 119 Hz;因此,我们的除法结果是 4.76。这让我们知道我们需要保留多少个 119 Hz 样本,以达到 25 Hz 的目标采样率:每 4.76 个样本中的 1 个。
因为保留一个分数个样本很困难,我们使用roundf()函数四舍五入到最近的数字 5。因此,为了降采样我们的信号,我们需要保留每五个测量值中的一个。这将导致一个有效的采样率为 23.8 Hz,这是一个足够接近的近似值,使我们的模型应该能够很好地工作。我们将这个值存储在sample_every_n变量中以供以后使用。
现在我们已经确定了降采样的参数,我们向用户发送一条消息,告诉他们应用程序已经准备好了,然后从SetupAccelerometer()函数返回:
error_reporter->Report("Magic starts!");
return kTfLiteOk;
}
接下来,我们定义ReadAccelerometer()。这个函数的任务是捕获新数据并将其写入模型的输出张量。它从一些代码开始,用于在成功识别手势后清除其内部缓冲区,为任何后续手势做好准备:
bool ReadAccelerometer(tflite::ErrorReporter* error_reporter, float* input,
int length, bool reset_buffer) {
// Clear the buffer if required, e.g. after a successful prediction
if (reset_buffer) {
memset(save_data, 0, 600 * sizeof(float));
begin_index = 0;
pending_initial_data = true;
}
接下来,我们使用 IMU 库在循环中检查是否有可用数据。如果有数据可用,我们读取它:
// Keep track of whether we stored any new data
bool new_data = false;
// Loop through new samples and add to buffer
while (IMU.accelerationAvailable()) {
float x, y, z;
// Read each sample, removing it from the device's FIFO buffer
if (!IMU.readAcceleration(x, y, z)) {
error_reporter->Report("Failed to read data");
break;
}
Arduino Nano 33 BLE Sense 板上的加速度计配备有一个称为FIFO 缓冲区的东西。这是一个特殊的内存缓冲区,位于加速度计本身上,保存最近的 32 个测量值。由于它是加速度计硬件的一部分,FIFO 缓冲区在我们的应用程序代码运行时继续积累测量值。如果没有 FIFO 缓冲区,我们可能会丢失很多数据,这意味着我们将无法准确记录所做手势。
当我们调用IMU.accelerationAvailable()时,我们正在查询加速度计,看看其 FIFO 缓冲区中是否有新数据可用。使用我们的循环,我们继续从缓冲区中读取所有数据,直到没有剩余数据为止。
接下来,我们实现我们超级简单的降采样算法:
// Throw away this sample unless it's the nth
if (sample_skip_counter != sample_every_n) {
sample_skip_counter += 1;
continue;
}
我们的方法是保留每n个样本中的一个,其中n存储在sample_every_n中。为了做到这一点,我们维护一个计数器,sample_skip_counter,它让我们知道自上次保留的样本以来已经读取了多少个样本。对于我们读取的每个测量值,我们检查它是否是第n个。如果不是,我们将continue循环,而不将数据写入任何地方,有效地将其丢弃。这个简单的过程导致我们的数据被降采样。
如果执行超过这一点,我们计划保留数据。为了做到这一点,我们将数据写入我们的save_data缓冲区中的连续位置:
// Write samples to our buffer, converting to milli-Gs
// and flipping y and x order for compatibility with
// model (sensor orientation is different on Arduino
// Nano BLE Sense compared with SparkFun Edge)
save_data[begin_index++] = y * 1000;
save_data[begin_index++] = x * 1000;
save_data[begin_index++] = z * 1000;
我们的模型按顺序接受加速度计测量值 x、y、z。您会注意到这里我们在将 x 值写入缓冲区之前将 y 值写入。这是因为我们的模型是在 SparkFun Edge 板上捕获的数据进行训练的,其加速度计的轴指向的物理方向与 Arduino 上的不同。这种差异意味着 SparkFun Edge 的 x 轴等同于 Arduino 的 y 轴,反之亦然。通过在我们的代码中交换这些轴的数据,我们可以确保我们的模型正在接收可以理解的数据。
我们循环的最后几行做一些家务,设置一些在我们循环中使用的状态变量:
// Since we took a sample, reset the skip counter
sample_skip_counter = 1;
// If we reached the end of the circle buffer, reset
if (begin_index >= 600) {
begin_index = 0;
}
new_data = true;
}
我们重置我们的降采样计数器,确保我们不会超出样本缓冲区的末尾,并设置一个标志以指示已保存新数据。
获取这些新数据后,我们进行更多的检查。这次,我们确保我们有足够的数据来执行推理。如果没有,或者这次没有捕获到新数据,我们将在不执行任何操作的情况下从函数中返回:
// Skip this round if data is not ready yet
if (!new_data) {
return false;
}
// Check if we are ready for prediction or still pending more initial data
if (pending_initial_data && begin_index >= 200) {
pending_initial_data = false;
}
// Return if we don't have enough data
if (pending_initial_data) {
return false;
}
当没有新数据时返回false,我们确保调用函数知道不要运行推理。
如果我们走到这一步,我们已经获得了一些新数据。我们复制适当数量的数据,包括我们的新样本,到输入张量中:
// Copy the requested number of bytes to the provided input tensor
for (int i = 0; i < length; ++i) {
int ring_array_index = begin_index + i - length;
if (ring_array_index < 0) {
ring_array_index += 600;
}
input[i] = save_data[ring_array_index];
}
return true;
}
就是这样!我们已经填充了输入张量,并准备运行推理。推理运行后,结果被传递到手势预测器,该预测器确定是否已经发现了有效手势。结果被传递到输出处理程序,接下来我们将详细介绍。
在 Arduino 上响应手势
输出处理程序在arduino/output_handler.cc中定义。它非常简单:根据检测到的手势记录信息到串行端口,并在每次运行推理时切换板上的 LED。
函数第一次运行时,LED 被配置为输出:
void HandleOutput(tflite::ErrorReporter* error_reporter, int kind) {
// The first time this method runs, set up our LED
static bool is_initialized = false;
if (!is_initialized) {
pinMode(LED_BUILTIN, OUTPUT);
is_initialized = true;
}
接下来,LED 在每次推理时切换开关:
// Toggle the LED every time an inference is performed
static int count = 0;
++count;
if (count & 1) {
digitalWrite(LED_BUILTIN, HIGH);
} else {
digitalWrite(LED_BUILTIN, LOW);
}
最后,我们根据匹配的手势打印一些漂亮的 ASCII 艺术:
// Print some ASCII art for each gesture
if (kind == 0) {
error_reporter->Report(
"WING:\n\r* * *\n\r * * * "
"*\n\r * * * *\n\r * * * *\n\r * * "
"* *\n\r * *\n\r");
} else if (kind == 1) {
error_reporter->Report(
"RING:\n\r *\n\r * *\n\r * *\n\r "
" * *\n\r * *\n\r * *\n\r "
" *\n\r");
} else if (kind == 2) {
error_reporter->Report(
"SLOPE:\n\r *\n\r *\n\r *\n\r *\n\r "
"*\n\r *\n\r *\n\r * * * * * * * *\n\r");
}
现在很难阅读,但当您将应用程序部署到您的板上时,您将获得完整的输出荣耀。
运行示例
要部署此示例,我们需要以下内容:
-
一个 Arduino Nano 33 BLE Sense 板
-
一个 micro-USB 电缆
-
Arduino IDE
提示
建议检查README.md以获取最新说明,因为构建过程可能会在本书编写后发生变化。
本书中的项目作为 TensorFlow Lite Arduino 库中的示例代码可用。如果您尚未安装该库,请打开 Arduino IDE 并从工具菜单中选择管理库。在出现的窗口中,搜索并安装名为 TensorFlowLite 的库。您应该能够使用最新版本,但如果遇到问题,本书测试的版本是 1.14-ALPHA。
注意
您还可以从 .zip 文件安装库,您可以从 TensorFlow Lite 团队下载或使用 TensorFlow Lite for Microcontrollers Makefile 生成自己的 .zip 文件。如果您更喜欢后者,请参阅附录 A。
安装完库后,magic_wand示例将显示在文件菜单下的 Examples→Arduino_TensorFlowLite 中,如图 11-5 所示。
单击“magic_wand”加载示例。它将显示为一个新窗口,每个源文件都有一个选项卡。第一个选项卡中的文件 magic_wand 相当于我们之前介绍的 main_functions.cc。
注意
“运行示例”已经解释了 Arduino 示例的结构,所以我们不会在这里再次介绍。

图 11-5. 示例菜单
除了 TensorFlow 库,我们还需要安装和修补 Arduino_LSM9DS1 库。默认情况下,该库不启用示例所需的 FIFO 缓冲区,因此我们需要对其代码进行一些修改。
在 Arduino IDE 中,选择 Tools→Manage Libraries,然后搜索 Arduino_LSM9DS1。为了确保以下说明有效,你必须安装驱动程序的 1.0.0 版本。
注意
可能在你阅读本章时,驱动程序已经修复。你可以在README.md中找到最新的部署说明。
驱动程序将安装到Arduino/libraries目录中,子目录为Arduino_LSM9DS1。
打开Arduino_LSM9DS1/src/LSM9DS1.cpp驱动源文件,然后转到名为LSM9DS1Class::begin()的函数。在函数末尾插入以下行,即在return 1语句之前立即插入:
// Enable FIFO (see docs https://www.st.com/resource/en/datasheet/DM00103319.pdf)
// writeRegister(LSM9DS1_ADDRESS, 0x23, 0x02);
// Set continuous mode
writeRegister(LSM9DS1_ADDRESS, 0x2E, 0xC0);
接下来,找到名为LSM9DS1Class::accelerationAvailable()的函数。你会看到以下几行:
if (readRegister(LSM9DS1_ADDRESS, LSM9DS1_STATUS_REG) & 0x01) {
return 1;
}
注释掉那些行,然后用以下内容替换它们:
// Read FIFO_SRC. If any of the rightmost 8 bits have a value, there is data.
if (readRegister(LSM9DS1_ADDRESS, 0x2F) & 63) {
return 1;
}
保存文件。补丁现在已经完成!
要运行示例,请通过 USB 插入 Arduino 设备。在工具菜单中,确保从 Board 下拉列表中选择正确的设备类型,如图 11-6 所示。
如果你的设备名称不在列表中,你需要安装支持包。要做到这一点,点击 Boards Manager,然后在弹出的窗口中搜索你的设备并安装相应支持包的最新版本。
接下来,确保设备的端口在 Port 下拉列表中被选中,也在 Tools 菜单中,如图 11-7 所示。

图 11-6. Board 下拉列表

图 11-7. 端口下拉列表
最后,在 Arduino 窗口中点击上传按钮(在图 11-8 中用白色标出)来编译并上传代码到你的 Arduino 设备。

图 11-8. 上传按钮
上传成功后,你应该看到 Arduino 板上的 LED 开始闪烁。
要尝试一些手势,请在 Tools 菜单中选择 Serial Monitor。最初你应该看到以下输出:
Magic starts!
现在你可以尝试做一些手势。用一只手举起板,组件朝上,USB 适配器朝向左边,如图 11-9 所示。

图 11-9. 手势执行时如何握住板
图 11-10 展示了如何执行每个手势的图表。因为该模型是在连接到魔杖时收集数据进行训练的,你可能需要几次尝试才能使它们起作用。

图 11-10. 三个魔杖手势
最容易开始的是“wing”。你应该快速移动手,大约需要一秒钟来执行手势。如果成功了,你应该看到以下输出,红色 LED 应该点亮:
WING:
* * *
* * * *
* * * *
* * * *
* * * *
* *
恭喜,你已经使用 Arduino 施展了你的第一个魔法咒语!
注意
此时,你可以选择创意地将板连接到魔杖的尖端,离手最远的地方。任何长度约一英尺(30 厘米)的棍子、尺子或其他家用物品都应该很好用。
确保设备牢固连接,并且朝向相同,组件朝上,USB 适配器朝向左边。选择一个坚固的魔杖,而不是一个灵活的;任何摇晃都会影响加速度计读数。
接下来,尝试“环”手势,用手(或魔杖的尖端)画一个顺时针圆圈。再次,尽量花费一秒钟执行手势。您应该看到以下内容如同魔术般出现:
RING:
*
* *
* *
* *
* *
* *
*
SparkFun Edge
SLOPE:
*
*
*
*
*
*
*
* * * * * * * *
就像任何好的魔法咒语一样,您可能需要练习一下才能每次都完美地执行它们。您可以在README.md中看到手势的视频演示。
制作您自己的更改
现在您已经部署了基本应用程序,请尝试玩耍并对代码进行一些更改。只需在 Arduino IDE 中编辑文件并保存,然后重复之前的说明以将修改后的代码部署到设备上。
以下是您可以尝试的一些事情:
-
尝试在arduino/constants.cc中调整阈值值,以使手势更容易或更难执行(以增加误报或漏报的代价)。
-
在您的计算机上编写一个程序,让您可以使用物理手势执行任务。
-
扩展程序以通过蓝牙传输检测结果。有一些示例显示如何做到这一点,这些示例包含在ArduinoBLE 库中,您可以通过 Arduino IDE 下载。
sparkfun_edge/accelerometer_handler.cc中包含捕获加速度计数据的代码。其中很多是特定于设备的,但我们将跳过实现细节,专注于重要的内容。
对于最后一个手势,在空中画一个三角形的角。最好通过 ASCII 艺术演示来描述,如下所示:
您会注意到调用一个名为initAccelerometer()的函数。这在SparkFun Edge BSP 的加速度计示例中定义,当我们的项目构建时,它作为依赖项被下载下来。它执行各种任务来打开和配置板载加速度计。
捕获 SparkFun Edge 上的加速度计数据
捕获加速度计数据的第一步是配置硬件。SetupAccelerometer()函数通过设置加速度计所需的各种低级参数来启动这个过程:
TfLiteStatus SetupAccelerometer(tflite::ErrorReporter* error_reporter) {
// Set the clock frequency.
am_hal_clkgen_control(AM_HAL_CLKGEN_CONTROL_SYSCLK_MAX, 0);
// Set the default cache configuration
am_hal_cachectrl_config(&am_hal_cachectrl_defaults);
am_hal_cachectrl_enable();
// Configure the board for low power operation.
am_bsp_low_power_init();
// Collecting data at 25Hz.
int accInitRes = initAccelerometer();
SparkFun Edge 配有三轴加速度计、电池支架和蓝牙支持。这使它非常适合用作魔杖,因为它可以无线操作。
加速度计运行后,我们启用其FIFO 缓冲区。这是一个特殊的内存缓冲区,位于加速度计本身上,可以保存最后 32 个数据点。通过启用它,我们能够在我们的应用程序代码忙于运行推断时继续收集加速度计测量数据。函数的其余部分设置缓冲区并在出现问题时记录错误:
// Enable the accelerometer's FIFO buffer.
// Note: LIS2DH12 has a FIFO buffer which holds up to 32 data entries. It
// accumulates data while the CPU is busy. Old data will be overwritten if
// it's not fetched in time, so we need to make sure that model inference is
// faster than 1/25Hz * 32 = 1.28s
if (lis2dh12_fifo_set(&dev_ctx, 1)) {
error_reporter->Report("Failed to enable FIFO buffer.");
}
if (lis2dh12_fifo_mode_set(&dev_ctx, LIS2DH12_BYPASS_MODE)) {
error_reporter->Report("Failed to clear FIFO buffer.");
return 0;
}
if (lis2dh12_fifo_mode_set(&dev_ctx, LIS2DH12_DYNAMIC_STREAM_MODE)) {
error_reporter->Report("Failed to set streaming mode.");
return 0;
}
error_reporter->Report("Magic starts!");
return kTfLiteOk;
}
初始化完成后,我们可以调用ReadAccelerometer()函数来获取最新数据。这将发生在每次推断之间。
首先,如果reset_buffer参数为true,ReadAccelerometer()会对其数据缓冲区进行重置。在检测到有效手势后执行此操作,以便为进一步手势提供一个干净的基础。作为这个过程的一部分,我们使用am_util_delay_ms()让我们的代码等待 10 毫秒。没有这个延迟,当读取新数据时代码经常会挂起(截至目前为止,原因尚不清楚,但如果您确定有更好的修复方法,TensorFlow 开源项目欢迎拉取请求):
bool ReadAccelerometer(tflite::ErrorReporter* error_reporter, float* input,
int length, bool reset_buffer) {
// Clear the buffer if required, e.g. after a successful prediction
if (reset_buffer) {
memset(save_data, 0, 600 * sizeof(float));
begin_index = 0;
pending_initial_data = true;
// Wait 10ms after a reset to avoid hang
am_util_delay_ms(10);
}
重置主缓冲区后,ReadAccelerometer()会检查加速度计的 FIFO 缓冲区中是否有任何新数据可用。如果还没有可用的数据,我们就从函数中返回:
// Check FIFO buffer for new samples
lis2dh12_fifo_src_reg_t status;
if (lis2dh12_fifo_status_get(&dev_ctx, &status)) {
error_reporter->Report("Failed to get FIFO status.");
return false;
}
int samples = status.fss;
if (status.ovrn_fifo) {
samples++;
}
// Skip this round if data is not ready yet
if (samples == 0) {
return false;
}
我们的应用程序的主循环将继续调用,这意味着一旦有数据可用,我们就可以继续执行。
函数的下一部分循环遍历新数据并将其存储在另一个更大的缓冲区中。首先,我们设置一个特殊类型为axis3bit16_t的结构体,用于保存加速度计数据。然后我们调用lis2dh12_acceleration_raw_get()来填充下一个可用的测量值。如果此函数失败,将返回零,此时我们会显示错误:
// Load data from FIFO buffer
axis3bit16_t data_raw_acceleration;
for (int i = 0; i < samples; i++) {
// Zero out the struct that holds raw accelerometer data
memset(data_raw_acceleration.u8bit, 0x00, 3 * sizeof(int16_t));
// If the return value is non-zero, sensor data was successfully read
if (lis2dh12_acceleration_raw_get(&dev_ctx, data_raw_acceleration.u8bit)) {
error_reporter->Report("Failed to get raw data.");
如果成功获取了测量值,我们将其转换为毫 G,这是模型期望的测量单位,然后将其写入save_data[],这是一个我们用作缓冲区以存储将用于推理的值的数组。加速度计每个轴的值是连续存储的:
} else {
// Convert each raw 16-bit value into floating point values representing
// milli-Gs, a unit of acceleration, and store in the current position of
// our buffer
save_data[begin_index++] =
lis2dh12_from_fs2_hr_to_mg(data_raw_acceleration.i16bit[0]);
save_data[begin_index++] =
lis2dh12_from_fs2_hr_to_mg(data_raw_acceleration.i16bit[1]);
save_data[begin_index++] =
lis2dh12_from_fs2_hr_to_mg(data_raw_acceleration.i16bit[2]);
// Start from beginning, imitating loop array.
if (begin_index >= 600) begin_index = 0;
}
}
我们的save_data[]数组可以存储 200 组三轴值,因此当达到 600 时,我们将begin_index计数器设置回 0。
我们现在已经将所有新数据合并到我们的save_data[]缓冲区中。接下来,我们检查是否有足够的数据进行预测。在测试模型时,发现我们总缓冲区大小的三分之一是可靠预测所需的最少数据量;因此,如果我们至少有这么多数据,我们将pending_initial_data标志设置为false(默认为true):
// Check if we are ready for prediction or still pending more initial data
if (pending_initial_data && begin_index >= 200) {
pending_initial_data = false;
}
接下来,如果仍然没有足够的数据来运行推理,我们将返回false:
// Return if we don't have enough data
if (pending_initial_data) {
return false;
}
如果我们走到这一步,缓冲区中有足够的数据来运行推理。函数的最后部分将请求的数据从缓冲区复制到input参数中,该参数是指向模型输入张量的指针:
// Copy the requested number of bytes to the provided input tensor
for (int i = 0; i < length; ++i) {
int ring_array_index = begin_index + i - length;
if (ring_array_index < 0) {
ring_array_index += 600;
}
input[i] = save_data[ring_array_index];
}
return true;
变量length是传递给ReadAccelerometer()的参数,用于确定应复制多少数据。因为我们的模型以 128 个三轴读数作为输入,所以main_functions.cc中的代码调用ReadAccelerometer(),长度为 384(128 * 3)。
此时,我们的输入张量已填充新的加速度计数据。将运行推理,手势预测器将解释结果,并将结果传递给输出处理程序显示给用户。
响应 SparkFun Edge 上的手势
输出处理程序位于sparkfun_edge/output_handler.cc,非常简单。第一次运行时,我们为 LED 配置输出:
void HandleOutput(tflite::ErrorReporter* error_reporter, int kind) {
// The first time this method runs, set up our LEDs correctly
static bool is_initialized = false;
if (!is_initialized) {
am_hal_gpio_pinconfig(AM_BSP_GPIO_LED_RED, g_AM_HAL_GPIO_OUTPUT_12);
am_hal_gpio_pinconfig(AM_BSP_GPIO_LED_BLUE, g_AM_HAL_GPIO_OUTPUT_12);
am_hal_gpio_pinconfig(AM_BSP_GPIO_LED_GREEN, g_AM_HAL_GPIO_OUTPUT_12);
am_hal_gpio_pinconfig(AM_BSP_GPIO_LED_YELLOW, g_AM_HAL_GPIO_OUTPUT_12);
is_initialized = true;
}
接下来,我们每次推理时切换黄色 LED:
// Toggle the yellow LED every time an inference is performed
static int count = 0;
++count;
if (count & 1) {
am_hal_gpio_output_set(AM_BSP_GPIO_LED_YELLOW);
} else {
am_hal_gpio_output_clear(AM_BSP_GPIO_LED_YELLOW);
}
之后,我们检查检测到了哪个手势。对于每个单独的手势,我们点亮一个 LED,清除所有其他 LED,并通过串行端口输出一些漂亮的 ASCII 艺术。以下是处理“翼”手势的代码:
// Set the LED color and print a symbol (red: wing, blue: ring, green: slope)
if (kind == 0) {
error_reporter->Report(
"WING:\n\r* * *\n\r * * * "
"*\n\r * * * *\n\r * * * *\n\r * * "
"* *\n\r * *\n\r");
am_hal_gpio_output_set(AM_BSP_GPIO_LED_RED);
am_hal_gpio_output_clear(AM_BSP_GPIO_LED_BLUE);
am_hal_gpio_output_clear(AM_BSP_GPIO_LED_GREEN);
在串行端口监视器上,输出将如下所示:
WING:
* * *
* * * *
* * * *
* * * *
* * * *
* *
每个手势使用不同的串行输出和 LED。
运行示例
我们现在已经看到了 SparkFun Edge 代码的工作原理。接下来,让我们在我们的硬件上运行它。
提示
建议检查README.md以获取最新指令,因为构建过程可能会有变化。
构建和部署我们的代码,我们需要以下内容:
-
带有Himax HM01B0 breakout的 SparkFun Edge 板
-
一个 USB 编程器(我们推荐 SparkFun Serial Basic Breakout,可在micro-B USB和USB-C变体中获得)
-
匹配的 USB 电缆
-
Python 3 和一些依赖项
注意
如果您不确定是否安装了正确版本的 Python,“运行示例”中有检查方法的说明。
打开一个终端窗口,克隆 TensorFlow 存储库,然后切换到其目录:
git clone https://github.com/tensorflow/tensorflow.git
cd tensorflow
接下来,我们将构建二进制文件,并运行一些命令,使其准备好下载到设备中。为了避免一些打字,您可以从README.md中复制并粘贴这些命令。
构建二进制文件
以下命令下载所有必需的依赖项,然后为 SparkFun Edge 编译一个二进制文件:
make -f tensorflow/lite/micro/tools/make/Makefile \
TARGET=sparkfun_edge magic_wand_bin
二进制文件将被创建为一个.bin文件,位于以下位置:
tensorflow/lite/micro/tools/make/gen/
sparkfun_edge_cortex-m4/bin/magic_wand.bin
要检查文件是否存在,您可以使用以下命令:
test -f tensorflow/lite/micro/tools/make/gen/sparkfun_edge_ \
cortex-m4/bin/magic_wand.bin && echo "Binary was successfully created" || \
echo "Binary is missing"
如果运行该命令,您应该看到二进制文件已成功创建打印到控制台。
如果看到二进制文件丢失,则构建过程中出现问题。如果是这样,很可能在make命令的输出中有一些关于出错原因的线索。
对二进制文件进行签名
二进制文件必须使用加密密钥进行签名才能部署到设备上。让我们运行一些命令来对二进制文件进行签名,以便可以将其刷入 SparkFun Edge。此处使用的脚本来自 Ambiq SDK,在运行 Makefile 时下载。
输入以下命令设置一些虚拟加密密钥,供开发使用:
cp tensorflow/lite/micro/tools/make/downloads/AmbiqSuite-Rel2.0.0/ \
tools/apollo3_scripts/keys_info0.py
tensorflow/lite/micro/tools/make/downloads/AmbiqSuite-Rel2.0.0/ \
tools/apollo3_scripts/keys_info.py
接下来,运行以下命令以创建一个已签名的二进制文件。如果需要,将python3替换为python:
python3 tensorflow/lite/micro/tools/make/downloads/ \
AmbiqSuite-Rel2.0.0/tools/apollo3_scripts/create_cust_image_blob.py \
--bin tensorflow/lite/micro/tools/make/gen/ \
sparkfun_edge_cortex-m4/bin/micro_vision.bin \
--load-address 0xC000 \
--magic-num 0xCB \
-o main_nonsecure_ota \
--version 0x0
这将创建文件main_nonsecure_ota.bin。现在,运行此命令创建文件的最终版本,您可以使用该文件刷写设备,使用下一步中将使用的脚本:
python3 tensorflow/lite/micro/tools/make/downloads/ \
AmbiqSuite-Rel2.0.0/tools/apollo3_scripts/create_cust_wireupdate_blob.py \
--load-address 0x20000 \
--bin main_nonsecure_ota.bin \
-i 6 \
-o main_nonsecure_wire \
--options 0x1
现在,您应该在运行命令的目录中有一个名为main_nonsecure_wire.bin的文件。这是您将要刷写到设备的文件。
刷写二进制文件
SparkFun Edge 将当前运行的程序存储在其 1 兆字节的闪存中。如果您希望板运行一个新程序,您需要将其发送到板上,该程序将存储在闪存中,覆盖以前保存的任何程序。这个过程称为刷写。
将编程器连接到板上
要下载新程序到板上,您将使用 SparkFun USB-C 串行基本串行编程器。该设备允许您的计算机通过 USB 与微控制器通信。
要将此设备连接到您的板上,请执行以下步骤:
-
在 SparkFun Edge 的一侧,找到六针排针。
-
将 SparkFun USB-C 串行基本插入这些引脚,确保每个设备上标记为 BLK 和 GRN 的引脚正确对齐。
您可以在图 11-11 中看到正确的排列方式。

图 11-11. 连接 SparkFun Edge 和 USB-C 串行基本(图片由 SparkFun 提供)
将编程器连接到计算机
接下来,通过 USB 将板连接到计算机。要对板进行编程,您需要确定计算机给设备的名称。最好的方法是在连接设备之前和之后列出所有计算机的设备,然后查看哪个设备是新的。
警告
一些人报告了他们的操作系统默认驱动程序与编程器存在问题,因此我们强烈建议在继续之前安装驱动程序。
在通过 USB 连接设备之前,请运行以下命令:
# macOS:
ls /dev/cu*
# Linux:
ls /dev/tty*
这应该输出一个附加设备列表,看起来类似于以下内容:
/dev/cu.Bluetooth-Incoming-Port
/dev/cu.MALS
/dev/cu.SOC
现在,将编程器连接到计算机的 USB 端口,并再次运行命令:
# macOS:
ls /dev/cu*
# Linux:
ls /dev/tty*
您应该在输出中看到一个额外的项目,就像以下示例一样。您的新项目可能有不同的名称。这个新项目是设备的名称:
/dev/cu.Bluetooth-Incoming-Port
/dev/cu.MALS
/dev/cu.SOC
/dev/cu.wchusbserial-1450
这个名称将用于引用设备。但是,它可能会根据编程器连接到哪个 USB 端口而改变,因此如果您从计算机断开板然后重新连接,您可能需要再次查找其名称。
提示
一些用户报告列表中出现了两个设备。如果看到两个设备,则正确的设备名称以“wch”开头;例如,“/dev/wchusbserial-14410”。
确定设备名称后,将其放入一个 shell 变量以供以后使用:
export DEVICENAME=<*your device name here*>
这是在后续过程中运行需要设备名称的命令时可以使用的变量。
运行脚本刷写您的板
要刷写板子,您需要将其置于特殊的“引导加载程序”状态,以准备接收新的二进制文件。然后您可以运行一个脚本将二进制文件发送到板子上。
首先创建一个环境变量来指定波特率,即数据发送到设备的速度:
export BAUD_RATE=921600
现在将以下命令粘贴到您的终端中,但不要按 Enter!命令中的${DEVICENAME}和${BAUD_RATE}将替换为您在前几节中设置的值。如有必要,请将python3替换为python:
python3 tensorflow/lite/micro/tools/make/downloads/ \
AmbiqSuite-Rel2.0.0/tools/apollo3_scripts/uart_wired_update.py -b \
${BAUD_RATE} ${DEVICENAME} -r 1 -f main_nonsecure_wire.bin -i 6
接下来您将重置板子到引导加载程序状态并刷写板子。
在板子上,找到标记为RST和14的按钮,如图 11-12 所示。

图 11-12. SparkFun Edge 的按钮
执行以下步骤:
-
确保您的板子连接到编程器,并且整个设备通过 USB 连接到计算机。
-
在板子上,按住标记为
14的按钮。继续按住。 -
在仍然按住标记为
14的按钮的同时,按下标记为RST的按钮重置板子。 -
在计算机上按 Enter 键运行脚本。继续按住按钮
14。
现在您应该会看到类似以下内容出现在屏幕上:
Connecting with Corvette over serial port /dev/cu.usbserial-1440...
Sending Hello.
Received response for Hello
Received Status
length = 0x58
version = 0x3
Max Storage = 0x4ffa0
Status = 0x2
State = 0x7
AMInfo =
0x1
0xff2da3ff
0x55fff
0x1
0x49f40003
0xffffffff
[...lots more 0xffffffff...]
Sending OTA Descriptor = 0xfe000
Sending Update Command.
number of updates needed = 1
Sending block of size 0x158b0 from 0x0 to 0x158b0
Sending Data Packet of length 8180
Sending Data Packet of length 8180
[...lots more Sending Data Packet of length 8180...]
继续按住按钮14,直到看到Sending Data Packet of length 8180。在看到这个后可以释放按钮(但如果您继续按住也没关系)。
程序将继续在终端上打印行。最终,您会看到类似以下内容:
[...lots more Sending Data Packet of length 8180...]
Sending Data Packet of length 8180
Sending Data Packet of length 6440
Sending Reset Command.
Done.
这表示刷写成功。
提示
如果程序输出以错误结束,请检查是否打印了Sending Reset Command.。如果是,则尽管有错误,刷写很可能成功。否则,刷写可能失败。尝试再次运行这些步骤(您可以跳过设置环境变量)。
测试程序
首先按下RST按钮确保程序正在运行。当程序运行时,黄色 LED 灯会每次推断时闪烁一次。
接下来,使用以下命令开始打印设备的串行输出:
screen ${DEVICENAME} 115200
最初您应该会看到以下输出:
Magic starts!
现在您可以尝试做一些手势。用一只手举起板子,组件朝上,USB 适配器朝向左侧,如图 11-13 所示。

图 11-13. 手势执行时如何握住板子
图 11-14 展示了如何执行每个手势的图表。由于模型是在板子连接到魔杖时收集的数据上进行训练的,您可能需要尝试几次才能使它们正常工作。

图 11-14. 三个魔杖手势
最容易开始的是“翅膀”。您应该将手移动得足够快,以便大约一秒钟完成手势。如果成功,红色 LED 灯应该会亮起,并且您应该会看到以下输出:
WING:
* * *
* * * *
* * * *
* * * *
* * * *
* *
恭喜,您已经使用 SparkFun Edge 施展了您的第一个魔法咒语!
注意
此时,您可以选择发挥创造力,将板子连接到魔杖的尖端,距离手最远的地方。任何棍子、尺子或其他长度约为一英尺(30 厘米)的家用物品都应该很好用。
确保设备牢固连接,并且朝向相同,组件朝上,USB 适配器朝向左侧。选择一个坚固的魔杖,而不是一个灵活的魔杖,因为任何摇晃都会影响加速度计读数。
接下来尝试“环”手势,用手(或魔杖的尖端)顺时针画一个圆圈。再次,尽量花大约一秒钟来执行手势。您应该会看到以下内容,仿佛是魔法般出现的:
RING:
*
* *
* *
* *
* *
* *
*
对于最后一个手势,在空中画一个三角形的角。最好通过 ASCII 艺术演示来描述,如下所示:
SLOPE:
*
*
*
*
*
*
*
* * * * * * * *
就像任何好的魔法咒语一样,您可能需要练习一下,才能每次都完美地执行它们。您可以在README.md中看到手势的视频演示。
进行您自己的更改
现在您已经部署了基本应用程序,请尝试玩耍并进行一些更改。您可以在tensorflow/lite/micro/examples/magic_wand文件夹中找到应用程序的代码。只需编辑并保存,然后重复之前的说明以将修改后的代码部署到设备上。
以下是您可以尝试的一些事项:
-
尝试在constants.cc中调整阈值,使手势更容易或更难执行(以更多的误报或漏报为代价)。
-
在您的计算机上编写一个程序,让您可以使用物理手势执行任务。
-
扩展程序以通过蓝牙传输检测结果。在Ambiq SDK中有一个如何做到这一点的示例,在AmbiqSuite-Rel2.0.0/boards/apollo3_evb/examples/uart_ble_bridge中。构建魔法棒应用程序时,SDK 将下载到tensorflow/tensorflow/lite/micro/tools/make/downloads/AmbiqSuite-Rel2.0.0中。
总结
在本章中,您看到了一个有趣的例子,即嵌入式机器学习应用程序如何将模糊的传感器数据解释为更有用的形式。通过观察噪声中的模式,嵌入式机器学习模型使设备能够理解周围的世界并警示我们事件,即使原始数据对人类来说可能很难消化。
在第十二章中,我们探讨了我们的魔法棒模型是如何工作的,并学习如何收集数据并训练我们自己的魔法咒语。
¹ Mihaela Porumb 等人,“一种卷积神经网络方法来检测充血性心力衰竭。”生物医学信号处理和控制(2020 年 1 月)。https://oreil.ly/4HBFt
第十二章:魔杖:训练模型
在第十一章中,我们使用了一个 20 KB 的预训练模型来解释原始加速度计数据,用它来识别执行了一组手势中的哪一个。在本章中,我们将向您展示这个模型是如何训练的,然后我们将讨论它的实际工作原理。
我们的唤醒词和人员检测模型都需要大量数据进行训练。这主要是由于它们试图解决的问题的复杂性。一个人说“是”或“不”有很多不同的方式——想想所有使某人的声音独特的口音、语调和音调的变化。同样,一个人在图像中出现的方式有无限多种可能;你可能看到他们的脸、整个身体或一个手,他们可能站在任何可能的姿势中。
为了能够准确分类如此多样的有效输入,模型需要在同样多样的训练数据集上进行训练。这就是为什么我们的唤醒词和人员检测训练数据集如此庞大,以及为什么训练需要如此长时间。
我们的魔杖手势识别问题要简单得多。在这种情况下,我们并不是试图对广泛范围的自然声音或人类外貌和姿势进行分类,而是试图理解三种特定和故意选择的手势之间的差异。虽然不同人执行每个手势的方式会有一些变化,但我们希望我们的用户会尽可能正确和统一地执行这些手势。
这意味着我们期望的有效输入变化会少得多,这使得在不需要大量数据的情况下训练准确的模型变得更加容易。事实上,我们将用来训练模型的数据集每种手势只包含大约 150 个示例,总大小仅为 1.5 MB。想到一个有用的模型可以在如此小的数据集上训练,真是令人兴奋,因为获得足够的数据通常是机器学习项目中最困难的部分。
在本章的第一部分,您将学习如何训练魔杖应用程序中使用的原始模型。在第二部分,我们将讨论这个模型的实际工作原理。最后,您将看到如何捕获自己的数据并训练一个识别不同手势的新模型。
训练模型
为了训练我们的模型,我们使用了位于 TensorFlow 存储库中的训练脚本。您可以在magic_wand/train中找到它们。
脚本执行以下任务:
-
为训练准备原始数据。
-
生成合成数据。¹
-
将数据拆分为训练、验证和测试集。
-
执行数据增强。
-
定义模型架构。
-
运行训练过程。
-
将模型转换为 TensorFlow Lite 格式。
为了简化生活,这些脚本附带了一个 Jupyter 笔记本,演示了如何使用它们。您可以在 Colaboratory(Colab)上的 GPU 运行时中运行笔记本。使用我们的小数据集,训练只需要几分钟。
首先,让我们在 Colab 中走一遍训练过程。
在 Colab 中进行训练
打开magic_wand/train/train_magic_wand_model.ipynb中的 Jupyter 笔记本,并单击“在 Google Colab 中运行”按钮,如图 8-1 所示。

图 12-1。在 Google Colab 中运行按钮
注
截至目前,GitHub 存在一个错误,导致在显示 Jupyter 笔记本时会出现间歇性错误消息。如果在尝试访问笔记本时看到消息“抱歉,出了点问题。重新加载?”,请按照“构建我们的模型”中的说明操作。
本笔记本将演示训练模型的过程。它包括以下步骤:
-
安装依赖项
-
下载和准备数据
-
加载 TensorBoard 以可视化训练过程
-
训练模型
-
生成 C 源文件
启用 GPU 训练
训练这个模型应该非常快,但如果我们使用 GPU 运行时会更快。要启用此选项,请转到 Colab 的运行时菜单,并选择“更改运行时类型”,如图 12-2 所示。
这将打开图 12-3 所示的“笔记本设置”对话框。
从“硬件加速器”下拉列表中选择 GPU,如图 12-4 所示,然后点击保存。
现在您已经准备好运行笔记本了。

图 12-2。在 Colab 中更改运行时类型的选项

图 12-3。笔记本设置对话框

图 12-4。硬件加速器下拉列表
安装依赖项
第一步是安装所需的依赖项。在“安装依赖项”部分,运行单元格安装正确版本的 TensorFlow 并获取训练脚本的副本。
准备数据
接下来,在“准备数据”部分,运行单元格下载数据集并将其分割为训练、验证和测试集。
第一个单元格下载并提取数据集到训练脚本目录。数据集包括四个目录,一个用于每个手势(“wing”,“ring”和“slope”),另一个“negative”目录用于表示没有明显手势的数据。每个目录包含代表手势执行过程中捕获的原始数据的文件:
data/
├── slope
│ ├── output_slope_dengyl.txt
│ ├── output_slope_hyw.txt
│ └── ...
├── ring
│ ├── output_ring_dengyl.txt
│ ├── output_ring_hyw.txt
│ └── ...
├── negative
│ ├── output_negative_1.txt
│ └── ...
└── wing
├── output_wing_dengyl.txt
├── output_wing_hyw.txt
└── ...
每个手势有 10 个文件,我们稍后会详细介绍。每个文件包含一个由命名个体演示的手势,文件名的最后部分对应其用户 ID。例如,文件output_slope_dengyl.txt包含了用户 ID 为dengyl的用户演示“slope”手势的数据。
每个文件中大约有 15 次给定手势的表演,每行一个加速度计读数,每次表演都以行-,-,-开头:
-,-,-
-766.0,132.0,709.0
-751.0,249.0,659.0
-714.0,314.0,630.0
-709.0,244.0,623.0
-707.0,230.0,659.0
每次表演包括几秒钟的数据日志,每秒 25 行。手势本身发生在该窗口内的某个时间点,设备在其余时间内保持静止。
由于测量数据的捕获方式,文件中还包含一些垃圾字符。我们的第一个训练脚本data_prepare.py,将在第二个训练单元格中运行,将清理这些脏数据:
# Prepare the data
!python data_prepare.py
该脚本旨在从文件夹中读取原始数据文件,忽略任何垃圾字符,并将它们以经过清理的形式写入到训练脚本目录内的另一个位置(data/complete_data)。清理混乱的数据源是训练机器学习模型时的常见任务,因为大型数据集很容易出现错误、损坏和其他问题。
除了清理数据,脚本还生成了一些合成数据。这是指通过算法生成的数据,而不是从现实世界中捕获的数据。在这种情况下,data_prepare.py中的generate_negative_data()函数创建了相当于加速度计移动但不对应任何特定手势的合成数据。这些数据用于训练我们的“未知”类别。
由于生成合成数据比捕获现实世界数据要快得多,因此有助于增强我们的训练过程。然而,现实世界的变化是不可预测的,因此往往不可能完全使用合成数据创建整个数据集。在我们的情况下,这有助于使我们的“未知”类别更加健壮,但对于分类已知手势并不有用。
在第二个单元格中运行的下一个脚本是data_split_person.py:
# Split the data by person
!python data_split_person.py
这个脚本将数据分成训练、验证和测试集。因为我们的数据带有创建者的标签,我们可以使用一个人的数据进行训练,另一个人的数据进行验证,最后一个人的数据进行测试。数据分割如下:
train_names = [
"hyw", "shiyun", "tangsy", "dengyl", "jiangyh", "xunkai", "negative3",
"negative4", "negative5", "negative6"
]
valid_names = ["lsj", "pengxl", "negative2", "negative7"]
test_names = ["liucx", "zhangxy", "negative1", "negative8"]
我们使用六个人的数据进行训练,两个用于验证,两个用于测试。此外,我们混合了与特定用户无关的负面数据。我们的总数据在三个集合之间以大约 60%/20%/20%的比例分配,这对于机器学习来说是相当标准的。
通过按个人分割,我们试图确保我们的模型能够推广到新数据。因为模型将在未包含在训练数据集中的个体数据上进行验证和测试,所以模型需要对每个人执行每个手势的方式的个体变化具有鲁棒性。
也可以随机分割数据,而不是按个人分割。在这种情况下,训练、验证和测试数据集将分别包含每个个体的每个手势的一些样本。由此产生的模型将被训练在每个人的数据上,而不仅仅是六个人,因此它将更多地接触到人们不同的手势风格。
然而,由于验证和训练集也包含来自每个个体的数据,我们无法测试模型是否能够推广到之前未见过的新手势风格。以这种方式开发的模型可能在验证和测试过程中报告更高的准确性,但不能保证在新数据上的表现同样出色。
在继续之前,请确保您已经运行了“准备数据”部分中的两个单元格。
加载 TensorBoard
数据准备好后,我们可以运行下一个单元格来加载 TensorBoard,这将帮助我们监视训练过程:
# Load TensorBoard
%load_ext tensorboard
%tensorboard --logdir logs/scalars
训练日志将被写入训练脚本目录下的logs/scalars子目录中,因此我们将其传递给 TensorBoard。
开始训练
TensorBoard 加载完成后,现在是开始训练的时候了。运行以下单元格:
!python train.py --model CNN --person true
脚本train.py设置了模型架构,使用data_load.py加载数据,并开始训练过程。
当数据加载时,load_data.py还使用data_augmentation.py中定义的代码执行数据增强。函数augment_data()接受表示手势的数据,并创建一些稍微修改的新版本,每个版本都与原始数据略有不同。修改包括在时间上移动和扭曲数据点,添加随机噪声,以及增加加速度的量。这些增强数据与原始数据一起用于训练模型,有助于充分利用我们的小数据集。
随着训练的加速,您将看到一些输出出现在您刚刚运行的单元格下方。那里有很多内容,让我们挑出最值得注意的部分。首先,Keras 生成了一个漂亮的表格,显示了我们模型的架构:
_________________________________________________________________
Layer (type) Output Shape Param #
=================================================================
conv2d (Conv2D) (None, 128, 3, 8) 104
_________________________________________________________________
max_pooling2d (MaxPooling2D) (None, 42, 1, 8) 0
_________________________________________________________________
dropout (Dropout) (None, 42, 1, 8) 0
_________________________________________________________________
conv2d_1 (Conv2D) (None, 42, 1, 16) 528
_________________________________________________________________
max_pooling2d_1 (MaxPooling2 (None, 14, 1, 16) 0
_________________________________________________________________
dropout_1 (Dropout) (None, 14, 1, 16) 0
_________________________________________________________________
flatten (Flatten) (None, 224) 0
_________________________________________________________________
dense (Dense) (None, 16) 3600
_________________________________________________________________
dropout_2 (Dropout) (None, 16) 0
_________________________________________________________________
dense_1 (Dense) (None, 4) 68
=================================================================
它告诉我们所使用的所有层,以及它们的形状和参数数量——这是权重和偏差的另一个术语。您可以看到我们的模型使用了Conv2D层,因为它是一个卷积模型。在这个表中没有显示的是我们模型的输入形状是(None, 128, 3)。我们稍后会更仔细地查看模型的架构。
输出还将显示模型大小的估计:
Model size: 16.796875 KB
这代表了模型可训练参数所占用的内存量。它不包括存储模型执行图所需的额外空间,因此我们的实际模型文件会稍微大一些,但这给我们一个正确数量级的概念。这绝对可以被称为一个微小模型!
最终您将看到训练过程本身开始:
1000/1000 [==============================] - 12s 12ms/step - loss: 7.6510 - accuracy: 0.5207 - val_loss: 4.5836 - val_accuracy: 0.7206
此时,您可以查看 TensorBoard,以查看训练过程的进行情况。
评估结果
训练完成后,我们可以查看单元格的输出以获取一些有用的信息。首先,我们可以看到我们最终时期的验证准确率非常有希望,为 0.9743,损失也很低:
Epoch 50/50
1000/1000 [==============================] - 7s 7ms/step - loss: 0.0568 -
accuracy: 0.9835 - val_loss: 0.1185 - val_accuracy: 0.9743
这很棒,特别是因为我们使用了按人员数据拆分,这意味着我们的验证数据来自完全不同的一组个体。然而,我们不能仅仅依靠我们的验证准确性来评估我们的模型。因为模型的超参数和架构是在验证数据集上手动调整的,我们可能已经过度拟合了。
为了更好地了解我们模型的最终性能,我们可以通过调用 Keras 的model.evaluate()函数来评估它与我们的测试数据集的表现。下一行输出显示了这个结果:
6/6 [==============================] - 0s 6ms/step - loss: 0.2888 - accuracy: 0.9323
尽管验证数字没有那么惊人,但模型显示了一个足够好的准确率为 0.9323,损失仍然很低。该模型将在 93%的时间内预测正确的类别,这对我们的目的应该是可以接受的。
接下来的几行显示了结果的混淆矩阵,由tf.math.confusion_matrix()函数计算:
tf.Tensor(
[[ 75 3 0 4]
[ 0 69 0 15]
[ 0 0 85 3]
[ 0 0 1 129]], shape=(4, 4), dtype=int32)
混淆矩阵是评估分类模型性能的有用工具。它显示了测试数据集中每个输入的预测类别与其实际值的一致程度。
混淆矩阵的每一列对应于一个预测标签,依次为“wing”,“ring”,“slope”,然后“unknown”。从上到下,每一行对应于实际标签。从我们的混淆矩阵中,我们可以看到绝大多数预测与实际标签一致。我们还可以看到混淆发生的具体位置:最显著的是,相当多的输入被错误分类为“unknown”,特别是属于“ring”类别的输入。
混淆矩阵让我们了解模型的弱点在哪里。在这种情况下,它告诉我们,为了帮助模型更好地学习“ring”和“unknown”之间的差异,获取更多的“ring”手势的训练数据可能是有益的。
train.py的最后一步是将模型转换为 TensorFlow Lite 格式,包括浮点和量化变体。以下输出显示了每个变体的大小:
Basic model is 19544 bytes
Quantized model is 8824 bytes
Difference is 10720 bytes
我们的 20 KB 模型在量化后缩小到 8.8 KB。这是一个非常小的模型,是一个很好的结果。
创建一个 C 数组
在“创建 C 源文件”部分中的下一个单元格将其转换为 C 源文件。运行此单元格以查看输出:
# Install xxd if it is not available
!apt-get -qq install xxd
# Save the file as a C source file
!xxd -i model_quantized.tflite > /content/model_quantized.cc
# Print the source file
!cat /content/model_quantized.cc
我们可以将此文件的内容复制粘贴到我们的项目中,以便我们可以在我们的应用程序中使用新训练的模型。稍后,您将学习如何收集新数据并教导应用程序理解新的手势。现在,让我们继续前进。
运行脚本的其他方法
如果您不想使用 Colab,或者您正在更改模型训练脚本并希望在本地测试它们,您可以轻松地从自己的开发机器上运行这些脚本。您可以在README.md中找到说明。
接下来,我们将介绍模型本身的工作原理。
模型的工作原理
到目前为止,我们已经确定我们的模型是一个卷积神经网络(CNN),它将表示大约五秒时间的 128 个三轴加速度计读数序列转换为四个概率数组:一个用于每个手势,一个用于“unknown”。
当相邻值之间的关系包含重要信息时,CNNs 被用来。在我们解释的第一部分中,我们将查看我们的数据并了解为什么 CNN 非常适合理解它。
可视化输入
在我们的时间序列加速度计数据中,相邻的加速度计读数给我们关于设备运动的线索。例如,如果一个轴上的加速度从零迅速变为正值,然后再回到零,那么设备可能已经开始朝着那个方向运动。图 12-5 展示了这种假设性示例。

图 12-5. 设备单轴加速度计值
任何给定的手势由一系列运动组成,一个接着一个。例如,考虑我们的“翼”手势,如图 12-6 所示。

图 12-6. “翼”手势
设备首先向下和向右移动,然后向上和向右移动,然后再向下和向右移动,然后再向上和向右移动。图 12-7 显示了在“翼”手势期间捕获的实际数据样本,以毫 G 为单位测量。

图 12-7. “翼”手势期间的加速度计值
通过查看这个图表并将其分解为其组成部分,我们可以理解正在进行的手势。从 z 轴加速度来看,很明显设备正在上下移动,这符合我们对“翼”手势形状的预期。更微妙的是,我们可以看到 x 轴上的加速度如何与 z 轴的变化相关联,表明设备在手势的宽度方向上移动。同时,我们可以观察到 y 轴基本保持稳定。
同样,具有多层的 CNN 能够学习如何通过其特征组件部分来辨别每个手势。例如,网络可能学会区分上下运动,并且当与适当的 z 轴和 y 轴运动结合时,表示“翼”手势的两个运动。
为了做到这一点,CNN 学习一系列滤波器,排列在层中。每个滤波器学会在数据中发现特定类型的特征。当它注意到这个特征时,它将这个高级信息传递给网络的下一层。例如,网络的第一层中的一个滤波器可能学会发现一些简单的东西,比如一个向上加速的周期。当它识别到这样的结构时,它将这些信息传递给网络的下一层。
后续的滤波器层学习如何将早期更简单的滤波器的输出组合在一起形成更大的结构。例如,一系列四个交替的向上和向下的加速度可能组合在一起表示我们“翼”手势中的“W”形状。
在这个过程中,嘈杂的输入数据逐渐转化为高级符号表示。我们网络的后续层可以分析这个符号表示,猜测执行了哪个手势。
在接下来的部分中,我们将详细介绍实际的模型架构,并看看它如何映射到这个过程中。
理解模型架构
我们模型的架构在train.py中定义,在build_cnn()函数中。这个函数使用 Keras API 逐层定义模型:
model = tf.keras.Sequential([
tf.keras.layers.Conv2D( # input_shape=(batch, 128, 3)
8, (4, 3),
padding="same",
activation="relu",
input_shape=(seq_length, 3, 1)), # output_shape=(batch, 128, 3, 8)
tf.keras.layers.MaxPool2D((3, 3)), # (batch, 42, 1, 8)
tf.keras.layers.Dropout(0.1), # (batch, 42, 1, 8)
tf.keras.layers.Conv2D(16, (4, 1), padding="same",
activation="relu"), # (batch, 42, 1, 16)
tf.keras.layers.MaxPool2D((3, 1), padding="same"), # (batch, 14, 1, 16)
tf.keras.layers.Dropout(0.1), # (batch, 14, 1, 16)
tf.keras.layers.Flatten(), # (batch, 224)
tf.keras.layers.Dense(16, activation="relu"), # (batch, 16)
tf.keras.layers.Dropout(0.1), # (batch, 16)
tf.keras.layers.Dense(4, activation="softmax") # (batch, 4)
])
这是一个顺序模型,意味着每一层的输出直接传递到下一层。让我们逐层走过并探索正在发生的事情。第一层是一个Conv2D:
tf.keras.layers.Conv2D(
8, (4, 3),
padding="same",
activation="relu",
input_shape=(seq_length, 3, 1)), # output_shape=(batch, 128, 3, 8)
这是一个卷积层;它直接接收我们网络的输入,这是一系列原始加速度计数据。输入的形状在input_shape参数中提供。它设置为(seq_length, 3, 1),其中seq_length是传入的加速度计测量的总数(默认为 128)。每个测量由三个值组成,表示 x 轴、y 轴和 z 轴。输入在图 12-8 中可视化。

图 12-8. 模型的输入
我们的卷积层的工作是获取原始数据并提取一些基本特征,这些特征可以被后续层解释。Conv2D()函数的参数确定将提取多少特征。这些参数在tf.keras.layers.Conv2D()文档中有描述。
第一个参数确定层将具有多少滤波器。在训练期间,每个滤波器学习识别原始数据中的特定特征,例如,一个滤波器可能学习识别向上运动的显著特征。对于每个滤波器,层输出一个显示它所学习的特征在输入中出现位置的特征图。
我们代码中定义的层有八个滤波器,这意味着它将学习识别并输出来自输入数据的八种不同类型的高级特征。您可以在输出形状(batch_size, 128, 3, 8)中看到这一点,它的最后一个维度有八个特征通道,每个特征对应一个通道。每个通道中的值表示该位置的输入中存在该特征的程度。
正如我们在第八章中学到的,卷积层在数据上滑动一个窗口,并决定该窗口中是否存在给定特征。Conv2D()的第二个参数是我们提供此窗口尺寸的地方。在我们的情况下,它是(4, 3)。这意味着我们的滤波器正在寻找的特征跨越四个连续的加速度计测量和所有三个轴。因为窗口跨越了四个测量,每个滤波器分析了一个小的时间快照,这意味着它可以生成代表随时间加速度变化的特征。您可以在图 12-9 中看到这是如何工作的。

图 12-9. 一个卷积窗口覆盖在数据上
padding参数确定窗口如何在数据上移动。当padding设置为"same"时,层的输出长度(128)和宽度(3)与输入相同。因为每次滤波器窗口的移动都会产生一个输出值,所以"same"参数意味着窗口必须在数据上移动三次,并且在下面移动 128 次。
因为窗口的宽度为 3,这意味着它必须从数据的左侧开始悬挂。空白处,即滤波器窗口未覆盖实际值的地方,用零填充。为了在数据的长度上总共移动 128 次,滤波器还必须悬挂在数据的顶部。您可以在图 12-10 和 12-11 中看到这是如何工作的。
一旦卷积窗口在所有数据上移动,使用每个滤波器创建八个不同的特征图,输出将传递给我们的下一层MaxPool2D:
tf.keras.layers.MaxPool2D((3, 3)), # (batch, 42, 1, 8)

图 12-10. 卷积窗口处于第一个位置,需要在顶部和左侧填充

图 12-11. 同一卷积窗口已移动到第二个位置,只需要在顶部填充
这个MaxPool2D层接收前一层的输出,一个(128, 3, 8)张量,并将其缩小为一个(42, 1, 8)张量——原始大小的三分之一。它通过查看输入数据的窗口,然后选择窗口中的最大值,并将仅该值传播到输出中来实现这一点。然后,该过程会重复下一个数据窗口。提供给MaxPool2D()函数的参数(3, 3)指定使用一个 3×3 的窗口。默认情况下,窗口总是移动,以包含全新的数据。图 12-12 展示了这个过程是如何工作的。

图 12-12。最大池化的工作
请注意,尽管图中每个元素只显示了一个值,但我们的数据实际上每个元素有八个特征通道。
但是为什么我们需要像这样缩小我们的输入呢?当用于分类时,CNN 的目标是将一个大而复杂的输入张量转换为一个小而简单的输出。MaxPool2D层有助于实现这一目标。它将我们第一个卷积层的输出浓缩成一个集中的、高级别的表示,其中包含的相关信息。
通过集中信息,我们开始剥离那些与识别输入中包含的手势无关的内容。只有在第一个卷积层的输出中最大程度地表示的最重要的特征被保留下来。有趣的是,即使我们的原始输入每次测量都有三个加速度计轴,但Conv2D和MaxPool2D的组合现在已经将它们合并成一个单一的值。
在我们将数据缩小后,它经过了一个Dropout层:
tf.keras.layers.Dropout(0.1), # (batch, 42, 1, 8)
Dropout层在训练期间会随机将张量的一些值设为零。在这种情况下,通过调用Dropout(0.1),我们将 10%的值设为零,完全消除了这些数据。这可能看起来像是一种奇怪的做法,让我们解释一下。
Dropout是一种正则化技术。正如本书前面提到的,正则化是改进机器学习模型的过程,使其不太可能过度拟合训练数据。Dropout 是一种简单但有效的限制过拟合的方法。通过在一层和下一层之间随机删除一些数据,我们迫使神经网络学习如何应对意外的噪音和变化。在层之间添加 dropout 是一种常见且有效的做法。
Dropout 层只在训练期间激活。在推断期间,它没有任何效果;所有数据都被允许通过。
在Dropout层之后,我们再次通过一个MaxPool2D层和一个Dropout层传递数据:
tf.keras.layers.Conv2D(16, (4, 1), padding="same",
activation="relu"), # (batch, 42, 1, 16)
这个层有 16 个过滤器和一个窗口大小为(4, 1)。这些数字是模型的超参数的一部分,在模型开发过程中通过迭代过程选择。设计一个有效的架构是一个反复试验的过程,这些神奇的数字是在经过大量实验后得出的。你不太可能第一次就选择到完全正确的值。
与第一个卷积层一样,这一层也学会了发现包含有意义信息的相邻值的模式。它的输出是给定输入内容的更高级表示。它识别的特征是我们第一个卷积层识别的特征的组合。
在这个卷积层之后,我们再做一次MaxPool2D和Dropout:
tf.keras.layers.MaxPool2D((3, 1), padding="same"), # (batch, 14, 1, 16)
tf.keras.layers.Dropout(0.1), # (batch, 14, 1, 16)
这继续了将原始输入精炼为更小、更易管理的表示的过程。输出的形状为(14, 1, 16),是一个多维张量,象征性地表示了输入数据中只包含的最重要的结构。
如果我们愿意,我们可以继续卷积和池化的过程。CNN 中的层数只是我们可以在模型开发过程中调整的另一个超参数。然而,在开发这个模型的过程中,我们发现两个卷积层已经足够了。
到目前为止,我们一直在通过卷积层运行我们的数据,这些层只关心相邻值之间的关系——我们并没有真正考虑更大的整体情况。然而,由于我们现在有了包含在我们输入中的主要特征的高级表示,我们可以“放大”并以总体方式研究它们。为此,我们将我们的数据展平并将其输入到一个Dense层(也称为全连接层)中:
tf.keras.layers.Flatten(), # (batch, 224)
tf.keras.layers.Dense(16, activation="relu"), # (batch, 16)
Flatten层用于将多维张量转换为具有单个维度的张量。在这种情况下,我们的(14, 1, 16)张量被压缩成一个形状为(224)的单个维度。
然后将其输入到具有 16 个神经元的Dense层中。这是深度学习工具箱中最基本的工具之一:每个输入都连接到每个神经元的层。通过一次考虑所有数据,这一层可以学习各种输入组合的含义。这个Dense层的输出将是一组 16 个值,代表原始输入的内容以高度压缩的形式。
我们的最后任务是将这 16 个值缩小为 4 个类。为此,我们首先添加一些更多的 dropout,然后添加一个最终的Dense层:
tf.keras.layers.Dropout(0.1), # (batch, 16)
tf.keras.layers.Dense(4, activation="softmax") # (batch, 4)
这一层有四个神经元;每个代表一个手势类。它们中的每一个都连接到前一层的所有 16 个输出。在训练过程中,每个神经元将学习与其代表的手势相对应的前一层激活的组合。
该层配置了一个"softmax"激活函数,导致该层的输出是一组总和为 1 的概率。这个输出是我们在模型输出张量中看到的。
这种模型架构——卷积和全连接层的组合——在分类时间序列传感器数据方面非常有用,比如我们从加速度计获取的测量数据。该模型学习识别代表特定输入类的“指纹”的高级特征。它小巧、运行快速,训练时间不长。这种架构将是您作为嵌入式机器学习工程师的宝贵工具。
使用您自己的数据进行训练
在本节中,我们将向您展示如何训练自己的自定义模型,以识别新的手势。我们将逐步介绍如何捕获加速度计数据,修改训练脚本以将其纳入,训练新模型,并将其集成到嵌入式应用程序中。
捕获数据
要获取训练数据,我们可以使用一个简单的程序在手势执行时将加速度计数据记录到串行端口。
SparkFun Edge
快速入门的最快方法是修改SparkFun Edge Board Support Package (BSP)中的一个示例。首先,按照 SparkFun 的“使用 Ambiq Apollo3 SDK 与 SparkFun Edge Board”指南设置 Ambiq SDK 和 SparkFun Edge BSP。
下载 SDK 和 BSP 后,您需要调整示例代码以使其符合我们的要求。
首先,在您选择的文本编辑器中打开文件AmbiqSuite-Rel2.2.0/boards/SparkFun_Edge_BSP/examples/example1_edge_test/src/tf_adc/tf_adc.c。找到文件第 61 行的am_hal_adc_samples_read()调用:
if (AM_HAL_STATUS_SUCCESS != am_hal_adc_samples_read(g_ADCHandle,
NULL,
&ui32NumSamples,
&Sample))
将其第二个参数更改为true,使整个函数调用看起来像这样:
if (AM_HAL_STATUS_SUCCESS != am_hal_adc_samples_read(g_ADCHandle,
true,
&ui32NumSamples,
&Sample))
接下来,您需要修改文件AmbiqSuite-Rel2.2.0/boards/SparkFun_Edge_BSP/examples/example1_edge_test/src/main.c。找到第 51 行的while循环:
/*
* Read samples in polling mode (no int)
*/
while(1)
{
// Use Button 14 to break the loop and shut down
uint32_t pin14Val = 1;
am_hal_gpio_state_read( AM_BSP_GPIO_14, AM_HAL_GPIO_INPUT_READ, &pin14Val);
更改代码以添加以下额外行:
/*
* Read samples in polling mode (no int)
*/
while(1)
{
am_util_stdio_printf("-,-,-\r\n");
// Use Button 14 to break the loop and shut down
uint32_t pin14Val = 1;
am_hal_gpio_state_read( AM_BSP_GPIO_14, AM_HAL_GPIO_INPUT_READ, &pin14Val);
现在在while循环中稍后找到这行:
am_util_stdio_printf("Acc [mg] %04.2f x, %04.2f y, %04.2f z,
Temp [deg C] %04.2f, MIC0 [counts / 2¹⁴] %d\r\n",
acceleration_mg[0], acceleration_mg[1], acceleration_mg[2],
temperature_degC, (audioSample) );
删除原始行,并替换为以下内容:
am_util_stdio_printf("%04.2f,%04.2f,%04.2f\r\n", acceleration_mg[0],
acceleration_mg[1], acceleration_mg[2]);
程序现在将以训练脚本所期望的格式输出数据。
接下来,按照SparkFun 指南中的说明构建example1_edge_test示例应用程序并将其刷写到设备上。
记录数据
在构建和刷写示例代码之后,请按照以下说明捕获一些数据。
首先,打开一个新的终端窗口。然后运行以下命令开始将终端的所有输出记录到名为output.txt的文件中:
script output.txt
接下来,在同一个窗口中,使用screen连接到设备:
screen ${DEVICENAME} 115200
加速度计的测量结果将显示在屏幕上,并以逗号分隔的格式保存到output.txt中,这是训练脚本所期望的格式。
您应该努力在单个文件中捕获同一手势的多次表演。要开始捕获手势的单次表演,请按下标记为RST的按钮。字符-,-,-将被写入串口;训练脚本使用此输出来识别手势表演的开始。表演完手势后,按下标记为14的按钮停止记录数据。
当您多次记录相同手势时,通过按下 Ctrl-A,紧接着按 K 键,然后按 Y 键退出screen。退出screen后,输入以下命令停止将数据记录到output.txt:
exit
现在您有一个包含一个人执行单个手势数据的文件output.txt。要训练一个全新的模型,您应该努力收集与原始数据集中相似数量的数据,该数据集包含 10 个人每个手势约 15 次表演。
如果您不在乎您的模型是否适用于其他人,您可能只需捕获自己的表现。尽管如此,您收集的表现变化越多,效果就会越好。
为了与训练脚本兼容,您应该按照以下格式重命名捕获的数据文件:
output_<*gesture_name*>_<*person_name*>.txt
例如,由“Daniel”制作的假设“三角形”手势的数据将具有以下名称:
output_triangle_Daniel.txt
训练脚本将期望数据以每个手势名称的目录组织;例如:
data/
├── triangle
│ ├── output_triangle_Daniel.txt
│ └── ...
├── square
│ ├── output_square_Daniel.txt
│ └── ...
└── star
├── output_star_Daniel.txt
└── ...
您还需要为“未知”类别提供数据,存储在名为negative的目录中。在这种情况下,您可以重复使用原始数据集中的数据文件。
请注意,因为模型架构设计为输出四个类别(三个手势加上“未知”)的概率,您应该提供自己的三个手势。如果您想要训练更多或更少的手势,您需要更改训练脚本并调整模型架构。
修改训练脚本
要使用新手势训练模型,您需要对训练脚本进行一些更改。
首先,用以下文件中的手势名称替换所有手势名称:
接下来,用以下文件中的人员名称替换所有人员名称:
请注意,如果您有不同数量的人员名称(原始数据集有 10 个)并且您想在训练过程中按人员拆分数据,您需要决定一个新的拆分方式。如果您只有少数人的数据,将无法在训练过程中按人员拆分数据,所以不用担心data_split_person.py。
训练
要训练一个新模型,将您的数据文件目录复制到训练脚本的目录中,并按照本章前面介绍的过程进行操作。
如果您只有少数人的数据,应该随机拆分数据而不是按人员拆分。为此,在准备训练时,运行data_split.py而不是data_split_person.py。
因为你正在训练新手势,值得尝试调整模型的超参数以获得最佳准确性。例如,你可以尝试训练更多或更少的 epochs,或者使用不同排列的层或神经元数量,或者使用不同的卷积超参数来查看是否能获得更好的结果。你可以使用 TensorBoard 来监视你的进展。
一旦你有一个准确度可接受的模型,你需要对项目进行一些更改以确保它正常运行。
使用新模型
首先,你需要将由xxd -i格式化的新模型数据复制到magic_wand_model_data.cc中。确保你还更新g_magic_wand_model_data_len的值,以匹配xxd输出的数字。
接下来,在数组should_continuous_count中,你需要更新accelerometer_handler.cc中指定每个手势所需的连续预测次数的值。该值对应于手势执行所需的时间。鉴于原始的“翅膀”手势需要连续计数为 15,估算一下你的新手势相对于那个需要多长时间,然后更新数组中的值。你可以通过迭代调整这些值,直到获得最可靠的性能。
最后,更新output_handler.cc中的代码以打印你的新手势的正确名称。完成后,你可以构建你的代码并刷写你的设备。
总结
在本章中,我们深入探讨了典型嵌入式机器学习模型的架构。这种卷积模型是对时间序列数据进行分类的强大工具,你将经常遇到它。
到目前为止,希望你已经了解了嵌入式机器学习应用程序的外观,以及它们的应用代码如何与模型一起工作来理解周围的世界。当你构建自己的项目时,你将开始组建一个熟悉模型的工具箱,可以用来解决不同的问题。
学习机器学习
本书旨在提供对嵌入式机器学习可能性的初步介绍,但它并不是机器学习本身的完整参考资料。如果你想深入了解如何构建自己的模型,有一些令人惊叹且易于访问的资源适合各种背景的学生,并将为你提供一个良好的起点。
以下是一些我们喜欢的内容,将建立在你在这里学到的基础上:
-
François Chollet 的Python 深度学习(Manning)
-
Aurélien Géron 的使用 Scikit-Learn、Keras 和 TensorFlow 进行实践机器学习,第二版(O’Reilly)
-
Deeplearning.ai 的深度学习专项和TensorFlow 实践课程
-
Udacity 的深度学习 TensorFlow 入门课程
接下来是什么
本书的剩余章节将更深入地探讨嵌入式机器学习的工具和工作流程。你将学习如何思考设计自己的 TinyML 应用程序,如何优化模型和应用代码以在低功耗设备上运行良好,如何将现有的机器学习模型移植到嵌入式设备上,以及如何调试嵌入式机器学习代码。我们还将解决一些高层次的问题,如部署、隐私和安全性。
但首先,让我们更多地了解一下 TensorFlow Lite,这是本书中所有示例的框架动力源。
¹ 这是一个新术语,我们稍后会谈论。
第十三章:用于微控制器的 TensorFlow Lite
在本章中,我们将介绍我们在本书中所有示例中使用的软件框架:TensorFlow Lite for Microcontrollers。我们将详细介绍,但您不需要理解我们涵盖的所有内容才能在应用程序中使用它。如果您对底层发生的事情不感兴趣,请随意跳过本章;当您有问题时,您可以随时返回。如果您想更好地了解您用于运行机器学习的工具,我们在这里介绍了该库的历史和内部工作原理。
什么是用于微控制器的 TensorFlow Lite?
您可能会问的第一个问题是该框架实际上是做什么的。要理解这一点,有助于稍微解释一下(相当长的)名称并解释组件。
TensorFlow
如果您研究过机器学习,很可能已经听说过 TensorFlow 本身。TensorFlow是谷歌的开源机器学习库,其座右铭是“一个面向所有人的开源机器学习框架”。它是在谷歌内部开发的,并于 2015 年首次向公众发布。自那时以来,围绕该软件形成了一个庞大的外部社区,贡献者数量超过了谷歌内部。它面向 Linux、Windows 和 macOS 桌面和服务器平台,提供了许多工具、示例和优化,用于在云中训练和部署模型。它是谷歌内部用于支持其产品的主要机器学习库,核心代码在内部和发布版本中是相同的。
此外,谷歌和其他来源还提供了大量示例和教程。这些示例可以向您展示如何为从语音识别到数据中心电力管理或视频分析等各种用途训练和使用模型。
当 TensorFlow 推出时最大的需求是能够在桌面环境中训练模型并运行它们。这影响了很多设计决策,例如为了更低的延迟和更多功能性而交换可执行文件的大小-在云服务器上,即使 RAM 也是以吉字节计量,存储空间也有几千兆字节,拥有几百兆字节大小的二进制文件并不是问题。另一个例子是,它在推出时的主要接口语言是 Python,这是一种在服务器上广泛使用的脚本语言。
然而,这些工程上的权衡对其他平台并不太适用。在 Android 和 iPhone 设备上,即使将几兆字节添加到应用程序的大小中也会大大降低下载量和客户满意度。您可以为这些手机平台构建 TensorFlow,但默认情况下会将 20 MB 添加到应用程序大小中,即使经过一些工作,也永远不会缩小到 2 MB 以下。
TensorFlow Lite
为了满足移动平台对更小尺寸的要求,谷歌在 2017 年启动了一个名为 TensorFlow Lite 的辅助项目,以便在移动设备上高效轻松地运行神经网络模型。为了减少框架的大小和复杂性,它放弃了在这些平台上不太常见的功能。例如,它不支持训练,只能在之前在云平台上训练过的模型上运行推断。它也不支持主要 TensorFlow 中可用的所有数据类型(如double)。此外,一些不常用的操作也不存在,比如tf.depth_to_space。您可以在TensorFlow 网站上找到最新的兼容性信息。
作为这些折衷的回报,TensorFlow Lite 可以适应几百千字节,使其更容易适应大小受限的应用程序。它还具有针对 Arm Cortex-A 系列 CPU 高度优化的库,以及通过 OpenGL 支持 Android 的神经网络 API 的加速器和 GPU。另一个关键优势是它对网络的 8 位量化有很好的支持。因为一个模型可能有数百万个参数,仅从 32 位浮点数到 8 位整数的 75%大小减少就是值得的,但还有专门的代码路径,使得推断在较小的数据类型上运行得更快。
微控制器的 TensorFlow Lite
TensorFlow Lite 已被移动开发人员广泛采用,但其工程折衷并不符合所有平台的要求。团队注意到有很多谷歌和外部产品可以从在嵌入式平台上构建的机器学习中受益,而现有的 TensorFlow Lite 库则不适用。再次,最大的限制是二进制大小。对于这些环境来说,即使几百千字节也太大了;他们需要适合在 20 KB 或更小范围内的东西。许多移动开发人员认为理所当然的依赖项,如 C 标准库,也不存在,因此不能使用依赖于这些库的代码。然而,许多要求非常相似。推断是主要用例,量化网络对性能很重要,并且具有足够简单以供开发人员探索和修改的代码库是首要任务。
考虑到这些需求,2018 年,谷歌团队(包括本书的作者)开始尝试专门针对这些嵌入式平台的 TensorFlow Lite 的特殊版本。目标是尽可能重用移动项目中的代码、工具和文档,同时满足嵌入式环境的严格要求。为了确保谷歌正在构建实用的东西,团队专注于识别口头“唤醒词”的真实用例,类似于商业语音界面中的“Hey Google”或“Alexa”示例。旨在提供一个端到端的示例来解决这个问题,谷歌努力确保我们设计的系统适用于生产系统。
要求
谷歌团队知道在嵌入式环境中运行对代码编写有很多限制,因此确定了库的一些关键要求:
没有操作系统依赖
机器学习模型基本上是一个数学黑匣子,其中输入数字,输出结果也是数字。执行这些操作不需要访问系统的其余部分,因此可以编写一个不调用底层操作系统的机器学习框架。一些目标平台根本没有操作系统,避免在基本代码中引用文件或设备使得可以将其移植到这些芯片上。
在链接时没有标准的 C 或 C++库依赖
这比操作系统要求更微妙一些,但团队的目标是部署在可能只有几十 KB 内存来存储程序的设备上,因此二进制大小非常重要。即使看似简单的函数如sprintf()本身可能就需要 20KB 的空间,因此团队的目标是避免从包含 C 和 C++标准库实现的库存档案中提取任何内容。这很棘手,因为头文件依赖(如stdint.h,其中包含数据类型的大小)和标准库的链接时部分(如许多字符串函数或sprintf())之间没有明确定义的边界。实际上,团队必须运用一些常识来理解,通常情况下,编译时常量和宏是可以接受的,但应避免使用更复杂的内容。唯一的例外是标准 C math库,它被用于需要链接的三角函数等功能。
不需要浮点硬件
许多嵌入式平台不支持硬件浮点运算,因此代码必须避免对浮点数的性能关键使用。这意味着专注于具有 8 位整数参数的模型,并在操作中使用 8 位算术(尽管为了兼容性,该框架还支持浮点运算,如果需要的话)。
不支持动态内存分配
许多使用微控制器的应用程序需要连续运行数月或数年。如果程序的主循环使用malloc()/new和free()/delete来分配和释放内存,很难保证堆最终不会处于碎片化状态,导致分配失败和崩溃。大多数嵌入式系统上可用的内存非常有限,因此提前规划这种有限资源比其他平台更为重要,而且没有操作系统可能甚至没有堆和分配例程。这意味着嵌入式应用程序通常完全避免使用动态内存分配。因为该库是为这些应用程序设计的,所以它也需要这样做。实际上,该框架要求调用应用程序在初始化时传入一个小型、固定大小的区域,框架可以在其中进行临时分配(如激活缓冲区)。如果区域太小,库将立即返回错误,客户端需要重新编译以使用更大的区域。否则,进行推理调用时不会有进一步的内存分配,因此可以反复进行,而不会出现堆碎片化或内存错误的风险。
团队还决定不采用嵌入式社区中常见的其他一些约束,因为这将使共享代码和与移动 TensorFlow Lite 的兼容性维护变得太困难。因此:
它需要 C++11
在 C 中编写嵌入式程序很常见,有些平台根本不支持 C++,或者支持的版本比 2011 年的标准修订版旧。TensorFlow Lite 主要是用 C++编写的,具有一些纯 C API,这使得从其他语言调用它更容易。它不依赖于复杂的模板等高级功能;其风格是“更好的 C”,使用类来帮助模块化代码。将框架重写为 C 将需要大量工作,并且对于移动平台上的用户来说是一种倒退,当我们调查最受欢迎的平台时,我们发现,它们都已经支持 C++11,因此团队决定牺牲对旧设备的支持,以使代码更容易在所有版本的 TensorFlow Lite 之间共享。
它需要 32 位处理器
嵌入式世界中有大量不同的硬件平台可用,但近年来的趋势是向 32 位处理器发展,而不是以前常见的 16 位或 8 位芯片。在调查了生态系统之后,Google 决定将开发重点放在更新的 32 位设备上,因为这样可以保持假设,例如 C int数据类型为 32 位,这样可以使移动和嵌入式版本的框架保持一致。我们已经收到了一些成功移植到一些 16 位平台的报告,但这些平台依赖于弥补限制的现代工具链,并不是我们的主要重点。
为什么要解释模型?
经常出现的一个问题是,为什么我们选择在运行时解释模型,而不是提前从模型生成代码。解释该决定涉及分析涉及的不同方法的一些好处和问题。
代码生成涉及将模型直接转换为 C 或 C++代码,其中所有参数都存储为代码中的数据数组,架构表示为一系列函数调用,这些函数调用将激活从一层传递到下一层。这些代码通常输出到一个单独的大型源文件中,其中包含少量入口点。然后可以直接将该文件包含在 IDE 或工具链中,并像任何其他代码一样进行编译。以下是代码生成的一些关键优势:
易于构建
用户告诉我们,最大的好处是它有多么容易集成到构建系统中。如果您只有几个 C 或 C++文件,没有外部库依赖项,您可以轻松地将它们拖放到几乎任何 IDE 中,并构建一个项目,几乎没有出错的机会。
可修改性
当您有少量代码在单个实现文件中时,如果需要,通过代码进行步进和更改会更简单,至少与首先需要确定哪些实现正在使用的大型库相比是如此。
内联数据
模型本身的数据可以存储为实现源代码的一部分,因此不需要额外的文件。它也可以直接存储为内存中的数据结构,因此不需要加载或解析步骤。
代码大小
如果您提前知道要构建的模型和平台,可以避免包含永远不会被调用的代码,因此可以保持程序段的大小最小化。
解释模型是一种不同的方法,依赖于加载定义模型的数据结构。执行的代码是静态的;只有模型数据发生变化,模型中的信息控制执行哪些操作以及从哪里提取参数。这更像是在解释语言(如 Python)中运行脚本,而将代码生成视为更接近传统编译语言(如 C)。以下是与解释模型数据结构相比,代码生成的一些缺点:
可升级性
如果您在本地修改了生成的代码,但想要升级到整体框架的新版本以获得新功能或优化,会发生什么?您要么需要手动将更改挑选到本地文件中,要么完全重新生成它们,然后尝试将本地更改补丁回去。
多个模型
通过代码生成很难支持多个模型,而不会有大量源代码重复。
替换模型
每个模型都表示为程序中源代码和数据数组的混合,因此很难在不重新编译整个程序的情况下更改模型。
团队意识到的是,可以通过使用我们所谓的项目生成来获得代码生成的许多好处,而不会遇到缺点。
项目生成
在 TensorFlow Lite 中,项目生成是一个过程,它创建了构建特定模型所需的源文件副本,而不对其进行任何更改,并且还可以选择设置任何特定于 IDE 的项目文件,以便可以轻松构建。它保留了大部分代码生成的好处,但它具有一些关键优势:
可升级性
所有的源文件都只是主要 TensorFlow Lite 代码库中原始文件的副本,并且它们出现在文件夹层次结构中的相同位置,因此如果您进行本地修改,可以轻松地将其移植回原始源,并且可以简单地使用标准合并工具合并库升级。
多个和替换模型
底层代码是一个解释器,因此您可以拥有多个模型或轻松更换数据文件而无需重新编译。
内联数据
如果需要,模型参数本身仍然可以编译到程序中作为 C 数据数组,并且使用 FlatBuffers 序列化格式意味着这种表示可以直接在内存中使用,无需解包或解析。
外部依赖
构建项目所需的所有头文件和源文件都复制到与常规 TensorFlow 代码相邻的文件夹中,因此不需要单独下载或安装任何依赖项。
最大的优势并不是自动获得的代码大小,因为解释器结构使得更难以发现永远不会被调用的代码路径。在 TensorFlow Lite 中,通过手动使用OpResolver机制来注册您在应用程序中期望使用的内核实现,可以单独解决这个问题。
构建系统
TensorFlow Lite 最初是在 Linux 环境中开发的,因此我们的许多工具基于传统的 Unix 工具,如 shell 脚本、Make 和 Python。我们知道这对于嵌入式开发人员来说并不常见,因此我们旨在支持其他平台和编译工具链作为一流公民。
我们通过上述项目生成来实现这一点。如果您从 GitHub 获取 TensorFlow 源代码,可以使用 Linux 上的标准 Makefile 方法为许多平台构建。例如,这个命令行应该编译和测试库的 x86 版本:
make -f tensorflow/lite/micro/tools/make/Makefile test
您可以构建特定目标,比如为 SparkFun Edge 平台构建语音唤醒示例,使用以下命令:
make -f tensorflow/lite/micro/tools/make/Makefile \
TARGET="sparkfun_edge" micro_speech_bin
如果您在 Windows 机器上运行或想要使用 Keil、Mbed、Arduino 或其他专门的构建系统,那么项目生成就派上用场了。您可以通过在 Linux 上运行以下命令行来生成一个准备在 Mbed IDE 中使用的文件夹:
make -f tensorflow/lite/micro/tools/make/Makefile \
TARGET="disco_f746ng" generate_micro_speech_mbed_project
现在,您应该在tensorflow/lite/micro/tools/make/gen/disco_f746ng_x86_64/prj/micro_speech/mbed/中看到一组源文件,以及在 Mbed 环境中构建所需的所有依赖项和项目文件。同样的方法适用于 Keil 和 Arduino,还有一个通用版本,只输出源文件的文件夹层次结构,不包括项目元信息(尽管它包括一个定义了一些构建规则的 Visual Studio Code 文件)。
您可能想知道这种 Linux 命令行方法如何帮助其他平台上的用户。我们会自动将此项目生成过程作为我们每晚的持续集成工作流的一部分以及每次进行重大发布时运行。每次运行时,它会自动将生成的文件放在公共 Web 服务器上。这意味着所有平台上的用户应该能够找到适合其首选 IDE 的版本,并且可以下载该项目作为一个独立的文件夹,而不是通过 GitHub。
专门化代码
代码生成的一个好处是很容易重写库的部分,使其在特定平台上运行良好,甚至只是针对你知道在你的用例中很常见的一组特定参数进行函数优化。我们不想失去这种修改的便利性,但我们也希望尽可能地使更普遍有用的更改能够轻松地合并回主框架的源代码中。我们还有一个额外的约束条件,即一些构建环境在编译过程中不容易传递自定义的#define宏,因此我们不能依赖于在编译时使用宏保护切换到不同的实现。
为了解决这个问题,我们将库拆分为小模块,每个模块都有一个实现其功能的单个 C++文件,以及一个定义其他代码可以调用以使用该模块的接口的 C++头文件。然后我们采用了一个约定,如果您想编写一个模块的专门版本,您将您的新版本保存为与原始文件同名但在原始文件所在目录的子文件夹中的 C++实现文件。这个子文件夹应该有您专门为其进行特化的平台或功能的名称(参见图 13-1),并且在为该平台或功能构建时将自动使用 Makefile 或生成的项目而不是原始实现。这可能听起来很复杂,所以让我们通过几个具体的例子来解释一下。
语音唤醒词示例代码需要从麦克风中获取音频数据,但不幸的是没有跨平台的方法来捕获音频。因为我们至少需要在各种设备上进行编译,所以我们编写了一个默认实现,它只返回一个充满零值的缓冲区,而不使用麦克风。以下是该模块的接口是什么样子的,来自audio_provider.h:
TfLiteStatus GetAudioSamples(tflite::ErrorReporter* error_reporter,
int start_ms, int duration_ms,
int* audio_samples_size, int16_t** audio_samples);
int32_t LatestAudioTimestamp();

图 13-1. 专门音频提供者文件的截图
第一个函数为给定时间段输出填充有音频数据的缓冲区,如果出现问题则返回错误。第二个函数返回最近捕获到的音频数据的时间戳,因此客户端可以请求正确的时间范围,并知道何时有新数据到达。
因为默认实现不能依赖于麦克风的存在,所以audio_provider.cc中的两个函数的实现非常简单:
namespace {
int16_t g_dummy_audio_data[kMaxAudioSampleSize];
int32_t g_latest_audio_timestamp = 0;
} // namespace
TfLiteStatus GetAudioSamples(tflite::ErrorReporter* error_reporter,
int start_ms, int duration_ms,
int* audio_samples_size, int16_t** audio_samples) {
for (int i = 0; i < kMaxAudioSampleSize; ++i) {
g_dummy_audio_data[i] = 0;
}
*audio_samples_size = kMaxAudioSampleSize;
*audio_samples = g_dummy_audio_data;
return kTfLiteOk;
}
int32_t LatestAudioTimestamp() {
g_latest_audio_timestamp += 100;
return g_latest_audio_timestamp;
}
时间戳在每次调用函数时自动递增,以便客户端表现得好像有新数据进来,但捕获例程每次都返回相同的零数组。这样做的好处是可以让您在系统上的麦克风工作之前就可以对示例代码进行原型设计和实验。kMaxAudioSampleSize在模型头文件中定义,是函数将被要求的最大样本数。
在真实设备上,代码需要更复杂,因此我们需要一个新的实现。早些时候,我们为 STM32F746NG Discovery kit 开发板编译了这个示例,该开发板内置了麦克风,并使用单独的 Mbed 库来访问它们。代码在disco_f746ng/audio_provider.cc中。这里没有内联包含它,因为它太大了,但如果您查看该文件,您会看到它实现了与默认audio_provider.cc相同的两个公共函数:GetAudioSamples()和LatestAudioTimestamp()。这些函数的定义要复杂得多,但从客户端的角度来看,它们的行为是相同的。复杂性被隐藏起来,调用代码可以保持不变,尽管平台发生了变化,现在,而不是每次都接收到一个零数组,捕获的音频将显示在返回的缓冲区中。
如果你查看这个专门实现的完整路径,tensorflow/lite/micro/examples/micro_speech/disco_f746ng/audio_provider.cc,你会发现它几乎与默认实现的tensorflow/lite/micro/examples/micro_speech/audio_provider.cc相同,但它位于与原始.cc文件相同级别的disco_f746ng子文件夹内。如果你回顾一下用于构建 STM32F746NG Mbed 项目的命令行,你会看到我们传入了TARGET=disco_f746ng来指定我们想要的平台。构建系统总是在目标名称的子文件夹中寻找.cc文件,以便可能的专门实现,因此在这种情况下,disco_f746ng/audio_provider.cc被用来代替父文件夹中的默认audio_provider.cc版本。在为 Mbed 项目复制源文件时,会忽略父级.cc文件,并复制子文件夹中的文件;因此,生成的项目将使用专门版本。
在几乎每个平台上,音频捕获的方式都不同,因此我们有许多不同的专门实现这个模块。甚至还有一个 macOS 版本,osx/audio_provider.cc,如果你在 Mac 笔记本上本地调试,这将非常有用。
这种机制不仅用于可移植性,还足够灵活以用于优化。实际上,我们在语音唤醒词示例中使用这种方法来加速深度卷积操作。如果你查看tensorflow/lite/micro/kernels,你会看到 TensorFlow Lite for Microcontrollers 支持的所有操作的实现。这些默认实现被设计为简短、易于理解,并在任何平台上运行,但是为了达到这些目标,它们通常会错过提高运行速度的机会。优化通常涉及使算法更复杂、更难理解,因此这些参考实现预计会相对较慢。我们的想法是要让开发人员能够以最简单的方式运行代码,并确保他们获得正确的结果,然后逐步更改代码以提高性能。这意味着每个小改变都可以进行测试,以确保它不会破坏正确性,从而使调试变得更加容易。
语音唤醒词示例中使用的模型严重依赖深度卷积操作,该操作在tensorflow/lite/micro/kernels/depthwise_conv.cc中有一个未经优化的实现。核心算法在tensorflow/lite/kernels/internal/reference/depthwiseconv_uint8.h中实现,并被写成一组嵌套循环。以下是代码本身:
for (int b = 0; b < batches; ++b) {
for (int out_y = 0; out_y < output_height; ++out_y) {
for (int out_x = 0; out_x < output_width; ++out_x) {
for (int ic = 0; ic < input_depth; ++ic) {
for (int m = 0; m < depth_multiplier; m++) {
const int oc = m + ic * depth_multiplier;
const int in_x_origin = (out_x * stride_width) - pad_width;
const int in_y_origin = (out_y * stride_height) - pad_height;
int32 acc = 0;
for (int filter_y = 0; filter_y < filter_height; ++filter_y) {
for (int filter_x = 0; filter_x < filter_width; ++filter_x) {
const int in_x =
in_x_origin + dilation_width_factor * filter_x;
const int in_y =
in_y_origin + dilation_height_factor * filter_y;
// If the location is outside the bounds of the input image,
// use zero as a default value.
if ((in_x >= 0) && (in_x < input_width) && (in_y >= 0) &&
(in_y < input_height)) {
int32 input_val =
input_data[Offset(input_shape, b, in_y, in_x, ic)];
int32 filter_val = filter_data[Offset(
filter_shape, 0, filter_y, filter_x, oc)];
acc += (filter_val + filter_offset) *
(input_val + input_offset);
}
}
}
if (bias_data) {
acc += bias_data[oc];
}
acc = DepthwiseConvRound<output_rounding>(acc, output_multiplier,
output_shift);
acc += output_offset;
acc = std::max(acc, output_activation_min);
acc = std::min(acc, output_activation_max);
output_data[Offset(output_shape, b, out_y, out_x, oc)] =
static_cast<uint8>(acc);
}
}
}
}
}
你可能会从快速查看中看到许多加速的机会,比如在内部循环中每次计算的所有数组索引都预先计算出来。这些改变会增加代码的复杂性,因此对于这个参考实现,我们避免了它们。然而,语音唤醒词示例需要在微控制器上多次运行,结果发现这种朴素的实现是阻碍 SparkFun Edge Cortex-M4 处理器实现这一目标的主要速度瓶颈。为了使示例以可用的速度运行,我们需要添加一些优化。
为了提供一个优化的实现,我们在tensorflow/lite/micro/kernels内创建了一个名为portable_optimized的新子文件夹,并添加了一个名为depthwise_conv.cc的新的 C++源文件。这比参考实现复杂得多,并利用了语音模型的特定特性来实现专门的优化。例如,卷积窗口的宽度是 8 的倍数,因此我们可以将值作为两个 32 位字从内存中加载,而不是作为 8 个单独的字节。
您会注意到我们将子文件夹命名为portable_optimized,而不是像前面的示例那样特定于平台。这是因为我们所做的更改都不与特定芯片或库绑定;它们是通用优化,预计将有助于各种处理器,例如预先计算数组索引或将多个字节值加载为更大的字。然后,我们通过将portable_optimized添加到ALL_TAGS列表中来指定应在make项目文件中使用此实现。由于存在此标签,并且在具有相同名称的子文件夹中存在depthwise_conv.cc的实现,因此链接了优化实现,而不是默认的参考版本。
希望这些示例展示了如何利用子文件夹机制来扩展和优化库代码,同时保持核心实现简洁易懂。
Makefiles
说到易于理解,Makefiles 并不是。Make 构建系统现在已经有 40 多年的历史,具有许多令人困惑的特性,比如使用制表符作为有意义的语法或通过声明性规则间接指定构建目标。我们选择使用 Make 而不是 Bazel 或 Cmake 等替代方案,因为它足够灵活,可以实现像项目生成这样的复杂行为,我们希望大多数 TensorFlow Lite for Microcontrollers 的用户会在更现代的 IDE 中使用这些生成的项目,而不是直接与 Makefiles 交互。
如果您对核心库进行更改,可能需要更深入了解 Makefiles 中的内部情况,因此,本节涵盖了一些您需要熟悉的约定和辅助函数,以便进行修改。
注意
如果您在 Linux 或 macOS 上使用 bash 终端,可以通过键入正常的make -f tensorflow/lite/micro/tools/make/Makefile命令,然后按 Tab 键来查看所有可用的目标(可以构建的内容的名称)。在查找或调试目标时,此自动完成功能非常有用。
如果您只是添加一个模块或操作的专门版本,您根本不需要更新 Makefile。有一个名为specialize()的自定义函数,它会自动获取字符串(包含平台名称以及任何自定义标签)的ALL_TAGS列表和源文件列表,并返回替换原始版本的正确专门版本的列表。这也使您有灵活性,在命令行上手动指定标签。例如,这样:
make -f tensorflow/lite/micro/tools/make/Makefile \
TARGET="bluepill" TAGS="portable_optimized foo" test
将生成一个看起来像“bluepill portable_optimized foo”的ALL_TAGS列表,对于每个源文件,将按顺序搜索子文件夹以查找任何专门的版本来替换。
如果您只是向标准文件夹添加新的 C++文件,也不需要修改 Makefile,因为大多数情况下这些文件会被通配符规则自动捕捉,比如MICROLITE_CC_BASE_SRCS的定义。
Makefile 依赖于在根级别定义要构建的源文件和头文件列表,然后根据指定的平台和标签进行修改。这些修改发生在从父构建项目包含的子 Makefiles 中。例如,tensorflow/lite/micro/tools/make/targets文件夹中的所有.inc文件都会自动包含。如果您查看其中一个,比如用于 Ambiq 和 SparkFun Edge 平台的apollo3evb_makefile.inc,您会看到它检查了是否已为此构建指定了目标芯片;如果有,它会定义许多标志并修改源列表。以下是包含一些最有趣部分的简化版本:
ifeq ($(TARGET),$(filter $(TARGET),apollo3evb sparkfun_edge))
export PATH := $(MAKEFILE_DIR)/downloads/gcc_embedded/bin/:$(PATH)
TARGET_ARCH := cortex-m4
TARGET_TOOLCHAIN_PREFIX := arm-none-eabi-
...
$(eval $(call add_third_party_download,$(GCC_EMBEDDED_URL), \
$(GCC_EMBEDDED_MD5),gcc_embedded,))
$(eval $(call add_third_party_download,$(CMSIS_URL),$(CMSIS_MD5),cmsis,))
...
PLATFORM_FLAGS = \
-DPART_apollo3 \
-DAM_PACKAGE_BGA \
-DAM_PART_APOLLO3 \
-DGEMMLOWP_ALLOW_SLOW_SCALAR_FALLBACK \
...
LDFLAGS += \
-mthumb -mcpu=cortex-m4 -mfpu=fpv4-sp-d16 -mfloat-abi=hard \
-nostartfiles -static \
-Wl,--gc-sections -Wl,--entry,Reset_Handler \
...
MICROLITE_LIBS := \
$(BOARD_BSP_PATH)/gcc/bin/libam_bsp.a \
$(APOLLO3_SDK)/mcu/apollo3/hal/gcc/bin/libam_hal.a \
$(GCC_ARM)/lib/gcc/arm-none-eabi/7.3.1/thumb/v7e-m/fpv4-sp/hard/crtbegin.o \
-lm
INCLUDES += \
-isystem$(MAKEFILE_DIR)/downloads/cmsis/CMSIS/Core/Include/ \
-isystem$(MAKEFILE_DIR)/downloads/cmsis/CMSIS/DSP/Include/ \
-I$(MAKEFILE_DIR)/downloads/CMSIS_ext/ \
...
MICROLITE_CC_SRCS += \
$(APOLLO3_SDK)/boards/apollo3_evb/examples/hello_world/gcc_patched/ \
startup_gcc.c \
$(APOLLO3_SDK)/utils/am_util_delay.c \
$(APOLLO3_SDK)/utils/am_util_faultisr.c \
$(APOLLO3_SDK)/utils/am_util_id.c \
$(APOLLO3_SDK)/utils/am_util_stdio.c
这是特定平台的所有定制发生的地方。在这段代码中,我们指示构建系统在哪里找到我们想要使用的编译器,并指定要使用的架构。我们指定了一些额外的外部库要下载,如 GCC 工具链和 Arm 的 CMSIS 库。我们为构建设置编译标志,并传递给链接器的参数,包括要链接的额外库归档文件和要查找头文件的包含路径。我们还添加了一些我们需要在 Ambiq 平台上成功构建的额外 C 文件。
构建示例时也使用了类似的子 Makefile 包含。语音唤醒词示例代码在micro_speech/Makefile.inc中有自己的 Makefile,并定义了要编译的源代码文件列表,以及要下载的额外外部依赖项。
您可以使用generate_microlite_projects()函数为不同的 IDE 生成独立项目。这将接受一组源文件和标志,然后将所需文件复制到一个新文件夹中,以及构建系统所需的任何其他项目文件。对于某些 IDE,这非常简单,但例如 Arduino 需要将所有.cc文件重命名为.cpp,并且在复制时需要更改源文件中的一些包含路径。
外部库,如用于嵌入式 Arm 处理器的 C++工具链,将作为 Makefile 构建过程的一部分自动下载。这是因为对每个所需库调用的add_third_party_download规则,传入一个 URL 以拉取文件,并传入一个 MD5 校验和以检查归档文件以确保正确性。这些文件应为 ZIP、GZIP、BZ2 或 TAR 文件,根据文件扩展名将调用适当的解压程序。如果构建目标需要这些文件中的头文件或源文件,则应明确包含在 Makefile 中的文件列表中,以便将其复制到任何生成的项目中,因此每个项目的源树都是自包含的。这很容易被忽略,因为设置包含路径足以使 Makefile 编译正常工作,而无需明确提及每个包含的文件,但生成的项目将无法构建。您还应确保包含任何许可文件在您的文件列表中,以便外部库的副本保留正确的归属。
编写测试
TensorFlow 旨在为其所有代码编写单元测试,我们已经在第五章中详细介绍了其中一些测试。这些测试通常安排为与正在测试的模块相同文件夹中的_test.cc文件,并具有与原始源文件相同的前缀。例如,深度卷积操作的实现通过tensorflow/lite/micro/kernels/depthwise_conv_test.cc进行测试。如果要添加新的源文件,如果要将修改提交回主树,则必须添加一个相应的单元测试来测试它。这是因为我们需要支持许多不同的平台和模型,许多人正在我们的代码之上构建复杂系统,因此重要的是我们的核心组件可以检查正确性。
如果您在tensorflow/tensorflow/lite/experimental/micro的直接子文件夹中添加文件,您应该能够将其命名为microlite_test Makefile 辅助函数添加显式调用,例如此处:
# Tests the feature provider module using the mock audio provider.
$(eval $(call microlite_test,feature_provider_mock_test,\
$(FEATURE_PROVIDER_MOCK_TEST_SRCS),$(FEATURE_PROVIDER_MOCK_TEST_HDRS)))
测试本身需要在微控制器上运行,因此它们必须遵守围绕动态内存分配、避免 OS 和外部库依赖的相同约束,这是框架旨在满足的。不幸的是,这意味着像Google Test这样的流行单元测试系统是不可接受的。相反,我们编写了自己非常简化的测试框架,定义和实现在micro_test.h头文件中。
要使用它,创建一个包含头文件的.cc文件。在新行上以TF_LITE_MICRO_TESTS_BEGIN语句开始,然后定义一系列测试函数,每个函数都有一个TF_LITE_MICRO_TEST()宏。在每个测试中,您调用像TF_LITE_MICRO_EXPECT_EQ()这样的宏来断言您希望从正在测试的函数中看到的预期结果。在所有测试函数的末尾,您将需要TF_LITE_MICRO_TESTS_END。这里是一个基本示例:
#include "tensorflow/lite/micro/testing/micro_test.h"
TF_LITE_MICRO_TESTS_BEGIN
TF_LITE_MICRO_TEST(SomeTest) {
TF_LITE_LOG_EXPECT_EQ(true, true);
}
TF_LITE_MICRO_TESTS_END
如果您为您的平台编译此代码,您将获得一个正常的二进制文件,您应该能够运行它。执行它将输出类似于这样的日志信息到stderr(或者在您的平台上由ErrorReporter写入的任何等效内容):
----------------------------------------------------------------------------
Testing SomeTest
1/1 tests passed
~~~ALL TESTS PASSED~~~
----------------------------------------------------------------------------
这是为了便于人类阅读,因此您可以手动运行测试,但只有在所有测试确实通过时,字符串~~~ALL TESTS PASSED~~~才应该出现。这使得可以通过扫描输出日志并查找该魔术值来与自动化测试系统集成。这就是我们能够在微控制器上运行测试的方式。只要有一些调试日志连接回来,主机就可以刷新二进制文件,然后监视输出日志以确保预期的字符串出现以指示测试是否成功。
支持新硬件平台
TensorFlow Lite for Microcontrollers 项目的主要目标之一是使在许多不同设备、操作系统和架构上运行机器学习模型变得容易。核心代码被设计为尽可能可移植,构建系统编写为使引入新环境变得简单。在本节中,我们提供了一个逐步指南,以在新平台上运行 TensorFlow Lite for Microcontrollers。
打印到日志
TensorFlow Lite 绝对需要的唯一平台依赖是能够将字符串打印到可以从桌面主机机器外部检查的日志中。这样我们就可以看到测试是否成功运行,并通常调试我们正在运行的程序内部发生的情况。由于这是一个困难的要求,您在您的平台上需要做的第一件事是确定可用的日志记录设施类型,然后编写一个小程序来打印一些内容以测试它们。
在 Linux 和大多数其他桌面操作系统上,这将是许多 C 培训课程的经典“hello world”示例。它通常看起来像这样:
#include <stdio.h>
int main(int argc, char** argv) {
fprintf(stderr, "Hello World!\n");
}
如果您在 Linux、macOS 或 Windows 上编译和构建此代码,然后从命令行运行可执行文件,您应该会在终端上看到“Hello World!”打印出来。如果微控制器正在运行高级操作系统,它可能也会工作,但至少您需要弄清楚文本本身出现在哪里,因为嵌入式系统本身没有显示器或终端。通常,您需要通过 USB 或其他调试连接连接到桌面机器才能查看任何日志,即使在编译时支持fprintf()。
从微控制器的角度来看,这段代码有一些棘手的部分。其中一个问题是,stdio.h库需要链接函数,其中一些函数非常庞大,可能会使二进制文件大小超出小型设备可用的资源。该库还假定所有常规的 C 标准库设施都可用,如动态内存分配和字符串函数。而在嵌入式系统上,stderr应该放在哪里并没有自然的定义,因此 API 不清晰。
相反,大多数平台定义了自己的调试日志接口。这些接口的调用方式通常取决于主机和微控制器之间使用的连接类型,以及嵌入式系统上运行的硬件架构和操作系统(如果有)。例如,Arm Cortex-M 微控制器支持semihosting,这是在开发过程中在主机和目标系统之间通信的标准。如果你正在使用类似OpenOCD的连接从主机机器上,从微控制器调用SYS_WRITE0系统调用将导致寄存器 1 中的零终止字符串参数显示在 OpenOCD 终端上。在这种情况下,等效“hello world”程序的代码将如下所示:
void DebugLog(const char* s) {
asm("mov r0, #0x04\n" // SYS_WRITE0
"mov r1, %[str]\n"
"bkpt #0xAB\n"
:
: [ str ] "r"(s)
: "r0", "r1");
}
int main(int argc, char** argv) {
DebugLog("Hello World!\n");
}
这里需要汇编的原因显示了这个解决方案有多么特定于平台,但它确实避免了完全不引入任何外部库的需要(甚至是标准 C 库)。
如何做到这一点在不同平台上会有很大差异,但一个常见的方法是使用串行 UART 连接到主机。这是在 Mbed 上如何做的:
#include <mbed.h>
// On mbed platforms, we set up a serial port and write to it for debug logging.
void DebugLog(const char* s) {
static Serial pc(USBTX, USBRX);
pc.printf("%s", s);
}
int main(int argc, char** argv) {
DebugLog("Hello World!\n");
}
这里有一个稍微复杂一点的 Arduino 示例:
#include "Arduino.h"
// The Arduino DUE uses a different object for the default serial port shown in
// the monitor than most other models, so make sure we pick the right one. See
// https://github.com/arduino/Arduino/issues/3088#issuecomment-406655244
#if defined(__SAM3X8E__)
#define DEBUG_SERIAL_OBJECT (SerialUSB)
#else
#define DEBUG_SERIAL_OBJECT (Serial)
#endif
// On Arduino platforms, we set up a serial port and write to it for debug
// logging.
void DebugLog(const char* s) {
static bool is_initialized = false;
if (!is_initialized) {
DEBUG_SERIAL_OBJECT.begin(9600);
// Wait for serial port to connect. Only needed for some models apparently?
while (!DEBUG_SERIAL_OBJECT) {
}
is_initialized = true;
}
DEBUG_SERIAL_OBJECT.println(s);
}
int main(int argc, char** argv) {
DebugLog("Hello World!\n");
}
这两个示例都创建了一个串行对象,然后期望用户将串行连接到微控制器上的主机机器上。
移植工作的关键第一步是为你的平台创建一个最小示例,在你想要使用的 IDE 中运行,以某种方式将一个字符串打印到主机控制台。如果你能让这个工作起来,你使用的代码将成为你将添加到 TensorFlow Lite 代码中的专门函数的基础。
实现 DebugLog()
如果你查看tensorflow/lite/micro/debug_log.cc,你会看到DebugLog()函数的实现,看起来与我们展示的第一个“hello world”示例非常相似,使用stdio.h和fprintf()将字符串输出到控制台。如果你的平台完全支持标准 C 库,并且不介意额外的二进制文件大小,你可以使用这个默认实现,忽略本节的其余部分。不过,更有可能的是你需要使用不同的方法。
作为第一步,我们将使用已经存在的DebugLog()函数的测试。首先,运行以下命令行:
make -f tensorflow/lite/micro/tools/make/Makefile \
generate_micro_error_reporter_test_make_project
当你查看tensorflow/lite/micro/tools/make/gen/linux_x86_64/prj/micro_error_reporter_test/make/(如果你在不同的主机平台上,请将linux替换为osx或windows),你应该会看到一些像tensorflow和third_party这样的文件夹。这些文件夹包含 C++源代码,如果你将它们拖入你的 IDE 或构建系统并编译所有文件,你应该会得到一个可执行文件,用于测试我们需要创建的错误报告功能。你第一次尝试构建这段代码很可能会失败,因为它仍在使用debug_log.cc中的默认DebugLog()实现,依赖于stdio.h和 C 标准库。为了解决这个问题,修改debug_log.cc,删除#include <cstdio>语句,并用一个什么都不做的实现替换DebugLog():
#include "tensorflow/lite/micro/debug_log.h"
extern "C" void DebugLog(const char* s) {
// Do nothing for now.
}
更改后,尝试成功编译一组源文件。完成后,将生成的二进制文件加载到嵌入式系统上。如果可以的话,检查程序是否可以正常运行,尽管您目前还看不到任何输出。
当程序似乎构建和运行正确时,请查看是否可以使调试日志记录正常工作。将您在上一节“hello world”程序中使用的代码放入debug_log.cc中的DebugLog()实现中。
实际的测试代码存在于tensorflow/lite/micro/micro_error_reporter_test.cc,看起来是这样的:
int main(int argc, char** argv) {
tflite::MicroErrorReporter micro_error_reporter;
tflite::ErrorReporter* error_reporter = µ_error_reporter;
error_reporter->Report("Number: %d", 42);
error_reporter->Report("Badly-formed format string %");
error_reporter->Report("Another % badly-formed %% format string");
error_reporter->Report("~~~%s~~~", "ALL TESTS PASSED");
}
它不直接调用DebugLog(),而是通过处理变量数量等内容的ErrorReporter接口,但它确实依赖于您刚刚编写的代码作为其基础实现。如果一切正常,您应该在调试控制台中看到类似以下内容:
Number: 42
Badly-formed format string
Another badly-formed format string
~~~ALL TESTS PASSED~~~
在这方面工作后,您将希望将DebugLog()的实现放回主源代码树中。为此,您将使用我们之前讨论过的子文件夹专业化技术。您需要决定一个短名称(不含大写字母、空格或其他特殊字符),用于标识您的平台。例如,我们已经支持的一些平台使用arduino、sparkfun_edge和linux。在本教程中,我们将使用my_mcu。首先,在您从 GitHub 检出的源代码副本中的tensorflow/lite/micro/中创建一个名为my_mcu的新子文件夹(不是您刚生成或下载的那个)。将带有您实现的debug_log.cc文件复制到该my_mcu文件夹中,并使用 Git 进行源代码跟踪。将生成的项目文件复制到备份位置,然后运行以下命令:
make -f tensorflow/lite/micro/tools/make/Makefile TARGET=my_mcu clean
make -f tensorflow/lite/micro/tools/make/Makefile \
TARGET=my_mcu generate_micro_error_reporter_test_make_project
如果您现在查看tensorflow/lite/micro/tools/make/gen/my_mcu_x86_64/prj/micro_error_reporter_test/make/tensorflow/lite/micro/,您会发现默认的debug_log.cc不再存在,而是在my_mcu子文件夹中。如果您将这组源文件拖回到您的 IDE 或构建系统中,您现在应该看到一个成功构建、运行并输出到调试控制台的程序。
运行所有目标
如果成功,恭喜:您现在已启用所有 TensorFlow 测试和可执行目标!实现调试日志记录是您需要进行的唯一必需的特定于平台的更改;代码库中的其他所有内容应该以足够便携的方式编写,以便在任何支持 C++11 的工具链上构建和运行,无需标准库链接,只需使用math库。要创建所有目标,以便在 IDE 中尝试它们,您可以从终端运行以下命令:
make -f tensorflow/lite/micro/tools/make/Makefile generate_projects \
TARGET=my_mcu
这将在与生成的错误报告测试类似的位置创建大量文件夹,每个文件夹都会测试库的不同部分。如果您想在您的平台上运行语音唤醒词示例,您可以查看tensorflow/lite/micro/tools/make/gen/my_mcu_x86_64/prj/micro_speech/make/。
现在您已经实现了DebugLog(),它应该在您的平台上运行,但它不会执行任何有用的操作,因为默认的audio_provider.cc实现总是返回全零数组。要使其正常工作,您需要创建一个专门的audio_provider.cc模块,返回捕获的声音,使用之前描述的子文件夹专业化方法。如果您不关心一个工作演示,您仍然可以查看使用相同示例代码在您的平台上的神经网络推理延迟等内容,或者其他一些测试。
除了支持传感器和 LED 等输出设备的硬件支持外,您可能还希望实现更快运行的神经网络运算符版本,通过利用您平台的特殊功能。我们欢迎这种专门优化,并希望子文件夹专用化技术能够很好地将它们整合回主源树中,如果它们被证明是有用的。
与 Makefile 构建集成
到目前为止,我们只讨论了如何使用自己的集成开发环境,因为对许多嵌入式程序员来说,这通常比使用我们的 Make 系统更简单和更熟悉。如果您希望能够通过我们的持续集成构建来测试您的代码,或者希望在特定集成开发环境之外使用它,您可能希望更全面地将您的更改与我们的 Makefiles 集成。其中一个关键是找到适用于您平台的可公开下载的工具链,以及任何 SDK 或其他依赖项的公开下载,这样一个 shell 脚本就可以自动获取构建所需的一切,而无需担心网站登录或注册。例如,我们从 Arm 下载 macOS 和 Linux 版本的 GCC 嵌入式工具链,URL 在[tensorflow/lite/micro/tools/make/third_party_downloads.inc]中。
然后,您需要确定传递给编译器和链接器的正确命令行标志,以及您需要的任何额外源文件,这些文件无法使用子文件夹专用化找到,并将这些信息编码到[tensorflow/lite/micro/tools/make/targets]中的一个子 Makefile 中。如果您想获得额外的学分,您可以尝试使用类似Renode的工具在 x86 服务器上模拟您的微控制器,以便我们可以在持续集成期间运行测试,而不仅仅是确认构建。您可以在[tensorflow/lite/micro/testing/test_bluepill_binary.sh]中看到我们使用 Renode 来测试“Bluepill”二进制文件的脚本示例。
如果您已经正确配置了所有构建设置,您将能够运行类似以下的命令来生成可刷写的二进制文件(根据您的平台设置目标):
make -f tensorflow/lite/micro/tools/make/Makefile \
TARGET=bluepill micro_error_reporter_test_bin
如果您已经正确配置了运行测试的脚本和环境,您可以这样做来运行平台的所有测试:
make -f tensorflow/lite/micro/tools/make/Makefile TARGET=bluepill test
支持新的集成开发环境或构建系统
TensorFlow Lite for Microcontrollers 可以为 Arduino、Mbed 和 Keil 工具链创建独立项目,但我们知道许多嵌入式工程师使用许多其他开发环境。如果您需要在新环境中运行框架,我们建议的第一步是查看在生成 Make 项目时生成的“原始”文件集是否可以导入到您的集成开发环境中。这种项目存档仅包含特定目标所需的源文件,包括任何第三方依赖项,因此在许多情况下,您只需将您的工具链指向根文件夹,并要求它包含一切。
注意
当您只有少量文件时,将它们保留在原始源树的嵌套子文件夹(如tensorflow/lite/micro/examples/micro_speech)中,当您将它们导出到生成的项目时,可能会显得有些奇怪。将目录层次结构展平会更有意义吗?
我们选择保持深度嵌套的文件夹结构是为了尽可能简化合并回主源树,即使在处理生成的项目文件时可能不太方便。如果原始代码从 GitHub 检出并在每个项目中的副本之间的路径始终匹配,跟踪更改和更新就会更容易。
不幸的是,这种方法并不适用于所有 IDE。例如,Arduino 库要求所有 C++源代码文件的后缀为.cpp,而不是 TensorFlow 默认的.cc,它们也无法指定包含路径,因此当我们将原始文件复制到 Arduino 目标时,我们需要在代码中更改路径。为了支持这些更复杂的转换,我们在 Makefile 构建中有一些规则和脚本,根函数generate_microlite_projects()调用每个 IDE 的专门版本,然后依赖于更多的规则、Python 脚本和模板文件来创建最终输出。如果你需要为自己的 IDE 做类似的事情,你需要使用 Makefile 添加类似的功能,这并不容易实现,因为构建系统相当复杂。
在项目和存储库之间集成代码更改
代码生成系统的一个最大缺点是,你最终会得到源代码的多个副本分散在不同的位置,这使得处理代码更新变得非常棘手。为了最小化合并更改的成本,我们采用了一些惯例和推荐的程序,这应该会有所帮助。最常见的用例是,你对本地项目副本中的文件进行了一些修改,然后想要更新到新版本的 TensorFlow Lite 框架以获得额外的功能或错误修复。以下是我们建议处理该过程的方法:
-
要么下载一个 IDE 和目标的项目文件的预构建存档,要么使用你感兴趣的框架版本从 Makefile 手动生成一个。
-
将这组新文件解压到一个文件夹中,并确保新文件夹和包含你一直在修改的项目文件的文件夹之间的文件夹结构匹配。例如,两者顶层都应该有tensorflow子文件夹。
-
在两个文件夹之间运行合并工具。你使用的工具将取决于你的操作系统,但Meld是一个在 Linux、Windows 和 macOS 上都能工作的不错选择。合并过程的复杂程度将取决于你在本地更改了多少文件,但预计大部分差异将是在框架方面的更新,所以你通常应该能够选择“接受他们”的等效选项。
如果你只在本地修改了一个或两个文件,可能更容易的方法是从旧版本中复制修改后的代码,然后手动合并到新导出的项目中。
你也可以通过将修改后的代码提交到 Git 中,将最新的项目文件导入为一个新的分支,然后使用 Git 内置的合并工具来处理集成。我们还不够高级,无法提供关于这种方法的建议,所以我们自己也没有使用过。
这个过程与使用更传统的代码生成方法做同样的事情的区别在于,代码仍然分成许多逻辑文件,其路径随时间保持不变。典型的代码生成会将所有源代码连接成一个文件,这样合并或跟踪更改就变得非常困难,因为对顺序或布局的微小更改会使历史比较变得不可能。
有时候你可能想要将变更从项目文件合并到主源代码树中。这个主源代码树不需要是GitHub 上的官方仓库;它可以是你维护并且不分发的本地分支。我们很乐意接收主仓库的拉取请求,包括修复或升级,但我们知道在专有嵌入式开发中这并不总是可能,所以我们也很乐意帮助保持分支的健康。关键是要注意,你要尽量保持开发文件的单一“真相源”。特别是如果你有多个开发者,很容易在项目存档中的不同本地副本中进行不兼容的更改,这会使更新和调试变得一团糟。无论是仅内部使用还是公开共享,我们强烈建议使用一个源代码控制系统,每个文件只有一个副本,而不是检入多个版本。
为了处理将更改迁移到真相源仓库,你需要跟踪你修改过的文件。如果你没有这些信息,你可以随时回到最初下载或生成的项目文件,并运行 diff 来查看有什么变化。一旦你知道哪些文件被修改或新增了,只需将它们复制到 Git(或其他源代码控制系统)仓库中,路径与项目文件中的路径相同。
唯一的例外是第三方库的文件,因为这些文件在 TensorFlow 仓库中不存在。提交这些文件的更改超出了本书的范围——这个过程将取决于每个单独仓库的规则——但作为最后手段,如果你的更改没有被接受,你通常可以在 GitHub 上 fork 该项目,并将你的平台构建系统指向那个新的 URL,而不是原始 URL。假设你只是在更改 TensorFlow 源文件,那么现在你应该有一个包含你的更改的本地修改过的仓库。为了验证这些修改已经成功集成,你需要使用 Make 运行generate_projects(),然后确保你的 IDE 和目标项目已经应用了你期望的更新。当这一切完成,并且你已经运行了测试以确保没有其他问题,你可以将你的更改提交到你的 TensorFlow 分支。一旦完成,最后一步是提交一个拉取请求,如果你希望看到你的更改被公开。
回馈开源 Contributing Back to Open Source
TensorFlow 外部的贡献者已经比内部的更多,而微控制器工作更多地依赖于协作。我们非常渴望得到社区的帮助,其中通过拉取请求是最重要的帮助方式之一(虽然还有很多其他方式,比如Stack Overflow或创建你自己的示例项目)。GitHub 有很好的文档涵盖了拉取请求的基础知识,但在使用 TensorFlow 时有一些细节是有帮助的:
-
我们有一个由内外部 Google 项目维护者运行的代码审查流程。这是通过 GitHub 的代码审查系统管理的,所以你应该期望在那里看到关于你提交的讨论。
-
不仅仅是修复错误或优化的更改通常需要先有一个设计文档。有一个名为SIG Micro的组织,由外部贡献者运营,帮助定义我们的优先事项和路线图,所以这是一个讨论新设计的好地方。这个文档可以只有一页或两页,对于较小的更改来说,了解拉取请求背后的背景和动机是有帮助的。
-
维护一个公共分支可以是在提交到主分支之前获取实验性变更反馈的好方法,因为您可以进行任何繁琐的流程更改而不会拖慢您的速度。
-
有自动化测试针对所有拉取请求运行,包括公开的和一些额外的谷歌内部工具,检查与我们依赖的项目的集成。遗憾的是,这些测试的结果有时很难解释,甚至更糟糕的是,它们偶尔会出现“不稳定”的情况,测试失败的原因与您的更改无关。我们一直在努力改进这个过程,因为我们知道这是一个糟糕的体验,但如果您在理解测试失败方面遇到困难,请在对话线程中联系维护者。
-
我们的目标是实现 100%的测试覆盖率,因此如果一个变更没有被现有的测试覆盖到,我们会要求您提供一个新的测试。这些测试可以非常简单;我们只是想确保我们所做的一切都有一定的覆盖范围。
-
为了可读性起见,我们在整个 TensorFlow 代码库中一致使用 Google 的 C 和 C++代码格式指南,因此我们要求任何新的或修改过的代码都采用这种风格。您可以使用
clang-format并使用google风格参数自动格式化您的代码。
非常感谢您对 TensorFlow 所做的任何贡献,以及对提交变更所涉及工作的耐心。这并不总是容易的,但您将对全球许多开发人员产生影响!
支持新的硬件加速器
TensorFlow Lite for Microcontrollers 的一个目标是成为一个参考软件平台,帮助硬件开发人员更快地推进他们的设计。我们观察到,让一个新芯片在机器学习中做一些有用的事情的工作很大一部分在于诸如从训练环境编写导出器之类的任务,特别是涉及到量化和实现机器学习模型所需的“长尾”操作等棘手细节。这些任务所需的时间很少,它们不适合进行硬件优化。
为了解决这些问题,我们希望硬件开发人员将采取的第一步是在其平台上运行 TensorFlow Lite for Microcontrollers 的未优化参考代码,并产生正确的结果。这将证明除了硬件优化之外的一切都在运行,因此可以将剩下的工作重点放在硬件优化上。一个挑战可能是如果芯片是一个不支持通用 C++编译的加速器,因为它只具有专门的功能而不是传统的 CPU。对于嵌入式用例,我们发现几乎总是需要一些通用计算能力,即使它很慢(比如一个小型微控制器),因为许多用户的图形操作无法紧凑地表达,除非作为任意的 C++实现。我们还做出了设计决策,即 TensorFlow Lite for Microcontrollers 解释器不支持子图的异步执行,因为这将使代码变得更加复杂,而且在嵌入式领域似乎不常见(不像移动世界,Android 的神经网络 API 很受欢迎)。
这意味着 TensorFlow Lite for Microcontrollers 支持的架构类型看起来更像是与传统处理器同步运行的协处理器,加速器加速计算密集型函数,否则这些函数将需要很长时间,但将更灵活要求更小的操作推迟到 CPU。实际上,我们建议首先通过在内核级别替换单个操作符实现来调用任何专门的硬件。这意味着结果和输入预计将在 CPU 可寻址的正常内存中,因为您无法保证后续操作将在哪个处理器上运行,并且您将需要等待加速器完成后才能继续,或者使用特定于平台的代码切换到微框架之外的线程。尽管存在这些限制,但至少应该能够进行一些快速的原型设计,并希望能够在始终能够测试每个小修改的正确性的同时进行增量更改。
理解文件格式
TensorFlow Lite 用于存储其模型的格式具有许多优点,但不幸的是简单性不是其中之一。不过,不要被复杂性吓倒;一旦理解了一些基本原理,就会发现实际上很容易处理。
正如我们在第三章中提到的,神经网络模型是具有输入和输出的操作图。某些操作的输入可能是大型数组,称为权重,而其他输入可能来自先前操作的结果,或者由应用层提供的输入值数组。这些输入可能是图像像素、音频样本数据或加速度计时间序列数据。在运行模型的单次传递结束时,最终操作将在它们的输出中留下值数组,通常表示不同类别的分类预测等内容。
模型通常在台式机上进行训练,因此我们需要一种将其转移到手机或微控制器等其他设备的方法。在 TensorFlow 世界中,我们使用一个转换器来将从 Python 中训练的模型导出为 TensorFlow Lite 文件。这个导出阶段可能会遇到问题,因为很容易在 TensorFlow 中创建一个依赖于桌面环境特性的模型(比如能够执行 Python 代码片段或使用高级操作),而这些特性在简单平台上不受支持。还需要将训练中可变的所有值(如权重)转换为常量,删除仅用于梯度反向传播的操作,并执行优化,如融合相邻操作或将昂贵的操作(如批量归一化)折叠为更便宜的形式。更加棘手的是,主线 TensorFlow 中有 800 多个操作,而且新的操作一直在增加。这意味着编写自己的转换器来处理一小部分模型是相当简单的,但要可靠地处理用户在 TensorFlow 中创建的更广泛的网络范围则更加困难。跟上新操作的步伐已经是一项全职工作了。
转换过程中得到的 TensorFlow Lite 文件不会受到大多数这些问题的影响。我们试图生成一个更简单、更稳定的训练模型表示,具有清晰的输入和输出,将变量“冻结”为权重,并进行常见的图优化,如已应用的融合。这意味着即使您不打算在微控制器上使用 TensorFlow Lite,我们也建议使用 TensorFlow Lite 文件格式作为访问 TensorFlow 模型进行推断的方式,而不是从 Python 层编写自己的转换器。
FlatBuffers
我们使用FlatBuffers作为我们的序列化库。它专为性能关键的应用程序设计,因此非常适合嵌入式系统。其中一个好处是,它的运行时内存表示与其序列化形式完全相同,因此模型可以直接嵌入到闪存中并立即访问,无需任何解析或复制。这意味着生成的代码类用于读取属性可能有点难以理解,因为存在几层间接引用,但重要数据(如权重)直接存储为可以像原始 C 数组一样访问的小端 blob。也几乎没有浪费空间,因此使用 FlatBuffers 不会产生大小惩罚。
FlatBuffers 使用模式来定义我们要序列化的数据结构,以及一个编译器,将该模式转换为本机 C++(或 C、Python、Java 等)代码,用于读取和写入信息。对于 TensorFlow Lite,模式位于tensorflow/lite/schema/schema.fbs,我们将生成的 C++访问器代码缓存到tensorflow/lite/schema/schema_generated.h。我们可以在每次进行新构建时生成 C++代码,而不是将其存储在源代码控制中,但这将要求我们构建的每个平台都包含flatc编译器以及其他工具链的其余部分,我们决定牺牲自动生成的便利性以换取易于移植。
如果您想了解字节级别的格式,我们建议查看 FlatBuffers C++项目的内部页面,或者C 库的等效页面。我们希望大多数需求都可以通过各种高级语言接口来满足,因此您不需要以那种粒度工作。为了向您介绍格式背后的概念,我们将逐步介绍模式和MicroInterpreter中读取模型的代码;希望具体示例将有助于理解。
具有讽刺意味的是,要开始,我们需要滚动到模式的最末尾。在这里,我们看到一行声明root_type为Model:
root_type Model;
FlatBuffers 需要一个作为文件中包含的其他数据结构树的根的单个容器对象。这个声明告诉我们,这种格式的根将是一个Model。要了解这意味着什么,我们再向上滚动几行到Model的定义:
table Model {
这告诉我们Model是 FlatBuffers 称为table的内容。您可以将其视为 Python 中的Dict或 C 或 C++中的struct(尽管它比这更灵活)。它定义了对象可以具有的属性,以及它们的名称和类型。FlatBuffers 中还有一种不太灵活的类型称为struct,对于对象数组更节省内存,但我们目前在 TensorFlow Lite 中没有使用这种类型。
您可以通过查看micro_speech示例的main()函数来了解实际应用中如何使用这个功能:
// Map the model into a usable data structure. This doesn't involve any
// copying or parsing, it's a very lightweight operation.
const tflite::Model* model =
::tflite::GetModel(g_tiny_conv_micro_features_model_data);
g_tiny_conv_micro_features_model_data变量是指向包含序列化 TensorFlow Lite 模型的内存区域的指针,而对::tflite::GetModel()的调用实际上只是一个转换,以获取由该底层内存支持的 C++对象。它不需要任何内存分配或遍历数据结构,因此这是一个非常快速和高效的调用。要理解我们如何使用它,请看我们在数据结构上执行的下一个操作:
if (model->version() != TFLITE_SCHEMA_VERSION) {
error_reporter->Report(
"Model provided is schema version %d not equal "
"to supported version %d.\n",
model->version(), TFLITE_SCHEMA_VERSION);
return 1;
}
如果您查看模式中Model定义的开始,您可以看到此代码所引用的version属性的定义:
// Version of the schema.
version:uint;
这告诉我们version属性是一个 32 位无符号整数,因此为model->version()生成的 C++代码返回该类型的值。在这里,我们只是进行错误检查,以确保版本是我们可以理解的版本,但对于模式中定义的所有属性,都生成了相同类型的访问函数。
要了解文件格式的更复杂部分,值得跟随MicroInterpreter类的流程,因为它加载模型并准备执行。构造函数接收一个指向内存中模型的指针,例如前面示例中的g_tiny_conv_micro_features_model_data。它访问的第一个属性是缓冲区:
const flatbuffers::Vector<flatbuffers::Offset<Buffer>>* buffers =
model->buffers();
您可能会在类型定义中看到Vector名称,并担心我们试图在嵌入式环境中使用类似于标准模板库(STL)类型的对象,而不需要动态内存管理,这将是一个坏主意。然而,幸运的是,FlatBuffers 的Vector类只是对底层内存的只读包装器,因此就像根Model对象一样,创建它不需要解析或内存分配。
要了解buffers数组代表的更多信息,值得查看模式定义:
// Table of raw data buffers (used for constant tensors). Referenced by tensors
// by index. The generous alignment accommodates mmap-friendly data structures.
table Buffer {
data:[ubyte] (force_align: 16);
}
每个缓冲区都被定义为一个无符号 8 位值的原始数组,在内存中第一个值是 16 字节对齐的。这是用于图中所有权重数组(和任何其他常量值)的容器类型。张量的类型和形状是分开保存的;这个数组只是保存了数组内部数据的原始字节。操作通过在顶层向量内部的索引引用这些常量缓冲区。
我们访问的下一个属性是子图列表:
auto* subgraphs = model->subgraphs();
if (subgraphs->size() != 1) {
error_reporter->Report("Only 1 subgraph is currently supported.\n");
initialization_status_ = kTfLiteError;
return;
}
subgraph_ = (*subgraphs)[0];
子图是一组操作符、它们之间的连接以及它们使用的缓冲区、输入和输出。未来可能需要多个子图来支持一些高级模型,例如支持控制流,但目前我们想要在微控制器上支持的所有网络都有一个单独的子图,因此我们可以通过确保当前模型满足该要求来简化后续的代码。要了解子图中的内容,我们可以回顾一下模式:
// The root type, defining a subgraph, which typically represents an entire
// model.
table SubGraph {
// A list of all tensors used in this subgraph.
tensors:[Tensor];
// Indices of the tensors that are inputs into this subgraph. Note this is
// the list of non-static tensors that feed into the subgraph for inference.
inputs:[int];
// Indices of the tensors that are outputs out of this subgraph. Note this is
// the list of output tensors that are considered the product of the
// subgraph's inference.
outputs:[int];
// All operators, in execution order.
operators:[Operator];
// Name of this subgraph (used for debugging).
name:string;
}
每个子图的第一个属性是张量列表,MicroInterpreter代码访问它如此:
tensors_ = subgraph_->tensors();
正如我们之前提到的,Buffer对象只保存权重的原始值,没有关于它们类型或形状的任何元数据。张量是存储常量缓冲区的额外信息的地方。它们还为临时数组(如输入、输出或激活层)保存相同的信息。您可以在它们的定义中看到这些元数据在模式文件的顶部附近:
table Tensor {
// The tensor shape. The meaning of each entry is operator-specific but
// builtin ops use: [batch size, height, width, number of channels] (That's
// Tensorflow's NHWC).
shape:[int];
type:TensorType;
// An index that refers to the buffers table at the root of the model. Or,
// if there is no data buffer associated (i.e. intermediate results), then
// this is 0 (which refers to an always existent empty buffer).
//
// The data_buffer itself is an opaque container, with the assumption that the
// target device is little-endian. In addition, all builtin operators assume
// the memory is ordered such that if `shape` is [4, 3, 2], then index
// [i, j, k] maps to data_buffer[i*3*2 + j*2 + k].
buffer:uint;
name:string; // For debugging and importing back into tensorflow.
quantization:QuantizationParameters; // Optional.
is_variable:bool = false;
}
shape是一个简单的整数列表,指示张量的维度,而type是一个枚举,映射到 TensorFlow Lite 支持的可能数据类型。buffer属性指示根级列表中的哪个Buffer具有支持此张量的实际值,如果它是从文件中读取的常量,则为零,如果值是动态计算的(例如激活层),则为零。name只是为张量提供一个可读的标签,有助于调试,quantization属性定义了如何将低精度值映射到实数。最后,is_variable成员用于支持未来的训练和其他高级应用,但在微控制器单元(MCU)上不需要使用。
回到MicroInterpreter代码,我们从子图中提取的第二个主要属性是操作符列表:
operators_ = subgraph_->operators();
这个列表保存了模型的图结构。要了解这是如何编码的,我们可以回到Operator的模式定义:
// An operator takes tensors as inputs and outputs. The type of operation being
// performed is determined by an index into the list of valid OperatorCodes,
// while the specifics of each operations is configured using builtin_options
// or custom_options.
table Operator {
// Index into the operator_codes array. Using an integer here avoids
// complicate map lookups.
opcode_index:uint;
// Optional input and output tensors are indicated by -1.
inputs:[int];
outputs:[int];
builtin_options:BuiltinOptions;
custom_options:[ubyte];
custom_options_format:CustomOptionsFormat;
// A list of booleans indicating the input tensors which are being mutated by
// this operator.(e.g. used by RNN and LSTM).
// For example, if the "inputs" array refers to 5 tensors and the second and
// fifth are mutable variables, then this list will contain
// [false, true, false, false, true].
//
// If the list is empty, no variable is mutated in this operator.
// The list either has the same length as `inputs`, or is empty.
mutating_variable_inputs:[bool];
}
opcode_index成员是Model内部operator_codes向量中的索引。因为特定类型的操作符,比如Conv2D,可能在一个图中出现多次,而且一些操作需要一个字符串来定义它们,所以将所有操作定义保存在一个顶层数组中,并从子图间接引用它们可以节省序列化大小。
inputs和outputs数组定义了操作符与图中邻居之间的连接。这些是整数列表,指的是父子图中的张量数组,可能指的是从模型中读取的常量缓冲区,应用程序输入的输入,运行其他操作的结果,或者在计算完成后将被应用程序读取的输出目标缓冲区。
关于子图中保存的操作符列表的一个重要事项是,它们总是按照拓扑顺序排列,这样如果你从数组的开头执行它们到结尾,所有依赖于先前操作的给定操作的输入在到达该操作时都已经计算完成。这使得编写解释器变得更简单,因为执行循环不需要在执行之前执行任何图操作,只需按照它们列出的顺序执行操作。这意味着以不同顺序运行相同的子图(例如,使用反向传播进行训练)并不简单,但 TensorFlow Lite 的重点是推断,因此这是一个值得的权衡。
操作符通常还需要参数,比如Conv2D内核的滤波器的形状和步幅。这些参数的表示非常复杂。出于历史原因,TensorFlow Lite 支持两种不同的操作族。内置操作首先出现,是移动应用程序中最常用的操作。您可以在模式中看到一个列表。截至 2019 年 11 月,只有 122 个,但 TensorFlow 支持超过 800 个操作——那么我们该怎么处理剩下的操作呢?自定义操作由字符串名称定义,而不是像内置操作那样的固定枚举,因此可以更容易地添加而不影响模式。
对于内置操作,参数结构在模式中列出。以下是Conv2D的示例:
table Conv2DOptions {
padding:Padding;
stride_w:int;
stride_h:int;
fused_activation_function:ActivationFunctionType;
dilation_w_factor:int = 1;
dilation_h_factor:int = 1;
}
希望列出的大多数成员看起来都有些熟悉,它们的访问方式与其他 FlatBuffers 对象相同:通过每个Operator对象的builtin_options联合体,根据操作符代码选择适当的类型(尽管这样做的代码基于一个庞大的switch语句)。
如果操作符代码表明是自定义操作符,我们事先不知道参数列表的结构,因此无法生成代码对象。相反,参数信息被打包到FlexBuffer中。这是 FlatBuffer 库提供的一种格式,用于在不事先知道结构的情况下编码任意数据,这意味着实现操作符的代码需要访问生成的数据,指定类型是什么,并且语法比内置操作符更混乱。以下是一些目标检测代码的示例:
const flexbuffers::Map& m = flexbuffers::GetRoot(buffer_t, length).AsMap();
op_data->max_detections = m["max_detections"].AsInt32();
在这个示例中被引用的缓冲指针最终来自Operator表的custom_options成员,展示了如何从这个属性访问参数数据。
Operator的最后一个成员是mutating_variable_inputs。这是一个实验性功能,用于帮助管理长短期记忆(LSTM)和其他可能希望将其输入视为变量的操作,对于大多数 MCU 应用程序来说并不相关。
这些是 TensorFlow Lite 序列化格式的关键部分。还有一些我们没有涵盖的成员(比如Model中的metadata_buffer),但这些是可选的非必要功能,通常可以忽略。希望这个概述足以让您开始阅读、编写和调试自己的模型文件。
将 TensorFlow Lite 移动操作移植到 Micro
在主要的 TensorFlow Lite 版本中,针对移动设备有一百多个“内置”操作。微控制器的 TensorFlow Lite 重用了大部分代码,但是因为这些操作的默认实现引入了像 pthread、动态内存分配或其他嵌入式系统不支持的功能,因此操作实现(也称为内核)需要一些工作才能在 Micro 上使用。
最终,我们希望统一两个 op 实现的分支,但这需要在整个框架中进行一些设计和 API 更改,因此短期内不会发生。大多数操作应该已经有 Micro 实现,但如果您发现一个在移动 TensorFlow Lite 上可用但在嵌入式版本中不可用的操作,本节将指导您完成转换过程。在确定要移植的操作后,有几个阶段。
分离参考代码
所有列出的操作应该已经有参考代码,但这些函数可能在reference_ops.h中。这是一个几乎有 5000 行长的单片头文件。因为它涵盖了这么多操作,它引入了许多在嵌入式平台上不可用的依赖项。要开始移植过程,您首先需要将所需操作的参考函数提取到单独的头文件中。您可以在https://oreil.ly/vH-6[_conv.h]和pooling.h中看到这些较小头文件的示例。参考函数本身应该与它们实现的操作名称匹配,并且通常会有多个不同数据类型的实现,有时使用模板。
一旦文件从较大的头文件中分离出来,您需要从reference_ops.h中包含它,以便所有使用该头文件的现有用户仍然看到您移动的函数(尽管我们的 Micro 代码将单独包含分离的头文件)。您可以查看我们如何为conv2d 这里。您还需要将头文件添加到kernels/internal/BUILD:reference_base和kernels/internal/BUILD:legacy_reference_base构建规则中。在进行这些更改后,您应该能够运行测试套件并看到所有现有的移动测试都通过了:
bazel test tensorflow/lite/kernels:all
这是一个创建初始拉取请求供审查的好时机。您尚未将任何内容移植到micro分支,但您已经为更改准备好了现有代码,因此值得尝试在您继续以下步骤时进行审查和提交。
创建运算符的微型副本
每个微操作符实现都是移动版本的修改副本,保存在tensorflow/lite/kernels/中。例如,微conv.cc基于移动conv.cc。有一些重要的区别。首先,在嵌入式环境中动态内存分配更加棘手,因此为了在推理期间使用的计算中缓存计算值,OpData 结构的创建被移动到一个单独的函数中,以便它可以在Invoke()期间调用,而不是从Prepare()返回。这对每个Invoke()调用需要更多的工作,但通常减少内存开销对于微控制器是有意义的。
其次,在Prepare()中的大部分参数检查代码通常会被删除。最好将其封装在#if defined(DEBUG)中,而不是完全删除,但删除可以将代码大小保持最小。应从包含和代码中删除对外部框架(Eigen,gemmlowp,cpu_backend_support)的所有引用。在Eval()函数中,除了调用reference_ops::命名空间中的函数的路径之外,应删除其他内容。
修改后的运算符实现应保存在与移动版本相同名称的文件中(通常是运算符名称的小写版本),保存在tensorflow/lite/micro/kernels/文件夹中。
将测试移植到微框架
我们无法在嵌入式平台上运行完整的 Google Test 框架,因此需要使用 Micro Test 库。这对于 GTest 的用户应该很熟悉,但它避免了任何需要动态内存分配或 C++全局初始化的构造。本书的其他地方有更多文档。
您需要在嵌入式环境中运行与移动端相同的测试,因此您需要使用tensorflow/lite/kernels/<your op name>_test.cc中的版本作为起点。例如,查看tensorflow/lite/kernels/conv_test.cc和移植版本tensorflow/lite/micro/kernels/conv_test.cc。以下是主要区别:
-
移动代码依赖于 C++ STL 类,如
std::map和std::vector,这些类需要动态内存分配。 -
移动代码还使用辅助类,并以涉及分配的方式传递数据对象。
-
微版本在堆栈上分配所有数据,使用
std::initializer_list传递类似于std::vectors的对象,但不需要动态内存分配。 -
运行测试的调用表示为函数调用,而不是对象分配,因为这有助于重用大量代码而不会遇到分配问题。
-
大多数标准错误检查宏都可用,但带有
TF_LITE_MICRO_后缀。例如,EXPECT_EQ变为TF_LITE_MICRO_EXPECT_EQ。
所有测试都必须位于一个文件中,并被单个TF_LITE_MICRO_TESTS_BEGIN/TF_LITE_MICRO_TESTS_END对包围。在底层,这实际上创建了一个main()函数,以便可以将测试作为独立的二进制运行。
我们还尽量确保测试仅依赖于内核代码和 API,而不引入其他类,如解释器。测试应直接调用内核实现,使用从GetRegistration()返回的 C API。这是因为我们希望确保内核可以完全独立使用,而不需要框架的其余部分,因此测试代码也应避免这些依赖关系。
构建一个 Bazel 测试
现在您已经创建了运算符实现和测试文件,您需要检查它们是否有效。您需要使用 Bazel 开源构建系统来执行此操作。在BUILD文件中添加一个tflite_micro_cc_test规则,然后尝试构建和运行以下命令行(将conv替换为您的运算符名称):
bazel test ttensorflow/lite/micro/kernels:conv_test --test_output=streamed
毫无疑问会出现编译错误和测试失败,因此需要花费一些时间来迭代修复这些问题。
将您的运算符添加到 AllOpsResolver
应用程序可以选择仅拉取某些运算符实现,以减小二进制大小,但有一个运算符解析器会拉取所有可用的运算符,以便轻松入门。您应该在all_ops_resolver.cc的构造函数中添加一个调用来注册您的运算符实现,并确保实现和头文件也包含在BUILD规则中。
构建一个 Makefile 测试
到目前为止,您所做的一切都在 TensorFlow Lite 的micro分支中进行,但您一直在 x86 上构建和测试。这是开发的最简单方式,最初的任务是创建所有操作的可移植、未优化的实现,因此我们建议您尽可能多地在这个领域进行工作。不过,到了这一点,您应该已经在桌面 Linux 上完全运行和测试了操作员实现,所以现在是时候开始在嵌入式设备上进行编译和测试了。
Google 开源项目的标准构建系统是 Bazel,但不幸的是,使用 Bazel 实现交叉编译和支持嵌入式工具链并不容易,因此我们不得不转向备受尊敬的 Make 进行部署。Makefile 本身在内部非常复杂,但希望您的新操作员应该会根据其实现文件和测试的名称和位置自动选择。唯一的手动步骤应该是将您创建的参考头文件添加到MICROLITE_CC_HDRS文件列表中。
要在这种环境中测试您的操作员,请cd到文件夹,并运行以下命令(将您自己的操作员名称替换为conv):
make -f tensorflow/lite/micro/tools/make/Makefile test_conv_test
希望这次编译和测试能够通过。如果没有通过,请按照正常的调试程序来找出问题所在。
这仍然在您本地的 Intel x86 桌面机上本地运行,尽管它使用与嵌入式目标相同的构建机制。您现在可以尝试将代码编译并刷写到像 SparkFun Edge 这样的真实微控制器上(只需在 Makefile 行中传入TARGET=sparkfun_edge即可),但为了让生活更轻松,我们还提供了 Cortex-M3 设备的软件仿真。您应该能够通过执行以下命令来运行您的测试:
make -f tensorflow/lite/micro/tools/make/Makefile TARGET=bluepill test_conv_test
这可能有点不稳定,因为有时仿真器执行时间太长,进程会超时,但希望再试一次会解决问题。如果您已经走到这一步,我们鼓励您尽可能将您的更改贡献回开源构建。开源您的代码的完整过程可能有点复杂,但 TensorFlow 社区指南是一个很好的起点。
总结
完成本章后,您可能感觉自己像是在尝试从消防栓中喝水。我们为您提供了关于 TensorFlow Lite for Microcontrollers 如何工作的大量信息。如果您不理解全部内容,甚至大部分内容也不用担心,我们只是想给您足够的背景知识,以便在需要深入了解时知道从哪里开始查找。代码都是开源的,是了解框架运作方式的终极指南,但我们希望这些评论能帮助您理解其结构,并理解为什么会做出某些设计决策。
在看完如何运行一些预构建示例并深入了解库的工作原理后,您可能想知道如何将所学到的应用到自己的应用程序中。本书的剩余部分将集中讨论您需要掌握的技能,以便在自己的产品中部署自定义机器学习,涵盖优化、调试和移植模型,以及隐私和安全性。
第十四章:设计你自己的 TinyML 应用程序
到目前为止,我们已经探讨了重要领域如音频、图像和手势识别的现有参考应用。如果你的问题类似于其中一个示例,你应该能够调整训练和部署过程,但如果不明显如何修改我们的示例以适应呢?在本章和接下来的章节中,我们将介绍为一个没有易于起点的问题构建嵌入式机器学习解决方案的过程。你对示例的经验将为创建自己的系统奠定良好基础,但你还需要了解更多关于设计、训练和部署新模型的知识。由于我们平台的约束非常严格,我们还花了很多时间讨论如何进行正确的优化,以适应存储和计算预算,同时不会错过准确性目标。你肯定会花费大量时间尝试理解为什么事情不起作用,因此我们涵盖了各种调试技术。最后,我们探讨了如何为用户的隐私和安全建立保障措施。
设计过程
训练模型可能需要几天或几周的时间,引入新的嵌入式硬件平台也可能非常耗时——因此,任何嵌入式机器学习项目最大的风险之一是在你有可用的东西之前时间耗尽。减少这种风险的最有效方法是尽早回答尽可能多的未解决问题,通过规划、研究和实验。对训练数据或架构的每次更改很容易涉及一周的编码和重新训练,部署硬件更改会在整个软件堆栈中产生连锁反应,需要大量重写先前有效的代码。你可以在开始时做的任何事情,以减少后续开发过程中所需更改的数量,可以节省你本来会花在这些更改上的时间。本章重点介绍了我们建议用于在编写最终应用程序之前回答重要问题的一些技术。
你需要微控制器,还是更大的设备可以工作?
你真正需要回答的第一个问题是,你是否需要嵌入式系统的优势,或者至少对电池寿命、成本和尺寸的要求可以放松,至少对于一个初始原型来说。在具有完整现代操作系统(如 Linux)的系统上编程比在嵌入式世界中开发要容易得多(也更快)。你可以以低于 25 美元的价格获得像树莓派这样的完整桌面级系统,以及许多外围设备,如摄像头和其他传感器。如果你需要运行计算密集型的神经网络,NVIDIA 的 Jetson 系列板卡起价为 99 美元,并带来了强大的软件堆栈,体积小。这些设备的最大缺点是它们会消耗几瓦的电力,使它们的电池寿命在几小时或几天左右,具体取决于能量存储的物理尺寸。只要延迟不是一个硬性约束,你甚至可以启动尽可能多的强大云服务器来处理神经网络工作负载,让客户端设备处理界面和网络通信。
我们坚信能够在任何地方部署的力量,但如果你试图确定一个想法是否有效,我们强烈建议尝试使用一个易于快速实验的设备进行原型设计。开发嵌入式系统非常痛苦,所以在深入之前尽可能梳理出应用程序的真正需求,你成功的机会就越大。
举个实际的例子,想象一下你想要建造一个设备来帮助监测羊的健康。最终产品需要能够在没有良好连接的环境中运行数周甚至数月,因此必须是嵌入式系统。然而,在开始时,你不想使用这种难以编程的设备,因为你还不知道关键细节,比如你想要运行哪些模型,需要哪些传感器,或者根据你收集的数据需要采取什么行动,而且你还没有任何训练数据。为了启动你的工作,你可能会想找一个友好的农民,他们有一小群在易于接近的地方放牧的羊。你可以组装一个树莓派平台,每晚从每只被监测的羊身上取下来自己充电,然后建立一个覆盖放牧区域范围的室外 WiFi 网络,这样设备就可以轻松地与网络通信。显然,你不能指望真正的客户去做这种麻烦的事情,但通过这种设置,你将能够回答许多关于你需要构建什么的问题,尝试新的模型、传感器和形态因素将比嵌入式版本快得多。
微控制器很有用,因为它们可以按照其他硬件无法做到的方式进行扩展。它们便宜、小巧,几乎不需要能量,但这些优势只有在实际需要扩展时才会发挥作用。如果可以的话,推迟处理扩展的问题,直到绝对必要,这样你就可以确信你正在扩展正确的东西。
理解可能性
很难知道深度学习能够解决什么问题。我们发现一个非常有用的经验法则是,神经网络模型擅长处理人们可以“眨眼间”解决的任务。我们直觉上似乎能够瞬间识别物体、声音、单词和朋友,而这些正是神经网络可以执行的任务。同样,DeepMind 的围棋解决算法依赖于一个卷积神经网络,它能够查看棋盘并返回每个玩家处于多强势位置的估计。然后,该系统的长期规划部分是基于这些基础组件构建的。
这是一个有用的区分,因为它在不同种类的“智能”之间划定了界限。神经网络并不自动具备规划或像定理证明这样的高级任务的能力。它们更擅长接收大量嘈杂和混乱的数据,并稳健地发现模式。例如,神经网络可能不是指导牧羊犬如何引导羊群穿过大门的好解决方案,但它很可能是利用各种传感器数据(如体温、脉搏和加速度计读数)来预测羊羊是否感到不适的最佳方法。我们几乎无意识地执行的判断更有可能被深度学习覆盖,而不是需要明确思考的问题。这并不意味着那些更抽象的问题不能通过神经网络得到帮助,只是它们通常只是一个更大系统的组成部分,该系统使用它们的“本能”预测作为输入。
跟随他人的脚步
在研究领域,“文献回顾”是一个相对宏大的名字,用于指阅读与你感兴趣的问题相关的研究论文和其他出版物。即使你不是研究人员,当涉及深度学习时,这也是一个有用的过程,因为有很多有用的关于尝试将神经网络模型应用于各种挑战的账户,如果你能从他人的工作中获得一些启示,你将节省很多时间。理解研究论文可能是具有挑战性的,但最有用的是了解人们在类似问题上使用了什么样的模型,以及是否有任何现有的数据集可以使用,考虑到收集数据是机器学习过程中最困难的部分之一。
例如,如果你对机械轴承的预测性维护感兴趣,你可以在 arxiv.org 上搜索“深度学习预测性维护轴承”,这是机器学习研究论文最受欢迎的在线主机。截至本文撰写时,排名第一的结果是来自 2019 年的一篇综述论文,“用于轴承故障诊断的机器学习和深度学习算法:综合回顾”由 Shen Zhang 等人撰写。从中,你将了解到有一个名为Case Western Reserve University 轴承数据集的标记轴承传感器数据的标准公共数据集。拥有现有的数据集非常有帮助,因为它将帮助你在甚至还没有从自己的设置中收集读数之前就进行实验。还有对已经用于该问题的不同模型架构的很好概述,以及对它们的优势、成本和整体结果的讨论。
寻找一些类似的模型进行训练
在你对模型架构和训练数据有了一些想法之后,值得花一些时间在训练环境中进行实验,看看在没有资源限制的情况下你能够取得什么样的结果。本书专注于 TensorFlow,因此我们建议你找到一个示例 TensorFlow 教程或脚本(取决于你的经验水平),将其运行起来,然后开始适应你的问题。如果可能的话,可以参考本书中的训练示例,因为它们还包括部署到嵌入式平台所需的所有步骤。
一个思考哪种模型可能有效的好方法是查看传感器数据的特征,并尝试将其与教程中的类似内容进行匹配。例如,如果你有来自轮轴承的单通道振动数据,那将是一个相对高频的时间序列,与麦克风的音频数据有很多共同之处。作为一个起点,你可以尝试将所有轴承数据转换为.wav格式,然后将其输入到语音训练过程中,而不是标准的语音命令数据集,带有适当的标签。然后你可能需要更多地定制这个过程,但希望至少能得到一个有些预测性的模型,并将其用作进一步实验的基准。类似的过程也适用于将手势教程适应到任何基于加速度计的分类问题,或者为不同的机器视觉应用重新训练人员检测器。如果在本书中没有明显的示例可供参考,那么搜索展示如何使用 Keras 构建你感兴趣的模型架构的教程是一个很好的开始。
查看数据
大部分机器学习研究的重点是设计新的架构;对于训练数据集的覆盖并不多。这是因为在学术界,通常会给你一个固定的预生成训练数据集,你的竞争重点是你的模型在这个数据集上的得分如何与其他人相比。在研究之外,我们通常没有现成的数据集来解决问题,我们关心的是我们为最终用户提供的体验,而不是在一个固定数据集上的得分,因此我们的优先事项变得非常不同。
其中一位作者写了一篇博客文章更详细地介绍了这一点,但总结是你应该期望花费更多的时间收集、探索、标记和改进你的数据,而不是在模型架构上。你投入的时间回报会更高。
在处理数据时,我们发现一些常见的技术非常有用。其中一个听起来非常明显但我们经常忘记的技巧是:查看你的数据!如果你有图像,将它们下载到按标签排列的文件夹中,并在本地机器上浏览它们。如果你在处理音频文件,也是同样的操作,并且听一些音频文件的选段。你会很快发现各种你没有预料到的奇怪和错误,比如标记为美洲豹的汽车被标记为美洲豹猫,或者录音中声音太微弱或被裁剪导致部分单词被切断。即使你只有数值数据,查看逗号分隔值(CSV)文本文件中的数字也会非常有帮助。过去我们发现了一些问题,比如许多数值达到传感器的饱和限制并达到最大值,甚至超出,或者灵敏度太低导致大部分数据被挤压到一个过小的数值范围内。你可以在数据分析中更加深入,你会发现像 TensorBoard 这样的工具对于聚类和其他数据集中发生的可视化非常有帮助。
另一个要注意的问题是训练集不平衡。如果你正在对类别进行分类,不同类别在训练输入中出现的频率将影响最终的预测概率。一个容易陷入的陷阱是认为网络的结果代表真实概率——例如,“是”得分为 0.5 意味着网络预测说话的单词是“是”的概率为 50%。事实上,这种关系更加复杂,因为训练数据中每个类别的比例将控制输出值,但应用程序真实输入分布中每个类别的先验概率是需要了解真实概率的。举个例子,想象一下在 10 种不同物种的鸟类图像分类器上进行训练。如果你将其部署在南极,看到一个指示你看到了鹦鹉的结果会让你非常怀疑;如果你在亚马逊看视频,看到企鹅同样会让你感到惊讶。将这种领域知识融入训练过程可能是具有挑战性的,因为你通常希望每个类别的样本数量大致相等,这样网络才能平等“关注”每个类别。相反,通常在模型推断运行后会进行一个校准过程,根据先验知识对结果进行加权。在南极的例子中,你可能需要一个非常高的阈值才能报告一只鹦鹉,但对企鹅的阈值可能要低得多。
奥兹巫师
我们最喜欢的机器学习设计技术之一实际上并不涉及太多技术。工程中最困难的问题是确定需求是什么,很容易花费大量时间和资源在实际上对于一个问题并不起作用的东西上,特别是因为开发一个机器学习模型的过程需要很长时间。为了澄清需求,我们强烈推荐绿野仙踪方法。在这种情况下,你创建一个系统的模拟,但是不是让软件做决策,而是让一个人作为“幕后之人”。这让你在经历耗时的开发周期之前测试你的假设,以确保在将它们融入设计之前对规格进行了充分测试。
这在实践中是如何运作的呢?想象一下,你正在设计一个传感器,用于检测会议室内是否有人,如果没有人在房间里,它会调暗灯光。与构建和部署运行人员检测模型的无线微控制器不同,采用绿野仙踪方法,你会创建一个原型,只需将实时视频传送给一个坐在附近房间里的人,他手里有一个控制灯光的开关,并有指示在没有人可见时将其调暗。你很快会发现可用性问题,比如如果摄像头没有覆盖整个房间,灯光会在有人仍然在场时不断关闭,或者当有人进入房间时打开灯光存在不可接受的延迟。你可以将这种方法应用于几乎任何问题,它将为你提供关于产品的假设的宝贵验证,而不需要你花费时间和精力在基于错误基础的机器学习模型上。更好的是,你可以设置这个过程,以便从中生成用于训练集的标记数据,因为你将拥有输入数据以及你的绿野仙踪根据这些输入所做的决定。
首先在桌面上让它运行起来
绿野仙踪方法是尽快让原型运行起来的一种方式,但即使在进行模型训练之后,你也应该考虑如何尽快进行实验和迭代。将模型导出并使其在嵌入式平台上运行足够快可能需要很长时间,因此一个很好的捷径是从环境中的传感器向附近的桌面或云计算机传输数据进行处理。这可能会消耗太多能量,无法成为生产中可部署的解决方案,但只要你能确保延迟不会影响整体体验,这是一个很好的方式来获取关于你的机器学习解决方案在整个产品设计背景下运行情况的反馈。
另一个重要的好处是,你可以录制一次传感器数据流,然后一遍又一遍地用于对模型进行非正式评估。如果模型在过去曾经犯过特别严重的错误,而这些错误可能无法在正常指标中得到充分体现,这将尤其有用。如果你的照片分类器将一个婴儿标记为狗,即使你整体准确率为 95%,你可能也会特别想避免这种情况,因为这会让用户感到不安。
在桌面上运行模型有很多选择。开始的最简单方法是使用像树莓派这样具有良好传感器支持的平台收集示例数据,然后将数据批量复制到您的桌面机器(或者如果您喜欢,可以复制到云实例)。然后,您可以使用标准的 Python TensorFlow 以离线方式训练和评估潜在模型,没有交互性。当您有一个看起来很有前途的模型时,您可以采取增量步骤,例如将您的 TensorFlow 模型转换为 TensorFlow Lite,但继续在 PC 上针对批处理数据进行评估。在这之后,您可以尝试将桌面 TensorFlow Lite 应用程序放在一个简单的 Web API 后面,并从具有您所瞄准的外形因素的设备上调用它,以了解它在真实环境中的工作方式。
第十五章:优化延迟
嵌入式系统的计算能力有限,这意味着神经网络所需的密集计算可能比大多数其他平台花费更长的时间。由于嵌入式系统通常实时处理传感器数据流,运行速度过慢可能会导致许多问题。假设您试图观察可能仅在短暂时间内发生的事情(比如相机视野中出现的鸟)。如果处理时间太长,您可能会以太慢的速度采样传感器,错过其中一个事件。有时,通过重复观察重叠的传感器数据窗口,可以改善预测的质量,就像唤醒词检测示例在音频数据上运行一秒钟的窗口来进行唤醒词识别,但每次只将窗口向前移动一百毫秒或更少,对结果进行平均。在这些情况下,减少延迟可以帮助我们提高整体准确性。加快模型执行还可以使设备以更低的 CPU 频率运行,或在推理之间进入睡眠状态,从而降低整体能源使用量。
由于延迟是优化的一个重要领域,本章重点介绍了一些不同的技术,可以帮助您减少运行模型所需的时间。
首先确保它重要
有可能您的神经网络代码只是整体系统延迟的一小部分,加快它可能对产品的性能没有太大影响。确定是否是这种情况的最简单方法是在应用代码中注释掉对tflite::MicroInterpreter::Invoke()的调用。这个函数包含了所有的推理计算,并且会阻塞直到网络运行完毕,因此通过移除它,您可以观察它对整体延迟的影响。在理想的情况下,您可以通过计时器日志语句或分析器来计算这种变化,但正如稍后所述,即使只是闪烁 LED 并粗略估计频率差异,也足以让您对速度增加有一个大致的概念。如果运行网络推理和不运行之间的差异很小,那么从优化代码的深度学习部分中获益不大,您应该首先关注应用的其他部分。
硬件更改
如果您确实需要加快神经网络代码的速度,首先要问的问题是是否能够使用更强大的硬件设备。对于许多嵌入式产品来说,这可能是不可能的,因为通常在很早的时候或者外部已经确定了要使用哪种硬件平台,但因为从软件角度来看这是最容易改变的因素,所以值得明确考虑。如果您有选择的余地,最大的约束通常是能源、速度和成本。如果可以的话,通过更换使用的芯片来权衡能源或成本以换取速度。您甚至可能在研究中幸运地发现一个新平台,它可以在不失去其他两个主要因素的情况下提供更快的速度!
注意
当神经网络进行训练时,通常会一次发送大量的训练示例,在每个训练步骤中。这样可以进行许多计算优化,而当一次只提交一个样本时是不可能的。例如,一百张图像和标签可能会作为一个单独的训练调用的一部分发送。这些训练数据的集合称为批次。
在嵌入式系统中,我们通常一次处理一组传感器读数,实时处理,因此我们不希望等待收集更大的批次再触发推理。这种“单批次”关注意味着我们无法从一些在训练阶段有意义的优化中获益,因此对云端有帮助的硬件架构并不总是适用于我们的用例。
模型改进
在切换硬件平台后,对神经网络延迟产生重大影响的最简单方法是在架构层面。如果您能够创建一个足够准确但涉及更少计算的新模型,您可以加速推断而无需进行任何代码更改。通常可以通过降低准确性来换取增加速度,因此,如果您能够从一开始就使用尽可能准确的模型开始,那么您将有更多的空间进行这些权衡。这意味着花时间改进和扩展您的训练数据在整个开发过程中可能非常有帮助,即使在看似无关的任务,如延迟优化方面。
在优化过程代码时,通常更好的做法是花时间改变代码基于的高级算法,而不是在汇编中重写内部循环。对模型架构的关注基于同样的想法;如果可以的话,最好是完全消除工作,而不是提高执行工作的速度。在我们的情况下不同的是,交换机器学习模型比在传统代码中切换算法要容易得多,因为每个模型只是一个接受输入数据并返回数值结果的功能黑盒。在收集了一组良好的数据之后,应该相对容易地在训练脚本中用另一个模型替换一个模型。您甚至可以尝试删除您正在使用的模型中的单个层并观察效果。神经网络往往具有非常良好的退化性能,因此您应该随意尝试许多不同的破坏性更改,并观察它们对准确性和延迟的影响。
估算模型延迟
大多数神经网络模型在运行时花费大部分时间在运行大型矩阵乘法或非常接近的等效操作。这是因为每个输入值必须由不同的权重缩放以获得每个输出值,因此,每个网络层的工作量大约等于每个输入值乘以每个输出值的数量。这通常通过讨论网络在单次推断运行中所需的浮点运算数(或 FLOPs)来近似。通常,一个乘加操作(通常在机器码级别是一个单指令)计为两个 FLOPs,即使您执行 8 位或更低精度的量化计算,有时也会看到它们被称为 FLOPs,尽管不再涉及浮点数。可以通过手动逐层计算网络所需的 FLOPs。例如,全连接层所需的 FLOPs 数量等于输入向量的大小乘以输出向量的大小。因此,如果您知道这些维度,您可以计算出所需的工作量。通常在讨论和比较模型架构的论文中可以找到 FLOP 的估计值,比如MobileNet。
FLOPs 作为一个粗略的度量单位,用于衡量一个网络执行所需时间的多少,因为其他条件相同,涉及更少计算的模型将以与 FLOPs 差异成比例的速度运行得更快。例如,您可以合理地期望一个需要 1 亿 FLOPs 的模型比 2 亿 FLOP 版本运行速度快两倍。在实践中,这并不完全正确,因为还有其他因素,比如软件对特定层的优化程度会影响延迟,但这是评估不同网络架构的一个很好的起点。这也有助于确定对于您的硬件平台可以期望什么是现实的。如果您能在芯片上以 100 毫秒运行一个 100 万 FLOP 模型,那么您可以做出一个合理的猜测,即需要 1000 万 FLOPs 的不同模型将需要大约一秒来计算。
如何加速您的模型
模型架构设计仍然是一个活跃的研究领域,因此目前很难为初学者撰写一份好的指南。最好的起点是找到一些已经设计为高效的现有模型,然后迭代地尝试进行更改。许多模型具有特定的参数,我们可以改变这些参数以影响所需的计算量,比如 MobileNet 的深度通道因子,或者期望的输入大小。在其他情况下,您可能会查看每个层所需的 FLOPs,并尝试删除特别慢的层或用更快的替代方案替换它们(例如使用深度卷积代替普通卷积)。如果可以的话,最好查看在设备上运行时每个层的实际延迟,而不是通过 FLOPs 来估计。尽管这将需要一些在接下来的代码优化部分讨论的性能分析技术。
注意
设计模型架构是困难且耗时的,但最近已经有一些自动化这个过程的进展,比如MnasNet,使用遗传算法等方法来改进网络设计。这些方法还没有完全取代人类(它们通常需要以已知的良好架构作为起点,并且需要手动规则来确定使用的搜索空间,例如),但很可能我们将在这个领域看到快速的进展。
已经有像AutoML这样的服务,允许用户避开训练的许多细节,希望这种趋势会继续下去,这样您就能够选择最适合您的数据和效率权衡的最佳模型。
量化
运行神经网络需要进行数十万甚至数百万次计算以进行每次预测。执行这种复杂计算的大多数程序对数值精度非常敏感;否则,错误会累积并导致结果太不准确而无法使用。深度学习模型不同——它们能够在中间计算中承受大量数值精度损失,仍然能够产生整体准确的最终结果。这种特性似乎是它们训练过程的副产品,其中输入很大且充满噪音,因此模型学会了对微不足道的变化具有鲁棒性,并专注于重要的模式。
在实践中,这意味着使用 32 位浮点表示进行操作几乎总是比推断所需的精度更高。训练要求更高一些,因为它需要对权重进行许多小的更改来学习,但即使在那里,16 位表示也被广泛使用。大多数推断应用程序可以产生与浮点等效物无法区分的结果,只需使用 8 位来存储权重和激活值。鉴于我们的许多平台对这些模型依赖的 8 位乘积累加指令提供了强大的支持,这对于嵌入式应用来说是个好消息,因为这些指令在信号处理算法中很常见。
然而,将模型从浮点转换为 8 位并不简单。为了有效地执行计算,8 位值需要线性转换为实数。这对于权重来说很容易,因为我们知道每个层的范围是从训练值中得出的,因此我们可以推导出正确的缩放因子来执行转换。然而,对于激活来说就比较棘手,因为从检查模型参数和架构中并不明显每个层输出的范围是多少。如果我们选择的范围太小,一些输出将被剪切到最小值或最大值,但如果范围太大,输出的精度将比可能的精度小,我们将面临整体结果精度下降的风险。
量化仍然是一个活跃的研究课题,有许多不同的选择,因此 TensorFlow 团队在过去几年中尝试了各种方法。您可以在Raghuraman Krishnamoorthi 的“为高效推理量化深度卷积网络:白皮书”中看到一些这些实验的讨论,而量化规范则涵盖了我们现在基于经验使用的推荐方法。
我们将量化过程集中在将模型从 TensorFlow 训练环境转换为 TensorFlow Lite 图的过程中。我们过去推荐了一种量化感知训练方案,但发现这种方法难以使用,我们发现我们可以在导出时使用一些额外的技术获得等效的结果。最容易使用的量化类型是所谓的训练后权重量化。这是将权重量化为 8 位,但激活层保持浮点数的情况。这是有用的,因为它将模型文件大小缩小了 75%,并提供了一些速度优势。这是最容易运行的方法,因为它不需要任何关于激活层范围的知识,但仍然需要快速浮点硬件,这在许多嵌入式平台上并不存在。
训练后整数量化意味着模型可以在没有任何浮点计算的情况下执行,这使得它成为我们在本书中涵盖的用例的首选方法。使用它最具挑战性的部分是,在模型导出过程中需要提供一些示例输入,以便通过运行一些典型图像、音频或其他数据来观察激活层输出的范围。正如我们之前讨论过的,如果没有这些范围的估计,就无法准确地量化这些层。过去,我们使用过其他方法,比如在训练期间记录范围或在运行时捕获范围,但这些方法都有缺点,比如使训练变得更加复杂或施加延迟惩罚,因此这是最不好的方法。
如果您回顾一下我们在第十章中导出人员检测器模型的说明,您会看到我们向converter对象提供了一个representative_dataset函数。这是一个 Python 函数,用于生成激活范围估计过程所需的输入,对于人员检测器模型,我们从训练数据集中加载一些示例图像。不过,对于您训练的每个模型,您都需要弄清楚预期输入,因为每个应用程序的预期输入都会发生变化。此外,很难辨别输入在预处理过程中是如何缩放和转换的,因此创建该函数可能需要一些试错。我们希望未来能够简化这个过程。
在几乎所有平台上运行完全量化的模型都具有很大的延迟优势,但如果您支持一个新设备,您可能需要优化最计算密集的操作,以利用硬件提供的专门指令。如果您正在处理卷积网络,一个很好的起点是Conv2D操作和kernel。您会注意到许多内核有uint8和int8版本;uint8版本是旧的量化方法的残余物,现在不再使用,所有模型现在都应该使用int8路径导出。
产品设计
你可能不会将产品设计视为优化延迟的一种方式,但实际上这是投入时间的最佳地方之一。关键是要弄清楚你是否可以放宽对网络的要求,无论是速度还是准确性。例如,你可能想使用摄像头以每秒多帧的速度跟踪手势,但如果你有一个需要一秒钟才能运行的身体姿势检测模型,你可能可以使用更快的光学跟踪算法以更高的速率跟踪识别的点,当更准确但不太频繁的神经网络结果可用时进行更新。另一个例子,你可以让微控制器将高级语音识别委托给通过网络访问的云 API,同时保持唤醒词检测在本地设备上运行。在更广泛的层面上,你可能可以通过将不确定性纳入用户界面来放宽网络的准确性要求。用于语音识别系统的唤醒词通常是包含不太可能出现在正常语音中的音节序列的短语。如果你有一个手势系统,也许你可以要求每个序列以竖起大拇指结束以确认命令是有意的?
目标是提供尽可能好的用户体验,因此在系统的其他部分中做任何可以更容忍错误的事情,可以让你有更多的空间来权衡准确性和速度或其他需要改进的属性。
代码优化
我们将这个主题放在章节的最后,因为在优化延迟方面有其他方法是你应该首先尝试的,但传统的代码优化是实现可接受性能的重要途径。特别是,TensorFlow Lite for Microcontrollers 的代码已经被编写成在尽可能小的二进制占用空间下运行良好,因此可能有一些优化仅适用于你特定的模型或平台,你可以从中受益。这也是我们鼓励你尽可能推迟代码优化的原因之一,因为如果你更改硬件平台或使用的模型架构,许多这类改变可能不适用,因此首先确定这些事项是至关重要的。
性能分析
任何代码优化工作的基础是知道程序中不同部分运行所需的时间。在嵌入式世界中,这可能会很难确定,因为你可能甚至没有一个简单的默认计时器,即使有,记录和返回所需的信息也可能很困难。以下是我们使用过的各种方法,从最容易实现到最棘手的。
闪烁
几乎所有的嵌入式开发板上都至少有一个 LED 可以从程序中控制。如果你要测量超过半秒的时间,可以尝试在你想要测量的代码部分开始时点亮 LED,然后在之后关闭它。你可以大致估计花费的时间,使用外部秒表并手动计算在 10 秒内看到多少次闪烁。你也可以将两个开发板并排放置,分别运行不同版本的代码,通过闪烁的频率来估计哪个更快。
散弹式性能分析
在大致了解您的应用程序正常运行需要多长时间后,估计特定代码段需要多长时间的最简单方法是将其注释掉,看整体执行速度提高了多少。这被称为shotgun profiling,类比于 shotgun debugging,其中您删除大块代码以定位崩溃,当其他信息很少时。对于神经网络调试来说,这可能会非常有效,因为模型执行代码中通常没有数据相关分支,因此通过注释掉其内部实现将任何一个操作变为无操作不应该影响模型其他部分的速度。
调试日志
在大多数情况下,您应该能够从嵌入式开发板向主机计算机输出一行文本,因此这似乎是检测代码执行时机的理想方式。不幸的是,与开发机器通信本身可能非常耗时。在 Arm Cortex-M 芯片上,串行线调试输出可能需要长达 500 毫秒的时间,延迟变化很大,这使得它对于简单的日志分析方法毫无用处。基于 UART 连接的调试日志通常成本较低,但仍不理想。
逻辑分析仪
类似于切换 LED 但更精确,您可以让您的代码打开和关闭 GPIO 引脚,然后使用外部逻辑分析仪(我们过去使用过Saleae Logic Pro 16)来可视化和测量持续时间。这需要一些布线,设备本身可能很昂贵,但它提供了一种非常灵活的方式来调查程序的延迟,而无需任何软件支持超出一个或多个 GPIO 引脚的控制。
计时器
如果您有一个可以提供足够精度的一致当前时间的计时器,您可以记录您感兴趣的代码部分的开始和结束时的时间,并在之后将持续时间输出到日志中,其中任何通信延迟都不会影响结果。出于这个原因,我们考虑在 TensorFlow Lite for Microcontrollers 中需要一个平台无关的计时器接口,但我们认为这会给那些移植到不同平台的人增加太多负担,因为设置计时器可能会很复杂。不幸的是,这意味着您需要探索如何为您正在运行的芯片实现此功能。还有一个缺点是您需要在您想要调查的任何代码周围添加计时器调用,因此需要工作和计划来识别关键部分,并且您需要在探索时间去向的过程中不断重新编译和刷新。
分析器
如果您幸运的话,您将使用支持某种外部分析工具的工具链和平台。这些应用程序通常会使用来自您的程序的调试信息,以匹配他们从设备上运行您的程序时收集的执行统计信息。然后,它们将能够可视化哪些函数花费了最多时间,甚至是哪些代码行。这是了解代码中速度瓶颈所在的最快方式,因为您将能够快速探索和放大到重要的函数。
优化操作
在确保您使用尽可能简单的模型并确定哪些代码部分花费了最多时间之后,您应该看看如何加快它们的速度。神经网络的大部分执行时间应该花在操作实现内部,因为每个层可能涉及数十万或数百万次计算,因此很可能您已经发现其中一个或多个是瓶颈。
寻找已经优化的实现
TensorFlow Lite for Microcontrollers 中所有操作的默认实现都是为了小巧、易懂和可移植,而不是快速的,因此预期您应该能够通过使用更多代码行或内存的方法轻松击败它们。我们在kernels/portable_optimized 目录中有一组更快的实现,使用了第十三章中描述的子文件夹专业化方法。这些实现不应该有任何平台依赖性,但它们可能使用比参考版本更多的内存。因为它们使用子文件夹专业化,您只需传递TAGS="portable_optimized"参数即可生成一个使用这些实现而不是默认实现的项目。
如果您正在使用具有特定于平台的实现的设备,例如通过类似 CMSIS-NN 的库,并且在指定目标时它们没有自动选择,您可以选择通过传递适当的标签来使用这些非可移植版本。但是,您需要查阅平台的文档和 TensorFlow Lite for Microcontrollers 源代码树,以找到相应的内容。
编写您自己的优化实现
如果您找不到正在占用大部分时间的操作的优化实现,或者可用的实现速度不够快,您可能需要自己编写。好消息是,您应该能够缩小范围,使工作更容易。您只需要调用几种不同的输入和输出大小以及参数的操作,因此您只需要专注于使这些路径更快,而不是一般情况。例如,我们发现深度卷积参考代码在 SparkFun Edge 开发板上的语音唤醒示例的第一个版本中占用了大部分时间,并且整体运行速度太慢,无法使用。当我们查看代码时,我们发现卷积滤波器的宽度始终为八,这使得可以编写利用该模式的一些优化代码。我们可以使用 32 位整数并行获取四个输入值和四个字节中保存的权重。
要开始优化过程,请使用前面描述的子文件夹专业化方法在kernels根目录中创建一个新目录。将参考内核实现复制到该子文件夹中,作为您代码的起点。为确保构建正确,请运行与该操作相关的单元测试,并确保它仍然通过;如果您传递了正确的标签,它应该使用新的实现:
make -f tensorflow/lite/micro/tools/make/Makefile test_depthwise_conv_\
test TAGS="portable_optimized"
然后建议为您的操作添加一个新的测试到单元测试代码中,该测试不检查正确性,只报告执行操作所需的时间。拥有这样的基准测试将帮助您验证您的更改是否按照您的预期提高了性能。对于您在分析中看到速度瓶颈的每种情况,您应该为每种情况都有一个基准测试,具有与模型中该点的操作相同的大小和其他参数(尽管权重和输入可以是随机值,因为在大多数情况下,数字不会影响执行延迟)。基准测试代码本身将需要依赖本章前面讨论的一种性能分析方法,最好使用高精度计时器来测量持续时间,但如果没有,至少切换 LED 或逻辑输出。如果您的测量过程的粒度太大,您可能需要在循环中多次执行操作,然后除以迭代次数以捕获实际所需的时间。在编写基准测试后,记录在您进行任何更改之前的延迟,并确保它大致与您从分析应用程序中看到的相匹配。
有了代表性的基准测试数据,现在您应该能够快速迭代潜在的优化。一个很好的第一步是找到初始实现的最内部循环。这是代码中将被最频繁运行的部分,因此对其进行改进将比算法的其他部分产生更大的影响。通过查看代码并找到最深度嵌套的for循环(或等效部分),您应该能够识别出这一部分,但值得验证您是否有适当的部分,通过将其注释掉并再次运行基准测试。如果延迟显著下降(希望至少降低 50%),则您已经找到了需要关注的正确区域。例如,从深度卷积的参考实现中获取这段代码:
for (int b = 0; b < batches; ++b) {
for (int out_y = 0; out_y < output_height; ++out_y) {
for (int out_x = 0; out_x < output_width; ++out_x) {
for (int ic = 0; ic < input_depth; ++ic) {
for (int m = 0; m < depth_multiplier; m++) {
const int oc = m + ic * depth_multiplier;
const int in_x_origin = (out_x * stride_width) - pad_width;
const int in_y_origin = (out_y * stride_height) - pad_height;
int32 acc = 0;
for (int filter_y = 0; filter_y < filter_height; ++filter_y) {
for (int filter_x = 0; filter_x < filter_width; ++filter_x) {
const int in_x =
in_x_origin + dilation_width_factor * filter_x;
const int in_y =
in_y_origin + dilation_height_factor * filter_y;
// If the location is outside the bounds of the input image,
// use zero as a default value.
if ((in_x >= 0) && (in_x < input_width) && (in_y >= 0) &&
(in_y < input_height)) {
int32 input_val =
input_data[Offset(input_shape, b, in_y, in_x, ic)];
int32 filter_val = filter_data[Offset(
filter_shape, 0, filter_y, filter_x, oc)];
acc += (filter_val + filter_offset) *
(input_val + input_offset);
}
}
}
if (bias_data) {
acc += bias_data[oc];
}
acc = DepthwiseConvRound<output_rounding>(acc, output_multiplier,
output_shift);
acc += output_offset;
acc = std::max(acc, output_activation_min);
acc = std::min(acc, output_activation_max);
output_data[Offset(output_shape, b, out_y, out_x, oc)] =
static_cast<uint8>(acc);
}
}
}
}
}
仅通过检查缩进,就可以确定正确的内部循环如下所示:
const int in_x =
in_x_origin + dilation_width_factor * filter_x;
const int in_y =
in_y_origin + dilation_height_factor * filter_y;
// If the location is outside the bounds of the input image,
// use zero as a default value.
if ((in_x >= 0) && (in_x < input_width) && (in_y >= 0) &&
(in_y < input_height)) {
int32 input_val =
input_data[Offset(input_shape, b, in_y, in_x, ic)];
int32 filter_val = filter_data[Offset(
filter_shape, 0, filter_y, filter_x, oc)];
acc += (filter_val + filter_offset) *
(input_val + input_offset);
}
这段代码被执行的次数比函数中的其他行要多得多,这是因为它位于所有循环的中间位置,将其注释掉将确认它占用了大部分时间。如果你有逐行分析信息的幸运,这也可以帮助你找到确切的部分。
现在你已经找到了一个高影响区域,目标是尽可能将更多工作移到不太关键的部分。例如,在中间有一个if语句,这意味着在每次内部循环迭代时必须执行条件检查,但可以将这部分工作提升到代码的其他部分,以便在外部循环中更少频繁地执行检查。你可能还会注意到一些条件或计算对于你的特定模型和基准测试是不需要的。在语音唤醒词模型中,扩张因子始终为 1,因此涉及它们的乘法可以被跳过,节省更多工作。我们建议您在顶层进行这种参数特定的优化检查,并在参数不符合优化要求时退回到普通的参考实现。这可以加速已知模型,但确保如果您有不符合这些标准的操作,它们至少能正常工作。为了确保您不会意外破坏正确性,值得经常运行操作的单元测试,因为您正在进行更改。
本书的范围超出了覆盖所有优化数值处理代码的方式,但您可以查看portable_optimized文件夹中的内核,看看一些可能有用的技术。
利用硬件特性
到目前为止,我们只讨论了不特定于平台的可移植优化。这是因为重构代码以完全避免工作通常是产生重大影响的最简单方法。它还简化了更专门优化的焦点和范围。您可能会发现自己在像 Cortex-M 设备这样的平台上,具有SIMD 指令,这些指令通常对神经网络推断中占用大部分时间的重复计算非常有帮助。您可能会诱惑直接使用内部函数或者甚至汇编来重写内部循环,但要抵制!至少要查看供应商提供的库的文档,看看是否已经有适合的内容来实现算法的较大部分,因为那可能已经高度优化了(尽管可能会错过您可以应用的优化,了解您的操作参数)。如果可以的话,尝试调用现有函数来计算一些常见的东西,比如快速傅立叶变换,而不是编写自己的版本。
如果您已经完成了这些阶段,那么现在是时候尝试您平台的汇编级别了。我们推荐的方法是从逐行将代码替换为其在汇编中的机械等效物开始,一次替换一行,这样您可以在进行过程中验证正确性,而不必一开始就担心加速。在您转换了必要的代码之后,您可以尝试融合操作和其他技术来减少延迟。与更复杂的处理器相比,嵌入式系统的一个优势是它们的行为通常比较简单,没有深层指令流水线或缓存,因此更容易在纸上理解潜在的性能,并建立潜在的汇编级优化,而不会有太多意外副作用的风险。
加速器和协处理器
随着机器学习工作负载在嵌入式世界中变得更加重要,我们看到越来越多的系统出现,提供专门的硬件来加速或降低它们所需的功耗。然而,目前还没有明确的编程模型或标准 API,因此并不总是清楚如何将它们与软件框架集成。通过 TensorFlow Lite for Microcontrollers,我们希望支持与主处理器同步工作的硬件的直接集成,但异步组件超出了当前项目的范围。
我们所说的同步是指加速硬件与主 CPU 紧密耦合,共享内存空间,并且操作员实现可以快速调用加速器,并在结果返回之前阻塞。从程序员的角度来看,这种加速器更像是早期 x86 系统上存在的浮点协处理器,而不是另一种更像 GPU 的模型。我们专注于这种同步加速器的原因是它们似乎对我们的低能耗系统最有意义,避免异步协调可以使运行时更简单。
类似协处理器的加速器需要与系统架构中的 CPU 非常接近,才能以如此低的延迟响应。相反的模型是现代 GPU 所使用的模型,其中有一个完全独立的系统,具有自己的控制逻辑,位于总线的另一端。编程这些类型的处理器涉及 CPU 排队一长串命令,这些命令需要相对较长的时间来执行,并在批处理准备就绪后立即发送,但立即继续其他工作,不等待加速器完成。在这种模型中,CPU 和加速器之间的通信延迟是微不足道的,因为发送命令的频率很低,而且没有等待结果。加速器可以从这种方法中受益,因为一次看到很多命令会提供许多重新排列和优化工作的机会,这在任务更加细粒度且需要按顺序执行时很难做到。这对图形渲染非常适用,因为结果根本不需要返回给 CPU;渲染的显示缓冲区只需显示给用户。通过向深度学习训练发送大批量的训练样本,可以确保一次有很多工作要做,并尽可能多地保留在卡上,避免将数据复制回 CPU。随着嵌入式系统变得更加复杂并承担更大的工作负载,我们可能会重新审视框架的要求,并通过类似移动版 TensorFlow Lite 中的委托接口来支持这种流程,但这超出了我们当前版本库的范围。
回馈开源
我们始终热衷于看到对 TensorFlow Lite 的贡献,当您努力优化一些框架代码后,您可能会有兴趣将其分享回主线。一个很好的开始是加入SIG Micro邮件列表,并发送一封简短的电子邮件总结您所做的工作,以及指向带有您提议更改的 TensorFlow 存储库分支的指针。如果您包括您正在使用的基准测试以及一些内联文档讨论优化将有所帮助的地方,那将会很有帮助。社区应该能够提供反馈;他们将寻找可以在其基础上构建的东西,通常是有用的,并且可以维护和测试。我们迫不及待地想看看您的成果,感谢您考虑开源您的改进!
收尾
在本章中,我们介绍了加快模型执行速度所需了解的最重要的事情。最快的代码是根本不运行的代码,所以要记住的关键是在开始优化单个函数之前,在模型和算法级别缩小您正在进行的工作。您可能需要解决延迟问题,然后才能让您的应用程序在真实设备上运行,并测试它是否按照您的意图工作。之后,下一个优先事项可能是确保您的设备具有足够的寿命以便有用——这就是下一章关于优化能源使用的地方将会有用的地方。
第十六章:优化能量使用
嵌入式设备相对于台式机或移动系统最重要的优势是它们消耗的能量非常少。服务器 CPU 可能消耗几十甚至几百瓦,需要冷却系统和主电源供应才能运行。即使手机也可能消耗几瓦,并需要每天充电。微控制器可以以不到一毫瓦的功率运行,比手机 CPU 少一千倍以上,因此可以在硬币电池或能量收集上运行数周、数月或数年。
如果您正在开发 TinyML 产品,最具挑战性的限制可能是电池寿命。需要人为干预更换或充电电池通常是不可行的,因此您设备的有用寿命(它将继续工作多长时间)将由其使用的能量量和存储量来定义。电池容量通常受产品的物理尺寸限制(例如,一个剥离式传感器不太可能能够容纳超过一个硬币电池),即使您能够使用能量收集,对其供应的功率也有严格限制。这意味着您可以控制的主要领域是影响设备寿命的能量系统使用量。在本章中,我们将讨论如何调查您的功耗以及如何改进它。
培养直觉
大多数台式工程师对不同类型操作所需时间有一个大致的了解,他们知道网络请求可能比从 RAM 读取数据慢,通常更快地从固态硬盘(SSD)访问文件比从旋转磁盘驱动器访问文件快。但是很少有人需要考虑不同功能需要多少能量,但为了建立心理模型并计划功率效率,您需要一些经验法则来了解您的操作需要多少能量。
注意
在本章中,我们在能量和功率测量之间来回切换。功率是能量随时间的变化,因此例如,每秒使用 1 焦耳(J)能量的 CPU 将使用 1 瓦特的功率。由于我们最关心的是设备的寿命,因此通常最有帮助的是专注于平均功率使用量作为度量标准,因为这与设备在电池中存储的固定能量量上运行的时间长度成正比。这意味着我们可以轻松预测,一个平均功率使用量为 1 毫瓦的系统将持续时间是一个使用 2 毫瓦的系统的两倍。我们有时仍会提到一次性操作的能量使用,这些操作不会持续很长时间。
典型组件功率使用
如果您想深入了解系统组件使用多少能量,Sasu Tarkoma 等人的《智能手机能量消耗》(剑桥大学出版社)是一个很好的开始。以下是我们从他们的计算中得出的一些数字:
-
Arm Cortex-A9 CPU 的功耗在 500 到 2000 毫瓦之间。
-
显示器可能使用 400 毫瓦。
-
活动蜂窝无线电可能使用 800 毫瓦。
-
蓝牙可能使用 100 毫瓦。
超越智能手机,以下是我们观察到的嵌入式组件的最佳测量值:
-
一个麦克风传感器可能使用 300 微瓦(µW)。
-
蓝牙低功耗可能使用 40 毫瓦。
-
一个 320×320 像素的单色图像传感器(如 Himax HM01B0)可能在 30 FPS 时使用 1 毫瓦。
-
Ambiq Cortex-M4F 微控制器可能在 48 MHz 时钟频率下使用 1 毫瓦。
-
一个加速度计可能使用 1 毫瓦。
这些数字将根据您使用的确切组件而有很大变化,但它们对于您至少了解不同操作的大致比例是有用的。一个顶层摘要是,无线电使用的功率比您在嵌入式产品中可能需要的其他功能要多得多。此外,传感器和处理器的能量需求下降速度比通信功率快得多,因此未来这种差距可能会进一步增加。
一旦您了解了系统中活动组件可能使用的能量,您需要考虑您可以存储或收集多少能量来为它们供电。以下是一些大致数字(感谢James Meyers提供的能量收集估算):
-
一个 CR2032 纽扣电池可能容纳 2,500 焦耳。这意味着如果您的系统平均使用 1 毫瓦的功率,您可以希望获得大约一个月的使用时间。
-
一个 AA 电池可能有 15,000 焦耳,为 1 毫瓦系统提供六个月的使用寿命。
-
从工业机器中收集温差可能会产生每平方厘米 1 至 10 毫瓦的能量。
-
室内光源可能每平方厘米提供 10 微瓦的能量。
-
室外光照可能使您能够每平方厘米收集 10 毫瓦的能量。
正如您所看到的,目前只有工业温差或室外光照对于自供电设备是实际可行的,但随着处理器和传感器的能量需求降低,我们希望使用其他方法将开始变得可能。您可以关注商业供应商如Matrix或e-peas以了解一些最新的能量收集设备。
希望这些大致数字能帮助您勾勒出对于您的寿命、成本和尺寸要求组合可能实用的系统类型。它们应该足够至少进行初步可行性检查,如果您能将它们内化为直觉,您将能够快速思考许多不同的潜在权衡。
硬件选择
当您大致了解您的产品可能使用的组件类型时,您需要查看您可以购买的实际零件。如果您正在寻找一些对爱好者来说文档完备且易于获取的东西,最好从浏览像SparkFun、Arduino或AdaFruit这样的网站开始。这些网站提供的组件配有教程、驱动程序和有关连接到其他部件的建议。它们也是开始原型设计的最佳地点,因为您很可能能够获得已经配置好您所需的一切的完整系统。最大的缺点是您的选择会更有限,集成系统可能不会针对整体功耗进行优化,而且您将为额外资源支付溢价。
为了更多选择和更低价格,但没有宝贵的支持,您可以尝试像Digi-Key、Mouser Electronics或甚至Alibaba这样的电子供应商。所有这些网站的共同之处是它们应该为其所有产品提供数据表。这些数据表包含有关每个部件的丰富细节:从如何提供时钟信号到有关芯片大小及其引脚的机械数据。然而,您可能最想了解的第一件事是功耗,而这可能会令人惊讶地难以找到。例如,看看STMicroelectronics Cortex-M0 MCU 的数据表。这本书有近百页,从目录中一眼看去并不明显如何找到功耗。我们发现的一个有用技巧是在这些文档中搜索“毫安”或“ma”(带有空格),因为这些通常是用来表示功耗的单位。在这份数据表中,这种搜索导致了第 47 页上的一个表,如图 16-1 所示,提供了电流消耗的值。

图 16-1. STMicroelectronics 的电流消耗表
这仍然可能很难解释,但我们通常感兴趣的是这个芯片可能使用多少瓦特(或毫瓦)。为了得到这个值,我们需要将表中显示的安培数乘以电压,这里列出的电压为 3.6 伏特(我们已经在表的顶部突出显示了这一点)。如果我们这样做,我们可以看到典型功耗范围从接近 100 毫瓦到只有在睡眠模式下的 10 毫瓦。这让我们知道这个微控制器在功耗方面相对较高,尽管其价格为 55 美分,可能会在您的权衡中得到补偿。您应该能够对您有兴趣使用的所有组件的数据表执行类似的侦探工作,并根据所有这些部分的总和来组装一个关于可能整体功耗的图像。
测量实际功耗
一旦您有了一组组件,您将需要将它们组装成一个完整的系统。这个过程超出了本书的范围,但我们建议您尽早完成一些工作,以便在实际世界中尝试产品并了解更多关于其需求的信息。即使您没有使用您想要的组件或者没有准备好所有软件,获得早期反馈也是非常宝贵的。
拥有一个完整的系统的另一个好处是您可以测试实际的功耗。数据表和估算对于规划是有帮助的,但总有一些东西无法适应简单模型,集成测试通常会显示比您预期的更高的功耗。
有很多工具可以用来测量系统的功耗,了解如何使用万用表(一种用于测量各种电气特性的设备)可能非常有帮助,但最可靠的方法是在设备中放置一个已知容量的电池,然后看它能持续多久。毕竟,这才是您真正关心的,尽管您可能希望它的寿命为几个月或几年,但最有可能的是,您的第一次尝试只能运行几个小时或几天。这种实验方法的优势在于它捕捉了您关心的所有效果,包括当电压下降太低时可能出现的故障,这可能不会在简单的建模计算中显示出来。这种方法也非常简单,即使是软件工程师也可以做到!
为模型估算功耗
估计模型在特定设备上使用多少功率的最简单方法是测量运行一个推理所需的延迟,然后将系统的平均功耗乘以该时间段的能量使用量。在项目开始阶段,你可能不太可能有延迟和功耗的确切数字,但你可以得出大致的数字。如果你知道一个模型需要多少算术运算,以及处理器每秒大约可以执行多少运算,你可以大致估计该模型执行所需的时间。数据表通常会给出设备在特定频率和电压下的功耗数据,尽管要注意的是它们可能不包括整个系统的常见部分,比如内存或外设。值得对这些早期估计持怀疑态度,并将它们用作你可能实现的上限,但至少你可以对你的方法的可行性有一些想法。
举个例子,如果你有一个像人体检测器一样需要执行 6000 万次操作的模型,而你有一个像 Arm Cortex-M4 这样以 48 MHz 运行的芯片,并且你相信它可以使用其 DSP 扩展每个周期执行两次 8 位乘加运算,你可能会猜测最大延迟为 48,000,000/60,000,000 = 800 毫秒。如果你的芯片使用 2 毫瓦,那么每次推理的能量消耗将为 1.6(毫焦)。
改进功耗
现在你知道了系统的大致寿命,你可能会寻找改进的方法。你可能会找到一些硬件修改的方法,比如关闭你不需要的模块或更换组件,但这些超出了本书的范围。幸运的是,有一些常见的技术不需要电气工程知识,但可以帮助很多。因为这些方法是以软件为重点的,它们假设微控制器本身占据了大部分功耗。如果你的设备中的传感器或其他组件是耗电量大的,你将需要进行硬件调查。
占空比
几乎所有嵌入式处理器都有能力将自己置于睡眠模式中,在这种模式下它们不执行任何计算,功耗很低,但能够在一段时间后或外部信号进入时唤醒。这意味着减少功耗的最简单方法之一是在推理调用之间插入睡眠,以便处理器在低功耗模式下花费更多时间。这在嵌入式世界中通常被称为占空比。你可能会担心这会排除连续传感器数据采集,但许多现代微控制器具有直接内存访问(DMA)功能,能够连续采样模拟数字转换器(ADC)并将结果存储在内存中,而无需主处理器的参与。
类似地,你可能能够降低处理器执行指令的频率,使其实际上运行得更慢,从而大幅减少其功耗。之前展示的数据表示例演示了随着时钟频率降低所需能量的减少。
占空比和频率降低提供的是通过计算来交换功耗的能力。这在实践中意味着,如果你能减少软件的延迟,你可以用更低的功耗预算来交换。即使你能够在规定的时间内运行,也要寻找优化延迟的方法,如果你想要减少功耗。
级联设计
机器学习相对于传统的过程式编程的一个重要优势是,它可以轻松地扩展或缩减所需的计算和存储资源量,而准确性通常会逐渐降低。手动编码的算法很难实现这一点,因为通常没有明显的参数可以调整以影响这些属性。这意味着您可以创建所谓的模型级联。传感器数据可以输入到一个计算要求很小的模型中,即使它不是特别准确,也可以调整它,使其在特定条件存在时有很高的触发概率(即使它也会产生很多误报)。如果结果表明刚刚发生了有趣的事情,相同的输入可以被输入到一个更复杂的模型中,以产生更准确的结果。这个过程可以在几个更多的阶段中重复。
这种方法的好处在于,虽然不准确但微小的模型可以适应非常节能的嵌入式设备,并且持续运行它不会消耗太多能量。当发现潜在事件时,可以唤醒一个更强大的系统并运行一个更大的模型,依此类推。因为更强大的系统仅在很短的时间内运行,它们的功耗不会超出预算。这就是手机上始终开启的语音接口是如何工作的。DSP 不断监视麦克风,一个模型在监听“Alexa”、“Siri”、“Hey Google”或类似的唤醒词。主 CPU 可以保持在睡眠模式,但当 DSP 认为可能听到正确的短语时,它会发出信号唤醒它。然后 CPU 可以运行一个更大更准确的模型来确认是否确实是正确的短语,并且如果是的话,可能将随后的语音发送到云中更强大的处理器。
这意味着嵌入式产品即使不能承载一个足够准确以便自行采取行动的模型,也可能实现其目标。如果您能够训练一个能够发现大多数真正阳性的网络,并且假阳性发生的频率足够低,您可能可以将剩余的工作转移到云端。无线电非常耗电,但如果您能够将其使用限制在罕见的情况和短时间内,它可能符合您的能源预算。
总结
对于我们许多人(包括您的作者在内),优化能源消耗是一个陌生的过程。幸运的是,我们在优化延迟方面涵盖的许多技能在这里也适用,只是要监控不同的指标。通常最好先专注于延迟优化,因为您通常需要验证您的产品是否能够提供您想要的短期用户体验,即使其寿命不足以在现实世界中有用。同样,通常在延迟和能源之后处理第十七章的主题,空间优化,是有意义的。在实践中,您可能会在所有不同的权衡之间来回迭代,以满足您的约束条件,但在其他方面相对稳定之后,尺寸通常是最容易处理的。
第十七章:优化模型和二进制大小
无论您选择哪种平台,闪存存储和 RAM 都可能非常有限。大多数嵌入式系统的闪存只读存储器少于 1 MB,许多只有几十 KB。内存也是如此:很少有超过 512 KB 的静态 RAM(SRAM)可用,而在低端设备上,这个数字可能只有几个 KB。好消息是,TensorFlow Lite for Microcontrollers 被设计为可以使用至少 20 KB 的闪存和 4 KB 的 SRAM,但您需要仔细设计您的应用程序并做出工程权衡以保持占用空间较小。本章介绍了一些方法,您可以使用这些方法来监控和控制内存和存储需求。
了解系统的限制
大多数嵌入式系统具有一种架构,其中程序和其他只读数据存储在闪存存储器中,仅在上传新可执行文件时才写入。通常还有可修改的内存可用,通常使用 SRAM 技术。这是用于较大 CPU 缓存的相同技术,它提供快速访问和低功耗,但尺寸有限。更先进的微控制器可以提供第二层可修改内存,使用更耗电但可扩展的技术,如动态 RAM(DRAM)。
您需要了解潜在平台提供的内容以及权衡。例如,具有大量二级 DRAM 的芯片可能因其灵活性而具有吸引力,但如果启用额外的内存超出了您的功耗预算,那可能不值得。如果您正在操作本书关注的 1 mW 及以下功率范围,通常不可能使用超出 SRAM 的任何东西,因为更大的内存方法将消耗太多能量。这意味着您需要考虑的两个关键指标是可用的闪存只读存储器量和可用的 SRAM 量。这些数字应列在您查看的任何芯片的描述中。希望您甚至不需要深入挖掘数据表“硬件选择”。
估算内存使用量
当您了解硬件选项时,您需要了解软件将需要的资源以及您可以做出的权衡来控制这些要求。
闪存使用量
通常,通过编译完整的可执行文件,然后查看生成图像的大小,您可以确定在闪存中需要多少空间。这可能会令人困惑,因为链接器生成的第一个工件通常是带有调试符号和部分信息的可执行文件的注释版本,格式类似于 ELF(我们在“测量代码大小”中更详细地讨论)。您要查看的文件是实际上刷入设备的文件,通常由objcopy等工具生成。用于估算所需闪存内存量的最简单方程式是以下因素之和:
操作系统大小
如果您使用任何类型的实时操作系统(RTOS),则需要在可执行文件中留出空间来保存其代码。这通常可以根据您使用的功能进行配置,并且估算占用空间的最简单方法是使用所需功能构建一个示例“hello world”程序。如果查看图像文件大小,这将为您提供 OS 程序代码有多大的基准。可能占用大量程序空间的典型模块包括 USB、WiFi、蓝牙和蜂窝无线电堆栈,因此请确保启用它们,如果您打算使用它们。
TensorFlow Lite for Microcontrollers 代码大小
机器学习框架需要空间来加载和执行神经网络模型的程序逻辑,包括运行核心算术的操作实现。本章后面我们将讨论如何配置框架以减小特定应用程序的大小,但首先只需编译一个标准单元测试(比如micro_speech测试),其中包括框架,并查看估计的结果图像大小。
模型数据大小
如果您还没有训练好的模型,可以通过计算其权重来估计它所需的闪存存储空间。例如,全连接层的权重数量等于其输入向量的大小乘以其输出向量的大小。对于卷积层,情况会更复杂一些;您需要将滤波框的宽度和高度乘以输入通道的数量,然后乘以滤波器的数量。您还需要为与每一层相关的任何偏置向量添加存储空间。这很快就会变得复杂,因此最简单的方法可能是在 TensorFlow 中创建一个候选模型,然后将其导出为 TensorFlow Lite 文件。该文件将直接映射到闪存中,因此其大小将为您提供占用多少空间的确切数字。您还可以查看Keras 的model.summary()方法列出的权重数量。
注意
我们在第四章中介绍了量化,并在第十五章中进一步讨论了它,但在模型大小的背景下进行一个快速的复习是值得的。在训练期间,权重通常以浮点值存储,每个占用 4 个字节的内存。由于空间对于移动和嵌入式设备来说是一个限制,TensorFlow Lite 支持将这些值压缩到一个字节中,这个过程称为量化。它通过跟踪存储在浮点数组中的最小值和最大值,然后将所有值线性转换为该范围内均匀间隔的 256 个值中最接近的一个。这些代码都存储在一个字节中,可以对它们进行算术运算而几乎不损失精度。
应用程序代码大小
您需要编写代码来访问传感器数据,对其进行预处理以准备神经网络,并响应结果。您可能还需要一些其他类型的用户界面和机器学习模块之外的业务逻辑。这可能很难估计,但您至少应该尝试了解是否需要任何外部库(例如用于快速傅立叶变换),并计算它们的代码空间需求。
RAM 使用量
确定所需的可修改内存量可能比理解存储需求更具挑战性,因为程序的 RAM 使用量会随着程序的生命周期而变化。类似于估计闪存需求的过程,您需要查看软件的不同层以估计整体使用要求:
操作系统大小
大多数RTOS(如 FreeRTOS)记录了它们不同配置选项所需的 RAM 量,您应该能够使用这些信息来规划所需的大小。您需要注意可能需要缓冲区的模块,特别是通信堆栈如 TCP/IP、WiFi 或蓝牙。这些将需要添加到任何核心操作系统要求中。
微控制器的 TensorFlow Lite RAM 大小
ML 框架的核心运行时不需要大量内存,并且其数据结构在 SRAM 中不应该需要超过几千字节的空间。这些分配为解释器使用的类的一部分,因此您的应用程序代码是将这些创建为全局或局部对象将决定它们是在堆栈上还是在一般内存中。我们通常建议将它们创建为全局或static对象,因为空间不足通常会导致链接时错误,而堆栈分配的局部变量可能会导致更难理解的运行时崩溃。
模型内存大小
当神经网络执行时,一个层的结果被馈送到后续操作中,因此必须保留一段时间。这些激活层的寿命因其在图中的位置而异,每个激活层所需的内存大小由层写出的数组的形状控制。这些变化意味着需要随时间计划以将所有这些临时缓冲区尽可能地放入内存的小区域中。目前,这是在解释器首次加载模型时完成的,因此如果竞技场不够大,您将在控制台上看到错误。如果您在错误消息中看到可用内存与所需内存之间的差异,并将竞技场增加该数量,您应该能够解决该错误。
应用程序内存大小
与程序大小一样,应用程序逻辑的内存使用在编写之前可能很难计算。但是,您可以对内存的更大使用者进行一些猜测,例如您将需要用于存储传入样本数据的缓冲区,或者库将需要用于预处理的内存区域。
不同问题上模型准确性和大小的大致数字
了解不同类型问题的当前技术水平将有助于您规划您的应用程序可能实现的目标。机器学习并非魔法,了解其局限性将有助于您在构建产品时做出明智的权衡。第十四章探讨了设计过程,是开始培养直觉的好地方,但您还需要考虑随着模型被迫适应严格资源限制时准确性如何下降。为了帮助您,这里有一些为嵌入式系统设计的架构示例。如果其中一个接近您需要做的事情,可能会帮助您设想在模型创建过程结束时可能实现的结果。显然,您的实际结果将在很大程度上取决于您的具体产品和环境,因此请将这些作为规划的指导,并不要依赖于能够实现完全相同的性能。
语音唤醒词模型
我们之前提到的使用 400,000 次算术运算的小型(18 KB)模型作为代码示例,能够在区分四类声音时达到 85%的一级准确性(参见“建立度量”)。这是训练评估指标,这意味着通过呈现一秒钟的片段并要求模型对其输入进行一次分类来获得结果。在实践中,您通常会在流式音频上使用模型,根据逐渐向前移动的一秒钟窗口重复预测结果,因此在实际应用中的实际准确性低于该数字可能表明的准确性。您通常应该将这种大小的音频模型视为更大处理级联中的第一阶段门卫,以便更复杂的模型可以容忍和处理其错误。
作为一个经验法则,您可能需要一个具有 300 到 400 KB 权重和数千万算术操作的模型,才能以足够可接受的准确性检测唤醒词,以在语音界面中使用。不幸的是,您还需要一个商业质量的数据集进行训练,因为目前仍然没有足够的开放标记语音数据库可用,但希望这种限制随着时间的推移会减轻。
加速度计预测性维护模型
有各种不同的预测性维护问题,但其中一个较简单的情况是检测电机轴承故障。这通常表现为加速度计数据中可以看到的明显震动模式。一个合理的模型来识别这些模式可能只需要几千个权重,使其大小不到 10 KB,并且数十万个算术操作。您可以期望使用这样的模型对这些事件进行分类的准确率超过 95%,并且您可以想象从那里增加模型的复杂性来处理更困难的问题(例如检测具有许多移动部件或自行移动的机器上的故障)。当然,参数和操作的数量也会相应增加。
人员存在检测
计算机视觉在嵌入式平台上并不是常见的任务,因此我们仍在探索哪些应用是有意义的。我们听到的一个常见请求是能够检测到附近有人时,唤醒用户界面或执行其他更耗电的处理,这是不可能一直运行的。我们试图在Visual Wake Word Challenge中正式捕捉这个问题的要求,结果显示,如果使用一个 250 KB 模型和大约 6000 万算术操作,您可以期望在一个小(96×96 像素)单色图像的二进制分类中获得大约 90%的准确性。这是使用缩减版 MobileNet v2 架构的基线(如本书中早期描述的),因此我们希望随着更多研究人员解决这一特殊需求集,准确性会提高,但它给出了您在微控制器内存占用中可能在视觉问题上表现如何的粗略估计。您可能会想知道这样一个小模型在流行的 ImageNet-1000 类别问题上会表现如何 - 很难说确切的原因是最终的全连接层对于一千个类别很快就会占用一百多千字节(参数数量是嵌入输入乘以类别计数),但对于大约 500 KB 的总大小,您可以期望在 top-one 准确性方面达到大约 50%。
模型选择
在优化模型和二进制大小方面,我们强烈建议从现有模型开始。正如我们在第十四章中讨论的那样,投资最有价值的领域是数据收集和改进,而不是调整架构,从已知模型开始将让您尽早专注于数据改进。嵌入式平台上的机器学习软件也仍处于早期阶段,因此使用现有模型增加了其操作在您关心的设备上得到支持和优化的机会。我们希望本书附带的代码示例将成为许多不同应用的良好起点 - 我们选择它们以涵盖尽可能多种不同类型的传感器输入,但如果它们不适合您的用例,您可能可以在线搜索一些替代方案。如果找不到适合的大小优化架构,您可以尝试在 TensorFlow 的训练环境中从头开始构建自己的架构,但正如第十三章和第十九章讨论的那样,成功将其移植到微控制器可能是一个复杂的过程。
减小可执行文件的大小
您的模型可能是微控制器应用程序中只读内存的最大消耗者之一,但您还必须考虑编译代码占用了多少空间。代码大小的限制是我们在针对嵌入式平台时不能只使用未经修改的 TensorFlow Lite 的原因:它将占用数百 KB 的闪存内存。TensorFlow Lite for Microcontrollers 可以缩减至至少 20 KB,但这可能需要您进行一些更改,以排除您的应用程序不需要的代码部分。
测量代码大小
在开始优化代码大小之前,您需要知道它有多大。在嵌入式平台上,这可能有点棘手,因为构建过程的输出通常是一个文件,其中包含调试和其他信息,这些信息不会传输到嵌入式设备上,因此不应计入总大小限制。在 Arm 和其他现代工具链中,这通常被称为可执行和链接格式(ELF)文件,无论是否具有.elf后缀。如果您在 Linux 或 macOS 开发机器上,可以运行file命令来调查您的工具链的输出;它将向您显示文件是否为 ELF。
查看的更好文件通常被称为bin:实际上传到嵌入式设备的闪存存储的代码二进制快照。这通常会完全等于将要使用的只读闪存内存的大小,因此您可以使用它来了解实际使用情况。您可以通过在主机上使用ls -l或dir之类的命令行,甚至在 GUI 文件查看器中检查它来找出其大小。并非所有工具链都会自动显示这个bin文件,它可能没有任何后缀,但它是您通过 USB 在 Mbed 上下载并拖放到设备上的文件,并且使用 gcc 工具链可以通过运行类似arm-none-eabi-objcopy app.elf app.bin -O binary来生成它。查看.o中间文件或甚至构建过程生成的.a库并不有用,因为它们包含了许多元数据,这些元数据不会出现在最终代码占用空间中,并且很多代码可能会被修剪为未使用。
因为我们期望您将模型编译为可执行文件中的 C 数据数组(因为您不能依赖存在文件系统来加载它),所以包括模型的任何程序的二进制大小将包含模型数据。要了解实际代码占用了多少空间,您需要从二进制文件长度中减去这个模型大小。模型大小通常应在包含 C 数据数组的文件中定义(比如在tiny_conv_micro_features_model_data.cc的末尾),因此您可以从二进制文件大小中减去它以了解真实的代码占用空间。
Tensorflow Lite for Microcontrollers 占用了多少空间?
当您了解整个应用程序的代码占用空间大小时,您可能想要调查 TensorFlow Lite 占用了多少空间。测试这一点的最简单方法是注释掉所有对框架的调用(包括创建OpResolvers和解释器等对象),看看二进制文件变小了多少。您应该至少期望减少 20 到 30 KB,因此如果您没有看到类似的情况,您应该再次检查是否捕捉到了所有引用。这应该有效,因为链接器将剥离您从未调用的任何代码,将其从占用空间中删除。这也可以扩展到代码的其他模块,只要确保没有引用,以帮助更好地了解空间的去向。
OpResolver
TensorFlow Lite 支持 100 多种操作,但在单个模型中不太可能需要所有这些操作。每个操作的单独实现可能只占用几千字节,但随着这么多可用的操作,总量很快就会增加。幸运的是,有一种内置机制可以去除你不需要的操作的代码占用空间。
当 TensorFlow Lite 加载模型时,它会使用OpResolver接口来搜索每个包含的操作的实现。这是一个你传递给解释器以加载模型的类,它包含了查找函数指针以获取操作实现的逻辑,给定操作定义。存在这个的原因是为了让你可以控制哪些实现实际上被链接进来。在大多数示例代码中,你会看到我们正在创建并传递一个AllOpsResolver类的实例。正如我们在第五章中讨论的那样,这实现了OpResolver接口,正如其名称所示,它为 TensorFlow Lite for Microcontrollers 中支持的每个操作都有一个条目。这对于入门很方便,因为这意味着你可以加载任何支持的模型,而不必担心它包含哪些操作。
然而,当你开始担心代码大小时,你会想要重新审视这个类。在你的应用程序主循环中,不要再传递AllOpsResolver的实例,而是将all_ops_resolver.cc和.h文件复制到你的应用程序中,并将它们重命名为my_app_resolver.cc和.h,类重命名为MyAppResolver。在你的类构造函数中,删除所有适用于你模型中不使用的操作的AddBuiltin()调用。不幸的是,我们不知道有一种简单的自动方式来创建模型使用的操作列表,但Netron模型查看器是一个可以帮助这个过程的好工具。
确保你用MyAppResolver替换你传递给解释器的AllOpsResolver实例。现在,一旦编译你的应用程序,你应该会看到大小明显缩小。这个改变背后的原因是,大多数链接器会自动尝试删除不能被调用的代码(或死代码)。通过删除AllOpsResolver中的引用,你允许链接器确定可以排除所有不再列出的操作实现。
如果你只使用了少数操作,你不需要像我们使用大型AllOpsResolver那样将注册包装在一个新类中。相反,你可以创建一个MicroMutableOpResolver类的实例,并直接添加你需要的操作注册。MicroMutableOpResolver实现了OpResolver接口,但有额外的方法让你添加操作到列表中(这就是为什么它被命名为Mutable)。这是用来实现AllOpsResolver的类,也是你自己的解析器类的一个很好的基础,但直接调用它可能更简单。我们在一些示例中使用了这种方法,你可以在这个来自micro_speech示例的片段中看到它是如何工作的:
static tflite::MicroMutableOpResolver micro_mutable_op_resolver;
micro_mutable_op_resolver.AddBuiltin(
tflite::BuiltinOperator_DEPTHWISE_CONV_2D,
tflite::ops::micro::Register_DEPTHWISE_CONV_2D());
micro_mutable_op_resolver.AddBuiltin(
tflite::BuiltinOperator_FULLY_CONNECTED,
tflite::ops::micro::Register_FULLY_CONNECTED());
micro_mutable_op_resolver.AddBuiltin(tflite::BuiltinOperator_SOFTMAX,
tflite::ops::micro::Register_SOFTMAX());
你可能会注意到我们将解析器对象声明为static。这是因为解释器可以随时调用它,所以它的生命周期至少需要与我们为解释器创建的对象一样长。
理解单个函数的大小
如果你使用 GCC 工具链,你可以使用像nm这样的工具来获取目标(.o)中间文件中函数和对象的大小信息。这里有一个构建二进制文件然后检查编译后的audio_provider.cc对象文件中项目大小的示例:
nm -S tensorflow/lite/micro/tools/make/gen/ \
sparkfun_edge_cortex-m4/obj/tensorflow/lite/micro/ \
examples/micro_speech/sparkfun_edge/audio_provider.o
你应该会看到类似以下的结果:
00000140 t $d
00000258 t $d
00000088 t $d
00000008 t $d
00000000 b $d
00000000 b $d
00000000 b $d
00000000 b $d
00000000 b $d
00000000 b $d
00000000 b $d
00000000 b $d
00000000 b $d
00000000 b $d
00000000 b $d
00000000 b $d
00000000 r $d
00000000 r $d
00000000 t $t
00000000 t $t
00000000 t $t
00000000 t $t
00000001 00000178 T am_adc_isr
U am_hal_adc_configure
U am_hal_adc_configure_dma
U am_hal_adc_configure_slot
U am_hal_adc_enable
U am_hal_adc_initialize
U am_hal_adc_interrupt_clear
U am_hal_adc_interrupt_enable
U am_hal_adc_interrupt_status
U am_hal_adc_power_control
U am_hal_adc_sw_trigger
U am_hal_burst_mode_enable
U am_hal_burst_mode_initialize
U am_hal_cachectrl_config
U am_hal_cachectrl_defaults
U am_hal_cachectrl_enable
U am_hal_clkgen_control
U am_hal_ctimer_adc_trigger_enable
U am_hal_ctimer_config_single
U am_hal_ctimer_int_enable
U am_hal_ctimer_period_set
U am_hal_ctimer_start
U am_hal_gpio_pinconfig
U am_hal_interrupt_master_enable
U g_AM_HAL_GPIO_OUTPUT_12
00000001 0000009c T _Z15GetAudioSamplesPN6tflite13ErrorReporterEiiPiPPs
00000001 000002c4 T _Z18InitAudioRecordingPN6tflite13ErrorReporterE
00000001 0000000c T _Z20LatestAudioTimestampv
00000000 00000001 b _ZN12_GLOBAL__N_115g_adc_dma_errorE
00000000 00000400 b _ZN12_GLOBAL__N_121g_audio_output_bufferE
00000000 00007d00 b _ZN12_GLOBAL__N_122g_audio_capture_bufferE
00000000 00000001 b _ZN12_GLOBAL__N_122g_is_audio_initializedE
00000000 00002000 b _ZN12_GLOBAL__N_122g_ui32ADCSampleBuffer0E
00000000 00002000 b _ZN12_GLOBAL__N_122g_ui32ADCSampleBuffer1E
00000000 00000004 b _ZN12_GLOBAL__N_123g_dma_destination_indexE
00000000 00000004 b _ZN12_GLOBAL__N_124g_adc_dma_error_reporterE
00000000 00000004 b _ZN12_GLOBAL__N_124g_latest_audio_timestampE
00000000 00000008 b _ZN12_GLOBAL__N_124g_total_samples_capturedE
00000000 00000004 b _ZN12_GLOBAL__N_128g_audio_capture_buffer_startE
00000000 00000004 b _ZN12_GLOBAL__N_1L12g_adc_handleE
U _ZN6tflite13ErrorReporter6ReportEPKcz
许多这些符号是内部细节或无关紧要的,但最后几个可以识别为我们在audio_provider.cc中定义的函数,它们的名称被搅乱以匹配 C++链接器约定。第二列显示它们的大小是多少十六进制。您可以看到InitAudioRecording()函数的大小为0x2c4或 708 字节,这在小型微控制器上可能相当显著,因此如果空间紧张,值得调查函数内部大小的来源。
我们发现的最佳方法是将源代码与反汇编函数混合在一起。幸运的是,objdump工具通过使用-S标志让我们可以做到这一点——但与nm不同,您不能使用安装在 Linux 或 macOS 桌面上的标准版本。相反,您需要使用随您的工具链一起提供的版本。如果您正在使用 TensorFlow Lite for Microcontrollers 的 Makefile 构建,通常会自动下载。它通常会存在于类似tensorflow/lite/micro/tools/make/downloads/gcc_embedded/bin的位置。以下是一个运行以查看audio_provider.cc内部函数更多信息的命令:
tensorflow/lite/micro/tools/make/downloads/gcc_embedded/bin/ \
arm-none-eabi-objdump -S tensorflow/lite/micro/tools/make/gen/ \
sparkfun_edge_cortex-m4/obj/tensorflow/lite/micro/examples/ \
micro_speech/sparkfun_edge/audio_provider.o
我们不会展示所有的输出,因为太长了;相反,我们只展示一个简化版本,只显示我们感兴趣的函数:
...
Disassembly of section .text._Z18InitAudioRecordingPN6tflite13ErrorReporterE:
00000000 <_Z18InitAudioRecordingPN6tflite13ErrorReporterE>:
TfLiteStatus InitAudioRecording(tflite::ErrorReporter* error_reporter) {
0: b570 push {r4, r5, r6, lr}
// Set the clock frequency.
if (AM_HAL_STATUS_SUCCESS !=
am_hal_clkgen_control(AM_HAL_CLKGEN_CONTROL_SYSCLK_MAX, 0)) {
2: 2100 movs r1, #0
TfLiteStatus InitAudioRecording(tflite::ErrorReporter* error_reporter) {
4: b088 sub sp, #32
6: 4604 mov r4, r0
am_hal_clkgen_control(AM_HAL_CLKGEN_CONTROL_SYSCLK_MAX, 0)) {
8: 4608 mov r0, r1
a: f7ff fffe bl 0 <am_hal_clkgen_control>
if (AM_HAL_STATUS_SUCCESS !=
e: 2800 cmp r0, #0
10: f040 80e1 bne.w 1d6 <_Z18InitAudioRecordingPN6tflite13ErrorReporterE+0x1d6>
return kTfLiteError;
}
// Set the default cache configuration and enable it.
if (AM_HAL_STATUS_SUCCESS !=
am_hal_cachectrl_config(&am_hal_cachectrl_defaults)) {
14: 4890 ldr r0, [pc, #576] ; (244 <am_hal_cachectrl_config+0x244>)
16: f7ff fffe bl 0 <am_hal_cachectrl_config>
if (AM_HAL_STATUS_SUCCESS !=
1a: 2800 cmp r0, #0
1c: f040 80d4 bne.w 1c8 <_Z18InitAudioRecordingPN6tflite13ErrorReporterE+0x1c8>
error_reporter->Report("Error - configuring the system cache failed.");
return kTfLiteError;
}
if (AM_HAL_STATUS_SUCCESS != am_hal_cachectrl_enable()) {
20: f7ff fffe bl 0 <am_hal_cachectrl_enable>
24: 2800 cmp r0, #0
26: f040 80dd bne.w 1e4 <_Z18InitAudioRecordingPN6tflite13Error\
ReporterE+0x1e4>
...
您不需要理解汇编在做什么,但希望您可以看到通过查看函数大小(反汇编行最左边的数字;例如,在InitAudioRecording()末尾的十六进制10)如何随着每个 C++源代码行的增加而增加。如果查看整个函数,您会发现所有的硬件初始化代码都已内联在InitAudioRecording()实现中,这解释了为什么它如此庞大。
框架常量
在库代码中有一些地方我们使用硬编码的数组大小来避免动态内存分配。如果 RAM 空间非常紧张,值得尝试看看是否可以减少它们以适应您的应用程序(或者,对于非常复杂的用例,甚至可能需要增加它们)。其中一个数组是TFLITE_REGISTRATIONS_MAX,它控制可以注册多少不同的操作。默认值为 128,这对于大多数应用程序来说可能太多了——特别是考虑到它创建了一个包含 128 个TfLiteRegistration结构的数组,每个结构至少占用 32 字节,需要 4 KB 的 RAM。您还可以查看像MicroInterpreter中的kStackDataAllocatorSize这样的较小的问题,或者尝试缩小您传递给解释器构造函数的 arena 的大小。
真正微小的模型
本章中的许多建议都与能够承受使用 20 KB 框架代码占用的嵌入式系统有关,以运行机器学习,并且不试图仅使用不到 10 KB 的 RAM。如果您的设备资源约束非常严格——例如,只有几千字节的 RAM 或闪存,您将无法使用相同的方法。对于这些环境,您需要编写自定义代码,并非常小心地调整每个细节以减小大小。
我们希望 TensorFlow Lite for Microcontrollers 在这些情况下仍然有用。我们建议您仍然在 TensorFlow 中训练一个模型,即使它很小,然后使用导出工作流从中创建一个 TensorFlow Lite 模型文件。这可以作为提取权重的良好起点,并且您可以使用现有的框架代码来验证您自定义版本的结果。您正在使用的操作的参考实现也应该是您自己操作代码的良好起点;它们应该是可移植的、易于理解的,并且在内存效率方面表现良好,即使它们对延迟不是最佳的。
总结
在这一章中,我们看了一些最好的技术,来缩小嵌入式机器学习项目所需的存储量。这很可能是你需要克服的最艰难的限制之一,但当你拥有一个足够小、足够快、并且不消耗太多能量的应用程序时,你就有了一个明确的路径来推出你的产品。剩下的是排除所有不可避免的小精灵,它们会导致你的设备以意想不到的方式行为。调试可能是一个令人沮丧的过程(我们听说过它被描述为一场谋杀案,你是侦探、受害者和凶手),但这是一个必须学会的技能,以便将产品推向市场。第十八章介绍了可以帮助你理解机器学习系统发生了什么的基本技术。
第十八章:调试
当您将机器学习集成到您的产品中时,无论是嵌入式还是其他方式,您都很可能会遇到一些令人困惑的错误,而且可能会比您想象的要早。在本章中,我们将讨论一些在事情出错时理解发生了什么的方法。
训练和部署之间的准确性损失
当您将一个机器学习模型从 TensorFlow 等创作环境部署到应用程序中时,问题可能会悄然而至。即使您能够构建和运行模型而不报告任何错误,您可能仍然无法获得您期望的准确性结果。这可能会非常令人沮丧,因为神经网络推断步骤似乎是一个黑匣子,没有内部发生的可见性或导致任何问题的原因。
预处理差异
在机器学习研究中很少受到关注的一个领域是如何将训练样本转换为神经网络可以操作的形式。如果您尝试对图像进行对象分类,那么这些图像必须转换为张量,即数字的多维数组。您可能会认为这应该很简单,因为图像已经以 2D 数组的形式存储,通常具有红色、绿色和蓝色值的三个通道。即使在这种情况下,您仍然需要进行一些更改。分类模型期望它们的输入具有特定的宽度和高度,例如宽 224 像素,高 224 像素,而相机或其他输入源不太可能以正确的尺寸产生它们。这意味着您需要将捕获的数据重新缩放以匹配。对于训练过程也必须做类似的处理,因为数据集可能是磁盘上一组任意大小的图像。
一个经常出现的微妙问题是,用于部署的重新缩放方法与用于训练模型的方法不匹配。例如,早期版本的Inception使用双线性缩放来缩小图像,这让具有图像处理背景的人感到困惑,因为这种方式的缩小会降低图像的视觉质量,通常应该避免。因此,许多开发人员在应用程序中使用这些模型进行推断时,改用了更正确的区域采样方法,但事实证明,这实际上降低了结果的准确性!直觉是,训练模型已经学会寻找双线性缩放产生的伪影,而它们的缺失导致了前一错误率增加了几个百分点。
图像预处理并不仅止于重新缩放步骤。还有一个问题,即如何将通常编码为 0 到 255 的图像值转换为训练期间使用的浮点数。出于几个原因,这些值通常会线性缩放到一个较小的范围内:要么是-1.0 到 1.0,要么是 0.0 到 1.0。如果您要输入浮点值,您需要在应用程序中进行相同的值缩放。如果您直接输入 8 位值,您在运行时不需要执行此操作——原始的 8 位值可以不经转换地使用,但您仍需要通过toco导出工具通过--mean_values和--std_values标志将它们传递进去。对于-1.0 到 1.0 的范围,您可以使用--mean_values=128 --std_values=128。
令人困惑的是,从模型代码中往往不明显知道输入图像值的正确比例应该是多少,因为这通常是隐藏在所使用 API 的实现中的细节。许多发布的 Google 模型使用的 Slim 框架默认为-1.0 到 1.0,因此这是一个不错的尝试范围,但如果没有记录,您可能最终不得不通过训练 Python 实现进行调试以找出其他情况下的正确比例。
更糟糕的是,即使调整大小或值缩放有点错误,您也可能得到大部分正确的结果,但会降低准确性。这意味着您的应用程序在初步检查时可能看起来正常运行,但最终体验可能不如预期那样令人印象深刻。图像预处理周围的挑战实际上比其他领域(如音频或加速度计数据)要简单得多,因为可能存在将原始数据转换为神经网络数字数组的复杂特征生成管道。如果查看micro_speech示例的预处理代码,您将看到我们必须实现许多信号处理阶段,以从音频样本获得可馈送到模型中的频谱图,任何此代码与训练中使用的版本之间的差异都会降低结果的准确性。
调试预处理
鉴于这些输入数据转换很容易出错,您可能甚至很难发现问题,即使发现了问题,也可能很难找出原因。您应该怎么办?我们发现有一些方法可以帮助。
如果可能的话,最好有一个可以在桌面机器上运行的代码版本,即使外围设备被存根。在 Linux、macOS 或 Windows 环境中,您将拥有更好的调试工具,并且可以轻松在训练工具和应用程序之间传输测试数据。对于 TensorFlow Lite for Microcontrollers 中的示例代码,我们将应用程序的不同部分拆分为模块,并为 Linux 和 macOS 目标启用了 Makefile 构建,因此我们可以分别运行推断和预处理阶段。
调试预处理问题最重要的工具是比较训练环境和应用程序中所看到的结果。最困难的部分是在训练过程中提取您关心的节点的正确值并控制输入是什么。本书的范围无法详细介绍如何做到这一点,但您需要识别与核心神经网络阶段对应的操作的名称(在文件解码、预处理和接收预处理结果的第一个操作之后)。接收预处理结果的第一个操作对应于toco的--input_arrays参数。如果您能识别这些操作,请在 Python 中在每个操作后插入一个tf.print操作,其中summarize设置为-1。然后,如果运行训练循环,您将能够在调试控制台中看到每个阶段张量内容的打印输出。
然后,您应该能够将这些张量内容转换为 C 数据数组,然后将其编译到您的程序中。在micro_speech代码中有一些示例,比如一个说“yes”的一秒音频样本,以及预处理该输入的预期结果。在获得这些参考值之后,您应该能够将它们作为输入馈送到保存每个阶段的模块(预处理、神经网络推断)中,并确保输出与您的预期相匹配。如果时间不足,您可以使用临时代码来完成此操作,但将其转换为单元测试是值得额外投资的,以确保随着代码随时间变化,您的预处理和模型推断仍然得到验证。
设备上的评估
在训练结束时,神经网络会使用一组测试输入进行评估,将预测结果与期望结果进行比较,以表征模型的整体准确性。这是训练过程的正常部分,但很少对已部署在设备上的代码进行相同的评估。通常最大的障碍只是将构成典型测试数据集的成千上万个输入样本传输到资源有限的嵌入式系统上。然而,这是一种遗憾;确保设备上的准确性与训练结束时看到的准确性相匹配是确保模型已正确部署的唯一方法,因为有很多方式可以引入难以察觉的细微错误。我们没有设法为micro_speech演示实现完整的测试集评估,但至少有端到端测试,确保我们对两个不同输入获得正确的标签。
数值差异
神经网络是对大量数字数组执行的一系列复杂数学操作。原始训练通常是以浮点数进行的,但我们尝试将其转换为嵌入式应用程序的低精度整数表示。这些操作本身可以以许多不同的方式实现,取决于平台和优化权衡。所有这些因素意味着您不能期望从不同设备上的网络获得位级相同的结果,即使给定相同的输入。这意味着您必须确定您可以容忍的差异,并且如果这些差异变得太大,如何追踪其来源。
差异是否是问题?
我们有时开玩笑说,唯一真正重要的度量标准是应用商店评分。我们的目标应该是生产让人们满意的产品,因此所有其他度量标准只是用户满意度的代理。由于训练环境总会存在数值差异,第一个挑战是了解它们是否影响产品体验。如果您从网络中获得的值毫无意义,这可能很明显,但如果它们与预期值仅有几个百分点的差异,值得尝试将生成的网络作为具有现实用例的完整应用程序的一部分。也许准确性损失不是问题,或者有其他更重要的问题应该优先考虑。
建立一个度量标准
当您确定确实存在问题时,量化问题会有所帮助。可能会诱人选择一个数值度量,比如输出得分向量与期望结果之间的百分差异。然而,这可能并不很好地反映用户体验。例如,如果您正在进行图像分类,所有得分都比您期望的低 5%,但结果的相对排序保持不变,那么最终结果对于许多应用程序可能是完全合适的。
相反,我们建议设计一个反映产品需求的度量标准。在图像分类案例中,您可能会选择所谓的top-one分数,跨一组测试图像,因为这将显示模型选择正确标签的频率。top-one 度量标准是模型将地面真实标签选为最高得分预测的频率(top-five类似,但涵盖地面真实标签在五个最高得分预测中的频率)。然后,您可以使用 top-one 度量标准来跟踪您的进展,并且重要的是,了解您所做的更改何时足够好。
您还应小心组装一组标准输入,以反映实际输入到神经网络处理的内容,因为正如我们之前讨论的,预处理可能会引入错误。
与基准比较
TensorFlow Lite for Microcontrollers 被设计为具有其所有功能的参考实现,我们这样做的原因之一是为了能够将它们的结果与优化代码进行比较,以调试潜在的差异。一旦您有了一些标准输入,您应该尝试通过桌面版本的框架运行它们,不启用任何优化,以便调用参考操作符实现。如果您想要这种独立测试的起点,请查看micro_speech_test.cc。如果您将结果通过您建立的度量标准运行,您应该会看到您期望的分数。如果没有,可能在转换过程中出现了一些错误,或者在您的工作流程中的早期阶段出现了其他问题,因此您需要调试回到训练阶段以了解问题所在。
如果您看到使用参考代码获得了良好的结果,那么您应该尝试在目标平台上启用所有优化构建并运行相同的测试。当然,这可能并不像这么简单,因为通常嵌入式设备没有足够的内存来保存所有输入数据,如果您只有调试日志连接,输出结果可能会很棘手。然而,值得坚持,即使您必须将测试分成多次运行。当您获得结果时,请通过您的度量标准运行它们,以了解实际的差距是什么。
替换实现
许多平台默认启用优化,因为参考实现在嵌入式设备上运行时间太长,实际上无法使用。有很多方法可以禁用这些优化,但我们发现最简单的方法通常是找到当前正在使用的所有内核实现,通常在tensorflow/lite/micro/kernels的子文件夹中,并用该父目录中的参考版本覆盖它们(确保您备份要替换的文件)。作为第一步,替换所有优化实现并重新运行设备上的测试,以确保您看到您期望的更好分数。
在进行全面替换之后,尝试仅覆盖一半的优化内核,看看这如何影响度量标准。在大多数情况下,您可以使用二分搜索方法确定哪个优化内核实现导致分数最大下降。一旦您将其缩小到特定的优化内核,然后应该能够通过捕获坏运行之一的输入值和来自参考实现的这些输入的预期输出值来创建一个最小可重现案例。在测试运行期间从内核实现中进行调试日志记录是最简单的方法。
现在您有了一个可重现的案例,您应该能够从中创建一个单元测试。您可以查看标准内核测试之一以开始,并创建一个新的独立测试,或将其添加到该内核的现有文件中。这样,您就可以使用这个工具将问题传达给负责优化实现的团队,因为您将能够展示他们的代码和参考版本之间存在差异,并且这影响了您的应用程序。如果您将其贡献回去,同样的测试也可以添加到主代码库中,并确保没有其他优化实现会导致相同的问题。这也是一个很好的用于自行调试实现的工具,因为您可以在隔离的代码中进行实验并快速迭代。
神秘的崩溃和卡顿
在嵌入式系统中最难修复的情况之一是当你的程序无法运行,但没有明显的日志输出或错误来解释出了什么问题。理解问题的最简单方法是连接调试器(如 GDB),然后查看堆栈跟踪(如果挂起)或逐步执行代码,看看执行出了问题的地方。然而,设置调试器并不总是容易的,或者即使使用调试器后问题的根源仍然不明确,所以还有一些其他技术可以尝试。
桌面调试
像 Linux、macOS 和 Windows 这样的完整操作系统都有广泛的调试工具和错误报告机制,所以如果可能的话,尽量保持你的程序可以在这些平台之一上运行,即使你需要用虚拟实现替换一些硬件特定功能。这就是 TensorFlow Lite for Microcontrollers 的设计方式,这意味着我们可以首先尝试在我们的 Linux 机器上重现任何出现问题的情况。如果在这个环境中发生了相同的错误,通常使用标准工具进行跟踪会更容易更快速,而且无需刷写设备,加快迭代速度。即使维护整个应用程序作为桌面构建太困难,至少看看是否可以为你的模块创建可以在桌面上编译的单元测试和集成测试。然后你可以尝试给它们提供与你遇到问题的情况类似的输入,看看是否也会导致类似的错误。
日志追踪
TensorFlow Lite for Microcontrollers 唯一需要的平台特定功能是DebugLog()的实现。我们有这个要求是因为在开发过程中理解发生了什么是如此重要,即使在生产部署中并不需要。在理想的情况下,任何崩溃或程序错误都应该触发日志输出,例如,我们为 STM32 设备提供的裸机支持有一个故障处理程序来实现这一点,但这并不总是可行的。
你应该始终能够自己向代码中注入日志语句。这些语句不需要有意义,只需要说明代码中的位置。你甚至可以定义一个自动跟踪宏,就像这样:
#define TRACE DebugLog(__FILE__ ":" __LINE__)
然后在你的代码中像这样使用它:
int main(int argc, char**argv) {
TRACE;
InitSomething();
TRACE;
while (true) {
TRACE;
DoSomething();
TRACE;
}
}
你应该在调试控制台中看到输出,显示代码执行到了哪个位置。通常最好从代码的最高级别开始,然后看看日志停在哪里。这将让你大致了解崩溃或挂起发生的区域,然后你可以添加更多的TRACE语句来进一步确定问题发生的具体位置。
散弹式调试
有时候追踪并不能提供足够的信息来解释出现问题的原因,或者问题可能只会在你无法访问日志的环境中发生,比如生产环境。在这种情况下,我们建议使用所谓的“散弹式调试”。这类似于我们在第十五章中介绍的“散弹式性能分析”,只需要注释掉代码的部分部分,看看错误是否仍然发生。如果你从应用程序的顶层开始,逐步向下工作,通常可以做到类似于二分查找的方式来确定哪些代码行导致了问题。例如,你可以从主循环中的某些内容开始:
int main(int argc, char**argv) {
InitSomething();
while (true) {
// DoSomething();
}
}
如果使用注释掉DoSomething()成功运行,那么你就知道问题发生在该函数内部。然后你可以取消注释,并递归地在其内部执行相同的操作,以便集中关注出现问题的代码。
内存损坏
最痛苦的错误是由于内存中的值被意外覆盖而引起的。嵌入式系统没有与台式机或移动 CPU 相同的硬件来防止这种情况,因此这些问题可能特别难以调试。即使跟踪或注释掉代码也可能产生令人困惑的结果,因为覆盖可能发生在使用损坏值的代码运行之前很久,因此崩溃可能与其原因相距甚远。它们甚至可能依赖于传感器输入或硬件定时,使问题变得间歇性且难以复现。
我们的经验中,导致这种情况的头号原因是超出程序堆栈。这是存储本地变量的地方,而 TensorFlow Lite for Microcontrollers 广泛使用这些变量来存储相对较大的对象;因此,它需要比许多其他嵌入式应用程序更多的空间。不幸的是,确切的所需大小并不容易确定。通常,最大的贡献者是您需要传递给SimpleTensorAllocator的内存区域,该区域在示例中被分配为本地数组:
// Create an area of memory to use for input, output, and intermediate arrays.
// The size of this will depend on the model you're using, and may need to be
// determined by experimentation.
const int tensor_arena_size = 10 * 1024;
uint8_t tensor_arena[tensor_arena_size];
tflite::SimpleTensorAllocator tensor_allocator(tensor_arena,
tensor_arena_size);
如果您使用相同的方法,您需要确保堆栈大小大约等于该区域的大小,再加上运行时使用的几千字节的杂项变量。如果您的区域存放在其他地方(可能作为全局变量),则您只需要几千字节的堆栈。所需的确切内存量取决于您的架构、编译器和正在运行的模型,因此不幸的是,事先很难给出确切的值。如果您遇到神秘的崩溃,值得尽可能增加此值,以查看是否有所帮助。
如果您仍然遇到问题,您应该首先尝试确定哪个变量或内存区域被覆盖。希望可以使用之前描述的日志记录或代码消除方法来实现这一点,将问题缩小到似乎已被损坏的值的读取。一旦您知道哪个变量或数组条目被破坏,您可以编写一个类似于TRACE宏的变体,该宏输出该内存位置的值以及调用它的文件和行。您可能需要执行特殊技巧,例如将内存地址存储在全局变量中,以便在本地时可以从更深的堆栈帧中访问。然后,就像您追踪普通崩溃一样,您可以在运行程序并尝试确定哪些代码负责覆盖它时,TRACE出该位置的内容。
总结
在训练环境中正常工作但在实际设备上失败时提出解决方案可能是一个漫长而令人沮丧的过程。在本章中,我们为您提供了一套工具,当您发现自己陷入困境并一筹莫展时,可以尝试使用这些方法。不幸的是,在调试中没有太多捷径,但通过使用这些方法系统地解决问题,我们确信您可以追踪到任何嵌入式机器学习问题。
一旦您在产品中使一个模型正常工作,您可能会开始思考如何调整它,甚至创建一个全新的模型来解决不同的问题。第十九章讨论了如何将您自己的模型从 TensorFlow 训练环境转移到 TensorFlow Lite 推断引擎中。
第十九章:将模型从 TensorFlow 迁移到 TensorFlow Lite
如果你已经走到这一步,你会明白我们倡导在新任务中尽可能重用现有模型。从头开始训练一个全新的模型可能需要大量时间和实验,即使是专家也经常无法在尝试许多不同的原型之前预测最佳方法。这意味着创建新架构的完整指南超出了本书的范围,我们建议查看第二十一章以获取更多相关信息。然而,有一些方面(如使用受限操作集或预处理需求)是独特于资源受限、设备端机器学习的,因此本章提供了关于这些方面的建议。
了解需要哪些操作
本书侧重于在 TensorFlow 中创建的模型,因为作者在 Google 团队工作,但即使在一个框架内,创建模型的方式有很多不同。如果你查看语音命令训练脚本,你会看到它直接使用核心 TensorFlow 操作构建模型,并手动运行训练循环。这在当今是一种相当老式的工作方式(该脚本最初是在 2017 年编写的),而使用 TensorFlow 2.0 的现代示例可能会使用 Keras 作为一个高级 API,它会处理很多细节。
这样做的缺点是,从检查代码中不再明显地了解模型使用的底层操作。相反,它们将作为层的一部分被创建,这些层代表图中的较大块在一个调用中。这是一个问题,因为了解模型使用了哪些 TensorFlow 操作对于理解模型是否能在 TensorFlow Lite 中运行以及资源需求是非常重要的。幸运的是,即使从 Keras 中,只要可以使用tf.keras.backend.get_session()检索底层的Session对象,你仍然可以访问底层的低级操作。如果你直接在 TensorFlow 中编码,很可能已经将会话存储在一个变量中,所以下面的代码仍然有效:
for op in sess.graph.get_operations():
print(op.type)
如果你将会话分配给了sess变量,这将打印出模型中所有操作的类型。你也可以访问其他属性,比如name,以获取更多信息。了解 TensorFlow 操作的存在将有助于在转换过程中到 TensorFlow Lite 时;否则,你看到的任何错误将更难理解。
查看 Tensorflow Lite 中现有操作的覆盖范围
TensorFlow Lite 仅支持 TensorFlow 的一部分操作,并且有一些限制。你可以在操作兼容性指南中查看最新列表。这意味着如果你计划创建一个新模型,你应该确保一开始就不依赖于不受支持的功能或操作。特别是,LSTMs、GRUs 和其他递归神经网络目前还不能使用。目前在完整的移动版本 TensorFlow Lite 和微控制器分支之间存在差距。了解当前 TensorFlow Lite for Microcontrollers 支持哪些操作的最简单方法是查看all_ops_resolver.cc,因为操作不断被添加。
在 TensorFlow 训练会话中显示的操作与 TensorFlow Lite 支持的操作进行比较可能会有点混淆,因为在导出过程中会发生几个转换步骤。例如,这些步骤将存储为变量的权重转换为常量,并可能将浮点操作量化为其整数等效项以进行优化。还有一些仅作为训练循环的一部分存在的操作,比如参与反向传播的操作,这些操作将被完全剥离。找出可能遇到的问题的最佳方法是在创建模型后立即尝试导出潜在模型,而不是在训练之前,这样您就可以在花费大量时间进行训练之前调整其结构。
将预处理和后处理移入应用代码
深度学习模型通常有三个阶段。通常有一个预处理步骤,可能只是从磁盘加载图像和标签并解码 JPEG,或者像将音频数据转换为频谱图这样复杂的语音示例。然后是一个核心神经网络,它接收值数组并以类似形式输出结果。最后,您需要在后处理步骤中理解这些值。对于许多分类问题,这只是将向量中的分数与相应的标签进行匹配,但是如果看一下像 MobileSSD 这样的模型,网络输出是一堆重叠的边界框,需要经过一个称为“非最大抑制”的复杂过程才能作为结果有用。
核心神经网络模型通常是计算量最大的部分,通常由相对较少的操作组成,如卷积和激活。预处理和后处理阶段通常需要更多的操作,包括控制流,尽管它们的计算负载要低得多。这意味着通常更合理的做法是将非核心步骤作为应用中的常规代码实现,而不是将它们嵌入到 TensorFlow Lite 模型中。例如,机器视觉模型的神经网络部分将接收特定尺寸的图像,如高 224 像素,宽 224 像素。在训练环境中,我们将使用 DecodeJpeg 操作,然后是 ResizeImages 操作将结果转换为正确的尺寸。然而,在设备上运行时,我们几乎肯定是从固定大小的源中获取输入图像,无需解压缩,因此编写自定义代码来创建神经网络输入比依赖库中的通用操作更有意义。我们可能还需要处理异步捕获,并可能从线程化所涉及的工作中获得一些好处。在语音命令的情况下,我们会做很多工作来缓存 FFT 的中间结果,以便在流式输入运行时尽可能重用尽可能多的计算。
并非每个模型在训练环境中都有显著的后处理阶段,但是在设备上运行时,通常希望利用随时间的连贯性来改善向用户显示的结果。即使模型只是一个分类器,唤醒词检测代码每秒运行多次并且 使用平均值 来提高结果的准确性是非常常见的。这种代码最好在应用级别实现,因为将其表达为 TensorFlow Lite 操作很困难,并且并不提供太多好处。虽然可能会看到在 detection_postprocess.cc 中,但是这需要在导出过程中从底层 TensorFlow 图中进行大量工作的连接,因为通常表达为 TensorFlow 中的小操作并不是在设备上实现它的有效方式。
这意味着您应该尝试排除图中的非核心部分,这将需要一些工作来确定哪些部分是哪些。我们发现Netron是一个很好的工具,可以用来探索 TensorFlow Lite 图,了解存在哪些操作,并了解它们是神经网络的核心部分还是仅仅是处理步骤。一旦了解内部发生的情况,您应该能够隔离核心网络,仅导出这些操作,并将其余部分实现为应用程序代码。
必要时实现所需操作
如果您发现有一些您绝对需要的 TensorFlow 操作在 TensorFlow Lite 中不受支持,那么可以将它们保存为 TensorFlow Lite 文件格式中的 自定义 操作,然后在框架内自行实现。完整的过程超出了本书的范围,但以下是关键步骤:
-
使用启用
allow_custom_ops的toco运行,以便将不受支持的操作存储为序列化模型文件中的自定义操作。 -
编写实现操作的内核,并在您的应用程序中使用的 op 解析器中使用
AddCustom()进行注册。 -
在调用
Init()方法时,解压存储在 FlexBuffer 格式中的参数。
优化操作
即使您在新模型中使用了受支持的操作,您可能以尚未优化的方式使用它们。TensorFlow Lite 团队的优先事项受特定用例驱动,因此如果您正在运行一个新模型,可能会遇到尚未优化的代码路径。我们在第十五章中讨论了这一点,但正如我们建议您尽快检查导出兼容性一样——甚至在训练模型之前——确保在计划开发时间表之前获得所需的性能是值得的,因为您可能需要预留一些时间来处理操作延迟。
总结
训练一个新颖的神经网络以成功完成任务本身就具有挑战性,但要想构建一个能够产生良好结果并在嵌入式硬件上高效运行的网络更加困难!本章讨论了您将面临的一些挑战,并提供了克服这些挑战的方法建议,但这是一个庞大且不断增长的研究领域,因此我们建议查看第二十一章中的一些资源,看看是否有新的灵感来源可以用于您的模型架构。特别是,在这个领域,跟踪 arXiv 上最新的研究论文可能非常有用。
克服所有这些挑战后,您应该拥有一个小巧、快速、节能的产品,可以随时部署到现实世界中。在发布之前,值得考虑一下它可能对用户造成的潜在有害影响,因此第二十章涵盖了围绕隐私和安全的问题。
第二十章:隐私、安全和部署
在阅读本书之前的章节后,您希望能够构建一个依赖于机器学习的嵌入式应用程序。然而,要将您的项目转化为可以成功部署到世界上的产品,您仍然需要应对许多挑战。保护用户的隐私和安全是两个关键挑战。本章介绍了一些我们发现有用的方法来克服这些挑战。
隐私
设备上的机器学习依赖于传感器输入。其中一些传感器,如麦克风和摄像头,引发了明显的隐私问题,但甚至其他传感器,如加速计,也可能被滥用;例如,通过识别个人的步态来识别他们在使用您的产品时。作为工程师,我们都有责任保护用户免受产品可能造成的损害,因此在设计的各个阶段都要考虑隐私是至关重要的。处理敏感用户数据还涉及法律责任,超出了我们的范围,但您应该咨询您的律师。如果您是大型组织的一部分,您可能有隐私专家和流程可以帮助您获得专业知识。即使您无法获得这些资源,您也应该花一些时间在项目开始时进行自己的隐私审查,并定期重新审查,直到项目上线。关于“隐私审查”到底是什么,目前还没有广泛的共识,但我们讨论了一些最佳实践,其中大部分围绕着建立强大的隐私设计文档(PDD)。
隐私设计文档
隐私工程领域仍然非常新颖,很难找到关于如何处理产品隐私影响的文档。许多大公司处理确保应用程序隐私的过程的方式是创建一个隐私设计文档。这是一个单一的地方,您可以涵盖产品的重要隐私方面。您的文档应包括以下各小节提到的所有主题的信息。
数据收集
PDD 的第一部分应涵盖您将收集的数据、如何收集以及为什么收集。您应尽可能具体,并使用简单的英语,例如,“收集温度和湿度”而不是“获取环境大气信息”。在处理这一部分时,您还有机会思考您实际收集了什么,并确保这是您产品所需的最小数据。如果您只是在听大声噪音以唤醒更复杂的设备,您是否真的需要使用麦克风以 16 KHz 采样音频,还是可以使用一个更简单的传感器,确保即使发生安全漏洞也无法录制语音?在这一部分中,一个简单的系统图可以很有用,显示信息在产品中不同组件之间的流动(包括任何云 API)。这一部分的总体目标是向非技术人员提供对您将收集的内容的良好概述,无论是您的律师、高管还是董事会成员。一个思考方式是,如果由一位不友好的记者撰写的故事登在报纸头版上,会是什么样子。确保您已尽一切可能减少用户受到他人恶意行为的影响。具体而言,思考“一个虐待前任可能使用这项技术做什么?”等情景,并尽可能富有想象力,确保内置了尽可能多的保护措施。
数据使用
在收集数据后,对数据做了什么?例如,许多初创公司都会被诱惑利用用户数据来训练他们的机器学习模型,但从隐私角度来看,这是一个极其棘手的过程,因为它需要长时间存储和处理潜在非常敏感的信息,仅为间接用户利益。我们强烈建议将训练数据采集视为一个完全独立的程序,使用明确同意的付费提供者,而不是收集数据作为产品使用的副作用。
在设备上进行机器学习的好处之一是您有能力在本地处理敏感数据并仅共享聚合结果。例如,您可能有一个行人计数设备,每秒捕获图像,但传输的唯一数据是看到的人和车辆的计数。如果可以的话,尽量设计您的硬件以确保这些保证不会被打破。如果您只使用 224×224 像素图像作为分类算法的输入,使用一个分辨率低的摄像头传感器,以便无法识别面孔或车牌。如果您计划仅传输几个值作为摘要(如行人计数),请仅支持低比特率的无线技术,以避免即使您的设备被黑客入侵也无法传输源视频。我们希望未来,专用硬件将有助于执行这些保证,但即使现在,在系统设计层面仍有很多事情可以做,以避免过度设计并使滥用更加困难。
数据共享和存储
谁可以访问您收集的数据?有什么系统可以确保只有这些人可以看到它?数据会保留多长时间,无论是在设备上还是在云端?如果数据被保留了一段时间,删除政策是什么?您可能认为存储剥离了明显用户 ID(如电子邮件地址或姓名)的信息是安全的,但身份可以从许多来源推导出,比如 IP 地址、可识别的声音,甚至步态,因此您应该假设您收集的任何传感器数据都是个人可识别信息(PII)。最佳政策是将这种类型的 PII 视为放射性废物。如果可能的话,避免收集它,当您需要时要妥善保护它,并在完成后尽快处理它。
在考虑谁可以访问时,不要忘记所有您的许可系统都可以被政府压力覆盖,这可能会给您的用户在压制国家造成严重伤害。这是限制传输和存储到最低限度的另一个原因,以避免这种责任并限制用户的暴露。
同意
使用您的产品的人是否了解它正在收集什么信息,并且他们是否同意您将如何使用它?这里有一个狭窄的法律问题,您可能认为可以通过点击式最终用户许可协议来回答,但我们鼓励您将其更广泛地看作是一个营销挑战。假设您相信产品的好处值得收集更多数据,那么您如何清晰地向潜在客户传达这一点,以便他们做出知情选择?如果您在构思这条信息时遇到困难,那就是您应该重新考虑设计以减少隐私影响或增加产品的好处的迹象。
使用 PDD
您应该将 PDD 视为一份不断更新的活动文件,随着产品的发展而不断更新。显然,它对于向您的律师和其他业务利益相关者传达产品细节非常有用,但它在许多其他情境下也很有用。例如,您应该与您的营销团队合作,以确保其传达的信息是基于您正在做的事情,并与任何第三方服务提供商(如广告)合作,以确保他们遵守您所承诺的内容。团队中的所有工程师都应该可以访问它并添加评论,因为在实施层面可能会有一些隐藏的隐私影响。例如,您可能正在使用一个泄漏设备 IP 地址的地理编码云 API,或者您的微控制器上可能有一个未使用但理论上可以启用以传输敏感数据的 WiFi 芯片。
安全性
确保嵌入式设备的总体安全性非常困难。攻击者可以轻易获得系统的物理控制权,然后使用各种侵入性技术来提取信息。您的第一道防线是确保尽可能少的敏感信息保留在您的嵌入式系统上,这就是为什么 PDD 如此重要。如果您依赖与云服务的安全通信,您应该考虑调查安全加密处理器以确保任何密钥都安全保存。这些芯片还可以用于安全引导,以确保只有您刷写的程序才能在设备上运行。
与隐私一样,您应该努力设计硬件,以限制任何攻击者的机会。如果您不需要 WiFi 或蓝牙,构建一个没有这些功能的设备。不要在发货产品上提供像 SWD 这样的调试接口,并研究在 Arm 平台上禁用代码读取。尽管这些措施并不完美,但它们会增加攻击的成本。
您还应该尽量依赖已建立的库和服务来进行安全和加密。自行开发加密是一个非常糟糕的主意,因为很容易犯错误,而这些错误很难发现,但会破坏系统的安全性。嵌入式系统安全的全部挑战超出了本书的范围,但您应该考虑创建一个安全设计文档,类似于我们为隐私推荐的文档。您应该涵盖您认为可能的攻击、它们的影响以及您将如何防御它们。
保护模型
我们经常听到工程师们担心保护他们的机器学习模型免受不道德的竞争对手的侵害,因为这些模型需要大量工作来创建,但却被部署在设备上并且通常以易于理解的格式存在。坏消息是,没有绝对的保护免受复制。在这方面,模型就像任何其他软件:它们可以被窃取和检查,就像常规的机器码一样。然而,就像软件一样,问题并不像一开始看起来那么糟糕。就像反汇编过程式程序不会显示真正的源代码一样,检查量化模型也不会提供任何访问训练算法或数据的途径,因此攻击者将无法有效地修改模型以供其他用途。如果模型被部署在竞争对手的设备上,直接复制模型应该很容易被发现,并且可以在法律上证明竞争对手窃取了您的知识产权,就像您可以对任何其他软件做的那样。
让对您的模型进行非正式攻击变得更加困难可能是值得的。一种简单的技术是使用私钥对序列化模型进行 XOR 后存储在闪存中,然后在使用之前将其复制到 RAM 并解密。这将防止简单地转储闪存来揭示您的模型,但在运行时具有 RAM 访问权限的攻击者仍将能够访问它。您可能认为切换到专有格式而不是 TensorFlow Lite FlatBuffer 会有所帮助,但由于权重参数本身是大量数值数组,并且从调试器中逐步了解调用哪些操作以及顺序,我们发现这种混淆的价值非常有限。
注意
一种有趣的方法用于发现模型被盗用是在训练过程中故意引入微小缺陷,然后在检查疑似侵权时寻找它们。例如,您可以训练一个唤醒词检测模型,不仅监听“Hello”,还秘密监听“Ahoy, sailor!”。独立训练的模型极不可能对相同短语做出响应,因此如果有响应,这是模型被复制的强烈信号,即使您无法访问设备的内部工作原理。这种技术基于在参考作品中包含虚构条目的古老想法,例如地图、目录和字典,以帮助发现侵犯版权;它已经被称为mountweazeling,源自在地图上放置虚构山峰“Mountweazel”来帮助识别副本的做法。
部署
使用现代微控制器很容易启用空中更新,这样您就可以随时修改设备上运行的代码,甚至在发货后很久。这为安全和隐私侵犯打开了一个广泛的攻击面,我们敦促您考虑是否对您的产品真正必不可少。如果没有经过良好设计的安全引导系统和其他保护措施,很难确保只有您有能力上传新代码,如果出现错误,您就将完全将设备的控制权交给了恶意行为者。作为默认设置,我们建议在设备制造后不允许任何形式的代码更新。这可能听起来严厉,因为它阻止了修复安全漏洞的更新,但在几乎所有情况下,消除攻击者代码在系统上运行的可能性将更有利于安全,而不是有害。这也简化了网络架构,因为不再需要任何协议“监听”更新;设备可能有效地能够在仅传输模式下运行,这也大大减少了攻击面。
这意味着在设备发布之前,您需要更多地承担编写正确代码的责任,特别是关于模型准确性。我们之前谈到过像单元测试和针对专用测试集验证整体模型准确性等方法,但它们不会捕捉到所有问题。当您准备发布时,我们强烈建议使用一种自用方法,在这种方法中,您可以在真实环境中尝试设备,但在组织内部人员的监督下进行。这些实验更有可能揭示意外行为,而不是工程测试,因为测试受到其创建者的想象力的限制,而现实世界比我们任何人都能提前预测的要惊人得多。好消息是,在遇到不良行为之后,您可以将其转化为可以作为正常开发过程的一部分解决的测试用例。事实上,开发这种深入了解产品需求的机构记忆,并将其编码为测试,可能是您最大的竞争优势之一,因为获得这种优势的唯一方法是通过痛苦的试错。
从开发板转向产品
将在开发板上运行的应用程序转变为成品的完整过程超出了本书的范围,但在开发过程中有一些值得考虑的事项。您应该研究您考虑使用的微控制器的批量价格,例如在Digi-Key等网站上,以确保您最终的目标系统符合您的预算。假设您在开发过程中使用的是相同的芯片,将代码移植到生产设备应该相当简单,因此从编程的角度来看,主要任务是确保您的开发板与生产目标匹配。在您的代码以最终形式部署后,调试任何出现的问题将变得更加困难,尤其是如果您之前已经采取了保护平台的步骤,因此尽可能推迟这一步骤是值得的。
总结
保护用户的隐私和安全是我们作为工程师的最重要责任之一,但如何决定最佳方法并不总是清晰的。在本章中,我们涵盖了思考和设计保护措施的基本过程,以及一些更高级的安全考虑。通过这些内容,我们完成了构建和部署嵌入式机器学习应用的基础,但我们知道这个领域远不止我们在一本书中能涵盖的内容。最后一章讨论了您可以使用的资源,以继续学习更多。
第二十一章:进一步学习
我们希望这本书能帮助您解决重要的问题,使用廉价、低功耗的设备。这是一个新兴且快速增长的领域,所以我们在这里包含的内容只是一个快照。如果您想保持最新,这里有一些推荐的资源。
TinyML 基金会
TinyML 峰会是一年一度的会议,汇集了嵌入式硬件、软件和机器学习从业者,讨论跨学科合作。在湾区和德克萨斯州奥斯汀还有每月聚会,未来预计会有更多地点。即使无法亲临现场,您也可以在 TinyML 基金会网站上查看活动的视频、幻灯片和其他材料。
SIG Micro
本书专注于微控制器的 TensorFlow Lite,如果您有兴趣为框架做出贡献,有一个特别兴趣小组(SIG)可以让外部开发人员合作改进。SIG Micro 有公开的每月视频会议、邮件列表和 Gitter 聊天室。如果您对库中的新功能有想法或请求,这是一个讨论的好地方。您将看到所有参与项目的开发人员,包括谷歌内部和外部的开发人员,分享即将进行的工作的路线图和计划。任何更改的通常流程是从分享设计文档开始,对于简单更改,可以只是一页纸,涵盖为什么需要更改以及它将做什么。我们通常将其发布为 RFC(“请求评论”)以允许利益相关者提供反馈,然后在达成一致后,跟进包含实际代码更改的拉取请求。
TensorFlow 网站
主要的 TensorFlow 网站有一个用于微控制器工作的首页,您可以在那里查看最新的示例和文档。特别是,我们将在培训示例代码中继续迁移到 TensorFlow 2.0,所以如果您遇到兼容性问题,值得一看。
其他框架
我们专注于 TensorFlow 生态系统,因为这是我们最了解的库,但其他框架上也有很多有趣的工作正在进行。我们非常欣赏 Neil Tan 在 uTensor 上的开创性工作,该工作对从 TensorFlow 模型生成代码进行了许多有趣的实验。微软的嵌入式学习库支持除深度神经网络之外的大量不同机器学习算法,并且针对 Arduino 和 micro:bit 平台。
您是否构建了一个嵌入式机器学习项目,想告诉全世界?我们很乐意看到您正在解决的问题,通过在 Twitter 上使用#tinyml标签分享链接是一个很好的联系方式。我们自己也在 Twitter 上,账号是@petewarden 和@dansitu,我们将在@tinymlbook 上发布有关本书的更新。
TinyML 的朋友们
在这个领域有很多有趣的公司,从初创公司到大公司。如果您正在开发产品,您会想探索他们提供的内容,所以这里是一些我们合作过的组织的按字母顺序排列的列表:
-
Adafruit
-
Ambiq Micro
-
Arduino
-
Arm
-
Cadence/Tensilica
-
CEVA/DSP Group
-
Edge Impulse
-
Eta Compute
-
Everactive
-
GreenWaves Technologies
总结
感谢您加入我们探索嵌入式设备上的机器学习之旅。我们希望我们已经激发了您开展自己的项目,并且我们迫不及待地想看到您的成果,以及您如何推动这个令人兴奋的新领域向前发展!
附录 A:使用和生成 Arduino 库 Zip
Arduino IDE 要求源文件以一定的方式打包。TensorFlow Lite for Microcontrollers Makefile 知道如何为您做这件事,并且可以生成一个包含所有源文件的.zip文件,您可以将其导入到 Arduino IDE 作为库。这将允许您构建和部署您的应用程序。
在本节的后面会有生成此文件的说明。然而,开始的最简单方法是使用 TensorFlow 团队每晚生成的预构建.zip文件。
在下载了该文件之后,您需要导入它。在 Arduino IDE 的 Sketch 菜单中,选择包含库→添加.ZIP 库,如图 A-1 所示。

图 A-1. “添加.ZIP 库…”菜单选项
在出现的文件浏览器中,找到.zip文件,然后点击选择以导入它。
您可能希望自己生成库,例如,如果您对 TensorFlow Git 存储库中的代码进行了更改,并希望在 Arduino 环境中测试这些更改。
如果您需要自己生成文件,请打开终端窗口,克隆 TensorFlow 存储库,并切换到其目录:
git clone https://github.com/tensorflow/tensorflow.git
cd tensorflow
现在运行以下脚本以生成.zip文件:
tensorflow/lite/micro/tools/ci_build/test_arduino.sh
文件将被创建在以下位置:
tensorflow/lite/micro/tools/make/gen/arduino_x86_64/ \
prj/micro_speech/tensorflow_lite.zip
然后,您可以按照之前记录的步骤将此.zip文件导入到 Arduino IDE 中。如果您之前安装了库,您需要先删除原始版本。您可以通过从 Arduino IDE 的libraries目录中删除tensorflow_lite目录来实现这一点,您可以在 IDE 的首选项窗口中的“Sketchbook 位置”下找到它。
附录 B:在 Arduino 上捕获音频
以下文本将从第七章中唤醒词应用程序的音频捕获代码中走过。由于它与机器学习没有直接关系,所以作为附录提供。
Arduino Nano 33 BLE Sense 具有内置麦克风。要从麦克风接收音频数据,我们可以注册一个回调函数,当有一块新的音频数据准备好时就会调用它。
每次发生这种情况,我们将新数据块写入存储数据储备的缓冲区中。由于音频数据占用大量内存,缓冲区只能容纳一定量的数据。当缓冲区变满时,这些数据将被覆盖。
每当我们的程序准备好运行推断时,它可以从该缓冲区中读取最近一秒钟的数据。只要新数据持续进入得比我们需要访问的快,缓冲区中总是会有足够的新数据进行预处理并馈入我们的模型。
每个预处理和推断周期都很复杂,需要一些时间来完成。因此,在 Arduino 上我们每秒只能运行几次推断。这意味着我们的缓冲区很容易保持满状态。
正如我们在第七章中看到的,audio_provider.h实现了这两个函数:
-
GetAudioSamples(),提供指向一块原始音频数据的指针 -
LatestAudioTimestamp(),返回最近捕获音频的时间戳
实现这些功能的 Arduino 代码位于arduino/audio_provider.cc中。
在第一部分中,我们引入了一些依赖项。PDM.h库定义了我们将用来从麦克风获取数据的 API。文件micro_model_settings.h包含了与我们模型数据需求相关的常量,这将帮助我们以正确的格式提供音频数据。
#include "tensorflow/lite/micro/examples/micro_speech/
audio_provider.h"
#include "PDM.h"
#include "tensorflow/lite/micro/examples/micro_speech/
micro_features/micro_model_settings.h"
接下来的代码块是设置一些重要变量的地方:
namespace {
bool g_is_audio_initialized = false;
// An internal buffer able to fit 16x our sample size
constexpr int kAudioCaptureBufferSize = DEFAULT_PDM_BUFFER_SIZE * 16;
int16_t g_audio_capture_buffer[kAudioCaptureBufferSize];
// A buffer that holds our output
int16_t g_audio_output_buffer[kMaxAudioSampleSize];
// Mark as volatile so we can check in a while loop to see if
// any samples have arrived yet.
volatile int32_t g_latest_audio_timestamp = 0;
} // namespace
布尔值g_is_audio_initialized用于跟踪麦克风是否已开始捕获音频。我们的音频捕获缓冲区由g_audio_capture_buffer定义,大小为DEFAULT_PDM_BUFFER_SIZE的 16 倍,这是在PDM.h中定义的一个常量,表示每次调用回调函数时从麦克风接收的音频量。拥有一个很大的缓冲区意味着如果程序因某种原因变慢,我们不太可能耗尽数据。
除了音频捕获缓冲区,我们还保留一个输出音频缓冲区g_audio_output_buffer,当调用GetAudioSamples()时,我们将返回一个指向它的指针。它的长度是kMaxAudioSampleSize,这是来自micro_model_settings.h的一个常量,定义了我们的预处理代码一次可以处理的 16 位音频样本的数量。
最后,我们使用g_latest_audio_timestamp来跟踪我们最新音频样本所代表的时间。这不会与您手表上的时间匹配;它只是相对于音频捕获开始时的毫秒数。该变量声明为volatile,这意味着处理器不应尝试缓存其值。稍后我们会看到原因。
设置这些变量后,我们定义回调函数,每当有新的音频数据可用时就会调用它。以下是完整的函数:
void CaptureSamples() {
// This is how many bytes of new data we have each time this is called
const int number_of_samples = DEFAULT_PDM_BUFFER_SIZE;
// Calculate what timestamp the last audio sample represents
const int32_t time_in_ms =
g_latest_audio_timestamp +
(number_of_samples / (kAudioSampleFrequency / 1000));
// Determine the index, in the history of all samples, of the last sample
const int32_t start_sample_offset =
g_latest_audio_timestamp * (kAudioSampleFrequency / 1000);
// Determine the index of this sample in our ring buffer
const int capture_index = start_sample_offset % kAudioCaptureBufferSize;
// Read the data to the correct place in our buffer
PDM.read(g_audio_capture_buffer + capture_index, DEFAULT_PDM_BUFFER_SIZE);
// This is how we let the outside world know that new audio data has arrived.
g_latest_audio_timestamp = time_in_ms;
}
这个函数有点复杂,所以我们将分块解释。它的目标是确定正确的索引,将这些新数据写入音频捕获缓冲区。
首先,我们确定每次调用回调函数时将接收多少新数据。我们使用这个数据来确定一个以毫秒表示缓冲区中最近音频样本的时间的数字:
// This is how many bytes of new data we have each time this is called
const int number_of_samples = DEFAULT_PDM_BUFFER_SIZE;
// Calculate what timestamp the last audio sample represents
const int32_t time_in_ms =
g_latest_audio_timestamp +
(number_of_samples / (kAudioSampleFrequency / 1000));
每秒的音频样本数是kAudioSampleFrequency(这个常量在micro_model_settings.h中定义)。我们将这个数除以 1,000 得到每毫秒的样本数。
接下来,我们将每个回调的样本数(number_of_samples)除以每毫秒的样本数以获取每个回调获得的数据的毫秒数:
(number_of_samples / (kAudioSampleFrequency / 1000))
然后,我们将其添加到我们先前最近音频样本的时间戳g_latest_audio_timestamp,以获取最新新音频样本的时间戳。
当我们有了这个数字后,我们可以使用它来获取所有样本历史记录中最近样本的索引。为此,我们将先前最近音频样本的时间戳乘以每毫秒的样本数:
const int32_t start_sample_offset =
g_latest_audio_timestamp * (kAudioSampleFrequency / 1000);
然而,我们的缓冲区没有足够的空间来存储每个捕获的样本。相反,它有 16 倍DEFAULT_PDM_BUFFER_SIZE的空间。一旦数据超过这个限制,我们就开始用新数据覆盖缓冲区。
现在,我们有了我们新样本在所有样本历史记录中的索引。接下来,我们需要将其转换为实际缓冲区内样本的正确索引。为此,我们可以通过缓冲区长度除以历史索引并获取余数。这是使用模运算符(%)完成的:
// Determine the index of this sample in our ring buffer
const int capture_index = start_sample_offset % kAudioCaptureBufferSize;
因为缓冲区的大小kAudioCaptureBufferSize是DEFAULT_PDM_BUFFER_SIZE的倍数,新数据将始终完全适合缓冲区。模运算符将返回新数据应开始的缓冲区内的索引。
接下来,我们使用PDM.read()方法将最新音频读入音频捕获缓冲区:
// Read the data to the correct place in our buffer
PDM.read(g_audio_capture_buffer + capture_index, DEFAULT_PDM_BUFFER_SIZE);
第一个参数接受一个指向数据应写入的内存位置的指针。变量g_audio_capture_buffer是指向音频捕获缓冲区起始地址的指针。通过将capture_index添加到此位置,我们可以计算出要写入新数据的内存中的正确位置。第二个参数定义应读取多少数据,我们选择最大值DEFAULT_PDM_BUFFER_SIZE。
最后,我们更新g_latest_audio_timestamp:
// This is how we let the outside world know that new audio data has arrived.
g_latest_audio_timestamp = time_in_ms;
这将通过LatestAudioTimestamp()方法暴露给程序的其他部分,让它们知道何时有新数据可用。因为g_latest_audio_timestamp声明为volatile,每次访问时其值将从内存中查找。这很重要,否则变量将被处理器缓存。因为其值在回调中设置,处理器不会知道要刷新缓存的值,任何访问它的代码都不会收到其当前值。
您可能想知道是什么使CaptureSamples()充当回调函数。它如何知道何时有新音频可用?这是我们代码的下一部分处理的,这部分是启动音频捕获的函数:
TfLiteStatus InitAudioRecording(tflite::ErrorReporter* error_reporter) {
// Hook up the callback that will be called with each sample
PDM.onReceive(CaptureSamples);
// Start listening for audio: MONO @ 16KHz with gain at 20
PDM.begin(1, kAudioSampleFrequency);
PDM.setGain(20);
// Block until we have our first audio sample
while (!g_latest_audio_timestamp) {
}
return kTfLiteOk;
}
第一次有人调用GetAudioSamples()时将调用此函数。它首先使用PDM库通过调用PDM.onReceive()来连接CaptureSamples()回调。接下来,使用两个参数调用PDM.begin()。第一个参数指示要记录多少个音频通道;我们只需要单声道音频,因此指定1。第二个参数指定每秒要接收多少样本。
接下来,使用PDM.setGain()来配置增益,定义麦克风音频应放大多少。我们指定增益为20,这是在一些实验之后选择的。
最后,我们循环直到g_latest_audio_timestamp评估为 true。因为它从0开始,这会阻止执行,直到回调捕获到一些音频,此时g_latest_audio_timestamp将具有非零值。
我们刚刚探讨的两个函数允许我们启动捕获音频的过程并将捕获的音频存储在缓冲区中。接下来的函数GetAudioSamples()为我们代码的其他部分(即特征提供者)提供了获取音频数据的机制:
TfLiteStatus GetAudioSamples(tflite::ErrorReporter* error_reporter,
int start_ms, int duration_ms,
int* audio_samples_size, int16_t** audio_samples) {
// Set everything up to start receiving audio
if (!g_is_audio_initialized) {
TfLiteStatus init_status = InitAudioRecording(error_reporter);
if (init_status != kTfLiteOk) {
return init_status;
}
g_is_audio_initialized = true;
}
该函数被调用时带有一个用于写日志的ErrorReporter,两个指定我们请求的音频的变量(start_ms和duration_ms),以及用于传回音频数据的两个指针(audio_samples_size和audio_samples)。函数的第一部分调用InitAudioRecording()。正如我们之前看到的,这会阻塞执行,直到音频的第一个样本到达。我们使用变量g_is_audio_initialized来确保这个设置代码只运行一次。
在这一点之后,我们可以假设捕获缓冲区中存储了一些音频。我们的任务是找出正确音频数据在缓冲区中的位置。为了做到这一点,我们首先确定我们想要的第一个样本在所有样本历史中的索引:
const int start_offset = start_ms * (kAudioSampleFrequency / 1000);
接下来,我们确定我们想要抓取的样本总数:
const int duration_sample_count =
duration_ms * (kAudioSampleFrequency / 1000);
现在我们有了这些信息,我们可以确定在我们的音频捕获缓冲区中从哪里读取。我们将在循环中读取数据:
for (int i = 0; i < duration_sample_count; ++i) {
// For each sample, transform its index in the history of all samples into
// its index in g_audio_capture_buffer
const int capture_index = (start_offset + i) % kAudioCaptureBufferSize;
// Write the sample to the output buffer
g_audio_output_buffer[i] = g_audio_capture_buffer[capture_index];
}
之前,我们看到如何使用取模运算符来找到在缓冲区内的正确位置,该缓冲区只有足够空间来容纳最近的样本。在这里,我们再次使用相同的技术——如果我们将当前索引在所有样本历史中除以音频捕获缓冲区的大小kAudioCaptureBufferSize,余数将指示数据在缓冲区中的位置。然后我们可以使用简单的赋值将数据从捕获缓冲区读取到输出缓冲区。
接下来,为了从这个函数中获取数据,我们使用作为参数提供的两个指针。它们分别是audio_samples_size,指向音频样本的数量,和audio_samples,指向输出缓冲区:
// Set pointers to provide access to the audio
*audio_samples_size = kMaxAudioSampleSize;
*audio_samples = g_audio_output_buffer;
return kTfLiteOk;
}
最后,我们通过返回kTfLiteOk来结束函数,让调用者知道操作成功了。
然后,在最后部分,我们定义LatestAudioTimestamp():
int32_t LatestAudioTimestamp() { return g_latest_audio_timestamp; }
由于这总是返回最近音频的时间戳,其他部分的代码可以在循环中检查它,以确定是否有新的音频数据到达。
这就是我们的音频提供程序的全部内容!我们现在确保我们的特征提供程序有稳定的新鲜音频样本供应。












浙公网安备 33010602011771号