机器学习算法-全-

机器学习算法(全)

原文:annas-archive.org/md5/790dc401711df8b2093de3b73e595cb9

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

本书是机器学习世界的入门,这是一个越来越重要的主题,不仅对 IT 专业人士和分析师来说如此,对所有希望利用预测分析、分类、聚类和自然语言处理等技术的巨大力量的科学家和工程师也是如此。当然,不可能用适当的精确度涵盖所有细节;因此,一些主题仅简要描述,使用户有机会只关注一些基本概念,并通过参考文献深入了解所有那些将引起很大兴趣的元素。我提前为任何不精确或错误表示歉意,并感谢所有 Packt 编辑的合作和持续关注。

我将这本书献给我的父母,他们一直相信我,并鼓励我培养对这个非凡主题的热爱。

本书涵盖的内容

第一章,机器学习的温和介绍,介绍了机器学习的世界,解释了创建智能应用最重要的方法的基本概念。

第二章,机器学习中的重要元素,解释了与最常见机器学习问题相关的数学概念,包括可学习性和信息论的一些元素。

第三章,特征选择和特征工程,描述了用于预处理数据集、选择最有信息量的特征和降低原始维度的最重要技术。

第四章,线性回归,描述了连续线性模型的结构,重点关注线性回归算法。本章还涵盖了岭回归、Lasso 和 ElasticNet 优化,以及其他高级技术。

第五章,逻辑回归,介绍了线性分类的概念,重点关注逻辑回归和随机梯度下降算法。第二部分涵盖了最重要的评估指标。

第六章,朴素贝叶斯,解释了贝叶斯概率理论,并描述了最广泛使用的朴素贝叶斯分类器的结构。

第七章,支持向量机,介绍了这个算法家族,重点关注线性和非线性分类问题。

第八章,决策树和集成学习,解释了分层决策过程的概念,并描述了决策树分类、Bootstrap 和 bagged trees 以及投票分类器的概念。

第九章,聚类基础,介绍了聚类的概念,描述了 k-means 算法和确定最佳聚类数量的不同方法。在第二部分,本章涵盖了其他聚类算法,如 DBSCAN 和谱聚类。

第十章,层次聚类,继续上一章的解释,并介绍了聚合聚类的概念。

第十一章,推荐系统简介,解释了推荐系统中使用最广泛的算法:基于内容和基于用户的策略、协同过滤和交替最小二乘法。

第十二章,自然语言处理简介,解释了词袋模型的概念,并介绍了处理自然语言数据集所需的最重要技术。

第十三章,自然语言处理中的主题建模和情感分析,介绍了主题建模的概念,并描述了最重要的算法,如潜在语义分析和潜在狄利克雷分配。在第二部分,本章涵盖了情感分析的问题,解释了处理该问题的最常见方法。

第十四章,简要介绍深度学习和 TensorFlow,介绍了深度学习的世界,解释了神经网络和计算图的概念。第二部分简要介绍了 TensorFlow 和 Keras 框架的主要概念,并附带了一些实际示例。

第十五章,构建机器学习架构,解释了如何定义一个完整的机器学习流程,重点关注每个步骤的独特性和缺点。

你需要这本书的内容

没有特定的数学先决条件;然而,为了完全理解所有算法,重要的是要具备线性代数、概率论和微积分的基本知识。

所有实际示例均用 Python 编写,并使用scikit-learn机器学习框架、自然语言工具包NLTK)、Crab、langdetect、Spark、gensimTensorFlow**(深度学习框架)。这些框架适用于 Linux、Mac OS X 和 Windows 操作系统,支持 Python 2.7 和 3.3 以上版本。当使用特定框架执行特定任务时,将提供详细的说明和参考。

可以按照这些网站提供的说明安装 scikit-learn、NLTK 和 TensorFlow:scikit-learn.orgwww.nltk.orgwww.tensorflow.org

这本书的适用对象

这本书是为想要进入数据科学领域且对机器学习非常陌生的 IT 专业人士编写的。熟悉 Python 语言在这里将非常有价值。此外,还需要基本的数学知识(线性代数、微积分和概率论)来完全理解大多数章节的内容。

惯例

在这本书中,你会发现许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名如下所示:“我们通过SparkConf类创建了一个配置。”

任何命令行输入或输出都写作如下:

>>> nn = NearestNeighbors(n_neighbors=10, radius=5.0, metric='hamming')
>>> nn.fit(items)

新术语重要词汇以粗体显示。

警告或重要提示会像这样出现在一个框中。

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

读者反馈

我们欢迎读者的反馈。告诉我们你对这本书的看法——你喜欢或不喜欢什么。读者的反馈对我们很重要,因为它帮助我们开发出你真正能从中获得最大收益的标题。要发送一般反馈,请简单地发送电子邮件至feedback@packtpub.com,并在邮件的主题中提及书籍的标题。如果你在某个主题上有专业知识,并且你对撰写或为书籍做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors

客户支持

现在你已经是 Packt 图书的骄傲拥有者了,我们有一些事情可以帮助你从购买中获得最大收益。

下载示例代码

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

  1. 使用您的电子邮件地址和密码登录或注册我们的网站。

  2. 将鼠标指针悬停在顶部的 SUPPORT 标签上。

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

  4. 在搜索框中输入书籍名称。

  5. 选择你想要下载代码文件的书籍。

  6. 从你购买这本书的下拉菜单中选择。

  7. 点击代码下载。

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

  • WinRAR / 7-Zip(适用于 Windows)

  • Zipeg / iZip / UnRarX(适用于 Mac)

  • 7-Zip / PeaZip(适用于 Linux)

这本书的代码包也托管在 GitHub 上,地址为github.com/PacktPublishing/Machine-Learning-Algorithms。我们还有其他来自我们丰富的图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!

下载本书的颜色图像

我们还为您提供了一个包含本书中使用的截图/图表的颜色图像的 PDF 文件。这些颜色图像将帮助您更好地理解输出的变化。您可以从www.packtpub.com/sites/default/files/downloads/MachineLearningAlgorithms_ColorImages.pdf下载此文件。

勘误

尽管我们已经尽一切努力确保我们内容的准确性,但错误仍然可能发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误部分下的现有勘误列表中。要查看之前提交的勘误,请转到www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在勘误部分下。

盗版

互联网上对版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现我们作品的任何非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。请通过copyright@packtpub.com与我们联系,并提供疑似盗版材料的链接。我们感谢您在保护我们作者和我们为您提供有价值内容的能力方面的帮助。

问题

如果您对本书的任何方面有问题,您可以通过questions@packtpub.com与我们联系,我们将尽力解决问题。

第一章:机器学习入门

在过去的几年里,机器学习已经成为最重要的和最有成效的 IT 和人工智能分支之一。它在其应用每天都在商业领域的各个行业中变得更加普遍,总是伴随着新的和更强大的工具和结果,这并不令人惊讶。开源、生产就绪的框架,以及每月发表的数百篇论文,正在推动 IT 历史上最普遍的民主化进程之一。但为什么机器学习如此重要和有价值呢?

简介 - 经典和自适应机器

从古至今,人类一直在建造工具和机器来简化他们的工作,并减少完成许多不同任务所需的总体努力。即使不知道任何物理定律,他们也发明了杠杆(首次由阿基米德正式描述)、仪器和更复杂的机器来执行更长和更复杂的程序。用锤子钉钉子变得更容易、更不痛苦,使用手推车搬运重石或木材也是如此。但是,这两个例子有什么区别呢?即使后者仍然是一个简单的机器,但其复杂性允许一个人在不考虑每一步的情况下完成一个复合任务。一些基本的机械定律在允许水平力有效地对抗重力方面起着主要作用,但人类、马或牛对此一无所知。原始人只是观察到一个天才的技巧(轮子)如何改善他们的生活。

我们学到的教训是,一个机器如果没有具体的使用可能性,不能以实用主义的方式使用,那么它永远不会高效或时尚。如果一个用户可以轻松理解哪些任务可以以更少的努力或完全自动完成,那么这个机器立即被认为是有用的,并且注定要不断改进。在后一种情况下,似乎在齿轮、轮子或轴旁边出现了一些智能。因此,我们可以将自动机器添加到我们的进化列表中:这些机器(如今我们称之为编程)是为了通过将能量转化为工作来完成特定目标而构建的。风车或水车是一些能够以最小(与直接活动相比)的人类控制完成完整任务的简单工具。

在下面的图中,有一个经典系统的通用表示,该系统接收一些输入值,处理它们,并产生输出结果:

图片

但再次强调,磨坊成功的关键是什么?我们并不急于说,自从技术诞生以来,人类就试图将一些智能转移到他们的工具上。河流中的水和风都表现出我们可以简单称之为流动的行为。它们拥有大量的能量,无需任何费用就能为我们提供,但机器应该有一定的意识来促进这个过程。一个轮子可以在固定的轴上转动数百万次,但风必须找到一个合适的表面来推动。答案似乎很明显,但你应该尝试思考那些没有任何知识或经验的人;即使是无意识的,他们也开始了对技术的一种全新的方法。如果你倾向于将“智能”这个词保留给更近期的成果,那么可以说,这条路始于工具,首先转向简单的机器,然后转向更智能的机器。

在没有进一步的中介(但同样重要)步骤的情况下,我们可以跳入我们的时代,改变我们讨论的范围。可编程计算机已经普及,灵活,并且越来越强大;此外,互联网的扩散使我们能够以最小的努力共享软件应用和相关信息。我使用的文字处理软件、我的电子邮件客户端、网络浏览器以及在同一台机器上运行的许多其他常见工具都是这种灵活性的例子。不可否认,信息技术革命极大地改变了我们的生活,有时也改善了我们的日常工作,但没有机器学习(及其所有应用),还有很多任务似乎远远超出了计算机领域。垃圾邮件过滤、自然语言处理、使用网络摄像头或智能手机进行视觉跟踪、预测分析,这些都是革命性地改变了人机交互并提高了我们期望的少数应用。在许多情况下,它们将我们的电子工具转变为实际的认知扩展,改变了我们与许多日常情况的互动方式。它们通过填补人类感知、语言、推理、模型和人工工具之间的差距来实现这一目标。

下面是一个自适应系统的示意图:

自适应系统示意图

这样的系统并不是基于静态或永久性的结构(模型参数和架构),而是基于持续适应其行为以适应外部信号(数据集或实时输入)的能力,就像人类一样,使用不确定和零散的信息来预测未来。

只有学习才是最重要的

学习究竟意味着什么?简单来说,我们可以认为学习是能够根据外部刺激而改变的能力,并且记住大部分以往的经验。因此,机器学习是一种工程方法,它给予每个能够增加或改善适应性改变倾向的技术以最大重视。例如,机械表是一种非凡的工艺品,但其结构遵循的是静态定律,如果外部发生变化,它就会变得无用。这种能力是动物所特有的,尤其是人类;根据达尔文的学说,这也是所有物种生存和进化的关键成功因素。即使机器不能自主进化,它们似乎也遵循相同的法则。

因此,机器学习的主要目标是研究、设计和改进数学模型,这些模型可以通过与上下文相关的数据(由一个通用环境提供)进行训练(一次或持续进行),以推断未来并做出决策,而不需要完全了解所有影响因素(外部因素)。换句话说,一个智能体(它是一个从环境中接收信息、选择最佳行动以实现特定目标并观察其结果的软件实体)采用统计学习方法,试图确定正确的概率分布,并使用它们来计算最有可能成功的行动(价值或决策)(误差最小)。

我更倾向于使用“推理”这个词而不是“预测”,只是为了避免这种奇怪(但并不罕见)的想法,即机器学习是一种现代魔法。此外,可以引入一个基本陈述:一个算法只有在影响实际数据的情况下,才能以相对高的精度外推一般规律并学习它们的结构。因此,“预测”这个术语可以自由使用,但与物理学或系统理论中采用的意义相同。即使在最复杂的场景中,例如使用卷积神经网络进行图像分类,每一条信息(几何、颜色、特殊特征、对比度等)都已经存在于数据中,模型必须足够灵活,能够永久地提取和学习它。

在接下来的章节中,将简要介绍一些常见的机器学习方法。数学模型、算法和实际例子将在后面的章节中讨论。

监督学习

监督场景的特点是存在一个教师或监督者的概念,其主要任务是向智能体提供其误差的精确度量(直接与输出值可比较)。在实际算法中,这一功能由一个由成对元素(输入和预期输出)组成的训练集提供。从这些信息出发,智能体可以调整其参数,以减少全局损失函数的幅度。在每次迭代之后,如果算法足够灵活且数据元素一致,整体准确度会提高,预测值与预期值之间的差异会接近于零。当然,在监督场景中,目标是训练一个系统,该系统还必须能够处理之前从未见过的样本。因此,有必要让模型发展出泛化能力,并避免一个常见的称为过拟合的问题,这是由于过大的容量导致的过度学习(我们将在下一章中更详细地讨论这个问题,但可以这样说,这种问题的主要影响之一是只能正确预测用于训练的样本,而对于其他样本的错误率始终非常高)。

在下面的图中,一些训练点用圆圈标记,细蓝色的线代表完美的泛化(在这种情况下,连接是一个简单的线段):

图片

使用相同的训练数据集(对应于两条较大的线)训练了两个不同的模型。前者是不可接受的,因为它不能泛化并捕捉最快的动态(从频率的角度来看),而后者在原始趋势和正确泛化预测分析中的残余能力之间似乎是一个非常好的折衷方案。

形式上,前面的例子被称为回归,因为它基于连续的输出值。相反,如果只有有限数量的可能结果(称为类别),这个过程就变成了分类。有时,与其预测实际的类别,不如确定其概率分布更好。例如,一个算法可以被训练来识别手写的字母,因此其输出是分类的(在英语中,将有 26 个允许的符号)。另一方面,即使是对于人类来说,当字母的视觉表示不够清晰,不足以属于单一类别时,这个过程也可能导致多个可能的输出。这意味着实际的输出最好用离散概率分布来描述(例如,使用 26 个连续值,并归一化,使它们总是加起来等于 1)。

在下面的图中,有一个具有两个特征的元素分类的例子。大多数算法通过施加不同的条件来尝试找到最佳分离超平面(在这种情况下,这是一个线性问题),但目标始终相同:减少误分类的数量并增加噪声鲁棒性。例如,看看靠近平面的三角形点(其坐标大约是[5.1 - 3.0])。如果第二个特征的幅度受到噪声的影响,其值相当小于 3.0,那么一个稍微高一些的超平面可能会错误地将其分类。我们将在后面的章节中讨论一些强大的技术来解决这些问题。

图片

常见的监督学习应用包括:

  • 基于回归或分类分类的预测分析

  • 垃圾邮件检测

  • 模式检测

  • 自然语言处理

  • 情感分析

  • 自动图像分类

  • 自动序列处理(例如,音乐或语音)

无监督学习

这种方法基于没有任何监督者和绝对误差度量;当需要学习如何根据相似性(或距离度量)将一组元素分组(聚类)时很有用。例如,观察前面的图,人可以立即识别出两组,而不考虑颜色或形状。事实上,圆形点(以及三角形点)确定了一个连贯的集合;它与其他集合的分离程度远大于其内部点的分离程度。用比喻来说,一个理想的场景是一片只有几个岛屿的海域,这些岛屿可以通过它们的相互位置和内部凝聚力来相互分离。

在下一张图中,每个椭圆代表一个簇,其区域内的所有点都可以用相同的方式进行标记。还有一些边界点(如重叠圆区域的三角形),需要特定的标准(通常是权衡距离度量)来确定相应的簇。就像对于具有模糊性的分类(P 和变形的 R)一样,一个好的聚类方法应该考虑异常值的存在,并相应地处理它们,以提高内部连贯性(在视觉上,这意味着选择一个最大化局部密度的子划分)以及簇之间的分离。

例如,可以优先考虑单个点与质心的距离,或者属于同一簇和不同簇的点之间的平均距离。在这张图中,所有边界三角形彼此都很接近,所以最近的邻居是另一个三角形。然而,在现实生活中的问题中,通常存在边界区域,其中存在部分重叠,这意味着一些点由于特征值而具有高度的不确定性。

图片

另一种解释可以使用概率分布来表示。如果你看椭圆,它们代表在最小和最大方差之间的多变量高斯分布区域。考虑到整个域,一个点(例如,一个蓝色星号)可能属于所有簇,但第一个(左下角)给出的概率最高,因此这决定了成员资格。一旦所有高斯分布的方差和均值(换句话说,形状)变得稳定,每个边界点就会自动被单个高斯分布捕获(除非概率相等)。技术上,我们说这种方法最大化了给定一定数据集的高斯混合物的似然性。这是一个非常重要的统计学习概念,跨越了许多不同的应用,因此将在下一章中更深入地探讨。此外,我们将讨论一些常见的聚类方法,考虑它们的优缺点,并比较它们在各种测试分布上的性能。

其他重要的技术涉及使用标记和无标记数据。因此,这种方法被称为半监督,可以在需要用少量完整(标记)示例对大量数据进行分类时采用,或者当需要向聚类算法施加某些约束时(例如,将某些元素分配到特定簇或排除其他元素)。

常见的无监督应用包括:

  • 物体分割(例如,用户、产品、电影、歌曲等)

  • 相似度检测

  • 自动标注

强化学习

即使没有实际监督者,强化学习也是基于环境提供的反馈。然而,在这种情况下,信息更加定性,并不能帮助代理确定其错误的精确度量。在强化学习中,这种反馈通常被称为奖励(有时,负奖励被定义为惩罚),并且了解在某个状态下执行的动作是积极还是消极是有用的。最有用的动作序列是代理必须学习的策略,因此能够始终做出最佳决策,以获得最高即时的和累积奖励。换句话说,一个动作也可能是不完美的,但从全局策略的角度来看,它必须提供最高的总奖励。这个概念基于这样一个观点,即理性代理总是追求可以增加其财富的目标。能够预见长远前景是高级代理的显著特征,而短视的代理通常无法正确评估其即时行动的后果,因此他们的策略总是次优的。

当环境不是完全确定性的,当它通常非常动态,并且无法有一个精确的错误度量时,强化学习特别有效。在过去的几年里,许多经典算法已经应用于深度神经网络,以学习玩 Atari 视频游戏的最佳策略,并教会智能体如何将正确的行动与表示状态的输入(通常是屏幕截图或内存转储)相关联。

在以下图中,有一个训练以玩著名 Atari 游戏的深度神经网络的示意图。作为输入,有一个或多个连续的屏幕截图(这通常足以捕捉时间动态)。它们通过不同的层(稍后简要讨论)进行处理,以产生代表特定状态转换策略的输出。在应用此策略后,游戏产生反馈(作为奖励-惩罚),并使用此结果来细化输出,直到它变得稳定(因此状态被正确识别,建议的行动总是最好的)并且总奖励超过预定义的阈值。

图片

我们将在介绍深度学习和 TensorFlow 的章节中讨论一些强化学习的例子。

超越机器学习 - 深度学习和生物启发自适应系统

在过去的几年里,得益于更强大且更便宜的计算机,许多研究人员开始采用复杂的(深度)神经网络架构来实现仅二十年前难以想象的目标。自 1957 年罗森布拉特发明第一个感知器以来,对神经网络的研究兴趣日益增长。然而,许多限制(涉及内存和 CPU 速度)阻碍了大规模的研究,并隐藏了这些算法类型的大量潜在应用。

在过去十年中,许多研究人员开始训练更大和更大的模型,这些模型由多个不同的层构建(这就是为什么这种方法被称为深度学习),以解决新的挑战性问题。便宜且快速的计算机的可用性使他们能够在可接受的时间内获得结果,并使用非常大的数据集(由图像、文本和动画组成)。这种努力导致了令人印象深刻的成果,特别是在基于像素的分类和实时智能交互中使用强化学习。

这些技术背后的理念是创建像大脑一样工作的算法,这一领域的重要进步得益于神经科学和认知心理学的贡献。特别是,对模式识别和联想记忆的兴趣日益增长,其结构和功能与新皮层中发生的情况相似。这种方法还允许使用更简单的算法,称为无模型;这些算法不是基于任何特定问题的数学物理公式,而是基于通用的学习技术和重复的经验。

当然,测试不同的架构和优化算法比定义一个复杂的模型要简单得多(并且可以通过并行处理来完成)。此外,深度学习在性能上优于其他方法,即使没有基于上下文的模型。这表明,在许多情况下,拥有一个不那么精确但具有不确定性的决策,比由一个非常复杂的模型(通常速度不快)确定的精确决策更好。对于动物来说,这往往是生死攸关的问题,如果它们成功了,那是因为它们隐含地放弃了某些精度。

常见的深度学习应用包括:

  • 图像分类

  • 实时视觉跟踪

  • 自动驾驶汽车

  • 逻辑优化

  • 生物信息学

  • 语音识别

许多这些问题也可以使用经典方法解决,有时方法更为复杂,但深度学习在所有方法中表现最佳。此外,它还允许将这些应用扩展到最初被认为极其复杂的场景,例如自动驾驶汽车或实时视觉物体识别。

本书仅详细介绍了部分经典算法;然而,有许多资源既可以作为入门,也可以用于更深入的了解。

Google DeepMind 团队(deepmind.com)已经取得了许多有趣的结果,我建议您访问他们的网站,了解他们最新的研究和目标。

机器学习和大数据

另一个可以利用机器学习的领域是大数据。在 Apache Hadoop 的首次发布后,它实现了一个高效的 MapReduce 算法,不同商业环境中管理的信息量呈指数增长。同时,它用于机器学习的机会也出现了,如大规模协同过滤等应用成为现实。

想象一个拥有百万用户和仅有一千种产品的在线商店。考虑一个矩阵,其中每个用户通过隐式或显式排名与每个产品相关联。这个矩阵将包含 1,000,000 x 1,000 个单元格,即使产品的数量非常有限,对它的任何操作都将很慢且消耗内存。相反,使用集群以及并行算法,这样的问题就会消失,并且可以在非常短的时间内执行更高维度的操作。

考虑训练一个包含一百万个样本的图像分类器。单个实例需要迭代多次,处理小批量的图片。即使这个问题可以使用流式方法(有限的内存量)执行,等待几天模型才开始表现良好也并不奇怪。采用大数据方法,可以异步训练多个本地模型,定期共享更新,并与主模型重新同步。这项技术也被用于解决一些强化学习问题,其中许多代理(通常由不同的线程管理)玩同样的游戏,定期为 全球 智能做出贡献。

并非每个机器学习问题都适合大数据,而且并非所有大数据集在训练模型时都真正有用。然而,在特定情况下它们的结合可以通过消除通常影响较小场景的许多限制,从而产生非凡的结果。

在关于推荐系统的章节中,我们将讨论如何使用 Apache Spark 实现协同过滤。相同的框架也将被用于一个朴素贝叶斯分类的示例。

如果你想了解更多关于整个 Hadoop 生态系统,请访问 hadoop.apache.org。Apache Mahout (mahout.apache.org) 是一个专门的机器学习框架,而 Spark (spark.apache.org),作为最快的计算引擎之一,有一个名为 MLib 的模块,实现了许多受益于并行处理的常见算法。

进一步阅读

在 Russell S. 和 Norvig P. 的 《人工智能:一种现代方法》 的前几章中可以找到关于人工智能的优秀介绍。在第二卷中,还有关于许多不同环境中统计学习的非常广泛的讨论。关于深度学习的完整书籍是 Goodfellow I.、Bengio Y. 和 Courville A. 的 《深度学习》,由麻省理工学院出版社出版。如果你想了解更多关于新皮层如何工作的信息,Kurzweil R. 的 《如何创造思维》,Duckworth Overlook 出版,提供了一个简单但令人惊叹的介绍。Python 编程语言的全面介绍可以在 Lutz M. 的 《学习 Python》,O'Reilly 出版中找到。

摘要

在本章中,我们介绍了自适应系统的概念;它们可以从经验中学习并修改其行为以最大化实现特定目标的可能性。机器学习是指一组允许实现自适应算法进行预测和根据其共同特征自动组织输入数据的技术的名称。

主要的学习策略有监督学习、无监督学习和强化学习。第一种假设存在一个教师,可以提供关于错误的精确反馈。因此,算法可以将其输出与正确的结果进行比较,并相应地调整其参数。在无监督场景中,没有外部教师,所以所有内容都是直接从数据中学习的。算法将试图找出属于一组元素的所有共同特征,以便能够将新的样本与正确的簇关联起来。前一种类型的例子是由所有根据某些已知特征将对象自动分类到特定类别的自动分类提供,而无监督学习的常见应用是自动将项目分组并进行后续标记或处理。第三种学习类似于监督学习,但它只接收关于其行动质量的环保反馈。它不知道具体是什么错误或其错误的程度,但接收有助于它决定是否继续采用策略或选择另一个策略的通用信息。

在下一章中,我们将讨论机器学习的一些基本要素,特别关注我们在所有其他章节中需要的数学符号和主要定义。我们还将讨论重要的统计学习概念以及关于可学习性和其限制的理论。

第二章:机器学习中的重要元素

在本章中,我们将讨论一些跨越所有机器学习主题的重要元素和方法,并为许多常见技术提供一个哲学基础。首先,了解数据格式和预测函数的数学基础是有用的。在大多数算法中,这些概念以不同的方式处理,但目标始终相同。更近期的技术,如深度学习,广泛使用能量/损失函数,就像本章中描述的那样,即使有细微的差别,好的机器学习结果通常与最佳损失函数的选择和使用正确的算法来最小化它有关。

数据格式

在监督学习问题中,将始终存在一个数据集,定义为具有每个 m 个特征的有限个实向量集合:

图片

考虑到我们的方法始终是概率性的,我们需要将每个 X 视为从统计多元分布 D 中抽取的。对于我们的目的,在整体数据集 X 上添加一个非常重要的条件也是有用的:我们期望所有样本都是 独立且 同分布的i.i.d)。这意味着所有变量都属于同一个分布 D,并且考虑一个任意的 m 个值的子集,它发生的情况是:

图片

对应的输出值可以是数值-连续或分类。在前一种情况下,该过程称为 回归,而在第二种情况下,它称为 分类。数值输出的例子包括:

图片

分类示例包括:

图片

我们定义了通用的 回归器,一个将输入值关联到连续输出值的向量值函数,以及通用的 分类器,一个预测输出为分类(离散)的向量值函数。如果它们还依赖于一个内部参数向量,该向量决定了通用预测器的实际实例,则该方法称为 参数学习方法

图片

另一方面,非参数学习不对预测器的家族做出初始假设(例如,定义r(...)c(...)的通用参数化版本)。一个非常常见的非参数家族被称为基于实例的学习,它基于仅由训练样本(实例集)确定的假设进行实时预测(无需预先计算参数值)。一种简单且广泛采用的方法是采用邻域的概念(具有固定的半径)。在分类问题中,一个新样本会自动被分类的训练元素所包围,输出类别是根据邻域中的主导元素确定的。在这本书中,我们将讨论属于此类的一个非常重要的算法家族:基于核的支持向量机。更多示例可以在 Russel S.,Norvig P.的《人工智能:一种现代方法》,Pearson*中找到。

内部动态和所有元素的解释都是每个单独算法特有的,因此我们更愿意现在不讨论阈值或概率,而是尝试使用一个抽象的定义。一个通用的参数化训练过程必须找到最佳参数向量,该向量在给定的特定训练数据集上最小化回归/分类误差,并且它还应该生成一个预测器,当提供未知样本时可以正确泛化。

另一种解释可以用加性噪声来表示:

图片

对于我们的目的,我们可以期望在完美预测中添加零均值和低方差的高斯噪声。训练任务必须通过优化参数来增加信噪比。当然,每当这样的术语不具有零均值(独立于其他X值)时,可能意味着存在一个必须考虑的隐藏趋势(可能是一个过早被丢弃的特征)。另一方面,高噪声方差意味着X是脏的,其测量不可靠。

到目前为止,我们假设回归和分类都操作于m-长度向量,但只产生一个值或一个标签(换句话说,一个输入向量总是与一个输出元素相关联)。然而,有许多策略来处理多标签分类和多输出回归。

在无监督学习中,我们通常只有一个输入集X,其中包含m-长度向量,我们定义聚类函数(具有n个目标聚类)如下:

图片

在大多数 scikit-learn 模型中,存在一个实例变量coef_,其中包含所有训练参数。例如,在单参数线性回归(我们将在下一章中广泛讨论它),输出将是:

>>> model = LinearRegression()
>>> model.fit(X, Y)
>>> model.coef_
array([ 9.10210898])

多类策略

当输出类别的数量大于一个时,管理分类问题有两种主要可能性:

  • 一对多

  • 一对一

在这两种情况下,选择都是透明的,返回给用户的输出将始终是最终值或类别。然而,为了优化模型并始终选择最佳替代方案,理解不同的动态是很重要的。

一对多

这可能是最常用的策略,并且 scikit-learn 的大多数算法都广泛采用。如果有n个输出类别,将并行训练n个分类器,考虑到实际类别和剩余类别之间总是存在分离。这种方法相对轻量(最多需要n-1次检查来找到正确的类别,因此它具有O(n)的复杂性),因此通常是默认选择,无需进一步操作。

一对一

与一对多相比,一种替代方案是为每一对类别训练一个模型。复杂性不再是线性的(实际上它是O(n²))并且正确的类别是通过多数投票确定的。一般来说,这种选择成本更高,只有在完整数据集比较不合适时才应采用。

如果你想了解更多关于 scikit-learn 实现的多类策略,请访问

scikit-learn.org/stable/modules/multiclass.html

可学习性

一个参数模型可以被分为两部分:一个静态结构和一组动态参数。前者由特定算法的选择决定,通常是不可变的(除了模型提供某些重新建模功能的情况),而后者是我们优化的目标。考虑到n个无界参数,它们生成一个n-维空间(施加边界会产生一个子空间,在我们的讨论中不会引起相关变化)其中每个点,连同估计函数的不可变部分,代表一个学习假设H(与一组特定的参数相关联):

图片

参数学习过程的目标是找到最佳假设,其对应的预测误差最小,并且剩余的泛化能力足够以避免过拟合。在下面的图中,有一个示例数据集,其点必须被分类为红色(类别 A)或蓝色(类别 B)。显示了三个假设:第一个(从左到右开始的中间线)错误地分类了一个样本,而下面和上面的分别错误地分类了 13 个和 23 个样本:

图片

当然,第一个假设是最优的,应该被选中;然而,理解一个基本概念非常重要,这个概念可以决定潜在的过拟合。思考一个n-维二进制分类问题。我们说数据集X线性可分(没有变换)的,如果存在一个超平面可以将空间分为两个子空间,只包含属于同一类的元素。移除线性的约束,我们有无穷多的使用通用超面的替代方案。然而,参数模型只采用一族非周期性和近似函数,其振荡和拟合数据集的能力由参数的数量(有时以非常复杂的方式)决定。

考虑以下图中所示的示例:

图片

蓝色分类器是线性的,而红色的是立方的。乍一看,非线性策略似乎表现更好,因为它可以通过其凹面捕捉更多的表达能力。然而,如果按照最后四个样本(从右到左)定义的趋势添加新的样本,它们将被完全错误分类。事实上,虽然线性函数在全局上更好,但不能捕捉 0 和 4 之间的初始振荡,而立方方法可以几乎完美地拟合这些数据,但同时也失去了保持全局线性趋势的能力。因此,有两种可能性:

  • 如果我们期望未来的数据与训练样本的分布完全相同,那么一个更复杂的模型可以是一个好的选择,以捕捉低级模型会丢弃的小变化。在这种情况下,一个线性(或低级)模型将导致欠拟合,因为它无法捕捉适当的表达能力水平。

  • 如果我们认为未来的数据可以在局部以不同的方式分布,但保持全局趋势,那么更倾向于有更高的残余误分类错误以及更精确的泛化能力。仅关注训练数据的大模型可能导致过拟合。

欠拟合和过拟合

机器学习模型的目的是对一个未知函数进行近似,该函数将输入元素与输出元素(对于分类器,我们称之为类别)关联起来。然而,训练集通常是一个全局分布的表示,但它不能包含所有可能的元素;否则问题可以用一对一的关联来解决。同样,我们不知道可能的基本函数的解析表达式,因此,在训练时,必须考虑拟合模型,但保持它在面对未知输入时可以自由泛化的能力。不幸的是,这种理想条件并不总是容易找到,并且考虑两种不同的危险是很重要的:

  • 欠拟合:这意味着模型无法捕捉出由相同的训练集所展示的动态(可能是因为其容量过于有限)。

  • 过拟合:模型具有过度的容量,并且它不能根据训练集提供的原始动态进行泛化。它可以几乎完美地将所有已知样本与其相应的输出值关联起来,但当呈现未知输入时,相应的预测误差可能非常高。

在下面的图片中,有低容量(欠拟合)、正常容量(正常拟合)和过度容量(过拟合)插值的例子:

图片

避免欠拟合和过拟合非常重要。考虑到预测误差,欠拟合更容易检测,而过拟合可能证明更难发现,因为它最初可能被认为是完美拟合的结果。

在下一章中我们将讨论的交叉验证和其他技术可以很容易地显示我们的模型如何与训练阶段从未见过的测试样本一起工作。这样,我们就可以在一个更广泛的环境中评估泛化能力(记住,我们不是处理所有可能值,而是一直处理一个应该反映原始分布的子集)。

然而,一个通用的经验法则表明,一个残差误差总是必要的,以保证良好的泛化能力,而一个在训练样本上显示 99.999...百分比的验证精度的模型几乎肯定过拟合了,并且很可能无法正确预测从未见过的输入样本。

误差度量

通常,当在监督场景下工作时,我们定义一个非负误差度量 e[m],它接受两个参数(预期输出和预测输出),并允许我们计算整个数据集(由 n 个样本组成)的总误差值:

图片

这个值也隐式地依赖于特定的假设 H 通过参数集,因此优化误差意味着找到一个最优假设(考虑到许多优化问题的难度,这并不是绝对最好的,但是一个可接受的近似)。在许多情况下,考虑均方误差MSE)是有用的:

图片

其初始值代表了一个 n 变量函数表面的一个起点。一个通用的训练算法必须找到全局最小值或一个非常接近它的点(总是有一个容忍度以避免过多的迭代和随之而来的过拟合风险)。这个度量也被称为损失函数,因为它的值必须通过一个优化问题来最小化。当容易确定一个必须最大化的元素时,相应的损失函数将是它的倒数。

另一个有用的损失函数被称为零一损失,它特别适用于二元分类(也适用于一对一多类策略):

这个函数隐式地是一个指示器,可以很容易地应用于基于误分类概率的损失函数。

一个通用的(连续的)损失函数的有帮助的解释可以用势能来表示:

预测器就像粗糙表面上的一颗球:从一个能量(=错误)通常相当高的随机点开始,它必须移动直到它达到一个稳定的平衡点,在那里它的能量(相对于全局最小值)为零。在下面的图中,有一些不同情况的概念图示:

就像在物理情况中一样,起点在没有外部扰动的情况下是稳定的,因此为了启动这个过程,需要提供初始动能。然而,如果这种能量足够强大,那么在沿着斜坡下降后,球不能停在全局最小值处。剩余的动能可能足以克服脊并达到正确的山谷。如果没有其他能量来源,球就会被困在平原山谷中,无法再移动。已经开发了许多技术来解决此问题并避免局部最小值。然而,必须始终仔细分析每种情况,以了解可以接受的剩余能量(或错误)水平,或者是否最好采用不同的策略。我们将在下一章中讨论其中的一些。

PAC 学习

在许多情况下,机器学习似乎无缝工作,但有没有什么方法可以正式确定一个概念的可学习性?1984 年,计算机科学家 L. Valiant 提出了一种数学方法来确定一个问题是否可以通过计算机学习。这种技术的名称是 PAC,或 可能近似正确

原始公式(您可以在 Valiant L. 的《可学习理论》《ACM 通讯》,第 27 卷,第 11 期,1984 年 11 月中找到)基于一个特定的假设,然而,在不损失大量精度的情况下,我们可以考虑一个分类问题,其中算法 A 必须学习一组概念. 具体来说,一个概念是输入模式 X 的一个子集,它决定了相同的输出元素。因此,学习一个概念(参数化地)意味着最小化对应损失函数在特定类别的限制,而学习所有可能的概念(属于同一宇宙),意味着找到全局损失函数的最小值。

然而,给定一个问题,我们有许多可能的(有时,理论上无限)假设,并且通常需要进行概率权衡。因此,我们基于有限数量的输入元素和多项式时间内产生的结果,以高概率接受良好的近似。

因此,算法A能够学习所有概念(使它们成为 PAC 可学习的)的类别C,如果它能够找到一个假设H,通过一个O(n^k)过程,使得A,以概率p,可以在最大允许误差m[e]下正确分类所有模式。这必须对所有X上的统计分布和训练样本的数量有效,该数量必须大于或等于仅取决于pm[e]的最小值。

计算复杂度的限制不是一个次要问题,实际上,我们期望我们的算法在问题相当复杂的情况下也能在合理的时间内高效地学习。当数据集太大或优化起点离可接受的最低点非常远时,指数时间可能导致计算爆炸。此外,重要的是要记住所谓的维度诅咒,这是一种在某些模型中经常发生的效果,其中训练或预测时间与维度成比例(不一定是线性关系),因此当特征数量增加时,模型的性能(当输入维度较小时可能是合理的)会急剧下降。此外,在许多情况下,为了捕捉完整的表达能力,需要一个非常大的数据集,而没有足够的训练数据,近似可能会变得有问题(这被称为休斯现象)。因此,寻找多项式时间算法不仅仅是一个简单的努力,因为它可以决定机器学习问题的成功或失败。因此,在接下来的章节中,我们将介绍一些可以用来有效地降低数据集维度而不损失信息的技术的技术。

统计学习方法

假设你需要从以下初始(过于简化的)基于两个参数的分类开始设计一个垃圾邮件过滤算法:

参数 垃圾邮件(X[1]) 常规邮件(X2)
p[1] - 包含> 5 个黑名单词汇 80 20
p[2]- 消息长度< 20 个字符 75 25

我们收集了 200 封电子邮件(X)(为了简单起见,我们考虑p[1]p[2]相互排斥)并且我们需要找到一对概率假设(用p[1]p[2]来表示),以确定:

我们还假设这两个项的条件独立性(这意味着h[p1]h[p2]以相同的方式共同贡献垃圾邮件,就像它们单独存在时一样)。

例如,我们可以考虑以下规则(假设):“如果有超过五个黑名单词汇”或“如果消息长度小于 20 个字符”那么“垃圾邮件的概率很高”(例如,大于 50%)。然而,如果没有分配概率,当数据集发生变化时(如现实世界中的反垃圾邮件过滤器),很难进行泛化。我们还想确定一个分区阈值(如绿色、黄色和红色信号),以帮助用户决定保留什么和删除什么。

由于假设是通过数据集 X 确定的,我们也可以以离散形式写出:

图片

在这个例子中,确定每个项的值相当容易。然而,在一般情况下,有必要引入贝叶斯公式(将在第六章,朴素贝叶斯):

图片

比例性是必要的,以避免引入边缘概率 P(X),它只起一个规范化因子的作用(记住,在离散随机变量中,所有可能的概率结果之和必须等于 1)。

在前一个方程中,第一项被称为后验概率(which comes after),因为它是由一个边缘先验概率(which comes first)乘以一个称为似然的因子所确定的。为了理解这种方法的哲学,举一个简单的例子很有用:抛一个公平的硬币。每个人都知道每个面的边缘概率都是相等的,等于 0.5,但谁决定了这一点?这是逻辑和概率公理的理论结果(一个好的物理学家会说,由于我们简单地忽略了几个因素,它永远不会是 0.5)。抛硬币 100 次后,我们观察结果,令人惊讶的是,我们发现正反比略有所不同(例如,0.46)。我们如何纠正我们的估计?称为似然的项衡量我们的实际实验在多大程度上证实了先验假设,并确定另一个概率(后验概率),它反映了实际情况。因此,似然帮助我们动态地纠正估计,克服固定概率的问题。

在第六章,朴素贝叶斯,专门讨论朴素贝叶斯算法的章节中,我们将深入讨论这些主题,并使用 scikit-learn 实现一些示例,然而,在这里介绍两种非常普遍的统计学习方法是有用的。有关更多信息,请参阅Russel S.,Norvig P.,人工智能:一种现代方法,Pearson

MAP 学习

在选择合适的假设时,贝叶斯方法通常是最好的选择之一,因为它考虑了所有因素,并且正如我们将要看到的,即使它基于条件独立性,当某些因素部分相关时,这种方法也能完美地工作。然而,其复杂性(从概率的角度来看)可以轻易增长,因为所有项都必须始终被考虑。例如,一枚真实的硬币是一个非常短的圆柱体,所以在抛硬币时,我们也应该考虑偶数的概率。比如说,它是 0.001。这意味着我们有三种可能的结果:P(head) = P(tail) = (1.0 - 0.001) / 2.0 和 P(even) = 0.001。后者事件显然不太可能,但在贝叶斯学习中必须考虑(即使它会被其他项的强度所压缩)。

另一个选择是选择基于后验概率的最可能假设:

图片

这种方法被称为最大后验概率估计(MAP)并且它确实可以简化某些假设非常不可能的情况(例如,在抛硬币时,MAP 假设将丢弃P(even))。然而,它仍然有一个重要的缺点:它依赖于先验概率(记住,最大化后验概率意味着也要考虑先验概率)。正如 Russel 和 Norvig(Russel S.,Norvig P.,《人工智能:一种现代方法》,Pearson)所指出的,这通常是推理过程中的一个微妙部分,因为总有一个理论背景可以导致特定的选择并排除其他选择。为了仅依赖于数据,有必要采用不同的方法。

最大似然学习

我们已经将似然定义为贝叶斯公式中的过滤项。一般来说,它具有以下形式:

图片

这里第一个项表示给定数据集X的假设的实际似然。正如你可以想象的,在这个公式中不再有先验概率,所以最大化它并不意味着接受一个理论上的偏好假设,也不考虑不可能的假设。一个非常常见的方法,称为期望最大化(expectation-maximization)并且被许多算法使用(我们将在逻辑回归中看到一个例子),分为两个主要部分:

  • 基于模型参数确定对数似然表达式(它们将被相应优化)

  • 最大化它直到残差误差足够小

对数似然(通常称为L)是一个有用的技巧,可以简化梯度计算。一个通用的似然表达式是:

图片

由于所有参数都在h[i]中,梯度是一个复杂的表达式,不太容易管理。然而,我们的目标是最大化似然,但最小化其倒数更容易:

图片

通过应用自然对数(这是一个单调函数),这可以转化为一个非常简单的表达式:

最后这一项是一个求和,它可以在大多数优化算法中轻松推导和使用。在完成这个过程后,我们可以找到一组参数,它提供了最大似然,而不需要对先验分布做出任何强烈的声明。这种方法可能看起来非常技术性,但它的逻辑实际上非常简单直观。为了理解它是如何工作的,我提出一个简单的练习,这是高斯混合技术的一部分,也在 Russel S.,Norvig P.的《人工智能:一种现代方法》,Pearson.中讨论过。

让我们考虑从均值为零、标准差等于 2.0 的高斯分布中抽取的 100 个点(由独立样本组成的准白色噪声):

import numpy as np

nb_samples = 100
X_data = np.random.normal(loc=0.0, scale=np.sqrt(2.0), size=nb_samples)

下图展示了情节:

在这个情况下,没有必要进行深入探索(我们知道它们是如何生成的),然而,在将假设空间限制为高斯族(仅考虑图的情况下最合适的)之后,我们希望找到均值和方差的最佳值。首先,我们需要计算对数似然(由于指数函数的存在,这相当简单):

接下来提供了一个简单的 Python 实现(为了方便使用,这里只有一个数组,它包含均值(0)和方差(1)):

def negative_log_likelihood(v):
 l = 0.0
 f1 = 1.0 / np.sqrt(2.0 * np.pi * v[1]) 
 f2 = 2.0 * v[1]

 for x in X_data:
 l += np.log(f1 * np.exp(-np.square(x - v[0]) / f2))

 return -l

然后我们需要使用任何可用的方法(梯度下降或其他数值优化算法)找到它的最小值(就均值和方差而言)。例如,使用scipy的最小化函数,我们可以轻松地得到:

from scipy.optimize import minimize

>>> minimize(fun=negative_log_likelihood, x0=[0.0, 1.0])

 fun: 172.33380423827057
 hess_inv: array([[ 0.01571807,  0.02658017],
       [ 0.02658017,  0.14686427]])
      jac: array([  0.00000000e+00,  -1.90734863e-06])
  message: 'Optimization terminated successfully.'
     nfev: 52
      nit: 9
     njev: 13
   status: 0
  success: True
        x: array([ 0.04088792,  1.83822255])

接下来绘制了负对数似然函数的图表。该函数的全局最小值对应于给定一定分布下的最优似然。这并不意味着问题已经完全解决,因为该算法的第一步是确定一个期望值,这个期望值必须始终是现实的。然而,似然函数对错误的分布非常敏感,因为它在概率低时很容易接近零。因此,最大似然ML)学习通常比需要先验分布的 MAP 学习更可取,后者在未以最合适的方式选择时可能会失败:

这种方法已经应用于特定的分布族(这确实很容易管理),但当模型更复杂时,它也能完美地工作。当然,始终有必要对如何确定似然性有一个初步的了解,因为多个可行的家族可以生成相同的数据集。在这些所有情况下,奥卡姆剃刀是最佳的前进方式:应该首先考虑最简单的假设。如果它不合适,可以给我们的模型添加额外的复杂度。正如我们将看到的,在许多情况下,最简单的解决方案就是获胜的方案,增加参数的数量或使用更详细模型只会增加噪声和过拟合的可能性。

SciPy (www.scipy.org) 是一组针对 Python 的高端科学和数据导向的库。它包括 NumPy、Pandas 以及许多其他有用的框架。如果你想了解更多关于 Python 科学计算的信息,请参考 Johansson R. 的 Numerical Python,Apress 出版,或者 Landau R. H.、Pàez M. J.、Bordeianu C. C. 的 Computational Physics. Problem Solving with Python,Wiley-VCH 出版。

信息论要素

机器学习问题也可以从信息传递或交换的角度进行分析。我们的数据集由 n 个特征组成,这些特征被认为是独立的(为了简单起见,即使这通常是一个现实的假设),它们来自 n 个不同的统计分布。因此,有 n 个概率密度函数 pi,必须通过其他 nq**i 函数来近似。在任何机器学习任务中,理解两个相应的分布如何发散以及当我们近似原始数据集时损失多少信息是非常重要的。

最有用的度量称为

这个值与 X 的不确定性成正比,并且以 比特(如果对数的底数不同,这个单位也可能改变)来衡量。对于许多目的来说,高熵是首选的,因为它意味着某个特征包含更多的信息。例如,在抛硬币(两种可能的结果)的情况下,H(X) = 1 比特,但如果结果的数量增加,即使概率相同,H(X) 也会因为更多不同的值和因此增加的变异性而增加。可以证明,对于高斯分布(使用自然对数):

因此,熵与方差成正比,方差是衡量单个特征所携带信息量的度量。在下一章中,我们将讨论一种基于方差阈值的特征选择方法。高斯分布非常常见,因此这个例子可以被视为特征过滤的一般方法:低方差意味着低信息水平,模型通常会丢弃所有这些特征。

在以下图中,展示了高斯分布的 H(X) 图形,该分布以 nats(当使用自然对数时对应的单位)表示:

图片

例如,如果一个数据集由一些特征组成,这些特征的方差(在这里更方便谈论标准差)介于 8 和 10 之间,而少数具有 STD < 1.5 的特征,后者可以在有限的信息损失下被丢弃。这些概念在现实生活中的问题中非常重要,当必须以高效的方式清理和处理大量数据集时。

如果我们有一个目标概率分布 p(x),它被另一个分布 q(x) 近似,一个有用的度量是 pq 之间的 交叉熵(我们使用离散定义,因为我们的问题必须通过数值计算来解决):

图片

如果以 2 为底数,它衡量使用针对 Q 优化的代码解码从 P 中抽取的事件所需的位数。在许多机器学习问题中,我们有一个源分布,我们需要训练一个估计器来正确识别样本的类别。如果错误为零,P = Q 且交叉熵最小(对应于熵 H(P))。然而,由于与 Q 一起工作时零错误几乎是不可能的,我们需要支付 H(P, Q) 位的代价,从预测开始确定正确的类别。我们的目标通常是使其最小化,以在不会改变预测输出的阈值以下减少这种“代价”。换句话说,考虑一个二元输出和 sigmoid 函数:我们有一个阈值为 0.5(这是我们能够支付的最大的“代价”),使用阶跃函数(0.6 -> 1,0.1 -> 0,0.4999 -> 0,等等)来识别正确的类别。由于我们的分类器不知道原始分布,我们不能支付这个“代价”,因此有必要将交叉熵降低到可接受的噪声鲁棒性阈值以下(这总是可以实现的最低值)。

为了理解机器学习方法的表现,引入一个 条件 熵或 X 在知道 Y 的知识下的不确定性也是很有用的:

图片

通过这个概念,可以引入互信息的思想,即两个变量共享的信息量,因此,通过 Y 的知识提供的关于 X 的不确定性减少:

图片

直观地,当XY是独立的,它们不共享任何信息。然而,在机器学习任务中,原始特征与其预测之间存在非常紧密的依赖关系,因此我们希望最大化两个分布共享的信息。如果条件熵足够小(因此Y能够很好地描述X),则互信息接近边缘熵H(X),它衡量我们想要学习的信息量。

一种基于信息论的有趣的学习方法,称为最小描述长度MDL),在 Russel S.,Norvig P.的《人工智能:一种现代方法》一书中进行了讨论,Pearson 出版社,我建议您在此处查找有关这些主题的更多信息。

参考文献

  • Russel S.,Norvig P.,《人工智能:一种现代方法》,Pearson

  • Valiant L.,《可学习理论》,ACM 通讯,第 27 卷,第 11 期(1984 年 11 月)

  • Hastie T.,Tibshirani R.,Friedman J.,《统计学习的元素:数据挖掘、推理和预测》,Springer

  • Aleksandrov A.D.,Kolmogorov A.N,Lavrent'ev M.A.,《数学:其内容、方法和意义》,Courier Corporation

摘要

在本章中,我们介绍了一些关于机器学习的主要概念。我们首先从一些基本的数学定义开始,以便对数据格式、标准和函数类型有一个清晰的认识。这种符号将在所有其他章节中采用,并且在技术出版物中也是最广泛使用的。我们讨论了 scikit-learn 如何无缝地处理多类问题,以及何时一种策略比另一种策略更可取。

下一步是引入一些关于可学习性的基本理论概念。我们试图回答的主要问题是:我们如何决定一个问题是否可以通过算法来学习,以及我们能够达到的最大精度是什么。PAC 学习是一个通用的但强大的定义,可以在定义算法边界时采用。实际上,一个 PAC 可学习的问题不仅可以通过合适的算法来管理,而且足够快,可以在多项式时间内计算。然后我们引入了一些常见的统计学习概念,特别是 MAP 和最大似然学习方法。前者试图选择最大化后验概率的假设,而后者则处理似然性,寻找与数据最吻合的假设。这种策略在许多机器学习问题中非常普遍,因为它不受先验概率的影响,并且在许多不同的环境中很容易实现。我们还给出了损失函数作为能量函数的物理解释。训练算法的目标是始终尝试找到全局最小点,这对应于误差表面的最深谷。在本章的结尾,简要介绍了信息论以及我们如何用信息增益和熵来重新解释我们的问题。每个机器学习方法都应该努力减少从预测开始并恢复原始(期望)结果所需的信息量。

在下一章中,我们将讨论特征工程的基本概念,这是几乎所有机器学习流程的第一步。我们将展示如何管理不同类型的数据(数值和分类)以及如何在信息损失不大的情况下降低维度。

第三章:特征选择和特征工程

特征工程是机器学习流程中的第一步,涉及所有用于清理现有数据集、增加其信噪比和减少其维度的技术。大多数算法对输入数据都有强烈的假设,当使用原始数据集时,它们的性能可能会受到负面影响。此外,数据很少是各向同性的;通常有一些特征决定了样本的一般行为,而其他相关的特征并不提供任何额外的信息。因此,了解数据集并知道用于减少特征数量或仅选择最佳特征的最常用算法是很重要的。

scikit-learn 玩具数据集

scikit-learn 提供了一些内置数据集,可用于测试目的。它们都包含在 sklearn.datasets 包中,并具有一个共同的格式:数据实例变量包含整个输入集 X,而目标包含分类的标签或回归的目标值。例如,考虑波士顿房价数据集(用于回归),我们有:

from sklearn.datasets import load_boston

>>> boston = load_boston()
>>> X = boston.data
>>> Y = boston.target

>>> X.shape
(506, 13)
>>> Y.shape
(506,)

在这种情况下,我们有 506 个样本,13 个特征和一个单一的目标值。在这本书中,我们将用它来进行回归,以及 MNIST 手写数字数据集(load_digits())用于分类任务。scikit-learn 还提供了从零开始创建虚拟数据集的函数:make_classification()make_regression()make_blobs()(特别适用于测试聚类算法)。它们非常易于使用,在许多情况下,这是测试模型而不加载更复杂数据集的最佳选择。

访问 scikit-learn.org/stable/datasets/ 获取更多信息。

scikit-learn 提供的 MNIST 数据集由于明显的原因而有限。如果您想实验原始版本,请参考由 Y. LeCun、C. Cortes 和 C. Burges 管理的网站:yann.lecun.com/exdb/mnist/。在这里,您可以下载一个包含 70,000 个已拆分为训练集和测试集的手写数字的完整版本。

创建训练集和测试集

当数据集足够大时,将其拆分为训练集和测试集是一个好习惯;前者用于训练模型,后者用于测试其性能。在以下图中,有这个过程的示意图:

执行此类操作有两个主要规则:

  • 两个数据集都必须反映原始分布

  • 在拆分阶段之前,原始数据集必须随机打乱,以避免后续元素之间的相关性。

使用 scikit-learn,这可以通过 train_test_split() 函数实现:

from sklearn.model_selection import train_test_split

>>> X_train, X_test, Y_train, Y_test = train_test_split(X, Y, test_size=0.25, random_state=1000)

参数test_size(以及training_size)允许指定放入测试/训练集的元素百分比。在这种情况下,训练比为 75%,测试比为 25%。另一个重要参数是random_state,它可以接受 NumPy 的RandomState生成器或一个整数种子。在许多情况下,提供实验的可重复性很重要,因此也需要避免使用不同的种子,从而避免不同的随机分割:

我的建议是始终使用相同的数字(也可以是 0 或完全省略),或者定义一个全局RandomState,它可以传递给所有需要的函数。

from sklearn.utils import check_random_state

>>> rs = check_random_state(1000)
<mtrand.RandomState at 0x12214708>

>>> X_train, X_test, Y_train, Y_test = train_test_split(X, Y, test_size=0.25, random_state=rs)

这样,如果种子保持相等,所有实验都必须得出相同的结果,并且可以由其他科学家在不同的环境中轻松复制。

想要了解更多关于 NumPy 随机数生成的信息,请访问docs.scipy.org/doc/numpy/reference/generated/numpy.random.RandomState.html

管理分类数据

在许多分类问题中,目标数据集由无法立即由任何算法处理的分类标签组成。需要编码,scikit-learn 提供了至少两种有效选项。让我们考虑一个由 10 个具有两个特征的分类样本组成的小数据集:

import numpy as np

>>> X = np.random.uniform(0.0, 1.0, size=(10, 2))
>>> Y = np.random.choice(('Male','Female'), size=(10))
>>> X[0]
array([ 0.8236887 ,  0.11975305])
>>> Y[0]
'Male'

第一种选择是使用LabelEncoder类,它采用字典导向的方法,将每个类别标签与一个递增的整数号码关联,即实例数组classes_的索引:

from sklearn.preprocessing import LabelEncoder

>>> le = LabelEncoder()
>>> yt = le.fit_transform(Y)
>>> print(yt)
[0 0 0 1 0 1 1 0 0 1]

>>> le.classes_array(['Female', 'Male'], dtype='|S6')

可以用这种方式获得逆变换:

>>> output = [1, 0, 1, 1, 0, 0]
>>> decoded_output = [le.classes_[i] for i in output]
['Male', 'Female', 'Male', 'Male', 'Female', 'Female']

这种方法简单且在许多情况下效果良好,但它有一个缺点:所有标签都被转换成顺序数字。然后,使用实值工作的分类器将根据它们的距离考虑相似数字,而不考虑语义。因此,通常更倾向于使用所谓的独热编码,它将数据二进制化。对于标签,可以使用LabelBinarizer类实现:

from sklearn.preprocessing import LabelBinarizer

>>> lb = LabelBinarizer()
>>> Yb = lb.fit_transform(Y)
array([[1],
       [0],
       [1],
       [1],
       [1],
       [1],
       [0],
       [1],
       [1],
       [1]])

>>> lb.inverse_transform(Yb)
array(['Male', 'Female', 'Male', 'Male', 'Male', 'Male', 'Female', 'Male',
       'Male', 'Male'], dtype='|S6')

在这种情况下,每个分类标签首先被转换成一个正整数,然后转换成一个向量,其中只有一个特征是 1,而其他所有特征都是 0。这意味着,例如,使用具有对应主要类别的峰值的 softmax 分布可以很容易地转换成一个离散向量,其中唯一的非空元素对应于正确的类别。例如:

import numpy as np

>>> Y = lb.fit_transform(Y)
array([[0, 1, 0, 0, 0],
       [0, 0, 0, 1, 0],
       [1, 0, 0, 0, 0]])

>>> Yp = model.predict(X[0])
array([[0.002, 0.991, 0.001, 0.005, 0.001]])

>>> Ypr = np.round(Yp)
array([[ 0.,  1.,  0.,  0.,  0.]])

>>> lb.inverse_transform(Ypr)
array(['Female'], dtype='|S6')

当分类特征的结构类似于字典列表(不一定是密集的,它们可能只为少数几个特征有值)时,可以采用另一种方法。例如:

data = [
   { 'feature_1': 10.0, 'feature_2': 15.0 },
   { 'feature_1': -5.0, 'feature_3': 22.0 },
   { 'feature_3': -2.0, 'feature_4': 10.0 }
]

在这种情况下,scikit-learn 提供了 DictVectorizerFeatureHasher 类;它们都生成可以输入到任何机器学习模型中的稀疏矩阵。后者具有有限的内存消耗,并采用 MurmurHash 3(阅读en.wikipedia.org/wiki/MurmurHash,获取更多信息)。以下是对这两种方法的代码示例:

from sklearn.feature_extraction import DictVectorizer, FeatureHasher

>>> dv = DictVectorizer()
>>> Y_dict = dv.fit_transform(data)

>>> Y_dict.todense()
matrix([[ 10.,  15.,   0.,   0.],
        [ -5.,   0.,  22.,   0.],
        [  0.,   0.,  -2.,  10.]])

>>> dv.vocabulary_
{'feature_1': 0, 'feature_2': 1, 'feature_3': 2, 'feature_4': 3}

>>> fh = FeatureHasher()
>>> Y_hashed = fh.fit_transform(data)

>>> Y_hashed.todense()
matrix([[ 0.,  0.,  0., ...,  0.,  0.,  0.],
        [ 0.,  0.,  0., ...,  0.,  0.,  0.],
        [ 0.,  0.,  0., ...,  0.,  0.,  0.]])

在这两种情况下,我建议您阅读原始 scikit-learn 文档,以了解所有可能的选项和参数。

当处理分类特征(通常通过 LabelEncoder 转换为正整数)时,也可以使用 OneHotEncoder 类来过滤数据集,以便应用独热编码。在以下示例中,第一个特征是一个二进制索引,表示 'Male''Female'

from sklearn.preprocessing import OneHotEncoder

>>> data = [
   [0, 10],
   [1, 11],
   [1, 8],
   [0, 12],
   [0, 15]
]

>>> oh = OneHotEncoder(categorical_features=[0])
>>> Y_oh = oh.fit_transform(data1)

>>> Y_oh.todense()
matrix([[  1.,   0.,  10.],
        [  0.,   1.,  11.],
        [  0.,   1.,   8.],
        [  1.,   0.,  12.],
        [  1.,   0.,  15.]])

考虑到这些方法会增加值的数量(也随着二进制版本呈指数增长),所有类都采用基于 SciPy 实现的稀疏矩阵。有关更多信息,请参阅docs.scipy.org/doc/scipy-0.18.1/reference/sparse.html

管理缺失特征

有时数据集可能包含缺失特征,因此可以考虑以下几种选择:

  • 删除整行

  • 创建子模型来预测这些特征

  • 使用自动策略根据其他已知值输入它们

第一种选择是最激进的,只有在数据集相当大、缺失特征数量高,任何预测都可能存在风险时才应考虑。第二种选择要困难得多,因为需要确定一个监督策略来为每个特征训练一个模型,最后预测它们的值。综合考虑所有优缺点,第三种选择可能是最佳选择。scikit-learn 提供了 Imputer 类,该类负责使用基于均值(默认选择)、中位数或频率(将使用最频繁的条目来填充所有缺失值)的策略来填充空缺。

以下代码片段展示了使用三种方法(缺失特征条目的默认值是 NaN。然而,可以通过 missing_values 参数使用不同的占位符)的示例:

from sklearn.preprocessing import Imputer

>>> data = np.array([[1, np.nan, 2], [2, 3, np.nan], [-1, 4, 2]])

>>> imp = Imputer(strategy='mean')
>>> imp.fit_transform(data)
array([[ 1\. ,  3.5,  2\. ],
       [ 2\. ,  3\. ,  2\. ],
       [-1\. ,  4\. ,  2\. ]])

>>> imp = Imputer(strategy='median')
>>> imp.fit_transform(data)
array([[ 1\. ,  3.5,  2\. ],
       [ 2\. ,  3\. ,  2\. ],
       [-1\. ,  4\. ,  2\. ]])

>>> imp = Imputer(strategy='most_frequent')
>>> imp.fit_transform(data)
array([[ 1.,  3.,  2.],
       [ 2.,  3.,  2.],
       [-1.,  4.,  2.]])

数据缩放和归一化

一个通用的数据集(我们假设它总是数值的)由不同的值组成,这些值可以来自不同的分布,具有不同的尺度,有时也存在异常值。机器学习算法本身无法区分这些不同的情况,因此,在处理之前始终最好对数据集进行标准化。一个非常常见的问题来自于非零均值和大于一的方差。在以下图中,比较了原始数据集和相同数据集缩放和居中的情况:

可以使用StandardScaler类来实现这一结果:

from sklearn.preprocessing import StandardScaler

>>> ss = StandardScaler()
>>> scaled_data = ss.fit_transform(data)

可以通过参数with_mean=True/Falsewith_std=True/False(默认情况下两者都激活)来指定缩放过程是否必须包括均值和标准差。如果您需要一个更强大的缩放功能,具有对异常值的高级控制以及选择分位数范围的可能性,还有RobustScaler类。以下是一些不同分位数的示例:

from sklearn.preprocessing import RubustScaler

>>> rb1 = RobustScaler(quantile_range=(15, 85))
>>> scaled_data1 = rb1.fit_transform(data)

>>> rb1 = RobustScaler(quantile_range=(25, 75))
>>> scaled_data1 = rb1.fit_transform(data)

>>> rb2 = RobustScaler(quantile_range=(30, 60))
>>> scaled_data2 = rb2.fit_transform(data)

结果显示在下述图中:

其他选项包括MinMaxScalerMaxAbsScaler,它们通过移除不属于给定范围(前者)或考虑最大绝对值(后者)来缩放数据。

scikit-learn 还提供了一个用于样本归一化的类,Normalizer。它可以对数据集的每个元素应用maxl1l2范数。在欧几里得空间中,它们被定义为以下方式:

下文展示了每种归一化的示例:

from sklearn.preprocessing import Normalizer

>>> data = np.array([1.0, 2.0])

>>> n_max = Normalizer(norm='max')
>>> n_max.fit_transform(data.reshape(1, -1))
[[ 0.5, 1\. ]]

>>> n_l1 = Normalizer(norm='l1')
>>> n_l1.fit_transform(data.reshape(1, -1))
[[ 0.33333333,  0.66666667]]

>>> n_l2 = Normalizer(norm='l2')
>>> n_l2.fit_transform(data.reshape(1, -1))
[[ 0.4472136 ,  0.89442719]]

特征选择和过滤

一个具有许多特征的非归一化数据集包含的信息与所有特征及其方差的相关性成比例。让我们考虑一个具有三个特征的小数据集,这些特征是通过随机高斯分布生成的:

即使没有进一步的分析,很明显,中心线(具有最低方差)几乎是恒定的,并且不提供任何有用的信息。如果您还记得上一章,熵 H(X)相当小,而其他两个变量携带更多的信息。因此,方差阈值是一个有用的方法来移除所有那些贡献(在变异性和信息方面)低于预定义水平的元素。scikit-learn 提供了VarianceThreshold类,可以轻松解决这个问题。通过将其应用于前一个数据集,我们得到以下结果:

from sklearn.feature_selection import VarianceThreshold

>>> X[0:3, :]
array([[-3.5077778 , -3.45267063,  0.9681903 ],
       [-3.82581314,  5.77984656,  1.78926338],
       [-2.62090281, -4.90597966,  0.27943565]])

>>> vt = VarianceThreshold(threshold=1.5)
>>> X_t = vt.fit_transform(X)

>>> X_t[0:3, :]
array([[-0.53478521, -2.69189452],
       [-5.33054034, -1.91730367],
       [-1.17004376,  6.32836981]])

第三个特征已被完全移除,因为它的方差低于所选阈值(在本例中为 1.5)。

还有许多单变量方法可以根据基于 F 检验和 p 值的特定标准来选择最佳特征,例如卡方检验或方差分析。然而,它们的讨论超出了本书的范围,读者可以在 Freedman D.、Pisani R.、Purves R.的《统计学》一书中找到更多信息。

下文展示了两个特征选择的例子,分别使用了SelectKBest类(它选择最佳K高分数特征)和SelectPercentile类(它仅选择属于特定百分比的子集特征)。它们都可以应用于回归和分类数据集,但需要注意选择合适的评分函数:

from sklearn.datasets import load_boston, load_iris
from sklearn.feature_selection import SelectKBest, SelectPercentile, chi2, f_regression

>>> regr_data = load_boston()
>>> regr_data.data.shape
(506L, 13L)

>>> kb_regr = SelectKBest(f_regression)
>>> X_b = kb_regr.fit_transform(regr_data.data, regr_data.target)

>>> X_b.shape
(506L, 10L)

>>> kb_regr.scores_
array([  88.15124178,   75.2576423 ,  153.95488314,   15.97151242,
        112.59148028,  471.84673988,   83.47745922,   33.57957033,
         85.91427767,  141.76135658,  175.10554288,   63.05422911,
        601.61787111])

>>> class_data = load_iris()
>>> class_data.data.shape
(150L, 4L)

>>> perc_class = SelectPercentile(chi2, percentile=15)
>>> X_p = perc_class.fit_transform(class_data.data, class_data.target)

>>> X_p.shape
(150L, 1L)

>>> perc_class.scores_
array([  10.81782088,    3.59449902,  116.16984746,   67.24482759]) 

关于所有 scikit-learn 分数函数及其使用的更多详细信息,请访问 scikit-learn.org/stable/modules/feature_selection.html#univariate-feature-selection

主成分分析

在许多情况下,输入数据集 X 的维度很高,与之相关的每个机器学习算法的复杂性也高。此外,信息很少均匀地分布在所有特征上,正如前一章所讨论的,将会有高熵特征和低熵特征,当然,它们不会对最终结果产生显著贡献。一般来说,如果我们考虑欧几里得空间,我们有:

图片

因此,每个点都是用由 m 个线性无关向量组成的正交基来表示的。现在,考虑一个数据集 X,一个自然的问题出现了:在不造成信息量急剧损失的情况下,是否可以减少 m?让我们考虑以下图(没有任何特定的解释):

图片

无论哪种分布生成 X=(x,y),然而,水平分量的方差明显大于垂直分量。正如所讨论的,这意味着第一个组件提供的信息量更高,例如,如果 x 轴在保持垂直轴不变的情况下水平拉伸,分布就变成了一个深度越来越不重要的段。

为了评估每个组件带来的信息量以及它们之间的相关性,一个有用的工具是协方差矩阵(如果数据集的均值为零,我们可以使用相关矩阵):

图片

C 是对称和正半定的,所以所有特征值都是非负的,但每个值的含义是什么?前一个示例的协方差矩阵是:

图片

如预期的那样,水平方差明显高于垂直方差。此外,其他值都接近于零。如果你记得定义,并且为了简单起见,移除均值项,它们代表了一对组件之间的互相关。很明显,在我们的例子中,XY 是不相关的(它们是正交的),但在现实生活中的例子中,可能会有一些特征表现出残留的互相关性。从信息论的角度来看,这意味着知道 Y 给我们提供了一些关于 X(我们已知)的信息,因此它们共享的信息实际上是翻倍了。所以我们的目标也是在尝试降低其维度的同时去相关 X

这可以通过考虑 C 的排序特征值并选择 g < m 值来实现:

图片图片

因此,可以将原始特征向量投影到这个新的(子)空间中,其中每个成分携带总方差的一部分,并且新的协方差矩阵被去相关以减少不同特征之间无用的信息共享(就相关性而言)。在 scikit-learn 中,有一个PCA类可以非常顺畅地完成所有这些操作:

from sklearn.datasets import load_digits
from sklearn.decomposition import PCA

>>> digits = load_digits()

下图展示了几个随机的 MNIST 手写数字:

图像

每个图像都是一个 64 位无符号整数(8 位)数字的向量(0, 255),因此初始的成分数确实是 64。然而,黑色像素的总数通常占主导地位,而书写 10 个数字所需的基本符号是相似的,因此有理由假设在几个成分上存在高交叉相关和低方差。尝试使用 36 个主成分,我们得到:

>>> pca = PCA(n_components=36, whiten=True)
>>> X_pca = pca.fit_transform(digits.data / 255)

为了提高性能,所有整数值都被归一化到[0, 1]的范围内,并且通过参数whiten=True,每个成分的方差被缩放到 1。正如官方 scikit-learn 文档所说,这个过程在需要各向同性分布以使许多算法高效运行时特别有用。可以通过实例变量explained_variance_ratio_访问解释方差比它显示了每个单独成分携带的总方差的部分:

>>> pca.explained_variance_ratio_
array([ 0.14890594,  0.13618771,  0.11794594,  0.08409979,  0.05782415,
        0.0491691 ,  0.04315987,  0.03661373,  0.03353248,  0.03078806,
        0.02372341,  0.02272697,  0.01821863,  0.01773855,  0.01467101,
        0.01409716,  0.01318589,  0.01248138,  0.01017718,  0.00905617,
        0.00889538,  0.00797123,  0.00767493,  0.00722904,  0.00695889,
        0.00596081,  0.00575615,  0.00515158,  0.00489539,  0.00428887,
        0.00373606,  0.00353274,  0.00336684,  0.00328029,  0.0030832 ,
        0.00293778])

下图展示了 MNIST 数字示例的图表。左图表示方差比,右图表示累积方差。可以立即看出,第一成分通常是最重要的信息成分,而后续的成分则提供了分类器可能丢弃的细节:

图像

如预期的那样,从第五个成分开始,对总方差的贡献急剧减少,因此可以在不造成不可接受的信息损失的情况下降低原始维度,这可能导致算法学习错误的类别。在前面的图表中,有使用前 36 个成分重建的手写数字,这些成分在 0 到 1 之间进行了白化和归一化。为了获得原始图像,我们需要对所有新向量进行逆变换,并将它们投影到原始空间中:

>>> X_rebuilt = pca.inverse_transform(X_pca)

结果如下所示:

图像

此过程还可以通过去除残留方差来部分去噪原始图像,这种方差通常与噪声或不需要的贡献相关(几乎每种书法都会扭曲一些用于识别的结构元素)。

我建议读者尝试不同的成分数量(使用解释方差数据),以及n_components='mle',它实现了最佳维度的自动选择(Minka T.P, 自动选择 PCA 的维度, NIPS 2000: 598-604)。

scikit-learn 使用奇异值分解SVD)解决 PCA 问题,这可以在 Poole D.的《线性代数》,Brooks Cole 中详细研究。可以通过参数svd_solver控制算法,其值有'auto', 'full', 'arpack', 'randomized'。Arpack 实现了截断 SVD。随机化基于一个近似算法,它丢弃了许多奇异向量,并且在高维数据集(实际组件数量明显较小)中也能实现非常好的性能。

非负矩阵分解

当数据集由非负元素组成时,可以使用非负矩阵分解NNMF)而不是标准的主成分分析(PCA)。该算法基于 Frobenius 范数优化一个损失函数(在WH上交替进行):

如果dim(X) = n x m,则dim(W) = n x pdim(H) = p x m,其中p等于请求的组件数量(n_components参数),这通常小于原始维度nm

最终的重构完全是加性的,并且已经证明它对于通常没有非负元素的图像或文本特别有效。在下面的代码片段中,有一个使用 Iris 数据集(它是非负的)的示例。init参数可以假设不同的值(请参阅文档),这些值决定了数据矩阵的初始处理方式。对于非负矩阵,随机选择仅进行缩放(不执行奇异值分解):

from sklearn.datasets import load_iris
from sklearn.decomposition import NMF

>>> iris = load_iris()
>>> iris.data.shape
(150L, 4L)

>>> nmf = NMF(n_components=3, init='random', l1_ratio=0.1)
>>> Xt = nmf.fit_transform(iris.data)

>>> nmf.reconstruction_err_
1.8819327624141866

>>> iris.data[0]
array([ 5.1,  3.5,  1.4,  0.2])
>>> Xt[0]
array([ 0.20668461,  1.09973772,  0.0098996 ])
>>> nmf.inverse_transform(Xt[0])
array([ 5.10401653,  3.49666967,  1.3965409 ,  0.20610779])

NNMF 与其他分解方法一起,对于更高级的技术,如推荐系统和主题建模,将非常有用。

NNMF 对其参数(特别是初始化和正则化)非常敏感,因此建议阅读原始文档以获取更多信息:scikit-learn.org/stable/modules/generated/sklearn.decomposition.NMF.html.

稀疏 PCA

scikit-learn 提供了不同的 PCA 变体,可以解决特定问题。我确实建议阅读原始文档。然而,我想提到SparsePCA,它允许在提取主成分的同时利用数据的自然稀疏性。如果你考虑手写数字或其他必须被分类的图像,它们的初始维度可以相当高(一个 10x10 的图像有 100 个特征)。然而,应用标准 PCA 只选择平均最重要的特征,假设每个样本可以使用相同的组件重建。简化来说,这相当于:

另一方面,我们总是可以使用有限数量的组件,但不需要由密集投影矩阵给出的限制。这可以通过使用稀疏矩阵(或向量)来实现,其中非零元素的数量相当低。这样,每个元素都可以使用其特定的组件重建(在大多数情况下,它们将是始终最重要的),这可以包括通常由密集 PCA 丢弃的元素。前面的表达式现在变为:

图片

在这里,非零分量已经被放入了第一个块(它们的顺序与之前的表达式不同),而所有其他的零项已经被分开。从线性代数的角度来看,现在的向量空间具有原始的维度。然而,利用稀疏矩阵的强大功能(由scipy.sparse提供),scikit-learn 可以比传统的 PCA 更高效地解决这个问题。

下面的代码片段显示了一个具有 60 个组件的稀疏 PCA。在这种情况下,它们通常被称为原子,稀疏度可以通过L1范数正则化来控制(更高的alpha参数值导致更稀疏的结果)。这种方法在分类算法中非常常见,将在下一章中讨论:

from sklearn.decomposition import SparsePCA

>>> spca = SparsePCA(n_components=60, alpha=0.1)
>>> X_spca = spca.fit_transform(digits.data / 255)

>>> spca.components_.shape
(60L, 64L)

如需有关 SciPy 稀疏矩阵的更多信息,请访问docs.scipy.org/doc/scipy-0.18.1/reference/sparse.html

核 PCA

我们将在第七章“支持向量机”中讨论核方法,然而,提及KernelPCA类是有用的,它对非线性可分的数据集执行 PCA。为了理解这种方法(数学公式并不简单),考虑将每个样本投影到一个特定的空间是有用的,在这个空间中,数据集变得线性可分。这个空间的部分对应于第一个、第二个、...主成分,因此,核 PCA 算法计算了我们的样本到每个部分的投影。

让我们考虑一个由一个圆和一个内部的 blob 组成的集合:

from sklearn.datasets import make_circles

>>> Xb, Yb = make_circles(n_samples=500, factor=0.1, noise=0.05)

图形表示如下。在这种情况下,经典的 PCA 方法无法捕捉现有组件的非线性依赖关系(读者可以验证投影与原始数据集等价)。然而,通过观察样本并使用极坐标(因此,一个可以投影所有点的空间),很容易将两个集合分开,只需考虑半径:

图片

考虑到数据集的结构,我们可以研究具有径向基函数核的主成分分析(PCA)的行为。由于 gamma 的默认值是特征数量的 1.0/(目前,将此参数视为与高斯方差成反比),我们需要将其增加以捕捉外部圆环。1.0 的值就足够了:

from sklearn.decomposition import KernelPCA

>>> kpca = KernelPCA(n_components=2, kernel='rbf', fit_inverse_transform=True, gamma=1.0)
>>> X_kpca = kpca.fit_transform(Xb)

实例变量 X_transformed_fit_ 将包含我们的数据集在新空间中的投影。绘制它,我们得到:

图表显示了一个预期的分离,同时也可以看到属于中心块点的数据点具有曲线分布,因为它们对中心距离更敏感。

当我们将数据集视为由可以构成成分(特别是径向基或多项式)的元素组成时,核主成分分析(Kernel PCA)是一种强大的工具,但我们无法确定它们之间的线性关系。

想了解更多关于 scikit-learn 支持的不同核的信息,请访问 scikit-learn.org/stable/modules/metrics.html#linear-kernel

原子提取和字典学习

字典学习是一种技术,它允许从稀疏原子字典(类似于主成分)开始重建样本。在 Mairal J., Bach F., Ponce J., Sapiro G. 的《在线字典学习用于稀疏编码》(2009 年 29 届国际机器学习会议论文集)中,描述了 scikit-learn 采取的相同在线策略,可以概括为一个双优化问题,其中:

输入数据集和目标是找到字典 D 以及每个样本的一组权重:

在训练过程之后,可以计算输入向量如下:

优化问题(涉及 D 和 alpha 向量)可以表示为以下损失函数的最小化:

在这里,参数 c 控制稀疏度水平(与 L1 归一化的强度成正比)。这个问题可以通过交替最小二乘变量直到达到稳定点来解决。

在 scikit-learn 中,我们可以使用 DictionaryLearning 类(使用常用的 MNIST 数据集)实现这样的算法,其中 n_components 如常,决定了原子的数量:

from sklearn.decomposition import DictionaryLearning

>>> dl = DictionaryLearning(n_components=36, fit_algorithm='lars', transform_algorithm='lasso_lars')
>>> X_dict = dl.fit_transform(digits.data)

每个原子(成分)的图表如下所示:

在低端机器上,这个过程可能非常耗时。在这种情况下,我建议将样本数量限制在 20 或 30 个。

参考文献

  • Freedman D., Pisani R., Purves R., 统计学, 诺顿出版社

  • Gareth J., Witten D., Hastie T., Tibshirani R., 统计学习引论:R 语言应用, Springer

  • Poole D., 线性代数, Brooks Cole

  • Minka T.P, PCA 的自动维度选择, NIPS 2000: 598-604

  • Mairal J., Bach F., Ponce J., Sapiro G., 在线字典学习用于稀疏编码, 第 29 届国际机器学习会议论文集,2009 年

摘要

特征选择是机器学习流程中的第一步(有时也是最重要的一步)。并非所有特征对我们都有用,有些特征使用不同的符号表示,因此通常在执行任何进一步操作之前,需要对我们的数据集进行预处理。

我们看到了如何使用随机洗牌将数据分为训练集和测试集,以及如何管理缺失元素。另一个非常重要的部分涵盖了用于管理分类数据或标签的技术,这在某些特征只假设离散值集合时非常常见。

然后我们分析了维度问题。一些数据集包含许多相互关联的特征,它们不提供任何新信息,但增加了计算复杂度并降低了整体性能。主成分分析是一种选择只包含最大总方差子集特征的方法。这种方法及其变体允许解耦特征并降低维度,而不会在准确性方面造成剧烈损失。字典学习是另一种从数据集中提取有限数量的构建块的技术,同时提取重建每个样本所需的信息。当数据集由相似元素的多个版本组成(如图像、字母或数字)时,这种方法特别有用。

在下一章中,我们将讨论线性回归,这是预测连续值的最常见和最简单的监督方法。我们还将分析如何克服一些局限性,以及如何使用相同的算法解决非线性问题。

第四章:线性回归

线性模型是最简单的参数化方法,始终值得适当的关注,因为许多问题,甚至本质上是非线性的,都可以用这些模型轻松解决。正如之前讨论的那样,回归是一种预测,其中目标值是连续的,其应用范围很广,因此了解线性模型如何拟合数据,其优势和劣势是什么,以及在什么情况下选择替代方案更可取,是很重要的。在章节的最后部分,我们将讨论一种有趣的方法,使用相同的模型有效地处理非线性数据。

线性模型

考虑一个真实值向量的数据集:

图片

每个输入向量都与一个实数值 y[i] 相关联:

图片

线性模型基于这样的假设:可以通过基于以下规则的回归过程来近似输出值:

图片

换句话说,强烈的假设是,我们的数据集和所有其他未知点都位于一个超平面上,最大误差与训练质量和原始数据集的适应性成正比。当数据集明显是非线性的,并且必须考虑其他模型(如神经网络或核支持向量机)时,最常见的问题之一就会出现。

一个二维示例

让我们考虑一个由向 -6 和 6 之间的一段添加一些均匀噪声的点构建的小数据集。原始方程是:y = x + 2 + n,其中 n 是噪声项。

在下面的图中,有一个候选回归函数的图表:

图片

由于我们在平面上工作,我们寻找的回归器仅是两个参数的函数:

图片

为了拟合我们的模型,我们必须找到最佳参数,为此我们选择普通最小二乘法。要最小化的损失函数是:

图片

使用解析方法,为了找到全局最小值,我们必须施加:

图片

因此(为了简单起见,它接受包含两个变量的向量的向量):

import numpy as np

def loss(v):
 e = 0.0
 for i in range(nb_samples):
 e += np.square(v[0] + v[1]*X[i] - Y[i])
 return 0.5 * e

梯度可以定义为:

def gradient(v):
 g = np.zeros(shape=2)
 for i in range(nb_samples):
 g[0] += (v[0] + v[1]*X[i] - Y[i])
 g[1] += ((v[0] + v[1]*X[i] - Y[i]) * X[i])
 return g

现在可以使用 SciPy 解决优化问题:

from scipy.optimize import minimize

>>> minimize(fun=loss, x0=[0.0, 0.0], jac=gradient, method='L-BFGS-B')
fun: 9.7283268345966025
 hess_inv: <2x2 LbfgsInvHessProduct with dtype=float64>
 jac: array([  7.28577538e-06,  -2.35647522e-05])
 message: 'CONVERGENCE: REL_REDUCTION_OF_F_<=_FACTR*EPSMCH'
 nfev: 8
 nit: 7
 status: 0
 success: True
 x: array([ 2.00497209,  1.00822552])

如预期的那样,回归去噪了我们的数据集,重建了原始方程:y = x + 2

使用 scikit-learn 和更高维度的线性回归

scikit-learn 提供了 LinearRegression 类,它适用于 n 维空间。为此,我们将使用波士顿数据集:

from sklearn.datasets import load_boston

>>> boston = load_boston()

>>> boston.data.shape
(506L, 13L)
>>> boston.target.shape
(506L,)

它有 506 个样本,13 个输入特征和一个输出。在下面的图中,有一组前 12 个特征的图表:

图片

当处理数据集时,有一个表格视图来操作数据是非常有用的。pandas 是这个任务的完美框架,尽管这超出了本书的范围,但我建议你使用命令pandas.DataFrame(boston.data, columns=boston.feature_names)创建一个数据框,并使用 Jupyter 来可视化它。有关更多信息,请参阅 Heydt M.的《Learning pandas - Python Data Discovery and Analysis Made Easy》,Packt 出版社。

存在不同的尺度异常值(可以使用前几章研究的方法去除),因此最好让模型在处理数据之前对数据进行归一化。此外,为了测试目的,我们将原始数据集分为训练集(90%)和测试集(10%):

from sklearn.linear_model import LinearRegression
from sklearn.model_selection import train_test_split

>>> X_train, X_test, Y_train, Y_test = train_test_split(boston.data, boston.target, test_size=0.1)

>>> lr = LinearRegression(normalize=True)
>>> lr.fit(X_train, Y_train)
LinearRegression(copy_X=True, fit_intercept=True, n_jobs=1, normalize=True)

当原始数据集不够大时,将其分为训练集和测试集可能会减少可用于拟合模型的样本数量。k 折交叉验证可以通过不同的策略帮助解决这个问题。整个数据集被分为 k 个部分,始终使用 k-1 个部分进行训练,剩余的一个用于验证模型。将执行 k 次迭代,每次使用不同的验证部分。在以下图中,有一个 3 个部分/迭代的示例:

图片

以这种方式,最终得分可以确定为所有值和所有样本的平均值,并且所有样本都会被用于训练 k-1 次。

要检查回归的准确性,scikit-learn 提供了内部方法score(X, y),它评估模型在测试数据上的表现:

>>> lr.score(X_test, Y_test)
0.77371996006718879

因此,整体准确率约为 77%,考虑到原始数据集的非线性,这是一个可接受的结果,但它也可能受到train_test_split所做的细分(如我们的情况)的影响。相反,对于 k 折交叉验证,我们可以使用cross_val_score()函数,它适用于所有分类器。评分参数非常重要,因为它决定了将采用哪个指标进行测试。由于LinearRegression使用普通最小二乘法,我们更喜欢负均方误差,这是一个累积度量,必须根据实际值进行评估(它不是相对的)。

from sklearn.model_selection import cross_val_score

>>> scores = cross_val_score(lr, boston.data, boston.target, cv=7, scoring='neg_mean_squared_error')
array([ -11.32601065,  -10.96365388,  -32.12770594,  -33.62294354,
 -10.55957139, -146.42926647,  -12.98538412])

>>> scores.mean()
-36.859219426420601
>>> scores.std()
45.704973900600457

在回归中使用的另一个非常重要的指标被称为确定系数。它衡量了由数据集解释的预测中的方差量。我们定义残差,以下数量:

图片

换句话说,它是样本与预测之间的差异。因此,被定义为以下:

图片

对于我们的目的,接近 1 的值意味着几乎完美的回归,而接近 0(或负数)的值意味着模型不好。使用这个指标进行交叉验证相当简单:

>>> cross_val_score(lr, X, Y, cv=10, scoring='r2')
0.75

回归分析表达式

如果我们想要得到我们模型(一个超平面)的解析表达式,LinearRegression 提供了两个实例变量,intercept_coef_

>>> print('y = ' + str(lr.intercept_) + ' ')
>>> for i, c in enumerate(lr.coef_):
 print(str(c) + ' * x' + str(i))

y = 38.0974166342 
-0.105375005552 * x0
0.0494815380304 * x1
0.0371643549528 * x2
3.37092201039 * x3
-18.9885299511 * x4
3.73331692311 * x5
0.00111437695492 * x6
-1.55681538908 * x7
0.325992743837 * x8
-0.01252057277 * x9
-0.978221746439 * x10
0.0101679515792 * x11
-0.550117114635 * x12

对于任何其他模型,可以通过 predict(X) 方法获得预测。作为一个实验,我们可以尝试向我们的训练数据添加一些高斯噪声并预测值:

>>> X = boston.data[0:10] + np.random.normal(0.0, 0.1)

>>> lr.predict(X)
array([ 29.5588731 ,  24.49601998,  30.0981552 ,  28.01864586,
 27.28870704,  24.65881135,  22.46335968,  18.79690943,
 10.53493932,  18.18093544])

>>> boston.target[0:10]
array([ 24\. ,  21.6,  34.7,  33.4,  36.2,  28.7,  22.9,  27.1,  16.5,  18.9])

很明显,模型的表现并不理想,有许多可能的原因,最显著的是非线性性和异常值的存在。然而,一般来说,线性回归模型并不是一个完美的鲁棒解决方案。在 Hastie T.,Tibshirani R.,Friedman J. 的 The Elements of Statistical Learning: Data Mining, Inference, and, Prediction,Springer 一书中,你可以找到关于其优势和劣势的非常详细的讨论。然而,在这个上下文中,一个常见的威胁是由共线性引起的,这会导致低秩 X 矩阵。这决定了病态矩阵,它对噪声特别敏感,导致某些参数的爆炸。为了减轻这种风险并提供更鲁棒的解决方案,已经研究了以下方法。

岭回归、Lasso 和 ElasticNet

岭回归在普通最小二乘损失函数上施加额外的收缩惩罚,以限制其平方 L2 范数:

图片

在这种情况下,X 是一个包含所有样本作为列的矩阵,而项 w 代表权重向量。通过系数 alpha 的附加项(如果很大,则意味着更强的正则化和更小的值),迫使损失函数不允许 w 无限增长,这可能是由于多重共线性或病态性引起的。在下面的图中,展示了应用岭惩罚时发生的情况:

图片

灰色表面表示损失函数(这里,为了简单起见,我们只使用两个权重),而圆心 O 是由岭条件强加的边界。最小值将具有较小的 w 值,并避免了潜在的爆炸。

在下面的代码片段中,我们将使用交叉验证比较 LinearRegressionRidge

from sklearn.datasets import load_diabetes
from sklearn.linear_model import LinearRegression, Ridge

>>> diabetes = load_diabetes()

>>> lr = LinearRegression(normalize=True)
>>> rg = Ridge(0.001, normalize=True)

>>> lr_scores = cross_val_score(lr, diabetes.data, diabetes.target, cv=10)
>>> lr_scores.mean()
0.46196236195833718

>>> rg_scores = cross_val_score(rg, diabetes.data, diabetes.target, cv=10)
>>> rg_scores.mean()
0.46227174692391299

有时,找到 alpha(岭系数)的正确值并不那么直接。scikit-learn 提供了 RidgeCV 类,允许执行自动网格搜索(在一系列值中返回最佳估计):

from sklearn.linear_model import RidgeCV

>>> rg = RidgeCV(alphas=(1.0, 0.1, 0.01, 0.005, 0.0025, 0.001, 0.00025), normalize=True)
>>> rg.fit(diabetes.data, diabetes.target)

>>> rg.alpha_
0.0050000000000000001

Lasso 回归器对 wL1 范数施加惩罚,以确定一个可能更高的零系数数量:

图片

稀疏性是惩罚项的结果(数学证明非平凡,将省略)。

图片

在这种情况下,存在一些顶点,其中某个组件非空,而所有其他权重为零。与顶点相交的概率与权重向量 w 的维度成正比,因此,在训练 Lasso 回归器后,发现一个相当稀疏的模型是正常的。

在以下代码片段中,使用了糖尿病数据集来拟合 Lasso 模型:

from sklearn.linear_model import Lasso

>>> ls = Lasso(alpha=0.001, normalize=True)
>>> ls_scores = cross_val_score(ls, diabetes.data, diabetes.target, cv=10)
>>> ls_scores.mean()
0.46215747851504058

对于 Lasso,也有运行网格搜索以找到最佳 alpha 参数的可能性。在这种情况下,类是 LassoCV,其内部动态与之前看到的 Ridge 类似。Lasso 还可以在通过 scipy.sparse 类生成的稀疏数据上高效运行,从而允许在不进行部分拟合的情况下训练更大的模型:

from scipy import sparse

>>> ls = Lasso(alpha=0.001, normalize=True)
>>> ls.fit(sparse.coo_matrix(diabetes.data), diabetes.target)
Lasso(alpha=0.001, copy_X=True, fit_intercept=True, max_iter=1000,
 normalize=True, positive=False, precompute=False, random_state=None,
 selection='cyclic', tol=0.0001, warm_start=False)

当处理大量数据时,一些模型无法完全装入内存,因此无法训练它们。scikit-learn 提供了一些模型,例如 随机梯度下降SGD),其工作方式与 LinearRegression 结合 Ridge/Lasso 非常相似;然而,它们还实现了 partial_fit() 方法,这也允许通过 Python 生成器进行连续训练。有关更多详细信息,请参阅 scikit-learn.org/stable/modules/linear_model.html#stochastic-gradient-descent-sgd

最后一种选择是 ElasticNet,它将 Lasso 和 Ridge 结合成一个具有两个惩罚因子的单一模型:一个与 L1 范数成正比,另一个与 L2 范数成正比。这样,得到的模型将像纯 Lasso 一样稀疏,但具有 Ridge 提供的相同的正则化能力。结果损失函数为:

图片

ElasticNet 类提供了一个实现,其中 alpha 参数与 l1_ratio(公式中的 beta)一起工作。ElasticNet 的主要特性是避免由于 L1L2 范数的平衡作用而导致的特征选择性排除。

在以下代码片段中,使用了 ElasticNetElasticNetCV 类的示例:

from sklearn.linear_model import ElasticNet, ElasticNetCV

>>> en = ElasticNet(alpha=0.001, l1_ratio=0.8, normalize=True)
>>> en_scores = cross_val_score(en, diabetes.data, diabetes.target, cv=10)
>>> en_scores.mean()
0.46358858847836454

>>> encv = ElasticNetCV(alphas=(0.1, 0.01, 0.005, 0.0025, 0.001), l1_ratio=(0.1, 0.25, 0.5, 0.75, 0.8), normalize=True)
>>> encv.fit(dia.data, dia.target)
ElasticNetCV(alphas=(0.1, 0.01, 0.005, 0.0025, 0.001), copy_X=True, cv=None,
 eps=0.001, fit_intercept=True, l1_ratio=(0.1, 0.25, 0.5, 0.75, 0.8),
 max_iter=1000, n_alphas=100, n_jobs=1, normalize=True,
 positive=False, precompute='auto', random_state=None,
 selection='cyclic', tol=0.0001, verbose=0)

>>> encv.alpha_
0.001
>>> encv.l1_ratio_
0.75

带有随机样本一致性的鲁棒回归

线性回归的一个常见问题是由于异常值的存在。普通最小二乘法将考虑它们,因此结果(就系数而言)将因此有偏。以下图示了这种行为的一个例子:

图片

斜率较小的线代表一个可接受的回归,它忽略了异常值,而另一条线则受到异常值的影响。为了避免这个问题,随机样本一致性RANSAC)提供了一种有趣的方法,它通过迭代与每个回归器一起工作,在将数据集分为内点和异常值之后。模型仅使用有效样本(通过内部评估或通过可调用的 is_data_valid())进行训练,并且所有样本都会重新评估以验证它们是否仍然是内点或已变成异常值。过程在固定次数的迭代后结束,或者当达到所需的分数时。

在下面的代码片段中,有一个简单线性回归应用于前一个图中所示数据集的例子。

from sklearn.linear_model import LinearRegression

>>> lr = LinearRegression(normalize=True)
>>> lr.fit(X.reshape((-1, 1)), Y.reshape((-1, 1)))
>>> lr.intercept_
array([ 5.500572])
>>> lr.coef_
array([[ 2.53688672]])

如预期的那样,由于异常值的存在,斜率很高。得到的回归器是 y = 5.5 + 2.5x(比图中所显示的斜率略小)。现在我们将使用 RANSAC 与相同的线性回归器:

from sklearn.linear_model import RANSACRegressor

>>> rs = RANSACRegressor(lr)
>>> rs.fit(X.reshape((-1, 1)), Y.reshape((-1, 1)))
>>> rs.estimator_.intercept_
array([ 2.03602026])
>>> es.estimator_.coef_
array([[ 0.99545348]])

在这种情况下,回归器大约是 y = 2 + x(这是原始的无异常值清洁数据集)。

如果您想了解更多信息,我建议访问页面 scikit-learn.org/stable/modules/generated/sklearn.linear_model.RANSACRegressor.html。对于其他鲁棒回归技术,请访问:scikit-learn.org/stable/modules/linear_model.html#robustness-regression-outliers-and-modeling-errors

多项式回归

多项式回归是一种基于技巧的技术,即使在数据集具有强烈的非线性时也能使用线性模型。其思路是添加一些从现有变量计算得出的额外变量,并仅使用(在这种情况下)多项式组合:

图片

例如,对于两个变量,可以通过将初始向量(其维度等于 m)转换为另一个具有更高维度的向量(其维度为 k > m)来扩展到二次问题:

图片

在这种情况下,模型在形式上保持线性,但它可以捕捉内部非线性。为了展示 scikit-learn 如何实现这一技术,让我们考虑以下图中的数据集:

图片

这显然是一个非线性数据集,仅基于原始二维点的线性回归无法捕捉其动态。为了尝试,我们可以在同一数据集上训练一个简单的模型(对其进行测试):

from sklearn.linear_model import LinearRegression

>>> lr = LinearRegression(normalize=True)
>>> lr.fit(X.reshape((-1, 1)), Y.reshape((-1, 1)))
>>> lr.score(X.reshape((-1, 1)), Y.reshape((-1, 1)))
0.10888218817034558

性能如预期的那样较差。然而,从图中看,我们可能会认为二次回归可以轻易解决这个问题。scikit-learn 提供了PolynomialFeatures类,该类根据degree参数将原始集转换为一个扩展集:

from sklearn.preprocessing import PolynomialFeatures

>>> pf = PolynomialFeatures(degree=2)
>>> Xp = pf.fit_transform(X.reshape(-1, 1))

>>> Xp.shape
(100L, 3L)

如预期的那样,旧的x[1]坐标已被一个三元组所取代,其中也包含了二次和混合项。在此阶段,可以训练一个线性回归模型:

>>> lr.fit(Xp, Y.reshape((-1, 1)))
>>> lr.score(Xp, Y.reshape((-1, 1)))
0.99692778265941961

得分相当高,我们付出的唯一代价是特征数量的增加。一般来说,这是可行的;然而,如果数量超过一个可接受的阈值,尝试维度降低或作为一个极端解决方案,转向非线性模型(如 SVM 核)是有用的。通常,一个好的方法是使用SelectFromModel类,让 scikit-learn 根据它们的重要性选择最佳特征。实际上,当特征数量增加时,所有特征都具有相同重要性的概率会降低。这是由于相互相关性或主要和次要趋势的共同存在,它们像噪声一样没有足够的力量改变超平面斜率的可感知性。此外,当使用多项式展开时,一些弱特征(不能用于线性分离)被它们的函数所替代,因此实际上的强特征数量减少了。

在以下代码片段中,有一个使用先前波士顿数据集的示例。threshold参数用于设置最小重要性级别。如果缺失,分类器将尝试通过移除尽可能多的特征来最大化效率。

from sklearn.feature_selection import SelectFromModel

>>> boston = load_boston()

>>> pf = PolynomialFeatures(degree=2)
>>> Xp = pf.fit_transform(boston.data)
>>> Xp.shape
(506L, 105L)

>>> lr = LinearRegression(normalize=True)
>>> lr.fit(Xp, boston.target)
>>> lr.score(Xp, boston.target)
0.91795268869997404

>>> sm = SelectFromModel(lr, threshold=10)
>>> Xt = sm.fit_transform(Xp, boston.target)
>>> sm.estimator_.score(Xp, boston.target)
0.91795268869997404

>>> Xt.shape
(506L, 8L)

在仅选择最佳特征(阈值设置为 10)后,得分保持不变,维度降低保持一致(只有 8 个特征被认为对预测很重要)。如果在任何其他处理步骤之后需要返回到原始数据集,可以使用逆变换:

>>> Xo = sm.inverse_transform(Xt)
>>> Xo.shape
(506L, 105L)

等调回归

有时我们需要为非递减点的数据集找到一个回归器,这些点可能呈现低级振荡(如噪声)。线性回归可以轻易实现一个非常高的得分(考虑到斜率大约是恒定的),但它像一个降噪器,产生一条无法捕捉我们想要建模的内部动态的线。对于这些情况,scikit-learn 提供了IsotonicRegression类,该类产生一个分段插值函数,最小化函数:

图片

下一个示例(使用一个玩具数据集)将提供:

>>> X = np.arange(-5, 5, 0.1)
>>> Y = X + np.random.uniform(-0.5, 1, size=X.shape)

下图是数据集的图。正如大家所看到的,它可以很容易地被线性回归器建模,但没有一个高非线性函数,很难捕捉到斜率上的微小(和局部的)变化:

图片

IsotonicRegression类需要知道y[min]y[max](它们对应于损失函数中的y[0]和y[n]变量)。在这种情况下,我们设定为-6 和 10:

from sklearn.isotonic import IsotonicRegression

>>> ir = IsotonicRegression(-6, 10)
>>> Yi = ir.fit_transform(X, Y)

结果通过三个实例变量提供:

>>> ir.X_min_
-5.0
>>> ir.X_max_
4.8999999999999648
>>> ir.f_
<scipy.interpolate.interpolate.interp1d at 0x126edef8>

最后一个,(ir.f_),是一个插值函数,可以在[x[min], x[max]]域内评估。例如:

>>> ir.f_(2)
array(1.7294334618146134)

下图显示了该函数(绿色线)与原始数据集的图:

图片

想要了解更多关于 SciPy 插值的信息,请访问 docs.scipy.org/doc/scipy-0.18.1/reference/interpolate.html

参考文献

Hastie T.,Tibshirani R.,Friedman J.,《统计学习的要素:数据挖掘、推理和预测》,Springer

摘要

在本章中,我们介绍了线性模型的重要概念,并描述了线性回归的工作原理。特别是,我们关注了基本模型及其主要变体:Lasso、Ridge 和 ElasticNet。它们不修改内部动态,但作为权重的正规化器,以避免当数据集包含未缩放的样本时的常见问题。这些惩罚具有特定的特性。Lasso 促进稀疏性,Ridge 试图在权重必须位于以原点为中心的圆上(其半径被参数化以增加/减少正规化强度)的约束下找到最小值。ElasticNet 是这两种技术的混合,它试图找到权重足够小且达到一定稀疏度的最小值。

我们还讨论了诸如 RANSAC 等高级技术,它以非常鲁棒的方式处理异常值,以及多项式回归,这是一种将虚拟非线性特征包含到我们的模型中并继续以相同线性方法处理它们的非常智能的方法。通过这种方式,可以创建另一个数据集,包含原始列及其多项式组合。这个新数据集可以用来训练线性回归模型,然后可以选择那些有助于实现良好性能的特征。我们看到的最后一种方法是等距回归,当插值函数始终不是递减时特别有用。此外,它还可以捕捉到由通用线性回归平滑的小振荡。

在下一章中,我们将讨论一些用于分类的线性模型。特别是,我们将关注逻辑回归和随机梯度下降算法。此外,我们将介绍一些有用的度量来评估分类系统的准确性,以及一种强大的技术来自动找到最佳超参数。

第五章:逻辑回归

本章首先分析线性分类问题,特别关注逻辑回归(尽管它的名字是分类算法)和随机梯度下降方法。即使这些策略看起来过于简单,但它们仍然是许多分类任务中的主要选择。说到这一点,记住一个非常重要的哲学原则是有用的:奥卡姆剃刀。在我们的语境中,它表明第一个选择必须是简单的,并且只有当它不适用时,才需要转向更复杂的模型。在章节的第二部分,我们将讨论一些用于评估分类任务的常用指标。它们不仅限于线性模型,因此我们在讨论不同的策略时也使用它们。

线性分类

让我们考虑一个具有两个类别的通用线性分类问题。在下面的图中,有一个例子:

图片

我们的目标是找到一个最优的超平面,它将两个类别分开。在多类问题中,通常采用一对一的策略,因此讨论可以仅集中在二分类上。假设我们有以下数据集:

图片

这个数据集与以下目标集相关联:

图片

我们现在可以定义一个由 m 个连续分量组成的权重向量:

图片

我们也可以定义数量 z

图片

如果 x 是一个变量,z 是由超平面方程确定的值。因此,如果已经确定的系数集 w 是正确的,那么就会发生以下情况:

图片

现在我们必须找到一种方法来优化 w,以减少分类误差。如果存在这样的组合(具有一定的错误阈值),我们说我们的问题是线性可分的。另一方面,当无法找到线性分类器时,问题被称为非线性可分。一个简单但著名的例子是由逻辑运算符 XOR 提供的:

图片

如您所见,任何一行都可能包含一个错误的样本。因此,为了解决这个问题,有必要涉及非线性技术。然而,在许多实际情况下,我们也会使用线性技术(通常更简单、更快)来解决非线性问题,接受可容忍的错误分类误差。

逻辑回归

即使被称为回归,这实际上是一种基于样本属于某一类别的概率的分类方法。由于我们的概率必须在 R 中连续并且介于 (0, 1) 之间,因此有必要引入一个阈值函数来过滤 z 这一项。这个名字“逻辑回归”来源于使用 sigmoid(或逻辑)函数的决定:

图片

该函数的部分图示如下:

图片

如您所见,函数在纵坐标 0.5 处与x=0相交,对于x<0y<0.5,对于x>0y>0.5。此外,其定义域为R,并且在 0 和 1 处有两个渐近线。因此,我们可以定义一个样本属于一个类(从现在开始,我们将它们称为 0 和 1)的概率:

图片

在这一点上,找到最优参数等同于在给定输出类别的情况下最大化对数似然:

图片

因此,优化问题可以用指示符号表示为损失函数的最小化:

图片

如果y=0,第一个项变为零,第二个项变为log(1-x),这是类别 0 的对数概率。另一方面,如果y=1,第二个项为零,第一个项代表x的对数概率。这样,两种情况都包含在一个单一的表达式中。从信息论的角度来看,这意味着最小化目标分布和近似分布之间的交叉熵:

图片

特别是,如果采用log[2],该泛函表示使用预测分布对原始分布进行编码所需的额外比特数。很明显,当J(w) = 0时,两个分布是相等的。因此,最小化交叉熵是优化预测误差的一种优雅方法,当目标分布是分类的。

实现和优化

scikit-learn 实现了LogisticRegression类,该类可以使用优化算法解决这个问题。让我们考虑一个由 500 个样本组成的玩具数据集:

图片

点属于类别 0,而三角形属于类别 1。为了立即测试我们分类的准确性,将数据集分为训练集和测试集是有用的:

from sklearn.model_selection import train_test_split

>>> X_train, X_test, Y_train, Y_test = train_test_split(X, Y, test_size=0.25)

现在我们可以使用默认参数来训练模型:

from sklearn.linear_model import LogisticRegression

>>> lr = LogisticRegression()
>>> lr.fit(X_train, Y_train)
LogisticRegression(C=1.0, class_weight=None, dual=False, fit_intercept=True,
 intercept_scaling=1, max_iter=100, multi_class='ovr', n_jobs=1,
 penalty='l2', random_state=None, solver='liblinear', tol=0.0001,
 verbose=0, warm_start=False)

>>> lr.score(X_test, Y_test)
0.95199999999999996

也可以通过交叉验证(类似于线性回归)来检查质量:

from sklearn.model_selection import cross_val_score

>>> cross_val_score(lr, X, Y, scoring='accuracy', cv=10)
array([ 0.96078431,  0.92156863,  0.96      ,  0.98      ,  0.96      ,
 0.98      ,  0.96      ,  0.96      ,  0.91836735,  0.97959184])

分类任务在没有进一步操作的情况下成功(交叉验证也证实了这一点),还可以检查结果超平面参数:

>>> lr.intercept_
array([-0.64154943])

>>> lr.coef_
array([[ 0.34417875,  3.89362924]])

在以下图中,展示了这个超平面(一条线),可以看到分类是如何工作的以及哪些样本被错误分类。考虑到两个块的区域局部密度,很容易看出错误分类发生在异常值和一些边界样本上。后者可以通过调整超参数来控制,尽管通常需要权衡。例如,如果我们想将分离线上的四个右点包括在内,这可能会排除右侧的一些元素。稍后,我们将看到如何找到最优解。然而,当一个线性分类器可以轻松地找到一个分离超平面(即使有一些异常值)时,我们可以说问题是可以线性建模的;否则,必须考虑更复杂的不线性技术。

图片

就像线性回归一样,可以对权重施加范数条件。特别是,实际的功能变为:

图片

其行为与上一章中解释的相同。两者都产生收缩,但L1强制稀疏性。这可以通过参数 penalty(其值可以是L1L2)和C(正则化因子的倒数,1/alpha)来控制,较大的值会减少强度,而较小的值(特别是小于 1 的值)会迫使权重更靠近原点。此外,L1将优先考虑顶点(其中所有但一个分量都是零),因此在使用SelectFromModel来优化收缩后的实际特征是一个好主意。

随机梯度下降算法

在讨论了逻辑回归的基本原理之后,介绍SGDClassifier类是有用的,该类实现了一个非常著名的算法,可以应用于多个不同的损失函数。随机梯度下降背后的思想是基于损失函数梯度的权重更新迭代:

图片

然而,更新过程不是应用于整个数据集,而是应用于从中随机提取的批次。在先前的公式中,L 是我们想要最小化的损失函数(如第二章中所述,机器学习的重要元素)和伽马(在 scikit-learn 中为eta0)是学习率,这是一个在学习过程中可以保持恒定或衰减的参数。learning_rate参数也可以保留其默认值(optimal),该值根据正则化因子内部计算。

当权重停止修改或其变化保持在所选阈值以下时,过程应该结束。scikit-learn 实现使用n_iter参数来定义所需的迭代次数。

存在许多可能的损失函数,但在这章中,我们只考虑log感知器。其他的一些将在下一章中讨论。前者实现逻辑回归,而后者(也作为自主类感知器可用)是最简单的神经网络,由一个权重层w、一个称为偏置的固定常数和一个二元输出函数组成:

图片

输出函数(将数据分为两类)是:

图片

感知器逻辑回归之间的区别在于输出函数(符号函数与 Sigmoid 函数)和训练模型(带有损失函数)。实际上,感知器通常通过最小化实际值与预测值之间的均方距离来训练:

图片

正如任何其他线性分类器一样,感知器不能解决非线性问题;因此,我们的例子将使用内置函数make_classification生成:

from sklearn.datasets import make_classification

>>> nb_samples = 500
>>> X, Y = make_classification(n_samples=nb_samples, n_features=2, n_informative=2, n_redundant=0, n_clusters_per_class=1)

这样,我们可以生成 500 个样本,分为两类:

图片

在一个确定的精度阈值下,这个问题可以线性解决,因此我们对感知器逻辑回归的期望是等效的。在后一种情况下,训练策略集中在最大化概率分布的似然。考虑到数据集,红色样本属于类别 0 的概率必须大于 0.5(当z = 0时等于 0.5,因此当点位于分离超平面时),反之亦然。另一方面,感知器将调整超平面,使得样本与权重之间的点积根据类别为正或负。在以下图中,有一个感知器的几何表示(其中偏置为 0):

图片

权重向量与分离超平面正交,因此只有考虑点积的符号才能进行判别。以下是一个具有感知器损失(无L1/L2约束)的随机梯度下降的例子:

from sklearn.linear_model import SGDClassifier

>>> sgd = SGDClassifier(loss='perceptron', learning_rate='optimal', n_iter=10)
>>> cross_val_score(sgd, X, Y, scoring='accuracy', cv=10).mean()
0.98595918367346935

可以直接使用Perceptron类得到相同的结果:

from sklearn.linear_model import Perceptron

>>> perc = Perceptron(n_iter=10)
>>> cross_val_score(perc, X, Y, scoring='accuracy', cv=10).mean()
0.98195918367346935

通过网格搜索寻找最佳超参数

寻找最佳超参数(之所以称为最佳超参数,是因为它们影响训练阶段学习的参数)并不总是容易的,而且很少有好的方法可以从中开始。个人经验(一个基本元素)必须由一个高效的工具如GridSearchCV来辅助,该工具自动化不同模型的训练过程,并使用交叉验证为用户提供最佳值。

例如,我们展示如何使用它来找到使用 Iris 玩具数据集进行线性回归的最佳惩罚和强度因子:

import multiprocessing

from sklearn.datasets import load_iris
from sklearn.model_selection import GridSearchCV

>>> iris = load_iris()

>>> param_grid = [
 { 
 'penalty': [ 'l1', 'l2' ],
 'C': [ 0.5, 1.0, 1.5, 1.8, 2.0, 2.5]
 }
]

>>> gs = GridSearchCV(estimator=LogisticRegression(), param_grid=param_grid,
 scoring='accuracy', cv=10, n_jobs=multiprocessing.cpu_count())

>>> gs.fit(iris.data, iris.target)
GridSearchCV(cv=10, error_score='raise',
 estimator=LogisticRegression(C=1.0, class_weight=None, dual=False, fit_intercept=True,
 intercept_scaling=1, max_iter=100, multi_class='ovr', n_jobs=1,
 penalty='l2', random_state=None, solver='liblinear', tol=0.0001,
 verbose=0, warm_start=False),
 fit_params={}, iid=True, n_jobs=8,
 param_grid=[{'penalty': ['l1', 'l2'], 'C': [0.1, 0.2, 0.4, 0.5, 1.0, 1.5, 1.8, 2.0, 2.5]}],
 pre_dispatch='2*n_jobs', refit=True, return_train_score=True,
 scoring='accuracy', verbose=0)

>>> gs.best_estimator_
LogisticRegression(C=1.5, class_weight=None, dual=False, fit_intercept=True,
 intercept_scaling=1, max_iter=100, multi_class='ovr', n_jobs=1,
 penalty='l1', random_state=None, solver='liblinear', tol=0.0001,
 verbose=0, warm_start=False)

>>> cross_val_score(gs.best_estimator_, iris.data, iris.target, scoring='accuracy', cv=10).mean()
0.96666666666666679

可以将任何由模型支持的参数及其值列表插入到param字典中。GridSearchCV将并行处理并返回最佳估计器(通过实例变量best_estimator_,它是一个与通过参数estimator指定的相同分类器的实例)。

当使用并行算法时,scikit-learn 提供了n_jobs参数,允许我们指定必须使用多少线程。将n_jobs=multiprocessing.cpu_count()设置为有效,可以充分利用当前机器上可用的所有 CPU 核心。

在下一个示例中,我们将找到使用感知损失训练的SGDClassifier的最佳参数。数据集在以下图中展示:

图片

import multiprocessing

from sklearn.model_selection import GridSearchCV

>>> param_grid = [
 { 
 'penalty': [ 'l1', 'l2', 'elasticnet' ],
 'alpha': [ 1e-5, 1e-4, 5e-4, 1e-3, 2.3e-3, 5e-3, 1e-2],
 'l1_ratio': [0.01, 0.05, 0.1, 0.15, 0.25, 0.35, 0.5, 0.75, 0.8]
 }
]

>>> sgd = SGDClassifier(loss='perceptron', learning_rate='optimal')
>>> gs = GridSearchCV(estimator=sgd, param_grid=param_grid, scoring='accuracy', cv=10, n_jobs=multiprocessing.cpu_count())

>>> gs.fit(X, Y)
GridSearchCV(cv=10, error_score='raise',
 estimator=SGDClassifier(alpha=0.0001, average=False, class_weight=None, epsilon=0.1,
 eta0=0.0, fit_intercept=True, l1_ratio=0.15,
 learning_rate='optimal', loss='perceptron', n_iter=5, n_jobs=1,
 penalty='l2', power_t=0.5, random_state=None, shuffle=True,
 verbose=0, warm_start=False),
 fit_params={}, iid=True, n_jobs=8,
 param_grid=[{'penalty': ['l1', 'l2', 'elasticnet'], 'alpha': [1e-05, 0.0001, 0.0005, 0.001, 0.0023, 0.005, 0.01], 'l1_ratio': [0.01, 0.05, 0.1, 0.15, 0.25, 0.35, 0.5, 0.75, 0.8]}],
 pre_dispatch='2*n_jobs', refit=True, return_train_score=True,
 scoring='accuracy', verbose=0)

>>> gs.best_score_
0.89400000000000002

>>> gs.best_estimator_
SGDClassifier(alpha=0.001, average=False, class_weight=None, epsilon=0.1,
 eta0=0.0, fit_intercept=True, l1_ratio=0.1, learning_rate='optimal',
 loss='perceptron', n_iter=5, n_jobs=1, penalty='elasticnet',
 power_t=0.5, random_state=None, shuffle=True, verbose=0,
 warm_start=False)

分类指标

一个分类任务可以通过多种不同的方式来评估,以达到特定的目标。当然,最重要的指标是准确率,通常表示为:

图片

在 scikit-learn 中,可以使用内置的accuracy_score()函数进行评估:

from sklearn.metrics import accuracy_score

>>> accuracy_score(Y_test, lr.predict(X_test))
0.94399999999999995

另一个非常常见的方法是基于零一损失函数,我们在第二章,机器学习的重要元素中看到了它,定义为所有样本上L[0/1](其中1分配给误分类)的归一化平均值。在以下示例中,我们展示了一个归一化分数(如果它接近 0,则更好)以及相同的未归一化值(这是实际的误分类数量):

from sklearn.metrics import zero_one_loss

>>> zero_one_loss(Y_test, lr.predict(X_test))
0.05600000000000005

>>> zero_one_loss(Y_test, lr.predict(X_test), normalize=False)
7L

一个类似但相反的指标是Jaccard 相似系数,定义为:

图片

这个指标衡量相似性,其值介于 0(最差性能)和 1(最佳性能)之间。在前者情况下,交集为零,而在后者情况下,交集和并集相等,因为没有误分类。在 scikit-learn 中的实现是:

from sklearn.metrics import jaccard_similarity_score

>>> jaccard_similarity_score(Y_test, lr.predict(X_test))
0.94399999999999995

这些指标为我们提供了对分类算法的良好洞察。然而,在许多情况下,有必要能够区分不同类型的误分类(我们考虑的是具有传统记号的二元情况:0-负,1-正),因为相对权重相当不同。因此,我们引入以下定义:

  • 真阳性:一个被正确分类的阳性样本

  • 假阳性:一个被分类为正例的阴性样本

  • 真阴性:一个被正确分类的阴性样本

  • 假阴性:一个被分类为负例的阳性样本

乍一看,假阳性和假阴性可以被认为是类似的错误,但考虑一下医学预测:虽然假阳性可以通过进一步的测试轻易发现,但假阴性往往被忽视,并随之产生后果。因此,引入混淆矩阵的概念是有用的:

图片

在 scikit-learn 中,可以使用内置函数构建混淆矩阵。让我们考虑一个在数据集X上具有标签Y的通用逻辑回归:

>>> X_train, X_test, Y_train, Y_test = train_test_split(X, Y, test_size=0.25)
>>> lr = LogisticRegression()
>>> lr.fit(X_train, Y_train)
LogisticRegression(C=1.0, class_weight=None, dual=False, fit_intercept=True,
 intercept_scaling=1, max_iter=100, multi_class='ovr', n_jobs=1,
 penalty='l2', random_state=None, solver='liblinear', tol=0.0001,
 verbose=0, warm_start=False)

现在我们可以计算我们的混淆矩阵,并立即看到分类器是如何工作的:

from sklearn.metrics import confusion_matrix

>>> cm = confusion_matrix(y_true=Y_test, y_pred=lr.predict(X_test))
cm[::-1, ::-1]
[[50  5]
 [ 2 68]]

最后的操作是必要的,因为 scikit-learn 采用了一个逆轴。然而,在许多书中,混淆矩阵的真实值位于主对角线上,所以我更喜欢反转轴。

为了避免错误,我建议您访问scikit-learn.org/stable/modules/generated/sklearn.metrics.confusion_matrix.html页面,并检查真实/假正/假负的位置。

因此,我们有五个假阴性样本和两个假阳性样本。如果需要,进一步的分析可以检测到误分类,以决定如何处理它们(例如,如果它们的方差超过预定义的阈值,可以考虑它们为异常值并移除它们)。

另一个有用的直接指标是:

图片

这直接关联到捕捉决定样本正性的特征的能力,以避免被错误地分类为负类。在 scikit-learn 中,实现方式如下:

from sklearn.metrics import precision_score

>>> precision_score(Y_test, lr.predict(X_test))
0.96153846153846156

如果您不想翻转混淆矩阵,但想要得到相同的指标,必须在所有指标评分函数中添加pos_label=0参数。

在所有潜在的阳性样本中检测真实阳性样本的能力可以使用另一个指标来评估:

图片

scikit-learn 的实现方式如下:

from sklearn.metrics import recall_score

>>> recall_score(Y_test, lr.predict(X_test))
0.90909090909090906

我们有 90%的召回率和 96%的精确度并不令人惊讶,因为假阴性(影响召回率)的数量与假阳性(影响精确度)的数量成比例较高。精确度和召回率之间的加权调和平均值由以下公式提供:

图片

当 beta 值等于 1 时,确定所谓的F[1]分数,这是两个指标之间的完美平衡。当 beta 小于 1 时,更重视精确度;当 beta 大于 1 时,更重视召回率。以下代码片段展示了如何使用 scikit-learn 实现它:

from sklearn.metrics import fbeta_score

>>> fbeta_score(Y_test, lr.predict(X_test), beta=1)
0.93457943925233655

>>> fbeta_score(Y_test, lr.predict(X_test), beta=0.75)
0.94197437829691033

>>> fbeta_score(Y_test, lr.predict(X_test), beta=1.25)
0.92886270956048933

对于F[1]分数,scikit-learn 提供了f1_score()函数,它与fbeta_score()函数的beta=1等价。

最高分是通过更重视精确度(精确度更高)来实现的,而最低分则对应着召回率占优。因此,Beta 系数有助于获得一个关于准确性的紧凑图景,该图景是高精确度和有限数量的假阴性之间的权衡。

ROC 曲线

ROC 曲线(或接收者操作特征)是用于比较不同分类器(可以为它们的预测分配分数)的有价值工具。通常,这个分数可以解释为概率,因此它在 0 和 1 之间。平面结构如下所示:

图片

x 轴代表不断增加的假阳性率(也称为特异性),而 y 轴代表真正阳性率(也称为灵敏度)。虚线斜线代表一个完全随机的分类器,因此所有低于此阈值的曲线的性能都比随机选择差,而高于此阈值的曲线则表现出更好的性能。当然,最佳分类器的 ROC 曲线将分为 [0, 0] - [0, 1] 和 [0, 1] - [1, 1] 这两个部分,我们的目标是找到性能尽可能接近这个极限的算法。为了展示如何使用 scikit-learn 创建 ROC 曲线,我们将训练一个模型来确定预测的分数(这可以通过 decision_function()predict_proba() 方法实现):

>>> X_train, X_test, Y_train, Y_test = train_test_split(X, Y, test_size=0.25)

>>> lr = LogisticRegression()
>>> lr.fit(X_train, Y_train)
LogisticRegression(C=1.0, class_weight=None, dual=False, fit_intercept=True,
 intercept_scaling=1, max_iter=100, multi_class='ovr', n_jobs=1,
 penalty='l2', random_state=None, solver='liblinear', tol=0.0001,
 verbose=0, warm_start=False)

>>> Y_scores = lr.decision_function(X_test)

现在我们可以计算 ROC 曲线:

from sklearn.metrics import roc_curve

>>> fpr, tpr, thresholds = roc_curve(Y_test, Y_scores)

输出由不断增加的真正阳性和假阳性率以及不断降低的阈值(通常不用于绘制曲线)组成。在继续之前,计算曲线下面积AUC)也是很有用的,其值介于 0(最差性能)和 1(最佳性能)之间,完全随机值对应于 0.5:

from sklearn.metrics import auc

>>> auc(fpr, tpr)
0.96961038961038959

我们已经知道我们的性能相当好,因为 AUC 接近 1。现在我们可以使用 matplotlib 绘制 ROC 曲线。由于这本书不是专门介绍这个强大框架的,我将使用可以在多个示例中找到的代码片段:

import matplotlib.pyplot as plt

>>> plt.figure(figsize=(8, 8))
>>> plt.plot(fpr, tpr, color='red', label='Logistic regression (AUC: %.2f)' % auc(fpr, tpr))
>>> plt.plot([0, 1], [0, 1], color='blue', linestyle='--')
>>> plt.xlim([0.0, 1.0])
>>> plt.ylim([0.0, 1.01])
>>> plt.title('ROC Curve')
>>> plt.xlabel('False Positive Rate')
>>> plt.ylabel('True Positive Rate')
>>> plt.legend(loc="lower right")
>>> plt.show()

结果 ROC 曲线如下所示:

图片

如 AUC 所证实,我们的 ROC 曲线显示出非常好的性能。在后面的章节中,我们将使用 ROC 曲线来直观地比较不同的算法。作为一个练习,你可以尝试同一模型的不同的参数,并绘制所有 ROC 曲线,以立即了解哪种设置更可取。

我建议访问 matplotlib.org,以获取更多信息和学习教程。此外,一个非凡的工具是 Jupyter (jupyter.org),它允许使用交互式笔记本,在那里你可以立即尝试你的代码并可视化内联图表。

摘要

线性模型使用分离超平面来对样本进行分类;因此,如果可以找到一个线性模型,其准确率超过一个预定的阈值,则问题线性可分。逻辑回归是最著名的线性分类器之一,其原理是最大化样本属于正确类的概率。随机梯度下降分类器是一组更通用的算法,由采用的损失函数的不同而决定。SGD 允许部分拟合,尤其是在数据量太大而无法加载到内存中的情况下。感知器是 SGD 的一个特定实例,代表一个不能解决XOR问题的线性神经网络(因此,多层感知器成为了非线性分类的首选)。然而,在一般情况下,其性能与逻辑回归模型相当。

所有分类器的性能都必须使用不同的方法进行衡量,以便能够优化它们的参数或在我们对结果不满意时更改它们。我们讨论了不同的指标,特别是 ROC 曲线,它以图形方式显示了不同分类器的性能。

在下一章中,我们将讨论朴素贝叶斯分类器,这是另一组非常著名且强大的算法家族。得益于这种简单的方法,我们可以仅使用概率和结果质量来构建垃圾邮件过滤系统,并解决看似复杂的问题。即使经过几十年,它仍然优于或与许多更复杂的解决方案相当。

第六章:朴素贝叶斯

朴素贝叶斯是一系列强大且易于训练的分类器,它们使用贝叶斯定理根据一组条件确定结果的可能性。换句话说,条件概率被反转,因此查询可以表示为可测量量的函数。这种方法很简单,形容词“朴素”之所以被赋予,并不是因为这些算法有限或效率较低,而是因为我们对因果因素的假设是基本的。朴素贝叶斯是多用途分类器,很容易在不同的环境中找到它们的应用;然而,它们的性能在所有那些由某些因果因素的概率确定类别的情境中尤其出色。一个很好的例子是自然语言处理,其中一段文本可以被视为字典的一个特定实例,所有术语的相对频率提供了足够的信息来推断所属类别。我们将在后面的章节中讨论这些概念。在这一章中,我们的例子将始终是通用的,以便让读者了解如何在各种环境中应用朴素贝叶斯。

贝叶斯定理

让我们考虑两个概率事件 A 和 B。我们可以使用乘法规则将边缘概率 P(A)P(B) 与条件概率 P(A|B)P(B|A) 相关联:

图片

考虑到交集是可交换的,第一成员是相等的;因此,我们可以推导出贝叶斯定理

图片

这个公式具有非常深刻的哲学意义,它是统计学习的基本元素。首先,让我们考虑边缘概率 P(A);这通常是一个确定目标事件可能性的值,例如 P(Spam)P(Rain)。由于没有其他元素,这种概率被称为先验概率,因为它通常由数学考虑或简单地由频率计数确定。例如,想象我们想要实现一个非常简单的垃圾邮件过滤器,我们已经收集了 100 封电子邮件。我们知道其中 30 封是垃圾邮件,70 封是常规邮件。因此,我们可以说 P(Spam) = 0.3。

然而,我们希望使用一些标准来评估(为了简单起见,让我们考虑一个),例如,电子邮件文本的长度少于 50 个字符*。因此,我们的查询变为:

图片

第一个项类似于P(Spam),因为它是在特定条件下垃圾邮件的概率。因此,它被称为后验概率(换句话说,它是在知道一些额外元素后我们可以估计的概率)。在等式的右侧,我们需要计算缺失的值,但这很简单。假设有 35 封邮件的文本长度小于 50 个字符,所以P(Text < 50 chars) = 0.35。仅查看我们的垃圾邮件文件夹,我们发现只有 25 封垃圾邮件的文本较短,因此P(Text < 50 chars|Spam) = 25/30 = 0.83。结果是:

图片

因此,收到一封非常短的电子邮件后,有 71%的概率它是垃圾邮件。现在,我们可以理解P(Text < 50 chars|Spam)的作用;因为我们有实际数据,我们可以测量在查询条件下我们的假设有多可能。换句话说,我们定义了一个似然(与逻辑回归进行比较),它是先验概率和后验概率(分母中的项不太重要,因为它作为一个常规化因子)之间的权重:

图片

常规化因子通常用希腊字母α表示,因此公式变为:

图片

最后一步是考虑存在更多并发条件的情况(这在现实生活中的问题中更为现实):

图片

一个常见的假设被称为条件独立性(换句话说,每个原因产生的影响是相互独立的)并且这允许我们写出简化的表达式:

图片

简单贝叶斯分类器

简单贝叶斯分类器之所以被称为简单贝叶斯,是因为它基于一个简单的条件,这暗示了原因之间的条件独立性。这在许多情况下可能很难接受,在这些情况下,特定特征的概率严格地与另一个特征相关。例如,在垃圾邮件过滤中,文本长度小于 50 个字符可能会增加图像出现的概率,或者如果域名已经被列入黑名单,用于向数百万用户发送相同的垃圾邮件,那么很可能找到特定的关键词。换句话说,原因的存在通常不是独立于其他原因的存在。然而,在张 H.的《简单贝叶斯的最优性》,AAAI 1,第 2 期(2004 年):3 中,作者表明在特定条件下(并不罕见),不同的依赖关系相互消除,即使其简单性被违反,简单贝叶斯分类器也能成功实现非常高的性能。

让我们考虑一个数据集:

图片

为了简化,每个特征向量将表示为:

图片

我们还需要一个目标数据集:

图片

在这里,每个y可以属于P个不同类别中的一个。在条件独立下考虑贝叶斯定理,我们可以写出:

图片

边缘先验概率P(y)和条件概率P(x[i]|y)的值是通过频率计数获得的;因此,给定一个输入向量x,预测的类别是后验概率最大的那个类别。

scikit-learn 中的朴素贝叶斯

scikit-learn 实现了基于相同数量的不同概率分布的三个朴素贝叶斯变体:伯努利、多项式和高斯。第一个是一个二元分布,当特征可以存在或不存在时很有用。第二个是一个离散分布,当必须用整数表示特征时使用(例如,在自然语言处理中,它可以是一个术语的频率),而第三个是一个连续分布,其特征是均值和方差。

伯努利朴素贝叶斯

如果X是伯努利分布的随机变量,它只能假设两个值(为了简单起见,让我们称它们为 0 和 1),它们的概率是:

图片

要使用 scikit-learn 尝试此算法,我们将生成一个虚拟数据集。伯努利朴素贝叶斯期望二元特征向量;然而,BernoulliNB类有一个binarize参数,允许我们指定一个阈值,该阈值将内部用于将特征转换:

from sklearn.datasets import make_classification

>>> nb_samples = 300
>>> X, Y = make_classification(n_samples=nb_samples, n_features=2, n_informative=2, n_redundant=0)

我们已经生成了以下图中所示的二维数据集:

图片

我们决定使用 0.0 作为二元阈值,因此每个点都可以用其所在象限来表征。当然,这对我们的数据集来说是一个合理的选择,但伯努利朴素贝叶斯是为二元特征向量或可以精确分割的连续值(例如,使用预定义的阈值)而设计的:

from sklearn.naive_bayes import BernoulliNB
from sklearn.model_selection import train_test_split

>>> X_train, X_test, Y_train, Y_test = train_test_split(X, Y, test_size=0.25)

>>> bnb = BernoulliNB(binarize=0.0)
>>> bnb.fit(X_train, Y_train)
>>> bnb.score(X_test, Y_test)
0.85333333333333339

分数相当不错,但如果我们想了解二元分类器是如何工作的,看到数据是如何内部二值化的很有用:

图片

现在,检查朴素贝叶斯预测,我们得到:

>>> data = np.array([[0, 0], [0, 1], [1, 0], [1, 1]])
>>> bnb.predict(data)
array([0, 0, 1, 1])

这正是我们预期的。

多项式朴素贝叶斯

多项式分布对于建模特征向量很有用,其中每个值代表,例如,一个术语出现的次数或其相对频率。如果特征向量有n个元素,并且每个元素可以以概率p[k]假设k个不同的值,那么:

图片

条件概率P(x[i]|y)通过频率计数(这相当于应用最大似然方法)来计算,但在这个情况下,考虑alpha 参数(称为拉普拉斯平滑因子)是很重要的。它的默认值是 1.0,它防止模型在频率为零时设置零概率。可以分配所有非负值;然而,较大的值将赋予缺失特征更高的概率,这种选择可能会改变模型的稳定性。在我们的例子中,我们将考虑默认值 1.0。

对于我们的目的,我们将使用DictVectorizer,这在第二章 - 机器学习中的重要元素中已经分析过。有自动工具可以计算术语的频率,但我们将在稍后讨论。让我们只考虑两个记录:第一个代表一个城市,第二个代表乡村。我们的字典包含假设频率,就像术语是从文本描述中提取出来的一样:

from sklearn.feature_extraction import DictVectorizer

>>> data = [
   {'house': 100, 'street': 50, 'shop': 25, 'car': 100, 'tree': 20},
   {'house': 5, 'street': 5, 'shop': 0, 'car': 10, 'tree': 500, 'river': 1}
]

>>> dv = DictVectorizer(sparse=False)
>>> X = dv.fit_transform(data)
>>> Y = np.array([1, 0])

>>> X
array([[ 100.,  100.,    0.,   25.,   50.,   20.],
       [  10.,    5.,    1.,    0.,    5.,  500.]])

注意,术语'river'在第一个集合中缺失,因此保持 alpha 等于 1.0 以给它一个小的概率是有用的。输出类别是 1 代表城市,0 代表乡村。现在我们可以训练一个MultinomialNB实例:

from sklearn.naive_bayes import MultinomialNB

>>> mnb = MultinomialNB()
>>> mnb.fit(X, Y)
MultinomialNB(alpha=1.0, class_prior=None, fit_prior=True)

为了测试模型,我们创建了一个有河流的虚拟城市和一个没有河流的虚拟乡村:

>>> test_data = data = [
   {'house': 80, 'street': 20, 'shop': 15, 'car': 70, 'tree': 10, 'river': 1},
   {'house': 10, 'street': 5, 'shop': 1, 'car': 8, 'tree': 300, 'river': 0}
]

>>> mnb.predict(dv.fit_transform(test_data))
array([1, 0])

如预期的那样,预测是正确的。稍后,当讨论自然语言处理的一些元素时,我们将使用多项式朴素贝叶斯进行文本分类,并使用更大的语料库。即使多项式分布基于发生次数,它也可以成功地与频率或更复杂的函数一起使用。

高斯朴素贝叶斯

高斯朴素贝叶斯在处理可以用高斯分布建模的连续值时很有用:

条件概率P(x[i]|y)也是高斯分布的;因此,有必要使用最大似然方法估计每个条件概率的均值和方差。这相当简单;事实上,考虑到高斯性质,我们得到:

在这里,k索引指的是我们数据集中的样本,P(x[i]|y)本身就是一个高斯分布。通过最小化这个表达式的倒数(在 Russel S.,Norvig P.,《人工智能:现代方法》,Pearson 中有一个完整的分析解释),我们得到与P(x[i]|y)相关的每个高斯分布的均值和方差,因此模型得到了训练。

作为例子,我们使用 ROC 曲线比较高斯朴素贝叶斯与逻辑回归。数据集有 300 个样本,两个特征。每个样本属于一个单一类别:

from sklearn.datasets import make_classification

>>> nb_samples = 300
>>> X, Y = make_classification(n_samples=nb_samples, n_features=2, n_informative=2, n_redundant=0)

数据集的图示如下所示:

现在我们可以训练这两个模型并生成 ROC 曲线(朴素贝叶斯的 Y 分数是通过 predict_proba 方法获得的):

from sklearn.naive_bayes import GaussianNB
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_curve, auc
from sklearn.model_selection import train_test_split

>>> X_train, X_test, Y_train, Y_test = train_test_split(X, Y, test_size=0.25)

>>> gnb = GaussianNB()
>>> gnb.fit(X_train, Y_train)
>>> Y_gnb_score = gnb.predict_proba(X_test)

>>> lr = LogisticRegression()
>>> lr.fit(X_train, Y_train)
>>> Y_lr_score = lr.decision_function(X_test)

>>> fpr_gnb, tpr_gnb, thresholds_gnb = roc_curve(Y_test, Y_gnb_score[:, 1])
>>> fpr_lr, tpr_lr, thresholds_lr = roc_curve(Y_test, Y_lr_score)

生成的 ROC 曲线(与前一章中显示的相同方式生成)如下所示:

图片

Naive Bayes 的性能略优于逻辑回归;然而,这两个分类器的准确率和 曲线下面积AUC)相似。比较高斯和多项式朴素贝叶斯在 MNIST 数字数据集上的性能很有趣。每个样本(属于 10 个类别之一)是一个 8 x 8 的图像,编码为无符号整数(0-255);因此,即使每个特征不表示实际的计数,也可以将其视为一种大小或频率:

from sklearn.datasets import load_digits
from sklearn.model_selection import cross_val_score

>>> digits = load_digits()

>>> gnb = GaussianNB()
>>> mnb = MultinomialNB()

>>> cross_val_score(gnb, digits.data, digits.target, scoring='accuracy', cv=10).mean()
0.81035375835678214

>>> cross_val_score(mnb, digits.data, digits.target, scoring='accuracy', cv=10).mean()
0.88193962163008377 

多项式朴素贝叶斯比高斯变体表现更好,这个结果并不令人惊讶。实际上,每个样本可以被视为从 64 个符号的字典中派生出的特征向量。值可以是每个发生的计数,因此多项式分布可以更好地拟合数据,而高斯则稍微受限于其均值和方差。

参考文献

  • Russell S., Norvig P., 《人工智能:现代方法》,Pearson

  • Zhang H., 《朴素贝叶斯的最优性,AAAI 1》,第 2 期(2004 年):3

  • Papoulis A., 《概率论、随机变量与随机过程》,McGraw-Hill

摘要

在本章中,我们从贝叶斯定理及其内在哲学出发,介绍了朴素贝叶斯的一般方法。这些算法的朴素性是由于选择假设所有原因都是条件独立的。这意味着每个贡献在每种组合中都是相同的,特定原因的存在不能改变其他原因的概率。这并不总是现实的;然而,在某些假设下,可以证明内部依赖会相互清除,从而使结果概率不受其关系的影响。

scikit-learn 提供了三种朴素贝叶斯实现:伯努利、多项式和高斯。它们之间的唯一区别在于采用的概率分布。第一个是一个二元算法,特别适用于一个特征可以存在或不存在的情况。多项式假设有特征向量,其中每个元素代表其出现的次数(或,非常常见的是,其频率)。这种技术在自然语言处理或样本由一个共同的字典组成时非常有效。相反,高斯基于连续分布,适用于更通用的分类任务。

在下一章中,我们将介绍一种名为支持向量机的新分类技术。这些算法在解决线性和非线性问题方面非常强大。由于它们效率高,内部动态非常简单,并且可以在非常短的时间内进行训练,因此它们通常被选为更复杂场景的首选。

第七章:支持向量机

在本章中,我们将介绍另一种使用称为支持向量机算法族的方法来进行分类。它们可以处理线性和非线性场景,允许在许多不同环境中实现高性能。与神经网络一起,SVMs 可能是许多难以找到良好分离超平面的任务的最佳选择。例如,由于它们可以使用数学技巧捕捉非常高的非线性动态,而不需要对算法进行复杂修改,因此 SVMs 长期以来一直是 MNIST 数据集分类的最佳选择。在第一部分,我们将讨论线性 SVM 的基本知识,然后将其用于它们的非线性扩展。我们还将讨论一些控制参数数量的技术,最后,将支持向量算法应用于回归问题。

线性支持向量机

让我们考虑一个我们想要分类的特征向量数据集:

图片

为了简化,我们假设它是一个二元分类(在其他所有情况下,可以使用自动的 one-versus-all 策略)并将我们的类别标签设置为-1 和 1:

图片

我们的目标是找到最佳分离超平面,其方程为:

图片

在以下图中,有一个二维表示的此类超平面:

图片

以这种方式,我们的分类器可以写成:

图片

在现实场景中,两个类别通常由一个包含两个边界的间隔分开,其中有一些元素位于边界上。这些元素被称为支持向量。为了更通用的数学表达,最好重新归一化我们的数据集,使得支持向量将位于两个具有以下方程的超平面上:

图片

在以下图中,有一个包含两个支持向量的示例。虚线是原始的分离超平面:

图片

我们的目标是最大化这两个边界超平面之间的距离,以减少误分类的概率(当距离短时,误分类的概率更高,并且没有像前一个图中的两个明确分离的团块)。

考虑到边界是平行的,它们之间的距离由垂直于两者并连接两个点的线段的长度定义:

图片

将点视为向量,因此,我们有:

图片

现在,考虑到边界超平面方程,我们得到:

图片

最后部分的第一个项等于-1,因此我们解出t

图片

x[1]x[2] 之间的距离是线段 t 的长度;因此我们得到:

图片

现在,考虑到我们数据集中的所有点,我们可以施加以下约束:

图片

这通过使用 -1 和 1 作为类别标签和边界边缘来保证。等式仅对支持向量成立,而对于所有其他点,它将大于 1。重要的是要考虑模型不考虑这个边缘之外的向量。在许多情况下,这可以产生一个非常鲁棒的模型,但在许多数据集中,这也可能是一个强烈的限制。在下一段中,我们将使用一个技巧来避免这种僵化,同时保持相同的优化技术。

在这个阶段,我们可以定义用于训练支持向量机的最小化函数:

图片

这可以通过从范数中移除平方根来进一步简化(以下二次规划问题):

图片

scikit-learn 实现

为了允许模型拥有更灵活的分离超平面,所有 scikit-learn 实现都基于一个简单变体,该变体在最小化函数中包括所谓的松弛变量

图片

在这种情况下,约束变为:

图片

松弛变量的引入使我们能够创建一个灵活的边缘,使得属于一个类别的某些向量也可以出现在超空间的另一部分,并可以包含在模型训练中。这种灵活性的强度可以通过参数 C 来设置。小值(接近零)产生非常硬的边缘,而大于或等于 1 的值允许更多的灵活性(同时也增加了误分类率)。C 的正确选择不是立即的,但最佳值可以通过使用网格搜索自动找到,如前几章所述。在我们的例子中,我们保持默认值 1。

线性分类

我们的第一个例子是基于线性 SVM,如前文所述。我们首先创建一个包含 500 个向量的虚拟数据集,这些向量被划分为两类:

from sklearn.datasets import make_classification

>>> nb_samples = 500
>>> X, Y = make_classification(n_samples=nb_samples, n_features=2, n_informative=2, n_redundant=0, n_clusters_per_class=1)

在以下图中,是数据集的图表。注意,一些点重叠在两个主要团块上。因此,需要一个正的 C 值来允许模型捕捉更复杂的动态。

图片

scikit-learn 提供了 SVC 类,这是一个非常高效的实现,在大多数情况下都可以使用。我们将使用它与交叉验证一起验证性能:

from sklearn.svm import SVC
from sklearn.model_selection import cross_val_score

>>> svc = SVC(kernel='linear')
>>> cross_val_score(svc, X, Y, scoring='accuracy', cv=10).mean()
0.93191356542617032

在这个例子中,kernel参数必须设置为'linear'。在下一节中,我们将讨论它是如何工作的以及它如何可以显著提高 SVM 在非线性场景中的性能。正如预期的那样,准确度与逻辑回归相当,因为这个模型试图找到一个最优的线性分离器。训练好模型后,可以通过名为support_vectors_的实例变量获取支持向量的数组。以下图显示了它们的图示:

图片

如图中所示,它们被放置在分离线上的一个条带中。C和松弛变量的影响决定了可移动的边界,部分地捕捉了现有的重叠。当然,使用线性分类器完美地分离集合是不可能的,另一方面,大多数现实生活中的问题都是非线性的;因此,这是一个必要的进一步步骤。

基于核的分类

当处理非线性问题时,通过将原始向量投影到一个更高维的空间中,使其可以线性分离,这是一种有用的方法。我们在讨论多项式回归时看到了类似的方法。SVMs 也采用相同的方法,尽管我们现在需要克服一个复杂性问题。我们的数学公式变为:

图片

每个特征向量现在都通过一个非线性函数进行过滤,可以完全改变场景。然而,引入这样的函数增加了计算复杂度,这可能会明显地阻碍这种方法。为了理解发生了什么,有必要使用拉格朗日乘数表达二次问题。整个过程超出了本书的范围(在 Nocedal J.,Wright S. J.的《数值优化》Springer 中可以找到二次规划问题的完整和正式描述);然而,最终的公式是:

图片

因此,对于每一对向量,有必要计算以下内容:

图片

而这个程序可能成为瓶颈,对于大问题来说是不可以接受的。然而,现在所谓的核技巧就出现了。有一些特定的函数(称为核)具有以下特性:

图片

换句话说,两个特征向量核的值是两个投影向量的乘积。通过这个技巧,计算复杂度几乎保持不变,但我们可以在非常高的维度中受益于非线性投影的力量。

除了简单的乘积的线性核之外,scikit-learn 支持三种不同的核,可以解决许多现实生活中的问题。

径向基函数

RBF 核是 SVC 的默认值,基于以下函数:

图片

Gamma 参数决定了函数的振幅,它不受方向的影响,只受距离的影响。

多项式核

多项式核基于以下函数:

图片

指数 c 通过参数 degree 指定,而常数项 r 被称为 coef0。这个函数可以通过大量的支持变量轻松地扩展维度,克服非常非线性的问题;然而,在资源方面的要求通常更高。考虑到一个非线性函数通常可以很好地逼近一个有界区域(通过采用多项式),因此许多复杂问题使用这个核函数变得容易解决并不令人惊讶。

Sigmoid 核

Sigmoid 核基于以下函数:

图片

常数项 r 通过参数 coef0 指定。

自定义核函数

通常,内置核可以有效地解决大多数实际问题;然而,scikit-learn 允许我们创建自定义核,就像普通的 Python 函数一样:

import numpy as np 

>>> def custom_kernel(x1, x2): 
 return np.square(np.dot(x1, x2) + 1)

该函数可以通过 SVC 的 kernel 参数传递,该参数可以假设固定字符串值('linear', 'rbf', 'poly''sigmoid')或一个可调用对象(例如 kernel=custom_kernel)。

非线性示例

为了展示核 SVM 的强大功能,我们将解决两个问题。第一个问题比较简单,但完全是非线性的,数据集是通过内置函数 make_circles() 生成的:

from sklearn.datasets import make_circles 

>>> nb_samples = 500 
>>> X, Y = make_circles(n_samples=nb_samples, noise=0.1)

下图展示了该数据集的图表:

图片

如所见,线性分类器永远无法分离这两个集合,每个近似值平均将包含 50%的错误分类。这里展示了逻辑回归的一个例子:

from sklearn.linear_model import LogisticRegression 

>>> lr = LogisticRegression() 
>>> cross_val_score(lr, X, Y, scoring='accuracy', cv=10).mean() 
0.438

如预期,准确率低于 50%,没有其他优化可以显著提高它。让我们考虑使用 SVM 和不同核函数的网格搜索(保持每个的默认值):

import multiprocessing 
from sklearn.model_selection import GridSearchCV 

>>> param_grid = [ 
 { 
 'kernel': ['linear', 'rbf', 'poly', 'sigmoid'], 
 'C': [ 0.1, 0.2, 0.4, 0.5, 1.0, 1.5, 1.8, 2.0, 2.5, 3.0 ] 
 } 
] 

>>> gs = GridSearchCV(estimator=SVC(), param_grid=param_grid, 
 scoring='accuracy', cv=10, n_jobs=multiprocessing.cpu_count()) 

>>> gs.fit(X, Y) 
GridSearchCV(cv=10, error_score='raise', 
 estimator=SVC(C=1.0, cache_size=200, class_weight=None, coef0=0.0, 
 decision_function_shape=None, degree=3, gamma='auto', kernel='rbf', 
 max_iter=-1, probability=False, random_state=None, shrinking=True, 
 tol=0.001, verbose=False), 
 fit_params={}, iid=True, n_jobs=8, 
 param_grid=[{'kernel': ['linear', 'rbf', 'poly', 'sigmoid'], 'C': [0.1, 0.2, 0.4, 0.5, 1.0, 1.5, 1.8, 2.0, 2.5, 3.0]}], 
 pre_dispatch='2*n_jobs', refit=True, return_train_score=True, 
 scoring='accuracy', verbose=0) 

>>> gs.best_estimator_ 
SVC(C=2.0, cache_size=200, class_weight=None, coef0=0.0, 
 decision_function_shape=None, degree=3, gamma='auto', kernel='rbf', 
 max_iter=-1, probability=False, random_state=None, shrinking=True, 
 tol=0.001, verbose=False) 

>>> gs.best_score_ 
0.87

如从数据集的几何形状所预期,最佳的核函数是径向基函数,准确率达到 87%。进一步对 gamma 进行优化可以略微提高这个值,但由于两个子集之间有部分重叠,要达到接近 100%的准确率非常困难。然而,我们的目标不是过度拟合我们的模型;而是保证一个适当的泛化水平。因此,考虑到形状,有限数量的错误分类是可以接受的,以确保模型能够捕捉到边界表面的次级振荡。

另一个有趣的例子是由 MNIST 手写数字数据集提供的。我们之前已经见过它,并使用线性模型对其进行分类。现在我们可以尝试使用 SVM 找到最佳的核函数:

from sklearn.datasets import load_digits 

>>> digits = load_digits() 

>>> param_grid = [ 
 { 
 'kernel': ['linear', 'rbf', 'poly', 'sigmoid'], 
 'C': [ 0.1, 0.2, 0.4, 0.5, 1.0, 1.5, 1.8, 2.0, 2.5, 3.0 ] 
 } 
] 

>>> gs = GridSearchCV(estimator=SVC(), param_grid=param_grid, 
 scoring='accuracy', cv=10, n_jobs=multiprocessing.cpu_count()) 

>>> gs.fit(digits.data, digits.target) 
GridSearchCV(cv=10, error_score='raise', 
 estimator=SVC(C=1.0, cache_size=200, class_weight=None, coef0=0.0, 
 decision_function_shape=None, degree=3, gamma='auto', kernel='rbf', 
 max_iter=-1, probability=False, random_state=None, shrinking=True, 
 tol=0.001, verbose=False), 
 fit_params={}, iid=True, n_jobs=8, 
 param_grid=[{'kernel': ['linear', 'rbf', 'poly', 'sigmoid'], 'C': [0.1, 0.2, 0.4, 0.5, 1.0, 1.5, 1.8, 2.0, 2.5, 3.0]}], 
 pre_dispatch='2*n_jobs', refit=True, return_train_score=True, 
 scoring='accuracy', verbose=0) 

>>> gs.best_estimator_ 
SVC(C=0.1, cache_size=200, class_weight=None, coef0=0.0, 
 decision_function_shape=None, degree=3, gamma='auto', kernel='poly', 
 max_iter=-1, probability=False, random_state=None, shrinking=True, 
 tol=0.001, verbose=False) 

>>> gs.best_score_ 
0.97885364496382865

因此,最佳分类器(几乎 98%的准确性)基于多项式核和非常低的C值。这意味着一个非常硬边界的非线性变换可以轻松地捕捉所有数字的动态。实际上,SVM(具有各种内部替代方案)在这个数据集上始终表现出优异的性能,并且它们的用途可以轻松地扩展到类似的问题。

另一个有趣的例子是基于 Olivetti 人脸数据集,这个数据集不属于 scikit-learn,但可以使用内置函数fetch_olivetti_faces()自动下载和设置:

from sklearn.datasets import fetch_olivetti_faces

>>> faces = fetch_olivetti_faces(data_home='/ML/faces/')

通过data_home参数,可以指定数据集必须放置的本地文件夹。以下图中显示了样本的子集:

有 40 个不同的人,每个人用 10 张 64 x 64 像素的图片来表示。类别的数量(40)不算高,但考虑到许多照片的相似性,一个好的分类器应该能够捕捉到一些特定的解剖细节。使用非线性核进行网格搜索,我们得到:

>>> param_grid = [
 { 
 'kernel': ['rbf', 'poly'],
 'C': [ 0.1, 0.5, 1.0, 1.5 ],
 'degree': [2, 3, 4, 5],
 'gamma': [0.001, 0.01, 0.1, 0.5]
 }
]

>>> gs = GridSearchCV(estimator=SVC(), param_grid=param_grid, scoring='accuracy', cv=8,  n_jobs=multiprocessing.cpu_count())
>>> gs.fit(faces.data, faces.target)
GridSearchCV(cv=8, error_score='raise',
 estimator=SVC(C=1.0, cache_size=200, class_weight=None, coef0=0.0,
 decision_function_shape=None, degree=3, gamma='auto', kernel='rbf',
 max_iter=-1, probability=False, random_state=None, shrinking=True,
 tol=0.001, verbose=False),
 fit_params={}, iid=True, n_jobs=8,
 param_grid=[{'kernel': ['rbf', 'poly'], 'C': [0.1, 0.5, 1.0, 1.5], 'gamma': [0.001, 0.01, 0.1, 0.5], 'degree': [2, 3, 4, 5]}],
 pre_dispatch='2*n_jobs', refit=True, return_train_score=True,
 scoring='accuracy', verbose=0)

>>> gs.best_estimator_
SVC(C=0.1, cache_size=200, class_weight=None, coef0=0.0,
 decision_function_shape=None, degree=2, gamma=0.1, kernel='poly',
 max_iter=-1, probability=False, random_state=None, shrinking=True,
 tol=0.001, verbose=False)

因此,最佳估计器是基于多项式的,degree=2,相应的准确率是:

>>> gs.best_score_
0.96999999999999997

这证实了 SVM 即使在可以非常快速计算出的简单核中也能捕捉非线性动态的能力。对于读者来说,尝试不同的参数组合或预处理数据并应用主成分分析以降低其维度性将是有趣的。

受控支持向量机

在真实数据集上,SVM 可以提取大量的支持向量来提高准确性,这可能会减慢整个过程。为了找到精度和支持向量数量之间的权衡,scikit-learn 提供了一个名为NuSVC的实现,其中参数nu(介于 0—不包括—和 1 之间)可以用来同时控制支持向量的数量(较大的值会增加它们的数量)和训练错误(较小的值会减少错误的比例)。让我们考虑一个具有线性核和简单数据集的例子。以下图中是我们的数据集的散点图:

让我们从检查标准 SVM 的支持向量数量开始:

>>> svc = SVC(kernel='linear') 
>>> svc.fit(X, Y) 
>>> svc.support_vectors_.shape 
(242L, 2L)

因此,模型找到了 242 个支持向量。现在让我们尝试使用交叉验证来优化这个数量。默认值是 0.5,这是一个可接受的权衡:

from sklearn.svm import NuSVC 

>>> nusvc = NuSVC(kernel='linear', nu=0.5) 
>>> nusvc.fit(X, Y) 
>>> nusvc.support_vectors_.shape 
(251L, 2L) 

>>> cross_val_score(nusvc, X, Y, scoring='accuracy', cv=10).mean() 
0.80633213285314143

如预期,其行为类似于标准 SVC。现在让我们降低nu的值:

>>> nusvc = NuSVC(kernel='linear', nu=0.15) 
>>> nusvc.fit(X, Y) 
>>> nusvc.support_vectors_.shape 
(78L, 2L) 

>>> cross_val_score(nusvc, X, Y, scoring='accuracy', cv=10).mean() 
0.67584393757503003

在这种情况下,支持向量的数量比之前少,而且这个选择也影响了准确性。我们不必尝试不同的值,可以通过网格搜索来寻找最佳选择:

import numpy as np 

>>> param_grid = [ 
 { 
 'nu': np.arange(0.05, 1.0, 0.05) 
 } 
] 

>>> gs = GridSearchCV(estimator=NuSVC(kernel='linear'), param_grid=param_grid, 
 scoring='accuracy', cv=10, n_jobs=multiprocessing.cpu_count()) 
>>> gs.fit(X, Y) 
GridSearchCV(cv=10, error_score='raise', 
 estimator=NuSVC(cache_size=200, class_weight=None, coef0=0.0, 
 decision_function_shape=None, degree=3, gamma='auto', kernel='linear', 
 max_iter=-1, nu=0.5, probability=False, random_state=None, 
 shrinking=True, tol=0.001, verbose=False), 
 fit_params={}, iid=True, n_jobs=8, 
 param_grid=[{'nu': array([ 0.05,  0.1 ,  0.15,  0.2 ,  0.25,  0.3 ,  0.35,  0.4 ,  0.45, 
 0.5 ,  0.55,  0.6 ,  0.65,  0.7 ,  0.75,  0.8 ,  0.85,  0.9 ,  0.95])}], 
 pre_dispatch='2*n_jobs', refit=True, return_train_score=True, 
 scoring='accuracy', verbose=0) 

>>> gs.best_estimator_ 
NuSVC(cache_size=200, class_weight=None, coef0=0.0, 
 decision_function_shape=None, degree=3, gamma='auto', kernel='linear', 
 max_iter=-1, nu=0.5, probability=False, random_state=None, 
 shrinking=True, tol=0.001, verbose=False) 

>>> gs.best_score_ 
0.80600000000000005 

>>> gs.best_estimator_.support_vectors_.shape 
(251L, 2L)

因此,在这种情况下,默认值 0.5 产生了最准确的结果。通常,这种方法工作得相当好,但当需要减少支持向量的数量时,它可以是一个逐步减少 nu 值直到结果可接受的好起点。

支持向量回归

scikit-learn 提供了一种基于已描述算法(请参阅原始文档以获取更多信息)的非常简单的变体支持向量回归器。这种方法真正的力量在于使用非线性核(特别是多项式核);然而,用户应建议逐步评估度数,因为复杂性可以迅速增长,同时训练时间也会增加。

对于我们的示例,我创建了一个基于二次噪声函数的虚拟数据集:

>>> nb_samples = 50 

>>> X = np.arange(-nb_samples, nb_samples, 1) 
>>> Y = np.zeros(shape=(2 * nb_samples,)) 

>>> for x in X: 
 Y[int(x)+nb_samples] = np.power(x*6, 2.0) / 1e4 + np.random.uniform(-2, 2)

数据集在以下图中展示:

图片

为了避免非常长的训练过程,模型使用degree设置为2进行评估。epsilon 参数允许我们指定预测的软边缘;如果预测值包含在以目标值为中心、半径等于 epsilon 的球内,则不对要最小化的函数应用惩罚。默认值是 0.1:

from sklearn.svm import SVR 

>>> svr = SVR(kernel='poly', degree=2, C=1.5, epsilon=0.5) 
>>> cross_val_score(svr, X.reshape((nb_samples*2, 1)), Y, scoring='neg_mean_squared_error', cv=10).mean() 
-1.4641683636397234

参考文献

Nocedal J.,Wright S. J.,数值优化,Springer

摘要

在本章中,我们讨论了支持向量机在线性和非线性场景下的工作原理,从基本的数学公式开始。主要概念是找到通过使用有限数量的样本(称为支持向量)来最大化类别之间的距离的超平面,这些样本是最接近分离边缘的。

我们看到了如何使用核函数将非线性问题转换为线性问题,这些核函数允许将原始空间重新映射到另一个高维空间,在那里问题变得线性可分。我们还看到了如何控制支持向量的数量以及如何使用 SVM 进行回归问题。

在下一章中,我们将介绍另一种称为决策树的分类方法,这是本书中最后解释的方法。

第八章:决策树和集成学习

在本章中,我们将讨论二进制决策树和集成方法。尽管它们可能不是最常见的分类方法,但它们提供了良好的简单性,并且可以在许多不需要高复杂性的任务中应用。当需要展示决策过程的工作原理时,它们也非常有用,因为它们基于一种可以在演示中轻松展示并逐步描述的结构。

集成方法是复杂算法的有力替代品,因为它们试图利用多数投票的统计概念。可以训练许多弱学习器来捕捉不同的元素并做出自己的预测,这些预测不是全局最优的,但使用足够数量的元素,从统计上讲,大多数预测将是正确的。特别是,我们将讨论决策树的随机森林和一些提升方法,这些方法稍微不同的算法可以通过关注误分类样本或通过持续最小化目标损失函数来优化学习过程。

二进制决策树

二进制决策树是一种基于顺序决策过程的结构。从根节点开始,评估一个特征并选择两个分支中的一个。这个过程会重复进行,直到达到一个最终的叶子节点,它通常代表我们寻找的分类目标。与其他算法相比,决策树在动态上似乎更简单;然而,如果数据集在保持内部平衡的同时可以分割,整个过程在预测上既直观又相对快速。此外,决策树可以有效地处理未归一化的数据集,因为它们的内部结构不受每个特征所取值的影响。在下图中,有未归一化的二维数据集的图和用逻辑回归和决策树获得的交叉验证分数:

决策树始终达到接近 1.0 的分数,而逻辑回归的平均分数略大于 0.6。然而,如果没有适当的限制,决策树可能会潜在地生长到每个节点中只有一个样本(或非常少的样本)。这种情况会导致模型过拟合,并且树无法正确泛化。使用一致的测试集或交叉验证可以帮助避免这个问题;然而,在关于 scikit-learn 实现的部分,我们将讨论如何限制树的增长。

二进制决策

让我们考虑一个输入数据集 X

每个向量由 m 个特征组成,因此每个特征都可以作为基于(特征,阈值)元组的节点的好候选:

根据特征和阈值,树的结构将发生变化。直观上,我们应该选择最能分离我们的数据的特征,换句话说,一个完美的分离特征将只存在于一个节点,接下来的两个分支将不再基于它。在现实问题中,这往往是不可行的,因此需要找到最小化后续决策步骤数量的特征。

例如,让我们考虑一个学生群体,其中所有男生都有深色头发,所有女生都有金色头发,而这两个子集都有不同大小的样本。如果我们的任务是确定班级的组成,我们可以从以下细分开始:

图片

然而,包含深色?的块将包含男性和女性(这是我们想要分类的目标)。这个概念用术语纯净度(或者更常见的是其对立概念,杂质)来表示。一个理想的场景是基于杂质为零的节点,这样后续的所有决策都只基于剩余的特征。在我们的例子中,我们可以简单地从颜色块开始:

图片

根据颜色特征,现在得到的两个集合是纯净的,这足以满足我们的任务。如果我们需要更详细的细节,例如发长,必须添加其他节点;它们的杂质不会为零,因为我们知道,例如,既有长发男生也有长发女生。

更正式地说,假设我们定义选择元组如下:

图片

这里,第一个元素是我们想要在某个节点上分割数据集所使用的特征的索引(它只会在开始时是整个数据集;每一步之后,样本数都会减少),而第二个是确定左右分支的阈值。最佳阈值的选择是一个基本元素,因为它决定了树的结构,因此也决定了其性能。目标是减少分割中剩余的杂质,以便在样本数据和分类结果之间有非常短的决策路径。

我们还可以通过考虑两个分支来定义总杂质度量:

图片

在这里,D 是所选节点上的整个数据集,D[left]D[right] 是通过应用选择元组得到的结果子集,而 I 是杂质度量。

杂质度量

要定义最常用的杂质度量,我们需要考虑目标类别的总数:

图片

在某个节点 j,我们可以定义概率 p(i|j),其中 i 是与每个类别关联的索引 [1, n]。换句话说,根据频率主义方法,这个值是属于类别 i 的样本数与属于所选节点的总样本数之间的比率。

Gini 不纯度指数

Gini 不纯度指数定义为:

图片

在这里,总和总是扩展到所有类别。这是一个非常常见的度量,并且被 scikit-learn 用作默认值。给定一个样本,Gini 不纯度衡量的是如果使用分支的概率分布随机选择标签时发生错误分类的概率。当节点中所有样本都被分类到单个类别时,该指标达到最小值(0.0)。

交叉熵不纯度指数

交叉熵度量定义为:

图片

这个度量基于信息理论,并且仅在分割中存在属于单个类别的样本时假设为空值,而在类别之间有均匀分布时达到最大值(这是决策树中最坏的情况之一,因为它意味着还有许多决策步骤直到最终分类)。这个指标与 Gini 不纯度非常相似,尽管更正式地说,交叉熵允许你选择最小化关于分类不确定性的分割,而 Gini 不纯度最小化错误分类的概率。

在第二章《机器学习中的重要元素》中,我们定义了互信息的概念 I(X; Y) = H(X) - H(X|Y),作为两个变量共享的信息量,从而减少了由 Y 的知识提供的关于 X 的不确定性。我们可以使用这个来定义分割提供的信息增益:

图片

当生长树时,我们首先选择提供最高信息增益的分割,并继续进行,直到满足以下条件之一:

  • 所有节点都是纯净的

  • 信息增益为零

  • 已达到最大深度

错误分类不纯度指数

错误分类不纯度是最简单的指标,定义为:

图片

在质量性能方面,这个指标并不是最佳选择,因为它对不同的概率分布(这可以很容易地驱动选择使用 Gini 或交叉熵指标进行细分)并不特别敏感。

特征重要性

当使用多维数据集生长决策树时,评估每个特征在预测输出值中的重要性可能很有用。在第三章《特征选择和特征工程》中,我们讨论了一些通过仅选择最显著的特征来降低数据集维度的方法。决策树提供了一种基于每个特征确定的不纯度减少的不同方法。特别是,考虑一个特征 x[i],其重要性可以确定如下:

图片

求和扩展到所有使用x[i]的节点,而N[k]是达到节点k的样本数量。因此,重要性是所有仅考虑使用特征分割的节点的杂质减少的加权总和。如果采用 Gini 杂质指数,这个度量也称为Gini 重要性

使用 scikit-learn 进行决策树分类

scikit-learn 包含DecisionTreeClassifier类,它可以训练具有 Gini 和交叉熵杂质度量的二叉决策树。在我们的例子中,让我们考虑一个具有三个特征和三个类别的数据集:

from sklearn.datasets import make_classification

>>> nb_samples = 500
>>> X, Y = make_classification(n_samples=nb_samples, n_features=3, n_informative=3, n_redundant=0, n_classes=3, n_clusters_per_class=1)

让我们先考虑一个默认 Gini 杂质度量的分类:

from sklearn.tree import DecisionTreeClassifier
from sklearn.model_selection import cross_val_score

>>> dt = DecisionTreeClassifier()
>>> print(cross_val_score(dt, X, Y, scoring='accuracy', cv=10).mean())
0.970

一个非常有趣的功能是能够将树以Graphviz格式导出,并将其转换为 PDF。

Graphviz 是一个免费工具,可以从www.graphviz.org下载。

要导出训练好的树,必须使用内置函数export_graphviz()

from sklearn.tree import export_graphviz

>>> dt.fit(X, Y)
>>> with open('dt.dot', 'w') as df:
 df = export_graphviz(dt, out_file=df, 
 feature_names=['A','B','C'], 
 class_names=['C1', 'C2', 'C3'])

在这种情况下,我们使用了ABC作为特征名称,C1C2C3作为类别名称。一旦文件创建完成,可以使用命令行工具将其转换为 PDF:

>>> <Graphviz Home>bindot -Tpdf dt.dot -o dt.pdf

我们的示例图相当大,所以在下图中您只能看到分支的一部分:

图片

如您所见,有两种类型的节点:

  • 非终端,它包含分割元组(作为特征 <= 阈值)和正杂质度量

  • 终端,其中杂质度量值为空且存在一个最终的目标类别

在这两种情况下,您都可以始终检查样本数量。这种类型的图在理解需要多少决策步骤非常有用。不幸的是,即使过程相当简单,数据集的结构可能导致非常复杂的树,而其他方法可以立即找到最合适的类别。当然,不是所有特征的重要性都相同。如果我们考虑树的根和第一个节点,我们会发现能够分离大量样本的特征;因此,它们的重要性必须高于所有终端节点的重要性,在终端节点中剩余的样本数量最少。在 scikit-learn 中,在训练模型后可以评估每个特征的 Gini 重要性:

>>> dt.feature_importances_
array([ 0.12066952,  0.12532507,  0.0577379 ,  0.14402762,  0.14382398,
 0.12418921,  0.14638565,  0.13784106])

>>> np.argsort(dt.feature_importances_)
array([2, 0, 5, 1, 7, 4, 3, 6], dtype=int64)

下图显示了重要性的绘图:

图片

最重要的特征是 6、3、4 和 7,而例如特征 2 将非常少的样本分开,可以认为对于分类任务来说是非信息的。

在效率方面,也可以使用max_depth参数对树进行剪枝;然而,理解哪个值是最好的并不总是那么简单(网格搜索可以帮助完成这项任务)。另一方面,决定在每个分割点考虑的最大特征数更容易。可以使用max_features参数来完成这个目的:

  • 如果是一个数字,该值将在每个分割时直接考虑

  • 如果是 'auto''sqrt',将采用特征数量的平方根

  • 如果是 'log2',将使用以 2 为底的对数

  • 如果是 'None',将使用所有特征(这是默认值)

通常,当总特征数量不是太高时,默认值是最好的选择,尽管当太多特征可能相互干扰时,引入小的压缩(通过 sqrtlog2)是有用的。另一个有助于控制性能和效率的参数是 min_samples_split,它指定了考虑分割的最小样本数。以下是一些示例:

>>> cross_val_score(DecisionTreeClassifier(), X, Y, scoring='accuracy', cv=10).mean()
0.77308070807080698

>>> cross_val_score(DecisionTreeClassifier(max_features='auto'), X, Y, scoring='accuracy', cv=10).mean()
0.76410071007100711

>>> cross_val_score(DecisionTreeClassifier(min_samples_split=100), X, Y, scoring='accuracy', cv=10).mean()
0.72999969996999692

如前所述,找到最佳参数通常是一项困难的任务,而执行它的最佳方式是在包括所有可能影响准确性的值的同时进行网格搜索。

在前一个集合上使用逻辑回归(仅用于比较),我们得到:

from sklearn.linear_model import LogisticRegression

>>> lr = LogisticRegression()
>>> cross_val_score(lr, X, Y, scoring='accuracy', cv=10).mean()
0.9053368347338937

因此,正如预期的那样,得分更高。然而,原始数据集相当简单,基于每个类别只有一个簇的概念。这允许更简单、更精确的线性分离。如果我们考虑一个具有更多变量和更复杂结构(线性分类器难以捕捉)的略微不同的场景,我们可以比较线性回归和决策树的 ROC 曲线:

>>> nb_samples = 1000
>>> X, Y = make_classification(n_samples=nb_samples, n_features=8, n_informative=6, n_redundant=2,     n_classes=2, n_clusters_per_class=4)

结果的 ROC 曲线显示在下图中:

图片

在 MNIST 数字数据集上使用最常见的参数进行网格搜索,我们可以得到:

from sklearn.model_selection import GridSearchCV

param_grid = [
 { 
 'criterion': ['gini', 'entropy'],
 'max_features': ['auto', 'log2', None],
 'min_samples_split': [ 2, 10, 25, 100, 200 ],
 'max_depth': [5, 10, 15, None]
 }
]

>>> gs = GridSearchCV(estimator=DecisionTreeClassifier(), param_grid=param_grid,
 scoring='accuracy', cv=10, n_jobs=multiprocessing.cpu_count())

>>> gs.fit(digits.data, digits.target)
GridSearchCV(cv=10, error_score='raise',
 estimator=DecisionTreeClassifier(class_weight=None, criterion='gini', max_depth=None,
 max_features=None, max_leaf_nodes=None,
 min_impurity_split=1e-07, min_samples_leaf=1,
 min_samples_split=2, min_weight_fraction_leaf=0.0,
 presort=False, random_state=None, splitter='best'),
 fit_params={}, iid=True, n_jobs=8,
 param_grid=[{'max_features': ['auto', 'log2', None], 'min_samples_split': [2, 10, 25, 100, 200], 'criterion': ['gini', 'entropy'], 'max_depth': [5, 10, 15, None]}],
 pre_dispatch='2*n_jobs', refit=True, return_train_score=True,
 scoring='accuracy', verbose=0)

>>> gs.best_estimator_
DecisionTreeClassifier(class_weight=None, criterion='entropy', max_depth=None,
 max_features=None, max_leaf_nodes=None,
 min_impurity_split=1e-07, min_samples_leaf=1,
 min_samples_split=2, min_weight_fraction_leaf=0.0,
 presort=False, random_state=None, splitter='best')

>>> gs.best_score_
0.8380634390651085

在这种情况下,影响准确率最大的因素是考虑分割的最小样本数。考虑到这个数据集的结构和需要有许多分支来捕捉甚至微小的变化,这是合理的。

集成学习

到目前为止,我们已经在单个实例上训练模型,通过迭代算法来最小化目标损失函数。这种方法基于所谓的强学习器,或通过寻找最佳可能解决方案来优化解决特定问题的方法。另一种方法是基于一组弱学习器,这些学习器可以并行或顺序(对参数进行轻微修改)训练,并基于多数投票或结果平均作为集成使用。这些方法可以分为两大类:

  • Bagged(或 Bootstrap)树:在这种情况下,集成是完整构建的。训练过程基于随机选择的分割,预测基于多数投票。随机森林是 Bagged 树集成的一个例子。

  • Boosted 树:集成是按顺序构建的,专注于先前被错误分类的样本。Boosted 树的例子包括 AdaBoost 和梯度提升树。

随机森林

随机森林是一组基于随机样本构建的决策树,其分割节点的策略不同:在这种模型中,不是寻找最佳选择,而是使用随机特征子集(对于每棵树),试图找到最佳的数据分割阈值。因此,将训练出许多以较弱方式训练的树,并且每棵树都会产生不同的预测。

解释这些结果有两种方式;更常见的方法是基于多数投票(得票最多的类别将被认为是正确的)。然而,scikit-learn 实现了一个基于平均结果的算法,这产生了非常准确的预测。即使它们在理论上不同,训练好的随机森林的概率平均也不可能与多数预测相差很大(否则,应该有不同的稳定点);因此,这两种方法通常会导致可比的结果。

例如,让我们考虑由不同数量的树组成的随机森林 MNIST 数据集:

from sklearn.ensemble import RandomForestClassifier
>>> nb_classifications = 100
>>> accuracy = []

>>> for i in range(1, nb_classifications):
 a = cross_val_score(RandomForestClassifier(n_estimators=i), digits.data, digits.target,  scoring='accuracy', cv=10).mean()
 rf_accuracy.append(a)

下图显示了生成的图表:

图片

如预期,当树的数量低于最小阈值时,准确性较低;然而,当树的数量少于 10 棵时,它开始迅速增加。在 20 到 30 棵树之间可以获得最佳结果(95%),这比单棵决策树要高。当树的数量较少时,模型的方差非常高,平均过程会产生许多错误的结果;然而,增加树的数量可以减少方差,并使模型收敛到一个非常稳定的解。scikit-learn 还提供了一个方差,它增强了选择最佳阈值时的随机性。使用ExtraTreesClassifier类,可以实现一个随机计算阈值并选择最佳值的模型。正如官方文档中讨论的那样,这使我们能够进一步减少方差:

from sklearn.ensemble import ExtraTreesClassifier
>>> nb_classifications = 100 
>>> for i in range(1, nb_classifications):
 a = cross_val_score(ExtraTreesClassifier(n_estimators=i), digits.data, digits.target,  scoring='accuracy', cv=10).mean()
 et_accuracy.append(a)

在准确性方面,具有相同树数量的结果略好,如下图所示:

图片

随机森林中的特征重要性

我们之前介绍的特征重要性概念也可以应用于随机森林,通过计算森林中所有树的平均值:

图片

我们可以很容易地使用包含 50 个特征和 20 个非信息元素的虚拟数据集来测试重要性评估:

>>> nb_samples = 1000
>>> X, Y = make_classification(n_samples=nb_samples, n_features=50, n_informative=30, n_redundant=20, n_classes=2, n_clusters_per_class=5)

下图展示了由 20 棵树组成的随机森林计算出的前 50 个特征的重要性:

图片

如预期的那样,有几个非常重要的特征,一个中等重要性的特征块,以及一个包含对预测影响相当低的特征的尾部。这种类型的图表在分析阶段也很有用,可以帮助更好地理解决策过程是如何构建的。对于多维数据集,理解每个因素的影响相当困难,有时许多重要的商业决策在没有完全意识到它们潜在影响的情况下就被做出了。使用决策树或随机森林,可以评估所有特征的“真实”重要性,并排除所有低于固定阈值的元素。这样,复杂的决策过程就可以简化,同时部分去噪。

AdaBoost

另一种技术被称为AdaBoost(即自适应提升),其工作方式与许多其他分类器略有不同。其背后的基本结构可以是决策树,但用于训练的数据集会持续适应,迫使模型专注于那些被错误分类的样本。此外,分类器是按顺序添加的,因此新的一个通过提高那些它不如预期准确的地方的性能来增强前一个。

在每次迭代中,都会对每个样本应用一个权重因子,以增加错误预测样本的重要性并降低其他样本的重要性。换句话说,模型会反复增强,从一个非常弱的学习者开始,直到达到最大的n_estimators数量。在这种情况下,预测总是通过多数投票获得。

在 scikit-learn 实现中,还有一个名为learning_rate的参数,它衡量每个分类器的影响。默认值是 1.0,因此所有估计器都被认为是同等重要的。然而,正如我们从 MNIST 数据集中看到的那样,降低这个值是有用的,这样每个贡献都会减弱:

from sklearn.ensemble import AdaBoostClassifier

>>> accuracy = []

>>> nb_classifications = 100

>>> for i in range(1, nb_classifications):
 a = cross_val_score(AdaBoostClassifier(n_estimators=i, learning_rate=0.1), digits.data, digits.target, scoring='accuracy', cv=10).mean()
>>> ab_accuracy.append(a)

结果显示在下图中:

图片

准确率不如前面的例子高;然而,可以看到当提升添加大约 20-30 棵树时,它达到了一个稳定值。对learning_rate进行网格搜索可以让你找到最佳值;然而,在这种情况下,顺序方法并不理想。一个经典的随机森林,从第一次迭代开始就使用固定数量的树,表现更好。这很可能是由于 AdaBoost 采用的策略;在这个集合中,增加正确分类样本的权重并降低错误分类的强度可能会在损失函数中产生振荡,最终结果不是最优的最小点。用 Iris 数据集(结构上要简单得多)重复实验可以得到更好的结果:

from sklearn.datasets import load_iris

>>> iris = load_iris()

>>> ada = AdaBoostClassifier(n_estimators=100, learning_rate=1.0)
>>> cross_val_score(ada, iris.data, iris.target, scoring='accuracy', cv=10).mean()
0.94666666666666666

在这种情况下,学习率为 1.0 是最好的选择,很容易理解提升过程可以在几次迭代后停止。在下图中,你可以看到显示此数据集准确率的图表:

图片

经过大约 10 次迭代后,准确率变得稳定(残差振荡可以被忽略),达到与这个数据集兼容的值。使用 AdaBoost 的优势在于资源利用;它不与一组完全配置好的分类器和整个样本集一起工作。因此,在大型数据集上训练时,它可以帮助节省时间。

梯度树提升

梯度树提升是一种技术,允许你逐步构建一个树集成,目标是最小化目标损失函数。集成的一般输出可以表示为:

图片

这里,fi是一个表示弱学习者的函数。该算法基于在每个步骤添加一个新的决策树的概念,以使用最速下降法(参见en.wikipedia.org/wiki/Method_of_steepest_descent,获取更多信息)来最小化全局损失函数:

图片

在引入梯度后,前面的表达式变为:

图片

scikit-learn 实现了GradientBoostingClassifier类,支持两种分类损失函数:

  • 二项式/多项式负对数似然(这是默认选择)

  • 指数(例如 AdaBoost)

让我们使用一个由 500 个样本组成、具有四个特征(三个信息性和一个冗余)和三个类别的更复杂的虚拟数据集来评估此方法的准确率:

from sklearn.datasets import make_classification

>>> nb_samples = 500

>>> X, Y = make_classification(n_samples=nb_samples, n_features=4, n_informative=3, n_redundant=1, n_classes=3)

现在,我们可以收集一定范围内(1, 50)的多个估计器的交叉验证平均准确率。损失函数是默认的(多项式负对数似然):

from sklearn.ensemble import GradientBoostingClassifier
from sklearn.model_selection import cross_val_score

>>> a = []
>>> max_estimators = 50

>>> for i in range(1, max_estimators):
>>> score = cross_val_score(GradientBoostingClassifier(n_estimators=i, learning_rate=10.0/float(i)), X, Y, cv=10, scoring='accuracy').mean()
>>> a.append(score)

在增加估计器数量(参数n_estimators)时,重要的是要降低学习率(参数learning_rate)。最佳值难以预测;因此,进行网格搜索通常很有用。在我们的例子中,我一开始设置了非常高的学习率(5.0),当估计器数量达到 100 时,收敛到 0.05。这并不是一个完美的选择(在大多数实际情况下都是不可接受的!),这样做只是为了展示不同的准确率性能。结果如下所示:

图片

如我们所见,最佳估计器数量约为 50,学习率为 0.1。读者可以尝试不同的组合,并比较此算法与其他集成方法的性能。

投票分类器

VotingClassifier提供了一个非常有趣的集成解决方案,它不是一个实际的分类器,而是一组不同分类器的包装,这些分类器是并行训练和评估的。预测的最终决策是根据两种不同的策略通过多数投票来确定的:

  • 硬投票:在这种情况下,获得最多投票的类别,即Nc,将被选择:

图片

  • 软投票:在这种情况下,每个预测类(对于所有分类器)的概率向量被相加并平均。获胜的类别是对应最高值的类别:

图片

让我们考虑一个虚拟数据集,并使用硬投票策略计算准确率:

from sklearn.datasets import make_classification

>>> nb_samples = 500

>>> X, Y = make_classification(n_samples=nb_samples, n_features=2, n_redundant=0, n_classes=2)

对于我们的示例,我们将考虑三个分类器:逻辑回归、决策树(默认使用 Gini 不纯度),以及一个 SVM(使用多项式核,并将probability=True设置为生成概率向量)。这个选择仅出于教学目的,可能不是最佳选择。在创建集成时,考虑每个涉及分类器的不同特征并避免“重复”算法(例如,逻辑回归和线性 SVM 或感知器可能会产生非常相似的性能)是有用的。在许多情况下,将非线性分类器与随机森林或 AdaBoost 分类器混合可能很有用。读者可以用其他组合重复此实验,比较每个单一估计器的性能和投票分类器的准确率:

from sklearn.linear_model import LogisticRegression
from sklearn.svm import SVC
from sklearn.tree import DecisionTreeClassifier
from sklearn.ensemble import VotingClassifier

>>> lr = LogisticRegression()
>>> svc = SVC(kernel='poly', probability=True)
>>> dt = DecisionTreeClassifier()

>>> classifiers = [('lr', lr),
 ('dt', dt),
 ('svc', svc)]

>>> vc = VotingClassifier(estimators=classifiers, voting='hard')

计算交叉验证准确率,我们得到:

from sklearn.model_selection import cross_val_score

>>> a = []

>>> a.append(cross_val_score(lr, X, Y, scoring='accuracy', cv=10).mean())
>>> a.append(cross_val_score(dt, X, Y, scoring='accuracy', cv=10).mean())
>>> a.append(cross_val_score(svc, X, Y, scoring='accuracy', cv=10).mean())
>>> a.append(cross_val_score(vc, X, Y, scoring='accuracy', cv=10).mean())

>>> print(np.array(a))
[ 0.90182873  0.84990876  0.87386955  0.89982873] 

每个单一分类器和集成方法的准确率在以下图中展示:

图片

如预期,集成方法利用了不同的算法,其性能优于任何单一算法。现在我们可以用软投票重复实验,考虑到也可以通过参数weights引入权重向量,以给予每个分类器更多或更少的重视:

图片

例如,考虑前面的图,我们可以决定给予逻辑回归更多的重视,而给予决策树和 SVM 较少的重视:

>>> weights = [1.5, 0.5, 0.75]

>>> vc = VotingClassifier(estimators=classifiers, weights=weights, voting='soft')

重复相同的计算用于交叉验证准确率,我们得到:

>>> print(np.array(a))
[ 0.90182873  0.85386795  0.87386955  0.89578952]

结果图如下所示:

图片

权重分配不仅限于软策略。它也可以应用于硬投票,但在此情况下,它将用于过滤(减少或增加)实际发生次数的数量。

图片

在这里,Nc是每个目标类别的投票数,每个投票数都乘以相应的分类器权重因子。

当单一策略无法达到所需的准确度阈值时,投票分类器可以是一个不错的选择;在利用不同的方法的同时,仅使用一小组强大(但有时有限)的学习器就可以捕捉到许多微观趋势。

参考文献

Louppe G.,Wehenkel L.,Sutera A.,和 Geurts P.,在随机树森林中理解变量重要性,NIPS 进程 2013。

摘要

在本章中,我们介绍了决策树作为一种特定的分类器。其概念背后的基本思想是,通过使用分裂节点,决策过程可以变成一个顺序过程,其中根据样本,选择一个分支,直到我们达到最终的叶子节点。为了构建这样的树,引入了不纯度的概念;从完整的数据集开始,我们的目标是找到一个分割点,创建两个具有最小特征数且在过程结束时应与单个目标类别相关联的独立集合。树的复杂性取决于内在的不纯度——换句话说,当总是容易确定一个最佳分离集合的特征时,深度会较低。然而,在许多情况下,这几乎是不可行的,因此生成的树需要许多中间节点来减少不纯度,直到达到最终的叶子节点。

我们还讨论了一些集成学习方法:随机森林、AdaBoost、梯度树提升和投票分类器。它们都基于训练多个弱学习器并使用多数投票或平均来评估其预测的想法。然而,虽然随机森林创建了一组部分随机训练的决策树,AdaBoost 和梯度提升树则采用逐步添加新模型的技术,并专注于那些先前被错误分类的样本,或者专注于最小化特定的损失函数。相反,投票分类器允许混合不同的分类器,在预测期间采用多数投票来决定哪个类别必须被视为获胜者。

在下一章中,我们将介绍第一种无监督学习方法,k-means,这是最广泛使用的聚类算法之一。我们将集中讨论其优势和劣势,并探索 scikit-learn 提供的某些替代方案。

第九章:聚类基础

在本章中,我们将介绍聚类的基概念和 k-means 的结构,这是一个相当常见的算法,可以有效地解决许多问题。然而,它的假设非常强烈,特别是关于簇的凸性,这可能导致其在应用中存在一些局限性。我们将讨论其数学基础及其优化方法。此外,我们将分析两种在 k-means 无法对数据集进行聚类时可以采用的替代方案。这些替代方案是 DBSCAN(通过考虑样本密度的差异来工作)和基于点之间亲和力的非常强大的方法——谱聚类。

聚类基础

让我们考虑一个点集数据集:

图片

我们假设可以找到一个标准(不是唯一的)以便每个样本都能与一个特定的组相关联:

图片

传统上,每个组被称为,寻找函数 G 的过程称为聚类。目前,我们没有对簇施加任何限制;然而,由于我们的方法是未监督的,应该有一个相似性标准来连接某些元素并分离其他元素。不同的聚类算法基于不同的策略来解决这个问题,并可能产生非常不同的结果。在下图中,有一个基于四组二维样本的聚类示例;将一个点分配给簇的决定仅取决于其特征,有时还取决于一组其他点的位置(邻域):

图片

在这本书中,我们将讨论硬聚类技术,其中每个元素必须属于单个簇。另一种方法称为软聚类(或模糊聚类),它基于一个成员分数,该分数定义了元素与每个簇“兼容”的程度。通用的聚类函数变为:

图片

向量 m[i] 代表 x[i] 的相对成员资格,通常将其归一化为概率分布。

K-means

k-means 算法基于(强烈的)初始条件,通过分配 k 个初始质心均值来决定簇的数量:

图片

然后计算每个样本与每个质心之间的距离,并将样本分配到距离最小的簇。这种方法通常被称为最小化簇的惯性,其定义如下:

图片

该过程是迭代的——一旦所有样本都已被处理,就会计算一个新的质心集 K^((1))(现在考虑属于聚类的实际元素),并且重新计算所有距离。算法在达到所需的容差时停止,换句话说,当质心变得稳定,因此惯性最小化时停止。

当然,这种方法对初始条件非常敏感,已经研究了某些方法来提高收敛速度。其中之一被称为k-means++(Karteeka Pavan K.,Allam Appa Rao,Dattatreya Rao A. V.,和 Sridhar G.R.,《K-Means 类型算法的鲁棒种子选择算法》,国际计算机科学和信息技术杂志 3,第 5 期,2011 年 10 月 30 日),该方法选择初始质心,使其在统计上接近最终质心。数学解释相当困难;然而,这种方法是 scikit-learn 的默认选择,并且通常对于任何可以用此算法解决的聚类问题来说都是最佳选择。

让我们考虑一个简单的示例,使用一个虚拟数据集:

from sklearn.datasets import make_blobs

nb_samples = 1000
X, _ = make_blobs(n_samples=nb_samples, n_features=2, centers=3, cluster_std=1.5)

我们期望有三个具有二维特征的聚类,由于每个团块的方差,它们之间存在部分重叠。在我们的例子中,我们不会使用Y变量(它包含预期的聚类),因为我们只想生成一组局部一致的点来尝试我们的算法。

结果图示如下所示:

图片

在这种情况下,问题非常简单,所以我们期望 k-means 在X的[-5, 0]区间内以最小误差将三个组分开。保持默认值,我们得到:

from sklearn.cluster import KMeans

>>> km = KMeans(n_clusters=3)
>>> km.fit(X)
KMeans(algorithm='auto', copy_x=True, init='k-means++', max_iter=300,
 n_clusters=3, n_init=10, n_jobs=1, precompute_distances='auto',
 random_state=None, tol=0.0001, verbose=0)

>>> print(km.cluster_centers_)
[[ 1.39014517,  1.38533993]
 [ 9.78473454,  6.1946332 ]
 [-5.47807472,  3.73913652]]

使用三种不同的标记重新绘制数据,可以验证 k-means 如何成功地将数据分离:

图片

在这种情况下,分离非常容易,因为 k-means 基于欧几里得距离,它是径向的,因此预期聚类将是凸集。当这种情况不发生时,无法使用此算法解决问题。大多数时候,即使凸性没有得到完全保证,k-means 也能产生良好的结果,但有一些情况下预期的聚类是不可能的,让 k-means 找到质心可能会导致完全错误的结果。

让我们考虑同心圆的情况。scikit-learn 提供了一个内置函数来生成这样的数据集:

from sklearn.datasets import make_circles

>>> nb_samples = 1000
>>> X, Y = make_circles(n_samples=nb_samples, noise=0.05)

该数据集的图示如下所示:

图片

我们希望有一个内部聚类(对应于用三角形标记表示的样本)和一个外部聚类(用点表示)。然而,这样的集合不是凸集,k-means 无法正确地将它们分离(均值应该是相同的!)。实际上,假设我们尝试将算法应用于两个聚类:

>>> km = KMeans(n_clusters=2)
>>> km.fit(X)
KMeans(algorithm='auto', copy_x=True, init='k-means++', max_iter=300,
 n_clusters=2, n_init=10, n_jobs=1, precompute_distances='auto',
 random_state=None, tol=0.0001, verbose=0)

我们得到以下图所示的分离:

图片

如预期,k-means 收敛到两个半圆中间的两个质心,并且得到的聚类结果与我们预期的完全不同。此外,如果必须根据与公共中心的距离来考虑样本的不同,这个结果将导致完全错误的预测。显然,必须采用另一种方法。

寻找最佳聚类数量

k-means 最常见的一个缺点与选择最佳聚类数量有关。过小的值将确定包含异质元素的大分组,而较大的值可能导致难以识别聚类之间差异的场景。因此,我们将讨论一些可以用来确定适当分割数量和评估相应性能的方法。

优化惯性

第一种方法基于这样的假设:适当的聚类数量必须产生小的惯性。然而,当聚类数量等于样本数量时,这个值达到最小(0.0);因此,我们不能寻找最小值,而是寻找一个在惯性和聚类数量之间权衡的值。

假设我们有一个包含 1,000 个元素的数据库。我们可以计算并收集不同数量聚类下的惯性(scikit-learn 将这些值存储在实例变量inertia_中):

>>> nb_clusters = [2, 3, 5, 6, 7, 8, 9, 10]

>>> inertias = []

>>> for n in nb_clusters:
>>>    km = KMeans(n_clusters=n)
>>>    km.fit(X)
>>>    inertias.append(km.inertia_)

绘制值,我们得到以下图所示的结果:

图片

如您所见,2 和 3 之间有一个戏剧性的减少,然后斜率开始变平。我们希望找到一个值,如果减少,会导致惯性大幅增加,如果增加,会产生非常小的惯性减少。因此,一个好的选择可能是 4 或 5,而更大的值可能会产生不希望的聚类内分割(直到极端情况,每个点成为一个单独的聚类)。这种方法非常简单,可以用作确定潜在范围的第一个方法。接下来的策略更复杂,可以用来找到最终的聚类数量。

形状系数

形状系数基于“最大内部凝聚力和最大聚类分离”的原则。换句话说,我们希望找到产生数据集细分,形成彼此分离的密集块的数量。这样,每个聚类将包含非常相似的元素,并且选择属于不同聚类的两个元素,它们的距离应该大于最大聚类内距离。

在定义距离度量(欧几里得通常是不错的选择)之后,我们可以计算每个元素的聚类内平均距离:

图片

我们还可以定义平均最近簇距离(这对应于最低的簇间距离):

图片

元素x[i]的轮廓得分定义为:

图片

这个值介于-1 和 1 之间,其解释如下:

  • 一个接近 1 的值是好的(1 是最佳条件),因为这表示a(x[i]) << b(x[i])

  • 接近 0 的值表示簇内和簇间测量的差异几乎为零,因此存在簇重叠。

  • 接近-1 的值表示样本被分配到了错误的聚类,因为a(x[i]) >> b(x[i])

scikit-learn 允许计算平均轮廓得分,以便对不同数量的聚类有一个立即的概览:

from sklearn.metrics import silhouette_score

>>> nb_clusters = [2, 3, 5, 6, 7, 8, 9, 10]

>>> avg_silhouettes = []

>>> for n in nb_clusters:
>>>    km = KMeans(n_clusters=n)
>>>    Y = km.fit_predict(X)
>>>    avg_silhouettes.append(silhouette_score(X, Y))

对应的图表如下所示:

图片

最佳值是 3(非常接近 1.0),然而,考虑到前面的方法,4 个聚类提供了更小的惯性,同时轮廓得分也合理。因此,选择 4 而不是 3 可能是一个更好的选择。然而,3 和 4 之间的决定并不立即,应该通过考虑数据集的性质来评估。轮廓得分表明存在 3 个密集的聚簇,但惯性图表明其中至少有一个可以可能分成两个簇。为了更好地理解聚类是如何工作的,还可以绘制轮廓图,显示所有簇中每个样本的排序得分。在以下代码片段中,我们为 2、3、4 和 8 个簇创建图表:

from sklearn.metrics import silhouette_samples

>>> fig, ax = subplots(2, 2, figsize=(15, 10))

>>> nb_clusters = [2, 3, 4, 8]
>>> mapping = [(0, 0), (0, 1), (1, 0), (1, 1)]

>>> for i, n in enumerate(nb_clusters):
>>>    km = KMeans(n_clusters=n)
>>>    Y = km.fit_predict(X)

>>>    silhouette_values = silhouette_samples(X, Y)

>>>    ax[mapping[i]].set_xticks([-0.15, 0.0, 0.25, 0.5, 0.75, 1.0])
>>>    ax[mapping[i]].set_yticks([])
>>>    ax[mapping[i]].set_title('%d clusters' % n)
>>>    ax[mapping[i]].set_xlim([-0.15, 1])
>>>    ax[mapping[i]].grid()
>>>    y_lower = 20

>>>    for t in range(n):
>>>        ct_values = silhouette_values[Y == t]
>>>        ct_values.sort()

>>>        y_upper = y_lower + ct_values.shape[0]

>>>        color = cm.Accent(float(t) / n)
>>>        ax[mapping[i]].fill_betweenx(np.arange(y_lower, y_upper), 0, 
>>>                                     ct_values, facecolor=color, edgecolor=color)

>>>        y_lower = y_upper + 20

每个样本的轮廓系数是通过函数 silhouette_values(这些值始终介于-1 和 1 之间)计算的。在这种情况下,我们将图表限制在-0.15 和 1 之间,因为没有更小的值。然而,在限制之前检查整个范围是很重要的。

结果图表如下所示:

图片

每个轮廓的宽度与属于特定聚类的样本数量成正比,其形状由每个样本的得分决定。理想的图表应包含均匀且长的轮廓,没有峰值(它们必须类似于梯形而不是三角形),因为我们期望同一聚类中的样本得分方差非常低。对于两个聚类,形状是可以接受的,但一个聚类的平均得分为 0.5,而另一个的值大于 0.75;因此,第一个聚类的内部一致性较低。在对应于 8 个聚类的图表中,展示了完全不同的情况。所有轮廓都是三角形的,其最大得分略大于 0.5。这意味着所有聚类在内部是一致的,但分离度不可接受。对于三个聚类,图表几乎是完美的,除了第二个轮廓的宽度。如果没有其他指标,我们可以考虑这个数字是最好的选择(也由平均得分证实),但聚类的数量越多,惯性越低。对于四个聚类,图表略差,有两个轮廓的最大得分约为 0.5。这意味着两个聚类完美一致且分离,而剩下的两个则相对一致,但它们可能没有很好地分离。目前,我们应在 3 和 4 之间做出选择。接下来,我们将介绍其他方法,以消除所有疑虑。

卡尔金斯-哈拉巴斯指数

另一种基于密集和分离良好聚类概念的方法是卡尔金斯-哈拉巴斯指数。要构建它,我们首先需要定义簇间分散度。如果我们有 k 个聚类及其相对质心和全局质心,簇间分散度(BCD)定义为:

图片

在上述表达式中,n[k] 是属于聚类 k 的元素数量,mu(公式中的希腊字母)是全局质心,而 mu[i] 是聚类 i 的质心。簇内分散度(WCD)定义为:

图片

卡尔金斯-哈拉巴斯指数定义为 BCD(k)WCD(k) 之间的比率:

图片

我们在寻找低簇内分散度(密集的聚团)和高簇间分散度(分离良好的聚团),需要找到最大化此指数的聚类数量。我们可以以类似于我们之前为轮廓得分所做的方式获得一个图表:

from sklearn.metrics import calinski_harabaz_score

>>> nb_clusters = [2, 3, 5, 6, 7, 8, 9, 10]

>>> ch_scores = []

>>> km = KMeans(n_clusters=n)
>>> Y = km.fit_predict(X)

>>> for n in nb_clusters:
>>>    km = KMeans(n_clusters=n)
>>>    Y = km.fit_predict(X)
>>>    ch_scores.append(calinski_harabaz_score(X, Y))

结果图表如下所示:

图片

如预期的那样,最高值(5,500)是在三个聚类时获得的,而四个聚类得到的值略低于 5,000。仅考虑这种方法,没有疑问,最佳选择是 3,即使 4 也是一个合理的值。让我们考虑最后一种方法,它评估整体稳定性。

聚类不稳定性

另一种方法基于在 Von Luxburg U. 的文章《Cluster stability: an overview》中定义的簇不稳定性概念,arXiv 1007:1075v1,2010 年 7 月 7 日。直观地说,我们可以认为,如果一个聚类方法在扰动相同数据集的版本中产生非常相似的结果,那么这个聚类方法是稳定的。更正式地说,如果我们有一个数据集 X,我们可以定义一组 m 扰动(或噪声)版本:

图片

考虑两个具有相同簇数(k)的聚类之间的距离度量 d(C(X[1]), C(X[2])),不稳定性定义为噪声版本聚类对之间的平均距离:

图片

对于我们的目的,我们需要找到使 I(C) 最小化的 k 值(因此最大化稳定性)。首先,我们需要生成一些数据集的噪声版本。假设 X 包含 1,000 个二维样本,标准差为 10.0。我们可以通过添加一个均匀随机值(范围在 [-2.0, 2.0] 内)以 0.25 的概率扰动 X

>>> nb_noisy_datasets = 4

>>> X_noise = []

>>> for _ in range(nb_noisy_datasets):
>>>    Xn = np.ndarray(shape=(1000, 2))
>>>    for i, x in enumerate(X):
>>>        if np.random.uniform(0, 1) < 0.25:
>>>            Xn[i] = X[i] + np.random.uniform(-2.0, 2.0)
>>>        else:
>>>            Xn[i] = X[i]
>>>    X_noise.append(Xn)

在这里,我们假设有四个扰动版本。作为一个度量标准,我们采用汉明距离,该距离与不同意的输出元素数量成比例(如果归一化)。在这个阶段,我们可以计算不同簇数量下的不稳定性:

from sklearn.metrics.pairwise import pairwise_distances

>>> instabilities = []

>>> for n in nb_clusters:
>>>    Yn = []
>>> 
>>>    for Xn in X_noise:
>>>        km = KMeans(n_clusters=n)
>>>        Yn.append(km.fit_predict(Xn))

>>> distances = []

>>> for i in range(len(Yn)-1):
>>>        for j in range(i, len(Yn)):
>>>            d = pairwise_distances(Yn[i].reshape(-1, 1), Yn[j].reshape(-1, -1), 'hamming')
>>>            distances.append(d[0, 0])

>>>    instability = (2.0 * np.sum(distances)) / float(nb_noisy_datasets ** 2)
>>>    instabilities.append(instability)

由于距离是对称的,我们只计算矩阵的上三角部分。结果如下所示:

图片

排除具有 2 个簇的配置,其中惯性非常高,我们有 3 个簇的最小值,这个值已经被前三种方法所确认。因此,我们最终可以决定将 n_clusters 设置为 3,排除 4 个或更多簇的选项。这种方法非常强大,但重要的是要用合理的噪声数据集数量来评估稳定性,注意不要过度改变原始几何形状。一个好的选择是使用高斯噪声,方差设置为数据集方差的分数(例如 1/10)。其他方法在 Von Luxburg U. 的文章《Cluster stability: an overview》中有所介绍,arXiv 1007:1075v1,2010 年 7 月 7 日。

即使我们已经用 k-means 展示了这些方法,它们也可以应用于任何聚类算法来评估性能并比较它们。

DBSCAN

DBSCAN 或 基于密度的空间聚类应用噪声 是一种强大的算法,可以轻松解决 k-means 无法解决的非凸问题。其思想很简单:簇是一个高密度区域(对其形状没有限制),周围被低密度区域包围。这个陈述通常是正确的,并且不需要对预期簇的数量进行初始声明。该过程从分析一个小区域(形式上,一个由最小数量的其他样本包围的点)开始。如果密度足够,它被认为是簇的一部分。此时,考虑邻居。如果它们也有高密度,它们将与第一个区域合并;否则,它们将确定拓扑分离。当扫描完所有区域后,簇也已经确定,因为它们是被空空间包围的岛屿。

scikit-learn 允许我们通过两个参数来控制此过程:

  • eps: 负责定义两个邻居之间的最大距离。值越高,聚合的点越多,而值越小,创建的簇越多。

  • min_samples: 这决定了定义一个区域(也称为核心点)所需的周围点的数量。

让我们尝试一个非常困难的聚类问题,称为半月形。可以使用内置函数创建数据集:

from sklearn.datasets import make_moons

>>> nb_samples = 1000
>>> X, Y = make_moons(n_samples=nb_samples, noise=0.05)

数据集的图示如下所示:

图片

为了理解,k-means 将通过寻找最优凸性来进行聚类,结果如下所示:

图片

当然,这种分离是不可接受的,而且没有方法可以提高准确性。让我们尝试使用 DBSCAN(将 eps 设置为 0.1,min_samples 的默认值为 5):

from sklearn.cluster import DBSCAN

>>> dbs = DBSCAN(eps=0.1)
>>> Y = dbs.fit_predict(X)

与其他实现方式不同,DBSCAN 在训练过程中预测标签,因此我们已经有了一个包含每个样本分配的簇的数组 Y。在下图中,有两种不同的标记表示:

图片

如您所见,准确度非常高,只有三个孤立点被错误分类(在这种情况下,我们知道它们的类别,因此我们可以使用这个术语,即使它是一个聚类过程)。然而,通过执行网格搜索,很容易找到优化聚类过程的最佳值。调整这些参数非常重要,以避免两个常见问题:少数大簇和许多小簇。这个问题可以通过以下方法轻松避免。

谱聚类

谱聚类是一种基于对称亲和矩阵的更复杂的方法:

图片

在这里,每个元素a[ij]代表两个样本之间的亲和度度量。最常用的度量(也由 scikit-learn 支持)是径向基函数和最近邻。然而,如果核产生的度量具有距离的特征(非负、对称和递增),则可以使用任何核。

计算拉普拉斯矩阵并应用标准聚类算法到特征向量的子集(这个元素严格与每个单独的策略相关)。

scikit-learn 实现了 Shi-Malik 算法(Shi J., Malik J., Normalized Cuts and Image Segmentation, IEEE Transactions on Pattern Analysis and Machine Intelligence, Vol. 22, 08/2000),也称为 normalized-cuts,该算法将样本划分为两个集合(G[1]G[2],这些集合形式上是图,其中每个点是一个顶点,边由归一化拉普拉斯矩阵导出),使得属于簇内点的权重远高于属于分割的权重。完整的数学解释超出了本书的范围;然而,在Von Luxburg U., A Tutorial on Spectral Clustering, 2007中,你可以阅读关于许多替代谱方法的完整解释。

让我们考虑之前的半月形示例。在这种情况下,亲和度(就像 DBSCAN 一样)应该基于最近邻函数;然而,比较不同的核很有用。在第一个实验中,我们使用具有不同gamma参数值的 RBF 核:

from sklearn.cluster import SpectralClustering

>>> Yss = []
>>> gammas = np.linspace(0, 12, 4)

>>> for gamma in gammas:
 sc = SpectralClustering(n_clusters=2, affinity='rbf', gamma=gamma)
 Yss.append(sc.fit_predict(X))

在这个算法中,我们需要指定我们想要多少个簇,因此我们将值设置为 2。结果图如下所示:

图片

如您所见,当缩放因子 gamma 增加时,分离变得更加准确;然而,考虑到数据集,在任何搜索中都不需要使用最近邻核。

>>> sc = SpectralClustering(n_clusters=2, affinity='nearest_neighbors')
>>> Ys = sc.fit_predict(X)

结果图如下所示:

图片

对于许多基于核的方法,谱聚类需要先前的分析来检测哪个核可以提供亲和度矩阵的最佳值。scikit-learn 也允许我们为那些难以使用标准核解决的问题定义自定义核。

基于真实情况的评估方法

在本节中,我们介绍了一些需要了解真实情况的评估方法。由于聚类通常作为无监督方法应用,因此这种条件并不总是容易获得;然而,在某些情况下,训练集已经被手动(或自动)标记,在预测新样本的簇之前评估模型是有用的。

同质性

对于一个聚类算法(给定真实情况)的一个重要要求是,每个簇应只包含属于单个类别的样本。在第二章《机器学习中的重要元素》中,我们定义了熵 H(X) 和条件熵 H(X|Y) 的概念,这些概念衡量了在知道 Y 的情况下 X 的不确定性。因此,如果类集表示为 C,聚类集表示为 K,则 H(C|K) 是在聚类数据集后确定正确类别的不确定性的度量。为了得到同质性分数,有必要考虑类集的初始熵 H(C) 来归一化这个值:

图片

在 scikit-learn 中,有一个内置函数 homogeneity_score() 可以用来计算这个值。对于这个和接下来的几个例子,我们假设我们有一个标记的数据集 X(带有真实标签 Y):

from sklearn.metrics import homogeneity_score

>>> km = KMeans(n_clusters=4)
>>> Yp = km.fit_predict(X)
>>> print(homogeneity_score(Y, Yp))
0.806560739827

0.8 的值意味着大约有 20%的残余不确定性,因为一个或多个簇包含一些属于次要类别的点。与其他在上一节中展示的方法一样,可以使用同质性分数来确定最佳簇数量。

完整性

另一个互补的要求是,属于一个类别的每个样本都被分配到同一个簇中。这个度量可以通过条件熵 H(K|C) 来确定,这是在知道类别的情况下确定正确簇的不确定性。像同质性分数一样,我们需要使用熵 H(K) 来归一化这个值:

图片

我们可以使用函数 completeness_score()(在相同的数据集上)来计算这个分数:

from sklearn.metrics import completeness_score

>>> km = KMeans(n_clusters=4)
>>> Yp = km.fit_predict(X)
>>> print(completeness_score(Y, Yp))
0.807166746307

此外,在这种情况下,这个值相当高,这意味着大多数属于一个类别的样本已经被分配到同一个簇中。这个值可以通过不同的簇数量或改变算法来提高。

调整后的 rand 指数

调整后的 rand 指数衡量原始类划分(Y)和聚类之间的相似性。考虑到与前面评分中采用相同的符号,我们可以定义:

  • a:属于类集 C 和聚类集 K 中相同划分的元素对的数量

  • b:属于类集 C 和聚类集 K 中不同划分的元素对的数量

如果数据集中的样本总数为 n,则 rand 指数定义为:

图片

校正后的随机性版本是调整后的 rand 指数,其定义如下:

图片

我们可以使用函数 adjusted_rand_score() 来计算调整后的 rand 分数:

from sklearn.metrics import adjusted_rand_score

>>> km = KMeans(n_clusters=4)
>>> Yp = km.fit_predict(X)
>>> print(adjusted_rand_score(Y, Yp))
0.831103137285

由于调整后的兰德指数介于-1.0 和 1.0 之间,负值表示不良情况(分配高度不相关),0.83 的分数意味着聚类与真实情况非常相似。此外,在这种情况下,可以通过尝试不同的簇数量或聚类策略来优化这个值。

参考文献

  • Karteeka Pavan K., Allam Appa Rao, Dattatreya Rao A. V. 和 Sridhar G.R.,针对 k-means 类型算法的鲁棒种子选择算法,《International Journal of Computer Science and Information Technology》第 3 卷第 5 期(2011 年 10 月 30 日)

  • Shi J., Malik J., 归一化切割与图像分割,《IEEE Transactions on Pattern Analysis and Machine Intelligence》,第 22 卷(2000 年 8 月)

  • Von Luxburg U.,谱聚类教程,2007

  • Von Luxburg U.,簇稳定性:概述,arXiv 1007:1075v1,2010 年 7 月 7 日

摘要

在本章中,我们介绍了基于定义(随机或根据某些标准)k 个质心代表簇并优化它们的位置,使得每个簇中每个点到质心的平方距离之和最小的 k-means 算法。由于距离是一个径向函数,k-means 假设簇是凸形的,不能解决形状有深凹处的(如半月形问题)问题。

为了解决这类情况,我们提出了两种替代方案。第一个被称为 DBSCAN,它是一个简单的算法,分析被其他样本包围的点与边界样本之间的差异。这样,它可以很容易地确定高密度区域(成为簇)以及它们之间的低密度空间。对于簇的形状或数量没有假设,因此需要调整其他参数,以便生成正确的簇数量。

谱聚类是一类基于样本之间亲和度度量的算法。它们在由亲和度矩阵的拉普拉斯算子生成的子空间上使用经典方法(如 k-means)。这样,就可以利用许多核函数的力量来确定点之间的亲和度,而简单的距离无法正确分类。这种聚类对于图像分割特别有效,但也可以在其他方法无法正确分离数据集时成为一个好的选择。

在下一章中,我们将讨论另一种称为层次聚类的另一种方法。它允许我们通过分割和合并簇直到达到最终配置来分割数据。

第十章:层次聚类

在本章中,我们将讨论一种称为层次聚类的特定聚类技术。这种方法不是与整个数据集中的关系一起工作,而是从一个包含所有元素的单个实体(分裂)或 N 个分离元素(聚合)开始,然后根据某些特定的标准分裂或合并簇,我们将分析和比较这些标准。

层次化策略

层次聚类基于寻找部分簇的层次结构的一般概念,这些簇是通过自下而上或自上而下的方法构建的。更正式地说,它们被称为:

  • 聚合聚类:过程从底部开始(每个初始簇由一个元素组成)并通过合并簇进行,直到达到停止标准。一般来说,目标在过程结束时具有足够小的簇数量。

  • 分裂聚类:在这种情况下,初始状态是一个包含所有样本的单簇,过程通过分裂中间簇直到所有元素分离。在这个点上,过程继续使用基于元素之间差异的聚合标准。一个著名的(超出了本书范围)方法称为DIANA,由 Kaufman L.,Roussew P.J.,在数据中寻找群体:聚类分析导论,Wiley 描述。

scikit-learn 仅实现聚合聚类。然而,这并不是一个真正的限制,因为分裂聚类的复杂度更高,而聚合聚类的性能与分裂方法达到的性能相当。

聚合聚类

让我们考虑以下数据集:

图片

我们定义亲和力,这是一个具有相同维度 m 的两个参数的度量函数。最常见的度量(也由 scikit-learn 支持)是:

  • 欧几里得L2

图片

  • 曼哈顿(也称为城市街区)或 L1

图片

  • 余弦距离

图片

欧几里得距离通常是好的选择,但有时拥有一个与欧几里得距离差异逐渐增大的度量是有用的。曼哈顿度量具有这种特性;为了展示这一点,在下面的图中有一个表示属于直线 y = x 的点从原点到距离的图:

图片

余弦距离,相反,在我们需要两个向量之间角度成比例的距离时很有用。如果方向相同,距离为零,而当角度等于 180°(意味着相反方向)时,距离最大。这种距离可以在聚类必须不考虑每个点的L2范数时使用。例如,一个数据集可能包含具有不同尺度的二维点,我们需要将它们分组到对应于圆形扇区的聚类中。或者,我们可能对它们根据四个象限的位置感兴趣,因为我们已经为每个点分配了特定的含义(对点与原点之间的距离不变)。

一旦选择了度量(让我们简单地称之为d(x,y)),下一步是定义一个策略(称为连接)来聚合不同的聚类。有许多可能的方法,但 scikit-learn 支持三种最常见的方法:

  • 完全连接:对于每一对聚类,算法计算并合并它们,以最小化聚类之间的最大距离(换句话说,最远元素的距离):

图片

  • 平均连接:它与完全连接类似,但在这个情况下,算法使用聚类对之间的平均距离:

图片

  • Ward 的连接:在这个方法中,考虑所有聚类,算法计算聚类内的平方距离之和,并合并它们以最小化它。从统计学的角度来看,聚合过程导致每个结果聚类的方差减少。该度量是:

图片

  • Ward 的连接只支持欧几里得距离。

树状图

为了更好地理解聚合过程,引入一种称为树状图的图形方法很有用,它以静态方式显示聚合是如何进行的,从底部(所有样本都分离)到顶部(连接完全)。不幸的是,scikit-learn 不支持它们。然而,SciPy(它是其强制性要求)提供了一些有用的内置函数。

让我们从创建一个虚拟数据集开始:

from sklearn.datasets import make_blobs

>>> nb_samples = 25
>>> X, Y = make_blobs(n_samples=nb_samples, n_features=2, centers=3, cluster_std=1.5)

为了避免结果图过于复杂,样本数量已经保持得很低。在以下图中,有数据集的表示:

图片

现在我们可以计算树状图。第一步是计算距离矩阵:

from scipy.spatial.distance import pdist

>>> Xdist = pdist(X, metric='euclidean')

我们选择了一个欧几里得度量,这在当前情况下是最合适的。此时,必须决定我们想要哪种连接。让我们选择 Ward;然而,所有已知的方法都是支持的:

from scipy.cluster.hierarchy import linkage

>>> Xl = linkage(Xdist, method='ward')

现在,我们可以创建并可视化树状图:

from scipy.cluster.hierarchy import dendrogram

>>> Xd = dendrogram(Xl)

结果图示如下截图:

图片

x轴上,有样本(按顺序编号),而y轴表示距离。每个弧连接两个由算法合并的簇。例如,23 和 24 是合并在一起的单个元素。然后元素 13 被聚合到结果簇中,这个过程继续进行。

如您所见,如果我们决定在距离 10 处切割图,我们将得到两个独立的簇:第一个簇从 15 到 24,另一个簇从 0 到 20。查看之前的数据集图,所有Y < 10 的点都被认为是第一个簇的一部分,而其他点属于第二个簇。如果我们增加距离,链接变得非常激进(特别是在这个只有少数样本的例子中),并且当值大于 27 时,只生成一个簇(即使内部方差相当高!)。

scikit-learn 中的层次聚类

让我们考虑一个具有 8 个中心的更复杂的虚拟数据集:

>>> nb_samples = 3000
>>> X, _ = make_blobs(n_samples=nb_samples, n_features=2, centers=8, cluster_std=2.0)

下图显示了图形表示:

截图

我们现在可以使用不同的链接方法(始终保持欧几里得距离)进行层次聚类,并比较结果。让我们从完全链接开始(AgglomerativeClustering使用fit_predict()方法来训练模型并转换原始数据集):

from sklearn.cluster import AgglomerativeClustering

>>> ac = AgglomerativeClustering(n_clusters=8, linkage='complete')
>>> Y = ac.fit_predict(X)

下图显示了结果的图示(使用不同的标记和颜色):

截图

这种方法的结果完全糟糕。这种方法惩罚了组间方差并合并簇,这在大多数情况下应该是不同的。在之前的图中,中间的三个簇相当模糊,考虑到由点表示的簇的方差,错误放置的概率非常高。现在让我们考虑平均链接:

>>> ac = AgglomerativeClustering(n_clusters=8, linkage='average')
>>> Y = ac.fit_predict(X)

结果显示在下述截图:

截图

在这种情况下,簇的定义更加清晰,尽管其中一些簇可能变得非常小。尝试其他度量标准(特别是L1)并比较结果也可能很有用。最后一种方法,通常是最佳方法(它是默认方法),是 Ward 的链接方法,只能与欧几里得度量一起使用(也是默认的):

>>> ac = AgglomerativeClustering(n_clusters=8)
>>> Y = ac.fit_predict(X)

下图显示了生成的结果图:

截图

在这种情况下,无法修改度量标准,因此,正如官方 scikit-learn 文档中建议的那样,一个有效的替代方案可能是平均链接,它可以与任何亲和力一起使用:

连通性约束

scikit-learn 还允许指定连接矩阵,该矩阵在寻找要合并的聚类时可以用作约束。通过这种方式,彼此距离较远的聚类(在连接矩阵中不相邻)将被跳过。创建此类矩阵的一个非常常见的方法是使用基于样本邻居数量的 k 近邻图函数(作为kneighbors_graph()实现),该函数根据特定的度量来确定样本的邻居数量。在以下示例中,我们考虑了一个圆形虚拟数据集(常在官方文档中使用):

from sklearn.datasets import make_circles

>>> nb_samples = 3000
>>> X, _ = make_circles(n_samples=nb_samples, noise=0.05)

下图显示了图形表示:

我们从基于平均连接的未结构化聚合聚类开始,并设定了 20 个聚类:

>>> ac = AgglomerativeClustering(n_clusters=20, linkage='average')
>>> ac.fit(X)

在这种情况下,我们使用了fit()方法,因为AgglomerativeClustering类在训练后通过实例变量labels_公开标签(聚类编号),当聚类数量非常高时,使用此变量更方便。以下图显示了结果的图形表示:

现在我们可以尝试为k设定不同的约束值:

from sklearn.neighbors import kneighbors_graph

>>> acc = []
>>> k = [50, 100, 200, 500]

>>> for i in range(4):
>>>    kng = kneighbors_graph(X, k[i])
>>>    ac1 = AgglomerativeClustering(n_clusters=20, connectivity=kng, linkage='average')
>>>    ac1.fit(X)
>>>    acc.append(ac1)

以下截图显示了生成的图表:

如您所见,施加约束(在这种情况下,基于 k 近邻)可以控制聚合如何创建新的聚类,并且可以成为调整模型或避免在原始空间中距离较大的元素(这在聚类图像时特别有用)的有力工具。

参考文献

Kaufman L.,Roussew P.J.,在数据中寻找群组:聚类分析导论,Wiley

摘要

在本章中,我们介绍了层次聚类,重点关注聚合版本,这是 scikit-learn 唯一支持的版本。我们讨论了哲学,这与许多其他方法采用的哲学相当不同。在聚合聚类中,过程从将每个样本视为单个聚类开始,并通过合并块直到达到所需的聚类数量。为了执行此任务,需要两个元素:一个度量函数(也称为亲和力)和一个连接标准。前者用于确定元素之间的距离,而后者是一个目标函数,用于确定哪些聚类必须合并。

我们还展示了如何使用 SciPy 通过树状图可视化这个过程。当需要保持对整个过程和最终聚类数量的完全控制,且初始时聚类数量未知(决定在哪里截断图更容易)时,这项技术非常有用。我们展示了如何使用 scikit-learn 执行基于不同指标和连接方式的层次聚类,并在本章末尾,我们还介绍了在需要强制过程避免合并距离过远的聚类时有用的连通性约束。

在下一章中,我们将介绍推荐系统,这些系统被许多不同的系统日常使用,以根据用户与其他用户及其偏好的相似性自动向用户推荐项目。

第十一章:推荐系统简介

想象一个拥有数千篇文章的在线商店。如果你不是注册用户,你可能会看到一些突出显示的主页,但如果你已经购买了一些商品,网站显示你可能购买的产品,而不是随机选择的产品,这将是很有趣的。这就是推荐系统的目的,在本章中,我们将讨论创建此类系统最常见的技术。

基本概念包括用户、项目和评分(或关于产品的隐式反馈,例如购买的事实)。每个模型都必须与已知数据(如在监督场景中)一起工作,以便能够建议最合适的项目或预测尚未评估的所有项目的评分。

我们将讨论两种不同的策略:

  • 基于用户或内容

  • 协同过滤

第一种方法基于我们对用户或产品的信息,其目标是将新用户与现有的一组同龄人关联起来,以建议其他成员正面评价的所有项目,或者根据其特征对产品进行聚类,并提出与考虑的项目相似的项目子集。第二种方法稍微复杂一些,使用显式评分,其目的是预测每个项目和每个用户的此值。尽管协同过滤需要更多的计算能力,但如今,廉价资源的广泛可用性允许使用此算法处理数百万用户和产品,以提供最准确的实时推荐。该模型还可以每天重新训练或更新。

天真的基于用户的系统

在这个第一个场景中,我们假设我们有一组由特征向量表示的用户:

图片

典型的特征包括年龄、性别、兴趣等。所有这些都必须使用前几章中讨论的技术之一进行编码(例如,它们可以被二值化)。此外,我们有一组项目:

图片

假设也存在一个关系,将每个用户与一组项目(已购买或正面评价)相关联,对于这些项目已执行了明确的行为或反馈:

图片

在基于用户的系统中,用户会定期进行聚类(通常使用k 最近邻方法),因此,考虑一个通用的用户 u(也是新的),我们可以立即确定包含所有与我们样本相似(因此是邻居)的用户球体:

图片

在这一点上,我们可以使用之前介绍的关系创建建议项目的集合:

图片

换句话说,这个集合包含所有被邻居积极评价或购买的唯一产品。我之所以使用“天真”这个词,是因为我们将要在专门讨论协同过滤的章节中讨论一个类似的替代方案。

基于用户的系统实现与 scikit-learn

对于我们的目的,我们需要创建一个用户和产品的虚拟数据集:

import numpy as np

>>> nb_users = 1000
>>> users = np.zeros(shape=(nb_users, 4))

>>> for i in range(nb_users):
>>>    users[i, 0] = np.random.randint(0, 4)
>>>    users[i, 1] = np.random.randint(0, 2)
>>>    users[i, 2] = np.random.randint(0, 5)
>>>    users[i, 3] = np.random.randint(0, 5)

我们假设我们有 1,000 个用户,他们有四个特征,这些特征由介于 0 和 4 或 5 之间的整数表示。它们的具体含义无关紧要;它们的作用是表征用户并允许对集合进行聚类。

对于产品,我们还需要创建关联:

>>> nb_product = 20
>>> user_products = np.random.randint(0, nb_product, size=(nb_users, 5))

我们假设我们有 20 个不同的项目(从 1 到 20;0 表示用户没有购买任何东西)和一个关联矩阵,其中每个用户都与 0 到 5(最大值)之间的产品数量相关联。例如:

图片

在这一点上,我们需要使用 scikit-learn 提供的NearestNeighbors实现来对用户进行聚类:

from sklearn.neighbors import NearestNeighbors

>>> nn = NearestNeighbors(n_neighbors=20, radius=2.0)
>>> nn.fit(users)
NearestNeighbors(algorithm='auto', leaf_size=30, metric='minkowski',
 metric_params=None, n_jobs=1, n_neighbors=20, p=2, radius=2.0)

我们选择了 20 个邻居和等于 2 的欧几里得半径。当我们要查询模型以了解包含在以样本为中心且具有固定半径的球体内的项目时,我们会使用这个参数。在我们的案例中,我们将查询模型以获取一个测试用户的全部邻居:

>>> test_user = np.array([2, 0, 3, 2])
>>> d, neighbors = nn.kneighbors(test_user.reshape(1, -1))

>>> print(neighbors)
array([[933,  67, 901, 208,  23, 720, 121, 156, 167,  60, 337, 549,  93,
 563, 326, 944, 163, 436, 174,  22]], dtype=int64)

现在我们需要使用关联矩阵来构建推荐列表:

>>> suggested_products = []

>>> for n in neighbors:
>>>    for products in user_products[n]:
>>>       for product in products:
>>>          if product != 0 and product not in suggested_products:
>>>             suggested_products.append(product)

>>> print(suggested_products)
[14, 5, 13, 4, 8, 9, 16, 18, 10, 7, 1, 19, 12, 11, 6, 17, 15, 3, 2]

对于每个邻居,我们检索他/她购买的产品并执行并集操作,避免包含值为零的项目(表示没有产品)和重复元素。结果是(未排序)建议列表,对于许多不同的系统,几乎可以实时获得。在某些情况下,当用户或项目数量太多时,可以限制列表为固定数量的元素并减少邻居的数量。这种方法也是天真的,因为它没有考虑用户之间的实际距离(或相似性)来权衡建议。可以考虑将距离作为权重因子,但采用提供更稳健解决方案的协同过滤方法更简单。

基于内容的系统

这可能是最简单的方法,它仅基于产品,将其建模为特征向量:

图片

就像用户一样,特征也可以是分类的(实际上,对于产品来说更容易),例如,书籍或电影的类型,并且它们可以在编码后与数值(如价格、长度、正面评价数量等)一起使用。

然后采用聚类策略,尽管最常用的是k 最近邻,因为它允许控制每个邻域的大小,从而确定给定一个样本产品,其质量和建议的数量。

使用 scikit-learn,首先我们创建一个虚拟产品数据集:

>>> nb_items = 1000
>>> items = np.zeros(shape=(nb_items, 4))

>>> for i in range(nb_items):
>>>    items[i, 0] = np.random.randint(0, 100)
>>>    items[i, 1] = np.random.randint(0, 100)
>>>    items[i, 2] = np.random.randint(0, 100)
>>>    items[i, 3] = np.random.randint(0, 100)

在这种情况下,我们有 1000 个样本,四个整数特征介于 0 和 100 之间。然后我们继续,就像上一个例子一样,将它们进行聚类:

>>> nn = NearestNeighbors(n_neighbors=10, radius=5.0)
>>> nn.fit(items)

在这一点上,我们可以使用方法 radius_neighbors() 来查询我们的模型,这允许我们仅将研究限制在有限的子集。默认半径(通过参数 radius 设置)为 5.0,但我们可以动态地更改它:

>>> test_product = np.array([15, 60, 28, 73])
>>> d, suggestions = nn.radius_neighbors(test_product.reshape(1, -1), radius=20)

>>> print(suggestions)
[array([657, 784, 839, 342, 446, 196], dtype=int64)]

>>> d, suggestions = nn.radius_neighbors(test_product.reshape(1, -1), radius=30)

>>> print(suggestions)
[ array([844, 340, 657, 943, 461, 799, 715, 863, 979, 784, 54, 148, 806,
 465, 585, 710, 839, 695, 342, 881, 864, 446, 196, 73, 663, 580, 216], dtype=int64)]

当然,当尝试这些示例时,建议的数量可能不同,因为我们正在使用随机数据集,所以我建议尝试不同的半径值(特别是当使用不同的度量标准时)。

当使用 k-最近邻 进行聚类时,考虑用于确定样本之间距离的度量标准非常重要。scikit-learn 的默认值是 Minkowski 距离,它是欧几里得和曼哈顿距离的推广,定义为:

图片

参数 p 控制距离的类型,默认值为 2,因此得到的度量是经典的欧几里得距离。SciPy(在 scipy.spatial.distance 包中)提供了其他距离,例如 汉明杰卡德 距离。前者定义为两个向量之间的不一致比例(如果它们是二进制的,则这是不同位的标准化数量)。例如:

from scipy.spatial.distance import hamming

>>> a = np.array([0, 1, 0, 0, 1, 0, 1, 1, 0, 0])
>>> b = np.array([1, 1, 0, 0, 0, 1, 1, 1, 1, 0])
>>> d = hamming(a, b)

>>> print(d)
0.40000000000000002

这意味着有 40% 的不一致比例,或者考虑到两个向量都是二进制的,有 4 个不同的位(在 10 位中)。这个度量在需要强调特定特征的呈现/缺失时可能很有用。

杰卡德距离定义为:

图片

测量两个不同集合(AB)的项之间的差异特别有用。如果我们的特征向量是二进制的,则可以使用布尔逻辑立即应用此距离。使用之前的测试值,我们得到:

from scipy.spatial.distance import jaccard

>>> d = jaccard(a, b)
>>> print(d)
0.5714285714285714

这个度量在 0(相等向量)和 1(完全不同)之间有界。

至于汉明距离,当需要比较由二进制状态(如存在/不存在、是/否等)组成的项时,它非常有用。如果您想为 k-最近邻 采用不同的度量标准,可以直接使用 metric 参数指定它:

>>> nn = NearestNeighbors(n_neighbors=10, radius=5.0, metric='hamming')
>>> nn.fit(items) >>> nn = NearestNeighbors(n_neighbors=10, radius=5.0, metric='jaccard')
>>> nn.fit(items)

无模型(或基于记忆)的协同过滤

与基于用户的方法一样,让我们考虑有两个元素集合:用户和物品。然而,在这种情况下,我们不假设它们有显式特征。相反,我们试图根据每个用户(行)对每个物品(列)的偏好来建模用户-物品矩阵。例如:

图片

在这种情况下,评分介于 1 到 5 之间(0 表示没有评分),我们的目标是根据用户的评分向量(实际上,这是一种基于特定类型特征的内部表示)对用户进行聚类。这允许在没有任何关于用户的明确信息的情况下产生推荐。然而,它有一个缺点,称为冷启动,这意味着当一个新用户没有评分时,无法找到正确的邻域,因为他/她可能属于几乎任何聚类。

一旦完成聚类,很容易检查哪些产品(尚未评分)对特定用户有更高的评分,因此更有可能被购买。可以像之前那样在 scikit-learn 中实现解决方案,但我想要介绍一个名为Crab的小型框架(见本节末尾的框),它简化了这一过程。

为了构建模型,我们首先需要将用户-物品矩阵定义为 Python 字典,其结构如下:

{ user_1: { item1: rating, item2: rating, ... }, ..., user_n: ... }

用户内部字典中的缺失值表示没有评分。在我们的例子中,我们考虑了 5 个用户和 5 个物品:

from scikits.crab.models import MatrixPreferenceDataModel

>>> user_item_matrix = {
 1: {1: 2, 2: 5, 3: 3},
 2: {1: 5, 4: 2},
 3: {2: 3, 4: 5, 3: 2},
 4: {3: 5, 5: 1},
 5: {1: 3, 2: 3, 4: 1, 5: 3}
 }

>>> model = MatrixPreferenceDataModel(user_item_matrix)

一旦定义了用户-物品矩阵,我们需要选择一个度量标准以及因此一个距离函数 d(u[i], u[j]) 来构建相似度矩阵:

使用 Crab,我们以以下方式(使用欧几里得距离)进行此操作:

from scikits.crab.similarities import UserSimilarity
from scikits.crab.metrics import euclidean_distances

>>> similarity_matrix = UserSimilarity(model, euclidean_distances)

有许多度量标准,如皮尔逊或贾卡德,所以我建议访问网站(muricoca.github.io/crab)以获取更多信息。在此阶段,可以构建基于 k 最近邻聚类方法的推荐系统并对其进行测试:

from scikits.crab.recommenders.knn import UserBasedRecommender

>>> recommender = UserBasedRecommender(model, similarity_matrix, with_preference=True)

>>> print(recommender.recommend(2))
[(2, 3.6180339887498949), (5, 3.0), (3, 2.5527864045000417)]

因此,推荐系统为用户 2 建议以下预测评分:

  • 物品 2:3.6(可以四舍五入到 4.0)

  • 物品 5:3

  • 物品 3:2.5(可以四舍五入到 3.0)

在运行代码时,可能会看到一些警告(Crab 仍在开发中);然而,它们并不影响功能。如果您想避免它们,可以使用catch_warnings()上下文管理器:

import warnings

>>> with warnings.catch_warnings():
>>>    warnings.simplefilter("ignore")
>>>    print(recommender.recommend(2))

可以建议所有物品,或者限制列表只包含高评分(例如,避免物品 3)。这种方法与基于用户的模型相当相似。然而,它更快(非常大的矩阵可以并行处理)并且它不关心可能导致误导结果的具体细节。只有评分被视为定义用户的有用特征。像基于模型的协同过滤一样,冷启动问题可以通过两种方式解决:

  • 要求用户对一些物品进行评分(这种做法通常被采用,因为它很容易展示一些电影/书籍封面,让用户选择他们喜欢和不喜欢的内容)。

  • 通过随机分配一些平均评分将用户放置在平均邻域中。在这种方法中,可以立即开始使用推荐系统。然而,在开始时必须接受一定程度的错误,并在产生真实评分时纠正虚拟评分。

Crab 是一个用于构建协同过滤系统的开源框架。它仍在开发中,因此尚未实现所有可能的功能。然而,它非常易于使用,对于许多任务来说非常强大。带有安装说明和文档的主页是:muricoca.github.io/crab/index.html。Crab 依赖于 scikits.learn,它仍然与 Python 3 有一些问题。因此,我建议在这个例子中使用 Python 2.7。可以使用 pip 安装这两个包:pip install -U scikits.learnpip install -U crab

基于模型的协同过滤

这是目前最先进的方法之一,是前一个章节中已看到内容的扩展。起点始终是基于评分的用户-项目矩阵:

图片

然而,在这种情况下,我们假设用户和项目都存在潜在因素。换句话说,我们定义一个通用的用户为:

图片

一个通用的项目定义为:

图片

我们不知道每个向量分量的值(因此它们被称为潜在值),但我们假设通过以下方式获得排名:

图片

因此,我们可以这样说,排名是从一个包含 k 个潜在变量的潜在空间中获得的,其中 k 是我们希望在模型中考虑的潜在变量的数量。一般来说,有规则可以确定 k 的正确值,因此最佳方法是检查不同的值,并使用已知评分的子集测试模型。然而,仍然有一个大问题需要解决:找到潜在变量。有几种策略,但在讨论它们之前,了解我们问题的维度很重要。如果我们有 1000 个用户和 500 个产品,M 有 500,000 个元素。如果我们决定排名等于 10,这意味着我们需要找到 5000000 个变量,这些变量受已知评分的限制。正如你可以想象的那样,这个问题很容易变得无法用标准方法解决,并且必须采用并行解决方案。

单值分解策略

第一种方法是基于用户-项目矩阵的奇异值分解SVD)。这种技术允许通过低秩分解来转换矩阵,也可以像 Sarwar B.,Karypis G.,Konstan J.,Riedl J.,Incremental Singular Value Decomposition Algorithms for Highly Scalable Recommender Systems,2002 年描述的那样以增量方式使用。特别是,如果用户-项目矩阵有m行和n列:

图片

我们假设我们拥有实矩阵(在我们的情况下通常是真的),但一般来说,它们是复数。UV是正交的,而 sigma 是对角的。U的列包含左奇异向量,转置V的行包含右奇异向量,而对角矩阵 Sigma 包含奇异值。选择k个潜在因子意味着取前k个奇异值,因此,相应的k个左和右奇异向量:

图片

这种技术具有最小化MM[k]之间 Frobenius 范数差异的优点,对于任何k的值,因此,它是逼近完整分解的最佳选择。在进入预测阶段之前,让我们使用 SciPy 创建一个示例。首先要做的是创建一个虚拟的用户-项目矩阵:

>>> M = np.random.randint(0, 6, size=(20, 10))

>>> print(M)
array([[0, 4, 5, 0, 1, 4, 3, 3, 1, 3],
 [1, 4, 2, 5, 3, 3, 3, 4, 3, 1],
 [1, 1, 2, 2, 1, 5, 1, 4, 2, 5],
 [0, 4, 1, 2, 2, 5, 1, 1, 5, 5],
 [2, 5, 3, 1, 1, 2, 2, 4, 1, 1],
 [1, 4, 3, 3, 0, 0, 2, 3, 3, 5],
 [3, 5, 2, 1, 5, 3, 4, 1, 0, 2],
 [5, 2, 2, 0, 1, 0, 4, 4, 1, 0],
 [0, 2, 4, 1, 3, 1, 3, 0, 5, 4],
 [2, 5, 1, 5, 3, 0, 1, 4, 5, 2],
 [1, 0, 0, 5, 1, 3, 2, 0, 3, 5],
 [5, 3, 1, 5, 0, 0, 4, 2, 2, 2],
 [5, 3, 2, 4, 2, 0, 4, 4, 0, 3],
 [3, 2, 5, 1, 1, 2, 1, 1, 3, 0],
 [1, 5, 5, 2, 5, 2, 4, 5, 1, 4],
 [4, 0, 2, 2, 1, 0, 4, 4, 3, 3],
 [4, 2, 2, 3, 3, 4, 5, 3, 5, 1],
 [5, 0, 5, 3, 0, 0, 3, 5, 2, 2],
 [1, 3, 2, 2, 3, 0, 5, 4, 1, 0],
 [1, 3, 1, 4, 1, 5, 4, 4, 2, 1]])

我们假设有 20 个用户和 10 个产品。评分介于 1 到 5 之间,0 表示没有评分。现在我们可以分解M

from scipy.linalg import svd

import numpy as np

>>> U, s, V = svd(M, full_matrices=True)
>>> S = np.diag(s)

>>> print(U.shape)
(20L, 20L)

>>> print(S.shape)
(10L, 10L)

>>> print(V.shape)
(10L, 10L)

现在让我们只考虑前八个奇异值,这将使用户和项目都有八个潜在因子:

>>> Uk = U[:, 0:8]
>>> Sk = S[0:8, 0:8]
>>> Vk = V[0:8, :]

请记住,在 SciPy 的 SVD 实现中,V已经转置。根据 Sarwar B.,Karypis G.,Konstan J.,Riedl J.,Incremental Singular Value Decomposition Algorithms for Highly Scalable Recommender Systems,2002 年的描述,我们可以很容易地通过考虑客户和产品之间的余弦相似度(与点积成正比)来进行预测。两个潜在因子矩阵是:

图片

为了考虑到精度的损失,考虑每个用户的平均评分(这对应于用户-项目矩阵的行平均值)也是很有用的,这样用户i和项目j的结果评分预测就变为:

图片

这里SUSI分别是用户和产品向量。继续我们的例子,让我们确定用户 5 和项目 2 的评分预测:

>>> Su = Uk.dot(np.sqrt(Sk).T)
>>> Si = np.sqrt(Sk).dot(Vk).T
>>> Er = np.mean(M, axis=1)

>>> r5_2 = Er[5] + Su[5].dot(Si[2])
>>> print(r5_2)
2.38848720112

此方法具有中等复杂度。特别是,SVD 是O(m³),当添加新用户或项目时,必须采用增量策略(如 Sarwar B.、Karypis G.、Konstan J.、Riedl J.在 2002 年发表的高度可扩展推荐系统增量奇异值分解算法中所述);然而,当元素数量不是太多时,它可能非常有效。在所有其他情况下,可以采用下一个策略(与并行架构一起)。

交替最小二乘策略

通过定义以下损失函数,可以轻松地将寻找潜在因子的难题表达为一个最小二乘优化问题:

图片

L 仅限于已知样本(用户、项目)。第二个项作为一个正则化因子,整个问题可以很容易地通过任何优化方法解决。然而,还有一个额外的问题:我们有两组不同的变量需要确定(用户和项目因子)。我们可以通过一种称为交替最小二乘的方法来解决此问题,该方法由 Koren Y.、Bell R.、Volinsky C.在 2009 年 8 月的 IEEE 计算机杂志上发表的推荐系统矩阵分解技术中描述。该算法非常容易描述,可以总结为两个主要的迭代步骤:

  • p[i]是固定的,q[j]是优化的

  • q[j]是固定的,p[i]是优化的

当达到预定义的精度时,算法停止。它可以很容易地通过并行策略实现,以便在短时间内处理大量矩阵。此外,考虑到虚拟集群的成本,还可以定期重新训练模型,以立即(在可接受的延迟内)包含新产品和用户。

使用 Apache Spark MLlib 的交替最小二乘

Apache Spark 超出了本书的范围,因此如果您想了解更多关于这个强大框架的信息,我建议您阅读在线文档或许多可用的书籍。在 Pentreath N.的Spark 机器学习(Packt)中,有一个关于库 MLlib 和如何实现本书中讨论的大多数算法的有趣介绍。

Spark 是一个并行计算引擎,现在是 Hadoop 项目的一部分(即使它不使用其代码),可以在本地模式或非常大的集群(具有数千个节点)上运行,以使用大量数据执行复杂任务。它主要基于 Scala,尽管有 Java、Python 和 R 的接口。在这个例子中,我们将使用 PySpark,这是运行 Spark 的 Python 代码的内置 shell。

在本地模式下启动 PySpark 后,我们得到一个标准的 Python 提示符,我们可以开始工作,就像在任何其他标准 Python 环境中一样:

# Linux
>>> ./pyspark

# Mac OS X
>>> pyspark

# Windows
>>> pyspark

Python 2.7.12 |Anaconda 4.0.0 (64-bit)| (default, Jun 29 2016, 11:07:13) [MSC v.1500 64 bit (AMD64)] on win32
Type "help", "copyright", "credits" or "license" for more information.
Anaconda is brought to you by Continuum Analytics.
Please check out: http://continuum.io/thanks and https://anaconda.org
Using Spark's default log4j profile: org/apache/spark/log4j-defaults.properties
Setting default log level to "WARN".
To adjust logging level use sc.setLogLevewl(newLevel).
Welcome to
 ____ __
 / __/__ ___ _____/ /__
 _\ \/ _ \/ _ `/ __/ '_/
 /__ / .__/\_,_/_/ /_/\_\ version 2.0.2
 /_/

Using Python version 2.7.12 (default, Jun 29 2016 11:07:13)
SparkSession available as 'spark'.
>>>

Spark MLlib 通过一个非常简单的机制实现了 ALS 算法。Rating类是元组(user, product, rating)的包装器,因此我们可以轻松地定义一个虚拟数据集(这只能被视为一个示例,因为它非常有限):

from pyspark.mllib.recommendation import Rating

import numpy as np

>>> nb_users = 200
>>> nb_products = 100

>>> ratings = []

>>> for _ in range(10):
>>>    for i in range(nb_users):
>>>        rating = Rating(user=i, 
>>>                        product=np.random.randint(1, nb_products), 
>>>                        rating=np.random.randint(0, 5))
>>>        ratings.append(rating)

>>> ratings = sc.parallelize(ratings)

我们假设有 200 个用户和 100 个产品,并且通过迭代 10 次主循环,为随机产品分配评分来填充评分列表。我们没有控制重复或其他不常见的情况。最后的命令sc.parallelize()是一种请求 Spark 将我们的列表转换为称为弹性分布式数据集RDD)的结构的方法,它将被用于剩余的操作。这些结构的大小实际上没有限制,因为它们分布在不同的执行器上(如果是在集群模式下),并且可以像处理千字节数据集一样处理 PB 级的数据集。

在这个阶段,我们可以训练一个ALS模型(形式上是MatrixFactorizationModel),并使用它来进行一些预测:

from pyspark.mllib.recommendation import ALS 
>>> model = ALS.train(ratings, rank=5, iterations=10)

我们想要 5 个潜在因素和 10 次优化迭代。正如之前讨论的那样,确定每个模型的正确秩并不容易,因此,在训练阶段之后,应该始终有一个使用已知数据的验证阶段。均方误差是一个很好的指标,可以用来了解模型的工作情况。我们可以使用相同的训练数据集来完成这项工作。首先要做的是移除评分(因为我们只需要由用户和产品组成的元组):

>>> test = ratings.map(lambda rating: (rating.user, rating.product))

如果您不熟悉 MapReduce 范式,您只需要知道map()会对所有元素应用相同的函数(在这种情况下,是一个 lambda 函数)。现在我们可以大量预测评分:

>>> predictions = model.predictAll(test)

然而,为了计算误差,我们还需要添加用户和产品,以便可以进行比较:

>>> full_predictions = predictions.map(lambda pred: ((pred.user, pred.product), pred.rating))

结果是一系列具有结构((user, item), rating)的行,就像一个标准的字典条目(key, value)。这很有用,因为使用 Spark,我们可以通过它们的键来连接两个 RDD。我们也对原始数据集做了同样的事情,然后通过连接训练值和预测值来继续操作:

>>> split_ratings = ratings.map(lambda rating: ((rating.user, rating.product), rating.rating))
>>> joined_predictions = split_ratings.join(full_predictions)

现在对于每个键 (user, product),我们有两个值:目标和预测。因此,我们可以计算均方误差:

>>> mse = joined_predictions.map(lambda x: (x[1][0] - x[1][1]) ** 2).mean()

第一个map操作将每一行转换为目标和预测之间的平方差,而mean()函数计算平均值。在这个时候,让我们检查我们的误差并生成一个预测:

>>> print('MSE: %.3f' % mse)
MSE: 0.580

>>> prediction = model.predict(10, 20)
>>> print('Prediction: %3.f' % prediction)
Prediction: 2.810

因此,我们的误差相当低,但可以通过改变秩或迭代次数来提高。用户 10 对产品 20 的评分预测约为 2.8(可以四舍五入到 3)。如果您运行代码,这些值可能会有所不同,因为我们正在使用随机的用户-项目矩阵。此外,如果您不想使用 shell 直接运行代码,您需要在文件开头显式声明一个SparkContext

from pyspark import SparkContext, SparkConf

>>> conf = SparkConf().setAppName('ALS').setMaster('local[*]')
>>> sc = SparkContext(conf=conf)

我们通过SparkConf类创建了一个配置,并指定了应用程序名称和主节点(在本地模式下,使用所有可用核心)。这足以运行我们的代码。然而,如果您需要更多信息,请访问章节末尾信息框中提到的页面。要运行应用程序(自 Spark 2.0 起),您必须执行以下命令:

# Linux, Mac OSx
./spark-submit als_spark.py

# Windows
spark-submit als_spark.py

当使用spark-submit运行脚本时,您将看到数百行日志,这些日志会通知您正在执行的所有操作。其中,在计算结束时,您还会看到打印函数消息(stdout)。

当然,这只是一个 Spark ALS 的介绍,但我希望它有助于理解这个过程有多简单,同时如何有效地解决维度限制问题。

如果您不知道如何设置环境和启动 PySpark,我建议阅读在线快速入门指南(spark.apache.org/docs/2.1.0/quick-start.html),即使您不了解所有细节和配置参数,它也可能很有用。

参考文献

  • Sarwar B.,Karypis G.,Konstan J.,Riedl J.,高度可扩展推荐系统的增量奇异值分解算法,2002

  • Koren Y.,Bell R.,Volinsky C.,推荐系统的矩阵分解技术,IEEE 计算机杂志,2009 年 8 月

  • Pentreath N.,使用 Spark 进行机器学习,Packt

摘要

在本章中,我们讨论了构建推荐系统的主要技术。在基于用户的场景中,我们假设我们拥有足够关于用户的信息来对他们进行聚类,并且我们隐含地假设相似的用户会喜欢相同的产品。这样,就可以立即确定每个新用户所在的邻域,并建议其同龄人给予正面评价的产品。以类似的方式,基于内容的场景是基于根据产品的独特特征对产品进行聚类。在这种情况下,假设较弱,因为更有可能的是,购买过某个商品或给予正面评价的用户会对类似产品做同样的事情。

然后我们介绍了协同过滤,这是一种基于显式评分的技术,用于预测所有用户和产品的所有缺失值。在基于记忆的变体中,我们不训练模型,而是直接与用户-产品矩阵一起工作,寻找测试用户的 k 个最近邻,并通过平均计算排名。这种方法与基于用户的场景非常相似,并且具有相同的局限性;特别是,管理大型矩阵非常困难。另一方面,基于模型的方法更复杂,但在训练模型后,它可以实时预测评分。此外,还有像 Spark 这样的并行框架,可以使用廉价的集群服务器处理大量数据。

在下一章中,我们将介绍一些自然语言处理技术,这些技术在自动分类文本或与机器翻译系统协同工作时非常重要。

第十二章:自然语言处理简介

自然语言处理是一组机器学习技术,允许处理文本文档,考虑其内部结构和单词的分布。在本章中,我们将讨论收集文本、将它们拆分为原子并转换为数值向量的所有常用方法。特别是,我们将比较不同的方法来分词文档(分离每个单词)、过滤它们、应用特殊转换以避免屈折或动词变位形式,并最终构建一个共同词汇。使用词汇,将能够应用不同的向量化方法来构建特征向量,这些向量可以轻松用于分类或聚类目的。为了展示如何实现整个管道,在本章末尾,我们将设置一个简单的新闻行分类器。

NLTK 和内置语料库

自然语言工具包NLTK)是一个非常强大的 Python 框架,实现了大多数 NLP 算法,并将与 scikit-learn 一起在本章中使用。此外,NLTK 提供了一些内置的语料库,可用于测试算法。在开始使用 NLTK 之前,通常需要使用特定的图形界面下载所有附加元素(语料库、词典等)。这可以通过以下方式完成:

import nltk

>>> nltk.download()

此命令将启动用户界面,如图所示:

图片

可以选择每个单独的特征或下载所有元素(如果您有足够的空闲空间,我建议选择此选项)以立即利用所有 NLTK 功能。

NLTK 可以使用 pip(pip install -U nltk)或通过www.nltk.org上可用的二进制分发安装。在同一网站上,有完整的文档,对于深入了解每个主题非常有用。

语料库示例

格鲁吉亚项目的一个子集被提供,并且可以通过这种方式免费访问:

from nltk.corpus import gutenberg

>>> print(gutenberg.fileids())
[u'austen-emma.txt', u'austen-persuasion.txt', u'austen-sense.txt', u'bible-kjv.txt', u'blake-poems.txt', u'bryant-stories.txt', u'burgess-busterbrown.txt', u'carroll-alice.txt', u'chesterton-ball.txt', u'chesterton-brown.txt', u'chesterton-thursday.txt', u'edgeworth-parents.txt', u'melville-moby_dick.txt', u'milton-paradise.txt', u'shakespeare-caesar.txt', u'shakespeare-hamlet.txt', u'shakespeare-macbeth.txt', u'whitman-leaves.txt']

单个文档可以以原始版本访问或拆分为句子或单词:

>>> print(gutenberg.raw('milton-paradise.txt'))
[Paradise Lost by John Milton 1667] 

Book I 

Of Man's first disobedience, and the fruit 
Of that forbidden tree whose mortal taste 
Brought death into the World, and all our woe, 
With loss of Eden, till one greater Man 
Restore us, and regain the blissful seat, 
Sing, Heavenly Muse, that, on the secret top...

>>> print(gutenberg.sents('milton-paradise.txt')[0:2])
[[u'[', u'Paradise', u'Lost', u'by', u'John', u'Milton', u'1667', u']'], [u'Book', u'I']]

>>> print(gutenberg.words('milton-paradise.txt')[0:20])
[u'[', u'Paradise', u'Lost', u'by', u'John', u'Milton', u'1667', u']', u'Book', u'I', u'Of', u'Man', u"'", u's', u'first', u'disobedience', u',', u'and', u'the', u'fruit']

正如我们将要讨论的,在许多情况下,拥有原始文本以便使用自定义策略将其拆分为单词是有用的。在许多其他情况下,直接访问句子允许使用原始的结构性细分。其他语料库包括网络文本、路透社新闻行、布朗语料库以及许多更多。例如,布朗语料库是一个按体裁划分的著名文档集合:

from nltk.corpus import brown

>>> print(brown.categories())
[u'adventure', u'belles_lettres', u'editorial', u'fiction', u'government', u'hobbies', u'humor', u'learned', u'lore', u'mystery', u'news', u'religion', u'reviews', u'romance', u'science_fiction']

>>> print(brown.sents(categories='editorial')[0:100])
[[u'Assembly', u'session', u'brought', u'much', u'good'], [u'The', u'General', u'Assembly', u',', u'which', u'adjourns', u'today', u',', u'has', u'performed', u'in', u'an', u'atmosphere', u'of', u'crisis', u'and', u'struggle', u'from', u'the', u'day', u'it', u'convened', u'.'], ...]

关于语料库的更多信息可以在www.nltk.org/book/ch02.html找到。

词袋策略

在 NLP 中,一个非常常见的管道可以细分为以下步骤:

  1. 将文档收集到语料库中。

  2. 分词、去除停用词(冠词、介词等)和词干提取(还原到词根形式)。

  3. 构建共同词汇。

  4. 向量化文档。

  5. 对文档进行分类或聚类。

该管道被称为词袋模型,将在本章中讨论。一个基本假设是句子中每个单词的顺序并不重要。实际上,当我们定义特征向量时,我们将要看到,所采取的措施总是与频率相关,因此它们对所有元素的局部位置不敏感。从某些观点来看,这是一个限制,因为在自然语言中,句子的内部顺序对于保留意义是必要的;然而,有许多模型可以在不涉及局部排序的复杂性的情况下有效地处理文本。当绝对有必要考虑小序列时,将通过采用标记组(称为 n-gram)来实现,但在向量化步骤中将它们视为单个原子元素。

在以下图例中,有一个该过程的示意图(不包括第五步)对于一个示例文档(句子):

图片

执行每个步骤有许多不同的方法,其中一些是上下文特定的。然而,目标始终相同:通过移除过于频繁或来自同一词根(如动词)的术语来最大化文档的信息量并减少常用词汇表的大小。实际上,文档的信息含量是由在语料库中频率有限的特定术语(或术语组)的存在决定的。在前面图例中显示的例子中,狐狸是重要术语,而the则无用(通常称为停用词)。此外,跳跃可以转换为标准形式,当以不同形式出现时(如跳跃或跳过),它表达了一个特定的动作。最后一步是将其转换为数值向量,因为我们的算法处理的是数字,因此限制向量的长度对于提高学习速度和内存消耗非常重要。在接下来的章节中,我们将详细讨论每个步骤,并在最后构建一个用于新闻分类的示例分类器。

标记化

处理文本或语料库的第一步是将它们拆分成原子(句子、单词或单词的一部分),通常定义为标记。这个过程相当简单;然而,针对特定问题可能会有不同的策略。

句子标记化

在许多情况下,将大文本拆分成句子是有用的,这些句子通常由句号或其他等效标记分隔。由于每种语言都有自己的正字法规则,NLTK 提供了一个名为sent_tokenize()的方法,它接受一种语言(默认为英语)并根据特定规则拆分文本。在以下示例中,我们展示了该函数在不同语言中的使用:

from nltk.tokenize import sent_tokenize

>>> generic_text = 'Lorem ipsum dolor sit amet, amet minim temporibus in sit. Vel ne impedit consequat intellegebat.'

>>> print(sent_tokenize(generic_text))
['Lorem ipsum dolor sit amet, amet minim temporibus in sit.',
 'Vel ne impedit consequat intellegebat.']

>>> english_text = 'Where is the closest train station? I need to reach London'

>>> print(sent_tokenize(english_text, language='english'))
['Where is the closest train station?', 'I need to reach London']

>>> spanish_text = u'¿Dónde está la estación más cercana? Inmediatamente me tengo que ir a Barcelona.'

>>> for sentence in sent_tokenize(spanish_text, language='spanish'):
>>>    print(sentence)
¿Dónde está la estación más cercana?
Inmediatamente me tengo que ir a Barcelona.

单词标记化

将句子拆分成单词的最简单方法是类TreebankWordTokenizer提供的,然而,它也有一些局限性:

from nltk.tokenize import TreebankWordTokenizer

>>> simple_text = 'This is a simple text.'

>>> tbwt = TreebankWordTokenizer()

>>> print(tbwt.tokenize(simple_text))
['This', 'is', 'a', 'simple', 'text', '.']

>>> complex_text = 'This isn\'t a simple text'

>>> print(tbwt.tokenize(complex_text))
['This', 'is', "n't", 'a', 'simple', 'text']

如您所见,在第一种情况下,句子已经被正确地拆分成单词,同时保持了标点符号的分离(这不是一个真正的问题,因为可以在第二步中将其删除)。然而,在复杂示例中,缩写词isn't被拆分为isn't。不幸的是,没有进一步的加工步骤,将带有缩写词的标记转换为正常形式(如not)并不那么容易,因此,必须采用另一种策略。通过类RegexpTokenizer提供了一种灵活的方式来根据正则表达式拆分单词,这是解决单独标点问题的一个好方法:

from nltk.tokenize import RegexpTokenizer

>>> complex_text = 'This isn\'t a simple text.'

>>> ret = RegexpTokenizer('[a-zA-Z0-9\'\.]+')
>>> print(ret.tokenize(complex_text))
['This', "isn't", 'a', 'simple', 'text.']

大多数常见问题都可以很容易地使用这个类来解决,所以我建议你学习如何编写可以匹配特定模式的简单正则表达式。例如,我们可以从句子中移除所有数字、逗号和其他标点符号:

>>> complex_text = 'This isn\'t a simple text. Count 1, 2, 3 and then go!'

>>> ret = RegexpTokenizer('[a-zA-Z\']+')
>>> print(ret.tokenize(complex_text))
['This', "isn't", 'a', 'simple', 'text', 'Count', 'and', 'the', 'go']

即使 NLTK 提供了其他类,它们也总是可以通过自定义的RegexpTokenizer来实现,这个类足够强大,可以解决几乎每一个特定问题;因此,我更喜欢不深入这个讨论。

停用词移除

停用词是正常言语的一部分(如冠词、连词等),但它们的出现频率非常高,并且不提供任何有用的语义信息。因此,过滤句子和语料库时移除它们是一个好的做法。NLTK 提供了最常见语言的停用词列表,并且其使用是直接的:

from nltk.corpus import stopwords

>>> sw = set(stopwords.words('english'))

下面的代码片段显示了英语停用词的一个子集:

>>> print(sw)
{u'a',
 u'about',
 u'above',
 u'after',
 u'again',
 u'against',
 u'ain',
 u'all',
 u'am',
 u'an',
 u'and',
 u'any',
 u'are',
 u'aren',
 u'as',
 u'at',
 u'be', ...

要过滤一个句子,可以采用一种功能性的方法:

>>> complex_text = 'This isn\'t a simple text. Count 1, 2, 3 and then go!'

>>> ret = RegexpTokenizer('[a-zA-Z\']+')
>>> tokens = ret.tokenize(complex_text)
>>> clean_tokens = [t for t in tokens if t not in sw]
>>> print(clean_tokens)
['This', "isn't", 'simple', 'text', 'Count', 'go']

语言检测

停用词,像其他重要特征一样,与特定语言密切相关,因此在进行任何其他步骤之前,通常有必要检测语言。由langdetect库提供的一个简单、免费且可靠的解决方案,该库已从谷歌的语言检测系统移植过来。其使用是直接的:

from langdetect import detect

>>> print(detect('This is English'))
en

>>> print(detect('Dies ist Deutsch'))
de

该函数返回 ISO 639-1 代码(en.wikipedia.org/wiki/List_of_ISO_639-1_codes),这些代码可以用作字典中的键来获取完整的语言名称。当文本更复杂时,检测可能更困难,了解是否存在任何歧义是有用的。可以通过detect_langs()方法获取预期语言的概率:

from langdetect import detect_langs

>>> print(detect_langs('I really love you mon doux amour!'))
[fr:0.714281321163, en:0.285716747181]

可以使用 pip 安装 langdetect(pip install --upgrade langdetect)。更多信息可在pypi.python.org/pypi/langdetect找到。

词干提取

词干提取是一个将特定单词(如动词或复数形式)转换为它们的根形式的过程,以便在不增加唯一标记数量的情况下保留语义。例如,如果我们考虑三个表达式“我跑”、“他跑”和“跑步”,它们可以被简化为一个有用的(尽管语法上不正确)形式:“我跑”、“他跑”、“跑”。这样,我们就有一个定义相同概念(“跑”)的单个标记,在聚类或分类目的上,可以无任何精度损失地使用。NLTK 提供了许多词干提取器的实现。最常见(且灵活)的是基于多语言算法的SnowballStemmer

from nltk.stem.snowball import SnowballStemmer

>>> ess = SnowballStemmer('english', ignore_stopwords=True)
>>> print(ess.stem('flies'))
fli

>>> fss = SnowballStemmer('french', ignore_stopwords=True)
>>> print(fss.stem('courais'))
cour

ignore_stopwords参数通知词干提取器不要处理停用词。其他实现包括PorterStemmerLancasterStemmer。结果通常相同,但在某些情况下,词干提取器可以实施更选择性的规则。例如:

from nltk.stem.snowball import PorterStemmer
from nltk.stem.lancaster import LancasterStemmer

>>> print(ess.stem('teeth'))
teeth

>>> ps = PorterStemmer()
>>> print(ps.stem('teeth'))
teeth

>>> ls = LancasterStemmer()
>>> print(ls.stem('teeth'))
tee

如您所见,Snowball 和 Porter 算法保持单词不变,而 Lancaster 算法提取一个根(这在语义上是无意义的)。另一方面,后者算法实现了许多特定的英语规则,这实际上可以减少唯一标记的数量:

>>> print(ps.stem('teen'))
teen

>>> print(ps.stem('teenager'))
teenag

>>> print(ls.stem('teen'))
teen

>>> print(ls.stem('teenager'))
teen

不幸的是,Porter 和 Lancaster 词干提取器在 NLTK 中仅适用于英语;因此,默认选择通常是 Snowball,它在许多语言中都可用,并且可以与适当的停用词集一起使用。

向量化

这是词袋模型管道的最后一步,它是将文本标记转换为数值向量的必要步骤。最常见的技术是基于计数或频率计算,它们都在 scikit-learn 中可用,以稀疏矩阵表示(考虑到许多标记只出现几次而向量必须有相同的长度,这是一个可以节省大量空间的选择)。

计数向量化

该算法非常简单,它基于考虑一个标记在文档中出现的次数来表示一个标记。当然,整个语料库必须被处理,以确定有多少唯一的标记及其频率。让我们看看CountVectorizer类在简单语料库上的一个例子:

from sklearn.feature_extraction.text import CountVectorizer

>>> corpus = [
 'This is a simple test corpus',
 'A corpus is a set of text documents',
 'We want to analyze the corpus and the documents',
 'Documents can be automatically tokenized'
]

>>> cv = CountVectorizer()
>>> vectorized_corpus = cv.fit_transform(corpus)
>>> print(vectorized_corpus.todense())
[[0 0 0 0 0 1 0 1 0 0 1 1 0 0 1 0 0 0 0]
 [0 0 0 0 0 1 1 1 1 1 0 0 1 0 0 0 0 0 0]
 [1 1 0 0 0 1 1 0 0 0 0 0 0 2 0 1 0 1 1]
 [0 0 1 1 1 0 1 0 0 0 0 0 0 0 0 0 1 0 0]]

如您所见,每个文档都已转换为固定长度的向量,其中 0 表示相应的标记不存在,而正数表示出现的次数。如果我们需要排除所有文档频率低于预定义值的标记,我们可以通过参数min_df(默认值为 1)来设置它。有时避免非常常见的术语可能是有用的;然而,下一个策略将以更可靠和完整的方式解决这个问题。

词汇表可以通过实例变量vocabulary_访问:

>>> print(cv.vocabulary_)
{u'and': 1, u'be': 3, u'we': 18, u'set': 9, u'simple': 10, u'text': 12, u'is': 7, u'tokenized': 16, u'want': 17, u'the': 13, u'documents': 6, u'this': 14, u'of': 8, u'to': 15, u'can': 4, u'test': 11, u'corpus': 5, u'analyze': 0, u'automatically': 2}

给定一个通用向量,可以通过逆变换检索相应的标记列表:

>>> vector = [0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0, 0, 1, 1]
>>> print(cv.inverse_transform(vector))
[array([u'corpus', u'is', u'simple', u'test', u'this', u'want', u'we'], 
 dtype='<U13')]

这两种方法都可以使用外部分词器(通过参数 tokenizer),可以通过前几节中讨论的技术进行自定义:

>>> ret = RegexpTokenizer('[a-zA-Z0-9\']+')
>>> sw = set(stopwords.words('english'))
>>> ess = SnowballStemmer('english', ignore_stopwords=True)

>>> def tokenizer(sentence):
>>>    tokens = ret.tokenize(sentence)
>>>    return [ess.stem(t) for t in tokens if t not in sw]

>>> cv = CountVectorizer(tokenizer=tokenizer)
>>> vectorized_corpus = cv.fit_transform(corpus)
>>> print(vectorized_corpus.todense())
[[0 0 1 0 0 1 1 0 0 0]
 [0 0 1 1 1 0 0 1 0 0]
 [1 0 1 1 0 0 0 0 0 1]
 [0 1 0 1 0 0 0 0 1 0]]

使用我们的分词器(使用停用词和词干提取),词汇表更短,向量也更短。

N-grams

到目前为止,我们只考虑了单个标记(也称为单语素),但在许多情况下,考虑单词的短序列(双词组或三词组)作为我们的分类器的原子是有用的,就像所有其他标记一样。例如,如果我们正在分析某些文本的情感,考虑双词组如 pretty goodvery bad 等可能是个好主意。从语义角度来看,实际上,考虑不仅仅是副词,而是整个复合形式很重要。我们可以向向量器告知我们想要考虑的 n-grams 范围。例如,如果我们需要单语素和双语素,我们可以使用以下代码片段:

>>> cv = CountVectorizer(tokenizer=tokenizer, ngram_range=(1, 2))
>>> vectorized_corpus = cv.fit_transform(corpus)
>>> print(vectorized_corpus.todense())
[[0 0 0 0 0 1 0 1 0 0 1 1 0 0 1 0 0 0 0]
 [0 0 0 0 0 1 1 1 1 1 0 0 1 0 0 0 0 0 0]
 [1 1 0 0 0 1 1 0 0 0 0 0 0 2 0 1 0 1 1]
 [0 0 1 1 1 0 1 0 0 0 0 0 0 0 0 0 1 0 0]]

>>> print(cv.vocabulary_)
{u'and': 1, u'be': 3, u'we': 18, u'set': 9, u'simple': 10, u'text': 12, u'is': 7, u'tokenized': 16, u'want': 17, u'the': 13, u'documents': 6, u'this': 14, u'of': 8, u'to': 15, u'can': 4, u'test': 11, u'corpus': 5, u'analyze': 0, u'automatically': 2}

如您所见,词汇表现在包含了双词组,并且向量包括了它们的相对频率。

Tf-idf 向量化

计数向量化的最常见限制是,算法在考虑每个标记的频率时没有考虑整个语料库。向量化的目标通常是准备数据供分类器使用;因此,避免非常常见的特征是必要的,因为当全局出现次数增加时,它们的信息量会减少。例如,在一个关于体育的语料库中,单词 match 可能会在大量文档中出现;因此,它几乎作为一个分类特征是无用的。为了解决这个问题,我们需要不同的方法。如果我们有一个包含 n 个文档的语料库 C,我们定义词频,即一个标记在文档中出现的次数,如下所示:

图片

我们定义逆文档频率,如下所示:

图片

换句话说,idf(t,C) 衡量每个单个术语提供的信息量。实际上,如果 count(D,t) = n,这意味着一个标记总是存在,且 idf(t, C) 接近 0,反之亦然。分母中的术语 1 是一个校正因子,它避免了当 (D,t) = n 时的 null idf。因此,我们不仅考虑术语频率,还通过定义一个新的度量来权衡每个标记:

图片

scikit-learn 提供了 TfIdfVectorizer 类,我们可以将其应用于前一段中使用的相同玩具语料库:

>>> from sklearn.feature_extraction.text import TfidfVectorizer

>>> tfidfv = TfidfVectorizer()
>>> vectorized_corpus = tfidfv.fit_transform(corpus)
>>> print(vectorized_corpus.todense())
[[ 0\.          0\.          0\.          0\.          0\.          0.31799276
 0\.          0.39278432  0\.          0\.          0.49819711  0.49819711
 0\.          0\.          0.49819711  0\.          0\.          0\.          0\.        ]
 [ 0\.          0\.          0\.          0\.          0\.          0.30304005
 0.30304005  0.37431475  0.4747708   0.4747708   0\.          0.
 0.4747708   0\.          0\.          0\.          0\.          0\.          0\.        ]
 [ 0.31919701  0.31919701  0\.          0\.          0\.          0.20373932
 0.20373932  0\.          0\.          0\.          0\.          0\.          0.
 0.63839402  0\.          0.31919701  0\.          0.31919701  0.31919701]
 [ 0\.          0\.          0.47633035  0.47633035  0.47633035  0.
 0.30403549  0\.          0\.          0\.          0\.          0\.          0.
 0\.          0\.          0\.          0.47633035  0\.          0\.        ]]

现在我们检查词汇表,以便与简单的计数向量化进行比较:

>>> print(tfidfv.vocabulary_)
{u'and': 1, u'be': 3, u'we': 18, u'set': 9, u'simple': 10, u'text': 12, u'is': 7, u'tokenized': 16, u'want': 17, u'the': 13, u'documents': 6, u'this': 14, u'of': 8, u'to': 15, u'can': 4, u'test': 11, u'corpus': 5, u'analyze': 0, u'automatically': 2}

术语documents在两个向量器中都是第六个特征,并且出现在最后三个文档中。正如你所看到的,它的权重大约是 0.3,而术语the只在第三个文档中出现了两次,其权重大约是 0.64。一般规则是:如果一个术语代表一个文档,那么它的权重会接近 1.0,而如果在一个样本文档中找到它不能轻易确定其类别,那么它的权重会降低。

在这个情况下,也可以使用外部标记化器并指定所需的 n-gram 范围。此外,还可以通过参数norm对向量进行归一化,并决定是否将 1 添加到 idf 分母中(通过参数smooth_idf)。还可以使用参数min_dfmax_df定义接受的文档频率范围,以便排除出现次数低于或高于最小/最大阈值的标记。它们接受整数(出现次数)或范围在[0.0, 1.0]内的浮点数(文档比例)。在下一个示例中,我们将使用这些参数中的一些:

>>> tfidfv = TfidfVectorizer(tokenizer=tokenizer, ngram_range=(1, 2), norm='l2')
>>> vectorized_corpus = tfidfv.fit_transform(corpus)
>>> print(vectorized_corpus.todense())
[[ 0\.          0\.          0\.          0\.          0.30403549  0\.          0.
 0\.          0\.          0\.          0\.          0.47633035  0.47633035
 0.47633035  0.47633035  0\.          0\.          0\.          0\.          0\.        ]
 [ 0\.          0\.          0\.          0\.          0.2646963   0.
 0.4146979   0.2646963   0\.          0.4146979   0.4146979   0\.          0.
 0\.          0\.          0.4146979   0.4146979   0\.          0\.          0\.        ]
 [ 0.4146979   0.4146979   0\.          0\.          0.2646963   0.4146979
 0\.          0.2646963   0\.          0\.          0\.          0\.          0.
 0\.          0\.          0\.          0\.          0\.          0.4146979
 0.4146979 ]
 [ 0\.          0\.          0.47633035  0.47633035  0\.          0\.          0.
 0.30403549  0.47633035  0\.          0\.          0\.          0\.          0.
 0\.          0\.          0\.          0.47633035  0\.          0\.        ]]

>>> print(tfidfv.vocabulary_)
{u'analyz corpus': 1, u'set': 9, u'simpl test': 12, u'want analyz': 19, u'automat': 2, u'want': 18, u'test corpus': 14, u'set text': 10, u'corpus set': 6, u'automat token': 3, u'corpus document': 5, u'text document': 16, u'token': 17, u'document automat': 8, u'text': 15, u'test': 13, u'corpus': 4, u'document': 7, u'simpl': 11, u'analyz': 0}

特别是,如果向量必须作为分类器的输入,那么归一化向量总是一个好的选择,正如我们将在下一章中看到的。

基于路透社语料库的文本分类器示例

我们将基于 NLTK 路透社语料库构建一个文本分类器示例。这个分类器由成千上万的新闻行组成,分为 90 个类别:

from nltk.corpus import reuters

>>> print(reuters.categories())
[u'acq', u'alum', u'barley', u'bop', u'carcass', u'castor-oil', u'cocoa', u'coconut', u'coconut-oil', u'coffee', u'copper', u'copra-cake', u'corn', u'cotton', u'cotton-oil', u'cpi', u'cpu', u'crude', u'dfl', u'dlr', u'dmk', u'earn', u'fuel', u'gas', u'gnp', u'gold', u'grain', u'groundnut', u'groundnut-oil', u'heat', u'hog', u'housing', u'income', u'instal-debt', u'interest', u'ipi', u'iron-steel', u'jet', u'jobs', u'l-cattle', u'lead', u'lei', u'lin-oil', u'livestock', u'lumber', u'meal-feed', u'money-fx', u'money-supply', u'naphtha', u'nat-gas', u'nickel', u'nkr', u'nzdlr', u'oat', u'oilseed', u'orange', u'palladium', u'palm-oil', u'palmkernel', u'pet-chem', u'platinum', u'potato', u'propane', u'rand', u'rape-oil', u'rapeseed', u'reserves', u'retail', u'rice', u'rubber', u'rye', u'ship', u'silver', u'sorghum', u'soy-meal', u'soy-oil', u'soybean', u'strategic-metal', u'sugar', u'sun-meal', u'sun-oil', u'sunseed', u'tea', u'tin', u'trade', u'veg-oil', u'wheat', u'wpi', u'yen', u'zinc']

为了简化过程,我们将只选取两个具有相似文档数量的类别:

import numpy as np

>>> Xr = np.array(reuters.sents(categories=['rubber']))
>>> Xc = np.array(reuters.sents(categories=['cotton']))
>>> Xw = np.concatenate((Xr, Xc))

由于每个文档已经被分割成标记,并且我们想要应用我们的自定义标记化器(带有停用词去除和词干提取),我们需要重建完整的句子:

>>> X = []

>>> for document in Xw:
>>>    X.append(' '.join(document).strip().lower())

现在我们需要准备标签向量,将rubber分配为 0,将cotton分配为 1:

>>> Yr = np.zeros(shape=Xr.shape)
>>> Yc = np.ones(shape=Xc.shape)
>>> Y = np.concatenate((Yr, Yc))

在这一点上,我们可以对语料库进行向量化:

>>> tfidfv = TfidfVectorizer(tokenizer=tokenizer, ngram_range=(1, 2), norm='l2')
>>> Xv = tfidfv.fit_transform(X)

现在数据集已经准备好了,我们可以通过将其分为训练集和测试集来继续,并最终训练我们的分类器。我决定采用随机森林,因为它特别适合这类任务,但读者可以尝试不同的分类器并比较结果:

from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestClassifier

>>> X_train, X_test, Y_train, Y_test = train_test_split(Xv, Y, test_size=0.25)

>>> rf = RandomForestClassifier(n_estimators=25)
>>> rf.fit(X_train, Y_train)
>>> score = rf.score(X_test, Y_test)
>>> print('Score: %.3f' % score)
Score: 0.874

得分大约是 88%,这是一个相当好的结果,但让我们尝试用一条假新闻进行预测:

>>> test_newsline = ['Trading tobacco is reducing the amount of requests for cotton and this has a negative impact on our economy']

>>> yvt = tfidfv.transform(test_newsline)
>>> category = rf.predict(yvt)
>>> print('Predicted category: %d' % int(category[0]))
Predicted category: 1

分类结果正确;然而,通过采用我们将在下一章中讨论的一些技术,在更复杂的现实生活问题中也可以获得更好的性能。

参考文献

  1. Perkins J.,《Python 3 文本处理与 NLTK 3 烹饪书》,Packt。

  2. Hardeniya N.,《NLTK 基础》,Packt

  3. Bonaccorso G.,《BBC 新闻分类算法比较》,github.com/giuseppebonaccorso/bbc_news_classification_comparison

摘要

在本章中,我们讨论了所有基本的自然语言处理技术,从语料库的定义开始,直到最终将其转换为特征向量。我们分析了不同的分词方法,以解决将文档分割成单词的特定问题或情况。然后我们介绍了一些过滤技术,这些技术是必要的,以去除所有无用的元素(也称为停用词)并将屈折形式转换为标准标记。

这些步骤对于通过去除常用术语来增加信息内容非常重要。当文档被成功清理后,可以使用简单的方法如计数向量器实现的方法,或者更复杂的方法,如考虑术语全局分布的方法,例如 tf-idf。后者是为了补充词干处理阶段的工作;实际上,它的目的是定义向量,其中每个分量在信息量高时接近 1,反之亦然。通常,一个在许多文档中都存在的单词不是一个好的分类器标记;因此,如果在前面的步骤中没有被去除,tf-idf 将自动降低其权重。在本章结束时,我们构建了一个简单的文本分类器,该分类器实现了整个词袋模型管道,并使用随机森林对新闻行进行分类。

在下一章中,我们将通过简要讨论高级技术,如主题建模、潜在语义分析和情感分析,来完成这个介绍。

第十三章:自然语言处理中的主题建模和情感分析

在本章中,我们将介绍一些常见的主题建模方法,并讨论一些应用。主题建模是自然语言处理的一个重要部分,其目的是从文档语料库中提取语义信息。我们将讨论潜在语义分析,这是最著名的方法之一;它基于已经讨论过的基于模型的推荐系统的相同哲学。我们还将讨论其概率变体 PLSA,它旨在构建一个没有先验分布假设的潜在因子概率模型。另一方面,潜在狄利克雷分配是一种类似的方法,它假设潜在变量具有先验狄利克雷分布。在最后一节中,我们将通过基于 Twitter 数据集的具体示例来讨论情感分析。

主题建模

自然语言处理中主题建模的主要目标是分析语料库,以识别文档之间的共同主题。在这种情况下,即使我们谈论语义,这个概念也有一个特定的含义,它是由一个非常重要的假设驱动的。一个主题来源于同一文档中特定术语的使用,并且通过多个不同文档中第一个条件成立来得到证实。

换句话说,我们不考虑面向人类的语义,而是一种与有意义的文档一起工作的统计模型(这保证了术语的使用旨在表达特定的概念,因此,它们背后有人的语义目的)。因此,我们所有方法的起点都是一个发生矩阵,通常定义为文档-词矩阵(我们已经在第十二章,《自然语言处理导论》中讨论了计数向量化 tf-idf):

图片

在许多论文中,这个矩阵是转置的(它是一个词-文档矩阵);然而,scikit-learn 生成文档-词矩阵,为了避免混淆,我们将考虑这种结构。

潜在语义分析

潜在语义分析背后的思想是将M[dw]分解,以提取一组潜在变量(这意味着我们可以假设它们的存在,但它们不能直接观察到)。正如在第十一章,《推荐系统导论》中讨论的那样,一个非常常见的分解方法是 SVD:

图片

然而,我们并不对完全分解感兴趣;我们只对由前k个奇异值定义的子空间感兴趣:

图片

这个近似在考虑 Frobenius 范数的情况下享有最佳声誉,因此它保证了非常高的精度。当将其应用于文档-词矩阵时,我们得到以下分解:

图片

或者,以更紧凑的方式:

图片

这里,第一个矩阵定义了文档和 k 个潜在变量之间的关系,第二个则定义了 k 个潜在变量和单词之间的关系。考虑到原始矩阵的结构以及本章开头所解释的内容,我们可以将潜在变量视为主题,它们定义了一个子空间,文档被投影到这个子空间中。现在,一个通用的文档可以这样定义:

图片

此外,每个主题都成为单词的线性组合。由于许多单词的权重接近于零,我们可以决定只取前 r 个单词来定义一个主题;因此,我们得到:

图片

在这里,每个 h[ji] 都是在对 M[twk] 的列进行排序后得到的。为了更好地理解这个过程,让我们基于布朗语料库的一个子集(来自 news 类别的 500 篇文档)展示一个完整的示例:

from nltk.corpus import brown

>>> sentences = brown.sents(categories=['news'])[0:500]
>>> corpus = []

>>> for s in sentences:
>>>   corpus.append(' '.join(s))

在定义语料库之后,我们需要使用 tf-idf 方法进行分词和向量化:

from sklearn.feature_extraction.text import TfidfVectorizer

>>> vectorizer = TfidfVectorizer(strip_accents='unicode', stop_words='english', norm='l2', sublinear_tf=True)
>>> Xc = vectorizer.fit_transform(corpus).todense()

现在可以对 Xc 矩阵应用 SVD(记住在 SciPy 中,V 矩阵已经转置):

from scipy.linalg import svd

>>> U, s, V = svd(Xc, full_matrices=False)

由于语料库不是非常小,设置参数 full_matrices=False 以节省计算时间是很有用的。我们假设有两个主题,因此我们可以提取我们的子矩阵:

import numpy as np

>>> rank = 2

>>> Uk = U[:, 0:rank]
>>> sk = np.diag(s)[0:rank, 0:rank]
>>> Vk = V[0:rank, :]

如果我们想要分析每个主题的前 10 个单词,我们需要考虑以下几点:

图片

因此,我们可以通过使用向量器提供的 get_feature_names() 方法对矩阵进行排序后,获得每个主题的最显著单词:

>>> Mtwks = np.argsort(Vk, axis=1)[::-1]

>>> for t in range(rank):
>>>   print('\nTopic ' + str(t))
>>>     for i in range(10):
>>>        print(vectorizer.get_feature_names()[Mtwks[t, i]])

Topic 0
said
mr
city
hawksley
president
year
time
council
election
federal

Topic 1
plainfield
wasn
copy
released
absence
africa
clash
exacerbated
facing
difficulties

在这种情况下,我们只考虑矩阵 Vk 中的非负值;然而,由于主题是单词的混合,负成分也应该被考虑。在这种情况下,我们需要对 Vk 的绝对值进行排序:

>>> Mtwks = np.argsort(np.abs(Vk), axis=1)[::-1]

如果我们想要分析一个文档在这个子空间中的表示,我们必须使用:

图片

例如,让我们考虑我们语料库的第一个文档:

>>> print(corpus[0])
The Fulton County Grand Jury said Friday an investigation of Atlanta's recent primary election produced `` no evidence '' that any irregularities took place .

>>> Mdtk = Uk.dot(sk)

>>> print('d0 = %.2f*t1 + %.2f*t2' % (Mdtk[0][0], Mdtk[0][1]))
d0 = 0.15*t1 + -0.12*t2

由于我们正在处理一个二维空间,绘制每个文档对应的所有点是有趣的:

图片

在前面的图中,我们可以看到许多文档是相关的,有一个小的异常值组。这可能是由于我们选择两个主题的限制性。如果我们使用布朗语料库的两个类别(newsfiction)重复相同的实验,我们会观察到不同的行为:

sentences = brown.sents(categories=['news', 'fiction'])
corpus = []

for s in sentences:
 corpus.append(' '.join(s))

我不再重复剩余的计算,因为它们是相似的。(唯一的区别是,我们的语料库现在相当大,这导致计算时间更长。因此,我们将讨论一个替代方案,它要快得多。)绘制文档对应的点,我们现在得到:

图片

现在更容易区分两组,它们几乎是正交的(这意味着许多文档只属于一个类别)。我建议使用不同的语料库和秩重复这个实验。不幸的是,不可能绘制超过三个维度,但总是可以使用仅数值计算来检查子空间是否正确描述了潜在的语义。

如预期的那样,当发生矩阵很大时,标准的 SciPy SVD 实现可能会非常慢;然而,scikit-learn 提供了一个截断 SVD 实现,TruncatedSVD,它仅与子空间一起工作。结果是速度更快(它还可以直接管理稀疏矩阵)。让我们使用这个类重复之前的实验(使用完整的语料库):

from sklearn.decomposition import TruncatedSVD

>>> tsvd = TruncatedSVD(n_components=rank)
>>> Xt = tsvd.fit_transform(Xc)

通过n_components参数,可以设置所需的秩,丢弃矩阵的其余部分。在拟合模型后,我们可以直接将文档-主题矩阵M[dtk]作为fit_transform()方法的输出获得,而主题-单词矩阵M[twk]可以通过实例变量components_访问:

>>> Mtws = np.argsort(tsvd.components_, axis=1)[::-1]

>>> for t in range(rank):
>>>    print('\nTopic ' + str(t))
>>>       for i in range(10):
>>>          print(vectorizer.get_feature_names()[Mwts[t, i]])

Topic 0
said
rector
hans
aloud
liston
nonsense
leave
whiskey
chicken
fat

Topic 1
bong
varnessa
schoolboy
kaboom
keeeerist
aggravated
jealous
hides
mayonnaise
fowl

读者可以验证这个过程可以有多快;因此,我建议只有在需要访问完整矩阵时才使用标准的 SVD 实现。不幸的是,正如文档中所述,这种方法对算法和随机状态非常敏感。它还受到称为符号不确定性的现象的影响,这意味着如果使用不同的随机种子,所有组件的符号都可能改变。我建议你声明:

import numpy as np

np.random.seed(1234)

在每个文件的开始处使用固定的种子(即使是 Jupyter 笔记本)以确保可以重复计算并始终获得相同的结果。

此外,我建议使用非负矩阵分解重复这个实验,如第三章所述,特征选择与特征工程

概率潜在语义分析

之前的模型是基于确定性方法,但也可以在由文档和单词确定的范围内定义一个概率模型。在这种情况下,我们不对 Apriori 概率做出任何假设(这将在下一个方法中完成),我们将确定最大化我们模型对数似然参数的参数。特别是,考虑以下图中所示的板符号(如果你想了解更多关于这种技术的信息,请阅读en.wikipedia.org/wiki/Plate_notation):

图片

我们假设我们有一个包含 m 个文档的语料库,并且每个文档由 n 个单词组成(这两个元素都是观察到的,因此用灰色圆圈表示);然而,我们还假设存在一组有限的 k 个共同潜在因子(主题),它们将文档与一组单词联系起来(由于它们没有被观察到,圆圈是白色的)。正如已经写过的,我们无法直接观察到它们,但我们允许假设它们的存在。

找到具有特定单词的文档的联合概率是:

图片

因此,在引入潜在因子后,找到特定文档中单词的条件概率可以写成:

图片

初始联合概率 P(d, w) 也可以用潜在因子表示:

图片

这包括先验概率 P(t)。由于我们不想处理它,因此使用表达式 P(w|d) 更为可取。为了确定两个条件概率分布,一个常见的方法是 期望最大化EM)策略。完整的描述可以在 Hofmann T.的《基于概率潜在语义分析的无监督学习》,机器学习 42,177-196,2001,Kluwer 学术出版社中找到。*在此上下文中,我们只展示最终结果,而不提供任何证明。

对数似然可以写成:

图片

变成:

图片

M[dw] 是一个出现矩阵(通常通过计数向量器获得),而 Mdw 是单词 w 在文档 d 中的频率。为了简化,我们将通过排除第一个项(它不依赖于 t[k])来近似它:

图片

此外,引入条件概率 P(t|d,w) 也是有用的,它是给定文档和单词的主题概率。EM 算法在后验概率 P(t|d,w) 下最大化期望的完整对数似然:

图片

算法的 E 阶段可以表示为:

图片

必须扩展到所有主题、单词和文档,并且必须按主题求和进行归一化,以确保始终具有一致的概率。

M 阶段分为两个计算:

图片

在这个情况下,计算必须扩展到所有主题、单词和文档。但在第一种情况下,我们是按文档求和并按单词和文档进行归一化,而在第二种情况下,我们是按单词求和并按文档长度进行归一化。

算法必须迭代,直到对数似然停止增加其幅度。不幸的是,scikit-learn 没有提供 PLSA 实现(也许是因为下一个策略 LDA 被认为更强大和高效),因此我们需要从头编写一些代码。让我们首先定义布朗语料库的一个小子集,从editorial类别中取 10 个句子,从fiction类别中取 10 个:

>>> sentences_1 = brown.sents(categories=['editorial'])[0:10]
>>> sentences_2 = brown.sents(categories=['fiction'])[0:10]
>>> corpus = []

>>> for s in sentences_1 + sentences_2:
>>>    corpus.append(' '.join(s))

现在我们可以使用CountVectorizer类进行向量化:

import numpy as np

from sklearn.feature_extraction.text import CountVectorizer

>>> cv = CountVectorizer(strip_accents='unicode', stop_words='english')
>>> Xc = np.array(cv.fit_transform(corpus).todense())

在这一点上,我们可以定义排名(为了简单起见,我们选择 2),两个稍后将要使用的常数,以及用于存储概率 P(t|d)P(w|t)P(t|d,w) 的矩阵:

>>> rank = 2
>>> alpha_1 = 1000.0
>>> alpha_2 = 10.0

>>> Ptd = np.random.uniform(0.0, 1.0, size=(len(corpus), rank))
>>> Pwt = np.random.uniform(0.0, 1.0, size=(rank, len(cv.vocabulary_)))
>>> Ptdw = np.zeros(shape=(len(cv.vocabulary_), len(corpus), rank))

>>> for d in range(len(corpus)):
>>>    nf = np.sum(Ptd[d, :])
>>>    for t in range(rank):
>>>       Ptd[d, t] /= nf

>>> for t in range(rank):
>>>    nf = np.sum(Pwt[t, :])
>>>    for w in range(len(cv.vocabulary_)):
>>>       Pwt[t, w] /= nf

两个矩阵 P(t|d)P(w|t) 必须归一化,以便与算法保持一致;另一个初始化为零。现在我们可以定义对数似然函数:

>>> def log_likelihood():
>>>    value = 0.0
>>> 
>>>    for d in range(len(corpus)):
>>>       for w in range(len(cv.vocabulary_)):
>>>          real_topic_value = 0.0
>>>
>>>          for t in range(rank):
>>>             real_topic_value += Ptd[d, t] * Pwt[t, w]
>>>
>>>          if real_topic_value > 0.0:
>>>             value += Xc[d, w] * np.log(real_topic_value)
>>> 
>>>    return value

最后,期望最大化函数:

>>> def expectation():
>>>    global Ptd, Pwt, Ptdw
>>>
>>>    for d in range(len(corpus)):
>>>       for w in range(len(cv.vocabulary_)):
>>>          nf = 0.0
>>> 
>>>          for t in range(rank):
>>>             Ptdw[w, d, t] = Ptd[d, t] * Pwt[t, w]
>>>             nf += Ptdw[w, d, t]
>>> 
>>>          Ptdw[w, d, :] = (Ptdw[w, d, :] / nf) if nf != 0.0 else 0.0

在前面的函数中,当归一化因子为 0 时,每个主题的概率 P(t|w, d) 被设置为 0.0:

>>> def maximization():
>>>    global Ptd, Pwt, Ptdw
>>>
>>>    for t in range(rank):
>>>       nf = 0.0
>>> 
>>>       for d in range(len(corpus)):
>>>          ps = 0.0
>>> 
>>>          for w in range(len(cv.vocabulary_)):
>>>             ps += Xc[d, w] * Ptdw[w, d, t]
>>> 
>>>          Pwt[t, w] = ps
>>>          nf += Pwt[t, w]
>>>
>>>       Pwt[:, w] /= nf if nf != 0.0 else alpha_1
>>>
>>>    for d in range(len(corpus)):
>>>       for t in range(rank):
>>>          ps = 0.0
>>>          nf = 0.0
>>>
>>>          for w in range(len(cv.vocabulary_)):
>>>             ps += Xc[d, w] * Ptdw[w, d, t]
>>>             nf += Xc[d, w]
>>> 
>>>          Ptd[d, t] = ps / (nf if nf != 0.0 else alpha_2)

当归一化因子变为 0 时,常数 alpha_1alpha_2 被使用。在这种情况下,分配一个小的概率值可能是有用的;因此,我们为这些常数除以分子。我建议尝试不同的值,以便调整算法以适应不同的任务。

在这一点上,我们可以尝试我们的算法,限制迭代次数:

>>> print('Initial Log-Likelihood: %f' % log_likelihood())

>>> for i in range(50):
>>>    expectation()
>>>    maximization()
>>>    print('Step %d - Log-Likelihood: %f' % (i, log_likelihood()))

Initial Log-Likelihood: -1242.878549
Step 0 - Log-Likelihood: -1240.160748
Step 1 - Log-Likelihood: -1237.584194
Step 2 - Log-Likelihood: -1236.009227
Step 3 - Log-Likelihood: -1234.993974
Step 4 - Log-Likelihood: -1234.318545
Step 5 - Log-Likelihood: -1233.864516
Step 6 - Log-Likelihood: -1233.559474
Step 7 - Log-Likelihood: -1233.355097
Step 8 - Log-Likelihood: -1233.218306
Step 9 - Log-Likelihood: -1233.126583
Step 10 - Log-Likelihood: -1233.064804
Step 11 - Log-Likelihood: -1233.022915
Step 12 - Log-Likelihood: -1232.994274
Step 13 - Log-Likelihood: -1232.974501
Step 14 - Log-Likelihood: -1232.960704
Step 15 - Log-Likelihood: -1232.950965
...

在第 30 步之后,可以验证收敛性。在这个时候,我们可以检查每个主题按主题权重降序排列的 P(w|t) 条件分布的前五项单词:

>>> Pwts = np.argsort(Pwt, axis=1)[::-1]

>>> for t in range(rank):
>>>    print('\nTopic ' + str(t))
>>>       for i in range(5):
>>>          print(cv.get_feature_names()[Pwts[t, i]])

Topic 0
years
questions
south
reform
social

Topic 1
convened
maintenance
penal
year
legislators

潜在狄利克雷分配

在先前的方法中,我们没有对主题先验分布做出任何假设,这可能导致限制,因为算法没有受到任何现实世界直觉的驱动。相反,LDA 基于这样的观点:一个主题由一组重要的单词组成,通常一个文档不会涵盖许多主题。因此,主要假设是先验主题分布是对称的狄利克雷分布。概率密度函数定义为:

如果浓度参数 alpha 小于 1.0,分布将是稀疏的,正如所期望的那样。这允许我们模拟主题-文档和主题-单词分布,这些分布将始终集中在少数几个值上。这样我们就可以避免以下情况:

  • 分配给文档的主题混合可能会变得平坦(许多主题具有相似权重)

  • 考虑到单词集合,一个主题的结构可能会变得类似于背景(实际上,只有有限数量的单词必须是重要的;否则语义边界会变得模糊)。

使用板状符号,我们可以表示文档、主题和单词之间的关系,如下面的图所示:

在前面的图中,alpha 是主题-文档分布的 Dirichlet 参数,而 gamma 在主题-单词分布中具有相同的作用。Theta 相反,是特定文档的主题分布,而 beta 是特定单词的主题分布。

如果我们有一个包含 m 个文档和 n 个单词(每个文档有 n[i] 个单词)的语料库,并且我们假设有 k 个不同的主题,生成算法可以用以下步骤描述:

  • 对于每个文档,从主题-文档分布中抽取一个样本(一个主题混合):

图片

  • 对于每个主题,从主题-单词分布中抽取一个样本:

图片

必须估计两个参数。在此阶段,考虑到发生矩阵 M[dw] 和表示 m-th 文档中 n-th 单词所分配的主题的符号 z[mn],我们可以遍历文档(索引 d)和单词(索引 w):

  • 根据文档 d 和单词 w 选择一个主题:

图片

  • 根据以下标准选择一个单词:

图片

在这两种情况下,一个分类分布是一个单次多项式分布。参数估计的完整描述相当复杂,超出了本书的范围;然而,主要问题是找到潜在变量的分布:

图片

读者可以在 Blei D.,Ng A.,Jordan M.,潜在狄利克雷分配,《机器学习研究杂志》,3,(2003) 993-1022 中找到更多信息。然而,LDA 和 PLSA 之间一个非常重要的区别是 LDA 的生成能力,它允许处理未见过的文档。实际上,PLSA 训练过程只为语料库找到最优参数 p(t|d),而 LDA 采用随机变量。可以通过定义 theta(一个主题混合)的概率为与一组主题和一组单词的联合,并给定模型参数来理解这个概念:

图片

如前所述的论文所示,给定模型参数的文档(一组单词)的条件概率可以通过积分获得:

图片

这个表达式显示了 PLSA 和 LDA 之间的区别。一旦学习到 p(t|d),PLSA 就无法泛化,而 LDA 通过从随机变量中采样,总能找到一个适合未见文档的合适主题混合。

scikit-learn 通过 LatentDirichletAllocation 类提供了一个完整的 LDA 实现。我们将使用从布朗语料库的子集构建的更大的数据集(4,000 个文档)来使用它:

>>> sentences_1 = brown.sents(categories=['reviews'])[0:1000]
>>> sentences_2 = brown.sents(categories=['government'])[0:1000]
>>> sentences_3 = brown.sents(categories=['fiction'])[0:1000]
>>> sentences_4 = brown.sents(categories=['news'])[0:1000]
>>> corpus = []

>>> for s in sentences_1 + sentences_2 + sentences_3 + sentences_4:
>>>    corpus.append(' '.join(s))

现在,我们可以通过假设我们有八个主要主题来向量化、定义和训练我们的 LDA 模型:

from sklearn.decomposition import LatentDirichletAllocation

>>> cv = CountVectorizer(strip_accents='unicode', stop_words='english', analyzer='word', token_pattern='[a-z]+')
>>> Xc = cv.fit_transform(corpus)

>>> lda = LatentDirichletAllocation(n_topics=8, learning_method='online', max_iter=25)
>>> Xl = lda.fit_transform(Xc)

CountVectorizer中,我们通过参数token_pattern添加了一个正则表达式来过滤标记。这很有用,因为我们没有使用完整的分词器,在语料库中也有许多我们想要过滤掉的数字。LatentDirichletAllocation类允许我们指定学习方法(通过learning_method),可以是批处理或在线。我们选择了在线,因为它更快;然而,两种方法都采用变分贝叶斯来学习参数。前者采用整个数据集,而后者使用小批量。在线选项将在 0.20 版本中删除;因此,现在使用它时,您可能会看到弃用警告。theta 和 beta Dirichlet 参数可以通过doc_topic_prior(theta)和topic_word_prior(beta)来指定。默认值(我们也是如此)是1.0 / n_topics。保持这两个值都很小,特别是小于 1.0,以鼓励稀疏性。最大迭代次数(max_iter)和其他学习相关参数可以通过阅读内置文档或访问scikit-learn.org/stable/modules/generated/sklearn.decomposition.LatentDirichletAllocation.html来应用。

现在,我们可以通过提取每个主题的前五个关键词来测试我们的模型。就像TruncatedSVD一样,主题-词分布结果存储在实例变量components_中:

>>> Mwts_lda = np.argsort(lda.components_, axis=1)[::-1]

>>> for t in range(8):
>>>    print('\nTopic ' + str(t))
>>>       for i in range(5):
>>>          print(cv.get_feature_names()[Mwts_lda[t, i]])

Topic 0
code
cadenza
unlocks
ophthalmic
quo

Topic 1
countless
harnick
leni
addle
chivalry

Topic 2
evasive
errant
tum
rum
orations

Topic 3
grigory
tum
absurdity
tarantara
suitably

Topic 4
seventeenth
conant
chivalrous
janitsch
knight

Topic 5
hypocrites
errantry
adventures
knight
errant

Topic 6
counter
rogues
tum
lassus
wars

Topic 7
pitch
cards
cynicism
silences
shrewd

存在一些重复,这可能是由于某些主题的组成,读者可以尝试不同的先验参数来观察变化。可以进行实验来检查模型是否工作正确。

让我们考虑两份文档:

>>> print(corpus[0])
It is not news that Nathan Milstein is a wizard of the violin .

>>> print(corpus[2500])
The children had nowhere to go and no place to play , not even sidewalks .

它们相当不同,它们的主题分布也是如此:

>>> print(Xl[0])
[ 0.85412134 0.02083335 0.02083335 0.02083335 0.02083335 0.02083677
 0.02087515 0.02083335]

>>> print(Xl[2500])
[ 0.22499749 0.02500001 0.22500135 0.02500221 0.025 0.02500219
 0.02500001 0.42499674]

对于第一份文档,我们有一个主导主题(0.85t[0]),而对于第二份文档,有一个混合(0.22t[0] + 0.22t[2 ]+ 0.42t[7])。现在让我们考虑这两份文档的拼接:

>>> test_doc = corpus[0] + ' ' + corpus[2500]
>>> y_test = lda.transform(cv.transform([test_doc]))

>>> print(y_test)
[[ 0.61242771 0.01250001 0.11251451 0.0125011 0.01250001 0.01250278
 0.01251778 0.21253611]]

在生成的文档中,正如预期的那样,混合比例已经发生了变化:0.61t[0] + 0.11t[2] + 0.21t[7]。换句话说,算法通过削弱主题 2 和主题 7,引入了之前占主导地位的主题 5(现在变得更加强大)。这是合理的,因为第一份文档的长度小于第二份,因此主题 5 不能完全抵消其他主题。

情感分析

自然语言处理(NLP)最广泛的应用之一是对短文本(推文、帖子、评论、评论等)的情感分析。从市场营销的角度来看,理解这些信息片段所表达的情感语义非常重要。正如你可以理解的,当评论精确且只包含一组正面/负面词汇时,这项任务可以非常简单,但当同一句子中有可能相互冲突的不同命题时,它就变得更加复杂。例如,我喜欢那家酒店。那是一次美妙的经历显然是一个积极的评论,而这家酒店不错,然而,餐厅很糟糕,即使服务员很友好,我不得不与前台服务员争论再要一个枕头。在这种情况下,情况更加难以管理,因为既有积极的也有消极的元素,导致一个中性的评论。因此,许多应用不是基于二元决策,而是承认中间级别(至少一个来表达中立)。

这种类型的问题通常是监督性的(正如我们即将要做的那样),但也有更便宜且更复杂的解决方案。评估情感的最简单方法就是寻找特定的关键词。这种基于字典的方法速度快,并且与一个好的词干提取器结合使用,可以立即标记出正面和负面的文档。然而,它不考虑术语之间的关系,也不能学习如何权衡不同的组成部分。例如,美好的一天,糟糕的心情将导致中性(+1,-1),而使用监督方法,模型可以学习到mood非常重要,并且糟糕的心情通常会导致负面情感。其他方法(更为复杂)基于主题建模(你现在可以理解如何应用 LSA 或 LDA 来确定基于积极或消极的潜在主题);然而,它们需要进一步步骤来使用主题-词和主题-文档分布。在现实语义中,这可能很有帮助,例如,一个积极的形容词通常与其他类似成分(如动词)一起使用。比如说,这家酒店很棒,我肯定会再回来。在这种情况下(如果样本数量足够大),一个主题可以从诸如lovelyamazing等词语的组合中产生,以及(积极的)动词,如returningcoming back

另一种方法是考虑正负文档的主题分布,并在主题子空间中使用监督方法。其他方法包括深度学习技术(如 Word2Vec 或 Doc2Vec),其基于生成一个向量空间,其中相似的词彼此靠近,以便容易管理同义词。例如,如果训练集包含句子Lovely hotel,但它不包含Wonderful hotel,Word2Vec 模型可以从其他示例中学习到lovelywonderful非常接近;因此,新的文档Wonderful hotel可以立即使用第一评论提供的信息进行分类。关于这项技术的介绍和一些技术论文可以在code.google.com/archive/p/word2vec/找到。

现在让我们考虑我们的例子,它基于Twitter Sentiment Analysis Training Corpus数据集的一个子集。为了加快过程,我们将实验限制在 100,000 条推文。下载文件后(见本段末尾的框),需要解析它(使用 UTF-8 编码):

>>> dataset = 'dataset.csv'

>>> corpus = []
>>> labels = []

>>> with open(dataset, 'r', encoding='utf-8') as df:
>>>    for i, line in enumerate(df):
>>>    if i == 0:
>>>       continue
>>> 
>>>    parts = line.strip().split(',')
>>>    labels.append(float(parts[1].strip()))
>>>    corpus.append(parts[3].strip())

dataset变量必须包含 CSV 文件的完整路径。此过程读取所有行,跳过第一行(它是标题),并将每条推文作为新的列表条目存储在corpus变量中,相应的情感(二进制,0 或 1)存储在labels变量中。在这个阶段,我们像往常一样进行,标记化、向量化,并准备训练集和测试集:

from nltk.tokenize import RegexpTokenizer
from nltk.corpus import stopwords
from nltk.stem.lancaster import LancasterStemmer

from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.model_selection import train_test_split

>>> rt = RegexpTokenizer('[a-zA-Z0-9\.]+')
>>> ls = LancasterStemmer()
>>> sw = set(stopwords.words('english'))

>>> def tokenizer(sentence):
>>>    tokens = rt.tokenize(sentence)
>>>    return [ls.stem(t.lower()) for t in tokens if t not in sw]

>>> tfv = TfidfVectorizer(tokenizer=tokenizer, sublinear_tf=True, ngram_range=(1, 2), norm='l2')
>>> X = tfv.fit_transform(corpus[0:100000])
>>> Y = np.array(labels[0:100000])

>>> X_train, X_test, Y_train, Y_test = train_test_split(X, Y, test_size=0.1)

我们选择在RegexpTokenizer实例中包括点和字母数字,因为它们对于表达特定情绪很有用。此外,n-gram 的范围已被设置为(1,2),因此我们包括了二元组(读者也可以尝试三元组)。在这个阶段,我们可以训练一个随机森林:

from sklearn.ensemble import RandomForestClassifier

import multiprocessing

>>> rf = RandomForestClassifier(n_estimators=20, n_jobs=multiprocessing.cpu_count())
>>> rf.fit(X_train, Y_train)

现在我们可以生成一些指标来评估模型:

from sklearn.metrics import precision_score, recall_score

>>> print('Precision: %.3f' % precision_score(Y_test, rf.predict(X_test)))
Precision: 0.720

>>> print('Recall: %.3f' % recall_score(Y_test, rf.predict(X_test)))
Recall: 0.784 

性能并不出色(使用 Word2Vec 可以实现更好的准确率);然而,对于许多任务来说是可以接受的。特别是,78%的召回率意味着错误负例的数量大约是 20%,当使用情感分析进行自动处理任务时可能很有用(在许多情况下,自动发布负面评论的风险阈值相当低,因此必须采用更好的解决方案)。性能也可以通过相应的 ROC 曲线来确认:

图片

示例中使用的Twitter Sentiment Analysis Training Corpus数据集(CSV 文件格式)可以从thinknook.com/wp-content/uploads/2012/09/Sentiment-Analysis-Dataset.zip下载。考虑到数据量,训练过程可能非常长(甚至在较慢的机器上可能需要数小时)。

VADER 情感分析与 NLTK

对于英语语言,NLTK 提供了一个已经训练好的模型,称为 VADERValence Aware Dictionary and sEntiment Reasoner),它以略不同的方式工作,并采用规则引擎与词典一起推断文本的情感强度。更多信息和细节可以在 Hutto C.J.,Gilbert E.,VADER: A Parsimonious Rule-based Model for Sentiment Analysis of Social Media Text,AAAI,2014* 中找到。

NLTK 版本使用 SentimentIntensityAnalyzer 类,可以直接使用,以获得由四个组成部分组成的极性情感度量:

  • 正面因素

  • 负面因素

  • 中性因素

  • 复合因素

前三项无需解释,而最后一个是特定度量(一个归一化的总体得分),其计算方式如下:

图片

在这里,Sentiment(w[i]) 是单词 w[i] 的情感得分,alpha 是一个归一化系数,它应该近似最大预期值(NLTK 中默认设置为 15)。这个类的使用非常直接,以下代码片段可以证实:

from nltk.sentiment.vader import SentimentIntensityAnalyzer

>>> text = 'This is a very interesting and quite powerful sentiment analyzer'

>>> vader = SentimentIntensityAnalyzer()
>>> print(vader.polarity_scores(text))
{'neg': 0.0, 'neu': 0.535, 'pos': 0.465, 'compound': 0.7258} 

NLTK Vader 实现使用 Twython 库的一些功能。尽管这不是必需的,但为了避免警告,可以使用 pip 安装它(pip install twython)。

参考文献

  • Hofmann T.,Unsupervised Learning by Probabilistic Latent Semantic Analysis,Machine Learning 42,177-196,2001,Kluwer Academic Publishers。

  • Blei D.,Ng A.,Jordan M.,Latent Dirichlet Allocation, Journal of Machine Learning Research,3,(2003) 993-1022。

  • Hutto C.J.,Gilbert E.,VADER: A Parsimonious Rule-based Model for Sentiment Analysis of Social Media Text,AAAI,2014。

摘要

在本章中,我们介绍了主题建模。我们讨论了基于截断 SVD 的潜在语义分析、概率潜在语义分析(旨在构建一个不假设潜在因素先验概率的模型)以及潜在狄利克雷分配,后者优于前一种方法,并基于潜在因素具有稀疏先验狄利克雷分布的假设。这意味着一个文档通常只覆盖有限数量的主题,而一个主题只由少数几个重要单词来表征。

在最后一节中,我们讨论了文档的情感分析,其目的是确定一段文本是否表达了一种积极的或消极的感觉。为了展示一个可行的解决方案,我们基于一个 NLP 管道和一个随机森林构建了一个分类器,其平均性能可用于许多现实生活中的情况。

在下一章中,我们将简要介绍深度学习以及 TensorFlow 框架。由于这个主题本身就需要一本专门的书籍,我们的目标是定义一些主要概念,并通过一些实际例子进行说明。如果读者想要了解更多信息,本章末尾将提供一个完整的参考文献列表。

第十四章:深度学习和 TensorFlow 简介

在本章中,我们将通过基于 TensorFlow 的一些示例简要介绍深度学习。这个主题相当复杂,需要专门的书籍;然而,我们的目标是让读者理解一些在开始完整课程之前可能有用的基本概念。在第一部分,我们介绍了人工神经网络的架构以及它们如何通过几个不同的层转换成复杂的计算图。在第二部分,我们将介绍与 TensorFlow 有关的基本概念,并展示一些基于之前章节中讨论过的算法的示例。在最后一部分,我们简要介绍了 Keras,这是一个高级深度学习框架,并构建了一个使用卷积神经网络的图像分类示例。

概述深度学习

在过去几十年中,深度学习因其数百个改变我们与许多电子(和非电子)系统交互方式的应用而变得非常著名。语音、文本和图像识别;自动驾驶汽车;以及智能机器人(仅举几例)都是通常基于深度学习模型的应用,并且优于任何先前的经典方法。

为了更好地理解深度架构是什么(考虑到这只是一个简要介绍),我们需要回顾并讨论标准的人工神经网络。

人工神经网络

人工神经网络ANN)或简称为神经网络,是一种将输入层与输出层连接的定向结构。通常,所有操作都是可微分的,整体向量函数可以很容易地写成:

在这里:

形容词“神经”来源于两个重要元素:基本计算单元的内部结构和它们之间的相互连接。让我们先从前者开始。在下面的图中,有一个人工神经元的示意图:

神经元核心与n个输入通道相连,每个通道由一个突触权重w[i]表征。输入被分成其组成部分,然后它们与相应的权重相乘并求和。可以添加一个可选的偏差到这个和(它就像另一个连接到单位输入的权重)。这个和通过一个激活函数f[a](例如,如果你记得逻辑回归是如何工作的,可以使用 Sigmoid 函数)过滤,因此输出因此产生。在第五章“逻辑回归”中,我们也讨论了感知器(第一个人工神经网络),它正好对应这种具有二元步激活函数的架构。另一方面,逻辑回归也可以表示为一个单神经元神经网络,其中fa是 Sigmoid 函数。这个架构的主要问题是它本质上是线性的,因为输出总是输入向量和权重向量的点积的函数。你已经知道这样一个系统所有的局限性;因此,有必要向前迈进并创建第一个多层感知器MLP)。在下面的图中,有一个具有 n 维输入、p个隐藏神经元和k维输出的 MLP 的示意图:

图片

有三层(尽管数量可以更大):输入层,它接收输入向量;一个隐藏层;以及输出层,它负责产生输出。正如你所见,每个神经元都与下一层的所有神经元相连,现在我们有两个权重矩阵,W = (w[ij])H = (h[jk]),按照惯例,第一个索引指的是前一层,第二个索引指的是下一层。

因此,每个隐藏神经元的净输入及其相应的输出是:

图片

同样地,我们可以计算网络输出:

图片

正如你所见,网络已经变得高度非线性,这一特性使我们能够模拟那些用线性方法无法管理的复杂场景。但是,我们如何确定所有突触权重和偏差的值呢?最著名的算法被称为反向传播,它的工作方式非常简单(唯一重要的假设是两个 fa 都必须是可微的)。

首先,我们需要定义一个误差(损失)函数;对于许多分类任务,它可以是总平方误差:

图片

在这里,我们假设有N个输入样本。展开它,我们得到:

图片

此函数依赖于所有变量(权重和偏差),但我们可以从底部开始,首先只考虑h**jk  ]**

同样,我们可以推导出与w[ij]相关的梯度:

正如你所见,术语 alpha(与误差 delta 成正比)从输出层反向传播到隐藏层。如果有许多隐藏层,则此过程应递归重复,直到第一层。该算法采用梯度下降法;因此,它迭代更新权重,直到收敛:

在这里,参数eta(公式中的希腊字母)是学习率。

在许多实际问题中,采用的是随机梯度下降法(阅读en.wikipedia.org/wiki/Stochastic_gradient_descent,获取更多信息),该方法使用输入样本的批次,而不是考虑整个数据集。此外,可以采用许多优化方法来加速收敛,但这些内容超出了本书的范围。在 Goodfellow I.,Bengio Y.,Courville A.的《深度学习》,MIT Press*中,读者可以找到大多数这些优化的详细信息。就我们的目的而言,重要的是要知道我们可以构建一个复杂的网络,并在定义全局损失函数后,使用标准程序优化所有权重。在 TensorFlow 的章节中,我们将展示一个 MLP 的示例,但我们不会实现学习算法,因为幸运的是,所有优化器都已经构建好,并且可以应用于任何架构。

深度架构

MLPs 功能强大,但它们的表达能力受层数量和性质的限制。另一方面,深度学习架构基于一系列异构层,这些层在计算图中执行不同的操作。正确重塑的层输出被送入下一层,直到输出,这通常与一个用于优化的损失函数相关。最有趣的应用得益于这种堆叠策略,其中可变元素(权重和偏差)的数量可以轻松超过 1000 万;因此,捕捉细节和推广的能力超出了任何预期。在下一节中,我将简要介绍最重要的层类型。

全连接层

一个全连接层(有时称为密集层)由 n 个神经元组成,每个神经元接收来自前一层的所有输出值(如 MLP 中的隐藏层)。它可以由一个权重矩阵、一个偏差向量和一个激活函数来表征:

图片

它们通常用作中间层或输出层,特别是在需要表示概率分布时。例如,可以使用深度架构进行具有m个输出类别的图像分类。在这种情况下,softmax激活函数允许有一个输出向量,其中每个元素是某个类别的概率(并且所有输出的总和总是归一化到 1.0)。在这种情况下,参数被视为logit或概率的对数:

图片

W[i]W的 i 行。类别y[i]的概率是通过将softmax函数应用于每个logit来获得的:

图片

这种类型的输出可以很容易地使用交叉熵损失函数进行训练,正如已经讨论过的逻辑回归。

卷积层

卷积层通常应用于二维输入(尽管它们也可以用于向量和 3D 矩阵),并且由于它们在图像分类任务中表现出色,因此变得特别著名。它们基于一个小核k与二维输入的离散卷积(可以是另一个卷积层的输出):

图片

一层通常由 n 个固定大小的核组成,它们的值被视为使用反向传播算法进行学习的权重。在大多数情况下,卷积架构从具有较少较大核的层开始(例如,16(8 x 8)矩阵),并将它们的输出馈送到具有更多较小核的更高层(32(5 x 5),128(4 x 4),和 256(3 x 3))。这样,第一层应该学会捕获更通用的特征(例如,方向),而后续的层将被训练以捕获越来越小的元素(例如,脸部眼睛、鼻子和嘴巴的位置)。最后卷积层的输出通常会被展平(转换为 1D 向量)并用作一个或多个全连接层的输入。

在下面的图中,展示了一个对图片进行卷积的示意图:

图片

每个由 3 x 3 像素组成的正方形组与拉普拉斯核进行卷积,并转换为单个值,该值对应于上、下、左、右像素(考虑中心)的总和减去中心像素的四倍。我们将在下一节中看到一个使用此核的完整示例。

为了在卷积数量非常高时减少复杂性,可以采用一个或多个池化层。它们的作用是使用预定义的策略将每个输入点组(图像中的像素)转换为单个值。最常见的池化层有:

  • 最大池化:每个二维的(m x n)像素组被转换成一个像素,其值是该组中的最大值。

  • 平均池化:每个二维的(m x n)像素组被转换成一个像素,其值是该组的平均值。

以这种方式,原始矩阵的维度可以减少,但会损失一些信息,但这些信息通常可以丢弃(特别是在第一层,特征粒度较粗)。另一个重要的层类别是零填充层。它们通过在输入(1D)之前和之后或 2D 输入的左侧、右侧、顶部和底部添加空值(0)来工作。

Dropout 层

Dropout 层用于通过随机将固定数量的输入元素设置为 0 来防止网络过拟合。这个层在训练阶段被采用,但在测试、验证和生产阶段通常被禁用。Dropout 网络可以利用更高的学习率,在损失表面上移动不同的方向(在隐藏层中设置一些随机输入值为零相当于训练不同的子模型)并排除所有不导致一致优化的错误表面区域。Dropout 在非常大的模型中非常有用,它可以提高整体性能并降低某些权重冻结和模型过拟合的风险。

循环神经网络

循环层由特定的神经元组成,这些神经元具有循环连接,以便将时间t的状态绑定到其前一个值(通常只有一个)。这类计算单元在需要捕捉输入序列的时间动态时特别有用。实际上,在许多情况下,我们期望的输出值必须与相应输入的历史相关。但是,MLP 以及我们讨论的其他模型都是无状态的。因此,它们的输出仅由当前输入决定。RNN 通过提供内部记忆来克服这个问题,该记忆可以捕捉短期和长期依赖关系。

最常见的单元是长短期记忆LSTM)和门控循环单元GRU),它们都可以使用标准的反向传播方法进行训练。由于这只是一个简介,我无法深入探讨(RNN 的数学复杂性非同小可);然而,记住这一点是有用的,即每当需要在深度模型中包含时间维度时,RNNs 提供稳定且强大的支持。

TensorFlow 简介

TensorFlow 是由 Google 创建的计算框架,已经成为最广泛使用的深度学习工具包之一。它可以与 CPU 和 GPU 一起工作,并且已经实现了构建和训练复杂模型所需的大部分操作和结构。TensorFlow 可以作为 Python 包安装在 Linux、Mac 和 Windows 上(带或不带 GPU 支持);然而,我建议您遵循网站上的说明(链接可在本章末尾的信息框中找到),以避免常见的错误。

TensorFlow 的主要概念是计算图,或者是一系列后续操作,这些操作将输入批次转换成所需的输出。在下面的图中,有一个图的示意图:

图片

从底部开始,我们有两个输入节点(ab),一个转置操作(作用于 b),一个矩阵乘法和均值减少。init 块是一个独立的操作,它正式是图的一部分,但它没有直接连接到任何其他节点;因此它是自主的(实际上,它是一个全局初始化器)。

由于这只是一个简要的介绍,列出所有与 TensorFlow 一起工作所需的最重要战略元素是有用的,以便能够构建几个简单的示例,以展示这个框架的巨大潜力:

  • :这代表通过由操作组成的定向网络连接一个通用输入批次与输出张量的计算结构。它定义为 tf.Graph() 实例,通常与 Python 上下文管理器一起使用。

  • 占位符:这是一个对外部变量的引用,当需要输出使用它直接或间接进行的操作时,必须显式提供。例如,占位符可以代表一个变量 x,它首先被转换为其平方值,然后与一个常数值相加。输出结果是 x²+c,通过传递一个具体的 x 值来实现。它定义为 tf.placeholder() 实例。

  • 变量:一个内部变量,用于存储由算法更新的值。例如,一个变量可以是一个包含逻辑回归权重的向量。它通常在训练过程之前初始化,并由内置优化器自动修改。它定义为 tf.Variable() 实例。变量也可以用来存储在训练过程中不应考虑的元素;在这种情况下,它必须使用参数 trainable=False 声明。

  • 常数:定义为 tf.constant() 实例的常数值。

  • 操作:一种可以与占位符、变量和常量一起工作的数学操作。例如,两个矩阵的乘法是一个定义为 tf.matmul(A, B) 的操作。在所有操作中,梯度计算是最重要的之一。TensorFlow 允许从计算图中的某个确定点开始确定梯度,直到原点或逻辑上必须在其之前的另一个点。我们将看到这个操作的示例。

  • 会话:这是 TensorFlow 和我们的工作环境(例如 Python 或 C++)之间的一种包装接口。当需要评估图时,这个宏操作将由会话管理,会话必须提供所有占位符的值,并使用请求的设备生成所需的输出。对于我们的目的,没有必要深入研究这个概念;然而,我邀请读者从网站或本章末尾列出的资源中获取更多信息。它声明为 tf.Session() 的实例,或者,正如我们即将做的,tf.InteractiveSession() 的实例。这种会话在处理笔记本或 shell 命令时特别有用,因为它会自动将其设置为默认会话。

  • 设备:一个物理计算设备,例如 CPU 或 GPU。它通过 tf.device() 类的实例显式声明,并使用上下文管理器使用。当架构包含多个计算设备时,可以将工作拆分以便并行化许多操作。如果没有指定设备,TensorFlow 将使用默认设备(这是主要 CPU 或如果安装了所有必要的组件,则是一个合适的 GPU)。

我们现在可以使用这些概念分析一些简单的示例。

计算梯度

计算所有输出张量相对于任何连接的输入或节点的梯度的选项是 TensorFlow 最有趣的功能之一,因为它允许我们创建学习算法,而无需担心所有转换的复杂性。在这个例子中,我们首先定义一个线性数据集,表示范围在 (-100, 100) 内的函数 f(x) = x

import numpy as np

>>> nb_points = 100
>>> X = np.linspace(-nb_points, nb_points, 200, dtype=np.float32)

对应的图表如下所示:

现在我们想使用 TensorFlow 来计算:

第一步是定义一个图:

import tensorflow as tf

>>> graph = tf.Graph()

在这个图的上下文中,我们可以定义我们的输入占位符和其他操作:

>>> with graph.as_default():
>>>    Xt = tf.placeholder(tf.float32, shape=(None, 1), name='x')
>>>    Y = tf.pow(Xt, 3.0, name='x_3')
>>>    Yd = tf.gradients(Y, Xt, name='dx')
>>>    Yd2 = tf.gradients(Yd, Xt, name='d2x')

占位符通常使用类型(第一个参数)、形状和可选名称定义。我们决定使用 tf.float32 类型,因为这是唯一由 GPU 也支持的类型。选择 shape=(None, 1) 意味着可以使用任何二维向量,其第二维等于 1。

第一个操作计算Xt在所有元素上的三次幂。第二个操作计算Y相对于输入占位符Xt的所有梯度。最后一个操作将重复梯度计算,但在这个情况下,它使用Yd,这是第一个梯度操作的输出。

我们现在可以传递一些具体的数据来查看结果。首先要做的事情是创建一个与该图连接的会话:

>>> session = tf.InteractiveSession(graph=graph)

通过使用这个会话,我们要求使用run()方法进行的任何计算。所有输入参数都必须通过 feed 字典提供,其中键是占位符,值是实际的数组:

>>> X2, dX, d2X = session.run([Y, Yd, Yd2], feed_dict={Xt: X.reshape((nb_points*2, 1))})

我们需要调整我们的数组以符合占位符。run()的第一个参数是我们想要计算的张量列表。在这种情况下,我们需要所有操作输出。每个输出的图如下所示:

图片

如预期,它们分别代表:3x²,和6x

逻辑回归

现在我们可以尝试一个更复杂的例子,实现逻辑回归算法。第一步,像往常一样,是创建一个虚拟数据集:

from sklearn.datasets import make_classification

>>> nb_samples = 500
>>> X, Y = make_classification(n_samples=nb_samples, n_features=2, n_redundant=0, n_classes=2)

数据集在以下图中显示:

图片

在这个阶段,我们可以创建图和所有占位符、变量和操作:

import tensorflow as tf

>>> graph = tf.Graph()

>>> with graph.as_default():
>>>    Xt = tf.placeholder(tf.float32, shape=(None, 2), name='points')
>>>    Yt = tf.placeholder(tf.float32, shape=(None, 1), name='classes')
>>> 
>>>    W = tf.Variable(tf.zeros((2, 1)), name='weights')
>>>    bias = tf.Variable(tf.zeros((1, 1)), name='bias')
>>> 
>>>    Ye = tf.matmul(Xt, W) + bias
>>>    Yc = tf.round(tf.sigmoid(Ye))
>>> 
>>>    loss = tf.reduce_mean(tf.nn.sigmoid_cross_entropy_with_logits(logits=Ye, labels=Yt))
>>>    training_step = tf.train.GradientDescentOptimizer(0.025).minimize(loss)

需要占位符Xt来表示点,而Yt代表标签。在这个阶段,我们需要涉及几个变量:如果你记得,它们存储的是由训练算法更新的值。在这种情况下,我们需要一个权重向量W(包含两个元素)和一个单一的bias。当声明一个变量时,必须提供其初始值;我们决定使用tf.zeros()函数将它们都设置为零,该函数接受作为参数的期望张量的形状。

现在我们可以分两步计算输出(如果你不记得逻辑回归是如何工作的,请回顾第五章,逻辑回归):首先计算 sigmoid 指数Ye,然后通过四舍五入 sigmoid 值得到实际的二进制输出Yc。逻辑回归的训练算法最小化负对数似然,这对应于真实分布YYc之间的交叉熵。实现这个损失函数很容易;然而,函数tf.log()在数值上是不稳定的(当其值接近零时,它趋向于负无穷大并产生一个NaN值);因此,TensorFlow 实现了一个更健壮的函数,tf.nn.sigmoid_cross_entropy_with_logits(),它假设输出是由 sigmoid 产生的来计算交叉熵。它接受两个参数,logits(对应于指数Ye)和目标labels,它们存储在Yt中。

现在,我们可以使用 TensorFlow 最强大的功能之一:训练优化器。在定义损失函数后,它将依赖于占位符、常量和变量。训练优化器(如tf.train.GradientDescentOptimizer()),通过其minimize()方法,接受要优化的损失函数。内部,根据每个特定算法,它将计算损失函数相对于所有可训练变量的梯度,并将相应的校正应用于值。传递给优化器的参数是学习率。

因此,我们定义了一个额外的操作,称为training_step,它对应于一个单一的状态更新步骤。无论图有多复杂,所有涉及损失函数的可训练变量都将通过单一指令进行优化。

现在是时候训练我们的逻辑回归了。首先要做的是让 TensorFlow 初始化所有变量,以便在操作需要使用它们时它们已经准备好了:

>>> session = tf.InteractiveSession(graph=graph)
>>> tf.global_variables_initializer().run()

到这一点,我们可以创建一个简单的训练循环(应该在损失停止减少时停止;然而,我们有一个固定的迭代次数):

>>> feed_dict = {
>>>    Xt: X,
>>>    Yt: Y.reshape((nb_samples, 1))
>>> }

>>> for i in range(5000):
>>>    loss_value, _ = session.run([loss, training_step], feed_dict=feed_dict)
>>>    if i % 100 == 0:
>>>    print('Step %d, Loss: %.3f' % (i, loss_value))
Step 0, Loss: 0.269
Step 100, Loss: 0.267
Step 200, Loss: 0.265
Step 300, Loss: 0.264
Step 400, Loss: 0.263
Step 500, Loss: 0.262
Step 600, Loss: 0.261
Step 700, Loss: 0.260
Step 800, Loss: 0.260
Step 900, Loss: 0.259
...

如您所见,在每次迭代中,我们要求 TensorFlow 计算损失函数和训练步骤,并且我们总是传递包含XY的相同字典。在这个循环结束时,损失函数稳定了,我们可以通过绘制分离超平面来检查这个逻辑回归的质量:

图片

结果大约等同于使用 scikit-learn 实现得到的结果。如果我们想知道两个系数(权重)和截距(偏差)的值,我们可以通过在每个变量上调用eval()方法来让 TensorFlow 检索它们:

>>> Wc, Wb = W.eval(), bias.eval()

>>> print(Wc)
[[-1.16501403]
 [ 3.10014033]]

>>> print(Wb)
[[-0.12583369]]

多层感知器的分类

我们现在可以构建一个具有两个密集层的架构,并训练一个用于更复杂数据集的分类器。让我们先创建它:

from sklearn.datasets import make_classification

>>> nb_samples = 1000
>>> nb_features = 3

>>> X, Y = make_classification(n_samples=nb_samples, n_features=nb_features, 
>>> n_informative=3, n_redundant=0, n_classes=2, n_clusters_per_class=3)

即使只有两个类别,数据集有三个特征,每个类别有三个簇;因此,线性分类器几乎不可能以非常高的精度将其分离。以下图表显示了数据集的图示:

图片

为了基准测试的目的,测试一个逻辑回归是有用的:

from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression

>>> X_train, X_test, Y_train, Y_test = train_test_split(X, Y, test_size=0.2)

>>> lr = LogisticRegression()
>>> lr.fit(X_train, Y_train)
>>> print('Score: %.3f' % lr.score(X_test, Y_test))
Score: 0.715

在测试集上计算的分数大约是 71%,这并不算太差,但低于可接受的阈值。让我们尝试使用具有 50 个隐藏神经元(具有双曲正切激活)和 1 个 sigmoid 输出神经元的 MLP。双曲正切是:

图片

它的值在-1.0 和 1.0 之间渐近有界。

我们不会手动实现每一层,而是使用内置类tf.contrib.layers.fully_connected()。它接受输入张量或占位符作为第一个参数,并将层输出神经元的数量作为第二个参数。可以通过属性activation_fn指定激活函数:

import tensorflow as tf
import tensorflow.contrib.layers as tfl

>>> graph = tf.Graph()

>>> with graph.as_default():
>>>    Xt = tf.placeholder(tf.float32, shape=(None, nb_features), name='X')
>>>    Yt = tf.placeholder(tf.float32, shape=(None, 1), name='Y')
>>> 
>>>    layer_1 = tfl.fully_connected(Xt, num_outputs=50, activation_fn=tf.tanh)
>>>    layer_2 = tfl.fully_connected(layer_1, num_outputs=1,
>>>                                  activation_fn=tf.sigmoid)
>>> 
>>>    Yo = tf.round(layer_2)
>>> 
>>>    loss = tf.nn.l2_loss(layer_2 - Yt)
>>>    training_step = tf.train.GradientDescentOptimizer(0.025).minimize(loss)

如前一个示例所示,我们定义了两个占位符XtYt,以及两个全连接层。第一个接受Xt作为输入,有 50 个输出神经元(使用tanh激活),而第二个接受前一个层(layer_1)的输出,只有一个 sigmoid 神经元,代表类别。四舍五入的输出由Yo提供,损失函数是总平方误差,它通过tf.nn.l2_loss()函数实现,该函数计算网络输出(layer_2)与目标类别占位符Yt之间的差异。训练步骤使用标准的梯度下降优化器实现,就像逻辑回归示例中那样。

我们现在可以实施一个训练循环,将我们的数据集分成固定数量的批次(样本数量定义在变量batch_size中),并重复一个完整的周期nb_epochs个 epoch:

>>> session = tf.InteractiveSession(graph=graph)
>>> tf.global_variables_initializer().run()

>>> nb_epochs = 200
>>> batch_size = 50

>>> for e in range(nb_epochs):
>>>    total_loss = 0.0
>>>    Xb = np.ndarray(shape=(batch_size, nb_features), dtype=np.float32)
>>>    Yb = np.ndarray(shape=(batch_size, 1), dtype=np.float32)
>>> 
>>>    for i in range(0, X_train.shape[0]-batch_size, batch_size):
>>>       Xb[:, :] = X_train[i:i+batch_size, :]
>>>       Yb[:, 0] = Y_train[i:i+batch_size]
>>> 
>>>       loss_value, _ = session.run([loss, training_step], 
>>>                                   feed_dict={Xt: Xb, Yt: Yb})
>>>       total_loss += loss_value
>>> 
>>>        Y_predicted = session.run([Yo], 
>>>               feed_dict={Xt: X_test.reshape((X_test.shape[0], nb_features))})
>>>        accuracy = 1.0 -
>>>            (np.sum(np.abs(np.array(Y_predicted[0]).squeeze(axis=1) -Y_test)) /
>>>            float(Y_test.shape[0]))
>>> 
>>>        print('Epoch %d) Total loss: %.2f - Accuracy: %.2f' % 
>>>              (e, total_loss, accuracy))

Epoch 0) Total loss: 78.19 - Accuracy: 0.66
Epoch 1) Total loss: 75.02 - Accuracy: 0.67
Epoch 2) Total loss: 72.28 - Accuracy: 0.68
Epoch 3) Total loss: 68.52 - Accuracy: 0.71
Epoch 4) Total loss: 63.50 - Accuracy: 0.79
Epoch 5) Total loss: 57.51 - Accuracy: 0.84
...
Epoch 195) Total loss: 15.34 - Accuracy: 0.94
Epoch 196) Total loss: 15.32 - Accuracy: 0.94
Epoch 197) Total loss: 15.31 - Accuracy: 0.94
Epoch 198) Total loss: 15.29 - Accuracy: 0.94
Epoch 199) Total loss: 15.28 - Accuracy: 0.94

如我们所见,如果不特别关注所有细节,测试集上计算出的准确率是 94%。考虑到数据集的结构,这是一个可接受的价值。在 Goodfellow I.,Bengio Y.,Courville A.的《深度学习》,MIT Press 中,读者将找到许多重要概念的详细信息,这些信息仍然可以改善性能并加快收敛过程。

图像卷积

即使我们没有构建完整的深度学习模型,我们也可以通过一个简单的示例测试卷积是如何工作的。我们使用的输入图像已经由SciPy提供:

from scipy.misc import face

>>> img = face(gray=True)

原始图片在此显示:

我们将应用拉普拉斯滤波器,它强调每个形状的边界:

import numpy as np

>>> kernel = np.array(
>>>    [[0, 1, 0],
>>>     [1, -4, 0],
>>>     [0, 1, 0]], 
>>>    dtype=np.float32)

>>> cfilter = np.zeros((3, 3, 1, 1), dtype=np.float32)
>>> cfilter[:, :, 0, 0] = kernel 

因为 TensorFlow 卷积函数tf.nn.conv2d期望一个输入和一个输出滤波器,所以内核必须重复两次。我们现在可以构建图并测试它:

import tensorflow as tf

>>> graph = tf.Graph()

>>> with graph.as_default():
>>>    x = tf.placeholder(tf.float32, shape=(None, 768, 1024, 1), name='image')
>>>    f = tf.constant(cfilter)

>>>    y = tf.nn.conv2d(x, f, strides=[1, 1, 1, 1], padding='SAME')

>>> session = tf.InteractiveSession(graph=graph)

>>> c_img = session.run([y], feed_dict={x: img.reshape((1, 768, 1024, 1))})
>>> n_img = np.array(c_img).reshape((768, 1024))

参数strides是一个四维向量(每个值对应输入维度,因此第一个是批次,最后一个是对应通道的数量),它指定滑动窗口必须移动多少像素。在这种情况下,我们想要覆盖所有图像的像素对像素的移动。参数padding确定如何计算新维度以及是否需要应用零填充。在我们的情况下,我们使用值SAME,它通过将原始维度除以相应的步长值并四舍五入到下一个整数来计算维度(因为这两个步长值都是 1.0,所以结果图像大小将正好与原始图像相同)。

输出图像在此显示:

每个操作系统的安装说明可以在 www.tensorflow.org/install/ 找到。

快速浏览 Keras 内部结构

Keras (keras.io) 是一个高级深度学习框架,可以无缝地与 TensorFlow、Theano 或 CNTK 等低级后端一起工作。在 Keras 中,一个模型就像一系列层,每个输出都被送入下一个计算块,直到达到最终层。模型的通用结构如下:

from keras.models import Sequential

>>> model = Sequential()

>>> model.add(...)
>>> model.add(...)
...
>>> model.add(...)

Sequential 类定义了一个通用的空模型,它已经实现了添加层、根据底层框架编译模型、fitevaluate 模型以及给定输入预测输出的所有所需方法。所有最常用的层都已经实现,包括:

  • 密集、Dropout 和展平层

  • 卷积(1D、2D 和 3D)层

  • 池化层

  • 零填充层

  • RNN 层

模型可以使用多个损失函数(如均方误差或交叉熵)和所有最常用的随机梯度下降优化算法(如 RMSProp 或 Adam)进行编译。有关这些方法的数学基础的更多详细信息,请参阅 Goodfellow I.,Bengio Y.,Courville A. 的《深度学习》,MIT 出版社。由于篇幅有限,不可能讨论所有重要元素,我更喜欢创建一个基于卷积网络的图像分类的完整示例。我们将使用的数据集是 CIFAR-10 (www.cs.toronto.edu/~kriz/cifar.html),它由 60000 张小 RGB 图像(32 x 32)组成,属于 10 个不同的类别(飞机、汽车、鸟、猫、鹿、狗、青蛙、马、船、卡车)。在下图中,显示了图像的一个子集:

自从上次发布以来,Keras 允许我们使用内置函数下载这个数据集;因此,无需采取进一步行动即可使用它。

第一步是加载数据集并将其分为训练集和测试集:

from keras.datasets import cifar10

>>> (X_train, Y_train), (X_test, Y_test) = cifar10.load_data()

训练数据集包含 50000 张图片,而测试集 10000 张。现在可以构建模型了。我们想使用几个卷积层来捕捉每个类别的特定元素。正如前文所述,这些特定的层可以学会识别特定的几何属性并以优秀的方式泛化。在我们的小型架构中,我们从 (5 x 5) 的过滤器大小开始,以捕捉所有低级特征(如方向),然后通过增加过滤器数量并减小其大小来继续。这样,高级特征(如车轮的形状或眼睛、鼻子和嘴巴的相对位置)也可以被捕捉。

from keras.models import Sequential
from keras.layers.convolutional import Conv2D, ZeroPadding2D
from keras.layers.pooling import MaxPooling2D

>>> model = Sequential()

>>> model.add(Conv2D(32, kernel_size=(5, 5), activation='relu', input_shape=(32 ,32, 3)))
>>> model.add(MaxPooling2D(pool_size=(2, 2)))

>>> model.add(Conv2D(64, kernel_size=(4, 4), activation='relu'))
>>> model.add(ZeroPadding2D((1, 1)))

>>> model.add(Conv2D(128, kernel_size=(3, 3), activation='relu'))
>>> model.add(MaxPooling2D(pool_size=(2, 2)))
>>> model.add(ZeroPadding2D((1, 1)))

第一条指令创建了一个新的空模型。在这个时候,我们可以添加所有我们想要包含在计算图中的层。卷积层最常见的参数包括:

  • 滤波器数量

  • 核大小(作为元组)

  • 步长(默认值是[1, 1])。此参数指定滑动窗口在图像上移动时必须考虑多少像素。[1, 1]表示不丢弃任何像素。[2, 2]表示每次水平和垂直移动都将有 2 像素的宽度,依此类推。

  • 激活(默认值为 None,表示将使用恒等函数)

  • 输入形状(仅对于第一层此参数是强制性的)

我们的第一层有 32 个(5 x 5)滤波器,并使用ReLU归一化线性单元)激活。此函数定义为:

第二层通过考虑(2 x 2)块的最大池化来降低维度。然后我们应用另一个有 64 个(4 x 4)滤波器的卷积,随后是零填充(在输入的顶部、底部、左侧和右侧各 1 像素),最后,我们得到第三个有 128 个(3 x 3)滤波器的卷积层,随后是最大池化和零填充。

在这一点上,我们需要展平最后一层的输出,以便像在 MLP(多层感知器)中一样工作。

from keras.layers.core import Dense, Dropout, Flatten

>>> model.add(Dropout(0.2))
>>> model.add(Flatten())
>>> model.add(Dense(128, activation='relu'))
>>> model.add(Dropout(0.2))
>>> model.add(Dense(10, activation='softmax')) 

在最后一个零填充层的输出上应用了一个 dropout(概率为 0.2);然后这个多维值被展平并转换为一个向量。这个值被输入到一个有 128 个神经元的全连接层,并使用 ReLU 激活。然后对输出应用另一个 dropout(以防止过拟合),最后,这个向量被输入到另一个有 10 个神经元的全连接层,并使用softmax激活:

这样,模型的输出代表了一个离散的概率分布(每个值是对应类别的概率)。

在训练模型之前的最后一步是编译它:

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

Keras 将使用具有分类交叉熵损失函数(参见 TensorFlow 逻辑回归的示例)和 Adam 优化器的低级操作(如我们在上一节中讨论的)将高级描述转换为低级操作。此外,它将应用一个准确度指标以动态评估性能。

在这一点上,模型可以进行训练。我们只需要两个初步操作:

  • 将图像归一化,使它们的值介于 0 和 1 之间

  • 将整数标签应用 one-hot 编码

第一个操作可以通过将数据集除以 255 来简单地执行,而第二个可以通过使用内置函数to_categorical()轻松完成:

from keras.utils import to_categorical

>>> model.fit(X_train / 255.0, to_categorical(Y_train), batch_size=32, epochs=15)

我们希望以 32 张图像组成的批次进行训练,持续 15 个 epoch。读者可以自由更改所有这些值以比较结果。Keras 提供的输出显示了学习阶段的进度:

Epoch 1/15
50000/50000 [==============================] - 25s - loss: 1.5845 - acc: 0.4199 
Epoch 2/15
50000/50000 [==============================] - 24s - loss: 1.2368 - acc: 0.5602 
Epoch 3/15
50000/50000 [==============================] - 26s - loss: 1.0678 - acc: 0.6247 
Epoch 4/15
50000/50000 [==============================] - 25s - loss: 0.9495 - acc: 0.6658 
Epoch 5/15
50000/50000 [==============================] - 26s - loss: 0.8598 - acc: 0.6963 
Epoch 6/15
50000/50000 [==============================] - 26s - loss: 0.7829 - acc: 0.7220 
Epoch 7/15
50000/50000 [==============================] - 26s - loss: 0.7204 - acc: 0.7452 
Epoch 8/15
50000/50000 [==============================] - 26s - loss: 0.6712 - acc: 0.7629 
Epoch 9/15
50000/50000 [==============================] - 27s - loss: 0.6286 - acc: 0.7779 
Epoch 10/15
50000/50000 [==============================] - 27s - loss: 0.5753 - acc: 0.7952 
Epoch 11/15
50000/50000 [==============================] - 27s - loss: 0.5433 - acc: 0.8049 
Epoch 12/15
50000/50000 [==============================] - 27s - loss: 0.5112 - acc: 0.8170 
Epoch 13/15
50000/50000 [==============================] - 27s - loss: 0.4806 - acc: 0.8293 
Epoch 14/15
50000/50000 [==============================] - 28s - loss: 0.4551 - acc: 0.8365 
Epoch 15/15
50000/50000 [==============================] - 28s - loss: 0.4342 - acc: 0.8444

在第 15 个 epoch 结束时,训练集上的准确率约为 84%(一个非常好的结果)。最后的操作是使用测试集评估模型:

>>> scores = model.evaluate(X_test / 255.0, to_categorical(Y_test))
>>> print('Loss: %.3f' % scores[0])
>>> print('Accuracy: %.3f' % scores[1])
Loss: 0.972
Accuracy: 0.719

最终验证准确率(约 72%)低于训练阶段达到的准确率。这对于深度模型来说是正常行为,因此,在优化算法时,始终使用交叉验证或一个定义良好的测试集(与训练集具有相同的分布,包含 25-30% 的总样本)是一个好习惯。

当然,我们提出的是一个非常简单的架构,但读者可以深入研究这些主题并创建更复杂的模型(Keras 还包含一些非常著名的预训练架构,如 VGG16/19 和 Inception V3,这些架构也可以用于执行具有 1000 个类别的图像分类)。

在网站上可以找到安装 Keras 所需的所有信息,包括不同后端的使用和官方文档:

keras.io

参考文献

摘要

在本章中,我们简要讨论了一些基本的深度学习概念,读者现在应该理解什么是计算图以及如何使用 TensorFlow 来建模。实际上,深度架构可以看作是一系列相互连接的层。它们可以具有不同的特性和目的,但整体图始终是一个有向结构,将输入值与最终输出层关联起来。因此,可以推导出一个全局损失函数,该函数将由训练算法进行优化。我们还看到了 TensorFlow 如何计算输出张量相对于任何先前连接层的梯度,以及如何无缝地将标准反向传播策略应用于深度架构。我们没有讨论实际的深度学习问题和方法,因为它们需要更多的空间;然而,读者可以轻松找到许多有效的资源,以继续在这个迷人的领域的探索。

在下一章中,我们将总结之前讨论的许多概念,以便创建复杂的机器学习架构。

第十五章:创建机器学习架构

在本章中,我们将总结书中讨论的许多概念,目的是定义一个完整的机器学习架构,该架构能够预处理输入数据,分解/增强它,分类/聚类它,并最终使用图形工具展示结果。我们还将展示 scikit-learn 如何管理复杂的流程,以及如何在完整架构的全局范围内拟合它们并搜索最佳参数。

机器学习架构

到目前为止,我们讨论了可以用来解决特定问题的单一方法。然而,在现实情况下,很难有定义明确的可以立即输入到标准分类器或聚类算法中的数据集。机器学习工程师通常必须设计一个完整的架构,非专家可能会将其视为一个黑盒,其中原始数据进入,结果自动产生。实现最终目标所需的所有步骤都必须正确组织,并在类似于计算图的处理链中无缝连接(实际上,它通常是一个直接无环图)。不幸的是,这是一个非标准过程,因为每个现实生活中的问题都有其独特的特性。然而,有一些常见的步骤通常包含在几乎任何机器学习流程中。

在以下图片中,展示了该过程的示意图:

图片

现在我们将简要解释每个阶段的细节以及一些可能的解决方案。

数据收集

第一步总是最通用的,因为它依赖于每个单独的上下文。然而,在处理任何数据之前,有必要从所有存储数据的地方收集它。理想的情况是有一个逗号分隔值CSV)或其他合适的格式文件,可以立即加载,但更常见的情况是工程师必须查找所有的数据库表,定义正确的 SQL 查询来收集所有信息片段,并管理数据类型转换和编码。我们不会讨论这个话题,但重要的是不要低估这个阶段,因为它可能比预期的要困难得多。我建议,在可能的情况下,提取扁平化的表格,其中所有字段都放在同一行上,因为使用数据库管理系统或大数据工具处理大量数据更容易,但如果直接在普通的 PC 上使用 Python 工具进行,可能会非常耗费时间和内存。此外,对于所有文本字段,使用标准的字符编码也很重要。最常见的选择是 UTF-8,但也可以找到使用其他字符集编码的数据库表,通常在开始其他操作之前将所有文档转换为标准编码是一个好的做法。一个非常著名且功能强大的 Python 数据操作库是 pandas(SciPy 的一部分)。它基于 DataFrame 的概念(SQL 表的抽象)并实现了许多方法,允许选择、连接、分组和统计处理适合内存的数据集。在Heydt M.,《学习 pandas - Python 数据发现与分析简化》,Packt 这本书中,读者可以找到使用这个库解决许多实际问题所需的所有信息。在这个阶段必须管理的常见问题之一是填充缺失的特征。在第三章,“特征选择与特征工程”中,我们讨论了一些可以在开始下一步之前自动采用的实际方法。

归一化

对数值数据集进行归一化是其中一个最重要的步骤,尤其是当不同的特征具有不同的尺度时。在第三章,特征选择与特征工程中,我们讨论了几种可以用来解决这个问题的方法。通常情况下,使用StandardScaler来白化数据就足够了,但有时考虑噪声特征对全局趋势的影响,并使用RobustScaler来过滤它们,而不必担心会条件化剩余的特征会更好。读者可以很容易地验证在处理归一化和未归一化的数据集时,相同的分类器(特别是 SVM 和神经网络)的不同性能。正如我们将在下一节中看到的,将归一化步骤包含在处理管道中作为第一个动作之一,并在网格搜索中包含C参数,以便在训练阶段强制执行L1/L2权重归一化(参见第四章,线性回归中讨论正则化的重要性,当讨论岭回归、Lasso 和 ElasticNet 时)。

维度降低

这一步骤并非总是必需的,但在许多情况下,它可以是一个解决内存泄漏或长时间计算的好方法。当数据集具有许多特征时,某些隐藏相关性的概率相对较高。例如,产品的最终价格直接受所有材料价格的影响,如果我们移除一个次要元素,其价值会略有变化(更普遍地说,我们可以说总方差几乎保持不变)。如果你记得 PCA 是如何工作的,你就会知道这个过程也会使输入数据去相关。因此,检查 PCA 或核 PCA(用于非线性数据集)是否可以去除一些组件,同时保持解释方差接近 100%(这相当于以最小信息损失压缩数据)是有用的。在第三章,特征选择与特征工程中,也讨论了其他方法(如NMFSelectKBest),这些方法可以根据各种标准(如 ANOVA 或卡方检验)选择最佳特征。在项目的初始阶段测试每个因素的影响可以节省时间,这在需要评估较慢且更复杂的算法时可能是有用的。

数据增强

有时原始数据集只有少数非线性特征,对于标准分类器来说捕捉动态变化相当困难。此外,将算法强加于复杂数据集可能会导致模型过拟合,因为所有能力都耗尽在尝试最小化仅考虑训练集的错误上,而没有考虑到泛化能力。因此,有时通过现有特征的函数获得派生特征来丰富数据集是有用的。PolynomialFeatures 是数据增强的一个例子,它可以真正提高标准算法的性能并避免过拟合。在其他情况下,引入三角函数(如 sin(x)cos(x))或相关特征(如 x[1]x[2])可能很有用。前者允许更简单地管理径向数据集,而后者可以为分类器提供关于两个特征之间交叉相关性的信息。通常,数据增强可以在尝试更复杂的算法之前使用;例如,逻辑回归(这是一种线性方法)可以成功地应用于增强的非线性数据集(我们在第四章,线性回归,讨论多项式回归时看到了类似的情况)。选择使用更复杂(具有更高容量)的模型或尝试增强数据集取决于工程师,并且必须仔细考虑,权衡利弊。在许多情况下,例如,最好是不要修改原始数据集(这可能相当大),而是创建一个 scikit-learn 接口以实时增强数据。在其他情况下,神经网络模型可以提供更快、更准确的结果,而无需数据增强。与参数选择一样,这更多的是一种艺术而不是真正的科学,实验是唯一收集有用知识的方法。

数据转换

在处理分类数据时,这一步可能是最简单同时也是最重要的。我们已经讨论了几种使用数值向量编码标签的方法,没有必要重复已经解释的概念。一个一般规则是关于使用整数或二进制值(one-hot 编码)。当分类器的输出是值本身时,后者可能是最佳选择,因为,如第三章,特征选择和特征工程中讨论的那样,它对噪声和预测误差的鲁棒性更强。另一方面,one-hot 编码相当消耗内存。因此,当需要处理概率分布(如 NLP 中)时,整数标签(代表字典条目或频率/计数值)可能更有效。

模型/网格搜索/交叉验证

建模意味着选择最适合每个特定任务的分类/聚类算法。我们已经讨论了不同的方法,读者应该能够理解一组算法何时是一个合理的候选者,何时最好寻找另一种策略。然而,机器学习技术的成功往往还取决于模型中每个参数的正确选择。正如已经讨论过的,当谈到数据增强时,很难找到一个精确的方法来确定要分配的最佳值,最佳方法始终基于网格搜索。scikit-learn 提供了一个非常灵活的机制来调查不同参数组合的模型性能,以及交叉验证(这允许在没有减少训练样本数量的情况下进行稳健的验证),这确实是一个更合理的做法,即使是专家工程师也是如此。此外,当执行不同的转换时,选择的影响可能会影响整个流程,因此,(我们将在下一节中看到一些例子)我总是建议同时应用网格搜索到所有组件,以便能够评估每个可能选择的交叉影响。

可视化

有时,可视化中间步骤和最终步骤的结果是有用/必要的。在这本书中,我们始终使用 matplotlib 来展示图表和图解,matplotlib 是 SciPy 的一部分,提供了一个灵活且强大的图形基础设施。即使它不是本书的一部分,读者也可以轻松修改代码以获得不同的结果;为了更深入的理解,请参阅 Mcgreggor D.的《精通 matplotlib》,Packt 出版社。由于这是一个不断发展的领域,许多新的项目正在开发中,提供了新的、更时尚的绘图功能。其中之一是 Bokeh(bokeh.pydata.org),它使用一些 JavaScript 代码创建可以嵌入到网页中的交互式图表。

scikit-learn 工具用于机器学习架构

现在我们将介绍两个非常重要的 scikit-learn 类,这些类可以帮助机器学习工程师创建复杂的数据处理结构,包括从原始数据集中生成所需结果所需的所有步骤。

管道

scikit-learn 提供了一个灵活的机制来创建由后续处理步骤组成的管道。这是由于大多数类实现了标准接口,因此大多数组件(包括数据处理/转换器和分类器/聚类工具)可以无缝交换。类Pipeline接受一个参数steps,它是一个元组列表,形式为(组件名称—实例),并创建一个具有标准 fit/transform 接口的复杂对象。例如,如果我们需要应用 PCA、标准缩放,然后我们想使用 SVM 进行分类,我们可以按以下方式创建一个管道:

from sklearn.decomposition import PCA
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.svm import SVC

>>> pca = PCA(n_components=10)
>>> scaler = StandardScaler()
>>> svc = SVC(kernel='poly', gamma=3)

>>> steps = [
>>>    ('pca', pca),
>>>    ('scaler', scaler),
>>>    ('classifier', svc)
>>> ]

>>> pipeline = Pipeline(steps)

在这一点上,管道可以像单个分类器一样拟合(使用标准的fit()fit_transform()方法),即使输入样本首先通过PCA实例,通过StandardScaler实例对减少的数据集进行归一化,最后,将得到的样本传递给分类器。

管道与GridSearchCV一起也非常有用,可以评估不同参数组合的不同组合,不仅限于单个步骤,而是考虑整个流程。考虑到前面的例子,我们可以创建一个虚拟数据集并尝试找到最佳参数:

from sklearn.datasets import make_classification

>>> nb_samples = 500
>>> X, Y = make_classification(n_samples=nb_samples, n_informative=15, n_redundant=5, n_classes=2)

数据集相当冗余。因此,我们需要找到 PCA 的最佳组件数量和 SVM 的最佳核。当使用管道工作时,必须使用组件 ID 后跟两个下划线,然后是实际名称来指定参数的名称,例如classifier__kernel(如果您想检查所有具有正确名称的可接受参数,只需执行:print(pipeline.get_params().keys()))。因此,我们可以执行以下参数字典的网格搜索:

from sklearn.model_selection import GridSearchCV

>>> param_grid = {
>>>    'pca__n_components': [5, 10, 12, 15, 18, 20],
>>>    'classifier__kernel': ['rbf', 'poly'],
>>>    'classifier__gamma': [0.05, 0.1, 0.2, 0.5],
>>>    'classifier__degree': [2, 3, 5]
>>> }

>>> gs = GridSearchCV(pipeline, param_grid)
>>> gs.fit(X, Y)

如预期,最佳估计量(这是一个完整的管道)有 15 个主成分(这意味着它们是不相关的)和一个具有相对较高gamma值(0.2)的径向基函数 SVM:

>>> print(gs.best_estimator_)
Pipeline(steps=[('pca', PCA(copy=True, iterated_power='auto', n_components=15, random_state=None,
  svd_solver='auto', tol=0.0, whiten=False)), ('scaler', StandardScaler(copy=True, with_mean=True, with_std=True)), ('classifier', SVC(C=1.0, cache_size=200, class_weight=None, coef0=0.0,
  decision_function_shape=None, degree=2, gamma=0.2, kernel='rbf',
  max_iter=-1, probability=False, random_state=None, shrinking=True,
  tol=0.001, verbose=False))])

对应的分数是:

>>> print(gs.best_score_)
0.96

还可以使用PipelineGridSearchCV一起评估不同的组合。例如,比较一些分解方法与各种分类器混合可能很有用:

from sklearn.datasets import load_digits
from sklearn.decomposition import NMF
from sklearn.feature_selection import SelectKBest, f_classif
from sklearn.linear_model import LogisticRegression

>>> digits = load_digits()

>>> pca = PCA()
>>> nmf = NMF()
>>> kbest = SelectKBest(f_classif)
>>> lr = LogisticRegression()

>>> pipeline_steps = [
>>>    ('dimensionality_reduction', pca),
>>>    ('normalization', scaler),
>>>    ('classification', lr)
>>> ]

>>> pipeline = Pipeline(pipeline_steps)

我们想比较主成分分析PCA)、非负矩阵分解NMF)和基于 ANOVA 准则的 k 最佳特征选择,以及逻辑回归和核化 SVM:

>>> pca_nmf_components = [10, 20, 30]

>>> param_grid = [
>>>    {
>>>        'dimensionality_reduction': [pca],
>>>        'dimensionality_reduction__n_components': pca_nmf_components,
>>>        'classification': [lr],
>>>        'classification__C': [1, 5, 10, 20]
>>>    },
>>>    {
>>>        'dimensionality_reduction': [pca],
>>>        'dimensionality_reduction__n_components': pca_nmf_components,
>>>        'classification': [svc],
>>>        'classification__kernel': ['rbf', 'poly'],
>>>        'classification__gamma': [0.05, 0.1, 0.2, 0.5, 1.0],
>>>        'classification__degree': [2, 3, 5],
>>>        'classification__C': [1, 5, 10, 20]
>>>    },
>>>    {
>>>        'dimensionality_reduction': [nmf],
>>>        'dimensionality_reduction__n_components': pca_nmf_components,
>>>        'classification': [lr],
>>>        'classification__C': [1, 5, 10, 20]
>>>    },
>>>    {
>>>        'dimensionality_reduction': [nmf],
>>>        'dimensionality_reduction__n_components': pca_nmf_components,
>>>        'classification': [svc],
>>>        'classification__kernel': ['rbf', 'poly'],
>>>        'classification__gamma': [0.05, 0.1, 0.2, 0.5, 1.0],
>>>        'classification__degree': [2, 3, 5],
>>>        'classification__C': [1, 5, 10, 20]
>>>    },
>>>    {
>>>        'dimensionality_reduction': [kbest],
>>>        'classification': [svc],
>>>        'classification__kernel': ['rbf', 'poly'],
>>>        'classification__gamma': [0.05, 0.1, 0.2, 0.5, 1.0],
>>>        'classification__degree': [2, 3, 5],
>>>        'classification__C': [1, 5, 10, 20]
>>>    },
>>> ]

>>> gs = GridSearchCV(pipeline, param_grid)
>>> gs.fit(digits.data, digits.target)

执行网格搜索,我们得到由 PCA(原始数据集有 64 个特征)和具有非常小的gamma值(0.05)和中等(5.0)L2惩罚参数C的 RBF SVM 组成的管道:

>>> print(gs.best_estimator_)
Pipeline(steps=[('dimensionality_reduction', PCA(copy=True, iterated_power='auto', n_components=20, random_state=None,
  svd_solver='auto', tol=0.0, whiten=False)), ('normalization', StandardScaler(copy=True, with_mean=True, with_std=True)), ('classification', SVC(C=5.0, cache_size=200, class_weight=None, coef0=0.0,
  decision_function_shape=None, degree=2, gamma=0.05, kernel='rbf',
  max_iter=-1, probability=False, random_state=None, shrinking=True,
  tol=0.001, verbose=False))])

考虑到需要捕捉数字表示中的小细节,这些值是最佳选择。这个管道的分数确实非常高:

>>> print(gs.best_score_)
0.968836950473

特征组合

scikit-learn 提供的另一个有趣类别是 FeatureUnion,它允许将不同的特征转换连接到一个单一的输出矩阵中。与管道(也可以包括特征联合)的主要区别在于,管道从不同的场景中选择,而特征联合创建了一个统一的数据集,其中不同的预处理结果被合并在一起。例如,考虑到之前的结果,我们可以尝试通过执行具有 10 个组件的 PCA 并选择根据 ANOVA 指标选择的最佳 5 个特征来优化我们的数据集。这样,维度从 20 减少到 15:

from sklearn.pipeline import FeatureUnion

>>> steps_fu = [
>>>    ('pca', PCA(n_components=10)),
>>>    ('kbest', SelectKBest(f_classif, k=5)),
>>> ]

>>> fu = FeatureUnion(steps_fu)

>>> svc = SVC(kernel='rbf', C=5.0, gamma=0.05)

>>> pipeline_steps = [
>>>    ('fu', fu),
>>>    ('scaler', scaler),
>>>    ('classifier', svc)
>>> ]

>>> pipeline = Pipeline(pipeline_steps)

我们已经知道径向基函数支持向量机(RBF SVM)是一个不错的选择,因此我们保持架构的其余部分不变。进行交叉验证后,我们得到:

from sklearn.model_selection import cross_val_score
 >>> print(cross_val_score(pipeline, digits.data, digits.target, cv=10).mean())
0.965464333604

得分略低于之前(< 0.002),但特征数量已显著减少,因此计算时间也有所减少。将不同数据预处理器的输出合并是一种数据增强形式,当原始特征数量过高或冗余/噪声时,必须始终考虑这一点,因为单一分解方法可能无法成功捕捉所有动态。

参考文献

  • Mcgreggor D.,《精通 matplotlib》,Packt

  • Heydt M.,《学习 pandas - Python 数据发现与分析变得简单》,Packt

摘要

在本章的最后,我们讨论了机器学习架构的主要元素,考虑了一些常见的场景和通常用于防止问题和提高整体性能的程序。在仔细评估之前,不应丢弃任何这些步骤,因为模型的成功取决于许多参数和超参数的联合作用,而找到最佳最终配置的起点是考虑所有可能的预处理步骤。

我们看到网格搜索是一个强大的调查工具,并且通常与一套完整的替代管道(包括或不包括特征联合)一起使用是一个好主意,以便在全局场景中找到最佳解决方案。现代个人计算机足够快,可以在几小时内测试数百种组合,而当数据集太大时,可以使用现有的提供商之一提供云服务器。

最后,我想再次强调,到目前为止(也考虑到深度学习领域的研究),创建一个运行良好的机器学习架构需要持续分析替代解决方案和配置,而对于任何但最简单的情况,都没有一劳永逸的解决方案。这是一门仍然保持着艺术之心的科学!

posted @ 2025-09-04 14:12  绝不原创的飞龙  阅读(4)  评论(0)    收藏  举报