每个程序员应该知道的-50-个算法-全-

每个程序员应该知道的 50 个算法(全)

原文:annas-archive.org/md5/a52efc405cb495a26c2bdcb2c25f51df

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

在计算领域,从基础理论到实际应用,算法都是推动力。在本次更新版中,我们进一步深入探讨了算法的动态世界,扩展了视野,解决了诸多紧迫的现实问题。从算法的基础入手,我们走过了各种设计技巧,深入探讨了线性规划、页面排名、图论等复杂领域,并对机器学习进行了更深层次的探索。为了确保我们走在技术发展的前沿,我们增加了对序列网络、LLMs、LSTM、GRUs 的深入讨论,现在还包括了密码学以及大规模算法在云计算环境中的部署。

推荐系统中算法的重要性,作为当今数字时代的核心元素,也被详尽阐述。要有效运用这些算法,理解其背后的数学和逻辑至关重要。我们的实际案例研究,包括天气预报、推文分析、电影推荐以及深入探索大型语言模型(LLMs)的细节,展示了它们的实际应用。

本书为您提供的见解,旨在增强您在现代计算挑战中部署算法的信心。迈入这段解读和利用算法的扩展之旅,在当今不断发展的数字化环境中充分发挥其作用。

本书适合谁阅读

如果您是程序员或开发者,热衷于利用算法解决问题并编写高效代码,那么这本书适合您。从经典的广泛应用算法到数据科学、机器学习和密码学领域的最新技术,本指南涵盖了广泛的内容。虽然熟悉 Python 编程是有益的,但并非必须。

任何编程语言的基础知识都将对您有所帮助。此外,即使您不是程序员,但有一定技术倾向,您也能从本书中获得关于问题解决算法的广阔见解。

本书内容

第一部分:基础与核心算法

第一章算法概述,介绍了算法的基本概念。它从算法的基本概念开始,讲解人们如何使用算法来表述问题,以及不同算法的局限性。由于本书使用 Python 编写算法,因此本章还会说明如何设置 Python 环境以运行示例。接下来,我们将探讨如何量化和比较一个算法的性能。

第二章算法中使用的数据结构,讨论了算法中的数据结构。由于我们在本书中使用 Python,这一章重点介绍 Python 的数据结构,但所讲的概念也适用于其他语言,如 Java 和 C++。本章将向您展示 Python 如何处理复杂的数据结构,并介绍应在不同类型的数据中使用哪些结构。

第三章排序和搜索算法,首先介绍了不同类型的排序算法及其设计方法。然后,通过实际例子讨论了搜索算法。

第四章设计算法,介绍了在设计算法时可供选择的选项,讨论了问题特性的重要性。接下来,以著名的旅行推销员问题TSP)为案例,应用了我们将要介绍的设计技术。还介绍了线性规划及其应用。

第五章图算法,涵盖了我们如何利用图来表示数据结构的方法。它涵盖了关于图算法的基础理论、技术和方法,如网络理论分析和图遍历。我们将通过一个图算法的案例研究来探讨欺诈分析。

第二部分:机器学习算法

第六章无监督 机器学习算法,解释了无监督学习如何应用于现实世界的问题。我们将学习其基本算法和方法,如聚类算法、降维和关联规则挖掘。

第七章传统监督学习算法,深入探讨了监督机器学习的基本原理,包括分类器和回归器。我们将使用真实世界的问题案例来探索它们的能力。介绍了六种不同的分类算法,以及三种回归技术。最后,我们将比较它们的结果,总结本讨论的要点。

第八章神经网络算法,介绍了典型神经网络的主要概念和组成部分。接着介绍了各种类型的神经网络和它们所使用的激活函数。详细讨论了反向传播算法,这是训练神经网络最广泛使用的算法。最后,我们将通过一个实际应用案例学习如何使用深度学习来检测欺诈文档。

第九章自然语言处理算法,介绍了自然语言处理NLP)的算法。它介绍了 NLP 的基本原理,以及如何为 NLP 任务准备数据。之后,解释了文本数据向量化和词嵌入的概念。最后,我们展示了一个详细的使用案例。

第十章理解顺序模型,深入探讨了用于顺序数据训练神经网络的方法。它涵盖了顺序模型的核心原则,并介绍了其技术和方法概述。然后考虑了深度学习如何改进自然语言处理技术。

第十一章高级顺序建模算法,考虑了顺序模型的局限性以及顺序建模如何发展以克服这些局限性。它深入探讨了顺序模型的高级方面,理解复杂配置的创建过程。首先,我们将分析关键元素,如自动编码器和序列到序列Seq2Seq)模型。接下来,我们将研究注意力机制和变换器,它们在大语言模型LLM)的发展中至关重要,随后我们将学习这些内容。

第三部分:高级主题

第十二章推荐引擎,介绍了推荐引擎的主要类型及其内部工作原理。这些系统擅长向用户推荐量身定制的物品或产品,但也并非没有挑战。我们将讨论它们的优势以及所面临的局限性。最后,我们将学习如何使用推荐引擎来解决现实世界的问题。

第十三章数据处理的算法策略,介绍了数据算法及其分类的基本概念。我们将了解用于高效管理数据的数据存储和数据压缩算法,帮助我们理解在设计和实施以数据为中心的算法时所涉及的权衡。

第十四章密码学,介绍了与密码学相关的算法。我们将首先介绍密码学的背景,然后讨论对称加密算法。我们将学习消息摘要 5MD5)算法和安全哈希算法SHA),并展示每种算法的局限性和弱点。接下来,我们将讨论非对称加密算法及其如何用于创建数字证书。最后,我们将通过一个实际示例总结所有这些技术。

第十五章大规模算法,首先介绍了大规模算法及其所需的高效基础设施。我们将探讨管理多资源处理的各种策略。我们将检查并行处理的局限性,正如阿姆达尔定律所描述的那样,并研究图形处理单元GPU)的使用。完成本章后,你将掌握设计大规模算法所必需的基本策略,打下坚实的基础。

第十六章实际考虑,介绍了算法可解释性的问题,即算法内部机制能够以可理解的方式进行解释的程度。然后,我们将讨论使用算法的伦理问题以及在实施过程中可能产生的偏见。接着,将讨论处理 NP 难题的技术。最后,我们将研究在选择算法之前需要考虑的因素。

下载示例代码文件

本书的代码包也托管在 GitHub 上,地址为:github.com/cloudanum/50Algorithms。我们还提供了其他代码包,来自我们丰富的书籍和视频目录,您可以在github.com/PacktPublishing/查看。别忘了看看!您还可以在 Google Drive 上找到相同的代码包,地址为:code.50algo.com

下载彩色图片

我们还提供了一份 PDF 文件,其中包含了本书中所使用截图和图表的彩色图片。您可以在此下载:packt.link/UBw6g

使用的约定

本书中使用了许多文本约定。

文本中的代码:表示文本中的代码词汇、数据库表名、文件夹名称、文件名、文件扩展名、路径名、虚拟 URL、用户输入以及 Twitter 用户名。以下是一个例子:“让我们尝试使用 Python 中的networtx包创建一个简单的图。”

粗体:表示新术语、重要词汇或屏幕上出现的词。例如,新的术语在文本中会像这样出现:“Python 也是您可以在各种云计算基础设施中使用的语言之一,例如亚马逊网络服务AWS)和谷歌云平台GCP)。”

警告或重要的说明将会像这样呈现。

提示和技巧通常会像这样呈现

与我们联系

我们始终欢迎读者的反馈。

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

勘误表:尽管我们已尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在本书中发现错误,我们将非常感激您报告给我们。请访问www.packtpub.com/submit-errata,点击提交勘误表并填写表单。

盗版:如果您在互联网上遇到我们作品的任何非法副本,请提供相关的地址或网站名称,我们将不胜感激。请通过copyright@packtpub.com与我们联系,并附上相关材料的链接。

如果您有兴趣成为作者:如果您在某个领域有专长,并且有兴趣参与撰写或贡献一本书,请访问authors.packtpub.com

分享您的想法

阅读完《每个程序员都应该知道的 50 个算法(第二版)》后,我们非常希望听到您的反馈!请点击这里直接前往亚马逊的评价页面并分享您的意见。

您的评论对我们和技术社区至关重要,并将帮助我们确保提供高质量的内容。

下载本书的免费 PDF 副本

感谢购买本书!

你喜欢随时随地阅读,但又无法随身携带印刷版书籍吗?你购买的电子书是否与你选择的设备不兼容?

不用担心,现在每本 Packt 书籍都可以免费获得该书的无 DRM PDF 版本。

在任何地方、任何设备上都能阅读。直接从你喜欢的技术书籍中搜索、复制并粘贴代码到你的应用程序中。

福利不止于此,你还可以获得独家折扣、新闻通讯和每天发送到你邮箱的优质免费内容。

按照这些简单步骤获取福利:

  1. 扫描二维码或访问以下链接

    packt.link/free-ebook/9781803247762

  2. 提交你的购买证明

  3. 就是这样!我们将直接通过电子邮件向你发送免费的 PDF 和其他福利。

第一部分

基础知识与核心算法

本节介绍了算法的核心方面。我们将探讨什么是算法以及如何设计一个算法。我们还将了解算法中使用的数据结构。本节还介绍了排序和查找算法以及解决图形问题的算法。本节包含的章节有:

  • 第一章算法概述

  • 第二章算法中使用的数据结构

  • 第三章排序与查找算法

  • 第四章算法设计

  • 第五章图算法

第一章:算法概述

必须亲眼看到算法,才能相信它。

– 唐纳德·克努斯

本书涵盖了理解、分类、选择和实现重要算法所需的信息。除了讲解算法的逻辑,本书还讨论了适用于不同类别算法的数据结构、开发环境和生产环境。这是本书的第二版,在这一版中,我们特别关注日益重要的现代机器学习算法。除了逻辑部分,本书还展示了使用算法解决实际日常问题的实际例子。

本章为算法的基础提供了深入的理解。首先介绍了理解不同算法工作原理所需的基本概念。为了提供历史视角,本节总结了人们如何开始使用算法来数学化某类问题。还提到了不同算法的局限性。接下来的部分解释了指定算法逻辑的各种方法。由于本书使用 Python 编写算法,因此解释了如何设置 Python 环境以运行示例。然后,讨论了如何量化和比较算法的性能与其他算法的不同方法。最后,本章讨论了验证算法特定实现的各种方式。

总结来说,本章涵盖了以下主要内容:

  • 什么是算法?

  • 算法的各个阶段

  • 开发环境

  • 算法设计技巧

  • 性能分析

  • 验证算法

什么是算法?

从最简单的角度看,算法是一组规则,用于执行某些计算以解决问题。它被设计为根据精确定义的指令,对任何有效的输入产生结果。如果你查阅字典(例如《美国传统词典》),它是这样定义算法的:

算法是一组有限的、不含歧义的指令,在给定一组初始条件下,按照规定的顺序执行,以实现特定目标,并且具有可识别的结束条件。

设计一个算法是努力以最有效的方式创建一个数学公式,能够有效地用于解决现实世界的问题。这个公式可以作为开发更具可重用性和通用性的数学解决方案的基础,应用于更广泛的类似问题。

算法的各个阶段

开发、部署并最终使用算法的不同阶段如图 1.1所示:

图表,示意图 自动生成的描述

图 1.1:开发、部署和使用算法的不同阶段

正如我们所看到的,过程始于理解问题陈述中的需求,明确了需要做什么。一旦问题被清晰地陈述出来,就会引导我们进入开发阶段。

开发阶段包括两个阶段:

  1. 设计阶段:在设计阶段,算法的架构、逻辑和实现细节被构思并记录下来。在设计算法时,我们始终考虑准确性和性能。在寻找给定问题的最佳解决方案时,通常会有多个候选算法可供选择。算法的设计阶段是一个迭代过程,涉及比较不同的候选算法。有些算法可能提供简单且快速的解决方案,但可能会牺牲准确性。其他算法可能非常准确,但由于其复杂性,运行时可能需要相当长的时间。一些复杂的算法可能比其他算法更高效。在做出选择之前,应该仔细研究所有候选算法的固有权衡。特别是对于复杂问题,设计一个高效的算法非常重要。正确设计的算法将提供一个高效的解决方案,能够同时提供令人满意的性能和合理的准确性。

  2. 编码阶段:在编码阶段,设计好的算法被转换为计算机程序。计算机程序实现设计阶段提出的所有逻辑和架构是至关重要的。

商业问题的需求可以分为功能性需求和非功能性需求。直接指定解决方案预期特征的需求称为功能性需求。功能性需求详细说明了解决方案的预期行为。另一方面,非功能性需求关注算法的性能、可扩展性、可用性和准确性。非功能性需求还规定了数据安全性的期望。例如,假设我们需要为一家信用卡公司设计一个可以识别并标记欺诈交易的算法。这个例子中的功能性需求将通过提供给定一组输入数据的预期输出的详细信息,来指定有效解决方案的预期行为。在这种情况下,输入数据可能是交易的详细信息,而输出可能是一个二进制标志,用来标记交易是欺诈的还是非欺诈的。在这个例子中,非功能性需求可能会指定每个预测的响应时间。非功能性需求还会设置准确度的容许阈值。由于我们在这个例子中处理的是金融数据,因此与用户身份验证、授权和数据保密相关的安全需求也应该是非功能性需求的一部分。

请注意,功能性和非功能性需求的目标是精确定义需要做什么。设计解决方案是关于弄清楚如何做。实现设计是使用您选择的编程语言开发实际解决方案。设计一个完全满足功能性和非功能性需求的解决方案可能需要大量时间和精力。选择合适的编程语言和开发/生产环境可能取决于问题的需求。例如,由于 C/C++ 是比 Python 更低级的语言,因此对于需要编译代码和低级优化的算法,它可能是更好的选择。

一旦设计阶段完成且编码完成,算法就可以部署了。部署算法涉及设计实际的生产环境,其中代码将运行。生产环境的设计需要根据算法的数据和处理需求来进行。例如,对于可并行化的算法,需要一个适当数量计算节点的集群,以便高效执行算法。对于数据密集型算法,可能需要设计数据输入管道,以及缓存和存储数据的策略。生产环境的设计将在第十五章《大规模算法》和第十六章《实际考虑事项》中详细讨论。

一旦生产环境设计并实施完毕,算法就可以部署,算法将根据要求处理输入数据并生成输出。

开发环境

一旦设计完成,算法需要根据设计在编程语言中实现。对于本书,我们选择了 Python 作为编程语言。我们之所以选择它,是因为 Python 灵活且是开源编程语言。Python 也是您可以在各种云计算基础设施中使用的语言之一,如Amazon Web ServicesAWS)、Microsoft Azure 和 Google Cloud PlatformGCP)。

官方 Python 首页可以通过www.python.org/访问,页面上还有安装说明和有用的初学者指南。

为了更好地理解本书中呈现的概念,您需要具备基本的 Python 知识。

对于本书,我们建议使用最新版本的 Python 3。写作时,最新版本是 3.10,我们将使用这个版本来运行本书中的练习。

本书中我们将始终使用 Python。我们还将使用 Jupyter Notebook 来运行代码。本书的其余章节假设已安装 Python,并且 Jupyter Notebook 已正确配置并正在运行。

Python 包

Python 是一种通用编程语言。它遵循“自带电池”(batteries included)的理念,这意味着有一个标准库可供使用,而无需用户下载单独的包。然而,标准库模块仅提供最低限度的功能。根据您正在处理的特定用例,可能需要安装额外的包。Python 包的官方第三方库称为 PyPI,代表 Python 包索引。它以源代码分发和预编译代码的形式托管 Python 包。目前,PyPI 上托管了超过 113,000 个 Python 包。安装额外包最简单的方式是通过 pip 包管理系统。pip 是一个典型的递归首字母缩略词,Python 文化中充斥着这样的词汇。pip 代表 Pip Installs Python。好消息是,从 Python 3.4 版本开始,pip 默认已安装。要检查 pip 的版本,可以在命令行输入:

pip --version 

这个 pip 命令可用于安装额外的包:

pip install PackageName 

已安装的包需要定期更新,以获得最新的功能。这可以通过使用 upgrade 标志来实现:

pip install PackageName --upgrade 

并且可以安装特定版本的 Python 包:

pip install PackageName==2.1 

添加正确的库和版本已成为设置 Python 编程环境的一部分。帮助维护这些库的一个功能是能够创建一个列出所有所需包的 requirements 文件。requirements 文件是一个简单的文本文件,包含库的名称及其相关版本。requirements 文件的示例如下所示:

scikit-learn==0.24.1

tensorflow==2.5.0

tensorboard==2.5.0

按惯例,requirements.txt 文件放置在项目的顶层目录中。

创建后,可以使用以下命令通过安装所有 Python 库及其相关版本来设置开发环境:

pip install -r requirements.txt 

现在让我们来看看本书中将使用的主要包。

SciPy 生态系统

科学 PythonSciPy)——发音为 sigh pie——是为科学社区创建的一组 Python 包。它包含许多功能,包括广泛的随机数生成器、线性代数例程和优化器。

SciPy 是一个全面的包,随着时间的推移,人们开发了许多扩展,以根据自己的需求定制和扩展该包。SciPy 性能良好,因为它作为围绕 C/C++ 或 Fortran 编写的优化代码的薄包装器。

以下是该生态系统中主要的包:

  • NumPy:对于算法来说,能够创建多维数据结构,如数组和矩阵,十分重要。NumPy 提供了一组数组和矩阵数据类型,对于统计学和数据分析非常重要。有关 NumPy 的详细信息,请访问 www.numpy.org/

  • scikit-learn:这个机器学习扩展是 SciPy 最受欢迎的扩展之一。Scikit-learn 提供了广泛的重要机器学习算法,包括分类、回归、聚类和模型验证。你可以在 scikit-learn.org/ 上找到有关 scikit-learn 的更多详细信息。

  • pandas:pandas 包含了广泛用于输入、输出和处理表格数据的表格复杂数据结构,广泛应用于各种算法中。pandas 库包含了许多有用的函数,同时也提供了高度优化的性能。有关 pandas 的更多信息,请访问 pandas.pydata.org/

  • Matplotlib:Matplotlib 提供了创建强大可视化工具的功能。数据可以以折线图、散点图、条形图、直方图、饼图等形式呈现。有关更多信息,请访问 matplotlib.org/

使用 Jupyter Notebook

我们将使用 Jupyter Notebook 和 Google 的 Colaboratory 作为 IDE。有关 Jupyter Notebook 和 Colab 设置和使用的更多信息,请参见 附录 AB

算法设计技术

算法是解决实际问题的数学方法。在设计算法时,我们在设计和调优算法的过程中会考虑以下三个设计问题:

  • 问题 1:这个算法是否生成了我们预期的结果?

  • 问题 2:这是获取这些结果的最优方式吗?

  • 问题 3:这个算法在更大数据集上的表现如何?

在设计解决方案之前,理解问题本身的复杂性非常重要。例如,如果我们根据问题的需求和复杂性来描述它,这将有助于我们设计合适的解决方案。

一般来说,算法可以根据问题的特征分为以下几种类型:

  • 数据密集型算法:数据密集型算法旨在处理大量数据。它们预计具有相对简单的处理要求。应用于大型文件的压缩算法就是数据密集型算法的一个很好的例子。对于这类算法,数据的大小预计将远大于处理引擎的内存(单个节点或集群),并且可能需要开发一种迭代处理设计,以根据要求高效地处理数据。

  • 计算密集型算法:计算密集型算法有相当大的处理需求,但不涉及大量数据。一个简单的例子是寻找一个非常大的素数。找到一种策略,将算法划分为不同的阶段,以便至少一些阶段可以并行处理,是最大化算法性能的关键。

  • 数据和计算密集型算法:有些算法处理大量数据并且计算需求也很大。用于对实时视频流进行情感分析的算法就是一个很好的例子,其中数据和处理需求都非常庞大,完成任务所需的资源也很大。这类算法是最消耗资源的算法,需要仔细设计算法并智能地分配可用资源。

为了描述问题的复杂性和需求,深入研究它的数据和计算维度会有所帮助,我们将在接下来的章节中进行讨论。

数据维度

为了对问题的数据维度进行分类,我们查看其数据量速度多样性(即3Vs),定义如下:

  • 数据量:数据量是算法处理的数据的预期大小。

  • 速度:速度是算法使用时新数据生成的预期速率。它可以为零。

  • 多样性:多样性量化了设计的算法预期要处理的数据类型的数量。

图 1.2更详细地展示了数据的 3Vs。该图的中心显示了最简单的数据,具有小数据量、低多样性和低速度。随着我们远离中心,数据的复杂性增加,可能在三维中的一个或多个维度上增加。

例如,在速度维度上,我们有批处理过程作为最简单的,其次是周期性过程,然后是近实时过程。最后,我们有实时过程,这是在数据速度的背景下最复杂的处理方式。例如,一组监控摄像头收集的实时视频流将具有高数据量、高速度和高多样性,可能需要适当的设计来有效地存储和处理数据:

图表描述自动生成

图 1.2:数据的 3Vs:数据量、速度和多样性

让我们考虑三个具有三种不同数据类型的用例示例:

  • 首先,考虑一个简单的数据处理用例,其中输入数据是一个.csv文件。在这种情况下,数据的量、速度和多样性将较低。

  • 其次,考虑一个用例,其中输入数据是一个安全监控摄像头的实时视频流。在这种情况下,数据的量、速度和多样性将非常高,设计算法时应考虑这一点。

  • 第三,考虑典型传感器网络的使用案例。假设传感器网络的数据源是安装在一座大楼中的温度传感器网格。尽管生成的数据的速度通常非常高(因为新数据生成非常快),但数据量预期相对较低(因为每个数据元素通常只有 16 位长,包含 8 位测量值和 8 位元数据,如时间戳和地理坐标)。

以上三个示例的处理要求、存储需求和合适的软件栈选择都不相同,通常取决于数据源的体量、速度和多样性。将数据进行表征是设计算法的第一步,因此非常重要。

计算维度

为了表征计算维度,我们需要分析当前问题的处理需求。一个算法的处理需求决定了最适合的设计类型。例如,复杂算法通常需要大量的处理能力。对于这类算法,可能需要具有多节点并行架构。现代深度算法通常涉及大量的数值处理,可能需要 GPU 或 TUP 的计算能力,如第十六章实际考虑因素中所讨论的。

性能分析

分析算法的性能是其设计的一个重要部分。估算算法性能的方式之一是分析其复杂度。

复杂度理论是研究算法复杂度的学科。为了有用,任何算法都应该具备三个关键特性:

  • 应该正确:一个好的算法应该产生正确的结果。为了确认算法是否正确工作,需要进行广泛的测试,特别是测试边界情况。

  • 应该可理解:一个好的算法应该是可理解的。如果一个算法过于复杂,无法在计算机上实现,那么它再好也没有用。

  • 应该高效:一个好的算法应该是高效的。即使一个算法产生了正确的结果,如果它需要一千年才能完成,或者需要十亿 TB 的内存,那么它也没有多大帮助。

有两种可能的分析方法来量化算法的复杂度:

  • 空间复杂度分析:估算执行算法所需的运行时内存需求。

  • 时间复杂度分析:估算算法运行所需的时间。

让我们逐一研究:

空间复杂度分析

空间复杂度分析估算了算法处理输入数据时所需的内存量。在处理输入数据时,算法需要将瞬时临时数据结构存储在内存中。算法的设计方式会影响这些数据结构的数量、类型和大小。在分布式计算时代,随着需要处理的数据量越来越大,空间复杂度分析变得越来越重要。这些数据结构的大小、类型和数量将决定底层硬件的内存需求。现代分布式计算中使用的内存数据结构需要具备高效的资源分配机制,能够在算法的不同执行阶段意识到内存需求。复杂的算法往往是迭代式的。此类算法并不会一次性将所有信息加载到内存中,而是通过迭代逐步填充数据结构。为了计算空间复杂度,首先需要对我们计划使用的迭代算法类型进行分类。迭代算法可以使用以下三种类型的迭代:

  • 收敛迭代:随着算法通过迭代进行,每次迭代中处理的数据量都会减少。换句话说,随着算法迭代的进行,空间复杂度逐渐降低。主要的挑战是处理初始迭代的空间复杂度。现代可扩展的云基础设施,如 AWS 和 Google Cloud,非常适合运行这类算法。

  • 发散迭代:随着算法通过迭代进行,每次迭代中处理的数据量逐渐增加。随着空间复杂度随着算法迭代的推进而增加,重要的是设置约束条件,以防止系统变得不稳定。可以通过限制迭代次数和/或限制初始数据大小来设置这些约束条件。

  • 平面迭代:随着算法通过迭代进行,每次迭代中处理的数据量保持不变。由于空间复杂度不发生变化,因此不需要基础设施的弹性。

计算空间复杂度时,我们需要关注最复杂的迭代之一。在许多算法中,随着我们逐步接近解决方案,所需的资源会逐渐减少。在这种情况下,初始迭代是最复杂的,可以帮助我们更好地估算空间复杂度。选择后,我们估算算法使用的总内存量,包括其瞬时数据结构、执行和输入值所占用的内存。这将帮助我们很好地估算算法的空间复杂度。

以下是最小化空间复杂度的指导原则:

  • 在可能的情况下,尽量将算法设计为迭代式。

  • 在设计迭代算法时,每当有选择时,应该优先选择更多的迭代次数而不是更少的迭代次数。细粒度的更多迭代预计会有较低的空间复杂度。

  • 算法应该只将当前处理所需的信息加载到内存中,任何不需要的信息应当被清除出内存。

空间复杂度分析是高效设计算法的必要条件。如果在设计特定算法时没有进行适当的空间复杂度分析,可能会因为临时数据结构的内存不足而触发不必要的磁盘溢出,这可能会显著影响算法的性能和效率。

本章将深入探讨时间复杂度。空间复杂度将在第十五章《大规模算法》中更详细地讨论,其中我们将处理具有复杂运行时内存需求的大规模分布式算法。

时间复杂度分析

时间复杂度分析通过评估算法的结构,估算算法完成指定任务所需的时间。与空间复杂度不同,时间复杂度不依赖于算法运行的硬件。时间复杂度分析仅取决于算法本身的结构。时间复杂度分析的总体目标是尝试回答这两个重要问题:

  • 这个算法能扩展吗?一个设计良好的算法应该能够充分利用云计算环境中现代弹性基础设施的优势。算法应当设计成能够利用更多的 CPU、处理核心、GPU 和内存。例如,用于训练机器学习模型的算法应当能够在更多的 CPU 可用时使用分布式训练。

这样的算法在执行过程中应当能够充分利用 GPU 和额外的内存(如果有的话)。

  • 这个算法如何处理更大的数据集?

为了回答这些问题,我们需要确定当数据量增大时,算法的性能受到的影响,并确保算法的设计不仅要保证准确性,还要具有良好的扩展性。在当今“大数据”时代,算法的性能对大数据集来说变得越来越重要。

在许多情况下,我们可能有不止一种方法可以用来设计算法。在这种情况下,进行时间复杂度分析的目标将如下:

“给定一个特定的问题,且有多个算法可供选择,哪个算法在时间效率方面最为高效?”

计算算法时间复杂度有两种基本方法:

  • 后期实现的性能分析方法:在这种方法中,首先实现不同的候选算法,并比较它们的性能。

  • 预实现理论方法:在这种方法中,每个算法的性能在运行算法之前通过数学方法进行近似。

理论方法的优势在于它仅依赖于算法本身的结构。它不依赖于运行算法时将使用的实际硬件、运行时选择的软件栈,或实现算法所使用的编程语言。

估算性能

一个典型算法的性能将取决于输入数据的类型。例如,如果数据已经按照我们试图解决的问题的上下文进行了排序,那么算法可能会运行得非常快速。如果排序后的输入数据被用来基准测试该算法,那么它将给出一个不切实际的优异性能结果,这并不能真实反映其在大多数场景中的实际表现。为了处理算法对输入数据的依赖性,在进行性能分析时我们需要考虑不同的情况。

最佳情况

在最佳情况下,作为输入的数据已按算法能够提供最佳性能的方式进行组织。最佳情况分析给出性能的上界。

最坏情况

估算算法性能的第二种方法是尝试找出在给定条件下完成任务所需的最大时间。算法的最坏情况分析非常有用,因为我们可以保证无论条件如何,算法的性能总是优于我们分析中得出的数字。最坏情况分析对于估算处理复杂问题和大型数据集时的性能特别有用。最坏情况分析给出了算法性能的下界。

平均情况

这一方法首先将各种可能的输入划分为不同的组别。然后,从每个组别的一个代表性输入进行性能分析。最后,它计算每个组别性能的平均值。

平均情况分析并不总是准确的,因为它需要考虑所有不同的输入组合和可能性,这并不总是容易做到的。

大 O 符号

大 O 符号最早由巴赫曼(Bachmann)于 1894 年在一篇研究论文中提出,用于近似算法的增长。他写道:

“… 使用符号 O(n),我们表示一种其相对于 n 的阶数不超过 n 阶数的量。”(巴赫曼 1894,p. 401)

大 O 符号提供了一种描述算法性能长期增长率的方式。简单来说,它告诉我们,随着输入规模的增加,算法的运行时间是如何增长的。我们可以通过两个函数 f(n)g(n) 来进一步解释。如果我们说 f = O(g),意思是当 n 趋向于无穷大时,比例 保持有限或有界。换句话说,无论我们的输入有多大,f(n) 的增长速度都不会比 g(n) 快得不成比例。

让我们看看一些特定的函数:

f(n) = 1000n² + 100n + 10

并且

g(n) = n²

请注意,当 n 趋近无穷大时,两个函数都会趋近无穷大。让我们通过应用定义来验证 f = O(g)

首先,让我们计算

这将等于 = = (1000 + )。

很明显, 是有界的,并且当 n 趋向无穷大时,它不会趋向于无穷大。

因此,f(n) = O(g) = O(n²)

(n²) 表示该函数的复杂度随着输入 n 的平方而增加。如果我们将输入元素数量翻倍,那么复杂度预计会增加 4 倍。

处理大 O 符号时,请注意以下 4 个规则。

规则 1

让我们看看算法中的循环复杂度。如果一个算法执行某一系列步骤 n 次,那么它的性能就是 O(n)

规则 2

让我们来分析算法中的嵌套循环。如果一个算法执行的某个函数有 n¹ 步,并且对于每次循环它执行另一个 n² 步的操作,那么该算法的总性能是 O(n¹ × n²)

例如,如果一个算法有外循环和内循环,且都需要 n 步,那么该算法的复杂度将表示为:

O(nn)* = O(n²)

规则 3

如果一个算法执行一个需要 n¹ 步的函数 f(n),然后执行另一个需要 n² 步的函数 g(n),则该算法的总性能是 O(f(n)+g(n))

规则 4

如果一个算法的复杂度是 O(g(n) + h(n)),并且在大 n 的情况下,函数 g(n) 大于 h(n),那么该算法的性能可以简化为 O(g(n))

这意味着 O(1+n) = O(n)

并且 O(n²+ n³) = O(n²)

规则 5

计算算法的复杂度时,要忽略常数倍数。如果 k 是常数,O(kf(n)) 等同于 O(f(n))

同样,O(f(k × n)) 等同于 O(f(n))

因此,O(5n²) = O(n²)

并且 O((3n²)) = O(n²)

请注意:

  • 大 O 符号表示的复杂度仅为估算值。

  • 对于较小规模的数据,我们不关心时间复杂度。图中的 n⁰ 定义了我们开始关注时间复杂度的阈值。阴影区域描述了我们感兴趣的区域,在这个区域内我们将分析时间复杂度。

  • T(n) 时间复杂度大于原始函数。一个好的 T(n) 选择会尽量为 F(n) 创建一个紧密的上界。

下表总结了本节中讨论的不同类型的大 O 符号:

复杂度类别 名称 示例操作
O(1) 常数 添加、获取项、设置项。
O(logn) 对数 在已排序数组中查找一个元素。
O(n) 线性 复制、插入、删除、迭代
O(n²) 二次 嵌套循环

常数时间 (O(1)) 复杂度

如果一个算法的运行时间与输入数据的大小无关,始终相同,则称其为常数时间运行。它用 O(1) 表示。以访问数组中的 n^(th) 元素为例。无论数组的大小如何,获取结果所需的时间是常数时间。例如,以下函数将返回数组的第一个元素,并具有 O(1) 复杂度:

def get_first(my_list):
    return my_list[0]
get_first([1, 2, 3]) 
1 
get_first([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) 
1 

注意:

  • 向栈中添加新元素是通过 push 操作完成的,而从栈中删除元素是通过 pop 操作完成的。无论栈的大小如何,添加或删除一个元素所需的时间是相同的。

  • 当访问哈希表中的元素时,请注意,它是一种以关联格式存储数据的数据结构,通常为键值对。

线性时间 (O(n)) 复杂度

如果一个算法的执行时间与输入规模成正比,则称其为线性时间复杂度,表示为 O(n)。一个简单的例子是将一维数据结构中的元素相加:

def get_sum(my_list):
    sum = 0
    for item in my_list:
        sum = sum + item
    return sum 

请注意算法的主循环。主循环中的迭代次数随着 n 值的增加而线性增长,产生如图所示的 O(n) 复杂度:

get_sum([1, 2, 3]) 
6 
get_sum([1, 2, 3, 4]) 
10 

其他一些数组操作的示例如下:

  • 查找一个元素

  • 查找数组中所有元素的最小值

二次时间 (O(n²)) 复杂度

如果一个算法的执行时间与输入规模的平方成正比,则称该算法的运行时间为二次时间;例如,一个简单的函数用于求和一个二维数组,如下所示:

def get_sum(my_list):
    sum = 0
    for row in my_list:
        for item in row:
            sum += item
    return sum 

请注意,内层循环嵌套在另一个主循环内。这个嵌套循环使得前面的代码具有 O(n²) 的复杂度:

get_sum([[1, 2], [3, 4]]) 
10 
get_sum([[1, 2, 3], [4, 5, 6]]) 
21 

另一个例子是 冒泡排序算法(将在 第二章算法中的数据结构 中讨论)。

对数时间 (O(logn)) 复杂度

如果一个算法的执行时间与输入大小的对数成正比,那么这个算法被称为对数时间算法。在每次迭代中,输入大小会按固定倍数减少。一个对数算法的例子是二分查找。二分查找算法用于在一维数据结构中查找特定元素,例如 Python 列表。数据结构中的元素需要按降序排序。二分查找算法在一个名为 search_binary 的函数中实现,如下所示:

def search_binary(my_list, item):
    first = 0
    last = len(my_list)-1
    found_flag = False
    while(first <= last and not found_flag):
        mid = (first + last)//2
        if my_list[mid] == item:
            found_flag = True           
        else:
            if item < my_list[mid]:
                last = mid - 1
            else:
                first = mid + 1
    return found_flag
searchBinary([8,9,10,100,1000,2000,3000], 10) 
True 
searchBinary([8,9,10,100,1000,2000,3000], 5) 
False 

主循环利用了列表已排序的事实。它每次迭代都将列表分成一半,直到找到结果。

在定义了这个函数之后,它在第 11 和第 12 行进行了测试,用于搜索特定元素。二分查找算法将在 第三章排序与查找算法 中进一步讨论。

请注意,在展示的四种大 O 符号中,O(n²) 的性能最差,而 O(logn) 的性能最好。另一方面,O(n²) 不如 O(n³) 那么糟糕,但仍然,属于这个类别的算法无法处理大数据,因为时间复杂度限制了它们实际能够处理的数据量。四种大 O 符号的性能如 图 1.3 所示:

Chart, line chart  Description automatically generated

图 1.3:大 O 复杂度图表

降低算法复杂度的一种方法是牺牲其精确度,从而产生一种被称为 近似算法 的算法。

选择算法

如何知道哪种解决方案更好?如何知道哪个算法运行得更快?分析一个算法的时间复杂度可以回答这些问题。

为了看到算法的用途,让我们以一个简单的例子为例,目标是对数字列表进行排序。有许多现成的算法可以完成这个任务。问题是如何选择正确的算法。

首先可以观察到,如果列表中的数字不多,那么选择哪种算法来排序这些数字并不重要。因此,如果列表中只有 10 个数字(n=10),那么选择哪种算法并无太大关系,因为即使使用非常简单的算法,也可能只需要几微秒的时间。但随着 n 的增加,选择正确算法开始变得重要。一个设计不良的算法可能需要几个小时来运行,而一个设计良好的算法可能只需要几秒钟就能完成排序。因此,对于较大的输入数据集,花时间和精力进行性能分析,并选择合适设计的算法来高效地完成任务是非常有意义的。

验证算法

验证算法确认它实际上提供了我们尝试解决的问题的数学解决方案。验证过程应检查尽可能多的可能值和类型的输入值的结果。

精确、近似和随机化算法

验证算法也取决于算法的类型,因为测试技术是不同的。首先让我们区分确定性和随机化算法。

对于确定性算法,特定输入始终生成完全相同的输出。但对于某些类别的算法,随机数序列也被视为输入,这使得每次运行算法时输出都不同。详见第六章《无监督机器学习算法》中详细介绍的 k 均值聚类算法。

瀑布图  自动生成描述

图 1.4: 确定性与随机化算法

根据用于简化逻辑以使其运行更快的假设或近似,算法还可以分为以下两种类型:

  • 一个精确算法:精确算法预期能够在不引入任何假设或近似的情况下产生精确的解决方案。

  • 一个近似算法:当问题复杂度超出给定资源的处理能力时,我们通过做一些假设来简化问题。基于这些简化或假设的算法称为近似算法,它们不能给出精确的解决方案。

让我们通过一个例子来理解精确算法和近似算法之间的区别——著名的旅行推销员问题,这个问题在 1930 年被提出。旅行推销员问题挑战你找出一个特定推销员访问每个城市(从城市列表中)并返回原点的最短路线,这也是他被称为旅行推销员的原因。首次尝试提供解决方案将包括生成所有城市的排列组合,并选择最便宜的城市组合。显然,当城市超过 30 个时,时间复杂度开始变得难以管理。

如果城市数量超过 30,减少复杂性的一种方法是引入一些近似和假设。

对于近似算法,重要的是在收集需求时设定准确性期望。验证近似算法涉及验证结果的误差是否在可接受范围内。

可解释性

当算法用于关键情况时,有必要能够解释每个结果背后的原因。这对确保基于算法结果的决策不引入偏见至关重要。

准确识别直接或间接用于做出特定决策的特征的能力称为算法的可解释性。当算法用于关键应用场景时,需要评估其偏见和偏袒。算法的伦理分析已经成为验证过程的标准部分,特别是那些涉及影响到人们生活决策的算法。

对于处理深度学习的算法来说,实现可解释性是困难的。例如,如果一个算法被用来拒绝某人的抵押贷款申请,那么拥有透明度和能够解释原因是非常重要的。

算法的可解释性是一个活跃的研究领域。最近开发的有效技术之一是局部可解释模型无关解释LIME),这是在 2016 年计算机协会ACM)的知识发现与数据挖掘特别兴趣小组SIGKDD)国际会议的论文集中提出的。LIME 基于这样一个概念:对于每个实例,引入小的变化,然后努力映射该实例的局部决策边界。然后,它可以量化每个变量对该实例的影响。

小结

本章内容是学习算法的基础。首先,我们了解了开发算法的不同阶段。我们讨论了设计算法所需的不同方法,以明确算法的逻辑。接着,我们看了如何设计一个算法。我们学习了两种不同的分析算法性能的方法。最后,我们研究了验证算法的不同方面。

阅读完本章后,我们应该能够理解算法的伪代码。我们应该理解开发和部署算法的不同阶段。我们还学习了如何使用大 O 符号来评估算法的性能。

下一章将讨论算法中使用的数据结构。我们将从 Python 中的数据结构开始,接着探讨如何利用这些数据结构创建更复杂的数据结构,例如栈、队列和树,这些都是开发复杂算法所需要的。

在 Discord 上了解更多

要加入本书的 Discord 社区——在这里你可以分享反馈、向作者提问并了解新版本——请扫描下面的二维码:

packt.link/WHLel

第二章:算法中使用的数据结构

算法在执行过程中需要内存数据结构来存储临时数据。选择合适的数据结构对于其高效实现至关重要。某些类别的算法逻辑是递归或迭代的,需要专门为它们设计的数据结构。例如,如果使用嵌套数据结构,递归算法可能会更容易实现,并展现更好的性能。本章讨论了数据结构在算法中的应用。由于本书使用 Python,因此本章重点介绍 Python 数据结构,但本章中介绍的概念也适用于 Java 和 C++ 等其他语言。

本章结束时,你应该能够理解 Python 如何处理复杂数据结构,以及应为某种类型的数据选择使用哪种数据结构。

本章讨论的主要内容如下:

  • 探索 Python 内置数据类型

  • 使用 Series 和 DataFrames

  • 探索矩阵和矩阵运算

  • 理解抽象数据类型

探索 Python 内置数据类型

在任何语言中,数据结构用于存储和操作复杂数据。在 Python 中,数据结构是用于高效管理、组织和查找数据的存储容器。它们用于存储一组称为集合的数据元素,这些数据元素需要一起存储和处理。在 Python 中,用于存储集合的主要数据结构总结如下表 2.1

数据结构 简要说明 示例
列表 有序的、可能嵌套的、可变的元素序列 ["John", 33,"Toronto", True]
元组 有序的不可变元素序列 ('Red','Green','Blue','Yellow')
字典 无序的键值对集合 {'brand': 'Apple', 'color': 'black'}
集合 无序的元素集合 {'a', 'b', 'c'}

表 2.1:Python 数据结构

让我们在接下来的子章节中更详细地了解它们。

列表

在 Python 中,列表是用于存储可变元素序列的主要数据类型。列表中存储的元素序列不必是相同类型的。

可以通过将元素放入 [ ] 中来定义列表,元素之间需要用逗号分隔。例如,以下代码将创建四个不同类型的元素:

list_a = ["John", 33,"Toronto", True]
print(list_a) 
['John', 33, 'Toronto', True] 

在 Python 中,列表是创建一维可写数据结构的便捷方式,这在算法的不同内部阶段尤其需要。

使用列表

数据结构中的实用函数使其非常有用,因为它们可以用来管理列表中的数据。

让我们来看一下如何使用它们:

  • 列表索引:由于列表中元素的位置是确定的,因此可以使用索引来获取特定位置的元素。以下代码演示了这个概念:

    bin_colors=['Red','Green','Blue','Yellow'] 
    

    由这段代码创建的四元素列表显示在图 2.1中:

    图表 描述自动生成

    图 2.1:Python 中的四元素列表

    现在,我们将运行以下代码:

    bin_colors[1] 
    
    'Green' 
    

    请注意,Python 是一个零索引语言。这意味着任何数据结构(包括列表)的初始索引为 0Green,即第二个元素,可以通过索引 1 获取——即 bin_colors[1]

  • 列表切片:通过指定索引范围来获取列表的子集,称为 切片。可以使用以下代码创建列表的切片:

    bin_colors[0:2] 
    
    ['Red', 'Green'] 
    

    请注意,列表是 Python 中最流行的单维数据结构之一。

    在切片列表时,范围表示如下:第一个数字(包含)和第二个数字(不包含)。例如,bin_colors[0:2] 会包括 bin_color[0]bin_color[1],但不包括 bin_color[2]。在使用列表时,应该记住这一点,因为一些 Python 用户抱怨这不太直观。

    让我们来看一下以下代码片段:

    bin_colors=['Red','Green','Blue','Yellow']
    bin_colors[2:] 
    
    ['Blue', 'Yellow'] 
    
    bin_colors[:2] 
    
    ['Red', 'Green'] 
    

    如果没有指定起始索引,则表示列表的开头;如果没有指定结束索引,则表示列表的结尾,正如前面的代码所示。

  • 负索引:在 Python 中,我们也可以使用负索引,它是从列表的末尾开始计数的。以下代码演示了这一点:

    bin_colors=['Red','Green','Blue','Yellow']
    bin_colors[:-1] 
    
    ['Red', 'Green', 'Blue'] 
    
    bin_colors[:-2] 
    
    ['Red', 'Green'] 
    
    bin_colors[-2:-1] 
    
    ['Blue'] 
    

    请注意,负索引在我们想要使用最后一个元素作为参考点而不是第一个元素时尤其有用。

  • 嵌套:列表中的元素可以是任何数据类型。这允许列表中嵌套其他列表。对于迭代和递归算法,这提供了重要的功能。

    让我们来看一下以下代码,这是一个包含列表中的列表(嵌套)示例:

    a = [1,2,[100,200,300],6]
    max(a[2]) 
    
    300 
    
    a[2][1] 
    
    200 
    
  • 迭代:Python 允许通过 for 循环对列表中的每个元素进行迭代。以下示例演示了这一点:

    for color_a in bin_colors:
        print(color_a + " Square") 
    
    Red Square 
    Green Square 
    Blue Square 
    Yellow Square 
    

请注意,前面的代码通过列表迭代并打印每个元素。现在让我们使用 pop() 函数从栈中删除最后一个元素。

修改列表:append 和 pop 操作

让我们来看看修改一些列表,包括 append 和 pop 操作。

使用 append() 添加元素

当你想要在列表末尾插入新项时,可以使用 append() 方法。它的工作原理是将新元素添加到最近的可用内存位置。如果列表已满,Python 会扩展内存分配,在新开辟的空间中复制之前的项,然后插入新元素:

bin_colors = ['Red', 'Green', 'Blue', 'Yellow']
bin_colors.append('Purple')
print(bin_colors) 
['Red', 'Green', 'Blue', 'Yellow', 'Purple'] 

使用 pop() 删除元素

要从列表中提取元素,特别是最后一个元素,pop() 方法是一个方便的工具。调用该方法时,它会提取指定的项目(如果未给出索引,则提取最后一个项目)。被弹出项后面的元素会重新定位,以保持内存的连续性:

bin_colors.pop()
print(bin_colors) 
['Red', 'Green', 'Blue', 'Yellow'] 

range() 函数

range() 函数可以用来轻松生成大量数字列表。它被用来自动填充数字序列到列表中。

range() 函数使用起来非常简单。我们只需指定列表中所需的元素个数。默认情况下,它从零开始,并以 1 递增:

x = range(4)
for n in x:
  print(n) 
0 1 2 3 

我们还可以指定结束数字和步长:

odd_num = range(3,30,2)
for n in odd_num:
  print(n) 
3 5 7 9 11 13 15 17 19 21 23 25 27 29 

上述 range() 函数将返回从 329 的奇数。

要遍历列表,我们可以使用 for 函数:

for i in odd_num:
    print(i*100) 
300 500 700 900 1100 1300 1500 1700 1900 2100 2300 2500 2700 2900 

我们可以使用 range() 函数生成一个随机数字列表。例如,模拟十次掷骰子的实验可以使用以下代码:

import random
dice_output = [random.randint(1, 6) for x in range(10)]     
print(dice_output) 
[6, 6, 6, 6, 2, 4, 6, 5, 1, 4] 

列表的时间复杂度

列表各个函数的时间复杂度可以用大 O 符号总结如下:

  • 插入元素:在列表末尾插入一个元素通常具有常数时间复杂度,记作 O(1)。这意味着该操作所需的时间相对稳定,与列表的大小无关。

  • 删除元素:从列表中删除元素在最坏情况下的时间复杂度为 O(n)。这是因为在最不利的情况下,程序可能需要遍历整个列表才能删除目标元素。

  • 切片:当我们切割列表或提取其中一部分时,该操作可能需要的时间与切片的大小成正比;因此,它的时间复杂度是 O(n)

  • 元素检索:在没有索引的情况下查找列表中的元素,最坏情况下可能需要扫描所有元素。因此,它的时间复杂度也是 O(n)

  • 复制:创建列表的副本需要访问每个元素一次,导致时间复杂度为 O(n)

元组

另一个可以用来存储集合的数据结构是元组。与列表不同,元组是不可变(只读)的数据结构。元组由多个元素组成,这些元素被 ( ) 括起来。

和列表一样,元组中的元素可以是不同类型的。它们还允许元素是复杂数据类型。因此,可以在元组中嵌套元组,从而创建嵌套的数据结构。创建嵌套数据结构的能力在迭代和递归算法中尤其有用。

以下代码演示了如何创建元组:

bin_colors=('Red','Green','Blue','Yellow')
print(f"The second element of the tuple is {bin_colors[1]}") 
The second element of the tuple is Green 
print(f"The elements after third element onwards are {bin_colors[2:]}") 
The elements after third element onwards are ('Blue', 'Yellow') 
# Nested Tuple Data structure
nested_tuple = (1,2,(100,200,300),6)
print(f"The maximum value of the inner tuple {max(nested_tuple[2])}") 
The maximum value of the inner tuple 300 

在可能的情况下,应该优先选择不可变数据结构(如元组)而非可变数据结构(如列表),因为不可变数据结构的性能更好。尤其是在处理大数据时,不可变数据结构比可变数据结构要快得多。当一个数据结构作为不可变的传递给函数时,无需创建其副本,因为函数不能修改它。因此,输出可以直接引用输入数据结构。这被称为引用透明性,可以提高性能。我们为了能够修改列表中的数据元素,付出了代价,因此我们应仔细分析是否真的需要修改,以便将代码实现为只读元组,这样会更快。

请注意,由于 Python 是基于零索引的语言,a[2] 指的是第三个元素,它是一个元组 (100,200,300),而 a[2][1] 指的是该元组中的第二个元素,它是 200

元组的时间复杂度

元组各种函数的时间复杂度可以总结如下(使用大 O 表示法):

  • 访问元素:元组通过索引允许直接访问其元素。这个操作是常数时间,O(1),意味着无论元组的大小如何,所需的时间保持一致。

  • 切片:当提取或切片元组的一部分时,操作的效率与切片的大小成正比,结果的时间复杂度是 O(n)

  • 元素检索:在没有任何索引帮助的情况下,查找元组中的一个元素,在最坏的情况下可能需要遍历所有元素。因此,它的时间复杂度是 O(n)

  • 复制:复制一个元组,或者创建其副本,需要遍历每个元素一次,因此它的时间复杂度是 O(n)

字典与集合

在本节中,我们将讨论集合和字典,它们用于存储没有显式或隐式顺序的数据。字典和集合非常相似。区别在于字典包含键值对,而集合可以看作是一个唯一键的集合。

让我们逐一了解它们。

字典

将数据作为键值对存储非常重要,尤其是在分布式算法中。在 Python 中,这些键值对集合被存储为一种名为字典的数据结构。要创建字典,应该选择一个最适合在数据处理过程中标识数据的属性作为键。键值的限制是它们必须是可哈希类型。可哈希类型是可以运行哈希函数的对象类型,该函数生成的哈希码在对象生命周期内保持不变。这确保了键的唯一性,并且搜索键的速度很快。数值类型和扁平不可变类型都是可哈希的,是字典键的良好选择。值可以是任何类型的元素,例如数字或字符串。Python 还经常使用复杂数据类型,如列表,作为值。可以通过使用字典作为值的数据类型来创建嵌套字典。

要创建一个简单的字典,将颜色分配给各种变量,键值对需要用 { } 括起来。例如,以下代码创建了一个由三个键值对组成的简单字典:

bin_colors ={
  "manual_color": "Yellow",
  "approved_color": "Green",
  "refused_color": "Red"
}
print(bin_colors) 
{'manual_color': 'Yellow', 'approved_color': 'Green', 'refused_color': 'Red'} 

由前面的代码创建的三个键值对在下面的截图中也有展示:

Diagram  Description automatically generated

图 2.2:简单字典中的键值对

现在,让我们看看如何检索和更新与某个键相关联的值:

  1. 要检索与某个键相关联的值,可以使用get函数,或者将键作为索引:

    bin_colors.get('approved_color') 
    
    'Green' 
    
    bin_colors['approved_color'] 
    
    'Green' 
    
  2. 要更新与某个键相关联的值,使用以下代码:

    bin_colors['approved_color']="Purple"
    print(bin_colors) 
    
    {'manual_color': 'Yellow', 'approved_color': 'Purple', 'refused_color': 'Red'} 
    

请注意,前面的代码展示了如何更新字典中与特定键相关联的值。

当遍历字典时,通常我们需要同时获取键和值。我们可以通过使用.items()来遍历字典:

for k,v in bin_colors.items():
    print(k,'->',v+' color') 
manual_color -> Yellow color 
approved_color -> Purple color 
refused_color -> Red color 

要从字典中del一个元素,我们将使用del函数:

del bin_colors['approved_color']
print(bin_colors) 
{'manual_color': 'Yellow', 'refused_color': 'Red'} 

字典的时间复杂度

对于 Python 字典,各种操作的时间复杂度如下:

  • 通过键访问值:字典设计用于快速查找。当你有了键,访问对应的值通常是一个常数时间操作,O(1)。除非发生哈希冲突,这是一种罕见的情况,否则通常成立。

  • 插入键值对:添加一个新的键值对通常是一个快速的操作,时间复杂度为O(1)

  • 删除键值对:当已知键时,从字典中移除条目,平均来说也是一个O(1)操作。

  • 查找键:由于哈希机制,验证键的存在通常是一个常数时间O(1)操作。然而,最坏情况下可能会将其提高到O(n),特别是在有大量哈希冲突时。

  • 复制:创建字典的副本需要遍历每个键值对,因此时间复杂度为线性O(n)

集合

与字典紧密相关的是集合,集合被定义为一个无序的、由不同类型的元素组成的独特元素集合。定义集合的一种方法是将值包裹在{ }中。例如,请看下面的代码块:

green = {'grass', 'leaves'}
print(green) 
{'leaves', 'grass'} 

集合的定义特征是它只存储每个元素的唯一值。如果我们试图添加另一个冗余元素,集合会忽略它,如下所示:

green = {'grass', 'leaves','leaves'}
print(green) 
{'leaves', 'grass'} 

为了演示可以对集合进行哪些操作,让我们定义两个集合:

  • 一个名为yellow的集合,包含所有黄色的元素

  • 一个名为red的集合,包含所有红色的元素

请注意,这两个集合之间有一些共同的元素。这两个集合及其关系可以通过以下 Venn 图来表示:

图示,Venn 图  描述自动生成

图 2.3:展示元素如何存储在集合中的 Venn 图

如果我们想在 Python 中实现这两个集合,代码将如下所示:

yellow = {'dandelions', 'fire hydrant', 'leaves'}
red = {'fire hydrant', 'blood', 'rose', 'leaves'} 

现在,让我们考虑以下代码,展示了如何使用 Python 进行集合操作:

print(f"The union of yellow and red sets is {yellow|red}") 
The union of yellow and red sets is {leaves, blood, dandelions, fire hydrant, rose} 
print(f"The intersection of yellow and red is {yellow&red}") 
The intersection of yellow and red is {'fire hydrant', 'leaves'} 

如前面的代码片段所示,Python 中的集合可以执行如并集和交集等操作。我们知道,合并操作将两个集合中的所有元素合并,而交集操作将返回两个集合之间的共同元素。注意以下几点:

  • yellow|red用于获取前面定义的两个集合的并集。

  • yellow&red用于获取黄色和红色之间的重叠部分。

由于集合是无序的,集合的元素没有索引。这意味着我们不能通过索引来访问元素。

我们可以使用for循环遍历集合中的元素:

for x in yellow:
    print(x) 
fire hydrant 
leaves 
dandelions 

我们还可以通过使用in关键字检查指定的值是否存在于集合中。

print("leaves" in yellow) 
True 

集合的时间复杂度分析

以下是集合的时间复杂度分析:

集合 复杂度
添加一个元素 O(1)
移除一个元素 O(1)
复制 O(n)

表 2.2:集合的时间复杂度

什么时候使用字典,什么时候使用集合

假设我们正在寻找一个数据结构来存储我们的电话簿。我们希望存储公司员工的电话号码。为此,字典是正确的数据结构。每个员工的名字将是键,值将是电话号码:

employees_dict = {
    "Ikrema Hamza": "555-555-5555",
    "Joyce Doston" : "212-555-5555",
} 

但如果我们只想存储员工的唯一值,那么应该使用集合来完成:

employees_set = {
    "Ikrema Hamza",
    "Joyce Doston"
} 

使用 Series 和 DataFrame

处理数据是实现大多数算法时需要做的核心工作之一。在 Python 中,数据处理通常通过使用pandas库的各种函数和数据结构来完成。

在本节中,我们将深入了解 pandas 库中的以下两个重要数据结构,这些数据结构将在本书后续部分中用于实现各种算法:

  • Series:一维数组

  • 数据框(DataFrame):一种二维数据结构,用于存储表格数据

让我们首先了解序列数据结构。

序列(Series)

pandas库中,序列(Series)是一个一维的同质数据数组。我们可以将序列视为电子表格中的一列。我们可以认为序列保存了某一变量的各种值。

序列(Series)可以按如下方式定义:

import pandas as pd
person_1 = pd.Series(['John',"Male",33,True])
print(person_1) 
0    John 
1    Male 
2    33 
3    True 
dtype:    object 

请注意,在pandas基于序列的数据结构中,有一个术语叫做“轴(axis)”,它表示某一维度中的值的序列。Series 只有“轴 0”,因为它只有一个维度。在下一节中,我们将看到这个轴的概念如何应用于数据框(DataFrame)。

数据框(DataFrame)

数据框(DataFrame)是基于序列数据结构构建的。它以二维表格数据形式存储,用于处理传统的结构化数据。我们来考虑一下以下表格:

id name age decision
1 Fares 32 True
2 Elena 23 False
3 Doug 40 True

现在,让我们使用数据框来表示这个数据。

可以通过以下代码创建一个简单的数据框:

employees_df = pd.DataFrame([
    ['1', 'Fares', 32, True],
    ['2', 'Elena', 23, False],
    ['3', 'Doug', 40, True]])
employees_df.columns = ['id', 'name', 'age', 'decision']
print(employees_df) 
 id    name    age    decision
0    1    Fares    32    True
1    2    Elena    23    False
2    3    Doug    40    True 

请注意,在前面的代码中,df.column是一个列出列名的列表。在数据框中,单独的一列或一行称为轴(axis)。

数据框(DataFrame)也在其他流行的编程语言和框架中用于实现表格数据结构。例如,R 语言和 Apache Spark 框架。

创建数据框(DataFrame)的子集

从根本上讲,创建数据框子集的方式有两种主要方法:

  • 列选择

  • 行选择

让我们逐一了解它们。

列选择

在机器学习算法中,选择合适的特征集是一个重要的任务。在我们可能拥有的所有特征中,并不是每一个在算法的某个特定阶段都是必要的。在 Python 中,特征选择是通过列选择来实现的,这一点将在本节中讲解。

可以通过name来获取某一列,如下所示:

df[['name','age']] 
 name    age
0    Fares    32
1    Elena    23
2    Doug     40 

数据框中列的位置是确定的。可以通过位置来获取列,如下所示:

df.iloc[:,3] 
0    True 
1    False 
2    True 
Name: decision, dtype: bool 

请注意,在这段代码中,我们正在获取数据框的所有行。

行选择

数据框中的每一行对应我们问题空间中的一个数据点。如果我们想创建问题空间中的数据元素子集,就需要进行行选择。这个子集可以通过以下两种方法之一来创建:

  • 通过指定它们的位置

  • 通过指定过滤条件

可以通过行的位置来获取子集,方法如下:

df.iloc[1:3,:] 
 id    name    age    decision
1    2    Elena    23    False 

请注意,前面的代码将返回第二行和第三行以及所有列。它使用iloc方法,可以通过数值索引访问元素。

要通过指定过滤条件来创建子集,我们需要使用一个或多个列来定义选择标准。例如,可以通过以下方法选择数据元素的子集:

df[df.age>30] 
 id    name    age    decision
0    1    Fares    32    True
2    3    Doug     40    True 
df[(df.age<35)&(df.decision==True)]    id    name    age    decision 
 id    name    age    decision
0    1    Fares    32    True 

请注意,这段代码创建了一个符合筛选条件的行子集。

集合的时间复杂度分析

让我们揭示一些基础 DataFrame 操作的时间复杂度。

  • 选择操作

    • 选择列:访问 DataFrame 列,通常使用括号符号或点符号(对于没有空格的列名)进行,是一个 O(1) 操作。它提供了对数据的快速引用,而无需复制。

    • 选择行:使用 .loc[].iloc[] 等方法选择行,特别是在切片的情况下,时间复杂度是 O(n),其中“n”代表你正在访问的行数。

  • 插入操作

    • 插入列:向 DataFrame 添加新列通常是一个 O(1) 操作。然而,实际时间可能会根据数据类型和添加的数据大小而有所不同。

    • 插入行:使用 .append().concat() 等方法添加行可能会导致 O(n) 复杂度,因为它通常需要重新排列和重新分配内存。

  • 删除操作

    • 删除列:通过 .drop() 方法从 DataFrame 中删除一列是一个 O(1) 操作。它标记该列为垃圾回收对象,而不是立即删除。

    • 删除行:与插入行类似,删除行也可能导致 O(n) 时间复杂度,因为 DataFrame 需要重新排列其结构。

矩阵

矩阵是一个具有固定列数和行数的二维数据结构。矩阵的每个元素可以通过其列和行来引用。

在 Python 中,可以通过使用 numpy 数组或列表来创建矩阵。但 numpy 数组比列表要快得多,因为它们是位于连续内存位置的同质数据元素集合。以下代码可以用来从 numpy 数组创建矩阵:

import numpy as np
matrix_1 = np.array([[11, 12, 13], [21, 22, 23], [31, 32, 33]])
print(matrix_1) 
[[11 12 13] 
 [21 22 23] 
 [31 32 33]] 
print(type(matrix_1)) 
<class 'numpy.ndarray'> 

请注意,前面的代码将创建一个具有三行三列的矩阵。

矩阵操作

有许多可用于矩阵数据操作的操作。例如,我们尝试转置前面的矩阵。我们将使用 transpose() 函数,该函数将列转换为行,行转换为列:

print(matrix_1.transpose()) 
array([[11, 21, 31], 
       [12, 22, 32], 
       [13, 23, 33]]) 

请注意,矩阵操作在多媒体数据处理过程中被广泛使用。

大 O 符号与矩阵

在讨论操作的效率时,大 O 符号提供了对数据规模扩展时影响的高层次理解:

  • 访问:无论是在 Python 列表还是 numpy 数组中,访问元素都是一个常数时间操作,O(1)。这是因为,通过元素的索引,你可以直接访问它。

  • 追加:将元素追加到 Python 列表的末尾是一个平均情况下的 O(1) 操作。然而,对于 numpy 数组,在最坏情况下,这个操作可能是 O(n),因为如果没有足够的连续空间,整个数组可能需要被复制到新的内存位置。

  • 矩阵乘法:这是 numpy 的强项。矩阵乘法可能会非常耗费计算资源。传统方法对于 n x n 矩阵的时间复杂度为 O(n³)。然而,numpy 使用了优化算法,如 Strassen 算法,这大大降低了计算复杂度。

现在我们已经学习了 Python 中的数据结构,接下来让我们在下一部分讨论抽象数据类型。

探索抽象数据类型

抽象数据类型ADT)是通过一组变量和一组相关操作来定义行为的高级抽象。ADT 定义了“需要期待什么”的实现指导,但给程序员在“如何实现”的细节上提供自由。例如,向量、队列和栈就是 ADT。意味着两个不同的程序员可以采用不同的方式来实现一个 ADT,比如栈。通过隐藏实现细节,并给用户提供一个通用、与实现无关的数据结构,ADT 的使用可以创建出更简洁和更清晰的代码。ADT 可以在任何编程语言中实现,如 C++、Java 和 Scala。本节中,我们将使用 Python 实现 ADT。让我们首先从向量开始。

向量

向量是存储数据的单一维度结构。它们是 Python 中最常见的数据结构之一。创建向量有两种方法,如下所示:

  • 使用 Python 列表:创建一个向量的最简单方法是使用 Python 列表,如下所示:

    vector_1 = [22,33,44,55]
    print(vector_1) 
    
    [22, 33, 44, 55] 
    
    print(type(vector_1)) 
    
    <class 'list'> 
    

请注意,这段代码将创建一个包含四个元素的列表。

  • 使用 numpy 数组:另一种创建向量的流行方法是使用 numpy 数组。numpy 数组通常比 Python 列表更快,且内存效率更高,尤其是在处理大量数据的操作时。这是因为 numpy 设计上是为了处理同质数据,并且可以利用底层优化。numpy 数组可以通过如下方式实现:

    vector_2 = np.array([22,33,44,55])
    print(vector_2) 
    
    [22 33 44 55] 
    
    print(type(vector_2)) 
    
    <class 'numpy.ndarray'> 
    

请注意,我们在这段代码中使用 np.array 创建了 myVector

在 Python 中,我们可以使用下划线来分隔整数的各个部分。这使得它们更易于阅读并且减少了出错的可能性,特别是在处理大数字时。这对于表示十亿非常有用,可以写作 1000_000_000

large_number=1000_000_000
print(large_number) 
1000000000 

向量的时间复杂度

在讨论向量操作的效率时,理解时间复杂度是至关重要的:

  • 访问:在 Python 列表和 numpy 数组(向量)中访问元素都需要常量时间 O(1)。这确保了数据的快速检索。

  • 追加:向 Python 列表追加一个元素的平均时间复杂度为 O(1)。然而,对于 numpy 数组,追加操作在最坏情况下可能需要 O(n),因为 numpy 数组要求内存位置是连续的。

  • 搜索:在向量中查找一个元素的时间复杂度是 O(n),因为在最坏情况下,你可能需要遍历所有元素。

栈是一种线性数据结构,用于存储一维列表。它可以以 后进先出LIFO)或 先进后出FILO)的方式存储元素。栈的定义特征是元素的添加和移除方式。新元素添加到一端,并且元素只能从这一端移除。

以下是与栈相关的操作:

  • isEmpty:如果栈为空,返回true

  • push:添加一个新元素

  • pop:返回最近添加的元素并将其移除

图 2.4 显示了如何使用 pushpop 操作向栈中添加和移除数据:

图示 描述自动生成

图 2.4:Push 和 Pop 操作

图 2.4 的顶部显示了使用 push 操作向栈中添加元素的过程。在步骤 1.11.21.3 中,使用了三次 push 操作将三个元素添加到栈中。前面图形的底部用于从栈中检索存储的值。在步骤 2.22.3 中,使用了两次 pop 操作以 LIFO 格式从栈中取出两个元素。

我们在 Python 中创建一个名为Stack的类,在其中定义所有与Stack类相关的操作。该类的代码如下所示:

class Stack:
     def __init__(self):
         self.items = []
     def isEmpty(self):
         return self.items == []
     def push(self, item):
         self.items.append(item)
     def pop(self):
         return self.items.pop()
     def peek(self):
         return self.items[len(self.items)-1]
     def size(self):
         return len(self.items) 

要将四个元素压入栈中,可以使用以下代码:

Populate the stack
stack=Stack()
stack.push('Red')
stack.push('Green')
stack.push("Blue")
stack.push("Yellow") 

请注意,上述代码创建了一个包含四个数据元素的栈:

Pop
stack.pop()
stack.isEmpty() 

栈操作的时间复杂度

让我们看看栈操作的时间复杂度:

  • Push:该操作将一个元素添加到栈的顶部。由于不涉及任何迭代或检查,push 操作的时间复杂度为 O(1),即常数时间。无论栈的大小如何,元素都会被放置在顶部。

  • Pop:出栈指的是从栈中移除顶部元素。由于不需要与栈中的其余元素交互,pop 操作的时间复杂度为O(1)。这是一个直接作用于顶部元素的操作。

实际示例

栈是许多使用案例中的数据结构。例如,当用户想要浏览网页浏览器中的历史记录时,它采用的是 LIFO 数据访问模式,栈可以用来存储历史记录。另一个例子是当用户想在文字处理软件中执行撤销操作时。

队列

与栈类似,队列在一维结构中存储 n 个元素。元素按 FIFO 格式添加和移除。队列的一端称为 rear(后端),另一端称为 front(前端)。当从前端移除元素时,该操作称为 dequeue。当在后端添加元素时,该操作称为 enqueue

在下图中,顶部显示了入队操作。步骤 1.11.21.3 将三个元素添加到队列中,结果队列如1.4所示。请注意,黄色rear红色front

以下图的底部部分展示了dequeue操作。步骤 2.22.32.4 将队列中的元素一个接一个地从队列前端移除:

一个包含图表的图片,描述自动生成

图 2.5:入队和出队操作

前面图示的队列可以通过以下代码实现:

class Queue(object):
   def __init__(self):
      self.items = []
   def isEmpty(self):
      return self.items == []
   def enqueue(self, item):
       self.items.insert(0,item)
   def dequeue(self):
      return self.items.pop()
   def size(self):
      return len(self.items) 

让我们通过以下代码,结合前面的图示,来进行入队和出队操作:

# Using Queue
queue = Queue()
queue.enqueue("Red")
queue.enqueue('Green')
queue.enqueue('Blue')
queue.enqueue('Yellow')
print(f"Size of queue is {queue.size()}") 
Size of queue is 4 
print(queue.dequeue()) 
Red 

请注意,上述代码首先创建了一个队列,然后将四个项依次入队。

队列的时间复杂度分析

让我们来看看队列的时间复杂度:

  • 入队(Enqueue):此操作将一个元素插入队列的末尾。由于其简单明了,无需迭代或遍历,enqueue操作的时间复杂度为O(1) —— 恒定时间。

  • 出队(Dequeue):出队是指从队列中移除最前面的元素。由于该操作只涉及队列中的第一个元素,且无需检查或遍历队列,因此其时间复杂度仍保持为O(1)

使用栈和队列的基本思想

让我们通过一个类比来理解栈和队列的基本思想。假设我们有一张桌子,放着我们从邮政服务(例如加拿大邮政)收到的邮件。我们将它们堆放在一起,直到有时间一封封地打开并查看这些信件。有两种可能的做法:

  • 我们将信件放入栈中,每当有新信件到达时,我们将其放在栈的顶部。当我们想读取一封信时,我们从顶部开始。这就是我们所说的。请注意,最新到达的信件会在栈顶,并会优先处理。将信件从栈顶取出称为pop操作。每当有新信件到达,将其放在栈顶称为push操作。如果我们最终堆积了大量信件,并且许多信件不断到达,就有可能永远无法处理到堆栈底部那封非常重要的信件。

  • 我们将信件放入堆中,但我们想先处理最旧的信件;每次我们想查看一封或多封信时,我们会优先处理最旧的那一封。这就是我们所说的队列。将信件添加到堆中的操作称为enqueue操作。将信件从堆中移除的操作称为dequeue操作。

在算法的上下文中,树是最有用的数据结构之一,因为它具有分层数据存储的能力。在设计算法时,我们在需要表示数据元素之间的层级关系时会使用树。

让我们深入了解这个既有趣又非常重要的数据结构。

每棵树都有一个有限的节点集合,它有一个称为根节点的起始数据元素,节点之间通过链接连接在一起,称为分支

术语

让我们来看看与树数据结构相关的一些术语:

根节点 没有父节点的节点称为根节点。例如,在下图中,根节点是 A。在算法中,通常根节点保存着树结构中最重要的值。
节点的层级 节点的层级是指从根节点到该节点的距离。例如,在下图中,节点 D、E 和 F 的层级为二。
兄弟节点 树中两个节点如果处于同一层级,则称它们为兄弟节点。例如,在下图中,节点 B 和 C 是兄弟节点。
子节点与父节点 如果节点 F 和节点 C 直接相连,并且节点 C 的层级低于节点 F,则节点 F 是节点 C 的子节点。反之,节点 C 是节点 F 的父节点。下图中,节点 C 和 F 展示了这种父子关系。
节点的度数 节点的度数是指它有多少个子节点。例如,在下图中,节点 B 的度数为二。
树的度数 树的度数等于树中各个节点度数的最大值。例如,下图所示的树的度数为二。
子树 树的子树是树的一个部分,选定节点作为子树的根节点,所有子节点作为树的节点。例如,在下图中,树的节点 E 上的子树由节点 E 作为根节点,节点 G 和 H 作为两个子节点组成。
叶子节点 树中没有子节点的节点称为叶子节点。例如,在下图中,节点 D、G、H 和 F 是四个叶子节点。
内部节点 任何既不是根节点也不是叶子节点的节点称为内部节点。内部节点至少有一个父节点和一个子节点。

请注意,树是一种我们将在第六章无监督机器学习算法中研究的网络或图的类型。对于图和网络分析,我们使用链接来代替分支。其他大多数术语保持不变。

树的类型

有不同类型的树,下面解释了它们:

  • 二叉树:如果树的度数为二,则该树称为二叉树。例如,下图所示的树就是一棵二叉树,因为它的度数为二:

Diagram Description automatically generated

图 2.6:一棵二叉树

请注意,上面的图显示了一棵有四层、八个节点的树。

  • 完全树:完全树是指所有节点的度数相同,并且等于树的度数。下图显示了前面讨论的几种树:

图示  描述自动生成

图 2.7:完全树

注意,左侧的二叉树不是完全树,因为 C 节点的度为 1,而其他节点的度为 2。中间和右侧的树都是完全树。

  • 完美树:完美树是一种特殊的完全树,其中所有的叶节点位于同一层级。例如,前面图示中的右侧二叉树是一个完美的完全树,因为所有的叶节点都在同一层级——也就是第 2 层

  • 有序树:如果一个节点的子节点按照特定标准排列顺序,则该树被称为有序树。例如,树可以按从左到右的升序排列,其中同一层级的节点值在从左到右遍历时会逐渐增大。

实际例子

ADT 树是用于开发决策树的主要数据结构之一,正如在第七章《传统监督学习算法》中将讨论的那样。由于其层级结构,它在与网络分析相关的算法中也非常流行,正如在第六章《无监督机器学习算法》中将详细讨论的那样。树还用于各种搜索和排序算法中,在这些算法中需要实现分治策略。

总结

在这一章中,我们讨论了可以用来实现各种类型算法的数据结构。读完本章后,你应该能够选择合适的数据结构来存储和处理数据,并与算法结合使用。你还应该能够理解我们选择的数据结构对算法性能的影响。

下一章将介绍排序和查找算法,在这一章中,我们将使用本章中介绍的一些数据结构来实现这些算法。

在 Discord 上了解更多

要加入本书的 Discord 社区——在这里你可以分享反馈、向作者提问、并了解新版本的发布——请扫描下面的二维码:

packt.link/WHLel

第三章:排序和搜索算法

在本章中,我们将探讨用于排序和搜索的算法。这是一类重要的算法,可以单独使用,也可以成为更复杂算法的基础。这些包括自然语言处理NLP)和模式提取算法。本章首先介绍了不同类型的排序算法。它比较了设计排序算法的各种方法的性能。然后,详细介绍了一些搜索算法。最后,研究了本章中介绍的排序和搜索算法的一个实际例子。

到本章结束时,我们应该能够理解用于排序和搜索的各种算法,并能够理解它们的优缺点。由于搜索和排序算法是许多复杂算法的基础,详细了解它们将有助于我们更好地理解现代复杂算法,正如后面章节中所介绍的那样。

以下是本章讨论的主要概念:

  • 介绍排序算法

  • 介绍搜索算法

  • 排序和搜索算法的性能分析

  • 排序和搜索的实际应用

让我们首先看一些排序算法。

介绍排序算法

在复杂数据结构中高效地排序和搜索项目的能力非常重要,因为许多现代算法都需要这样的功能。正确的排序和搜索数据的策略将取决于数据的大小和类型,正如本章中所讨论的那样。虽然最终结果完全相同,但需要正确的排序和搜索算法才能为实际问题提供高效的解决方案。因此,仔细分析这些算法的性能非常重要。

排序算法广泛应用于分布式数据存储系统,如现代 NoSQL 数据库,这些数据库支持集群和云计算架构。在这种数据存储系统中,数据元素需要定期排序和存储,以便能够有效地检索。

本章介绍了以下排序算法:

  • 冒泡排序

  • 归并排序

  • 插入排序

  • 希尔排序

  • 选择排序

但在我们深入研究这些算法之前,让我们先讨论 Python 中的变量交换技术,在本章中我们将在代码中使用它。

在 Python 中交换变量

在实现排序和搜索算法时,我们需要交换两个变量的值。在 Python 中,有一种标准的方法来交换两个变量的值,如下所示:

var_1 = 1
var_2 = 2
var_1, var_2 = var_2, var_1
print(var_1,var_2) 
2, 1 

这种简单的交换值的方法在本章的排序和搜索算法中被广泛使用。

让我们从下一节开始看冒泡排序算法。

冒泡排序

冒泡排序是最简单也是最慢的排序算法之一。它的设计方式使得数据列表中最大的值在每次迭代中逐渐冒泡到列表的顶部。冒泡排序需要的运行时内存很少,因为所有的排序操作都发生在原始数据结构中。它不需要新的数据结构作为临时缓冲区。但其最坏情况性能是O(N²),即二次时间复杂度(其中N为待排序元素的数量)。如下一节所讨论的,它建议仅用于较小的数据集。冒泡排序的实际推荐数据大小限制将取决于可用的内存和处理资源,但通常建议将元素数量(N)控制在 1000 以下。

理解冒泡排序背后的逻辑

冒泡排序基于多次迭代,称为遍历。对于一个大小为N的列表,冒泡排序将有N-1次遍历。为了理解其工作原理,我们将专注于第一次迭代:第一次遍历。

第一次遍历的目标是将最大值推到列表的最高索引位置(顶部)。换句话说,随着第一次遍历的进行,我们将看到列表中的最大值逐渐冒泡到顶部。

冒泡排序的逻辑基于比较相邻的邻居值。如果较高索引位置的值大于较低索引位置的值,我们就交换它们。这一迭代会一直进行,直到遍历到列表的末尾。该过程如图 3.1所示:

一张包含文本的图片,电子产品  自动生成的描述

图 3.1:冒泡排序算法

现在我们来看一下如何使用 Python 实现冒泡排序。如果我们在 Python 中实现冒泡排序的第一次遍历,它将如下所示:

list = [25,21,22,24,23,27,26]
last_element_index = len(list)-1
print(0,list)
for idx in range(last_element_index):
                if list[idx]>list[idx+1]:
                    list[idx],list[idx+1]=list[idx+1],list[idx]
                print(idx+1,list) 
0 [25, 21, 22, 24, 23, 27, 26]
1 [21, 25, 22, 24, 23, 27, 26]
2 [21, 22, 25, 24, 23, 27, 26]
3 [21, 22, 24, 25, 23, 27, 26]
4 [21, 22, 24, 23, 25, 27, 26]
5 [21, 22, 24, 23, 25, 27, 26]
6 [21, 22, 24, 23, 25, 26, 27] 

注意,在第一次遍历后:

  • 最大值位于列表的顶部,存储在idx+1位置。

  • 在执行第一次遍历时,算法必须单独比较列表中的每个元素,以冒泡出最大值并将其移至顶部。

完成第一次遍历后,算法进入到第二次遍历。第二次遍历的目标是将第二大值移动到列表中的第二大索引位置。为了实现这一点,算法将再次比较相邻的邻居值,如果它们的顺序不正确,则交换它们。第二次遍历将排除已经由第一次遍历放置到正确位置的顶部索引值。因此,它需要处理的数据元素将少一个。

完成第二次遍历后,算法将继续进行第三次遍历及其后续遍历,直到列表中的所有数据点都按升序排列。对于一个大小为N的列表,算法需要进行N-1次遍历才能完全排序。

[21, 22, 24, 23, 25, 26, 27] 

我们提到过性能是冒泡排序算法的一个限制。接下来,我们将通过对冒泡排序算法的性能分析来量化其性能:

def bubble_sort(list):
# Exchange the elements to arrange in order
    last_element_index = len(list)-1
    for pass_no in range(last_element_index,0,-1):
        for idx in range(pass_no):
            if list[idx]>list[idx+1]:
                list[idx],list[idx+1]=list[idx+1],list[idx]
    return list
list = [25,21,22,24,23,27,26]
bubble_sort(list) 
[21, 22, 23, 24, 25, 26, 27] 

优化冒泡排序

上述使用bubble_sort函数实现的冒泡排序是一种直接的排序方法,其中相邻元素不断被比较,并在顺序错误时进行交换。该算法在最坏情况下始终需要进行O(N2)次比较和交换,其中N是列表中的元素数量。这是因为,对于N个元素的列表,算法无论初始顺序如何,总是需要进行N-1次遍历。

以下是优化版本的冒泡排序:

def optimized_bubble_sort(list):
    last_element_index = len(list)-1
    for pass_no in range(last_element_index, 0, -1):
        swapped = False
        for idx in range(pass_no):
            if list[idx] > list[idx+1]:
                list[idx], list[idx+1] = list[idx+1], list[idx]
                swapped = True
        if not swapped:
            break
    return list
list = [25,21,22,24,23,27,26]
optimized_bubble_sort(list) 
[21, 22, 23, 24, 25, 26, 27] 

optimized_bubble_sort函数对冒泡排序算法的性能进行了显著的优化。通过引入一个swapped标志,这种优化使得算法可以在完成所有N-1次遍历之前,提前检测到列表是否已经排序。当一次遍历没有任何交换时,它就表明列表已经排序,算法可以提前退出。因此,尽管对于完全无序或反向排序的列表,最坏情况下时间复杂度仍为O(N2),但由于这种优化,最佳情况下已经排序的列表的时间复杂度提高到了O(N)

本质上,虽然这两个函数在最坏情况下的时间复杂度都是O(N2),但是optimized_bubble_sort在实际应用中可能表现得更快,特别是在数据部分已排序的情况下,使其成为传统冒泡排序算法的更高效版本。

冒泡排序算法的性能分析

很容易看出,冒泡排序包含两层循环:

  • 外层循环:这些也被称为遍历。例如,第一次遍历是外层循环的第一次迭代。

  • 内层循环:这是指在列表中剩余的未排序元素被排序,直到最大值被冒泡到右侧。第一次遍历将有N-1次比较,第二次遍历将有N-2次比较,每次遍历都会减少一次比较的数量。

冒泡排序算法的时间复杂度如下:

  • 最佳情况:如果列表已经排序(或者几乎所有元素都已排序),那么运行时复杂度为O(1)

  • 最坏情况:如果没有元素或只有很少的元素被排序,那么最坏情况下的运行时复杂度为O(n2),因为算法将不得不完全执行内层和外层循环。

现在让我们来看看插入排序算法。

插入排序

插入排序的基本思想是在每次迭代中,我们从已有的数据结构中移除一个数据点,然后将其插入到正确的位置。这就是我们称之为插入排序算法的原因。

在第一次迭代中,我们选择两个数据点并对它们进行排序。然后,我们扩展选择范围,选择第三个数据点并根据其值找到它的正确位置。算法会继续进行,直到所有数据点都被移动到正确的位置。

这个过程如下面的图示所示:

A picture containing table  Description automatically generated

图 3.2:插入排序算法

插入排序算法可以通过以下方式在 Python 中实现:

def insertion_sort(elements):
    for i in range(1, len(elements)):
        j = i - 1
        next_element = elements[i]
        # Iterate backward through the sorted portion, 
        # looking for the appropriate position for 'next_element'
        while j >= 0 and elements[j] > next_element:
            elements[j + 1] = elements[j]
            j -= 1
        elements[j + 1] = next_element
    return elements
list = [25,21,22,24,23,27,26]
insertion_sort(list) 
[21, 22, 23, 24, 25, 26, 27] 

在算法的核心循环中,我们从第二个元素(索引为1)开始遍历列表中的每个元素。对于每个元素,算法检查前面的元素,找出它们在已排序子列表中的正确位置。这个检查是在条件elements[j] > next_element中进行的,确保我们将当前的‘next_element'放置在列表已排序部分的适当位置。

让我们来看看插入排序算法的性能。

插入排序算法的性能分析

理解算法的效率对于判断其适用于不同应用场景至关重要。让我们深入探讨插入排序的性能特点。

最佳情况

当输入数据已经排序时,插入排序表现最佳。在这种情况下,算法高效地在线性时间内运行,表示为O(n),其中n表示数据结构中的元素数量。

最坏情况

当输入数据按逆序排列时,效率会受到影响,即最大元素位于开头。在这种情况下,对于每个元素i(其中i表示当前元素在循环中的索引),内层循环可能需要移动几乎所有前面的元素。插入排序在这种情况下的性能可以通过一个二次函数表示,形式如下:

其中:

  • w是一个权重因子,用于调整i²的影响。

  • N表示一个随着输入规模增长的系数。

  • 是一个常量,通常表示其他项未涵盖的少量开销。

平均情况

通常,插入排序的平均性能趋向于二次,面对较大的数据集时,这可能会成为问题。

使用场景和建议

插入排序对于以下情况特别高效:

  • 小型数据集。

  • 几乎排序的数据集,只有少数元素未排序。

然而,对于较大且更加随机的数据集,具有更好平均和最坏情况性能的算法,如归并排序或快速排序,更为适用。插入排序的二次时间复杂度使其在处理大量数据时扩展性较差。

归并排序

与冒泡排序和插入排序等排序算法不同,归并排序因其独特的方法而显得格外突出。从历史上看,约翰·冯·诺依曼在 1940 年提出了这一技术。尽管许多排序算法在部分排序的数据上表现更好,但归并排序不受影响;其性能在数据初始排列方式无论如何都保持一致。这种韧性使其成为排序大数据集的首选方法。

分治法:归并排序的核心

归并排序采用分治策略,包含两个关键阶段——分割和合并:

  1. 分割阶段:与直接遍历列表不同,这一阶段递归地将数据集分割为两半。此分割继续进行,直到每个部分达到最小大小(为了说明目的,我们假设是一个元素)。虽然将数据分割到如此细粒度的程度可能看起来反直觉,但这种细粒度有助于在下一阶段进行有序的合并。

  2. 合并阶段:在此阶段,先前分割的部分被系统地合并。算法不断处理并合并这些部分,直到整个列表被排序。

请参见图 3.3,该图展示了归并排序算法的可视化表示。

A diagram of a program  Description automatically generated with medium confidence

图 3.3:归并排序算法

伪代码概览

在深入研究实际代码之前,让我们先通过一些伪代码来理解其逻辑:

merge_sort (elements, start, end)
    if(start < end)
        midPoint = (end - start) / 2 + start
        merge_sort (elements, start, midPoint)
        merge_sort (elements, midPoint + 1, end)
        merge(elements, start, midPoint, end) 

伪代码展示了该算法的步骤:

  1. 将列表围绕中央的midPoint分割。

  2. 递归地将数组分割,直到每个部分只有一个元素。

  3. 系统地将已排序的部分合并成一个完整的排序列表。

Python 实现

下面是归并排序的 Python 实现:

def merge_sort(elements):
    # Base condition to break the recursion
    if len(elements) <= 1:
        return elements
    mid = len(elements) // 2  # Split the list in half
    left = elements[:mid]
    right = elements[mid:]
    merge_sort(left)   # Sort the left half
    merge_sort(right)  # Sort the right half
    a, b, c = 0, 0, 0
    # Merge the two halves
    while a < len(left) and b < len(right):
        if left[a] < right[b]:
            elements[c] = left[a]
            a += 1
        else:
            elements[c] = right[b]
            b += 1
        c += 1
    # If there are remaining elements in the left half
    while a < len(left):
        elements[c] = left[a]
        a += 1
        c += 1
    # If there are remaining elements in the right half
    while b < len(right):
        elements[c] = right[b]
        b += 1
        c += 1
    return elements
list = [21, 22, 23, 24, 25, 26, 27]
merge_sort(list) 
[21, 22, 23, 24, 25, 26, 27] 

Shell 排序

冒泡排序算法比较相邻的元素,并在顺序错误时交换它们。另一方面,插入排序通过一次转移一个元素来创建排序好的列表。如果我们有一个部分排序的列表,插入排序应该能提供合理的性能。

但对于完全未排序的列表,大小为N,你可以认为冒泡排序必须完全遍历N-1轮,才能将其完全排序。

唐纳德·谢尔(Donald Shell)提出了 Shell 排序(以他名字命名),该算法质疑了选择直接相邻元素进行比较和交换的重要性。

现在,让我们理解这个概念。

在第一轮中,我们不是选择直接相邻的元素,而是选择位于固定间隔的元素,最终排序由一对数据点组成的子列表。如下图所示。在第二轮中,它对包含四个数据点的子列表进行排序(见下图)。在随后的轮次中,每个子列表中的数据点数量不断增加,而子列表的数量不断减少,直到最终只剩下一个子列表,其中包含所有数据点。

在此时,我们可以假设列表已经排序:

Chart  Description automatically generated

图 3.4:Shell 排序算法的各轮次

在 Python 中,实现 Shell 排序算法的代码如下:

def shell_sort(elements):
    distance = len(elements) // 2
    while distance > 0:
        for i in range(distance, len(elements)):
            temp = elements[i]
            j = i
# Sort the sub list for this distance
            while j >= distance and elements[j - distance] > temp:
                list[j] = elements[j - distance]
                j = j-distance
            list[j] = temp
# Reduce the distance for the next element
        distance = distance//2
    return elements
list = [21, 22, 23, 24, 25, 26, 27]
shell_sort(list) 
[21, 22, 23, 24, 25, 26, 27] 

请注意,调用ShellSort函数已经导致输入数组的排序。

Shell 排序算法的性能分析

可以观察到,在最坏的情况下,Shell 排序算法需要通过两个循环,因此它的时间复杂度为O(n2)。Shell 排序不适用于大数据集,它适用于中等大小的数据集。大致来说,对于最多包含 6,000 个元素的列表,它的性能相对较好。如果数据部分已经有序,那么性能会更好。在最佳情况下,如果列表已经排序完毕,它只需要遍历N个元素来验证顺序,产生最优的O(N)性能。

选择排序

如我们在本章前面看到的,冒泡排序是最简单的排序算法之一。选择排序是冒泡排序的改进版本,它试图减少算法所需的交换次数。它的设计是每一轮仅进行一次交换,而冒泡排序需要进行N-1次交换。与冒泡排序将最大值逐步“冒泡”到顶部不同(这样会导致N-1次交换),选择排序每次都会寻找最大值并将其移动到顶部。因此,在第一次遍历后,最大值将位于顶部;第二次遍历后,第二大的值将紧跟在最大值后面。随着算法的进行,后续的值将根据其大小移动到正确的位置。

最后一个值将在(N-1)^(次)遍后被移动。因此,选择排序需要N-1次遍历来排序N个项:

图片 16

图 3.5:选择排序算法

这里展示了选择排序在 Python 中的实现:

def selection_sort(list):
    for fill_slot in range(len(list) - 1, 0, -1):
        max_index = 0
        for location in range(1, fill_slot + 1):
            if list[location] > list[max_index]:
                max_index = location
        list[fill_slot],list[max_index] = list[max_index],list[fill_slot]
    return list
list = [21, 22, 23, 24, 25, 26, 27]
selection_sort(list) 
[21, 22, 23, 24, 25, 26, 27] 

选择排序算法的性能分析

选择排序的最坏情况性能是O(N2)。注意,它的最差性能与冒泡排序类似,因此不适合用于排序较大的数据集。不过,选择排序比冒泡排序更为精心设计,且由于减少了交换次数,它的平均性能也优于冒泡排序。

选择排序算法的选择

当谈到排序算法时,并没有一种“放之四海而皆准”的解决方案。最优选择通常取决于你数据的具体情况,例如数据的大小和当前状态。在这里,我们将深入探讨如何做出明智的决策,并通过一些现实世界的例子来加以说明。

小型且已排序的列表

对于较小的数据集,尤其是那些已经排好序的数据,通常使用复杂的算法会显得过于“杀鸡用牛刀”。虽然像归并排序这样强大的算法无可否认,但对于小数据集来说,它的复杂性可能会掩盖它的优势。

现实生活中的例子:想象一下通过书架上书籍的作者姓氏来进行排序。直接扫描并手动调整顺序(类似于冒泡排序)比采用复杂的排序方法要简单快捷得多。

部分排序的数据

当处理已经有些组织的数据时,像插入排序这样的算法表现尤为出色。它们利用现有的顺序,从而提高效率。

现实生活中的例子:考虑一个教室场景。如果学生按身高排队,但有一些略微不在正确位置,老师可以轻松发现并调整这些小的偏差(类似插入排序),而无需重新排队。

大数据集

对于庞大的数据集,数据量可能令人生畏,归并排序则被证明是一个可靠的帮手。其分治策略能够高效处理大列表,成为行业中的最爱。

现实生活中的例子:想象一下一个庞大的图书馆,每天接收成千上万本书籍。按出版日期或作者对它们进行排序需要一种系统的方法。在这里,像归并排序这样的算法,能够将任务分解成可管理的小块,显得尤为重要。

搜索算法简介

在许多计算任务的核心中,存在一个基本需求:在复杂结构中定位特定数据。从表面上看,最简单的方法可能就是扫描每一个数据点,直到找到目标。但正如我们所想象的那样,随着数据量的增加,这种方法的效果会大打折扣。

为什么搜索如此重要?无论是用户查询数据库、系统访问文件,还是应用程序获取特定数据,高效的搜索决定了这些操作的速度和响应能力。如果没有高效的搜索技术,系统会变得迟缓,尤其是在数据集不断膨胀的情况下。

随着对快速数据检索需求的增加,复杂搜索算法的作用变得不可忽视。它们提供了所需的敏捷性和高效性,能够在海量数据中迅速找到目标,确保系统保持灵活,用户满意。因此,搜索算法成为数字世界的导航者,带领我们在信息的海洋中找到精确的数据。

本节介绍了以下搜索算法:

  • 线性搜索

  • 二分查找

  • 插值搜索

让我们更详细地看看它们。

线性搜索

寻找数据的最简单策略之一是通过遍历每个元素来查找目标。每个数据点都会进行匹配搜索,当找到匹配时,返回结果并退出循环。否则,算法会继续搜索,直到数据的末尾。线性搜索的明显缺点是由于其固有的穷举搜索,它非常缓慢。其优点是数据无需像本章其他算法那样进行排序。

让我们看一下线性搜索的代码:

def linear_search(elements, item):
    index = 0
    found = False
# Match the value with each data element       
    while index < len(elements) and found is False:
        if elements[index] == item:
            found = True
        else:
            index = index + 1
    return found 

现在让我们看看前面代码的输出:

list = [12, 33, 11, 99, 22, 55, 90]
print(linear_search(list, 12))
print(linear_search(list, 91)) 
True
False 

请注意,运行LinearSearch函数,如果能够成功找到数据,它将返回True值。

线性搜索算法的性能分析

如前所述,线性查找是一种简单的算法,执行的是穷举搜索。其最坏情况的时间复杂度为O(N)。更多信息请访问wiki.python.org/moin/TimeComplexity

二分查找

二分查找算法的前提是数据已经排序。该算法通过迭代地将列表分为两部分,并跟踪最低和最高索引,直到找到目标值:

def binary_search(elements, item):
    first = 0
    last = len(elements) - 1
    while first<=last:
        midpoint = (first + last) // 2
        if elements[midpoint] == item:
            return True
        else:
            if item < elements[midpoint]:
                last = midpoint - 1
            else:
                first = midpoint + 1
    return False 

输出如下:

list = [12, 33, 11, 99, 22, 55, 90]
sorted_list = bubble_sort(list)
print(binary_search(list, 12))
print(binary_search(list, 91)) 
True
False 

请注意,调用BinarySearch函数会在输入列表中找到值时返回True

二分查找算法的性能分析

二分查找之所以命名为“二分查找”,是因为在每次迭代中,算法都将数据分为两部分。如果数据有 N 个元素,最多需要O(logN)步进行迭代。这意味着该算法的运行时间复杂度为O(logN)

插值查找

二分查找的基本逻辑是它专注于数据的中间部分,而插值查找则更为复杂。插值查找利用目标值来估算元素在排序数组中的位置。我们通过一个例子来理解它。假设我们想在英语词典中查找一个单词,如river。我们将利用这些信息进行插值,并开始查找以r开头的单词。一个更为通用的插值查找可以编程实现如下:

def int_polsearch(list,x ):
    idx0 = 0
    idxn = (len(list) - 1)
    while idx0 <= idxn and x >= list[idx0] and x <= list[idxn]:
# Find the mid point
        mid = idx0 +int(((float(idxn - idx0)/(list[idxn] - list[idx0])) * (x - list[idx0])))
# Compare the value at mid point with search value 
        if list[mid] == x:
            return True
        if list[mid] < x:
            idx0 = mid + 1
    return False 

输出如下:

list = [12, 33, 11, 99, 22, 55, 90]
sorted_list = bubble_sort(list)
print(int_polsearch(list, 12))
print(int_polsearch(list, 91)) 
True
False 

请注意,在使用IntPolsearch之前,数组需要先使用排序算法进行排序。

插值查找算法的性能分析

如果数据分布不均匀,插值查找算法的性能将较差。该算法的最坏情况时间复杂度为O(N),而如果数据分布相对均匀,最佳性能则为O(log(log N))

实际应用

在给定数据存储库中高效且准确地查找数据,对于许多实际应用至关重要。根据你选择的查找算法,你可能还需要先对数据进行排序。选择合适的排序和查找算法将取决于数据的类型和大小,以及你要解决问题的性质。

让我们尝试使用本章介绍的算法来解决某个国家移民局为新申请人匹配历史记录的问题。当有人申请签证进入该国时,系统会尝试将申请人与现有的历史记录进行匹配。如果找到至少一个匹配项,系统会进一步计算该人过去被批准或拒绝的次数。另一方面,如果没有找到匹配项,系统将此申请人归类为新申请人,并为其分配一个新的标识符。

在历史数据中搜索、定位和识别一个人的能力对于系统至关重要。这个信息非常重要,因为如果某人过去申请过且申请被拒绝,这可能会对该人当前的申请产生负面影响。同样,如果某人的申请过去被批准,这个批准可能会增加该人当前申请获批的机会。通常,历史数据库将包含数百万行数据,我们需要一个设计良好的解决方案,以便在历史数据库中匹配新的申请人。

假设数据库中的历史表格如下所示:

个人 ID 申请 ID 名字 姓氏 出生日期 决定 决定日期
45583 677862 John Doe 2000-09-19 已批准 2018-08-07
54543 877653 Xman Xsir 1970-03-10 被拒绝 2018-06-07
34332 344565 Agro Waka 1973-02-15 被拒绝 2018-05-05
45583 677864 John Doe 2000-09-19 已批准 2018-03-02
22331 344553 Kal Sorts 1975-01-02 已批准 2018-04-15

在这个表格中,第一列个人 ID与历史数据库中的每个独特申请人相关联。如果历史数据库中有 3000 万独特申请人,那么就会有 3000 万个独特的个人 ID。每个个人 ID 都标识历史数据库系统中的一个申请人。

我们的第二列是申请 ID。每个申请 ID 都标识系统中的唯一申请。一个人可能在过去申请过多次。因此,在历史数据库中,我们将拥有比个人 ID 更多的独特申请 ID。John Doe 只有一个个人 ID,但有两个申请 ID,如上表所示。

上表仅显示了历史数据集的一部分。假设我们在历史数据集中有接近 100 万行数据,涵盖了过去 10 年申请人的记录。新申请人以大约每分钟 2 个申请人的平均速度不断到达。对于每个申请人,我们需要做如下操作:

  • 为申请人发放新的申请 ID。

  • 查看是否与历史数据库中的某个申请人匹配。

  • 如果找到匹配项,则使用该申请人在历史数据库中找到的个人 ID。我们还需要确定该申请在历史数据库中已被批准或拒绝的次数。

  • 如果没有找到匹配项,那么我们需要为该个人发放一个新的个人 ID。

假设有一个新的人带着以下凭证到达:

  • 名字:John

  • 姓氏:Doe

  • 出生日期:2000-09-19

那么,我们如何设计一个能够高效且具成本效益地执行搜索的应用程序呢?

一种搜索新申请的策略可以如下设计:

  1. 按照出生日期(DOB)对历史数据库进行排序。

  2. 每当有新的人到来时,向申请人发放一个新的应用 ID。

  3. 获取所有与出生日期匹配的记录。这将是主要的搜索条件。

  4. 从所有匹配的记录中,使用姓名进行二次搜索。

  5. 如果找到匹配项,则使用个人 ID来引用申请人。计算批准和拒绝的数量。

  6. 如果未找到匹配项,则向申请人发放新的个人 ID。

让我们尝试选择合适的算法来对历史数据库进行排序。由于数据量庞大,我们可以安全地排除冒泡排序。希尔排序表现更好,但前提是我们有部分已排序的列表。因此,归并排序可能是对历史数据库进行排序的最佳选择。

当一个新的人到来时,我们需要在历史数据库中搜索并定位该人。由于数据已经排序,可以使用插值搜索或二分查找。因为申请人可能会均匀分布,根据DOB,我们可以安全地使用二分查找。

最初,我们根据DOB进行搜索,返回一组具有相同出生日期的申请人。现在,我们需要在这一小部分具有相同出生日期的人中找到所需的申请人。由于我们已经成功将数据减少到一个小子集,因此可以使用任何搜索算法,包括冒泡排序,来搜索申请人。请注意,我们在这里简化了二次搜索问题。如果找到多个匹配项,我们还需要通过聚合搜索结果来计算批准和拒绝的总数。

在现实场景中,每个个体在二次搜索中都需要使用某种模糊搜索算法进行识别,因为姓名可能会有轻微的拼写差异。搜索可能需要使用某种距离算法来实现模糊搜索,在该算法中,相似度超过定义阈值的数据点被认为是相同的。

摘要

在这一章中,我们介绍了一组排序和查找算法。我们还讨论了不同排序和查找算法的优缺点。我们量化了这些算法的性能,并学习了何时使用每种算法。

在下一章,我们将学习动态算法。我们还将通过一个设计算法的实际示例来了解页面排名算法的细节。最后,我们将学习线性规划算法。

在 Discord 上了解更多

要加入本书的 Discord 社区——在这里你可以分享反馈、向作者提问并了解新版本的发布——请扫描下面的二维码:

packt.link/WHLel

第四章:4

设计算法

本章介绍了各种算法的核心设计概念。讨论了设计算法的各种技术的优缺点。通过理解这些概念,我们将学习如何设计高效的算法。

本章首先讨论我们在设计算法时可以选择的不同方案。然后,讨论了准确描述我们试图解决的具体问题的重要性。接下来,以著名的旅行商问题TSP)为用例,应用我们将要介绍的不同设计技术。然后,引入线性规划并讨论其应用。最后,介绍如何利用线性规划解决一个现实世界问题。

到本章结束时,你应该能够理解设计高效算法的基本概念。

本章讨论了以下概念:

  • 设计算法的不同方法

  • 理解选择正确算法设计所涉及的权衡

  • 解决现实世界问题的最佳实践

  • 解决一个现实世界中的优化问题

首先让我们来看设计算法的基本概念。

介绍设计算法的基本概念

根据《美国传统词典》,算法被定义为:

一组有限的明确指令,在给定初始条件下,可以按照规定的顺序执行,以实现某个特定目标,并且具有可识别的结束条件。

设计算法是以最高效的方式提出这组“有限的明确指令”,以“实现特定目标”。对于一个复杂的现实世界问题,设计算法是一项繁琐的任务。为了提出一个好的设计,我们首先需要完全理解我们试图解决的问题。我们从弄清楚需要做什么(即理解需求)开始,再去思考如何做(即设计算法)。理解问题包括解决问题的功能性和非功能性需求。让我们来看看这些是什么:

  • 功能性需求正式地指定了我们想要解决的问题的输入和输出接口及其相关功能。功能性需求帮助我们理解数据处理、数据操作和实现计算来生成结果所需的操作。

  • 非功能性需求设定了关于算法性能和安全方面的期望。

请注意,设计算法是关于在给定的条件下,以最佳方式处理功能性和非功能性需求,并考虑到运行设计算法所需的资源。

为了得出一个既能满足功能要求又能满足非功能要求的良好响应,我们的设计应当关注以下三个方面,正如第一章 算法概述中所讨论的:

  • 正确性:设计的算法是否能产生我们预期的结果?

  • 性能:这是获取这些结果的最佳方式吗?

  • 可扩展性:该算法在处理更大数据集时表现如何?

在这一节中,我们将逐一讨论这些关注点。

关注点 1:正确性:设计的算法是否能产生我们预期的结果?

算法是解决现实问题的数学方法。为了有用,它应该产生准确的结果。如何验证算法的正确性不应是事后考虑的问题,而应该融入算法的设计中。在制定验证算法的策略之前,我们需要考虑以下两个方面:

  • 定义真值:为了验证算法,我们需要一组已知正确的结果作为输入的基准。这些已知正确的结果在我们试图解决的问题的上下文中被称为真值。真值非常重要,因为它是我们在迭代优化算法时用作参考的依据。

  • 选择度量指标:我们还需要考虑如何量化与定义真值之间的偏差。选择正确的度量指标将帮助我们准确地量化算法的质量。

    例如,对于监督式机器学习算法,我们可以使用现有的标注数据作为真值。我们可以选择一个或多个度量指标,如准确率、召回率或精确度,来量化与真值之间的偏差。需要注意的是,在某些使用场景下,正确的输出不是单一值,而是给定输入集的一个范围。随着我们对算法进行设计和开发,目标将是通过迭代优化算法,直到其在需求中指定的范围内。

  • 边界情况考虑:边界情况发生在我们设计的算法在操作参数的极限状态下运行时。边界情况通常是罕见的场景,但需要经过充分的测试,因为它可能导致我们的算法失败。非边界情况则被称为“正常路径”,涵盖了操作参数在正常范围内时通常会发生的所有场景。绝大多数情况下,算法会保持在“正常路径”上。不幸的是,无法列出所有可能的边界情况,但我们应尽可能多地考虑这些边界情况。如果没有考虑到边界情况,问题可能会发生。

关注点 2:性能:这是获取这些结果的最佳方式吗?

第二个关注点是关于回答以下问题:

这是否是最优解决方案,我们能否验证没有比我们的解决方案更好的其他解决方案存在?

乍一看,这个问题似乎很简单。然而,对于某些类别的算法,研究人员几十年来一直未能成功验证算法生成的特定解决方案是否为最佳解决方案,以及是否存在其他解决方案可以提供更好的性能。因此,首先理解问题、其需求以及运行算法所需的资源变得非常重要。

要提供某个复杂问题的最佳解决方案,我们需要回答一个基本问题:我们是否应该致力于找到这个问题的最优解?如果找到和验证最优解非常耗时且复杂,那么一个可行的解决方案可能是最好的选择。这些近似的可行解决方案被称为启发式

因此,理解问题及其复杂性是重要的,并有助于我们估计运行算法所需的资源。

在深入探讨之前,让我们先定义几个术语:

  • 多项式算法:如果一个算法的时间复杂度为O(n^k),我们称之为多项式算法,其中k是一个常数。

  • 证书:在迭代解决特定问题的过程中生成的建议候选解决方案称为证书。随着我们在解决问题时的逐步进展,通常会生成一系列证书。如果解决方案朝着收敛前进,每个生成的证书都将比前一个更好。在某个时刻,当我们的证书满足要求时,我们将选择该证书作为最终解决方案。

第一章算法概述中,我们介绍了大 O 符号,它可以用来分析算法的时间复杂度。在分析时间复杂度的背景下,我们考虑以下不同的时间间隔:

  • 候选解决方案生成时间t[r]:这是算法生成候选解决方案所需的时间。

  • 候选解决方案验证时间t[s]:这是验证候选解决方案(证书)所需的时间。

表征问题的复杂性

多年来,研究界根据其复杂性将问题分为不同的类别。

在我们尝试设计问题的解决方案之前,首先尝试对其进行特征化是有意义的。一般来说,问题可以分为三种类型:

  • 对于我们可以保证存在多项式算法来解决的问题

  • 对于我们可以证明无法通过多项式算法解决的问题

  • 对于我们无法找到多项式算法来解决,但也无法证明对于这些问题不存在多项式解决方案

让我们根据其复杂性来看看各种类别的问题:

  • 非确定性多项式时间 (NP):可以通过非确定性计算机在多项式时间内解决的问题。广义上讲,这意味着通过在每一步做出合理的猜测,而不致力于找到最优解,可以在多项式时间内找到并验证问题的一个合理解。形式上,对于一个问题要成为 NP 问题,它必须满足以下条件,称为条件 A:

    • 条件 A:保证存在一个多项式时间算法,可以用来验证候选解(证书)是否最优。
  • 多项式时间 (P):可以通过确定性计算机在多项式时间内解决的问题。这些问题可以通过某些算法在运行时间为 O(N^k) 的情况下解决,无论 k 的值有多大。这些问题可以被看作是 NP 的子集。除了满足 NP 问题的条件 A 外,P 类问题还需要满足另一个条件,称为条件 B:

    • 条件 A:保证存在一个多项式时间算法,可以用来验证候选解(证书)是否最优。

    • 条件 B:保证至少存在一个多项式时间算法,可以用来解决该问题。

探索 P 类和 NP 类问题的关系

理解 P 类和 NP 类问题的关系仍在进行中。我们已知的是,P 类问题是 NP 类问题的子集,即!。这一点从上述讨论中显而易见,因为 NP 类问题只需满足 P 类问题需要满足的两个条件中的第一个条件。

P 类问题与 NP 类问题之间的关系如图 4.1所示:

维恩图 描述自动生成,置信度较低

图 4.1:P 类问题与 NP 类问题的关系

我们目前不确定的问题是,如果一个问题是 NP 问题,它是否也是 P 问题?这是计算机科学中最伟大的未解问题之一。由克莱数学研究所选出的千年奖问题宣布,解决这个问题将获得一百万美元的奖金,因为它将在人工智能、密码学和理论计算机科学等领域产生重大影响。有些问题,比如排序,已知属于 P 类。其他问题,如背包问题和旅行商问题(TSP),已知属于 NP 类。

目前有大量的研究工作在努力解答这个问题。至今,没有研究人员发现能在多项式时间内确定性地解决背包问题或旅行商问题的算法。这个问题仍在进行中,且尚未有人能够证明不存在这样的算法。

维恩图 描述自动生成

图 4.2:P 是否等于 NP?我们目前尚不知晓

引入 NP 完全问题和 NP 难问题

让我们继续列举各种问题类别:

  • NP 完全问题:NP 完全类别包含了所有 NP 问题中最难的问题。一个 NP 完全问题满足以下两个条件:

    • 尚未有已知的多项式算法可以生成证书。

    • 已知的多项式算法可以验证所提出的证书是否最优。

  • NP-hard:NP-hard 类别包含的问题至少与 NP 类中的任何问题一样难,但这些问题不一定要属于 NP 类。

现在,让我们尝试绘制一个图示,来说明这些不同类别的问题:

Diagram  Description automatically generated

图 4.3:P、NP、NP-complete 和 NP-hard 之间的关系

请注意,是否 P = NP 仍需通过研究界来证明。尽管这一问题尚未被证明,但极有可能 P ≠ NP。在这种情况下,NP-complete 问题将不存在多项式解决方案。请注意,前面的图示是基于这一假设的。

P、NP、NP-complete 和 NP-hard 之间的区别

不幸的是,P、NP、NP-complete 和 NP-hard 之间的区别并不清晰。让我们总结并研究一些例子,以便更好地理解本节中讨论的概念:

  • P:这是可以在多项式时间内解决的问题类。例如:

    • 哈希表查找

    • 像 Djikstra 算法这样的最短路径算法

    • 线性和二分查找算法

  • NP-problem:这些问题不能在多项式时间内解决,但它们的解决方案可以在多项式时间内进行验证。例如:

    • RSA 加密算法
  • NP-hard:这些是复杂的问题,目前尚未找到解决方案,但如果能够解决,将会有一个多项式时间的解决方案。例如:

    • 使用 K 均值算法进行最优聚类
  • NP-complete:NP-complete 问题是 NP 类中“最难”的问题。它们既是 NP-hard 也是 NP。例如:

    • 计算旅行商问题的最优解

如果解决了其中一种类别(NP-hard 或 NP-complete)的问题,将意味着所有 NP-hard/NP-complete 问题的解决方案都可以得到解决。

关注点 3 - 可扩展性:算法在处理更大数据集时的表现如何?

算法以定义的方式处理数据并生成结果。通常,随着数据量的增加,处理数据和计算所需结果所需的时间也会越来越长。大数据这一术语有时用来粗略标识由于数据集的体量、种类和流速而预计对基础设施和算法构成挑战的数据集。一个设计良好的算法应该具有可扩展性,这意味着它应该以一种方式设计,使其能够在可能的情况下高效运行,利用可用资源并在合理的时间框架内生成正确的结果。当处理大数据时,算法的设计变得尤为重要。为了量化算法的可扩展性,我们需要关注以下两个方面:

  • 随着输入数据增大,资源需求增加:估算这种需求的过程被称为空间复杂度分析。

  • 随着输入数据增加,运行所需的时间也会增加:估算这一点的过程叫做时间复杂度分析。

请注意,我们正生活在一个数据爆炸的时代。大数据一词已成为主流,它能够捕捉现代算法通常需要处理的数据的规模和复杂性。

在开发和测试阶段,许多算法仅使用少量数据样本。当设计算法时,考虑算法的可扩展性非常重要。特别是,必须仔细分析(即测试或预测)随着数据集增大,算法性能的变化。

云的弹性与算法可扩展性

云计算为应对算法的资源需求提供了新的选择。云计算基础设施能够在处理需求增加时提供更多的资源。云计算的这一能力被称为基础设施的弹性,现如今它为我们设计算法提供了更多的选择。当算法部署在云上时,可能会根据要处理的数据规模要求更多的 CPU 或虚拟机(VM)。

典型的深度学习算法就是一个很好的例子。要训练出一个优秀的深度学习模型,需要大量标注数据。对于一个设计良好的深度学习算法,训练深度学习模型所需的处理量与示例的数量是成正比的,或者说接近正比。当在云端训练深度学习模型时,随着数据量的增加,我们会尝试配置更多的资源,以保持训练时间在可管理的范围内。

理解算法策略

一个设计良好的算法会尽可能地通过将问题划分成更小的子问题来最有效地利用可用资源。设计算法时有不同的算法策略。一种算法策略涉及算法中的以下三个方面:

本节将介绍以下三种策略:

  • 分治策略

  • 动态规划策略

  • 贪心算法策略

理解分治策略

一种策略是找到一种方法,将较大的问题划分成可以独立解决的小问题。这些小问题的子解将被合并以生成问题的整体解决方案。这就是分治策略。

从数学上讲,如果我们正在为一个有 n 个输入的(P)问题设计解决方案,需要处理数据集 d,我们将问题划分为 k 个子问题,P1Pk。每个子问题将处理数据集的一个分区 d。通常,我们会让 P1Pk 分别处理 d1dk

让我们来看一个实际的例子。

一个实际的例子——分治法应用于 Apache Spark

Apache Spark (spark.apache.org/) 是一个开源框架,用于解决复杂的分布式问题。它实现了一种分治策略来解决问题。为了处理一个问题,它将问题划分为多个子问题,并独立地处理这些子问题。这些子问题可以在不同的机器上运行,从而实现水平扩展。我们将通过一个简单的示例——从列表中统计单词来演示这一点。

假设我们有以下单词列表:

words_list = ["python", "java", "ottawa", "news", "java", "ottawa"]

我们想要计算这个列表中每个单词的频率。为此,我们将应用分治策略以高效地解决这个问题。

分治法的实现如下面的示意图所示:

Diagram  Description automatically generated图 4.4:分治法

前面的示意图展示了一个问题分解的以下阶段:

  1. 拆分:输入数据被划分为可以独立处理的分区,这叫做拆分(splitting)。在前面的示意图中,我们有三个分割。

  2. 映射:任何可以在分割上独立运行的操作都叫做映射(map)。在前面的示意图中,映射操作将每个分区中的单词转换为键值对。对应于三个分割,三个映射器将并行运行。

  3. 洗牌:洗牌是将相似的键聚集在一起的过程。一旦相似的键被聚集在一起,就可以对它们的值运行聚合函数。注意,洗牌是一个性能密集型操作,因为相似的键需要被聚集,而这些键原本可能分布在整个网络中。

  4. 归约:对相似键的值运行聚合函数叫做归约(reducing)。在前面的示意图中,我们需要统计单词的数量。

让我们看看如何编写代码来实现这一点。为了演示分治策略,我们需要一个分布式计算框架。我们将使用 Apache Spark 上运行的 Python 来演示:

  1. 首先,为了使用 Apache Spark,我们将创建一个 Apache Spark 的运行时上下文:

    import findspark
    findspark.init()
    from pyspark.sql import SparkSession
    spark = SparkSession.builder.master("local[*]").getOrCreate()
    sc = spark.sparkContext 
    
  2. 现在,让我们创建一个包含一些单词的示例列表。我们将把这个列表转换为 Spark 的本地分布式数据结构,称为弹性分布式数据集RDD):

    wordsList = ['python', 'java', 'ottawa', 'ottawa', 'java','news']
    wordsRDD = sc.parallelize(wordsList, 4)
    # Print out the type of wordsRDD
    print (wordsRDD.collect()) 
    
  3. 它将打印:

    ['python', 'java', 'ottawa', 'ottawa', 'java', 'news'] 
    
  4. 现在,让我们使用map函数将单词转换为键值对:

    wordPairs = wordsRDD.map(lambda w: (w, 1))
    print (wordPairs.collect()) 
    
  5. 它将打印:

    [('python', 1), ('java', 1), ('ottawa', 1), ('ottawa', 1), ('java', 1), ('news', 1)] 
    
  6. 让我们使用reduce函数来进行聚合并得到结果:

    wordCountsCollected = wordPairs.reduceByKey(lambda x,y: x+y)
    print(wordCountsCollected.collect()) 
    
  7. 它打印:

    [('python', 1), ('java', 2), ('ottawa', 2), ('news', 1)] 
    

这展示了我们如何使用分治策略来统计单词的数量。请注意,当一个问题可以被分解为子问题并且每个子问题至少可以在某种程度上独立地解决时,分治法是有用的。但对于需要大量迭代处理的算法,如优化算法,分治法并不是最佳选择。对于这类算法,适用的是动态规划,接下来将介绍这一内容。

现代云计算基础设施,如 Microsoft Azure、Amazon Web Services 和 Google Cloud,通过实现分治策略(无论是直接还是间接的方式)在分布式基础设施中实现了可扩展性,能够并行使用多个 CPU/GPU。

理解动态规划策略

在上一节中,我们学习了分治法,它是一种自上而下的方法。与此相对,动态规划是一种自下而上的策略。我们从最小的子问题开始,不断地将解决方案组合起来,直到达到最终的解决方案。像分治法一样,动态规划通过将子问题的解决方案组合来解决问题。

动态规划是一种由理查德·贝尔曼(Richard Bellman)在 1950 年代提出的优化特定类别算法的策略。需要注意的是,在动态规划中,"编程"一词指的是使用表格方法,与编写代码无关。与分治策略相对,动态规划适用于子问题之间不独立的情况。它通常应用于优化问题,其中每个子问题的解决方案都有一个值。

我们的目标是找到一个具有最优值的解决方案。动态规划算法只对每个子问题求解一次,并将其结果保存在表格中,从而避免了每次遇到子问题时都重新计算答案。

动态规划的组成部分

动态规划基于两个主要组成部分:

  • 递归:它通过递归的方式解决子问题。

  • 记忆化:记忆化或缓存。它基于一种智能缓存机制,尝试重用重计算的结果。这个智能缓存机制称为记忆化。子问题部分涉及到在多个子问题中重复的计算。这个思想是只进行一次计算(这是耗时的步骤),然后在其他子问题中重用它。这是通过记忆化来实现的,这在解决可能多次评估相同输入的递归问题时尤其有用。

使用动态规划的条件

我们尝试用动态规划解决的问题应具备两个特征。

  • 最优结构:当我们尝试解决的问题可以被分解成子问题时,动态规划能够带来良好的性能收益。

  • 重叠子问题:动态规划使用一个递归函数,通过调用自身并解决原问题的较小子问题来解决特定问题。子问题的计算结果会存储在一个表格中,避免重复计算。因此,在存在重叠子问题的情况下,需要使用这一技术。

动态规划非常适合组合优化问题,这类问题需要提供输入元素的最优组合作为解决方案。

示例包括:

  • 为像 FedEx 或 UPS 这样的公司寻找最优的包裹配送方式

  • 寻找最优的航空公司航线和机场

  • 决定如何为像 Uber Eats 这样的在线外卖系统分配司机

理解贪心算法

正如名字所示,贪心算法相对较快地产生一个好的解,但它不一定是最优解。与动态规划类似,贪心算法主要用于解决无法使用分治策略的优化问题。在贪心算法中,解决方案通过一系列步骤逐步计算。在每一步中,做出局部最优选择。

使用贪心编程的条件

贪心算法是一种在具有以下两种特征的问题中表现良好的策略:

  • 从局部到全局:通过选择局部最优解,可以达到全局最优解。

  • 最优子结构:问题的最优解由其子问题的最优解组成。

为了理解贪心算法,我们首先定义两个术语:

  • 算法开销:每当我们尝试找到某个问题的最优解时,都需要一些时间。随着我们要优化的问题变得越来越复杂,找到最优解所需的时间也会增加。我们用 来表示算法开销。

  • 与最优解的差距:对于一个给定的优化问题,存在一个最优解。通常我们通过迭代优化所选择的算法来逐步逼近最优解。对于某个问题,总是存在一个完美的解决方案,称为最优解。如前所述,基于我们尝试解决的问题的分类,最优解可能是未知的,或者计算和验证最优解可能需要不合理的时间。假设最优解是已知的,则当前解在第 i 次迭代中的与最优解的差距被称为最优差距,表示为

对于复杂问题,我们有两种可能的策略:

  • 花更多的时间寻找最接近最优解的解决方案,以便使得 尽可能小。

  • 最小化算法开销,。采用简单粗暴的方法,只求一个可行的解决方案。

贪心算法基于第二种策略,我们不努力寻找全局最优解,而是选择最小化算法开销。

使用贪心算法是一种快速简便的策略,用于解决多阶段问题的全局最优值。它基于选择局部最优值,而不努力验证局部最优值是否也为全局最优。通常,除非我们运气好,贪心算法不会得到可以视为全局最优的值。然而,找到一个全局最优值是一个耗时的任务。因此,与分治法和动态规划算法相比,贪心算法的速度较快。

通常,贪心算法定义如下:

  1. 假设我们有一个数据集 D。在这个数据集中,选择一个元素 k

  2. 假设候选解或证书是 S。考虑将 k 包含在解 S 中。如果可以包含,那么解就是 Union(S, e)

  3. 重复该过程,直到 S 填满或 D 耗尽。

示例

分类与回归树 (CART) 算法是一个贪心算法,它在顶层寻找最优划分。它在每个后续层次重复此过程。请注意,CART 算法并不计算并检查该划分是否会导致几层下的最低可能杂质。CART 使用贪心算法,因为找到最优树被认为是一个 NP 完全问题。它的算法复杂度是 O(exp(m)) 时间。

一个实际应用——解决 TSP 问题

首先让我们看一下 TSP 的问题陈述,这是一个众所周知的问题,最早在 1930 年代被提出作为挑战。TSP 是一个 NP-困难问题。首先,我们可以随机生成一个满足访问所有城市的条件的旅行路径,而不考虑最优解。然后,我们可以在每次迭代中努力改进解。每次迭代中生成的旅行路径称为候选解(也叫证书)。证明一个证书是最优的需要指数级的时间。相反,使用不同的启发式解决方案来生成接近最优但并非最优的路径。

一个旅行推销员需要访问给定的城市列表才能完成任务:

输入 一个包含 n 个城市的列表(记作 V),以及每对城市之间的距离,d ij (1 ≤ i, j ≤ n)
输出 最短的旅行路径,访问每个城市一次并返回初始城市

请注意以下几点:

  • 城市之间的距离是已知的

  • 给定列表中的每个城市需要精确访问一次

我们能为旅行推销员生成旅行计划吗?什么是能够最小化旅行推销员总行程的最优解?

以下是我们可以用于 TSP 的五个加拿大城市之间的距离:

渥太华 蒙特利尔 金斯顿 多伦多 萨德伯里
渥太华 - 199 196 450 484
蒙特利尔 199 - 287 542 680
金斯顿 196 287 - 263 634
多伦多 450 542 263 - 400
萨德伯里 484 680 634 400 -

注意,目标是得到一条从起始城市出发并返回起始城市的路线。例如,一个典型的路线可能是渥太华–萨德伯里–蒙特利尔–金斯顿–多伦多–渥太华,总费用为 484 + 680 + 287 + 263 + 450 = 2,164。这是销售员需要行走的最短路线吗?能够最小化销售员总行程的最优解是什么?我留给你自己去思考并计算。

使用暴力破解策略

解决旅行商问题(TSP)时,首先想到的解决方案是通过暴力破解方法找到最短路径,使得销售员恰好访问每座城市一次,并返回到起始城市。因此,暴力破解策略如下:

  • 评估所有可能的路线。

  • 选择距离最短的那个。

问题是,对于 n 个城市,有 (n-1)! 种可能的路线。这意味着五座城市会产生 4! = 24 种路线,我们会选择最短距离对应的那一条。显然,这种方法只适用于城市数量较少的情况。随着城市数量的增加,暴力破解策略因产生的排列组合数量过多而变得无法解决。

让我们看看如何在 Python 中实现暴力破解策略。

首先需要注意,{1,2,3} 代表一条从城市 1 到城市 2 和城市 3 的路线。一个路线的总距离是该路线覆盖的总距离。我们假设城市之间的距离是它们之间的最短距离(即欧几里得距离)。

让我们首先定义三个实用函数:

  • distance_points:计算两点之间的绝对距离

  • distance_tour:计算销售员在给定路线中需要覆盖的总距离

  • generate_cities:随机生成一组位于宽度为 500 和高度为 300 的矩形区域内的 n 个城市

看一下下面的代码:

import random
from itertools import permutations 

在前面的代码中,我们从 itertools 包的 permutations 函数实现了 alltours。我们还用复数表示了距离。这意味着以下内容:

计算两座城市,ab,之间的距离就像 distance (a,b) 一样简单。

我们可以通过调用 generate_cities(n) 来创建 n 个城市:

def distance_tour(aTour):
    return sum(distance_points(aTour[i - 1], aTour[i]) 
               for i in range(len(aTour))
    )
aCity = complex
def distance_points(first, second):
    return abs(first - second)
def generate_cities (number_of_cities):
    seed=111
    width=500
    height=300
    random.seed((number_of_cities, seed))
    return frozenset(aCity(random.randint(1, width),
                           random.randint(1, height))
                     for c in range(number_of_cities)) 

现在,让我们定义一个函数 brute_force,它会生成所有可能的城市路线。一旦生成了所有可能的路线,它将选择最短距离的那一条:

def brute_force(cities):
    return shortest_tour(alltours(cities))
def shortest_tour(tours):
    return min(tours, key=distance_tour) 

现在让我们定义一些有助于绘制城市图的实用函数。我们将定义以下函数:

  • visualize_tour:绘制特定旅行路线中的所有城市和链接。它还会突出显示旅行开始的城市。

  • visualize_segment:由visualize_tour使用,用于绘制一个段中的城市和链接。

看一下以下代码:

import matplotlib.pyplot as plt
def visualize_tour(tour, style='bo-'): 
if len(tour) > 1000:
        plt.figure(figsize=(15, 10))
    start = tour[0:1]
    visualize_segment(tour + start, style)
    visualize_segment(start, 'rD') 

def visualize_segment (segment, style='bo-'):
    plt.plot([X(c) for c in segment], [Y(c) for c in segment], style, clip_on=False)
    plt.axis('scaled')
    plt.axis('off')

def X(city):
    "X axis";
    return city.real
def Y(city):
    "Y axis";
    return city.imag 

让我们实现一个函数 tsp(),它完成以下操作:

  1. 根据算法和所请求的城市数生成旅行路线。

  2. 计算算法运行所花费的时间。

  3. 生成一个图表。

一旦定义了tsp(),我们就可以用它来创建旅行路线:

from time import time
from collections import Counter
def tsp(algorithm, cities):
    t0 = time()
    tour = algorithm(cities)
    t1 = time()
    # Every city appears exactly once in tour
    assert Counter(tour) == Counter(cities) 
    visalize_tour(tour)
    print("{}:{} cities => tour length {;.0f} (in {:.3f} sec".format(
        name(algorithm), len(tour), distance_tour(tour), t1-t0))
def name(algorithm):
    return algorithm.__name__.replace('_tsp','')
tps(brute_force, generate_cities(10)) 

图表,折线图 描述自动生成

图 4.5:TSP 问题的解决方案

请注意,我们已经用它生成了 10 个城市的旅行路线。由于n = 10,它将生成(10-1)! = 362,880种可能的排列组合。如果 n 增加,排列组合的数量急剧增加,暴力破解法将无法使用。

使用贪心算法

如果我们使用贪心算法来解决旅行商问题(TSP),那么在每一步,我们可以选择一个看起来合理的城市,而不是寻找一个能够导致最佳整体路径的城市。因此,每当我们需要选择一个城市时,我们只是选择离得最近的城市,而不考虑这个选择是否能导致全局最优路径。

贪心算法的方法很简单:

  1. 从任何城市开始。

  2. 在每一步,通过移动到下一个城市来继续构建旅行路线,该城市是最近的未访问过的邻近城市。

  3. 重复步骤 2

让我们定义一个名为greedy_algorithm的函数,它能够实现这一逻辑:

def greedy_algorithm(cities, start=None):
    city_ = start or first(cities)
    tour = [city_]
    unvisited = set(cities - {city_})
    while unvisited:
        city_ = nearest_neighbor(city_, unvisited)
        tour.append(city_)
        unvisited.remove(city_)
    return tour
def first(collection): return next(iter(collection))
def nearest_neighbor(city_a, cities):
    return min(cities, key=lambda city_: distance_points(city_, city_a)) 

现在,让我们使用greedy_algorithm为 2,000 个城市创建一条旅行路线:

tsp(greedy_algorithm, generate_cities(2000)) 

二维码 描述自动生成,信心中等

图 4.6:在 Jupyter Notebook 中显示的城市

请注意,生成 2,000 个城市的旅行路线仅用了0.514秒。如果我们使用暴力破解法,它将生成(2000-1)! = 1.65e⁵⁷³²个排列组合,这几乎是无限的。

请注意,贪心算法是基于启发式的,并且没有证明该解是最优的。

三种策略的比较

总结来说,贪心算法的结果在计算时间上更为高效,而暴力破解法则提供了全局最优解的组合。这意味着计算时间和结果的质量有所不同。所提出的贪心算法可能会得到与暴力破解几乎相同的结果,但计算时间显著较少,但由于它不搜索最优解,因此它基于一种基于努力的策略,并且没有保证。

现在,让我们来看一下PageRank算法的设计。

展示 PageRank 算法

作为一个实际示例,让我们来看一下 Google 用来对用户查询的搜索结果进行排名的 PageRank 算法。它生成一个数字,量化搜索结果在用户执行的查询背景下的重要性。这一算法由两位博士生,Larry Page 和 Sergey Brin,在 1990 年代末期的斯坦福大学设计,他们也随之创办了 Google。PageRank 算法是以 Larry Page 的名字命名的。

首先,我们正式定义 PageRank 最初设计的目标问题。

问题定义

每当用户在网页搜索引擎中输入查询时,通常会返回大量的搜索结果。为了使结果对最终用户有用,使用某些标准对网页进行排名是很重要的。显示的结果使用此排名来总结展示给用户的结果,并且依赖于底层算法定义的标准。

实现 PageRank 算法

首先,在使用 PageRank 算法时,采用以下表示方法:

  • 网页在有向图中由节点表示。

  • 图中的边代表超链接。

PageRank 算法中最重要的部分是找到计算查询结果中每个网页重要性的最佳方法。某一特定网页在网络中的排名是通过计算一个随机浏览边(即点击链接)的人到达该页面的概率来得出的。此外,该算法由阻尼因子 alpha 参数化,默认值为 0.85。这个阻尼因子表示用户继续点击的概率。请注意,PageRank 最高的页面是最具吸引力的:无论用户从哪里开始,该页面都有最高的概率成为最终目标页面。

该算法需要多次迭代或遍历网页集合,以确定每个网页的正确重要性(或 PageRank 值)。

为了计算一个从 01 的数字,用来量化某个特定网页的重要性,算法结合了以下两个组件的信息:

  • 与用户输入的查询相关的信息:这个组件在用户输入的查询背景下,评估网页内容的相关性。网页的内容直接依赖于页面的作者。

  • 与用户输入的查询无关的信息:这个组件试图量化每个网页在其链接、浏览量和邻域中的重要性。一个网页的邻域是与该页面直接相连的网页群体。由于网页具有异质性,且很难制定适用于整个网页的标准,这一组件的计算非常困难。

为了在 Python 中实现 PageRank 算法,首先让我们导入必要的库:

import numpy as np
import networkx as nx
import matplotlib.pyplot as plt 

注意,该网络来自 networkx.org/。为了演示的目的,假设我们只分析网络中的五个网页。让我们称这组页面为 my_pages,它们一起位于名为 my_web 的网络中:

my_web = nx.DiGraph()
my_pages = range(1,6) 

现在,让我们随机连接它们以模拟一个实际网络:

connections = [(1,3),(2,1),(2,3),(3,1),(3,2),(3,4),(4,5),(5,1),(5,4)]
my_web.add_nodes_from(my_pages)
my_web.add_edges_from(connections) 

现在,让我们绘制这个图:

pos = nx.shell_layout(my_web)
nx.draw(my_web, pos, arrows=True, with_labels=True)
plt.show() 

它创建了我们网络的视觉表示,如下所示:

Shape  Description automatically generated with medium confidence

图 4.7:网络的视觉表示

在 PageRank 算法中,网页的模式包含在一个称为转移矩阵的矩阵中。有算法不断更新转移矩阵,以捕捉网络不断变化的状态。转移矩阵的大小为 n x n,其中 n 是节点的数量。矩阵中的数字是访问者由于出站链接而下次转到该链接的概率。

在我们的情况下,前面的图显示了我们拥有的静态网页。让我们定义一个可以用来创建转移矩阵的函数:

def create_page_rank(a_graph):
    nodes_set = len(a_graph)
    M = nx.to numpy_matrix(a_graph)
    outwards = np.squeeze(np.asarray (np. sum (M, axis=1)))
    prob outwards = np.array([
        1.0 / count if count>0
        else 0.0
        for count in outwards
    ])
    G = np.asarray(np.multiply (M.T, prob_outwards))
    p = np.ones(nodes_set) / float (nodes_set)
    return G, p 

注意,该函数将返回 G,它代表我们图的转移矩阵。

让我们为我们的图生成转移矩阵:

G,p = create_page_rank(my_web)
print (G) 

Calendar  Description automatically generated with medium confidence

图 4.8:转移矩阵

注意,我们图的转移矩阵是 5 x 5。每列对应图中的每个节点。例如,第二列是关于第二个节点的信息。访问者从节点 2 导航到节点 1 或节点 3 的概率为 0.5。请注意,在我们的图中,转移矩阵的对角线为 0,因为没有节点自身到自身的出站链接。在实际网络中,这可能是可能的。

注意,转移矩阵是一个稀疏矩阵。随着节点数量的增加,其大部分值将为 0。因此,图的结构被提取为转移矩阵。在转移矩阵中,节点以列和行表示:

  • :指示网络浏览者在线的节点

  • :指示浏览者因出站链接而访问其他节点的概率

在真实的网络中,PageRank 算法所需的转移矩阵是由蜘蛛持续探索链接建立的。

理解线性规划

许多现实世界问题涉及最大化或最小化一个目标,并且有一些给定的约束条件。一种方法是将目标指定为某些变量的线性函数。我们还将资源约束条件表述为这些变量上的等式或不等式。这种方法被称为线性规划问题。线性规划背后的基本算法是由乔治·丹齐格(George Dantzig)在 20 世纪 40 年代初在加利福尼亚大学伯克利分校开发的。丹齐格在为美国空军工作时,利用这一概念进行部队物流供给与产能规划的实验。

第二次世界大战结束时,丹齐格开始为五角大楼工作,并将他的算法发展成了一种叫做线性规划的技术。这一技术被用于军事作战规划。

今天,它被用于解决与基于某些约束条件最小化或最大化一个变量相关的重要现实世界问题。以下是一些此类问题的例子:

  • 基于资源最小化修车时间

  • 在分布式计算环境中分配可用的分布式资源,以最小化响应时间

  • 基于公司内部资源的最优分配来最大化公司的利润

构建线性规划问题

使用线性规划的条件如下:

  • 我们应该能够通过一组方程来构建问题。

  • 方程中使用的变量必须是线性的。

定义目标函数

请注意,上述三个例子的目标都是关于最小化或最大化一个变量。这个目标在数学上被表述为其他变量的线性函数,称为目标函数。线性规划问题的目标是最小化或最大化目标函数,同时保持在指定的约束条件内。

指定约束条件

在尝试最小化或最大化某个变量时,现实世界问题中往往会有一些需要遵守的约束条件。例如,在试图最小化修车时间时,我们还需要考虑到可用的机械师数量有限。通过线性方程来指定每个约束条件是构建线性规划问题的重要部分。

一个实际应用——使用线性规划进行产能规划

让我们看看一个实际的用例,看看如何使用线性规划来解决现实世界中的问题。

假设我们想要最大化一家先进工厂的利润,该工厂生产两种不同类型的机器人:

  • 高级模型A):该模型提供完整的功能。制造每个高级模型单位的利润为$4,200。

  • 基本模型B):该模型只提供基本功能。制造每个基本模型单位的利润为$2,800。

制造一台机器人需要三种不同类型的人。每种类型机器人制造所需的具体天数如下:

机器人类型 技术员 AI 专家 工程师
机器人 A:高级模型 3 天 4 天 4 天
机器人 B:基础模型 2 天 3 天 3 天

工厂采用 30 天的周期运行。每个 AI 专家在一个周期内有 30 天的可用时间。每个工程师在 30 天内会休息 8 天,因此,工程师每个周期只能工作 22 天。一个技术员在 30 天的周期中有 20 天可用。

下表显示了我们工厂中有多少人:

技术员 AI 专家 工程师
人数 1 1 2
周期总天数 1 x 20 = 20 天 1 x 30 = 30 天 2 x 22 = 44 天

这可以按如下方式建模:

  • 最大利润 = 4200A + 2800B

  • 受以下限制:

    • A ≥ 0:生产的高级机器人数量可以是 0 或更多。

    • B ≥ 0:生产的基础机器人数量可以是 0 或更多。

    • 3A + 2B ≤ 20:这些是技术员可用时间的约束。

    • 4A+3B ≤ 30:这些是 AI 专家可用时间的约束。

    • 4A + 3B ≤ 44:这些是工程师可用时间的约束。

首先,我们导入名为pulp的 Python 包,它用于实现线性规划:

import pulp 

然后,我们调用此包中的LpProblem函数来实例化问题类,并将实例命名为利润最大化问题

# Instantiate our problem class
model = pulp.LpProblem("Profit_maximising_problem", pulp.LpMaximize) 

然后,我们定义了两个线性变量AB。变量A表示生产的高级机器人数量,变量B表示生产的基础机器人数量:

A = pulp.LpVariable('A', lowBound=0,  cat='Integer')
B = pulp.LpVariable('B', lowBound=0, cat='Integer') 

我们按如下方式定义目标函数和约束条件:

# Objective function
model += 5000 * A + 2500 * B, "Profit"
# Constraints
model += 3 * A + 2 * B <= 20 
model += 4 * A + 3 * B <= 30
model += 4 * A + 3 * B <= 44 

我们使用solve函数生成解决方案:

# Solve our problem
model.solve()
pulp.LpStatus[model.status] 

然后,我们打印出AB的值以及目标函数的值:

# Print our decision variable values
print (A.varValue)
print (B.varValue) 

输出结果是:

6.0
1.0 
# Print our objective function value
print (pulp.value(model.objective)) 

它打印出:

32500.0 

线性规划广泛应用于制造业,用于寻找应使用的最优产品数量,以优化现有资源的使用。

本章到此结束!让我们总结一下所学的内容。

总结

在本章中,我们研究了设计算法的各种方法。我们考察了选择正确算法设计时的权衡。我们还讨论了制定现实世界问题的最佳实践,并学习了如何解决实际的优化问题。本章所学的内容可以用于实现设计良好的算法。

在下一章中,我们将重点介绍基于图的算法。我们将首先研究图的不同表示方式。接下来,我们将学习在各种数据点周围建立邻域的技术,以进行特定调查。最后,我们将研究在图中查找信息的最佳方法。

在 Discord 上了解更多

要加入本书的 Discord 社区——在这里你可以分享反馈、向作者提问,并了解新版本的发布——请扫描下面的二维码:

packt.link/WHLel

第五章:图算法

图提供了一种独特的数据结构表示方式,尤其与结构化或表格数据相比。虽然结构化数据(如数据库)擅长存储和查询静态、统一的信息,但图在捕捉存在于实体之间的复杂关系和模式方面表现出色。以 Facebook 为例,每个用户都是一个节点,每个友谊或互动就是一个连接的边;这种连接的网络最好通过图结构来表示和分析。

在计算领域,某些问题,尤其是涉及关系和连接的问题,更自然地通过图算法来解决。这些算法的核心目标是理解图的结构。理解这一结构意味着要弄清楚数据点(或节点)是如何通过连接(或边)相互关联的,以及如何有效地导航这些连接以提取或分析所需的数据。

在本章中,我们将踏上以下领域的探索之旅:

  • 图表示:捕捉图的各种方式。

  • 网络理论分析:网络结构背后的基础理论。

  • 图遍历:有效遍历图的技巧。

  • 案例研究:通过图算法深入分析欺诈行为。

  • 邻域技术:确定和分析大型图中局部区域的方法。

完成本章后,我们将对图作为数据结构有一个坚实的理解。我们应当能够构建复杂的关系—无论是直接的还是间接的—并将能够使用图算法解决复杂的现实问题。

理解图:简要介绍

在现代数据的广阔互联景观中,超越表格模型的局限,图结构作为强大的工具,能够 encapsulate(封装)复杂的关系。它们的崛起不仅仅是一个趋势,而是对数字世界错综复杂的结构所带来的挑战的回应。图论中的历史性进展,例如莱昂哈德·欧拉(Leonhard Euler)对柯尼斯堡七桥问题的开创性解决方案,为理解复杂关系奠定了基础。欧拉将现实世界问题转化为图形表示的方法,彻底改变了我们看待和操作图的方式。

图:现代数据网络的支柱

图不仅为社交媒体网络和推荐引擎等平台提供了支撑,还作为打开看似无关领域中模式的钥匙,例如道路网络、电路、分子、生态系统,甚至计算机程序中的逻辑流。图的关键在于它们内在的能力,能够表达有形和无形的互动。

那么,为什么这种由节点和边组成的结构在现代计算中如此重要呢?答案就在于图算法。专门为理解和解析关系而设计的这些数学算法,精确地处理连接。它们通过明确的步骤解码图结构,揭示出图的总体特征以及复杂的细节。

在深入探讨图的表示方式之前,首先要建立对其背后机制的基础理解。图结构,根植于数学和计算机科学的丰富土壤,为描绘实体之间关系提供了一种形象的方法。

现实世界的应用

现代数据中日益复杂的模式和连接,在图论中找到了清晰的解释。简单的节点和边背后,蕴藏着解决世界上一些最复杂问题的答案。当图算法的数学精确性遇到现实世界的挑战时,其结果往往会带来令人惊讶的变革性影响。

  • 欺诈检测:在数字金融世界中,欺诈交易往往彼此紧密相连,构成一张微妙的网络,旨在欺骗传统的检测系统。图论被用来识别这些模式。例如,从单一来源到多个账户的相互连接的小额交易突然激增,可能是洗钱的迹象。

    通过将这些交易绘制成图,分析人员可以识别出异常模式,隔离可疑节点,并追溯潜在欺诈的来源,从而确保数字经济的安全。

  • 航空交通管制:天空中充满了忙碌的航班。每架飞机必须在确保与其他航班保持安全距离的同时,穿越复杂的航线迷宫。图算法为天空绘制了路线,将每架飞机视为节点,其航线视为边。2010 年美国空中交通拥堵事件证明了图分析的威力。科学家们使用图论解读了系统性级联延误,提供了优化航班安排的见解,减少未来此类事件的发生。

  • 疾病传播建模:疾病,尤其是传染性疾病的传播,并非随机发生;它们遵循人类互动和流动的隐形轨迹。图论构建了复杂的模型来模拟这些模式。通过将个体视为节点,将他们的互动视为边,流行病学家成功地预测了疾病的传播,识别潜在的热点,并实施及时的干预。例如,在 COVID-19 疫情初期,图算法在预测潜在的爆发聚集区方面发挥了重要作用,帮助指导封锁和其他预防措施。

  • 社交媒体推荐:曾想过像 Facebook 或 Twitter 这样的平台如何推荐朋友或内容吗?这些推荐背后潜藏着巨大的图,代表用户的互动、兴趣和行为。例如,如果两个用户有多个共同的朋友或相似的参与模式,他们可能彼此认识或有相同的兴趣。图算法帮助解码这些连接,使平台能够通过相关推荐增强用户体验。

图的基础:顶点(或节点)

这些是图中的个体实体或数据点。想象一下你 Facebook 列表中的每个朋友都是一个独立的顶点:

  • 边(或链接):顶点之间的连接或关系。当你在 Facebook 上添加朋友时,你的顶点和他们的顶点之间形成一条边。

  • 网络:由顶点和边的互联网形成的更大结构。例如,整个 Facebook,以及其所有用户及其友谊,可以被视为一个巨大的网络。

图 5.1 中,ABC 表示顶点,而连接它们的线条则是边。这是一个图的简单表示,为我们将要探索的更复杂的结构和操作奠定了基础。

图 5.1:简单图的图形表示

图论和网络分析

图论和网络分析虽然交织在一起,但在理解复杂系统方面具有不同的功能。图论是离散数学的一个分支,提供节点(实体)和边(关系)的基本概念,而网络分析则是将这些原理应用于研究和解释现实世界网络的应用。例如,图论可以定义社交媒体平台的结构,其中个体是节点,而友谊是边;相反,网络分析则深入研究这种结构,揭示模式,如影响者中心或孤立社区,从而提供关于用户行为和平台动态的可操作见解。

我们将首先看看如何在数学上和视觉上表示这些图。然后,我们将利用一组被称为“图算法”的关键工具对这些表示进行网络分析。

图的表示方式

图是一种用顶点和边表示数据的结构。一个图被表示为 a[Graph] = (, ),其中 表示顶点集合, 表示边集合。请注意 a[Graph] 有 个顶点和 条边。需要注意的是,除非另有说明,边可以是双向的,表示连接的顶点之间存在双向关系。

一个顶点,,代表一个现实世界的对象,如一个人、一台计算机或一个活动。一个边,,连接网络中的两个顶点:

上述方程表示,在图中,所有的边属于一个集合,,所有的顶点属于另一个集合,。请注意,符号‘|’表示某个元素属于特定的集合,从而确保了边、顶点及其相应集合之间关系的清晰性。

一个顶点象征着像个人或计算机等有形实体,而一条边,连接两个顶点,表示它们之间的关系。这种关系可以是个体之间的友谊、在线连接、设备之间的物理连接,或者是像参加会议这样的参与性连接。

图的机制与类型

图有多种类型,每种图都有其独特的属性:

  • 简单图:没有平行边或自环的图。

  • 有向图(DiGraph):每条边都有方向,表示单向关系的图。

  • 无向图:一类边没有特定方向,表示相互关系的图。

  • 加权图:一类每条边都有一个权重的图,通常代表距离、成本等。

在本章中,我们将使用 networkx Python 包来表示图。可以从networkx.org/下载。让我们尝试使用 Python 中的 networkx 包来创建一个简单的图。所谓“简单图”,正如图论中所提到的,是指没有平行边或自环的图。首先,让我们尝试创建一个没有顶点或节点的空图,aGraph

import networkx as nx
graph = nx.Graph() 

让我们添加一个单独的顶点:

graph.add_node("Mike") 

我们还可以通过列表添加一系列顶点:

graph.add_nodes_from(["Amine", "Wassim", "Nick"]) 

我们还可以在现有顶点之间添加一条边,如下所示:

graph.add_edge("Mike", "Amine") 

现在让我们打印边和顶点:

print(graph.nodes())
print(graph.edges()) 
['Mike', 'Amine', 'Wassim', 'Nick']
[('Mike', 'Amine')] 

请注意,如果我们添加一条边,这也会导致添加相关的顶点,如果这些顶点尚不存在,如下所示:

G.add_edge("Amine", "Imran") 

如果我们打印节点列表,观察到的输出如下:

print(graph.edges()) 
[('Mike', 'Amine'), ('Amine', 'Imran')] 

请注意,添加已存在的顶点的请求会被默默忽略。请求会根据我们创建的图的类型被忽略或考虑。

自我中心化网络

许多网络分析的核心概念是自我中心化网络,或简而言之,称为自我网络。想象一下,你不仅想研究一个单独的节点,还想研究它的周围环境。这时,自我网络就派上用场了。

自我网络的基础

对于一个给定的顶点——我们称其为 m——与 m 直接连接的周围节点构成了 m 的直接邻域。这个邻域,加上 m 本身,就构成了 m 的自我网络。在这个语境下:

  • m 被称为 自我

  • 直接连接的节点被称为 一跳邻居 或简单称为 替代节点

一跳、二跳及更远的节点

当我们说“单跳邻居”时,我们指的是与我们关注的节点直接相连的节点。可以把它看作是从一个节点到下一个节点的一步或“一跳”。如果我们考虑两个跳跃之远的节点,那么它们被称为“二跳邻居”,以此类推。这种命名法可以扩展到任意数量的跳跃,帮助我们理解n度邻域。

特定节点3的自我网络如下图所示:

图示 说明自动生成

图 5.2:节点 3 的自我网络,展示了自我和它的单跳邻居

自我网络的应用

自我网络在社会网络分析中被广泛使用。它们对于理解大规模网络中的局部结构至关重要,并能基于个体的即时网络环境提供关于个体行为的见解。

例如,在在线社交平台中,自我网络可以帮助检测有影响力的节点,或理解信息在局部网络区域内的传播模式。

引入网络分析理论

网络分析使我们能够深入研究互联的数据,并将其呈现为网络形式。它涉及研究和应用方法论,以检查这种网络格式中排列的数据。在这里,我们将分解与网络分析相关的核心要素和概念。

网络的核心是“顶点”,它作为基本单元存在。可以把网络想象成一个网状结构;顶点是这个网的节点,而连接它们的边表示不同实体之间的关系。值得注意的是,两个顶点之间可能存在不同的关系,这意味着边可以被标记为表示不同类型的关系。比如,假设有两个人,他们可以通过“朋友”或“同事”关系相连;这两者都是不同的关系,但连接的是同样的个体。

为了充分利用网络分析的潜力,至关重要的是评估顶点在网络中的重要性,尤其是针对具体问题。存在多种技术可以帮助我们确认这一重要性。

让我们来看一下网络分析理论中一些重要的概念。

理解最短路径

在图论中,“路径”被定义为一系列节点,连接起始节点和结束节点,且不经过任何中间节点的重复。简而言之,路径描绘了两者之间的路线。这条路径的“长度”是通过计算其中包含的边数来确定的。在两个节点之间的各种路径中,包含边数最少的路径被称为“最短路径”。

确定最短路径是许多图算法中的一项基本任务。然而,这一任务并非总是简单明了的。随着时间的推移,已经开发了多种算法来解决这个问题,其中最著名的之一就是 20 世纪 50 年代末提出的迪杰斯特拉算法。该算法旨在找出图中的最短距离,并已广泛应用于 GPS 设备等领域,依赖它来推算两个点之间的最短距离。在网络路由领域,迪杰斯特拉方法再次证明了它的宝贵价值。

像谷歌和苹果这样的科技公司正在进行一场持续的竞争,特别是在增强它们的地图服务方面。目标不仅是识别最短路线,还要快速完成,通常在几秒钟内。

在本章的后面,我们将探讨广度优先搜索BFS)算法,这是一种可以作为迪杰斯特拉算法基础的方法。标准 BFS 假设图中任何路径的遍历成本相等。然而,迪杰斯特拉算法考虑了不同的遍历成本。为了将 BFS 转化为迪杰斯特拉算法,我们需要整合这些不同的遍历成本。

最后,尽管迪杰斯特拉算法侧重于从单一源点到所有其他顶点的最短路径识别,但如果目标是确定图中每对顶点之间的最短路径,则弗洛伊德-沃尔沙尔算法更为适合。

创建邻域

在深入研究图算法时,“邻域”这一术语经常出现。那么,在这种情况下,我们所说的“邻域”是什么意思呢?可以把它想象成围绕某个特定节点的紧密社区。这个“社区”由那些直接连接或与焦点节点密切相关的节点组成。

作为类比,想象一个城市地图,其中地标代表节点。位于某一显著地点附近的地标形成其“邻域”。

划分这些邻域的广泛采用的方法是通过k-阶策略。在这里,我们通过确定距离节点k跳远的顶点来界定一个节点的邻域。为了更直观的理解,当k=1时,邻域包含所有与焦点节点直接相连的节点。对于k=2,它扩展到包括那些与这些直接邻居相连的节点,模式继续下去。

想象一个圆圈中的中心点作为我们的目标顶点。当k=1时,任何与这个中心点直接连接的点就是它的邻居。随着k的增加,圆圈的半径增大,包含了距离更远的点。

利用和解释邻域对于图算法来说是重要的,因为它帮助识别关键的分析区域。

让我们看一下创建邻域的各种标准:

  1. 三角形

  2. 密度

让我们更详细地探讨这些内容。

三角形

在广阔的图论世界中,识别那些具有强大互联关系的顶点可以揭示重要的洞察。经典的方法是寻找三角形——即三个节点彼此之间保持直接连接的子图。

我们通过一个实际的应用案例——欺诈检测来探索这个问题,接下来的案例研究中将对此进行更详细的分析。想象一个相互连接的网络——一个“自我网络”——围绕一个中心人物展开——我们称他为马克斯。在这个自我网络中,除了马克斯之外,还有两个人,爱丽丝和鲍勃。现在,这三人形成了一个“三角形”——马克斯是我们的核心人物(或“自我”),而爱丽丝和鲍勃是次要人物(或“他者”)。

这时事情变得有趣了:如果爱丽丝和鲍勃有过欺诈活动的记录,那么这就引起了对马克斯可信度的质疑。这就像发现你的两个亲密朋友曾参与可疑的行为——自然会让你处于审视之下。然而,如果其中只有一个人有可疑的过去,那么马克斯的情况就变得模糊了。我们不能直接给他贴上标签,而需要更深入的调查。

为了更直观地理解,想象马克斯处于一个三角形的中心,爱丽丝和鲍勃位于另外两个顶点。它们之间的相互关系,特别是如果带有负面含义时,可以影响人们对马克斯诚信的看法。

密度

在图论中,密度是衡量网络紧密程度的一个指标。具体来说,它是图中实际存在的边数与最大可能的边数之比。在数学上,对于一个简单的无向图,密度定义为:

为了更好地理解这一点,让我们来看一个例子:

假设我们是一个有五个成员的书籍俱乐部成员:爱丽丝、鲍勃、查理、戴夫和伊芙。如果每个成员都认识并与其他每个成员都有互动,那么他们之间将总共有 10 条连接(或边)(爱丽丝-鲍勃、爱丽丝-查理、爱丽丝-戴夫、爱丽丝-伊芙、鲍勃-查理,等等)。在这种情况下,最大可能的连接数或边数是 10。如果这些连接都存在,那么密度就是:

这表明一个完全密集或完全连接的网络。

然而,假设爱丽丝只认识鲍勃和查理,鲍勃认识爱丽丝和戴夫,查理只认识爱丽丝。戴夫和伊芙虽然是成员,但尚未与任何人互动。在这种情况下,实际上只有三条连接:爱丽丝-鲍勃、爱丽丝-查理和鲍勃-戴夫。让我们来计算密度:

这个值小于 1,表明书籍俱乐部的互动网络并没有完全连接;许多潜在的互动(边)尚未发生。

从本质上讲,接近 1 的密度表示网络连接紧密,而接近 0 的值则表明互动稀疏。理解密度有助于多种场景,从分析社交网络到优化基础设施规划,通过评估系统元素的互联程度。

理解中心性度量

中心性度量提供了一个了解图中各个节点重要性的窗口。可以将中心性看作是识别网络中关键角色或枢纽的工具。例如,在社交环境中,它有助于确定具有影响力的人物或核心人物。在城市规划中,中心性可能表明在交通流动或可达性方面发挥关键作用的建筑物或交汇点。理解中心性至关重要,因为它揭示了网络中对功能、凝聚力或影响力至关重要的节点。

在图分析中最常用的中心性指标包括:

  • 度数:反映一个节点的直接连接。

  • 介数:表示一个节点在两其他节点之间的最短路径上充当桥梁的频率。

  • 紧密度:表示一个节点距离网络中所有其他节点的远近。

  • 特征向量:根据节点连接的质量而非数量来衡量节点的影响力。

注意,中心性度量适用于所有图。正如我们所知,图是对象(顶点或节点)及其关系(边)的通用表示形式,中心性度量有助于识别这些节点在图中的重要性或影响力。回想一下,网络是图的特定实现或应用,通常表示像社交网络、交通系统或通信网络这样的现实世界系统。因此,虽然讨论的中心性度量可以广泛应用于所有类型的图,但它们常常在网络的上下文中得到强调,因为它们在理解和优化现实世界系统中具有实际意义。

让我们更深入地探讨这些指标,以更好地理解它们的实用性和细微差别。

度数

连接到特定顶点的边的数量称为其度数。它可以表示一个特定顶点的连接程度以及其在网络中快速传播消息的能力。

让我们考虑 a[图] = (, ),其中 代表一组顶点, 代表一组边。回想一下,a[图] 有 个顶点和 条边。如果我们将节点的度数除以 (),这就是度数中心性:

现在,让我们看一个具体的例子。考虑以下图:

包含文字的图片,室内,设备 描述自动生成

图 5.3:一个示例图,说明了度数和度数中心性的概念

现在,在前面的图中,顶点 C 的度为 4。它的度中心性可以通过以下方式计算:

介数

介数中心性是衡量图中顶点重要性的关键指标。在社交媒体背景下,它评估一个人在特定子群体中进行通信的关键角色的可能性。在计算机网络中,当顶点代表计算机时,介数可以提供关于如果某个计算机(或顶点)发生故障时,它对节点间通信的潜在影响的见解。

计算顶点 a 的介数,给定 a[图] = (, ),按以下步骤进行:

  1. 计算 a[图] 中每对顶点之间的最短路径。我们可以用 来表示。

  2. 中,计算通过顶点 a 的最短路径数量。我们可以用 来表示。

  3. 通过以下方式计算介数:

公平性与接近度

在图论中,我们通常希望确定一个特定顶点与其他顶点之间的中心性或距离。一种量化此概念的方法是计算一个称为“公平性”的指标。对于给定的顶点“a”和图“g”,公平性通过将顶点“a”到图中每个其他顶点的距离相加来确定。本质上,它让我们了解一个顶点与其邻居之间的“分布”或“远近”。这个概念与中心性密切相关,中心性衡量的是一个顶点与所有其他顶点之间的整体距离。

相反,接近度可以被视为公平性的对立面。虽然直觉上可能认为接近度是顶点与其他顶点距离之和的负值,但从技术上讲并不准确。接近度衡量的是一个顶点与图中所有其他顶点的接近程度,通常通过取其到其他顶点距离之和的倒数来计算。

公平性和接近度是网络分析中至关重要的指标。它们提供了有关信息如何在网络中流动或某个特定节点可能有多大影响力的洞察。通过理解这些指标,可以深入理解网络结构及其潜在的动态。

特征向量中心性

特征向量中心性是一种评估图中节点重要性的指标。它不仅考虑节点的直接连接数,还考虑这些连接的质量。简而言之,如果一个节点与其他在网络中具有重要地位的节点相连接,那么这个节点就被视为重要。

为了给这个问题增加一些数学背景,假设每个节点 v 都有一个中心性得分 x(v)。对于每个节点 v,它的特征向量中心性是基于它的邻居节点的中心性得分之和,并按一个因子进行缩放!(特征向量的相关特征值):

其中 M(v) 表示 v 的邻居节点。

根据节点的邻居加权节点重要性这一思想,为谷歌开发 PageRank 算法时奠定了基础。该算法为互联网上的每个网页分配一个排名,表示其重要性,且深受特征向量中心性概念的影响。

对于有兴趣了解我们即将展示的瞭望塔示例的读者,理解特征向量中心性的本质将为复杂网络分析技术的运作提供更深入的见解。

使用 Python 计算中心性指标

让我们创建一个网络,然后尝试计算它的中心性指标。

1. 建立基础:库和数据

这包括导入必要的库和定义我们的数据:

import networkx as nx
import matplotlib.pyplot as plt 

对于我们的示例,我们考虑了一组顶点和边:

vertices = range(1, 10)
edges = [(7, 2), (2, 3), (7, 4), (4, 5), (7, 3), (7, 5), (1, 6), (1, 7), (2, 8), (2, 9)] 

在这个设置中,顶点代表网络中的单个点或节点。边则表示这些节点之间的关系或链接。

2. 构建图形

基础设置完成后,我们继续构建我们的图形。这涉及将我们的数据(顶点和边)输入到图结构中:

graph = nx.Graph()
graph.add_nodes_from(vertices)
graph.add_edges_from(edges) 

这里,Graph() 函数初始化一个空图。随后的方法 add_nodes_fromadd_edges_from 将我们的定义的节点和边添加到这个图中。

3. 描绘图像:可视化图形

图形表示通常比原始数据更具表达力。可视化不仅有助于理解,还能提供图形整体结构的快照:

nx.draw(graph, with_labels=True, node_color='y', node_size=800)
plt.show() 

这段代码为我们绘制图形。with_labels=True 方法确保每个节点都有标签,node_color 提供不同的颜色,node_size 调整节点大小以提高可读性。

设备包含的图片  描述自动生成

图 5.4:图形的示意表示,展示了节点及其相互关系

一旦我们的图形建立完成,下一步关键是计算并理解每个节点的中心性度量。正如前面所讨论的,中心性度量衡量了节点在网络中的重要性。

  • 度中心性:这个度量值给出了一个特定节点连接的节点的比例。简单来说,如果一个节点具有高的度中心性,它就与图中的许多其他节点相连。nx.degree_centrality(graph) 函数返回一个字典,字典的键是节点,值是它们相应的度中心性:

    print("Degree Centrality:", nx.degree_centrality(graph)) 
    
    Degree Centrality: {1: 0.25, 2: 0.5, 3: 0.25, 4: 0.25, 5: 0.25, 6: 0.125, 7: 0.625, 8: 0.125, 9: 0.125} 
    
  • 介数中心性:这一度量表示通过特定节点的最短路径数量。具有高介数中心性的节点可以视为图中不同部分之间的“桥梁”或“瓶颈”。函数nx.betweenness_centrality(graph)可以为每个节点计算这一度量:

    print("Betweenness Centrality:", nx.betweenness_centrality(graph)) 
    
    Betweenness Centrality: {1: 0.25, 2: 0.46428571428571425, 3: 0.0, 4: 0.0, 5: 0.0, 6: 0.0, 7: 0.7142857142857142, 8: 0.0, 9: 0.0} 
    
  • 接近中心性:这表示一个节点与图中所有其他节点的接近程度。具有高接近中心性的节点能够迅速与所有其他节点进行互动,因此位于图的中心。这个衡量标准通过nx.closeness_centrality(graph)计算:

    print("Closeness Centrality:", nx.closeness_centrality(graph)) 
    
    Closeness Centrality: {1: 0.5, 2: 0.6153846153846154, 3: 0.5333333333333333, 4: 0.47058823529411764, 5: 0.47058823529411764, 6: 0.34782608695652173, 7: 0.7272727272727273, 8: 0.4, 9: 0.4} 
    
  • 特征向量中心性:与度中心性通过计算直接连接的数量不同,特征向量中心性考虑了这些连接的质量或强度。与其他高得分节点连接的节点会得到加权,从而成为影响力节点的衡量标准。为了便于解释,我们进一步对这些中心性值进行排序:

    eigenvector_centrality = nx.eigenvector_centrality(graph)
    sorted_centrality = sorted((vertex, '{:0.2f}'.format(centrality_val)) 
                               for vertex, centrality_val in eigenvector_centrality.items())
    print("Eigenvector Centrality:", sorted_centrality) 
    
    Eigenvector Centrality: [(1, '0.24'), (2, '0.45'), (3, '0.36'), (4, '0.32'), (5, '0.32'), (6, '0.08'), (7, '0.59'), (8, '0.16'), (9, '0.16')] 
    

请注意,中心性度量值预计将给出图或子图中特定顶点的中心性度量。通过查看图形,标记为7的顶点似乎处于最中心的位置。顶点 7 在所有四个中心性度量中具有最高的值,因此反映了它在此上下文中的重要性。

现在让我们来看一下如何从图中获取信息。图是复杂的数据结构,包含了存储在顶点和边中的大量信息。我们将探讨一些有效的策略,帮助我们高效地在图中导航,以便收集信息来回答查询。

社会网络分析

社会网络分析SNA)作为图论中的一个重要应用脱颖而出。其核心是,当分析符合以下标准时,就可以被认为是社会网络分析:

  • 图中的顶点代表个体。

  • 边表示这些个体之间的社会连接,包括友谊、共同兴趣、家庭纽带、意见差异等。

  • 图分析的主要目标是理解显著的社会背景。

社会网络分析(SNA)一个有趣的方面是它能揭示与犯罪行为相关的模式。通过绘制关系和互动,可以识别出可能表明欺诈行为或异常活动的模式。例如,分析连接模式可能揭示出在特定地点存在不寻常的连接或频繁的互动,暗示潜在的犯罪热点或网络。

LinkedIn 在社会网络分析(SNA)相关新技术的研究和发展中做出了巨大贡献。实际上,LinkedIn 可以被看作是该领域许多算法的开创者。

因此,SNA(社会网络分析)——由于其固有的分布式和互联架构——是图论最强大的应用之一。抽象图形的另一种方式是将其视为一个网络,并应用针对网络设计的算法。这个领域被称为网络分析理论,我们接下来将讨论它。

理解图遍历

为了利用图形,必须从中挖掘信息。图遍历被定义为一种策略,用来确保每个顶点和边都按顺序访问。我们的目标是确保每个顶点和边都被访问一次且仅一次——既不多也不少。大致上,遍历图形搜索其中数据的方式可以有两种不同的方式。

在本章前面我们学到,通过宽度进行遍历叫做广度优先搜索BFS)——通过深度进行遍历叫做深度优先搜索DFS)。让我们逐一了解它们。

BFS

当我们处理的图形(a)具有邻居层级或级别的概念时,BFS 效果最佳。例如,当一个人在 LinkedIn 上的连接以图的形式表示时,首先是一级连接,然后是二级连接,这直接转化为层次。

BFS 算法从根顶点开始,探索邻域中的顶点。然后它移动到下一个邻域层级并重复这个过程。

让我们看一下 BFS 算法。首先,考虑以下无向图:

图表 描述自动生成

图 5.5:展示个人连接的无向图

构建邻接表

在 Python 中,字典数据结构非常适合表示图的邻接表。下面是我们如何定义一个无向图:

graph={ 'Amin'   : {'Wasim', 'Nick', 'Mike'},
         'Wasim' : {'Imran', 'Amin'},
         'Imran' : {'Wasim','Faras'},
         'Faras' : {'Imran'},
         'Mike'  : {'Amin'},
         'Nick' :  {'Amin'}} 

为了在 Python 中实现它,我们将按如下步骤进行。

我们将首先解释初始化,然后是主循环。

BFS 算法实现

算法实现将涉及两个主要阶段:初始化和主循环。

初始化

我们遍历图形的过程依赖于两个关键的数据结构:

  • visited:一个集合,用于存储我们已探索的所有顶点。它开始时为空。

  • queue:一个列表,用来存储待探索的顶点。最初,它只包含我们的起始顶点。

主循环

BFS 的主要逻辑围绕着逐层探索节点展开:

  1. 从队列中移除第一个节点,并将其视为当前迭代的节点:

    node = queue.pop(0) 
    
  2. 如果节点没有被访问过,标记为已访问并获取其邻居:

    if node not in visited:
        visited.add(node)
        neighbours = graph[node] 
    
  3. 将未访问的邻居添加到队列中:

    for neighbour in neighbours:
        if neighbour not in visited:
            queue.append(neighbour) 
    
  4. 一旦主循环完成,返回visited数据结构,它包含了所有已遍历的节点。

完整的 BFS 代码实现

完整的代码,包括初始化和主循环,将如下所示:

def bfs(graph, start):
    visited = set()
    queue = [start]
    while queue:
        node = queue.pop(0)
        if node not in visited:
            visited.add(node)
            neighbours = graph[node]
            unvisited_neighbours = [neighbour for neighbour in neighbours                                     if neighbour not in visited]
            queue.extend(unvisited_neighbours)
   return visited 

BFS 遍历机制如下:

  1. 过程从第一层开始,由节点“Amin”表示。

  2. 它接着扩展到第二层,访问“Wasim”、“Nick”和“Mike”。

  3. 随后,BFS 深入到第三层和第四层,分别访问“Imran”和“Faras”。

当 BFS 完成遍历时,所有节点都已记录在已访问集合中,队列为空。

使用 BFS 进行特定搜索

为了更好地理解 BFS 的实际操作,我们将使用已实现的函数来查找图中到特定人的路径:

图示  自动生成的描述

图 5.6:使用 BFS 对图进行层级遍历

现在,让我们尝试使用 BFS 从这个图中找到某个特定的人。我们来指定要搜索的数据,并观察结果:

start_node = 'Amin'
print(bfs(graph, start_node)) 
{'Faras', 'Nick', 'Wasim', 'Imran', 'Amin', 'Mike'} 

这表示 BFS 从Amin开始时访问的节点顺序。

现在让我们来看一下 DFS 算法。

DFS

DFS 提供了与 BFS 不同的图遍历方式。BFS 按层级逐层探索图,首先关注邻近的节点,而 DFS 则尽可能深入某条路径,直到回溯再继续探索其他路径。

想象一棵树。从根部开始,DFS 会深入到某个分支的最远叶子节点,标记沿着该分支的所有节点为已访问,然后回溯以类似的方式探索其他分支。这个思想是先到达给定分支上的最远叶节点,再考虑其他分支。“叶子”是指树中没有任何子节点的节点,或者在图的上下文中,指没有被访问的相邻节点。

为了确保遍历不会陷入死循环,特别是在有环图中,DFS 使用了一个布尔标志。该标志表示某个节点是否已经被访问,从而防止算法重新访问已访问的节点,避免陷入无限循环。

为了实现 DFS,我们将使用栈数据结构,这在第二章中已经详细讨论过,算法中的数据结构。记住,栈遵循后进先出LIFO)原则。这与队列不同,队列用于 BFS 时遵循先进先出FIFO)原则:

以下代码用于 DFS:

def dfs(graph, start, visited=None):
    if visited is None:
        visited = set()
    visited.add(start)
    print(start)
    for next in graph[start] - visited:
        dfs(graph, next, visited)
    return visited 

让我们再次使用以下代码来测试先前定义的dfs函数:

graph={ 'Amin' : {'Wasim', 'Nick', 'Mike'},
         'Wasim' : {'Imran', 'Amin'},
         'Imran' : {'Wasim','Faras'},
         'Faras' : {'Imran'},
         'Mike'  :{'Amin'},
         'Nick'  :{'Amin'}} 

如果我们运行此算法,输出将如下所示:

Amin
Wasim
Imran
Faras
Nick
Mike 

让我们来看一下使用 DFS 方法遍历这个图的完整模式:

  1. 迭代从顶层节点 Amin 开始。

  2. 然后,它移动到第二层节点 Wasim。从那里,它继续向下,直到到达末尾,即 Imran 和 Fares 节点。

  3. 完成第一个完整的分支后,它回溯并转到第二层,访问 Nick 和 Mike。

遍历模式如图 5.7所示:

图示  自动生成的描述

图 5.7:DFS 遍历的可视化表示

注意,DFS 也可以在树中使用。

现在,让我们看一个案例研究,解释我们到目前为止在本章中讨论的概念如何应用于解决现实世界的问题。

案例研究:使用社会网络分析(SNA)进行欺诈检测

介绍

人类天生具有社交性,他们的行为通常反映了他们的社交圈。在欺诈分析领域,一个叫做“同质性”的原则表示个体之间可能会基于共同的属性或行为而建立联系。例如,一个同质网络可能由来自同一故乡、同一大学或有共同爱好的人组成。其基本原则是,个体的行为,包括欺诈行为,可能会受到他们直接联系的影响。这也有时被称为“通过联系而定罪”。

在此背景下,什么是欺诈?

在本案例研究的背景下,欺诈是指可能包括冒充、信用卡盗窃、伪造支票提交或任何其他可以在关系网络中表示和分析的非法活动。为了理解这个过程,首先让我们看一个简单的案例。为此,我们将使用一个包含九个顶点和八条边的网络。在这个网络中,四个顶点是已知的欺诈案例,并被分类为欺诈F)。其余五个人没有欺诈相关的历史,分类为非欺诈NF)。

我们将通过以下步骤编写代码来生成这个图形:

  1. 让我们导入所需的包:

    import networkx as nx
    import matplotlib.pyplot as plt 
    
  2. 定义顶点的数据结构:

    vertices = range(1,10)
    edges= [(7,2), (2,3), (7,4), (4,5), (7,3), (7,5), (1,6),(1,7),(2,8),(2,9)] 
    
  3. 实例化图形:

    graph = nx.Graph() 
    
  4. 现在,绘制图形:

    graph.add_nodes_from(vertices)
    graph.add_edges_from(edges)
    positions = nx.spring_layout(graph) 
    
  5. 让我们定义 NF 节点:

    nx.draw_networkx_nodes(graph, positions, 
                           nodelist=[1, 4, 3, 8, 9], 
                           with_labels=True, 
                           node_color='g', 
                           node_size=1300) 
    
  6. 现在,让我们创建已知涉及欺诈的节点:

    nx.draw_networkx_nodes(graph, positions, 
                           nodelist=[1, 4, 3, 8, 9], 
                           with_labels=True, 
                           node_color='g', 
                           node_size=1300) 
    
  7. 最后,为节点创建标签:

    labels = {1: '1 NF', 2: '2 F', 3: '3 NF', 4: '4 NF', 5: '5 F', 6: '6 F', 7: '7 F', 8: '8 NF', 9: '9 NF'}
    nx.draw_networkx_labels(graph, positions, labels, font_size=16)
    nx.draw_networkx_edges(graph, positions, edges, width=3, alpha=0.5, edge_color='b')
    plt.show() 
    

一旦上述代码运行,它将显示如下图形:

图表 描述自动生成

图 5.8:初始网络表示,展示了欺诈节点和非欺诈节点

请注意,我们已经进行过详细的分析,将每个节点分类为图形节点或非图形节点。假设我们将添加一个新的顶点,命名为q,如下面的图所示。我们没有关于这个人是否涉及欺诈的先前信息。我们希望根据这个人与社交网络中现有成员的联系,将此人分类为 NF 或 F:

图示 描述自动生成

图 5.9:向现有网络引入新节点

我们设计了两种方法来将这个新加入的人(由节点q表示)分类为 F 或 NF:

  • 使用一个简单的方法,不使用中心性度量和关于欺诈类型的附加信息

  • 使用一种瞭望塔方法,这是一种高级技术,利用现有节点的中心性度量,以及关于欺诈类型的附加信息

我们将详细讨论每种方法。

进行简单的欺诈分析

简单的欺诈分析技术基于这样一个假设:在一个网络中,个人的行为受到他们所连接的人影响。在一个网络中,两个顶点如果相互连接,它们更可能表现出相似的行为。

基于这个假设,我们将设计一个简单的技术。如果我们想找出某个特定节点a属于F的概率,该概率表示为P(F/q),其计算方法如下:

让我们将其应用到前面的图中,其中Neighborhood[n]表示顶点n的邻域,w(n, n[j])表示nn[j]之间连接的权重。另外,DOS[normalized]是怀疑程度的标准化值,介于 0 和 1 之间。最后,degree[q]是节点q的度数。

概率的计算方法如下:

基于这一分析,这个人涉及欺诈的可能性为 67%。我们需要设置一个阈值。如果阈值是 30%,那么此人超过了阈值,我们可以放心地将其标记为F

请注意,这一过程需要对网络中的每个新节点重复进行。

现在,让我们看看进行欺诈分析的高级方法。

展示瞭望塔欺诈分析方法

之前的简单欺诈分析技术有以下两个局限性:

  • 它并未评估社交网络中每个顶点的重要性。与涉及欺诈的中心节点的连接可能比与一个远离的孤立个体的关系具有不同的含义。

  • 在将某人标记为已知的欺诈案例时,我们不考虑犯罪的严重性。

瞭望塔欺诈分析方法解决了这两个局限性。首先,让我们看一下几个概念。

负面结果评分

如果已知某人涉及欺诈,我们称该人有一个负面结果。并非每个负面结果的严重性相同。已知冒充他人的人,其负面结果会比仅仅尝试用过期的$20 礼品卡以某种创意方式使其有效的人更为严重。

从 1 到 10 的评分中,我们将不同的负面结果评分如下:

负面结果 负面结果评分
冒充身份 10
参与信用卡盗窃 8
虚假支票提交 7
犯罪记录 6
无记录 0

请注意,这些分数将基于我们对欺诈案件及其对历史数据的影响的分析。

怀疑程度

怀疑程度DOS)量化了我们对某人可能涉及欺诈的怀疑程度。DOS 值为 0 表示该人风险较低,DOS 值为 9 表示该人风险较高。

历史数据分析表明,职业诈骗犯在其社交网络中占有重要位置。为了考虑这一点,我们首先计算网络中每个节点的四个中心性指标。然后,我们取这些节点的平均值。这反映了该特定个体在网络中的重要性。

如果与某个节点关联的人涉及诈骗,我们通过使用前述表格中预设的值对该人进行评分,从而展示该负面结果。这是为了确保犯罪的严重性体现在每个个体的 DOS 值中。

最后,我们将中心性指标的平均值与负面结果分数相乘,以获得 DOS 值。然后,通过将其除以网络中最大 DOS 值来标准化 DOS。

让我们计算前述网络中九个节点的 DOS:

节点 1 节点 2 节点 3 节点 4 节点 5 节点 6 节点 7 节点 8 节点 9
中心度 0.25 0.5 0.25 0.25 0.25 0.13 0.63 0.13 0.13
介数中心性 0.25 0.47 0 0 0 0 0.71 0 0
接近度 0.5 0.61 0.53 0.47 0.47 0.34 0.72 0.4 0.4
特征向量 0.24 0.45 0.36 0.32 0.32 0.08 0.59 0.16 0.16
中心性指标的平均值 0.31 0.51 0.29 0.26 0.26 0.14 0.66 0.17 0.17
负面结果分数 0 6 0 0 7 8 10 0 0
DOS 0 3 0 0 1.82 1.1 6.625 0 0
标准化 DOS 0 0.47 0 0 0.27 0.17 1 0 0

每个节点及其标准化的 DOS 在下图中展示:

游戏截图,描述自动生成,信心较低

图 5.10:展示具有计算 DOS 值的节点

为了计算新增节点的 DOS,我们将使用以下公式:

使用相关值,我们将按如下方式计算 DOS:

这将显示与该新节点相关的欺诈风险。这意味着,在 0 到 1 的范围内,这个人的 DOS 值为0.42。我们可以为 DOS 创建不同的风险区间,如下所示:

DOS 值 风险分类
DOS = 0 无风险
0<DOS<=0.10 低风险
0.10<DOS<=0.3 中等风险
DOS>0.3 高风险

根据这些标准,可以看出新的个体是一个高风险人员,应当标记为高风险。

通常,在进行此类分析时不涉及时间维度。但现在有一些先进的技术可以随着时间的推移观察图的增长。这使得研究人员可以查看随着网络演化而变化的顶点关系。尽管对图的时间序列分析会大大增加问题的复杂性,但它可能提供额外的欺诈证据,这在其他情况下是无法获得的。

摘要

在这一章中,我们学习了基于图的算法。本章使用了不同的技术来表示、搜索和处理表示为图的数据。我们还培养了计算两个顶点之间最短距离的技能,并在问题空间中构建了邻域。这些知识将帮助我们使用图论来解决诸如欺诈检测等问题。

在下一章中,我们将重点介绍不同的无监督机器学习算法。本章讨论的许多应用案例技术与无监督学习算法相辅相成,这些内容将在下一章中详细讨论。在数据集中找到欺诈证据就是这样的应用案例之一。

在 Discord 上了解更多

要加入本书的 Discord 社区——在这里你可以分享反馈、向作者提问并了解新版本——请扫描下面的二维码:

packt.link/WHLel

第二部分

机器学习算法

本节详细介绍了不同种类的机器学习算法,如无监督机器学习算法和传统的监督学习算法,并且还介绍了自然语言处理算法。本节包含的章节有:

  • 第六章无监督机器学习算法

  • 第七章传统监督学习算法

  • 第八章神经网络算法

  • 第九章自然语言处理算法

  • 第十章理解序列模型

  • 第十一章高级序列建模算法

第六章:无监督机器学习算法

本章讲解的是无监督机器学习算法。我们的目标是在本章结束时,能够理解无监督学习及其基本算法和方法如何有效应用于解决现实世界中的问题。

我们将涵盖以下主题:

  • 介绍无监督学习

  • 理解聚类算法

  • 降维

  • 关联规则挖掘

介绍无监督学习

如果数据不是随机生成的,它往往在多维空间中表现出某些元素之间的模式或关系。无监督学习涉及在数据集中检测并利用这些模式,以便更有效地对其进行结构化和理解。无监督学习算法揭示这些模式,并利用它们作为赋予数据集特定结构的基础。识别这些模式有助于更深入地理解和呈现数据。从原始数据中提取模式有助于更好地理解原始数据。

该概念如图 6.1所示:

形状,箭头 说明自动生成

图 6.1:使用无监督机器学习从未标记的原始数据中提取模式

在接下来的讨论中,我们将穿越 CRISP-DM 生命周期,这是一种流行的机器学习过程模型。在这个背景下,我们将确定无监督学习适合的位置。举例来说,可以将无监督学习比作侦探在没有预设知识的情况下,通过拼凑线索来形成模式或群组。就像侦探的洞察力对破案至关重要一样,无监督学习在机器学习生命周期中也扮演着关键角色。

无监督学习在数据挖掘生命周期中的作用

首先让我们了解一下典型机器学习过程的不同阶段。为了理解机器学习生命周期的不同阶段,我们将通过使用机器学习进行数据挖掘的例子来学习。数据挖掘是从给定数据集中发现有意义的相关性、模式和趋势的过程。为了讨论使用机器学习进行数据挖掘的不同阶段,本书采用了跨行业数据挖掘标准流程CRISP-DM)。CRISP-DM 由来自不同组织的数据挖掘专家们设计并实现,包括克莱斯勒和 IBM 等知名公司。更多详情请参考www.ibm.com/docs/en/spss-modeler/saas?topic=dm-crisp-help-overview

CRISP-DM 生命周期包含六个不同的阶段,见下图:

图示 说明自动生成

图 6.2:CRISP-DM 生命周期的不同阶段

让我们逐一解析并探索每个阶段。

阶段 1:业务理解

本阶段的重点是收集需求,并尝试从业务角度深入理解问题。定义问题的范围并根据机器学习适当重新表述它是这一阶段的重要部分。本阶段包括确定目标、定义项目的范围,并理解利益相关者的需求。

需要注意的是,CRISP-DM 生命周期的第一阶段是关于业务理解。它关注的是需要做什么,而不是如何做。

阶段 2:数据理解

本阶段的重点是理解可用于数据挖掘的数据。在此阶段,我们将了解给定的数据集中是否拥有解决第一阶段定义的问题所需的所有信息。我们可以使用数据可视化、仪表板和汇总报告等工具来理解数据中的模式。如本章后面所述,无监督机器学习算法也可以用于发现数据中的模式,并通过详细分析其结构来理解这些模式。

阶段 3:数据准备

这涉及为我们将在阶段 4 中训练的机器学习模型准备数据。根据使用案例和需求,数据准备可能包括去除异常值、归一化、去除空值以及减少数据的维度。后续章节会更详细地讨论这些内容。数据处理和准备好后,通常会以 70-30 的比例进行分割。较大的一部分,称为训练数据,用于训练模型识别各种模式,而较小的一部分,称为测试数据,则保存以评估模型在阶段 5 中对未见数据的表现。还可以选择保留一部分数据用于验证和微调模型,以防止过拟合。

阶段 4:建模

这是我们通过训练模型来形成数据模式的阶段。对于模型训练,我们将使用在阶段 3 中准备的训练数据分区。模型训练包括将我们准备好的数据输入机器学习算法。通过迭代学习,算法识别并学习数据中固有的模式。目标是形成表示数据集中不同变量之间关系和依赖性的模式。我们将在后续章节中讨论这些数学公式的复杂性和性质如何在很大程度上依赖于我们选择的算法——例如,线性回归模型将生成一个线性方程,而决策树模型将构建一个类似树的决策模型。

除了模型训练,模型调优也是 CRISP-DM 生命周期这一阶段的一个组成部分。该过程包括优化学习算法的参数,以提高其性能,从而使预测更为准确。它涉及使用可选的验证集对模型进行微调,帮助调整模型的复杂性,找到从数据中学习与对未见数据进行概括之间的平衡。在机器学习中,验证集是数据集的一部分,用于对预测模型进行精细调整。

它有助于调整模型的复杂性,旨在找到已知数据学习与对未见数据进行概括之间的最佳平衡。这种平衡对于防止过拟合至关重要,过拟合是指模型过于“记住”训练数据,从而在新数据上表现不佳。因此,模型调优不仅可以提升模型的预测能力,还可以确保其稳健性和可靠性。

阶段 5:评估

这一阶段涉及通过使用来自阶段 3 的测试数据来评估最近训练的模型。我们将模型的性能与阶段 1 中设定的基准进行对比。基准在机器学习中的作用是提供一个参考点,可以通过多种方法来确定。它可以通过基本的基于规则的系统、简单的统计模型、随机机会,甚至是基于人类专家的表现来建立。基准的目的是提供一个最小的性能门槛,我们的机器学习模型应该超越这个门槛。基准充当比较的标准,给我们提供一个期望的参考点。如果模型的评估结果与阶段 1 中最初定义的期望一致,我们就可以继续。如果不一致,我们必须重新审视并迭代所有之前的阶段,从阶段 1 开始。

阶段 6:部署

一旦评估阶段(阶段 5)结束,我们就会检查训练好的模型的表现是否达到或超过了既定的期望。需要记住的是,成功的评估并不自动意味着可以部署。模型在我们的测试数据上表现良好,但这并不是判断模型是否准备好解决实际问题的唯一标准,如阶段 1 所定义的那样。我们必须考虑诸如模型在从未见过的新数据上的表现、如何与现有系统集成以及如何处理未预见的边缘情况等因素。因此,只有当这些广泛的评估得到了令人满意的验证时,我们才能自信地将模型部署到生产环境中,在那里它开始为我们预先定义的问题提供可用的解决方案。

CRISP-DM 生命周期的第 2 阶段(数据理解)和第 3 阶段(数据准备)都是关于理解数据并为训练模型做准备。这些阶段涉及数据处理。一些组织为此数据工程阶段配备了专门的人员。

很明显,提出问题解决方案的过程是完全数据驱动的。结合有监督和无监督机器学习来制定可行的解决方案。本章专注于解决方案中的无监督学习部分。

数据工程包括第 2 阶段和第 3 阶段,是机器学习中最耗时的部分。它可能占据典型机器学习ML)项目的 70% 的时间和资源(Data Management in Machine Learning: Challenges, Techniques, and Systems, Cody 等人,SIGMOD ‘17:2017 年 ACM 国际数据管理会议论文集,2017 年 5 月)。无监督学习算法在数据工程中可以发挥重要作用。

以下部分提供了关于无监督算法的更多详细信息。

当前无监督学习的研究趋势

机器学习研究领域经历了显著的变化。早期的重点主要集中在有监督学习技术上。这些方法对推理任务立即有效,提供了明显的优势,如节省时间、降低成本和提高预测准确性。

相反,无监督机器学习算法的内在能力最近才开始引起关注。与有监督的对应方法不同,无监督技术在没有直接指令或先入为主的假设下运作。它们擅长探索数据中更广泛的“维度”或方面,从而使数据集的审查更加全面。

为了澄清,在机器学习术语中,“特征”是被观察现象的个体可度量的属性或特征。例如,在一个涉及客户信息的数据集中,特征可能包括客户的年龄、购买历史或浏览行为。“标签”则代表我们希望模型根据这些特征预测的结果。

虽然有监督学习主要集中于建立这些特征与特定标签之间的关系,但无监督学习并不局限于预先确定的标签。相反,它可以更深入地挖掘,发现各种特征之间的复杂模式,这些模式在使用有监督方法时可能会被忽视。这使得无监督学习在应用中具有更广阔和灵活的潜力。

然而,无监督学习固有的灵活性带来了一个挑战。由于探索空间更大,它往往会导致更高的计算需求,从而带来更高的成本和更长的处理时间。此外,由于其探索性特点,管理无监督学习任务的规模或“范围”可能更为复杂。然而,能够挖掘数据中隐藏的模式或关联,使得无监督学习成为数据驱动洞察的强大工具。

如今,研究趋势正朝着监督学习和无监督学习方法的整合发展。这种结合策略旨在利用两种方法的优势。

现在让我们来看一些实际的例子。

实际示例

目前,无监督学习用于更好地理解数据并为其提供更多结构——例如,它被用于市场细分、数据分类、欺诈检测和市场篮分析(本章稍后讨论)。让我们看一下无监督学习在市场细分中的应用示例。

使用无监督学习进行市场细分

无监督学习作为市场细分的强大工具。市场细分是指根据共享特征将目标市场划分为不同组的过程,使公司能够量身定制其营销策略和信息,有效地接触并吸引特定的客户群体。用于分组目标市场的特征可能包括人口统计、行为或地理相似性。通过利用算法和统计技术,它使企业能够从客户数据中提取有意义的见解,识别隐藏的模式,并根据客户行为、偏好或特征的相似性将其分为不同的群体。这种数据驱动的方法使营销人员能够制定量身定制的策略、提高客户定位能力,并增强整体营销效果。

理解聚类算法

无监督学习中最简单且最强大的技术之一是通过聚类算法将相似的模式组合在一起。它用于理解与我们要解决的问题相关的特定数据方面。聚类算法寻找数据项中的自然分组。由于该分组不基于任何目标或假设,因此它被归类为无监督学习技术。

以一个充满书籍的巨大图书馆为例。每本书代表一个数据点——包含诸如类型、作者、出版年份等多种属性。现在,想象一个图书管理员(聚类算法),他被要求对这些书籍进行分类。在没有预设类别或说明的情况下,图书管理员开始根据书籍的属性对其进行分类——所有的侦探小说放在一起,经典文学放在一起,同一作者的书籍放在一起,等等。这就是我们所说的“自然组”,即那些共享相似特征的数据项被聚集在一起。

各种聚类算法所创建的分组是基于在问题空间中找到各个数据点之间的相似性。请注意,在机器学习的背景下,数据点是存在于多维空间中的一组测量值或观察结果。简单来说,它是帮助机器了解其所要完成任务的单一信息。确定数据点之间相似性的最佳方法因问题而异,且取决于我们所处理问题的性质。让我们看看可以用来计算数据点之间相似性的各种方法。

量化相似性

无监督学习技术,如聚类算法,通过在给定问题空间内确定各种数据点之间的相似性,能够有效工作。这些算法的有效性在很大程度上取决于我们是否能够正确地衡量这些相似性,在机器学习术语中,这些常常被称为“距离度量”。那么,究竟什么是距离度量呢?

本质上,距离度量是一个数学公式或方法,用于计算两个数据点之间的“距离”或相似度。在这个背景下,重要的是要理解,“距离”并不指物理距离,而是基于数据点的特征或属性来衡量相似性或差异性。

在聚类中,我们可以讨论两种主要的距离类型:簇间距离和簇内距离。簇间距离指的是不同簇(或数据点组)之间的距离。与此相对,簇内距离指的是同一簇内(或者说,同一组内)数据点之间的距离。一个好的聚类算法的目标是最大化簇间距离(确保每个簇彼此独立),同时最小化簇内距离(确保同一簇内的数据点尽可能相似)。以下是三种最常用的量化相似性的方法:

  • 欧几里得距离度量

  • 曼哈顿距离度量

  • 余弦距离度量

让我们更详细地了解这些距离度量。

欧几里得距离

不同点之间的距离可以量化两个数据点之间的相似性,并广泛应用于无监督机器学习技术,如聚类。欧几里得距离是最常用且最简单的距离度量。这里所说的“距离”量化的是两个数据点在多维空间中相似或不同的程度,这对于理解数据点的分组至关重要。最简单且最广泛使用的距离度量之一就是欧几里得距离。

欧几里得距离可以被认为是三维空间中两个点之间的直线距离,类似于我们在现实世界中测量距离的方式。例如,考虑地图上的两座城市;欧几里得距离就是这两座城市之间的“鸟飞直线”距离,即从城市 A 到城市 B 的直线距离,忽略了任何可能的障碍物,如山脉或河流。

类似地,在我们数据的多维空间中,欧几里得距离计算的是两个数据点之间最短的“直线”距离。通过这种方式,它提供了一个量化的度量,表示数据点之间的相似性或远近,基于它们的特征或属性。例如,假设我们有两个点,A(1,1)B(4,4),它们位于二维空间中,如下图所示:

图表 说明自动生成

图 6.3:计算两个给定点之间的欧几里得距离

要计算AB之间的距离——即d(A,B),我们可以使用以下勾股定理公式:

注意,这个计算适用于二维问题空间。对于n维问题空间,我们可以按照以下方式计算两个点AB之间的距离:

曼哈顿距离

在许多情况下,使用欧几里得距离度量两点之间的最短距离并不能真正代表两点之间的相似性或接近性——例如,如果两个数据点代表地图上的位置,那么通过陆地交通工具(如汽车或出租车)从 A 点到 B 点的实际距离将比通过欧几里得距离计算的距离更远。想象一下一个繁忙的城市网格,在这里你不能像欧几里得距离那样穿越建筑物从一个点到另一个点,而是必须在街道网格中导航。曼哈顿距离就像是这种现实世界中的导航——它计算的是沿着这些网格线从点 A 到点 B 所走的总距离。

对于类似的情况,我们使用曼哈顿距离,它估算了在城市街道上沿网格状街道从起点到目的地时两点之间的距离。与欧几里得距离等直线距离度量不同,曼哈顿距离在这种情况下能更准确地反映两个位置之间的实际距离。曼哈顿距离和欧几里得距离度量之间的比较如下面的图所示:

图表,折线图 说明自动生成

图 6.4:计算两个点之间的曼哈顿距离

请注意,在图中,这些点之间的曼哈顿距离表示为严格沿着该图网格线的折线路径。相比之下,欧几里得距离则显示为从 A 点到 B 点的直线。显然,曼哈顿距离总是等于或大于对应的欧几里得距离。

余弦距离

尽管欧几里得和曼哈顿距离度量在简单的低维空间中表现良好,但随着我们进入更加复杂的“高维”环境,它们的效果会减少。“高维”空间指的是包含大量特征或变量的数据集。随着维度(特征)的增加,使用欧几里得和曼哈顿距离计算的距离变得越来越没有意义,且计算负担更加繁重。

为了解决这个问题,我们在高维情况下使用“余弦距离”度量。该度量通过评估由两个数据点与原点连接所形成的角度的余弦来工作。在这里,重要的不是两点之间的物理距离,而是它们形成的角度。

如果数据点在多维空间中距离较近,它们将形成一个较小的角度,无论涉及多少维度。相反,如果数据点之间相距较远,产生的角度会更大。因此,余弦距离在高维数据中提供了一个更细致的相似度度量,帮助我们更好地理解复杂的数据模式:

图表 说明自动生成

图 6.5:计算余弦距离

文本数据几乎可以被视为一个高维空间。这源于文本数据的独特性质,每个独特的单词可以被视为一个不同的维度或特征。由于余弦距离度量在高维空间中表现得非常好,因此它在处理文本数据时是一个不错的选择。

请注意,在前面的图中,A(2,5)B(4.4)之间的角度余弦就是由图 6.5中的 表示的余弦距离。这些点之间的参考点是原点——即X(0,0)。但实际上,问题空间中的任何点都可以作为参考数据点,不一定非得是原点。

现在,让我们来看看最流行的无监督机器学习技术之一——即 k-means 聚类算法。

k-means 聚类算法

k-means 聚类算法得名于创建“k”个聚类并使用均值或平均值来确定数据点之间的“接近度”的过程。“均值”一词指的是计算每个聚类的质心或“中心点”的方法,质心本质上是聚类内所有数据点的平均值。换句话说,算法会计算聚类内每个特征的均值,从而得出一个新的数据点——质心。然后,这个质心作为衡量其他数据点“接近度”的参考点。

k-means 的流行源于其可扩展性和速度。该算法计算高效,因为它使用一个简单的迭代过程,其中聚类的质心会反复调整,直到它们成为聚类成员的代表。正是这种简单性使得该算法在处理大规模数据集时尤其快速且可扩展。

然而,k-means 算法的一个显著限制是它无法独立地确定最佳的聚类数“k”。理想的“k”依赖于给定数据集中的自然分组。这个限制背后的设计理念是保持算法的简洁和快速,因此假设有一个外部机制来计算“k”。根据问题的上下文,“k”可以直接确定。例如,如果任务是将一组数据科学学生分成两个聚类,一个专注于数据科学技能,另一个专注于编程技能,那么“k”自然为二。然而,对于那些“k”的值不容易显现的问题,可能需要通过试错的迭代过程,或基于启发式的方法,来估算最适合的数据集聚类数。

k-means 聚类的逻辑

在这一部分,我们将深入探讨 k-means 聚类算法的工作原理。我们将逐步拆解它的操作过程,以帮助你清晰地理解其机制和应用。本节描述了 k-means 聚类算法的逻辑。

初始化

为了进行分组,k-means 算法使用距离度量来找出数据点之间的相似性或接近度。在使用 k-means 算法之前,需要选择最合适的距离度量。默认情况下,会使用欧几里得距离度量。然而,根据数据的性质和需求,你可能会发现其他距离度量,如曼哈顿距离或余弦相似度,更为合适。此外,如果数据集存在离群值,则需要设计一个机制来确定哪些标准需要被识别并从数据集中去除离群值。

有多种统计方法可用于异常值检测,如 Z-score 方法或 四分位距 (IQR) 方法。现在让我们来看一下 k-means 算法中涉及的不同步骤。

k-means 算法的步骤

k-means 聚类算法涉及的步骤如下:

第 1 步 我们选择簇的数量,k
第 2 步 在数据点中,我们随机选择 k 个点作为簇中心。
第 3 步 基于选定的距离度量,我们迭代地计算问题空间中每个点到每个 k 个簇中心的距离。根据数据集的大小,这可能是一个耗时的步骤——例如,如果簇中有 10,000 个点,且 k = 3,这意味着需要计算 30,000 个距离。
第 4 步 我们将问题空间中的每个数据点分配到最近的簇中心。
第 5 步 现在,我们问题空间中的每个数据点都有一个分配的簇中心。但我们还没有完成,因为初始簇中心的选择是基于随机选择的。我们需要验证当前随机选择的簇中心是否实际上是每个簇的质心。我们通过计算每个 k 个簇的组成数据点的均值来重新计算簇中心。这一步解释了为什么这个算法叫做 k-means。
第 6 步 如果在第 5 步中簇中心发生了变化,这意味着我们需要重新计算每个数据点的簇分配。为此,我们将回到第 3 步,重复进行计算密集型的步骤。如果簇中心没有发生变化,或者我们预定的停止条件(例如最大迭代次数)已经满足,那么我们就完成了。

以下图显示了在二维问题空间中运行 k-means 算法的结果:

图表,散点图 说明自动生成

图 6.6:k-means 聚类结果 (a) 聚类前的数据点;(b) 运行 k-means 聚类算法后的结果簇

注意,在这种情况下,运行 k-means 后得到的两个簇是明显区分开的。现在我们来看看 k-means 算法的停止条件。

停止条件

在像 k-means 这样的无监督学习算法中,停止条件在确定算法何时应该停止迭代过程中起着至关重要的作用。对于 k-means 算法,默认的停止条件是在第 5 步中簇中心不再发生移动。但是,与许多其他算法一样,k-means 算法可能需要较长的时间才能收敛,尤其是在处理大数据集和高维问题空间时。

我们可以显式地定义停止条件,而不是等待算法收敛,具体如下:

  • 通过指定最大执行时间:

    • 停止条件t>t[max],其中 t 是当前执行时间,t[max] 是我们为算法设置的最大执行时间。
  • 通过指定最大迭代次数:

    • 停止条件:如果 m>m[max],其中 m 是当前迭代次数,m[max] 是我们为算法设置的最大迭代次数。

编写 k-means 算法

我们将在您提供的一个简单二维数据集上执行 k-means 聚类,数据集包含两个特征 xy。假设你看到夜晚花园里散布的萤火虫群体。你的任务是根据它们之间的接近程度将这些萤火虫分组。这就是 k-means 聚类的本质,它是一个流行的无监督学习算法。

我们给定了一个数据集,就像我们的花园一样,数据点绘制在二维空间中。我们的数据点由 xy 坐标表示:

import pandas as pd
dataset = pd.DataFrame({
    'x': [11, 21, 28, 17, 29, 33, 24, 45, 45, 52, 51, 52, 55, 53, 55, 61, 62, 70, 72, 10],
    'y': [39, 36, 30, 52, 53, 46, 55, 59, 63, 70, 66, 63, 58, 23, 14, 8, 18, 7, 24, 10]
}) 

我们的任务是使用 k-means 算法对这些数据点进行聚类。

首先,我们导入所需的库:

from sklearn import cluster
import matplotlib.pyplot as plt 

接下来,我们将通过指定聚类数(k)来初始化 KMeans 类。对于这个例子,假设我们想将数据分成 3 个聚类:

kmeans = cluster.KMeans(n_clusters=2) 

现在,我们用数据集来训练我们的KMeans模型。值得一提的是,这个模型只需要特征矩阵(x),而不需要目标向量(y),因为它是一个无监督学习算法:

kmeans.fit(dataset) 

现在,让我们来看看标签和聚类中心:

labels = labels = kmeans.labels_
centers = kmeans.cluster_centers_
print(labels) 
[0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 0] 
print(centers) 
[[16.77777778 48.88888889]
 [57.09090909 15.09090909]] 

最后,为了可视化我们的聚类,我们绘制了数据点,并根据其分配的聚类为其着色。聚类的中心,也称为质心,也被绘制出来:

plt.scatter(dataset['x'], dataset['y'], c=labels)
plt.scatter(kmeans.cluster_centers_[:, 0], kmeans.cluster_centers_[:, 1], s=300, c='red')
plt.show() 

在图中,彩色的点表示我们的数据点及其相应的聚类,而红色的点表示每个聚类的质心。

A graph with many colored dots  Description automatically generated

图 6.7:k-means 聚类结果

注意,图中较大的点是由 k-means 算法确定的质心。

k-means 聚类的限制

k-means 算法设计简单且快速。由于其设计上的故意简化,它具有以下限制:

  • k-means 聚类的最大限制是初始聚类数必须预先确定。

  • 聚类中心的初始分配是随机的。这意味着每次运行算法时,可能会给出略微不同的聚类结果。

  • 每个数据点只能分配到一个聚类。

  • k-means 聚类对离群点敏感。

现在让我们来看看另一种无监督机器学习技术——层次聚类。

层次聚类

K 均值聚类使用自上而下的方法,因为我们从最重要的数据点开始,即聚类中心。还有一种聚类方法,与自上而下的方式不同,我们从底部开始算法。在这里,底部指的是问题空间中的每个单独数据点。解决方案是随着算法向上推进,逐步将相似的数据点聚在一起,直到达到聚类中心。这种自底向上的方法由层次聚类算法使用,且在本节中有详细讨论。

层次聚类的步骤

层次聚类包括以下步骤:

  1. 我们为问题空间中的每个数据点创建一个单独的聚类。如果问题空间包含 100 个数据点,则会从 100 个聚类开始。

  2. 我们只将彼此最接近的点分为一组。

  3. 我们检查停止条件;如果停止条件尚未满足,则重复步骤 2。

结果得到的聚类结构称为树状图

在树状图中,垂直线的高度决定了项目之间的相似度,如下图所示:

图形描述自动生成,置信度中等

图 6.8:层次聚类

请注意,停止条件在图 6.8中以虚线表示。

编写层次聚类算法

让我们学习如何在 Python 中编写层次聚类算法:

  1. 我们首先从sklearn.cluster库中导入AgglomerativeClustering,同时导入pandasnumpy包:

    from sklearn.cluster import AgglomerativeClustering
    import pandas as pd
    import numpy as np 
    
  2. 然后我们将在二维问题空间中创建 20 个数据点:

    dataset = pd.DataFrame({
        'x': [11, 11, 20, 12, 16, 33, 24, 14, 45, 52, 51, 52, 55, 53, 55, 61, 62, 70, 72, 10],
        'y': [39, 36, 30, 52, 53, 46, 55, 59, 12, 15, 16, 18, 11, 23, 14, 8, 18, 7, 24, 70]
    }) 
    
  3. 接下来,我们通过指定超参数来创建层次聚类。需要注意的是,超参数指的是在训练过程前设置的机器学习模型的配置参数,它会影响模型的行为和性能。我们使用fit_predict函数来实际处理算法:

    cluster = AgglomerativeClustering(n_clusters=2, affinity='euclidean', linkage='ward')
    cluster.fit_predict(dataset) 
    
  4. 现在让我们来看看每个数据点与所创建的两个聚类的关联:

    print(cluster.labels_) 
    
    [0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 0] 
    

你可以看到,层次聚类和k-均值算法的聚类分配非常相似。

k-均值聚类算法相比,层次聚类算法具有其独特的优势和缺点。一个关键的优势是,层次聚类不需要事先指定聚类数量,这与k-均值不同。

当数据没有明确提示最佳聚类数量时,这个特性非常有用。层次聚类还提供了树状图,这是一种类似树的图示,对于可视化数据的嵌套分组以及理解层次结构非常有帮助。

然而,层次聚类也有其缺点。它比k-均值更耗费计算资源,因此不适合处理大数据集。

理解 DBSCAN

基于密度的空间应用聚类与噪声 (DBSCAN) 是一种无监督学习技术,它基于数据点的密度进行聚类。基本思想是基于这样的假设:如果我们将数据点在一个拥挤或高密度的空间中聚集在一起,我们可以实现有意义的聚类。

这种聚类方法有两个重要的含义:

  • 基于这个思想,算法可能会将无论其形状或模式如何的点聚集在一起。这种方法有助于创建任意形状的簇。这里的“形状”指的是多维空间中数据点的模式或分布。这个能力非常有用,因为现实世界中的数据通常是复杂且非线性的,而能够创建任意形状的簇可以更准确地表示和理解这些数据。

  • 与 k-means 算法不同,我们不需要指定簇的数量,算法可以检测到数据中适当的分组数量。

以下步骤涉及 DBSCAN 算法:

  1. 算法在每个数据点周围建立一个邻域。在此上下文中,“邻域”是指一个区域,其中其他数据点会被检查与感兴趣点的接近程度。这是通过计算在一个通常由变量eps表示的距离范围内的数据点数量来实现的。在此设置中,eps变量指定了两个数据点之间的最大距离,超过该距离则不被视为在同一邻域内。默认情况下,这个距离是通过欧几里得距离度量来确定的。

  2. 接下来,算法对每个数据点的密度进行量化。它使用一个名为min_samples的变量,表示一个数据点被视为“核心实例”所需的最小其他数据点数量,这些数据点应该位于数据点的eps距离内。简单来说,核心实例是一个密集地被其他数据点包围的数据点。从逻辑上讲,数据点密度高的区域将有更多的核心实例。

  3. 每个已识别的邻域标识一个簇。需要特别注意的是,围绕一个核心实例(一个在其“eps”距离内有最小数量其他数据点的数据点)的邻域可能包含其他核心实例。这意味着核心实例并非专属于某一个簇,而是由于其与多个数据点的接近,可能有助于形成多个簇。因此,这些簇的边界可能会重叠,从而导致一个复杂的、相互连接的簇结构。

  4. 任何不是核心实例的数据点,或者不在核心实例邻域内的数据点,都被视为离群点。

让我们看看如何在 Python 中使用 DBSCAN 创建簇。

在 Python 中使用 DBSCAN 创建簇

首先,我们将从sklearn库中导入必要的函数:

from sklearn.cluster import DBSCAN
from sklearn.datasets import make_moons 

让我们使用DBSCAN来解决一个稍微复杂一点的聚类问题,这个问题涉及被称为“半月形”结构的数据。在这个上下文中,“半月形”是指两组数据点,形状像新月,每个半月代表一个独特的簇。这样的数据集构成了挑战,因为簇是不可线性分割的,意味着无法用一条直线轻松地将不同的组分开。

这是“非线性类边界”概念的应用。与可以由直线表示的线性类边界不同,非线性类边界更加复杂,通常需要曲线或多维面来准确分隔不同的类或簇。

为了生成这个半月形数据集,我们可以利用make_moons()函数。这个函数生成一个类似两个半月的螺旋图案。可以根据我们的需求调整月形的“噪声”程度和生成的样本数量。

生成的数据集如下所示:

图表,散点图  描述自动生成

图 6.9:用于 DBSCAN 的数据

为了使用 DBSCAN,我们需要提供epsmin_samples参数,正如前文所讨论的:

from matplotlib import pyplot
from pandas import DataFrame
# generate 2d classification dataset
X, y = make_moons (n_samples=1000, noise=0.05)
# scatter plot, dots colored by class value
df = DataFrame (dict (x=X[,0], y=X[,1], label=y))
colors = {0: 'red', 1:'blue'}
fig, ax = pyplot.subplots()
grouped = df.groupby('label')
for key, group in grouped:
    group.plot(ax=ax, kind='scatter', x='x', y='y', label=key, color-colors[key])
pyplot.show() 

评估聚类

良好质量的聚类目标是,属于不同簇的数据点应该是可以区分的。这意味着以下几点:

  • 属于同一簇的数据点应该尽可能相似。

  • 属于不同簇的数据点应该尽可能不同。

人类直觉可以通过可视化簇来评估聚类结果,但也有数学方法可以量化聚类的质量。这些方法不仅测量每个簇的紧密度(凝聚度)和不同簇之间的分离度,还提供了一种数值化的客观评估聚类质量的方法。轮廓分析就是一种技术,它比较由 k-means 算法创建的簇的紧密度和分离度。它是一个量化簇中凝聚度和分离度的度量指标。虽然这种技术在 k-means 的背景下有提及,但事实上它是可以推广的,适用于评估任何聚类算法的结果,而不仅限于 k-means。

轮廓分析为每个数据点分配一个分数,称为轮廓系数,范围从 0 到 1。它本质上衡量了同一簇内的每个数据点与相邻簇中数据点的距离。

聚类的应用

聚类被广泛应用于需要发现数据集潜在模式的场合。

在政府应用场景中,聚类可以用于以下方面:

  • 犯罪热点分析:将聚类应用于地理位置数据、事件报告和其他相关特征。这有助于识别犯罪高发地区,使执法机构能够优化巡逻路线并更有效地部署资源。

  • 人口社会分析:聚类可以分析诸如年龄、收入、教育和职业等人口统计数据。这有助于了解不同地区的社会经济组成,为公共政策和社会服务提供信息。

在市场研究中,聚类可以用于以下方面:

  • 市场细分:通过对消费者数据(包括消费习惯、产品偏好和生活方式指标)进行聚类,企业可以识别出不同的市场细分群体。这有助于量身定制产品开发和营销策略。

  • 定向广告:聚类有助于分析客户的在线行为,包括浏览模式、点击率和购买历史。这使得公司能够为每个客户群体创建个性化广告,从而提高参与度和转化率。

  • 客户分类:通过聚类,企业可以根据客户与产品或服务的互动、反馈和忠诚度对客户进行分类。这有助于了解客户行为、预测趋势并制定客户保持策略。

主成分分析PCA)也用于一般性的数据探索以及去除实时数据中的噪声,例如股市交易。在这种情况下,“噪声”指的是可能掩盖数据中潜在模式或趋势的随机或不规则波动。PCA 帮助过滤这些不规则波动,使数据分析和解释更加清晰。

降维

我们数据中的每个特征对应于我们问题空间中的一个维度。通过减少特征的数量,使我们的问题空间变得更简单,称为降维。它可以通过以下两种方式之一来实现:

  • 特征选择:选择在我们试图解决的问题背景下重要的特征集合

  • 特征聚合:通过以下算法之一将两个或多个特征合并以减少维度:

    • PCA:一种线性无监督机器学习算法

    • 线性判别分析LDA):一种线性有监督机器学习算法

    • KPCA:一种非线性算法

让我们更深入地了解其中一种流行的降维算法——主成分分析(PCA),并详细探讨。

主成分分析

PCA 是一种无监督机器学习方法,通常用于通过线性变换的过程来减少数据集的维度。简单来说,它是一种通过关注数据中最重要的部分来简化数据的方式,这些部分是通过它们的方差来识别的。

考虑一个数据集的图形表示,其中每个数据点都绘制在多维空间中。PCA 帮助识别主成分,这些主成分是数据变化最大的方向。在图 6.10中,我们看到了其中的两个,PC1 和 PC2。这些主成分展示了数据点分布的整体“形状”。

每个主成分对应一个新的较小维度,捕捉尽可能多的信息。从实际角度来看,这些主成分可以视为原始数据的摘要指标,使得数据更易于管理和分析。例如,在一个关于顾客行为的大型数据集中,PCA 可以帮助我们识别出定义大多数顾客行为的关键驱动因素(主成分)。

确定这些主成分的系数涉及计算数据协方差矩阵的特征向量和特征值,这是我们在后续章节中将深入探讨的话题。这些系数作为每个原始特征在新组件空间中的权重,定义了每个特征对主成分的贡献。

图表,饼图描述自动生成

图 6.10:主成分分析

进一步说明,假设你有一个包含国家经济各个方面的数据集,例如 GDP、就业率、通货膨胀等。这些数据庞大且多维。在这里,PCA 允许你将这些多个维度简化为两个主成分,PC1 和 PC2。这些成分将包含最关键的信息,同时丢弃噪声或不太重要的细节。

结果图表,以 PC1 和 PC2 作为坐标轴,将为你提供一个更易于解释的经济数据的可视化表示,每个点代表一个经济体的状态,该状态基于其 GDP、就业率和其他因素的组合。

这使得 PCA 成为简化和解释高维数据的宝贵工具。

让我们考虑以下代码:

from sklearn.decomposition import PCA
import pandas as pd
url = "https://storage.googleapis.com/neurals/data/iris.csv"
iris = pd.read_csv(url)
iris
X = iris.drop('Species', axis=1)
pca = PCA(n_components=4)
pca.fit(X) 
 Sepal.Length   Sepal.Width    Petal.Length    Petal.Width    Species
0    5.1    3.5    1.4    0.2    setosa
1    4.9    3.0    1.4    0.2    setosa
2    4.7    3.2    1.3    0.2    setosa
3    4.6    3.1    1.5    0.2    setosa
4    5.0    3.6    1.4    0.2    setosa
...    ...    ...    ...    ...    ...
145    6.7    3.0    5.2    2.3    virginica
146    6.3    2.5    5.0    1.9    virginica
147    6.5    3.0    5.2    2.0    virginica
148    6.2    3.4    5.4    2.3    virginica
149    5.9    3.0    5.1    1.8    virginica 
X = iris.drop('Species', axis=1)
pca = PCA(n_components=4)
pca.fit(X) 
PCA(n_components=4) 

现在让我们打印出我们 PCA 模型的系数:

pca_df=(pd.DataFrame(pca.components_,columns=X.columns))
pca_df 

表格描述自动生成

图 6.11:突出显示 PCA 模型系数的图示

请注意,原始 DataFrame 有四个特征:Sepal.LengthSepal.WidthPetal.LengthPetal.Width。前面的 DataFrame 指定了四个主成分 PC1、PC2、PC3 和 PC4 的系数——例如,第一行指定了 PC1 的系数,可用于替代原始的四个变量。

在这里需要注意的是,主成分的数量(在本例中为四个:PC1、PC2、PC3 和 PC4)不一定像我们之前的经济学例子那样必须是两个。主成分的数量是我们根据愿意处理数据的复杂度来选择的。我们选择的主成分越多,我们就能保留原始数据更多的方差,但这也会增加复杂度。

根据这些系数,我们可以计算输入数据框X的 PCA 组件:

X['PC1'] = X['Sepal.Length']* pca_df['Sepal.Length'][0] + X['Sepal.Width']* pca_df['Sepal.Width'][0]+ X['Petal.Length']* pca_df['Petal.Length'][0]+X['Petal.Width']* pca_df['Petal.Width'][0]
X['PC2'] = X['Sepal.Length']* pca_df['Sepal.Length'][1] + X['Sepal.Width']* pca_df['Sepal.Width'][1]+ X['Petal.Length']* pca_df['Petal.Length'][1]+X['Petal.Width']* pca_df['Petal.Width'][1]
X['PC3'] = X['Sepal.Length']* pca_df['Sepal.Length'][2] + X['Sepal.Width']* pca_df['Sepal.Width'][2]+ X['Petal.Length']* pca_df['Petal.Length'][2]+X['Petal.Width']* pca_df['Petal.Width'][2]
X['PC4'] = X['Sepal.Length']* pca_df['Sepal.Length'][3] + X['Sepal.Width']* pca_df['Sepal.Width'][3]+ X['Petal.Length']* pca_df['Petal.Length'][3]+X['Petal.Width']* pca_df['Petal.Width'][3]
X 

现在让我们在计算 PCA 组件后打印X

表格描述自动生成

图 6.12:PCA 组件的打印计算结果

现在让我们打印方差比率,并尝试理解使用 PCA 的含义:

print(pca.explained_variance_ratio_) 
[0.92461872 0.05306648 0.01710261 0.00521218] 

方差比率表示以下内容:

  • 如果我们选择用 PC1 替换原始的四个特征,那么我们将能够捕获原始变量方差的约 92.3%。由于未捕获原始四个特征的 100%方差,我们会引入一些近似。

  • 如果我们选择用 PC1 和 PC2 替换原始的四个特征,那么我们将捕获原始变量方差的额外 5.3%。

  • 如果我们选择用 PC1、PC2 和 PC3 替换原始的四个特征,那么我们将再捕获原始变量方差的 0.017%。

  • 如果我们选择用四个主成分替换原始的四个特征,那么我们将捕获原始变量方差的 100%(92.4 + 0.053 + 0.017 + 0.005),但用四个主成分替换四个原始特征是没有意义的,因为我们根本没有降低维度,什么也没实现。接下来,让我们看看 PCA 的局限性。

PCA 的局限性

尽管 PCA 有很多优点,但它也存在局限性,如下所述:

  • 首先,PCA 在处理连续变量时最为有效,因为它的基本数学原理是为处理数值数据而设计的。它处理类别变量时效果较差,而类别变量通常出现在包括性别、国籍或产品类型等属性的数据集中。例如,如果你正在分析一个包含数字响应(如年龄或收入)和类别响应(如偏好或选择的选项)的调查数据集,PCA 对于类别数据并不适用。

  • 此外,PCA 通过在较低维空间中创建原始高维数据的近似来运作。虽然这种降维简化了数据处理和操作,但它也带来了代价:一些信息的丢失。这是一个在每个使用案例中需要仔细评估的权衡。例如,如果你处理的是一个生物医学数据集,其中每个特征代表特定的基因标记,那么使用 PCA 可能会导致丢失对某种疾病的诊断或治疗至关重要的信息。

因此,虽然 PCA(主成分分析)在处理具有许多相互关联的数值变量的大型数据集时是一个强大的降维工具,但其局限性需要仔细考虑,以确保它是某个特定应用的正确选择。

关联规则挖掘

特定数据集中的模式是需要发现、理解并挖掘出其包含信息的宝藏。有一类重要的算法专注于给定数据集中的模式分析。这类算法中比较流行的一种被称为关联规则挖掘算法,它为我们提供了以下功能:

  • 能够衡量模式的频率

  • 能够建立因果关系模式

  • 通过将模式的准确性与随机猜测进行比较,量化模式的有用性

现在我们将看一些关联规则挖掘的例子。

使用示例

关联规则挖掘用于我们试图调查数据集不同变量之间的因果关系时。以下是它能帮助回答的典型问题:

  • 哪些湿度、云量和温度值可能导致明天下雨?

  • 什么类型的保险索赔可能表明欺诈行为?

  • 哪些药物组合可能会导致患者出现并发症?

正如这些例子所示,关联规则挖掘在商业智能、医疗保健和环境研究等领域有广泛的应用。这一算法是数据科学家工具包中的强大工具,能够将复杂的模式转化为跨多个领域的可操作洞察。

市场篮子分析

推荐引擎是本书第十二章《推荐引擎》中广泛讨论的一个重要主题,是个性化用户体验的强大工具。然而,还有一种更简单且有效的生成推荐的方法,称为市场篮子分析。市场篮子分析基于哪些商品经常一起购买的信息。与更复杂的推荐引擎不同,这种方法不考虑用户特定数据或用户表达的个别商品偏好。在这里必须做出区分。推荐引擎通常根据用户的过去行为、偏好以及大量其他用户特定信息创建个性化建议。相反,市场篮子分析只关注购买的商品组合,而不考虑购买者是谁或他们的个别偏好。

市场篮分析的一个主要优势是数据收集的相对简便性。收集全面的用户偏好数据可能复杂且耗时。然而,关于商品共同购买的数据通常可以直接从交易记录中提取,这使得市场篮分析成为商家进入推荐领域的一个便捷起点。例如,当我们在沃尔玛购物时,就会生成这类数据,而不需要任何特殊技术来获取这些数据。

所谓的“特殊技术”,是指额外的步骤,如进行用户调查、使用追踪 Cookie 或构建复杂的数据管道。相反,这些数据是作为销售过程的副产品随时可用的。当这些数据在一段时间内收集时,就称为跨国数据

当关联规则分析应用于便利店、超市和快餐连锁店的购物车跨国数据时,这被称为市场篮分析。它衡量一起购买一组商品的条件概率,有助于回答以下问题:

  • 商品应如何在货架上摆放?

  • 商品应该如何在营销目录中展示?

  • 基于用户的购买模式,应该推荐什么?

由于市场篮分析能够估计商品之间的关联性,因此它常常用于大宗零售市场,如超市、便利店、药店和快餐连锁店。市场篮分析的优点在于结果几乎是自解释的,意味着商业用户可以轻松理解。

我们来看一个典型的大型超市。商店中所有独特的商品可以用一个集合表示, = {item[1], item[2], . . . , item[m]}。因此,如果该超市销售 500 种不同的商品,那么将是一个包含 500 个元素的集合。

人们会在这家商店购买商品。每次有人购买商品并在收银台付款时,该商品就会被添加到某个特定交易的商品集合中,这个集合被称为商品集。在特定时间段内,这些交易会被汇总到一个集合中,表示为,其中 = {t[1], t[2], . . . , t[n]}。

我们来看以下仅由四个交易组成的简单交易数据。这些交易汇总在下面的表格中:

t[1] 门柱,护垫
t[2] 球棒,门柱,护垫,头盔
t[3] 头盔,球
t[4] 球棒,护垫,头盔

让我们更详细地看这个例子:

= {球棒,门柱,护垫,头盔,球},表示商店中所有独特的商品。

让我们考虑来自的一个事务 t[3]。注意,在 t[3] 中购买的商品可以用物品集 t[3] = {头盔, 球} 表示,意味着顾客购买了两件商品。这个集合被称为物品集,因为它包含了单个事务中购买的所有商品。由于这个物品集有两个商品,所以物品集 t[3] 的大小被认为是二。这个术语让我们能够更有效地分类和分析购买模式。

关联规则挖掘

一个关联规则从数学上描述了涉及多个事务的物品之间的关系。它通过研究两个物品集之间的关系,以 XY 的形式进行描述,其中 。此外,XY 是不重叠的物品集;这意味着

一个关联规则可以通过以下形式来描述:

{头盔, } ⇒ {自行车}

这里, {头盔, } 是 X, {自行车} 是 Y

让我们来看看不同类型的关联规则。

规则的类型

运行关联分析算法通常会从事务数据集中生成大量规则。它们中的大多数是无用的。为了挑选出能提供有用信息的规则,我们可以将它们分类为以下三种类型:

  • 微不足道的

  • 无法解释

  • 可执行的

让我们更详细地看一下这些类型。

微不足道的规则

在生成的大量规则中,许多从数据中推导出的规则是无用的,因为它们总结了有关业务的常识。这些被称为微不足道的规则。即使微不足道规则的置信度很高,它们仍然无用,不能用于任何基于数据的决策。请注意,这里“置信度”指的是在关联分析中使用的一种度量,量化了在另一个事件(A)已经发生的情况下,某个特定事件(比如 B)发生的概率。我们可以安全地忽略所有微不足道的规则。

以下是一些微不足道规则的例子:

  • 任何从高楼大厦跳下来的人很可能会死。

  • 更努力工作可以在考试中获得更好的成绩。

  • 当温度下降时,取暖器的销售量会上升。

  • 在高速公路上超速驾驶会导致发生事故的几率增加。

无法解释的规则

在运行关联规则算法之后生成的规则中,那些没有明显解释的规则是最难使用的。请注意,只有当一个规则可以帮助我们发现并理解一个新的模式,并最终可能导致某个特定的行动时,才是有用的。如果不能解释为什么事件 X 导致事件 Y,那么它就是一个无法解释的规则,因为它只不过是一个数学公式,最终探索的是两个无关和独立事件之间没有意义的关系。

以下是一些无法解释的规则的例子:

  • 穿红色衬衫的人在考试中往往表现更好。

  • 绿色自行车更容易被盗。

  • 买泡菜的人也会购买尿布。

可操作规则

可操作规则是我们寻找的黄金规则。它们为业务所理解并能带来洞察。当向熟悉业务领域的观众展示时,它们可以帮助我们发现事件的可能原因。例如,可操作规则可能基于当前的购买模式,建议在商店中为特定产品选择最佳陈列位置。它们还可以建议将哪些物品放在一起,以最大化销售机会,因为用户倾向于将这些物品一起购买。

以下是可操作规则及其相应行动的示例:

  • 规则 1:向用户社交媒体账户展示广告会提高销售的可能性。

  • 可操作项:建议广告产品的替代方式。

  • 规则 2:创建更多的价格点可以提高销售的可能性。

  • 可操作项:某一商品可以进行促销,而另一商品的价格则可以提高。

现在让我们来看一下如何对规则进行排序。

排序规则

关联规则有三种衡量方式:

  • 物品的支持度(频率)

  • 信心

  • 提升度

让我们更详细地了解一下它们。

支持度

支持度度量是一个数值,用于量化我们在数据集中寻找的模式的频率。它是通过首先计算我们感兴趣的模式出现的次数,然后将其除以所有交易的总数来计算的。

让我们来看一下针对特定项目集[a]的公式:

numItemset[a] = 包含项目集*[a]的交易数量

num[total] = 交易总数

仅通过查看支持度,我们就可以了解模式出现的稀有性。低支持度意味着我们在寻找一个稀有事件。在商业环境中,这些稀有事件可能是异常情况或离群值,可能具有重要的意义。例如,它们可能代表不寻常的客户行为或独特的销售趋势,这可能标志着需要战略关注的机会或威胁。

例如,如果项目集[a] = {头盔, 球}出现在六笔交易中的两笔中,那么支持度(项目集[a]) = 2/6 = 0.33

信心

信心是一个数值,它通过计算条件概率量化了我们可以多大程度地将左侧(X)与右侧(Y)关联。它计算了在事件X发生的情况下,事件X导致事件Y发生的概率。

数学上,考虑规则XY

该规则的信心表示为信心(XY ),其测量方式如下:

让我们看一个例子。考虑以下规则:

{头盔, } ⇒ {球门柱}

该规则的信心通过以下公式计算:

这意味着,如果某人购物篮中有{头盔,球},则有 0.5 或 50%的概率他们还会购买搭配的球棒。

提升度

估算规则质量的另一种方法是计算提升度。提升度返回一个数字,量化规则在预测结果时相比仅假设方程右侧结果所取得的改进。“改进”指的是规则在预测结果时,相比于基线或默认方法所带来的增强或改善程度。它表示规则提供的预测比仅依赖方程右侧假设所获得的预测更准确或更具洞察力的程度。如果XY项集是独立的,则提升度按如下方式计算:

关联分析的算法

在本节中,我们将探索以下两种可用于关联分析的算法:

  • Apriori 算法:由 Agrawal, R. 和 Srikant 于 1994 年提出。

  • FP-growth 算法:由 Han 等人于 2001 年提出的改进方案。

让我们逐一看一下这些算法。

Apriori 算法

Apriori 算法是一种迭代和多阶段的算法,用于生成关联规则。它基于生成与测试方法。

在执行 Apriori 算法之前,我们需要定义两个变量:支持度[阈值]和置信度[阈值]。

该算法包括以下两个阶段:

  • 候选生成阶段:它生成候选项集,其中包含所有支持度[阈值]以上的项集。

  • 筛选阶段:它会过滤掉所有低于期望置信度[阈值]的规则。

经过筛选后,得到的规则即为答案。

Apriori 算法的局限性

Apriori 算法的主要瓶颈是在第一阶段生成候选规则——例如, = {项[1],项[2],...,项[m]}可以产生 2^m 个可能的项集。由于其多阶段设计,它首先生成这些项集,然后再寻找频繁项集。这个限制是一个巨大的性能瓶颈,使得 Apriori 算法不适用于较大的项集,因为它在找到频繁项集之前会生成过多的项集,这会影响所需时间。

现在让我们深入了解 FP-growth 算法。

FP-growth 算法

频繁模式增长FP-growth)算法是对 Apriori 算法的改进。它首先展示频繁事务 FP 树,这是一棵有序树。它包括两个步骤:

  • 填充 FP 树

  • 挖掘频繁模式

让我们逐步看这些步骤。

填充 FP 树

让我们考虑下表所示的交易数据。首先,我们将其表示为稀疏矩阵:

ID 球棒 球票 护垫 头盔
1 0 1 1 0 0
2 1 1 1 1 0
3 0 0 0 1 1
4 1 0 1 1 0

现在,让我们计算每个项的频率并按频率降序排列:

频率
护垫 3
头盔 3
球棒 2
门柱 2
1

现在,让我们根据频率重新排列基于交易的数据:

ID 原始项 重新排序的项
t1 门柱,护垫 护垫,门柱
t2 球棒,门柱,护垫,头盔 头盔,护垫,门柱,球棒
t3 头盔,球 头盔,球
t4 球棒,护垫,头盔 头盔,护垫,球棒

为了构建 FP 树,让我们从 FP 树的第一分支开始。FP 树以作为根节点。为了构建树,我们可以用节点表示每个项,如下图所示(这里显示的是 t[1]的树表示)。

请注意,每个节点的标签是项的名称,频率则附加在冒号后面。同时请注意,护垫项的频率为 1:

图示  描述自动生成

图 6.13:FP 树表示第一个交易

运用相同的模式,让我们绘制所有四个交易,从而得到完整的 FP 树。FP 树有四个叶节点,每个叶节点表示与四个交易相关联的项集。请注意,我们需要计算每个项的频率,并在多次使用时增加其频率——例如,当将 t[2]添加到 FP 树时,头盔的频率增加到 2。同样,在添加 t[4]时,它的频率再次增加到 3。

得到的树如以下图所示:

图示,示意图  描述自动生成

图 6.14:FP 树表示所有交易

请注意,前面的图示中生成的 FP 树是一个有序树。这引出了 FP-growth 树的第二阶段:挖掘频繁模式。

挖掘频繁模式

FP-growth 过程的第二阶段专注于从 FP 树中挖掘频繁模式。创建有序树是一个故意的步骤,旨在生成一种便于在寻找这些频繁模式时轻松导航的数据结构。

我们从一个叶节点开始这段旅程,这是一个终点节点,并向上遍历。例如,让我们从一个叶节点项目“球棒”开始。我们接下来的任务是找出“球棒”的条件模式基。术语“条件模式基”可能听起来很复杂,但它仅仅是指从特定叶节点项目到树根的所有路径的集合。对于我们的项目“球棒”,条件模式基将包括从“球棒”节点到树顶部的所有路径。此时,理解有序和无序树之间的差异变得至关重要。在有序树(如 FP 树)中,项目遵循固定顺序,简化了模式挖掘过程。无序树不提供这种结构化设置,这可能使频繁模式的发现更具挑战性。

当计算“球棒”的条件模式基时,我们实质上是映射从“球棒”节点到根的所有路径。这些路径显示了在交易中与“球棒”经常共现的项目。实质上,我们正在遵循与“球棒”相关的树的“分支”,以理解它与其他项目的关系。这种视觉上的说明阐明了我们从哪里获取这些信息以及 FP 树如何帮助阐明交易数据中的频繁模式。球棒的条件模式基如下所示:

曲棍球: 1 护具: 1 头盔: 1
护腕: 1 头盔: 1

球棒频繁模式将如下所示:

{曲棍球, 护具, 头盔}: 球棒

{护腕, 头盔}: 球棒

使用 FP-growth 的代码

让我们看看如何使用 Python 中的 FP-growth 算法生成关联规则。为此,我们将使用pyfpgrowth包。首先,如果我们以前从未使用过pyfpgrowth,让我们先安装它:

!pip install pyfpgrowth 

然后,让我们导入我们实现此算法所需的包:

import pandas as pd
import numpy as np
import pyfpgrowth as fp 

现在我们将创建输入数据,形式为transactionSet

dict1 = {
    'id':[0,1,2,3],
    'items':[["wickets","pads"],
    ["bat","wickets","pads","helmet"],
    ["helmet","pad"],
    ["bat","pads","helmet"]]
 }
transactionSet = pd.DataFrame(dict1) 
 id    items
0    0    [wickets, pads]
1    1    [bat, wickets, pads, helmet]
2    2    [helmet, pad]
3    3    [bat, pads, helmet] 

一旦生成了输入数据,我们将基于我们在find_frequent_patterns()中传递的参数生成图案。请注意,传递给此函数的第二个参数是最小支持度,在本例中为 1:

patterns = fp.find_frequent_patterns(transactionSet['items'],1) 

图案已生成。现在让我们打印这些图案。这些图案列出了项目组合及其支持度:

patterns 
{('pad',): 1,
 ('helmet', 'pad'): 1,
 ('wickets',): 2,
 ('pads', 'wickets'): 2,
 ('bat', 'wickets'): 1,
 ('helmet', 'wickets'): 1,
 ('bat', 'pads', 'wickets'): 1,
 ('helmet', 'pads', 'wickets'): 1,
 ('bat', 'helmet', 'wickets'): 1,
 ('bat', 'helmet', 'pads', 'wickets'): 1,
 ('bat',): 2,
 ('bat', 'helmet'): 2,
 ('bat', 'pads'): 2,
 ('bat', 'helmet', 'pads'): 2,
 ('pads',): 3,
 ('helmet',): 3,
 ('helmet', 'pads'): 2} 

现在让我们生成规则:

rules = fp.generate_association_rules(patterns,0.3)
rules 
{('helmet',): (('pads',), 0.6666666666666666),
 ('pad',): (('helmet',), 1.0),
 ('pads',): (('helmet',), 0.6666666666666666),
 ('wickets',): (('bat', 'helmet', 'pads'), 0.5),
 ('bat',): (('helmet', 'pads'), 1.0),
 ('bat', 'pads'): (('helmet',), 1.0),
 ('bat', 'wickets'): (('helmet', 'pads'), 1.0),
 ('pads', 'wickets'): (('bat', 'helmet'), 0.5),
 ('helmet', 'pads'): (('bat',), 1.0),
 ('helmet', 'wickets'): (('bat', 'pads'), 1.0),
 ('bat', 'helmet'): (('pads',), 1.0),
 ('bat', 'helmet', 'pads'): (('wickets',), 0.5),
 ('bat', 'helmet', 'wickets'): (('pads',), 1.0),
 ('bat', 'pads', 'wickets'): (('helmet',), 1.0),
 ('helmet', 'pads', 'wickets'): (('bat',), 1.0)} 

每条规则都有左手边和右手边,用冒号(:)分隔。它还为我们提供了数据集中每条规则的支持度。

摘要

在本章中,我们探讨了各种无监督机器学习技术。我们研究了在何种情况下尝试降低我们试图解决的问题的维度是一个好主意,以及如何通过不同的方法实现这一点。我们还研究了无监督机器学习技术在哪些实际示例中可能非常有帮助,包括市场篮分析。

在下一章,我们将探讨各种监督学习技术。我们将从线性回归开始,然后介绍更复杂的监督学习算法,如基于决策树的算法、SVM 和 XGBoost。我们还将学习朴素贝叶斯算法,它最适用于非结构化文本数据。

在 Discord 上了解更多信息

要加入本书的 Discord 社区——在这里你可以分享反馈、向作者提问并了解新版本——请扫描下面的二维码:

packt.link/WHLel

第七章:传统的监督式学习算法

人工智能是新的电力。

—Andrew Ng

第七章,我们将关注监督式机器学习算法。这些算法的特点是依赖有标签的数据进行模型训练,其本质多样且灵活。让我们来看看一些实例,如决策树、支持向量机SVMs)和线性回归等,它们都属于监督学习范畴。

随着我们对这个领域的深入探讨,需要指出的是,本章并未涉及神经网络,尽管它是监督式机器学习中的一个重要类别。鉴于神经网络的复杂性以及该领域的快速发展,神经网络值得深入探讨,我们将在接下来的三章中展开。神经网络的广泛内容需要不止一章来充分讨论它们的复杂性和潜力。

在本章中,我们将深入探讨监督式机器学习的核心要素,重点介绍分类器和回归器。我们将通过实际问题作为案例,探索它们的能力。将展示六种不同的分类算法,接着介绍三种回归技术。最后,我们将比较它们的结果,总结本次讨论的关键要点。

本章的总体目标是帮助你理解不同类型的监督式机器学习技术,并了解哪些监督式机器学习技术最适用于特定类别的问题。

本章将讨论以下概念:

  • 理解监督式机器学习

  • 理解分类算法

  • 评估分类器性能的方法

  • 理解回归算法

  • 评估回归算法性能的方法

我们先来了解监督式机器学习的基本概念。

理解监督式机器学习

机器学习侧重于利用数据驱动的方法来创建自主系统,这些系统可以帮助我们在有无人工监督的情况下做出决策。为了创建这些自主系统,机器学习使用一组算法和方法来发现并构建数据中的可重复模式。机器学习中最流行和最强大的方法之一是监督式机器学习方法。在监督式机器学习中,算法会接收一组输入,称为特征,以及它们对应的输出,称为标签。这些特征通常包括结构化数据,如用户资料、历史销售数据或传感器测量值,而标签通常代表我们希望预测的具体结果,如客户购买习惯或产品质量评分。通过给定的数据集,监督式机器学习算法用于训练一个模型,捕捉特征和标签之间通过数学公式表示的复杂关系。这个训练好的模型是用于预测的基本工具。

监督学习中从现有数据中学习的能力类似于人类大脑从经验中学习的能力。这种学习能力利用了人类大脑的一个特性,是将决策能力和智能引入机器的基本方式。

让我们考虑一个示例,我们希望使用监督式机器学习技术训练一个模型,能够将一组电子邮件分类为合法邮件(称为合法)和垃圾邮件(称为垃圾邮件)。为了开始,我们需要从过去的例子中获取数据,以便机器可以学习什么类型的邮件内容应被分类为垃圾邮件。

使用文本数据进行的基于内容的学习任务是一个复杂的过程,并通过其中一种监督式机器学习算法实现。在本示例中,可以用于训练模型的一些监督式机器学习算法示例包括决策树和朴素贝叶斯分类器,我们将在本章稍后讨论。

目前,我们将重点讨论如何构建监督式机器学习问题。

构建监督式机器学习问题

在深入讨论监督式机器学习算法的细节之前,让我们先定义一些基本的监督式机器学习术语:

术语 解释
标签 标签是我们模型需要预测的变量。监督式机器学习模型中只能有一个标签。
特征 用于预测标签的输入变量集称为特征。
特征工程 转换特征以准备它们用于选择的监督式机器学习算法的过程称为特征工程。
特征向量 在将输入提供给监督学习算法之前,所有的特征都会组合成一个叫做特征向量的数据结构。
历史数据 用于构建标签与特征之间关系的数据,称为历史数据。历史数据包含实例。
训练/测试数据 历史数据通过实例分为两部分——较大的数据集称为训练数据,较小的数据集称为测试数据。
模型 一种数学形式,用于表达最能捕捉标签与特征之间关系的模式。
训练 使用训练数据创建模型。
测试 使用测试数据评估训练模型的质量。
预测 使用我们训练过的模型来估计标签的过程。在这个语境中,“预测”是模型的最终输出,指定了一个确切的结果。与“预测概率”不同,后者不是给出一个具体的结果,而是提供每个潜在结果的统计可能性。

一个训练过的监督学习模型能够通过根据特征来估计标签,从而进行预测。

让我们引入本章中将用于讨论机器学习技术的符号:

变量 含义
y 实际标签
ý 预测标签
d 实例总数
b 训练实例的数量
c 测试实例的数量
X_train 训练特征向量

请注意,在这个语境中,“实例”指的是我们数据集中的单个实例。每个实例包含一组特征(输入数据)和一个对应的标签(我们要预测的结果)。

让我们深入探讨我们已经引入的一些术语的实际应用。考虑一个特征向量,本质上是一个数据结构,包含了所有的特征。

比如,如果我们有“n”个特征和“b”个训练实例,我们表示这个训练特征向量为 X_train。因此,如果我们的训练数据集包含五个实例和五个变量或特征,X_train 将有五行——每个实例一行,总共有 25 个元素(5 个实例 x 5 个特征)。

在这个语境中,X_train 是一个特定术语,表示我们的训练数据集。这个数据集中的每个实例是特征和其对应标签的组合。我们用上标来表示特定实例的行号。因此,我们数据集中的单个实例表示为 (X^((1)), y^((1))),其中 X^((1)) 表示第一个实例的特征,而 y^((1)) 是它的对应标签。

因此,我们的完整标注数据集 D 可以表示为 D = {( X((1))*,y*((1))), (y ((2))*,y*((2))), ….. , (X((d))*,y*((d)))},其中 D 表示总的样本数量。

我们将D划分为两个子集——训练集D[train]和测试集D[test]。训练集D[train]可以表示为D[train] = {(X^((1)), y((1))*),(X*((2)), y((2))*),…..,(X*((b)), y^((b)))},其中‘b’是训练示例的数量。

训练模型的主要目标是确保训练集中的任何第i个样本的预测目标值('ý')与实际标签('y')尽可能一致。这确保了模型的预测反映了样本中呈现的真实结果。

现在,让我们看看一些术语是如何在实际中被构建的。

正如我们讨论过的,特征向量被定义为一个数据结构,其中存储了所有特征。

如果特征的数量是n,训练样本的数量是b,则X_train表示训练特征向量。

对于训练数据集,特征向量由X_train表示。如果训练数据集中有b个样本,那么X_train将有b行。如果有n个变量,那么训练数据集的维度将是n x b

我们将使用上标表示训练示例的行号。

我们的标签数据集中的这个特定示例由(Features^((1)), label^((1))) = (X^((1)), y^((1))*)表示。

因此,我们的标签数据集表示为D = {(X^((1)), y((1))*),(X*((2)), y((2))*),…..,(X*((d)), y^((d)))}

我们将其分为两部分——D[train] 和 D[test]。

所以,我们的训练集可以表示为D[train] = {(X^((1)), y((1))*),(X*((2)), y((2))*),…..,(X*((b)), y^((b)))}

训练模型的目标是,对于训练集中的任何第i个样本,预测的目标值应该尽可能接近样本中的实际值。换句话说:

因此,我们的测试集可以表示为D[test] = {X^((1)), y^((1))), (X^((2)), y((2))*),.....,(X*((c)), y^((c)))}

标签的值通过一个向量Y表示:

Y ={y^((1)), y^((2)), ....., y^((m))}*。

让我们通过一个例子来说明这些概念。

假设我们正在进行一个项目,目的是根据各种特征预测房价,比如卧室数量、房屋面积和房龄。我们将如何将机器学习术语应用到这个现实场景中呢?

在这种情况下,我们的“特征”将是卧室数量、房屋面积和房龄。假设我们有 50 个样本(即 50 个不同的房子,并且我们拥有这些房子的一些特征以及对应的价格)。我们可以将它们表示为一个训练特征向量,叫做X_train

X_train变成一个包含 50 行(每个房子的行)和 3 列(每个特征:卧室数、面积和房龄)的表格。这是一个 50 x 3 的矩阵,保存了我们所有的特征数据。

单个房屋的特征集和价格可能表示为((X((i))*,y*((i)))),其中X((i))包含第*i*个房屋的特征,*y*((i))是其实际价格。

我们的整个数据集D可以表示为D = {(X((1))*,y*((1))), (X((2))*,y*((2))), ... , ((X((50))*,y*((50))))}

假设我们使用 40 个房屋进行训练,剩余的 10 个用于测试。我们的训练集D[train]将是前 40 个样本:{(X((1))*,y*((1))), (X((2))*,y*((2))), ... , ((X((40))*,y*((40))))}

训练模型后,我们的目标是预测房价 ,这些预测结果应与所有训练集中房屋的实际价格 紧密匹配。

我们的测试集D[test]由剩余的 10 个样本组成:{(X((41))*,y*((41))), (X((42))*,y*((42))), ... , (X((50))*,y*((50))))}

最后,我们有Y向量,包含了我们所有实际的房价:Y ={ y^((1)), y^((2)), ....., y^((50))}

通过这个具体的例子,我们可以看到在使用监督学习预测房价时,这些概念和方程是如何转化为实践的。

理解启用条件

一个监督学习算法需要满足某些条件才能执行。启用条件是确保监督学习算法有效性的某些前提条件。这些启用条件如下:

  • 足够的示例:监督学习算法需要足够的示例来训练模型。当我们有确凿证据表明兴趣模式在数据集中得到了充分表示时,我们就认为有足够的示例。

  • 历史数据中的模式:用于训练模型的示例需要具有某种模式。我们关注的事件发生的可能性应该依赖于模式、趋势和事件的组合。在我们的模型中,标签在数学上表示了我们关注的事件。如果没有这些,我们所处理的数据就是随机数据,无法用于训练模型。

  • 有效假设:当我们使用示例训练监督学习模型时,我们期望适用于这些示例的假设在未来也能有效。让我们看一个实际的例子。如果我们想为政府训练一个可以预测学生签证是否会被批准的机器学习模型,那么理解是,当该模型用于预测时,法律和政策不会发生变化。如果在训练模型后执行了新政策或新法律,模型可能需要重新训练以纳入这些新信息。

让我们探讨如何区分分类器和回归器。

区分分类器和回归器

在机器学习模型中,标签可以是类别变量或连续变量。连续变量是数值变量,可以在两个值之间具有无限多个值,而类别变量是定性变量,可以分类为不同的类别。标签的类型决定了我们使用的有监督机器学习模型的类型。从根本上讲,我们有两种类型的有监督机器学习模型:

  • 分类器:如果标签是类别变量,则机器学习模型被称为分类器。分类器可以用来回答以下类型的业务问题:

    • 这种异常的组织生长是恶性肿瘤吗?

    • 根据当前的天气条件,明天会下雨吗?

    • 根据某位特定申请人的资料,是否应批准他们的按揭申请?

  • 回归器:如果标签是连续变量,则我们训练一个回归器。回归器可以用来回答以下类型的业务问题:

    • 根据当前的天气状况,明天会下多少雨?

    • 给定某一特定房屋的特征,价格会是多少?

让我们更详细地看看分类器和回归器。

理解分类算法

在有监督机器学习中,如果标签是类别变量,则模型被归类为分类器。回忆一下,模型本质上是从训练数据中学习到的数学表示:

  • 历史数据被称为已标记数据

  • 需要预测标签的生产数据被称为未标记数据

使用训练过的模型准确地标记未标记的数据是分类算法的真正力量。分类器预测未标记数据的标签,以回答特定的业务问题。

在我们展示分类算法的细节之前,让我们先展示一个业务问题,作为分类器的挑战。然后我们将使用六种不同的算法回答相同的挑战,这将帮助我们比较它们的方法论、思路和性能。

展示分类器挑战

我们将首先展示一个常见问题,作为挑战测试六种不同的分类算法。这个常见问题在本章中被称为分类器挑战。使用所有六个分类器来解决相同的问题将帮助我们从两个方面进行改进:

  • 所有输入变量需要处理并组装成一个复杂的数据结构,称为特征向量。使用相同的特征向量帮助我们避免为所有六种算法重复数据准备。

  • 由于使用相同的特征向量作为输入,我们可以准确比较各种算法的性能。

分类器挑战是预测一个人是否会进行购买的可能性。在零售行业,能够帮助最大化销售的一件事就是更好地理解顾客的行为。这可以通过分析历史数据中发现的模式来实现。我们先陈述问题。

问题陈述

给定历史数据,我们能否训练一个二元分类器,预测一个特定用户是否最终会根据其个人资料购买产品?

首先,让我们探索可用的标记数据集来解决这个问题:

请注意,x 是实数集合的一个成员。 表示它是一个具有 b 个实时特征的向量。 意味着它是一个二元变量,因为我们处理的是一个二分类问题。输出可以是 01,每个数字代表一个不同的类别。

对于这个具体的示例,当 y = 1 时,我们称其为正类,当 y = 0 时,我们称其为负类。为了使其更具体,当 y 等于 1 时,我们处理的是正类,意味着用户可能会进行购买。相反,当 y 等于 0 时,它表示负类,意味着用户不太可能购买任何东西。该模型将帮助我们根据用户的历史行为预测未来的用户行为。

尽管正类和负类的水平可以任意选择,但一个好的做法是将正类定义为感兴趣的事件。如果我们尝试为银行标记欺诈交易,那么正类(即 y = 1)应该是欺诈交易,而不是反过来。

现在,让我们来看以下内容:

  • 实际标签,用 y 表示

  • 预测标签,用 ý 表示

请注意,对于我们的分类器挑战,示例中标签的实际值用 y 来表示。如果在我们的示例中,有人购买了一个商品,我们就说 y = 1。预测值用 ý 来表示。输入特征向量 x 的维度将等于输入变量的数量。我们希望确定给定特定输入时,用户购买商品的概率。

所以,我们希望确定在给定特征向量 x 的特定值时,y = 1 的概率。从数学上讲,我们可以这样表示:

请注意,表达式 P(y = 1|x) 表示在事件 x 发生的条件下,事件 y 等于 1 的条件概率。换句话说,它表示在特定条件 x 的知识或存在下,结果 y 为真的概率,即 y 为正类的概率。

现在,让我们看看如何处理和组装特征向量 x 中的不同输入变量。使用处理管道组装 x 不同部分的方法将在下一部分中更详细地讨论。

使用数据处理管道进行特征工程

为所选机器学习算法准备数据的过程称为特征工程,是机器学习生命周期中的关键部分。特征工程可以分为不同的阶段或阶段。用于处理数据的多阶段处理代码统称为数据管道。尽可能使用标准处理步骤构建数据管道,可以使其可重用,并减少训练模型所需的工作量。通过使用更多经过充分测试的软件模块,代码的质量也得到了提升。

除了特征工程,值得注意的是,数据清洗也是此过程中的一个关键部分。这涉及到解决异常值检测和缺失值处理等问题。例如,异常值检测可以帮助你识别并处理那些可能会负面影响模型性能的异常数据点。同样,缺失值处理是用来填充或处理数据集中的缺失数据点,确保模型能够获得完整的数据视图。这些步骤是数据管道中不可或缺的一部分,有助于提高机器学习模型的可靠性和准确性。

让我们为分类器挑战设计一个可重用的处理管道。如前所述,我们将一次性准备数据,然后用于所有分类器。

导入数据

让我们首先导入必要的库:

import numpy as np
import sklearn,sklearn.tree
import matplotlib.pyplot as plt
import pandas as pd
import sklearn.metrics as metrics
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import OneHotEncoder, StandardScaler 

请注意,我们将使用 Python 中的pandas库,它是一个强大的开源数据操作与分析工具,提供高性能的数据结构和数据分析工具。我们还将使用sklearn,它提供了一套全面的工具和算法,用于各种机器学习任务。

导入数据

该问题的标签数据包含了示例,存储在一个名为Social_Network_Ads.csvCSV格式文件中。我们从读取这个文件开始:

# Importing the dataset
dataset = pd.read_csv('https://storage.googleapis.com/neurals/data/Social_Network_Ads.csv') 

这个文件可以从storage.googleapis.com/neurals/data/Social_Network_Ads.csv下载。

特征选择

选择与我们想要解决的问题相关的特征的过程称为特征选择。这是特征工程中的一个重要部分。

一旦文件导入后,我们会删除User ID列,该列用于识别个人,在训练模型时应该排除。通常,User ID是一个唯一标识每个人的字段,但对我们试图建模的模式或趋势没有实际意义。

因此,在训练机器学习模型之前,通常的做法是删除这些列:

dataset = dataset.drop(columns=['User ID']) 

现在,让我们使用head命令预览数据集,它将打印出该数据集的前五行:

dataset.head(5) 

数据集如下所示:

一张包含数字和文本的表格  自动生成的描述

图 7.1: 示例数据集

现在,让我们看看如何进一步处理输入数据集。

独热编码

许多机器学习模型在所有特征都作为连续变量表示时表现最好。这一要求意味着我们需要一种方法将类别特征转换为连续特征。一种常见的技术是‘独热编码’。

在我们的背景下,Gender特征是类别型的,我们的目标是使用独热编码将其转换为连续变量。但是,究竟什么是独热编码呢?

独热编码是一种将类别变量转换为机器学习算法能更好理解的格式的过程。它通过为原始特征中的每个类别创建新的二进制特征来实现这一点。例如,如果我们对‘Gender’应用独热编码,它将生成两个新特征:MaleFemale。如果性别是Male,则‘Male’特征为 1(表示真),‘Female’特征为 0(表示假),反之亦然。

现在,让我们将这个独热编码过程应用到我们的‘Gender’特征上,并继续我们的模型准备过程:

enc = sklearn.preprocessing.OneHotEncoder() 

drop='first'参数表示应删除‘Gender’特征中的第一个类别。

首先,让我们对‘Gender’进行独热编码:

enc.fit(dataset.iloc[:,[0]])
onehotlabels = enc.transform(dataset.iloc[:,[0]]).toarray() 

在这里,我们使用fit_transform方法对‘Gender’列应用独热编码。reshape(-1, 1)函数用于确保数据符合编码器所期望的正确二维格式。toarray()函数用于将输出的稀疏矩阵转换为密集的numpy数组,以便后续更方便地处理。

接下来,让我们将编码后的Gender重新添加到数据框中:

genders = pd.DataFrame({'Female': onehotlabels[:, 0], 'Male': onehotlabels[:, 1]}) 

请注意,这行代码将编码后的‘Gender’数据重新添加到数据框中。由于我们设置了drop='first',假设‘Male’类别被视为第一个类别,那么我们新添加的列‘Female’将具有1的值(表示女性),如果性别是男性,则为0

然后,我们从数据框中删除原始的Gender列,因为它已经被我们的新Female列所替代:

result = pd.concat([genders,dataset.iloc[:,1:]], axis=1, sort=False) 

一旦转换完成,让我们再次查看数据集:

result.head(5) 

Table  Description automatically generated

图 7.2: 在这里添加标题…

请注意,为了将变量从类别变量转换为连续变量,独热编码已将Gender转换为两个单独的列——MaleFemale

让我们来看看如何指定特征和标签。

指定特征和标签

让我们指定特征和标签。在本书中,我们将使用y表示标签,使用X表示特征集:

y=result['Purchased']
X=result.drop(columns=['Purchased']) 

X表示特征向量,包含我们需要用于训练模型的所有输入变量。

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

接下来,我们将把数据集分成两部分:70%用于训练,30%用于测试。这个划分的理由是,在机器学习实践中,有一个经验法则,我们希望数据集的大部分用于训练模型,使其能够从各种示例中有效学习。这里的 70%便是为了这个目的。然而,我们也需要确保我们的模型能够很好地推广到未见过的数据,而不仅仅是记住训练集。为了评估这一点,我们将把 30%的数据保留用于测试。这部分数据在训练过程中不会被使用,作为衡量训练模型性能和其对新、未见数据进行预测能力的基准:

X_train, X_test, y_train, y_test = train_test_split(X, y,test_size = 0.25, random_state = 0) 

这已经创建了以下四个数据结构:

  • X_train:包含训练数据特征的数据结构

  • X_test:包含测试数据集特征的数据结构

  • y_train:包含训练数据集中标签值的向量

  • y_test:包含测试数据集标签值的向量

现在让我们对数据集应用特征归一化。

特征缩放

在准备我们的数据集以供机器学习模型使用时,一个重要步骤是特征归一化,也称为缩放。在许多机器学习算法中,特征变量被缩放到统一的范围,通常是从 0 到 1,这可以通过确保没有单一特征因其尺度过大而主导其他特征,从而提高模型的性能。

这个过程还可以帮助算法更快地收敛到解决方案。现在,让我们对数据集应用这一变换,以获得最佳结果。

首先,我们初始化一个StandardScaler类的实例,它将用于执行缩放操作:

# Feature Scaling
sc = StandardScaler() 

然后,我们使用fit_transform方法。这个变换会对特征进行缩放,使它们的均值为0,标准差为1,这就是标准化缩放的本质。转换后的数据存储在X_train_scaled变量中:

X_train = sc.fit_transform(X_train) 

接下来,我们将应用transform方法,它将相同的变换(如前面的代码所示)应用于测试数据集X_test

X_test = sc.transform(X_test) 

在我们缩放数据后,它已经准备好作为不同分类器的输入,这些分类器将在后续章节中展示。

评估分类器

一旦模型训练完成,我们需要评估其性能。为此,我们将使用以下过程:

  1. 我们将把标记数据集分为两部分——训练分区和测试分区。我们将使用测试分区来评估训练好的模型。

  2. 我们将使用测试分区的特征来生成每一行的标签。这是我们的预测标签集。

  3. 我们将比较预测标签集与实际标签,以评估模型。

除非我们试图解决一些非常简单的问题,否则在评估模型时会出现一些误分类。我们如何解读这些误分类来确定模型质量,取决于我们选择使用哪些性能指标。

一旦我们得到了实际标签和预测标签,就可以使用一系列性能指标来评估模型。

量化模型的最佳指标将取决于我们要解决的业务问题的需求,以及训练数据集的特点。

现在让我们来看一下混淆矩阵。

混淆矩阵

混淆矩阵用于总结分类器评估的结果。二分类器的混淆矩阵如下所示:

图示  描述自动生成

图 7.3:混淆矩阵

如果我们训练的分类器标签有两个级别,那么它就是一个二分类器。监督式机器学习的第一个关键应用——特别是二分类器——是在第一次世界大战期间,用于区分飞机和飞鸟。

分类可以分为以下四类:

  • 真正阳性 (TPs):正确分类为正类的正类

  • 真阴性 (TNs):正确分类为负类的负类

  • 假阳性 (FPs):实际上是负类的被分类为正类

  • 假阴性 (FNs):实际上是正类的被分类为负类

让我们看看如何使用这四个类别来创建各种性能指标。

混淆矩阵通过详细列出正确和错误预测的数量,提供了模型性能的全面快照。它列举了 TPs、TNs、FPs 和 FNs。在这些中,正确分类指的是模型正确识别类别的实例,即 TPs 和 TNs。模型的准确率是指这些正确分类(TPs 和 TNs)在所有预测中所占的比例,可以直接从这个混淆矩阵中计算得到。混淆矩阵通过计算 TPs、TNs、FPs 和 FNs,告诉你正确分类和误分类的数量。模型准确率定义为所有预测中正确分类所占的比例,可以从混淆矩阵中轻松看到,如下所示。

当我们的数据中有近似相等数量的正例和负例示例时——这种情况称为平衡类——准确率指标可以提供一个有价值的模型性能度量。换句话说,准确率是模型正确预测数量与总预测数量的比率。例如,如果我们的模型在 100 个测试实例中正确识别了 90 个,无论它们是正例还是负例,其准确率将为 90%。这个度量可以让我们大致了解模型在两个类别中的整体表现。如果我们的数据具有平衡的类别(即正例总数大致等于负例总数),那么准确率将为我们提供对训练模型质量的良好洞察。准确率是在所有预测中分类正确的比例。

在数学上:

理解召回率和精确度

在计算准确率时,我们不区分 TP 和 TN。通过准确率评估模型是直接的,但是当数据存在不平衡类时,它将无法准确量化训练模型的质量。当数据存在不平衡类时,另外两个指标可以更好地量化训练模型的质量,即召回率和精确度。我们将利用一个流行的钻石采矿过程的例子来解释这两个额外指标的概念。

几个世纪以来,冲积钻石采矿一直是从全世界各地河床沙中提取钻石的最流行方式之一。数千年的侵蚀已知将钻石从其主要矿床冲刷到世界各地的河床中。为了采矿钻石,人们在一个大型露天矿坑中从河岸收集沙子。经过大量冲洗后,矿坑中留下大量岩石。

绝大多数这些冲刷过的岩石只是普通的石头。将其中一块岩石识别为钻石是罕见但非常重要的事件。在我们的场景中,一家矿山的所有者正在尝试使用计算机视觉来识别哪些冲刷过的岩石是普通的石头,哪些是钻石。他们正在利用形状、颜色和反射通过计算机视觉对冲刷过的岩石进行分类。

在这个例子的背景下:

TP 一块正确识别为钻石的冲刷过的岩石
TN 一块正确识别为石头的冲刷过的岩石
FP 一块错误地识别为钻石的石头
FN 一颗错误地识别为石头的钻石

让我们在心中记住这个从矿山中提取钻石的过程,来解释召回率和精确度:

  • 召回率:这计算了命中率,即在大量事件库中识别出目标事件的比例。换句话说,这个指标衡量了我们找到或“命中”大部分目标事件的能力,并尽可能减少未被识别的事件。在识别一堆普通石头中的钻石的背景下,召回率是量化寻宝成功率的指标。对于某个装满了洗净石头的坑,召回率将是识别到的钻石数量与坑内钻石总数的比例:

    假设坑内有 10 颗钻石,每颗价值 $1,000。我们的机器学习算法能够识别出其中的九颗。那么,召回率就是 9/10 = 0.90

    因此,我们能够找回 90% 的宝藏。按美元计算,我们识别出了 $9,000 的宝藏,而总值为 $10,000。

  • 精准率:精准率只关注由训练好的模型标记为正类的数据点,并丢弃其他所有数据。如果我们只筛选出模型标记为正类的事件(即 TPs 和 FPs),然后计算其准确性,这就是精准率。

    现在,让我们在钻石开采的背景下探讨精准率。假设我们希望使用计算机视觉技术从一堆洗净的石头中识别出钻石,并将其发送给客户。

    这个过程应该是自动化的。最坏的情况是算法错误地将一块石头分类为钻石,最终客户收到这块石头并因此被收费。因此,显然,为了使这个过程可行,精准率应该很高。

    针对钻石开采的例子:

理解召回率和精准率之间的权衡

使用分类器做出决策涉及一个两步过程。首先,分类器生成一个介于 0 到 1 之间的决策分数。然后,它应用一个决策阈值来确定每个数据点的类别。分数高于阈值的数据点被分配为正类,而分数低于阈值的数据点则被分配为负类。这两个步骤可以如下解释:

  1. 分类器生成一个决策分数,这是一个介于 0 到 1 之间的数字。

  2. 分类器使用一个叫做决策阈值的参数值,将当前数据点分配到两个类别之一。任何决策(分数 > 决策阈值)的数据点被预测为正类,而任何决策(分数 < 决策阈值)的数据点被预测为负类。

想象一个情境,你在经营一个钻石矿。你的任务是从一堆普通的石头中识别出珍贵的钻石。为了简化这个过程,你开发了一个机器学习分类器。该分类器检查每一块石头,分配一个介于 0 到 1 之间的决策分数,最后根据这个分数和预设的决策阈值对石头进行分类。

决策分数本质上表示分类器对某块岩石确实是钻石的信心,分数接近 1 的岩石很可能是钻石。而决策阈值是一个预定义的截止点,决定岩石的最终分类。分数高于阈值的岩石被分类为钻石(正类),而低于阈值的岩石则被丢弃为普通岩石(负类)。

现在,假设所有岩石按其决策分数升序排列,如图 7.4所示。最左侧的岩石分数最低,最不可能是钻石,而最右侧的岩石分数最高,最可能是钻石。在理想情况下,决策阈值右侧的每块岩石都会是钻石,而左侧的每块岩石都是普通石头。

考虑如图 7.4所示的情况,其中决策阈值处于中央。在决策边界的右侧,我们发现三个实际的钻石(TPs)和一个被错误标记为钻石的普通岩石(FPs)。在左侧,我们有两个正确识别的普通岩石(TNs)和两个被错误分类为普通岩石的钻石(FNs)。

因此,在决策阈值的左侧,你会发现两个正确分类和两个误分类。它们分别是 2 个真正负类(TNs)和 2 个假负类(FNs)。

让我们计算图 7.4中的召回率和精确率:

钻石与石头的对比  描述自动生成

图 7.4:精确率/召回率权衡:岩石按分类器分数排序

处于决策阈值之上的被认为是钻石。

请注意,阈值越高,精确率越高,但召回率越低。

调整决策阈值会影响精确率和召回率之间的权衡。如果我们将阈值向右移动(如图 7.5所示),我们增加了将岩石分类为钻石的标准,从而提高了精确率,但降低了召回率:

计算机屏幕截图  描述自动生成

图 7.5:精确率/召回率权衡:岩石按分类器分数排序

处于决策阈值之上的被认为是钻石。请注意,阈值越高,精确率越高,但召回率越低。

图 7.6中,我们降低了决策阈值。换句话说,我们降低了将岩石分类为钻石的标准。因此,假负类(即漏掉的宝石)会减少,但假正类(即错误信号)会增加。因此,如果我们降低阈值(如图 7.6所示),我们放宽了钻石分类的标准,增加了召回率,但降低了精确率:

计算机屏幕截图  描述自动生成

图 7.6:精确度/召回率权衡:岩石根据其分类器得分排序

那些高于决策阈值的被视为钻石。请注意,阈值越高,精确度越高,但召回率越低。

所以,调整决策边界的值就是在召回率和精确度之间权衡。我们增加决策边界以获得更好的精确度,同时可以预期更多的召回率;我们降低决策边界以获得更好的召回率,但可能会牺牲精确度。

让我们绘制一张精确度与召回率的图表,以便更好地理解两者之间的权衡:

Chart, line chart  Description automatically generated

图 7.7:精确度与召回率的关系

召回率和精确度的最佳选择是什么?

增加召回率是通过降低我们用来识别数据点为正样本的标准来实现的。精确度预计会降低,但如上图所示,当精确度降到约 0.8 时,下降幅度非常明显。这是我们可以选择合适的召回率和精确度值的点。在上面的图表中,如果我们选择 0.8 作为召回率,精确度为 0.75。我们可以将其解释为能够标记 80% 的所有目标数据点。在这个精确度水平下,我们对这些数据点的标记准确率为 75%。如果没有特定的业务需求,且只是用于通用场景,这可能是一个合理的折中方案。

展示精确度与召回率之间固有权衡的另一种方法是使用 接收操作特性曲线ROC)。为此,我们先定义两个术语:真正例率TPR)和 假正例率FPR)。

让我们看看 ROC 曲线。为了计算 TPR 和 FPR,我们需要观察坑中的钻石:

请注意:

  • TPR 等于召回率或命中率。

  • TNR 可以看作是负事件的召回率或命中率。它决定了我们正确识别负事件的成功率。它也叫做 特异度

  • FPR = 1 – TNR = 1 - 特异度。

很明显,这些图中的 TPR 和 FPR 可以通过以下方式计算:

图号 TPR FPR
7.4 3/5=0.6 1/3=0.33
7.5 2/5=0.4 0/3 = 0
7.6 5/5 = 1 1/3 = 0.33

请注意,降低我们的决策阈值会增加 TPR 或召回率。为了从矿山中尽可能多地提取钻石,我们会降低将石头分类为钻石的标准。结果是更多的石头会被错误地分类为钻石,从而增加 FPR。

请注意,一个高质量的分类算法应该能够为每一块岩石提供决策分数,这个分数大致对应岩石成为钻石的概率。该算法的输出如图 7.8所示。钻石应该位于右侧,石块应该位于左侧。在图中,随着我们将决策阈值从0.8降到0.2,我们预计 TRP 和 FPR 会有显著的提升。实际上,TRP 的急剧增加与 FPR 的轻微增加是评估二分类器质量的最佳指标之一,因为该分类算法能够生成与岩石成为钻石的可能性直接相关的决策分数。如果钻石和石块在决策分数轴上随机分布,降低决策阈值会同样可能标记为石块或钻石。这将是最差的二分类器,也叫做随机化器:

Chart, scatter chart  Description automatically generated

图 7.8:ROC 曲线

理解过拟合

如果一个机器学习模型在开发环境中表现优秀,但在生产环境中明显退化,我们称这个模型为过拟合。这意味着训练出的模型过于贴合训练数据集,模型创建的规则中包含了过多的细节。这表明模型的方差与偏差之间的权衡最能体现这一概念。

在开发机器学习模型时,我们通常会对模型需要捕捉的真实世界现象做出一些简化假设。这些假设对于使建模过程更可控、更简洁至关重要。然而,这些假设的简化会在我们的模型中引入一定程度的“偏差”。

让我们进一步分析一下。偏差是一个量化我们预测值与真实值偏离程度的术语。简单来说,如果我们有较高的偏差,意味着我们的模型预测值与实际值相差较远,这会导致训练数据上较高的误差率。

例如,考虑线性回归模型。它假设输入特征与输出变量之间存在线性关系。然而,在现实世界中,关系可能是非线性的或更加复杂的,这并不总是成立。虽然这个线性假设简化了我们的模型,但它可能会导致较高的偏差,因为它可能无法完全捕捉变量之间的实际关系。

现在,让我们来谈谈“方差”。在机器学习中,方差是指如果我们使用不同的训练数据集,我们的模型预测会发生多大的变化。一个高方差的模型非常注重训练数据,容易从噪声和细节中学习。因此,它在训练数据上的表现非常好,但在未见过的数据或测试数据上的表现较差。这种表现差异通常被称为过拟合。

我们可以通过靶心图来可视化偏差和方差,如图 7.9所示。请注意,靶心的中心是一个完美预测正确值的模型。离靶心较远的射击表示高偏差,而分散较广的射击表示高方差。在理想的情况下,我们希望偏差低、方差低,所有射击都击中靶心。然而,在现实世界中,这是一种权衡。降低偏差会增加方差,而降低方差会增加偏差。

这就是偏差-方差权衡,它是机器学习模型设计中的一个基本概念:

不同类型偏差的示意图  自动生成的描述

图 7.9:偏差和方差的图示

在机器学习模型中,平衡适当的泛化程度是一个微妙的过程。这个平衡,有时也是不平衡的,正是通过偏差-方差权衡来描述的。机器学习中的泛化指的是模型在适应新数据(这些数据来源于与训练数据相同的分布)时的能力。换句话说,一个泛化良好的模型能够将从训练数据中学到的规则有效地应用于新的、未见过的数据。通过使用更简单的假设,可以实现更广泛的规则,从而使模型对训练数据的波动不那么敏感。这意味着模型的方差较低,因为它在不同训练集之间变化不大。

然而,这也有其负面影响。简单的假设意味着模型可能无法完全捕捉数据中的所有复杂关系。这会导致模型始终偏离真实输出,从而增加偏差。

因此,从这个角度看,更高的泛化意味着更低的方差,但更高的偏差。这就是偏差-方差权衡的本质:一个过度泛化的模型(高偏差)可能会过度简化问题,错过重要的模式,而一个泛化不足的模型(高方差)可能会对训练数据进行过拟合,同时捕捉噪声和信号。

在这两种极端之间找到平衡是机器学习中的一个核心挑战,管理这种权衡的能力往往能够决定一个模型是好还是优秀。偏差与方差之间的这种权衡由算法的选择、数据的特征以及各种超参数决定。根据你试图解决的具体问题的需求,达到偏差与方差之间的适当妥协是非常重要的。

现在让我们来看一下如何指定分类器的不同阶段。

指定分类器的阶段

一旦标记数据准备好,分类器的开发就包括训练、评估和部署。这三个实施分类器的阶段在下面的跨行业数据挖掘标准流程(CRISP-DM)生命周期图中有所展示(CRISP-DM 生命周期在第五章图算法中有更详细的解释):

图表 描述自动生成

图 7.10: CRISP DM 生命周期

在实施分类器模型时,需要考虑几个关键阶段,从彻底理解当前的业务问题开始。这包括识别解决此问题所需的数据,并理解数据的实际背景。收集相关的标记数据后,下一步是将数据集分成两部分:训练集和测试集。训练集通常较大,用于训练模型以理解数据中的模式和关系。另一方面,测试集用于评估模型在未见过的数据上的表现。

为确保这两个数据集能代表整体数据分布,我们将使用随机抽样技术。这样,我们可以合理地期望整个数据集中的模式在训练集和测试集两部分中都有体现。

注意,如图 7.10所示,首先是训练阶段,在该阶段使用训练数据来训练模型。训练阶段结束后,使用测试数据评估训练后的模型。通过不同的性能矩阵来量化训练模型的表现。一旦模型被评估,我们进入模型部署阶段,将训练好的模型部署并用于推断,通过标记未标记数据来解决实际问题。

现在,让我们来看一些分类算法。

在接下来的章节中,我们将介绍以下分类算法:

  • 决策树算法

  • XGBoost 算法

  • 随机森林算法

  • 逻辑回归算法

  • SVM算法

  • 朴素贝叶斯算法

让我们从决策树算法开始。

决策树分类算法

决策树基于递归划分方法(分治法),它生成一组规则用于预测标签。算法从根节点开始,并将其划分为多个分支。内部节点表示对某个属性的测试,测试结果通过分支表示到达下一层级。决策树在叶节点结束,叶节点包含决策。当划分不再改善结果时,过程停止。

现在,让我们深入了解决策树算法的细节。

理解决策树分类算法

决策树分类的独特之处在于生成一组可由人类理解的规则层次结构,这些规则用于在运行时预测标签。该模型的透明性是其一个主要优势,因为它使我们能够理解每个预测背后的推理过程。这个层次结构是通过递归算法形成的,遵循一系列步骤。

首先,让我们通过一个简化的例子来说明这一点。考虑一个预测一个人是否会喜欢某部特定电影的决策树模型。树顶端的决策或“规则”可能是,“这部电影是喜剧吗?”如果答案是“是”,树则分支到下一个规则,比如,“这部电影是否由这个人的最喜欢的演员主演?”如果答案是否,树则分支到另一个规则。每个决策点都会进一步细分,形成规则的树状结构,直到我们得到最终的预测。

通过这一过程,决策树引导我们通过一系列易于理解的逻辑步骤,最终得出预测结果。这种清晰性使得决策树分类器区别于其他机器学习模型。

该算法本质上是递归的。创建这一规则层次结构涉及以下步骤:

  1. 找到最重要的特征:在所有特征中,算法识别出最能区分训练数据集中的数据点与标签之间的特征。计算依据诸如信息增益或基尼不纯度等度量。

  2. 二分:使用最重要的特征,算法创建一个标准,用于将训练数据集分为两个分支:

    • 符合标准的数据点

    • 不符合标准的数据点

  3. 检查叶节点:如果任何结果分支中大部分标签属于同一类别,该分支将成为最终分支,从而形成叶节点。

  4. 检查停止条件并重复:如果提供的停止条件未满足,算法将回到步骤 1进行下一次迭代。否则,模型将标记为已训练,并且结果决策树中最低级别的每个节点将被标记为叶节点。停止条件可以简单地定义为迭代次数,或者使用默认的停止条件,当算法达到每个叶节点的同质性水平时便停止。

决策树算法可以通过以下图示进行解释:

图示 说明自动生成

图 7.11:决策树

在上面的图中,根节点包含了一些圆圈和叉号。它们仅代表某个特征的两类不同类别。该算法创建了一个标准,试图将圆圈与叉号分开。在每个层级,决策树对数据进行划分,并且这些划分期望在从第 1 层开始越来越均匀。一个完美的分类器的叶节点只包含圆圈或叉号。由于现实世界数据集的内在不可预测性和噪声,训练完美的分类器通常是困难的。

请注意,决策树具有一些关键优势,使其在许多场景中成为首选。决策树分类器的优点在于其可解释性。与许多其他模型不同,它们提供了一套清晰透明的“如果-那么”规则,使得决策过程易于理解和审计。这在医疗保健或金融等领域尤为有利,在这些领域,理解预测背后的逻辑与预测本身一样重要。

此外,决策树对数据的规模不太敏感,并且能够处理分类和数值变量的混合。这使得它们在面对多样化数据类型时成为一个多功能的工具。

因此,尽管训练一个“完美”的决策树分类器可能是困难的,但它们所提供的优势,包括简单性、透明性和灵活性,通常足以克服这一挑战。

我们将使用决策树分类算法来解决分类器挑战问题。

现在,让我们使用决策树分类算法解决我们之前定义的常见问题,预测客户是否最终购买产品:

  1. 首先,让我们实例化决策树分类算法,并使用我们为分类器准备的数据的训练部分来训练一个模型:

    classifier = sklearn.tree.DecisionTreeClassifier(criterion = 'entropy', random_state = 100, max_depth=2) 
    
    DecisionTreeClassifier(criterion = 'entropy', random_state = 100, max_depth=2) 
    
  2. 现在,让我们使用我们训练好的模型来预测标注数据的测试部分的标签。让我们生成一个混淆矩阵,以总结我们训练模型的性能:

    y_pred = classifier.predict(X_test)
    cm = metrics.confusion_matrix(y_test, y_pred) 
    
  3. 这将给出以下输出:

    cm 
    
    array([[64, 4],
           [2, 30]]) 
    
  4. 现在,让我们使用决策树分类算法计算所创建分类器的准确率召回率精确度值:

    accuracy= metrics.accuracy_score(y_test,y_pred)
    recall = metrics.recall_score(y_test,y_pred)
    precision = metrics.precision_score(y_test,y_pred)
    print(accuracy,recall,precision) 
    
  5. 运行上述代码将产生以下输出:

    0.94 0.9375 0.8823529411764706 
    

性能衡量标准帮助我们将不同的训练建模技术相互比较。

现在,让我们来探讨决策树分类器的优缺点。

决策树分类器的优缺点

在本节中,我们将讨论使用决策树分类算法的优缺点。

决策树分类器最显著的优势之一在于其固有的透明性。支配其模型构建的规则是可读且可解释的,这使得它们非常适合那些需要清晰理解决策过程的情境。这种类型的模型通常被称为白盒模型,是需要最小化偏差并最大化透明度的场景中不可或缺的组成部分。这在政府和保险等关键行业中尤其重要,因为这些行业对问责制和可追溯性有着至关重要的要求。

此外,决策树分类器能够很好地处理分类变量。它们的设计天生适合从离散问题空间中提取信息,这使得它们在大多数特征属于特定类别的数据集中特别合适。

另一方面,决策树分类器也确实存在一些局限性。它们最大的挑战是容易过拟合。当决策树深入挖掘时,可能会创建捕捉过多细节的规则,这会导致模型从训练数据中过度泛化,进而在未见过的数据上表现不佳。因此,使用决策树分类器时,实施剪枝等策略以防止过拟合是至关重要的。

决策树分类器的另一个局限性是它们在处理非线性关系时的困难。它们的规则主要是线性的,因此可能无法捕捉到非直线关系的细微差别。因此,虽然决策树为解决问题带来了一些显著的优势,但它们的弱点在选择适当的模型时需要特别谨慎。

用例

决策树分类器可以用于以下用例来分类数据:

  • 抵押贷款申请:训练一个二分类器来判断申请人是否可能违约。

  • 客户细分:将客户分类为高价值客户、中等价值客户和低价值客户,以便根据每个类别定制营销策略。

  • 医学诊断:训练一个分类器来区分良性或恶性肿瘤。

  • 治疗效果分析:训练一个分类器来标记对某种治疗反应积极的患者。

  • 使用决策树进行特征选择:在考察决策树分类器时,另一个值得讨论的方面是它们的特征选择能力。在规则创建过程中,决策树倾向于从数据集中选择一部分特征。决策树这一固有特性在处理特征数目庞大的数据集时尤为有益。

你可能会问,为什么特征选择如此重要?在机器学习中,处理大量特征可能是一个挑战。特征过多可能导致模型复杂,难以解释,甚至由于"维度诅咒"导致性能下降。

通过自动选择最重要的特征子集,决策树可以简化模型,并专注于最相关的预测变量。

值得注意的是,决策树中的特征选择过程并不限于它们自身模型的开发。这个过程的结果也可以作为其他机器学习模型的初步特征选择。这可以提供哪些特征最重要的初步了解,并帮助简化其他机器学习模型的开发。

接下来,让我们深入探讨集成方法。

理解集成方法

在机器学习领域,集成是一种技术,其中多个模型,每个模型都有细微的差异,被创建并结合成一个复合或聚合模型。这些差异可以来自使用不同的模型参数、数据子集,甚至是不同的机器学习算法。

然而,"稍微不同"在这个上下文中是什么意思?在这里,集成中的每个单独模型都是为了独特性而创建的,但不会有根本性的差异。这可以通过调整超参数、在不同的训练数据子集上训练每个模型,或者使用不同的算法来实现。目标是让每个模型捕捉数据的不同方面或细微差别,当它们结合在一起时,可以帮助提升整体的预测能力。

那么,这些模型是如何结合的呢?集成技术涉及一种称为聚合的决策过程,其中个别模型的预测被合并。这可能是一个简单的平均值、一个多数投票,或者是根据所使用的具体集成技术采用更复杂的方法。

至于何时以及为何需要集成方法,当单个模型无法达到较高的准确率时,它们尤其有用。通过组合多个模型,集成可以捕捉更多的复杂性,并通常能获得更好的性能。这是因为集成可以平均化偏差,减少方差,并且不容易过拟合训练数据。

最后,评估一个集成模型的有效性类似于评估单个模型。可以根据问题的性质使用如准确率、精确度、召回率或 F1 分数等指标。关键的不同在于,这些指标是应用于集成的聚合预测,而不是单一模型的预测。

让我们先看一些集成算法,从 XGBoost 开始。

使用 XGBoost 算法实现梯度提升

XGBoost 于 2014 年推出,是一种集成分类算法,因其基于梯度提升原理而广受欢迎。那么,梯度提升到底是什么呢?本质上,它是一种机器学习技术,涉及顺序构建多个模型,每个新模型都试图纠正前一个模型的错误。这个过程会一直进行,直到误差率显著降低,或添加了预定义数量的模型为止。

在 XGBoost 的背景下,它采用一组相互关联的决策树,并通过梯度下降优化它们的预测结果,梯度下降是一种常用的优化算法,旨在找到一个函数的最小值——在这种情况下是残差误差。简单来说,梯度下降通过不断调整模型来最小化预测值与实际值之间的差异。

XGBoost 的设计使其非常适合分布式计算环境。这种兼容性扩展到 Apache Spark——一个大规模数据处理平台,以及像 Google Cloud 和Amazon Web ServicesAWS)这样的云计算平台。这些平台提供了高效运行 XGBoost 所需的计算资源,特别是在处理更大数据集时。

现在,我们将走过使用 XGBoost 算法实现梯度提升的过程。我们的旅程包括数据准备、模型训练、生成预测和评估模型性能。首先,数据准备是正确使用 XGBoost 算法的关键。原始数据通常包含不一致性、缺失值或可能不适合算法的变量类型。因此,必须对数据进行预处理和清理,按需规范化数值字段并编码分类字段。一旦数据格式化正确,我们可以继续进行模型训练。已经创建了一个 XGBClassifier 实例,我们将用它来拟合我们的模型。让我们看看步骤:

  1. 这个过程使用X_trainy_train数据子集进行训练,分别代表我们的特征和标签:

    from xgboost import XGBClassifier
    classifier = XGBClassifier()
    classifier.fit(X_train, y_train) 
    
    XGBClassifier(base_score=None, booster=None, callbacks=None,
                  colsample_bylevel=None, colsample_bynode=None,
                  colsample_bytree=None, early_stopping_rounds=None,
                  enable_categorical=False, eval_metric=None, feature_types=None,
                  gamma=None, gpu_id=None, grow_policy=None, importance_type=None,
                  interaction_constraints=None, learning_rate=None, max_bin=None,
                  max_cat_threshold=None, max_cat_to_onehot=None,
                  max_delta_step=None, max_depth=None, max_leaves=None,
                  min_child_weight=None, missing=nan, monotone_constraints=None,
                  n_estimators=100, n_jobs=None, num_parallel_tree=None,
                  predictor=None, random_state=None, ...) 
    
  2. 然后,我们将基于新训练的模型生成预测结果:

    y_pred = classifier.predict(X_test)
    cm = metrics.confusion_matrix(y_test, y_pred) 
    
  3. 该过程产生如下输出:

    cm 
    
    array([[64, 4],
           [4, 28]]) 
    
  4. 最后,我们将量化模型的性能:

    accuracy = metrics.accuracy_score(y_test,y_pred)
    recall = metrics.recall_score(y_test,y_pred)
    precision = metrics.precision_score(y_test,y_pred)
    print(accuracy,recall,precision) 
    
  5. 这将给我们以下输出:

    0.92 0.875 0.875 
    

现在,让我们看看随机森林算法。

随机森林算法是一种集成学习方法,通过结合多个决策树的输出,减少偏差和方差,从而实现其效果。在这里,我们深入探讨它是如何训练的,以及它是如何生成预测的。在训练过程中,随机森林算法利用一种称为 bagging(或自助聚合)的技术。它从训练数据集中生成N个子集,每个子集通过从输入数据中随机选择一些行和列来创建。这个选择过程引入了随机性,因此被称为“随机森林”。每个数据子集用于训练一棵独立的决策树,最终得到一组树,表示为 C[1]到 C[m]。这些树可以是任何类型,但通常是二叉树,每个节点根据一个特征来划分数据。

在预测方面,随机森林模型采用民主投票系统。当一个新的数据实例被输入模型进行预测时,森林中的每棵决策树都会生成自己的标签。最终预测由多数投票决定,意味着得到最多票数的标签将成为整体预测。

图 7.12所示:

图表 描述自动生成

图 7.12:随机森林

请注意,在图 7.12中,训练了m棵树,表示为C[1]到C[m],即树 = {C[1],..,C[m]*}。

每棵树都会生成一个预测,表示为一个集合:

个体预测 = P= {P[1],..., P[m]}

最终预测由Pf表示。它由大多数个体预测决定。可以使用mode函数找到多数决策(mode是出现次数最多且占多数的数字)。个体预测与最终预测是相关的,如下所示:

Pf = mode (P) 

这种集成技术提供了几个优势。首先,数据选择和决策树构建中引入的随机性降低了过拟合的风险,从而提高了模型的鲁棒性。其次,森林中的每棵树都独立运行,使得随机森林模型高度并行化,因此适用于大数据集。最后,随机森林模型具有多功能性,能够处理回归和分类任务,并有效应对缺失数据或异常数据。

然而,请记住,随机森林模型的有效性在很大程度上取决于它包含的树木数量。树木太少可能导致模型较弱,而树木太多则可能导致不必要的计算。根据应用的具体需求,调整这个参数非常重要。

区分随机森林算法与集成增强方法

随机森林和集成增强法代表了集成学习的两种不同方法,集成学习是机器学习中的一种强大方法,它通过结合多个模型来创建更稳健和准确的预测。

在随机森林算法中,每棵决策树独立运行,不受其他树的性能或结构的影响。每棵树都是从数据的不同子集构建的,并且在做出决策时使用不同的特征子集,这增加了集成的整体多样性。最终输出是通过对所有树的预测进行聚合来确定的,通常通过多数投票。

集成增强法(ensemble boosting)采用一种顺序过程,每个模型都意识到其前一个模型的错误。增强技术生成一系列模型,其中每个后续模型旨在纠正前一个模型的错误。通过为下一模型分配更高的权重给训练集中的错误分类实例来实现这一点。最终的预测是所有模型预测的加权总和,从而有效地赋予更准确的模型更多的影响力。

本质上,虽然随机森林利用独立性和多样性的优势,集成增强法则侧重于纠正错误并从过去的错误中改进。每种方法都有其自身的优势,根据数据的性质和结构的不同,它们可能更有效。

使用随机森林算法进行分类挑战

让我们实例化随机森林算法,并使用训练数据来训练我们的模型。

这里有两个关键的超参数,我们将重点关注:

  • n_estimators

  • max_depth

n_estimators 超参数决定了集成中构建的单个决策树的数量。本质上,它决定了“森林”的大小。更多的树通常会导致更稳健的预测,因为它增加了决策路径的多样性和模型的泛化能力。然而,重要的是要注意,增加更多的树也会增加计算复杂性,超过某个点后,准确性的提升可能变得微不足道。

另一方面,max_depth 超参数指定了每棵树可以达到的最大深度。在决策树的上下文中,“深度”是指从根节点(树顶的起始点)到叶节点(底部的最终决策输出)之间的最长路径。通过限制最大深度,我们实际上控制了学习结构的复杂性,在欠拟合和过拟合之间取得平衡。过于浅的树可能会错过重要的决策规则,而过于深的树可能会对训练数据进行过拟合,捕捉到噪声和异常值。

微调这两个超参数在优化基于决策树的模型性能方面起着至关重要的作用,能够在预测能力和计算效率之间取得正确的平衡。

为了使用随机森林算法训练分类器,我们将执行以下步骤:

classifier = RandomForestClassifier(n_estimators = 10, max_depth = 4,
criterion = 'entropy', random_state = 0)
classifier.fit(X_train, y_train) 
RandomForestClassifier(n_estimators = 10, max_depth = 4,criterion = 'entropy', random_state = 0) 

一旦随机森林模型训练完成,我们就可以用它来进行预测:

y_pred = classifier.predict(X_test)
cm = metrics.confusion_matrix(y_test, y_pred)
cm 

这将给出如下输出:

array ([[64, 4],
        [3, 29]]) 

现在,让我们量化模型的好坏:

accuracy= metrics.accuracy_score(y_test,y_pred)
recall = metrics.recall_score(y_test,y_pred)
precision = metrics.precision_score(y_test,y_pred)
print(accuracy,recall,precision) 

我们将观察到以下输出:

0.93 0.90625 0.8787878787878788 

请注意,随机森林是一种流行且多用途的机器学习方法,既可以用于分类任务,也可以用于回归任务。它因其简单性、鲁棒性和灵活性而著称,使其能够应用于各种不同的场景。

接下来,我们来看一下逻辑回归。

逻辑回归

逻辑回归是一种用于二分类的分类算法。它使用逻辑函数来表述输入特征与标签之间的关系。它是最简单的分类技术之一,用于建模二元因变量。

假设条件

逻辑回归假设如下:

  • 训练数据集没有缺失值。

  • 标签是二元分类变量。

  • 标签是有序的——换句话说,是具有顺序值的分类变量。

  • 所有特征或输入变量相互独立。

建立关系

对于逻辑回归,预测值的计算方法如下:

假设:

所以现在:

前述关系可以图形化表示如下:

图表,折线图 描述自动生成

图 7.13:绘制 sigmoid 函数

请注意,如果 z 很大, (z) 将等于 1。如果 z 很小或是一个很大的负数, (z) 将等于 0。另外,当 z 为 0 时, (z) = 0.5。sigmoid 是一个自然函数,适用于表示概率,因为它的值严格限定在 0 到 1 之间。所谓“自然”是指它由于固有的性质而特别适用或有效。在这种情况下,sigmoid 函数始终输出一个介于 0 和 1 之间的值,这与概率范围一致。这使得它成为逻辑回归中建模概率的一个优秀工具。训练逻辑回归模型的目标是找到 wj 的正确值。

逻辑回归得名于用于表述它的函数,称为逻辑函数sigmoid 函数

损失函数和成本函数

损失函数定义了我们如何为训练数据中的某个特定示例量化误差。成本函数定义了我们如何在整个训练数据集上最小化误差。因此,损失函数用于训练数据集中的单个示例,而成本函数用于量化实际值和预测值之间的总体偏差。它依赖于wh的选择。

在逻辑回归中,用于训练集中特定示例i损失函数如下:

损失(ý^((i)), y^((i))) = - (y^((i)) log ý^((i)) + (1-y^((i)) ) log (1-ý^((i)))

请注意,当y^((i)) = 1 时,损失(ý^((i)), y^((i))) = - logý((i))。最小化损失将导致ý((i))的值变大。作为一个 sigmoid 函数,它的最大值为1

如果y^((i)) = 0,损失(ý^((i)), y^((i))) = - log (1-ý^((i)))

最小化损失将导致ý^((i))尽可能小,这时其值为0

逻辑回归的成本函数如下:

现在,让我们深入了解逻辑回归的细节。

何时使用逻辑回归

逻辑回归在二分类器中表现很好。为了澄清,二分类是指预测两种可能结果之一的过程。例如,如果我们尝试预测一封邮件是否为垃圾邮件,这就是一个二分类问题,因为只有两种可能的结果——‘垃圾邮件’或‘非垃圾邮件’。

然而,逻辑回归存在一些局限性。特别是,当处理质量较差的大型数据集时,它可能会遇到困难。例如,假设有一个数据集,其中包含大量缺失值、异常值或无关特征。在这种情况下,逻辑回归模型可能会发现很难做出准确的预测。

此外,虽然逻辑回归能够有效地处理特征与目标变量之间的线性关系,但在处理复杂的非线性关系时可能会显得力不从心。想象一个数据集,其中预测变量与目标之间的关系不是一条直线,而是曲线;在这种情况下,逻辑回归模型可能会遇到困难。

尽管存在这些局限性,逻辑回归通常仍然是分类任务的一个可靠起点。它提供了一个基准性能,可以用来与更复杂的模型效果进行比较。即使它不能提供最高的准确度,但它提供了可解释性和简单性,在某些情境下是非常有价值的。

使用逻辑回归算法进行分类器挑战

在本节中,我们将看到如何使用逻辑回归算法来进行分类器挑战:

  1. 首先,让我们实例化一个逻辑回归模型并使用训练数据进行训练:

    from sklearn.linear_model import LogisticRegression
    classifier = LogisticRegression(random_state = 0)
    classifier.fit(X_train, y_train) 
    
  2. 让我们预测test数据的值并创建混淆矩阵:

    y_pred = classifier.predict(X_test)
    cm = metrics.confusion_matrix(y_test, y_pred)
    cm 
    
  3. 我们在运行上述代码后得到以下输出:

    array ([[65, 3],
            [6, 26]]) 
    
  4. 现在,让我们来看一下性能指标:

    accuracy= metrics.accuracy_score(y_test,y_pred)
    recall = metrics.recall_score(y_test,y_pred)
    precision = metrics.precision_score(y_test,y_pred)
    print(accuracy,recall,precision) 
    
  5. 我们在运行上述代码后得到以下输出:

    0.91 0.8125 0.8996551724137931 
    

接下来,让我们看看SVM

SVM 算法

SVM分类器是机器学习工具中的一个强大工具,通过识别一个最优决策边界或超平面,来显著区分两类。为了进一步解释,可以将这个“超平面”看作是一条线(二维中),一个面(三维中),或一个流形(高维中),它能最好地分隔特征空间中的不同类别。

区分 SVM 的关键特性是其优化目标——旨在最大化边距,即决策边界与来自任一类的最近数据点(称为“支持向量”)之间的距离。简单来说,SVM 算法不仅仅是找到一条分割类别的线;它还尝试找到一条尽可能远离每个类别最近点的线,从而最大化分隔间隙。

考虑一个基本的二维示例,我们尝试将圆圈与叉号分开。我们使用 SVM 的目标不仅仅是找到一条分开这两种形状的线,而是找到一条线,使它与最靠近它的圆圈和叉号之间的距离最大。

SVM(支持向量机)在处理高维数据、复杂领域,或当类之间无法通过简单的直线分割时,非常有用。它们在逻辑回归可能失败的地方表现尤为出色,例如,在非线性可分的数据场景中。

边距定义为分隔超平面(决策边界)与最接近该超平面的训练样本之间的距离,这些训练样本被称为支持向量。因此,我们从一个非常基本的二维示例开始,X[1]和X[2]。我们希望找到一条线来分开圆圈和叉号。如下图所示:

图表,散点图 说明自动生成

图 7.14:SVM 算法

我们画了两条线,两条线都能完美地将叉号和圆圈分开。然而,必须存在一条最优线或决策边界,它能为我们提供最大机会,以正确分类大多数额外的样本。一个合理的选择可能是将这两类之间均匀分隔的线,从而为每个类提供一点缓冲区,如下图所示:

图示 说明自动生成

图 7.15:与 SVM 相关的概念

此外,与逻辑回归不同,SVM 更适合处理较小、更干净的数据集,并且它能够在不需要大量数据的情况下捕捉复杂的关系。然而,这里有一个权衡——可解释性——尽管逻辑回归能够提供易于理解的模型决策过程,SVM 由于其固有的复杂性,解释起来不如逻辑回归直观。

现在,让我们看看如何使用 SVM 来训练一个分类器以应对我们的挑战。

使用 SVM 算法解决分类器挑战

首先,让我们实例化 SVM 分类器,然后使用标记数据的训练部分来训练它。kernel 超参数决定了对输入数据应用何种变换,以使其线性可分:

from sklearn.svm import SVC
classifier = SVC(kernel = 'linear', random_state = 0)
classifier.fit(X_train, y_train) 
  1. 一旦训练完成,让我们生成一些预测并查看混淆矩阵:

    y_pred = classifier.predict(X_test)
    cm = metrics.confusion_matrix(y_test, y_pred)
    cm 
    
  2. 观察以下输出:

    array ([[66, 2],
            [9, 23]]) 
    
  3. 现在,让我们来看一下各种性能指标:

    accuracy= metrics.accuracy_score(y_test,y_pred)
    recall = metrics.recall_score(y_test,y_pred)
    precision = metrics.precision_score(y_test,y_pred)
    print(accuracy,recall,precision) 
    

运行上述代码后,我们得到以下值作为输出:

0.89 0.71875 0.92 

理解朴素贝叶斯算法

基于概率论,朴素贝叶斯是最简单的分类算法之一。如果使用得当,它可以给出准确的预测。朴素贝叶斯算法得名有两个原因:

  • 它基于一个朴素假设,即特征与输入变量之间是独立的。

  • 它基于贝叶斯定理。请注意,贝叶斯定理用于计算在观察到某些特征的情况下,特定类别或结果的概率。

该算法尝试基于先前属性/实例的概率来分类实例,假设属性之间完全独立。

事件有三种类型:

  • 独立事件不会影响另一个事件发生的概率(例如,收到一封提供免费入场技术活动的电子邮件 公司进行重组的事件)。

  • 相关事件会影响另一个事件发生的概率;也就是说,它们在某种程度上是相关的(例如,你准时到达会议的概率可能会受到航空公司员工罢工或航班可能不准时的影响)。

  • 互斥事件不能同时发生(例如,掷骰子时同时出现三和六的概率为 0——这两个结果是互斥的)。

贝叶斯定理

贝叶斯定理用于计算两个独立事件 AB 之间的条件概率。事件 AB 发生的概率分别由 P(A) 和 P(B) 表示。条件概率由 P(B|A) 表示,即在事件 A 发生的情况下,事件 B 发生的条件概率:

在应用朴素贝叶斯算法时,特别适用于输入数据维度(特征数量)较高的场景。这使得它非常适合用于文本分类任务,如垃圾邮件检测或情感分析。

它可以处理连续数据和离散数据,并且计算效率高,适用于实时预测。当你拥有有限的计算资源并且需要快速简单的实现时,朴素贝叶斯也是一个不错的选择。但需要注意的是,它的“朴素”假设(即特征独立性)在某些情况下可能成为限制。

计算概率

朴素贝叶斯基于概率基础。单个事件发生的概率(观察概率)是通过计算事件发生的次数,并将其除以可能导致该事件发生的总次数。例如,呼叫中心每天接到超过 100 个支持电话,在一个月内发生了 50 次。你想知道在过去的时间记录下,电话能否在三分钟内得到回应的概率。如果呼叫中心在 27 次情况下达到了这个时间记录,那么 100 个电话在三分钟内得到回应的观察概率如下:

P(3 分钟内接到 100 个支持电话) = (27 / 50) = 0.54(54%)

基于过去 50 次发生的记录,100 个电话可以在大约一半的时间内在三分钟内得到回应。

现在,让我们来看看AND事件的乘法规则。

AND事件的乘法规则

要计算两个或多个事件同时发生的概率,首先要考虑事件是独立的还是依赖的。如果它们是独立的,则使用简单的乘法规则:

P(结果 1 AND 结果 2)= P(结果 1) P(结果 2)*

例如,要计算收到一封包含免费入场券的技术活动电子邮件并且工作场所发生重组的概率,可以使用这个简单的乘法规则。这两个事件是独立的,因为一个事件的发生不会影响另一个事件发生的概率。

如果收到技术活动电子邮件的概率是 31%,而员工重组的概率是 82%,则两者同时发生的概率如下计算:

P(电子邮件 AND 重组) = P(电子邮件) P(重组) = (0.31) * (0.82) = 0.2542(25%)*

一般乘法规则

如果两个或多个事件是依赖的,则使用一般乘法规则。这个公式实际上在独立事件和依赖事件的两种情况下都是有效的:

P(结果 1 AND 结果 2)= P(结果 1) P(结果 2 | 结果 1)*

请注意,P(事件 2 | 事件 1) 是指在事件 1 已经发生的情况下,事件 2 发生的条件概率。这个公式反映了事件之间的依赖性。如果事件是独立的,那么条件概率就不再重要,因为一个事件不会影响另一个事件发生的概率,此时 P(事件 2 | 事件 1) 就简化为 P(事件 2)。请注意,这种情况下的公式变成了简单的乘法规则。

让我们通过一个简单的例子来说明。假设你从一副牌中抽两张牌,并且你想知道先抽到一张 ace 再抽到一张 king 的概率。第一个事件(抽到 ace)会改变第二个事件(抽到 king)的条件,因为我们并没有将 ace 放回牌堆。根据一般的乘法规则,我们可以计算为 P(ace) * P(king | ace),其中 P(king | ace) 是在已抽到 ace 的情况下抽到 king 的概率。

OR 事件的加法规则

在计算事件 A 或事件 B 发生的概率(互斥事件)时,使用以下简单的加法规则:

P(事件 1 或 事件 2) = P(事件 1) + P(事件 2)

例如,掷出 6 或 3 的概率是多少?为了解答这个问题,首先需要注意这两个结果不能同时发生。掷出 6 的概率是 (1 / 6),同样掷出 3 的概率也是 (1 / 6):

P(6 或 3) = (1 / 6) + (1 / 6) = 0.33 (33%)

如果事件不是互斥的且可以同时发生,则使用以下通用加法公式,这在互斥和非互斥的情况下都适用:

P(事件 1 或 事件 2) = P(事件 1) + P(事件 2) - P(事件 1 和 事件 2)

使用朴素贝叶斯算法解决分类器挑战

现在,让我们使用朴素贝叶斯算法来解决分类器挑战:

  1. 首先,我们将导入 GaussianNB() 函数并用它来训练模型:

    # Fitting Decision Tree Classification to the Training set
    from sklearn.naive_bayes import GaussianNB
    classifier = GaussianNB()
    classifier.fit(X_train, y_train) 
    
    GaussianNB() 
    
  2. 现在,让我们使用训练好的模型来预测结果。我们将用它来预测测试数据集的标签,即 X_test

    # Predicting the Test set results
    y_pred = classifier.predict(X_test)
    cm = metrics.confusion_matrix(y_test, y_pred) 
    
  3. 现在,让我们打印混淆矩阵:

    cm 
    
    array([[66, 2],
    [6, 26]]) 
    
  4. 现在,让我们打印性能矩阵,量化我们训练模型的质量:

    accuracy= metrics.accuracy_score(y_test,y_pred)
    recall = metrics.recall_score(y_test,y_pred)
    precision = metrics.precision_score(y_test,y_pred)
    print(accuracy,recall,precision) 
    

结果如下:

0.92 0.8125 0.9285714285714286 

对于分类算法,最终的赢家是...

让我们稍作停顿,比较一下我们讨论过的各种算法的性能指标。不过,请记住,这些指标高度依赖于我们在这些例子中使用的数据,对于不同的数据集,它们可能会有显著差异。

一个模型的性能可能会受到数据特性、数据质量以及模型假设与数据匹配程度等因素的影响。

下面是我们观察结果的总结:

算法 准确率 召回率 精确度
决策树 0.94 0.93 0.88
XGBoost 0.93 0.90 0.87
随机森林 0.93 0.90 0.87
逻辑回归 0.91 0.81 0.89
SVM 0.89 0.71 0.92
朴素贝叶斯 0.92 0.81 0.92

从上表来看,决策树分类器在这个特定的背景下,无论是准确度还是召回率都展现了最高的性能。就精确度而言,我们看到 SVM 和朴素贝叶斯算法并列。

然而,记住这些结果是数据依赖的。例如,SVM 可能在数据线性可分或者通过核变换使其可分的场景中表现出色。另一方面,朴素贝叶斯在特征独立时表现良好。决策树和随机森林在处理复杂的非线性关系时可能更受青睐。逻辑回归是二分类任务的稳健选择,并且可以作为一个不错的基准模型。最后,XGBoost 作为一种集成技术,在处理各种数据类型时非常强大,且在许多任务中通常在模型性能上领先。

因此,在选择模型之前,理解你的数据和任务需求至关重要。这些结果仅仅是一个起点,应该针对每个具体的应用场景进行更深入的探索和验证。

理解回归算法

如果标签是连续变量,则监督式机器学习模型会使用回归算法。在这种情况下,机器学习模型称为回归器。

为了提供更具体的理解,假设我们想基于历史数据预测下周的温度,或者我们希望预测零售店在未来几个月的销售额。

温度和销售额都是连续变量,这意味着它们可以取指定范围内的任何值,与之相对,分类变量具有固定数量的离散类别。在这种情况下,我们会使用回归器而不是分类器。

在本节中,我们将介绍可以用于训练监督式机器学习回归模型的各种算法,简单来说就是回归器。在深入探讨这些算法的细节之前,我们先创建一个挑战,来测试这些算法的性能、能力和有效性。

回归器挑战展示

类似于我们在分类算法中使用的方法,我们将首先提出一个问题,作为所有回归算法的挑战。我们将这个共同的问题称为回归器挑战。然后,我们将使用三种不同的回归算法来解决这一挑战。使用共同挑战来比较不同回归算法的方法有两个好处:

  • 我们可以先准备数据,然后在所有三种回归算法上使用准备好的数据。

  • 我们可以以有意义的方式比较三种回归算法的性能,因为我们将使用它们来解决相同的问题。

让我们看看挑战的题目。

回归算法挑战的任务描述

预测不同车辆的油耗在今天非常重要。一辆高效的车辆对环境有益,也更具成本效益。油耗可以根据发动机的功率和车辆的特性来估算。让我们为回归算法设计一个挑战,训练一个模型,根据车辆的特性预测每加仑的英里数MPG)。

让我们看看将用于训练回归算法的历史数据集。

探索历史数据集

以下是我们拥有的历史数据集中的特征:

名称 类型 描述
名称 类别 标识特定的车辆
气缸数 连续型 气缸的数量(介于四个和八个之间)
排量 连续型 引擎的排量(单位:立方英寸)
马力 连续型 发动机的马力
加速度 连续型 从 0 加速到 60 英里每小时所需的时间(单位:秒)

这个问题的标签是一个连续变量MPG,它指定每辆车的英里每加仑(MPG)值。

首先,我们为这个问题设计数据处理管道。

使用数据处理管道进行特征工程

让我们看看如何设计一个可重用的数据处理管道来应对回归算法的挑战。如前所述,我们将一次性准备好数据,然后在所有回归算法中使用它。让我们按以下步骤进行:

  1. 我们将首先导入数据集,如下所示:

    dataset = pd.read_csv('https://storage.googleapis.com/neurals/data/data/auto.csv') 
    
  2. 现在让我们预览一下数据集:

    dataset.head(5) 
    
  3. 这就是数据集的样子:

Table  Description automatically generated with medium confidence

图 7.16:请在此添加标题

  1. 现在,让我们继续进行特征选择。我们将删除NAME列,因为它只是车辆所需的标识符。数据集中用于标识行的列与训练模型无关。让我们删除这一列。

  2. 让我们转换所有输入变量,并填充所有null值:

    dataset=dataset.drop(columns=['NAME'])
    dataset.head(5)
    dataset= dataset.apply(pd.to_numeric, errors='coerce')
    dataset.fillna(0, inplace=True) 
    

    填充缺失值可以提高数据质量,并为训练模型做好准备。现在,让我们看一下最后一步。

  3. 让我们将数据分为测试集和训练集:

    y=dataset['MPG']
    X=dataset.drop(columns=['MPG'])
    # Splitting the dataset into the Training set and Test set
    from sklearn.model_selection import train_test_split
    from sklearn.cross_validation import train_test_split
    X_train, X_test, y_train, y_test = train_test_split(X, y, test_size = 0.25, random_state = 0) 
    

这已创建以下四个数据结构:

  • X_train:包含训练数据特征的数据结构

  • X_test:包含训练测试特征的数据结构

  • y_train:包含训练数据集标签值的向量

  • y_test:包含测试数据集标签值的向量

现在,让我们使用准备好的数据在三种不同的回归算法上进行测试,以便比较它们的性能。

线性回归

在各种监督学习算法中,线性回归通常被认为是最简单的。最初,我们将探讨简单线性回归,然后逐渐扩展讨论,涵盖多元线性回归。

然而,重要的是要注意,尽管线性回归方法易于实现且易于使用,但并不总是在所有情况下都是‘最佳’选择。每种机器学习算法,包括我们到目前为止讨论过的,都有其独特的优势和局限性,并且它们的效果因数据的类型和结构而异。

例如,决策树和随机森林在处理分类数据和捕捉复杂的非线性关系方面表现出色。支持向量机(SVM)在高维数据中表现良好,并且对异常值具有鲁棒性,而逻辑回归对于二分类问题特别有效。

另一方面,线性回归模型非常适合预测连续结果,并且具有可解释性,这在理解单个特征的影响时非常有价值。

简单线性回归

在最基本的层面上,线性回归建立了两个变量之间的关系,通常表示为一个自变量和一个因变量。线性回归是一种技术,可以帮助我们研究因变量(绘制在 y 轴上)的变化是如何受到自变量(绘制在 x 轴上)变化的影响的。它可以表示如下:

这个公式可以这样解释:

  • y 是因变量。

  • X 是自变量。

  • 是斜率,表示每当 X 增加时,直线上升的幅度。

  • α 是截距,表示当 X = 0 时 y 的值。

线性回归基于以下假设:

  • 线性关系:自变量与因变量之间的关系是线性的。

  • 独立性:观察值之间相互独立。

  • 无多重共线性:自变量之间没有过高的相关性。

单一连续因变量与单一连续自变量之间关系的一些例子如下:

  • 一个人的体重与其卡路里摄入量之间的关系

  • 特定社区中房屋价格与其面积(以平方英尺计)之间的关系

  • 空气中的湿度与降雨概率之间的关系

对于线性回归,输入(自变量)和目标(因变量)必须都是数值型的。通过最小化每个数据点到通过所有点绘制的直线的垂直距离的平方和,来找到最佳的关系。假设自变量与标签之间是线性关系。例如,投入更多资金进行研发,会导致销售额的提高。

让我们来看一个具体的例子。假设我们要建立营销支出与某一产品销售之间的关系。经过研究发现,它们是直接相关的。营销支出和销售额在二维图表上绘制出来,显示为蓝色的菱形点。我们可以通过绘制一条直线来近似表示这种关系,如下图所示:

图表,散点图 说明自动生成

图 7.17:线性回归

一旦画出线性直线,我们就能看到营销支出与销售额之间的数学关系。

回归模型评估

我们绘制的线性直线是依赖变量和自变量之间关系的近似。即便是最佳拟合线,也会与实际值有所偏差,如下所示:

图表,散点图 说明自动生成

图 7.18:回归模型评估

量化线性回归模型性能的常见方法是使用 均方根误差 (RMSE)。该方法计算训练模型的误差标准差。例如,在训练数据集中,loss 函数的计算如下:

Loss (ý^((i)), y^((i))) = 1/2(ý^((i))- y^((i))

这得出了以下 cost 函数,用于最小化训练集所有样本的损失:

让我们试着解释 RMSE。如果我们预测产品价格的示例模型的 RMSE 为 50 美元,这意味着大约 68.2% 的预测结果会落在真实值的 50 美元范围内(即 )。这也意味着 95% 的预测结果会落在真实值的 100 美元范围内(即 )。最后,99.7% 的预测结果会落在真实值的 150 美元范围内。

让我们深入了解多元回归。

多元回归

事实上,大多数现实世界的分析都涉及多个自变量。多元回归是简单线性回归的扩展。其关键区别在于,除了原有的回归系数外,还有额外的预测变量系数。在训练模型时,目标是找到那些最小化线性方程误差的回归系数。让我们尝试用数学公式表达依赖变量与一组自变量(特征)之间的关系。

比如,在房地产市场中,房价(依赖变量)可能受多个因素的影响,如房屋的面积、位置、年龄等(自变量)。

类似于简单线性方程,依赖变量 y 可以量化为截距项与每个 i 特征的 x 值乘以回归系数的乘积之和:

误差由表示,表明预测并不完美。

系数使每个特征对y值的影响可以单独估算,因为当x[i]增加 1 个单位时,y变化了。此外,截距()表示当所有自变量为 0 时,y的期望值。

请注意,前面方程中的所有变量都可以表示为一组向量。目标和预测变量现在是带有一行的向量,而回归系数和误差也都是向量。

接下来,让我们看看如何使用线性回归来解决回归者挑战。

使用线性回归算法解决回归者挑战

现在,让我们使用数据集的训练部分来训练模型。请注意,我们将使用之前讨论过的相同数据和数据工程逻辑:

  1. 让我们先导入线性回归包:

    from sklearn.linear_model import LinearRegression 
    
  2. 然后,让我们实例化线性回归模型,并使用训练数据集对其进行训练:

    regressor = LinearRegression()
    regressor.fit(X_train, y_train) 
    
    LinearRegression() 
    
  3. 现在,让我们使用数据集的测试部分来预测结果:

    y_pred = regressor.predict(X_test)
    from sklearn.metrics import mean_squared_error
    sqrt(mean_squared_error(y_test, y_pred)) 
    
  4. 运行前面的代码生成的输出将如下所示:

    19.02827669300187 
    

如前节所述,RMSE 是误差的标准差。它表明 68.2%的预测结果将在标签值的4.36范围内。

让我们看看什么时候可以使用线性回归。

什么时候使用线性回归?

线性回归被用来解决许多现实世界中的问题,包括以下几个:

  • 销售预测

  • 预测最佳产品价格

  • 量化事件与反应之间的因果关系,例如在临床药物试验、工程安全测试或市场研究中。

  • 识别可以用来预测未来行为的模式,前提是已知某些标准——例如,预测保险理赔、自然灾害损失、选举结果和犯罪率。

接下来,让我们看看线性回归的弱点。

线性回归的弱点

线性回归的弱点如下:

  • 它只适用于数值特征。

  • 类别数据需要预处理。

  • 它不适应缺失数据。

  • 它对数据有假设。

回归树算法

类似于用于分类结果的分类树,回归树是决策树的另一个子集,但它们在目标或标签是连续变量而不是类别变量时使用。这一区别影响了树算法如何处理和学习数据。

对于分类树,算法试图识别数据点属于哪个类别。然而,在回归树中,目标是预测一个特定的连续值。这可能是房价、公司未来的股票价格,或者明天的温度。

分类树和回归树之间的这些差异也导致了所使用算法的不同。在分类树中,我们通常使用如基尼不纯度或熵等指标来找到最佳分割点。而回归树则利用均方误差MSE)等度量来最小化实际值和预测值之间的距离。

使用回归树算法解决回归问题挑战

在这一部分,我们将看到如何使用回归树算法来处理回归问题挑战:

  1. 首先,我们使用回归树算法来训练模型:

    from sklearn.tree import DecisionTreeRegressor
    regressor = DecisionTreeRegressor(max_depth=3)
    regressor.fit(X_train, y_train) 
    
    DecisionTreeRegressor(max_depth=3) 
    
  2. 一旦回归树模型训练完成,我们就使用训练好的模型来预测值:

    y_pred = regressor.predict(X_test) 
    
  3. 然后,我们计算 RMSE 来量化模型的性能:

    from sklearn.metrics import mean_squared_error
    from math import sqrt
    sqrt(mean_squared_error(y_test, y_pred)) 
    

我们得到了以下输出:

4.464255966462035 

梯度提升回归算法

现在,让我们把焦点转向梯度提升回归算法,它通过集成多个决策树来提取数据集中的潜在模式。

从本质上讲,梯度提升回归通过创建一个“团队”决策树来运作,每个成员逐步从前一个成员的错误中学习。实际上,序列中的每棵决策树都试图纠正前一棵树所做的预测错误,从而形成一个“集成”模型,基于所有单个树的集体智慧做出最终预测。这个算法真正独特之处在于它能够处理广泛的数据,并且具有抗过拟合的能力。这种多功能性使得它能够在不同的数据集和问题场景中表现出色。

使用梯度提升回归算法解决回归问题挑战

在这一部分,我们将看到如何使用梯度提升回归算法解决回归问题挑战,预测汽车的 MPG(每加仑英里数)评分,这是一个连续变量,因此是一个典型的回归问题。记住,我们的自变量包括像‘CYLINDERS’、‘DISPLACEMENT’、‘HORSEPOWER’、‘WEIGHT’ 和 ‘ACCELERATION’ 等特征。

仔细观察,MPG 并不像看起来那样简单,因为影响因素之间存在多方面的关系。例如,虽然具有更大排量的汽车通常消耗更多燃料,导致较低的 MPG,但这一关系可能会被像重量和马力等因素所抵消。正是这些微妙的交互作用可能会让简单的模型如线性回归或单一决策树无法捕捉到。

这时,梯度提升回归算法可能会派上用场。通过构建一个决策树的集成体,每棵树都从前一个树的错误中学习,模型将旨在辨别数据中的这些复杂模式。每棵树都为数据提供其理解,精炼预测,使其更加准确和可靠。

例如,一个决策树可能会发现,拥有较大‘DISPLACEMENT'值的汽车往往具有较低的MPG。下一个决策树可能会捕捉到一个细微差别,即具有相同‘DISPLACEMENT'值的较轻汽车(‘WEIGHT')有时可以获得更高的MPG。通过这种迭代学习过程,模型揭示了变量之间复杂的关系层次:

  1. 我们的 Python 脚本的第一步是导入必要的库:

    from sklearn import ensemble 
    
  2. 在这里,我们从sklearn库导入ensemble模块:

    params = {'n_estimators': 500, 'max_depth': 4,          'min_samples_split': 2, 'learning_rate': 0.01,          'loss': 'squared_error'}
    regressor = ensemble.GradientBoostingRegressor(**params)
    regressor.fit(X_train, y_train) 
    
    GradientBoostingRegressor(learning_rate=0.01, max_depth=4, n_estimators=500) 
    
    y_pred = regressor.predict(X_test) 
    
  3. 最后,我们计算 RMSE 以量化模型的表现:

    from sklearn.metrics import mean_squared_error
    from math import sqrt
    sqrt(mean_squared_error(y_test, y_pred)) 
    
  4. 运行这个程序将给出如下输出值:

    4.039759805419003 
    

对于回归算法,优胜者是……

让我们看看在相同数据和完全相同的用例上,我们使用的三种回归算法的表现:

算法 RMSE
线性回归 4.36214129677179
回归树 5.2771702288377
梯度提升回归 4.034836373089085

从所有回归算法的表现来看,很明显梯度提升回归的表现最佳,因为它的 RMSE 最低。紧随其后的是线性回归。回归树算法在这个问题中的表现最差。

实际例子——如何预测天气

现在,我们将从理论过渡到应用,运用本章讨论的概念,通过一整年的某城市天气数据来预测明天的降雨情况。这个实际场景旨在强化有监督学习的原理。

有许多算法能够完成这个任务,但选择最合适的算法取决于我们问题和数据的具体特点。每种算法都有独特的优势,在特定情境下表现出色。例如,当存在明显的数值相关性时,线性回归可能是理想选择,而当处理分类变量或非线性关系时,决策树可能更有效。

对于这个预测挑战,我们选择了逻辑回归。这个选择源于我们预测目标的二元性质(即,明天是否会下雨?),这种情况下逻辑回归往往表现优异。该算法提供一个介于 0 和 1 之间的概率分数,使我们能够做出明确的是/否预测,非常适合我们的降雨预测场景。

记住,这个实际例子与之前的例子有所不同。它旨在帮助你理解我们如何选择并应用特定的算法来解决实际问题,提供了算法选择背后的思维过程的更深入理解。

训练此模型的数据位于名为weather.csv的 CSV 文件中:

  1. 让我们将数据导入为 pandas DataFrame:

    import numpy as np
    import pandas as pd
    df = pd.read_csv("weather.csv") 
    
  2. 让我们来看一下 DataFrame 的列:

    df.columns 
    
    Index(['Date', 'MinTemp', 'MaxTemp', 'Rainfall', 
           'Evaporation', 'Sunshine', 'WindGustDir', 
           'WindGustSpeed', 'WindDir9am', 'WindDir3pm', 
           'WindSpeed9am', 'WindSpeed3pm', 'Humidity9am', 
           'Humidity3pm', 'Pressure9am', 'Pressure3pm', 
           'Cloud9am', 'Cloud3pm', 'Temp9am', 'Temp3pm', 
           'RainToday', 'RISK_MM', 'RainTomorrow'],
          dtype='object') 
    
  3. 现在,让我们来看一下weather.csv数据中显示城市典型天气的前 13 列的表头:

    df.iloc[:,0:12].head() 
    

A screenshot of a computer  Description automatically generated

图 7.19:显示城市典型天气的数据

  1. 现在,让我们看看weather.csv数据的最后 10 列:

    df.iloc[:,12:25].head() 
    

A picture containing application  Description automatically generated

图 7.20:weather.csv数据的最后 10 列

  1. 让我们用x来表示输入特征。我们将删除Date字段,因为它在预测中没有用处。我们还将删除RainTomorrow标签:

    x = df.drop(['Date','RainTomorrow'],axis=1) 
    
  2. 让我们用y来表示标签:

    y = df['RainTomorrow'] 
    
  3. 现在,让我们将数据划分为train_test_split

    from sklearn.model_selection import train_test_split
    train_x , train_y ,test_x , test_y = train_test_split(x,y,
    test_size = 0.2,random_state = 2) 
    
  4. 由于标签是一个二元变量,我们将训练一个分类器。因此,逻辑回归在这里是一个不错的选择。首先,让我们实例化逻辑回归模型:

    model = LogisticRegression() 
    
  5. 现在,我们可以使用train_xtest_x来训练模型:

    model.fit(train_x , test_x) 
    
  6. 一旦模型训练完成,我们就可以使用它进行预测:

    predict = model.predict(train_y) 
    
  7. 现在,让我们来看看我们训练模型的准确度:

    predict = model.predict(train_y)
    from sklearn.metrics import accuracy_score
    accuracy_score(predict , test_y) 
    
    0.9696969696969697 
    

现在,这个二元分类器可以用来预测明天是否会下雨。

总结

总结来说,本章是一次深入监督机器学习多面性领域的全面探索。我们重点介绍了分类和回归算法的主要组成部分,剖析了它们的机制和应用。

本章通过实际例子展示了广泛的算法,提供了在现实场景中理解这些工具功能的机会。这一旅程强调了监督学习技术的适应性及其解决各种问题的能力。

通过并列不同算法的表现,我们强调了在选择最佳机器学习策略时,上下文的重要性。数据大小、特征复杂度和预测要求等因素在这个选择过程中起着重要作用。

随着我们进入接下来的章节,从本次探索中获得的知识将作为坚实的基础。这种如何在实际场景中应用监督学习技术的理解,是机器学习广阔领域中的一项关键技能。在我们进一步探索人工智能的迷人世界时,保持这些见解在手,为深入研究神经网络的复杂世界做好准备。

在 Discord 上了解更多

要加入本书的 Discord 社区——你可以在这里分享反馈、向作者提问并了解新版本——请扫描下面的二维码:

packt.link/WHLel

第八章:神经网络算法

幽默没有算法。

—罗伯特·曼科夫

神经网络已经成为研究的课题超过七十年,但由于计算能力的限制和数字化数据的匮乏,它们的应用受到了制约。如今,由于我们日益增长的解决复杂挑战的需求、数据生产的爆炸性增长以及如云计算等技术的进步,环境发生了显著变化,赋予了我们强大的计算能力。这些改进为我们提供了开发和应用这些复杂算法的潜力,以解决曾经被认为不切实际的复杂问题。事实上,这是一个迅速发展的研究领域,是机器人技术、边缘计算、自然语言处理和自动驾驶汽车等前沿技术领域大多数重大进展的源泉。

本章首先介绍典型神经网络的主要概念和组成部分。接下来,介绍神经网络的不同类型,并解释这些神经网络中使用的各种激活函数。然后,详细讨论反向传播算法,这是训练神经网络中最广泛使用的算法。接下来,解释转移学习技术,它可以极大简化并部分自动化模型的训练。最后,通过一个现实世界的应用示例,说明如何使用深度学习来标记欺诈性文档。

本章讨论的主要概念如下:

  • 理解神经网络

  • 神经网络的演变

  • 训练神经网络

  • 工具和框架

  • 转移学习

  • 案例研究:使用深度学习进行欺诈检测

让我们从神经网络的基础开始。

神经网络的演变

神经网络在其最基本的层面上,由被称为神经元的独立单元组成。这些神经元是神经网络的基石,每个神经元执行各自特定的任务。当这些独立的神经元组织成结构化的层时,神经网络的真正力量得以展现,从而促进复杂的处理过程。每个神经网络都由这些层的错综复杂的网络组成,层与层之间通过连接形成互联网络。

信息或信号在通过这些层时被一步一步地处理。每一层都会修改信号,最终影响整体输出。具体来说,初始层接收输入信号,对其进行处理后将其传递到下一层。随后的层进一步处理接收到的信号并继续传递。这一传递过程一直持续,直到信号到达最终层,生成所需的输出。

正是这些隐藏层或中间层赋予了神经网络进行深度学习的能力。这些层通过逐步将原始输入数据转换为更有用的形式,创建了抽象表示的层次结构。这有助于从原始数据中提取更高层次的特征。

这种深度学习能力具有广泛的实际应用,从使亚马逊的 Alexa 能够理解语音命令,到支持谷歌的图像和整理谷歌照片。

历史背景

受到人类大脑中神经元工作的启发,Frank Rosenblatt 在 1957 年提出了神经网络的概念。要完全理解其结构,简要查看人类大脑神经元的分层结构是很有帮助的。(参考图 8.1,了解人类大脑中神经元如何相互连接。)

在人脑中,树突充当传感器,检测信号。树突是神经元的组成部分,作为主要感觉器官。它们负责检测传入的信号。然后信号传递给轴突,这是神经细胞的一种长而细的突出部分。轴突的功能是将这个信号传输到肌肉、腺体和其他神经元。如下图所示,信号通过称为突触的相互连接组织传递,然后传递给其他神经元。请注意,通过这种有机管道,信号一直传播,直到达到目标肌肉或腺体,引起所需的动作。信号通常需要七到八毫秒才能通过神经元链传播并到达目标。

自动生成的图表描述

图 8.1:人脑中连接在一起的神经元

受到这种自然信号处理建筑杰作的启发,Frank Rosenblatt 设计了一种技术,使得可以按层处理数字信息以解决复杂的数学问题。他最初设计的神经网络尝试非常简单,看起来像一个线性回归模型。这种简单的神经网络没有任何隐藏层,被命名为感知器。这种没有任何层的简单神经网络,感知器,成为了神经网络的基本单元。实质上,感知器是生物神经元的数学模拟,因此是更复杂神经网络的基本构建块。

现在,让我们深入了解人工智能AI)演化历史的简明史账。

AI 寒冬与 AI 春天的曙光

对感知器这一突破性概念的最初热情在其重大局限性被发现后迅速消退。1969 年,马文·明斯基和西摩·帕珀特进行了深入研究,揭示了感知器在学习能力上的局限性。他们发现,感知器无法学习和处理复杂的逻辑函数,甚至在处理像异或(XOR)这样的简单逻辑函数时也存在困难。

这一发现引发了对机器学习ML)和神经网络兴趣的显著下降,开启了一个通常被称为“人工智能寒冬”的时代。这一时期,全球研究界普遍对人工智能的潜力表示怀疑,认为其不足以解决复杂问题。

回顾起来,“人工智能寒冬”在某种程度上是当时硬件能力受限的结果。那时的硬件要么缺乏必要的计算能力,要么过于昂贵,这极大地阻碍了人工智能的进展。这一限制阻碍了人工智能的应用和发展,导致人们普遍对其潜力感到失望。

到了 1990 年代末,关于人工智能及其潜力的看法发生了巨大变化。推动这一变化的催化剂是分布式计算的发展,它提供了易于获取和负担得起的基础设施。看到人工智能的潜力,当时新崛起的 IT 巨头(如谷歌)将人工智能作为其研发的重点。这种对人工智能的重新兴趣导致了所谓“人工智能寒冬”的解冻。这一解冻重新激发了对人工智能的研究,最终使得当前时代成为一个可以称之为人工智能春天的时代,大家对人工智能和神经网络充满兴趣。此外,数字化数据当时尚未普及。

理解神经网络

首先,让我们从神经网络的核心——感知器开始。你可以把一个单独的感知器看作是最简单的神经网络,它是现代复杂多层架构的基本构建模块。让我们从理解感知器的工作原理开始。

理解感知器

一个单一的感知器有多个输入和一个输出,该输出由激活函数控制或激活。如下图图 8.2所示:

图表 描述自动生成

图 8.2:一个简单的感知器

图 8.2中显示的感知机有三个输入特征;x[1],x[2],和x[3]。我们还加入了一个常数信号,称为偏置。偏置在我们的神经网络模型中起着关键作用,因为它允许在拟合数据时具有灵活性。它的作用类似于线性方程中添加的截距——作为激活函数的一种“偏移”——从而使我们在输入为零时能够更好地拟合数据。输入特征和偏置与权重相乘并求和,得到加权和 。这个加权和会传递给激活函数,产生输出 y。能够使用多种激活函数来制定特征与标签之间的复杂关系是神经网络的一个优势。通过超参数可以选择多种激活函数。一些常见的例子包括 sigmoid 函数,它将值压缩到 0 到 1 之间,是二分类问题的好选择;tanh 函数,它将值缩放到 -1 到 1 之间,提供零中心的输出;以及修正线性单元ReLU)函数,它将向量中的所有负值设为零,有效地去除任何负面影响,并且在卷积神经网络中常常被使用。接下来,本章将详细讨论这些激活函数。

现在让我们来探讨一下神经网络背后的直觉。

理解神经网络背后的直觉

在上一章中,我们讨论了一些传统的机器学习算法。这些传统算法在许多重要的应用场景中表现优异,但它们也有一定的局限性。当训练数据集中的潜在模式开始变得非线性和多维时,传统机器学习算法的能力已经无法准确捕捉特征与标签之间复杂的关系。这些不完备的、相对简化的数学公式化表示复杂模式,导致在这些用例中的训练模型表现不佳。

在现实世界的场景中,我们经常遇到特征与标签之间的关系不是线性或简单的,而是呈现出复杂的模式。这正是神经网络的优势所在,它为我们提供了一个强大的工具,用于建模这些复杂性。

神经网络在处理高维数据或特征与结果之间的关系是非线性的情况下特别有效。例如,它们在图像和语音识别等应用中表现出色,其中输入数据(像素或声波)具有复杂的层级结构。传统的机器学习算法可能在这些情况下表现不佳,因为特征之间关系的高度复杂性和非线性。

虽然神经网络是非常强大的工具,但我们必须承认它们并非没有局限性。这些限制将在本章后面详细探讨,对于神经网络在解决现实问题中的有效应用,了解这些限制至关重要。

现在,让我们举例说明使用更简单的机器学习算法,如线性回归时常见的模式及其相关挑战。假设我们正在尝试根据“受教育年限”预测数据科学家的工资。我们从两个不同的组织收集了两个数据集。

首先,让我们介绍数据集 1,如图 8.3(a)所示。它描述了特征(受教育年限)与标签(工资)之间的相对简单的关系,看起来是线性的。然而,即使是这个简单的模式,在我们尝试用线性算法进行数学建模时,也会遇到一些挑战:

  • 我们知道,工资不能为负数,这意味着无论受教育年限如何,工资(y)都不应小于零。

  • 至少有一位刚毕业的初级数据科学家,可能只用了“x[1]”年的教育时间,但目前工资为零,可能是实习生。因此,在“x”的取值范围从零到“x[1]”时,工资“y”保持为零,如图 8.3(a)所示。

有趣的是,我们可以利用神经网络中可用的修正线性激活函数来捕捉特征与标签之间这种复杂的关系,这是我们后面将探讨的一个概念。

接下来,我们来看数据集 2,如图 8.3(b)所示。这个数据集表示特征与标签之间的非线性关系。其工作原理如下:

  1. 当“x” (受教育年限)从零变化到“x[1]”时,工资“y”保持为零。

  2. 当“x”接近“x[2]”时,工资急剧增加。

  3. 但一旦“y”超过“x[2]”,工资将达到平稳状态并趋于平坦。

正如我们将在本书后面看到的,我们可以在神经网络框架内使用 Sigmoid 激活函数来建模此类关系。理解这些模式并知道何时应用合适的工具,是有效利用神经网络强大功能的关键:

Chart, scatter chart  Description automatically generated

图 8.3:工资与受教育年限

(a) 数据集 1:线性模式 (b) 数据集 2:非线性模式

理解分层深度学习架构

对于更复杂的问题,研究人员开发了一种多层神经网络,称为多层感知器。一个多层神经网络有几个不同的层,如下图所示。这些层如下:

  • 输入层:第一层是输入层。在输入层,特征值作为输入被馈送到网络中。

  • 隐藏层:输入层后面跟随一个或多个隐藏层。每个隐藏层都是类似激活函数的数组。

  • 输出层:最后一层称为输出层。

一个简单的神经网络会有一个隐藏层。一个深度神经网络是一个有两个或更多隐藏层的神经网络。见图 8.4

图表,散点图 说明自动生成

图 8.4:简单神经网络与深度神经网络

接下来,让我们尝试理解隐藏层的功能。

培养对隐藏层的直觉

在神经网络中,隐藏层在解释输入数据中起着关键作用。隐藏层在神经网络中以层级结构有序组织,每一层对其输入数据执行独特的非线性转换。这种设计允许从输入中提取逐渐更抽象、更细致的特征。

以卷积神经网络为例,卷积神经网络是专为图像处理任务设计的神经网络子类型。在这个背景下,较低的隐藏层专注于辨别图像中的简单局部特征,如边缘和角落。这些特征虽然是基础的,但单独来看并没有太大意义。

随着我们深入到隐藏层,这些层开始连接起各个点。可以说,它们将较低层检测到的基本模式整合成更复杂、更有意义的结构。结果,本来杂乱无章的边缘和角落,转变为可识别的形状和模式,从而赋予网络一定的“视觉”。

这个逐步转换的过程将未经处理的像素值转化为精细的特征和模式映射,从而实现诸如指纹识别等高级应用。在这里,网络能够识别指纹中脊线和谷线的独特排列,将这些原始的视觉数据转换为独特的身份标识。因此,隐藏层将原始数据转换并提炼成有价值的洞察。

应该使用多少个隐藏层?

请注意,隐藏层的最佳数量会根据问题的不同而有所变化。对于某些问题,应该使用单层神经网络。这些问题通常表现出简单的模式,可以通过简洁的网络设计轻松捕捉和表达。对于其他问题,我们应增加多个层以获得最佳性能。例如,如果你正在处理一个复杂的问题,如图像识别或自然语言处理,可能需要一个具有多个隐藏层和每层更多节点的神经网络。

数据的潜在模式的复杂性将很大程度上影响你的网络设计。例如,对于简单问题使用过于复杂的神经网络可能导致过拟合,使得你的模型过度拟合训练数据,并且在新的、未见过的数据上表现不佳。另一方面,过于简单的模型可能会导致欠拟合,即模型未能捕捉数据中的关键模式。

此外,激活函数的选择也起着关键作用。例如,如果你的输出需要是二元的(如是/否问题),则可以使用 sigmoid 函数。对于多分类问题,softmax 函数可能更为合适。

最终,选择神经网络架构的过程需要仔细分析你的问题,并进行实验和微调。在这个过程中,开发一个基准实验模型可能会很有帮助,这样你可以通过迭代调整和优化网络设计,以达到最佳性能。

接下来,我们来看看神经网络的数学基础。

神经网络的数学基础

理解神经网络的数学基础是发挥其能力的关键。虽然它们看起来复杂,但其原理基于熟悉的数学概念,如线性代数、微积分和概率论。神经网络的魅力在于其从数据中学习并随着时间推移不断改进的能力,这些特性源于它们的数学结构:

自动生成的图示描述

图 8.5:多层感知机

图 8.5 显示了一个四层神经网络。在这个神经网络中,一个重要的要点是,神经元是该网络的基本单元,并且每一层的神经元都与下一层的所有神经元相连接。对于复杂的网络,这些连接的数量会急剧增加,我们将探索在不牺牲太多质量的情况下减少这些连接的不同方法。

首先,让我们尝试表述我们要解决的问题。

输入是一个特征向量 x,其维度为 n

我们希望神经网络能够预测值。预测值用 ý 表示。

从数学角度看,我们想要确定,在给定特定输入的情况下,交易是欺诈的概率。换句话说,给定 x 的特定值,y = 1 的概率是多少?从数学角度看,我们可以表示为:

请注意,x 是一个 n[x] 维的向量,其中 n[x] 是输入变量的数量。

图 8.6所示,神经网络有四层。输入层和输出层之间的层称为隐藏层。第一层隐藏层中的神经元数量用表示。各个节点之间的连接由被称为权重的参数乘以。训练神经网络的过程本质上是围绕着确定与网络中各个神经元连接相关的权重的最优值。通过调整这些权重,网络可以调整其计算并随着时间的推移提高性能。

让我们看看如何训练一个神经网络。

训练神经网络

使用给定数据集构建神经网络的过程称为训练神经网络。让我们深入了解典型神经网络的结构。当我们谈论训练神经网络时,我们是在谈论为权重计算最佳值。训练是通过使用一组以训练数据形式呈现的示例进行的。训练数据中的示例为不同输入值组合的输出提供了预期值。神经网络的训练过程与传统模型的训练方式不同(这在第七章传统监督学习算法中有讨论)。

理解神经网络的结构

让我们看看神经网络由哪些部分组成:

  • :层是神经网络的核心构建块。每一层是一个数据处理模块,充当过滤器。它接收一个或多个输入,以某种方式处理这些输入,然后生成一个或多个输出。每次数据通过一层时,它都会经历一个处理阶段,并展示与我们试图回答的业务问题相关的模式。

  • 损失函数:损失函数提供了在学习过程的各个迭代中使用的反馈信号。损失函数为单个示例提供偏差。

  • 成本函数:成本函数是完整示例集上的损失函数。

  • 优化器:优化器决定如何解释损失函数提供的反馈信号。

  • 输入数据:输入数据是用于训练神经网络的数据。它指定了目标变量。

  • 权重:权重通过训练网络来计算。权重大致对应于每个输入的重要性。例如,如果某个输入比其他输入更重要,那么在训练后,它将被赋予更大的权重值,作为乘数。即使这个重要输入的信号较弱,它也会因为较大的权重值(作为乘数的作用)而增强。因此,权重最终根据输入的重要性调整每个输入的影响。

  • 激活函数:这些值会被不同的权重乘以,然后汇总。它们如何被汇总以及如何解读其值,将由所选择的激活函数的类型决定。

现在,让我们来看看神经网络训练中一个非常重要的方面。

在训练神经网络时,我们会逐一处理每一个样本。对于每一个样本,我们使用正在训练的模型生成输出。术语“正在训练”指的是模型的学习状态,此时模型仍在调整并从数据中学习,尚未达到最佳性能。在这个阶段,模型参数,如权重,持续更新和调整,以提高其预测性能。我们计算期望输出与预测输出之间的差异。对于每个单独的样本,这个差异被称为损失。所有样本的损失加起来,就是代价。随着训练的进行,我们的目标是找到合适的权重值,以使得损失值最小化。在整个训练过程中,我们会不断调整权重值,直到找到一组能使总体代价最小的权重值。一旦我们达到最小代价,就标志着模型已经训练完成。

定义梯度下降

训练神经网络的核心目标是确定权重的正确值,这些权重像“旋钮”或“调节器”一样,通过调整它们来最小化模型预测与实际值之间的差异。

当训练开始时,我们使用随机或默认值初始化这些权重。然后,我们使用优化算法逐步调整它们,常用的优化算法是“梯度下降”,以逐步改进模型的预测结果。

让我们深入了解梯度下降算法。梯度下降的旅程从我们设置的初始随机权重值开始。

从这个起点开始,我们迭代并在每一步调整这些权重,使得我们更接近最小代价。

为了更清楚地说明这一点,假设我们的数据特征是输入向量X。目标变量的真实值是Y,而我们模型预测的值是Y。我们衡量实际值与预测值之间的差异或偏差,这个差异就是我们的损失。

然后,我们更新权重,考虑到两个关键因素:移动的方向和步伐的大小,也就是学习率。

“方向”告诉我们该朝哪个方向移动,以找到损失函数的最小值。可以把它想象成下坡——我们希望沿着坡度最陡的地方“下坡”,这样可以最快到达底部(即最小损失)。

“学习率”决定了我们在选择的方向上的步长大小。就像决定是走下山坡还是跑下去——较大的学习率意味着更大的步伐(像奔跑),而较小的学习率意味着较小的步伐(像走路)。

这个迭代过程的目标是达到一个我们无法继续“下坡”的点,意味着我们已经找到了最小的成本,表示我们的权重已经最优,模型也已经很好地训练完成。

这个简单的迭代过程在下图中展示:

一张图片,包含文字,运动比赛,运动描述自动生成

图 8.6:梯度下降算法,寻找最小值

图示展示了通过调整权重,梯度下降如何尝试找到最小的成本。学习率和选择的方向将决定图表中下一个要探索的点。

选择正确的学习率非常重要。如果学习率太小,问题可能需要很长时间才能收敛。如果学习率过高,问题将无法收敛。在前面的图示中,表示当前解的点会在图表的两条对立线之间不断摆动。

现在,让我们来看看如何最小化梯度。仅考虑两个变量,xyxy 的梯度计算如下:

为了最小化梯度,可以使用以下方法:

def adjust_position(gradient):
    while gradient != 0:
        if gradient < 0:
            print("Move right")
            # here would be your logic to move right
        elif gradient > 0:
            print("Move left")
            # here would be your logic to move left 

该算法还可以用于寻找神经网络权重的最优或近似最优值。

请注意,梯度下降的计算是从网络的后端开始进行的。我们首先计算最终层的梯度,然后是倒数第二层的梯度,再然后是之前的层,一直到达第一层。这就是所谓的反向传播,它是由 Hinton、Williams 和 Rumelhart 于 1985 年提出的。

接下来,让我们深入探讨激活函数。

激活函数

激活函数制定了如何处理特定神经元的输入以生成输出的方式。

图 8.7所示,神经网络中的每个神经元都有一个激活函数,决定了如何处理输入数据:

图示,描述自动生成

图 8.7:激活函数

在前面的图示中,我们可以看到由激活函数生成的结果被传递到输出端。激活函数设定了如何解释输入值以生成输出的标准。

对于完全相同的输入值,不同的激活函数将产生不同的输出。理解如何选择正确的激活函数在使用神经网络解决问题时非常重要。

现在,让我们逐一看看这些激活函数。

步骤函数

最简单的激活函数是阈值函数。阈值函数的输出是二值的:0 或 1。如果任何输入大于 1,它将生成 1 作为输出。这可以通过 图 8.8 来解释:

Diagram  Description automatically generated with medium confidence

图 8.8:阶跃函数

尽管其简单性,阈值激活函数在我们需要输出之间清晰划分时起着重要作用。使用此函数,只要输入的加权和中有任何非零值,输出 (y) 就会变为 1。然而,它的简单性也带来了缺点——该函数极其敏感,可能会因为输入中的微弱信号或噪声而被错误触发。

例如,考虑一种情况,其中神经网络使用此函数将电子邮件分类为“垃圾邮件”或“非垃圾邮件”。在这里,输出 1 可能表示“垃圾邮件”,而 0 可能表示“非垃圾邮件”。某个特征(如某些关键垃圾邮件词汇)的最轻微出现可能会触发该函数将电子邮件分类为“垃圾邮件”。因此,尽管它在某些应用中是一个有价值的工具,但在输入数据中噪声或轻微变化常见的情况下,应该考虑其过度敏感性的潜力。接下来,让我们深入了解 Sigmoid 函数。

Sigmoid 函数

Sigmoid 函数可以视为阈值函数的一种改进。在这里,我们可以控制激活函数的敏感性:

Diagram  Description automatically generated with medium confidence

图 8.9:Sigmoid 激活函数

Sigmoid 函数 y 定义如下,并在 图 8.9 中显示:

可以通过以下方式在 Python 中实现:

def sigmoidFunction(z):
      return 1/ (1+np.exp(-z)) 

上面的代码使用 Python 演示了 Sigmoid 函数。这里,np.exp(-z) 是对 -z 应用的指数运算,结果加上 1 构成方程的分母,从而得到一个介于 0 和 1 之间的值。

通过 Sigmoid 函数降低激活函数的敏感性,使其不易受到输入中的突变或“故障”影响。然而,值得注意的是,输出仍然是二值的,意味着它只能是 0 或 1。

Sigmoid 函数广泛应用于二分类问题,其中输出预期为 0 或 1。例如,如果你正在开发一个模型来预测电子邮件是否是垃圾邮件(1)或非垃圾邮件(0),则 Sigmoid 激活函数将是一个合适的选择。

现在,让我们深入了解 ReLU 激活函数。

ReLU

本章介绍的前两个激活函数的输出是二进制的。这意味着它们会将一组输入变量转换为二进制输出。ReLU 是一种激活函数,它将一组输入变量作为输入,并将其转换为单一的连续输出。在神经网络中,ReLU 是最流行的激活函数,通常用于隐藏层,我们不希望将连续变量转换为类别变量。

以下图表总结了 ReLU 激活函数:

图表,折线图 描述自动生成

图 8.10:ReLU

请注意,当x ≤ 0 时,这意味着y = 0。这意味着输入中任何为零或小于零的信号都会被转换为零输出:

一旦x变为大于零,它就是x

ReLU 函数是神经网络中最常用的激活函数之一。它可以在 Python 中按如下方式实现:

def relu(x):
    if x < 0:
        return 0
    else:
        return x 

现在让我们看看基于 ReLU 的 Leaky ReLU。

Leaky ReLU

在 ReLU 中,x的负值会导致y的值为零。这意味着在过程中丢失了一些信息,这使得训练周期特别在训练初期变得更长。Leaky ReLU 激活函数解决了这个问题。以下内容适用于 Leaky ReLU:

如下图所示:

图表 描述自动生成

图 8.11:Leaky ReLU

这里,是一个值小于 1 的参数。

它可以在 Python 中按如下方式实现:

def leaky_relu(x, beta=0.01):
    if x < 0:
        return beta * x    
    else:        
        return x 

赋值有多种策略:

  • 默认值:我们可以为指定一个默认值,通常为0.01。这是最直接的方法,在我们希望快速实现而不进行复杂调优的情况下非常有用。

  • 参数化 ReLU:另一种方法是允许在我们的神经网络模型中成为一个可调参数。在这种情况下,的最佳值是在训练过程中学习到的。这在我们希望将激活函数调整为数据中存在的特定模式时非常有用。

  • 随机 ReLU:我们还可以选择随机赋值给。这种技术被称为随机 ReLU,可以作为一种正则化方法,通过引入一些随机性来防止过拟合。这在我们有一个包含复杂模式的大型数据集,并且希望确保模型不会过拟合训练数据时非常有用。

双曲正切(tanh)

双曲正切函数,或称为 tanh,与 sigmoid 函数密切相关,其主要区别在于:它可以输出负值,从而提供一个更广泛的输出范围,介于 -11 之间。这在我们想要建模包含正负影响的现象时非常有用。图 8.12 展示了这一点:

图表 描述自动生成,信心水平中等

图 8.12:双曲正切

y 函数如下:

它可以通过以下 Python 代码实现:

import numpy as np
def tanh(x): 
    numerator = 1 - np.exp(-2 * x) 
    denominator = 1 + np.exp(-2 * x) 
    return numerator / denominator 

在这段 Python 代码中,我们使用了 numpy 库,简称 np,来处理数学运算。tanh 函数,像 sigmoid 一样,是神经网络中的一种激活函数,用于为模型引入非线性。它通常在神经网络的隐藏层中优于 sigmoid,因为它通过将输出的均值设置为 0 来使数据居中,从而使得下一个层的学习更加容易。然而,选择 tanhsigmoid 或其他激活函数,主要取决于你正在处理的模型的具体需求和复杂性。

接下来,让我们深入探讨一下 softmax 函数。

Softmax

有时,我们需要激活函数的输出有多个层级。Softmax 就是一种激活函数,它为我们提供了超过两个层级的输出。它最适合用于多分类问题。假设我们有 n 个类别。我们有输入值,这些输入值将类别映射如下:

x = {x((1))*,x*((2)),....x^((n))}

Softmax 操作基于概率理论。对于二分类器,最后一层的激活函数将是 sigmoid,而对于多分类器,则使用 softmax。举个例子,假设我们要对一张水果图片进行分类,类别为 apple(苹果)、banana(香蕉)、cherry(樱桃)和 date(枣)。Softmax 函数会计算这张图片属于每个类别的概率。概率最高的类别会被认为是预测结果。

为了在 Python 代码和公式中解释这一点,让我们来看以下内容:

import numpy as np
def softmax(x): 
    return np.exp(x) / np.sum(np.exp(x), axis=0) 
numpy library (np) to perform the mathematical operations. The softmax function takes an array of x as input, applies the exponential function to each element, and normalizes the results so that they sum up to 1, which is the total probability across all classes.

现在,让我们看一下与神经网络相关的各种工具和框架。

工具和框架

本节将深入探讨一些专门为便于实现神经网络而开发的工具和框架。每个框架都有其独特的优点和可能的局限性。

在众多可用的选项中,我们选择重点介绍 Keras,它是一个高级神经网络 API,可以在 TensorFlow 之上运行。你可能会问,为什么是 Keras 和 TensorFlow?这两个结合起来提供了多个显著的优势,成为了业内实践者的热门选择。

首先,Keras 因其用户友好且模块化的特点,简化了构建和设计神经网络模型的过程,既适合初学者也适合有经验的用户。其次,它与 TensorFlow 的兼容性——TensorFlow 是一个强大的端到端开源机器学习平台——确保了其健壮性和多功能性。TensorFlow 提供的高计算性能是其另一个宝贵资产。两者结合,形成了一个动态组合,在可用性和功能性之间取得了平衡,使其成为神经网络模型开发和部署的绝佳选择。

在接下来的章节中,我们将探讨如何使用具有 TensorFlow 后端的 Keras 来构建神经网络。

Keras

Keras (www.tensorflow.org/guide/keras) 是一个非常流行且易于使用的神经网络库,使用 Python 编写。它的编写目标是易用性,并提供了实现深度学习的最快方式。Keras 仅提供高级模块,被视为模型级别的工具。

现在,让我们看看 Keras 的各种后端引擎。

Keras 的后端引擎

Keras 需要一个较低级别的深度学习库来执行张量级别的操作。这个基础层被称为“后端引擎”。

简单来说,张量级别的操作涉及对多维数据数组(即张量)进行计算和转换,张量是神经网络中使用的主要数据结构。这个较低级别的深度学习库被称为后端引擎。Keras 的后端引擎可能包括以下几种:

这种模块化深度学习技术栈的格式如下面的图所示:

图表描述自动生成

图 8.13:Keras 架构

这种模块化的深度学习架构的优势在于,Keras 的后端可以在不重写任何代码的情况下进行更改。例如,如果我们发现 TensorFlow 在某个特定任务上比 Theano 更好,我们可以简单地将后端更改为 TensorFlow,而无需重写代码。

接下来,让我们深入了解深度学习栈的低级层次。

深度学习栈的低级层次

我们刚才提到的三种后端引擎都可以在 CPU 和 GPU 上运行,使用堆栈的低级层。对于 CPU,使用一个低级的张量操作库Eigen。对于 GPU,TensorFlow 使用 NVIDIA 的CUDA 深度神经网络cuDNN)库。值得解释的是,为什么在机器学习中通常更偏爱 GPU。

虽然 CPU 具有多功能性和强大性能,但 GPU 是专门设计用来同时处理多个操作的,这在处理大量数据时尤其有利,而这在机器学习任务中非常常见。GPU 的这一特性,加上更高的内存带宽,可以显著加快机器学习计算,因此它们成为这些任务的流行选择。

接下来,让我们解释一下超参数。

定义超参数

第六章无监督机器学习算法》中讨论的那样,超参数是一个在学习过程开始之前选择的参数值。我们通常从常识性的值开始,然后尝试优化它们。对于神经网络,重要的超参数包括:

  • 激活函数

  • 学习率

  • 隐藏层的数量

  • 每个隐藏层中的神经元数量

让我们看看如何使用 Keras 定义一个模型。

定义一个 Keras 模型

定义完整 Keras 模型涉及三个步骤:

  1. 定义层

  2. 定义学习过程

  3. 测试模型

我们可以使用Keras有两种方式来构建模型:

  • 函数式 API:这允许我们为无环图的层架构模型。可以使用函数式 API 创建更复杂的模型。

  • 顺序 API:这允许我们为线性堆叠的层架构模型。它适用于相对简单的模型,是构建模型时的常用选择。

首先,我们来看一下使用顺序方式定义 Keras 模型:

  1. 让我们从导入tensorflow库开始:

    import tensorflow as tf 
    
  2. 然后,从 Keras 的 datasets 加载 MNIST 数据集:

    mnist = tf.keras.datasets.mnist 
    
  3. 接下来,将数据集拆分为训练集和测试集:

    (train_images, train_labels), (test_images, test_labels) = mnist.load_data() 
    
  4. 我们将像素值从255的比例归一化到1的比例:

    train_images, test_images = train_images / 255.0,                             test_images / 255.0 
    
  5. 接下来,我们定义模型的结构:

    model = tf.keras.models.Sequential([
        tf.keras.layers.Flatten(input_shape=(28, 28)),
        tf.keras.layers.Dense(128, activation='relu'),
        tf.keras.layers.Dropout(0.15),
        tf.keras.layers.Dense(128, activation='relu'),
        tf.keras.layers.Dropout(0.15),
        tf.keras.layers.Dense(10, activation='softmax'),
    ]) 
    

该脚本正在训练一个模型来分类MNIST数据集中的图像,该数据集包含 70,000 张由高中生和美国人口普查局员工手写的数字小图像。

模型使用 Keras 中的Sequential方法定义,表示我们的模型是一个线性堆叠的层:

  1. 第一层是一个Flatten层,它将图像的格式从二维数组转换为一维数组。

  2. 下一层是一个Dense层,它是一个全连接神经层,包含 128 个节点(或神经元)。此处使用relu(ReLU)激活函数。

  3. Dropout层在每次训练时随机将输入单元设置为0,其频率由每步的率控制,帮助防止过拟合。

  4. 另一个Dense层被包含在内,类似于前一个层,它也使用relu激活函数。

  5. 我们再次应用一个Dropout层,使用与之前相同的比率。

  6. 最后一层是一个 10 节点的 softmax 层——它返回一个包含 10 个概率分数的数组,总和为1。每个节点包含一个分数,表示当前图像属于 10 个数字类别中的某一类别的概率。

请注意,在这里,我们创建了三层——前两层使用relu激活函数,第三层使用softmax作为激活函数。

现在,让我们来看一下使用 Functional API 定义 Keras 模型的方式:

  1. 首先,我们导入tensorflow库:

    # Ensure TensorFlow 2.x is being used
    %tensorflow_version 2.x
    import tensorflow as tf
    from tensorflow.keras.datasets import mnist 
    
  2. 为了使用MNIST数据集,我们首先将其加载到内存中。该数据集已经方便地分为训练集和测试集,包含了图像和相应的标签:

    # Load MNIST dataset
    (train_images, train_labels), (test_images, test_labels) = mnist.load_data()
    # Normalize the pixel values to be between 0 and 1
    train_images, test_images = train_images / 255.0, test_images / 255.0 
    
  3. MNIST数据集中的图像大小为28x28像素。在使用 TensorFlow 设置神经网络模型时,需要指定输入数据的形状。在这里,我们为模型建立了输入张量:

    inputs = tf.keras.Input(shape=(28,28)) 
    
  4. 接下来,Flatten层是一个简单的数据预处理步骤。它通过“拉平”输入,将二维的128x128像素图像转换为一维数组。这样可以为后续的Dense层做准备:

    x = tf.keras.layers.Flatten()(inputs) 
    
  5. 接下来是第一个Dense层,也称为全连接层,其中每个输入节点(或神经元)都与每个输出节点相连接。该层有 512 个输出节点,并使用relu激活函数。ReLU 是一个流行的激活函数,它在输入为正时直接输出输入值;否则输出零:

    x = tf.keras.layers.Dense(512, activation='relu', name='d1')(x) 
    
  6. Dropout层会在每次训练更新时随机将输入节点的一部分(在本例中为 0.2 或 20%)设置为 0,这有助于防止过拟合:

    x = tf.keras.layers.Dropout(0.2)(x) 
    
  7. 最后是输出层。它是另一个Dense层,包含 10 个输出节点(假设是 10 个类别)。应用softmax激活函数,该函数输出一个概率分布,覆盖 10 个类别,这意味着它将输出 10 个值,总和为 1。每个值表示模型对输入图像对应某一特定类别的置信度:

    predictions = tf.keras.layers.Dense(10, activation=tf.nn.softmax, name='d2')(x)
    model = tf.keras.Model(inputs=inputs, outputs=predictions) 
    

请注意,我们可以使用 Sequential 和 Functional 两种 API 定义相同的神经网络。从性能角度来看,选择哪种方式定义模型并没有区别。

让我们将数值型的train_labelstest_labels转换为 one-hot 编码向量。在下面的代码中,每个标签都会变成一个大小为 10 的二进制数组,其中相应数字的索引位置为 1,其余位置为 0:

# One-hot encode the labels
train_labels_one_hot = tf.keras.utils.to_categorical(train_labels, 10)
test_labels_one_hot = tf.keras.utils.to_categorical(test_labels, 10) 

我们现在应该定义学习过程。

在这一步,我们定义三项内容:

  • 优化器

  • loss函数

  • 将量化模型质量的度量标准:

optimizer = tf.keras.optimizers.RMSprop()
loss = 'categorical_crossentropy'
metrics = ['accuracy']
model.compile(optimizer=optimizer, loss=loss, metrics=metrics) 

请注意,我们使用model.compile函数来定义优化器、损失函数和度量标准。

现在我们将训练模型。

一旦架构定义完成,就可以开始训练模型:

history = model.fit(train_images, train_labels_one_hot, epochs=10, validation_data=(test_images, test_labels_one_hot)) 

请注意,像batch_sizeepochs这样的参数是可配置的参数,因此它们是超参数。

接下来,让我们深入探讨如何选择顺序模型或功能模型。

选择顺序模型或功能模型

在决定使用顺序模型还是功能模型来构建神经网络时,网络架构的性质将指导你的选择。顺序模型适用于简单的线性层堆叠。它实现起来简单直接,是初学者或处理简单任务的理想选择。然而,这种模型有一个关键的限制:每一层只能连接到一个输入张量和一个输出张量。

如果你的网络架构更加复杂,例如在任何阶段(输入层、输出层或隐藏层)有多个输入或输出,那么顺序模型就不再适用。对于这种复杂的架构,功能模型更为合适。该模型提供了更高的灵活性,允许在任何层具有多个输入和输出,从而支持更复杂的网络结构。现在让我们更深入地理解 TensorFlow。

了解 TensorFlow

TensorFlow 是最流行的神经网络工作库之一。在前面的部分中,我们看到它如何作为 Keras 的后台引擎使用。它是一个开源的高性能库,实际上可以用于任何数值计算。

如果我们看看堆栈,我们可以看到,我们可以使用像 Python 或 C++这样的高级语言编写 TensorFlow 代码,这些代码会被 TensorFlow 分布式执行引擎解释执行。这使得它对开发者来说非常有用并且广受欢迎。

TensorFlow 通过使用有向图DG)来体现你的计算。在这个图中,节点是数学运算,连接这些节点的边代表这些运算的输入和输出。此外,这些边还表示数据数组。

除了作为 Keras 的后台引擎,TensorFlow 还广泛应用于各种场景中。它可以帮助开发复杂的机器学习模型、处理大规模数据集,甚至在不同平台上部署 AI 应用。无论你是在创建推荐系统、图像分类模型,还是自然语言处理工具,TensorFlow 都能有效地满足这些任务以及更多需求。

介绍 TensorFlow 的基本概念

让我们简要了解一下 TensorFlow 中的概念,比如标量、向量和矩阵。我们知道,像三或五这样的简单数字,在传统数学中被称为标量。此外,在物理学中,向量是具有大小和方向的量。在 TensorFlow 中,我们用向量表示一维数组。扩展这一概念,二维数组即为矩阵。对于三维数组,我们使用3D 张量这一术语。我们使用来表示数据结构的维度。因此,标量秩 0的数据结构,向量秩 1的数据结构,矩阵秩 2的数据结构。这些多维结构被称为张量,并在以下图表中展示:

形状,正方形 描述自动生成

图 8.14:多维结构或张量

如我们在前面的图表中看到的,秩定义了张量的维度。

现在,让我们来看另一个参数,shapeshape是一个整数元组,指定每个维度中数组的长度。

以下图表解释了shape的概念:

一张图形用户界面的图片 描述自动生成

图 8.15:形状的概念

使用shape和秩,我们可以指定张量的详细信息。

理解张量数学

现在让我们看一下使用张量进行的不同数学运算:

  • 让我们定义两个标量,并尝试使用 TensorFlow 进行加法和乘法运算:

    print("Define constant tensors")
    a = tf.constant(2)
    print("a = %i" % a)
    b = tf.constant(3)
    print("b = %i" % b) 
    
    Define constant tensors
    a = 2
    b = 3 
    
  • 我们可以对其进行加法和乘法运算并展示结果:

    print("Running operations, without tf.Session")
    c = a + b
    print("a + b = %i" % c)
    d = a * b
    print("a * b = %i" % d) 
    
    Running operations, without tf.Session
    a + b = 5
    a * b = 6 
    
  • 我们还可以通过将两个张量相加来创建一个新的标量张量:

    c = a + b
    print("a + b = %s" % c) 
    
    a + b = tf.Tensor(5, shape=(), dtype=int32) 
    
  • 我们还可以执行复杂的张量运算:

    d = a*b
    print("a * b = %s" % d) 
    
    a * b = tf.Tensor(6, shape=(), dtype=int32) 
    

理解神经网络的类型

神经网络可以根据神经元的互联方式进行不同的设计。在密集型或全连接的神经网络中,给定层中的每个神经元都与下一层的每个神经元相连接。这意味着来自前一层的每个输入都会传递到下一层的每个神经元,从而最大化信息流动。

然而,神经网络并不总是完全连接的。有些网络可能基于其解决问题的需求,具有特定的连接模式。例如,在用于图像处理的卷积神经网络中,某一层的每个神经元可能只与上一层中一小块区域的神经元相连接。这与人类视觉皮层中神经元的组织方式相似,并帮助网络高效处理视觉信息。

记住,神经网络的具体架构——神经元如何互联——对其功能和性能有着极大的影响。

卷积神经网络

卷积神经网络CNNs)通常用于分析多媒体数据。为了深入了解 CNN 如何分析基于图像的数据,我们需要掌握以下过程:

  • 卷积

  • 池化

让我们逐一探索它们。

卷积

卷积过程通过使用一个称为滤波器(也叫卷积核)的小图像处理特定图像,强调图像中的某些模式。例如,如果我们想找到图像中物体的边缘,我们可以将图像与特定的滤波器卷积,从而得到边缘。边缘检测有助于物体检测、物体分类等应用。因此,卷积过程就是在图像中寻找特征和特性。

查找模式的方法是基于找到可以在不同数据上重用的模式。这些可重用的模式被称为滤波器或卷积核。

池化

为了进行机器学习,处理多媒体数据的重要部分是下采样。下采样是减少数据分辨率的过程,即降低数据的复杂性或维度。池化提供了两个主要优点:

  • 通过降低数据的复杂性,我们可以显著减少模型的训练时间,提高计算效率。

  • 池化抽象并聚合了多媒体数据中的不必要细节,使其更加通用。反过来,这增强了模型表示相似问题的能力。

下采样过程如下:

Diagram  Description automatically generated

图 8.16:下采样

在下采样过程中,我们本质上是将一组像素压缩成一个代表性像素。例如,我们可以将一个 2x2 像素块压缩成一个像素,从而将原始数据的分辨率减少四倍。

新像素的代表值可以通过多种方式选择。其中一种方法是“最大池化”,在这种方法中,我们从原始像素块中选择最大值来表示新的单一像素。

另一方面,如果我们选择取像素块值的平均值,这个过程将被称为“平均池化”。

最大池化与平均池化的选择通常取决于具体任务。最大池化在我们希望保留图像中最显著特征时特别有用,因为它保留了一个块中的最大像素值,从而捕捉到该部分中最突出或最显眼的特征。

相比之下,平均池化在我们希望保留整体背景并减少噪声时通常更有用,因为它考虑了块内的所有值并计算它们的平均值,从而创建一个更平衡的表示,可能对像素值中的细微变化或噪声不太敏感。

生成对抗网络

生成对抗网络,通常称为 GAN,是一种能够生成合成数据的神经网络类别。它由 Ian Goodfellow 及其团队于 2014 年首次提出,因其创新的方法而受到赞誉,能够创建与原始训练样本相似的新数据。

生成对抗网络(GAN)一个显著的应用是能够生成现实中不存在的人的逼真图像,展示了它们在细节生成上的非凡能力。然而,更为关键的应用在于它们能够生成合成数据,从而扩充现有的训练数据集,在数据可用性有限的情况下,这种应用非常有价值。

尽管生成对抗网络(GAN)具有潜力,但它们并非没有局限性。GAN 的训练过程可能相当具有挑战性,常常导致一些问题,如模式崩溃,生成器开始产生有限种类的样本。此外,生成数据的质量在很大程度上取决于输入数据的质量和多样性。数据不具代表性或有偏差时,可能导致合成数据的效果不佳,甚至可能偏向某些特定方向。

在接下来的章节中,我们将探讨什么是迁移学习。

使用迁移学习

多年来,无数组织、研究机构和开源社区的贡献者们精心构建了适用于一般用途的复杂模型。这些模型通常通过大量数据进行训练,经过多年的努力优化,适用于各种应用场景,如:

  • 检测视频或图像中的物体

  • 转录音频

  • 分析文本情感

在启动新的机器学习模型训练时,不妨考虑一个问题:与其从零开始,是否可以修改已有的预训练模型来满足我们的需求。简而言之,我们能否利用现有模型的学习成果,定制一个适应我们特定需求的模型?这种方法被称为迁移学习,具有以下几项优势:

  • 它为我们的模型训练提供了一个良好的起点。

  • 它通过利用一个经过预验证和可靠的模型,可能提升我们模型的质量。

  • 在我们的问题缺乏足够数据的情况下,使用预训练模型进行迁移学习可以提供极大的帮助。

考虑以下实际例子,在这些场景下,迁移学习将大有裨益:

  • 在训练机器人时,可以先通过一个模拟游戏来训练神经网络模型。在这个受控环境中,我们可以创建一些在现实世界中难以复制的稀有事件。训练完成后,可以应用迁移学习来使模型适应现实世界的场景。

  • 假设我们旨在构建一个模型,用于区分视频流中的苹果和 Windows 笔记本电脑。现有的开源物体检测模型以其在视频流中对不同物体分类的高准确率而著称,这些模型可以作为理想的起点。通过迁移学习,我们可以首先利用这些模型识别物体为笔记本电脑。接着,我们可以进一步优化我们的模型,区分苹果和 Windows 笔记本电脑。

在下一节中,我们将实现本章讨论的原则,创建一个用于分类欺诈文档的神经网络。

作为一个视觉示例,考虑一个预训练的模型作为一棵成熟的大树,树上有许多枝条(层)。一些枝条上已经挂满了果实(训练好以识别特征)。在应用迁移学习时,我们“冻结”这些结实的枝条,保留它们已建立的学习成果。然后,我们允许新的枝条生长并结出果实,这类似于训练额外的层来理解我们的特定特征。冻结某些层并训练其他层的过程概括了迁移学习的本质。

案例研究 – 使用深度学习进行欺诈检测

使用机器学习技术识别欺诈文档是一个活跃且具有挑战性的研究领域。研究人员正在探索神经网络的模式识别能力在多大程度上可以用于此目的。与手动属性提取器不同,可以使用原始像素来构建多种深度学习架构结构。

方法论

本节介绍的技术使用了一种称为Siamese 神经网络的神经网络架构,该架构具有两个共享相同架构和参数的分支。

使用 Siamese 神经网络标记欺诈文档的示意图如下所示:

图示描述自动生成

图 8.17:Siamese 神经网络

当需要验证某一文档的真实性时,我们首先根据其布局和类型对文档进行分类,然后将其与预期的模板和模式进行比较。如果偏离超过某个阈值,则标记为伪造文档;否则,认为它是一个真实文档。对于关键的使用场景,我们可以为边界情况添加人工处理过程,在这些情况下,算法明确将文档分类为真实或伪造。

为了将文档与预期模板进行比较,我们在我们的 Siamese 架构中使用两个相同的 CNN。CNN 具有学习最佳平移不变局部特征检测器的优势,并且能够构建对输入图像几何畸变具有鲁棒性的表示。这非常适合我们的问题,因为我们的目标是通过单个网络传递真实文档和测试文档,然后比较它们的输出以确定相似性。为了实现这一目标,我们执行以下步骤。

假设我们要测试一个文档。对于每种文档类别,我们执行以下步骤:

  1. 获取存储的真实文档图像。我们称其为真实文档。测试文档应该与真实文档相似。

  2. 真实文档通过神经网络层传递,创建一个特征向量,这是该文档模式的数学表示。我们称其为特征向量 1,如前图所示。

  3. 需要测试的文档称为 测试文档。我们将该文档传递通过一个与用于创建真实文档特征向量的神经网络类似的神经网络。测试文档的特征向量称为 特征向量 2

  4. 我们使用 特征向量 1特征向量 2 之间的欧氏距离来计算真实文档与测试文档之间的相似度得分。这个相似度得分被称为 相似度度量MOS)。MOS 是一个介于 0 和 1 之间的数字。数字越大,表示文档之间的距离越小,文档相似的可能性越大。

  5. 如果神经网络计算出的相似度得分低于预定义的阈值,我们将标记该文档为欺诈文档。

让我们看看如何使用 Python 实现双胞胎神经网络。

为了说明如何使用 Python 实现双胞胎神经网络,我们将把这个过程拆解成更简单、易管理的块。这种方法将帮助我们遵循 PEP8 风格指南,保持代码的可读性和可维护性:

  1. 首先,让我们导入所需的 Python 包:

    import random
    import numpy as np
    import tensorflow as tf 
    
  2. 接下来,我们定义将处理双胞胎网络每个分支的网络模型。注意,我们已将丢弃率设置为 0.15,以减少过拟合:

    def createTemplate():
        return tf.keras.models.Sequential([
            tf.keras.layers.Flatten(),
            tf.keras.layers.Dense(128, activation='relu'),
            tf.keras.layers.Dropout(0.15),
            tf.keras.layers.Dense(128, activation='relu'),
            tf.keras.layers.Dropout(0.15),
            tf.keras.layers.Dense(64, activation='relu'), 
        ]) 
    
  3. 对于我们的双胞胎网络,我们将使用 MNIST 图像。这些图像非常适合测试我们的双胞胎网络的有效性。我们准备数据,使得每个样本将包含两张图像和一个二元相似度标志,指示它们是否属于同一类别:

    def prepareData(inputs: np.ndarray, labels: np.ndarray):
        classesNumbers = 10
        digitalIdx = [np.where(labels == i)[0] for i in range(classesNumbers)] 
    
  4. prepareData 函数中,我们确保所有数字的样本数量相等。我们首先使用 np.where 函数创建一个索引,表示每个数字在数据集中出现的位置。

    然后,我们准备图像对并分配标签:

     pairs = list()
        labels = list()
        n = min([len(digitalIdx[d]) for d in range(classesNumbers)]) - 1
        for d in range(classesNumbers):
            for i in range(n):
                z1, z2 = digitalIdx[d][i], digitalIdx[d][i + 1]
                pairs += [[inputs[z1], inputs[z2]]]
                inc = random.randrange(1, classesNumbers)
                dn = (d + inc) % classesNumbers
                z1, z2 = digitalIdx[d][i], digitalIdx[dn][i]
                pairs += [[inputs[z1], inputs[z2]]]
                labels += [1, 0] 
        return np.array(pairs), np.array(labels, dtype=np.float32) 
    
  5. 随后,我们将准备训练和测试数据集:

    input_a = tf.keras.layers.Input(shape=input_shape)
    encoder1 = base_network(input_a)
    input_b = tf.keras.layers.Input(shape=input_shape)
    encoder2 = base_network(input_b) 
    
  6. 最后,我们将实现 MOS,它量化了我们想要比较的两个文档之间的距离:

    distance = tf.keras.layers.Lambda( 
        lambda embeddings: tf.keras.backend.abs(
            embeddings[0] - embeddings[1]
        )
    ) ([encoder1, encoder2])
    measureOfSimilarity = tf.keras.layers.Dense(1, activation='sigmoid') (distance) 
    

现在,让我们训练模型。我们将使用 10 个 epoch 来训练该模型:

# Build the model
model = tf.keras.models.Model([input_a, input_b], measureOfSimilarity)
# Train
model.compile(loss='binary_crossentropy',optimizer=tf.keras.optimizers.Adam(),metrics=['accuracy'])
model.fit([train_pairs[:, 0], train_pairs[:, 1]], tr_labels, 
          batch_size=128,epochs=10,validation_data=([test_pairs[:, 0], test_pairs[:, 1]], test_labels)) 
Epoch 1/10
847/847 [==============================] - 6s 7ms/step - loss: 0.3459 - accuracy: 0.8500 - val_loss: 0.2652 - val_accuracy: 0.9105
Epoch 2/10
847/847 [==============================] - 6s 7ms/step - loss: 0.1773 - accuracy: 0.9337 - val_loss: 0.1685 - val_accuracy: 0.9508
Epoch 3/10
847/847 [==============================] - 6s 7ms/step - loss: 0.1215 - accuracy: 0.9563 - val_loss: 0.1301 - val_accuracy: 0.9610
Epoch 4/10
847/847 [==============================] - 6s 7ms/step - loss: 0.0956 - accuracy: 0.9665 - val_loss: 0.1087 - val_accuracy: 0.9685
Epoch 5/10
847/847 [==============================] - 6s 7ms/step - loss: 0.0790 - accuracy: 0.9724 - val_loss: 0.1104 - val_accuracy: 0.9669
Epoch 6/10
847/847 [==============================] - 6s 7ms/step - loss: 0.0649 - accuracy: 0.9770 - val_loss: 0.0949 - val_accuracy: 0.9715
Epoch 7/10
847/847 [==============================] - 6s 7ms/step - loss: 0.0568 - accuracy: 0.9803 - val_loss: 0.0895 - val_accuracy: 0.9722
Epoch 8/10
847/847 [==============================] - 6s 7ms/step - loss: 0.0513 - accuracy: 0.9823 - val_loss: 0.0807 - val_accuracy: 0.9770
Epoch 9/10
847/847 [==============================] - 6s 7ms/step - loss: 0.0439 - accuracy: 0.9847 - val_loss: 0.0916 - val_accuracy: 0.9737
Epoch 10/10
847/847 [==============================] - 6s 7ms/step - loss: 0.0417 - accuracy: 0.9853 - val_loss: 0.0835 - val_accuracy: 0.9749
<tensorflow.python.keras.callbacks.History at 0x7ff1218297b8> 

注意,使用 10 个 epoch 我们达到了 97.49% 的准确率。增加 epoch 数量将进一步提高准确度。

摘要

在本章中,我们探讨了神经网络的演变,研究了不同类型、关键组件如激活函数,以及重要的梯度下降算法。我们还提到了迁移学习的概念及其在识别欺诈文档中的实际应用。

随着我们进入下一章,我们将深入探讨自然语言处理,探索诸如词嵌入和递归网络等领域。我们还将学习如何实现情感分析。神经网络的迷人世界仍在展开。

在 Discord 上了解更多

要加入本书的 Discord 社区——在这里你可以分享反馈,向作者提问,并了解新版本的发布——请扫描下方二维码:

packt.link/WHLel

第九章:自然语言处理的算法

语言是思维最重要的工具。

—马文·明斯基

本章介绍了自然语言处理NLP)的算法。首先介绍了 NLP 的基础知识。然后介绍了为 NLP 任务准备数据。接下来,解释了文本数据向量化的概念。然后,我们讨论了词嵌入。最后,展示了一个详细的用例。

本章由以下几部分组成:

  • 介绍 NLP

  • 基于词袋模型BoW-based)的 NLP

  • 词嵌入介绍

  • 案例研究:餐厅评论情感分析

到本章结束时,你将理解用于自然语言处理(NLP)的基本技术。你还应该理解 NLP 如何用于解决一些有趣的现实世界问题。

让我们从基本概念开始。

介绍 NLP

NLP 是机器学习算法的一个分支,处理计算机与人类语言之间的互动。它涉及分析、处理和理解人类语言,以使计算机能够理解并回应人类的沟通。NLP 是一个综合性的学科,涉及使用计算机语言学算法以及人机交互技术和方法来处理复杂的非结构化数据。

NLP 通过处理人类语言并将其分解为基本部分,如单词、短语和句子,来工作。目标是使计算机理解文本的含义并做出适当回应。NLP 算法利用各种技术,如统计模型、机器学习和深度学习,来分析和处理大量的自然语言数据。对于复杂问题,我们可能需要使用多种技术的组合来找到有效的解决方案。

NLP 的一个重大挑战是处理人类语言的复杂性和歧义性。语言种类繁多,具有复杂的语法结构和习惯用语。此外,单词和短语的含义可能根据使用的上下文而有所不同。NLP 算法必须能够处理这些复杂性,以实现有效的语言处理。

让我们从一些在讨论 NLP 时使用的术语开始。

理解 NLP 术语

NLP 是一个广泛的研究领域。在这一部分,我们将探讨一些与 NLP 相关的基本术语:

  • 语料库:语料库是一个大型且结构化的文本或语音数据集合,作为 NLP 算法的资源。它可以由各种类型的文本数据组成,如书面文本、口语语言、转录对话和社交媒体帖子。语料库通过有意地从各种在线和离线来源收集和组织数据来创建,包括互联网。虽然互联网是获取数据的丰富来源,但决定将哪些数据包含在语料库中,需要根据特定研究或分析的目标进行有目的的选择和对齐。

    语料库(corpora)是语料(corpus)的复数形式,可以进行注释,意味着它们可能包含关于文本的额外细节,例如词性标签和命名实体。这些注释语料库提供了特定的信息,能够增强 NLP 算法的训练和评估,使它们在该领域成为极具价值的资源。

  • 标准化:这个过程涉及将文本转换为标准形式,例如将所有字符转换为小写字母或去除标点符号,使其更容易进行分析。

  • 分词:分词将文本拆分成更小的部分,称为词元,通常是单词或子词,从而实现更结构化的分析。

  • 命名实体识别NER):NER 用于识别和分类文本中的命名实体,例如人名、地点、组织等。

  • 停用词:这些是常用词,例如 andtheis,它们在文本处理过程中通常会被过滤掉,因为它们可能不会提供显著的意义。

  • 词干提取和词形还原:词干提取是将单词还原为其词根形式,而词形还原是将单词转换为其基本或词典形式。这两种技术有助于分析单词的核心含义。

接下来,让我们研究 NLP 中使用的不同文本预处理技术:

  • 词嵌入:这是一种将单词转换为数值形式的方法,其中每个单词都表示为一个向量,位于一个可能具有多个维度的空间中。在这个背景下,“高维向量”指的是一个数字数组,其中维度数量或单独的成分是相当大的——通常在数百甚至数千维之间。使用高维向量的思想是为了捕捉单词之间复杂的关系,使得具有相似含义的单词在这个多维空间中更接近。向量维度越多,它能够捕捉的关系就越细致。因此,在词嵌入中,语义相关的单词会在这个高维空间中彼此更接近,从而使得算法能够更容易地理解和处理语言,反映出人类的理解方式。

  • 语言建模:语言建模是开发统计模型的过程,这些模型可以根据给定文本语料库中发现的模式和结构,预测或生成单词或字符的序列。

  • 机器翻译:使用自然语言处理(NLP)技术和模型自动将文本从一种语言翻译成另一种语言的过程。

  • 情感分析:通过分析文本中使用的单词、短语及其上下文来确定一段文本中表达的态度或情感的过程。

NLP 中的文本预处理

文本预处理是 NLP 中的一个关键阶段,在这一阶段,原始文本数据会经过转换,变得适合机器学习算法。这个转化过程涉及将无序且通常杂乱无章的文本转化为所谓的“结构化格式”。结构化格式意味着数据被组织成更加系统和可预测的模式,通常涉及分词、词干提取和删除不需要的字符等技术。这些步骤有助于清理文本,减少无关信息或“噪音”,并以一种更便于机器学习模型理解的方式整理数据。通过这种方法,原始文本中的不一致性和不规则性得以转化,形成一种能够提高后续 NLP 任务准确性、性能和效率的形式。在本节中,我们将探索用于文本预处理的各种技术,以实现这种结构化格式。

分词

提醒一下,分词是将文本分解成更小单位(即令牌)的关键过程。这些令牌可以是单个词语,甚至是子词。在 NLP 中,分词通常被视为准备文本数据进行进一步分析的第一步。分词之所以如此重要,源于语言本身的特性,理解和处理文本需要将其分解为可管理的部分。通过将连续的文本流转化为单独的令牌,我们创造了一种结构化格式,类似于人类自然阅读和理解语言的方式。这种结构化使得机器学习模型能够以清晰且系统化的方式分析文本,从而识别数据中的模式和关系。随着我们深入研究 NLP 技术,这种令牌化的格式成为许多其他预处理和分析步骤的基础。

is tokenizing the given text using the Natural Language Toolkit (nltk) library in Python. The nltk is a widely used library in Python, specifically designed for working with human language data. It provides easy-to-use interfaces and tools for tasks such as classification, tokenization, stemming, tagging, parsing, and more, making it a valuable asset for NLP. For those who wish to leverage these capabilities in their Python projects, the nltk library can be downloaded and installed directly from the Python Package Index (PyPI) by using the command pip install nltk. By incorporating the nltk library into your code, you can access a rich set of functions and resources that streamline the development and execution of various NLP tasks, making it a popular choice among researchers, educators, and developers in the field of computational linguistics. Let us start by importing relevant functions and using them:
from nltk.tokenize import word_tokenize
corpus = 'This is a book about algorithms.'
tokens = word_tokenize(corpus)
print(tokens) 

输出将是如下所示的列表:

['This', 'is', 'a', 'book', 'about', 'algorithms', '.'] 

在这个示例中,每个令牌都是一个单词。最终令牌的粒度将根据目标而有所不同——例如,每个令牌可以是一个单词、一句话或一段话。

要基于句子对文本进行分词,可以使用nltk.tokenize模块中的sent_tokenize函数:

from nltk.tokenize import sent_tokenize
corpus = 'This is a book about algorithms. It covers various topics in depth.' 

在这个例子中,corpus变量包含了两个句子。sent_tokenize函数将语料库作为输入,并返回一个句子的列表。当你运行修改后的代码时,将得到以下输出:

sentences = sent_tokenize(corpus)
print(sentences) 
['This is a book about algorithms.', 'It covers various topics in depth.'] 

有时我们可能需要将较大的文本拆分为按段落划分的块。nltk可以帮助完成这项任务。这项功能在文档摘要等应用中尤其有用,因为在这些应用中,理解段落级别的结构可能至关重要。将文本按段落进行分词看似简单,但根据文本的结构和格式,可能会变得复杂。一个简单的方法是通过两个换行符来拆分文本,这通常用于纯文本文档中的段落分隔。

这里是一个基本示例:

def tokenize_paragraphs(text):
    # Split by two newline characters
    paragraphs = text.split('\n\n') 
    return [p.strip() for p in paragraphs if p] 

接下来,让我们看看如何清理数据。

清理数据

清理数据是 NLP 中的一个关键步骤,因为原始文本数据通常包含噪音和无关信息,这些信息可能会妨碍 NLP 模型的性能。清理数据的目标是对文本数据进行预处理,去除噪音和无关信息,并将其转换为适合使用 NLP 技术分析的格式。请注意,数据清理是在数据被分词之后进行的。原因在于,清理可能涉及到依赖于分词揭示的结构的操作。例如,删除特定单词或修改单词形式可能在文本被分词为独立的词汇后更为准确。

让我们研究一些用于清理数据并为机器学习任务做准备的技术:

大小写转换

大小写转换是自然语言处理(NLP)中的一种技术,它将文本从一种大小写格式转换为另一种格式,例如从大写转换为小写,或者从标题式大小写转换为大写。

例如,标题式大小写的“Natural Language Processing”可以转换为小写,即“natural language processing”。

这一简单而有效的步骤有助于标准化文本,从而简化其在各种 NLP 算法中的处理。通过确保文本处于统一的大小写格式,有助于消除由于大小写变化可能产生的不一致性。

标点符号移除

在 NLP 中,标点符号移除是指在分析之前,从原始文本数据中删除标点符号的过程。标点符号是如句号(.)、逗号(,)、问号(?)和感叹号(!)等符号,它们在书面语言中用于表示停顿、强调或语调。虽然它们在书面语言中至关重要,但它们会为原始文本数据增加噪音和复杂性,进而妨碍 NLP 模型的性能。

合理的疑虑是,删除标点符号可能会影响句子的意义。请考虑以下示例:

"她是只猫。"

"她是只猫??"

没有标点符号时,两行文本都变成了“她是只猫”,可能失去了问号所传达的独特强调。

然而,值得注意的是,在许多 NLP 任务中,如主题分类或情感分析,标点符号可能不会显著影响整体理解。此外,模型可以依赖于文本结构、内容或上下文中的其他线索来推导含义。在标点符号细微差别至关重要的情况下,可能需要使用专门的模型和预处理技术来保留所需的信息。

处理数字在 NLP 中的应用

文本数据中的数字可能给 NLP 带来挑战。下面是两种处理文本中数字的主要策略,既考虑了传统的去除方法,也考虑了标准化的替代选项。

在某些自然语言处理(NLP)任务中,数字可能被视为噪声,特别是当关注点集中在像词频或情感分析等方面时。这就是为什么一些分析师可能选择去除数字的原因:

  • 缺乏相关性:在某些文本分析情境中,数字字符可能不携带重要的含义。

  • 扭曲词频统计:数字可能会扭曲词频统计,尤其是在像主题建模这样的模型中。

  • 减少复杂性:去除数字可以简化文本数据,可能提升 NLP 模型的性能。

然而,一种替代方法是将所有数字转换为标准表示,而不是将其丢弃。这种方法承认数字可以携带重要信息,并确保其在一致格式中保留其值。在数字数据对文本含义起着至关重要作用的语境中,这种方法特别有用。

决定是否去除或保留数字需要理解所解决的问题。一个算法可能需要定制,以根据文本的上下文和特定的 NLP 任务来区分数字是否重要。分析数字在文本领域中的作用以及分析的目标,可以引导这一决策过程。

在 NLP 中处理数字并不是一成不变的方法。是否去除、标准化或仔细分析数字,取决于任务的独特要求。理解这些选项及其影响,有助于做出符合文本分析目标的明智决策。

去除空格

在 NLP 中,空格去除指的是去除不必要的空格字符,如多个空格和制表符字符。在文本数据的语境中,空格不仅是单词之间的空白,还包括其他“看不见”的字符,这些字符在文本中创建了间距。在 NLP 中,空格去除指的是去除这些不必要的空格字符。去除不必要的空格可以减少文本数据的大小,并使其更易于处理和分析。

下面是一个简单的例子来说明空格去除:

  • 输入文本:"The quick brown fox \tjumps over the lazy dog."

  • 处理过的文本:"The quick brown fox jumps over the lazy dog."

在上述示例中,去除了额外的空格和一个制表符字符(由 \t 表示),从而创建了更干净且更标准化的文本字符串。

停用词去除

停用词去除是指从文本语料库中删除常见词汇,称为停用词。停用词是在语言中频繁出现,但不具有重要意义或不有助于理解文本的词汇。英语中的停用词包括 the,, is, infor。停用词去除有助于减少数据的维度,并提高算法的效率。通过去除那些对分析没有重要贡献的词汇,可以将计算资源集中在真正重要的词汇上,从而提高各种自然语言处理算法的效率。

请注意,停用词去除不仅仅是减少文本大小;它是为了专注于分析中真正重要的词汇。虽然停用词在语言结构中扮演着重要角色,但在自然语言处理中的去除,可以提升分析的效率和重点,特别是在情感分析等任务中,主要关心的是理解潜在的情感或观点。

词干提取和词形还原

在文本数据中,大多数单词可能以略微不同的形式出现。将每个单词简化为它的原型或词干,这一过程称为词干提取。它用于根据单词的相似含义将单词分组,以减少需要分析的单词总数。本质上,词干提取减少了问题的整体条件性。英语中最常用的词干提取算法是 Porter 算法。

例如,让我们看一些例子:

  • 示例 1:{use, used, using, uses} => use

  • 示例 2:{easily, easier, easiest} => easi

需要注意的是,词干提取有时会导致拼写错误,如示例 2 中生成的 easi

词干提取是一种简单且快速的处理过程,但它可能并不总是产生正确的结果。在需要正确拼写的情况下,词形还原是一种更合适的方法。词形还原考虑上下文并将单词还原为其基本形式。单词的基本形式,也称为词根,是其最简单且最具意义的版本。它代表了单词在字典中的形式,去除了任何词尾变化,形成一个正确的英语单词,从而产生更准确、更有意义的词根。

引导算法识别相似性是一个精确且深思熟虑的任务。与人类不同,算法需要明确的规则和标准来建立连接,这些连接对我们来说可能看起来是显而易见的。理解这一差异并知道如何提供必要的引导,是开发和调整算法在各种应用中的重要技能。

使用 Python 清理数据

让我们看一下如何使用 Python 清理文本。

首先,我们需要导入必要的库:

import string
import re
import nltk
from nltk.corpus import stopwords
from nltk.stem import PorterStemmer
# Make sure to download the NLTK resources
nltk.download('punkt')
nltk.download('stopwords') 

接下来,这是执行文本清理的主函数:

def clean_text(text):
    """
    Cleans input text by converting case, removing punctuation, numbers,
    white spaces, stop words and stemming
    """
    # Convert to lowercase
    text = text.lower()

    # Remove punctuation
    text = text.translate(str.maketrans('', '', string.punctuation))

    # Remove numbers
    text = re.sub(r'\d+', '', text)

    # Remove white spaces
    text = text.strip()

    # Remove stop words
    stop_words = set(stopwords.words('english'))
    tokens = nltk.word_tokenize(text)
    filtered_text = [word for word in tokens if word not in stop_words]
    text = ' '.join(filtered_text)

    # Stemming
    ps = PorterStemmer()
    tokens = nltk.word_tokenize(text)
    stemmed_text = [ps.stem(word) for word in tokens]
    text = ' '.join(stemmed_text)

    return text 

让我们测试一下clean_text()函数:

corpus="7- Today, Ottawa is becoming cold again "
clean_text(corpus) 

结果将是:

today ottawa becom cold 

注意输出中的单词becom。由于我们使用了词干提取技术,输出中的并非所有单词都是正确的英语单词。

所有前面的处理步骤通常是必需的;实际的处理步骤取决于我们要解决的问题。它们会因使用场景而异——例如,如果文本中的数字表示一些在我们尝试解决的问题背景下可能有意义的内容,那么在标准化阶段我们可能不需要去除这些数字。

一旦数据被清理,我们需要将结果存储在一个专门为此目的设计的数据结构中。这个数据结构被称为术语文档矩阵TDM),接下来会详细解释。

理解术语文档矩阵

TDM(术语文档矩阵)是自然语言处理(NLP)中使用的数学结构。它是一个表格,用于统计文档集合中术语(单词)的频率。每一行表示一个独特的术语,每一列表示一个特定的文档。它是文本分析中的一个重要工具,可以让你看到每个单词在不同文本中出现的频率。

对于包含单词catdog的文档:

  • 文档 1:cat cat dog

  • 文档 2:dog dog cat

Document 1 Document 2
cat 2 1
dog 1 2

这种矩阵结构允许高效地存储、组织和分析大型文本数据集。在 Python 中,可以使用sklearn库中的CountVectorizer模块来创建一个 TDM,方法如下:

from sklearn.feature_extraction.text import CountVectorizer
# Define a list of documents
documents = ["Machine Learning is useful", "Machine Learning is fun", "Machine Learning is AI"]
# Create an instance of CountVectorizer
vectorizer = CountVectorizer()
# Fit and transform the documents into a TDM
tdm = vectorizer.fit_transform(documents)
# Print the TDM
print(tdm.toarray()) 

输出如下:

[[0 0 1 1 1 1]
 [0 1 1 1 1 0]
 [1 0 1 1 1 0]] 

请注意,每个文档对应一行,每个不同的单词对应一列。这里有三个文档和六个不同的单词,结果是一个 3x6 的矩阵。

在这个矩阵中,数字表示每个单词(列)在对应文档(行)中出现的频率。例如,如果第一行第一列的数字是 1,这意味着第一个单词在第一个文档中出现了一次。

默认情况下,TDM 使用每个术语的频率,这是量化每个单词在每个文档中的重要性的一种简单方法。更精细的量化方法是使用 TF-IDF,这将在下一节中解释。

使用 TF-IDF

词频-逆文档频率TF-IDF)是一种用于计算单词在文档中重要性的方法。它考虑了两个主要成分来确定每个术语的权重:词频TF)和逆文档频率IDF)。TF 关注一个词在特定文档中出现的频率,而 IDF 则检查这个词在整个文档集合(即语料库)中的稀有程度。在 TF-IDF 的上下文中,语料库指的是你正在分析的所有文档。如果我们正在处理一组书评,举例来说,语料库将包括所有的书评:

  • TF:TF 衡量一个术语在文档中出现的次数。它的计算方法是术语在文档中出现的次数与文档中术语总数的比值。术语出现得越频繁,TF 值就越高。

  • IDF:IDF 衡量一个术语在整个文档集合中的重要性。它的计算方法是语料库中总文档数与包含该术语的文档数之比的对数。术语在语料库中越稀有,它的 IDF 值就越高。

要使用 Python 计算 TF-IDF,请执行以下操作:

from sklearn.feature_extraction.text import TfidfVectorizer
# Define a list of documents
documents = ["Machine Learning enables learning", "Machine Learning is fun", "Machine Learning is useful"]
# Create an instance of TfidfVectorizer
vectorizer = TfidfVectorizer()
# Fit and transform the documents into a TF-IDF matrix
tfidf_matrix = vectorizer.fit_transform(documents)
# Get the feature names
feature_names = vectorizer.get_feature_names_out()
# Loop over the feature names and print the TF-IDF score for each term
for i, term in enumerate(feature_names):
    tfidf = tfidf_matrix[:, i].toarray().flatten()
    print(f"{term}: {tfidf}") 

这将输出:

enables:   [0.60366655 0\.         0\.        ]
fun:       [0\.         0.66283998 0\.        ]
is:        [0\.         0.50410689 0.50410689]
learning:  [0.71307037 0.39148397 0.39148397]
machine:   [0.35653519 0.39148397 0.39148397]
useful:    [0\.         0\.         0.66283998] 

输出中的每一列对应一个文档,行表示各文档中术语的 TF-IDF 值。例如,术语kids只有在第二个文档中才有非零的 TF-IDF 值,这与我们的预期一致。

结果总结与讨论

TF-IDF 方法提供了一种有价值的方式来衡量术语在单个文档内以及在整个语料库中的重要性。计算得到的 TF-IDF 值揭示了每个术语在每个文档中的相关性,同时考虑了它们在给定文档中的频率以及在整个语料库中的稀有性。在提供的示例中,不同术语的 TF-IDF 值变化表明该模型能够区分那些在特定文档中独有的词汇和那些使用频率较高的词汇。这种能力可以在多个应用中加以利用,如文本分类、信息检索和特征选择,提升对文本数据的理解和处理。

词嵌入简介

NLP 的一项重大进展是我们能够创建单词的有意义的数字表示形式,采用稠密向量的形式。这种技术称为词嵌入。那么,稠密向量到底是什么呢?假设你有一个单词 apple(苹果)。在词嵌入中,apple 可能被表示为一系列数字,例如 [0.5, 0.8, 0.2],其中每个数字都是连续的、多维空间中的一个坐标。“稠密”意味着这些数字大多数或全部都不为零,不像稀疏向量那样许多元素为零。简单来说,词嵌入将文本中的每个单词转化为一个独特的、多维的空间点。这样,含义相似的单词将最终在这个空间中彼此接近,从而使算法能够理解单词之间的关系。Yoshua Bengio 在他的论文 A Neural Probabilistic Language Model 中首次提出了这个术语。NLP 问题中的每个单词可以被视为一个类别对象。

在词嵌入中,尝试建立每个单词的邻域,并利用它来量化单词的意义和重要性。一个单词的邻域是指围绕特定单词的一组单词。

为了真正理解词嵌入的概念,我们来看一个涉及四个常见水果词汇的具体例子:apple(苹果)、banana(香蕉)、orange(橙子)和pear(梨)。这里的目标是将这些单词表示为稠密向量,这些向量是数字数组,其中每个数字捕捉单词的特定特征或特性。

为什么要以这种方式表示单词呢?在自然语言处理(NLP)中,将单词转换为稠密向量可以使算法量化不同单词之间的关系。本质上,我们是在将抽象的语言转化为可以用数学方法衡量的内容。

考虑我们水果单词的甜度、酸度和多汁度特征。我们可以对每个水果的这些特征进行从 0 到 1 的评分,0 表示该特征完全缺失,1 表示该特征非常明显。评分可能是这样的:

"apple": [0.5, 0.8, 0.2] – moderately sweet, quite acidic, not very juicy
"banana": [0.2, 0.3, 0.1] – not very sweet, moderately acidic, not juicy
"orange": [0.9, 0.6, 0.9] – very sweet, somewhat acidic, very juicy
"pear": [0.4, 0.1, 0.7] – moderately sweet, barely acidic, quite juicy 

这些数字是主观的,可以通过味觉测试、专家意见或其他方法得出,但它们的作用是将单词转化为算法可以理解并使用的格式。

通过可视化,你可以想象一个三维空间,其中每个坐标轴代表一个特征(甜度、酸度或多汁度),每个水果的向量将其放置在这个空间中的特定位置。具有相似口味的单词(水果)会在这个空间中彼此更接近。

那么,为什么选择长度为 3 的稠密向量呢?这是基于我们选择的特征来表示的。在其他应用中,向量的长度可能不同,取决于你希望捕捉的特征数量。

这个例子展示了词嵌入是如何将一个单词转化为一个持有实际意义的数字向量的。这是让机器“理解”并处理人类语言的关键步骤。

使用 Word2Vec 实现词嵌入

Word2Vec 是一种用于获取单词向量表示的突出方法,通常称为单词嵌入。该算法并不是“生成单词”,而是创建代表每个单词语义的数值向量。

Word2Vec 的基本思想是利用神经网络来预测给定文本语料库中每个单词的上下文。神经网络通过输入单词及其周围的上下文单词进行训练,网络学习输出给定输入单词的上下文单词的概率分布。神经网络的权重随后被用作单词嵌入,这些嵌入可以用于各种自然语言处理任务:

import gensim
# Define a text corpus
corpus = [['apple', 'banana', 'orange', 'pear'],
          ['car', 'bus', 'train', 'plane'],
          ['dog', 'cat', 'fox', 'fish']]
# Train a word2vec model on the corpus
model = gensim.models.Word2Vec(corpus, window=5, min_count=1, workers=4) 

让我们分解一下Word2Vec()函数的重要参数:

  • sentences:这是模型的输入数据。它应该是一个句子的集合,每个句子是一个单词列表。实际上,它是一个单词列表的列表,代表了你的整个文本语料库。

  • size:定义了单词嵌入的维度。换句话说,它设置了表示单词的向量中的特征或数值的数量。一个典型的值可能是100300,具体取决于词汇的复杂性。

  • window:该参数设置目标单词与句子中用于预测的上下文单词之间的最大距离。例如,如果将窗口大小设置为5,算法将在训练过程中考虑目标单词前后五个立即相邻的单词。

  • min_count:通过设置此参数,可以排除在语料库中出现频率较低的单词。例如,如果将min_count设置为2,那么在所有句子中出现次数少于两次的单词将在训练过程中被忽略。

  • workers:指的是训练过程中使用的处理线程数量。增加该值可以通过启用并行处理来加速在多核机器上的训练。

一旦 Word2Vec 模型训练完成,使用它的强大方法之一是测量嵌入空间中单词之间的相似性或“距离”。这个相似性得分可以让我们洞察模型如何看待不同单词之间的关系。现在,让我们通过查看cartrain之间的距离来检查模型:

print(model.wv.similarity('car', 'train')) 
-0.057745814 

现在让我们来看一下carapple的相似度:

print(model.wv.similarity('car', 'apple')) 
0.11117952 

因此,输出给我们的是基于模型学习到的单词嵌入之间的相似性得分。

解释相似性得分

以下细节有助于解释相似性得分:

  • 非常相似:接近 1 的得分表示强烈的相似性。具有此得分的单词通常共享上下文或语义意义。

  • 适度相似:接近 0.5 的得分表示某种程度的相似性,可能是由于共享的属性或主题。

  • 相似度弱或没有相似性:接近 0 或负数的得分表示意义之间几乎没有相似性,甚至存在对比。

因此,这些相似度分数提供了关于单词关系的定量见解。通过理解这些分数,你可以更好地分析文本语料库的语义结构,并将其用于各种 NLP 任务。

Word2Vec 提供了一种强大且高效的方式来表示文本数据,能够捕捉单词之间的语义关系、减少维度并提高下游 NLP 任务的准确性。让我们来看看 Word2Vec 的优缺点。

Word2Vec 的优缺点

以下是使用 Word2Vec 的优点:

  • 捕捉语义关系:Word2Vec 的嵌入在向量空间中的位置使得语义相关的单词靠得很近。通过这种空间安排,捕捉了语法和语义关系,如同义词、类比等,从而在信息检索和语义分析等任务中取得更好的表现。

  • 降维:传统的单热编码(one-hot encoding)会创建一个稀疏且高维的空间,尤其是当词汇表很大时。Word2Vec 将其压缩为一个更加密集且低维的连续向量空间(通常为 100 到 300 维)。这种压缩表示保留了重要的语言模式,同时在计算上更高效。

  • 处理词汇外单词:Word2Vec 可以通过利用上下文词来推断未出现在训练语料中的单词的嵌入。这个特性有助于更好地泛化到未见过或新的文本数据,增强了模型的鲁棒性。

现在让我们来看看使用 Word2Vec 的一些缺点:

  • 训练复杂性:Word2Vec 模型的训练可能需要大量计算资源,特别是在拥有庞大词汇表和高维向量时。它们需要大量的计算资源,并可能需要优化技术,如负采样或层次化软最大(hierarchical softmax),以实现高效扩展。

  • 缺乏可解释性:Word2Vec 嵌入的连续性和密集性使得它们难以被人类理解。与精心设计的语言特征不同,Word2Vec 中的维度不对应直观的特征,这使得理解捕获了单词的哪些具体方面变得困难。

  • 对文本预处理敏感:Word2Vec 嵌入的质量和效果可能会根据应用于文本数据的预处理步骤而显著变化。诸如分词、词干提取、词形还原或去除停用词等因素必须谨慎考虑。预处理的选择可能会影响向量空间中的空间关系,从而可能影响模型在下游任务中的表现。

接下来,我们来看一个关于餐厅评论的案例研究,结合了本章介绍的所有概念。

案例研究:餐厅评论情感分析

我们将使用 Yelp 评论数据集,该数据集包含标记为正面(5 星)或负面(1 星)的评论。我们将训练一个可以将餐厅评论分类为负面或正面的模型。

让我们通过以下步骤实现这个处理管道。

导入所需的库并加载数据集

首先,我们导入所需的包:

import numpy as np
import pandas as pd
import re
from nltk.stem import PorterStemmer
from nltk.corpus import stopwords 

然后我们从一个.csv文件导入数据集:

url = 'https://storage.googleapis.com/neurals/data/2023/Restaurant_Reviews.tsv'
dataset = pd.read_csv(url, delimiter='\t', quoting=3)
dataset.head() 
 Review     Liked
0                           Wow... Loved this place.        1
1                                 Crust is not good.        0
2          Not tasty and the texture was just nasty.        0
3     Stopped by during the late May bank holiday of...     1
4      The selection on the menu was great and so wer...    1 

构建一个干净的语料库:文本数据预处理

接下来,我们通过对数据集中的每条评论进行词干提取和停用词去除等文本预处理来清洗数据:

def clean_text(text):
    text = re.sub('[^a-zA-Z]', ' ', text)
    text = text.lower()
    text = text.split()
    ps = PorterStemmer()
    text = [
        ps.stem(word) for word in text 
        if not word in set(stopwords.words('english'))]
    text = ' '.join(text)
    return text
corpus = [clean_text(review) for review in dataset['Review']] 

代码遍历数据集中的每一条评论(在这种情况下是'Review'列),并应用clean_text函数对每条评论进行预处理和清洗。代码创建了一个名为corpus的新列表。结果是一个存储在corpus变量中的已清洗和预处理过的评论列表。

将文本数据转换为数值特征

现在让我们定义特征(由y表示)和标签(由X表示)。记住,特征是描述数据特征的自变量或属性,作为预测的输入。

标签是模型被训练来预测的因变量或目标值,表示与特征对应的结果:

vectorizer = CountVectorizer(max_features=1500)
X = vectorizer.fit_transform(corpus).toarray()
y = dataset.iloc[:, 1].values 

让我们将数据分为测试数据和训练数据:

X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.20, random_state=0) 

为了训练模型,我们使用了在第七章中学习的朴素贝叶斯算法:

classifier = GaussianNB()
classifier.fit(X_train, y_train) 

让我们预测测试集的结果:

y_pred = classifier.predict(X_test) 

接下来,我们打印混淆矩阵。记住,混淆矩阵是一个帮助可视化分类模型表现的表格:

cm = confusion_matrix(y_test, y_pred)
print(cm) 
[[55 42]
 [12 91]] 

通过查看混淆矩阵,我们可以估算误分类情况。

分析结果

混淆矩阵让我们窥见了模型所做的误分类。在这个背景下,有:

  • 55 个真正的正例(正确预测的正面评论)

  • 42 个假正例(错误预测为正面的评论)

  • 12 个假负例(错误预测为负面的评论)

  • 91 个真正的负例(正确预测的负面评论)

55 个真正的正例和 91 个真正的负例表明我们的模型具有合理的能力,能够区分正面和负面评论。然而,42 个假正例和 12 个假负例突显了潜在的改进空间。

在餐厅评论的背景下,理解这些数字有助于商家和顾客评估整体情感。高比例的真正正例和真正负例表明模型能够被信任,提供准确的情感概述。这些信息对于想要提升服务的餐厅或寻求真实评论的潜在顾客来说,可能非常宝贵。另一方面,假正例和假负例的存在则表明模型可能需要调整,以避免误分类并提供更准确的洞察。

自然语言处理(NLP)的应用

自然语言处理(NLP)技术的持续进步彻底改变了我们与计算机和其他数字设备的互动方式。近年来,它在多个任务上取得了显著进展,并取得了令人印象深刻的成就,包括:

  • 主题识别:在文本库中发现主题,并根据发现的主题对库中的文档进行分类。

  • 情感分析:根据文本中的正面或负面情感对其进行分类。

  • 机器翻译:在不同语言之间进行翻译。

  • 语音转文本:将口语转换为文本。

  • 问答:这是通过使用可用信息来理解和回应查询的过程。它涉及智能地解读问题,并根据现有知识或数据提供相关的答案。

  • 实体识别:从文本中识别实体(如人、地点或事物)。

  • 假新闻检测:根据内容标记假新闻。

总结

本章讨论了与 NLP 相关的基本术语,如语料库、词向量、语言建模、机器翻译和情感分析。此外,本章还介绍了 NLP 中至关重要的各种文本预处理技术,包括分词,它将文本分解成称为标记的小单位,以及其他技术,如词干提取和去除停用词。

本章还讨论了词向量,并展示了一个餐厅评论情感分析的案例。现在,读者应当对 NLP 中使用的基本技术及其在现实世界问题中的潜在应用有了更好的理解。

在下一章,我们将讨论如何训练处理顺序数据的神经网络。我们还将探讨如何利用深度学习进一步改善自然语言处理(NLP)技术和本章讨论的方法论。

了解更多信息,请访问 Discord

要加入本书的 Discord 社区 —— 你可以在这里分享反馈、向作者提问并了解新版本 —— 请扫描下面的二维码:

packt.link/WHLel

第十章:理解顺序模型

一个序列的工作方式是一个集合无法做到的。

—George Murray

本章介绍了机器学习模型中的一个重要类别——顺序模型。此类模型的一个定义特征是,处理层以这样的方式排列:一层的输出是另一层的输入。这种架构使它们非常适合处理顺序数据。顺序数据是由有序元素组成的数据类型,例如文档中的一句话或股市价格的时间序列。

在本章中,我们将从理解顺序数据的特征开始。然后,我们将介绍 RNN 的工作原理以及如何使用它们处理顺序数据。接下来,我们将学习如何通过 GRU 解决 RNN 的局限性而不牺牲准确性。然后,我们将讨论 LSTM 的架构。最后,我们将比较不同的顺序建模架构,并推荐在何时使用哪种架构。

在本章中,我们将讨论以下概念:

  • 理解顺序数据

  • RNN 如何处理顺序数据

  • 通过 GRU 解决 RNN 的局限性

  • 理解 LSTM

让我们首先了解顺序数据的特征。

理解顺序数据

顺序数据是一种特殊的数据结构,其中元素的顺序至关重要,每个元素与其前面的元素之间存在关联依赖性。这种“顺序行为”非常独特,因为它不仅在单独的元素中传递信息,还在它们发生的模式或顺序中传递信息。在顺序数据中,当前的观察不仅受到外部因素的影响,还受到序列中之前观察值的影响。这种依赖性构成了顺序数据的核心特征。

理解不同类型的顺序数据对于理解其广泛应用至关重要。以下是主要的分类:

  • 时间序列数据:这是按时间顺序索引或列出的数据点序列。任何时间点的值都依赖于过去的值。时间序列数据广泛应用于经济学、金融和医疗等各个领域。

  • 文本数据:文本数据本质上也是顺序的,其中单词、句子或段落的顺序可以传递意义。自然语言处理NLP)利用这一顺序特性来分析和解释人类语言。

  • 时空数据:这类数据捕捉了空间和时间之间的关系,例如特定地理区域内的天气模式或交通流量随时间变化的情况。

下面是这些类型的顺序数据在现实世界场景中的表现方式:

  • 时间序列数据:这种数据类型通过金融市场趋势得到清晰体现,股票价格随着持续的市场动态不断变化。类似地,社会学研究可以分析出生率,反映出受经济条件和社会政策等因素影响的年度变化。

  • 文本数据:文本的顺序性在文学和新闻作品中至关重要。在小说、新闻文章或论文中,单词、句子和段落的特定排列构建了叙事和论证,赋予文本超越单词本身的意义。

  • 时空数据:这种数据类型在城市发展和环境研究中至关重要。例如,可以跟踪不同地区的房价随时间的变化,以识别经济趋势,而气象研究可以监测特定地理位置的天气变化,预测模式和自然事件。

这些现实世界的例子展示了不同类型数据中固有的顺序行为如何被利用以提供见解并推动各个领域的决策。

在深度学习中,处理序列数据需要像序列模型这样的专门神经网络架构。这些模型旨在捕获和利用序列数据元素之间固有的时间依赖关系。通过识别这些依赖关系,序列模型为创建更为细致和有效的机器学习模型提供了坚实的框架。

总之,序列数据是一种在各个领域中应用广泛的丰富而复杂的数据类型。认识其顺序性、理解其类型,并利用专业模型,使数据科学家能够深入洞察并构建更强大的预测工具。在我们研究技术细节之前,让我们先来看一看序列建模技术的历史。

让我们来研究不同类型的序列模型。

序列模型的类型

通过检查它们处理的数据类型(如文本信息、数值数据或基于时间的模式),以及这些数据从过程开始到结束如何演变或转换,将序列模型分类为各种类别。通过深入了解这些特征,我们可以确定三种主要类型的序列模型。

一对多

在一对多序列模型中,单一事件或输入可以启动整个序列的生成。这个独特的特性为广泛的应用领域打开了大门,但也带来了训练和实施的复杂性。一对多序列模型提供了令人兴奋的机会,但也伴随着训练和执行中的固有复杂性。随着生成性 AI 的不断进步,这些模型很可能在塑造各个领域的创造性和定制化解决方案中发挥关键作用。

发挥其潜力的关键在于理解其能力,并识别训练和实施中的复杂性。一对多序列模型如图 10.1所示:

A diagram of a flowchart  Description automatically generated

图 10.1:一对多序列模型

让我们深入探讨一对多模型的特性、能力和挑战:

  • 广泛的应用范围:将单一输入转化为有意义的序列的能力使得一对多模型既多才多艺又强大。它们可以用于写诗、创作艺术作品如绘画和图画,甚至为求职申请编写个性化的求职信。

  • 生成性 AI 的一部分:这些模型属于生成性 AI 的范畴,这是一个新兴领域,旨在创建既连贯又符合上下文的新内容。这使得它们能够执行上述提到的各种任务。

  • 强化训练过程:与其他序列模型相比,训练一对多模型通常更加耗时且计算开销更大。原因在于将单一输入转化为多种潜在输出的复杂性。模型不仅需要学习输入与输出之间的关系,还需要掌握生成序列中固有的复杂模式和结构。

请注意,与一对一模型(一个输入对应一个输出)或多对多模型(一个输入序列对应一个输出序列)不同,一对多范式必须从单一的起点中推导出丰富且结构化的序列。这需要对底层模式有更深入的理解,并且通常需要更复杂的训练算法。

一对多方法并非没有挑战。确保生成的序列保持一致性、相关性和创造性需要精心设计和细致调优。它通常需要更大规模的数据集,并依赖于特定领域的专家知识来指导模型的训练。

多对一

多对一序列模型是数据分析中的专业工具,它们将一系列输入转化为一个单一输出。将多个输入合成为一个简明输出的过程构成了多对一模型的核心,使其能够提炼数据的本质特征。

这些模型有广泛的应用,例如情感分析,在这种应用中,像评论或帖子这样的词语序列被分析,以确定整体情感,例如正面、负面或中立。多对一的序列模型如图 10.2所示:

A diagram of a flowchart  Description automatically generated

图 10.2:多对一序列模型

多对一模型的训练过程是其功能中复杂但至关重要的一部分。它使得这些模型与一对多模型有所不同,后者的重点是从单一输入创建一个序列。相比之下,多对一模型必须高效地压缩信息,因此需要仔细选择算法并精确调节参数。

训练多对一模型的过程包括教会它识别输入序列的关键特征,并准确地在输出中表示这些特征。这需要舍弃不相关的信息,这项任务需要精细的平衡。训练过程通常还需要针对输入数据的特定性质进行专业的预处理和特征工程。

正如前一小节所讨论的,训练多对一模型可能比其他类型的模型更具挑战性,因为它需要更深入地理解数据中的潜在关系。在训练过程中持续监控模型的表现,以及系统地选择数据和超参数,对于模型的成功至关重要。

多对一模型因其能够将复杂数据简化为易于理解的洞察力而值得注意,广泛应用于各行各业的任务,如摘要、分类和预测。尽管它们的设计和训练可能非常复杂,但它们解读序列数据的独特能力为解决复杂数据分析挑战提供了创新的解决方案。

因此,多对一序列模型是当代数据分析中至关重要的工具,理解其特定的训练过程对于充分利用其能力至关重要。训练过程的特点是精确的算法选择、参数调优和领域专业知识,这使得这些模型与众不同。随着该领域的发展,多对一模型将继续为数据解读和应用做出宝贵贡献。

多对多

这是一种序列模型,它将序列数据作为输入,经过某种方式处理后,生成序列数据作为输出。多对多模型的一个例子是机器翻译,其中一种语言的词序列被翻译成另一种语言中的对应序列。例如,英语文本翻译成法语就是一个典型的例子。虽然有许多机器翻译模型属于这一类别,但一种突出的做法是使用序列到序列(Seq2Seq)模型,特别是与 LSTM 网络配合使用。使用 LSTM 的 Seq2Seq 模型已经成为处理英语到法语翻译等任务的标准方法,并已在多个 NLP 框架和工具中实现。多对多序列模型如图 10.3所示:

自动生成的流程图示意图

图 10.3:多对多序列模型

多年来,已经开发出许多算法,用于处理和训练使用序列数据的机器学习模型。我们先从学习如何使用三维数据结构表示序列数据开始。

序列模型的数据表示

时间步(Timesteps)为数据增添了深度,使其成为一个三维结构。在序列数据的上下文中,这一维度的每个“单元”或实例被称为“时间步”。需要记住的是:虽然这一维度被称为“时间步”,但这一维度中的每个数据点都是一个“时间步”。图 10.4 展示了用于训练 RNN 的三维数据结构,强调了时间步的添加:

自动生成的图像,包含文字和线条

图 10.4:RNN 训练中使用的三维数据结构

鉴于时间步的概念是我们探索中的新内容,特引入了一种特殊的符号表示法来有效地表示它。该符号是在尖括号中括住时间步,并与相关变量配对。例如,使用此符号表示法,分别表示变量stock_price在时间步t1t2的值。

将数据划分为批次的选择,本质上是决定“长度”,既可以是有意的设计决策,也可能受到外部工具和库的影响。通常,机器学习框架提供了自动批处理数据的工具,但选择最佳的批次大小可能需要结合实验和领域知识。

让我们从 RNN 开始讨论序列建模技术。

介绍 RNN

RNN(循环神经网络)是一种专门为处理序列数据而设计的神经网络。以下是其关键特性的解析。

“递归”一词源于 RNN 所具备的独特反馈回路。与传统的神经网络不同,传统神经网络本质上是无状态的,并且仅根据当前输入生成输出,而 RNN 则将一个“状态”从序列中的一步传递到下一步。

当我们谈论 RNN 中的“运行”时,我们指的是序列中某个元素的单次传递或处理。因此,随着 RNN 处理每个元素或每次“运行”,它会保留一些来自前一步的信息。

RNN 的魔力在于它们能够保持对先前运行或步骤的记忆。它们通过结合一个额外的输入——即来自前一步的状态或记忆——来实现这一点。这种机制使 RNN 能够识别和学习序列中元素之间的依赖关系,例如句子中连续单词之间的关系。

让我们详细研究 RNN 的架构。

理解 RNN 的架构

首先,让我们定义一些变量:

  • :时间步* t *的输入

  • :时间步* t *的实际输出(真实值)

  • :时间步* t *的预测输出

理解记忆单元和隐藏状态

RNNs之所以突出,是因为它们天生具有记住并维持上下文的能力,随着时间步的推进保持这种能力。某个时间步* t 的状态通过 表示,其中h表示隐藏状态。这是截至某一特定时间步所学到的信息的总结。如图 10.5*所示,RNN 通过在每个时间步更新其隐藏状态不断学习。RNN 在每个时间步使用这个隐藏状态来保持上下文。从本质上讲,“上下文”是指 RNN 从之前的时间步中保留的集体信息或知识。它使 RNN 能够在每个时间步记住状态,并将这些信息传递到下一个时间步,随着序列的推进。这个隐藏状态使得 RNN 是有状态的:

Diagram  Description automatically generated

图 10.5:RNN 中的隐藏状态

例如,如果我们使用 RNN 将一句话从英语翻译成法语,那么每个输入就是需要定义为序列数据的句子。为了准确翻译,RNN 不能单独翻译每个单词。它需要捕捉到已经翻译的单词的上下文,从而使 RNN 能够正确翻译整个句子。这是通过在每个时间步计算并存储隐藏状态来实现的,并将其传递给后续时间步。

RNN 通过记住状态并打算将其用于未来时间步的策略带来了新的研究问题,需要解决。例如,记住什么以及忘记什么。而且,也许最棘手的问题是,何时忘记。RNN 的变种,如 GRU 和 LSTM,尝试以不同的方式回答这些问题。

理解输入变量的特性

让我们更深入地理解输入变量及其在处理 RNN 时的编码方法。RNN 的一个关键应用领域是在 NLP 中。在这里,我们处理的序列数据是句子。可以把每个句子看作是一个单词的序列,因此一个句子可以被描述为:

在这个表示中,表示句子中的一个单独单词。为了避免混淆:每个并不是整个句子,而是其中的一个单独单词。

每个单词都使用一个独热编码向量进行编码。该向量的长度由|V|定义,其中:

  • V 表示我们的词汇集合,它是一个包含不同单词的集合。

  • |V|量化了 V 中条目的总数。

在广泛应用的背景下,可以将 V 视为包含标准英语词典中所有单词的集合,通常包含大约 150,000 个单词。然而,对于特定的 NLP 任务,只需要这个庞大词汇表的一部分。

注意:区分 V 和|V|非常重要。V 代表词汇表本身,而|V|表示该词汇表的大小。

当提到“词典”时,我们指的是标准英语词典的一般概念。然而,也有更为详尽的语料库可用,如 Common Crawl,其中包含的词集可以达到数千万个单词。

对于许多应用程序来说,这个词汇表的子集就足够了。形式化地说,

为了理解 RNN 的工作原理,让我们先看看第一个时间步t1

在第一个时间步训练 RNN

RNN 通过一次分析一个时间步的序列来操作。让我们深入了解这一过程的初始阶段。在时间步t1,网络接收表示为的输入。基于这个输入,RNN 做出初步预测,我们将其表示为。在每个时间步tt,RNN 利用来自前一个时间步的隐藏状态,以提供上下文信息。

然而,在t1时刻,由于我们刚刚开始,因此没有前一个隐藏状态可以引用。因此,隐藏状态初始化为零。

激活函数的作用

参考图 10.6,你会注意到一个标记为A的元素。它代表激活函数,这是神经网络中的关键组件。本质上,激活函数决定了多少信号传递到下一层。在这个时间步,激活函数接收输入和前一个隐藏状态

第八章所述,神经网络中的激活函数是一个数学方程式,根据输入决定神经元的输出。它的主要作用是将非线性引入网络,从而使其能够通过误差进行学习和调整,这对于学习复杂的模式至关重要。

许多神经网络中常用的激活函数是“tanh”。那么,这种偏好的背后有什么原因呢?

神经网络的世界并非没有挑战,其中一个难题就是梯度消失问题。简单来说,当我们持续训练模型时,梯度值——指导我们调整权重的数值——有时会变得非常小。这种下降意味着我们对网络权重的调整几乎可以忽略不计。这些微小的调整导致学习过程变得极其缓慢,有时甚至会停滞不前。这时,“tanh”函数就发挥了作用。之所以选择它,是因为它在对抗梯度消失问题时充当了缓冲作用,推动训练过程朝着一致性和效率的方向发展:

图示描述,自动生成,信心中等

图 10.6:RNN 在时间步 t1 的训练

当我们聚焦于激活函数的结果时,我们得到了隐藏状态的值,。在数学上,这种关系可以表示为:

这个隐藏状态不仅仅是一个过渡阶段。当我们进入下一个时间步,t2时,它依然保持重要的价值。可以把它想象成接力赛选手传递接力棒,或者在这个例子中,是从一个时间步到下一个时间步的上下文传递,确保序列的连续性。

第二个激活函数(在图 10.7中由B表示)用于生成时间步t1的预测输出 。选择这个激活函数将取决于输出变量的类型。例如,如果 RNN 用于预测股市价格,可以采用 ReLU 函数,因为输出变量是连续的。另一方面,如果我们对一堆帖子进行情感分析,可能会使用 sigmoid 激活函数。在图 10.7中,假设它是一个多类输出变量,我们使用的是 softmax 激活函数。请记住,多类输出变量指的是输出或预测可以落入多个不同类别中的情况。在机器学习中,这种情况通常出现在分类问题中,目标是将输入归类为几个预定义类别之一。例如,如果我们将物体分类为汽车、自行车或公交车,则输出变量有多个类别,因此称为“多类”。在数学上,我们可以将其表示为:

方程 10.1方程 10.2可以明显看出,训练 RNN 的目标是找到三组权重矩阵(W[hx]、W[hh]和W[yh])和两组偏置(b[h]和b[y])的最优值。随着训练的进展,显而易见,这些权重和偏置在所有时间步中保持一致。

训练整个序列的 RNN

之前,我们为第一个时间步t1推导了隐藏状态的数学公式。现在,我们通过多个时间步来研究 RNN 的工作原理,以训练完整的序列,如图 10.7所示:

图示描述自动生成

图 10.7:RNN 中的顺序处理

信息:在图 10.7中,可以观察到隐藏状态从左到右传播,并通过箭头A将上下文信息向前传递。RNN 及其变体能够创建这种“信息高速公路”并在时间上传播,是 RNN 的定义特征。

我们为时间步t1计算了方程 10.1。对于任何时间步 t,我们可以将方程 10.1推广为:

对于 NLP 应用,被编码为一个独热向量。在这种情况下,的维度将等于|V|,其中 V 是表示词汇表的向量。隐藏变量将是原始输入的低维表示。通过将输入变量的维度降低多个倍数,我们希望隐藏层仅捕捉输入变量的重要信息。的维度由D[h]表示。

对于的维度比低 500 倍,这并不罕见。

所以,通常情况下:

由于的维度较低,权重矩阵W[hh]相对较小,类似于。另一方面,W[hx]的宽度将与一样宽。

合并权重矩阵

方程 10.3中,W[hh]和W[hx]都用于计算。为了简化分析,有助于将W[hh]和W[hx]合并为一个权重参数矩阵,。这种简化表示对于后续章节中讨论的更复杂的 RNN 变体非常有用。

为了创建一个合并的权重矩阵W[h],我们只需将W[hh]和W[hx]水平拼接,形成一个合并的权重矩阵W[h]:

由于我们只是进行水平拼接,W[h]的维度将有相同数量的行和总列数,即:

方程 10.3中使用W[h]:

其中 表示将两个向量垂直堆叠在一起。

其中 是相应的转置向量。

让我们看一个具体的例子。

假设我们正在为自然语言处理(NLP)应用使用 RNN。词汇表的大小是 50,000 个单词。这意味着每个输入 将被编码为一个维度为 50,000 的热编码向量。假设 的维度为 50。它将是 的低维表示。

现在,应该显而易见,W[hh] 的维度将是 (5050)。W[hx] 的维度将是 (5050,000)。

回到上面的例子,W[h] 的维度将是 (50x50,000+50) = 5050,050,即:

计算每个时间步的输出

在我们的模型中,给定时间步(如 t1)生成的输出由 表示。由于我们在模型中使用了 softmax 函数进行归一化,因此任何时间步 tt 的输出可以通过以下方程式进行概括:

理解在每个时间步如何计算输出,为随后的训练阶段奠定基础,在这一阶段,我们需要评估模型的表现。

现在我们已经掌握了如何在每个时间步生成输出,接下来需要确定这些预测输出与实际目标值之间的差异。这种差异被称为“损失”,它为我们提供了模型误差的衡量标准。在接下来的部分,我们将深入探讨计算 RNN 损失的方法,帮助我们评估模型的准确性,并对权重和偏差进行必要的调整。这个过程对于训练模型使其做出更准确的预测至关重要,从而提高整体性能。

计算 RNN 损失

如前所述,训练 RNN 的目标是找到三组权重(W[hx]、W[hh] 和 W[yh])以及两组偏差(b[h] 和 b[y])的正确值。最初,在时间步 t1,这些值会随机初始化。

随着训练过程的推进,这些值会随着梯度下降算法的应用而发生变化。我们需要在 RNN 的前向传播过程中计算每个时间步的损失。让我们分解计算损失的过程:

  1. 计算单个时间步的损失

    在时间步 t1,预测输出为 。期望输出为 。实际使用的损失函数将取决于我们训练的模型类型。例如,如果我们正在训练分类器,那么在时间步 t1 的损失将是:

  2. 完整序列的聚合损失

    对于由多个时间步组成的完整序列,我们将计算每个时间步的单独损失, {t[1],t[2],…t[T]}。一个包含 T 个时间步的序列的损失,将是每个时间步损失的汇总,按照以下公式计算:

  3. 计算一批次中多个序列的损失

    如果一个批次中有多个序列,首先会为每个单独的序列计算损失。然后,我们计算一个批次中所有序列的总体损失,并将其用于反向传播。

    通过以这种结构化的方式计算损失,我们引导模型调整其权重和偏差,以更好地与期望的输出对齐。这个迭代过程,在多个批次和时期中反复进行,使得模型能够从数据中学习并做出更准确的预测。

时间反向传播

第八章所述,反向传播用于神经网络中,从训练数据集的示例中逐步学习。RNN 在训练数据中增加了另一个维度,即时间步。时间反向传播BPTT)旨在处理序列数据,因为训练过程是通过时间步进行的。

当前馈过程计算完一个批次的最后一个时间步的损失时,触发反向传播。然后我们应用这个导数来调整 RNN 模型的权重和偏差。RNN 有三组权重,W[hh],W[hx] 和 W[hy],以及两组偏差(b[h] 和 b[y])。一旦调整了权重和偏差,我们将继续进行梯度下降来训练模型。

本节的名称 时间反向传播 并不暗示任何让我们回到中世纪时代的时间机器。它的来源在于一旦通过前馈计算了成本,必须通过每个时间步倒推并更新权重和偏差。

反向传播过程对于调优模型的参数至关重要,但一旦模型训练完成,接下来该做什么呢?在我们使用反向传播来最小化损失之后,模型已经准备好进行预测。在接下来的部分,我们将探讨如何使用训练好的 RNN 模型对新数据进行预测。我们会发现,使用 RNN 进行预测与使用全连接神经网络的过程类似,输入数据经过训练好的 RNN 处理后,生成预测结果。这种从训练到预测的转变,形成了理解 RNN 如何应用于现实问题的自然进展。

使用 RNN 进行预测

一旦模型训练完成,使用 RNN 进行预测与使用全连接神经网络进行预测类似。将输入数据作为输入传递给训练好的 RNN 模型,得到预测结果。其工作原理如下:

  1. 输入准备:与标准神经网络一样,首先要准备输入数据。对于 RNN 而言,这些输入数据通常是序列化的,代表过程或系列中的时间步。

  2. 模型利用:接着将输入数据输入到已训练的 RNN 模型中。模型在训练阶段优化的学习权重和偏置将用于通过网络的每一层处理输入数据。在 RNN 中,这包括通过处理数据的循环连接来处理数据的序列特性。

  3. 激活函数:与其他神经网络一样,RNN 中的激活函数会在数据通过各层时对其进行转换。根据 RNN 的具体设计,可能在不同阶段使用不同的激活函数。

  4. 生成预测:倒数第二步是生成预测。RNN 的输出会通过最后一层进行处理,通常在分类任务中使用 softmax 激活函数,生成每个输入序列的最终预测结果。

  5. 解释:预测结果将根据具体任务进行解释。这可能是分类一段文本序列、预测时间序列中的下一个值,或者任何依赖于序列数据的其他任务。

因此,RNN 的预测过程与全连接神经网络相似,主要区别在于对序列数据的处理。RNN 捕捉数据中时间关系的能力使其能够提供其他神经网络架构难以处理的独特洞察和预测。

基本 RNN 的局限性

在本章的前面,我们介绍了基本 RNN。有时我们将基本 RNN 称为“纯粹的香草”RNN。这个术语指的是它们的基本、朴素结构。虽然它们作为递归神经网络的一个良好入门,但这些基本 RNN 确实有显著的局限性:

  1. 梯度消失问题:这个问题使得 RNN 很难学习和保持数据中的长期依赖关系。

  2. 无法预见序列中的未来:传统的 RNN 从头到尾处理序列,这限制了它们理解序列中未来上下文的能力。

让我们逐一探讨这些问题。

梯度消失问题

RNN 逐步处理输入数据,一次一个时间步。这意味着随着输入序列变长,RNN 很难捕捉长期依赖关系。长期依赖关系指的是序列中相距较远的元素之间的关系。想象一下分析一段长篇文本,比如一部小说。如果一个角色在第一章的行为影响了最后一章的事件,那就是一个长期依赖关系。文本开头的信息必须“记住”直到结尾,才能充分理解。

RNN 通常在处理这样的长程依赖时会遇到困难。RNN 的隐藏状态机制旨在保留来自前一个时间步的信息,但它过于简单,无法捕捉这些复杂的关系。随着相关元素之间距离的增加,RNN 可能会丧失对连接的跟踪。它没有智能来判断何时保存记忆、何时忘记信息。

对于许多顺序数据的应用场景,只有最新的信息是重要的。例如,考虑一个预测文本应用,它试图通过建议下一个要输入的单词来协助一个人写邮件。

如我们所知,这种功能现在在现代文字处理软件中是标准配置。如果用户正在输入:

图 10.8:预测文本示例

预测文本应用可以轻松地建议下一个单词“hard”。它不需要带入前一句话的上下文来预测下一个单词。对于这种不需要长时记忆的应用,RNN 是最佳选择。RNN 在不牺牲准确性的情况下,不会使架构过于复杂。

但对于其他应用,保留长时依赖关系是重要的。RNN 在管理长时依赖关系时遇到困难。让我们来看一个例子:

图 10.9:带有长时依赖的预测文本示例

当我们从左到右阅读这句话时,可以观察到“was”(稍后在句子中使用)指的是“man”。原始形式的 RNN 在多时间步长中会很难保持隐藏状态的传递。原因在于,在 RNN 中,隐藏状态是针对每个时间步计算的,并且被传递到下一个时间步。

由于此操作的递归特性,我们总是担心在不同时间步从一个元素到另一个元素的过程中,信号会提前衰减。RNN 的这种行为被称为梯度消失问题。为了应对梯度消失问题,我们通常选择 tanh 作为激活函数。由于 tanh 的二阶导数衰减到零的速度非常慢,选择 tanh 有助于在一定程度上管理梯度消失问题。但是我们需要更复杂的架构,如 GRU 和 LSTM,以更好地管理梯度消失问题,下一节将详细讨论这一点。

无法在序列中向前看

RNN 可以根据信息流动的方向分类。主要有两种类型:单向 RNN 和双向 RNN。

  • 单向 RNN:这些网络以单一方向处理输入数据,通常是从序列的开始到结束。它们将上下文信息逐步传递,随着序列元素的迭代(如句子中的单词),逐步建立理解。其局限性在于:单向 RNN 无法在序列中“向前看”。

    它们只能访问到迄今为止所看到的信息,这意味着它们无法结合未来的元素来构建更准确或更细致的上下文。想象一下,逐字阅读一篇复杂的句子,而无法提前预览即将出现的内容。你可能会错过一些细微之处或误解整体意义。

  • 双向 RNN:相反,双向 RNN 同时处理序列的两个方向。它们结合了过去和未来元素的信息,使得对上下文有更丰富的理解。

让我们考虑以下两句话:

图 10.10:RNN 必须在句子中提前查看的示例

这两句话都使用了“cricket”这个词。如果上下文仅从左到右构建,就像单向 RNN 那样,我们就无法正确地理解“cricket”,因为其相关信息将在未来的时间步中出现。为了解决这个问题,我们将研究双向 RNN,它们在第十一章中有详细讨论。

现在让我们研究 GRU 及其详细的工作原理和架构。

GRU

GRU 代表了基本 RNN 结构的一种演变,特别设计用来解决传统 RNN 遇到的一些挑战,例如梯度消失问题。GRU 的架构如图 10.8所示:

图示,示意图  描述自动生成

图 10.11:GRU

让我们从讨论第一种激活函数开始,该函数标注为A。在每个时间步 t,GRU 首先使用 tanh 激活函数计算隐藏状态,并利用 作为输入。这个计算与上一节中介绍的原始 RNN 中隐藏状态的确定方法没有什么不同。但是有一个重要的区别。输出是一个候选隐藏状态,它是通过公式 10.6计算得出的:

其中 是隐藏层的候选值。

现在,GRU 不会立即使用候选隐藏状态,而是花时间决定是否使用它。可以想象成某人做决定之前停下来思考。这一停顿思考的过程就是我们所说的门控机制。它检查信息,然后选择接下来要记住的细节和要遗忘的部分。它有点像过滤掉噪音,集中注意力在重要的东西上。通过将旧信息(来自之前的隐藏状态)和新草案(候选状态)结合起来,GRU 能够更好地跟随长篇故事或序列,而不会迷失方向。通过引入候选隐藏状态,GRU 增加了额外的灵活性。它们可以谨慎地决定将候选状态的哪一部分纳入。这个区别使得 GRU 能够巧妙地应对诸如梯度消失之类的挑战,而传统 RNN 通常缺乏这种能力。简单来说,经典的 RNN 可能难以记住长篇故事,而 GRU 凭借其独特的特点,更像是优秀的听众和记忆者。

LSTM 是在 1997 年提出的,而 GRU 则是在 2014 年提出的。大多数关于这一主题的书籍倾向于按时间顺序呈现,首先介绍 LSTM。我选择按复杂度顺序呈现这些算法。由于 GRU 的提出动机是简化 LSTM,因此从学习较简单的算法开始可能会更有帮助。

引入更新门

在标准的 RNN 中,每个时间步的隐藏值都会被计算并自动成为记忆单元的新状态。相比之下,GRU 引入了一个更细致的方法。GRU 模型通过允许控制何时更新记忆单元的状态,为这个过程带来了更多的灵活性。这个增加的灵活性是通过一个叫做“更新门”的机制实现的,有时也被称为“重置门”。

更新门的作用是评估候选隐藏状态中的信息,,是否足够重要以更新记忆单元的隐藏状态,或者记忆单元是否应该保留之前时间步的旧隐藏值。

从数学角度来看,这个决策过程帮助模型更加有选择性地管理信息,决定是整合新的见解,还是继续依赖之前获得的知识。如果模型认为候选隐藏状态的信息不足以改变记忆单元当前的状态,那么就会保留之前的隐藏值。相反,如果新的信息被认为相关,它就会覆盖记忆单元的状态,从而在处理序列时调整模型的内部表示。

这种独特的门控机制使得 GRU 区别于传统的 RNN,并且使得它能在处理具有复杂时间关系的序列数据时,进行更有效的学习。

实现更新门

我们在记忆单元中如何更新状态所加入的智能是 GRU 的定义特征。很快我们将决定是否应该用候选隐藏状态更新当前的隐藏状态。为了做出这个决定,我们使用图 10.11中显示的第二个激活函数,标注为B。这个激活函数实现了更新门。

它作为一个 sigmoid 层实现,该层以当前输入和先前的隐藏状态为输入。sigmoid 层的输出是一个介于 0 和 1 之间的值,由变量表示。更新门的输出是变量,它由以下 sigmoid 函数控制:

由于是 sigmoid 函数的输出,它接近于 1 或 0,这决定了更新门是否开启。如果更新门开启,将被选为新的隐藏状态。在训练过程中,GRU 将学习何时开启门,何时关闭门。

更新隐藏单元

对于某个时间步,下一隐藏状态是通过以下方程的计算得出的:

方程 10.8 包含两个项,标注为12。作为 sigmoid 函数的输出,可以是 0 或 1。这意味着:

换句话说,如果门是开启的,更新的值。否则,只需保留旧状态。

现在让我们来看看如何在多个时间步上运行 GRU。

在多个时间步上运行 GRU

当在多个时间步上部署 GRU 时,我们可以像图 10.12所示那样可视化这个过程。就像我们在前一部分讨论的基础 RNN 一样,GRU 创建了可以看作“信息高速公路”的东西。这条路径有效地将上下文从序列的开始传递到结束,在图 10.12中可视化为,并标注为A

GRU 与传统 RNN 的区别在于它关于信息如何在这条高速公路上传输的决策过程。与每个时间步盲目地传递信息不同,GRU 会暂停并评估其相关性。

让我们通过一个基本的例子来说明。假设你正在阅读一本书,每个句子都是一条信息。然而,与你记住每个句子的每个细节不同,你的大脑(像一个 GRU)会选择性地回忆起那些最有影响力或最有情感的句子。这种选择性记忆类似于 GRU 中更新门的工作方式。

更新门在这里发挥着至关重要的作用。它是一个机制,决定哪些先前的信息,或先前的“隐藏状态”,应该保留或丢弃。本质上,更新门帮助网络聚焦并保留最相关的细节,确保传递的上下文保持尽可能相关。

图示  描述自动生成

图 10.12:RNN 中的顺序处理

引入 LSTM

RNN 广泛应用于序列建模任务,但它们在捕捉数据中的长期依赖性方面存在局限性。为了克服这些局限性,开发了 RNN 的高级版本——LSTM。与简单的 RNN 不同,LSTM 具有更复杂的机制来管理上下文,使其能够更好地捕捉序列中的模式。

在上一节中,我们讨论了 GRU,其中隐藏状态 用于将上下文从一个时间步传递到下一个时间步。LSTM 拥有更为复杂的机制来管理上下文。它有两个变量来携带上下文信息:细胞状态和隐藏状态。它们的解释如下:

  1. 细胞状态(表示为 ):它负责维护输入数据的长期依赖性。它从一个时间步传递到下一个时间步,用于在更长时间内保持信息。正如我们在本节稍后将学到的,细胞状态的内容是由遗忘门和更新门精确决定的。它可以被视为 LSTM 的“持久层”或“记忆”,因为它在较长时间内保持信息。

  2. 隐藏状态(表示为 ):该上下文关注于当前时间步,它可能对长期依赖性重要,也可能不重要。它是 LSTM 单元在特定时间步的输出,并作为输入传递到下一个时间步。如图 10.23所示,隐藏状态 用于生成时间步 t 的输出

现在让我们更详细地研究这些机制,从当前细胞状态如何更新开始。

引入遗忘门

LSTM 网络中的遗忘门负责确定从先前状态中丢弃哪些信息,保留哪些信息。它在图 10.3中标注为A。它实现为一个 sigmoid 层,输入为当前输入和先前的隐藏状态。sigmoid 层的输出是一个在 0 到 1 之间的值向量,每个值对应于 LSTM 记忆中一个单元的状态。

由于它是一个 sigmoid 函数,这意味着 可以接近 0 或接近 1。

如果 为 1,则意味着应使用来自前一状态 的值来计算 。如果 为 0,则意味着应忘记来自前一状态 的值。

信息:通常,二进制变量在其逻辑为 1 时被认为是激活的。当 = 0 时,"遗忘门" 忘记前一状态的行为可能显得不直观,但这是原始论文中提出的逻辑,研究人员为了保持一致性遵循了这一点。

图示,示意图  描述自动生成

图 10.13:LSTM 架构

候选单元状态

在 LSTM 中,在每个时间步,计算出一个候选单元状态,,它在图 10.13中标注为 Y,并作为提议的新状态用于记忆单元。它通过当前输入 和前一隐藏状态 来计算,公式如下:

更新门

更新门也叫做输入门。LSTM 网络中的更新门是一种机制,它允许网络有选择地将新信息融入当前状态,使得记忆能够集中关注最相关的信息。它在图 10.13中标注为 B

它负责判断候选单元状态 是否应添加到 中。它作为一个 sigmoid 层实现,输入为当前输入 和前一时刻的隐藏状态:

sigmoid 层的输出,,是一个值介于 0 和 1 之间的向量,每个值对应 LSTM 记忆中的一个单元。值为 0 表示计算出的 应该被忽略,而值为 1 表示 足够重要,应该被纳入 中。作为一个 sigmoid 函数,它的值可以介于 0 和 1 之间,表示来自 的部分信息应该被融入 ,但不是全部。

更新门允许 LSTM 有选择地将新信息融入当前状态,防止记忆被无关数据淹没。通过控制新信息加入记忆状态的量,更新门帮助 LSTM 保持在保留前一状态和融入新信息之间的平衡。

计算记忆状态

与 GRU 相比,LSTM 的主要区别在于,LSTM 不仅有一个更新门(如 GRU 中的那样),还为隐藏状态管理提供了独立的更新和遗忘门。每个门决定了各种状态的正确混合,以最优地计算长时记忆 、当前单元状态和当前隐藏状态 。记忆状态通过以下方式计算:

方程 10.12 由标注为 12 的两个项组成。作为 sigmoid 函数的输出, 的值可以是 0 或 1。意味着:

换句话说,如果门是开启的,则更新 的值。否则,保留旧状态。

因此,GRU 中的更新门是一种机制,允许网络有选择地丢弃之前隐藏状态中的信息,以便隐藏状态可以专注于最相关的信息。如图 10.13所示,展示了状态如何从左向右传递。

输出门

LSTM 网络中的输出门在图 10.13中标注为 C。它负责确定当前记忆状态中的哪些信息应作为 LSTM 的输出传递。它作为一个 sigmoid 层实现,输入为当前输入和前一个隐藏状态。sigmoid 层的输出是一个值在 0 和 1 之间的向量,其中每个值对应于 LSTM 内存中的一个单独的单元。

由于它是一个 sigmoid 函数,这意味着 可以接近 0 或 1。

如果 为 1,则表示应该使用之前状态的值 来进行计算。若 为 0,则表示应该忘记之前状态的值

值为 0 表示对应的单元不应对输出作出贡献,而值为 1 则表示该单元应完全贡献于输出。介于 0 和 1 之间的值表示该单元应部分贡献其值给输出。

在 LSTM 中,经过输出门处理后,当前状态会通过一个 tanh 函数。该函数调整值,使其落在 -1 到 1 的范围内。为什么需要这种缩放?tanh 函数确保 LSTM 的输出保持归一化,并防止值变得过大,这在训练过程中可能会导致梯度爆炸等问题。

经缩放后,输出门的结果与此归一化状态相乘。这个组合值表示 LSTM 在特定时间步的最终输出。

为了提供一个简单的类比:想象调整音乐的音量,使其既不太大也不太小,而是刚好适合你的环境。tanh函数的作用类似,确保输出是优化的,适合进一步处理。

输出门非常重要,因为它允许 LSTM 从当前的记忆状态中选择性地传递相关信息作为输出。它还帮助防止无关信息被作为输出传递。

该输出门生成变量 ,决定细胞状态对隐藏状态的贡献是否输出:

在 LSTM 中, 被用作输入到门中,而 是隐藏状态。

总结来说,LSTM 网络中的输出门是一种机制,它允许网络从当前记忆状态中选择性地传递相关信息作为输出,这样 LSTM 就可以根据其存储的相关信息生成适当的输出。

将所有内容汇总

让我们深入了解 LSTM 在多个时间步中的工作原理,如图 10.14中的A所示。

就像 GRU 一样,LSTM 创建了一条通道——通常称为“信息高速公路”——帮助将上下文传递到后续的时间步。这个过程在图 10.14中有所展示。LSTM 的魅力在于它能够利用长期记忆来传递这些上下文。

当我们从一个时间步推进到下一个时间步时,LSTM 会学习哪些信息应该保留在其长期记忆中,表示为 。在每个时间步的开始, 与“忘记门”进行交互,允许一些信息被丢弃。接着,它遇到“更新门”,新的数据被注入其中。这使得 能够在时间步之间转换,按照两个门的指示,不断地获得和舍弃信息。

现在,事情变得复杂了。在每个时间步结束时,长期记忆的副本 通过 tanh 函数进行转换。处理后的数据经过输出门筛选,最终得到我们称之为短期记忆的结果 。这个短期记忆有双重作用:它决定了特定时间步的输出,并为随后的时间步奠定基础,如图 10.14所示:

图示 说明自动生成

图 10.14:带有多个时间步的 LSTM

现在让我们看看如何编写 RNN 的代码。

编写顺序模型

在我们的 LSTM 探索中,我们将深入研究使用著名的 IMDb 电影评论数据集进行情感分析。在这里,每条评论都被标记为一个情感,正面或负面,使用二进制值进行编码(True表示正面,False表示负面)。我们的目标是创建一个二分类器,能够仅根据评论的文本内容预测这些情感。

总体而言,该数据集包含 50,000 条电影评论。为了我们的目的,我们将其平分:25,000 条用于训练模型,剩下的 25,000 条用于评估模型的性能。

对于那些希望深入了解数据集的人,更多信息可以在斯坦福的 IMDB 数据集中找到。

加载数据集

首先,我们需要加载数据集。我们将通过keras.datasets导入该数据集。通过keras.datasets导入的优势在于,它已经被处理成可以用于机器学习的格式。例如,评论已被单独编码为单词索引的列表。特定单词的总体频率被选为索引。因此,如果一个单词的索引是“7”,这意味着它是第 7 个最常见的单词。使用预处理好的数据让我们能够专注于 RNN 算法,而不是数据准备:

import tensorflow as tf
from tensorflow.keras.datasets import imdb
vocab_size = 50000
(X_train,Y_train),(X_test,Y_test) = tf.keras.datasets.imdb.load_data(num_words= vocab_size) 

请注意,参数num_words=50000用于仅选择前 50000 个词汇。由于单词的频率被用作索引,这意味着所有索引小于 50000 的单词都会被过滤掉:

"I watched the movie in a cinema and I really like it" 
[13, 296, 4, 20, 11, 6, 4435, 5, 13, 66, 447,12] 

在处理长度不等的序列时,通常需要确保它们都具有相同的长度。这一点在将序列输入神经网络时尤为重要,因为神经网络通常期望输入的大小一致。为此,我们使用填充——在序列的开头或末尾添加零,直到它们达到指定的长度。

下面是如何使用 TensorFlow 实现这一点:

# Pad the sequences
max_review_length = 500
x_train = tf.keras.preprocessing.sequence.pad_sequences(x_train, maxlen=max_review_length)
x_test = tf.keras.preprocessing.sequence.pad_sequences(x_test, maxlen=max_review_length) 

索引非常适合算法处理。为了便于人类阅读,我们可以将这些索引转换回单词:

word_index = tf.keras.datasets.imdb.get_word_index()
reverse_word_index = dict([(value, key) for (key, value) in word_index.items()])
def decode_review(padded_sequence):
    return " ".join([reverse_word_index.get(i - 3, "?") for i in padded_sequence]) 

请注意,单词索引从 3 开始,而不是从 0 或 1. 这是因为前三个索引是保留的。

接下来,让我们看看如何准备数据。

准备数据

在我们的例子中,我们考虑的词汇量为 50,000 个单词。这意味着输入序列中的每个单词都将使用一个 one-hot 向量表示,其中每个向量的维度为 50,000. One-hot 向量是一个二进制向量,除了与单词对应的索引位置为 1 外,其他位置都是 0。下面是我们如何在 TensorFlow 中加载 IMDb 数据集,并指定词汇大小:

vocab_size = 50000
(x_train, y_train), (x_test, y_test) = tf.keras.datasets.imdb.load_data(num_words=vocab_size) 

请注意,由于vocab_size被设置为50,000,因此数据将加载前50,000个最常出现的单词,其余单词将被丢弃或替换为一个特殊的符号(通常用<UNK>表示“未知”)。这确保了我们的输入数据是可管理的,并且只包含对模型最相关的信息。变量x_trainx_test分别包含训练和测试输入数据,而y_trainy_test则包含相应的标签。

创建模型

我们首先定义一个空的堆栈。我们将使用这个堆栈逐层构建我们的网络:

model = tf.keras.models.Sequential() 

接下来,我们将向模型中添加一个Embedding层。如果你还记得我们在第九章中讨论的词嵌入,我们使用它们来表示词汇在连续向量空间中的位置。Embedding层也起到类似的作用,但它是在神经网络中进行的。它提供了一种将词汇表中的每个单词映射到连续向量的方式。彼此接近的词在这个向量空间中可能会共享上下文或意义。

让我们定义Embedding层,考虑到我们之前选择的词汇表大小,并将每个词映射到一个 50 维的向量,对应于!的维度:

model.add(
    tf.keras.layers.Embedding(
        input_dim = vocab_size, 
        output_dim = 50, 
        input_length = review_length 
    )
) 

Dropout层可以防止过拟合,并通过在学习阶段随机禁用神经元,迫使模型学习同一数据的多种表示。让我们随机禁用 25%的神经元以应对过拟合:

model.add(
    tf.keras.layers.Dropout(
        rate=0.25
    )
) 

接下来,我们将添加一个 LSTM 层,它是 RNN 的一种专门形式。虽然基础 RNN 在学习长期依赖关系时存在问题,LSTM 旨在记住这些依赖关系,使其适用于我们的任务。这个 LSTM 层将分析评论中词语的顺序及其嵌入,利用这些信息来确定给定评论的情感。我们将在这一层中使用 32 个单元:

model.add(
    tf.keras.layers.LSTM(
        units=32 
    )
) 

添加第二个Dropout层,随机丢弃 25%的神经元,以减少过拟合:

model.add(
    tf.keras.layers.Dropout(
        rate=0.25
    )
) 

所有 LSTM 单元都连接到Dense层中的一个节点。一个 sigmoid 激活函数决定了这个节点的输出——一个介于 0 和 1 之间的值。接近 0 表示负面评论,接近 1 表示正面评论:

model.add(
    tf.keras.layers.Dense(
        units=1, 
        activation='sigmoid' 
    )
) 

现在,让我们编译模型。我们将使用binary_crossentropy作为损失函数,Adam作为优化器:

model.compile(
    loss=tf.keras.losses.binary_crossentropy, 
    optimizer=tf.keras.optimizers.Adam(), 
    metrics=['accuracy']) 

显示模型结构的摘要:

model.summary() 
__________________________________________________________________________
Layer (type)               Output Shape               Param #
=========================================================================
embedding (Embedding)      (None, 500, 32)            320000
dropout (Dropout)          (None, 500, 32)            0
lstm (LSTM)                (None, 32)                 8320
dropout_1 (Dropout)        (None, 32)                 0
dense (Dense)              (None, 1)                  33
=========================================================================
Total params: 328,353
Trainable params: 328,353
Non—trainable params: 0 

训练模型

我们现在将基于训练数据训练 LSTM 模型。训练模型涉及几个关键组件,每个组件如下所述:

  • 训练数据:这些是模型将从中学习的特征(评论)和标签(正面或负面情感)。

  • 批次大小:这决定了每次更新模型参数时将使用的样本数量。较大的批次大小可能需要更多的内存。

  • Epochs:一个 epoch 是对整个训练数据集的一次完整迭代。epoch 越多,学习算法就会越多次地遍历整个训练数据集。

  • Validation Split:这部分训练数据将被用于验证,不用于训练。它帮助我们评估模型的表现。

  • Verbose:这个参数控制模型在训练过程中会输出多少内容。值为 1 时,进度条会显示出来:

    history = model.fit(
        x_train, y_train,    # Training data
        batch_size=256,      
        epochs=3,            
        validation_split=0.2,
        verbose=1            
    ) 
    
    Epoch 1/3
    79/79 [==============================] - 75s 924ms/step - loss: 0.5757 - accuracy: 0.7060 - val_loss: 0.4365 - val_accuracy: 0.8222
    Epoch 2/3
    79/79 [==============================] - 79s 1s/step - loss: 0.2958 - accuracy: 0.8900 - val_loss: 0.3040 - val_accuracy: 0.8812
    Epoch 3/3
    79/79 [==============================] - 73s 928ms/step - loss: 0.1739 - accuracy: 0.9437 - val_loss: 0.2768 - val_accuracy: 0.8884 
    

查看一些错误的预测

让我们看一下其中一些错误分类的评论:

predicted_probs = model.predict(x_test)
predicted_classes_reshaped = (predicted_probs > 0.5).astype("int32").reshape(-1)
incorrect = np.nonzero(predicted_classes_reshaped != y_test)[0] 

我们选择了前 20 条被错误分类的评论:

class_names = ["Negative", "Positive"]
for j, incorrect_index in enumerate(incorrect[0:20]):
    predicted = class_names[predicted_classes_reshaped[incorrect_index]]
    actual = class_names[y_test[incorrect_index]]
    human_readable_review = decode_review(x_test[incorrect_index])
    print(f"Incorrectly classified Test Review [{j+1}]")
    print(f"Test Review #{incorrect_index}: Predicted [{predicted}] Actual [{actual}]")
    print(f"Test Review Text: {human_readable_review.replace('<PAD> ', '')}\n") 

总结

本章解释了顺序模型的基础概念,旨在让你对这些技术及其方法有一个基本的了解。在这一章中,我们介绍了适合处理顺序数据的 RNN。GRU 是一种 RNN 类型,由 Cho 等人于 2014 年提出,作为 LSTM 网络的简化替代方案。

和 LSTM 一样,GRU 被设计用来学习顺序数据中的长期依赖关系,但它们采用了不同的方法。GRU 使用单一的门控机制来控制信息在隐藏状态中流入和流出的过程,而 LSTM 使用三个门控机制。这使得 GRU 更容易训练,参数更少,使用起来更高效。

下一章介绍了一些与顺序模型相关的高级技术。

了解更多内容,请访问 Discord

要加入这本书的 Discord 社区,在这里你可以分享反馈、向作者提问并了解新版本,请扫描下面的二维码:

packt.link/WHLel

第十一章:高级顺序建模算法

算法是一个指令序列,按照该序列执行可以解决一个问题。

—未知

在上一章中,我们探讨了顺序模型的核心原理。它提供了这些技术和方法的入门概述。上一章讨论的顺序建模算法有两个基本限制。首先,输出序列必须与输入序列有相同数量的元素。其次,这些算法一次只能处理输入序列中的一个元素。如果输入序列是一句句子,那么到目前为止讨论的顺序算法只能一次“关注”或处理一个单词。为了更好地模拟人脑的处理能力,我们需要的不仅仅是这些。我们需要复杂的顺序模型,能够处理长度与输入不同的输出,并且能够同时关注句子中的多个单词,从而打破这一信息瓶颈。

本章将更深入地探讨顺序模型的高级内容,了解如何创建复杂的配置。我们将从分解关键元素开始,例如自编码器和序列到序列Seq2Seq)模型。接下来,我们将介绍注意力机制和变换器,它们在大语言模型LLMs)的发展中起着至关重要的作用,随后我们将对其进行学习。

到本章结束时,您将全面了解这些高级结构及其在机器学习领域的重要性。我们还将提供这些模型的实际应用的深入见解。

本章涵盖以下主题:

  • 自编码器介绍

  • Seq2Seq 模型

  • 注意力机制

  • 变换器

  • 大语言模型

  • 深度和广度架构

首先,让我们概述一下高级顺序模型。

高级顺序建模技术的演变

第十章,《理解顺序模型》中,我们讨论了顺序模型的基础知识。尽管它们有许多应用场景,但在理解和生成复杂的人类语言细节方面仍然面临挑战。

我们将从讨论自编码器开始。自编码器是在 2010 年代初期提出的,为数据表示提供了一种新颖的方式。它们在自然语言处理NLP)中标志着一个重要的演变,彻底改变了我们对数据编码和解码的思考方式。但 NLP 的进展并未止步于此。到 2010 年代中期,Seq2Seq模型开始出现,带来了用于任务(如语言翻译)的一些创新方法。这些模型能够巧妙地将一种序列形式转化为另一种,开启了先进序列处理的新时代。

然而,随着数据复杂度的增加,NLP 社区感受到了对更精密工具的需求。这促成了 2015 年注意力机制的发布。这个优雅的解决方案赋予了模型选择性地关注输入数据特定部分的能力,使其能更高效地处理更长的序列。实质上,它允许模型对不同数据段的重要性进行加权,从而放大相关信息,减小不相关信息。

在此基础上,2017 年迎来了变换器架构的出现。充分利用注意力机制的能力,变换器在 NLP 中树立了新的标杆。

这些进展最终促成了大语言模型LLMs)的发展。经过海量且多样化文本数据的训练,LLMs 能够理解并生成细腻的人类语言表达。它们无与伦比的能力在广泛应用中得到了体现,从医疗诊断到金融中的算法交易。

在接下来的部分中,我们将深入探讨自动编码器的复杂性——从它们的早期起源到今天在先进序列模型中的核心作用。准备好深入了解这些变革性工具的机制、应用和演变吧。

探索自动编码器

自动编码器在神经网络架构的领域中占据了独特的地位,在高级序列模型的叙事中扮演着关键角色。基本上,自动编码器旨在创建一个输出与输入相似的网络,意味着将输入数据压缩成更简洁、低维的潜在表示。

自动编码器结构可以被概念化为一个双阶段过程:编码阶段和解码阶段。

考虑以下图示:

A picture containing text, clock  Description automatically generated

图 11.1:自动编码器架构

在这个图示中,我们做出以下假设:

  • x 对应于输入数据

  • h 是我们数据的压缩形式

  • r 表示输出,即 x 的重建或近似值

我们可以看到,这两个阶段分别由 fg 表示。让我们更详细地看一下它们:

  • 编码f):用数学形式表示为 h = f(x)。在此阶段,输入数据* x *被转化为一个简化的、隐藏的表示,称为 h

  • 解码g):在此阶段,用 r = g(h) 表示,紧凑的 h 被展开,旨在重建最初的输入。

在训练自动编码器时,目标是完善 h,确保它能 encapsulate 输入数据的本质。在实现高质量的 h 时,我们确保重建的输出 r 能尽量少的损失地再现原始的 x。目标不仅是重建,还要训练出一个精简且高效的 h,以完成这个重建任务。

编写自动编码器

美国国家标准与技术研究所MNIST)数据集是一个著名的手写数字数据库,包含 28x28 像素的灰度图像,表示从 0 到 9 的数字。它已广泛用作机器学习算法的基准。更多信息以及数据集的访问可以通过官方 MNIST 网站获得。对于有兴趣访问数据集的人,它可以在 Yann LeCun 主办的官方 MNIST 存储库中找到:yann.lecun.com/exdb/mnist/。请注意,下载数据集可能需要创建账户。

在本节中,我们将使用自编码器重建这些手写数字。自编码器的独特之处在于其训练机制:输入目标输出是相同的图像。我们来详细解析一下。

首先是训练阶段,期间将进行以下步骤:

  1. MNIST 图像被提供给自编码器。

  2. 编码器部分将这些图像压缩成浓缩的潜在表示。

  3. 解码器部分随后会尝试从这个表示中恢复原始图像。通过反复迭代这个过程,自编码器掌握了压缩和重构的细节,捕捉到手写数字的核心模式。

第二步是重构阶段

  1. 训练好的模型在接收到新的手写数字图像时,自编码器会首先将其编码成内部表示。

  2. 然后,解码这个表示将得到一个重构的图像,如果训练成功,它应该与原始图像非常相似。

在 MNIST 数据集上有效训练后的自编码器,成为一个强大的工具,用来处理和重构手写数字图像。

环境设置

在深入代码之前,必须导入必要的库。TensorFlow 将是我们的主要工具,但在数据处理方面,像 NumPy 这样的库可能至关重要:

import tensorflow as tf 

数据准备

接下来,我们将把数据集划分为训练集和测试集,然后对它们进行归一化处理:

# Load dataset
(x_train, _), (x_test, _) = tf.keras.datasets.mnist.load_data()
# Normalize data to range [0, 1]
x_train, x_test = x_train / 255.0, x_test / 255.0 

请注意,255.0的除法操作是为了对灰度图像数据进行归一化,这是优化学习过程的一个步骤。

模型架构

设计自编码器涉及到关于层次、尺寸和激活函数的决策。在这里,模型是通过 TensorFlow 的SequentialDense类定义的:

model = tf.keras.Sequential([
    tf.keras.layers.Flatten(input_shape=(28, 28)),
    tf.keras.layers.Dense(32, activation='relu'),
    tf.keras.layers.Dense(784, activation='sigmoid'),
    tf.keras.layers.Reshape((28, 28))
]) 

将 28x28 的图像展平后,我们得到一个包含 784 个元素的一维数组,因此输入的形状为该数组的形状。

编译

模型定义完成后,将使用指定的损失函数和优化器进行编译。由于我们的灰度图像是二值性质,因此选择了二元交叉熵作为损失函数:

model.compile(loss='binary_crossentropy', optimizer='adam') 

训练

训练阶段通过fit方法启动。在这里,模型学习 MNIST 手写数字的细节:

model.fit(x_train, x_train, epochs=10, batch_size=128,
          validation_data=(x_test, x_test)) 

预测

使用训练好的模型,可以执行以下预测操作(包括编码和解码):

encoded_data = model.predict(x_test)
decoded_data = model.predict(encoded_data) 

可视化

现在让我们直观地比较原始图像与其重建后的对比图像。以下脚本展示了一个可视化过程,显示了两排图像:

n = 10  # number of images to display
plt.figure(figsize=(20, 4))
for i in range(n):
    # Original images
    ax = plt.subplot(2, n, i + 1)
    plt.imshow(x_test[i].reshape(28, 28) , cmap='gray')
    ax.get_xaxis().set_visible(False)
    ax.get_yaxis().set_visible(False)
    # Reconstructed images
    ax = plt.subplot(2, n, i + 1 + n)
    plt.imshow(decoded_data[i].reshape(28, 28) , cmap='gray')
    ax.get_xaxis().set_visible(False)
    ax.get_yaxis().set_visible(False)
plt.show() 

以下截图显示了输出的重建图像:

A black square with white numbers  Description automatically generated

图 11.2:原始测试图像(上排)和自编码器重建后的图像(下排)

最上排展示了原始测试图像,而下排展示了通过自编码器重建后的图像。通过这种并排比较,我们可以辨别出模型在保持输入的内在特征方面的有效性。

现在让我们讨论 Seq2Seq 模型。

理解 Seq2Seq 模型

在我们探索自编码器之后,另一个在高级序列模型领域中的突破性架构是Seq2Seq模型。Seq2Seq 模型在许多最先进的自然语言处理任务中占据重要地位,展现了一种独特的能力:将输入序列转换为可能在长度上不同的输出序列。这种灵活性使其在诸如机器翻译等挑战中表现出色,因为源语言和目标语言的句子长度可以自然地不同。

请参阅图 11.3,它展示了 Seq2Seq 模型的核心组成部分:

Diagram  Description automatically generated

图 11.3:Seq2Seq 模型架构示意图

总的来说,有三个主要元素:

  • 编码器:处理输入序列

  • 思维向量:编码器和解码器之间的桥梁

  • 解码器:生成输出序列

让我们一个一个地探索它们。

编码器

编码器在图 11.3中显示为Icon  Description automatically generated。正如我们所观察到的,它是一个输入循环神经网络RNN),用于处理输入序列。此处的输入句子是一个三词句子:Is Ottawa cold? 它可以表示为:

X = {x^(<1>), x^(<2>),… …., x^()}

编码器遍历该序列,直到遇到句子结束(<EOS>)标记,表示输入的结束。它将位于时间步L1

思维向量

在编码阶段,RNN 更新其隐藏状态,表示为 h^()。序列结束时捕获的最终隐藏状态 h^()会传递给解码器。这个最终状态被称为思维向量,由 Geoffrey Hinton 于 2015 年提出。这个紧凑的表示捕捉了输入序列的精髓。思维向量在图 11.3中显示为Icon  Description automatically generated

解码器或写入器

在编码过程完成后,<GO>符号表示解码器开始工作。使用编码器的最后一个隐藏状态 h^()作为其初始输入,解码器作为输出 RNN,开始构建输出序列 Y = {y^(<1>), y^(<2>),… …., y^()}。在图 11.3的背景下,这个输出序列转化为句子:是的它是

Seq2Seq 中的特殊符号

虽然<EOS><GO>是 Seq2Seq 范式中的重要符号,但还有一些其他值得注意的符号:

  • <UNK>:代表未知,这个符号用来替换不常见的单词,确保词汇表保持可管理。

  • <PAD>:用于填充较短的序列,这个符号在训练过程中标准化序列长度,从而提升模型的效率。

Seq2Seq 模型的一个显著特点是能够处理可变序列长度,这意味着输入和输出序列在大小上本质上可以有所不同。这种灵活性,再加上其顺序特性,使得 Seq2Seq 成为高级建模领域中的一个重要架构,架起了从自编码器到更复杂、更精细的序列处理系统的桥梁。

在经历了自编码器的基础领域并深入研究了 Seq2Seq 模型之后,我们现在需要理解编码器-解码器框架的局限性。

信息瓶颈难题

正如我们所学到的,传统 Seq2Seq 模型的核心是思维向量 h^()。这是来自编码器的最后一个隐藏状态,作为连接编码器和解码器的桥梁。这个向量负责封装整个输入序列,X。该机制的简单性既是其优势也是其弱点。当序列变得更长时,这种弱点尤为突出;将大量信息压缩成固定大小的表示变得越来越困难。这被称为信息瓶颈。无论输入的丰富性或复杂性如何,固定长度的记忆限制意味着从编码器传递到解码器的信息量是有限的。

要了解这个问题是如何被解决的,我们需要将焦点从 Seq2Seq 模型转移到注意力机制上。

理解注意力机制

在传统 Seq2Seq 模型中的固定长度记忆带来的挑战之后,2014 年标志着一次革命性的进步。Dzmitry Bahdanau、KyungHyun Cho 和 Yoshua Bengio 提出了一种变革性解决方案:注意力机制。与早期模型试图(常常徒劳地)将整个序列压缩到有限的内存空间不同,注意力机制使得模型能够专注于输入序列中具体且相关的部分。可以把它想象成在每个解码步骤中仅对最关键数据进行放大。

神经网络中的注意力是什么?

正如俗话所说,注意力集中之处,就是焦点所在。在自然语言处理(NLP)领域,尤其是在大语言模型(LLM)的训练中,注意力受到了极大的关注。传统上,神经网络按固定顺序处理输入数据,可能会错过上下文的重要性。而注意力机制的引入,则是为了解决这个问题——它能够衡量不同输入数据的重要性,更加关注相关内容。

基本概念

就像人类会将更多注意力放在图像或文本的显著部分一样,注意力机制使神经模型能够专注于输入数据中更相关的部分。它有效地指引模型下一步“看”哪里。

示例

受到我最近一次前往埃及的旅行启发,这次旅行仿佛是一次穿越时空的旅行,让我们思考古埃及的表现力和象征性语言:象形文字。

象形文字不仅仅是符号;它们是艺术与语言的复杂融合,代表着多重含义。这个系统,通过其丰富的符号阵列,展示了神经网络注意力机制的基础原则。

沙漠中的一群金字塔  描述自动生成

图 11.4:吉萨的著名金字塔——胡夫金字塔和哈夫拉金字塔,旁边是古老埃及象形文字的铭文,“象形文字”(照片由作者拍摄)

举例来说,一位埃及抄写员希望传达关于尼罗河边即将举行的盛大节日的消息。在成千上万的象形文字中:

  • 黑色背景上的白色符号  描述自动生成 安卡象形文字,象征生命,捕捉了节日的生动气氛和庆祝精神。

  • 带有数字一的黑色杆  描述自动生成 瓦斯符号,形似权杖,暗示着权威或法老在庆典中的关键角色。

  • 白色背景上的黑色锯齿线  描述自动生成 尼罗河的插图,象征着埃及文化的中心,标示出节庆的举办地点。

然而,为了传达节日的宏伟和重要性,并非所有符号的权重都相同。抄写员必须强调或重复特定的象形文字,以引起人们对信息最关键部分的注意。

这种选择性强调与神经网络的注意力机制相似。

注意力机制的三个关键方面

在神经网络,尤其是 NLP 任务中,注意力机制在过滤和聚焦相关信息方面起着至关重要的作用。在这里,我们将注意力的主要方面提炼为三个关键组成部分:上下文相关性、符号效率和优先关注:

  • 上下文相关性

    • 概述:本质上,注意力旨在将更多的重视分配给那些被认为与当前任务更相关的输入数据部分。

    • 深入探讨:以一个简单的输入例如“宏伟的尼罗河节”为例。在这种情况下,注意力机制可能会给“尼罗河”和“宏伟”这两个词赋予更高的权重。这并非因为它们的普遍意义,而是由于它们在任务中的特定重要性。注意力机制并非将每个词或输入都视为同等重要,而是根据上下文区分并调整模型的聚焦点。

    • 实际应用:可以把这看作是一束聚光灯。就像聚光灯在舞台上照亮特定演员在关键时刻的表现,而其他演员则被暗淡处理,注意力也会把光照在更具上下文价值的输入数据上。

  • 符号效率

    • 概述:注意力机制能够将大量信息浓缩成易于理解的关键片段。

    • 深入探讨:象形文字可以通过单一符号承载复杂的叙事或思想。类比地,注意力机制通过分配不同的权重,能够判断数据的哪些部分包含最多的信息,并应优先处理这些部分。

    • 实际应用:考虑将一篇大文档压缩成简洁的摘要。摘要仅保留最关键的信息,这类似于注意力机制从更大的输入中提取并优先处理最相关的数据。

  • 优先聚焦

    • 概述:注意力机制不会均匀分配它们的聚焦点。它们的设计是根据输入数据与任务的相关性来优先考虑某些片段。

    • 深入探讨:从象形文字的例子中汲取灵感,就像古埃及的抄写员在表达生命或庆祝的概念时会强调“安卡”符号一样,注意力机制也会根据任务的相关性调整对输入特定部分的聚焦(或权重)。

    • 实际应用:这就像阅读一篇研究论文。虽然整个文档都有价值,但人们可能更关注摘要、结论或与自己当前研究需求相关的特定数据点。

因此,神经网络中的注意力机制模仿了人类在处理信息时自然使用的选择性聚焦方式。通过理解注意力如何优先处理数据的细微差别,我们可以更好地设计和解读神经模型。

更深入探讨注意力机制

注意力机制可以被看作是一种进化的沟通方式,就像古代的象形文字一样。传统上,编码器试图将整个输入序列浓缩成一个概括性的隐藏状态。这就像古埃及的抄写员试图通过一个单一的象形文字来表达整个事件。虽然这在理论上是可能的,但它很具挑战性,并且可能无法完整捕捉事件的全部精髓。

现在,采用增强的编码器-解码器方法,我们可以为每个步骤生成一个隐藏状态,从而为解码器提供更丰富的数据。但是,如果同时引用每一个象形符号(或状态),那将会是混乱的,类似于一位抄写员用所有可用的符号来描述尼罗河旁的一个事件。这就是注意力机制的作用所在。

注意力机制使解码器能够进行优先级排序。就像一位抄写员可能专注于“Ankh”符号来象征生命和活力,或者“Was”权杖代表权力,甚至描绘尼罗河本身来指示位置一样,解码器会为每个编码器状态分配不同的权重。它决定序列中的哪些部分(或哪些象形符号)值得更多的关注。以我们的翻译示例为例,当将“Transformers are great!”翻译为“Transformatoren sind grossartig!”时,机制会强调“great”与“grossartig”的对齐,确保核心情感不变。

这种选择性聚焦,无论是在神经网络的注意力机制中,还是在象形文字叙事中,都能确保传达信息的准确性和清晰度。

enc-dec-attn

图 11.5:采用增强注意力机制的编码器-解码器结构的 RNN

注意力机制的挑战

尽管将注意力机制与 RNN 结合可以带来显著的改进,但这并不是万能的解决方案。其中一个重大障碍是计算成本。将多个隐藏状态从编码器传输到解码器的过程需要大量的计算能力。

然而,正如所有技术进步一样,解决方案不断涌现。其中一个进展是自注意力的引入,这是变压器架构的基石。这种创新的变体优化了注意力过程,使其更加高效和可扩展。

深入探讨自注意力机制

再次考虑古老的象形文字艺术,其中符号的选择是有意为之,用来传达复杂的消息。自注意力的运作方式类似,确定序列中哪些部分是关键并应当被强调。

图 11.6展示了在顺序模型中整合自注意力的美妙之处。想象底层通过双向 RNN 运作,像是金字塔的基石。它们生成我们所称的上下文向量c2),类似于象形文字对事件的总结。

序列中的每一步或每个单词都有其权重,用α表示。这些权重与上下文向量相互作用,强调某些元素的重要性,而不是其他元素。

假设一个场景,其中输入X[k]表示一个独立的句子,记作k,该句子的长度为L1。这可以用数学方式表示为:

在这里,每个元素,,代表句子k中的一个单词或符号:上标表示它在句子中的特定位置或时间步。

注意力权重

在自注意力机制中,注意力权重起着至关重要的作用,像一个指向重要单词的指南针。它们在生成上下文向量时,给每个单词分配一个“重要性分数”。

为了让这更有意义,考虑我们之前的翻译示例:“Transformers are great!” 翻译为 “Transformatoren sind grossartig!”。当聚焦于“Transformers”时,注意力权重可能是这样分布的:

  • α[2,1]:衡量“Transformers”与句子开头之间的关系。此处的高值表示“Transformers”在其上下文中显著依赖句子开头。

  • α[2,2]:反映了“Transformers”对其内在意义的强调程度。

  • α[2,3] 和 α[2,4]:衡量“Transformers”在上下文中对“are”和“great!”这两个词的依赖程度。高分值表示“Transformers”深受这些周围词汇的影响。

在训练过程中,这些注意力权重会不断地被调整和精细化。这种持续的优化确保我们的模型理解句子中单词之间错综复杂的关系,捕捉到显性和隐性之间的联系。

A diagram of a complex structure  Description automatically generated

图 11.6:在序列模型中整合自注意力机制

在深入探讨自注意力机制之前,理解组成图 11.6中的关键部分是至关重要的。

编码器:双向 RNN

在上一章中,我们研究了单向 RNN 及其变种的主要架构组件。双向 RNN的发明旨在解决这一需求(Schuster 和 Paliwal,1997)。我们还发现了单向 RNN 的一个缺陷,即它们只能向一个方向传递上下文。

对于一个输入序列,假设是 X,双向 RNN 首先从头到尾读取它,然后再从尾到头读取。这种双重方法帮助捕捉基于前后元素的信息。对于每个时间步,我们得到两个隐藏状态:一个是 (正向)方向的,另一个是 (反向)方向的。这些状态被合并成该时间步的单一状态,表示为:

举个例子,如果 是 64 维向量,那么结果的 h^() 将是 128 维的。这个合并后的状态是来自两个方向的序列上下文的详细表示。

思维向量

思维向量,这里用 C[k] 表示,是输入 X[k] 的一种表征。如我们所学,它的创建旨在捕捉 X[k] 中每个元素的顺序模式、上下文和状态。

在我们前面的图示中,它被定义为:

其中 是在训练过程中针对时间步 t 精炼后的注意力权重。

使用求和符号,它可以表示为:

解码器:常规的 RNN

图 11.5 显示了通过思维向量连接的解码器与编码器。

某个句子 k 的解码器输出表示为:

请注意,输出的长度是 L2,与输入序列的长度 L1 不同。

训练与推理

在某个输入序列 k 的训练数据中,我们有一个表示地面真实的期望输出向量,这个向量用 Y[k]表示。它是:

在每个时间步,解码器的 RNN 接收三个输入:

  • :前一个隐藏状态

  • C[k]:序列 k 的思维向量

  • :地面真实向量 Y[k]中的前一个词

然而,在推理过程中,由于没有先前的真实值可用,解码器的 RNN 会使用先前的输出词,,代替。

现在我们已经了解了自注意力如何解决注意力机制面临的挑战以及它涉及的基本操作,我们可以将注意力转向序列建模中的下一个重大进展:变换器(transformers)。

变换器:自注意力之后神经网络的演进

我们对自注意力的探索揭示了它强大的能力,可以重新解释序列数据,基于与其他词的关系为每个词提供上下文理解。这个原则为神经网络设计的进化跃进奠定了基础:变换器架构。

变换器架构由谷歌大脑团队在他们 2017 年的论文《Attention is All You Need》(arxiv.org/abs/1706.03762)中提出,基于自注意力的本质。变换器问世之前,RNN 是首选。可以将 RNN 看作是勤奋的图书管理员,逐字地读取英语句子并将其翻译成德语,确保上下文从一个词传递到下一个词。它们在处理短文本时非常可靠,但当句子变得过长时,可能会发生错位,丢失早期词汇的本意。

transformer-self-attn

图 11.7:原始变换器的编码器-解码器架构

变换器是处理序列数据的一种全新方法。与线性逐字的进程不同,变换器借助先进的注意力机制,可以一眼理解整个序列。这就像瞬间抓住整段文字的情感,而不是逐字拼凑。这种全局视角确保了更丰富、全面的理解,庆祝了词与词之间微妙的相互作用。

自注意力是变换器高效性的核心。虽然我们在前面已经提到过这一点,但值得注意的是它在这里的重要性。网络的每一层通过自注意力机制可以与输入数据的其他部分产生共鸣。如图 11.7所示,变换器架构在其编码器和解码器部分都使用自注意力机制,然后这些部分再传递给神经网络(也称为前馈神经网络(FFNNs))。除了更易于训练外,这一架构也促进了 NLP 领域的许多最新突破。

为了说明这一点,考虑《古埃及:埃及历史的迷人概述》,作者比利·韦尔曼。在书中,像拉美西斯和克娄巴特拉这样的早期法老与金字塔建设之间的关系既庞大又复杂。传统模型可能在面对如此庞大的内容时会遇到困难。

为什么变换器如此出色

变换器架构凭借其自注意力机制,成为一个有前景的解决方案。当遇到像“金字塔”这样的术语时,模型可以通过自注意力机制评估它与“拉美西斯”或“克娄巴特拉”等术语的相关性,而不管它们的位置如何。这种对不同输入部分进行关注的能力,展示了变换器在现代 NLP 中的重要性。

一个 Python 代码解析

下面是自注意力机制实现的简化版本:

import numpy as np
def self_attention(Q, K, V):
    """
    Q: Query matrix
    K: Key matrix
    V: Value matrix
    """

    # Calculate the attention weights
    attention_weights = np.matmul(Q, K.T)

    # Apply the softmax to get probabilities
    attention_probs = np.exp(attention_weights) / np.sum(np.exp(attention_weights), axis=1, keepdims=True)

    # Multiply the probabilities with the value matrix to get the output
    output = np.matmul(attention_probs, V)

    return output
# Example
Q = np.array([[1, 0, 1], [0, 2, 0], [1, 1, 0]])  # Example Query
K = np.array([[1, 0, 1], [0, 2, 0], [1, 1, 0]])  # Key matrix
V = np.array([[0, 2, 0], [1, 0, 1], [0, 1, 2]])  # Value matrix
output = self_attention(Q, K, V)
print(output) 

输出:

[[0.09003057 1.57521038 0.57948752]
 [0.86681333 0.14906291 1.10143419]
 [0.4223188  0.73304361 1.26695639]] 

这段代码是一个基本表示,真实的变换器模型采用了更优化且更详细的方法,尤其是在扩展到更大序列时。但本质上是动态加权序列中的不同单词,允许模型引入上下文理解。

理解输出

  • 第一行,[0.09003057 1.57521038 0.57948752],对应查询中第一个单词的 V 矩阵加权组合(在这种情况下,表示为 Q 矩阵的第一行)。这意味着当我们的模型遇到由这个查询表示的单词时,它会将 9%的关注放在第一个单词,57.5%的关注放在第二个单词,57.9%的关注放在第三个单词,从 V 矩阵中提取上下文理解。

  • 第二行,[0.86681333 0.14906291 1.10143419],是查询中第二个单词的注意力结果。它对 V 矩阵中第一个单词、第二个单词和第三个单词的关注度分别为 86.6%、14.9%和 110.1%。

  • 第三行,[0.4223188 0.73304361 1.26695639],对应查询中的第三个单词。它对 V 矩阵中各个单词的注意力权重分别为 42.2%、73.3%和 126.7%。

在回顾了变换器、它们在序列建模中的地位、它们的代码及其输出之后,我们可以考虑 NLP 领域的下一个重大进展。接下来,我们来看一下 LLMs。

LLMs

LLMs是 NLP 领域中继变换器(transformers)之后的下一个进化步骤。它们不仅仅是增强版的旧模型;它们代表了一个飞跃。这些模型可以处理大量文本数据,并执行曾经被认为只有人类大脑才能完成的任务。

简而言之,LLMs 可以生成文本、回答问题,甚至编写代码。想象一下和软件聊天,它像人类一样回复,捕捉微妙的暗示并记得之前对话的内容。这正是 LLMs 所能提供的。

语言模型LMs)一直是自然语言处理(NLP)的支柱,帮助完成从机器翻译到更现代的文本分类等任务。早期的 LMs 依赖于 RNNs 和长短期记忆LSTM)结构,而今天 NLP 的成就主要得益于深度学习技术,特别是 transformer 模型。

LLMs 的标志性特征?它们能够阅读并从大量文本中学习。从零开始训练一个 LLM 是一项艰巨的任务,需要强大的计算机和大量的时间。根据模型的大小和训练数据的量——例如来自像维基百科或 Common Crawl 数据集这样的庞大来源——训练一个 LLM 可能需要几周甚至几个月的时间。

处理长序列是 LLMs 面临的已知挑战。早期基于 RNNs 和 LSTMs 的模型在处理长序列时常常会丢失重要细节,这影响了它们的性能。这时我们开始看到注意力的作用。注意力机制就像一盏手电筒,照亮长输入中的重要部分。例如,在一篇关于汽车进展的文章中,注意力确保模型能够识别并聚焦于主要突破,无论这些突破出现在文本的哪里。

理解 LLMs 中的注意力机制

注意力机制已经成为神经网络领域的基础,尤其在 LLMs 中表现得尤为突出。训练这些庞大的模型,包含数百万甚至数十亿个参数,并非易事。从本质上讲,注意力机制就像高光笔,强调关键细节。例如,在处理一篇关于 NLP 发展的长篇文章时,LLMs 能够理解整体主题,但注意力确保它们不会错过重要的里程碑。transformer 模型利用这一注意力特性,帮助 LLMs 处理庞大的文本片段,并确保上下文的一致性。

对于 LLMs 来说,上下文至关重要。例如,如果一个 LLM 编写了一个以猫为开头的故事,注意力机制确保随着故事的发展,上下文保持一致。因此,故事不会引入像“狗吠声”这样的无关声音,而是自然地倾向于“猫叫声”或“喵喵声”。

训练一个 LLM 就像是连续运行超级计算机数月,仅仅为了处理大量的文本数据。而且,当初始训练完成时,这只是个开始。可以把它想象成拥有一辆高端汽车——你需要定期进行维护。同样,LLMs 也需要基于新数据进行频繁的更新和调整。

即使训练完一个 LLM,工作也远未结束。为了保持这些模型的有效性,它们需要不断学习。想象一下,教某人英语语法规则,然后再加入俚语或习语——他们需要适应这些不规则用法,才能全面理解。

强调一个历史性转折点,2017 年至 2018 年间,LLM(大规模语言模型)领域发生了显著变化。包括 OpenAI 在内的公司开始利用无监督预训练,为情感分析等任务的更简化模型铺平了道路。

探索 NLP 的强大引擎:GPT 和 BERT

通用语言模型微调ULMFiT)为自然语言处理(NLP)开启了新时代。这种方法开创了预训练 LSTM 模型的再利用,并将其适应于多种 NLP 任务,从而节省了计算资源和时间。让我们来分解一下它的过程:

  1. 预训练:这类似于教一个孩子语言的基础。通过使用像 Wikipedia 这样的广泛数据集,模型学习语言的基本结构和语法。可以把它看作是为学生提供通识教材。

  2. 领域适应:模型然后深入到特定领域或类型。如果第一步是学习语法,那么这一步就像是将模型引入不同的文学类型——从惊悚小说到科学期刊。它仍然在预测词汇,但现在是在特定的上下文中进行。

  3. 微调:最后,模型被精细调整,以应对特定任务,例如检测给定文本中的情绪或情感。这相当于训练学生写作论文或深入分析文本。

2018 年 LLM 先驱:GPT 和 BERT

2018 年见证了两款突出模型的崛起:GPT 和 BERT。让我们更详细地了解它们。

生成预训练变换器(GPT)

受 ULMFiT 启发,GPT 是一个依赖于变换器架构解码器部分的模型。想象人类文学的浩瀚。如果传统模型是通过固定的一组书籍来训练,那么 GPT 就像是给学者提供了整个图书馆的访问权限,包括 BookCorpus——一个包含多样化未出版书籍的丰富数据集。这使得 GPT 可以从小说到历史等多种类型中汲取见解。

这里有一个类比:传统模型可能知道莎士比亚剧本的情节。GPT 通过广泛的学习,不仅理解情节,还能掌握文化背景、人物细微之处以及莎士比亚写作风格随时间的演变。

它对解码器的专注使 GPT 成为生成既相关又连贯文本的大师,就像一位经验丰富的作者在起草小说。

BERT(双向编码器表示从变换器)

BERT 通过其“掩蔽语言模型”技术彻底改变了传统的语言建模。与仅预测句子中下一个单词的模型不同,BERT 会填补故意空缺或“掩蔽”的词,从而增强了其对上下文的理解。

让我们来看看这个变化的背景。在像“她去巴黎参观 ___”这样的句子中,传统模型可能会预测符合“the”之后的词,比如“博物馆”。而 BERT 在遇到“她去巴黎参观 masked”时,会试图推断出“masked”应该被“埃菲尔铁塔”替代,理解到巴黎之行的更广泛背景。

BERT 的方法提供了更全面的语言理解,基于前后文捕捉单词的本质,提升了其语言理解能力。

训练大语言模型(LLM)成功的关键在于结合“深度”和“广度”学习架构。可以把“深度”部分看作是专注于某一领域的专家,而“广度”方法则像是通才,了解各个领域的基础知识。

使用深度和广度模型来创建强大的大语言模型(LLMs)

大语言模型的设计非常精细,旨在在一个相对具体的任务上表现出色:预测序列中的下一个词汇。起初看似简单,但为了高精度地完成这一任务,模型往往借鉴了人类学习中的某些方面。

人脑,作为自然界的奇迹,通过识别和抽象周围环境中的常见模式来处理信息。在此基础理解的基础上,人类还通过记忆那些不符合常规模式的特定实例或例外来增强他们的知识。可以将其理解为:首先了解一条规则,然后学习该规则的例外情况。

为了让机器具备这种双层次学习方法,我们需要深思熟虑的机器学习架构。一个初步的方法可能仅仅是基于普遍的模式来训练模型,而忽略了例外情况。然而,要真正做到卓越,尤其是在诸如预测下一个词汇这样的任务中,模型必须能够掌握既能捕捉常见模式,又能识别语言中偶尔出现的独特例外。

虽然大语言模型并非旨在完全模拟人类智能(人类智能是多面的,不仅仅是关于预测序列),但它们确实借鉴了人类的学习策略,以便在其特定任务上变得更加熟练。

大语言模型的设计旨在通过检测大量文本数据中的模式来理解和生成语言。可以考虑以下基本的语言学准则:

  1. 古埃及象形文字提供了一个有趣的例子。在这个早期的书写系统中,一个符号可能代表一个单词、一个声音,甚至是一个概念。例如,虽然一个单一的象形文字可以表示“河流”一词,但多个象形文字的组合可以传达更深的含义,如“生命之源——尼罗河”。

  2. 现在,考虑一下问题是如何形成的。通常,问题的构建始于助动词。然而,间接的询问,例如“我想知道尼罗河今年是否会泛滥”,则偏离了这种常规模式。

为了有效地预测一个序列中的下一个词汇或短语,大语言模型(LLMs)必须掌握既有的语言规范及其偶尔的例外。

表单底部

因此,结合深度和广度模型(图 11.8)已被证明能提升模型在广泛任务上的表现。深度模型的特点是拥有许多隐藏层,能够学习输入与输出之间复杂的关系。

相反,宽度模型旨在学习数据中的简单模式。通过将两者结合,可以同时捕捉复杂关系和简单模式,从而得到更强大、更灵活的模型。

自动生成的网络图示

图 11.8:深度与宽度模型的架构

在训练过程中融入例外对于模型在面对新数据时更好地泛化至关重要。例如,只有在包含某个词义的数据上训练的语言模型,可能在遇到新数据时无法识别该词的其他含义。通过融入例外,模型可以学习识别一个词的多种含义,从而提升其在各种自然语言处理任务中的表现。

深度架构通常用于需要学习数据的复杂层次抽象表示的任务。表现出可泛化模式的特征称为密集特征。当我们使用深度架构来制定规则时,我们称之为通过泛化进行学习。为了构建一个宽深网络,我们将稀疏特征直接连接到输出节点。

在机器学习领域,结合深度和宽度模型已被确定为构建更灵活、更强大的模型的重要方法,这些模型可以同时捕捉数据中的复杂关系和简单模式。深度模型擅长学习数据的复杂层次抽象表示,通过多个隐藏层,每一层处理数据并在不同层次上学习不同的特征。相对而言,宽度模型具有最少的隐藏层,通常用于需要学习数据中简单非线性关系的任务,而不会创建任何抽象层。

这些模式通过稀疏特征来表示。当模型的宽部分具有一个或零个隐藏层时,它可以用来记住示例并制定例外。因此,当宽架构用于制定规则时,我们称之为通过记忆进行学习。

深度和宽度模型可以利用深度神经网络来泛化模式。通常,这部分模型需要大量的时间进行训练。宽度部分以及在实时捕捉这些泛化的所有例外的努力,都是持续算法学习过程的一部分。

总结

在本章中,我们讨论了先进的序列模型,这些技术专门用于处理输入序列,尤其是在输出序列的长度可能与输入序列不同的情况下。自编码器是一种神经网络架构,特别擅长压缩数据。它们通过将输入数据编码成更小的表示,然后再解码回来,以便与原始输入相似。这个过程可以用于图像去噪等任务,在这些任务中,图像中的噪声被过滤掉,以生成更清晰的版本。

另一个有影响力的模型是 Seq2Seq 模型。它被设计用来处理输入和输出序列长度不一致的任务,使其在诸如机器翻译等应用中非常理想。然而,传统的 Seq2Seq 模型面临着信息瓶颈问题,即需要将输入序列的整个上下文捕捉到一个固定大小的表示中。为了解决这个问题,引入了注意力机制,使模型能够动态地聚焦于输入序列的不同部分。论文《Attention is All You Need》中提出的 Transformer 架构正是利用了这一机制,彻底改变了序列数据的处理方式。与其前身不同,Transformer 可以同时关注序列中的所有位置,从而捕捉数据中的复杂关系。这一创新为大型语言模型(LLM)的发展铺平了道路,这些模型因其类人文本生成能力而广受关注。

在下一章,我们将探讨如何使用推荐引擎。

了解更多内容请访问 Discord

要加入本书的 Discord 社区——在这里你可以分享反馈、向作者提问,并了解新版本的发布——请扫描以下二维码:

packt.link/WHLel

第三部分

高级主题

正如标题所示,本节将讨论与算法相关的一些精选高级主题。密码学和大规模算法是本节的关键亮点。我们还将探讨与训练复杂算法所需的大规模基础设施相关的问题。本节的最后一章将探讨在实施算法时应考虑的实际问题。本节包括的章节有:

  • 第十二章推荐引擎

  • 第十三章数据处理的算法策略

  • 第十四章密码学

  • 第十五章大规模算法

  • 第十六章实际考虑事项

第十二章:推荐引擎

我能得到的最好推荐是我自己的才华,和我自己的努力成果,而别人无法为我做的事,我会尽力为自己去做。

—18 至 19 世纪科学家约翰·詹姆斯·奥杜邦

推荐引擎利用可用的用户偏好和物品详细数据,提供量身定制的建议。推荐引擎的核心目标是识别不同物品之间的共性,并理解用户与物品互动的动态。推荐系统不仅仅专注于产品,还考虑任何类型的物品——无论是歌曲、新闻文章还是产品——并根据这些来定制推荐。

本章首先介绍推荐引擎的基础知识。接着,讨论推荐引擎的各种类型。在本章的后续部分,我们将探讨推荐系统的内部工作原理。这些系统能够为用户推荐量身定制的物品或产品,但也面临着挑战。我们将讨论它们的优势和局限性。最后,我们将学习如何使用推荐引擎解决现实世界中的问题。

本章将涵盖以下内容:

  • 推荐引擎概述

  • 不同类型的推荐系统

  • 认识到推荐方法的限制

  • 实际应用领域

  • 一个实际的例子

到本章结束时,你应该能够理解如何利用推荐引擎根据某些偏好标准推荐不同的物品。

让我们从推荐引擎的背景概念开始了解。

介绍推荐系统

推荐系统是强大的工具,最初由研究人员设计,但现在在商业环境中得到广泛应用,能够预测用户可能感兴趣的物品。它们通过提供个性化的物品推荐,使得推荐系统成为一个不可或缺的资产,尤其在数字购物领域中。

当推荐引擎应用于电子商务时,使用复杂的算法来改善购物体验,允许服务提供商根据用户的偏好定制产品。

这些系统的重要性的经典例子是 2009 年的 Netflix 奖挑战。Netflix 为了优化其推荐算法,提供了 100 万美元的奖金,奖励任何能够将现有推荐系统 Cinematch 提升 10%的团队。这个挑战吸引了全球研究人员的参与,BellKor 的 Pragmatic Chaos 团队最终获胜。他们的成就突显了推荐系统在商业领域中至关重要的作用和潜力。关于这个有趣挑战的更多内容,可以在本章中了解。

推荐引擎的类型

我们可以将推荐引擎大致分为三大类:

  • 基于内容的推荐引擎:它们关注物品属性,将一个产品的特征与另一个产品进行匹配。

  • 协同过滤引擎:它们根据用户行为预测偏好。

  • 混合推荐引擎:这是一种融合了基于内容和协同过滤方法优点的推荐引擎,用于优化建议。

在建立了类别之后,让我们逐一深入了解这三种推荐引擎的细节:

基于内容的推荐引擎

基于内容的推荐引擎基于一个简单的原理:它们推荐用户以前互动过的类似物品。这些系统的关键在于准确地衡量物品之间的相似性。

举个例子,想象一下图 12.1中描述的场景:

Diagram, schematic Description automatically generated

图 12.1:基于内容的推荐系统

假设用户 1阅读了文档 1。由于文档之间的相似性,我们可以向用户 1推荐文档 2

这种方法只有在我们能够识别并量化这些相似性时才有效。因此,识别物品之间的相似性对于推荐至关重要。接下来我们将探讨如何量化这些相似性。

确定非结构化文档中的相似性

确定不同文档之间相似性的一种方法是使用共现矩阵,它的前提是经常一起购买的物品可能共享相似性或属于互补类别。

例如,购买剃须刀的人可能还需要购买剃须膏。我们可以通过四个用户的购买习惯数据来解码这一点:

剃须刀 苹果 剃须膏 自行车 鹰嘴豆泥
Mike 1 1 1 0 1
Taylor 1 0 1 1 1
Elena 0 0 0 1 0
Amine 1 0 1 0 0

要构建共现矩阵,请按照以下步骤操作:

  1. 初始化一个NxN矩阵,其中N是物品的数量。该矩阵将存储共现计数。

  2. 对于用户-物品矩阵中的每个用户,通过增加用户与物品对交互的单元格值来更新共现矩阵。

  3. 最终矩阵展示了基于用户交互的物品关联性。

上表的共现矩阵如下所示:

剃须刀 苹果 剃须膏 自行车 鹰嘴豆泥
剃须刀 - 1 3 1 2
Apple 1 - 1 0 1
剃须膏 3 1 - 1 2
自行车 1 0 1 - 1
鹰嘴豆泥 2 1 2 1 -

本矩阵本质上展示了两种物品一起购买的可能性。它是推荐系统中的一种有价值的工具。

协同过滤推荐引擎

协同过滤的推荐基于用户历史购买模式的分析。基本假设是,如果两位用户对大部分相同的项目表现出兴趣,我们可以将这两位用户归为相似用户。换句话说,我们可以假设以下情况:

  • 如果两位用户的购买历史重叠度超过某个阈值,我们可以将他们归类为相似用户。

  • 通过查看相似用户的历史,购买历史中没有重叠的项目将成为协同过滤推荐的基础。

例如,让我们看一个具体的例子。我们有两个用户,User1User2,如下面的图所示:

一张两个人的示意图  描述自动生成

图 12.2:协同过滤推荐引擎

请注意以下几点:

  • User1User2都对完全相同的文档,Doc1Doc2,表现出了兴趣。

  • 根据他们相似的历史模式,我们可以将两者归类为相似用户。

  • 如果User1现在阅读Doc3,我们也可以将Doc3推荐给User2

基于用户历史记录向其推荐项目的策略并不总是有效。让我们更详细地了解与协同过滤相关的问题。

与协同过滤相关的问题

协同过滤涉及的三个潜在问题是:

  1. 由于样本量有限而导致的不准确性

  2. 容易受到孤立分析的影响

  3. 对历史的过度依赖

让我们更详细地了解这些局限性。

由于样本量有限而导致的不准确性

协同过滤系统的准确性和有效性还取决于样本量。例如,如果仅分析三份文档,那么做出准确推荐的可能性就会受到限制。

然而,如果系统拥有数百或数千个文档和交互数据,它的预测能力将变得更加可靠。这就像是基于少量数据点做出预测与拥有全面数据集以进行洞察分析之间的区别。

即使拥有大量数据,协同过滤也并非万无一失。原因在于它完全依赖于用户和项目之间的历史交互,而忽视了任何外部因素。

容易受到孤立分析的影响

协同过滤关注的是用户行为及其与项目互动形成的模式。这意味着它通常会忽视那些可能影响用户选择的外部因素。例如,用户选择某本书可能并非出于个人兴趣,而是因为学术需要或朋友推荐。协同过滤模型无法识别这些细微差别。

对历史的过度依赖

因为系统依赖历史数据,它有时会加强刻板印象,或者跟不上用户不断变化的口味。想象一下,如果一个用户曾经有一段时间喜欢科幻电影,但现在转而喜欢浪漫影片。如果他们过去看了大量的科幻电影,系统可能仍然主要推荐这些电影,而忽视了他们当前的偏好。

从本质上讲,尽管协同过滤在数据量大时非常强大,但理解其固有的局限性非常重要,因为它是通过孤立的操作方式来工作的。

接下来,我们来看看混合推荐引擎。

混合推荐引擎

到目前为止,我们讨论了基于内容和基于协同过滤的推荐引擎。这两种推荐引擎可以结合起来创建一个混合推荐引擎。为此,我们遵循以下步骤:

  1. 生成物品的相似度矩阵。

  2. 生成用户的偏好矩阵。

  3. 生成推荐。

让我们逐步分析这些步骤。

生成物品的相似度矩阵

在混合推荐中,我们首先通过内容推荐创建物品的相似度矩阵。这可以通过使用共现矩阵或任何距离度量来量化物品之间的相似度。

假设我们目前有五个物品。通过基于内容的推荐,我们生成一个矩阵,捕捉物品之间的相似度,如图 12.3所示:

Calendar  Description automatically generated with low confidence

图 12.3:相似度矩阵

让我们看看如何将相似度矩阵与偏好矩阵结合,以生成推荐。

生成用户的参考向量

根据系统中每个用户的历史记录,我们将生成一个捕捉这些用户兴趣的偏好向量。

假设我们要为一家名为KentStreetOnline的在线商店生成推荐,该商店销售 100 种独特的商品。KentStreetOnline非常受欢迎,拥有 100 万活跃订阅者。需要注意的是,我们只需要生成一个尺寸为 100×100 的相似度矩阵。同时,我们还需要为每个用户生成一个偏好向量;这意味着我们需要为 100 万用户生成 100 万个偏好向量。

性能向量的每一项代表了对某个物品的偏好。第一行的值表示Item 1的偏好权重为4。偏好分数并不是购买次数的直接反映,而是一个加权指标,可能考虑浏览历史、过去的购买、物品评分等因素。

一个4的分数可能代表了对Item 1的兴趣和过去的互动,表明用户很可能会喜欢这个物品。

这在图 12.4中有图示:

表格 描述自动生成

图 12.4:用户偏好矩阵

现在,让我们来看看如何基于相似度矩阵S和用户偏好矩阵U生成推荐。

生成推荐

为了进行推荐,我们可以对矩阵进行相乘。用户更可能对与他们给出高评分的物品频繁共现的物品感兴趣:

矩阵[S] × 矩阵[U] = 矩阵[R]

这个计算在图 12.5中以图形方式展示:

表格 描述自动生成

图 12.5:推荐矩阵的生成

为每个用户生成一个单独的结果矩阵。推荐矩阵Matrix[R]中的数字量化了用户对每个项目的预测兴趣。例如,在结果矩阵中,第四个项目的数字最大,达到 58。所以这个项目被强烈推荐给这个特定的用户。

发展推荐系统

推荐系统不是静态的;它们依赖于不断的优化。这个演变是如何发生的呢?通过将推荐的项目(预测)与用户的实际选择进行对比。通过分析差异,系统可以识别出需要改进的地方。随着时间的推移,通过基于用户反馈和观察到的行为进行重新校准,系统提高了推荐的准确性,确保用户始终能收到最相关的建议。

现在,让我们来看看不同推荐系统的局限性。

理解推荐系统的局限性

推荐引擎使用预测算法向一群用户推荐内容。这是一项强大的技术,但我们也应当了解其局限性。让我们来探讨推荐系统的各种限制。

冷启动问题

协同过滤的核心是一个至关重要的依赖性:历史用户数据。没有用户偏好的记录,生成准确的推荐将变得困难。对于一个新加入系统的用户,由于缺乏数据,我们的算法主要基于假设,这可能导致不精确的推荐。同样,在基于内容的推荐系统中,新项目可能缺乏全面的细节,这使得推荐过程不太可靠。这种数据依赖性——需要有一定的用户和项目数据才能产生可靠的推荐——就是所谓的冷启动问题

有几种策略可以应对冷启动问题:

  1. 混合系统:将协同过滤与基于内容的过滤相结合,可以通过利用另一种系统的优势来弥补某个系统的局限性。

  2. 基于知识的推荐:如果历史数据稀缺,依靠关于用户和项目的显式知识可以帮助弥补这一空白。

  3. 新手问卷:对于新用户,可以通过简短的偏好问卷为系统提供初步数据,从而引导早期推荐。

理解并应对这些挑战,确保推荐系统在用户参与策略中始终是一种有效且可靠的工具。

元数据需求

虽然基于内容的推荐系统可以在没有元数据的情况下运行,但纳入这些细节可以提升其精度。值得注意的是,元数据不仅限于文本描述。在我们多元化的数字生态系统中,商品涵盖了各种媒体类型,如图像、音频或电影。对于这些媒体,“内容”可以从其固有属性中提取。例如,图像相关的元数据可能来自视觉模式;音频的元数据可以来自波形或频谱特征;对于电影,可以考虑如类型、演员阵容或场景结构等方面。

将这些多样的内容维度进行整合,可以使推荐系统更加适应,提供跨广泛商品范围的精确建议。

数据稀疏问题

在大量商品中,用户可能只会评价少数商品,从而导致一个非常稀疏的用户/商品评分矩阵。

亚马逊拥有大约十亿用户和十亿个商品。亚马逊的推荐引擎被认为是全球数据最稀疏的推荐引擎之一。

为了应对这种稀疏性,采用了多种技术。例如,矩阵分解方法可以预测这些稀疏区域中的潜在评分,从而提供更完整的用户-项目交互景观。此外,混合推荐系统结合了基于内容的过滤和协同过滤的元素,即使在用户-项目交互有限的情况下,也能生成有意义的推荐。通过整合这些及其他方法,推荐系统能够有效应对和缓解稀疏数据集带来的挑战。

推荐系统中的社交影响力的双刃剑

推荐系统可以受到社交动态的显著影响。事实上,我们的社交圈常常对我们的偏好和选择产生重要影响。例如,朋友们往往做出相似的购买决策,并以相似的方式对产品或服务进行评价。

从积极的方面来看,利用社交连接可以提升推荐的相关性。如果系统发现某个特定社交群体中的个体喜欢某部电影或某个产品,那么推荐同样的商品给群体中的其他成员可能是有意义的。这可能会带来更高的用户满意度,并且有可能提高转化率。

然而,也存在一个缺点。过度依赖社交影响可能会在推荐中引入偏见。它可能会无意间创建回音室效应,让用户只接触到他们社交圈内认可的项目,从而限制了多样性,并可能错过那些更符合个人需求的产品或服务。此外,这还可能导致自我强化的反馈循环,同样的项目不断被推荐,压倒其他潜在的有价值项目。

因此,尽管社交影响力是塑造用户偏好的强大工具,推荐系统仍需要将其与用户个体行为和更广泛的趋势平衡,以确保多样化和个性化的用户体验。

实际应用领域

推荐系统在我们日常的数字互动中扮演着关键角色。为了真正理解它们的重要性,我们将探讨它们在各个行业中的应用。

根据提供的关于 Netflix 使用数据科学及其推荐系统的全面信息,让我们来看一下调整后的陈述,涵盖了这些要点。

Netflix 在数据驱动推荐方面的精通

作为流媒体行业的领导者,Netflix 通过数据分析优化内容推荐,硅谷的 800 名工程师在推动这一努力。它们对数据驱动策略的重视在 Netflix Prize 挑战中得到了体现。获胜团队 BellKor’s Pragmatic Chaos 使用了 107 种不同的算法,从矩阵分解到限制玻尔兹曼机,投入了 2,000 小时的开发时间。

结果是他们的“Cinematch”系统显著提高了 10.06%。这转化为更多的流媒体观看时长、更少的订阅取消,以及 Netflix 的巨大节省。有趣的是,现在大约 75%的用户观看内容是由推荐决定的。Töscher 等人(2009 年)提出了一种有趣的“1 天效应”,暗示共享账户或用户情绪波动可能影响推荐。

尽管这个挑战展示了 Netflix 对数据的承诺,但它也暗示了集成技术在平衡推荐多样性和准确性方面的潜力。

今天,获胜模型的元素仍然是 Netflix 推荐引擎的核心,但随着技术的不断发展,仍有进一步改进的潜力,例如集成强化算法和改进的 A/B 测试。

以下是 Netflix 统计数据的来源:towardsdatascience.com/netflix-recommender-system-a-big-data-case-study-19cfa6d56ff5

亚马逊推荐系统的演变

在 2000 年代初期,亚马逊通过将推荐引擎从基于用户的协同过滤转变为物品与物品之间的协同过滤,彻底改变了其推荐系统。正如林登、史密斯和约克在 2003 年的开创性论文中所详细介绍的,这一策略从基于相似用户推荐产品转变为根据个别产品购买情况推荐相关产品。

这种“相关性”的本质来自于观察到的客户购买模式。如果《哈利·波特》书籍的买家经常购买哈利·波特书签,那么这些物品就被认为是相关的。然而,初始系统存在缺陷。对于高频购买者,推荐不够精细,这促使史密斯和他的团队对算法进行了必要的调整。

快进到几年后——在 2019 年的 re:MARS 大会上,亚马逊强调了其在为 Prime Video 客户提供电影推荐方面的重大进展,实现了两倍的提升。

该技术灵感来源于矩阵补全问题。这种方法涉及将 Prime Video 用户和电影表示在一个网格中,并预测用户观看特定电影的概率。亚马逊随后应用深度神经网络来解决这个矩阵问题,从而实现了更准确和个性化的电影推荐。

未来充满潜力。随着持续的研究和进步,亚马逊团队计划进一步优化和革新推荐算法,不断提升客户体验。

你可以在这里找到亚马逊的统计数据:www.amazon.science/the-history-of-amazons-recommendation-algorithm

现在,让我们尝试使用推荐引擎来解决一个实际问题。

实际案例 – 创建一个推荐引擎

让我们构建一个推荐引擎,能够为一群用户推荐电影。我们将使用由明尼苏达大学 GroupLens 研究小组整理的数据。

1. 搭建框架

我们的第一项任务是确保拥有正确的工具。对于 Python 来说,这意味着导入必要的库:

import pandas as pd
import numpy as np 

2. 数据加载:导入评论和标题

现在,让我们导入 df_reviewsdf_movie_titles 数据集:

df_reviews = pd.read_csv('https://storage.googleapis.com/neurals/data/data/reviews.csv')
df_reviews.head() 

reviews.csv 数据集包含了丰富的用户评论集合。每个条目包含用户的 ID、他们评论过的电影 ID、评分以及评论时间戳。

带有数字和文本的表格 自动生成的描述

图 12.6:reviews.csv 数据集的内容

movies.csv 数据集是电影标题及其详细信息的汇编。每条记录通常包含一个独特的电影 ID、电影标题以及其相关的类别或类别。

计算机截图 自动生成的描述

图 12.7:movies.csv 数据集的内容

3. 合并数据:打造全面视图

从全局角度看,我们需要合并这些数据集。'movieId' 是我们连接它们的桥梁:

df = pd.merge(df_reviews, df_movie_titles, on='movieId')
df.head() 

合并后的数据集应包含以下信息:

一个包含数字和文字的表格,描述自动生成

图 12.8:合并的电影数据

以下是每一列的简要说明:

  • userId:每个用户的唯一标识符。

  • movieId:每部电影的唯一标识符。

  • rating:表示用户对电影的评分,范围从 1 到 5。

  • timestamp:表示特定电影被评分的时间。

  • title:电影的标题

  • genres:与电影相关的类型。

4. 描述性分析:从评分中获取洞察

让我们深入了解数据的核心:评分。一个好的起点是计算每部电影的平均评分。同时,了解评分人数也能提供有关电影受欢迎程度的线索:

df_ratings = pd.DataFrame(df.groupby('title')['rating'].mean())
df_ratings['number_of_ratings'] = df.groupby('title')['rating'].count()
df_ratings.head() 

每部电影的 mean 评分应如下所示:

计算机截图,描述自动生成

图 12.9:计算平均评分

通过这些聚合指标,我们可以识别出评分较高的热门电影,具有大量评分的潜在大片,或者可能有较少评论但平均评分较高的隐藏佳作。

这个基础将为后续步骤铺平道路,我们将深入构建实际的推荐引擎。随着进展,我们对用户偏好的理解将不断深化,从而使我们能够推荐更符合个人口味的电影。

5. 为推荐构建结构:构建矩阵

下一步是将我们的数据集转换为适合推荐的结构。可以将这个结构想象成一个矩阵:

  • 行表示我们的用户(按 userId 索引)

  • 列表示电影标题

  • 矩阵中的单元格填充了评分,显示了用户对特定电影的评价

Pandas 中的 pivot_table 函数是一个多功能工具,帮助重塑或透视 DataFrame 中的数据,以提供总结视图。该函数本质上是从原始表创建一个新的派生表:

movie_matrix = df.pivot_table(index='userId', columns='title', values='rating') 

请注意,前面的代码将生成一个非常稀疏的矩阵。

6. 测试引擎:推荐电影

让我们看看引擎如何工作。假设一个用户刚刚看过 阿凡达(2009)。我们如何找到他们可能喜欢的其他电影呢?

我们的第一个任务是隔离所有评分过 阿凡达(2009)的用户:

avatar_ratings = movie_matrix['Avatar (2009)']
avatar_ratings = avatar_ratings.dropna()
print("\nRatings for 'Avatar (2009)':")
print(avatar_ratings.head()) 
userId
10    2.5
15    3.0
18    4.0
21    4.0
22    3.5
Name: Avatar (2009), dtype: float64 

从前面的代码中,注意以下几点:

  • userId:表示我们数据集中每个用户的唯一标识符。userId 列表包含 1015182122——我们数据快照中前五个评分 阿凡达(2009)的用户。

  • 评分:与每个userId2.53.04.04.03.5)相邻的数字代表这些用户给阿凡达(2009)电影的评分。评分范围为15,其中更高的评分表示用户对电影的评价更高。例如,用户 10阿凡达(2009)的评分是2.5,意味着他们觉得电影一般,甚至略低于预期,而用户 22给出了3.5的评分,表明他们对电影有略高于平均水平的评价。

让我们构建一个推荐引擎,能够为一群用户推荐电影。

寻找与《阿凡达》(2009)相关的电影

通过确定其他电影与阿凡达(2009)的评分模式的相关性,我们可以建议可能吸引阿凡达粉丝的电影。

为了简洁地呈现我们的发现:

similar_to_Avatar=movie_matrix.corrwith(Avatar_user_rating)
corr_Avatar = pd.DataFrame(similar_to_Avatar, columns=['correlation'])
corr_Avatar.dropna(inplace=True)
corr_Avatar = corr_Avatar.join(df_ratings['number_of_ratings'])
corr_Avatar.head() 
 correlation      number_of_ratings
title                                                                
'burbs, The (1989)                    0.353553                     17
(500) Days of Summer (2009)           0.131120                     42
*batteries not included (1987)        0.785714                      7
10 Things I Hate About You (1999)     0.265637                     54 

《公元 10000 年》(2008) -0.075431 理解相关性

较高的相关性(接近 1)表示一部电影的评分模式与阿凡达(2009)相似。负值则表示相反的情况。

然而,必须谨慎处理推荐。例如,电影《电池未 included》(1987)成了阿凡达(2009)粉丝的热门推荐,这看起来并不准确。可能是因为仅依赖用户评分而忽视其他因素(如类型或电影主题)会导致问题。为了更精确的推荐系统,必须进行调整和优化。

最终的表格展示了与阿凡达的用户评分行为相关的电影。我们分析结束时生成的表格按与阿凡达的相关性列出了电影。但是,这到底意味着什么呢?

在这里,相关性指的是一种统计度量,用来解释一组数据相对于另一组数据的变化关系。具体来说,我们使用了皮尔逊相关系数,它的取值范围是从-1 到 1:

  • 1:完美正相关。这意味着如果阿凡达收到某用户的高评分,那么同一用户给的其他电影也会获得高评分。

  • -1:完美负相关。如果用户给阿凡达打了高分,那么同一用户给的其他电影评分会很低。

  • 0:无相关性。阿凡达与其他电影的评分彼此独立。

在我们的电影推荐情境中,与阿凡达相关性较高(接近 1)的电影被认为更适合推荐给喜欢阿凡达的用户。这是因为这些电影展示了与阿凡达相似的评分模式。

通过检查表格,你可以识别出哪些电影的评分行为与阿凡达类似,因此它们可能成为阿凡达粉丝的潜在推荐。

这意味着我们可以将这些电影作为推荐项提供给用户。

评估模型

测试和评估至关重要。评估我们模型的一种方法是使用诸如训练-测试分割等方法,将部分数据留作测试。然后将模型在测试集上的推荐与实际用户评分进行比较。像平均绝对误差MAE)或均方根误差RMSE)等指标可以量化这些差异。

随着时间的推移重新训练:融入用户反馈

用户偏好会不断变化。定期用新数据重新训练推荐模型,确保其推荐结果始终相关。引入反馈循环,让用户对推荐进行评分或评论,进一步提升模型的准确性。

总结

本章我们学习了推荐引擎。我们研究了如何根据我们要解决的问题选择合适的推荐引擎。我们还探讨了如何为推荐引擎准备数据,以创建相似度矩阵。我们还学习了推荐引擎如何用于解决实际问题,例如根据用户过去的行为模式推荐电影。

在下一章中,我们将重点介绍用于理解和处理数据的算法。

在 Discord 上了解更多

要加入本书的 Discord 社区——在这里你可以分享反馈、向作者提问以及了解新版本——请扫描下面的二维码:

packt.link/WHLel

第十三章:数据处理的算法策略

数据是数字经济的新石油。

—《连线杂志》

在这个数据驱动的时代,从大量数据集中提取有意义的信息的能力正在从根本上塑造我们的决策过程。我们在本书中探讨的算法在很大程度上依赖于数据。因此,开发旨在创建强大且高效的数据存储基础设施的工具、方法和战略计划变得尤为重要。

本章的重点是以数据为中心的算法,用于高效地管理数据。这些算法的核心操作包括高效存储和数据压缩。通过运用这些方法,以数据为中心的架构能够实现数据管理和高效的资源利用。通过本章的学习,你应该能够很好地理解设计和实施各种数据中心算法时涉及的概念和权衡。

本章讨论以下概念:

  • 数据算法简介

  • 数据分类

  • 数据存储算法

  • 数据压缩算法

让我们首先介绍基本概念。

数据算法简介

数据算法专门用于管理和优化数据存储。除了存储,它们还处理数据压缩等任务,确保高效的存储空间利用率,并简化快速的数据检索,这在许多应用中至关重要。

理解数据算法,特别是在分布式系统中,关键的一点是 CAP 定理。它的重要性在于:该定理阐明了在一致性、可用性和分区容忍性之间的平衡。在任何分布式系统中,同时实现这三者中的两项保障是我们所能期望的。理解 CAP 的微妙之处有助于识别现代数据算法中的挑战和设计决策。

在数据治理的范围内,这些算法是非常宝贵的。它们确保分布式系统中所有节点的数据一致性,从而保证数据的完整性。它们还确保数据的高效可用性,并管理数据分区容忍度,从而增强系统的弹性和安全性。

CAP 定理在数据算法中的重要性

CAP 定理不仅设定了理论上的极限,它在现实场景中也具有实际的意义,尤其是在数据被操作、存储和检索的过程中。例如,假设一个算法必须从分布式系统中检索数据。关于一致性、可用性和分区容忍性的选择会直接影响该算法的效率和可靠性。如果一个系统优先考虑可用性,数据可能很容易被检索,但可能不是最新的版本。相反,优先考虑一致性的系统可能会延迟数据检索,以确保访问的始终是最新的数据。

我们在这里讨论的数据中心算法在许多方面都受到这些 CAP 约束的影响。通过将我们对 CAP 定理的理解与数据算法相结合,我们可以在处理数据挑战时做出更明智的决策。

分布式环境中的存储

单节点架构适用于较小的数据集。然而,随着数据集规模的激增,分布式环境存储已成为大规模问题的标准解决方案。在此类环境中,确定合适的数据存储策略取决于多个因素,包括数据的性质和预期的使用模式。

CAP 定理为开发这些存储策略提供了一个基础性原理,帮助我们应对与管理庞大数据集相关的挑战。

连接 CAP 定理与数据压缩

起初,CAP 定理与数据压缩似乎没有什么重叠。但考虑到实际的影响,如果我们在系统中优先考虑一致性(按照 CAP 的考虑),那么我们的数据压缩方法需要确保数据在所有节点间始终保持一致的压缩状态。在一个以可用性为优先的系统中,即便这可能导致轻微的不一致,压缩方法可能会为了速度进行优化。这种相互作用表明,我们在 CAP 方面的选择甚至会影响到数据的压缩和检索方式,展示了定理在数据中心算法中的广泛影响。

展示 CAP 定理

1998 年,Eric Brewer 提出了一个定理,后来被称为 CAP 定理。它突出了设计分布式服务系统中涉及的各种权衡。为了理解 CAP 定理,首先,我们需要定义分布式服务系统的以下三个特性:一致性、可用性和分区容忍性。CAP 实际上是由这三种特性组成的首字母缩写:

  • 一致性(或简称C):分布式服务由多个节点组成。任何一个节点都可以用来读取、写入或更新数据仓库中的记录。一致性保证了在某个特定时间 t1,无论我们使用哪个节点来读取数据,都能得到相同的结果。每次读取操作要么返回最新的数据(在分布式数据仓库中保持一致),要么返回错误信息。

  • 可用性(或简称A):在分布式系统中,可用性意味着系统整体始终对请求作出响应。这确保了用户每次查询系统时都会得到回复,即使这可能不是最新的数据。因此,重点不在于每个节点是否都保持最新,而在于整个系统的响应能力。它保证了即使系统的某些部分包含过时的信息,用户的请求也永远不会没有回复。

  • 分区容忍性(简称P):在分布式系统中,多个节点通过通信网络连接。分区容忍性保证在发生部分节点(一个或多个)之间的通信故障时,系统仍然能够正常运行。需要注意的是,为了保证分区容忍性,数据需要在足够数量的节点之间进行复制。

利用这三个特性,CAP 定理仔细总结了分布式服务系统架构和设计中涉及的权衡。具体来说,CAP 定理指出,在分布式存储系统中,我们只能拥有以下三个特性中的两个:一致性或C,可用性或A,分区容忍性或P

这一点在下面的图表中得以展示:

图表,维恩图 描述自动生成

图 13.1:在分布式系统中可视化选择:CAP 定理

分布式数据存储正日益成为现代 IT 基础设施的重要组成部分。设计分布式数据存储时应根据数据的特性和我们要解决的问题的需求来仔细考虑。当应用于分布式数据库时,CAP 定理有助于指导设计和决策过程,确保开发人员和架构师理解在创建分布式数据库系统时涉及的基本权衡和限制。平衡这三个特性对于实现分布式数据库系统的期望性能、可靠性和可扩展性至关重要。当应用于分布式数据库时,CAP 定理有助于指导设计和决策过程,确保开发人员和架构师理解基本的权衡。平衡这三个特性在实现分布式数据库系统的期望性能、可靠性和可扩展性方面至关重要。在 CAP 定理的背景下,我们可以假设有三种类型的分布式存储系统:

  • CA系统(实现一致性-可用性)

  • AP系统(实现可用性-分区容忍性)

  • CP系统(实现一致性-分区容忍性)

将数据存储分类为CAAPCP系统有助于我们理解在设计数据存储系统时涉及的各种权衡。

让我们逐一了解它们。

CA 系统

传统的单节点系统是 CA 系统。在非分布式系统中,分区容忍性不是一个问题,因为无需管理多个节点之间的通信。因此,这些系统可以专注于同时维护一致性和可用性。换句话说,它们是 CA 系统。

一个系统可以通过在单个节点或服务器上存储和处理数据来实现没有分区容错的功能。虽然这种方法可能不适用于处理大规模数据集或高速数据流,但对于较小的数据规模或对性能要求不高的应用来说,它是有效的。

传统的单节点数据库,如 Oracle 或 MySQL,是 CA 系统的典型例子。这些系统非常适合数据量和数据流速相对较低且分区容错不是关键因素的使用场景。例子包括中小型企业、学术项目或具有有限用户和数据源的应用。

AP 系统

AP 系统是设计用来优先考虑可用性和分区容错的分布式存储系统,即使需要牺牲一致性。这些高响应系统可以在必要时牺牲一致性,以适应高速数据流。在这样做的过程中,这些分布式存储系统能够立即处理用户请求,即使这会导致不同节点间暂时提供稍微过时或不一致的数据。

当 AP 系统牺牲一致性时,用户可能会偶尔获取稍微过时的信息。在某些情况下,这种临时的不一致性是可以接受的,因为能够快速处理用户请求并保持高可用性被认为比严格的数据一致性更为关键。

典型的 AP 系统用于实时监控系统,如传感器网络。高速度的分布式数据库,如 Cassandra,是 AP 系统的典型例子。

在需要高可用性、响应性和分区容错的场景中,推荐使用 AP 系统来实现分布式数据存储。

例如,如果加拿大交通运输部希望通过在渥太华高速公路上不同位置安装的传感器网络来监控交通状况,那么 AP 系统将是首选。在这种情况下,优先考虑实时数据处理和可用性对于确保交通监控能够有效运行至关重要,即使存在网络分区或临时的不一致性。因此,尽管可能会牺牲一致性,AP 系统仍然是这种应用的推荐选择。

CP 系统

CP 系统优先考虑一致性和分区容错,确保分布式存储系统在读取过程中获取值之前保证一致性。这些系统专门设计用于维护数据一致性,并在存在网络分区的情况下继续有效运行。

CP 系统的理想数据类型是那些需要严格一致性和准确性的数据,即使这意味着牺牲系统的即时可用性。例子包括财务交易、库存管理和关键业务操作数据。在这些情况下,确保数据在分布式环境中的一致性和准确性至关重要。

CP 系统的典型使用案例是我们想要以 JSON 格式存储文档文件。像 MongoDB 这样的文档数据存储是为分布式环境中的一致性而调整的 CP 系统。

通过了解不同类型的分布式存储系统,我们现在可以继续探索数据压缩算法。

解码数据压缩算法

数据压缩是用于数据存储的重要方法。它不仅提高了存储效率,减少了数据传输时间,而且在成本节省和性能提升方面具有重要意义,特别是在大数据和云计算领域。本节介绍了详细的数据压缩技术,特别关注无损算法哈夫曼和 LZ77,以及它们对现代压缩方案(如 Gzip、LZO 和 Snappy)的影响。

无损压缩技术

无损压缩围绕着消除数据中的冗余,以减少存储需求,同时确保完美的可逆性。哈夫曼和 LZ77 是两个基础算法,它们在这一领域产生了深远影响。

哈夫曼编码侧重于可变长度编码,使用较少的位表示频繁出现的字符,而 LZ77 是一种基于字典的算法,利用重复的数据序列并用较短的引用表示它们。让我们一一来看。

哈夫曼编码:实现可变长度编码

哈夫曼编码,一种熵编码形式,广泛应用于无损数据压缩。哈夫曼编码的基本原理是为频繁出现的字符分配较短的编码,从而减少整体数据大小。

该算法使用一种特定类型的二叉树,称为哈夫曼树,其中每个叶子节点对应一个数据元素。元素出现的频率决定了在树中的位置:频繁出现的元素更靠近树根。这种策略确保最常见的元素具有最短的编码。

一个简单的示例

假设我们有包含字母ABC的数据,其频率分别为5912。在哈夫曼编码中:

  • C,最常见的,可能会得到像0这样的短编码。

  • B,下一个最常见的,可能会得到10

  • A,最不常见的,可能会得到11

为了全面理解,我们来通过一个 Python 示例。

在 Python 中实现哈夫曼编码

我们通过为每个字符创建一个节点开始,其中节点包含字符及其频率。然后,将这些节点添加到优先队列中,频率最低的元素具有最高优先级。为此,我们创建一个Node类来表示霍夫曼树中的每个字符。每个Node对象包含字符、其频率,以及指向其左子节点和右子节点的指针。__lt__方法用于根据频率比较两个Node对象。

import functools
@functools.total_ordering
class Node:
    def __init__(self, char, freq):
        self.char = char
        self.freq = freq
        self.left = None
        self.right = None
    def __lt__(self, other):
        return self.freq < other.freq
    def __eq__(self, other):
        return self.freq == other.freq 

接下来,我们构建霍夫曼树。构建霍夫曼树涉及在优先队列中进行一系列的插入和删除操作,通常实现为二叉堆。为了构建霍夫曼树,我们创建一个Node对象的最小堆。最小堆是一种特殊的树状结构,满足一个简单而重要的条件:父节点的值小于或等于其子节点的值。这个属性确保最小的元素始终位于根节点,使得优先操作更加高效。我们反复弹出两个频率最低的节点,将它们合并,并将合并后的节点推回堆中。这个过程持续进行,直到只剩下一个节点,它成为霍夫曼树的根节点。树的构建通过以下定义的build_tree函数来实现:

import heapq
def build_tree(frequencies):
    heap = [Node(char, freq) for char, freq in frequencies.items()]
    heapq.heapify(heap)
    while len(heap) > 1:
        node1 = heapq.heappop(heap)
        node2 = heapq.heappop(heap)
        merged = Node(None, node1.freq + node2.freq)
        merged.left = node1
        merged.right = node2
        heapq.heappush(heap, merged)
    return heap[0]  # the root node 

一旦霍夫曼树构建完成,我们可以通过遍历树来生成霍夫曼编码。从根节点开始,每走一条左分支就附加一个0,每走一条右分支就附加一个1。当我们到达一个叶子节点时,沿着从根到该叶子节点路径上累积的01序列就是该叶子节点对应字符的霍夫曼编码。这个功能通过如下定义的generate_codes函数来实现。

def generate_codes(node, code='', codes=None):
    if codes is None:
        codes = {}
    if node is None:
        return {}
    if node.char is not None:
        codes[node.char] = code
        return codes
    generate_codes(node.left, code + '0', codes)
    generate_codes(node.right, code + '1', codes)
    return codes 

现在让我们使用霍夫曼树。我们首先定义用于霍夫曼编码的数据。

data = {
    'L': 0.45,
    'M': 0.13,
    'N': 0.12,
    'X': 0.16,
    'Y': 0.09,
    'Z': 0.05
} 

然后,我们打印出每个字符的霍夫曼编码。

# Build the Huffman tree and generate the Huffman codes
root = build_tree(data)
codes = generate_codes(root)
# Print the root of the Huffman tree
print(f'Root of the Huffman tree: {root}')
# Print out the Huffman codes
for char, code in codes.items():
    print(f'{char}: {code}') 
Root of the Huffman tree: <__main__.Node object at 0x7a537d66d240>
L: 0
M: 101
N: 100
X: 111
Y: 1101
Z: 1100 

现在,我们可以推断出以下内容:

  • 固定长度编码:此表的固定长度编码为3。这是因为,对于六个字符,固定长度的二进制表示需要最多三个位(2³ = 8 种可能的组合,可以涵盖我们的 6 个字符)。

  • 可变长度编码:此表的可变长度编码为

    45(1) + .13(3) + .12(3) + .16(3) + .09(4) + .05(4) = 2.24.

以下图示显示了从前述示例创建的霍夫曼树:

图示 自动生成的描述

图 13.2:霍夫曼树:可视化压缩过程

请注意,霍夫曼编码是将数据转换为霍夫曼树,从而实现压缩。解码或解压缩则将数据恢复为原始格式。

在了解了霍夫曼编码后,我们接下来将探索另一种基于字典的无损压缩技术。

接下来,我们讨论基于字典的压缩。

理解基于字典的压缩 LZ77

LZ77 属于一种称为字典编码器的压缩算法家族。与霍夫曼编码通过维持静态的代码词典不同,LZ77 会动态构建一个输入数据中已出现的子字符串字典。这个字典不会单独存储,而是通过一个滑动窗口隐式引用已经编码的输入数据,从而提供一种优雅且高效的表示重复序列的方法。

LZ77 算法的原理是将重复的数据替换为指向单一副本的引用。它保持一个“滑动窗口”,用于处理最近的数据。当它遇到已经出现过的子字符串时,它不会存储实际的子字符串;而是存储一对值——指向重复子字符串开始位置的距离,以及重复子字符串的长度。

通过一个示例来理解

想象一下,你正在阅读以下字符串:

data_string = "ABABCABABD"

当你从左到右处理这个字符串时,当遇到子字符串 "CABAB" 时,你会注意到 "ABAB" 之前已经出现过,紧接着最初的 "AB" 后面。LZ77 利用了这种重复现象。

LZ77 不会再写 "ABAB",它会建议:“嘿,回溯两个字符并复制接下来的两个字符!”从技术角度讲,这是指回溯两个字符并复制两个字符。

所以,使用 LZ77 压缩我们的 data_string,它可能看起来像这样:

ABABC<2,2>D

这里,<2,2> 是 LZ77 的符号表示,意味着“回溯两个字符并复制接下来的两个字符”。

与霍夫曼的比较

为了更好地理解 LZ77 和霍夫曼之间的强大功能和差异,使用相同的数据是有帮助的。让我们继续使用 data_string = "ABABCABABD"

虽然 LZ77 识别数据中的重复序列并加以引用,但霍夫曼编码更多的是将频繁出现的字符表示为更短的编码。

例如,如果你使用霍夫曼算法压缩我们的 data_string,你可能会看到一些字符,比如 'A' 和 'B',它们出现频率较高,会用比较少出现的 'C' 和 'D' 更短的二进制代码表示。

这个比较展示了,尽管霍夫曼编码基于频率来表示字符,但 LZ77 则是通过识别和引用模式来进行压缩。根据数据的类型和结构,某一种可能比另一种更高效。

高级无损压缩格式

由霍夫曼和 LZ77 提出的原则催生了高级压缩格式。本章将探讨三种高级格式。

  1. LZO

  2. Snappy

  3. gzip

让我们一一来看它们。

LZO 压缩:优先考虑速度

LZO 是一种无损数据压缩算法,强调快速的压缩和解压缩。它将重复的数据替换为指向单一副本的引用。在经过这一次 LZ77 压缩后,数据会被传递到霍夫曼编码阶段。

尽管其压缩比可能不是最高的,但其处理速度明显快于许多其他算法。这使得 LZO 成为在实时数据处理和流媒体应用等需要快速数据访问的场景中非常理想的选择。

Snappy 压缩:寻求平衡

Snappy 是另一个由 Google 最初开发的快速压缩和解压缩库。Snappy 的主要关注点是实现高速和合理的压缩,但不一定是最大压缩比。

Snappy 的压缩方法基于 LZ77,但侧重于速度,并且没有像霍夫曼编码那样的额外熵编码步骤。相反,Snappy 使用了一种更简单的编码算法,确保压缩和解压缩过程的快速执行。该算法采用基于复制的策略,寻找数据中重复的序列,并将其编码为长度和对先前位置的引用。

应该注意,由于这种速度上的权衡,Snappy 的数据压缩效率不如使用霍夫曼编码或其他形式的熵编码的算法。然而,在速度比压缩比更为关键的使用场景中,Snappy 可以是一个非常有效的选择。

GZIP 压缩:最大化存储效率

GZIP 是一种文件格式和软件应用程序,用于文件压缩和解压缩。GZIP 数据格式结合了 LZ77 算法和霍夫曼编码。

实际示例:AWS 中的数据管理:聚焦于 CAP 定理和压缩算法

让我们考虑一个全球电子商务平台的例子,该平台运行在全球多个云服务器上。这个平台每秒处理成千上万的交易,来自这些交易的数据需要高效地存储和处理。我们将看到 CAP 定理和压缩算法如何引导平台数据管理系统的设计。

1. 应用 CAP 定理

CAP 定理指出,分布式数据存储不能同时提供以下三种保证中的两种:一致性、可用性和分区容错性。

在我们的电子商务平台场景中,可用性和分区容错性可能会被优先考虑。高可用性确保即使一些服务器发生故障,系统仍能继续处理交易。分区容错性意味着即使网络故障导致某些服务器被隔离,系统仍然可以继续运行。

虽然这意味着系统可能无法始终提供强一致性(每次读取都获得最新的写入),但它可以使用最终一致性(更新通过系统传播,最终所有副本显示相同的值)来确保良好的用户体验。在实践中,轻微的不一致是可以接受的,例如,当用户的购物车在所有设备上更新需要几秒钟时。

在 AWS 生态系统中,我们有多种数据存储服务可供选择,可以根据 CAP 定理定义的需求进行选择。对于我们的电商平台,我们更倾向于选择可用性和分区容忍度,而非一致性。亚马逊 DynamoDB 作为一款键值型 NoSQL 数据库,十分契合这一需求。它内建支持多区域复制和自动分片,确保高可用性和分区容忍度。

为了保持一致性,DynamoDB 提供了“最终一致性”和“强一致性”选项。在我们的案例中,我们会选择最终一致性,以优先考虑可用性和性能。

2. 使用压缩算法

平台会生成大量的数据,包括交易详情、用户行为日志和产品信息。存储和传输这些数据可能会既昂贵又耗时。

在这里,像 gzip、Snappy 或 LZO 这样的压缩算法可以提供帮助。例如,平台可能会使用 gzip 来压缩那些被归档存储的交易日志。考虑到 gzip 通常能将文本文件压缩到原始大小的约 30%,这可以显著降低存储成本。

另一方面,对于用户行为数据的实时分析,平台可能会使用 Snappy 或 LZO。虽然这些算法的压缩比可能不如 gzip,但它们更快速,能够让分析系统更快地处理数据。

AWS 提供了多种实现压缩的方法,具体取决于数据的类型和使用场景。对于长时间存储的交易日志,我们可以使用亚马逊 S3(简单存储服务)结合 gzip 压缩。S3 支持在上传文件时自动进行 gzip 压缩,这可以显著降低存储成本。对于用户行为数据的实时分析,我们可以使用亚马逊 Kinesis 数据流结合 Snappy 或 LZO 压缩。Kinesis 可以捕获、处理并存储数据流以进行实时分析,并支持压缩以处理大容量数据。

3. 量化效益

其效益的量化方式与前述类似。

让我们通过一个实际的例子来演示潜在的成本节省。假设我们的平台每天生成 1 TB 的交易日志。通过利用 gzip 压缩与 S3,我们可能将存储需求缩小到大约 300 GB。截至 2023 年 8 月,S3 对前 50 TB 每月收费约为每 GB $0.023。算一下,这每月可以节省约 $485,年节省约 $5,820,仅从日志存储方面来看。值得注意的是,引用的 AWS 定价仅供参考,具体数据可能因 2023 年 8 月而异,使用时请务必查看最新的定价。

使用 Snappy 或 LZO 与 Kinesis 进行实时分析可以提高数据处理速度。这可能会导致更及时和个性化的用户推荐,进而有可能增加销售额。财务收益可以根据通过提高推荐速度所带来的转化率提升来计算。

最后,通过使用 DynamoDB 并遵循 CAP 定理,我们可以确保即使在发生网络分区或单个服务器故障的情况下,用户仍能享受到流畅的购物体验。这一选择的价值可以体现在平台的用户留存率和整体客户满意度上。

总结

在本章中,我们探讨了以数据为中心的算法设计,重点关注三个关键组成部分:数据存储、数据治理和数据压缩。我们研究了与数据治理相关的各种问题。我们分析了数据的不同属性如何影响数据存储的架构决策。我们探讨了不同的数据压缩算法,每种算法在效率和性能方面提供了特定的优势。在下一章中,我们将研究密码学算法。我们将了解如何利用这些算法的力量来确保交换和存储的信息安全。

在 Discord 上了解更多

要加入本书的 Discord 社区——在这里你可以分享反馈、向作者提问,并了解新版本的发布——请扫描下面的二维码:

packt.link/WHLel

第十四章:密码学

我把我未写的诗隐藏在面部的密码中!

—乔治·艾略特

本章将向你介绍与密码学相关的算法。我们将首先介绍背景知识,然后讨论对称加密算法。接着,我们将解释消息摘要 5MD5)算法和安全哈希算法SHA),并展示对称算法的局限性和弱点。然后,我们将讨论非对称加密算法,以及它们如何用于创建数字证书。最后,我们将呈现一个实际示例,总结所有这些技术。

到本章结束时,你将对与密码学相关的各种问题有一个基本的理解。

本章讨论了以下主题:

  • 密码学简介

  • 理解密码学技术的种类

  • 示例 – 部署机器学习模型时的安全问题

让我们从基础概念开始。

密码学简介

保护秘密的技术已经存在了几个世纪。最早尝试保护和隐藏数据以防敌人窃取可以追溯到古埃及在纪念碑上发现的古老铭文,其中使用了一种只有少数可信人员才知道的特殊字母。这种早期的安全形式叫做“模糊性”,至今仍以不同形式使用。为了使这种方法有效,保护秘密是至关重要的,在上述例子中就是保护字母的秘密含义。后来,在第一次世界大战和第二次世界大战中,找到可靠的保护重要信息的方法变得尤为重要。进入 20 世纪后期,随着电子技术和计算机的出现,发展出了一些复杂的算法来保护数据,这促成了密码学这一新领域的出现。本章讨论了密码学的算法方面。密码学算法的一种用途是允许两个过程或用户之间进行安全的数据交换。密码算法通过使用数学函数来确保既定的安全目标。

首先,我们将看一下基础设施中“最弱链条”的重要性。

理解最弱链条的重要性

有时候,在设计数字基础设施的安全性时,我们过于强调单个实体的安全,而没有足够关注端到端的安全性。这可能导致我们忽视系统中的某些漏洞和脆弱点,后来这些漏洞可能被黑客利用,从而访问敏感数据。需要记住的重要一点是,一个数字基础设施整体的强度取决于它最薄弱的环节。对于黑客来说,这个最弱的环节可能为他们提供进入数字基础设施的后门,访问敏感数据。超过某个点之后,如果没有关闭所有后门,再加固前门就没有太大的意义。

随着用于保护数字基础设施的算法和技术变得越来越复杂,攻击者也不断升级他们的技术。我们始终需要记住,攻击者攻破数字基础设施的最简单方法之一,就是通过利用这些漏洞来访问敏感信息。

2014 年,对加拿大一个联邦研究机构——国家研究委员会NRC)的网络攻击估计造成了数亿美元的损失。攻击者能够窃取数十年的研究数据和知识产权材料。他们利用了 Web 服务器上使用的 Apache 软件中的一个漏洞,成功获得了敏感数据。

本章将重点讲解各种加密算法的漏洞。

让我们首先来看一下使用的基本术语。

基本术语

让我们来看一下与密码学相关的基本术语:

  • 密码:执行特定密码学功能的算法。

  • 明文:原始数据,可以是文本文件、视频、位图或数字化的语音。在本章中,我们将明文表示为 P

  • 密文:在对明文应用密码学后得到的加密文本。在本章中,我们将其表示为 C

  • 密码套件:一组或一套密码学软件组件。当两个独立的节点希望使用密码学交换信息时,他们首先需要就密码套件达成一致。这一点对于确保他们使用完全相同的密码学功能实现非常重要。

  • 加密:将明文 P 转换为密文 C 的过程称为加密。从数学上讲,它表示为 encrypt(P) = C

  • 解密:将密文转换回明文的过程。数学上,它表示为 decrypt(C) = P

  • 密码分析:分析密码算法强度的方法。分析者试图在没有密钥的情况下恢复明文。

  • 个人身份信息PII):PII 是指那些可以单独或与其他相关数据一起用于追溯个人身份的信息。举例来说,保护性信息如社会安全号码、出生日期或母亲的娘家姓。

让我们首先了解系统的安全需求。

理解安全需求

了解一个系统的确切安全需求非常重要。理解这一点将帮助我们使用正确的密码学技术,并发现系统中的潜在漏洞。

更好地理解系统安全需求的一种方法是通过回答以下四个问题:

  • 哪些个人或流程需要受到保护?

  • 我们保护这些个人和流程免受哪些威胁?

  • 我们应该在哪些地方进行保护?

  • 我们为什么要保护这些信息?

让我们以 AWS 云中的虚拟私有云VPC)为例。VPC 允许我们创建一个逻辑隔离的网络,在其中添加虚拟机等资源。为了理解 VPC 的安全要求,首先需要通过回答以下四个问题来识别身份:

  • 有多少人计划使用这个系统?

  • 需要保护的是什么样的信息?

  • 我们是只保护 VPC,还是需要将消息加密并传递给系统,再与 VPC 进行通信?

  • 数据的安全分类是什么?潜在的风险有哪些?为什么有人会有动机尝试攻击系统?

这些问题的大多数答案将通过执行以下三个步骤来获得:

  1. 识别实体。

  2. 建立安全目标。

  3. 理解数据的敏感性。

让我们逐一看看这些步骤。

第 1 步:识别实体

实体可以定义为个人、过程或信息系统中的资源。我们首先需要识别在运行时用户、资源和过程如何存在。然后,我们将量化这些识别出的实体的安全需求,既可以单独考虑,也可以作为一个整体来考虑。

一旦我们更好地理解了这些要求,就可以建立我们数字系统的安全目标。

第 2 步:建立安全目标

设计安全系统的目标是保护信息免受盗窃、破坏或攻击。通常使用加密算法来实现一个或多个安全目标:

  • 认证:认证是我们确认用户、设备或系统身份的机制,确保他们确实是他们所声称的身份。

  • 授权:授权是指给予用户访问特定资源或功能的权限的过程。

  • 机密性:需要保护的数据被称为敏感数据。机密性是指将敏感数据限制给授权用户的概念。为了在数据传输或存储过程中保护敏感数据的机密性,需要将数据加密,使其只有授权用户才能读取。这个过程是通过使用加密算法来实现的,我们将在本章稍后讨论这些算法。

  • 完整性:完整性是指在数据传输或存储过程中,确保数据未被篡改的过程。例如,TCP/IP传输控制协议/互联网协议)使用校验和或循环冗余检查CRC)算法来验证数据完整性。

  • 不可否认性:不可否认性是指能够提供不可伪造且无可辩驳的证据,证明一条消息已被发送或接收。这些证据可以在之后用来证明数据的接收。

第 3 步:理解数据的敏感性

理解数据的机密性质非常重要。数据由监管机构如政府、机构或组织根据其泄露后的后果的严重程度进行分类。数据的分类有助于我们选择正确的加密算法。根据数据所包含信息的敏感性,数据的分类方法不止一种。让我们来看看数据分类的典型方式:

  • 公开数据或未分类数据:任何可供公众消费的数据,例如公司网站或政府信息门户网站上的信息。

  • 内部数据或机密数据:虽然不供公众消费,但将这些数据公开可能不会产生严重后果。例如,如果一个员工抱怨经理的电子邮件被公开,可能会让公司感到尴尬,但这可能不会带来严重后果。

  • 敏感数据或机密数据:不应公开的数据显示,公开这些数据可能会对个人或组织造成严重后果。例如,泄露未来 iPhone 的细节可能会损害苹果的商业目标,并且可能给竞争对手,如三星,带来优势。

  • 高度敏感数据:也称为绝密数据。如果这些信息被泄露,可能会对组织造成极大损害。高度敏感数据的例子包括专有研究、战略商业计划或内部财务数据。

绝密数据通过多层安全保护,需要特别的权限才能访问。

通常,更复杂的安全设计比简单的算法更慢。重要的是在安全性和系统性能之间找到正确的平衡。

理解密码的基本设计

设计密码是制定一个算法,将敏感数据进行混淆,以便恶意进程或未经授权的用户无法访问。尽管随着时间的推移,密码变得越来越复杂,但密码所基于的基本原理依然保持不变。

让我们从一些相对简单的密码开始,帮助我们理解设计加密算法时使用的基本原理。

介绍替代密码

替代密码已经使用了几百年,并且以不同形式出现。顾名思义,替代密码基于一个简单的概念——将明文中的字符按照预定的、组织好的方式替换为其他字符。

让我们看看这个过程的具体步骤:

  1. 首先,我们将每个字符映射到一个替代字符。

  2. 然后,我们通过使用替代映射将明文中的每个字符替换为密码文本中的另一个字符,从而对明文进行编码和转换成密码文本。

  3. 为了解码,我们通过使用替代映射将明文还原回来。

以下是基于替换的密码示例:

  • 凯撒密码

  • 旋转 13

让我们更详细地研究一下它们。

凯撒密码

凯撒密码基于替换映射。替换映射通过应用一个保密的简单公式,以确定性方式改变实际字符串。

替换映射是通过将每个字符替换为它右边第三个字符来创建的。这个映射在下图中进行了说明:

图示  自动生成的描述

图 13.1:凯撒密码的替换映射

让我们看看如何用 Python 实现凯撒密码:

rotation = 3
P = 'CALM'; C=''
for letter in P:
    C = C+ (chr(ord(letter) + rotation)) 

我们可以看到我们对明文 CALM 应用了凯撒密码。

让我们在加密明文后打印出密文:

print(C) 
FDOP 

据说凯撒密码曾被尤利乌斯·凯撒用来与他的顾问通信。

凯撒密码是一种简单的密码,且易于实现。缺点是它并不难破解,因为黑客可以简单地通过遍历字母表的所有可能位移(共 2626 种)来查看是否出现任何连贯的消息。鉴于现代计算机的处理能力,这个组合数是相对较小的。因此,它不应被用来保护高度敏感的数据。

旋转 13 (ROT13)

ROT13 是凯撒密码的一种特殊情况,其中替换映射是通过将每个字符替换为它右边第 13 个字符来创建的。下图演示了这一点:

一个包含矩形的图片  自动生成的描述

图 14.2:ROT13 的工作原理

这意味着如果 ROT13() 是实现 ROT13 的函数,那么以下内容适用:

rotation = 13
P = 'CALM'; C=''
for letter in P:
    C = C+ (chr(ord(letter) + rotation)) 

现在,让我们打印 C 的编码值:

print(c) 
PNYZ 

ROT13 实际上并不是用来实现数据保密的。它更多是用来掩盖文本,例如隐藏可能令人反感的文本。它也可以用来避免泄露谜题的答案,以及其他类似的使用场景。

替换密码的密码分析

替换密码易于实现和理解。不幸的是,它们也容易破解。对替换密码的简单密码分析表明,如果我们使用英语字母表,那么破解密码所需做的就是确定我们旋转了多少。我们可以一一尝试英语字母表中的每个字母,直到我们能够解密文本为止。这意味着需要大约 25 次尝试来恢复明文。

现在,让我们看一下另一种简单的密码——换位密码。

理解换位密码

在换位密码中,明文的字符通过换位加密。换位是一种加密方法,我们通过使用确定性逻辑将字符的位置打乱。换位密码将字符写入矩阵中的行,然后按列读取作为输出。让我们来看一个例子。

让我们来看一下 Ottawa Rocks 的明文(P)。

首先,让我们对 P 进行编码。为此,我们将使用一个 3 x 4 的矩阵,并水平书写明文:

O t t a
w a R o
c k s

read 过程将会垂直读取字符串,这将生成密文——OwctaktRsao。密钥将是 {1,2,3,4},表示列的读取顺序。使用不同的密钥加密,比如 {2,4,3,1},将会得到不同的密文,此时为 takaotRsOwc

德国人在第一次世界大战中使用了一种名为 ADFGVX 的密码,这种密码结合了换位和替代密码。几年后,这个密码被乔治·潘文破解。

所以,这些是一些常见的密码。通常,密码使用密钥来加密明文。现在,让我们看看一些目前使用的密码学技术。密码学通过加密和解密过程来保护信息,这在下一节中会进一步讨论。

理解密码学技术的类型

不同类型的密码学技术使用不同的算法,并在不同的情况下应用。由于不同的情况和使用场景对安全性有不同的需求,且这些需求依据业务要求和数据分类的不同而有所差异,因此选择合适的技术对于一个设计良好的架构至关重要。

广义上,密码学技术可以分为以下三种类型:

  • 哈希

  • 对称

  • 非对称

让我们逐一来看。

使用密码学哈希函数

密码学哈希函数是一种数学算法,可以用来创建消息的唯一指纹。它从明文生成一个输出,称为哈希。输出的大小通常是固定的,但某些特定算法的输出大小可能会有所不同。

从数学角度来看,这样表示:

C[1] = hashFunction(P[1])

这可以解释为:

  • P[1] 是表示输入数据的明文

  • C[1] 是由密码学哈希函数生成的固定长度哈希

这在以下图表中有所显示。通过单向哈希函数,变长的数据被转换为固定长度的哈希:

图表 说明自动生成

图 14.3:单向哈希函数

哈希函数是一种数学算法,它将任意数量的数据转换为固定大小的字节串。它在确保数据的完整性和真实性方面起着至关重要的作用。以下是定义密码学哈希函数的关键特征:

  • 确定性:哈希函数是确定性的,这意味着相同的输入(或“明文”)总是会产生相同的输出(或“哈希”)。无论你对某一数据进行多少次哈希,结果将始终保持一致。

  • 唯一性:理想情况下,不同的输入应始终产生唯一的哈希输出。如果两个不同的输入产生相同的哈希,这被称为碰撞。优质的哈希函数旨在尽量减少碰撞的可能性。

  • 固定长度:哈希函数的输出具有固定的长度,无论输入数据的大小如何。无论你是在哈希一个字符还是整个小说,结果哈希的大小都将相同,且特定于所使用的哈希算法(例如,MD5 为 128 位,SHA-256 为 256 位)。

  • 对输入变化敏感:即使在明文中进行微小的修改,也会导致结果哈希值发生显著且不可预测的变化。这一特性确保了无法推导出原始输入,也无法找到一个不同的输入产生相同的哈希,从而增强了哈希函数的安全性。其效果是,即使在大文档中改变一个字母,也会导致哈希值看起来完全不同。

  • 单向函数:哈希函数是单向的,意味着计算上不可行地逆转这个过程,从哈希(C[1])生成原始明文(P[1])。这确保了即使未经授权的方获得了哈希值,他们也无法用它来确定原始数据。

如果我们遇到每个唯一的消息没有唯一的哈希值的情况,我们称之为碰撞。换句话说,碰撞是指哈希算法对两个不同的输入值产生相同的哈希值。对于安全应用程序,碰撞是一种潜在的漏洞,其概率应该非常低。也就是说,如果我们有两个文本,P1 和 P2,在碰撞的情况下,意味着hashFunction(P[1]) = hashFunction(P[2])

无论使用何种哈希算法,碰撞是罕见的。否则,哈希就不会有用。然而,对于某些应用程序来说,碰撞是不能容忍的。在这些情况下,我们需要使用一种更复杂的哈希算法,但它生成碰撞的可能性要小得多。

实现加密哈希函数

加密哈希函数可以通过使用各种算法来实现。让我们深入了解其中的两种:

  1. MD5

  2. 安全哈希算法 (SHA)

理解 MD5 容忍性

MD5 由 Poul-Henning Kamp 于 1994 年开发,用于替代 MD4。它生成一个 128 位的哈希值。生成一个 128 位的哈希值意味着结果哈希值由 128 个二进制数字(位)组成。

这意味着固定长度为 16 字节或 32 个十六进制字符。固定长度确保了无论原始数据的大小如何,哈希值始终为 128 位长。这个固定长度输出的目的是为了创建原始数据的“指纹”或“摘要”。MD5 是一个相对简单的算法,但它容易受到碰撞攻击。在无法容忍碰撞的应用场景中,不应使用 MD5。例如,它可以用于检查从互联网下载文件的完整性。

让我们来看一个示例。为了在 Python 中生成 MD5 哈希值,我们将从使用hashlib模块开始,hashlib是 Python 标准库的一部分,提供多种不同的加密哈希算法:

import hashlib 

接下来,我们定义一个名为generate_md5_hash()的工具函数,该函数以input_string作为参数。该字符串将被该函数哈希处理:

def generate_md5_hash(input_string):
    # Create a new md5 hash object
    md5_hash = hashlib.md5()
    # Encode the input string to bytes and hash it
    md5_hash.update(input_string.encode())
    # Return the hexadecimal representation of the hash
    return md5_hash.hexdigest() 

请注意,hashlib.md5()会创建一个新的哈希对象。这个对象使用 MD5 算法,md5_hash.update(input_string.encode())会用输入字符串的字节更新哈希对象。该字符串会使用默认的 UTF-8 编码转换为字节。所有数据更新到哈希对象后,我们可以调用hexdigest()方法返回摘要的十六进制表示。这就是输入字符串的 MD5 哈希值。

在这里,我们使用generate_md5_hash()函数来获取字符串"Hello, World!"的 MD5 哈希值,并将结果打印到控制台:

def verify_md5_hash(input_string, correct_hash):
    # Generate md5 hash for the input_string
    computed_hash = generate_md5_hash(input_string)
    # Compare the computed hash with the provided hash
    return computed_hash == correct_hash
# Test
input_string = "Hello, World!"
hash_value = generate_md5_hash(input_string)
print(f"Generated hash: {hash_value}")
correct_hash = hash_value
print(verify_md5_hash(input_string, correct_hash))# This should return True 
Generated hash: 65a8e27d8879283831b664bd8b7f0ad4
True 

verify_md5_hash函数中,我们接受一个输入字符串和一个已知的正确 MD5 哈希值。我们使用generate_md5_hash函数生成输入字符串的 MD5 哈希值,并将其与已知的正确哈希值进行比较。

何时使用 MD5

回顾历史,MD5 的弱点是在 1990 年代末被发现的。尽管存在一些问题,MD5 的使用仍然很普遍。它非常适合用于数据的完整性检查。请注意,MD5 消息摘要并未唯一地将哈希值与其所有者关联,因为 MD5 摘要不是一个签名哈希。MD5 用于证明自哈希计算以来,文件没有被更改。它并不用于证明文件的真实性。现在,我们来看看另一种哈希算法——SHA。

理解安全哈希算法(SHA)

SHA 是由美国国家标准与技术研究院NIST)开发的。它被广泛用于验证数据的完整性。在其变种中,SHA-512 是一种流行的哈希函数,Python 的hashlib库中包括了它。让我们看看如何使用 Python 创建一个使用 SHA 算法的哈希值。为此,我们首先需要导入hashlib库:

import hashlib 

然后我们将定义盐值和消息。加盐是将随机字符添加到密码中以进行哈希的做法。它通过使哈希碰撞变得更加困难来增强安全性:

salt = "qIo0foX5"
password = "myPassword" 

接下来,我们将把盐值与密码结合,应用加盐过程:

salted_password = salt + password 

接下来,我们将使用sha512函数来生成盐值密码的哈希值:

sha512_hash = hashlib.sha512()
sha512_hash.update(salted_password.encode())
myHash = sha512_hash.hexdigest() 

让我们打印myHash

myHash 
2e367911b87b12f73b135b1a4af9fac193a8064d3c0a52e34b3a52a5422beed2b6276eabf9
5abe728f91ba61ef93175e5bac9a643b54967363ffab0b35133563 

请注意,当我们使用 SHA 算法时,生成的哈希值为 512 字节。这个特定的大小并非随意,而是算法安全性特征的关键组成部分。更大的哈希大小对应着更多的潜在组合,从而减少了“碰撞”的概率——即两个不同的输入产生相同的哈希输出。碰撞会损害哈希算法的可靠性,而 SHA-512 的 512 字节输出大大降低了这一风险。

密码哈希函数的应用

哈希函数用于在复制文件后检查文件的完整性。为此,当文件从源复制到目标(例如从 Web 服务器下载时),相应的哈希也会一并复制。这个原始哈希值horiginal作为原文件的指纹。在复制文件后,我们从复制版本的文件生成哈希——即hcopied。如果horiginal = hcopied——即生成的哈希与原始哈希匹配——这就验证了文件没有发生变化,下载过程中没有丢失任何数据。我们可以使用任何密码哈希函数,如 MD5 或 SHA,来生成哈希值。

在 MD5 和 SHA 之间选择

MD5 和 SHA 都是哈希算法。MD5 简单且快速,但它的安全性较差。相比 MD5,SHA 更复杂,提供了更高的安全性。

现在,让我们看看对称加密。

使用对称加密

在密码学中,密钥是用来通过选择的算法对明文进行编码的一组数字。在对称加密中,我们使用相同的密钥进行加密和解密。如果用于对称加密的密钥是K,那么对于对称加密,以下公式成立:

EK(P) = C

在这里,P是明文,C是密文。

对于解密,我们使用相同的密钥K,将其转换回P

DK(C) = P

这个过程如图所示:

图示 说明自动生成

图 14.4:对称加密

现在,让我们看看如何使用 Python 进行对称加密。

编码对称加密

在本节中,我们将探讨如何使用 Python 内置的hashlib库处理哈希函数。hashlib是 Python 的预装库,提供了多种哈希算法。首先,我们导入hashlib库:

import hashlib 

我们将使用 SHA-256 算法来创建我们的哈希值。其他算法如 MD5、SHA-1 等也可以使用:

sha256_hash = hashlib.sha256() 

让我们为消息"Ottawa is really cold"创建一个哈希值:

message = "Ottawa is really cold".encode()
sha256_hash.update(message) 

哈希的十六进制表示可以通过以下方式打印出来:

print(sha256_hash.hexdigest()) 
b6ee63a201c4505f1f50ff92b7fe9d9e881b57292c00a3244008b76d0e026161 

让我们看看对称加密的一些优点。

对称加密的优点

以下是对称加密的优点:

  • 简单:使用对称加密进行加密和解密实现起来更简单。

  • 快速:对称加密比非对称加密更快。

  • 安全:美国政府指定的最广泛使用的对称密钥加密系统是高级加密标准AES)。当使用像 AES 这样的安全算法时,对称加密至少和非对称加密一样安全。

对称加密的问题

当两个用户或进程计划使用对称加密进行通信时,他们需要通过安全通道交换密钥。这就产生了以下两个问题:

  • 密钥保护:如何保护对称加密密钥

  • 密钥分发:如何通过安全通道从源头到目的地共享对称加密密钥

现在,让我们来看看非对称加密。

非对称加密

在 1970 年代,非对称加密被发明出来,以解决我们在上一节讨论的对称加密的一些弱点。

非对称加密的第一步是生成两个看起来完全不同但在算法上有关联的密钥。一个被选为私钥,Kpr,另一个被选为公钥,Kpu。选择哪一个密钥作为公钥或私钥是任意的。数学上,我们可以表示为:

EKpr(P) = C

这里,P是明文,C是密文。

我们可以如下解密:

DKpu(C) = P

公钥应该是可以自由分发的,而私钥则由密钥对的拥有者保密。例如,在 AWS 中,密钥对用于确保与虚拟实例的连接安全,并管理加密资源。公钥由他人用来加密数据或验证签名,而私钥则由拥有者安全存储,用于解密数据或签署数字内容。通过遵循将私钥保密、公钥可访问的原则,AWS 用户可以确保云环境中的通信安全和数据完整性。公钥和私钥的分离是 AWS 及其他云服务中安全性和信任机制的基石。

基本原理是,如果你用其中一个密钥加密,唯一的解密方法就是使用另一个密钥。例如,如果我们用公钥加密数据,那么我们需要用另一个密钥——即私钥——来解密。

现在,让我们来看一下非对称加密的一个基本协议——安全套接字层SSL)/传输层安全TLS)握手协议——它负责通过非对称加密在两个节点之间建立连接。

SSL/TLS 握手算法

SSL 最初是为了为 HTTP 添加安全性而开发的。随着时间的推移,SSL 被一个更高效、更安全的协议所替代,称为 TLS。TLS 握手是 HTTP 如何创建安全通信会话的基础。TLS 握手发生在两个参与实体之间——客户端和服务器。此过程如下图所示:

Diagram  Description automatically generated

图 14.5:客户端和服务器之间的安全会话

TLS 握手在参与节点之间建立安全连接。以下是此过程中涉及的步骤:

  1. 客户端向服务器发送一个client hello消息。该消息还包含以下内容:

    • 使用的 TLS 版本

    • 客户端支持的密码套件列表

    • 压缩算法

    • 一个随机字节串,标识为byte_client

  2. 服务器向客户端发送一个server hello消息。该消息还包含以下内容:

    • 由服务器从客户端提供的列表中选择的密码套件。

    • 会话 ID。

    • 一个随机字节串,标识为byte_server

    • 服务器数字证书,标识为cert_server,包含服务器的公钥。

    • 如果服务器需要数字证书来进行客户端身份验证或请求客户端证书,客户端-服务器请求还包括以下内容:

      • 可接受 CA 的区分名称

      • 支持的证书类型

    • 客户端验证cert_server

    • 客户端生成一个随机字节串,标识为byte_client2,并使用通过cert_server提供的服务器公钥加密。

    • 客户端生成一个随机字节串,并使用其私钥对其进行标识和加密。

    • 服务器验证客户端证书。

    • 客户端向服务器发送一个finished消息,该消息使用秘密密钥进行加密。

    • 为了从服务器端确认这一点,服务器向客户端发送一个finished消息,该消息使用秘密密钥进行加密。

    • 服务器和客户端现在已经建立了一个安全通道。它们现在可以交换使用共享秘密密钥对称加密的消息。整个方法论如下所示:

Chart, timeline  Description automatically generated

图 14.6:客户端和服务器之间的安全会话

现在,让我们讨论如何使用非对称加密来创建公钥基础设施PKI),该基础设施旨在满足组织的一个或多个安全目标。

公钥基础设施

非对称加密用于实现公钥基础设施(PKI)。PKI 是管理组织加密密钥的最流行且可靠的方法之一。所有参与者信任一个称为证书授权机构CA)的中央信任机构。CA 验证个人和组织的身份,然后向他们颁发数字证书(数字证书包含个人或组织的公钥及其身份),验证与该个人或组织关联的公钥确实属于该个人或组织。

它的工作原理是,证书授权机构(CA)要求用户证明其身份。基本验证称为域验证,这可能仅仅是验证域名的所有权。扩展验证(如有需要)涉及更严格的流程,需要根据用户尝试获得的数字证书类型提供身份的物理证明。如果 CA 确认用户确实是他们所声称的人,用户将通过安全通道向 CA 提供他们的公钥。

CA 利用这些信息创建包含用户身份和公钥的数字证书。该证书由 CA 进行数字签名。证书是公开的实体,因为用户可以将其证书展示给任何需要验证其身份的人,而无需通过安全通道传送,因为证书本身不包含任何敏感信息。接收证书的人不需要直接验证用户的身份。那个人只需通过验证 CA 的数字签名来验证证书是否有效,从而确认证书中包含的公钥确实属于证书上所列的个人或组织。

组织的 CA 私钥是 PKI 信任链中的最弱环节。如果冒充者掌握了微软的私钥,例如,他们可以通过冒充 Windows 更新在全球数百万台计算机上安装恶意软件。

区块链与加密学

毫无疑问,近年来区块链和加密货币引起了大量关注。区块链被认为是有史以来最安全的技术之一。关于区块链的热潮始于比特币和数字货币。数字货币最早在 1980 年被开发,但随着比特币的出现,它们开始走向主流。比特币的崛起归功于分布式系统的广泛应用。它有两个重要特点,使其成为游戏规则的改变者:

  1. 它在设计上是去中心化的。它利用了一种矿工网络和一种被称为区块链的分布式算法。

  2. 比特币基于矿工为了将区块添加到区块链而竞争的固有激励机制,通过尝试解答非常复杂的计算难题。获胜的矿工有资格要求不同比特币作为他们努力的奖励。

尽管区块链最初是为比特币开发的,但它已经找到了更广泛的用途和应用。区块链基于分布式共识算法,使用分布式账本技术DLT)。它具有以下特点:

  • 去中心化:它基于分布式而非集中式架构。没有中央机构。区块链系统中的每个节点都参与维护 DLT 的完整性。所有参与节点之间存在共识。在这种分布式架构中,交易存储在组成节点的节点上,形成 P2P 网络。

    注意,“P2P”术语代表“点对点”,这意味着网络中的每个节点或“对等方”直接与其他节点通信,而无需经过中央服务器或机构。

  • 链状形成:所有区块链的交易都累积在一个区块列表中。当添加多个区块时,它形成链状结构,这也是其名称“区块链”的原因。

  • 不可变性:数据是安全的,复制的,并以不可变的区块存储。

  • 可靠性:每笔交易都维护了一个血统或历史。使用密码学技术验证和记录每笔交易。

在底层,区块链交易使用链中每个先前区块的加密哈希。哈希函数用于创建任意数据块的单向指纹。默克尔树或哈希树用于验证存储、处理和在不同参与节点之间传输的数据。它使用 SHA-2 进行哈希。下面显示了一个特定交易的图示:

一个带有标签的井号的图示 自动生成的描述

图 14.7:区块链的默克尔树

图 13.7总结了区块链的工作原理。它显示了如何将交易转换为区块,然后再转换为链。左侧显示了四笔交易,A、B、C 和 D。接下来,通过应用哈希函数创建了默克尔根。默克尔根可以视为区块头的一部分数据结构。由于交易是不可改变的,先前记录的交易不能被更改。

注意,前一个区块头的哈希值也成为区块的一部分,从而整合交易记录。这创建了链状处理结构,并是“区块链”名称的原因。

每个区块链用户通过加密技术进行身份验证和授权,从而消除了对第三方身份验证和授权的需求。数字签名也用于确保交易的安全性。交易的接收者拥有一个公钥。区块链技术消除了第三方参与交易验证,依赖加密证明来实现这一点。交易通过数字签名来确保安全。每个用户都有一个唯一的私钥,在系统中建立其数字身份。

示例:部署机器学习模型时的安全性问题

第六章《无监督机器学习算法》中,我们讨论了跨行业数据挖掘标准过程CRISP-DM)生命周期,该生命周期定义了训练和部署机器学习模型的不同阶段。一旦模型训练并评估完成,最终阶段就是部署。如果是关键性的机器学习模型,我们希望确保其所有安全目标都得到满足。

让我们分析在部署这样的模型时面临的常见挑战,并讨论如何利用本章中讨论的概念来解决这些挑战。我们将讨论保护训练模型免受以下三大挑战的策略:

  • 中间人攻击MITM

  • 冒充

  • 数据篡改

让我们逐一分析它们。

中间人攻击

我们希望保护模型免受的一种可能攻击是中间人攻击(MITM)。中间人攻击发生在入侵者试图窃听本应私密的通信时。

让我们通过一个示例场景来逐步理解中间人攻击。

假设 Bob 和 Alice 想通过公钥基础设施(PKI)交换信息:

  1. Bob 使用{Pr[Bob], Pu[Bob]},而 Alice 使用{Pr[Alice], Pu[Alice]}。Bob 创建了消息M[Bob],Alice 创建了消息M[Alice]。他们希望以安全的方式相互交换这些消息。

  2. 最初,他们需要交换公钥,以建立彼此之间的安全连接。这意味着 Bob 使用Pu[Alice]来加密M[Bob],然后将消息发送给 Alice。

  3. 假设我们有一个窃听者,通常称为 Eve X,Eve X 使用{Pr[X], Pu[X]}。攻击者能够拦截 Bob 和 Alice 之间的公钥交换,并将其替换为自己的公钥证书。

  4. Bob 将M[Bob]发送给 Alice,用Pu[X]加密,而不是用Pu[Alice],错误地认为这是 Alice 的公钥证书。窃听者X拦截了这次通信。它拦截了M[Bob]消息,并使用Pr[Bob]解密它。

这个中间人攻击在以下图表中展示:

图表描述自动生成

图 14.8:中间人攻击

现在,让我们看一下如何防止中间人攻击。

如何防止中间人攻击(MITM)

让我们探讨如何通过引入 CA(证书授权中心)来防止 MITM 攻击。假设这个 CA 的名称是 myTrustCA。数字证书中嵌入了它的公钥,名为PumyTrustCA。myTrustCA 负责为组织中的所有人签发证书,包括 Alice 和 Bob。这意味着 Bob 和 Alice 的证书都由 myTrustCA 签署。在签发证书时,myTrustCA 验证他们确实是他们所声称的身份。

现在,随着这一新安排的到位,让我们重新审视 Bob 和 Alice 之间的顺序交互:

  1. Bob 使用 {Pr[Bob], Pu[Bob]},Alice 使用 {Pr[Alice], Pu[Alice]}。他们的公钥都嵌入在由 myTrustCA 签署的数字证书中。Bob 创建了一个消息* M[Bob],Alice 创建了一个消息 M*[Alice]。他们希望以安全的方式交换这些消息。

  2. 他们交换包含公钥的数字证书。只有当公钥嵌入在由他们信任的 CA 签署的证书中时,他们才会接受这些公钥。他们需要交换公钥以建立安全连接。这意味着 Bob 将使用Pu[Alice]来加密M[Bob],然后将消息发送给 Alice。

  3. 假设我们有一个窃听者 X,他使用 {Pr[X], Pu[X]}。攻击者能够拦截 Bob 和 Alice 之间的公钥交换,并用自己公钥证书Pu[X]替换它们。

  4. Bob 拒绝了X的尝试,因为坏人的数字证书没有被 Bob 信任的 CA 签署。安全握手被中止,攻击尝试的时间戳和所有细节被记录,并触发了安全异常。

在部署训练好的机器学习模型时,替代 Alice 的是一个部署服务器。Bob 在建立安全通道后才部署模型,使用之前提到的步骤。

避免伪装

攻击者 X 伪装成授权用户 Bob,获得对敏感数据的访问权限,在这个案例中是已训练的模型。我们需要保护模型不受未经授权的更改。

保护训练模型免受伪装的一种方式是使用授权用户的私钥加密模型。加密后,任何人都可以通过授权用户的公钥解密模型并使用它,而该公钥可以在他们的数字证书中找到。没有人能够对模型进行未经授权的更改。

数据和模型加密

一旦模型部署完成,作为输入提供给模型的实时无标签数据也可能被篡改。训练好的模型用于推理,并为这些数据提供标签。为了防止数据被篡改,我们需要保护静态数据和传输中的数据。为了保护静态数据,可以使用对称加密对其进行编码。

为了传输数据,可以建立基于 SSL/TLS 的安全通道来提供一个安全隧道。这个安全隧道可以用来传输对称密钥,数据可以在服务器上解密,然后再提供给训练好的模型。

这是保护数据不被篡改的更高效且万无一失的方法之一。

对称加密也可以用来加密训练好的模型,在将其部署到服务器之前。这将防止在模型部署之前任何未经授权的访问。

让我们看看如何使用对称加密加密源端的训练模型,按照以下步骤操作,然后在目标端解密它并使用:

  1. 让我们首先使用 Iris 数据集训练一个简单的模型:

    import pickle
    from joblib import dump, load
    from sklearn.linear_model import LogisticRegression
    from sklearn.model_selection import train_test_split
    from sklearn.datasets import load_iris
    from cryptography.fernet import Fernet
    iris = load_iris()
    X = iris.data
    y = iris.target
    X_train, X_test, y_train, y_test = train_test_split(X, y)
    model = LogisticRegression(max_iter=1000)  # Increase max_iter for convergence
    model.fit(X_train, y_train) 
    
  2. 现在,我们定义将存储模型的文件名:

    filename_source = "unencrypted_model.pkl"
    filename_destination = "decrypted_model.pkl"
    filename_sec = "encrypted_model.pkl" 
    
  3. 请注意,filename_source是存储源端训练未加密模型的文件,filename_destination是存储目标端训练未加密模型的文件,而filename_sec是加密的训练模型。

  4. 我们将使用pickle将训练好的模型存储在文件中:

    from joblib import dump
    dump(model, filename_source) 
    
  5. 我们定义一个名为write_key()的函数,它将生成一个对称密钥并将其存储在名为key.key的文件中:

    def write_key():
         key = Fernet.generate_key()
         with open("key.key", "wb") as key_file:
             key_file.write(key) 
    
  6. 现在,让我们定义一个名为load_key()的函数,它可以从key.key文件中读取存储的密钥:

    def load_key():
        return open("key.key", "rb").read() 
    
  7. 接下来,我们定义一个encrypt()函数,它可以加密并训练模型,并将其存储在名为filename_sec的文件中:

    def encrypt(filename, key):
         f = Fernet(key)
         with open(filename,"rb") as file:         
             file_data = file.read() 
         encrypted_data = f.encrypt(file_data)
         with open(filename_sec,"wb") as file:
             file.write(encrypted_data) 
    
  8. 我们将使用这些函数来生成一个对称密钥并将其存储在一个文件中。然后,我们将读取这个密钥并使用它将训练好的模型存储在名为filename_sec的文件中:

    write_key()
    key = load_key()
    encrypt(filename_source, key) 
    

现在模型已经加密。它将被传输到目标端,在那里它将用于预测:

  1. 首先,我们定义一个名为decrypt()的函数,可以用来使用存储在key.key文件中的密钥将模型从filename_sec解密到filename_destination

    def decrypt(filename, key):
        f = Fernet(key)
        with open(filename, "rb") as file:
            encrypted_data = file.read()        
        decrypted_data = f.decrypt(encrypted_data)
        with open(filename_destination, "wb") as file:
            file.write(decrypted_data) 
    
  2. 现在让我们使用这个函数来解密模型并将其存储在名为filename_destination的文件中:

    decrypt(filename_sec, key) 
    
  3. 现在让我们使用这个未加密的文件来加载模型并用它进行预测:

    loaded model = load(filename_destination)
    result = loaded_model.score(X_test, y_test)
    print(result) 
    
    0.9473684210526315 
    

请注意,我们使用了对称加密来编码模型。如果需要,相同的技术也可以用来加密数据。

总结

在本章中,我们学习了加密算法。我们首先确定了一个问题的安全目标。然后我们讨论了各种加密技术,还深入了解了公钥基础设施(PKI)的细节。最后,我们看了如何保护训练好的机器学习模型免受常见攻击。现在,你应该能够理解用于保护现代 IT 基础设施的安全算法的基础。

在下一章中,我们将研究设计大规模算法。我们将学习设计和选择大算法时面临的挑战和权衡。我们还将探讨使用 GPU 和集群来解决复杂问题。

在 Discord 上了解更多

要加入本书的 Discord 社区——在这里你可以分享反馈、向作者提问并了解新版本——请扫描下面的二维码:

packt.link/WHLel

第十五章:大规模算法

大规模算法专门设计用于解决庞大且复杂的问题。它们的特点在于需要多个执行引擎来应对大量的数据和处理需求。此类算法的例子包括大语言模型LLMs),如 ChatGPT,它们需要分布式模型训练来应对深度学习固有的巨大计算需求。此类复杂算法的资源密集型特性突显了强大并行处理技术的必要性,这对于训练模型至关重要。

本章将从介绍大规模算法的概念开始,然后讨论支撑它们所需的高效基础设施。此外,我们还将探索管理多资源处理的各种策略。在本章中,我们将研究由 Amdahl 定律提出的并行处理局限性,并探讨图形处理单元GPUs)的使用。完成本章后,您将掌握设计大规模算法所需的基本策略,并打下坚实的基础。

本章涉及的主题包括:

  • 大规模算法简介

  • 大规模算法的高效基础设施

  • 多资源处理策略

  • 利用集群/云的力量运行大规模算法

让我们从简介开始。

大规模算法简介

在历史上,人类一直在解决复杂问题,从预测蝗虫群体的位置到发现最大的质数。我们的好奇心和决心推动了问题解决方法的不断创新。计算机的发明是这一历程中的一个关键时刻,使我们能够处理复杂的算法和计算。如今,计算机使我们能够以惊人的速度和精确度处理海量数据集、执行复杂计算并模拟各种场景。

然而,随着我们遇到越来越复杂的挑战,单台计算机的资源往往不足以应对。这时,大规模算法便派上了用场,它们利用多台计算机共同工作的强大计算力。大规模算法设计是计算机科学中的一个动态且广泛的领域,致力于创建和分析能够高效利用多台机器计算资源的算法。这些大规模算法支持两种类型的计算——分布式计算和并行计算。在分布式计算中,我们将一个任务拆分到多台计算机上,每台计算机处理任务的一部分,最后将结果汇总。可以把它想象成组装一辆汽车:不同的工人处理不同的部件,但共同完成整辆车的组装。相比之下,并行计算则是多台处理器同时执行多个任务,类似于流水线,每个工人同时进行不同的工作。

LLM,如 OpenAI 的 GPT-4,在这个广阔的领域中占据着至关重要的地位,因为它们代表了一种大规模算法的形式。LLM 旨在通过处理大量数据并识别语言中的模式来理解和生成类似人类的文本。然而,训练这些模型是一项重负荷的任务。它涉及处理数十亿,甚至数万亿的数据单元,称为“标记”(tokens)。这个训练过程包含需要逐步完成的步骤,比如准备数据,也有一些步骤可以同时进行,比如确定模型不同层次所需的变化。

这项工作并非夸大其词。由于其规模庞大,通常的做法是同时使用多台计算机来训练 LLM。我们称这些为“分布式系统”。这些系统使用多个 GPU——这些是计算机中负责重负载工作的部件,用于创建图像或处理数据。更准确地说,LLM 几乎总是在多台计算机协同工作来训练单一模型的情况下进行训练。

在这种背景下,我们首先要描述一个设计良好的大规模算法,它能够充分利用现代计算基础设施的潜力,如云计算、集群和 GPU/TPU。

描述大规模算法的高效基础设施

为了高效运行大规模算法,我们需要高性能的系统,因为这些系统被设计成通过增加计算资源来处理更大的工作负载,从而分配处理任务。水平扩展是实现分布式系统可扩展性的关键技术,它使系统能够通过将任务分配给多个资源来扩展其处理能力。这些资源通常是硬件(如 中央处理单元CPU)或 GPU)或软件元素(如内存、磁盘空间或网络带宽),系统可以利用这些资源执行任务。为了让可扩展的系统高效地满足计算需求,它应具备弹性和负载均衡,以下部分将详细讨论这一点。

弹性

弹性是指基础设施根据变化的需求动态扩展资源的能力。实现这一特性的常见方法是自动扩展,这是云计算平台(如 Amazon Web ServicesAWS))中常见的策略。在云计算的背景下,服务器组是由一组虚拟服务器或实例组成,这些服务器或实例经过编排协同工作以处理特定的工作负载。这些服务器组可以组织成集群,以提供高可用性、容错能力和负载均衡。组内的每台服务器可以配置特定的资源,如 CPU、内存和存储,以优化执行预定任务的性能。自动扩展允许服务器组通过修改运行中的节点(虚拟服务器)数量来适应波动的需求。在弹性系统中,可以通过增加资源(横向扩展)来应对需求增加,同样,当需求减少时,也可以释放资源(横向缩减)。这种动态调整能有效利用资源,帮助在性能需求与成本效益之间保持平衡。

AWS 提供了一项自动扩展服务,它与其他 AWS 服务(如 EC2弹性计算云)和 ELB弹性负载均衡))集成,能够自动调整组内服务器实例的数量。这确保了在流量高峰或系统故障期间,资源能够得到优化分配,性能保持稳定。

描述一个设计良好的大规模算法

一个设计良好的大规模算法能够处理海量信息,并且被设计为具备适应性、弹性和高效性。它具有弹性,能够适应大规模环境中不断变化的动态。

一个设计良好的大规模算法具有以下两个特点:

  • 并行性:并行性是让算法一次性处理多个任务的特性。对于大型计算任务,算法应能够将任务分配到多台计算机上。因为这些计算是同时进行的,计算速度得以加快。在大规模计算的背景下,算法应能将任务拆分到多台机器上,从而通过并行处理加速计算过程。

  • 容错性:由于大规模环境中组件数量庞大,系统故障的风险增大,因此构建能够承受这些故障的算法至关重要。算法应具备在不丧失大量数据或输出精度的情况下从故障中恢复的能力。

三大云计算巨头,谷歌、亚马逊和微软,提供高度弹性的基础设施。由于它们共享资源池的庞大规模,几乎没有公司能够与这三家公司基础设施的弹性匹敌。

大规模算法的性能与底层基础设施的质量密切相关。这个基础设施应提供充足的计算资源、广泛的存储能力、高速的网络连接和可靠的性能,以确保算法的最佳运行。让我们描述一个适合大规模算法的基础设施。

负载均衡

负载均衡是大规模分布式计算算法中的一项核心实践。通过均衡地管理和分配工作负载,负载均衡避免了资源过载,并保持了系统的高性能。它在确保高效运作、优化资源利用和实现高吞吐量方面发挥着重要作用,尤其是在分布式深度学习领域。

图 15.1 直观地展示了这一概念。它展示了一个用户与负载均衡器交互,负载均衡器则管理多个节点的负载。在此例中,有四个节点,节点 1节点 2节点 3节点 4。负载均衡器不断监控所有节点的状态,将传入的用户请求在它们之间分配。将任务分配给某个特定节点的决定,取决于该节点的当前负载以及负载均衡器的算法。通过防止任何单一节点被压垮,而其他节点仍处于低负载状态,负载均衡器确保系统性能的最佳化:

图表 描述自动生成

图 15.1:负载均衡

在云计算的更广泛背景下,AWS 提供了一个名为弹性负载均衡(ELB)的功能。ELB 会自动将传入的应用流量分配到 AWS 生态系统内的多个目标,如 Amazon EC2 实例、IP 地址或 Lambda 函数。通过这种方式,ELB 可以防止资源过载,并保持应用的高可用性和性能。

ELB:结合弹性和负载均衡

ELB 代表一种先进的技术,它将弹性和负载均衡的元素结合到一个单一的解决方案中。它利用服务器集群来增强计算基础设施的响应性、效率和可扩展性。其目标是在所有可用资源之间保持均匀的工作负载分配,同时使基础设施能够根据需求波动动态调整其规模。

图 15.2 显示了一个负载均衡器管理四个服务器组。请注意,服务器组是一个节点集合,负责执行特定的计算功能。在此,服务器组指的是由节点组成的一个集合,每个节点被赋予一个独特的计算任务。

服务器组的关键特性之一是其弹性——根据情况灵活地添加或移除节点的能力:

图形用户界面,图表 描述自动生成

图 15.2:智能负载均衡服务器自动扩展

负载均衡器通过实时监控工作负载指标来运行。当计算任务变得越来越复杂时,处理能力的需求也相应增加。为应对这种需求激增,系统会触发“扩容”操作,将额外的节点集成到现有的服务器组中。在此背景下,“扩容”是指增加计算能力以适应扩展的工作负载。相反,当需求下降时,基础设施可以启动“缩容”操作,移除一些节点。通过这种跨服务器组的动态节点重新分配,确保了资源的最优利用比率。通过将资源分配调整以匹配当前的工作负载,系统可以避免资源过度配置或不足配置。这种动态资源管理策略提高了运营效率和成本效益,同时保持了高水平的性能。

策略化多资源处理

在多资源处理策略的早期,大规模算法是在被称为超级计算机的强大机器上执行的。这些单体机器具有共享内存空间,能够让不同的处理器之间进行快速通信,并通过相同的内存访问共享变量。随着运行大规模算法的需求增长,超级计算机转变为分布式共享内存DSM)系统,在这种系统中,每个处理节点拥有一段物理内存。随后,集群应运而生,构成了松散连接的系统,这些系统依赖于处理节点之间的消息传递。

有效运行大规模算法需要多个执行引擎并行工作以解决复杂的挑战。可以利用三种主要策略来实现这一点:

  • 向内看:通过利用计算机上的现有资源,使用 GPU 上成百上千的核心来执行大规模算法。例如,一位数据科学家希望训练一个复杂的深度学习模型时,可以利用 GPU 的计算能力来增强计算能力。

  • 向外看:实施分布式计算以访问补充的计算资源,这些资源可以协同解决大规模问题。例子包括集群计算和云计算,它们通过利用分布式资源,使得运行复杂且资源需求高的算法成为可能。

  • 混合策略:将分布式计算与每个节点上的 GPU 加速结合,以加快算法执行。一些处理大量数据并进行复杂模拟的科研组织可能会采用这种方法。如图 15.3所示,计算负载被分配到多个节点(节点 1节点 2节点 3)上,每个节点都配备有自己的 GPU。该图有效展示了混合策略,展示了如何利用分布式计算和 GPU 加速的优势,推动每个节点中的模拟和计算的加速:

计算机屏幕截图,描述自动生成,信心中等

图 15.3:多资源处理的混合策略

在我们探索并行计算在运行大规模算法中的潜力时,理解支配其效率的理论限制同样重要。

在接下来的部分,我们将深入探讨并行计算的基本约束,揭示影响其性能的因素,以及它可以被优化的程度。

理解并行计算的理论限制

需要注意的是,并行算法并不是万能的。即使是设计最好的并行架构,也可能无法达到我们预期的性能。并行计算的复杂性,如通信开销和同步问题,使得实现最佳效率变得具有挑战性。为了帮助我们应对这些复杂性,并更好地理解并行算法的潜在收益和局限性,提出了阿姆达尔定律。

阿姆达尔定律

基因·阿姆达尔(Gene Amdahl)是最早研究并行处理的人之一,他在 20 世纪 60 年代提出了阿姆达尔定律,该定律至今仍然适用,并成为理解设计并行计算解决方案时涉及的各种权衡的基础。阿姆达尔定律提供了一个理论极限,即在给定可并行化部分的算法下,使用并行化版本的算法能够实现的最大执行时间改进。

它基于这样一个概念:在任何计算过程中,并非所有过程都可以并行执行。总会有一部分过程是顺序执行的,无法并行化。

推导阿姆达尔定律

考虑一个可以分为并行部分(f)和串行部分(1 - f)的算法或任务。并行部分指的是可以在多个资源或处理器上同时执行的任务部分。这些任务相互独立,可以并行执行,因此称为“并行可分”。另一方面,串行部分是任务中无法拆分的部分,必须按顺序依次执行,因此称为“串行”。

Tp(1) 表示在单个处理器上处理该任务所需的时间。可以表达为:

T[p](1) = N(1 - f)**τ[p] + N(f)**τ[p] = N**τ[p]

在这些方程中,Nτ[p] 表示:

  • N:算法或任务必须执行的任务总数或迭代次数,在单个处理器和并行处理器上是一致的。

  • τ[p]:处理器完成单个工作单元、任务或迭代所需的时间,不论使用多少个处理器,这个时间保持不变。

前面的方程计算了在单个处理器上处理所有任务所需的总时间。现在,让我们看看任务在N个并行处理器上执行的情况。

执行所需的时间可以表示为 T[p](N)。在下面的图示中,X 轴表示处理器数量,即执行我们程序所使用的计算单元或核心的数量。当我们沿着 X 轴向右移动时,使用的处理器数量增加。Y 轴表示加速比,这是衡量使用多个处理器时程序运行速度相比仅使用一个处理器时的提升程度。当我们沿着 Y 轴向上移动时,程序的速度成比例地增加,导致任务执行效率更高。

图 15.4 中的图表和阿姆达尔定律向我们展示了,更多的处理器可以提高性能,但由于代码中的串行部分,性能提升是有限的。这个原理是并行计算中收益递减的经典例子。

N = N(1 - f)**τ[p] + (f)**τ[p]

在这里,RHS右侧)的第一项表示处理任务的串行部分所需的时间,而第二项表示处理并行部分所需的时间。

在这种情况下,加速比是由于任务的并行部分分布到N个处理器上。阿姆达尔定律定义了使用N个处理器时实现的加速比S(N),公式为:

对于显著的加速,必须满足以下条件:

1 - f << f / N (4.4)

这个不等式表示并行部分(f)必须非常接近于 1,尤其是在N很大的时候。

现在,让我们来看一个典型的图表,解释阿姆达尔定律:

图表描述自动生成

图 15.4:并行处理中的收益递减:可视化阿姆达尔定律

图 15.4 中,X 轴表示处理器的数量 (N),对应用于执行程序的计算单元或核心。沿着 X 轴向右移动,N 增加。Y 轴表示加速比 (S),这是一个衡量程序在多个处理器上执行时相较于只使用一个处理器执行的时间 T[p] 改进的指标。沿 Y 轴向上移动表示程序执行速度的提升。

该图展示了四条线,每条线代表不同并行化比例 (f) 下获得的加速比 S,分别为 50%、75%、90% 和 95%:

  • 50% 并行 (f = 0.5): 这一行展示了最小的加速比 S。尽管添加了更多的处理器 (N),程序的一半仍然按顺序执行,限制了加速比的最大值为 2。

  • 75% 并行 (f = 0.75): 与 50% 的情况相比,加速比 S 更高。然而,程序的 25% 仍然是顺序执行,这限制了整体加速比。

  • 90% 并行 (f = 0.9): 在这种情况下,观察到显著的加速比 S。然而,程序的 10% 顺序部分对加速比施加了限制。

  • 95% 并行 (f = 0.95): 这一行展示了最高的加速比 S。然而,顺序执行的 5% 仍然对加速比施加了上限。

该图与阿姆达尔定律结合,强调了虽然增加处理器数量 (N) 可以提高性能,但由于代码中的顺序部分 (1 - f),仍然存在一个固有的限制。这个原则是并行计算中收益递减的经典示例。

阿姆达尔定律提供了有关多处理器系统中可以实现的性能增益的宝贵见解,并强调并行化部分 (f) 在决定系统整体加速比中的重要性。在讨论了并行计算的理论限制之后,重要的是要介绍并探讨另一种强大且广泛使用的并行处理技术:GPU 及其相关的编程框架 CUDA。

CUDA: 释放 GPU 架构在并行计算中的潜力

GPU 最初是为图形处理设计的,但后来发展演变,展现出与 CPU 不同的独特特点,形成了完全不同的计算范式。

与处理器核心数量有限的 CPU 不同,GPU 由数千个核心组成。然而,需要注意的是,这些核心单独来看并不像 CPU 核心那样强大,但 GPU 在并行执行大量相对简单的计算任务时非常高效。

由于 GPU 最初是为图形处理设计的,GPU 架构非常适合图形处理,其中多个操作可以独立执行。例如,渲染图像涉及对每个像素的颜色和亮度进行计算。这些计算彼此基本独立,因此可以同时进行,充分利用 GPU 的多核架构。

表格底部

这种设计选择使得 GPU 在它们所设计的任务上变得极其高效,比如渲染图形和处理大规模数据集。下面是图 15.5所示的 GPU 架构:

一张包含表格的图片,描述自动生成

图 15.5:GPU 架构

这种独特的架构不仅有利于图形处理,还对其他类型的计算问题具有显著的优势。任何可以分解成较小、独立任务的问题,都可以利用这种架构进行更快速的处理。这包括像科学计算、机器学习,甚至是加密货币挖矿等领域,这些领域的数据集庞大且计算复杂。

在 GPU 成为主流之后,数据科学家开始探索它们在高效执行并行操作方面的潜力。由于典型的 GPU 拥有成千上万的算术逻辑单元ALUs),它有潜力产生成千上万的并发进程。需要注意的是,ALU 是核心的主力部件,负责执行大部分实际的计算。大量的 ALU 使得 GPU 非常适合执行需要对许多数据点同时进行相同操作的任务,例如数据科学和机器学习中常见的向量和矩阵运算。因此,能够执行并行计算的算法最适合在 GPU 上运行。例如,在 GPU 上进行视频中的对象搜索,速度至少比 CPU 快 20 倍。第五章中讨论的图算法,已知在 GPU 上的运行速度远快于 CPU。

2007 年,NVIDIA 开发了一个名为计算统一设备架构CUDA)的开源框架,以便数据科学家能够利用 GPU 的强大计算能力来处理他们的算法。CUDA 将 CPU 和 GPU 分别抽象为主机和设备。

主机指的是 CPU 和主内存,负责执行主程序并将数据并行计算任务卸载到 GPU 上。

设备指的是 GPU 及其内存(VRAM),负责执行执行数据并行计算的内核。

在典型的 CUDA 程序中,主机在设备上分配内存,传输输入数据并调用内核。设备执行计算,结果存储回其内存。主机随后检索结果。通过这种劳动分工,充分发挥了每个组件的优势,CPU 处理复杂逻辑,GPU 负责大规模数据并行计算。

CUDA 在 NVIDIA GPU 上运行,并需要操作系统内核的支持,最初从 Linux 开始,后来扩展到 Windows。CUDA 驱动 API 连接了编程语言 API 和 CUDA 驱动,支持 C、C++和 Python。

LLM 中的并行处理:一个关于阿姆达尔定律和收益递减的案例研究

像 ChatGPT 这样的 LLM 是复杂的系统,能够根据给定的初始提示生成与人类写作非常相似的文本。这项任务涉及一系列复杂的操作,可以大致分为顺序任务和可并行化任务。

顺序任务是指那些必须按照特定顺序依次进行的任务。这些任务可能包括像分词这样的预处理步骤,其中输入文本被拆分成更小的部分,通常是单词或短语,模型能够理解。它还可能包括像解码这样的后处理任务,在这些任务中,模型的输出(通常是以词元概率的形式呈现)被转化回人类可读的文本。这些顺序任务对模型的功能至关重要,但由于其本质,它们无法拆分并同时执行。

另一方面,能够并行化的任务是那些可以拆分并同时运行的任务。一个典型的例子是模型神经网络中的前向传播阶段。在这里,网络中每一层的计算可以并行执行。这个操作构成了模型大部分的计算时间,正是在这里可以发挥并行处理的强大优势。

现在,假设我们使用的是一款拥有 1000 个核心的 GPU。在语言模型的背景下,任务中可以并行化的部分可能涉及前向传播阶段,在这个阶段中,神经网络每一层的计算可以并行执行。我们假设这占总计算时间的 95%。其余 5%的任务可能涉及诸如分词和解码之类的操作,这些是顺序进行的,无法并行化。

将阿姆达尔定律应用于此场景给出了以下结果:

加速 = 1 / ((1 - 0.95) + 0.95/1000) = 1 / (0.05 + 0.00095) = 19.61

在理想情况下,这表明我们的语言处理任务在 1000 核心的 GPU 上比在单核 CPU 上要快约 19.61 倍。

为了进一步说明并行计算的收益递减,我们来调整核心数量,分别为 2、50 和 100:

  • 对于 2 个核心:加速 = 1 / ((1 - 0.95) + 0.95/2) = 1.67

  • 对于 50 个核心:加速 = 1 / ((1 - 0.95) + 0.95/50) = 14.71

  • 对于 100 个核心:加速 = 1 / ((1 - 0.95) + 0.95/100) = 16.81

从我们的计算结果来看,向并行计算环境中添加更多核心并不会导致速度的等效提升。这是并行计算中收益递减概念的一个典型例子。即使将核心数量从 2 增加到 4,或者从 2 增加到 100 时增加 50 倍,速度提升也不会翻倍或增加 50 倍。相反,速度提升会根据阿姆达尔定律达到一个理论限制。

这种收益递减的主要原因是任务中存在无法并行化的部分。在我们的例子中,像标记化和解码这样的操作构成了这一顺序部分,占总计算时间的 5%。无论我们向系统添加多少核心,或者我们能多高效地执行并行化部分,这一顺序部分都会对可实现的加速造成上限。它将始终存在,要求占用其计算时间份额。

阿姆达尔定律优雅地捕捉了并行计算的这一特性。它指出,使用并行处理的最大潜在加速由任务中无法并行化的部分决定。该定律提醒算法设计师和系统架构师,尽管并行性可以显著加速计算,但它并不是一个可以无限利用来提高速度的资源。它强调了识别和优化算法中顺序部分的重要性,以便最大化并行处理的优势。

在大规模语言模型(LLM)的背景下,这一理解尤为重要,因为计算的庞大规模使得高效的资源利用成为一个关键问题。它强调了需要一种平衡的方法,将并行计算策略与优化任务中顺序部分的性能的努力结合起来。

重新思考数据局部性

在传统的并行和分布式处理中,数据局部性原理在决定最优资源分配方面至关重要。它从根本上表明,在分布式基础设施中应尽量避免数据移动。只要可能,数据应在其所在节点上本地处理,而不是移动数据;否则,它将减少并行化和水平扩展的好处,其中水平扩展是通过增加更多机器或节点来分配工作负载,从而提高系统容量,使其能够处理更高的流量或数据量。

随着网络带宽多年来的提升,数据局部性带来的限制变得不再那么显著。更高的数据传输速度使得分布式计算环境中节点之间的通信更加高效,从而减少了对数据局部性进行性能优化的依赖。网络带宽可以通过网络分段带宽来量化,分段带宽是指网络中两部分之间的带宽。这在资源物理分布的分布式计算中尤为重要。如果我们在分布式网络中的两组资源之间画一条线,分段带宽就是指一侧的服务器与另一侧的服务器之间的通信速度,如 图 15.6 所示。为了使分布式计算高效运行,这是需要考虑的最重要参数。如果没有足够的网络分段带宽,分布式计算中多个执行引擎所带来的好处将被缓慢的通信链路所掩盖。

A picture containing text  Description automatically generated

图 15.6:分段带宽

高分段带宽使我们能够在数据所在的地方进行处理,而无需复制数据。如今,主要的云计算提供商提供卓越的分段带宽。例如,在 Google 数据中心,分段带宽高达每秒 1 petabyte。其他主要云供应商也提供类似的带宽。相比之下,典型的企业网络可能只提供每秒 1 到 10 gigabytes 的分段带宽。

这种速度上的巨大差异展示了现代云基础设施的卓越能力,使其非常适合大规模数据处理任务。

增加的宠物比特分段带宽为高效存储和处理大数据开辟了新的选项和设计模式。这些新选项包括由于网络容量的增加而变得可行的替代方法和设计模式,使得数据处理变得更快速、更高效。

利用 Apache Spark 进行集群计算的优势

Apache Spark 是一个广泛使用的平台,用于管理和利用集群计算。在这个背景下,“集群计算”是指将多台机器组合在一起,使它们作为一个单一的系统共同工作以解决问题。Spark 不仅仅实现了这一点,它还创建并控制这些集群以实现高速数据处理。

在 Apache Spark 中,数据会转换成被称为 弹性分布式数据集 (RDDs) 的形式。这些实际上是 Apache Spark 数据抽象的核心。

RDD 是不可变的,意味着一旦创建后,它们无法被更改,是可以并行处理的元素集合。换句话说,这些数据集的不同部分可以同时进行处理,从而加速数据处理过程。

当我们说“容错”时,指的是 RDD 具有从执行过程中的潜在失败或错误中恢复的能力。这使得它们在大数据处理任务中具有强大的可靠性和稳健性。RDD 被划分为多个较小的块,称为“分区”,然后分布在集群中的多个节点或独立计算机上。这些分区的大小可以变化,主要由任务的性质和 Spark 应用的配置决定。

Spark 的分布式计算框架使得任务可以分布在多个节点上,从而显著提高处理速度和效率。

Spark 架构由多个主要组件组成,包括驱动程序、执行器、工作节点和集群管理器。

  • 驱动程序:驱动程序是 Spark 应用中的关键组件,功能类似于操作的控制中心。它存在于一个独立的进程中,通常位于称为驱动机器的机器上。驱动程序的角色类似于管弦乐队的指挥;它运行主 Spark 程序,并监督其中的众多任务。

    驱动程序的主要任务之一是处理和运行 SparkSession。SparkSession 对于 Spark 应用至关重要,因为它封装了 SparkContext。SparkContext 就像 Spark 应用的中枢神经系统——它是应用与 Spark 计算生态系统交互的门户。

    为了简化理解,可以将 Spark 应用比作一栋办公大楼。驱动程序就像大楼管理员,负责整体运作和维护。在这栋大楼中,SparkSession 代表一个独立的办公室,而 SparkContext 是通往该办公室的主要入口。关键是,这些组件——驱动程序、SparkSession 和 SparkContext——协同工作,以协调任务并管理 Spark 应用中的资源。SparkContext 包含应用启动时预加载的基本功能和上下文信息。此外,它还携带关于集群的重要细节,如配置和状态,这对于应用的运行和任务的有效执行至关重要。

  • 集群管理器:驱动程序与集群管理器无缝互动。集群管理器是一个外部服务,负责提供和管理集群中的资源,如计算能力和内存。驱动程序和集群管理器密切合作,以识别集群中可用的资源,进行有效分配,并在 Spark 应用的生命周期内管理其使用。

  • 执行器:执行器是指专门为在集群中某个节点上运行的单个 Spark 应用程序而启动的计算进程。每个执行器进程都运行在工作节点上,实际上充当着 Spark 应用程序背后的计算“肌肉”。

  • 以这种方式共享内存和全局参数可以显著提高任务执行的速度和效率,使得 Spark 成为一个高性能的大数据处理框架。

  • 工作节点:工作节点,顾名思义,负责在分布式 Spark 系统中执行任务的实际操作。

    每个工作节点能够托管多个执行器,这些执行器又可以为多个 Spark 应用程序提供服务:

    集群管理器的示意图 自动生成的描述,信心中等

    图 15.7:Spark 的分布式架构

Apache Spark 如何支持大规模算法处理

Apache Spark 已成为处理和分析大数据的领先平台,这得益于其强大的分布式计算能力、容错特性和易用性。在本节中,我们将探讨 Apache Spark 如何支持大规模算法处理,使其成为复杂、资源密集型任务的理想选择。

分布式计算

Apache Spark 架构的核心概念是数据分区,这使得数据可以在集群中的多个节点之间分配。这个特性使得并行处理和高效的资源利用成为可能,而这两者对于运行大规模算法至关重要。Spark 的架构由一个驱动程序和分布在工作节点上的多个执行器进程组成。驱动程序负责管理并分配任务到各个执行器,而每个执行器则在多个线程中并行运行任务,从而实现高吞吐量。

内存处理

Spark 的一大亮点是其内存处理能力。与传统的基于磁盘的系统不同,Spark 可以将中间数据缓存到内存中,显著加速需要多次遍历数据的迭代算法。

  • 这种内存处理能力对于大规模算法尤为有利,因为它最小化了磁盘 I/O 的时间,从而加快了计算速度,并更高效地利用资源。

在云计算中使用大规模算法

数据的快速增长以及机器学习模型日益复杂,使得分布式模型训练成为现代深度学习管道中不可或缺的一部分。大规模算法需要大量的计算资源,并且需要高效的并行处理来优化其训练时间。云计算提供了一系列服务和工具,支持分布式模型训练,使你能够充分发挥资源密集型大规模算法的潜力。

使用云计算进行分布式模型训练的一些关键优势包括:

  • 可扩展性:云计算提供几乎无限的资源,使你能够根据大规模算法的需求扩展模型训练工作负载。

  • 灵活性:云计算支持多种机器学习框架和库,使你能够选择最适合你特定需求的工具。

  • 性价比:使用云计算,你可以通过选择合适的实例类型和利用抢占实例来优化培训成本,从而降低开支。

示例

随着我们深入研究机器学习模型,尤其是处理自然语言处理NLP)任务的模型时,我们发现对计算资源的需求越来越大。例如,像 GPT-3 这样的变压器模型,用于大规模语言建模任务,可能拥有数十亿个参数,需求巨大的处理能力和内存。在庞大的数据集上训练这样的模型,如包含数十亿网页的 Common Crawl,进一步加剧了这些需求。

云计算在这里成为了一种强有力的解决方案。它提供了分布式模型训练的服务和工具,使我们能够访问几乎无限的资源池,扩展工作负载,并选择最适合的机器学习框架。更重要的是,云计算通过提供灵活的实例类型和抢占实例来促进成本优化——本质上是在竞标空闲的计算能力。通过将这些资源密集型任务委托给云计算,我们可以更加专注于创新工作,加快训练过程,并开发更强大的模型。

总结

本章我们探讨了大规模并行算法设计的概念和原理。我们分析了并行计算的重要作用,特别是它在将计算任务有效地分配到多个处理单元方面的能力。详细研究了 GPU 的非凡能力,展示了它们在并发执行大量线程时的实用性。此外,我们还讨论了分布式计算平台,特别是 Apache Spark 和云计算环境。它们在促进大规模算法的开发和部署方面的重要性被强调,为高性能计算提供了强大、可扩展且具成本效益的基础设施。

在 Discord 上了解更多

要加入本书的 Discord 社区——在这里你可以分享反馈、向作者提问,并了解新版本——请扫描下面的二维码:

packt.link/WHLel

第十六章:实际考虑因素

本书中介绍了许多可用于解决现实世界问题的算法。在这一章中,我们将探讨本书中算法的实用性。我们的重点将放在它们的现实世界适用性、潜在挑战和整体主题上,包括效用和伦理影响。

本章的组织结构如下:我们将从引言开始。接着,我们将介绍算法可解释性的问题,即算法的内部机制能够以可理解的术语进行解释的程度。然后,我们将讨论使用算法的伦理问题以及实施算法时可能产生的偏见。接下来,我们将讨论处理 NP-hard 问题的技术。最后,我们将研究在选择算法之前应考虑的因素。

到本章结束时,你将了解在使用算法解决现实世界问题时,必须牢记的实际考虑因素。

本章将涵盖以下主题:

  • 引入实际考虑因素

  • 算法的可解释性

  • 理解伦理与算法

  • 减少模型中的偏差

  • 何时使用算法

让我们从一些算法解决方案面临的挑战开始。

算法解决方案面临的挑战

除了设计、开发和测试算法外,在许多情况下,考虑开始依赖机器来解决现实世界问题时的某些实际因素也是非常重要的。对于某些算法,我们可能需要考虑如何可靠地加入新信息,而这些信息预计在我们部署算法之后仍会持续变化。例如,全球供应链的突发中断可能会使我们用于训练模型以预测产品利润率的某些假设失效。我们需要仔细考虑是否加入这些新信息会以某种方式改变我们经过充分测试的算法的质量。如果会,那么我们的设计如何处理这种变化?

预见意外情况

许多使用算法开发的现实世界问题解决方案都是基于某些假设的。这些假设在模型部署后可能会意外地发生变化。一些算法使用的假设可能会受到全球地缘政治情况变化的影响。例如,考虑一个训练好的模型,它预测一家在全球各地都有办事处的国际公司的财务利润。像战争或突如其来的致命病毒蔓延这样的意外破坏事件,可能会根本性地改变这个模型的假设和预测的质量。对于这种应用场景,建议是“预见意外”并为意外情况做好应对策略。对于某些数据驱动的模型,意外可能来自于解决方案部署后法规政策的变化。

当我们使用算法来解决现实世界的问题时,某种程度上,我们是在依赖机器来进行问题解决。即使是最复杂的算法也都建立在简化和假设的基础上,无法应对突发情况。我们仍然离将关键决策完全交给我们自己设计的算法的目标相距甚远。

例如,谷歌的推荐引擎算法由于隐私问题,最近面临欧盟的监管限制。这些算法可能是该领域最先进的技术之一,但如果被禁止,这些算法可能会变得毫无用处,因为它们将无法解决它们原本应解决的问题。

然而,事实是,遗憾的是,算法的实际考虑往往是事后的思考,通常在初期设计阶段并未得到充分的考虑。

对于许多用例来说,一旦算法被部署,短期内的解决方案带来的兴奋感过去后,使用算法的实际问题和影响将在时间的推移中被发现,并最终决定项目的成败。

让我们来看看一个实际的例子,分析一下没有关注实际考虑导致一个全球顶尖 IT 公司设计的高调项目失败的原因。

Tay 的失败,推特 AI 机器人

让我们来看一个经典的例子——Tay,这是微软在 2016 年创建的首个 AI 推特机器人。通过使用 AI 算法,Tay 被训练成一个能够根据特定话题回复推文的自动化推特机器人。为了实现这一目标,它具备根据对话上下文构建简单消息的能力,利用现有的词汇。一旦部署,它本应从实时的在线对话中不断学习,并通过增强其词汇库,吸收在重要对话中频繁使用的单词。然而,Tay 在网络空间中生活了几天后,开始学习新词汇。除了学习了一些新词,遗憾的是,Tay 还从正在进行的推文中学到了一些种族主义和粗俗的词语。它很快开始使用新学到的词语发布自己的推文。尽管其中绝大多数推文并无恶意,但其中少数推文足够冒犯人们,迅速引发了警报。尽管它展现出了智能,并且迅速学会了如何根据实时事件创作定制的推文,但与此同时,它也严重冒犯了人们。微软将其下线并尝试进行重新调整,但未能成功。最终,微软不得不终止了这个项目。这是一个雄心勃勃的项目的悲惨结局。

请注意,尽管微软在其内置的人工智能方面取得了令人印象深刻的成绩,但公司忽视了部署自学习 Twitter 机器人的实际影响。尽管自然语言处理和机器学习算法可能是业内最先进的,但由于显而易见的缺陷,该项目几乎是毫无用处的。如今,Tay 已经成为一个由于忽视让算法实时学习的实际后果而导致失败的教科书式例子。Tay 的失败所带来的教训无疑影响了后来的 AI 项目。数据科学家们也开始更加关注算法的透明性。

为了更深入了解,以下是关于 Tay 的全面研究:spectrum.ieee.org/in-2016-microsofts-racist-chatbot-revealed-the-dangers-of -online-conversation

这引出了下一个话题,探讨了为何需要让算法透明以及如何实现这一点。

算法的可解释性

首先,让我们区分黑盒算法和白盒算法:

  • 黑盒算法是指其逻辑无法被人类解释的算法,原因可能是其复杂性或其逻辑以复杂的方式呈现。

  • 白盒算法是指其逻辑对人类可见且可以理解的算法。

在机器学习的背景下,可解释性指的是我们理解和表达算法特定输出背后原因的能力。从本质上讲,它衡量的是一个算法的内部工作原理和决策路径对于人类认知的可理解程度。

许多算法,特别是在机器学习领域,由于其不透明性,通常被称为“黑盒”。例如,考虑我们在第八章中讨论的神经网络算法,神经网络算法。这些算法是许多深度学习应用的基础,是典型的黑盒模型。它们的复杂性和多层结构使得它们本质上不直观,导致其内部决策过程对人类理解来说是谜一样的。

然而,必须注意,“黑盒”和“白盒”这两个术语是明确的分类,分别表示完全不透明和完全透明。它们不是一个渐变或光谱,一个算法不可能是“有点黑”或“有点白”。当前的研究正致力于让这些黑盒算法(如神经网络)变得更加透明和可解释。然而,由于其复杂的架构,它们仍然主要属于黑盒类别。

如果算法用于关键决策中,理解算法生成结果的原因可能是非常重要的。避免使用黑箱算法,转而使用白箱算法,也能更好地洞察模型的内部运作。第七章中讨论的决策树算法,传统监督学习算法,就是这种白箱算法的一个例子。例如,一个可解释的算法将指导医生了解哪些特征实际被用来将病人分类为生病或未生病。如果医生对结果有任何疑问,他们可以回去重新检查这些特征的准确性。

机器学习算法和可解释性

在机器学习领域,可解释性的概念至关重要。那么,到底我们指的是什么是可解释性呢?从本质上讲,可解释性指的是我们能够理解和解释机器学习模型决策的清晰度。

这意味着揭开模型预测背后的面纱,理解其背后的“原因”。

在利用机器学习,特别是在决策场景中,个人往往需要信任模型的输出。如果模型的过程和决策是透明的并且可以解释的,那么这种信任可以得到显著增强。为了说明可解释性的重要性,让我们考虑一个现实场景。

假设我们想利用机器学习根据房屋的特征预测波士顿地区的房价。假设当地的城市规定允许我们使用机器学习算法,但前提是每当需要时,我们必须提供详细的信息来证明任何预测的合理性。这些信息是审计的需要,以确保住房市场的某些细分不会被人为操控。让我们的训练模型具有可解释性将提供这些额外的信息。

让我们深入了解实现已训练模型可解释性的不同选项。

解释性策略展示

对于机器学习,提供算法可解释性的基本策略有两种:

  • 全球可解释性策略:这是指提供整个模型制定过程的细节。例如,我们可以考虑一个用于批准或拒绝个人贷款的机器学习模型的案例。可以使用全球可解释性策略来量化该模型决策的透明度。全球可解释性策略并不是针对单个决策的透明度,而是关于整体趋势的透明度。假设媒体对该模型中的性别偏见进行猜测,全球可解释性策略将提供必要的信息来验证或否定这一猜测。

  • 局部可解释策略:这是为了提供由我们的训练模型做出的单一预测的依据。其目的是为每个单独的决策提供透明度。例如,考虑我们之前的例子,预测波士顿地区的房价。如果一位房主质疑为什么他们的房子被模型评估为特定价格,那么局部可解释策略将提供有关该具体估价的详细推理,明确指出各种因素及其权重,帮助理解模型如何做出该预测。

对于全局可解释性,我们有一些技术,如使用概念激活向量进行测试TCAV),它用于为图像分类模型提供可解释性。TCAV 通过计算方向导数来量化用户定义的概念与图像分类之间的关系程度。例如,它会量化分类一个人是男性的预测对面部毛发存在的敏感度。还有其他全局可解释性策略,例如部分依赖图和计算排列重要性,它们有助于解释我们训练模型中的公式。全局和局部可解释性策略可以是模型特定的,也可以是模型无关的。模型特定策略适用于某些类型的模型,而模型无关策略可以应用于各种不同的模型。

以下图示总结了可用于机器学习可解释性的不同策略:

图示 描述自动生成

图 16.1:机器学习可解释性方法

现在,让我们来看看如何使用这些策略之一实现可解释性。

实现可解释性

局部可解释模型无关解释LIME)是一种模型无关的方法,可以解释经过训练的模型所做出的单个预测。由于其模型无关的特性,它可以解释大多数类型训练过的机器学习模型的预测。

LIME 通过对每个实例的输入进行小幅度变化来解释决策。它可以收集这些变化对该实例的局部决策边界的影响。它会遍历循环,提供每个变量的详细信息。通过查看输出,我们可以看到哪个变量对该实例的影响最大。

让我们看看如何使用 LIME 使我们的房价预测模型的个别预测变得可解释:

  1. 如果你之前从未使用过LIME,你需要使用pip安装该软件包:

    !pip install lime 
    
  2. 然后,让我们导入我们需要的 Python 包:

    import sklearn
    import requests
    import pickle
    import numpy as np
    from lime.lime_tabular import LimeTabularExplainer as ex 
    
  3. 我们将训练一个模型,该模型可以预测某个特定城市的房价。为此,我们首先将导入存储在housing.pkl文件中的数据集。然后,我们将探索它所包含的特征:

    # Define the URL
    url = "https://storage.googleapis.com/neurals/data/data/housing.pkl"
    # Fetch the data from the URL
    response = requests.get(url)
    data = response.content
    # Load the data using pickle
    housing = pickle.loads(data)
    housing['feature_names'] 
    
    array(['crime_per_capita', 'zoning_prop', 'industrial_prop', 
           'nitrogen oxide', 'number_of_rooms', 'old_home_prop', 
           'distance_from_city_center', 'high_way_access', 
           'property_tax_rate', 'pupil_teacher_ratio', 
           'low_income_prop', 'lower_status_prop', 
           'median_price_in_area'], dtype='<U25') 
    

    基于这些特征,我们需要预测一套房屋的价格。

  4. 现在,让我们训练模型。我们将使用随机森林回归器来训练模型。首先,我们将数据分为测试集和训练集,然后使用它来训练模型:

    from sklearn.ensemble import RandomForestRegressor
    X_train, X_test, y_train, y_test = sklearn.model_selection.train_test_split(housing.data, housing.target)
    regressor = RandomForestRegressor()
    regressor.fit(X_train, y_train) 
    
    RandomForestRegressor() 
    
  5. 接下来,让我们识别类别列:

    cat_col = [i for i, col in enumerate(housing.data.T)
                            if np.unique(col).size < 10] 
    
  6. 现在,让我们使用所需的配置参数来实例化 LIME 解释器。请注意,我们指定我们的标签是'price',表示波士顿的房价:

    myexplainer = ex(X_train,
        feature_names=housing.feature_names,
        class_names=['price'],
        categorical_features=cat_col,
        mode='regression') 
    
  7. 让我们尝试深入了解预测的细节。为此,首先让我们从matplotlib导入pyplot作为绘图工具:

    exp = myexplainer.explain_instance(X_test[25], regressor.predict,
            num_features=10)
    exp.as_pyplot_figure()
    from matplotlib import pyplot as plt
    plt.tight_layout() 
    

图 16.2:房价预测的特征逐项解释

  1. 由于 LIME 解释器是针对个别预测工作的,我们需要选择要分析的预测。我们已要求解释器对索引为135的预测提供理由:

    for i in [1, 35]:
        exp = myexplainer.explain_instance(X_test[i], regressor.predict,
                num_features=10)
    exp.as_pyplot_figure()
    plt.tight_layout() 
    

图表,瀑布图 描述自动生成

图 16.3:突出显示关键特征:解读测试实例 1 和 35 的预测

让我们尝试分析 LIME 之前的解释,它告诉我们以下内容:

  • 在个别预测中使用的特征列表:它们在前面的截图中标示在y轴上。

  • 特征在决定中的相对重要性:条形线越大,重要性越大。数字的值显示在x轴上。

  • 每个输入特征对标签的正负影响:红色条表示负面影响,绿色条表示特定特征的正面影响。

理解伦理学与算法

算法伦理学,也称为计算伦理学,探讨算法的道德维度。这个关键领域的目标是确保基于这些算法运行的机器遵守伦理标准。算法的开发和部署可能无意中导致不道德的结果或偏见。设计算法时,预测其所有道德后果是一项挑战。当我们在这个背景下讨论大规模算法时,指的是那些处理大量数据的算法。然而,当多个用户或设计者共同参与算法的设计时,复杂性进一步增加,因为这会引入不同的人为偏见。算法伦理学的总体目标是突出并解决这些领域中出现的问题:

  • 偏见与歧视:有许多因素会影响算法所创建的解决方案的质量。一个主要的关注点是无意中的算法偏见。其原因可能在于算法的设计导致某些数据比其他数据更重要。或者,原因可能出在数据的收集和选择上。这可能会导致应由算法计算的数据被遗漏,或者本不应包含的数据被纳入。例如,一家保险公司使用算法计算风险时,可能会使用包含驾驶者性别的车祸数据。根据现有的数据,算法可能会认为女性驾驶员涉及更多的事故,因此女性驾驶员自动获得更高费用的保险报价。

  • 隐私:算法使用的数据可能包含个人信息,并且可能以侵犯个人隐私的方式被使用。例如,启用面部识别的算法就是隐私问题的一个例子。目前,全球许多城市和机场都在使用面部识别系统。挑战在于如何以保护个人隐私不受侵犯的方式使用这些算法。

越来越多的公司将算法的伦理分析纳入其设计过程中。但事实是,问题可能直到我们发现某个有问题的应用场景时才会显现出来。

学习算法的问题

能够根据变化的数据模式进行自我调整的算法被称为学习算法。它们在实时的学习模式中工作,但这种实时学习能力可能带来伦理上的问题。这就有可能导致它们的学习过程做出从伦理角度来看有问题的决策。由于它们被设计为处于持续进化的阶段,几乎不可能对它们进行持续的伦理分析。

例如,让我们研究一下亚马逊在其招聘算法中发现的问题。亚马逊从 2015 年开始使用 AI 算法来招聘员工。在部署之前,它经过了严格的测试,以确保其满足功能和非功能要求,并且没有任何偏见或其他伦理问题。由于它是一个学习算法,因此随着新数据的不断出现,它会不断地自我调优。部署几周后,亚马逊发现 AI 算法意外地发展出了性别偏见。亚马逊将算法下线并进行了调查。结果发现,性别偏见是由于新数据中的一些特定模式引入的。具体而言,最近的数据中男性数量远多于女性,而这些男性恰好有更相关的背景适合该职位。实时的自我调优学习带来了一些无意的后果,导致算法开始偏向男性而非女性,从而引入了偏见。该算法开始将性别作为招聘的决定因素之一。之后,模型重新训练,并添加了必要的安全防护措施,确保不会再次引入性别偏见。

随着算法复杂性的增加,全面理解它们对社会中个人和群体的长期影响变得越来越困难。

了解伦理考量

算法解决方案是数学公式。开发算法的人员有责任确保其符合我们试图解决问题的伦理敏感性。一旦解决方案部署,它们可能需要定期监控,以确保随着新数据的到来和基础假设的变化,算法不会开始产生伦理问题。

这些算法的伦理考量取决于算法的类型。例如,让我们来看一下以下算法及其伦理考量。需要仔细考虑伦理问题的一些强大算法示例如下:

  • 分类算法和回归算法在机器学习中各自有不同的用途。分类算法将数据分为预定义的类别,并且可以直接用于决策过程。例如,它们可能决定签证审批或识别城市中的特定人群。另一方面,回归算法基于输入数据预测数值,这些预测确实可以用于决策。例如,回归模型可能预测在市场上列出房子的最佳价格。本质上,分类提供了类别结果,而回归提供了定量预测;两者在不同场景下都对知情决策有价值。

  • 算法在推荐引擎中的应用可以将简历与求职者匹配,无论是针对个人还是群体。对于这种使用场景,算法应该在局部和全局层面都实现可解释性。局部层面的可解释性会提供特定个人简历与可用职位匹配时的可追溯性。全局层面的可解释性则提供了匹配简历和职位所使用的整体逻辑的透明度。

  • 数据挖掘算法可以用来从各种数据源中挖掘关于个人的信息,这些信息可能被政府用来进行决策。例如,芝加哥警察局使用数据挖掘算法来识别城市中的犯罪热点和高风险个体。确保这些数据挖掘算法的设计和使用符合所有伦理要求,需要通过精心设计和持续监控来实现。

因此,算法的伦理考量将取决于其使用的具体场景以及它们直接或间接影响的实体。在开始使用算法进行关键决策之前,需要从伦理角度进行仔细分析。这些伦理考量应当是设计过程的一部分。

影响算法解决方案的因素

以下是我们在分析算法解决方案优劣时应当牢记的因素。

考虑不确定证据

在机器学习中,数据集的质量和广度在模型结果的准确性和可靠性中起着至关重要的作用。通常,数据可能显得有限,或者缺乏提供决定性结果所需的全面深度。

例如,考虑临床试验:如果一款新药在一小部分人群中进行测试,结果可能无法全面反映其疗效。同样,如果我们在某个城市的特定邮政编码区域检查欺诈模式,有限的数据可能会暗示一个趋势,但这个趋势在更广泛的范围内并不一定准确。

关键在于区分“有限数据”和“不确定证据”。虽然大多数数据集本质上都是有限的(没有数据集能捕捉到所有可能性),但“不确定证据”是指数据未能提供明确或决定性的趋势或结果。这一区分至关重要,因为基于不确定模式做出的决策可能会导致判断错误。特别是在使用基于此类数据训练的算法时,决策时必须保持批判性的眼光。

基于不确定证据做出的决策容易导致不合理的行动。

可追溯性

机器学习算法通常有单独的开发和生产环境。这可能导致训练阶段与推理阶段之间的脱节。这意味着,如果算法造成了某种伤害,追踪和调试非常困难。而且,当算法发现问题时,实际上很难确定受到影响的人群。

误导性证据

算法是数据驱动的公式。垃圾进,垃圾出GIGO)原则意味着算法的结果只会与其所基于的数据一样可靠。如果数据中存在偏见,那么这些偏见也会反映在算法中。

不公平的结果

算法的使用可能会对已经处于不利地位的弱势群体造成伤害。

此外,使用算法来分配研究资金已经被证明多次对男性群体存在偏见。用于移民审批的算法有时无意间对弱势群体存在偏见。

尽管使用高质量的数据和复杂的数学公式,如果结果是不公平的,整个努力可能带来的伤害大于收益。

让我们看看如何减少模型中的偏差。

减少模型中的偏差

正如我们所讨论的,模型中的偏见是指特定算法的某些属性导致它产生不公平的结果。在当今世界,基于性别、种族和性取向的偏见是已知的,并且有文献记载。这意味着我们收集的数据可能会表现出这些偏见,除非我们在收集数据之前做出了努力,去消除这些偏见。

大多数时候,算法中的偏见是由人类直接或间接引入的。人类通过疏忽无意中引入偏见,或者通过主观性故意引入偏见。人类偏见的一个原因是人类大脑容易受到认知偏见的影响,这种偏见反映了一个人在数据处理和算法逻辑创建过程中的主观性、信仰和意识形态。人类偏见可以反映在算法使用的数据中,也可以反映在算法本身的制定中。对于一个典型的机器学习项目,遵循CRISP-DM(即跨行业标准过程)生命周期,正如在第五章中所解释的,图算法,偏见通常呈现如下:

图表 描述自动生成

图 16.4:偏见可以在 CRISP-DM 生命周期的不同阶段被引入

减少偏差最棘手的部分是首先识别和定位潜在的无意识偏见。

让我们看看何时使用算法。

何时使用算法

算法就像是从业者工具箱中的工具。首先,我们需要了解在给定的情况下,哪种工具是最合适的。有时,我们需要问自己,是否已经有解决我们正在尝试解决的问题的方案,何时是部署解决方案的最佳时机。我们需要确定,使用算法是否能提供一个实际有用的解决方案,而不是替代方案。我们需要从三个方面分析使用算法的效果:

  • 成本:使用算法的成本是否值得?

  • 时间:我们的解决方案是否使整体过程比更简单的替代方案更高效?

  • 准确性:我们的解决方案是否比更简单的替代方案产生更准确的结果?

选择合适的算法时,我们需要找到以下问题的答案:

  • 我们能否通过做出假设来简化问题?

  • 我们将如何评估我们的算法?

  • 关键的度量标准是什么?

  • 它将如何被部署和使用?

  • 它需要被解释吗?

  • 我们是否理解三项重要的非功能性需求——安全性、性能和可用性?

  • 是否有预期的截止日期?

在根据上述标准选择算法后,值得考虑的是,尽管大多数事件或挑战都可以预见并加以解决,但仍然有一些例外事件,它们违背了我们传统的理解和预测能力。让我们更详细地探讨这一点。

理解黑天鹅事件及其对算法的影响

在数据科学和算法解决方案领域,一些不可预测且罕见的事件可能会带来独特的挑战。“黑天鹅事件”这一术语由纳西姆·塔勒布在《随机的愚弄》(2001)中提出,形象地代表了那些罕见且不可预测的事件。

要被认为是黑天鹅事件,它必须满足以下标准:

  • 意外性:该事件令大多数观察者感到惊讶,就像广岛原子弹轰炸那样。

  • 事件的重大性:该事件具有颠覆性和重大意义,就像西班牙流感的爆发。

  • 事件后可预测性:在事件发生后,很明显,如果之前注意到某些线索,事件是可以预见的,就像在西班牙流感成为大流行之前被忽视的迹象。

  • 并非所有人都感到惊讶:一些人可能早已预见到事件,就像参与曼哈顿计划的科学家们预见到原子弹的爆炸一样。

    在黑天鹅事件首次在野外被发现之前,几个世纪以来,它们一直用来代表一些不可能发生的事情。发现之后,这个词依然流行,但其代表的意义发生了变化。它现在代表的是一些极其罕见,无法预测的事件。

黑天鹅事件对算法的挑战和机会

  • 预测困境:虽然有许多预测算法,从 ARIMA 到深度学习方法,但预测黑天鹅事件仍然是一个难题。使用标准技术可能会给人一种虚假的安全感。例如,预测像 COVID-19 这样的事件的确切发生时间,由于历史数据不足,面临着许多挑战。

  • 预测影响:一旦黑天鹅事件发生,预测其广泛的社会影响就变得复杂。我们可能缺乏相关的数据和对事件影响下的社会关系的理解。

  • 预测潜力:虽然黑天鹅事件看似随机,但它们通常是由于被忽视的复杂前兆所引起的。算法在此提供了机会:制定预测和检测这些前兆的策略,可能有助于预见潜在的黑天鹅事件。

实际应用的相关性

让我们考虑最近的 COVID-19 大流行,这是一个典型的黑天鹅事件。一种潜在的实际应用可能涉及利用先前大流行的相关数据、全球旅行模式和当地健康指标。然后,一个算法可以监控疾病的异常激增或其他潜在的早期迹象,提示可能的全球健康威胁。然而,黑天鹅事件的独特性使得这一过程变得更加困难。

总结

在本章中,我们学习了设计算法时应考虑的实际方面。我们探讨了算法可解释性的概念,以及如何在不同层次上提供可解释性。我们还探讨了算法中可能出现的伦理问题。最后,我们描述了在选择算法时需要考虑的因素。

算法是我们今天所见证的这个新自动化世界中的引擎。了解、实验和理解使用算法的影响至关重要。理解它们的优点和局限性,以及使用算法的伦理影响,将对改善我们生活的世界产生深远的影响,本书的目标就是在这个不断变化和发展的世界中实现这一重要目标。

在 Discord 上了解更多

要加入本书的 Discord 社区——在这里你可以分享反馈、向作者提问并了解新版本——请扫描下面的二维码:

packt.link/WHLel

posted @ 2025-10-27 08:51  绝不原创的飞龙  阅读(9)  评论(0)    收藏  举报