无监督学习实用指南-全-

无监督学习实用指南(全)

原文:annas-archive.org/md5/5d48074db68aa41a4c5eb547fcbf1a69

译者:飞龙

协议:CC BY-NC-SA 4.0

序言

机器学习的简要历史

机器学习是人工智能的一个子领域,计算机通过数据学习,通常是为了在某些狭义定义的任务上提高性能,而无需显式编程。机器学习 这个术语早在 1959 年就被创造出来了(由 AI 领域的传奇人物亚瑟·塞缪尔),但在 21 世纪,机器学习并没有取得多少主要的商业成功。相反,该领域仍然是一种学术界的小众研究领域。

在早期(上世纪六十年代),AI 社区中许多人对其未来持过于乐观的态度。当时的研究人员,如赫伯特·西蒙和马文·明斯基,声称 AI 将在几十年内达到人类水平的智能:¹

机器将在二十年内有能力完成人类能够完成的任何工作。

赫伯特·西蒙,1965

从三到八年内,我们将拥有一台具有平均人类智能的机器。

马文·明斯基,1970

研究人员过于乐观,专注于所谓的强人工智能通用人工智能(AGI) 项目,试图构建能够解决问题、知识表示、学习和规划、自然语言处理、感知和运动控制的 AI 代理。这种乐观主义帮助吸引了来自国防部等主要参与者的重要资金,但这些研究人员所解决的问题过于雄心勃勃,最终注定失败。

AI 研究很少从学术界跨入工业界,随之而来的是一系列所谓的 AI 寒冬。在这些 AI 寒冬中(这是基于冷战时代核冬天的类比),对 AI 的兴趣和资金逐渐减少。偶尔会出现围绕 AI 的炒作周期,但很少有持久性。到了 1990 年代初,对 AI 的兴趣和资金已经达到了低谷。

AI 回归了,但为什么现在?

过去二十年中,AI 重新以全新的姿态出现——起初作为一种纯学术兴趣领域,现在已成为吸引大学和公司最聪明头脑的完整领域。

三个关键发展推动了这一复兴:机器学习算法的突破、大量数据的可用性以及超快速的计算机。

首先,研究人员不再专注于过于雄心勃勃的强人工智能项目,转而关注强人工智能的狭义子问题,也被称为弱人工智能狭义人工智能。这种专注于改进狭义任务解决方案的做法导致了算法上的突破,为成功的商业应用铺平了道路。许多这些算法——通常最初在大学或私人研究实验室开发——很快被开源,加速了这些技术在工业界的采纳。

其次,数据捕获成为大多数组织的焦点,数字数据存储成本大幅下降。得益于互联网,大量数据也以前所未有的规模广泛公开和共享。

其次,云端计算的普及使得 AI 研究人员能够根据需求轻松、廉价地扩展其 IT 基础设施,而无需进行大规模的前期硬件投资。

应用人工智能的出现

这三股力量将 AI 从学术界推向工业界,每年吸引越来越高的兴趣和资金。AI 不再仅仅是一个理论上的兴趣领域,而是一个成熟的应用领域。Figure P-1 展示了 Google Trends 中机器学习兴趣的增长趋势图,涵盖了过去五年的时间。

机器学习兴趣随时间变化图

图 P-1. 机器学习兴趣随时间变化图

现在,人工智能被视为一种突破性的横向技术,类似于计算机和智能手机的出现,将在未来十年对每一个行业产生重大影响。²

涉及机器学习的成功商业应用包括但不限于光学字符识别、电子邮件垃圾过滤、图像分类、计算机视觉、语音识别、机器翻译、群体分割与聚类、生成合成数据、异常检测、网络犯罪预防、信用卡欺诈检测、网络欺诈检测、时间序列预测、自然语言处理、棋盘游戏和视频游戏、文档分类、推荐系统、搜索、机器人技术、在线广告、情感分析、DNA 序列分析、金融市场分析、信息检索、问答系统和医疗决策。

过去 20 年来应用人工智能的主要里程碑

这些里程碑将 AI 从当时主要是学术讨论的话题带到了今天科技的主流位置。

  • 1997 年:Deep Blue,一个自上世纪 80 年代中期开始研发的 AI 机器人,在一场备受关注的国际象棋比赛中击败了世界冠军加里·卡斯帕罗夫。

  • 2004 年:DARPA 推出了 DARPA Grand Challenge,这是一项年度举办的自动驾驶挑战赛,在沙漠地区举行。2005 年,斯坦福获得了最高奖。2007 年,卡内基梅隆大学在城市环境中实现了这一壮举。2009 年,谷歌推出了自动驾驶汽车。到 2015 年,包括特斯拉、Alphabet 的 Waymo 和 Uber 在内的许多主要技术巨头都推出了资金充裕的主流自动驾驶技术项目。

  • 2006 年:多伦多大学的 Geoffrey Hinton 提出了一种快速学习算法,用于训练多层神经网络,开启了深度学习革命。

  • 2006: Netflix 启动了 Netflix 奖(Netflix Prize)竞赛,奖金高达一百万美元,挑战团队利用机器学习技术,将其推荐系统的准确性提高至少 10%。一个团队在 2009 年赢得了这一奖项。

  • 2007: AI 在跳棋比赛中达到超人类水平,由阿尔伯塔大学的团队解决。

  • 2010: ImageNet 启动了年度比赛——ImageNet 大规模视觉识别挑战(ILSVRC),团队使用机器学习算法在一个大型、经过良好筛选的图像数据集中正确检测和分类对象。这引起了学术界和技术巨头的重视。由于深度卷积神经网络的进展,2011 年的分类错误率从 25%降至 2015 年的几个百分点。这导致了计算机视觉和物体识别的商业应用。

  • 2010: Microsoft 推出了 Xbox 360 的 Kinect。由 Microsoft Research 的计算机视觉团队开发,Kinect 能够跟踪人体动作并将其转化为游戏操作。

  • 2010: Siri,最早的主流数字语音助手之一,被 Apple 收购,并作为 iPhone 4S 的一部分于 2011 年 10 月发布。最终,Siri 在 Apple 的所有产品中都推出。由卷积神经网络和长短期记忆递归神经网络驱动,Siri 执行语音识别和自然语言处理。随后,亚马逊、微软和谷歌进入竞争,分别发布了 Alexa(2014 年)、Cortana(2014 年)和 Google Assistant(2016 年)。

  • 2011: IBM Watson,一个由 David Ferrucci 领导的团队开发的问答型 AI 代理程序,击败了前《危险边缘》获胜者 Brad Rutter 和 Ken Jennings。IBM Watson 现在被多个行业使用,包括医疗保健和零售。

  • 2012: 由 Andrew Ng 和 Jeff Dean 领导的 Google Brain 团队,通过观看来自 YouTube 视频的未标记图像,训练神经网络识别猫。

  • 2013: Google 赢得了 DARPA 机器人挑战赛,涉及半自主机器人在危险环境中执行复杂任务,如驾驶车辆、越过瓦砾、清除被堵入的入口、打开门和爬梯子。

  • 2014: Facebook 发布了基于神经网络的 DeepFace 系统,可以以 97%的准确率识别面部。这接近人类水平的性能,比先前系统提高了 27%以上。

  • 2015: AI 成为主流,并广泛出现在全球的媒体报道中。

  • 2015: Google DeepMind 的 AlphaGo 击败了世界级职业选手樊麾的围棋比赛。2016 年,AlphaGo 又击败了李世石,2017 年又击败了柯洁。2017 年,名为 AlphaGo Zero 的新版本以 100 比 0 击败了以前的 AlphaGo 版本。AlphaGo Zero 采用无监督学习技术,仅通过与自己对弈就掌握了围棋。

  • 2016 年:谷歌对其语言翻译系统 Google Translate 进行了重大改进,将其现有的基于短语的翻译系统替换为基于深度学习的神经机器翻译系统,将翻译错误率降低了多达 87%,接近人类水平的准确度。

  • 2017 年:由卡内基梅隆大学开发的 Libratus 在无限制德州扑克头对头比赛中获胜。

  • 2017 年:OpenAI 训练的机器人在 Dota 2 比赛中击败了职业玩家。

从狭义人工智能到通用人工智能

当然,将人工智能成功应用于狭义问题只是一个起点。在人工智能社区中,有一种越来越强烈的信念,即通过结合几个弱人工智能系统,我们可以开发出强人工智能。这种强人工智能或通用人工智能(AGI)代理将能够在许多广义任务上达到人类水平的表现。

在人工智能达到人类水平表现之后不久,一些研究人员预测这种强人工智能将超越人类智能,达到所谓的超级智能。达到这种超级智能的时间估计从现在起可能只需 15 年,也可能需要 100 年,但大多数研究人员认为,在未来几代人之内,人工智能将足够发展到达到这一水平。这一次,这是否又是像之前人工智能周期中看到的炒作,还是有所不同?

只有时间能说明一切。

目标与方法

到目前为止,大多数成功的商业应用程序(如计算机视觉、语音识别、机器翻译和自然语言处理)都涉及有标签数据的监督学习。然而,大多数世界数据是未标记的。

在这本书中,我们将涵盖无监督学习领域(这是机器学习的一个分支,用于发现隐藏的模式),并学习未标记数据中的潜在结构。根据许多行业专家的说法,比如 Facebook 的 AI 研究总监兼纽约大学教授杨立昆,无监督学习是人工智能的下一个前沿,可能是实现通用人工智能的关键。因此,无监督学习是当今人工智能领域最炙手可热的话题之一。

本书的目标是概述您在日常工作中应用这项技术所需的概念和工具,以便您能够发展出这种直觉。换句话说,这是一本应用性的书籍,将帮助您构建真实世界的系统。我们还将探讨如何高效地标记未标记的数据集,将无监督学习问题转化为半监督学习问题。

本书将采用实践方法,介绍一些理论,但主要侧重于将无监督学习技术应用于解决现实世界的问题。数据集和代码可在Jupyter notebooks on GitHub上找到。

凭借从本书中获得的概念理解和实践经验,您将能够将无监督学习应用于大型未标记数据集,以揭示隐藏模式,获取更深入的业务见解,检测异常,基于相似性对群组进行聚类,执行自动特征工程和选择,生成合成数据集等等。

先决条件

本书假定您具有一些 Python 编程经验,包括熟悉 NumPy 和 Pandas。

有关 Python 的更多信息,请访问官方 Python 网站。有关 Jupyter Notebook 的更多信息,请访问官方 Jupyter 网站。要复习大学水平的微积分、线性代数、概率和统计学,请阅读 Ian Goodfellow 和 Yoshua Bengio 的《深度学习》教材的第 I 部分。要复习机器学习,请阅读统计学习的要素

路线图

本书分为四个部分,涵盖以下主题:

第 I 部分,无监督学习基础

监督学习和无监督学习之间的差异,流行的监督学习和无监督学习算法的概述,以及端到端的机器学习项目

第 II 部分,使用 Scikit-Learn 进行无监督学习

降维、异常检测和聚类和分组分割

提示

有关部分 I 和 II 中讨论的概念的更多信息,请参阅Scikit-learn 文档

第 III 部分,使用 TensorFlow 和 Keras 进行无监督学习

表示学习和自动特征提取、自动编码器和半监督学习

第 IV 部分,使用 TensorFlow 和 Keras 进行深度无监督学习

受限玻尔兹曼机、深度信念网络和生成对抗网络

本书中使用的约定

本书中使用以下印刷约定:

斜体

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

常量宽度

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

常量宽度粗体

显示用户应该按字面意思输入的命令或其他文本。

常量宽度斜体

显示应替换为用户提供的值或上下文确定的值的文本。

提示

此元素表示提示或建议。

注意

此元素表示一般注释。

警告

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

使用代码示例

可以在GitHub上下载补充材料(代码示例等)。

本书旨在帮助您完成工作。一般情况下,如果本书提供示例代码,您可以在您的程序和文档中使用它。除非您复制了代码的大部分内容,否则无需征得我们的许可。例如,编写一个使用本书中几个代码片段的程序无需许可。销售或分发包含奥莱利书籍示例的 CD-ROM 需要许可。引用本书回答问题并引用示例代码无需许可。将本书的大量示例代码整合到您产品的文档中需要许可。

我们感谢但不需要署名。署名通常包括标题、作者、出版商和 ISBN。例如:“使用 Python 进行无监督学习实战 作者 Ankur A. Patel(奥莱利)。版权所有 2019 Ankur A. Patel,978-1-492-03564-0。”

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

奥莱利在线学习

注意

近 40 年来,奥莱利传媒 为企业的成功提供技术和商业培训、知识和洞察。

我们独特的专家和创新者网络通过书籍、文章、会议和我们的在线学习平台分享他们的知识和专业知识。奥莱利的在线学习平台为您提供按需访问的现场培训课程、深度学习路径、交互式编码环境以及来自奥莱利和其他 200 多家出版商的大量文本和视频。更多信息,请访问 http://oreilly.com

如何联系我们

请将有关本书的评论和问题发送至出版商:

  • 奥莱利传媒公司

  • 1005 Gravenstein Highway North

  • CA 95472 Sebastopol

  • 800-998-9938(美国或加拿大)

  • 707-829-0515(国际或本地)

  • 707-829-0104(传真)

我们为本书设立了一个网页,列出勘误、示例和任何额外信息。您可以访问 http://bit.ly/unsupervised-learning

要就本书发表评论或提出技术问题,请发送电子邮件至 bookquestions@oreilly.com

有关我们的图书、课程、会议和新闻的更多信息,请访问我们的网站 http://www.oreilly.com

在 Facebook 上找到我们:http://facebook.com/oreilly

在 Twitter 上关注我们:http://twitter.com/oreillymedia

在 YouTube 上观看我们:http://www.youtube.com/oreillymedia

¹ 这些观点在 1968 年启发了斯坦利·库布里克创作 2001:太空漫游 中的 AI 代理人 HAL 9000。

根据麦肯锡全球研究所的报告,到 2055 年,人们获得报酬的所有专业活动中超过一半可以实现自动化。

第一部分:无监督学习的基础知识

首先,让我们探索当前的机器学习生态系统以及无监督学习的定位。我们还将从头开始构建一个机器学习项目,涵盖基础内容,如设置编程环境、获取和准备数据、探索数据、选择机器学习算法和成本函数,以及评估结果。

第一章:机器学习生态系统中的无监督学习

大多数人类和动物的学习都是无监督学习。如果智能是一个蛋糕,无监督学习将是蛋糕,监督学习将是蛋糕上的糖衣,而强化学习将是蛋糕上的樱桃。我们知道如何制作糖衣和樱桃,但我们不知道如何制作蛋糕。在我们甚至考虑真正的 AI 之前,我们需要解决无监督学习问题。

伊恩·拉坤

在本章中,我们将探讨基于规则的系统与机器学习、监督学习与无监督学习之间的区别,以及每种方法的相对优势和劣势。

我们还将介绍许多流行的监督学习算法和无监督学习算法,并简要探讨半监督学习和强化学习如何融入其中。

基础机器学习术语

在我们深入探讨不同类型的机器学习之前,让我们先看一个简单且常用的机器学习示例,以帮助我们更具体地理解我们介绍的概念:电子邮件垃圾过滤器。我们需要构建一个简单的程序,输入电子邮件并正确地将它们分类为“垃圾邮件”或“非垃圾邮件”。这是一个直接的分类问题。

这里是一些机器学习术语的复习:这个问题的输入变量是电子邮件的文本。这些输入变量也被称为特征预测变量独立变量。我们试图预测的输出变量是标签“垃圾邮件”或“非垃圾邮件”。这也被称为目标变量依赖变量响应变量(或,因为这是一个分类问题)。

AI 训练的示例集被称为训练集,每个单独的示例称为训练实例样本。在训练过程中,AI 试图最小化其成本函数错误率,或者更积极地说,最大化其价值函数—在本例中,是正确分类的电子邮件比例。AI 在训练期间积极优化以达到最小的错误率。它的错误率是通过将 AI 预测的标签与真实标签进行比较来计算的。

然而,我们最关心的是 AI 如何将其训练推广到以前从未见过的电子邮件上。这将是 AI 的真正测试:它能否使用在训练集示例中学到的知识正确分类它以前从未见过的电子邮件?这种泛化误差样外误差是我们用来评估机器学习解决方案的主要指标。

这组以前从未见过的示例被称为测试集保留集(因为这些数据被保留在训练之外)。如果我们选择有多个保留集(也许在训练过程中评估我们的泛化误差是明智的),我们可能会有用于评估我们进展的中间保留集,这些中间保留集称为验证集

将所有这些结合起来,AI 在训练数据(经验)上进行训练,以提高在标记垃圾邮件(任务)中的错误率(性能),最终成功的标准是其经验如何推广到新的、以前从未见过的数据上(泛化误差)。

基于规则与机器学习

使用基于规则的方法,我们可以设计一个垃圾邮件过滤器,通过明确的规则捕捉垃圾邮件,比如标记使用“u”代替“you”,“4”代替“for”,“BUY NOW”等的电子邮件。但是随着坏人改变他们的垃圾邮件行为以逃避规则,这种系统在时间上会很难维护。如果我们使用基于规则的系统,我们将不得不经常手动调整规则,以保持系统的最新状态。而且,设置这种系统将非常昂贵——想象一下我们需要创建多少规则才能使其正常运行。

与基于规则的方法不同,我们可以使用机器学习来训练电子邮件数据,并自动创建规则以正确标记恶意电子邮件为垃圾邮件。这种基于机器学习的系统也可以随着时间的推移自动调整。这种系统的培训和维护成本要低得多。

在这个简单的电子邮件问题中,我们可能可以手工制定规则,但是对于许多问题来说,手工制定规则根本不可行。例如,考虑设计自动驾驶汽车——想象一下为汽车在每一个遇到的情况下如何行为制定规则,这是一个棘手的问题,除非汽车可以根据自己的经验学习和适应。

我们还可以将机器学习系统作为探索或数据发现工具,以深入了解我们尝试解决的问题。例如,在电子邮件垃圾邮件过滤器的示例中,我们可以学习哪些单词或短语最能预测垃圾邮件,并识别新出现的恶意垃圾邮件模式。

监督学习与非监督学习

机器学习领域有两个主要分支——监督学习无监督学习——以及许多桥接这两者的子分支。

在监督学习中,AI 代理可以访问标签,这些标签可以用来改善其在某些任务上的表现。在电子邮件垃圾邮件过滤问题中,我们有一个包含每封电子邮件中所有文本的数据集。我们还知道哪些邮件是垃圾邮件或非垃圾邮件(所谓的标签)。这些标签在帮助监督学习 AI 区分垃圾邮件和其他邮件方面非常有价值。

在无监督学习中,没有标签可用。因此,AI 代理的任务并不是明确定义的,性能也不能如此清晰地衡量。考虑电子邮件垃圾邮件过滤器问题——这次没有标签。现在,AI 代理将尝试理解电子邮件的基本结构,将电子邮件数据库分成不同的组,使得组内的电子邮件彼此相似但与其他组的电子邮件不同。

这个无监督学习问题比监督学习问题的定义不太明确,对 AI 代理来说更难解决。但是,如果处理得当,解决方案将更为强大。

原因在于:无监督学习 AI 可能会发现几个后来标记为“垃圾邮件”的组,但 AI 也可能会发现后来标记为“重要”的组,或者归类为“家庭”、“专业”、“新闻”、“购物”等。换句话说,由于问题没有严格定义的任务,AI 代理可能会发现我们最初未曾寻找的有趣模式。

此外,这种无监督系统在未来数据中发现新模式的能力优于监督系统,使得无监督解决方案在前进时更加灵活。这就是无监督学习的力量。

监督学习的优势和劣势

监督学习在定义良好的任务和充足标签的情况下优化性能。例如,考虑一个非常大的对象图像数据集,其中每个图像都有标签。如果数据集足够大,并且我们使用正确的机器学习算法(即卷积神经网络)并且使用足够强大的计算机进行训练,我们可以构建一个非常好的基于监督学习的图像分类系统。

当监督学习 AI 在数据上进行训练时,它将能够通过比较其预测的图像标签与我们文件中的真实图像标签来测量其性能(通过成本函数)。AI 将明确尝试将这个成本函数最小化,使其在以前未见过的图像(从留存集)上的错误尽可能低。

这就是为什么标签如此强大——它们通过提供错误度量来指导 AI 代理。AI 使用这个错误度量随着时间的推移来提高其性能。没有这样的标签,AI 不知道它在正确分类图像方面有多成功(或不成功)。

然而,手动标记图像数据集的成本很高。即使是最好的策划图像数据集也只有数千个标签。这是一个问题,因为监督学习系统在对具有标签的对象图像分类方面表现非常出色,但在对没有标签的对象图像分类方面表现不佳。

尽管监督学习系统非常强大,但它们在将知识推广到以前未见过的实例上的能力也受到限制。由于世界上大多数数据都没有标签,因此使用监督学习时,AI 将其性能扩展到以前未见过的实例的能力是相当有限的。

换句话说,监督学习擅长解决狭义 AI 问题,但在解决更有雄心、定义不太明确的强 AI 类型问题时表现不佳。

无监督学习的优势和劣势

在狭义定义的任务中,有着明确定义的模式并且随时间变化不大以及具有充足可用的标记数据集时,监督学习将在效果上胜过无监督学习。

然而,对于那些模式未知或不断变化,或者我们没有足够大的标记数据集的问题,无监督学习确实表现出色。

无监督学习不依赖标签,而是通过学习其训练的数据的基本结构来工作。它通过试图用比数据集中可用示例数量显著较小的一组参数来表示其训练的数据来实现这一点。通过执行这种表示学习,无监督学习能够识别数据集中的不同模式。

在图像数据集示例中(这次没有标签),无监督学习的 AI 可能能够根据它们彼此的相似性以及与其余图像的不同性将图像识别并分组。例如,所有看起来像椅子的图像将被分组在一起,所有看起来像狗的图像将被分组在一起,依此类推。

当然,无监督学习的 AI 本身无法将这些组标记为“椅子”或“狗”,但现在相似的图像被分组在一起后,人类的标记任务变得简单得多。人类可以手动标记所有不同的组,标签将应用于每个组内的所有成员。

经过初步训练后,如果无监督学习的 AI 发现了不属于任何已标记组的图像,AI 将为未分类的图像创建单独的组,触发人类标记新的、尚未标记的图像组。

无监督学习使以前棘手的问题更易解决,并且在找到历史数据和未来数据中隐藏模式方面更为灵活。此外,我们现在有了一种处理世界上存在的大量未标记数据的 AI 方法。

尽管无监督学习在解决特定、狭义定义的问题方面不如监督学习熟练,但在解决更为开放的强 AI 类型问题和推广这种知识方面表现更佳。

同样重要的是,无监督学习可以解决数据科学家在构建机器学习解决方案时遇到的许多常见问题。

使用无监督学习来改善机器学习解决方案

机器学习的最近成功是由大量数据的可用性、计算硬件和基于云的资源的进步以及机器学习算法的突破推动的。但这些成功主要出现在狭义 AI 问题,如图像分类、计算机视觉、语音识别、自然语言处理和机器翻译领域。

要解决更雄心勃勃的 AI 问题,我们需要发挥无监督学习的价值。让我们探讨数据科学家在构建解决方案时面临的最常见挑战,以及无监督学习如何帮助解决这些挑战。

标记不足的数据

我认为 AI 就像建造一艘火箭。你需要一个巨大的引擎和大量的燃料。如果你有一个巨大的引擎和少量的燃料,你无法进入轨道。如果你有一个微小的引擎和大量的燃料,你甚至无法起飞。要建造一艘火箭,你需要一个巨大的引擎和大量的燃料。

Andrew Ng

如果机器学习是一艘火箭,数据就是燃料——没有大量数据,火箭是无法飞行的。但并非所有数据都是平等的。要使用监督算法,我们需要大量标记数据,这在生成过程中是困难且昂贵的。¹

使用无监督学习,我们可以自动标记未标记的示例。这里是它的工作原理:我们会对所有示例进行聚类,然后将标记示例的标签应用于同一聚类中的未标记示例。未标记的示例将获得它们与之最相似的已标记示例的标签。我们将在第五章中探讨聚类。

过拟合

如果机器学习算法根据训练数据学习了一个过于复杂的函数,它在从保留集(例如验证集或测试集)中获得的以前未见实例上可能表现非常糟糕。在这种情况下,算法过度拟合了训练数据——从数据中提取了太多的噪声,并且具有非常差的泛化误差。换句话说,该算法是在记忆训练数据,而不是学习如何基于其泛化知识。²

为了解决这个问题,我们可以将无监督学习引入作为正则化器正则化是一种用来降低机器学习算法复杂度的过程,帮助其捕捉数据中的信号而不是过多地调整到噪声。无监督预训练就是这种正则化的形式之一。我们可以不直接将原始输入数据馈送到监督学习算法中,而是馈送我们生成的原始输入数据的新表示。

这种新的表示捕捉了原始数据的本质——真正的底层结构——同时在过程中减少了一些不太代表性的噪声。当我们将这种新的表示输入监督学习算法时,它需要处理的噪声较少,捕捉到更多的信号,从而改善其泛化误差。我们将在第七章探讨特征提取。

维度诅咒

尽管计算能力有所提升,大数据对机器学习算法的管理仍然颇具挑战性。一般来说,增加更多实例并不太成问题,因为我们可以利用现代的映射-减少解决方案(如 Spark)并行操作。然而,特征越多,训练就越困难。

在非常高维空间中,监督算法需要学习如何分离点并构建函数逼近,以做出良好的决策。当特征非常多时,这种搜索变得非常昂贵,无论是从时间还是计算资源的角度来看。在某些情况下,可能无法快速找到一个好的解决方案。

这个问题被称为维度诅咒,无监督学习非常适合帮助管理这一问题。通过降维,我们可以找到原始特征集中最显著的特征,将维度减少到一个更易管理的数量,同时在过程中几乎不丢失重要信息,然后应用监督算法来更有效地执行寻找良好函数逼近的搜索。我们将在第三章涵盖降维技术。

特征工程

特征工程是数据科学家执行的最关键任务之一。如果没有合适的特征,机器学习算法将无法在空间中有效分离点,从而不能在以前未见的示例上做出良好的决策。然而,特征工程通常非常耗时,需要人类创造性地手工设计正确类型的特征。相反,我们可以使用无监督学习算法中的表示学习来自动学习适合解决手头任务的正确类型的特征表示。我们将在第七章探索自动特征提取。

异常值

数据的质量也非常重要。如果机器学习算法在稀有的、扭曲的异常值上进行训练,其泛化误差将低于忽略或单独处理异常值的情况。通过无监督学习,我们可以使用降维技术进行异常检测,并分别为异常数据和正常数据创建解决方案。我们将在第四章构建一个异常检测系统。

数据漂移

机器学习模型还需要意识到数据中的漂移。如果模型用于预测的数据在统计上与模型训练时的数据不同,那么模型可能需要在更能代表当前数据的数据上重新训练。如果模型不重新训练或者没有意识到这种漂移,那么模型在当前数据上的预测质量将会受到影响。

通过使用无监督学习构建概率分布,我们可以评估当前数据与训练集数据的差异性——如果两者差异足够大,我们可以自动触发重新训练。我们将探讨如何构建这些数据判别器类型的内容在第十二章中。

对监督算法的更详细探讨

在我们深入研究无监督学习系统之前,让我们先看看监督学习算法及其工作原理。这将有助于我们理解无监督学习在机器学习生态系统中的位置。

在监督学习中,存在两种主要类型的问题:分类回归。在分类中,AI 必须正确地将项目分类为两个或更多类别之一。如果只有两个类别,则该问题称为二元分类。如果有三个或更多类别,则该问题被归类为多类分类

分类问题也被称为离散预测问题,因为每个类别都是一个离散的群体。分类问题也可能被称为定性分类问题。

在回归中,AI 必须预测一个连续变量而不是离散变量。回归问题也可能被称为定量问题。

监督式机器学习算法涵盖了从非常简单到非常复杂的整个范围,但它们的目标都是最小化与数据集标签相关的某个成本函数或错误率(或最大化某个值函数)。

正如前面提到的,我们最关心的是机器学习解决方案在前所未见的情况下的泛化能力。选择监督学习算法非常重要,可以最大程度地减少这种泛化误差。

为了达到尽可能低的泛化误差,算法模型的复杂性应该与数据底层真实函数的复杂性相匹配。我们不知道这个真实函数究竟是什么。如果我们知道,我们就不需要使用机器学习来创建模型了——我们只需解决函数以找到正确答案。但由于我们不知道这个真实函数是什么,我们选择机器学习算法来测试假设,并找到最接近这个真实函数的模型(即具有尽可能低的泛化误差)。

如果算法模拟的内容比真实函数复杂度低,我们就欠拟合了数据。在这种情况下,我们可以通过选择能够模拟更复杂函数的算法来改善泛化误差。然而,如果算法设计了一个过于复杂的模型,我们就过拟合了训练数据,并且在以前从未见过的情况下表现不佳,增加了我们的泛化误差。

换句话说,选择复杂算法而不是简单算法并不总是正确的选择——有时简单才是更好的。每种算法都有其一系列的优点、弱点和假设,知道在给定你拥有的数据和你试图解决的问题时何时使用何种方法对于掌握机器学习非常重要。

在本章的其余部分中,我们将描述一些最常见的监督学习算法(包括一些实际应用),然后再介绍无监督算法。³

线性方法

最基本的监督学习算法模拟了输入特征与我们希望预测的输出变量之间的简单线性关系。

线性回归

所有算法中最简单的是线性回归,它使用一个模型假设输入变量(x)与单个输出变量(y)之间存在线性关系。如果输入与输出之间的真实关系是线性的,并且输入变量之间不高度相关(称为共线性),线性回归可能是一个合适的选择。如果真实关系更为复杂或非线性,线性回归将会欠拟合数据。⁴

因为它非常简单,解释算法模型的关系也非常直接。可解释性 对于应用机器学习非常重要,因为解决方案需要被技术和非技术人员在工业中理解和实施。如果没有可解释性,解决方案就会变成不可理解的黑匣子。

优点

线性回归简单、可解释,并且难以过拟合,因为它无法模拟过于复杂的关系。当输入和输出变量之间的基础关系是线性的时,它是一个极好的选择。

弱点

当输入和输出变量之间的关系是非线性的时,线性回归将欠拟合数据。

应用

由于人类体重与身高之间的真实基础关系是线性的,因此线性回归非常适合使用身高作为输入变量来预测体重,或者反过来,使用体重作为输入变量来预测身高。

逻辑回归

最简单的分类算法是 逻辑回归,它也是一种线性方法,但预测结果经过逻辑函数转换。这种转换的输出是类别概率——换句话说,实例属于各个类别的概率,每个实例的概率之和为一。然后将每个实例分配给其最有可能属于的类别。

优势

与线性回归类似,逻辑回归简单且可解释。当我们尝试预测的类别不重叠且线性可分时,逻辑回归是一个很好的选择。

弱点

当类别不是线性可分时,逻辑回归会失败。

应用场景

当类别大部分不重叠时,例如年幼儿童的身高与成年人的身高,逻辑回归效果很好。

基于邻居的方法

另一组非常简单的算法是基于邻居的方法。基于邻居的方法是惰性学习器,因为它们学习如何根据新点与现有标记点的接近程度来标记新点。与线性回归或逻辑回归不同,基于邻居的模型不会学习一个固定的模型来预测新点的标签;相反,这些模型仅基于新点到预先标记点的距离来预测新点的标签。惰性学习也称为基于实例的学习非参数方法

k 近邻算法

最常见的基于邻居的方法是 k 近邻算法 (KNN)。为了给每个新点贴上标签,KNN 查看 k 个最近的已标记点(其中 k 是整数),并让这些已标记的邻居投票决定如何给新点贴标签。默认情况下,KNN 使用欧氏距离来衡量最近的点。

k 的选择非常重要。如果 k 设置得非常低,KNN 变得非常灵活,可能会绘制非常微妙的边界并可能过度拟合数据。如果 k 设置得非常高,KNN 变得不够灵活,绘制出过于刚性的边界,可能会欠拟合数据。

优势

不同于线性方法,KNN 非常灵活,能够学习更复杂、非线性的关系。尽管如此,KNN 仍然简单且可解释。

弱点

当观测数和特征数量增加时,KNN 的表现较差。在这种高度密集且高维的空间中,KNN 变得计算效率低下,因为它需要计算新点到许多附近已标记点的距离,以预测标签。它无法依靠具有减少参数数量的高效模型进行必要的预测。此外,KNN 对 k 的选择非常敏感。当 k 设置过低时,KNN 可能过拟合;当 k 设置过高时,KNN 可能欠拟合。

应用场景

KNN 经常被用于推荐系统,比如用来预测电影品味(Netflix)、音乐喜好(Spotify)、朋友(Facebook)、照片(Instagram)、搜索(Google)和购物(Amazon)。例如,KNN 可以帮助预测用户会喜欢什么,基于类似用户喜欢的东西(称为协同过滤)或者用户过去喜欢的东西(称为基于内容的过滤)。

基于树的方法

而不是使用线性方法,我们可以让 AI 构建决策树,在这些实例中所有的实例都被分割分层成许多区域,这些区域由我们的标签引导。一旦完成这种分割,每个区域对应于一个特定的标签类别(用于分类问题)或预测值范围(用于回归问题)。这个过程类似于让 AI 自动构建规则,其明确目标是做出更好的决策或预测。

单一决策树

最简单的基于树的方法是单一决策树,在这种方法中,AI 一次通过训练数据,根据标签创建数据分割规则,并使用这棵树对从未见过的验证或测试集进行预测。然而,单一决策树通常在将其在训练期间学到的内容推广到从未见过的情况时表现不佳,因为它通常在其唯一的训练迭代期间过拟合训练数据。

装袋

要改进单一决策树,我们可以引入自助聚合(更常被称为装袋),其中我们从训练数据中取多个随机样本实例,为每个样本创建一个决策树,然后通过平均这些树的预测来预测每个实例的输出。通过随机化样本和对多个树的预测结果进行平均——这种方法也被称为集成方法——装袋将解决由单一决策树导致的过拟合问题的一些方面。

随机森林

我们可以通过对预测变量进行采样来进一步改善过拟合。通过随机森林,我们像在装袋中那样从训练数据中取多个随机样本实例,但是,在每个决策树的每次分割中,我们基于预测变量的随机样本而不是所有预测变量进行分割。每次分割考虑的预测变量数量通常是总预测变量数量的平方根。

通过这种方式对预测变量进行采样,随机森林算法创建的树与彼此更少相关(与装袋中的树相比),从而减少过拟合并改善泛化误差。

提升法

另一种称为提升的方法用于创建多棵树,类似于装袋法,但是顺序构建树,使用 AI 从前一棵树学到的知识来改进后续树的结果。每棵树保持相当浅,只有几个决策分裂点,并且学习是逐步进行的,树与树之间逐步增强。在所有基于树的方法中,梯度提升机是表现最佳的,并且常用于赢得机器学习竞赛。⁵

优点

基于树的方法是预测问题中表现最佳的监督学习算法之一。这些方法通过逐步学习许多简单规则来捕捉数据中的复杂关系。它们还能够处理缺失数据和分类特征。

弱点

基于树的方法很难解释,特别是如果需要许多规则来做出良好的预测。随着特征数量的增加,性能也成为一个问题。

应用

梯度提升和随机森林在预测问题上表现出色。

支持向量机

我们可以使用算法在空间中创建超平面来分隔数据,这些算法由我们拥有的标签引导。这种方法被称为支持向量机(SVMs)。 SVMs 允许在这种分隔中存在一些违规情况——并非超空间中的所有点都必须具有相同的标签——但某一标签的边界定义点与另一标签的边界定义点之间的距离应尽可能最大化。此外,边界不一定是线性的——我们可以使用非线性核来更灵活地分隔数据。

神经网络

我们可以使用神经网络来学习数据的表示,神经网络由输入层、多个隐藏层和输出层组成。⁶ 输入层使用特征,输出层试图匹配响应变量。隐藏层是一个嵌套的概念层次结构——每个层(或概念)都试图理解前一层如何与输出层相关联。

使用这种概念层次结构,神经网络能够通过将简单的概念组合起来来学习复杂的概念。神经网络是函数逼近中最强大的方法之一,但容易过拟合且难以解释,我们将在本书后面更详细地探讨这些缺点。

深入探讨无监督算法

现在我们将注意力转向没有标签的问题。无监督学习算法将尝试学习数据的潜在结构,而不是尝试进行预测。

降维

一类算法——称为降维算法——将原始高维输入数据投影到低维空间,滤除不那么相关的特征并保留尽可能多的有趣特征。降维允许无监督学习 AI 更有效地识别模式,并更高效地解决涉及图像、视频、语音和文本的大规模计算问题。

线性投影

维度的两个主要分支是线性投影和非线性降维。我们将首先从线性投影开始。

主成分分析(PCA)

学习数据的基本结构的一种方法是确定在完整特征集中哪些特征对解释数据实例之间变异性最重要。并非所有特征都是相等的——对于某些特征,数据集中的值变化不大,这些特征在解释数据集方面不那么有用。对于其他特征,其值可能会有显著变化——这些特征值得更详细探讨,因为它们将更有助于我们设计的模型分离数据。

PCA中,该算法在保留尽可能多的变化的同时找到数据的低维表示。我们得到的维度数量远远小于完整数据集的维度数(即总特征数)。通过转移到这个低维空间,我们会失去一些方差,但数据的基本结构更容易识别,这样我们可以更有效地执行诸如聚类之类的任务。

PCA 有几种变体,我们将在本书后面探讨。这些包括小批量变体,如增量 PCA,非线性变体,如核 PCA,以及稀疏变体,如稀疏 PCA

奇异值分解(SVD)

学习数据的基本结构的另一种方法是将原始特征矩阵的秩降低到一个较小的秩,使得可以用较小秩矩阵中某些向量的线性组合来重建原始矩阵。这就是SVD。为了生成较小秩矩阵,SVD 保留具有最多信息(即最高奇异值)的原始矩阵向量。较小秩矩阵捕捉了原始特征空间的最重要元素。

随机投影

类似的降维算法涉及将高维空间中的点投影到远低于其维度的空间中,以保持点之间的距离比例。我们可以使用随机高斯矩阵随机稀疏矩阵来实现这一点。

流形学习

PCA 和随机投影都依赖于将数据从高维空间线性投影到低维空间。与线性投影不同,执行数据的非线性变换可能更好——这被称为流形学习非线性降维

Isomap

Isomap是一种流形学习方法。该算法通过估计每个点及其邻居之间的测地线曲线距离而不是欧氏距离来学习数据流形的内在几何结构。Isomap 将此用于将原始高维空间嵌入到低维空间。

t-分布随机近邻嵌入(t-SNE)

另一种非线性降维方法——称为t-SNE——将高维数据嵌入到仅具有两个或三个维度的空间中,使得转换后的数据可以可视化。在这个二维或三维空间中,相似的实例被建模为更接近,而不相似的实例被建模为更远。

字典学习

一种被称为字典学习的方法涉及学习底层数据的稀疏表示。这些代表性元素是简单的二进制向量(零和一),数据集中的每个实例都可以重构为代表性元素的加权和。这种无监督学习生成的矩阵(称为字典)大多数由零填充,只有少数非零权重。

通过创建这样一个字典,该算法能够有效地识别原始特征空间中最显著的代表性元素——这些元素具有最多的非零权重。不太重要的代表性元素将具有较少的非零权重。与 PCA 一样,字典学习非常适合学习数据的基本结构,这对于分离数据和识别有趣的模式将会有所帮助。

独立分量分析

无标签数据的一个常见问题是,许多独立信号被嵌入到我们所获得的特征中。使用独立分量分析(ICA),我们可以将这些混合信号分离成它们的个体组成部分。分离完成后,我们可以通过将我们生成的个体组成部分的某种组合相加来重构任何原始特征。ICA 在信号处理任务中通常用于(例如,识别繁忙咖啡馆音频剪辑中的个别声音)。

潜在狄利克雷分配

无监督学习还可以通过学习为什么数据集的某些部分相互类似来解释数据集。这需要学习数据集中的未观察元素——一种被称为潜在狄利克雷分配(LDA)的方法。例如,考虑一个文本文档,其中有许多词。文档内的这些词并非纯粹随机;相反,它们呈现出一定的结构。

这种结构可以建模为称为主题的未观察元素。经过训练后,LDA 能够用一小组主题解释给定的文档,每个主题都有一小组经常使用的单词。这是 LDA 能够捕捉的隐藏结构,帮助我们更好地解释以前结构不清晰的文本语料库。

注意

降维将原始特征集合减少到仅包含最重要的特征集合。然后,我们可以在这些较小的特征集上运行其他无监督学习算法,以发现数据中的有趣模式(参见下一节关于聚类的内容),或者如果有标签,我们可以通过向这些较小的特征矩阵输入来加快监督学习算法的训练周期,而不是使用原始特征矩阵。

聚类

一旦我们将原始特征集减少到一个更小、更易处理的集合,我们可以通过将相似的数据实例分组来找到有趣的模式。这被称为聚类,可以使用各种无监督学习算法来实现,并且可用于市场细分等现实应用中。

k-means

要进行良好的聚类,我们需要识别出不同的群组,使得群组内的实例彼此相似,但与其他群组内的实例不同。其中一种算法是k-means 聚类。使用这种算法,我们指定所需的群组数量k,算法将每个实例分配到这k个群组中的一个。它通过最小化群内变异性(也称为惯性)来优化分组,使得所有k个群组内的群内变异性之和尽可能小。

为了加速这一聚类过程,k-means 随机将每个观测分配到k个群组中的一个,然后开始重新分配这些观测,以最小化每个观测与其群组中心点或质心之间的欧氏距离。因此,不同运行的k-means(每次都从随机起点开始)将导致略有不同的观测聚类分配。从这些不同的运行中,我们可以选择具有最佳分离性能的运行,即所有k个群组内的总群内变异性之和最低的运行。⁷

层次聚类

另一种聚类方法——不需要预先确定特定群组数量的方法被称为层次聚类。层次聚类的一种版本称为聚合聚类,使用基于树的聚类方法,并构建所谓的树状图。树状图可以以图形方式呈现为倒置的树,其中叶子位于底部,树干位于顶部。

在数据集中,最底部的叶子是个体实例。然后,按照它们彼此的相似程度,层次聚类将这些叶子连接在一起——随着我们在颠倒的树上向上移动。最相似的实例(或实例组)会更早地连接在一起,而不那么相似的实例则稍后连接。通过这个迭代过程,所有实例最终链接在一起,形成了树的单一主干。

这种垂直描绘非常有帮助。一旦层次聚类算法运行完成,我们可以查看树状图并确定我们想要切割树的位置——我们在树干上切割得越低,留下的个体分支就越多(即更多的簇)。如果我们想要更少的簇,我们可以在树状图的更高处切割,靠近这颠倒树的顶部的单一主干。这种垂直切割的位置类似于在k-means 聚类算法中选择k簇的数量。⁸

DBSCAN

另一个更强大的聚类算法(基于点的密度)称为DBSCAN(具有噪声的基于密度的空间聚类应用程序)。给定我们在空间中的所有实例,DBSCAN 会将那些紧密聚集在一起的实例分组在一起,其中紧密定义为必须存在一定距离内的最小数量的实例。我们同时指定所需的最小实例数和距离。

如果一个实例在指定的距离内接近多个簇,则将其与其最密集的簇分组。任何不在另一个簇指定距离内的实例都被标记为异常值。

不像k-means 那样,我们不需要预先指定簇的数量。我们还可以拥有任意形状的簇。DBSCAN 在数据中典型的由异常值引起的扭曲问题上要少得多。

特征提取

通过无监督学习,我们可以学习数据原始特征的新表示——一个称为特征提取的领域。特征提取可用于将原始特征的数量减少到更小的子集,从而有效地执行降维。但是特征提取也可以生成新的特征表示,以帮助在监督学习问题上提高性能。

Autoencoders

要生成新的特征表示,我们可以使用前馈、非循环神经网络进行表示学习,其中输出层中的节点数量与输入层中的节点数量相匹配。这种神经网络被称为自编码器,有效地重构原始特征,利用隐藏层之间的学习新的表示。⁹

自编码器的每个隐藏层学习原始特征的表示,后续层基于前面层学习的表示构建。逐层,自编码器从简单表示中学习越来越复杂的表示。

输出层是原始特征的最终新学习表示。这个学习表示然后可以用作监督学习模型的输入,目的是改善泛化误差。

使用前馈网络的监督训练进行特征提取

如果有标签,另一种特征提取方法是使用前馈非递归神经网络,其中输出层试图预测正确的标签。就像自编码器一样,每个隐藏层学习原始特征的表示。

然而,在生成新表示时,该网络明确地由标签引导。为了从这个网络中提取原始特征的最终新学习表示,我们提取倒数第二层——即输出层之前的隐藏层。然后,可以将这个倒数第二层用作任何监督学习模型的输入。

无监督深度学习

在深度学习领域,无监督学习执行许多重要功能,其中一些我们将在本书中探讨。这个领域被称为无监督深度学习

直到最近,深度神经网络的训练在计算上是棘手的。在这些神经网络中,隐藏层学习内部表示来帮助解决手头的问题。这些表示会随着神经网络在每次训练迭代中如何使用误差函数的梯度来更新各个节点的权重而不断改进。

这些更新计算成本很高,过程中可能会出现两种主要类型的问题。首先,误差函数的梯度可能变得非常小,由于反向传播依赖于这些小权重的乘积,网络的权重可能更新非常缓慢,甚至根本不更新,从而阻止网络的正确训练。¹⁰ 这被称为梯度消失问题

相反,另一个问题是误差函数的梯度可能变得非常大;通过反向传播,网络中的权重可能会大幅度地更新,使得网络的训练非常不稳定。这被称为梯度爆炸问题

无监督预训练

为了解决训练非常深、多层神经网络的困难,机器学习研究人员采用多阶段训练神经网络的方法,每个阶段涉及一个浅层神经网络。一个浅层网络的输出被用作下一个神经网络的输入。通常,这个流水线中的第一个浅层神经网络涉及无监督神经网络,但后续的网络是有监督的。

这个无监督部分被称为贪婪逐层无监督预训练。2006 年,Geoffrey Hinton 展示了成功应用无监督预训练来初始化更深神经网络管道的情况,从而开启了当前的深度学习革命。无监督预训练使得 AI 能够捕获原始输入数据的改进表示,随后监督部分利用这些表示来解决手头的具体任务。

这种方法被称为“贪婪”,因为神经网络的每个部分都是独立训练的,而不是联合训练。 “逐层”指的是网络的各层。在大多数现代神经网络中,通常不需要预训练。相反,所有层都使用反向传播联合训练。主要的计算机进步使得梯度消失问题和梯度爆炸问题变得更加可管理。

无监督预训练不仅使监督问题更容易解决,还促进了迁移学习。迁移学习涉及使用机器学习算法将从解决一个任务中获得的知识存储起来,以更快速且需要更少数据的方式解决另一个相关任务。

受限玻尔兹曼机

无监督预训练的一个应用例子是受限玻尔兹曼机(RBM),一个浅层的双层神经网络。第一层是输入层,第二层是隐藏层。每个节点与另一层的每个节点相连接,但节点与同一层的节点不连接——这就是约束的地方。

RBMs 可以执行无监督任务,如降维和特征提取,并作为监督学习解决方案的有用无监督预训练的一部分。RBMs 类似于自动编码器,但在某些重要方面有所不同。例如,自动编码器有一个输出层,而 RBM 则没有。我们将在本书的后续部分详细探讨这些及其他差异。

深度信念网络

RBMs 可以连接在一起形成多阶段神经网络管道,称为深度信念网络(DBN)。每个 RBM 的隐藏层被用作下一个 RBM 的输入。换句话说,每个 RBM 生成数据的表示,然后下一个 RBM 在此基础上构建。通过成功地链接这种表示学习,深度信念网络能够学习更复杂的表示,通常用作特征检测器。¹¹

生成对抗网络

无监督深度学习的一个重大进展是生成对抗网络(GANs)的出现,由 Ian Goodfellow 及其蒙特利尔大学的同事于 2014 年引入。GANs 有许多应用,例如,我们可以使用 GANs 创建接近真实的合成数据,如图像和语音,或执行异常检测。

在 GAN 中,我们有两个神经网络。一个网络——生成器——基于其创建的模型数据分布生成数据,该模型数据是通过接收的真实数据样本创建的。另一个网络——鉴别器——区分生成器创建的数据和真实数据分布的数据。

简单类比,生成器是伪造者,鉴别器是试图识别伪造品的警察。这两个网络处于零和博弈中。生成器试图欺骗鉴别器,让其认为合成数据来自真实数据分布,而鉴别器则试图指出合成数据是假的。

GAN(生成对抗网络)是无监督学习算法,因为生成器可以在没有标签的情况下学习真实数据分布的潜在结构。GAN 通过训练过程学习数据中的潜在结构,并使用少量可管理的参数高效捕捉这种结构。

这个过程类似于深度学习中的表征学习。生成器神经网络中的每个隐藏层通过从简单开始捕捉底层数据的表示,随后的层通过建立在较简单前层的基础上,捕捉更复杂的表示。

使用所有这些层,生成器学习数据的潜在结构,并利用所学,尝试创建几乎与真实数据分布相同的合成数据。如果生成器已经捕捉到真实数据分布的本质,合成数据将看起来是真实的。

使用无监督学习处理顺序数据问题

无监督学习也可以处理时间序列等顺序数据。一种方法涉及学习马尔可夫模型的隐藏状态。在简单马尔可夫模型中,状态完全可观察且随机变化(换句话说,随机)。未来状态仅依赖于当前状态,而不依赖于先前状态。

隐藏马尔可夫模型中,状态仅部分可观察,但与简单马尔可夫模型一样,这些部分可观察状态的输出是完全可观察的。由于我们的观察不足以完全确定状态,我们需要无监督学习帮助更充分地发现这些隐藏状态。

隐藏马尔可夫模型算法涉及学习给定我们所知的先前发生的部分可观察状态和完全可观察输出的可能下一个状态。这些算法在涉及语音、文本和时间序列的顺序数据问题中具有重要的商业应用。

使用无监督学习进行强化学习

强化学习是机器学习的第三大主要分支,其中一个代理人根据它收到的奖励反馈,决定其在环境中的最佳行为(actions)。这种反馈称为强化信号。代理人的目标是随时间最大化其累积奖励。

尽管强化学习自 1950 年代以来就存在,但直到近年来才成为主流新闻头条。2013 年,现为谷歌所有的 DeepMind 应用强化学习实现了超越人类水平的表现,玩转多种不同的 Atari 游戏。DeepMind 的系统仅使用原始感官数据作为输入,并且没有关于游戏规则的先验知识。

2016 年,DeepMind 再次吸引了机器学习社区的想象力——这一次,基于强化学习的 AI 代理 AlphaGo 击败了李世石,世界顶级围棋选手之一。这些成功奠定了强化学习作为主流 AI 主题的地位。

如今,机器学习研究人员正在应用强化学习来解决许多不同类型的问题,包括:

  • 股市交易中,代理人买卖股票(actions),并获得利润或损失(rewards)作为回报。

  • 视频游戏和棋盘游戏中,代理人做出游戏决策(actions),并赢得或输掉(rewards)。

  • 自动驾驶汽车中,代理人指导车辆(actions),并且要么保持在路线上,要么发生事故(rewards)。

  • 机器控制中,代理人在其环境中移动(actions),并且要么完成任务,要么失败(rewards)。

在最简单的强化学习问题中,我们有一个有限问题——环境的状态有限,任何给定环境状态下可能的动作有限,并且奖励的数量也是有限的。在给定当前环境状态下,代理人采取的行动决定了下一个状态,代理人的目标是最大化其长期奖励。这类问题称为有限的马尔可夫决策过程

然而,在现实世界中,事情并不那么简单——奖励是未知的和动态的,而不是已知的和静态的。为了帮助发现这个未知的奖励函数并尽可能地逼近它,我们可以应用无监督学习。利用这个近似的奖励函数,我们可以应用强化学习解决方案,以增加随时间累积的奖励。

半监督学习

尽管监督学习和无监督学习是机器学习的两个明显不同的主要分支,但每个分支的算法可以作为机器学习流水线的一部分混合在一起。¹² 通常,在我们想充分利用少数标签或者想从无标签数据中找到新的未知模式以及从标记数据中已知的模式时,我们会混合使用监督和无监督学习。这些类型的问题通过一种称为半监督学习的混合方式来解决。我们将在本书后续章节详细探讨这一领域。

无监督学习的成功应用

在过去的十年中,大多数成功的商业应用来自监督学习领域,但情况正在改变。无监督学习应用变得越来越普遍。有时,无监督学习只是改善监督应用的手段。其他时候,无监督学习本身就实现了商业应用。以下是迄今为止两个最大的无监督学习应用的更详细介绍:异常检测和群体分割。

异常检测

进行降维可以将原始的高维特征空间转化为一个转换后的低维空间。在这个低维空间中,我们找到了大多数点密集分布的地方。这部分被称为正常空间。远离这些点的点被称为离群点异常,值得更详细地调查。

异常检测系统通常用于诸如信用卡欺诈、电汇欺诈、网络欺诈和保险欺诈等欺诈检测。异常检测还用于识别罕见的恶意事件,如对互联网连接设备的黑客攻击,对飞机和火车等关键设备的维护故障,以及由恶意软件和其他有害代理引起的网络安全漏洞。

我们可以将这些系统用于垃圾邮件检测,例如我们在本章前面使用的电子邮件垃圾过滤器示例。其他应用包括寻找如恐怖主义资金、洗钱、人口和毒品贩运以及军火交易等活动的不良行为,识别金融交易中的高风险事件,以及发现癌症等疾病。

为了使异常分析更加可管理,我们可以使用聚类算法将相似的异常分组在一起,然后基于它们所代表的行为类型手动标记这些聚类。通过这样的系统,我们可以拥有一个能够识别异常、将它们聚类到适当组中,并且利用人类提供的聚类标签向业务分析师推荐适当行动的无监督学习人工智能。

通过异常检测系统,我们可以将一个无监督问题逐步转换为半监督问题,通过这种集群和标记的方法。随着时间的推移,我们可以在未标记数据上运行监督算法,并行进行无监督算法。对于成功的机器学习应用程序,无监督系统和监督系统应该同时使用,相辅相成。

监督系统以高精度找到已知模式,而无监督系统发现可能感兴趣的新模式。一旦这些模式被无监督 AI 揭示,人类会对这些模式进行标记,将更多数据从未标记转换为已标记。

群体分割

通过聚类,我们可以根据行为相似性在市场营销、客户保持、疾病诊断、在线购物、音乐收听、视频观看、在线约会、社交媒体活动和文档分类等领域中对群体进行分割。在这些领域产生的数据量非常庞大,且数据只有部分被标记。

对于我们已经了解并希望加强的模式,我们可以使用监督学习算法。但通常我们希望发现新的模式和感兴趣的群体——对于这一发现过程,无监督学习是一个自然的选择。再次强调,这一切都是关于协同作用。我们应该同时使用监督和无监督学习系统来构建更强大的机器学习解决方案。

结论

在本章中,我们探讨了以下内容:

  • 基于规则的系统和机器学习的区别

  • 监督学习和无监督学习的区别

  • 无监督学习如何帮助解决训练机器学习模型中的常见问题

  • 常见的监督、无监督、强化和半监督学习算法

  • 无监督学习的两个主要应用——异常检测和群体分割

在第二章中,我们将探讨如何构建机器学习应用程序。然后,我们将详细讨论降维和聚类,逐步构建异常检测系统和群体分割系统。

¹ 有像 Figure Eight 这样明确提供人在循环服务的初创企业。

² 欠拟合是在构建机器学习应用程序时可能出现的另一个问题,但这更容易解决。欠拟合是因为模型过于简单——算法无法构建足够复杂的函数逼近来为当前任务做出良好的决策。为了解决这个问题,我们可以允许算法增加规模(增加参数、执行更多训练迭代等)或者应用更复杂的机器学习算法。

³ 这个列表并非详尽无遗,但包含了最常用的机器学习算法。

⁴ 可能有其他潜在问题会使得线性回归成为一个不好的选择,包括异常值、误差项相关性以及误差项方差的非常数性。

⁵ 想要了解机器学习竞赛中梯度提升的更多信息,请查阅 Ben Gorman 的博客文章

⁶ 想要了解更多关于神经网络的信息,请参阅 Ian Goodfellow、Yoshua Bengio 和 Aaron Courville 的《深度学习》(MIT Press)。

k-均值聚类的快速变体包括小批量k-均值,我们稍后在书中进行介绍。

⁸ 分层聚类默认使用欧几里得距离,但也可以使用其他相似度度量,比如基于相关的距离,我们稍后在书中详细探讨。

⁹ 有几种类型的自编码器,每种都学习不同的表示。这些包括去噪自编码器、稀疏自编码器和变分自编码器,我们稍后在书中进行探讨。

¹⁰ 反向传播(也称为误差反向传播)是神经网络使用的基于梯度下降的算法,用于更新权重。在反向传播中,首先计算最后一层的权重,然后用于更新前面层的权重。这个过程一直持续到第一层的权重被更新。

¹¹ 特征检测器学习原始数据的良好表示,帮助分离不同的元素。例如,在图像中,特征检测器帮助分离鼻子、眼睛、嘴等元素。

¹² Pipeline 指的是一种机器学习解决方案的系统,这些解决方案依次应用以实现更大的目标。

第二章:端到端机器学习项目

在我们详细探讨无监督学习算法之前,我们将回顾如何设置和管理机器学习项目,涵盖从获取数据到构建和评估模型以及实现解决方案的所有内容。我们将在本章使用监督学习模型——大多数读者应该对此有所了解——然后在下一章跳入无监督学习模型。

环境设置

在继续之前,让我们先设置数据科学环境。这个环境对于监督学习和无监督学习都是相同的。

注意

这些说明针对的是 Windows 操作系统的优化,但也提供了适用于 Mac 和 Linux 的安装包。

版本控制:Git

如果你还没有安装 Git,你需要安装它。Git 是一个用于代码版本控制的系统,本书中的所有代码示例都可以在 GitHub 仓库 的 Jupyter notebooks 中找到。请参阅 Roger Dudler 的 Git 指南,了解如何克隆仓库、添加、提交和推送更改,并使用分支进行版本控制。

克隆《实战无监督学习》Git 仓库

打开命令行界面(例如 Windows 上的命令提示符,Mac 上的终端等)。导航至你将存储无监督学习项目的目录。使用以下提示从 GitHub 克隆与本书相关的仓库:

$ git clone https://github.com/aapatel09/handson-unsupervised-learning.git
$ git lfs pull

或者,你可以访问 仓库 的 GitHub 网站,手动下载仓库供你使用。你可以 watchstar 该仓库以便随时了解更新。

一旦仓库被拉取或手动下载,使用命令行界面导航至 handson-unsupervised-learning 仓库。

$ cd handson-unsupervised-learning

对于接下来的安装步骤,我们将继续使用命令行界面。

科学计算库:Python 的 Anaconda 发行版

要安装 Python 和机器学习所需的科学计算库,请下载 Python 的 Anaconda 发行版(推荐使用版本 3.6,因为本书编写时版本 3.7 较新,不是所有我们将使用的机器学习库都支持该版本)。

创建一个孤立的 Python 环境,以便你可以为每个项目单独导入不同的库:

$ conda create -n unsupervisedLearning python=3.6 anaconda

这将创建一个名为 unsupervisedLearning 的孤立的 Python 3.6 环境——其中包含 Anaconda 发行版提供的所有科学计算库。

现在,激活它以便使用:

$ activate unsupervisedLearning

神经网络:TensorFlow 和 Keras

一旦激活 unsupervisedLearning,你需要安装 TensorFlow 和 Keras 来构建神经网络。TensorFlow 是由 Google 开源的项目,不是 Anaconda 发行版的一部分:

$ pip install tensorflow

Keras 是一个开源的神经网络库,它为我们提供了一个更高级的 API,用于在 TensorFlow 的底层函数上进行操作。换句话说,我们将在 TensorFlow(后端)之上使用 Keras,以便使用更直观的 API 调用来开发我们的深度学习模型:

$ pip install keras

梯度增强,第一版:XGBoost

接下来,安装一种称为 XGBoost 的梯度增强的版本。为了简化操作(至少对 Windows 用户而言),您可以导航到 handson-unsupervised-learning 存储库中的 xgboost 文件夹,并在那里找到包。

要安装该包,请使用 pip install

cd xgboost
pip install xgboost-0.6+20171121-cp36-cp36m-win_amd64.whl

或者,根据您的系统下载正确版本的 XGBoost —— 32 位或 64 位版本。

在命令行界面中,导航到具有此新下载文件的文件夹。使用 pip install

$ pip install xgboost-0.6+20171121-cp36-cp36m-win_amd64.whl
注意

您的 XGBoost WHL 文件名可能会略有不同,因为新版本的软件已公开发布。

安装成功后,回到 handson-unsupervised-learning 文件夹。

梯度增强,第二版:LightGBM

安装另一个梯度增强版本,Microsoft 的 LightGBM:

$ pip install lightgbm

聚类算法

让我们安装一些在本书后面将要使用的聚类算法。其中一个聚类包 fastcluster 是一个 C++ 库,具有 Python/SciPy 的接口。¹

可以使用以下命令安装这个 fastcluster 包:

$ pip install fastcluster

另一个聚类算法是 hdbscan,也可以通过 pip 安装:

$ pip install hdbscan

另外,为了时间序列聚类,让我们安装 tslearn

$ pip install tslearn

交互式计算环境:Jupyter Notebook

Jupyter notebook 是 Anaconda 发行版的一部分,因此我们现在将其激活,以启动我们刚刚设置的环境。在输入以下命令之前,请确保您位于 handson-unsupervised-learning 存储库中(为了方便使用):

$ jupyter notebook

您应该看到浏览器打开并启动 http://localhost:8888/ 页面。必须启用 Cookie 才能正常访问。

现在我们准备构建我们的第一个机器学习项目。

数据概述

在本章中,我们将使用一个真实的数据集,该数据集包含 2013 年 9 月由欧洲持卡人进行的匿名信用卡交易。² 这些交易被标记为欺诈或真实,我们将使用机器学习构建欺诈检测解决方案,以预测从未见过的实例的正确标签。

此数据集高度不平衡。在 284,807 笔交易中,只有 492 笔是欺诈交易(0.172%)。这种低欺诈比例对于信用卡交易来说相当典型。

共有 28 个特征,全部为数值特征,没有分类变量。³ 这些特征不是原始特征,而是通过主成分分析得出的,我们将在 第 3 章 中探索这种降维方法,其将 28 个原始特征精简为主成分。

除了 28 个主成分外,我们还有三个其他变量——交易时间、交易金额以及交易的真实类别(如果是欺诈则为一,否则为零)。

数据准备

在可以使用机器学习训练数据并开发欺诈检测解决方案之前,我们需要为算法准备数据。

数据采集

任何机器学习项目的第一步是数据采集。

下载数据

下载数据集,并在 handson-unsupervised-learning 目录中将 CSV 文件放置在 /datasets/credit_card_data/ 文件夹中。如果您之前已经下载了 GitHub 仓库,则已在该仓库的此文件夹中有此文件。

导入必要的库

导入我们构建欺诈检测解决方案所需的 Python 库:

'''Main'''
import numpy as np
import pandas as pd
import os

'''Data Viz'''
import matplotlib.pyplot as plt
import seaborn as sns
color = sns.color_palette()
import matplotlib as mpl

%matplotlib inline

'''Data Prep'''
from sklearn import preprocessing as pp
from scipy.stats import pearsonr
from sklearn.model_selection import train_test_split
from sklearn.model_selection import StratifiedKFold
from sklearn.metrics import log_loss
from sklearn.metrics import precision_recall_curve, average_precision_score
from sklearn.metrics import roc_curve, auc, roc_auc_score
from sklearn.metrics import confusion_matrix, classification_report

'''Algos'''
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
import xgboost as xgb
import lightgbm as lgb

读取数据

current_path = os.getcwd()
file = '\\datasets\\credit_card_data\\credit_card.csv'
data = pd.read_csv(current_path + file)

预览数据

表格 2-1 显示数据集的前五行。您可以看到,数据已经正确加载:

data.head()

表格 2-1. 数据预览

时间 V1 V2 V3 V4 V5
0 0.0 –1.359807 –0.072781 2.536347 1.378155 –0.338321
1 0.0 1.191857 0.266151 0.166480 0.448154 0.060018
2 1.0 –1.358354 –1.340163 1.773209 0.379780 –0.503198
3 1.0 –0.966272 –0.185226 1.792993 –0.863291 –0.010309
4 2.0 –1.158233 0.877737 1.548718 0.403034 –0.407193
5 行 × 31 列

数据探索

接下来,让我们深入了解数据。我们将为数据生成摘要统计信息,识别任何缺失值或分类特征,并按特征计算不同值的数量。

生成摘要统计信息

表格 2-2 逐列描述数据。接下来的代码块列出了所有列名,以便参考。

data.describe()

表格 2-2. 简单的摘要统计

时间 V1 V2 V3 V4
总数 284807.000000 2.848070e+05 2.848070e+05 2.848070e+05 2.848070e+05
均值 94813.859575 3.919560e–15 5.688174e–16 –8.769071e–15 2.782312e–15
标准差 47488.145955 1.958696e+00 1.651309e+00 1.516255e+00 1.415869e+00
最小值 0.000000 –5.640751e+01 –7.271573e+01 –4.832559e+01 –5.683171e+00
25% 54201.500000 –9.203734e–01 –5.985499e–01 –8.903648e–01 –8.486401e–01
50% 84692.000000 1.810880e–02 6.548556e–02 1.798463e–01 –1.984653e–02
75% 139320.500000 1.315642e+00 8.037239e–01 1.027196e+00 7.433413e–01
最大值 172792.000000 2.454930e+00 2.205773e+01 9.382558e+00 1.687534e+01
31 列 x 8 行
data.columns
Index(['Time', 'V1,' 'V2', 'V3', 'V4', 'V5', 'V6', 'V7', 'V8', 'V9', 'V10',
'V11', 'V12', 'V13', 'V14', 'V15', 'V16', 'V17', 'V18', 'V19', 'V20', 'V21',
'V22', 'V23', 'V24', 'V25', 'V26', 'V27', 'V28', 'Amount', 'Class'],
dtype='object')
data['Class'].sum()

正标签的总数,或欺诈交易,为 492。如预期,共有 284,807 个实例和 31 列——28 个数值特征(V1 至 V28),时间,金额和类别。

时间戳范围从 0 到 172,792,金额范围从 0 到 25,691.16,有 492 笔欺诈交易。这些欺诈交易也称为正案例或正标签(标记为一);正常交易称为负案例或负标签(标记为零)。

这 28 个数值特征目前尚未标准化,但我们很快将对数据进行标准化。标准化会将数据重新缩放,使其均值为零,标准差为一。

小贴士

一些机器学习解决方案对数据的规模非常敏感,因此通过标准化使所有数据在相同的相对比例上具有良好的机器学习实践。

另一种常见的数据缩放方法是归一化,它将数据重新缩放到零到一的范围内。与标准化数据不同,所有归一化数据都在正数范围内。

通过特征识别非数字值

一些机器学习算法无法处理非数字值或缺失值。因此,最佳实践是识别非数字值(也称为非数字NaN)。

在缺失值的情况下,我们可以填充值——例如,用特征的平均值、中位数或众数替换缺失点——或用某个用户定义的值替换。对于分类值,我们可以对数据进行编码,以便所有分类值都用稀疏矩阵表示。然后,这个稀疏矩阵与数值特征结合。机器学习算法基于这个组合特征集进行训练。

以下代码显示,观察中没有 NaN 值,因此我们不需要填充或编码任何值:

nanCounter = np.isnan(data).sum()
Time 		0
V1 		0
V2 		0
V3 		0
V4 		0
V5 		0
V6 		0
V7 		0
V8 		0
V9 		0
V10 		0
V11 		0
V12 		0
V13 		0
V14 		0
V15 		0
V16 		0
V17 		0
V18 		0
V19 		0
V20 		0
V21 		0
V22 		0
V23 		0
V24 		0
V25 		0
V26 		0
V27 		0
V28 		0
Amount 	0
Class 		0
dtype: 	int64

通过特征识别不同的值

为了更好地理解信用卡交易数据集,让我们按特征计算不同值的数量。

以下代码显示,我们有 124,592 个不同的时间戳。但是我们从之前知道总共有 284,807 个观测值。这意味着某些时间戳上有多次交易。

不出所料,只有两类——一类是欺诈,零类是非欺诈:

distinctCounter = data.apply(lambda x: len(x.unique()))
Time 		124592
V1 		275663
V2 		275663
V3 		275663
V4 		275663
V5 		275663
V6 		275663
V7 		275663
V8 		275663
V9 		275663
V10 		275663
V11 		275663
V12 		275663
V13 		275663
V14 		275663
V15 		275663
V16 		275663
V17 		275663
V18 		275663
V19 		275663
V20 		275663
V21 		275663
V22 		275663
V23 		275663
V24 		275663
V25 		275663
V26 		275663
V27 		275663
V28 		275663
Amount 	32767
Class 		2
dtype: 	int64

生成特征矩阵和标签数组

让我们创建并标准化特征矩阵 X,并分离标签数组 y(欺诈为一,非欺诈为零)。稍后在训练期间,我们将把它们输入到机器学习算法中。

创建特征矩阵 X 和标签数组 Y

dataX = data.copy().drop([‘Class’],axis=1)
dataY = data[‘Class’].copy()

标准化特征矩阵 X

让我们重新缩放特征矩阵,使得每个特征(时间除外)的均值为零,标准差为一:

featuresToScale = dataX.drop(['Time'],axis=1).columns
sX = pp.StandardScaler(copy=True)
dataX.loc[:,featuresToScale] = sX.fit_transform(dataX[featuresToScale])

正如 Table 2-3 所示,标准化后的特征现在均值为零,标准差为一。

Table 2-3. Summary of scaled features

Time V1 V2 V3 V4
count 284807.000000 2.848070e+05 2.848070e+05 2.848070e+05 2.848070e+05
mean 94813.859575 –8.157366e–16 3.154853e–17 –4.409878e–15 –6.734811e–16
std 47488.145955 1.000002e+00 1.000002e+00 1.000002e+00 1.000002e+00
min 0.000000 –2.879855e+01 –4.403529e+01 –3.187173e+01 –4.013919e+00
25% 54201.500000 –4.698918e–01 –3.624707e–01 –5.872142e–01 –5.993788e–01
50% 84692.000000 9.245351e–03 3.965683e–02 1.186124e–02 –1.401724e–01
75% 139320.500000 6.716939e–01 4.867202e–01 6.774569e–01 5.250082e–01
max 172792.000000 1.253351e+00 1.335775e+01 6.187993e+00 1.191874e+01
8 rows x 30 columns

Feature Engineering and Feature Selection

在大多数机器学习项目中,我们应该将特征工程特征选择视为解决方案的一部分。特征工程涉及创建新特征,例如从原始特征计算比率、计数或总和,以帮助机器学习算法从数据集中提取更强的信号。

特征选择涉及选择用于训练的特征子集,有效地从考虑中移除一些不太相关的特征。这有助于防止机器学习算法过度拟合数据集中的噪声。

对于这个信用卡欺诈数据集,我们没有原始特征。我们只有从 PCA 中得出的主成分,PCA 是一种我们将在第三章中探讨的降维形式。由于我们不知道任何特征代表什么,我们无法进行任何智能特征工程。

由于观测值(284,807)远远超过特征数(30),因此特征选择也是不必要的,这显著降低了过拟合的可能性。而且,正如 Figure 2-1 所示,特征之间的相关性只是轻微的。换句话说,我们没有冗余特征。如果有的话,我们可以通过降维来消除或减少冗余。当然,这并不奇怪。PCA 已经在这个信用卡数据集上执行过了,为我们消除了冗余。

检查特征之间的相关性

correlationMatrix = pd.DataFrame(data=[],index=dataX.columns,
columns=dataX.columns)
for i in dataX.columns:
    for j in dataX.columns:
        correlationMatrix.loc[i,j] = np.round(pearsonr(dataX.loc[:,i],
         dataX.loc[:,j])[0],2)

Correlation matrix

Figure 2-1. Correlation matrix

Data Visualization

最后一步,让我们来可视化数据,以了解数据集的不平衡程度(Figure 2-2)。由于欺诈案例很少,这是一个难题;幸运的是,我们有整个数据集的标签:

count_classes = pd.value_counts(data['Class'],sort=True).sort_index()
ax = sns.barplot(x=count_classes.index, y=tuple(count_classes/len(data)))
ax.set_title('Frequency Percentage by Class')
ax.set_xlabel('Class')
ax.set_ylabel('Frequency Percentage')

Frequency percentage of labels

Figure 2-2. Frequency percentage of labels

Model Preparation

现在数据准备好了,让我们为模型做准备。我们需要将数据分割为训练集和测试集,选择成本函数,并为k折交叉验证做准备。

将数据集分割为训练集和测试集

正如您可能从第一章中回忆起的,机器学习算法从数据中学习(即在数据上进行训练),以在以前未见过的案例上表现良好(即准确预测)。在这些以前未见过的案例上的表现被称为泛化误差——这是确定机器学习模型好坏的最重要指标。

我们需要设置我们的机器学习项目,以便从中学习的机器学习算法具有训练集。我们还需要一个测试集(以前未见过的案例),机器学习算法可以对其进行预测。这个测试集上的性能将是成功的最终标准。

让我们继续将我们的信用卡交易数据集分割为训练集和测试集。

X_train, X_test, y_train, y_test = train_test_split(dataX,
                                    dataY, test_size=0.33,
                                    random_state=2018, stratify=dataY)

现在我们有一个包含 190,280 个实例的训练集(原始数据集的 67%)和一个包含 93,987 个实例的测试集(剩余的 33%)。为了保持训练集和测试集中欺诈比例(约 0.17%)的一致性,我们设置了分层参数。我们还将随机状态设置为 2018,以便更容易地重现结果。⁴

我们将使用测试集来最终评估我们的泛化误差(也称为样本外误差)。

选择成本函数

在我们对训练集进行训练之前,我们需要一个成本函数(也称为错误率或值函数),将其传递给机器学习算法。机器学习算法将尝试通过从训练示例中学习来最小化这个成本函数。

由于这是一个监督分类问题——有两个类别——让我们使用二元分类对数损失(如方程式 2-1 所示),它将计算真实标签与基于模型的预测之间的交叉熵。

方程式 2-1. 对数损失函数

log loss= 1N Σ i=1 N Σ j=1 M y i,j log ( p i,j )

其中N是观察数;M是类别标签数(在本例中为两个);log 是自然对数;[yi,j] 如果观察i属于类别j则为 1,否则为 0;[pi,j] 是观察i属于类别j的预测概率。

机器学习模型将为每笔信用卡交易生成欺诈概率。欺诈概率越接近真实标签(即欺诈为 1 或非欺诈为 0),对数损失函数的值越低。这是机器学习算法将尝试最小化的目标。

创建 k 折交叉验证集

为了帮助机器学习算法估计其在以前未见过的示例(测试集)上的性能,最佳做法是进一步将训练集分割为训练集和验证集。

例如,如果我们将训练集分为五分之一,我们可以在原始训练集的四分之一上进行训练,并通过对原始训练集的第五个切片进行预测来评估新的训练模型,称为验证集。

可以像这样训练和评估五次——每次留出一个不同的五分之一作为验证集。这被称为k折交叉验证,其中k在本例中为五。通过这种方法,我们将不是一个估计值,而是五个泛化误差的估计值。

我们将为五次运行中的每一次存储训练得分和交叉验证得分,并且我们将每次存储交叉验证预测。在所有五次运行完成后,我们将对整个数据集进行交叉验证预测。这将是测试集性能的最佳整体估计。

下面是如何为k折验证设置,其中k为五:

k_fold = StratifiedKFold(n_splits=5, shuffle=True, random_state=2018)

机器学习模型(第一部分)

现在我们准备构建机器学习模型。对于我们考虑的每个机器算法,我们将设置超参数,训练模型,并评估结果。

模型 #1:逻辑回归

让我们从最基本的分类算法开始,逻辑回归。

设置超参数

penalty = 'l2'
C = 1.0
class_weight = 'balanced'
random_state = 2018
solver = 'liblinear'

logReg = LogisticRegression(penalty=penalty, C=C,
            class_weight=class_weight, random_state=random_state,
                            solver=solver, n_jobs=n_jobs)

我们将把惩罚设置为默认值 L2 而不是 L1。与 L1 相比,L2 对异常值不太敏感,并且将为几乎所有特征分配非零权重,从而产生一个稳定的解决方案。L1 将为最重要的特征分配高权重,并为其余特征分配接近零的权重,实际上在算法训练时执行特征选择。然而,由于权重在特征之间变化很大,所以 L1 解决方案对数据点的变化不如 L2 解决方案稳定。⁵

C 是正则化强度。如您可能还记得的来自第一章,正则化通过惩罚复杂性来帮助解决过拟合问题。换句话说,正则化越强,机器学习算法对复杂性的惩罚就越大。正则化促使机器学习算法更喜欢简单的模型而不是更复杂的模型,其他条件相等。

这个正则化常数 C 必须是一个正浮点数。数值越小,正则化越强。我们将保持默认值 1.0。

我们的信用卡交易数据集非常不平衡——在所有的 284,807 个案例中,只有 492 个是欺诈性的。随着机器学习算法的训练,我们希望算法更多地关注学习来自正标记交易的情况,换句话说,就是欺诈交易,因为在数据集中这样的交易很少。

对于这个逻辑回归模型,我们将设置class_weight为平衡。这向逻辑回归算法表示我们有一个类别不平衡的问题;算法在训练时将需要更重视正标签。在这种情况下,权重将与类别频率成反比;算法将给罕见的正标签(即欺诈)分配更高的权重,给更常见的负标签(即非欺诈)分配较低的权重。

随机状态固定为 2018,以帮助其他人——例如你,读者——复现结果。我们将保持默认的 solver liblinear。

训练模型

现在超参数已经设定好,我们将在每个五折交叉验证分割上训练逻辑回归模型,用训练集的四分之四来训练,并在留置的第五切片上评估性能。

当我们像这样训练和评估五次后,我们将计算成本函数——我们信用卡交易问题的对数损失——对训练集(即原始训练集的五分之四切片)和验证集(即原始训练集的五分之一切片)。我们还将存储每个五折交叉验证集的预测;到第五次运行结束时,我们将得到整个训练集的预测:

trainingScores = []
cvScores = []
predictionsBasedOnKFolds = pd.DataFrame(data=[],
                                        index=y_train.index,columns=[0,1])

model = logReg

for train_index, cv_index in k_fold.split(np.zeros(len(X_train))
                                          ,y_train.ravel()):
    X_train_fold, X_cv_fold = X_train.iloc[train_index,:], \
        X_train.iloc[cv_index,:]
    y_train_fold, y_cv_fold = y_train.iloc[train_index], \
        y_train.iloc[cv_index]

    model.fit(X_train_fold, y_train_fold)
    loglossTraining = log_loss(y_train_fold,
                               model.predict_proba(X_train_fold)[:,1])
    trainingScores.append(loglossTraining)

    predictionsBasedOnKFolds.loc[X_cv_fold.index,:] = \
        model.predict_proba(X_cv_fold)
    loglossCV = log_loss(y_cv_fold,
                         predictionsBasedOnKFolds.loc[X_cv_fold.index,1])
    cvScores.append(loglossCV)

    print('Training Log Loss: ', loglossTraining)
    print('CV Log Loss: ', loglossCV)

loglossLogisticRegression = log_loss(y_train,
                                     predictionsBasedOnKFolds.loc[:,1])
print('Logistic Regression Log Loss: ', loglossLogisticRegression)

评估结果

下面的代码显示了五次运行中每次的训练对数损失和交叉验证对数损失。一般来说(但不总是),训练对数损失会低于交叉验证对数损失。因为机器学习算法直接从训练数据中学习,所以它在训练集上的表现(即对数损失)应该比在交叉验证集上好。请记住,交叉验证集包含了在训练过程中明确保留的交易。

Training Log Loss: 		0.10080139188958696
CV Log Loss:		0.10490645274118293
Training Log Loss: 		0.12098957040484648
CV Log Loss:		0.11634801169793386
Training Log Loss: 		0.1074616029843435
CV Log Loss:		0.10845630232487576
Training Log Loss: 		0.10228137039781758
CV Log Loss:		0.10321736161148198
Training Log Loss: 		0.11476012373315266
CV Log Loss:		0.1160124452312548
注意

对于我们的信用卡交易数据集,重要的是要记住我们正在构建一个欺诈检测解决方案。当我们提到机器学习模型的性能时,我们指的是模型在数据集中预测欺诈的能力有多好。

机器学习模型为每笔交易输出一个预测概率,其中 1 表示欺诈,0 表示非欺诈。预测概率越接近 1,交易越可能是欺诈;越接近 0,交易越可能是正常的。通过将模型的预测概率与真实标签进行比较,我们可以评估模型的好坏。

对于五次运行中的每一次,它们的训练和交叉验证对数损失是相似的。逻辑回归模型没有表现出严重的过拟合;如果有的话,我们将会看到低的训练对数损失和相对较高的交叉验证对数损失。

由于我们存储了每个五折交叉验证集的预测结果,我们可以将这些预测合并成一个单一集合。这个单一集合与原始训练集相同,现在我们可以计算整个训练集的总体对数损失。这是对测试集上逻辑回归模型对数损失的最佳估计:

Logistic Regression Log Loss: 0.10978811472134588

评估指标

虽然对数损失是评估机器学习模型性能的好方法,但我们可能希望有更直观的方法来理解结果。例如,在训练集中的欺诈交易中,我们捕获了多少个?这就是召回率。或者,逻辑回归模型标记为欺诈交易的交易中,有多少是真正的欺诈交易?这就是模型的精确率

让我们查看这些及其他类似的评估指标,以帮助我们更直观地理解结果。

注意

这些评估指标非常重要,因为它们使数据科学家能够直观地向不熟悉对数损失、交叉熵和其他成本函数的业务人员解释结果。将复杂结果尽可能简单地传达给非数据科学家是应用数据科学家必须掌握的基本技能之一。

混淆矩阵

在典型的分类问题(没有类别不平衡情况)中,我们可以使用混淆矩阵来评估结果,它是一个总结真正例、真负例、假正例和假负例数量的表格(图 2-3)。

混淆矩阵

图 2-3. 混淆矩阵

鉴于我们的信用卡交易数据集类别高度不平衡,使用混淆矩阵将是有意义的。例如,如果我们预测每笔交易都不是欺诈交易,我们将得到 284,315 个真负例,492 个假负例,零个真正例和零个假正例。我们在识别真正欺诈交易方面的准确率为 0%。在这种类别不平衡问题下,混淆矩阵未能有效捕捉到这种次优结果。

对于涉及更平衡类别的问题(即真正例数量大致与真负例数量相似),混淆矩阵可能是一个好的、直接的评估指标。考虑到我们的不平衡数据集,我们需要找到一个更合适的评估指标。

精确率-召回率曲线

对于我们的不平衡信用卡交易数据集,评估结果的更好方法是使用精确率和召回率。精确率是真正例的数量除以总的正例预测数量。换句话说,模型捕获了多少个欺诈交易?

精确率 = 真正例 + 假正例

高精度意味着——在所有我们的正面预测中——许多是真正例(换句话说,它具有较低的假阳性率)。

召回率是数据集中实际正例的数量中捕捉到的欺诈交易数量。换句话说,模型捕捉了多少欺诈交易?⁷

召回率 = 真正例 (真正例 + 假正例)

高召回率意味着模型捕捉到了大部分真正例(换句话说,它具有较低的假阴性率)。

高召回率但低精度的解决方案返回许多结果——捕捉到许多正例,但也有许多误报。高精度但低召回率的解决方案则恰恰相反;返回很少的结果——捕捉到数据集中所有正例的一部分,但其大多数预测是正确的。

如果我们的解决方案精度高但召回率低,那么找到的欺诈交易数量很少,但大多数确实是欺诈交易。

然而,如果解决方案精度低但召回率高,则会标记许多交易为欺诈,从而捕获大部分欺诈行为,但被标记的交易中大多数并非欺诈。

显然,两种解决方案都存在重大问题。在高精度-低召回率的情况下,信用卡公司会因为欺诈而损失很多钱,但不会因不必要地拒绝交易而激怒客户。在低精度-高召回率的情况下,信用卡公司会捕捉到很多欺诈行为,但肯定会因不必要地拒绝大量正常非欺诈交易而惹怒客户。

最佳解决方案需要具有高精度和高召回率,仅拒绝那些真正欺诈的交易(即高精度),并捕捉数据集中大部分的欺诈案例(高召回率)。

精度和召回率通常存在折衷,通常由算法设置的阈值决定,以将正例与负例分开;在我们的例子中,正例是欺诈,负例是非欺诈。如果阈值设置得太高,预测为正例的案例很少,导致高精度但低召回率。随着阈值的降低,预测为正例的案例增加,通常降低精度并增加召回率。

对于我们的信用卡交易数据集来说,可以把阈值看作是机器学习模型在拒绝交易方面的敏感性。如果阈值过高/严格,模型会拒绝很少的交易,但被拒绝的交易很可能是欺诈的。

阈值降低(即变得不那么严格),模型会拒绝更多交易,捕获更多的欺诈案例,但也不必要地拒绝更多正常案例。

精确率-召回率曲线的图形展示了精确率和召回率之间的权衡。为了评估精确率-召回率曲线,我们可以计算平均精度,即在每个阈值下达到的精确率的加权平均值。平均精度越高,解决方案越好。

注意

阈值的选择非常重要,并且通常需要业务决策者的输入。数据科学家可以向这些业务决策者展示精确率-召回率曲线,以确定阈值应该设定在何处。

对于我们的信用卡交易数据集,关键问题是如何平衡客户体验(即避免拒绝正常交易)与欺诈检测(即捕捉到欺诈交易)?没有业务输入,我们无法回答这个问题,但我们可以找到具有最佳精确率-召回率曲线的模型。然后,我们可以将该模型呈现给业务决策者,以设定适当的阈值。

接收者操作特征曲线

另一个很好的评估指标是接收者操作特征曲线下的面积(auROC)。接收者操作特征(ROC)曲线将真阳性率绘制在 Y 轴上,将假阳性率绘制在 X 轴上。真阳性率也可以称为灵敏度,假阳性率也可以称为 1-特异度。曲线越接近绘图的左上角,解决方案越好——绝对最优点的值为(0.0, 1.0),表示假阳性率为 0%,真阳性率为 100%。

要评估解决方案,我们可以计算这条曲线下的面积。auROC 越大,解决方案越好。

评估逻辑回归模型

现在我们了解了一些使用的评估指标,让我们使用它们更好地理解逻辑回归模型的结果。

首先,让我们绘制精确率-召回率曲线并计算平均精度:

preds = pd.concat([y_train,predictionsBasedOnKFolds.loc[:,1]], axis=1)
preds.columns = ['trueLabel','prediction']
predictionsBasedOnKFoldsLogisticRegression = preds.copy()

precision, recall, thresholds = precision_recall_curve(preds['trueLabel'],
                                                       preds['prediction'])

average_precision = average_precision_score(preds['trueLabel'],
                                            preds['prediction'])

plt.step(recall, precision, color='k', alpha=0.7, where='post')
plt.fill_between(recall, precision, step='post', alpha=0.3, color='k')

plt.xlabel('Recall')
plt.ylabel('Precision')
plt.ylim([0.0, 1.05])
plt.xlim([0.0, 1.0])

plt.title('Precision-Recall curve: Average Precision = {0:0.2f}'.format(
          average_precision))

图 2-4 展示了精确率-召回率曲线的图表。综合我们之前讨论的内容,你可以看到我们可以实现大约 80%的召回率(即捕获 80%的欺诈交易),精确率约为 70%(即模型标记为欺诈的交易中,70%确实是欺诈交易,而其余 30%则错误地被标记为欺诈)。

逻辑回归的精确率-召回率曲线

图 2-4. 逻辑回归的精确率-召回率曲线

我们可以通过计算平均精度将这条精确率-召回率曲线简化为一个数字,对于这个逻辑回归模型来说,这个平均精度为 0.73。目前我们还不能确定这个平均精度是好是坏,因为我们没有其他模型可以与我们的逻辑回归模型进行比较。

现在,让我们测量 auROC:

fpr, tpr, thresholds = roc_curve(preds['trueLabel'],preds['prediction'])

areaUnderROC = auc(fpr, tpr)

plt.figure()
plt.plot(fpr, tpr, color='r', lw=2, label='ROC curve')
plt.plot([0, 1], [0, 1], color='k', lw=2, linestyle='--')
plt.xlim([0.0, 1.0])
plt.ylim([0.0, 1.05])
plt.xlabel('False Positive Rate')
plt.ylabel('True Positive Rate')
plt.title('Receiver operating characteristic:
          Area under the curve = {0:0.2f}'.format(areaUnderROC))
plt.legend(loc="lower right")
plt.show()

如图 Figure 2-5 所示,auROC 曲线为 0.97. 这个指标是评估逻辑回归模型优劣的另一种方式,它可以帮助您确定在保持尽可能低的误报率的情况下能够捕获多少欺诈。和平均精度一样,我们不知道这个 0.97 的 auROC 曲线是好还是坏,但一旦与其他模型进行比较,我们就会知道。

逻辑回归的 ROC 曲线下面积

图 2-5. 逻辑回归的 auROC 曲线

机器学习模型(第二部分)

为了比较逻辑回归模型的优劣,让我们使用其他监督学习算法构建几个更多的模型。

模型 #2:随机森林

让我们从随机森林开始。

与逻辑回归一样,我们将设置超参数,训练模型,并使用精确-召回曲线和 auROC 评估结果。

设置超参数

n_estimators = 10
max_features = 'auto'
max_depth = None
min_samples_split = 2
min_samples_leaf = 1
min_weight_fraction_leaf = 0.0
max_leaf_nodes = None
bootstrap = True
oob_score = False
n_jobs = -1
random_state = 2018
class_weight = 'balanced'

RFC = RandomForestClassifier(n_estimators=n_estimators,
        max_features=max_features, max_depth=max_depth,
        min_samples_split=min_samples_split, min_samples_leaf=min_samples_leaf,
        min_weight_fraction_leaf=min_weight_fraction_leaf,
        max_leaf_nodes=max_leaf_nodes, bootstrap=bootstrap,
        oob_score=oob_score, n_jobs=n_jobs, random_state=random_state,
        class_weight=class_weight)

让我们从默认的超参数开始。估计器的数量设置为 10;换句话说,我们将建立 10 棵树,并在这 10 棵树上的结果上取平均值。对于每棵树,模型将考虑总特征数的平方根(在本例中,总共 30 个特征的平方根,即 5 个特征,向下取整)。

max_depth 设为 none 后,决策树会尽可能深地生长,在给定特征子集的情况下进行尽可能多的分裂。与逻辑回归相似,我们将随机状态设置为 2018 以保证结果的可复现性,并考虑到数据集不平衡将类别权重设置为平衡。

训练模型

我们将运行 k-折交叉验证五次,在四分之四的训练数据上进行训练,并在第五个切片上进行预测。我们将逐步存储预测结果:

trainingScores = []
cvScores = []
predictionsBasedOnKFolds = pd.DataFrame(data=[],
                                        index=y_train.index,columns=[0,1])

model = RFC

for train_index, cv_index in k_fold.split(np.zeros(len(X_train)),
                                          y_train.ravel()):
    X_train_fold, X_cv_fold = X_train.iloc[train_index,:], \
        X_train.iloc[cv_index,:]
    y_train_fold, y_cv_fold = y_train.iloc[train_index], \
        y_train.iloc[cv_index]

    model.fit(X_train_fold, y_train_fold)
    loglossTraining = log_loss(y_train_fold, \
                                model.predict_proba(X_train_fold)[:,1])
    trainingScores.append(loglossTraining)

    predictionsBasedOnKFolds.loc[X_cv_fold.index,:] = \
        model.predict_proba(X_cv_fold)
    loglossCV = log_loss(y_cv_fold, \
        predictionsBasedOnKFolds.loc[X_cv_fold.index,1])
    cvScores.append(loglossCV)

    print('Training Log Loss: ', loglossTraining)
    print('CV Log Loss: ', loglossCV)

loglossRandomForestsClassifier = log_loss(y_train,
                                          predictionsBasedOnKFolds.loc[:,1])
print('Random Forests Log Loss: ', loglossRandomForestsClassifier)

评估结果

训练和交叉验证的对数损失结果如下:

Training Log Loss: 		0.0003951763883952557
CV Log Loss:		0.014479198936303003
Training Log Loss: 		0.0004501221178398935
CV Log Loss:		0.005712702421375242
Training Log Loss: 		0.00043128813023860164
CV Log Loss:		0.00908372752510077
Training Log Loss: 		0.0004341676022058672
CV Log Loss:		0.013491161736979267
Training Log Loss: 		0.0004275530435950083
CV Log Loss:		0.009963232439211515

注意,训练集的训练对数损失要远远低于交叉验证的对数损失,这表明随机森林分类器在训练过程中在某种程度上对数据进行了过度拟合,尽管使用了大多数默认的超参数。

下面的代码显示了整个训练集上的对数损失(使用交叉验证预测):

Random Forests Log Loss: 0.010546004611793962

尽管它在某种程度上过度拟合了训练数据,但随机森林的验证对数损失约为逻辑回归的十分之一——相对于先前的机器学习解决方案,这是显著的改进。随机森林模型在正确标记信用卡交易中的欺诈方面表现更好。

Figure 2-6 显示了随机森林的精确-召回曲线。从曲线可以看出,该模型可以以大约 80% 的精确度捕获大约 80% 的欺诈情况。这比逻辑回归模型以 70% 精确度捕获的大约 80% 的欺诈情况更为显著。

随机森林的精确-召回曲线

图 2-6. 随机森林的精确-召回曲线

随机森林模型的平均精度为 0.79,明显优于逻辑回归模型的 0.73 平均精度。然而,随机森林的 auROC,如图 Figure 2-7,稍微差一些,为 0.93,而逻辑回归为 0.97。

随机森林的 ROC 曲线下面积

图 2-7. 随机森林的 auROC 曲线

模型 #3:梯度提升机(XGBoost)

现在让我们使用梯度提升进行训练并评估结果。梯度提升有两个流行版本,一个是被称为 XGBoost,另一个是微软快速版本 LightGBM。让我们使用每个版本构建模型,首先是 XGBoost。⁸

设置超参数

我们将其设置为一个二元分类问题,并使用对数损失作为成本函数。我们将每棵树的最大深度设置为默认值六,并设置默认学习率为 0.3。对于每棵树,我们将使用所有观测值和所有特征;这些是默认设置。我们将设置随机状态为 2018,以确保结果的可重现性:

params_xGB = {
    'nthread':16, #number of cores
    'learning rate': 0.3, #range 0 to 1, default 0.3
    'gamma': 0, #range 0 to infinity, default 0
        # increase to reduce complexity (increase bias, reduce variance)
    'max_depth': 6, #range 1 to infinity, default 6
    'min_child_weight': 1, #range 0 to infinity, default 1
    'max_delta_step': 0, #range 0 to infinity, default 0
    'subsample': 1.0, #range 0 to 1, default 1
        # subsample ratio of the training examples
    'colsample_bytree': 1.0, #range 0 to 1, default 1
        # subsample ratio of features
    'objective':'binary:logistic',
    'num_class':1,
    'eval_metric':'logloss',
    'seed':2018,
    'silent':1
}

训练模型

与之前一样,我们将使用k-折交叉验证,在不同的四分之四的训练数据上训练,并在第五部分进行预测,总共进行五次运行。

对于五次运行中的每一次,梯度提升模型将训练多达两千轮,评估交叉验证的对数损失是否在进行中减少。如果交叉验证的对数损失在前两百轮停止改善,则训练过程将停止,以避免过拟合。训练过程的结果很详细,所以我们不会在这里打印它们,但可以通过 GitHub 上的代码 找到:

trainingScores = []
cvScores = []
predictionsBasedOnKFolds = pd.DataFrame(data=[],
                                    index=y_train.index,columns=['prediction'])

for train_index, cv_index in k_fold.split(np.zeros(len(X_train)),
                                          y_train.ravel()):
    X_train_fold, X_cv_fold = X_train.iloc[train_index,:], \
        X_train.iloc[cv_index,:]
    y_train_fold, y_cv_fold = y_train.iloc[train_index], \
        y_train.iloc[cv_index]

    dtrain = xgb.DMatrix(data=X_train_fold, label=y_train_fold)
    dCV = xgb.DMatrix(data=X_cv_fold)

    bst = xgb.cv(params_xGB, dtrain, num_boost_round=2000,
                 nfold=5, early_stopping_rounds=200, verbose_eval=50)

    best_rounds = np.argmin(bst['test-logloss-mean'])
    bst = xgb.train(params_xGB, dtrain, best_rounds)

    loglossTraining = log_loss(y_train_fold, bst.predict(dtrain))
    trainingScores.append(loglossTraining)

    predictionsBasedOnKFolds.loc[X_cv_fold.index,'prediction'] = \
        bst.predict(dCV)
    loglossCV = log_loss(y_cv_fold, \
        predictionsBasedOnKFolds.loc[X_cv_fold.index,'prediction'])
    cvScores.append(loglossCV)

    print('Training Log Loss: ', loglossTraining)
    print('CV Log Loss: ', loglossCV)

loglossXGBoostGradientBoosting = \
    log_loss(y_train, predictionsBasedOnKFolds.loc[:,'prediction'])
print('XGBoost Gradient Boosting Log Loss: ', loglossXGBoostGradientBoosting)

评估结果

如以下结果所示,整个训练集上的对数损失(使用交叉验证预测)仅为随机森林的五分之一,逻辑回归的五十分之一。这是对前两个模型的显著改进:

XGBoost Gradient Boosting Log Loss: 0.0029566906288156715

如图 Figure 2-8 所示,平均精度为 0.82,略低于随机森林(0.79),但明显优于逻辑回归(0.73)。

XGBoost 梯度提升的精确-召回曲线

图 2-8. XGBoost 梯度提升的精确-召回曲线

如图 Figure 2-9 所示,auROC 曲线为 0.97,与逻辑回归相同(0.97),比随机森林(0.93)更好。到目前为止,基于对数损失、精确-召回曲线和 auROC,梯度提升是三个模型中最好的。

XGBoost 梯度提升的 ROC 曲线下面积

图 2-9. XGBoost 梯度提升的 auROC 曲线

模型 #4:梯度提升机(LightGBM)

现在让我们使用另一个名为 LightGBM 的梯度提升版本进行训练。⁹

设置超参数

我们将其设置为二元分类问题,并使用对数损失作为成本函数。 我们将每棵树的最大深度设置为 4,并使用学习率为 0.1。 对于每棵树,我们将使用所有样本和所有特征; 这些是默认设置。 我们将使用一个树的默认叶子节点数(31),并设置一个随机状态以确保结果的可重现性:

params_lightGB = {
    'task': 'train',
    'application':'binary',
    'num_class':1,
    'boosting': 'gbdt',
    'objective': 'binary',
    'metric': 'binary_logloss',
    'metric_freq':50,
    'is_training_metric':False,
    'max_depth':4,
    'num_leaves': 31,
    'learning_rate': 0.01,
    'feature_fraction': 1.0,
    'bagging_fraction': 1.0,
    'bagging_freq': 0,
    'bagging_seed': 2018,
    'verbose': 0,
    'num_threads':16
}

训练模型

与之前一样,我们将使用k-fold 交叉验证,并在这五次循环中进行存储验证集上的预测:

trainingScores = []
cvScores = []
predictionsBasedOnKFolds = pd.DataFrame(data=[],
                                index=y_train.index,columns=['prediction'])

for train_index, cv_index in k_fold.split(np.zeros(len(X_train)),
                                          y_train.ravel()):
    X_train_fold, X_cv_fold = X_train.iloc[train_index,:], \
        X_train.iloc[cv_index,:]
    y_train_fold, y_cv_fold = y_train.iloc[train_index], \
        y_train.iloc[cv_index]

    lgb_train = lgb.Dataset(X_train_fold, y_train_fold)
    lgb_eval = lgb.Dataset(X_cv_fold, y_cv_fold, reference=lgb_train)
    gbm = lgb.train(params_lightGB, lgb_train, num_boost_round=2000,
                   valid_sets=lgb_eval, early_stopping_rounds=200)

    loglossTraining = log_loss(y_train_fold, \
                gbm.predict(X_train_fold, num_iteration=gbm.best_iteration))
    trainingScores.append(loglossTraining)

    predictionsBasedOnKFolds.loc[X_cv_fold.index,'prediction'] = \
        gbm.predict(X_cv_fold, num_iteration=gbm.best_iteration)
    loglossCV = log_loss(y_cv_fold, \
        predictionsBasedOnKFolds.loc[X_cv_fold.index,'prediction'])
    cvScores.append(loglossCV)

    print('Training Log Loss: ', loglossTraining)
    print('CV Log Loss: ', loglossCV)

loglossLightGBMGradientBoosting = \
    log_loss(y_train, predictionsBasedOnKFolds.loc[:,'prediction'])
print('LightGBM gradient boosting Log Loss: ', loglossLightGBMGradientBoosting)

对于五次运行中的每一次,梯度提升模型将训练多达两千轮,评估交叉验证对数损失是否在进行中减少。 如果交叉验证对数损失停止改善(在前两百轮中),则训练过程将停止以避免过拟合。 训练过程的结果很冗长,所以我们不会在这里打印出来,但可以通过 GitHub 上的代码 找到。

评估结果

下面的结果显示,整个训练集上的对数损失(使用交叉验证预测)与 XGBoost 相似,是随机森林的五分之一,是逻辑回归的五十分之一。 但与 XGBoost 相比,LightGBM 速度要快得多:

LightGBM Gradient Boosting Log Loss: 0.0029732268054261826

如 图 2-10 所示,平均精度为 0.82,与 XGBoost 相同(0.82),优于随机森林(0.79),远优于逻辑回归(0.73)。

LightGBM 梯度提升的精确度-召回率曲线

图 2-10. LightGBM 梯度提升的精确度-召回率曲线

如 图 2-11 所示,auROC 曲线为 0.98,比 XGBoost(0.97),逻辑回归(0.97)和随机森林(0.93)都有所改进。

LightGBM 梯度提升的 ROC 曲线下面积

图 2-11. LightGBM 梯度提升的 auROC 曲线

使用测试集评估四个模型

到目前为止,在本章中,我们学习了如何:

  • 设置机器学习项目的环境

  • 获取、加载、探索、清洗和可视化数据

  • 将数据集分割为训练集和测试集,并设置k-fold 交叉验证集

  • 选择适当的成本函数

  • 设置超参数并进行训练和交叉验证

  • 评估结果

我们尚未探索如何调整超参数(即超参数微调过程),以改善每个机器学习解决方案的结果并解决欠拟合/过拟合问题,但是 GitHub 上的代码 将使您能够非常轻松地进行这些实验。

即使没有进行这样的精细调整,结果也很明显。根据我们的训练和k折交叉验证,LightGBM 梯度提升是最佳解决方案,紧随其后的是 XGBoost。随机森林和逻辑回归则较差。

让我们使用测试集作为四个模型的最终评估。

对于每个模型,我们将使用训练好的模型预测测试集交易的欺诈概率。然后,通过比较模型预测的欺诈概率与真实欺诈标签,计算每个模型的对数损失:

predictionsTestSetLogisticRegression = \
    pd.DataFrame(data=[],index=y_test.index,columns=['prediction'])
predictionsTestSetLogisticRegression.loc[:,'prediction'] = \
    logReg.predict_proba(X_test)[:,1]
logLossTestSetLogisticRegression = \
    log_loss(y_test, predictionsTestSetLogisticRegression)

predictionsTestSetRandomForests = \
    pd.DataFrame(data=[],index=y_test.index,columns=['prediction'])
predictionsTestSetRandomForests.loc[:,'prediction'] = \
    RFC.predict_proba(X_test)[:,1]
logLossTestSetRandomForests = \
    log_loss(y_test, predictionsTestSetRandomForests)

predictionsTestSetXGBoostGradientBoosting = \
    pd.DataFrame(data=[],index=y_test.index,columns=['prediction'])
dtest = xgb.DMatrix(data=X_test)
predictionsTestSetXGBoostGradientBoosting.loc[:,'prediction'] = \
    bst.predict(dtest)
logLossTestSetXGBoostGradientBoosting = \
    log_loss(y_test, predictionsTestSetXGBoostGradientBoosting)

predictionsTestSetLightGBMGradientBoosting = \
    pd.DataFrame(data=[],index=y_test.index,columns=['prediction'])
predictionsTestSetLightGBMGradientBoosting.loc[:,'prediction'] = \
    gbm.predict(X_test, num_iteration=gbm.best_iteration)
logLossTestSetLightGBMGradientBoosting = \
    log_loss(y_test, predictionsTestSetLightGBMGradientBoosting)

在下面的对数损失块中没有什么意外。LightGBM 梯度提升在测试集上有最低的对数损失,其次是其他模型。

Log Loss of Logistic Regression on Test Set: 0.123732961313
Log Loss of Random Forests on Test Set: 0.00918192757674
Log Loss of XGBoost Gradient Boosting on Test Set: 0.00249116807943
Log Loss of LightGBM Gradient Boosting on Test Set: 0.002376320092424

图 2-12 到 2-19 是四个模型的精确率-召回率曲线、平均精度和 auROC 曲线,验证了我们以上的发现。

逻辑回归

逻辑回归的测试集精确率-召回率曲线

图 2-12. 逻辑回归的测试集精确率-召回率曲线

逻辑回归的测试集 auROC 曲线下面积

图 2-13. 逻辑回归的测试集 auROC 曲线

随机森林

随机森林的测试集精确率-召回率曲线

图 2-14. 随机森林的测试集精确率-召回率曲线

随机森林的测试集 auROC 曲线下面积

图 2-15. 逻辑回归的测试集 auROC 曲线

XGBoost 梯度提升

XGBoost 梯度提升的测试集精确率-召回率曲线

图 2-16. XGBoost 梯度提升的测试集精确率-召回率曲线

XGBoost 梯度提升的测试集 ROC 曲线下面积

图 2-17. XGBoost 梯度提升的测试集 auROC 曲线

LightGBM 梯度提升

LightGBM 梯度提升的测试集精确率-召回率曲线

图 2-18. LightGBM 梯度提升的测试集精确率-召回率曲线

LightGBM 梯度提升的测试集 auROC 曲线下面积

图 2-19. LightGBM 梯度提升的测试集 auROC 曲线

LightGBM 梯度提升的结果令人印象深刻——我们可以捕捉超过 80%的欺诈交易,并且准确率接近 90%(换句话说,捕捉到 80%的总欺诈交易中,LightGBM 模型仅有 10%的错误)。

考虑到我们的数据集中欺诈案例很少,这是一项很大的成就。

集成模型

我们可以评估是否将这些开发的机器学习解决方案集成到生产中,以提高欺诈检测率¹⁰,而不是仅选择一个。

通常,如果我们包含来自不同机器学习家族的同样强大的解决方案(例如来自随机森林和神经网络的解决方案),这些解决方案的集成将比任何一个独立的解决方案产生更好的结果。这是因为每个独立的解决方案都有不同的优势和劣势。通过在集成中包含这些独立解决方案,一些模型的优势弥补了其他模型的劣势,反之亦然。

不过有重要的注意事项。如果独立的解决方案同样强大,集成模型的性能将优于任何一个独立的解决方案。但如果其中一个解决方案远远优于其他解决方案,集成模型的性能将等于最佳独立解决方案的性能;而次优解决方案将对集成模型的性能毫无贡献。

另外,独立的解决方案需要相对不相关。如果它们高度相关,一个解决方案的优点会反映在其余解决方案上,同样的情况也会出现在缺点上。通过集成来实现多样化将不会有太多好处。

堆叠

在我们的问题中,两个模型(LightGBM 梯度提升和 XGBoost 梯度提升)比其他两个模型(随机森林和逻辑回归)强大得多。但是最强大的两个模型来自同一个家族,这意味着它们的优势和劣势高度相关。

我们可以使用堆叠(一种集成形式)来确定是否能够相比之前的独立模型获得性能改进。在堆叠中,我们从每个四个独立模型的k-折交叉验证预测(称为第一层预测)中获取预测,并将它们附加到原始训练数据集上。然后,我们使用该原始特征加上第一层预测数据集进行k-折交叉验证训练。

这将产生一个新的k-折交叉验证预测集,称为第二层预测,我们将评估是否在性能上比任何单独的模型有所改进。

将第一层预测与原始训练数据集结合

首先,让我们将每个构建的四个机器学习模型的预测与原始训练数据集相结合:

predictionsBasedOnKFoldsFourModels = pd.DataFrame(data=[],index=y_train.index)
predictionsBasedOnKFoldsFourModels = predictionsBasedOnKFoldsFourModels.join(
    predictionsBasedOnKFoldsLogisticRegression['prediction'].astype(float), \
    how='left').join(predictionsBasedOnKFoldsRandomForests['prediction'] \
	.astype(float),how='left',rsuffix="2").join( \
    predictionsBasedOnKFoldsXGBoostGradientBoosting['prediction'] \
	.astype(float), how='left',rsuffix="3").join( \
    predictionsBasedOnKFoldsLightGBMGradientBoosting['prediction'] \
	.astype(float), how='left',rsuffix="4")
predictionsBasedOnKFoldsFourModels.columns = \
    ['predsLR','predsRF','predsXGB','predsLightGBM']

X_trainWithPredictions = \
    X_train.merge(predictionsBasedOnKFoldsFourModels,
                  left_index=True,right_index=True)

设置超参数

现在我们将使用 LightGBM 梯度提升——之前练习中的最佳机器学习算法——在该原始特征加上第一层预测数据集上进行训练。超参数将保持与之前相同:

params_lightGB = {
    'task': 'train',
    'application':'binary',
    'num_class':1,
    'boosting': 'gbdt',
    'objective': 'binary',
    'metric': 'binary_logloss',
    'metric_freq':50,
    'is_training_metric':False,
    'max_depth':4,
    'num_leaves': 31,
    'learning_rate': 0.01,
    'feature_fraction': 1.0,
    'bagging_fraction': 1.0,
    'bagging_freq': 0,
    'bagging_seed': 2018,
    'verbose': 0,
    'num_threads':16
}

训练模型

和之前一样,我们将使用k-折交叉验证,并为五个不同的交叉验证集生成欺诈概率:

trainingScores = []
cvScores = []
predictionsBasedOnKFoldsEnsemble = \
    pd.DataFrame(data=[],index=y_train.index,columns=['prediction'])

for train_index, cv_index in k_fold.split(np.zeros(len(X_train)), \
                                          y_train.ravel()):
    X_train_fold, X_cv_fold = \
        X_trainWithPredictions.iloc[train_index,:], \
        X_trainWithPredictions.iloc[cv_index,:]
    y_train_fold, y_cv_fold = y_train.iloc[train_index], y_train.iloc[cv_index]

    lgb_train = lgb.Dataset(X_train_fold, y_train_fold)
    lgb_eval = lgb.Dataset(X_cv_fold, y_cv_fold, reference=lgb_train)
    gbm = lgb.train(params_lightGB, lgb_train, num_boost_round=2000,
                   valid_sets=lgb_eval, early_stopping_rounds=200)

    loglossTraining = log_loss(y_train_fold, \
        gbm.predict(X_train_fold, num_iteration=gbm.best_iteration))
    trainingScores.append(loglossTraining)

    predictionsBasedOnKFoldsEnsemble.loc[X_cv_fold.index,'prediction'] = \
        gbm.predict(X_cv_fold, num_iteration=gbm.best_iteration)
    loglossCV = log_loss(y_cv_fold, \
        predictionsBasedOnKFoldsEnsemble.loc[X_cv_fold.index,'prediction'])
    cvScores.append(loglossCV)

    print('Training Log Loss: ', loglossTraining)
    print('CV Log Loss: ', loglossCV)

loglossEnsemble = log_loss(y_train, \
        predictionsBasedOnKFoldsEnsemble.loc[:,'prediction'])
print('Ensemble Log Loss: ', loglossEnsemble)

评估结果

在以下结果中,我们没有看到任何改进。集成对数损失非常类似于独立梯度提升对数损失。由于最佳的独立解决方案来自相同的家族(梯度提升),我们没有看到结果的改进。它们在检测欺诈方面具有高度相关的优势和劣势。在模型之间进行多样化并没有好处:

Ensemble Log Loss: 0.002885415974220497

如图 2-20 和 2-21 所示,精确率-召回率曲线、平均精度和 auROC 也证实了改进的缺乏。

集成的精确率-召回率曲线

图 2-20. 集成的精确率-召回率曲线

集成的 ROC 曲线下的面积

图 2-21. 集成的 auROC 曲线

最终模型选择

由于集成并没有提高性能,我们更倾向于使用独立的 LightGBM 梯度提升模型的简洁性,并将其用于生产。

在我们为新进入的交易创建流水线之前,让我们可视化一下 LightGBM 模型在测试集中如何将欺诈交易与正常交易分开。

图 2-22 在 x 轴上显示了预测概率。基于这个图表,模型在将实际欺诈交易分配高欺诈概率方面表现相当不错。反之,该模型通常会给不欺诈的交易分配低概率。偶尔,模型会错误地给实际欺诈案例分配低概率,而给非欺诈案例分配高概率。

总体而言,结果相当令人印象深刻。

预测概率和真实标签的绘图

图 2-22. 预测概率和真实标签的绘图

生产流水线

现在我们已经选择了一个模型进行生产,让我们设计一个简单的流水线,对新进入的数据执行三个简单的步骤:加载数据,缩放特征,并使用我们已经训练并选择用于生产的 LightGBM 模型生成预测:

'''Pipeline for New Data'''
# first, import new data into a dataframe called 'newData'
# second, scale data
# newData.loc[:,featuresToScale] = sX.transform(newData[featuresToScale])
# third, predict using LightGBM
# gbm.predict(newData, num_iteration=gbm.best_iteration)

一旦生成了这些预测,分析师可以对预测为欺诈概率最高的交易采取行动(即进一步调查),并逐一处理列表。或者,如果自动化是目标,分析师可以使用一个自动拒绝预测为欺诈概率高于某个阈值的交易的系统。

例如,基于 图 2-13,如果我们自动拒绝预测概率高于 0.90 的交易,我们将拒绝几乎肯定是欺诈的案例,而不会意外地拒绝非欺诈案例。

结论

恭喜!您已经使用监督学习构建了一个信用卡欺诈检测系统。

我们一起建立了一个机器学习环境,获取并准备了数据,训练和评估了多个模型,选择了最终用于生产的模型,并设计了一个新的、流入交易的管道。你已经成功创建了一个应用的机器学习解决方案。

现在,我们将采用同样的实践方法,利用无监督学习开发应用的机器学习解决方案。

注意

随着欺诈模式的变化,上述解决方案需要随时间重新训练。此外,我们应该找到其他机器学习算法——来自不同机器学习家族的算法——它们能像梯度提升一样表现良好,并将它们组合起来以改善整体的欺诈检测性能。

最后,解释性对于机器学习在实际应用中非常重要。因为这个信用卡交易数据集的特征是 PCA 的输出(一种我们将在第三章探讨的降维形式),我们无法用简单的英语解释为什么某些交易被标记为潜在的欺诈行为。为了更好地解释结果,我们需要访问原始的 PCA 前特征,但对于这个示例数据集,我们没有这些特征。

¹ 想了解更多关于 fastcluster 的信息,请参阅文档

² 这个数据集可以通过Kaggle获取,并且是由 Worldline 和 Universite Libre de Bruxelles 的机器学习小组在研究合作期间收集的。更多信息请参见 Andrea Dal Pozzolo、Olivier Caelen、Reid A. Johnson 和 Gianluca Bontempi 的论文《Calibrating Probability with Undersampling for Unbalanced Classification》,发表于 IEEE 的计算智能与数据挖掘研讨会(CIDM),2015 年。

³ 分类变量取可能的有限数量的可能质量值之一,通常需要进行编码以在机器学习算法中使用。

⁴ 想了解 stratify 参数如何保留正标签比例,请访问官方网站。为了在你的实验中复制相同的分割,请将随机状态设置为 2018。如果设置为其他数字或者不设置,结果将不同。

⁵ 想了解 L1 与 L2 的区别,请参考博文“L1 和 L2 作为损失函数和正则化的区别。”

⁶ 真正例是预测和实际标签都为真的实例。真负例是预测和实际标签都为假的实例。假正例是预测为真但实际标签为假的实例(也称为误报或类型 I 错误)。假负例是预测为假但实际标签为真的实例(也称为漏报或类型 II 错误)。

⁷ 召回率也称为敏感性或真正率。与敏感性相关的概念是特异性或真负率。特异性定义为数据集中真负例数除以总实际负例数。特异性 = 真负率 = 真负例 / (真负例 + 假正例)。

⁸ 关于 XGBoost 梯度提升的更多信息,请参阅GitHub 代码库

⁹ 关于 Microsoft 的 LightGBM 梯度提升的更多信息,请参阅GitHub 代码库

¹⁰ 关于集成学习的更多信息,请参考“Kaggle 集成指南,” “Python 中的集成/堆叠介绍,”“实践中的模型堆叠指南”

第二部分:使用 Scikit-Learn 进行无监督学习

在接下来的几章中,我们将介绍两个重要的无监督学习概念——降维和聚类——并使用它们进行异常检测和群组分割。

异常检测和群组分割在许多不同行业中都有重要的实际应用。

异常检测用于高效发现罕见事件,如欺诈、网络安全漏洞、恐怖主义、人类、武器和毒品走私、洗钱、异常交易活动、疾病爆发以及关键设备的维护故障。

群组分割使我们能够理解用户在市场营销、在线购物、音乐听取、视频观看、在线约会和社交媒体活动等领域的行为。

第三章:降维

在本章中,我们将关注构建成功应用机器学习解决方案的一个主要挑战:维度灾难。无监督学习有一个很好的对策——降维。在本章中,我们将介绍这个概念,并从那里开始,帮助你培养对其工作原理的直觉。

在 第四章中,我们将基于降维构建我们自己的无监督学习解决方案——具体来说,是一个基于无监督学习的信用卡欺诈检测系统(与我们在第二章中构建的基于有监督的系统不同)。这种无监督的欺诈检测被称为异常检测,是应用无监督学习领域一个迅速发展的领域。

但在构建异常检测系统之前,让我们在本章中介绍降维。

降维的动机

正如第一章中所提到的,降维有助于克服机器学习中最常见的问题之一——维度灾难,其中算法由于特征空间的巨大规模,无法有效和高效地在数据上训练。

降维算法将高维数据投影到低维空间,同时尽可能保留重要信息,去除冗余信息。一旦数据进入低维空间,机器学习算法能够更有效、更高效地识别有趣的模式,因为噪声已经被大大减少。

有时,降维本身就是目标——例如,构建异常检测系统,我们将在下一章中展示。

其他时候,降维不是一个终点,而是达到另一个终点的手段。例如,降维通常是机器学习管道的一部分,帮助解决涉及图像、视频、语音和文本的大规模、计算密集型问题。

MNIST 手写数字数据库

在介绍降维算法之前,让我们先探索一下本章将使用的数据集。我们将使用一个简单的计算机视觉数据集:MNIST(美国国家标准与技术研究院)手写数字数据库,这是机器学习中最为人知的数据集之一。我们将使用 Yann LeCun 网站上公开的 MNIST 数据集版本。¹ 为了方便起见,我们将使用deeplearning.net提供的 pickle 版本。²

这个数据集已被分为三个部分——一个包含 50,000 个例子的训练集,一个包含 10,000 个例子的验证集和一个包含 10,000 个例子的测试集。我们为所有例子都有标签。

该数据集由手写数字的 28x28 像素图像组成。每个数据点(即每个图像)可以表示为一组数字的数组,其中每个数字描述每个像素的暗度。换句话说,一个 28x28 的数字数组对应于一个 28x28 像素的图像。

为了简化起见,我们可以将每个数组展平为一个 28x28 或 784 维度的向量。向量的每个分量是介于零和一之间的浮点数——表示图像中每个像素的强度。零表示黑色,一表示白色。标签是介于零和九之间的数字,指示图像表示的数字。

数据获取和探索

在我们使用降维算法之前,让我们加载将要使用的库:

# Import libraries
'''Main'''
import numpy as np
import pandas as pd
import os, time
import pickle, gzip

'''Data Viz'''
import matplotlib.pyplot as plt
import seaborn as sns
color = sns.color_palette()
import matplotlib as mpl

%matplotlib inline

'''Data Prep and Model Evaluation'''
from sklearn import preprocessing as pp
from scipy.stats import pearsonr
from numpy.testing import assert_array_almost_equal
from sklearn.model_selection import train_test_split
from sklearn.model_selection import StratifiedKFold
from sklearn.metrics import log_loss
from sklearn.metrics import precision_recall_curve, average_precision_score
from sklearn.metrics import roc_curve, auc, roc_auc_score
from sklearn.metrics import confusion_matrix, classification_report

'''Algos'''
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
import xgboost as xgb
import lightgbm as lgb

加载 MNIST 数据集

现在让我们加载 MNIST 数据集:

# Load the datasets
current_path = os.getcwd()
file = '\\datasets\\mnist_data\\mnist.pkl.gz'

f = gzip.open(current_path+file, 'rb')
train_set, validation_set, test_set = pickle.load(f, encoding='latin1')
f.close()

X_train, y_train = train_set[0], train_set[1]
X_validation, y_validation = validation_set[0], validation_set[1]
X_test, y_test = test_set[0], test_set[1]

验证数据集的形状

让我们验证数据集的形状,以确保它们已正确加载:

# Verify shape of datasets
print("Shape of X_train: ", X_train.shape)
print("Shape of y_train: ", y_train.shape)
print("Shape of X_validation: ", X_validation.shape)
print("Shape of y_validation: ", y_validation.shape)
print("Shape of X_test: ", X_test.shape)
print("Shape of y_test: ", y_test.shape)

以下代码确认了数据集的形状与预期相符:

Shape of X_train:       (50000, 784)
Shape of y_train:       (50000,)
Shape of X_validation:  (10000, 784)
Shape of y_validation:  (10000,)
Shape of X_test:        (10000, 784)
Shape of y_test:        (10000,)

从数据集创建 Pandas DataFrames

让我们将 numpy 数组转换为 Pandas DataFrames,以便更容易进行探索和处理:

# Create Pandas DataFrames from the datasets
train_index = range(0,len(X_train))
validation_index = range(len(X_train), /
                         len(X_train)+len(X_validation))
test_index = range(len(X_train)+len(X_validation), /
                   len(X_train)+len(X_validation)+len(X_test))

X_train = pd.DataFrame(data=X_train,index=train_index)
y_train = pd.Series(data=y_train,index=train_index)

X_validation = pd.DataFrame(data=X_validation,index=validation_index)
y_validation = pd.Series(data=y_validation,index=validation_index)

X_test = pd.DataFrame(data=X_test,index=test_index)
y_test = pd.Series(data=y_test,index=test_index)

探索数据

让我们生成数据的摘要视图:

# Describe the training matrix
X_train.describe()

表格 3-1 显示了图像数据的摘要视图。许多数值为零——换句话说,图像中的大多数像素是黑色的。这是有道理的,因为数字是白色的,显示在黑色背景的中央。

表格 3-1. 数据探索

0 1 2 3 4 5 6
计数 50000.0 50000.0 50000.0 50000.0 50000.0 50000.0 50000.0
平均值 0.0 0.0 0.0 0.0 0.0 0.0 0.0
标准差 0.0 0.0 0.0 0.0 0.0 0.0 0.0
最小值 0.0 0.0 0.0 0.0 0.0 0.0 0.0
25% 0.0 0.0 0.0 0.0 0.0 0.0 0.0
50% 0.0 0.0 0.0 0.0 0.0 0.0 0.0
75% 0.0 0.0 0.0 0.0 0.0 0.0 0.0
最大 0.0 0.0 0.0 0.0 0.0 0.0 0.0
8 行 x 784 列

标签数据是一个表示图像中实际内容的一维向量。前几个图像的标签如下:

# Show the labels
y_train.head()
  0   5
  1   0
  2   4
  3   1
  4   9
  dtype: int64

显示图像

让我们定义一个函数来查看图像及其标签:

def view_digit(example):
    label = y_train.loc[0]
    image = X_train.loc[example,:].values.reshape([28,28])
    plt.title('Example: %d Label: %d' % (example, label))
    plt.imshow(image, cmap=plt.get_cmap('gray'))
    plt.show()

第一个图像的视图——一旦将 784 维向量重塑为 28 x 28 像素图像——显示数字五(图 3-1)。

查看第一个数字

图 3-1. 第一个数字的视图

降维算法

现在我们已经加载并探索了 MNIST 数字数据集,让我们转向降维算法。对于每个算法,我们将首先介绍概念,然后通过将算法应用于 MNIST 数字数据集来深入理解。

线性投影 vs 流形学习

降维有两大主要分支。第一种被称为线性投影,它涉及将数据从高维空间线性投影到低维空间。这包括主成分分析,奇异值分解随机投影等技术。

第二种被称为流形学习,也被称为非线性降维。这涉及技术,如isomap,它学习点之间的曲线距离(也称为测地距离),而不是欧氏距离。其他技术包括多维缩放(MDS),局部线性嵌入(LLE),t 分布随机近邻嵌入(t-SNE),字典学习,随机树嵌入独立成分分析

主成分分析

我们将探讨几个 PCA 版本,包括标准 PCA,增量 PCA,稀疏 PCA 和核 PCA。

PCA,概念

让我们从标准 PCA 开始,这是最常见的线性降维技术之一。在 PCA 中,算法找到数据的低维表示,同时尽可能保留尽可能多的变化(即显著信息)。

PCA 通过处理特征之间的相关性来实现这一点。如果一组特征之间的相关性非常高,PCA 将尝试合并高度相关的特征,并用较少数量的线性不相关特征表示这些数据。该算法持续执行这种相关性减少,找到原始高维数据中方差最大的方向,并将它们投影到较小维度的空间中。这些新导出的成分称为主成分。

有了这些成分,可以重构原始特征,虽然不完全准确但一般足够接近。PCA 算法在寻找最优成分期间积极尝试最小化重构误差。

在我们的 MNIST 示例中,原始特征空间有 784 维,称为d维。PCA 将数据投影到较小的k维子空间(其中k < d),同时尽可能保留关键信息。这k个维度称为主成分。

我们留下的有意义的主成分数量远远小于原始数据集中的维数。通过转移到这个低维空间,我们会失去一些方差(即信息),但数据的基本结构更容易识别,使我们能够更有效地执行异常检测和聚类等任务。

此外,通过减少数据的维数,PCA 将减少数据的大小,进一步提高机器学习管道中后续阶段(例如图像分类等任务)的性能。

注意

在运行 PCA 之前执行特征缩放非常重要。PCA 对原始特征的相对范围非常敏感。通常,我们必须缩放数据以确保特征处于相同的相对范围。然而,对于我们的 MNIST 数字数据集,特征已经缩放到 0 到 1 的范围,因此我们可以跳过这一步。

PCA 实践

现在您对 PCA 工作原理有了更好的掌握,让我们将 PCA 应用于 MNIST 数字数据集,并看看 PCA 如何在将数据从原始的 784 维空间投影到较低维空间时捕获数字的最显著信息。

设置超参数

让我们为 PCA 算法设置超参数:

from sklearn.decomposition import PCA

n_components = 784
whiten = False
random_state = 2018

pca = PCA(n_components=n_components, whiten=whiten, \
          random_state=random_state)

应用 PCA

我们将主成分的数量设置为原始维数(即 784)。然后,PCA 将从原始维度捕获显著信息并开始生成主成分。生成这些组件后,我们将确定需要多少个主成分才能有效地捕获原始特征集中大部分的方差/信息。

让我们拟合并转换我们的训练数据,生成这些主成分:

X_train_PCA = pca.fit_transform(X_train)
X_train_PCA = pd.DataFrame(data=X_train_PCA, index=train_index)

评估 PCA

因为我们完全没有降低维度(只是转换了数据),所以由 784 个主成分捕获的原始数据的方差/信息应为 100%:

# Percentage of Variance Captured by 784 principal components
print("Variance Explained by all 784 principal components: ", \
      sum(pca.explained_variance_ratio_))
Variance Explained by all 784 principal components: 0.9999999999999997

然而,需要注意的是 784 个主成分的重要性差异相当大。这里总结了前 X 个主成分的重要性:

# Percentage of Variance Captured by X principal components
importanceOfPrincipalComponents = \
    pd.DataFrame(data=pca.explained_variance_ratio_)
importanceOfPrincipalComponents = importanceOfPrincipalComponents.T

print('Variance Captured by First 10 Principal Components: ',
      importanceOfPrincipalComponents.loc[:,0:9].sum(axis=1).values)
print('Variance Captured by First 20 Principal Components: ',
      importanceOfPrincipalComponents.loc[:,0:19].sum(axis=1).values)
print('Variance Captured by First 50 Principal Components: ',
      importanceOfPrincipalComponents.loc[:,0:49].sum(axis=1).values)
print('Variance Captured by First 100 Principal Components: ',
      importanceOfPrincipalComponents.loc[:,0:99].sum(axis=1).values)
print('Variance Captured by First 200 Principal Components: ',
      importanceOfPrincipalComponents.loc[:,0:199].sum(axis=1).values)
print('Variance Captured by First 300 Principal Components: ',
      importanceOfPrincipalComponents.loc[:,0:299].sum(axis=1).values)
Variance Captured by First 10 Principal Components: [0.48876238]
Variance Captured by First 20 Principal Components: [0.64398025]
Variance Captured by First 50 Principal Components: [0.8248609]
Variance Captured by First 100 Principal Components: [0.91465857]
Variance Captured by First 200 Principal Components: [0.96650076]
Variance Captured by First 300 Principal Components: [0.9862489]

前 10 个组件总共捕获了大约 50% 的方差,前一百个组件超过了 90%,前三百个组件几乎捕获了 99% 的方差;其余主成分中的信息几乎可以忽略不计。

我们还可以绘制每个主成分的重要性,从第一个主成分到最后一个主成分进行排名。为了便于阅读,只显示了前 10 个组件在 图 3-2 中。

现在 PCA 的力量应该更加明显了。仅使用前两百个主成分(远少于原始的 784 维度),我们就捕获了超过 96% 的方差/信息。

PCA 允许我们大幅减少原始数据的维度,同时保留大部分显著信息。在 PCA 减少的特征集上,其他机器学习算法——在机器学习流水线中的下游——将更容易在空间中分离数据点(执行异常检测和聚类等任务),并且需要更少的计算资源。

PCA 组件的重要性

图 3-2. PCA 组件的重要性

可视化空间中点的分离

为了展示 PCA 高效、简洁地捕捉数据中的方差/信息的能力,让我们在二维空间中绘制这些观察结果。具体来说,我们将展示第一和第二主成分的散点图,并用真实标签标记这些观察结果。我们将称这个函数为scatterPlot,因为接下来我们还需要为其他维度算法呈现可视化效果。

def scatterPlot(xDF, yDF, algoName):
    tempDF = pd.DataFrame(data=xDF.loc[:,0:1], index=xDF.index)
    tempDF = pd.concat((tempDF,yDF), axis=1, join="inner")
    tempDF.columns = ["First Vector", "Second Vector", "Label"]
    sns.lmplot(x="First Vector", y="Second Vector", hue="Label", \
               data=tempDF, fit_reg=False)
    ax = plt.gca()
    ax.set_title("Separation of Observations using "+algoName)

scatterPlot(X_train_PCA, y_train, "PCA")

如图 3-3 所示,仅使用前两个主成分,PCA 能够有效地将空间中的点分离开来,使相似的点通常比其他不相似的点更靠近。换句话说,相同数字的图像彼此之间比与其他数字的图像更接近。

PCA 可以在完全不使用标签的情况下完成这一任务。这展示了无监督学习捕捉数据潜在结构的能力,帮助在没有标签的情况下发现隐藏的模式。

使用 PCA 分离观察结果

图 3-3. 使用 PCA 分离观察结果

如果我们使用原始的 784 个特征集中最重要的两个特征(通过训练监督学习模型确定),运行相同的二维散点图,最多也只能得到很差的分离效果(见图 3-4)。

不使用 PCA 分离观察结果

图 3-4. 不使用 PCA 分离观察结果

比较图 3-3 和图 3-4,可以看出 PCA 在学习数据集潜在结构方面的强大能力,完全不使用任何标签——即使仅使用两个维度,我们也可以开始有意义地通过显示的数字分离图像。

注意

PCA 不仅帮助分离数据以便更容易发现隐藏模式,还有助于减少特征集的大小,从而在训练机器学习模型时节省时间和计算资源。

对于 MNIST 数据集来说,由于数据集非常小——仅有 784 个特征和 50,000 个观察结果,因此减少训练时间的效果可能很有限。但如果数据集拥有数百万个特征和数十亿个观察结果,降维将大大减少后续机器学习管道中机器学习算法的训练时间。

最后,PCA 通常会丢弃原始特征集中的一些信息,但它会明智地捕获最重要的元素并丢弃不太有价值的元素。基于 PCA 减少特征集训练的模型在准确性上可能不如基于完整特征集训练的模型表现得好,但训练和预测时间会快得多。这是在选择是否在机器学习产品中使用降维时必须考虑的重要权衡之一。

增量 PCA

对于无法全部存入内存的大型数据集,我们可以逐批次增量地执行 PCA,其中每个批次都能放入内存中。批处理大小可以手动设置或自动确定。这种基于批处理的 PCA 形式称为增量 PCA。PCA 和增量 PCA 的生成主成分通常非常相似(图 3-5)。以下是增量 PCA 的代码:

# Incremental PCA
from sklearn.decomposition import IncrementalPCA

n_components = 784
batch_size = None

incrementalPCA = IncrementalPCA(n_components=n_components, \
                                batch_size=batch_size)

X_train_incrementalPCA = incrementalPCA.fit_transform(X_train)
X_train_incrementalPCA = \
    pd.DataFrame(data=X_train_incrementalPCA, index=train_index)

X_validation_incrementalPCA = incrementalPCA.transform(X_validation)
X_validation_incrementalPCA = \
    pd.DataFrame(data=X_validation_incrementalPCA, index=validation_index)

scatterPlot(X_train_incrementalPCA, y_train, "Incremental PCA")

使用增量 PCA 分离观测结果

图 3-5. 使用增量 PCA 分离观测结果

稀疏 PCA

普通 PCA 算法在所有输入变量中搜索线性组合,尽可能紧凑地减少原始特征空间。但对于某些机器学习问题,可能更倾向于一定程度的稀疏性。保留一定程度稀疏性的 PCA 版本,由名为alpha的超参数控制,称为稀疏 PCA。稀疏 PCA 算法仅在部分输入变量中搜索线性组合,将原始特征空间减少到一定程度,但不像普通 PCA 那样紧凑。

因为这种算法的训练速度比普通 PCA 稍慢,所以我们将仅在训练集中的前 10,000 个示例上进行训练(总共有 50,000 个示例)。当算法的训练时间较慢时,我们会继续采用在少于总观测数的情况下进行训练的做法。

对于我们的目的(即开发这些降维算法如何工作的直觉),减少训练过程是可以接受的。为了获得更好的解决方案,建议在完整的训练集上进行训练:

# Sparse PCA
from sklearn.decomposition import SparsePCA

n_components = 100
alpha = 0.0001
random_state = 2018
n_jobs = -1

sparsePCA = SparsePCA(n_components=n_components, \
                alpha=alpha, random_state=random_state, n_jobs=n_jobs)

sparsePCA.fit(X_train.loc[:10000,:])
X_train_sparsePCA = sparsePCA.transform(X_train)
X_train_sparsePCA = pd.DataFrame(data=X_train_sparsePCA, index=train_index)

X_validation_sparsePCA = sparsePCA.transform(X_validation)
X_validation_sparsePCA = \
    pd.DataFrame(data=X_validation_sparsePCA, index=validation_index)

scatterPlot(X_train_sparsePCA, y_train, "Sparse PCA")

图 3-6 展示了使用稀疏 PCA 的前两个主成分的二维散点图。

使用稀疏 PCA 分离观测结果

图 3-6. 使用稀疏 PCA 分离观测结果

注意,这个散点图看起来与普通 PCA 的不同,正如预期的那样。普通和稀疏 PCA 生成主成分的方式不同,点的分离也有所不同。

核 PCA

正常 PCA、增量 PCA 和稀疏 PCA 将原始数据线性投影到较低维度空间,但也有一种非线性形式的 PCA 称为核 PCA,它在原始数据点对上运行相似度函数以执行非线性降维。

通过学习这个相似度函数(称为核方法),核 PCA 映射了大部分数据点所在的隐式特征空间,并在比原始特征集中的维度小得多的空间中创建了这个隐式特征空间。当原始特征集不是线性可分时,这种方法特别有效。

对于核 PCA 算法,我们需要设置所需的组件数、核类型和核系数,称为gamma。最流行的核是径向基函数核,更常被称为RBF 核。这是我们将在这里使用的核心:

# Kernel PCA
from sklearn.decomposition import KernelPCA

n_components = 100
kernel = 'rbf'
gamma = None
random_state = 2018
n_jobs = 1

kernelPCA = KernelPCA(n_components=n_components, kernel=kernel, \
                      gamma=gamma, n_jobs=n_jobs, random_state=random_state)

kernelPCA.fit(X_train.loc[:10000,:])
X_train_kernelPCA = kernelPCA.transform(X_train)
X_train_kernelPCA = pd.DataFrame(data=X_train_kernelPCA,index=train_index)

X_validation_kernelPCA = kernelPCA.transform(X_validation)
X_validation_kernelPCA = \
    pd.DataFrame(data=X_validation_kernelPCA, index=validation_index)

scatterPlot(X_train_kernelPCA, y_train, "Kernel PCA")

核 PCA 的二维散点图与我们的 MNIST 数字数据集的线性 PCA 几乎相同(图 3-7)。学习 RBF 核并不改善降维。

使用核 PCA 进行观测分离

图 3-7. 使用核 PCA 进行观测分离

奇异值分解

学习数据的潜在结构的另一种方法是将特征的原始矩阵的秩降低到一个较小的秩,以便可以使用较小秩矩阵中某些向量的线性组合重新创建原始矩阵。这被称为奇异值分解(SVD)

为了生成较小秩矩阵,SVD 保留原始矩阵中具有最多信息的向量(即最高的奇异值)。较小秩矩阵捕获了原始特征空间的最重要元素。

这与 PCA 非常相似。PCA 使用协方差矩阵的特征值分解来进行降维。奇异值分解(SVD)使用奇异值分解,正如其名称所示。事实上,PCA 在其计算中使用了 SVD,但本书的大部分讨论超出了此范围。

这是 SVD 的工作原理:

# Singular Value Decomposition
from sklearn.decomposition import TruncatedSVD

n_components = 200
algorithm = 'randomized'
n_iter = 5
random_state = 2018

svd = TruncatedSVD(n_components=n_components, algorithm=algorithm, \
                   n_iter=n_iter, random_state=random_state)

X_train_svd = svd.fit_transform(X_train)
X_train_svd = pd.DataFrame(data=X_train_svd, index=train_index)

X_validation_svd = svd.transform(X_validation)
X_validation_svd = pd.DataFrame(data=X_validation_svd, index=validation_index)

scatterPlot(X_train_svd, y_train, "Singular Value Decomposition")

图 3-8 显示了我们使用 SVD 的两个最重要向量实现的点的分离。

使用 SVD 进行观测分离

图 3-8. 使用 SVD 进行观测分离

随机投影

另一种线性降维技术是随机投影,它依赖于Johnson–Lindenstrauss 引理。根据 Johnson–Lindenstrauss 引理,高维空间中的点可以嵌入到一个远低于其维度的空间中,以便点之间的距离几乎保持不变。换句话说,即使从高维空间移动到低维空间,原始特征集的相关结构也得到保留。

高斯随机投影

随机投影有两个版本——标准版本称为高斯随机投影,稀疏版本称为稀疏随机投影

对于高斯随机投影,我们可以指定我们希望在降维特征空间中拥有的组件数量,或者我们可以设置超参数eps。eps 控制嵌入的质量,根据 Johnson–Lindenstrauss 引理,较小的值会生成更多的维度。在我们的情况下,我们将设置这个超参数:

# Gaussian Random Projection
from sklearn.random_projection import GaussianRandomProjection

n_components = 'auto'
eps = 0.5
random_state = 2018

GRP = GaussianRandomProjection(n_components=n_components, eps=eps, \
                               random_state=random_state)

X_train_GRP = GRP.fit_transform(X_train)
X_train_GRP = pd.DataFrame(data=X_train_GRP, index=train_index)

X_validation_GRP = GRP.transform(X_validation)
X_validation_GRP = pd.DataFrame(data=X_validation_GRP, index=validation_index)

scatterPlot(X_train_GRP, y_train, "Gaussian Random Projection")

图 3-9 显示了使用高斯随机投影的二维散点图。

使用高斯随机投影分离观测

图 3-9. 使用高斯随机投影分离观测

尽管随机投影与 PCA 一样都是一种线性投影形式,但随机投影是一种完全不同的降维方法家族。因此,随机投影的散点图看起来与普通 PCA、增量 PCA、稀疏 PCA 和核 PCA 的散点图非常不同。

稀疏随机投影

正如 PCA 有稀疏版本一样,随机投影也有稀疏版本,称为稀疏随机投影。它在转换后的特征集中保留了一定程度的稀疏性,并且通常比普通的高斯随机投影更高效,能够更快地将原始数据转换为降维空间:

# Sparse Random Projection
from sklearn.random_projection import SparseRandomProjection

n_components = 'auto'
density = 'auto'
eps = 0.5
dense_output = False
random_state = 2018

SRP = SparseRandomProjection(n_components=n_components, \
        density=density, eps=eps, dense_output=dense_output, \
        random_state=random_state)

X_train_SRP = SRP.fit_transform(X_train)
X_train_SRP = pd.DataFrame(data=X_train_SRP, index=train_index)

X_validation_SRP = SRP.transform(X_validation)
X_validation_SRP = pd.DataFrame(data=X_validation_SRP, index=validation_index)

scatterPlot(X_train_SRP, y_train, "Sparse Random Projection")

图 3-10 显示了使用稀疏随机投影的二维散点图。

使用稀疏随机投影分离观测

图 3-10. 使用稀疏随机投影分离观测

Isomap

与其线性投影高维空间到低维空间的数据,我们可以使用非线性降维方法。这些方法统称为流形学习。

流形学习最基本的形式被称为等距映射,简称Isomap。与核 PCA 类似,Isomap 通过计算所有点的成对距离来学习原始特征集的新的低维嵌入,其中距离是曲线测地距离,而不是欧氏距离。换句话说,它基于每个点相对于流形上邻近点的位置学习原始数据的内在几何结构:

# Isomap

from sklearn.manifold import Isomap

n_neighbors = 5
n_components = 10
n_jobs = 4

isomap = Isomap(n_neighbors=n_neighbors, \
                n_components=n_components, n_jobs=n_jobs)

isomap.fit(X_train.loc[0:5000,:])
X_train_isomap = isomap.transform(X_train)
X_train_isomap = pd.DataFrame(data=X_train_isomap, index=train_index)

X_validation_isomap = isomap.transform(X_validation)
X_validation_isomap = pd.DataFrame(data=X_validation_isomap, \
                                   index=validation_index)

scatterPlot(X_train_isomap, y_train, "Isomap")

图 3-11 显示了使用 Isomap 的二维散点图。

使用 Isomap 分离观察结果

图 3-11. 使用 isomap 分离观察结果

多维缩放

多维缩放(MDS)是一种非线性降维形式,它学习原始数据集中点的相似性,并利用这种相似性在较低维度空间中进行建模:

# Multidimensional Scaling
from sklearn.manifold import MDS

n_components = 2
n_init = 12
max_iter = 1200
metric = True
n_jobs = 4
random_state = 2018

mds = MDS(n_components=n_components, n_init=n_init, max_iter=max_iter, \
          metric=metric, n_jobs=n_jobs, random_state=random_state)

X_train_mds = mds.fit_transform(X_train.loc[0:1000,:])
X_train_mds = pd.DataFrame(data=X_train_mds, index=train_index[0:1001])

scatterPlot(X_train_mds, y_train, "Multidimensional Scaling")

图 3-12 显示了使用 MDS 的二维散点图。

使用 MDS 分离观察结果

图 3-12. 使用 MDS 分离观察结果

局部线性嵌入

另一种流行的非线性降维方法称为局部线性嵌入(LLE)。该方法在将数据从原始特征空间投影到降维空间时保持了局部邻域内的距离。LLE 通过将数据分段成较小的组件(即点的邻域)并将每个组件建模为线性嵌入,发现了原始高维数据中的非线性结构。

对于该算法,我们设置我们期望的组件数量和在给定邻域中考虑的点数:

# Locally Linear Embedding (LLE)
from sklearn.manifold import LocallyLinearEmbedding

n_neighbors = 10
n_components = 2
method = 'modified'
n_jobs = 4
random_state = 2018

lle = LocallyLinearEmbedding(n_neighbors=n_neighbors, \
        n_components=n_components, method=method, \
        random_state=random_state, n_jobs=n_jobs)

lle.fit(X_train.loc[0:5000,:])
X_train_lle = lle.transform(X_train)
X_train_lle = pd.DataFrame(data=X_train_lle, index=train_index)

X_validation_lle = lle.transform(X_validation)
X_validation_lle = pd.DataFrame(data=X_validation_lle, index=validation_index)

scatterPlot(X_train_lle, y_train, "Locally Linear Embedding")

图 3-13 显示了使用 LLE 的二维散点图。

使用 LLE 分离观察结果

图 3-13. 使用 LLE 分离观察结果

t-分布随机邻域嵌入

t-分布随机邻域嵌入(t-SNE)是一种非线性降维技术,用于可视化高维数据。t-SNE 通过将每个高维点建模到二维或三维空间中来实现这一目标,使得相似的点模型接近,而不相似的点则模型远离。它通过构建两个概率分布实现此目标,一个是在高维空间中点对的概率分布,另一个是在低维空间中点对的概率分布,使得相似的点具有较高的概率,而不相似的点具有较低的概率。具体来说,t-SNE 最小化了两个概率分布之间的Kullback–Leibler 散度

在 t-SNE 的实际应用中,最好在应用 t-SNE 之前使用另一种降维技术(例如 PCA,正如我们在此处所做的那样)来减少维数。通过先应用另一种降维方法,我们可以减少馈入 t-SNE 的特征中的噪音,并加快算法的计算速度:

# t-SNE
from sklearn.manifold import TSNE

n_components = 2
learning_rate = 300
perplexity = 30
early_exaggeration = 12
init = 'random'
random_state = 2018

tSNE = TSNE(n_components=n_components, learning_rate=learning_rate, \
            perplexity=perplexity, early_exaggeration=early_exaggeration, \
            init=init, random_state=random_state)

X_train_tSNE = tSNE.fit_transform(X_train_PCA.loc[:5000,:9])
X_train_tSNE = pd.DataFrame(data=X_train_tSNE, index=train_index[:5001])

scatterPlot(X_train_tSNE, y_train, "t-SNE")
注意

t-SNE 具有非凸成本函数,这意味着算法的不同初始化会生成不同的结果。不存在稳定的解决方案。

图 3-14 显示了 t-SNE 的二维散点图。

使用 t-SNE 分离观察结果

图 3-14. 使用 t-SNE 进行观察分离

其他降维方法

我们已经涵盖了线性和非线性形式的降维。现在我们将转向不依赖任何几何或距离度量的方法。

字典学习

其中一种方法是字典学习,它学习原始数据的稀疏表示。生成的矩阵称为字典,字典中的向量称为原子。这些原子是简单的二进制向量,由零和一填充。原始数据中的每个实例可以被重构为这些原子的加权和。

假设原始数据中有d个特征和n个字典原子,我们可以有一个欠完备字典,其中n < d,或过完备字典,其中n > d。欠完备字典实现了降维,用较少的向量表示原始数据,这是我们将专注的内容。³

我们将在我们的数字数据集上应用字典学习的小批量版本。与其他降维方法一样,我们将设置成分的数量。我们还将设置批量大小和执行训练的迭代次数。

由于我们想要使用二维散点图来可视化图像,我们将学习一个非常密集的字典,但实际上,我们会使用一个更稀疏的版本:

# Mini-batch dictionary learning

from sklearn.decomposition import MiniBatchDictionaryLearning

n_components = 50
alpha = 1
batch_size = 200
n_iter = 25
random_state = 2018

miniBatchDictLearning = MiniBatchDictionaryLearning( \
                        n_components=n_components, alpha=alpha, \
                        batch_size=batch_size, n_iter=n_iter, \
                        random_state=random_state)

miniBatchDictLearning.fit(X_train.loc[:,:10000])
X_train_miniBatchDictLearning = miniBatchDictLearning.fit_transform(X_train)
X_train_miniBatchDictLearning = pd.DataFrame( \
    data=X_train_miniBatchDictLearning, index=train_index)

X_validation_miniBatchDictLearning = \
    miniBatchDictLearning.transform(X_validation)
X_validation_miniBatchDictLearning = \
    pd.DataFrame(data=X_validation_miniBatchDictLearning, \
    index=validation_index)

scatterPlot(X_train_miniBatchDictLearning, y_train, \
            "Mini-batch Dictionary Learning")

图 3-15 展示了使用字典学习的二维散点图。

使用字典学习进行观察分离

图 3-15. 使用字典学习进行观察分离

独立成分分析

无标签数据的一个常见问题是,有许多独立信号嵌入到我们给定的特征中。使用独立成分分析(ICA),我们可以将这些混合信号分离成它们的各个组成部分。分离完成后,我们可以通过组合生成的各个个体成分的某些组合来重建任何原始特征。ICA 在信号处理任务中广泛应用(例如,在繁忙咖啡馆音频剪辑中识别各个声音)。

以下展示了 ICA 的工作原理:

# Independent Component Analysis
from sklearn.decomposition import FastICA

n_components = 25
algorithm = 'parallel'
whiten = True
max_iter = 100
random_state = 2018

fastICA = FastICA(n_components=n_components, algorithm=algorithm, \
                  whiten=whiten, max_iter=max_iter, random_state=random_state)

X_train_fastICA = fastICA.fit_transform(X_train)
X_train_fastICA = pd.DataFrame(data=X_train_fastICA, index=train_index)

X_validation_fastICA = fastICA.transform(X_validation)
X_validation_fastICA = pd.DataFrame(data=X_validation_fastICA, \
                                    index=validation_index)

scatterPlot(X_train_fastICA, y_train, "Independent Component Analysis")

图 3-16 展示了使用 ICA 的二维散点图。

使用独立成分分析进行观察分离

图 3-16. 使用独立成分分析进行观察分离

结论

在本章中,我们介绍并探讨了多种降维算法,从线性方法如 PCA 和随机投影开始。然后,我们转向非线性方法——也称为流形学习,例如 Isomap、多维尺度分析、LLE 和 t-SNE。我们还涵盖了非基于距离的方法,如字典学习和 ICA。

降维捕捉数据集中最显著的信息,并通过学习数据的潜在结构将其压缩到少量维度,而无需使用任何标签。通过将这些算法应用于 MNIST 数字数据集,我们能够仅使用前两个维度基于其所代表的数字有效地分离图像。

这突显了降维的强大能力。

在 第四章,我们将使用这些降维算法构建一个应用型的无监督学习解决方案。具体来说,我们将重新审视在 第二章 中介绍的欺诈检测问题,并尝试在不使用标签的情况下将欺诈交易与正常交易分离开来。

¹ 手写数字 MNIST 数据库,由 Yann Lecun 提供。

² MNIST 数据集的 Pickled 版本,由 deeplearning.net 提供。

³ 过完备字典有不同的用途,例如图像压缩。

第四章:异常检测

在 第三章 中,我们介绍了核心的降维算法,并探讨了它们在 MNIST 数字数据库中以显著较少的维度捕获最显著信息的能力。即使在仅两个维度下,这些算法也能有意义地分离数字,而无需使用标签。这就是无监督学习算法的力量 — 它们能够学习数据的潜在结构,并在缺乏标签的情况下帮助发现隐藏的模式。

让我们使用这些降维方法构建一个应用的机器学习解决方案。我们将回顾在 第二章 中介绍的问题,并构建一个无需使用标签的信用卡欺诈检测系统。

在现实世界中,欺诈通常不会被发现,只有被捕获的欺诈行为提供了数据集的标签。此外,欺诈模式随时间变化,因此使用欺诈标签构建的监督系统(如我们在 第二章 中构建的系统)变得过时,捕捉到的是历史上的欺诈模式,而不能适应新出现的欺诈模式。

出于这些原因(标签不足和尽快适应新出现的欺诈模式的需求),无监督学习欺诈检测系统备受青睐。

在本章中,我们将使用前一章探索的一些降维算法来构建这样一个解决方案。

信用卡欺诈检测

让我们重新访问来自 第二章 的信用卡交易问题。

准备数据

就像我们在 第二章 中所做的那样,让我们加载信用卡交易数据集,生成特征矩阵和标签数组,并将数据拆分为训练集和测试集。我们不会使用标签来执行异常检测,但我们将使用标签来帮助评估我们构建的欺诈检测系统。

提醒一下,总共有 284,807 笔信用卡交易,其中 492 笔是欺诈交易,具有正面(欺诈)标签为一。其余的是正常交易,具有负面(非欺诈)标签为零。

我们有 30 个特征用于异常检测 — 时间、金额和 28 个主成分。然后,我们将数据集分为一个训练集(包含 190,820 笔交易和 330 笔欺诈案例)和一个测试集(剩余 93,987 笔交易和 162 笔欺诈案例):

# Load datasets
current_path = os.getcwd()
file = '\\datasets\\credit_card_data\\credit_card.csv'
data = pd.read_csv(current_path + file)

dataX = data.copy().drop(['Class'],axis=1)
dataY = data['Class'].copy()

featuresToScale = dataX.columns
sX = pp.StandardScaler(copy=True)
dataX.loc[:,featuresToScale] = sX.fit_transform(dataX[featuresToScale])

X_train, X_test, y_train, y_test = \
    train_test_split(dataX, dataY, test_size=0.33, \
                    random_state=2018, stratify=dataY)

定义异常分数函数

接下来,我们需要定义一个函数来计算每笔交易的异常程度。交易越异常,它被识别为欺诈的可能性就越大,假设欺诈很少且看起来与大多数正常交易不同。

正如我们在前一章讨论的那样,降维算法在试图最小化重建误差的同时减少数据的维度。换句话说,这些算法试图以尽可能好的方式捕捉原始特征的最显著信息,以便从降维特征集重建原始特征集。然而,这些降维算法不能捕捉所有原始特征的信息,因为它们移动到较低维空间;因此,在将这些算法从降维特征集重建回原始维数时会存在一些误差。

在我们的信用卡交易数据集的背景下,算法将在最难建模的交易中产生最大的重建误差——换句话说,那些发生最少且最异常的交易。由于欺诈很少且可能与正常交易不同,欺诈交易应该表现出最大的重建误差。因此,让我们将异常分数定义为重建误差。每笔交易的重建误差是原始特征矩阵与使用降维算法重建的矩阵之间差异平方和。我们将通过整个数据集的差异平方和的最大-最小范围来缩放差异平方和的总和,以使所有重建误差都在零到一的范围内。

具有最大差异平方和的交易将具有接近一的误差,而具有最小差异平方和的交易将具有接近零的误差。

这应该是熟悉的。就像我们在第二章中构建的监督式欺诈检测解决方案一样,降维算法将有效地为每笔交易分配一个介于零和一之间的异常分数。零表示正常,一表示异常(最有可能是欺诈)。

这里是函数:

def anomalyScores(originalDF, reducedDF):
    loss = np.sum((np.array(originalDF)-np.array(reducedDF))**2, axis=1)
    loss = pd.Series(data=loss,index=originalDF.index)
    loss = (loss-np.min(loss))/(np.max(loss)-np.min(loss))
    return loss

定义评估指标

尽管我们不会使用欺诈标签来构建无监督的欺诈检测解决方案,但我们将使用这些标签来评估我们开发的无监督解决方案。这些标签将帮助我们了解这些解决方案捕捉已知欺诈模式的效果如何。

就像我们在第二章中所做的那样,我们将使用精确-召回曲线、平均精度和 auROC 作为我们的评估指标。

这里是将绘制这些结果的函数:

def plotResults(trueLabels, anomalyScores, returnPreds = False):
    preds = pd.concat([trueLabels, anomalyScores], axis=1)
    preds.columns = ['trueLabel', 'anomalyScore']
    precision, recall, thresholds = \
        precision_recall_curve(preds['trueLabel'],preds['anomalyScore'])
    average_precision = \
        average_precision_score(preds['trueLabel'],preds['anomalyScore'])

    plt.step(recall, precision, color='k', alpha=0.7, where='post')
    plt.fill_between(recall, precision, step='post', alpha=0.3, color='k')

    plt.xlabel('Recall')
    plt.ylabel('Precision')
    plt.ylim([0.0, 1.05])
    plt.xlim([0.0, 1.0])

    plt.title('Precision-Recall curve: Average Precision = \
 {0:0.2f}'.format(average_precision))

    fpr, tpr, thresholds = roc_curve(preds['trueLabel'], \
                                     preds['anomalyScore'])
    areaUnderROC = auc(fpr, tpr)

    plt.figure()
    plt.plot(fpr, tpr, color='r', lw=2, label='ROC curve')
    plt.plot([0, 1], [0, 1], color='k', lw=2, linestyle='--')
    plt.xlim([0.0, 1.0])
    plt.ylim([0.0, 1.05])
    plt.xlabel('False Positive Rate')
    plt.ylabel('True Positive Rate')
    plt.title('Receiver operating characteristic: \
 Area under the curve = {0:0.2f}'.format(areaUnderROC))
    plt.legend(loc="lower right")
    plt.show()

    if returnPreds==True:
        return preds
注意

欺诈标签和评估指标将帮助我们评估无监督欺诈检测系统在捕捉已知欺诈模式(我们过去已经捕捉到并有标签的欺诈)方面的表现如何。

然而,我们将无法评估无监督欺诈检测系统在捕捉未知欺诈模式方面的表现如何。换句话说,数据集中可能存在被错误标记为非欺诈的欺诈行为,因为金融公司从未发现它们。

正如你可能已经看到的那样,无监督学习系统比监督学习系统更难评估。通常,无监督学习系统是通过其捕捉已知欺诈模式的能力来评判的。这是一个不完整的评估;更好的评估指标应该是评估它们在识别未知欺诈模式方面的能力,无论是在过去还是在未来。

由于我们无法返回金融公司,并要求他们评估我们识别出的任何未知欺诈模式,因此我们将仅基于它们如何检测已知欺诈模式来评估这些无监督系统。在评估结果时,牢记这一限制非常重要。

定义绘图函数

我们将重用第三章中的散点图函数来展示降维算法在前两个维度上实现的点的分离情况:

def scatterPlot(xDF, yDF, algoName):
    tempDF = pd.DataFrame(data=xDF.loc[:,0:1], index=xDF.index)
    tempDF = pd.concat((tempDF,yDF), axis=1, join="inner")
    tempDF.columns = ["First Vector", "Second Vector", "Label"]
    sns.lmplot(x="First Vector", y="Second Vector", hue="Label", \
               data=tempDF, fit_reg=False)
    ax = plt.gca()
    ax.set_title("Separation of Observations using "+algoName)

普通 PCA 异常检测

在第三章中,我们演示了 PCA 如何仅通过少数几个主成分就捕获了 MNIST 数字数据集中的大部分信息,远少于原始维度。事实上,仅通过两个维度,就可以根据它们展示的数字将图像明显地分成不同的组。

基于这一概念,我们现在将使用 PCA 来学习信用卡交易数据集的潜在结构。一旦学习了这种结构,我们将使用学习模型来重构信用卡交易,然后计算重构交易与原始交易的差异。那些 PCA 重构效果最差的交易是最异常的(也最可能是欺诈性的)。

注意

请记住,我们拥有的信用卡交易数据集中的特征已经是 PCA 的输出结果 — 这是金融公司提供给我们的。然而,对于已经降维的数据集进行 PCA 异常检测并没有什么特别的。我们只需将给定的原始主成分视为原始特征即可。

今后,我们将称我们得到的原始主成分为原始特征。未来任何对主成分的提及都将指的是 PCA 过程中的主成分,而不是我们最初获得的原始特征。

让我们从更深入地理解 PCA 及其在异常检测中的作用开始。正如我们所定义的,异常检测依赖于重构误差。我们希望罕见交易(最有可能是欺诈的交易)的重构误差尽可能高,而其余交易的重构误差尽可能低。

对于 PCA,重构误差主要取决于我们保留和用于重构原始交易的主要成分数量。我们保留的主要成分越多,PCA 在学习原始交易的潜在结构方面表现越好。

然而,需要平衡。如果我们保留太多主要成分,PCA 可能会过于容易地重构原始交易,以至于所有交易的重构误差都将最小化。如果我们保留的主要成分太少,PCA 可能无法充分重构任何原始交易,甚至是正常的非欺诈性交易。

让我们寻找保留以构建良好的欺诈检测系统的正确主成分数。

PCA 成分等于原始维度数

首先,让我们考虑一些事情。如果我们使用 PCA 生成与原始特征数相同数量的主要成分,我们能执行异常检测吗?

如果你仔细思考,答案应该是显而易见的。回顾我们在前一章对 MNIST 数字数据集的 PCA 示例。

当主要成分的数量等于原始维度的数量时,PCA 会捕获数据中近乎 100%的方差/信息,因为它生成主要成分。因此,当 PCA 从主要成分重构交易时,所有交易(无论是欺诈还是正常的)的重构误差都将太小。我们将无法区分罕见交易和正常交易,换句话说,异常检测将效果不佳。

为了突出这一点,让我们应用 PCA 生成与原始特征数相同数量的主要成分(对于我们的信用卡交易数据集为 30 个)。这是通过 Scikit-Learn 中的fit_transform函数实现的。

为了从我们生成的主要成分中重构原始交易,我们将使用 Scikit-Learn 中的inverse_transform函数:

# 30 principal components
from sklearn.decomposition import PCA

n_components = 30
whiten = False
random_state = 2018

pca = PCA(n_components=n_components, whiten=whiten, \
          random_state=random_state)

X_train_PCA = pca.fit_transform(X_train)
X_train_PCA = pd.DataFrame(data=X_train_PCA, index=X_train.index)

X_train_PCA_inverse = pca.inverse_transform(X_train_PCA)
X_train_PCA_inverse = pd.DataFrame(data=X_train_PCA_inverse, \
                                   index=X_train.index)

scatterPlot(X_train_PCA, y_train, "PCA")

图 4-1 展示了使用 PCA 的前两个主要成分对交易进行分离的图表。

使用正常 PCA 和 30 个主要成分分离观察

图 4-1. 使用正常 PCA 和 30 个主要成分分离观察

让我们计算精确率-召回率曲线和 ROC 曲线:

anomalyScoresPCA = anomalyScores(X_train, X_train_PCA_inverse)
preds = plotResults(y_train, anomalyScoresPCA, True)

具有平均精度为 0.11,这是一个较差的欺诈检测解决方案(参见图 4-2)。它几乎无法捕捉到欺诈行为。

使用正常 PCA 和 30 个主成分的结果

图 4-2. 使用 30 个主成分的结果

搜索最佳主成分数量

现在,让我们通过减少 PCA 生成的主成分数量来执行一些实验,并评估欺诈检测结果。我们需要基于 PCA 的欺诈检测解决方案在罕见情况下有足够的误差,以便能够有效地区分欺诈案例和正常案例。但是误差不能对所有交易的罕见和正常交易都过低或过高,以至于它们几乎无法区分。

经过一些实验(可以使用GitHub 代码执行),我们发现 27 个主成分是此信用卡交易数据集的最佳数量。

图 4-3 展示了使用 PCA 的前两个主成分分离交易的图表。

使用正常 PCA 和 27 个主成分分离观察

图 4-3. 使用正常 PCA 和 27 个主成分分离观察

图 4-4 展示了精度-召回曲线、平均精度和 auROC 曲线。

使用正常 PCA 和 27 个主成分的结果

图 4-4. 使用正常 PCA 和 27 个主成分的结果

正如你所看到的,我们能够以 75%的精度捕捉到 80%的欺诈行为。考虑到训练集中有 190,820 笔交易,其中只有 330 笔是欺诈交易,这是非常令人印象深刻的结果。

使用 PCA,我们计算了这 190,820 笔交易中每笔交易的重建误差。如果我们按照重建误差(也称为异常分数)的降序对这些交易进行排序,并从列表中提取前 350 笔交易,我们可以看到其中有 264 笔交易是欺诈的。

这是 75%的精度。此外,我们从我们选择的 350 笔交易中捕捉到的 264 笔交易代表了训练集中 80%的总欺诈行为(330 笔欺诈案例中的 264 笔)。而且,请记住,这是一个真正的无监督欺诈检测解决方案,没有使用标签。

下面是突出显示此问题的代码:

preds.sort_values(by="anomalyScore",ascending=False,inplace=True)
cutoff = 350
predsTop = preds[:cutoff]
print("Precision: ",np.round(predsTop. \
            anomalyScore[predsTop.trueLabel==1].count()/cutoff,2))
print("Recall: ",np.round(predsTop. \
            anomalyScore[predsTop.trueLabel==1].count()/y_train.sum(),2))

下面的代码总结了结果:

Precision: 0.75
Recall: 0.8
Fraud Caught out of 330 Cases: 264

尽管这已经是一个相当好的解决方案,但让我们尝试使用其他降维方法开发欺诈检测系统。

稀疏 PCA 异常检测

让我们尝试使用稀疏 PCA 设计一个欺诈检测解决方案。回想一下,稀疏 PCA 类似于普通 PCA,但提供一个更稀疏的版本;换句话说,稀疏 PCA 提供了主成分的稀疏表示。

我们仍然需要指定所需的主成分数量,但我们还必须设置控制稀疏程度的 alpha 参数。在搜索最佳稀疏 PCA 欺诈检测解决方案时,我们将尝试不同的主成分值和 alpha 参数值。

请注意,对于普通 PCA,Scikit-Learn 使用 fit_transform 函数生成主成分,并使用 inverse_transform 函数从主成分重构原始维度。利用这两个函数,我们能够计算原始特征集和从 PCA 派生的重构特征集之间的重构误差。

不幸的是,Scikit-Learn 并未为稀疏 PCA 提供 inverse_transform 函数。因此,在执行稀疏 PCA 后,我们必须自行重构原始维度。

让我们首先生成具有 27 个主成分和默认 alpha 参数 0.0001 的稀疏 PCA 矩阵:

# Sparse PCA
from sklearn.decomposition import SparsePCA

n_components = 27
alpha = 0.0001
random_state = 2018
n_jobs = -1

sparsePCA = SparsePCA(n_components=n_components, \
                alpha=alpha, random_state=random_state, n_jobs=n_jobs)

sparsePCA.fit(X_train.loc[:,:])
X_train_sparsePCA = sparsePCA.transform(X_train)
X_train_sparsePCA = pd.DataFrame(data=X_train_sparsePCA, index=X_train.index)

scatterPlot(X_train_sparsePCA, y_train, "Sparse PCA")

图 4-5 显示了稀疏 PCA 的散点图。

使用稀疏 PCA 和 27 个主成分的观察分离

图 4-5. 使用稀疏 PCA 和 27 个主成分的观察分离

现在,让我们通过稀疏 PCA 矩阵的简单矩阵乘法(包含 190,820 个样本和 27 个维度)和 Scikit-Learn 库提供的稀疏 PCA 成分(一个 27 x 30 矩阵)生成稀疏 PCA 矩阵的原始维度。这样可以创建一个原始尺寸的矩阵(一个 190,820 x 30 矩阵)。我们还需要将每个原始特征的均值添加到这个新矩阵中,然后就完成了。

利用这个新推导出的逆矩阵,我们可以像对待普通 PCA 那样计算重构误差(异常分数):

X_train_sparsePCA_inverse = np.array(X_train_sparsePCA). \
    dot(sparsePCA.components_) + np.array(X_train.mean(axis=0))
X_train_sparsePCA_inverse = \
    pd.DataFrame(data=X_train_sparsePCA_inverse, index=X_train.index)

anomalyScoresSparsePCA = anomalyScores(X_train, X_train_sparsePCA_inverse)
preds = plotResults(y_train, anomalyScoresSparsePCA, True)

现在,让我们生成精确率-召回率曲线和 ROC 曲线。

使用稀疏 PCA 和 27 个主成分的结果

图 4-6. 使用稀疏 PCA 和 27 个主成分的结果

如图 4-6 所示,结果与普通 PCA 的结果完全相同。这是预期的,因为普通 PCA 和稀疏 PCA 非常相似——后者只是前者的稀疏表示。

使用 GitHub 代码,您可以通过更改生成的主成分数量和 alpha 参数来进行实验,但根据我们的实验,这是最佳的基于稀疏 PCA 的欺诈检测解决方案。

Kernel PCA 异常检测

现在让我们设计一个欺诈检测解决方案,使用核 PCA,这是 PCA 的非线性形式,如果欺诈交易与非欺诈交易不是线性可分的,它将非常有用。

我们需要指定要生成的组件数量,内核(我们将使用 RBF 内核,就像我们在上一章中做的那样),以及 gamma(默认情况下设置为 1/n_features,因此在我们的情况下为 1/30)。我们还需要将fit_inverse_transform设置为true,以应用 Scikit-Learn 提供的内置inverse_transform函数。

最后,由于核 PCA 在训练中非常昂贵,我们将仅在交易数据集的前两千个样本上进行训练。这并非理想选择,但为了快速进行实验,这是必要的。

我们将使用这个训练来转换整个训练集并生成主成分。然后,我们将使用inverse_transform函数从由核 PCA 导出的主成分重新创建原始维度:

# Kernel PCA
from sklearn.decomposition import KernelPCA

n_components = 27
kernel = 'rbf'
gamma = None
fit_inverse_transform = True
random_state = 2018
n_jobs = 1

kernelPCA = KernelPCA(n_components=n_components, kernel=kernel, \
                gamma=gamma, fit_inverse_transform= \
                fit_inverse_transform, n_jobs=n_jobs, \
                random_state=random_state)

kernelPCA.fit(X_train.iloc[:2000])
X_train_kernelPCA = kernelPCA.transform(X_train)
X_train_kernelPCA = pd.DataFrame(data=X_train_kernelPCA, \
                                 index=X_train.index)

X_train_kernelPCA_inverse = kernelPCA.inverse_transform(X_train_kernelPCA)
X_train_kernelPCA_inverse = pd.DataFrame(data=X_train_kernelPCA_inverse, \
                                         index=X_train.index)

scatterPlot(X_train_kernelPCA, y_train, "Kernel PCA")

图 4-7 显示了核 PCA 的散点图。

使用核 PCA 和 27 个主成分分离观察

图 4-7. 使用核 PCA 和 27 个主成分分离观察

现在,让我们计算异常分数并打印结果。

使用核 PCA 和 27 个主成分的结果

图 4-8. 使用核 PCA 和 27 个主成分的结果

如图 4-8 所示,其结果远不如普通 PCA 和稀疏 PCA。虽然进行核 PCA 实验是值得的,但考虑到我们有更好的性能解决方案,我们不会将其用于欺诈检测。

我们不会使用 SVD 构建异常检测解决方案,因为其解决方案与普通 PCA 非常相似。这是预期的——PCA 和 SVD 密切相关。

反而,让我们转向基于随机投影的异常检测。

高斯随机投影异常检测

现在,让我们尝试使用高斯随机投影开发欺诈检测解决方案。请记住,我们可以设置我们想要的组件数量或eps参数,后者控制基于 Johnson-Lindenstrauss 引理导出的嵌入质量。

我们将选择显式设置组件的数量。高斯随机投影训练非常快,因此我们可以在整个训练集上进行训练。

与稀疏 PCA 一样,我们需要推导出自己的inverse_transform函数,因为 Scikit-Learn 没有提供这样的函数:

# Gaussian Random Projection
from sklearn.random_projection import GaussianRandomProjection

n_components = 27
eps = None
random_state = 2018

GRP = GaussianRandomProjection(n_components=n_components, \
                               eps=eps, random_state=random_state)

X_train_GRP = GRP.fit_transform(X_train)
X_train_GRP = pd.DataFrame(data=X_train_GRP, index=X_train.index)

scatterPlot(X_train_GRP, y_train, "Gaussian Random Projection")

图 4-9 显示了高斯随机投影的散点图。图 4-10 显示了高斯随机投影的结果。

使用高斯随机投影和 27 个分量分离观察结果

图 4-9. 使用高斯随机投影和 27 个分量分离观察结果

使用高斯随机投影和 27 个分量的结果

图 4-10. 使用高斯随机投影和 27 个分量的结果

这些结果很差,因此我们不会使用高斯随机投影进行欺诈检测。

稀疏随机投影异常检测

让我们尝试使用稀疏随机投影设计一个欺诈检测解决方案。

我们将指定我们需要的分量数量(而不是设置 eps 参数)。而且,就像使用高斯随机投影一样,我们将使用我们自己的 inverse_transform 函数从稀疏随机投影派生的分量中创建原始维度:

# Sparse Random Projection

from sklearn.random_projection import SparseRandomProjection

n_components = 27
density = 'auto'
eps = .01
dense_output = True
random_state = 2018

SRP = SparseRandomProjection(n_components=n_components, \
        density=density, eps=eps, dense_output=dense_output, \
                                random_state=random_state)

X_train_SRP = SRP.fit_transform(X_train)
X_train_SRP = pd.DataFrame(data=X_train_SRP, index=X_train.index)

scatterPlot(X_train_SRP, y_train, "Sparse Random Projection")

图 4-11 显示了稀疏随机投影的散点图。 图 4-12 展示了稀疏随机投影的结果。

使用稀疏随机投影和 27 个分量分离观察结果

图 4-11. 使用稀疏随机投影和 27 个分量分离观察结果

使用稀疏随机投影和 27 个分量的结果

图 4-12. 使用稀疏随机投影和 27 个分量的结果

和高斯随机投影一样,这些结果很差。让我们继续使用其他降维方法构建异常检测系统。

非线性异常检测

到目前为止,我们已经使用了线性降维方法开发了欺诈检测解决方案,如常规 PCA、稀疏 PCA、高斯随机投影和稀疏随机投影。我们还使用了非线性版本的 PCA——核 PCA。

到目前为止,PCA 是迄今为止最好的解决方案。

我们可以转向非线性降维算法,但这些算法的开源版本运行非常缓慢,不适合快速欺诈检测。因此,我们将跳过这一步,直接转向非距离度量的降维方法:字典学习和独立分量分析。

字典学习异常检测

让我们使用字典学习来开发一个欺诈检测解决方案。回想一下,在字典学习中,算法学习原始数据的稀疏表示。使用学习字典中的向量,可以将原始数据中的每个实例重构为这些学习向量的加权和。

对于异常检测,我们希望学习一个欠完备字典,使得字典中的向量数量少于原始维度。在这个约束条件下,更容易重构出频繁发生的正常交易,但更难构建出罕见的欺诈交易。

在我们的情况下,我们将生成 28 个向量(或成分)。为了学习字典,我们将提供 10 个批次,每个批次包含 200 个样本。

我们也需要使用我们自己的inverse_transform函数:

# Mini-batch dictionary learning
from sklearn.decomposition import MiniBatchDictionaryLearning

n_components = 28
alpha = 1
batch_size = 200
n_iter = 10
random_state = 2018

miniBatchDictLearning = MiniBatchDictionaryLearning( \
    n_components=n_components, alpha=alpha, batch_size=batch_size, \
    n_iter=n_iter, random_state=random_state)

miniBatchDictLearning.fit(X_train)
X_train_miniBatchDictLearning = \
    miniBatchDictLearning.fit_transform(X_train)
X_train_miniBatchDictLearning = \
    pd.DataFrame(data=X_train_miniBatchDictLearning, index=X_train.index)

scatterPlot(X_train_miniBatchDictLearning, y_train, \
            "Mini-batch Dictionary Learning")

图 4-13 展示了字典学习的散点图。图 4-14 展示了字典学习的结果。

使用字典学习和 28 个成分的观测分离

图 4-13. 使用字典学习和 28 个成分的观测分离

使用字典学习和 28 个成分的结果

图 4-14. 使用字典学习和 28 个成分的结果

这些结果远优于核 PCA、高斯随机投影和稀疏随机投影的结果,但与普通 PCA 的结果不相上下。

您可以在 GitHub 上尝试代码,看看是否能改进这个解决方案,但目前来看,PCA 仍然是这个信用卡交易数据集的最佳欺诈检测解决方案。

ICA 异常检测

让我们使用 ICA 设计我们的最后一个欺诈检测解决方案。

我们需要指定成分的数量,我们将设置为 27。Scikit-Learn 提供了一个inverse_transform函数,因此我们不需要使用自己的函数:

# Independent Component Analysis

from sklearn.decomposition import FastICA

n_components = 27
algorithm = 'parallel'
whiten = True
max_iter = 200
random_state = 2018

fastICA = FastICA(n_components=n_components, \
    algorithm=algorithm, whiten=whiten, max_iter=max_iter, \
    random_state=random_state)

X_train_fastICA = fastICA.fit_transform(X_train)
X_train_fastICA = pd.DataFrame(data=X_train_fastICA, index=X_train.index)

X_train_fastICA_inverse = fastICA.inverse_transform(X_train_fastICA)
X_train_fastICA_inverse = pd.DataFrame(data=X_train_fastICA_inverse, \
                                       index=X_train.index)

scatterPlot(X_train_fastICA, y_train, "Independent Component Analysis")

图 4-15 展示了 ICA 的散点图。图 4-16 展示了 ICA 的结果。

使用字典学习和 28 个成分的观测分离

图 4-15. 使用 ICA 和 27 个成分的观测分离

独立成分分析和 27 个成分的结果

图 4-16. 使用 ICA 和 27 个成分的结果

这些结果与普通 PCA 的结果相同。ICA 的欺诈检测解决方案与我们迄今为止开发的最佳解决方案相匹配。

测试集上的欺诈检测

现在,为了评估我们的欺诈检测解决方案,让我们将其应用于前所未见的测试集。我们将对我们开发的前三种解决方案进行评估:普通 PCA、ICA 和字典学习。我们不会使用稀疏 PCA,因为它与普通 PCA 解决方案非常相似。

普通 PCA 在测试集上的异常检测

让我们从普通 PCA 开始。我们将使用 PCA 算法从训练集学习到的 PCA 嵌入,并用此转换测试集。然后,我们将使用 Scikit-Learn 的inverse_transform函数从测试集的主成分矩阵重新创建原始维度。

通过比较原始测试集矩阵和新重建的矩阵,我们可以计算异常分数(正如我们在本章中多次做过的):

# PCA on Test Set
X_test_PCA = pca.transform(X_test)
X_test_PCA = pd.DataFrame(data=X_test_PCA, index=X_test.index)

X_test_PCA_inverse = pca.inverse_transform(X_test_PCA)
X_test_PCA_inverse = pd.DataFrame(data=X_test_PCA_inverse, \
                                  index=X_test.index)

scatterPlot(X_test_PCA, y_test, "PCA")

图 4-17 显示了在测试集上使用 PCA 的散点图。图 4-18 显示了在测试集上使用 PCA 的结果。

在测试集上使用 PCA 和 27 个分量的观察结果分离

图 4-17. 在测试集上使用 PCA 和 27 个分量进行观察结果分离

在测试集上使用 PCA 和 27 个分量的结果

图 4-18. 在测试集上使用 PCA 和 27 个分量的结果

这些是令人印象深刻的结果。我们能够在测试集中捕捉到 80% 的已知欺诈,精度为 80%——而且全部不使用任何标签。

在测试集上的 ICA 异常检测

现在让我们转向 ICA,并在测试集上进行欺诈检测:

# Independent Component Analysis on Test Set
X_test_fastICA = fastICA.transform(X_test)
X_test_fastICA = pd.DataFrame(data=X_test_fastICA, index=X_test.index)

X_test_fastICA_inverse = fastICA.inverse_transform(X_test_fastICA)
X_test_fastICA_inverse = pd.DataFrame(data=X_test_fastICA_inverse, \
                                      index=X_test.index)

scatterPlot(X_test_fastICA, y_test, "Independent Component Analysis")

图 4-19 显示了在测试集上使用 ICA 的散点图。图 4-20 显示了在测试集上使用 ICA 的结果。

在测试集上使用独立分量分析和 27 个分量的观察结果分离

图 4-19. 在测试集上使用 ICA 和 27 个分量进行观察结果分离

在测试集上使用独立分量分析和 27 个分量的结果

图 4-20. 在测试集上使用 ICA 和 27 个分量的结果

结果与常规 PCA 完全相同,因此令人印象深刻。

在测试集上的字典学习异常检测

现在让我们转向字典学习,虽然它的表现不如常规 PCA 和 ICA,但仍值得最后一看:

X_test_miniBatchDictLearning = miniBatchDictLearning.transform(X_test)
X_test_miniBatchDictLearning = \
    pd.DataFrame(data=X_test_miniBatchDictLearning, index=X_test.index)

scatterPlot(X_test_miniBatchDictLearning, y_test, \
            "Mini-batch Dictionary Learning")

图 4-21 显示了在测试集上使用字典学习的散点图。图 4-22 显示了在测试集上使用字典学习的结果。

在测试集上使用字典学习和 28 个分量的观察结果分离

图 4-21. 在测试集上使用字典学习和 28 个分量进行观察结果分离

在测试集上使用字典学习和 28 个分量的结果

图 4-22. 在测试集上使用字典学习和 28 个分量的结果

尽管结果并不糟糕——我们可以用 20% 的精度捕捉到 80% 的欺诈——但与常规 PCA 和 ICA 的结果相比差距很大。

结论

在本章中,我们使用了上一章的核心降维算法来针对第二章的信用卡交易数据集开发欺诈检测解决方案。

在第二章中,我们使用标签构建了一个欺诈检测解决方案,但是在本章的训练过程中我们没有使用任何标签。换句话说,我们使用无监督学习构建了一个应用型欺诈检测系统。

虽然并非所有降维算法在这个信用卡交易数据集上表现良好,但是有两个表现非常出色——普通 PCA 和 ICA。

普通的 PCA 和 ICA 可以捕捉到 80%以上的已知欺诈,并且精度达到 80%。相比之下,第二章中表现最佳的基于有监督学习的欺诈检测系统几乎可以捕捉到 90%的已知欺诈,并且精度达到 80%。无监督欺诈检测系统在捕捉已知欺诈模式方面只比有监督系统稍微差一点。

请记住,无监督的欺诈检测系统在训练过程中不需要标签,能够很好地适应不断变化的欺诈模式,并且可以发现以前未被发现的欺诈行为。考虑到这些额外的优势,无监督学习的解决方案通常会比有监督学习的解决方案更好地捕捉到未来已知和未知或新出现的欺诈模式,尽管将两者结合使用效果最佳。

现在我们已经涵盖了降维和异常检测,让我们来探讨聚类,这是无监督学习领域的另一个重要概念。

第五章:聚类

在第三章中,我们介绍了无监督学习中最重要的降维算法,并突出它们密集捕捉信息的能力。在第四章中,我们使用了降维算法构建了一个异常检测系统。具体来说,我们应用这些算法来检测信用卡欺诈,而不使用任何标签。这些算法学习了信用卡交易中的潜在结构。然后,我们根据重构误差将正常交易与罕见的、潜在的欺诈交易分开。

在本章中,我们将在无监督学习的概念基础上进一步讨论聚类,它试图根据相似性将对象组合在一起。聚类在不使用任何标签的情况下实现这一点,比较一个观察数据与其他观察数据的相似性并进行分组。

聚类有许多应用。例如,在信用卡欺诈检测中,聚类可以将欺诈交易分组在一起,与正常交易分开。或者,如果我们的数据集中只有少数几个标签的观察结果,我们可以使用聚类首先对观察结果进行分组(而不使用标签)。然后,我们可以将少数标记观察结果的标签转移到同一组内的其余观察结果上。这是迁移学习的一种形式,是机器学习中一个快速发展的领域。

在在线购物、零售、市场营销、社交媒体、电影、音乐、书籍、约会等领域,聚类可以根据用户行为将相似的人群组合在一起。一旦建立了这些群体,业务用户就能更好地洞察他们的用户群体,并为每个独特的群体制定有针对性的业务战略。

就像我们在降维中所做的那样,让我们先在本章中介绍概念,然后在下一章中构建一个应用的无监督学习解决方案。

MNIST 手写数字数据集

为了简化问题,我们将继续使用我们在第三章中介绍的手写数字 MNIST 图像数据集。

数据准备

让我们首先加载必要的库:

# Import libraries
'''Main'''
import numpy as np
import pandas as pd
import os, time
import pickle, gzip

'''Data Viz'''
import matplotlib.pyplot as plt
import seaborn as sns
color = sns.color_palette()
import matplotlib as mpl

%matplotlib inline

'''Data Prep and Model Evaluation'''
from sklearn import preprocessing as pp
from sklearn.model_selection import train_test_split
from sklearn.metrics import precision_recall_curve, average_precision_score
from sklearn.metrics import roc_curve, auc, roc_auc_score

接下来,让我们加载数据集并创建 Pandas 数据框:

# Load the datasets
current_path = os.getcwd()
file = '\\datasets\\mnist_data\\mnist.pkl.gz'

f = gzip.open(current_path+file, 'rb')
train_set, validation_set, test_set = pickle.load(f, encoding='latin1')
f.close()

X_train, y_train = train_set[0], train_set[1]
X_validation, y_validation = validation_set[0], validation_set[1]
X_test, y_test = test_set[0], test_set[1]

# Create Pandas DataFrames from the datasets
train_index = range(0,len(X_train))
validation_index = range(len(X_train), \
                         len(X_train)+len(X_validation))
test_index = range(len(X_train)+len(X_validation), \
                   len(X_train)+len(X_validation)+len(X_test))

X_train = pd.DataFrame(data=X_train,index=train_index)
y_train = pd.Series(data=y_train,index=train_index)

X_validation = pd.DataFrame(data=X_validation,index=validation_index)
y_validation = pd.Series(data=y_validation,index=validation_index)

X_test = pd.DataFrame(data=X_test,index=test_index)
y_test = pd.Series(data=y_test,index=test_index)

聚类算法

在执行聚类之前,我们将使用 PCA 减少数据的维度。正如在第三章中所示,降维算法捕捉了原始数据中的显著信息,同时减少了数据集的大小。

当我们从高维度向低维度移动时,数据集中的噪声会被最小化,因为降维算法(在本例中是 PCA)需要捕捉原始数据的最重要的方面,而不能将注意力放在频繁出现的元素(例如数据集中的噪声)上。

记得降维算法在学习数据中的潜在结构方面非常强大。在 第三章 中,我们展示了仅使用两个维度进行降维后,可以根据 MNIST 图像所显示的数字有意义地分开它们。

现在让我们再次将 PCA 应用于 MNIST 数据集:

# Principal Component Analysis
from sklearn.decomposition import PCA

n_components = 784
whiten = False
random_state = 2018

pca = PCA(n_components=n_components, whiten=whiten, \
          random_state=random_state)

X_train_PCA = pca.fit_transform(X_train)
X_train_PCA = pd.DataFrame(data=X_train_PCA, index=train_index)

尽管我们没有降低维度,但我们将在聚类阶段指定我们将使用的主成分数目,从而有效地降低维度。

现在让我们转向聚类。三个主要的聚类算法是 k-means层次聚类DBSCAN。我们将逐一介绍并探讨每一个。

k-Means

聚类的目标是在数据集中识别出不同的组,使得组内的观察值彼此相似,但与其他组的观察值不同。在 k-means 聚类中,我们指定所需的簇数 k,算法将每个观察值精确分配到这 k 个簇中的一个。该算法通过最小化 簇内变化(也称为 惯性)来优化这些组,从而使得所有 k 个簇内的变化总和尽可能小。

不同的 k-means 运行会导致略有不同的簇分配,因为 k-means 随机地将每个观察值分配给 k 个簇中的一个来启动聚类过程。k-means 通过这种随机初始化来加速聚类过程。在此随机初始化后,k-means 将重新将观察值分配给不同的簇,以尽量减小每个观察值与其簇中心点(或 质心)之间的欧氏距离。这种随机初始化是随机性的来源,导致从一个 k-means 运行到另一个运行略有不同的聚类分配。

典型情况下,k-means 算法会进行多次运行,并选择具有最佳分离效果的运行,这里分离效果定义为所有 k 个簇内部变化总和最低。

k-Means 惯性

让我们介绍算法。我们需要设置我们想要的簇数目 (n_clusters),我们希望执行的初始化次数 (n_init),算法将运行以重新分配观察值以最小化惯性的最大迭代次数 (max_iter),以及声明收敛的容差 (tol)。

我们将保留默认值,即初始化次数(10)、最大迭代次数(300)和容差(0.0001)。此外,目前我们将从 PCA 中选择前 100 个主成分 (cutoff)。为了测试我们指定的簇数目如何影响惯性度量,让我们对簇大小从 2 到 20 运行 k-means,并记录每个簇的惯性。

这是代码:

# k-means - Inertia as the number of clusters varies
from sklearn.cluster import KMeans

n_clusters = 10
n_init = 10
max_iter = 300
tol = 0.0001
random_state = 2018
n_jobs = 2

kMeans_inertia = pd.DataFrame(data=[],index=range(2,21), \
                              columns=['inertia'])
for n_clusters in range(2,21):
    kmeans = KMeans(n_clusters=n_clusters, n_init=n_init, \
                max_iter=max_iter, tol=tol, random_state=random_state, \
                n_jobs=n_jobs)

    cutoff = 99
    kmeans.fit(X_train_PCA.loc[:,0:cutoff])
    kMeans_inertia.loc[n_clusters] = kmeans.inertia_

如图 5-1 所示,随着群集数量的增加,惯性在减少。这是有道理的。群集越多,每个群集内观察结果的同质性就越大。然而,比起更多的群集,较少的群集更容易处理,因此在运行k-means 时找到正确的群集数量是一个重要考虑因素。

群集大小为 2 至 20 的*k*-means 惯性

图 5-1. 群集大小为 2 至 20 的k-means 惯性

评估聚类结果

为了演示k-means 的工作原理以及增加群集数量如何导致更加同质的群集,让我们定义一个函数来分析我们每次实验的结果。聚类算法生成的群集分配将存储在名为clusterDF的 Pandas DataFrame 中。

让我们统计每个群集中的观察结果数量,并将这些存储在名为countByCluster的 Pandas DataFrame 中:

def analyzeCluster(clusterDF, labelsDF):
    countByCluster = \
        pd.DataFrame(data=clusterDF['cluster'].value_counts())
    countByCluster.reset_index(inplace=True,drop=False)
    countByCluster.columns = ['cluster','clusterCount']

接下来,让我们将clusterDF与称为labelsDF的真实标签数组结合起来:

    preds = pd.concat([labelsDF,clusterDF], axis=1)
    preds.columns = ['trueLabel','cluster']

让我们还统计训练集中每个真实标签的观察结果数量(这不会改变,但我们需要了解):

    countByLabel = pd.DataFrame(data=preds.groupby('trueLabel').count())

现在,对于每个群集,我们将计算每个不同标签在群集内的观察结果数量。例如,如果给定的群集有三千个观察结果,其中两千可能代表数字二,五百可能代表数字一,三百可能代表数字零,其余的两百可能代表数字九。

一旦我们计算这些,我们将为每个群集存储最频繁出现数字的计数。在上述示例中,我们将为此群集存储两千的计数:

    countMostFreq = \
        pd.DataFrame(data=preds.groupby('cluster').agg( \
                        lambda x:x.value_counts().iloc[0]))
    countMostFreq.reset_index(inplace=True,drop=False)
    countMostFreq.columns = ['cluster','countMostFrequent']

最后,我们将根据每次聚类运行中观察结果在每个群集内的紧密程度来评估每次聚类运行的成功程度。例如,在上述示例中,群集中有两千个观察结果具有相同的标签,总共有三千个观察结果在该群集中。

由于我们理想情况下希望将相似的观察结果聚集在同一个群集中并排除不相似的观察结果,因此这个群集并不理想。

让我们定义聚类的总体准确性,即通过总体训练集观察结果中最频繁出现的观察结果的计数之和除以总观察结果数(即 50,000):

    accuracyDF = countMostFreq.merge(countByCluster, \
                        left_on="cluster",right_on="cluster")
    overallAccuracy = accuracyDF.countMostFrequent.sum()/ \
                        accuracyDF.clusterCount.sum()

我们也可以通过群集评估准确性:

    accuracyByLabel = accuracyDF.countMostFrequent/ \
                        accuracyDF.clusterCount

为了简洁起见,我们将所有这些代码放在一个单独的函数中,可以在GitHub上找到。

k-means 准确性

现在,让我们执行之前的实验,但是不计算惯性,而是根据我们为 MNIST 数字数据集定义的准确性度量来计算群集的整体同质性:

# k-means - Accuracy as the number of clusters varies

n_clusters = 5
n_init = 10
max_iter = 300
tol = 0.0001
random_state = 2018
n_jobs = 2

kMeans_inertia = \
    pd.DataFrame(data=[],index=range(2,21),columns=['inertia'])
overallAccuracy_kMeansDF = \
    pd.DataFrame(data=[],index=range(2,21),columns=['overallAccuracy'])

for n_clusters in range(2,21):
    kmeans = KMeans(n_clusters=n_clusters, n_init=n_init, \
                max_iter=max_iter, tol=tol, random_state=random_state, \
                n_jobs=n_jobs)

    cutoff = 99
    kmeans.fit(X_train_PCA.loc[:,0:cutoff])
    kMeans_inertia.loc[n_clusters] = kmeans.inertia_
    X_train_kmeansClustered = kmeans.predict(X_train_PCA.loc[:,0:cutoff])
    X_train_kmeansClustered = \
        pd.DataFrame(data=X_train_kmeansClustered, index=X_train.index, \
                     columns=['cluster'])

    countByCluster_kMeans, countByLabel_kMeans, countMostFreq_kMeans, \
        accuracyDF_kMeans, overallAccuracy_kMeans, accuracyByLabel_kMeans \
        = analyzeCluster(X_train_kmeansClustered, y_train)

    overallAccuracy_kMeansDF.loc[n_clusters] = overallAccuracy_kMeans

图 5-2 显示了不同群集大小的整体准确性的图表。

簇大小为 2 到 20 的 k-Means 准确性

Figure 5-2. 簇大小为 2 到 20 的 k-means 准确性

如 Figure 5-2 所示,随着簇数的增加,准确性也会提高。换句话说,随着簇数的增加,簇变得更加同质化,因为每个簇变得更小且更紧凑。

按簇计算的准确度差异很大,有些簇表现出高度的同质性,而其他簇则较少。例如,某些簇中超过 90% 的图像具有相同的数字;在其他簇中,少于 50% 的图像具有相同的数字:

0    0.636506
1    0.928505
2    0.848714
3    0.521805
4    0.714337
5    0.950980
6    0.893103
7    0.919040
8    0.404707
9    0.500522
10   0.381526
11   0.587680
12   0.463382
13   0.958046
14   0.870888
15   0.942325
16   0.791192
17   0.843972
18   0.455679
19   0.926480
dtype:  float64

k-Means 和主成分数量

让我们进行另一个实验——这次,让我们评估在聚类算法中使用的主成分数量如何影响簇的同质性(定义为 准确性)。

在之前的实验中,我们使用了一百个主成分,从正常的 PCA 中推导出来。回想一下,MNIST 数字数据集的原始维度是 784。如果 PCA 能够很好地捕捉数据中的基础结构并尽可能紧凑地表示,那么聚类算法将更容易将相似的图像分组在一起,无论是在少量主成分上还是在更多主成分上进行聚类。换句话说,聚类在使用 10 或 50 个主成分时应该和使用一百或几百个主成分时一样好。

让我们来验证这个假设。我们将使用 10、50、100、200、300、400、500、600、700 和 784 个主成分,并评估每个聚类实验的准确性。然后我们将绘制这些结果,看看主成分数量的变化如何影响聚类的准确性:

# k-means - Accuracy as the number of components varies

n_clusters = 20
n_init = 10
max_iter = 300
tol = 0.0001
random_state = 2018
n_jobs = 2

kMeans_inertia = pd.DataFrame(data=[],index=[9, 49, 99, 199, \
                    299, 399, 499, 599, 699, 784],columns=['inertia'])

overallAccuracy_kMeansDF = pd.DataFrame(data=[],index=[9, 49, \
                    99, 199, 299, 399, 499, 599, 699, 784], \
                    columns=['overallAccuracy'])

for cutoffNumber in [9, 49, 99, 199, 299, 399, 499, 599, 699, 784]:
    kmeans = KMeans(n_clusters=n_clusters, n_init=n_init, \
                max_iter=max_iter, tol=tol, random_state=random_state, \
                n_jobs=n_jobs)

    cutoff = cutoffNumber
    kmeans.fit(X_train_PCA.loc[:,0:cutoff])
    kMeans_inertia.loc[cutoff] = kmeans.inertia_
    X_train_kmeansClustered = kmeans.predict(X_train_PCA.loc[:,0:cutoff])
    X_train_kmeansClustered = pd.DataFrame(data=X_train_kmeansClustered, \
                                index=X_train.index, columns=['cluster'])

    countByCluster_kMeans, countByLabel_kMeans, countMostFreq_kMeans, \
        accuracyDF_kMeans, overallAccuracy_kMeans, accuracyByLabel_kMeans \
        = analyzeCluster(X_train_kmeansClustered, y_train)

    overallAccuracy_kMeansDF.loc[cutoff] = overallAccuracy_kMeans

Figure 5-3 显示了不同主成分数量下聚类准确性的图表。

k-means 聚类准确性随主成分数量变化

Figure 5-3. 随着主成分数量变化的 k-means 聚类准确性

这个图表支持我们的假设。随着主成分数量从 10 变化到 784,聚类的准确性保持稳定在约 70% 左右。这也是为什么应该在降维后的数据集上执行聚类的一个原因——聚类算法通常在降维后的数据集上表现更好,无论是在时间还是聚类准确性方面。

对于 MNIST 数据集而言,原始的 784 维度对于聚类算法来说是可以管理的,但是想象一下如果原始数据集的维度是成千上万的话。在这种情况下,进行聚类之前降低维度的理由更加强烈。

在原始数据集上的 k-Means

为了更清楚地说明这一点,让我们在原始数据集上执行聚类,并测量我们传递到聚类算法中的维度数量如何影响聚类准确性。

对于前一节中的 PCA 降维数据集,我们传递给聚类算法的主成分数量变化并不影响聚类准确性,其保持稳定且一致,约为 70%。这对原始数据集也适用吗?

# k-means - Accuracy as the number of components varies
# On the original MNIST data (not PCA-reduced)

n_clusters = 20
n_init = 10
max_iter = 300
tol = 0.0001
random_state = 2018
n_jobs = 2

kMeans_inertia = pd.DataFrame(data=[],index=[9, 49, 99, 199, \
                    299, 399, 499, 599, 699, 784],columns=['inertia'])

overallAccuracy_kMeansDF = pd.DataFrame(data=[],index=[9, 49, \
                    99, 199, 299, 399, 499, 599, 699, 784], \
                    columns=['overallAccuracy'])

for cutoffNumber in [9, 49, 99, 199, 299, 399, 499, 599, 699, 784]:
    kmeans = KMeans(n_clusters=n_clusters, n_init=n_init, \
                max_iter=max_iter, tol=tol, random_state=random_state, \
                n_jobs=n_jobs)

    cutoff = cutoffNumber
    kmeans.fit(X_train.loc[:,0:cutoff])
    kMeans_inertia.loc[cutoff] = kmeans.inertia_
    X_train_kmeansClustered = kmeans.predict(X_train.loc[:,0:cutoff])
    X_train_kmeansClustered = pd.DataFrame(data=X_train_kmeansClustered, \
                                index=X_train.index, columns=['cluster'])

    countByCluster_kMeans, countByLabel_kMeans, countMostFreq_kMeans, \
        accuracyDF_kMeans, overallAccuracy_kMeans, accuracyByLabel_kMeans \
        = analyzeCluster(X_train_kmeansClustered, y_train)

    overallAccuracy_kMeansDF.loc[cutoff] = overallAccuracy_kMeans

图 5-4 显示了在不同原始维度下的聚类准确性。

随着原始维度数量变化的 k-means 聚类准确性

图 5-4. 随着原始维度数量变化的 k-means 聚类准确性

正如图表所示,低维度下的聚类准确性非常低,但仅当维度数量提升至六百时,聚类准确性才接近 70%。

在 PCA 案例中,即使在 10 个维度下,聚类准确性也约为 70%,展示了降维在原始数据集中密集捕捉显著信息的能力。

层次聚类

现在我们来介绍一种叫做层次聚类的第二种聚类方法。这种方法不要求我们预先确定特定数量的簇。相反,我们可以在层次聚类运行完成后选择我们想要的簇的数量。

使用我们数据集中的观察结果,层次聚类算法将构建一个树形图,它可以被描绘为一个倒置的树,叶子位于底部,树干位于顶部。

底部的叶子是数据集中的个别实例。随着我们沿着倒置树向上移动,层次聚类会将这些叶子根据它们彼此的相似程度连接在一起。最相似的实例(或实例组)会更早地连接在一起,而不那么相似的实例则会较晚连接。

通过这个迭代过程,所有实例最终都连接在一起,形成树的单一主干。

这种垂直表示非常有帮助。一旦层次聚类算法运行完成,我们可以查看树状图,并确定我们想要切割树的位置——我们切割得越低,我们留下的个别分支(即更多簇)就越多。如果我们想要更少的簇,我们可以在树状图上部更高处切割,接近这个倒置树顶部的单一主干。

这个垂直切割的位置类似于在k-means 聚类算法中选择k个簇的数量。

聚合式层次聚类

我们将探索的层次聚类版本称为聚合聚类。虽然 Scikit-Learn 有一个库可以实现这一点,但执行速度非常慢。相反,我们选择使用另一个名为fastcluster的层次聚类版本。这个包是一个 C++库,有 Python/SciPy 接口。¹

在本包中我们将使用的主要函数是fastcluster.linkage_vector。这需要几个参数,包括训练矩阵Xmethodmetricmethod可以设置为singlecentroidmedianward,指定用于确定树枝图中新节点到其他节点距离的聚类方案。在大多数情况下,metric应设置为euclidean,并且如果methodcentroidmedianward,则必须为euclidean。有关这些参数的更多信息,请参阅 fastcluster 文档。

让我们为我们的数据设置层次聚类算法。与之前一样,我们将在 PCA 降维的 MNIST 图像数据集的前一百个主成分上训练算法。我们将把method设置为ward(在实验中表现得非常好),metric设置为euclidean

Ward 代表Ward 最小方差法。您可以在在线了解更多关于这种方法的信息。在层次聚类中,Ward 是一个很好的默认选择,但是,根据特定数据集的实际情况进行实验是最好的。

import fastcluster
from scipy.cluster.hierarchy import dendrogram, cophenet
from scipy.spatial.distance import pdist

cutoff = 100
Z = fastcluster.linkage_vector(X_train_PCA.loc[:,0:cutoff], \
                               method='ward', metric='euclidean')
Z_dataFrame = pd.DataFrame(data=Z, \
    columns=['clusterOne','clusterTwo','distance','newClusterSize'])

层次聚类算法将返回一个矩阵Z。该算法将我们的 50,000 个 MNIST 数字数据集中的每个观察视为单点聚类,并且在每次训练迭代中,算法将合并距离最小的两个聚类。

初始时,算法仅合并单点聚类,但随着进行,它将单点或多点聚类与单点或多点聚类合并。最终,通过这个迭代过程,所有的聚类被合并在一起,形成了倒置树(树枝图)的主干。

树枝图

表 5-1 展示了聚类算法生成的 Z 矩阵,显示了算法的成就。

表 5-1. 层次聚类的 Z 矩阵的前几行

clusterOne clusterTwo distance newClusterSize
0 42194.0 43025.0 0.562682 2.0
1 28350.0 37674.0 0.590866 2.0
2 26696.0 44705.0 0.621506 2.0
3 12634.0 32823.0 0.627762 2.0
4 24707.0 43151.0 0.637668 2.0
5 20465.0 24483.0 0.662557 2.0
6 466.0 42098.0 0.664189 2.0
7 46542.0 49961.0 0.665520 2.0
8 2301.0 5732.0 0.671215 2.0
9 37564.0 47668.0 0.675121 2.0
10 3375.0 26243.0 0.685797 2.0
11 15722.0 30368.0 0.686356 2.0
12 21247.0 21575.0 0.694412 2.0
13 14900.0 42486.0 0.696769 2.0
14 30100.0 41908.0 0.699261 2.0
15 12040.0 13254.0 0.701134 2.0
16 10508.0 25434.0 0.708872 2.0
17 30695.0 30757.0 0.710023 2.0
18 31019.0 31033.0 0.712052 2.0
19 36264.0 37285.0 0.713130 2.0

在这个表格中,前两列clusterOneclusterTwo列出了两个簇——可以是单点簇(即原始观测数据)或多点簇——在彼此之间的距离下被合并。第三列distance显示了由我们传入聚类算法的 Ward 方法和euclidean度量计算出的距离。

如你所见,距离是单调递增的。换句话说,最短距离的簇首先合并,然后算法迭代地合并下一个最短距离的簇,直到所有点都合并为顶部树形图中的单一簇。

起初,算法将单点簇合并在一起,形成大小为两个的新簇,如第四列newClusterSize所示。然而,随着算法的进展,算法将大型多点簇与其他大型多点簇合并,如表格 5-2 所示。在最后一次迭代(49,998),两个大型簇合并在一起,形成单一簇——顶部树干,包含所有 50,000 个原始观测数据。

表格 5-2. 分层聚类 Z 矩阵的最后几行

clusterOne clusterTwo distance newClusterSize
49980 99965.0 99972.0 161.106998 5197.0
49981 99932.0 99980.0 172.070003 6505.0
49982 99945.0 99960.0 182.840860 3245.0
49983 99964.0 99976.0 184.475761 3683.0
49984 99974.0 99979.0 185.027847 7744.0
49985 99940.0 99975.0 185.345207 5596.0
49986 99957.0 99967.0 211.854714 5957.0
49987 99938.0 99983.0 215.494857 4846.0
49988 99978.0 99984.0 216.760365 11072.0
49989 99970.0 99973.0 217.355871 4899.0
49990 99969.0 99986.0 225.468298 8270.0
49991 99981.0 99982.0 238.845135 9750.0
49992 99968.0 99977.0 266.146782 5567.0
49993 99985.0 99989.0 270.929453 10495.0
49994 99990.0 99991.0 346.840948 18020.0
49995 99988.0 99993.0 394.365194 21567.0
49996 99987.0 99995.0 425.142387 26413.0
49997 99992.0 99994.0 440.148301 23587.0
49998 99996.0 99997.0 494.383855 50000.0

在这个表格中,你可能对clusterOneclusterTwo的条目感到有些困惑。例如,在最后一行——49,998 行——cluster 99,996 与 cluster 99,997 合并。但是你知道,在 MNIST 数字数据集中只有 50,000 个观测数据。

clusterOneclusterTwo指的是数字 0 至 49,999 的原始观测值。对于超过 49,999 的数字,聚类编号指的是先前聚类的点。例如,50,000 指的是在第 0 行形成的新聚类,50,001 指的是在第 1 行形成的新聚类,依此类推。

在第 49,998 行,clusterOne,99,996 指的是在第 49,996 行形成的聚类,而clusterTwo,99,997 指的是在第 49,997 行形成的聚类。你可以继续使用这个公式来查看聚类是如何被合并的。

评估聚类结果

现在我们已经有了树状图,请确定在哪里切断树状图以获得我们想要的聚类数目。为了更容易地将层次聚类的结果与k-means 的结果进行比较,让我们将树状图切割成恰好 20 个聚类。然后,我们将使用聚类准确度指标——在k-means部分定义——来评估层次聚类的聚类的同质性。

要从树状图中创建我们想要的聚类,让我们从 SciPy 引入fcluster库。我们需要指定树状图的距离阈值,以确定我们剩下多少个不同的聚类。距离阈值越大,我们得到的聚类就越少。在我们设定的距离阈值内的数据点将属于同一个聚类。较大的距离阈值类似于在非常高的垂直点剪切倒置树。因为随着树的高度越来越高,越来越多的点被分组在一起,我们得到的聚类就越少。

要获得确切的 20 个聚类,我们需要尝试不同的距离阈值,就像这里做的一样。fcluster库将使用我们指定的距离阈值对我们的树状图进行切割。MNIST 手写数字数据集中的每一个观测值将获得一个聚类标签,并且我们将这些标签存储在一个 Pandas DataFrame 中:

from scipy.cluster.hierarchy import fcluster

distance_threshold = 160
clusters = fcluster(Z, distance_threshold, criterion='distance')
X_train_hierClustered = \
    pd.DataFrame(data=clusters,index=X_train_PCA.index,columns=['cluster'])

让我们验证确实有恰好 20 个不同的聚类,考虑到我们选择的距离阈值:

print("Number of distinct clusters: ", \
      len(X_train_hierClustered['cluster'].unique()))

正如预期的那样,这证实了 20 个聚类:

Number of distinct clusters: 20

现在,让我们评估结果:

countByCluster_hierClust, countByLabel_hierClust, \
    countMostFreq_hierClust, accuracyDF_hierClust, \
    overallAccuracy_hierClust, accuracyByLabel_hierClust \
    = analyzeCluster(X_train_hierClustered, y_train)

print("Overall accuracy from hierarchical clustering: ", \
      overallAccuracy_hierClust)

我们发现总体准确度约为 77%,甚至比k-means 的约 70%准确度更好:

Overall accuracy from hierarchical clustering: 0.76882

让我们也评估每个聚类的准确度。

如下所示,准确度变化相当大。对于一些聚类,准确度非常高,接近 100%。对于一些聚类,准确度略低于 50%:

0       0.987962
1       0.983727
2       0.988998
3       0.597356
4       0.678642
5       0.442478
6       0.950033
7       0.829060
8       0.976062
9       0.986141
10      0.990183
11      0.992183
12      0.971033
13      0.554273
14      0.553617
15      0.720183
16      0.538891
17      0.484590
18      0.957732
19      0.977310
dtype:  float64

总体而言,层次聚类在 MNIST 手写数字数据集上表现良好。请记住,这是在不使用任何标签的情况下完成的。

在实际示例中,它将如何工作:首先我们会应用降维(如 PCA),然后执行聚类(如层次聚类),最后我们会为每个聚类手动标记几个点。例如,对于 MNIST 数字数据集,如果我们没有任何标签,我们会查看每个聚类中的几幅图像,并基于它们显示的数字对这些图像进行标记。只要聚类足够同质,我们生成的少量手动标签就可以自动应用于聚类中的所有其他图像。

突然之间,我们几乎可以以 77%的准确率对我们 50,000 个数据集中的所有图像进行标记。这令人印象深刻,并突显了无监督学习的力量。

DBSCAN

现在让我们转向第三个也是最后一个主要的聚类算法,DBSCAN,它代表具有噪声的基于密度的空间聚类。正如其名称所示,这种聚类算法基于点的密度进行分组。

DBSCAN 将紧密排列的点分组在一起,其中“紧密”定义为在一定距离内存在最少数量的点。如果点在多个聚类的一定距离内,则将其与其最密集的聚类分组在一起。不在任何其他聚类的一定距离内的任何实例被标记为离群点。

k-means 和层次聚类中,所有点都必须被聚类,而且离群点处理不当。在 DBSCAN 中,我们可以明确将点标记为离群点,避免必须将它们聚类。这非常强大。与其他聚类算法相比,DBSCAN 在数据中通常由离群点引起的失真问题上要少得多。此外,像层次聚类一样,但不像k-means,我们不需要预先指定聚类的数量。

DBSCAN 算法

现在让我们首先使用 Scikit-Learn 中的 DBSCAN 库。我们需要指定两点之间被视为相邻的最大距离(称为eps)和称为min_samples的最小样本数以被称为聚类的组。eps的默认值是 0.5,min_samples的默认值是 5。如果eps设置得太低,可能没有足够的点接近其他点以被视为相邻。因此,所有点将保持未聚类状态。如果eps设置得太高,可能会将许多点聚类在一起,只有少数点会保持未聚类状态,实际上被标记为数据集中的离群点。

我们需要为我们的 MNIST 数字数据集寻找最佳的epsmin_samples指定在eps距离内需要多少点才能称为一个簇。一旦有足够数量的紧密排列的点,任何距离这些所谓的核心点eps距离内的其他点都属于该簇,即使这些其他点周围没有达到eps距离内的min_samples数量的点。如果这些其他点周围没有min_samples数量的点在eps距离内,它们被称为该簇的边界点

一般来说,随着min_samples的增加,簇的数量减少。与eps类似,我们需要为我们的 MNIST 数字数据集寻找最佳的min_samples。正如您所见,这些簇有核心点和边界点,但就所有意图和目的而言,它们都属于同一组。所有未被分组的点——无论是簇的核心点还是边界点——都被标记为离群点。

应用 DBSCAN 到我们的数据集

现在让我们转向我们的具体问题。与以前一样,我们将对经过 PCA 降维的 MNIST 数字数据集的前 100 个主成分应用 DBSCAN 算法:

from sklearn.cluster import DBSCAN

eps = 3
min_samples = 5
leaf_size = 30
n_jobs = 4

db = DBSCAN(eps=eps, min_samples=min_samples, leaf_size=leaf_size,
            n_jobs=n_jobs)

cutoff = 99
X_train_PCA_dbscanClustered = db.fit_predict(X_train_PCA.loc[:,0:cutoff])
X_train_PCA_dbscanClustered = \
    pd.DataFrame(data=X_train_PCA_dbscanClustered, index=X_train.index, \
                 columns=['cluster'])

countByCluster_dbscan, countByLabel_dbscan, countMostFreq_dbscan, \
    accuracyDF_dbscan, overallAccuracy_dbscan, accuracyByLabel_dbscan \
    = analyzeCluster(X_train_PCA_dbscanClustered, y_train)

overallAccuracy_dbscan

我们将保持min_samples的默认值为 5,但我们将调整eps为 3,以避免集群中点数过少。

这里是总体精度:

Overall accuracy from DBSCAN: 0.242

如您所见,与k-means 和层次聚类相比,准确率非常低。我们可以调整参数epsmin_samples来改善结果,但似乎 DBSCAN 不适合为这个特定数据集的观测进行聚类。

为了探索原因,让我们看看簇(表 5-3)。

表 5-3:DBSCAN 的簇结果

cluster clusterCount
0 –1 39575
1 0 8885
2 8 720
3 5 92
4 18 51
5 38 38
6 41 22
7 39 22
8 4 16
9 20 16

大多数点都未被聚类。您可以在图中看到这一点。在训练集中的 50,000 个观测中,39,651 个点属于簇-1,这意味着它们不属于任何簇。它们被标记为离群点——即噪声。

8,885 个点属于 0 号簇。然后是一长串较小规模的簇。看起来 DBSCAN 在找到明显的密集点组时有困难,因此在基于 MNIST 图像展示的数字进行聚类方面表现不佳。

HDBSCAN

让我们尝试 DBSCAN 的另一个版本,看看结果是否会改善。这个版本被称为HDBSCAN,或者层次 DBSCAN。它采用我们介绍过的 DBSCAN 算法,并将其转换为层次聚类算法。换句话说,它基于密度进行分组,然后迭代地根据距离链接基于密度的簇,就像我们在前一节介绍的层次聚类算法中做的那样。

该算法的两个主要参数是min_cluster_sizemin_samples,当设置为None时,默认为min_cluster_size。让我们使用开箱即用的参数选择,评估 HDBSCAN 在我们的 MNIST 数字数据集上是否比 DBSCAN 表现更好:

import hdbscan

min_cluster_size = 30
min_samples = None
alpha = 1.0
cluster_selection_method = 'eom'

hdb = hdbscan.HDBSCAN(min_cluster_size=min_cluster_size, \
        min_samples=min_samples, alpha=alpha, \
        cluster_selection_method=cluster_selection_method)

cutoff = 10
X_train_PCA_hdbscanClustered = \
    hdb.fit_predict(X_train_PCA.loc[:,0:cutoff])

X_train_PCA_hdbscanClustered = \
    pd.DataFrame(data=X_train_PCA_hdbscanClustered, \
    index=X_train.index, columns=['cluster'])

countByCluster_hdbscan, countByLabel_hdbscan, \
    countMostFreq_hdbscan, accuracyDF_hdbscan, \
    overallAccuracy_hdbscan, accuracyByLabel_hdbscan \
    = analyzeCluster(X_train_PCA_hdbscanClustered, y_train)

这里是总体准确率:

Overall accuracy from HDBSCAN: 0.24696

在 25%时,这比 DBSCAN 略好一点,但远远低于k-means 和层次聚类超过 70%的表现。表 5-4 展示了各个聚类的准确率。

表 5-4. HDBSCAN 的聚类结果

聚类 聚类数量
0 –1 42570
1 4 5140
2 7 942
3 0 605
4 6 295
5 3 252
6 1 119
7 5 45
8 2 32

我们看到与 DBSCAN 类似的现象。大多数点未被聚类,然后是一长串小规模的聚类。结果并未有太大改进。

结论

在本章中,我们介绍了三种主要类型的聚类算法——k-means、层次聚类和 DBSCAN,并将它们应用于 MNIST 数字数据集的降维版本。前两种聚类算法在数据集上表现非常好,能够很好地对图像进行分组,使得跨聚类的标签一致性超过 70%。

对于这个数据集,DBSCAN 的表现并不太理想,但它仍然是一个可行的聚类算法。既然我们已经介绍了这些聚类算法,让我们在第六章中构建一个应用的无监督学习解决方案。

¹ 关于fastcluster的更多信息,请访问该项目的网页。

第六章:群体分割

在 第五章 中,我们介绍了聚类,一种无监督学习方法,用于识别数据中的潜在结构并根据相似性将点分组。这些组(称为簇)应该是同质且明显不同的。换句话说,组内成员应该彼此非常相似,并且与任何其他组的成员非常不同。

从应用的角度来看,基于相似性将成员分组且无需标签指导的能力非常强大。例如,这样的技术可以应用于为在线零售商找到不同的消费者群体,为每个不同的群体定制营销策略(例如预算购物者、时尚达人、球鞋爱好者、技术爱好者、发烧友等)。群体分割可以提高在线广告的定位精度,并改进电影、音乐、新闻、社交网络、约会等推荐系统的推荐效果。

在本章中,我们将使用前一章的聚类算法构建一个应用型无监督学习解决方案 —— 具体来说,我们将执行群体分割。

Lending Club 数据

对于本章,我们将使用 Lending Club 的贷款数据,这是一家美国的点对点借贷公司。平台上的借款人可以以未担保的个人贷款形式借款 $1,000 到 $40,000,期限为三年或五年。

投资者可以浏览贷款申请,并根据借款人的信用历史、贷款金额、贷款等级和贷款用途选择是否融资。投资者通过贷款支付的利息赚钱,而 Lending Club 则通过贷款起始费用和服务费赚钱。

我们将使用的贷款数据来自 2007–2011 年,并且可以在 Lending Club 网站 上公开获取。数据字典也可以在那里找到。

数据准备

像在前几章中一样,让我们准备好环境以处理 Lending Club 数据。

加载库

首先,让我们加载必要的库:

# Import libraries
'''Main'''
import numpy as np
import pandas as pd
import os, time, re
import pickle, gzip

'''Data Viz'''
import matplotlib.pyplot as plt
import seaborn as sns
color = sns.color_palette()
import matplotlib as mpl

%matplotlib inline

'''Data Prep and Model Evaluation'''
from sklearn import preprocessing as pp
from sklearn.model_selection import train_test_split
from sklearn.metrics import precision_recall_curve, average_precision_score
from sklearn.metrics import roc_curve, auc, roc_auc_score

'''Algorithms'''
from sklearn.decomposition import PCA
from sklearn.cluster import KMeans
import fastcluster
from scipy.cluster.hierarchy import dendrogram, cophenet, fcluster
from scipy.spatial.distance import pdist

探索数据

接下来,让我们加载贷款数据并指定要保留哪些列:

原始贷款数据文件有 144 列,但大多数列为空,并且对我们的价值有限。因此,我们将指定一部分主要填充且值得在我们的聚类应用中使用的列。这些字段包括贷款请求金额、资助金额、期限、利率、贷款等级等贷款属性,以及借款人的就业长度、住房所有权状态、年收入、地址以及借款用途等借款人属性。

我们还将稍微探索一下数据:

# Load the data
current_path = os.getcwd()
file = '\\datasets\\lending_club_data\\LoanStats3a.csv'
data = pd.read_csv(current_path + file)

# Select columns to keep
columnsToKeep = ['loan_amnt','funded_amnt','funded_amnt_inv','term', \
                 'int_rate','installment','grade','sub_grade', \
                 'emp_length','home_ownership','annual_inc', \
                 'verification_status','pymnt_plan','purpose', \
                 'addr_state','dti','delinq_2yrs','earliest_cr_line', \
                 'mths_since_last_delinq','mths_since_last_record', \
                 'open_acc','pub_rec','revol_bal','revol_util', \
                 'total_acc','initial_list_status','out_prncp', \
                 'out_prncp_inv','total_pymnt','total_pymnt_inv', \
                 'total_rec_prncp','total_rec_int','total_rec_late_fee', \
                 'recoveries','collection_recovery_fee','last_pymnt_d', \
                 'last_pymnt_amnt']

data = data.loc[:,columnsToKeep]

data.shape

data.head()

数据包含 42,542 笔贷款和 37 个特征(42,542, 37)。

表 6-1 预览数据。

表 6-1. 贷款数据的前几行

loan_amnt funded_amnt funded_amnt_inv term int_rate instsallment grade
0 5000.0 5000.0 4975.0 36 个月 10.65% 162.87 B
1 2500.0 2500.0 2500.0 60 个月 15.27% 59.83 C
2 2400.0 2400.0 2400.0 35 个月 15.96% 84.33 C
3 10000.0 10000.0 10000.0 36 个月 13.49% 339.31 C
4 3000.0 3000.0 3000.0 60 个月 12.69% 67.79 B

将字符串格式转换为数值格式

一些特征,如贷款的期限、贷款的利率、借款人的就业时长以及借款人的循环利用率,需要从字符串格式转换为数值格式。让我们进行转换:

# Transform features from string to numeric
for i in ["term","int_rate","emp_length","revol_util"]:
    data.loc[:,i] = \
        data.loc[:,i].apply(lambda x: re.sub("[⁰-9]", "", str(x)))
    data.loc[:,i] = pd.to_numeric(data.loc[:,i])

对于我们的聚类应用程序,我们将只考虑数值特征,忽略所有的分类特征,因为非数值特征在当前形式下无法被我们的聚类算法处理。

填充缺失值

找到这些数值特征,并计算每个特征中 NaN 的数量。然后我们将用特征的平均值或者有时仅仅是数字零来填充这些 NaN,具体取决于从业务角度来看这些特征代表什么:

# Determine which features are numerical
numericalFeats = [x for x in data.columns if data[x].dtype != 'object']

# Display NaNs by feature
nanCounter = np.isnan(data.loc[:,numericalFeats]).sum()
nanCounter

下面的代码显示了每个特征中的 NaN 数量:

loan_amnt               7
funded_amnt             7
funded_amnt_inv         7
term                    7
int_rate                7
installment             7
emp_length              1119
annual_inc              11
dti                     7
delinq_2yrs             36
mths_since_last_delinq  26933
mths_since_last_record  38891
open_acc                36
pub_rec                 36
revol_bal               7
revol_util              97
total_acc               36
out_prncp               7
out_prncp_inv           7
total_pymnt             7
total_pymnt_inv         7
total_rec_prncp         7
total_rec_int           7
total_rec_late_fee      7
recoveries              7
collection_recovery_fee 7
last_pymnt_amnt         7
dtype: int64

大多数特征有少量的 NaN,而一些特征,例如自上次拖欠以来的月数和记录变更以来的时间,有很多 NaN。

让我们填充这些 NaN,这样我们在聚类过程中就不必处理任何 NaN:

# Impute NaNs with mean
fillWithMean = ['loan_amnt','funded_amnt','funded_amnt_inv','term', \
                'int_rate','installment','emp_length','annual_inc',\
                'dti','open_acc','revol_bal','revol_util','total_acc',\
                'out_prncp','out_prncp_inv','total_pymnt', \
                'total_pymnt_inv','total_rec_prncp','total_rec_int', \
                'last_pymnt_amnt']

# Impute NaNs with zero
fillWithZero = ['delinq_2yrs','mths_since_last_delinq', \
                'mths_since_last_record','pub_rec','total_rec_late_fee', \
                'recoveries','collection_recovery_fee']

# Perform imputation
im = pp.Imputer(strategy='mean')
data.loc[:,fillWithMean] = im.fit_transform(data[fillWithMean])

data.loc[:,fillWithZero] = data.loc[:,fillWithZero].fillna(value=0,axis=1)

让我们重新计算 NaN,以确保没有任何 NaN 保留。

我们现在是安全的。所有的 NaN 都已经填充:

numericalFeats = [x for x in data.columns if data[x].dtype != 'object']

nanCounter = np.isnan(data.loc[:,numericalFeats]).sum()
nanCounter
loan_amnt               0
funded_amnt             0
funded_amnt_inv         0
term                    0
int_rate                0
installment             0
emp_length              0
annual_inc              0
dti                     0
delinq_2yrs             0
mths_since_last_delinq  0
mths_since_last_record  0
open_acc                0
pub_rec                 0
revol_bal               0
revol_util              0
total_acc               0
out_prncp               0
out_prncp_inv           0
total_pymnt             0
total_pymnt_inv         0
total_rec_prncp         0
total_rec_int           0
total_rec_late_fee      0
recoveries              0
collection_recovery_fee 0
last_pymnt_amnt         0
dtype: int64

工程特征

让我们还要工程化几个新特征,以补充现有的特征集。这些新特征大多是贷款金额、循环余额、还款和借款人年收入之间的比率:

# Feature engineering
data['installmentOverLoanAmnt'] = data.installment/data.loan_amnt
data['loanAmntOverIncome'] = data.loan_amnt/data.annual_inc
data['revol_balOverIncome'] = data.revol_bal/data.annual_inc
data['totalPymntOverIncome'] = data.total_pymnt/data.annual_inc
data['totalPymntInvOverIncome'] = data.total_pymnt_inv/data.annual_inc
data['totalRecPrncpOverIncome'] = data.total_rec_prncp/data.annual_inc
data['totalRecIncOverIncome'] = data.total_rec_int/data.annual_inc

newFeats = ['installmentOverLoanAmnt','loanAmntOverIncome', \
            'revol_balOverIncome','totalPymntOverIncome', \
           'totalPymntInvOverIncome','totalRecPrncpOverIncome', \
            'totalRecIncOverIncome']

选择最终的特征集并执行缩放

接下来,我们将生成训练数据集,并为我们的聚类算法缩放特征:

# Select features for training
numericalPlusNewFeats = numericalFeats+newFeats
X_train = data.loc[:,numericalPlusNewFeats]

# Scale data
sX = pp.StandardScaler()
X_train.loc[:,:] = sX.fit_transform(X_train)

指定评估标签

聚类是一种无监督学习方法,因此不使用标签。然而,为了评估我们的聚类算法在找到这个 Lending Club 数据集中不同且同质化的借款人群组时的好坏程度,我们将使用贷款等级作为代理标签。

贷款等级目前由字母进行评分,“A” 级贷款最值得信赖和安全,“G” 级贷款最不值得:

labels = data.grade
labels.unique()
array(['B', 'C', 'A', 'E', 'F', 'D', 'G', nan], dtype=object)

贷款等级中有一些 NaN。我们将用值“Z”来填充这些 NaN,然后使用 Scikit-Learn 中的 LabelEncoder 将字母等级转换为数值等级。为了保持一致性,我们将这些标签加载到一个名为“y_train”的 Python 系列中:

# Fill missing labels
labels = labels.fillna(value="Z")

# Convert labels to numerical values
lbl = pp.LabelEncoder()
lbl.fit(list(labels.values))
labels = pd.Series(data=lbl.transform(labels.values), name="grade")

# Store as y_train
y_train = labels

labelsOriginalVSNew = pd.concat([labels, data.grade],axis=1)
labelsOriginalVSNew

表 6-2. 数字与字母贷款等级对比

grade grade
0 1 B
1 2 C
2 2 C
3 2 C
4 1 B
5 0 A
6 2 C
7 4 E
8 5 F
9 1 B
10 2 C
11 1 B
12 2 C
13 1 B
14 1 B
15 3 D
16 2 C

正如你从表 6-2 中所看到的,所有的“A”等级都被转换为 0,“B”等级为 1,以此类推。

让我们也检查一下是否“A”等级的贷款通常有最低的收费利率,因为它们是最不风险的,其他贷款的利率会逐渐增加:

# Compare loan grades with interest rates
interestAndGrade = pd.DataFrame(data=[data.int_rate,labels])
interestAndGrade = interestAndGrade.T

interestAndGrade.groupby("grade").mean()

表 6-3 证实了这一点。较高的字母等级贷款有较高的利率。¹

表 6-3. 等级与利率

grade int_rate
0.0 734.270844
1.0 1101.420857
2.0 1349.988902
3.0 1557.714927
4.0 1737.676783
5.0 1926.530361
6.0 2045.125000
7.0 1216.501563

聚类的优度

现在数据准备就绪。我们有一个包含所有 34 个数值特征的 X_train,以及一个包含数值贷款等级的 y_train,我们仅用于验证结果,而不是像在监督式机器学习问题中那样用于训练算法。在构建我们的第一个聚类应用之前,让我们介绍一个函数来分析我们使用聚类算法生成的聚类的优度。具体来说,我们将使用一致性的概念来评估每个聚类的优度。

如果聚类算法在 Lending Club 数据集中很好地分离借款人,那么每个集群都应该有非常相似的借款人,并且与其他组中的借款人不相似。假设相似并被分组在一起的借款人应该有相似的信用档案—换句话说,他们的信用价值应该相似。

如果是这种情况(而在现实世界中,这些假设大部分只是部分成立),给定集群中的借款人通常应被分配相同的数值贷款等级,我们将使用 y_train 中设置的数值贷款等级来验证。在每个集群中具有最频繁出现的数值贷款等级的借款人所占的百分比越高,聚类应用的效果就越好。

举例来说,考虑一个拥有一百名借款人的集群。如果有 30 名借款人的数值贷款等级为 0,25 名借款人的贷款等级为 1,20 名借款人的贷款等级为 2,剩余的借款人贷款等级在 3 到 7 之间,我们会说该集群的准确率为 30%,因为该集群中最频繁出现的贷款等级仅适用于该集群中的 30% 借款人。

如果我们没有一个包含数值贷款等级的y_train来验证簇的好坏,我们可以采用替代方法。我们可以从每个簇中抽样一些借款人,手动确定他们的数值贷款等级,并确定我们是否会给这些借款人大致相同的数值贷款等级。如果是,则该簇是一个好簇——它足够同质化,我们会给我们抽样的借款人大致相同的数值贷款等级。如果不是,则该簇不够好——借款人过于异质化,我们应该尝试使用更多数据、不同的聚类算法等来改进解决方案。

尽管如此,我们不需要对借款人进行抽样和手动标记,因为我们已经有了数值贷款等级,但在没有标签的特定问题上,这一点很重要。

这是分析簇的函数:

def analyzeCluster(clusterDF, labelsDF):
    countByCluster = \
        pd.DataFrame(data=clusterDF['cluster'].value_counts())
    countByCluster.reset_index(inplace=True,drop=False)
    countByCluster.columns = ['cluster','clusterCount']

    preds = pd.concat([labelsDF,clusterDF], axis=1)
    preds.columns = ['trueLabel','cluster']

    countByLabel = pd.DataFrame(data=preds.groupby('trueLabel').count())

    countMostFreq = pd.DataFrame(data=preds.groupby('cluster').agg( \
        lambda x:x.value_counts().iloc[0]))
    countMostFreq.reset_index(inplace=True,drop=False)
    countMostFreq.columns = ['cluster','countMostFrequent']

    accuracyDF = countMostFreq.merge(countByCluster, \
        left_on="cluster",right_on="cluster")

    overallAccuracy = accuracyDF.countMostFrequent.sum()/ \
        accuracyDF.clusterCount.sum()

    accuracyByLabel = accuracyDF.countMostFrequent/ \
        accuracyDF.clusterCount

    return countByCluster, countByLabel, countMostFreq, \
        accuracyDF, overallAccuracy, accuracyByLabel

k-均值应用

我们使用这个 Lending Club 数据集的第一个聚类应用将使用k-均值,这在第五章中有介绍。回顾一下,在k-均值聚类中,我们需要指定所需的簇k,算法将每个借款人精确地分配到这些k簇中的一个。

该算法将通过最小化簇内变化(也称为惯性),使得所有k簇中的簇内变化之和尽可能小,来实现这一点。

我们不只是指定一个k值,而是进行一个实验,将k从 10 到 30 的范围内设置,并绘制我们在前一节定义的准确度测量结果。

基于哪种k度量表现最佳,我们可以构建使用这种最佳k度量的聚类管道:

from sklearn.cluster import KMeans

n_clusters = 10
n_init = 10
max_iter = 300
tol = 0.0001
random_state = 2018
n_jobs = 2

kmeans = KMeans(n_clusters=n_clusters, n_init=n_init, \
                max_iter=max_iter, tol=tol, \
                random_state=random_state, n_jobs=n_jobs)

kMeans_inertia = pd.DataFrame(data=[],index=range(10,31), \
                              columns=['inertia'])

overallAccuracy_kMeansDF = pd.DataFrame(data=[], \
    index=range(10,31),columns=['overallAccuracy'])

for n_clusters in range(10,31):
    kmeans = KMeans(n_clusters=n_clusters, n_init=n_init, \
                    max_iter=max_iter, tol=tol, \
                    random_state=random_state, n_jobs=n_jobs)

    kmeans.fit(X_train)
    kMeans_inertia.loc[n_clusters] = kmeans.inertia_
    X_train_kmeansClustered = kmeans.predict(X_train)
    X_train_kmeansClustered = pd.DataFrame(data= \
        X_train_kmeansClustered, index=X_train.index, \
        columns=['cluster'])

    countByCluster_kMeans, countByLabel_kMeans, \
    countMostFreq_kMeans, accuracyDF_kMeans, \
    overallAccuracy_kMeans, accuracyByLabel_kMeans = \
    analyzeCluster(X_train_kmeansClustered, y_train)

    overallAccuracy_kMeansDF.loc[n_clusters] = \
        overallAccuracy_kMeans

overallAccuracy_kMeansDF.plot()

图 6-1 显示了结果的图表。

使用 K-means 进行不同 K 值的整体准确率

图 6-1. 使用k-均值进行不同k度量的整体准确率

正如我们所见,准确率在大约 30 个簇时最佳,约为 39%。换句话说,对于任何给定的簇,大约 39%的借款人具有该簇中最常见的标签。其余 61%的借款人具有非最常见的标签。

下面的代码显示了k = 30 时的准确率:

0      0.326633
1      0.258993
2      0.292240
3      0.234242
4      0.388794
5      0.325654
6      0.303797
7      0.762116
8      0.222222
9      0.391381
10     0.292910
11     0.317533
12     0.206897
13     0.312709
14     0.345233
15     0.682208
16     0.327250
17     0.366605
18     0.234783
19     0.288757
20     0.500000
21     0.375466
22     0.332203
23     0.252252
24     0.338509
25     0.232000
26     0.464418
27     0.261583
28     0.376327
29     0.269129
dtype: float64

准确率在不同簇之间变化很大。有些簇比其他簇更加同质化。例如,簇 7 的准确率为 76%,而簇 12 的准确率仅为 21%。这是构建一个聚类应用程序的起点,用于根据其与其他借款人的相似度将申请 Lending Club 贷款的新借款人自动分配到预先存在的组中。基于这种聚类,可以自动为新借款人分配一个暂定的数值贷款等级,大约 39%的时间是正确的。

这不是最佳解决方案,我们应考虑是否获取更多数据、执行更多特征工程和选择、选择不同的k-均值算法参数或更改为其他聚类算法以改善结果。可能我们没有足够的数据能够像我们已经做的那样有效地将借款人分成不同且同质的群体;如果是这种情况,需要更多数据和更多的特征工程和选择。或者,对于我们拥有的有限数据,k-均值算法不适合执行此分离。

让我们转向层次聚类,看看我们的结果是否会有所改善。

层次聚类应用

请记住,在层次聚类中,我们不需要预先确定特定数量的群集。相反,我们可以在层次聚类运行结束后选择我们想要的群集数量。层次聚类将构建一个树状图,概念上可以视为倒置的树。底部的叶子是在 Lending Club 上申请贷款的个体借款人。

层次聚类根据借款人彼此之间的相似程度,随着我们垂直向上移动倒置树而将它们连接在一起。彼此最相似的借款人更早加入,而不那么相似的借款人则加入得更晚。最终,所有借款人都在倒置树的顶部——主干处一起加入。

从业务角度来看,这种聚类过程显然非常强大。如果我们能够找到彼此相似并将它们分组的借款人,我们可以更有效地为它们分配信用评级。我们还可以针对不同的借款人群体制定具体策略,并从关系的角度更好地管理它们,提供更好的整体客户服务。

一旦层次聚类算法运行完毕,我们可以确定我们想要切割树的位置。我们切得越低,留下的借款人群体就越多。

让我们首先像我们在第五章中所做的那样训练层次聚类算法:

import fastcluster
from scipy.cluster.hierarchy import dendrogram
from scipy.cluster.hierarchy import cophenet
from scipy.spatial.distance import pdist

Z = fastcluster.linkage_vector(X_train, method='ward', \
                               metric='euclidean')

Z_dataFrame = pd.DataFrame(data=Z,columns=['clusterOne', \
                'clusterTwo','distance','newClusterSize'])

表 6-4 展示了输出的数据框的样子。前几行是最底层借款人的初始联接。

表 6-4. 层次聚类的最底层叶子节点

clusterOne clusterTwo distance newClusterSize
0 39786.0 39787.0 0.000000e+00 2.0
1 39788.0 42542.0 0.000000e+00 3.0
2 42538.0 42539.0 0.000000e+00 2.0
3 42540.0 42544.0 0.000000e+00 3.0
4 42541.0 42545.0 3.399350e-17 4.0
5 42543.0 42546.0 5.139334e-17 7.0
6 33251.0 33261.0 1.561313e-01 2.0
7 42512.0 42535.0 3.342654e-01 2.0
8 42219.0 42316.0 3.368231e-01 2.0
9 6112.0 21928.0 3.384368e-01 2.0
10 33248.0 33275.0 3.583819e-01 2.0
11 33253.0 33265.0 3.595331e-01 2.0
12 33258.0 42552.0 3.719377e-01 3.0
13 20430.0 23299.0 3.757307e-01 2.0
14 5455.0 32845.0 3.828709e-01 2.0
15 28615.0 30306.0 3.900294e-01 2.0
16 9056 .0 9769.0 3.967378e-01 2.0
17 11162.0 13857.0 3.991124e-01 2.0
18 33270.0 42548.0 3.995620e-01 3.0
19 17422.0 17986.0 4.061704e-01 2.0

请记住,最后几行表示倒置树的顶部,最终将 42,541 名借款人汇总在一起(见表 6-5)。

表 6-5. 层次聚类的最顶层叶节点

clusterOne clusterTwo distance newClusterSize
42521 85038.0 85043.0 132.715723 3969.0
42522 85051.0 85052.0 141.386569 2899.0
42532 85026.0 85027.0 146.976703 2351.0
42524 85048.0 85049.0 152.660192 5691.0
42525 85036.0 85059.0 153.512281 5956.0
42526 85033.0 85044.0 160.825959 2203.0
42527 85055.0 85061.0 163.701428 668.0
42528 85062.0 85066.0 168.199295 6897.0
42529 85054.0 85060.0 168.924039 9414.0
42530 85028.0 85064.0 185.215769 3118.0
42531 85067.0 85071.0 187.832588 15370.0
42532 85056.0 85073.0 203.212147 17995.0
42533 85057.0 85063.0 205.285993 9221.0
42534 85068.0 85072.0 207.902660 5321.0
42535 85069.0 85075.0 236.754581 9889.0
42536 85070.0 85077.0 298.587755 16786.0
42537 85058.0 85078.0 309.946867 16875.0
42538 85074.0 85079.0 375.698458 34870.0
42539 85065.0 85080.0 400.711547 37221.0
42504 85076.0 85081.0 644.047472 42542.0

现在,让我们根据distance_threshold来截取树状图,以便获得可管理的集群数量。根据试验,设置distance_threshold为 100,结果得到 32 个集群,这是我们将在本例中使用的数量。

from scipy.cluster.hierarchy import fcluster
distance_threshold = 100
clusters = fcluster(Z, distance_threshold, criterion='distance')
X_train_hierClustered = pd.DataFrame(data=clusters,
 index=X_train_PCA.index,columns=['cluster'])

print("Number of distinct clusters: ",
 len(X_train_hierClustered['cluster'].unique()))

我们选择的距离阈值所给出的不同集群数量为 32:

countByCluster_hierClust, countByLabel_hierClust, countMostFreq_hierClust,
 accuracyDF_hierClust, overallAccuracy_hierClust, accuracyByLabel_hierClust =
 analyzeCluster(X_train_hierClustered, y_train)
print("Overall accuracy from hierarchical clustering: ",
 overallAccuracy_hierClust)

下面的代码展示了层次聚类的总体准确率:

Overall accuracy from hierarchical clustering: 0.3651685393258427

总体准确率约为 37%,略低于k-means 聚类。尽管如此,层次聚类与k-means 聚类的工作方式不同,可能会更准确地分组一些借款人,而k-means 可能会比层次聚类更准确地分组其他借款人。

换句话说,这两种聚类算法可能互补,值得通过合并两种算法并评估合并结果来探索。与k-means 一样,准确率在不同集群之间差异很大。一些集群比其他集群更同质化:

Accuracy by cluster for hierarchical clustering

0      0.304124
1      0.219001
2      0.228311
3      0.379722
4      0.240064
5      0.272011
6      0.314560
7      0.263930
8      0.246138
9      0.318942
10     0.302752
11     0.269772
12     0.335717
13     0.330403
14     0.346320
15     0.440141
16     0.744155
17     0.502227
18     0.294118
19     0.236111
20     0.254727
21     0.241042
22     0.317979
23     0.308771
24     0.284314
25     0.243243
26     0.500000
27     0.289157
28     0.365283
29     0.479693
30     0.393559
31     0.340875

HDBSCAN 应用

现在让我们转向 HDBSCAN,并将此聚类算法应用于在 Lending Club 数据集中对相似借款人进行分组。

回想一下,HDBSCAN 将根据借款人在高维空间中属性的密集程度将其分组在一起。与k-means 或分层聚类不同,不是所有的借款人都会被分组。一些与其他借款人群体非常不同的借款人可能保持未分组状态。这些是异常借款人,值得调查,看看它们与其他借款人不同的良好业务原因。可能可以为一些借款人群体自动分配数值贷款等级,但对于那些不同的借款人,可能需要更为细致的信用评分方法。

让我们看看 HDBSCAN 的表现:

import hdbscan

min_cluster_size = 20
min_samples = 20
alpha = 1.0
cluster_selection_method = 'leaf'

hdb = hdbscan.HDBSCAN(min_cluster_size=min_cluster_size, \
    min_samples=min_samples, alpha=alpha, \
    cluster_selection_method=cluster_selection_method)

X_train_hdbscanClustered = hdb.fit_predict(X_train)
X_train_hdbscanClustered = pd.DataFrame(data= \
    X_train_hdbscanClustered, index=X_train.index, \
    columns=['cluster'])

countByCluster_hdbscan, countByLabel_hdbscan, \
    countMostFreq_hdbscan, accuracyDF_hdbscan, \
    overallAccuracy_hdbscan, accuracyByLabel_hdbscan = \
    analyzeCluster(X_train_hdbscanClustered, y_train)

下面的代码显示了 HDBSCAN 的整体准确率:

Overall accuracy from HDBSCAN: 0.3246203751586667

如此所见,整体准确率约为 32%,比k-means 或分层聚类的准确率都差。

表 6-6 显示了各种簇及其簇大小。

表 6-6. HDBSCAN 的簇结果

簇计数
0 –1 32708
1 7 4070
2 2 3668
3 1 1096
4 4 773
5 0 120
6 6 49
7 3 38
8 5 20

32,708 名借款人属于簇-1,意味着它们未分组。

以下显示了各簇的准确率:

0       0.284487
1       0.341667
2       0.414234
3       0.332061
4       0.552632
5       0.438551
6       0.400000
7       0.408163
8       0.590663

在这些簇中,准确率从 28%到 59%不等。

结论

在本章中,我们基于从 2007 年到 2011 年在 Lending Club 申请无抵押个人贷款的借款人构建了一个无监督的聚类应用程序。这些应用程序基于k-means、分层聚类和分层 DBSCAN。k-means 表现最佳,整体准确率约为 39%。

虽然这些应用程序表现还可以,但它们可以大幅改进。你应该尝试使用这些算法来改进解决方案。

这结束了本书中使用 Scikit-Learn 的无监督学习部分。接下来,我们将探索基于神经网络的形式的无监督学习,使用 TensorFlow 和 Keras。我们将从第七章中的表示学习和自编码器开始。

¹ 我们可以忽略评级为“7”的,这对应于贷款等级“Z”。这些是我们不得不填补贷款等级缺失的贷款。

² 我们在第二章中探讨了集成学习。如果需要复习,请参考“集成”。

第三部分:使用 TensorFlow 和 Keras 进行无监督学习

我们刚刚完成了基于 Scikit-Learn 的无监督学习部分。现在我们将转向基于神经网络的无监督学习。在接下来的几章中,我们将介绍神经网络,包括应用它们的流行框架 TensorFlow 和 Keras。

在第七章中,我们将使用自编码器——一个浅层神经网络——自动进行特征工程和特征选择。在此基础上,在第八章中,我们将把自编码器应用到一个真实世界的问题上。随后,在第九章中,我们将探讨如何将无监督学习问题转化为半监督学习问题,利用少量标签来提高纯无监督模型的精确度和召回率。

完成浅层神经网络的回顾后,我们将在本书的最后部分讨论深层神经网络。

第七章:自编码器

本书的前六章探讨了如何利用无监督学习进行降维和聚类,我们讨论的概念帮助我们构建了检测异常和基于相似性分割群组的应用程序。

然而,无监督学习能够做的远不止这些。无监督学习在特征提取方面表现出色,特征提取是一种从原始特征集生成新特征表示的方法;新的特征表示称为学习表示,并用于提高监督学习问题的性能。换句话说,特征提取是无监督学习到监督学习的手段。

自编码器是特征提取的一种形式。它们使用前馈、非递归神经网络执行表示学习。表示学习是涉及神经网络的整个机器学习分支的核心部分。

在自编码器中——它们是一种表示学习的形式——神经网络的每一层学习原始特征的表示,后续层基于前面层学到的表示进行构建。逐层递进,自编码器从简单的表示学习逐步建立更为复杂的表示,形成所谓的层次概念,并且这些概念变得越来越抽象。

输出层是原始特征的最终新学习表示。然后,可以将这种学习表示用作监督学习模型的输入,以改进泛化误差。

但在我们过多深入之前,让我们先介绍神经网络以及 Python 框架 TensorFlow 和 Keras。

神经网络

在其根本上,神经网络执行表示学习,即神经网络的每一层从前一层学习到一个表示。通过逐层构建更加细致和详细的表示,神经网络可以完成非常惊人的任务,如计算机视觉、语音识别和机器翻译。

神经网络有两种形式——浅层和深层。浅层网络有少量层,而深层网络有许多层。深度学习因其使用深度(多层)神经网络而得名。浅层神经网络并不特别强大,因为表示学习的程度受到层次较少的限制。另一方面,深度学习非常强大,目前是机器学习中最热门的领域之一。

明确一点,使用神经网络进行浅层和深层学习只是整个机器学习生态系统的一部分。使用神经网络和传统机器学习之间的主要区别在于,神经网络自动执行了大部分特征表示,而在传统机器学习中则是手动设计的。

神经网络具有输入层、一个或多个隐藏层和一个输出层。隐藏层的数量定义了神经网络的深度。您可以将这些隐藏层视为中间计算;这些隐藏层共同允许整个神经网络执行复杂的函数逼近。

每个层次有一定数量的节点(也称为神经元单元)组成该层。然后,每层的节点连接到下一层的节点。在训练过程中,神经网络确定分配给每个节点的最佳权重。

除了增加更多的层次外,我们还可以向神经网络添加更多节点,以增加神经网络模拟复杂关系的能力。这些节点被输入到一个激活函数中,该函数决定了当前层的值被馈送到神经网络的下一层。常见的激活函数包括线性sigmoid双曲正切修正线性单元(ReLU)激活函数。最终的激活函数通常是softmax 函数,它输出输入观察值属于某个类的概率。这对于分类问题非常典型。

神经网络可能还包括偏置节点;这些节点始终是常量值,并且与前一层的节点不连接。相反,它们允许激活函数的输出向上或向下偏移。通过隐藏层(包括节点、偏置节点和激活函数),神经网络试图学习正确的函数逼近,以便将输入层映射到输出层。

在监督学习问题中,这相当直观。输入层表示馈送到神经网络的特征,输出层表示分配给每个观察的标签。在训练过程中,神经网络确定了在整个神经网络中哪些权重有助于最小化每个观察的预测标签与真实标签之间的误差。在无监督学习问题中,神经网络通过各个隐藏层学习输入层的表示,但不受标签的指导。

神经网络非常强大,能够模拟复杂的非线性关系,这是传统机器学习算法难以处理的。总体来说,这是神经网络的一个伟大特性,但也存在潜在风险。因为神经网络能够建模如此复杂的非线性关系,它们也更容易过拟合,这是在设计使用神经网络的机器学习应用时需要注意和解决的问题。¹

尽管有多种类型的神经网络,比如递归神经网络(数据可以在任何方向上流动,用于语音识别和机器翻译)和卷积神经网络(用于计算机视觉),我们将专注于更为直接的前馈神经网络,其中数据仅向一个方向移动:向前。

我们还必须进行更多的超参数优化,以使神经网络表现良好——包括选择成本函数、用于最小化损失的算法、起始权重的初始化类型、用于训练神经网络的迭代次数(即周期数)、每次权重更新前要喂入的观察次数(即批量大小)以及在训练过程中移动权重的步长(即学习率)。

TensorFlow

在介绍自动编码器之前,让我们先探索一下TensorFlow,这是我们用来构建神经网络的主要库。TensorFlow 是一个开源软件库,用于高性能数值计算,最初由 Google Brain 团队为内部使用开发。在 2015 年 11 月,它作为开源软件发布。²

TensorFlow 可在许多操作系统上使用(包括 Linux、macOS、Windows、Android 和 iOS),并且可以在多个 CPU 和 GPU 上运行,使得软件在快速性能方面非常具有可扩展性,并且可以部署到桌面、移动、网络和云端用户。

TensorFlow 的美妙之处在于用户可以在 Python 中定义神经网络——或者更普遍地说,定义计算图——然后使用 C++ 代码运行这个神经网络,这比 Python 快得多。

TensorFlow 还能够并行化计算,将整个操作序列分解为多个部分,并在多个 CPU 和 GPU 上并行运行。对于像 Google 为其核心操作(如搜索)运行的大规模机器学习应用程序来说,这样的性能非常重要。

尽管有其他能够实现类似功能的开源库,TensorFlow 已经成为最受欢迎的一个,部分原因是 Google 的品牌。

TensorFlow 示例

在我们继续之前,让我们建立一个 TensorFlow 计算图并运行一个计算。我们将导入 TensorFlow,使用 TensorFlow API 定义几个变量(类似于我们在之前章节中使用的 Scikit-Learn API),然后计算这些变量的值:

import tensorflow as tf

b = tf.constant(50)
x = b * 10
y = x + b

with tf.Session() as sess:
    result = y.eval()
    print(result)

很重要的一点是,这里有两个阶段。首先,我们构建计算图,定义了 b、x 和 y。然后,通过调用 tf.Session() 执行计算图。在调用之前,CPU 和/或 GPU 不会执行任何计算。而是仅仅存储计算的指令。执行此代码块后,您将如预期看到结果为“550”。

后面,我们将使用 TensorFlow 构建实际的神经网络。

Keras

Keras 是一个开源软件库,提供在 TensorFlow 之上运行的高级 API。它为 TensorFlow 提供了一个更加用户友好的接口,使数据科学家和研究人员能够比直接使用 TensorFlow 命令更快速、更轻松地进行实验。Keras 的主要作者也是一位 Google 工程师,弗朗索瓦·朱勒。

当我们开始使用 TensorFlow 构建模型时,我们将亲自动手使用 Keras 并探索其优势。

自编码器:编码器和解码器

现在我们已经介绍了神经网络及其在 Python 中的流行库——TensorFlow 和 Keras,让我们来构建一个自编码器,这是最简单的无监督学习神经网络之一。

自编码器包括两部分,一个编码器和一个解码器。编码器将输入的特征集通过表示学习转换为不同的表示,解码器将这个新学到的表示转换回原始格式。

自编码器的核心概念与我们在第三章中学习的降维概念类似。类似于降维,自编码器不会记忆原始观察和特征,这将是所谓的恒等函数。如果它学到了确切的恒等函数,那么自编码器就没有用处。相反,自编码器必须尽可能接近但不完全复制原始观察,使用新学到的表示;换句话说,自编码器学习了恒等函数的近似。

由于自编码器受到约束,它被迫学习原始数据的最显著特性,捕获数据的基础结构;这与降维中发生的情况类似。约束是自编码器的一个非常重要的属性——约束迫使自编码器智能地选择要捕获的重要信息和要丢弃的不相关或较不重要的信息。

自编码器已经存在几十年了,你可能已经怀疑它们已广泛用于降维和自动特征工程/学习。如今,它们经常用于构建生成模型,例如生成对抗网络

不完全自编码器

在自编码器中,我们最关心的是编码器,因为这个组件是学习原始数据新表示的组件。这个新表示是从原始特征和观察得到的新特征集。

我们将自编码器的编码器函数称为h = f(x),它接收原始观察x并使用函数f中捕获的新学到的表示输出h。解码器函数使用编码器函数重建原始观察,其形式为r = g(h)

如您所见,解码器函数将编码器的输出h馈入并使用其重构函数g重构观察结果,称为r。如果做得正确,g(f(x))不会在所有地方完全等于x,但会足够接近。

我们如何限制编码器函数来近似x,以便它只能学习x的最显著属性而不是精确复制它?

我们可以约束编码器函数的输出h,使其维数少于x。这被称为欠完备自编码器,因为编码器的维数少于原始输入的维数。这再次类似于降维中发生的情况,其中我们接收原始输入维度并将其减少到一个更小的集合。

在这种方式下受限制,自编码器试图最小化我们定义的一个损失函数,使得解码器近似地使用编码器的输出重构观察结果后的重构误差尽可能小。重要的是要意识到隐藏层是维度受限的地方。换句话说,编码器的输出比原始输入的维数少。但解码器的输出是重构的原始数据,因此与原始输入具有相同数量的维数。

当解码器为线性且损失函数为均方误差时,欠完备自编码器学习的是与 PCA 相同类型的新表示,PCA 是我们在第三章介绍的一种降维方法。然而,如果编码器和解码器函数是非线性的,自编码器可以学习更复杂的非线性表示。这才是我们最关心的。但要注意——如果自编码器被赋予了太多的容量和自由度来建模复杂的、非线性的表示,它将简单地记住/复制原始观察结果,而不是从中提取最显著的信息。因此,我们必须有意义地限制自编码器,以防止这种情况发生。

过完备自编码器

如果编码器在比原始输入维度更多的维度上学习表示,那么自编码器被认为是过完备的。这样的自编码器简单地复制原始观察结果,并且不像欠完备自编码器那样被迫有效而紧凑地捕获原始分布的信息。话虽如此,如果我们采用某种形式的正则化,对神经网络学习不必要复杂函数进行惩罚,过完备自编码器可以成功用于降维和自动特征工程。

与欠完备自编码器相比,正则化超完备自编码器更难成功设计,但可能更强大,因为它们可以学习到更复杂但不过度复杂的表示,从而更好地近似原始观察结果而不是精确复制它们。

简而言之,表现良好的自编码器是那些学习到新表示,这些表示足够接近原始观察结果但并非完全相同的自编码器。为了做到这一点,自编码器本质上学习了一个新的概率分布。

密集自编码器 vs. 稀疏自编码器

如果你还记得,在第三章中,我们有密集(正常)和稀疏版本的降维算法。自编码器的工作原理类似。到目前为止,我们只讨论了输出密集最终矩阵的普通自编码器,以便少数特征具有有关原始数据的最显著信息。然而,我们可能希望输出一个稀疏的最终矩阵,以便捕获的信息更好地分布在自编码器学习到的特征之间。

为了做到这一点,我们需要在自编码器中包括不仅作为一部分的重构误差,还要包括稀疏惩罚,以便自编码器必须考虑最终矩阵的稀疏性。稀疏自编码器通常是超完备的——隐藏层的单元数比输入特征的数量多,但只有很小一部分隐藏单元被允许同时处于活动状态。这样定义的稀疏自编码器将输出一个具有更多零值的最终矩阵,所捕获的信息将更好地分布在学习到的特征中。

对于某些机器学习应用,稀疏自编码器具有更好的性能,并且学习到的表示也与正常(密集)自编码器略有不同。稍后,我们将使用真实示例来看看这两种类型的自编码器之间的区别。

去噪自编码器

正如你现在所知,自编码器能够从原始输入数据中学习新的(并且改进的)表示,捕获最显著的元素,但忽略原始数据中的噪音。

在某些情况下,我们可能希望设计的自编码器更积极地忽略数据中的噪声,特别是如果我们怀疑原始数据在某种程度上被损坏。想象一下在白天嘈杂的咖啡店里记录两个人之间的对话。我们希望将对话(信号)与背景嘈杂声(噪音)隔离开来。又或者,想象一下由于低分辨率或某种模糊效果而导致图像有颗粒感或失真的数据集。我们希望将核心图像(信号)与失真(噪音)隔离开来。

针对这些问题,我们可以设计一个去噪自编码器,它接收损坏的数据作为输入,并训练以尽可能地输出原始未损坏的数据。当然,尽管这并不容易,但这显然是自编码器应用于解决现实问题的一个非常强大的应用。

变分自编码器

到目前为止,我们已经讨论了使用自编码器来学习原始输入数据的新表示(通过编码器),以最小化新重构数据(通过解码器)与原始输入数据之间的重构误差。

在这些示例中,编码器的大小是固定的,为n,其中n通常比原始维度的数量小——换句话说,我们训练了一个欠完备自编码器。或者n可能大于原始维度的数量——一个过完备自编码器——但通过使用正则化惩罚、稀疏性惩罚等进行约束。但在所有这些情况下,编码器输出一个固定大小为n的单个向量。

一种替代的自编码器被称为变分自编码器,其编码器输出两个向量而不是一个:一个均值向量mu和一个标准差向量sigma。这两个向量形成随机变量,使得musigma的第i个元素对应于第i个随机变量的均值标准差。通过编码器形成这种随机输出,变分自编码器能够基于其从输入数据中学到的知识在连续空间中进行采样。

变分自编码器不仅局限于它训练过的示例,还可以进行泛化并输出新的示例,即使它可能以前从未见过完全相似的示例。这非常强大,因为现在变分自编码器可以生成看起来属于从原始输入数据学习的分布中的新合成数据。像这样的进展导致了一个完全新的和趋势的无监督学习领域,被称为生成建模,其中包括生成对抗网络。使用这些模型,可以生成合成图像、语音、音乐、艺术等,为 AI 生成数据开辟了无限可能。

结论

在本章中,我们介绍了神经网络及其流行的开源库 TensorFlow 和 Keras。我们还探讨了自编码器及其从原始输入数据学习新表示的能力。变种包括稀疏自编码器、去噪自编码器和变分自编码器,等等。

在第八章中,我们将使用本章讨论的技术构建实际应用程序。

在我们继续之前,让我们重新思考一下为什么自动特征提取如此重要。如果没有自动提取特征的能力,数据科学家和机器学习工程师将不得不手工设计可能在解决现实世界问题中重要的特征。这是非常耗时的,并且会极大地限制人工智能领域的进展。

实际上,直到 Geoffrey Hinton 和其他研究人员开发出使用神经网络自动学习新特征的方法——从 2006 年开始引发了深度学习革命——涉及计算机视觉、语音识别、机器翻译等问题一直大多数难以解决。

一旦自动编码器和其他神经网络变种被用来自动从输入数据中提取特征,许多这些问题就变得可以解决,导致过去十年间机器学习领域的一些重大突破。

通过在第八章中的自动编码器的实际应用中,你将看到自动特征提取的力量。

¹ 这个过程被称为正则化。

² 欲了解更多有关 TensorFlow 的信息,请查阅网站

第八章:自动编码器实战

在本章中,我们将构建使用各种版本的自动编码器的应用程序,包括欠完备、过完备、稀疏、去噪和变分自动编码器。

首先,让我们回到我们在 第三章 中介绍的信用卡欺诈检测问题。对于这个问题,我们有 284,807 笔信用卡交易,其中只有 492 笔是欺诈性的。使用监督模型,我们实现了平均精度为 0.82,这非常令人印象深刻。我们可以找到超过 80% 的欺诈,并且精度超过 80%。使用无监督模型,我们实现了平均精度为 0.69,考虑到我们没有使用标签,这也是非常好的。我们可以找到超过 75% 的欺诈,并且精度超过 75%。

让我们看看如何使用自动编码器来解决同样的问题,它也是一种无监督算法,但使用了神经网络。

数据准备

让我们首先加载必要的库:

'''Main'''
import numpy as np
import pandas as pd
import os, time, re
import pickle, gzip

'''Data Viz'''
import matplotlib.pyplot as plt
import seaborn as sns
color = sns.color_palette()
import matplotlib as mpl

%matplotlib inline

'''Data Prep and Model Evaluation'''
from sklearn import preprocessing as pp
from sklearn.model_selection import train_test_split
from sklearn.model_selection import StratifiedKFold
from sklearn.metrics import log_loss
from sklearn.metrics import precision_recall_curve, average_precision_score
from sklearn.metrics import roc_curve, auc, roc_auc_score

'''Algos'''
import lightgbm as lgb

'''TensorFlow and Keras'''
import tensorflow as tf
import keras
from keras import backend as K
from keras.models import Sequential, Model
from keras.layers import Activation, Dense, Dropout
from keras.layers import BatchNormalization, Input, Lambda
from keras import regularizers
from keras.losses import mse, binary_crossentropy

接下来,加载数据集并准备使用。我们将创建一个 dataX 矩阵,其中包含所有的 PCA 成分和特征 Amount,但排除 ClassTime。我们将把 Class 标签存储在 dataY 矩阵中。我们还将对 dataX 矩阵中的特征进行缩放,使所有特征的平均值为零,标准差为一。

data = pd.read_csv('creditcard.csv')
dataX = data.copy().drop(['Class','Time'],axis=1)
dataY = data['Class'].copy()
featuresToScale = dataX.columns
sX = pp.StandardScaler(copy=True, with_mean=True, with_std=True)
dataX.loc[:,featuresToScale] = sX.fit_transform(dataX[featuresToScale])

就像我们在 第三章 中所做的那样,我们将创建一个训练集,其中包含三分之二的数据和标签,并创建一个测试集,其中包含三分之一的数据和标签。

让我们将训练集和测试集分别存储为 X_train_AEX_test_AE,我们很快将在自动编码器中使用它们。

X_train, X_test, y_train, y_test = \
    train_test_split(dataX, dataY, test_size=0.33, \
                     random_state=2018, stratify=dataY)

X_train_AE = X_train.copy()
X_test_AE = X_test.copy()

让我们还要重用本书中早期介绍的函数 anomalyScores,来计算原始特征矩阵与新重构特征矩阵之间的重构误差。该函数计算平方误差的总和,并将其归一化到零到一的范围内。

这是一个关键的函数。误差接近于一的交易最异常(即具有最高的重构误差),因此最可能是欺诈性的。误差接近于零的交易具有最低的重构误差,最可能是正常的。

def anomalyScores(originalDF, reducedDF):
    loss = np.sum((np.array(originalDF) - \
                   np.array(reducedDF))**2, axis=1)
    loss = pd.Series(data=loss,index=originalDF.index)
    loss = (loss-np.min(loss))/(np.max(loss)-np.min(loss))
    return loss

我们还将重用一个名为 plotResults 的函数来绘制精确率-召回率曲线、平均精度和 ROC 曲线。

def plotResults(trueLabels, anomalyScores, returnPreds = False):
    preds = pd.concat([trueLabels, anomalyScores], axis=1)
    preds.columns = ['trueLabel', 'anomalyScore']
    precision, recall, thresholds = \
        precision_recall_curve(preds['trueLabel'], \
                               preds['anomalyScore'])
    average_precision = average_precision_score( \
                        preds['trueLabel'], preds['anomalyScore'])

    plt.step(recall, precision, color='k', alpha=0.7, where='post')
    plt.fill_between(recall, precision, step='post', alpha=0.3, color='k')

    plt.xlabel('Recall')
    plt.ylabel('Precision')
    plt.ylim([0.0, 1.05])
    plt.xlim([0.0, 1.0])

    plt.title('Precision-Recall curve: Average Precision = \
 {0:0.2f}'.format(average_precision))

    fpr, tpr, thresholds = roc_curve(preds['trueLabel'], \
                                     preds['anomalyScore'])
    areaUnderROC = auc(fpr, tpr)

    plt.figure()
    plt.plot(fpr, tpr, color='r', lw=2, label='ROC curve')
    plt.plot([0, 1], [0, 1], color='k', lw=2, linestyle='--')
    plt.xlim([0.0, 1.0])
    plt.ylim([0.0, 1.05])
    plt.xlabel('False Positive Rate')
    plt.ylabel('True Positive Rate')
    plt.title('Receiver operating characteristic: Area under the \
 curve = {0:0.2f}'.format(areaUnderROC))
    plt.legend(loc="lower right")
    plt.show()

    if returnPreds==True:
        return preds

自动编码器的组成部分

首先,让我们构建一个非常简单的自动编码器,包括输入层、单隐藏层和输出层。我们将原始特征矩阵 x 输入到自动编码器中——这由输入层表示。然后,激活函数将应用于输入层,生成隐藏层。这个激活函数称为 f,代表自动编码器的 编码器 部分。隐藏层称为 h(等于 f(x)),代表新学习到的表示。

接下来,激活函数应用于隐藏层(即新学到的表示),以重构原始观测数据。这个激活函数称为g,代表自动编码器的解码器部分。输出层称为r(等于g(h)),代表新重构的观测数据。为了计算重构误差,我们将比较新构建的观测数据r与原始观测数据x

激活函数

在我们决定在这个单隐藏层自动编码器中使用的节点数之前,让我们讨论一下激活函数。

神经网络学习在每个层的节点上应用的权重,但节点是否激活(用于下一层)由激活函数决定。换句话说,激活函数应用于每层的加权输入(加上偏置,如果有的话)。我们将加权输入加偏置称为Y

激活函数接收Y,如果Y超过某个阈值,则激活;否则,不激活。如果激活,则给定节点中的信息传递到下一层;否则,不传递。但是,我们不希望简单的二进制激活。相反,我们希望一系列激活值。为此,我们可以选择线性激活函数或非线性激活函数。线性激活函数是无界的。它可以生成介于负无穷到正无穷之间的激活值。常见的非线性激活函数包括 sigmoid、双曲正切(或简称 tanh)、修正线性单元(或简称 ReLu)和 softmax:

Sigmoid 函数

Sigmoid 函数是有界的,并且可以生成介于零和一之间的激活值。

Tanh 函数

tanh 函数也是有界的,并且可以生成介于负一到正一之间的激活值。其梯度比 sigmoid 函数更陡。

ReLu 函数

ReLu 函数具有一个有趣的性质。如果Y是正的,ReLu 将返回Y。否则,将返回零。因此,对于正值的Y,ReLu 是无界的。

Softmax 函数

softmax 函数用作神经网络中分类问题的最终激活函数,因为它将分类概率归一化为总和为一的值。

在所有这些函数中,线性激活函数是最简单且计算开销最小的。ReLu 是接下来计算开销第二小的,其它则依次类推。

我们的第一个自动编码器

让我们从一个具有线性激活函数的两层自动编码器开始。请注意,只有隐藏层的数量加上输出层计入神经网络的层数。由于我们有一个隐藏层,因此这被称为两层神经网络。

要使用 TensorFlow 和 Keras 构建这一过程,我们首先需要调用Sequential model API。Sequential 模型是层的线性堆叠,在编译模型并在数据上进行训练之前,我们将把我们想要的层类型传递到模型中。¹

# Model one
# Two layer complete autoencoder with linear activation

# Call neural network API
model = Sequential()

一旦我们调用了 Sequential 模型,我们接下来需要指定输入形状,即指定与原始特征矩阵dataX中维度数量相匹配的维度数,这个数字是 29。

我们还需要指定应用于输入层的激活函数(也称为编码器函数)以及我们希望隐藏层具有的节点数。我们将使用linear作为激活函数。

首先,让我们使用一个完整的自编码器,其中隐藏层中的节点数等于输入层中的节点数,即 29。所有这些都可以使用一行代码完成:

model.add(Dense(units=29, activation='linear',input_dim=29))

同样地,我们需要指定应用于隐藏层的激活函数(也称为解码器函数),以重构观察结果,并且我们希望输出层具有的维数。由于我们希望最终重构的矩阵与原始矩阵具有相同的维度,维数需要为 29。此外,我们还将在解码器中使用线性激活函数:

model.add(Dense(units=29, activation='linear'))

接下来,我们需要编译我们为神经网络设计的层。这需要我们选择一个损失函数(也称为目标函数)来指导权重的学习,一个优化器来设定权重学习的过程,并列出一系列度量标准以帮助我们评估神经网络的好坏。

损失函数

让我们从损失函数开始。回想一下,我们根据自编码器基于重构后的特征矩阵与我们输入自编码器的原始特征矩阵之间的重构误差来评估模型。

因此,我们希望将均方误差作为评估指标。(对于我们自定义的评估函数,我们使用平方误差之和,这类似。)²

优化器

神经网络训练多个回合(称为epochs)。在每个回合中,神经网络调整其学习的权重,以减少与上一个回合相比的损失。设置学习这些权重的过程由优化器决定。我们希望找到一个过程,帮助神经网络高效地学习各层节点的最佳权重,从而最小化我们选择的损失函数。

要学习最佳权重,神经网络需要智能地调整其对最佳权重的“猜测”。一种方法是迭代地朝着有助于逐步减少损失函数的方向移动权重。但更好的方法是以一定的随机性朝着这个方向移动权重,换句话说,随机地移动权重。

尽管还有更多内容,这个过程被称为随机梯度下降(或简称 SGD),是训练神经网络中最常用的优化器。³ SGD 具有一个称为alpha的单一学习率,用于所有权重更新,而这个学习率在训练过程中不会改变。然而,在大多数情况下,调整学习率是更好的选择。例如,在早期的 epochs 中,通过较大的程度调整权重更为合理,换句话说,具有较大的学习率或 alpha。

在后续的 epochs 中,当权重更加优化时,微调权重的程度比单向或另一方向上的大步调整更为合理。因此,比 SGD 更好的优化器是Adam 优化算法,它源自自适应矩估计。Adam 优化器动态调整学习率,而不像 SGD 那样在训练过程中保持不变,并且这是我们将使用的优化器。⁴

对于这个优化器,我们可以设置α,这决定了权重更新的速度。较大的α值在更新学习率之前会导致更快的初始学习速度。

训练模型

最后,我们需要选择评估指标,我们将其设置为accuracy以保持简单:⁵

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

接下来,我们需要选择 epoch 数量和批次大小,然后通过调用fit方法开始训练过程。epoch 数量决定了整个传递到神经网络中的数据集训练次数。我们将这个设置为 10 来开始。

批次设置了神经网络在进行下一个梯度更新之前训练的样本数量。如果批次等于观察的总数,神经网络将每个 epoch 仅进行一次梯度更新。否则,它将在每个 epoch 中进行多次更新。我们将这个设置为通用的 32 个样本来开始。

在 fit 方法中,我们将传入初始输入矩阵x和目标矩阵y。在我们的案例中,xy都将是原始特征矩阵X_train_AE,因为我们希望比较自编码器的输出——重构特征矩阵与原始特征矩阵来计算重构误差。

记住,这是一个完全无监督的解决方案,所以我们根本不会使用y矩阵。我们将在整个训练矩阵上测试重构误差来验证我们的模型:

num_epochs = 10
batch_size = 32

history = model.fit(x=X_train_AE, y=X_train_AE,
                    epochs=num_epochs,
                    batch_size=batch_size,
                    shuffle=True,
                    validation_data=(X_train_AE, X_train_AE),
                    verbose=1)

由于这是一个完整的自编码器——隐藏层与输入层具有相同的维数,因此对于训练集和验证集,损失都非常低:

Training history of complete autoencoder

Train on 190820 samples, validate on 190820 samples
Epoch 1/10
190820/190820 [==============================] - 29s 154us/step - loss: 0.1056
- acc: 0.8728 - val_loss: 0.0013 - val_acc: 0.9903
Epoch 2/10
190820/190820 [==============================] - 27s 140us/step - loss: 0.0012
- acc: 0.9914 - val_loss: 1.0425e-06 - val_acc: 0.9995
Epoch 3/10
190820/190820 [==============================] - 23s 122us/step - loss: 6.6244
e-04 - acc: 0.9949 - val_loss: 5.2491e-04 - val_acc: 0.9913
Epoch 4/10
190820/190820 [==============================] - 23s 119us/step - loss: 0.0016
- acc: 0.9929 - val_loss: 2.2246e-06 - val_acc: 0.9995
Epoch 5/10
190820/190820 [==============================] - 23s 119us/step - loss: 5.7424
e-04 - acc: 0.9943 - val_loss: 9.0811e-05 - val_acc: 0.9970
Epoch 6/10
190820/190820 [==============================] - 22s 118us/step - loss: 5.4950
e-04 - acc: 0.9941 - val_loss: 6.0598e-05 - val_acc: 0.9959
Epoch 7/10
190820/190820 [==============================] - 22s 117us/step - loss: 5.2291
e-04 - acc: 0.9946 - val_loss: 0.0023 - val_acc: 0.9675
Epoch 8/10
190820/190820 [==============================] - 22s 117us/step - loss: 6.5130
e-04 - acc: 0.9932 - val_loss: 4.5059e-04 - val_acc: 0.9945
Epoch 9/10
190820/190820 [==============================] - 23s 122us/step - loss: 4.9077
e-04 - acc: 0.9952 - val_loss: 7.2591e-04 - val_acc: 0.9908
Epoch 10/10
190820/190820 [==============================] - 23s 118us/step - loss: 6.1469
e-04 - acc: 0.9945 - val_loss: 4.4131e-06 - val_acc: 0.9991

这并不是最优的——自编码器对原始特征矩阵进行了过于精确的重构,记住了输入。

请回想一下,自编码器旨在学习一个新的表示,捕捉原始输入矩阵中最显著的信息,同时丢弃不太相关的信息。简单地记忆输入——也称为学习恒等函数——不会带来新的和改进的表示学习。

在测试集上评估

让我们使用测试集来评估这个自编码器在识别信用卡交易中的欺诈问题上的成功程度。我们将使用predict方法来完成这个任务:

predictions = model.predict(X_test, verbose=1)
anomalyScoresAE = anomalyScores(X_test, predictions)
preds = plotResults(y_test, anomalyScoresAE, True)

如图 8-1 所示,平均精度为 0.30,这并不是很好的结果。在第四章的无监督学习中,使用无监督学习的最佳平均精度为 0.69,有监督系统的平均精度为 0.82。然而,每次训练过程将为训练后的自编码器产生略有不同的结果,因此您可能不会在您的运行中看到相同的性能。

为了更好地了解两层完整自编码器在测试集上的表现,让我们分别运行这个训练过程十次,并存储每次运行在测试集上的平均精度。我们将根据这 10 次运行的平均精度来评估这个完整自编码器在捕捉欺诈方面的能力。

完整自编码器的评估指标

第 8-1 图。完整自编码器的评估指标

为了总结我们迄今为止的工作,这里是从头到尾模拟 10 次运行的代码:

# 10 runs - We will capture mean of average precision
test_scores = []
for i in range(0,10):
    # Call neural network API
    model = Sequential()

    # Apply linear activation function to input layer
    # Generate hidden layer with 29 nodes, the same as the input layer
    model.add(Dense(units=29, activation='linear',input_dim=29))

    # Apply linear activation function to hidden layer
    # Generate output layer with 29 nodes
    model.add(Dense(units=29, activation='linear'))

    # Compile the model
    model.compile(optimizer='adam',
                  loss='mean_squared_error',
                  metrics=['accuracy'])

    # Train the model
    num_epochs = 10
    batch_size = 32

    history = model.fit(x=X_train_AE, y=X_train_AE,
                        epochs=num_epochs,
                        batch_size=batch_size,
                        shuffle=True,
                        validation_data=(X_train_AE, X_train_AE),
                        verbose=1)

    # Evaluate on test set
    predictions = model.predict(X_test, verbose=1)
    anomalyScoresAE = anomalyScores(X_test, predictions)
    preds, avgPrecision = plotResults(y_test, anomalyScoresAE, True)
    test_scores.append(avgPrecision)

print("Mean average precision over 10 runs: ", np.mean(test_scores))
test_scores

下面的代码总结了这 10 次运行的结果。平均精度为 0.30,但平均精度从 0.02 到 0.72 不等。变异系数(定义为 10 次运行中标准差除以平均值)为 0.88。

Mean average precision over 10 runs: 0.30108318944579776
Coefficient of variation over 10 runs: 0.8755095071789248

[0.25468022666666157,
0.092705950994909,
0.716481644928299,
0.01946589342639965,
0.25623865457838263,
0.33597083510378234,
0.018757053070824415,
0.6188569405068724,
0.6720552647581304,
0.025619070873716072]

让我们尝试通过构建这个自编码器的变种来改进我们的结果。

具有线性激活函数的两层欠完整自编码器

让我们尝试一个欠完整自编码器,而不是完整的自编码器。

与先前的自编码器相比,唯一变化的是隐藏层中节点的数量。不再将其设置为原始维度的数量(29),我们将节点数设置为 20。换句话说,这个自编码器是一个受限制的自编码器。编码器函数被迫用较少的节点捕捉输入层中的信息,解码器则必须将这个新的表示用于重构原始矩阵。

我们应该预期这里的损失比完整自编码器的损失更高。让我们运行代码。我们将执行 10 次独立运行,以测试各种欠完整自编码器在捕捉欺诈方面的表现:

# 10 runs - We will capture mean of average precision
test_scores = []
for i in range(0,10):
    # Call neural network API
    model = Sequential()

    # Apply linear activation function to input layer
    # Generate hidden layer with 20 nodes
    model.add(Dense(units=20, activation='linear',input_dim=29))

    # Apply linear activation function to hidden layer
    # Generate output layer with 29 nodes
    model.add(Dense(units=29, activation='linear'))

    # Compile the model
    model.compile(optimizer='adam',
                  loss='mean_squared_error',
                  metrics=['accuracy'])

    # Train the model
    num_epochs = 10
    batch_size = 32

    history = model.fit(x=X_train_AE, y=X_train_AE,
                        epochs=num_epochs,
                        batch_size=batch_size,
                        shuffle=True,
                        validation_data=(X_train_AE, X_train_AE),
                        verbose=1)

    # Evaluate on test set
    predictions = model.predict(X_test, verbose=1)
    anomalyScoresAE = anomalyScores(X_test, predictions)
    preds, avgPrecision = plotResults(y_test, anomalyScoresAE, True)
    test_scores.append(avgPrecision)

print("Mean average precision over 10 runs: ", np.mean(test_scores))
test_scores

如下所示,欠完整自编码器的损失远高于完整自编码器的损失。显然,自编码器学习了一个比原始输入矩阵更加新颖和受限制的表示——自编码器并非简单地记忆输入:

Training history of undercomplete autoencoder with 20 nodes

Train on 190820 samples, validate on 190820 samples
Epoch 1/10
190820/190820 [==============================] - 28s 145us/step - loss: 0.3588
- acc: 0.5672 - val_loss: 0.2789 - val_acc: 0.6078
Epoch 2/10
190820/190820 [==============================] - 29s 153us/step - loss: 0.2817
- acc: 0.6032 - val_loss: 0.2757 - val_acc: 0.6115
Epoch 3/10
190820/190820 [==============================] - 28s 147us/step - loss: 0.2793
- acc: 0.6147 - val_loss: 0.2755 - val_acc: 0.6176
Epoch 4/10
190820/190820 [==============================] - 30s 155us/step - loss: 0.2784
- acc: 0.6164 - val_loss: 0.2750 - val_acc: 0.6167
Epoch 5/10
190820/190820 [==============================] - 29s 152us/step - loss: 0.2786
- acc: 0.6188 - val_loss: 0.2746 - val_acc: 0.6126
Epoch 6/10
190820/190820 [==============================] - 29s 151us/step - loss: 0.2776
- acc: 0.6140 - val_loss: 0.2752 - val_acc: 0.6043
Epoch 7/10
190820/190820 [==============================] - 30s 156us/step - loss: 0.2775
- acc: 0.5947 - val_loss: 0.2745 - val_acc: 0.5946
Epoch 8/10
190820/190820 [==============================] - 29s 149us/step - loss: 0.2770
- acc: 0.5903 - val_loss: 0.2740 - val_acc: 0.5882
Epoch 9/10
190820/190820 [==============================] - 29s 153us/step - loss: 0.2768
- acc: 0.5921 - val_loss: 0.2770 - val_acc: 0.5801
Epoch 10/10
190820/190820 [==============================] - 29s 150us/step - loss: 0.2767
- acc: 0.5803 - val_loss: 0.2744 - val_acc: 0.5743
93987/93987[==============================] - 3s 36us/step

这是自编码器应该工作的方式——它应该学习一个新的表示。图 8-2 显示了这种新表示在识别欺诈方面的有效性。

使用 20 个节点的欠完全自编码器的评估指标

图 8-2. 使用 20 个节点的欠完全自编码器的评估指标

平均精度为 0.29,与完全自编码器的类似。

下面的代码显示了 10 次运行中平均精度的分布。平均精度的均值为 0.31,但离散度非常小(如 0.03 的离散系数所示)。这比使用完全自编码器设计的系统稳定得多。

Mean average precision over 10 runs: 0.30913783987972737
Coefficient of variation over 10 runs: 0.032251659812254876

[0.2886910204920736,
0.3056142045082387,
0.31658073591381186,
0.30590858583039254,
0.31824197682595556,
0.3136952374067599,
0.30888135217515555,
0.31234000424933206,
0.29695149753706923,
0.3244746838584846]

但我们仍然陷入相当平庸的平均精度。为什么欠完全自编码器表现不佳呢?可能是因为这个欠完全自编码器节点不够。或者,我们可能需要使用更多隐藏层进行训练。让我们逐个尝试这两种变化。

增加节点数量

下面的代码显示了使用 27 个节点的两层欠完全自编码器的训练损失:

Training history of undercomplete autoencoder with 27 nodes

Train on 190820 samples, validate on 190820 samples

Epoch 1/10
190820/190820 [==============================] - 29s 150us/step - loss: 0.1169
- acc: 0.8224 - val_loss: 0.0368 - val_acc: 0.8798
Epoch 2/10
190820/190820 [==============================] - 29s 154us/step - loss: 0.0388
- acc: 0.8610 - val_loss: 0.0360 - val_acc: 0.8530
Epoch 3/10
190820/190820 [==============================] - 30s 156us/step - loss: 0.0382
- acc: 0.8680 - val_loss: 0.0359 - val_acc: 0.8745
Epoch 4/10
190820/190820 [==============================] - 30s 156us/step - loss: 0.0371
- acc: 0.8811 - val_loss: 0.0353 - val_acc: 0.9021
Epoch 5/10
190820/190820 [==============================] - 30s 155us/step - loss: 0.0373
- acc: 0.9114 - val_loss: 0.0352 - val_acc: 0.9226
Epoch 6/10
190820/190820 [==============================] - 30s 155us/step - loss: 0.0377
- acc: 0.9361 - val_loss: 0.0370 - val_acc: 0.9416
Epoch 7/10
190820/190820 [==============================] - 30s 156us/step - loss: 0.0361
- acc: 0.9448 - val_loss: 0.0358 - val_acc: 0.9378
Epoch 8/10
190820/190820 [==============================] - 30s 156us/step - loss: 0.0354
- acc: 0.9521 - val_loss: 0.0350 - val_acc: 0.9503
Epoch 9/10
190820/190820 [==============================] - 29s 153us/step - loss: 0.0352
- acc: 0.9613 - val_loss: 0.0349 - val_acc: 0.9263
Epoch 10/10
190820/190820 [==============================] - 29s 153us/step - loss: 0.0353
- acc: 0.9566 - val_loss: 0.0343 - val_acc: 0.9477
93987/93987[==============================] - 4s 39us/step

图 8-3 展示了平均精度、精确率-召回率曲线和 auROC 曲线。

使用 27 个节点的欠完全自编码器的评估指标

图 8-3. 使用 27 个节点的欠完全自编码器的评估指标

平均精度显著提高至 0.70. 这比完全自编码器的平均精度更好,也比第四章中最佳的无监督学习解决方案更好。

下面的代码总结了 10 次运行中平均精度的分布。平均精度的均值为 0.53,比之前的约 0.30 平均精度好得多。平均精度的离散度也相当好,离散系数为 0.50。

Mean average precision over 10 runs: 0.5273341559141779
Coefficient of variation over 10 runs: 0.5006880691999009

[0.689799495450694,
0.7092146840717755,
0.7336692377321005,
0.6154173765950426,
0.7068800243349335,
0.35250757724667586,
0.6904117414832501,
0.02335388808244066,
0.690798140588336,
0.061289393556529626]

我们在先前基于自编码器的异常检测系统上有了明显改进。

添加更多隐藏层

让我们看看通过向自编码器添加额外的隐藏层是否可以改善我们的结果。目前我们将继续使用线性激活函数。

注意

实验是发现解决问题的最佳神经网络架构的重要组成部分。您所做的一些更改会带来更好的结果,而另一些则会带来更糟糕的结果。了解如何在搜索过程中修改神经网络和超参数以改进解决方案是非常重要的。

我们将不再使用 27 个节点的单隐藏层,而是使用一个 28 个节点的隐藏层和一个 27 个节点的隐藏层。这只是与先前使用的稍微不同。由于我们有两个隐藏层加上输出层,所以现在是一个三层神经网络。输入层不算在这个数目中。

这个额外的隐藏层只需要添加一行代码,如下所示:

# Model two
# Three layer undercomplete autoencoder with linear activation
# With 28 and 27 nodes in the two hidden layers, respectively

model = Sequential()
model.add(Dense(units=28, activation='linear',input_dim=29))
model.add(Dense(units=27, activation='linear'))
model.add(Dense(units=29, activation='linear'))

下面的代码总结了 10 次运行中平均精度的分布。平均精度的平均值为 0.36,比刚刚取得的 0.53 还要差。平均精度的离散度也更差,变异系数为 0.94(越高越差):

Mean average precision over 10 runs: 0.36075271075596366
Coefficient of variation over 10 runs: 0.9361649046827353

[0.02259626054852924,
0.6984699403560997,
0.011035001202665167,
0.06621450000830197,
0.008916986608776182,
0.705399684020873,
0.6995233144849828,
0.008263068338243631,
0.6904537524978872,
0.6966545994932775]

非线性自编码器

现在让我们使用非线性激活函数来构建一个欠完备自编码器。我们将使用 ReLu,但您也可以尝试 tanh、sigmoid 和其他非线性激活函数。

我们将包含三个隐藏层,分别有 27、22 和 27 个节点。在概念上,前两个激活函数(应用于输入和第一个隐藏层)执行编码,创建具有 22 个节点的第二个隐藏层。然后,接下来的两个激活函数执行解码,将 22 节点的表示重构为原始维度的数量,即 29:

model = Sequential()
model.add(Dense(units=27, activation='relu',input_dim=29))
model.add(Dense(units=22, activation='relu'))
model.add(Dense(units=27, activation='relu'))
model.add(Dense(units=29, activation='relu'))

下面的代码显示了这个自编码器的损失,而图 8-4 显示了平均精度、精确率-召回率曲线和 auROC 曲线:

Training history of undercomplete autoencoder with three hidden layers and ReLu
activation function

Train on 190820 samples, validate on 190820 samples

Epoch 1/10
190820/190820 [==============================] - 32s 169us/step - loss: 0.7010
- acc: 0.5626 - val_loss: 0.6339 - val_acc: 0.6983
Epoch 2/10
190820/190820 [==============================] - 33s 174us/step - loss: 0.6302
- acc: 0.7132 - val_loss: 0.6219 - val_acc: 0.7465
Epoch 3/10
190820/190820 [==============================] - 34s 177us/step - loss: 0.6224
- acc: 0.7367 - val_loss: 0.6198 - val_acc: 0.7528
Epoch 4/10
190820/190820 [==============================] - 34s 179us/step - loss: 0.6227
- acc: 0.7380 - val_loss: 0.6205 - val_acc: 0.7471
Epoch 5/10
190820/190820 [==============================] - 33s 174us/step - loss: 0.6206
- acc: 0.7452 - val_loss: 0.6202 - val_acc: 0.7353
Epoch 6/10
190820/190820 [==============================] - 33s 175us/step - loss: 0.6206
- acc: 0.7458 - val_loss: 0.6192 - val_acc: 0.7485
Epoch 7/10
190820/190820 [==============================] - 33s 174us/step - loss: 0.6199
- acc: 0.7481 - val_loss: 0.6239 - val_acc: 0.7308
Epoch 8/10
190820/190820 [==============================] - 33s 175us/step - loss: 0.6203
- acc: 0.7497 - val_loss: 0.6183 - val_acc: 0.7626
Epoch 9/10
190820/190820 [==============================] - 34s 177us/step - loss: 0.6197
- acc: 0.7491 - val_loss: 0.6188 - val_acc: 0.7531
Epoch 10/10
190820/190820 [==============================] - 34s 177us/step - loss: 0.6201
- acc: 0.7486 - val_loss: 0.6188 - val_acc: 0.7540
93987/93987 [==============================] - 5s 48 us/step

三层隐藏层和 ReLu 激活函数下的欠完备自编码器评估指标

图 8-4. 三层隐藏层和 ReLu 激活函数下的欠完备自编码器评估指标

结果显著更差。

下面的代码总结了 10 次运行中平均精度的分布。平均精度的平均值为 0.22,比之前的 0.53 要差。平均精度的离散度非常小,变异系数为 0.06:

Mean average precision over 10 runs:    0.2232934196381843
Coefficient of variation over 10 runs:   0.060779960264380296

[0.22598829389665595,
0.22616147166925166,
0.22119489753135715,
0.2478548473814437,
0.2251289336369011,
0.2119454446242229,
0.2126914064768752,
0.24581338950742185,
0.20665608837737512,
0.20949942328033827]

这些结果比使用线性激活函数的简单自编码器要糟糕得多。也许对于这个数据集来说,一个线性的、欠完备的自编码器是最佳解决方案。

对于其他数据集,情况可能并非总是如此。和往常一样,需要进行实验以找到最优解。改变节点数、隐藏层数和激活函数的组合,看看解决方案变得更好或更差了多少。

这种类型的实验被称为超参数优化。您正在调整超参数——节点数、隐藏层数和激活函数的组合,以寻找最优解。

具有线性激活的过完备自编码器

现在让我们来强调一下过完备自编码器的问题。过完备自编码器的隐藏层中的节点数比输入层或输出层都要多。由于神经网络模型的容量非常高,自编码器只是简单地记忆训练过的观测结果。

换句话说,自编码器学习了恒等函数,这正是我们想要避免的。自编码器会对训练数据过拟合,并且在区分欺诈信用卡交易和正常交易方面表现非常差。

记住,我们需要自编码器在训练集中学习信用卡交易的显著特征,这样它才能学习到正常交易的样子,而不是死记硬背不太正常和稀少的欺诈交易的信息。

只有当自编码器能够丢失一些训练集中的信息时,它才能够分离欺诈交易和正常交易:

model = Sequential()
model.add(Dense(units=40, activation='linear',input_dim=29))
model.add(Dense(units=29, activation='linear'))

下面的代码显示了这个自编码器的损失,并且图 8-6 显示了平均精度、精确率-召回率曲线和 auROC 曲线:

Training history of overcomplete autoencoder with single hidden layer and
 linear activation function

Train on 190820 samples, validate on 190820 samples
Epoch 1/10
190820/190820 [==============================] - 31s 161us/step - loss: 0.0498
- acc: 0.9438 - val_loss: 9.2301e-06 - val_acc: 0.9982
Epoch 2/10
190820/190820 [==============================] - 33s 171us/step - loss: 0.0014
- acc: 0.9925 - val_loss: 0.0019 - val_acc: 0.9909
Epoch 3/10
190820/190820 [==============================] - 33s 172us/step - loss: 7.6469
e-04 - acc: 0.9947 - val_loss: 4.5314e-05 - val_acc: 0.9970
Epoch 4/10
190820/190820 [==============================] - 35s 182us/step - loss: 0.0010
- acc: 0.9930 - val_loss: 0.0039 - val_acc: 0.9859
Epoch 5/10
190820/190820 [==============================] - 32s 166us/step - loss: 0.0012
- acc: 0.9924 - val_loss: 8.5141e-04 - val_acc: 0.9886
Epoch 6/10
190820/190820 [==============================] - 31s 163us/step - loss: 5.0655
e-04 - acc: 0.9955 - val_loss: 8.2359e-04 - val_acc: 0.9910
Epoch 7/10
190820/190820 [==============================] - 30s 156us/step - loss: 7.6046
e-04 - acc: 0.9930 - val_loss: 0.0045 - val_acc: 0.9933
Epoch 8/10
190820/190820 [==============================] - 30s 157us/step - loss: 9.1609
e-04 - acc: 0.9930 - val_loss: 7.3662e-04 - val_acc: 0.9872
Epoch 9/10
190820/190820 [==============================] - 30s 158us/step - loss: 7.6287
e-04 - acc: 0.9929 - val_loss: 2.5671e-04 - val_acc: 0.9940
Epoch 10/10
190820/190820 [==============================] - 30s 157us/step - loss: 7.0697
e-04 - acc: 0.9928 - val_loss: 4.5272e-06 - val_acc: 0.9994
93987/93987[==============================] - 4s 48us/step

单隐藏层和线性激活函数的过度完备自编码器的评估指标

图 8-5. 单隐藏层、线性激活函数的过度完备自编码器的评估指标

如预期的那样,损失非常低,而且过度完备的自编码器在检测欺诈信用卡交易方面表现非常糟糕。

下面的代码总结了 10 次运行中平均精度的分布。平均精度的均值为 0.31,比我们之前实现的 0.53 要差。平均精度的离散度不是很紧,变异系数为 0.89:

Mean average precision over 10 runs: 0.3061984081568074
Coefficient of variation over 10 runs: 0.8896921668864564

[0.03394897465567298,
0.14322827274920255,
0.03610123178524601,
0.019735235731640446,
0.012571999125881402,
0.6788921569665146,
0.5411349583727725,
0.388474572258503,
0.7089617645810736,
0.4989349153415674]

使用线性激活和丢弃的过度完备自编码器

改进过度完备自编码器解决方案的一种方法是使用正则化技术来减少过拟合。其中一种技术被称为丢弃。使用丢弃时,我们强制自编码器从神经网络中的层中丢弃一定百分比的单元。

有了这个新的约束条件,过度完备自编码器就不能简单地记住训练集中的信用卡交易了。相反,自编码器必须更多地进行泛化。自编码器被迫学习数据集中更显著的特征,并丢失一些不太显著的信息。

我们将使用 10%的丢弃率,将其应用于隐藏层。换句话说,10%的神经元会被丢弃。丢弃率越高,正则化效果越强。这只需要一行额外的代码。

让我们看看这是否能改善结果:

model = Sequential()
model.add(Dense(units=40, activation='linear', input_dim=29))
model.add(Dropout(0.10))
model.add(Dense(units=29, activation='linear'))

下面的代码显示了这个自编码器的损失,并且图 8-6 显示了平均精度、精确率-召回率曲线和 auROC 曲线:

Training history of overcomplete autoencoder with single hidden layer,
dropout, and linear activation function

Train on 190820 samples, validate on 190820 samples
Epoch 1/10
190820/190820 [==============================] - 27s 141us/step - loss: 0.1358
- acc: 0.7430 - val_loss: 0.0082 - val_acc: 0.9742
Epoch 2/10
190820/190820 [==============================] - 28s 146us/step - loss: 0.0782
- acc: 0.7849 - val_loss: 0.0094 - val_acc: 0.9689
Epoch 3/10
190820/190820 [==============================] - 28s 149us/step - loss: 0.0753
- acc: 0.7858 - val_loss: 0.0102 - val_acc: 0.9672
Epoch 4/10
190820/190820 [==============================] - 28s 148us/step - loss: 0.0772
- acc: 0.7864 - val_loss: 0.0093 - val_acc: 0.9677
Epoch 5/10
190820/190820 [==============================] - 28s 147us/step - loss: 0.0813
- acc: 0.7843 - val_loss: 0.0108 - val_acc: 0.9631
Epoch 6/10
190820/190820 [==============================] - 28s 149us/step - loss: 0.0756
- acc: 0.7844 - val_loss: 0.0095 - val_acc: 0.9654
Epoch 7/10
190820/190820 [==============================] - 29s 150us/step - loss: 0.0743
- acc: 0.7850 - val_loss: 0.0077 - val_acc: 0.9768
Epoch 8/10
190820/190820 [==============================] - 29s 150us/step - loss: 0.0767
- acc: 0.7840 - val_loss: 0.0070 - val_acc: 0.9759
Epoch 9/10
190820/190820 [==============================] - 29s 150us/step - loss: 0.0762
- acc: 0.7851 - val_loss: 0.0072 - val_acc: 0.9733
Epoch 10/10
190820/190820 [==============================] - 29s 151us/step - loss: 0.0756
- acc: 0.7849 - val_loss: 0.0067 - val_acc: 0.9749
93987/93987 [==============================] - 3s 32us/step

单隐藏层、丢弃和线性激活函数的过度完备自编码器的评估指标

图 8-6. 具有单隐藏层、丢弃率和线性激活函数的过完备自编码器的评估指标

如预期的那样,损失非常低,而且过拟合的过完备自编码器在检测欺诈信用卡交易方面表现非常差。

以下代码总结了在 10 次运行中平均精度的分布。平均精度的均值为 0.21,比我们之前达到的 0.53 差。变异系数为 0.40:

Mean average precision over 10 runs: 0.21150415381770646
Coefficient of variation over 10 runs: 0.40295807771579256

[0.22549974304927337,
0.22451178120391296,
0.17243952488912334,
0.2533716906936315,
0.13251890273915556,
0.1775116247503748,
0.4343283958332979,
0.10469065867732033,
0.19480068075466764,
0.19537213558630712]

具有线性激活的稀疏过完备自编码器

另一种正则化技术是稀疏性。我们可以强制自编码器考虑矩阵的稀疏性,使得大多数自编码器的神经元大部分时间处于非活跃状态——换句话说,它们不会激活。这使得即使自编码器是过完备的,也更难记忆恒等函数,因为大多数节点无法激活,因此不能像以前那样轻易地过拟合观察结果。

我们将使用与之前相同的单隐藏层过完备自编码器,有 40 个节点,但只有稀疏性惩罚,而没有丢弃。

让我们看看结果是否从之前的 0.21 平均精度有所提高:

model = Sequential()
    model.add(Dense(units=40, activation='linear',  \
        activity_regularizer=regularizers.l1(10e-5), input_dim=29))
model.add(Dense(units=29, activation='linear'))

以下代码显示了这个自编码器的损失,而图 8-7 则展示了平均精度、精确-召回曲线和 auROC 曲线:

Training history of sparse overcomplete autoencoder with single hidden layer
and linear activation function

Train on 190820 samples, validate on 190820 samples
Epoch 1/10
190820/190820 [==============================] - 27s 142us/step - loss: 0.0985
- acc: 0.9380 - val_loss: 0.0369 - val_acc: 0.9871
Epoch 2/10
190820/190820 [==============================] - 26s 136us/step - loss: 0.0284
- acc: 0.9829 - val_loss: 0.0261 - val_acc: 0.9698
Epoch 3/10
190820/190820 [==============================] - 26s 136us/step - loss: 0.0229
- acc: 0.9816 - val_loss: 0.0169 - val_acc: 0.9952
Epoch 4/10
190820/190820 [==============================] - 26s 137us/step - loss: 0.0201
- acc: 0.9821 - val_loss: 0.0147 - val_acc: 0.9943
Epoch 5/10
190820/190820 [==============================] - 26s 137us/step - loss: 0.0183
- acc: 0.9810 - val_loss: 0.0142 - val_acc: 0.9842
Epoch 6/10
190820/190820 [==============================] - 26s 137us/step - loss: 0.0206
- acc: 0.9774 - val_loss: 0.0158 - val_acc: 0.9906
Epoch 7/10
190820/190820 [==============================] - 26s 136us/step - loss: 0.0169
- acc: 0.9816 - val_loss: 0.0124 - val_acc: 0.9866
Epoch 8/10
190820/190820 [==============================] - 26s 137us/step - loss: 0.0165
- acc: 0.9795 - val_loss: 0.0208 - val_acc: 0.9537
Epoch 9/10
190820/190820 [==============================] - 26s 136us/step - loss: 0.0164
- acc: 0.9801 - val_loss: 0.0105 - val_acc: 0.9965
Epoch 10/10
190820/190820 [==============================] - 27s 140us/step - loss: 0.0167
- acc: 0.9779 - val_loss: 0.0102 - val_acc: 0.9955
93987/93987 [==============================] - 3s 32us/step

具有单隐藏层和线性激活函数的稀疏过完备自编码器的评估指标

图 8-7. 具有单隐藏层和线性激活函数的稀疏过完备自编码器的评估指标

以下代码总结了在 10 次运行中平均精度的分布。平均精度的均值为 0.21,比我们之前达到的 0.53 差。变异系数为 0.99:

Mean average precision over 10 runs: 0.21373659011504448
Coefficient of variation over 10 runs: 0.9913040763536749

[0.1370972172100049,
0.28328895710699215,
0.6362677613798704,
0.3467265637372019,
0.5197889253491589,
0.01871495737323161,
0.0812609121251577,
0.034749761900336684,
0.04846036143317335,
0.031010483535317393]

具有线性激活和丢弃的稀疏过完备自编码器

当然,我们可以结合正则化技术来改善解决方案。这里是一个具有线性激活、单隐藏层中有 40 个节点和 5% 丢弃率的稀疏过完备自编码器:

model = Sequential()
    model.add(Dense(units=40, activation='linear',  \
        activity_regularizer=regularizers.l1(10e-5), input_dim=29))
    model.add(Dropout(0.05))
model.add(Dense(units=29, activation='linear'))

以下训练数据显示了这个自编码器的损失,而图 8-8 则展示了平均精度、精确-召回曲线和 auROC 曲线:

Training history of sparse overcomplete autoencoder with single hidden layer,
dropout, and linear activation function

Train on 190820 samples, validate on 190820 samples
Epoch 1/10
190820/190820 [==============================] - 31s 162us/step - loss: 0.1477
- acc: 0.8150 - val_loss: 0.0506 - val_acc: 0.9727
Epoch 2/10
190820/190820 [==============================] - 29s 154us/step - loss: 0.0756
- acc: 0.8625 - val_loss: 0.0344 - val_acc: 0.9788
Epoch 3/10
190820/190820 [==============================] - 29s 152us/step - loss: 0.0687
- acc: 0.8612 - val_loss: 0.0291 - val_acc: 0.9790
Epoch 4/10
190820/190820 [==============================] - 29s 154us/step - loss: 0.0644
- acc: 0.8606 - val_loss: 0.0274 - val_acc: 0.9734
Epoch 5/10
190820/190820 [==============================] - 31s 163us/step - loss: 0.0630
- acc: 0.8597 - val_loss: 0.0242 - val_acc: 0.9746
Epoch 6/10
190820/190820 [==============================] - 31s 162us/step - loss: 0.0609
- acc: 0.8600 - val_loss: 0.0220 - val_acc: 0.9800
Epoch 7/10
190820/190820 [==============================] - 30s 156us/step - loss: 0.0624
- acc: 0.8581 - val_loss: 0.0289 - val_acc: 0.9633
Epoch 8/10
190820/190820 [==============================] - 29s 154us/step - loss: 0.0589
- acc: 0.8588 - val_loss: 0.0574 - val_acc: 0.9366
Epoch 9/10
190820/190820 [==============================] - 29s 154us/step - loss: 0.0596
- acc: 0.8571 - val_loss: 0.0206 - val_acc: 0.9752
Epoch 10/10
190820/190820 [==============================] - 31s 165us/step - loss: 0.0593
- acc: 0.8590 - val_loss: 0.0204 - val_acc: 0.9808
93987/93987 [==============================] - 4s 38us/step

具有单隐藏层、丢弃率和线性激活函数的稀疏过完备自编码器的评估指标

图 8-8. 具有单隐藏层、丢弃率和线性激活函数的稀疏过完备自编码器的评估指标

以下代码总结了在 10 次运行中平均精度的分布。平均精度的均值为 0.24,比我们之前达到的 0.53 差。变异系数为 0.62:

Mean average precision over 10 runs: 0.2426994231628755
Coefifcient of variation over 10 runs: 0.6153219870606188

[0.6078198313533932,
0.20862366991302814,
0.25854513247057875,
0.08496595007072019,
0.26313491674585093,
0.17001322998258625,
0.15338215561753896,
0.1439107390306835,
0.4073422280287587,
0.1292563784156162]

处理嘈杂数据集

实际数据的一个常见问题是数据的嘈杂性,数据通常因为数据捕获、数据迁移、数据转换等问题而畸变。我们需要自编码器足够健壮,以便不被这种噪声所迷惑,并能够从数据中学习到真正重要的潜在结构。

为了模拟这种噪声,让我们向我们的信用卡交易数据集添加一个高斯随机噪声矩阵,然后在这个嘈杂的训练集上训练一个自编码器。然后,我们将看看这个自编码器在嘈杂的测试集上预测欺诈交易的表现:

noise_factor = 0.50
X_train_AE_noisy = X_train_AE.copy() + noise_factor * \
 np.random.normal(loc=0.0, scale=1.0, size=X_train_AE.shape)
X_test_AE_noisy = X_test_AE.copy() + noise_factor * \
 np.random.normal(loc=0.0, scale=1.0, size=X_test_AE.shape)

去噪自编码器

与原始的非失真数据集相比,对信用卡交易嘈杂数据集的过拟合惩罚要高得多。数据集中有足够的噪声,以至于一个对噪声数据拟合得太好的自编码器很难从正常交易和欺诈交易中检测出欺诈交易。

这应该是有道理的。我们需要一个自编码器,它能够很好地适应数据,以便能够足够好地重构大部分观测值,但又不能够过于好,以至于意外地重构了噪音。换句话说,我们希望自编码器能够学习到潜在的结构,但忽略数据中的噪音。

让我们从到目前为止表现良好的选项中尝试几个。首先,我们将尝试一个单隐藏层、27 节点的欠完全自编码器,采用线性激活。接下来,我们将尝试一个单隐藏层、40 节点的稀疏过完备自编码器,带有 dropout。最后,我们将使用一个带有非线性激活函数的自编码器。

两层去噪欠完全自编码器,采用线性激活

在嘈杂的数据集上,具有线性激活和 27 个节点的单隐藏层自编码器的平均精度为 0.69。让我们看看它在嘈杂的数据集上表现如何。这种自编码器——因为它正在处理一个嘈杂的数据集并试图去噪它——被称为去噪自编码器

代码与之前类似,只是现在我们将其应用于嘈杂的训练和测试数据集X_train_AE_noisyX_test_AE_noisy

for i in range(0,10):
    # Call neural network API
    model = Sequential()

    # Generate hidden layer with 27 nodes using linear activation
    model.add(Dense(units=27, activation='linear', input_dim=29))

    # Generate output layer with 29 nodes
    model.add(Dense(units=29, activation='linear'))

    # Compile the model
    model.compile(optimizer='adam',
                  loss='mean_squared_error',
                  metrics=['accuracy'])

    # Train the model
    num_epochs = 10
    batch_size = 32

    history = model.fit(x=X_train_AE_noisy, y=X_train_AE_noisy,
                        epochs=num_epochs,
                        batch_size=batch_size,
                        shuffle=True,
                        validation_data=(X_train_AE, X_train_AE),
                        verbose=1)

    # Evaluate on test set
    predictions = model.predict(X_test_AE_noisy, verbose=1)
    anomalyScoresAE = anomalyScores(X_test, predictions)
    preds, avgPrecision = plotResults(y_test, anomalyScoresAE, True)
    test_scores.append(avgPrecision)
    model.reset_states()

print("Mean average precision over 10 runs: ", np.mean(test_scores))
test_scores

以下训练数据显示了这个自编码器的损失,而图 8-9 展示了平均精度、精确率-召回率曲线和 auROC 曲线:

Training history of denoising undercomplete autoencoder with single hidden layer
and linear activation function

Train on 190820 samples, validate on 190820 samples
Epoch 1/10
190820/190820 [==============================] - 25s 133us/step - loss: 0.1733
- acc: 0.7756 - val_loss: 0.0356 - val_acc: 0.9123
Epoch 2/10
190820/190820 [==============================] - 24s 126us/step - loss: 0.0546
- acc: 0.8793 - val_loss: 0.0354 - val_acc: 0.8973
Epoch 3/10
190820/190820 [==============================] - 24s 126us/step - loss: 0.0531
- acc: 0.8764 - val_loss: 0.0350 - val_acc: 0.9399
Epoch 4/10
190820/190820 [==============================] - 24s 126us/step - loss: 0.0525
- acc: 0.8879 - val_loss: 0.0342 - val_acc: 0.9573
Epoch 5/10
190820/190820 [==============================] - 24s 126us/step - loss: 0.0530
- acc: 0.8910 - val_loss: 0.0347 - val_acc: 0.9503
Epoch 6/10
190820/190820 [==============================] - 24s 126us/step - loss: 0.0524
- acc: 0.8889 - val_loss: 0.0350 - val_acc: 0.9138
Epoch 7/10
190820/190820 [==============================] - 24s 126us/step - loss: 0.0531
- acc: 0.8845 - val_loss: 0.0343 - val_acc: 0.9280
Epoch 8/10
190820/190820 [==============================] - 24s 126us/step - loss: 0.0530
- acc: 0.8798 - val_loss: 0.0339 - val_acc: 0.9507
Epoch 9/10
190820/190820 [==============================] - 24s 126us/step - loss: 0.0526
- acc: 0.8877 - val_loss: 0.0337 - val_acc: 0.9611
Epoch 10/10
190820/190820 [==============================] - 24s 127us/step - loss: 0.0528
- acc: 0.8885 - val_loss: 0.0352 - val_acc: 0.9474
93987/93987 [==============================] - 3s 34us/step

图 8-9。去噪欠完全自编码器的评估指标,采用单隐藏层和线性激活函数

图 8-9。去噪欠完全自编码器的评估指标,采用单隐藏层和线性激活函数

平均精度现在为 0.28。您可以看出,线性自编码器在去噪这个嘈杂的数据集上是多么困难:

Mean average precision over 10 runs: 0.2825997155005206
Coeficient of variation over 10 runs: 1.1765416185187383

[0.6929639885685303,
0.008450118408150287,
0.6970753417267612,
0.011820311633718597,
0.008924124892696377,
0.010639537507746342,
0.6884911855668772,
0.006549332886020607,
0.6805304226634528,
0.02055279115125298]

它在将数据中真实的潜在结构与我们添加的高斯噪声分离方面存在困难。

具有线性激活函数的两层降噪过完备自编码器

现在让我们尝试一个单隐藏层过完备自编码器,有 40 个节点,稀疏性正则化器,以及 0.05%的 Dropout。

在原始数据集上,这个模型的平均精度为 0.56:

model = Sequential()
model.add(Dense(units=40, activation='linear',
 activity_regularizer=regularizers.l1(10e-5),
                input_dim=29))
model.add(Dropout(0.05))
model.add(Dense(units=29, activation='linear'))

以下训练数据显示了该自编码器的损失,而图 8-10 展示了平均精度、精确率-召回率曲线和 auROC 曲线:

Training history of denoising overcomplete autoencoder with dropout and linear
activation function

Train on 190820 samples, validate on 190820 samples
Epoch 1/10
190820/190820 [==============================] - 28s 145us/step - loss: 0.1726
- acc: 0.8035 - val_loss: 0.0432 - val_acc: 0.9781
Epoch 2/10
190820/190820 [==============================] - 26s 138us/step - loss: 0.0868
- acc: 0.8490 - val_loss: 0.0307 - val_acc: 0.9775
Epoch 3/10
190820/190820 [==============================] - 26s 138us/step - loss: 0.0809
- acc: 0.8455 - val_loss: 0.0445 - val_acc: 0.9535
Epoch 4/10
190820/190820 [==============================] - 26s 138us/step - loss: 0.0777
- acc: 0.8438 - val_loss: 0.0257 - val_acc: 0.9709
Epoch 5/10
190820/190820 [==============================] - 27s 139us/step - loss: 0.0748
- acc: 0.8434 - val_loss: 0.0219 - val_acc: 0.9787
Epoch 6/10
190820/190820 [==============================] - 26s 138us/step - loss: 0.0746
- acc: 0.8425 - val_loss: 0.0210 - val_acc: 0.9794
Epoch 7/10
190820/190820 [==============================] - 26s 138us/step - loss: 0.0713
- acc: 0.8437 - val_loss: 0.0294 - val_acc: 0.9503
Epoch 8/10
190820/190820 [==============================] - 26s 138us/step - loss: 0.0708
- acc: 0.8426 - val_loss: 0.0276 - val_acc: 0.9606
Epoch 9/10
190820/190820 [==============================] - 26s 139us/step - loss: 0.0704
- acc: 0.8428 - val_loss: 0.0180 - val_acc: 0.9811
Epoch 10/10
190820/190820 [==============================] - 27s 139us/step - loss: 0.0702
- acc: 0.8424 - val_loss: 0.0185 - val_acc: 0.9710
93987/93987 [==============================] - 4s 38us/step

使用 Dropout 和线性激活函数的降噪过完备自编码器的评估指标

图 8-10. 使用 Dropout 和线性激活函数的降噪过完备自编码器的评估指标

以下代码总结了 10 次运行中平均精度的分布情况。平均精度的均值为 0.10,比我们之前达到的 0.53 差。变异系数为 0.83:

Mean average precision over 10 runs: 0.10112931070692295
Coefficient of variation over 10 runs: 0.8343774832756188

[0.08283546387140524,
0.043070120657586454,
0.018901753737287603,
0.02381040174486509,
0.16038446580196433,
0.03461061251209459,
0.17847771715513427,
0.2483282420447288,
0.012981344347664117,
0.20789298519649893]

具有 ReLU 激活的两层降噪过完备自编码器

最后,让我们看看同一个自编码器使用 ReLU 作为激活函数而不是线性激活函数时的表现。回想一下,非线性激活函数的自编码器在原始数据集上的表现不如线性激活函数的表现:

model = Sequential()
    model.add(Dense(units=40, activation='relu',  \
        activity_regularizer=regularizers.l1(10e-5), input_dim=29))
    model.add(Dropout(0.05))
model.add(Dense(units=29, activation='relu'))

以下训练数据显示了该自编码器的损失,而图 8-11 展示了平均精度、精确率-召回率曲线和 auROC 曲线:

Training history of denoising overcomplete autoencoder with dropout and ReLU
activation function"

Train on 190820 samples, validate on 190820 samples
Epoch 1/10
190820/190820 [==============================] - 29s 153us/step - loss: 0.3049
- acc: 0.6454 - val_loss: 0.0841 - val_acc: 0.8873
Epoch 2/10
190820/190820 [==============================] - 27s 143us/step - loss: 0.1806
- acc: 0.7193 - val_loss: 0.0606 - val_acc: 0.9012
Epoch 3/10
190820/190820 [==============================] - 27s 143us/step - loss: 0.1626
- acc: 0.7255 - val_loss: 0.0500 - val_acc: 0.9045
Epoch 4/10
190820/190820 [==============================] - 27s 143us/step - loss: 0.1567
- acc: 0.7294 - val_loss: 0.0445 - val_acc: 0.9116
Epoch 5/10
190820/190820 [==============================] - 27s 143us/step - loss: 0.1484
- acc: 0.7309 - val_loss: 0.0433 - val_acc: 0.9136
Epoch 6/10
190820/190820 [==============================] - 27s 144us/step - loss: 0.1467
- acc: 0.7311 - val_loss: 0.0375 - val_acc: 0.9101
Epoch 7/10
190820/190820 [==============================] - 27s 143us/step - loss: 0.1427
- acc: 0.7335 - val_loss: 0.0384 - val_acc: 0.9013
Epoch 8/10
190820/190820 [==============================] - 27s 143us/step - loss: 0.1397
- acc: 0.7307 - val_loss: 0.0337 - val_acc: 0.9145
Epoch 9/10
190820/190820 [==============================] - 27s 143us/step - loss: 0.1361
- acc: 0.7322 - val_loss: 0.0343 - val_acc: 0.9066
Epoch 10/10
190820/190820 [==============================] - 27s 144us/step - loss: 0.1349
- acc: 0.7331 - val_loss: 0.0325 - val_acc: 0.9107
93987/93987 [==============================] - 4s 41us/step

使用 Dropout 和 ReLU 激活函数的降噪过完备自编码器的评估指标

图 8-11. 使用 Dropout 和 ReLU 激活函数的降噪过完备自编码器的评估指标

以下代码总结了 10 次运行中平均精度的分布情况。平均精度的均值为 0.20,比我们之前达到的 0.53 差。变异系数为 0.55:

Mean average precision over 10 runs: 0.1969608394689088
Coefficient of variation over 10 runs: 0.5566706365802669

[0.22960316854089222,
0.37609633487223315,
0.11429775486529765,
0.10208135698072755,
0.4002384343852861,
0.13317480663248088,
0.15764518571284625,
0.2406315655171392,
0.05080529996343734,
0.1650344872187474]

您可以尝试不同的节点数、层数、稀疏度、Dropout 百分比和激活函数,看看能否改善结果。

结论

在本章中,我们回顾了本书早期提到的信用卡欺诈问题,并开发了基于神经网络的无监督欺诈检测解决方案。

为了找到我们自编码器的最优结构,我们尝试了各种自编码器。我们尝试了完备、欠完备和过完备的自编码器,有单层或几层隐藏层。我们还使用了线性和非线性激活函数,并应用了两种主要的正则化方法,稀疏性和 Dropout。

我们发现,在原始信用卡数据集上,一个相当简单的两层欠完备神经网络,使用线性激活效果最佳,但在嘈杂的信用卡数据集中,我们需要一个稀疏的两层过完备自编码器,配备线性激活和 dropout 来处理噪声。

我们的许多实验都基于试错法进行——每次实验中,我们调整了几个超参数,并将结果与先前的迭代进行比较。可能存在更好的基于自编码器的欺诈检测解决方案,我鼓励您进行自己的实验,看看您能找到什么。

至此,本书中我们将监督学习和无监督学习视为独立且不同的方法,但在第九章中,我们将探讨如何同时使用监督和无监督方法,开发一个称为半监督解决方案,其表现优于任何单独的方法。

¹ 欲了解更多关于Keras Sequential model的信息,请访问官方文档。

² 欲了解更多关于损失函数的信息,请参阅官方 Keras 文档

³ 请查阅维基百科,了解更多关于随机梯度下降的信息。

⁴ 欲了解更多有关优化器的信息,请参阅文档

⁵ 欲了解更多评估指标,请参阅文档

第九章:半监督学习

到目前为止,我们将监督学习和无监督学习视为机器学习的两个独立而不同的分支。当我们的数据集有标签时,适合使用监督学习,当数据集没有标签时,需要使用无监督学习。

在现实世界中,区分并不是那么清晰。数据集通常是部分标记的,我们希望在利用标记集中的信息的同时,有效地标记未标记的观察结果。使用监督学习,我们必须丢弃大多数未标记的数据集。使用无监督学习,我们会有大部分数据可供使用,但不知道如何利用我们拥有的少量标记。

半监督学习领域融合了监督学习和无监督学习的优点,利用少量可用标记来揭示数据集的结构并帮助标记其余部分。

在本章中,我们将继续使用信用卡交易数据集来展示半监督学习。

数据准备

像之前一样,让我们加载必要的库并准备数据。现在这应该很熟悉了:

'''Main'''
import numpy as np
import pandas as pd
import os, time, re
import pickle, gzip

'''Data Viz'''
import matplotlib.pyplot as plt
import seaborn as sns
color = sns.color_palette()
import matplotlib as mpl

%matplotlib inline

'''Data Prep and Model Evaluation'''
from sklearn import preprocessing as pp
from sklearn.model_selection import train_test_split
from sklearn.model_selection import StratifiedKFold
from sklearn.metrics import log_loss
from sklearn.metrics import precision_recall_curve, average_precision_score
from sklearn.metrics import roc_curve, auc, roc_auc_score

'''Algos'''
import lightgbm as lgb

'''TensorFlow and Keras'''
import tensorflow as tf
import keras
from keras import backend as K
from keras.models import Sequential, Model
from keras.layers import Activation, Dense, Dropout
from keras.layers import BatchNormalization, Input, Lambda
from keras import regularizers
from keras.losses import mse, binary_crossentropy

像之前一样,我们将生成一个训练集和一个测试集。但是我们会从训练集中删除 90%的欺诈信用卡交易,以模拟如何处理部分标记的数据集。

尽管这看起来可能是一个非常激进的举措,但涉及支付欺诈的真实世界问题同样具有很低的欺诈率(每 10,000 例中可能只有 1 例欺诈)。通过从训练集中删除 90%的标签,我们正在模拟这种现象:

# Load the data
current_path = os.getcwd()
file = '\\datasets\\credit_card_data\\credit_card.csv'
data = pd.read_csv(current_path + file)

dataX = data.copy().drop(['Class','Time'],axis=1)
dataY = data['Class'].copy()

# Scale data
featuresToScale = dataX.columns
sX = pp.StandardScaler(copy=True, with_mean=True, with_std=True)
dataX.loc[:,featuresToScale] = sX.fit_transform(dataX[featuresToScale])

# Split into train and test
X_train, X_test, y_train, y_test = \
    train_test_split(dataX, dataY, test_size=0.33, \
                     random_state=2018, stratify=dataY)

# Drop 95% of the labels from the training set
toDrop = y_train[y_train==1].sample(frac=0.90,random_state=2018)
X_train.drop(labels=toDrop.index,inplace=True)
y_train.drop(labels=toDrop.index,inplace=True)

我们还将重用anomalyScoresplotResults函数:

def anomalyScores(originalDF, reducedDF):
    loss = np.sum((np.array(originalDF) - \
                   np.array(reducedDF))**2, axis=1)
    loss = pd.Series(data=loss,index=originalDF.index)
    loss = (loss-np.min(loss))/(np.max(loss)-np.min(loss))
    return loss
def plotResults(trueLabels, anomalyScores, returnPreds = False):
    preds = pd.concat([trueLabels, anomalyScores], axis=1)
    preds.columns = ['trueLabel', 'anomalyScore']
    precision, recall, thresholds = \
        precision_recall_curve(preds['trueLabel'], \
                               preds['anomalyScore'])
    average_precision = average_precision_score( \
                        preds['trueLabel'], preds['anomalyScore'])

    plt.step(recall, precision, color='k', alpha=0.7, where='post')
    plt.fill_between(recall, precision, step='post', alpha=0.3, color='k')

    plt.xlabel('Recall')
    plt.ylabel('Precision')
    plt.ylim([0.0, 1.05])
    plt.xlim([0.0, 1.0])

    plt.title('Precision-Recall curve: Average Precision = \
 {0:0.2f}'.format(average_precision))

    fpr, tpr, thresholds = roc_curve(preds['trueLabel'], \
                                     preds['anomalyScore'])
    areaUnderROC = auc(fpr, tpr)

    plt.figure()
    plt.plot(fpr, tpr, color='r', lw=2, label='ROC curve')
    plt.plot([0, 1], [0, 1], color='k', lw=2, linestyle='--')
    plt.xlim([0.0, 1.0])
    plt.ylim([0.0, 1.05])
    plt.xlabel('False Positive Rate')
    plt.ylabel('True Positive Rate')
    plt.title('Receiver operating characteristic: Area under the \
 curve = {0:0.2f}'.format(areaUnderROC))
    plt.legend(loc="lower right")
    plt.show()

    if returnPreds==True:
        return preds, average_precision

最后,这里有一个新函数叫做precisionAnalysis,帮助我们在某个召回率水平上评估模型的精度。具体来说,我们将确定模型在测试集中捕捉到 75%的欺诈信用卡交易的精度。精度越高,模型越好。

这是一个合理的基准。换句话说,我们希望能够捕捉到 75%的欺诈行为,并且尽可能高精度。如果我们没有达到足够高的精度,我们将不必要地拒绝良好的信用卡交易,可能会激怒我们的客户群体:

def precisionAnalysis(df, column, threshold):
    df.sort_values(by=column, ascending=False, inplace=True)
    threshold_value = threshold*df.trueLabel.sum()
    i = 0
    j = 0
    while i < threshold_value+1:
        if df.iloc[j]["trueLabel"]==1:
            i += 1
        j += 1
    return df, i/j

监督模型

为了对我们的半监督模型进行基准测试,让我们先看看单独使用监督模型和无监督模型的效果如何。

我们将从基于轻量梯度提升的监督学习解决方案开始,就像在第二章中表现最佳的那个。我们将使用k-折交叉验证来创建五个折叠:

k_fold = StratifiedKFold(n_splits=5,shuffle=True,random_state=2018)

接下来,设定梯度提升的参数:

params_lightGB = {
    'task': 'train',
    'application':'binary',
    'num_class':1,
    'boosting': 'gbdt',
    'objective': 'binary',
    'metric': 'binary_logloss',
    'metric_freq':50,
    'is_training_metric':False,
    'max_depth':4,
    'num_leaves': 31,
    'learning_rate': 0.01,
    'feature_fraction': 1.0,
    'bagging_fraction': 1.0,
    'bagging_freq': 0,
    'bagging_seed': 2018,
    'verbose': 0,
    'num_threads':16
}

现在,让我们训练算法:

trainingScores = []
cvScores = []
predictionsBasedOnKFolds = pd.DataFrame(data=[], index=y_train.index, \
                                        columns=['prediction'])

for train_index, cv_index in k_fold.split(np.zeros(len(X_train)), \
                                          y_train.ravel()):
    X_train_fold, X_cv_fold = X_train.iloc[train_index,:], \
        X_train.iloc[cv_index,:]
    y_train_fold, y_cv_fold = y_train.iloc[train_index], \
        y_train.iloc[cv_index]

    lgb_train = lgb.Dataset(X_train_fold, y_train_fold)
    lgb_eval = lgb.Dataset(X_cv_fold, y_cv_fold, reference=lgb_train)
    gbm = lgb.train(params_lightGB, lgb_train, num_boost_round=2000,
                   valid_sets=lgb_eval, early_stopping_rounds=200)

    loglossTraining = log_loss(y_train_fold, gbm.predict(X_train_fold, \
                                num_iteration=gbm.best_iteration))
    trainingScores.append(loglossTraining)

    predictionsBasedOnKFolds.loc[X_cv_fold.index,'prediction'] = \
        gbm.predict(X_cv_fold, num_iteration=gbm.best_iteration)
    loglossCV = log_loss(y_cv_fold, \
        predictionsBasedOnKFolds.loc[X_cv_fold.index,'prediction'])
    cvScores.append(loglossCV)

    print('Training Log Loss: ', loglossTraining)
    print('CV Log Loss: ', loglossCV)

loglossLightGBMGradientBoosting = log_loss(y_train, \
        predictionsBasedOnKFolds.loc[:,'prediction'])
print('LightGBM Gradient Boosting Log Loss: ', \
        loglossLightGBMGradientBoosting)

现在,我们将使用这个模型来预测信用卡交易测试集上的欺诈行为。

图 9-1 展示了结果。

监督模型的结果

图 9-1. 监督模型的结果

基于精度-召回曲线的测试平均精度为 0.62。要捕捉 75%的欺诈案例,我们的精度仅为 0.5%。

无监督模型

现在让我们使用无监督学习构建欺诈检测解决方案。具体来说,我们将构建一个稀疏的两层过完备自动编码器,使用线性激活函数。我们将在隐藏层中有 40 个节点,并且 2%的丢失率。

然而,我们将通过过采样我们拥有的欺诈案例来调整我们的训练集。过采样是一种用于调整给定数据集中类分布的技术。我们希望向我们的数据集中添加更多的欺诈案例,以便我们训练的自动编码器更容易将正常/非欺诈交易与异常/欺诈交易区分开来。

记住,在从训练集中删除 90%欺诈案例后,我们只剩下 33 个欺诈案例。我们将取这 33 个欺诈案例,复制它们 100 次,然后添加到训练集中。我们还会保留非过采样训练集的副本,以便在机器学习流水线的其余部分使用它们。

记住,我们不会触及测试集——测试集不进行过采样,只有训练集进行过采样:

oversample_multiplier = 100

X_train_original = X_train.copy()
y_train_original = y_train.copy()
X_test_original = X_test.copy()
y_test_original = y_test.copy()

X_train_oversampled = X_train.copy()
y_train_oversampled = y_train.copy()
X_train_oversampled = X_train_oversampled.append( \
        [X_train_oversampled[y_train==1]]*oversample_multiplier, \
        ignore_index=False)
y_train_oversampled = y_train_oversampled.append( \
        [y_train_oversampled[y_train==1]]*oversample_multiplier, \
        ignore_index=False)

X_train = X_train_oversampled.copy()
y_train = y_train_oversampled.copy()

现在让我们训练我们的自动编码器:

model = Sequential()
model.add(Dense(units=40, activation='linear', \
                activity_regularizer=regularizers.l1(10e-5), \
                input_dim=29,name='hidden_layer'))
model.add(Dropout(0.02))
model.add(Dense(units=29, activation='linear'))

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

num_epochs = 5
batch_size = 32

history = model.fit(x=X_train, y=X_train,
                    epochs=num_epochs,
                    batch_size=batch_size,
                    shuffle=True,
                    validation_split=0.20,
                    verbose=1)

predictions = model.predict(X_test, verbose=1)
anomalyScoresAE = anomalyScores(X_test, predictions)
preds, average_precision = plotResults(y_test, anomalyScoresAE, True)

图 9-2 展示了结果。

无监督模型的结果

图 9-2. 无监督模型的结果

基于精度-召回曲线的测试平均精度为 0.57。要捕捉 75%的欺诈案例,我们的精度仅为 45%。虽然无监督解决方案的平均精度与监督解决方案相似,但在 75%召回率下的 45%精度更佳。

然而,单独的无监督解决方案仍然不是很好。

半监督模型

现在,让我们取自动编码器学到的表示(隐藏层),将其与原始训练集结合起来,并将其馈送到梯度提升算法中。这是一种半监督方法,充分利用了监督和无监督学习。

要获取隐藏层,我们从 Keras API 中调用Model()类并使用get_layer函数:

layer_name = 'hidden_layer'

intermediate_layer_model = Model(inputs=model.input, \
                                 outputs=model.get_layer(layer_name).output)
intermediate_output_train = intermediate_layer_model.predict(X_train_original)
intermediate_output_test = intermediate_layer_model.predict(X_test_original)

让我们将这些自动编码器表示存储到 DataFrame 中,然后将它们与原始训练集结合起来:

intermediate_output_trainDF = \
    pd.DataFrame(data=intermediate_output_train,index=X_train_original.index)
intermediate_output_testDF = \
    pd.DataFrame(data=intermediate_output_test,index=X_test_original.index)

X_train = X_train_original.merge(intermediate_output_trainDF, \
                                 left_index=True,right_index=True)
X_test = X_test_original.merge(intermediate_output_testDF, \
                               left_index=True,right_index=True)
y_train = y_train_original.copy()

现在我们将在这个新的 69 个特征的训练集上训练梯度提升模型(29 个来自原始数据集,40 个来自自动编码器的表示):

trainingScores = []
cvScores = []
predictionsBasedOnKFolds = pd.DataFrame(data=[],index=y_train.index, \
                                        columns=['prediction'])

for train_index, cv_index in k_fold.split(np.zeros(len(X_train)), \
                                          y_train.ravel()):
    X_train_fold, X_cv_fold = X_train.iloc[train_index,:], \
        X_train.iloc[cv_index,:]
    y_train_fold, y_cv_fold = y_train.iloc[train_index], \
        y_train.iloc[cv_index]

    lgb_train = lgb.Dataset(X_train_fold, y_train_fold)
    lgb_eval = lgb.Dataset(X_cv_fold, y_cv_fold, reference=lgb_train)
    gbm = lgb.train(params_lightGB, lgb_train, num_boost_round=5000,
                   valid_sets=lgb_eval, early_stopping_rounds=200)

    loglossTraining = log_loss(y_train_fold,
                                gbm.predict(X_train_fold, \
                                num_iteration=gbm.best_iteration))
    trainingScores.append(loglossTraining)

    predictionsBasedOnKFolds.loc[X_cv_fold.index,'prediction'] = \
        gbm.predict(X_cv_fold, num_iteration=gbm.best_iteration)
    loglossCV = log_loss(y_cv_fold, \
            predictionsBasedOnKFolds.loc[X_cv_fold.index,'prediction'])
    cvScores.append(loglossCV)

    print('Training Log Loss: ', loglossTraining)
    print('CV Log Loss: ', loglossCV)

loglossLightGBMGradientBoosting = log_loss(y_train, \
                        predictionsBasedOnKFolds.loc[:,'prediction'])
print('LightGBM Gradient Boosting Log Loss: ', \
                        loglossLightGBMGradientBoosting)

图 9-3 展示了结果。

半监督模型的结果

图 9-3. 半监督模型的结果

基于精度-召回曲线的测试集平均精度为 0.78。这比监督和无监督模型都高出许多。

要捕获 75%的欺诈,我们的精度达到了 92%。这是一个显著的改进。在这种精度水平下,支付处理器应该对拒绝模型标记为潜在欺诈的交易感到放心。不到十分之一会出错,而我们将捕获大约 75%的欺诈行为。

监督学习和非监督学习的威力

在这种半监督信用卡欺诈检测解决方案中,监督学习和非监督学习都发挥了重要作用。探索的一种方式是分析最终梯度提升模型发现的最重要的特征是哪些。

让我们从刚刚训练的模型中找出并存储这些特征重要性数值:

featuresImportance = pd.DataFrame(data=list(gbm.feature_importance()), \
                        index=X_train.columns,columns=['featImportance'])
featuresImportance = featuresImportance/featuresImportance.sum()
featuresImportance.sort_values(by='featImportance', \
                               ascending=False,inplace=True)
featuresImportance

表 9-1 显示了按降序排列的一些最重要的特征。

表 9-1. 半监督模型的特征重要性

featImportance
V28 0.047843
Amount 0.037263
21 0.030244
V21 0.029624
V26 0.029469
V12 0.028334
V27 0.028024
6 0.027405
28 0.026941
36 0.024050
5 0.022347

正如您在这里所看到的,一些顶级特征是自动编码器学习的隐藏层特征(非“V”特征),而其他特征则是原始数据集的主要成分(“V”特征)以及交易金额。

结论

半监督模型击败了独立的监督模型和独立的非监督模型的性能。

我们只是初步探讨了半监督学习的潜力,但这应该有助于从辩论监督和非监督学习之间的选择转变为在寻找最佳应用解决方案中结合监督和非监督学习。

第四部分:使用 TensorFlow 和 Keras 进行深度无监督学习

到目前为止,我们只使用了浅层神经网络;换句话说,只有少数隐藏层的网络。浅层神经网络在构建机器学习系统时确实很有用,但过去十年中机器学习中最强大的进展来自于具有许多隐藏层的神经网络,称为深度神经网络。这个机器学习的子领域称为深度学习。在大型标记数据集上进行的深度学习已经在计算机视觉、物体识别、语音识别和机器翻译等领域取得了重大的商业成功。

我们将专注于大型无标记数据集上的深度学习,这通常被称为深度无监督学习。这个领域仍然非常新,充满潜力,但与监督变体相比商业成功较少。在接下来的几章中,我们将构建深度无监督学习系统,从最简单的构建块开始。

第十章涵盖了受限玻尔兹曼机,我们将使用它来构建电影推荐系统。在第十一章,我们将把受限玻尔兹曼机堆叠在一起,创建称为深信网的深度神经网络。在第十二章,我们将使用生成对抗网络生成合成数据,这是当今深度无监督学习中最热门的领域之一。然后在第十三章,我们将回到聚类,但这次是处理时间序列数据。

这是很多高级材料,但很多深度无监督学习都依赖于我们在本书前面介绍的基本原理。

第十章:使用受限玻尔兹曼机的推荐系统

在本书的早期,我们使用无监督学习来学习未标记数据中的潜在(隐藏)结构。具体而言,我们进行了降维,将高维数据集减少到具有更少维度的数据集,并构建了异常检测系统。我们还进行了聚类,根据对象彼此之间的相似性或不相似性将它们分组。

现在,我们将进入生成式无监督模型,这涉及从原始数据集学习概率分布,并用它对以前未见过的数据进行推断。在后面的章节中,我们将使用这些模型生成看似真实的数据,有时几乎无法与原始数据区分开来。

到目前为止,我们主要研究了判别模型,这些模型根据算法从数据中学到的内容来分离观察结果;这些判别模型不会从数据中学习概率分布。判别模型包括监督学习模型,如逻辑回归和决策树(来自第二章),以及聚类方法,如k-均值和层次聚类(来自第五章)。

让我们从最简单的生成式无监督模型开始,即受限玻尔兹曼机

玻尔兹曼机

玻尔兹曼机最早由 Geoffrey Hinton(当时是卡内基梅隆大学的教授,现在是深度学习运动的先驱之一,多伦多大学的教授,以及谷歌的机器学习研究员)和 Terry Sejnowski(当时是约翰霍普金斯大学的教授)于 1985 年发明。

玻尔兹曼机——无限制型——由具有输入层和一个或多个隐藏层的神经网络组成。神经网络中的神经元或单元根据训练中输入的数据和玻尔兹曼机试图最小化的成本函数,做出是否启动的随机决策。通过这种训练,玻尔兹曼机发现数据的有趣特征,有助于模拟数据中复杂的潜在关系和模式。

然而,这些无限制的玻尔兹曼机使用神经网络,其中神经元不仅连接到其他层中的神经元,而且连接到同一层中的神经元。这与许多隐藏层的存在一起,使得无限制的玻尔兹曼机的训练效率非常低。由于这个原因,无限制的玻尔兹曼机在 20 世纪 80 年代和 90 年代几乎没有商业成功。

受限玻尔兹曼机

在 2000 年代,Geoffrey Hinton 等人开始通过使用修改后的原始无限制玻尔兹曼机取得商业成功。这些受限玻尔兹曼机(RBM)具有一个输入层(也称为可见层)和一个单独的隐藏层,神经元之间的连接受限,使得神经元仅连接到其他层的神经元,而不连接同一层的神经元。换句话说,没有可见-可见的连接和隐藏-隐藏的连接。¹

Geoffrey Hinton 还展示了这样简单的受限玻尔兹曼机(RBM)可以堆叠在一起,以便一个 RBM 的隐藏层的输出可以被馈送到另一个 RBM 的输入层。这种 RBM 堆叠可以多次重复,以逐步学习原始数据更细致的隐藏表示。这种多个 RBM 组成的网络可以看作是一个深层、多层次的神经网络模型——因此,深度学习领域从 2006 年开始蓬勃发展。

注意,RBM 使用随机方法来学习数据的潜在结构,而例如自编码器则使用确定性方法。

推荐系统

在本章中,我们将使用 RBM 构建一个推荐系统,这是迄今为止最成功的机器学习应用之一,在行业中广泛用于帮助预测用户对电影、音乐、书籍、新闻、搜索、购物、数字广告和在线约会的偏好。

推荐系统有两大主要类别——协同过滤推荐系统和基于内容的推荐系统。协同过滤涉及根据用户的过去行为以及与用户相似的其他用户的行为来构建推荐系统。这种推荐系统可以预测用户可能感兴趣的项目,即使用户从未明确表达过兴趣。Netflix 上的电影推荐就依赖于协同过滤。

基于内容的过滤涉及学习一个项目的独特属性,以推荐具有类似属性的其他项目。Pandora 上的音乐推荐就依赖于基于内容的过滤。

协同过滤

基于内容的过滤并不常用,因为学习项目的独特属性是一个相当困难的任务——目前人工机器很难达到这种理解水平。收集和分析大量关于用户行为和偏好的信息,并基于此进行预测,要容易得多。因此,协同过滤更广泛地被使用,也是我们这里将重点关注的推荐系统类型。

协同过滤不需要了解底层物品本身。相反,协同过滤假设在过去达成一致的用户将来也会达成一致,并且用户的偏好随时间保持稳定。通过建模用户与其他用户的相似性,协同过滤可以进行相当强大的推荐。此外,协同过滤不必依赖于显式数据(即用户提供的评分)。相反,它可以使用隐式数据,例如用户观看或点击特定项目的时间长短或频率来推断用户的喜好和厌恶。例如,过去 Netflix 要求用户对电影进行评分,但现在使用用户的隐式行为来推断用户的喜好和厌恶。

然而,协同过滤也存在其挑战。首先,它需要大量用户数据来进行良好的推荐。其次,这是一个非常计算密集的任务。第三,数据集通常非常稀疏,因为用户只对可能物品宇宙中的一小部分物品展现了偏好。假设我们有足够的数据,我们可以使用技术来处理数据的稀疏性并高效解决这个问题,我们将在本章中进行讨论。

Netflix 奖励

2006 年,Netflix 赞助了一场为期三年的比赛,旨在改进其电影推荐系统。该公司向那支能将其现有推荐系统的准确性提高至少 10%的团队提供了 100 万美元的大奖。它还发布了一个包含超过 1 亿部电影评分的数据集。2009 年 9 月,BellKor 的 Pramatic Chaos 团队赢得了这一奖项,他们使用了多种不同算法方法的集成。

这样一场备受关注的比赛,拥有丰富的数据集和有意义的奖金,激励了机器学习社区,并推动了推荐系统研究的实质性进展,为工业界在过去几年里开发出更好的推荐系统铺平了道路。

在本章中,我们将使用一个类似的电影评分数据集来构建我们自己的推荐系统,使用 RBM(Restricted Boltzmann Machines)。

MovieLens 数据集

不同于 Netflix 的 1 亿条评分数据集,我们将使用一个更小的电影评分数据集,称为MovieLens 20M 数据集,由明尼苏达大学双城分校计算机科学与工程系的研究实验室 GroupLens 提供。该数据集包含了从 1995 年 1 月 9 日到 2015 年 3 月 31 日,138,493 位用户对 27,278 部电影进行的 20,000,263 次评分。我们将随机选择至少评分了 20 部电影的用户子集。

这个数据集比 Netflix 的 1 亿条评分数据集更易于处理。由于文件大小超过了 100 兆字节,该文件在 GitHub 上不可访问。您需要直接从MovieLens 网站下载该文件。

数据准备

如前所述,让我们加载必要的库:

'''Main'''
import numpy as np
import pandas as pd
import os, time, re
import pickle, gzip, datetime

'''Data Viz'''
import matplotlib.pyplot as plt
import seaborn as sns
color = sns.color_palette()
import matplotlib as mpl

%matplotlib inline

'''Data Prep and Model Evaluation'''
from sklearn import preprocessing as pp
from sklearn.model_selection import train_test_split
from sklearn.model_selection import StratifiedKFold
from sklearn.metrics import log_loss
from sklearn.metrics import precision_recall_curve, average_precision_score
from sklearn.metrics import roc_curve, auc, roc_auc_score, mean_squared_error

'''Algos'''
import lightgbm as lgb

'''TensorFlow and Keras'''
import tensorflow as tf
import keras
from keras import backend as K
from keras.models import Sequential, Model
from keras.layers import Activation, Dense, Dropout
from keras.layers import BatchNormalization, Input, Lambda
from keras import regularizers
from keras.losses import mse, binary_crossentropy

接下来,我们将加载评分数据集并将字段转换为适当的数据类型。我们只有几个字段。用户 ID,电影 ID,用户为电影提供的评分,以及提供评分的时间戳:

# Load the data
current_path = os.getcwd()
file = '\\datasets\\movielens_data\\ratings.csv'
ratingDF = pd.read_csv(current_path + file)

# Convert fields into appropriate data types
ratingDF.userId = ratingDF.userId.astype(str).astype(int)
ratingDF.movieId = ratingDF.movieId.astype(str).astype(int)
ratingDF.rating = ratingDF.rating.astype(str).astype(float)
ratingDF.timestamp = ratingDF.timestamp.apply(lambda x: \
                datetime.utcfromtimestamp(x).strftime('%Y-%m-%d %H:%M:%S'))

表 10-1 展示了数据的部分视图。

表 10-1. MovieLens 评分数据

用户 ID 电影 ID 评分 时间戳
0 1 2 3.5 2005-04-02 23:53:47
1 1 29 3.5 2005-04-02 23:31:16
2 1 32 3.5 2005-04-02 23:33:39
3 1 47 3.5 2005-04-02 23:32:07
4 1 50 3.5 2005-04-02 23:29:40
5 1 112 3.5 2004-09-10 03:09:00
6 1 151 4.0 2004-09-10 03:08:54
7 1 223 4.0 2005-04-02 23:46:13
8 1 253 4.0 2005-04-02 23:35:40
9 1 260 4.0 2005-04-02 23:33:46
10 1 293 4.0 2005-04-02 23:31:43
11 1 296 4.0 2005-04-02 23:32:47
12 1 318 4.0 2005-04-02 23:33:18
13 1 337 3.5 2004-09-10 03:08:29

让我们确认唯一用户数、唯一电影数和总评分数,还将计算用户提供的平均评分数量:

n_users = ratingDF.userId.unique().shape[0]
n_movies = ratingDF.movieId.unique().shape[0]
n_ratings = len(ratingDF)
avg_ratings_per_user = n_ratings/n_users

print('Number of unique users: ', n_users)
print('Number of unique movies: ', n_movies)
print('Number of total ratings: ', n_ratings)
print('Average number of ratings per user: ', avg_ratings_per_user)

数据正如我们所预期的那样:

Number of unique users: 138493
Number of unique movies: 26744
Number of total ratings: 20000263
Average number of ratings per user: 144.4135299257002

为了减少这个数据集的复杂性和大小,让我们集中于排名前一千的电影。这将把评分数从约 20 百万减少到约 12.8 百万。

movieIndex = ratingDF.groupby("movieId").count().sort_values(by= \
                "rating",ascending=False)[0:1000].index
ratingDFX2 = ratingDF[ratingDF.movieId.isin(movieIndex)]
ratingDFX2.count()

我们还将随机抽取一千名用户的样本,并仅过滤这些用户的数据集。这将把评分数从约 12.8 百万减少到 90,213 个。这个数量足以展示协同过滤的效果:

userIndex = ratingDFX2.groupby("userId").count().sort_values(by= \
    "rating",ascending=False).sample(n=1000, random_state=2018).index
ratingDFX3 = ratingDFX2[ratingDFX2.userId.isin(userIndex)]
ratingDFX3.count()

同样,让我们重新索引movieIDuserID到 1 到 1,000 的范围内,用于我们的简化数据集:

movies = ratingDFX3.movieId.unique()
moviesDF = pd.DataFrame(data=movies,columns=['originalMovieId'])
moviesDF['newMovieId'] = moviesDF.index+1

users = ratingDFX3.userId.unique()
usersDF = pd.DataFrame(data=users,columns=['originalUserId'])
usersDF['newUserId'] = usersDF.index+1

ratingDFX3 = ratingDFX3.merge(moviesDF,left_on='movieId', \
                              right_on='originalMovieId')
ratingDFX3.drop(labels='originalMovieId', axis=1, inplace=True)

ratingDFX3 = ratingDFX3.merge(usersDF,left_on='userId', \
                              right_on='originalUserId')
ratingDFX3.drop(labels='originalUserId', axis=1, inplace=True)

让我们计算我们简化数据集中的唯一用户数、唯一电影数、总评分数以及每个用户的平均评分数量:

n_users = ratingDFX3.userId.unique().shape[0]
n_movies = ratingDFX3.movieId.unique().shape[0]
n_ratings = len(ratingDFX3)
avg_ratings_per_user = n_ratings/n_users

print('Number of unique users: ', n_users)
print('Number of unique movies: ', n_movies)
print('Number of total ratings: ', n_ratings)
print('Average number of ratings per user: ', avg_ratings_per_user)

结果如预期:

Number of unique users: 1000
Number of unique movies: 1000
Number of total ratings: 90213
Average number of ratings per user: 90.213

让我们从这个简化的数据集中生成一个测试集和一个验证集,使得每个留出集占简化数据集的 5%:

X_train, X_test = train_test_split(ratingDFX3,
 test_size=0.10, shuffle=True, random_state=2018)

X_validation, X_test = train_test_split(X_test,
 test_size=0.50, shuffle=True, random_state=2018)

下面显示了训练集、验证集和测试集的大小:

Size of train set: 81191
Size of validation set: 4511
Size of test set: 4511

定义成本函数:均方误差

现在我们已经准备好处理这些数据了。

首先,让我们创建一个m x n的矩阵,其中m是用户数,n是电影数。这将是一个稀疏填充的矩阵,因为用户只对电影的一小部分进行评分。例如,一个拥有一千个用户和一千部电影的矩阵在训练集中只有 81,191 个评分。如果每个一千个用户都对每一千部电影进行评分,我们将得到一个百万个评分的矩阵,但是平均而言用户只对少数电影进行评分,因此我们在训练集中只有 81,191 个评分。其余的值(矩阵中近 92%的值)将为零:

# Generate ratings matrix for train
ratings_train = np.zeros((n_users, n_movies))
for row in X_train.itertuples():
    ratings_train[row[6]-1, row[5]-1] = row[3]

# Calculate sparsity of the train ratings matrix
sparsity = float(len(ratings_train.nonzero()[0]))
sparsity /= (ratings_train.shape[0] * ratings_train.shape[1])
sparsity *= 100
print('Sparsity: {:4.2f}%'.format(sparsity))

我们将为验证集和测试集生成类似的矩阵,它们会更加稀疏,当然:

# Generate ratings matrix for validation
ratings_validation = np.zeros((n_users, n_movies))
for row in X_validation.itertuples():
    ratings_validation[row[6]-1, row[5]-1] = row[3]

# Generate ratings matrix for test
ratings_test = np.zeros((n_users, n_movies))
for row in X_test.itertuples():
    ratings_test[row[6]-1, row[5]-1] = row[3]

在构建推荐系统之前,让我们定义我们将用来评判模型好坏的成本函数。我们将使用均方误差(MSE),这是机器学习中最简单的成本函数之一。MSE 测量了预测值与实际值之间的平均平方误差。要计算 MSE,我们需要两个大小为[n,1]的向量,其中n是我们正在预测评分的数量 —— 对于验证集是 4,511。一个向量包含实际评分,另一个向量包含预测值。

让我们首先将验证集中带有评分的稀疏矩阵展平。这将是实际评分的向量:

actual_validation = ratings_validation[ratings_validation.nonzero()].flatten()

进行基准实验

作为基准,让我们预测验证集的平均评分为 3.5,并计算 MSE:

pred_validation = np.zeros((len(X_validation),1))
pred_validation[pred_validation==0] = 3.5
pred_validation

mean_squared_error(pred_validation, actual_validation)

这种非常天真预测的 MSE 是 1.05。这是我们的基准:

Mean squared error using naive prediction: 1.055420084238528

让我们看看是否可以通过预测用户对给定电影的评分来改善结果,基于该用户对所有其他电影的平均评分:

ratings_validation_prediction = np.zeros((n_users, n_movies))
i = 0
for row in ratings_train:
    ratings_validation_prediction[i][ratings_validation_prediction[i]==0] \
        = np.mean(row[row>0])
    i += 1

pred_validation = ratings_validation_prediction \
    [ratings_validation.nonzero()].flatten()
user_average = mean_squared_error(pred_validation, actual_validation)
print('Mean squared error using user average:', user_average)

均方误差(MSE)改善到 0.909:

Mean squared error using user average: 0.9090717929472647

现在,让我们基于所有其他用户对该电影的平均评分来预测用户对给定电影的评分:

ratings_validation_prediction = np.zeros((n_users, n_movies)).T
i = 0
for row in ratings_train.T:
    ratings_validation_prediction[i][ratings_validation_prediction[i]==0] \
        = np.mean(row[row>0])
    i += 1

ratings_validation_prediction = ratings_validation_prediction.T
pred_validation = ratings_validation_prediction \
    [ratings_validation.nonzero()].flatten()
movie_average = mean_squared_error(pred_validation, actual_validation)
print('Mean squared error using movie average:', movie_average)

这种方法的 MSE 为 0.914,与使用用户平均值发现的 MSE 类似:

Mean squared error using movie average: 0.9136057106858655

矩阵分解

在使用 RBM 构建推荐系统之前,让我们首先使用矩阵分解来构建一个。矩阵分解将用户-物品矩阵分解为两个较低维度矩阵的乘积。用户在较低维度潜在空间中表示,物品也是如此。

假设我们的用户-物品矩阵是 R,有 m 个用户和 n 个物品。矩阵分解将创建两个较低维度的矩阵,HWH 是一个 "m 用户" x "k 潜在因子" 的矩阵,W 是一个 "k 潜在因子" x "n 物品" 的矩阵。

评分通过矩阵乘法计算:R = H__W

k 潜在因子的数量决定了模型的容量。k 越高,模型的容量越大。通过增加k,我们可以提高对用户评分预测的个性化能力,但如果k过高,模型将过度拟合数据。

所有这些对你来说应该是熟悉的。矩阵分解学习了用户和物品在较低维度空间中的表示,并基于新学到的表示进行预测。

一个潜在因子

让我们从最简单的矩阵分解形式开始 —— 只使用一个潜在因子。我们将使用 Keras 来执行我们的矩阵分解。

首先,我们需要定义图表。输入是用户嵌入的一维向量和电影嵌入的一维向量。我们将这些输入向量嵌入到一个潜在空间中,然后展平它们。为了生成输出向量 product,我们将采用电影向量和用户向量的点积。我们将使用 Adam 优化器 来最小化我们的损失函数,该损失函数定义为 mean_squared_error

n_latent_factors = 1

user_input = Input(shape=[1], name='user')
user_embedding = Embedding(input_dim=n_users + 1, output_dim=n_latent_factors,
 name='user_embedding')(user_input)
user_vec = Flatten(name='flatten_users')(user_embedding)

movie_input = Input(shape=[1], name='movie')
movie_embedding = Embedding(input_dim=n_movies + 1, output_dim=n_latent_factors,
 name='movie_embedding')(movie_input)
movie_vec = Flatten(name='flatten_movies')(movie_embedding)

product = dot([movie_vec, user_vec], axes=1)
model = Model(inputs=[user_input, movie_input], outputs=product)
model.compile('adam', 'mean_squared_error')

让我们通过训练集中的用户和电影向量来训练模型。我们还将在训练过程中对验证集进行评估。我们将根据实际评分计算 MSE。

我们将训练一百个 epochs,并记录训练和验证结果的历史。让我们也来绘制结果:

history = model.fit(x=[X_train.newUserId, X_train.newMovieId], \
                    y=X_train.rating, epochs=100, \
                    validation_data=([X_validation.newUserId, \
                    X_validation.newMovieId], X_validation.rating), \
                    verbose=1)

pd.Series(history.history['val_loss'][10:]).plot(logy=False)
plt.xlabel("Epoch")
plt.ylabel("Validation Error")
print('Minimum MSE: ', min(history.history['val_loss']))

图 10-1 展示了结果。

使用 MF 和一个潜在因子的验证 MSE 图

图 10-1. 使用矩阵因子化和一个潜在因子的验证 MSE 图

使用矩阵因子化和一个潜在因子的最小 MSE 为 0.796。这比之前的用户平均和电影平均方法更好。

看看我们是否可以通过增加潜在因子的数量(即模型的容量)来进一步改进。

三个潜在因子

图 10-2 展示了使用三个潜在因子的结果。

使用 MF 和三个潜在因子的验证 MSE 图

图 10-2. 使用矩阵因子化和三个潜在因子的验证 MSE 图

最小 MSE 为 0.765,比使用一个潜在因子更好。

五个潜在因子

现在让我们构建一个使用五个潜在因子的矩阵因子化模型(参见 图 10-3 的结果)。

使用 MF 和五个潜在因子的验证 MSE 图

图 10-3. 使用矩阵因子化和五个潜在因子的验证 MSE 图

最小 MSE 未能改进,在前 25 个 epochs 左右明显出现过拟合迹象。验证误差下降然后开始增加。增加矩阵因子化模型的容量将不会帮助太多。

使用 RBM 进行协同过滤

让我们再次回到 RBM。回想一下,RBM 有两层——输入/可见层和隐藏层。每一层中的神经元与另一层中的神经元进行通信,但不与同一层中的神经元进行通信。换句话说,神经元之间没有同层通信——这就是 RBM 中“限制”的一部分。

RBM 的另一个重要特征是层之间的通信是双向的,而不仅仅是单向的。例如,对于自编码器,神经元只能通过前向传递与下一层通信。

使用 RBM,可见层中的神经元与隐藏层通信,然后隐藏层将信息传回可见层,来回多次交换。 RBM 执行此通信——在可见层和隐藏层之间来回传递——以开发生成模型,使得从隐藏层输出的重构与原始输入相似。

换句话说,RBM 正在尝试创建一个生成模型,该模型将根据用户评分的电影之间的相似性以及用户与其他评分该电影的用户的相似性,帮助预测用户是否会喜欢用户从未看过的电影。

可见层将有 X 个神经元,其中 X 是数据集中电影的数量。 每个神经元将具有从零到一的归一化评分值,其中零表示用户未看过电影。 归一化评分值越接近一,表示用户越喜欢神经元表示的电影。

可见层中的神经元将与隐藏层中的神经元通信,后者将试图学习表征用户-电影偏好的潜在特征。

注意,RBM 也被称为对称的二分图、双向图——对称是因为每个可见节点与每个隐藏节点相连,二分是因为有两层节点,双向是因为通信是双向的。

RBM 神经网络架构

对于我们的电影推荐系统,我们有一个m x n矩阵,其中m为用户数,n为电影数。 要训练 RBM,我们将一批k用户及其n电影评分传递到神经网络,并训练一定数量的epochs

每个传入神经网络的输入x表示单个用户对所有n部电影的评分偏好,例如,我们的示例中n为一千。 因此,可见层有n个节点,每个节点对应一个电影。

我们可以指定隐藏层中节点的数量,通常比可见层中的节点少,以尽可能有效地让隐藏层学习原始输入的最显著方面。

每个输入v0都与其相应的权重W相乘。 权重是从可见层到隐藏层的连接学习的。 然后我们在隐藏层添加一个称为hb的偏置向量。 偏置确保至少有一些神经元会激活。 这个Wv0+hb*结果通过激活函数传递。

之后,我们将通过一种称为Gibbs sampling的过程对生成的输出样本进行采样。 换句话说,隐藏层的激活结果以随机方式生成最终输出。 这种随机性有助于构建性能更好、更强大的生成模型。

接下来,吉布斯采样后的输出—称为h0—通过神经网络反向传播回去,进行所谓的反向传播。在反向传播中,吉布斯采样后的前向传播中的激活被馈送到隐藏层,并与之前相同的权重W相乘。然后我们在可见层添加一个新的称为vb的偏置向量。

这个W_h0+vb通过激活函数传递,并进行吉布斯采样。这个输出是v1,然后作为新的输入传递到可见层和神经网络中,进行另一次前向传播。

RBM 通过一系列前向和反向传播的步骤来学习最优权重,试图构建一个健壮的生成模型。RBM 是我们探索的第一种生成学习模型。通过执行吉布斯采样和通过前向和反向传播重新训练权重,RBM 试图学习原始输入的概率分布。具体来说,RBM 最小化Kullback–Leibler 散度,该散度用于衡量一个概率分布与另一个之间的差异;在这种情况下,RBM 最小化原始输入的概率分布与重建数据的概率分布之间的差异。

通过迭代调整神经网络中的权重,受限玻尔兹曼机(RBM)学习尽可能地逼近原始数据。

通过这个新学习到的概率分布,RBM 能够对以前未见过的数据进行预测。在这种情况下,我们设计的 RBM 将尝试基于用户与其他用户的相似性及其他用户对这些电影的评分来预测用户从未看过的电影的评分。

构建 RBM 类的组件

首先,我们将用几个参数初始化这个类;这些参数包括 RBM 的输入大小、输出大小、学习率、训练时的周期数以及训练过程中的批处理大小。

我们还将创建用于权重矩阵、隐藏偏置向量和可见偏置向量的零矩阵:

# Define RBM class
class RBM(object):

    def __init__(self, input_size, output_size,
                 learning_rate, epochs, batchsize):
        # Define hyperparameters
        self._input_size = input_size
        self._output_size = output_size
        self.learning_rate = learning_rate
        self.epochs = epochs
        self.batchsize = batchsize

        # Initialize weights and biases using zero matrices
        self.w = np.zeros([input_size, output_size], "float")
        self.hb = np.zeros([output_size], "float")
        self.vb = np.zeros([input_size], "float")

接下来,让我们定义前向传播、反向传播和在这些传播过程中数据抽样的函数。

这里是前向传播,其中h是隐藏层,v是可见层:

def prob_h_given_v(self, visible, w, hb):
    return tf.nn.sigmoid(tf.matmul(visible, w) + hb)

这里是反向传播的过程:

def prob_v_given_h(self, hidden, w, vb):
    return tf.nn.sigmoid(tf.matmul(hidden, tf.transpose(w)) + vb)

这里是抽样函数的定义:

def sample_prob(self, probs):
    return tf.nn.relu(tf.sign(probs - tf.random_uniform(tf.shape(probs))))

现在我们需要一个函数来执行训练。由于我们使用 TensorFlow,我们首先需要为 TensorFlow 图创建占位符,在我们将数据馈送到 TensorFlow 会话中时使用。

我们将为权重矩阵、隐藏偏置向量和可见偏置向量创建占位符。我们还需要用零初始化这三者的值。此外,我们需要一个集合来保存当前值和一个集合来保存先前的值:

_w = tf.placeholder("float", [self._input_size, self._output_size])
_hb = tf.placeholder("float", [self._output_size])
_vb = tf.placeholder("float", [self._input_size])

prv_w = np.zeros([self._input_size, self._output_size], "float")
prv_hb = np.zeros([self._output_size], "float")
prv_vb = np.zeros([self._input_size], "float")

cur_w = np.zeros([self._input_size, self._output_size], "float")
cur_hb = np.zeros([self._output_size], "float")
cur_vb = np.zeros([self._input_size], "float")

同样,我们需要一个可见层的占位符。隐藏层是从可见层和权重矩阵的矩阵乘法以及隐藏偏置向量的矩阵加法导出的:

v0 = tf.placeholder("float", [None, self._input_size])
h0 = self.sample_prob(self.prob_h_given_v(v0, _w, _hb))

在反向传播期间,我们取隐藏层输出,与正向传播期间使用的权重矩阵的转置相乘,并加上可见偏置向量。请注意,权重矩阵在正向和反向传播期间是相同的。然后,我们再次执行正向传播:

v1 = self.sample_prob(self.prob_v_given_h(h0, _w, _vb))
h1 = self.prob_h_given_v(v1, _w, _hb)

要更新权重,我们执行对比散度[²]。

我们还将误差定义为 MSE。

positive_grad = tf.matmul(tf.transpose(v0), h0)
negative_grad = tf.matmul(tf.transpose(v1), h1)

update_w = _w + self.learning_rate * \
    (positive_grad - negative_grad) / tf.to_float(tf.shape(v0)[0])
update_vb = _vb +  self.learning_rate * tf.reduce_mean(v0 - v1, 0)
update_hb = _hb +  self.learning_rate * tf.reduce_mean(h0 - h1, 0)

err = tf.reduce_mean(tf.square(v0 - v1))

有了这些,我们可以使用刚刚定义的变量初始化 TensorFlow 会话。

一旦我们调用sess.run,我们可以输入数据批次开始训练。在训练过程中,将进行前向和反向传播,并根据生成数据与原始输入的比较更新 RBM 权重。我们将打印每个 epoch 的重构误差。

with tf.Session() as sess:
 sess.run(tf.global_variables_initializer())

 for epoch in range(self.epochs):
     for start, end in zip(range(0, len(X),
      self.batchsize),range(self.batchsize,len(X), self.batchsize)):
         batch = X[start:end]
         cur_w = sess.run(update_w, feed_dict={v0: batch,
          _w: prv_w, _hb: prv_hb, _vb: prv_vb})
         cur_hb = sess.run(update_hb, feed_dict={v0: batch,
          _w: prv_w, _hb: prv_hb, _vb: prv_vb})
         cur_vb = sess.run(update_vb, feed_dict={v0: batch,
          _w: prv_w, _hb: prv_hb, _vb: prv_vb})
         prv_w = cur_w
         prv_hb = cur_hb
         prv_vb = cur_vb
     error = sess.run(err, feed_dict={v0: X,
      _w: cur_w, _vb: cur_vb, _hb: cur_hb})
     print ('Epoch: %d' % epoch,'reconstruction error: %f' % error)
 self.w = prv_w
 self.hb = prv_hb
 self.vb = prv_vb

训练 RBM 推荐系统

要训练 RBM,让我们从ratings_train创建一个名为inputX的 NumPy 数组,并将这些值转换为 float32。我们还将定义 RBM 以接受一千维的输入,输出一千维的输出,使用学习率为 0.3,训练五百个 epoch,并使用批量大小为两百。这些参数只是初步的参数选择;您应该通过实验找到更优的参数,鼓励进行实验:

# Begin the training cycle

# Convert inputX into float32
inputX = ratings_train
inputX = inputX.astype(np.float32)

# Define the parameters of the RBMs we will train
rbm=RBM(1000,1000,0.3,500,200)

让我们开始训练:

rbm.train(inputX)
outputX, reconstructedX, hiddenX = rbm.rbm_output(inputX)

图 10-4 显示了重构误差的图。

RBM 错误图

图 10-4. RBM 错误图

长时间训练后,误差项通常会减少。

现在让我们将开发的 RBM 模型应用于预测验证集中用户的评分(该验证集与训练集中的用户相同):

# Predict ratings for validation set
inputValidation = ratings_validation
inputValidation = inputValidation.astype(np.float32)

finalOutput_validation, reconstructedOutput_validation, _ = \
    rbm.rbm_output(inputValidation)

接下来,让我们将预测转换为数组,并根据真实验证评分计算 MSE:

predictionsArray = reconstructedOutput_validation
pred_validation = \
    predictionsArray[ratings_validation.nonzero()].flatten()
actual_validation = \
    ratings_validation[ratings_validation.nonzero()].flatten()

rbm_prediction = mean_squared_error(pred_validation, actual_validation)
print('Mean squared error using RBM prediction:', rbm_prediction)

以下代码显示了验证集上的 MSE:

Mean squared error using RBM prediction: 9.331135003325205

这个 MSE 是一个起点,随着更多的实验,可能会有所改进。

结论

在本章中,我们探讨了受限玻尔兹曼机,并用它们构建了一个电影评分的推荐系统。我们构建的 RBM 推荐系统学习了给定用户之前评分和他们最相似用户的评分情况下电影评分的概率分布。然后,我们使用学习的概率分布来预测以前未见的电影的评分。

在第十一章,我们将堆叠 RBM 以构建深度信念网络,并使用它们执行更强大的无监督学习任务。

¹ 这类 RBM 的最常见训练算法被称为基于梯度的对比散度算法。

² 更多关于这个主题的内容,请参阅论文“对比散度学习”

第十一章:使用深度信念网络进行特征检测

在第十章中,我们探索了限制玻尔兹曼机并使用它们构建了一个电影评分的推荐系统。在本章中,我们将堆叠 RBM 构建深度信念网络(DBNs)。DBNs 是由多伦多大学的杰弗·辛顿于 2006 年首次提出的。

RBM 只有两层,一个可见层和一个隐藏层;换句话说,RBM 只是浅层神经网络。DBN 由多个 RBM 组成——一个 RBM 的隐藏层作为下一个 RBM 的可见层。因为它们涉及许多层,所以 DBN 是深度神经网络。事实上,它们是我们迄今为止介绍的第一种深度无监督神经网络。

浅层无监督神经网络,比如 RBM,不能捕获图像、声音和文本等复杂数据的结构,但 DBN 可以。DBN 已被用于识别和聚类图像、视频捕获、声音和文本,尽管过去十年中其他深度学习方法在性能上已超过了 DBN。

深度信念网络详解

与 RBM 一样,DBN 可以学习输入的基本结构并以概率方式重构它。换句话说,DBN——就像 RBM 一样——是生成模型。而且,与 RBM 一样,DBN 中的层之间只有连接,但每一层内部的单元之间没有连接。

在 DBN 中,一次训练一层,从第一个隐藏层开始,它与输入层一起组成第一个 RBM。一旦训练了第一个 RBM,第一个 RBM 的隐藏层将作为下一个 RBM 的可见层,并用于训练 DBN 的第二个隐藏层。

这个过程会一直持续到 DBN 的所有层都被训练完毕。除了 DBN 的第一层和最后一层之外,DBN 中的每一层都既充当了一个隐藏层,也充当了一个 RBM 的可见层。

DBN 是一种表示的层次结构,就像所有神经网络一样,它是一种表示学习形式。请注意,DBN 不使用任何标签。相反,DBN 一次学习输入数据中的一个层的底层结构。

标签可以用来微调 DBN 的最后几层,但只有在初始无监督学习完成后才能这样做。例如,如果我们想要 DBN 成为一个分类器,我们会先进行无监督学习(称为预训练过程),然后使用标签微调 DBN(称为微调过程)。

MNIST 图像分类

让我们再次使用 DBN 构建图像分类器。我们将再次使用 MNIST 数据集。

首先,让我们加载必要的库:

'''Main'''
import numpy as np
import pandas as pd
import os, time, re
import pickle, gzip, datetime

'''Data Viz'''
import matplotlib.pyplot as plt
import seaborn as sns
color = sns.color_palette()
import matplotlib as mpl

%matplotlib inline

'''Data Prep and Model Evaluation'''
from sklearn import preprocessing as pp
from sklearn.model_selection import train_test_split
from sklearn.model_selection import StratifiedKFold
from sklearn.metrics import log_loss, accuracy_score
from sklearn.metrics import precision_recall_curve, average_precision_score
from sklearn.metrics import roc_curve, auc, roc_auc_score, mean_squared_error

'''Algos'''
import lightgbm as lgb

'''TensorFlow and Keras'''
import tensorflow as tf
import keras
from keras import backend as K
from keras.models import Sequential, Model
from keras.layers import Activation, Dense, Dropout
from keras.layers import BatchNormalization, Input, Lambda
from keras.layers import Embedding, Flatten, dot
from keras import regularizers
from keras.losses import mse, binary_crossentropy

然后我们将加载数据并将其存储在 Pandas DataFrames 中。我们还将将标签编码为 one-hot 向量。这与我们在本书早期介绍 MNIST 数据集时所做的工作类似:

# Load the datasets
current_path = os.getcwd()
file = '\\datasets\\mnist_data\\mnist.pkl.gz'
f = gzip.open(current_path+file, 'rb')
train_set, validation_set, test_set = pickle.load(f, encoding='latin1')
f.close()

X_train, y_train = train_set[0], train_set[1]
X_validation, y_validation = validation_set[0], validation_set[1]
X_test, y_test = test_set[0], test_set[1]

# Create Pandas DataFrames from the datasets
train_index = range(0,len(X_train))
validation_index = range(len(X_train),len(X_train)+len(X_validation))
test_index = range(len(X_train)+len(X_validation), \
                   len(X_train)+len(X_validation)+len(X_test))

X_train = pd.DataFrame(data=X_train,index=train_index)
y_train = pd.Series(data=y_train,index=train_index)

X_validation = pd.DataFrame(data=X_validation,index=validation_index)
y_validation = pd.Series(data=y_validation,index=validation_index)

X_test = pd.DataFrame(data=X_test,index=test_index)
y_test = pd.Series(data=y_test,index=test_index)

def view_digit(X, y, example):
    label = y.loc[example]
    image = X.loc[example,:].values.reshape([28,28])
    plt.title('Example: %d Label: %d' % (example, label))
    plt.imshow(image, cmap=plt.get_cmap('gray'))
    plt.show()

def one_hot(series):
    label_binarizer = pp.LabelBinarizer()
    label_binarizer.fit(range(max(series)+1))
    return label_binarizer.transform(series)

# Create one-hot vectors for the labels
y_train_oneHot = one_hot(y_train)
y_validation_oneHot = one_hot(y_validation)
y_test_oneHot = one_hot(y_test)

限制玻尔兹曼机

接下来,让我们定义一个 RBM 类,这样我们就可以快速连续训练多个 RBM(它们是 DBN 的构建模块)。

请记住,RBM 具有输入层(也称为可见层)和单个隐藏层,神经元之间的连接受到限制,使得神经元仅连接到其他层中的神经元,而不连接同一层中的神经元。还要记住,层间通信是双向的,不仅是单向的或者像自编码器那样的前向方式。

在 RBM 中,可见层的神经元与隐藏层通信,隐藏层从 RBM 学习的概率模型生成数据,然后隐藏层将这个生成的信息传递回可见层。可见层接收来自隐藏层的生成数据样本,对其进行采样,将其与原始数据进行比较,并根据生成数据样本与原始数据之间的重构误差,向隐藏层发送新信息,以再次重复此过程。

通过这种双向通信方式,RBM 开发了一个生成模型,使得从隐藏层输出的重构数据与原始输入相似。

构建 RBM 类的组件

就像我们在第十章中所做的那样,让我们逐步了解RBM类的各个组成部分。

首先,我们将使用几个参数来初始化这个类;它们是 RBM 的输入大小、输出大小、学习速率、训练时的时代数以及批处理大小。我们还将创建权重矩阵、隐藏偏置向量和可见偏置向量的零矩阵:

# Define RBM class
class RBM(object):

    def __init__(self, input_size, output_size,
                 learning_rate, epochs, batchsize):
        # Define hyperparameters
        self._input_size = input_size
        self._output_size = output_size
        self.learning_rate = learning_rate
        self.epochs = epochs
        self.batchsize = batchsize

        # Initialize weights and biases using zero matrices
        self.w = np.zeros([input_size, output_size], "float")
        self.hb = np.zeros([output_size], "float")
        self.vb = np.zeros([input_size], "float")

接下来,让我们定义正向传递、反向传递以及在每次传递期间对数据进行采样的函数。

这里是正向传递,其中h是隐藏层,v是可见层:

def prob_h_given_v(self, visible, w, hb):
    return tf.nn.sigmoid(tf.matmul(visible, w) + hb)

这里是向后传递:

def prob_v_given_h(self, hidden, w, vb):
    return tf.nn.sigmoid(tf.matmul(hidden, tf.transpose(w)) + vb)

这里是采样函数:

def sample_prob(self, probs):
    return tf.nn.relu(tf.sign(probs - tf.random_uniform(tf.shape(probs))))

现在我们需要一个执行训练的函数。因为我们使用的是 TensorFlow,所以我们首先需要为 TensorFlow 图创建占位符,当我们将数据提供给 TensorFlow 会话时将使用这些占位符。

我们将为权重矩阵、隐藏偏置向量和可见偏置向量设立占位符。我们还需要使用零初始化这三者的值。并且,我们需要一个集合来保存当前值,另一个集合来保存先前的值:

_w = tf.placeholder("float", [self._input_size, self._output_size])
_hb = tf.placeholder("float", [self._output_size])
_vb = tf.placeholder("float", [self._input_size])

prv_w = np.zeros([self._input_size, self._output_size], "float")
prv_hb = np.zeros([self._output_size], "float")
prv_vb = np.zeros([self._input_size], "float")

cur_w = np.zeros([self._input_size, self._output_size], "float")
cur_hb = np.zeros([self._output_size], "float")
cur_vb = np.zeros([self._input_size], "float")

同样地,我们需要一个可见层的占位符。隐藏层是通过可见层和权重矩阵的矩阵乘法以及隐藏偏置向量的矩阵加法派生的:

v0 = tf.placeholder("float", [None, self._input_size])
h0 = self.sample_prob(self.prob_h_given_v(v0, _w, _hb))

在向后传递期间,我们获取隐藏层输出,将其与在正向传递期间使用的权重矩阵的转置相乘,并添加可见偏置向量。请注意,权重矩阵在正向和向后传递期间都是相同的。

然后我们再次执行正向传递:

v1 = self.sample_prob(self.prob_v_given_h(h0, _w, _vb))
h1 = self.prob_h_given_v(v1, _w, _hb)

要更新权重,我们执行对比散度,我们在 第十章 中介绍过。我们还定义误差为均方误差(MSE):

positive_grad = tf.matmul(tf.transpose(v0), h0)
negative_grad = tf.matmul(tf.transpose(v1), h1)

update_w = _w + self.learning_rate * \
    (positive_grad - negative_grad) / tf.to_float(tf.shape(v0)[0])
update_vb = _vb +  self.learning_rate * tf.reduce_mean(v0 - v1, 0)
update_hb = _hb +  self.learning_rate * tf.reduce_mean(h0 - h1, 0)

err = tf.reduce_mean(tf.square(v0 - v1))

有了这个,我们就可以用刚刚定义的变量初始化 TensorFlow 会话了。

一旦我们调用 sess.run,我们就可以提供数据的批次开始训练。在训练过程中,将进行前向和反向传播,并根据生成数据与原始输入的比较更新 RBM 的权重。我们将打印每个周期的重建误差:

with tf.Session() as sess:
    sess.run(tf.global_variables_initializer())

    for epoch in range(self.epochs):
        for start, end in zip(range(0, len(X), self.batchsize), \
                range(self.batchsize,len(X), self.batchsize)):
            batch = X[start:end]
            cur_w = sess.run(update_w, \
                feed_dict={v0: batch, _w: prv_w, \
                           _hb: prv_hb, _vb: prv_vb})
            cur_hb = sess.run(update_hb, \
                feed_dict={v0: batch, _w: prv_w, \
                           _hb: prv_hb, _vb: prv_vb})
            cur_vb = sess.run(update_vb, \
                feed_dict={v0: batch, _w: prv_w, \
                           _hb: prv_hb, _vb: prv_vb})
            prv_w = cur_w
            prv_hb = cur_hb
            prv_vb = cur_vb
        error = sess.run(err, feed_dict={v0: X, _w: cur_w, \
                                        _vb: cur_vb, _hb: cur_hb})
        print ('Epoch: %d' % epoch,'reconstruction error: %f' % error)
    self.w = prv_w
    self.hb = prv_hb
    self.vb = prv_vb

使用 RBM 模型生成图像

让我们也定义一个函数,从 RBM 学习的生成模型中生成新图像:

def rbm_output(self, X):

    input_X = tf.constant(X)
    _w = tf.constant(self.w)
    _hb = tf.constant(self.hb)
    _vb = tf.constant(self.vb)
    out = tf.nn.sigmoid(tf.matmul(input_X, _w) + _hb)
    hiddenGen = self.sample_prob(self.prob_h_given_v(input_X, _w, _hb))
    visibleGen = self.sample_prob(self.prob_v_given_h(hiddenGen, _w, _vb))
    with tf.Session() as sess:
        sess.run(tf.global_variables_initializer())
        return sess.run(out), sess.run(visibleGen), sess.run(hiddenGen)

我们将原始图像矩阵 X 输入到函数中。我们为原始图像矩阵、权重矩阵、隐藏偏置向量和可见偏置向量创建 TensorFlow 占位符。然后,我们将输入矩阵推送以产生正向传播的输出(out)、隐藏层的样本生成(hiddenGen)以及模型生成的重建图像的样本(visibleGen)。

查看中间特征检测器

最后,让我们定义一个函数来显示隐藏层的特征检测器:

def show_features(self, shape, suptitle, count=-1):
    maxw = np.amax(self.w.T)
    minw = np.amin(self.w.T)
    count = self._output_size if count == -1 or count > \
            self._output_size else count
    ncols = count if count < 14 else 14
    nrows = count//ncols
    nrows = nrows if nrows > 2 else 3
    fig = plt.figure(figsize=(ncols, nrows), dpi=100)
    grid = Grid(fig, rect=111, nrows_ncols=(nrows, ncols), axes_pad=0.01)

    for i, ax in enumerate(grid):
        x = self.w.T[i] if i<self._input_size else np.zeros(shape)
        x = (x.reshape(1, -1) - minw)/maxw
        ax.imshow(x.reshape(*shape), cmap=mpl.cm.Greys)
        ax.set_axis_off()

    fig.text(0.5,1, suptitle, fontsize=20, horizontalalignment='center')
    fig.tight_layout()
    plt.show()
    return

现在我们将在 MNIST 数据集上使用这些函数及其他函数。

训练深度信念网络的三个 RBM

现在我们将使用 MNIST 数据来依次训练三个 RBM,其中一个 RBM 的隐藏层将作为下一个 RBM 的可见层。这三个 RBM 将组成我们正在构建的用于图像分类的深度信念网络(DBN)。

首先,让我们将训练数据转换为 NumPy 数组并存储起来。接下来,我们将创建一个名为 rbm_list 的列表来保存我们训练的 RBM。然后,我们将定义三个 RBM 的超参数,包括输入大小、输出大小、学习率、训练周期数以及训练的批次大小。

所有这些都可以使用我们之前定义的 RBM 类来构建。

对于我们的深度信念网络(DBN),我们将使用以下的受限玻尔兹曼机(RBM):第一个将接收原始的 784 维输入,并输出一个 700 维的矩阵。接下来的 RBM 将使用第一个 RBM 输出的 700 维矩阵,并输出一个 600 维的矩阵。最后,我们训练的最后一个 RBM 将接收 600 维的矩阵,并输出一个 500 维的矩阵。

我们将使用学习率为 1.0 来训练所有三个 RBM,每个训练 100 个周期,并使用批次大小为两百:

# Since we are training, set input as training data
inputX = np.array(X_train)

# Create list to hold our RBMs
rbm_list = []

# Define the parameters of the RBMs we will train
rbm_list.append(RBM(784,700,1.0,100,200))
rbm_list.append(RBM(700,600,1.0,100,200))
rbm_list.append(RBM(600,500,1.0,100,200))

现在让我们训练 RBM。我们将把训练好的 RBM 存储在名为 outputList 的列表中。

注意,我们使用我们之前定义的 rbm_output 函数来生成输出矩阵,换句话说,是后续我们训练的 RBM 的输入/可见层:

outputList = []
error_list = []
#For each RBM in our list
for i in range(0,len(rbm_list)):
    print('RBM', i+1)
    #Train a new one
    rbm = rbm_list[i]
    err = rbm.train(inputX)
    error_list.append(err)
    #Return the output layer
    outputX, reconstructedX, hiddenX = rbm.rbm_output(inputX)
    outputList.append(outputX)
    inputX = hiddenX

随着训练的进行,每个 RBM 的误差都在下降(参见图 11-1,11-2 和 11-3)。请注意,RBM 误差反映了给定 RBM 可见层重构数据与输入数据有多相似。

第一个 RBM 的重构误差

图 11-1. 第一个 RBM 的重构误差

第二个 RBM 的重构误差

图 11-2. 第二个 RBM 的重构误差

第三个 RBM 的重构误差

图 11-3. 第三个 RBM 的重构误差

检查特征探测器

现在让我们使用之前定义的rbm.show_features函数来查看每个 RBM 学到的特征:

rbm_shapes = [(28,28),(25,24),(25,20)]
for i in range(0,len(rbm_list)):
    rbm = rbm_list[i]
    print(rbm.show_features(rbm_shapes[i],
     "RBM learned features from MNIST", 56))

图 11-4 展示了各个 RBM 学到的特征。

如您所见,每个 RBM 从 MNIST 数据中学到的特征越来越抽象。第一个 RBM 的特征模糊地类似于数字,而第二个和第三个 RBM 的特征则越来越微妙且难以辨认。这在图像数据的特征探测器中是非常典型的;神经网络的深层逐渐识别原始图像中越来越抽象的元素。

RBM 的学习特征

图 11-4. RBM 的学习特征

查看生成的图像

在我们构建完整的 DBN 之前,让我们查看我们刚刚训练的某个 RBM 生成的一些图像。

为了简化问题,我们将原始的 MNIST 训练矩阵输入我们训练过的第一个 RBM 中,进行前向传播和反向传播,然后生成我们需要的图像。我们将比较 MNIST 数据集的前十张图像与新生成的图像:

inputX = np.array(X_train)
rbmOne = rbm_list[0]

print('RBM 1')
outputX_rbmOne, reconstructedX_rbmOne, hiddenX_rbmOne =
 rbmOne.rbm_output(inputX)
reconstructedX_rbmOne = pd.DataFrame(data=reconstructedX_rbmOne,
 index=X_train.index)
for j in range(0,10):
    example = j
    view_digit(reconstructedX, y_train, example)
    view_digit(X_train, y_train, example)

图 11-5 展示了第一个 RBM 生成的第一张图像与第一张原始图像的比较。

第一个 RBM 的第一张生成图像

图 11-5. 第一个 RBM 的第一张生成图像

如您所见,生成的图像与原始图像相似——两者都显示数字五。

让我们查看更多这样的图像,将 RBM 生成的图像与原始图像进行比较(参见 11-6 到 11-9 图)。

第一个 RBM 的第二张生成图像

图 11-6. 第一个 RBM 的第二张生成图像

第一个 RBM 的第三张生成图像

图 11-7. 第一个 RBM 的第三张生成图像

第一个 RBM 的第四张生成图像

图 11-8. 第一个 RBM 的第四张生成图像

第一个 RBM 的第五张生成图像

图 11-9. 第一个 RBM 的第五张生成图像

这些数字分别是零,四,一和九,并且生成的图像与原始图像看起来相似。

完整的 DBN

现在,让我们定义 DBN 类,它将接受我们刚刚训练的三个 RBM,并添加一个第四个 RBM,执行前向和后向传递,以完善基于 DBN 的生成模型。

首先,让我们定义类的超参数。这些包括原始输入大小,我们刚刚训练的第三个受限玻尔兹曼机(RBM)的输入大小,我们希望从深度置信网络(DBN)得到的最终输出大小,学习率,我们希望训练的周期数,用于训练的批量大小,以及我们刚刚训练的三个 RBM。和以前一样,我们需要生成权重矩阵,隐藏偏置和可见偏置的零矩阵:

class DBN(object):
    def __init__(self, original_input_size, input_size, output_size,
                 learning_rate, epochs, batchsize, rbmOne, rbmTwo, rbmThree):
        # Define hyperparameters
        self._original_input_size = original_input_size
        self._input_size = input_size
        self._output_size = output_size
        self.learning_rate = learning_rate
        self.epochs = epochs
        self.batchsize = batchsize
        self.rbmOne = rbmOne
        self.rbmTwo = rbmTwo
        self.rbmThree = rbmThree

        self.w = np.zeros([input_size, output_size], "float")
        self.hb = np.zeros([output_size], "float")
        self.vb = np.zeros([input_size], "float")

类似之前,我们将定义函数执行前向传递和后向传递,并从每个中获取样本:

def prob_h_given_v(self, visible, w, hb):
    return tf.nn.sigmoid(tf.matmul(visible, w) + hb)

def prob_v_given_h(self, hidden, w, vb):
    return tf.nn.sigmoid(tf.matmul(hidden, tf.transpose(w)) + vb)

def sample_prob(self, probs):
    return tf.nn.relu(tf.sign(probs - tf.random_uniform(tf.shape(probs))))

对于训练,我们需要权重,隐藏偏置和可见偏置的占位符。我们还需要用于以前和当前权重,隐藏偏置和可见偏置的矩阵:

def train(self, X):
    _w = tf.placeholder("float", [self._input_size, self._output_size])
    _hb = tf.placeholder("float", [self._output_size])
    _vb = tf.placeholder("float", [self._input_size])

    prv_w = np.zeros([self._input_size, self._output_size], "float")
    prv_hb = np.zeros([self._output_size], "float")
    prv_vb = np.zeros([self._input_size], "float")

    cur_w = np.zeros([self._input_size, self._output_size], "float")
    cur_hb = np.zeros([self._output_size], "float")
    cur_vb = np.zeros([self._input_size], "float")

我们将为可见层设置一个占位符。

接下来,我们将初始输入——可见层——通过之前训练的三个 RBM。这导致了输出forward,我们将其传递到我们作为这个 DBN 类一部分训练的第四个 RBM:

v0 = tf.placeholder("float", [None, self._original_input_size])
forwardOne = tf.nn.relu(tf.sign(tf.nn.sigmoid(tf.matmul(v0, \
                self.rbmOne.w) + self.rbmOne.hb) - tf.random_uniform( \
                tf.shape(tf.nn.sigmoid(tf.matmul(v0, self.rbmOne.w) + \
                self.rbmOne.hb)))))
forwardTwo = tf.nn.relu(tf.sign(tf.nn.sigmoid(tf.matmul(forwardOne, \
                self.rbmTwo.w) + self.rbmTwo.hb) - tf.random_uniform( \
                tf.shape(tf.nn.sigmoid(tf.matmul(forwardOne, \
                self.rbmTwo.w) + self.rbmTwo.hb)))))
forward = tf.nn.relu(tf.sign(tf.nn.sigmoid(tf.matmul(forwardTwo, \
                self.rbmThree.w) + self.rbmThree.hb) - \
                tf.random_uniform(tf.shape(tf.nn.sigmoid(tf.matmul( \
                forwardTwo, self.rbmThree.w) + self.rbmThree.hb)))))
h0 = self.sample_prob(self.prob_h_given_v(forward, _w, _hb))
v1 = self.sample_prob(self.prob_v_given_h(h0, _w, _vb))
h1 = self.prob_h_given_v(v1, _w, _hb)

我们将像之前一样定义对比散度:

positive_grad = tf.matmul(tf.transpose(forward), h0)
negative_grad = tf.matmul(tf.transpose(v1), h1)

update_w = _w + self.learning_rate * (positive_grad - negative_grad) / \
                tf.to_float(tf.shape(forward)[0])
update_vb = _vb +  self.learning_rate * tf.reduce_mean(forward - v1, 0)
update_hb = _hb +  self.learning_rate * tf.reduce_mean(h0 - h1, 0)

一旦我们通过这个 DBN 进行完整的前向传递——包括我们早先训练的三个 RBM 和最新的第四个 RBM——我们需要将第四个 RBM 的隐藏层输出再通过整个 DBN。

这需要通过第四个 RBM 进行反向传递,以及通过前三个 RBM 进行反向传递。我们还将像以前一样使用均方误差(MSE)。以下是反向传递发生的方式:

backwardOne = tf.nn.relu(tf.sign(tf.nn.sigmoid(tf.matmul(v1, \
                    self.rbmThree.w.T) + self.rbmThree.vb) - \
                    tf.random_uniform(tf.shape(tf.nn.sigmoid( \
                    tf.matmul(v1, self.rbmThree.w.T) + \
                    self.rbmThree.vb)))))
backwardTwo = tf.nn.relu(tf.sign(tf.nn.sigmoid(tf.matmul(backwardOne, \
                    self.rbmTwo.w.T) + self.rbmTwo.vb) - \
                    tf.random_uniform(tf.shape(tf.nn.sigmoid( \
                    tf.matmul(backwardOne, self.rbmTwo.w.T) + \
                    self.rbmTwo.vb)))))
backward = tf.nn.relu(tf.sign(tf.nn.sigmoid(tf.matmul(backwardTwo, \
                    self.rbmOne.w.T) + self.rbmOne.vb) - \
                    tf.random_uniform(tf.shape(tf.nn.sigmoid( \
                    tf.matmul(backwardTwo, self.rbmOne.w.T) + \
                    self.rbmOne.vb)))))

err = tf.reduce_mean(tf.square(v0 - backward))

这是 DBN 类的实际训练部分,与之前的 RBM 非常相似:

with tf.Session() as sess:
    sess.run(tf.global_variables_initializer())

    for epoch in range(self.epochs):
        for start, end in zip(range(0, len(X), self.batchsize), \
                range(self.batchsize,len(X), self.batchsize)):
            batch = X[start:end]
            cur_w = sess.run(update_w, feed_dict={v0: batch, _w: \
                                prv_w, _hb: prv_hb, _vb: prv_vb})
            cur_hb = sess.run(update_hb, feed_dict={v0: batch, _w: \
                                prv_w, _hb: prv_hb, _vb: prv_vb})
            cur_vb = sess.run(update_vb, feed_dict={v0: batch, _w: \
                                prv_w, _hb: prv_hb, _vb: prv_vb})
            prv_w = cur_w
            prv_hb = cur_hb
            prv_vb = cur_vb
        error = sess.run(err, feed_dict={v0: X, _w: cur_w, _vb: \
                            cur_vb, _hb: cur_hb})
        print ('Epoch: %d' % epoch,'reconstruction error: %f' % error)
    self.w = prv_w
    self.hb = prv_hb
    self.vb = prv_vb

让我们定义函数来从 DBN 生成图像并展示特征。这些与之前的 RBM 版本类似,但我们将数据通过 DBN 类中的所有四个 RBM,而不仅仅是一个单独的 RBM:

def dbn_output(self, X):

    input_X = tf.constant(X)
    forwardOne = tf.nn.sigmoid(tf.matmul(input_X, self.rbmOne.w) + \
                               self.rbmOne.hb)
    forwardTwo = tf.nn.sigmoid(tf.matmul(forwardOne, self.rbmTwo.w) + \
                               self.rbmTwo.hb)
    forward = tf.nn.sigmoid(tf.matmul(forwardTwo, self.rbmThree.w) + \
                            self.rbmThree.hb)

    _w = tf.constant(self.w)
    _hb = tf.constant(self.hb)
    _vb = tf.constant(self.vb)

    out = tf.nn.sigmoid(tf.matmul(forward, _w) + _hb)
    hiddenGen = self.sample_prob(self.prob_h_given_v(forward, _w, _hb))
    visibleGen = self.sample_prob(self.prob_v_given_h(hiddenGen, _w, _vb))

    backwardTwo = tf.nn.sigmoid(tf.matmul(visibleGen, self.rbmThree.w.T) + \
                                self.rbmThree.vb)
    backwardOne = tf.nn.sigmoid(tf.matmul(backwardTwo, self.rbmTwo.w.T) + \
                                self.rbmTwo.vb)
    backward = tf.nn.sigmoid(tf.matmul(backwardOne, self.rbmOne.w.T) + \
                             self.rbmOne.vb)

    with tf.Session() as sess:
        sess.run(tf.global_variables_initializer())
        return sess.run(out), sess.run(backward)
def show_features(self, shape, suptitle, count=-1):
    maxw = np.amax(self.w.T)
    minw = np.amin(self.w.T)
    count = self._output_size if count == -1 or count > \
            self._output_size else count
    ncols = count if count < 14 else 14
    nrows = count//ncols
    nrows = nrows if nrows > 2 else 3
    fig = plt.figure(figsize=(ncols, nrows), dpi=100)
    grid = Grid(fig, rect=111, nrows_ncols=(nrows, ncols), axes_pad=0.01)

    for i, ax in enumerate(grid):
        x = self.w.T[i] if i<self._input_size else np.zeros(shape)
        x = (x.reshape(1, -1) - minw)/maxw
        ax.imshow(x.reshape(*shape), cmap=mpl.cm.Greys)
        ax.set_axis_off()

    fig.text(0.5,1, suptitle, fontsize=20, horizontalalignment='center')
    fig.tight_layout()
    plt.show()
    return

DBN 训练的工作原理

每个我们已经训练的三个 RBM 都有自己的权重矩阵,隐藏偏置向量和可见偏置向量。在作为 DBN 一部分训练的第四个 RBM 期间,我们不会调整这前三个 RBM 的权重矩阵,隐藏偏置向量和可见偏置向量。相反,我们将使用这前三个 RBM 作为 DBN 的固定组件。我们将仅调用这前三个 RBM 执行正向和反向传播(并使用这三个生成的数据样本)。

在训练 DBN 的第四个 RBM 时,我们只会调整第四个 RBM 的权重和偏置。换句话说,DBN 中的第四个 RBM 以前三个 RBM 的输出作为给定值,并执行前向和反向传播,学习生成模型,以使其生成的图像与原始图像之间的重构误差最小化。

训练 DBN 的另一种方法是允许 DBN 在执行整个网络的前向和反向传播时学习和调整所有四个 RBM 的权重。然而,DBN 的训练会非常昂贵(也许今天的计算机不算,但从 2006 年首次引入 DBN 的标准来看,肯定是如此)。

话虽如此,如果我们希望进行更细致的预训练,我们可以允许单个受限玻尔兹曼机(RBM)的权重在每次网络前向和反向传播的批次中进行调整。我们不会深入讨论这一点,但我鼓励你在自己的时间里进行实验。

训练 DBN

现在我们将训练 DBN。我们设置原始图像尺寸为 784,第三个 RBM 的输出尺寸为 500,DBN 的期望尺寸也为 500。我们将使用学习率为 1.0 进行 50 个 epochs 的训练,并使用批量大小为 200。最后,我们将前三个训练好的 RBM 称为 DBN 的一部分:

# Instantiate DBN Class
dbn = DBN(784, 500, 500, 1.0, 50, 200, rbm_list[0], rbm_list[1], rbm_list[2])

现在,让我们开始训练:

inputX = np.array(X_train)
error_list = []
error_list = dbn.train(inputX)

图 11-10 展示了训练过程中深度信念网络(DBN)的重构误差。

DBN 的重构误差

图 11-10. DBN 的重构误差

图 11-11 展示了 DBN 最后一层——第四个 RBM 的隐藏层——学到的特征。

DBN 中第四个 RBM 的学习特征

图 11-11. DBN 中第四个 RBM 的学习特征

重构误差和学习到的特征看起来都很合理,并且与我们之前分析的单独 RBM 的情况相似。

无监督学习如何帮助监督学习

到目前为止,我们所做的所有关于训练 RBM 和 DBN 的工作都涉及无监督学习。我们完全没有使用任何图像的标签。相反,我们通过从 50,000 个示例训练集中的原始 MNIST 图像中学习相关的潜在特征来构建生成模型。这些生成模型生成的图像看起来与原始图像相似(最小化重构误差)。

让我们退一步,以理解这种生成模型的用处。

请记住,世界上大多数数据都是无标签的。因此,尽管监督学习非常强大和有效,我们仍然需要无监督学习来帮助理解所有存在的无标签数据。仅靠监督学习是不够的。

为了展示无监督学习的有效性,想象一下,如果训练集中的 MNIST 图像只有 5000 张标记图像,而不是 50000 张标记图像,有监督学习的图像分类器的效果将大不如拥有 50000 张图像的有监督学习的图像分类器。我们拥有的标记数据越多,机器学习解决方案就越好。

无监督学习在这种情况下如何帮助?无监督学习能提供帮助的一种方式是生成新的带标签示例,以帮助补充最初的标记数据集。然后,有监督学习可以在一个更大的标记数据集上进行,从而获得更好的整体解决方案。

生成图像以构建更好的图像分类器

为了模拟无监督学习能够提供的这种好处,让我们将 MNIST 训练数据集缩减到仅有五千个标记示例。我们将把前五千个图像存储在一个名为inputXReduced的数据框中。

然后,从这五千张标记图像中,我们将使用刚刚构建的生成模型来生成新的图像,使用 DBN。我们将重复这个过程 20 次。换句话说,我们将生成五千个新图像,共创建一个包含十万个样本的数据集,所有这些数据都将被标记。从技术上讲,我们存储的是最终的隐藏层输出,而不是直接重构的图像,尽管我们也会存储重构的图像,以便尽快评估它们。

我们将这 100,000 个输出存储在名为generatedImages的 NumPy 数组中:

# Generate images and store them
inputXReduced = X_train.loc[:4999]
for i in range(0,20):
    print("Run ",i)
    finalOutput_DBN, reconstructedOutput_DBN = dbn.dbn_output(inputXReduced)
    if i==0:
        generatedImages = finalOutput_DBN
    else:
        generatedImages = np.append(generatedImages, finalOutput_DBN, axis=0)

我们将循环使用训练标签中的前五千个标签,称为y_train,重复 20 次以生成名为labels的标签数组:

# Generate a vector of labels for the generated images
for i in range(0,20):
    if i==0:
        labels = y_train.loc[:4999]
    else:
        labels = np.append(labels,y_train.loc[:4999])

最后,我们将在验证集上生成输出,这将用于评估我们即将构建的图像分类器:

# Generate images based on the validation set
inputValidation = np.array(X_validation)
finalOutput_DBN_validation, reconstructedOutput_DBN_validation = \
    dbn.dbn_output(inputValidation)

在使用我们刚生成的数据之前,让我们查看一些重构的图像:

# View reconstructed images
for i in range(0,10):
    example = i
    reconstructedX = pd.DataFrame(data=reconstructedOutput_DBN, \
                                  index=X_train[0:5000].index)
    view_digit(reconstructedX, y_train, example)
    view_digit(X_train, y_train, example)

DBN 的第一张生成图像

图 11-12. DBN 的第一张生成图像

正如您在图 11-12 中所看到的,生成的图像与原始图像非常相似——两者都显示数字五。与我们之前看到的由 RBM 生成的图像不同,这些更类似于原始的 MNIST 图像,包括像素化的部分。

让我们再查看几张这样的图像,以比较 DBN 生成的图像与原始 MNIST 图像(参见图 11-13 到图 11-16)。

DBN 的第二张生成图像

图 11-13. DBN 的第二张生成图像

DBN 的第三张生成图像

图 11-14. DBN 的第三张生成图像

DBN 的第四张生成图像

图 11-15. DBN 的第四张生成图像

DBN 的第五张生成图像

图 11-16. 深度信念网络生成的第五张图像

还要注意,DBN 模型(以及 RBM 模型)是生成型的,因此图像是使用随机过程生成的。图像不是使用确定性过程生成的,因此同一示例的图像在不同的 DBN 运行中会有所不同。

为了模拟这个过程,我们将采用第一张 MNIST 图像,并使用深度信念网络生成一张新图像,重复这个过程 10 次:

# Generate the first example 10 times
inputXReduced = X_train.loc[:0]
for i in range(0,10):
    example = 0
    print("Run ",i)
    finalOutput_DBN_fives, reconstructedOutput_DBN_fives = \
        dbn.dbn_output(inputXReduced)
    reconstructedX_fives = pd.DataFrame(data=reconstructedOutput_DBN_fives, \
                                        index=[0])
    print("Generated")
    view_digit(reconstructedX_fives, y_train.loc[:0], example)

正如您从图 11-17 到 11-21 所看到的,所有生成的图像都显示数字五,但它们的图像会因为使用相同的原始 MNIST 图像而有所不同。

数字五的第一和第二生成图像

图 11-17. 数字五的第一和第二生成图像

数字五的第三和第四生成图像

图 11-18. 数字五的第三和第四生成图像

数字五的第五和第六生成图像

图 11-19. 数字五的第五和第六生成图像

数字五的第七和第八生成图像

图 11-20. 数字五的第七和第八生成图像

数字五的第九和第十生成图像

图 11-21. 数字五的第九和第十生成图像

使用 LightGBM 的图像分类器

现在让我们使用本书前面介绍的监督学习算法构建一个图像分类器:梯度提升算法LightGBM

仅监督学习

第一个图像分类器仅依赖于前五千个标记的 MNIST 图像。这是从原始的 50,000 个标记的 MNIST 训练集中减少的集合;我们设计这个集合来模拟现实世界中标记示例相对较少的问题。由于本书前面已经深入讨论了梯度提升和 LightGBM 算法,因此我们在这里不会详细介绍。

让我们为算法设置参数:

predictionColumns = ['0','1','2','3','4','5','6','7','8','9']

params_lightGB = {
    'task': 'train',
    'application':'binary',
    'num_class':10,
    'boosting': 'gbdt',
    'objective': 'multiclass',
    'metric': 'multi_logloss',
    'metric_freq':50,
    'is_training_metric':False,
    'max_depth':4,
    'num_leaves': 31,
    'learning_rate': 0.1,
    'feature_fraction': 1.0,
    'bagging_fraction': 1.0,
    'bagging_freq': 0,
    'bagging_seed': 2018,
    'verbose': 0,
    'num_threads':16
}

接下来,我们将在 5,000 个标记的 MNIST 训练集(减少后的集合)上进行训练,并在 10,000 个标记的 MNIST 验证集上进行验证:

trainingScore = []
validationScore = []
predictionsLightGBM = pd.DataFrame(data=[], \
                        index=y_validation.index, \
                        columns=predictionColumns)

lgb_train = lgb.Dataset(X_train.loc[:4999], y_train.loc[:4999])
lgb_eval = lgb.Dataset(X_validation, y_validation, reference=lgb_train)
gbm = lgb.train(params_lightGB, lgb_train, num_boost_round=2000,
                   valid_sets=lgb_eval, early_stopping_rounds=200)

loglossTraining = log_loss(y_train.loc[:4999], \
    gbm.predict(X_train.loc[:4999], num_iteration=gbm.best_iteration))
trainingScore.append(loglossTraining)

predictionsLightGBM.loc[X_validation.index,predictionColumns] = \
    gbm.predict(X_validation, num_iteration=gbm.best_iteration)
loglossValidation = log_loss(y_validation,
    predictionsLightGBM.loc[X_validation.index,predictionColumns])
validationScore.append(loglossValidation)

print('Training Log Loss: ', loglossTraining)
print('Validation Log Loss: ', loglossValidation)

loglossLightGBM = log_loss(y_validation, predictionsLightGBM)
print('LightGBM Gradient Boosting Log Loss: ', loglossLightGBM)

下面的代码显示了这种仅监督学习解决方案的训练和验证 log loss:

Training Log Loss: 0.0018646953029132292
Validation Log Loss: 0.19124276982588717

下面的代码显示了这种仅监督学习图像分类解决方案的总体准确性:

predictionsLightGBM_firm = np.argmax(np.array(predictionsLightGBM), axis=1)
accuracyValidation_lightGBM = accuracy_score(np.array(y_validation), \
                                            predictionsLightGBM_firm)
print("Supervised-Only Accuracy: ", accuracyValidation_lightGBM)
Supervised-Only Accuracy: 0.9439

无监督和监督解决方案

现在,我们不再训练五千个标记的 MNIST 图像,而是训练来自 DBN 生成的 10 万张图像:

# Prepare DBN-based DataFrames for LightGBM use
generatedImagesDF = pd.DataFrame(data=generatedImages,index=range(0,100000))
labelsDF = pd.DataFrame(data=labels,index=range(0,100000))

X_train_lgb = pd.DataFrame(data=generatedImagesDF,
                           index=generatedImagesDF.index)
X_validation_lgb = pd.DataFrame(data=finalOutput_DBN_validation,
                                index=X_validation.index)
# Train LightGBM
trainingScore = []
validationScore = []
predictionsDBN = pd.DataFrame(data=[],index=y_validation.index,
                              columns=predictionColumns)

lgb_train = lgb.Dataset(X_train_lgb, labels)
lgb_eval = lgb.Dataset(X_validation_lgb, y_validation, reference=lgb_train)
gbm = lgb.train(params_lightGB, lgb_train, num_boost_round=2000,
                   valid_sets=lgb_eval, early_stopping_rounds=200)

loglossTraining = log_loss(labelsDF, gbm.predict(X_train_lgb, \
                            num_iteration=gbm.best_iteration))
trainingScore.append(loglossTraining)

predictionsDBN.loc[X_validation.index,predictionColumns] = \
    gbm.predict(X_validation_lgb, num_iteration=gbm.best_iteration)
loglossValidation = log_loss(y_validation,
    predictionsDBN.loc[X_validation.index,predictionColumns])
validationScore.append(loglossValidation)

print('Training Log Loss: ', loglossTraining)
print('Validation Log Loss: ', loglossValidation)

loglossDBN = log_loss(y_validation, predictionsDBN)
print('LightGBM Gradient Boosting Log Loss: ', loglossDBN)

下面的代码显示了这种无监督增强图像分类解决方案的 log loss:

Training Log Loss: 0.004145635328203315
Validation Log Loss: 0.16377638170016542

下面的代码显示了这种无监督增强图像分类解决方案的总体准确性:

DBN-Based Solution Accuracy: 0.9525

正如您所看到的,这个解决方案提高了近一个百分点,这是相当可观的。

结论

在第十章,我们介绍了第一类生成模型——限制玻尔兹曼机。在本章中,我们基于这一概念介绍了更先进的生成模型,称为深度信念网络,它由多个堆叠的 RBM 组成。

我们展示了深度玻尔兹曼机(DBNs)的工作原理——在纯无监督的情况下,DBN 学习数据的潜在结构,并利用其学习生成新的合成数据。根据新合成数据与原始数据的比较,DBN 改善其生成能力,以至于合成数据越来越像原始数据。我们还展示了 DBNs 生成的合成数据如何补充现有的标记数据集,通过增加整体训练集的大小来提高监督学习模型的性能。

我们开发的半监督解决方案利用了 DBNs(无监督学习)和梯度提升(监督学习),在我们所面对的 MNIST 图像分类问题中,其表现优于纯监督解决方案。

在第十二章,我们介绍了无监督学习(特别是生成建模)中的最新进展之一,即生成对抗网络。

第十二章:生成对抗网络

我们已经探索了两种生成模型:RBM 和 DBN。在本章中,我们将探讨生成对抗网络(GANs),这是无监督学习和生成建模中最新和最有前景的领域之一。

GANs,概念

GANs 是由 Ian Goodfellow 及其蒙特利尔大学的同行研究人员在 2014 年引入的。在 GANs 中,我们有两个神经网络。一个网络称为生成器,根据其已创建的模型生成数据,该模型是使用其作为输入接收到的真实数据样本创建的。另一个网络称为鉴别器,用于区分生成器创建的数据和来自真实分布的数据。

简单类比,生成器就像是伪造者,而鉴别器则是试图识别伪造品的警察。这两个网络处于零和博弈中。生成器试图欺骗鉴别器,使其认为合成数据来自真实分布,而鉴别器则试图揭露合成数据为假。

GANs 是无监督学习算法,因为即使没有标签,生成器也可以学习真实分布的基本结构。生成器通过使用比其训练的数据量明显较少的一些参数来学习基本结构——这是我们在前几章中多次探讨过的无监督学习的核心概念。这一约束迫使生成器有效地捕捉到真实数据分布的最显著方面。这类似于深度学习中发生的表示学习。生成器的每个隐藏层捕捉到数据的底层表示——从非常简单的开始——而后续层通过在简单前层基础上构建更复杂的表示来增强。

使用所有这些层次,生成器学习数据的基本结构,并尝试创建几乎与真实数据相同的合成数据。如果生成器捕捉到了真实数据的本质,那么合成数据看起来将会是真实的。

GANs 的威力

在第十一章中,我们探讨了利用无监督学习模型(如深度信念网络)生成的合成数据来提高监督学习模型性能的能力。像 DBNs 一样,GANs 在生成合成数据方面非常擅长。

如果目标是生成大量新的训练样本,以帮助补充现有的训练数据——例如,以提高图像识别任务的准确性——我们可以使用生成器创建大量合成数据,将新合成数据添加到原始训练数据中,然后在现在大得多的数据集上运行监督式机器学习模型。

GANs 在异常检测方面也表现出色。如果目标是识别异常,例如检测欺诈、黑客攻击或其他可疑行为,我们可以使用判别器对真实数据中的每个实例进行评分。判别器排名为“可能合成”的实例将是最异常的实例,也是最有可能代表恶意行为的实例。

深度卷积 GANs

在本章中,我们将返回到我们在前几章中使用过的 MNIST 数据集,并应用一种 GANs 版本来生成合成数据以补充现有的 MNIST 数据集。然后我们将应用一个监督学习模型来进行图像分类。这是半监督学习的又一版本。

注意

顺便说一句,现在你应该对半监督学习有了更深的理解。因为世界上大部分数据都没有标签,无监督学习自身有效地帮助标记数据的能力非常强大。作为半监督机器学习系统的一部分,无监督学习增强了迄今为止所有成功商业应用的监督学习的潜力。

即使在半监督系统的应用之外,无监督学习也有独立运用的潜力,因为它能够从没有任何标签的数据中学习,并且是 AI 领域中从狭义 AI 向更广义 AI 应用迈进的最有潜力的领域之一。

我们将使用的 GANs 版本称为深度卷积生成对抗网络(DCGANs),这是由 Alec Radford、Luke Metz 和 Soumith Chintala 于 2015 年底首次引入的¹。

DCGANs 是一种无监督学习的形式卷积神经网络(CNNs),在监督学习系统中用于计算机视觉和图像分类方面被广泛使用并取得了巨大成功。在深入研究 DCGANs 之前,让我们首先探讨 CNNs,特别是它们在监督学习系统中用于图像分类的方式。

卷积神经网络

与数值和文本数据相比,图像和视频的计算成本要高得多。例如,一个 4K Ultra HD 图像的尺寸总共为 4096 x 2160 x 3(26,542,080)。直接在这种分辨率的图像上训练神经网络将需要数千万个神经元,并且导致非常长的训练时间。

而不是直接在原始图像上构建神经网络,我们可以利用图像的某些属性,即像素与附近的像素相关联,但通常与远处的像素无关。

卷积(从中卷积神经网络得名)是将图像进行滤波处理以减小图像尺寸而不丢失像素之间关系的过程。²

在原始图像上,我们应用几个特定大小的滤波器,称为核大小,并以小步长移动这些滤波器,称为步幅,以得出新的减少像素输出。卷积后,我们通过逐个小区域获取减少像素输出中的像素的最大值来进一步减小表示的大小。这称为最大池化

我们多次执行这种卷积和最大池化,以降低图像的复杂性。然后,我们展平图像并使用正常的全连接层进行图像分类。

现在让我们构建一个 CNN,并在 MNIST 数据集上进行图像分类。首先,我们将加载必要的库:

'''Main'''
import numpy as np
import pandas as pd
import os, time, re
import pickle, gzip, datetime

'''Data Viz'''
import matplotlib.pyplot as plt
import seaborn as sns
color = sns.color_palette()
import matplotlib as mpl
from mpl_toolkits.axes_grid1 import Grid

%matplotlib inline

'''Data Prep and Model Evaluation'''
from sklearn import preprocessing as pp
from sklearn.model_selection import train_test_split
from sklearn.model_selection import StratifiedKFold
from sklearn.metrics import log_loss, accuracy_score
from sklearn.metrics import precision_recall_curve, average_precision_score
from sklearn.metrics import roc_curve, auc, roc_auc_score, mean_squared_error

'''Algos'''
import lightgbm as lgb

'''TensorFlow and Keras'''
import tensorflow as tf
import keras
from keras import backend as K
from keras.models import Sequential, Model
from keras.layers import Activation, Dense, Dropout, Flatten, Conv2D, MaxPool2D
from keras.layers import LeakyReLU, Reshape, UpSampling2D, Conv2DTranspose
from keras.layers import BatchNormalization, Input, Lambda
from keras.layers import Embedding, Flatten, dot
from keras import regularizers
from keras.losses import mse, binary_crossentropy
from IPython.display import SVG
from keras.utils.vis_utils import model_to_dot
from keras.optimizers import Adam, RMSprop
from tensorflow.examples.tutorials.mnist import input_data

接下来,我们将加载 MNIST 数据集,并将图像数据存储在 4D 张量中,因为 Keras 需要图像数据以这种格式。我们还将使用 Keras 中的to_categorical函数从标签创建独热向量。

为了以后使用,我们还将从数据中创建 Pandas DataFrames。让我们再次使用本书早期的view_digit函数来查看这些图像:

# Load the datasets
current_path = os.getcwd()
file = '\\datasets\\mnist_data\\mnist.pkl.gz'
f = gzip.open(current_path+file, 'rb')
train_set, validation_set, test_set = pickle.load(f, encoding='latin1')
f.close()

X_train, y_train = train_set[0], train_set[1]
X_validation, y_validation = validation_set[0], validation_set[1]
X_test, y_test = test_set[0], test_set[1]

X_train_keras = X_train.reshape(50000,28,28,1)
X_validation_keras = X_validation.reshape(10000,28,28,1)
X_test_keras = X_test.reshape(10000,28,28,1)

y_train_keras = to_categorical(y_train)
y_validation_keras = to_categorical(y_validation)
y_test_keras = to_categorical(y_test)

# Create Pandas DataFrames from the datasets
train_index = range(0,len(X_train))
validation_index = range(len(X_train),len(X_train)+len(X_validation))
test_index = range(len(X_train)+len(X_validation),len(X_train)+ \
                   len(X_validation)+len(X_test))

X_train = pd.DataFrame(data=X_train,index=train_index)
y_train = pd.Series(data=y_train,index=train_index)

X_validation = pd.DataFrame(data=X_validation,index=validation_index)
y_validation = pd.Series(data=y_validation,index=validation_index)

X_test = pd.DataFrame(data=X_test,index=test_index)
y_test = pd.Series(data=y_test,index=test_index)

def view_digit(X, y, example):
    label = y.loc[example]
    image = X.loc[example,:].values.reshape([28,28])
    plt.title('Example: %d Label: %d' % (example, label))
    plt.imshow(image, cmap=plt.get_cmap('gray'))
    plt.show()

现在让我们构建 CNN。

我们将在 Keras 中调用Sequential()开始模型创建。然后,我们将添加两个卷积层,每个层有 32 个大小为 5 x 5 的过滤器,默认步幅为 1,并使用 ReLU 激活函数。然后,我们使用 2 x 2 的池化窗口和 1 的步幅进行最大池化。我们还执行 dropout,你可能记得这是一种正则化形式,用于减少神经网络的过拟合。具体来说,我们将丢弃输入单元的 25%。

在下一阶段,我们再次添加两个卷积层,这次使用 64 个大小为 3 x 3 的过滤器。然后,我们使用 2 x 2 的池化窗口和 2 的步幅进行最大池化。接着,我们添加一个 dropout 层,dropout 比例为 25%。

最后,我们将图像展平,添加一个具有 256 个隐藏单元的常规神经网络,使用 50%的 dropout 比例进行 dropout,并使用softmax函数进行 10 类分类:

model = Sequential()

model.add(Conv2D(filters = 32, kernel_size = (5,5), padding = 'Same',
                 activation ='relu', input_shape = (28,28,1)))
model.add(Conv2D(filters = 32, kernel_size = (5,5), padding = 'Same',
                 activation ='relu'))
model.add(MaxPooling2D(pool_size=(2,2)))
model.add(Dropout(0.25))

model.add(Conv2D(filters = 64, kernel_size = (3,3), padding = 'Same',
                 activation ='relu'))
model.add(Conv2D(filters = 64, kernel_size = (3,3), padding = 'Same',
                 activation ='relu'))
model.add(MaxPooling2D(pool_size=(2,2), strides=(2,2)))
model.add(Dropout(0.25))

model.add(Flatten())
model.add(Dense(256, activation = "relu"))
model.add(Dropout(0.5))
model.add(Dense(10, activation = "softmax"))

对于这个 CNN 训练,我们将使用Adam 优化器并最小化交叉熵。我们还将将图像分类的准确性作为评估指标存储。

现在让我们对模型进行一百个 epochs 的训练,并在验证集上评估结果:

# Train CNN
model.compile(optimizer='adam',
              loss='categorical_crossentropy',
              metrics=['accuracy'])

model.fit(X_train_keras, y_train_keras,
          validation_data=(X_validation_keras, y_validation_keras), \
          epochs=100)

图 12-1 显示了训练一百个 epochs 后的准确性。

CNN 结果

图 12-1. CNN 结果

正如您所看到的,我们刚刚训练的 CNN 最终准确率达到了 99.55%,优于本书中迄今为止训练过的任何 MNIST 图像分类解决方案。

重新审视 DCGANs

现在让我们再次转向深度卷积生成对抗网络。我们将建立一个生成模型,生成与原始 MNIST 图像非常相似的合成 MNIST 图像。

要生成接近真实但合成的图像,我们需要训练一个生成器,从原始的 MNIST 图像生成新的图像,以及一个判别器,判断这些图像是否与原始图像相似(基本上执行一种“胡说测试”)。

这里还有另一种思考方式。原始的 MNIST 数据集代表了原始的数据分布。生成器从这个原始分布中学习,并基于所学内容生成新的图像,而判别器则试图确定新生成的图像是否与原始分布几乎无法区分。

对于生成器,我们将使用 Radford、Metz 和 Chintala 在 ICLR 2016 年会议上提出的架构,这是我们之前引用过的(见 图 12-2)。

DCGAN 生成器

图 12-2。DCGAN 生成器

生成器接受一个初始的 噪声向量,这里显示为 100 x 1 的噪声向量,表示为 z,然后将其投影和重塑成 1024 x 4 x 4 张量。这种 投影和重塑 行为是卷积的反向过程,被称为 转置卷积(或在某些情况下称为 反卷积)。在转置卷积中,卷积的原始过程被反转,将一个缩小的张量映射到一个较大的张量³。

在初始的转置卷积之后,生成器应用四个额外的反卷积层映射到最终的 64 x 3 x 3 张量。

这里是各个阶段:

100 x 1 → 1024 x 4 x 4 → 512 x 8 x 8 → 256 x 16 x 16 → 128 x 32 x 32 → 64 x 64 x 3

在设计 MNIST 数据集上的 DCGAN 时,我们将应用类似(但不完全相同)的架构。

DCGAN 的生成器

对于我们设计的 DCGAN,我们将利用 Rowel Atienza 的工作并在此基础上构建⁴。我们首先会创建一个名为 DCGAN 的类,用于构建生成器、判别器、判别器模型和对抗模型。

让我们从生成器开始。我们将为生成器设置几个参数,包括辍学率(默认值为 0.3)、张量的深度(默认值为 256)以及其他维度(默认值为 7 x 7)。我们还将使用批归一化,其默认动量值为 0.8。初始输入维度为一百,最终输出维度为 28 x 28 x 1。

请记住,辍学和批归一化都是正则化器,帮助我们设计的神经网络避免过拟合。

要构建生成器,我们从 Keras 中调用 Sequential() 函数。然后,我们通过调用 Dense() 函数添加一个全连接神经网络层。它的输入维度为 100,输出维度为 7 x 7 x 256。我们将执行批归一化,使用 ReLU 激活函数,并执行辍学:

def generator(self, depth=256, dim=7, dropout=0.3, momentum=0.8, \
              window=5, input_dim=100, output_depth=1):
    if self.G:
        return self.G
    self.G = Sequential()
    self.G.add(Dense(dim*dim*depth, input_dim=input_dim))
    self.G.add(BatchNormalization(momentum=momentum))
    self.G.add(Activation('relu'))
    self.G.add(Reshape((dim, dim, depth)))
    self.G.add(Dropout(dropout))

接下来,我们将进行 上采样转置卷积 三次。每次,我们将输出空间的深度从 256 逐渐减半至 128、64、32,并增加其他维度。我们将保持 5 x 5 的卷积窗口和默认的步幅为一。在每次转置卷积期间,我们将执行批归一化,并使用 ReLU 激活函数。

这是它的样子:

100 → 7 x 7 x 256 → 14 x 14 x 128 → 28 x 28 x 64 → 28 x 28 x 32 → 28 x 28 x 1

    self.G.add(UpSampling2D())
    self.G.add(Conv2DTranspose(int(depth/2), window, padding='same'))
    self.G.add(BatchNormalization(momentum=momentum))
    self.G.add(Activation('relu'))

    self.G.add(UpSampling2D())
    self.G.add(Conv2DTranspose(int(depth/4), window, padding='same'))
    self.G.add(BatchNormalization(momentum=momentum))
    self.G.add(Activation('relu'))

    self.G.add(Conv2DTranspose(int(depth/8), window, padding='same'))
    self.G.add(BatchNormalization(momentum=momentum))
    self.G.add(Activation('relu'))

最后,生成器将输出一个 28 x 28 的图像,与原始 MNIST 图像具有相同的尺寸:

    self.G.add(Conv2DTranspose(output_depth, window, padding='same'))
    self.G.add(Activation('sigmoid'))
    self.G.summary()
    return self.G

DCGAN 的鉴别器

对于鉴别器,我们将将默认的 dropout 百分比设置为 0.3,深度为 64,并将 LeakyReLU 函数的 alpha 设置为 0.3。⁵

首先,我们将加载一个 28 x 28 x 1 的图像,并使用 64 个通道、5 x 5 的滤波器和步幅为二进行卷积。我们将使用 LeakyReLU 作为激活函数,并执行 dropout。我们将继续这个过程三次,每次将输出空间的深度加倍,同时减少其他维度。对于每个卷积,我们将使用 LeakyReLU 激活函数和 dropout。

最后,我们将展平图像,并使用 Sigmoid 函数输出一个概率。这个概率表示鉴别器对输入图像判断为伪造的信心程度(0.0 表示伪造,1.0 表示真实)。

这是它的样子:

28 x 28 x 1 → 14 x 14 x 64 → 7 x 7 x 128 → 4 x 4 x 256 → 4 x 4 x 512 → 1

def discriminator(self, depth=64, dropout=0.3, alpha=0.3):
    if self.D:
        return self.D
    self.D = Sequential()
    input_shape = (self.img_rows, self.img_cols, self.channel)
    self.D.add(Conv2D(depth*1, 5, strides=2, input_shape=input_shape,
        padding='same'))
    self.D.add(LeakyReLU(alpha=alpha))
    self.D.add(Dropout(dropout))

    self.D.add(Conv2D(depth*2, 5, strides=2, padding='same'))
    self.D.add(LeakyReLU(alpha=alpha))
    self.D.add(Dropout(dropout))

    self.D.add(Conv2D(depth*4, 5, strides=2, padding='same'))
    self.D.add(LeakyReLU(alpha=alpha))
    self.D.add(Dropout(dropout))

    self.D.add(Conv2D(depth*8, 5, strides=1, padding='same'))
    self.D.add(LeakyReLU(alpha=alpha))
    self.D.add(Dropout(dropout))

    self.D.add(Flatten())
    self.D.add(Dense(1))
    self.D.add(Activation('sigmoid'))
    self.D.summary()
    return self.D

鉴别器和对抗模型

接下来,我们定义鉴别器模型(即检测伪造品的警察)和对抗模型(即从警察学习的伪造者)。对于对抗模型和鉴别器模型,我们将使用 RMSprop 优化器,将损失函数定义为二元交叉熵,并使用准确率作为我们的报告指标。

对于对抗模型,我们使用之前定义的生成器和鉴别器网络。对于鉴别器模型,我们仅使用鉴别器网络:

def discriminator_model(self):
    if self.DM:
        return self.DM
    optimizer = RMSprop(lr=0.0002, decay=6e-8)
    self.DM = Sequential()
    self.DM.add(self.discriminator())
    self.DM.compile(loss='binary_crossentropy', \
                    optimizer=optimizer, metrics=['accuracy'])
    return self.DM

def adversarial_model(self):
    if self.AM:
        return self.AM
    optimizer = RMSprop(lr=0.0001, decay=3e-8)
    self.AM = Sequential()
    self.AM.add(self.generator())
    self.AM.add(self.discriminator())
    self.AM.compile(loss='binary_crossentropy', \
                    optimizer=optimizer, metrics=['accuracy'])
    return self.AM

用于 MNIST 数据集的 DCGAN

现在让我们为 MNIST 数据集定义 DCGAN。首先,我们将为 28 x 28 x 1 的 MNIST 图像初始化 MNIST_DCGAN 类,并使用之前定义的生成器、鉴别器模型和对抗模型:

class MNIST_DCGAN(object):
    def __init__(self, x_train):
        self.img_rows = 28
        self.img_cols = 28
        self.channel = 1

        self.x_train = x_train

        self.DCGAN = DCGAN()
        self.discriminator =  self.DCGAN.discriminator_model()
        self.adversarial = self.DCGAN.adversarial_model()
        self.generator = self.DCGAN.generator()

train 函数将默认进行两千次训练周期,并使用批大小为 256。在这个函数中,我们将批量的图像输入到刚刚定义的 DCGAN 架构中。生成器将生成图像,鉴别器将判断图像是真实的还是假的。在这个对抗模型中,随着生成器和鉴别器的较量,合成图像变得越来越接近原始的 MNIST 图像:

def train(self, train_steps=2000, batch_size=256, save_interval=0):
    noise_input = None
    if save_interval>0:
        noise_input = np.random.uniform(-1.0, 1.0, size=[16, 100])
    for i in range(train_steps):
        images_train = self.x_train[np.random.randint(0,
            self.x_train.shape[0], size=batch_size), :, :, :]
        noise = np.random.uniform(-1.0, 1.0, size=[batch_size, 100])
        images_fake = self.generator.predict(noise)
        x = np.concatenate((images_train, images_fake))
        y = np.ones([2*batch_size, 1])
        y[batch_size:, :] = 0

        d_loss = self.discriminator.train_on_batch(x, y)

        y = np.ones([batch_size, 1])
        noise = np.random.uniform(-1.0, 1.0, size=[batch_size, 100])
        a_loss = self.adversarial.train_on_batch(noise, y)
        log_mesg = "%d: [D loss: %f, acc: %f]" % (i, d_loss[0], d_loss[1])
        log_mesg = "%s [A loss: %f, acc: %f]" % (log_mesg, a_loss[0], \
                                                  a_loss[1])
        print(log_mesg)
        if save_interval>0:
            if (i+1)%save_interval==0:
                self.plot_images(save2file=True, \
                    samples=noise_input.shape[0],\
                    noise=noise_input, step=(i+1))

我们也来定义一个函数来绘制由这个 DCGAN 模型生成的图像:

def plot_images(self, save2file=False, fake=True, samples=16, \
                noise=None, step=0):
    filename = 'mnist.png'
    if fake:
        if noise is None:
            noise = np.random.uniform(-1.0, 1.0, size=[samples, 100])
        else:
            filename = "mnist_%d.png" % step
        images = self.generator.predict(noise)
    else:
        i = np.random.randint(0, self.x_train.shape[0], samples)
        images = self.x_train[i, :, :, :]

    plt.figure(figsize=(10,10))
    for i in range(images.shape[0]):
        plt.subplot(4, 4, i+1)
        image = images[i, :, :, :]
        image = np.reshape(image, [self.img_rows, self.img_cols])
        plt.imshow(image, cmap='gray')
        plt.axis('off')
    plt.tight_layout()
    if save2file:
        plt.savefig(filename)
        plt.close('all')
    else:
        plt.show()

MNIST DCGAN 的实际应用

现在我们已经定义了MNIST_DCGAN调用,让我们调用它并开始训练过程。我们将使用 256 的批次大小训练 10,000 个 epochs:

# Initialize MNIST_DCGAN and train
mnist_dcgan = MNIST_DCGAN(X_train_keras)
timer = ElapsedTimer()
mnist_dcgan.train(train_steps=10000, batch_size=256, save_interval=500)

下面的代码显示了判别器和对抗模型的损失和准确率:

0:  [D loss: 0.692640, acc: 0.527344] [A loss: 1.297974, acc: 0.000000]
1:  [D loss: 0.651119, acc: 0.500000] [A loss: 0.920461, acc: 0.000000]
2:  [D loss: 0.735192, acc: 0.500000] [A loss: 1.289153, acc: 0.000000]
3:  [D loss: 0.556142, acc: 0.947266] [A loss: 1.218020, acc: 0.000000]
4:  [D loss: 0.492492, acc: 0.994141] [A loss: 1.306247, acc: 0.000000]
5:  [D loss: 0.491894, acc: 0.916016] [A loss: 1.722399, acc: 0.000000]
6:  [D loss: 0.607124, acc: 0.527344] [A loss: 1.698651, acc: 0.000000]
7:  [D loss: 0.578594, acc: 0.921875] [A loss: 1.042844, acc: 0.000000]
8:  [D loss: 0.509973, acc: 0.587891] [A loss: 1.957741, acc: 0.000000]
9:  [D loss: 0.538314, acc: 0.896484] [A loss: 1.133667, acc: 0.000000]
10: [D loss: 0.510218, acc: 0.572266] [A loss: 1.855000, acc: 0.000000]
11: [D loss: 0.501239, acc: 0.923828] [A loss: 1.098140, acc: 0.000000]
12: [D loss: 0.509211, acc: 0.519531] [A loss: 1.911793, acc: 0.000000]
13: [D loss: 0.482305, acc: 0.923828] [A loss: 1.187290, acc: 0.000000]
14: [D loss: 0.395886, acc: 0.900391] [A loss: 1.465053, acc: 0.000000]
15: [D loss: 0.346876, acc: 0.992188] [A loss: 1.443823, acc: 0.000000]

判别器的初始损失波动很大,但始终保持在 0.50 以上。换句话说,判别器最初非常擅长捕捉生成器生成的低质量赝品。随着生成器变得越来越擅长创建赝品,判别器开始困难;其准确率接近 0.50:

9985: [D loss: 0.696480, acc: 0.521484] [A loss: 0.955954, acc: 0.125000]
9986: [D loss: 0.716583, acc: 0.472656] [A loss: 0.761385, acc: 0.363281]
9987: [D loss: 0.710941, acc: 0.533203] [A loss: 0.981265, acc: 0.074219]
9988: [D loss: 0.703731, acc: 0.515625] [A loss: 0.679451, acc: 0.558594]
9989: [D loss: 0.722460, acc: 0.492188] [A loss: 0.899768, acc: 0.125000]
9990: [D loss: 0.691914, acc: 0.539062] [A loss: 0.726867, acc: 0.464844]
9991: [D loss: 0.716197, acc: 0.500000] [A loss: 0.932500, acc: 0.144531]
9992: [D loss: 0.689704, acc: 0.548828] [A loss: 0.734389, acc: 0.414062]
9993: [D loss: 0.714405, acc: 0.517578] [A loss: 0.850408, acc: 0.218750]
9994: [D loss: 0.690414, acc: 0.550781] [A loss: 0.766320, acc: 0.355469]
9995: [D loss: 0.709792, acc: 0.511719] [A loss: 0.960070, acc: 0.105469]
9996: [D loss: 0.695851, acc: 0.500000] [A loss: 0.774395, acc: 0.324219]
9997: [D loss: 0.712254, acc: 0.521484] [A loss: 0.853828, acc: 0.183594]
9998: [D loss: 0.702689, acc: 0.529297] [A loss: 0.802785, acc: 0.308594]
9999: [D loss: 0.698032, acc: 0.517578] [A loss: 0.810278, acc: 0.304688]

合成图像生成

现在 MNIST DCGAN 已经训练完毕,让我们使用它生成一些合成图像的样本(图 12-3)。

MNIST DCGAN 生成的合成图像

图 12-3. MNIST DCGAN 生成的合成图像

这些合成图像——虽然不能完全与真实的 MNIST 数据集区分开来——与真实数字非常相似。随着训练时间的增加,MNIST DCGAN 应该能够生成更接近真实 MNIST 数据集的合成图像,并可用于扩充该数据集的规模。

虽然我们的解决方案相对不错,但有许多方法可以使 MNIST DCGAN 表现更好。论文"Improved Techniques for Training GANs"和其附带的代码深入探讨了改进 GAN 性能的更高级方法。

结论

在本章中,我们探讨了深度卷积生成对抗网络(DCGAN),这是一种专门用于图像和计算机视觉数据集的生成对抗网络形式。

GAN 是一种具有两个神经网络的生成模型,它们被锁定在一个零和博弈中。其中一个网络是生成器(即伪造者),从真实数据中生成合成数据,而另一个网络是判别器(即警察),负责判断伪造品是真实还是假的。⁶ 生成器从判别器中学习的这种零和博弈导致一个总体上生成相当逼真的合成数据的生成模型,并且通常随着训练时间的增加而变得更好。

GAN(生成对抗网络)相对较新 —— 首次由 Ian Goodfellow 等人于 2014 年提出。⁷ GAN 目前主要用于异常检测和生成合成数据,但在不久的将来可能有许多其他应用。机器学习社区仅仅开始探索其可能性,如果你决定在应用的机器学习系统中使用 GAN,一定要做好大量实验的准备。⁸

在第十三章 中,我们将通过探索时间聚类来结束本书的这一部分内容,这是一种用于处理时间序列数据的无监督学习方法。

¹ 想深入了解 DCGANs,可以参考该主题的官方论文

² 想了解更多关于卷积层的内容,可以阅读《深度学习中不同类型卷积的介绍》一文

³ 想了解更多关于卷积层的内容,可以查看《深度学习中不同类型卷积的介绍》一文,这篇文章也在本章中有提及。

⁴ 想获取原始代码基础,请访问Rowel Atienza 的 GitHub 页面

LeakyReLUhttps://keras.io/layers/advanced-activations/)是一种先进的激活函数,类似于普通的 ReLU,但在单元不活跃时允许一个小的梯度。它正在成为图像机器学习问题中首选的激活函数。

⁶ 想获取更多信息,请查阅OpenAI 博客上的生成模型文章

⁷ 想了解更多相关内容,请参阅这篇重要的论文

⁸ 阅读这篇关于如何优化 GANs提升性能的文章,可以了解一些技巧和窍门。

第十三章:时间序列聚类

到目前为止,在本书中,我们主要处理横断面数据,即我们在单个时间点上观察实体的数据。这包括信用卡数据集,记录了两天内的交易,以及 MNIST 数据集,其中包含数字图像。对于这些数据集,我们应用了无监督学习来学习数据的潜在结构,并将相似的交易和图像分组在一起,而不使用任何标签。

无监督学习对处理时间序列数据也非常有价值,其中我们在不同时间间隔内观察单个实体。我们需要开发一种能够跨时间学习数据的潜在结构的解决方案,而不仅仅是针对特定时间点。如果我们开发了这样的解决方案,我们就可以识别出类似的时间序列模式并将它们分组在一起。

这在金融、医学、机器人学、天文学、生物学、气象学等领域具有非常大的影响,因为这些领域的专业人员花费大量时间分析数据,根据当前事件与过去事件的相似性来分类当前事件。通过将当前事件与类似的过去事件分组在一起,这些专业人员能够更自信地决定采取正确的行动。

在本章中,我们将根据模式相似性对时间序列数据进行聚类。时间序列数据的聚类是一种纯无监督方法,不需要对数据进行训练注释,尽管对于验证结果,像所有其他无监督学习实验一样,需要注释数据。

注记

还有一种数据组合,结合了横断面和时间序列数据。这被称为面板纵向数据。

ECG 数据

为了使时间序列聚类问题更具体化,让我们引入一个特定的现实世界问题。想象一下,我们在医疗保健领域工作,需要分析心电图(EKG/ECG)读数。ECG 机器使用放置在皮肤上的电极,在一段时间内记录心脏的电活动。ECG 在大约 10 秒钟内测量活动,并记录的指标有助于检测任何心脏问题。

大多数 ECG 读数记录的是正常的心跳活动,但异常读数是医疗专业人员必须识别的,以在任何不良心脏事件(如心脏骤停)发生之前采取预防性措施。ECG 产生带有峰和谷的折线图,因此将读数分类为正常或异常是一项简单的模式识别任务,非常适合机器学习。

现实世界的 ECG 读数并不是如此清晰显示,这使得将图像分类到这些不同桶中变得困难且容易出错。

例如,波的振幅变化(中心线到峰值或谷值的高度)、周期(从一个峰值到下一个的距离)、相位移(水平移动)和垂直移都是任何机器驱动分类系统的挑战。

时间序列聚类方法

任何时间序列聚类方法都需要处理这些类型的扭曲。正如您可能记得的那样,聚类依赖于距离度量,以确定数据在空间中与其他数据的接近程度,从而将相似的数据组合成不同且同质的簇。

时间序列数据的聚类工作方式类似,但我们需要一个距离度量,该度量是尺度和位移不变的,以便将类似的时间序列数据组合在一起,而不考虑幅度、周期、相位移和垂直移的微小差异。

k-Shape

满足这一标准的时间序列聚类的先进方法之一是k-shape,它由 John Paparrizos 和 Luis Gravano 于 2015 年首次在 ACM SIGMOD 上介绍¹。

k-shape 使用一种距离度量,该度量对缩放和位移不变,以保持比较时间序列序列的形状。具体来说,k-shape 使用标准化的交叉相关来计算簇质心,并在每次迭代中更新时间序列分配到这些簇。

除了对缩放和位移不变之外,k-shape 还是领域无关且可扩展的,需要最少的参数调整。其迭代改进过程在序列数量上线性扩展。这些特性使其成为当今最强大的时间序列聚类算法之一。

到这一点,应该清楚k-shape 的运行方式与k-means 类似:两种算法都使用迭代方法根据数据与最近群组的质心之间的距离来分配数据。关键的区别在于k-shape 计算距离的方式——它使用基于形状的距离,依赖于交叉相关性。

使用k-shape对 ECGFiveDays 进行时间序列聚类

让我们使用k-shape 构建一个时间序列聚类模型。

在本章中,我们将依赖于 UCR 时间序列收集的数据。由于文件大小超过一百兆字节,在 GitHub 上无法访问。您需要从UCR 时间序列网站下载这些文件。

这是最大的公共类标记时间序列数据集收藏,总计有 85 个。这些数据集来自多个领域,因此我们可以测试我们的解决方案在不同领域的表现。每个时间序列只属于一个类别,因此我们也有标签来验证时间序列聚类的结果。

数据准备

让我们开始加载必要的库:

'''Main'''
import numpy as np
import pandas as pd
import os, time, re
import pickle, gzip, datetime
from os import listdir, walk
from os.path import isfile, join

'''Data Viz'''
import matplotlib.pyplot as plt
import seaborn as sns
color = sns.color_palette()
import matplotlib as mpl
from mpl_toolkits.axes_grid1 import Grid

%matplotlib inline

'''Data Prep and Model Evaluation'''
from sklearn import preprocessing as pp
from sklearn.model_selection import train_test_split
from sklearn.model_selection import StratifiedKFold
from sklearn.metrics import log_loss, accuracy_score
from sklearn.metrics import precision_recall_curve, average_precision_score
from sklearn.metrics import roc_curve, auc, roc_auc_score, mean_squared_error
from keras.utils import to_categorical
from sklearn.metrics import adjusted_rand_score
import random

'''Algos'''
from kshape.core import kshape, zscore
import tslearn
from tslearn.utils import to_time_series_dataset
from tslearn.clustering import KShape, TimeSeriesScalerMeanVariance
from tslearn.clustering import TimeSeriesKMeans
import hdbscan

'''TensorFlow and Keras'''
import tensorflow as tf
import keras
from keras import backend as K
from keras.models import Sequential, Model
from keras.layers import Activation, Dense, Dropout, Flatten, Conv2D, MaxPool2D
from keras.layers import LeakyReLU, Reshape, UpSampling2D, Conv2DTranspose
from keras.layers import BatchNormalization, Input, Lambda
from keras.layers import Embedding, Flatten, dot
from keras import regularizers
from keras.losses import mse, binary_crossentropy
from IPython.display import SVG
from keras.utils.vis_utils import model_to_dot
from keras.optimizers import Adam, RMSprop
from tensorflow.examples.tutorials.mnist import input_data

我们将使用 tslearn 包来访问基于 Python 的 k-shape 算法。tslearn 的框架与 Scikit-learn 类似,但专门用于处理时间序列数据。

接下来,让我们从 UCR 时间序列存档下载的 ECGFiveDays 数据集中加载训练和测试数据。此矩阵的第一列是类别标签,其余列是时间序列数据的值。我们将数据存储为 X_trainy_trainX_testy_test

# Load the datasets
current_path = os.getcwd()
file = '\\datasets\\ucr_time_series_data\\'
data_train = np.loadtxt(current_path+file+
                        "ECGFiveDays/ECGFiveDays_TRAIN",
                        delimiter=",")
X_train = to_time_series_dataset(data_train[:, 1:])
y_train = data_train[:, 0].astype(np.int)

data_test = np.loadtxt(current_path+file+
                       "ECGFiveDays/ECGFiveDays_TEST",
                       delimiter=",")
X_test = to_time_series_dataset(data_test[:, 1:])
y_test = data_test[:, 0].astype(np.int)

下面的代码显示了时间序列的数量、唯一类别的数量以及每个时间序列的长度:

# Basic summary statistics
print("Number of time series:", len(data_train))
print("Number of unique classes:", len(np.unique(data_train[:,0])))
print("Time series length:", len(data_train[0,1:]))
Number of time series: 23
Number of unique classes: 2
Time series length: 136

有 23 个时间序列和 2 个唯一类别,每个时间序列长度为 136. Figure 13-1 显示了每个类别的几个示例;现在我们知道这些 ECG 读数是什么样的了:

# Examples of Class 1.0
for i in range(0,10):
    if data_train[i,0]==1.0:
        print("Plot ",i," Class ",data_train[i,0])
        plt.plot(data_train[i])
        plt.show()

ECG Five Days Class 1.0 - First Two Examples

图 13-1. ECGFiveDays 类 1.0—第一组示例

ECG Five Days Class 1.0 - Second Two Examples

图 13-2. ECGFiveDays 类 1.0—第二组示例

这里是绘制 Class 2.0 结果的代码:

# Examples of Class 2.0
for i in range(0,10):
    if data_train[i,0]==2.0:
        print("Plot ",i," Class ",data_train[i,0])
        plt.plot(data_train[i])
        plt.show()

ECG Five Days Class 2.0 - First Two Examples

图 13-3. ECGFiveDays 类 2.0—第一组示例

ECG Five Days Class 2.0 - Second Two Examples

图 13-4. ECGFiveDays 类 2.0—第二组示例

对于未经训练的肉眼来说,来自类 1.0 和类 2.0 的示例看起来无法区分,但这些观察结果已由领域专家注释。这些图表因噪声和失真而复杂。振幅、周期、相移和垂直移位的差异也使得分类成为一项挑战。

让我们准备 k-shape 算法的数据。我们将对数据进行归一化,使其均值为零,标准差为一:

# Prepare the data - Scale
X_train = TimeSeriesScalerMeanVariance(mu=0., std=1.).fit_transform(X_train)
X_test = TimeSeriesScalerMeanVariance(mu=0., std=1.).fit_transform(X_test)

训练和评估

接下来,我们将调用 k-shape 算法,并将集群数量设置为 2,最大迭代次数设置为一百,训练轮数设置为一百:²

# Train using k-Shape
ks = KShape(n_clusters=2, max_iter=100, n_init=100,verbose=0)
ks.fit(X_train)

为了衡量时间序列聚类的好坏,我们将使用 adjusted Rand index,这是一种校正后的元素随机分组机制相似度测量方法。这与准确度测量相关。³

直观地,兰德指数衡量了预测聚类和真实聚类分配之间的一致性。如果模型的调整兰德指数接近 0.0,则表示纯随机分配聚类;如果模型的调整兰德指数接近 1.0,则表示预测聚类完全与真实聚类匹配。

我们将使用 Scikit-learn 中的调整兰德指数实现,称为 adjusted_rand_score

让我们生成聚类预测,然后计算调整兰德指数:

# Make predictions and calculate adjusted Rand index
preds = ks.predict(X_train)
ars = adjusted_rand_score(data_train[:,0],preds)
print("Adjusted Rand Index:", ars)

根据此次运行,调整兰德指数为 0.668. 如果您多次进行此训练和预测,您会注意到调整兰德指数会有所变化,但始终保持在 0.0 以上:

Adjusted Rand Index: 0.668041237113402

让我们在测试集上进行预测,并计算其调整兰德指数:

# Make predictions on test set and calculate adjusted Rand index
preds_test = ks.predict(X_test)
ars = adjusted_rand_score(data_test[:,0],preds_test)
print("Adjusted Rand Index on Test Set:", ars)

测试集上的调整兰德指数明显较低,勉强超过 0. 聚类预测几乎是随机分配——时间序列基于相似性进行分组,但成功率很低:

Adjusted Rand Index on Test Set: 0.0006332050676187496

如果我们有一个更大的训练集来训练基于k-shape 的时间序列聚类模型,我们预计在测试集上会有更好的表现。

使用 ECG5000 进行时间序列聚类

不使用仅有 23 个观测值的ECGFiveDays数据集,而是使用一个更大的心电图读数数据集。ECG5000数据集(也可以在 UCR 时间序列存档中找到),总共有五千个心电图读数(即时间序列),分布在训练集和测试集中。

数据准备

我们将加载数据集并进行自定义的训练集和测试集划分,其中 80%的五千个读数在自定义训练集中,剩余的 20%在自定义测试集中。有了这个更大的训练集,我们应该能够开发出一个时间序列聚类模型,其在训练集和测试集上都有更好的性能:

# Load the datasets
current_path = os.getcwd()
file = '\\datasets\\ucr_time_series_data\\'
data_train = np.loadtxt(current_path+file+
                        "ECG5000/ECG5000_TRAIN",
                        delimiter=",")

data_test = np.loadtxt(current_path+file+
                       "ECG5000/ECG5000_TEST",
                       delimiter=",")

data_joined = np.concatenate((data_train,data_test),axis=0)
data_train, data_test = train_test_split(data_joined,
                                    test_size=0.20, random_state=2019)

X_train = to_time_series_dataset(data_train[:, 1:])
y_train = data_train[:, 0].astype(np.int)
X_test = to_time_series_dataset(data_test[:, 1:])
y_test = data_test[:, 0].astype(np.int)

让我们探索一下这个数据集:

# Summary statistics
print("Number of time series:", len(data_train))
print("Number of unique classes:", len(np.unique(data_train[:,0])))
print("Time series length:", len(data_train[0,1:]))

下面的代码显示了基本的摘要统计信息。在训练集中有四千个读数,分为五个不同的类别,每个时间序列的长度为 140:

Number of time series: 4000
Number of unique classes: 5
Time series length: 140

让我们也考虑一下这些类别的读数数量。

# Calculate number of readings per class
print("Number of time series in class 1.0:",
      len(data_train[data_train[:,0]==1.0]))
print("Number of time series in class 2.0:",
      len(data_train[data_train[:,0]==2.0]))
print("Number of time series in class 3.0:",
      len(data_train[data_train[:,0]==3.0]))
print("Number of time series in class 4.0:",
      len(data_train[data_train[:,0]==4.0]))
print("Number of time series in class 5.0:",
      len(data_train[data_train[:,0]==5.0]))

分布显示在图 13-5 中。大多数读数属于第一类,其次是第二类。第三、第四和第五类的读数显著较少。

让我们取每个类别的平均时间序列读数,以更好地了解各类别的外观。

# Display readings from each class
for j in np.unique(data_train[:,0]):
    dataPlot = data_train[data_train[:,0]==j]
    cnt = len(dataPlot)
    dataPlot = dataPlot[:,1:].mean(axis=0)
    print(" Class ",j," Count ",cnt)
    plt.plot(dataPlot)
    plt.show()

第一类(图 13-5)有一个明显的低谷,随后是一个尖锐的峰值和稳定期。这是最常见的读数类型。

ECG 5000 第一类 1.0

图 13-5. ECG5000 第一类 1.0

第二类(图 13-6)有一个明显的低谷,随后恢复,然后是一个更加尖锐和更低的低谷,并带有部分恢复。这是第二常见的读数类型。

ECG 5000 第二类 2.0

图 13-6. ECG5000 第二类 2.0

第三类(图 13-7)有一个明显的低谷,随后恢复,然后是一个更加尖锐和更低的低谷,并没有恢复。数据集中有一些这样的例子。

ECG 5000 第三类 3.0

图 13-7. ECG5000 第三类 3.0

第四类(图 13-8)有一个明显的低谷,随后恢复,然后是一个较浅的低谷和稳定。数据集中有一些这样的例子。

ECG 5000 Class 4.0

图 13-8. ECG5000 类别 4.0

第 5 类(图 13-9)有一个明显的低谷,然后是不均匀的恢复,一个峰值,然后是不稳定的下降到一个浅低谷。数据集中这样的例子很少。

ECG 5000 Class 5.0

图 13-9. ECG5000 类别 5.0

训练和评估

如前所述,让我们将数据归一化,使其均值为零,标准差为一。然后,我们将使用k-shape 算法,这次将聚类数设为五。其余保持不变:

# Prepare data - Scale
X_train = TimeSeriesScalerMeanVariance(mu=0., std=1.).fit_transform(X_train)
X_test = TimeSeriesScalerMeanVariance(mu=0., std=1.).fit_transform(X_test)

# Train using k-Shape
ks = KShape(n_clusters=5, max_iter=100, n_init=10,verbose=1,random_state=2019)
ks.fit(X_train)

让我们评估训练集上的结果:

# Predict on train set and calculate adjusted Rand index
preds = ks.predict(X_train)
ars = adjusted_rand_score(data_train[:,0],preds)
print("Adjusted Rand Index on Training Set:", ars)

以下代码显示了训练集上的调整兰德指数。这一指数在 0.75 处显著增强:

Adjusted Rand Index on Training Set: 0.7499312374127193

让我们也在测试集上评估结果:

# Predict on test set and calculate adjusted Rand index
preds_test = ks.predict(X_test)
ars = adjusted_rand_score(data_test[:,0],preds_test)
print("Adjusted Rand Index on Test Set:", ars)

测试集上的调整兰德指数也高得多,为 0.72:

Adjusted Rand Index on Test Set: 0.7172302400677499

将训练集增加到四千个时间序列(从 23 个),我们得到了一个表现更好的时间序列聚类模型。

让我们进一步探索预测聚类,以查看它们的同质性。对于每个预测聚类,我们将评估真实标签的分布。如果聚类定义明确且同质,每个聚类中的大多数读数应具有相同的真实标签:

# Evaluate goodness of the clusters
preds_test = preds_test.reshape(1000,1)
preds_test = np.hstack((preds_test,data_test[:,0].reshape(1000,1)))
preds_test = pd.DataFrame(data=preds_test)
preds_test = preds_test.rename(columns={0: 'prediction', 1: 'actual'})

counter = 0
for i in np.sort(preds_test.prediction.unique()):
    print("Predicted Cluster ", i)
    print(preds_test.actual[preds_test.prediction==i].value_counts())
    print()
    cnt = preds_test.actual[preds_test.prediction==i] \
                        .value_counts().iloc[1:].sum()
    counter = counter + cnt
print("Count of Non-Primary Points: ", counter)

以下代码显示了聚类的同质性:

ECG 5000 k-shape predicted cluster analysis

Predicted Cluster 0.0
    2.0   29
    4.0   2
    1.0   2
    3.0   2
    5.0   1
    Name: actual, dtype: int64

Predicted Cluster 1.0
    2.0   270
    4.0   14
    3.0   8
    1.0   2
    5.0   1
    Name: actual, dtype: int64

Predicted Cluster 2.0
    1.0   553
    4.0   16
    2.0   9
    3.0   7
    Name: actual, dtype: int64

Predicted Cluster 3.0
    2.0   35
    1.0   5
    4.0   5
    5.0   3
    3.0   3
    Name: actual, dtype: int64

Predicted Cluster 4.0
    1.0   30
    4.0   1
    3.0   1
    2.0   1
    Name: actual, dtype: int64

Count of Non-Primary Points: 83

每个预测聚类中的大多数读数属于同一个真实标签类。这突显了k-shape 衍生聚类的定义明确和同质性。

使用k-means 对 ECG5000 进行时间序列聚类

为了完整起见,让我们将k-shape 与k-means 的结果进行比较。我们将使用tslearn库进行训练,并像之前一样使用调整兰德指数进行评估。

我们将聚类数设为五,单次运行的最大迭代次数为一百,独立运行次数为一百,距离度量为欧氏距离,随机状态为 2019:

# Train using Time Series k-Means
km = TimeSeriesKMeans(n_clusters=5, max_iter=100, n_init=100, \
                      metric="euclidean", verbose=1, random_state=2019)
km.fit(X_train)

# Predict on training set and evaluate using adjusted Rand index
preds = km.predict(X_train)
ars = adjusted_rand_score(data_train[:,0],preds)
print("Adjusted Rand Index on Training Set:", ars)

# Predict on test set and evaluate using adjusted Rand index
preds_test = km.predict(X_test)
ars = adjusted_rand_score(data_test[:,0],preds_test)
print("Adjusted Rand Index on Test Set:", ars)

TimeSeriesKMean算法甚至比使用欧氏距离度量的k-shape 算法更快。但结果并不如k-shape 那么好:

Adjusted Rand Index of Time Series k-Means on Training Set: 0.5063464656715959

训练集上的调整兰德指数为 0.506:

Adjusted Rand Index of Time Series k-Means on Test Set: 0.4864981997585834

测试集上的调整兰德指数为 0.486。

使用层次 DBSCAN 对 ECG5000 进行时间序列聚类

最后,让我们应用层次 DBSCAN,这是本书前面探讨过的方法,并评估其性能。

我们将使用默认参数运行HDBSCAN,并使用调整兰德指数评估其性能:

# Train model and evaluate on training set
min_cluster_size = 5
min_samples = None
alpha = 1.0
cluster_selection_method = 'eom'
prediction_data = True

hdb = hdbscan.HDBSCAN(min_cluster_size=min_cluster_size, \
                      min_samples=min_samples, alpha=alpha, \
                      cluster_selection_method=cluster_selection_method, \
                      prediction_data=prediction_data)

preds = hdb.fit_predict(X_train.reshape(4000,140))
ars = adjusted_rand_score(data_train[:,0],preds)
print("Adjusted Rand Index on Training Set:", ars)

训练集上的调整兰德指数令人印象深刻,为 0.769:

Adjusted Rand Index on Training Set using HDBSCAN: 0.7689563655060421

训练集上的调整兰德指数令人印象深刻,为 0.769。

让我们在测试集上评估:

# Predict on test set and evaluate
preds_test = hdbscan.prediction.approximate_predict( \
                hdb, X_test.reshape(1000,140))
ars = adjusted_rand_score(data_test[:,0],preds_test[0])
print("Adjusted Rand Index on Test Set:", ars)

训练集上的调整兰德指数同样令人印象深刻,为 0.720:

Adjusted Rand Index on Test Set using HDBSCAN: 0.7200816245545564

比较时间序列聚类算法

HDBSCAN 和 k-shape 在 ECG5000 数据集上表现相似,而 k-means 的表现较差。然而,仅通过评估这三种聚类算法在单个时间序列数据集上的表现,我们无法得出强有力的结论。

让我们运行一个更大的实验,看看这三种聚类算法在彼此之间的表现如何。

首先,我们将加载 UCR 时间序列分类文件夹中的所有目录和文件,以便在实验期间对它们进行迭代。总共有 85 个数据集:

# Load the datasets
current_path = os.getcwd()
file = '\\datasets\\ucr_time_series_data\\'

mypath = current_path + file
d = []
f = []
for (dirpath, dirnames, filenames) in walk(mypath):
    for i in dirnames:
        newpath = mypath+"\\"+i+"\\"
        onlyfiles = [f for f in listdir(newpath) if isfile(join(newpath, f))]
        f.extend(onlyfiles)
    d.extend(dirnames)
    break

接下来,让我们为三种聚类算法中的每一种重复使用代码,并使用我们刚刚准备的数据集列表来运行完整实验。我们将按数据集存储训练和测试的调整后兰德指数,并测量每种聚类算法完成 85 个数据集的整个实验所需的时间。

k-Shape 的完整运行

第一个实验使用了 k-shape。

# k-Shape Experiment
kShapeDF = pd.DataFrame(data=[],index=[v for v in d],
                        columns=["Train ARS","Test ARS"])

# Train and Evaluate k-Shape
class ElapsedTimer(object):
    def __init__(self):
        self.start_time = time.time()
    def elapsed(self,sec):
        if sec < 60:
            return str(sec) + " sec"
        elif sec < (60 * 60):
            return str(sec / 60) + " min"
        else:
            return str(sec / (60 * 60)) + " hr"
    def elapsed_time(self):
        print("Elapsed: %s " % self.elapsed(time.time() - self.start_time))
        return (time.time() - self.start_time)

timer = ElapsedTimer()
cnt = 0
for i in d:
    cnt += 1
    print("Dataset ", cnt)
    newpath = mypath+"\\"+i+"\\"
    onlyfiles = [f for f in listdir(newpath) if isfile(join(newpath, f))]
    j = onlyfiles[0]
    k = onlyfiles[1]
    data_train = np.loadtxt(newpath+j, delimiter=",")
    data_test = np.loadtxt(newpath+k, delimiter=",")

    data_joined = np.concatenate((data_train,data_test),axis=0)
    data_train, data_test = train_test_split(data_joined,
                                        test_size=0.20, random_state=2019)

    X_train = to_time_series_dataset(data_train[:, 1:])
    y_train = data_train[:, 0].astype(np.int)
    X_test = to_time_series_dataset(data_test[:, 1:])
    y_test = data_test[:, 0].astype(np.int)

    X_train = TimeSeriesScalerMeanVariance(mu=0., std=1.) \
                                .fit_transform(X_train)
    X_test = TimeSeriesScalerMeanVariance(mu=0., std=1.) \
                                .fit_transform(X_test)

    classes = len(np.unique(data_train[:,0]))
    ks = KShape(n_clusters=classes, max_iter=10, n_init=3,verbose=0)
    ks.fit(X_train)

    print(i)
    preds = ks.predict(X_train)
    ars = adjusted_rand_score(data_train[:,0],preds)
    print("Adjusted Rand Index on Training Set:", ars)
    kShapeDF.loc[i,"Train ARS"] = ars

    preds_test = ks.predict(X_test)
    ars = adjusted_rand_score(data_test[:,0],preds_test)
    print("Adjusted Rand Index on Test Set:", ars)
    kShapeDF.loc[i,"Test ARS"] = ars

kShapeTime = timer.elapsed_time()

k-shape 算法大约需要一个小时的运行时间。我们已经存储了调整后的兰德指数,并将用这些指数来比较 k-shape 和 k-means 以及 HDBSCAN 的表现。

注意

我们对 k-shape 的测量时间基于我们设置的实验超参数以及机器的本地硬件规格。不同的超参数和硬件规格可能导致实验时间显著不同。

k-Means 的完整运行

接下来是 k-means:

# k-Means Experiment - FULL RUN
# Create dataframe
kMeansDF = pd.DataFrame(data=[],index=[v for v in d], \
                        columns=["Train ARS","Test ARS"])

# Train and Evaluate k-Means
timer = ElapsedTimer()
cnt = 0
for i in d:
    cnt += 1
    print("Dataset ", cnt)
    newpath = mypath+"\\"+i+"\\"
    onlyfiles = [f for f in listdir(newpath) if isfile(join(newpath, f))]
    j = onlyfiles[0]
    k = onlyfiles[1]
    data_train = np.loadtxt(newpath+j, delimiter=",")
    data_test = np.loadtxt(newpath+k, delimiter=",")

    data_joined = np.concatenate((data_train,data_test),axis=0)
    data_train, data_test = train_test_split(data_joined, \
                                        test_size=0.20, random_state=2019)

    X_train = to_time_series_dataset(data_train[:, 1:])
    y_train = data_train[:, 0].astype(np.int)
    X_test = to_time_series_dataset(data_test[:, 1:])
    y_test = data_test[:, 0].astype(np.int)

    X_train = TimeSeriesScalerMeanVariance(mu=0., std=1.) \
                                    .fit_transform(X_train)
    X_test = TimeSeriesScalerMeanVariance(mu=0., std=1.) \
                                    .fit_transform(X_test)

    classes = len(np.unique(data_train[:,0]))
    km = TimeSeriesKMeans(n_clusters=5, max_iter=10, n_init=10, \
                          metric="euclidean", verbose=0, random_state=2019)
    km.fit(X_train)

    print(i)
    preds = km.predict(X_train)
    ars = adjusted_rand_score(data_train[:,0],preds)
    print("Adjusted Rand Index on Training Set:", ars)
    kMeansDF.loc[i,"Train ARS"] = ars

    preds_test = km.predict(X_test)
    ars = adjusted_rand_score(data_test[:,0],preds_test)
    print("Adjusted Rand Index on Test Set:", ars)
    kMeansDF.loc[i,"Test ARS"] = ars

kMeansTime = timer.elapsed_time()

k-means 在所有 85 个数据集上运行不到五分钟:

HDBSCAN 的完整运行

最后,我们有了 HBDSCAN:

# HDBSCAN Experiment - FULL RUN
# Create dataframe
hdbscanDF = pd.DataFrame(data=[],index=[v for v in d], \
                         columns=["Train ARS","Test ARS"])

# Train and Evaluate HDBSCAN
timer = ElapsedTimer()
cnt = 0
for i in d:
    cnt += 1
    print("Dataset ", cnt)
    newpath = mypath+"\\"+i+"\\"
    onlyfiles = [f for f in listdir(newpath) if isfile(join(newpath, f))]
    j = onlyfiles[0]
    k = onlyfiles[1]
    data_train = np.loadtxt(newpath+j, delimiter=",")
    data_test = np.loadtxt(newpath+k, delimiter=",")

    data_joined = np.concatenate((data_train,data_test),axis=0)
    data_train, data_test = train_test_split(data_joined, \
                                    test_size=0.20, random_state=2019)

    X_train = data_train[:, 1:]
    y_train = data_train[:, 0].astype(np.int)
    X_test = data_test[:, 1:]
    y_test = data_test[:, 0].astype(np.int)

    X_train = TimeSeriesScalerMeanVariance(mu=0., std=1.) \
                                    .fit_transform(X_train)
    X_test = TimeSeriesScalerMeanVariance(mu=0., std=1.)  \
                                    .fit_transform(X_test)

    classes = len(np.unique(data_train[:,0]))
    min_cluster_size = 5
    min_samples = None
    alpha = 1.0
    cluster_selection_method = 'eom'
    prediction_data = True

    hdb = hdbscan.HDBSCAN(min_cluster_size=min_cluster_size, \
                          min_samples=min_samples, alpha=alpha, \
                          cluster_selection_method= \
                              cluster_selection_method, \
                          prediction_data=prediction_data)

    print(i)
    preds = hdb.fit_predict(X_train.reshape(X_train.shape[0], \
                                            X_train.shape[1]))
    ars = adjusted_rand_score(data_train[:,0],preds)
    print("Adjusted Rand Index on Training Set:", ars)
    hdbscanDF.loc[i,"Train ARS"] = ars

    preds_test = hdbscan.prediction.approximate_predict(hdb,
                            X_test.reshape(X_test.shape[0], \
                                           X_test.shape[1]))
    ars = adjusted_rand_score(data_test[:,0],preds_test[0])
    print("Adjusted Rand Index on Test Set:", ars)
    hdbscanDF.loc[i,"Test ARS"] = ars

hdbscanTime = timer.elapsed_time()

HBDSCAN 在所有 85 个数据集上运行不到 10 分钟。

比较所有三种时间序列聚类方法

现在让我们比较这三种聚类算法,看看哪一种表现最佳。一种方法是分别计算每种聚类算法在训练集和测试集上的平均调整兰德指数。

每种算法的得分如下:

k-Shape Results

Train ARS     0.165139
Test ARS      0.151103
k-Means Results

Train ARS     0.184789
Test ARS      0.178960
HDBSCAN Results

Train ARS     0.178754
Test ARS 0.158238

结果相当可比,k-means 的兰德指数最高,紧随其后的是 k-shape 和 HDBSCAN。

为了验证这些发现,让我们统计每种算法在所有 85 个数据集中分别获得第一、第二或第三名的次数:

# Count top place finishes
timeSeriesClusteringDF = pd.DataFrame(data=[],index=kShapeDF.index, \
                            columns=["kShapeTest", \
                                    "kMeansTest", \
                                    "hdbscanTest"])

timeSeriesClusteringDF.kShapeTest = kShapeDF["Test ARS"]
timeSeriesClusteringDF.kMeansTest = kMeansDF["Test ARS"]
timeSeriesClusteringDF.hdbscanTest = hdbscanDF["Test ARS"]

tscResults = timeSeriesClusteringDF.copy()

for i in range(0,len(tscResults)):
    maxValue = tscResults.iloc[i].max()
    tscResults.iloc[i][tscResults.iloc[i]==maxValue]=1
    minValue = tscResults .iloc[i].min()
    tscResults.iloc[i][tscResults.iloc[i]==minValue]=-1
    medianValue = tscResults.iloc[i].median()
    tscResults.iloc[i][tscResults.iloc[i]==medianValue]=0
# Show results
tscResultsDF = pd.DataFrame(data=np.zeros((3,3)), \
                index=["firstPlace","secondPlace","thirdPlace"], \
                columns=["kShape", "kMeans","hdbscan"])
tscResultsDF.loc["firstPlace",:] = tscResults[tscResults==1].count().values
tscResultsDF.loc["secondPlace",:] = tscResults[tscResults==0].count().values
tscResultsDF.loc["thirdPlace",:] = tscResults[tscResults==-1].count().values
tscResultsDF

k-shape 在大多数数据集上获得了最多的第一名,其次是 HDBSCAN。k-means 在大多数数据集上获得了第二名,表现既不是最好的也不是最差的(表 13-1)。

表 13-1. 比较总结

kShape kMeans hbdscan
firstPlace 31.0 24.0 29.0
secondPlace 19.0 41.0 26.0
thirdPlace 35.0 20.0 30.0

根据这些比较,很难得出一个算法能够全面击败其他算法的结论。虽然 k-shape 获得了最多的第一名,但它比另外两种算法慢得多。

此外,k-means 和 HDBSCAN 都表现出色,在大量数据集上获得了第一名。

结论

在本章中,我们首次探索了时间序列数据,并展示了不需要任何标签就能根据相似性对时间序列模式进行分组的无监督学习的强大能力。我们详细讨论了三种聚类算法——k-shape、k-means 和 HDBSCAN。虽然k-shape 今天被认为是最好的选择,但另外两种算法也表现不俗。

最重要的是,我们使用的 85 个时间序列数据集的结果突显了实验的重要性。与大多数机器学习一样,没有单一算法能够胜过所有其他算法。你必须不断扩展你的知识广度并进行实验,以找出哪种方法最适合手头的问题。知道在什么时候应用何种方法是一个优秀数据科学家的标志。

希望通过本书学习到的多种不监督学习方法,能够更好地帮助你解决未来可能遇到的各种问题。

¹ 这篇论文可以在这里公开获取。

² 关于超参数的更多信息,请参考官方k-shape 文档

³ 请查阅维基百科,了解更多关于Rand 指数的信息。

第十四章:结论

人工智能正处于自 20 年前互联网时代以来在科技界所未见的炒作周期中。¹ 然而,这并不意味着炒作没有理由或在某种程度上不合理。

虽然前几十年的人工智能和机器学习工作大多是理论性和学术性质的,并且成功的商业应用寥寥无几,但过去十年在这一领域的工作更加应用化和行业化,由 Google、Facebook、Amazon、Microsoft 和 Apple 等公司主导。

专注于为狭义定义的任务(即弱或狭义 AI)开发机器学习应用,而不是更雄心勃勃的任务(即强 AI 或 AGI),使得这一领域对希望在较短的 7 到 10 年时间内获得良好回报的投资者更具吸引力。投资者的更多关注和资本反过来促使该领域在朝着狭义 AI 的进展以及为强 AI 打下基础方面更加成功。

当然,资本并非唯一的催化剂。大数据的崛起,计算机硬件的进步(尤其是由 Nvidia 主导的 GPU 的崛起,用于训练深度神经网络),以及算法研究和开发的突破都在为人工智能的最近成功做出同样有意义的贡献。

像所有炒作周期一样,当前周期最终可能会带来一些失望,但到目前为止,该领域的进展已经使科学界许多人感到惊讶,并且已经吸引了日益主流的观众的想象力。

监督学习

到目前为止,监督学习已经为机器学习中大多数商业成功负责。这些成功可以按数据类型进行分类:

  • 使用图像,我们有光学字符识别、图像分类和面部识别等技术。例如,Facebook 根据新照片中的面部与之前标记面部的相似度自动标记面部,利用了 Facebook 现有的照片数据库。

  • 使用视频,我们有自动驾驶汽车,这些汽车已经在今天美国各地的道路上运行。像 Google、特斯拉和 Uber 这样的主要参与者已经大量投资到自动驾驶车辆中。

  • 使用语音,我们有语音识别,由诸如 Siri、Alexa、Google 助理和 Cortana 等助手提供支持。

  • 使用文本,我们有电子邮件垃圾邮件过滤的经典示例,还有机器翻译(即 Google 翻译)、情感分析、语法分析、实体识别、语言检测和问答系统。在这些成功的基础上,我们在过去几年见证了聊天机器人的激增。

监督学习在时间序列预测方面也表现出色,这在金融、医疗保健和广告技术等领域有许多应用。当然,监督学习应用并不局限于一次只使用一种数据类型。例如,视频字幕系统将图像识别与自然语言处理相结合,对视频应用机器学习并生成文本字幕。

无监督学习

到目前为止,无监督学习的成功远不及监督学习,但它的潜力是巨大的。大多数世界数据都是未标记的。为了将机器学习规模化应用于比监督学习已经解决的更有野心的任务,我们需要同时处理标记和未标记的数据。

无监督学习非常擅长通过学习未标记数据的底层结构来发现隐藏模式。一旦发现了隐藏模式,无监督学习可以根据相似性将隐藏模式分组,使相似模式归为一组。

一旦以这种方式将模式分组,人们可以对每个组抽样一些模式并提供有意义的标签。如果组别定义良好(即成员是同质的,并且与其他组的成员明显不同),那么人类手动提供的少量标签可以应用于该组的其他(尚未标记的)成员。这个过程导致以前未标记的数据非常快速和高效地标记。

换句话说,无监督学习使监督学习方法得以成功应用。无监督学习与监督学习之间的这种协同作用,也称为半监督学习,可能推动成功的机器学习应用的下一波浪潮。

Scikit-Learn

到目前为止,无监督学习的这些主题应该对你来说已经非常熟悉了。但让我们回顾一下我们到目前为止所涵盖的一切。

在第三章中,我们探讨了如何使用降维算法通过学习底层结构来降低数据的维度,仅保留最显著的特征,并将特征映射到较低维度空间。

一旦数据映射到较低维度空间,就能更容易地揭示数据中的隐藏模式。在第四章中,我们通过构建异常检测系统来演示这一点,将正常的信用卡交易与异常的交易分开。

在这个较低维度的空间中,将类似的点分组也更容易;这被称为聚类,在第五章中我们探讨过。聚类的一个成功应用是群组分割,根据它们彼此的相似程度和与其他项的不同程度来分离项目。我们在第六章中对提交贷款申请的借款人执行了此操作。第 3 至 6 章总结了本书中使用 Scikit-Learn 的无监督学习部分。

在第十三章中,我们首次将聚类扩展到时间序列数据,并探索了各种时间序列聚类方法。我们进行了许多实验,并强调了拥有广泛的机器学习方法库的重要性,因为没有一种方法适用于所有数据集。

TensorFlow 和 Keras

第 7 到 12 章探讨了使用 TensorFlow 和 Keras 的无监督学习。

首先,我们介绍了神经网络和表示学习的概念。在第七章中,我们使用自编码器从原始数据中学习了新的更紧凑的表示方式——这是无监督学习从数据中学习底层结构以提取洞见的另一种方式。

在第八章中,我们将自编码器应用于信用卡交易数据集,以构建一个欺诈检测解决方案。而在第九章中,我们将无监督方法与监督方法结合,以改进第八章中基于无监督学习的独立信用卡欺诈检测解决方案,突显了无监督和监督学习模型之间的潜在协同作用。

在第十章中,我们首次介绍了生成模型,从限制玻尔兹曼机开始。我们利用这些模型构建了一个电影推荐系统,这是 Netflix 和 Amazon 等公司使用的推荐系统的一个轻量级版本。

在第十一章中,我们从浅层神经网络转向深层神经网络,并通过堆叠多个限制玻尔兹曼机来构建了一个更先进的生成模型。通过这种所谓的深信度网络,我们生成了 MNIST 数据集的合成图像,以增强现有的图像分类系统。这再次突显了利用无监督学习来改进监督解决方案的潜力。

在第十二章中,我们转向另一类现今流行的生成模型——生成对抗网络。我们利用这些模型生成了更多类似于 MNIST 图像数据集中数字的合成图像。

强化学习

在本书中,我们没有详细介绍强化学习,但这是另一个正在受到越来越多关注的机器学习领域,特别是在棋盘游戏和视频游戏领域取得的最新成功后。

最显著的是,Google DeepMind 几年前向世界介绍了其围棋软件AlphaGo,并在 2016 年 3 月 AlphaGo 历史性地击败了当时的世界冠军李世石,这一壮举被许多人认为是需要 AI 再过一个完整十年才能实现的,这显示了 AI 领域取得的巨大进展。

更近期,Google DeepMind 已将强化学习与无监督学习相结合,开发出了更好的 AlphaGo 软件的版本。这款名为AlphaGo Zero的软件根本不使用人类游戏数据。

从不同机器学习分支的结合中获得的成功案例证实了本书的一个主要主题——机器学习的下一波成功将由发现使用未标记数据来改进现有的依赖于标记数据集的机器学习解决方案的方法来引领。

今天无监督学习最有前景的领域

我们将以无监督学习的现状和可能的未来状态来结束本书。今天,无监督学习在工业界有几个成功的应用程序;在此列表的最上面是异常检测、维度约简、聚类、未标记数据集的高效标记和数据增强。

无监督学习在识别新兴模式方面表现出色,特别是当未来模式看起来与过去模式非常不同时;在某些领域,过去模式的标签对于捕捉未来感兴趣的模式的价值有限。例如,异常检测用于识别各种欺诈行为——信用卡、借记卡、电汇、在线、保险等——以及标记与洗钱、恐怖主义资助和人口贩卖有关的可疑交易。

异常检测也用于网络安全解决方案以识别和阻止网络攻击。基于规则的系统难以捕捉新类型的网络攻击,因此无监督学习已成为该领域的一个基本内容。异常检测还擅长突出显示数据质量问题;通过异常检测,数据分析师可以更有效地找出并解决不良数据捕获问题。

无监督学习还有助于解决机器学习中的一个主要挑战:维度诅咒。数据科学家通常必须选择要在分析数据和构建机器学习模型中使用的特征子集,因为完整的特征集太大了,如果不是棘手的话,会使计算变得困难。无监督学习使数据科学家不仅可以使用原始特征集,还可以在模型构建过程中补充其他特征工程,而无需担心遇到主要的计算挑战。

一旦原始加工过的特征集准备就绪,数据科学家就会应用维度约简来去除冗余特征,并保留最突出、不相关的特征用于分析和模型构建。这种数据压缩也是在监督机器学习系统中的预处理步骤中有用的(特别是在视频和图像中)。

无监督学习还帮助数据科学家和业务人员回答诸如哪些客户行为最不寻常(即与大多数客户非常不同)的问题。这种洞察力来自将相似点聚类在一起,帮助分析师执行群组分割。一旦识别出不同的群体,人类可以探索什么使这些群体特别,并与其他群体有明显不同。从这种练习中获得的洞察力可以应用于更深入地理解业务正在发生的情况并改进企业战略。

聚类使得标记未标记数据变得更加高效。由于类似的数据被分组在一起,人类只需标记每个群组中的少数点。一旦每个群组中的少数点被标记,其他尚未标记的点可以采用已标记点的标签。

最后,生成模型可以生成合成数据来补充现有数据集。我们通过 MNIST 数据集的工作展示了这一点。创建大量新的合成数据——包括图像和文本等多种数据类型——是非常强大的,并且现在才刚开始认真探索这一能力。

无监督学习的未来

我们目前处于人工智能浪潮的早期阶段。当然,迄今为止已经取得了重大成功,但人工智能世界很大程度上建立在炒作和承诺之上。还有许多潜力有待实现。

迄今为止的成功主要集中在由监督学习主导的大多数狭窄定义的任务中。随着当前人工智能浪潮的成熟,希望是从狭义人工智能任务(如图像分类、机器翻译、语音识别、问答机器人)转向更雄心勃勃的强人工智能(能够理解人类语言中的意义并像人类一样自然地对话的聊天机器人,能够理解并在不过度依赖标记数据的情况下操作周围物理世界的机器人,能够展示超人类驾驶性能的自动驾驶汽车,以及能够展示人类级推理和创造力的人工智能)。

许多人认为无监督学习是发展强人工智能的关键。否则,人工智能将受到我们拥有多少标记数据的限制。

人类从出生起在学习执行任务方面擅长于无需许多示例。例如,幼儿仅通过少数示例就能区分猫和狗。今天的人工智能需要更多的示例/标签。理想情况下,人工智能可以学会用尽可能少的标签分离不同类别的图像(即猫与狗),甚至可能只需一个或零个标签。要执行这种一次性或零次性学习将需要在无监督学习领域取得更多进展。

此外,今天大多数人工智能并不具备创造力,它只是基于训练标签来优化模式识别。要构建直观和创造性的人工智能,研究人员需要构建能够理解大量未标记数据的人工智能,以发现甚至人类以前未曾发现的模式。

幸运的是,有一些迹象表明人工智能正在逐渐发展成为更强的类型。

谷歌 DeepMind 的 AlphaGo 软件就是一个典型例子。首个击败人类职业围棋选手(于 2015 年 10 月)的 AlphaGo 版本依赖于过去人类和机器对弈的数据,以及强化学习等机器学习方法(包括能够预测多步并确定哪一步能够显著提高获胜的几率)。

这个版本的 AlphaGo 非常令人印象深刻,它在 2016 年 3 月的首尔高调五番棋系列赛中击败了世界顶级围棋选手李世石。但最新版本的 AlphaGo 更加出色。

原始版的 AlphaGo 依赖于数据和人类专业知识。最新版本的 AlphaGo,称为AlphaGo Zero,纯粹通过自我对弈学习如何玩并获胜围棋。² 换句话说,AlphaGo Zero 不依赖任何人类知识,并且达到了超越人类的表现,以百胜于前一版本的 AlphaGo。³

AlphaGo Zero 从对围棋一无所知开始,在几天内就积累了数千年的人类围棋知识。但它并不止步于此,超越了人类水平的熟练度。AlphaGo Zero 发现了新的知识,并发展了新的非传统的获胜策略。

换句话说,AlphaGo 表现出了创造力。

如果人工智能继续发展,依靠从几乎没有或没有任何先验知识学习(即几乎没有或没有标记的数据),我们将能够开发出具有创造力、推理能力和复杂决策能力的人工智能,这些领域目前还是人类的专利。⁴

最后总结

我们只是初步探索了无监督学习及其潜力,但希望您能更好地理解无监督学习的能力以及它如何应用于您设计的机器学习系统。

至少,您应该对使用无监督学习来发现隐藏模式,获得更深入的商业洞见,检测异常,根据相似性对群组进行聚类,执行自动特征提取以及从未标记数据集生成合成数据集有一个概念性理解和实际操作经验。

人工智能的未来充满了希望。去创造它吧。

¹ 根据PitchBook的数据,2017 年风险投资者在人工智能和机器学习公司投资超过 108 亿美元,这比 2010 年的 5 亿美元增长了一倍多,也比 2016 年的 57 亿美元增长了近一倍。

² “AlphaGo Zero: 从零开始学习”详细介绍了 AlphaGo Zero。

³ 欲了解更多信息,请查阅Nature的文章“不借助人类知识掌握围棋”

⁴ OpenAI 也在应用无监督学习取得了一些显著的成功,用于语言理解,这两者都是强人工智能的重要基石。

posted @ 2024-06-17 18:33  绝不原创的飞龙  阅读(17)  评论(0编辑  收藏  举报