Go-机器学习项目-全-

Go 机器学习项目(全)

原文:annas-archive.org/md5/4616f8384ccd1e939817bbe6eb92f7d8

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Go 是机器学习的完美语言。其简单的语法有助于清晰地描述复杂算法,但不会让开发者无法理解如何运行高效优化的代码。这本书将教你如何在 Go 中实现机器学习,以创建易于部署和易于理解和调试的程序,同时还可以测量其性能。

本书首先指导你使用 Go 库和功能设置机器学习环境。然后,你将深入分析一个真实的房屋定价数据集的回归分析,并在 Go 中构建一个分类模型来将电子邮件分类为垃圾邮件或正常邮件。使用 Gonum、Gorgonia 和 STL,你将探索时间序列分析,以及分解和如何通过聚类推文清理你的个人 Twitter 时间线。此外,你还将学习如何使用神经网络和卷积神经网络识别手写,这些都是深度学习技术。一旦你掌握了所有技术,你将借助面部检测项目学习如何选择最适合你项目的机器学习算法。

在这本书的结尾,你将培养出坚实的机器学习思维模式,对强大的 Go 库有深入的了解,并对机器学习算法在实际项目中的实际应用有清晰的理解。

本书面向对象

如果你是一名机器学习工程师、数据科学专业人士或 Go 程序员,希望在自己的实际项目中实现机器学习并更轻松地创建智能应用程序,这本书适合你。

为了充分利用这本书

在 Golang 中有一些编码经验以及基本机器学习概念的知识将有助于你理解本书中涵盖的概念。

下载示例代码文件

你可以从www.packt.com的账户下载这本书的示例代码文件。如果你在其他地方购买了这本书,你可以访问www.packt.com/support并注册,以便将文件直接通过电子邮件发送给你。

你可以通过以下步骤下载代码文件:

  1. www.packt.com上登录或注册。

  2. 选择“支持”选项卡。

  3. 点击“代码下载与勘误”。

  4. 在搜索框中输入书名,并遵循屏幕上的说明。

文件下载完成后,请确保使用最新版本的软件解压或提取文件夹:

  • WinRAR/7-Zip for Windows

  • Zipeg/iZip/UnRarX for Mac

  • 7-Zip/PeaZip for Linux

书籍的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Go-Machine-Learning-Projects。如果代码有更新,它将在现有的 GitHub 仓库中更新。

我们还有其他来自我们丰富的书籍和视频目录的代码包,可在 github.com/PacktPublishing/ 上找到。去看看吧!

使用的约定

本书使用了多种文本约定。

CodeInText: 表示文本中的代码词汇、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“我们草拟了一个什么也不做的虚拟Classifier类型。”

代码块应如下设置:

Word: she - true
Word: shan't - false
Word: be - false
Word: learning - true
Word: excessively. - true

任何命令行输入或输出都应如下所示:

go get -u github.com/go-nlp/tfidf

粗体: 表示新术语、重要词汇或屏幕上看到的词汇。例如,菜单或对话框中的文字会以这种方式显示。以下是一个示例:“从管理面板中选择系统信息。”

警告或重要注意事项看起来像这样。

小贴士和技巧看起来像这样。

联系我们

我们欢迎读者的反馈。

一般反馈:如果您对本书的任何方面有疑问,请在邮件主题中提及书名,并通过 customercare@packtpub.com 邮箱联系我们。

勘误: 尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,如果您能向我们报告,我们将不胜感激。请访问 www.packt.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。

盗版: 如果您在互联网上以任何形式发现我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过 copyright@packt.com 联系我们,并提供材料的链接。

如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问 authors.packtpub.com

评论

请留下评论。一旦您阅读并使用了这本书,为什么不在这家您购买书籍的网站上留下评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,Packt 可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!

有关 Packt 的更多信息,请访问 packt.com

第一章:如何解决所有机器学习问题

欢迎阅读《Go Machine Learning Projects》这本书。

这是一本相当奇特的书。它不是一本关于机器学习ML)如何工作的书。事实上,最初的决定是我们将假设读者熟悉我在这些章节中将要介绍的机器学习ML)算法。这样做会得到一本相当空洞的书,我担心。如果读者知道 ML 算法,接下来要做的就是简单地将 ML 算法应用于问题的正确上下文中!这本书的 10 章左右的内容可以在不到 30 页内完成——任何为政府机构撰写过拨款报告的人都会有这样的写作经验。

那么,这本书将要讲述些什么呢?

这本书将要讲述的是在特定的问题上下文中应用 ML 算法。这些问题是具体的,由我一时兴起指定的。但是,为了探索 ML 算法应用于问题的途径,读者必须首先熟悉算法和问题!因此,这本书必须在理解问题和理解解决问题的具体算法之间找到一个非常微妙的平衡。

但在我们走得太远之前,什么是问题?当我提到算法时,我指的是什么?还有这个机器学习是怎么回事?

什么是问题?

在日常用语中,问题是要克服的事情。当人们说他们有金钱问题时,问题可能仅仅通过拥有更多的钱就能解决。当某人有一个数学问题时,问题可能通过数学就能解决。用来克服问题的东西或过程被称为解决方案

到目前为止,我定义一个常见的词可能看起来有点奇怪。啊,但为了用机器学习解决问题,需要精确和清晰的思维。你必须精确地知道你到底在尝试解决什么。

问题可以被分解为子问题。但到了某个点,进一步分解这些问题就不再有意义了。我向读者提出,世界上有不同类型的问题。问题的类型如此之多,以至于不值得一一列举。尽管如此,问题的紧迫性应该被考虑。

如果你正在构建一个照片组织工具(也许你打算与谷歌照片或 Facebook 竞争),那么在照片中识别人脸的紧迫性不如知道照片存储在哪里以及如何检索照片。如果你不知道如何解决后者,那么解决前者所需的所有知识都将浪费。

我认为,尽管紧迫性具有主观性,但在考虑更大问题的子问题时,紧迫性是一个很好的衡量标准。使用一些更具体的例子,考虑三种都需要某种机器学习解决方案的场景,但所需的解决方案的紧迫性不同。这些例子明显是虚构的,与现实生活关系不大。它们全部的目的只是为了说明一个观点。

首先,考虑一个房地产智能业务。整个业务的生存依赖于能否正确预测即将出售的房屋价格,尽管也许他们还通过某种形式的二级市场赚钱。对他们来说,面临的机器学习问题是紧迫的。他们必须完全理解解决方案的来龙去脉,否则他们可能会面临倒闭的风险。在流行的紧迫性/重要性划分中,机器学习问题也可以被认为是重要紧迫的。

其次,考虑一个在线超市。他们想知道哪些产品组合的销售最好,以便他们可以捆绑销售以更具竞争力。这并不是核心业务活动,因此他们面临的机器学习问题比前一个例子不那么紧迫。了解解决方案的工作原理是必要的。想象一下,他们的算法说他们应该将腹泻药物与他们的自有品牌食品产品捆绑销售。他们需要能够理解解决方案是如何得出这个结论的。

最后,考虑前面提到的照片应用程序。面部识别是一个很好的额外功能,但不是主要功能。因此,在三个问题中,机器学习问题最不紧迫。

不同的紧迫性会导致在解决问题时产生不同的需求。

什么是算法?

前一节在术语算法的使用上相当勤奋。在这本书中,这个术语被广泛使用,但总是谨慎使用。但什么是算法?

要回答这个问题,首先,我们必须先问,什么是程序?程序是一系列由计算机执行的步骤。算法是一套解决问题的规则。因此,机器学习算法是一套解决问题的规则。它们作为程序在计算机上实现。

对于我来说,真正深入理解算法究竟是什么的一个启发性的时刻是大约 15 年前的一次经历。我在一个朋友家过夜。我的朋友有一个七岁的孩子,朋友在试图让孩子学习编程时感到非常沮丧,因为孩子太固执,不愿意学习语法规则。我推测,根本原因在于孩子没有理解算法的概念。所以第二天早上,我们让孩子自己准备早餐。但他不是自己准备早餐。他需要写下一系列步骤,他的母亲必须严格按照这些步骤执行。

早餐很简单——一碗牛奶玉米片。然而,孩子尝试了十一次才得到一碗谷物食品。结果是泪水、大量的牛奶和谷物食品洒在台面上,但对孩子来说,这是一次很好的学习。

这可能看起来像是任意的儿童虐待,但这也对我大有裨益。特别是,孩子对他的母亲和我(用概括的方式)说:“但你已经知道如何制作谷物食品;为什么你需要指令来做这件事?”他的母亲回答:“把这看作是教我如何制作电脑游戏。”这里有一个关于算法的元概念。那个指导如何制作谷物食品的孩子正在教孩子如何编程;本身就是一个算法!

机器学习算法可以指被学习的算法,或者指教机器使用正确算法的算法。在这本书的大部分内容中,我们将指后者,但将前者视为一种精神锻炼也是很有用的。自从图灵以来,我们可以用机器来代替算法。

在阅读以下部分之后,花些时间通读这些句子。这有助于在第二次阅读时澄清我的意思。

什么是机器学习?

那么,什么是机器学习(ML)?正如这个词可能暗示的,它是做某事的机器学习。机器不能像人类那样学习,但它们确实可以模仿人类学习的一些部分。但它们应该学习什么呢?不同的算法学习不同的事情,但共同的主题是机器学习一个程序。或者用不那么具体的话来说,机器学习如何正确的事情。

那么,什么是正确的事情?不想打开一个哲学的罐头,正确的事情是我们作为计算机的人类程序员定义的正确的事情。

机器学习系统有多种分类方案,但在最常见的分类方案中,是将机器学习分为两种类型:监督学习和无监督学习。在这本书的整个过程中,我们将看到这两种类型的例子,但在我看来,这种分类形式完全属于“知道好但操作上不重要”的大脑领域。我之所以这么说,是因为除了少数著名的算法之外,无监督学习仍然非常活跃地处于研究之中。监督学习算法也是如此,但它们在工业界的使用时间比无监督学习算法要长。这并不是说无监督学习没有价值——一些已经从学术象牙塔中走出来,并被证明非常有用。我们将在其中一章中探讨K-meansk-最近邻KNN)。

让我们假设现在我们有一个机器学习算法。这个算法是一个黑盒——我们不知道里面发生了什么。我们给它一些数据。然后通过其内部机制,它产生一个输出。输出可能不正确。所以它会检查输出是否正确。如果输出不正确,它会改变其内部机制,并一次又一次地尝试,直到输出正确。这就是机器学习算法通常是如何工作的。这被称为训练

当然,有关于“正确”含义的概念。在监督学习的情况下,我们人类向机器提供正确数据的示例。在无监督学习的情况下,正确性的概念依赖于其他指标,如值之间的距离。每个算法都有其特定的细节,但一般来说,机器学习算法正如描述的那样。

你需要机器学习吗?

可能最令人惊讶的问题是,你是否需要机器学习来解决你的问题。毕竟,这个章节的这一节是第四部分,有一个很好的理由——我们必须了解问题究竟是什么;在提出问题之前,我们必须了解算法是什么:你是否需要机器学习?

当然,首先要问的问题是:你有没有需要解决的问题?我假设答案是肯定的,因为我们生活在这个世界上,是这个世界的一部分。即使是苦行僧也有需要解决的问题。但也许问题应该更具体:你有没有可以用机器学习解决的问题?

我咨询过很多次,在我咨询的早期,我会毫不犹豫地接受大多数咨询请求。啊,年轻时无知所做的事情。问题往往在我答应之后出现。结果发现,许多这些咨询请求最好通过更深入地了解业务领域和更深入地了解计算机科学来解决。

经常带到我面前的一个常见问题需要信息检索解决方案,而不是机器学习解决方案。考虑以下几年前的请求:

Hi Xuanyi,

我是 XXXX。我们几个月前在 YYYY 聚会上见过面。我的公司目前正在构建一个提取实体之间关系的机器学习系统。想知道你是否愿意一起喝咖啡?

自然地,这激起了我的兴趣——关系抽取是机器学习中一个特别具有挑战性的任务。我那时年轻,对解决难题充满热情。所以我坐下来与公司一起工作,根据表面信息确定需要什么。我提出了几个模型,所有这些模型都受到了热情的欢迎。我们最终确定了一个基于 SVM 的模型。然后我开始着手工作。

任何机器学习项目的第一步是收集数据。所以我做了。令我惊讶的是,数据已经被整齐地分类,实体已经被识别。此外,实体之间有一种静态的、不变的关系。一种类型的实体将与另一种类型的实体保持永久的关系。那么机器学习问题是什么?

在收集了一个半月的数据之后,我提出了这个问题。发生了什么?我们有干净的数据,我们有干净的关系。所有新的数据都有干净的关系。机器学习的需求在哪里?

后来发现,数据来自手动数据输入,这在当时是法律要求的。实体关系被相当严格地定义。他们真正需要的数据要求只是一个清理过的数据库实体关系图。因为他们的数据库结构如此复杂,他们实际上看不到他们真正需要做的是定义一个外键关系来强制关系。当我要求数据时,数据是从单个 SQL 查询中获得的。根本不需要机器学习!

他们的数据库管理员(DBA)值得赞扬,他们的 DBA 一直在说。

这给了我一个教训:在花费时间工作之前,一定要弄清楚某人是否真的需要机器学习解决方案。

我已经确定了一种非常简单的方法来判断某人是否需要机器学习。这些是我的经验法则

  1. 这个问题能否以这样的形式表达:“给定 X,我想预测 Y”

  2. 一个“是什么”的问题通常是有问题的。一个“是什么”问题看起来像这样:“我想知道 XYZ 产品的转化率是多少”

一般的问题解决过程

只有当一般性的经验法则得到满足时,我才会进一步参与。对我来说,一般的问题解决过程如下:

  1. 明确识别问题。

  2. 将问题转化为更具体的状态。

  3. 收集数据

  4. 进行探索性数据分析

  5. 确定正确的机器学习解决方案

  6. 建立模型。

  7. 训练模型。

  8. 测试模型。

在本书的各章节中,将遵循上述模式。探索性数据分析部分将只在前几章中进行。这隐含着这些工作会在后面的章节中完成。

我试图在章节标题中清楚地说明我们试图解决什么问题,但写作是一项困难的任务,所以我可能遗漏了一些。

什么是模型?

所有模型都是错误的;但有些是有用的

现在如果任何存在于现实世界中的系统能够被任何简单的模型精确地表示,那将是非常了不起的。然而,巧妙选择的简约模型通常确实提供了非常有用的近似。例如,通过常数 R 关联压力图片、体积图片和温度图片的“理想”气体的定律对于任何真实气体来说并不完全正确,但它经常提供有用的近似,而且它的结构是有启发性的,因为它源于对气体分子行为的物理观点。

对于这样的模型,没有必要问“模型是否正确?”如果“真理”是“全部真理”,那么答案必须是“不”。唯一有趣的问题是“模型是否具有启发性和实用性?”

  • 乔治·博克斯(1978 年)

模型火车是一种相当常见的爱好,尽管像《生活大爆炸》这样的节目对其进行讽刺。模型火车不是真正的火车。首先,尺寸不同。模型火车的运作方式并不完全像真正的火车。模型火车有不同等级,每个等级都比前一个更接近实际火车。

在这个意义上,模型是现实的表示。我们用什么来表示它?总的来说,是数字。模型是一组描述现实的数字,还有更多。

每次我试图解释什么是模型时,我不可避免地会得到类似“你不能把我们简化为一堆数字!”这样的回应。那么我所说的“数字”是什么意思呢?

考虑以下直角三角形:

图片

我们如何描述所有直角三角形?我们可能会这样说:

图片

这意味着直角三角形中所有角度的总和为 180 度,并且存在一个 90 度的角度。这足以描述笛卡尔空间中的所有直角三角形。

但是,描述的是三角形本身吗?不是的。自从亚里士多德时代以来,这个问题一直困扰着哲学家。我们不会进入哲学讨论,因为这样的讨论只会延长本章的痛苦。

因此,在本章的用途中,我们将说,模型是描述现实的值,以及产生这些值的算法。这些值通常是数字,尽管它们也可能是其他类型。

什么是好的模型?

模型是一组描述世界的值,以及产生这些值的程序。这一点,我们从上一节中得出结论。现在我们必须对模型进行一些价值判断——模型是好是坏。

一个好的模型需要准确地描述世界。这是以最通用的方式表达的。这样描述,关于一个好的模型的说法涵盖了多个概念。我们必须使这个抽象的想法更加具体,以便继续前进。

机器学习算法是在大量数据上训练的。对机器来说,这堆数据就是世界。但对我们来说,我们为训练机器提供的数据并不是世界。对我们人类来说,世界比机器可能知道的要复杂得多。所以当我说“一个好的模型需要准确地描述世界”时,这里的“世界”有两个含义——机器所知道的世界,以及我们所知道的世界。

机器只看到了我们所知道的世界的一部分。有些世界的一部分机器没有看到。因此,当机器能够为它尚未看到的输入提供正确的输出时,它就是一个好的机器学习模型。

作为具体的例子,让我们再次假设我们有一个机器学习算法,该算法用于确定一张图片是否是热狗。我们向模型提供热狗和汉堡的图片。对机器来说,世界仅仅是热狗和汉堡的图片。当我们输入蔬菜的图片时会发生什么?一个好的模型能够泛化并说这不是热狗。一个差的模型会直接崩溃。

因此,通过这个类比,我们定义了一个好的模型,即能够很好地泛化到未知情况。

通常,作为构建机器学习系统过程的一部分,我们可能想要对这个概念进行测试。因此,我们必须将我们的数据集分成测试集和训练集。机器将在训练集上训练,为了测试模型在训练完成后有多好,我们将测试集输入到机器中。当然,假设机器从未见过测试集。因此,一个好的机器学习模型应该能够为测试集生成正确的输出,尽管它从未见过它。

关于写作和章节组织

关于本书的写作。正如你可能已经猜到的,我决定采用更口语化的语气。我发现这种语气对那些可能被机器算法吓到的读者来说更友好。如果你还没有注意到,我在写作中也很有自己的观点。我努力通过我的写作阐明什么是事实,什么是应该做的。应用休谟的划分标准由读者自行决定。但作为一个快速指南,当我们谈论算法及其工作原理时,它们是“是”的陈述。当我们谈论应该做什么,以及代码的组织时,它们是“应该”的陈述。

本书章节的设计中存在两种一般模式。首先,随着章节的进行,问题会变得越来越难。其次,有人可能希望将章节心理上分为三个不同的部分。第一部分——第二章,线性回归 - 房价预测,第三章,分类 - 垃圾邮件检测,第四章,使用时间序列分析分解 CO2 趋势,第七章,卷积神经网络 - MNIST 手写识别,第八章,基本人脸检测)对应于那些有紧急机器学习问题需求的读者。第二部分——第五章,通过聚类推文清理个人 Twitter 时间线,第九章,热狗还是不是热狗 - 使用外部服务,第六章,神经网络;MNIST 手写识别,第七章,卷积神经网络 - MNIST 手写识别,第八章,基本人脸检测)是为那些有类似第二个例子中机器学习问题的人准备的。第三部分,最后两章,是为那些机器学习问题不那么紧急但仍需要解决方案的人准备的。

到第八章,基本人脸检测,为止,对于每一章,通常会有一个或两个专门用于解释算法本身的章节。我坚信,如果不至少对所使用的算法有一个基本理解,就无法编写出任何有意义的程序。当然,你可以使用别人提供的算法,但如果没有恰当的理解,它就是无意义的。有时无意义的程序可能会产生结果,就像有时大象似乎知道如何做算术,或者宠物狗似乎能完成复杂的动作,比如说话。

这也意味着我可能听起来对某些想法相当轻视。例如,我对使用机器学习算法来预测股价持轻视态度。我不相信这样做将是一项富有成效的努力,因为我对生成股市价格的基本过程以及时间的影响有深刻的理解。

然而,时间会证明我是对还是错。也许有一天,有人会发明一个在动态系统上完美工作的机器学习算法,但现在还不是时候,就在这里。我们正处于计算新时代的黎明,我们必须尽力去理解事物。通常,你可以从历史中学到很多东西。因此,我也努力插入一些重要的历史轶事,说明某些事物是如何形成的。这些绝对不是全面的调查。事实上,这是没有多少规律性的。然而,我非常希望它能增加这本书的风味。

为什么选择 Go?

这本书是关于使用 Go 进行机器学习的书籍。Go 是一种相当有偏见的编程语言。有 Go 的方式,或者完全没有其他方式。这听起来可能有些法西斯主义,但它确实带来了一种非常愉快的编程体验。这也使得团队合作非常高效。

此外,与 Python 相比,Go 是一种相当高效的编程语言。我几乎完全转向使用 Go 来做我的机器学习和数据科学工作。

Go 还有一个好处,那就是它可以在跨平台上很好地工作。在工作时,开发者可能会选择在不同的操作系统上工作。Go 在所有这些操作系统上都能很好地工作。用 Go 编写的程序可以轻松地交叉编译到其他平台。这使得部署变得容易得多。无需使用 Docker 或 Kubernetes 进行不必要的混乱。

使用 Go 进行机器学习有缺点吗?仅作为库的作者。一般来说,使用 Go 机器学习库是无痛的。但为了使其无痛,你必须放弃任何之前编程的方式。

快速开始

首先安装 Go,可以在golang.org找到。它提供了关于 Go 的全面指南。现在,快速开始。

函数

函数是 Go 中计算任何事物的主要方式。

这是一个函数:

func addInt(a, b int) int { return a + b }

我们调用 func addInt(a, b int) int,这是 函数签名。函数签名由函数名称、参数和返回类型组成。

函数的名称是 addInt。注意所使用的格式。函数名称使用驼峰式命名法——这是 Go 中名称的首选命名方式。任何名称的首字母大写,如 AddInt,表示它应该被导出。总的来说,在这本书中,我们不会担心导出或未导出的名称,因为我们主要会使用函数。但如果你正在编写一个包,那么这很重要。导出的名称可以在包外部访问。

接下来,请注意 ab 是参数,并且它们都有 int 类型。我们稍后会讨论类型,但同一个函数也可以这样写:

func addInt(a int, b int) int { return a + b }

接着,这就是函数返回的内容。这个名为 addInt 的函数返回一个 int 类型。这意味着当函数被正确调用时,就像这样:

 z := addInt(1, 2) 

z 将有一个 int 类型。

在返回类型定义之后,{...} 表示函数体。当本书中提到 "{...}" 时,意味着函数体的内容对于当前讨论并不重要。书中可能包含一些函数体的片段,但它们没有 func foo(...) 的签名。再次强调,这些片段是讨论的片段。预期读者将根据书中的上下文拼凑出函数。

Go 函数可以返回多个结果。函数签名看起来可能像这样:

 func divUint(a, b uint) (uint, error) { ... }
 func divUint(a, b uint) (retVal uint, err error) { ... }

再次强调,区别主要在于返回值的命名。在第二个例子中,返回值分别命名为 retValerrretValuint 类型,而 errerror 类型。

变量

这是一个变量声明:

var a int

它说 a 是一个 int。这意味着 a 可以包含任何具有 int 类型的值。典型的 int 值可能是 012 等等。阅读上一句话可能看起来有些奇怪,但 “typically” 是正确使用的。所有 int 类型的值都是典型的 int

这是一个变量声明,随后是将值放入变量中:

s := "Hello World"

这里,我们说的是,将 s 定义为 string 类型,并让值为 "Hello World":= 语法只能在函数体内使用。这样做的主要原因不是让程序员不得不输入 var s string = "Hello World"

关于变量使用的一个注意事项:在 Go 中,变量应该被视为带有名称的桶,它们持有值。名称的重要性在于它们向读者传达了它们应该持有的值。然而,名称并不一定需要跨越障碍。我经常用 retVal 命名返回值,但在其他地方会使用不同的名称。一个具体的例子如下:

 func foo(...) (retVal int) { ... return retVal }
 func main() {
     something := foo()
     ...
 }

我已经教授编程和机器学习(ML)多年,我相信这是每个程序员都必须克服的一个障碍。学生或初级团队成员可能会对命名差异感到困惑。他们更愿意看到如下这样的东西:

 func foo(...) (something int) { ... return something }
 func main() {
     something := foo()
     ...
 }

这是可以的。然而,严格从经验来说,这往往会削弱抽象思维能力,而抽象思维能力是一种有用的技能,尤其是在机器学习(ML)中。我的建议是,习惯于使用不同的名称,这会让你更加抽象地思考。

特别是,名称不会跨越我的朋友詹姆斯·科佩尔(James Koppel)所说的抽象障碍。什么是抽象障碍?函数是一个抽象障碍。函数体内发生的事情都在函数体内发生,并且不能被语言中的其他东西访问。因此,如果在函数体内命名一个值为 fooBar,则 fooBar 的含义仅在函数内部有效。

后续我们将看到另一种形式的抽象障碍——包。

值是程序处理的内容。如果你编写了一个计算器程序,那么程序中的值就是数字。如果你编写了一个文本搜索程序,那么值就是字符串。

我们现在作为程序员所处理的程序,比计算器要复杂得多。我们处理不同类型的值,从数字类型(intfloat64等)到文本(字符串)。

变量持有值:

var a int = 1

前一行表明a是一个持有int类型值为1的变量。我们之前已经看到了使用"Hello World"字符串的例子。

类型

就像所有主要的编程语言(是的,包括 Python 和 JavaScript)一样,Go 中的值是有类型的。然而,与 Python 或 JavaScript 不同的是,Go 的函数和变量也是有类型的,而且非常严格。这意味着以下代码将导致程序无法编译:

var a int
a = "Hello World"

这种行为在学术界外被称为强类型。在学术界,强类型通常没有意义。

Go 也允许程序员定义他们自己的类型:

 type email string

这里,我们定义了一个新的类型email。其底层数据类型是string

你为什么要这样做呢?考虑这个函数:

 func emailSomeone(address, person string) { ... }

如果两者都是string,就很容易出错——我们可能会不小心做些这样的事情:

var address, person string
address = "John Smith"
person = "john@smith.com"
emailSomeone(address, person)

实际上,你甚至可以这样做:emailSomeone(person, address),而程序仍然可以正确编译!

然而,想象一下如果emailSomeone是这样定义的:

func emailSomeone(address email, person string) {...}

然后,以下代码将无法编译:

var address email
var person string
person = "John Smith"
address = "john@smith.com"
emailSomeone(person, address)

这是一件好事——它防止了不好的事情发生。关于这个问题就不再多说了。

Go 也允许程序员定义他们自己的复杂类型:

type Record struct {
     Name string
     Age int
 }

这里,我们定义了一个名为Record的新类型。它是一个包含两个值的structName类型为stringAge类型为int

什么是struct?简单来说,struct是一种数据结构。在Record中的NameAge被称为struct字段

如果你来自 Python,struct相当于元组,但如果熟悉NamedTuple,它就像一个NamedTuple。在 JavaScript 中,最接近的等效物是一个对象。同样,在 Java 中,最接近的等效物是一个普通的 Java 对象。在 C#中,最接近的等效物是一个普通的 CLR 对象。在 C++中,等效物是普通的数据。

注意我谨慎使用“最接近的等效物”和“等效物”这两个词。我之所以推迟介绍struct,是因为在读者可能来自的大多数现代语言中,它可能有一些类似 Java 的对象导向形式。struct不是一个类。它只是 CPU 中数据排列的定义。因此,与 Python 的元组相比,而不是 Python 的类,甚至 Python 的新数据类相比。

给定一个类型为Record的值,可能需要提取其内部数据。这可以这样完成:

 r := Record {
     Name: "John Smith",
     Age: 20,
 }
 r.Name

这里展示的代码片段展示了几个方面:

  • 如何编写一个结构化值——简单写出类型的名称,然后填写字段。

  • 如何读取结构体的字段——使用.Name语法。

在整本书中,我将使用 .FIELDNAME 作为获取特定数据结构字段名的符号。预期读者能够从上下文中理解我在谈论哪种数据结构。偶尔,我可能会使用全称,如 r.Name,以明确指出我在谈论哪些字段。

方法

假设我们编写了这些函数,并且我们已经像之前那样定义了 email

 type email string

 func check(a email) { ... }
 func send(a email, msg string) { ... }

注意到 email 总是函数参数中的第一个类型。

调用函数看起来像这样:

e := "john@smith.com"
check(e)
send(e, "Hello World")

我们可能想要将这个变成 email 类型的方法。我们可以这样做:

type email string

func (e email) check() { ... }
func (e email) send(msg string) { ... }

(e email) 被称为方法的接收者

这样定义了方法之后,我们就可以继续调用它们:

e := "john@smith.com"
e.check()
e.send("Hello World")

观察函数和方法之间的区别。check(e) 变为 e.check()send(e, "Hello World") 变为 e.send("Hello World")。除了语法上的区别之外,还有什么区别?答案是,没有多少。

Go 中的方法与 Go 中的函数完全相同,方法的接收者作为函数的第一个参数。这与面向对象编程语言中类的方法定义不同。

那么为什么还要使用方法呢?一方面,它非常巧妙地解决了表达式问题。要了解如何做到这一点,我们将查看将一切联系在一起的 Go 的特性:接口。

接口

接口是一组方法。我们可以通过列出它预期支持的方法来定义一个接口。例如,考虑以下接口:

var a interface {
     check()
 }

这里我们定义 a 为一个具有类型 interface{ check() } 的变量。这究竟是什么意思呢?

这意味着你可以将任何值放入 a 中,只要这个值具有名为 check() 的方法。

这有什么价值呢?当考虑执行类似操作的多种类型时,它很有价值。考虑以下情况:

 type complicatedEmail struct {...}

 func (e complicatedEmail) check() {...}
 func (e complicatedEmail) send(a string) {...}

 type simpleEmail string

 func (e simpleEmail) check() {...}
 func (e simpleEmail) send(a string) {...}

现在我们想编写一个 do 函数,它做两件事:

  • 检查电子邮件地址是否正确

  • 向电子邮件发送 "Hello World"

你将需要两个 do 函数:

func doC(a complicatedEmail) {
     a.check()
     a.send("Hello World")
 }

func doS(a simpleEmail) {
     a.check()
     a.send("Hello World")
 }

相反,如果函数体就这些,我们可能会选择这样做:

func do(a interface{
     check()
     send(a string)
     }) {
         a.check()
         a.send("Hello World")
     }

这相当难以阅读。所以让我们给接口起个名字:

type checkSender interface{
     check()
     send(a string)
 }

然后,我们可以简单地重新定义 do 为以下内容:

func do(a checkSender) {
     a.check()
     a.send("Hello World")
 }

关于在 Go 中命名接口的注意事项。通常,接口会使用 -er 后缀命名。如果一个类型实现了 check(),那么接口名称应该是 checker。这鼓励接口保持小巧。接口应该只定义少量方法——较大的接口是程序设计不佳的迹象。

包和导入

最后,我们来到包和导入的概念。对于本书的大部分内容,描述的项目都存在于一个名为 main 的包中。main 包是一个特殊的包。编译 main 包将生成一个可执行文件,你可以运行它。

话虽如此,将代码组织成多个包也是一个很好的主意。包是一种抽象屏障,我们之前在讨论变量和名称时已经讨论过。导出的名称可以从包外部访问。结构体的导出字段也可以从包外部访问。

要导入一个包,你需要在文件顶部调用一个导入语句:

package main
import "PACKAGE LOCATION"

在整本书中,我会明确指出需要导入的内容,特别是那些在 Go 标准库中找不到的外部库。我们将使用其中的一些库,因此我会明确指出。

Go 强制执行代码卫生。如果你导入了一个包但没有使用它,你的程序将无法编译。再次强调,这是一件好事,因为它可以减少你在以后某个时间点混淆自己的可能性。我个人使用一个名为 goimports 的工具来管理我的导入。在保存我的文件后,goimports 会为我添加导入语句,并从我的导入语句中删除任何未使用的包。

要安装 goimports,请在您的终端中运行以下命令:

go get golang.org/x/tools/cmd/goimports

让我们开始吧!

在本章中,我们讨论了什么是问题以及如何将问题建模为机器学习问题。然后我们学习了使用 Go 的基础知识。在下一章中,我们将深入探讨我们的第一个问题:线性回归。

我强烈建议你在学习 Go 之前先练习一下。但如果你已经知道如何使用 Go,那么,让我们开始吧!

第二章:线性回归 - 房价预测

线性回归是世界上最早的机器学习概念之一。在 19 世纪初发明,它仍然是理解输入和输出之间关系的一种较为脆弱的方法。

线性回归背后的思想对我们来说都很熟悉。我们觉得有些事物彼此相关。有时它们在本质上具有因果关系。相关性和因果关系之间存在着一条非常细微的界限。例如,夏天冰淇淋和冷饮的销售量增加,而冬天热巧克力饮料和咖啡的销售量增加。我们可以这样说,季节本身导致了销售量的变化——它们在本质上具有因果关系。但它们真的是这样吗?

没有进一步的分析,我们所能说的最好的事情就是它们彼此相关。夏天的现象与一年中冷饮和冰淇淋销售量超过其他时间的现象相联系。冬天的现象,以某种方式,与一年中热饮料销售量超过其他时间的现象相联系。

理解事物之间的关系是线性回归的核心所在。线性回归可以从许多不同的角度来观察,但我们将从机器学习的角度来观察。也就是说,我们希望构建一个机器学习模型,能够根据一些输入准确预测结果。

使用相关性进行预测的愿望确实是线性回归最初被发明的原因。弗朗西斯·高尔顿(Francis Galton)偶然是查尔斯·达尔文(Charles Darwin)的表亲,来自一个上层社会家庭,家族中有医生。他在经历了一次神经崩溃后放弃了医学研究,开始作为地质学家周游世界——那是在地质学家是最酷的工作的时候(就像今天的数据科学家一样)——然而,据说高尔顿没有达尔文的勇气,不久他就放弃了周游世界的想法,对非洲的经历感到失望。在父亲去世后,高尔顿继承了财富,开始涉足所有能引起他兴趣的事物,包括生物学。

他表亲的巨著《物种起源》(On the Origin of Species)的出版使高尔顿加倍致力于生物学研究,最终转向优生学。高尔顿像孟德尔一样,偶然地在豌豆上进行实验。他想要预测后代植物的特征,而当时只有关于亲本植物特征的信息。他意识到后代植物的特征通常位于亲本植物特征之间。当高尔顿意识到他可以通过椭圆曲线拟合推导出一个表示遗传的数学方程时,他发明了回归。

回归背后的推理很简单:有一个驱动力——一种信号,它导致后代植物的特征趋向于他拟合的曲线。如果是这样,这意味着驱动力遵循某种数学定律。如果它确实遵循数学定律,那么它可以用于预测,高尔顿推理。为了进一步细化他的想法,他寻求数学家卡尔·皮尔逊的帮助。

高尔顿和皮尔逊需要尝试几次来细化概念并量化趋势。但最终,他们采用了最小二乘法来拟合曲线。

即使到现在,当提到线性回归时,可以安全地假设将使用最小二乘模型,这正是我们将要做的。

我们将执行探索性数据分析——这将使我们更好地理解数据。在这个过程中,我们将构建和使用机器学习项目所需的数据结构。我们将大量依赖 Gonum 的绘图库。之后,我们将运行线性回归,解释结果,并确定这种机器学习技术的优缺点。

项目

我们想要做的是创建一个房价模型。我们将使用这个开源房价数据集(www.kaggle.com/c/house-prices-advanced-regression-techniques/data)来构建我们的线性回归模型。具体来说,数据集是马萨诸塞州阿默斯地区已售房屋的价格及其相关特征。

与任何机器学习项目一样,我们首先提出最基本的问题:我们想要预测什么?在这种情况下,我已经指出我们将预测房价,因此所有其他数据都将用作预测房价的信号。在统计学中,我们称房价为因变量,其他字段为自变量。

在接下来的章节中,我们将构建一个依赖逻辑条件的图表,然后以此作为计划,编写一个寻找线性回归模型的程序。

探索性数据分析

探索性数据分析是任何建模过程的组成部分。理解正在运行的算法也同样重要。鉴于本章围绕线性回归展开,探索数据以理解线性回归的角度可能是有益的。

但首先,让我们看看数据。我建议任何热衷于机器学习的初学者做的第一件事就是探索数据,或者数据的一个子集,以了解其感觉。我通常在电子表格应用程序,如 Excel 或 Google Sheets 中这样做。然后我尝试以人类的方式理解数据的含义。

该数据集附带字段描述,我无法在此全部列举。然而,本章后续讨论的一个快照将是有启发性的:

  • SalePrice:物业的售价(美元)。这是我们试图预测的依赖变量。

  • MSSubClass:建筑类别。

  • MSZoning:一般的分区分类。

  • LotFrontage:与物业相连的街道的线性英尺数。

  • LotArea:地块面积(平方英尺)。

理解线性回归的方式可能有多种。然而,我最喜欢的一种理解线性回归的方式与探索性数据分析直接相关。具体来说,我们感兴趣的是通过独立变量的条件期望函数CEFs)来观察线性回归。

变量的条件期望函数简单地说就是变量的期望值,取决于另一个变量的值。这似乎是一个相当复杂的话题,所以我将提供三种不同观点的同一主题,以试图澄清:

  • 统计观点:给定协变量向量的依赖变量的条件期望函数简单地是当固定为时的期望值(平均值)。

  • 伪 SQL 编程观点select avg(Y) from dataset where X = 'Xi'。当基于多个条件进行条件化时,它只是这样:select avg(Y) from dataset where X1 = 'Xik' and X2 = 'Xjl'

  • 具体例子:如果其中一个独立变量——比如说,MSZoning 是 RL,那么预期的房价是多少?预期的房价是人口平均值,这可以转化为:在波士顿的所有房屋中, zoning 类型为 RL 的房屋的平均售价是多少?

如此看来,这是对 CEF 的相当简化的版本——在 CEF 的定义中涉及一些细微之处,但这超出了本书的范围,所以我们将其留到以后。现在,对 CEF 的这种粗略理解足以让我们开始探索性数据分析。

伪 SQL 的编程观点是有用的,因为它告诉我们我们需要什么,以便我们可以快速计算数据的汇总。我们需要创建索引。由于我们的数据集很小,我们可以相对随意地选择用于索引数据的数据结构。

数据摄取和索引

可能最好的索引数据的方式是在摄取数据时进行。我们将使用Go 标准库中找到的encoding/csv包来摄取数据并建立索引。

在我们深入代码之前,让我们看看索引的概念以及它是如何构建的。虽然索引在数据库中非常常用,但它们也适用于任何生产系统。索引的目的是让我们能够快速访问数据。

我们想要构建一个索引,使我们能够随时知道哪些行具有某个值。在具有大量数据集的系统上,可能需要使用更复杂的索引结构(如 B-Tree)。然而,对于这个数据集,基于映射的索引就足够了。

这就是我们的索引看起来像什么:[]map[string][]int——它是一个映射切片。第一个切片按列索引——这意味着如果我们想获取列 0,我们只需获取 index[0],然后返回 map[string][]int。映射告诉我们列中有什么值(映射的键),以及哪些行包含这些值(映射的值)。

现在,问题转向:你如何知道哪些变量与哪些列相关联?一个更传统的答案可能是使用类似 map[string]int 的结构,其中键代表变量名,值代表列号。虽然这是一个有效的策略,但我更喜欢使用 []string 作为索引和列名之间的关联映射。搜索的时间复杂度是 O(N),但大多数情况下,如果你有命名的变量,N 是很小的。在未来的章节中,我们将看到更大的 N 值。

因此,我们返回列名的索引作为 []string,或者在读取 CSV 的情况下,它简单地是第一行,如下面的代码片段所示:

// ingest is a function that ingests the file and outputs the header, data, and index.
func ingest(f io.Reader) (header []string, data [][]string, indices []map[string][]int, err error) {
  r := csv.NewReader(f)

  // handle header
  if header, err = r.Read(); err != nil {
    return
  }

  indices = make([]map[string][]int, len(header))
  var rowCount, colCount int = 0, len(header)
  for rec, err := r.Read(); err == nil; rec, err = r.Read() {
    if len(rec) != colCount {
      return nil, nil, nil, errors.Errorf("Expected Columns: %d. Got %d columns in row %d", colCount, len(rec), rowCount)
    }
    data = append(data, rec)
    for j, val := range rec {
      if indices[j] == nil {
        indices[j] = make(map[string][]int)
      }
      indices[j][val] = append(indices[j][val], rowCount)
    }
    rowCount++
  }
  return
}

阅读这段代码片段,一个优秀的程序员会在脑海中响起警钟。为什么一切都是字符串类型?答案很简单:我们稍后会转换类型。我们现在需要的只是进行一些基于计数的基本统计数据,以进行数据探索性分析。

关键在于函数返回的索引中。我们有一个列的唯一值计数。这是如何计数的:

// cardinality counts the number of unique values in a column. 
// This assumes that the index i of indices represents a column.
func cardinality(indices []map[string][]int) []int {
  retVal := make([]int, len(indices))
  for i, m := range indices {
    retVal[i] = len(m)
  }
  return retVal
}

通过这个,我们可以分析每个单独列的基数——即有多少个不同的值。如果一个列中的不同值与行数相同,那么我们可以相当确信该列不是分类数据。或者,如果我们知道该列是分类数据,并且不同值与行数相同,那么我们可以确定该列不能用于线性回归。

我们的主函数现在看起来是这样的:

func main() {
  f, err := os.Open("train.csv")
  mHandleErr(err)
  hdr, data, indices, err := ingest(f)
  mHandleErr(err)
  c := cardinality(indices)

  fmt.Printf("Original Data: \nRows: %d, Cols: %d\n========\n", len(data), len(hdr))
  c := cardinality(indices)
  for i, h := range hdr {
    fmt.Printf("%v: %v\n", h, c[i])
  }
  fmt.Println("")

}

为了完整性,这是 mHandleError 的定义:

// mHandleErr is the error handler for the main function. 
// If an error happens within the main function, it is not 
// unexpected for a fatal error to be logged and for the program to immediately quit.
func mHandleErr(err error){
  if err != nil {
    log.Fatal(err)
  }
}

快速运行 go run *.go 可以得到这个结果(已被截断):

$ go run *.go
Rows: 1460
========
Id: 1460
MSSubClass: 15
MSZoning: 5
LotFrontage: 111
LotArea: 1073
SaleCondition: 6
SalePrice: 663

单独来看,这告诉我们很多有趣的事实,其中最显著的是,分类数据比连续数据要多得多。此外,对于一些本质上连续的列,可用的离散值只有少数。一个特定的例子是 LowQualSF 列——它是一个连续变量,但只有 24 个唯一的值。

我们想计算离散协变量的 CEF 以进行进一步分析。但在那之前,我们需要清理数据。在这个过程中,我们可能还想创建数据结构的逻辑分组。

清洁工作

数据科学工作的很大一部分集中在清理上。在生产化系统中,这些数据通常会直接从数据库中获取,已经相对干净(高质量的生产数据科学工作需要一个干净的数据库)。然而,我们目前还没有进入生产模式。我们仍然处于模型构建阶段。想象编写一个专门用于清理数据的程序会有所帮助。

让我们看看我们的需求:从我们的数据开始,每一列是一个变量——大多数是独立变量,除了最后一列,它是因变量。一些变量是分类的,一些是连续的。我们的任务是编写一个函数,将数据从当前的[][]string转换为[][]float64

要做到这一点,我们需要将所有数据转换为float64。对于连续变量,这是一个简单的任务:只需将字符串解析为浮点数。有一些异常需要处理,希望你在打开文件到电子表格中时已经注意到了。但主要的问题在于将分类数据转换为float64

幸运的是,比我们聪明得多的人早在几十年前就解决了这个问题。存在一种编码方案,允许分类数据与线性回归算法良好地配合。

编码分类数据

编码分类数据的技巧是将分类数据扩展为多个列,每列有一个 1 或 0,表示它是真还是假。这当然伴随着一些需要注意的警告和微妙的问题。在接下来的这个子节中,我将使用一个真实的分类变量来进一步解释。

考虑LandSlope变量。LandSlope有三个可能的值:

  • Gtl

  • Mod

  • Sev

这是一种可能的编码方案(这通常被称为独热编码):

Slope Slope_Gtl Slope_Mod Slope_Sev
Gtl 1 0 0
Mod 0 1 0
Sev 0 0 1

这将是一个糟糕的编码方案。要理解为什么,我们首先必须通过普通最小二乘法来理解线性回归。不过不深入细节,基于 OLS 的线性回归的核心是以下公式(我非常喜欢这个公式,以至于我有多件印有这个公式的 T 恤):

这里,是一个(m x n)矩阵,而是一个(m x 1)向量。因此,这些乘法不是简单的乘法——它们是矩阵乘法。当使用独热编码进行线性回归时,得到的输入矩阵通常会是奇异的——换句话说,矩阵的行列式为 0。奇异矩阵的问题在于它们不能被求逆。

因此,我们采用以下编码方案:

斜率 斜率 _ 模 斜率 _ 严重程度
Gtl 0 0
模式 1 0
Sev 0 1

在这里,我们看到 Go 谚语的应用,将零值变得有用,以便在数据科学环境中应用。确实,对分类变量进行巧妙的编码在处理先前未见过的数据时会产生略微更好的结果。

这个话题太广泛,无法在这里展开,但如果你的分类数据可以部分排序,那么当遇到未见过的数据时,只需将未见过的数据编码到最接近的有序变量值,结果将略好于编码到零值或使用随机编码。我们将在本章的后续部分更多地介绍这一点。

处理不良数据

另一部分的清洁工作就是处理不良数据。一个很好的例子是在LotFrontage变量中。从数据描述中,我们知道这应该是一个连续变量。因此,所有数字都应该可以直接转换为float64。然而,当我们查看数据时,我们发现并非如此——存在 NA 数据。

根据描述,LotFrontage是连接到财产的街道的线性英尺。NA 可能意味着两种情况之一:

  • 我们没有关于是否有街道连接到该财产的信息

  • 没有街道连接到该财产

在任何情况下,用 0 替换 NA 都是合理的。这是合理的,因为LotFrontage的第二低值是 21。当然,还有其他方法可以插补数据,而且通常插补会导致更好的模型。但就目前而言,我们将用 0 进行插补。

我们也可以用这个方法处理这个数据集中任何其他连续变量,因为当你用 0 替换 NA 时,它们是有意义的。一个技巧是在句子中使用它:这所房子有一个未知的GarageArea。如果是这种情况,那么最好的猜测是什么?嗯,假设这所房子没有车库,所以用 0 替换 NA 是合理的。

注意,在其他机器学习项目中可能并非如此。记住——人类的洞察力可能是有缺陷的,但通常它是解决数据中许多不规则性的最佳方案。如果你是一名房地产经纪人,并且拥有更多的领域知识,你可以在插补阶段注入这种领域知识——例如,你可以使用变量来计算和估计其他变量。

对于分类变量,我们大部分可以将 NA 视为变量的零值,所以如果有 NA,那里不会有变化。对于某些分类数据,NA 或 None 可能没有意义。这就是上述巧妙编码分类数据可能派上用场的地方。对于这些变量的情况,我们将使用最常见的值作为零值:

  • MSZoning

  • BsmtFullBath

  • BsmtHalfBath

  • Utilities

  • Functional

  • Electrical

  • KitchenQual

  • SaleType

  • Exterior1st

  • Exterior2nd

此外,还有一些变量是分类的,但数据是数值的。数据集中发现的一个例子是MSSubclass变量。它本质上是一个分类变量,但其数据是数值的。在编码这类分类数据时,按数值排序是有意义的,这样 0 值确实是最低值。

最终要求

尽管我们现在正在构建模型,但我们希望从未来的角度来构建。未来是一个生产就绪的机器学习系统,它执行线性回归。因此,我们编写的任何函数和方法都必须考虑到在生产环境中可能发生而在模型构建阶段可能不会发生的事情。

以下是需要考虑的事项:

  • 未见值:我们必须编写一个函数,能够对之前未见过的值进行编码。

  • 未见变量:在未来的某个时刻,我们可能会传递一个包含在模型构建时未知变量的数据的不同版本。我们必须处理这种情况。

  • 不同的插补策略:不同的变量将需要不同的策略来猜测缺失数据。

编写代码

到目前为止,我们只是在头脑中进行了清理。我个人认为这是一种更有回报的练习:在实际清理之前,先在心理上清理数据。这并不是因为我非常自信我会处理所有数据的不规则性。相反,我喜欢这个过程,因为它阐明了需要做什么。而这反过来又指导了完成这项工作所需的数据结构。

但是,一旦思考完成,就是时候用代码来验证我们的思考了。

我们从清洁函数开始:

// hints is a slice of bools indicating whether it's a categorical variable
func clean(hdr []string, data [][]string, indices []map[string][]int, hints []bool, ignored []string) (int, int, []float64, []float64, []string, []bool) {
  modes := mode(indices)
  var Xs, Ys []float64
  var newHints []bool
  var newHdr []string
  var cols int

  for i, row := range data {

    for j, col := range row {
      if hdr[j] == "Id" { // skip id
        continue
      }
      if hdr[j] == "SalePrice" { // we'll put SalePrice into Ys
        cxx, _ := convert(col, false, nil, hdr[j])
        Ys = append(Ys, cxx...)
        continue
      }

      if inList(hdr[j], ignored) {
        continue
      }

      if hints[j] {
        col = imputeCategorical(col, j, hdr, modes)
      }
      cxx, newHdrs := convert(col, hints[j], indices[j], hdr[j])
      Xs = append(Xs, cxx...)

      if i == 0 {
        h := make([]bool, len(cxx))
        for k := range h {
          h[k] = hints[j]
        }
        newHints = append(newHints, h...)
        newHdr = append(newHdr, newHdrs...)
      }
    }
    // add bias

    if i == 0 {
      cols = len(Xs)
    }
  }
  rows := len(data)
  if len(Ys) == 0 { // it's possible that there are no Ys (i.e. the test.csv file)
    Ys = make([]float64, len(data))
  }
  return rows, cols, Xs, Ys, newHdr, newHints
}

clean函数接收数据(以[][]string的形式),借助之前构建的索引,我们想要构建一个Xs(这将是一个float64矩阵)和Ys的矩阵。在 Go 语言中,这是一个简单的循环。我们将读取输入数据并尝试转换它。同时还会传递一个hints切片,以帮助我们确定一个变量是否应该被视为分类变量或连续变量。

特别是,对任何年份变量的处理存在争议。一些统计学家认为将年份变量视为离散的、非分类变量是可以的,而另一些统计学家则持不同意见。我个人认为这并不重要。如果将年份变量作为分类变量可以提高模型得分,那么无论如何都可以使用它。不过,这种情况不太可能发生。

上述代码的核心是将字符串转换为[]float64,这正是convert函数所做的事情。我们稍后会查看这个函数,但重要的是要注意,在转换之前必须先填充数据。这是因为 Go 的切片类型严格。[]float64只能包含float64

虽然我们也可以用 NaN 替换任何未知数据,但这并不有帮助,尤其是在分类数据的情况下,NA 可能实际上具有语义意义。因此,我们在转换之前填充分类数据。这就是imputeCategorical的样子:

// imputeCategorical replaces "NA" with the mode of categorical values
func imputeCategorical(a string, col int, hdr []string, modes []string) string {
  if a != "NA" || a != "" {
    return a
  }
  switch hdr[col] {
  case "MSZoning", "BsmtFullBath", "BsmtHalfBath", "Utilities", "Functional", "Electrical", "KitchenQual", "SaleType", "Exterior1st", "Exterior2nd":
    return modes[col]
  }
  return a
}

这个函数的意思是,如果值不是NA且值不是空字符串,那么它是一个有效值,因此我们可以提前返回。否则,我们得考虑是否将NA作为有效类别返回。

对于某些特定的类别,NA 不是有效的类别,它们被替换为最常出现的值。这是一件合乎逻辑的事情去做——一个位于荒野中、没有电力、没有天然气和没有浴室的棚屋是非常罕见的。有一些处理这种问题的技术(例如 LASSO 回归),但我们现在不会这么做。相反,我们将它们替换为众数。

模式是在clean函数中计算的。这是一个非常简单的定义,用于寻找众数;我们只是找到具有最大长度的值并返回该值:

// mode finds the most common value for each variable
func mode(index []map[string][]int) []string {
  retVal := make([]string, len(index))
  for i, m := range index {
    var max int
    for k, v := range m {
      if len(v) > max {
        max = len(v)
        retVal[i] = k
      }
    }
  }
  return retVal
}

在填充了分类数据之后,我们将所有数据转换为[]float。对于数值数据,这将导致包含单个值的切片。但对于分类数据,它将导致包含 0 和 1 的切片。

为了本章的目的,任何在数值数据中发现的 NA 将被转换为 0.0。还有其他一些有效的策略可以略微提高模型的性能,但这些策略并不简短。

因此,转换代码看起来很简单:

// convert converts a string into a slice of floats
func convert(a string, isCat bool, index map[string][]int, varName string) ([]float64, []string) {
  if isCat {
    return convertCategorical(a, index, varName)
  }
  // here we deliberately ignore errors, because the zero value of float64 is well, zero.
  f, _ := strconv.ParseFloat(a, 64)
  return []float64{f}, []string{varName}
}

// convertCategorical is a basic function that encodes a categorical variable as a slice of floats.
// There are no smarts involved at the moment.
// The encoder takes the first value of the map as the default value, encoding it as a []float{0,0,0,...}
func convertCategorical(a string, index map[string][]int, varName string) ([]float64, []string) {
  retVal := make([]float64, len(index)-1)

  // important: Go actually randomizes access to maps, so we actually need to sort the keys
  // optimization point: this function can be made stateful.
  tmp := make([]string, 0, len(index))
  for k := range index {
    tmp = append(tmp, k)
  }

  // numerical "categories" should be sorted numerically
  tmp = tryNumCat(a, index, tmp)

  // find NAs and swap with 0
  var naIndex int
  for i, v := range tmp {
    if v == "NA" {
      naIndex = i
      break
    }
  }
  tmp[0], tmp[naIndex] = tmp[naIndex], tmp[0]

  // build the encoding
  for i, v := range tmp[1:] {
    if v == a {
      retVal[i] = 1
      break
    }
  }
  for i, v := range tmp {
    tmp[i] = fmt.Sprintf("%v_%v", varName, v)
  }

  return retVal, tmp[1:]
}

我想引起您对convertCategorical函数的注意。代码中有些冗余,但冗余会消除魔法。因为 Go 随机访问映射,所以获取键的列表并对其进行排序很重要。这样,所有后续访问都将具有确定性。

该函数还留有优化的空间——将这个函数做成一个有状态的函数可以进一步优化它,但在这个项目中我们不会去麻烦。

这是我们到目前为止的主要函数:

func main() {
 f, err := os.Open("train.csv")
 mHandleErr(err)
 hdr, data, indices, err := ingest(f)
 mHandleErr(err)
 fmt.Printf("Original Data: nRows: %d, Cols: %dn========n", len(data), len(hdr))
 c := cardinality(indices)
 for i, h := range hdr {
  fmt.Printf("%v: %vn", h, c[i])
 }
 fmt.Println("")
 fmt.Printf("Building into matricesn=============n")
 rows, cols, XsBack, YsBack, newHdr, _ := clean(hdr, data, indices, datahints, nil)
 Xs := tensor.New(tensor.WithShape(rows, cols), tensor.WithBacking(XsBack))
 Ys := tensor.New(tensor.WithShape(rows, 1), tensor.WithBacking(YsBack
 fmt.Printf("Xs:\n%+1.1snYs:\n%1.1sn", Xs, Ys)
 fmt.Println("")
}

代码的输出如下:

Original Data:
Rows: 1460, Cols: 81
========
Id: 1460
MSSubClass: 15
MSZoning: 5
LotFrontage: 111
LotArea: 1073
Street: 2
 ⋮
Building into matrices
=============
Xs:
⎡ 0 0 ⋯ 1 0⎤
⎢ 0 0 ⋯ 1 0⎥
 ⋮
⎢ 0 0 ⋯ 1 0⎥
⎣ 0 0 ⋯ 1 0⎦
Ys:
C[2e+05 2e+05 ⋯ 1e+05 1e+05]

注意,虽然原始数据有 81 个变量,但在编码完成后,变量数量增加到 615 个。这是我们想要传递给回归分析的。在这个时候,经验丰富的数据科学家可能会注意到一些可能让她感到不舒服的事情。例如,变量的数量(615)与观测值的数量(1,460)过于接近,所以我们可能会遇到一些问题。我们将在稍后解决这些问题。

另一个需要注意的点是我们正在将数据转换为*tensor.Dense。你可以将*tensor.Dense数据结构视为一个矩阵。它是一个高效的数据结构,具有许多我们将在以后使用的优点。

进一步的探索工作

在这个阶段,我们可能会非常想直接使用这些矩阵进行回归分析。虽然这可能可行,但并不一定能产生最佳结果。

条件期望函数

相反,让我们做我们最初打算做的事情:探索变量的CEF。幸运的是,我们已经有必要的数据结构(换句话说,索引),因此编写查找CEF的函数相对容易。

下面的代码块:

func CEF(Ys []float64, col int, index []map[string][]int) map[string]float64 {
  retVal := make(map[string]float64)
  for k, v := range index[col] {
    var mean float64
    for _, i := range v {
      mean += Ys[i]
    }
    mean /= float64(len(v))
    retVal[k]=mean
  }
  return retVal
}

这个函数在保持一个变量固定的情况下找到条件期望的房价。我们可以探索所有变量,但为了本章的目的,我将只分享对 yearBuilt 变量的探索作为例子。

现在,YearBuilt 是一个值得深入研究的变量。它是一个分类变量(1950.5 没有意义),但它也可以完全排序(1,945 小于 1,950)。YearBuilt 有很多值。因此,我们不应该打印出来,而应该用以下函数将其绘制出来:

// plotCEF plots the CEF. This is a simple plot with only the CEF. 
// More advanced plots can be also drawn to expose more nuance in understanding the data.
func plotCEF(m map[string]float64) (*plot.Plot, error) {
  ordered := make([]string, 0, len(m))
  for k := range m {
    ordered = append(ordered, k)
  }
  sort.Strings(ordered)

  p, err := plot.New()
  if err != nil {
    return nil, err
  }

  points := make(plotter.XYs, len(ordered))
  for i, val := range ordered {
    // if val can be converted into a float, we'll use it
    // otherwise, we'll stick with using the index
    points[i].X = float64(i)
    if x, err := strconv.ParseFloat(val, 64); err == nil {
      points[i].X = x
    }

    points[i].Y = m[val]
  }
  if err := plotutil.AddLinePoints(p, "CEF", points); err != nil {
    return nil, err
  }
  return p, nil
}

我们不断增长的主函数现在附加了以下内容:

ofInterest := 19 // variable of interest is in column 19
cef := CEF(YsBack, ofInterest, indices)
plt, err := plotCEF(cef)
mHandleErr(err)
plt.Title.Text = fmt.Sprintf("CEF for %v", hdr[ofInterest])
plt.X.Label.Text = hdr[ofInterest]
plt.Y.Label.Text = "Conditionally Expected House Price"
mHandleErr(plt.Save(25*vg.Centimeter, 25*vg.Centimeter, "CEF.png"))

运行程序会产生以下图表:

图片

Yearbuilt 的条件期望函数

在检查图表后,我必须承认我有点惊讶。我对房地产并不特别熟悉,但我的直觉是老房子会更贵——在我的心目中,房子就像美酒一样,越老越贵。显然,情况并非如此。哦,好吧,活到老,学到老。

应尽可能多地探索变量。我在本书中只是为了简洁而省略了。

偏斜

现在我们来看看房价数据的分布情况:

func hist(a []float64) (*plot.Plot, error){
  h, err := plotter.NewHist(plotter.Values(a), 10)
  if err != nil {
    return nil, err
  }
  p, err := plot.New()
  if err != nil {
    return nil, err
  }

  h.Normalize(1)
  p.Add(h)
  return p, nil
}

这个部分被添加到主函数中:

hist, err := plotHist(YsBack)
mHandleErr(err)
hist.Title.Text = "Histogram of House Prices"
mHandleErr(hist.Save(25*vg.Centimeter, 25*vg.Centimeter, "hist.png"))

下面的图示是:

图片

房价直方图

如所示,价格直方图略有偏斜。幸运的是,我们可以通过应用一个执行值对数运算并加 1 的函数来解决这个问题。标准库提供了一个这样的函数:math.Log1p。因此,我们在主函数中添加以下内容:

for i := range YsBack {
 YsBack[i] = math.Log1p(YsBack[i])
 }
 hist2, err := plotHist(YsBack)
 mHandleErr(err)
 hist2.Title.Text = "Histogram of House Prices (Processed)"
 mHandleErr(hist2.Save(25*vg.Centimeter, 25*vg.Centimeter, "hist2.png"))

下面的图示是:

图片

房价直方图(处理过)

哎!看起来好多了。我们为所有的Ys都做了这个。那么Xs中的任何变量呢?为了做到这一点,我们必须遍历Xs的每一列,找出它们是否偏斜,如果是的话,我们需要应用转换函数。

这是我们在主函数中添加的内容:

  it, err := native.MatrixF64(Xs)
  mHandleErr(err)
  for i, isCat := range datahints {
    if isCat {
      continue
    }
    skewness := skew(it, i)
    if skewness > 0.75 {
      log1pCol(it, i)
    }
  }

native.MatrixF64s将一个*tensor.Dense转换成一个本地的 Go 迭代器。底层支持数据不会改变,因此如果有人要写入it[0][0] = 1000,实际的矩阵本身也会改变。这允许我们在不进行额外分配的情况下执行转换。对于这个话题,这可能不是那么重要;然而,对于更大的项目,这将会变得非常有用。

这也允许我们编写检查和修改矩阵的函数:

// skew returns the skewness of a column/variable
func skew(it [][]float64, col int) float64 {
  a := make([]float64, 0, len(it[0]))
  for _, row := range it {
    for _, col := range row {
      a = append(a, col)
    }
  }
  return stat.Skew(a, nil)
}

// log1pCol applies the log1p transformation on a column
func log1pCol(it [][]float64, col int) {
  for i := range it {
    it[i][col] = math.Log1p(it[i][col])
  }
}

多重共线性

如本节开头几段所述,变量的数量有点多,不太舒服。当变量数量很多时,多重共线性增加的可能性也会增加。多重共线性是指两个或更多变量以某种方式相互关联。

从对数据的初步观察中,我们可以看出这是真的。一个简单的事情是 GarageArea 与 GarageCars 相关。在现实生活中,这是有道理的——一个可以停放两辆车的车库在面积上会比只能停放一辆车的车库大。同样,分区与社区高度相关。

考虑变量的一个好方法是它们包含的信息。有时,变量包含重叠的信息。例如,当 GarageArea 为 0 时,它与 GarageType 的 NA 重叠——毕竟,如果你没有车库,车库的面积就是零。

困难的部分是遍历变量列表,并决定保留哪些变量。这有点像一门艺术,需要算法的帮助。实际上,我们首先要做的是找出一个变量与另一个变量之间的相关性。我们通过计算相关矩阵,然后绘制热图来实现这一点。

要计算相关矩阵,我们只需使用 Gonum 中的函数,并使用以下代码片段:

  m64, err := tensor.ToMat64(Xs, tensor.UseUnsafe())
  mHandleErr(err)
  corr := stat.CorrelationMatrix(nil, m64, nil)
  hm, err := plotHeatMap(corr, newHdr)
  mHandleErr(err)
  hm.Save(60*vg.Centimeter, 60*vg.Centimeter, "heatmap.png")

让我们逐行分析:

m64, err := tensor.ToMat64(Xs, tensor.UseUnsafe())*tensor.Dense转换为mat.Mat64。因为我们不想分配额外的内存块,并且我们已经确定可以安全地重用矩阵中的数据,所以我们传递了一个tensor.UseUnsafe()函数选项,告诉 Gorgonia 重用 Gonum 矩阵中的底层内存。

stat.CorrelationMatrix(nil, m64, nil)计算相关矩阵。相关矩阵是一个三角形矩阵——Gonum 包提供的特别有用的数据结构。对于这个用例来说,这是一个非常巧妙的小数据结构,因为矩阵沿着对角线是镜像的。

接下来,我们使用以下代码片段绘制heatmap

type heatmap struct {
  x mat.Matrix
}

func (m heatmap) Dims() (c, r int) { r, c = m.x.Dims(); return c, r }
func (m heatmap) Z(c, r int) float64 { return m.x.At(r, c) }
func (m heatmap) X(c int) float64 { return float64(c) }
func (m heatmap) Y(r int) float64 { return float64(r) }

type ticks []string

func (t ticks) Ticks(min, max float64) []plot.Tick {
  var retVal []plot.Tick
  for i := math.Trunc(min); i <= max; i++ {
    retVal = append(retVal, plot.Tick{Value: i, Label: t[int(i)]})
  }
  return retVal
}

func plotHeatMap(corr mat.Matrix, labels []string) (p *plot.Plot, err error) {
  pal := palette.Heat(48, 1)
  m := heatmap{corr}
  hm := plotter.NewHeatMap(m, pal)
  if p, err = plot.New(); err != nil {
    return
  }
  hm.NaN = color.RGBA{0, 0, 0, 0} // black

  // add and adjust the prettiness of the chart
  p.Add(hm)
  p.X.Tick.Label.Rotation = 1.5
  p.Y.Tick.Label.Font.Size = 6
  p.X.Tick.Label.Font.Size = 6
  p.X.Tick.Label.XAlign = draw.XRight
  p.X.Tick.Marker = ticks(labels)
  p.Y.Tick.Marker = ticks(labels)

  // add legend
  l, err := plot.NewLegend()
  if err != nil {
    return p, err
  }

  thumbs := plotter.PaletteThumbnailers(pal)
  for i := len(thumbs) - 1; i >= 0; i-- {
    t := thumbs[i]
    if i != 0 && i != len(thumbs)-1 {
      l.Add("", t)
      continue
    }
    var val float64
    switch i {
    case 0:
      val = hm.Min
    case len(thumbs) - 1:
      val = hm.Max
    }
    l.Add(fmt.Sprintf("%.2g", val), t)
  }

  // this is a hack. I place the legends between the axis and the actual heatmap
  // because if the legend is on the right, we'd need to create a custom canvas to take
  // into account the additional width of the legend.
  //
  // So instead, we shrink the legend width to fit snugly within the margins of the plot and the axes.
  l.Left = true
  l.XOffs = -5
  l.ThumbnailWidth = 5
  l.Font.Size = 5

  p.Legend = l
  return
}

plotter.NewHeatMap函数期望一个接口,这就是为什么我将mat.Mat包装在热力图数据结构中,这为绘图器提供了绘制热力图的接口。这种模式将在接下来的章节中变得越来越常见——仅仅为了提供额外的接口而包装数据结构。它们既便宜又容易获得,应该最大限度地使用。

这段代码的大部分涉及对标签的 hack。Gonum 绘图的方式是,当计算画布大小时,标签被认为是位于绘图内的。为了能够在绘图外绘制标签,需要编写大量的额外代码。因此,我缩小了标签以适应轴和绘图本身之间的空白区域,以避免覆盖到绘图的重要区域:

图片

热力图

在这个热力图中,特别值得注意的是白色条纹。我们期望一个变量与其自身完全相关。但如果你注意到,有一些白色线条区域与对角线白色线条平行。这些是总相关。我们需要移除它们。

热力图看起来很漂亮,但相当愚蠢。人眼并不擅长区分色调。因此,我们还将报告数字。变量之间的相关系数在-1 和 1 之间。我们特别关注接近两端的相关系数。

这个片段打印了结果:

  // heatmaps are nice to look at, but are quite ridiculous.
  var tba []struct {
    h1, h2 string
    corr float64
  }
  for i, h1 := range newHdr {
    for j, h2 := range newHdr {
      if c := corr.At(i, j); math.Abs(c) >= 0.5 && h1 != h2 {
        tba = append(tba, struct {
          h1, h2 string
          corr float64
        }{h1: h1, h2: h2, corr: c})
      }
    }
  }
  fmt.Println("High Correlations:")
  for _, a := range tba {
    fmt.Printf("\t%v-%v: %v\n", a.h1, a.h2, a.corr)
  }

在这里,我使用匿名结构体,而不是命名结构体,因为我们不会重用数据——它仅用于打印。一个匿名元组就足够了。在大多数情况下,这并不是最佳实践。

这个相关图只显示了自变量的相关系数。要真正理解多重共线性,我们需要找到每个变量与其他变量以及因变量的相关系数。这将被留给读者作为练习。

如果你绘制相关矩阵,它看起来会和我们这里的一样,但会多出一个行和列用于因变量。

最终,多重共线性只能在运行回归后检测到。相关图只是指导变量包含和排除的简写方式。消除多重共线性的实际过程是一个迭代过程,通常还会使用其他统计量,如方差膨胀因子,来帮助决定包含哪些变量以及排除哪些变量。

为了本章的目的,我已确定多个要包含的变量——大多数变量都被排除。这可以在const.go文件中找到。在忽略列表中被注释掉的行是最终模型中包含的内容。

如本节开头段落所述,这实际上是一种艺术,由算法辅助。

标准化

作为最后的转换,我们需要标准化我们的输入数据。这允许我们比较模型,看看一个模型是否比另一个模型更好。为此,我编写了两种不同的缩放算法:

func scale(a [][]float64, j int) {
  l, m, h := iqr(a, 0.25, 0.75, j)
  s := h - l
  if s == 0 {
    s = 1
  }

  for _, row := range a {
    row[j] = (row[j] - m) / s
  }
}

func scaleStd(a [][]float64, j int) {
  var mean, variance, n float64
  for _, row := range a {
    mean += row[j]
    n++
  }
  mean /= n
  for _, row := range a {
    variance += (row[j] - mean) * (row[j] - mean)
  }
  variance /= (n-1)

  for _, row := range a {
    row[j] = (row[j] - mean) / variance
  }
}

如果你来自数据科学的 Python 世界,第一个缩放函数本质上就是 scikits-learn 的RobustScaler所做的那样。第二个函数本质上与StdScaler相同,但将方差调整为适用于样本数据。

此函数将给定列(j)中的值缩放到所有值都约束在某个值之内的方式。此外,请注意,两个缩放函数的输入都是[][]float64。这就是tensor包的好处所在。一个*tensor.Dense可以转换为[][]float64而不需要任何额外的分配。一个额外的有益副作用是,你可以修改a,而张量值也会随之改变。本质上,[][]float64将作为底层张量数据的迭代器。

我们现在的转换函数看起来是这样的:

func transform(it [][]float64, hdr []string, hints []bool) []int {
  var transformed []int
  for i, isCat := range hints {
    if isCat {
      continue
    }
    skewness := skew(it, i)
    if skewness > 0.75 {
      transformed = append(transformed, i)
      log1pCol(it, i)
    }
  }
  for i, h := range hints {
    if !h {
      scale(it, i)
    }
  }
  return transformed
}

注意,我们只想缩放数值变量。分类变量也可以缩放,但差别并不大。

线性回归

现在所有这些都完成了,让我们来做一些线性回归!但首先,让我们清理一下我们的代码。我们将到目前为止的探索工作移动到一个名为exploration()的函数中。然后我们将重新读取文件,将数据集分为训练集和测试集,并在最终运行回归之前执行所有转换。为此,我们将使用github.com/sajari/regression并应用回归。

第一部分看起来是这样的:

func main() {
  // exploratory() // commented out because we're done with exploratory work.

  f, err := os.Open("train.csv")
  mHandleErr(err)
  defer f.Close()
  hdr, data, indices, err := ingest(f)
  rows, cols, XsBack, YsBack, newHdr, newHints := clean(hdr, data, indices, datahints, ignored)
  Xs := tensor.New(tensor.WithShape(rows, cols), tensor.WithBacking(XsBack))
  it, err := native.MatrixF64(Xs)
  mHandleErr(err)

  // transform the Ys
  for i := range YsBack {
    YsBack[i] = math.Log1p(YsBack[i])
  }
  // transform the Xs
  transform(it, newHdr, newHints)

  // partition the data
  shuffle(it, YsBack)
  testingRows := int(float64(rows) * 0.2)
  trainingRows := rows - testingRows
  testingSet := it[trainingRows:]
  testingYs := YsBack[trainingRows:]
  it = it[:trainingRows]
  YsBack = YsBack[:trainingRows]
  log.Printf("len(it): %d || %d", len(it), len(YsBack))
...

我们首先摄入和清理数据,然后为Xs的矩阵创建一个迭代器,以便更容易访问。然后我们转换XsYs。最后,我们洗牌Xs,并将它们划分为训练集和测试集。

回想一下第一章中关于判断模型好坏的内容。一个好的模型必须能够泛化到之前未见过的值组合。为了防止过拟合,我们必须交叉验证我们的模型。

为了实现这一点,我们必须只在数据的一个有限子集上训练,然后使用模型在测试数据集上进行预测。然后我们可以得到一个分数,表明它在测试集上运行得有多好。

理想情况下,这应该在将数据解析到XsYs之前完成。但我们想重用我们之前写的函数,所以不会这么做。然而,单独的摄入和清理函数允许你这样做。如果你访问 GitHub 上的仓库,你会发现所有这样的操作都可以轻松完成。

现在,我们简单地取出数据集的 20%,并将其放在一边。使用洗牌来重新采样行,这样我们就不需要在每次训练时都使用相同的 80%。

此外,请注意,现在clean函数接受ignored,而在探索模式中,它接受nil。这与洗牌一起,对于后续的交叉验证非常重要。

回归

因此,现在我们准备好构建回归模型了。请记住,在实际生活中,这一部分是非常迭代的。我将描述迭代过程,但只会分享我选择确定的模型。

github.com/sajari/regression包做得很好。但我们需要扩展这个包,以便能够比较模型和参数的系数。所以我写了这个函数:

func runRegression(Xs [][]float64, Ys []float64, hdr []string) (r *regression.Regression, stdErr []float64) {
  r = new(regression.Regression)
  dp := make(regression.DataPoints, 0, len(Xs))
  for i, h := range hdr {
    r.SetVar(i, h)
  }
  for i, row := range Xs {
    if i < 3 {
      log.Printf("Y %v Row %v", Ys[i], row)
    }
    dp = append(dp, regression.DataPoint(Ys[i], row))
  }
  r.Train(dp...)
  r.Run()

  // calculate StdErr
  var sseY float64
  sseX := make([]float64, len(hdr)+1)
  meanX := make([]float64, len(hdr)+1)
  for i, row := range Xs {
    pred, _ := r.Predict(row)
    sseY += (Ys[i] - pred) * (Ys[i] - pred)
    for j, c := range row {
      meanX[j+1] += c
    }
  }
  sseY /= float64(len(Xs) - len(hdr) - 1) // n - df ; df = len(hdr) + 1
  vecf64.ScaleInv(meanX, float64(len(Xs)))
  sseX[0] = 1
  for _, row := range Xs {
    for j, c := range row {
      sseX[j+1] += (c - meanX[j+1]) * (c - meanX[j+1])
    }
  }
  sseY = math.Sqrt(sseY)
  vecf64.Sqrt(sseX)
  vecf64.ScaleInvR(sseX, sseY)

  return r, sseX
}

runRegression将执行回归分析,并打印系数标准误差的输出。这是系数标准差的估计——想象这个模型被运行很多次:每次系数可能略有不同。标准误差简单地报告系数的变化量。

标准误差是通过gorgonia.org/vecf64包计算的,该包对向量执行原地操作。可选地,你可以选择用循环来编写它们。

这个函数还向我们介绍了github.com/sajari/regression包的 API——要预测,只需使用r.Predict(vars)。这在需要将此模型用于生产的情况下将非常有用。

目前,让我们专注于主函数的另一部分:

  // do the regessions
  r, stdErr := runRegression(it, YsBack, newHdr)
  tdist := distuv.StudentsT{Mu: 0, Sigma: 1, Nu: float64(len(it) - len(newHdr) - 1), Src: rand.New(rand.NewSource(uint64(time.Now().UnixNano())))}
  fmt.Printf("R²: %1.3f\n", r.R2)
  fmt.Printf("\tVariable \tCoefficient \tStdErr \tt-stat\tp-value\n")
  fmt.Printf("\tIntercept: \t%1.5f \t%1.5f \t%1.5f \t%1.5f\n", r.Coeff(0), stdErr[0], r.Coeff(0)/stdErr[0], tdist.Prob(r.Coeff(0)/stdErr[0]))
  for i, h := range newHdr {
    b := r.Coeff(i + 1)
    e := stdErr[i+1]
    t := b / e
    p := tdist.Prob(t)
    fmt.Printf("\t%v: \t%1.5f \t%1.5f \t%1.5f \t%1.5f\n", h, b, e, t, p)
  }
...

在这里,我们运行回归分析,然后打印结果。我们不仅想要输出回归系数,还想要输出标准误差、t 统计量和 P 值。这将给我们对估计系数的信心。

tdist := distuv.StudentsT{Mu: 0, Sigma: 1, Nu: float64(len(it) - len(newHdr) - 1), Src: rand.New(rand.NewSource(uint64(time.Now().UnixNano())))}创建了一个学生 t 分布,我们将将其与我们的数据进行比较。t 统计量非常简单地通过将系数除以标准误差来计算。

交叉验证

现在我们来到了最后一部分——为了比较模型,我们想要交叉验证模型。我们已经留出了一部分数据。现在,我们必须在留出的数据上测试模型,并计算一个分数。

我们将使用的是均方根误差。它被使用是因为它简单且易于理解:

  // VERY simple cross validation
  var MSE float64
  for i, row := range testingSet {
    pred, err := r.Predict(row)
    mHandleErr(err)
    correct := testingYs[i]
    eStar := correct - pred
    e2 := eStar * eStar
    MSE += e2
  }
  MSE /= float64(len(testingSet))
  fmt.Printf("RMSE: %v\n", math.Sqrt(MSE))

现在,我们真正准备好运行回归分析了。

运行回归

简单地运行程序。如果程序运行时忽略列表为空,结果将显示为一系列 NaN。你还记得我们之前对一些变量之间相关性进行的关联分析吗?

我们将首先将它们添加到我们的忽略列表中,然后运行回归。一旦我们得到不再为 NaN 的分数,我们就可以开始比较模型了。

我打印的最终模型如下:

R²: 0.871
 Variable Coefficient StdErr t-stat p-value
 Intercept: 12.38352 0.14768 83.85454 0.00000
 MSSubClass_30: -0.06466 0.02135 -3.02913 0.00412
 MSSubClass_40: -0.03771 0.08537 -0.44172 0.36175
 MSSubClass_45: -0.12998 0.04942 -2.63027 0.01264
 MSSubClass_50: -0.01901 0.01486 -1.27946 0.17590
 MSSubClass_60: -0.06634 0.01061 -6.25069 0.00000
 MSSubClass_70: 0.04089 0.02269 1.80156 0.07878
 MSSubClass_75: 0.04604 0.03838 1.19960 0.19420
 MSSubClass_80: -0.01971 0.02177 -0.90562 0.26462
 MSSubClass_85: -0.02167 0.03838 -0.56458 0.34005
 MSSubClass_90: -0.05748 0.02222 -2.58741 0.01413
 MSSubClass_120: -0.06537 0.01763 -3.70858 0.00043
 MSSubClass_160: -0.15650 0.02135 -7.33109 0.00000
 MSSubClass_180: -0.01552 0.05599 -0.27726 0.38380
 MSSubClass_190: -0.04344 0.02986 -1.45500 0.13840
 LotFrontage: -0.00015 0.00265 -0.05811 0.39818
 LotArea: 0.00799 0.00090 8.83264 0.00000
 Neighborhood_Blueste: 0.02080 0.10451 0.19903 0.39102
 Neighborhood_BrDale: -0.06919 0.04285 -1.61467 0.10835
 Neighborhood_BrkSide: -0.06680 0.02177 -3.06894 0.00365
 Neighborhood_ClearCr: -0.04217 0.03110 -1.35601 0.15904
 Neighborhood_CollgCr: -0.06036 0.01403 -4.30270 0.00004
 Neighborhood_Crawfor: 0.08813 0.02500 3.52515 0.00082
 Neighborhood_Edwards: -0.18718 0.01820 -10.28179 0.00000
 Neighborhood_Gilbert: -0.09673 0.01858 -5.20545 0.00000
 Neighborhood_IDOTRR: -0.18867 0.02825 -6.67878 0.00000
 Neighborhood_MeadowV: -0.24387 0.03971 -6.14163 0.00000
 Neighborhood_Mitchel: -0.15112 0.02348 -6.43650 0.00000
 Neighborhood_NAmes: -0.11880 0.01211 -9.81203 0.00000
 Neighborhood_NPkVill: -0.05093 0.05599 -0.90968 0.26364
 Neighborhood_NWAmes: -0.12200 0.01913 -6.37776 0.00000
 Neighborhood_NoRidge: 0.13126 0.02688 4.88253 0.00000
 Neighborhood_NridgHt: 0.16263 0.01899 8.56507 0.00000
 Neighborhood_OldTown: -0.15781 0.01588 -9.93456 0.00000
 Neighborhood_SWISU: -0.12722 0.03252 -3.91199 0.00020
 Neighborhood_Sawyer: -0.17758 0.02040 -8.70518 0.00000
 Neighborhood_SawyerW: -0.11027 0.02115 -5.21481 0.00000
 Neighborhood_Somerst: 0.05793 0.01845 3.13903 0.00294
 Neighborhood_StoneBr: 0.21206 0.03252 6.52102 0.00000
 Neighborhood_Timber: -0.00449 0.02825 -0.15891 0.39384
 Neighborhood_Veenker: 0.04530 0.04474 1.01249 0.23884
 HouseStyle_1.5Unf: 0.16961 0.04474 3.79130 0.00031
 HouseStyle_1Story: -0.03547 0.00864 -4.10428 0.00009
 HouseStyle_2.5Fin: 0.16478 0.05599 2.94334 0.00531
 HouseStyle_2.5Unf: 0.04816 0.04690 1.02676 0.23539
 HouseStyle_2Story: 0.03271 0.00937 3.49038 0.00093
 HouseStyle_SFoyer: 0.02498 0.02777 0.89968 0.26604
 HouseStyle_SLvl: -0.02233 0.02076 -1.07547 0.22364
 YearBuilt: 0.01403 0.00151 9.28853 0.00000
 YearRemodAdd: 5.06512 0.41586 12.17991 0.00000
 MasVnrArea: 0.00215 0.00164 1.30935 0.16923
 Foundation_CBlock: -0.01183 0.00873 -1.35570 0.15910
 Foundation_PConc: 0.01978 0.00869 2.27607 0.03003
 Foundation_Slab: 0.01795 0.03416 0.52548 0.34738
 Foundation_Stone: 0.03423 0.08537 0.40094 0.36802
 Foundation_Wood: -0.08163 0.08537 -0.95620 0.25245
 BsmtFinSF1: 0.01223 0.00145 8.44620 0.00000
 BsmtFinSF2: -0.00148 0.00236 -0.62695 0.32764
 BsmtUnfSF: -0.00737 0.00229 -3.21186 0.00234
 TotalBsmtSF: 0.02759 0.00375 7.36536 0.00000
 Heating_GasA: 0.02397 0.02825 0.84858 0.27820
 Heating_GasW: 0.06687 0.03838 1.74239 0.08747
 Heating_Grav: -0.15081 0.06044 -2.49506 0.01785
 Heating_OthW: -0.00467 0.10451 -0.04465 0.39845
 Heating_Wall: 0.06265 0.07397 0.84695 0.27858
 CentralAir_Y: 0.10319 0.01752 5.89008 0.00000
 1stFlrSF: 0.01854 0.00071 26.15440 0.00000
 2ndFlrSF: 0.01769 0.00131 13.46733 0.00000
 FullBath: 0.10586 0.01360 7.78368 0.00000
 HalfBath: 0.09048 0.01271 7.11693 0.00000
 Fireplaces: 0.07432 0.01096 6.77947 0.00000
 GarageType_Attchd: -0.37539 0.00884 -42.44613 0.00000
 GarageType_Basment: -0.47446 0.03718 -12.76278 0.00000
 GarageType_BuiltIn: -0.33740 0.01899 -17.76959 0.00000
 GarageType_CarPort: -0.60816 0.06044 -10.06143 0.00000
 GarageType_Detchd: -0.39468 0.00983 -40.16266 0.00000
 GarageType_2Types: -0.54960 0.06619 -8.30394 0.00000
 GarageArea: 0.07987 0.00301 26.56053 0.00000
 PavedDrive_P: 0.01773 0.03046 0.58214 0.33664
 PavedDrive_Y: 0.02663 0.01637 1.62690 0.10623
 WoodDeckSF: 0.00448 0.00166 2.69397 0.01068
 OpenPorchSF: 0.00640 0.00201 3.18224 0.00257
 PoolArea: -0.00075 0.00882 -0.08469 0.39742
 MoSold: 0.00839 0.01020 0.82262 0.28430
 YrSold: -4.27193 6.55001 -0.65220 0.32239
RMSE: 0.1428929042451045

交叉验证的结果(RMSE 为 0.143)是相当不错的——不是最好的,但也不是最差的。这是通过仔细消除变量来实现的。一位经验丰富的计量经济学家可能会进入这个模型,阅读结果,并决定进行进一步的特征工程。

事实上,看到这些结果,我脑海中立刻想到了其他几种可以进行的特征工程——从出售年份中减去翻修年份(翻修/翻新的时效性)。另一种特征工程的形式是对数据集进行 PCA 白化处理。

对于线性回归模型,我倾向于避免复杂的特征工程。这是因为线性回归的关键优势在于它可以用自然语言进行解释。

例如,我们可以这样说:对于地块面积每增加一个单位,如果其他所有因素保持不变,我们预计房价将增加 0.07103 倍。

从这个回归中得出的一个特别反直觉的结果是 PoolArea 变量。解释结果时,我们会说:对于游泳池面积每增加一个单位,在其他条件不变的情况下,我们预计价格将减少 0.00075 倍。当然,系数的 p 值为 0.397,这意味着这个系数可能是完全随机得到的。因此,我们必须非常小心地说这句话——在爱姆斯,马萨诸塞州,拥有游泳池会降低你的财产价值。

讨论和进一步工作

现在,这个模型已经准备好用于预测了。这是最好的模型吗?不,不是。寻找最佳模型是一个永无止境的追求。当然,有无数种方法可以改进这个模型。在使用变量之前,可以使用 LASSO 方法来确定变量的重要性。

模型不仅包括线性回归,还包括与之相关的数据清洗函数和导入函数。这导致了许多可调整的参数。也许如果你不喜欢我的数据插补方式,你总是可以编写自己的方法!

此外,本章中的代码还可以进一步优化。在清洁函数中返回如此多的值,不如创建一个新的元组类型来保存 X 和 Y 值——一种类似数据框的结构。实际上,这正是我们将在接下来的章节中构建的内容。通过使用状态持有结构体,可以使得几个函数更加高效。

如果你注意的话,Go 语言中像 Pandas 这样的统计包并不多。这并不是因为尝试得不够。Go 语言本身就是为了解决问题而设计的,而不是为了构建通用的包。Go 语言中确实有类似 data frame 的包,但根据我的经验,使用它们往往会让人忽视最明显和最有效率的解决方案。通常,最好是为手头的问题构建自己的数据结构。

在 Go 语言中,模型构建通常是一个迭代过程,而将模型投入生产则是在模型构建之后的过程。本章表明,通过一点笨拙,我们可以使用迭代过程构建一个模型,该模型可以直接转化为一个生产就绪的系统。

摘要

在本章中,我们学习了如何使用 Go 语言(虽然有些笨拙)来探索数据。我们绘制了一些图表,并将它们用作选择回归变量的指南。随后,我们实现了一个回归模型,该模型包含错误报告,使我们能够比较模型。最后,为了确保我们没有过度拟合,我们使用 RMSE 分数来交叉验证我们的模型,并得到了一个相当不错的分数。

这只是未来内容的预览。在接下来的章节中,抽象中的观点将被重复——我们将清理数据,然后编写机器学习模型,该模型将进行交叉验证。唯一的一般性区别将是数据,以及模型。

在下一章中,我们将学习一种简单的方法来判断一封电子邮件是否为垃圾邮件。

第三章:分类 - 垃圾邮件检测

什么让你成为你?我有一头黑发,皮肤苍白,具有亚洲特征。我戴眼镜。我的面部结构略呈圆形,与同龄人相比,我的脸颊有更多的皮下脂肪。我所做的是描述我的面部特征。这些描述的每个特征都可以被视为概率连续体中的一个点。拥有黑发的概率是多少?在我的朋友们中,黑发是一个非常常见的特征,眼镜也是如此(一个引人注目的统计数据显示,在我 Facebook 页面上调查的大约 300 人中,有 281 人需要处方眼镜)。我的眼睑褶皱可能不太常见,脸颊上的额外皮下脂肪也是如此。

为什么我在关于垃圾邮件分类的章节中提到我的面部特征?这是因为原理是相同的。如果我给你一张人类面孔的照片,这张照片是我照片的概率是多少?我们可以这样说,照片是我面孔的概率是拥有黑发、拥有苍白皮肤、拥有眼睑褶皱等概率的组合。从朴素的角度来看,我们可以认为每个特征独立地贡献于照片是我面孔的概率——我眼睛上有眼睑褶皱这一事实与我的皮肤是黄色这一事实是独立的。但是,当然,随着遗传学最近的发展,这已经被证明是完全错误的。在现实生活中,这些特征是相互关联的。我们将在未来的章节中探讨这一点。

尽管在现实生活中概率是相互依赖的,我们仍然可以假设朴素立场,并将这些概率视为对照片是我面孔的概率的独立贡献。

在本章中,我们将使用朴素贝叶斯算法构建一个电子邮件垃圾邮件分类系统,该系统可以用于电子邮件垃圾邮件分类之外的应用。在这个过程中,我们将探讨自然语言处理的基础知识,以及概率如何与我们所使用的语言本质相连。通过引入词频-逆文档频率TF-IDF),我们将从地面开始构建对语言的概率理解,并将其转化为贝叶斯概率,用于对电子邮件进行分类。

项目

我们想要做的是很简单:给定一封电子邮件,它是可接受的(我们称之为正常邮件),还是垃圾邮件?我们将使用LingSpam数据库。该数据库中的电子邮件有些过时——垃圾邮件发送者不断更新他们的技术和词汇。然而,我选择LingSpam语料库有一个很好的原因:它已经被很好地预处理了。本章最初的范围是介绍电子邮件的预处理;然而,自然语言预处理选项本身就是一个可以写成一整本书的主题,所以我们将使用已经预处理过的数据集。这使我们能够更多地关注非常优雅的算法的机制。

不要担心,因为我将实际介绍预处理的基本知识。但是,警告一下,复杂度将以非常陡峭的曲线上升,所以请准备好投入许多小时进行自然语言的预处理。在本章结束时,我还会推荐一些有用的库,这些库将有助于预处理。

探索性数据分析

让我们来看看数据。LingSpam语料库包含同一语料库的四个变体:barelemmlemm_stopstop。在每个变体中,都有十个部分,每个部分包含多个文件。每个文件代表一封电子邮件。文件名带有spmsg前缀的是垃圾邮件,其余的是正常邮件。以下是一个示例电子邮件(来自bare变体):

Subject: re : 2 . 882 s - > np np
> date : sun , 15 dec 91 02 : 25 : 02 est > from : michael < mmorse @ vm1 . yorku . ca > > subject : re : 2 . 864 queries > > wlodek zadrozny asks if there is " anything interesting " to be said > about the construction " s > np np " . . . second , > and very much related : might we consider the construction to be a form > of what has been discussed on this list of late as reduplication ? the > logical sense of " john mcnamara the name " is tautologous and thus , at > that level , indistinguishable from " well , well now , what have we here ? " . to say that ' john mcnamara the name ' is tautologous is to give support to those who say that a logic-based semantics is irrelevant to natural language . in what sense is it tautologous ? it supplies the value of an attribute followed by the attribute of which it is the value . if in fact the value of the name-attribute for the relevant entity were ' chaim shmendrik ' , ' john mcnamara the name ' would be false . no tautology , this . ( and no reduplication , either . )

关于这封特定的电子邮件,这里有一些需要注意的事项:

  • 这是一封关于语言学的电子邮件——具体来说,是关于将自然句子解析成多个名词短语np)。这与当前的项目几乎没有关系。然而,我认为通过这些主题进行探讨是个好主意,至少在手动操作时可以提供一个理智的检查。

  • 这封电子邮件和一个人相关联——数据集并没有特别进行匿名化。这将对机器学习的未来产生一些影响,我将在本书的最后一章中探讨。

  • 电子邮件被非常巧妙地分割成字段(即每个单词之间用空格分隔)。

  • 电子邮件有一个主题行。

前两点尤其值得关注。有时,在机器学习中,主题内容实际上很重要。在我们的案例中,我们可以构建我们的算法使其具有盲点——它们可以通用于所有电子邮件。但有时,具有上下文敏感性将使您的机器学习算法达到新的高度。需要注意的是,第二个问题是匿名性。我们生活在一个软件缺陷往往是公司倒闭的时代。在非匿名数据集上进行机器学习往往充满偏见。我们应该尽可能地对数据进行匿名化。

词元化

当处理自然语言句子时,通常的第一步活动是对句子进行分词。给定一个如下的句子:The child was learning a new word and was using it excessively. "Shan't!", she cried。我们需要将句子拆分成构成句子的各个部分。我们将每个部分称为一个标记,因此这个过程被称为分词。这里有一个可能的分词方法,我们使用简单的strings.Split(a, " ")

这里有一个简单的程序:

func main() {
  a := "The child was learning a new word and was using it excessively. \"shan't!\", she cried"
  dict := make(map[string]struct{}) 
  words := strings.Split(a, " ")
  for _, word := range words{
    fmt.Println(word)
    dict[word] = struct{}{} // add the word to the set of words already seen before.
  }
}

这是我们将得到的结果:

The
child
was
learning
a
new
word
and
was
using
it
excessively.
"shan't!",
she
cried

现在考虑在向字典添加单词以学习的情况下。假设我们想要使用同一组英语单词来形成一个新的句子:she shan't be learning excessively.(请原谅句子中的不良含义)。我们将其添加到我们的程序中,看看它是否出现在字典中:

func main() {
  a := "The child was learning a new word and was using it excessively. \"shan't!\", she cried"
  dict := make(map[string]struct{}) 
  words := strings.Split(a, " ")
  for _, word := range words{
    dict[word] = struct{}{} // add the word to the set of words already seen before.
  }

  b := "she shan't be learning excessively."
  words = strings.Split(b, " ")
  for _, word := range words {
    _, ok := dict[word]
    fmt.Printf("Word: %v - %v\n", word, ok)
  }
}

这导致以下结果:

Word: she - true
Word: shan't - false
Word: be - false
Word: learning - true
Word: excessively. - true

一个优秀的分词算法会产生如下结果:

The
child
was
learning
a
new
word
and
was
using
it
excessively
.
"
sha
n't
!
"
,
she
cried

有一个特别需要注意的事项是,符号和标点现在被视为标记。另一个需要注意的事项是shan't现在被拆分为两个标记:shan't。单词shan'tshallnot的缩写;因此,它被分词成两个单词。这是一种独特的英语分词策略。英语的另一个独特之处在于单词之间由边界标记——谦逊的空格分隔。在像中文或日语这样的没有单词边界标记的语言中,分词过程变得显著更加复杂。再加上像越南语这样的语言,其中存在音节边界标记,但没有单词边界标记,你就有了一个非常复杂的分词器。

一个好的分词算法的细节相当复杂,分词本身值得一本书来专门讨论,所以我们在这里不涉及它。

LingSpam语料库的最好之处在于分词已经完成。一些注释,如复合词和缩写,没有被分词成不同的标记,例如shan't的例子。它们被视为一个单词。对于垃圾邮件分类器来说,这是可以的。然而,当处理不同类型的 NLP 项目时,读者可能需要考虑更好的分词策略。

关于分词策略的最后一项注意:英语不是一个特别规则的语言。尽管如此,正则表达式对于小数据集是有用的。对于这个项目,你可能可以用以下正则表达式来解决问题:

``const re = ([A-Z])(\.[A-Z])+\.?|\w+(-\w+)*|\$?\d+(\.\d+)?%?|\.\.\.|[][.,;"'?():-_ + "]"

规范化和词形还原

在上一节中,我提到第二个例子中的所有单词,即“她不应该过分学习”,在第一句话的字典中已经存在。细心的读者可能会注意到单词be实际上不在字典中。从语言学的角度来看,这并不一定错误。单词beis的词根,而was是其过去式。在这里,有一个观点认为,我们不应该只是直接添加单词,而应该添加词根。这被称为词形还原。继续上一个例子,以下是从第一句话中词形还原的单词:

the
child
be
learn
a
new
word
and
be
use
it
excessively
shall
not
she
cry

再次,我想指出一些观察者会立即注意到的不一致之处。特别是,单词excessively的词根是excess。那么为什么excessively被列出?再次,词形还原的任务并不完全是直接在字典中查找词根。在复杂的 NLP 相关任务中,单词通常需要根据它们所在的上下文进行词形还原。这超出了本章的范围,因为,就像之前一样,这是一个相当复杂的话题,可能需要整章的 NLP 预处理书籍来阐述。

因此,让我们回到向字典中添加单词的话题。另一件有用的事情是对单词进行规范化。在英语中,这通常意味着将文本转换为小写,替换 Unicode 组合字符等。在 Go 生态系统中有扩展的标准库包可以做到这一点:golang.org/x/text/unicode/norm。特别是,如果我们将要处理真实的数据集,我个人更喜欢 NFC 规范化方案。关于字符串规范化的好资源可以在 Go 博客文章中找到:blog.golang.org/normalization。内容并不仅限于 Go,而且是一般字符串规范化的良好指南。

LingSpam语料库包含经过规范化(通过小写化和 NFC)和词形还原的变体。它们可以在语料库的lemmlemm_stop变体中找到。

停用词

通过阅读这篇文章,我会假设读者熟悉英语。你可能已经注意到,有些单词比其他单词使用得更频繁。例如,thetherefrom等。判断一封电子邮件是垃圾邮件还是正常邮件的任务本质上是统计性的。当某些单词在文档(如电子邮件)中使用频率较高时,它更多地传达了该文档的主题。例如,我今天收到了一封关于猫的电子邮件(我是猫保护协会的赞助者)。单词catcats在约 120 个单词中出现了 11 次。可以假设这封电子邮件是关于猫的。

然而,单词the出现了 19 次。如果我们通过单词计数来分类电子邮件的主题,电子邮件将被分类在主题the下。像这样的连接词对于理解句子的特定上下文是有用的,但在朴素统计分析中,它们通常只会增加噪音。因此,我们必须删除它们。

停用词通常针对特定项目,我并不是特别喜欢直接删除它们。然而,LingSpam语料库有两个变体:stoplemm_stop,其中包含了停用词列表,以及移除了停用词。

导入数据

现在,无需多言,让我们编写一些代码来导入数据。首先,我们需要一个训练示例的数据结构:

// Example is a tuple representing a classification example
type Example struct {
    Document []string
    Class
}

这样做的目的是为了将我们的文件解析成Example列表。函数如下所示:

func ingest(typ string) (examples []Example, err error) {
  switch typ {
  case "bare", "lemm", "lemm_stop", "stop":
  default:
    return nil, errors.Errorf("Expected only \"bare\", \"lemm\", \"lemm_stop\" or \"stop\"")
  }

  var errs errList
  start, end := 0, 11

  for i := start; i < end; i++ { // hold 30% for crossval
    matches, err := filepath.Glob(fmt.Sprintf("data/lingspam_public/%s/part%d/*.txt", typ, i))
    if err != nil {
      errs = append(errs, err)
      continue
    }

    for _, match := range matches {
      str, err := ingestOneFile(match)
      if err != nil {
        errs = append(errs, errors.WithMessage(err, match))
        continue
      }

      if strings.Contains(match, "spmsg") {
        // is spam
        examples = append(examples, Example{str, Spam})
      } else {
        // is ham
        examples = append(examples, Example{str, Ham})
      }
    }
  }
  if errs != nil {
    err = errs
  }
  return
}

在这里,我使用了filepath.Glob来找到特定目录内匹配模式的文件列表,这是硬编码的。在实际代码中,不一定需要硬编码路径,但硬编码路径可以使演示程序更简单。对于每个匹配的文件名,我们使用ingestOneFile函数解析文件。然后我们检查文件名是否以spmsg为前缀。如果是,我们创建一个具有Spam类的Example。否则,它将被标记为Ham。在本章的后续部分,我将介绍Class类型及其选择理由。现在,这里是ingestOneFile函数。请注意其简单性:

func ingestOneFile(abspath string) ([]string, error) {
  bs, err := ioutil.ReadFile(abspath)
  if err != nil {
    return nil, err
  }
  return strings.Split(string(bs), " "), nil
}

处理错误

在某些编程语言理论中有一个中心论点,即大多数程序中的错误发生在边界。虽然对这个论点有许多解释(边界的什么?一些学者认为是在函数的边界;一些认为是在计算的边界),但从经验来看,确实是真的,I/O 的边界是最容易发生错误的地方。因此,我们在处理输入和输出时必须格外小心。

为了导入文件,我们定义了一个errList类型,如下所示:

type errList []error

func (err errList) Error() string {
  var buf bytes.Buffer
  fmt.Fprintf(&buf, "Errors Found:\n")
  for _, e := range err {
    fmt.Fprintf(&buf, "\t%v\n", e)
  }
  return buf.String()
}

这样我们就可以继续进行,即使读取文件时发生错误。错误会一直冒泡到顶部,而不会引起任何恐慌。

分类器

在我们继续构建我们的分类器之前,让我们想象一下主函数将如下所示。它看起来将与以下类似:

unc main() {
  examples, err := ingest("bare")
  log.Printf("Examples loaded: %d, Errors: %v", len(examples), err)
  shuffle(examples)

  if len(examples) == 0 {
    log.Fatal("Cannot proceed: no training examples")
  }

  // create new classifier
  c := New()

  // train new classifier
  c.Train(examples)

  // predict
  predicted := c.Predict(aDocument)
  fmt.Printf("Predicted %v", predicted)
}

使用TrainPredict作为导出方法是有用的,它可以指导我们接下来要构建什么。从前面的代码块中的草图来看,我们需要一个Classifier类型,它至少有TrainPredict。所以我们将从这里开始:

type Classifier {}

func (c *Classifier) Train(examples []Example) {}

func (c *Classifier) Predict(document []string) Class { ... }

现在,这变成了一个关于分类器如何工作的问题。

简单贝叶斯

分类器是一个朴素贝叶斯分类器。为了解释清楚,朴素贝叶斯中的“朴素”意味着我们假设所有输入特征都是独立的。为了理解分类器的工作原理,首先需要引入一个额外的组件:词频-逆文档频率TF-IDF)的统计对。

TF-IDF

TF-IDF,正如其名称所示,由两个统计量组成:词频TF)和逆文档频率IDF)。

TF 的中心论点是,如果一个词(称为术语)在文档中多次出现,这意味着文档更多地围绕这个词展开。这是有道理的;看看你的电子邮件。关键词通常围绕一个中心主题。但 TF 比这简单得多。没有主题的概念。它只是计算一个词在文档中出现的次数。

相反,IDF 是一种统计量,用于确定一个术语对文档的重要性。在我们看到的例子中,请注意,带有大写S的单词Subject在两种类型的文档中(垃圾邮件和正常邮件)都只出现了一次。总的来说,IDF 的计算方法如下:

.

精确的公式各不相同,每种变化都有细微差别,但所有公式都遵循将文档总数除以术语频率的概念。

在我们的项目中,我们将使用来自go-nlptf-idf库,这是一个 Go 语言的 NLP 相关库的存储库。要安装它,只需运行以下命令:

go get -u github.com/go-nlp/tfidf

这是一个经过极好测试的库,测试覆盖率达到了 100%。

当一起使用时,代表了一种用于计算文档中词重要性的有用加权方案。它看起来很简单,但非常强大,尤其是在概率的背景下使用时。

请注意,TF-IDF 不能严格地解释为概率。当严格将 IDF 解释为概率时,会出现一些理论上的问题。因此,在本项目的背景下,我们将把 TF-IDF 视为一种概率的加权方案。

现在我们已经准备好讨论朴素贝叶斯算法的基本原理。但首先我想进一步强调贝叶斯定理的一些直觉。

条件概率

我们将从条件概率的概念开始。为了设定场景,我们将考虑几种水果类型:

  • 苹果

  • 鳄梨

  • 香蕉

  • 菠萝

  • 油桃

  • 芒果

  • 草莓

对于每种水果类型,我们将有这些水果的几个实例——例如,我们可以在苹果类别中有绿色的 Granny Smith 和红色的 Red Delicious。同样,我们也可以有成熟和未成熟的水果——例如,芒果和香蕉可以是黄色的(成熟)或绿色的(未成熟)。最后,我们还可以根据水果的种类对这些水果进行分类——热带水果(鳄梨、香蕉、菠萝和芒果)与非热带水果:

水果 可以是绿色的 可以是黄色的 可以是红色的 是热带水果
苹果
鳄梨
香蕉
荔枝
芒果
油桃
菠萝
草莓

我想让你现在想象你被蒙上了眼睛,你随机摘了一个水果。然后我会描述这个水果的一个特征,你将猜测这个水果是什么。

假设你摘的水果外面是黄色的。可能的水果有哪些?桃子、香蕉、菠萝和芒果都会出现在你的脑海中。如果你选择其中一个选项,你有四分之一的正确机会。我们称之为黄色的概率!。分子是“可以是黄色的”这一列中的“是”的数量,分母是总行数。

如果我给你另一个关于水果的特征,你可以提高你的胜算。比如说,我告诉你这个水果是热带的。现在你有三分之一的正确机会——油桃已经被排除在可能的选择之外了。

我们可以提出这样的问题:如果我们知道一个水果是热带水果,那么这个水果是黄色的概率是多少?答案是 3/5。从先前的表格中,我们可以看到有五种热带水果,其中三种是黄色的。这被称为条件概率。我们用公式来表示它(对于更倾向于数学的人来说,这是柯尔莫哥洛夫条件概率的定义):

这样读取公式:已知 A 在 B 的条件下发生的概率,我们需要得到 A 和 B 同时发生的概率以及 B 本身发生的概率。

在已知水果是热带水果的条件下,水果是黄色的条件概率是五分之三;实际上有很多热带水果是黄色的——热带条件允许在水果生长过程中有更多的类胡萝卜素和维生素 C 的沉积。

通过查看表格结果可以更容易地理解条件概率。然而,必须注意的是,条件概率可以被计算。具体来说,要计算条件概率,这是公式:

水果既是黄色又是热带的概率是八分之三;在总共八种水果中,有三种是这样的水果。水果是热带的概率是八分之五;在列出的八种水果中,有五种是热带水果。

现在,我们终于准备好弄清楚我们是如何得到那个三分之一数字的。水果类别的概率是均匀的。如果你随机选择,你会在八分之一的时间里选对。我们可以将问题重新表述为:如果一个水果是黄色的并且是热带的,那么它是香蕉的概率是多少?

让我们将其重写为一个公式:

重要的是,我们依赖于一个特殊的技巧来分析前面的概率。具体来说,我们假设每个“是”代表一个存在的单一示例,而“否”表示没有示例,简而言之,这个表格:

水果 是绿色 是黄色 是红色 是热带
苹果 1 0 1 0
鳄梨 1 0 0 1
香蕉 1 1 0 1
荔枝 1 0 1 1
芒果 1 1 0 1
桃子 0 1 1 0
菠萝 1 1 0 1
草莓 1 0 1 0

这对于垃圾邮件检测项目的分析非常重要。每个数字代表在数据集中出现的次数。

特征

从之前的例子中,我们已经看到,我们需要特征,比如水果是否可以是绿色、黄色或红色,或者它是否是热带的。我们现在专注于手头的项目。特征应该是什么?:

类别 ??? ??? ???
垃圾邮件
火腿

一封电子邮件由什么组成?单词组成电子邮件。因此,考虑每个单词特征的出现情况是合适的。我们可以更进一步,利用我们之前开发的 TF-IDF 直觉,而不是简单地计数 1 表示存在,而是计数单词在文档类型中出现的总次数。

表格看起来可能如下所示:

类别 有 XXX 有站点 有免费 有语言学 ...
垃圾邮件 200 189 70 2 ...
火腿 1 2 55 120 ...

这也意味着有很多特征。我们当然可以尝试列举所有可能的计算。但这样做会很繁琐,而且计算量很大。相反,我们可以尝试做得更聪明一些。具体来说,我们将使用条件概率的另一个定义来巧妙地减少需要进行的计算量。

贝叶斯定理

条件概率公式也可以写成贝叶斯定理:

我们称先验概率被称为似然。这些是我们感兴趣的东西,因为本质上是一个常数。

到目前为止的理论有点枯燥。这与我们的项目有什么关系?

首先,我们可以将通用的贝叶斯定理重写为适合我们项目的形式:

图片

这个公式完美地封装了我们的项目;给定一个由单词组成的文档,它是HamSpam的概率是多少?在下一节中,我将向您展示如何将这个公式翻译成一个功能强大的分类器,代码行数少于 100 行。

实现分类器

在本章的早期部分,我们草拟了一个什么也不做的Classifier类型。现在让我们让它做些事情:

type Classifier struct {
  corpus *corpus.Corpus

  tfidfs [MAXCLASS]*tfidf.TFIDF
  totals [MAXCLASS]float64

  ready bool
  sync.Mutex
}

这里介绍了一些内容。让我们逐一了解:

  • 我们将从corpus.Corpus类型开始。

  • 这是一个从corpus包导入的类型,它是 Go 语言 NLP 库lingo的子包。

  • 要安装lingo,只需运行go get -u github.com/chewxy/lingo/....

  • 要使用corpus包,只需像这样导入:import "github.com/chewxy/lingo/corpus".

请记住,在不久的将来,这个包将更改为github.com/go-nlp/lingo。如果你在 2019 年 1 月之后阅读这篇文章,请使用新的地址。

corpus.Corpus对象简单地将单词映射到整数。这样做的原因有两个:

  • 它节省了内存[]int[]string使用更少的内存。一旦语料库被转换为 ID,字符串的内存就可以释放。这样做是为了提供一个字符串池化的替代方案。

  • 字符串池化是变幻莫测的:字符串池化是一种程序,在整个程序的内存中,只有一个字符串的副本。这比预期的要难得多。整数提供了一个更稳定的池化程序。

接下来,我们面临两个字段,它们是数组。具体来说,tfidfs [MAXCLASS]*tfidf.TFIDFtotals [MAXCLASS]float64。在这个时候,讨论Class类型可能是个好主意。

类别

当我们编写摄入代码时,我们接触到了Class类型。这是Class的定义:

type Class byte

const (
  Ham Class = iota
  Spam
  MAXCLASS
)

换句话说,Ham0Spam1MAXCLASS2。它们都是常量值,不能在运行时更改。

首先要谨慎注意的是,这种方法有一些局限性。特别是,这意味着在运行程序之前,你必须知道将有多少个类别。在我们的例子中,我们知道最多会有两个类别:SpamHam。如果我们知道有第三个类别,比如Prosciutto,那么我们可以在MAXCLASS之前将其编码为一个值。使用常量数值类型的Class有许多原因。其中两个主要原因是正确性和性能。

假设我们有一个以Class作为输入的函数:

func ExportedFn(a Class) error {
  // does some decision making with a
}

使用这个函数的人可能会传入3作为类别:ExportedFn(Class(3))。如果我们有一个如下所示的有效性验证函数,我们可以立即判断值是否有效:

func (c Class) isValid() bool { return c < MAXCLASS }

虽然这不如 Haskell 等其他语言那样优雅,在 Haskell 中你可以直接这样做:

data Class = Ham 
            |Spam

并让编译器检查在调用点传入的值是否有效。我们仍然想要保证正确性,所以我们将检查推迟到运行时。ExportedFn 现在的读取方式如下:

func ExportedFn(a Class) error {
  if !a.isValid() {
    return errors.New("Invalid class")
  }
  // does some decision making with a
  }
}

数据类型具有有效值范围的观念并不是一个革命性的观念。例如,Ada 自 1990 年代以来就有有界范围。而使用常量值作为 MAXCLASS 的范围的好处是,我们可以伪造范围检查并在运行时执行它们。在这方面,Go 大概与 Python、Java 或其他不安全语言相似。然而,真正闪耀的地方在于性能。

软件工程实践的一个小贴士是,在不牺牲理解或整洁性的情况下,尽可能让程序对人类来说是可知的。使用常量数值(或枚举)通常允许人类程序员理解值可以拥有的约束。在下一节中,我们将看到,使用常量字符串值会让程序员面临无约束的值。这就是通常发生错误的地方。

注意在 Classifier 结构体中,tfidfstotals 都是数组。与切片不同,在 Go 中访问值时,数组不需要额外的间接层。这使得事情变得稍微快一点。但为了真正理解这种设计的权衡,我们需要查看 Class 的替代设计以及相应的 tfidfstotals 字段的替代设计。

替代类设计

这里,我们想象 Class 的一个替代设计:

type Class string

const (
  Ham Class = "Ham"
  Spam Class = "Spam"
)

由于这个变化,我们将不得不更新 Classifier 的定义:

type Classifier struct {
  corpus *corpus.Corpus

  tfidfs map[Class]*tfidf.TFIDF
  totals map[Class]float64

  ready bool
  sync.Mutex
}

现在考虑获取 Ham 类总量的步骤:

  1. 字符串必须进行哈希处理

  2. 将使用哈希查找存储 totals 数据的桶

  3. 对桶进行间接引用,并检索数据返回给用户

如果类设计是原始的,现在考虑获取 Ham 类总量的步骤:

  • 由于 Ham 是一个数字,我们可以直接计算用于检索和返回给用户的 数据位置。

通过使用一个常量值和类型 Class 的数值定义,以及 totals 的数组类型,我们能够跳过两个步骤。这带来非常微小的性能提升。在这个项目中,直到数据达到一定规模,这些提升大多是可以忽略不计的。

本节关于 Class 设计的目的是培养一种机械同情感。如果你理解机器是如何工作的,你就可以设计非常快的机器学习算法。

说了这么多,有一个假设支撑着整个练习。这是一个main包。如果你正在设计一个将在不同数据集上重用的包,权衡考虑会有很大不同。在软件工程的背景下,过度泛化你的包往往会导致难以调试的抽象。最好是编写稍微更具体和特定的数据结构,这些结构是专门为特定目的构建的。

分类器第二部分

主要考虑之一是朴素贝叶斯分类器是一个非常简单的程序,而且很难出错。整个程序实际上少于 100 行。让我们进一步看看它。

我们已经概述了Train方法,该方法将在给定的一组输入上训练分类器。以下是它的样子:

func (c *Classifier) Train(examples []Example) {
  for _, ex := range examples {
    c.trainOne(ex)
  }
}

func (c *Classifier) trainOne(example Example) {
  d := make(doc, len(example.Document))
  for i, word := range example.Document {
    id := c.corpus.Add(word)
    d[i] = id
  }
  c.tfidfs[example.Class].Add(d)
  c.totals[example.Class]++
}

因此,这里非常清楚,Train是一个操作。但是函数的结构使得并行调用c.trainOne变得非常简单。在这个项目的背景下,这并不是必要的,因为程序能在不到一秒内完成。然而,如果你正在为更大、更多样化的数据集修改这个程序,并行化调用可能会有所帮助。《Classifier》和tfidf.TFIDF结构中包含互斥锁,以允许这类扩展。

但更有趣的是trainOne示例。看看它,它似乎只是将每个单词添加到语料库中,获取其 ID,然后将 ID 添加到doc类型中。顺便说一句,doc被定义为如下:

type doc []int

func (d doc) IDs() []int { return []int(d) }

这个定义是为了适应tfidf.TFIDF.Add接口所接受的格式。

让我们更仔细地看看trainOne方法。在创建doc之后,示例中的单词被添加到语料库中,而 ID 随后被放入doc中。然后,doc被添加到相关类的tfidf.TFIDF中。

初看之下,这里似乎没有太多训练过程;我们只是在增加 TF 统计量。

真正的魔法发生在PredictScore方法中。

Score被定义为如下:

func (c *Classifier) Score(sentence []string) (scores [MAXCLASS]float64) {
  if !c.ready {
    c.Postprocess()
  }

  d := make(doc, len(sentence))
  for i, word := range sentence {
    id := c.corpus.Add(word)
    d[i] = id
  }

  priors := c.priors()

  // score per class
  for i := range c.tfidfs {
    score := math.Log(priors[i])
    // likelihood
    for _, word := range sentence {
      prob := c.prob(word, Class(i))
      score += math.Log(prob)
    }

    scores[i] = score
  }
  return
}

给定一个分词句子,我们希望返回每个类的scores。这样我们就可以查看scores并找到得分最高的类:

func (c *Classifier) Predict(sentence []string) Class {
  scores := c.Score(sentence)
  return argmax(scores)
}

Score函数值得深入探讨,因为所有的魔法都发生在这里。首先,我们检查分类器是否准备好评分。在线机器学习系统随着新数据的到来而学习。这种设计意味着分类器不能以在线方式使用。所有训练都需要在前面完成。一旦完成训练,分类器将被锁定,不再进行训练。任何新的数据都必须是不同运行的一部分。

后处理方法相当简单。记录了所有 TF 统计信息后,我们现在想要计算每个术语相对于文档的相对重要性。tfidf包附带了一个基于Log的 IDF 简单计算,但你也可以使用任何其他 IDF 计算函数,如下所示:

func (c *Classifier) Postprocess() {
  c.Lock()
  if c.ready {
    c.Unlock()
    return
  }

  var docs int
  for _, t := range c.tfidfs {
    docs += t.Docs
  }
  for _, t := range c.tfidfs {
    t.Docs = docs
    // t.CalculateIDF()
    for k, v := range t.TF {
      t.IDF[k] = math.Log1p(float64(t.Docs) / v)
    }
  }
  c.ready = true
  c.Unlock()
}

重要的是要注意,每个类别的文档计数都有更新:t.Docs = docs到所有已看到的文档的总和。这是因为当我们向每个类别的词频添加时,tfidf.TFIDF结构不会意识到其他类别的文档。

我们想要计算 IDF 的原因是为了更好地控制这些值。

回想一下,条件概率可以写成贝叶斯定理的形式:

图片

让我们再次熟悉一下公式,通过用英语重述它,首先熟悉一下术语:

  • 图片: 这是指一个类的先验概率。如果我们有一批电子邮件消息,并且随机挑选一个,那么这封电子邮件是HamSpam的概率是多少?这很大程度上对应于我们拥有的数据集。从探索性分析中,我们知道HamSpam的比例大约是 80:20。

  • 图片: 这是指任何随机文档属于某一类的可能性。因为一个文档由单个单词组成,我们简单地做出一个朴素假设,即这些单词之间是相互独立的。因此,我们想要计算图片的概率。假设单词是独立的,这使我们能够简单地乘以概率。

因此,用英语来说:

给定一个文档,该文档属于 Ham 类的条件概率是文档属于 Ham 的先验概率与文档是 Ham 的似然性的乘积。

注意到我没有解释图片的原因很简单。考虑文档的概率。它只是语料库中所有单词概率的乘积。它根本不与Class交互。它可能是一个常数。

此外,如果我们使用乘积的概率,我们还会遇到另一个问题。乘积的概率往往会得到越来越小的数字。计算机没有真正的有理数。float64是一种巧妙的方法来掩盖计算机的基本限制。当处理机器学习问题时,你经常会遇到数字变得太小或太大的边缘情况。

幸运的是,对于这个案例,我们有一个优雅的解决方案:我们可以选择在对数域中工作。我们不会考虑似然性,而是考虑对数似然性。在对数运算后,乘法变成了加法。这使得我们可以将其置于视线之外,并从脑海中排除。对于大多数情况,包括本项目,这是一个很好的选择。可能存在你希望归一化概率的情况。那么,忽略分母就不会有效。

让我们看看如何编写priors的代码:

func (c *Classifier) priors() (priors []float64) {
  priors = make([]float64, MAXCLASS)
  var sum float64
  for i, total := range c.totals {
    priors[i] = total
    sum += total
  }
  for i := Ham; i < MAXCLASS; i++ {
    priors[int(i)] /= sum
  }
  return
}

先验概率基本上是HamSpam与所有文档总和的比例。这相当简单。为了计算似然性,让我们看看Score中的循环:

  // likelihood
  for _, word := range sentence {
    prob := c.prob(word, Class(i))
    score += math.Log(prob)
  }

我们将似然函数纳入评分函数只是为了便于理解。但似然函数的重要启示是我们正在对给定类别的词的概率进行求和。你是如何计算  的?例如以下内容:

func (c *Classifier) prob(word string, class Class) float64 {
  id, ok := c.corpus.Id(word)
  if !ok {
    return tiny
  }

  freq := c.tfidfs[class].TF[id]
  idf := c.tfidfs[class].IDF[id]
  // idf := 1.0

  // a word may not appear at all in a class.
  if freq == 0 {
    return tiny
  }

  return freq * idf / c.totals[class]
}

首先,我们检查单词是否已被看到。如果单词之前没有见过,那么我们返回一个默认值tiny——一个小的非零值,不会引起除以零错误。

一个词在某个类别中出现的概率简单地是其频率除以该类别看到的单词数。但我们要更进一步;我们想要控制频繁的单词在决定类别概率时不要成为过于重要的因素,所以我们将其乘以我们之前计算出的 IDF。这就是如何得到给定类别的词的概率。

在我们得到概率后,我们取其对数,然后将其加到分数上。

将它们全部组合起来

现在我们有了所有部件。让我们看看如何将它们组合在一起:

  1. 我们首先ingest数据集,然后将数据分割成训练集和交叉验证集。数据集被分成十部分以进行 k 折交叉验证。我们不会这样做。相反,我们将通过保留 30%的数据进行交叉验证来执行单折交叉验证:
  typ := "bare"
  examples, err := ingest(typ)
  log.Printf("errs %v", err)
  log.Printf("Examples loaded: %d", len(examples))
  shuffle(examples)
  cvStart := len(examples) - len(examples)/3
  cv := examples[cvStart:]
  examples = examples[:cvStart]
  1. 我们首先训练分类器,然后检查分类器是否能够很好地预测其自己的数据集:
  c := New()
  c.Train(examples)

  var corrects, totals float64
  for _, ex := range examples {
    // log.Printf("%v", c.Score(ham.Document))
    class := c.Predict(ex.Document)
    if class == ex.Class {
      corrects++
    }
    totals++
  }
  log.Printf("Corrects: %v, Totals: %v. Accuracy %v", corrects, totals, corrects/totals)
  1. 训练分类器后,我们在数据上执行交叉验证:
  log.Printf("Start Cross Validation (this classifier)")
  corrects, totals = 0, 0
  hams, spams := 0.0, 0.0
  var unseen, totalWords int
  for _, ex := range cv {
    totalWords += len(ex.Document)
    unseen += c.unseens(ex.Document)
    class := c.Predict(ex.Document)
    if class == ex.Class {
      corrects++
    }
    switch ex.Class {
    case Ham:
      hams++
    case Spam:
      spams++
    }
    totals++
  }
  1. 在这里,我还添加了unseentotalWords计数,作为一个简单的统计来查看分类器在遇到之前未见过的单词时如何泛化。

此外,因为我们事先知道数据集大约由 80%的Ham和 20%的Spam组成,所以我们有一个要击败的基线。简单来说,我们可以编写一个执行此操作的分类器:

type Classifier struct{}
func (c Classifier) Predict(sentence []string) Class { return Ham }

假设我们有一个这样的分类器。那么它 80%的时候都是正确的!为了知道我们的分类器是好是坏,它必须击败一个基线。为了本章的目的,我们简单地打印出统计数据并相应地进行调整:

  fmt.Printf("Dataset: %q. Corrects: %v, Totals: %v. Accuracy %v\n", typ, corrects, totals, corrects/totals)
  fmt.Printf("Hams: %v, Spams: %v. Ratio to beat: %v\n", hams, spams, hams/(hams+spams))
  fmt.Printf("Previously unseen %d. Total Words %d\n", unseen, totalWords)

因此,最终的main函数看起来如下所示:

func main() {
  typ := "bare"
  examples, err := ingest(typ)
  if err != nil {
    log.Fatal(err)
  }

  fmt.Printf("Examples loaded: %d\n", len(examples))
  shuffle(examples)
  cvStart := len(examples) - len(examples)/3
  cv := examples[cvStart:]
  examples = examples[:cvStart]

  c := New()
  c.Train(examples)

  var corrects, totals float64
  for _, ex := range examples {
    // fmt.Printf("%v", c.Score(ham.Document))
    class := c.Predict(ex.Document)
    if class == ex.Class {
      corrects++
    }
    totals++
  }
  fmt.Printf("Dataset: %q. Corrects: %v, Totals: %v. Accuracy %v\n", typ, corrects, totals, corrects/totals)

  fmt.Println("Start Cross Validation (this classifier)")
  corrects, totals = 0, 0
  hams, spams := 0.0, 0.0
  var unseen, totalWords int
  for _, ex := range cv {
    totalWords += len(ex.Document)
    unseen += c.unseens(ex.Document)
    class := c.Predict(ex.Document)
    if class == ex.Class {
      corrects++
    }
    switch ex.Class {
    case Ham:
      hams++
    case Spam:
      spams++
    }
    totals++
  }

  fmt.Printf("Dataset: %q. Corrects: %v, Totals: %v. Accuracy %v\n", typ, corrects, totals, corrects/totals)
  fmt.Printf("Hams: %v, Spams: %v. Ratio to beat: %v\n", hams, spams, hams/(hams+spams))
  fmt.Printf("Previously unseen %d. Total Words %d\n", unseen, totalWords)
}

bare上运行它,这是我得到的结果:

Examples loaded: 2893
Dataset: "bare". Corrects: 1917, Totals: 1929\. Accuracy 0.9937791601866252
Start Cross Validation (this classifier)
Dataset: "bare". Corrects: 946, Totals: 964\. Accuracy 0.9813278008298755
Hams: 810, Spams: 154\. Ratio to beat: 0.8402489626556017
Previously unseen 17593\. Total Words 658105

要看到去除停用词和词形还原的影响,我们只需切换到使用lemm_stop数据集,这就是我得到的结果:

Dataset: "lemm_stop". Corrects: 1920, Totals: 1929\. Accuracy 0.995334370139969
Start Cross Validation (this classifier)
Dataset: "lemm_stop". Corrects: 948, Totals: 964\. Accuracy 0.983402489626556
Hams: 810, Spams: 154\. Ratio to beat: 0.8402489626556017
Previously unseen 16361\. Total Words 489255

不论哪种方式,这个分类器都非常有效。

摘要

在本章中,我展示了朴素贝叶斯分类器的基本形态——一个用对统计学基本理解编写的分类器,在任何时候都会胜过任何公开可用的库。

分类器本身的代码行数不到 100 行,但它带来了巨大的力量。能够以 98%或更高的准确率进行分类可不是什么容易的事情。

关于 98%这个数字的说明:这并不是最先进的技术。最先进的技术在 99.xx%的高位。之所以有为了那最后 1%而进行的竞争,主要是因为规模问题。想象一下,你是谷歌,你在运行 Gmail。0.01%的错误意味着数百万封邮件被错误分类。这意味着许多不满意的客户。

在机器学习中,是否采用新且未经测试的方法在很大程度上取决于你问题的规模。根据我过去 10 年从事机器学习的经验,大多数公司并没有达到那么大的数据规模。因此,朴素的朴素贝叶斯分类器会非常适用。

在下一章中,我们将探讨人类面临的最棘手的问题之一:时间。

第四章:使用时间序列分析分解 CO2 趋势

如果你是在 2055 年阅读这本书——假设你仍然在使用基于公历的年份系统(一年是地球围绕太阳公转一次所需的时间)——恭喜你!你已经存活了下来。这本书是在 2018 年写的,而我们人类在物种生存方面有许多担忧。

总的来说,我们已经设法进入了一个相对稳定的和平时期,但作为整体,我们物种的未来面临着各种威胁。其中大部分威胁是由我们过去的行为引起的。我想强调一点:我并不是在责怪过去任何人造成这些威胁。我们的祖先正忙于优化不同的目标,而这些威胁通常是当时行动中不可预见/无法预见的外部效应。

另一个影响因素是,从生物学的角度来看,人类并不擅长思考未来。我们的大脑根本不把我们未来的自己视为当前自我的连续体[0],[1]。因此,我们常常把未来可能发生的事情看作是别人发生的事情,或者认为未来被夸大了。这导致了今天做出的决策没有考虑到未来的影响。这导致了我们物种过去行为引起的许多威胁。

其中一个威胁是失控的气候变化,这可能会摧毁我们整个的生活方式,并可能威胁到整个人类的灭绝。这是非常真实且不过分的。人为引起的气候变化是一个非常广泛的话题,有许多细分领域。人为引起气候变化的主要原因是在空气中释放二氧化碳(CO[2])的速率增加。

在本章中,我们将对空气中的二氧化碳(CO[2])进行时间序列分析。本章的主要目标是作为时间序列分析的入门介绍。在技术层面,你将学习使用Gonum进行细粒度绘图。此外,我们还将学习如何处理非常规数据格式。

探索性数据分析

空气中的二氧化碳含量是可以测量的。国家海洋和大气管理局NOAA)部门自 20 世纪 50 年代初就开始收集空气中二氧化碳含量的数据。我们将使用的数据可以在www.esrl.noaa.gov/gmd/ccgg/trends/data.html找到。我们将特别使用莫纳罗亚的月平均数据。

移除注释后的数据看起来大致如下:

# decimal average interpolated trend #days
# date (season corr)
1958 3 1958.208 315.71 315.71 314.62 -1
1958 4 1958.292 317.45 317.45 315.29 -1
1958 5 1958.375 317.50 317.50 314.71 -1
1958 6 1958.458 -99.99 317.10 314.85 -1
1958 7 1958.542 315.86 315.86 314.98 -1
1958 8 1958.625 314.93 314.93 315.94 -1

尤其是我们对插值列感兴趣。

由于这是一个特别有趣的数据库,可能值得看看如何在 Go 中直接下载和预处理数据。

从非 HTTP 源下载

我们将首先编写一个下载数据的函数,如下所示:

func download() io.Reader {
  client, err := ftp.Dial("aftp.cmdl.noaa.gov:21")
  dieIfErr(err)
  dieIfErr(client.Login("anonymous", "anonymous"))
  reader, err := client.Retr("products/trends/co2/co2_mm_mlo.txt")
  dieIfErr(err)
  return reader
}

NOAA 数据位于一个公开可访问的 FTP 服务器上:ftp://aftp.cmdl.noaa.gov/products/trends/co2/co2_mm_mlo.txt。如果你通过网页浏览器访问 URI,你会立即看到数据。以编程方式访问数据有点棘手,因为这不是一个典型的 HTTP URL。

为了处理 FTP 连接,我们将使用 github.com/jlaffaye/ftp 包。可以使用标准的 go get 方法安装该包:go get -u github.com/jlaffaye/ftp。该包的文档相对较少,并且需要你理解 FTP 标准。但不用担心,使用 FTP 获取文件相对简单。

首先,我们需要连接到服务器(如果你正在处理 HTTP 端点——net/http 仅抽象出连接过程,所以你不必一定看到后台发生的事情)。因为连接是一个相当低级的过程,我们需要提供端口号。就像 HTTP 的惯例是服务器监听端口 80 一样,FTP 服务器的惯例是监听端口 21,所以我们需要连接到一个指定要连接到端口 21 的服务器。

对于不习惯使用 FTP 的人来说,一个额外的奇怪之处在于 FTP 需要登录到服务器。对于具有匿名只读访问的服务器,通常的做法是使用 "anonymous" 作为用户名和密码。

成功登录后,我们检索请求的资源(我们想要的文件)并下载文件。位于 github.com/jlaffaye/ftpfttp 库返回 io.Reader。可以将其视为包含数据的文件。

处理非标准数据

仅使用标准库解析数据是一件轻而易举的事情:

func parse(l loader) (dates []string, co2s []float64) {
  s := bufio.NewScanner(l())
  for s.Scan() {
    row := s.Text()
    if strings.HasPrefix(row, "#") {
      continue
    }
    fields := strings.Fields(row)
    dates = append(dates, fields[2])
    co2, err := strconv.ParseFloat(fields[4], 64)
    dieIfErr(err)
    co2s = append(co2s, co2)
  }
  return
}

解析函数接受一个 loader,当调用时返回一个 io.Reader。然后我们将 io.Reader 包装在 bufio.Scanner 中。回想一下,格式不是标准的。有些是我们想要的,有些我们不想。然而,数据格式相当一致——我们可以使用标准库函数来过滤我们想要的和不想的。

s.Scan() 方法扫描 io.Reader 直到遇到换行符。我们可以使用 s.Text() 获取字符串。如果字符串以 # 开头,我们跳过该行。

否则,我们使用 strings.Fields 将字符串拆分为字段。我们之所以使用 strings.Fields 而不是 strings.Split,是因为后者处理多个空格不好。

在将行拆分为字段之后,我们解析必要的内容:

type loader func() io.Reader

为什么我们需要一个 loader 类型?

原因很简单:我们想成为好公民——在开发程序时,我们不应该反复从 FTP 服务器请求数据。相反,我们会缓存文件,并在开发模式下使用该单个文件。这样,我们就不必总是从互联网上下载。

从文件中读取的相应loader类型看起来如下,相当直观:

func readFromFile() io.Reader {
  reader, err := os.Open("data.txt")
  dieIfErr(err)
  return reader
}

处理十进制日期

在这些数据中使用的更多有趣的自定义格式之一是日期。它是一种称为十进制日期的格式。它们看起来如下所示:

2018.5

这意味着这个日期代表了 2018 年的中点。2018 年有 365 天。50%的标记将是 183 天:2018 年 7 月 3 日。

我们可以将这个逻辑转换为以下代码:

// parseDecimalDate takes a string in format of a decimal date
// "2018.05" and converts it into a date.
//
func parseDecimalDate(a string, loc *time.Location) (time.Time, error) {
  split := strings.Split(a, ".")
  if len(split) != 2 {
    return time.Time{}, errors.Errorf("Unable to split %q into a year followed by a decimal", a)
  }
  year, err := strconv.Atoi(split[0])
  if err != nil {
    return time.Time{}, err
  }
  dec, err := strconv.ParseFloat("0."+split[1], 64) // bugs can happen if you forget to add "0."
  if err != nil {
    return time.Time{}, err
  }

  // handle leap years
  var days float64 = 365
  if year%400 == 0 || year%4 == 0 && year%100 != 0 {
    days = 366
  }

  start := time.Date(year, time.January, 1, 0, 0, 0, 0, loc)
  daysIntoYear := int(dec * days)
  retVal := start.AddDate(0, 0, daysIntoYear)
  return retVal, nil
}

第一步是将字符串拆分为年份和小数部分。年份被解析为int数据类型,而小数部分被解析为浮点数以确保我们可以进行数学运算。在这里,需要注意的是,如果不小心处理,可能会发生错误:在拆分字符串后,需要将"0."添加到字符串前面。

一个更简洁的替代方案是将字符串解析为float64,然后使用math.Modf将浮点数拆分为整数部分和小数部分。

无论哪种方式,一旦我们有了小数部分,我们就可以用它来确定一年中的天数。但首先,我们需要确定这一年是否是闰年。

我们可以通过将小数乘以一年中的天数来简单地计算年份中的天数。在此基础上,我们只需添加日期数,然后返回日期。

需要注意的一点是,我们传递了一个*time.Location——在这个特定的情况下,我们知道天文台位于夏威夷,因此我们将其设置为"Pacific/Honolulu"。尽管在这种情况下,我们可以将位置设置为世界上任何其他地方,这不会改变数据的结果。但这在这个项目中是独特的——在其他时间序列数据中,时区可能很重要,因为数据收集方法可能涉及来自不同时区的时数据。

绘图

现在我们已经完成了获取文件和解析文件的工作,让我们绘制数据。再次,就像在第二章中,线性回归-房价预测,我们将使用 Gonum 出色的绘图库。这一次,我们将更详细地探索它。我们将学习以下内容:

  • 如何绘制时间序列图

  • 如何将图表分解为其元素以及如何操作这些元素来设计图表

  • 如何创建 Gonum 不提供的图表类型的绘图器

我们首先编写一个函数来绘制时间序列图:

func newTSPlot(xs []time.Time, ys []float64, seriesName string) *plot.Plot {
  p, err := plot.New()
  dieIfErr(err)
  xys := make(plotter.XYs, len(ys))
  for i := range ys {
    xys[i].X = float64(xs[i].Unix())
    xys[i].Y = ys[i]
  }
  l, err := plotter.NewLine(xys)
  dieIfErr(err)
  l.LineStyle.Color = color.RGBA{A: 255} // black
  p.Add(l)
  p.Legend.Add(seriesName, l)
  p.Legend.TextStyle.Font = defaultFont

  // dieIfErr(plotutil.AddLines(p, seriesName, xys))
  p.X.Tick.Marker = plot.TimeTicks{Format: "2006-01-01"}
  p.Y.Label.TextStyle.Font = defaultFont
  p.X.Label.TextStyle.Font = defaultFont
  p.X.Tick.Label.Font = defaultFont
  p.Y.Tick.Label.Font = defaultFont
  p.Title.Font = defaultFont
  p.Title.Font.Size = 16

  return p
}

在这里,我们使用已经熟悉的plotter.XYs(你会在第一章中熟悉它)。这次,我们不会像上次那样使用plotutil.AddLines,而是手动操作,这使我们能够更好地控制线条的样式。

我们只需使用 plotter.NewLine 创建一个新的 *Line 对象。*Line 对象主要是 plot.Plotter,它是任何可以将其自身绘制到画布上的类型。在本章的后续部分,我们将探讨如何创建我们自己的 plot.Plotter 接口和其他相关类型来绘制自定义类型。

样式

但是,目前,能够访问 *Line 对象使我们能够对样式进行更多操作。为了与本章相当忧郁的性质相匹配,我选择了一条纯黑色线条(实际上,我已经相当喜欢纯黑色线条图表,并开始在日常图表中使用它们)。需要注意的是,我这样做:

l.LineStyle.Color = color.RGBA{A: 255}

l.LineStyle.Color 接受 color.Color——color.RGBA 是标准库中 color 库中的一个结构体。它是一个包含四个字段的表示颜色的结构体,例如 RedGreenBlueAlpha。在这里,我利用了 Go 的默认值——0s。但是,Alpha 值为 0 意味着它是不可见的。因此,我只将 A 字段设置为 255——其余字段默认为 0,这使得它呈现为纯黑色。

在设置线条样式后,我们使用 p.Add(l) 将线条添加到图中。因为我们没有使用 plotutil.AddLines,它抽象了一些手动工作,所以我们可能会发现如果我们运行该函数,图中没有图例。没有图例的图通常是没有用的。因此,我们还需要使用 p.Legend.Add(seriesName, l) 添加图例。

除了颜色、宽度和类似之外,我还想为本章制作的图表添加一种更粗犷的感觉——毕竟,本章相当悲观。我觉得默认字体 Times New Roman 稍显人文主义。因此,我们需要更改字体。幸运的是,扩展的 Go 标准库附带了一个字体处理库。虽然通常我会选择使用粗衬线字体来实现粗犷的外观,但 Go 本身就附带了一个效果很好的字体——Go 字体家族。

我们如何在 *plot.Plot 中更改字体?*plot.Plot 的大多数组件都接受一个 draw.TextStyle,这是一个配置文本样式的数据结构,包括字体。因此,我们可以设置这些字段来表示我们想要使用我们选择的字体。

正如我提到的,在扩展的标准库中,Go 附带了字体和字体处理工具。我们在这里将使用它。首先,我们必须安装以下包:go get -u golang.org/x/image/font/gofont/gomonogo get -u github.com/golang/freetype/truetype。前者是 Go 字体家族的官方 等宽字体。后者是一个用于处理 TrueType 字体的库。

这里必须提到一个注意事项——虽然draw.TextStyle允许配置字体,但字体是vg.Font类型,它封装了一个*truetype.Font类型。如果我们使用truetype.Parse(gomono.TTF),我们将得到*truetype.Fontvg包提供了一个创建这些字体的函数——vg.MakeFont。之所以需要这样做而不是仅仅使用*truetype.Font,是因为vg有很多后端——其中一些渲染字体可能需要字体大小信息。

因此,为了避免多次调用解析字体并创建vg.Font类型,我们可以安全地将它放在一个全局变量中,前提是我们已经提前决定所有字体都将采用相同的粗犷风格:

var defaultFont vg.Font

func init() {
  font, err := truetype.Parse(gomono.TTF)
  if err != nil {
    panic(err)
  }
  vg.AddFont("gomono", font)
  defaultFont, err = vg.MakeFont("gomono", 12)
  if err != nil {
    panic(err)
  }
}

完成这些后,我们可以将所有draw.TextStyle.Font设置为defaultFont。然而,将默认字体大小设置为 12 并不意味着你将固定使用这个大小。因为vg.Font是一个结构体,而不是结构体的指针,一旦在一个对象中设置,你就可以自由地更改该特定字段的字体大小,就像我在以下两行中展示的那样:

  p.Title.Font = defaultFont
  p.Title.Font.Size = 16

使用我们的main函数,我们可以执行以下代码:

func main() {
  dateStrings, co2s := parse(readFromFile)
  dates := parseDates(dateStrings)
  plt := newTSPlot(dates, co2s, "CO2 Level")
  plt.X.Label.Text = "Time"
  plt.Y.Label.Text = "CO2 in the atmosphere (ppm)"
  plt.Title.Text = "CO2 in the atmosphere (ppm) over time\nTaken over the Mauna-Loa observatory"
  dieIfErr(plt.Save(25*vg.Centimeter, 25*vg.Centimeter, "Moana-Loa.png"))
}

结果非常明显,如下面的截图所示:

图片

分解

关于前面的截图,有两点需要注意:

  • 空气中的二氧化碳水平随着时间的推移稳步上升。

  • 二氧化碳水平有起伏,但结果最终还是上升。这些起伏发生在有规律的图案中。

第一点是统计学家所熟知的趋势。你可能已经熟悉了来自 Microsoft Excel 的趋势线的概念。趋势是一种描述随时间逐渐变化的模式。在我们的例子中,很明显趋势是向上的。

第二点被称为季节性——非常恰当,因为它可能就是这样。季节性描述的是定期发生的方差模式。如果你仔细观察图表,通常在每年的 8 月到 10 月,二氧化碳水平降至全年的最低点。之后,它们又稳步上升,直到大约 5 月达到峰值。以下是一个关于为什么会发生这种情况的好提示:植物通过光合作用从空气中吸收二氧化碳。光合作用需要植物细胞中的一个称为叶绿体的细胞器,其中含有一种名为叶绿素的绿色色素。如果你生活在北半球,你一定会知道树木从春天到秋天都是绿色的。这很大程度上与 5 月到 10 月的时期相吻合。季节的变化导致大气二氧化碳水平的变化。你当然可以理解为什么“季节性”这个术语非常恰当。

一个值得提出的问题可能是这样的:我们能否将趋势与季节性分开,以便我们能够单独处理每个组成部分?答案是肯定的。事实上,在本节的剩余部分,我将展示如何做到这一点。

现在,至于为什么你想做那件事,嗯,在我们迄今为止的项目中,我们已经看到了受现实生活日历季节影响的季节性。想象一下,如果你在为一家西方国家的玩具公司做统计分析。你会在圣诞节期间看到年销售高峰。通常,季节性会给我们的分析增加噪声——很难判断销售增长是由于圣诞节还是实际的销售增长。此外,还有一些周期并不一定遵循日历年。如果你在主要面向中国/越南社区的销售中处理销售,你会在春节/越南新年之前看到销售高峰。这些并不遵循我们的日历年。同样,如果你在日期行业——你会在斋月期间看到销售高峰,因为穆斯林斋戒期间对日期的需求急剧增加。

虽然大多数时间序列都会有一些趋势和季节性成分,但我提到并非所有趋势和季节性都特别有用。你可能想将本章学到的知识应用到股票市场上,但请注意!分析复杂的市场与分析空气中 CO[2]的趋势或企业的销售情况是不同的。市场时间序列的基本属性有所不同——它是一个具有马尔可夫性质的进程,这最好描述为过去的表现并不预示未来的表现。相比之下,我们将看到,在这个项目中,过去与现在和未来有很好的相关性。

但回到我们的话题——分解。如果你阅读数据文件上的注释(我们跳过的导入行),以下内容被提及:

"首先,我们为每个月计算一个围绕每月值的 7 年窗口内的平均季节周期。这样,季节周期可以随着时间的推移缓慢变化。然后,通过去除季节周期,我们确定每个月的“趋势”值;这个结果在“趋势”列中显示。"

STL

但如何计算季节周期呢?在本节中,我们将使用 1980 年代末发明的一种算法,称为由 Cleveland 等人发明的季节和趋势分解STL)算法,它被称为 LOESS。我编写了一个实现该算法的库。你可以通过运行go get -u github.com/chewxy/stl来安装它。

这个库实际上非常小巧——只有一个需要调用的main函数(stl.Dcompose),并且库中包含了一系列辅助数据分解的功能。

尽管如此,我认为在使用它之前对 STL 算法有一个大致的了解是个好主意,因为使用它需要相关知识。

LOESS

STL 的动力来源于局部回归的概念——LOESS 本身是一个糟糕的缩写,由 LOcal regrESSion 组成——不管统计学家在 20 世纪 90 年代吸了什么药,我愿意加入他们。我们已经从第一章,如何解决所有机器学习问题中熟悉了线性回归的概念。

回想一下,线性回归的作用是给定一个直线函数: 。我们想要估计  和 。与其试图一次性拟合整个数据集,为什么不将数据集分成许多小的局部组件,并对每个小数据集进行回归分析呢?以下是我想要表达的一个例子:

| X | Y |
 |:--:|:--|
 | -1 | 1 |
 | -0.9 | 0.81 |
 | -0.8 | 0.64 |
 | -0.7 | 0.49 |
 | -0.6 | 0.36 |
 | -0.5 | 0.25 |
 | -0.4 | 0.16 |
 | -0.3 | 0.09 |
 | -0.2 | 0.04 |
 | -0.1 | 0.01 |
 | 0 | 0 |
 | 0.1 | 0.01 |
 | 0.2 | 0.04 |
 | 0.3 | 0.09 |
 | 0.4 | 0.16 |
 | 0.5 | 0.25 |
 | 0.6 | 0.36 |
 | 0.7 | 0.49 |
 | 0.8 | 0.64 |
 | 0.9 | 0.81 |

前面的表格是一个表示 的函数。在回归分析中,我们通常需要拉入整个数据集,但如果我们每三行进行一次运行回归分析会怎样呢?我们从第 2 行(x = -0.9)开始。考虑的数据点是它之前的1和它之后的1x = -1x = -0.8)。对于第 3 行,我们将使用第234行作为数据点进行线性回归。在这个阶段,我们并不特别关注局部回归的错误。我们只想估计梯度交叉。下面是得到的结果表:

| X | Y | m | c
 |:--:|:--:|:--:|:--:|
 | -0.9 | 0.81 | -1.8 | -0.803333333333333 |
 | -0.8 | 0.64 | -1.6 | -0.633333333333334 |
 | -0.7 | 0.49 | -1.4 | -0.483333333333334 |
 | -0.6 | 0.36 | -1.2 | -0.353333333333333 |
 | -0.5 | 0.25 | -1 | -0.243333333333333 |
 | -0.4 | 0.16 | -0.8 | -0.153333333333333 |
 | -0.3 | 0.09 | -0.6 | -0.083333333333333 |
 | -0.2 | 0.04 | -0.4 | -0.033333333333333 |
 | -0.1 | 0.01 | -0.2 | -0.003333333333333 |
 | 0 | 0 | -2.71050543121376E-17 | 0.006666666666667 |
 | 0.1 | 0.01 | 0.2 | -0.003333333333333 |
 | 0.2 | 0.04 | 0.4 | -0.033333333333333 |
 | 0.3 | 0.09 | 0.6 | -0.083333333333333 |
 | 0.4 | 0.16 | 0.8 | -0.153333333333333 |
 | 0.5 | 0.25 | 1 | -0.243333333333333 |
 | 0.6 | 0.36 | 1.2 | -0.353333333333333 |
 | 0.7 | 0.49 | 1.4 | -0.483333333333334 |
 | 0.8 | 0.64 | 1.6 | -0.633333333333333 |
 | 0.9 | 0.81 | 1.8 | -0.803333333333333 |

实际上,我们可以证明,如果你单独绘制每条线,你会得到一个有点“弯曲”的形状。所以,这里有一个我编写的辅助程序来绘制这个图表:

// +build sidenote

package main

import (
  "image/color"

  "github.com/golang/freetype/truetype"
  "golang.org/x/image/font/gofont/gomono"
  "gonum.org/v1/plot"
  "gonum.org/v1/plot/plotter"
  "gonum.org/v1/plot/vg"
  "gonum.org/v1/plot/vg/draw"
)

var defaultFont vg.Font

func init() {
  font, err := truetype.Parse(gomono.TTF)
  if err != nil {
    panic(err)
  }
  vg.AddFont("gomono", font)
  defaultFont, err = vg.MakeFont("gomono", 12)
  if err != nil {
    panic(err)
  }
}

var table = []struct {
  x, m, c float64
}{
  {-0.9, -1.8, -0.803333333333333},
  {-0.8, -1.6, -0.633333333333334},
  {-0.7, -1.4, -0.483333333333334},
  {-0.6, -1.2, -0.353333333333333},
  {-0.5, -1, -0.243333333333333},
  {-0.4, -0.8, -0.153333333333333},
  {-0.3, -0.6, -0.083333333333333},
  {-0.2, -0.4, -0.033333333333333},
  {-0.1, -0.2, -0.003333333333333},
  {0, -2.71050543121376E-17, 0.006666666666667},
  {0.1, 0.2, -0.003333333333333},
  {0.2, 0.4, -0.033333333333333},
  {0.3, 0.6, -0.083333333333333},
  {0.4, 0.8, -0.153333333333333},
  {0.5, 1, -0.243333333333333},
  {0.6, 1.2, -0.353333333333333},
  {0.7, 1.4, -0.483333333333334},
  {0.8, 1.6, -0.633333333333333},
  {0.9, 1.8, -0.803333333333333},
}

type estimates []struct{ x, m, c float64 }

func (es estimates) Plot(c draw.Canvas, p *plot.Plot) {
  trX, trY := p.Transforms(&c)
  lineStyle := plotter.DefaultLineStyle
  lineStyle.Dashes = []vg.Length{vg.Points(2), vg.Points(2)}
  lineStyle.Color = color.RGBA{A: 255}
  for i, e := range es {
    if i == 0 || i == len(es)-1 {
      continue
    }
    strokeStartX := es[i-1].x
    strokeStartY := e.m*strokeStartX + e.c
    strokeEndX := es[i+1].x
    strokeEndY := e.m*strokeEndX + e.c
    x1 := trX(strokeStartX)
    y1 := trY(strokeStartY)
    x2 := trX(strokeEndX)
    y2 := trY(strokeEndY)
    x := trX(e.x)
    y := trY(e.x*e.m + e.c)

    c.DrawGlyph(plotter.DefaultGlyphStyle, vg.Point{X: x, Y: y})
    c.StrokeLine2(lineStyle, x1, y1, x2, y2)
  }
}

func main() {
  p, err := plot.New()
  if err != nil {
    panic(err)
  }
  p.Title.Text = "X² Function and Its Estimates"
  p.X.Label.Text = "X"
  p.Y.Label.Text = "Y"
  p.X.Min = -1.1
  p.X.Max = 1.1
  p.Y.Min = -0.1
  p.Y.Max = 1.1
  p.Y.Label.TextStyle.Font = defaultFont
  p.X.Label.TextStyle.Font = defaultFont
  p.X.Tick.Label.Font = defaultFont
  p.Y.Tick.Label.Font = defaultFont
  p.Title.Font = defaultFont
  p.Title.Font.Size = 16

现在,我们将看到如何绘制原始函数:

  // Original function
  original := plotter.NewFunction(func(x float64) float64 { return x * x })
  original.Color = color.RGBA{A: 16}
  original.Width = 10
  p.Add(original)

  // Plot estimates
  est := estimates(table)
  p.Add(est)

  if err := p.Save(25*vg.Centimeter, 25*vg.Centimeter, "functions.png"); err != nil {
    panic(err)
  }
}

前面的代码生成了一个图表,如下面的截图所示:

大部分代码将在本章的后半部分进行解释,但,现在让我们专注于这样一个事实:你确实可以在数据的“局部”子集上运行许多小的线性回归来绘制曲线。

LOESS 通过指出,如果你有一个值窗口(在玩具示例中,我们使用了3),那么这些值应该被加权。逻辑很简单:一个值离考虑的行越近,其权重就越高。如果我们使用了5的窗口大小,那么在考虑第3行时,24行会比15行有更高的权重。这个宽度,结果证明,对我们平滑处理很重要。

子包 "github.com/chewxy/stl/loess" 实现了 LOESS 作为平滑算法。如果你对了解更多细节感兴趣,请阅读代码。

算法

回想一下,我们的目标是把时间序列分解成季节性和趋势。显然,一旦我们移除了季节性和趋势,就会有一些剩余的部分。我们把这些称为残差。那么,我们该如何做呢?

算法有很多微调是为了提高鲁棒性。我将省略对各种鲁棒性优化进行的解释,但我认为了解算法在一般情况下是如何工作的有一个大致的概念是很重要的。

以下是对算法的简要概述:

  1. 计算趋势(在第一次循环中,趋势全是 0)。

  2. 从输入数据中减去趋势。这被称为去趋势

  3. 循环子序列平滑:数据被划分为N个子周期。每个子周期对应一个周期。然后使用 LOESS 对数据进行平滑处理。结果是临时的季节性数据集。

  4. 对于每个临时的季节性数据集(每个周期一个),我们执行低通滤波——我们保留低频值。

  5. 低通滤波后的值从临时季节性数据集中减去。这就是季节性数据。

  6. 从输入数据中减去季节性数据。这就是新的趋势数据。

  7. 迭代步骤 1 到步骤 6,直到达到所需的迭代次数。这通常是 1 或 2。

如您所见,算法是迭代的——每次迭代都会改进趋势,然后使用改进后的趋势来找到新的季节性数据,然后使用新的季节性数据来更新趋势,如此循环。但 STL 依赖于一个非常重要且容易被忽视的“魔法”。

因此,我们来到理解算法的第二个重要原因:STL 依赖于数据集周期的定义

使用 STL

回顾一下,STL 算法有两个基本的重要部分:

  • 用于平滑的宽度

  • 数据集中的周期数

当我们查看 CO[2]数据集时,我们可以通过计数图表中的峰值来计算周期数。我数了 60 个峰值。这对应于观测站过去 60 年一直在收集数据的事实。

从这里,我们从统计学的硬科学领域转向了更软的解释领域。这在数据科学和机器学习中通常是正确的——我们经常需要依靠直觉来引导我们。

在这种情况下,我们有一个明确的起点:已经过去了 60 年,所以我们预计至少有 60 个周期。另一个起点可以在数据集本身的注释中找到:NOAA 使用 7 年窗口来计算季节性成分。我看不出不使用这些值的原因。所以,让我们将我们的时间序列分解为趋势季节性残差成分。

但在我们开始之前,还有一个额外的注意事项:我们希望将时间序列分解为三个组成部分,但这三个组成部分如何重新组合成整体呢?一般来说,有两种方法:加法或乘法。简单来说,我们可以将数据分解为以下方程之一:

这也可以表述如下:

github.com/chewxy/stl包支持这两种模型,甚至支持介于加性和乘性模型之间的自定义模型。

何时使用加性模型:当季节性不随时间序列的水平变化时使用加性模型。大多数标准商业案例时间序列都属于这一类。

何时使用乘性模型:当季节性或趋势随时间序列的水平变化时使用乘性模型。大多数计量经济学模型都属于这一类。

为了这个项目的目的,我们将使用一个加性模型。再次展示main函数:

func main() {
  dateStrings, co2s := parse(readFromFile)
  dates := parseDates(dateStrings)
  plt := newTSPlot(dates, co2s, "CO2 Level")
  plt.X.Label.Text = "Time"
  plt.Y.Label.Text = "CO2 in the atmosphere (ppm)"
  plt.Title.Text = "CO2 in the atmosphere (ppm) over time\nTaken over the Mauna-Loa observatory"
  dieIfErr(plt.Save(25*vg.Centimeter, 25*vg.Centimeter, "Moana-Loa.png"))

  decomposed := stl.Decompose(co2s, 12, 84, stl.Additive(), stl.WithIter(1))
  dieIfErr(decomposed.Err)
  plts := plotDecomposed(dates, decomposed)
  writeToPng(plts, "decomposed.png", 25, 25)
}

让我们分解一下;特别是参数:

decomposed := stl.Decompose(co2s, 12, 84, stl.Additive(), stl.WithIter(1))

看一下前面代码中的以下术语:

  • 12:我们计算了 60 个周期。数据是月度数据;因此,一个周期为 12 个月,或者说我们知道的——一年。

  • 84:我们使用 NOAA 指定的平滑窗口。七年是 84 个月。

  • stl.Additive(): 我们希望使用一个加性模型。

  • stl.WithIter(1): STL 对迭代次数相当敏感。默认值是2。但是如果你运行次数太多,所有东西都会被迭代地“平滑”掉。所以,我们坚持使用1

在接下来的几节中,我将展示误用的例子以及尽管如此,1 和 2 仍然相当好的迭代次数的原因。

你可能会注意到,我们不是指定周期的数量,而是指定了周期的长度。该包期望数据是均匀分布的——任意两行之间的距离应该是相同的。

运行这个操作会得到以下图表:

图表

第一张图是原始数据,然后是提取的趋势和季节性,最后是残差。关于图表的开始部分还有一些奇怪的地方,但这只是一个事实,即github.com/chewxy/stl库不进行“回溯”。因此,始终至少从一个额外的周期开始是一个好主意。

如何解释这个图?嗯,由于这是一个加性模型,解释起来要简单得多——Y值表示每个组成部分对实际数据中空气中二氧化碳 ppm 的贡献,所以第一张图实际上是将底部图表相加的结果。

如何用统计数据撒谎

重要的是要注意,这些参数实际上控制了将大气中的 CO[2]归因于每个成分的程度。这些控制相当主观。stl包提供了大量关于如何分解时间序列的控制,我认为这取决于阅读这本书的数据科学家或统计学家(也就是你),要负责任地进行统计分析。

如果我们说一个周期是五年呢?保持一切不变,我们可以使用以下代码来找出:

lies := stl.Decompose(co2s, 60, 84, stl.Additive(), stl.WithIter(1))
dieIfErr(lies.Err)
plts2 := plotDecomposed(dates, lies)
writeToPng(plts2, "CO2 in the atmosphere (ppm), decomposed (Liar Edition)", "lies.png", 25, 25)

以下图表是生成的:

图表

然后,我们可以拿这张图展示前两部分,并说:“看!统计数据告诉我们,尽管数据看起来在上升,但实际上是在下降。#科学。”

你当然可以这样做。但我知道你不是个不诚实的人。相反,我希望你阅读这本书是出于拯救世界的良好意图。

但知道使用正确的参数是困难的。我有一个建议,那就是走极端,然后再回来。我的意思是——我们有一个大致的想法,STL 算法是如何工作的。一个已知的控制因素是迭代次数,默认为 2。以下是原始的正确版本,包含 1、2、5、10、20 和 100 次迭代:

图片 6

迭代:

图片 1图片 2图片 3图片 4图片 5

在迭代过程中,经过迭代平滑后,季节性失去了其锯齿状。尽管如此,趋势的形状保持不变。因此,在这种情况下,增加迭代次数只是将季节性贡献转移到趋势成分上。这表明趋势成分是更强的“信号”类型。

相比之下,如果我们运行“谎言”版本,我们会看到在两次迭代后,趋势的形状发生了变化,从第 10 次迭代开始,趋势的形状保持不变。这给我们提供了一个关于“真实”趋势的线索。

在 STL 中,我们真正控制的是季节性。我们告诉算法的是,我们相信周期是 12 个月;因此,请找到一个符合这个周期的季节性。如果我们告诉算法我们相信周期是五年(60 个月),算法将尽力找到一个符合这个模式的季节性和趋势。

我希望明确——每五年发生一次的季节性概念并不错误。事实上,在商业相关的预测中,在多个季节性层面上工作是很常见的。但知道运行多少次迭代,这需要经验和智慧。

检查单位!如果单位没有意义,就像在“谎言”图表中那样,那么它可能不是真实的。

更多绘图

本章除了时间序列分析之外的一个主要主题是绘图。你可能也注意到了main函数中的一些新函数。现在是我们回顾它们的时候了。

我们从stl.Decompose的输出开始。这是定义:

type Result struct {
  Data []float64
  Trend []float64
  Seasonal []float64
  Resid []float64
  Err error
}

结果中没有时间的概念。假设当你将数据传递给stl.Decompose时,数据是按时间序列排序的。结果也遵循这个概念。

我们之前已经定义了newTSPlot,它对于数据、趋势和季节性来说工作得很好,但对于残差来说则不行。我们不希望将残差作为折线图绘制的原因是,如果做得正确,残差应该基本上是随机的。让一条折线穿过随机点将会相当混乱。

典型的残差图仅仅是残差的散点图。然而,当挤压到多图图像中时,这也相对难以解释。

相反,我们想要为每个残差值绘制一条垂直的直线。

回顾一下,这是我们想要做的事情:

  1. DataTrendSeasonal中的每一个绘制时间序列图表。

  2. Resid绘制残差图表。

  3. 将所有前面的图表合并到一个图像中。

第一步很简单,因为我们只需使用之前解析的日期为每个组件调用newTSPlot。第二步稍微有些棘手。Gonum 默认并没有我们想要的残差图。

要绘制它,我们需要创建一个新的plot.Plotter接口。以下是定义:

type residChart struct {
  plotter.XYs
  draw.LineStyle
}

func (r *residChart) Plot(c draw.Canvas, p *plot.Plot) {
  xmin, xmax, ymin, ymax := r.DataRange()
  p.Y.Min = ymin
  p.Y.Max = ymax
  p.X.Min = xmin
  p.X.Max = xmax

  trX, trY := p.Transforms(&c)
  zero := trY(0)
  lineStyle := r.LineStyle
  for _, xy := range r.XYs {
    x := trX(xy.X)
    y := trY(xy.Y)
    c.StrokeLine2(lineStyle, x, zero, x, y)
  }
}

func (r *residChart) DataRange() (xmin, xmax, ymin, ymax float64) {
  xmin = math.Inf(1)
  xmax = math.Inf(-1)
  ymin = math.Inf(1)
  ymax = math.Inf(-1)
  for _, xy := range r.XYs {
    xmin = math.Min(xmin, xy.X)
    xmax = math.Max(xmax, xy.X)
    ymin = math.Min(ymin, xy.Y)
    ymax = math.Max(ymax, xy.Y)
  }
  return
}

尽管 Gonum 没有我们想要的图表类型,但正如你所见,我们定义自己的图表类型并不需要很多代码。这是 Gonum 的plot库的一部分力量——它足够抽象,允许你编写自己的图表类型,同时它还提供了所有必要的辅助函数,以便用很少的代码使其工作。

Gonum 绘图入门

在我们进一步深入之前,我认为了解 Gonum 的绘图库总体情况可能是有益的。到目前为止,我们一直在以相当随意的的方式使用 Gonum 的plot库。这是为了让你熟悉如何使用这个库。现在你已经有些熟悉了,是时候学习更多关于其内部结构的知识,以便未来能更好地进行绘图。

一个*plot.Plot对象包含图表的元数据。一个图表由以下特性组成:

  • 一个标题

  • XY

  • 一个图例

  • plot.Plotter列表

plot.Plotter接口简单地说就是任何可以接受一个*plot.Plot对象并将其绘制到draw.Canvas(如下定义)上的东西:

type Plotter interface {
 Plot(draw.Canvas, *Plot)
}

通过将plot对象和绘图将要在其上绘制的画布的概念分开,这为 Gonum 的图表打开了各种不同的绘图后端选项。为了了解我所说的后端选项,我们需要更仔细地看看draw.Canvas

draw.Canvas是一个vg.Canvasvg.Rectangle的元组。那么vg究竟是什么呢?vg实际上代表矢量图形。在其中,Canvas类型被定义为一个具有许多方法的接口。这允许vg拥有丰富的后端选项:

  • vg/vgimg:这是我们迄今为止主要使用的包;它将数据写入图像文件。

  • vg/vgpdf:这个包将数据写入 PDF 文件。

  • vg/vgsvg:这个包将数据写入 SVG 文件。

  • vg/vgeps:这个包将数据写入 EPS 文件。

  • vg/vgtex:这个包将数据写入 TEX 文件。

这些画布实现中的每一个都有一个以 (0, 0) 为左下角的坐标系。

残差绘图器

在本章的后面部分将更深入地探讨画布系统。现在,让我们回到满足 plot.Plotter 接口的 Plot 方法。

最有趣的是以下几行:

  trX, trY := p.Transforms(&c)
  zero := trY(0)
  lineStyle := r.LineStyle
  for _, xy := range r.XYs {
    x := trX(xy.X)
    y := trY(xy.Y)
    c.StrokeLine2(lineStyle, x, zero, x, y)
  }

p.Transforms(&c) 返回两个函数,这些函数将我们的数据点的坐标转换到后端的坐标。这样我们就不必担心每个点的绝对位置。相反,它将与最终图像中的绝对位置相关联。

获得变换函数后,我们遍历我们拥有的残差,并将每个转换到画布内的坐标(x := trX(xy.X)y := trY(xy.Y))。

最后,我们告诉画布在两个点之间画一条直线:(x,0)和(xy)。这将在 X 轴上画一条直线向上或向下。

因此,我们创建了自己的 plot.Plotter 接口,现在我们可以将其添加到 plot 对象中。但是直接添加到 *plot.Plot 对象需要很多调整。所以,这里有一个函数可以很好地封装所有这些:

func newResidPlot(xs []time.Time, ys []float64) *plot.Plot {
  p, err := plot.New()
  dieIfErr(err)
  xys := make(plotter.XYs, len(ys))
  for i := range ys {
    xys[i].X = float64(xs[i].Unix())
    xys[i].Y = ys[i]
  }
  r := &residChart{XYs: xys, LineStyle: plotter.DefaultLineStyle}
  r.LineStyle.Color = color.RGBA{A: 255}
  p.Add(r)
  p.Legend.Add("Residuals", r)

  p.Legend.TextStyle.Font = defaultFont
  p.X.Tick.Marker = plot.TimeTicks{Format: "2006-01-01"}
  p.Y.Label.TextStyle.Font = defaultFont
  p.X.Label.TextStyle.Font = defaultFont
  p.X.Tick.Label.Font = defaultFont
  p.Y.Tick.Label.Font = defaultFont
  p.Title.Font.Size = 16
  return p
}

这个函数让人联想到 newTSPlot——你提供 XY 的值,然后得到一个 *plot.Plot 对象,其中所有内容都经过适当的样式和格式化。

你可能会注意到我们还在添加绘图对象作为图例。为了不出现错误,residChart 类型需要实现 plot.Thumbnailer。这同样相当直接:

func (r *residChart) Thumbnail(c *draw.Canvas) {
  y := c.Center().Y
  c.StrokeLine2(r.LineStyle, c.Min.X, y, c.Max.X, y)
}

到目前为止,你可能想知道 canvas 对象。如果我们要在画布的最小 X 和最大 X 之间画一条线,这不是就会在整个画布上画一条水平线吗?

答案其实并不是。回想一下,画布是在后端提供的,而 draw.Canvas 只是一个包含画布后端和矩形的元组?这个矩形实际上用于在绘制时对画布进行子集化和约束。

我们将看到它的实际应用。现在我们已经完成,我们可以将注意力转向下一节,它描述了将所有图表组合成一张图像。

结合图表

一个关键函数允许我们做到这一点的是 plot.Align 函数。为了让我们看到它的实际应用,我们需要编写一个函数,允许我们将任意数量的图表绘制到一个文件中,如下所示:

func writeToPng(a interface{}, title, filename string, width, height vg.Length) {
  switch at := a.(type) {
  case *plot.Plot:
    dieIfErr(at.Save(width*vg.Centimeter, height*vg.Centimeter, filename))
    return
  case [][]*plot.Plot:
    rows := len(at)
    cols := len(at[0])
    t := draw.Tiles{
      Rows: rows,
      Cols: cols,
    }
    img := vgimg.New(width*vg.Centimeter, height*vg.Centimeter)
    dc := draw.New(img)

    if title != "" {
      at[0][0].Title.Text = title
    }

    canvases := plot.Align(at, t, dc)
    for i := 0; i < t.Rows; i++ {
      for j := 0; j < t.Cols; j++ {
        at[i][j].Draw(canvases[i][j])
      }
    }

    w, err := os.Create(filename)
    dieIfErr(err)

    png := vgimg.PngCanvas{Canvas: img}
    _, err = png.WriteTo(w)
    dieIfErr(err)
    return
  }
  panic("Unreachable")
}

我们将跳过如果 aplot.Plot,我们只需调用 .Save 方法的部分。相反,我们将查看第二个案例,其中 a[][]*plot.Plot

起初这可能会显得有些奇怪——为什么我们要有一个图表的切片的切片,而我们只想快速连续地将它们结合起来。理解这个问题的关键在于,Gonum 支持图表的平铺,所以如果你想要以 2x2 的方式排列四个图表,这是可以实现的。一行中有四个图表只是 4x1 布局的特殊情况。

我们可以使用一个函数来安排布局,如下所示:

func plotDecomposed(xs []time.Time, a stl.Result) [][]*plot.Plot {
  plots := make([][]*plot.Plot, 4)
  plots[0] = []*plot.Plot{newTSPlot(xs, a.Data, "Data")}
  plots[1] = []*plot.Plot{newTSPlot(xs, a.Trend, "Trend")}
  plots[2] = []*plot.Plot{newTSPlot(xs, a.Seasonal, "Seasonal")}
  plots[3] = []*plot.Plot{newResidPlot(xs, a.Resid, "Residuals")}

  return plots
}

在获得[][]*plot.Plot之后,我们需要告诉 Gonum 我们感兴趣的镶嵌格式,所以下面的代码片段定义了镶嵌格式:

  t := draw.Tiles{
      Rows: rows,
      Cols: cols,
  }

如果你正在跟随代码,你会意识到rows3cols1

接下来,我们必须提供一个画布来绘制:

    img := vgimg.New(width*vg.Centimeter, height*vg.Centimeter)
    dc := draw.New(img)

在这里,我们使用vgimg后端,因为我们想写入 PNG 图像。例如,如果你想设置图像的 DPI,你可以使用vgimg.NewWith,并传入 DPI 选项。

dc是从大画布img初始化的draw.Canvas。现在到了神奇的部分:canvases := plot.Align(at, t, dc)基本上将大画布(img)分割成各种更小的画布——它们仍然是大画布的一部分,但现在,每个*plot.Plot对象都分配到了画布的一个更小的部分,每个部分都有相对于大画布的自己的坐标系。

以下代码只是将图表绘制到各自的迷你画布上:

    for i := 0; i < t.Rows; i++ {
      for j := 0; j < t.Cols; j++ {
        at[i][j].Draw(canvases[i][j])
      }
    }

自然地,这个过程可以递归地重复。在*plot.Plot中的Legend对象只是简单地得到画布的一个更小的部分,从最小X到最大X的直线绘制只是在整个小画布上画一条水平线。

这就是绘制图表的方法。

预测

我们在这里使用 STL 算法分解时间序列。还有其他分解时间序列的方法——你可能熟悉其中一种:离散傅里叶变换。如果你的数据是基于时间的信号(如电脉冲或音乐),傅里叶变换本质上允许你将时间序列分解成不同的部分。记住,它们不再是季节性和趋势,而是不同时间和频率域的分解。

这引发了一个问题:分解时间序列的目的是什么?

我们进行任何机器学习的主要原因之一是能够根据输入预测值。当在时间序列上执行时,这被称为预测

想想这个问题:如果时间序列由多个组成部分组成,我们能否更好地按每个组成部分进行预测?如果我们能够通过 STL 或傅里叶变换将时间序列分解成其组成部分,那么如果我们按每个组成部分进行预测并在最后重新组合数据,我们会得到更好的结果。

由于我们在 STL 上工作,我们已经有分解好的序列。Holt 在 1957 年发明的一个非常简单的指数平滑算法允许我们使用趋势和季节成分,以及原始数据来进行预测。

Holt-Winters

在本节中,我将解释 Holt-Winters 指数平滑算法的修改版,这对于预测非常有用。Holt-Winters 是一个相当简单的算法。下面是它的样子:

func hw(a stl.Result, periodicity, forward int, alpha, beta, gamma float64) []float64 {
  level := make([]float64, len(a.Data))
  trend := make([]float64, len(a.Trend))
  seasonal := make([]float64, len(a.Seasonal))
  forecast := make([]float64, len(a.Data)+forward)
  copy(seasonal, a.Seasonal)

  for i := range a.Data {
    if i == 0 {
      continue
    }
    level[i] = alpha*a.Data[i] + (1-alpha)*(level[i-1]+trend[i-1])
    trend[i] = beta*(level[i]-level[i-1]) + (1-beta)*(trend[i-1])
    if i-periodicity < 0 {
      continue
    }
    seasonal[i] = gamma*(a.Data[i]-level[i-1]-trend[i-1]) + (1-gamma)*(seasonal[i-periodicity])
  }

  hplus := ((periodicity - 1) % forward) + 1
  for i := 0; i+forward < len(forecast); i++ {
    forecast[i+forward] = level[i] + float64(forward)*trend[i] + seasonal[i-periodicity+hplus]
  }
  copy(forecast, a.Data)

  return forecast
}

调用它相当简单。我们最终会得到一个包含额外周期的时序。因此,在调用newTSPlot之前,我们也需要扩展我们的日期范围。这同样是一个相当简单的问题:

func forecastTime(dates []time.Time, forwards int) []time.Time {
  retVal := append(dates, make([]time.Time, forwards)...)
  lastDate := dates[len(dates)-1]
  for i := len(dates); i < len(retVal); i++ {
    retVal[i] = lastDate.AddDate(0, 1, 0)
    lastDate = retVal[i]
  }
  return retVal
}

理想情况下,我们还想绘制一个灰色背景,表示该区域内的值是预测值。将所有这些放在一起,看起来大致如下:

  fwd := 120
  forecast := hw(decomposed, 12, fwd, 0.1, 0.05, 0.1)
  datesplus := forecastTime(dates, fwd)
  forecastPlot := newTSPlot(datesplus, forecast, "")
  maxY := math.Inf(-1)
  minY := math.Inf(1)
  for i := range forecast {
    if forecast[i] > maxY {
      maxY = forecast[i]
    }
    if forecast[i] < minY {
      minY = forecast[i]
    }
  }
  // extend the range a little
  minY--
  maxY++
  maxX := float64(datesplus[len(datesplus)-1].Unix())
  minX := float64(datesplus[len(dates)-1].Unix())

  shadePoly := plotter.XYs{
    {X: minX, Y: minY},
    {X: maxX, Y: minY},
    {X: maxX, Y: maxY},
    {X: minX, Y: maxY},
  }
  poly, err := plotter.NewPolygon(shadePoly)
  dieIfErr(err)
  poly.Color = color.RGBA{A: 16}
  poly.LineStyle.Color = color.RGBA{}
  forecastPlot.Add(poly)

  writeToPng(forecastPlot, "Forecasted CO2 levels\n(10 years)", "forecast.png", 25, 25)

这将产生以下图表:

如果一切照旧进行,我们预计在 10 年内将看到二氧化碳水平增加。当然,如果我们现在采取行动,它可能会下降。

摘要

这是一章相当难写的章节。主要主题,毫不夸张地说,是关于存在性威胁的。在科学领域使用的方法比我在这章中涵盖的要复杂得多。

我所涉及的技术是统计学一个大型领域——时间序列分析的一部分,而在这个写作技巧中,我们甚至还没有触及它的表面。

参考文献

以下是一些参考文献:

  • [0] Hershfield, Hal. (2011). 未来自我连续性: 如何对未来自我的概念改变跨时间选择。纽约科学院年鉴。1235 卷。30-43 页。10.1111/j.1749-6632.2011.06201.x.

  • [1] Qin, P. and Northoff, G. (2011): 我们的自我与中线区域和默认模式网络有何关联?. 神经影像学,57(3),第 1221-1233 页。

第五章:通过聚类推文清理您的个人 Twitter 时间线

这里有一点点八卦:这个标题的原始项目是关于检测社交媒体上外国对美国选举的影响。大约在同一时间,我还在申请美国的签证,去发表一系列演讲。后来发现,我根本不需要签证;ESTA 涵盖了我在美国想要做的所有事情。但在准备签证的过程中,一位律师严厉地告诫我不要写一本关于美国政治的书。一般的建议是——如果我不想与美国海关和边境巡逻队发生麻烦,我就不应该在社交媒体上写或说任何关于美国政治的事情,更不要说写一本书的章节。所以,我不得不匆忙重写这一章。这一章中使用的多数方法都可以用于原始目的,但内容要温和得多。

我大量使用 Twitter。我主要在空闲时间发推文和阅读 Twitter。我关注了许多有相似兴趣的人,包括机器学习、人工智能、围棋、语言学和编程语言。这些人不仅与我分享兴趣,彼此之间也分享兴趣。因此,有时可能会有多个人就同一主题发推文。

从我大量使用 Twitter 这一事实可能显而易见,我是一个新鲜事物爱好者。我喜欢新鲜事物。如果我对不同的观点感兴趣,那么多人就同一主题发推文是件好事,但我不那样使用 Twitter。我使用 Twitter 作为一种有趣话题的总结。X、Y、Z 事件发生了。知道它们发生了就足够了。对于大多数话题,深入学习和了解细节对我没有好处,而且 140 个字符对于细微差别来说也不算多。因此,一个浅显的概述就足以让我的一般知识跟上其他人。

因此,当多个人就同一主题发推文时,在我的新闻源中就是重复。这很烦人。如果我的新闻源中每个主题只出现一次,那会怎么样呢?

我认为我的 Twitter 阅读习惯是分批进行的。每次会话通常是五分钟。我实际上每次会话只阅读大约 100 条推文。如果在我阅读的 100 条推文中,我关注的 30%的人重叠在某个话题上,那么实际上我只阅读了 30 条真实内容的推文。这根本就不高效!效率意味着每次会话能够覆盖更多的话题。

那么,如何提高阅读推文的效率呢?当然,移除覆盖相同主题的推文!还有选择最好的一条总结该主题的推文的问题,但这将是另一天的主题。

项目

我们将要做的就是在 Twitter 上对推文进行聚类。我们将使用两种不同的聚类技术,K-means 和 DBSCAN。对于本章,我们将依赖我们在第二章中构建的一些技能,线性回归 – 房价预测。我们还将使用第二章中使用的相同库,线性回归 – 房价预测。除此之外,我们还将使用 mpraski 的聚类库。

到项目结束时,我们将能够清理 Twitter 上的任何推文集合,并将它们聚类成组。实现目标的主体代码非常简单,总共只有大约 150 行代码。其余的代码用于获取和预处理数据。

K-means

K-means是一种数据聚类的方法。问题可以这样提出——给定一个包含 N 个项目的数据集,我们希望将数据划分为 K 组。你该如何做呢?

让我稍微偏离一下主题,探索一下坐标的奇妙世界。不,不,别跑!这非常直观。

图片

哪条线更长?你怎么知道?

图片

你知道哪条线更长,因为你可以从点 a、b、c 和 d 测量每条线。现在,让我们尝试一些不同的事情:

图片

哪个点离 X 最近?你怎么知道?

你知道,因为你可以测量点之间的距离。现在,让我们进行最后的练习:

图片

考虑以下距离:

  • AX

  • AY

  • AZ

  • BX

  • BY

  • BZ

  • CX

  • CY

  • CZ

AXBXCX之间的平均距离是多少?AYBYCY之间的平均距离是多少?AZBZCZ之间的平均距离是多少?

如果你必须在XYZ之间选择一个点来代表ABC,你会选择哪一个?

恭喜!你刚刚完成了一个非常简单和简化的 K-means 聚类。具体来说,你做的是一个变体,其中k = 1。如果你必须在XYZ之间选择两个点,那么那将是k = 2。因此,聚类是由使组内平均距离最小化的点集组成的。

这听起来很复杂,但回想一下你刚才做了什么。现在,你不再只有三个点ABC,而是有很多点。你没有给出XYZ;你必须生成自己的XYZ点。然后,你必须找到使每个可能的XYZ点距离最小的组。

概而言之,这就是 K-means。它容易理解,但难以良好实现。结果证明 K-means 是 NP 难的;它可能无法在多项式时间内解决。

DBSCAN

DBSCAN继承了数据可以表示为多维点的想法。再次,以二维为例,以下是 DBSCAN 大致的工作步骤:

  1. 选择一个尚未访问的点。

  2. 以点为中心画一个圆。圆的半径是 epsilon。

  3. 计算有多少其他点落入圆内。如果有超过指定的阈值,我们将所有点标记为属于同一个簇。

  4. 对这个簇中的每个点递归地执行相同的操作。这样做会扩大簇。

  5. 重复这些步骤。

我强烈建议你们在点状纸上尝试自己画出这个图。首先,绘制随机点,然后用铅笔在纸上画圆。这将给你们一个关于 DBSCAN 如何工作的直观感受。图片显示了我增强对 DBSCAN 工作原理直觉的工作。我发现这种直觉非常有用。

数据获取

在早期的练习中,我要求你们观察点并计算出距离。这为我们如何思考数据提供了一些线索。我们需要将数据视为某个想象中的坐标系中的坐标。现在,我们的数据不会仅仅是二维的,因为它是文本的。相反,它将是多维的。这为我们提供了关于数据外观的线索——代表某个任意大的 N 维空间中坐标的数字切片。

但是,首先,我们需要获取数据。

为了获取推文,我们将使用 Aditya Mukherjee 的出色 Anaconda 库。要安装它,只需运行go get -u github.com/ChimeraCoder/Anaconda

当然,不能随意从 Twitter 抓取数据。我们需要通过 Twitter API 获取数据。Twitter API 的文档是开始的好资源:developer.twitter.com/en/docs/basics/getting-started

你们需要首先申请一个 Twitter 开发者账户(如果你们还没有的话):developer.twitter.com/en/apply/user。这个过程相当漫长,需要人工批准开发者账户。尽管如此,你们不需要开发者访问权限来开发这个项目。我开始时以为我有访问 Twitter API 的权限,但结果证明我没有。好消息是,Twitter API 文档页面提供了足够的示例,可以帮助你们开始开发必要的数据结构。

我们感兴趣的具体终点是:developer.twitter.com/en/docs/tweets/timelines/api-reference/get-statuses-home_timeline.html

探索性数据分析

让我们看看从 Twitter API 端点获取的JSON。单个推文看起来可能像这样(来自 Twitter API 文档示例):

 {
 "coordinates": null,
 "truncated": false,
 "created_at": "Tue Aug 28 19:59:34 +0000 2012",
 "favorited": false,
 "id_str": "240539141056638977",
 "in_reply_to_user_id_str": null,
 "entities": {
 "urls": [
],
 "hashtags": 
],
 "user_mentions": [
]
 },
 "text": "You'd be right more often if you thought you were wrong.",
 "contributors": null,
 "id": 240539141056638977,
 "retweet_count": 1,
 "in_reply_to_status_id_str": null,
 "geo": null,
 "retweeted": false,
 "in_reply_to_user_id": null,
 "place": null,
 "source": "web",
 "user": {
 "name": "Taylor Singletary",
 "profile_sidebar_fill_color": "FBFBFB",
 "profile_background_tile": true,
 "profile_sidebar_border_color": "000000",
 "profile_image_url": "http://a0.twimg.com/profile_images/2546730059/f6a8zq58mg1hn0ha8vie_normal.jpeg",
 "created_at": "Wed Mar 07 22:23:19 +0000 2007",
 "location": "San Francisco, CA",
 "follow_request_sent": false,
 "id_str": "819797",
 "is_translator": false,
 "profile_link_color": "c71818",
 "entities": {
 "url": {
 "urls": [
 {
 "expanded_url": "http://www.rebelmouse.com/episod/",
 "url": "http://t.co/Lxw7upbN",
 "indices": [
 0,
 20
 ],
 "display_url": "rebelmouse.com/episod/"
 }
 ]
 },
 "description": {
 "urls": [
]
 }
 },
 "default_profile": false,
 "url": "http://t.co/Lxw7upbN",
 "contributors_enabled": false,
 "favourites_count": 15990,
 "utc_offset": -28800,
 "profile_image_url_https": "https://si0.twimg.com/profile_images/2546730059/f6a8zq58mg1hn0ha8vie_normal.jpeg",
 "id": 819797,
 "listed_count": 340,
 "profile_use_background_image": true,
 "profile_text_color": "D20909",
 "followers_count": 7126,
 "lang": "en",
 "protected": false,
 "geo_enabled": true,
 "notifications": false,
 "description": "Reality Technician, Twitter API team, synthesizer enthusiast; a most excellent adventure in timelines. I know it's hard to believe in something you can't see.",
 "profile_background_color": "000000",
 "verified": false,
 "time_zone": "Pacific Time (US & Canada)",
 "profile_background_image_url_https": "https://si0.twimg.com/profile_background_images/643655842/hzfv12wini4q60zzrthg.png",
 "statuses_count": 18076,
 "profile_background_image_url": "http://a0.twimg.com/profile_background_images/643655842/hzfv12wini4q60zzrthg.png",
 "default_profile_image": false,
 "friends_count": 5444,
 "following": true,
 "show_all_inline_media": true,
 "screen_name": "episod"
 },
 "in_reply_to_screen_name": null,
 "in_reply_to_status_id": null
 }

我们将用类似这样的数据结构来表示每个单独的推文:

 type processedTweet struct {
 anaconda.Tweet
// post processed stuff
 ids []int // to implement Document
 textVec []float64
 normTextVec []float64
 location []float64
 isRT bool
 }

注意,我们嵌入anaconda.Tweet,这在 Anaconda 包中是这样给出的:

 type Tweet struct {
 Contributors []int64 `json:"contributors"`
 Coordinates *Coordinates `json:"coordinates"`
 CreatedAt string `json:"created_at"`
 DisplayTextRange []int `json:"display_text_range"`
 Entities Entities `json:"entities"`
 ExtendedEntities Entities `json:"extended_entities"`
 ExtendedTweet ExtendedTweet `json:"extended_tweet"`
 FavoriteCount int `json:"favorite_count"`
 Favorited bool `json:"favorited"`
 FilterLevel string `json:"filter_level"`
 FullText string `json:"full_text"`
 HasExtendedProfile bool `json:"has_extended_profile"`
 Id int64 `json:"id"`
 IdStr string `json:"id_str"`
 InReplyToScreenName string `json:"in_reply_to_screen_name"`
 InReplyToStatusID int64 `json:"in_reply_to_status_id"`
 InReplyToStatusIdStr string `json:"in_reply_to_status_id_str"`
 InReplyToUserID int64 `json:"in_reply_to_user_id"`
 InReplyToUserIdStr string `json:"in_reply_to_user_id_str"`
 IsTranslationEnabled bool `json:"is_translation_enabled"`
 Lang string `json:"lang"`
 Place Place `json:"place"`
 QuotedStatusID int64 `json:"quoted_status_id"`
 QuotedStatusIdStr string `json:"quoted_status_id_str"`
 QuotedStatus *Tweet `json:"quoted_status"`
 PossiblySensitive bool `json:"possibly_sensitive"`
 PossiblySensitiveAppealable bool `json:"possibly_sensitive_appealable"`
 RetweetCount int `json:"retweet_count"`
 Retweeted bool `json:"retweeted"`
 RetweetedStatus *Tweet `json:"retweeted_status"`
 Source string `json:"source"`
 Scopes map[string]interface{} `json:"scopes"`
 Text string `json:"text"`
 User User `json:"user"`
 WithheldCopyright bool `json:"withheld_copyright"`
 WithheldInCountries []string `json:"withheld_in_countries"`
 WithheldScope string `json:"withheld_scope"`
 }

为了构建程序,我们将使用 Twitter 提供的示例推文。我将示例响应保存到一个名为example.json的文件中,然后创建了一个mock函数来模拟调用 API:

 func mock() []*processedTweet {
 f, err := os.Open("example.json")
 dieIfErr(err)
 return load(f)
 }
 func load(r io.Reader) (retVal []*processedTweet) {
 dec := json.NewDecoder(r)
 dieIfErr(dec.Decode(&retVal))
 return retVal
 }

实用函数dieIfErr被定义为通常:

 func dieIfErr(err error) {
 if err != nil {
 log.Fatal(err)
 }
 }

注意,在mock中,没有对 Twitter 进行 API 调用。将来,我们将创建一个具有类似 API 的函数,这样我们就可以用真实的版本替换这个函数的模拟版本,从 API 获取时间线。

目前,我们可以通过以下程序测试它是否工作:

 func main(){
 tweets := mock()
 for _, tweet := range tweets {
 fmt.Printf("%q\n", tweet.FullText)
 }
 }

这是我的输出结果:

 $ go run *.go
 "just another test"
 "lecturing at the \"analyzing big data with twitter\" class at @cal with @othman http://t.co/bfj7zkDJ"
 "You'd be right more often if you thought you were wrong."

数据整理

当我们测试数据结构是否合理时,我们打印了FullText字段。我们希望根据推文的内文进行聚类。对我们来说,重要的是内容。这可以在结构的FullText字段中找到。在章节的后面,我们将看到我们如何可能使用推文的元数据,例如位置,来帮助更好地聚类推文。

如前几节所述,每个单独的推文都需要在某个高维空间中表示为一个坐标。因此,我们的目标是获取时间线中的所有推文,并预处理它们,以便我们得到以下输出表:

| Tweet ID | twitter | test | right | wrong |
 |:--------:|:------:|:----:|:----:|:---:|
 | 1 | 0 | 1 | 0 | 0 |
 | 2 | 1 | 0 | 0 | 0 |
 | 3 | 0 | 0 | 1 | 1 |

表中的每一行代表一个推文,通过推文 ID 进行索引。接下来的列是推文中存在的单词,通过其标题进行索引。因此,在第一行中,test出现在推文中,而twitterrightwrong没有出现。第一行中的数字切片[0 1 0 0]是我们对聚类算法所需的输入。

当然,表示推文中单词存在的二进制数字并不是最好的。如果使用单词的相对重要性会更有趣。再次,我们转向熟悉的 TF-IDF,它首次在第二章中介绍,线性回归 – 房价预测。更高级的技术,如使用词嵌入,也存在。但你会惊讶于像 TF-IDF 这样简单的东西可以表现得有多好。

到现在为止,这个过程应该很熟悉了——我们希望将文本表示为数字切片,而不是字节切片。为了做到这一点,我们需要某种类型的字典来将文本中的单词转换为 ID。从那里,我们可以构建表格。

再次,就像在 第二章 中,线性回归 – 房价预测,我们将采用简单的标记化策略。更高级的标记化器很棒,但对我们来说不是必需的。相反,我们将依赖古老的 strings.Field

处理器

在确定了我们的需求后,我们可以将它们组合成一个包含所需内容的单一数据结构。以下是处理器数据结构的外观:

 type processor struct {
 tfidf *tfidf.TFIDF
 corpus *corpus.Corpus
 locations map[string]int
 t transform.Transformer
 locCount int
 }

现在,忽略 locations 字段。我们将研究元数据在聚类中的用途。

要创建一个新的 processor,定义了以下函数:

 func newProcessor() *processor {
 c, err := corpus.Construct(corpus.WithWords([]string{mention, hashtag, retweet, url}))
 dieIfErr(err)
 return &processor{
 tfidf: tfidf.New(),
 corpus: c,
 locations: make(map[string]int),
 }
 }

在这里,我们看到一些有趣的决策。语料库是用一些特殊字符串构建的——mentionhashtagretweeturl。这些定义如下:

 const (
 mention = "<@MENTION>"
 hashtag = "<#HASHTAG>"
 retweet = "<RETWEET>"
 url = "<URL>"
 )

这部分设计的历史原因。很久以前,在 Twitter 支持转发作为动作之前,人们通过在推文前加上 RT 来手动转发推文。如果我们必须分析很久以前的数据(我们不会在本章中这样做),那么我们必须了解 Twitter 的历史设计。因此,你必须为此进行设计。

但是,构建包含特殊关键词的语料库意味着某些事情。它意味着在将推文的文本转换为一系列 ID 和数字、提及、hashtag、转发和 URL 时,它们都被视为相同的。它意味着我们并不真正关心 URL 是什么,或者谁被提及。然而,当涉及到 hashtag 时,这是一个有趣的情况。

常用 hashtag 来表示推文的主题。例如 #MeToo#TimesUp。hashtag 包含信息。将所有 hashtag 压缩成一个单一的 ID 可能没有用。这是我们稍后实验时需要注意的一个点。

说了这么多,以下是处理 *processedTweet 列表的方法。随着章节的进行,我们将重新访问和修改这个函数:

 func (p *processor) process(a []*processedTweet) {
 for _, tt := range a {
 for _, word := range strings.Fields(tt.FullText) {
 wordID, ok := p.single(word)
 if ok {
 tt.ids = append(tt.ids, wordID)
 }
if isRT(word) {
 tt.isRT = true
 }
 }
 p.tfidf.Add(tt)
 }
p.tfidf.CalculateIDF()
 // calculate scores
 for _, tt := range a {
 tt.textVec = p.tfidf.Score(tt)
 }
// normalize text vector
 size := p.corpus.Size()
 for _, tt := range a {
 tt.normTextVec = make([]float64, size)
 for i := range tt.ids {
 tt.normTextVec[tt.ids[i]] = tt.textVec[i]
 }
 }
 }

让我们逐行分析这个函数。

我们首先遍历所有的 *processedTweetsa[]*processedTweet 的原因——我们希望在过程中修改结构。如果 a[]processedTweet,那么我们就必须分配更多的空间,或者有复杂的修改方案。

每条推文由其 FullText 组成。我们想要从文本中提取每个单词,然后为每个单词分配一个 ID。为此,这是循环:

 for _, word := range strings.Fields(tt.FullText) {
 wordID, ok := p.single(word)
 if ok {
 tt.ids = append(tt.ids, wordID)
 }
 }

预处理单个单词

p.single 处理单个单词。它返回单词的 ID,以及是否将其添加到构成推文的单词列表中。它定义如下:

 func (p *processor) single(a string) (wordID int, ok bool) {
 word := strings.ToLower(a)
 if _, ok = stopwords[word]; ok {
 return -1, false
 }
 if strings.HasPrefix(word, "#") {
 return p.corpus.Add(hashtag), true
 }
 if strings.HasPrefix(word, "@") {
 return p.corpus.Add(mention), true
 }
 if strings.HasPrefix(word, "http://") {
 return p.corpus.Add(url), true
 }
 if isRT(word) {
 return p.corpus.Add(retweet), false
 }
return p.corpus.Add(word), true
 }

我们首先将单词转换为小写。这使得像 caféCafé 这样的单词等效。

说到café,如果有两条推文都提到了café,但一个用户写成café,另一个用户写成 cafe?当然,假设他们都指的是同一件事。我们需要某种归一化形式来告诉我们它们是相同的。

字符串归一化

首先,单词需要被归一化为NFKC形式。在第二章线性回归-房价预测中,这被介绍过,但我随后提到 LingSpam 基本上提供了归一化数据集。在现实世界的数据中,比如 Twitter,数据通常是杂乱的。因此,我们需要能够以苹果对苹果的方式比较它们。

为了展示这一点,让我们写一个辅助程序:

 package main
import (
 "fmt"
 "unicode"
"golang.org/x/text/transform"
 "golang.org/x/text/unicode/norm"
 )
func isMn(r rune) bool { return unicode.Is(unicode.Mn, r) }
func main() {
 str1 := "cafe"
 str2 := "café"
 str3 := "cafe\u0301"
 fmt.Println(str1 == str2)
 fmt.Println(str2 == str3) 
t := transform.Chain(norm.NFD, transform.RemoveFunc(isMn), norm.NFKC)
 str1a, _, _ := transform.String(t, str1)
 str2a, _, _ := transform.String(t, str2)
 str3a, _, _ := transform.String(t, str3)
fmt.Println(str1a == str2a)
 fmt.Println(str2a == str3a)
 }

首先要注意的是,至少有三种方式可以写出单词café,在这个演示中意味着咖啡馆。从前两个比较中很明显,这两个单词是不相同的。但既然它们意味着相同的事情,比较应该返回true

要做到这一点,我们需要将所有文本转换成一种形式,然后进行比较。为此,我们需要定义一个转换器:

t := transform.Chain(norm.NFD, transform.RemoveFunc(isMn), norm.NFKC)

这个转换器是一系列文本转换器的链,一个接一个地应用。

首先,我们将所有文本转换为它的分解形式,NFD。这将café转换为cafe\u0301

然后,我们移除任何非间隔符号。这会将cafe\u0301转换为cafe。这个移除函数是通过isMn函数完成的,定义如下:

func isMn(r rune) bool { return unicode.Is(unicode.Mn, r) }

最后,将所有内容转换为 NKFC 形式以实现最大兼容性和节省空间。现在这三个字符串都是相等的。

注意,这种比较是基于一个单一的假设:我们正在进行比较的语言是英语。法语中的Café意味着咖啡以及咖啡馆。这种去除重音符号的归一化,只要去除重音符号不会改变单词的意义,就可以工作。在处理多种语言时,我们需要在归一化方面更加小心。但在这个项目中,这是一个足够好的假设。

带着这些新知识,我们需要更新我们的processor类型:

 type processor struct {
 tfidf *tfidf.TFIDF
 corpus *corpus.Corpus
 transformer transformer.Transformer
 locations map[string]int
 locCount int
 }
func newProcessor() *processor {
 c, err := corpus.Construct(corpus.WithWords([]string{mention, hashtag, retweet, url}))
 dieIfErr(err)
t := transform.Chain(norm.NFD, transform.RemoveFunc(isMn), norm.NFKC)
 return &processor{
 tfidf: tfidf.New(),
 corpus: c,
 transformer: t,
 locations: make(map[string]int),
 }
 }

我们p.single函数的第一行也需要改变,从以下内容变为:

 func (p *processor) single(a string) (wordID int, ok bool) {
 word := strings.ToLower(a)

它将变成这样:

 func (p *processor) single(a string) (wordID int, ok bool) {
 word, _, err := transform.String(p.transformer, a)
 dieIfErr(err)
 word = strings.ToLower(word)

如果你感觉特别勤奋,尝试将strings.ToLower转换为transform.Transformer。这比你想象的要难,但也没有你想象的那么难。

预处理停用词

关于归一化就说到这里。我们现在将注意力转向stopwords

回想一下第二章线性回归-房价预测,停用词是一些像thetherefrom这样的词。它们是连接词,有助于理解句子的特定上下文,但对于简单的统计分析来说,它们通常只会增加噪音。因此,我们必须移除它们。

对停用词的检查很简单。如果一个词匹配stopwords,我们将返回false以确定是否将词 ID 添加到句子中:

if _, ok = stopwords[word]; ok {
 return -1, false
 }

停用词列表从哪里来?这很简单,我就在stopwords.go中写了这个:

const sw = `a about above across after afterwards again against all almost alone along already also although always am among amongst amoungst amount an and another any anyhow anyone anything anyway anywhere are around as at back be became because become becomes becoming been before beforehand behind being below beside besides between beyond bill both bottom but by call can cannot can't cant co co. computer con could couldnt couldn't cry de describe detail did didn didn't didnt do does doesn doesn't doesnt doing don done down due during each eg e.g eight either eleven else elsewhere empty enough etc even ever every everyone everything everywhere except few fifteen fify fill find fire first five for former formerly forty found four from front full further get give go had has hasnt hasn't hasn have he hence her here hereafter hereby herein hereupon hers herself him himself his how however hundred i ie i.e. if in inc indeed interest into is it its itself just keep kg km last latter latterly least less ltd made make many may me meanwhile might mill mine more moreover most mostly move much must my myself name namely neither never nevertheless next nine no nobody none noone nor not nothing now nowhere of off often on once one only onto or other others otherwise our ours ourselves out over own part per perhaps please put quite rather re really regarding same say see seem seemed seeming seems serious several she should show side since sincere six sixty so some somehow someone something sometime sometimes somewhere still such system take ten than that the their them themselves then thence there thereafter thereby therefore therein thereupon these they thick thin third this those though three through throughout thru thus to together too top toward towards twelve twenty two un under unless until up upon us used using various very via was we well were what whatever when whence whenever where whereafter whereas whereby wherein whereupon wherever whether which while whither who whoever whole whom whose why will with within without would yet you your yours yourself yourselves`
var stopwords = make(map[string]struct{})
func init() {
 for _, s := range strings.Split(sw, " ") {
 stopwords[s] = struct{}{}
 }
 }

就这样!内容看起来像这样的推文——一天一个苹果,医生远离我——会有appledaydoctoraway的 ID。

停用词列表是从lingo包中使用的列表改编而来的。lingo包中的停用词列表是用来在词干化的单词上使用的。因为我们没有进行词干化,所以一些词是手动添加的。它并不完美,但足够满足我们的目的。

预处理推特实体

在我们移除了停用词之后,就到了处理特殊的推特实体的时候了:

 if strings.HasPrefix(word, "#") {
 return p.corpus.Add(hashtag), true
 }
 if strings.HasPrefix(word, "@") {
 return p.corpus.Add(mention), true
 }
 if strings.HasPrefix(word, "http://") {
 return p.corpus.Add(url), true
 }

这些都很直接。

如果一个词以"#"开头,那么它是一个标签。我们可能稍后会回到这个话题,所以记住这一点是好的。

任何以"@"开头的词都是提及。这有点棘手。有时,人们会发推文说诸如I am @PlaceName这样的话,表示一个地点,而不是提及一个用户(实际上,可能会发现@PlaceName并不存在)。或者,人们可能会发推文说I am @ PlaceName。在这种情况下,单独的"@"仍然会被视为提及。我发现对于前者(@PlaceName),将这个词视为提及并没有太大的关系。Twitter 的 API 确实会返回一个提及列表,你可以对其进行检查。但对我来说,这只是一个不必要的额外工作。所以,把这当作一个加分项目——检查 API 返回的提及列表。

当然,我们不应该那么懒惰,把所有事情都留给加分项目;可以做一些简单的检查——如果@是单独的,那么我们不应该将其视为提及。它应该被视为at

现在,我们检查 URL。行if strings.HasPrefix(word, "http://")检查http://前缀。这并不好。这没有考虑到使用https方案的 URL。

现在我们知道了如何修改这段代码。它看起来是这样的:

 switch {
 case strings.HasPrefix(word, "#"):
 return p.corpus.Add(hashtag), true
 case strings.HasPrefix(word, "@"):
 if len(word) == 0 {
 return p.corpus.Add("at"), true
 }
 return p.corpus.Add(mention), true
 case strings.HasPrefix(word, "http"):
 return p.corpus.Add(url), true
 }

最后,添加了一行代码来处理在 Twitter 支持转发之前的历史推文:

 if word == "rt" {
 return p.corpus.Add(retweet), false
 }

处理单个推文

考虑以下代码片段:

 for _, tt := range a {
 for _, word := range strings.Fields(tt.FullText) {
 wordID, ok := p.single(word)
 if ok {
 tt.ids = append(tt.ids, wordID)
 }
if word == "rt" {
 tt.isRT = true
 }
 }
 p.tfidf.Add(tt)
 }

它的意思是在我们预处理了每个单词之后,我们只需简单地将该单词添加到 TFIDF 中。

聚类

这个项目的目的是清理我需要阅读的推文数量。如果有 100 条推文的阅读预算,我不想阅读 50 条同一主题的推文;它们可能代表不同的观点,但一般来说,对于浏览目的,它们与我感兴趣的不相关。聚类为这个问题提供了一个很好的解决方案。

首先,如果推文被聚类,同一主题的 50 条推文将被分组在同一个聚类中。这样,如果我想深入研究,我可以这样做。否则,我可以跳过这些推文并继续。

在这个项目中,我们希望使用 K-means。为此,我们将使用 Marcin Praski 的clusters库。要安装它,只需运行go get -u github.com/mpraski/clusters。这是一个好的库,它内置了多个聚类算法。我之前介绍了 K-means,但我们还将使用 DBSCAN。

最后,我们将使用 DMMClust 算法进行比较。DMMClust 算法位于不同的库中。要安装它,只需运行go get -u github.com/go-nlp/dmmclust。DMMClust 的目的使用创新的过程对小型文本进行聚类。

K-means 聚类

回顾一下,到目前为止我们做了什么——我们将来自主页时间线的推文列表中的每条推文处理成float64的切片。这些代表高维空间中的坐标。现在,我们只需要做以下事情:

  1. 创建一个聚类器。

  2. 创建一个[][]float64来表示时间线上的所有推文。

  3. 训练聚类器。

  4. 预测每条推文属于哪个聚类。

可以这样做:

 func main() {
 tweets := mock()
 p := newProcessor()
 p.process(tweets)
// create a clusterer
 c, err := clusters.KMeans(10000, 25, clusters.EuclideanDistance)
 dieIfErr(err)
data := asMatrix(tweets)
 dieIfErr(c.Learn(data))clusters := c.Guesses()
 for i, clust := range clusters{
 fmt.Printf("%d: %q\n", clust, tweets[i].FullText)
 }
 }

惊讶吗?让我们来分析一下。

前几行是用于处理tweets的:

 tweets := mock()
 p := newProcessor()
 p.process(tweets)

然后我们创建一个聚类器:

 // create a clusterer
 c, err := clusters.KMeans(10000, 25, clusters.EuclideanDistance)
 dieIfErr(err)

这里,我们说我们想要一个 K-means 聚类器。我们将对数据进行 10,000 次训练,并希望找到 25 个聚类,使用EuclideanDistance方法计算距离。欧几里得距离是标准的距离计算方法,与你在 K-means 部分之前的练习中计算两点之间距离的方法相同。还有其他计算距离的方法,它们更适合文本数据。在本章的后面部分,我将向你展示如何创建一个距离函数,即 Jaccard 距离,当用于文本时,它比欧几里得距离要好得多。

在创建聚类器之后,我们需要将我们的tweets列表转换为矩阵。然后我们训练聚类器:

 data := asMatrix(tweets)
 dieIfErr(c.Learn(data))

最后,我们显示clusters

 clusters := c.Guesses()
 for i, clust := range clusters{
 fmt.Printf("%d: %q\n", clust, tweets[i].FullText)
 }

DBSCAN 聚类

使用 Marcin 的包进行 DBSCAN 聚类同样简单。实际上,你只需要更改一行代码,如下所示:

c, err := clusters.KMeans(10000, 25, clusters.EuclideanDistance)

你需要将其改为这样:

c, err := clusters.DBSCAN(eps, minPts, clusters.EuclideanDistance)

当然,现在的问题是epsminPts应该取什么值?

eps代表两个点被认为是邻居所需的最小距离。minPts是形成密集聚类的最小点数。让我们先讨论eps

我们如何知道最佳的距离是多少?通常,一个好的方法是通过可视化数据来找出答案。事实上,这正是 DBSCAN 算法的原始发明者所建议的。但我们究竟要可视化什么呢?

我们想要可视化推文之间的距离。给定一个数据集,我们可以计算一个看起来像这样的距离矩阵:

| | A | B | C | ... |
 |--|--|--|--|--|--|
 | A | | | | |
 | B | | | | |
 | C | | | | |
 | ... | | | | |

为了做到这一点,我们编写了以下函数:

 func knn(a [][]float64, k int, distance func(a, b []float64) float64) ([][]float64, []float64) {
 var distances [][]float64
 for _, row := range a {
 var dists []float64
 for _, row2 := range a {
 dist := distance(row, row2)
 dists = append(dists, dist)
 }
 sort.Sort(sort.Float64Slice(dists))
 topK := dists[:k]
 distances = append(distances, topK)
 }
 var lastCol []float64
 for _, d := range distances {
 l := d[len(d)-1]
 lastCol = append(lastCol, l)
 }
 sort.Sort(sort.Float64Slice(lastCol))
 return distances, lastCol
 }

这个函数接受一个浮点数矩阵;每一行代表一条推文,并找到最接近的 k 个邻居。让我们来分析一下这个算法。在我们分析算法的过程中,请记住每一行代表一条推文;因此,你可以将每一行想象成一个非常复杂的坐标。

我们想要做的第一件事是找到一条推文与另一条推文之间的距离,因此有以下的代码块:

 var distances [][]float64
 for _, row := range a {
 var dists []float64
 for _, row2 := range a {
 dist := distance(row, row2)
 dists = append(dists, dist)
 }

特别值得注意的是两个表达式for _, row := range afor _, row2 := range a。在一个普通的 KNN 函数中,你会有两个矩阵,ab,你会在a中的推文和b中的推文之间找到距离。但为了绘制这张图表的目的,我们将比较同一数据集中的推文。

一旦我们获得了所有的距离,我们想要找到最近的邻居,所以我们排序列表,然后将它们放入距离矩阵中:

 sort.Sort(sort.Float64Slice(dists))
 topK := dists[:k]
 distances = append(distances, topK)

这,以非常快捷的方式,就是如何进行 K 近邻算法。当然,这并不是最有效的方法。我在这里展示的算法是O(n²)。当然有更好的方法来做这件事,但出于这个项目的目的,这已经足够了。

然后,我们抓取矩阵的最后一列并对其进行排序。这是我们想要绘制的。绘图代码与之前章节中看到的不太一样。我将在这里提供它,不再进一步解释如何使用它:

 func plotKNNDist(a []float64) plotter.XYs {
 points := make(plotter.XYs, len(a))
 for i, val := range a {
 points[i].X = float64(i)
 points[i].Y = val
 }
 return points
 }

当我绘制真实的 Twitter 数据以确定理想的eps值时,我得到了以下输出:

图片

你想要找到的是图片中的“肘部”或“膝盖”。不幸的是,正如你可以看到的,有很多这样的点。这将使得使用 DBSCAN 算法进行聚类变得困难。这意味着数据相当嘈杂。

其中一个特别重要的事情是使用的距离函数。我将在后续的章节中进一步介绍如何调整程序。

使用 DMMClust 进行聚类

在我的 Twitter 主频道的距离图让我有些气馁之后,我寻找了另一种聚类推文的方法。为此,我使用了dmmclust库(我是其主要作者)。DMMClust 算法的目的在于它能够很好地处理小文本。事实上,它是为了处理小文本的问题而编写的。

什么是小文本?大多数文本聚类研究都是在大量单词的文本上进行的。直到最近,Twitter 只支持 140 个字符。正如你可以想象的,140 个字符作为人类语言传递的信息量并不多。

DMMClust 算法的工作方式非常类似于学生加入高中社交俱乐部。想象一下推文就像一群学生。每个学生随机加入一个社交俱乐部。在每一个社交俱乐部中,他们可能喜欢俱乐部的其他成员,或者他们可能不喜欢。如果他们不喜欢小组中的人,他们被允许更换社交俱乐部。这会一直发生,直到所有俱乐部都有最喜欢彼此的人,或者直到迭代次数用完。

简而言之,这就是 DMMClust 算法的工作原理。

真实数据

到目前为止,我们一直在处理 Twitter 文档提供的示例JSON。我假设您现在已经有了 Twitter API 访问权限。那么,让我们获取真实的 Twitter 数据吧!

要从开发者门户获取您的 API 密钥,请点击“开始”链接。您将来到如下页面:

图片

选择“创建应用”。您将被带到如下页面:

图片

我之前创建了一个 Twitter 应用很久以前(它具有与我们在本项目创建的应用非常相似的功能);因此,我已经在那里有一个应用了。点击右上角的蓝色“创建应用”按钮。您将被带到以下表单:

图片

填写表格后点击提交。可能需要几天时间您才会收到一封邮件,告知您的应用已获批准开发。请确保在描述中保持真实。最后,您应该能够点击进入您的应用,并看到以下页面,其中显示了您的 API 密钥和密钥:

图片

点击“创建”以创建您的访问令牌和访问令牌密钥。您将需要它们。

现在我们有了 API 访问密钥,这是使用 Anaconda 包访问 Twitter 的方法:

 const (
 ACCESSTOKEN = "_____"
 ACCESSTOKENSECRET = "______"
 CONSUMERKEY = "_____"
 CONSUMERSECRET = "_______"
 )
func main() {
 twitter := anaconda.NewTwitterApiWithCredentials(ACCESSTOKEN, ACCESSTOKENSECRET, CONSUMERKEY, CONSUMERSECRET)
 raw, err := twitter.GetHomeTimeline(nil)
f, err := os.OpenFile("dev.json", os.O_TRUNC|os.O_WRONLY|os.O_CREATE, 0644)
 dieIfErr(err)
 enc := json.NewEncoder(f)
 enc.Encode(raw)
 f.Close()
 }

初看,这段代码有点奇怪。让我们逐行分析代码。前六行处理访问令牌和密钥。显然,它们不应该被硬编码。处理这类秘密的一个好方法是将它们放入环境变量中。我将把这留作读者的练习。我们将继续分析代码的其余部分:

 twitter := anaconda.NewTwitterApiWithCredentials(ACCESSTOKEN, ACCESSTOKENSECRET, CONSUMERKEY, CONSUMERSECRET)
 raw, err := twitter.GetHomeTimeline(nil)

这两行代码使用 Anaconda 库获取主时间线中找到的推文。传入的nil可能值得关注。为什么会这样做呢?GetHomeTimeline方法接受一个url.Values映射。该包可以在标准库中找到,作为net/urlValues定义如下:

type Values map[string][]string

但这些值代表什么呢?实际上,您可以向 Twitter API 传递一些参数。参数及其作用在此列举:developer.twitter.com/en/docs/tweets/timelines/api-reference/get-statuses-home_timeline。我不想限制任何东西,所以传入nil是可以接受的。

结果是 []anaconda.Tweet,所有内容都整齐地打包供我们使用。因此,以下几行相当奇怪:

 f, err := os.OpenFile("dev.json", os.O_TRUNC|os.O_WRONLY|os.O_CREATE, 0644)
 dieIfErr(err)
 enc := json.NewEncoder(f)
 enc.Encode(raw)
 f.Close()

为什么我想将其保存为 JSON 文件?答案很简单——当使用机器学习算法时,你可能需要调整算法。将请求保存为 JSON 文件有两个目的:

  • 它允许保持一致性。在积极开发中,你可能会大量调整算法。如果 JSON 文件不断变化,你怎么知道是调整带来了改进,而不是因为 JSON 的变化?

  • 成为一名好公民。Twitter 的 API 有速率限制。这意味着你不能反复多次请求相同的内容。在测试和调整机器学习算法时,你可能会不得不反复处理你的数据。与其不断敲打 Twitter 服务器,你更应该成为一名好公民,并使用本地缓存的副本。

我们之前定义了 load。再次强调,我们将看到它在调整算法方面的用途。

程序

一旦我们这样做,我们可以将之前的 main() 移入不同的函数中,再次为我们留下一个空白的 main() 画布。我们现在准备好程序的实质性内容。这是一个骨架程序。鼓励你在编写这个程序时实际积极地修改程序:

func main() {
 f, err := os.Open("dev.json")
 dieIfErr(err)
 tweets := load(f)
 p := newProcessor()
 tweets = p.process(tweets)
expC := 20
 distances, last := knn(asMatrix(tweets), expC, clusters.EuclideanDistance)
 log.Printf("distances %v | %v", distances, last)
// plot for DBSCAN elbows
 plt, err := plot.New()
 dieIfErr(err)
 plotutil.AddLinePoints(plt, "KNN Distance", plotKNNDist(last))
 plt.Save(25*vg.Centimeter, 25*vg.Centimeter, "KNNDist.png")
// actually do the clustering
 dmmClust := dmm(tweets, expC, p.corpus.Size())
 kmeansClust := kmeans(tweets, expC)
 dbscanClust, clustCount := dbscan(tweets)
// print output
 log.Printf("len(tweets)%d", len(tweets))
 var buf bytes.Buffer
bc := byClusters2(dmmClust, expC)
 lc, tweetCount := largestCluster2(dmmClust)
 fmt.Fprintf(&buf, "Largest Cluster %d - %d tweets\n", lc, tweetCount)
 for i, t := range bc {
 fmt.Fprintf(&buf, "CLUSTER %d: %d\n", i, len(t))
 for _, c := range t {
 fmt.Fprintf(&buf, "\t%v\n", tweets[c].clean2)
 }
 }
 fmt.Fprintf(&buf, "==============\n")
 bc2 := byClusters(kmeansClust, expC)
 for i, t := range bc2 {
 fmt.Fprintf(&buf, "CLUSTER %d: %d\n", i, len(t))
 for _, c := range t {
 fmt.Fprintf(&buf, "\t%v\n", tweets[c].clean2)
 }
 }
 fmt.Fprintf(&buf, "==============\n")
 bc3 := byClusters(dbscanClust, clustCount)
 for i, t := range bc3 {
 fmt.Fprintf(&buf, "CLUSTER %d: %d\n", i, len(t))
 for _, c := range t {
 fmt.Fprintf(&buf, "\t%v\n", tweets[c].clean2)
 }
 }
log.Println(buf.String())
 }

我还有一些实用函数尚未向你展示。现在是时候定义它们了:

 func dmm(a []*processedTweet, expC int, corpusSize int) []dmmclust.Cluster {
 conf := dmmclust.Config{
 K: expC,
 Vocabulary: corpusSize,
 Iter: 1000,
 Alpha: 0.0,
 Beta: 0.01,
 Score: dmmclust.Algorithm4,
 Sampler: dmmclust.NewGibbs(rand.New(rand.NewSource(1337))),
 }
 dmmClust, err := dmmclust.FindClusters(toDocs(a), conf)
 dieIfErr(err)
 return dmmClust
 }
func kmeans(a []*processedTweet, expC int) []int {
 // create a clusterer
 kmeans, err := clusters.KMeans(100000, expC, clusters.EuclideanDistance)
 dieIfErr(err)
 data := asMatrix(a)
 dieIfErr(kmeans.Learn(data))
 return kmeans.Guesses()
 }
func dbscan(a []*processedTweet) ([]int, int) {
 dbscan, err := clusters.DBSCAN(5, 0.965, 8, clusters.EuclideanDistance)
 dieIfErr(err)
 data := asMatrix(a)
 dieIfErr(dbscan.Learn(data))
 clust := dbscan.Guesses()
counter := make(map[int]struct{})
 for _, c := range clust {
 counter[c] = struct{}{}
 }
 return clust, len(counter)
 }
func largestCluster(clusters []int) (int, int) {
 cc := make(map[int]int)
 for _, c := range clusters {
 cc[c]++
 }
var retVal, maxVal int
for k, v := range cc {
 if v > maxVal {
 retVal = k
 maxVal = v
 }
 }
 return retVal, cc[retVal]
 }
func largestCluster2(clusters []dmmclust.Cluster) (int, int) {
 cc := make(map[int]int)
 for _, c := range clusters {
 cc[c.ID()]++
 }
var retVal, maxVal int
for k, v := range cc {
 if v > maxVal {
 retVal = k
 maxVal = v
 }
 }
 return retVal, cc[retVal]
 }
func byClusters(a []int, expectedClusters int) (retVal [][]int) {
 if expectedClusters == 0 {
 return nil
 }
 retVal = make([][]int, expectedClusters)
 var i, v int
 defer func() {
 if r := recover(); r != nil {
 log.Printf("exp %v | %v", expectedClusters, v)
 panic(r)
 }
 }()
 for i, v = range a {
 if v == -1 {
 // retVal[0] = append(retVal[0], i)
 continue
 }
 retVal[v-1] = append(retVal[v-1], i)
 }
 return retVal
 }
func byClusters2(a []dmmclust.Cluster, expectedClusters int) (retVal [][]int) {
 retVal = make([][]int, expectedClusters)
 for i, v := range a {
 retVal[v.ID()] = append(retVal[v.ID()], i)
 }
 return retVal
 }

这些是一些可能在 utils.go 中找到的实用函数。它们主要帮助调整程序。现在通过输入 go run *.go 来运行程序。

调整程序

如果你一直跟到现在,你可能从所有聚类算法中得到非常差的结果。我想提醒你,这本书的一般目标通常是传授在 Go 中进行数据科学的感觉。在大多数情况下,我提倡一种可以描述为深入思考问题,然后写下答案的方法。但现实是,通常需要试错。

在我的 Twitter 主时间线中对我有效的解决方案可能对你不起作用。例如,这段代码在朋友的 Twitter 动态上运行良好。为什么是这样?他关注了很多同时谈论相似话题的相似的人。在我自己的 Twitter 主时间线中聚类推文要困难一些。我关注的人群多样化。我关注的人没有固定的发推时间表,并且通常不与其他 Twitter 用户互动。因此,推文本身就已经非常多样化了。

正是出于这个考虑,我鼓励你实验并调整你的程序。在接下来的小节中,我将概述对我有效的方法。这可能对你不起作用。

调整距离

到目前为止,我们一直在使用由 Marcin 库提供的欧几里得距离。欧几里得距离的计算方法如下:

$ EuclideanDistance(\mathbf{q},\mathbf{p}) = \sqrt{\sum_{i=1}^n (q_i-p_i)²}.$

EuclideanDistance是处理笛卡尔空间中的坐标时一个好的度量标准。确实,我之前曾将推文比作空间中的一组坐标,以解释 K-means 和 DBSCAN。但现实是文本文档并不真正位于笛卡尔空间中。你可以将它们视为位于笛卡尔空间中,但它们并不严格如此。

因此,让我介绍另一种类型的距离,它更适合处理我们在当前设置中使用的词袋模型中的文本元素,即 Jaccard 距离。

Jaccard 距离定义为以下:

$ d_J(A,B) = 1 - J(A,B) = { { |A \cup B| - |A \cap B| } \over |A \cup B| } $

在这里,$A$$B$是每个推文中的单词集合。Go 语言中 Jaccard 距离的实现是基础的,但它是有效的:

 func jaccard(a, b []float64) float64 {
 setA, setB := make(map[int]struct{}), make(map[int]struct{})
 union := make(map[int]struct{})
 for i := range a {
 if a[i] != 0 {
 union[i] = struct{}{}
 setA[i] = struct{}{}
 }
 }
for i := range b {
 if b[i] != 0 {
 union[i] = struct{}{}
 setB[i] = struct{}{}
 }
 }
intersection := 0.0
 for k := range setA {
 if _, ok := setB[k]; ok {
 intersection++
 }
 }
return 1 - (intersection / float64(len(union)))
 }

调整预处理步骤

你可能注意到,推文的预处理非常简单,其中一些规则很奇怪。例如,所有哈希标签都被视为一个,所有链接和提及也是如此。当这个项目开始时,这似乎是一个合理的理由。没有其他理由比这更合理;在任何项目中,总是需要一个跳板来开始。在那个阶段,一个薄弱的借口和其他借口一样好。

尽管如此,我还是调整了我的预处理步骤。这些是我最终确定下来的函数。请注意,这与之前章节中列出的原始版本之间的差异:

 var nl = regexp.MustCompile("\n+")
 var ht = regexp.MustCompile("&.+?;")
func (p *processor) single(word string) (wordID int, ok bool) {
 if _, ok = stopwords[word]; ok {
 return -1, false
 }
 switch {
 case strings.HasPrefix(word, "#"):
 word = strings.TrimPrefix(word, "#")
 case word == "@":
 return -1, false // at is a stop word!
 case strings.HasPrefix(word, "http"):
 return -1, false
 }
if word == "rt" {
 return -1, false
 }
return p.corpus.Add(word), true
 }
func (p *processor) process(a []*processedTweet) []*processedTweet {
 // remove things from consideration
 i := 0
 for _, tt := range a {
 if tt.Lang == "en" {
 a[i] = tt
 i++
 }
 }
 a = a[:i]
var err error
 for _, tt := range a {
 if tt.RetweetedStatus != nil {
 tt.Tweet = *tt.RetweetedStatus
 }
tt.clean, _, err = transform.String(p.transformer, tt.FullText)
 dieIfErr(err)
 tt.clean = strings.ToLower(tt.clean)
 tt.clean = nl.ReplaceAllString(tt.clean, "\n")
 tt.clean = ht.ReplaceAllString(tt.clean, "")
 tt.clean = stripPunct(tt.clean)
 log.Printf("%v", tt.clean)
 for _, word := range strings.Fields(tt.clean) {
 // word = corpus.Singularize(word)
 wordID, ok := p.single(word)
 if ok {
 tt.ids = append(tt.ids, wordID)
 tt.clean2 += " "
 tt.clean2 += word
 }
if word == "rt" {
 tt.isRT = true
 }
 }
 p.tfidf.Add(tt)
 log.Printf("%v", tt.clean2)
 }
p.tfidf.CalculateIDF()
 // calculate scores
 for _, tt := range a {
 tt.textVec = p.tfidf.Score(tt)
 }
// normalize text vector
 size := p.corpus.Size()
 for _, tt := range a {
 tt.normTextVec = make([]float64, size)
 for i := range tt.ids {
 tt.normTextVec[tt.ids[i]] = tt.textVec[i]
 }
 }
 return a
 }
func stripPunct(a string) string {
 const punct = ",.?;:'\"!’*-“"
 return strings.Map(func(r rune) rune {
 if strings.IndexRune(punct, r) < 0 {
 return r
 }
 return -1
 }, a)
 }

我所做的最显著的变化是,现在我将哈希标签视为一个单词。提及被移除。至于 URL,在一次尝试聚类时,我意识到聚类算法将所有包含 URL 的推文聚到了同一个簇中。这个认识让我移除了哈希标签、提及和 URL。哈希标签的#被移除,并被视为普通单词。

此外,你可能注意到我添加了一些快速且简单的方法来清理某些事物:

 tt.clean = strings.ToLower(tt.clean)
 tt.clean = nl.ReplaceAllString(tt.clean, "\n")
 tt.clean = ht.ReplaceAllString(tt.clean, "")
 tt.clean = stripPunct(tt.clean)

在这里,我使用了正则表达式将多个换行符替换为一个,并将所有 HTML 编码的文本替换为空。最后,我移除了所有标点符号。

在更正式的设置中,我会使用一个合适的词法分析器来处理我的文本。我会使用的词法分析器来自 Lingo(github.com/chewxy/lingo)。但鉴于 Twitter 是一个低价值环境,这样做并没有太多意义。Lingo 中的合适词法分析器会将文本标记为多个类别,从而便于移除。

另一件你可能注意到的事情是,我在中途改变了关于推文的定义:

 if tt.RetweetedStatus != nil {
 tt.Tweet = *tt.RetweetedStatus
 }

这段代码表示如果一条推文确实是一条转发的状态,则用转发的推文替换该推文。这对我是有效的。但可能对你来说不一定有效。我个人认为任何转发都等同于重复一条推文。因此,我认为它们不应该分开。此外,Twitter 允许用户对转发进行评论。如果你想包含这些评论,你可能需要稍微改变一下逻辑。无论如何,我是通过手动检查我保存的JSON文件来达到这个结果的。

它是询问这些问题,然后做出判断,确定在数据科学中什么重要,无论是使用 Go 语言还是任何其他语言。这并不是盲目地应用算法。相反,它始终由数据告诉你的信息所驱动。

最后一点你可能注意到的是这个奇怪的代码块:

 // remove things from consideration
 i := 0
 for _, tt := range a {
 if tt.Lang == "en" {
 a[i] = tt
 i++
 }
 }
 a = a[:i]

在这里,我只考虑英文推文。我关注许多使用各种语言发推的人。在任何给定的时间,我的主页时间线大约有 15%的推文是法语、中文、日语或德语。对不同语言的推文进行聚类是完全不同的游戏,所以我选择省略它们。

摘要

在本章中,我们学习了如何使用各种聚类方法对推文进行聚类。尽管经常被吹捧为最稳健的算法之一,但我们已经表明,由于推文的本质是嘈杂的,DBSCAN 在聚类推文时存在问题。相反,我们发现,较老的传统方法以及一种新的聚类方法会得到更好的结果。

这指出了一个教训——没有一种机器学习算法可以统治一切;没有终极算法。相反,我们需要尝试不止一种方法。在接下来的章节中,这个主题将更加明显,我们将以更严谨的态度来处理这些问题。在下一章中,我们将学习神经网络的基础知识,并将它们应用于手写数字的识别。

第六章:神经网络 - MNIST 手写识别

想象一下,你是一名邮递员。你的工作就是递送信件。大多数时候,收件人的姓名和地址都会打印出来,并且非常清晰,你的工作就变得相当简单。但是到了感恩节和圣诞节,带有手写地址的信封数量会增加,因为人们会添加个人风格和装饰。坦白说,有些人(包括我)的书写实在糟糕。

如果你必须责怪学校不再强调书写,那么问题仍然存在:书写难以阅读和理解。上帝保佑,如果你不得不递送一封由医生手写的信(祝你好运!)。

想象一下,如果你构建了一个机器学习系统,可以让你阅读手写文字。这正是我们将在本章和下一章中要做的事情;我们将构建一种称为人工神经网络的机器学习算法,在下一章中,我们将通过深度学习来扩展这一概念。

在本章中,我们将学习神经网络的基础知识,了解它是如何受到生物神经元启发的,找到更好的表示方法,并最终将神经网络应用于手写识别数字。

一个神经网络

在现代用语中,神经网络这个术语可以指两种不同的事物。第一种指的是你大脑中发现的神经元网络。这些神经元形成特定的网络和路径,对你理解这个句子至关重要。该术语的第二种含义指的是人工神经网络;即我们在软件中构建来模拟大脑中神经网络的实体。

当然,这导致了生物神经网络和人工神经网络之间非常多的不幸比较。为了理解原因,我们必须从开始讲起。

从现在开始,我将使用英国式的拼写来表示神经元,以表示真实的神经元细胞,而美国式的拼写,neuron,将保留用于人工变体。

下面的图示是一个神经元:

图片

通常,一个神经元由细胞体(包含其核的细胞的一般部分)、一个可选的、被一种称为髓鞘的脂肪组织覆盖的轴突和树突组成。后两个组成部分(轴突和树突)特别有趣,因为它们共同形成了一个称为突触的结构。具体来说,是轴突的末端(即突触)形成了这样的突触。

哺乳动物大脑中的绝大多数突触位于轴突末端和树突之间。信号的典型流动(化学或电脉冲)从一个神经元开始,沿着轴突传播,并将其信号沉积到下一个神经元上。

图片

在上面的图像中,我们有三个神经元,标记为 A、B 和 C。想象一下 A 从外部来源(比如你的眼睛)接收一个信号。它接收到的信号足够强,以至于它可以通过轴突传递,通过突触接触到 B 的树突。B 接收信号并决定它不值得将信号传递给 C,所以 B 的轴突没有信号传递。

因此,我们现在将探讨如何模拟这一点。

模拟神经网络

让我们简化一下前面的神经网络图:

图片

我们会用一个圆圈来代表神经元的主体,我们称之为神经元。神经元的“树突”接收来自其他神经元(未显示)的输入并将所有输入加起来。每个输入代表来自另一个神经元的输入;所以,如果你看到三个输入,这意味着这个神经元连接了三个其他神经元。

如果输入的总和超过一个阈值值,那么我们可以说神经元“放电”或被激活。这模拟了实际神经元的激活潜力。为了简单起见,让我们假设如果它放电,则输出将是 1;否则,将是 0。以下是它在 Go 代码中的良好模拟:

func neuron(threshold int, inputs ...int) int {
  var total int
  for _, in := range inputs {
    total += in
  }
  if total > threshold {
    return 1
  }
  return 0
}

这通常被称为感知器,如果你对神经元工作原理的了解还停留在 20 世纪 40 年代和 50 年代,那么它就是对神经元工作原理的忠实模拟。

这里有一个相当有趣的故事:当我写这个部分的时候,King Princess 的 1950 开始在背景中播放,我觉得想象自己在 20 世纪 50 年代开发感知器是非常合适的。仍然存在一个问题:我们迄今为止模拟的人工网络无法学习!它是被编程去做输入告诉它做的事情。

“人工神经网络学习”究竟意味着什么?在 20 世纪 50 年代的神经科学中出现了一个想法,称为赫布规则,可以简要概括为:“一起放电的神经元会一起生长”。这引发了一个想法,即某些突触更厚;因此,它们有更强的连接,而其他突触更薄;因此,它们有较弱的连接。

为了模拟这一点,我们需要引入加权值的概念,其权重对应于来自另一个神经元的输入强度。以下是这个想法的一个很好的近似:

func neuron(threshold, weights, inputs []int) int {
  if len(weights) != len(inputs) {
    panic("Expected length of weights to be the same as the length of inputs")
  }
  var total int 
  for i, in := range inputs {
    total += weights[i]*in
  }
  if total > threshold {
    return 1
  }
  return 0
}

到这一点,如果你熟悉线性代数,你可能会想到total本质上是一个向量积。你会完全正确。此外,如果阈值是 0,那么你只是应用了一个heaviside阶跃函数:

func heaviside(a float64) float64 {
  if a >= 0 {
    return 1
  }
  return 0
}

换句话说,我们可以用以下方式总结一个单个神经元:

func neuron(weights, inputs []float64) float64 {
  return heaviside(vectorDot(weights, inputs))
}

注意在最后两个例子中,我从int切换到了更标准的float64。要点仍然是:单个神经元只是一个应用于向量积的函数。

单个神经元并没有做什么。但是将它们堆叠起来,并按层排列,然后突然它们开始做更多的事情:

现在我们来到需要概念飞跃的部分:如果一个神经元本质上只是一个向量积,堆叠神经元就简单地使其成为一个矩阵!

给定一个图像可以表示为一个 float64 的平面切片,vectorDot 函数被 matVecMul 函数替换,这是一个将矩阵和向量相乘以返回向量的函数。我们可以这样写一个表示神经层的函数:

func affine(weights [][]float64, inputs []float64) []float64 {
  return activation(matVecMul(weights, inputs))
}

线性代数 101

我想绕道谈谈线性代数。到目前为止,这本书中已经提到了很多,尽管它并没有被明确提及。事实上,线性代数是我们迄今为止所做每一章的基础。

想象你有两个方程:

假设 分别是 ,我们可以现在这样写出以下方程:

我们可以使用基本的代数来解它(请自己动手计算):

如果你有三个、四个或五个联立方程呢?计算这些值开始变得繁琐。相反,我们发明了一种新的符号:矩阵符号,这将使我们能够更快地解联立方程。

它在没有名字的情况下使用了大约 100 年(它最初被詹姆斯·西尔维斯特称为“矩阵”),并且直到 1858 年亚瑟·凯莱将这些规则形式化之前,一直在使用正式的规则。尽管如此,将方程的一部分组合在一起作为一个整体的想法已经被长期使用。

我们首先将方程“分解”成它们的各个部分:

水平线表示这是两个不同的方程,而不是它们是比例关系。当然,我们意识到我们已经重复得太多了,所以我们简化了 的矩阵。

在这里,你可以看到 只被写了一次。我们刚才写的方式相当不整洁,所以我们用这种方式来写得更整洁:

不仅我们这样写,我们还给出了如何阅读这种符号的具体规则:

我们应该给矩阵命名,这样我们以后可以引用它们:

粗体表示变量持有多个值。大写表示矩阵 (),小写表示向量 (  和 )。这是为了与只持有单个值的标量变量(通常不使用粗体)区分开来(例如,  和 )。

为了解这些方程,解法很简单:

 的上标表示需要取逆。这与正常的代数相当一致。

考虑一个问题 ,其中要求你解出 。解法很简单 。或者我们可以将其重写为一系列乘法,如。关于分数,我们知道什么?如果一个分数是分子,它可以简单地写成-1 的幂。因此,我们得到了这个解方程: 

现在如果你非常仔细地眯着眼睛看,方程的标量版本看起来非常像方程的矩阵表示版本。

如何计算矩阵的逆不是这本书的目标。相反,我鼓励你找一本线性代数教科书。我强烈推荐 Sheldon Axler 的《线性代数这样做是正确的》(Springer Books)。

总结一下,以下是主要观点:

  • 矩阵乘法和符号是为了解决联立方程而发明的。

  • 为了解这个联立方程,我们将方程视为变量是标量变量,并使用逆来处理。

现在是有趣的部分。使用相同的两个方程,我们将问题反过来。如果我们知道  和  是什么,方程现在看起来会像这样:

将其写成矩阵形式,我们得到以下:

仔细的读者现在应该已经发现了错误:这里有四个变量 (, , , 和 ),但只有两个方程。从高中数学中,我们知道你不能解一个方程组,其中方程的数量少于变量的数量!

问题是,你的高中数学老师有点骗了你。某种程度上是可以解决的,你已经在第二章,线性回归 - 房价预测中自己做到了。

事实上,大多数机器学习问题都可以用线性代数重新表达,具体形式如下:

图片

在我看来,这是正确思考人工神经网络的方式:一系列数学函数,而不是生物神经元的模拟。我们将在下一章中进一步探讨这一点。实际上,这种理解对于理解深度学习和它为什么有效至关重要。

现在,继续跟随更常见的观点,即人工神经网络在行为上类似于受生物启发的神经网络。

探索激活函数

线性代数的特性是它是线性的。当输出变化与输入变化成比例时,它是有用的。现实世界充满了非线性函数和方程。用大写的 H 解决非线性方程是困难的。但我们有一个技巧。我们可以取一个线性方程,然后向其中添加一个非线性。这样,函数就变得非线性了!

从这个观点出发,你可以将人工神经网络视为我们迄今为止所经历的所有章节的通用版本。

在人工神经网络的历史上,社区以一种时尚的方式青睐特定的激活函数。在早期,Heaviside 函数受到青睐。逐渐地,社区转向青睐可微分的连续函数,如 sigmoid 和 tanh。但最近,时尚的钟摆又回到了更硬、看似不连续的函数上。关键是,我们学会了如何对函数进行微分的新技巧,例如修正线性单元ReLu)。

在历史上,一些激活函数因其流行而受到青睐:

图片

图片

图片

关于这些函数的一点需要注意的是,这些函数都是非线性的,它们在 y 轴上都有一个硬限制。

激活函数的垂直范围是有限的,但水平范围不是。我们可以使用偏置来调整我们的激活函数看起来如何。

应该注意的是,偏置可以是零。这也意味着我们可以省略偏置。大多数时候,对于更复杂的项目来说,这是可以的,尽管添加偏置会增加神经网络的准确性。

学习

我想让你思考你是如何学习的。不是学习风格,不,我想让你对你的学习过程进行深入的思考。想想你学习的各种方式。也许你曾经热的时候碰过炉子。或者如果你曾经学习过一门新语言,也许你开始时是通过记忆短语来成为流利的。想想所有先于这一章的章节。它们有什么共同点?

简而言之,学习是通过纠正来实现的。如果你在热的时候碰到了炉子,你犯了错误。纠正的方法是永远不要再在热的时候碰炉子。你已经学会了如何在热的时候不碰炉子。

类似地,神经网络的学习方式是通过纠正来实现的。如果我们想要训练一台机器学习识别手写文字,我们需要提供一些样本图像,并告诉机器哪些是正确的标签。如果机器预测标签错误,我们需要告诉它改变神经网络中的某些东西并再次尝试。

可以改变什么?当然是权重。输入不能改变;它们是输入。但我们可以尝试不同的权重。因此,学习过程可以分为两个步骤:

  • 当神经网络犯错时,告诉它它是错的。

  • 更新权重,以便下一次尝试能得到更好的结果。

这样分解后,我们就有了一个很好的下一步进行的方法。一种方法是通过二元决定机制:如果神经网络预测了正确答案,则不更新权重。如果它错了,则更新权重。

那么,如何更新权重呢?好吧,一种方法是用新的值完全替换权重矩阵并再次尝试。由于权重矩阵是由从随机分布中抽取的值填充的,新的权重矩阵将是一个新的随机矩阵。

很明显,这两种方法结合在一起,神经网络学会任何东西都需要非常非常长的时间;这就像我们只是在猜测正确的权重矩阵一样。

相反,现代神经网络使用反向传播的概念来告诉神经网络它犯了错误,并使用某种形式的梯度下降来更新权重。

反向传播和梯度下降的具体内容超出了本章(和本书)的范围。然而,我会通过分享一个故事来简要地介绍这些基本概念。我和几位也在机器学习领域工作的朋友一起吃午饭,那次午餐以我们争论告终。这是因为我随意提到反向传播是“发现”的,而不是“发明”的。我的朋友们坚决认为反向传播是发明的,而不是发现的。我的推理很简单:如果多个人以相同的公式偶然发现数学,那么数学是“发现”的。如果没有人平行地发现它,那么数学是“发明”的。

反向传播,以各种形式,在时间中被不断重新发现。反向传播第一次被发现是在线性回归的发明中。我应该指出,它是一种非常具体的反向传播形式,专门针对线性回归:平方误差之和可以通过对平方误差之和关于输入的导数来反向传播到其输入。

我们从一个成本开始。记住我们不得不告诉神经网络它犯了一个错误。我们通过告诉神经网络预测的成本来做这件事。这被称为成本函数。我们可以定义一个成本,使得当神经网络做出正确预测时,成本较低,而当神经网络做出错误预测时,成本较高。

现在想象一下,成本函数是 。你如何知道在  的哪些值下成本最低?从高中数学我们知道,解决方案是对  关于  求导,并求解当它为 0 时的解:

反向传播采取了同样的线索。简而言之,反向传播只是一系列关于权重的偏导数。我们的玩具示例和真实反向传播之间的主要区别在于,我们表达式的推导很容易解决。对于更复杂的数学表达式,计算解决方案可能过于昂贵。相反,我们依赖于梯度下降来找到答案。

梯度下降假设我们从某个地方开始我们的 x,并通过迭代更新 x 以趋向最低成本。在每次迭代中,我们更新权重。梯度下降的最简单形式是将权重的梯度加到权重本身上。

关键的启示是这样一个强大的概念:通过执行函数的微分并找到一个导数最小的点,你可以告诉输入已经发生了一个错误。

项目

我们即将开始的项目就是开头段落中提到的那个。我们将要分类的数据集是由国家标准与技术研究院最初收集的一组手写数字,后来由 Yann LeCun 的团队修改。我们的目标是将这些手写数字分类为 0、1、2... 9 中的一个。

我们将基于神经网络是应用线性代数的理解来构建一个基本的神经网络,并且我们将使用 Gorgonia 来完成这一章和下一章的内容。

要安装 Gorgonia,只需运行 go get -u gorgonia.org/gorgonia 和 go get -u gorgonia.org/tensor

Gorgonia

Gorgonia 是一个库,它通过在构建深度神经网络时进行高效数学运算来简化操作。它基于这样的基本理解:神经网络是数学表达式。因此,使用 Gorgonia 构建神经网络相当容易。

关于章节的说明:因为 Gorgonia 是一个相对庞大的库,所以本章将省略一些关于 Gorgonia 的内容,但将在下一章以及另一本 Packt 书籍《Hands On Deep Learning in Go》中进一步展开。

获取数据

MNIST 数据的数据可以在本章的存储库中找到。在其原始形式中,它不是标准的图像格式。因此,我们需要将数据解析为可接受格式。

数据集分为两部分:标签和图像。因此,这里有一些函数,用于读取和解析 MNIST 文件:

// Image holds the pixel intensities of an image.
// 255 is foreground (black), 0 is background (white).
type RawImage []byte

// Label is a digit label in 0 to 9
type Label uint8

const numLabels = 10
const pixelRange = 255

const (
  imageMagic = 0x00000803
  labelMagic = 0x00000801
  Width = 28
  Height = 28
)

func readLabelFile(r io.Reader, e error) (labels []Label, err error) {
  if e != nil {
    return nil, e
  }

  var magic, n int32
  if err = binary.Read(r, binary.BigEndian, &magic); err != nil {
    return nil, err
  }
  if magic != labelMagic {
    return nil, os.ErrInvalid
  }
  if err = binary.Read(r, binary.BigEndian, &n); err != nil {
    return nil, err
  }
  labels = make([]Label, n)
  for i := 0; i < int(n); i++ {
    var l Label
    if err := binary.Read(r, binary.BigEndian, &l); err != nil {
      return nil, err
    }
    labels[i] = l
  }
  return labels, nil
}

func readImageFile(r io.Reader, e error) (imgs []RawImage, err error) {
  if e != nil {
    return nil, e
  }

  var magic, n, nrow, ncol int32
  if err = binary.Read(r, binary.BigEndian, &magic); err != nil {
    return nil, err
  }
  if magic != imageMagic {
    return nil, err /*os.ErrInvalid*/
  }
  if err = binary.Read(r, binary.BigEndian, &n); err != nil {
    return nil, err
  }
  if err = binary.Read(r, binary.BigEndian, &nrow); err != nil {
    return nil, err
  }
  if err = binary.Read(r, binary.BigEndian, &ncol); err != nil {
    return nil, err
  }
  imgs = make([]RawImage, n)
  m := int(nrow * ncol)
  for i := 0; i < int(n); i++ {
    imgs[i] = make(RawImage, m)
    m_, err := io.ReadFull(r, imgs[i])
    if err != nil {
      return nil, err
    }
    if m_ != int(m) {
      return nil, os.ErrInvalid
    }
  }
  return imgs, nil
}

首先,函数从 io.Reader 读取文件并读取一组 int32。这些是文件的元数据。第一个 int32 是一个魔术数字,用于指示文件是标签文件还是图像文件。n 表示文件包含的图像或标签数量。nrowncol 是文件中存在的元数据,表示每个图像中的行数/列数。

深入到 readImageFile 函数中,我们可以看到在读取所有元数据之后,我们知道要创建一个大小为 n[]RawImage。MNIST 数据集中使用的图像格式基本上是一个 784 字节的切片(28 列和 28 行)。因此,每个字节代表图像中的一个像素。每个字节的值表示像素的亮度,范围从 0 到 255:

示例图像

上述图像是 MNIST 图像放大的示例。在左上角,平面切片中像素的索引为 0。在右上角,平面切片中像素的索引为 27。在左下角,平面切片中像素的索引为 755。最后,在右下角,索引为 727。这是一个需要记住的重要概念:一个二维图像可以表示为一个一维切片。

可接受格式

什么格式是表示图像的可接受格式?字节切片对于读取和显示图像很有用,但并不特别适用于进行任何机器学习。相反,我们希望将图像表示为浮点数的切片。所以,这里有一个将字节转换为 float64 的函数:

func pixelWeight(px byte) float64 {
  retVal := float64(px)/pixelRange*0.9 + 0.1
  if retVal == 1.0 {
    return 0.999
  }
  return retVal
}

这是一个基本的缩放函数,将 0-255 的范围缩放到 0.0 和 1.0 之间。还有一个额外的检查;如果值是 1.0,我们返回 0.999 而不是 1。这主要是因为当值为 1.0 时,数值不稳定性往往会发生,因为数学函数往往会表现得古怪。所以,用非常接近 1 的值来替换 1.0。

因此,现在,我们可以将一个RawImage转换成[]float64。因为我们有以[]RawImage形式存在的N个图像,我们可以将其转换成[][]float64,或者一个矩阵。

从图像到矩阵

到目前为止,我们已经确定可以将特殊格式的图像列表转换成float64的切片的切片。回想一下,当你堆叠神经元时,它们形成一个矩阵,神经层的激活仅仅是矩阵-向量乘法。而当输入被堆叠在一起时,它仅仅是矩阵-矩阵乘法。

我们技术上可以用[][]float64来构建一个神经网络。但最终结果会相当慢。作为一个物种,我们大约有 40 年的经验,专门开发用于高效线性代数运算的算法,如矩阵乘法和矩阵-向量乘法。这些算法集合通常被称为 BLAS(基本线性代数子程序)。

到目前为止,这本书中,我们一直在使用建立在提供 BLAS 函数的库之上的库,即 Gonum 的 BLAS 库。如果你一直跟随着这本书,那么它已经安装好了。否则,运行go get -u gonum.org/v1/gonum/...,这将安装整个 Gonum 库套件。

由于 BLAS 通常的工作方式,我们需要比[][]float64更好的矩阵表示方法。这里我们有两种选择:

  • Gonum 的mat

  • Gorgonia 的tensor

为什么 Gorgonia 的tensortensor的原因相当简单。它与 Gorgonia 本身配合得很好,因为 Gorgonia 需要多维数组。Gonum 的mat只能处理最多两个维度,而在下一章中,我们将看到四维数组的用法。

什么是张量?

基本上,张量非常类似于向量。这个想法是从物理学中借鉴的。想象一下在一个二维平面上推一个盒子。如果你沿着x轴用 1 牛顿的力推盒子,那么y轴上没有施加力。你会这样写向量:[1, 0]。如果盒子沿着x轴以 10 公里/小时的速度移动,沿着y轴以 2 公里/小时的速度移动,你会这样写向量:[10, 2]。注意,它们是无单位的:第一个例子是牛顿的向量,第二个例子是带有公里/小时单位的向量。

简而言之,它是对某个方向上应用的东西(一个力、一个速度,或者任何具有大小和方向的东西)的一种表示。从这个想法出发,计算机科学借鉴了向量的名字。但在 Go 语言中,它们被称为切片

那么什么是张量?省略了很多细节,但不会失去一般性,张量就像向量一样。只是多维的。想象一下如果你要在平面上描述两个速度(想象一下愚蠢的橡皮泥以不同的速度在两个方向上拉伸):[1, 0][10, 2]。你会这样写:

⎡ 1 0⎤

⎣10 2⎦

这也被称为矩阵(当它是二维时)。当它是三维时,称为 3-张量,当它是四维时,称为 4-张量,以此类推。注意,如果你有一个第三个速度(即愚蠢的橡皮泥在第三个方向上被拉伸),你将不会有一个 3-张量。相反,你仍然会有一个矩阵,有三个行。

为了在前面示例的基础上可视化 3-张量,想象一下,如果愚蠢的橡皮泥被拉向两个方向,这就像时间中的一个切片。然后想象另一个时间中的切片,同样的愚蠢的橡皮泥再次被拉向两个方向。现在你将有两个矩阵。当你想象将这些矩阵堆叠在一起时,就会得到一个 3-张量。

[]RawImage 转换为 tensor.Tensor 的代码如下:

func prepareX(M []RawImage) (retVal tensor.Tensor) {
  rows := len(M)
  cols := len(M[0])

  b := make([]float64, 0, rows*cols)
  for i := 0; i < rows; i++ {
    for j := 0; j < len(M[i]); j++ {
      b = append(b, pixelWeight(M[i][j]))
    }
  }
  return tensor.New(tensor.WithShape(rows, cols), tensor.WithBacking(b))
}

Gorgonia 对于初学者可能有点难以理解。所以让我逐行解释这段代码。但首先,你必须意识到,与 Gonum 矩阵一样,Gorgonia 张量,无论有多少维度,在内部也以扁平切片的形式表示。Gorgonia 张量在某种程度上更加灵活,因为它们可以接受不仅仅是 float64 的扁平切片(它们也可以接受其他类型的切片)。这被称为后备切片或数组。这是在 Gonum 和 Gorgonia 中执行线性代数操作比使用普通的 [][]float64 更高效的一个基本原因。

rows := len(M)cols := len(M[0]) 非常直观易懂。我们想知道行数(即图像数量)和列数(图像中的像素数量)。

b := make([]float64, 0, rows*cols) 创建了一个容量为 rows * cols 的后备数组。这个后备数组被称为后备 数组,因为在整个 b 的生命周期中,其大小不会改变。我们在这里从长度 0 开始,因为我们想稍后使用 append 函数。

a := make([]T, 0, capacity) 是预分配切片的一个好模式。考虑以下类似的代码片段:

a := make([]int, 0)

    for i := 0; i < 10; i++ {

        a = append(a, i)

}

在第一次调用 append 时,Go 运行时会查看 a 的容量,发现它是 0。因此,它会分配一些内存来创建一个大小为 1 的切片。然后在第二次调用 append 时,Go 运行时会查看 a 的容量,发现它是 1,这显然是不够的。所以它会分配当前切片容量的两倍。在第四次迭代时,它会发现 a 的容量不足以追加,并再次将切片的当前容量加倍。

关于分配的问题在于,它是一个昂贵的操作。偶尔,Go 运行时可能不仅需要分配内存,还需要将内存复制到新位置。这增加了向切片追加的成本。

因此,如果我们事先知道切片的容量,最好一次性分配所有内容。我们可以指定长度,但通常会导致索引错误。因此,我的建议是使用容量和长度为0进行分配。这样,你可以安全地使用append而不用担心索引错误。

在创建底层切片后,我们只需使用之前描述的pixelWeight函数将像素值填充到底层切片中,将其转换为float64

最后,我们调用tensor.New(tensor.WithShape(rows, cols), tensor.WithBacking(b)),它返回一个*tensor.Densetensor.WithShape(rows, cols)构造选项创建了一个具有指定形状的*tensor.Dense,而tensor.WithBacking(b)则简单地使用已经预先分配和填充的b作为底层切片。

tensor库将简单地重用整个底层数组,以便减少分配。这意味着你处理b时必须小心。在之后修改b的内容将改变tensor.Dense中的内容。鉴于b是在prepareX函数中创建的,一旦函数返回,就无法修改b的内容。这是一种防止意外修改的好方法。

从标签到单热向量

回想一下,在 Gorgonia 中构建的神经网络只接受tensor.Tensors 作为输入。因此,标签也必须转换为tensor.Tensor。该函数与prepareX非常相似:

func prepareY(N []Label) (retVal tensor.Tensor) {
  rows := len(N)
  cols := 10

  b := make([]float64, 0, rows*cols)
  for i := 0; i < rows; i++ {
    for j := 0; j < 10; j++ {
      if j == int(N[i]) {
        b = append(b, 1)
      } else {
        b = append(b, 0)
      }
    }
  }
  return tensor.New(tensor.WithShape(rows, cols), tensor.WithBacking(b))
}

我们在这里构建的是一个有N行和十个列的矩阵。为什么我们要构建(N,10)矩阵的具体原因将在下一章中探讨,但就现在而言,让我们聚焦到一个假设的行。想象一下第一个标签,(int(N[i]))7。这一行看起来是这样的:

[0, 0, 0, 0, 0, 0, 0, 1, 0, 0]

这被称为单热向量编码。它将对我们有用,并在下一章中进一步探讨。

可视化

当我们处理图像数据时,可视化也是有用的。之前我们使用pixelWeight将图像像素从byte转换为float64。也很有必要有一个反向函数:

func reversePixelWeight(px float64) byte {
  return byte(((px - 0.001) / 0.999) * pixelRange)
}

这是如何可视化 100 张图像的方法:

// visualize visualizes the first N images given a data tensor that is made up of float64s.
// It's arranged into (rows, 10) image.
// Row counts are calculated by dividing N by 10 - we only ever want 10 columns.
// For simplicity's sake, we will truncate any remainders.
func visualize(data tensor.Tensor, rows, cols int, filename string) (err error) {
  N := rows * cols

  sliced := data
  if N > 1 {
    sliced, err = data.Slice(makeRS(0, N), nil) // data[0:N, :] in python
    if err != nil {
      return err
    }
  }

  if err = sliced.Reshape(rows, cols, 28, 28); err != nil {
    return err
  }

  imCols := 28 * cols
  imRows := 28 * rows
  rect := image.Rect(0, 0, imCols, imRows)
  canvas := image.NewGray(rect)

  for i := 0; i < cols; i++ {
    for j := 0; j < rows; j++ {
      var patch tensor.Tensor
      if patch, err = sliced.Slice(makeRS(i, i+1), makeRS(j, j+1)); err != nil {
        return err
      }

      patchData := patch.Data().([]float64)
      for k, px := range patchData {
        x := j*28 + k%28
        y := i*28 + k/28
        c := color.Gray{reversePixelWeight(px)}
        canvas.Set(x, y, c)
      }
    }
  }

  var f io.WriteCloser
  if f, err = os.Create(filename); err != nil {
    return err
  }

  if err = png.Encode(f, canvas); err != nil {
    f.Close()
    return err
  }

  if err = f.Close(); err != nil {
    return err
  }
  return nil
}

数据集是一大块图像。我们首先需要确定我们想要多少个;因此,N := rows * cols。有了我们想要的数字,我们然后使用data.Slice(makeRS(0, N), nil)进行切片,这将张量沿第一个轴切片。然后,切片张量通过sliced.Reshape(rows, cols, 28,28)重塑为一个四维数组。你可以这样理解:它是由 28x28 图像堆叠的行和列。

切片入门

一个*tensor.Dense的行为非常类似于标准的 Go 切片;就像你可以对a[0:2]进行切片一样,你可以在 Gorgonia 的张量上做同样的事情。所有张量的.Slice()方法接受一个tensor.Slice描述符,定义为:

type Slice interface {

    Start() int

    End() int

    Step() int

}

因此,我们可能需要创建自己的数据类型,以满足Slice接口。它在项目的utils.go文件中定义。makeRS(0, N)简单地读作如果我们正在做data[0:N]。关于此 API 的详细信息及其原因可以在 Gorgonia 张量 Godoc 页面上找到。

然后使用内置的image包创建一个灰度图像:canvas := image.NewGray(rect)image.Gray本质上是一个字节的切片,每个字节代表一个像素。接下来我们需要做的是填充像素。简单来说,我们只需遍历每个补丁中的列和行,并用从张量中提取的正确值填充它。reversePixelWeight函数用于将浮点数转换为字节,然后将其转换为color.Gray。然后使用canvas.Set(x, y, c)设置画布中的像素。

随后,画布被编码为 PNG 格式。Et voilà,我们的可视化工作就完成了!

现在在主函数中调用visualize如下:

func main() {
  imgs, err := readImageFile(os.Open("train-images-idx3-ubyte"))
  if err != nil {
    log.Fatal(err)
  }
  log.Printf("len imgs %d", len(imgs))

  data := prepareX(imgs)
  visualize(data, 100, "image.png")
}

这会产生以下图像:

图片

预处理

我们接下来要做的是使用零相位成分分析ZCA)来“白化”我们的数据。ZCA 的定义超出了本章的范围,但简而言之,ZCA 非常类似于主成分分析PCA)。在我们的 784 像素切片中,像素之间高度相关的可能性很大。PCA 所做的就是找到一组彼此不相关的像素。它是通过一次性查看所有图像并找出每一列如何相互关联来做到这一点的:

func zca(data tensor.Tensor) (retVal tensor.Tensor, err error) {
  var dataᵀ, data2, sigma tensor.Tensor
  data2 = data.Clone().(tensor.Tensor)

  if err := minusMean(data2); err != nil {
    return nil, err
  }
  if dataᵀ, err = tensor.T(data2); err != nil {
    return nil, err
  }

  if sigma, err = tensor.MatMul(dataᵀ, data2); err != nil {
    return nil, err
  }

  cols := sigma.Shape()[1]
  if _, err = tensor.Div(sigma, float64(cols-1), tensor.UseUnsafe()); err != nil {
    return nil, err
  }

  s, u, _, err := sigma.(*tensor.Dense).SVD(true, true)
  if err != nil {
    return nil, err
  }

  var diag, uᵀ, tmp tensor.Tensor
  if diag, err = s.Apply(invSqrt(0.1), tensor.UseUnsafe()); err != nil {
    return nil, err
  }
  diag = tensor.New(tensor.AsDenseDiag(diag))

  if uᵀ, err = tensor.T(u); err != nil {
    return nil, err
  }

  if tmp, err = tensor.MatMul(u, diag); err != nil {
    return nil, err
  }

  if tmp, err = tensor.MatMul(tmp, uᵀ); err != nil {
    return nil, err
  }

  if err = tmp.T(); err != nil {
    return nil, err
  }

  return tensor.MatMul(data, tmp)
}

func invSqrt(epsilon float64) func(float64) float64 {
  return func(a float64) float64 {
    return 1 / math.Sqrt(a+epsilon)
  }
}

这是一段相当大的代码块。让我们来分析这段代码。但在分析实现 ZCA 的代码之前,让我们先了解 ZCA 背后的关键思想。

首先,回忆一下 PCA 做了什么:它找到一组输入(列和像素,可以互换使用)它们之间相关性最小。ZCA 所做的就是然后取找到的主成分,并将它们乘以输入,以转换输入,使它们彼此之间相关性降低。

首先,我们想要减去行均值。为此,我们首先克隆数据(我们稍后会看到原因),然后使用此函数减去均值:

func minusMean(a tensor.Tensor) error {
  nat, err := native.MatrixF64(a.(*tensor.Dense))
  if err != nil {
    return err
  }
  for _, row := range nat {
    mean := avg(row)
    vecf64.Trans(row, -mean)
  }

  rows, cols := a.Shape()[0], a.Shape()[1]

  mean := make([]float64, cols)
  for j := 0; j < cols; j++ {
    var colMean float64
    for i := 0; i < rows; i++ {
      colMean += nat[i][j]
    }
    colMean /= float64(rows)
    mean[j] = colMean
  }

  for _, row := range nat {
    vecf64.Sub(row, mean)
  }

  return nil
}

在所有关于平面切片与[][]float64效率的讨论之后,我接下来要提出的建议可能会听起来有些反直觉。但请耐心听我说。native.MatrixF64接受一个*tensor.Dense并返回一个[][]float64,我们称之为natnat与张量a共享相同的分配。没有额外的分配,对nat所做的任何修改都会在a中显示出来。在这种情况下,我们应该将[][]float64视为一种轻松遍历张量中值的简单方法。这可以在下面看到:

  for j := 0; j < cols; j++ {
    var colMean float64
    for i := 0; i < rows; i++ {
      colMean += nat[i][j]
    }
    colMean /= float64(rows)
    mean[j] = colMean
  }

就像在visualize函数中一样,我们首先遍历列,尽管目的不同。我们想要找到每列的平均值。然后我们将每列的平均值存储在mean变量中。这允许我们减去列均值:

  for _, row := range nat {
    vecf64.Sub(row, mean)
  }

这段代码使用了 Gorgonia 附带的自定义vecf64包,从另一个切片中减去一个切片,逐元素进行。它与以下内容相当:

  for _, row := range nat {
    for j := range row {
      row[j] -= mean[j]
    }
  }

使用vecf64的唯一真正原因是它经过优化,可以使用 SIMD 指令执行操作:而不是逐个执行row[j] -= mean[j],它同时执行row[j] -= mean[j]row[j+1] -= mean[j+1]row[j+2] -= mean[j+2]row[j+3] -= mean[j+3]

在我们减去均值之后,我们找到它的转置并复制一份:

  if dataᵀ, err = tensor.T(data2); err != nil {
    return nil, err
  }

通常,你会通过使用类似data2.T()的方式来找到tensor.Tensor的转置。但这并不返回它的一个副本。相反,tensor.T函数克隆了数据结构,然后对其进行转置。这样做的原因是什么?我们即将使用转置和data2来找到Sigma(关于矩阵乘法的更多内容将在下一章中解释):

  var sigma tensor.Tensor
  if sigma, err = tensor.MatMul(dataᵀ, data2); err != nil {
    return nil, err
  }

在我们找到sigma之后,我们将其除以列数减 1。这提供了一个无偏估计量。tensor.UseUnsafe选项用于指示结果应存储回sigma张量:

  cols := sigma.Shape()[1]
  if _, err = tensor.Div(sigma, float64(cols-1), tensor.UseUnsafe()); err != nil {
    return nil, err
  }

所有这些操作都是为了我们能够在sigma上执行奇异值分解(SVD):

  s, u, _, err := sigma.(*tensor.Dense).SVD(true, true)
  if err != nil {
    return nil, err
  }

如果你不熟悉奇异值分解(Singular Value Decomposition),它是一种将矩阵分解为其组成部分的方法之一。你为什么要这样做呢?一方面,它使得某些计算的部分变得更容易。它所做的就是将一个(M, N)矩阵分解为一个(M, N)矩阵,称为![img/e8e5b0cf-7095-4d02-bcea-adf293b141b7.png],一个(M, M)矩阵,称为![img/b3336713-7358-4b4f-81ea-59bac8d93aa8.png],以及一个(N, N)矩阵,称为![img/2ef3a6de-2db6-47a8-9325-d4ba67b13ab6.png]。为了重建 A,公式很简单:

![img/958010d7-bc97-44b5-9afb-6f620e3879f6.png]

分解的部分将被使用。在我们的情况下,我们并不特别关注右奇异值 ![img/8cbd12c1-85b2-42bc-894d-c37cfdbe30e8.png],所以我们现在暂时忽略它。分解的部分简单用于转换图像,这些图像可以在函数体的末尾找到。

在预处理之后,我们再次可视化前 100 多张图像:

![img/882ed7d0-2a36-45fd-a66b-45c9c5c04b30.png]

构建神经网络

最后,让我们构建一个神经网络!我们将构建一个简单的三层神经网络,包含一个隐藏层。三层神经网络有两个权重矩阵,因此我们可以将神经网络定义为如下:

type NN struct {
  hidden, final *tensor.Dense
  b0, b1 float64
}

hidden代表输入层和隐藏层之间的权重矩阵,而final代表隐藏层和最终层之间的权重矩阵。

这是我们的*NN 数据结构的图形表示:

输入层是 784 个float64的切片,然后通过前馈(即矩阵乘法后跟激活函数)形成隐藏层。隐藏层随后通过前馈形成最终层。最终层是一个包含十个float64的向量,这正是我们之前讨论过的独热编码。你可以把它们看作是伪概率,因为它们的值并不正好加起来等于 1。

需要注意的关键点:b0b1分别是隐藏层和最终层的偏置值。它们实际上并没有被主要使用,主要是因为混乱;正确求导相当困难。对读者的一个挑战是在以后结合使用b0b1

要创建一个新的神经网络,我们有New函数:

func New(input, hidden, output int) (retVal *NN) {
  r := make([]float64, hidden*input)
  r2 := make([]float64, hidden*output)
  fillRandom(r, float64(len(r)))
  fillRandom(r2, float64(len(r2)))
  hiddenT := tensor.New(tensor.WithShape(hidden, input), tensor.WithBacking(r))
  finalT := tensor.New(tensor.WithShape(output, hidden), tensor.WithBacking(r2))
  return &NN{
    hidden: hiddenT,
    final: finalT,
  }
}

fillRandom函数用随机值填充一个[]float64。在我们的情况下,我们用从均匀分布中抽取的随机值填充它。在这里,我们使用 Gonum 的distuv包:

func fillRandom(a []float64, v float64) {
  dist := distuv.Uniform{
    Min: -1 / math.Sqrt(v),
    Max: 1 / math.Sqrt(v),
  }
  for i := range a {
    a[i] = dist.Rand()
  }
}

在切片rr2被填充后,创建了张量hiddenTfinalT,并返回了*NN

前馈

现在我们已经对神经网络的工作原理有了概念性的了解,让我们编写前向传播函数。我们将称之为Predict,因为,嗯,要预测,你只需要运行函数的前向部分:

func (nn *NN) Predict(a tensor.Tensor) (int, error) {
  if a.Dims() != 1 {
    return -1, errors.New("Expected a vector")
  }

  var m maybe
  hidden := m.do(func() (tensor.Tensor, error) { return nn.hidden.MatVecMul(a) })
  act0 := m.do(func() (tensor.Tensor, error) { return hidden.Apply(sigmoid, tensor.UseUnsafe()) })

  final := m.do(func() (tensor.Tensor, error) { return tensor.MatVecMul(nn.final, act0) })
  pred := m.do(func() (tensor.Tensor, error) { return final.Apply(sigmoid, tensor.UseUnsafe()) })

  if m.err != nil {
    return -1, m.err
  }
  return argmax(pred.Data().([]float64)), nil
}

这相当直接,除了几个控制结构。我首先应该解释的是,tensor 包的 API 在表达性方面相当强,因为它允许用户以多种方式完成相同的事情,尽管类型签名不同。简而言之,模式如下:

  • tensor.BINARYOPERATION(a, b tensor.Tensor, opts ...tensor.FuncOpt) (tensor.Tensor, error)

  • tensor.UNARYOPERATION(a tensor.Tensor, opts ...tensor.FuncOpt)(tensor.Tensor, error)

  • (a *tensor.Dense) BINARYOPERATION (b *tensor.Dense, opts ...tensor.FuncOpt) (*tensor.Dense, error)

  • (a *tensor.Dense) UNARYOPERATION(opts ...tensor.FuncOpt) (*tensor.Dense, error)

需要注意的关键点是包级操作(如tensor.Addtensor.Sub等)接受一个或多个tensor.Tensor,并返回一个tensor.Tensor和一个error。有多个东西实现了tensor.Tensor接口,tensor 包提供了两种结构类型来实现该接口:

  • *tensor.Dense: 一个密集填充张量的表示

  • *tensor.CS: 以压缩稀疏列/行格式排列的稀疏填充张量的内存高效表示

在大多数情况下,最常用的tensor.Tensor类型是*tensor.Dense类型。*tensor.CS数据结构仅用于针对特定算法的特定内存受限优化。我们在此章节中不会更多讨论*tensor.CS类型。

除了包级别的操作之外,每个特定类型也实现了它们自己的方法。*tensor.Dense的方法(.Add(...), .Sub(...)等)接受一个或多个*tensor.Dense并返回*tensor.Dense和一个错误。

使用 maybe 处理错误

在快速介绍完这些之后,我们现在可以谈谈maybe类型。

你可能已经注意到的其中一件事是,几乎所有操作都返回一个错误。事实上,很少有函数和方法不返回错误。背后的逻辑很简单:大多数错误实际上是可恢复的,并且有合适的恢复策略。

然而,对于这个项目,我们有一个错误恢复策略:将错误冒泡到main函数中,在那里将调用log.Fatal并检查错误以进行调试。

因此,我定义了maybe如下:

type maybe struct {
  err error
}

func (m *maybe) do(fn func() (tensor.Tensor, error)) tensor.Tensor {
  if m.err != nil {
    return nil
  }

  var retVal tensor.Tensor
  if retVal, m.err = fn(); m.err == nil {
    return retVal
  }
  m.err = errors.WithStack(m.err)
  return nil
}

这样,它能够处理任何函数,只要它被封装在一个闭包中。

为什么这样做?我个人并不喜欢这种结构。我曾把它当作一个酷炫的小技巧教给几个学生,从那时起,他们声称生成的代码比有块状结构的代码更易于理解:

if foo, err := bar(); err != nil {
  return err
}

我确实能理解这种观点。在我看来,它在原型设计阶段最有用,尤其是在还不清楚何时何地处理错误(在我们的例子中是提前返回)的时候。将返回错误留到函数的末尾可能是有用的。但在生产代码中,我更喜欢尽可能明确地关于错误处理策略。

这可以通过将常见的函数调用抽象成方法来进一步扩展。例如,我们在前面的代码片段中看到了这一行,m.do(func() (tensor.Tensor, error) { return hidden.Apply(sigmoid, tensor.UseUnsafe()) }),出现了两次。如果我们想优先考虑可读性,同时尽量保持结构不变,我们可以通过创建一个新的方法来抽象它:

func (m *maybe) sigmoid(a tensor.Tensor) (retVal tensor.Tensor){
  if m.err != nil {
    return nil
  }
  if retVal, m.err = a.Apply(sigmoid); m.err == nil {
    return retVal
  }
  m.err = errors.WithStack(m.err)
  return nil
}

我们只需调用m.sigmoid(hidden)即可。这是程序员可以采用的许多错误处理策略之一。记住,你是一名程序员;你被允许,甚至被期望以编程的方式解决问题!

解释前向函数

在完成所有这些之后,让我们逐行分析前向函数。

首先,回想一下在模拟神经网络这一节中,我们可以如下定义神经网络:

func affine(weights [][]float64, inputs []float64) []float64 {
  return activation(matVecMul(weights, inputs))
}

我们在计算第一个隐藏层时执行第一次矩阵乘法:hidden := m.do(func() (tensor.Tensor, error) { return nn.hidden.MatVecMul(a)) })。使用MatVecMul是因为我们在乘以一个向量。

然后我们执行计算层的第二部分:act0 := m.do(func() (tensor.Tensor, error) { return hidden.Apply(sigmoid, tensor.UseUnsafe()) })。再次使用tensor.UseUnsafe()函数选项来告诉函数不要分配一个新的 tensor。!我们已经成功计算了第一层。

最后一个步骤重复了相同的两个步骤,我们得到了一个类似 one-hot 的向量。请注意,在第一步中,我使用了tensor.MatVecMul(nn.final, act0)而不是nn.final.MatVecMul(act0)。这样做是为了表明这两个函数确实是相同的,它们只是接受不同的类型(方法接受具体类型,而包函数接受抽象数据类型)。它们在其他方面功能相同。

注意一下affine函数的阅读起来是多么简单,而其他函数则相当难以阅读?阅读关于maybe的部分,看看你是否能想出一个方法来编写它,使其读起来更像affine

有没有一种方法可以将函数抽象成一个像affine一样的函数,这样你就可以只调用一个函数而不重复自己?

在我们返回结果之前,我们需要进行检查,看看前一步骤中是否有任何错误发生。想想可能发生的错误。根据我的经验,这些错误主要是形状相关的错误。在这个具体项目中,应该将形状错误视为失败,因此我们返回一个 nil 结果和错误。

我们为什么必须在这个时候检查错误,是因为我们即将使用pred。如果pred是 nil(如果之前发生了错误,它就会是 nil),尝试访问.Data()函数将导致恐慌。

不管怎样,检查之后,我们调用.Data()方法,它返回原始数据作为一个扁平的切片。它是一个interface{}类型,所以我们在进一步检查数据之前必须将其转换回[]float64。因为结果是向量,所以在数据布局上与[]float64没有区别,所以我们可以直接在它上面调用argmax

argmax简单地返回切片中最大值的索引。它被定义为:

func affine(weights [][]float64, inputs []float64) []float64 {
  return activation(matVecMul(weights, inputs))
}

因此,我们已经成功地为我们神经网络编写了一个馈送前向函数。

成本

在编写了一个相当直接的馈送前向函数之后,我们现在来看看如何让神经网络学习。

回想一下我们之前说过的,神经网络在学习时,你会告诉它它犯了错误?更技术地说,我们提出的问题是:我们可以使用什么样的成本函数,以便它能够准确地传达给神经网络真实值是什么。

我们想为这个项目使用的成本函数是平方误差之和。错误是什么?嗯,错误简单地是真实值和预测值之间的差异。这意味着如果真实值是7,而神经网络预测了2,成本就只是7-2吗?不。这是因为我们不应该将标签视为数字。它们是标签。

那我们应该减去什么?回想一下我们之前创建的那个单热向量?如果我们查看Predict函数内部,我们可以看到pred,最终激活的结果是十个float64的切片。这就是我们要减去的内容。因为它们都是十个float64的切片,所以我们必须逐元素相减。

仅仅减去切片是没有用的;结果可能是负数。想象一下,如果你被要求找到一个产品的最低可能成本。如果有人告诉你他们的产品成本是负数,并且他们愿意为此付钱给你使用,你不会使用它吗?所以为了防止这种情况,我们取误差的平方。

为了计算平方误差的和,我们只需将结果平方。因为我们一次训练一个图像的神经网络,所以这个和就是单个图像的平方误差。

反向传播

成本这一节内容相对较少,这是有充分理由的。此外,还有一个转折:我们不会完全计算完整的成本函数,主要是因为在这个特定情况下我们不需要这样做。成本与反向传播的概念紧密相关。现在我们将进行一些数学技巧的操作。

回想一下,我们的成本是平方误差的总和。我们可以写成这样:

图片

现在我将要描述的内容听起来可能非常像作弊,但这确实是一个有效的策略。关于预测的导数是这样的:

图片

为了让我们自己更容易操作,让我们重新定义成本为这样:

图片

这对寻找最低成本的过程没有影响。想想看;想象一下最高成本和最低成本。如果它们前面有一个乘数,那么它们之间的差异并不会改变最低成本仍然是低于最高成本的事实。花点时间自己解决这个问题,以说服自己一个常数乘数不会改变这个过程。

sigmoid函数的导数是:

图片

从那里,我们可以推导出关于权重矩阵的成本函数的导数。如何进行完整的反向传播将在下一章中解释。现在,这里是有代码:

  // backpropagation
  outputErrors := m.do(func() (tensor.Tensor, error) { return tensor.Sub(y, pred) })
  cost = sum(outputErrors.Data().([]float64))

  hidErrs := m.do(func() (tensor.Tensor, error) {
    if err := nn.final.T(); err != nil {
      return nil, err
    }
    defer nn.final.UT()
    return tensor.MatMul(nn.final, outputErrors)
  })

  if m.err != nil {
    return 0, m.err
  }

  dpred := m.do(func() (tensor.Tensor, error) { return pred.Apply(dsigmoid, tensor.UseUnsafe()) })
  m.do(func() (tensor.Tensor, error) { return tensor.Mul(pred, outputErrors, tensor.UseUnsafe()) })
  // m.do(func() (tensor.Tensor, error) { err := act0.T(); return act0, err })
  dpred_dfinal := m.do(func() (tensor.Tensor, error) {
    if err := act0.T(); err != nil {
      return nil, err
    }
    defer act0.UT()
    return tensor.MatMul(outputErrors, act0)
  })

  dact0 := m.do(func() (tensor.Tensor, error) { return act0.Apply(dsigmoid) })
  m.do(func() (tensor.Tensor, error) { return tensor.Mul(hidErrs, dact0, tensor.UseUnsafe()) })
  m.do(func() (tensor.Tensor, error) { err := hidErrs.Reshape(hidErrs.Shape()[0], 1); return hidErrs, err })
  // m.do(func() (tensor.Tensor, error) { err := x.T(); return x, err })
  dcost_dhidden := m.do(func() (tensor.Tensor, error) {
    if err := x.T(); err != nil {
      return nil, err
    }
    defer x.UT()
    return tensor.MatMul(hidErrs, x)
  })

就这样,我们得到了关于输入矩阵的成本导数。

对于导数,我们可以将它们用作梯度来更新输入矩阵。要做到这一点,使用一个简单的梯度下降算法;我们只需将梯度加到值本身上。但我们不想添加梯度的完整值。如果我们这样做,并且我们的起始值非常接近最小值,我们就会超过它。所以我们需要将梯度乘以一个很小的值,这个值被称为学习率:

  // gradient update
  m.do(func() (tensor.Tensor, error) { return tensor.Mul(dcost_dfinal, learnRate, tensor.UseUnsafe()) })
  m.do(func() (tensor.Tensor, error) { return tensor.Mul(dcost_dhidden, learnRate, tensor.UseUnsafe()) })
  m.do(func() (tensor.Tensor, error) { return tensor.Add(nn.final, dcost_dfinal, tensor.UseUnsafe()) })
  m.do(func() (tensor.Tensor, error) { return tensor.Add(nn.hidden, dcost_dhidden, tensor.UseUnsafe()) })

这就是完整的训练函数:

// X is the image, Y is a one hot vector
func (nn *NN) Train(x, y tensor.Tensor, learnRate float64) (cost float64, err error) {
  // predict
  var m maybe
  m.do(func() (tensor.Tensor, error) { err := x.Reshape(x.Shape()[0], 1); return x, err })
  m.do(func() (tensor.Tensor, error) { err := y.Reshape(10, 1); return y, err })

  hidden := m.do(func() (tensor.Tensor, error) { return tensor.MatMul(nn.hidden, x) })
  act0 := m.do(func() (tensor.Tensor, error) { return hidden.Apply(sigmoid, tensor.UseUnsafe()) })

  final := m.do(func() (tensor.Tensor, error) { return tensor.MatMul(nn.final, act0) })
  pred := m.do(func() (tensor.Tensor, error) { return final.Apply(sigmoid, tensor.UseUnsafe()) })
  // log.Printf("pred %v, correct %v", argmax(pred.Data().([]float64)), argmax(y.Data().([]float64)))

  // backpropagation.
  outputErrors := m.do(func() (tensor.Tensor, error) { return tensor.Sub(y, pred) })
  cost = sum(outputErrors.Data().([]float64))

  hidErrs := m.do(func() (tensor.Tensor, error) {
    if err := nn.final.T(); err != nil {
      return nil, err
    }
    defer nn.final.UT()
    return tensor.MatMul(nn.final, outputErrors)
  })

  if m.err != nil {
    return 0, m.err
  }

  dpred := m.do(func() (tensor.Tensor, error) { return pred.Apply(dsigmoid, tensor.UseUnsafe()) })
  m.do(func() (tensor.Tensor, error) { return tensor.Mul(pred, outputErrors, tensor.UseUnsafe()) })
  // m.do(func() (tensor.Tensor, error) { err := act0.T(); return act0, err })
  dpred_dfinal := m.do(func() (tensor.Tensor, error) {
    if err := act0.T(); err != nil {
      return nil, err
    }
    defer act0.UT()
    return tensor.MatMul(outputErrors, act0)
  })

  dact0 := m.do(func() (tensor.Tensor, error) { return act0.Apply(dsigmoid) })
  m.do(func() (tensor.Tensor, error) { return tensor.Mul(hidErrs, dact0, tensor.UseUnsafe()) })
  m.do(func() (tensor.Tensor, error) { err := hidErrs.Reshape(hidErrs.Shape()[0], 1); return hidErrs, err })
  // m.do(func() (tensor.Tensor, error) { err := x.T(); return x, err })
  dcost_dhidden := m.do(func() (tensor.Tensor, error) {
    if err := x.T(); err != nil {
      return nil, err
    }
    defer x.UT()
    return tensor.MatMul(hidErrs, x)
  })

  // gradient update
  m.do(func() (tensor.Tensor, error) { return tensor.Mul(dcost_dfinal, learnRate, tensor.UseUnsafe()) })
  m.do(func() (tensor.Tensor, error) { return tensor.Mul(dcost_dhidden, learnRate, tensor.UseUnsafe()) })
  m.do(func() (tensor.Tensor, error) { return tensor.Add(nn.final, dcost_dfinal, tensor.UseUnsafe()) })
  m.do(func() (tensor.Tensor, error) { return tensor.Add(nn.hidden, dcost_dhidden, tensor.UseUnsafe()) })
  return cost, m.err

有几个观察点需要注意:

  • 你可能会注意到Predict方法的部分内容在Train方法顶部重复。

  • tensor.UseUnsafe()函数选项被大量使用。

当我们开始扩展到更深层的网络时,这将成为一个痛点。因此,在下一章中,我们将探讨解决这些问题的可能方案。

训练神经网络

目前我们的主要结构如下:

func main() {
  imgs, err := readImageFile(os.Open("train-images-idx3-ubyte"))
  if err != nil {
    log.Fatal(err)
  }
  labels, err := readLabelFile(os.Open("train-labels-idx1-ubyte"))
  if err != nil {
    log.Fatal(err)
  }

  log.Printf("len imgs %d", len(imgs))
  data := prepareX(imgs)
  lbl := prepareY(labels)
  visualize(data, 10, 10, "image.png")

  data2, err := zca(data)
  if err != nil {
    log.Fatal(err)
  }
  visualize(data2, 10, 10, "image2.png")

  nat, err := native.MatrixF64(data2.(*tensor.Dense))
  if err != nil {
    log.Fatal(err)
  }

  log.Printf("Start Training")
  nn := New(784, 100, 10)
  costs := make([]float64, 0, data2.Shape()[0])
  for e := 0; e < 5; e++ {
    data2Shape := data2.Shape()
    var oneimg, onelabel tensor.Tensor
    for i := 0; i < data2Shape[0]; i++ {
      if oneimg, err = data2.Slice(makeRS(i, i+1)); err != nil {
        log.Fatalf("Unable to slice one image %d", i)
      }
      if onelabel, err = lbl.Slice(makeRS(i, i+1)); err != nil {
        log.Fatalf("Unable to slice one label %d", i)
      }
      var cost float64
      if cost, err = nn.Train(oneimg, onelabel, 0.1); err != nil {
        log.Fatalf("Training error: %+v", err)
      }
      costs = append(costs, cost)
    }
    log.Printf("%d\t%v", e, avg(costs))
    shuffleX(nat)
    costs = costs[:0]
  }
  log.Printf("End training")
}

简要步骤如下:

  1. 加载图像文件。

  2. 加载标签文件。

  3. 将图像文件转换为*tensor.Dense.

  4. 将标签文件转换为*tensor.Dense.

  5. 可视化 100 张图像。

  6. 对图像执行 ZCA 白化。

  7. 可视化白化后的图像。

  8. 为数据集创建一个本地迭代器。

  9. 创建一个具有 100 个单元隐藏层的神经网络。

  10. 创建成本切片。这样我们可以跟踪平均成本随时间的变化。

  11. 在每个 epoch 中,将输入切割成单个图像切片。

  12. 在每个 epoch 中,将输出标签切割成单个切片。

  13. 在每个 epoch 中,使用学习率为0.1nn.Train()调用,并使用切割的单个图像和单个标签作为训练示例。

  14. 训练五个 epoch。

我们如何知道神经网络已经学好了呢?一种方法是监控成本。如果神经网络正在学习,平均成本随时间将下降。当然,可能会有一些波动,但整体的大趋势应该是成本不会高于程序首次运行时的水平。

交叉验证

另一种测试神经网络学习效果的方法是交叉验证。神经网络可能在训练数据上学习得很好,本质上是在记忆哪些像素集合会导致特定的标签。然而,为了检查机器学习算法是否具有良好的泛化能力,我们需要向神经网络展示一些它从未见过的数据。

下面是执行此操作的代码:

  log.Printf("Start testing")
  testImgs, err := readImageFile(os.Open("t10k-images.idx3-ubyte"))
  if err != nil {
    log.Fatal(err)
  }

  testlabels, err := readLabelFile(os.Open("t10k-labels.idx1-ubyte"))
  if err != nil {
    log.Fatal(err)
  }

  testData := prepareX(testImgs)
  testLbl := prepareY(testlabels)
  shape := testData.Shape()
  testData2, err := zca(testData)
  if err != nil {
    log.Fatal(err)
  }

  visualize(testData, 10, 10, "testData.png")
  visualize(testData2, 10, 10, "testData2.png")

  var correct, total float64
  var oneimg, onelabel tensor.Tensor
  var predicted, errcount int
  for i := 0; i < shape[0]; i++ {
    if oneimg, err = testData.Slice(makeRS(i, i+1)); err != nil {
      log.Fatalf("Unable to slice one image %d", i)
    }
    if onelabel, err = testLbl.Slice(makeRS(i, i+1)); err != nil {
      log.Fatalf("Unable to slice one label %d", i)
    }
    if predicted, err = nn.Predict(oneimg); err != nil {
      log.Fatalf("Failed to predict %d", i)
    }

    label := argmax(onelabel.Data().([]float64))
    if predicted == label {
      correct++
    } else if errcount < 5 {
      visualize(oneimg, 1, 1, fmt.Sprintf("%d_%d_%d.png", i, label, predicted))
      errcount++
    }
    total++
  }
  fmt.Printf("Correct/Totals: %v/%v = %1.3f\n", correct, total, correct/total)

注意,代码与之前main函数中的代码大致相同。唯一的例外是,我们不是调用nn.Train,而是调用nn.Predict。然后我们检查标签是否与我们预测的一致。

这里是可调整的参数:

运行后(耗时 6.5 分钟),调整各种参数后,我运行了代码并得到了以下结果:

$ go build . -o chapter7
 $ ./chapter7
 Corerct/Totals: 9719/10000 = 0.972

一个简单的三层神经网络可以达到 97%的准确率!这当然离最先进的技术还差得远。我们将在下一章构建一个可以达到 99.xx%准确率的神经网络,但这需要思维方式的重大转变。

训练神经网络需要时间。通常明智的做法是保存神经网络的成果。*tensor.Dense类型实现了gob.GobEncodergob.GobDecoder,要将神经网络保存到磁盘,只需保存权重(nn.hiddennn.final)。作为一个额外的挑战,为这些权重矩阵编写一个 gob 编码器并实现保存/加载功能。

此外,让我们看看一些被错误分类的事物。在先前的代码中,这个片段写出了五个错误的预测:

    if predicted == label {
      correct++
    } else if errcount < 5 {
      visualize(oneimg, 1, 1, fmt.Sprintf("%d_%d_%d.png", i, label, predicted))
      errcount++
    }

下面就是它们:

图片 1 图片 2 图片 3

在第一张图像中,神经网络将其分类为0,而真实值是6。正如你所见,这是一个容易犯的错误。第二张图像显示了一个2,而神经网络将其分类为4。你可能倾向于认为它看起来有点像4。最后,如果你是一位美国读者,你很可能接触过帕尔默书写法。如果是这样,我敢打赌你可能会将最后一张图片分类为7,而不是2,这正是神经网络预测的结果。不幸的是,真实标签是它是2。有些人就是书写得很糟糕。

摘要

在本章中,我们学习了如何编写一个只有一个隐藏层的简单神经网络,它表现得非常出色。在这个过程中,我们学习了如何执行 ZCA 白化,以便清理数据。当然,这个模型有一些困难;在编码之前,你必须手动预先计算导数。

关键的收获点是,一个简单的神经网络可以做很多事情!虽然这个版本的代码非常以 Gorgonia 的 tensor 为中心,但原理是完全相同的,即使使用 Gonum 的 mat。实际上,Gorgonia 的 tensor 在底层使用了 Gonum 的出色的矩阵乘法库。

在下一章中,我们将重新审视在相同数据集上对神经网络的概念,以达到 99%的准确率,但我们的神经网络处理方法的心态必须改变。我建议重新阅读线性代数部分,以更好地掌握这些概念。

第七章:卷积神经网络 - MNIST 手写识别

在上一章中,我提出了一种场景,即你是一名邮递员,试图识别手写体。在那里,我们最终构建了一个基于 Gorgonia 的神经网络。在本章中,我们将探讨相同的场景,但我们将扩展我们对神经网络的理解,并编写一个更先进的神经网络,这是一个直到最近仍然是尖端技术的神经网络。

具体来说,在本章中,我们将构建一个 卷积神经网络CNN)。CNN 是一种近年来流行的深度学习网络。

你所知道的关于神经元的一切都是错误的

在上一章中,我提到关于神经网络的你知道的一切都是错误的。在这里,我重申这一说法。大多数关于神经网络的文献都是从与生物神经元的比较开始的,并以此结束。这导致读者经常假设它是。我想指出,人工神经网络与它们的生物同名物没有任何相似之处。

相反,在上一章中,我花了很多时间描述线性代数,并解释说,转折点是你可以将几乎任何 机器学习ML)问题表达为线性代数。我将在本章继续这样做。

与其将人工神经网络视为现实生活神经网络的类比,我个人鼓励你将人工神经网络视为数学方程式。激活函数引入的非线性性与线性组合相结合,使得人工神经网络能够近似任何函数。

神经网络 - 重新审视

对神经网络的基本理解是它们是数学表达式,这导致了神经网络简单易行的实现。回想一下上一章,神经网络可以写成这样:

func affine(weights [][]float64, inputs []float64) []float64 {
  return activation(matVecMul(weights, inputs))
}

如果我们将代码重写为一个数学方程式,我们可以写出如下神经网络:

图片

顺便提一下:图片图片相同。

我们可以使用 Gorgonia 简单地写出它,如下所示:

import (
  G "gorgonia.org/gorgonia"
)

var Float tensor.Float = tensor.Float64
func main() {
  g := G.NewGraph()
  x := G.NewMatrix(g, Float, G.WithName("x"), G.WithShape(N, 728))
  w := G.NewMatrix(g, Float, G.WithName("w"), G.WithShape(728, 800), 
       G.WithInit(G.Uniform(1.0)))
  b := G.NewMatrix(g, Float, G.WithName("b"), G.WithShape(N, 800), 
       G.WithInit(G.Zeroes()))
  xw, _ := G.Mul(x, w)
  xwb, _ := G.Add(xw, b)
  act, _ := G.Sigmoid(xwb)

  w2 := G.NewMatrix(g, Float, G.WithName("w2"), G.WithShape(800, 10), 
        G.WithInit(G.Uniform(1.0)))
  b2 := G.NewMatrix(g, Float, G.WithName("b2"), G.WithShape(N, 10), 
        G.WithInit(G.Zeroes()))
  xw2, _ := G.Mul(act, w2)
  xwb2, _ := G.Add(xw2, b2)
  sm, _ := G.SoftMax(xwb2)
}

上一段代码是以下神经网络在图像中的表示:

图片

中间层由 800 个隐藏单元组成。

当然,前面的代码隐藏了很多东西。你不可能在少于 20 行代码中从头开始构建一个神经网络,对吧?为了理解正在发生的事情,我们需要简要地了解一下 Gorgonia 是什么。

Gorgonia

Gorgonia 是一个库,它提供了用于处理深度学习特定数学表达式的原语。当与机器学习相关的项目一起工作时,你将开始发现自己对世界的洞察力更强,并且总是质疑假设。这是好事。

考虑当你阅读以下数学表达式时,你心中的想法:

你应该立刻想到“等等,这是错误的”。为什么你的大脑会这样想?

这主要是因为你的大脑评估了数学表达式。一般来说,表达式有三个部分:左边、等号和右边。你的大脑分别评估每一部分,然后评估整个表达式为假。

当我们阅读数学表达式时,我们会自动在心中评估这些表达式,并且我们理所当然地认为这是评估。在 Gorgonia 中,我们理所当然的事情被明确化了。使用 Gorgonia 有两个一般的 部分:定义表达式和评估表达式。

由于你很可能是程序员,你可以把第一部分看作是编写程序,而第二部分可以看作是运行程序。

当在 Gorgonia 中描述神经网络时,想象自己用另一种编程语言编写代码通常是有益的,这种语言是专门用于构建神经网络的。这是因为 Gorgonia 中使用的模式与一种新的编程语言非常相似。事实上,Gorgonia 是从零开始构建的,其理念是它是一种没有语法前端的编程语言。因此,在本节中,我经常会要求你想象自己在另一种类似 Go 的语言中编写代码。

为什么?

一个好问题是“为什么?”为什么要费心分离这个过程?毕竟,前面的代码可以被重写为上一章的 Predict 函数:

func (nn *NN) Predict(a tensor.Tensor) (int, error) {
  if a.Dims() != 1 {
    return nil, errors.New("Expected a vector")
  }

  var m maybe
  act0 := m.sigmoid(m.matVecMul(nn.hidden, a))
  pred := m.sigmoid(m.matVecMul(nn.final, act0))
  if m.err != nil {
    return -1, m.err
  }
  return argmax(pred.Data().([]float64)), nil
}

在这里,我们用 Go 语言定义网络,当我们运行 Go 代码时,神经网络就像定义时一样运行。我们面临的问题是什么,需要引入将神经网络定义和运行分离的想法?我们已经看到了当我们编写 Train 方法时的这个问题。

如果你还记得,在上一个章节中,我说过编写 Train 方法需要我们实际上从 Predict 方法中复制和粘贴代码。为了刷新你的记忆,以下是 Train 方法:

// X is the image, Y is a one hot vector
func (nn *NN) Train(x, y tensor.Tensor, learnRate float64) (cost float64, err error) {
  // predict
  var m maybe
  m.reshape(x, s.Shape()[0], 1)
  m.reshape(y, 10, 1)
  act0 := m.sigmoid(m.matmul(nn.hidden, x))
  pred := m.sigmoid(m.matmul(nn.final, act0))

  // backpropagation.
  outputErrors := m.sub(y, pred))
  cost = sum(outputErrors.Data().([]float64))

  hidErrs := m.do(func() (tensor.Tensor, error) {
    if err := nn.final.T(); err != nil {
      return nil, err
    }
    defer nn.final.UT()
    return tensor.MatMul(nn.final, outputErrors)
  })
  dpred := m.mul(m.dsigmoid(pred), outputErrors, tensor.UseUnsafe())
  dpred_dfinal := m.dmatmul(outputErrors, act0)
    if err := act0.T(); err != nil {
      return nil, err
    }
    defer act0.UT()
    return tensor.MatMul(outputErrors, act0)
  })

  m.reshape(m.mul(hidErrs, m.dsigmoid(act0), tensor.UseUnsafe()), 
                  hidErrs.Shape()[0], 1)
  dcost_dhidden := m.do(func() (tensor.Tensor, error) {
    if err := x.T(); err != nil {
      return nil, err
    }
    defer x.UT()
    return tensor.MatMul(hidErrs, x)
  })

  // gradient update
  m.mul(dpred_dfinal, learnRate, tensor.UseUnsafe())
  m.mul(dcost_dhidden, learnRate, tensor.UseUnsafe())
  m.add(nn.final, dpred_dfinal, tensor.UseUnsafe())
  m.add(nn.hidden, dcost_dhidden, tensor.UseUnsafe())
  return cost, m.err
}

让我们通过重构练习来突出问题。暂时摘下我们的机器学习帽子,戴上软件工程师的帽子,看看我们如何重构 TrainPredict,即使是在概念上。我们在 Train 方法中看到,我们需要访问 act0pred 来反向传播错误。在 Predict 中,act0pred 是终端值(也就是说,函数返回后我们不再使用它们),而在 Train 中则不是。

那么,在这里,我们可以创建一个新的方法;让我们称它为 fwd

func (nn *NN) fwd(x tensor.Tensor) (act0, pred tensor.Tensor, err error) {
  var m maybe
  m.reshape(x, s.Shape()[0], 1)
  act0 := m.sigmoid(m.matmul(nn.hidden, x))
  pred := m.sigmoid(m.matmul(nn.final, act0))
  return act0, pred, m.err
}

我们可以将 Predict 重构为如下所示:

func (nn *NN) Predict(a tensor.Tensor) (int, error) {
  if a.Dims() != 1 {
    return nil, errors.New("Expected a vector")
  }

  var err error
  var pred tensor.Tensor
  if _, pred, err = nn.fwd(a); err!= nil {
    return -1, err
  }
  return argmax(pred.Data().([]float64)), nil
}

Train 方法将看起来像这样:

// X is the image, Y is a one hot vector
func (nn *NN) Train(x, y tensor.Tensor, learnRate float64) (cost float64, err error) {
  // predict
  var act0, pred tensor.Tensor
  if act0, pred, err = nn.fwd(); err != nil {
    return math.Inf(1), err
  }

  var m maybe
  m.reshape(y, 10, 1)
  // backpropagation.
  outputErrors := m.sub(y, pred))
  cost = sum(outputErrors.Data().([]float64))

  hidErrs := m.do(func() (tensor.Tensor, error) {
    if err := nn.final.T(); err != nil {
      return nil, err
    }
    defer nn.final.UT()
    return tensor.MatMul(nn.final, outputErrors)
  })
  dpred := m.mul(m.dsigmoid(pred), outputErrors, tensor.UseUnsafe())
  dpred_dfinal := m.dmatmul(outputErrors, act0)
    if err := act0.T(); err != nil {
      return nil, err
    }
    defer act0.UT()
    return tensor.MatMul(outputErrors, act0)
  })

  m.reshape(m.mul(hidErrs, m.dsigmoid(act0), tensor.UseUnsafe()), 
                  hidErrs.Shape()[0], 1)
  dcost_dhidden := m.do(func() (tensor.Tensor, error) {
    if err := x.T(); err != nil {
      return nil, err
    }
    defer x.UT()
    return tensor.MatMul(hidErrs, x)
  })

  // gradient update
  m.mul(dpred_dfinal, learnRate, tensor.UseUnsafe())
  m.mul(dcost_dhidden, learnRate, tensor.UseUnsafe())
  m.add(nn.final, dpred_dfinal, tensor.UseUnsafe())
  m.add(nn.hidden, dcost_dhidden, tensor.UseUnsafe())
  return cost, m.err
}

这个看起来更好。我们在这里到底在做什么呢?我们在编程。我们在将一种语法形式重新排列成另一种语法形式,但我们并没有改变语义,即程序的意义。重构后的程序与未重构前的程序具有完全相同的意义。

编程

等一下,你可能自己会想。我说的“程序的意义”是什么意思?这是一个非常深奥的话题,涉及到整个数学分支,称为同伦。但就本章的所有实际目的而言,让我们将程序的意义定义为程序的扩展定义。如果两个程序编译并运行,接受相同的输入,并且每次都返回相同的精确输出,那么我们说两个程序是相等的。

这两个程序将是相等的:

程序 A 程序 B
fmt.Println("Hello World") fmt.Printf("Hello " + "World\n")

故意地,如果我们把程序可视化为一个 抽象语法树AST),它们看起来略有不同:

两个程序的语法不同,但它们的语义是相同的。我们可以通过消除 + 将程序 B 重构为程序 A。

但请注意我们在这里做了什么:我们取了一个程序,并以抽象语法树(AST)的形式表示它。通过语法,我们操作了 AST。这就是编程的本质。

什么是张量? – 第二部分

在上一章中,有一个信息框介绍了张量的概念。那个信息框有点简化。如果你在谷歌上搜索什么是张量,你会得到非常矛盾的结果,这些结果只会让人更加困惑。我不想增加困惑。相反,我将简要地触及张量,使其与我们项目相关,并且以一种非常类似于典型欧几里得几何教科书介绍点概念的方式:通过将其视为从用例中显而易见。

同样,我们将从用例中认为张量是显而易见的。首先,我们将看看乘法的概念:

  • 首先,让我们定义一个向量:。你可以把它想象成这个图:

  • 接下来,让我们将向量乘以一个标量值:。结果是类似这样的:

有两个观察点:

  • 箭头的总体方向没有改变。

  • 只有长度发生变化。在物理术语中,这被称为大小。如果向量代表行进的距离,你将沿着相同方向行进两倍的距离。

那么,你如何仅通过乘法来改变方向呢?你需要乘以什么来改变方向?让我们尝试以下矩阵,我们将称之为 T,用于变换:

现在如果我们用变换矩阵乘以向量,我们得到以下结果:

如果我们绘制起始向量和结束向量,我们得到的结果如下:

如我们所见,方向已经改变。大小也发生了变化。

现在,你可能会说,“等等,这不是线性代数 101 的内容吗?”是的,它是。但为了真正理解张量,我们必须学习如何构造它。我们刚才使用的矩阵也是一个秩为 2 的张量。秩为 2 的张量的正确名称是 二重积

为什么会有命名约定的混合?这里有一点点有趣的趣闻。当我编写 Gorgonia 最早版本的时候,我在思考计算机科学糟糕的命名约定,这是 Bjarne Stroustrup 本人也曾哀叹的事实。秩为 2 的张量的标准名称是 二重积,但它可以表示为一个矩阵。我一直在努力给它一个合适的名字;毕竟,名字中蕴含着力量,命名就是驯服。

大约在我开发 Gorgonia 最早版本的同时,我正在追一部非常优秀的 BBC 电视系列剧 Orphan Black,其中 Dyad 学院是主角的主要敌人。他们相当邪恶,这显然在我的脑海中留下了深刻印象。我决定不这样命名它。回顾起来,这似乎是一个相当愚蠢的决定。

现在让我们考虑变换二重积。你可以把二重积想象成向量 u 乘以向量 v。用方程式表示出来:

到目前为止,你可能已经熟悉了上一章的线性代数概念。你可能会想:“如果两个向量相乘,那会得到一个标量值,对吗?如果是这样,你怎么乘以两个向量并得到一个矩阵呢?”

在这里,我们需要引入一种新的乘法类型:外积(相比之下,上一章中引入的乘法是内积)。我们用这个符号来表示外积:

具体来说,外积,也称为二重积,定义为如下:

在本章中,我们不会特别关注 uv 的具体细节。然而,能够从其组成向量构造二重积是张量概念的一个基本组成部分。

具体来说,我们可以将 T 替换为 uv

现在我们得到  作为标量大小变化,u 作为方向变化。

那么,张量究竟有什么大惊小怪的?我可以给出两个原因。

首先,从向量中可以形成二元的想法可以向上推广。一个三张量,或三元组,可以通过二元乘积 uvw 形成,一个四张量或四元组可以通过二元乘积 uvwx 形成,以此类推。这为我们提供了一个心理捷径,当我们看到与张量相关的形状时,这将非常有用。

将张量可以想象成什么的有用心理模型如下:一个向量就像一个事物列表,一个二元组就像一个向量列表,一个三元组就像一个二元组列表,以此类推。这在思考图像时非常有帮助,就像我们在上一章中看到的那样:

一张图像可以看作是一个 (28, 28) 矩阵。十个图像的列表将具有形状 (10, 28, 28)。如果我们想以这样的方式排列图像,使其成为十个图像的列表的列表,那么它的形状将是 (10, 10, 28, 28)。

当然,这一切都有一个前提:张量只能在变换存在的情况下定义。正如一位物理教授曾经告诉我的:“那些像张量一样变换的东西就是张量”。没有任何变换的张量只是一个 n- 维数据数组。数据必须变换,或者在一个方程中从张量流向张量。在这方面,我认为 TensorFlow 是一个极其恰当命名的产品。

关于张量的更多信息,我推荐相对密集的教科书,Kostrikin 的《线性代数与几何》(我未能完成这本书,但正是这本书给了我一个我认为相当强的张量理解)。关于张量流的信息可以在 Spivak 的《微分几何》中找到。

所有表达式都是图

现在我们终于可以回到前面的例子了。

如果你记得,我们的问题是我们必须指定神经网络两次:一次用于预测,一次用于学习目的。然后我们重构了程序,这样我们就不必两次指定网络。此外,我们必须手动编写反向传播的表达式。这很容易出错,尤其是在处理像我们在本章将要构建的这样的大型神经网络时。有没有更好的方法?答案是肯定的。

一旦我们理解和完全内化了神经网络本质上是一种数学表达式的观点,我们就可以从张量中吸取经验,并构建一个神经网络,其中整个神经网络是张量流。

回想一下,张量只能在变换存在的情况下定义;那么,任何用于变换张量(们)的操作,与持有数据的结构一起使用时,都是张量。此外,回想一下,计算机程序可以表示为抽象语法树。数学表达式可以表示为一个程序。因此,数学表达式也可以表示为抽象语法树。

然而,更准确地说,数学表达式可以表示为图;具体来说,是一个有向无环图。我们称之为表达式图

这种区别很重要。树不能共享节点。图可以。让我们考虑以下数学表达式:

图片

这里是图和树的表示:

图片

在左边,我们有一个有向无环图,在右边,我们有一个树。请注意,在数学方程的树变体中,有重复的节点。两者都以图片为根。箭头应该读作依赖于图片依赖于两个其他节点,图片图片,等等。

图和树都是同一数学方程的有效表示,当然。

为什么要把数学表达式表示为图或树呢?回想一下,抽象语法树表示一个计算。如果一个数学表达式,以图或树的形式表示,具有共享的计算概念,那么它也代表了一个抽象语法树。

的确,我们可以对图或树中的每个节点进行计算。如果每个节点是计算的表示,那么逻辑上就越少的节点意味着计算越快(以及更少的内存使用)。因此,我们应该更喜欢使用有向无环图表示。

现在我们来到了将数学表达式表示为图的主要好处:我们能够免费获得微分。

如果您从上一章回忆起来,反向传播本质上是对输入的成本进行微分。一旦计算了梯度,就可以用来更新权重的值。有了图结构,我们就不必编写反向传播的部分。相反,如果我们有一个执行图的虚拟机,从叶子节点开始,向根节点移动,虚拟机可以自动在遍历图从叶子到根的过程中对值进行微分。

或者,如果我们不想进行自动微分,我们也可以通过操纵图来执行符号微分,就像我们在“什么是编程”部分中操纵 AST 一样,通过添加和合并节点。

以这种方式,我们现在可以将我们对神经网络的看法转移到这个:

图片

描述神经网络

现在我们回到编写神经网络的任务,并以图表示的数学表达式来思考它。回想一下,代码看起来像这样:

import (
  G "gorgonia.org/gorgonia"
)

var Float tensor.Float = tensor.Float64
func main() {
  g := G.NewGraph()
  x := G.NewMatrix(g, Float, G.WithName("x"), G.WithShape(N, 728))
  w := G.NewMatrix(g, Float, G.WithName("w"), G.WithShape(728, 800), 
                   G.WithInit(G.Uniform(1.0)))
  b := G.NewMatrix(g, Float, G.WithName("b"), G.WithShape(N, 800), 
                   G.WithInit(G.Zeroes()))
  xw, _ := G.Mul(x, w)
  xwb, _ := G.Add(xw, b)
  act, _ := G.Sigmoid(xwb)

  w2 := G.NewMatrix(g, Float, G.WithName("w2"), G.WithShape(800, 10), 
                    G.WithInit(G.Uniform(1.0)))
  b2 := G.NewMatrix(g, Float, G.WithName("b2"), G.WithShape(N, 10),  
                    G.WithInit(G.Zeroes()))
  xw2, _ := G.Mul(act, w2)
  xwb2, _ := G.Add(xw2, b2)
  sm, _ := G.SoftMax(xwb2)
}

现在我们来分析这段代码。

首先,我们使用g := G.NewGraph()创建一个新的表达式图。表达式图是一个持有数学表达式的对象。我们为什么想要一个表达式图呢?表示神经网络的数学表达式包含在*gorgonia.ExpressionGraph对象中。

数学表达式只有在我们使用变量时才有意思。是一个非常无趣的表达式,因为你无法用这个表达式做很多事情。你可以做的唯一事情是评估这个表达式,看看它返回的是真还是假。稍微有趣一些。但再次强调,a只能为1

然而,考虑一下这个表达式!。有了两个变量,它突然变得更有趣。ab可以取的值相互依赖,并且存在一系列可能的数字对可以适合到ab中。

回想一下,神经网络中的每一层仅仅是一个类似这样的数学表达式:。在这种情况下,wxb是变量。因此,我们创建了它们。请注意,在这种情况下,Gorgonia 将变量处理得就像编程语言一样:你必须告诉系统变量代表什么。

在 Go 语言中,你会通过输入var x Foo来完成这个操作,这告诉 Go 编译器x应该是一个类型Foo。在 Gorgonia 中,数学变量通过使用NewMatrixNewVectorNewScalarNewTensor来声明。x := G.NewMatrix(g, Float, G.WithName, G.WithShape(N, 728))简单地说,x是表达式图g中的一个名为x的矩阵,其形状为(N, 728)

在这里,读者可能会注意到728是一个熟悉的数字。实际上,这告诉我们x代表输入,即N张图像。因此,x是一个包含N行的矩阵,其中每一行代表一张单独的图像(728 个浮点数)。

留意细节的读者会注意到wb有额外的选项,而x的声明没有。你看,NewMatrix只是声明了表达式图中的变量。它没有与之关联的值。这允许在值附加到变量时具有灵活性。然而,关于权重矩阵,我们希望方程从一些初始值开始。G.WithInit(G.Uniform(1.0))是一个构造选项,它使用具有增益1.0的均匀分布的值填充权重矩阵。如果你想象自己在另一种专门用于构建神经网络的编程语言中编码,它看起来可能像这样:var w Matrix(728, 800) = Uniform(1.0)

在此之后,我们只需写出数学方程式: 简单来说,就是矩阵乘法,它是 之间的乘法;因此,xw, _ := G.Mul(x, w). 在这一点上,应该明确的是,我们只是在描述应该发生的计算。它尚未发生。这种方式与编写程序并无太大区别;编写代码并不等同于运行程序。

G.Mul 和 Gorgonia 中的大多数操作实际上都会返回一个错误。为了演示的目的,我们忽略了从符号上乘以 xw 可能产生的任何错误。简单的乘法可能出错吗?嗯,我们处理的是矩阵乘法,所以形状必须具有匹配的内部维度。一个 (N, 728) 矩阵只能与一个 (728, M) 矩阵相乘,这将导致一个 (N, M) 矩阵。如果第二个矩阵没有 728 行,那么将发生错误。因此,在实际的生产代码中,错误处理是必须的

说到必须,Gorgonia 提供了一个名为 G.Must 的实用函数。从标准库中找到的 text/templatehtml/template 库中汲取灵感,当发生错误时,G.Must 函数会引发恐慌。要使用,只需编写这个:xw := G.Must(G.Mul(x,w))

在将输入与权重相乘之后,我们使用 G.Add(xw, b) 将偏差加到上面。同样,可能会发生错误,但在这个例子中,我们省略了错误检查。

最后,我们将结果与非线性函数:sigmoid 函数,通过 G.Sigmoid(xwb) 进行处理。这一层现在已经完成。如果你跟着走,它的形状将是 (N, 800)。

完成的层随后被用作下一层的输入。下一层的布局与第一层相似,只是没有使用 sigmoid 非线性,而是使用了 G.SoftMax。这确保了结果矩阵中的每一行总和为 1。

单热向量

也许并非巧合,最后一层的形状是 (N, 10)。N 是输入图像的数量(我们从 x 中获得);这一点相当直观。这也意味着输入到输出的映射是清晰的。不那么直观的是 10。为什么是 10?简单来说,我们想要预测 10 个可能的数字 - 0, 1, 2, 3, 4, 5, 6, 7, 8, 9:

上一图是一个示例结果矩阵。回想一下,我们使用了 G.SoftMax 来确保每一行的总和为 1。因此,我们可以将每一行每一列的数字解释为预测特定数字的概率。要找到我们预测的数字,只需找到每一列的最高概率即可。

在上一章中,我介绍了单热向量编码的概念。为了回顾,它接受一个标签切片并返回一个矩阵。

现在,这显然是一个编码问题。谁又能说列 0 必须代表 0 呢?我们当然可以想出一个完全疯狂的编码方式,比如这样,神经网络仍然可以工作:

当然,我们不会使用这样的编码方案;这将是一个巨大的编程错误来源。相反,我们将采用一热向量的标准编码。

我希望这已经让你感受到了表达式图概念的力量。我们还没有涉及到的是图的执行。你该如何运行一个图呢?我们将在下一节进一步探讨这个问题。

项目

所有的准备工作都完成之后,是时候开始项目了!再次强调,我们将识别手写数字。但这一次,我们将构建一个 CNN 来完成这个任务。这一次,我们不仅会使用 Gorgonia 的tensor包,还会使用 Gorgonia 的所有功能。

再次提醒,要安装 Gorgonia,只需运行 go get -u gorgonia.org/gorgoniago get -u gorgonia.org/tensor

获取数据

数据与上一章相同:MNIST 数据集。它可以在本章的仓库中找到,我们将使用上一章编写的函数来获取数据:

// Image holds the pixel intensities of an image.
// 255 is foreground (black), 0 is background (white).
type RawImage []byte

// Label is a digit label in 0 to 9
type Label uint8

const numLabels = 10
const pixelRange = 255

const (
  imageMagic = 0x00000803
  labelMagic = 0x00000801
  Width = 28
  Height = 28
)

func readLabelFile(r io.Reader, e error) (labels []Label, err error) {
  if e != nil {
    return nil, e
  }

  var magic, n int32
  if err = binary.Read(r, binary.BigEndian, &magic); err != nil {
    return nil, err
  }
  if magic != labelMagic {
    return nil, os.ErrInvalid
  }
  if err = binary.Read(r, binary.BigEndian, &n); err != nil {
    return nil, err
  }
  labels = make([]Label, n)
  for i := 0; i < int(n); i++ {
    var l Label
    if err := binary.Read(r, binary.BigEndian, &l); err != nil {
      return nil, err
    }
    labels[i] = l
  }
  return labels, nil
}

func readImageFile(r io.Reader, e error) (imgs []RawImage, err error) {
  if e != nil {
    return nil, e
  }

  var magic, n, nrow, ncol int32
  if err = binary.Read(r, binary.BigEndian, &magic); err != nil {
    return nil, err
  }
  if magic != imageMagic {
    return nil, err /*os.ErrInvalid*/
  }
  if err = binary.Read(r, binary.BigEndian, &n); err != nil {
    return nil, err
  }
  if err = binary.Read(r, binary.BigEndian, &nrow); err != nil {
    return nil, err
  }
  if err = binary.Read(r, binary.BigEndian, &ncol); err != nil {
    return nil, err
  }
  imgs = make([]RawImage, n)
  m := int(nrow * ncol)
  for i := 0; i < int(n); i++ {
    imgs[i] = make(RawImage, m)
    m_, err := io.ReadFull(r, imgs[i])
    if err != nil {
      return nil, err
    }
    if m_ != int(m) {
      return nil, os.ErrInvalid
    }
  }
 return imgs, nil

上一章的其他内容

显然,我们可以从上一章中重用很多内容:

  • 范围归一化函数(pixelWeight)及其等距对应函数(reversePixelWeight

  • prepareXprepareY

  • visualize 函数

为了方便,这里再次列出:

func pixelWeight(px byte) float64 {
    retVal := (float64(px) / 255 * 0.999) + 0.001
    if retVal == 1.0 {
        return 0.999
    }
    return retVal
}
func reversePixelWeight(px float64) byte {
    return byte(((px - 0.001) / 0.999) * 255)
}
func prepareX(M []RawImage) (retVal tensor.Tensor) {
    rows := len(M)
    cols := len(M[0])

    b := make([]float64, 0, rows*cols)
    for i := 0; i < rows; i++ {
        for j := 0; j < len(M[i]); j++ {
            b = append(b, pixelWeight(M[i][j]))
        }
    }
    return tensor.New(tensor.WithShape(rows, cols), tensor.WithBacking(b))
}
func prepareY(N []Label) (retVal tensor.Tensor) {
    rows := len(N)
    cols := 10

    b := make([]float64, 0, rows*cols)
    for i := 0; i < rows; i++ {
        for j := 0; j < 10; j++ {
            if j == int(N[i]) {
                b = append(b, 0.999)
            } else {
                b = append(b, 0.001)
            }
        }
    }
    return tensor.New(tensor.WithShape(rows, cols), tensor.WithBacking(b))
}
func visualize(data tensor.Tensor, rows, cols int, filename string) (err error) {
    N := rows * cols

    sliced := data
    if N > 1 {
        sliced, err = data.Slice(makeRS(0, N), nil) // data[0:N, :] in python
        if err != nil {
            return err
        }
    }

    if err = sliced.Reshape(rows, cols, 28, 28); err != nil {
        return err
    }

    imCols := 28 * cols
    imRows := 28 * rows
    rect := image.Rect(0, 0, imCols, imRows)
    canvas := image.NewGray(rect)

    for i := 0; i < cols; i++ {
        for j := 0; j < rows; j++ {
            var patch tensor.Tensor
            if patch, err = sliced.Slice(makeRS(i, i+1), makeRS(j,  
                                         j+1)); err != nil {
                return err
            }

            patchData := patch.Data().([]float64)
            for k, px := range patchData {
                x := j*28 + k%28
                y := i*28 + k/28
                c := color.Gray{reversePixelWeight(px)}
                canvas.Set(x, y, c)
            }
        }
    }

    var f io.WriteCloser
    if f, err = os.Create(filename); err != nil {
        return err
    }

    if err = png.Encode(f, canvas); err != nil {
        f.Close()
        return err
    }

    if err = f.Close(); err != nil {
        return err
    }
    return nil
}

CNNs

我们将要构建的是一个 CNN。那么,什么是卷积神经网络呢?正如其名所示,它是一个神经网络,与我们之前构建的神经网络类似。所以,显然,它们有一些相似之处。也有一些不同之处,因为如果它们相似,我们就不需要这一章了。

什么是卷积?

与我们在上一章构建的神经网络相比,CNN 的主要区别在于卷积层。回想一下,神经网络能够学习与数字相关的特征。为了更精确,神经网络层需要学习更具体的特征。实现这一目标的一种方法就是添加更多的层;更多的层会导致学习到更多的特征,从而产生深度学习。

在 1877 年一个春天的傍晚,穿着现代人们认为是黑色礼服的人们聚集在伦敦的皇家学会。晚上的演讲者是弗朗西斯·高尔顿,也就是我们在第一章,如何解决所有机器学习问题中遇到的高尔顿。在他的演讲中,高尔顿展示了一个奇特的装置,他称之为五点阵。这是一个垂直的木制板,上面有木钉均匀地交错排列。前面覆盖着玻璃,顶部有一个开口。然后从顶部滴下微小的球,当它们击中木钉时,会向左或向右弹跳,并落入相应的斜槽中。这个过程一直持续到球收集到底部:

图片

一个好奇的形状开始形成。这是现代统计学家已经认识到的二项分布的形状。大多数统计教材的故事就在这里结束。五点阵,现在被称为高尔顿板,非常清晰和坚定地说明了中心极限定理的概念。

当然,我们的故事并没有结束。回想一下第一章,如何解决所有机器学习问题,我提到高尔顿非常关注遗传问题。几年前,高尔顿出版了一本名为遗传天才的书。他收集了前几个世纪英国杰出人物的数据,让他非常沮丧的是,他发现杰出的父系往往会导致不杰出的子女。他把这称为回归到平庸

图片

图片

然而,他推理道,数学并没有显示出这样的事情!他通过展示一个两层结构的五点阵来解释这一点。两层结构的五点阵是代际效应的替代品。顶层基本上是特征的分布(比如说,身高)。当下降到第二层时,珠子会导致分布扁平化,而这并不是他所观察到的。相反,他推测必须存在另一个因素,导致回归到平均值。为了说明他的想法,他安装了斜槽作为控制因素,这会导致回归到平均值。仅仅 40 年后,孟德尔的豌豆实验的重新发现将揭示遗传是这一因素。那是一个另外的故事。

我们感兴趣的是为什么分布会扁平化。虽然标准的它是物理!可以作为一个答案,但仍然存在一些有趣的问题我们可以问。让我们看看一个简化的描述:

图片

在这里,我们评估球落下并击中某个位置的概率。曲线表示球落在位置 B 的概率。现在,我们添加一个第二层:

假设,从上一层,球落在位置 2。那么,球最终静止在位置 D 的概率是多少?

为了计算这个,我们需要知道球最终到达位置 D 的所有可能方式。限制我们的选项只从 A 到 D,这里它们是:

Level 1 Position L1 Horizontal Distance Level 2 position L2 Horizontal Distance
A 0 D 3
B 1 D 2
C 2 D 1
D 3 D 0

现在我们可以用概率来提问。表中的水平距离是一种编码,允许我们以概率和通用的方式提问。球水平移动一个单位的概率可以表示为P(1),球水平移动两个单位的概率可以表示为P(2),依此类推。

要计算球在两个级别后最终落在 D 的概率,本质上就是将所有概率加起来:

我们可以写成这样:

我们可以将其理解为最终距离为\(c = a+b\)的概率是\(P_1(a)\)的和,其中 1 级水平,球水平移动了\(a\),以及\(P_2(b)\)的和,其中 2 级水平,球水平移动了\(b\)

这就是卷积的典型定义:

如果积分让你感到害怕,我们可以等效地将其重写为求和操作(这仅在我们考虑离散值时有效;对于连续实数值,必须使用积分):

现在,如果你非常仔细地眯着眼睛看,这个方程看起来非常像前面的概率方程。用代替,我们可以将其重写为

而概率是什么呢,但函数?毕竟,我们之所以用\(P(a)\)的格式写概率,是有原因的。我们确实可以将概率方程泛化到卷积定义。

然而,现在让我们加强我们对卷积的理解。为此,我们将保持我们讨论的函数具有概率的概念。首先,我们应该注意球最终落在特定位置的概率取决于它开始的位置。但想象一下,如果第二个平台的平台水平移动:

现在球的最终静止位置高度依赖于初始起始位置,以及第二层起始位置。球甚至可能不会落在底部!

因此,这里有一个关于卷积的良好心理捷径:就像一个层中的函数在另一个函数上滑动一样。

因此,卷积是导致高尔顿方阵展平的原因。本质上,这是一个在水平维度上滑动的函数,它在移动过程中将概率函数展平。这是一个一维卷积;球只沿着一个维度移动。

二维卷积与一维卷积类似。相反,对于每一层,我们考虑两个距离或度量:

图片

但这个方程几乎无法理解。相反,这里有一系列如何逐步工作的方便图片:

卷积(步骤 1):

图片

卷积(步骤 2):

图片

卷积(步骤 3):

图片

卷积(步骤 4):

图片

卷积(步骤 5):

图片

卷积(步骤 6):

图片

卷积(步骤 7):

图片

卷积(步骤 8):

图片

卷积(步骤 9):

图片

再次,你可以将这想象为在二维空间中滑动一个函数,该函数在另一个函数(输入)上滑动。滑动的函数执行标准的线性代数变换,即乘法后加法。

你可以在一个图像处理示例中看到这一点,这个示例无疑是常见的:Instagram。

Instagram 滤镜的工作原理

我假设你熟悉 Instagram。如果不熟悉,我既羡慕又同情你;但这里是 Instagram 的要点:它是一个照片分享服务,其卖点在于允许用户对其图像应用过滤器。这些过滤器会改变图像的颜色,通常是为了增强主题。

这些过滤器是如何工作的?卷积!

例如,让我们定义一个过滤器:

图片

要进行卷积,我们只需将过滤器滑动到以下图中(这是一位名叫皮特·丘的艺术家的一幅非常著名的艺术品):

图片

应用前面的过滤器会产生如下效果:

图片

是的,过滤器会模糊图像!

这里有一个用 Go 编写的示例,以强调这个想法:

func main() {
  kb := []float64{
    1 / 16.0, 1 / 8.0, 1 / 16.0,
    1 / 8.0, 1 / 4.0, 1 / 8.0,
    1 / 16.0, 1 / 8.0, 1 / 16.0,
  }
  k := tensor.New(tensor.WithShape(3,3), tensor.WithBacking(kb))

  for _, row := range imgIt {
    for j, px := range row {
      var acc float64

      for _, krow := range kIt {
        for _, kpx := range krow {
          acc += px * kpx 
        }
      }
      row[j] = acc
    }
  }
}

函数当然相当慢且效率低下。Gorgonia 自带一个更复杂的算法

回到神经网络

好的,现在我们知道卷积在过滤器使用中很重要。但这与神经网络有什么关系呢?

回想一下,神经网络被定义为作用于其上的非线性应用()的线性变换。注意,x,输入图像,作为一个整体被作用。这就像在整个图像上有一个单一的过滤器。但如果我们能一次处理图像的一小部分会怎样呢?

除了这些,在前一节中,我展示了如何使用一个简单的过滤器来模糊图像。过滤器也可以用来锐化图像,突出重要的特征,同时模糊掉不重要的特征。那么,如果一台机器能够学会创建什么样的过滤器呢?

这就是为什么我们想在神经网络中使用卷积的原因:

  • 卷积一次作用于图像的小部分,只留下重要的特征

  • 我们可以学习特定的过滤器

这给了机器很多精细的控制。现在,我们不再需要一个同时作用于整个图像的粗糙特征检测器,我们可以构建许多过滤器,每个过滤器专门针对一个特定的特征,从而允许我们提取出对数字分类必要的特征。

Max-pooling

现在我们心中有一个概念性的机器,它会学习它需要应用到图像上以提取特征的过滤器。但是,同时,我们不想让机器过度拟合学习。一个对训练数据过度具体的过滤器在现实生活中是没有用的。例如,如果一个过滤器学会所有的人类面孔都有两只眼睛、一个鼻子和一个嘴巴,那就结束了,它将无法分类一个半张脸被遮挡的人的图片。

因此,为了尝试教会机器学习算法更好地泛化,我们只是给它更少的信息。Max-pooling 是这样一个过程,dropout(见下一节)也是如此。

Max-pooling 的工作原理是将输入数据分成非重叠的区域,并简单地找到该区域的最大值:

图片

当然,有一个隐含的理解,这肯定会改变输出的形状。实际上,你会观察到它缩小了图像。

Dropout

Max-pooling 后的结果是输出中的最小信息。但这可能仍然信息过多;机器可能仍然会过度拟合。因此,出现了一个非常有趣的问题:如果随机将一些激活置零会怎样?

这就是 Dropout 的基础。这是一个非常简单但能提高机器学习算法泛化能力的方法,它通过影响信息来达到目的。在每次迭代中,随机激活被置零。这迫使算法只学习真正重要的东西。它是如何做到这一点的涉及到结构代数,这是另一个故事。

对于这个项目来说,Gorgonia 实际上是通过使用随机生成的 1s 和 0s 矩阵进行逐元素乘法来处理 Dropout 的。

描述 CNN

说了这么多,构建神经网络是非常简单的。首先,我们这样定义一个神经网络:

type convnet struct {
    g                  *gorgonia.ExprGraph
    w0, w1, w2, w3, w4 *gorgonia.Node // weights. the number at the back indicates which layer it's used for
    d0, d1, d2, d3     float64        // dropout probabilities

    out    *gorgonia.Node
    outVal gorgonia.Value
}

在这里,我们定义了一个具有四层的神经网络。卷积层在许多方面类似于线性层。例如,它可以写成方程:

图片

注意,在这个特定的例子中,我考虑 dropout 和 max-pool 是同一层的部分。在许多文献中,它们被认为是独立的层。

我个人认为没有必要将它们视为独立的层。毕竟,一切只是数学方程;函数的组合是自然而然的。

一个没有结构的数学方程本身是相当没有意义的。不幸的是,我们并没有足够的技术来简单地定义数据类型(类型依赖性语言,如 Idris,在这方面很有前景,但它们还没有达到深度学习所需的可用性或性能水平)。相反,我们必须通过提供一个函数来定义convnet来约束我们的数据结构:

func newConvNet(g *gorgonia.ExprGraph) *convnet {
  w0 := gorgonia.NewTensor(g, dt, 4, gorgonia.WithShape(32, 1, 3, 3), 
                 gorgonia.WithName("w0"),    
                 gorgonia.WithInit(gorgonia.GlorotN(1.0)))
  w1 := gorgonia.NewTensor(g, dt, 4, gorgonia.WithShape(64, 32, 3, 3), 
                 gorgonia.WithName("w1"),  
                 gorgonia.WithInit(gorgonia.GlorotN(1.0)))
  w2 := gorgonia.NewTensor(g, dt, 4, gorgonia.WithShape(128, 64, 3, 3), 
                 gorgonia.WithName("w2"), 
                 gorgonia.WithInit(gorgonia.GlorotN(1.0)))
  w3 := gorgonia.NewMatrix(g, dt, gorgonia.WithShape(128*3*3, 625), 
                 gorgonia.WithName("w3"), 
                 gorgonia.WithInit(gorgonia.GlorotN(1.0)))
  w4 := gorgonia.NewMatrix(g, dt, gorgonia.WithShape(625, 10), 
                 gorgonia.WithName("w4"), 
                 gorgonia.WithInit(gorgonia.GlorotN(1.0)))
  return &convnet{
    g: g,
    w0: w0,
    w1: w1,
    w2: w2,
    w3: w3,
    w4: w4,

    d0: 0.2,
    d1: 0.2,
    d2: 0.2,
    d3: 0.55,
  }
}

我们将从dt开始。这本质上是一个全局变量,表示我们希望在哪种数据类型下工作。对于这个项目的目的,我们可以使用var dt = tensor.Float64来表示我们希望在项目的整个过程中使用float64。这允许我们立即重用上一章中的函数,而无需处理不同的数据类型。注意,如果我们确实计划使用float32,计算速度会立即加倍。在本书的代码库中,你可能会注意到代码使用了float32

我们将从d0一直到最后d3开始。这相当简单。对于前三层,我们希望 20%的激活随机置零。但对于最后一层,我们希望 55%的激活随机置零。从非常粗略的角度来看,这会导致信息瓶颈,这将导致机器只学习真正重要的特征。

看看w0是如何定义的。在这里,我们说w0是一个名为w0的变量。它是一个形状为(32, 1, 3, 3)的张量。这通常被称为批次数量、通道、高度、宽度NCHW/BCHW)格式。简而言之,我们说的是我们希望学习 32 个过滤器,每个过滤器的高度和宽度为(3, 3),并且它有一个颜色通道。MNIST 毕竟只有黑白。

BCHW 不是唯一的格式!一些深度学习框架更喜欢使用 BHWC 格式。选择一种格式而不是另一种格式纯粹是出于操作上的考虑。一些卷积算法与 NCHW 配合得更好;一些与 BHWC 配合得更好。Gorgonia 中的那些只支持 BCHW。

3 x 3 滤波器的选择纯粹是无原则的,但并非没有先例。你可以选择 5 x 5 滤波器,或者 2 x 1 滤波器,或者实际上,任何形状的滤波器。然而,必须说的是,3 x 3 滤波器可能是最通用的滤波器,可以在各种图像上工作。这类正方形滤波器在图像处理算法中很常见,因此我们选择 3 x 3 是遵循这样的传统。

高层权重开始看起来更有趣。例如,w1 的形状为 (64, 32, 3, 3)。为什么?为了理解为什么,我们需要探索激活函数和形状之间的相互作用。以下是 convnet 的整个前向函数:

// This function is particularly verbose for educational reasons. In reality, you'd wrap up the layers within a layer struct type and perform per-layer activations
func (m *convnet) fwd(x *gorgonia.Node) (err error) {
    var c0, c1, c2, fc *gorgonia.Node
    var a0, a1, a2, a3 *gorgonia.Node
    var p0, p1, p2 *gorgonia.Node
    var l0, l1, l2, l3 *gorgonia.Node

    // LAYER 0
    // here we convolve with stride = (1, 1) and padding = (1, 1),
    // which is your bog standard convolution for convnet
    if c0, err = gorgonia.Conv2d(x, m.w0, tensor.Shape{3, 3}, []int{1, 1}, []int{1, 1}, []int{1, 1}); err != nil {
        return errors.Wrap(err, "Layer 0 Convolution failed")
    }
    if a0, err = gorgonia.Rectify(c0); err != nil {
        return errors.Wrap(err, "Layer 0 activation failed")
    }
    if p0, err = gorgonia.MaxPool2D(a0, tensor.Shape{2, 2}, []int{0, 0}, []int{2, 2}); err != nil {
        return errors.Wrap(err, "Layer 0 Maxpooling failed")
    }
    if l0, err = gorgonia.Dropout(p0, m.d0); err != nil {
        return errors.Wrap(err, "Unable to apply a dropout")
    }

    // Layer 1
    if c1, err = gorgonia.Conv2d(l0, m.w1, tensor.Shape{3, 3}, []int{1, 1}, []int{1, 1}, []int{1, 1}); err != nil {
        return errors.Wrap(err, "Layer 1 Convolution failed")
    }
    if a1, err = gorgonia.Rectify(c1); err != nil {
        return errors.Wrap(err, "Layer 1 activation failed")
    }
    if p1, err = gorgonia.MaxPool2D(a1, tensor.Shape{2, 2}, []int{0, 0}, []int{2, 2}); err != nil {
        return errors.Wrap(err, "Layer 1 Maxpooling failed")
    }
    if l1, err = gorgonia.Dropout(p1, m.d1); err != nil {
        return errors.Wrap(err, "Unable to apply a dropout to layer 1")
    }

    // Layer 2
    if c2, err = gorgonia.Conv2d(l1, m.w2, tensor.Shape{3, 3}, []int{1, 1}, []int{1, 1}, []int{1, 1}); err != nil {
        return errors.Wrap(err, "Layer 2 Convolution failed")
    }
    if a2, err = gorgonia.Rectify(c2); err != nil {
        return errors.Wrap(err, "Layer 2 activation failed")
    }
    if p2, err = gorgonia.MaxPool2D(a2, tensor.Shape{2, 2}, []int{0, 0}, []int{2, 2}); err != nil {
        return errors.Wrap(err, "Layer 2 Maxpooling failed")
    }
    log.Printf("p2 shape %v", p2.Shape())

    var r2 *gorgonia.Node
    b, c, h, w := p2.Shape()[0], p2.Shape()[1], p2.Shape()[2], p2.Shape()[3]
    if r2, err = gorgonia.Reshape(p2, tensor.Shape{b, c * h * w}); err != nil {
        return errors.Wrap(err, "Unable to reshape layer 2")
    }
    log.Printf("r2 shape %v", r2.Shape())
    if l2, err = gorgonia.Dropout(r2, m.d2); err != nil {
        return errors.Wrap(err, "Unable to apply a dropout on layer 2")
    }

    // Layer 3
    if fc, err = gorgonia.Mul(l2, m.w3); err != nil {
        return errors.Wrapf(err, "Unable to multiply l2 and w3")
    }
    if a3, err = gorgonia.Rectify(fc); err != nil {
        return errors.Wrapf(err, "Unable to activate fc")
    }
    if l3, err = gorgonia.Dropout(a3, m.d3); err != nil {
        return errors.Wrapf(err, "Unable to apply a dropout on layer 3")
    }

    // output decode
    var out *gorgonia.Node
    if out, err = gorgonia.Mul(l3, m.w4); err != nil {
        return errors.Wrapf(err, "Unable to multiply l3 and w4")
    }
    m.out, err = gorgonia.SoftMax(out)
    gorgonia.Read(m.out, &m.outVal)
    return
}

应该注意的是,卷积层确实会改变输入的形状。给定一个 (N, 1, 28, 28) 的输入,Conv2d 函数将返回一个 (N, 32, 28, 28) 的输出,这正是因为现在有 32 个滤波器。MaxPool2d 将返回一个形状为 (N, 32, 14, 14) 的输出;回想一下,最大池化的目的是减少神经网络中的信息量。碰巧的是,形状为 (2, 2) 的最大池化将很好地将图像的长度和宽度减半(并将信息量减少四倍)。

第 0 层的输出形状为 (N, 32, 14, 14)。如果我们坚持我们之前对形状的解释,即格式为 (N, C, H, W),我们可能会感到困惑。32 个通道是什么意思?为了回答这个问题,让我们看看我们是如何根据 BCHW 编码彩色图像的:

图片

注意,我们将其编码为三个单独的层,堆叠在一起。这是关于如何思考有 32 个通道的一个线索。当然,每个 32 个通道都是应用每个 32 个滤波器的结果;可以说是提取的特征。结果当然可以以相同的方式堆叠,就像颜色通道一样。

然而,在很大程度上,仅仅进行符号推演就足以构建一个深度学习系统;不需要真正的智能。这当然反映了中国房间难题的思想实验,我对这一点有很多话要说,尽管现在并不是时候也不是地方。

更有趣的部分在于第三层的构建。第一层和第二层的构建与第 0 层非常相似,但第三层的构建略有不同。原因是第 2 层的输出是一个秩为 4 的张量,但为了执行矩阵乘法,它需要被重塑为秩为 2 的张量。

最后,解码输出的最后一层使用 softmax 激活函数来确保我们得到的结果是概率。

实际上,这就是你所看到的。一个用非常整洁的方式编写的 CNN,它并没有模糊数学定义。

反向传播

为了让卷积神经网络学习,所需的是反向传播,它传播误差,以及一个梯度下降函数来更新权重矩阵。在 Gorgonia 中这样做相对简单,简单到我们甚至可以在主函数中实现它而不影响可读性:

func main() {
    flag.Parse()
    parseDtype()
    imgs, err := readImageFile(os.Open("train-images-idx3-ubyte"))
    if err != nil {
        log.Fatal(err)
    }
    labels, err := readLabelFile(os.Open("train-labels-idx1-ubyte"))
    if err != nil {
        log.Fatal(err)
    }

    inputs := prepareX(imgs)
    targets := prepareY(labels)

    // the data is in (numExamples, 784).
    // In order to use a convnet, we need to massage the data
    // into this format (batchsize, numberOfChannels, height, width).
    //
    // This translates into (numExamples, 1, 28, 28).
    //
    // This is because the convolution operators actually understand height and width.
    //
    // The 1 indicates that there is only one channel (MNIST data is black and white).
    numExamples := inputs.Shape()[0]
    bs := *batchsize

    if err := inputs.Reshape(numExamples, 1, 28, 28); err != nil {
        log.Fatal(err)
    }
    g := gorgonia.NewGraph()
    x := gorgonia.NewTensor(g, dt, 4, gorgonia.WithShape(bs, 1, 28, 28), gorgonia.WithName("x"))
    y := gorgonia.NewMatrix(g, dt, gorgonia.WithShape(bs, 10), gorgonia.WithName("y"))
    m := newConvNet(g)
    if err = m.fwd(x); err != nil {
        log.Fatalf("%+v", err)
    }
    losses := gorgonia.Must(gorgonia.HadamardProd(m.out, y))
    cost := gorgonia.Must(gorgonia.Mean(losses))
    cost = gorgonia.Must(gorgonia.Neg(cost))

    // we wanna track costs
    var costVal gorgonia.Value
    gorgonia.Read(cost, &costVal)

    if _, err = gorgonia.Grad(cost, m.learnables()...); err != nil {
        log.Fatal(err)
    }

对于误差,我们使用简单的交叉熵,通过逐元素相乘期望输出并平均,如本片段所示:

    losses := gorgonia.Must(gorgonia.HadamardProd(m.out, y))
    cost := gorgonia.Must(gorgonia.Mean(losses))
    cost = gorgonia.Must(gorgonia.Neg(cost))

在此之后,我们只需调用gorgonia.Grad(cost, m.learnables()...),它执行符号反向传播。你可能想知道m.learnables()是什么?它只是我们希望机器学习的变量。定义如下:

func (m *convnet) learnables() gorgonia.Nodes {
    return gorgonia.Nodes{m.w0, m.w1, m.w2, m.w3, m.w4}
}

再次强调,这相当简单。

另一个我想让读者注意的评论是gorgonia.Read(cost, &costVal)Read是 Gorgonia 中较为复杂的一部分。但是,当正确地构建框架时,它相当容易理解。

早期,在描述神经网络这一节中,我把 Gorgonia 比作用另一种编程语言进行编写。如果是这样,那么Read就相当于io.WriteFilegorgonia.Read(cost, &costVal)所表达的意思是,当数学表达式被评估时,将cost的结果复制并存储在costVal中。这是由于 Gorgonia 系统中数学表达式评估的方式所必需的。

为什么叫Read而不是Write?我最初将 Gorgonia 建模为相当单调的(在 Haskell 单调的概念中),因此人们会读取一个值。经过三年的发展,这个名字似乎已经固定下来。

运行神经网络

注意,到目前为止,我们仅仅描述了我们需要执行的运算。神经网络实际上并没有运行;这只是在描述要运行的神经网络。

我们需要能够评估数学表达式。为了做到这一点,我们需要将表达式编译成一个可执行的程序。以下是实现这一点的代码:

    vm := gorgonia.NewTapeMachine(g, 
        gorgonia.WithPrecompiled(prog, locMap), 
        gorgonia.BindDualValues(m.learnables()...))
    solver := gorgonia.NewRMSPropSolver(gorgonia.WithBatchSize(float64(bs)))
    defer vm.Close()

调用gorgonia.Compile(g)并不是严格必要的。这样做是为了教学目的,展示数学表达式确实可以被编译成类似汇编的程序。在生产系统中,我经常这样做:vm := gorgonia.NewTapeMachine(g, gorgonia.BindDualValues(m.learnables()...))

Gorgonia 提供了两种vm类型,每种类型代表不同的计算模式。在这个项目中,我们仅仅使用NewTapeMachine来获取*gorgonia.tapeMachine。创建vm的函数有很多选项,而BindDualValues选项只是将模型中每个变量的梯度绑定到变量本身。这允许更便宜的梯度下降。

最后,请注意,VM是一种资源。你应该把VM想象成一个外部的 CPU,一个计算资源。在使用完外部资源后关闭它们是一个好的实践,幸运的是,Go 有一个非常方便的方式来处理清理:defer vm.Close()

在我们继续讨论梯度下降之前,这是编译后的程序看起来像伪汇编:


 Instructions:
 0 loadArg 0 (x) to CPU0
 1 loadArg 1 (y) to CPU1
 2 loadArg 2 (w0) to CPU2
 3 loadArg 3 (w1) to CPU3
 4 loadArg 4 (w2) to CPU4
 5 loadArg 5 (w3) to CPU5
 6 loadArg 6 (w4) to CPU6
 7 im2col<(3,3), (1, 1), (1,1) (1, 1)> [CPU0] CPU7 false false false
 8 Reshape(32, 9) [CPU2] CPU8 false false false
 9 Reshape(78400, 9) [CPU7] CPU7 false true false
 10 Alloc Matrix float64(78400, 32) CPU9
 11 A × Bᵀ [CPU7 CPU8] CPU9 true false true
 12 DoWork
 13 Reshape(100, 28, 28, 32) [CPU9] CPU9 false true false
 14 Aᵀ{0, 3, 1, 2} [CPU9] CPU9 false true false
 15 const 0 [] CPU10 false false false
 16 >= true [CPU9 CPU10] CPU11 false false false
 17 ⊙ false [CPU9 CPU11] CPU9 false true false
 18 MaxPool{100, 32, 28, 28}(kernel: (2, 2), pad: (0, 0), stride: (2, 
                             2)) [CPU9] CPU12 false false false
 19 0(0, 1) - (100, 32, 14, 14) [] CPU13 false false false
 20 const 0.2 [] CPU14 false false false
 21 > true [CPU13 CPU14] CPU15 false false false
 22 ⊙ false [CPU12 CPU15] CPU12 false true false
 23 const 5 [] CPU16 false false false
 24 ÷ false [CPU12 CPU16] CPU12 false true false
 25 im2col<(3,3), (1, 1), (1,1) (1, 1)> [CPU12] CPU17 false false false
 26 Reshape(64, 288) [CPU3] CPU18 false false false
 27 Reshape(19600, 288) [CPU17] CPU17 false true false
 28 Alloc Matrix float64(19600, 64) CPU19
 29 A × Bᵀ [CPU17 CPU18] CPU19 true false true
 30 DoWork
 31 Reshape(100, 14, 14, 64) [CPU19] CPU19 false true false
 32 Aᵀ{0, 3, 1, 2} [CPU19] CPU19 false true false
 33 >= true [CPU19 CPU10] CPU20 false false false
 34 ⊙ false [CPU19 CPU20] CPU19 false true false
 35 MaxPool{100, 64, 14, 14}(kernel: (2, 2), pad: (0, 0), stride: (2, 
                             2)) [CPU19] CPU21 false false false
 36 0(0, 1) - (100, 64, 7, 7) [] CPU22 false false false
 37 > true [CPU22 CPU14] CPU23 false false false
 38 ⊙ false [CPU21 CPU23] CPU21 false true false
 39 ÷ false [CPU21 CPU16] CPU21 false true false
 40 im2col<(3,3), (1, 1), (1,1) (1, 1)> [CPU21] CPU24 false false false
 41 Reshape(128, 576) [CPU4] CPU25 false false false
 42 Reshape(4900, 576) [CPU24] CPU24 false true false
 43 Alloc Matrix float64(4900, 128) CPU26
 44 A × Bᵀ [CPU24 CPU25] CPU26 true false true
 45 DoWork
 46 Reshape(100, 7, 7, 128) [CPU26] CPU26 false true false
 47 Aᵀ{0, 3, 1, 2} [CPU26] CPU26 false true false
 48 >= true [CPU26 CPU10] CPU27 false false false
 49 ⊙ false [CPU26 CPU27] CPU26 false true false
 50 MaxPool{100, 128, 7, 7}(kernel: (2, 2), pad: (0, 0), stride: (2, 
                            2)) [CPU26] CPU28 false false false
 51 Reshape(100, 1152) [CPU28] CPU28 false true false
 52 0(0, 1) - (100, 1152) [] CPU29 false false false
 53 > true [CPU29 CPU14] CPU30 false false false
 54 ⊙ false [CPU28 CPU30] CPU28 false true false
 55 ÷ false [CPU28 CPU16] CPU28 false true false
 56 Alloc Matrix float64(100, 625) CPU31
 57 A × B [CPU28 CPU5] CPU31 true false true
 58 DoWork
 59 >= true [CPU31 CPU10] CPU32 false false false
 60 ⊙ false [CPU31 CPU32] CPU31 false true false
 61 0(0, 1) - (100, 625) [] CPU33 false false false
 62 const 0.55 [] CPU34 false false false
 63 > true [CPU33 CPU34] CPU35 false false false
 64 ⊙ false [CPU31 CPU35] CPU31 false true false
 65 const 1.8181818181818181 [] CPU36 false false false
 66 ÷ false [CPU31 CPU36] CPU31 false true false
 67 Alloc Matrix float64(100, 10) CPU37
 68 A × B [CPU31 CPU6] CPU37 true false true
 69 DoWork
 70 exp [CPU37] CPU37 false true false
 71 Σ[1] [CPU37] CPU38 false false false
 72 SizeOf=10 [CPU37] CPU39 false false false
 73 Repeat[1] [CPU38 CPU39] CPU40 false false false
 74 ÷ false [CPU37 CPU40] CPU37 false true false
 75 ⊙ false [CPU37 CPU1] CPU37 false true false
 76 Σ[0 1] [CPU37] CPU41 false false false
 77 SizeOf=100 [CPU37] CPU42 false false false
 78 SizeOf=10 [CPU37] CPU43 false false false
 79 ⊙ false [CPU42 CPU43] CPU44 false false false
 80 ÷ false [CPU41 CPU44] CPU45 false false false
 81 neg [CPU45] CPU46 false false false
 82 DoWork
 83 Read CPU46 into 0xc43ca407d0
 84 Free CPU0
 Args: 11 | CPU Memories: 47 | GPU Memories: 0
 CPU Mem: 133594448 | GPU Mem []

Printing the program allows you to actually have a feel for the complexity of the neural network. At 84 instructions, the convnet is among the simpler programs I've seen. However, there are quite a few expensive operations, which would inform us quite a bit about how long each run would take. This output also tells us roughly how many bytes of memory will be used: 133594448 bytes, or 133 megabytes.

Now it's time to talk about, gradient descent. Gorgonia comes with a number of gradient descent solvers. For this project, we'll be using the RMSProp algorithm. So, we create a solver by calling `solver := gorgonia.NewRMSPropSolver(gorgonia.WithBatchSize(float64(bs)))`. Because we are planning to perform our operations in batches, we should correct the solver by providing it the batch size, lest the solver overshoots its target.

![](https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/go-ml-proj/img/4c1d9844-845b-4a62-aed4-e0074809e3cd.png)

To run the neural network, we simply run it for a number of epochs (which is passed in as an argument to the program):

batches := numExamples / bs

log.Printf("批次 %d", batches)

bar := pb.New(batches)

bar.SetRefreshRate(time.Second)

bar.SetMaxWidth(80)

for i := 0; i < *epochs; i++ {

    bar.Prefix(fmt.Sprintf("第 %d 个周期", i))

    bar.Set(0)

    bar.Start()

    for b := 0; b < batches; b++ {

        start := b * bs

        end := start + bs

        if start >= numExamples {

            break

        }

        if end > numExamples {

            end = numExamples

        }

        var xVal, yVal tensor.Tensor

        if xVal, err = inputs.Slice(sli{start, end}); err != nil {

            log.Fatal("无法切片 x")

        }

        if yVal, err = targets.Slice(sli{start, end}); err != nil {

            log.Fatal("无法切片 y")

        }

        if err = xVal.(*tensor.Dense).Reshape(bs, 1, 28, 28); err != nil {

            log.Fatalf("无法重塑 %v", err)

        }

        gorgonia.Let(x, xVal)

        gorgonia.Let(y, yVal)

        if err = vm.RunAll(); err != nil {

            log.Fatalf("在第 %d 个周期失败: %v", i, err)

        }

        solver.Step(gorgonia.NodesToValueGrads(m.learnables()))

        vm.Reset()

        bar.Increment()

    }

    log.Printf("第 %d 个周期 | 成本 %v", i, costVal)

}

Because I was feeling a bit fancy, I decided to add a progress bar to track the progress. To do so, I'm using `cheggaaa/pb.v1` as the library to draw a progress bar. To install it, simply run `go get gopkg.in/cheggaaa/pb.v1` and to use it, simply add `import "gopkg.in/cheggaaa/pb.v1` in the imports.

The rest is fairly straightforward. From the training dataset, we slice out a small portion of it (specifically, we slice out `bs` rows). Because our program takes a rank-4 tensor as an input, the data has to be reshaped to `xVal.(*tensor.Dense).Reshape(bs, 1, 28, 28)`.

Finally, we feed the value into the function by using `gorgonia.Let`. Where `gorgonia.Read` reads a value out from the execution environment, `gorgonia.Let` puts a value into the execution environment. After which, `vm.RunAll()` executes the program, evaluating the mathematical function. As a programmed and intentional side-effect, each call to `vm.RunAll()` will populate the cost value into `costVal`.

Once the equation has been evaluated, this also means that the variables of the equation are now ready to be updated. As such, we use `solver.Step(gorgonia.NodesToValueGrads(m.learnables()))` to perform the actual gradient updates. After this, `vm.Reset()` is called to reset the VM state, ready for its next iteration.

Gorgonia in general, is pretty efficient. In the current version as this book was written, it managed to use all eight cores in my CPU as shown here:

![](https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/go-ml-proj/img/7b1b4faf-4ecf-4a20-8fe5-6aef1eb035d4.png)

# Testing

Of course we'd have to test our neural network.

First we load up the testing data:

testImgs, err := readImageFile(os.Open("t10k-images.idx3-ubyte"))

if err != nil {

log.Fatal(err)

}

testlabels, err := readLabelFile(os.Open("t10k-labels.idx1-ubyte"))

if err != nil {

log.Fatal(err)

}

testData := prepareX(testImgs)

testLbl := prepareY(testlabels)

shape := testData.Shape()

visualize(testData, 10, 10, "testData.png")


In the last line, we visualize the test data to ensure that we do indeed have the correct dataset:

![](https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/go-ml-proj/img/1f8e92fd-bd9f-4382-a9d2-1a9fc82433cc.png)

Then we have the main testing loop. Do observe that it's extremely similar to the training loop - because it's the same neural network!

var correct, total float32

numExamples = shape[0]

batches = numExamples / bs

for b := 0; b < batches; b++ {

start := b * bs

end := start + bs

if start >= numExamples {

break

}

if end > numExamples {

end = numExamples

}

var oneimg, onelabel tensor.Tensor

    if oneimg, err = testData.Slice(sli{start, end}); err != nil {

        log.Fatalf("无法切片图像 (%d, %d)", start, end)

    }

    if onelabel, err = testLbl.Slice(sli{start, end}); err != nil {

        log.Fatalf("无法切片标签 (%d, %d)", start, end)

    }

    if err = oneimg.(*tensor.Dense).Reshape(bs, 1, 28, 28); err != nil {

        log.Fatalf("无法重塑 %v", err)

    }

    gorgonia.Let(x, oneimg)

    gorgonia.Let(y, onelabel)

    if err = vm.RunAll(); err != nil {

        log.Fatal("预测 (%d, %d) 失败 %v", start, end, err)

    }

    label, _ := onelabel.(*tensor.Dense).Argmax(1)

    predicted, _ := m.outVal.(*tensor.Dense).Argmax(1)

    lblData := label.Data().([]int)

    for i, p := range predicted.Data().([]int) {

        if p == lblData[i] {

            correct++

        }

        total++

    }

}

fmt.Printf("正确/总数: %v/%v = %1.3f\n", correct, total, correct/total)

One difference is in the following snippet:

label, _ := onelabel.(*tensor.Dense).Argmax(1)

predicted, _ := m.outVal.(*tensor.Dense).Argmax(1)

lblData := label.Data().([]int)

for i, p := range predicted.Data().([]int) {

if p == lblData[i] {

    correct++

    }

    total++

}


在上一章中,我们编写了自己的 `argmax` 函数。Gorgonia 的 tensor 包实际上提供了一个方便的方法来做这件事。但为了理解发生了什么,我们首先需要查看结果。

`m.outVal`的形状是(N, 10),其中 N 是批量大小。相同的形状也适用于`onelabel`。 (N, 10)意味着有 N 行,每行有 10 列。这 10 列可能是什么?当然,它们是编码的数字!所以我们要做的是找到每行的列中的最大值。这就是第一维。因此,当调用`.ArgMax()`时,我们指定 1 作为轴。

因此,`.Argmax()`调用的结果将具有形状(N)。对于该向量中的每个值,如果它们对于`lblData`和`predicted`是相同的,那么我们就增加`correct`计数器。这为我们提供了一种计算准确度的方法。

# 准确度

我们使用准确度是因为上一章使用了准确度。这使得我们可以进行苹果对苹果的比较。此外,你可能还会注意到缺乏交叉验证。这将被留给读者作为练习。

在对批量大小为 50 和 150 个周期的神经网络训练了两个小时后,我很高兴地说,我得到了 99.87%的准确度。这甚至还不是最先进的!

在上一章中,仅用 6.5 分钟就达到了 97%的准确度。而额外提高 2%的准确度则需要更多的时间。这在现实生活中是一个因素。通常,商业决策是选择机器学习算法的一个重要因素。

# 摘要

在本章中,我们学习了神经网络,并详细研究了 Gorgonia 库。然后我们学习了如何使用 CNN 识别手写数字。

在下一章中,我们将通过在 Go 中构建一个多面部检测系统来加强我们对计算机视觉可以做什么的直觉。


# 第八章:基本面部检测

前几章可以最好地描述为尝试读取图像。这是机器学习中的一个子领域,称为**计算机视觉(CV**)。通过卷积神经网络(第七章,*卷积神经网络 – MNIST 手写识别*),我们发现卷积层学会了如何过滤图像。

有一种普遍的误解,认为任何值得做的**机器学习(ML**)都必须来自神经网络和深度学习。这显然不是事实。相反,应该将深度学习视为实现目标的一种技术;深度学习不是终点。本章的目的是让读者了解一些关于使机器学习算法在生产环境中更好地工作的见解。本章的代码非常简单。这个主题是微不足道的,许多人认为它已经被解决了。然而,这些见解并不简单。我希望本章能促使读者更深入地思考他们面临的问题。

因此,本章将要介绍的一些算法最初起源于学术界。然而,这些算法的发明是由一个高度实际的需求驱动的,通过分析这些算法是如何被发明的,我们可以学到很多东西。

在本章中,我们将通过在 Go 语言中构建多个面部检测系统,进一步加深我们对计算机视觉可以做什么的了解。我们将使用`GoCV`和`Pigo`。我们将构建的程序能够从实时网络摄像头中检测人脸。然而,本章将与前几章有所不同,因为我们将比较两种算法。目的是让读者更多地思考实际面临的问题,而不仅仅是复制粘贴代码。

# 什么是人脸?

为了检测人脸,我们需要了解什么是人脸,特别是什么是人类人脸。想想一个典型的人类人脸。一个典型的人类人脸有两只眼睛、一个鼻子和一个嘴巴。但是拥有这些特征并不足以定义一个人脸。狗也有两只眼睛、一个鼻子和一个嘴巴。毕竟,我们是哺乳动物进化的产物。

我鼓励读者更仔细地思考是什么构成了人脸。我们本能地知道什么是人脸,但要真正量化构成人脸的确切要素需要工作。通常,这可能会导致关于本质主义的哲学沉思。

如果你看过糟糕的程序化电视剧,可能会看到当电视上的侦探在数据库中进行面部识别时,人脸是用点和线描绘出来的。这些点和线主要归功于伍德罗·布莱索、海伦·陈和查尔斯·比松在 20 世纪 60 年代的工作。他们是第一批研究自动面部检测的人之一。首先注意到的是,面部标准特征——发际线、眉毛、眼睛的凹陷程度、鼻梁的高度等等,都是可以动态定义的;也就是说,这些特征是相对于彼此来测量的。这使得自动检测特征比预期的要更具挑战性。

他们的解决方案是新颖的:使用一种类似于今天绘图板的设备,标注眼睛、鼻子、嘴巴和其他面部特征的位置。然后,这些标注之间的距离被用作面部识别的特征。今天的过程与此不同,只是自动化程度更高。布莱索、陈及其团队的工作导致了大量努力,以量化像素如何共同出现以形成面部特征。

为了理解构成人脸的特征,我们需要进行抽象。描绘人脸所需的最小点数和线数是多少?观察 kaomoji 的使用可以提供有益的启示。考虑以下 kaomoji:

![](https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/go-ml-proj/img/ad935efb-80ae-4768-9ae2-3835a66890e0.png)

很容易看出这些描绘的是人脸。将它们与描绘其他事物(鱼、蜘蛛、枪和炸弹)的 kaomoji 进行对比:

![](https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/go-ml-proj/img/6f6fb6b1-cf8f-4b9b-a16c-f0a3a5e8c0be.png)

抽象的过程——即移除细节直到只剩下重要的部分——有助于人们更清晰地思考一个主题。这在艺术和数学中都是如此。在软件工程中也是如此,尽管需要对抽象进行仔细的实现。回到 kaomoji,请注意,即使在其高度抽象的形式中,它们也能够表达情感。按照显示顺序,kaomoji 展示了快乐、冷漠、爱、不满和愤怒。这些抽象描绘为我们提供了思考图片中面部特征的方法。为了确定是否存在人脸,我们只需确定那些线条是否存在。现在的问题变成了如何从照片中绘制线条?

从面部结构开始,假设在一个均匀照明的房间里。除了像格雷夫斯病这样的疾病会导致眼球突出外,眼睛通常是凹进去的。这导致眼睛区域被面部眉毛和颧骨的阴影所覆盖。在均匀照明的面部照片中,眼睛会显得在阴影中。另一方面,鼻子会显得更明亮,因为鼻子相对于面部其他部分是凸起的。同样,嘴唇有一个暗区和亮区,由一条暗线分开。这些都是在考虑检测人脸时有用的特征。

# Viola-Jones

快进到 2000 年代初。随着 Viola 和 Jones 引入了一种非常快速的对象检测方法,面部检测方法取得了飞跃。`Viola-Jones`方法虽然足够通用,可以检测任何对象,但主要是为了检测面部。Viola-Jones 方法的关键天才之处在于它使用了多个小分类器以分阶段的方式对图像区域进行分类。这被称为**级联分类器**。

为了使解释更清晰,每当在 Viola-Jones 方法中提到*分类器*时,我指的是级联分类器中的小分类器。当提到级联分类器时,将明确指出这一点。

级联分类器由许多小分类器组成。每个分类器由多个过滤器组成。关于过滤器的简要介绍,请参阅上一章(Instagram 滤镜是如何工作的)。为了检测面部,首先从图像的一个小部分(称为**窗口**)开始。依次运行分类器。如果将分类器中所有过滤器应用的结果之和超过分类器的预定义阈值,则认为它是面部的一部分。然后,级联分类器继续到下一个分类器。这是级联分类器的*级联*部分。一旦所有分类器都完成,窗口滑动到下一个像素,过程重新开始。如果级联分类器中的某个分类器未能识别出面部的一部分,则整个区域被拒绝,滑动窗口继续滑动。

过滤器通过检测面部上提到的明暗区域来工作。例如,眼睛周围区域通常是凹的,因此有阴影。如果我们要在某个区域应用过滤器,我们只会突出眼睛:

![图片](https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/go-ml-proj/img/0e8d31e7-0b40-4472-ba28-1944360cc496.png)

用于眼睛的分类器会有多个过滤器,配置为测试眼睛的可能配置。用于鼻子的分类器会有多个针对鼻子的特定过滤器。在级联分类器中,我们可以安排重要性;也许我们将眼睛定义为面部最重要的部分(毕竟,它们是灵魂之窗)。我们可以这样安排,使得级联分类器首先对眼睛区域进行分类。如果有眼睛,我们接着寻找鼻子,然后是嘴巴。如果没有,滑动窗口应该继续滑动:

![图片](https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/go-ml-proj/img/7c046b65-f1ce-4af1-937f-852c6459bd41.png)

Viola-Jones 的另一个创新点是该方法被设计用于在图像金字塔上工作。什么是图像金字塔?想象一下你有一个大型的 1024 x 768 像素的图像。这个图像具有多个不同尺度的两个面。有一个人站在相机非常近的位置,另一个人站在较远的位置。任何对相机光学有所了解的人都会立刻意识到,站在相机近处的人的脸在图像中会比站在远处的人的脸大得多。问题是,我们如何能够检测到不同尺度的两个脸?

一个可能的答案是设计多个过滤器,每个可能的尺度一个。但这留下了很多错误的空间。而不是设计多个过滤器,如果图像被多次调整大小,相同的过滤器可以被重复使用:

![图片](https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/go-ml-proj/img/5d447976-f901-4f10-89ed-324b22650208.png)

非常靠近相机的脸不会被设计用于检测小脸的过滤器检测到。相反,在原始分辨率下,分类器会检测到较小的脸。然后,图像被调整大小,使得分辨率现在更小,比如说 640 x 480 像素。大脸现在变成了小脸,小脸现在变成了单独的点。分类器现在能够检测到大脸而不是小脸。但总的来说,分类器已经检测到了图像中的所有脸。因为图像是直接调整大小的,所以在较小图像中的坐标可以很容易地转换成原始图像中的坐标。这允许在较小尺度上的检测直接转换成原始尺度上的检测。

到目前为止,如果你已经阅读了上一章,这开始感觉有些熟悉。**卷积神经网络**(**CNNs**)以非常相似的方式工作。在 CNN 中,多个过滤器被应用于子区域,生成一个过滤后的图像。然后,过滤后的图像通过一个减少层(最大池化或其他减少方法)。CNN 中的关键是学习过滤器会是什么样子。实际上,每个 CNN 的第一层学习到的过滤器与 Viola-Jones 方法中使用的过滤器非常相似。

主要的相似之处在于 Viola-Jones 基本上相当于有一个滑动窗口并将过滤器应用于图像的某个部分。这类似于 CNN 中的卷积。CNN 的优势在于它们能够学习这些过滤器,而 Viola-Jones 方法中的过滤器是手动创建的。另一方面,Viola-Jones 方法的好处是级联:如果其中一个分类器失败,它可能会提前终止搜索某个区域的搜索。这节省了很多计算。事实上,Viola-Jones 方法的影响如此之大,以至于它启发了 2016 年由 Zhang 等人撰写的*使用多任务级联卷积网络的联合人脸检测与对齐*,该方法使用了三个神经网络以级联方式识别人脸。

很容易将图像金字塔与 CNN 中的池化层所做的工作等同起来。这并不正确。Viola-Jones 方法中的多尺度检测是一个巧妙的方法,而 CNN 中的池化层则导致学习到更高阶的特征。CNN 学习到更高阶的特征,如眼睛、鼻子和嘴巴,而 Viola-Jones 方法则没有。

考虑到这一点,人们可能会想知道 CNN 是否可能更好。它们确实以人类的方式检测人脸——通过识别眼睛、鼻子和嘴巴作为特征,而不是过滤像素上的模式。仍然有理由在今天使用 Viola-Jones。到目前为止,Viola-Jones 方法在库中得到了很好的理解和优化。它内置在 GoCV 中,这是我们将会使用的。该方法也比基于深度学习的模型更快,但牺牲了一些灵活性。大多数 Viola-Jones 模型只检测正面的人脸。此外,Viola-Jones 方法可能无法检测旋转的人脸(如果你想要检测一个转头恶魔的脸作为驱魔者的证据,那就太糟糕了)。

根据用例,可能根本不需要基于深度学习的系统来执行人脸检测!

# PICO

我们将要使用的一种另一种技术是**基于像素强度比较的对象检测**(**PICO**),它最初由 Markus、Frljak 等人于 2014 年开发。它使用与 Viola-Jones 方法相同的广泛原则,即存在级联分类器。它有两个不同之处。首先,不使用滑动窗口。这是由于后者的差异。其次,级联分类器的分类器与 Viola-Jones 的不同。在 Viola-Jones 中,使用重复应用滤波器然后求和的结果作为分类器的方法。相比之下,在 PICO 中,使用决策树。

决策树是一种树,其中每个节点都是一个特征,特征的分支由一个阈值定义。在 PICO 的情况下,决策树应用于照片中的每个像素。对于每个考虑到的像素,其强度将与另一个位置另一个像素的强度进行比较。这些位置由均匀分布生成,从而消除了滑动窗口的需要。

PICO 方法也消除了需要图像金字塔和积分图像的需求。分类器能够直接从图像中检测人脸。这使得它非常快。

尽管如此,Viola-Jones 的遗产是显而易见的。分类器是分阶段应用的。首先,使用简单的分类器。这将消除存在人脸概率较低的区域。接下来,在减少的搜索区域上使用更复杂的分类器。这会重复进行,直到达到最后阶段。每个分类器的结果都会保留以供后续使用。

读者可能会意识到,在图片中肯定有脸的区域将被更多的分类器搜索。正是基于这种直觉,作者在 PICO 分类器中引入了一个最终的聚类步骤。规则很简单:如果分类器搜索的区域有重叠,并且重叠百分比大于 30%,则认为它们是同一个簇的一部分。因此,最终结果对小的变化具有鲁棒性。

# 关于学习的笔记

你可能已经注意到,在之前描述算法时,我故意没有提到这些模型的训练过程。这种省略是有意为之的。因为我们不会训练任何模型,所以 Viola-Jones 方法和 PICO 方法是如何训练以产生模型的,将留给读者作为练习。

相反,在本章中,我们希望使用已经创建的模型。这些模型在实践中被广泛使用。然后我们将比较和对比这些方法,以找出它们的优缺点。

# GoCV

在本章中,我们将使用 GoCV。GoCV 是 OpenCV 的一个绑定,并附带了一组可以从 OpenCV 使用的功能。OpenCV 的一个功能是 Viola-Jones 分类器,我们将利用这个分类器。

安装 GoCV 有点棘手,然而。它需要先安装 OpenCV。在撰写本文时,GoCV 支持的版本是 OpenCV 3.4.2。安装 OpenCV 可能是一个相当痛苦的过程。也许最好的地方去了解**如何**安装 OpenCV 是一个叫做**Learn OpenCV**的网站。他们提供了关于在所有平台上安装 OpenCV 的出色指南:

+   在 Ubuntu 上安装 OpenCV: [`www.learnopencv.com/install-opencv3-on-ubuntu/`](https://www.learnopencv.com/install-opencv3-on-ubuntu/)

+   在 Windows 上安装 OpenCV: [`www.learnopencv.com/install-opencv3-on-windows/`](https://www.learnopencv.com/install-opencv3-on-windows/)

+   在 MacOS 上安装 OpenCV: [`www.learnopencv.com/install-opencv3-on-macos/`](https://www.learnopencv.com/install-opencv3-on-macos/)

在完成令人敬畏的 OpenCV 安装过程之后,安装 GoCV 就像小菜一碟。只需运行`go get -u gocv.io.x.gocv`,然后 Bob 就是你的叔叔。

# API

GoCV 的 API 与 OpenCV 的 API 非常匹配。一个特别好的 API 展示是显示窗口。有了显示窗口,人们能够显示摄像头实时接收到的图像。它也是一个非常有用的调试工具,在可能需要编写新分类器的情况下。

我已经开发了多年的程序。可以说,我见过很多设计模式和包。对于几乎所有编程语言来说,最棘手的问题之一就是外函数接口(FFI),当程序需要调用用另一种语言编写的库时。做得好的不多。大多数都做得粗糙,好像是在底层的**外函数接口**(**FFI**)上贴了些东西。在 Go 语言中,FFI 是通过 cgo 来处理的。

很常见,库的作者(包括我自己)会变得过于聪明,并试图代表用户管理资源。虽然乍一看这似乎是好的用户体验,甚至是好的客户服务,但最终这会导致很多痛苦。在撰写本文时,Gorgonia 本身刚刚经历了一系列重构,以使资源隐喻更加清晰,特别是关于 CUDA 的使用。

说了这么多,GoCV 可能是关于 cgo 使用方面最一致的 Go 库之一。GoCV 一致的部分在于其对外部对象的处理。一切都被视为资源;因此,大多数类型都有 `.Close()` 方法。GoCV 确实还有其他优点,包括 `customenv` 构建标签,它允许库用户定义 OpenCV 的安装位置,但我对 GoCV 的主要赞扬在于其在将 OpenCV 对象视为外部资源方面的连贯性。

使用资源隐喻处理对象指导我们在 GoCV API 中的使用。所有对象在使用后都必须关闭,这是一个简单的规则要遵守。

# Pigo

Pigo 是一个使用 PICO 算法检测人脸的 Go 库。与 Viola-Jones 方法相比,PICO 很快。自然地,PIGO 也很快。考虑到 GoCV 使用 cgo,这会带来速度上的惩罚,PIGO 可能看起来是一个更好的整体选择。然而,必须注意的是,PICO 算法比原始的 Viola-Jones 方法更容易产生误报。

使用 PIGO 库很简单。提供的文档很清晰。然而,PIGO 是设计在作者的工作流程中运行的。与该工作流程不同将需要一些额外的工作。具体来说,作者使用外部助手如 `github.com/fogleman/gg` 绘制图像。我们不会这样做。但是,工作量并不大。

要安装 `pigo`,只需运行 `go get -u github.com/esimov/pigo/...`。

# 人脸检测程序

我们想要做的是编写一个程序,从摄像头读取图像,将图像传递给人脸检测器,然后在图像上绘制矩形。最后,我们想要显示带有绘制矩形的图像。

# 从摄像头捕获图像

首先,我们将打开与摄像头的连接:

```py

func main() {

// 打开摄像头

webcam, err := gocv.VideoCaptureDevice(0)

if err != nil {

log.Fatal(err)

}

defer webcam.Close()

}

在这里,我使用了 VideoCaptureDevice(0),因为在我的电脑上,它运行的是 Ubuntu 系统,摄像头是设备 0。您的摄像头可能在不同设备编号上。另外,请注意 defer webcam.Close()。这是 GoCV 非常坚持的资源隐喻。摄像头(特别是 VideoCaptureDevice)是一种资源,就像文件一样。实际上,在 Linux 中,这是真的;我的电脑上的摄像头挂载在 /dev/video0 目录下,我可以通过使用 cat 的变体来访问它的原始字节。但我不打算深入。重点是,必须在资源上调用 .Close() 以释放使用。

关于关闭资源以释放使用的讨论自然引发了一个问题,鉴于我们是用 Go 编程。通道是一个资源吗?答案是,不是。通道的 close(ch) 只会通知每个发送者这个通道不再接收数据。

能够访问摄像头很棒,但我们还希望能够从它那里抓取图像。我提到过可以从摄像头的文件中读取原始流。我们也可以用 GoCV 做同样的事情:

img := gocv.NewMat()

defer img.Close()

width := int(webcam.Get(gocv.VideoCaptureFrameWidth))

height := int(webcam.Get(gocv.VideoCaptureFrameHeight))

fmt.Printf("Webcam resolution: %v, %v", width, height)

if ok := webcam.Read(&img); !ok {

log.Fatal("cannot read device 0")

}

首先,我们创建一个新的矩阵,代表一个图像。同样,矩阵被当作一个资源来处理,因为它是由外部函数接口拥有的。因此,我们写下了 defer img.Close()。接下来,我们查询摄像头的分辨率信息。现在这并不那么重要,但以后会很重要。尽管如此,知道摄像头运行在什么分辨率上还是相当不错的。最后,我们将摄像头的图像读入矩阵。

在这个阶段,如果你已经熟悉 Gorgonia 的张量库,这个模式可能看起来很熟悉,但感觉有点奇怪。img := gocv.NewMat() 并没有定义一个大小。GoCV 是如何知道为矩阵分配多少空间的呢?答案是,这个魔法发生在 webcam.Read 中。底层的矩阵将由 OpenCV 根据需要调整大小。这样,程序的 Go 部分实际上并没有进行真正的内存分配。

显示图像

所以,图像已经神奇地读入矩阵。我们如何从中获取任何内容呢?

答案是我们必须将 OpenCV 控制的从数据结构中的数据复制到一个 Go 原生数据结构中。幸运的是,GoCV 也处理了这一点。在这里,我们将它写入文件:

 goImg, err := img.ToImage()
 if err != nil {
 log.Fatal(err)
 }
 outFile, err := os.OpenFile("first.png", os.O_WRONLY|os.O_TRUNC|os.O_CREATE, 0644)
 if err != nil {
 log.Fatal(err)
 }
 png.Encode(outFile, goImg)

首先,矩阵必须转换为 image.Image。为此,调用 img.ToImage()。然后,使用 png.Encode 将其编码为 PNG 格式。

你将得到一个测试图像。这是我用的:

在图片中,我拿着一个装着拉尔夫·瓦尔多·爱默生照片的盒子,他是著名的美国作家。熟悉书写工具的读者可能会注意到,这实际上是我写作时使用的墨水品牌。

因此,现在我们已经有了从摄像头获取图像并将其写入文件的基本流程。摄像头持续捕捉图像,但我们只将一个图像读入矩阵,然后将矩阵写入文件。如果我们将其放入循环中,我们就有能力连续从摄像头读取图像并将其写入文件。

类似于有一个文件,我们可以将其写入屏幕。GoCV 与 OpenCV 的集成如此完整,以至于这很简单。我们不是写入文件,而是显示一个窗口。

为了做到这一点,我们首先需要创建一个窗口对象,标题为 Face Detection Window

 window := gocv.NewWindow("Face Detection Window")
 defer window.Close()

然后,要显示图像在窗口中,只需替换我们写入文件的代码部分为以下内容:

 window.IMShow(img)

当程序运行时,将弹出一个窗口,显示由网络摄像头捕获的图像。

在图像上涂鸦

在某个时候,我们可能还想在图像上绘制,最好是在输出到显示或文件之前。GoCV 在这方面表现得非常好。在本章的用途中,我们只需绘制矩形来表示可能存在面部的地方。GoCV 与标准库的 Rectangle 类型配合良好。

要使用 GoCV 在图像上绘制矩形,我们首先定义一个矩形:

 r := image.Rect(50, 50, 100, 100)

这里,我定义了一个从位置(50, 50)开始,宽度为 100 像素,高度为 100 像素的矩形。

然后,需要定义一个颜色。同样,GoCV 与标准库中的 image/color 结合得非常好。所以,这是颜色 blue 的定义:

 blue := color.RGBA{0, 0, 255, 0}

现在,让我们继续在图像上绘制矩形!:

 gocv.Rectangle(&amp;img, r, blue, 3)

这是在图像中绘制一个以矩形左上角为(50, 50)的蓝色矩形。

到目前为止,我们已经有了构建两个不同管道所需的组件。一个将图像写入文件。另一个创建一个窗口来显示图像。处理来自网络摄像头的输入有两种方式:一次性或持续进行。此外,我们还可以在输出之前修改图像矩阵。这为我们提供了在构建程序过程中的大量灵活性。

面部检测 1

我们想要使用的第一个面部检测算法是 Viola-Jones 方法。这个方法内置在 GoCV 中,因此我们可以直接使用它。GoCV 的一致性给我们提供了下一步要做什么的提示。我们需要一个分类器对象(并且记得要关闭它!)

这就是创建分类器对象的方法:

classifier := gocv.NewCascadeClassifier()

if !classifier.Load(haarCascadeFile) {

log.Fatalf("Error reading cascade file: %v\n", haarCascadeFile)

}

defer classifier.Close()

注意,在这个阶段,仅仅创建一个分类器是不够的。我们需要用模型来加载它。所使用的模型非常成熟。它最初由 Rainer Lienhart 在 2000 年代初创建。像大多数 2000 年代的产品一样,该模型被序列化为 XML 文件。

该文件可以从 GoCV GitHub 仓库下载:github.com/hybridgroup/gocv/blob/master/data/haarcascade_frontalface_default.xml

在前面的代码中,haarCascadeFile 是一个表示文件路径的字符串。GoCV 处理其余部分。

要检测面部,这是一个简单的单行代码:

rects := classifier.DetectMultiScale(img)

在这一行代码中,我们告诉 OpenCV 使用 Viola-Jones 的多尺度检测来检测人脸。内部,OpenCV 构建了一个积分图像的图像金字塔,并在图像金字塔上运行分类器。在每一阶段,算法认为人脸所在位置的矩形被生成。这些矩形就是返回的内容。它们可以在输出到文件或窗口之前绘制到图像上。

这里展示了一个完整的窗口化管道的示例:

var haarCascadeFile = "Path/To/CascadeFile.xml"

var blue = color.RGBA{0, 0, 255, 0}

func main() {

// 打开摄像头

webcam, err := gocv.VideoCaptureDevice(0)

if err != nil {

log.Fatal(err)

}

defer webcam.Close()

var err error

// 打开显示窗口

window := gocv.NewWindow("人脸检测")

defer window.Close()

// 准备图像矩阵

img := gocv.NewMat()

defer img.Close()

// 当检测到人脸时的人脸矩形颜色

// 加载分类器以识别人脸

classifier := gocv.NewCascadeClassifier()

if !classifier.Load(haarCascadeFile) {

log.Fatalf("读取级联文件时出错: %v\n", haarCascadeFile)

}

defer classifier.Close()

for {

if ok := webcam.Read(&img); !ok {

fmt.Printf("无法读取设备 %d\n", deviceID)

return

}

if img.Empty() {

continue

}

rects := classifier.DetectMultiScale(img)

for _, r := range rects {

gocv.Rectangle(&img, r, blue, 3)

}

window.IMShow(img)

if window.WaitKey(1) >= 0 {

break

}

}

}

程序现在能够从摄像头获取图像,检测人脸,在人脸周围绘制矩形,然后显示图像。你可能注意到,它在这方面做得相当快。

人脸检测 2

一举一动,GoCV 为我们提供了进行实时人脸检测所需的一切。但是,它与其他人脸检测算法一起使用容易吗?答案是肯定的,但需要一些工作。

我们想要使用的算法是 PICO 算法。回想一下,GoCV 中的图像是gocv.Mat类型。为了使 PIGO 能够使用它,我们需要将其转换为 PICO 可读的格式。顺便说一句,这种共享格式是标准库中的image.Image

再次提醒,gocv.Mat类型有一个.ToImage()方法,它返回一个image.Image。这就是我们的桥梁!

在穿过它之前,让我们看看如何创建一个 PIGO 分类器。这里有一个创建它的函数:

func pigoSetup(width, height int) (*image.NRGBA, []uint8, *pigo.Pigo,

        pigo.CascadeParams, pigo.ImageParams) {

goImg := image.NewNRGBA(image.Rect(0, 0, width, height))

grayGoImg := make([]uint8, width*height)

cParams := pigo.CascadeParams{

                        MinSize: 20,

                        MaxSize: 1000,

                        ShiftFactor: 0.1,

                        ScaleFactor: 1.1,

}

imgParams := pigo.ImageParams{

                        Pixels: grayGoImg,

                        Rows: height,

                        Cols: width,

                        Dim: width,

}

classifier := pigo.NewPigo()

var err error

if classifier, err = classifier.Unpack(pigoCascadeFile); err != nil {

            log.Fatalf("读取级联文件时出错: %s", err)

}

return goImg, grayGoImg, classifier, cParams, imgParams

}

此函数相当密集。让我们来分解它。我们将以逻辑方式而不是自顶向下的线性方式来执行。

首先,使用 classifier := pigo.NewPigo() 创建一个 pigo.Pigo 对象。这创建了一个新的分类器。与 Viola-Jones 方法一样,需要一个模型来提供。

与 GoCV 不同,模型是二进制格式,需要解包。此外,classifier.Unpack 接受一个 []byte,而不是表示文件路径的字符串。提供的模型可以在 GitHub 上获取:https://github.com/esimov/pigo/blob/master/data/facefinder

一旦获取了文件,就需要像下面片段所示的那样以 []byte 格式读取它(该片段被 init 函数包裹):

pigoCascadeFile, err = ioutil.ReadFile("path/to/facefinder")

if err != nil {

log.Fatalf("Error reading the cascade file: %v", err)

}

一旦 pigoCascadeFile 可用,我们可以使用 classifier.Unpack(pigoCascadeFile) 将其解包到分类器中。通常的错误处理适用。

但这一节前面的部分呢?为什么这是必要的?

为了理解这一点,让我们看看 PIGO 是如何进行分类的。它大致如下:

dets := pigoClass.RunCascade(imgParams, cParams)
dets = pigoClass.ClusterDetections(dets, 0.3)

当 PIGO 运行分类器时,它接受两个参数,这些参数决定了其行为:ImageParamCascadeParams。特别是,ImageParam 的细节对我们理解过程很有启发。它被定义为:

// ImageParams is a struct for image related settings.
// Pixels: contains the grayscale converted image pixel data.
// Rows: the number of image rows.
// Cols: the number of image columns.
// Dim: the image dimension.
type ImageParams struct {
  Pixels []uint8
  Rows int
  Cols int
  Dim int
}

在这种情况下,pigoSetup 函数具有额外的功能。goImg 不是严格必需的,但在考虑 GoCV 和 PIGO 之间的桥梁时很有用。

PIGO 需要图像以 []uint8 格式存在,表示灰度图像。GoCV 将网络摄像头图像读取到 gocv.Mat 中,该对象具有 .ToImage() 方法。该方法返回一个 image.Image 对象。大多数网络摄像头捕获的是彩色图像。以下是将 GoCV 和 PIGO 结合使用的步骤:

  1. 从网络摄像头捕获图像。

  2. 将图像转换为 image.Image

  3. 将该图像转换为灰度图像。

  4. 从灰度图像中提取 []uint8

  5. []uint8 上执行人脸检测。

对于我们前面的管道,图像参数和级联参数基本上是静态的。图像处理以线性方式进行。直到完成人脸检测、绘制矩形和最终在窗口中显示图像之前,网络摄像头的帧不会捕获。

因此,一次分配一个图像,然后在每个循环中覆盖图像是完全正确的。.ToImage() 方法每次调用时都会分配一个新的图像。相反,我们可以有一个“顽皮”版本,其中已分配的图像被重复使用。

下面是如何做到这一点:

func naughtyToImage(m *gocv.Mat, imge image.Image) error {
                    typ := m.Type()
  if typ != gocv.MatTypeCV8UC1 &amp;&amp; typ != gocv.MatTypeCV8UC3 &amp;&amp; typ != 
            gocv.MatTypeCV8UC4 {
    return errors.New("ToImage supports only MatType CV8UC1, CV8UC3 and 
                       CV8UC4")
  }

  width := m.Cols()
  height := m.Rows()
  step := m.Step()
  data := m.ToBytes()
  channels := m.Channels()

  switch img := imge.(type) {
  case *image.NRGBA:
    c := color.NRGBA{
      R: uint8(0),
      G: uint8(0),
      B: uint8(0),
      A: uint8(255),
    }
    for y := 0; y &lt; height; y++ {
      for x := 0; x &lt; step; x = x + channels {
        c.B = uint8(data[y*step+x])
        c.G = uint8(data[y*step+x+1])
        c.R = uint8(data[y*step+x+2])
        if channels == 4 {
          c.A = uint8(data[y*step+x+3])
        }
        img.SetNRGBA(int(x/channels), y, c)
      }
    }

  case *image.Gray:
    c := color.Gray{Y: uint8(0)}
    for y := 0; y &lt; height; y++ {
      for x := 0; x &lt; width; x++ {
        c.Y = uint8(data[y*step+x])
        img.SetGray(x, y, c)
      }
    }
  }
  return nil
}

此函数允许用户重用现有的图像。我们只需遍历 gocv.Mat 的字节,并覆盖图像的底层字节。

使用相同的逻辑,我们还可以创建一个将图像转换为灰度的“顽皮”版本的函数:

func naughtyGrayscale(dst []uint8, src *image.NRGBA) []uint8 {
  rows, cols := src.Bounds().Dx(), src.Bounds().Dy()
  if dst == nil || len(dst) != rows*cols {
    dst = make([]uint8, rows*cols)
  }
  for r := 0; r &lt; rows; r++ {
    for c := 0; c &lt; cols; c++ {
      dst[r*cols+c] = uint8(
        0.299*float64(src.Pix[r*4*cols+4*c+0]) +
          0.587*float64(src.Pix[r*4*cols+4*c+1]) +
          0.114*float64(src.Pix[r*4*cols+4*c+2]),
      )
    }
  }
  return dst
}

函数签名之间的差异是风格上的。后者签名更好——最好返回类型。这允许进行错误纠正,如下所示:

if dst == nil || len(dst) != rows*cols {
    dst = make([]uint8, rows*cols)
  }

因此,我们的流程看起来像这样:

var haarCascadeFile = "Path/To/CascadeFile.xml"
var blue = color.RGBA{0, 0, 255, 0}
var green = color.RGBA{0, 255, 0, 0}
func main() {
var err error
  // open webcam
  if webcam, err = gocv.VideoCaptureDevice(0); err != nil {
    log.Fatal(err)
  }
  defer webcam.Close()
  width := int(webcam.Get(gocv.VideoCaptureFrameWidth))
  height := int(webcam.Get(gocv.VideoCaptureFrameHeight))

  // open display window
  window := gocv.NewWindow("Face Detect")
  defer window.Close()

  // prepare image matrix
  img := gocv.NewMat()
  defer img.Close()

  // set up pigo
  goImg, grayGoImg, pigoClass, cParams, imgParams := pigoSetup(width, 
                                                     height)

  for {
    if ok := webcam.Read(&amp;img); !ok {
      fmt.Printf("cannot read device %d\n", deviceID)
      return
    }
    if img.Empty() {
      continue
    }
    if err = naughtyToImage(&amp;img, goImg); err != nil {
      log.Fatal(err)
    }
    grayGoImg = naughtyGrayscale(grayGoImg, goImg)
    imgParams.Pixels = grayGoImg
    dets := pigoClass.RunCascade(imgParams, cParams)
    dets = pigoClass.ClusterDetections(dets, 0.3)

    for _, det := range dets {
      if det.Q &lt; 5 {
        continue
      }
      x := det.Col - det.Scale/2
      y := det.Row - det.Scale/2
      r := image.Rect(x, y, x+det.Scale, y+det.Scale)
      gocv.Rectangle(&amp;img, r, green, 3)
    }

    window.IMShow(img)
    if window.WaitKey(1) &gt;= 0 {
      break
    }
  }
}

这里有一些需要注意的事情。如果你遵循逻辑,你会注意到真正改变的是 imgParams.Pixels 中的数据。其余的东西并没有真正改变很多。

回想一下之前对 PICO 算法的解释——检测中可能存在重叠。需要最终聚类步骤来完成最终检测。这解释了以下两行代码:

dets := pigoClass.RunCascade(imgParams, cParams)
dets = pigoClass.ClusterDetections(dets, 0.3)

0.3 这个值是基于原始论文选择的。在 PIGO 的文档中,建议的值是 0.2

另一个不同之处在于,PIGO 不返回矩形作为检测结果。相反,它返回自己的 pigo.Detection 类型。将这些转换为标准的 image.Rectangle 只需以下几行代码:

x := det.Col - det.Scale/2
y := det.Row - det.Scale/2
r := image.Rect(x, y, x+det.Scale, y+det.Scale)

运行程序会弹出一个窗口,显示带有脸部周围绿色矩形的网络摄像头图像。

将所有这些放在一起

现在我们有了两种不同的算法来检测人脸的不同应用。

这里有一些观察结果:

  • 使用 PIGO 的图像更平滑——跳跃和延迟更少。

  • PIGO 算法比标准的 Viola-Jones 方法抖动得更多。

  • PIGO 算法对旋转更鲁棒——我可以倾斜我的头部更多,仍然能够检测到我的脸部,与标准的 Viola-Jones 方法相比。

我们当然可以将它们放在一起:

var haarCascadeFile = "Path/To/CascadeFile.xml"
var blue = color.RGBA{0, 0, 255, 0}
var green = color.RGBA{0, 255, 0, 0}
func main() {
var err error
  // open webcam
  if webcam, err = gocv.VideoCaptureDevice(0); err != nil {
    log.Fatal(err)
  }
  defer webcam.Close()
  width := int(webcam.Get(gocv.VideoCaptureFrameWidth))
  height := int(webcam.Get(gocv.VideoCaptureFrameHeight))

  // open display window
  window := gocv.NewWindow("Face Detect")
  defer window.Close()

  // prepare image matrix
  img := gocv.NewMat()
  defer img.Close()

  // set up pigo
  goImg, grayGoImg, pigoClass, cParams, imgParams := pigoSetup(width, 
                                                       height)

  // create classifier and load model
  classifier := gocv.NewCascadeClassifier()
  if !classifier.Load(haarCascadeFile) {
    log.Fatalf("Error reading cascade file: %v\n", haarCascadeFile)
  }
  defer classifier.Close()

  for {
    if ok := webcam.Read(&amp;img); !ok {
      fmt.Printf("cannot read device %d\n", deviceID)
      return
    }
    if img.Empty() {
      continue
    }
    // use PIGO
    if err = naughtyToImage(&amp;img, goImg); err != nil {
      log.Fatal(err)
    }

    grayGoImg = naughtyGrayscale(grayGoImg, goImg)
    imgParams.Pixels = grayGoImg
    dets := pigoClass.RunCascade(imgParams, cParams)
    dets = pigoClass.ClusterDetections(dets, 0.3)

    for _, det := range dets {
      if det.Q &lt; 5 {
        continue
      }
      x := det.Col - det.Scale/2
      y := det.Row - det.Scale/2
      r := image.Rect(x, y, x+det.Scale, y+det.Scale)
      gocv.Rectangle(&amp;img, r, green, 3)
    }

    // use GoCV
    rects := classifier.DetectMultiScale(img)
    for _, r := range rects {
      gocv.Rectangle(&amp;img, r, blue, 3)
    }

    window.IMShow(img)
    if window.WaitKey(1) &gt;= 0 {
      break
    }
  }
}

在这里,我们看到 PIGO 和 GoCV 都能够相当准确地检测到它们,并且它们之间相当一致。

此外,我们还可以看到现在动作和动作在屏幕上显示之间有一个相当明显的延迟。这是因为还有更多的工作要做。

评估算法

我们可以从很多维度来评估算法。本节探讨了如何评估算法。

假设我们想要快速的人脸检测——哪个算法会更好?

理解算法性能的唯一方法就是测量它。幸运的是,Go 内置了基准测试功能。这正是我们即将要做的事情。

要构建基准测试,我们必须非常小心我们正在测试的内容。在这种情况下,我们想要测试检测算法的性能。这意味着比较 classifier.DetectMultiScalepigoClass.RunCascadepigoClass.ClusterDetections

此外,我们必须比较苹果和苹果——如果我们用一个 3840 x 2160 的图像和一个 640 x 480 的图像来比较一个算法,而另一个算法,这将是不公平的。前者比后者有更多的像素:

func BenchmarkGoCV(b *testing.B) {
  img := gocv.IMRead("test.png", gocv.IMReadUnchanged)
  if img.Cols() == 0 || img.Rows() == 0 {
    b.Fatalf("Unable to read image into file")
  }

  classifier := gocv.NewCascadeClassifier()
  if !classifier.Load(haarCascadeFile) {
    b.Fatalf("Error reading cascade file: %v\n", haarCascadeFile)
  }

  var rects []image.Rectangle
  b.ResetTimer()

  for i := 0; i &lt; b.N; i++ {
    rects = classifier.DetectMultiScale(img)
  }
  _ = rects
}

有几点需要注意——设置是在函数的早期完成的。然后调用b.ResetTimer()。这样做是为了重置计时器,使得设置不被计入基准测试。第二点需要注意的是,分类器被设置为在相同的图像上反复检测人脸。这样做是为了我们可以准确地了解算法的性能。最后一点需要注意的是结尾的相当奇怪的_ = rects行。这样做是为了防止 Go 优化掉这些调用。从技术上讲,这并不是必需的,因为我相当确信DetectMultiScale函数已经复杂到从未被优化掉,但那行只是为了保险。

对于 PIGO 也可以进行类似的设置:

func BenchmarkPIGO(b *testing.B) {
  img := gocv.IMRead("test.png", gocv.IMReadUnchanged)
  if img.Cols() == 0 || img.Rows() == 0 {
    b.Fatalf("Unable to read image into file")
  }
  width := img.Cols()
  height := img.Rows()
  goImg, grayGoImg, pigoClass, cParams, imgParams := pigoSetup(width, 
                                                     height)

  var dets []pigo.Detection
  b.ResetTimer()

  for i := 0; i &lt; b.N; i++ {
    grayGoImg = naughtyGrayscale(grayGoImg, goImg)
    imgParams.Pixels = grayGoImg
    dets = pigoClass.RunCascade(imgParams, cParams)
    dets = pigoClass.ClusterDetections(dets, 0.3)
  }
  _ = dets
}

这次设置比 GoCV 基准测试更复杂。可能会觉得这两个函数正在基准测试不同的事情——GoCV 基准测试使用gocv.Mat,而 PIGO 基准测试使用[]uint8。但记住,我们感兴趣的是算法在图像上的性能。

将灰度化也添加到基准测试中的主要原因是因为,尽管 GoCV 接受彩色图像,但实际的 Viola-Jones 方法使用的是灰度图像。在内部,OpenCV 在检测之前将图像转换为灰度。因为我们无法单独分离检测部分,唯一的替代方案是将转换为灰度作为检测过程的一部分来考虑。

要运行基准测试,需要将这两个函数添加到algorithms_test.go中。然后运行go test -run=^$ -bench=. -benchmem。结果如下:

goos: darwin
goarch: amd64
pkg: chapter9
BenchmarkGoCV-4 20 66794328 ns/op 32 B/op 1 allocs/op
BenchmarkPIGO-4 30 47739076 ns/op 0 B/op 0 allocs/op
PASS
ok chapter9 3.093s

在这里我们可以看到 GoCV 比 PIGO 慢大约 1/3。这其中的一个关键原因是由于与 OpenCV 接口而进行的 cgo 调用。然而,也应该注意的是,PICO 算法比原始的 Viola-Jones 算法更快。PIGO 能够超越 OpenCV 中找到的经过高度调整和优化的 Viola-Jones 算法的性能,这相当令人印象深刻。

然而,速度并不是唯一需要考虑的因素。还有其他维度也很重要。以下是在考虑人脸检测算法时需要注意的事项。对这些事项的测试被建议,但留作读者的练习:

| Consideration | Test |
|:---:          |:---:|
| Performance in detecting many faces | Benchmark with image of crowd |
| Correctness in detecting many faces | Test with image of crowd, with  
                                        known numbers |
| No racial discrimination | Test with images of multi-ethnic peoples  
                             with different facial features |

最后一个特别有趣。多年来,机器学习算法并没有很好地服务于有色人种。我自己在使用 Viola-Jones 模型(与存储库中的模型不同)检测眼睛时遇到了一些问题。在大约五年前的一个面部特征检测项目中,我试图在脸上检测眼睛。

所说的亚洲眼睛由两个主要特征组成——从鼻子向外侧的脸上有一个向上的斜坡;以及有内眦褶的眼睑,给人一种单层眼睑的错觉——也就是说,没有褶皱的眼睑。我正在工作的模型有时无法检测到我的眼睛位置,因为过滤器寻找的是眼睑的褶皱,而我眼睑上的褶皱并不明显。

在这方面,一些算法和模型可能意外地具有排他性。为了明确,我并不是说这些算法和模型的创造者是种族主义者。然而,在设计这些算法时,确实存在一些假设,它们没有考虑所有可能的情况——而且永远也不能。例如,任何基于对比度的面部特征检测在肤色较深的人身上表现都会很差。另一方面,基于对比度的检测系统通常非常快,因为所需的计算量最小。在这里,我们需要做出权衡——你需要检测每个人,还是你需要速度快?

本章旨在鼓励读者更多地思考机器学习算法的应用场景以及使用算法所需的权衡。这本书主要关于权衡的思考。我强烈建议读者深入思考机器学习算法的应用场景。理解所有所需的权衡。一旦理解了适当的权衡,实现通常就是小菜一碟。

摘要

在本章中,我们学习了使用 GoCV 和 PIGO,并构建了一个从实时网络摄像头检测人脸的程序。在本章结束时,我们实现了一个可用的面部识别系统,熟悉了面部特征哈希的概念,并了解了如何使用 Gorgonia 库系列以及 GoCV(OpenCV 的绑定)来快速进行推理。

话虽如此,在下一章中,我们将探讨没有自己构建算法的一些影响。

第九章:热狗还是不是热狗 - 使用外部服务

在之前的章节中,我强调了理解算法背后的数学的重要性。这里是一个回顾。我们从线性回归开始,然后是朴素贝叶斯分类器。然后,话题转向数据科学中更复杂的话题之一:时间序列。然后,我们偏离了主题,讨论了 K-means 聚类。这之后是关于神经网络的两个章节。在这些章节中,我解释了这些算法背后的数学,并展示了令人惊讶的是,生成的程序既短又简单。

这本书的目的是在数学和实现之间走一条微妙的路线。我希望我已经提供了足够的信息,以便你理解数学及其可能的有用之处。项目是真实的项目,但通常它们以各种形式存在,简化且相当学术。因此,本章不会包含很多数学解释可能会让你感到有些意外。相反,本章旨在引导读者通过更多现实世界的场景。

在上一章中,我们讨论了人脸检测。给定一张图片,我们想要找到人脸。但他们是谁呢?为了知道这些人脸属于谁,我们需要进行人脸识别。

MachineBox

如前所述,我们不会关注人脸检测背后发生的数学运算。相反,我们将使用外部服务来为我们执行识别任务。这个外部服务是 MachineBox。它所做的事情相当聪明。你不需要编写自己的深度学习算法,MachineBox 将常用的深度学习功能打包成容器,你只需直接使用它们即可。我所说的常用深度学习功能是指什么?如今,人们越来越依赖深度学习来完成诸如人脸识别等任务。

就像 2000 年代初的 Viola-Jones 一样,只有少数常用的模型——我们使用了 Rainer Lienhart 在 2002 年生成的 Haar-like 级联模型。同样的情况也正在深度学习模型中发生,我将在下一章中更多地讨论这一点。所谓模型,是指深度学习网络的实际权重(对于更深入的介绍,请参阅第七章,卷积神经网络 – MNIST 手写识别,关于深度神经网络)。这些常用模型由 MachineBox 打包,你可以直接使用它们。

必须记住的一点是,MachineBox 是一项付费服务。他们确实提供免费层,这对于本章的需求是足够的。我与 MachineBox 没有任何关联。我只是认为他们是一家很酷的公司,他们所做的工作值得认可。此外,他们不做一些可疑的事情,比如秘密扣费你的信用卡,所以这也是我加分的地方。

什么是 MachineBox?

MachineBox 首先是一个服务。机器学习算法被包装成云服务。此外,由于 MachineBox 关注开发者体验,他们为你提供了 SDK 和本地实例,以便你进行开发。这以容器的形式出现。设置 Docker,运行 MachineBox 网站上的命令,你就完成了!

在这个项目中,我们希望使用面部识别系统来识别面部。MachineBox 提供了一个名为 facebox 的服务。

注册和登录

首先,我们需要登录 MachineBox。访问machinebox.io并点击注册。方便的是,登录页面也是一样的。MachineBox 会随后给你发送一个链接。点击链接应该会带你到这个页面:

点击显示你的密钥。复制密钥。如果你使用的是基于 UNIX 的操作系统,例如 Linux 或 MacOS,在你的终端中运行以下命令:

export MB_KEY="YOUR KEY HERE"

或者,如果你想持久化这个环境变量,只需编辑你的终端配置文件(我在 Linux 和 MacOS 上使用 bash,所以我编辑的文件是.bash_profile.bashrc,具体取决于我使用的操作系统)。

在 Windows 中:

  1. 前往系统 | 控制面板

  2. 点击高级系统设置

  3. 点击环境变量

  4. 系统变量部分,点击新建

  5. MB_KEY作为密钥,变量就是密钥。

MachineBox 依赖于另一项基于 Go 语言构建的技术:Docker。大多数现代软件开发者已经在他们的机器上安装了 Docker。如果你还没有安装,可以通过访问docs.docker.com/install/并安装 Docker 社区版来安装 Docker。

Docker 安装和设置

一切都准备好了,我们可以使用以下命令来启动 MachineBox:

 docker run -p 8080:8080 -e "MB_KEY=$MB_KEY" machinebox/facebox

在 Go 中使用 MachineBox

要与 MachineBox 交互,只需访问http://localhost:8080。在那里,你会看到箱子上的一组选项。但我们的目标是程序化地与服务交互。为此,MachineBox 提供了一个 SDK。要安装它,运行go get github.com/machinebox/sdk-go/facebox。这将为我们安装 SDK 以与 facebox 交互。

项目

这是本书的最后一个项目。所以,为了有点乐趣,让我们基于上一章的项目进行构建,但给它一个转折。有一个亚洲说唱歌手叫MC Hot Dog。所以让我们构建一个面部识别系统来判定一个脸是不是 HotDog。

我们想要做的是从网络摄像头读取一张图片,并使用 MachineBox 来确定图片中是否有 MC Hot Dog。我们再次将使用 GoCV 从网络摄像头读取图片,但这次,图片将被发送到 MachineBox 进行分类。

训练

MachineBox 是一个机器学习即服务系统。它可能在某个后端有一个通用模型——比如说,一个经过许多面孔训练的卷积神经网络,这样它就知道什么是面孔。它不提供你可能需要的特定模型。因此,我们需要通过提供训练数据来微调 MachineBox 提供的模型。按照 MachineBox 的术语,这被称为教学。作为好奇心收集的一部分,我已经收集了一些适合教学 MachineBox 识别 MC Hot Dog 外观的小但可用的图像。

对于这个项目,图像存储在hotdog.zip文件中。将文件解压到名为HotDog的文件夹中。这个文件夹应该与这个项目的main.go文件处于同一级别。

使用提供的 SDK,训练 MachineBox 模型很简单。以下代码展示了程序:

 import "github.com/machinebox/sdk-go/facebox"

 func train(box *facebox.Client) error {
     files, err := filepath.Glob("HotDog/*")
     if err != nil {
          return err
     }
     for _, filename := range files {
         f , err := os.Open(filename)
         if err != nil {
             return err
         }

         if err := box.Teach(f, filename, "HotDog"); err != nil {
             return err
         }
         if err := f.Close(); err != nil {
             return err
         }
     }
     return nil
 }

 func main(){
     box := facebox.New("http://localhost:8080")
     if err := train(box); err !=nil {
         log.Fatal(err)
     }
 }

由此,你就有了一个完整的教程,介绍了如何教会 MachineBox 如何识别 MC Hot Dog。MachineBox 使这个过程变得简单——简单到你不需要了解深度学习系统背后的数学知识。

从 Web 摄像头读取

到目前为止,我希望你已经阅读了上一章,并且安装了 GoCV。如果你还没有,那么请阅读上一章中的GoCV部分以开始。

要从 Web 摄像头读取,我们只需将以下几行添加到主文件中。你可能认识它们作为上一章的片段:

     // open webcam
     webcam, err := gocv.VideoCaptureDevice(0)
     if err != nil {
         log.Fatal(err)
     }
     defer webcam.Close()

     // prepare image matrix
     img := gocv.NewMat()
     defer img.Close()

     if ok := webcam.Read(&img); !ok {
         log.Fatal("Failed to read image")
     }

当然,令人困惑的部分是如何将img传递给 MachineBox,其中imggocv.Mat类型。MachineBox 客户端存在一个Check方法,它接受io.Readerimg有一个ToBytes方法,它返回一个字节数组;结合bytes.NewReader,应该能够轻松地将io.Reader传递给Check

但如果你尝试这样做,它不会工作。

原因如下:MachineBox 期望输入的格式为 JPEG 或 PNG。如果不是,你将收到一个 400 Bad Request 错误。格式不佳的图片也会导致这类问题,这就是为什么在上一行中box.Teach()返回的错误被故意未处理的。在实际应用中,人们可能真的想检查是否返回了一个 400 Bad Request 错误。

img中的图像原始字节数据不是以已知图像格式编码的。相反,我们必须将img中的图像编码为 JPEG 或 PNG,然后按照以下方式传递给 MachineBox:

     var buf bytes.Buffer
     prop, _ := img.ToImage()
     if err = jpeg.Encode(&buf, prop, nil); err != nil {
         log.Fatal("Failed to encode image as JPG %v", err)
     }

     faces, err := box.Check(&buf)
     fmt.Printf("Error: %v\n", err)
     fmt.Printf("%#v", faces)

在这里,我们利用了*bytes.Buffer既作为io.Reader也作为io.Writer的事实。这样,我们就不需要直接写入文件——相反,所有内容都保持在内存中。

美化结果

程序打印结果。看起来如下:

 Error: <nil>
 []facebox.Face{facebox.Face{Rect:facebox.Rect{Top:221, Left:303, Width:75, Height:75}, ID:"", Name:"", Matched:false, Confidence:0, Faceprint:""}}

这是在终端输出上打印的一个相当无聊的结果。我们现在生活在图形用户界面(GUIs)的时代!所以让我们绘制我们的结果。

因此,我们希望窗口显示摄像头所显示的内容。然后,当按下键时,图像被捕获,并由 MachineBox 进行处理。如果发现人脸,则应在其周围绘制一个矩形。如果人脸被识别为 MC Hot Dog,则标签为HotDog,并跟随着置信度。否则,该框应标记为Not HotDog。这段代码看起来有点复杂:

     // open webcam
     webcam, err := gocv.VideoCaptureDevice(0)
     if err != nil {
         log.Fatal(err)
     }
     defer webcam.Close()

     // prepare image matrix
     img := gocv.NewMat()
     defer img.Close()

     // open display window
     window := gocv.NewWindow("Face Recognition")
     defer window.Close()

     var recognized bool
     for {
         if !recognized {
             if ok := webcam.Read(&img); !ok {
                 log.Fatal("Failed to read image")
             }
         }

         window.IMShow(img)
         if window.WaitKey(1) >= 0 {
             if !recognized {
                 recognize(&img, box)
                 recognized = true
                 continue
             } else {
                 break
             }
         }
     }

但如果我们将其分解,我们可以看到主函数中的代码可以分为两部分。第一部分处理打开摄像头并创建一个窗口来显示图像。关于这一点的更完整说明可以在前面的章节中找到。

特别是,让我们将注意力转向无限循环:

     for {
         if !recognized {
             if ok := webcam.Read(&img); !ok {
                 log.Fatal("Failed to read image")
             }
         }

         window.IMShow(img)
         if window.WaitKey(1) >= 0 {
             if !recognized {
                 recognize(&img, box)
                 recognized = true
             } else {
                 break
             }
         }
     }

这句话的意思很简单:首先检查识别过程是否已完成。如果没有,从摄像头获取图像,然后使用window.IMShow(img)显示图像。这构成了主循环——摄像头将连续捕获图像,然后立即在窗口中显示它。

但当按下键时会发生什么?接下来的代码块指示等待 1 毫秒的键盘事件。如果有事件,任何事件都可以,我们检查图像是否之前已被识别。如果没有,调用recognize,传入从矩阵捕获的图像和 MachineBox 客户端。然后我们将recognized标志设置为 true。因此,在下次按键时,我们将退出程序。

recognize是绘制的主要内容所在。如果你已经阅读了前面的章节,这应该对你来说已经很熟悉了。否则,这里是如何看起来recognize


 var blue = color.RGBA{0, 0, 255, 0}

 func recognize(img *gocv.Mat, box *facebox.Client) (err error) {
     var buf bytes.Buffer
     prop, _ := img.ToImage()
     if err = jpeg.Encode(&buf, prop, nil); err != nil {
         log.Fatal("Failed to encode image as JPG %v", err)
     }

     // rd := bytes.NewReader(prop.(*image.RGBA).Pix)
     faces, err := box.Check(&buf)
     // fmt.Println(err)
     // fmt.Printf("%#v\n", faces)

     for _, face := range faces {
         // draw a rectangle
         r := rect2rect(face.Rect)
         gocv.Rectangle(img, r, blue, 3)

         lbl := "Not HotDog"
         if face.Matched {
             lbl = fmt.Sprintf("%v %1.2f%%", face.Name, face.Confidence*100)
         }
         size := gocv.GetTextSize(lbl, gocv.FontHersheyPlain, 1.2, 2)
         pt := image.Pt(r.Min.X+(r.Min.X/2)-(size.X/2), r.Min.Y-2)
         gocv.PutText(img, lbl, pt, gocv.FontHersheyPlain, 1.2, blue, 2)
     }
     return nil
 }

在这里,我们看到用于首先将图像编码为 JPEG,然后将其发送到 MachineBox 客户端进行分类的熟悉代码。然后,对于每个找到的人脸,我们围绕它绘制一个蓝色矩形。facebox.Face定义如下:

 type Face struct {
     Rect       Rect
     ID         string
     Name       string
     Matched    bool
     Confidence float64
     Faceprint  string
 }

facebox.Face允许我们识别人脸,如果它们匹配,以及置信度水平。所以如果找到一个face,这些字段将可供程序员访问。

但首先,我们必须解决矩形的问题。MachineBox 不使用与标准库中image.Rectangle相同的矩形定义。

因此,需要一个辅助函数将facebox.Rect转换为image.Rectangle

 func rect2rect(a facebox.Rect) image.Rectangle {
     return image.Rect(a.Left, a.Top, a.Left+a.Width, a.Top+a.Height)
 }

定义矩形的只有几种方法。在这两种不同类型之间的转换是微不足道的。

在绘制矩形之后,写入一个标签。如果人脸被识别为 MC Hot Dog,我们将将其标记为HotDog。MachineBox 还提供了一个置信度分数,这是一个介于 0 和 1 之间的数字,表示人脸是HotDog还是Not HotDog。因此,我们也将这个分数绘制到标签中。

结果

你可能对结果很好奇。以下是一些结果:我的脸被以 57%的置信度分类为 HotDog。实际上,使用我的手机和几张其他人的照片,我发现有些人比其他人更像 HotDog,如下面的图片所示:

图片

图片图片

这本书没有涵盖什么?

在 Go 中,我们可以探索许多事情。以下是一些你可能想要探索的非详尽列表:

  • 随机树和随机森林

  • 支持向量机

  • 梯度提升方法

  • 最大熵方法

  • 图形方法

  • 局部异常因子

如果这本书有第二版,我可能会涵盖它们。如果你熟悉机器学习方法,你可能会注意到,这些方法,尤其是前三种,可能是与这本书中写的内容相比性能最高的机器学习方法之一。你可能会想知道为什么它们没有被包括在内。这些方法所属的思想流派可能提供一些线索。

例如,随机树和随机森林可以被认为是伪符号主义——它们是符号主义思想的一个远亲,起源于决策树。支持向量机是类比器。最大熵和图形方法属于贝叶斯学派。

这本书偏向于连接主义思想,有很好的理由:深度学习现在很流行。如果风向不同,这本书会有很大的不同。还有可解释性的问题。我可以很好地解释支持向量机,但这将包括页面的数学类比。另一方面,选择不解释 SVMs 的工作原理,会导致一个非常薄的章节——SVMs 的标准实现是使用 libsvm 或 svmlight。只需调用库提供的函数,工作就完成了!因此,对 SVMs 的解释是必要的。

这一切意味着什么?

这是否意味着 MachineBox 的算法不好?简短的回答是不:我们不能说 MachineBox 算法不好。更长的回答需要更细腻的理解,这需要结合工程理解和机器学习的理解。至于 facebox 的算法,关于 facebox 由什么组成,没有确切细节。但我们可以推断出发生了什么。

首先,请注意,具有匹配的图像的置信度都在 50%以上。然后我们可以假设,如果置信度水平大于 50%,facebox 才会认为找到了匹配。我通过在一个包含 1000 多张人脸图片的目录上运行识别器来验证了这一点。只有匹配的图片才有超过 50%的置信度。程序如下:

 func testFacebox() error {
     files, err := filepath.Glob("OtherFaces/*")
     if err != nil {
          return err
     }
     var count, lt50 int
     for _, filename := range files {
         f , err := os.Open(filename)
         if err != nil {
             return err
         }
         faces, err := box.Check(f)
         if err != nill {
             return err
         }
         for _, face := range faces {
             if face.Matched && face.Confidence < 0.5 {
                 lt50++
             }
         }
         if err := f.Close(); err != nil {
             return err
         }
         count++
     }
     fmt.Printf("%d/%d has Matched HotDog but Confidence < 0.5\n", lt50, count)
     return nil
 }

在这种情况下,这也意味着我们无法直接使用facebox.Matched字段作为真实值,除非是非常基础的使用场景。相反,我们必须考虑返回结果的可信度。

例如,我们可以将匹配的阈值设置得更高,以便被认为是 HotDog。将其设置为 0.8 表明只有 MC Hot Dog 的图像被识别为 HotDog。

在这里学到的教训是,由其他人创建的 API 需要一些理解。本章提供的代码非常简短。这是 MachineBox 对开发者友好性的证明。但这并不能免除开发者至少对事物有最基本的了解。

为什么选择 MachineBox?

我个人更喜欢开发自己的机器学习解决方案。当然,有人可能会把这归因于自负。然而,在第一章中,我介绍了不同类型问题的概念。其中一些问题可能可以通过机器学习算法来解决。有些问题可能只需要通用的机器学习算法,而有些则需要从通用算法派生出的专用算法。在这本书的大部分内容中,我展示了通用算法,读者可以自由地将这些算法应用到他们自己的具体问题上。

我也认识到,将通用机器学习算法作为解决方案的一部分是有价值的。想象一下,你正在开发一个程序来重新组织你电脑上的个人照片。没有必要花费大量时间在一个包含面部语料库的卷积神经网络上进行训练。主要任务是组织照片,而不是人脸识别!相反,可以使用已经训练好的模型。这类现成的解决方案适合那些现成解决方案只是其中一小部分的问题。对这类解决方案的需求日益增长。

因此,现在许多机器学习算法都作为服务提供。亚马逊网络服务有自己的产品,谷歌云和微软 Azure 也是如此。为什么我没有选择在本章介绍它们?关于我,你应该知道的另一件事是:我喜欢离线工作。我发现工作时连接到互联网只会分散我的注意力——Slack 消息、电子邮件和各种其他网站争夺我有限的注意力。不,我更喜欢离线工作和思考。

云服务公司确实提供机器学习作为服务,并且它们都要求有互联网接入。MachineBox 值得称赞的是,它提供了一个 Docker 镜像。只需要执行一次 Docker pull 操作。下载文件需要一次性的互联网连接。但一旦完成,整个工作流程就可以离线开发——或者,正如本章所有代码的情况,在飞机上开发。

这是 MachineBox 的主要优势:你不需要依赖一个要求始终连接到其云服务的公司实体。但当然,这还不是全部。MachineBox 因其对开发者的友好而闻名。我能够在飞行中编写本章大部分代码的事实就是对他们开发者友好性的证明。公平地说,即使作为一个经验丰富的机器学习库作者,人脸识别仍然相当神奇。

摘要

在结束之前,公正地说,MachineBox 对于其免费层确实存在一些限制;但根据我的经验,在个人项目中,你不太会遇到这些问题。尽管我对现有的各种机器学习即服务系统持有个人保留意见,但我确实认为它们提供了价值。我时不时地使用过它们,但通常我不需要它们。尽管如此,我强烈建议读者去了解一下。

本章与上一章结合,展示了机器学习在行业中的广泛性。如果你的主要问题不需要,并非所有机器学习算法都必须从头开始手写。我很幸运,能够从事我热爱的工作:构建定制的机器学习算法。这可能会影响我对这个问题的看法。你可能是一名工程师,需要在截止日期前解决一些更大的商业问题。为此,这两章是为你准备的。

下一章将列出 Go 中机器学习的更多途径。

第十章:接下来是什么?

这本书涵盖的项目可以被认为是小项目。它们可以在一天或两天内完成。一个真实的项目通常需要几个月。它们需要机器学习专业知识、工程专业知识以及 DevOps 专业知识。在没有跨越多个章节的同时保持相同详细程度的情况下,很难详细地描述这样的项目。事实上,正如这本书的进展所见证的,随着项目的复杂度增加,详细程度会降低。事实上,最后两章相当简略。

话虽如此,我们在这本书中已经取得了相当大的成就。然而,我们还有很多没有涉及的内容。这归因于我在机器学习的一些其他领域缺乏个人专业知识。在介绍章节中,我提到机器学习系统有多种分类方案,并且我们将选择常见的观点,即只有无监督学习和监督学习两种类型。显然,还有其他的分类方案。让我分享另一个,它将机器学习系统分为五个类别:

  • 连接主义

  • 进化

  • 贝叶斯

  • 类比者

  • 符号

在这里,我使用术语机器学习。其他人可能使用人工智能来对这些系统进行分类。这种差异是微妙的。这五个类别在技术上属于人工智能的思想流派。这为手头的话题设定了一个更大的舞台。

除了两个之外,在这本书中,我们已经探讨了人工智能的不同思想流派。在连接主义学派中,我们从第二章的线性回归开始,即线性回归 – 房价预测,以及第八章的基本面部检测和第十章的接下来是什么中提到的各种神经网络。在贝叶斯学派中,我们有第三章的朴素贝叶斯,即分类 – 垃圾邮件检测,以及第六章的 DMMClust 算法,即神经网络 – MNIST 手写识别;我们还有各种距离和聚类算法,这些多少有些属于类比学派。

没有涵盖的人工智能两种思想流派是进化学派和符号学派。前者我只有一些理论经验。我对人工智能进化学派的理解并不深刻。我从马丁·诺瓦克这样的人那里有很多要学习的。后者,我比较熟悉——有人告诉我,我对围棋的介绍暴露了我对符号学派思想的大量经验。

我没有写关于符号主义学派的文章的主要原因是因为作为一个主题,它太密集了,而且我并不是一个足够好的作家来真正处理这个主题。它比连接主义学派更直接地开启了棘手的哲学含义。这些含义是我目前还没有准备好处理的,尽管读者可能准备好了。

说到这里,我一生中最令人兴奋的时刻之一就是构建 DeepMind 的 AlphaGo 围棋算法。你可以在这里找到代码:github.com/gorgonia/agogo。这是一个庞大的项目,由一个由四个人组成的小团队成功完成。这是一次极具回报的经历。AlphaGo 算法将连接主义深度神经网络与符号主义树搜索相结合。尽管取得了这样的成就,我仍然认为自己还没有准备好撰写关于符号人工智能方法的文章。

所有这些都引发了一个问题:接下来是什么?

读者应该关注什么?

每次我上机器学习和人工智能的课程时,都会有人问我这个问题。我在引言章节中提到,一个人可能想成为一名机器学习从业者或研究者。我的专业角色横跨这两个领域。这使得我有了一些经验,可以为对这两个领域感兴趣的读者提供一些建议。

实践者

对于从业者来说,最重要的技能不在于机器学习。最重要的技能在于理解问题。这句话隐含的意思是,从业者至少应该了解哪些机器学习算法适合当前的问题。显然,这需要理解机器学习算法是如何工作的。

新进入这个领域的人经常问我,深度学习是否能够解决他们所有的问题。答案是明确地不。解决方案必须针对问题量身定制。实际上,在速度和准确性方面,非深度学习解决方案往往优于深度学习解决方案。这些通常是简单的问题,所以这里有一个好的经验法则:如果问题是不可组合的,你很可能不需要使用深度学习。

我所说的不可组合是指什么?回想一下第一章,“如何解决所有机器学习问题”,当我介绍问题类型以及问题如何分解为子问题时。如果子问题本身又由更小的子问题组成,那么,这意味着问题是由子问题组成的。不可组合的问题不需要深度学习。

当然,这只是一个非常粗略的问题概述。对问题的更深入理解总是必要的。

研究者

对研究者来说,最重要的技能是理解机器学习算法在高级别是如何工作的。在此基础上,理解数据结构是最重要的。然后,才能编写实际的算法。

值得注意的是数据表示和数据结构之间的区别。也许在未来的某一天——希望不会太远——我们将拥有编程语言,其中数据表示不再重要。但现在,数据表示仍然很重要。良好的表示会产生高效的算法。差的表示会导致算法性能不佳。

在大多数情况下,我的建议是先从简单开始,尽可能使事物易于理解。然后开始去除不必要的部分。一个很好的例子可以在第三章中找到,分类 – 垃圾邮件检测,在朴素贝叶斯中。贝叶斯函数的直接表示会相当笨拙。但在理解算法的动态部分时,我们能够使其高效且小巧。

有时,一些复杂性不可避免。有些复杂性不可避免,因为算法本质上很复杂。有些复杂性是必须做出的权衡。一个例子是使用 Gorgonia。深度学习在本质上只是写一个长的数学表达式。为了更新权重,使用反向传播。反向传播仅仅是微分。但没有人愿意手动计算微分!我们希望机械地评估我们的微积分!因此,一些复杂性不可避免。

智慧在于知道何时这些复杂性不可避免。智慧来自经验,因此对研究者来说,我的建议是尽可能多做。在不同的规模上做事也会带来不同的经验。例如,在多台机器上执行 K-means 算法与前面章节中展示的代码非常不同。

研究者、实践者和他们的利益相关者

关于规模的问题——有一种趋势是求助于包或外部程序,例如 Spark,来解决问题。通常它们确实解决了问题。但根据我的经验,最终,在规模化的工作中,没有一种适合所有情况的解决方案。因此,学习基础知识是很好的,这样在必要时,你可以参考基础知识并将它们应用到你的情况中。

再次谈到规模的问题——研究人员和从业者都应该学会规划项目。这是我最不擅长的事情之一。即使有多个项目经理的帮助,机器学习项目也往往容易失控。管理这些项目确实需要相当多的自律。这既涉及实施者的部分,也涉及利益相关者的部分。

最后,学会管理利益相关者的期望。我的许多项目都失败了。我能说项目失败了本身就是一种资格证明。对于我参与的多数项目,我都已经定义了成功和失败的标准。如果是一个更传统的基于统计学的项目,那么这些就是你的简单零假设。未能拒绝零假设则被视为失败。同样,更复杂的项目会有多个假设——这些通常以 F 分数等形式出现。熟练掌握这些工具,并将它们传达给你的利益相关者。你必须意识到,绝大多数机器学习项目在最初的几次尝试中都会失败。

我在哪里可以学到更多?

我坚信机器学习方法不应该与编程语言绑定。如果明天出现了一种新的语言,它提供了比 Go 更好的性能,同时保持了 Go 的开发友好性,我会毫不犹豫地转向这种语言。我不必担心不得不重新学习新的机器学习方法。我已经知道了它们。我只需简单地在新语言中重写它们。因此,我的建议将是语言无关的。

如果你想了解更多关于机器学习算法的知识,我推荐克里斯托弗·贝斯特的《模式识别与机器学习》。这是一本稍微有些年代的书,但你可能会惊讶地发现,许多机器学习的新发展都源于这本书。

如果你想了解更多关于深度学习的知识,我推荐伊恩·古德费洛和约书亚·本吉奥的《深度学习》。这是一本新书——它非常理论化,没有代码,但获得的见解将是无价的。

如果你想了解更多关于使用 Go 和 Gorgonia 进行深度学习的知识,有一本即将出版的书由达雷尔·丘亚和加雷思·塞内克撰写,由 Packt 出版。它涵盖了广泛的深度学习相关主题。

如果你想了解更多关于 Go 语言中的数据科学和机器学习,我也推荐丹尼尔·惠特纳克的《用 Go 进行机器学习》。这是关于 Go 语言中机器学习的第一本书之一,时至今日,它仍然是一个出色的资源。

如果你想了解更多关于 Go 语言的知识,我强烈推荐艾伦·多诺万和布莱恩·克尼汉的《Go 编程语言》。克尼汉是著名 C 语言书籍K&R中的K。在这里,他完成了类似的壮举。

谢谢

感谢您阅读这本书;我希望它对您有所帮助。

posted @ 2025-09-03 10:08  绝不原创的飞龙  阅读(8)  评论(0)    收藏  举报