正则化秘籍-全-

正则化秘籍(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

序言

你是否曾想过为什么如此多的机器学习项目在生产环境中失败?

在许多情况下,这是由于模型缺乏泛化能力,导致在面对新的、未见过的数据时做出意外的预测。这就是正则化的核心:确保即使面对新数据,模型也能提供预期的预测。

在本书中,我们将探讨多种形式的正则化。为了实现这一点,我们将根据本章中的方案,探索两条主要的正则化解决方案路径:

  • 当给定一个机器学习模型时,我们如何进行正则化?正则化最适合用于模型已经被设定(无论是更新已有的遗留解决方案,还是有强需求)的应用场景,且训练数据是固定的,因此唯一的解决方案是对模型进行正则化。

  • 给定一个机器学习任务,我们如何获得一个稳健、良好泛化的解决方案?这种方法最适合用于那些仅定义了问题但尚未提供强约束条件的应用场景,从而可以探索更多的解决方案。

希望这些方法能够为你提供必要的工具和技巧,以解决你可能面临的需要正则化的绝大多数机器学习问题,并且深入理解其中的基本概念。

本书适合谁阅读

本书适合任何有一定 Python 知识的人:充分理解本书所提出的解决方案的唯一要求是你能够阅读和运行简单的 Python 代码。对于每一种新方法或模型,都会提供一些背景和实用的解释,以便任何具有计算机科学背景的人都能完全理解自己在做什么。

虽然任何 Python 从业者都可以跟随本书学习,但主要目标读者是以下人群:

  • 机器学习从业者,如机器学习工程师、应用科学家和数据科学家,他们希望在面对新问题或任务时,可以从本书中挑选现成的解决方法和代码。通过本书,希望他们能够通过稍微调整代码,处理许多不同的情况。

  • 想要深入了解机器学习的爱好者,通过具体的实例和可运行的代码获得更深的理解。通过本书,他们可以通过动手实践,深入掌握知识,并构建一个扎实的项目组合。

本书的内容

第一章正则化概述,为正则化提供了一个高层次的介绍,并提供了所有基本知识和词汇,帮助读者完全理解本书后续章节的内容。

第二章机器学习复习,引导你通过一个典型的机器学习工作流程和最佳实践,从数据加载和划分到模型训练和评估。

第三章线性模型中的正则化,介绍了常见线性模型的正则化:线性回归和逻辑回归。包括 L1 和 L2 惩罚的正则化,以及如何选择合适正则化方法的一些实用技巧。

第四章基于树的模型中的正则化,提供了关于决策树(用于分类和回归)的提醒,以及如何对其进行正则化。接着介绍了集成方法,如随机森林和梯度提升,以及它们的正则化方法。

第五章数据正则化,介绍了通过数据进行正则化,利用哈希及其特性和特征聚合。接着介绍了处理不平衡数据集的重采样方法。

第六章深度学习提醒,提供了有关深度学习的概念性和实践性提醒。从感知机开始,然后我们训练回归和分类模型。

第七章深度学习正则化,介绍了深度学习模型的正则化。探讨并解释了几种技术:L2 惩罚、提前停止、网络架构和 dropout。

第八章使用循环神经网络的正则化,深入探讨了循环神经网络RNNs)和门控循环单元GRUs)。首先解释了它们是什么以及如何训练这些模型。接着介绍了正则化技术,例如 dropout 和最大序列长度。

第九章自然语言处理中的高级正则化,探讨了专门针对自然语言处理NLP)的正则化方法。介绍了使用 word2vec 嵌入和 BERT 嵌入的正则化技术。还探讨了使用 word2vec 和 GPT-3 的数据增强方法,并提出了零-shot 推理解决方案。

第十章计算机视觉中的正则化,深入探讨了计算机视觉中的正则化和卷积神经网络CNNs)。在概念和实践上解释了 CNNs 在分类中的应用,然后提供了适用于物体检测和语义分割的正则化方案。

第十一章计算机视觉中的正则化 合成图像生成,深入探讨了用于正则化的合成图像生成。首先探讨了简单的数据增强方法。然后,使用仅有合成训练数据的 QR 码物体检测机制得到了构建。最后,我们探索了一种基于 Stable Diffusion 数据进行训练的实时风格迁移,并解释了如何独立使用该数据集进行工作。

为了从本书中获得最大的收获

您需要安装 Python 的某个版本。所有代码都已在 Ubuntu 22.04 上使用 Python 3.10 和 CUDA 12.1 版本进行测试。然而,代码应该能够在任何操作系统上使用 Python 3.9 及更高版本,并且支持 CUDA 11 及更高版本。

书中涵盖的软件/硬件 操作系统要求
Python 3.9 Windows, macOS 或 Linux(任意)

对于深度学习章节,特别是从第八章开始,建议使用图形处理单元GPU)。代码是在带有 24 GB 内存的 Nvidia GeForce RTX 3090 上测试的。根据您的硬件配置,代码可能需要相应调整。

食谱从第二章开始。 如果您使用的是本书的数字版,我们建议您自己输入代码或通过 GitHub 仓库访问代码(链接将在下一节提供)。这样做有助于避免因复制粘贴代码而导致的潜在错误。

下载示例代码文件

您可以从 GitHub 下载本书的示例代码文件,网址为github.com/PacktPublishing/The-Regularization-Cookbook。如果代码有更新,它将会在现有的 GitHub 仓库中进行更新。

我们还提供了来自我们丰富图书和视频目录中的其他代码包,您可以在github.com/PacktPublishing/查看!

使用的规范

本书中使用了多种文本规范。

文本中的代码:表示文本中的代码词语、数据库表名、文件夹名称、文件名、文件扩展名、路径名、虚拟网址、用户输入和 Twitter 账号。示例:“这两行应该下载一个.zip文件,然后解压其内容,以便获得名为Tweets.csv的文件。”

代码块设置如下:

# Load data data = pd.read_csv('Tweets.csv')data[['airline_sentiment', 'text']].head()

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

ip install pandas numpy scikit-learn matplotlib torch transformers

提示或重要说明

以这种方式显示。

章节

在本书中,您会看到几个常见的标题(准备工作如何操作...它是如何工作的...还有更多...,以及参见)。

为了清楚地说明如何完成食谱,使用以下各节:

准备工作

本节告诉您食谱中会涉及什么,并描述了为该食谱设置所需软件或任何预备设置的步骤。

如何操作…

本节包含执行该食谱所需的步骤。

它是如何工作的…

本节通常包含对上一节发生内容的详细解释。

还有更多…

本节包含有关食谱的额外信息,以帮助您更深入地了解该食谱。

参见

本节提供了其他有用信息的链接,供您参考。

联系我们

我们欢迎读者的反馈。

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

勘误:尽管我们已尽力确保内容的准确性,但错误仍然可能发生。如果您在本书中发现了错误,我们将不胜感激。如果您发现错误,请访问 www.packtpub.com/support/errata,选择您的书籍,点击“勘误提交表单”链接并填写详细信息。

盗版:如果您在互联网上发现我们作品的任何非法版本,请提供该地址或网站名称。请通过 copyright@packt.com 联系我们,并附上材料的链接。

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

分享您的想法

一旦您阅读了《正则化手册》,我们很想听听您的想法!请 点击此处直接前往 Amazon 评价页面,分享您的反馈。

您的评价对我们和技术社区都很重要,它将帮助我们确保提供优质的内容。

.

下载本书的免费 PDF 副本

感谢您购买本书!

您喜欢随时随地阅读,但又无法携带印刷书籍吗?

您的电子书购买无法与您选择的设备兼容吗?

不用担心,现在购买每一本 Packt 书籍,您都能免费获得该书的无 DRM 版本 PDF。

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

福利不止这些,您还可以每天在邮箱中获得独家折扣、时事通讯和精彩的免费内容。

按照以下简单步骤即可获得福利:

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

https://packt.link/free-ebook/9781837634088

  1. 提交您的购买证明

  2. 就是这样!我们将直接将免费的 PDF 和其他福利发送到您的电子邮件中

第一章:正则化概述

让我们开始进入机器学习正则化的世界。我希望你能学到很多,并像我在写这本书时那样享受阅读的乐趣。

正则化对于任何希望部署强健机器学习ML)模型的人来说都至关重要。

本章将在深入探讨正则化之前,介绍一些背景知识和关键概念。此时,你可能对本书和正则化本身有许多疑问。什么是正则化?为什么我们需要为生产级的机器学习模型进行正则化?如何诊断是否需要正则化?正则化的局限性是什么?正则化的方法有哪些?

本章将提供有关正则化的所有基础知识,旨在回答所有这些问题。这不仅会让你对正则化有一个高层次的理解,而且还会让你充分理解本书接下来几章提出的方法和技巧。

在本章中,我们将涵盖以下主要主题:

  • 介绍正则化

  • 在玩具数据集上发展正则化的直觉

  • 介绍欠拟合、过拟合、偏差和方差的关键概念

技术要求

在本章中,你将有机会生成一个玩具数据集,展示它,并在该数据上训练基本的线性回归。因此,以下 Python 库是必需的:

  • NumPy

  • Matplotlib

  • scikit-learn

介绍正则化

“机器学习中的正则化是一种通过为模型的参数添加额外约束来提高模型泛化能力的技术。这样可以迫使模型使用更简单的表示方式,并帮助减少过拟合的风险。

正则化还可以通过鼓励模型学习更相关、具有更强泛化能力的特征,帮助提高模型在未见数据上的表现。

这个关于正则化的定义,虽然可以说已经足够好,但实际上是由著名的 GPT-3 模型在给定以下提示时生成的:“机器学习中正则化的详细定义”。更令人惊讶的是,这个定义通过了几次抄袭测试,这意味着它实际上是完全原创的。如果你现在还不理解 GPT-3 给出的这个定义中的所有词汇,不用担心;它并非面向初学者的定义。但到本章结束时,你将完全理解它。

注意

GPT-3,全称生成式预训练变换器 3,是由 OpenAI 提出的一个 1750 亿参数的模型,并且可以通过platform.openai.com/playground使用。

你可以很容易地想象,为了得到这样的结果,GPT-3 不仅在大量数据上进行了训练,而且经过了精心的正则化处理,这样它就不会只是简单地复述已学的文本,而是会生成新的内容。

这正是正则化的核心:能够在面对未知情况时进行泛化,并产生可接受的结果。

为什么正则化对机器学习如此重要?成功将机器学习部署到生产环境的关键在于模型能否有效适应并处理新数据。一旦模型投入生产,它将不会接收到已知的、标准化的输入数据。生产中的模型很可能会面对未见过的数据、异常情景、特征分布的变化或不断变化的客户行为。虽然一个正则化良好的机器学习模型可能无法保证其在处理各种场景时的鲁棒性,但一个正则化不良的模型几乎可以确定会在初次部署时遇到失败。

现在让我们来看几个近年在部署过程中失败的模型示例,以便我们能充分理解为什么正则化如此重要。

没有通过部署测试的模型示例

过去几年充满了在部署初期就失败的模型示例。根据 2020 年 Gartner 报告(www.gartner.com/en/newsroom/press-releases/2020-10-19-gartner-identifies-the-top-strategic-technology-trends-for-2021#:~:text=Gartner%20research%20shows%20only%2053,a%20production%2Dgrade%20AI%20pipeline),超过 50%的人工智能原型将无法进入生产部署。并非所有的失败都仅仅是因为正则化问题,但有些确实是。

让我们快速回顾一下过去几年中一些在生产中部署失败的尝试:

这些只是一些来自科技巨头头条新闻的例子。经历失败但仍未对公众披露的项目数量令人震惊。这些失败通常涉及较小的公司和保密的计划。但仍然有几个例子可以从中学到教训:

  • 亚马逊的案例:输入数据对女性存在偏见,而模型也存在这种偏见

  • 微软的案例:由于它在新推文上的反馈过于敏感

  • IBM 的案例:该模型可能过度训练于合成或不现实的数据,而不能适应边缘案例和未见过的数据

正则化作为增强 ML 模型在生产中成功率的宝贵方法。有效的正则化技术可以通过消除某些特征或整合合成数据来防止 AI 招聘模型展现性别偏见。此外,适当的正则化使得聊天机器人能够保持对新推文的适当敏感性。它还使模型能够在训练于合成数据的情况下,熟练处理边缘案例和以前未见过的数据。

免责声明,可能还有许多其他克服或预防这些失败的方法,并且这些方法与正则化并不是互斥的。例如,拥有高质量的数据至关重要。AI 领域的每个人都知道这句格言 garbage in, garbage out

MLOps(一个日益成熟的领域)和 ML 工程的最佳实践也是许多项目成功的关键。主题知识有时也可能产生影响。

根据项目的上下文,许多其他参数可能会影响 ML 项目的成功,但是除了正则化之外的任何内容都超出了本书的范围。

现在我们理解了在生产级 ML 模型中为什么需要正则化,让我们退一步,通过一个简单的例子对正则化有些直觉。

对正则化的直觉

正则化已经在本书中定义和提到过,但现在让我们尝试发展一些关于它究竟是什么的直觉。

让我们考虑一个典型的现实世界用例:巴黎市一个公寓(或房屋)的平方米房价作为其表面积的函数。从业务角度来看,目标是能够预测每平方米的价格,给定公寓的表面积。

首先,我们需要一些导入,以及一个更方便地绘制数据的辅助函数。

plot_data() 函数简单地绘制提供的数据,并在需要时添加轴标签和图例:

import numpy as np
import matplotlib.pyplot as plt
from sklearn.linear_model import LinearRegression
np.random.seed(42)
def plot_data(surface: np.array, price: np.array,
    fit: np.array = None, legend: bool = False):
    plt.scatter(surface, price, label='data')
    if fit is not None:
        plt.plot(surface, fit, label='fit')
    if legend:
        plt.legend()
    plt.ylim(11300, 11550)
    plt.xlabel('Surface (m$^{2}$)')
    plt.ylabel('Price (€/m$^{2}$)')
    plt.grid(True)
    plt.show()

下面的代码现在将允许我们生成和显示我们的第一个玩具数据集:

# Define the surfaces and prices
surface = np.array([15, 17, 20, 22, 25, 28]).reshape(-1, 1)
price = 12000 - surface*50 + np.square(
    surface) + np.random.normal(0, 30, surface.shape)
# Plot the data
plot_data(surface, price)

这里是它的图表:

图 1.1 – 每平方米价格随公寓面积变化的图

图 1.1 – 每平方米价格随公寓面积变化的图

即便这只是一个示例数据集,为了教学目的,我们可以假设这些数据是从房地产市场中收集的。

我们可以看到,随着公寓面积的增加,每平方米价格呈下降趋势。事实上,在巴黎,小面积的公寓需求更大(可能是因为有很多学生,或者因为价格更实惠)。这或许能解释为什么小面积公寓的每平方米价格实际上更高。

为了简便起见,我们将省略所有典型的机器学习工作流程。在这里,我们仅对这些数据执行线性回归,并使用以下代码显示结果:

# Perform a linear regression on the data
lr = LinearRegression()
lr.fit(surface, price)
# Compute prediction
y_pred = lr.predict(surface)
# Plot data
plot_data(surface, price, y_pred, True)

这是输出:

图 1.2 – 每平方米价格随公寓面积变化及拟合曲线

图 1.2 – 每平方米价格随公寓面积变化及拟合曲线

足够好了!拟合似乎捕捉到了下降趋势。虽然与所有给定的数据样本不完全吻合,但目前商业方面对其表示满意,因为模型的表现符合预期。

借助这个新模型,公司现在获得了更多客户。从这些客户那里,公司收集了一些新的数据,涉及较大公寓的销售,因此我们的数据集现在看起来如下:

# Generate data
updated_surface = np.array([15, 17, 20, 22, 25, 28, 30, 33,
    35, 37]).reshape(-1, 1)
updated_price = 12000 - updated_surface*50 + np.square(
    updated_surface) + np.random.normal(0, 30, updated_surface.shape)
# Plot data
plot_data(updated_surface, updated_price)

这是它的图表:

图 1.3 – 每平方米价格随公寓面积变化的更新图

图 1.3 – 每平方米价格随公寓面积变化的更新图

这实际上改变了所有情况;这是典型的失败部署测试。现在已经没有全局的下降趋势了:随着公寓面积的增大,每平方米价格似乎跟随上升趋势。一个简单的商业解释可能是:大面积的公寓可能较为稀缺,因此价格更高。

没有信心的情况下,为了尝试,我们重新使用之前相同的方法:线性回归。结果将如下:

# Perform linear regression and plot result
lr = LinearRegression()
lr.fit(updated_surface, updated_price)
y_pred_updated = lr.predict(updated_surface)
plot_data(updated_surface, updated_price, y_pred_updated, True)

这是图表:

图 1.4 – 欠拟合示例:模型未能完全捕捉数据的复杂性

图 1.4 – 欠拟合示例:模型未能完全捕捉数据的复杂性

正如预期的那样,线性回归已无法捕捉数据的复杂性,导致了这种情况。这被称为欠拟合;模型未能完全捕捉数据的复杂性。事实上,仅以面积作为参数,模型能做到的最好就是一条直线,这对于这些数据来说是不够的。

线性回归捕捉更多复杂性的一个方法是提供更多特征。鉴于我们当前的输入数据仅限于表面,可能的直接方法是利用指数表面。为了这个示例,我们采取了一个相对极端的方法,添加了从power1power15的所有特征,并使它们拟合该数据集。这可以通过以下代码轻松实现:

# Compute power up to 15
x_power15 = np.concatenate([np.power(
    updated_surface, i+1) for i in range(15)], 1)
# Perform linear regression and plot result
lr = LinearRegression()
lr.fit(x_power15, updated_price)
y_pred_power15 = lr.predict(x_power15)
plot_data(updated_surface, updated_price, y_pred_power15, True)

这是它的输出:

图 1.5 – 过拟合示例:模型捕捉到数据中的噪声

图 1.5 – 过拟合示例:模型捕捉到数据中的噪声

正如我们所预期的那样,随着在模型中添加了如此多的自由度,拟合现在正好通过所有数据点。事实上,不深入探讨数学细节,模型的参数比它训练的数据点还多,因此能够通过所有这些点。但是,这真的是一个好的拟合吗?我们可以想象,它不仅捕捉到了数据的全局趋势,还捕捉到了噪声。这被称为过拟合:模型过于贴近数据,可能无法对新的、未见过的数据做出正确的预测。任何新的数据点都不会在曲线上,且训练范围之外的行为完全不可预测。

最终,对于这种情况,一个更合理的方法是只取表面到 2 次方,例如:

# Compute power up to 2
x_power2 = np.concatenate([np.power(
    updated_surface, i+1) for i in range(2)], 1)
# Perform linear regression and plot result
lr = LinearRegression()
lr.fit(x_power2, updated_price)
y_pred_power2 = lr.predict(x_power2)
plot_data(updated_surface, updated_price, y_pred_power2, True)

这是它的输出:

图 1.6 – 正确拟合示例:模型捕捉到整体趋势但没有捕捉到噪声

图 1.6 – 正确拟合示例:模型捕捉到整体趋势,但没有捕捉到噪声

现在,拟合看起来要更可接受了。它确实捕捉到了数据的全局趋势:最初对小表面呈下降趋势,然后对较大表面呈上升趋势。此外,它不再试图捕捉所有数据点中的噪声,使得它在预测新的、未见过的数据时更具鲁棒性。最终,超出训练范围的行为(例如,表面 < 15 或表面 > 40)相当可预测。

这正是一个好的模型所期望的行为:既不欠拟合也不过拟合。通过移除一些指数特征,我们的模型能够更好地泛化;我们实际上对模型进行了正则化。

总结这个示例,我们在这里探讨了几个概念:

  • 我们展示了欠拟合、过拟合和良好正则化模型的示例

  • 通过向特征中添加指数表面,我们成功地将模型从欠拟合过渡到过拟合

  • 最终,通过移除大部分指数特征(仅保留平方特征),我们成功将模型从过拟合调整为良好正则化的模型,有效地加入了正则化。

希望你现在对欠拟合、过拟合和正则化有了很好的理解,也理解了它们在机器学习中的重要性。现在我们可以基于此进一步构建,给出正则化关键概念的更正式定义。

正则化的关键概念

在获得了一些关于何为合适拟合的直觉,并理解了欠拟合和过拟合的例子后,让我们更精确地定义这些概念,并探讨正则化的关键概念,帮助我们更好地理解正则化。

偏差和方差

偏差和方差是讨论正则化时的两个关键概念。我们可以定义模型可能产生的两种主要错误:

  • 偏差是模型在捕捉数据的整体行为方面的不足

  • 方差是模型对小的输入数据波动的鲁棒性差的表现

这两个概念通常不是互相排斥的。如果我们从机器学习退后一步,存在一个非常常见的图示来可视化偏差和方差,假设模型的目标是击中目标的中心:

图 1.7 – 偏差和方差的可视化

图 1.7 – 偏差和方差的可视化

让我们描述这四种情况:

  • 高偏差和低方差:模型偏离目标中心,但方式非常一致

  • 低偏差和高方差:模型平均上击中目标的中心,但在此过程中非常嘈杂且不一致

  • 高偏差和高方差:模型以一种嘈杂的方式偏离中心

  • 低偏差和低方差:两者兼得——模型稳定地击中目标中心

欠拟合与过拟合

我们看到了一个非常经典的偏差和方差定义方法。

但是现在,这在机器学习中意味着什么呢?它与正则化有什么关系?在我们深入探讨之前,我们首先将回顾偏差和方差在一个更典型的机器学习案例中的表现:房地产价格的线性回归。

让我们看看模型在这些情况下如何在我们的数据上表现。

高偏差和低方差

模型对数据波动具有鲁棒性,但无法捕捉到数据的高层次行为。请参考下图:

图 1.8 – 线性回归中的高偏差和低方差实践

图 1.8 – 线性回归中的高偏差和低方差实践

这是欠拟合,正如我们之前在图 1.4中所遇到的那样。

低偏差和高方差

模型确实捕捉到了数据的全局行为,但无法保持对输入数据波动的鲁棒性。请参考下图:

图 1.9 – 线性回归中的低偏差和高方差实践

图 1.9 – 线性回归中的低偏差和高方差实践

这是过拟合,正如我们之前在图 1.5中遇到的情况。

高偏差和高方差

模型既无法捕捉全局行为,也不足够稳健,无法应对数据波动。这种情况通常不会发生,或者说高方差被隐藏在高偏差背后,但可能看起来像以下这样:

图 1.10 – 线性回归中的高偏差和高方差:这种情况在这样的数据中几乎不会发生

图 1.10 – 线性回归中的高偏差和高方差:这种情况在这样的数据中几乎不会发生

低偏差和低方差

模型能够既捕捉全局数据行为,又足够稳健以应对数据波动。这是训练模型时的最终目标。这正是我们在图 1.6中遇到的情况。

图 1.11 – 线性回归中的低偏差和低方差:最终目标

图 1.11 – 线性回归中的低偏差和低方差:最终目标

当然,目标几乎总是希望得到低偏差和低方差,即便这并不总是可能的。让我们看看正则化如何作为实现这个目标的手段。

正则化——从过拟合到欠拟合

通过以上这些例子,我们现在可以清楚地理解正则化的含义。

如果我们再次回顾 GPT-3 给出的定义,正则化通过向模型添加约束来防止模型过拟合。实际上,加入正则化使我们能够降低模型的方差,从而使模型的过拟合程度减轻。

我们可以进一步推测:如果正则化被加入到一个已经训练良好的模型中(即低偏差和低方差的模型),会怎样呢?换句话说,如果在一个表现良好的模型中添加约束,会发生什么呢?直觉上,这会降低模型的整体表现。它不会让模型完全理解数据的行为,因此会增加模型的偏差。

事实上,这里正是正则化的一个根本缺点:加入正则化会增加 模型偏差

这可以用一张图来总结:

图 1.12 – 一个高方差模型(底部);相同的模型加入更多正则化并达到适当拟合水平(中间);同样的模型加入更多正则化,现在发生了欠拟合(顶部)

图 1.12 – 一个高方差模型(底部);相同的模型加入更多正则化并达到适当拟合水平(中间);同样的模型加入更多正则化,现在发生了欠拟合(顶部)

这就是所谓的偏差-方差权衡,一个非常重要的概念。事实上,加入正则化总是一个平衡:

  • 我们需要有足够的正则化,使模型具有良好的泛化能力,并且不容易受小幅度数据波动和噪声的影响

  • 我们需要避免过多的正则化,以确保模型在任何情况下都能足够自由地完全捕捉数据的复杂性

随着我们在书中的深入,更多的工具和技术将被提供来诊断我们的模型并找到合适的偏差-方差平衡。

在接下来的章节中,我们将看到许多正则化模型的方法。我们通常认为正则化就是直接向模型添加约束,但实际上有许多间接的正则化方法可以帮助模型更好地泛化。现有正则化方法的非详尽清单可能包括以下内容:

  • 对模型架构添加约束

  • 向模型训练中添加约束,如损失函数

  • 通过不同的工程方法对输入数据添加约束

  • 通过生成更多样本来增加输入的约束

可能会提出其他正则化方法,但本书将主要关注适用于各种情况的方法,比如结构化和非结构化数据、线性模型、基于树的模型、深度学习模型、自然语言处理NLP)问题和计算机视觉问题。

即使模型采用了正确的正则化方法,许多任务对模型可以实现的表现仍有一个硬性上限:这就是我们所说的不可避免的偏差。让我们来看看它是什么。

不可避免的偏差

在几乎所有任务中,都有不可避免的偏差。例如,在图 1.13中,既有设得兰牧羊犬,也有粗毛牧羊犬。你能以 100%的准确度说出哪一只是哪一只吗?

图 1.13 – 设得兰牧羊犬和粗毛牧羊犬的随机图片

图 1.13 – 设得兰牧羊犬和粗毛牧羊犬的随机图片

从前面的图中,你能分辨出哪一只是哪一只吗?大概率是不能的。如果你是一个经过训练的犬类专家,你可能会有更低的错误率。但即便如此,给定足够数量的图片,你也可能会在某些图片上出错。专家能够达到的最低错误率被称为人类水平错误。在大多数情况下,人类水平错误能很好地反映出模型可能达到的最低错误。每当你在评估一个模型时,了解(或思考)人类在这种任务中的表现是一个很好的思路。

事实上,有些任务比其他任务容易得多:

  • 人类在分类狗和猫方面表现得很好,AI 也是如此

  • 人类在分类歌曲方面表现得很好,AI 也是如此

  • 人类在招聘人员方面表现得相当差(至少,并不是所有招聘人员都会就候选人达成一致),AI 也是如此

计算不可避免的偏差的另一个可能来源是贝叶斯误差。贝叶斯误差通常在复杂的 AI 任务中无法计算,它是分类器可以达到的最低错误率。大多数时候,贝叶斯误差低于人类水平误差,但估算起来要困难得多。这将是任何模型性能的实际理论限制。

贝叶斯误差和人类级别的误差是不可避免的偏差。它们表示任何模型的不可减少误差,是评估模型是否需要更多或更少正则化的关键概念。

偏差与方差的诊断

我们通常使用一些数据上的拟合图来定义偏差和方差,就像我们之前在公寓价格数据中做的那样。虽然这些图有助于解释概念,但在现实生活中,我们通常处理的是高维数据集。通过使用简单的 Titanic 数据集,我们提供了十几个特征,因此进行这种视觉检查几乎是不可能的。

假设我们正在训练一个猫狗分类模型,并且数据是平衡的。一种好的方法是比较训练集和验证集上的评估指标(无论是准确率、F 分数,或是任何你认为相关的指标)。

注意

如果评估指标或训练集与验证集的概念不清楚,它们将在第二章中有更详细的解释。简而言之,模型是在训练集上进行训练的。评估指标是用来评估训练好的模型的值,并且是在训练集和验证集上计算的。

例如,假设我们得到以下结果:

图 1.14 – 假设在训练集和验证集上的准确性

图 1.14 – 假设在训练集和验证集上的准确性

如果我们考虑到这类任务的预期人类级别错误,我们会预期更高的准确性。事实上,大多数人能够非常自信地从猫和狗中辨认出狗。

在这种情况下,训练集和验证集的表现远低于人类级别的错误率。这是典型的高偏差情形:评估指标在训练集和验证集上都很差。在这种情况下,模型需要更少的正则化。

现在假设在添加了较低的正则化(可能像我们在正则化直觉一节中所做的那样,添加了指数特征)之后,我们得到了以下结果:

图 1.15 – 添加更多特征后的假设准确性

图 1.15 – 添加更多特征后的假设准确性

这些结果更好;验证集现在的准确率为 89%。然而,这里有两个问题:

  • 训练集上的得分好得过头了:它简直是完美的

  • 验证集上的得分仍然远低于我们预期的至少 95% 的人类错误率

这是典型的高方差情形:在训练集上的结果非常好(通常过好),而在验证集上则远低于预期。在这种情况下,模型需要更多的正则化。

添加正则化后,假设我们现在得到了以下结果:

图 1.16 – 添加正则化后的假设准确性

图 1.16 – 添加正则化后的假设准确性

这看起来好多了:训练集和验证集的准确度似乎接近人类水平的表现。也许通过更多的数据、一个更好的模型或其他改进,结果可以稍微提升一些,但总体来看,这似乎是一个稳固的结果。

在大多数情况下,诊断高偏差和高方差是很简单的,方法始终是相同的:

  1. 在训练集和验证集上评估你的模型。

  2. 将结果相互比较,并与人类水平的误差率进行对比。

从这一点来看,通常有三种情况:

  • 高偏差/欠拟合:训练集和验证集的表现都很差

  • 高方差/过拟合:训练集表现远好于验证集表现;验证集表现远低于人类水平的误差率

  • 良好的拟合:训练集和验证集的表现接近人类水平的误差率

注意

大多数时候,建议在训练集和测试集上使用该技术,而不是训练集和验证集。尽管这一推理是成立的,但直接在测试集上进行优化可能会导致过拟合,从而高估模型的实际表现。然而,在本书中,我们将简化处理,接下来的章节将使用测试集。

记住所有正则化的关键概念后,你现在可能开始理解为什么正则化确实可能需要一本书来讲解。虽然诊断出正则化的需求通常相对简单,但选择合适的正则化方法却可能非常具有挑战性。接下来,让我们对本书中将要讨论的正则化方法进行分类。

正则化——一个多维度的问题

对模型进行正确的诊断至关重要,因为它使我们能够更仔细地选择策略来改进模型。但是,从任何诊断出发,都有许多途径可以改进模型。这些途径可以分为三大类,正如下图所示:

图 1.17 – 提出的正则化类型分类:数据、模型架构和模型训练

图 1.17 – 提出的正则化类型分类:数据、模型架构和模型训练

在数据层面,我们可能会使用以下工具进行正则化:

  • 添加更多数据,无论是合成数据还是真实数据

  • 添加更多特征

  • 特征工程

  • 数据预处理

实际上,数据在机器学习中极为重要,正则化也不例外。在本书中,我们将看到许多正则化数据的例子。

在模型层面,可以使用以下方法进行正则化:

  • 选择更简单或更复杂的架构

  • 在深度学习中,许多架构设计允许正则化(例如,Dropout)

模型复杂度可能对正则化产生很大影响。过于复杂的架构很容易导致过拟合,而过于简单的架构则可能导致欠拟合,正如下图所示:

图 1.18 – 模型复杂度与训练集和验证集误差之间关系的可视化示意图

图 1.18 – 模型复杂度与训练集和验证集误差之间关系的可视化示意图

最后,在训练层面,一些正则化的方法如下:

  • 添加惩罚项

  • 权重初始化

  • 转移学习

  • 提前停止

提前停止是一种非常常见的防止过拟合的方法,通过防止模型过度贴合训练集来避免过拟合。

正则化的方法可能有很多,因为这是一个多维度的问题:数据、模型架构和模型训练仅仅是高层次的分类。即使这些分类只是一些例子,且可能还存在或定义更多的分类,但本书将要涵盖的大多数——如果不是所有——技术都会属于这些分类中的某一类。

总结

我们通过几个实际案例开始本章,展示了在生产环境中,正则化是机器学习成功的关键。结合其他一些方法和最佳实践,稳健的正则化模型是生产中不可或缺的。在生产环境中,未见过的数据和极端情况将定期出现,因此,任何部署的模型都必须能接受这些情况的响应。

接着,我们讲解了一些正则化的关键概念。过拟合和欠拟合是机器学习中的两个常见问题,且与偏差和方差有一定关系。实际上,过拟合的模型具有高方差,而欠拟合的模型具有高偏差。因此,为了表现良好,模型需要具有低偏差和低方差。我们解释了无论一个模型多么优秀,不可避免的偏差都限制了它的性能。这些关键概念使我们提出了一种方法,通过训练集和验证集的表现,以及人类水平的误差估计,来诊断偏差和方差。

这让我们了解了正则化的定义:正则化是为模型添加约束,使其能够很好地泛化到新数据,并且不会对小数据波动过于敏感。正则化是将过拟合模型转化为稳健模型的一个重要工具。然而,由于偏差-方差权衡,我们不能过度正则化,以免得到一个欠拟合的模型。

最后,我们将本书将要涵盖的不同正则化方法进行了分类。它们主要分为三类:数据、模型架构和模型训练。

本章并未包括任何实用技巧,而是建立了理解本书其余内容所需的基础知识,但接下来的章节将包括实用技巧,并将更多聚焦于解决方案。

第二章:机器学习复习

机器学习ML)远不止是模型。它是关于遵循某种过程和最佳实践。本章将为这些内容提供复习:从加载数据、模型评估到模型训练和优化,主要的步骤和方法将在这里解释。

在本章中,我们将涵盖以下主要主题:

  • 数据加载

  • 数据拆分

  • 准备定量数据

  • 准备定性数据

  • 训练模型

  • 评估模型

  • 执行超参数优化

尽管本章中的各个教程在方法论上是独立的,但它们是相互衔接的,旨在按顺序执行。

技术要求

在本章中,你需要能够运行代码来加载数据集、准备数据、训练、优化和评估机器学习模型。为此,你将需要以下库:

  • numpy

  • pandas

  • scikit-learn

它们可以使用pip和以下命令行安装:

pip install numpy pandas scikit-learn

注意

在本书中,一些最佳实践,如使用虚拟环境,将不会明确提及。然而,强烈建议在使用pip或其他包管理器安装任何库之前,先使用虚拟环境。

数据加载

本教程的主要内容是从 CSV 文件加载数据。然而,这并不是本教程的唯一内容。由于数据通常是任何机器学习项目的第一步,因此这个教程也是快速回顾机器学习工作流和不同类型数据的好机会。

准备工作

在加载数据之前,我们应记住,机器学习模型遵循一个两步过程:

  1. 在给定的数据集上训练模型,以创建一个新模型。

  2. 重用先前训练的模型,对新数据进行推理预测。

这两步在以下图示中总结:

图 2.1 – 两步机器学习过程的简化视图

图 2.1 – 两步机器学习过程的简化视图

当然,在大多数情况下,这是一个相当简化的视图。更详细的视图可以参见图 2.2:

图 2.2 – 更完整的机器学习过程视图

图 2.2 – 更完整的机器学习过程视图

让我们更仔细地看看机器学习过程中的训练部分,见图 2.2

  1. 首先,从数据源(如数据库、数据湖、开放数据集等)查询训练数据。

  2. 数据被预处理,例如通过特征工程、重缩放等。

  3. 训练并存储模型(存储在数据湖、本地、边缘等位置)。

  4. 可选地,模型的输出会进行后处理,例如通过格式化、启发式方法、业务规则等。

  5. 可选地,这个模型(无论是否进行后处理)会被存储在数据库中,以便在需要时进行后续参考或评估。

现在,让我们看看机器学习过程中的推理部分:

  1. 数据从数据源(数据库、API 查询等)中查询。

  2. 数据经过与训练数据相同的预处理步骤。

  3. 如果训练好的模型不存在本地,会从远程获取模型。

  4. 使用该模型推断输出。

  5. 可选地,模型的输出通过与训练数据相同的后处理步骤进行后处理。

  6. 可选地,输出存储在数据库中,以供监控和后续参考。

即使在这个架构中,许多步骤也未被提及:数据划分用于训练、使用评估指标、交叉验证、超参数优化等。本章将深入探讨更具体的训练步骤,并将其应用于非常常见但实用的泰坦尼克号数据集,这是一个二分类问题。但首先,我们需要加载数据。

为此,你必须将 泰坦尼克号数据集训练集 下载到本地。可以通过以下命令行执行此操作:

wget https://github.com/PacktPublishing/The-Regularization-Cookbook/blob/main/chapter_02/train.csv

如何做到……

这个配方是关于加载一个 CSV 文件并显示几行代码,以便我们可以初步了解它的内容:

  1. 第一步是导入所需的库。在这里,我们唯一需要的库是 pandas:

    import pandas as pd
    
  2. 现在,我们可以使用 pandas 提供的 read_csv 函数加载数据。第一个参数是文件的路径。假设文件名为 train.csv,并位于当前文件夹中,我们只需要提供 train.csv 作为参数:

    # Load the data as a DataFrame
    
    df = pd.read_csv('train.csv')
    

返回的对象是一个 dataframe 对象,它提供了许多用于数据处理的有用方法。

  1. 现在,我们可以使用 .head() 方法显示加载文件的前五行:

    # Display the first 5 rows of the dataset
    
    df.head()
    

该代码将输出以下内容:

   PassengerId  Survived  Pclass  \
0        1            0         3
1        2            1         1
2        3            1         3
3        4            1         1
4        5            0         3
      Name                      Sex   Age     SibSp  \
0   Braund, Mr. Owen Harris     male  22.0       1
1  Cumings, Mrs. John Bradley (Florence Briggs Th...
                               female  38.0        1
2  Heikkinen, Miss. Laina  female  26.0        0
3  Futrelle, Mrs. Jacques Heath (Lily May Peel)
                            female  35.0        1
4  Allen, Mr. William Henry     male  35.0        0
 Parch      Ticket   Fare   Cabin        Embarked
0  0         A/5   21171   7.2500   NaN           S
1  0       PC 17599  71.2833   C85       C
2  0      STON/O2\. 3101282   7.9250   NaN       S
3  0        113803  53.1000  C123           S
4  0        373450   8.0500   NaN    S

这里是每一列数据类型的描述:

  • PassengerId(定性):每个乘客的唯一任意 ID。

  • Survived(定性):1 表示是,0 表示否。这是我们的标签,因此这是一个二分类问题。

  • Pclass(定量,离散):乘客的舱位,通常是定量的。舱位 1 是否比舱位 2 更好?很可能是的。

  • Name(非结构化):乘客的姓名和头衔。

  • Sex(定性):乘客的注册性别,可以是男性或女性。

  • Age(定量,离散):乘客的年龄。

  • SibSp(定量,离散):船上兄弟姐妹和配偶的数量。

  • Parch(定量,离散):船上父母和子女的数量。

  • Ticket(非结构化):票务参考。

  • Fare(定量,连续):票价。

  • Cabin(非结构化):舱位号,可以说是非结构化的。它可以看作是一个具有高基数的定性特征。

  • Embarked(定性):登船城市,可以是南安普顿(S)、瑟堡(C)或皇后镇(Q)。

还有更多内容……

让我们来谈谈可用的不同数据类型。数据是一个非常通用的词,可以描述很多东西。我们总是被数据包围。指定数据的一种方式是使用对立面。

数据可以是 结构化非结构化

  • 结构化数据以表格、数据库、Excel 文件、CSV 文件和 JSON 文件的形式存在。

  • 非结构化数据不适合表格形式:它可以是文本、声音、图像、视频等。即使我们倾向于使用表格形式表示,这类数据也不自然适合放入 Excel 表格中。

数据可以是定量的定性的

定量数据是有序的。以下是一些例子:

  • €100 比 €10 大

  • 1.8 米比 1.6 米更高

  • 18 岁比 80 岁年轻

定性数据没有固有的顺序,如下所示:

  • 蓝色并不天生优于红色

  • 一只狗并不天生比一只猫更优秀

  • 一个厨房并不天生比一个浴室更有用

这些特征并非互相排斥。一个对象可以同时具有定量和定性特征,如下图所示的汽车案例:

图 2.3 – 一个对象的定量(左)和定性(右)特征表示

图 2.3 – 一个对象的定量(左)和定性(右)特征表示

最后,数据可以是连续的离散的

一些数据是连续的,如下所示:

  • 一个体重

  • 一个体积

  • 一个价格

另一方面,一些数据是离散的:

  • 一种颜色

  • 一个足球得分

  • 一个国籍

注意

离散数据 ≠ 定性数据。

例如,足球得分是离散的,但存在固有的顺序:3 分大于 2 分。

另见

pandas 的 read_csv 函数非常灵活,能够使用其他分隔符、处理标题等更多功能。这些都在官方文档中有所描述:pandas.pydata.org/docs/reference/api/pandas.read_csv.xhtml

pandas 库允许执行具有不同输入类型的 I/O 操作。欲了解更多信息,请查看官方文档:pandas.pydata.org/docs/reference/io.xhtml

分割数据

加载数据后,分割数据是一个关键步骤。本食谱将解释为什么我们需要分割数据,以及如何进行分割。

准备中

为什么我们需要分割数据?一个机器学习模型就像一个学生。

你给学生提供许多讲座和练习,带或不带答案。但通常,学生们会在一个全新的问题上进行评估。为了确保他们完全理解概念和方法,他们不仅学习练习和解决方案——还要理解其背后的概念。

一个机器学习模型也是一样:你在训练数据上训练模型,然后在测试数据上评估它。通过这种方式,你可以确保模型充分理解任务,并且能够很好地推广到新的、未见过的数据。

因此,数据集通常会被分割成训练集测试集

  • 训练集必须尽可能大,以便为模型提供尽可能多的样本

  • 测试集必须足够大,以便在评估模型时具有统计意义

对于较小的数据集(例如,几百个样本),常见的划分比例为 80% 对 20%;对于非常大的数据集(例如,数百万个样本),比例通常为 99% 对 1%。

在本章节的这个示例和其他示例中,假设代码是在与前一个示例相同的笔记本中执行的,因为每个示例都会重复使用前一个示例中的代码。

如何操作…

以下是尝试此示例的步骤:

  1. 你可以使用 scikit-learn 和 train_test_split() 函数相对容易地划分数据:

    # Import the train_test_split function
    
    from sklearn.model_selection import train_test_split
    
    # Split the data
    
    X_train, X_test, y_train, y_test = train_test_split(
    
        df.drop(columns=['Survived']), df['Survived'],
    
        test_size=0.2, stratify=df['Survived'],
    
        random_state=0)
    

该函数使用以下参数作为输入:

  • X:除了 'Survived' 标签列之外的所有列

  • y'Survived' 标签列

  • test_size:这是 0.2,意味着训练集的比例为 80%。

  • stratify:此参数指定 'Survived' 列,以确保在两个数据集划分中标签的平衡性相同。

  • random_state0 是任何整数值,用于确保结果的可复现性。

它返回以下输出:

  • X_trainX 的训练集。

  • X_testX 的测试集。

  • y_train:与 X_train 相关的 y 的训练集。

  • y_test:与 X_test 相关的 y 的测试集。

注意

stratify 选项不是强制性的,但对于确保任何定性特征(不仅仅是标签)的平衡划分非常关键,特别是对于不平衡数据的情况。

数据划分应尽早进行,以避免潜在的数据泄漏。从现在起,所有预处理步骤都会在训练集上计算,然后应用到测试集,这与 图 2.2 的做法一致。

另见

查看 train_test_split 函数的官方文档:scikit-learn.org/stable/modules/generated/sklearn.model_selection.train_test_split.xhtml

准备定量数据

根据数据类型,特征的准备方式可能会有所不同。在本例中,我们将讨论如何准备定量数据,包括缺失数据填补和重新缩放。

准备工作

在 Titanic 数据集中,和其他任何数据集一样,可能存在缺失数据。处理缺失数据的方法有很多种。例如,可以删除某一列或某一行,或者进行数据填补。填补方法有很多种,其中一些更复杂或者更简单。scikit-learn 提供了几种填补器的实现,例如 SimpleImputerKNNImputer

如我们在这个示例中所见,使用 SimpleImputer,我们可以用均值来填补缺失的定量数据。

一旦缺失数据得到处理,我们就可以通过重新缩放数据来准备定量数据,使得所有数据都在相同的尺度上。

有几种重新缩放策略,例如最小-最大缩放、鲁棒缩放、标准缩放等。

在本教程中,我们将使用标准缩放。因此,对于每个特征,我们将减去该特征的均值,然后除以该特征的标准差:

幸运的是,scikit-learn 通过StandardScaler提供了一个完整的实现。

如何操作……

在本教程中,我们将依次处理缺失值并重新缩放数据:

  1. 导入所需的类——SimpleImputer用于缺失数据插补,StandardScaler用于重新缩放:

    from sklearn.impute import SimpleImputer
    
    from sklearn.preprocessing import StandardScaler
    
  2. 选择我们要保留的定量特征。在这里,我们将保留'Pclass''Age''Fare''SibSp''Parch',并将这些特征存储在训练集和测试集的新变量中:

    quanti_columns = ['Pclass', 'Age', 'Fare', 'SibSp', 'Parch']
    
    # Get the quantitative columns
    
    X_train_quanti = X_train[quanti_columns]
    
    X_test_quanti = X_test[quanti_columns]
    
  3. 使用均值策略实例化简单填充器。在这里,特征的缺失值将被该特征的均值替代:

    # Impute missing quantitative values with mean feature value
    
    quanti_imputer = SimpleImputer(strategy='mean')
    
  4. 在训练集上拟合填充器,并将其应用于测试集,这样可以避免在插补中出现数据泄漏:

    # Fit and impute the training set
    
    X_train_quanti = quanti_imputer.fit_transform(X_train_quanti)
    
    # Just impute the test set
    
    X_test_quanti = quanti_imputer.transform(X_test_quanti)
    
  5. 现在插补已经完成,实例化scaler对象:

    # Instantiate the standard scaler
    
    scaler = StandardScaler()
    
  6. 最后,拟合并应用标准缩放器到训练集,然后将其应用于测试集:

    # Fit and transform the training set
    
    X_train_quanti = scaler.fit_transform(X_train_quanti)
    
    # Just transform the test set
    
    X_test_quanti = scaler.transform(X_test_quanti)
    

现在我们有了没有缺失值、完全缩放的定量数据,且没有数据泄漏。

还有更多……

在本教程中,我们使用了简单填充器,假设存在缺失数据。在实际应用中,强烈建议你首先查看数据,检查是否存在缺失值以及缺失的数量。你可以使用以下代码片段查看每列的缺失值数量:

# Display the number of missing data for each column
X_train[quanti_columns].isna().sum()

这将输出如下结果:

Pclass        0
Age         146
Fare           0
SibSp         0
Parch         0

通过这样做,我们知道Age特征有146个缺失值,而其他特征没有缺失数据。

另见

scikit-learn 中有几种可用的填充器。完整列表请见这里:scikit-learn.org/stable/modules/classes.xhtml#module-sklearn.impute

数据缩放有很多方法,你可以在 scikit-learn 中找到可用的方法,详见:scikit-learn.org/stable/modules/classes.xhtml#module-sklearn.preprocessing

你可能会对以下内容感兴趣,这是一个关于不同缩放器在给定数据上效果对比的展示:scikit-learn.org/stable/auto_examples/preprocessing/plot_all_scaling.xhtml#sphx-glr-auto-examples-preprocessing-plot-all-scaling-py

准备定性数据

在本教程中,我们将准备定性数据,包括缺失值插补和编码。

准备工作

定性数据需要与定量数据不同的处理方式。使用特征的均值填补缺失值是没有意义的(并且对非数值数据不起作用):例如,使用最频繁的值或特征的众数来填补缺失值更为合理。SimpleImputer类允许我们进行这种操作。

重缩放同样适用:对定性数据进行重缩放没有意义。相反,更常见的是对其进行编码。最典型的技术之一叫做独热编码

其思想是将每个类别在总共可能的N个类别中转换为一个向量,其中包含一个 1 和 N-1 个零。在我们的例子中,Embarked特征的独热编码如下:

  • ‘C’ = [1, 0, 0]

  • ‘Q’ = [0, 1, 0]

  • ‘S’ = [0, 0, 1]

对于N个类别使用N列并不一定是最优的。如果在前面的例子中我们去掉第一列会发生什么?如果值既不是‘Q’ = [1, 0]也不是‘S’ = [0, 1],那么它必须是‘C’ = [0, 0]。没有必要再增加一列来获取所有必要的信息。这可以推广到N个类别,只需要N-1 列就能包含所有信息,这也是为什么独热编码函数通常允许你丢弃一列。

sklearn类的OneHotEncoder允许我们实现这一点。它还允许我们使用几种策略处理可能出现在测试集(或生产环境)中的未知类别,如错误、忽略或低频类别。最后,它允许我们在编码后丢弃第一列。

如何操作……

就像在前面的食谱中一样,我们将处理任何缺失的数据,并且特征将进行独热编码:

  1. 导入必要的类——用于缺失数据填补的SimpleImputer(在前面的食谱中已导入)和用于编码的OneHotEncoder。我们还需要导入numpy,以便我们可以在本食谱结束时将已准备好的定性和定量数据拼接在一起:

    import numpy as np
    
    from sklearn.impute import SimpleImputer
    
    from sklearn.preprocessing import OneHotEncoder
    
  2. 选择我们要保留的定性特征:'Sex''Embarked'。然后,将这些特征分别存储到训练集和测试集中的新变量中:

    quali_columns = ['Sex', 'Embarked']
    
    # Get the quantitative columns
    
    X_train_quali = X_train[quali_columns]
    
    X_test_quali = X_test[quali_columns]
    
  3. 使用SimpleImputer并选择most_frequent strategy。任何缺失的值将被最频繁的值替代:

    # Impute missing qualitative values with most frequent feature value
    
    quali_imputer =SimpleImputer(strategy='most_frequent')
    
  4. 在训练集上拟合并转换填补器,然后在测试集上进行转换:

    # Fit and impute the training set
    
    X_train_quali = quali_imputer.fit_transform(X_train_quali)
    
    # Just impute the test set
    
    X_test_quali = quali_imputer.transform(X_test_quali)
    
  5. 实例化编码器。在这里,我们将指定以下参数:

    • drop='first':这将删除编码后的第一列

    • handle_unknown='ignore':如果在测试集(或生产环境)中出现新值,它将被编码为零:

      # Instantiate the encoder
      
      encoder=OneHotEncoder(drop='first', handle_unknown='ignore')
      
  6. 在训练集上拟合并转换编码器,然后使用此编码器在测试集上进行转换:

    # Fit and transform the training set
    
    X_train_quali = encoder.fit_transform(X_train_quali).toarray()
    
    # Just encode the test set
    
    X_test_quali = encoder.transform(X_test_quali).toarray()
    

我们需要使用.toarray()从编码器中提取数据,因为默认情况下数组是稀疏矩阵对象,不能以这种形式与其他特征进行拼接。

  1. 这样,所有数据都已经准备好——包括定量数据和定性数据(考虑到本教程和之前的教程)。现在可以在训练模型之前将这些数据连接起来:

    # Concatenate the data back together
    
    X_train = np.concatenate([X_train_quanti,
    
        X_train_quali], axis=1)
    
    X_test = np.concatenate([X_test_quanti, X_test_quali], axis=1)
    

还有更多…

可以将数据保存为 pickle 文件,既可以分享它,也可以保存它以避免重新准备数据。以下代码将允许我们这样做:

import pickle
pickle.dump((X_train, X_test, y_train, y_test),
    open('prepared_titanic.pkl', 'wb'))

我们现在拥有完全准备好的数据,可以用于训练机器学习模型。

注意

这里省略或简化了一些步骤以便更加清晰。数据可能需要更多的准备工作,例如更彻底的缺失值填补、异常值和重复值检测(以及可能的删除)、特征工程等。假设你已经对这些方面有一定了解,并鼓励你在需要时阅读其他相关资料。

另请参见

这份关于缺失数据填补的更通用文档值得一看:scikit-learn.org/stable/modules/impute.xhtml

最后,这份关于数据预处理的更通用文档非常有用:scikit-learn.org/stable/modules/preprocessing.xhtml

训练模型

一旦数据完全清洗和准备好,通过 scikit-learn 训练模型就相对容易了。在这份教程中,在对 Titanic 数据集训练逻辑回归模型之前,我们将快速回顾机器学习的范式和我们可以使用的不同类型的机器学习。

准备工作

如果有人问你如何区分汽车和卡车,你可能会倾向于提供一系列规则,比如轮子数量、大小、重量等等。通过这样做,你就能提供一套显式的规则,让任何人都能将汽车和卡车区分开来。

传统编程并没有那么不同。在开发算法时,程序员通常会构建显式规则,从而使他们能够将数据输入(例如,一辆车)映射到答案(例如,一辆汽车)。我们可以将这种范式总结为数据 + 规则 = 答案

如果我们要训练一个机器学习模型来区分汽车和卡车,我们会使用另一种策略:我们会将大量数据及其相关答案输入机器学习算法,期望模型能够自我学习并纠正规则。这是一种不同的方法,可以总结为数据 + 答案 = 规则。这种范式的差异总结在图 2.4中。尽管它对机器学习从业者看起来可能微不足道,但在正则化方面,它改变了一切:

图 2.4 – 比较传统编程与机器学习算法

图 2.4 – 比较传统编程与机器学习算法

对传统算法进行正则化在概念上是直接的。例如,如果定义卡车的规则与公共汽车的定义重叠该怎么办?如果是这样,我们可以增加“公共汽车有很多窗户”这一事实。

机器学习中的正则化本质上是隐式的。如果在这种情况下模型无法区分公交车和卡车怎么办?

  • 我们应该增加更多数据吗?

  • 模型是否足够复杂,以捕捉这种差异?

  • 是欠拟合还是过拟合?

机器学习的这一基本特性使得正则化变得复杂。

机器学习可以应用于许多任务。任何使用机器学习的人都知道,机器学习模型不仅仅有一种类型。

可以说,大多数机器学习模型可以分为三大类:

  • 监督学习

  • 无监督学习

  • 强化学习

正如通常对于类别问题的情况一样,情况更为复杂,有子类别和多种方法重叠多个类别。但这超出了本书的范围。

本书将重点讨论监督学习中的正则化。在监督学习中,问题通常很容易界定:我们有输入特征,X(例如,公寓面积),以及标签,y(例如,公寓价格)。目标是训练一个足够健壮的模型,以便在给定X的情况下预测y

机器学习的两大主要类型是分类和回归:

  • 分类:标签由定性数据构成。例如,任务是预测两个或多个类别之间的区别,如汽车、公交车和卡车。

  • 回归:标签由定量数据构成。例如,任务是预测一个实际的值,如公寓价格。

再次强调,这条界限可能模糊;一些任务虽然标签是定量数据,但仍然可以通过分类来解决,而其他任务可能既属于分类也属于回归。参见图 2.5

图 2.5 – 正则化与分类

图 2.5 – 正则化与分类

如何实现…

假设我们要训练一个逻辑回归模型(将在下章中详细解释),scikit-learn 库提供了LogisticRegression类,以及fit()predict()方法。让我们来学习如何使用它:

  1. 导入LogisticRegression类:

    from sklearn.linear_model import LogisticRegression
    
  2. 实例化LogisticRegression对象:

    # Instantiate the model
    
    lr = LogisticRegression()
    
  3. 在训练集上拟合模型:

    # Fit on the training data
    
    lr.fit(X_train, y_train)
    
  4. 可选地,使用该模型在测试集上计算预测:

    # Compute and store predictions on the test data
    
    y_pred = lr.predict(X_test)
    

另见

尽管下章会提供更多细节,但你可能有兴趣查看LogisticRegression类的文档:scikit-learn.org/stable/modules/generated/sklearn.linear_model.LogisticRegression.xhtml

评估模型

一旦模型训练完成,评估它非常重要。在本例中,我们将在评估模型在测试集上的表现之前,提供一些关于分类和回归的典型指标的见解。

准备工作

存在许多评估指标。如果我们考虑预测一个二分类问题并回顾一下,只有四种情况:

  • 假阳性FP):正预测,负实际值

  • 真正例 (TP): 正确的正类预测,正类真实值

  • 真负例 (TN): 正确的负类预测,负类真实值

  • 假负例 (FN): 错误的负类预测,正类真实值:

图 2.6 – 假正例、真正例、真负例和假负例的表示

图 2.6 – 假正例、真正例、真负例和假负例的表示

基于此,我们可以定义一系列评估指标。

最常见的指标之一是准确率,它是正确预测的比例。准确率的定义如下:

注意

尽管准确率非常常见,但它可能会产生误导,尤其是在标签不平衡的情况下。例如,假设一个极端情况是 99% 的泰坦尼克号乘客幸存,而我们有一个模型预测每个乘客都幸存。我们的模型准确率为 99%,但对于所有未幸存的乘客来说,它都是错误的。

还有一些其他非常常见的指标,如精确度、召回率和 F1 分数。

精确度最适用于最大化真正例并最小化假正例的情况——例如,确保只检测到幸存的乘客:

召回率最适用于最大化真正例并最小化假负例的情况——例如,确保不会漏掉任何幸存的乘客:

F1 分数是精确度和召回率的调和均值组合:

另一个有用的分类评估指标是 接收者操作特征曲线下的面积 (ROC AUC) 分数。

所有这些指标的行为类似:当值介于 0 和 1 之间时,值越高,模型越好。有些指标对于标签不平衡更为稳健,尤其是 F1 分数和 ROC AUC。

对于回归任务,最常用的指标是 均方误差 (MSE) 和 R2 分数。

MSE 是预测值和真实值之间的平均平方差:

在这里,m是样本数量,ŷ是预测值,y是真实值:

图 2.7 – 回归任务的误差可视化

图 2.7 – 回归任务的误差可视化

就 R2 分数而言,它是一个可能为负的指标,定义如下:

注意

虽然 R2 分数是一个典型的评估指标(越接近 1 越好),但是 MSE 更典型地作为损失函数(越接近 0 越好)。

如何操作……

假设我们选择的评估指标是准确率,那么评估我们模型的一个非常简单的方法是使用 accuracy_score() 函数:

from sklearn.metrics import accuracy_score
# Compute the accuracy on test of our model
print('accuracy on test set:', accuracy_score(y_pred,
    y_test))

这会输出以下结果:

accuracy on test set: 0.7877094972067039

在这里,accuracy_score() 函数提供了 78.77% 的准确率,意味着大约 79% 的模型预测是正确的。

另见

以下是 scikit-learn 中可用度量的列表:scikit-learn.org/stable/modules/classes.xhtml#module-sklearn.metrics

执行超参数优化

在这个食谱中,我们将解释什么是超参数优化以及一些相关概念:超参数的定义、交叉验证和各种超参数优化方法。然后,我们将执行网格搜索,优化 Titanic 数据集上逻辑回归任务的超参数。

做好准备

在机器学习中,大多数情况下,我们不仅仅是在训练集上训练一个模型,然后在测试集上评估它。

这是因为像大多数其他算法一样,机器学习算法可以进行微调。这个微调过程让我们可以优化超参数,从而获得最佳的结果。这有时也作为杠杆,帮助我们对模型进行正则化。

注意

在机器学习中,超参数是由人类调整的,而参数是通过模型训练过程学习的,因此不能调整。

为了正确优化超参数,需要引入第三个拆分:验证集。

这意味着现在有了三个拆分:

  • 训练集:模型训练的地方

  • 验证集:超参数优化的地方

  • 测试集:模型进行评估的地方

你可以通过使用 scikit-learn 的 train_test_split() 函数将 X_train 拆分成 X_trainX_valid 来创建这样的集。

但在实际操作中,大多数人仅使用交叉验证,并且不费心创建这个验证集。k 折交叉验证方法允许我们从训练集中进行k次拆分,并将其分开,如图 2.8所示:

图 2.8 – 典型的训练集、验证集和测试集的拆分,无交叉验证(上图)和有交叉验证(下图)

图 2.8 – 典型的训练集、验证集和测试集的拆分,无交叉验证(上图)和有交叉验证(下图)

这样做时,不是训练一个模型,而是针对给定的超参数集训练k个模型。然后基于选择的度量(例如准确度、均方误差等),对这k个模型的表现进行平均。

然后可以测试多个超参数集合,选择性能最佳的那个。在选择了最佳超参数集合后,模型会在整个训练集上再次训练,以最大化用于训练的数据。

最后,你可以实施几种策略来优化超参数,如下所示:

  • 网格搜索:测试提供的超参数值的所有组合

  • 随机搜索:随机搜索超参数的组合

  • 贝叶斯搜索:对超参数进行贝叶斯优化

如何操作……

虽然从概念上解释起来相当复杂,但使用交叉验证进行超参数优化非常容易实现。在这个例子中,我们假设我们希望优化一个逻辑回归模型,预测乘客是否会幸存:

  1. 首先,我们需要从sklearn.model_selection导入GridSearchCV类。

  2. 我们希望测试以下C的超参数值:[0.01, 0.03, 0.1]。我们必须定义一个参数网格,其中超参数作为键,待测试的值列表作为值。

C超参数是惩罚强度的倒数:C越大,正则化越小。详细信息请参见下一章:

# Define the hyperparameters we want to test
param_grid = { 'C': [0.01, 0.03, 0.1] }
  1. 最后,假设我们希望通过五次交叉验证折叠来优化模型的准确性。为此,我们将实例化GridSearchCV对象,并提供以下参数:

    • 要优化的模型,它是一个LogisticRegression实例

    • 我们之前定义的超参数网格param_grid

    • 要优化的评分标准——即accuracy

    • 设置为5的交叉验证折叠次数

  2. 我们还必须将return_train_score设置为True,以便获取一些我们以后可以使用的有用信息:

    # Instantiate the grid search object
    
    grid = GridSearchCV(
    
        LogisticRegression(),
    
        param_grid,
    
        scoring='accuracy',
    
        cv=5,
    
        return_train_score=True
    
    )
    
  3. 最后,我们要做的就是在训练集上训练这个对象。这将自动进行所有计算并存储结果:

    # Fit and wait
    
    grid.fit(X_train, y_train)
    
    GridSearchCV(cv=5, estimator=LogisticRegression(),
    
        param_grid={'C': [0.01, 0.03, 0.1]},
    
        return_train_score=True, scoring='accuracy')
    

注意

根据输入数据集和测试的超参数数量,拟合可能需要一些时间。

拟合完成后,您可以获取许多有用的信息,例如以下内容:

  • 通过.best_params属性获得超参数集

  • 通过.best_score属性获得最佳准确度分数

  • 通过.cv_results属性获得交叉验证结果

  1. 最后,您可以使用.predict()方法推断经过优化超参数训练的模型:

    y_pred = grid.predict(X_test)
    
  2. 可选地,您可以使用准确度评分来评估所选择的模型:

    print('Hyperparameter optimized accuracy:',
    
        accuracy_score(y_pred, y_test))
    

这将提供以下输出:

Hyperparameter optimized accuracy: 0.781229050279329

借助 scikit-learn 提供的工具,优化模型并使用多个指标进行评估非常容易。在下一节中,我们将学习如何基于这样的评估来诊断偏差和方差。

另见

GridSearchCV的文档可以在scikit-learn.org/stable/modules/generated/sklearn.model_selection.GridSearchCV.xhtml找到。

第三章:线性模型的正则化

机器学习ML)的一个重要部分是由线性模型构成的。尽管有时它们被认为不如非线性模型(如基于树的模型或深度学习模型)强大,但线性模型确实能解决许多具体且有价值的问题。客户流失和广告优化就是线性模型可能是正确解决方案的两个问题。

在本章中,我们将覆盖以下内容:

  • 使用 scikit-learn 训练线性回归模型

  • 使用岭回归进行正则化

  • 使用套索回归进行正则化

  • 使用弹性网回归进行正则化

  • 训练逻辑回归模型

  • 正则化逻辑回归模型

  • 选择正确的正则化方法

到本章结束时,我们将学习如何使用并正则化一些最常用的线性模型。

技术要求

在本章中,除了加载数据外,您将学习如何使用多个线性模型进行拟合和推理计算。为此,以下库是必需的:

  • NumPy

  • Matplotlib

  • Scikit-learn

使用 scikit-learn 训练线性回归模型

线性回归是我们可以使用的最基础的机器学习模型之一,但它非常有用。大多数人在高中时就使用过线性回归,虽然当时并没有讨论机器学习,而且他们在电子表格中也经常使用它。在这个教程中,我们将解释线性回归的基础知识,然后使用 scikit-learn 在加利福尼亚房价数据集上训练并评估一个线性回归模型。

准备工作

线性回归不是一个复杂的模型,但理解其底层原理仍然非常有用,这样才能从中获得最佳效果。

线性回归的工作原理非常简单。回到房地产价格的例子,如果我们考虑一个特征 x,比如公寓的面积和一个标签 y,比如公寓价格,一个常见的解决方案是找到 ab 使得 y = ax + b

不幸的是,现实生活中并非如此简单。通常没有一个 ab 使得这个等式始终成立。更可能的情况是,我们可以定义一个函数 h(x),旨在给出尽可能接近 y 的值。

此外,我们可能不仅仅有一个特征 x,而是有多个特征 x1、x2、...、xn,代表公寓面积、位置、楼层、房间数量、指数特征等等。

按照这个逻辑,我们最终得到的预测 h(x) 可能会像下面这样:

是与特征 xj 相关的权重,b 是一个偏置项。这只是之前 y = ax + b 公式的一个推广,扩展到 n 个特征。这一公式允许线性回归预测几乎任何实数值。

我们的机器学习模型的目标是找到一组 wb 的值,使得训练集上的预测误差最小。也就是说,我们要找到参数 wb,使得 h(x)y 尽可能接近。

实现这一目标的一种方法是最小化损失 L,这里可以将其定义为稍作修改的均方 误差MSE):

是训练集中样本 i 的真实值,m 是训练集中的样本数量。

注意

损失通常表示真实值与预测值之间的差异。因此,最小化损失可以让模型预测的值尽可能接近真实值。

最小化均方误差可以帮助我们找到一组 wb 的值,使得预测 h(x) 尽可能接近真实值 y。从示意图来看,这可以表示为找到最小化损失的 w,如以下图所示:

图 3.1 – 损失函数作为参数 theta 的函数,在交点处具有全局最小值

图 3.1 – 损失函数作为参数 theta 的函数,在交点处具有全局最小值

下一个问题是:我们如何找到最小化损失的值集?解决这个问题有多种方法。一种在机器学习中常用的技术是梯度下降。

什么是梯度下降?简而言之,它是沿着曲线下降至前面图示中的最小值。

这怎么运作?这是一个多步骤的过程:

  1. 从随机的 wb 参数值开始。随机值通常使用以零为中心的正态分布来定义。因此,缩放特征可能有助于收敛。

  2. 计算给定数据和当前 wb 值的损失。如前所定义,我们可以使用均方误差来计算损失 L

以下图表示了此时的情形:

图 3.2 – 损失函数在红色交点处具有全局最小值,蓝色交点处为可能的随机初始状态

图 3.2 – 损失函数在红色交点处具有全局最小值,蓝色交点处为可能的随机初始状态

  1. 计算相对于每个参数的损失梯度 。这不过是给定参数的损失的斜率,可以通过以下公式计算:

注意

可以注意到,随着接近最小值,斜率预计会减小。事实上,随着接近最小值,误差趋向于零,斜率也趋向于零,这基于这些公式。

  1. 对参数应用梯度下降。对参数应用梯度下降,使用用户定义的学习率 α。这可以通过以下公式计算:

这让我们朝着最小值迈出一步,如下图所示:

图 3.3 – 梯度下降法允许我们沿着损失函数向下迈进一步,从而使我们更接近全局最小值

图 3.3 – 梯度下降法允许我们沿着损失函数向下迈进一步,从而使我们更接近全局最小值。

  1. 迭代执行 步骤 2 到 4,直到收敛或达到最大迭代次数。这将使我们达到最优参数,如下图所示:

图 3.4 – 在足够的迭代次数和凸损失函数下,参数将会收敛到全局最小值

图 3.4 – 在足够的迭代次数和凸损失函数下,参数将会收敛到全局最小值

注:

如果学习率 α 太大,可能会错过全局最小值,甚至导致发散;而如果学习率太小,收敛速度会非常慢。

要完成这个步骤,必须安装以下库:numpymatplotlibsklearn。你可以在终端使用 pip 安装,命令如下:

pip install -U numpy sklearn matplotlib

如何操作…

幸运的是,整个过程在 scikit-learn 中已经完全实现,你所需要做的就是充分利用这个库。现在我们来训练一个线性回归模型,使用 scikit-learn 提供的加利福尼亚住房数据集。

  1. fetch_california_housing:一个可以加载数据集的函数

  2. train_test_split:一个可以将数据拆分的函数

  3. StandardScaler:允许我们重新缩放数据的类

  4. LinearRegression:包含线性回归实现的类

以下是代码示例:

import numpy as np
from sklearn.datasets import fetch_california_housing
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LinearRegression
  1. XX*X

    # Load the California housing dataset
    
    X, y = fetch_california_housing(return_X_y=True)
    
    X = np.concatenate([X, X*X], axis=1)
    
  2. train_test_split 函数,使用 test_size=0.2,意味着我们将数据随机拆分,80% 的数据进入训练集,20% 的数据进入测试集。如下所示:

    # Split the data
    
    X_train, X_test, y_train, y_test = train_test_split(
    
        X, y, test_size=0.2, random_state=0)
    
  3. 准备数据:由于我们这里只有量化特征,因此唯一需要的准备工作是重新缩放。我们可以使用 scikit-learn 的标准缩放器。我们需要实例化它,然后在训练集上拟合并转换训练集,最后转换测试集。你可以自由使用任何其他的重缩放器:

    # Rescale the data
    
    scaler = StandardScaler()
    
    X_train = scaler.fit_transform(X_train)
    
    X_test = scaler.transform(X_test)
    
  4. 在训练集上拟合模型。模型必须先实例化,这里我们使用默认参数,因此没有指定任何内容。实例化模型后,我们可以使用 .fit() 方法对训练集进行拟合:

    # Fit the linear regression model
    
    lr = LinearRegression()
    
    lr.fit(X_train, y_train)
    
  5. 在训练集和测试集上评估模型。这里,我们使用 LinearRegression 类的 .score() 方法,它提供了 R2-score,但你也可以使用 sklearn.metrics 中提供的任何其他回归度量:

    # Print the R2-score on train and test
    
    print('R2-score on train set:', lr.score(X_train, y_train))
    
    print('R2-score on test set:', lr.score(X_test, y_test))
    

以下是输出结果:

R2-score on train set: 0.6323843381852894 R2-score on test set: -1.2472000127402643

如我们所见,训练集和测试集的分数差异显著,表明模型在训练集上出现了过拟合。为了解决这个问题,接下来将介绍正则化技术。

还有更多内容…

一旦模型训练完成,我们可以通过 LinearRegression 对象的属性 .coef_.intercept_ 分别访问所有的参数 w(这里是 16 个值,对应 16 个输入特征)以及截距 b

print('w values:', lr.coef_)
print('b value:', lr.intercept_)

这是输出结果:

w values: [ 1.12882772e+00 -6.48931138e-02 -4.04087026e-01  4.87937619e-01  -1.69895164e-03 -4.09553062e-01 -3.72826365e+00 -8.38728583e+00  -2.67065542e-01  2.04856554e-01  2.46387700e-01 -3.19674747e-01   2.58750270e-03  3.91054062e-01  2.82040287e+00 -7.50771410e+00] b value: 2.072498958939411

如果我们绘制这些值,我们会注意到它们在这个数据集中的值大约在 -82 之间:

import matplotlib.pyplot as plt
plt.bar(np.arange(len(lr.coef_)), lr.coef_)
plt.xlabel('feature index')
plt.ylabel('weight value')
plt.show()

这是一个可视化表示:

图 3.5 – 线性回归模型中每个权重的学习值。值的范围相当大,从 -8 到 2

图 3.5 – 线性回归模型中每个权重的学习值。值的范围相当大,从 -8 到 2

参见其他

要全面了解如何使用 scikit-learn 进行线性回归,最好参考该类的官方文档:scikit-learn.org/stable/modules/generated/sklearn.linear_model.LinearRegression.xhtml

我们现在对线性回归的工作原理有了较好的理解,接下来我们将看到如何通过惩罚进行正则化。

使用 ridge 回归进行正则化

一种非常常见且有用的正则化线性回归的方法是通过惩罚损失函数。在本示例中,经过回顾将惩罚项添加到 ridge 回归的损失函数之后,我们将在与之前示例相同的加利福尼亚住房数据集上训练一个 ridge 模型,看看它如何通过正则化提高评分。

准备就绪

确保模型参数不会过拟合的一种方法是保持它们接近零:如果参数无法自由变化,它们就不太可能过拟合。

为此,ridge 回归将一个新的项(正则化项)添加到损失函数中!

其中 L2 范数的 w

通过这个损失函数,我们可以直观地理解,较大的权重 w 是不可能的,因此过拟合的可能性较小。此外,𝜆 是一个超参数(可以微调),允许我们控制正则化的程度:

  • 𝜆 的较高值表示较强的正则化

  • 𝜆 = 0 表示没有正则化,例如普通的线性回归

梯度下降的公式稍作更新,如下所示:

现在让我们来看一下如何使用 ridge 回归与 scikit-learn:与前一个示例中一样,需要安装相同的库:numpysklearnmatplotlib

同时,我们假设数据已经通过上一示例下载并准备好。有关如何下载、拆分和准备数据的详细信息,请参考前一个示例。

如何操作……

假设我们正在重复使用前一个示例中的相同数据。我们将在完全相同的数据上训练和评估另一个模型,包括具有平方特征的特征工程。scikit-learn 中岭回归的相关实现是Ridge类,其中alpha类属性等同于前述方程中的𝜆。让我们来使用它:

  1. 从 scikit-learn 中导入Ridge类:

    from sklearn.linear_model import Ridge
    
  2. 然后我们实例化一个岭回归模型。在这里选择了正则化参数alpha=5000,但每个数据集可能需要一个非常具体的超参数值来表现最佳。接下来,用.fit()方法在训练集上训练模型(事先准备好的),如下所示:

    # Fit the Ridge model ridge = Ridge(alpha=5000)
    
    ridge.fit(X_train, y_train)
    
    Ridge(alpha=5000)
    
  3. 然后我们评估模型。在这里,我们计算并显示由岭回归类的.score()提供的 R2 分数,但也可以使用任何其他回归指标:

    # Print the R2-score on train and test
    
    print('R2-score on train set:', ridge.score(X_train, y_train))
    
    print('R2-score on test set:', ridge.score(X_test, y_test))
    

这里是输出:

R2-score on train set: 0.5398290317808138 R2-score on test set: 0.5034148460338739

我们会注意到,与线性回归模型(没有正则化)相比,我们在测试集上获得了更好的结果,允许测试集上的 R2 分数略高于0.5

还有更多...

我们还可以打印权重并绘制它们,将这些值与普通线性回归的值进行比较,如下所示:

print('theta values:', ridge.coef_)
print('b value:', ridge.intercept_)

这里是输出:

theta values: [ 0.43456599  0.06311698  0.00463607  0.00963748  0.00896739 -0.05894055  -0.17177956 -0.15109744  0.22933247  0.08516982  0.01842825 -0.01049763  -0.00358684  0.03935491 -0.17562536  0.1507696 ] b value: 2.07249895893891

为了可视化,我们还可以使用以下代码绘制这些值:

plt.bar(np.arange(len(ridge.coef_)), ridge.coef_)
plt.xlabel('feature index') plt.ylabel('weight value')
plt.show()

这段代码输出如下:

图 3.6 - 岭回归模型每个权重的学习值。请注意,一些是正数,一些是负数,并且没有一个完全等于零。此外,范围比无正则化时小得多

图 3.6 - 岭回归模型每个权重的学习值。请注意,一些是正数,一些是负数,并且没有一个完全等于零。此外,范围比无正则化时小得多

现在权重值的范围是从-0.2.0.5,确实比没有惩罚时要小得多。

正如预期的那样,添加正则化导致测试集的 R2 分数比没有正则化时更好,且接近训练集的分数。

参见

有关岭回归的所有可能参数和超参数的更多信息,您可以查看官方 scikit-learn 文档:scikit-learn.org/stable/modules/generated/sklearn.linear_model.LinearRegression.xhtml

使用套索回归进行正则化

套索回归代表最小绝对值收缩和选择算子。这是一个正则化方法,在某些情况下,套索回归优于岭回归,因此了解其作用和如何使用它非常有用。在这个示例中,我们将简要解释套索回归是什么,然后在相同的加利福尼亚房屋数据集上使用 scikit-learn 训练模型。

准备工作

lasso 使用 L1-norm,而不是 L2-norm,因此损失 公式如下:

虽然 ridge 回归倾向于平滑地将权重减少到接近零,lasso 的变化更加剧烈。由于 lasso 的损失曲线更陡峭,它倾向于迅速将权重设置为零。

就像 ridge 回归的做法一样,我们将使用相同的库,并假设它们已经安装:numpysklearnmatplotlib。同时,我们假设数据已经下载并准备好。

如何操作……

scikit-learn 的 lasso 实现可以通过 Lasso 类获得。与 Ridge 类类似,alpha 是控制正则化强度的参数:

  • alpha 值为 0 表示没有正则化

  • alpha 的大值意味着高强度的正则化

我们将再次使用已经准备好的数据集,该数据集已经用于线性回归和 ridge 回归:

  1. 从 scikit-learn 导入 Lasso 类:

    from sklearn.linear_model import Lasso
    
  2. 我们实例化一个 alpha=0.2 的 lasso 模型,它能提供相当不错的结果和较低的过拟合,如我们接下来所见。然而,欢迎测试其他值,因为每个数据集可能有其独特的最佳值。接下来,使用 Lasso 类的 .fit() 方法在训练集上训练该模型:

    # Fit the Lasso model lasso = Lasso(alpha=0.02)
    
    lasso.fit(X_train, y_train)
    
  3. 使用 .score() 方法中的 R2-score 来评估训练集和测试集上的 lasso 模型,该方法在 Lasso 类中实现:

    # Print the R2-score on train and test
    
    print('R2-score on train set:', lasso.score(X_train, y_train))
    
    print('R2-score on test set:', lasso.score(X_test, y_test))
    

这段代码会输出以下内容:

R2-score on train set: 0.5949103710772492
R2-score on test set: 0.57350350155955

在与没有惩罚的线性回归进行比较时,我们可以看到有所改进,并且相较于 ridge 回归,我们在测试集上的 R2-score 约为 0.57

还有更多……

如果我们再次绘制权重实例图,我们现在得到以下值:

plt.bar(np.arange(len(lasso.coef_)), lasso.coef_)
plt.xlabel('feature index')
plt.ylabel('weight value')
plt.show()

这将输出 图 3**.14 中的图表:

图 3.7 – 每个 lasso 模型权重的学习值。注意,与 ridge 模型不同,多个权重被设置为零

图 3.7 – 每个 lasso 模型权重的学习值。注意,与 ridge 模型不同,多个权重被设置为零

如预期所示,某些值被设置为 0,整体范围在 -0.50.7 之间。

在这种情况下,lasso 正则化使我们在测试集上的表现得到了显著提升,同时也减少了过拟合。

另见

查看 lasso 类的官方文档,了解更多信息:scikit-learn.org/stable/modules/generated/sklearn.linear_model.Lasso.xhtml.

另一种名为 group lasso 的技术可以用于正则化,了解更多信息请访问: group-lasso.readthedocs.io/en/latest/.

使用弹性网回归进行正则化

弹性网回归,除了有一个非常华丽的名字外,其实不过是岭回归和套索回归的组合。它是一种正则化方法,在一些特定情况下可能会有所帮助。让我们看看它在损失函数中的表现,然后在加利福尼亚住房数据集上训练一个模型。

准备工作

弹性网的思想是同时使用 L1 和 L2 正则化。

这意味着损失函数!是如下所示:

这两个超参数,,可以进行微调。

我们不会详细讨论梯度下降的公式,因为一旦理解了岭回归和套索回归,推导它们是非常直接的。

为了训练模型,我们再次需要sklearn库,这个库我们在之前的配方中已经安装过。此外,我们假设加利福尼亚住房数据集已经下载并准备好。

如何实现…

在 scikit-learn 中,弹性网在ElasticNet类中实现。然而,它们使用的是两个超参数alphal1_ratio,而不是两个超参数

现在让我们将其应用到已经准备好的加利福尼亚住房数据集上:

  1. 从 scikit-learn 中导入ElasticNet类:

    from sklearn.linear_model import ElasticNet
    
  2. 实例化一个弹性网模型。选择了alpha=0.1l1_ratio=0.5,但也可以测试其他值。然后使用ElasticNet类的.fit()方法,在训练集上训练模型:

    # Fit the LASSO model
    
    Elastic = ElasticNet(alpha=0.1, l1_ratio=0.5)
    
    elastic.fit(X_train, y_train)
    
    ElasticNet(alpha=0.1)
    
  3. 使用.score()方法计算的 R2 得分,在训练集和测试集上评估弹性网模型:

    # Print the R2-score on train and test
    
    print('R2-score on train set:', elastic.score(
    
        X_train, y_train))
    
    print('R2-score on test set:', elastic.score(
    
        X_test, y_test))
    

这是它的输出:

R2-score on train set: 0.539957010948829
R2-score on test set: 0.5134203748307193

在这个例子中,结果并没有超越套索回归:可能需要对超参数进行更好的微调才能达到相同的性能。

尽管弹性网回归由于有两个超参数而在微调时更为复杂,但它可能比岭回归或套索回归在正则化上提供更多灵活性。推荐使用超参数优化方法来找到适合特定任务的超参数组合。

注意

实际上,弹性网回归可能不如岭回归和套索回归广泛使用。

另见

scikit-learn 的官方文档中,关于弹性网回归的详细信息可以在此找到:scikit-learn.org/stable/modules/generated/sklearn.linear_model.ElasticNet.xhtml

训练逻辑回归模型

逻辑回归在概念上与线性回归非常接近。一旦完全理解了线性回归,逻辑回归就只需要几步小技巧。然而,与线性回归不同,逻辑回归最常用于分类任务。

让我们首先解释什么是逻辑回归,然后使用 scikit-learn 在乳腺癌数据集上训练一个模型。

准备开始

与线性回归不同,逻辑回归的输出范围限制在01之间。第一个思路与线性回归完全相同,为每个特征 分配一个参数

还有一步是将输出 z 限制在 01 之间,那就是对该输出应用逻辑函数。作为提醒,逻辑函数(也叫做 Sigmoid 函数,尽管它是一个更通用的函数)如下所示:

逻辑函数呈 S 形,其值从 01,在 x = 0 时取值为 0.5,如图 3.8所示:

图 3.8 – 逻辑函数,x = 0 时从 0 到 1 范围内穿过 0.5

图 3.8 – 逻辑函数,x = 0 时从 0 到 1 范围内穿过 0.5

最后,通过应用 Sigmoid 函数,我们得到了逻辑回归的预测 h(x),如下所示:

这确保了输出值 h(x)01的范围内。但它还没有让我们进行分类。最后一步是应用一个阈值(例如,0.5)来进行分类预测:

和线性回归一样,我们现在需要定义一个损失函数 L,以便最小化它,进而优化参数 b。常用的损失函数是所谓的二元 交叉熵

我们可以看到这里有四种极端情况:

  • 如果 y = 1 且 h(x)1:L 0

  • 如果 y = 1 且 h(x)0:L +∞

  • 如果 y = 0 且 h(x)0:L 0

  • 如果 y = 0 且 h(x)1:L +∞

现在,为了实现我们期望的行为,即一个趋向于 0 的损失,表示高度准确的预测,并且在错误预测时损失增加。这个过程在下图中表示:

图 3.9 – 二元交叉熵,最小化 y = 0 和 y = 1 的误差

图 3.9 – 二元交叉熵,最小化 y = 0 和 y = 1 的误差

同样,优化逻辑回归的一种方法是通过梯度下降,和线性回归完全相同。事实上,方程是完全相同的,尽管我们在这里无法证明这一点。

要准备这个步骤,我们只需要安装 scikit-learn 库。

如何做...

逻辑回归在 scikit-learn 中完全实现为 LogisticRegression 类。与 scikit-learn 中的线性回归不同,逻辑回归将正则化直接集成到一个类中。以下参数是需要调整的,以便微调正则化:

  • penalty:这可以是 'l1''l2''elasticnet''none'

  • C:这是一个浮动值,与正则化强度成反比;值越小,正则化越强。

在这个示例中,我们将应用不使用正则化的逻辑回归模型,处理由 scikit-learn 提供的乳腺癌数据集。我们将首先加载并准备数据,然后训练并评估逻辑回归模型,具体步骤如下:

  1. 导入load_breast_cancer函数,它将允许我们加载数据集,并导入LogisticRegression类:

    from sklearn.datasets import load_breast_cancer
    
    from sklearn.linear_model import LogisticRegression
    
  2. 使用load_breast_cancer函数加载数据集:

    # Load the dataset
    
    X, y = load_breast_cancer(return_X_y=True)
    
  3. 使用train_test_split函数拆分数据集。我们假设它已经在之前的示例中被导入。如果没有,你需要通过from sklearn.model_selection import train_test_split来导入。我们选择test_size=0.2,这样训练集占 80%,测试集占 20%:

    # Split the data
    
    X_train, X_test, y_train, y_test = train_test_split(
    
        X, y, test_size=0.2, random_state=42)
    
  4. 然后我们准备数据。由于数据集仅包含定量数据,我们只需要使用标准化方法进行重缩放:我们实例化标准化器,对训练集进行拟合,并用fit_transform()方法转换同一训练集。最后,我们使用.transform()对测试集进行重缩放。同样地,我们假设标准化器已经在之前的示例中被导入,否则需要通过from sklearn.preprocessing import StandardScaler导入:

    # Rescale the data
    
    scaler = StandardScaler()
    
    X_train = scaler.fit_transform(X_train)
    
    X_test = scaler.transform(X_test)
    
  5. 然后我们实例化逻辑回归模型,并指定penalty='none',这样就不对损失进行惩罚(为了教学目的)。查看下一个示例,了解惩罚项如何工作。接着我们使用.fit()方法在训练集上训练逻辑回归模型:

    # Fit the logistic regression model with no regularization
    
    Lr = LogisticRegression(penalty='none')
    
    lr.fit(X_train, y_train)
    
    LogisticRegression(penalty='none')
    
  6. 在训练集和测试集上评估模型。LogisticRegression类的.score()方法使用准确率作为评估指标,但也可以使用其他指标:

    # Print the accuracy score on train and test
    
    print('Accuracy on train set:', lr.score(X_train, y_train))
    
    print('Accuracy on test set:', lr.score(X_test, y_test))
    

这段代码输出如下内容:

Accuracy on train set: 1.0 Accuracy on test set: 0.9385964912280702

我们在这里面临较强的过拟合问题,训练集的分类准确率为 100%,但测试集的准确率只有大约 94%。这是一个好的开始,但在下一个示例中,我们将使用正则化来帮助提高测试准确率。

对逻辑回归模型进行正则化

逻辑回归与线性回归使用相同的技巧来添加正则化:它将惩罚项加到损失函数中。在这个示例中,我们将简要解释惩罚项如何影响损失,并展示如何使用 scikit-learn 在之前准备的乳腺癌数据集上添加正则化。

准备工作

就像线性回归一样,向损失函数L中添加正则化项非常简单,可以是 L1 或 L2 范数的参数w。例如,包含 L2 范数的损失函数如下所示:

和岭回归一样,我们在权重上加入了平方和,并在前面加了一个超参数。为了尽可能接近 scikit-learn 的实现,我们将使用 1/C 代替𝜆作为正则化超参数,但基本思想保持不变。

在这个食谱中,我们假设之前的食谱中已经安装了以下库:sklearnmatplotlib。同时,我们假设乳腺癌数据集中的数据已经从前一个食谱中加载和准备好,因此我们可以直接重用它。

如何做到…

现在,让我们通过添加 L2 正则化来尝试提高我们在上一道食谱中的测试精度:

  1. 实例化逻辑回归模型。在这里,我们选择 L2 惩罚和正则化值 C=0.1。较低的 C 值意味着更强的正则化:

    lr = LogisticRegression(penalty='l2', C=0.1)
    
  2. 使用 .fit() 方法在训练集上拟合逻辑回归模型:

    lr.fit(X_train, y_train)
    
    LogisticRegression(C=0.1)
    
  3. 在训练集和测试集上评估模型。我们在这里使用 .score() 方法,提供准确率得分:

    # Print the accuracy score on train and test
    
    print('Accuracy on train set:', lr.score(
    
        X_train, y_train))
    
    print('Accuracy on test set:', lr.score(
    
        X_test, y_test))
    
    Accuracy on train set: 0.9802197802197802
    
    Accuracy on test set: 0.9824561403508771
    

正如我们在这里看到的,添加 L2 正则化使我们在测试集上的精度达到了 98%,相比于没有正则化时的大约 94%,这是一项相当大的改进。

另一个提醒

找到 C 的最佳正则化值的最佳方法是进行超参数优化。

出于好奇,我们可以在这里绘制多个正则化强度值下的训练和测试精度:

accuracy_train = []
accuracy_test = []
c_values = [0.001,0.003,0.01,0.03,0.1,0.3,1,3,10,30]
for c in c_values:
    lr = LogisticRegression(penalty='l2', C=c)
    lr.fit(X_train, y_train)
    accuracy_train.append(lr.score(X_train, y_train))
    accuracy_test.append(lr.score(X_test, y_test))
plt.plot(c_values, accuracy_train, label='train')
plt.plot(c_values, accuracy_test, label='test')
plt.legend()
plt.xlabel('C: inverse of regularization strength')
plt.ylabel('Accuracy')
plt.xscale('log')
plt.show()

这是它的图表:

图 3.10 – 精度作为 C 参数的函数,适用于训练集和测试集

图 3.10 – 精度作为 C 参数的函数,适用于训练集和测试集

这个图实际上是从右到左读取的。我们可以看到,当 C 的值减小时(从而增加正则化),训练精度持续下降,因为正则化越来越强。另一方面,减小 C(即增加正则化)最初能够改善测试结果:模型的泛化能力越来越强。但在某个时刻,添加更多正则化(进一步减小 C)并不会带来更多帮助,甚至会降低测试精度。事实上,过多的正则化会产生高偏差模型。

还有更多…

LogisticRegression 的 scikit-learn 实现不仅允许我们使用 L2 惩罚,还允许使用 L1 和弹性网,就像线性回归一样,这让我们可以根据任何给定的数据集和任务来选择最佳的正则化方式。

可以查阅官方文档以获取更多细节:scikit-learn.org/stable/modules/generated/sklearn.linear_model.LogisticRegression.xhtml

选择合适的正则化

线性模型与 L1 和 L2 惩罚共享这种正则化方法。实现中的唯一区别是线性回归有针对每种正则化类型的独立类,如下所述:

  • LinearRegression 无正则化

  • RidgeRegression 用于 L2 正则化

  • Lasso 用于 L1 正则化

  • ElasticNet 用于 L1 和 L2

逻辑回归有一个集成实现,可以将 L1L2 作为类参数传递。

注意

对于SVC分类类和SVR回归类,使用C参数进行L2正则化。

但是对于线性回归和逻辑回归,有一个问题依然存在:我们应该使用 L1 还是 L2 正则化?

在这个方案中,我们将提供一些关于在某些情况下使用 L1 或 L2 惩罚的实用建议,然后我们将使用逻辑回归对乳腺癌数据集进行网格搜索,以找到最佳正则化方法。

准备工作

对于最适合的惩罚方式,并没有绝对的答案。大多数情况下,唯一的办法是通过超参数优化来找出答案。但在某些情况下,数据本身或外部约束可能会提示我们选择哪种正则化方法。让我们快速看一下。首先,比较 L1 和 L2 正则化,然后探索超参数优化。

L1 与 L2 正则化

L1 和 L2 正则化有内在的差异,这些差异有时可以帮助我们提前做出明智的判断,从而节省计算时间。让我们看看这些差异。

如前所述,L1 正则化倾向于将某些权重设置为零,从而可能允许特征选择。因此,如果我们有一个包含许多特征的数据集,拥有这些信息将会很有帮助,未来可以删除这些特征。

此外,L1 正则化使用权重的绝对值作为损失的一部分,使其相比 L2 正则化对异常值更加稳健。如果我们的数据集可能包含异常值,这是需要考虑的。

最后,在计算速度方面,L2 正则化是通过添加一个二次项来实现的,因此比 L1 正则化计算开销更小。如果训练速度是一个关注点,应该优先考虑 L2 正则化。

简而言之,我们可以考虑以下几种情况,这些情况可以根据数据或其他约束(如计算资源)在前期做出选择:

  • 数据包含许多特征,其中许多特征重要性较低:选择 L1 正则化

  • 数据包含许多异常值:L1 正则化

  • 如果训练计算资源有限:选择 L2 正则化

超参数优化

更实际、或许更务实的方法是直接进行超参数优化,将 L1 或 L2 作为一个超参数(弹性网回归也可以加入)。

我们将使用scikit-learn实现的网格搜索进行超参数优化,对乳腺癌数据集上的逻辑回归模型进行 L1 和 L2 正则化优化。

对于这个方案,我们假设在前一个方案中已经安装了scikit-learn库。我们还假设乳腺癌数据已经通过训练逻辑回归 模型方案加载并准备好。

如何实现…

我们将在一组给定的超参数上执行网格搜索,更具体地说,就是在多个惩罚值下,进行 L1 和 L2 正则化的惩罚,其中C为惩罚参数:

  1. 首先,我们需要从sklearn导入GridSearchCV类:

    from sklearn.model_selection import GridSearchCV
    
  2. 然后我们定义参数网格。这是我们想要测试的超参数空间。在这里,我们将同时尝试 L1 和 L2 惩罚项,并且对于每个惩罚项,我们将尝试C值,范围是[0.01, 0.03, 0.06, 0.1, 0.3, 0.6],具体如下:

    # Define the hyperparameters we want to test param_grid = {
    
        'penalty': ['l1', 'l2'],
    
        'C': [0.01, 0.03, 0.06, 0.1, 0.3, 0.6] }
    
  3. 接下来,我们实例化网格搜索对象。这里传入了几个参数:

    • LogisticRegression,我们指定求解器为liblinear(更多信息请参见另见部分),这样它就能处理 L1 和 L2 惩罚项。我们假设该类已从之前的代码中导入;否则,您可以通过from sklearn.linear_model import LogisticRegression导入它。

    • scoring='accuracy',但它也可以是任何其他相关的度量标准。

    • 这里使用cv=5表示 5 折交叉验证,因为这是比较标准的设置,但根据数据集的大小,其他值也可能适用:

      # Instantiate the grid search
      
      object grid = GridSearchCV(
      
          LogisticRegression(solver='liblinear'),
      
          param_grid,
      
          scoring='accuracy',
      
          cv=5 )
      
  4. 使用.fit()方法在训练数据上训练网格。然后,我们可以出于好奇使用best_params_属性显示找到的最佳超参数:

    # Fit and wait
    
    grid.fit(X_train, y_train)
    
    # Print the best set of hyperparameters
    
    print('best hyperparameters:', grid.best_params_)
    
    best hyperparameters: {'C': 0.06, 'penalty': 'l2'}
    

在这种情况下,最佳超参数是C=0.06,并使用 L2 惩罚。现在我们可以评估模型。

  1. 使用.score()方法对训练集和测试集上的模型进行评估,该方法计算准确率。直接在网格对象上使用.score().predict()将自动计算出最佳模型的预测结果:

    # Print the accuracy score on train and test
    
    print('Accuracy on train set:', grid.score(
    
        X_train, y_train))
    
    print('Accuracy on test set:', grid.score(
    
        X_test, y_test))
    
    Accuracy on train set: 0.9824175824175824
    
    Accuracy on test set: 0.9912280701754386
    

在这种情况下,这提高了测试集上的性能,尽管这并不总是那么简单。但方法保持不变,可以应用于任何数据集。

另见

网格搜索的官方文档可以在这里找到:scikit-learn.org/stable/modules/generated/sklearn.model_selection.GridSearchCV.xhtml

关于 scikit-learn 中可用的求解器和惩罚项的更多信息可以在这里找到:scikit-learn.org/stable/modules/linear_model.xhtml#logistic-regression

第四章:基于树的模型的正则化

基于树的集成学习模型,如随机森林或梯度提升,通常被视为易于使用的最先进模型,适用于常规机器学习任务。

许多 Kaggle 竞赛都是通过这样的模型获胜的,因为它们在发现数据中的复杂模式时非常健壮且高效。知道如何正则化和微调它们是获得最佳性能的关键。

在本章中,我们将探讨以下食谱:

  • 构建分类树

  • 构建回归树

  • 正则化决策树

  • 训练一个随机森林算法

  • 随机森林的正则化

  • 使用 XGBoost 训练提升模型

  • 使用 XGBoost 进行正则化

技术要求

在本章中,您将训练和微调几个基于决策树的模型,并可视化一棵树。本章将需要以下库:

  • NumPy

  • Matplotlib

  • Scikit-learn

  • Graphviz

  • XGBoost

  • pickle

构建分类树

决策树是机器学习中一个独立的模型类别。尽管单独的决策树可以被视为一个弱学习器,但结合集成学习的力量,如袋装法或提升法,决策树能够获得非常好的性能。在深入研究集成学习模型及其如何正则化之前,在这个食谱中,我们将回顾决策树是如何工作的,并且如何在鸢尾花数据集的分类任务中使用它们。

为了直观地理解决策树的强大能力,我们考虑一个使用场景。我们想知道是否应根据两个输入特征:阳光和温度,在海滩上卖冰激凌。

我们在图 4**.1中有数据,并希望在其上训练一个模型。

图 4.1 – 如果我们应该卖冰激凌,表示为圆圈;如果不应该,表示为叉号

图 4.1 – 如果我们应该卖冰激凌,表示为圆圈;如果不应该,表示为叉号

对于人类来说,这看起来相当简单。但对于线性模型来说就不那么容易了。如果我们尝试在这些数据上使用逻辑回归,它将最终绘制出类似于图 4**.2左侧的决策线。即便使用更高次幂的特征,逻辑回归也会遇到困难,并提出类似于图 4**.2右侧的决策线。

图 4.2 – 线性模型对该数据集分类的潜在结果:左侧是原始特征,右侧是更高次幂特征

图 4.2 – 线性模型对该数据集分类的潜在结果:左侧是原始特征,右侧是更高次幂特征

总而言之,这些数据是非线性可分的。但它可以被划分为两个独立的线性可分问题:

  • 天气晴吗?

  • 温度热吗?

如果我们满足这两个条件,那么我们应该卖冰激凌。这可以总结为图 4**.3中的树形结构:

图 4.3 – 一个正确分类所有数据点的决策树

图 4.3 – 一个正确分类所有数据点的决策树

在这里我们要介绍一些词汇:

  • 我们有Warm,这是第一个决策节点,也是根节点,包含两个分支:YesNo

  • 我们在Sunny中有另一个决策节点。决策节点是指包含两个(有时更多)分支的任何节点。

  • 我们有三个叶节点。叶节点没有任何分支,包含最终的预测结果。

  • 就像计算机科学中的二叉树一样,树的深度是根节点和最底部叶节点之间的边数。

如果我们回到我们的数据集,决策线现在将看起来像图 4.4那样,提供有效的解决方案,不是一个而是两个组合的线:

图 4.4 – 决策树在分类此数据集时的结果

图 4.4 – 决策树在分类此数据集时的结果

从现在开始,任何新数据都将进入这些叶节点之一,从而被正确分类。

这就是决策树的强大之处:它们能够计算复杂的非线性规则,赋予它们极大的灵活性。决策树使用贪婪算法进行训练,这意味着它每次只尝试优化一个步骤。

更具体地说,这意味着决策树并不是全局优化的,而是逐个节点进行优化。

这可以看作是一个递归算法:

  1. 取节点中的所有样本。

  2. 在某个特征中找到一个阈值,以最小化分裂的无序度。换句话说,找到给出最佳类别分离的特征和阈值。

  3. 将其分裂为两个新的节点。

  4. 回到第 1 步,直到你的节点是纯净的(意味着只剩下一个类别),或者满足其他条件,成为叶节点。

但我们如何选择最优的分裂方式呢?当然,我们使用一个损失函数,它使用无序度量。让我们在总结之前先深入探讨这两个话题。

无序度量

为了使分类树有效,它必须在其叶节点中尽可能减少无序。实际上,在前面的示例中,我们假设所有的叶节点都是纯净的。它们只包含来自单一类别的样本。实际上,叶节点可能是不纯的,包含来自多个类别的样本。

注意

如果训练决策树后,叶节点仍然不纯,我们将使用该叶节点的多数类进行分类。

所以,基本思路是最小化不纯度,但我们如何度量它呢?有两种方法:熵和基尼不纯度。让我们一起来看看这两种方法。

熵是一个广泛使用的术语,应用于多个领域,比如物理学和计算机科学。我们在这里使用的熵E可以用以下方程定义,其中 pi 是样本中子类 的比例:

我们来看一个具体的例子,如图 4.5所示:

图 4.5 – 一个包含 10 个样本,分为红色和蓝色两类的节点

图 4.5 – 一个包含两类(红色和蓝色)10 个样本的节点

在这个例子中,熵将是以下内容:

事实上,我们有以下结果:

  • =3/10,因为我们有 10 个样本中有 3 个是蓝色的

  • =7/10,因为我们有 10 个样本中有 7 个是蓝色的

如果我们观察极端情况,我们会理解熵非常适合用来计算混乱度:

  • 如果 =0,那么 =1 并且 E = 0

  • 如果 pblue = = 0.5, 那么 E = 1

所以,我们可以理解,当节点包含完全混合的样本时,熵达到最大值 1,而当节点只包含一个类别时,熵为零。这个总结可以通过图 4.6中的曲线展示:

图 4.6 – 熵作为 p 对于两个类别的函数

图 4.6 – 熵作为 p 对于两个类别的函数

基尼不纯度

基尼不纯度是另一种衡量混乱度的方法。基尼不纯度 G 的公式非常简单:

再次提醒, 是节点中类别的比例。

应用于图 4.5中的示例节点,基尼不纯度的计算将得出以下结果:

结果与熵有很大的不同,但让我们检查极值的情况,以确保属性保持一致:

  • 如果 ,那么 并且 G = 0

  • 如果 = 0.5,那么 G = 0.5

事实上,当混乱度最大时,基尼不纯度达到最大值 0.5,当节点是纯净的时,基尼不纯度为 0。

熵还是基尼?

那么,我们应该使用什么呢?这可以看作是一个超参数,而 scikit-learn 的实现允许选择熵或基尼。

在实践中,两者的结果通常是相同的。但基尼不纯度的计算更快(熵涉及更昂贵的对数计算),所以它通常是首选。

损失函数

我们有一个混乱度的度量,但我们应该最小化的损失是什么?最终目标是做出划分,最小化每次划分的混乱度。

考虑到一个决策节点总是有两个子节点,我们可以将它们定义为左子节点和右子节点。那么,这个节点的损失可以写成这样:

让我们分解这个公式:

  • m, ,以及 分别是每个节点中样本的数量

  • 是左子节点和右子节点的基尼不纯度

当然,这也可以通过熵而不是基尼不纯度来计算。

假设我们选择了一个划分决策,那么我们就有一个父节点和两个由图 4.7中的划分定义的子节点:

图 4.7 – 一个父节点和两个子节点,它们各自的基尼不纯度

图 4.7 – 一个父节点和两个子节点,以及它们各自的基尼杂质

在这种情况下,损失L将是如下:

使用这个损失计算方法,我们现在能够最小化杂质(从而最大化节点的纯度)。

注意

我们在这里定义的损失只是节点层次的损失。实际上,正如前面所说,决策树是通过贪心方法训练的,而不是通过梯度下降。

准备工作

最后,在进入本食谱的实际细节之前,我们需要安装以下库:scikit-learn、graphvizmatplotlib

它们可以通过以下命令行安装:

pip install scikit-learn graphviz matplotlib

如何操作…

在实际训练决策树之前,让我们快速浏览一下训练决策树的所有步骤:

  1. 我们有一个包含 N 个类别样本的节点。

  2. 我们遍历所有特征以及每个特征的所有可能值。

  3. 对于每个特征值,我们计算基尼杂质和损失。

  4. 我们保留具有最低损失的特征值,并将节点分裂成两个子节点。

  5. 返回到步骤 1,对两个节点进行处理,直到某个节点纯净(或者满足停止条件)。

使用这种方法,决策树最终会找到一组正确的决策,成功地分离各个类别。然后,对于每个叶子节点,有两种可能的情况:

  • 如果叶子节点纯净,则预测该类别

  • 如果叶子节点不纯净,则预测出现最多的类别

注意

测试所有可能特征值的一种方式是使用数据集中所有现有的值。另一种方法是对数据集中现有值的范围进行线性划分。

现在,让我们用 scikit-learn 在鸢尾花数据集上训练一个决策树:

  1. 首先,我们需要导入必需的库:用于数据可视化的matplotlib(如果没有其他需求,可以不必使用),用于加载数据集的load_iris,用于将数据集分为训练集和测试集的train_test_split,以及 scikit-learn 中实现的DecisionTreeClassifier决策树模型:

    from matplotlib import pyplot as plt
    
    from sklearn.datasets import load_iris
    
    from sklearn.model_selection import train_test_split
    
    from sklearn.tree import DecisionTreeClassifier
    
  2. 我们现在可以使用load_iris加载数据:

    # Load the dataset
    
    X, y = load_iris(return_X_y=True)
    
  3. 我们使用train_test_split将数据集分为训练集和测试集,保持默认参数,只指定随机状态以确保可重复性:

    # Split the dataset
    
    X_train, X_test, y_train, y_test = train_test_split(
    
        X, y, random_state=0)
    
  4. 在这一步,我们展示数据的二维投影。这个操作只是为了教学目的,但并非强制要求:

    # Plot the training points
    
    plt.scatter(X[:, 0], X[:, 1], c=y)
    
    plt.xlabel('Sepal length')
    
    plt.ylabel('Sepal width')
    
    plt.show()
    

这是相应的图表:

图 4.8 – 三个鸢尾花类别作为萼片宽度和萼片长度的函数(由代码生成的图)

图 4.8 – 三个鸢尾花类别作为萼片宽度和萼片长度的函数(由代码生成的图)

  1. 实例化DecisionTreeClassifier模型。我们在这里使用默认参数:

    # Instantiate the model
    
    dt = DecisionTreeClassifier()
    
  2. 在训练集上训练模型。这里我们没有对数据进行任何预处理,因为我们只有定量特征,而决策树不对特征的尺度敏感,不像线性模型。但如果对定量特征进行缩放也无妨:

    # Fit the model on the training data
    
    dt.fit(X_train, y_train)
    
  3. 最后,我们使用分类树的score()方法在训练集和测试集上评估模型的准确性:

    # Compute the accuracy on training and test sets
    
    print('Accuracy on training set:', dt.score(
    
        X_train, y_train))
    
    print('Accuracy on test set:', dt.score(
    
        X_test, y_test))
    

这将打印出以下输出:

Accuracy on training set: 1.0
Accuracy on test set: 0.9736842105263158

即使我们在训练集上出现了 100%的准确率,显然面临过拟合问题,我们仍然取得了令人满意的结果。

还有更多…

与线性模型不同,树没有与每个特征相关联的权重,因为树是由划分组成的。

为了可视化目的,我们可以利用graphviz库来展示树。这主要用于教学目的或兴趣,除此之外并不一定有用:

from sklearn.tree import export_graphviz
import graphviz
# We load iris data again to retrieve features and classes names
iris = load_iris()
# We export the tree in graphviz format
graph_data = export_graphviz(
    dt,
    out_file=None,
    feature_names=iris.feature_names,
    class_names=iris.target_names,
    filled=True, rounded=True
)
# We load the tree again with graphviz library in order to display it
graphviz.Source(graph_data)

这里是它的树形图:

图 4.9 – 使用 graphviz 库生成的树形可视化

图 4.9 – 使用 graphviz 库生成的树形可视化

从这个树形可视化中,我们可以看到,setosa 类的 37 个样本在第一个决策节点就完全被分类(考虑到数据的可视化,这并不令人惊讶)。而 virginica 和 versicolor 类的样本在提供的特征中似乎更为交织,因此树需要更多的决策节点才能完全区分它们。

与线性模型不同,我们没有与每个特征相关联的权重。但我们可以获得某种等效的信息,称为特征重要性,可以通过.``feature_importances属性获取:

import numpy as np
plt.bar(iris.feature_names, dt.feature_importances_)
plt.xticks(rotation=45))
plt.ylabel('Feature importance')
plt.title('Feature importance for the decision tree')
plt.show()

这里是它的图示:

图 4.10 – 特征重要性与特征名称的关系(由代码生成的直方图)

图 4.10 – 特征重要性与特征名称的关系(由代码生成的直方图)

该特征重要性是相对的(意味着所有特征重要性之和等于 1),并且是根据通过该特征分类的样本数量来计算的。

注意

特征重要性是根据用于划分的指标的减少量来计算的(例如,基尼不纯度或熵)。如果某个特征能够做出所有的划分,那么这个特征的重要性将是 1。

另见

Sci-kit 学习文档中关于分类树的说明,请参考以下网址:scikit-learn.org/stable/modules/generated/sklearn.tree.DecisionTreeClassifier.xhtml

构建回归树

在深入探讨决策树的正则化之前,让我们首先了解回归树的使用方法。事实上,之前的所有解释都是假设我们在处理分类任务。现在让我们解释如何将其应用于回归任务,并将其应用于加利福尼亚住房数据集。

对于回归树,与分类树相比,只需要修改几个步骤:推断和损失计算。除此之外,整体原理是相同的。

推断

为了进行推理,我们不能再使用叶子节点中最常见的类别(或者在纯叶子的情况下,唯一的类别)。因此,我们使用每个节点中标签的平均值。

图 4.11中提议的示例中,假设这是一个叶子节点,那么推理值将是这 10 个值的平均数,在这个案例中为 14。

图 4.11 – 具有相关值的 10 个样本示例

图 4.11 – 具有相关值的 10 个样本示例

损失

在回归树中,不使用杂乱度度量来计算损失,而是使用均方误差。所以,损失公式如下:

假设再次给定一个分割,导致左节点中的样本为,右节点中的样本为。每个分割的MSE通过计算该节点中标签的平均值来得出:

图 4.12 – 回归任务中的节点分割示例

图 4.12 – 回归任务中的节点分割示例

如果我们以图 4.12中提出的分割为例,我们已经具备计算 L 损失所需的一切:

基于这两处轻微的变化,我们可以使用与分类树相同的递归贪心算法来训练回归树。

准备工作

在实际操作之前,我们只需要安装 scikit-learn 库。如果尚未安装,只需在终端中输入以下命令:

pip install scikit-learn

如何操作…

我们将使用 scikit-learn 中的DecisionTreeRegressor类,在加利福尼亚房价数据集上训练回归树:

  1. 首先,所需的导入:fetch_california_housing函数用于加载加利福尼亚房价数据集,train_test_split函数用于拆分数据,DecisionTreeRegressor类包含回归树实现:

    from sklearn.datasets import fetch_california_housing
    
    from sklearn.model_selection import train_test_split
    
    from sklearn.tree import DecisionTreeRegressor
    
  2. 使用fetch_california_housing函数加载数据集:

    X, y = fetch_california_housing(return_X_y=True)
    
  3. 使用train_test_split函数将数据分为训练集和测试集:

    X_train, X_test, y_train, y_test = train_test_split(
    
        X, y, test_size=0.2, random_state=0)
    
  4. 实例化DecisionTreeRegressor对象。我们在这里保持默认参数,但此时可以自定义它们:

    dt = DecisionTreeRegressor()
    
  5. 使用DecisionTreeRegressor类的.fit()方法,在训练集上训练回归树。请注意,我们不对数据应用任何特定的预处理,因为我们只有定量特征,而决策树对特征尺度问题不敏感:

    dt.fit(X_train, y_train)
    
    DecisionTreeRegressor()
    
  6. 使用模型类的内置.score()方法,评估回归树在训练集和测试集上的 R2 分数:

    print('R2-score on training set:', dt.score(
    
        X_train, y_train))
    
    print('R2-score on test set:', dt.score(
    
        X_test, y_test))
    

这将显示类似以下内容:

R2-score on training set: 1.0
R2-score on test set: 0.5923572475948657

如我们所见,这里出现了强烈的过拟合,训练集上的 R2 分数完美,而测试集上的 R2 分数要差得多(但整体上仍然不错)。

另请参见

查看官方的 DecisionTreeRegressor 文档以获取更多信息:scikit-learn.org/stable/modules/generated/sklearn.tree.DecisionTreeRegressor.xhtml#sklearn-tree-decisiontreeregressor

正则化决策树

在这个食谱中,我们将探讨正则化决策树的方法。我们将回顾并评论几个方法供参考,并提供一些更多的供进一步探索。

准备工作

显然,我们无法像在线性模型中使用 L1 或 L2 正则化那样使用它。由于我们没有特征的权重,也没有类似均方误差或二元交叉熵的整体损失,因此在这里无法应用这种方法。

但我们确实有其他的正则化方法,例如树的最大深度、每个叶节点的最小样本数、每次分裂的最小样本数、最大特征数或最小杂质减少量。在这个食谱中,我们将探讨这些方法。

为此,我们只需要以下库:scikit-learn、matplotlibNumPy。另外,由于我们将提供一些可视化以帮助理解正则化,我们将使用以下的 plot_decision_function 函数:

def plot_decision_function(dt, X, y):
    # Create figure to draw chart
    plt.figure(2, figsize=(8, 6))
    # We create a grid of points contained within [x_min,
      #x_max]x[y_min, y_max] with step h=0.02
    x0_min, x0_max = X[:, 0].min() - .5, X[:, 0].max() + .5
    x1_min, x1_max = X[:, 1].min() - .5, X[:, 1].max() + .5
    h = .02  # step size of the grid
    xx0, xx1 = np.meshgrid(np.arange(x0_min, x0_max, h),
        np.arange(x1_min, x1_max, h))
    # Retrieve predictions for each point of the grid
    Z_dt = dt.predict(np.c_[xx0.ravel(), xx1.ravel()])
    Z_dt = Z_dt.reshape(xx0.shape)
    # Plot the decision boundary (label predicted assigned to a color)
    plt.pcolormesh(xx0, xx1, Z_dt, cmap=plt.cm.Paired)
    # Plot also the training points
    plt.scatter(X[:, 0], X[:, 1], c=y, edgecolors='k',
        cmap=plt.cm.Paired)
    # Format chart
    plt.xlabel('Sepal length')
    plt.ylabel('Sepal width')
    plt.xticks(())
    plt.yticks(())
    plt.show()

这个函数将帮助我们可视化决策树的决策函数,更好地理解分类树中的过拟合和正则化。

如何做……

我们将提供一个基于最大深度来正则化决策树的食谱,然后在 还有更多 部分探讨其他一些方法。

最大深度通常是调整超参数时首先需要调整的一个参数。事实上,正如我们之前看到的,决策树可以使用更多的决策节点学习复杂的数据模式。如果不加限制,决策树可能会因为太多连续的决策节点而过拟合数据。

我们现在将在鸢尾花数据集上训练一个限制最大深度的分类树:

  1. 进行必要的导入:

    • load_iris 函数用于加载数据集

    • train_test_split 函数用于将数据拆分为训练集和测试集

    • DecisionTreeClassifier 类:

      from sklearn.datasets import load_iris
      
      from sklearn.model_selection import train_test_split
      
      from sklearn.tree import DecisionTreeClassifier
      
  2. 使用 load_iris 函数加载数据集。为了能够完全可视化正则化的效果,我们还只保留了四个特征中的两个,以便可以将它们显示在图表中:

    X, y = load_iris(return_X_y=True)
    
    # Keep only 2 features
    
    X = X[:, :2]
    
  3. 使用 train_test_split 函数将数据拆分为训练集和测试集。我们只指定随机状态以确保可重复性,其他参数保持默认:

    X_train, X_test, y_train, y_test = train_test_split(
    
        X, y, random_state=0)
    
  4. 实例化一个决策树对象,将最大深度限制为五,并使用 max_depth=5 参数。我们还将随机状态设置为 0 以确保可重复性:

    dt = DecisionTreeClassifier(max_depth=5,
    
        random_state=0)
    
  5. 使用.fit()方法将分类树拟合到训练集。正如之前提到的,由于特征都是定量的,而决策树对特征的尺度不敏感,因此无需进行重缩放:

    dt.fit(X_train, y_train)
    
    DecisionTreeClassifier(max_depth=5, random_state=0)
    
  6. 使用DecisionTreeClassifier类的.score()方法评估模型准确性:

    print('Accuracy on training set:', dt.score(
    
        X_train, y_train))
    
    print('Accuracy on test set:', dt.score(
    
        X_test, y_test))
    

这将打印以下输出:

Accuracy on training set: 0.8660714285714286
Accuracy on test set: 0.6578947368421053

它是如何工作的…

为了更好地理解它是如何工作的,我们来看看我们保留的鸢尾花数据集的两个维度。我们将使用准备工作中定义的plot_decision_function()函数来绘制没有正则化的决策树的决策函数(即,默认超参数):

import numpy as np
from matplotlib import pyplot as plt
# Fit a decision tree over only 2 features
dt = DecisionTreeClassifier()
dt.fit(X_train[:, :2], y_train)
# Plot the decision tree decision function
plot_decision_function(dt, X_train[:, :2], y_train)

下面是图形:

图 4.13 – 模型的决策函数作为花萼宽度和花萼长度的函数,具有非常复杂且值得怀疑的决策函数(由代码生成的图)

图 4.13 – 模型的决策函数作为花萼宽度和花萼长度的函数,具有非常复杂且值得怀疑的决策函数(由代码生成的图)

从这个图中,我们可以推断出我们通常面临的是过拟合。事实上,边界非常具体,有时试图为单个样本创建复杂的模式,而不是专注于更高级的模式。

事实上,如果我们查看训练集和测试集的准确率,我们得到以下结果:

# Compute the accuracy on training and test sets for only 2 features
print('Accuracy on training set:', dt.score(X_train[:, :2], y_train))
print('Accuracy on test set:', dt.score(X_test[:, :2], y_test))

我们将得到以下输出:

Accuracy on training set: 0.9375
Accuracy on test set: 0.631578947368421

虽然训练集的准确率约为 94%,但测试集的准确率仅约为 63%。存在过拟合,正则化可能有助于缓解。

注意

由于我们仅使用两个特征进行可视化和教学目的,因此准确率远低于第一个示例。然而,如果我们保留四个特征,推理仍然成立。

现在让我们通过限制决策树的最大深度来添加正则化,正如我们在这个示例中所做的那样:

max_depth: int, default=None

如果树的最大深度是min_samples_split个样本。

这意味着默认情况下,树的深度没有限制地扩展。限制可能由其他因素设置,且深度可能非常大。如果我们通过将深度限制为5来解决这个问题,我们来看看对决策函数的影响:

# Fit a decision tree with max depth of 5 over only 2 features
dt = DecisionTreeClassifier(max_depth=5, random_state=0)
dt.fit(X_train[:, :2], y_train)
# Plot the decision tree decision function
plot_decision_function(dt, X_train[:, :2], y_train)

下面是输出结果:

图 4.14 – 最大深度正则化的决策函数(由代码生成的图)

图 4.14 – 最大深度正则化的决策函数(由代码生成的图)

通过将最大深度限制为5,我们得到了一个不太具体的决策函数,即使似乎仍然存在一些过拟合。如果我们再次查看准确率,我们可以看到它实际上有所帮助:

# Compute the accuracy on training and test sets for only 2 features
print('Accuracy on training set:', dt.score(X_train[:, :2], y_train))
print('Accuracy on test set:', dt.score(X_test[:, :2], y_test))

这将生成以下输出:

Accuracy on training set: 0.8660714285714286
Accuracy on test set: 0.6578947368421053

确实,测试集的准确率从 63%上升到 66%,而训练集的准确率从 95%下降到 87%。这通常是我们从正则化中可以预期的结果:它增加了偏差(因此降低了训练集的表现),并减少了方差(因此使我们能够更好地进行泛化)。

还有更多内容…

最大深度超参数非常方便,因为它容易理解并进行微调。但是,还有许多其他超参数可以帮助正则化决策树。我们将在这里回顾其中的一些。我们将重点介绍最小样本超参数,然后提出一些其他超参数。

最小样本数

其他允许我们进行正则化的超参数包括控制每个叶子节点的最小样本数和每次分裂的最小样本数。

这个想法相当直接且直观,但比最大深度更微妙。在我们在本章早些时候可视化的决策树中,我们可以看到第一次分裂分类了大量的样本。第一次分裂成功地将 37 个样本分类为 setosa,并将其余 75 个样本保留在另一个分裂中。在决策树的另一端,最底层的节点有时仅在三或四个样本上进行分裂。

仅在三条样本上进行分裂是否具有显著意义?如果这三条样本中有一个异常值呢?通常来说,如果最终目标是拥有一个稳健、良好泛化的模型,那么仅基于三条样本创建规则并不是什么值得期待的想法。

我们有两个不同但有些相关的超参数,可以帮助我们处理这个问题:

  • min_samples_split:进行内部节点分裂所需的最小样本数。如果提供的是浮动值,则表示样本总数的一个比例。

  • min_samples_leaf:被视为叶子的最小样本数。如果提供的是浮动值,则表示样本总数的一个比例。

虽然min_samples_split作用于决策节点层面,但min_samples_leaf仅作用于叶子层面。

让我们看看这是否能帮助我们避免在特定区域的过拟合。在这种情况下,我们将每次分裂的最小样本数设置为 15(其余参数保持默认值)。这预计将进行正则化,因为我们从决策树的可视化中知道,有些分裂的样本数少于五个:

# Fit a decision tree with min samples per split of 15 over only 2 features
dt = DecisionTreeClassifier(min_samples_split=15, random_state=0)
dt.fit(X_train[:, :2], y_train)
# Plot the decision tree decision function
plot_decision_function(dt, X_train[:, :2], y_train)

这是输出结果:

图 4.15 – 每次分裂正则化的决策函数(图由代码生成)

图 4.15 – 每次分裂正则化的决策函数(图由代码生成)

结果的决策函数与使用最大深度正则化时略有不同,且确实比没有对该超参数进行约束时更加正则化。

我们还可以查看准确率,以确认正则化是否成功:

# Compute the accuracy on training and test sets for only 2 features
print('Accuracy on training set:', dt.score(X_train[:, :2],
    y_train))
print('Accuracy on test set:', dt.score(X_test[:, :2],
    y_test))

我们将获得以下输出:

Accuracy on training set: 0.85714285714285717
Accuracy on test set: 0.7368421052631579

与默认超参数相比,测试集的准确率从 63%上升到 74%,而训练集的准确率从 95%下降到 86%。与最大深度超参数相比,我们增加了些许正则化,并在测试集上获得了略微更好的结果。

通常,关于样本数量的超参数(无论是每个叶子还是每个分裂)可能比最大深度提供更细粒度的正则化。实际上,最大深度超参数为整个决策树设置了一个固定的硬限制。但有可能两个处于相同深度层次的节点包含的样本数不同。一个节点可能有数百个样本(这时分裂可能是相关的),而另一个节点可能只有几个样本。

对于最小样本数的准则更为微妙:无论树的深度如何,如果一个节点没有足够的样本,我们认为没有必要进行分裂。

其他超参数

其他超参数可以用于正则化。我们不会逐一讲解每个超参数的细节,而是列出它们并简要解释:

  • max_features:默认情况下,决策树会在所有特征中找到最佳分裂。你可以通过设置每次分裂时使用的特征最大数量来增加随机性,可能通过加入噪声来增加正则化。

  • max_leaf_nodes:设置树中叶子的数量上限。它类似于最大深度超参数,通过限制分裂的数量进行正则化,优先选择具有最大纯度减少的节点。

  • min_impurity_decrease:仅当纯度减少超过给定阈值时,才会分裂节点。这允许我们通过只选择影响较大的节点分裂来进行正则化。

注意

尽管我们没有提到回归树,但其行为和原理是类似的,相同的超参数也可以通过类似的行为进行微调。

参见

scikit-learn 文档对所有超参数及其潜在影响进行了详细说明:scikit-learn.org/stable/modules/generated/sklearn.tree.DecisionTreeClassifier.xhtml

训练随机森林算法

随机森林算法是一种集成学习模型,意味着它使用多个决策树集成,因此其名字中有森林

在这个步骤中,我们将解释它是如何工作的,然后在加利福尼亚房价数据集上训练一个随机森林模型。

准备工作

集成学习在某种程度上基于集体智能的理念。让我们做一个思维实验,以理解集体智能的力量。

假设我们有一个机器人,它在任何二元问题上随机回答正确的概率为 51%。这会被认为是低效且不可靠的。

但是现在,我们假设不仅使用一个,而是使用一支随机回答问题的机器人队伍,并使用多数投票作为最终答案。如果我们有 1,000 个这样的机器人, majority vote 将 75%的情况下提供正确答案。如果我们有 10,000 个机器人, majority vote 将 97%的情况下提供正确答案。这会将一个低效的系统转变为一个极为高效的系统。

注意

对这个例子做了一个强假设:每个机器人必须独立于其他机器人。否则,这个例子就不成立。事实上,极端的反例是,所有机器人对任何问题都给出相同的答案,在这种情况下,无论你使用多少个机器人,准确率都保持在 51%。

这是集体智慧的概念,它在某种程度上与人类社会相关。大多数情况下,集体知识超过个人知识。

这也是集成模型背后的理念:一组弱学习者可以变成一个强大的模型。为了做到这一点,在随机森林中,我们需要定义两个关键方面:

  • 如何计算多数投票

  • 如何确保我们模型中每棵决策树在随机性下的独立性

多数投票

为了正确解释多数投票,假设我们有一个由三棵决策树组成的集成模型,训练于一个二分类任务。在给定的样本上,预测结果如下:

类别 1 的预测概率 类别预测
树 1 0.05 0
树 2 0.6 1
树 3 0.55 1

表格 4.1 – 预测

我们为每棵决策树提供了两条信息:

  • 类别 1 的预测概率

  • 预测类别(通常在概率>0.5 时计算为类别 1,否则计算为类别 0)

注意

DecisionTreeClassifier.predict_proba()方法允许我们获取预测概率。它是通过使用预测叶子中给定类别的比例计算的。

我们可以提出许多方法来计算这样的数据的多数投票,但让我们探讨两种方法,硬投票和软投票:

  • 硬投票是最直观的一种。这是简单的多数投票,即预测类别的简单多数。在我们的例子中,类别 1 被预测了三次中的两次。在这种情况下,硬多数投票的类别为 1。

  • 一个软投票方法使用平均概率然后应用一个阈值。在我们的例子中,平均概率为 0.4,低于 0.5 的阈值。在这种情况下,软多数投票的类别为 0。

特别值得注意的是,即使三棵树中有两棵预测了类别 1,唯一一个非常自信(具有高概率)的树却是预测类别 0 的那棵树。

一个现实中的例子是,当面对一个问题时:

  • 两个朋友给出了 A 答案,但不确定

  • 一个朋友给出了 B 答案,但非常自信

在这种情况下你会怎么做呢?你很可能会听从那个非常自信的朋友。这正是软多数投票的含义:赋予自信度高的树更多权重。大多数情况下,软投票优于硬投票。幸运的是,scikit-learn 中的随机森林实现是基于软投票的。

集成法

集成法(Bagging)是随机森林中的一个关键概念,它确保了决策树之间的独立性,并由自助抽样和聚合组成。让我们看看这两个步骤是如何协同工作,充分发挥集成决策树的优势的。

自助抽样是有放回的随机抽样。简单来说,如果我们对样本进行自助抽样并允许有放回,则意味着我们会随机从数据集中挑选样本,并且样本在选中后不会从数据集中移除,可能会被重新挑选。

假设我们有一个包含 10 个样本的初始数据集,样本为蓝色或红色。如果我们使用自助抽样从该初始数据集中选择 10 个样本,可能会有些样本缺失,某些样本可能会出现多次。如果我们独立进行三次这样的操作,可能会得到三个略有不同的数据集,就像在图 4.16中所示的那样。我们称这些新创建的数据集为子样本:

图 4.16 – 对初始数据集进行三次自助抽样并选择 10 个样本进行替换(这三个生成的子样本略有不同)

图 4.16 – 对初始数据集进行三次自助抽样并选择 10 个样本进行替换(这三个生成的子样本略有不同)

由于这些子样本略有不同,在随机森林中,会对每个子样本训练一个决策树,并希望得到独立的模型。接下来的步骤是通过软多数投票聚合这些模型的结果。一旦这两个步骤(自助抽样和聚合)结合起来,这就是我们所说的集成法(bagging)。图 4.17总结了这两个步骤:

图 4.17 – 对样本进行自助抽样并聚合结果以得到集成模型

图 4.17 – 对样本进行自助抽样并聚合结果以得到集成模型

正如我们所看到的,随机森林中的随机来自于样本的自助抽样(bootstrapping),意味着我们为每个训练的决策树随机选择原始数据集的一个子样本。但实际上,为了教学的需要,其他层次的随机性被省略了。无需进入所有细节,随机森林算法中有三个层次的随机性:

  • 样本的自助抽样:样本是通过有放回的方式进行选择的

  • 特征的自助抽样:特征是通过有放回的方式进行选择的

  • 节点最佳分割特征选择:在 scikit-learn 中,默认使用所有特征,因此在这一层次没有随机性

现在我们对随机森林的工作原理有了足够清晰的理解,接下来让我们在回归任务上训练一个随机森林算法。为此,我们只需要安装 scikit-learn。如果尚未安装,只需使用以下命令行进行安装:

pip install scikit-learn

如何操作……

与 scikit-learn 中的其他机器学习模型一样,训练随机森林算法也非常简单。主要有两个类:

  • RandomForestRegressor用于回归任务

  • RandomForestClassifier用于分类任务

在这里,我们将使用RandomForestRegressor模型处理加利福尼亚住房数据集:

  1. 首先,让我们进行必要的导入:fetch_california_housing用于加载数据,train_test_split用于分割数据集,RandomForestRegressor用于模型本身:

    from sklearn.datasets import fetch_california_housing
    
    from sklearn.model_selection import train_test_split
    
    from sklearn.ensemble import RandomForestRegressor
    
  2. 使用fetch_california_housing加载数据集:

    X, y = fetch_california_housing(return_X_y=True)
    
  3. 使用train_test_split分割数据。在这里,我们仅使用默认参数,并将随机种子设置为 0,以确保结果可复现:

    X_train, X_test, y_train, y_test = train_test_split(
    
        X, y, random_state=0)
    
  4. 实例化RandomForestRegressor模型。为简单起见,我们在此保持类的默认参数,只指定随机种子:

    rf = RandomForestRegressor(random_state=0)
    
  5. 使用.fit()方法在训练集上训练模型。这可能需要几秒钟来计算:

    rf.fit(X_train, y_train)
    
    RandomForestRegressor(random_state=0)
    
  6. 使用.score()方法评估训练集和测试集的 R2 得分:

    # Display the accuracy on both training and test set
    
    print('R2-score on training set:', rf.score(X_train, y_train))
    
    print('R2-score on test set:', rf.score(X_test, y_test))
    

我们的输出结果如下:

R2-score on training set: 0.9727159677969947
R2-score on test set: 0.7941678302821006

在训练集上的 R2 得分为 97%,而在测试集上的得分仅为 79%。这意味着我们遇到了过拟合问题,在下一个示例中我们将看到如何为此类模型添加正则化。

另见

随机森林的正则化

随机森林算法与决策树共享许多超参数,因为随机森林是由多棵树组成的。但还有一些额外的超参数存在,因此在这个示例中,我们将介绍它们,并展示如何使用它们来改善加利福尼亚住房数据集回归任务的结果。

入门

随机森林已知容易过拟合。即使这不是一个正式的证明,在前一个示例中,我们确实遇到了相当强的过拟合问题。但随机森林与决策树一样,拥有许多超参数,允许我们尝试减少过拟合。对于决策树,我们可以使用以下超参数:

  • 最大深度

  • 每个叶子的最小样本数

  • 每个划分的最小样本数

  • max_features

  • max_leaf_nodes

  • min_impurity_decrease

但也可以微调其他一些超参数:

  • n_estimators:这是在随机森林中训练的决策树的数量。

  • max_samples:从给定数据集中抽取的样本数,用于训练每棵决策树。较低的值会增加正则化。

从技术上讲,本食谱假设已安装 scikit-learn。

如何做到……

在这个食谱中,我们将尝试通过将特征的最大数量限制为特征总数的对数来添加正则化。如果你正在使用与前一个食谱相同的环境,你可以直接跳到第 4 步

  1. 像往常一样,让我们进行所需的导入:fetch_california_housing用于加载数据,train_test_split用于划分数据集,RandomForestRegressor用于模型本身:

    from sklearn.datasets import fetch_california_housing
    
    from sklearn.model_selection import train_test_split
    
    from sklearn.ensemble import RandomForestRegressor
    
  2. 使用fetch_california_housing加载数据集:

    X, y = fetch_california_housing(return_X_y=True)
    
  3. 使用train_test_split划分数据。在这里,我们仅使用默认参数,这意味着我们有一个 75%和 25%的划分,并将随机状态设置为0以保证可重复性:

    X_train, X_test, y_train, y_test = train_test_split(
    
        X, y, random_state=0)
    
  4. 实例化RandomForestRegressor模型。这次,我们指定max_features='log2',这样每次拆分时,只会使用所有特征的一个随机子集(大小为log2(n),假设有n个特征):

    rf = RandomForestRegressor(max_features='log2', random_state=0)
    
  5. 使用.fit()方法在训练集上训练模型。这可能需要几秒钟时间来计算:

    rf.fit(X_train, y_train)
    
    RandomForestRegressor(max_features='log2', random_state=0)
    
  6. 使用.score()方法评估训练集和测试集上的 R2 得分:

    print('R2-score on training set:', rf.score(X_train,
    
        y_train))
    
    print('R2-score on test set:', rf.score(X_test,
    
        y_test))
    

这将返回以下内容:

R2-score on training set: 0.9748218476882353
R2-score on test set: 0.8137208340736402

与前一个使用默认超参数的食谱相比,它将测试集的 R2 得分从 79%提高到了 81%,而对训练集得分的影响不大。

注意

在机器学习的许多情况下,像这样,可能会遇到一个棘手的情况(有时甚至不可能),即训练集和测试集的性能无法达到平衡。这意味着,即使训练集的 R2 得分为 97%,测试集为 79%,也不能保证你能改善测试集上的 R2 得分。有时,即使是最好的超参数也不是改善性能的正确关键。

简而言之,所有适用于决策树的正则化规则都可以应用于随机森林,并且还提供了更多的规则。像往常一样,找到合适的超参数集的有效方法是通过超参数优化。随机森林的训练时间比简单的决策树要长,因此可能需要一些时间。

使用 XGBoost 训练提升模型

现在让我们看看决策树的另一个应用:提升。与并行训练多棵树的包装(在随机森林模型中使用)不同,提升是关于顺序训练树。在这个食谱中,我们将快速回顾什么是提升,然后使用 XGBoost,一个广泛使用的提升库,训练一个提升模型。

准备工作

让我们来看一下引入包装的局限性,然后看看提升如何解决其中的一些限制,以及如何解决。最后,让我们使用 XGBoost 在已经准备好的 Titanic 数据集上训练一个模型。

包装的局限性

假设我们有一个二分类任务,我们在两个特征上训练了三棵决策树的随机森林。如果在特征空间中的任何地方,至少三分之二的决策树是正确的,那么袋装方法预计将表现良好,如图 4.18所示。

图 4.18 – 虚线圆圈区域的重叠缺失突出显示了决策树的错误,展示了随机森林的强大性能

图 4.18 – 虚线圆圈区域的重叠缺失突出显示了决策树的错误,展示了随机森林的强大性能

图 4.18中,我们观察到虚线圆圈内的区域是决策树错误的地方。由于它们没有重叠,三分之二的决策树在任何地方都是正确的。因此,随机森林表现良好。

不幸的是,始终有三分之二的决策树正确是一个强假设。如果在特征空间中只有一个或更少的决策树是正确的,会发生什么?如图 4.19所示,随机森林算法开始表现不佳。

图 4.19 – 当三个决策树中只有一个或更少是正确时,随机森林表现不佳

图 4.19 – 当三个决策树中只有一个或更少是正确时,随机森林表现不佳

让我们看看提升如何通过顺序训练决策树来修复这个问题,使每棵树都试图修正前一棵树的错误。

注意

这些示例被简化了,因为随机森林可以使用软投票,因此可以预测只有少数几棵树预测的类别。但总体原理仍然适用。

梯度提升原理

梯度提升有几种实现方式,存在一些差异:XGBoost、CatBoost 和 LightGBM 都有各自的优缺点,而每个方法的细节超出了本书的范围。我们将解释梯度提升算法的一些通用原理,足以对模型有一个高层次的理解。

算法训练可以通过以下步骤总结:

  1. 在训练集上计算平均猜测值!

  2. 计算每个样本相对于最后一次猜测预测的伪残差!

  3. 在伪残差上训练一个决策树作为标签,从而得到预测值!

  4. 基于决策树的性能计算权重!

  5. 使用学习率𝜂、𝛾i 和这些预测的伪残差更新猜测值:o。

  6. 回到步骤 2,使用更新后的!,迭代直到达到决策树的最大数量或其他标准。

最终,最终预测将是以下内容:

𝜂学习率是一个超参数,通常为 0.001。

注意

所有提升算法的实现都有些不同。例如,并不是所有提升算法的树都与权重 𝛾 相关。但由于这是我们将在此使用的 XGBoost 所具备的特性,因此值得提及,以便更好地理解。

最终,拥有足够的决策树可以让模型在大多数情况下表现得足够好。与随机森林不同,提升模型通常避免了类似于在同一位置有最多错误决策树的陷阱,因为每棵决策树都在尝试修正剩余的错误。

此外,提升模型通常比随机森林模型更具鲁棒性和泛化能力,使其在许多应用中非常强大。

最后,对于这个食谱,我们需要安装以下库:picklexgboost。它们可以通过以下命令行使用 pip 安装:

pip install pickle xgboost

我们还将重用之前准备好的 Titanic 数据集,以避免花费过多时间在数据准备上。该数据可以在github.com/PacktPublishing/The-Regularization-Cookbook/blob/main/chapter_02/prepared_titanic.pkl下载,并应在进行该食谱之前通过以下命令行本地添加:

wget https://github.com/PacktPublishing/The-Regularization-Cookbook/blob/main/chapter_02/prepared_titanic.pkl

如何做…

XGBoost 是一种非常流行的梯度提升实现。它可以像 scikit-learn 中的模型一样使用,使用以下方法:

  • fit(X, y) 用于训练模型

  • predict(X) 用于计算预测结果

  • score(X, y) 用于评估模型

我们将在本地下载的 Titanic 数据集上使用默认参数:

  1. 第一步是所需的导入。在这里,我们需要 pickle 来读取二进制格式的数据,以及 XGBoost 分类模型类 XGBClassifier

    import pickle
    
    from xgboost import XGBClassifier
    
  2. 我们使用 pickle 加载已经准备好的数据。请注意,我们已经得到一个拆分数据集,因为它实际上是以这种方式保存的:

    X_train, X_test, y_train, y_test = pickle.load(open(
    
        'prepared_titanic.pkl', 'rb'))
    
  3. 实例化提升模型。我们指定 use_label_encoder=False,因为我们的定性特征已经通过一热编码进行了编码,而且这个特性即将被弃用:

    bst = XGBClassifier(use_label_encoder=False)
    
  4. 使用 .fit() 方法在训练集上训练模型,就像我们为 scikit-learn 模型所做的那样:

    # Train the model on training set
    
    bst.fit(X_train, y_train)
    
  5. 使用 .score() 方法计算模型在训练集和测试集上的准确度。同样,这与 scikit-learn 中的做法相同:

    print('Accuracy on training set:', bst.score(X_train,
    
        y_train))
    
    print('Accuracy on test set:', bst.score(X_test,
    
        y_test))
    

现在我们得到以下结果:

Accuracy on training set: 0.9747191011235955
Accuracy on test set: 0.8156424581005587

我们注意到过拟合:训练集准确率为 97%,但测试集准确率仅为 81%。但最终,测试集的结果还是相当不错的,因为在 Titanic 数据集上很难达到 85% 以上的准确度。

另见

使用 XGBoost 进行正则化

在介绍了提升方法和 XGBoost 分类使用的实例后,接下来我们来看一下如何对这些模型进行正则化。我们将使用相同的 Titanic 数据集,并尝试提高测试准确度。

准备就绪

就像随机森林一样,XGBoost 模型由决策树组成。因此,它有一些超参数,例如树的最大深度(max_depth)或树的数量(n_estimators),这些超参数可以以相同的方式进行正则化。它还有一些与决策树相关的其他超参数可以进行微调:

  • subsample:用于训练的随机抽取样本数,等同于 scikit-learn 中决策树的max_sample。较小的值可能会增加正则化效果。

  • colsample_bytree:在每棵树中随机抽取的特征数量(等同于 scikit-learn 的max_features)。较小的值可能会增加正则化效果。

  • colsample_bylevel:在树级别随机抽取的特征数量。较小的值可能会增加正则化效果。

  • colsample_bynode:在节点级别随机抽取的特征数量。较小的值可能会增加正则化效果。

最后,还有一些与决策树或随机森林不同的超参数可以用于调优 XGBoost 模型:

  • learning_rate:学习率。较小的学习率可能会使训练更加接近训练集。因此,较大的学习率可能会起到正则化作用,尽管它也可能会降低模型性能。

  • reg_alpha:L1 正则化的强度。较高的值意味着更强的正则化。

  • reg_lambda:L2 正则化的强度。较高的值意味着更强的正则化。

与之前所见的其他基于树的模型不同,XGBoost 也支持 L1 和 L2 正则化。实际上,由于每棵树都有一个关联的权重𝛾i,因此可以像在线性模型中一样对这些参数添加 L1 或 L2 正则化。

这些是调优 XGBoost 时需要微调的主要超参数,以优化并适当正则化模型。虽然它是一个强大、健壮且高效的模型,但由于超参数数量众多,最佳调优可能会比较困难。

更实际地说,在这个实例中,我们将只添加 L1 正则化。为此,我们只需要安装 XGBoost,并准备好 Titanic 数据集,如同上一个实例一样。

如何做到这一点…

在这个实例中,我们将使用reg_alpha参数添加 L1 正则化,以便添加偏差并希望减少模型的方差。我们将重新使用之前实例中的XGBClassifier模型,使用已经准备好的 Titanic 数据。如果你的环境与之前一样,可以跳到步骤 3

  1. 和往常一样,我们从必需的导入开始:使用 pickle 从二进制格式读取数据,并使用 XGBoost 分类模型类 XGBClassifier

    import pickle
    
    from xgboost import XGBClassifier
    
  2. 然后,使用 pickle 加载已经准备好的数据。它假设 prepared_titanic.pkl 文件已经在本地下载:

    X_train, X_test, y_train, y_test = pickle.load(open(
    
        'prepared_titanic.pkl', 'rb'))
    
  3. 实例化提升模型。除了指定 use_label_encoder=False 外,我们现在还指定 reg_alpha=1 来添加 L1 正则化:

    bst = XGBClassifier(use_label_encoder=False,reg_alpha=1)
    
  4. 使用 .``fit() 方法在训练集上训练模型:

    bst.fit(X_train, y_train)
    
  5. 最后,使用 .``score() 方法计算模型在训练集和测试集上的准确率:

    print('Accuracy on training set:', bst.score(X_train,
    
        y_train))
    
    print('Accuracy on test set:', bst.score(X_test,
    
        y_test))
    

这将打印以下内容:

Accuracy on training set: 0.9410112359550562
Accuracy on test set: 0.8435754189944135

与之前的默认超参数配置相比,加入 L1 惩罚使我们能够改善结果。现在在测试集上的准确率约为 84%,而在训练集上降低到 94%,有效地加入了正则化。

还有更多内容……

使用 XGBoost 寻找最佳超参数集可能会比较棘手,因为超参数种类繁多。当然,使用超参数优化技术是为了节省一些前期时间。

对于回归任务,如前所述,XGBoost 库有一个 XGBRegressor 类来实现这些任务,并且相同的超参数对正则化有相同的效果。

第五章:数据正则化

尽管有许多正则化方法可供模型使用(每个模型都有一套独特的超参数),但有时最有效的正则化方法来自于数据本身。事实上,有时即使是最强大的模型,如果数据没有事先正确转换,也无法达到良好的性能。

在本章中,我们将介绍一些有助于通过数据正则化模型的方法:

  • 哈希高基数特征

  • 聚合特征

  • 对不平衡数据集进行欠采样

  • 对不平衡数据集进行过采样

  • 使用 SMOTE 对不平衡数据进行重采样

技术要求

在本章中,您将对数据应用几个技巧,并通过命令行重采样数据集或下载新数据。为此,您需要以下库:

  • NumPy

  • pandas

  • scikit-learn

  • imbalanced-learn

  • category_encoders

  • Kaggle API

哈希高基数特征

高基数特征是具有许多可能值的定性特征。高基数特征在许多应用中都会出现,例如客户数据库中的国家、广告中的手机型号,或 NLP 应用中的词汇。高基数问题可能是多方面的:不仅可能导致非常高维的数据集,而且随着越来越多的值的出现,它们还可能不断发展。事实上,即使国家数量或词汇的数据相对稳定,每周(如果不是每天)也会有新的手机型号出现。

哈希是一种非常流行且有用的处理这类问题的方法。在本章中,我们将了解它是什么,以及如何在实践中使用它来预测员工是否会离开公司。

入门

哈希是计算机科学中非常有用的技巧,广泛应用于密码学或区块链等领域。例如,它在处理高基数特征时也在机器学习中非常有用。它本身不一定有助于正则化,但有时它可能是一个副作用。

什么是哈希?

哈希通常在生产环境中用于处理高基数特征。高基数特征往往具有越来越多的可能结果。这些结果可能包括诸如手机型号、软件版本、商品 ID 等。在这种情况下,使用独热编码(one-hot encoding)处理高基数特征可能会导致一些问题:

  • 所需空间不是固定的,无法控制

  • 我们需要弄清楚如何编码一个新值

使用哈希代替独热编码可以解决这些限制。

为此,我们必须使用一种哈希函数,它将输入转换为一个可控的输出。一种著名的哈希函数是md5。如果我们对一些字符串应用md5,我们将得到如下结果:

from hashlib import md5
print('hashing of "regularization" ->',
    md5(b'regularization').hexdigest())
print('hashing of "regularized" ->',
    md5(b'regularized').hexdigest())
print('hashing of "machine learning" ->',
    md5(b'machine learning').hexdigest())

输出将如下所示:

hashing of "regularization" -> 04ef847b5e35b165c190ced9d91f65da
hashing of "regularized" -> bb02c45d3c38892065ff71198e8d2f89
hashing of "machine learning" -> e04d1bcee667afb8622501b9a4b4654d

如我们所见,哈希具有几个有趣的特性:

  • 无论输入大小如何,输出大小是固定的

  • 两个相似的输入可能导致非常不同的输出

这些属性使得哈希函数在与高基数特征一起使用时非常有效。我们需要做的就是这样:

  1. 选择一个哈希函数。

  2. 定义输出的预期空间维度。

  3. 使用该函数对我们的特征进行编码。

当然,哈希也有一些缺点:

  • 可能会发生冲突——两个不同的输入可能会有相同的输出(即使这不一定会影响性能,除非冲突非常严重)

  • 我们可能希望相似的输入有相似的输出(一个精心选择的哈希函数可以具备这样的特性)

所需的安装

我们需要为这个示例做一些准备。由于我们将下载一个 Kaggle 数据集,首先,我们需要安装 Kaggle API:

  1. 使用pip安装该库:

    pip install kaggle
    
  2. 如果你还没有这样做,创建一个 Kaggle 账户,访问www.kaggle.com

  3. 转到你的个人资料页面并通过点击kaggle.json文件将其下载到计算机:

图 5.1 – Kaggle 网站截图

图 5.1 – Kaggle 网站截图

  1. 你需要通过以下命令行将新下载的kaggle.json文件移动到~/.kaggle

    mkdir ~/.kaggle && mv kaggle.json ~/.kaggle/.
    
  2. 现在你可以使用以下命令行下载数据集:

    kaggle datasets download -d reddynitin/aug-train
    
  3. 我们现在应该有一个名为aug-train.zip的文件,其中包含我们将在这个示例中使用的数据。我们还需要通过以下命令行安装category_encoderspandassklearn库:

    pip install category_encoders pandas scikit-learn.
    

如何做...

在这个示例中,我们将加载并快速准备数据集(快速意味着更多的数据准备可能会带来更好的结果),然后应用逻辑回归模型来处理这个分类任务。在所选数据集上,city特征有 123 个可能的结果,因此可以认为它是一个高基数特征。此外,我们可以合理地假设生产数据可能包含更多的城市,因此哈希技巧在这里是有意义的:

  1. 导入所需的模块、函数和类:pandas用于加载数据,train_test_split用于划分数据,StandardScaler用于重新缩放定量特征,HashingEncoder用于编码定性特征,LogisticRegression作为模型:

    Import numpy as np
    
    import pandas as pd
    
    from sklearn.model_selection import train_test_split
    
    from sklearn.preprocessing import StandardScaler, OneHotEncoder
    
    from category_encoders.hashing import HashingEncoder
    
    from sklearn.linear_model import LogisticRegression
    
  2. 使用pd.read_csv()加载数据集。请注意,我们不需要先解压数据集,因为压缩包只包含一个 CSV 文件——pandas会为我们处理这一切:

    df = pd.read_csv('aug-train.zip')
    
    print('number of unique values for the feature city',
    
        df['city'].nunique())
    

正如我们所见,数据集中的city特征有123个可能的值:

number of unique values for the feature city 123
  1. 移除任何缺失的数据。我们在这里采取非常粗暴的策略:移除所有缺失数据较多的特征,然后移除所有包含剩余缺失数据的行。通常这不是推荐的方法,因为我们丢失了大量潜在有用的信息。由于处理缺失数据不在此讨论范围内,我们将采取这种简化的方法:

    df = df.drop(columns=['gender', 'major_discipline',
    
        'company_size', 'company_type'])
    
    df = df.dropna()
    
  2. 使用train_test_split函数将数据拆分为训练集和测试集:

    X_train, X_test, y_train, y_test = train_test_split(
    
        df.drop(columns=['target']), df['target'],
    
        stratify=df['target'], test_size=0.2,
    
        random_state=0
    
    )
    
  3. 选择并重新缩放任何定量特征。我们将使用标准化器来重新缩放所选的定量特征,但其他任何缩放器也可能适用:

    quanti_feats = ['city_development_index', 'training_hours']
    
    # Instantiate the scaler
    
    scaler = StandardScaler()
    
    # Select quantitative features
    
    X_train_quanti = X_train[quanti_feats]
    
    X_test_quanti = X_test[quanti_feats]
    
    # Rescale quantitative features
    
    X_train_quanti = scaler.fit_transform(X_train_quanti)
    
    X_test_quanti = scaler.transform(X_test_quanti)
    
  4. 选择并准备“常规”定性特征。在这里,我们将使用scikit-learn中的一热编码器,尽管我们也可以对这些特征应用哈希技巧:

    quali_feats = ['relevent_experience',
    
        'enrolled_university', 'education_level',
    
        'experience', 'last_new_job']
    
    quali_feats = ['last_new_job']
    
    # Instantiate the one hot encoder
    
    encoder = OneHotEncoder()
    
    # Select qualitative features to one hot encode
    
    X_train_quali = X_train[quali_feats]
    
    X_test_quali = X_test[quali_feats]
    
    # Encode those features
    
    X_train_quali = encoder.fit_transform(
    
        X_train_quali).toarray()
    
    X_test_quali = encoder.transform(
    
        X_test_quali).toarray()
    
  5. 使用哈希对高基数的'city'特征进行编码。由于当前该特征有123个可能的值,我们可以只使用 7 位来编码整个可能的值空间。这就是n_components=7参数所表示的内容。为了安全起见,我们可以将其设置为 8 位或更多,以考虑数据中可能出现的更多城市:

    high_cardinality_feature = ['city']
    
    # Instantiate the hashing encoder
    
    hasher = HashingEncoder(n_components=7)
    
    # Encode the city feature with hashing
    
    X_train_hash = hasher.fit_transform(
    
        X_train[high_cardinality_feature])
    
    X_test_hash = hasher.fit_transform(
    
        X_test[high_cardinality_feature])
    
    # Display the result on the training set
    
    X_train_hash.head()
    

输出结果类似于以下内容:

  col_0    col_1    col_2    col_3    col_4    col_5    col_6
18031     1       0         0       0       0         0              0
16295     0       0         0       1        0         0              0
7679      0       0         0        0       0         1              0
18154     0       0         1        0        0        0              0
10843     0       0         0        0        0        1              0

注意

如我们所见,所有值都被编码成七列,涵盖了 2⁷ = 128 个可能的值。

  1. 将所有准备好的数据连接起来:

    X_train = np.concatenate([X_train_quali,
    
        X_train_quanti, X_train_hash], 1)
    
    X_test = np.concatenate([X_test_quali,
    
        X_test_quanti, X_test_hash], 1)
    
  2. 实例化并训练逻辑回归模型。在这里,我们将使用scikit-learn为逻辑回归提供的默认超参数:

    lr = LogisticRegression()
    
    lr.fit(X_train, y_train)
    
  3. 使用.``score()方法打印训练集和测试集的准确率:

    print('Accuracy train set:', lr.score(X_train,
    
        y_train))
    
    print('Accuracy test set:', lr.score(X_test, y_test))
    

输出结果类似于以下内容:

Accuracy train set: 0.7812087988342239
Accuracy test set: 0.7826810990840966

如我们所见,我们在测试集上的准确率大约为 78%,且没有明显的过拟合。

注意

可能添加一些特征(例如,通过特征工程)有助于改善模型,因为目前模型似乎没有过拟合,且在自身上似乎没有太多提升空间。

另见

聚合特征

当你处理高基数特征时,一种可能的解决方案是减少该特征的实际基数。这里,聚合是一个可能的解决方案,并且在某些情况下可能非常有效。在本节中,我们将解释聚合是什么,并讨论何时应该使用它。完成这些后,我们将应用它。

准备工作

在处理高基数特征时,一热编码会导致高维数据集。由于所谓的维度灾难,即使有非常大的训练数据集,一热编码的高基数特征也可能会导致模型泛化能力不足。因此,聚合是一种降低一热编码维度的方法,从而降低过拟合的风险。

有几种方法可以进行聚合。例如,假设我们有一个包含“手机型号”特征的客户数据库,其中包含许多可能的手机型号(即数百种)。至少有两种方法可以对这种特征进行聚合:

  • 按出现概率:在数据中出现少于 X%的任何模型被视为“其他”

  • 按给定相似度:我们可以按生成、品牌甚至价格来聚合模型

这些方法有其优缺点:

  • 优点:按出现次数聚合简单、始终有效,并且不需要任何领域知识

  • 缺点:按给定相似度聚合可能更相关,但需要对特征有一定了解,而这些知识可能不可得,或者可能需要很长时间(例如,如果有数百万个值的话)

注意

当特征存在长尾分布时,聚合也有时很有用,这意味着一些值出现得很频繁,而许多其他值则仅偶尔出现。

在本配方中,我们将对一个包含许多城市作为特征但没有城市名称信息的数据集进行聚合。这将使我们只剩下按出现次数聚合的选择。我们将重复使用前一个配方中的相同数据集,因此我们将需要 Kaggle API。为此,请参考前一个配方。通过 Kaggle API,可以使用以下命令下载数据集:

kaggle datasets download -d reddynitin/aug-train

我们还需要pandasscikit-learn库,可以通过以下命令安装:

pip install pandas scikit-learn.

如何操作...

我们将使用与前一个配方相同的数据集。为了准备这个配方,我们将基于数据集中城市的出现次数聚合城市特征,然后在此数据上训练并评估模型:

  1. 导入所需的模块、函数和类:pandas用于加载数据,train_test_split用于划分数据,StandardScaler用于重新缩放定量特征,OneHotEncoder用于编码定性特征,LogisticRegression作为模型:

    Import numpy as np
    
    import pandas as pd
    
    from sklearn.model_selection import train_test_split
    
    from sklearn.preprocessing import OneHotEncoder, StandardScaler
    
    from sklearn.linear_model import LogisticRegression
    
  2. 使用pandas加载数据集。无需先解压文件——这一切都由pandas处理:

    df = pd.read_csv('aug-train.zip')
    
  3. 删除任何缺失数据。就像在前一个配方中一样,我们将采用一个简单的策略,删除所有有大量缺失数据的特征,然后删除包含缺失数据的行:

    df = df.drop(columns=['gender', 'major_discipline',
    
        'company_size', 'company_type'])
    
    df = df.dropna()
    
  4. 使用train_test_split函数将数据拆分为训练集和测试集:

    X_train, X_test, y_train, y_test = train_test_split(
    
        df.drop(columns=['target']), df['target'],
    
        stratify=df['target'], test_size=0.2,
    
        random_state=0
    
    )
    
  5. 使用scikit-learn提供的标准缩放器对任何定量特征进行重新缩放:

    quanti_feats = ['city_development_index',
    
        'training_hours']
    
    scaler = StandardScaler()
    
    X_train_quanti = X_train[quanti_feats]
    
    X_test_quanti = X_test[quanti_feats]
    
    X_train_quanti = scaler.fit_transform(X_train_quanti)
    
    X_test_quanti = scaler.transform(X_test_quanti)
    
  6. 现在,我们必须对city特征进行聚合:

    # Get only cities above threshold
    
    threshold = 0.1
    
    kept_cities = X_train['city'].value_counts(
    
        normalize=True)[X_train['city'].value_counts(
    
        normalize=True) > threshold].index
    
    # Update all cities below threshold as 'other'
    
    X_train.loc[~X_train['city'].isin(kept_cities),
    
        'city'] = 'other'
    
    X_test.loc[~X_test['city'].isin(kept_cities),
    
        'city'] = 'other'
    
  7. 使用独热编码准备定性特征,包括新聚合的city特征:

    # Get qualitative features
    
    quali_feats = ['city', 'relevent_experience',
    
        'enrolled_university', 'education_level',
    
        'experience', 'last_new_job']
    
    X_train_quali = X_train[quali_feats]
    
    X_test_quali = X_test[quali_feats]
    
    # Instantiate the one hot encoder
    
    encoder = OneHotEncoder()
    
    # Apply one hot encoding
    
    X_train_quali = encoder.fit_transform(
    
        X_train_quali).toarray()
    
    X_test_quali = encoder.transform(
    
        X_test_quali).toarray()
    
  8. 将定量特征和定性特征重新连接在一起:

    X_train = np.concatenate([X_train_quali,
    
        X_train_quanti], 1)
    
    X_test = np.concatenate([X_test_quali, X_test_quanti], 1)
    
  9. 实例化并训练逻辑回归模型。在这里,我们将保持模型的默认超参数:

    lr = LogisticRegression()
    
    lr.fit(X_train, y_train)
    
  10. 计算并打印模型在训练集和测试集上的准确率:

    print('Accuracy train set:', lr.score(X_train, y_train))
    
    print('Accuracy test set:', lr.score(X_test, y_test))
    

输出结果将如下所示:

Accuracy train set: 0.7805842759003538
Accuracy test set: 0.774909797391063

注意

对于这个特定情况,聚合似乎并没有显著帮助我们获得更强的结果,但至少它帮助模型变得更不容易预测,且对于新城市更具鲁棒性。

还有更多...

由于聚合代码可能看起来有些复杂,我们来看看我们做了什么。

因此,我们有city特征,它有许多可能的值;每个值在训练集中的频率都可以通过.value_counts(normalize=True)方法计算:

df['city'].value_counts(normalize=True)

这将产生以下输出:

city_103         0.232819
city_21           0.136227
city_16           0.081659
city_114         0.069613
city_160         0.045354
                                  ...
city_111         0.000167
city_129         0.000111
city_8              0.000111
city_140         0.000056
city_171         0.000056
Name: city, Length: 123, dtype: float64

看起来,在整个数据集中,city_103的值出现的频率超过 23%,而其他值,如city_111,出现的频率不到 1%。我们只需对这些值应用一个阈值,以便获取出现频率超过给定阈值的城市列表:

df['city'].value_counts(normalize=True) > 0.05

这将产生以下输出:

city_103           True
city_21              True
city_16              True
city_114           True
city_160         False
                             ...
city_111         False
city_129         False
city_8              False
city_140         False
city_171         False
Name: city, Length: 123, dtype: bool

现在,我们要做的就是获取所有真实值的索引(即城市名称)。这正是我们可以通过以下完整行来完成的:

kept_cities = df['city'].value_counts(normalize=True)[
    df['city'].value_counts(normalize=True) > 0.05].index
kept_cities

这将显示以下输出:

Index(['city_103', 'city_21', 'city_16', 'city_114'], dtype='object')

正如预期的那样,这将返回出现频率超过阈值的城市列表。

对不平衡数据集进行欠采样

机器学习中的典型情况是我们所称的“不平衡数据集”。不平衡数据集意味着对于某一类别,某些实例比其他实例更可能出现,从而导致数据的不平衡。不平衡数据集的案例有很多:医学中的罕见疾病、客户行为等。

在本方法中,我们将提出一种可能的处理不平衡数据集的方法:欠采样。在解释这个过程后,我们将其应用于一个信用卡欺诈检测数据集。

准备就绪

不平衡数据的问题在于,它可能会偏倚机器学习模型的结果。假设我们正在进行一个分类任务,检测数据集中仅占 1%的罕见疾病。此类数据的一个常见陷阱是模型总是预测为健康状态,因为这样它仍然可以达到 99%的准确率。因此,机器学习模型很可能会最小化其损失。

注意

在这种情况下,其他指标,如 F1 分数或ROC 曲线下面积ROC AUC),通常更为相关。

防止这种情况发生的一种方法是对数据集进行欠采样。更具体来说,我们可以通过删除部分样本来对过度代表的类别进行欠采样:

  • 我们保留所有欠代表类别的样本

  • 我们仅保留过度代表类别的子样本

通过这样做,我们可以人为地平衡数据集,避免不平衡数据集的陷阱。例如,假设我们有一个由以下属性组成的数据集:

  • 100 个带有疾病的样本

  • 9,900 个没有疾病的样本

完全平衡的欠采样将给我们以下数据集中的结果:

  • 100 个带有疾病的样本

  • 100 个随机选择的没有疾病的样本

当然,缺点是我们在这个过程中会丢失大量数据。

对于这个方法,我们首先需要下载数据集。为此,我们将使用 Kaggle API(请参考哈希高基数特征的方法了解如何安装)。可以使用以下命令行下载数据集:

kaggle datasets download -d mlg-ulb/creditcardfraud

还需要以下库:pandas 用于加载数据,scikit-learn 用于建模,matplotlib 用于显示数据,imbalanced-learn 用于欠采样。可以通过以下命令行安装:

pip install pandas scikit-learn matplotlib imbalanced-learn

如何操作...

在本篇教程中,我们将对信用卡欺诈数据集应用欠采样。这是一个相当极端的类别不平衡数据集的例子,因为其中仅约 0.18% 的样本是正类:

  1. 导入所需的模块、类和函数:

    • pandas 用于数据加载和处理

    • train_test_split 用于数据拆分

    • StandardScaler 用于数据重新缩放(数据集仅包含定量特征)

    • RandomUnderSampler 用于欠采样

    • LogisticRegression 用于建模

    • roc_auc_score 用于显示 ROC 和 ROC AUC 计算:

      import pandas as pd
      
      import matplotlib.pyplot as plt
      
      from sklearn.model_selection import train_test_split
      
      from sklearn.preprocessing import StandardScaler
      
      from imblearn.under_sampling import RandomUnderSampler
      
      from sklearn.linear_model import LogisticRegression
      
      from sklearn.metrics import roc_auc_score
      
  2. 使用 pandas 加载数据。我们可以直接加载 ZIP 文件。我们还将显示每个标签的相对数量:我们有大约 99.8% 的正常交易,而欺诈交易的比例不到 0.18%:

    df = pd.read_csv('creditcardfraud.zip')
    
    df['Class'].value_counts(normalize=True)
    

输出将如下所示:

0         0.998273
1         0.001727
Name: Class, dtype: float64
  1. 将数据拆分为训练集和测试集。在这种情况下,确保标签的分层非常关键:

    X_train, X_test, y_train, y_test = train_test_split(
    
        df.drop(columns=['Class']), df['Class'],
    
        test_size=0.2, random_state=0,
    
        stratify=df['Class'])
    
  2. 应用随机欠采样,使用最多 10% 的采样策略。这意味着我们必须对过度表示的类别进行欠采样,直到类别平衡达到 10 比 1 的比例。我们可以做到 1 比 1 的比例,但这会导致更多的数据丢失。此比例由 sampling_strategy=0.1 参数定义。我们还必须设置随机状态以确保可重复性:

    # Instantiate the object with a 10% strategy
    
    rus = RandomUnderSampler(sampling_strategy=0.1,
    
        random_state=0)
    
    # Undersample the train dataset
    
    X_train, y_train = rus.fit_resample(X_train, y_train)
    
    # Check the balance
    
    y_train.value_counts()
    

这将给我们以下输出:

0         3940
1           394
Name: Class, dtype: int64

在欠采样后,我们最终得到了 3940 个正常交易样本,与 394 个欺诈交易样本相比。

  1. 使用标准缩放器重新缩放数据:

    # Scale the data
    
    scaler = StandardScaler()
    
    X_train = scaler.fit_transform(X_train)
    
    X_test = scaler.transform(X_test)
    

注意

可以说,我们可以在重采样之前应用重新缩放。这将使过度表示的类别在重新缩放时更具权重,但不会被视为数据泄露。

  1. 在训练集上实例化并训练逻辑回归模型:

    lr = LogisticRegression()
    
    lr.fit(X_train, y_train)
    
  2. 计算训练集和测试集的 ROC AUC。为此,我们需要获取每个样本的预测概率,这可以通过 predict_proba() 方法获得,并且需要使用导入的 roc_auc_score() 函数:

    # Get the probas
    
    y_train_proba = lr.predict_proba(X_train)[:, 1]
    
    y_test_proba = lr.predict_proba(X_test)[:, 1]
    
    # Display the ROC AUC
    
    print('ROC AUC training set:', roc_auc_score(y_train,
    
        y_train_proba))
    
    print('ROC AUC test set:', roc_auc_score(y_test,
    
        y_test_proba))
    

这将返回以下结果:

ROC AUC training set: 0.9875041871730784
ROC AUC test set: 0.9731067071595099

我们在测试集上获得了大约 97% 的 ROC AUC,在训练集上接近 99%。

还有更多内容...

可选地,我们可以绘制训练集和测试集的 ROC 曲线。为此,我们可以使用 scikit-learn 中的 roc_curve() 函数:

import matplotlib.pyplot as plt
from sklearn.metrics import roc_curve
# Display the ROC curve
fpr_test, tpr_test, _ = roc_curve(y_test, y_test_proba)
fpr_train, tpr_train, _ = roc_curve(y_train, y_train_proba)
plt.plot(fpr_test, tpr_test, label='test')
plt.plot(fpr_train, tpr_train, label='train')
plt.xlabel('False positive rate')
plt.ylabel('True positive rate')
plt.legend()
plt.show()

图 5.2 – 训练集和测试集的 ROC 曲线。图形由代码生成

图 5.2 – 训练集和测试集的 ROC 曲线。图形由代码生成

如我们所见,尽管训练集和测试集的 ROC AUC 非常相似,但测试集的曲线略低。这意味着,正如预期的那样,模型稍微出现了过拟合。

请注意,微调 sampling_strategy 可能有助于获得更好的结果。

注意

为了同时优化采样策略和模型超参数,可以使用 scikit-learn 的Pipeline类。

另见

对不平衡数据集进行过采样

处理不平衡数据集的另一种解决方案是随机过采样。这是随机欠采样的对立面。在本配方中,我们将学习如何在信用卡欺诈检测数据集上使用它。

准备就绪

随机过采样可以看作是随机欠采样的对立面:其思想是复制欠代表类别的数据样本,以重新平衡数据集。

就像前面的配方一样,假设一个 1%-99%不平衡的数据集,其中包含以下内容:

  • 100 个有疾病样本

  • 9,900 个无疾病样本

为了使用 1/1 策略(即完全平衡的数据集)对这个数据集进行过采样,我们需要对每个疾病类别的样本进行 99 次复制。因此,过采样后的数据集将需要包含以下内容:

  • 9,900 个有疾病样本(100 个原始样本平均复制 99 次)

  • 9,900 个无疾病样本

我们可以轻松猜测这种方法的优缺点:

  • 优点:与欠采样不同,我们不会丢失过代表类别的任何数据,这意味着我们的模型可以在我们拥有的完整数据上进行训练

  • 缺点:我们可能会有很多欠代表类别的重复样本,这可能会导致对这些数据的过拟合

幸运的是,我们可以选择低于 1/1 的重平衡策略,从而限制数据重复。

对于这个配方,我们需要下载数据集。如果你已经完成了Undersampling an imbalanced dataset配方,那么你无需做其他操作。

否则,通过使用 Kaggle API(请参考Hashing high cardinality features配方了解如何安装),我们需要通过以下命令行下载数据集:

kaggle datasets download -d mlg-ulb/creditcardfraud

还需要以下库:pandas用于加载数据,scikit-learn用于建模,matplotlib用于显示数据,imbalanced-learn用于过采样部分。它们可以通过以下命令行安装:

pip install pandas scikit-learn matplotlib imbalanced-learn.

如何操作...

在本配方中,我们将对信用卡欺诈数据集应用过采样:

  1. 导入所需的模块、类和函数:

    • pandas用于数据加载和处理

    • train_test_split用于数据划分

    • StandardScaler用于数据缩放(数据集仅包含定量特征)

    • RandomOverSampler用于过采样

    • LogisticRegression用于建模

    • roc_auc_score用于显示 ROC 和 ROC AUC 计算:

      import pandas as pd
      
      import matplotlib.pyplot as plt
      
      from sklearn.model_selection import train_test_split
      
      from sklearn.preprocessing import StandardScaler
      
      from imblearn.over_sampling import RandomOverSampler
      
      from sklearn.linear_model import LogisticRegression
      
      from sklearn.metrics import roc_auc_score
      
  2. 使用 pandas 加载数据。我们可以直接加载 ZIP 文件。与前面的小节一样,我们将显示每个标签的相对数量,以提醒我们大约有 99.8%的正常交易和不到 0.18%的欺诈交易:

    df = pd.read_csv('creditcardfraud.zip')
    
    df['Class'].value_counts(normalize=True)
    

这将输出以下内容:

0         0.998273
1         0.001727
Name: Class, dtype: float64
  1. 将数据划分为训练集和测试集。我们必须指定标签的分层抽样,以确保平衡保持不变:

    X_train, X_test, y_train, y_test = train_test_split(
    
        df.drop(columns=['Class']), df['Class'],
    
        test_size=0.2, random_state=0,
    
        stratify=df['Class'])
    
  2. 使用 10%的采样策略进行随机过采样。这意味着我们将过采样不足表示的类别,直到类别平衡达到 10 比 1 的比例。这个比例由sampling_strategy=0.1参数定义。我们还必须设置随机状态以保证可重复性:

    # Instantiate the oversampler with a 10% strategy
    
    ros = RandomOverSampler(sampling_strategy=0.1,
    
        random_state=0)
    
    # Overersample the train dataset
    
    X_train, y_train = ros.fit_resample(X_train, y_train)
    
    # Check the balance
    
    y_train.value_counts()
    

这将输出以下内容:

0         227451
1           22745
Name: Class, dtype: int64

在过采样后,我们的训练集中现在有227451个正常交易(保持不变),与22745个欺诈交易。

注意

可以更改采样策略。像往常一样,这需要做出取舍:更大的采样策略意味着更多的重复样本来增加平衡,而较小的采样策略则意味着更少的重复样本,但平衡较差。

  1. 使用标准缩放器对数据进行缩放:

    # Scale the data
    
    scaler = StandardScaler()
    
    X_train = scaler.fit_transform(X_train)
    
    X_test = scaler.transform(X_test)
    
  2. 在训练集上实例化并训练逻辑回归模型:

    lr = LogisticRegression()
    
    lr.fit(X_train, y_train)
    
  3. 计算训练集和测试集上的 ROC AUC。为此,我们需要每个样本的预测概率,我们可以通过使用predict_proba()方法以及导入的roc_auc_score()函数来获得:

    # Get the probas
    
    y_train_proba = lr.predict_proba(X_train)[:, 1]
    
    y_test_proba = lr.predict_proba(X_test)[:, 1]
    
    # Display the ROC AUC
    
    print('ROC AUC training set:', roc_auc_score(y_train,
    
         y_train_proba))
    
    print('ROC AUC test set:', roc_auc_score(y_test,
    
        y_test_proba))
    

这将返回以下内容:

ROC AUC training set: 0.9884952360756659
ROC AUC test set: 0.9721115830969416

结果与我们通过欠采样获得的结果相当相似。然而,这并不意味着这两种技术总是相等的。

还有更多……

可选地,正如我们在欠采样不平衡数据集一节中所做的那样,我们可以使用scikit-learn中的roc_curve()函数绘制训练集和测试集的 ROC 曲线:

import matplotlib.pyplot as plt
from sklearn.metrics import roc_curve
# Display the ROC curve
fpr_test, tpr_test, _ = roc_curve(y_test, y_test_proba)
fpr_train, tpr_train, _ = roc_curve(y_train, y_train_proba)
plt.plot(fpr_test, tpr_test, label='test')
plt.plot(fpr_train, tpr_train, label='train')
plt.xlabel('False positive rate')
plt.ylabel('True positive rate')
plt.legend()
plt.show()

图 5.3 – 训练集和测试集的 ROC 曲线。代码生成的图

图 5.3 – 训练集和测试集的 ROC 曲线。代码生成的图

在这种情况下,测试集的 ROC AUC 曲线明显低于训练集的曲线,这意味着模型稍微出现了过拟合。

另见

RandomUnderSampler的文档可以在imbalanced-learn.org/stable/references/generated/imblearn.under_sampling.RandomUnderSampler.xhtml找到。

使用 SMOTE 重新采样不平衡数据

最后,处理不平衡数据集的一个更复杂的解决方案是名为 SMOTE 的方法。在解释 SMOTE 算法后,我们将应用这种方法来处理信用卡欺诈检测数据集。

准备工作

SMOTE代表Synthetic Minority Oversampling TEchnique。顾名思义,它为不平衡类创建合成样本。但它究竟如何创建合成数据?

该方法在不平衡类上使用 k-NN 算法。SMOTE 算法可以通过以下步骤总结:

  1. 随机选择少数类中的样本

  2. 使用 k-NN,在少数类中随机选择的 k 个最近邻之一。我们称此样本为

  3. 计算新的合成样本,,其中𝜆在[0, 1]范围内随机抽取:

图 5.4 – SMOTE 的视觉表示

图 5.4 – SMOTE 的视觉表示

与随机过采样相比,此方法更复杂,因为它有一个超参数:考虑的最近邻数k。此方法也有其利弊:

  • 优点:与随机过采样不同,它限制了在不平衡类上过拟合的风险,因为样本不会重复

  • 缺点:创建合成数据是一种冒险的赌注;没有任何保证它具有意义,并且可能会在真实数据上成为可能

要完成此配方,如果尚未这样做,请下载信用卡欺诈数据集(查看Undersampling an imbalanced datasetOversampling an imbalanced dataset配方以了解如何执行此操作)。

使用 Kaggle API(参考Hashing high cardinality features配方以了解如何安装它),我们必须通过以下命令行下载数据集:

kaggle datasets download -d mlg-ulb/creditcardfraud

需要以下库:pandas 用于加载数据,scikit-learn 用于建模,matplotlib 用于显示数据,imbalanced-learn 用于欠采样。它们可以通过以下命令行安装:

pip install pandas scikit-learn matplotlib imbalanced-learn.

如何做…

在本配方中,我们将对信用卡欺诈数据集应用 SMOTE:

  1. 导入所需的模块、类和函数:

    • pandas 用于数据加载和操作

    • train_test_split 用于数据拆分

    • StandardScaler 用于数据重新缩放(数据集仅包含定量特征)

    • SMOTE 用于 SMOTE 过采样

    • LogisticRegression 用于建模

    • roc_auc_score 用于显示 ROC 和 ROC AUC 计算:

      import pandas as pd
      
      import matplotlib.pyplot as plt
      
      from sklearn.model_selection import train_test_split
      
      from sklearn.preprocessing import StandardScaler
      
      from imblearn.over_sampling import SMOTE
      
      from sklearn.linear_model import LogisticRegression
      
      from sklearn.metrics import roc_auc_score
      
  2. 使用pandas加载数据。我们可以直接加载 ZIP 文件。与前两个配方一样,我们将显示每个标签的相对数量。再次,我们将有大约 99.8%的常规交易和少于 0.18%的欺诈交易:

    df = pd.read_csv('creditcardfraud.zip')
    
    df['Class'].value_counts(normalize=True)
    

输出将如下所示:

0         0.998273
1         0.001727
Name: Class, dtype: float64
  1. 将数据分割为训练集和测试集。我们必须在标签上指定分层,以确保平衡保持不变:

    X_train, X_test, y_train, y_test = train_test_split(
    
        df.drop(columns=['Class']), df['Class'],
    
        test_size=0.2, random_state=0,
    
        stratify=df['Class'])
    
  2. 使用sampling_strategy=0.1参数应用 SMOTE,以 10%的采样策略生成不平衡类的合成数据。通过这样做,我们将在类平衡中实现 10 比 1 的比例。我们还必须设置随机状态以实现可重现性:

    # Instantiate the SLOT with a 10% strategy
    
    smote = SMOTE(sampling_strategy=0.1, random_state=0)
    
    # Overersample the train dataset
    
    X_train, y_train = smote.fit_resample(X_train,
    
        y_train)
    
    # Check the balance
    
    y_train.value_counts()
    

通过此方法,我们将得到以下输出:

0         227451
1           22745
Name: Class, dtype: int64

在过采样之后,我们在训练集中现在有 227451 个常规交易(保持不变),与 22745 个欺诈交易,其中包括许多合成生成的样本。

  1. 使用标准化缩放器对数据进行重缩放:

    # Scale the data
    
    scaler = StandardScaler()
    
    X_train = scaler.fit_transform(X_train)
    
    X_test = scaler.transform(X_test)
    
  2. 在训练集上实例化并训练逻辑回归模型:

    lr = LogisticRegression()
    
    lr.fit(X_train, y_train)
    
  3. 计算训练集和测试集上的 ROC AUC。为此,我们需要每个样本的预测概率,这可以通过使用 predict_proba() 方法以及导入的 roc_auc_score() 函数来获得:

    # Get the probas
    
    y_train_proba = lr.predict_proba(X_train)[:, 1]
    
    y_test_proba = lr.predict_proba(X_test)[:, 1]
    
    # Display the ROC AUC
    
    print('ROC AUC training set:', roc_auc_score(y_train,
    
        y_train_proba))
    
    print('ROC AUC test set:', roc_auc_score(y_test,
    
        y_test_proba))
    

现在,输出应如下所示:

ROC AUC training set: 0.9968657635906649
ROC AUC test set: 0.9711737923925902

结果与我们在随机欠采样和过采样中得到的结果略有不同。尽管在测试集上的性能非常相似,但在这种情况下似乎有更多的过拟合。对此类结果有几种可能的解释,其中之一是合成样本对模型的帮助不大。

注意

我们在这个数据集上得到的重采样策略结果不一定能代表我们在其他数据集上得到的结果。此外,我们必须微调采样策略和模型,才能获得合适的性能对比。

还有更多...

可选地,我们可以使用 scikit-learn 中的 roc_curve() 函数绘制训练集和测试集的 ROC 曲线:

import matplotlib.pyplot as plt
from sklearn.metrics import roc_curve
# Display the ROC curve
fpr_test, tpr_test, _ = roc_curve(y_test, y_test_proba)
fpr_train, tpr_train, _ = roc_curve(y_train, y_train_proba)
plt.plot(fpr_test, tpr_test, label='test')
plt.plot(fpr_train, tpr_train, label='train')
plt.xlabel('False positive rate')
plt.ylabel('True positive rate')
plt.legend()
plt.show()

这是它的图示:

图 5.5 – 使用 SMOTE 后的训练集和测试集 ROC 曲线

图 5.5 – 使用 SMOTE 后的训练集和测试集 ROC 曲线

与随机欠采样和过采样相比,这里过拟合似乎更加明显。

另见

SMOTE 的官方文档可以在 imbalanced-learn.org/stable/references/generated/imblearn.over_sampling.SMOTE.xhtml 找到。

不建议将此实现应用于分类特征,因为它假设样本的特征值可以是其他样本特征值的任何线性组合。这对于分类特征来说并不成立。

也有针对分类特征的工作实现,以下是一些例子:

第六章:深度学习提示

深度学习 是基于神经网络的机器学习特定领域。深度学习被认为在处理非结构化数据(如文本、音频和图像)时特别强大,但对于时间序列和结构化数据也能发挥作用。在本章中,我们将回顾深度学习的基础知识,从感知机到神经网络的训练。我们将提供训练神经网络的三大主要用例的配方:回归、二分类和多类分类。

在本章中,我们将覆盖以下配方:

  • 训练感知机

  • 训练一个回归神经网络

  • 训练一个二分类神经网络

  • 训练一个多类分类神经网络

技术要求

在本章中,你将训练一个感知机以及多个神经网络。为此,需要以下库:

  • NumPy

  • pandas

  • scikit-learn

  • PyTorch

  • torchvision

训练感知机

感知机可以说是深度学习的基石。即便在生产系统中没有直接使用感知机,理解其原理对于构建深度学习的坚实基础是非常有帮助的。

在本配方中,我们将回顾感知机的基本概念,然后使用 scikit-learn 在鸢尾花数据集上训练一个感知机。

入门

感知机是一种最早提出用于模拟生物神经元的机器学习方法。它最早在 1940 年代提出,并在 1950 年代得到了实现。

从高层次来看,神经元可以被描述为一种接收输入信号并在输入信号的和超过某个阈值时发出信号的细胞。这正是感知机的工作原理;你只需做以下操作:

  • 用特征替换输入信号

  • 对这些特征应用加权和,并对其应用激活函数

  • 用预测值替代输出信号

更正式地说,假设有 n 个输入特征 n 个权重 ,则感知机的输出 ŷ 如下所示:

其中 是偏置,g 是激活函数;从历史上看,激活函数通常是步进函数,它对正输入值返回 1,其他情况返回 0。因此,最终,对于 n 个输入特征,感知机由 n+1 个参数组成:每个特征一个参数,再加上偏置。

提示

步进函数也被称为赫维赛德函数,并广泛应用于其他领域,如物理学。

感知机的前向计算总结在图 6.1中。如你所见,给定一组特征 和权重 ,前向计算只是加权和,再应用激活函数。

图 6.1 – 感知机的数学表示:从输入特征到输出,经过权重和激活函数

图 6.1 – 感知机的数学表示:从输入特征到输出,通过权重和激活函数

在实践中,安装此配方所需的唯一工具是 scikit-learn。可以通过 pip install scikit-learn 命令安装。

如何实现…

我们将再次使用 Iris 数据集,因为感知机在复杂分类任务中表现并不好:

  1. 从 scikit-learn 中导入所需的模块:

    • load_iris:一个加载数据集的函数

    • train_test_split:一个用于拆分数据的函数

    • StandardScaler:一个可以重新缩放数据的类

    • Perceptron:包含感知机实现的类:

      from sklearn.datasets import load_iris
      
      from sklearn.model_selection import train_test_split
      
      from sklearn.preprocessing import StandardScaler
      
      from sklearn.linear_model import Perceptron
      
  2. 加载 Iris 数据集:

    # Load the Iris dataset
    
    X, y = load_iris(return_X_y=True)
    
  3. 使用 train_test_split 函数将数据拆分为训练集和测试集,并将 random state 设置为 0 以确保可重复性:

    # Split the data
    
    X_train, X_test, y_train, y_test = train_test_split(
    
        X, y, random_state=0)
    
  4. 由于这里所有的特征都是定量的,我们只需使用标准缩放器对所有特征进行重新缩放:

    # Rescale the data
    
    scaler = StandardScaler()
    
    X_train = scaler.fit_transform(X_train)
    
    X_test = scaler.transform(X_test)
    
  5. 使用默认参数实例化模型,并通过 .fit() 方法在训练集上进行拟合:

    perc = Perceptron()perc.fit(X_train, y_train)
    
  6. 使用 LinearRegression 类的 .score() 方法在训练集和测试集上评估模型,并提供准确率得分:

    # Print the R2-score on train and test
    
    print('R2-score on train set:',
    
        perc.score(X_train, y_train))
    
    print('R2-score on test set:',
    
        perc.score(X_test, y_test))
    

这里是输出:

R2-score on train set: 0.9285714285714286
R2-score on test set: 0.8421052631578947
  1. 出于好奇,我们可以查看 .coef_ 中的权重和 .intercept_ 中的偏置。

    print('weights:', perc.coef_)
    
    print('bias:', perc.intercept_)
    

这里是输出:

weights: [[-0.49201984  2.77164495 -3.07208498 -2.51124259]
  [ 0.41482008 -1.94508614  3.2852582  -2.60994774]
  [-0.32320969  0.48524348  5.73376173  4.93525738]] bias: [-2\. -3\. -6.]

重要提示

共有三组四个权重和一个偏置,因为 scikit-learn 自动处理 One-vs-Rest 多类分类,所以我们为每个类别使用一个感知机。

还有更多…

感知机不仅仅是一个机器学习模型。它可以用来模拟逻辑门:OR、AND、NOR、NAND 和 XOR。让我们来看看。

我们可以通过以下代码轻松实现感知机的前向传播:

import numpy as np
class LogicalGatePerceptron:
    def __init__(self, weights: np.array, bias: float):
        self.weights = weights
        self.bias = bias
    def forward(self, X: np.array) -> int:
        return (np.dot(
            X, self.weights) + self.bias > 0).astype(int)

这段代码没有考虑许多边界情况,但在这里仅用于解释和演示简单概念。

AND 门具有以下真值表中定义的输入和期望输出:

输入 1 输入 2 输出
0 0 0
0 1 0
1 0 0
1 1 1

表 6.1 – AND 门真值表

让我们使用一个具有两个特征(输入 1 和输入 2)和四个样本的数组 X 来重现这个数据,并使用一个包含期望输出的数组 y

# Define X and y
X = np.array([[0, 0], [0, 1], [1, 0], [1, 1]])
y = [0, 0, 0, 1]

我们现在可以找到一组权重和偏置,使感知机能够作为 AND 门工作,并检查结果以验证它是否正常工作:

gate = LogicalGatePerceptron(np.array([1, 1]), -1)
y_pred = gate.forward(X)
print('Error:', (y - y_pred).sum())

这里是输出:

Error: 0

以相同的逻辑,感知机可以创建大多数基本的逻辑门:

  • AND 门:权重 [1, 1] 和偏置 -1

  • OR 门:权重 [1, 1] 和偏置 0

  • NOR 门:权重 [-1, -1] 和偏置 1

  • NAND 门:权重 [-1, -1] 和偏置 2

  • XOR 门:这需要两个感知机

提示

你可以通过试错法猜测权重和偏置,但你也可以使用逻辑门的真值表来做出合理的猜测,甚至可以通过解方程组来求解。

这意味着使用感知机可以计算任何逻辑函数。

另见

scikit-learn 实现的官方文档:scikit-learn.org/stable/modules/generated/sklearn.linear_model.Perceptron.xhtml

回归任务中训练神经网络

感知器并不是一个强大且常用的机器学习模型,但将多个感知器结合在神经网络中使用,可以成为一个强大的机器学习模型。在本教程中,我们将回顾一个简单的神经网络,有时称为多层感知器基础神经网络。然后,我们将使用广泛应用于深度学习的框架 PyTorch,在加利福尼亚住房数据集上进行回归任务的训练。

开始使用

让我们先回顾一下什么是神经网络,以及如何从输入特征开始进行神经网络的前向传播。

神经网络可以分为三部分:

  • 输入层,包含输入特征

  • 隐层,可以有任意数量的层和单元

  • 输出层,由神经网络的预期输出定义

在隐层和输出层中,我们将每个单元(或神经元)视为一个感知器,具有其自己的权重和偏差。

这三部分在图 6.2中得到了很好的表示。

图 6.2 – 神经网络的典型表示:左边是输入层,中间是隐层,右边是输出层

图 6.2 – 神经网络的典型表示:左边是输入层,中间是隐层,右边是输出层

我们将记录输入特征 ,第l层单位i的激活值,以及 ,第l层单位i的权重。

提示

如果神经网络中至少有一个隐层,我们认为它涉及深度学习。

训练回归任务中的神经网络与训练线性回归没有太大区别。它由相同的组成部分构成:

  • 前向传播,从输入特征和权重到预测结果

  • 一个需要最小化的损失函数

  • 更新权重的算法

让我们来看看这些组成部分。

前向传播

前向传播用于从输入特征中计算并输出结果。它必须从左到右计算,从输入层(输入特征)到输出层(输出预测)。每个单元都是感知器,第一隐层的计算相对简单:

其中 是偏差项, 是第 1 层的激活函数。

现在,如果我们想计算第二个隐藏层的激活值 ,我们将使用完全相同的公式,但输入将是第一隐藏层的激活值 (),而不是输入特征:

提示

你可以轻松地推广到任意数量的隐藏层和每层任意数量的单元——原理保持不变。

最后,输出层的计算方式完全相同,只不过在这种情况下我们只有一个输出神经元:

有一点值得强调:激活函数也是依赖于层的,这意味着每一层可以有不同的激活函数。对于输出层来说,这一点尤为重要,因为它需要根据任务和预期输出使用特定的输出函数。

对于回归任务,常用的激活函数是线性激活函数,这样神经网络的输出值可以是任意数字。

提示

激活函数在神经网络中起着决定性作用:它增加了非线性。如果我们对隐藏层仅使用线性激活函数,无论层数多少,这相当于没有隐藏层。

损失函数

在回归任务中,损失函数可以与线性回归中的相同:均方误差。在我们的例子中,如果我们认为预测 ŷ 是输出值 ,那么损失 L 只是如下:

假设 j 是样本索引。

更新权重

更新权重是通过尝试最小化损失函数来完成的。这与线性回归几乎相同。难点在于,与线性回归不同,我们有多个层的单元,其中每个单元都有权重和偏置,都需要更新。这就是所谓的反向传播,它允许逐层更新,从最右侧到最左侧(遵循 图 6.2 中的约定)。

反向传播的细节,尽管有用且有趣,但超出了本书的范围。

同样,正如在逻辑回归中有多个算法优化权重(在 scikit-learnLogisticRegression 中的 solver 参数),训练神经网络也有多种算法。这些算法通常被称为优化器。其中最常用的有随机梯度下降SGD)和自适应动量Adam)。

PyTorch

PyTorch 是一个广泛使用的深度学习框架,使我们能够轻松地训练和重用深度学习模型。

它非常容易使用,并且可以通过以下命令轻松安装:

pip install torch

对于这个食谱,我们还需要 scikit-learnmatplotlib,可以使用 pip install scikit-learn matplotlib 安装它们。

如何实现…

在这个示例中,我们将构建并训练一个神经网络来处理加利福尼亚住房数据集:

  1. 首先,我们需要导入所需的模块。在这些导入中,有一些来自我们在本书中已经使用过的 scikit-learn:

    • fetch_california_housing用于加载数据集。

    • train_test_split用于将数据分割为训练集和测试集。

    • StandardScaler用于重新缩放定量数据。

    • r2_score用于评估模型。

  2. 为了显示目的,我们还导入了 matplotlib:

    from sklearn.datasets import fetch_california_housing
    
    from sklearn.model_selection import train_test_split
    
    from sklearn.preprocessing import StandardScaler
    
    from sklearn.metrics import r2_score
    
    import matplotlib.pyplot as plt
    
  3. 我们还需要从 torch 导入一些模块:

    • torch本身提供了一些库中较低层级的函数。

    • torch.nn包含许多用于构建神经网络的有用类。

    • torch.nn.functional用于一些有用的函数。

    • DatasetDataLoader用于处理数据操作:

      import torch
      
      import torch.nn as nn
      
      import torch.nn.functional as F
      
      from torch.utils.data import Dataset, DataLoader
      
  4. 我们需要使用fetch_california_housing函数加载数据,并返回特征和标签:

    X, y = fetch_california_housing(return_X_y=True)
    
  5. 然后我们可以使用train_test_split函数将数据分割为训练集和测试集。我们设置测试集大小为 20%,并为可重复性指定随机种子:

    X_train, X_test, y_train, y_test = train_test_split(
    
        X.astype(np.float32), y.astype(np.float32),
    
           test_size=0.2, random_state=0)
    
  6. 现在我们可以使用标准缩放器对数据进行重新缩放:

    scaler = StandardScaler()
    
    X_train = scaler.fit_transform(X_train)
    
    X_test = scaler.transform(X_test)
    

重要提示

请注意,我们将Xy变量转换为 float32 类型的变量。这是为了防止后续在 PyTorch 中处理 float64 变量时出现问题。

  1. 对于 PyTorch,我们需要创建数据集类。这里并不复杂;这个类只需要以下内容才能正常工作:

    • 它必须继承自Dataset类(前面导入过的)。

    • 它必须有一个构造函数(__init__方法),处理(并可选地准备)数据。

    • 它必须有一个__len__方法,以便能够获取样本的数量。

    • 它必须有一个__getitem__方法,以便获取给定索引的Xy

让我们为加利福尼亚数据集实现这个,并将我们的类命名为CaliforniaDataset

class CaliforniaDataset(Dataset):
    def __init__(self, X: np.array, y: np.array):
        self.X = torch.from_numpy(X)
        self.y = torch.from_numpy(y)
    def __len__(self) -> int:
        return len(self.X)
    def __getitem__(self, idx: int) -> tuple[torch.Tensor]:
        return self.X[idx], self.y[idx]

如果我们分解这个类,我们会看到以下函数:

  • init构造函数简单地将Xy转换为 torch 张量,使用torch.from_numpy函数,并将结果存储为类的属性。

  • len方法只是返回X属性的长度;它同样适用于使用y属性的长度。

  • getitem方法简单地返回一个元组,其中包含给定索引idxXy张量。

这相当简单,然后会让pytorch知道数据是什么,数据集中有多少个样本,以及样本i是什么。为此,我们需要实例化一个DataLoader类。

小贴士

重新缩放也可以在这个CaliforniaDataset类中计算,以及任何预处理。

  1. 现在,我们实例化CaliforniaDataset对象用于训练集和测试集。然后,我们使用导入的DataLoader类实例化相关的加载器:

    # Instantiate datasets
    
    training_data = CaliforniaDataset(X_train, y_train)
    
    test_data = CaliforniaDataset(X_test, y_test)
    
    # Instantiate data loaders
    
    train_dataloader = DataLoader(training_data,
    
        batch_size=64, shuffle=True)
    
    test_dataloader = DataLoader(test_data, batch_size=64,
    
        shuffle=True)
    

数据加载器实例有几个可用的选项。在这里,我们指定以下内容:

  • batch_size:训练的批次大小。它可能对最终结果产生影响。

  • shuffle:确定是否在每个 epoch 时打乱数据。

  1. 我们最终可以创建神经网络模型类。对于这个类,我们只需要填充两个方法:

    • 构造函数,包含所有有用的内容,如参数和属性

    • forward方法计算前向传播:

      class Net(nn.Module):
      
          def __init__(self, input_shape: int,
      
              hidden_units: int = 24):
      
                  super(Net, self).__init__()
      
                  self.hidden_units = hidden_units
      
                  self.fc1 = nn.Linear(input_shape,
      
                      self.hidden_units)
      
                  self.fc2 = nn.Linear(self.hidden_units,
      
                      self.hidden_units)
      
                  self.output = nn.Linear(self.hidden_units,
      
                      1)
      
          def forward(self,
      
              x: torch.Tensor) -> torch.Tensor:
      
                  x = self.fc1(x)
      
                  x = F.relu(x)
      
                  x = self.fc2(x)
      
                  x = F.relu(x)
      
                  output = self.output(x)
      
                  return output
      

如果我们细分一下,我们设计了一个类,它接受两个输入参数:

  • input_shape是神经网络的输入形状——这基本上是数据集中特征的数量

  • hidden_units是隐藏层中单元的数量,默认为 24

神经网络本身包括以下内容:

  • 两个隐藏层,每个隐藏层有hidden_units个单元,激活函数为 ReLU

  • 一个输出层,只有一个单元,因为我们只需要预测一个值

重要提示

关于 ReLU 和其他激活函数的更多内容将在下一个这里有 更多小节中给出。

  1. 我们现在可以实例化一个神经网络,并在随机数据(形状符合预期)上进行测试,以检查forward方法是否正常工作:

    # Instantiate the network
    
    net = Net(X_train.shape[1])
    
    # Generate one random sample of 8 features
    
    random_data = torch.rand((1, X_train.shape[1]))
    
    # Compute the forward
    
    propagationprint(net(random_data))
    

我们将得到这个输出:

tensor([[-0.0003]], grad_fn=<AddmmBackward0>)

正如我们所看到的,前向传播在随机数据上的计算工作得很好,按预期返回了一个单一的值。这个步骤中的任何错误都意味着我们做错了什么。

  1. 在能够在数据上训练神经网络之前,我们需要定义损失函数和优化器。幸运的是,均方误差已经实现,并作为nn.MSELoss()提供。有许多优化器可以选择;我们在这里选择了 Adam,但也可以测试其他优化器:

    criterion = nn.MSELoss()
    
    optimizer = torch.optim.Adam(net.parameters(), lr=0.001)
    

重要提示

优化器需要将网络参数作为输入传递给其构造函数。

  1. 最后,我们可以使用以下代码训练神经网络 10 个 epoch:

    losses = []
    
    # Loop over the dataset multiple times
    
    for epoch in range(10):
    
        # Reset the loss for this epoch
    
        running_loss = 0.
    
        For I, data in enumerate(train_dataloader, 0):
    
            # Get the inputs per batch: data is a list of [inputs, labels]
    
            inputs, labels = data
    
            # Zero the parameter gradients
    
            optimizer.zero_grad()
    
            # Forward propagate + backward + optimize
    
            outputs = net(inputs)
    
            # Unsqueeze for dimension matching
    
            labels = labels.unsqueeze(1)
    
            # Compute the loss
    
            Loss = criterion(outputs, labels)
    
            # Backpropagate and update the weights
    
            loss.backward()
    
            optimizer.step()
    
            # Add this loss to the running loss
    
            running_loss += loss.item()
    
         # Compute the loss for this epoch and add it to the list
    
        epoch_loss = running_loss / len(
    
            train_dataloader)
    
        losses.append(epoch_loss)
    
        # Print the epoch and training loss
    
        print(f'[epoch {epoch + 1}] loss: {
    
            epoch_loss:.3f}')print('Finished Training')
    

希望注释是自解释的。基本上,有两个嵌套循环:

  • 一个外部循环遍历所有的 epoch:即模型在整个数据集上训练的次数

  • 一个内循环遍历样本:每个步骤中,使用batch_size样本的批次来训练模型

在内循环的每一步中,我们有以下主要步骤:

  • 获取一批数据:包括特征和标签

  • 对数据进行前向传播并获取输出预测

  • 计算损失:预测值与标签之间的均方误差

  • 使用反向传播更新网络的权重

在每个步骤结束时,我们打印损失值,希望它随着每个 epoch 的进行而减少。

  1. 我们可以将损失作为 epoch 的函数进行绘制。这非常直观,并且让我们能够确保网络在学习,如果损失在减少的话:

    plt.plot(losses)
    
    plt.xlabel('epoch')
    
    plt.ylabel('loss (MSE)')plt.show()
    

这是结果图:

图 6.3 – MSE 损失与 epoch 的关系

图 6.3 – MSE 损失与 epoch 的关系

重要提示

我们也可以在这一步追踪测试集上的损失,并显示出来,以获取更多信息。为了避免信息过载,我们将在下一个食谱中进行这个操作。

  1. 最后,我们可以在训练集和测试集上评估模型。正如本书前面在回归任务中所做的那样,我们将使用 R2-score。其他相关指标也可以使用:

    # Compute the predictions with the trained neural
    
    Network
    
    y_train_pred = net(torch.tensor((
    
        X_train))).detach().numpy()
    
    y_test_pred = net(torch.tensor((
    
        X_test))).detach().numpy()
    
    # Compute the R2-score
    
    print('R2-score on training set:',
    
        r2_score(y_train, y_train_pred))
    
    print('R2-score on test set:',
    
        r2_score(y_test, y_test_pred))
    

这是输出结果:

R2-score on training set: 0.7542622050620708 R2-score on test set: 0.7401526252651656

正如我们在这里看到的,我们在训练集上得到了合理的 R2-score 值 0.74,存在轻微的过拟合。

还有更多内容…

在本节中,我们提到了激活函数,但并没有真正解释它们是什么或为什么需要它们。

简而言之,激活函数添加了非线性因素,使模型能够学习更复杂的模式。事实上,如果我们有一个没有激活函数的神经网络,无论层数或单元数多少,整个模型都等同于一个线性模型(例如线性回归)。这一点在 图 6.4 中得到了总结。

图 6.4 – 左侧的神经网络没有激活函数,只能学习线性可分的决策函数;右侧的神经网络有激活函数,可以学习复杂的决策函数

图 6.4 – 左侧的神经网络没有激活函数,只能学习线性可分的决策函数;右侧的神经网络有激活函数,可以学习复杂的决策函数

有许多可用的激活函数,但最常见的隐藏层激活函数包括 sigmoid、ReLU 和 tanh。

Sigmoid

Sigmoid 函数与逻辑回归中使用的函数相同:

这个函数的值范围从 0 到 1,当 x = 0 时输出 0.5。

tanh

tanh 或双曲正切函数的值范围从 -1 到 1,当 x 为 0 时值为 0:

ReLU

ReLU修正线性单元 函数对于任何负输入值返回 0,对于任何正输入值 x 返回 x。与 sigmoid 和 tanh 不同,它不会出现平台效应,因此能够避免梯度消失问题。其公式如下:

可视化

我们可以使用以下代码将这三个激活函数(sigmoid、tanh 和 ReLU)一起可视化,从而更直观地理解它们:

import numpy as np
import matplotlib.pyplot as plt
x = np.arange(-2, 2, 0.02)
sigmoid = 1./(1+np.exp(-x))
tanh = (np.exp(x)-np.exp(-x))/(np.exp(x)+np.exp(-x))
relu = np.max([np.zeros(len(x)), x], axis=0)
plt.plot(x, sigmoid)
plt.plot(x, tanh)
plt.plot(x, relu)plt.grid()
plt.xlabel('x')
plt.ylabel('activation')
plt.legend(['sigmoid', 'tanh', 'relu'])
plt.show()

运行之前的代码时,你会得到这个输出,计算的是这些函数在 [-2, 2] 范围内的输出值:

图 6.5 – Sigmoid、tanh 和 ReLU 激活函数在 [-2, 2] 输入范围内的结果图

图 6.5 – Sigmoid、tanh 和 ReLU 激活函数在 [-2, 2] 输入范围内的结果图

想了解更多关于 PyTorch 中可用的激活函数,请查看以下链接:pytorch.org/docs/stable/nn.xhtml#non-linear-activations-weighted-sum-nonlinearity.

另见

这里有几个 PyTorch 教程的链接,它们对于熟悉 PyTorch 和深入理解其工作原理非常有帮助:

以下链接是一个关于深度学习的优秀网站,适合那些希望更好理解神经网络、梯度下降和反向传播的人:neuralnetworksanddeeplearning.com/.

训练一个用于二分类的神经网络

在这个食谱中,我们将训练第一个用于乳腺癌数据集的二分类任务神经网络。我们还将了解学习率和优化器对优化过程的影响,以及如何通过测试集评估模型。

准备工作

正如我们在这个食谱中所看到的,训练一个用于二分类的神经网络与训练一个回归神经网络并没有太大不同。主要有两个变化需要进行:

  • 输出层的激活函数

  • 损失函数

在之前的回归任务食谱中,输出层没有激活函数。实际上,对于回归任务,可以期望预测值取任意值。

对于二分类任务,我们期望输出是一个概率值,也就是介于 0 和 1 之间的值,就像逻辑回归一样。这就是为什么在做二分类时,输出层的激活函数通常是 sigmoid 函数。最终的预测结果将与逻辑回归的预测结果类似:一个数值,我们可以应用一个阈值(例如 0.5),超过该阈值时我们认为预测为类别 1。

由于标签是 0 和 1,而预测值是介于 0 和 1 之间的值,因此均方误差不再适用于训练此类模型。因此,就像逻辑回归一样,我们将使用二元交叉熵损失函数。

这个食谱所需的库是 matplotlib、scikit-learn 和 PyTorch,可以通过 pip install matplotlib scikit-learn torch 来安装。

如何做到这一点…

我们将训练一个具有两个隐藏层的简单神经网络,用于乳腺癌数据集上的二分类任务。尽管这个数据集不太适合深度学习,因为它是一个小型数据集,但它使我们能够轻松理解训练二分类神经网络的所有步骤:

  1. 我们从 scikit-learn 导入了以下所需的库:

    • load_breast_cancer 用于加载数据集

    • train_test_split 用于将数据拆分为训练集和测试集

    • 用于重新缩放定量数据的 StandardScaler

    • 用于评估模型的 accuracy_score

我们还需要 matplotlib 来进行显示,并且需要从 torch 中引入以下内容:

  • torch 本身

  • 包含构建神经网络所需类的 torch.nn

  • 用于激活函数(如 ReLU)的 torch.nn.functional

  • 处理数据的 DatasetDataLoader

    from sklearn.datasets import load_breast_cancer
    
    from sklearn.model_selection import train_test_split
    
    from sklearn.preprocessing import StandardScaler
    
    from sklearn.metrics import accuracy_score
    
    import matplotlib.pyplot as plt
    
    import torchimport torch.nn as nn
    
    import torch.nn.functional as F
    
    from torch.utils.data import Dataset, DataLoader
    
  1. 使用 load_breast_cancer 函数加载特征和标签:

    X, y = load_breast_cancer(return_X_y=True)
    
  2. 将数据拆分为训练集和测试集,指定随机状态以确保结果可复现。同时将特征和标签转换为 float32,以便与 PyTorch 后续操作兼容:

    X_train, X_test, y_train, y_test = train_test_split(
    
        X.astype(np.float32), y.astype(np.float32),
    
        test_size=0.2, random_state=0)
    
  3. 创建处理数据的 Dataset 类。请注意,在这个食谱中,我们将数据重新缩放集成到此步骤中,不像在之前的食谱中那样:

    class BreastCancerDataset(Dataset):
    
        def __init__(self, X: np.array, y: np.array,
    
            x_scaler: StandardScaler = None):
    
                if x_scaler is None:
    
                    self.x_scaler = StandardScaler()
    
                    X = self.x_scaler.fit_transform(X)
    
                else:
    
                    self.x_scaler = x_scaler
    
                    X = self.x_scaler.transform(X)
    
                self.X = torch.from_numpy(X)
    
                self.y = torch.from_numpy(y)
    
        def __len__(self) -> int:
    
            return len(self.X)
    
        def __getitem__(self, idx: int) -> tuple[torch.Tensor]:
    
            return self.X[idx], self.y[idx]
    

重要提示

将缩放器包含在类中有利有弊,其中一个优点是可以正确处理训练集和测试集之间的数据泄露。

  1. 实例化训练集和测试集及其加载器。请注意,训练数据集没有提供缩放器,而测试数据集则提供了训练集的缩放器,以确保所有数据以相同方式处理,避免数据泄露:

    training_data = BreastCancerDataset(X_train, y_train)
    
    test_data = BreastCancerDataset(X_test, y_test,
    
        training_data.x_scaler)
    
    train_dataloader = DataLoader(training_data,
    
        batch_size=64, shuffle=True)
    
    test_dataloader = DataLoader(test_data, batch_size=64,
    
        shuffle=True)
    
  2. 构建神经网络。在这里,我们构建一个具有两个隐藏层的神经网络。在 forward 方法中,torch.sigmoid() 函数被应用于输出层,确保返回的值在 0 和 1 之间。实例化模型所需的唯一参数是输入形状,这里仅为特征的数量:

    class Net(nn.Module):
    
        def __init__(self, input_shape: int,
    
            hidden_units: int = 24):
    
                super(Net, self).__init__()
    
                    self.hidden_units = hidden_units
    
                    self.fc1 = nn.Linear(input_shape,
    
                        self.hidden_units)
    
                    self.fc2 = nn.Linear(
    
                        self.hidden_units,
    
                        self.hidden_units)
    
                    self.output = nn.Linear(
    
                        self.hidden_units, 1)
    
        def forward(self, x: torch.Tensor) -> torch.Tensor:
    
            x = self.fc1(x)
    
            x = F.relu(x)
    
            x = self.fc2(x)
    
            x = F.relu(x)
    
            output = torch.sigmoid(self.output(x))
    
            return output
    
  3. 现在我们可以使用正确的输入形状实例化模型,并检查给定随机张量上的前向传播是否正常工作:

    # Instantiate the network
    
    net = Net(X_train.shape[1])
    
    # Generate one random sample
    
    random_data = torch.rand((1, X_train.shape[1]))
    
    # Compute the forward propagation
    
    print(net(random_data))
    

运行上述代码后,我们得到以下输出:

tensor([[0.4487]], grad_fn=<SigmoidBackward0>)
  1. 定义损失函数和优化器。如前所述,我们将使用二元交叉熵损失,PyTorch 中提供的 nn.BCELoss() 实现此功能。选择的优化器是 Adam,但也可以测试其他优化器:

    criterion = nn.BCELoss()
    
    optimizer = torch.optim.Adam(net.parameters(),
    
        lr=0.001)
    

重要提示

有关优化器的更多解释将在下一节 There’s more 中提供。

  1. 现在我们可以训练神经网络 50 个 epoch。我们还会在每个 epoch 计算训练集和测试集的损失,以便之后绘制它们。为此,我们需要切换模型的模式:

    • 在训练训练集之前,使用 model.train() 切换到训练模式

    • 在评估测试集之前,使用 model.eval() 切换到 eval 模式

      train_losses = []
      
      test_losses = []
      
      # Loop over the dataset 50 times
      
      for epoch in range(50):
      
          ## Train the model on the training set
      
          running_train_loss = 0.
      
          # Switch to train mode
      
          net.train()
      
          # Loop over the batches in train set
      
          for i, data in enumerate(train_dataloader, 0):
      
              # Get the inputs: data is a list of [inputs, labels]
      
              inputs, labels = data
      
              # Zero the parameter gradients
      
              optimizer.zero_grad()
      
              # Forward + backward + optimize
      
              outputs = net(inputs)
      
              loss = criterion(outputs, labels.unsqueeze(1))
      
              loss.backward()
      
              optimizer.step()
      
              # Add current loss to running loss
      
              running_train_loss += loss.item()
      
          # Once epoch is over, compute and store the epoch loss
      
          train_epoch_loss = running_train_loss / len(
      
              train_dataloader)
      
          train_losses.append(train_epoch_loss)
      
          ## Evaluate the model on the test set
      
          running_test_loss = 0.
      
          # Switch to eval model
      
          net.eval()
      
          with torch.no_grad():
      
              # Loop over the batches in test set
      
              for i, data in enumerate(test_dataloader, 0):
      
                  # Get the inputs
      
                  inputs, labels = data
      
                  # Compute forward propagation
      
                  outputs = net(inputs)
      
                  # Compute loss
      
                  loss = criterion(outputs,
      
                      labels.unsqueeze(1))
      
                  # Add to running loss
      
                  running_test_loss += loss.item()
      
                  # Compute and store the epoch loss
      
                  test_epoch_loss = running_test_loss / len(
      
                      test_dataloader)
      
                  test_losses.append(test_epoch_loss)
      
          # Print stats
      
          print(f'[epoch {epoch + 1}] Training loss: {
      
              train_epoch_loss:.3f} | Test loss: {
      
                  test_epoch_loss:.3f}')
      
          print('Finished Training')
      

重要提示

请注意在评估部分使用 with torch.no_grad()。这行代码允许我们禁用自动求导引擎,从而加速处理。

  1. 现在,我们将训练集和测试集的损失作为 epoch 的函数绘制,使用上一步骤中计算的两个列表:

    plt.plot(train_losses, label='train')
    
    plt.plot(test_losses, label='test')
    
    plt.xlabel('epoch')plt.ylabel('loss (BCE)')
    
    plt.legend()
    
    plt.show()
    

这是输出结果:

图 6.6 – 训练集和测试集的 MSE 损失

图 6.6 – 训练集和测试集的 MSE 损失

正如我们所看到的,两个损失都在减少。一开始,训练损失和测试损失几乎相等,但在 10 个 epoch 后,训练损失继续减少,而测试损失没有变化,意味着模型在训练集上发生了过拟合。

  1. 可以使用训练集和测试集的准确度分数来评估模型,通过 scikit-learn 的accuracy_score函数。计算预测结果时需要更多步骤,因为我们必须执行以下操作才能获得实际的类别预测:

    • 使用用于训练的标准化器对数据进行重新缩放,该标准化器可在training_data.x_scaler属性中找到

    • 使用torch.tensor()将 NumPy 数据转换为 torch 张量

    • 将前向传播应用到模型上

    • 使用.detach().numpy()将输出的 torch 张量转换回 NumPy

    • 使用> 0.5应用阈值,将概率预测(介于 0 和 1 之间)转换为类别预测

      # Compute the predictions with the trained neural network
      
      y_train_pred = net(torch.tensor((
      
          training_data.x_scaler.transform(
      
              X_train)))).detach().numpy() > 0.5
      
      y_test_pred = net(torch.tensor((
      
          training_data.x_scaler.transform(
      
              X_test)))).detach().numpy() > 0.5
      
      # Compute the accuracy score
      
      print('Accuracy on training set:', accuracy_score(
      
          y_train, y_train_pred))
      
      print('Accuracy on test set:', accuracy_score(y_test,
      
          y_test_pred))
      

这是前面代码的输出:

Accuracy on training set: 0.9912087912087912 Accuracy on test set: 0.9649122807017544

我们在训练集上获得了 99%的准确率,在测试集上获得了 96%的准确率,这证明了模型确实存在过拟合现象,正如从训练和测试损失随 epoch 变化的曲线所预期的那样。

还有更多…

正如我们在这里看到的,损失随着时间的推移而减少,意味着模型实际上在学习。

重要提示

即使有时损失出现一些波动,只要总体趋势保持良好,也不必担心。

有两个重要的概念可能会影响这些结果,它们在某种程度上是相互关联的:学习率和优化器。与逻辑回归或线性回归类似,优化器的目标是找到提供最低可能损失值的参数。因此,这是一个最小化问题,可以如图 6.7所示表示:我们寻求找到一组参数,能给出最低的损失值。

图 6.7 – 损失 L 作为参数 w 的函数的表示。红色交叉点是最优点,而蓝色交叉点是一个随机的任意权重集合

图 6.7 – 损失 L 作为参数 w 的函数的表示。红色交叉点是最优点,而蓝色交叉点是一个随机的任意权重集合

让我们看看学习率如何影响学习曲线。

学习率

在 PyTorch 中,通过实例化优化器时设置学习率,例如使用lr=0.001参数。可以说,学习率的值主要有四种情况,正如在图 6.8中所展示的,从较低的学习率到非常高的学习率。

图 6.8 – 学习率的四个主要类别:学习率过低、合适的学习率、学习率过高、学习率非常高(损失发散)

图 6.8 – 学习率的四个主要类别:学习率过低、合适的学习率、学习率过高、学习率非常高(损失发散)

就损失而言,学习率的变化可以从图 6**.9中直观感受到,图中展示了多个 epoch 中权重和损失的变化过程。

图 6.9 – 学习率四种情况的可视化解释:a) 低学习率,b) 合适的学习率,c) 高学习率,d) 非常高的学习率

图 6.9 – 学习率四种情况的可视化解释:a) 低学习率,b) 合适的学习率,c) 高学习率,d) 非常高的学习率

图 6**.9 可以通过以下内容进一步解释:

  • 低学习率 (a):损失会随着 epoch 的进行而逐渐减少,但速度太慢,可能需要非常长的时间才能收敛,也可能使模型陷入局部最小值。

  • 合适的学习率 (b):损失将稳定下降,直到接近全局最小值。

  • 略大的学习率 (c):损失一开始会急剧下降,但可能很快跳过全局最小值,无法再到达它。

  • 非常高的学习率 (d):损失将迅速发散,学习步伐过大。

调整学习率有时有助于产生最佳结果。几种技术,如所谓的学习率衰减,会随着时间推移逐渐降低学习率,以期更加准确地捕捉到全局最小值。

优化器

除了可能最著名的随机梯度下降和 Adam 优化器,深度学习中还有许多强大且有用的优化器。在不深入讨论这些优化器的细节的前提下,让我们简要了解它们的工作原理和它们之间的差异,如图 6**.10所总结:

  • 随机梯度下降 只是根据每个批次的损失计算梯度,没有进一步的复杂处理。这意味着,有时一个批次的优化方向可能几乎与另一个批次相反。

  • Adam 使用动量,这意味着对于每个批次,除了使用该批次的梯度外,还会使用之前计算过的梯度的动量。这使得 Adam 能够保持一个更加一致的方向,并且有望更快地收敛。

图 6.10 – 向全局最小值训练的可视化表现,左边是随机梯度下降,右边是 Adam 保持之前步骤的动量

图 6.10 – 向全局最小值训练的可视化表现,左边是随机梯度下降,右边是 Adam 保持之前步骤的动量

优化过程可以用一个简单的比喻来概括。这就像在雾霾中爬山,试图向下走(到达全局最小值)。你可以通过随机梯度下降或 Adam 来进行下降:

  • 使用随机梯度下降时,你观察周围的情况,选择向下坡度最陡的方向,并沿该方向迈出一步。然后再重复一次。

  • 使用 Adam,你做的和随机梯度下降一样,但速度更快。你迅速环顾四周,看到朝下坡度最陡的方向,然后试图朝那个方向迈出一步,同时保持之前步骤的惯性,因为你是在跑步。然后再重复这一过程。

请注意,在这个类比中,步长就是学习率。

另见

PyTorch 上可用的优化器列表:pytorch.org/docs/stable/optim.xhtml#algorithms

训练一个多类别分类神经网络

在这个食谱中,我们将关注另一个非常常见的任务:使用神经网络进行多类别分类,在本例中使用 PyTorch。我们将处理一个深度学习中的经典数据集:MNIST 手写数字识别。该数据集包含 28x28 像素的小灰度图像,描绘的是 0 到 9 之间的手写数字,因此有 10 个类别。

准备工作

在经典机器学习中,多类别分类通常不会原生处理。例如,在使用 scikit-learn 训练一个三分类任务(例如,Iris 数据集)时,scikit-learn 将自动训练三个模型,采用一对其余法(one-versus-the-rest)。

在深度学习中,模型可以原生处理超过两个类别。为了实现这一点,与二分类相比,只需进行少量的更改:

  • 输出层的单元数与类别数相同:这样,每个单元将负责预测一个类别的概率

  • 输出层的激活函数是 softmax 函数,该函数使得所有单元的和等于 1,从而允许我们将其视为概率

  • 损失函数是交叉熵损失,考虑多个类别,而不是二元交叉熵

在我们的案例中,我们还需要对代码进行一些其他特定于数据本身的更改。由于输入现在是图像,因此需要进行一些转换:

  • 图像是一个二维(或三维,如果是 RGB 彩色图像)数组,必须将其展平为一维

  • 数据必须标准化,就像定量数据的重缩放一样

为了做到这一点,我们将需要以下库:torch、torchvision(用于数据集加载和图像转换)和 matplotlib(用于可视化)。可以通过pip install torch torchvision matplotlib来安装。

如何做……

在这个食谱中,我们将重用本章之前相同的模式:训练一个有两个隐藏层的神经网络。但有一些事情会有所不同:

  • 输入数据是来自 MNIST 手写数字数据集的灰度图像,因此它是一个需要展平的二维数组

  • 输出层将不只有一个单元,而是为数据集的十个类别准备十个单元;损失函数也会相应改变

  • 我们不仅会在训练循环中计算训练和测试损失,还会计算准确率

现在让我们看看如何在实践中做到这一点:

  1. 导入所需的库。和以前的食谱一样,我们导入了几个有用的 torch 模块和函数:

    • torch本身

    • torch.nn包含构建神经网络所需的类

    • torch.nn.functional用于激活函数,例如 ReLU

    • DataLoader用于处理数据

我们还需要从torchvision导入一些内容:

  • MNIST用于加载数据集

  • transforms用于转换数据集,包括重新缩放和展平数据:

    import torch
    
    import torch.nn as nn
    
    import torch.nn.functional as F
    
    from torch.utils.data import DataLoader
    
    from torchvision.datasets import MNIST
    
    import torchvision.transforms as transforms
    
    import matplotlib.pyplot as plt
    
  1. 实例化转换。我们使用Compose类,可以将两个或更多的转换组合起来。这里,我们组合了三个转换:

    • transforms.ToTensor():将输入图像转换为torch.Tensor格式。

    • transforms.Normalize():使用均值和标准差对图像进行归一化。它将减去均值(即 0.1307),然后除以标准差(即 0.3081),对每个像素值进行处理。

    • transforms.Lambda(torch.flatten):将 2D 张量展平为 1D 张量:

      transform = transforms.Compose([transforms.ToTensor(),
      
          transforms.Normalize((0.1307), (0.3081)),
      
          transforms.Lambda(torch.flatten)])
      

重要提示

图像通常会用均值和标准差为 0.5 进行归一化。我们使用前面代码块中使用的特定值进行归一化,因为数据集是基于特定图像创建的,但 0.5 也能正常工作。有关解释,请查看本食谱中的另见小节。

  1. 加载训练集和测试集,以及训练数据加载器和测试数据加载器。通过使用MNIST类,我们分别使用train参数为TrueFalse来获取训练集和测试集。在加载数据时,我们也直接应用之前定义的转换。然后,我们以批大小 64 实例化数据加载器:

    trainset = MNIST('./data', train=True, download=True,
    
        transform=transform)
    
    train_dataloader = DataLoader(trainset, batch_size=64,
    
        shuffle=True)
    
    testset = MNIST('./data', train=False, download=True,
    
        transform=transform)
    
    test_dataloader = DataLoader(testset, batch_size=64,
    
        shuffle=True)
    
  2. 定义神经网络。这里我们默认定义了一个具有 2 个隐藏层、每层 24 个单元的神经网络。输出层有 10 个单元,表示数据的 10 个类别(我们的是 0 到 9 之间的数字)。请注意,Softmax 函数应用于输出层,使得 10 个单元的和严格等于 1:

    class Net(nn.Module):
    
        def __init__(self, input_shape: int,
    
            hidden_units: int = 24):
    
                super(Net, self).__init__()
    
                self.hidden_units = hidden_units
    
                self.fc1 = nn.Linear(input_shape,
    
                    self.hidden_units)
    
                self.fc2 = nn.Linear(
    
                    self.hidden_units,
    
                    self.hidden_units)
    
                self.output = nn.Linear(
    
                    self.hidden_units, 10)
    
        def forward(self,
    
            x: torch.Tensor) -> torch.Tensor:
    
                x = self.fc1(x)
    
                x = F.relu(x)
    
                x = self.fc2(x)
    
                x = F.relu(x)
    
                output = torch.softmax(self.output(x),
    
                    dim=1)
    
                return output
    
  3. 现在,我们可以用正确的输入形状 784(28x28 像素)来实例化模型,并检查前向传播在给定的随机张量上是否正常工作:

    # Instantiate the model
    
    net = Net(784)
    
    # Generate randomly one random 28x28 image as a 784 values tensor
    
    random_data = torch.rand((1, 784))
    
    result = net(random_data)
    
    print('Resulting output tensor:', result)
    
    print('Sum of the output tensor:', result.sum())
    

这是输出结果:

Resulting output tensor: tensor([[0.0918, 0.0960, 0.0924, 0.0945, 0.0931, 0.0745, 0.1081, 0.1166, 0.1238,              0.1092]], grad_fn=<SoftmaxBackward0>) Sum of the output tensor: tensor(1.0000, grad_fn=<SumBackward0>)

提示

请注意,输出是一个包含 10 个值的张量,其和为 1。

  1. 定义损失函数为交叉熵损失,在 PyTorch 中可以通过nn.CrossEntropyLoss()获得,并将优化器定义为 Adam:

    criterion = nn.CrossEntropyLoss()
    
    optimizer = torch.optim.Adam(net.parameters(),
    
        lr=0.001)
    
  2. 在训练之前,我们实现了一个epoch_step辅助函数,适用于训练集和测试集,允许我们遍历所有数据,计算损失和准确度,并在训练集上训练模型:

    def epoch_step(net, dataloader, training_set: bool):
    
        running_loss = 0.
    
        Correct = 0.
    
        For i, data in enumerate(dataloader, 0):
    
            # Get the inputs: data is a list of [inputs, labels]
    
            inputs, labels = data
    
            if training_set:
    
                # Zero the parameter gradients
    
                optimizer.zero_grad()
    
                # Forward + backward + optimize
    
                outputs = net(inputs)
    
                loss = criterion(outputs, labels)
    
                if training_set:
    
                    loss.backward()
    
                    optimizer.step()
    
                # Add correct predictions for this batch
    
                correct += (outputs.argmax(
    
                    dim=1) == labels).float().sum()
    
                # Compute loss for this batch
    
                running_loss += loss.item()
    
        return running_loss, correct
    
  3. 现在,我们可以在 20 个 epoch 上训练神经网络。在每个 epoch 中,我们还会计算以下内容:

    • 训练集和测试集的损失

    • 训练集和测试集的准确率

和前一个食谱一样,在训练之前,模型会通过model.train()切换到训练模式,而在评估测试集之前,它会通过model.eval()切换到评估模式:

# Create empty lists to store the losses and accuracies
train_losses = []
test_losses = []
train_accuracy = []
test_accuracy = []
# Loop over the dataset 20 times for 20 epochs
for epoch in range(20):
    ## Train the model on the training set
    net.train()
    running_train_loss, correct = epoch_step(net,
        dataloader=train_dataloader,training_set=True)
    # Compute and store loss and accuracy for this epoch
    train_epoch_loss = running_train_loss / len(
        train_dataloader)
    train_losses.append(train_epoch_loss)
    train_epoch_accuracy = correct / len(trainset)
     rain_accuracy.append(train_epoch_accuracy)
    ## Evaluate the model on the test set
    net.eval()
    with torch.no_grad():
        running_test_loss, correct = epoch_step(net,
            dataloader=test_dataloader,training_set=False)
        test_epoch_loss = running_test_loss / len(
            test_dataloader)
        test_losses.append(test_epoch_loss)
        test_epoch_accuracy = correct / len(testset)
        test_accuracy.append(test_epoch_accuracy)
    # Print stats
    print(f'[epoch {epoch + 1}] Training: loss={train_epoch_loss:.3f} accuracy={train_epoch_accuracy:.3f} |\
\t Test: loss={test_epoch_loss:.3f} accuracy={test_epoch_accuracy:.3f}')
print('Finished Training')
  1. 我们可以绘制损失随时代变化的图像,因为我们已经存储了每个时代的这些值,并且可以同时查看训练集和测试集的变化:

    plt.plot(train_losses, label='train')
    
    plt.plot(test_losses, label='test')
    
    plt.xlabel('epoch')plt.ylabel('loss (CE)')
    
    plt.legend()plt.show()
    

这是输出:

图 6.11 – 交叉熵损失随时代变化的结果,适用于训练集和测试集

图 6.11 – 交叉熵损失随时代变化的结果,适用于训练集和测试集

由于在经过 20 个时代后,训练集测试集的损失似乎仍在不断改进,从性能角度来看,继续训练更多的时代可能会很有趣。

  1. 同样,也可以通过显示相应结果,使用准确度分数进行相同操作:

    plt.plot(train_accuracy, label='train')
    
    plt.plot(test_accuracy, label='test')
    
    plt.xlabel('epoch')
    
    plt.ylabel('Accuracy')plt.legend()plt.show()
    

以下是结果:

图 6.12 – 训练集和测试集准确度随时代变化的结果

图 6.12 – 训练集和测试集准确度随时代变化的结果

最终,训练集的准确度大约为 97%,测试集的准确度为 96%。

一旦模型训练完成,当然可以将其存储起来,以便在新数据上直接使用。有几种保存模型的方法:

  • 必须首先实例化net类。

  • 保存整个模型:这会保存权重和架构,意味着只需要加载文件。

  • 以 torchscript 格式保存:这会使用更高效的表示方式保存整个模型。此方法更适合于大规模的部署和推断。

现在,我们只需保存state字典,重新加载它,然后对图像进行推断:

# Save the model's state dict
torch.save(net.state_dict(), 'path_to_model.pt')
# Instantiate a new model
new_model = Net(784)
# Load the model's weights
new_model.load_state_dict(torch.load('path_to_model.pt'))

现在,可以使用已加载的训练好模型对给定图像进行推断:

plt.figure(figsize=(12, 8))
for i in range(6):
    plt.subplot(3, 3, i+1)
    # Compute the predicted number
    pred = new_model(
        testset[i][0].unsqueeze(0)).argmax(axis=1)
    # Display the image and predicted number as title
    plt.imshow(testset[i][0].detach().numpy().reshape(
        28, 28), cmap='gray_r')
    plt.title(f'Prediction: {pred.detach().numpy()}')
    plt.axis('off')

这是我们得到的结果:

图 6.13 – 使用六个输入图像及其来自训练模型的预测结果

图 6.13 – 使用六个输入图像及其来自训练模型的预测结果

正如预期的那样,加载的模型能够正确预测大多数图像上的正确数字。

还有更多内容……

深度学习通常用于需要大量计算资源的任务。在这种情况下,使用 GPU 往往是必须的。当然,PyTorch 允许我们在 GPU 上训练和推断模型。只需要几个步骤:声明一个设备变量并将模型和数据移到该设备。让我们快速了解一下如何实现这一过程。

选择设备

声明一个设备变量为 GPU 可以通过以下 Python 代码完成:

device = torch.device(
    "cuda" if torch.cuda.is_available() else "cpu") print(device)

这一行实例化了一个torch.device对象,如果 CUDA 可用,它将包含"cuda",否则包含"cpu"。事实上,如果未安装 CUDA,或者硬件上没有 GPU,将使用 CPU(这是默认行为)。

如果 GPU 已被正确检测,print(device)的输出将是"cuda"。否则,输出将是"cpu"

将模型和数据移到 GPU

一旦设备正确设置为 GPU,模型和数据都必须移动到 GPU 内存中。为此,只需要在模型和数据上调用.to(device)方法。例如,我们在此食谱中使用的训练和评估代码将变成以下内容:

train_losses = []
test_losses = []
train_accuracy = []
test_accuracy = []
# Move the model to the GPU
net = net.to(device)
for epoch in range(20):
    running_train_loss = 0.
    correct = 0.
    net.train()
    for i, data in enumerate(train_dataloader, 0):
        inputs, labels = data
        # Move the data to the device
        inputs = inputs.to(device)
        labels = labels.to(device)
    running_test_loss = 0.
    correct = 0.
    net.eval()
    with torch.no_grad():
        for i, data in enumerate(test_dataloader, 0):
            inputs, labels = data
            # Move the data to the device
            inputs = inputs.to(device)
            labels = labels.to(device)
print('Finished Training')

一开始,模型通过net = net.to(device)将其一次性移动到 GPU 设备。

在每次训练和评估的迭代循环中,输入和标签张量都通过tensor = tensor.to(device)移动到设备上。

提示

数据可以在加载时完全加载到 GPU 中,或者在训练过程中一次加载一个批次。然而,由于只有较小的数据集可以完全加载到 GPU 内存中,因此我们在此并未展示此解决方案。

另请参见

第七章:深度学习正则化

在本章中,我们将介绍几种技巧和方法来正则化神经网络。我们将重用 L2 正则化技术,就像在处理线性模型时一样。但本书中还有其他尚未介绍的技术,比如早停法和 dropout,这些将在本章中进行讲解。

在本章中,我们将介绍以下食谱:

  • 使用 L2 正则化来正则化神经网络

  • 使用早停法正则化神经网络

  • 使用网络架构进行正则化

  • 使用 dropout 进行正则化

技术要求

在本章中,我们将训练神经网络来处理各种任务。这将要求我们使用以下库:

  • NumPy

  • Scikit-learn

  • Matplotlib

  • PyTorch

  • torchvision

使用 L2 正则化来正则化神经网络

就像线性模型一样,无论是线性回归还是逻辑回归,神经网络都有权重。因此,就像线性模型一样,可以对这些权重使用 L2 惩罚来正则化神经网络。在这个食谱中,我们将在 MNIST 手写数字数据集上应用 L2 惩罚来正则化神经网络。

提醒一下,当我们在第六章中训练神经网络时,经过 20 个周期后出现了轻微的过拟合,训练集的准确率为 97%,测试集的准确率为 95%。让我们通过在本食谱中添加 L2 正则化来减少这种过拟合。

准备工作

就像线性模型一样,L2 正则化只是向损失函数中添加一个新的 L2 项。给定权重 W=w1,w2,...,添加到损失函数中的项为 。这个新增的项对损失函数的影响是,权重会受到更多约束,并且必须保持接近零以保持损失函数较小。因此,它为模型添加了偏差,并帮助进行正则化。

注意

这里的权重表示法进行了简化。实际上,每个单元 i、每个特征 j 和每一层 l 都有权重 。但最终,L2 项仍然是所有权重平方的总和。

对于这个食谱,只需要三种库:

  • matplotlib 用于绘制图表

  • pytorch 用于深度学习

  • torchvision 用于 MNIST 数据集

可以使用 pip install matplotlib torch torchvision 安装这些库。

如何做...

在这个食谱中,我们重新使用了上一章中训练多类分类模型时的相同代码,数据集仍然是 MNIST。唯一的区别将在于 第 6 步 ——如果需要,可以直接跳到这一步。

输入数据是 MNIST 手写数据集:28x28 像素的灰度图像。因此,在能够训练自定义神经网络之前,数据需要进行重新缩放和展平处理:

  1. 导入所需的库。像以前的食谱一样,我们导入了几个有用的 torch 模块和函数:

    • torch

    • torch.nn 包含构建神经网络所需的类

    • torch.nn.functional用于激活函数,如 ReLU:

    • 用于处理数据的DataLoader

我们还从torchvision中导入了一些模块:

  • MNIST用于加载数据集:

  • 用于数据集转换的transforms——既包括缩放也包括扁平化数据:

    import torch
    
    import torch.nn as nn
    
    import torch.nn.functional as F
    
    from torch.utils.data import DataLoader
    
    from torchvision.datasets import MNIST
    
    import torchvision.transforms as transforms
    
    import matplotlib.pyplot as plt
    
  1. 实例化转换。此处使用Compose类来组合三个转换:

    • transforms.ToTensor():将输入图像转换为torch.Tensor格式

    • transforms.Normalize(): 使用均值和标准差对图像进行归一化处理。会先减去均值(即0.1307),然后将每个像素值除以标准差(即0.3081)。

    • transforms.Lambda(torch.flatten):将 2D 张量展平为 1D 张量:

以下是代码:

transform = transforms.Compose([transforms.ToTensor(),
    transforms.Normalize((0.1307), (0.3081)),
    transforms.Lambda(torch.flatten)])

注意:

图像通常使用均值和标准差为 0.5 进行归一化。我们使用这些特定的值进行归一化,因为数据集是由特定的图像构成的,但 0.5 也可以很好地工作。

  1. 加载训练集和测试集,以及训练和测试数据加载器。使用MNIST类,我们分别通过train=Truetrain=False参数获取训练集和测试集。在加载数据时,我们还直接应用之前定义的转换。然后,使用批量大小为64实例化数据加载器:

    trainset = MNIST('./data', train=True, download=True,
    
        transform=transform)
    
    train_dataloader = DataLoader(trainset, batch_size=64,
    
        shuffle=True)
    
    testset = MNIST('./data', train=False, download=True,
    
        transform=transform)
    
    test_dataloader = DataLoader(testset, batch_size=64,
    
        shuffle=True)
    
  2. 定义神经网络。这里默认定义了一个包含 2 个隐藏层(每层 24 个神经元)的神经网络。输出层包含 10 个神经元,因为有 10 个类别(数字从 0 到 9)。最后,对输出层应用softmax函数,使得 10 个单元的和严格等于1

    class Net(nn.Module):
    
        def __init__(self, input_shape: int,
    
        hidden_units: int = 24):
    
            super(Net, self).__init__()
    
            self.hidden_units = hidden_units
    
            self.fc1 = nn.Linear(input_shape,
    
                self.hidden_units)
    
            self.fc2 = nn.Linear(self.hidden_units,
    
                self.hidden_units)
    
            self.output = nn.Linear(self.hidden_units, 10)
    
        def forward(self,
    
            x: torch.Tensor) -> torch.Tensor:
    
                x = self.fc1(x)
    
                x = F.relu(x)
    
                x = self.fc2(x)
    
                x = F.relu(x)
    
                output = torch.softmax(self.output(x), dim=1)
    
                return output
    
  3. 为了检查代码,我们实例化模型,并使用正确的输入形状784(28x28 像素),确保在给定的随机张量上正向传播正常工作:

    # Instantiate the model
    
    net = Net(784)
    
    # Generate randomly one random 28x28 image as a 784 values tensor
    
    random_data = torch.rand((1, 784))
    
    result = net(random_data)
    
    print('Resulting output tensor:', result)
    
    print('Sum of the output tensor:', result.sum())
    

代码输出将类似如下(只有总和必须等于 1;其他数字可能不同):

Resulting output tensor: tensor([[0.0882, 0.1141, 0.0846, 0.0874, 0.1124, 0.0912, 0.1103, 0.0972, 0.1097,
         0.1048]], grad_fn=<SoftmaxBackward0>)
Sum of the output tensor: tensor(1.0000, grad_fn=<SumBackward0>)
  1. 定义损失函数为交叉熵损失,使用pytorch中的nn.CrossEntropyLoss(),并将优化器定义为Adam。在这里,我们给Adam优化器设置另一个参数:weight_decay=0.001。该参数是 L2 惩罚的强度。默认情况下,weight_decay0,表示没有 L2 惩罚。较高的值意味着更强的正则化,就像在 scikit-learn 中的线性模型一样:

    criterion = nn.CrossEntropyLoss()
    
    optimizer = torch.optim.Adam(net.parameters(),
    
        lr=0.001, weight_decay=0.001)
    
  2. 实例化epoch_step辅助函数,用于计算正向传播和反向传播(仅限于训练集),以及损失和准确率:

    def epoch_step(net, dataloader, training_set: bool):
    
        running_loss = 0.
    
        correct = 0.
    
        for i, data in enumerate(dataloader, 0):
    
            # Get the inputs: data is a list of [inputs, labels]
    
            inputs, labels = data
    
            if training_set:
    
                # Zero the parameter gradients
    
                optimizer.zero_grad()
    
            # Forward + backward + optimize
    
            outputs = net(inputs)
    
            loss = criterion(outputs, labels)
    
            if training_set:
    
                loss.backward()
    
                optimizer.step()
    
            # Add correct predictions for this batch
    
            correct += (outputs.argmax(
    
                dim=1) == labels).float().sum()
    
            # Compute loss for this batch
    
            running_loss += loss.item()
    
        return running_loss, correct
    
  3. 我们最终可以在 20 个 epoch 上训练神经网络,并计算每个 epoch 的损失和准确率。

由于我们在训练集上进行训练,并在测试集上进行评估,模型在训练前会切换到train模式(model.train()),而在评估测试集之前,则切换到eval模式(model.eval()):

# Create empty lists to store the losses and accuracies
train_losses = []
test_losses = []
train_accuracy = []
test_accuracy = []
# Loop over the dataset 20 times for 20 epochs
for epoch in range(20):
    ## Train the model on the training set
    running_train_loss, correct = epoch_step(net,
        dataloader=train_dataloader,
        training_set=True)
    # Compute and store loss and accuracy for this epoch
    train_epoch_loss = running_train_loss / len(
        train_dataloader)
    train_losses.append(train_epoch_loss)
    train_epoch_accuracy = correct / len(trainset)
    train_accuracy.append(train_epoch_accuracy)
    ## Evaluate the model on the test set
    #running_test_loss = 0.
    #correct = 0.
    net.eval()
    with torch.no_grad():
        running_test_loss, correct = epoch_step(net,
            dataloader=test_dataloader,
            training_set=False)
        test_epoch_loss = running_test_loss / len(
            test_dataloader)
        test_losses.append(test_epoch_loss)
        test_epoch_accuracy = correct / len(testset)
        test_accuracy.append(test_epoch_accuracy)
    # Print stats
    print(f'[epoch {epoch + 1}] Training: loss={
        train_epoch_loss:.3f}accuracy={
            train_epoch_accuracy:.3f} |\
            \t Test: loss={test_epoch_loss:.3f}
            accuracy={test_epoch_accuracy:.3f}')
print('Finished Training')

在最后一个 epoch,输出应该如下所示:

[epoch 20] Training: loss=1.505 accuracy=0.964 |   Test: loss=1.505 accuracy=0.962
Finished Training
  1. 为了可视化,我们可以绘制训练集和测试集的损失随 epoch 的变化:

    plt.plot(train_losses, label='train')
    
    plt.plot(test_losses, label='test')
    
    plt.xlabel('epoch')
    
    plt.ylabel('loss (CE)')
    
    plt.legend()
    
    plt.show()
    

这里是它的图示:

图 7.1 – 交叉熵损失随 epoch 的变化;来自前面的代码输出

图 7.1 – 交叉熵损失随 epoch 的变化;来自前面的代码输出

我们可以注意到,训练集和测试集的损失几乎相同,没有明显的偏离。而在没有 L2 惩罚的前几次尝试中,损失相差较大,这意味着我们有效地对模型进行了正则化。

  1. 显示相关结果,我们也可以用准确率来表示:

    plt.plot(train_accuracy, label='train')
    
    plt.plot(test_accuracy, label='test')
    
    plt.xlabel('epoch')
    
    plt.ylabel('Accuracy')
    
    plt.legend()
    
    plt.show()
    

这里是它的图示:

图 7.2 – 准确率随 epoch 的变化;来自前面的代码输出

图 7.2 – 准确率随 epoch 的变化;来自前面的代码输出

最终,训练集和测试集的准确率都约为 96%,且没有明显的过拟合。

还有更多内容...

即使 L2 正则化是正则化线性模型(如线性回归和逻辑回归)中非常常见的技术,它通常不是深度学习中的首选方法。其他方法,如早停或丢弃法,通常更受青睐。

另外,在这个示例中,我们只提到了训练集和测试集。但为了正确优化weight_decay超参数,需要使用验证集;否则,结果会产生偏差。为了简洁起见,我们简化了这个示例,只用了两个数据集。

注意

一般来说,在深度学习中,任何其他的超参数优化,如层数、单元数、激活函数等,必须针对验证集进行优化,而不仅仅是测试集。

另见

通过模型的优化器调整 L2 惩罚,而不是直接在损失函数中进行调整,可能看起来有些奇怪,实际上也是如此。

当然,也可以手动添加 L2 惩罚,但这可能不是最优选择。请查看这个 PyTorch 讨论帖,了解更多关于这个设计选择的信息,以及如何添加 L1 惩罚的示例:discuss.pytorch.org/t/simple-l2-regularization/139

使用早停法对神经网络进行正则化

早停是深度学习中常用的一种方法,用于防止模型过拟合。这个概念简单而有效:如果模型由于过长的训练周期而发生过拟合,我们就提前终止训练,以避免过拟合。我们可以在乳腺癌数据集上使用这一技术。

准备开始

在一个完美的世界里,是不需要正则化的。这意味着在训练集和验证集中,无论经过多少个 epoch,损失几乎完全相等,如图 7.3所示。

图 7.3 – 训练和验证损失随着周期数增加但无过拟合的示例

图 7.3 – 训练和验证损失随着周期数增加但无过拟合的示例

但现实中并非总是如此完美。在实践中,可能会发生神经网络在每个周期中逐渐学习到更多关于训练集数据分布的信息,这可能会牺牲对新数据的泛化能力。这个情况在图 7.4中有所展示。

图 7.4 – 训练和验证损失随着周期数增加而过拟合的示例

图 7.4 – 训练和验证损失随着周期数增加而过拟合的示例

当遇到这种情况时,一个自然的解决方案是,在模型的验证损失停止下降时暂停训练。一旦模型的验证损失停止下降,继续训练额外的周期可能会导致模型更擅长记忆训练数据,而不是提升其在新数据上的预测准确性。这个技术叫做早停法,它可以防止模型过拟合。

图 7.5 – 一旦验证损失停止下降,我们就可以停止学习并认为模型已经完全训练好;这就是早停法

图 7.5 – 一旦验证损失停止下降,我们就可以停止学习并认为模型已经完全训练好;这就是早停法。

由于这个示例将应用于乳腺癌数据集,因此必须安装scikit-learn,以及用于模型的torch和可视化的matplotlib。可以通过pip install sklearn torch matplotlib安装这些库。

如何实现…

在本示例中,我们将首先在乳腺癌数据集上训练一个神经网络,并可视化随着周期数增加,过拟合效应的加剧。然后,我们将实现早停法来进行正则化。

正常训练

由于乳腺癌数据集相对较小,我们将只考虑训练集和验证集,而不是将数据集拆分为训练集、验证集和测试集:

  1. scikit-learnmatplotlibtorch导入所需的库:

    • 使用load_breast_cancer加载数据集

    • 使用train_test_split将数据拆分为训练集和验证集

    • 使用StandardScaler对定量数据进行重新缩放

    • 使用accuracy_score评估模型

    • 使用matplotlib进行显示

    • 使用torch本身

    • 使用torch.nn包含构建神经网络所需的类

    • 使用torch.nn.functional实现激活函数,如 ReLU

    • 使用DatasetDataLoader处理数据

下面是实现代码:

import numpy as np
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import accuracy_score
import matplotlib.pyplot as plt
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import Dataset, DataLoader
  1. 使用load_breast_cancer函数加载特征和标签:

    X, y = load_breast_cancer(return_X_y=True)
    
  2. 将数据分为训练集和验证集,指定随机种子以确保可重复性,并将特征和标签转换为float32,以便后续与 PyTorch 兼容:

    X_train, X_val, y_train, y_val = train_test_split(
    
        X.astype(np.float32), y.astype(np.float32),
    
        test_size=0.2, random_state=0)
    
  3. 创建Dataset类来处理数据。我们简单地重用了上一章实现的类:

    class BreastCancerDataset(Dataset):
    
        def __init__(self, X: np.array, y: np.array,
    
            x_scaler: StandardScaler = None):
    
                if x_scaler is None:
    
                    self.x_scaler = StandardScaler()
    
                    X = self.x_scaler.fit_transform(X)
    
                else:
    
                    self.x_scaler = x_scaler
    
                    X = self.x_scaler.transform(X)
    
                self.X = torch.from_numpy(X)
    
                self.y = torch.from_numpy(y)
    
        def __len__(self) -> int:
    
            return len(self.X)
    
        def __getitem__(self, idx: int) -> tuple[torch.Tensor]:
    
            return self.X[idx], self.y[idx]
    
  4. 为 PyTorch 实例化训练集和验证集及其数据加载器。注意,在实例化验证集时,我们提供了训练数据集的缩放器,确保两个数据集使用的缩放器是基于训练集拟合的:

    training_data = BreastCancerDataset(X_train, y_train)
    
    val_data = BreastCancerDataset(X_val, y_val,
    
        training_data.x_scaler)
    
    train_dataloader = DataLoader(training_data,
    
        batch_size=64, shuffle=True)
    
    val_dataloader = DataLoader(val_data, batch_size=64,
    
        shuffle=True)
    
  5. 定义神经网络架构——2 个隐藏层,每个隐藏层有 36 个单元,输出层有 1 个单元,并使用 sigmoid 激活函数,因为这是一个二分类任务:

    class Net(nn.Module):
    
        def __init__(self, input_shape: int,
    
            hidden_units: int = 36):
    
                super(Net, self).__init__()
    
                self.hidden_units = hidden_units
    
                self.fc1 = nn.Linear(input_shape,
    
                    self.hidden_units)
    
                self.fc2 = nn.Linear(self.hidden_units,
    
                    self.hidden_units)
    
                self.output = nn.Linear(self.hidden_units,
    
                    1)
    
        def forward(self, x: torch.Tensor) ->
    
            torch.Tensor:
    
                x = self.fc1(x)
    
                x = F.relu(x)
    
                x = self.fc2(x)
    
                x = F.relu(x)
    
                output = torch.sigmoid(self.output(x))
    
                return output
    
  6. 用期望的输入形状(特征数量)实例化模型。我们还可以选择检查给定随机张量的前向传播是否正常工作:

    # Instantiate the model
    
    net = Net(X_train.shape[1])
    
    # Generate randomly one random 28x28 image as a 784 values tensor
    
    random_data = torch.rand((1, X_train.shape[1]))
    
    result = net(random_data)
    
    print('Resulting output tensor:', result)
    

该代码的输出如下(具体值可能会有所变化,但由于最后一层使用的是 sigmoid 激活函数,所以输出值会在 0 和 1 之间):

Resulting output tensor: tensor([[0.5674]], grad_fn=<SigmoidBackward0>)
  1. 将损失函数定义为二分类交叉熵损失函数,因为这是一个二分类任务。同时实例化优化器:

    criterion = nn.BCELoss()
    
    optimizer = torch.optim.Adam(net.parameters(),
    
        lr=0.001)
    
  2. 实现一个辅助函数epoch_step,该函数计算前向传播、反向传播(对于训练集)、损失和准确率,适用于一个训练周期:

    def epoch_step(net, dataloader, training_set: bool):
    
        running_loss = 0.
    
        correct = 0.
    
        for i, data in enumerate(dataloader, 0):
    
            # Get the inputs: data is a list of [inputs, labels]
    
            inputs, labels = data
    
            labels = labels.unsqueeze(1)
    
            if training_set:
    
                # Zero the parameter gradients
    
                optimizer.zero_grad()
    
            # Forward + backward + optimize
    
            outputs = net(inputs)
    
            loss = criterion(outputs, labels)
    
            if training_set:
    
                loss.backward()
    
                optimizer.step()
    
            # Add correct predictions for this batch
    
            correct += ((
    
                outputs > 0.5) == labels).float().sum()
    
            # Compute loss for this batch
    
            running_loss += loss.item()
    
        return running_loss, correct
    
  3. 现在让我们实现train_model函数,以便训练一个模型,无论是否使用耐心。该函数存储每个训练周期的信息,然后返回以下结果:

    • 训练集的损失和准确率

    • 验证集的损失和准确率

下面是模型的代码:

def train_model(net, train_dataloader, val_dataloader, criterion, optimizer, epochs, patience=None):
    # Create empty lists to store the losses and accuracies
    train_losses = []
    val_losses = []
    train_accuracy = []
    val_accuracy = []
    best_val_loss = np.inf
    best_val_loss_epoch = 0
    # Loop over the dataset 20 times for 20 epochs
    for epoch in range(500):
        ## If the best epoch was more than the patience, just stop training
        if patience is not None and epoch - best_val_loss_epoch > patience:
            break
        ## Train the model on the training set
        net.train()
        running_train_loss, correct = epoch_step(net,
            dataloader=train_dataloader,
            training_set=True)
        # Compute and store loss and accuracy for this epoch
        train_epoch_loss = running_train_loss / len(
            train_dataloader)
        train_losses.append(train_epoch_loss)
        train_epoch_accuracy = correct / len(training_data)
        train_accuracy.append(train_epoch_accuracy)
        ## Evaluate the model on the val set
        net.eval()
        with torch.no_grad():
            running_val_loss, correct = epoch_step(
                net, dataloader=val_dataloader,
                training_set=False)
            val_epoch_loss = running_val_loss / len(
                val_dataloader)
            val_losses.append(val_epoch_loss)
            val_epoch_accuracy = correct / len(val_data)
            val_accuracy.append(val_epoch_accuracy)
            # If the loss is better than the current best, update it
            if best_val_loss >= val_epoch_loss:
                best_val_loss = val_epoch_loss
                best_val_loss_epoch = epoch + 1
        # Print stats
        print(f'[epoch {epoch + 1}] Training: loss={
            train_epoch_loss:.3f} accuracy={
            train_epoch_accuracy:.3f} |\
                \t Valid: loss={val_epoch_loss:.3f}
                accuracy={val_epoch_accuracy:.3f}')
    return train_losses, val_losses, train_accuracy,
        val_accuracy

现在让我们在 500 个周期上训练神经网络,重用之前实现的train_model函数。以下是代码:

train_losses, val_losses, train_accuracy,
    val_accuracy = train_model(
        net, train_dataloader, val_dataloader,
        criterion, optimizer, epochs=500
)

在 500 个周期后,代码输出将类似于以下内容:

[epoch 500] Training: loss=0.000 accuracy=1.000 |   Validation: loss=0.099 accuracy=0.965
  1. 现在我们可以绘制训练集和验证集的损失图,作为训练周期的函数,并可视化随着周期数增加而加剧的过拟合效应:

    plt.plot(train_losses, label='train')
    
    plt.plot(val_losses, label='valid')
    
    plt.xlabel('epoch')
    
    plt.ylabel('loss (CE)')
    
    plt.legend()
    
    plt.show()
    

以下是该损失的图表:

图 7.6 – 交叉熵损失作为训练周期的函数。(尽管有几个波动,训练损失仍然在持续下降)

图 7.6 – 交叉熵损失作为训练周期的函数。(尽管有几个波动,训练损失仍然在持续下降)

确实,我们看到训练损失总体上持续下降,甚至达到了零的值。另一方面,验证损失开始下降并在第 100 个周期左右达到最小值,然后在接下来的周期中缓慢增加。

我们可以通过多种方式实现早停来避免这种情况:

  • 在第一次训练后,我们可以重新训练模型,最多 100 个周期(或任何识别的最佳验证损失),希望能够得到相同的结果。这将是浪费 CPU 时间。

  • 我们可以在每个周期保存模型,然后在之后挑选最佳模型。这个方法有时会被实现,但可能会浪费存储空间,特别是对于大型模型。

  • 我们可以在验证损失没有改善时,自动停止训练,这个停止条件通常称为“耐心”。

现在让我们实现后者的解决方案。

注意

使用耐心也有风险:耐心太小可能会让模型陷入局部最小值,而耐心太大可能会错过真正的最优 epoch,导致停止得太晚。

使用耐心和早停的训练

现在让我们使用早停重新训练一个模型。我们首先实例化一个新的模型,以避免训练已经训练过的模型:

  1. 实例化一个新的模型以及一个新的优化器。如果你使用的是相同的笔记本内核,就不需要测试它,也不需要重新实例化损失函数。如果你想单独运行这段代码,步骤 1步骤 8必须重复使用:

    # Instantiate a fresh model
    
    net = Net(X_train.shape[1])
    
    optimizer = torch.optim.Adam(net.parameters(), lr=0.001)
    
  2. 我们现在使用30的耐心来训练这个模型。在连续 30 个 epoch 内,如果val损失没有改善,训练将会停止:

    train_losses, val_losses, train_accuracy,
    
        val_accuracy = train_model(
    
            net, train_dataloader, val_dataloader,
    
            criterion, optimizer, patience=30, epochs=500
    
    )
    

代码输出将类似以下内容(在达到早停之前的总 epoch 数量可能会有所不同):

[epoch 134] Training: loss=0.004 accuracy=1.000 |   Valid: loss=0.108 accuracy=0.982

训练在大约 100 个 epoch 后停止(结果可能会有所不同,因为默认情况下结果是非确定性的),验证准确率大约为 98%,远远超过我们在 500 个 epoch 后得到的 96%。

  1. 让我们再次绘制训练和验证损失,作为 epoch 数量的函数:

    plt.plot(train_losses, label='train')
    
    plt.plot(val_losses, label='validation')
    
    plt.xlabel('epoch')
    
    plt.ylabel('loss (BCE)')
    
    plt.legend()
    
    plt.show()
    

这里是它的图表:

图 7.7 – 交叉熵损失与 epoch 的关系

图 7.7 – 交叉熵损失与 epoch 的关系

如我们所见,验证损失已经发生过拟合,但没有时间增长太多,从而避免了进一步的过拟合。

还有更多...

如本例中前面所述,为了进行正确的评估,需要在单独的测试集上计算准确率(或任何选定的评估指标)。实际上,基于验证集停止训练并在同一数据集上评估模型是一种偏向的方法,可能会人为提高评估结果。

使用网络架构进行正则化

在本例中,我们将探讨一种不太常见但有时仍然有用的正则化方法:调整神经网络架构。在回顾为何使用此方法以及何时使用后,我们将其应用于加利福尼亚住房数据集,这是一个回归任务。

准备中

有时,最好的正则化方法不是使用任何花哨的技术,而是常识。在许多情况下,使用的神经网络可能对于输入任务和数据集来说过于庞大。一个简单的经验法则是快速查看网络中的参数数量(例如,权重和偏置),并将其与数据点的数量进行比较:如果比率大于 1(即参数多于数据点),则有严重过拟合的风险。

注意

如果使用迁移学习,这条经验法则不再适用,因为网络已经在一个假定足够大的数据集上进行了训练。

如果我们退后一步,回到线性模型,比如线性回归,大家都知道,特征之间高度相关会降低模型的性能。神经网络也是如此:过多的自由参数并不会提高性能。因此,根据任务的不同,并不总是需要几十层的网络;只需几层就足以获得最佳性能并避免过拟合。让我们通过加利福尼亚数据集在实践中验证这一点。

为此,需要使用的库有 scikit-learn、Matplotlib 和 PyTorch。可以通过pip install sklearn matplotlib torch来安装它们。

如何实现...

这将是一个两步的流程:首先,我们将训练一个较大的模型(相对于数据集来说),以揭示网络对过拟合的影响。然后,我们将在相同数据上训练另一个更适配的模型,期望解决过拟合问题。

训练一个大型模型

以下是训练模型的步骤:

  1. 首先需要导入以下库:

    • 使用fetch_california_housing来加载数据集

    • 使用train_test_split将数据划分为训练集和测试集

    • 使用StandardScaler来重新缩放特征

    • 使用r2_score来评估模型的最终表现

    • 使用matplotlib来显示损失

    • torch本身提供一些库中低级功能的实现

    • 使用torch.nn,它提供了许多用于构建神经网络的实用类

    • 使用torch.nn.functional来实现一些有用的函数

    • 使用DatasetDataLoader来处理数据操作

以下是这些import语句的代码:

import numpy as np
from sklearn.datasets
import fetch_california_housing
from sklearn.model_selection
import train_test_split
from sklearn.preprocessing
import StandardScaler
from sklearn.metrics
import r2_score
import matplotlib.pyplot as plt
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import Dataset, DataLoader
  1. 使用fetch_california_housing函数加载数据:

    X, y = fetch_california_housing(return_X_y=True)
    
  2. 使用train_test_split函数以 80%/20%的比例将数据划分为训练集和测试集。设置一个随机种子以保证可复现性。对于pytorch,数据会被转换成float32类型的变量:

    X_train, X_test, y_train, y_test = train_test_split(
    
        X.astype(np.float32), y.astype(np.float32),
    
        test_size=0.2, random_state=0)
    
  3. 使用标准化缩放器对数据进行重新缩放:

    scaler = StandardScaler()
    
    X_train = scaler.fit_transform(X_train)
    
    X_test = scaler.transform(X_test)
    
  4. 创建CaliforniaDataset类,用于处理数据。这里唯一的变换是将numpy数组转换为torch张量:

    class CaliforniaDataset(Dataset):
    
        def __init__(self, X: np.array, y: np.array):
    
            self.X = torch.from_numpy(X)
    
            self.y = torch.from_numpy(y)
    
        def __len__(self) -> int:
    
            return len(self.X)
    
        def __getitem__(self, idx: int) ->
    
            tuple[torch.Tensor]: return self.X[idx], self.y[idx]
    
  5. 实例化训练集和测试集的数据集以及数据加载器。这里定义了一个批处理大小为64,但可以根据需要进行调整:

    # Instantiate datasets
    
    training_data = CaliforniaDataset(X_train, y_train)
    
    test_data = CaliforniaDataset(X_test, y_test)
    
    # Instantiate data loaders
    
    train_dataloader = DataLoader(training_data,
    
        batch_size=64, shuffle=True)
    
    test_dataloader = DataLoader(test_data, batch_size=64,
    
        shuffle=True)
    
  6. 创建神经网络架构。考虑到数据集的规模,我们故意创建一个较大的模型——包含 5 个隐藏层,每个层有 128 个单元:

    class Net(nn.Module):
    
        def __init__(self, input_shape: int,
    
            hidden_units: int = 128):
    
                super(Net, self).__init__()
    
                self.hidden_units = hidden_units
    
                self.fc1 = nn.Linear(input_shape,
    
                    self.hidden_units)
    
                self.fc2 = nn.Linear(self.hidden_units,
    
                    self.hidden_units)
    
                self.fc3 = nn.Linear(self.hidden_units,
    
                    self.hidden_units)
    
                self.fc4 = nn.Linear(self.hidden_units,
    
                    self.hidden_units)
    
                self.fc5 = nn.Linear(self.hidden_units,
    
                    self.hidden_units)
    
                self.output = nn.Linear(self.hidden_units, 1)
    
        def forward(self, x: torch.Tensor) -> torch.Tensor:
    
            x = self.fc1(x)
    
            x = F.relu(x)
    
            x = self.fc2(x)
    
            x = F.relu(x)
    
            x = self.fc3(x)
    
            x = F.relu(x)
    
            x = self.fc4(x)
    
            x = F.relu(x)
    
            x = self.fc5(x)
    
            x = F.relu(x)
    
            output = self.output(x)
    
            return output
    
  7. 使用给定的输入形状(特征数量)实例化模型。可选地,我们可以使用预期形状的输入张量来检查网络是否正确创建(这里指的是特征数量):

    # Instantiate the network
    
    net = Net(X_train.shape[1])
    
    # Generate one random sample of 8 features
    
    random_data = torch.rand((1, X_train.shape[1]))
    
    # Compute the forward propagation
    
    print(net(random_data))
    
    tensor([[0.0674]], grad_fn=<AddmmBackward0>)
    
  8. 将损失函数实例化为均方误差损失(MSE),因为这是一个回归任务,并定义优化器为Adam,学习率为0.001

    criterion = nn.MSELoss()
    
    optimizer = torch.optim.Adam(net.parameters(), lr=0.001)
    
  9. 最后,使用train_model函数训练神经网络 500 个纪元。这个函数的实现与之前的类似,可以在 GitHub 仓库中找到。再次提醒,我们故意选择了一个较大的纪元数;否则,过拟合可能会通过提前停止来得到补偿。我们还会存储每个纪元的训练和测试损失,用于可视化和信息展示:

    train_losses, test_losses = train_model(net,
    
        train_dataloader, test_dataloader, criterion,
    
        optimizer, 500)
    

经过 500 个纪元后,最终的输出曲线将如下所示:

[epoch 500] Training: loss=0.013 | Test: loss=0.312
Finished Training
  1. 将训练集和测试集的损失绘制为与纪元相关的函数:

    plt.plot(train_losses, label='train')
    
    plt.plot(test_losses, label='test')
    
    plt.xlabel('epoch')
    
    plt.ylabel('loss (MSE)')
    
    plt.legend()
    
    plt.show()
    

这是其绘图:

图 7.8 – 平均平方误差损失与纪元的关系(注意训练集和测试集的损失明显分离)

图 7.8 – 平均平方误差损失与纪元的关系(注意训练集和测试集的损失明显分离)

我们可以注意到,训练损失持续下降,而测试损失很快达到一个平台期,然后再次上升。这是过拟合的明显信号。让我们通过计算 R2 分数来确认是否存在过拟合。

  1. 最后,让我们使用 R2 分数评估模型在训练集和测试集上的表现:

    # Compute the predictions with the trained neural network
    
    y_train_pred = net(
    
        torch.tensor((X_train))).detach().numpy()
    
    y_test_pred = net(
    
        torch.tensor((X_test))).detach().numpy()
    
    # Compute the R2-score
    
    print('R2-score on training set:', r2_score(y_train,
    
         y_train_pred))
    
    print('R2-score on test set:', r2_score(y_test,
    
        y_test_pred))
    

这段代码将输出如下类似的值:

R2-score on training set: 0.9922777453770203
R2-score on test set: 0.7610035849523354

如预期所示,我们确实遇到了明显的过拟合,在训练集上几乎达到了完美的 R2 分数,而在测试集上的 R2 分数约为 0.76。

注意

这可能看起来像是一个夸张的例子,但选择一个对于任务和数据集来说过于庞大的架构其实是相当容易的。

使用更小的网络进行正则化

现在让我们训练一个更合理的模型,看看这如何影响过拟合,即使是使用相同数量的纪元。目标不仅是减少过拟合,还要在测试集上获得更好的表现。

如果你使用的是相同的内核,则不需要重新执行第一步。否则,步骤 16 必须重新执行:

  1. 定义神经网络。这次我们只有两个包含 16 个单元的隐藏层,因此这个网络比之前的要小得多:

    class Net(nn.Module):
    
        def __init__(self, input_shape: int,
    
            hidden_units: int = 16):
    
                super(Net, self).__init__()
    
            self.hidden_units = hidden_units
    
            self.fc1 = nn.Linear(input_shape,
    
                self.hidden_units)
    
            self.fc2 = nn.Linear(self.hidden_units,
    
                self.hidden_units)
    
            self.output = nn.Linear(self.hidden_units, 1)
    
        def forward(self, x: torch.Tensor) -> torch.Tensor:
    
            x = self.fc1(x)
    
            x = F.relu(x)
    
            x = self.fc2(x)
    
            x = F.relu(x)
    
            output = self.output(x)
    
            return output
    
  2. 使用预期数量的输入特征和优化器实例化网络:

    # Instantiate the network
    
    net = Net(X_train.shape[1])
    
    optimizer = torch.optim.Adam(net.parameters(),
    
        lr=0.001)
    
  3. 训练神经网络 500 个纪元,以便我们可以将结果与之前的结果进行比较。我们将重新使用之前在本配方中使用的train_model函数:

    train_losses, test_losses = train_model(net,
    
        train_dataloader, test_dataloader, criterion,
    
        optimizer, 500)
    
    [epoch 500] Training: loss=0.248 | Test: loss=0.273
    
    Finished Training
    
  4. 将损失绘制为与纪元相关的函数,分别针对训练集和测试集:

    plt.plot(train_losses, label='train')
    
    plt.plot(test_losses, label='test')
    
    plt.xlabel('epoch')
    
    plt.ylabel('loss (MSE)')
    
    plt.legend()
    
    plt.show()
    

这是其绘图:

图 7.9 – 平均平方误差损失与纪元的关系(注意训练集和测试集几乎重叠)

图 7.9 – 平均平方误差损失与纪元的关系(注意训练集和测试集几乎重叠)

如我们所见,即使经过许多纪元,这次也没有明显的过拟合:无论纪元数量如何(除了少数噪声波动),训练集和测试集的损失保持接近,尽管随着时间推移,似乎会出现少量过拟合。

  1. 让我们再次使用 R2 分数评估模型在训练集和测试集上的表现:

    # Compute the predictions with the trained neural network
    
    y_train_pred = net(
    
        torch.tensor((X_train))).detach().numpy()
    
    y_test_pred = net(
    
        torch.tensor((X_test))).detach().numpy()
    
    # Compute the R2-score
    
    print('R2-score on training set:', r2_score(y_train,
    
        y_train_pred))
    
    print('R2-score on test set:', r2_score(y_test,
    
        y_test_pred))
    

以下是此代码的典型输出:

R2-score on training set: 0.8161885562733123
R2-score on test set: 0.7906037325658601

虽然训练集上的 R2 分数从 0.99 降到了 0.81,但测试集上的得分从 0.76 提高到了 0.79,有效地提高了模型的性能。

即使这是一个相当极端的例子,整体思路仍然是成立的。

注意

在这种情况下,提前停止(early stopping)也可能效果很好。这两种技术(提前停止和缩小网络)并不是相互排斥的,实际上可以很好地协同工作。

还有更多...

模型的复杂度可以通过参数数量来计算。即使这不是一个直接的度量,它仍然是一个良好的指示器。

例如,本食谱中使用的第一个神经网络,具有 10 个隐藏层和 128 个单元,共有 67,329 个可训练参数。另一方面,第二个神经网络,只有 2 个隐藏层和 16 个单元,仅有 433 个可训练参数。

全连接神经网络的参数数量是基于单元数量和层数的:不过,单元数和层数并不直接决定参数的数量。

要计算 torch 网络中可训练参数的数量,我们可以使用以下代码片段:

sum(p.numel() for p in net.parameters() if p.requires_grad)

为了更好地理解,让我们再看三个神经网络的例子,这三个网络的神经元数量相同,但层数不同。假设它们都具有 10 个输入特征和 1 个单元输出层:

  • 一个具有 1 个隐藏层和 100 个单元的神经网络:1,201 个参数

  • 一个具有 2 个隐藏层和 50 个单元的神经网络:3,151 个参数

  • 一个具有 10 个隐藏层和 10 个单元的神经网络:1,111 个参数

因此,在层数和每层单元数之间存在权衡,以便在给定神经元数量的情况下构建最复杂的神经网络。

使用丢弃法进行正则化

一种广泛使用的正则化方法是丢弃法(dropout)。丢弃法就是在训练阶段随机将一些神经元的激活值设置为零。让我们首先回顾一下它是如何工作的,然后将其应用于多类分类任务——sklearn数字数据集,这是 MNIST 数据集的一个较旧且较小的版本。

准备就绪

丢弃法是深度学习中广泛采用的正则化方法,因其简单有效而受到青睐。这种技术易于理解,但能够产生强大的效果。

原理很简单——在训练过程中,我们随机忽略一些单元,将它们的激活值设置为零,正如图 7.10中所示:

图 7.10 – 左侧是一个标准的神经网络及其连接,右侧是同一个神经网络应用丢弃法后,训练时平均有 50%的神经元被忽略

图 7.10 – 左侧是一个标准的神经网络及其连接,右侧是同一个神经网络应用丢弃法后,训练时平均有 50%的神经元被忽略

但是,dropout 添加了一个超参数:dropout 概率。对于 0% 的概率,没有 dropout;对于 50% 的概率,约 50% 的神经元将被随机选择忽略;对于 100% 的概率,嗯,那就没有东西可学了。被忽略的神经元并不总是相同的:对于每个新的批量大小,都会随机选择一组新的单元进行忽略。

注意

剩余的激活值会被缩放,以保持每个单元的一致全局输入。实际上,对于 1/2 的 dropout 概率,所有未被忽略的神经元都会被缩放 2 倍(即它们的激活值乘以 2)。

当然,在评估或对新数据进行推理时,dropout 会被停用,导致所有神经元都被激活。

但是这样做的意义何在呢?为什么随机忽略一些神经元会有帮助呢?一个正式的解释超出了本书的范围,但至少我们可以提供一些直观的理解。这个想法是避免给神经网络提供过多信息而导致混淆。作为人类,信息过多有时比有帮助更多:有时,信息更少反而可以帮助你做出更好的决策,避免被信息淹没。这就是 dropout 的思想:不是一次性给网络所有信息,而是通过在短时间内随机关闭一些神经元,以较少的信息来温和训练网络。希望这能最终帮助网络做出更好的决策。

在本教程中,将使用 scikit-learn 的 digits 数据集,该数据集实际上是 光学手写数字识别 数据集的一个链接。这些图像的一个小子集展示在 图 7.11 中。

图 7.11 – 数据集中的一组图像及其标签:每个图像由 8x8 像素组成

图 7.11 – 数据集中的一组图像及其标签:每个图像由 8x8 像素组成

每张图像都是一个 8x8 像素的手写数字图像。因此,数据集由 10 个类别组成,每个类别代表一个数字。

要运行本教程中的代码,所需的库是 sklearnmatplotlibtorch。可以通过 pip install sklearnmatplotlibtorch 安装这些库。

如何做……

本教程将包括两个步骤:

  1. 首先,我们将训练一个没有 dropout 的神经网络,使用一个相对较大的模型,考虑到数据的特点。

  2. 然后,我们将使用 dropout 训练相同的神经网络,希望能提高模型的性能。

我们将使用相同的数据、相同的批量大小和相同的训练轮次,以便比较结果。

没有 dropout

下面是没有 dropout 的正则化步骤:

  1. 必须加载以下导入:

    • 使用 sklearn 中的 load_digits 加载数据集

    • 使用 sklearn 中的 train_test_split 来拆分数据集

    • torchtorch.nntorch.nn.functional 用于神经网络

    • DatasetDataLoader 来自 torch,用于在 torch 中加载数据集

    • matplotlib用于可视化损失

这是import语句的代码:

import numpy as np
from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import Dataset, DataLoader
import matplotlib.pyplot as plt
  1. 加载数据。数据集包含 1,797 个样本,图像已经展平为 64 个值,范围从 0 到 16,对应 8x8 像素:

    X, y = load_digits(return_X_y=True)
    
  2. 将数据拆分为训练集和测试集,80%的数据用于训练集,20%的数据用于测试集。特征值转换为float32,标签值转换为int64,以避免后续torch错误:

    X_train, X_test, y_train, y_test = train_test_split(
    
        X.astype(np.float32), y.astype(np.int64),
    
        test_size=0.2, random_state=0)
    
  3. 为 PyTorch 创建DigitsDataset类。除了将特征转换为torch张量外,唯一的转换操作是将值除以 255,以使特征的范围落在[0, 1]之间:

    class DigitsDataset(Dataset):
    
        def __init__(self, X: np.array, y: np.array):
    
            self.X = torch.from_numpy(X/255)
    
            self.y = torch.from_numpy(y)
    
        def __len__(self) -> int:
    
            return len(self.X)
    
        def __getitem__(self, idx: int) -> tuple[torch.Tensor]:
    
            return self.X[idx], self.y[idx]
    
  4. 为训练集和测试集实例化数据集,并使用批次大小64实例化数据加载器:

    # Instantiate datasets
    
    training_data = DigitsDataset(X_train, y_train)
    
    test_data = DigitsDataset(X_test, y_test)
    
    # Instantiate data loaders
    
    train_dataloader = DataLoader(training_data,
    
        batch_size=64, shuffle=True)
    
    test_dataloader = DataLoader(test_data, batch_size=64,
    
        shuffle=True)
    
  5. 定义神经网络架构——这里有 3 个隐藏层,每层有 128 个单元(默认为 128),并且对所有隐藏层应用了 25%的丢弃概率:

    class Net(nn.Module):
    
        def __init__(self, input_shape: int,
    
            hidden_units: int = 128,
    
            dropout: float = 0.25):
    
                super(Net, self).__init__()
    
                self.hidden_units = hidden_units
    
                self.fc1 = nn.Linear(input_shape,
    
                    self.hidden_units)
    
                self.fc2 = nn.Linear(self.hidden_units,
    
                    self.hidden_units)
    
                self.fc3 = nn.Linear(self.hidden_units,
    
                    self.hidden_units)
    
                self.dropout = nn.Dropout(p=dropout)
    
                self.output = nn.Linear(self.hidden_units, 10)
    
        def forward(self, x: torch.Tensor) -> torch.Tensor:
    
            x = self.fc1(x)
    
            x = F.relu(x)
    
            x = self.dropout(x)
    
            x = self.fc2(x)
    
            x = F.relu(x)
    
            x = self.dropout(x)
    
            x = self.fc3(x)
    
            x = F.relu(x)
    
            x = self.dropout(x)
    
            output = torch.softmax(self.output(x), dim=1)
    
            return output
    

这里,丢弃操作分两步添加:

  • 在构造函数中实例化一个nn.Dropout(p=dropout)类,传入丢弃概率

  • 在每个隐藏层的激活函数之后应用丢弃层(在构造函数中定义),x = self.dropout(x)

注意

对于 ReLU 激活函数来说,将丢弃层设置在激活函数之前或之后不会改变输出。但对于其他激活函数,如 sigmoid,这会产生不同的结果。

  1. 使用正确的输入形状64(8x8 像素)实例化模型,并且由于我们希望先检查没有丢弃的结果,所以设置丢弃率为0。检查前向传播是否在给定的随机张量上正常工作:

    # Instantiate the model
    
    net = Net(X_train.shape[1], dropout=0)
    
    # Generate randomly one random 28x28 image as a 784 values tensor
    
    random_data = torch.rand((1, 64))
    
    result = net(random_data)
    
    print('Resulting output tensor:', result)
    
    print('Sum of the output tensor:', result.sum())
    

这段代码的输出应该如下所示:

Resulting output tensor: tensor([[0.0964, 0.0908, 0.1043, 0.1083, 0.0927, 0.1047, 0.0949, 0.0991, 0.1012,
         0.1076]], grad_fn=<SoftmaxBackward0>)
Sum of the output tensor: tensor(1., grad_fn=<SumBackward0>)
  1. 将损失函数定义为交叉熵损失,并将优化器设置为Adam

    criterion = nn.CrossEntropyLoss()
    
    optimizer = torch.optim.Adam(net.parameters(), lr=0.001)
    
  2. 使用 GitHub 仓库中可用的train_model函数,在 500 个周期内训练神经网络。每个周期,我们都存储并计算训练集和测试集的损失和精度:

    train_losses, test_losses, train_accuracy,
    
        test_accuracy = train_model(
    
            net, train_dataloader, test_dataloader,
    
            criterion, optimizer, epochs=500
    
    )
    

在 500 个周期后,你应该得到如下输出:

[epoch 500] Training: loss=1.475 accuracy=0.985 |       Test: loss=1.513 accuracy=0.947
Finished Training
  1. 绘制训练集和测试集的交叉熵损失与周期数的关系:

    plt.plot(train_losses, label='train')
    
    plt.plot(test_losses, label='test')
    
    plt.xlabel('epoch')
    
    plt.ylabel('loss (CE)')
    
    plt.legend()
    
    plt.show()
    

这里是它的图示:

图 7.12 – 交叉熵损失作为周期的函数(注意训练集和测试集之间的轻微偏差)

图 7.12 – 交叉熵损失作为周期的函数(注意训练集和测试集之间的轻微偏差)

  1. 绘制精度图将展示等效结果:

    plt.plot(train_accuracy, label='train')
    
    plt.plot(test_accuracy, label='test')
    
    plt.xlabel('epoch')
    
    plt.ylabel('Accuracy')
    
    plt.legend()
    
    plt.show()
    

这里是它的图示:

图 7.13 – 精度作为周期的函数;我们再次可以看到过拟合现象

图 7.13 – 精度作为周期的函数;我们再次可以看到过拟合现象

最终精度在训练集上大约为 98%,而在测试集上仅为 95%左右,显示出过拟合现象。现在我们尝试添加丢弃层来减少过拟合。

使用丢弃层

在这部分,我们将简单地从步骤 7开始,但使用丢弃层,并与之前的结果进行比较:

  1. 64作为输入共享,25%的 dropout 概率实例化模型。25%的概率意味着在训练过程中,在每一层隐藏层中,大约会有 32 个神经元被随机忽略。重新实例化一个新的优化器,依然使用Adam

    # Instantiate the model
    
    net = Net(X_train.shape[1], dropout=0.25)
    
    optimizer = torch.optim.Adam(net.parameters(), lr=0.001)
    
  2. 再次训练神经网络 500 个周期,同时记录训练和测试的损失值及准确度:

    train_losses, test_losses, train_accuracy, test_accuracy = train_model(
    
        net, train_dataloader, test_dataloader, criterion,
    
            optimizer, epochs=500
    
    )
    
    [epoch 500] Training: loss=1.472 accuracy=0.990 |       Test: loss=1.488 accuracy=0.975
    
    Finished Training
    
  3. 再次绘制训练和测试损失随周期变化的图表:

    plt.plot(train_losses, label='train')
    
    plt.plot(test_losses, label='test')
    
    plt.xlabel('epoch')
    
    plt.ylabel('loss (CE)')
    
    plt.legend()
    
    plt.show()
    

这是它的图表:

图 7.14 – 交叉熵损失随周期变化,得益于 dropout 减少了发散

图 7.14 – 交叉熵损失随周期变化,得益于 dropout 减少了发散

我们在这里观察到与之前不同的行为。训练和测试损失似乎不会随着周期的增加而相差太多。在最初的 100 个周期中,测试损失略低于训练损失,但之后训练损失进一步减少,表明模型轻微过拟合。

  1. 最后,绘制训练和测试准确度随周期变化的图表:

    plt.plot(train_accuracy, label='train')
    
    plt.plot(test_accuracy, label='test')
    
    plt.xlabel('epoch')
    
    plt.ylabel('Accuracy')
    
    plt.legend()
    
    plt.show()
    

这是它的图表:

图 7.15 – 准确度随周期变化(得益于 dropout,过拟合大幅度减少)

图 7.15 – 准确度随周期变化(得益于 dropout,过拟合大幅度减少)

我们的训练准确度达到了 99%,相比之前的 98%有所提升。更有趣的是,测试准确度也从之前的 95%上升到 97%,有效地实现了正则化并减少了过拟合。

还有更多...

尽管 dropout 并非万无一失,但它已被证明是一种有效的正则化技术,尤其是在对小数据集进行大规模网络训练时。有关更多内容,可以参考 Hinton 等人发表的论文《通过防止特征检测器的共适应来改进神经网络》。这篇论文可以在arxiv上找到:arxiv.org/abs/1207.0580

另见

第八章:使用循环神经网络进行正则化

在本章中,我们将使用循环神经网络RNNs)。正如我们将看到的,它们非常适合自然语言处理NLP)任务,即使它们也适用于时间序列任务。在学习如何训练 RNN 后,我们将应用几种正则化方法,例如使用 dropout 和序列最大长度。这将帮助你掌握基础知识,并能应用到 NLP 或时间序列相关的任务中。它还将为你理解下一章中涵盖的更高级技巧提供必要的知识。

在本章中,我们将涵盖以下食谱:

  • 训练 RNN

  • 训练 门控循环单元GRU

  • 使用 dropout 进行正则化

  • 使用最大序列长度进行正则化

技术要求

在本章中,我们将使用以下库训练 RNNs(循环神经网络)来处理各种任务:

  • NumPy

  • pandas

  • scikit-learn

  • Matplotlib

  • PyTorch

  • Transformers

训练 RNN

在 NLP 中,输入数据通常是文本数据。由于文本通常只是一个词序列,因此使用 RNN 有时是一个很好的解决方案。事实上,与全连接网络不同,RNN 会考虑数据的序列信息。

在本例中,我们将在推文上训练 RNN 来预测其情感是正面、负面还是中性。

开始

在自然语言处理(NLP)中,我们通常处理的是文本数据,这些数据是非结构化的。为了正确处理这些数据,通常需要一个多步骤的过程——首先将文本转换为数字,然后再在这些数字上训练模型。

有几种方法可以将文本转换为数字。在本例中,我们将使用一种简单的方法,称为['the', 'dog', 'is', 'out']。在分词过程中通常还有一步——一旦句子被转换成词语列表,它必须被转换为数字。每个词都会被分配一个数字,这样句子“The dog is out”就可以被分词为[3, 198, 50, 3027]

提示

这是一个相当简化的分词解释。有关更多资源,请查看 参见下文 子章节。

在本例中,我们将在推文上训练 RNN 来进行多类分类任务。然而,RNN 是如何工作的呢?RNN 以一系列特征作为输入,如图 8.1所示。

图 8.1 – 一个 RNN 的示意图。底层是输入特征,中间是隐藏层,顶层是输出层

图 8.1 – 一个 RNN 的示意图。底层是输入特征,中间是隐藏层,顶层是输出层

图 8.1中,RNN 的隐藏层有两个输入和两个输出:

  • 输入:当前步骤的特征,,以及前一个步骤的隐藏状态,

  • 输出:隐藏状态,(传递到下一步),以及该步骤的激活输出!

对于单层 RNN,激活函数就是输出

回到我们的例子,输入特征就是令牌。因此,在每个序列步骤中,一个或多个神经网络层会同时接收该步骤的特征和前一步的隐藏状态作为输入。

重要提示

RNN 还可以用于其他场景,如预测,其中输入特征既可以是定量的,也可以是定性的。

RNN 也有多个权重集。如 图 8.2 所示,存在三组权重:

  • :应用于前一步隐藏状态的权重,用于当前隐藏状态的计算

  • :应用于输入特征的权重,用于当前隐藏状态的计算

  • :应用于当前隐藏状态的权重,用于当前输出

图 8.2 – 一个展示不同权重集的 RNN 结构

图 8.2 – 一个展示不同权重集的 RNN 结构

总的来说,综合考虑这些,隐藏状态和激活输出的计算可以如下进行:

这里,g 是激活函数,且 是偏置项。我们在此使用 softmax 来进行输出计算,假设这是一个多类分类任务,但根据任务的不同,任何激活函数都可以使用。

最后,损失可以像任何其他机器学习任务一样轻松计算(例如,对于分类任务,可以计算真实值和神经网络输出之间的交叉熵损失)。在这种神经网络上进行反向传播,称为 时间反向传播,超出了本书的范围。

从实践角度来看,针对本食谱,我们需要一个 Kaggle 数据集。获取此数据集的方法是,配置好 Kaggle API 后,可以使用以下命令行将数据集下载到当前工作目录:

kaggle datasets download -d crowdflower/twitter-airline-sentiment --unzip

该行命令应该下载一个 .zip 文件并解压缩其内容,然后应能获得一个名为 Tweets.csv 的文件。你可以将该文件移动或复制到当前工作目录。

最后,必须安装以下库:pandasnumpyscikit-learnmatplotlibtorchtransformers。可以使用以下命令行进行安装:

pip install pandas numpy scikit-learn matplotlib torch transformers

如何做…

在本食谱中,我们将使用 RNN 来对推文进行三类分类——负面、中立和正面。正如前一部分所述,这将是一个多步骤的过程——首先进行推文文本的分词,然后进行模型训练:

  1. 导入所需的库:

    • 用于神经网络的 torch 和一些相关模块与类

    • 用于预处理的 train_test_splitLabelEncoder 来自 scikit-learn

    • 使用 Transformers 中的 AutoTokenizer 对推文进行分词

    • 使用pandas加载数据集

    • 使用matplotlib进行可视化:

      import torch import torch.nn as nn
      
      import torch.optim as optim from torch.utils.data
      
      import DataLoader, Dataset from sklearn.model_selection
      
      import train_test_split from sklearn.preprocessing
      
      import LabelEncoder from transformers
      
      import AutoTokenizer
      
      import pandas as pd
      
      import matplotlib.pyplot as plt
      
  2. 使用pandas.csv文件加载数据:

    # Load data
    
    data = pd.read_csv('Tweets.csv')
    
    data[['airline_sentiment', 'text']].head()
    

输出将是以下内容:

airline_sentiment Text
0 中立 @VirginAmerica What @``dhepburn said.
1 积极 @VirginAmerica plus you've added commercials t...
2 中立 @VirginAmerica I didn't today... Must mean I n...
3 消极 @VirginAmerica it's really aggressive to blast...
4 消极 @VirginAmerica and it's a really big bad thing...

表格 8.1 – 数据分类后的输出

我们将使用的数据由airline_sentiment列中的标签(包括消极、中立或积极)以及与之关联的原始推文文本(来自text列)组成。

  1. 使用train_test_split函数将数据拆分为训练集和测试集,测试集大小为 20%,并指定随机状态以确保可复现性:
# Split data into train and test sets
train_data, test_data = train_test_split(data,
    test_size=0.2, random_state=0)
  1. 实现TextClassificationDataset数据集类,处理数据。在实例化时,该类将执行以下操作:
  • 从 Transformers 中实例化AutoTokenizer

  • 使用之前实例化的分词器对推文进行分词并存储结果

  • 对标签进行编码并存储:

    # Define dataset class class TextClassificationDataset(Dataset):
    
    def __init__(self, data, max_length):
    
        self.data = data
    
        self.tokenizer = AutoTokenizer.from_pretrained(
    
            'bert-base-uncased')
    
        self.tokens = self.tokenizer(
    
            data['text'].to_list(), padding=True,
    
            truncation=True, max_length=max_length,
    
            return_tensors='pt')['input_ids']
    
        le = LabelEncoder()
    
        self.labels = torch.tensor(le.fit_transform(
    
            data['airline_sentiment']))
    
    def __len__(self):
    
        return len(self.data)
    
    def __getitem__(self, index):
    
        return self.tokens[index], self.labels[index]
    

使用该分词器指定了几个选项:

  • 它使用'bert-base-uncased'分词器进行实例化,这是一个用于 BERT 模型的分词器

  • 进行分词时,提供了一个最大长度作为构造函数的参数

  • 填充设置为True,这意味着如果一条推文的长度小于最大长度,它将用零填充,以匹配该长度

  • 截断设置为True,这意味着如果一条推文超过最大长度,剩余的令牌将被忽略

  • 返回的张量指定为'pt',因此它返回一个 PyTorch 张量

提示

请参阅更多内容...小节,了解分词器的详细信息。

  1. 实例化TextClassificationDataset对象,分别用于训练集和测试集,以及相关的数据加载器。这里我们指定最大词数为24,批处理大小为64。这意味着每条推文将被转换为恰好 24 个令牌的序列:
batch_size = 64 max_length = 24
# Initialize datasets and dataloaders
train_dataset = TextClassificationDataset(train_data,
    max_length)
test_dataset = TextClassificationDataset(test_data,
    max_length)
train_dataloader = DataLoader(train_dataset,
    batch_size=batch_size, shuffle=True)
test_dataloader = DataLoader(test_dataset,
    batch_size=batch_size, shuffle=True)
  1. 实现 RNN 模型:
# Define RNN model
class RNNClassifier(nn.Module):
    def __init__(self, vocab_size, embedding_dim,
        hidden_size, output_size, num_layers=3):
            super(RNNClassifier, self).__init__()
            self.num_layers = num_layers
            self.hidden_size = hidden_size
            self.embedding = nn.Embedding(
                num_embeddings=vocab_size,
                embedding_dim=embedding_dim)
            self.rnn = nn.RNN(
                input_size=embedding_dim,
                hidden_size=hidden_size,
                num_layers=num_layers,
                nonlinearity='relu',
                batch_first=True)
            self.fc = nn.Linear(hidden_size, output_size)
    def forward(self, inputs):
        batch_size = inputs.size(0)
        zero_hidden = torch.zeros(self.num_layers,
            batch_size, self.hidden_size)
        embedded = self.embedding(inputs)
        output, hidden = self.rnn(embedded, zero_hidden)
        output = torch.softmax(self.fc(output[:, -1]),
            dim=1)
        return output

这里定义的 RNN 模型可以通过几个步骤来描述:

  • 一个嵌入层,它将令牌作为输入,输入的大小为词汇表的大小,输出的大小为给定的嵌入维度

  • 三层 RNN,它将嵌入层的输出作为输入,具有给定的层数、隐藏层大小以及 ReLU 激活函数

  • 最后,一个嵌入层,它将令牌作为输入,输入的大小为词汇表的大小,输出的大小为给定的嵌入维度;请注意,输出仅针对最后一个序列步骤计算(即output[:, -1]),并应用 softmax 激活函数

重要提示

输出不一定仅针对最后一个序列步骤进行计算。根据任务的不同,在每个步骤输出一个值可能是有用的(例如,预测任务),或者只输出一个最终值(例如,分类任务)。

  1. 实例化并测试模型。词汇表大小由分词器提供,输出大小为三是由任务定义的;有三个类别(负面、中立、正面)。其他参数是超参数;这里选择了以下值:
  • 嵌入维度为64

  • 一个隐藏维度为64

当然,其他值也可以进行测试:

vocab_size = train_dataset.tokenizer.vocab_size
embedding_dim = 64
hidden_dim = 64
output_size = 3
model = RNNClassifier(
    vocab_size=vocab_size,
    embedding_dim=embedding_dim,
    hidden_size=hidden_dim,
    output_size=output_size, )
random_data = torch.randint(0, vocab_size,
    size=(batch_size, max_length))
result = model(random_data)
print('Resulting output tensor:', result.shape) print('Sum of the output tensor:', result.sum())

代码将输出如下:

Resulting output tensor: torch.Size([64, 3]) Sum of the output tensor: tensor(64.0000, grad_fn=<SumBackward0>)
  1. 实例化优化器;在这里,我们将使用 Adam 优化器,学习率为0.001。损失函数是交叉熵损失,因为这是一个多类分类任务:
optimizer = optim.Adam(model.parameters(), lr=0.001)
criterion = nn.CrossEntropyLoss()
  1. 让我们定义两个辅助函数来训练模型。

epoch_step_tweet将计算一个时代的损失和准确率,并更新训练集的权重:

def epoch_step_tweet(model, dataloader,
    training_set: bool):
        running_loss = 0
        correct = 0.
    for i, data in enumerate(dataloader, 0):
        # Get the inputs: data is a list of [inputs, labels]
        inputs, labels = data
        if training_set:
            # Zero the parameter gradients
            optimizer.zero_grad()
        # Forward + backward + optimize
        outputs = model(inputs)
        loss = criterion(outputs, labels) .long()
        if training_set:
            loss.backward()
            optimizer.step()
        # Add correct predictions for this batch
        correct += (outputs.argmax(
            dim=1) == labels).float().sum()
        # Compute loss for this batch
        running_loss += loss.item()
    return running_loss, correct

train_tweet_classification将循环遍历各个时代,并使用epoch_step_tweet计算和存储损失和准确率:

def train_tweet_classification(model,
    train_dataloader, test_dataloader, criterion,
    epochs: int = 20):
        # Train the model
        train_losses = []
        test_losses = []
        train_accuracy = []
        test_accuracy = []
    for epoch in range(20):
        running_train_loss = 0.
        correct = 0.
        model.train()
        running_train_loss,
        correct = epoch_step_tweet(model,
            dataloader=train_dataloader,
            training_set=True)
        # Compute and store loss and accuracy for this epoch
        train_epoch_loss = running_train_loss / len(
            train_dataloader)
        train_losses.append(train_epoch_loss)
        train_epoch_accuracy = correct / len(
            train_dataset)
        train_accuracy.append(train_epoch_accuracy)
        ## Evaluate the model on the test set
        running_test_loss = 0.
        correct = 0.
        model.eval()
        with torch.no_grad():
            running_test_loss,
            correct = epoch_step_tweet(model,
                dataloader=test_dataloader,
                training_set=False)
            test_epoch_loss = running_test_loss / len(
                test_dataloader)
            test_losses.append(test_epoch_loss)
            test_epoch_accuracy = correct / len(
            test_dataset)
            test_accuracy.append(test_epoch_accuracy)
        # Print stats
        print(f'[epoch {epoch + 1}] Training: loss={train_epoch_loss:.3f} accuracy={train_epoch_accuracy:.3f} |\
    \t Test: loss={test_epoch_loss:.3f} accuracy={test_epoch_accuracy:.3f}')
    return train_losses, test_losses, train_accuracy,
    test_accuracy
  1. 复用辅助函数,我们现在可以在 20 次迭代中训练模型。在这里,我们将计算并存储每个时代的训练集和测试集的准确率和损失,以便之后绘制它们:
train_losses, test_losses, train_accuracy, test_accuracy = train_tweet_classification(model,
    train_dataloader, test_dataloader, criterion,
    epochs=20)

在 20 次迭代后,输出应如下所示:

[epoch 20] Training: loss=0.727 accuracy=0.824 |  Test: loss=0.810 accuracy=0.738
  1. 绘制损失与时代数的关系,分别针对训练集和测试集:
plt.plot(train_losses, label='train')
plt.plot(test_losses, label='test')
plt.xlabel('epoch') plt.ylabel('loss (CE)')
plt.legend() plt.show()

这是最终得到的图表:

图 8.3 – 交叉熵损失与时代的关系

图 8.3 – 交叉熵损失与时代的关系

我们可以看到在第五次迭代时就开始出现过拟合,因为训练损失不断减少,而测试损失趋于平稳。

  1. 同样,绘制准确率与时代数的关系,分别针对训练集和测试集:
plt.plot(train_accuracy, label='train')
plt.plot(test_accuracy, label='test')
plt.xlabel('epoch') plt.ylabel('Accuracy')
plt.legend() plt.show()

然后,我们得到了这个图表:

图 8.4 – 准确率与时代的关系

图 8.4 – 准确率与时代的关系

在 20 次迭代后,训练集的准确率大约为 82%,但测试集的准确率仅为 74%,这意味着通过适当的正则化可能还有提升空间。

还有更多内容……

在这个例子中,我们使用了HuggingFace分词器,但它实际上做了什么呢?让我们看一个文本示例,以便完全理解它的功能。

首先,让我们使用AutoTokenizer类定义一个全新的分词器,指定 BERT 分词器:

from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained('bert-base-uncased')

重要提示

有许多分词器,它们有不同的方法,因此对于同一文本给出不同的输出。'bert-base-uncased'是一个相当常见的分词器,但也可以使用许多其他分词器。

现在让我们将这个分词器应用于给定的文本,使用tokenize方法,看看输出是什么:

tokenizer.tokenize("Let's use regularization in ML. Regularization should help to improve model robustness")

代码输出如下:

['let',  "'",  's',  'use',  'regular',  '##ization',  'in',
  'ml',  '.',  'regular',  '##ization',  'should',  'help',
  'to',  'improve',  'model',  'robust',  '##ness']

因此,分词可以描述为将一个句子拆分成更小的部分。其他分词器可能会在句尾有不同的词块(或词元),但过程本质上是相同的。

现在,如果我们在这个相同的句子中应用分词,我们可以通过'input_ids'获取词元编号:

tokenizer("Let's use regularization in ML. Regularization should help to improve model robustness")['input_ids']

代码输出现在是以下内容:

[101,  2292,  1005,  1055,  2224,  3180,  3989,  1999,  19875,
  1012,  3180,  3989,  2323,  2393,  2000,  5335,  2944,  15873,
  2791,  102]

重要提示

请注意,31803989这两个词元出现了两次。实际上,词汇regularization(被分词为两个独立的词元)出现了两次。

对于给定的分词器,词汇表的大小就是现有词汇的数量。这个大小存储在vocab_size属性中。在这个例子中,词汇表的大小是30522

提示

如果你感兴趣,你也可以直接查看整个词汇表,它作为字典存储在.vocab属性中。

另请参阅

训练一个 GRU

在这个教程中,我们将继续探索 RNN 与GRU —— 它是什么,如何工作,以及如何训练这样一个模型。

入门

RNN 的主要限制之一是网络在其步骤中的记忆。GRU 通过添加一个记忆门来克服这一限制。

如果我们退后一步,用一个简单的示意图描述 RNN 单元,它可能会像图 8.5一样。

图 8.5 – RNN 单元的示意图

图 8.5 – RNN 单元的示意图

所以基本上,在每个步骤t,都有一个隐藏状态和一组特征。它们被连接在一起,然后应用权重和激活函数 g,最终得到一个新的隐藏状态。可选地,从这个隐藏状态计算出一个输出,依此类推。

但如果这个特征步骤不相关呢?或者,如果网络偶尔完全记住这个隐藏状态会有用呢?这正是 GRU 所做的,通过所谓的门添加一组新的参数。

也是通过反向传播学习的,使用一组新的权重,并使网络能够学习更复杂的模式,以及记住相关的过去信息。

一个 GRU 由两个门组成:

  • :更新门,负责学习是否更新隐藏状态

  • :相关性门,负责学习隐藏状态的相关性

最后,GRU 单元的简化图示如图 8.6所示。

图 8.6 – GRU 单元的示意图。为清晰起见,省略了相关性门

图 8.6 – GRU 单元的示意图。为清晰起见,省略了相关性门

前向计算现在比简单 RNN 单元略复杂,可以通过以下公式集合来描述:

这些方程可以用几个词简单描述。与简单 RNN 相比,主要有三个区别:

  • 两个门,,与相关权重一起计算

  • 相关性门用于计算中间隐藏状态

  • 最终的隐藏状态是之前隐藏状态和当前中间隐藏状态的线性组合,更新门作为权重

这里的主要技巧是使用更新门,它可以在极端情况下解释为:

  • 如果仅由 1 构成,则会忘记之前的隐藏状态

  • 如果仅由 0 构成,则不考虑新的隐藏状态

尽管这些概念一开始可能比较复杂,但幸运的是,GRU 在 PyTorch 中使用起来非常简单,正如我们在本示例中所看到的。

为了运行本示例中的代码,我们将使用 IMDb 数据集——一个包含电影评论以及正面或负面标签的数据集。任务是根据文本推测评论的极性(正面或负面)。可以通过以下命令行下载:

kaggle datasets download -d lakshmi25npathi/imdb-dataset-of-50k-moviereviews --unzip

我们还需要以下库:pandasnumpyscikit-learnmatplotlibtorchtransformers。它们可以通过以下命令行安装:

pip install pandas numpy scikit-learn matplotlib torch transformers

如何实现…

在本示例中,我们将在相同的 IMDb 数据集上训练一个 GRU 模型进行二分类任务。正如我们所看到的,训练 GRU 的代码几乎与训练一个简单 RNN 的代码相同:

  1. 导入所需的库:

    • 使用torch及一些相关模块和类来构建神经网络

    • 从 scikit-learn 导入train_test_splitLabelEncoder进行预处理

    • 使用 Transformers 中的AutoTokenizer来对评论进行分词

    • 使用pandas加载数据集

    • 使用matplotlib进行可视化:

      import torch
      
      import torch.nn as nn
      
      import torch.optim as optim from torch.utils.data
      
      import DataLoader,Dataset from sklearn.model_selection
      
      import train_test_split from sklearn.preprocessing
      
      import LabelEncoder from transformers
      
      import AutoTokenizer
      
      import pandas as pd
      
      import numpy as np
      
      import matplotlib.pyplot as plt
      
  2. 使用 pandas 从.csv文件加载数据。这是一个 50,000 行的数据集,包含文本评论和标签:

    # Load data
    
    data = pd.read_csv('IMDB Dataset.csv')
    
    data.head()
    

代码输出如下:

                                   review    sentiment 0  One of the other reviewers has mentioned that ...       positive
1  A wonderful little production. <br /><br />The...  positive
2  I thought this was a wonderful way to spend ti...     positive
3  Basically there's a family where a little boy ...       negative
4  Petter Mattei's "Love in the Time of Money" is...     positive
  1. 使用train_test_split函数将数据拆分为训练集和测试集,测试集大小为 20%,并指定随机种子以确保可重现性:

    # Split data into train and test sets
    
    Train_data, test_data = train_test_split(data,
    
        test_size=0.2, random_state=0)
    
  2. 实现TextClassificationDataset数据集类来处理数据。在实例创建时,该类将执行以下操作:

    • 从 transformers 库中实例化AutoTokenizer,使用bert-base-uncased分词器

    • 使用先前实例化的分词器,对推文进行分词,并提供最大长度、填充和截断选项

    • 编码标签并存储:

      # Define dataset class
      
      class TextClassificationDataset(Dataset):
      
          def __init__(self, data, max_length):
      
              self.data = data
      
              self.tokenizer = AutoTokenizer.from_pretrained('bert-base-uncased')
      
              self.tokens = self.tokenizer(
      
                  data['review'].to_list(), padding=True,
      
                  truncation=True, max_length=max_length,
      
                  return_tensors='pt')['input_ids']
      
              le = LabelEncoder()
      
              self.labels = torch.tensor(le.fit_transform(
      
                  data['sentiment']).astype(np.float32))
      
          def __len__(self):
      
              return len(self.data)
      
          def __getitem__(self, index):
      
              return self.tokens[index],self.labels[index]
      
  3. 实例化TextClassificationDataset对象,分别为训练集和测试集创建数据加载器,最大词汇数为 64,批大小为 64。也就是说,每个电影评论将被转换为一个恰好包含 64 个标记的序列:

    batch_size = 64
    
    max_words = 64
    
    # Initialize datasets and dataloaders
    
    train_dataset = TextClassificationDataset(train_data,
    
        max_words)
    
    test_dataset = TextClassificationDataset(test_data,
    
        max_words)
    
    train_dataloader = DataLoader(train_dataset,
    
        batch_size=batch_size, shuffle=True)
    
    test_dataloader = DataLoader(test_dataset,
    
        batch_size=batch_size, shuffle=True)
    
  4. 实现 GRU 分类模型。它由以下元素组成:

    • 嵌入层(以零向量作为第一个输入)

    • 三层 GRU

    • 在最后一个序列步骤上使用全连接层,激活函数为 sigmoid,因为这是一个二分类任务:

      # Define GRU model
      
      class GRUClassifier(nn.Module):
      
          def __init__(self, vocab_size, embedding_dim,
      
              hidden_size, output_size, num_layers=3):
      
                  super(GRUClassifier, self).__init__()
      
                  self.num_layers = num_layers
      
                  self.hidden_size = hidden_size
      
                  self.embedding = nn.Embedding(
      
                      num_embeddings=vocab_size,
      
                      embedding_dim=embedding_dim)
      
                  self.gru = nn.GRU(
      
                      input_size=embedding_dim,
      
                      hidden_size=hidden_size,
      
                      num_layers=num_layers,
      
                      batch_first=True)
      
                  self.fc = nn.Linear(hidden_size,
      
                      output_size)
      
          def forward(self, inputs):
      
              batch_size = inputs.size(0)
      
              zero_hidden = torch.zeros(
      
                  self.num_layers, batch_size,
      
                  self.hidden_size).to(device)
      
              embedded = self.embedding(inputs)
      
              output, hidden = self.gru(embedded,
      
                  zero_hidden)
      
              output = torch.sigmoid(self.fc(output[:, -1]))
      
              return output
      
  5. 实例化 GRU 模型,设置嵌入维度和隐藏维度为 32:

    vocab_size = train_dataset.tokenizer.vocab_size
    
    embedding_dim = 32
    
    hidden_dim = 32
    
    output_size = 1
    
    # Optionally, set the device to GPU if you have one device = torch.device(
    
        'cuda' if torch.cuda.is_available() else 'cpu')
    
    model = GRUClassifier(
    
        vocab_size=vocab_size,
    
        embedding_dim=embedding_dim,
    
        hidden_size=hidden_dim,
    
        output_size=output_size,
    
    ).to(device)
    
    random_data = torch.randint(0, vocab_size,
    
        size=(batch_size, max_words)).to(device)
    
    result = model(random_data)
    
    print('Resulting output tensor:', result.shape)
    
    print('Sum of the output tensor:', result.sum())
    

代码输出如下:

Resulting output tensor: torch.Size([64, 1]) Sum of the output tensor: tensor(31.0246, device='cuda:0', grad_fn=<SumBackward0>)
  1. 实例化优化器为 Adam 优化器,学习率为0.001。损失定义为二元交叉熵损失,因为这是一个二分类任务:

    optimizer = optim.Adam(model.parameters(), lr=0.001)
    
    criterion = nn.BCELoss()
    
  2. 现在让我们实现两个辅助函数。

epoch_step_IMDB更新训练集的权重,并计算给定轮次的二元交叉熵损失和准确率:

def epoch_step_IMDB(model, dataloader, device,
    training_set: bool):
        running_loss = 0.
        correct = 0.
        for i, data in enumerate(dataloader, 0):
  # Get the inputs: data is a list of [inputs, labels]
            inputs, labels = data
            inputs = inputs.to(device)
            labels = labels.unsqueeze(1).to(device)
            if training_set:
                # Zero the parameter gradients
                optimizer.zero_grad()
                # Forward + backward + optimize
                outputs = model(inputs)
                loss = criterion(outputs, labels)
            if training_set:
                loss.backward()
                optimizer.step()
             # Add correct predictions for this batch
                correct += (
             (outputs > 0.5) == labels).float().sum()
                # Compute loss for this batch
                running_loss += loss.item()
    return running_loss, correct

train_IMDB_classification 在各个轮次中循环,训练模型,并存储训练集和测试集的准确率与损失:

def train_IMDB_classification(model, train_dataloader,
    test_dataloader, criterion, device,
    epochs: int = 20):
        # Train the model
        train_losses = []
        test_losses = []
        train_accuracy = []
        test_accuracy = []
    for epoch in range(20):
        running_train_loss = 0.
        correct = 0.
        model.train()
        running_train_loss, correct = epoch_step_IMDB(
            model, train_dataloader, device,
                training_set=True
        )
        # Compute and store loss and accuracy for this epoch
        train_epoch_loss = running_train_loss / len(
            train_dataloader)
        train_losses.append(train_epoch_loss)
        train_epoch_accuracy = correct / len(
            train_dataset)
        train_accuracy.append(
            train_epoch_accuracy.cpu().numpy())
        ## Evaluate the model on the test set
        running_test_loss = 0.
        correct = 0.
        model.eval()
        with torch.no_grad():
            running_test_loss,
            correct = epoch_step_IMDB(
                model, test_dataloader, device,
                training_set=False
            )
            test_epoch_loss = running_test_loss / len(
                test_dataloader)
            test_losses.append(test_epoch_loss)
            test_epoch_accuracy = correct / len(
                test_dataset)
            test_accuracy.append(
                test_epoch_accuracy.cpu().numpy())
        # Print stats
        print(f'[epoch {epoch + 1}] Training: loss={train_epoch_loss:.3f} accuracy={train_epoch_accuracy:.3f} |\
    \t Test: loss={test_epoch_loss:.3f} accuracy={test_epoch_accuracy:.3f}')
    return train_losses, test_losses, train_accuracy,
        test_accuracy
  1. 在 20 个轮次中训练模型,重用我们刚刚实现的函数。在每个轮次中计算并存储训练集和测试集的准确率与损失,用于可视化目的:

    train_losses, test_losses, train_accuracy, test_accuracy = train_IMDB_classification(model,
    
        train_dataloader, test_dataloader, criterion,
    
        device, epochs=20)
    

在 20 个轮次后,结果应该接近以下代码输出:

[epoch 20] Training: loss=0.040 accuracy=0.991 |  Test: loss=1.155 accuracy=0.751
  1. 将损失绘制为轮次数的函数,分别针对训练集和测试集:

    plt.plot(train_losses, label='train')
    
    plt.plot(test_losses, label='test')
    
    plt.xlabel('epoch') plt.ylabel('loss (BCE)')
    
    plt.legend() plt.show()
    

然后我们得到这个图表:

图 8.7 – 以轮次为函数的二元交叉熵损失

图 8.7 – 以轮次为函数的二元交叉熵损失

如我们所见,测试集的损失明显发散,这意味着在仅仅几个轮次后,模型已经出现过拟合。

  1. 同样,将准确率绘制为轮次数的函数,分别针对训练集和测试集:

    plt.plot(train_accuracy, label='train')
    
    plt.plot(test_accuracy, label='test')
    
    plt.xlabel('epoch') plt.ylabel('Accuracy')
    
    plt.legend() plt.show()
    

这是得到的图表:

图 8.8 – 以轮次为函数的准确率

图 8.8 – 以轮次为函数的准确率

与损失一样,我们可以看到模型在训练集上的准确率接近 100%,但在测试集上的最高准确率仅为 77%,这表明我们面临过拟合问题。

顺便提一下,如果你亲自尝试这份配方和前一份配方,你可能会发现 GRU 在结果上要稳定得多,而前一份配方中的 RNN 有时会在收敛时遇到困难。

还有更多……

在处理诸如文本、时间序列和音频等顺序数据时,RNNs 是常用的。虽然由于其局限性,简单的 RNNs 不常用,但 GRUs 通常是更好的选择。除了简单的 RNN 和 GRU,另一个常用的单元类型是 长短期记忆 单元,通常称为 LSTM

LSTM 的单元比 GRU 的单元更为复杂。虽然 GRU 单元有一个隐藏状态和两个门,但 LSTM 单元有两种隐藏状态(隐藏状态和单元状态)和三个门。现在我们来快速看一下。

LSTM 的单元状态如 图 8**.9 所示:

图 8.9 – LSTM 单元的示意图,假设 LSTM 激活函数为 tanh,输出层激活函数为 softmax

图 8.9 – LSTM 单元的示意图,假设 LSTM 激活函数为 tanh,输出层激活函数为 softmax。

不涉及 LSTM 的所有计算细节,从 图 8**.9 可以看出,那里有三个门,基于前一个隐藏状态 和当前特征 计算,和 GRU 一样,使用 sigmoid 激活函数:

  • 忘记门

  • 更新门

  • 输出门

每个步骤还会计算两个状态:

  • 一个单元状态

  • 一个隐藏状态

在这里,中间状态 是通过其自身的权重集计算的,类似于门控,且具有自由的激活函数。

由于有更多的门和状态,LSTM 的参数比 GRU 多,因此通常需要更多的数据才能正确训练。然而,它们在处理长序列时被证明非常有效,例如长文本。

使用 PyTorch,训练 LSTM 的代码与训练 GRU 的代码没有太大区别。在这个实例中,唯一需要更改的代码部分是模型实现,例如,替换为以下代码:

class LSTMClassifier(nn.Module):
    def __init__(self, vocab_size, embedding_dim,
        hidden_size, output_size, num_layers=3):
            super(LSTMClassifier, self).__init__()
            self.hidden_size = hidden_size
            self.num_layers = num_layers
            self.embedding = nn.Embedding(
                num_embeddings=vocab_size,
                embedding_dim=embedding_dim)
                self.lstm = nn.LSTM(
                    input_size=embedding_dim,
                    hidden_size=hidden_size,
                    num_layers=num_layers,
                    batch_first=True)
            self.fc = nn.Linear(hidden_size, output_size)
    def forward(self, inputs):
        batch_size = inputs.size(0)
        h_0 = torch.zeros(self.num_layers, batch_size,
            self.hidden_size)
        c_0 = torch.zeros(self.num_layers, batch_size,
            self.hidden_size)
        embedded = self.embedding(inputs)
        output,
        (final_hidden_state, final_cell_state) = self.lstm(
            embedded, (h_0, c_0))
        output = torch.softmax(self.fc(output[:, -1]),
            dim=1)
        return output

与之前实现的 GRUClassifier 的主要区别如下:

  • init 中:当然,使用 nn.LSTM 代替 nn.GRU,因为我们现在想要一个基于 LSTM 的分类器。

  • forward 中:我们现在初始化两个零向量,h0c0,它们被输入到 LSTM 中。

  • LSTM 的输出现在由输出以及隐藏状态和单元状态组成。

除此之外,它可以像 GRU 一样进行训练,使用相同的代码。

在对比说明中,让我们计算此 LSTM 中的参数数量,并与“等效”的 RNN 和 GRU 的参数数量进行比较(即相同的隐藏维度,相同的层数,等等)。

此 LSTM 中的参数数量可以通过以下代码计算:

sum(p.numel() for p in list(
    model.parameters())[1:] if p.requires_grad)

重要提示

请注意,我们没有考虑嵌入部分,因为我们忽略了第一层。

以下是每种模型的参数数量:

  • RNN:6,369

  • GRU:19,041

  • LSTM:25,377

解释这个的经验法则是门的数量。与简单的 RNN 相比,GRU 有两个额外的门,每个门都需要自己的权重,因此总的参数数量是原来的三倍。LSTM 同样适用这个逻辑,它有三个门。

总的来说,模型包含的参数越多,它需要的训练数据就越多,以确保其鲁棒性,这也是为什么 GRU 是一种很好的折中方案,通常是一个不错的首选。

重要提示

到目前为止,我们只假设 GRU(以及 RNN 一般)是从左到右运行——从句子的开始到句子的结尾。只是因为我们人类在阅读时通常是这么做的,并不意味着这是神经网络学习的最优方式。实际上,可以使用双向 RNN,也就是在模型定义时使用bidirectional=True,例如nn.GRU(bidirectional=True)

另请参见

使用 dropout 进行正则化

在这个食谱中,我们将向 GRU 中添加 dropout,以便在 IMDb 分类数据集上增加正则化。

准备工作

就像全连接神经网络一样,GRU 和 LSTM 等递归神经网络也可以通过 dropout 进行训练。作为提醒,dropout 就是在训练期间随机将某些单元的激活值设置为零。这样,它可以让网络一次性处理更少的信息,并希望能更好地泛化。

我们将通过在相同任务——IMDb 数据集二分类——上使用 dropout,来改进 GRU 训练食谱的结果。

如果尚未完成,可以使用以下命令行通过 Kaggle API 下载数据集:

kaggle datasets download -d lakshmi25npathi/imdb-dataset-of-50k-moviereviews --unzip

可以使用以下命令安装所需的库:

pip install pandas numpy scikit-learn matplotlib torch transformers

如何操作…

以下是执行这个食谱的步骤:

  1. 我们将在 IMDb 数据集上训练 GRU,就像在训练 GRU食谱中一样。由于训练 GRU的前五个步骤(从导入到DataLoaders实例化)在这个食谱中是通用的,因此我们假设它们已经执行完毕,直接开始实现模型类。实现 GRU 分类器模型,它由以下元素组成:

    • 一个嵌入层(以零向量作为第一个输入),在前向传播中对其应用 dropout

    • 三层 GRU,dropout 直接作为参数传递给 GRU 构造函数

    • 在最后一个序列步骤上添加一个全连接层,使用 sigmoid 激活函数,且不应用 dropout:

      # Define GRU model
      
      class GRUClassifier(nn.Module):
      
          def __init__(self, vocab_size, embedding_dim,
      
              hidden_size, output_size, num_layers=3,
      
              dropout=0.25):
      
                  super(GRUClassifier, self).__init__()
      
                  self.num_layers = num_layers
      
                  self.hidden_size = hidden_size
      
                  self.embedding = nn.Embedding(
      
                      num_embeddings=vocab_size,
      
                      embedding_dim=embedding_dim)
      
                  self.dropout = nn.Dropout(dropout)
      
                  self.gru = nn.GRU(
      
                      input_size=embedding_dim,
      
                      hidden_size=hidden_size,
      
                      num_layers=num_layers,
      
                      batch_first=True, dropout=dropout)
      
                  self.fc = nn.Linear(hidden_size,
      
                      output_size)
      
          def forward(self, inputs):
      
              batch_size = inputs.size(0)
      
              zero_hidden = torch.zeros(self.num_layers,
      
                  batch_size, self.hidden_size).to(device)
      
              embedded = self.dropout(
      
                  self.embedding(inputs))
      
              output, hidden = self.gru(embedded,
      
                  zero_hidden)
      
              output = torch.sigmoid(self.fc(output[:, -1]))
      
              return output
      

重要说明

并不是强制要求在嵌入层应用 dropout,也并非总是有用。在这种情况下,由于嵌入层占模型的大部分,仅在 GRU 层应用 dropout 对性能不会产生显著影响。

  1. 实例化 GRU 模型,嵌入维度和隐藏维度均为 32

    vocab_size = train_dataset.tokenizer.vocab_size
    
    embedding_dim = 32 hidden_dim = 32 output_size = 1
    
    # Optionally, set the device to GPU if you have one
    
    device = torch.device(
    
        'cuda' if torch.cuda.is_available() else 'cpu')
    
    model = GRUClassifier(
    
        vocab_size=vocab_size,
    
        embedding_dim=embedding_dim,
    
        hidden_size=hidden_dim,
    
         output_size=output_size, ).to(device)
    

实例化 Adam 优化器,学习率为 0.001。由于这是一个二分类任务,损失函数定义为二元交叉熵损失:

optimizer = optim.Adam(model.parameters(), lr=0.001)
criterion = nn.BCELoss()
  1. 通过重新使用前一个食谱中实现的辅助函数,在 20 个轮次内训练模型。每个轮次中,我们会计算并存储训练集和测试集的准确率和损失值:

    train_losses, test_losses, train_accuracy, 
    
    test_accuracy = train_IMDB_classification(model,
    
        train_dataloader, test_dataloader, criterion,
    
        device, epochs=20)
    

最后一轮的输出应如下所示:

[epoch 20] Training: loss=0.248 accuracy=0.896 |  Test: loss=0.550 accuracy=0.785
  1. 绘制损失函数与轮次数的关系图,分别针对训练集和测试集:

    plt.plot(train_losses, label='train')
    
    plt.plot(test_losses, label='test')
    
    plt.xlabel('epoch') plt.ylabel('loss (BCE)')
    
    plt.legend() plt.show()
    

这是输出结果:

图 8.10 – 二元交叉熵损失与轮次数的关系

图 8.10 – 二元交叉熵损失与轮次数的关系

我们可以看到,尽管我们仍然存在过拟合,但比没有应用 dropout 时要轻微一些。

  1. 最后,绘制训练集和测试集准确率与轮次数的关系图:

    plt.plot(train_accuracy, label='train')
    
    plt.plot(test_accuracy, label='test')
    
    plt.xlabel('epoch') plt.ylabel('Accuracy')
    
    plt.legend() plt.show()
    

这是输出结果:

图 8.11 – 准确率与轮次数的关系

图 8.11 – 准确率与轮次数的关系

请注意 dropout 对训练集准确率的影响。

尽管准确率没有显著提高,但它已从 77% 增加到 79%,且使用 dropout 后训练集和测试集的损失差距小于没有 dropout 时,从而提高了泛化能力。

还有更多…

与 dropout 不同,其他有助于正则化全连接神经网络的方法也可以与 GRU 和其他基于 RNN 的架构一起使用。

例如,由于我们在这里出现了明显的过拟合,训练损失有急剧下降,因此测试较小的架构可能会很有趣,使用较少的参数进行学习。

使用最大序列长度进行正则化

在本食谱中,我们将通过调整最大序列长度来进行正则化,使用基于 GRU 的神经网络处理 IMDB 数据集。

做好准备

到目前为止,我们没有太多调整序列的最大长度,但它有时是最重要的超参数之一。

事实上,根据输入数据集的不同,最佳的最大长度可能会有很大差异:

  • 推文很短,因此大多数情况下,最大令牌数设置为几百没有意义。

  • 产品或电影评论通常会更长,有时评论者会写很多关于产品/电影的优缺点,然后才给出最终结论——在这种情况下,较大的最大长度可能会有所帮助。

在这个配方中,我们将在 IMDB 数据集上训练一个 GRU,该数据集包含电影评论和相应的标签(正面或负面);该数据集包含一些非常长的文本。因此,我们将大幅增加单词的最大数量,并查看它对最终精度的影响。

如果尚未完成,你可以下载数据集,假设你已安装 Kaggle API,可以运行以下命令行:

kaggle datasets download -d lakshmi25npathi/imdb-dataset-of-50k-moviereviews --unzip

以下库是必须的:pandasnumpyscikit-learnmatplotlibtorchtransformers。它们可以通过以下命令行安装:

pip install pandas numpy scikit-learn matplotlib torch transformers

如何做到…

以下是执行此配方的步骤:

  1. 这个配方大部分与训练 GRU配方在 IMDB 数据集上的操作相同;唯一的区别是序列的最大长度。由于最显著的区别是序列长度值和结果,我们将假设训练 GRU的前四个步骤(从导入到数据集实现)已完成,并将重用一些代码。实例化训练集和测试集的TextClassificationDataset对象(重用在训练 GRU中实现的类),以及相关的数据加载器。

这次,我们选择了一个最大单词数为256,显著高于之前的64。我们将保持批处理大小为64

batch_size = 64 max_words = 256
# Initialize datasets and dataloaders
Train_dataset = TextClassificationDataset(train_data,
    max_words)
test_dataset = TextClassificationDataset(test_data,
    max_words)
train_dataloader = DataLoader(train_dataset,
    batch_size=batch_size, shuffle=True)
test_dataloader = DataLoader(test_dataset,
    batch_size=batch_size, shuffle=True)
  1. 通过重用在训练 GRU中实现的GRUClassifier类来实例化 GRU 模型,嵌入维度和隐藏维度为32

    vocab_size = train_dataset.tokenizer.vocab_size
    
    embedding_dim = 32
    
    hidden_dim = 32
    
    output_size = 1
    
    # Optionally, set the device to GPU if you have one
    
    device = torch.device(
    
        'cuda' if torch.cuda.is_available() else 'cpu')
    
    model = GRUClassifier(
    
        vocab_size=vocab_size,
    
        embedding_dim=embedding_dim,
    
        hidden_size=hidden_dim,
    
        output_size=output_size, ).to(device)
    

将优化器实例化为 Adam 优化器,学习率为0.001。损失函数定义为二元交叉熵损失,因为这是一个二分类任务:

optimizer = optim.Adam(model.parameters(), lr=0.001)
criterion = nn.BCELoss()
  1. 在 20 个 epoch 内训练模型,重用在训练 GRU配方中实现的train_IMDB_classification辅助函数;存储每个 epoch 的训练集和测试集的精度与损失:

    train_losses, test_losses, train_accuracy, 
    
    test_accuracy = train_IMDB_classification(model,
    
        train_dataloader, test_dataloader, criterion,
    
        device, epochs=20)
    

经过 20 个 epoch 后,输出结果如下:

[epoch 20] Training: loss=0.022 accuracy=0.995 |  Test: loss=0.640 accuracy=0.859
  1. 绘制训练集和测试集的损失随 epoch 数量变化的曲线:

    plt.plot(train_losses, label='train')
    
    plt.plot(test_losses, label='test')
    
    plt.xlabel('epoch') plt.ylabel('loss (BCE)')
    
    plt.legend() plt.show()
    

这是输出结果:

图 8.12 – 二元交叉熵损失与 epoch 的关系

图 8.12 – 二元交叉熵损失与 epoch 的关系

我们可以看到,在仅仅几个 epoch 后就出现了过拟合现象。

  1. 最后,绘制训练集和测试集的精度随 epoch 数量变化的曲线:

    plt.plot(train_accuracy, label='train')
    
    plt.plot(test_accuracy, label='test')
    
    plt.xlabel('epoch') plt.ylabel('Accuracy')
    
    plt.legend() plt.show()
    

这是我们得到的结果:

图 8.13 – 精度随 epoch 的关系

图 8.13 – 精度随 epoch 的关系

测试精度在几个 epoch 后达到最大值,然后缓慢下降。

尽管仍然存在较大的过拟合效应,但与最大长度为 64 的训练相比,测试精度从最大 77%提升到了最大 87%,这是一个显著的改善。

还有更多内容…

与其盲目选择最大数量的标记,不如先快速分析一下文本长度的分布。

对于相对较小的数据集,计算所有样本的长度非常简单;让我们用以下代码来实现:

tokenizer = AutoTokenizer.from_pretrained('bert-base-uncased')
review_lengths = [len(tokens) for tokens in tokenizer(
    train_data['review'].to_list())['input_ids']]

现在我们可以使用对数尺度绘制评论长度的分布直方图:

plt.hist(review_lengths, bins=50, log=True)
plt.xlabel('Review length (#tokens)') plt.show()

这是输出结果:

图 8.14 – IMDb 数据集评论长度的直方图,采用对数尺度

图 8.14 – IMDb 数据集评论长度的直方图,采用对数尺度

我们可以看到大约 300 个词汇长度的峰值,几乎没有评论超过 1,500 个词汇。正如从直方图中看到的,大多数评论的长度似乎在几百个词汇左右。我们也可以计算出平均长度和中位数:

print('Average length:', np.mean(review_lengths))
print('Median length:', np.median(review_lengths))

计算得出的平均值和中位数如下:

Average length: 309.757075 Median length: 231.0

结果是,平均长度大约为 309,中位数长度为 231。根据这些信息,如果计算能力允许,并且取决于任务,选择最大长度为 256 似乎是一个不错的初步选择。

第九章:自然语言处理中的高级正则化

关于 自然语言处理NLP)的正则化可以写成一本完整的书。NLP 是一个广泛的领域,涵盖了许多主题,从简单的分类任务(如评论排序)到复杂的模型和解决方案(如 ChatGPT)。本章仅会略微触及使用简单 NLP 解决方案(如分类)能够合理完成的内容。

本章将涵盖以下内容:

  • 使用 word2vec 嵌入的正则化

  • 使用 word2vec 的数据增强

  • 使用预训练模型进行零-shot 推理

  • 使用 BERT 嵌入的正则化

  • 使用 GPT-3 的数据增强

到本章结束时,你将能够利用高级方法处理 NLP 任务,如词嵌入和 transformers,并能使用数据增强生成合成训练数据。

技术要求

本章将使用各种 NLP 解决方案和工具,因此我们需要以下库:

  • NumPy

  • pandas

  • scikit-learn

  • Matplotlib

  • Gensim

  • NLTK

  • PyTorch

  • Transformers

  • OpenAI

使用 word2vec 嵌入的正则化

在本节中,我们将使用预训练的 word2vec 嵌入,借助迁移学习来提高任务的结果。我们将结果与 第八章中的 训练 GRU 任务进行比较,数据集为 IMDb 的评论分类。

准备工作

word2vec 是自然语言处理(NLP)领域中一种相对较旧的词嵌入方法,已广泛应用于许多 NLP 任务。尽管近期的技术有时更强大,但 word2vec 方法仍然高效且具有成本效益。

不深入讨论 word2vec 的细节,一个常用的模型是 300 维的嵌入;词汇表中的每个单词都会被嵌入到一个包含 300 个值的向量中。

word2vec 通常在大规模的文本语料库上进行训练。训练 word2vec 的主要方法有两种,基本可以描述如下:

  • 连续词袋模型CBOW):使用句子中周围词的上下文来预测缺失的词

  • skip-gram:使用一个词来预测其周围的上下文

这两种方法的示例见 图 9.1

图 9.1 – CBOW(左)和 skip-gram(右)方法的训练数据示例

图 9.1 – CBOW(左)和 skip-gram(右)方法的训练数据示例

注意

实践中,CBOW 通常更容易训练,而 skip-gram 对稀有词的表现可能更好。

目标不是训练我们自己的 word2vec,而是简单地重用一个已经训练好的模型,并利用迁移学习来提升我们预测的性能。在这个步骤中,我们将不再训练自己的嵌入,而是直接重用一个预训练的 word2vec 嵌入,然后只在这些嵌入的基础上训练我们的 GRU。

为此,我们将再次进行 IMDb 数据集分类任务:这是一个包含电影评论文本作为输入和相关二进制标签(正面或负面)的数据集。可以通过 Kaggle API 下载此数据集:

kaggle datasets download -d lakshmi25npathi/imdb-dataset-of-50k-moviereviews --unzip

以下命令将安装所需的库:

pip install pandas numpy scikit-learn matplotlib torch gensim nltk

如何操作…

在这个食谱中,我们将训练一个 GRU 模型,用于在 IMDb 评论数据集上进行二分类。与原始食谱相比,主要的区别在于第 5 步

  1. 导入以下必要的库:

    • torch及其相关模块和类,用于神经网络

    • 使用来自scikit-learntrain_test_splitLabelEncoder进行预处理

    • 使用来自transformersAutoTokenizer来标记化评论

    • pandas用于加载数据集

    • numpy用于数据处理

    • matplotlib用于可视化

    • 使用gensim进行 word2vec 嵌入,使用nltk进行文本标记化处理

如果你还没有这样做,你需要添加nltk.download('punkt')这一行,以下载一些必要的工具实例,如下所示:

import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader, Dataset
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import LabelEncoder
import pandas as pd
import matplotlib.pyplot as plt
import numpy as np
import gensim.downloader
import nltk
# If running for the first time nltk.download('punkt')
  1. 加载预训练的 word2vec 模型,该模型包含 300 维的嵌入。该模型大约有 1.6GB,下载可能需要一些时间,具体取决于你的带宽:

    # Will take a while the first time, need to download about 1.6GB of the model
    
    word2vec_model = gensim.downloader.load('
    
        word2vec-google-news-300')
    
  2. 使用pandas从 CSV 文件加载数据:

    # Load data data = pd.read_csv('IMDB Dataset.csv')
    
  3. 使用train_test_split函数将数据拆分为训练集和测试集,测试集大小为 20%,并指定随机状态以确保可复现性:

    # Split data into train and test sets train_data, 
    
        test_data = train_test_split(data, test_size=0.2,
    
            random_state=0)
    
  4. 实现数据集的TextClassificationDataset类,它处理数据。此处计算 word2vec 嵌入:

    # Define dataset class
    
    class TextClassificationDataset(Dataset):
    
        def __init__(self, data, word2vec_model,
    
            max_words):
    
            self.data = data
    
            self.word2vec_model = word2vec_model
    
            self.max_words = max_words
    
            self.embeddings = data['review'].apply(
    
                self.embed)
    
            le = LabelEncoder()
    
            self.labels = torch.tensor(le.fit_transform(
    
                data['sentiment']).astype(np.float32))
    
        def __len__(self):
    
            return len(self.data)
    
        def __getitem__(self, index):
    
            return self.embeddings.iloc[index],
    
                self.labels[index]
    
        def embed(self, text):
    
            tokens = nltk.word_tokenize(text)
    
            return self.tokens_to_embeddings(tokens)
    
        def tokens_to_embeddings(self, tokens):
    
            embeddings = []
    
            for i, token in enumerate(tokens):
    
                if i >= self.max_words:
    
                    break
    
                if token not in self.word2vec_model:
    
                    continue
    
                embeddings.append(
    
                    self.word2vec_model[token])
    
            while len(embeddings) < self.max_words:
    
                embeddings.append(np.zeros((300, )))
    
            return np.array(embeddings, dtype=np.float32)
    

在实例化时,每个输入电影通过embed方法以两种方式转换为嵌入:

  • 每个电影评论都通过一个单词标记器进行标记化(基本上是将句子分割为单词)。

  • 然后,计算一个max_words长度的向量,包含评论中前max_words个单词的 word2vec 嵌入。如果评论少于max_words个单词,则使用零填充该向量。

  1. 然后,我们必须为训练集和测试集实例化TextClassificationDataset对象,以及相关的数据加载器。最大单词数设置为64,批处理大小也设置为:

    batch_size = 64 max_words = 64
    
    # Initialize datasets and dataloaders
    
    Train_dataset = TextClassificationDataset(train_data,
    
        word2vec_model, max_words)
    
    test_dataset = TextClassificationDataset(test_data,
    
        word2vec_model, max_words)
    
    train_dataloader = DataLoader(train_dataset,
    
        batch_size=batch_size, shuffle=True)
    
    test_dataloader = DataLoader(test_dataset,
    
        batch_size=batch_size, shuffle=True)
    
  2. 然后,我们必须实现 GRU 分类模型。由于嵌入是在数据加载步骤中计算的,因此该模型直接计算一个三层 GRU,并随后应用一个带有 sigmoid 激活函数的全连接层:

    # Define RNN model
    
    class GRUClassifier(nn.Module):
    
        def __init__(self, embedding_dim, hidden_size,
    
            output_size, num_layers=3):
    
                super(GRUClassifier, self).__init__()
    
                self.hidden_size = hidden_size
    
                self.num_layers = num_layers
    
                self.gru = nn.GRU(
    
                    input_size=embedding_dim,
    
                    hidden_size=hidden_size,
    
                    num_layers=num_layers,
    
                    batch_first=True)
    
            self.fc = nn.Linear(hidden_size, output_size)
    
        def forward(self, inputs):
    
            batch_size = inputs.size(0)
    
            zero_hidden = torch.zeros(self.num_layers,
    
                batch_size, self.hidden_size).to(device)
    
            output, hidden = self.gru(inputs, zero_hidden)
    
            output = torch.sigmoid(self.fc(output[:, -1]))
    
            return output
    
  3. 接下来,我们必须实例化 GRU 模型。由 word2vec 模型定义的嵌入维度为300。我们选择了32作为隐藏维度,因此每个 GRU 层由 32 个单元组成:

    embedding_dim = 300
    
    hidden_dim = 32
    
    output_size = 1
    
    # Optionally, set the device to GPU if you have one device = torch.device(
    
        'cuda' if torch.cuda.is_available() else 'cpu')
    
    model = GRUClassifier(
    
        embedding_dim=ebedding_dim,
    
        hidden_siz=hidden_dim,
    
        output_size=output_size, ).to(device)
    
  4. 然后,我们必须实例化优化器为Adam优化器,学习率为0.001;损失定义为二元交叉熵损失,因为这是一个二分类任务:

    optimizer = optim.Adam(model.parameters(), lr=0.001)
    
    criterion = nn.BCELoss()
    
  5. 使用 train_model 函数训练模型 20 个 epoch,并在每个 epoch 存储训练集和测试集的损失和准确性。train_model 函数的实现可以在本书的 GitHub 仓库中找到:github.com/PacktPublishing/The-Regularization-Cookbook/blob/main/chapter_09/chapter_09.ipynb

    train_losses, test_losses, train_accuracy, 
    
    test_accuracy = train_model(
    
        model, train_dataloader, test_dataloader,
    
        criterion, optimizer, device, epochs=20)
    

这是 20 个 epoch 后的典型输出:

[epoch 20] Training: loss=0.207 accuracy=0.917 |  Test: loss=0.533 accuracy=0.790
  1. 绘制训练集和测试集的 BCE 损失图:

    plt.plot(train_losses, label='train')
    
    plt.plot(testlosse, label=''test'')
    
    plt.xlabel('epoch') plt.ylabel('loss (BCE)')
    
    plt.legend() plt.show()
    

这是它的绘制结果:

图 9.2 – 二元交叉熵损失随 epoch 变化

图 9.2 – 二元交叉熵损失随 epoch 变化

正如我们所见,虽然训练损失在 20 个 epoch 中持续减少,但测试损失在约 5 个 epoch 后达到了最小值,然后开始增加,表明出现了过拟合。

  1. 绘制训练集和测试集的准确性图:

    plt.plot(train_accuracy, label='train')
    
    plt.plot(testaccurcy, label=''test'')
    
    plt.xlabel('epoch') plt.ylabel('Accuracy')
    
    plt.legend() plt.show()
    

这是该图的绘制结果:

图 9.3 – 准确度随 epoch 变化

图 9.3 – 准确度随 epoch 变化

正如损失所示,训练集的准确性不断提高。对于测试集,它的最大值约为 81%(相比于前一章中的 77%,未使用 word2vec 嵌入)。word2vec 嵌入使我们略微提高了结果,尽管如果我们调整其他超参数,结果可能会有更大改善。

还有更多…

虽然我们在本教程中将嵌入作为数组使用,但它们也可以以不同的方式使用;例如,我们可以使用句子中所有嵌入的平均值或其他统计信息。

此外,尽管 word2vec 在许多情况下已经表现得足够好,但可以通过更专业的方法,如 doc2vec,来推导一些嵌入,doc2vec 对文档和长文本的处理有时更为强大。

另见

关于 word2vec 的维基百科文章是一个有价值的资源,因为它列出了许多相关的出版物:en.wikipedia.org/wiki/Word2vec#cite_note-:1-3

来自 Google 的文档也很有用:code.google.com/archive/p/word2vec/

使用 word2vec 进行数据增强

正则化模型并提高性能的一种方法是拥有更多的数据。收集数据并不总是容易或可能的,但合成数据可以是一种负担得起的提高性能的方式。我们将在本教程中做到这一点。

准备工作

使用 word2vec 嵌入,你可以生成具有相似语义的新合成数据。通过这种方式,对于给定的单词,可以很容易地找到词汇表中最相似的词。

在本教程中,使用 word2vec 和一些参数,我们将看到如何生成具有相似语义的新句子。我们仅将其应用于给定的句子作为示例,并提出如何将其集成到完整的训练流程中。

所需的库仅为numpygensim,这两个库都可以通过pip install numpy gensim安装。

如何做……

完成此配方的步骤如下:

  1. 第一步是导入必要的库——numpy用于随机调用,gensim用于加载 word2vec 模型:

    import numpy as np
    
    import gensim.downloader
    
  2. 加载一个预训练的 word2vec 模型。如果模型尚未下载并存储在本地缓存中,这可能需要一些时间。此外,这是一个相当大的模型,因此加载到内存中可能需要一些时间:

    # Load the Word2Vec model
    
    word2vec_model = gensim.downloader.load(
    
        'word2vec-google-news-300')
    
  3. 实现replace_words_with_similar函数,以便你可以随机地将word替换为另一个语义相近的词:

    def replace_words_with_similar(text, model,
    
        sim_threshold: float = 0.5,
    
        probability: float = 0.5,
    
        top_similar: int = 3,
    
        stop_words: list[str] = []):
    
        # Split in words
    
        words = text.split()
    
        # Create an empty list of the output words
    
        new_words = []
    
        # Loop over the words
    
        for word in words:
    
            added = False
    
            # If the word is in the vocab, not in stop words, and above probability, then...
    
            if word in model and word not in stop_words and np.random.uniform(0, 1) > probability:
    
                # Get the top_similar most similar words
    
                similar_words = model.most_similar(word,
    
                    topn=top_similar)
    
                # Randomly pick one of those words
    
                idx = np.random.randint(len(similar_words))
    
                # Get the similar word and similarity score
    
                sim_word, sim_score = similar_words[idx]
    
                # If the similary score is above threshold, add the word
    
                if sim_score > sim_threshold:
    
                    new_words.append(sim_word)
    
                    added = True
    
            if not added:
    
                # If no similar word is added, add the original word
    
                new_words.append(word)
    
        # Return the list as a string
    
        return ' '.join(new_words)
    

希望注释已经不言自明,但以下是该函数的作用:

  • 它通过使用简单的分割将输入文本拆分成单词(也可以使用词法分析器)。

  • 对每个词,它检查以下内容:

    • 如果该词在 word2vec 词汇表中

    • 如果该词不在停用词列表中(待定义)

    • 如果随机概率高于阈值概率(用于抽取随机词)

  • 如果一个词符合前面的检查,则计算如下:

    • top_similar 最相似的词

    • 从这些词中随机选择一个

    • 如果该词的相似度得分超过给定阈值,则将其添加到输出句子中

  • 如果没有添加更新的词,就直接添加原始词,以使整体句子保持逻辑通顺

参数如下:

  • sim_threshold:相似度阈值

  • probability:词被替换为相似词的概率

  • top_similar:计算给定词的相似词的数量

  • stop_words:一个不应被替换的词列表,以防某些词特别重要或有多重含义

  1. 将我们刚刚实现的replace_words_with_similar函数应用于给定的句子:

    original_text = "The quick brown fox jumps over the lazy dog"
    
    generated_text = replace_words_with_similar(
    
        original_text, word2vec_model, top_words=['the'])
    
    print(""Original text: {}"".format(original_text))
    
    print("New text: {}".format(generated_text))
    

代码输出如下。这允许我们在保持整体意思不变的情况下改变一些词:

Original text: The quick brown fox jumps over the lazy dog New text: This quick brown squirrel jumps Over the lazy puppy

借助这种数据增强技术,能够生成更多样化的数据,从而使我们能够使模型更强大并进行正则化。

还有更多内容……

向分类任务添加数据生成功能的一种方法是在数据加载步骤中添加它。这将实时生成合成数据,并可能允许我们对模型进行正则化。它可以被添加到数据集类中,如下所示:

class TextClassificationDatasetGeneration(Dataset):
    def __init__(self, data, max_length):
        self.data = data
        self.max_length = max_length
        self.tokenizer = AutoTokenizer.from_pretrained(
            'bert-base-uncased')
        self.tokens = self.tokenizer(
            data['review'].to_list(), padding=True,
            truncation=True, max_length=max_length,
            return_tensors='pt')['input_ids']
        le = LabelEncoder()
        self.labels = torch.tensor(le.fit_transform(
            data['sentiment']).astype(np.float32))
    def __len__(self):
        return len(self.data)
    def __getitem__(self, index):
        # Generate a new text
        text = replace_words_with_similar(
            self.data['review'].iloc[index])
        # Tokenize it
        tokens = self.tokenizer(text, padding=True,
            truncation=True, max_length=self.max_length,
            return_tensors='pt')['input_ids']
        return self.tokens[index], self.labels[index]

另见

有关 word2vec 模型的most_similar函数的文档可以在tedboy.github.io/nlps/generated/generated/gensim.models.Word2Vec.most_similar.xhtml找到。

使用预训练模型进行零样本推理

在过去几年中,NLP 领域经历了许多重大进展,这意味着许多预训练的高效模型可以重复使用。这些预训练的、免费提供的模型使我们能够以零样本推理的方式处理一些 NLP 任务,因为我们可以重复使用这些模型。我们将在本配方中尝试这种方法。

注意

我们有时会使用零-shot 推理(或零-shot 学习)和少-shot 学习。零-shot 学习意味着在没有针对特定任务的任何训练的情况下完成任务;少-shot 学习则意味着在仅用少量样本进行训练的情况下完成任务。

零-shot 推理是指在没有任何微调的情况下重用预训练模型。许多非常强大的、可以自由使用的模型已经可以做到和我们自己训练的模型一样好。由于这些可用模型是在庞大的数据集上训练的,且拥有巨大的计算能力,因此有时很难与我们自己训练的模型竞争,因为我们自己训练的模型可能使用的数据更少,计算能力也较低。

注意

话虽如此,有时候,在小而精心策划、特定任务的数据上进行训练也能产生奇迹,并提供更好的性能。这完全取决于上下文。

此外,我们有时会遇到没有标签的数据,因此监督学习就不可行。在这种情况下,我们自己为数据中的一个小子集标注标签,并针对这些数据评估零-shot 方法可能会有用。

准备开始

在这个配方中,我们将重用在 Tweets 数据集上预训练的模型,并将推文分类为负面、中性或正面。由于不需要训练,我们将直接在测试集上评估该模型,以便与我们在第八章中的训练 RNN配方中获得的结果进行比较。

为此,我们需要将数据集下载到本地。可以通过 Kaggle API 下载,然后使用以下命令解压:

kaggle datasets download -d crowdflower/twitter-airline-sentiment --unzip

运行此配方所需的库可以通过以下命令安装:

pip install pandas scikit-learn transformers

如何操作……

这是执行此配方的步骤:

  1. 导入以下必要的函数和模型:

    • 使用numpy进行数据处理

    • 使用pandas加载数据

    • 使用scikit-learntrain_test_split来拆分数据集

    • 使用scikit-learnaccuracy_score来计算准确度评分

    • 使用transformerspipeline来实例化零-shot 分类器

下面是这段代码:

import numpy as np
import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score
from transformers import pipeline
  1. 加载数据集。在我们的例子中,唯一感兴趣的列是text(特征)和airline_sentiment(标签):

    # Load dat
    
    Data = pd.read_csv(''Tweets.csv'')
    
    data[['airline_sentiment', 'text']].head()
    

下面是这段代码的输出:

图 9.4 – 所考虑列的数据集的前五行

图 9.4 – 所考虑列的数据集的前五行

  1. 将数据拆分为训练集和测试集,使用与使用 word2vec 嵌入的正则化配方中相同的参数,以便可以进行比较:test_size设置为0.2random_state设置为0。由于不需要训练,我们只会使用测试集:

    # Split data into train and test sets
    
    Train_data, test_data = train_test_split(data,
    
        test_size=0.2, random_state=0)
    
  2. 使用以下参数通过transformers管道实例化分类器:

    • task="zero-shot-classification":这将实例化一个零-shot 分类管道

    • model="facebook/bart-large-mnli":这将指定用于该管道的模型

下面是这段代码:

# Taking a long time first time for downloading odel...
Classifier = pipeline(task=""zero-shot-classification"",
    model="facebook/bart-large-mnli")

注意

当首次调用时,它可能会下载一些文件以及模型本身,可能需要一些时间。

  1. 将候选标签存储在数组中。这些候选标签是零-shot 分类所需的:

    candidate_labels = data['airline_sentiment'].unique()
    
  2. 在测试集上计算预测结果并将其存储在数组中:

    # Create an empty list to store the predictions
    
    preds = [] # Loop over the data
    
    for i in range(len(test_data)):
    
        # Compute the classifier results
    
        res = classifier(
    
            test_data['text'].iloc[i],
    
            candidate_labels=candidate_labels,
    
        )
    
        # Apply softmax to the results to get the predicted class
    
        pred = np.array(res['scores']).argmax()
    
        labels = res['labels']
    
        # Store the results in the list
    
        preds.append(labels[pred])
    

请参考更多内容...部分,了解分类器的功能以及它的输出。

  1. 计算预测的准确率:

    print(accuracy_score(test_data['airline_sentiment'], preds))
    

计算出的准确率如下:

0.7452725250278087

我们得到了 74.5%的准确率,这与我们在第八章中使用简单 RNN 训练后的结果相当。通过这种零-shot 分类,我们无需任何训练成本和大规模的标注数据集,就能获得相同的性能。

注意

零-shot 学习需要付出代价,因为预训练的语言模型通常相当庞大,且可能需要较大的计算能力来大规模运行。

更多内容…

让我们看一个classifier的输入和输出示例,以更好地理解它的工作原理:

res = classifier(
    'I love to learn about regularization',
    candidate_labels=['positive', 'negative', 'neutral'], )
print(res)

这段代码的输出如下:

{'sequence': 'I love to learn about regularization',  'labels': ['positive', 'neutral', 'negative'],  'scores': [0.6277033686637878, 0.27620458602905273, 0.09609206020832062]}

我们可以看到以下内容:

  • 一个输入句子:I love to learn about regularization

  • 候选标签:positivenegative,和neutral

结果是一个包含以下键值的字典:

  • 'sequence':输入序列

  • 'labels':输入的候选标签

  • 'scores':每个标签对应的得分列表,按降序排序

注意

由于得分总是按降序排序,标签的顺序可能会有所不同。

最终,预测的类别可以通过以下代码计算,该代码将检索得分的argmax值及其相关标签:

res['labels'][np.array(res['scores']).argmax()]

在我们的案例中,输出将是positive

另见

使用 BERT 嵌入的正则化

类似于我们如何使用预训练的 word2vec 模型来计算嵌入,我们也可以使用预训练的 BERT 模型的嵌入,这是一个基于 transformer 的模型。

在这个示例中,我们会在快速介绍 BERT 模型后,使用 BERT 嵌入训练一个模型。

BERT代表双向编码器表示从变换器,是谷歌在 2018 年提出的模型。它在 2019 年底首次在 Google 搜索中用于英文查询,并且支持许多其他语言。BERT 模型已被证明在多个 NLP 任务中有效,包括文本分类和问答。

在快速解释什么是 BERT 之前,让我们先回顾一下什么是注意力机制变换器

注意力机制广泛应用于 NLP 领域,并且在计算机视觉等其他领域的应用也日益增多,自 2017 年首次提出以来。注意力机制的高级概念是计算每个输入标记相对于给定序列中其他标记的权重。与按序列处理输入的 RNN 相比,注意力机制同时考虑整个序列。这使得基于注意力的模型能够更有效地处理序列中的长程依赖,因为注意力机制可以不考虑序列长度。

变换器是一种基于自注意力的神经网络。它们通常以嵌入向量开始,并且具有绝对位置编码,注意力层基于此进行训练。这些层通常使用多头注意力来捕捉输入序列的不同方面。更多详情可以参考原始论文《Attention Is All You Need》(可参阅另见部分)。

注意

由于 BERT 使用的是绝对位置编码,如果使用填充,建议将填充放在右侧。

BERT 模型建立在transformers之上,由 12 层基于变换器的编码层构成(大模型有 24 层),大约有 1.1 亿个参数。更有趣的是,它是以无监督方式预训练的,使用了两种方法:

  • 掩蔽语言模型:序列中 15%的标记被随机掩蔽,模型被训练去预测这些被掩蔽的标记。

  • 下一句预测:给定两句话,模型被训练预测它们是否在给定文本中是连续的。

这种预训练方法在下图中进行了总结,图示来自 BERT 论文《BERT: 语言理解的深度双向变换器预训练》:

图 9.5 – 文章中提出的 BERT 预训练图示,BERT: 语言理解的深度双向变换器预训练

图 9.5 – 文章中提出的 BERT 预训练图示,BERT: 语言理解的深度双向变换器预训练

注意

虽然 word2vec 嵌入是无上下文的(无论上下文如何,词嵌入保持不变),但 BERT 根据周围环境为给定词语提供不同的嵌入。这是有道理的,因为一个给定的词在两个句子中的意思可能不同(例如,apple 或 Apple 可以是水果也可以是公司,取决于上下文)。

准备工作

对于本食谱,我们将重用 Tweets 数据集,可以通过以下命令行下载并解压到本地:

kaggle datasets download -d crowdflower/twitter-airline-sentiment --unzip

必要的库可以通过pip install torch scikit-learn transformers pandas安装。

如何实现…

在本食谱中,我们将在预训练 BERT 嵌入上训练一个简单的逻辑回归模型:

  1. 导入所需的库:

    • 如果你有 GPU,可以使用torch进行设备管理。

    • 使用train_test_split方法和scikit-learn中的LogisticRegression类。

    • transformers中的相关BERT类。

    • 使用pandas加载数据。

这是相关代码:

import torch
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from transformers import BertConfig, BertModel, BertTokenizer import pandas as pd
  1. 使用pandas加载数据集:

    # Load data data = pd.read_csv('Tweets.csv')
    
  2. 将数据集拆分为训练集和测试集,保持与零-shot 推理食谱中预训练模型相同的参数,以便稍后比较它们的表现:

    # Split data into train and test sets train_data, 
    
        test_data = train_test_split(data, test_size=0.2,
    
            random_state=0)
    
  3. 实例化 tokenizer 和 BERT 模型。实例化模型是一个多步骤的过程:

    1. 首先,使用BertConfig类实例化模型的配置。

    2. 然后,使用随机权重实例化BertModel

    3. 加载预训练模型的权重(这将显示一个警告,因为并非所有权重都已加载)。

    4. 如果有 GPU,将模型加载到 GPU 上,并将模型设置为eval模式。

这是相关代码:

# Instantiate the tokenizer
tokenizer = BertTokenizer.from_pretrained('bert-base-uncased')
# Initializing a BERT configuration
configuration = BertConfig()
# Initializing a BERT model with random weights
bert = BertModel(configuration)
# Loading pre-trained weights
bert = bert.from_pretrained('bert-base-uncased')
# Load the model on the GPU
if any device = torch.device(
    "cuda" if torch.cuda.is_available() else "cpu")
bert.to(device)
# Set the model to eval mode
bert.eval()

你可能会收到一些警告消息,因为某些层没有预训练权重。

  1. 计算训练集和测试集的嵌入。这是一个两步过程:

    1. 使用 tokenizer 计算令牌(并可以选择将令牌加载到 GPU 上,如果有的话)。

    2. 然后,计算嵌入。

有关 BERT 模型输入输出的更多细节,请查看本食谱中的更多…小节。

这是相关代码:

max_length = 24
# Compute the embeddings for the train set 
train_tokens = tokenizer(
    train_data['text'].values.tolist(),
    add_special_tokens=True,
    padding='max_length',
    truncation=True,
    max_length=max_length,
    return_tensors='pt')
    train_tokens = {k: v.to(device) for k,
        v in train_tokens.items()}
with torch.no_gad():
    train_embeddings = bert(
        **train_tokens)..pooler_output
# Compute the embeddings for the test set
test_tokens = tokenizer(
    test_data['text'].values.tolist(),
    add_special_tokens=True, padding='max_length',
    truncation=True, max_length=max_length,
    return_tensors='pt')
test_tokens = {k: v.to(device) for k,
    v in test_tokens.items()}
with torch.no_grad():
    test_embeddings = bert(
        **test_tokens).pooler_output
  1. 然后,实例化并训练一个逻辑回归模型。它可能需要比默认模型更多的迭代次数。在这里,已将其设置为10,000

    lr = LogisticRegression(C=0.5, max_iter=10000)
    
    lr.fit(train_embeddings.cpu(),
    
        train_data['airline_sentiment'])
    
  2. 最后,打印训练集和测试集的准确性:

    print('train accuracy:',
    
        lr.score(train_embeddings.cpu(),
    
        train_data['airline_sentiment']))
    
    print('test accuracy:',
    
        lr.score(test_embeddings.cpu(),
    
        test_data['airline_sentiment']))
    

你应该得到类似于以下的输出结果:

train accuracy: 0.8035348360655737 test accuracy: 0.7882513661202186

我们在测试集上得到了大约 79%的最终准确率,在训练集上得到了 80%。作为对比,使用零-shot 推理和简单的 RNN 在同一数据集上提供的准确率为 74%。

还有更多…

为了更好地理解 tokenizer 计算的内容以及 BERT 模型输出的内容,让我们看一个例子。

首先,让我们将 tokenizer 应用于一个句子:

tokens = tokenizer('What is a tokenizer?', add_special_tokens=True,
    padding='max_length', truncation=True, max_length=max_length,
    return_tensors='pt')
print(tokens)

这将输出以下内容:

{'input_ids': tensor([[  101,  2054,  2003,  1037, 19204, 
17629,  1029,  2023,  2003,  1037,           2204,  3160,  1012,
   102,     0,     0,     0,     0,     0,     0,              0,
     0,     0,     0]]), 'token_type_ids': tensor([[0, 0, 0, 
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]]), 'attention_mask': tensor([[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]])}

如我们所见,tokenizer 返回三个输出:

  • input_ids:这是词汇表中令牌的索引。

  • token_type_ids:句子的编号。这对于配对句子才有用,就像 BERT 最初训练时一样。

  • attention_mask:这是模型将关注的地方。正如我们所看到的,它仅对实际的 token 设置为 1,然后对填充设置为 0

这三列数据将传入 BERT 模型,以便它计算其输出。输出由以下两个张量组成:

  • last_hidden_state:最后隐藏状态的值,其形状为 [batch_size, max_length, 768]

  • pooler_output:序列步骤输出的池化值,其形状为 [batch_size, 768]

还有许多其他类型的嵌入存在,它们的强度可能因任务的不同而有所不同。例如,OpenAI 还提供了可以通过 API 提供的嵌入。例如,以下代码允许我们为给定的句子获取嵌入:

import openai
# Give your
openai.api_key = 'xx-xxx'
# Query the API
input_text = 'This is a test sentence'
model = 'text-embedding-ada-002'
embeddings = openai.Embedding.create(input = [input_text],
    model=model)['data'][0]['embedding']

这将返回一个 1,536 维的嵌入,可以用于分类或其他任务。

当然,要使用这些嵌入,你需要做以下几步:

  • 使用 pip 安装 openai 库:pip install openai

  • 在 OpenAI 网站上创建 API 密钥

  • 提供有效的支付方式

另请参见

使用 GPT-3 进行数据增强

生成模型正变得越来越强大,特别是在自然语言处理(NLP)领域。使用这些模型生成新的合成数据,有时可以显著提升我们的结果并对模型进行正则化。在本食谱中,我们将学习如何做到这一点。

准备工作

尽管像 BERT 这样的模型在文本分类等任务中有效,但在文本生成方面,它们通常表现不佳。

其他类型的模型,如生成预训练变换器GPT)模型,在生成新数据方面可以非常出色。在本食谱中,我们将使用 OpenAI API 和 GPT-3.5 来生成合成但现实的数据。拥有更多的数据是我们模型进行更多正则化的关键,而数据生成是收集更多数据的一种方式。

对于本食谱,你需要使用 pip install openai 安装 OpenAI 库。

另外,由于我们将使用的 API 不是免费的,因此需要创建一个 OpenAI 账户,并生成 API 密钥和有效的支付方式。

创建 API 密钥

你可以通过访问 API keys 部分轻松在个人资料中创建一个 API 密钥。

更多内容...部分,我们将提供一个免费的替代方法——即使用 GPT-2 生成新数据——但它的结果会不那么真实。要使其工作,你必须安装 Hugging Face 的transformers库,可以通过pip install transformers来安装。

如何做到这一点……

在这个示例中,我们将简单地查询 GPT-3.5 生成几个正面和负面的电影评论,这样我们就可以获得更多的数据来训练电影评论分类模型。当然,这也可以从任何分类任务中得到,甚至许多其他 NLP 任务:

  1. 导入openai库,如下所示:

    import openai
    
  2. 提供你的 OpenAI API 密钥:

    openai.api_key = 'xxxxx'
    

注意

这只是一个代码示例——永远不要在公开的代码库中分享你的 API 密钥。可以使用环境变量等替代方法。

  1. 使用ChatCompletion API 生成三个正面示例:

    positive_examples = openai.ChatCompletion.create(
    
        model="gpt-3.5-turbo",
    
        messages=[
    
            {"role": "system", 
    
                "content": "You watched a movie you loved."},
    
            {"role": "user", "content": "Write a short,
    
                100-words review about this movie"},
    
        ],
    
        max_tokens=128,
    
        temperature=0.5,
    
        n=3, )
    

这里有几个参数:

  • model: gpt-3.5-turbo,它表现良好且成本高效,基于 GPT-3.5。

  • messages: 消息可以有三种类型:

    • system: 格式化消息,接下来是交替的用户和助手消息。

    • user: 用户消息

    • assistant: 助手消息;我们不会使用这个

  • max_tokens: 输出中令牌的最大数量。

  • temperature: 通常在 0 到 2 之间。值越大,随机性越强。

  • n: 所需输出的数量。

  1. 现在,我们可以显示生成的句子:

    for i in range(len(positive_examples['choices'])):
    
        print(f'\n\nGenerated sentence {i+1}: \n')
    
        print(positive_examples['choices'][i]['message']['content'])
    
    The following is the output of the three positive reviews generated by GPT-3.5:
    
    Generated sentence 1:   I recently watched the movie "Inception" and was blown away by its intricate plot and stunning visuals. The film follows a team of skilled thieves who enter people's dreams to steal their secrets. The concept of dream-sharing is fascinating and the execution of the idea is flawless. The cast, led by Leonardo DiCaprio, delivers outstanding performances that add depth to the characters. The action scenes are thrilling and the special effects are mind-bending. The film's score by Hans Zimmer is also noteworthy, adding to the overall immersive experience. "Inception" is a masterpiece that will leave you pondering its themes long after the credits roll.   Generated sentence 2:   I recently watched the movie "The Shawshank Redemption" and absolutely loved it. The story follows the life of a man named Andy Dufresne, who is wrongfully convicted of murder and sent to Shawshank prison. The movie beautifully portrays the struggles and hardships faced by prisoners, and the importance of hope and friendship in such a harsh environment. The acting by Tim Robbins and Morgan Freeman is outstanding, and the plot twists keep you engaged throughout the movie. Overall, "The Shawshank Redemption" is a must-watch for anyone who loves a good drama and a heartwarming story about the power of the human spirit.   Generated sentence 3:   I recently watched the movie "Parasite" and it blew me away. The story revolves around a poor family who slowly infiltrates the lives of a wealthy family, but things take a dark turn. The movie is a masterclass in storytelling, with each scene building tension and adding layers to the plot. The acting is superb, with standout performances from the entire cast. The cinematography is also stunning, with each shot expertly crafted to enhance the mood and atmosphere of the film. "Parasite" is a must-watch for anyone who loves a good thriller with a twist.
    
  2. 同样地,让我们生成并显示三个负面的电影评论示例:

    # Generate the generated examples
    
    ngative_examples = openai.ChatCompletion.create(
    
        model="gpt-3.5-turbo",
    
        messages=[
    
            {"role": "system",
    
             "content": "You watched a movie you hated."},
    
            {"role": "user",
    
             "content": "Write a short,
    
                100-wordsreview about this movie"},
    
        ],
    
        max_tokens=128,
    
        temperature=0.5,
    
        n=3, )
    
    # Display the generated examples
    
    for i in range(len(
    
        negative_examples['choices'])):
    
        print(f'\n\nGenerated sentence {i+1}: \n')
    
        print(negative_examples[
    
            'choices'][i]['message']['content'])
    

以下代码展示了生成的三个评论:

Generated sentence 1:   I recently watched a movie that left me feeling disappointed and frustrated. The plot was weak and predictable, and the characters were one-dimensional and unrelatable. The acting was subpar, with wooden performances and lackluster chemistry between the cast. The special effects were underwhelming and failed to add any excitement or visual interest to the film. Overall, I found myself checking the time and counting down the minutes until the end. I wouldn't recommend this movie to anyone looking for a compelling and engaging cinematic experience.   Generated sentence 2:   I recently watched a movie that left me feeling disappointed and underwhelmed. The plot was predictable and lacked any real depth or complexity. The characters were one-dimensional and unrelatable, making it hard to invest in their stories. The pacing was slow and dragged on unnecessarily, making the already dull plot even more tedious to sit through. The acting was subpar, with even the most talented actors failing to bring any life to their roles. Overall, I found this movie to be a complete waste of time and would not recommend it to anyone looking for an engaging and entertaining film experience.   Generated sentence 3:   I recently watched a movie that I absolutely hated - "The Roommate". The plot was predictable and the acting was subpar at best. The characters were one-dimensional and lacked any depth or development throughout the film. The dialogue was cringe-worthy and the attempts at suspense fell flat. Overall, I found the movie to be a waste of time and would not recommend it to anyone. If you're looking for a thrilling and well-crafted thriller, "The Roommate" is definitely not the movie for you.

生成的示例写得很好,可能足够自然,像是人类写的。此外,如果需要的话,也可以生成更中立、更随机、更长或更短的示例,这非常方便。

还有更多……

另外,可以免费使用 GPT-2 模型,尽管结果不如真实。让我们来学习如何做到这一点。

首先,让我们基于 GPT-2 实例化一个文本生成管道:

# Import the pipeline from transformers
from transformers import pipeline
# Instantiate a text-generation based on GPT-2 model
Generator = pipeline(''text-generation'', model=''gpt2'')
#generator = pipeline('text2text-generation')#,
# model = 'facebook/mbart-large-50')

这会生成一些文本。行为不完全相同,它仅处理文本补全,因此你必须为模型提供文本的开头,以便它自动完成:

# Generate the positive examples
positive_example = generator(
    "I loved everything about this movie, ",
    max_length = 512,
    num_return_sequences=3, )
# Display the generated examples
for i in range(len(positive_example)):
    print(f'\n\nGenerated sentence {i+1}: \n')
    print(positive_example[i]['generated_text'])

这将输出以下三个评论:

Generated sentence 1:   I loved everything about this movie,  the movie itself was great and I didn't want to stop after the first movie because I was so happy for it. But I did like the first movie and I loved the second one and so much of what I did with it and other things with how I handled the other films. What were your thoughts as fans? After the opening credits, I kept thinking, "This will be my big, special moment, I can't do this alone". I've watched the original 2 episodes, they do have a big difference in the ending. But really, it's just my dream to put in movies you love, and I'm always looking over every minute on how good they were and looking forward. I think because everyone is talking about it, and so much of the movie is just it of my dream, the movie that I am to get this, the movie that I want to watch or maybe I'm doing my dreams. Also, it's more to a big than your dream. I think if I get enough reviews, people will start to see it. There will even be a few who will see it in the summer. I know I did it in a way when I was in high school. I never read the reviews again because I'm not a gamer and I never read the reviews because I love that. But by watching the movies, I feel that I am better than that. So, my dream is going to be to give up on the game I started out with in high school and focus on the movies I like. Not just the one I played, but the one you all enjoy watching! I really hope you guys give it more attention and like for this one, just keep going or stay up for the next movie for when all the things you said can be true. Thanks in advance, and happy movie watching!   Generated sentence 2:   I loved everything about this movie,  It was a surprise to see. I want to say thank to the cast of the film, but don't call me the original star. I love that I have to keep myself on top of the world in other things. (laughs) I was excited about the ending and I was thinking about how much fun it would be to watch that ending. At the end of the day it was all for me. The movie was a shock to watch. It was all about the fact that he and her father can all die. It was so exciting. Says a fan, "I've been waiting for this movie since childhood, and this is the first time I've seen it."   Generated sentence 3:   I loved everything about this movie,  so I made the only mistake I have ever made because for once it felt like this movie was happening. It's always exciting to see a feature that gives the fans something to feel. It's a truly beautiful world in which life isn't a game; life is a process. But it's fun to be forced to watch something that tells you some great things about our environment, even when only one person actually is there, who cares about it. This film was not just another film, it was a true movie. And while I'm still looking forward to seeing more amazing, unique movies from the history of cinema, I can guarantee you that there's more we'll be hearing about from our friends at AMC and others who care about our history, the history of film making, and the history of art-design in general...

如我们所见,结果不如 GPT-3 那么有趣和真实,但如果我们进行一些手动筛选,仍然可以有用。

另见

第十章:计算机视觉中的正则化

在本章中,我们将探索深度学习的另一个热门领域——计算机视觉。计算机视觉是一个庞大的领域,包含许多任务,从分类、生成模型到物体检测。虽然我们无法覆盖所有内容,但我们会提供适用于所有任务的方法。

在本章中,我们将介绍以下几种配方:

  • 训练一个卷积神经网络CNN

  • 使用传统的神经网络NN)方法对 CNN 进行正则化

  • 使用迁移学习对 CNN 进行正则化以进行物体检测

  • 使用迁移学习进行语义分割

本章结束时,你将能够处理多个计算机视觉任务,如图像分类、物体检测、实例分割和语义分割。你将能够应用多种工具来正则化训练的模型,如架构、迁移学习和冻结权重进行微调。

技术要求

在本节中,我们将训练 CNN、物体检测和语义分割模型,所需的库包括:

  • NumPy

  • scikit-learn

  • Matplotlib

  • PyTorch

  • torchvision

  • Ultralytics

  • segmentation-models-pytorch

训练一个 CNN

在这个配方中,在回顾 CNN 的基本组件后,我们将训练一个用于分类任务的 CNN——CIFAR10 数据集。

开始使用

计算机视觉由于多种原因是一个特殊的领域。计算机视觉项目中处理的数据通常是相当庞大、多维且无结构的。然而,它最具特征的方面可能就是其空间结构。

其空间结构带来了许多潜在的困难,如下所示:

  • 纵横比:一些图像根据来源不同,可能具有不同的纵横比,如 16/9、4/3、1/1 和 9/16

  • 遮挡:一个物体可能被另一个物体遮挡

  • 变形:物体可以因透视或物理变形而发生变形

  • 视角:根据视角的不同,一个物体可能看起来完全不同

  • 光照:一张图片可以在多种光照环境下拍摄,可能会改变图像

许多这些困难在图 10.1中做了总结。

图 10.1 – 特定于计算机视觉的困难示例

图 10.1 – 特定于计算机视觉的困难示例

由于数据的空间结构,需要模型来处理它。虽然循环神经网络非常适合处理顺序数据,但 CNN 非常适合处理空间结构化数据。

为了正确构建 CNN,我们需要引入两种新的层:

  • 卷积层

  • 池化层

让我们快速解释一下它们两者。

卷积层

卷积层是由卷积操作组成的层。

在全连接层中,计算输入特征(或上一层的输入激活)的加权和,权重在训练过程中被学习。

在卷积层中,卷积应用于输入特征(或前一层的输入激活),卷积核的值在训练过程中被学习。这意味着神经网络将在训练过程中学习卷积核,以从输入图像中提取最相关的特征。

可以通过几个超参数来微调 CNN:

  • 内核大小

  • 填充大小

  • 步长大小

  • 输出通道的数量(即需要学习的内核数量)

提示

有关内核及 CNN 其他超参数的更多信息,请参见更多内容...小节。

池化层

一个池化层允许你减少图像的维度,并且在 CNN 中常常使用。例如,具有 2x2 内核的最大池化层将把图像的维度减少 4 倍(宽度和高度都减少 2 倍),如图 10.2所示。

图 10.2 – 左边是 4x4 的输入图像,右上方是 2x2 最大池化的结果,右下方是 2x2 平均池化的结果

图 10.2 – 左边是 4x4 的输入图像,右上方是 2x2 最大池化的结果,右下方是 2x2 平均池化的结果

有几种类型的池化,如下所示:

  • 最大池化:计算最大值

  • 平均池化:计算平均值

  • 全局平均池化:为所有通道计算全局平均值(通常在全连接层之前使用)

LeNet-5

LeNet-5是由 Yann Le Cun 提出的最早的 CNN 架构之一,用于手写数字识别。其架构如图 10.3所示,摘自 Yann 的论文《基于梯度的学习应用于文档识别》。

图 10.3 – LeNet-5 的原始架构

图 10.3 – LeNet-5 的原始架构

让我们详细描述一下:

  • 输入图像的尺寸为 32x32

  • C1:一个具有 5x5 内核和 6 个输出通道的卷积层

  • S2:一个具有 2x2 内核的池化层

  • C3:一个具有 5x5 内核和 16 个输出通道的卷积层

  • S4:一个具有 2x2 内核的池化层

  • C5:一个具有 120 个单元的全连接层

  • F6:一个具有 84 个单元的全连接层

  • 输出:一个具有 10 个单元的输出层,用于 10 个类别(数字 0 到 9)

我们将在本食谱中使用 CIFAR-10 数据集实现此网络。

要运行此食谱,可以使用以下命令安装所需的库:

pip install numpy matplotlib torch torchvision

如何操作…

在本食谱中,我们将在 CIFAR-10 数据集上训练一个 CNN 进行图像分类。CIFAR-10 数据集包含 32x32 的 RGB 图像,分为 10 个类别——plane(飞机)、car(汽车)、bird(鸟)、cat(猫)、deer(鹿)、dog(狗)、frog(青蛙)、horse(马)、ship(船)和truck(卡车)。

  1. 导入所需的模块:

    • 用于可视化的 matplotlib

    • 用于数据处理的 NumPy

    • 若干 torch 模块和类

    • 来自 torchvision 的数据集和转换模块

下面是import语句:

import matplotlib.pyplot as plt
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import DataLoader
from torchvision.utils import make_grid
from torchvision.datasets import CIFAR10
import torchvision.transforms as transforms
  1. 实例化应用于图像的转换。这里是一个简单的两步转换:

    • 将数据转换为 PyTorch 张量

    • 使用 0.5 的均值和标准差进行归一化:

      transform = transforms.Compose([
      
          transforms.ToTensor(),
      
          transforms.Normalize((0.5, 0.5, 0.5),
      
              (0.5, 0.5, 0.5)),
      
      ])
      
  2. 加载数据并实例化数据加载器。之前定义的转换在加载时作为 CIFAR10 构造函数的参数直接应用。数据加载器在这里以批量大小 64 实例化:

    # Will download the dataset at first
    
    trainset = CIFAR10('./data', train=True,
    
        download=True, transform=transform)
    
    train_dataloader = DataLoader(trainset, batch_size=64,
    
        shuffle=True)
    
    testset = CIFAR10('./data', train=False,
    
        download=True, transform=transform)
    
    test_dataloader = DataLoader(testset, batch_size=64,
    
        shuffle=True)
    
  3. 可选地,我们可以可视化几张图像,以检查输入数据是什么:

    # Get a batch of images and labels
    
    images, labels = next(iter(train_dataloader))
    
    # Denormalize the images
    
    images = images / 2 + 0.5
    
    # Compute a grid image for visualization
    
    images = make_grid(images)
    
    # Switch from channel first to channel last
    
    images = np.transpose(images.numpy(), (1, 2, 0))
    
    # Display the result
    
    plt.figure(figsize=(14, 8))
    
    plt.imshow(images)
    
    plt.axis('off')
    

这是输出结果:

图 10.4 – 来自 CIFAR-10 数据集的 64 张随机图像。图像模糊,但大多数足够清晰,供人类正确分类

图 10.4 – 来自 CIFAR-10 数据集的 64 张随机图像。这些图像有些模糊,但大多数足够清晰,供人类正确分类

  1. 实现 LeNet5 模型:

    class LeNet5(nn.Module):
    
        def __init__(self, n_classes: int):
    
            super(LeNet5, self).__init__()
    
            self.n_classes = n_classes
    
            self.c1 = nn.Conv2d(3, 6, kernel_size=5,
    
                stride=1, padding=0)
    
            self.s2 = nn.MaxPool2d(kernel_size=2)
    
            self.c3 = nn.Conv2d(6, 16, kernel_size=5,
    
                stride=1, padding=0)
    
            self.s4 = nn.MaxPool2d(kernel_size=2)
    
            self.c5 = nn.Linear(400, 120)
    
            self.f6 = nn.Linear(120, 84)
    
            self.output = nn.Linear(84, self.n_classes)
    
        def forward(self, x):
    
            x = F.relu(self.c1(x))
    
            x = self.s2(x)
    
            x = F.relu(self.c3(x))
    
            x = self.s4(x)
    
            # Flatten the 2D-array
    
            x = torch.flatten(x, 1)
    
            x = F.relu(self.c5(x))
    
            x = F.relu(self.f6(x))
    
            output = F.softmax(self.output(x), dim=1)
    
            return output
    

这个实现与原论文中的层结构几乎相同。这里有一些有趣的要点:

  • nn.Conv2dtorch 中的 2D 卷积层,其超参数包括输出维度、卷积核大小、步幅和填充

  • nn.MaxPool2d 是 PyTorch 中的最大池化层,其超参数为卷积核大小(可选的,步幅默认为卷积核大小)

  • 我们使用 ReLU 激活函数,尽管它并不是原论文中使用的激活函数

  • torch.flatten 允许我们将 2D 张量展平为 1D 张量,从而可以应用全连接层

  • 实例化模型,并确保它在随机输入张量上能够正常工作:

    # Instantiate the model
    
    lenet5 = LeNet5(10)
    
    # check device
    
    device = 'cuda' if torch.cuda.is_available() else 'cpu'
    
    lenet5 = lenet5.to(device)
    
    # Generate randomly one random 32x32 RGB image
    
    random_data = torch.rand((1, 3, 32, 32), device=device)
    
    result = lenet5(random_data)
    
    print('Resulting output tensor:', result)
    
    print('Sum of the output tensor:', result.sum())
    

结果输出将如下所示:

Resulting output tensor: tensor([[0.0890, 0.1047, 0.1039, 0.1003, 0.0957, 0.0918, 0.0948, 0.1078, 0.0999,
         0.1121]], grad_fn=<SoftmaxBackward0>)
Sum of the output tensor: tensor(1.0000, grad_fn=<SumBackward0>)
  1. 实例化损失函数和优化器——用于多类分类的交叉熵损失函数和 Adam 优化器:

    criterion = nn.CrossEntropyLoss()
    
    optimizer = torch.optim.Adam(lenet5.parameters(), lr=0.001)
    
  2. 实现一个辅助函数 epoch_step_cifar,它计算前向传播、反向传播(在训练集的情况下)、损失和每个 epoch 的准确率:

    def epoch_step_cifar(model, dataloader, device,
    
    training_set : bool) :
    
        running_loss = 0.
    
        correct = 0.
    
        for i, data in enumerate(dataloader, 0):
    
            inputs, labels = data
    
            inputs = inputs.to(device)
    
            labels = labels.to(device)
    
            if training_set:
    
                optimizer.zero_grad()
    
            outputs = model(inputs)
    
            loss = criterion(outputs, labels)
    
            if training_set:
    
                loss.backward()
    
                optimizer.step()
    
            correct += (outputs.argmax(
    
                dim=1) == labels).float().sum().cpu()
    
            running_loss += loss.item()
    
        return running_loss, correct
    
  3. 实现一个辅助函数 train_cifar_classifier,它训练模型并返回训练集和测试集的损失与准确率:

    def train_cifar_classifier(model, train_dataloader,
    
        test_dataloader, criterion, device, epochs):
    
            # Create empty lists to store the losses and accuracies
    
            train_losses = []
    
            test_losses = []
    
            train_accuracy = []
    
            test_accuracy = []
    
            # Loop over epochs
    
            for epoch in range(epochs):
    
                ## Train the model on the training set
    
                running_train_loss = 0.
    
                correct = 0.
    
                lenet5.train()
    
                running_train_loss,
    
                correct = epoch_step_cifar(
    
                    model, train_dataloader, device,
    
                    training_set=True
    
                )
    
                # Compute and store loss and accuracy for this epoch
    
            train_epoch_loss = running_train_loss / len(
    
                train_dataloader)
    
            train_losses.append(train_epoch_loss)
    
            train_epoch_accuracy = correct / len(trainset)
    
            train_accuracy.append(train_epoch_accuracy)
    
            ## Evaluate the model on the test set
    
            running_test_loss = 0.
    
            correct = 0.
    
            lenet5.eval()
    
            with torch.no_grad():
    
                running_test_loss,
    
                correct = epoch_step_cifar(
    
                    model, test_dataloader, device,
    
                    training_set=False
    
                )
    
                test_epoch_loss = running_test_loss / len(
    
                    test_dataloader)
    
                test_losses.append(test_epoch_loss)
    
                test_epoch_accuracy = correct / len(testset)
    
                test_accuracy.append(test_epoch_accuracy)
    
            # Print stats
    
            print(f'[epoch {epoch + 1}] Training: loss={train_epoch_loss:.3f} accuracy={train_epoch_accuracy:.3f} |\
    
        \t Test: loss={test_epoch_loss:.3f} accuracy={test_epoch_accuracy:.3f}')
    
        return train_losses, test_losses, train_accuracy,
    
            test_accuracy
    
  4. 使用辅助函数,在 50 个 epoch 上训练模型,并存储训练集和测试集的损失和准确率:

    train_losses, test_losses, train_accuracy, 
    
    test_accuracy = train_cifar_classifier(lenet5,
    
        train_dataloader, test_dataloader, criterion,
    
        device, epochs=50)
    

输出的最后一行将如下所示:

[epoch 50] Training: loss=1.740 accuracy=0.720 |   Test: loss=1.858 accuracy=0.600
  1. 绘制训练集和测试集的损失曲线:

    plt.plot(train_losses, label='train')
    
    plt.plot(test_losses, label='test')
    
    plt.xlabel('epoch')
    
    plt.ylabel('loss (CE)')
    
    plt.legend()
    
    plt.show()
    

这是生成的图表:

图 10.5 – 训练集和测试集的交叉熵损失

图 10.5 – 训练集和测试集的交叉熵损失

少于 10 个 epoch 后,曲线开始发散。

  1. 绘制准确率随训练轮数(epoch)变化的图示:

    plt.plot(train_accuracy, label='train')
    
    plt.plot(test_accuracy, label='test')
    
    plt.xlabel('epoch')
    
    plt.ylabel('Accuracy')
    
    plt.legend()
    
    plt.show()
    

这是我们得到的图:

图 10.6 – 训练集和测试集的准确率随训练轮数(epoch)变化的图示

图 10.6 – 训练集和测试集的准确率随训练轮数(epoch)变化的图示

经过大约 20 个 epoch 后,准确率达到约 60% 的平稳状态,而训练准确率持续增长,这表明发生了过拟合。

还有更多内容……

在这一小节中,让我们快速回顾一些理解 CNN 的必要工具和概念:

  • 如何存储图像

  • 填充

  • 核与卷积

  • 步幅

如何存储图像

图像只不过是空间上排列的像素数组。例如,一个百万像素的灰度方形图像就是一个由 1000x1000 像素组成的数组。

每个像素通常存储为一个 8 位值,并且可以表示为范围为 [0, 255] 的无符号整数。因此,最终这样的图像可以在 Python 中表示为形状为 (1000, 1000) 的 uint8 类型的 NumPy 数组。

我们可以进一步处理彩色图像。彩色图像通常存储有三个通道——红色、绿色、蓝色RGB)。每个通道都以 8 位整数形式存储,因此一个 1M 像素的平方彩色图像可以存储为一个形状为 (3, 1000, 1000) 的 NumPy 数组,假设通道是首先存储的。

提示

描述彩色图像还有许多其他方式——色相、饱和度、亮度HSV)、CIELAB、透明度等。然而,在本书中,以及许多计算机视觉的案例中,RGB 色彩空间已经足够。

填充

我们在前面的章节中已经使用了填充(padding)进行自然语言处理(NLP)。它通过在图像周围添加“空间”,基本上是通过在图像周围添加值的层来实现的。图 10.7 给出了一个 4x4 矩阵填充的例子。

图 10.7 – 一个 4x4 矩阵,填充了一个零层的示例

图 10.7 – 一个 4x4 矩阵,填充了一个零层的示例

填充可以接受多个参数,例如:

  • 填充的层数

  • 填充方法——给定值、重复、镜像等等

大多数时候,使用零填充,但有时更复杂的填充方法会很有用。

核与卷积

卷积是一种数学操作,是图像和核之间的运算,输出另一个图像。它可以简要表示如下:

卷积(输入图像,核)→ 输出图像

核只是一个预定义值的小矩阵,通过卷积我们可以从图像中获取某些属性。例如,使用正确的核,对图像进行卷积可以让我们模糊图像、锐化图像、检测边缘等等。

卷积的计算非常简单,可以描述如下:

  1. 从左上角开始,空间上匹配核和图像。

  2. 计算所有图像像素的权重和,使用相应的核值作为权重,并将此值存储为左上角的输出图像像素。

  3. 向右移动一个像素,重复上述操作;如果到达图像的最右边缘,则返回到最左边的像素,并向下移动一个像素。

这看起来很复杂,但通过图示来表示会简单得多,如图 10.8所示:

图 10.8 – 通过核进行图像卷积的示例。注意,得到的图像在尺寸上较小

图 10.8 – 通过核进行图像卷积的示例。注意,得到的图像在尺寸上较小

如我们在 图 10**.8 中所见,输出图像稍微小于输入图像。事实上,卷积核越大,输出图像越小。

步幅

关于卷积的另一个有用概念是步幅。步幅是指在两次卷积操作之间移动的像素步数。在 图 10**.8 中的示例中,我们隐式地使用了步幅为 1 – 每次卷积核移动一个像素。

然而,也可以考虑更大的步幅 – 我们可以有任意步长,如 图 10**.9 所示:

图 10.9 – 步幅对卷积的影响 – 步幅越大,输出图像越小

图 10.9 – 步幅对卷积的影响 – 步幅越大,输出图像越小

使用更大的步幅主要会带来几个相关的后果:

  • 由于卷积跳过了更多的像素,输出图像中保留的信息更少

  • 输出图像更小,从而实现降维

  • 计算时间更短

根据需求,使用大于 1 的步幅可以有效地降低计算时间。

总结来说,我们可以通过三个参数来控制卷积的多个方面:

  • 填充

  • 卷积核大小

  • 步幅

它们都影响输出图像的大小,遵循以下公式:

这个公式可以分解为如下:

  • I 是输入图像的大小

  • k 是卷积核的大小

  • p 是填充大小

  • s 是步幅大小

  • O 是输出图像的大小

借助这个公式,我们可以高效地选择所需的参数。

另见

使用传统神经网络方法对 CNN 进行正则化

由于 CNN 是神经网络的一种特殊类型,大多数传统神经网络优化方法都可以应用于它们。我们可以使用的 CNN 正则化技术的非详尽列表如下:

  • 卷积核大小

  • 池化大小

  • L2 正则化

  • 完全连接的单元数(如果有的话)

  • Dropout

  • 批量归一化

在这个方案中,我们将应用批量归一化来增加正则化,重用 LeNet-5 模型处理 CIFAR-10 数据集,但任何其他方法也可能有效。

批量归一化是一种简单而非常有效的方法,可以帮助神经网络进行正则化并加速收敛。批量归一化的思想是对给定批次的隐藏层激活值进行归一化。该方法非常类似于定量数据准备中的标准缩放器,但也存在一些区别。我们来看看它是如何工作的。

第一步是计算给定层的激活值的均值µ和标准差!。假设!是该层第 I 个单元的激活值,且该层有n个单元,以下是公式:

就像使用标准缩放器一样,现在可以通过以下公式计算重新缩放后的激活值!

这里,只是一个小值,用来避免除以零。

最后,不同于标准缩放器,批量归一化还有一个额外的步骤,它允许模型通过缩放和偏移的方法来学习最佳分布,这要得益于两个新的可学习参数β和!。它们被用来计算最终的批量归一化输出!

在这里,! 使我们能够调整缩放,而β允许我们调整偏移量。这两个参数是在训练过程中学习的,就像神经网络的任何其他参数一样。这使得模型能够根据需要调整分布,以提高其性能。

对于一个更直观的示例,我们可以在图 10.10中看到,左侧是一个三单元层的激活值分布——值呈偏态且标准差较大。经过批量归一化后,图 10.10的右侧,分布接近正态分布。

图 10.10 – 三单元层的激活分布在批量归一化前(左)和批量归一化后(右)的可能情况

图 10.10 - 三单元层的激活分布在批量归一化前(左)和批量归一化后(右)的可能情况

由于这种方法,神经网络(NN)往往能更快收敛并且具有更好的泛化能力,正如我们在本食谱中所看到的。

开始使用

对于本食谱,我们将重用 torch 及其集成的 CIFAR-10 数据集,因此所有需要的库可以通过以下命令行安装(如果在之前的食谱中尚未安装):

pip install numpy matplotlib torch torchvision

如何实现……

由于我们将重复使用与前一个食谱几乎相同的数据和网络,因此我们假设导入的库和实例化的类可以被重用:

  1. 实现正则化模型。在这里,我们将主要重复使用 LeNet-5 架构,并在每个步骤中添加批量归一化:

    class LeNet5(nn.Module):
    
        def __init__(self, n_classes: int):
    
            super(LeNet5, self).__init__()
    
            self.n_classes = n_classes
    
            self.c1 = nn.Conv2d(3, 6, kernel_size=5,
    
                stride=1, padding=0, )
    
            self.s2 = nn.MaxPool2d(kernel_size=2)
    
            self.bnorm2 = nn.BatchNorm2d(6)
    
            self.c3 = nn.Conv2d(6, 16, kernel_size=5,
    
                stride=1, padding=0)
    
            self.s4 = nn.MaxPool2d(kernel_size=2)
    
            self.bnorm4 = nn.BatchNorm1d(400)
    
            self.c5 = nn.Linear(400, 120)
    
            self.bnorm5 = nn.BatchNorm1d(120)
    
            self.f6 = nn.Linear(120, 84)
    
            self.bnorm6 = nn.BatchNorm1d(84)
    
            self.output = nn.Linear(84, self.n_classes)
    
        def forward(self, x):
    
            x = F.relu(self.c1(x))
    
            x = self.bnorm2(self.s2(x))
    
            x = F.relu(self.c3(x))
    
            x = self.s4(x)
    
            # Flatten the 2D-array
    
            x = self.bnorm4(torch.flatten(x, 1))
    
            x = self.bnorm5(F.relu(self.c5(x)))
    
            x = self.bnorm6(F.relu(self.f6(x)))
    
            output = F.softmax(self.output(x), dim=1)
    
            return output
    

如代码所示,批归一化可以简单地作为一个层添加,使用nn.BatchNorm1d(对于卷积部分使用nn.BatchNorm2d),它接受以下输入维度作为参数:

  • 全连接层和BatchNorm1d的单元数

  • 卷积层和BatchNorm2d的卷积核数量

重要提示

将批归一化放置在激活函数之后存在争议,有些人更倾向于将其放在激活函数之前。

  1. 实例化模型,使用交叉熵作为损失函数,Adam 作为优化器:

    # Instantiate the model
    
    lenet5 = LeNet5(10)
    
    # check device
    
    device = 'cuda' if torch.cuda.is_available() else 'cpu'
    
    lenet5 = lenet5.to(device)
    
    # Instantiate loss and optimizer
    
    criterion = nn.CrossEntropyLoss()
    
    optimizer = torch.optim.Adam(lenet5.parameters(), lr=0.001)
    
  2. 通过重新使用前一个配方中的train_cifar_classifier辅助函数,训练模型 20 个 epochs。请注意,与前一个配方中没有批归一化的情况相比,模型收敛得更快:

    train_losses, test_losses, train_accuracy, 
    
    test_accuracy = train_cifar_classifier(lenet5,
    
        train_dataloader, test_dataloader, criterion,
    
        device, epochs=20)
    
  3. 绘制损失与 epochs 的关系:

    plt.plot(train_losses, label='train')
    
    plt.plot(test_losses, label='test')
    
    plt.xlabel('epoch')
    
    plt.ylabel('loss (CE)')
    
    plt.legend()
    
    plt.show()
    

这是图表:

图 10.11 – 交叉熵损失与 epochs 的关系

图 10.11 – 交叉熵损失与 epochs 的关系

过拟合在仅经过几轮训练后就开始出现。

  1. 绘制训练集和测试集的准确率:

    plt.plot(train_accuracy, label='train')
    
    plt.plot(test_accuracy, label='test')
    
    plt.xlabel('epoch')
    
    plt.ylabel('Accuracy')
    
    plt.legend()
    
    plt.show()
    

这是我们得到的结果:

图 10.12 – 准确率与 epochs 的关系。测试准确率上升至 66%,而没有批归一化时为 61%

图 10.12 – 准确率与 epochs 的关系。测试准确率上升至 66%,而没有批归一化时为 61%

因此,我们可以看到,尽管仍然存在一定的过拟合,但得益于批归一化,测试准确率显著提高,从 61%提升至 66%。

还有更多内容……

CNN 的有趣之处在于我们可以查看它们从数据中学到了什么。查看学习到的卷积核是一种方法。可以使用visualize_kernels函数来做到这一点,定义如下:

from torchvision import utils
def visualize_kernels(tensor, ch=0, all_kernels=False, nrow=8, padding=1, title=None):
    n,c,w,h = tensor.shape
    if all_kernels:
        tensor = tensor.view(n*c, -1, w, h)
    elif c != 3:
        tensor = tensor[:,ch,:,:].unsqueeze(dim=1)
    rows = np.min((tensor.shape[0] // nrow + 1, 64))
    grid = utils.make_grid(tensor, nrow=nrow,
        normalize=True, padding=padding)
    # Display
    plt.figure(figsize=(nrow, rows))
    if title is not None:
        plt.title(title)
    plt.imshow(grid.cpu().numpy().transpose((1, 2, 0)))
    plt.axis('off')
    plt.show()

我们现在可以应用这个函数来可视化C1C3层学到的卷积核:

visualize_kernels(lenet5.c1.weight.data, all_kernels=False,
    title='C1 layer')
visualize_kernels(lenet5.c3.weight.data, title='C3 layer')

这里是 C1 层:

这里是 C3 层:

图 10.13 – 上图为 C1 层的学到的卷积核,下图为 C3 层的学到的卷积核

图 10.13 – 上图为 C1 层的学到的卷积核,下图为 C3 层的学到的卷积核

显示卷积核并不总是有用的,但根据任务的不同,它们可以提供模型识别的形状的提示。

另请参见

使用迁移学习对 CNN 进行正则化,用于物体检测

在本章中,我们将执行计算机视觉中的另一个典型任务——目标检测。在利用一次性检测YOLO)模型的迁移学习能力(YOLO 是一种广泛用于目标检测的模型类别)来提升性能之前,我们将简要介绍目标检测是什么,主要方法和指标,以及 COCO 数据集。

目标检测

目标检测是计算机视觉任务,涉及识别和定位给定类别的物体(例如,汽车、手机、人物或狗)。如图 10.14所示,物体通常通过预测的边界框以及预测的类别来定位。

图 10.14 – 一张带有目标检测的图像示例。物体通过边界框和类别进行检测

图 10.14 – 一张带有目标检测的图像示例。物体通过边界框和类别进行检测

研究人员提出了许多方法来帮助解决目标检测问题,其中一些在许多行业中得到广泛应用。目标检测方法有多种分组,但目前最常用的两大方法组可能是以下两种:

  • 一阶段方法,如 YOLO 和 SSD

  • 两阶段方法,基于区域卷积神经网络R-CNN

基于 R-CNN 的方法功能强大,通常比一阶段方法更准确。另一方面,一阶段方法通常计算开销较小,能够实时运行,但它们在检测小物体时可能更容易失败。

平均精度

由于这是一个特定任务,因此需要一个特定的指标来评估这些模型的表现——平均精度mAP)。让我们概述一下 mAP 是什么。为此,我们需要介绍几个概念,如交并比IoU)以及在目标检测中的精准率和召回率。

当一个物体被检测到时,会伴随三项信息:

  • 预测的类别

  • 一个边界框(通常由四个点组成,可以是中心加宽度和高度,或者是左上角和右下角的位置)

  • 一个表示该框内包含物体的置信度或概率

要认为一个物体被成功检测到,类别必须匹配,并且边界框必须定位准确。虽然判断类别是否匹配是显而易见的,但边界框定位则通过一个名为 IoU 的指标来计算。

由于有明确的名称,IoU 可以通过计算地面实况框和预测框的交集,再除以这两个框的并集来得出,如图 10.15所示。

图 10.15 – IoU 指标的表示

图 10.15 – IoU 指标的表示

给定两个边界框——例如,AB——IoU 可以通过以下方程来数学描述:

IoU 作为一个指标具有几个优点:

  • 这些值介于 0 和 1 之间

  • 值为 0 表示两个框没有重叠

  • 值为 1 表示两个框完全匹配

然后应用 IoU 阈值。如果 IoU 高于该阈值,则被视为 真正例 (TP),否则被视为 假正例 (FP),从而可以有效地计算精度。最后,假负例 (FN) 是未被检测到的物体,基于 IoU 阈值。

使用这些 TP、FP 和 FN 的定义,我们可以计算精确度和召回率。

提醒一下,精确度 P 和召回率 R 的公式如下:

使用 P 和 R,可以绘制 精确度-召回率曲线 (PR 曲线),其中 P 是 R 的函数,表示不同置信度阈值(从 0 到 1)下的结果。通过这个 PR 曲线,可以通过对不同 R 值下的 P 值(例如,计算 [0, 0.1, 0.2... 1] 范围内的 P 的插值)进行平均,来计算 平均精度 (AP)。

重要提示

平均召回率指标可以通过反向计算使用相同的方法,通过交换 R 和 P 来实现。

最终,mAP 通过对所有类别的 AP 取平均值来计算。

提示

请参阅 另见 子部分,获取一篇详细解释 mAP 计算的优秀博客文章链接。

这种 AP 计算方法的一个缺点是我们只考虑了一个 IoU 阈值,按相同的方式考虑了几乎完美的 IoU 为 0.95 的框和不太好的 IoU 为 0.5 的框。这就是为什么一些评估指标会对多个 IoU 阈值计算 AP 的平均值——例如,从 0.5 到 0.95,步长为 0.05,通常表示为 AP@[IoU=0.5:0.95] 或 AP50-95

COCO 数据集

常见物体背景数据集 (COCO) 是一个在目标检测中广泛使用的数据集,具有以下优点:

  • 成千上万的带标签图像

  • 80 类对象

  • 灵活的使用条款

  • 一个庞大的社区

这是进行目标检测时的标准数据集。正因为如此,大多数标准的目标检测模型都提供了在 COCO 数据集上预训练的权重,使我们能够利用迁移学习。

入门

在本教程中,我们将使用 Ultralytics 提出的 YOLO 算法。YOLO 代表 You Only Look Once,指的是该方法在单个阶段内操作,能够在具有足够计算能力的设备上实时执行。

YOLO 是一个流行的目标检测算法,首次提出于 2015 年。自那时以来,已有许多版本发布并进行改进;目前正在开发版本 8。

可以通过以下命令行简单安装:

pip install ultralytics

我们将在 Kaggle 上的一个车辆数据集上训练目标检测算法。可以通过以下命令下载并准备数据集:

  1. 使用 Kaggle API 下载数据集:

    kaggle datasets download -d saumyapatel/traffic-vehicles-object-detection --unzip
    
  2. 为了简化,重命名文件夹:

    mv 'Traffic Dataset' traffic
    
  3. 创建一个 datasets 文件夹:

    mkdir datasets
    
  4. 将数据集移动到此文件夹:

    mv traffic datasets/
    

结果是,你现在应该有一个包含以下结构的文件夹数据集:

traffic
├── images
│   ├── train: 738 images
│   ├── val: 185 images
│   ├── test: 278 images
├── labels
    ├── train
    ├── val

数据集分为 trainvaltest 集,分别包含 738、185 和 278 张图像。如我们在下一小节中所见,这些是典型的道路交通图像。标签有七个类别——CarNumber PlateBlur Number PlateTwo-WheelerAutoBusTruck。我们现在可以继续训练目标检测模型。

如何操作…

我们将首先快速探索数据集,然后在这些数据上训练并评估一个 YOLO 模型:

  1. 导入所需的模块和函数:

    • matplotlibcv2 用于图像加载和可视化

    • YOLO 用于模型训练

    • 使用 glob 作为 util 来列出文件:

      import cv2
      
      from glob import glob
      
      import matplotlib.pyplot as plt
      
      from ultralytics import YOLO
      
  2. 现在让我们来探索数据集。首先,我们将使用 glob 列出 train 文件夹中的图像,然后展示其中的八张:

    plt.figure(figsize=(14, 10))
    
    # Get all images paths
    
    images = glob('datasets/traffic/images/train/*.jpg')
    
    # Plot 8 of them
    
    for i, path in enumerate(images[:8]):
    
        img = plt.imread(path)
    
        plt.subplot(2, 4, i+1)
    
        plt.imshow(img)
    
        plt.axis('off')
    

结果如下:

图 10.16 – 来自交通数据集训练集的八张图像拼接图

图 10.16 – 来自交通数据集训练集的八张图像拼接图

如我们所见,这些大多数是与交通相关的图像,具有不同的形状和特征。

  1. 如果我们通过读取文件查看标签,得到如下内容:

    with open('datasets/traffic/labels/train/00 (10).txt') as file:
    
        print(file.read())
    
        file.close()
    

输出结果如下:

2 0.543893 0.609375 0.041985 0.041667
5 0.332061 0.346354 0.129771 0.182292
5 0.568702 0.479167 0.351145 0.427083

标签是每行一个对象,因此这里我们在图像中有三个标注对象。每行包含五个数字:

  • 类别编号

  • 盒子中心的 x 坐标

  • 盒子中心的 y 坐标

  • 盒子的宽度

  • 盒子的高度

请注意,所有框的信息相对于图像的大小,因此它们以 [0, 1] 范围内的浮动数字表示。

提示

图像中的框数据还有其他格式,例如 COCO 和 Pascal VOC 格式。更多信息可以在 另见 子章节中找到。

我们甚至可以使用这里实现的 plot_labels 函数,绘制带有标签框的图像:

def plot_labels(image_path, labels_path, classes):
    image = plt.imread(image_path)
    with open(labels_path, 'r') as file:
        lines = file.readlines()
        for line in lines:
            cls, xc, yc, w, h= line.strip().split(' ')
            xc = int(float(xc)*image.shape[1])
            yc = int(float(yc)*image.shape[0])
            w = int(float(w)*image.shape[1])
            h = int(float(h)*image.shape[0])
            cv2.rectangle(image, (xc - w//2,
                yc - h//2), (xc + w//2 ,yc + h//2),
                (255,0,0), 2)
            cv2.putText(image, f'{classes[int(cls)]}',
                (xc-w//2, yc - h//2 - 10),
                cv2.FONT_HERSHEY_SIMPLEX, 0.5,
                (255,0,0), 1)
    file.close()
    plt.imshow(image)
classes = ['Car', 'Number Plate', 'Blur Number Plate',
    'Two Wheeler', 'Auto', 'Bus', 'Truck']
plot_labels(
    'datasets/traffic/images/train/00 (10).jpg',
    'datasets/traffic/labels/train/00 (10).txt',
    classes
)

结果如下:

图 10.17 – 图像及其标注的边界框示例

图 10.17 – 图像及其标注的边界框示例

在这张照片中,我们有两辆车和一块车牌的标签。接下来,我们将进行下一步,使用这些数据训练一个模型。

  1. 我们需要创建一个 .yaml 文件,这是 YOLO 模型所期望的文件,包含数据集位置和类别。请在当前目录中创建并编辑一个名为 dataset.yaml 的文件,并用你喜欢的编辑器填充以下内容:

    train: traffic/images/train
    
    val: traffic/images/val
    
    nc: 7
    
    names: ['Car', 'Number Plate', 'Blur Number Plate',
    
        'Two Wheeler', 'Auto', 'Bus', 'Truck']
    
  2. 现在我们可以实例化一个新模型。这个模型将实例化一个 YOLOv8 nano 模型。YOLO 模型有五个不同的尺寸:

    • 'yolov8n.yaml',用于最小模型,具有 320 万个参数

    • 'yolov8s.yaml',具有 1120 万个参数

    • 'yolov8m.yaml',具有 2590 万个参数

    • 'yolov8l.yaml',具有 4370 万个参数

    • 'yolov8x.yaml',用于最大模型,具有 6820 万个参数:

      # Create a new YOLO model with random weights
      
      model = YOLO('yolov8n.yaml')
      
  3. 训练模型,提供包含之前创建的 dataset.yaml 文件的数据集、训练轮数以及名称(可选):

    # Train the model for 100 epochs
    
    model.train(data='dataset.yaml', epochs=100,
    
        name='untrained_traffic')
    

提示

当模型在内存中训练时,会显示大量信息,包括损失和指标。如果你想详细查看这些信息,其实并不复杂。

名称是可选的,但它可以帮助我们轻松找到结果和输出存储的位置——在runs/detect/<name>文件夹中。如果文件夹已存在,则会简单地增加一个编号,而不是覆盖。

在这个文件夹中,可以找到一些有用的文件,包括以下内容:

  • weights/best.pt:具有最佳验证损失的训练轮次权重

  • results.csv:记录每个训练轮次结果的日志文件

  • 显示了几个曲线和数据相关的信息

  1. 显示结果。在这里,我们将展示自动保存的结果图像,results.png

    plt.figure(figsize=(14, 10))
    
    plt.imshow(plt.imread(
    
        'runs/detect/untrained_traffic/results.png'))
    
    plt.axis('off')
    

这是结果:

图 10.18 – 从零开始训练的 YOLO 模型在 100 个训练轮次后的结果汇总

图 10.18 – 从零开始训练的 YOLO 模型在 100 个训练轮次后的结果汇总

显示了几个训练和验证的损失,以及精度(P)、召回率(R)、mAP50 和 mAP50-95 等指标的损失。

考虑到小数据集,结果是令人鼓舞的——我们看到损失在减少,而 mAP50 提高到了 0.7,这意味着模型在良好学习。

  1. 让我们以测试集中的一张图像为例展示结果。为此,我们首先需要实现一个函数,用于展示图像以及预测的边界框和类别,plot_results_one_image

    def plot_results_one_image(result):
    
        image = result[0].orig_img.copy()
    
        raw_res = result[0].boxes.data
    
        for detection in raw_res:
    
            x1, y1, x2, y2, p,
    
            cls = detection.cpu().tolist()
    
            cv2.rectangle(image, (int(x1), int(y1)),
    
                (int(x2), int(y2)), (255,0,0), 2)
    
            cv2.putText(image, f'{classes[int(cls)]}',
    
                (int(x1), int(y1) - 10),
    
                cv2.FONT_HERSHEY_SIMPLEX, 1, (255,0,0), 2)
    
        plt.imshow(image)
    
  2. 然后我们可以计算推理结果并将其展示在测试集中的一张图像上:

    # Compute the model inference on a test image
    
    result = model.predict(
    
        'datasets/traffic/images/test/00 (100).png')
    
    # Plot the results
    
    plot_results_one_image(result)
    

这是结果:

图 10.19 – 测试集中的一张图像和训练模型预测的检测结果

图 10.19 – 测试集中的一张图像和训练模型预测的检测结果

如我们所见,我们的 YOLO 模型已经学会了检测并正确分类多个类别。然而,仍然有改进的空间:

  • 这些框与物体并不完全匹配;它们要么太大,要么太小

  • 一个物体可能具有两个类别(即使模糊车牌车牌这两个类别之间的差异有争议)

重要说明

值得一提的是,YOLO 模型的直接输出通常包含更多的边界框。这里应用了一个后处理步骤,称为非极大抑制算法。该算法仅保留具有足够高置信度,并且与同类其他框重叠较小(通过 IoU 计算)的边界框。

让我们尝试通过迁移学习来修复这个问题。

使用迁移学习进行训练

我们现在将在完全相同的数据集上训练另一个模型,使用相同的训练轮数(epochs)。不过,与使用随机权重的模型不同,我们将加载一个在 COCO 数据集上训练过的模型,从而利用迁移学习的优势:

  1. 实例化并训练一个预训练模型。我们无需使用yolov8n.yaml来实例化模型,只需使用yolov8n.pt即可;这将自动下载预训练权重并加载它们:

    # Load a pretrained YOLO model
    
    pretrained_model = YOLO('yolov8n.pt')
    
    # Train the model for 100 epochs
    
    pretrained_model.train(data='dataset.yaml',
    
        epochs=100, name='pretrained_traffic')
    
  2. 现在让我们展示这个模型的结果:

    plt.figure(figsize=(14, 10))
    
    plt.imshow(plt.imread(
    
        'runs/detect/pretrained_traffic/results.png'))
    
    plt.axis('off')
    

这是结果:

图 10.20 – 经过 100 轮训练后,YOLO 模型在 COCO 数据集上使用预训练权重的结果总结

图 10.20 – 经过 100 轮训练后,YOLO 模型在 COCO 数据集上使用预训练权重的结果总结

通过迁移学习,所有指标的表现都得到了提升——mAP50 现在提高到了 0.8,而之前是 0.7,这是一个显著的改进。

  1. 我们现在可以在与之前相同的图像中展示结果,以便进行比较:

    result = pretrained_model.predict(
    
        'datasets/traffic/images/test/00 (100).png')
    
    plot_results_one_image(result)
    

这是结果:

图 10.21 – 来自测试集的图像,以及使用预训练权重的模型预测的检测结果

图 10.21 – 来自测试集的图像,以及使用预训练权重的模型预测的检测结果

这张单独的图像已经展示了几个改进——现在边界框不仅完全适应了物体,而且同一个车牌不再被检测为两个物体。得益于迁移学习,我们能够有效地帮助模型进行泛化。

还有更多…

在这个食谱中,我们专注于目标检测任务,但 YOLO 模型不仅仅能做这些。

使用相同的库,还可以训练以下模型:

  • 分类

  • 分割

  • 姿态

所有这些模型也都附带预训练权重,因此可以利用迁移学习,即使是小数据集也能获得良好的表现。

另请参见

使用迁移学习进行语义分割

在这个食谱中,我们将利用迁移学习和预训练模型的微调,来完成一个特定的计算机视觉任务——无人机图像的语义分割。

目标检测和实例分割主要关注图像中的物体检测 – 物体通过边界框进行限定,在实例分割中则使用多边形来限定物体。相反,语义分割则是对图像中的所有像素进行类别分类。

如我们在图 10.22中所见,所有像素都有特定的颜色,因此每个像素都被分配了一个类别。

图 10.22 – 语义分割注释的示例。左侧是原始图像,右侧是标注图像 – 每种颜色代表一个物体类别,每个像素都分配给一个特定类别

图 10.22 – 语义分割注释的示例。左侧是原始图像,右侧是标注图像 – 每种颜色代表一个物体类别,每个像素都分配给一个特定类别

即使它看起来可能与实例分割相似,我们将在本教程中看到,所使用的概念和方法是完全不同的。我们将回顾解决语义分割问题的可能度量、损失、架构和编码器。

度量标准

由于语义分割可以看作是对每个像素的多类别分类,最直观的度量标准是平均准确率分数 – 即整个图像的像素准确率的平均值。

确实,它有时可以用来得出较为稳固的结果。然而,在大多数语义分割任务中,一些类别的像素远少于其他类别 – 例如,在城市图像中,路面和建筑物的像素可能很多,而人的或自行车的像素则相对较少。这时,模型可能会得到很好的准确率,但并不一定能准确分割那些低频类别。

由于准确率度量的局限性,许多其他度量标准应运而生。在语义分割中,最常用的度量之一是 IoU,已经在前面的教程中解释过。IoU 可以针对每个类别独立计算,然后求平均值来计算一个单一的度量(其他平均方法也存在,并在后续部分中有更详细的探讨)。IoU 有时也被称为Jaccard 指数

另一个常用的度量标准是Dice 系数。给定两个像素集,A(例如,某一类别的预测)和 B(例如,某一类别的真实标注),Dice 系数可以通过以下公式计算:

这里,|A| 仅仅是 A 中像素的数量,有时称为 A 的基数。Dice 系数通常与 F1 分数进行比较,并且在数学上是等价的。与 IoU 相似,Dice 系数也可以在所有类别上求平均。

当然,存在其他可以使用的度量标准,但它们超出了本教程的范围。

损失

多年来,已经开发出几种损失函数,以提高语义分割模型的性能。同样,如果我们仅将语义分割视为在多个像素上的分类任务,交叉熵损失是一个直观的选择。然而,像准确度评分一样,交叉熵损失在类别不平衡的情况下并不是一个好的选择。

在实践中,常常直接使用 Dice 损失,它直接重用 Dice 系数。Dice 损失在类别不平衡的情况下通常表现更好,但有时训练损失会波动较大。

还有许多其他损失函数被提出,比如焦点损失和 Tversky 损失,它们各有优点和改进。总结最广泛使用的损失函数的论文已在另见小节中引用。

架构

语义分割是一个非常特定的任务,因为与目标检测不同,输入和输出都是图像。事实上,对于一个给定的 480x640 大小的输入图像(特意省略 RGB 通道),期望输出图像具有相同的 480x640 维度,因为每个像素必须有一个预测的类别。

更准确地说,对于一个N类别的语义分割任务,输出维度将是 480x640xN,每个像素的输出是 Softmax 函数的N个概率值。

处理此类问题的架构通常基于编码器-解码器原理:

  • 编码器计算输入图像的特征描述。

  • 解码器解码这些编码特征,以便获得预期的输出。

最著名的语义分割架构之一是 U-Net 架构,如图 10.23所示:

图 10.23 – U-Net 架构,如原始论文《U-Net:用于生物医学图像分割的卷积网络》中所述

图 10.23 – U-Net 架构,如原始论文《U-Net:用于生物医学图像分割的卷积网络》中所述

图 10.23所示,U-Net 架构可以分解为如下:

  1. 输入图像位于图示的左上角。

  2. 输入图像被顺序编码,我们可以通过卷积层和池化层逐步向图示的底部移动。

  3. 当我们向图示的右上角回退时,编码器的输出被解码,并与之前的编码器输出通过卷积和上采样层进行拼接。

  4. 最终,预测的输出图像具有与输入图像相同的宽度和高度。

U-Net 的一个优点是它先进行编码再进行解码,并且它还将中间编码拼接起来,从而实现高效的预测。现在它已经成为语义分割的标准架构。

还存在其他架构,其中一些也被广泛使用,例如以下几种:

  • 特征金字塔 网络FPN

  • U-Net++,是 U-Net 的一个改进版本。

编码器

在原始的 U-Net 论文中,正如我们在图 10.23中看到的,编码器部分是一个特定的结构,由卷积层和池化层组成。然而,在实际应用中,通常会使用著名的网络作为编码器,这些网络在 ImageNet 或 COCO 数据集上进行了预训练,这样我们就可以利用迁移学习的优势。

根据需求和约束,可以使用多个编码器,如下所示:

  • MobileNet – 一个轻量级的编码器,专为边缘设备上的快速推理开发

  • 视觉几何组VGG)架构

  • ResNet 及其基于 ResNet 的架构

  • EfficientNet 架构

SMP 库

分割模型 PyTorchSMP)库是一个开源库,允许我们做所有需要的操作,包括以下内容:

  • 选择如 U-Net、FPN 或 U-Net++等架构

  • 选择像 VGG 或 MobileNet 这样的编码器

  • 已实现的损失函数,如 Dice 损失和焦点损失

  • 计算 Dice 和 IoU 等指标的辅助函数

我们将在本教程中使用这个库,在无人机数据集上训练语义分割模型。

开始使用

对于这个教程,我们需要下载一个包含 400 张图像及其相关标签的数据集。可以使用 Kaggle API 通过以下命令下载:

kaggle datasets download -d santurini/semantic-segmentation-dronedataset --unzip

我们最终得到三个文件夹,其中包含多个数据集。我们将使用classes_dataset中的数据集,这是一个包含五个类别的数据集。

我们还需要使用以下命令安装所需的库:

pip install matplotlib pillow torch torchvision segmentation-models-pytorch

如何实现…

我们将首先使用迁移学习在我们的任务上训练一个带有 MobileNet 编码器的 U-Net 模型,然后我们将通过冻结层并逐步降低学习率的微调技术进行相同的训练,以提高模型的性能。

使用 ImageNet 权重进行训练并解冻所有权重

我们将首先按常规方式训练一个在 ImageNet 上预训练的模型,所有权重都是可训练的:

  1. 我们将首先进行所需的导入操作:

    from torch.utils.data import DataLoader, Dataset
    
    import torch
    
    import matplotlib.pyplot as plt
    
    import torchvision.transforms as transforms
    
    import numpy as np
    
    import tqdm
    
    from glob import glob
    
    from PIL import Image
    
    import segmentation_models_pytorch as smp
    
    import torch.nn as nn
    
    import torch.optim as optim
    
  2. 实现DroneDataset类:

    class DroneDataset(Dataset):
    
        def __init__(self, images_path: str,
    
            masks_path: str, transform, train: bool,
    
            num_classes: int = 5):
    
                self.images_path = sorted(glob(
    
                    f'{images_path}/*.png'))
    
                self.masks_path = sorted(glob(
    
                    f'{masks_path}/*.png'))
    
                self.num_classes = num_classes
    
                if train:
    
                    self.images_path = self.images_path[
    
                       :int(.8*len(self.images_path))]
    
                    Self.masks_path = self.masks_path[
    
                        :int(.8*len(self.masks_path))]
    
                else:
    
                    self.images_path = self.images_path[
    
                        int(.8*len(self.images_path)):]
    
                    self.masks_path = self.masks_path[
    
                        int(.8*len(self.masks_path)):]
    
                self.transform = transform
    
        def __len__(self):
    
            return len(self.images_path)
    
        def __getitem__(self, idx):
    
            image = np.array(Image.open(
    
                self.images_path[idx]))
    
            mask = np.array(Image.open(
    
                self.masks_path[idx]))
    
            return self.transform(image), torch.tensor(
    
                mask, dtype=torch.long)
    

__init__方法只是读取所有可用的图像和掩膜文件。它还接受一个布尔变量,表示是训练集还是测试集,使你可以选择文件的前 80%或后 20%。

__getitem__方法只是从路径加载图像,并将转换后的图像以及掩膜作为张量返回。

  1. 实例化转换操作,并将其应用于图像——在这里,它只是简单的张量转换和归一化:

    transform = transforms.Compose([
    
        transforms.ToTensor(),
    
        transforms.Normalize((0.5, 0.5, 0.5),
    
            (0.5, 0.5, 0.5))
    
    ])
    
  2. 定义几个常量——批次大小、学习率、类别和设备:

    batch_size = 4
    
    learning_rate = 0.005
    
    classes = ['obstacles', 'water', 'soft-surfaces',
    
        'moving-objects', 'landing-zones']
    
    device = torch.device(
    
        'cuda' if torch.cuda.is_available() else 'cpu')
    
  3. 实例化数据集和数据加载器:

    train_dataset = DroneDataset(
    
        'classes_dataset/classes_dataset/original_images/',
    
        'classes_dataset/classes_dataset/label_images_semantic/',
    
        transform,
    
        train=True
    
    )
    
    train_dataloader = DataLoader(train_dataset,
    
        batch_size=batch_size, shuffle=True)
    
    test_dataset = DroneDataset(
    
        'classes_dataset/classes_dataset/original_images/',
    
        'classes_dataset/classes_dataset/label_images_semantic/',
    
        transform,
    
        train=False
    
    )
    
    test_dataloader = DataLoader(test_dataset,
    
        batch_size=batch_size, shuffle=True)
    
  4. 显示带有相关标签叠加的图像:

    # Get a batch of images and labels
    
    images, labels = next(iter(train_dataloader))
    
    # Plot the image and overlay the labels
    
    plt.figure(figsize=(12, 10))
    
    plt.imshow(images[0].permute(
    
        1, 2, 0).cpu().numpy() * 0.5 + 0.5)
    
    plt.imshow(labels[0], alpha = 0.8)
    
    plt.axis('off')
    

结果如下:

图 10.24 – 带有掩膜覆盖的无人机数据集图像,掩膜由五种颜色表示五个类别

图 10.24 – 带有掩膜覆盖的无人机数据集图像,掩膜由五种颜色表示五个类别

如我们所见,图像上叠加了几种颜色:

  • 黄色代表'landing-zones'

  • 深绿色代表'soft-surfaces'

  • 蓝色表示'water'

  • 紫色表示'obstacles'

  • 浅绿色表示'moving-objects'

  1. 实例化模型——一个 U-Net 架构,使用 EfficientNet 作为编码器(更具体地说,是'efficientnet-b5'编码器),并在imagenet上进行预训练:

    model = smp.Unet(
    
        encoder_name='efficientnet-b5',
    
        encoder_weights='imagenet',
    
        in_channels=3,
    
        classes=len(classes),
    
        )
    
  2. 实例化 Adam 优化器,并将损失设置为 Dice 损失:

    optimizer = optim.Adam(model.parameters(),
    
        lr=learning_rate)
    
    criterion = smp.losses.DiceLoss(
    
        smp.losses.MULTICLASS_MODE, from_logits=True)
    
  3. 实现一个辅助函数,compute_metrics,它将帮助计算 IoU 和 F1 分数(等同于 Dice 系数):

    def compute_metrics(stats):
    
        tp = torch.cat([x["tp"] for x in stats])
    
        fp = torch.cat([x["fp"] for x in stats])
    
        fn = torch.cat([x["fn"] for x in stats])
    
        tn = torch.cat([x["tn"] for x in stats])
    
        iou = smp.metrics.iou_score(tp, fp, fn, tn,
    
            reduction='micro')
    
        f1_score = smp.metrics.f1_score(tp, fp, fn, tn,
    
            reduction='micro')
    
        return iou, f1_score
    
  4. 实现一个辅助函数,epoch_step_unet,该函数将计算前向传播、需要时的反向传播、损失函数和指标:

    def epoch_step_unet(model, dataloader, device,
    
        num_classes, training_set: bool):
    
            stats = []
    
            for i, data in tqdm.tqdm(enumerate(
    
                dataloader, 0)):
    
                inputs, labels = data
    
                inputs = inputs.to(device)
    
                labels = labels.to(device)
    
                if training_set:
    
                    optimizer.zero_grad()
    
                    outputs = model(inputs)
    
                    loss = criterion(outputs, labels)
    
                if training_set:
    
                    loss.backward()
    
                    optimizer.step()
    
            tp, fp, fn, tn = smp.metrics.get_stats(
    
                torch.argmax(outputs, dim=1), labels,
    
                mode='multiclass',
    
                num_classes=num_classes)
    
            stats.append({'tp': tp, 'fp': fp, 'fn':fn,
    
                'tn': tn, 'loss': loss.item()})
    
        return stats
    
  5. 实现一个train_unet函数,允许我们训练模型:

    def train_unet(model, train_dataloader,
    
        test_dataloader, criterion, device,
    
        epochs: int = 10, num_classes: int = 5,
    
        scheduler=None):
    
        train_metrics = {'loss': [], 'iou': [], 'f1': [],
    
            'lr': []}
    
        test_metrics = {'loss': [], 'iou': [], 'f1': []}
    
        model = model.to(device)
    
        for epoch in range(epochs):
    
      # loop over the dataset multiple times
    
            # Train
    
            model.train()
    
            #running_loss = 0.0
    
            train_stats = epoch_step_unet(model,
    
                train_dataloader, device, num_classes,
    
                training_set=True)
    
            # Eval
    
            model.eval()
    
            with torch.no_grad():
    
                test_stats = epoch_step_unet(model,
    
                    test_dataloader, device, num_classes,
    
                    training_set=False)
    
            if scheduler is not None:
    
                train_metrics['lr'].append(
    
                    scheduler.get_last_lr())
    
                scheduler.step()
    
            train_metrics['loss'].append(sum(
    
                [x['loss'] for x in train_stats]) / len(
    
                    train_dataloader))
    
            test_metrics['loss'].append(sum(
    
                [x['loss'] for x in test_stats]) / len(
    
                    test_dataloader))
    
            iou, f1 = compute_metrics(train_stats)
    
            train_metrics['iou'].append(iou)
    
            train_metrics['f1'].append(f1)
    
            iou, f1 = compute_metrics(test_stats)
    
            test_metrics['iou'].append(iou)
    
            test_metrics['f1'].append(f1)
    
            print(f"[{epoch + 1}] train loss: {train_metrics['loss'][-1]:.3f} IoU: {train_metrics['iou'][-1]:.3f} | \
    
                    test loss: {
    
                        test_metrics['loss'][-1]:.3f} IoU:
    
                        {test_metrics['iou'][-1]:.3f}")
    
        return train_metrics, test_metrics
    

train_unet函数执行以下操作:

  • 在训练集上训练模型,并计算评估指标(IoU 和 F1 分数)

  • 使用评估指标在测试集上评估模型

  • 如果提供了学习率调度器,则应用一个步骤(更多信息请见更多内容小节)

  • 在标准输出中显示训练和测试的损失以及 IoU

  • 返回训练和测试的指标

  1. 训练模型 50 个 epochs 并存储输出的训练和测试指标:

    train_metrics, test_metrics = train_unet(model,
    
        train_dataloader, test_dataloader, criterion,
    
        device, epochs=50, num_classes=len(classes))
    
  2. 显示训练集和测试集的指标:

    plt.figure(figsize=(10, 10))
    
    plt.subplot(3, 1, 1)
    
    plt.plot(train_metrics['loss'], label='train')
    
    plt.plot(test_metrics['loss'], label='test')
    
    plt.ylabel('Dice loss')
    
    plt.legend()
    
    plt.subplot(3, 1, 2)
    
    plt.plot(train_metrics['iou'], label='train')
    
    plt.plot(test_metrics['iou'], label='test')
    
    plt.ylabel('IoU')
    
    plt.legend()
    
    plt.subplot(3, 1, 3)
    
    plt.plot(train_metrics['f1'], label='train')
    
    plt.plot(test_metrics['f1'], label='test')
    
    plt.xlabel('epoch')
    
    plt.ylabel('F1-score')
    
    plt.legend()
    
    plt.show()
    

这是结果:

图 10.25 – Dice 损失(上图),IoU(中图)和 F1 分数(下图)随 epoch 变化的图像,分别显示训练集和测试集

图 10.25 – Dice 损失(上图),IoU(中图)和 F1 分数(下图)随 epoch 变化的图像,分别显示训练集和测试集

如我们所见,IoU 在测试集上达到了 87%,并且在大约 30 个 epochs 后似乎达到了一个平台期。此外,测试集的指标波动较大且不稳定,这可能是由于学习率过高或模型过大所致。

现在让我们尝试使用冻结层,并逐渐降低学习率来做同样的事情。

通过冻结层来微调预训练模型

我们现在将对预训练模型进行两阶段训练——首先,我们将在 20 个 epochs 内冻结模型的大部分层,然后解冻所有层,再训练 30 个 epochs 来微调模型:

  1. 首先,让我们定义两个辅助函数来冻结和解冻层。freeze_encoder函数将冻结编码器的所有层,直到给定的模块级别,由max_level参数提供。如果未给定max_level,则编码器的所有权重将被冻结:

    def freeze_encoder(model, max_level: int = None):
    
        for I, child in enumerate(model.encoder.children()):
    
            if max_level is not None and i >= max_level:
    
                    return
    
            for param in child.parameters():
    
                param.requires_grad = False
    
        return
    
    def unfreeze(model):
    
        for child in model.children():
    
            for param in child.parameters():
    
                param.requires_grad = True
    
        return
    
  2. 实例化一个新模型,与之前的模型相同,并打印可训练参数的数量:

    model = smp.Unet(
    
        encoder_name='efficientnet-b5',
    
        encoder_weights='imagenet',
    
        in_channels=3,
    
        classes=len(classes),
    
        )
    
    print''Total number of trainable parameters'', sum(p.numel() for p in model.parameters() if p.requires_grad))
    

代码输出如下:

Total number of trainable parameters: 31216581

如我们所见,模型由大约 3120 万个参数构成。

  1. 现在我们冻结编码器的一部分——前三个模块,这基本上是编码器的大部分权重——并打印剩余可训练参数的数量:

    # Freeze the of the encoder
    
    freeze_encoder(model, 3)
    
    print('Total number of trainable parameters:', sum(p.numel() for p in model.parameters() if p.requires_grad))
    

输出如下:

Total number of trainable parameters: 3928469

我们现在只剩下大约 390 万个可训练参数。编码器中的大约 2730 万个参数现在被冻结,编码器中的总参数约为 2800 万个——剩余的参数来自解码器。这意味着我们将首先训练解码器,并将预训练的编码器用作特征提取器。

  1. 实例化一个新的优化器进行训练,并使用调度器。我们将在这里使用 ExponentialLR 调度器,gamma 值为 0.95 ——这意味着在每个 epoch 后,学习率将乘以 0.95:

    optimizer = optim.Adam(model.parameters(),
    
        lr=learning_rate)
    
    scheduler = optim.lr_scheduler.ExponentialLR(
    
        optimizer, gamma=0.95)
    
  2. 在冻结层的情况下训练模型,训练 20 个 epoch:

    train_metrics, test_metrics = train_unet(model,
    
        train_dataloader, test_dataloader, criterion,
    
        device, epochs=20, num_classes=len(classes),
    
        scheduler=scheduler)
    

如我们所见,仅经过 20 个 epoch,测试集上的 IoU 已经达到了 88%,略高于没有冻结且没有任何学习率衰减的情况。

  1. 现在解码器和编码器的最后几层已对该数据集进行预热,让我们在训练更多的 epoch 之前解冻所有参数:

    unfreeze(model)
    
    print('Total number of trainable parameters:', sum(p.numel() for p in model.parameters() if p.requires_grad))
    

代码输出如下:

Total number of trainable parameters: 31216581

如我们所见,可训练参数现在已回升至 3120 万,意味着所有参数都可以训练。

  1. 在 30 个 epoch 后训练模型:

    train_metrics_unfreeze, test_metrics_unfreeze = train_unet(
    
    model, train_dataloader, test_dataloader,
    
        criterion, device, epochs=30,
    
        num_classes=len(classes), scheduler=scheduler)
    
  2. 通过将冻结与不冻结的结果拼接,绘制结果:

    plt.figure(figsize=(10, 10))
    
    plt.subplot(3, 1, 1)
    
    plt.plot(train_metrics['loss'] + train_metrics_unfreeze['loss'], label='train')
    
    plt.plot(test_metrics['loss'] + test_metrics_unfreeze['loss'], label='test')
    
    plt.ylabel('Dice loss')
    
    plt.legend()
    
    plt.subplot(3, 1, 2)
    
    plt.plot(train_metrics['iou'] + train_metrics_unfreeze['iou'], label='train')
    
    plt.plot(test_metrics['iou'] + test_metrics_unfreeze['iou'], label='test')
    
    plt.ylabel('IoU')
    
    plt.legend()
    
    plt.subplot(3, 1, 3)
    
    plt.plot(train_metrics['f1'] + train_metrics_unfreeze['f1'], label='train')
    
    plt.plot(test_metrics['f1'] + test_metrics_unfreeze['f1'], label='test')
    
    plt.xlabel('epoch')
    
    plt.ylabel('F1-score')
    
    plt.legend()
    
    plt.show()
    

这是结果:

图 10.26 – Dice 损失(顶部)、IoU(中间)和 F1-score(底部)作为经过微调的训练集和测试集的 epoch 函数 – 在解冻权重时出现下降后,指标再次提高并保持稳定

图 10.26 – Dice 损失(顶部)、IoU(中间)和 F1-score(底部)作为经过微调的训练集和测试集的 epoch 函数 – 在解冻权重时出现下降后,指标再次提高并保持稳定

我们可以看到,一旦在第 20 个 epoch 解冻所有参数,曲线就变得有些颠簸。然而,在接下来的 10 个 epoch(大约在第 30 个 epoch 时),指标再次变得稳定。

总共经过 50 个 epoch 后,IoU 几乎达到了 90%,而没有微调技术(冻结和学习率衰减)时,IoU 仅为 87%。

  1. 出于好奇,我们还可以将学习率作为 epoch 的函数绘制出来,以查看其下降情况:

    plt.plot(train_metrics['lr'] + train_metrics_unfreeze['lr'])
    
    plt.xlabel('epoch')
    
    plt.ylabel('Learning rate')
    
    plt.show()
    

这是结果:

图 10.27 – 对于 torch ExponentialLR 类,学习率值作为 epoch 的函数,gamma 值为 0.95

图 10.27 – 对于 torch ExponentialLR 类,学习率值作为 epoch 的函数,gamma 值为 0.95

正如预期的那样,在 50 个 epoch 后,初始学习率 0.05 被大约除以 13,降到大约 0.0003,因为

还有更多…

计算语义分割中的指标(如 IoU 或 Dice 系数)有多种方法。在本食谱中,正如在 compute_metrics 函数中实现的那样,选择了 'micro' 选项,以下是代码:

smp.metrics.iou_score(tp, fp, fn, tn, reduction='micro')

首先,我们可以像在任何其他分类任务中一样,定义每个像素的 TP、FP、FN 和 TN。然后,基于这些值计算指标。

基于此,最常见的计算方法已经在 SMP 文档中得到了很好的总结:

  • 'micro':对所有图像和类别中的 TP、FP、FN 和 TN 像素进行求和,然后计算得分。

  • 'macro':对所有图像中的每个标签的 TP、FP、FN 和 TN 像素求和,为每个标签计算得分,然后对所有标签取平均。如果存在类别不平衡(通常在语义分割中会出现),该方法将不予考虑,因此应该避免使用。

  • 'weighted':与 'macro' 相同,但使用标签的加权平均。

  • 'micro-imagewise''macro-imagewise''weighted-imagewise':分别与 'micro''macro''weighted' 相同,但它们在对所有图像求平均之前,会先独立地对每张图像计算得分。当数据集中的图像尺寸不一致时,这种方法非常有用。

大多数时候,'micro''weighted' 方法效果不错,但理解它们之间的差异并能够灵活使用总是很有帮助的。

另见

第十一章:计算机视觉中的正则化 – 合成图像生成

本章将重点介绍用于生成合成图像进行数据增强的技术和方法。拥有多样化的数据通常是正则化计算机视觉模型的最有效方法之一。许多方法可以帮助我们生成合成图像;从简单的技巧如图像翻转,到使用生成模型创建新图像。本章将探讨几种技术,包括以下内容:

  • 使用 Albumentations 进行图像增强

  • 创建合成图像用于目标检测 – 仅使用合成数据训练目标检测模型

  • 实时风格迁移 – 基于 Stable Diffusion(一个强大的生成模型)训练实时风格迁移模型

技术要求

在本章中,我们将训练几个深度学习模型并生成图像。我们将需要以下库:

  • NumPy

  • Matplotlib

  • Albumentations

  • Pytorch

  • torchvision

  • ultralytics

使用 Albumentations 应用图像增强

机器学习ML)中,数据对于提高模型的性能至关重要。计算机视觉也不例外,图像数据增强可以轻松提升到另一个层次。

事实上,轻松增强图像是可能的,例如,通过镜像操作,如 图 11.1 所示。

图 11.1 – 左侧是我狗的原始照片,右侧是我狗的镜像照片

图 11.1 – 左侧是我狗的原始照片,右侧是我狗的镜像照片

然而,除此之外,还有更多类型的增强方法,这些方法可以分为两大类:像素级和空间级变换。

接下来我们将讨论这两种方法。

空间级增强

镜像操作是空间级增强的一个例子;然而,除了简单的镜像操作,还可以做更多的变换。例如,见以下内容:

  • 平移:将图像平移到某个方向

  • 剪切:对图像进行剪切变换

  • 裁剪:只裁剪图像的一部分

  • 旋转:对图像进行旋转

  • 转置:对图像进行转置(换句话说,应用垂直和水平翻转)

  • 透视:对图像应用四点透视

正如我们所见,空间级增强有许多可能性。图 11.2 展示了在给定图像上这些可能性的一些例子,并显示了一些可能出现的伪影:平移图像时的黑色边框和旋转图像时的镜像填充。

图 11.2 – 原始图像(左上角)和五种不同的增强方式(请注意,某些伪影可能会出现,如黑色边框)

图 11.2 – 原始图像(左上角)和五种不同的增强方式(请注意,某些伪影可能会出现,如黑色边框)

像素级增强

另一种增强方式是像素级增强,和空间级增强一样有用。一个简单的例子可能是改变图像的亮度和对比度,使得模型能在不同的光照条件下更具鲁棒性。

以下是一些像素级增强的非详尽列表:

  • 亮度:修改亮度

  • 对比度:修改对比度

  • 模糊:模糊图像

  • HSV:随机修改图像的色相饱和度明度

  • 颜色转换:将图像转换为黑白或褐色调

  • 噪声:向图像添加噪声

  • 这里有许多可能性,这些变化可能大大提升模型的鲁棒性。以下图示展示了这些增强的一些结果:

图 11.3 – 原始图像(左上)和几种像素级增强

图 11.3 – 原始图像(左上)和几种像素级增强

如我们所见,使用像素级和空间级转换,我们可以很容易地将一张图像增强成 5 到 10 张图像。此外,这些增强有时可以组合起来以获得更多的多样性。当然,它无法替代一个真实的大型多样化数据集,但图像增强通常比收集数据便宜,并且能够显著提升模型性能。

Albumentations

当然,我们不必手动重新实现所有这些图像增强。现在有很多图像增强的库,Albumentations 无疑是市场上最完整、最自由、开源的解决方案。正如我们在这个教程中所见,Albumentations 库只需要几行代码就能实现强大的图像增强。

开始

在这个教程中,我们将应用图像增强来解决一个简单的挑战:分类猫和狗。

我们首先需要下载并准备数据集。这个数据集最初由微软提出,包含 12,491 张猫的图片和 12,470 张狗的图片。可以通过 Kaggle API 使用以下命令行操作进行下载:

kaggle datasets download -d karakaggle/kaggle-cat-vs-dog-dataset --unzip

这将下载一个名为kagglecatsanddogs_3367a的文件夹。

不幸的是,数据集尚未拆分为训练集和测试集。以下代码将把它拆分为 80%的训练集和 20%的测试集:

from glob import glob
import os
cats_folder = 'kagglecatsanddogs_3367a/PetImages/Cat/'
dogs_folder = 'kagglecatsanddogs_3367a/PetImages/Dog/'
cats_paths = sorted(glob(cats_folder + '*.jpg'))
dogs_paths = sorted(glob(dogs_folder + '*.jpg'))
train_ratio = 0.8
os.mkdir(cats_folder + 'train')
os.mkdir(cats_folder + 'test')
os.mkdir(dogs_folder + 'train')
os.mkdir(dogs_folder + 'test')
for i in range(len(cats_paths)):
    if i <= train_ratio * len(cats_paths):
        os.rename(cats_paths[i], cats_folder + 'train/' + cats_paths[i].split('/')[-1])
    else:
        os.rename(cats_paths[i], cats_folder + 'test/' + cats_paths[i].split('/')[-1])
for i in range(len(dogs_paths)):
    if i <= train_ratio * len(dogs_paths):
        os.rename(dogs_paths[i], dogs_folder + 'train/' + dogs_paths[i].split('/')[-1])
    else:
        os.rename(dogs_paths[i], dogs_folder + 'test/' + dogs_paths[i].split('/')[-1])

这将创建traintest子文件夹,因此kagglecatsanddogs_3367a文件夹结构现在如下所示:

kagglecatsanddogs_3367a
└── PetImages
    ├── Cat
    │   ├── train: 9993 images
    │   └── test: 2497 images
    └── Dog
        ├── train: 9976 images
        └── test: 2493 images

现在我们将能够高效地训练和评估一个针对该数据集的模型。

所需的库可以通过以下命令行安装:

pip install matplotlib numpy torch torchvision albumentations

如何操作…

现在,我们将在训练数据集上训练一个 MobileNet V3 网络,并在测试数据集上进行评估。然后,我们将使用 Albumentations 添加图像增强,以提高模型的性能:

  1. 首先,导入所需的库:

    • matplotlib用于显示和可视化

    • numpy用于数据处理

    • Pillow用于图像加载

    • glob用于文件夹解析

    • torchtorchvision用于模型训练及相关util实例

下面是import语句:

import matplotlib.pyplot as plt
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import DataLoader, Dataset
import torchvision.transforms as transforms
from torchvision.models import mobilenet_v3_small
from glob import glob
from PIL import Image
  1. 接下来,我们实现DogsAndCats数据集类。它接受以下参数:

    • cats_folder:包含猫咪图片的文件夹路径

    • dogs_folder:包含狗狗图片的文件夹路径

    • transform:应用于图像的转换(例如,调整大小、转换为张量等)

    • augment:应用图像增强,如我们将在本节后半部分做的那样

这是实现的代码:

class DogsAndCats(Dataset) :
    def __init__(self, cats_folder: str,
        dogs_folder: str, transform, augment = None):
            self.cats_path = sorted(glob(
                f'{cats_folder}/*.jpg'))
            self.dogs_path = sorted(glob(
                f'{dogs_folder}/*.jpg'))
            self.images_path = self.cats_path + self.dogs_path
            self.labels = [0.]*len(
            self.cats_path) + [1.]*len(self.dogs_path)
            self.transform = transform
            self.augment = augment
    def __len__(self):
        return len(self.images_path)
    def __getitem__(self, idx):
        image = Image.open(self.images_path[
            idx]).convert('RGB')
        if self.augment is not None:
            image = self.augment(
                image=np.array(image))["image"]
        return self.transform(image),
        torch.tensor(self.labels[idx],
            dtype=torch.float32)

这个类相当简单:构造函数收集所有图像路径,并相应地定义标签。获取器简单地加载图像, optionally 应用图像增强,并返回带有相关标签的tensor形式的图像。

  1. 然后,我们实例化转换类。在这里,我们组合了三个转换:

    • 张量转换

    • 由于并非所有图像的大小都相同,因此将图像调整为 224x224 大小

    • 图像输入的标准化

这是它的代码:

transform = transforms.Compose([
    transforms.ToTensor(),
    transforms.Resize((224, 224), antialias=True),
    transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5,
        0.5)),
])
  1. 然后,我们创建一些有用的变量,例如批量大小、设备和周期数:

    batch_size = 64
    
    device = torch.device(
    
        'cuda' if torch.cuda.is_available() else 'cpu')
    
    epochs = 20
    
  2. 实例化数据集和数据加载器。重用准备工作小节中提前准备好的训练和测试文件夹,我们现在可以轻松创建两个加载器。它们都使用相同的转换:

    trainset = DogsAndCats(
    
        'kagglecatsanddogs_3367a/PetImages/Cat/train/',
    
        'kagglecatsanddogs_3367a/PetImages/Dog/train/',
    
        transform=transform
    
    )
    
    train_dataloader = DataLoader(trainset,
    
        batch_size=batch_size, shuffle=True)
    
    testset = DogsAndCats(
    
        'kagglecatsanddogs_3367a/PetImages/Cat/test/',
    
        'kagglecatsanddogs_3367a/PetImages/Dog/test/',
    
        transform=transform
    
    )
    
    test_dataloader = DataLoader(testset,
    
        batch_size=batch_size, shuffle=True)
    
  3. 现在,我们使用以下代码展示一些图像及其标签,以便浏览数据集:

    def display_images(dataloader, classes = ['cat', 'dog']):
    
        plt.figure(figsize=(14, 10))
    
        images, labels = next(iter(dataloader))
    
        for idx in range(8):
    
            plt.subplot(2, 4, idx+1)
    
            plt.imshow(images[idx].permute(
    
                1, 2, 0).numpy() * 0.5 + 0.5)
    
            plt.title(classes[int(labels[idx].item())])
    
            plt.axis('off')
    
    display_images(train_dataloader)
    

这是图像:

图 11.4 – 数据集中的图像样本

图 11.4 – 数据集中的图像样本

如图所示,这是一个由各种场景下的狗和猫的常规图像组成的数据集,有时图片中也包含人类。

  1. 现在,我们实现Classifier类。我们将重用在pytorch中提供的现有mobilenet_v3_small实现,并仅添加一个具有一个单元和 sigmoid 激活函数的输出层,如下所示:

    class Classifier(nn.Module):
    
        def __init__(self):
    
            super(Classifier, self).__init__()
    
            self.mobilenet = mobilenet_v3_small()
    
            self.output_layer = nn.Linear(1000, 1)
    
        def forward(self, x):
    
            x = self.mobilenet(x)
    
            x = nn.Sigmoid()(self.output_layer(x))
    
            return x
    
  2. 接下来,我们实例化模型:

    model = Classifier()
    
    model = model.to(device)
    
  3. 然后,实例化损失函数为二元交叉熵损失,这非常适合二分类问题。接下来,我们实例化 Adam 优化器:

    criterion = nn.BCELoss()
    
    optimizer = torch.optim.Adam(model.parameters(),
    
        lr=0.001)
    
  4. 然后,训练模型 20 个周期并存储输出。为此,我们使用train_model函数,该函数在给定的周期数和数据集上训练输入模型。它返回每个周期的训练集和测试集的损失和准确率。此函数可以在 GitHub 仓库中找到(github.com/PacktPublishing/The-Regularization-Cookbook/blob/main/chapter_11/chapter_11.ipynb),这是二分类训练的典型代码,如我们在前几章中使用的那样:

    train_losses, test_losses, train_accuracy, 
    
    test_accuracy = train_model(
    
        epochs, model, criterion, optimizer, device,
    
        train_dataloader, test_dataloader, trainset,
    
        testset
    
    )
    
  5. 最后,将损失和准确率作为周期的函数进行显示:

    plt.figure(figsize=(10, 10))
    
    plt.subplot(2, 1, 1)
    
    plt.plot(train_losses, label='train')
    
    plt.plot(test_losses, label='test')
    
    plt.ylabel('BCE Loss')
    
    plt.legend()
    
    plt.subplot(2, 1, 2)
    
    plt.plot(train_accuracy, label='train')
    
    plt.plot(test_accuracy, label='test')
    
    plt.xlabel('Epoch')
    
    plt.ylabel('Accuracy')
    
    plt.legend()
    
    plt.show()
    

下面是它的图示:

图 11.5 – 二进制交叉熵损失(顶部)和准确度(底部)作为训练和测试集的周期函数,无增强(损失和准确度表明过拟合,测试准确度在约 88%时达到平台期)

图 11.5 – 二进制交叉熵损失(顶部)和准确度(底部)作为训练和测试集的周期函数,无增强(损失和准确度表明过拟合,测试准确度在约 88%时达到平台期)

如我们所见,测试准确度似乎在大约 10 个周期后达到了平台期,峰值准确度约为 88%。训练准确度达到了 98%,这表明在训练集上存在强烈的过拟合现象。

使用图像增强进行训练

现在让我们使用图像增强重新做同样的练习:

  1. 首先,我们需要实现所需的图像增强。使用与pytorch中变换相同的模式,借助 Albumentations,我们可以实例化一个包含增强列表的Compose类。在我们的例子中,我们使用以下增强方法:

    • HorizontalFlip:这涉及基本的镜像操作,发生的概率为 50%,意味着 50%的图像会被随机镜像

    • Rotate:这将以 50%的概率随机旋转图像,旋转范围为[-90, 90]度(此范围可修改)

    • RandomBrightnessContrast:这将以 20%的概率随机改变图像的亮度和对比度

这里是实例化的代码:

import albumentations as A
augment = A.Compose([
    A.HorizontalFlip(p=0.5),
    A.Rotate(p=0.5),
    A.RandomBrightnessContrast(p=0.2),
])
  1. 然后,实例化一个新的增强训练集和训练数据加载器。为此,我们只需将augment对象作为DogsAndCats类的参数提供即可:

    augmented_trainset = DogsAndCats(
    
        'kagglecatsanddogs_3367a/PetImages/Cat/train/',
    
        'kagglecatsanddogs_3367a/PetImages/Dog/train/',
    
        transform=transform,
    
        augment=augment,
    
    )
    
    augmented_train_dataloader = DataLoader(
    
        augmented_trainset, batch_size=batch_size,
    
        shuffle=True)
    

注意

我们不对测试集应用增强,因为我们需要能够将增强结果与未增强的结果进行比较。此外,除非使用测试时间增强(有关更多信息,请参见更多…部分),否则增强测试集是没有意义的。

  1. 然后,按如下方式显示该新增强数据集中的一些图像:

    display_images(augmented_train_dataloader)
    

这里是图像:

图 11.6 – 增强图像示例(有些已旋转,有些已镜像,还有些亮度和对比度已被修改)

图 11.6 – 增强图像示例(有些已旋转,有些已镜像,还有些亮度和对比度已被修改)

如我们所见,某些图像现在似乎被旋转了。此外,某些图像还被镜像并且亮度与对比度发生了变化,有效地提高了数据集的多样性。

  1. 然后,我们实例化模型和优化器:

    model = Classifier()
    
    model = model.to(device)
    
    optimizer = torch.optim.Adam(model.parameters(),
    
        lr=0.001)
    
  2. 接下来,在这个新的训练集上训练模型,同时保持相同的测试集,并记录输出的损失和指标:

    train_losses, test_losses, train_accuracy, 
    
    test_accuracy = train_model(
    
        epochs, model, criterion, optimizer, device,
    
        augmented_train_dataloader, test_dataloader,
    
        trainset, testset
    
    )
    

注意

在这个过程中,我们在线进行增强,意味着每次加载新的图像批次时,我们都会随机应用增强;因此,在每个周期中,我们可能会从不同增强的图像中训练。

另一种方法是离线增强数据:我们对数据集进行预处理和增强,存储增强后的图像,然后只在这些数据上训练模型。

两种方法各有优缺点:离线增强允许我们只增强一次图像,但需要更多的存储空间,而在线预处理可能需要更多时间来训练,但不需要额外的存储。

  1. 现在,最后,我们绘制结果:训练集和测试集的损失与准确率。以下是相应的代码:

    plt.figure(figsize=(10, 10))
    
    plt.subplot(2, 1, 1)
    
    plt.plot(train_losses, label='train')
    
    plt.plot(test_losses, label='test')
    
    plt.ylabel('BCE Loss')
    
    plt.legend()
    
    plt.subplot(2, 1, 2)
    
    plt.plot(train_accuracy, label='train')
    
    plt.plot(test_accuracy, label='test')
    
    plt.xlabel('epoch')
    
    plt.ylabel('Accuracy')
    
    plt.legend()
    
    plt.show()
    

这里是图示:

图 11.7 – 增强数据集的损失与准确率

图 11.7 – 增强数据集的损失与准确率

如你在前面的图示中看到的,与常规数据集相比,过拟合几乎完全消除,并且准确率也提高到了超过 91%,而之前为 88%。

多亏了这个相当简单的图像增强,我们将准确率从 88%提升到 91%,同时减少了过拟合:训练集的表现现在和测试集一样。

还有更多…

虽然我们使用了增强来进行训练,但有一种方法可以在测试时利用图像增强来提高模型的表现,这有时被称为测试时增强

这个方法很简单:对多个增强过的图像进行模型推理,并通过多数投票计算最终预测。

让我们来看一个简单的例子。假设我们有一张输入图像,必须使用我们训练好的狗猫模型进行分类,我们通过镜像以及调整亮度和对比度对这张输入图像进行增强,从而得到三张图像:

  • 图像 1:原始图像

  • 图像 2:镜像图像

  • 图像 3:调整亮度和对比度后的图像

现在我们将在这三张图像上进行模型推理,得到以下预测结果:

  • 图像 1 预测:猫

  • 图像 2 预测:猫

  • 图像 3 预测:狗

现在我们可以通过选择最多代表的预测类别来计算多数投票,从而得出猫类的预测结果。

注意

在实践中,我们很可能使用软多数投票法,通过对预测的概率(无论是二分类还是多分类)进行平均来进行预测,但概念是一样的。

测试时增强(Test Time Augmentation)在竞赛中常常使用,确实可以在不增加训练成本的情况下提高模型的表现。然而,在生产环境中,推理成本至关重要,因此这种方法很少使用。

另见

在这个案例中,我们使用了 Albumentations 来进行简单的分类任务,但它可以用于更多的场景:它允许我们进行目标检测、实例分割、语义分割、地标检测等图像增强。

要了解如何充分使用它,请查看编写得很好的文档,其中有许多工作示例,网址如下:https://albumentations.ai/docs/。

创建用于目标检测的合成图像

对于某些项目,数据量可能非常少,以至于你只能使用这些数据作为测试集。在某些罕见情况下,可以创建合成数据集,训练一个足够强大的模型,并使用小型真实测试集对其进行测试。

这是我们在这个食谱中要做的:我们有一个小型的二维码图片测试集,我们希望为二维码的检测构建一个目标检测模型。作为训练集,我们只有一组生成的二维码和从如 unsplash.com 等开放图像网站下载的图片。

开始入门

使用以下命令行从 https://www.kaggle.com/datasets/vincentv/qr-detection-yolo 下载并解压数据集:

kaggle datasets download -d vincentv/qr-detection-yolo --unzip

这个数据集由以下文件夹结构组成:

QR-detection-yolo
├── train
│   ├── images: 9750 images
│   └── labels: 9750 text files
├── test
│   ├── images: 683 images
│   └── labels: 683 text files
└── background_images: 44 images

它由三个文件夹组成:

  • 训练集:仅包含没有上下文的生成二维码

  • 测试集:在不同环境和场景中的二维码图片

  • 背景图片:如商店等场景的随机图像

目标是使用训练集中的数据和背景图像生成逼真的合成图像,以训练模型,然后再在由真实图像组成的测试集上评估模型。

对于这个食谱,所需的库可以通过以下命令行安装:

pip install albumentations opencv-python matplotlib numpy ultralytics.

如何实现…

让我们将这个食谱分成三部分:

  1. 首先,我们将探索数据集并实现一些辅助函数。

  2. 第二部分是关于使用二维码和背景图片生成合成数据的。

  3. 最后一部分是关于在生成的数据上训练 YOLO 模型并评估该模型。

让我们在接下来的章节中了解每个部分。

探索数据集

让我们从创建一些辅助函数开始,并使用它们显示训练集和测试集中的一些图像:

  1. 导入以下库:

    • glob 用于列出文件

    • os 用于创建存储生成合成图像的目录

    • albumentations 用于数据增强

    • cv2 用于图像处理

    • matplotlib 用于显示

    • numpy 用于各种数据处理

    • YOLO 用于模型

这是导入的库:

from glob import glob
import os
import albumentations as A
import cv2
import matplotlib.pyplot as plt
import numpy as np
from ultralytics import YOLO
  1. 让我们实现一个 read_labels 辅助函数,它将读取 YOLO 标签的文本文件并将其作为列表返回:

    def read_labels(labels_path):
    
        res = []
    
        with open(labels_path, 'r') as file:
    
            lines = file.readlines()
    
            for line in lines:
    
                cls,xc,yc,w,h = line.strip().split(' ')
    
                res.append([int(float(cls)), float(xc),
    
                    float(yc), float(w), float(h)])
    
            file.close()
    
        return res
    
  2. 现在,让我们实现一个 plot_labels 辅助函数,它将重用之前的 read_labels 函数,读取一些图像及其相应标签,并显示这些带有边界框的图像:

    def plot_labels(images_folder, labels_folder,
    
        classes):
    
            images_path = sorted(glob(
    
                images_folder + '/*.jpg'))
    
         labels_path = sorted(glob(
    
                labels_folder + '/*.txt'))
    
        plt.figure(figsize=(10, 6))
    
        for i in range(8):
    
            idx = np.random.randint(len(images_path))
    
            image = plt.imread(images_path[idx])
    
            labels = read_labels(labels_path[idx])
    
            for cls, xc, yc, w, h in labels:
    
                xc = int(xc*image.shape[1])
    
                yc = int(yc*image.shape[0])
    
                w = int(w*image.shape[1])
    
                h = int(h*image.shape[0])
    
                cv2.rectangle(image,
    
                    (xc - w//2, yc - h//2),
    
                    (xc + w//2 ,yc + h//2), (255,0,0), 2)
    
                cv2.putText(image, f'{classes[int(cls)]}',
    
                    (xc-w//2, yc - h//2 - 10),
    
                    cv2.FONT_HERSHEY_SIMPLEX, 0.5,
    
                    (1.,0.,0.), 1)
    
            plt.subplot(2, 4, i + 1)
    
            plt.imshow(image)
    
            plt.axis('off')
    
  3. 现在,使用以下代码显示训练集中的一组图像及其边界框:

    plot_labels('QR-detection-yolo/train/images/',
    
        'QR-detection-yolo/train/labels/', 'QR Code')
    

这里有一些二维码形式的示例图像:

图 11.8 – 来自训练集的一些样本及其标签(该数据集仅由白色背景上的生成二维码组成)

图 11.8 – 来自训练集的一些样本及其标签(该数据集仅由白色背景上的生成二维码组成)

如前所述,训练集仅由各种大小的生成二维码组成,背景是白色的,没有更多上下文。

  1. 现在,使用以下代码显示一些来自测试集的图像:

    plot_labels('QR-detection-yolo/test/images/',
    
        'QR-detection-yolo/test/labels/', 'QR Code')
    

以下是生成的图像:

图 11.9 – 测试集中一些示例,由真实世界的二维码图像组成

图 11.9 – 测试集中一些示例,由真实世界的二维码图像组成

测试集包含更复杂、真实的二维码示例,挑战性更大。

从背景图像生成合成数据集

在这一部分,我们将生成一个逼真的合成数据集。为此,我们将使用训练集中的二维码图像以及一组背景图像。步骤如下:

  1. 现在,我们使用两种材料生成一个合成数据集:

    • 真实背景图像

    • 数字生成的二维码

为此,我们将使用一个相当长且复杂的函数,generate_synthetic_background_image_with_tag,它执行以下操作:

  • 从给定文件夹中随机选择一个背景图像

  • 从给定文件夹中随机选择一个二维码图像

  • 增强选定的二维码

  • 随机将增强的二维码插入背景图像中

  • 对新创建的图像应用更多增强

  • 将生成的图像及其相应的标签以 YOLO 格式存储

执行此操作的代码可以在 GitHub 仓库中找到,代码较长,无法在这里显示,因此我们只展示它的签名和文档字符串。不过,我们强烈建议你查看并进行尝试。代码可在此找到:

https://github.com/PacktPublishing/The-Regularization-Cookbook/blob/main/chapter_11/chapter_11.ipynb

def generate_synthetic_background_image_with_tag(
    n_images_to_generate: int,
    output_path: str,
    raw_tags_folder: str,
    background_images_path: str,
    labels_path: str,
    background_proba: float = 0.8,
):
    """Generate images with random tag and synthetic background.
    Parameters
    ----------
    n_images_to_generate : int
        The number of images to generate.
    output_path : str
        The output directory path where to store the generated images.
        If the path does not exist, the directory is created.
    raw_tags_folder : str
        Path to the folder containing the raw QR codes.
    background_images_path : str
        Path to the folder containing the background images.
    labels_path : str
        Path to the folder containing the labels.
        Files must be in the same order as the ones in the raw_tags_folder.
    background_proba : float (optional, default=0.8)
        Probability to use a background image when generating a new sample.
    """

注意

该函数会根据需求多次执行此生成,并提供一些其他功能;请随时深入查看并更新它。

  1. 我们现在可以使用这个函数通过调用 generate_synthetic_background_image_with_tag 来生成 3,000 张图像(3,000 是一个相当任意的选择,可以根据需要生成更多或更少的图像)。这可能需要几分钟。生成的图像及其相关标签将存储在 QR-detection-yolo/generated_qr_code_images/ 文件夹中,如果该文件夹不存在,将自动创建:

    generate_synthetic_background_image_with_tag(
    
        n_images_to_generate=3000,
    
        output_path='QR-detection-yolo/generated_qr_code_images/',
    
        raw_tags_folder='QR-detection-yolo/train/images/',
    
        background_images_path='QR-detection-yolo/background_images/',
    
        labels_path='QR-detection-yolo/train/labels/'
    
    )
    

让我们通过以下代码查看一些生成图像的示例:

plot_labels(
    'QR-detection-yolo/generated_qr_code_images/images/',
    'QR-detection-yolo/generated_qr_code_images/labels/',
    'QR Code'
)

这里是这些图像:

图 11.10 – 合成创建的图像示例,由背景图像和生成的二维码与各种图像增强组合而成

图 11.10 – 合成创建的图像示例,由背景图像和生成的二维码与各种图像增强组合而成

正如我们所看到的,有些图像只是简单的增强二维码,没有背景,这得益于生成函数的作用。可以通过background_proba参数调整这一点。

模型训练

现在我们可以开始模型训练部分:我们将用前一步生成的 3,000 张图像训练一个 YOLO 模型,并在测试集上评估该模型。以下是步骤:

  1. 首先,按照以下方式实例化一个带有预训练权重的 YOLO 模型:

    # Create a new YOLO model with pretrained weights
    
    model = YOLO('yolov8n.pt')
    

注意

你可能会遇到 FileNotFoundError 错误,这是由于数据集路径不正确导致的。~/.config/Ultralytics/settings.yaml 中的 config 文件存有以前的路径。一个快速且无害的解决办法是简单地删除这个文件;一个新的文件将会自动生成。

  1. 然后,我们需要创建一个 .yaml 文件 data_qr_generated.yaml,其内容如下:

    train: ../../QR-detection-yolo/generated_qr_code_images/images
    
    val: ../../QR-detection-yolo/test/images
    
    nc: 1
    
    names: ['QR_CODE']
    
  2. 这个 .yaml 文件可以用来在我们的数据集上训练模型,训练周期为 50 epochs。我们还指定了初始学习率为 0.001 (lr0=0.001),因为默认的学习率(0.01)对于微调预训练模型来说过大:

    # Train the model for 50 epochs
    
    model.train(data='data_qr_generated.yaml', epochs=50,
    
        lr0=0.001, name='generated_qrcode')
    

结果应该保存在创建的文件夹 runs/detect/generated_qrcode 中。

  1. 在查看结果之前,我们先实现一个 plot_results_one_image 辅助函数,以显示模型的输出,代码如下:

    def plot_results_random_images(test_images, model, classes=['QR_code']):
    
        images = glob(test_images + '/*.jpg')
    
        plt.figure(figsize=(14, 10))
    
        for i in range(8):
    
            idx = np.random.randint(len(images))
    
            result = model.predict(images[idx])
    
            image = result[0].orig_img.copy()
    
            raw_res = result[0].boxes.data
    
            for detection in raw_res:
    
                x1, y1, x2, y2, p,
    
                    cls = detection.cpu().tolist()
    
                cv2.rectangle(image, (int(x1), int(y1)),
    
                    (int(x2), int(y2)), (255,0,0), 2)
    
                cv2.putText(image, f'{classes[int(cls)]}',
    
                    (int(x1), int(y1) - 10),
    
                        cv2.FONT_HERSHEY_SIMPLEX, 1,
    
                        (255,0,0), 2)
    
            plt.subplot(2, 4, i + 1)
    
            plt.axis('off')
    
            plt.imshow(image)
    
  2. 然后,我们可以重新加载最佳权重,计算推理,并在测试集中的一张图像上显示结果:

    # Load the best weights
    
    model = YOLO(
    
        'runs/detect/generated_qrcode/weights/best.pt')
    
    # Plot the results
    
    Plot_results_random_images(
    
        'QR-detection-yolo/test/images/', model)
    

这里是结果:

图 11.11 – 训练于合成数据的 YOLO 模型结果示例(尽管模型并不完美,但它能够在相当复杂和多样的情况下检测二维码)

图 11.11 – 训练于合成数据的 YOLO 模型结果示例(尽管模型并不完美,但它能够在相当复杂和多样的情况下检测二维码)

如我们所见,模型仍然没有完全发挥作用,但仍能在多个复杂场景中识别二维码。然而,在一些情况下,例如二维码非常小、图像质量差或二维码严重变形时,模型似乎表现不佳。

  1. 最后,我们可以可视化 YOLO 库生成的损失值和其他指标:

    plt.figure(figsize=(10, 8))
    
    plt.imshow(plt.imread(
    
        'runs/detect/generated_qrcode/results.png'))
    

这里是损失值:

图 11.12 – YOLO 库计算的指标

图 11.12 – YOLO 库计算的指标

根据显示的几张图像结果,指标并不完美,mAP50 仅为 75% 左右。

通过添加更多精心挑选的图像增强,可能会有所改进。

还有更多…

即使我们一开始没有任何真实数据,仍然有更多生成带标签图像的技术。在本教程中,我们仅使用了背景图像、生成的二维码和增强方法,但也可以使用生成模型来生成更多数据。

让我们看看如何使用 OpenAI 提出的 DALL-E 模型来实现:

  1. 首先,我们可以导入所需的库。可以使用 pip install openai 安装 openai 库:

    import openai
    
    import urllib
    
    from PIL import Image
    
    import matplotlib.pyplot as plt
    
    openai.api_key = 'xx-xxx'
    

注意

你需要通过在 openai.com 创建自己的帐户来生成自己的 API 密钥。

  1. 现在我们来创建一个辅助函数,将边界框和图像转换为掩膜,因为我们希望在边界框外部进行填充:

    def get_mask_to_complete(image_path, label_path, output_filename, margin: int = 100):
    
        image = plt.imread(image_path)
    
        labels = read_labels(label_path)
    
        output_mask = np.zeros(image.shape[:2])
    
        for cls, xc, yc, w, h in labels:
    
            xc = int(xc*image.shape[1])
    
            yc = int(yc*image.shape[0])
    
            w = int(w*image.shape[1])
    
            h = int(h*image.shape[0])
    
            output_mask[yc-h//2-margin:yc+h//2+margin,
    
                xc-w//2-margin:xc+w//2+margin] = 255
    
        output_mask = np.concatenate([image,
    
            np.expand_dims(output_mask, -1)],
    
                axis=-1).astype(np.uint8)
    
        # Save the images
    
        output_mask_filename = output_filename.split('.')[0] + '_mask.png'
    
        plt.imsave(output_filename, image)
    
        plt.imsave(output_mask_filename, output_mask)
    
        return output_mask_filename
    
  2. 我们现在可以计算掩膜,并将结果与原始图像并排显示,方法如下:

    output_image_filename = 'image_edit.png'
    
    mask_filename = get_mask_to_complete(
    
        'QR-detection-yolo/generated_qr_code_images/images/synthetic_image_0.jpg',
    
        'QR-detection-yolo/generated_qr_code_images/labels/synthetic_image_0.txt',
    
        output_image_filename
    
    )
    
    # Display the masked image and the original image side by side
    
    plt.figure(figsize=(12, 10))
    
    plt.subplot(1, 2, 1)
    
    plt.imshow(plt.imread(output_image_filename))
    
    plt.subplot(1, 2, 2)
    
    plt.imshow(plt.imread(mask_filename))
    

下面是结果:

图 11.13 – 左侧是原始图像,右侧是用于数据生成的相关掩膜图像

图 11.13 – 左侧是原始图像,右侧是用于数据生成的相关掩膜图像

注意

我们保留掩膜图像的边缘,以便调用 DALL-E 2 时,它能够感知周围环境。如果我们只提供一个二维码和白色背景的掩膜,结果可能不尽如人意。

  1. 我们现在可以查询 OpenAI 模型 DALL-E 2,通过 openai 库中的 create_edit 方法来填充这个二维码并生成新图像。该功能需要以下几个参数:

    • 输入图像(PNG 格式,小于 4 MB)

    • 输入掩膜(同样是 PNG 格式且小于 4 MB)

    • 描述期望输出图像的提示

    • 生成的图像数量

    • 输出图像的像素大小(可以是 256x256、512x512 或 1,024x1,024)

现在我们来查询 DALL-E,显示原始图像和生成图像并排显示:

# Query openAI API to generate image
response = openai.Image.create_edit(
    image=open(output_image_filename, 'rb'),
    mask=open(mask_filename, 'rb'),
    prompt="A store in background",
    n=1,
    size="512x512"
)
# Download and display the generated image
plt.figure(figsize=(12, 10))
image_url = response['data'][0]['url']
plt.subplot(1, 2, 1)
plt.imshow(plt.imread(output_image_filename))
plt.subplot(1, 2, 2)
plt.imshow(np.array(Image.open(urllib.request.urlopen(
    image_url))))

下面是图像的显示效果:

图 11.14 – 左侧是原始图像,右侧是使用 DALL-E 2 生成的图像

图 11.14 – 左侧是原始图像,右侧是使用 DALL-E 2 生成的图像

正如我们在 图 11.14 中看到的,使用这种技术能够创建更逼真的图像,这些图像可以轻松用于训练。这些创建的图像还可以通过 Albumentations 进行增强。

注意

然而,这种方法也有一些缺点。生成的图像大小为 512x512,这意味着边界框坐标需要转换(这可以通过 Albumentations 完成),而且生成的图像不总是很完美,需要进行视觉检查。

  1. 我们还可以使用 create_variation 函数创建给定图像的变化。这种方法更简单,且需要类似的输入参数:

    • 输入图像(仍然是小于 4 MB 的 PNG 图像)

    • 生成的变化图像数量

    • 输出图像的像素大小(同样可以是 256x256、512x512 或 1,024x1,024)

以下是这段代码:

# Query to create variation of a given image
response = openai.Image.create_variation(
    image=open(output_image_filename, "rb"),
    n=1,
    size="512x512"
)
# Download and display the generated image
plt.figure(figsize=(12, 10))
image_url = response['data'][0]['url']
plt.subplot(1, 2, 1)
plt.imshow(plt.imread(output_image_filename))
plt.subplot(1, 2, 2)
plt.imshow(np.array(Image.open(urllib.request.urlopen(
    image_url))))

这是输出结果:

图 11.15 – 左侧是原始图像,右侧是使用 DALL-E 生成的变化图像

图 11.15 – 左侧是原始图像,右侧是使用 DALL-E 生成的变化图像

前面图中展示的结果相当不错:我们可以看到背景中的会议室和前景中的二维码,就像原始图像一样。然而,除非我们手动标注这些数据,否则它可能不容易使用,因为我们无法确定二维码是否会出现在相同的位置(即使我们调整了边界框坐标)。尽管如此,使用这样的模型对于其他用例(如分类)仍然是非常有帮助的。

另见

实现实时风格迁移

在本食谱中,我们将基于 U-Net 架构构建我们自己的轻量级风格迁移模型。为此,我们将使用一个通过稳定扩散生成的数据集(稍后将详细介绍稳定扩散是什么)。这可以看作是一种知识蒸馏:我们将使用一个大型教师模型(稳定扩散,体积达到几个千兆字节)生成的数据来训练一个小型学生模型(这里是一个不到 30 MB 的 U-Net++)。这是一种有趣的方式,利用生成模型创建数据,但这里发展出的概念可以用于许多其他应用:其中一些将在更多内容...部分中提出,并提供如何使用稳定扩散创建自己的风格迁移数据集的指导。 在此之前,让我们先了解一下风格迁移的背景。

风格迁移是深度学习中的一个著名且有趣的应用,它允许我们将给定图像的风格转换为另一种风格。许多例子存在,比如《蒙娜丽莎》以梵高的《星夜》风格呈现,如下图所示:

图 11.16 – 《蒙娜丽莎》以《星夜》风格呈现

图 11.16 – 《蒙娜丽莎》以《星夜》风格呈现

直到最近,风格迁移大多是使用生成对抗网络GANs)进行的,而这些网络通常很难正确训练。

现在,使用基于稳定扩散的预训练模型应用风格迁移比以往任何时候都要简单。不幸的是,稳定扩散是一个庞大而复杂的模型,有时在最新的图形卡上生成一张图像可能需要几秒钟的时间。

在本食谱中,我们将训练一个类似于 U-Net 的模型,允许在任何设备上进行实时迁移学习。为此,我们将采用一种知识蒸馏的方式。具体而言,我们将使用稳定扩散数据来训练 U-Net 模型,并为此目的加入 VGG 感知损失。

注意

VGG 代表 视觉几何组,这是提出这一深度学习模型架构的牛津团队的名称。它是计算机视觉中的标准深度学习模型。

在继续进行食谱之前,让我们先看一下本食谱中的两个重要概念:

  • 稳定扩散

  • 感知损失

稳定扩散

稳定扩散是一个复杂而强大的模型,允许我们通过图像和文本提示生成新图像。

图 11.17 – 稳定扩散的架构图

图 11.17 – 稳定扩散的架构图

从上面的图可以看出,稳定扩散的训练方式可以简化总结如下:

  • 对输入图像 Z 进行 T 次扩散:这就像是向输入图像添加随机噪声

  • 这些扩散的图像被传入去噪 U-Net 模型

  • 可选择添加一个条件,如描述性文本或另一个图像提示,作为嵌入

  • 该模型被训练用于输出输入图像

一旦模型训练完成,就可以通过跳过如下第一部分来进行推断:

  • 给定一个种子,生成一个随机图像并将其作为输入提供给去噪 U-Net

  • 添加输入提示作为条件:它可以是文本或输入图像,例如

  • 然后生成输出图像:这就是最终结果

尽管这只是一个简单的工作原理解释,但它使我们能够大致了解它的工作方式以及生成新图像的预期输入是什么。

感知损失

感知损失被提出用来训练模型以学习图像中的感知特征。它是专门为风格迁移开发的,允许你不仅关注像素到像素的内容本身,还关注图像的风格。

它接受两幅图像作为输入:模型预测和标签图像,通常基于一个在 ImageNet 数据集或任何类似通用图像数据集上预训练的 VGG 神经网络。

更具体地说,对于两幅图像(例如,模型预测和标签),进行以下计算:

  • VGG 模型的前馈计算应用于每个图像

  • 每个 VGG 模型块后的输出被存储,这使我们能够通过更深的块获得越来越具体的特征

在深度神经网络中,前几层通常用于学习通用特征和形状,而最深的层则用于学习更具体的特征。感知损失利用了这一特性。

使用这些存储的计算,最终感知损失被计算为以下值的总和:

  • 每个块输出计算之间的差异(例如 L1 或 L2 范数):这些可以表示为特征重建损失,并将关注图像特征。

  • 每个块输出计算的 Gram 矩阵之间的差异:这些可以表示为风格重建损失,并且将关注于图像的风格。

给定一组向量的 Gram 矩阵通过计算每对向量的点积并将结果排列成矩阵来构造。它可以看作是给定向量之间的相似性或相关性。

最终,最小化这个感知损失应该允许我们将给定图像的风格应用到另一张图像上,正如我们将在本食谱中看到的那样。

入门

你可以通过以下命令行从 Kaggle 下载我为这个配方创建的完整数据集:

kaggle datasets download -d vincentv/qr-detection-yolo --unzip

然后你将拥有以下文件夹架构:

anime-style-transfer
├── train
│   ├── images: 820 images
│   └── labels: 820 images
└── test
     ├── images: 93 images
     └── labels: 93 images

这是一个相当小的数据集,但它应该足够展示该技术的潜力,能够获得良好的表现。

关于如何使用 ControlNet 自行创建这样的数据集,请查看 There’s more… 子章节。

这个配方所需的库可以通过以下命令行安装:

pip install matplotlib numpy torch torchvision segmentation-models-pytorch albumentations tqdm

如何做…

这是这个配方的步骤:

  1. 导入所需的库:

    • matplotlib 用于可视化

    • numpy 用于操作

    • 几个 torchtorchvision 模块

    • segmentation models pytorch 用于模型

    • albumentations 用于图像增强

这是它的代码:

import matplotlib.pyplot as plt
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import DataLoader, Dataset
import torchvision.transforms as transforms
from torchvision.models import vgg16, VGG16_Weights
from glob import glob
import segmentation_models_pytorch as smp
from torch.optim.lr_scheduler import ExponentialLR
import albumentations as A
import tqdm
  1. 实现 AnimeStyleDataset,允许我们加载数据集。请注意,我们使用了 Albumentations 中的 ReplayCompose 工具,允许我们对图像和相关标签应用完全相同的图像增强:

    class AnimeStyleDataset(Dataset):
    
        def __init__(self, input_path: str,
    
            output_path: str, transform, augment = None):
    
                self.input_paths = sorted(glob(
    
                    f'{input_path}/*.png'))
    
                self.output_paths = sorted(glob(
    
                    f'{output_path}/*.png'))
    
                self.transform = transform
    
                self.augment = augment
    
        def __len__(self):
    
            return len(self.input_paths)
    
        def __getitem__(self, idx):
    
            input_img = plt.imread(self.input_paths[idx])
    
            output_img = plt.imread(
    
                self.output_paths[idx])
    
            if self.augment:
    
                augmented = self.augment(image=input_img)
    
                input_img = augmented['image']
    
                output_img = A.ReplayCompose.replay(
    
                    augmented['replay'],
    
                    image=output_img)['image']
    
            return self.transform(input_img),
    
                self.transform(output_img)
    
  2. 实例化增强,这是以下变换的组合:

    • Resize

    • 以 50% 的概率进行水平翻转

    • ShiftScaleRotate,允许我们随机增加几何变化

    • RandomBrightnessContrast,允许我们增加光照变化

    • RandomCropFromBorders,它将随机裁剪图片的边缘

这是它的代码:

augment = A.ReplayCompose([
    A.Resize(512, 512),
    A.HorizontalFlip(p=0.5),
    A.ShiftScaleRotate(shift_limit=0.05,
        scale_limit=0.05, rotate_limit=15, p=0.5),
    A.RandomBrightnessContrast(p=0.5),
    A.RandomCropFromBorders(0.2, 0.2, 0.2, 0.2, p=0.5)
])
  1. 实例化变换,允许我们转换 torch 张量并重新缩放像素值。同时,定义批量大小和设备,如下所示:

    batch_size = 12
    
    device = torch.device(
    
        'cuda' if torch.cuda.is_available() else 'cpu')
    
    mean = (0.485, 0.456, 0.406)
    
    std = (0.229, 0.224, 0.225)
    
    transform = transforms.Compose([
    
        transforms.ToTensor(),
    
        transforms.Resize((512, 512), antialias=True),
    
        transforms.Normalize(mean, std),
    
    ])
    

注意

在这里,我们使用了特定于 ImageNet 数据集的均值和标准差重缩放,因为 VGG 感知损失(见下文)是在这一特定值集上训练的。此外,批量大小可能需要根据你的硬件规格进行调整,尤其是图形处理单元的内存。

  1. 实例化数据集和数据加载器,提供训练集和测试集文件夹。请注意,我们只对训练集应用增强:

    trainset = AnimeStyleDataset(
    
        'anime-style-transfer/train/images/',
    
        'anime-style-transfer/train/labels/',
    
        transform=transform,
    
        augment=augment,
    
    )
    
    train_dataloader = DataLoader(trainset,
    
    batch_size=batch_size, shuffle=True)
    
    testset = AnimeStyleDataset(
    
        'anime-style-transfer/test/images/',
    
        'anime-style-transfer/test/labels/',
    
        transform=transform,
    
    )
    
    test_dataloader = DataLoader(testset,
    
        batch_size=batch_size, shuffle=True)
    
  2. 显示几张图像及其标签,以便我们快速了解数据集。为此,我们首先需要一个辅助的 unnormalize 函数,将图像的值重新缩放到 [0, 1] 范围:

    def unnormalize(x, mean, std):
    
        x = np.asarray(x, dtype=np.float32)
    
        for dim in range(3):
    
            x[:, :, dim] = (x[:, :, dim] * std[dim]) + mean[dim]
    
        return x
    
    plt.figure(figsize=(12, 6))
    
    images, labels = next(iter(train_dataloader))
    
    for idx in range(4):
    
        plt.subplot(2, 4, idx*2+1)
    
        plt.imshow(unnormalize(images[idx].permute(
    
            1, 2, 0).numpy(), mean, std))
    
        plt.axis('off')
    
        plt.subplot(2, 4, idx*2+2)
    
        plt.imshow(unnormalize(labels[idx].permute(
    
            1, 2, 0).numpy(), mean, std))
    
        plt.axis('off')
    

以下是结果:

图 11.18 – 一组四张图片及其相关的动漫标签

图 11.18 – 一组四张图片及其相关的动漫标签

如前图所示,数据集由面部图像组成,标签是应用了绘画和动漫风格的相应图片。这些图片是使用 Stable Diffusion 和 ControlNet 生成的;请参阅 There’s more… 部分了解如何自行完成此操作。

  1. 现在,我们实例化模型类。在这里,我们重用了 SMP 库中提供的带有 U-Net++架构的现有mobilenetv3_large_100实现。我们分别使用in_channelsn_classes参数,指定输入和输出通道为3。我们还重用了imagenet的编码器权重。以下是代码:

    model = smp.UnetPlusPlus(
    
        encoder_name='timm-mobilenetv3_large_100',
    
        encoder_weights='imagenet',
    
        in_channels=3,
    
        classes=3,
    
        )
    
    model = model.to(device)
    

注意

关于 SMP 库的更多信息,请参考第十章中的语义分割与迁移学习章节。

  1. 现在,我们实现 VGG 感知损失如下:

    class VGGPerceptualLoss(torch.nn.Module):
    
        def __init__(self):
    
            super(VGGPerceptualLoss, self).__init__()
    
            blocks = []
    
            blocks.append(vgg16(weights=VGG16_Weights.DEFAULT).features[:4].eval())
    
            blocks.append(vgg16(weights=VGG16_Weights.DEFAULT).features[4:9].eval())
    
            blocks.append(vgg16(weights=VGG16_Weights.DEFAULT).features[9:16].eval())
    
            blocks.append(vgg16(weights=VGG16_Weights.DEFAULT).features[16:23].eval())
    
            for block in blocks:
    
                block = block.to(device)
    
                for param in block.parameters():
    
                    param.requires_grad = False
    
                self.blocks = torch.nn.ModuleList(blocks)
    
                self.transform = torch.nn.functional.interpolate
    
        def forward(self, input, target):
    
            input = self.transform(input, mode='bilinear',
    
                size=(224, 224), align_corners=False)
    
                target = self.transform(target,
    
                    mode='bilinear', size=(224, 224),
    
                    align_corners=False)
    
                loss = 0.0
    
                x = input
    
                y = target
    
                for i, block in enumerate(self.blocks):
    
                    x = block(x)
    
                    y = block(y)
    
                    loss += torch.nn.functional.l1_loss(
    
                        x, y)
    
                    act_x = x.reshape(x.shape[0],
    
                        x.shape[1], -1)
    
                    act_y = y.reshape(y.shape[0],
    
                        y.shape[1], -1)
    
                    gram_x = act_x @ act_x.permute(
    
                        0, 2, 1)
    
                    gram_y = act_y @ act_y.permute(
    
                        0, 2, 1)
    
                    loss += torch.nn.functional.l1_loss(
    
                        gram_x, gram_y)
    
            return loss
    

在此实现中,我们有两个方法:

  • init函数,定义所有模块并将其设置为不可训练

  • forward函数,将图像调整为 224x224(原始 VGG 输入形状),并计算每个模块的损失

  1. 接下来,我们定义优化器、指数学习率调度器、VGG 损失以及风格和内容损失的权重:

    optimizer = torch.optim.Adam(model.parameters(),
    
        lr=0.001)
    
    scheduler = ExponentialLR(optimizer, gamma=0.995)
    
    vgg_loss = VGGPerceptualLoss()
    
    content_loss_weight=1.
    
    style_loss_weight=5e-4
    

注意

风格损失(即 VGG 感知损失)通常远大于内容损失(即 L1 损失)。因此,在这里,我们通过对风格损失施加较低的权重来进行平衡。

  1. 在 50 个 epoch 内训练模型,并存储训练集和测试集的损失。为此,我们可以使用 GitHub 仓库中提供的train_style_transfer函数(github.com/PacktPublishing/The-Regularization-Cookbook/blob/main/chapter_11/chapter_11.ipynb):

    train_losses, test_losses = train_style_transfer(
    
        model,
    
        train_dataloader,
    
        test_dataloader,
    
        vgg_loss,
    
        content_loss_weight,
    
        style_loss_weight,
    
        device,
    
        epochs=50,
    
    )
    

注意

这个函数是一个典型的训练循环,和我们已经实现过多次的训练过程相似。唯一的区别是损失计算,计算方法如下:

style_loss = vgg_loss(outputs, labels)

content_loss = torch.nn.functional.l1_loss(outputs, labels)

loss = style_loss_weight*style_loss + content_loss_weight*content_loss

  1. 将损失图作为 epoch 的函数绘制,分别对应训练集和测试集:

    plt.plot(train_losses, label='train')
    
    plt.plot(test_losses, label='test')
    
    plt.ylabel('Loss')
    
    plt.xlabel('Epoch')
    
    plt.legend()
    
    plt.show()
    

下面是结果:

图 11.19 – 风格转移网络的训练和测试损失随 epoch 的变化

图 11.19 – 风格转移网络的训练和测试损失随 epoch 的变化

如前图所示,模型在学习,但随着测试损失在大约 40 个 epoch 后不再显著下降,似乎有轻微的过拟合。

  1. 最后,在测试集的一组图像上测试训练好的模型,并展示结果:

    images, labels = next(iter(test_dataloader))
    
    with torch.no_grad():
    
        outputs = model(images.to(device)).cpu()
    
    plt.figure(figsize=(12, 6))
    
    for idx in range(4):
    
        plt.subplot(2, 4, idx*2+1)
    
        plt.imshow(unnormalize(images[idx].permute(
    
            1, 2, 0).numpy(), mean, std))
    
        plt.axis('off')
    
        plt.subplot(2, 4, idx*2+2)
    
        plt.imshow(unnormalize(outputs[idx].permute(
    
            1, 2, 0).numpy(), mean, std).clip(0, 1))
    
        plt.axis('off')
    

下面是显示的结果:

图 11.20 – 一些图像及其预测的风格转移效果

图 11.20 – 一些图像及其预测的风格转移效果

如前图所示,模型成功地转移了一些风格,通过平滑皮肤并有时给头发上色。尽管如此,效果并不完美,因为输出图像看起来过于明亮。

通过微调损失权重和其他超参数,可能可以获得更好的结果。

还有更多内容...

虽然本配方展示了如何使用生成模型(如 Stable Diffusion)以有趣的方式创建新数据,但它也可以应用于许多其他场景。让我们看看如何使用 Stable Diffusion 创建自己的风格迁移数据集,以及一些其他可能的应用。

如前所述,Stable Diffusion 使我们能够基于输入提示创建逼真且富有创意的图像。不幸的是,单独使用它时,无法有效地将风格应用于给定图像而不妥协原始图像的细节(例如,面部形状等)。为此,我们可以使用基于 Stable Diffusion 的另一个模型:ControlNet。

ControlNet 的工作原理类似于 Stable Diffusion:它接收输入提示并生成输出图像。然而,与 Stable Diffusion 不同,ControlNet 会将控制信息作为输入,使我们能够根据控制图像特定地生成数据:这正是创建此配方数据集时所做的,通过有效地为面部添加绘画风格,同时保持整体面部特征。

控制信息可以有多种形式,例如:

  • 使用 Canny 边缘或 Hough 线的图像轮廓,允许我们为图像分类执行逼真且无限的图像增强

  • 深度估计,允许我们高效生成背景图像

  • 语义分割,允许对语义分割任务进行图像增强

  • 姿态估计,生成具有特定姿势的人物图像,这对目标检测、语义分割等非常有用

  • 还有更多内容,比如涂鸦和法线图

Canny 边缘检测器和 Hough 线变换是典型的图像处理算法,分别允许我们检测图像中的边缘和直线。

作为一个具体的例子,在下图中,使用输入图像和计算出的 Canny 边缘作为输入,再加上类似 A realistic cute shiba inu in a fluffy basket 的文本提示,ControlNet 使我们能够生成一个非常接近第一张图像的新图像。请参考下图:

图 11.21 – 左侧是输入图像;中间是计算出的 Canny 边缘;右侧是使用 ControlNet 和提示语“A realistic cute shiba inu in a fluffy basket”生成的图像

图 11.21 – 左侧是输入图像;中间是计算出的 Canny 边缘;右侧是使用 ControlNet 和提示语“A realistic cute shiba inu in a fluffy basket”生成的图像

有几种方法可以安装和使用 ControlNet,但可以使用以下命令安装官方库:

git clone git@github.com:lllyasviel/ControlNet.git
cd ControlNet
conda env create -f environment.yaml
conda activate control

从那里,你需要根据自己的需求下载特定的模型,这些模型可以在 HuggingFace 上找到。例如,你可以使用以下命令下载 Canny 模型:

wget https://huggingface.co/lllyasviel/ControlNet/resolve/main/models/control_sd15_canny.pth -P models/

下载的文件超过 6 GB,因此可能需要一些时间。

最后,你可以通过以下命令启动 ControlNet UI,python gradio_canny2image.py,然后按照提示访问创建的本地地址,http://0.0.0.0:7860

使用 ControlNet 和 Stable Diffusion,在拥有足够强大计算机的情况下,你现在可以生成几乎无限的新图像,使你能够训练出真正强大且规范化良好的计算机视觉模型。

另见

posted @ 2025-07-12 11:41  绝不原创的飞龙  阅读(0)  评论(0)    收藏  举报