C---机器学习实用指南第二版-全-
C++ 机器学习实用指南第二版(全)
原文:
annas-archive.org/md5/0094b5c825f8a9257e51e53be7bb10aa译者:飞龙
前言
C++ 可以让你的机器学习(ML)模型运行得更快、更高效。本书教你机器学习的基础知识,并展示如何使用 C++ 库。它解释了如何创建监督学习和无监督学习模型。
你将亲自动手调整和优化模型以适应不同的使用场景,本书将帮助你进行模型选择和性能测量。本书涵盖了产品推荐、集成学习、异常检测、情感分析和使用现代 C++ 库进行目标识别等技术。此外,你还将学习如何处理移动平台上的生产部署挑战,以及 ONNX 模型格式如何帮助你完成这些任务。
本版更新了关键主题,如使用迁移学习和基于 Transformer 的模型实现情感分析,以及使用 MLflow 跟踪和可视化机器学习实验。此外,还增加了一个关于使用 Optuna 进行超参数选择的新章节。关于将模型部署到移动平台的部分得到了扩展,增加了使用 C++ 在 Android 上进行实时目标检测的详细解释。
在阅读完这本 C++ 书籍之后,你将拥有实际的机器学习和 C++ 知识,以及使用 C++ 构建强大机器学习系统的技能。
本书面向的对象
如果你想要使用流行的 C++ 语言开始学习机器学习算法和技术,那么这本书就是为你准备的。除了作为 C++ 机器学习入门的有用课程外,这本书还会吸引那些希望使用 C++ 在生产环境中实现不同机器学习模型的数据分析师、数据科学家和机器学习开发者,这对于某些特定平台,例如嵌入式设备,可能很有用。要开始阅读这本书,需要具备 C++ 编程语言、线性代数和基本微积分知识。
本书涵盖的内容
第一章,使用 C++ 的机器学习简介,将引导你了解机器学习的基本知识,包括线性代数概念、机器学习算法类型及其构建模块。
第二章,数据处理,展示了如何从不同的文件格式加载数据用于机器学习模型训练,以及如何在各种 C++ 库中初始化数据集对象。
第三章,衡量性能和选择模型,展示了如何衡量各种类型机器学习模型的性能,如何选择最佳的超参数集以实现更好的模型性能,以及如何在各种 C++ 和外部库中使用网格搜索方法进行模型选择。
第四章, 聚类,讨论了根据对象的本质特征进行分组算法,解释了我们通常为什么使用无监督算法来解决这类任务,最后概述了各种聚类算法及其在不同 C++库中的实现和使用。
第五章, 异常检测,讨论了异常和新颖性检测任务的基础知识,并引导你了解不同类型的异常检测算法、它们的实现以及在各种 C++库中的使用。
第六章, 降维,讨论了各种降维算法,这些算法保留了数据的本质特征,以及它们在不同 C++库中的实现和使用。
第七章, 分类,展示了分类任务是什么以及它与聚类任务的区别。你将了解各种分类算法、它们的实现以及在各种 C++库中的使用。
第八章, 推荐系统,使你对推荐系统概念熟悉。你将了解处理推荐任务的不同方法,并看到如何使用 C++语言解决这类任务。
第九章, 集成学习,讨论了将多个机器学习模型结合以获得更高准确性和处理学习问题的各种方法。你将遇到使用不同 C++库的集成实现。
第十章, 用于图像分类的神经网络,使你对人工神经网络的 fundamentals 熟悉。你将遇到基本构建块、所需的数学概念和学习算法。你将了解提供神经网络实现功能的不同 C++库。此外,本章还将展示使用 PyTorch 库实现图像分类的深度卷积网络的实现。
第十一章, 使用 BERT 和迁移学习进行情感分析,介绍了大型语言模型(LLMs),并简要描述了它们的工作原理。它还将展示如何使用迁移学习技术,利用预训练的 LLMs,通过 PyTorch 库实现情感分析。
第十二章, 导出和导入模型,展示了如何使用各种 C++库保存和加载模型参数和架构。此外,你将看到如何使用 ONNX 格式,通过 Caffe2 库的 C++ API 加载和使用预训练模型。
第十三章,跟踪和可视化机器学习实验,展示了如何使用 MLflow 工具包来跟踪和可视化您的机器学习实验。可视化对于理解实验中的模式、关系和趋势至关重要。实验跟踪允许您比较结果、识别最佳实践并避免重复错误。
第十四章,在移动平台上部署模型,指导您使用 Android 平台上的神经网络开发用于设备相机图像目标检测的应用程序。
为了充分利用本书
为了能够编译和运行本书中包含的示例,您需要配置特定的开发环境。所有代码示例都已使用 Ubuntu Linux 22.04 版本的发行版进行测试。以下列表概述了您需要在 Ubuntu 平台上安装的包:
-
unzip -
build-essential -
gdb -
git -
libfmt-dev -
wget -
cmake -
python3 -
python3-pip -
python-is-python3 -
libblas-dev -
libopenblas-dev -
libfftw3-dev -
libatlas-base-dev -
liblapacke-dev -
liblapack-dev -
libboost-all-dev -
libopencv-core4.5d -
libopencv-imgproc4.5d -
libopencv-dev -
libopencv-highgui4.5d -
libopencv-highgui-dev -
libhdf5-dev -
libjson-c-dev -
libx11-dev -
openjdk-8-jdk -
openjdk-17-jdk -
ninja-build -
gnuplot -
vim -
python3-venv -
libcpuinfo-dev -
libspdlog-dev
您需要一个版本不低于 2.27 的 cmake 包。在 Ubuntu 22.04 上,您需要手动下载并安装它。例如,可以按照以下步骤操作:
wget https://github.com/Kitware/CMake/releases/download/v3.27.5/cmake-3.27.5-Linux-x86_64.sh \
-q -O /tmp/cmake-install.sh \
&& chmod u+x /tmp/cmake-install.sh \
&& mkdir /usr/bin/cmake \
&& /tmp/cmake-install.sh --skip-license --prefix=/usr/bin/cmake \
&& rm /tmp/cmake-install.sh
export PATH="/usr/bin/cmake/bin:${PATH}"
此外,您还需要为 Python 安装额外的包,这可以通过以下命令完成:
pip install pyyaml
pip install typing
pip install typing_extensions
pip install optuna
pip install torch==2.3.1 \
--index-url https://download.pytorch.org/whl/cpu
pip install transformers
pip install mlflow==2.15.0
除了开发环境之外,您还需要检查第三方库的源代码示例并构建它们。大多数这些库都在积极开发中,因此您需要提供特定的版本(Git 标签),以便我们确保代码示例的兼容性。以下表格显示了您需要检查的库、它们的仓库 URL 以及要检查的提交的标签或哈希号:
| 库仓库 | 分支/标签 | 提交 |
|---|---|---|
bitbucket.org/blaze-lib/blaze.git |
v3.8.2 | |
github.com/arrayfire/arrayfire |
v3.8.3 | |
github.com/flashlight/flashlight.git |
v0.4.0 | |
github.com/davisking/dlib |
v19.24.6 | |
gitlab.com/conradsnicta/armadillo-code |
14.0.x | |
github.com/xtensor-stack/xtl |
0.7.7 | |
github.com/xtensor-stack/xtensor |
0.25.0 | |
github.com/xtensor-stack/xtensor-blas |
0.21.0 | |
github.com/nlohmann/json.git |
v3.11.3 | |
github.com/mlpack/mlpack |
4.5.0 | |
gitlab.com/libeigen/eigen.git |
3.4.0 | |
github.com/BlueBrain/HighFive |
v2.10.0 | |
github.com/yhirose/cpp-httplib |
v0.18.1 | |
github.com/Kolkir/plotcpp |
c86bd4f5d9029986f0d5f368450d79f0dd32c7e4 | |
github.com/ben-strasser/fast-cpp-csv-parser |
4ade42d5f8c454c6c57b3dce9c51c6dd02182a66 | |
github.com/lisitsyn/tapkee |
Ba5f052d2548ec03dcc6a4ac0ed8deeb79f1d43a | |
github.com/Microsoft/onnxruntime.git |
v1.19.2 | |
github.com/pytorch/pytorch |
v2.3.1 |
注意,由于可能与 onnxruntime 使用的 protobuf 库版本冲突,最好最后编译和安装 PyTorch。
此外,对于最后一章,你可能需要安装 Android Studio IDE。你可以从官方网站下载它,网址为 developer.android.com/studio。除了 IDE,你还需要安装和配置 Android SDK、NDK 以及基于 Android 的 OpenCV 库。以下工具的版本是必需的:
| 名称 | 版本 |
|---|---|
| OpenCV | 4.10.0 |
| Linux 的 Android 命令行工具 | 9477386 |
| Android NDK | 26.1.10909125 |
| Android 平台 | 35 |
你可以使用 Android IDE 或命令行工具配置这些工具,如下所示:
wget https://github.com/opencv/opencv/releases/download/4.10.0/opencv-4.10.0-android-sdk.zip
unzip opencv-4.10.0-android-sdk.zip
wget https://dl.google.com/android/repository/commandlinetools-linux-9477386_latest.zip
unzip commandlinetools-linux-9477386_latest.zip
./cmdline-tools/bin/sdkmanager --sdk_root=$ANDROID_SDK_ROOT "cmdline-tools;latest"
./cmdline-tools/latest/bin/sdkmanager --licenses
./cmdline-tools/latest/bin/sdkmanager "platform-tools" "tools"
./cmdline-tools/latest/bin/sdkmanager "platforms;android-35"
./cmdline-tools/latest/bin/sdkmanager "build-tools;35.0.0"
./cmdline-tools/latest/bin/sdkmanager "system-images;android-35;google_apis;arm64-v8a"
./cmdline-tools/latest/bin/sdkmanager --install "ndk;26.1.10909125"
另一种配置开发环境的方法是通过使用 Docker。Docker 允许你配置一个具有特定组件的轻量级虚拟机。你可以从官方 Ubuntu 软件包仓库安装 Docker。然后,使用本书提供的脚本自动配置环境。你将在 examples 仓库中找到 build-env 文件夹。以下步骤展示了如何使用 Docker 配置脚本:
-
首先配置您的 GitHub 账户。然后,您将能够配置使用 SSH 进行 GitHub 认证,如文章《使用 SSH 连接 GitHub》中所述(
docs.github.com/en/authentication/connecting-to-github-with-ssh);这是首选方式。或者,您可以使用 HTTPS,并在克隆新仓库时每次都提供您的用户名和密码。如果您使用双重认证(2FA)来保护您的 GitHub 账户,那么您需要使用个人访问令牌而不是密码,如《创建个人访问令牌》文章中所述(docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token)。 -
运行以下命令以创建镜像、运行它并配置环境:
cd docker docker build -t buildenv:1.0 . -
使用以下命令启动一个新的 Docker 容器,并将书籍示例源代码与之共享:
docker run -it -v [host_examples_path]:[container_examples_path] [tag name] bash
在这里,host_examples是从github.com/PacktPublishing/Hands-on-Machine-learning-with-C-Second-Edition.git检查出的示例源代码的路径,而container_examples_path是容器中的目标挂载路径,例如,/samples。
运行前面的命令后,您将处于具有必要配置的软件包、编译的第三方库和编程示例包的命令行环境。您可以使用此环境编译和运行本书中的代码示例。每个编程示例都配置为使用 CMake 构建系统,因此您将以相同的方式构建它们。以下脚本展示了一个构建代码示例的可能场景:
cd Chapter01
mkdir build
cd build
cmake ..
cmake --build . --target all
这是手动方法。我们还提供了可用于构建每个示例的现成脚本。这些脚本位于存储库的build_scripts文件夹中。例如,第一章的构建脚本为build_ch1.sh,可以直接从这个文件夹中运行。
如果您打算手动配置构建环境,请注意LIBS_DIR变量,它应该指向所有第三方库安装的文件夹;使用提供的 Docker 环境构建脚本,它将指向$HOME/development/libs。
此外,您还可以配置您的本地机器环境,以便与 Docker 容器共享 X 服务器,从而能够从这个容器中运行图形用户界面应用程序。这将允许您从 Docker 容器中使用,例如,Android Studio IDE 或 C++ IDE(如 Qt Creator),而无需本地安装。以下脚本展示了如何进行此操作:
xhost +local:root
docker run --net=host -e DISPLAY=$DISPLAY -v /tmp/.X11-unix:/tmp/.X11-unix -it -v [host_examples_path]:[container_examples_path] [tag name] bash
为了更舒适地理解和构建代码示例,我们建议你仔细阅读每个第三方库的文档,并花一些时间学习 Docker 系统的基本知识和 Android 平台开发。此外,我们假设你具备足够的 C++语言和编译器的实际知识,并且熟悉 CMake 构建系统。
如果你正在使用这本书的数字版,我们建议你亲自输入代码或通过 GitHub 仓库(以下章节中有链接)访问代码。这样做将帮助你避免与代码复制粘贴相关的任何潜在错误。
下载示例代码文件
你可以从 GitHub 下载这本书的示例代码文件,链接为github.com/PacktPublishing/Hands-on-Machine-learning-with-C-Second-Edition。如果代码有更新,它将在 GitHub 仓库中更新。
我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!
使用的约定
本书使用了多种文本约定。
文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter/X 用户名。以下是一个示例:“Dlib库没有很多分类算法。”
代码块设置如下:
std::vector<fl::Tensor> fields{train_x, train_y};
auto dataset = std::make_shared<fl::TensorDataset>(fields);
int batch_size = 8;
auto batch_dataset = std::make_shared<fl::BatchDataset>(dataset,
batch_size);
当我们希望引起你对代码块中特定部分的注意时,相关的行或项目将以粗体显示:
[default]
exten => s,1,Dial(Zap/1|30)
exten => s,2,Voicemail(u100)
exten => s,102,Voicemail(b100)
exten => i,1,Voicemail(s0)
任何命令行输入或输出都按照以下方式编写:
$ mkdir css
$ cd css
粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“在 N 类的一对一策略中,训练了 N 个分类器,每个分类器将其类别与其他所有类别分开。”
小贴士或重要提示
看起来是这样的。
联系我们
欢迎读者反馈。
一般反馈:如果你对本书的任何方面有疑问,请通过电子邮件发送给我们 customercare@packtpub.com,并在邮件主题中提及书名。
勘误:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果你在这本书中发现了错误,我们将非常感激如果你能向我们报告。请访问www.packtpub.com/support/errata并填写表格。
盗版:如果你在互联网上以任何形式发现我们作品的非法副本,我们将非常感激如果你能提供位置地址或网站名称。请通过电子邮件 copyright@packtpub.com 与我们联系,并提供材料的链接。
如果你有兴趣成为作者:如果你在某个领域有专业知识,并且你感兴趣的是撰写或为书籍做出贡献,请访问authors.packtpub.com。
分享你的想法
一旦你阅读了《Hands-On Machine Learning with C++》,我们很乐意听到你的想法!请点击此处直接进入本书的亚马逊评论页面并分享你的反馈。
你的评论对我们和科技社区都很重要,并将帮助我们确保我们提供高质量的内容。
下载本书的免费 PDF 副本
感谢您购买本书!
你喜欢在路上阅读,但无法携带你的印刷书籍到处走吗?
你的电子书购买是否与你的选择设备不兼容?
别担心,现在每购买一本 Packt 图书,你都可以免费获得该书的 DRM 免费 PDF 版本。
在任何地方、任何设备上阅读。直接从你最喜欢的技术书籍中搜索、复制和粘贴代码到你的应用程序中。
优惠不仅限于此,你还可以获得独家折扣、时事通讯和每日免费内容的每日电子邮件。
按照以下简单步骤获取福利:
- 扫描下面的二维码或访问以下链接

packt.link/free-ebook/9781805120575
-
提交你的购买证明
-
就这些!我们将直接将你的免费 PDF 和其他福利发送到你的电子邮件。
第一部分:机器学习概述
在这部分,我们将借助 C++和各种机器学习框架的示例,深入探讨机器学习的基础知识。我们将展示如何从各种文件格式加载数据,并描述模型性能测量技术和最佳模型选择方法。
本部分包括以下章节:
-
第一章,使用 C++的机器学习简介
-
第二章,数据处理
-
第三章,性能测量和模型选择
第一章:使用 C++介绍机器学习
计算机解决任务的方法有很多种。其中一种方法是定义一个明确的算法,另一种方法是使用基于数学和统计方法的隐式策略。机器学习(ML)是使用数学和统计方法解决任务的隐式方法之一。它是一个正在迅速发展的学科,许多科学家和研究人员认为它是向具有人类水平人工智能(AI)行为的系统迈进的最佳途径之一。
通常,机器学习方法的基本思想是在给定的数据集中寻找模式。考虑一个新闻推送的推荐系统,它根据用户的先前活动或偏好为用户提供个性化的推送。软件收集有关用户阅读的新闻文章类型的资料,并计算一些统计数据。例如,它可能是某些主题在一组新闻文章中出现的频率。然后,它执行一些预测分析,识别一般模式,并使用这些模式来填充用户的新闻推送。这样的系统定期跟踪用户的活动,更新数据集,并为推荐计算新的趋势。
机器学习是一个快速发展的领域,在各个行业中都有广泛的应用。在医疗保健领域,它通过分析医疗数据来检测模式和预测疾病及治疗效果。在金融领域,它有助于信用评分、欺诈检测、风险评估、投资组合优化和算法交易,从而增强决策和运营。电子商务通过基于客户行为的推荐系统来提升销售和满意度。自动驾驶汽车使用机器学习进行环境感知、决策和安全的导航。
客户服务通过聊天机器人和虚拟助手得到改善,它们可以处理查询和任务。网络安全利用机器学习通过分析网络流量和识别威胁来检测和预防网络攻击。语言翻译工具使用机器学习进行准确高效的文字翻译。图像识别,借助计算机视觉算法,在图像和视频中识别对象、人脸和场景,支持人脸识别和内容审核等应用。语音助手如 Siri、Google Assistant 和 Alexa 中的语音识别依赖于机器学习来理解和响应用户指令。这些例子展示了机器学习在塑造我们生活方面的巨大潜力。
本章介绍了机器学习的概念以及哪些任务可以使用机器学习来解决,并讨论了机器学习中使用的不同方法。它旨在展示开始实现机器学习算法所需的最基本的数学知识。它还涵盖了如何执行基本的Eigen、xtensor、ArrayFire、Blaze和Dlib操作,并以线性回归任务为例进行解释。
本章将涵盖以下主题:
-
理解机器学习的基本原理
-
线性代数概述
-
线性回归示例概述
理解机器学习的基本原理
创建和训练机器学习模型有不同的方法。在本节中,我们展示了这些方法是什么以及它们之间的区别。除了我们用来创建机器学习模型的方法之外,还有一些参数管理模型在训练和评估过程中的行为。模型参数可以分为两组,它们应该以不同的方式配置。第一组参数是机器学习算法中的模型权重,用于调整模型的预测。这些权重在训练过程中被分配数值,这些数值决定了模型如何根据新数据做出决策或预测。第二组是模型超参数,它们控制机器学习模型在训练过程中的行为。这些超参数不像模型中的其他参数那样从数据中学习,而是在训练开始之前由用户或算法设置。机器学习过程的最后关键部分是我们用来训练模型的技术。通常,训练技术使用某种数值优化算法来找到目标函数的最小值。在机器学习中,目标函数通常被称为损失函数,用于在训练算法出错时对其进行惩罚。我们将在以下章节中更精确地讨论这些概念。
探索机器学习技术
我们可以将机器学习方法分为两种技术,如下所示:
-
监督学习是一种基于使用标记数据的方法。标记数据是一组已知数据样本及其对应的已知目标输出。此类数据用于构建一个可以预测未来输出的模型。
-
无监督学习是一种不需要标记数据的处理方法,可以在任意类型的数据中搜索隐藏的模式和结构。
让我们详细看看每种技术。
监督学习
监督学习算法通常使用有限的一组标记数据来构建可以对新数据进行合理预测的模型。我们可以将监督学习算法分为两个主要部分,即分类和回归技术,具体描述如下:
-
分类模型预测一些有限且不同的类别类型——这可能是标识电子邮件是否为垃圾邮件的标签,或者图像中是否包含人脸的标签。分类模型应用于语音和文本识别、图像中的对象识别、信用评分等领域。创建分类模型的典型算法包括支持向量机(SVM)、决策树方法、k-最近邻(KNN)、逻辑回归、朴素贝叶斯和神经网络。以下章节将描述这些算法的一些细节。
-
回归模型预测连续响应,例如温度变化或货币汇率值。回归模型应用于算法交易、电力负荷预测、收入预测等领域。如果给定标记数据的输出是实数,则创建回归模型通常是有意义的。创建回归模型的典型算法包括线性回归和多变量回归、多项式回归模型以及逐步回归。我们也可以使用决策树技术和神经网络来创建回归模型。
以下章节描述了这些算法的一些细节。
无监督学习
无监督学习算法不使用标记数据集。它们创建使用数据内在关系来寻找隐藏模式并用于做出预测的模型。最著名的无监督学习技术是聚类。聚类涉及根据数据项的一些内在属性将给定的数据集划分为有限数量的组。聚类应用于市场研究、不同类型的探索性分析、脱氧核糖核酸(DNA)分析、图像分割和目标检测。创建执行聚类模型的典型算法包括 k-means、k-medoids、高斯混合模型、层次聚类和隐马尔可夫模型。本书的以下章节中解释了其中一些算法。
处理机器学习模型
我们可以将机器学习模型解释为接受不同类型参数的函数。这些函数根据这些参数的值,为给定的输入提供输出。开发者可以通过调整模型参数来配置机器学习模型的行为,以解决特定问题。训练机器学习模型通常可以被视为寻找其参数最佳组合的过程。我们可以将机器学习模型的参数分为两种类型。第一种类型是模型内部的参数,我们可以从训练(输入)数据中估计它们的值。第二种类型是模型外部的参数,我们无法从训练数据中估计它们的值。模型外部的参数通常被称为超参数。
内部参数具有以下特征:
-
它们对于做出预测是必要的
-
它们定义了模型在给定问题上的质量
-
我们可以从训练数据中学习它们
-
通常,它们是模型的一部分
如果模型包含固定数量的内部参数,则称为参数化。否则,我们可以将其归类为非参数化。
内部参数的例子如下:
-
人工神经网络(ANNs)的权重
-
SVM 模型的支撑向量值
-
线性回归或逻辑回归的多项式系数
ANNs(人工神经网络)是受人类大脑中生物神经网络的架构和功能启发的计算机系统。它们由相互连接的节点或神经元组成,这些节点处理和传输信息。ANNs 旨在从数据中学习模式和关系,使它们能够根据新的输入进行预测或决策。学习过程涉及调整神经元之间连接的权重和偏差,以提高模型的准确性。
另一方面,超参数具有以下特征:
-
它们用于配置估计模型参数的算法
-
实践者通常指定它们
-
它们的估计通常基于使用启发式方法
-
他们针对具体的建模问题
对于特定问题,很难知道模型超参数的最佳值。此外,从业者通常需要研究如何调整所需的超参数,以便模型或训练算法以最佳方式运行。从业者使用经验法则、从类似项目中复制值以及如网格搜索等特殊技术来进行超参数估计。
超参数的例子如下:
-
用于分类质量配置的 SVM 算法中的 C 和 sigma 参数
-
在神经网络训练过程中用于配置算法收敛的学习率参数
-
在 KNN 算法中用于配置邻居数量的k值
模型参数估计
模型参数估计通常使用某些优化算法。结果模型的速度和质量可能显著取决于所选的优化算法。优化算法的研究在工业界和学术界都是一个热门话题。机器学习(ML)通常使用基于损失函数优化的优化技术和算法。评估模型预测数据好坏的函数称为损失函数。如果预测与目标输出非常不同,损失函数将返回一个可以解释为不良的值,通常是一个大数字。这样,损失函数在优化算法向错误方向移动时对其进行惩罚。因此,一般思想是使损失函数的值最小化以减少惩罚。没有单一的通用损失函数适用于优化算法。不同的因素决定了如何选择损失函数。以下是一些这样的因素的例子:
-
给定问题的具体细节——例如,它是一个回归模型还是分类模型
-
计算导数的容易程度
-
数据集中异常值的百分比
在机器学习(ML)中,术语 优化器 用于定义一个将损失函数与更新模型参数的技术相连接的算法,该技术是对损失函数值的响应。因此,优化器通过调整模型参数来调整机器学习模型,以最准确的方式预测新数据的目标值。优化器或优化算法在训练机器学习模型中起着至关重要的作用。它们帮助找到模型的最佳参数,这可以提高其性能和准确性。它们在各个领域都有广泛的应用,例如图像识别、自然语言处理和欺诈检测。例如,在图像分类任务中,可以使用优化算法来训练深度神经网络,以准确识别图像中的对象。有许多优化器:梯度下降、Adagrad、RMSProp、Adam 等。此外,开发新的优化器是一个活跃的研究领域。例如,微软(位于雷德蒙德)有一个名为 ML 和优化 的研究小组,其研究领域包括组合优化、凸和非凸优化,以及它们在机器学习和人工智能中的应用。该行业中的其他公司也有类似的研究小组;Facebook Research、Amazon Research 和 OpenAI 小组都有许多出版物。
现在,我们将了解机器学习是什么以及它的主要概念部分。因此,让我们学习其数学基础最重要的部分:线性代数。
线性代数的概述
线性代数的概念对于理解机器学习背后的理论至关重要,因为它们帮助我们理解机器学习算法在底层是如何工作的。此外,大多数机器学习算法的定义都使用了线性代数的术语。
线性代数不仅是一个实用的数学工具,而且线性代数的概念也可以用现代计算机架构非常有效地实现。机器学习(特别是深度学习)的兴起是在现代 Cuda 和 OpenCL 显著性能提升之后开始的,一个专门的线性代数库的例子是 cuBLAS。此外,使用 通用图形处理单元(GPGPUs)变得更加普遍,因为这些将现代 GPU 的计算能力转化为强大的通用计算资源。
此外,还有 AVx、SSE 和 MMx。还有 Eigen、xtensor、VienaCL 等术语,它们被用来提高计算性能。
学习线性代数的概念
线性代数是一个很大的领域。它是研究线性对象的代数部分:向量(或线性)空间、线性表示和线性方程组。线性代数中使用的工具主要有行列式、矩阵、共轭和张量计算。
要理解机器学习算法,我们只需要一组小的线性代数概念。然而,要研究新的机器学习算法,从业者应该对线性代数和微积分有深入的理解。
以下列表包含了理解机器学习算法最有价值的线性代数概念:
-
标量:这是一个单独的数字。
-
向量:这是一个有序数字的数组。每个元素都有一个独特的索引。向量的表示法是名称使用粗体小写字母,元素使用带有下标的斜体字母,如下例所示!
-
矩阵:这是一个二维数字数组。每个元素都有一个独特的索引对。矩阵的表示法是名称使用粗体大写字母,元素使用带有逗号分隔的索引列表的下标斜体字母,如下例所示:

- 张量:这是一个以多维规则网格排列的数字数组,代表了矩阵的推广。它就像一个多维矩阵。例如,具有 2 x 2 x 2 维度的张量 A 可以看起来像这样:

线性代数库和机器学习框架通常使用张量的概念而不是矩阵,因为它们实现的是通用算法,而矩阵只是具有两个维度的张量的特例。此外,我们可以将向量视为大小为 n x 1 的矩阵。
基本线性代数操作
编程线性代数算法最常用的操作如下:
- 逐元素操作:这些操作以逐元素的方式在相同大小的向量、矩阵或张量上执行。结果元素将是相应输入元素上操作的输出,如下所示:




以下示例显示了逐元素求和:

- 点积:在线性代数中,张量和矩阵有两种乘法类型——一种是逐元素,另一种是点积。点积处理两个等长数字序列,并返回一个数字。在矩阵或张量上应用此操作要求矩阵或张量 A 的列数与矩阵或张量 B 的行数相同。以下示例显示了当 A 是一个 n x m 矩阵且 B 是一个 m x p 矩阵时的点积操作:

- 转置:矩阵的转置是一种翻转矩阵对角线上的操作,这会导致矩阵的列和行索引翻转,从而创建一个新的矩阵。一般来说,它是交换矩阵的行和列。以下示例显示了转置的工作原理:

- 范数:这个操作计算向量的大小;这个结果是一个非负实数。范数公式如下:

这种范数的通用名称是
范数,用于
。通常,我们使用更具体的范数,例如 p = 2 的
范数,这被称为欧几里得范数,我们可以将其解释为点之间的欧几里得距离。另一种广泛使用的范数是平方
范数,其计算公式为
。平方
范数比
范数更适合数学和计算操作。平方
范数的每一阶偏导数只依赖于 x 的对应元素,而
范数的偏导数则依赖于整个向量;这一特性在优化算法中起着至关重要的作用。另一种广泛使用的范数操作是 p = 1 的
范数,在机器学习中,当我们关注零和非零元素之间的差异时常用。L¹ 范数也称为曼哈顿距离。
- 求逆:逆矩阵是这样的矩阵,
,其中 I 是单位矩阵。单位矩阵是一个当我们用该矩阵乘以一个向量时不会改变该向量的矩阵。
我们已经考虑了主要的线性代数概念及其操作。使用这个数学工具,我们可以定义和编程许多机器学习算法。例如,我们可以使用张量和矩阵来定义训练数据集,标量可以用作不同类型的系数。我们可以使用逐元素操作对整个数据集(矩阵或张量)进行算术运算。例如,我们可以使用逐元素乘法来缩放数据集。我们通常使用转置来改变向量或矩阵的视图,使其适合点积操作。点积通常用于将权重表示为矩阵系数的线性函数应用于向量;例如,这个向量可以是一个训练样本。此外,点积操作也用于根据算法更新表示为矩阵或张量系数的模型参数。
范数操作常用于损失函数的公式中,因为它自然地表达了距离概念,可以衡量目标和预测值之间的差异。逆矩阵是线性方程组解析求解的关键概念。这类系统常出现在不同的优化问题中。然而,计算逆矩阵的计算成本非常高。
计算机中的张量表示
我们可以用不同的方式在计算机内存中表示张量对象。最明显的方法是在计算机内存中(随机存取存储器,或 RAM)使用简单的线性数组。然而,线性数组也是现代 CPU 最有效的计算数据结构。有两种标准做法在内存中使用线性数组组织张量:行主序排列和列主序排列。
在行主序排列中,我们将一行的连续元素依次线性排列,并且每一行也排在上一行的末尾之后。在列主序排列中,我们以相同的方式处理列元素。数据布局对计算性能有重大影响,因为遍历数组的速度依赖于现代 CPU 架构,它们在处理顺序数据时比处理非顺序数据更有效。CPU 缓存效应是这种行为的理由。此外,连续的数据布局使得可以使用与顺序数据更有效地工作的 SIMD 向量化指令,我们可以将它们用作一种并行处理方式。
不同的库,即使在同一编程语言中,也可以使用不同的排列。例如,Eigen 使用列主序排列,但 PyTorch 使用行主序排列。因此,开发者应该注意他们使用的库中内部张量表示,并在执行数据加载或从头实现算法时注意这一点。
考虑以下矩阵:

然后,在行主序数据布局中,矩阵的成员在内存中的布局如下:
| 0 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|
| a11 | a12 | a13 | a21 | a22 | a23 |
表 1.1 – 行主序数据布局示例
在列主序数据布局的情况下,顺序布局将紧随其后,如下所示:
| 0 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|
| a11 | a21 | a12 | a22 | a13 | a23 |
表 1.2 – 列主序数据布局示例
线性代数 API 示例
让我们考虑一些 C++ 线性代数 应用程序编程接口(API)并看看我们如何使用它们来创建线性代数原语以及使用它们进行代数运算。
使用 Eigen
Eigen 是一个通用的线性代数 C++ 库。在 Eigen 中,所有矩阵和向量都是 Matrix 模板类的对象,向量是矩阵类型的特化,具有一行或一列。张量对象在官方 API 中没有表示,但作为子模块存在。
我们可以这样定义一个已知 3 x 3 维度和浮点数据类型的矩阵类型:
typedef Eigen::Matrix<float, 3, 3> MyMatrix33f;
我们可以这样定义一个列向量:
typedef Eigen::Matrix<float, 3, 1> MyVector3f;
Eigen 已经为向量和矩阵对象提供了许多预定义的类型——例如,Eigen::Matrix3f(浮点 3 x 3 矩阵类型)或Eigen::RowVector2f(浮点 1 x 2 向量类型)。此外,Eigen 并不限于在编译时已知维度的矩阵。我们可以在运行时初始化时定义矩阵类型,它将根据行数或列数动态调整。为了定义此类类型,我们可以使用名为Eigen::Dynamic的特殊类型变量作为Matrix类模板参数。例如,为了定义一个具有动态维度的双精度矩阵,我们可以使用以下定义:
typedef Eigen::
Matrix<double, Eigen::Dynamic, Eigen::Dynamic>
MyMatrix;
从我们定义的类型初始化的对象将看起来像这样:
MyMatrix33f a;
MyVector3f v;
MyMatrix m(10,15);
要将这些值放入这些对象中,我们可以使用几种方法。我们可以使用特殊预定义的初始化函数,如下所示:
a = MyMatrix33f::Zero(); // fill matrix elements with zeros
a = MyMatrix33f::Identity(); // fill matrix as Identity matrix
v = MyVector3f::Random(); // fill matrix elements with random values
我们可以使用逗号初始化器语法,如下所示:
a << 1,2,3,
4,5,6,
7,8,9;
此代码构造以以下方式初始化矩阵值:

我们可以使用直接元素访问来设置或更改矩阵系数。以下代码示例展示了如何使用()运算符进行此类操作:
a(0,0) = 3;
我们可以使用Map类型的对象将现有的 C++数组或向量包装在Matrix类型对象中。此类映射对象将使用底层对象的内存和值,而不会分配额外的内存并复制值。以下代码片段展示了如何使用Map类型:
int data[] = {1,2,3,4};
Eigen::Map<Eigen::RowVectorxi> v(data,4);
std::vector<float> data = {1,2,3,4,5,6,7,8,9};
Eigen::Map<MyMatrix33f> a(data.data());
我们可以在数学运算中使用初始化的矩阵对象。Eigen库中的矩阵和向量算术运算通过标准 C++算术运算符的重载提供,如+、-、*,或者通过方法如dot()和cross()。以下代码示例展示了如何在 Eigen 中表示通用数学运算:
using namespace Eigen;
auto a = Matrix2d::Random();
auto b = Matrix2d::Random();
auto result = a + b;
result = a.array() * b.array(); // element wise multiplication
result = a.array() / b.array();
a += b;
result = a * b; // matrix multiplication
//Also it's possible to use scalars:
a = b.array() * 4;
注意,在 Eigen 中,算术运算符如+本身并不执行任何计算。这些运算符返回一个表达式对象,它描述了要执行的计算。实际的计算发生在整个表达式评估之后,通常是在=算术运算符中。这可能会导致一些奇怪的行为,主要如果开发者频繁使用auto关键字的话。
有时,我们只需要对矩阵的一部分执行操作。为此,Eigen 提供了block方法,它接受四个参数:i,j,p,q。这些参数是块大小p,q和起始点i,j。以下代码展示了如何使用此方法:
Eigen::Matrixxf m(4,4);
Eigen::Matrix2f b = m.block(1,1,2,2); // copying the middle
//part of matrix
m.block(1,1,2,2) *= 4; // change values in original matrix
有两种更多通过索引访问行和列的方法,它们也是一种block操作。以下代码片段展示了如何使用col和row方法:
m.row(1).array() += 3;
m.col(2).array() /= 4;
线性代数库的另一个重要特性是广播,Eigen 通过colwise和rowwise方法支持这一特性。广播可以理解为通过在一个方向上复制矩阵来解释为一个矩阵。以下是一个如何将向量添加到矩阵每一列的示例:
Eigen::Matrixxf mat(2,4);
Eigen::Vectorxf v(2); // column vector
mat.colwise() += v;
此操作的结果如下:

使用 xtensor
xtensor库是一个用于数值分析的 C++库,具有多维数组表达式。xtensor的容器灵感来源于 NumPy,Python 数组编程库。机器学习算法主要使用 Python 和 NumPy 进行描述,因此这个库可以使得将它们移动到 C++更加容易。以下容器类在xtensor库中实现了多维数组。
xarray类型是一个动态大小的多维数组,如下面的代码片段所示:
std::vector<size_t> shape = { 3, 2, 4 };
xt::xarray<double, xt::layout_type::row_major> a(shape);
xarray类型的动态大小意味着这个形状可以在编译时改变。
xtensor类型是一个在编译时固定范围的多维数组。精确的维度值可以在初始化步骤中配置,如下面的代码片段所示:
std::array<size_t, 3> shape = { 3, 2, 4 };
xt::xtensor<double, 3> a(shape);
xtensor_fixed类型是一个在编译时固定维度形状的多维数组,如下面的代码片段所示:
xt::xtensor_fixed<double, xt::xshape<3, 2, 4>> a;
xtensor库还通过 Eigen 等表达式模板技术实现了算术运算符,这是一个在 C++中实现的数学库的常见方法。因此,计算是延迟的,整个表达式评估时才会计算实际结果。
延迟计算,也称为延迟评估或按需评估,是编程中的一种策略,其中表达式的评估被延迟到其实际需要时。
这与即时求值相对立,即时求值是在遇到表达式时立即进行评估。容器定义也是表达式。xtensor库中还有一个名为xt::eval的函数,用于强制表达式评估。
在xtensor库中存在不同种类的容器初始化。xtensor数组的初始化可以使用 C++初始化列表来完成,如下所示:
xt::xarray<double> arr1{{1.0, 2.0, 3.0},
{2.0, 5.0, 7.0},
{2.0, 5.0, 7.0}}; // initialize a 3x3 array
xtensor库还为特殊张量类型提供了构建函数。以下代码片段展示了其中的一些:
std::vector<uint64_t> shape = {2, 2};
auto x = xt::ones(shape); // creates 2x2 matrix of 1s
auto y = xt::zero(shape); // creates zero 2x2 matrix
auto z = xt::eye(shape); // creates 2x2 matrix with ones
//on the diagonal
此外,我们可以使用xt::adapt函数将现有的 C++数组映射到xtensor容器中。此函数返回一个使用底层对象的内存和值的对象,如下面的代码片段所示:
std::vector<float> data{1,2,3,4};
std::vector<size_t> shape{2,2};
auto data_x = xt::adapt(data, shape);
我们可以使用()运算符直接访问容器元素,以设置或更改张量值,如下面的代码片段所示:
std::vector<size_t> shape = {3, 2, 4};
xt::xarray<float> a = xt::ones<float>(shape);
a(2,1,3) = 3.14f;
xtensor库通过标准 C++算术运算符(如+、-和*)的重载来实现线性代数算术运算。要使用其他操作,如点积运算,我们必须将应用程序与名为xtensor-blas的库链接起来。这些运算符在xt::linalg命名空间中声明。
以下代码展示了使用xtensor库进行算术运算的用法:
auto a = xt::random::rand<double>({2,2});
auto b = xt::random::rand<double>({2,2});
auto c = a + b;
a -= b;
c = xt::linalg::dot(a,b);
c = a + 5;
要获取对 xtensor 容器的部分访问权限,我们可以使用 xt::view 函数。view 函数返回一个新的张量对象,它与原始张量共享相同的基本数据,但具有不同的形状或步长。这允许您以不同的方式访问张量中的数据,而实际上并不改变底层数据本身。以下示例展示了此函数的工作原理:
xt::xarray<int> a{
{1, 2, 3, 4},
{5, 6, 7, 8},
{9, 10, 11, 12},
{13, 14, 15, 16}
};
auto b = xt::view(a, xt::range(1, 3), xt::range(1, 3));
此操作从张量中取一个矩形块,看起来像这样:

xtensor 库在大多数情况下实现了自动广播。当操作涉及不同维度的两个数组时,它会将具有较小维度的数组传输到另一个数组的前导维度,这样我们就可以直接将一个向量加到一个矩阵上。以下代码示例展示了这有多么简单:
auto m = xt::random::rand<double>({2,2});
auto v = xt::random::rand<double>({2,1});
auto c = m + v;
使用 Blaze
Blaze 是一个通用高性能 C++ 库,用于密集和稀疏线性代数。Blaze 中有不同类来表示矩阵和向量。
我们可以定义具有已知维度和浮点数据类型的矩阵类型,如下所示:
typedef blaze::
StaticMatrix<float, 3UL, 3UL, blaze::columnMajor>
MyMatrix33f;
我们可以以下方式定义一个向量:
typedef blaze::StaticVector<float, 3UL> MyVector3f;
此外,Blaze 并不仅限于在编译时已知维度的矩阵。我们可以在运行时初始化期间定义将接受行数或列数的矩阵类型。为了定义此类类型,我们可以使用 blaze::DynamicMatrix 或 blaze::DynamicVector 类。例如,为了定义具有动态维度的双精度矩阵,我们可以使用以下定义:
typedef blaze::DynamicMatrix<double> MyMatrix;
从我们定义的类型初始化的对象将如下所示:
MyMatrix33f a;
MyVector3f v;
MyMatrix m(10, 15);
要将这些值放入这些对象中,我们可以使用几种方法。我们可以使用特殊预定义的初始化函数,如下所示:
a = blaze::zero<float>(3UL, 3UL); // Zero matrix
a = blaze::IdentityMatrix<float>(3UL); // Identity matrix
blaze::Rand<float> rnd;
v = blaze::generate(3UL, & { return rnd.generate(); });
// Random generated vector
// Matrix filled from the initializer list
a = {{1, 2, 3}, {4, 5, 6}, {7, 8, 9}};
// Matrix filled with a single value
a = blaze::uniform(3UL, 3UL, 3.f);
此代码构造以以下方式初始化矩阵值:

我们可以使用直接元素访问来设置或更改矩阵系数。以下代码示例展示了如何使用 () 操作符进行此类操作:
a(0,0) = 3;
我们可以使用 blaze::CustomVector 类型的对象将现有的 C++ 数组或向量包装到 Matrix 或 Vector 类型对象中。此类映射对象将使用底层对象的内存和值,而不会分配额外的内存并复制值。以下代码片段展示了如何使用这种方法:
std::array<int, 4> data = {1, 2, 3, 4};
blaze::CustomVector<int,
blaze::unaligned,
blaze::unpadded,
blaze::rowMajor>
v2(data.data(), data.size());
std::vector<float> mdata = {1, 2, 3, 4, 5, 6, 7, 8, 9};
blaze::CustomMatrix<float,
blaze::unaligned,
blaze::unpadded,
blaze::rowMajor>
a2(mdata.data(), 3UL, 3UL);
我们可以在数学运算中使用初始化的矩阵对象。注意,我们使用了两个参数:blaze::unaligned和blaze::unpadded。unpadded参数可以在某些函数或方法中使用,以控制填充或截断数组的行为。在某些场景中,当您希望在重塑、切片或连接数组等操作中避免不必要的填充或截断数据时,此参数可能很重要。blaze::unaligned参数允许用户对未对齐的数据执行操作,这在数据没有对齐到特定内存边界的情况下可能很有用。
在Blaze库中,矩阵和向量的算术运算可以通过标准 C++算术运算符的重载来实现,例如+、-或*,或者通过dot()和cross()等方法。以下代码示例展示了如何在 Blaze 中表达一般的数学运算:
blaze::StaticMatrix<float, 2UL, 2UL> a = {{1, 2}, {3, 4}};
auto b = a;
// element wise operations
blaze::StaticMatrix<float, 2UL, 2UL> result = a % b;
a = b * 4;
// matrix operations
result = a + b;
a += b;
result = a * b;
注意,在 Blaze 中,算术运算符如+本身并不执行任何计算。这些运算符返回一个表达式对象,它描述了要执行的计算。实际的计算发生在整个表达式评估之后,通常是在=算术运算符或具体对象的构造函数中。这可能导致一些不明显的行为,主要如果开发者频繁使用auto关键字。该库提供了两个函数eval()和evaluate()来评估给定的表达式。evaluate()函数通过auto关键字帮助推断操作的精确结果类型,而eval()函数应用于显式评估较大表达式中的子表达式。
有时,我们只需要对矩阵的一部分执行操作。为此,Blaze 提供了blaze::submatrix和blaze::subvector类,这些类可以用模板参数进行参数化。这些参数是一个区域的左上角起始点、宽度和高度。还有具有相同名称的函数,它们接受相同的参数,可以在运行时使用。以下代码展示了如何使用此类:
blaze::StaticMatrix<float, 4UL, 4UL> m = {{1, 2, 3, 4},
{5, 6, 7, 8},
{9, 10, 11, 12},
{13, 14, 15, 16}}
// make a view of the middle part of matrix
auto b = blaze::submatrix<1UL, 1UL, 2UL, 2UL>(m);
// change values in original matrix
blaze::submatrix<1UL, 1UL, 2UL, 2UL>(m) *= 0;
有两个额外的函数可以通过索引访问行和列,它们也是一种block操作。以下代码片段展示了如何使用col和row函数:
blaze::row<1UL>(m) += 3;
blaze::column<2UL>(m) /= 4;
与 Eigen 相比,Blaze 不支持隐式广播。但有一个blaze::expand()函数可以在不实际分配内存的情况下虚拟地扩展矩阵或向量。以下代码展示了如何使用它:
blaze::DynamicMatrix<float, blaze::rowVector> mat =
blaze::uniform(4UL, 4UL, 2);
blaze::DynamicVector<float, blaze::rowVector> vec = {1, 2, 3, 4};
auto ex_vec = blaze::expand(vec, 4UL);
mat += ex_vec;
此操作的输出结果如下:
( 3 4 5 6 )
( 3 4 5 6 )
( 3 4 5 6 )
( 3 4 5 6 )
使用 ArrayFire
ArrayFire是一个用于并行计算的高性能通用 C++库。
并行计算是一种通过将复杂问题分解为更小的任务并在多个处理器或核心上同时执行来解决复杂问题的方法。与顺序计算相比,这种方法可以显著加快处理时间,使其成为数据密集型应用(如机器学习)的必要工具。
它提供了一个单一的 array 类型来表示矩阵、体积和向量。这种基于数组的表示法以可读的数学符号表达计算算法,因此用户不需要显式地表达并行计算。它具有广泛的向量化和平行批量操作。此库支持在 CUDA 和 OpenCL 设备上加速执行。ArrayFire 库的另一个有趣特性是,它通过执行代码的运行时分析来优化内存使用和算术计算。通过避免许多临时分配,这成为可能。
我们可以这样定义具有已知维度和浮点数据类型的矩阵类型:
af::array a(3, 3, af::dtype::f32);
我们可以这样定义一个 64 位浮点向量:
af::array v(3, af::dtype::f64);
要将这些值放入这些对象中,我们可以使用几种方法。我们可以使用特殊预定义的初始化函数,如下所示:
a = af::constant(0, 3, 3); // Zero matrix
a = af::identity(3, 3); // Identity matrix
v = af::randu(3); // Random generated vector
// Matrix filled with a single value
a = af::constant(3, 3, 3);
// Matrix filled from the initializer list
a = af::array(
af::dim4(3, 3),
{1.f, 2.f, 3.f, 4.f, 5.f, 6.f, 7.f, 8.f, 9.f});
此代码构造以以下方式初始化矩阵值:
1.0 4.0 7.0
2.0 5.0 8.0
3.0 6.0 9.0
注意到矩阵是以列主序格式初始化的。ArrayFire 库不支持行主序初始化。
我们可以使用直接元素访问来设置或更改矩阵系数。以下代码示例展示了如何使用 () 运算符进行此类操作:
a(0,0) = 3;
与我们之前讨论的其他库的一个重要区别是,你不能将现有的 C/C++ 数组数据映射到 ArrayFire 的 array 对象,因为它将被复制。以下代码片段展示了这种情况:
std::vector<float> mdata = {1, 2, 3, 4, 5, 6, 7, 8, 9};
a = af::array(3, 3, mdata.data());
只有在 CUDA 或 OpenCL 设备上分配的内存将不会被复制,但 ArrayFire 将接管指针的所有权。
我们可以在数学运算中使用初始化的数组对象。ArrayFire 库中的算术运算可以通过标准 C++ 算术运算符的重载,如 +、- 或 *,或通过如 af::matmul 这样的方法提供。以下代码示例展示了如何在 ArrayFire 中表达通用数学运算:
auto a = af::array(af::dim4(2, 2), {1, 2, 3, 4});
a = a.as(af::dtype::f32);
auto b = a.copy();
// element wise operations
auto result = a * b;
a = b * 4;
// matrix operations
result = a + b;
a += b;
result = af::matmul(a, b);
与我们之前讨论的其他库相比,ArrayFire 并不广泛使用模板表达式来连接算术运算。此库使用一个 即时(JIT)编译引擎,将数学表达式转换为 CUDA、OpenCL 或 CPU 设备的计算内核。此外,此引擎将不同的操作合并在一起,以提供最佳性能。这种操作融合技术减少了内核调用次数,并减少了全局内存操作。
有时,我们只需要对数组的一部分执行操作。为此,ArrayFire 提供了一种特殊的索引技术。有一些特定的类可以用来表示给定维度的索引子范围。它们如下所示:
seq - representing a linear sequence
end - representing the last element of a dimension
span - representing the entire dimension
以下代码展示了如何访问矩阵的中央部分的一个示例:
auto m = af::iota(af::dim4(4, 4));
auto center = m(af::seq(1, 2), af::seq(1, 2));
// modify a part of the matrix
center *= 2;
要访问和更新数组中的特定行或列,有row(i)和col(i)方法指定单个行或列。它们可以按以下方式使用:
m.row(1) += 3;
m.col(2) /= 4;
此外,为了处理多行或多列,有rows(first,last)和cols(first,last)方法指定行或列的范围。
ArrayFire 不支持隐式广播,但有一个af::batchFunc函数可以用来模拟和并行化此类功能。一般来说,这个函数会找到一个数据批次维度,并将给定的函数并行地应用于多个数据块。以下代码展示了如何使用它:
auto mat = af::constant(2, 4, 4);
auto vec = af::array(4, {1, 2, 3, 4});
mat = af::batchFunc(
vec,
mat,
[](const auto& a, const auto& b) { return a + b; });
这个操作的输出将是以下内容:
3.0 3.0 3.0 3.0
4.0 4.0 4.0 4.0
5.0 5.0 5.0 5.0
6.0 6.0 6.0 6.0
注意,这个向量是一个列主序的。
使用 Dlib
Dlib是一个现代的 C++工具包,包含机器学习算法和用于在 C++中创建计算机视觉软件的工具。Dlib 中的大多数线性代数工具都处理密集矩阵。然而,它也有限地支持处理稀疏矩阵和向量。特别是,Dlib工具使用 C++ 标准模板库(STL)中的容器来表示稀疏向量。
在 Dlib 中有两种主要的容器类型用于处理线性代数:matrix和vector类。Dlib 中的矩阵操作是通过表达式模板技术实现的,这允许它们消除通常从表达式如M = A+B+C+D返回的临时矩阵对象。
我们可以通过指定模板参数来在编译时创建一个矩阵,如下所示:
Dlib::matrix<double,3,1> y;
或者,我们可以创建动态大小的矩阵对象。在这种情况下,我们将矩阵维度传递给构造函数,如下面的代码片段所示:
Dlib::matrix<double> m(3,3);
之后,我们可以使用以下方法更改这个矩阵的大小:
m.set_size(6,6);
我们可以使用逗号运算符初始化矩阵值,如下面的代码片段所示:
m = 54.2, 7.4, 12.1,
1, 2, 3,
5.9, 0.05, 1;
如同之前的库一样,我们可以将现有的 C++数组包装到矩阵对象中,如下面的代码片段所示:
double data[] = {1,2,3,4,5,6};
auto a = Dlib::mat(data, 2,3); // create matrix with size 2x3
此外,我们可以使用()运算符访问矩阵元素以修改或获取特定值,如下面的代码片段所示:
m(1,2) = 3;
Dlib库提供了一系列预定义函数,用于使用诸如单位矩阵、全 1 矩阵或随机值等值初始化矩阵,如下面的代码片段所示:
auto a = Dlib::identity_matrix<double>(3);
auto b = Dlib::ones_matrix<double>(3,4);
auto c = Dlib::randm(3,4); // matrix with random values
//with size 3x3
在Dlib库中,大多数线性代数算术运算都是通过标准 C++算术运算符的重载实现的,如+、-或*。其他复杂操作由库作为独立函数提供。
以下示例展示了在 Dlib 库中如何使用算术运算:
auto c = a + b;
auto e = a * b; // real matrix multiplication
auto d = Dlib::pointwise_multiply(a, b); // element wise
//multiplication
a += 5;
auto t = Dlib::trans(a); // transpose matrix
为了部分访问矩阵,Dlib 提供了一套特殊函数。以下代码示例展示了如何使用其中的一些函数:
a = Dlib::rowm(b,0); // takes first row of matrix
a = Dlib::rowm(b,Dlib::range(0,1));//takes first two rows
a = Dlib::colm(b,0); // takes first column
// takes a rectangular part from center:
a = Dlib::subm(b, range(1,2), range(1,2));
// initialize part of the matrix:
Dlib::set_subm(b,range(0,1), range(0,1)) = 7;
// add a value to the part of the matrix:
Dlib::set_subm(b,range(0,1), range(0,1)) += 7;
在 Dlib 库中,可以使用 set_rowm()、set_colm() 和 set_subm() 函数对 Dlib 库中的广播进行建模,这些函数为特定的矩阵行、列或原始矩阵的矩形部分提供修改器对象。从这些函数返回的对象支持所有设置或算术运算。以下代码片段展示了如何将向量添加到列中:
Dlib::matrix<float, 2,1> x;
Dlib::matrix<float, 2,3> m;
Dlib::set_colm(b,Dlib::range(0,1)) += x;
在本节中,我们学习了线性代数的主要概念及其在不同 C++ 库中的实现。我们看到了如何创建矩阵和张量,以及如何使用它们执行不同的数学运算。在下一节中,我们将看到我们的第一个完整的机器学习示例——使用线性回归方法解决回归问题。
线性回归概述
考虑一个现实世界监督机器学习算法的例子,称为线性回归。一般来说,线性回归 是一种基于解释值(独立值)建模目标值(因值)的方法。这种方法用于预测和寻找值之间的关系。我们可以根据输入的数量(独立变量)和输入与输出(因变量)之间关系的类型对回归方法进行分类。
简单线性回归是独立变量数量为 1 的情况,独立变量 (x) 和因变量 (y) 之间存在线性关系。
线性回归在各个领域得到广泛应用,如科学研究,它可以描述变量之间的关系,以及在工业应用中,如收入预测。例如,它可以估计代表股票价格时间序列数据长期运动的趋势线。它说明了特定数据集的兴趣值在给定期间是增加还是减少,如下面的截图所示:

图 1.1 – 线性回归可视化
如果我们有一个输入变量(独立变量)和一个输出变量(因变量),则回归被称为简单,我们用术语 简单线性回归 来表示它。对于多个独立变量,我们称之为 多元线性回归 或 多变量线性回归。通常,当我们处理现实世界问题时,我们有很多独立变量,所以我们用多个回归模型来建模这些问题。多元回归模型有一个通用的定义,涵盖了其他类型,因此即使是简单线性回归也经常使用多元回归的定义来定义。
使用不同库解决线性回归任务
假设我们有一个数据集
,这样我们可以用数学公式以下方式表达y和x之间的线性关系:

在这里,p是自变量的维度,T表示转置,因此
是向量
和β的内积。我们还可以用矩阵表示法重写前面的表达式,如下所示:


,



上述矩阵表示可以解释如下:
-
y:这是一个观察到的目标值向量。
-
x:这是一个行向量的矩阵,
,被称为解释变量或独立变量。 -
ß:这是一个(p+1)维度的参数向量。
-
ε:这被称为误差项或噪声。这个变量捕捉了除了回归变量之外影响y因变量的所有其他因素。
当我们考虑简单线性回归时,p等于 1,方程将看起来像这样:

线性回归任务的目标是找到满足前述方程的参数向量。通常,这样的线性方程组没有精确解,因此任务是估计满足这些方程的参数,并基于一些假设。最流行的估计方法之一是基于最小二乘原理:最小化给定数据集中观察到的因变量与由线性函数预测的因变量之间的差的平方和。这被称为普通最小二乘法(OLS)估计量。因此,任务可以用以下公式表示:

在前面的公式中,目标函数S由以下矩阵表示给出:

这个最小化问题在x矩阵的p列线性无关的情况下有一个唯一解。我们可以通过求解正规方程来得到这个解,如下所示:

线性代数库可以直接用解析方法解决这样的方程,但它有一个显著的缺点——计算成本。在y和x维度很大的情况下,对计算机内存量和计算时间的要求太高,以至于无法解决现实世界的问题。
因此,通常,这个最小化任务是通过迭代方法解决的。梯度下降(GD)就是这类算法的一个例子。GD 是一种基于以下观察的技术:如果函数
在点
的邻域内定义且可导,那么当它沿着点
处的负梯度方向移动时,
减少最快。
我们可以将我们的
目标函数改变为一个更适合迭代方法的形式。我们可以使用均方误差(MSE)函数,它衡量估计值与估计值之间的差异,如下所示:

在多元回归的情况下,我们对每个x分量对此函数进行偏导数,如下所示:

因此,在线性回归的情况下,我们采取以下导数:


整个算法有以下描述:
-
将β初始化为零。
-
为学习率参数定义一个值,该参数控制我们在学习过程中调整参数的程度。
-
计算以下β的值:


- 重复步骤 1-3 多次,或者直到 MSE 值达到一个合理的数量。
之前描述的算法是监督机器学习中最简单的算法之一。我们用本章早期引入的线性代数概念来描述它。后来,人们越来越明显地认识到,几乎所有机器学习算法都在底层使用线性代数。线性回归在各个行业中广泛应用于预测分析、预测和决策。以下是一些线性回归在金融、营销和医疗保健中的实际应用案例。线性回归可以根据公司收益、利率和经济指标等历史数据预测股价。这有助于投资者在何时买卖股票时做出明智的决定。可以构建线性回归模型来根据人口统计信息、购买历史和其他相关数据预测客户行为。这使得营销人员能够更有效地定位他们的活动并优化他们的营销支出。线性回归用于分析医疗数据,以识别有助于改善患者结果的模式和关系。例如,它可以用来研究某些治疗方法对病人健康的影响。
以下示例展示了不同线性代数库中用于解决线性回归任务的更高级 API,我们提供它们以展示库如何简化底层复杂的数学。我们将在以下章节中详细介绍这些示例中使用的 API。
使用 Eigen 解决线性回归任务
Eigen库中有几种迭代方法可以解决形式为
的问题。LeastSquaresConjugateGradient类是其中之一,它允许我们使用共轭梯度算法解决线性回归问题。共轭梯度算法比常规 GD 更快地收敛到函数的最小值,但要求矩阵A是正定的,以保证数值稳定性。《LeastSquaresConjugateGradient》类有两个主要设置:最大迭代次数和一个容忍阈值,它用作停止标准,作为相对残差误差的上限,如下面的代码块所示:
typedef float DType;
using Matrix = Eigen::Matrix<DType, Eigen::Dynamic, Eigen::Dynamic>;
int n = 10000;
Matrix x(n,1);
Matrix y(n,1);
Eigen::LeastSquaresConjugateGradient<Matrix> gd;
gd.setMaxIterations(1000);
gd.setTolerance(0.001) ;
gd.compute(x);
auto b = gddg.solve(y);
对于新的x输入,我们可以通过矩阵运算预测新的y值,如下所示:
Eigen::Matrixxf new_x(5, 2);
new_x << 1, 1, 1, 2, 1, 3, 1, 4, 1, 5;
auto new_y = new_x.array().rowwise() * b.transpose().array();
此外,我们还可以通过直接求解正则方程来计算参数的b向量(线性回归任务解),如下所示:
auto b = (x.transpose() * x).ldlt().solve(x.transpose() * y);
使用 Blaze 解决线性回归任务
由于 Blaze 只是一个数学库,因此没有专门用于解决线性回归任务的类或函数。然而,正则方程方法可以轻松实现。让我们看看如何使用 Blaze 定义和解决线性回归任务:
假设我们已经有我们的训练数据:
typedef blaze::DynamicMatrix<float,blaze::columnMajor> Matrix;
typedef blaze::DynamicVector<float,blaze::columnVector> Vector;
// the first column of X is just 1 for the bias term
Matrix x(n, 2UL);
Matrix y(n, 1UL);
因此,我们可以通过以下方式找到线性回归系数:
// calculate X^T*X
auto xtx = blaze::trans(x) * x;
// calculate the inverse of X^T*X
auto inv_xtx = blaze::inv(xtx);
// calculate X^T*y
auto xty = blaze::trans(x) * y;
// calculate the coefficients of the linear regression
Matrix beta = inv_xtx * xty;
然后,我们可以使用估计的系数对新数据进行预测。以下代码片段展示了如何实现:
auto line_coeffs = blaze::expand(
blaze::row<0UL>(blaze::trans(beta)), new_x.rows());
auto new_y = new_x % line_coeffs;
注意,我们实际上扩展了系数向量以执行与 x 数据逐元素相乘。
使用 ArrayFire 解决线性回归任务
ArrayFire 也没有专门用于解决此类问题的函数和类。然而,由于它具有所有必要的数学抽象,可以应用正则方程方法。另一种方法是使用 GD 的迭代方法。这种算法在本章的第一节中进行了描述。这种技术消除了计算逆矩阵的必要性,使得它能够应用于更大量的训练数据。计算大矩阵的逆是一个非常耗时的操作。
让我们定义一个 lambda 函数来计算从数据和系数中得出的预测值:
auto predict = [](auto& v, auto& w) {
return af::batchFunc(v, w, [](const auto& a, const auto& b) {
return af::sum(a * b, /*dim*/ 1);
});
};
假设我们已经将训练数据定义在x和y变量中。我们定义train_weights变量来保存和更新从训练数据中想要学习的系数:
// the first column is for the bias term
af::dim4 weights_dim(1, 2);
auto train_weights = af::constant(0.f, weights_dim, af::dtype::f32);
然后,我们可以定义 GD 循环,其中我们将迭代更新系数。以下代码片段展示了如何实现它:
af::array j, dj; // cost value and its gradient
float lr = 0.1f; // learning rate
int n_iter = 300;
for (int i = 0; i < n_iter; ++i) {
std::cout << "Iteration " << i << ":\n";
// get the cost
auto h = predict(x, train_weights);
auto diff = (y - h);
auto j = af::sum(diff * diff) / n;
af_print(j);
// find the gradient of cost
auto dm = (-2.f / n) * af::sum(x.col(1) * diff);
auto dc = (-2.f / n) * af::sum(diff);
auto dj = af::join(1, dc, dm);
// update the parameters via gradient descent
train_weights = train_weights - lr * dj;
}
这个循环最重要的部分是计算预测误差:
auto h = predict(x, train_weights);
auto diff = (y – h);
另一个重要部分是基于与每个系数相关的偏导数计算梯度值:
auto dm = (-2.f / n) * af::sum(x.col(1) * diff);
auto dc = (-2.f / n) * af::sum(diff);
我们将这些值合并成一个向量,以形成一个更新训练参数的单个表达式:
auto dj = af::join(1, dc, dm);
train_weights = train_weights - lr * dj;
我们可以在迭代一定次数后或当成本函数值达到某个适当的收敛值时停止此训练循环。成本函数值可以按以下方式计算:
auto j = af::sum(diff * diff) / n;
您可以看到,这仅仅是所有训练样本平方误差的总和。
使用 Dlib 进行线性回归
Dlib 库提供了 krr_trainer 类,该类可以获取 linear_kernel 类型的模板参数以解决线性回归任务。此类使用核岭回归算法对此类问题进行直接解析求解,如下代码块所示:
std::vector<matrix<double>> x;
std::vector<float> y;
krr_trainer<KernelType> trainer;
trainer.set_kernel(KernelType());
decision_function<KernelType> df = trainer.train(x, y);
对于新的 x 输入,我们可以按以下方式预测新的 y 值:
std::vector<matrix<double>> new_x;
for (auto& v : x) {
auto prediction = df(v);
std::cout << prediction << std::endl;
}
在本节中,我们学习了如何使用不同的 C++ 库解决线性回归问题。我们了解到其中一些库包含了完整的算法实现,可以轻松应用,并且我们看到了如何仅使用基本的线性代数原语从头实现这种方法。
摘要
在本章中,我们学习了机器学习(ML)是什么,它与其他计算机算法有何不同,以及它为何如此受欢迎。我们还熟悉了开始使用机器学习算法所需的必要数学背景。我们查看了一些提供线性代数 API 的软件库,并实现了我们的第一个机器学习算法——线性回归。
C++ 还有其他线性代数库。此外,流行的深度学习框架使用它们自己的线性代数库实现。例如,MXNet 框架基于 mshadow 库,而 PyTorch 框架基于 ATen 库。这些库中的一些可以使用 GPU 或特殊的 CPU 指令来加速计算。这些特性通常不会改变 API,但需要一些额外的库初始化设置或显式地将对象转换为不同的后端,如 CPU 或 GPU。
真实的机器学习项目可能具有挑战性和复杂性。常见的问题包括数据质量问题、过拟合和欠拟合、选择错误的模型以及计算资源不足。数据质量差也可能影响模型性能。因此,清理和预处理数据以去除异常值、处理缺失值以及转换特征以获得更好的表示至关重要。模型的选取应根据问题的性质和可用数据来确定。过拟合发生在模型记住训练数据而不是学习一般模式时,而欠拟合则发生在模型无法捕捉数据的基本结构时。为了避免这些陷阱,重要的是要清楚地理解问题,使用适当的数据预处理技术,选择正确的模型,并使用与任务相关的指标来评估性能。机器学习的最佳实践还包括在生产中监控模型性能并根据需要做出调整。本书将讨论这些技术的细节。在接下来的两章中,我们将学习更多关于实现更复杂算法所需的软件工具,以及如何管理机器学习算法的理论背景。
进一步阅读
-
深度学习中的基础线性代数:
towardsdatascience.com/linear-algebra-for-deep-learning-f21d7e7d7f23 -
MIT 出版社的《深度学习》一书:
www.deeplearningbook.org/contents/linear_algebra.html -
Eigen库文档:gitlab.com/libeigen/eigen -
xtensor库文档:xtensor.readthedocs.io/en/latest/ -
Dlib库文档:dlib.net/ -
blaze库文档:bitbucket.org/blaze-lib/blaze/wiki/Home -
ArrayFire 库文档:
arrayfire.org/docs/index.htm
第二章:数据处理
在机器学习(ML)中,我们用于训练的数据是至关重要的。我们可以从我们所工作的流程中收集训练数据,或者我们可以从第三方来源获取已经准备好的训练数据。无论如何,我们必须将训练数据存储在满足我们开发要求的文件格式中。这些要求取决于我们解决的问题以及数据收集过程。有时,我们需要将存储在一个格式中的数据转换为另一个格式以满足我们的需求。以下是一些这样的需求的例子:
-
提高人类可读性,以便与工程师进行沟通
-
存在压缩可能性,以便数据在辅助存储上占用更少的空间
-
使用二进制形式的数据来加速解析过程
-
支持数据不同部分之间的复杂关系,以便精确地映射特定领域
-
平台独立性,以便能够在不同的开发和生产环境中使用数据集
今天,存在各种用于存储不同类型信息的文件格式。其中一些非常特定,而另一些则是通用目的的。有一些软件库允许我们操作这些文件格式。很少需要从头开始开发新的格式和解析器。使用现有的软件来读取格式可以显著减少开发和测试时间,这使我们能够专注于特定的任务。
本章讨论了如何处理我们用于存储数据的流行文件格式。它展示了用于处理 OpenCV 和 Dlib 库的库,以及如何将这些库中使用的数据格式转换为线性代数库中使用的数据类型。它还描述了数据归一化技术,如特征缩放和标准化过程,以处理异构数据。
本章将涵盖以下主题:
-
将数据格式解析为 C++ 数据结构
-
从 C++ 数据结构初始化矩阵和张量对象
-
使用
OpenCV和Dlib库操作图像 -
将图像转换为各种库的矩阵和张量对象
-
数据归一化
技术要求
本章所需的技术和安装如下:
-
支持 C++17/C++20 的现代 C++ 编译器
-
CMake 构建系统版本 >= 3.22
-
Dlib库安装 -
mlpack库安装 -
Flashlight库安装 -
Eigen库安装 -
hdf5lib库安装 -
HighFive库安装 -
nlohmann-json库安装 -
Fast-CPP-CSV-Parser库安装
本章的代码可以在以下 GitHub 仓库中找到:github.com/PacktPublishing/Hands-on-Machine-learning-with-C-Second-Edition
将数据格式解析为 C++ 数据结构
表示结构化数据最流行的格式被称为 CSV。这种格式只是一个包含二维表的文本文件,其中行中的值用逗号分隔,而行则放在每一行的开头。它看起来像这样:
1, 2, 3, 4
5, 6, 7, 8
9, 10, 11, 12
这种文件格式的优点在于其结构简单,许多软件工具可以处理它,它是可读的,并且支持多种计算机平台。缺点是它不支持多维数据以及结构复杂的数据,并且与二进制格式相比,解析速度较慢。
另一种广泛使用的格式是 JSON。尽管该格式的缩写中包含 JavaScript,但我们几乎可以用所有编程语言使用它。这是一个具有名称-值对的文件格式,以及此类对的数组。它有关于如何将此类对分组为不同的对象和数组声明的规则,以及如何定义不同类型值的规则。以下代码示例展示了 JSON 格式的文件:
{
"name": "Bill",
"age": 25,
"phones": [
{
"type": "home",
"number": 43534590
},
{
"type": "work",
"number": 56985468
}
]
}
这种格式的优点在于人眼可读性、许多计算机平台上的软件支持,以及存储分层和嵌套数据结构的可能性。缺点是,与二进制格式相比,其解析速度较慢,并且对于表示数值矩阵来说并不十分有用。在字符读取方面,二进制格式提供了对底层数据结构的直接访问,允许更快、更精确地提取字符。使用文本格式时,可能需要额外的步骤将字符转换为它们的数值表示,这可能会引入额外的处理开销。
通常,我们会使用多种文件格式的组合来表示复杂的数据集。例如,我们可以用 JSON 描述对象关系,而二进制形式的数据/数值数据可以存储在文件系统上的文件夹结构中,并在 JSON 文件中引用它。
HDF5 是一种专门用于存储科学数据的文件格式。这种文件格式是为了存储具有复杂结构的异构多维数据而开发的。因为它具有优化数据结构以使用辅助存储,所以它能够快速访问单个元素。此外,HDF5 支持数据压缩。一般来说,这种文件格式由包含多类型多维数组的命名组组成。该文件格式的每个元素都可以包含元数据,如下面的图所示:

图 2.1 – HDF5 格式结构
这种格式的优点是其读写速度快,快速访问不同元素,以及支持复杂结构和多种类型的数据。缺点是用户需要专用工具进行编辑和查看,不同平台之间类型转换的支持有限,以及整个数据集使用单个文件。最后一个问题使得在文件损坏的情况下数据恢复几乎不可能。因此,定期备份数据以防止硬件故障或意外删除导致的数据丢失是有意义的。
对于机器学习中的数据集表示,有许多其他格式,但我们发现提到的这些格式最有用。
使用 Fast-CPP-CSV-Parser 库读取 CSV 文件
考虑如何在 C++中处理 CSV 格式。有多个不同的库可以用于解析 CSV 格式,它们有不同的函数集和集成到应用程序中的不同方式。使用 C++库最简单的方法是使用仅包含头文件的库,因为这消除了构建和链接它们的需要。我们建议使用Fast-CPP-CSV-Parser库,因为它是一个小型单文件头文件库,具有所需的最小功能,可以轻松集成到开发代码库中。它还提供了一种快速高效地读取和写入 CSV 数据的方法。
作为 CSV 文件格式的示例,我们使用Iris数据集,它描述了三种不同的鸢尾花植物(Iris setosa、Iris versicolor和Iris virginica),由 R.A. Fisher 构想。文件中的每一行包含以下字段:花瓣长度、花瓣宽度、花萼长度、花萼宽度,以及一个带有类名的字符串。这个数据集用于展示如何根据这四个特征对未知鸢尾花进行分类的示例。
注意
Iris 数据集的引用如下:Dua, D. 和 Graff, C. (2019)。UCI Machine Learning Repository [archive.ics.uci.edu/static/public/53/iris.zip]。Irvine, CA: University of California, School of Information and Computer Science。
要使用Fast-CPP-CSV-Parser库读取此数据集,我们需要包含一个单独的头文件,如下所示:
#include <csv.h>
然后,我们定义一个io::CSVReader类型的对象。我们必须将列数定义为模板参数。这个参数是库的限制之一,因为我们需要了解 CSV 文件的结构。下面的代码片段展示了这一点:
const uint32_t columns_num = 5;
io::CSVReader<columns_num> csv_reader(file_path);
接下来,我们定义用于存储读取值的容器,如下所示:
std::vector<std::string> categorical_column;
std::vector<double> values;
然后,为了使我们的代码更通用,并将所有关于列类型的信息集中在一个地方,我们引入以下辅助类型和函数。我们定义了一个元组对象,用于描述一行中的值,如下所示:
using RowType =
std::tuple<double, double, double, double, std::string>;
RowType row;
使用元组的原因是我们可以用元编程技术轻松迭代它。然后,我们定义两个辅助函数。一个是用于从文件中读取一行,它使用io::CSVReader类的read_row()方法。read_row()方法接受不同类型的不同数量的参数。我们的RowType类型描述了这些值。我们通过使用std::index_sequence类型和std::get函数来实现自动参数填充,如下面的代码片段所示:
template <std::size_t... Idx, typename T, typename R>
bool read_row_help(std::index_sequence<Idx...>, T& row, R& r) {
return r.read_row(std::get<Idx>(row)...);
}
第二个辅助函数使用类似的技术将行元组对象转换为我们的值向量,如下所示:
template <std::size_t... Idx, typename T>
void fill_values(std::index_sequence<Idx...>,
T& row,
std::vector<double>& data) {
data.insert(data.end(), {std::get<Idx>(row)...});
}
现在,我们可以将所有部分组合在一起。我们定义一个循环,其中我们连续读取行值并将它们移动到我们的容器中。读取一行后,我们检查read_row()方法的返回值,它告诉我们读取是否成功。一个false的返回值意味着我们已经到达了文件的末尾。在解析错误的情况下,我们捕获来自io::error命名空间的异常。有不同解析失败类型的异常。在以下示例中,我们处理数字解析错误:
try {
bool done = false;
while (!done) {
done = !read_row_help(
std::make_index_sequence<
std::tuple_size<RowType>::value>{},
row, csv_reader);
if (!done) {
categorical_column.push_back(std::get<4>(row));
fill_values(
std::make_index_sequence<columns_num - 1>{},
row, values);
}
}
}
} catch (const io::error::no_digit& err) {
// Ignore badly formatted samples
std::cerr << err.what() << std::endl;
}
此外,请注意,我们只将四个值移动到我们的双精度浮点数向量中,因为最后一列包含字符串对象,我们将它们放入另一个分类值向量中。
在这个代码示例中,我们看到了如何将包含字符串和数值的特殊数据集解析到两个容器中:std::vector<std::string> categorical_column 和 std::vector<double> values。
预处理 CSV 文件
有时候,我们拥有的数据格式与我们要使用的库不兼容。例如,Iris 数据集文件包含一个包含字符串的列。许多机器学习库无法读取这样的值,因为它们假设 CSV 文件只包含可以直接加载到内部矩阵表示的数值。
因此,在使用此类数据集之前,我们需要对它们进行预处理。在 Iris 数据集的情况下,我们需要将包含字符串标签的categorical列替换为数值编码。在下面的代码示例中,我们用不同的数字替换字符串,但一般来说,这种方法不是一个好主意,尤其是在分类任务中。机器学习算法通常只学习数值关系,因此一个更合适的方法是使用专门的编码——例如,独热编码。独热编码是机器学习中用来表示分类数据为数值的方法。它涉及为分类特征中的每个唯一值创建一个二进制向量,其中向量中只有一个元素设置为1,其余都设置为0。代码可以在下面的代码块中看到:
#include <fstream>
#include <regex>
...
std::ifstream data_stream("iris.data");
std::string data_string(
(std::istreambuf_iterator<char>(data_stream)),
std::istreambuf_iterator<char>()
);
data_string = std::regex_replace(data_string,
std::regex("Irissetosa"),
"1");
data_string = std::regex_replace(data_string,
std::regex("Irisversicolor"),
"2");
data_string = std::regex_replace(data_string,
std::regex("Irisvirginica"),
"3");
std::ofstream out_stream("iris_fix.csv");
out_stream << data_string;
我们使用std::ifstream对象将 CSV 文件内容读取到std::string对象中。此外,我们使用std::regex例程将字符串类名替换为数字。使用regex函数可以减少代码量,并使其与通常使用std::string::find()和std::string::replace()方法的循环方法相比更具表达性。在替换文件中的所有分类类名后,我们使用std::ofstream对象创建一个新的文件。
使用 mlpack 库读取 CSV 文件
许多机器学习框架已经具有将 CSV 文件格式读取到其内部表示的例程。在以下代码示例中,我们展示了如何使用mlpack库将 CSV 文件加载到matrix对象中。该库中的 CSV 解析器可以自动为非数值值创建数值映射,因此我们可以轻松地加载 Iris 数据集而无需额外的预处理。
要使用mlpack库读取 CSV 文件,我们必须包含相应的头文件,如下所示:
#include <mlpack/core.hpp>
using namespace mlpack;
我们可以使用data::Load函数从文件中加载 CSV 数据,如下面的代码片段所示:
arma::mat dataset;
data::DatasetInfo info;
data::Load(file_name,
dataset,
info,
/*fail with error*/ true);
注意,data::Load函数接受要加载数据的dataset矩阵对象和DatasetInfo类型的info对象,该对象可以用来获取有关加载文件的附加信息。此外,最后一个布尔参数true用于在加载错误情况下使函数抛出异常。例如,我们可以获取列数和用于非数值值的可用映射,如下所示:
std::cout << "Number of dimensions: " << info.Dimensionality()
<< std::endl;
std::cout << "Number of classes: " << info.NumMappings(4)
<< std::endl;
由于数据是按原样加载的,因此没有关于数据集结构的自动假设。因此,为了提取标签,我们需要手动将加载的矩阵对象划分为以下形式:
arma::Row<size_t> labels;
labels = arma::conv_to<arma::Row<size_t>>::from(
dataset.row(dataset.n_rows - 1));
dataset.shed_row(dataset.n_rows – 1);
我们使用arma::conv_to函数从数据集行创建独立的arma::Row对象。然后,我们使用shed_row方法从数据集中删除最后一行。
使用 Dlib 库读取 CSV 文件
Dlib库可以直接将 CSV 文件加载到其矩阵类型中,就像mlpack库一样。为此操作,我们可以使用简单的 C++流操作符和标准的std::ifstream对象。
作为第一步,我们进行必要的include语句,如下所示:
include <Dlib/matrix.h>
using namespace Dlib;
然后,我们定义一个matrix对象并从文件中加载数据,如下所示:
matrix<double> data;
std::ifstream file("iris_fix.csv");
file >> data;
std::cout << data << std::endl;
在Dlib库中,matrix对象用于直接训练机器学习算法,无需将它们转换为中间数据集类型。
使用 nlohmann-json 库读取 JSON 文件
一些数据集附带结构化注释,可以包含多个文件和文件夹。这类复杂数据集的一个例子是上下文中的常见物体(COCO)数据集。该数据集包含一个文本文件,用于描述物体及其结构部分之间的关系。这个广为人知的数据集被用于训练分割、物体检测和分类任务的模型。该数据集中的注释定义在 JSON 文件格式中。JSON 是一种广泛使用的用于对象(实体)表示的文件格式。
它只是一个带有特殊符号来描述物体及其部分的文本文件。在下面的代码示例中,我们将展示如何使用 nlohmann-json 库来处理这种文件格式。这个库提供了一个简单直观的接口来处理 JSON,使得在 JSON 字符串和 C++ 数据结构(如映射、向量和自定义类)之间进行转换变得容易。它还支持各种功能,如自动类型转换、格式化打印和错误处理。然而,我们将使用一个更直接的、定义论文评论的数据集。这个数据集的作者是 Keith, B.、Fuentes, E. 和 Meneses, C.,他们为他们的工作《应用于论文评论的混合方法情感分析》(2017)创建了此数据集。
下面的示例展示了基于 JSON 的数据集的一部分:
{
"paper": [
{
"id": 1,
"preliminary_decision": "accept",
"review": [
{
"confidence": "4",
"evaluation": "1",
"id": 1,
"lan": "es",
"orientation": "0",
"remarks": "",
"text" : "- El artículo aborda un problema contingente\n
y muy relevante, e incluye tanto un diagnóstico\n
nacional de uso de buenas prácticas como una solución\n
(buenas prácticas concretas)... ",
"timespan": "2010-07-05"
},
{
"confidence": "4",
"evaluation": "1",
"id": 2,
"lan": "es",
"orientation": "1",
"remarks": "",
"text" : "El artículo presenta recomendaciones\n
prácticas para el desarrollo de software seguro... ",
"timespan": "2010-07-05"
},
{
"confidence": "5",
"evaluation": "1",
"id": 3,
"lan": "es",
"orientation": "1",
"remarks": "",
"text" : "- El tema es muy interesante y puede ser de\n
mucha ayuda una guía para incorporar prácticas de\n
seguridad... ",
"timespan": "2010-07-05"
}
]
}
]
}
解析和处理 JSON 文件主要有两种方法,如下列出:
-
第一种方法假设一次性解析整个文件并创建一个文档对象模型(DOM)。DOM 是一个表示存储在文件中的实体的分层对象结构。它通常存储在计算机内存中,在处理大文件的情况下,它可能占用大量的内存。
-
另一种方法是连续解析文件,并为用户提供一个应用程序编程接口(API)来处理和每个与文件解析过程相关的事件。这种第二种方法通常被称为简单 XML API(SAX)。尽管它的名字叫 SAX,但它是一种通用的方法,也用于非 XML 数据。SAX 在解析大型 XML 文件时比 DOM 快,因为它不会在内存中构建整个文档的完整树形表示。然而,对于需要访问文档特定部分进行复杂操作的情况,它可能更难以使用。
使用 DOM 处理训练数据集通常需要为对机器学习算法无用的结构分配大量内存。因此,在许多情况下,使用 SAX 接口更为可取。SAX 允许我们过滤无关数据并初始化可以直接在我们的算法中使用的结构。在下面的代码示例中,我们使用这种方法。
作为初步步骤,我们定义了 paper/review 实体的类型,如下所示:
...
struct Paper {
uint32_t id{0};
std::string preliminary_decision;
std::vector<Review> reviews;
};
using Papers = std::vector<Paper>;
...
struct Review {
std::string confidence;
std::string evaluation;
uint32_t id{0};
std::string language;
std::string orientation;
std::string remarks;
std::string text;
std::string timespan;
};
然后,我们声明一个用于对象的类型,该类型将由解析器用于处理解析事件。这个类型应该从nlohmann::json::json_sax_t基类继承,并且我们需要重写解析器在特定解析事件发生时将调用的虚拟处理函数,如下面的代码块所示:
#include <nlohmann/json.hpp>
using json = nlohmann::json;
...
struct ReviewsHandler
: public json::json_sax_t {
ReviewsHandler(Papers* papers) : papers_(papers) {}
bool null() override;
bool boolean(bool) override;
bool number_integer(number_integer_t) override;
bool number_float(number_float_t, const string_t&) override;
bool binary(json::binary_t&) override;
bool parse_error(std::size_t, const std::string&,
const json::exception& ex) override;
bool number_unsigned(number_unsigned_t u) override;
bool string(string_t& str) override ;
bool key(string_t& str) override;
bool start_object(std::size_t) override;
bool end_object() override;
bool start_array(std::size_t)override;
bool end_array() override;
Paper paper_;
Review review_;
std::string key_;
Papers* papers_{nullptr};
HandlerState state_{HandlerState::None};
};
我们必须重写所有方法,但我们可以只为对象、数组解析事件和解析无符号int/string值的解析事件提供实际的处理函数实现。其他方法可以有简单的实现,如下所示:
bool number_float(number_float_t, const string_t&) override {
return true;
}
现在,我们可以使用nlohmann::json::sax_parse方法加载 JSON 文件;这个方法接受std::istream对象和一个handler对象作为第二个参数。下面的代码块显示了如何使用它:
std::ifstream file(filename);
if (file) {
// define papers data container to be filled
Papers papers;
// define object with SAX handlers
ReviewsHandler handler(&papers);
// parse file
bool result = json::sax_parse(file, &handler);
// check parsing result
if (!result) {
throw std::runtime_error(handler.error_);
}
return papers;
} else {
throw std::invalid_argument("File can't be opened " + filename);
}
当没有解析错误时,我们将有一个初始化的Paper类型对象数组。更精确地考虑,事件处理器的实现细节。我们的事件处理器作为一个状态机工作。在一个状态下,我们用Review对象填充它,在另一个状态下,我们用Paper对象填充,还有其他事件的状态,如下面的代码片段所示:
enum class HandlerState {
None,
Global,
PapersArray,
Paper,
ReviewArray,
Review
};
我们只为Paper和Review对象的Id属性解析无符号unit值,并根据当前状态和之前解析的键更新这些值,如下所示:
bool number_unsigned(number_unsigned_t u) override {
bool res{true};
try {
if (state_ == HandlerState::Paper && key_ == "id") {
paper_.id = u;
} else if (state_ == HandlerState::Review && key_ == "id") {
review_.id = u;
} else {
res = false;
}
} catch (...) {
res = false;
}
key_.clear();
return res;
}
字符串值也存在于这两种类型的对象中,所以我们进行相同的检查来更新相应的值,如下所示:
bool string(string_t& str) override {
bool res{true};
try {
if (state_ == HandlerState::Paper &&
key_ == "preliminary_decision") {
paper_.preliminary_decision = str;
} else if (state_ == HandlerState::Review &&
key_ == "confidence") {
review_.confidence = str;
} else if (state_ == HandlerState::Review &&
key_ == "evaluation") {
review_.evaluation = str;
} else if (state_ == HandlerState::Review &&
key_ == "lan") {
review_.language = str;
} else if (state_ == HandlerState::Review &&
key_ == "orientation") {
review_.orientation = str;
} else if (state_ == HandlerState::Review &&
key_ == "remarks") {
review_.remarks = str;
} else if (state_ == HandlerState::Review &&
key_ == "text") {
review_.text = str;
} else if (state_ == HandlerState::Review &&
key_ == "timespan") {
review_.timespan = str;
} else {
res = false;
}
} catch (...) {
res = false;
}
key_.clear();
return res;
}
JSON key属性的事件处理器将key值存储到适当的变量中,我们使用这个变量在解析过程中识别当前对象,如下所示:
bool key(string_t& str) override {
key_ = str;
return true;
}
start_object事件处理器根据当前的key值和之前的state值切换状态。我们的当前实现基于对当前 JSON 文件结构的了解:没有Paper对象的数组,每个Paper对象包含一个评论数组。这是 SAX 接口的一个限制——我们需要知道文档的结构才能正确实现所有事件处理器。代码可以在下面的代码块中看到:
bool start_object(std::size_t) override {
if (state_ == HandlerState::None && key_.empty()) {
state_ = HandlerState::Global;
} else if (state_ == HandlerState::PapersArray && key_.empty()) {
state_ = HandlerState::Paper;
} else if (state_ == HandlerState::ReviewArray && key_.empty()) {
state_ = HandlerState::Review;
} else {
return false;
}
return true;
}
在end_object事件处理器中,我们根据当前状态填充Paper和Review对象的数组。同时,我们通过运行以下代码将当前状态切换回上一个状态:
bool end_object() override {
if (state_ == HandlerState::Global) {
state_ = HandlerState::None;
} else if (state_ == HandlerState::Paper) {
state_ = HandlerState::PapersArray;
papers_->push_back(paper_);
paper_ = Paper();
} else if (state_ == HandlerState::Review) {
state_ = HandlerState::ReviewArray;
paper_.reviews.push_back(review_);
} else {
return false;
}
return true;
}
在start_array事件处理器中,我们通过运行以下代码根据当前的state值切换当前状态到新的一个:
bool start_array(std::size_t) override {
if (state_ == HandlerState::Global && key_ == "paper") {
state_ = HandlerState::PapersArray;
key_.clear();
} else if (state_ == HandlerState::Paper && key_ == "review") {
state_ = HandlerState::ReviewArray;
key_.clear();
} else {
return false;
}
return true;
}
在end_array事件处理器中,我们通过运行以下代码根据我们对文档结构的了解将当前状态切换回上一个状态:
bool end_array() override {
if (state_ == HandlerState::ReviewArray) {
state_ = HandlerState::Paper;
} else if (state_ == HandlerState::PapersArray) {
state_ = HandlerState::Global;
} else {
return false;
}
return true;
}
在这种方法中,关键的事情是在对象处理完毕后清除当前的key值。这有助于我们调试解析错误,并且我们总是有关于当前处理实体的实际信息。
对于小文件,使用 DOM 方法可能更可取,因为它导致代码更少且算法更简洁。
使用 HighFive 库编写和读取 HDF5 文件
HDF5 是一种用于存储数据集和科学值的非常高效的文件格式。HighFive 库为 HDF Group 提供了一个高级 C++ 接口。在这个例子中,我们建议通过将上一节中使用的数据集转换为 HDF5 格式来查看其接口。
HDF5 格式的核心概念是组和数据集。每个组可以包含其他组和不同类型属性。此外,每个组还可以包含一组数据集条目。每个数据集是相同类型值的多维数组,也可以具有不同类型的属性。
让我们从包含所需的头文件开始,如下所示:
#include <highfive/H5DataSet.hpp>
#include <highfive/H5DataSpace.hpp>
#include <highfive/H5File.hpp>
然后,我们必须创建一个 file 对象,我们将在此对象中写入数据集,如下所示:
HighFive::File file(file_name,
HighFive::File::ReadWrite | HighFive::File::Create |
HighFive::File::Truncate);
在我们有一个 file 对象后,我们可以开始创建组。我们定义一个包含所有 paper 对象的论文组,如下所示:
auto papers_group = file.createGroup("papers");
然后,我们遍历一个论文数组(如前节所示),并为每个 paper 对象创建一个组,包含两个属性:数值 id 属性和 preliminary_decision 属性,后者为 string 类型,如下面的代码块所示:
for (const auto& paper : papers) {
auto paper_group = papers_group.createGroup(
"paper_" + std::to_string(paper.id));
std::vector<uint32_t> id = {paper.id};
auto id_attr = paper_group.createAttribute<uint32_t>(
"id", HighFive::DataSpace::From(id));
id_attr.write(id);
auto dec_attr = paper_group.createAttribute<std::string>(
"preliminary_decision",
HighFive::DataSpace::From(paper.preliminary_decision));
dec_attr.write(paper.preliminary_decision);
}
在我们创建属性后,我们必须使用 write() 方法将其值放入其中。请注意,HighFive::DataSpace::From 函数会自动检测属性值的大小。大小是存储属性值所需的内存量。然后,对于每个 paper_group 对象,我们创建相应的评论组,如下所示:
auto reviews_group = paper_group.createGroup("reviews");
我们在每个 reviews_group 对象中插入一个包含 confidence、evaluation 和 orientation 字段数值的数据集。对于数据集,我们定义 DataSpace(数据集中元素的数量)大小为 3,并将存储类型定义为 32 位整数,如下所示:
std::vector<size_t> dims = {3};
std::vector<int32_t> values(3);
for (const auto& r : paper.reviews) {
auto dataset = reviews_group.createDataSet<int32_t>(
std::to_string(r.id), HighFive::DataSpace(dims));
values[0] = std::stoi(r.confidence);
values[1] = std::stoi(r.evaluation);
values[2] = std::stoi(r.orientation);
dataset.write(values);
}
}
在我们创建并初始化所有对象后,HDF5 格式的 Papers/Reviews 数据集就准备好了。当 file 对象离开作用域时,其析构函数会将所有内容保存到辅助存储中。
拥有 HDF5 格式的文件,我们可以考虑使用 HighFive 库的文件读取接口。
作为第一步,我们再次创建一个 HighFive::File 对象,但带有读取属性,如下所示:
HighFive::File file(file_name, HighFive::File::ReadOnly);
然后,我们使用 getGroup() 方法获取顶级 papers_group 对象,如下所示:
auto papers_group = file.getGroup("papers");
getGroup 方法允许我们通过名称获取一个特定的组,因此它是一种在 HDF5 文件结构中的导航方式。
我们需要获取此组中所有嵌套对象的列表,因为我们只能通过它们的名称来访问对象。我们可以通过运行以下代码来完成此操作:
auto papers_names = papers_group.listObjectNames();
使用循环,我们遍历 papers_group 容器中的所有 papers_group 对象,如下所示:
for (const auto& pname : papers_names) {
auto paper_group = papers_group.getGroup(pname);
...
}
对于每个paper对象,我们读取其属性和属性值所需的内存空间。由于每个属性可以是多维的,因此我们应该注意这一点,并分配一个合适的容器,如下所示:
std::vector<uint32_t> id;
paper_group.getAttribute("id").read(id);
std::cout << id[0];
std::string decision;
paper_group.getAttribute("preliminary_decision").read(decision);
std::cout << " " << decision << std::endl;
对于读取数据集,我们可以使用相同的方法:获取reviews组,然后获取数据集名称列表,最后在循环中读取每个数据集,如下所示:
auto reviews_group = paper_group.getGroup("reviews");
auto reviews_names = reviews_group.listObjectNames();
std::vector<int32_t> values(2);
for (const auto& rname : reviews_names) {
std::cout << "\t review: " << rname << std::endl;
auto dataset = reviews_group.getDataSet(rname);
auto selection = dataset.select({1}, {2});
// or use just dataset.read method to get whole data
selection.read(values);
std::cout << "\t\t evaluation: " << values[0] << std::endl;
std::cout << "\t\t orientation: " << values[1] << std::endl;
}
注意,我们使用select()方法来处理数据集,这允许我们只读取数据集的一部分。我们通过作为参数给出的范围来定义这部分。dataset类型中有一个read()方法,可以一次性读取整个数据集。
使用这些技术,我们可以读取和转换任何 HDF5 数据集。这种文件格式允许我们只处理所需数据的一部分,而不必将整个文件加载到内存中。此外,由于这是一个二进制格式,其读取效率比读取大型文本文件要高。HDF5 的其他有用特性如下:
-
数据集和属性的数据压缩选项,减少存储空间和传输时间。
-
I/O 操作的并行化,允许多个线程或进程同时访问文件。这可以大大提高吞吐量并减少处理时间。
在本节中,我们了解了如何使用各种 C++库提供的 C++数据结构将不同文件格式的数据加载进来。特别是,我们学习了如何填充将在不同机器学习算法中使用到的矩阵和张量对象。在下一节中,我们将看到如何使用常规 C++容器中的值来初始化这些相同的数据结构,这在实现自己的数据加载器时可能很重要。
从 C++数据结构初始化矩阵和张量对象
用于数据集的文件格式有很多种,并且并非所有这些格式都可能被库支持。对于使用不受支持的格式的数据,我们可能需要编写自定义解析器。在将值读取到常规 C++容器之后,我们通常需要将它们转换成我们在使用的机器学习框架中使用的对象类型。作为一个例子,让我们考虑从文件中读取矩阵数据到 C++对象的情况。
使用 Eigen 库
使用Eigen库,我们可以使用Eigen::Map类型将 C++数组包装成Eigen::Matrix对象。包装后的对象将表现得像一个标准的Eigen矩阵。我们必须用具有所需行为的矩阵类型来参数化Eigen::Map类型。此外,当我们创建Eigen::Map对象时,它需要一个指向 C++数组的指针和矩阵维度作为参数,如下面的代码片段所示:
std::vector<double> values;
...
auto x_data = Eigen::Map<Eigen::Matrix<double,
Eigen::Dynamic,
Eigen::Dynamic,
Eigen::RowMajor>>(
values.data(),
rows_num,
columns_num);
使用 Eigen 库
Blaze库有特殊的类,可以用来为 C++数组创建包装器。为了使用这些类的对象来包装一个 C++容器,我们必须传递一个指向数据的指针和相应的维度作为参数,如下面的代码片段所示:
std::array<int, 4> data = {1, 2, 3, 4};
blaze::CustomVector<int,
blaze::unaligned,
blaze::unpadded,
blaze::rowMajor>
v2(data.data(), data.size());
std::vector<float> mdata = {1, 2, 3, 4, 5, 6, 7, 8, 9};
blaze::CustomMatrix<float,
blaze::unaligned,
blaze::unpadded,
blaze::rowMajor>
a2(mdata.data(), 3UL, 3UL);
注意,使用了额外的模板参数来指定内存布局、对齐和填充。
使用 Dlib 库
Dlib库有Dlib::mat()函数,用于将 C++容器包装到Dlib矩阵对象中。它也接受数据指针和矩阵维度作为参数,如下面的代码片段所示:
double data[] = {1, 2, 3, 4, 5, 6};
auto m2 = Dlib::mat(data, 2, 3); // create matrix with size 2x3
Dlib::mat函数有其他重载,可以接受其他类型的容器来创建矩阵。
使用 ArrayFire 库
ArrayFire库有一种初始化数组对象的方法,即使用外部内存指针。它可以如下使用:
float host_data[] = {0, 1, 2, 3, 4, 5};
array A(2, 3, host_data);
在这个例子中,我们使用C数组对象中的数据初始化了2x3矩阵。我们使用了array类型构造函数。前两个参数是矩阵的行数和列数,最后一个参数是数据指针。我们也可以使用 CUDA 指针以相同的方式初始化array类型对象,但第四个参数应该是afDevice指定。
使用 mlpack 库
mlpack框架使用Armadillo库进行线性代数对象。因此,要将 C++容器包装到arma::mat对象中,我们可以使用相应的构造函数,该构造函数接受数据指针和矩阵维度,如下面的代码片段所示:
std::vector<double> values;
...
arma::mat(values.data(), n_rows, n_cols, /*copy_aux_mem*/ false);
如果将名为copy_aux_mem的第四个参数设置为false,则数据将不会复制到矩阵的内部缓冲区中。
注意,所有这些函数只为存储数据的原始 C++数组创建了一个包装器,并没有将值复制到新位置。如果我们想将 C++数组中的值复制到matrix对象中,我们通常需要调用clone()方法或其包装对象的类似方法。
在我们为所使用的机器学习框架创建了一个矩阵对象之后,我们可以初始化其他用于训练机器学习算法的专用对象。此类抽象的例子包括Flashlight库中的fl::TensorDataset类或libtorch库中的torch::data::Dataset类。
在本节中,我们学习了如何使用常规 C++容器和指针初始化矩阵和张量对象。下一节将转向另一个重要主题:图像操作。
使用 OpenCV 和 Dlib 库操作图像
许多机器学习算法与计算机视觉(CV)问题相关。此类任务的例子包括图像中的目标检测、分割、图像分类等。为了能够处理此类任务,我们需要用于处理图像的工具。我们通常需要将图像加载到计算机内存的例程,以及图像处理的例程。例如,标准操作是图像缩放,因为许多机器学习算法仅在特定大小的图像上训练。这种限制源于算法结构或硬件要求。例如,由于图形处理单元(GPU)内存大小有限,我们不能将大图像加载到 GPU 内存中。
此外,硬件要求可能导致我们的硬件支持的数值类型范围有限,因此我们需要将初始图像表示更改为硬件可以高效处理的表示。此外,ML 算法通常假设图像通道的预定义布局,这可能与原始图像文件中的布局不同。
另一种图像处理任务是创建训练数据集。在许多情况下,我们针对特定任务只有有限数量的可用图像。然而,为了使机器算法训练良好,我们通常需要更多的训练图像。因此,典型的做法是对现有图像进行增强。增强可以通过随机缩放、裁剪图像部分、旋转以及其他可以用来从现有集合中生成不同图像的操作来完成。
在本节中,我们将展示如何使用两个最流行的图像处理库来处理 C++。OpenCV 是一个用于解决 CV 问题的框架,其中包含许多现成的 CV 算法实现。此外,它还提供了许多图像处理功能。Dlib 是一个包含大量实现算法的 CV 和 ML 框架,以及丰富的图像处理例程。
使用 OpenCV
在 OpenCV 库中,图像被视为一个多维值矩阵。为此,有一个特殊的 cv::Mat 类型。有两个基本函数:cv::imread() 函数加载图像,cv::imwrite() 函数将图像写入文件,如下面的代码片段所示:
#include <opencv2/opencv.hpp>
..
cv::Mat img = cv::imread(file_name);
cv::imwrite(new_file_name, img);
此外,还有用于管理位于内存缓冲区中的图像的功能。cv::imdecode() 函数从内存缓冲区中加载图像,而 cv::imencode() 函数将图像写入内存缓冲区。
OpenCV 库中的缩放操作可以使用 cv::resize() 函数来完成。此函数接受输入图像、输出图像、输出图像大小或缩放因子以及插值类型作为参数。插值类型决定了缩放后的输出图像将如何显示。以下是一些一般性建议:
-
使用
cv::INTER_AREA进行缩小 -
使用
cv::INTER_CUBIC(较慢)或cv::INTER_LINEAR进行缩放 -
由于速度快,使用
cv::INTER_LINEAR进行所有缩放操作
线性缩放和立方缩放之间的主要区别在于它们缩放像素的方法。线性缩放保持宽高比且更简单,而立方缩放则试图保持细节和过渡。选择哪种缩放取决于您项目的具体要求和期望的结果。
以下代码示例展示了如何缩放图像:
cv::resize(img,
img,
{img.cols / 2, img.rows / 2},
0,
0,
cv::INTER_AREA);
cv::resize(img, img, {}, 1.5, 1.5, cv::INTER_CUBIC);
在 OpenCV 库中没有专门用于图像裁剪的函数,但 cv::Mat 类型重载了 operator() 方法,该方法接受一个裁剪矩形作为参数,并返回一个包含指定矩形周围图像部分的新的 cv::Mat 对象。此外,请注意,此对象将与原始图像共享相同的内存,因此其修改将改变原始图像。要创建 cv::Mat 对象的深度副本,我们需要使用 clone() 方法,如下所示:
img = img(cv::Rect(0, 0, img.cols / 2, img.rows / 2));
有时,我们需要移动或旋转图像。OpenCV 库通过仿射变换支持图像的平移和旋转操作。我们必须手动或使用辅助函数创建一个二维仿射变换矩阵,然后将其应用于我们的图像。对于移动(平移),我们可以手动创建这样的矩阵,然后使用 cv::wrapAffine() 函数将其应用于图像,如下所示:
cv::Mat trm = (cv::Mat_<double>(2, 3) << 1, 0, -50, 0, 1, -50);
cv::wrapAffine(img, img, trm, {img.cols, img.rows});
我们可以使用 cv::getRotationMatrix2D() 函数创建一个旋转矩阵。此函数接受一个原点和一个旋转角度(以度为单位),如下面的代码片段所示:
auto rotm = cv::getRotationMatrix2D({img.cols / 2, img.rows / 2},
45,
1);
cv::wrapAffine(img, img, rotm, {img.cols, img.rows});
另一个有用的操作是不缩放但添加边界来扩展图像大小。OpenCV 库中有 cv::copyMakeBorder() 函数用于此目的。此函数在创建边界方面有不同的选项。它接受输入图像、输出图像、顶部、底部、左侧和右侧的边界大小、边界类型以及边界颜色。边界类型可以是以下之一:
-
BORDER_CONSTANT: 使函数用单一颜色填充边界 -
BORDER_REPLICATE: 使函数在每个边界侧用最后像素值的副本填充边界(例如,aaaaaa|abcdefgh|hhhhhhh) -
BORDER_REFLECT: 使函数在每个边界侧用相反像素值的副本填充边界(例如,fedcba|abcdefgh|hgfedcb) -
BORDER_WRAP: 通过模拟图像重复来填充边界,例如(cdefgh|abcdefgh|abcdefg)
以下示例展示了如何使用此函数:
int top = 50; // px
int bottom = 20; // px
int left = 150; // px
int right = 5; // px
cv::copyMakeBorder(img,img,top,bottom,left,right,
cv::BORDER_CONSTANT | cv::BORDER_ISOLATED,
cv::Scalar(255, 0, 0));
当我们使用这个函数时,应该注意源图像的起点。OpenCV 文档中提到:“如果源图像是大图像的一部分,该函数将尝试使用 ROI(区域兴趣)之外的像素来形成边界。要禁用此功能并始终进行外推,就像源图像不是另一图像的一部分一样,请使用边界 类型 BORDER_ISOLATED。”
之前描述的函数在需要将不同大小的训练图像适应某些机器学习算法中使用的标准图像大小时非常有用,因为使用此函数,我们不会扭曲目标图像内容。
存在 cv::cvtColor() 函数在 OpenCV 库中将不同的颜色空间进行转换。该函数接受一个输入图像、一个输出图像和一个转换方案类型。例如,在下面的代码示例中,我们将 红色、绿色和蓝色 (RGB) 颜色空间转换为灰度:
cv::cvtColor(img, img, cv::COLOR_RGB2GRAY);
// now pixels values are in range 0-1
在某些场景下,这可以非常方便。
使用 Dlib
Dlib 是另一个流行的图像处理库。这个库有不同的函数和类用于数学运算和图像处理。库文档建议使用 Dlib::array2d 类型来表示图像。Dlib::array2d 类型是一个模板类型,必须用像素类型进行参数化。Dlib 库中的像素类型是用像素类型特性定义的。有以下预定义的像素类型:rgb_pixel、bgr_pixel、rgb_alpha_pixel、hsi_pixel、lab_pixel,以及任何标量类型都可以用于灰度像素的表示。
我们可以使用 load_image() 函数从磁盘加载图像,如下所示:
#include <Dlib/image_io.h>
#include <Dlib/image_transforms.h>
using namespace Dlib;
...
array2d<rgb_pixel> img;
load_image(img, file_path);
对于缩放操作,有 Dlib::resize_image() 函数。这个函数有两个不同的重载版本。一个接受一个缩放因子和一个图像对象的引用。另一个接受输入图像、输出图像、期望的大小和插值类型。要在 Dlib 库中指定插值类型,我们应该调用特殊函数:interpolate_nearest_neighbor()、interpolate_quadratic() 和 interpolate_bilinear() 函数。选择其中一个的标准与我们在 使用 OpenCV 部分讨论的标准相同。请注意,resize_image() 函数的输出图像应该已经预先分配,如下面的代码片段所示:
array2d<rgb_pixel> img2(img.nr() / 2, img.nc() / 2);
resize_image(img, img2, interpolate_nearest_neighbor());
resize_image(1.5, img); // default interpolate_bilinear
要使用 Dlib 裁剪图像,我们可以使用 Dlib::extract_image_chips() 函数。这个函数接受一个原始图像、矩形定义的边界和一个输出图像。此外,这个函数还有接受矩形边界数组和输出图像数组的重载版本,如下所示:
extract_image_chip(
img,
rectangle(0, 0, img.nc() / 2, img.nr() / 2),
img2);
Dlib 库通过仿射变换支持图像变换操作。存在一个 Dlib::transform_image() 函数,它接受一个输入图像、一个输出图像和一个仿射变换对象。变换对象的例子可以是 Dlib::point_transform_affine 类的实例,它使用旋转矩阵和变换向量定义仿射变换。此外,Dlib::transform_image() 函数可以接受一个插值类型作为最后一个参数,如下面的代码片段所示:
transform_image(img,img2,interpolate_bilinear(),
point_transform_affine(
identity_matrix<double>(2),
Dlib::vector<double, 2>(-50, -50)));
如果我们只需要旋转,Dlib 有 Dlib::rotate_image() 函数。Dlib::rotate_image() 函数接受一个输入图像、一个输出图像、一个以度为单位的角度和一个插值类型,如下所示:
rotate_image(img, img2, -45, interpolate_bilinear());
在 Dlib 库中没有为添加图像边框的函数的完整对应物。有两个函数:Dlib::assign_border_pixels() 和 Dlib::zero_border_pixels() 用于用指定的值填充图像边框。在使用这些例程之前,我们应该调整图像大小并将内容放置在正确的位置。新的图像大小应包括边框的宽度。我们可以使用 Dlib::transform_image() 函数将图像内容移动到正确的位置。以下代码示例展示了如何向图像添加边框:
int top = 50; // px
int bottom = 20; // px
int left = 150; // px
int right = 5; // px
img2.set_size(img.nr() + top + bottom, img.nc() + left + right);
transform_image(
img, img2, interpolate_bilinear(),
point_transform_affine(
identity_matrix<double>(2),
Dlib::vector<double, 2>(-left/2, -top/2)
)
);
对于颜色空间转换,Dlib 库中存在 Dlib::assign_image() 函数。此函数使用我们从图像定义中使用的像素类型信息。因此,要将图像转换为另一个颜色空间,我们应该定义一个新的图像,具有所需的像素类型,并将其传递给此函数。以下示例展示了如何将 RGB 图像转换为 蓝、绿、红 (BGR)图像:
array2d<bgr_pixel> img_bgr;
assign_image(img_bgr, img);
要创建灰度图像,我们可以定义一个具有 unsigned char 像素类型的图像,如下所示:
array2d<unsigned char> img_gray;
assign_image(img_gray, img);
在本节中,我们学习了如何使用 OpenCV 和 Dlib 库加载和预处理图像。下一步重要的步骤是将图像转换为矩阵或张量结构,以便能够在机器学习算法中使用它们;这将在下一节中描述。
将图像转换为各种库的矩阵或张量对象
在大多数情况下,图像在计算机内存中以交错格式表示,这意味着像素值按顺序逐个放置。每个像素值由表示颜色的几个数字组成。例如,对于 RGB 格式,将会有三个值放在一起。因此,在内存中,我们将看到以下布局,对于一个 4x4 的图像:
rgb rgb rgb rgb
rgb rgb rgb rgb
rgb rgb rgb rgb
rgb rgb rgb rgb
对于图像处理库,这种值布局不是问题,但许多机器学习算法需要不同的顺序。例如,对于 神经网络 (NNs),按顺序单独排列图像通道是一种常见的方法。以下示例展示了这种布局通常如何在内存中放置:
r r r r g g g g b b b b
r r r r g g g g b b b b
r r r r g g g g b b b b
r r r r g g g g b b b b
因此,通常,我们需要在将图像传递给某些机器学习算法之前解交错图像表示。这意味着我们需要将颜色通道提取到单独的向量中。
此外,我们通常还需要转换颜色的值数据类型。例如,OpenCV 库的用户经常使用浮点格式,这使得他们在图像变换和处理程序中能够保留更多的颜色信息。相反的情况是,当我们使用 256 位类型来表示颜色通道信息时,但此时我们需要将其转换为浮点类型。因此,在许多情况下,我们需要将底层数据类型转换为更适合我们需求的另一种类型。
OpenCV 中的解交错
默认情况下,当我们使用 OpenCV 库加载图像时,它以 BGR 格式加载图像,并以 char 作为底层数据类型。因此,我们需要将其转换为 RGB 格式,如下所示:
cv::cvtColor(img, img, cv::COLOR_BGR2RGB);
然后,我们可以将底层数据类型转换为float类型,如下所示:
img.convertTo(img, CV_32FC3, 1/255.0);
接下来,为了解交织通道,我们需要使用cv::split()函数将它们分割,如下所示:
cv::Mat bgr[3];
cv::split(img, bgr);
然后,我们可以使用cv::vconcat()函数将通道按所需顺序放回cv::Mat对象中,如下所示:
cv::Mat ordered_channels;
cv::vconcat(bgr[2], bgr[1], ordered_channels);
cv::vconcat(ordered_channels, bgr[0], ordered_channels);
cv::Mat类型中有一个有用的方法名为isContinuous,它允许我们检查矩阵的数据是否以单个连续块放置在内存中。如果是true,我们可以复制这个内存块或将它传递给处理原始 C 数组的程序。
Dlib 中的解交织
Dlib库使用unsigned char类型来表示像素颜色,我们只能使用浮点类型来处理灰度图像。《Dlib》库以行主序存储像素,通道交错,数据在内存中连续放置在一个块中。Dlib库中没有特殊函数来管理图像通道,因此我们无法解交织它们或混合它们。然而,我们可以使用原始像素数据手动管理颜色值。Dlib库中的两个函数可以帮助我们:image_data()函数用于访问原始像素数据,width_step()函数用于获取填充值。
解交织Dlib图像对象的最直接方法是通过遍历所有像素的循环。在这样的循环中,我们可以将每个像素值分割成单独的颜色。
作为第一步,我们为每个通道定义了容器,如下所示:
auto channel_size = static_cast<size_t>(img.nc() * img.nr());
std::vector<unsigned char> ch1(channel_size);
std::vector<unsigned char> ch2(channel_size);
std::vector<unsigned char> ch3(channel_size);
然后,我们使用两个嵌套循环遍历图像的行和列,读取每个像素的颜色值,如下所示:
size_t i{0};
for (long r = 0; r < img.nr(); ++r) {
for (long c = 0; c < img.nc(); ++c) {
ch1[i] = img[r][c].red;
ch2[i] = img[r][c].green;
ch3[i] = img[r][c].blue;
++i;
}
}
结果是三个包含颜色通道值的容器,我们可以单独使用它们。它们适合用于初始化灰度图像以在图像处理程序中使用。或者,我们可以使用它们来初始化一个矩阵类型的对象,我们可以使用线性代数程序来处理它。
我们看到了如何加载和准备图像以用于线性代数抽象和机器学习算法。在下一节中,我们将学习准备数据以用于机器学习算法的一般方法。这些方法将帮助我们使学习过程更加稳定并更快地收敛。
数据归一化
数据归一化是机器学习中的一个关键预处理步骤。通常,数据归一化是一个将多尺度数据转换为相同尺度的过程。数据集中的特征值可以具有非常不同的尺度——例如,高度可以以厘米为单位给出,数值较小,但收入可以有大数值。这一事实对许多机器学习算法有重大影响。
例如,如果某些特征值与其他特征值相差几倍,那么在基于欧几里得距离的分类算法中,这个特征将主导其他特征。一些算法对输入数据的归一化有强烈的要求;这类算法的例子是支持向量机(SVM)算法。神经网络也通常需要归一化的输入数据。此外,数据归一化对优化算法也有影响。例如,基于梯度下降(GD)方法的优化器如果数据具有相同的尺度,可以更快地收敛。
归一化的方法有多种,但根据我们的观点,最流行的是标准化、最小-最大和均值归一化方法。
标准化是一个使数据具有零均值和标准差等于 1 的过程。标准化向量的公式为
,其中
是一个原始向量,
是使用公式
计算得到的
的平均值,而
是使用公式
计算得到的
的标准差。
[0, 1]。我们可以使用以下公式进行缩放:

当你的数据集中不同特征的比例差异显著时,最小-最大缩放非常有用。它有助于使特征可比较,这对于许多机器学习模型来说非常重要。
[-1, 1],使其均值变为零。我们可以使用以下公式进行均值归一化:

这种转换有助于使数据更容易解释,并通过减少异常值的影响并确保所有特征处于相似尺度上,从而提高某些机器学习算法的性能。考虑我们如何实现这些归一化技术以及哪些机器学习框架函数可以用来计算它们。
我们假设这个矩阵
的每一行是一个训练样本,每一列的值是当前样本的一个特征值。
使用特征值进行归一化
Eigen库中没有数据归一化的函数。然而,我们可以根据提供的公式实现它们。
对于标准化,我们首先必须计算标准差,如下所示:
Eigen::Array<double, 1, Eigen::Dynamic> std_dev =
((x.rowwise() - x.colwise().mean())
.array()
.square()
.colwise()
.sum() /
(x_data.rows() - 1))
.sqrt();
注意,Eigen库中的一些减少函数仅与数组表示形式一起工作;例如,sum()和sqrt()函数。我们还计算了每个特征的均值——我们使用了x.colwise().mean()函数组合,它返回一个mean向量。我们可以用相同的方法来计算其他特征统计值。
在得到标准差值后,标准化公式的其余部分如下所示:
Eigen::Matrix<double, Eigen::Dynamic, Eigen::Dynamic> x_std =
(x.rowwise() - x.colwise().mean()).array().rowwise() / std_dev;
min-max归一化的实现非常直接,不需要中间值,如下面的代码片段所示:
Eigen::Matrix<double, Eigen::Dynamic, Eigen::Dynamic> x_min_max =
(x.rowwise() - x.colwise().minCoeff()).array().rowwise() /
(x.colwise().maxCoeff() - x.colwise().minCoeff()).array();
我们以相同的方式实现均值归一化,如下所示:
Eigen::Matrix<double, Eigen::Dynamic, Eigen::Dynamic> x_avg =
(x.rowwise() - x.colwise().mean()).array().rowwise() /
(x.colwise().maxCoeff() - x.colwise().minCoeff()).array();
注意,我们以向量化方式实现公式,而不使用循环;这种方法在计算上更有效,因为它可以被编译为在 GPU 或中央处理单元(CPU)的****单指令多数据(SIMD)指令上执行。
使用 mlpack 进行归一化
mlpack库中有不同的类用于特征缩放。对我们来说最有趣的是data::data::MinMaxScaler,它实现了最小-最大归一化(或缩放),以及mlpack::data::StandardScaler,它实现了数据标准化。我们可以重用这些类的对象来对具有相同学习统计数据的不同的数据进行缩放。这在我们在一个应用了缩放的数据格式上训练 ML 算法,然后使用该算法对新数据进行预测的情况下可能很有用。为了使此算法按我们的意愿工作,我们必须以与训练过程中相同的方式缩放新数据,如下所示:
#include <mlpack/core.hpp>
...
arma::mat features;
arma::Row<size_t> labels;
data::MinMaxScaler min_max_scaler;
min_max_scaler.Fit(features); // learn statistics
arma::mat scaled_dataset;
min_max_scaler.Transform(features, scaled_dataset);
要学习统计值,我们使用MinMaxScaler类的Fit()方法,而对于特征修改,我们使用Transform()方法。
可以以相同的方式使用StandardScaler类,如下所示:
data::StandardScaler standard_scaler;
standard_scaler.Fit(features);
standard_scaler.Transform(features, scaled_dataset);
要在mlpack库中打印矩阵对象,可以使用以下标准流操作符:
std::cout << scaled_dataset << std::endl;
此外,为了撤销应用缩放,这些类有InverseTransform方法。
使用 Dlib 进行归一化
Dlib库提供了Dlib::vector_normalizer类来提供特征标准化的功能。使用此类的一个限制是,我们无法使用它来处理包含所有训练样本的大矩阵。作为替代,我们应该用单独的向量对象表示每个样本,并将它们放入 C++的std::vector容器中,如下所示:
std::vector<matrix<double>> samples;
...
vector_normalizer<matrix<double>> normalizer;
samples normalizer.train(samples);
samples = normalizer(samples);
我们可以看到,此类对象可以重用,但应该先进行训练。训练方法的实现可能如下所示:
matrix<double> m(mean(mat(samples)));
matrix<double> sd(reciprocal(stddev(mat(samples))));
for (size_t i = 0; i < samples.size(); ++i)
samples[i] = pointwise_multiply(samples[i] - m, sd);
注意,Dlib::mat()函数有不同的重载,用于从不同来源创建矩阵。此外,我们还使用了reciprocal()函数,它将输入矩阵m转换为
矩阵。
在Dlib库中,为了调试目的打印矩阵可以使用简单的流操作符,如下面的代码片段所示:
std::cout << mat(samples) << std::endl;
我们可以看到,Dlib库提供了一个丰富的数据预处理接口,可以轻松使用。
使用 Flashlight 进行归一化
Flashlight库没有特定的类来执行特征缩放。但它有计算基本统计数据的函数,因此我们可以按照以下方式实现特征缩放算法:
fl::Tensor x;
...
// min-max scaling
auto x_min = fl::amin(x, {1});
auto x_max = fl::amax(x, {1});
auto x_min_max = (x - x_min) / (x_max - x_min);
// normalization(z-score)
auto x_mean = fl::mean(x, {1});
auto x_std = fl::std(x, {1});
auto x_norm = (x - x_mean) / x_std;
fl::amin 和 fl::amax 函数用于查找最小值和最大值。fl::mean 和 fl::std 函数分别计算平均值和标准差。所有这些函数都沿着指定的维度进行计算,该维度作为第二个参数。这意味着我们单独对数据集中的每个 x 特征进行缩放。
我们可以使用标准的 C++ 流操作符来打印 fl::Tensor 对象,如下所示:
std::cout << dataset << std::endl;
我们看到,尽管 FlashLight 库没有提供专门用于数据预处理的类,但我们可以使用线性代数例程构建它们。
摘要
在本章中,我们讨论了如何从 CSV、JSON 和 HDF5 格式加载数据。CSV 格式易于读写,使其适用于小型到中型数据集。CSV 文件通常用于表格数据,例如客户信息、销售记录或金融交易。JSON 是一种轻量级的数据交换格式,可读性强且易于解析。它通常用于表示结构化数据,包括对象、数组和键值对。在机器学习中,JSON 可以用于存储用于训练模型的数据,例如特征向量、标签和元数据。HDF5 是一种高性能的文件格式,专为科学数据存储和分析设计。它支持具有复杂结构的大型数据集,允许高效地存储多维数组和表格。HDF5 文件常用于需要高效存储和访问大量数据的应用程序。
我们看到了如何将加载的数据转换为适用于不同机器学习框架的对象。我们使用了库的 API 将原始 C++ 数组转换为矩阵和更高层次的机器学习算法数据集对象。
我们探讨了如何使用 OpenCV 和 Dlib 库加载和处理图像。这些库提供了一系列函数和算法,可用于各种计算机视觉应用。这些库可用于基本的图像预处理,以及更复杂的系统,这些系统使用机器学习来解决行业重要任务,例如人脸检测和识别,这些可以用于构建安全系统、访问控制或面部认证。目标检测可用于图像中的对象计数、产品缺陷检测、特定对象的识别或其运动的跟踪。这在工业自动化、监控系统识别可疑活动以及自动驾驶汽车中非常有用。图像分割允许用户提取图像的特定部分以进行进一步分析。这在医学影像分析中诊断疾病时至关重要。随时间对物体进行运动跟踪也用于体育分析、交通监控和监控。
我们熟悉了数据归一化过程,这对于许多机器学习算法非常重要。我们还看到了机器学习库中可用的归一化技术,并使用 Eigen 库的线性代数函数实现了某些归一化方法。
在下一章中,我们将看到如何测量模型在不同类型数据上的性能。我们将探讨一些特殊技术,这些技术帮助我们理解模型如何很好地描述训练数据集以及它在新数据上的表现。此外,我们还将学习机器学习模型所依赖的不同类型参数,并了解如何选择最佳参数组合以提升模型性能。
进一步阅读
-
HDF5® 库和文件格式:
www.hdfgroup.org/solutions/hdf5/ -
Fast-CPPCSV 解析器的 GitHub 链接:
github.com/ben-strasser/Fast-CPP-CSV-Parser -
OpenCV:opencv.org/ -
DlibC++ 库:Dlib.net/ -
Flashlight文档:fl.readthedocs.io/en/latest/index.html -
nlohmann-json文档:json.nlohmann.me/ -
mlpack文档:mlpack.org/doc/index.html -
应用于论文评论的混合情感分析方法 数据集:
archive.ics.uci.edu/static/public/410/paper+reviews.zip
第三章:测量性能和选择模型
本章描述了偏差和方差效应及其病理情况,这些情况通常出现在训练机器学习(ML)模型时。
在本章中,我们将学习如何通过使用正则化来处理过拟合,并讨论我们可以使用的不同技术。我们还将考虑不同的模型性能估计指标以及它们如何被用来检测训练问题。在本章的末尾,我们将探讨如何通过介绍网格搜索技术和其在 C++ 中的实现来找到模型的最佳超参数。
本章将涵盖以下主题:
-
机器学习模型的性能指标
-
理解偏差和方差特性
-
使用网格搜索技术进行模型选择
技术要求
对于本章,你需要以下内容:
-
支持 C++20 的现代 C++ 编译器
-
CMake 构建系统版本 >= 3.10
-
Dlib库 -
mlpack库 -
Flashlight库 -
Plotcpp库
本章的代码文件可以在以下 GitHub 仓库中找到:github.com/PacktPublishing/Hands-on-Machine-learning-with-C-Second-Edition/tree/main/Chapter03
机器学习模型的性能指标
当我们开发或实施特定的机器学习算法时,我们需要评估其工作效果。换句话说,我们需要评估其解决我们任务的能力。通常,我们使用一些数值指标来估计算法性能。这样的指标可以是针对目标和预测值计算出的均方误差(MSE)的值。我们可以使用这个值来估计我们的预测值与用于训练的目标值之间的距离。性能指标的另一个用途是在优化过程中作为目标函数。一些性能指标用于手动观察,而其他指标也可以用于优化目的。
机器学习算法类型的不同性能指标。在第一章《使用 C++ 的机器学习入门》中,我们讨论了存在两种主要的机器学习算法类别:回归算法和分类算法。机器学习学科中还有其他类型的算法,但这两类是最常见的。本节将介绍回归和分类算法最流行的性能指标。
回归指标
回归任务指标用于衡量预测值与真实值之间的接近程度。这类测量可以帮助我们评估算法的预测质量。在回归指标下,有四个主要指标,我们将在以下小节中深入探讨。
MSE 和 RMSE
MSE 是回归算法广泛使用的指标,用于估计其质量。它是预测值与真实值之间平均平方差的度量。这由以下公式给出:

在这里,
是预测和真实项的数量,
是第i项的真实值,
是第i项的预测值。
MSE 经常用作优化算法的目标损失函数,因为它平滑可微且是凸函数。
均方根误差(RMSE)指标通常用于估计性能,例如当我们需要给更高的误差更大的权重(以惩罚它们)时。我们可以将其解释为预测值与真实值之间差异的标准差。这由以下公式给出:

要使用Dlib库计算 MSE,存在一个mean_squared_error函数,它接受两个浮点向量并返回 MSE。
mlpack库提供了具有静态Evaluate函数的MeanSquaredError类,该函数运行算法预测并计算 MSE。
Flashlight库也有MeanSquaredError类;该类的对象可以用作损失函数,因此具有前向和后向函数。此外,这个库还有一个MSEMeter类,用于测量目标和预测之间的 MSE,可用于性能跟踪。
平均绝对误差
平均绝对误差(MAE)是另一个用于回归算法质量估计的流行指标。MAE 指标是一个具有等权预测误差的线性函数。这意味着它不考虑误差的方向,这在某些情况下可能会出现问题。例如,如果一个模型持续低估或高估真实值,即使模型可能表现不佳,MAE 仍然会给出一个低分。但这个指标比 RMSE 对异常值更稳健。它由以下公式给出:

我们可以使用Flashlight库中的MeanSquaredError类来计算这种类型的误差。MeanSquaredError类实现了损失功能,因此具有前向/后向函数。不幸的是,Dlib和mlpack库中没有专门用于 MAE 计算的函数,但可以使用它们的线性代数后端轻松实现。
R-squared
R-squared 指标也被称为确定系数。它用于衡量我们的独立变量(训练集的特征)如何描述问题并解释因变量的可变性(预测值)。较高的值告诉我们模型足够好地解释了我们的数据,而较低的值告诉我们模型犯了错误。这由以下方程给出:



在这里,
是预测和真实值项的数量,
是第i个项的真实值,而
是第i个项的预测值。
这个指标的唯一问题是添加新的独立变量可能会在某些情况下增加 R-squared,因此考虑这些变量的质量和相关性以及避免过度拟合模型至关重要。这似乎表明模型开始更好地解释数据,但这并不正确——这个值只有在有更多训练项时才会增加。
在Flashlight库中没有现成的函数来计算这个指标;然而,使用线性代数函数实现它很简单。
在Dlib库中有一个用于计算两个std::vector实例匹配元素之间的 R-squared 系数的r_squared函数。
m``lpack库中的R2Score类有一个静态的Evaluate函数,该函数运行指定算法的预测并计算 R-squared 误差。
调整后的 R-squared
调整后的 R-squared 指标旨在解决之前描述的 R-squared 指标的问题。它与 R-squared 指标相同,但为大量独立变量添加了惩罚。主要思想是,如果新的独立变量提高了模型的质量,这个指标的值会增加;否则,它们会减少。这可以由以下方程给出:

这里,k是参数的数量,n是样本的数量。
分类指标
在我们开始讨论分类指标之前,我们必须介绍一个重要的概念,称为混淆矩阵。假设我们有两个类别和一个将这些类别分配给对象的算法。在这里,混淆矩阵将看起来像这样:
![]() |
![]() |
|
|---|---|---|
![]() |
True positive (TP) | False positive (FP) |
![]() |
False negative (FN) | True negative (TN) |
这里,
是对象的预测类别,
是真实标签。混淆矩阵是我们用来计算不同分类指标的抽象。它给出了正确分类和错误分类的项目数量。它还提供了关于错误分类类型的信息。假阴性是我们算法错误地将项目分类为负的项目,而假阳性是我们算法错误地将项目分类为正的项目。在本节中,我们将学习如何使用这个矩阵并计算不同的分类性能指标。
在mlpack库中可以使用ConfusionMatrix函数创建混淆矩阵;此函数仅适用于离散数据/分类数据。
Dlib库也有工具来获取混淆矩阵。有test_multiclass_decision函数,它测试多类决策函数并返回一个描述结果的混淆矩阵。它还有一个test_sequence_labeler类,该类测试labeler对象与给定的样本和标签,并返回一个总结结果的混淆矩阵。
准确率
最明显的分类指标之一是准确率:

这为我们提供了所有正面预测与所有其他预测的比率。通常,这个指标并不很有用,因为它没有显示在类别数量为奇数的情况下的真实情况。让我们考虑一个垃圾邮件分类任务,并假设我们有 10 封垃圾邮件和 100 封非垃圾邮件。我们的算法正确地将 90 封预测为非垃圾邮件,并且只正确分类了 5 封垃圾邮件。在这种情况下,准确率将具有以下值:

然而,如果算法预测所有字母都不是垃圾邮件,那么其准确率应该如下:

这个例子表明,我们的模型仍然不起作用,因为它无法预测所有垃圾邮件,但准确率值已经足够好。
在Flashlight库中计算准确率时,我们使用FrameErrorMeter类,该类可以根据用户设置估计准确率或错误率。
mlpack库有Accuracy类,其中包含静态的Evaluate函数,该函数运行指定算法的分类并计算准确率值。
不幸的是,Dlib库没有计算准确率值的函数,因此如果需要,该函数应该使用线性代数后端实现。
精确度和召回率
为了估计每个分类类的算法质量,我们将介绍两个指标:精确度和召回率。以下图表显示了在分类中使用的所有对象以及它们根据算法结果是如何被标记的:

图 3.1 – 精确度和召回率
中心圆圈包含 选定元素——我们的算法预测为正的元素。
精度与所选项目中正确分类的项目数量成正比,所选项目如下定义:

召回率与所有真实正样本中正确分类的项目数量成正比,定义如下:

召回率的另一个名称是 灵敏度。假设我们感兴趣的是检测正项目——让我们称它们为相关项目。因此,我们使用召回率作为衡量算法检测相关项目能力的指标,使用精度值作为衡量算法区分类别之间差异能力的指标。这些指标不依赖于每个类别中的对象数量,我们可以使用它们进行不平衡数据集分类。
在 Dlib 库中,有几种计算这些指标的方法。有 average_precision 函数,可以直接用来计算精度值。还有 test_ranking_function 函数,它测试给定的排名函数在提供的数据上的表现,并可以返回平均平均精度。另一种方法是使用 test_sequence_segmenter 类,它测试一个 segmenter 对象与给定样本的匹配,并返回精度、召回率和 F1 分数,其中 sequence_segmenter 是一个将对象序列分割成一系列非重叠块的对象。
mlpack 库有两个类——Precision 和 Recall——它们具有静态的 Evaluate 函数,这些函数运行指定算法的分类并相应地计算精度和召回率。
Flashlight 库没有计算这些值的函数。
F 分数
在许多情况下,只使用一个指标来展示分类的质量是有用的。例如,使用一些算法来搜索最佳超参数是有意义的,比如后面章节中将要讨论的网格搜索算法。这类算法通常在搜索过程中应用各种参数值后,使用一个指标来比较不同的分类结果。在这种情况下,最流行的指标之一是 F-measure(或 F 分数),它可以表示如下:

这里,
是精度指标权重。通常,
的值等于 1。在这种情况下,我们有一个乘数值等于 2,如果 精度 = 1 且 召回率 = 1,则给出
。在其他情况下,当精度值或召回率值趋向于零时,F-measure 值也会降低。
Dlib 库仅在 test_sequence_segmenter 类功能范围内提供 F1 分数的计算,该功能测试 segmenter 对象与给定样本,并返回精确度、召回率和 F1 分数。
mlpack 库中有一个名为 F1 的类,它具有 Evaluate 函数,可以通过运行指定算法和数据进行的分类来计算 F1 分数值。
Flashlight 库没有计算 F 分数值的功能。
AUC-ROC
通常,分类算法不会返回具体的类标识符,而是返回一个对象属于某个类的概率。因此,我们通常使用一个阈值来决定一个对象是否属于某个类。最明显的阈值是 0.5,但在数据不平衡的情况下(当我们有一个类有很多值,而另一个类显著较少时),它可能工作不正确。
我们可以使用的一种方法来估计没有实际阈值的模型是 接收者操作特征曲线下面积(AUC-ROC)的值。这条曲线是 真正例率(TPR)和 假正例率(FPR)坐标中的从 (0,0) 到 (1,1) 的线:


TPR 值等于召回率,而 FPR 值与被错误分类的负类对象的数量成比例(它们应该是正的)。在理想情况下,当没有分类错误时,我们有 FPR = 0,TPR = 1,并且 1 下的面积。在随机预测的情况下,ROC 曲线下方的面积将等于 0.5,因为我们会有相等数量的 TP 和 FP 分类:

图 3.2 – ROC
曲线上的每个点都对应某个阈值值。请注意,曲线的陡峭程度是一个重要特征,因为我们希望最小化 FPR,所以我们通常希望这条曲线趋向于点 (0,1)。我们也可以在数据不平衡的数据集中成功使用 AUC-ROC 指标。
Dlib 库中有一个名为 compute_roc_curve 的函数,用于计算给定数据的 ROC 曲线。
不幸的是,Flashlight 和 mlpack 库没有计算 AUC-ROC 指标的函数。
Log-Loss
逻辑损失函数值(Log-Loss)用作优化目标的目标损失函数。它由以下方程给出:

我们可以将 Log-Loss 值理解为经过校正的准确度,但会对错误预测进行惩罚。这个函数对单个错误分类的对象也给予显著的惩罚,因此数据中的所有异常对象都应该单独处理或从数据集中删除。
Dlib 库中有一个 loss_binary_log 类实现了 Log-Loss,这对于二元分类问题来说是合适的。这个类被设计成用作神经网络(NN)模块。
Flashlight 库中的 BinaryCrossEntropy 类可以计算输入张量 x 和目标张量 y 之间的二进制交叉熵损失。此外,这个类的主要目的是实现神经网络训练中的损失函数。
mlpack 库中的 CrossEntropyError 类也代表了神经网络构建中的损失函数;它具有前向和后向函数。因此,它被用来根据输入和目标分布之间的交叉熵来衡量网络的表现。
在本节中,我们学习了性能估计指标,这些指标可以为你提供关于模型准确度、精确度以及其他性能特征的更清晰的认识。在下一节中,我们将学习偏差和方差以及如何估计和修复模型预测特征。
理解偏差和方差特征
偏差和方差特征用于预测模型行为。例如,高方差效应,也称为过拟合,是机器学习中的一种现象,即构建的模型解释了训练集中的示例,但在未参与训练过程的示例上表现相对较差。这是因为当训练模型时,随机模式开始出现,而这些模式通常在一般人群中是缺失的。与过拟合相反的是欠拟合,它对应于高偏差效应。这发生在训练模型变得无法预测新数据或训练数据中的模式时。这种效应可能是有限的训练数据集或弱模型设计的后果。
在我们进一步描述它们的意义之前,我们应该考虑验证。验证是一种用于测试模型性能的技术。它估计模型在新数据上做出预测的好坏。新数据是我们没有用于训练过程的数据。为了进行验证,我们通常将我们的初始数据集分成两到三个部分。其中一部分应该包含大部分数据,并将用于训练,而其他部分将用于验证和测试模型。通常,验证是在一个训练周期(通常称为epoch)之后对迭代算法进行的。或者,我们在整体训练过程之后进行测试。
验证和测试操作评估的是我们从训练过程中排除的数据上的模型,这导致了我们为这个特定模型选择的性能指标值。例如,原始数据集可以分成以下几部分:80%用于训练,10%用于验证,10%用于测试。这些验证指标值可以用来估计模型和预测误差趋势。验证和测试最关键的问题是,它们的数据应该始终来自与训练数据相同的分布。
在本章的剩余部分,我们将使用多项式回归模型来展示不同的预测行为。多项式度数将用作超参数。
偏差
偏差是预测特征,它告诉我们模型预测值与真实值之间的距离。通常,我们使用术语高偏差或欠拟合来说明模型预测值与真实值相差太远,这意味着模型的泛化能力较弱。考虑以下图表:

图 3.3 – 多项式度数为 1 的回归模型预测
此图表显示了原始值、用于验证的值以及代表多项式回归模型输出的线条。在这种情况下,多项式度数等于 1。我们可以看到,预测值根本无法描述原始数据,因此我们可以说这个模型具有高偏差。此外,我们可以绘制每个训练周期的验证指标,以获取更多关于训练过程和模型行为的详细信息。
以下图表显示了多项式回归模型训练过程的 MAE 指标值,其中多项式度数等于 1:

图 3.4 – 训练和验证损失值
我们可以看到,训练数据和验证数据的指标值线条是平行且足够远的。此外,这些线条在多次训练迭代后不会改变方向。这些事实也告诉我们,模型具有高偏差,因为在常规训练过程中,验证指标值应该接近训练值。
为了处理高偏差,我们可以在训练样本中添加更多特征。例如,对于多项式回归模型,增加多项式度数会添加更多特征;这些全新的特征描述了原始训练样本,因为每个额外的多项式项都是基于原始样本值。
方差
方差是预测特征,它告诉我们模型预测的变异性;换句话说,输出值范围可以有多大。通常,当模型试图非常精确地包含许多训练样本时,我们使用术语高方差或过拟合。在这种情况下,模型不能为新数据提供良好的近似,但在训练数据上表现优异。
下图显示了多项式回归模型的行为,多项式次数等于15:

图 3.5 – 多项式次数等于 15 的回归模型预测
我们可以看到,模型几乎包含了所有训练数据。注意,在图表的图例中,训练数据被标记为orig,而用于验证的数据被标记为val。我们可以看到,这两组数据——训练数据和验证数据——在某种程度上是分离的,并且我们的模型由于缺乏近似而错过了验证数据。以下图表显示了学习过程的 MAE 值:

图 3.6 – 验证误差
我们可以看到,在大约 75 次学习迭代后,模型开始更好地预测训练数据,错误值降低。然而,对于验证数据,MAE 值开始上升。为了处理高方差,我们可以使用特殊的正则化技术,我们将在以下章节中讨论。我们还可以增加训练样本的数量并减少一个样本中的特征数量,以降低高方差。
我们在前几段中讨论的性能指标图可以在训练过程的运行时绘制。我们可以使用它们来监控训练过程,以查看高偏差或高方差问题。请注意,对于多项式回归模型,MAE 比 MSE 或 RMSE 是更好的性能特征,因为平方函数过度平均了误差。此外,即使是直线模型,对于此类数据也可以有低 MSE 值,因为线两边的误差相互补偿。MAE 和 MSE 之间的选择取决于特定任务和项目的目标。如果预测的整体准确性很重要,那么 MAE 可能更可取。但 MAE 对所有误差给予相同的权重,无论其大小。这意味着它对异常值不敏感,异常值可能会显著影响整体误差。因此,如果需要最小化大误差,那么 MSE 可以给出更准确的结果。在某些情况下,你可以使用这两个指标来更深入地分析模型的性能。
正常训练
考虑这样一个训练过程,其中模型具有平衡的偏差和方差:

图 3.7 – 模型理想训练时的预测值
在这个图中,我们可以看到多项式回归模型的多项式次数为八。输出值接近训练数据和验证数据。以下图表显示了训练过程中的 MAE 值:

图 3.8 – 模型理想训练时的损失值
我们可以看到 MAE 值持续下降,并且训练数据和验证数据的预测值接近真实值。这意味着模型的超参数足够好,可以平衡偏差和方差。
正则化
正则化是一种用于减少模型过拟合的技术。正则化主要有两种方法。第一种被称为训练数据预处理。第二种是损失函数修改。损失函数修改技术的主要思想是在损失函数中添加惩罚算法结果的项,从而引起显著的方差。训练数据预处理技术的思想是添加更多独特的训练样本。通常,在这种方法中,通过增强现有样本来生成新的训练样本。总的来说,这两种方法都将关于任务域的一些先验知识添加到模型中。这些附加信息有助于我们进行方差正则化。因此,我们可以得出结论,正则化是任何导致最小化泛化误差的技术。
L1 正则化 – Lasso
L1 正则化是损失函数的一个附加项:

这个附加项将参数大小的绝对值作为惩罚。在这里,λ 是正则化系数。这个系数的值越高,正则化越强,可能会导致欠拟合。有时,这种正则化被称为最小绝对收缩和选择算子(Lasso)正则化。L1 正则化的基本思想是惩罚不那么重要的特征。我们可以将其视为一个特征选择过程,因为在优化过程中,一些系数(例如,在线性回归中)变为零,这表明这些特征没有对模型的性能做出贡献。我们最终得到一个具有较少特征的稀疏模型。
L2 正则化 – Ridge
L2 正则化是损失函数的一个附加项:

这个附加项将参数幅度的平方值作为惩罚。这种惩罚将参数幅度缩小到零。λ也是正则化的系数。其较高值会导致更强的正则化,并可能导致欠拟合,因为模型变得过于约束,无法学习数据中的复杂关系。这种正则化类型的另一个名称是岭回归。与 L1 正则化不同,这种类型没有特征选择特性。相反,我们可以将其解释为模型平滑度配置器。此外,对于基于梯度的优化器,L2 正则化在计算上更有效,因为其微分有解析解。
在Dlib库中,正则化机制通常集成到算法实现中——例如,rr_trainer类代表执行线性岭回归的工具,这是一种正则化的最小二乘支持向量机(LSSVM)。
在mlpack库中有一个LRegularizer类,它实现了一种广义 L-正则化器,允许 NN 使用 L1 和 L2 正则化方法。一些算法实现,如LARS类,可以训练 LARS/LASSO/Elastic Net 模型,并具有 L1 和 L2 正则化参数。LinearRegression类有一个岭回归的正则化参数。
在Flashlight库中没有独立的正则化功能。所有正则化都集成到 NN 优化算法实现中。
数据增强
数据增强过程可以被视为正则化,因为它向模型添加了一些关于问题的先验知识。这种方法在计算机视觉(CV)任务中很常见,例如图像分类或目标检测。在这种情况下,当我们看到模型开始过拟合且没有足够的训练数据时,我们可以增强我们已有的图像,以增加数据集的大小并提供更多独特的训练样本。图像增强包括随机图像旋转、裁剪和转换、镜像翻转、缩放和比例变化。但是,数据增强应该谨慎设计,以下是一些原因:
-
如果生成数据与原始数据过于相似,可能会导致过拟合。
-
它可能会向数据集引入噪声或伪影,这可能会降低结果的模型质量。
-
增强的数据可能无法准确反映真实世界数据的分布,导致训练集和测试集之间的领域偏移。这可能导致泛化性能不佳。
提前停止
提前停止训练过程也可以被解释为一种正则化形式。这意味着如果我们检测到模型开始过拟合,我们可以停止训练过程。在这种情况下,一旦训练停止,模型将具有参数。
NN 的正则化
L1 和 L2 正则化在训练神经网络(NNs)时被广泛使用,通常被称为权重衰减。数据增强也在神经网络训练过程中发挥着至关重要的作用。在神经网络中还可以使用其他正则化方法。例如,Dropout 是一种特别为神经网络开发的正则化方法。此算法随机丢弃一些神经网络节点;它使得其他节点对其他节点的权重更加不敏感,这意味着模型变得更加鲁棒,并停止过拟合。
在接下来的章节中,我们将看到如何使用自动化算法与手动调整来选择模型超参数。
使用网格搜索技术进行模型选择
需要有一组合适的超参数值来创建一个好的机器学习(ML)模型。这是因为随机值会导致有争议的结果和从业者未预期的行为。我们可以遵循几种方法来选择最佳的超参数值集。我们可以尝试使用与我们任务相似的已训练算法的超参数。我们还可以尝试找到一些启发式方法并手动调整它们。然而,这项任务可以自动化。网格搜索技术是一种自动化的方法,用于搜索最佳超参数值。它使用交叉验证技术来估计模型性能。
交叉验证
我们已经讨论了验证过程是什么。它用于估计我们尚未用于训练的模型性能数据。如果我们有一个有限的或小的训练数据集,从原始数据集中随机采样验证数据会导致以下问题:
-
原始数据集的大小减小
-
有可能将重要的验证数据留在训练部分
为了解决这些问题,我们可以使用交叉验证方法。其背后的主要思想是以一种方式将原始数据集分割,使得所有数据都将用于训练和验证。然后,对所有分区执行训练和验证过程,并对结果进行平均。
最著名的交叉验证方法是 K-折交叉验证,其中 K 指的是用于分割数据集的折数或分区数。其思想是将数据集划分为相同大小的 K 个块。然后,我们使用其中一个块进行验证,其余的用于训练。我们重复这个过程 K 次,每次选择不同的块进行验证,最后平均所有结果。在整个交叉验证周期中的数据分割方案如下:
-
将数据集划分为相同大小的 K 个块。
-
选择一个块进行验证,其余 K-1 个块用于训练。
-
重复此过程,确保每个块都用于验证,其余的用于训练。
-
对每个迭代中计算的验证集性能指标的结果进行平均。
下图显示了交叉验证周期:

图 3.9 – K 折验证方案
网格搜索
网格搜索方法背后的主要思想是创建一个包含最合理超参数值的网格。该网格用于快速生成一定数量的不同参数集。我们应该对任务领域有一些先验知识,以初始化网格生成的最小和最大值,或者我们可以使用一些合理的广泛范围初始化网格。然而,如果选择的范围太广,搜索参数的过程可能需要很长时间,并且需要大量的计算资源。
在每个步骤中,网格搜索算法选择一组超参数值并训练一个模型。之后,训练步骤算法使用 K 折交叉验证技术来估计模型性能。我们还应该定义一个用于模型比较的单个模型性能估计指标,算法将在每个训练步骤为每个模型计算该指标。在完成每个网格单元中每一组参数的模型训练过程后,算法通过比较指标值并选择最佳值来选择最佳的超参数值集。通常,具有最小值的集合是最好的。
考虑在不同库中实现此算法。我们的任务是选择多项式回归模型的最佳超参数集,以获得最佳曲线,该曲线适合给定的数据。本例中的数据是一些带有随机噪声的余弦函数值。
mlpack 示例
mlpack 库包含一个特殊的 HyperParameterTuner 类,用于在离散和连续空间中使用不同算法进行超参数搜索。默认搜索算法是网格搜索。此类是一个模板,应该针对具体任务进行特殊化。一般定义如下:
template<typename MLAlgorithm, typename Metric,
typename CV,...>
class HyperParameterTuner{…};
我们可以看到,主要模板参数是我们想要找到的算法、性能度量的超参数以及交叉验证算法。让我们定义一个 HyperParameterTuner 对象来搜索线性岭回归算法的最佳正则化值。定义如下:
double validation_size = 0.2;
HyperParameterTuner<LinearRegression,
MSE,
SimpleCV> parameters_tuner(
validation_size, samples, labels);
在这里,LinearRegression 是目标算法类,MSE 是计算 MSE 的性能度量类,而 SimpleCV 是实现交叉验证的类。它将数据分为两个集合,训练集和验证集,然后在训练集上运行训练,并在验证集上评估性能。此外,我们注意到我们将 validation_size 参数传递给构造函数。它的值为 0.2,这意味着使用 80% 的数据进行训练,剩余的 20% 用于使用 MSE 进行评估。接下来的两个构造函数参数是我们的训练数据集;samples 只是样本,而 labels 是相应的标签。
让我们看看如何为这些示例生成训练数据集。它将包括以下两个步骤:
-
生成遵循某些预定义模式的数据——例如,二维正态分布的点,加上一些噪声
-
数据归一化
以下示例展示了如何使用 Armadillo 库生成数据,它是 mlpack 数学后端:
std::pair<arma::mat, arma::rowvec> GenerateData(
size_t num_samples) {
arma::mat samples = arma::randn<arma::mat>(1, num_samples);
arma::rowvec labels = samples + arma::randn<arma::rowvec(
num_samples, arma::distr_param(1.0, 1.5));
return {samples, labels};
}
...
size_t num_samples = 1000;
auto [raw_samples, raw_labels] = GenerateData(num_samples);
注意,对于样本,我们使用了 arma::mat 类型,对于标签,我们使用了 arma::rowvec 类型。因此,样本被放置在矩阵实体中,标签则相应地放入一维向量中。此外,我们还使用了 arma::randn 函数来生成正态分布的数据和噪声。
现在,我们可以按以下方式对数据进行归一化:
data::StandardScaler sample_scaler;
sample_scaler.Fit(raw_samples);
arma::mat samples(1, num_samples);
sample_scaler.Transform(raw_samples, samples);
data::StandardScaler label_scaler;
label_scaler.Fit(raw_labels);
arma::rowvec labels(num_samples);
label_scaler.Transform(raw_labels, labels);
我们使用了来自 mlpack 库的 StandardScaler 类的对象来进行归一化。这个对象应该首先使用 Fit 方法在数据上训练以学习均值和方差,然后它可以使用 Transform 方法应用于其他数据。
现在,让我们讨论如何准备数据和如何定义超参数调优对象。这样,我们就准备好启动网格搜索以找到最佳的规范化值;以下示例展示了如何进行:
arma::vec lambdas{0.0, 0.001, 0.01, 0.1, 1.0};
double best_lambda = 0;
std::tie(best_lambda) = parameters_tuner.Optimize(lambdas);
我们定义了一个包含搜索空间的 lambdas 向量,然后调用了超参数调优对象的 Optimize 方法。你可以看到返回值是一个元组,我们使用 std::tie 函数来提取特定值。Optimize 方法根据我们在搜索中使用的机器学习算法的变量数量接受参数,每个参数将为算法中使用的每个超参数定义一个搜索空间。LinearRegression 类的构造函数只有一个 lambda 参数。搜索完成后,我们可以直接使用搜索到的最佳参数,或者我们可以获取最佳优化的模型对象,如下面的代码片段所示:
LinearRegression& linear_regression = parameters_tuner.BestModel();
我们现在可以尝试在新数据上使用新的模型来查看其效果。首先,我们将生成数据并使用预训练的缩放器对象对其进行归一化,如下面的示例所示:
size_t num_new_samples = 50;
arma::dvec new_samples_values = arma::linspace<
arma::dvec>(x_minmax.first, x_minmax.second, num_new_samples);
arma::mat new_samples(1, num_new_samples);
new_samples.row(0) = arma::trans(new_samples_values);
arma::mat norm_new_samples(1, num_new_samples);
sample_scaler.Transform(new_samples, norm_new_samples);
在这里,我们使用了 arma::linspace 函数来获取线性分布的数据范围。这个函数产生一个向量;我们编写了额外的代码将这个向量转换成矩阵对象。然后,我们使用了已经训练好的 sample_scaler 对象来归一化数据。
以下示例展示了如何使用网格搜索找到的最佳参数来使用模型:
arma::rowvec predictions(num_new_samples);
linear_regression.Predict(norm_new_samples, predictions);
你必须注意的一个重要事项是,用于网格搜索的机器学习算法应该由 SimpleCV 或其他验证类支持。如果没有默认实现,你需要自己提供。
Optuna 与 Flashlight 示例
Flashlight 库中不支持任何超参数调整算法,但我们可以使用一个名为 Optuna 的外部工具来处理我们想要搜索最佳超参数的机器学习程序。主要思想是使用某种 进程间通信(IPC)方法以不同的参数运行训练,并在训练后获取一些性能指标值。
Optuna 是一个超参数调整框架,旨在与不同的机器学习库和程序一起使用。它使用 Python 编程语言实现,因此其主要应用领域是具有一些 Python API 的工具。但 Optuna 还有一个 命令行界面(CLI),可以与不支持 Python 的工具一起使用。另一种使用此类工具的方法是从 Python 中调用它们,传递它们的命令行参数并读取它们的标准输出。本书将展示此类 Optuna 使用示例,因为从作者的角度来看,编写 Python 程序比为相同的自动化任务创建 Bash 脚本更有用。
要使用 Optuna 进行超参数调整,我们需要完成以下三个阶段:
-
定义一个用于优化的目标函数。
-
创建一个研究对象。
-
运行优化过程。
让我们看看如何用 Python 编写一个简单的 Optuna 程序来搜索用 Flashlight 库编写的 C++ 多项式回归算法的最佳参数。
首先,我们需要导入所需的 Python 库:
import optuna
import subprocess
然后,我们需要定义一个目标函数;这是一个由 Optuna 调优算法调用的函数。它接受一个包含一组超参数分布的 Trial 类对象,并应返回一个性能指标值。确切的超参数值应从传递的分布中进行采样。以下示例展示了我们如何实现此类函数:
def objective(trial: optuna.trial.Trial):
lr = trial.suggest_float("learning_rate", low=0.01, high=0.05)
d = trial.suggest_int("polynomial_degree", low=8, high=16)
bs = trial.suggest_int("batch_size", low=16, high=64)
result = subprocess.run(
[binary_path, str(d), str(lr), str(bs)],
stdout=subprocess.PIPE
)
mse = float(result.stdout)
return mse
在此代码中,我们使用 trial 对象中的一系列函数来采样具体的超参数值。我们通过调用 suggest_float 方法采样 lr 学习率,并通过 suggest_int 方法采样 d 多项式度和 bs 批处理大小。您可以看到这些方法的签名几乎相同。它们接受超参数的名称、值范围的低和高界限,并且可以接受一个步长值,我们没有使用。这些方法可以从离散空间和连续空间中采样值。suggest_float 方法从连续空间中采样,而 suggest_int 方法从离散空间中采样。
然后,我们调用了subprocess模块的run方法;它在系统中启动另一个进程。此方法接受命令行参数的字符串数组和一些其他参数——在我们的案例中,是stdout重定向。这种重定向是必要的,因为我们希望将进程的输出作为run方法调用返回值的返回值;你可以在最后几行看到result.stdout,它被转换为浮点值,并被解释为 MSE。
在拥有目标函数的情况下,我们可以定义一个study对象。这个对象告诉 Optuna 如何调整超参数。这个对象的两个主要特性是优化方向和超参数采样算法的搜索空间。以下示例展示了如何为我们的任务定义一个离散的搜索空间:
search_space = {
"learning_rate": [0.01, 0.025, 0.045],],],
"polynomial_degree": [8, 14, 16],
"batch_size": [16, 32, 64],
}
在这段 Python 代码中,我们定义了一个包含三个条目的search_space字典。每个条目都有一个键字符串和数组值。键是learning_rate、polynomial_degree和batch_size。在定义搜索空间之后,我们可以创建一个study对象;以下示例展示了这一点:
study = optuna.create_study(
study_name="PolyFit",
direction="minimize",
sampler=optuna.samplers.GridSampler(search_space),
)
我们使用了optuna模块中的create_study函数,并传递了三个参数:study_name、direction和sampler。我们指定的优化方向将是最小化,因为我们想最小化 MSE。对于sampler对象,我们使用了GridSampler,因为我们想实现网格搜索方法,并且我们用我们的搜索空间初始化了它。
最后一步是应用优化过程以获取最佳超参数。我们可以用以下方式完成:
study.optimize(objective)
print(f"Best value: {study.best_value}
(params: {study.best_params})\n")
我们使用了study对象的optimize方法。你可以看到它只接受一个参数——我们的objective函数,它调用外部进程尝试采样超参数。优化的结果存储在study对象的best_value和best_params字段中。best_value字段包含最佳 MSE 值,而best_params字段包含包含最佳超参数的字典。
这是最小化的 Optuna 使用示例。这个框架有各种各样的调整和采样算法,实际应用可能要复杂得多。此外,使用 Python 可以让我们避免为 CLI 方法编写大量的模板代码。
让我们简要地看看使用 Flashlight 库的多项式回归实现。我将只展示最重要的部分;完整的示例可以在以下链接找到:github.com/PacktPublishing/Hands-on-Machine-learning-with-C-Second-Edition/blob/main/Chapter03/flashlight/grid_fl.cc。
第一个重要部分是,我们的程序应该从命令行参数中获取所有超参数。它可以按以下方式实现:
int main(int argc, char** argv) {
if (argc < 3) {
std::cout << "Usage: " << argv[0] <<
" polynomial_degree learning_rate batch_size" << std::endl;
return 0;
} else {
// Hyper parameters
int polynomial_degree = std::atoi(argv[1]);
double learning_rate = std::atof(argv[2]);
int batch_size = std::atoi(argv[3]);
// Other code...
}
}
首先,我们通过比较argc参数与所需数量来检查我们是否有足够的命令行参数。在失败的情况下,我们打印一条帮助信息。但在成功的情况下,我们从argv参数中读取所有超参数,并将它们从字符串转换为适当的类型。
然后,我们的程序生成 2D 余弦函数点并混合噪声。为了用线性回归近似非线性函数,我们可以将简单的 Ax+b 方法转换为更复杂的多项式,如下所示:
a1*x+a2*x²+a3*x³+...+an*x^n + b
这意味着我们必须选择某个多项式次数,并通过将x元素提升到相应的幂来将单维x值转换为多维值。因此,多项式次数是这个算法中最重要的超参数。
Flashlight 库没有为回归算法提供任何特殊的实现,因为这个库是针对 NN 算法的。但是,可以使用梯度下降方法轻松实现回归;以下代码示例显示了如何实现:
// define learnable variables
auto weight = fl::Variable(fl::rand({polynomial_degree, 1}),
/*calcGrad*/ true);
auto bias = fl::Variable(fl::full({1}, 0.0),
/*calcGrad*/ true);
float mse = 0;
fl::MeanSquaredError mse_func;
for (int e = 1; e <= num_epochs; ++e) {
fl::Tensor error = fl::fromScalar(0);
for (auto& batch : *batch_dataset) {
auto input = fl::Variable(batch[0],
/*calcGrad*/ false);
auto local_batch_size = batch[0].shape().dim(1);
auto predictions = fl::matmul(fl::transpose(
weight), input) + fl::tile(
bias, {1, local_batch_size});
auto targets = fl::Variable(
fl::reshape(batch[1], {1, local_batch_size}),
/*calcGrad*/ false);
// Mean Squared Error Loss
auto loss = mse_func.forward(predictions, targets);
// Compute gradients using backprop
loss.backward();
// Update the weight and bias
weight.tensor() -= learning_rate * weight.grad().tensor();
bias.tensor() -= learning_rate * bias.grad().tensor();
// Clear the gradients for next iteration
weight.zeroGrad();
bias.zeroGrad();
mse_func.zeroGrad();
error += loss.tensor();
}
// Mean Squared Error for the epoch
error /= batch_dataset->size();
mse = error.scalar<float>();
首先,我们为 Flashlight 的autograd系统定义了可学习的变量;它们是X的每个幂的权重和偏置项。然后,我们运行指定数量的 epoch 的循环,以及数据批次的第二个循环,以使计算向量化;这使得计算更有效,并使学习过程对噪声的依赖性降低。对于每个训练数据批次,我们通过获取多项式值来计算预测;请参阅调用matmul函数的行。MeanSquareError类的目的是获取损失函数值。为了计算相应的梯度,请参阅mse_func.forward和mse_func.backward调用。然后,我们使用学习率和相应的梯度更新我们的多项式权重和偏置。
所有这些概念将在以下章节中详细描述。下一个重要部分是error和mse值的计算。error值是包含整个 epoch 平均 MSE 的 Flashlight 张量对象,而mse值是这个单值张量的浮点值。这个mse变量在训练结束时打印到程序的标准化输出流中,如下所示:
std::cout << mse;
我们在我们的 Python 程序中读取这个值,并返回给定超参数集的 Optuna 目标函数的结果。
Dlib 示例
Dlib库也包含网格搜索算法的所有必要功能。然而,我们应该使用函数而不是类。以下代码片段显示了CrossValidationScore函数的定义。这个函数执行交叉验证并返回性能指标值:
auto CrossValidationScore = & {
auto degree = std::floor(degree_in);
using KernelType = Dlib::polynomial_kernel<SampleType>;
Dlib::svr_trainer<KernelType> trainer;
trainer.set_kernel(KernelType(gamma, c, degree));
Dlib::matrix<double> result = Dlib::
cross_validate_regression_trainer(
trainer, samples, raw_labels, 10);
return result(0, 0);
};
CrossValidationScore函数接收作为参数设置的超参数。在这个函数内部,我们使用svr_trainer类定义了一个模型的训练器,该类基于Shogun库的示例实现了基于核的岭回归。
在我们定义了模型之后,我们使用cross_validate_regression_trainer()函数通过交叉验证方法来训练模型。这个函数自动将我们的数据分割成多个折,其最后一个参数是折数。cross_validate_regression_trainer()函数返回一个矩阵,以及不同性能指标的价值。请注意,我们不需要定义它们,因为它们在库的实现中是预定义的。
这个矩阵的第一个值是平均均方误差(MSE)值。我们使用这个值作为函数的结果。然而,对于这个函数应该返回什么值没有强烈的要求;要求是返回值应该是数值型的并且可以比较。另外,请注意,我们将CrossValidationScore函数定义为 lambda 表达式,以简化对在外部作用域中定义的训练数据容器的访问。
接下来,我们可以使用find_min_global函数搜索最佳参数:
auto result = find_min_global(
CrossValidationScore,
{0.01, 1e-8, 5}, // minimum values for gamma, c, and degree
{0.1, 1, 15}, // maximum values for gamma, c, and degree
max_function_calls(50));
这个函数接收交叉验证函数、参数范围的最小值容器、参数范围的最大值容器以及交叉验证重复次数。请注意,参数范围的初始化值应该按照与在CrossValidationScore函数中定义的参数相同的顺序排列。然后,我们可以提取最佳超参数并使用它们来训练我们的模型:
double gamma = result.x(0);
double c = result.x(1);
double degree = result.x(2);
using KernelType = Dlib::polynomial_kernel<SampleType>;
Dlib::svr_trainer<KernelType> trainer;
trainer.set_kernel(KernelType(gamma, c, degree));
auto descision_func = trainer.train(samples, raw_labels)
我们使用了与CrossValidationScore函数中相同的模型定义。对于训练过程,我们使用了所有的训练数据。使用trainer对象的train方法来完成训练过程。训练结果是一个函数,它接受一个单独的样本作为参数并返回一个预测值。
摘要
在本章中,我们讨论了如何估计机器学习模型的性能以及可以使用哪些指标进行此类估计。我们考虑了回归和分类任务的不同指标以及它们的特征。我们还看到了如何使用性能指标来确定模型的行为,并探讨了偏差和方差特征。我们研究了某些高偏差(欠拟合)和高方差(过拟合)问题,并考虑了如何解决这些问题。我们还了解了正则化方法,这些方法通常用于处理过拟合。然后,我们研究了验证是什么以及它在交叉验证技术中的应用。我们看到交叉验证技术允许我们在训练有限数据的同时估计模型性能。在最后一节中,我们将评估指标和交叉验证结合到网格搜索算法中,我们可以使用它来选择模型的最佳超参数集。
在下一章中,我们将学习可以使用来解决具体问题的机器学习算法。我们将深入讨论的下一个主题是聚类——将原始对象集按属性分成组的程序。我们将探讨不同的聚类方法和它们的特性。
进一步阅读
-
选择评估机器学习模型的正确指标——第一部分:
medium.com/usf-msds/choosing-the-right-metric-for-machine-learning-models-part-1-a99d7d7414e4 -
理解回归性能指标:
becominghuman.ai/understand-regression-performance-metrics-bdb0e7fcc1b3 -
分类性能指标:
nlpforhackers.io/classification-performance-metrics/ -
正则化:机器学习中的一个重要概念:
towardsdatascience.com/regularization-for-machine-learning-67c37b132d61 -
深度学习(DL)中正则化技术的概述(带 Python 代码):
www.analyticsvidhya.com/blog/2018/04/fundamentals-deep-learning-regularization-techniques -
理解偏差-方差权衡:
towardsdatascience.com/understanding-the-bias-variance-tradeoff-165e6942b229 -
DL – 过拟合:
towardsdatascience.com/combating-overfitting-in-deep-learning-efb0fdabfccc -
k 折交叉验证的温和介绍:
machinelearningmastery.com/k-fold-cross-validation/
第二部分:机器学习算法
在这部分,我们将向您展示如何使用各种 C++框架实现不同的知名机器学习模型(算法)。
本部分包括以下章节:
-
第四章, 聚类
-
第五章, 异常检测
-
第六章, 降维
-
第七章, 分类
-
第八章, 推荐系统
-
第九章, 集成学习
第四章:聚类
聚类是一种无监督的机器学习方法,用于将原始数据集的对象分割成按属性分类的组。在机器学习中,一个对象通常被表示为多维度量空间中的一个点。每个空间维度对应一个对象属性(特征),度量是一个属性值的函数。根据这个空间中维度的类型,这些维度可以是数值的也可以是分类的,我们选择一种聚类算法和特定的度量函数。这种选择取决于不同对象属性类型的本质。
在当前阶段,聚类通常被用作数据分析的第一步。聚类任务在诸如统计学、模式识别、优化和机器学习等科学领域被提出。在撰写本文时,将对象分组划分成聚类的数量方法相当庞大——几十种算法,甚至更多,当你考虑到它们的各种修改时。
本章将涵盖以下主题:
-
聚类中的距离度量
-
聚类算法的类型
-
使用
mlpack库处理聚类任务样本的示例 -
使用
Dlib库处理聚类任务样本的示例 -
使用 C++ 绘制数据
技术要求
完成本章需要以下技术和安装:
-
支持 C++17 的现代 C++ 编译器
-
CMake 构建系统版本 >= 3.8
-
Dlib库 -
mlpack库 -
plotcpp库
本章的代码文件可以在本书的 GitHub 仓库中找到:github.com/PacktPublishing/Hands-on-Machine-learning-with-C-Second-Edition/tree/main/Chapter04。
聚类中的距离度量
在聚类中,度量或距离度量是必不可少的,因为它决定了对象之间的相似性。然而,在将距离度量应用于对象之前,我们必须制作一个对象特征的向量;通常,这是一个数值集合,如人类身高或体重。此外,一些算法可以处理分类对象特征(或特性)。标准做法是对特征值进行归一化。归一化确保每个特征在距离度量计算中具有相同的影响。在聚类任务范围内可以使用许多距离度量函数。最常用的用于数值属性的函数是欧几里得距离、平方欧几里得距离、曼哈顿距离和切比雪夫距离。以下小节将详细描述它们。
欧几里得距离
欧几里得距离是最常用的距离度量。一般来说,这是一个多维空间中的几何距离。欧几里得距离的公式如下:

平方欧几里得距离
平方欧几里得距离具有与欧几里得距离相同的属性,但相对于较近的值,它赋予较远的值更大的重要性(权重)。以下是平方欧几里得距离的公式:

曼哈顿距离
曼哈顿距离是坐标的平均差异。在大多数情况下,它的值给出的聚类结果与欧几里得距离相同。然而,它降低了远距离值(异常值)的重要性(权重)。以下是曼哈顿距离的公式:

切比雪夫距离
当两个对象仅在坐标中的一个坐标上不同时,切比雪夫距离可能很有用。以下是切比雪夫距离的公式:

下图显示了各种距离之间的差异:

图 4.1 – 不同距离度量之间的差异
在这里,我们可以看到曼哈顿距离是两个维度距离的总和,就像在城市街区中行走一样。欧几里得距离只是直线长度。切比雪夫距离是曼哈顿距离的一个更灵活的替代方案,因为斜对角移动也被考虑在内。
在本节中,我们熟悉了主要的聚类概念,即距离度量。在下一节中,我们将讨论各种类型的聚类算法。
聚类算法的类型
我们可以将不同的聚类类型分类为以下几组:基于划分的、基于谱的、层次化的、基于密度的和基于模型的。基于划分的聚类算法可以逻辑上分为基于距离的方法和基于图论的方法。
在我们介绍不同的聚类算法之前,让我们了解聚类和分类之间的主要区别。这两者之间的主要区别是未定义的目标组集合,该集合由聚类算法确定。目标组(簇)集合是算法的结果。
我们可以将聚类分析分为以下阶段:
-
选择聚类对象
-
确定我们将用于度量的对象属性集合
-
标准化属性值
-
计算度量
-
根据度量值识别不同的对象组
分析聚类结果后,可能需要对所选算法的度量进行一些修正。
我们可以使用聚类来完成各种现实世界的任务,包括以下内容:
-
将新闻分割成几个类别供广告商使用
-
通过市场分析识别客户群体
-
识别用于生物研究的植物和动物群体
-
识别和分类城市规划和管理中的属性
-
检测地震震中簇以确定危险区域
-
对保险政策持有人群体进行分类以进行风险管理
-
对图书馆中的书籍进行分类
-
在数据中寻找隐藏的结构相似性
就这样,让我们深入了解不同的聚类算法类型。
基于划分的聚类算法
基于划分的方法使用相似度度量来将对象组合成组。从业者通常根据对问题的先验知识或启发式方法选择此类算法的相似度度量。有时,需要尝试几种度量与同一算法一起使用,以便选择最佳度量。此外,基于划分的方法通常需要显式指定所需簇的数量或调节输出簇数量的阈值。相似度度量的选择可以显著影响生成的簇的质量和准确性,可能导致对数据模式和洞察力的误解。
基于距离的聚类算法
这类方法中最著名的代表是 k-means 和 k-medoids 算法。它们接受k个输入参数,并将数据空间划分为k个簇,使得一个簇中对象的相似度最大。同时,它们最小化不同簇中对象的相似度。相似度值是对象到簇中心的距离。这些方法之间的主要区别在于簇中心定义的方式。
使用 k-means 算法,相似度与对象到簇质心的距离成正比。簇质心是簇对象在数据空间中坐标的平均值。k-means 算法可以简要描述为以下几步。首先,我们选择k个随机对象,并将每个对象定义为代表簇质心的簇原型。然后,将剩余的对象附加到具有更高相似度的簇上。之后,重新计算每个簇的质心。对于每个获得的划分,计算一个特定的评估函数,其值在每个步骤形成一个收敛序列。这个过程一直持续到指定的序列收敛到其极限值。
换句话说,当簇保持不变时,将物体从一个簇移动到另一个簇的过程就会结束。最小化评估函数可以使生成的簇尽可能紧凑且分离。当簇是彼此显著分离的紧凑“云”时,k-means 方法效果很好。它适用于处理大量数据,但不适用于检测非凸形状的簇或大小差异很大的簇。此外,该方法容易受到噪声和孤立点的影响,因为即使少量这样的点也会显著影响簇质心的计算。
为了减少噪声和孤立点对聚类结果的影响,与 k-means 算法不同,k-medoids 算法使用簇中的一个对象(称为代表对象)作为簇的中心。与 k-means 方法一样,随机选择k个代表对象。每个剩余的对象都与最近的代表对象组合成一个簇。然后,每个代表对象通过迭代地用数据空间中的一个任意非代表对象替换。替换过程继续进行,直到结果簇的质量提高。聚类质量由对象与对应簇的代表对象之间的偏差之和决定,该方法试图最小化这个偏差。因此,迭代继续进行,直到每个簇中的代表对象成为中位数。
中位数是距离簇中心最近的对象。该算法在处理大量数据时扩展性较差,但基于随机搜索的聚类大应用(CLARANS)算法解决了这个问题,它补充了 k-medoids 方法。CLARANS 通过使用随机搜索技术来更有效地找到好的解决方案,试图解决可扩展性问题。这种方法使得能够快速收敛到一个好的解决方案,而无需搜索所有可能的 medoids 组合。对于多维聚类,可以使用投影聚类(PROCLUS)算法。
基于图论的聚类算法
基于图论的算法本质在于将目标对象以图的形式表示。图顶点对应于对象,边权重等于顶点间的距离。图聚类算法的优点在于其卓越的可视性、相对容易的实现以及基于几何考虑的各种改进能力。用于聚类的图论主要概念包括选择连通分量、构建最小生成树和多层图聚类。
选择连通分量的算法基于R输入参数,该算法移除图中距离大于R的所有边。只有最近的成对对象保持连接。算法的目标是找到使图塌缩为几个连通分量的R值。结果形成的分量即为聚类。为了选择R参数,通常构建成对距离分布的直方图。对于具有明确定义聚类数据结构的问题,直方图中将出现两个峰值——一个对应于簇内距离,另一个对应于簇间距离。R参数通常从这两个峰值之间的最小区域中选择。使用距离阈值管理簇的数量可能会很困难。
最小生成树算法在图上构建最小生成树,然后依次移除权重最高的边。以下图表显示了九个对象的最小生成树:

图 4.2 – 扩展树示例
通过移除 C 和 D 之间的连接,长度为 6 个单位(最大距离的边),我们得到两个簇:{A, B, C} 和 {D, E, F, G, H, I}. 通过移除长度为 4 个单位的 EF 边,我们可以将第二个簇进一步划分为两个簇。
多层聚类算法基于在对象(顶点)之间某个距离级别上识别图的连通分量。阈值 C 定义了距离级别——例如,如果对象之间的距离是
,则
。
层次聚类算法生成图 G 的子图序列,这些子图反映了簇之间的层次关系,
,其中以下适用:
-
:在
级别的子图 -
![]()
-
:距离的第 t 个阈值 -
:层次级别的数量 -
,o:当
时,一个空的图边集 -
:当
时,没有距离阈值的对象图
通过改变
距离阈值,其中
,可以控制结果簇的层次深度。因此,多层聚类算法可以创建既平坦又分层的分区数据。
谱聚类算法
谱聚类指的是所有使用图邻接矩阵或由此派生的其他矩阵的特征向量将数据集划分为簇的方法。邻接矩阵描述了一个完全图,其中对象是顶点,每对对象之间的边具有与这些顶点之间相似度对应的权重。谱聚类涉及将初始对象集转换为空间中的一系列点,其坐标是特征向量的元素。此类任务的正式名称是 归一化 切割问题。
然后使用标准方法对得到的点集进行聚类——例如,使用 k 均值算法。改变由特征向量创建的表示,我们可以更清楚地设置原始簇集的性质。因此,谱聚类可以分离 k 均值方法无法分离的点——例如,当 k 均值方法得到一个凸点集时。谱聚类的主要缺点是其立方计算复杂度和二次内存需求。
层次聚类算法
在层次聚类算法中,有两种主要类型:自底向上和基于自顶向下的算法。自顶向下算法基于的原则是,最初,所有对象都放置在一个簇中,然后将其分割成越来越小的簇。自底向上算法比自顶向下算法更常见。它们在工作的开始时将每个对象放置在一个单独的簇中,然后合并簇以形成更大的簇,直到数据集中的所有对象都包含在一个簇中,构建一个嵌套分区系统。这些算法的结果通常以树形形式呈现,称为树状图。此类树的经典例子是生命之树,它描述了动物和植物的分类。
层次聚类方法的主要问题是难以确定停止条件,以便隔离自然簇并防止其过度分裂。层次聚类方法的另一个问题是选择簇的分离或合并点。这个选择至关重要,因为在每个后续步骤中分裂或合并簇之后,该方法将仅对新形成的簇进行操作。因此,在任何步骤中错误地选择合并或分裂点可能导致聚类质量较差。此外,由于决定是否分割或合并簇需要分析大量对象和簇,因此层次聚类方法不能应用于大数据集,这导致该方法具有显著的计算复杂性。
层次聚类方法中用于簇合并的几个度量或连接标准:
-
单连接(最近邻距离):在此方法中,两个簇之间的距离由不同簇中两个最近的对象(最近邻)之间的距离确定。得到的簇倾向于链状连接。
-
完全连接(最远邻居之间的距离):在此方法中,簇之间的距离由不同簇中任意两个对象之间的最大距离(即最远邻居)确定。当对象来自不同的组时,此方法通常工作得非常好。如果簇是细长的或其自然类型是链状,则此方法不适用。
-
未加权成对平均链接法:在此方法中,两个不同簇之间的距离被计算为它们所有对象对之间的平均距离。当对象形成不同的组时,此方法很有用,但在长形(链式)簇的情况下也工作得同样好。
-
加权成对平均链接法:这种方法与未加权的成对平均方法相同,只是在计算中使用了相应簇的大小(包含在其中的对象数量)作为权重因子。因此,当我们假设簇的大小不相等时,应使用此方法。
-
加权质心链接法:在此方法中,两个簇之间的距离定义为它们质心之间的距离。
-
加权质心链接法(中位数):这种方法与前面的一种相同,只是在计算中使用了测量簇大小之间的距离的权重。因此,如果簇的大小存在显著差异,此方法比前一种方法更可取。
下图显示了一个层次聚类的树状图:

图 4.3 – 层次聚类示例
前面的图显示了层次聚类的树状图示例,你可以看到簇的数量如何取决于对象之间的距离。较大的距离会导致簇的数量减少。
基于密度的聚类算法
在基于密度的方法中,簇被认为是多个对象密度高的区域。这是通过具有低密度对象区域来分隔的。
基于密度的空间聚类应用带噪声(DBSCAN)算法是第一个创建的密度聚类算法之一。该算法的基础是几个陈述,详细说明如下:
-
对象的
属性是围绕对象的
半径邻域区域。 -
根对象是
包含最小非零对象数量的对象。假设这个最小数量等于一个预定义的值,称为 MinPts。 -
如果 p 在 q 的
属性中,并且 q 是根对象,则 p 对象可以直接从 q 对象中密集访问。 -
如果存在一个
对象的序列,其中
和
,使得
可以直接从
、
中密集访问,则对于给定的
和 MinPts,p 对象可以从 q 对象中密集访问。 -
对于给定的
和 MinPts,如果存在一个 o 对象,使得 p 和 q 都可以从 o 中密集访问,则 p 对象与 q 对象是密集连接的。
DBSCAN 算法检查每个对象的邻域以寻找聚类。如果 p 对象的
属性包含比 MinPts 更多的点,则创建一个新的聚类,以 p 对象作为根对象。然后 DBSCAN 递归地收集直接从根对象可访问的对象,这可能导致几个密集可访问聚类的合并。当无法向任何聚类添加新对象时,过程结束。
与基于划分的方法不同,DBSCAN 不需要预先指定聚类数量;它只需要
和 MinPts 值,因为这些参数直接影响聚类的结果。这些参数的最佳值很难确定,尤其是在多维数据空间中。此外,此类空间中的分布式数据通常是不对称的,这使得无法使用全局密度参数进行聚类。对于聚类多维数据空间,有基于 DBSCAN 算法的 子空间聚类(SUBCLU)算法。
MeanShift 方法也属于基于密度的聚类算法类别。它是一种非参数算法,将数据集点向一定半径内最高密度区域的中心移动。算法通过迭代进行这样的移动,直到点收敛到密度函数的局部最大值。这些局部最大值也被称为模式,因此该算法有时被称为模式寻找算法。这些局部最大值代表了数据集中的聚类中心。
基于模型的聚类算法
基于模型的算法假设数据空间中存在特定的聚类数学模型,并试图最大化该模型和可用数据的似然性。通常,这使用数学统计学的工具。
期望最大化(EM)算法假设数据集可以使用多维正态分布的线性组合进行建模。其目的是估计最大化似然函数的分布参数,该函数用作模型质量的度量。换句话说,它假设每个聚类中的数据遵循特定的分布定律——即正态分布。基于这个假设,可以确定分布定律的最佳参数——似然函数最大的均值和方差。因此,我们可以假设任何对象属于所有聚类,但概率不同。在这种情况下,任务将是将分布集拟合到数据中,并确定对象属于每个聚类的概率。对象应分配到概率高于其他聚类的聚类。
EM 算法简单易实现。它对孤立对象不敏感,在成功初始化的情况下可以快速收敛。然而,它要求我们指定簇数,这暗示了对数据的先验知识。此外,如果初始化失败,算法可能收敛缓慢,或者我们可能会得到一个质量较差的结果。这类算法不适用于高维空间,因为在这种情况下,假设该空间中数据分布的数学模型是复杂的。
既然我们已经了解了各种聚类算法的类型,让我们看看它们在许多行业中的应用,以将相似的数据点分组到簇中。以下是一些聚类算法如何应用的例子:
-
客户细分:聚类算法可以根据客户的购买历史、人口统计信息和其他属性对客户进行细分。然后,这些信息可以用于定向营销活动、个性化产品推荐和客户服务。
-
图像识别:在计算机视觉领域,聚类算法根据视觉特征(如颜色、纹理和形状)对图像进行分组。这可以用于图像分类、目标检测和场景理解。
-
欺诈检测:在金融领域,聚类算法可以通过根据交易模式中的相似性对交易进行分组来检测可疑交易。这有助于识别潜在的欺诈行为并防止财务损失。
-
推荐系统:在电子商务中,聚类算法根据客户偏好对产品进行分组。这使得推荐系统可以向客户推荐相关产品,从而增加销售额和客户满意度。
-
社交网络分析:在社交媒体中,聚类算法识别具有相似兴趣或行为的用户组。这使得定向广告、内容创作和社区建设成为可能。
-
基因组学:在生物学中,聚类算法分析基因表达数据以识别在特定条件下共同表达的基因组。这有助于理解基因功能和疾病机制。
-
文本挖掘:在自然语言处理中,聚类算法根据文档的内容对文档进行分类。这对于主题建模、文档分类和信息检索很有用。
这些只是聚类算法广泛应用的几个例子。具体的应用案例将取决于行业、数据集和商业目标。
在本节中,我们讨论了各种聚类算法及其用途。在接下来的章节中,我们将学习如何在各种真实世界示例中使用它们,并使用各种 C++库。
使用 mlpack 库处理聚类任务示例
mlpack 库包含基于模型、基于密度和基于划分的聚类方法的实现。基于模型的算法称为高斯混合模型(GMM),基于 EM,而基于划分的算法是 k-means 算法。我们可以使用两种基于密度的算法:DBSCAN 和 MeanShift 聚类。
使用 mlpack 的 GMM 和 EM
GMM 算法假设簇可以拟合到某些高斯(正态)分布;它使用 EM 方法进行训练。有 GMM 和 mlpack 库实现了这种方法,如下面的代码片段所示:
GMM gmm(num_clusters, /*dimensionality*/ 2);
KMeans<> kmeans;
size_t max_iterations = 250;
double tolerance = 1e-10;
EMFit<KMeans<>, NoConstraint> em(max_iterations, tolerance, kmeans);
gmm.Train(inputs, /*trials*/ 3, /*use_existing_model*/ false, em);
注意到 GMM 类的构造函数接受所需的簇数和特征维度性作为参数。在 GMM 对象初始化后,EMFit 类的对象使用最大迭代次数、容差和聚类对象进行初始化。EMFit 中的容差参数控制两个点必须多么相似才能被认为是同一簇的一部分。更高的容差值意味着算法将组合更多的点,导致簇数更少。相反,较低的容差值会导致簇数更多,每个簇中的点更少。聚类对象——在我们的案例中,kmeans——将被算法用于找到高斯拟合的初始质心。然后,我们将训练特征和 EM 对象传递到训练方法中。现在,我们有了训练好的 GMM 模型。在 mlpack 库中,应该使用训练好的 gmm 对象来分类新的特征点,但我们可以用它来显示用于训练的原始数据的簇分配。以下代码片段展示了这些步骤,并绘制了聚类的结果:
arma::Row<size_t> assignments;
gmm.Classify(inputs, assignments);
Clusters plot_clusters;
for (size_t i = 0; i != inputs.n_cols; ++i) {
auto cluser_idx = assignments[i];
plot_clusters[cluser_idx].first.push_back(inputs.at(0, i));
plot_clusters[cluser_idx].second.push_back(inputs.at(1, i));
}
PlotClusters(plot_clusters, "GMM", name + "-gmm.png");
在这里,我们使用了 GMM::Classify() 方法来识别我们的对象属于哪个簇。此方法为每个元素填充了一个簇标识符的行向量,该向量对应于输入数据。得到的簇索引被用于填充 plot_clusters 容器。此容器将簇索引与输入数据坐标映射,用于绘图。它被用作 PlotClusters() 函数的参数,该函数可视化了聚类结果,如下面的图所示:

图 4.4 – mlpack GMM 聚类可视化
在前面的图片中,我们可以看到 GMM 和 EM 算法在不同的人工数据集上是如何工作的。
使用 mlpack 的 K-means 聚类
mlpack 库中的 k-means 算法是在 KMeans 类中实现的。这个类的构造函数接受多个参数,其中最重要的参数是迭代次数和距离度量计算的对象。在下面的示例中,我们将使用默认值,这样构造函数就可以不带参数被调用。一旦我们构建了一个 KMeans 类型的对象,我们将使用 KMeans::Cluster() 方法来运行算法,并为每个输入元素分配一个簇标签,如下所示:
arma::Row<size_t> assignments;
KMeans<> kmeans;
kmeans.Cluster(inputs, num_clusters, assignments);
聚类的结果是带有标签的 assignments 容器对象。请注意,我们通过 Cluster 方法的参数传递了期望的簇数。以下代码示例展示了如何绘制聚类的结果:
Clusters plot_clusters;
for (size_t i = 0; i != inputs.n_cols; ++i) {
auto cluser_idx = assignments[i];
plot_clusters[cluser_idx].first.push_back(inputs.at(0, i));
plot_clusters[cluser_idx].second.push_back(inputs.at(1, i));
}
PlotClusters(plot_clusters, "K-Means", name + "-kmeans.png");
如我们所见,可视化代码与上一个示例相同——它是 mlpack 库中统一聚类 API 的结果。我们收到了与之前转换的数据结构相同的 assignments 容器,这对于我们使用的可视化库是合适的,并调用了 PlotClusters 函数。可视化结果如图所示:

图 4.5 – mlpack K-means 聚类可视化
在前面的图中,我们可以看到 k-means 算法在不同的人工数据集上的工作方式。
使用 mlpack 的 DBSCAN
DBSCAN 类在 mlpack 库中实现了相应的算法。这个类的构造函数接受多个参数,但最重要的两个是 epsilon 和最小点数。在下面的代码片段中,我们创建了这个类的对象:
DBSCAN<> dbscan(/*epsilon*/ 0.1, /*min_points*/ 15);
在这里,epsilon 是范围搜索的半径,而 min_points 是形成簇所需的最小点数。在构建了 BSSCAN 类型的对象之后,我们可以使用 Cluster() 方法来运行算法,并为每个输入元素分配一个簇标签,如下所示:
dbscan.Cluster(inputs, assignments);
聚类的结果是带有标签的 assignments 容器对象。请注意,对于这个算法,我们没有指定期望的簇数,因为算法自己确定了它们。可视化代码与之前的示例相同——我们将 assignments 容器转换为适合我们使用的可视化库的数据结构,并调用 PlotClusters 函数。以下图显示了 DBSCAN 聚类可视化结果:

图 4.6 – mlpack DBSCAN 聚类可视化
在前面的图中,我们可以看到 DBSCAN 算法在不同的人工数据集上的工作方式。与之前算法的主要区别是算法找到了更多的簇。从这一点我们可以看出,它们的质心靠近某些局部密度最大值。
使用 mlpack 进行 MeanShift 聚类
MeanShift类实现了mlpack库中相应的算法。此类的构造函数接受几个参数,其中最重要的是密度区域搜索半径。手动找到此参数的适当值相当棘手。然而,库为我们提供了一个非常有用的方法来自动确定它。在以下代码片段中,我们创建了一个MeanShift类的对象,而没有明确指定半径参数:
MeanShift<> mean_shift;
auto radius = mean_shift.EstimateRadius(inputs);
mean_shift.Radius(radius);
在这里,我们使用了EstimateRadius方法来获取自动半径估计,该估计基于具有适当搜索半径值的MeanShift对象,我们可以使用Cluster()方法运行算法并为每个输入元素分配一个簇标签,如下所示:
arma::Row<size_t> assignments;
arma::mat centroids;
mean_shift.Cluster(inputs, assignments, centroids);
聚类的结果是包含标签和包含簇中心坐标的附加矩阵的assignments容器对象。对于此算法,我们也没有指定簇的数量,因为算法自行确定了它们。可视化代码与前面的示例相同——我们将assignments容器转换为适合我们使用的可视化库的数据结构,并调用PlotClusters函数。以下图显示了MeanShift聚类可视化结果:

图 4.7 – mlpack MeanShift 聚类可视化
前面的图显示了 MeanShift 算法在不同的人工数据集上的工作方式。我们可以看到,结果与 K-means 聚类的结果在某些方面相似,但簇的数量是自动确定的。我们还可以看到,在其中一个数据集中,算法未能得到正确的簇数量,因此我们必须通过搜索半径值进行实验以获得更精确的聚类结果。
使用 Dlib 库处理聚类任务示例
Dlib库提供了 k-means、谱系、层次和另外两种图聚类算法——Newman和Chinese Whispers——作为聚类方法。让我们看看。
使用 Dlib 进行 K-means 聚类
Dlib库使用核函数作为 k-means 算法的距离函数。此类函数的一个例子是径向基函数。作为第一步,我们定义所需的类型,如下所示:
typedef matrix<double, 2, 1> sample_type;
typedef radial_basis_kernel<sample_type> kernel_type;
然后,我们初始化一个 kkmeans 类型的对象。其构造函数接受一个定义聚类质心的对象作为输入参数。我们可以使用 kcentroid 类型的对象来完成这个目的。其构造函数接受三个参数:第一个是定义核(距离函数)的对象,第二个是质心估计的数值精度,第三个是运行时间复杂度的上限(实际上,是 kcentroid 对象允许使用的最大字典向量数),如下面的代码片段所示:
kcentroid<kernel_type> kc(kernel_type(0.1), 0.01, 8);
kkmeans<kernel_type> kmeans(kc);
作为下一步,我们使用 pick_initial_centers() 函数初始化聚类中心。此函数接受聚类数量、中心对象输出容器、训练数据和距离函数对象作为参数,如下所示:
std::vector<sample_type> samples; //training dataset
...
size_t num_clusters = 2;
std::vector<sample_type> initial_centers;
pick_initial_centers(num_clusters,
initial_centers,
samples,
kmeans.get_kernel());
当选择初始中心时,我们可以使用它们来调用 kkmeans::train() 方法以确定精确的聚类,如下所示:
kmeans.set_number_of_centers(num_clusters);
kmeans.train(samples, initial_centers);
for (size_t i = 0; i != samples.size(); i++) {
auto cluster_idx = kmeans(samples[i]);
...
}
我们使用 kmeans 对象作为函数对象在单个数据项上执行聚类。聚类结果将是该项的聚类索引。然后,我们使用聚类索引来可视化最终的聚类结果,如下面的图所示:

图 4.8 – Dlib K-means 聚类可视化
在前面的图中,我们可以看到 Dlib 库中实现的 k-means 聚类算法在不同的人工数据集上的工作方式。
使用 Dlib 进行谱聚类
Dlib 库中的谱聚类算法在 spectral_cluster 函数中实现。它接受距离函数对象、训练数据集和聚类数量作为参数。结果,它返回一个包含聚类索引的容器,其顺序与输入数据相同。在下面的示例中,使用 knn_kernel 类型的对象作为距离函数。你可以在本书提供的示例中找到其实现。这个 knn_kernel 距离函数对象估计给定对象的第一个 KNN 对象。这些对象通过使用 KNN 算法确定,该算法使用欧几里得距离作为距离度量,如下所示:
typedef matrix<double, 2, 1> sample_type;
typedef knn_kernel<sample_type> kernel_type;
...
std::vector<sample_type> samples;
...
std::vector<unsigned long> clusters =
spectral_cluster(kernel_type(samples, 15),
samples,
num_clusters);
spectral_cluster() 函数调用将 clusters 对象填充了聚类索引值,我们可以使用这些值来可视化聚类结果,如下面的图所示:

图 4.9 – Dlib 谱聚类可视化
在前面的图中,我们可以看到 Dlib 库中实现的谱聚类算法在不同的人工数据集上的工作方式。
使用 Dlib 进行层次聚类
Dlib库实现了聚合层次(自下而上)聚类算法。bottom_up_cluster()函数实现了此算法。此函数接受数据集对象之间的距离矩阵、聚类索引容器(作为输出参数)和聚类数量作为输入参数。请注意,它返回的容器中的聚类索引顺序与矩阵中提供的距离顺序相同。
在以下代码示例中,我们已将距离矩阵填充为输入数据集中每对元素之间的成对欧几里得距离:
matrix<double> dists(inputs.nr(), inputs.nr());
for (long r = 0; r < dists.nr(); ++r) {
for (long c = 0; c < dists.nc(); ++c) {
dists(r, c) = length(subm(inputs, r, 0, 1, 2) -
subm(inputs, c, 0, 1, 2));
}
}
std::vector<unsigned long> clusters;
bottom_up_cluster(dists, clusters, num_clusters);
bottom_up_cluster()函数调用将clusters对象填充了聚类索引值,我们可以使用这些值来可视化聚类结果,如图所示:

图 4.10 – Dlib 层次聚类可视化
在前面的图中,我们可以看到Dlib库中实现的层次聚类算法在不同的人工数据集上的工作方式。
基于 Dlib 的 Newman 模块性图聚类算法
该算法的实现基于 M. E. J. Newman 所著的网络中的模块性和社区结构一文。此算法基于网络或图的模块性矩阵,并且它不是基于特定的图论。然而,它确实与谱聚类有某些相似之处,因为它也使用了特征向量。
Dlib库通过newman_cluster()函数实现了此算法,该函数接受一个加权图边的向量,并输出每个顶点的聚类索引容器。加权图边的向量表示网络中节点之间的连接,每条边都有一个表示其强度的权重。这些权重用于确定节点之间的相似性,从而影响聚类过程。使用此算法的初始步骤是定义图边。在以下代码示例中,我们正在创建数据集对象几乎每对之间的边。请注意,我们只使用距离大于阈值的对(这是出于性能考虑)。阈值距离可以根据需要调整以达到不同的聚类结果粒度。
此外,此算法不需要预先知道聚类数量,因为它可以自行确定聚类数量。以下是代码:
for (long i = 0; i < inputs.nr(); ++i) {
for (long j = 0; j < inputs.nr(); ++j) {
auto dist = length(subm(inputs, i, 0, 1, 2) -
subm(inputs, j, 0, 1, 2));
if (dist < 0.5)
edges.push_back(sample_pair(i, j, dist));
}
}
remove_duplicate_edges(edges);
std::vector<unsigned long> clusters;
const auto num_clusters = newman_cluster(edges, clusters);
newman_cluster()函数调用将clusters对象填充了聚类索引值,我们可以使用这些值来可视化聚类结果。请注意,另一种用于计算边权重的方案可能会导致不同的聚类结果。此外,边权重值应根据特定任务进行初始化。边长仅用于演示目的。
结果如下所示:

图 4.11 – Dlib Newman 聚类可视化
在前面的图中,我们可以看到在Dlib库中实现的 Newman 聚类算法在不同的人工数据集上的工作方式。
Chinese Whispers – 基于 Dlib 的图聚类算法
Chinese Whispers 算法是一种用于对加权无向图的节点进行划分的算法。它在 Chris Biemann 撰写的论文《Chinese Whispers – 一种高效的图聚类算法及其在自然语言处理问题中的应用》中进行了描述。此算法也不使用任何独特的图论方法;相反,它使用局部上下文进行聚类的想法,因此它可以被归类为基于密度的方法。
在Dlib库中,此算法通过chinese_whispers()函数实现,该函数接收加权图边的向量,并为每个顶点输出包含聚类索引的容器。出于性能考虑,我们通过距离阈值限制数据集对象之间的边数。加权图边和阈值参数的含义与新曼算法相同。此外,与 Newman 算法一样,此算法也自行确定结果聚类的数量。代码可以在以下代码片段中查看:
std::vector<sample_pair> edges;
for (long i = 0; i < inputs.nr(); ++i) {
for (long j = 0; j < inputs.nr(); ++j) {
auto dist = length(subm(inputs, i, 0, 1, 2) -
subm(inputs, j, 0, 1, 2));
if (dist < 1)
edges.push_back(sample_pair(i, j, dist));
}
}
std::vector<unsigned long> clusters;
const auto num_clusters = chinese_whispers(edges,clusters);
chinese_whispers()函数调用将聚类索引值填充到clusters对象中,我们可以使用这些值来可视化聚类结果。请注意,我们使用1作为边权重的阈值;另一个阈值值可能导致不同的聚类结果。此外,边权重值应根据特定任务进行初始化。边长仅用于演示目的。
结果可以在以下图中查看:

图 4.12 – Dlib Chinese Whispers 聚类可视化
在前面的图中,我们可以看到在Dlib库中实现的 Chinese Whispers 聚类算法在不同的人工数据集上的工作方式。
在本节和前几节中,我们看到了许多显示聚类结果的图像示例。下一节将解释如何使用plotcpp库,这是我们用来绘制这些图像的库。
使用 C++绘制数据
聚类后,我们使用plotcpp库绘制结果,这是一个围绕gnuplot命令行工具的轻量级包装器。使用这个库,我们可以在散点图上绘制点或绘制线条。使用这个库开始绘图的第一步是创建Plot类的对象。然后,我们必须指定绘图的输出目的地。我们可以使用Plot::SetTerminal()方法设置目的地,该方法接受一个包含目的地点缩写的字符串。例如,我们可以使用qt字符串值来显示Plot类方法。然而,它并不涵盖gnuplot所有可能的配置。在需要一些独特选项的情况下,我们可以使用Plot::gnuplotCommand()方法直接进行gnuplot配置。
我们可以遵循两种绘图方法来在一个图表上绘制一组不同的图形:
-
我们可以使用
Draw2D()方法与Points或Lines类的对象一起使用,但在这个情况下,我们应该在编译前指定所有图形配置。 -
我们可以使用
Plot::StartDraw2D()方法获取一个中间绘图状态对象。然后,我们可以使用Plot::AddDrawing()方法将不同的绘图添加到一个图表中。在绘制了最后一张图形后,应该调用Plot::EndDraw2D()方法。
我们可以使用Points类型来绘制点。此类型对象应该使用起始和结束前向迭代器初始化,这些迭代器代表坐标。我们应该指定三个迭代器作为点坐标,两个迭代器用于x坐标,即它们的起始和结束位置,以及一个迭代器用于y坐标的起始位置。容器中的坐标数量应该相同。最后一个参数是gnuplot视觉风格配置。Lines类的对象可以以相同的方式进行配置。
一旦我们完成了所有的绘图操作,我们应该调用Plot::Flush()方法将所有命令渲染到窗口或文件中,如下面的代码块所示:
// Define helper data types for point clusters coordinates
// Container type for single coordinate values
using Coords = std::vector<DataType>;
// Paired x, y coordinate containers
using PointCoords = std::pair<Coords, Coords>;
// Clusters mapping container
using Clusters = std::unordered_map<index_t, PointCoords>;
// define color values container
const std::vector<std::string> colors{
"black", "red", "blue", "green",
"cyan", "yellow", "brown", "magenta"};
...
// Function for clusters visualization
void
PlotClusters(const Clusters& clusters,
const std::string& name,
const std::string& file_name) {
// Instantiate plotting object
plotcpp::Plot plt;
// Configure plotting object
plt.SetTerminal("png");
plt.SetOutput(file_name);
plt.SetTitle(name);
plt.SetXLabel("x");
plt.SetYLabel("y");
plt.SetAutoscale();
plt.gnuplotCommand("set grid");
// Start 2D scatter plot drawing
auto draw_state =
plt.StartDraw2D<Coords::const_iterator>();
for (auto& cluster : clusters) {
std::stringstream params;
// Configure cluster visualization color string
params << "lc rgb '" << colors[cluster.first]
<< "' pt 7";
// Create cluster name string
auto cluster_name =
std::to_string(cluster.first) + " cls";
// Create points visualization object using "cluster"
// points
plotcpp::Points points(cluster.second.first.begin(),
cluster.second.first.end(),
cluster.second.second.begin(),
cluster_name, params.str());
// Add current cluster visualization to the 2D scatter
// plot
plt.AddDrawing(draw_state, points);
}
// Finalize 2D scatter plot
plt.EndDraw2D(draw_state);
// Render the plot
plt.Flush();
}
在这个例子中,我们学习了如何使用plotcpp库绘制我们的聚类结果。我们必须能够配置不同的可视化参数,例如图表类型、点颜色和轴名称,因为这些参数使我们的图表更具信息量。我们还学习了如何将此图表保存到文件中,以便我们以后可以使用它或将它插入到另一个文档中。这个库将在整本书中用于可视化结果。
摘要
在本章中,我们考虑了聚类是什么以及它与分类有何不同。我们探讨了不同的聚类方法,例如基于划分、频谱、层次、基于密度和基于模型的方法。我们还观察到基于划分的方法可以分为更多类别,例如基于距离的方法和基于图论的方法。
然后,我们使用了这些算法的实现,包括 k-means 算法(基于距离的方法)、GMM 算法(基于模型的方法)、基于 Newman 模块性的算法以及中国 whispers 算法,用于图聚类。我们还学习了如何在程序中使用层次聚类和谱聚类算法的实现。我们看到了成功聚类的关键问题包括距离度量函数的选择、初始化步骤、分割或合并策略以及关于聚类数量的先验知识。
每个特定算法的组合都是独特的。我们还看到,聚类算法的结果很大程度上取决于数据集的特征,因此我们应该根据这些特征来选择算法。
在本章末尾,我们研究了如何使用plotcpp库可视化聚类结果。
在下一章中,我们将学习什么是数据异常以及存在哪些机器学习算法用于异常检测。我们还将看到异常检测算法如何用于解决现实生活中的问题,以及这些算法的哪些属性在不同任务中扮演着更重要的角色。
进一步阅读
要了解更多关于本章所涉及主题的信息,请查看以下资源:
-
数据科学家需要了解的 5 种聚类算法:
towardsdatascience.com/the-5-clustering-algorithms-data-scientists-need-to-know-a36d136ef68 -
不同类型的聚类 算法:
www.geeksforgeeks.org/different-types-clustering-algorithm/ -
聚类及其不同方法的介绍:
www.analyticsvidhya.com/blog/2016/11/an-introduction-to-clustering-and-different-methods-of-clustering/ -
图论入门书籍:图论 (Graduate Texts in Mathematics),作者为 Adrian Bondy 和 U.S.R. Murty
-
《统计学习的要素:数据挖掘、推理与预测》,作者为 Trevor Hastie、Robert Tibshirani 和 Jerome Friedman,涵盖了机器学习理论和算法的许多方面。
第五章:异常检测
异常检测是我们在一个给定的数据集中寻找意外值的地方。异常是系统行为或数据值与标准或预期值之间的偏差。异常也被称为异常值、错误、偏差和例外。它们可能由于技术故障、事故、故意黑客攻击等原因,出现在性质和结构多样化的数据中。
我们可以使用许多方法和算法来搜索各种类型数据中的异常。这些方法使用不同的方法来解决相同的问题。有无监督、监督和半监督算法。然而,在实践中,无监督方法是最受欢迎的。无监督异常检测技术检测未标记的测试数据集中的异常,假设数据集的大部分是正常的。它是通过寻找不太可能适合其余数据集的数据点来做到这一点的。无监督算法之所以更受欢迎,是因为异常事件的发生频率与正常或预期数据相比显著较低,因此通常很难获得适合异常检测的适当标记的数据集。
从广义上讲,异常检测适用于广泛的领域,如入侵检测、欺诈检测、故障检测、健康监测、事件检测(在传感器网络中)以及环境破坏的检测。通常,异常检测被用作数据准备的前处理步骤,在数据传递给其他算法之前。
因此,在本章中,我们将讨论最流行的无监督异常检测算法及其应用。
本章将涵盖以下主题:
-
探索异常检测的应用
-
异常检测的学习方法
-
使用不同的 C++ 库进行异常检测的示例
技术要求
完成本章示例所需的软件列表如下:
-
Shogun-toolbox库 -
Shark-ML库 -
Dlib库 -
PlotCpp库 -
支持 C++17 的现代 C++ 编译器
-
CMake 构建系统版本 >= 3.8
本章的代码文件可以在以下 GitHub 仓库中找到:github.com/PacktPublishing/Hands-on-Machine-learning-with-C-Second-Edition/tree/main/Chapter05
探索异常检测的应用
数据分析中的两个领域寻找异常:异常值检测和新颖性检测。
一个 新对象 或 新颖性 是一个与训练数据集中的对象在属性上不同的对象。与异常不同,新对象本身不在数据集中,但它可以在系统开始工作后的任何时刻出现。它的任务是检测其出现。例如,如果我们分析现有的温度测量值并识别异常高或低的值,那么我们就是在检测异常。另一方面,如果我们创建一个算法,对于每次新的测量,评估温度与过去值的相似性并识别显著异常的值,那么我们就是在检测新颖性。
异常出现的原因包括数据错误、噪声的存在、误分类的对象,以及来自其他数据集或分布的外来对象。让我们解释两种最不为人知的异常类型:数据错误和来自不同分布的数据。数据错误可以广泛地指代测量不准确、舍入误差和不正确的输入。属于不同分布的对象的例子是来自损坏传感器的测量值。这是因为这些值将属于可能与预期不同的范围。
新颖性通常是由于根本新的对象行为而产生的。例如,如果我们的对象是计算机系统行为描述,那么在病毒侵入计算机并从这些描述中删除一些信息后,它们将被视为新颖性。另一个新颖性的例子可能是一群与其他客户行为不同但与其他客户有相似之处的客户。新颖性对象的主要特征是它们是新的,因为在训练集中不可能有关于所有可能的病毒感染或故障的信息。创建这样的训练数据集是一个复杂的过程,通常也没有意义。然而,幸运的是,我们可以通过关注系统或机制的普通(常规)操作来获得足够大的数据集。
通常,异常检测的任务与分类的任务相似,但有一个本质的区别:类别不平衡。例如,设备故障(异常)比设备正常工作的情况要罕见得多。
我们可以在不同类型的数据中观察到异常。在下面的图中,我们可以看到一个数值序列中异常的例子:

图 5.1 – 数值序列中异常的例子
在下面的图中,我们可以看到图中存在的异常;这些异常可以是边也可以是顶点(见用较浅颜色标记的元素):

图 5.2 – 图中的异常
下面的文本展示了字符序列中的异常:
AABBCCCAABBCCCAACABBBCCCAABB
可以使用,例如受试者工作特征曲线下面积(AUC-ROC)来估计异常检测任务的品质或性能,就像分类任务可以一样。
我们已经讨论了什么是异常,那么让我们看看有哪些方法可以用来检测它们。
异常检测的学习方法
在本节中,我们将探讨我们用于异常检测的最流行和最直接的方法。
使用统计测试检测异常
统计测试通常用于捕捉单个特征的极端值。这类测试的通用名称是极端值分析。这种测试的一个例子是使用 Z 分数度量:

这里,
是数据集的一个样本,µ是数据集中所有样本的平均值,
是数据集中样本的标准差。一个Z分数值告诉我们数据点与平均值有多少个标准差。因此,通过选择适当的阈值值,我们可以过滤掉一些异常值。任何Z分数大于阈值的点将被视为异常值或数据集中的异常值。通常,大于3或小于-3的值被视为异常,但你可以根据你特定的项目需求调整这个阈值。以下图表显示了使用 Z 分数测试可以将哪些来自某种正态分布数据的值视为异常或离群值:

图 5.3 – Z 分数异常检测
我们应该提到的一个重要概念是极端值——给定数据集的最大值和最小值。重要的是要理解,极端值和异常是不同的概念。以下是一个小的数据样本:
[1, 39, 2, 1, 101, 2, 1, 100, 1, 3, 101, 1, 3, 100, 101, 100, 100]
我们可以将39视为异常值,但不是因为它是最大或最小值。重要的是要理解,异常不一定是极端值。
虽然极端值通常不是异常,但在某些情况下,我们可以将极端值分析方法适应于异常检测的需求。然而,这取决于具体任务,并且应该由机器学习(ML)从业者仔细分析。
使用局部离群因子方法检测异常
基于距离测量的方法被广泛用于解决不同的机器学习问题,以及用于异常检测。这些方法假设在对象空间中存在一个特定的度量标准,有助于我们找到异常。当我们使用基于距离的方法进行异常检测时的一般假设是,异常值只有少数邻居,而正常点有许多邻居。因此,例如,到第 k 个邻居的距离可以作为一个很好的异常值度量,正如在局部异常因子(LOF)方法中所反映的那样。这种方法基于估计已检查异常的对象密度。位于最低密度区域的对象被认为是异常值或异常。
与其他方法相比,LOF 方法的优势在于它与对象的局部密度相结合。因此,LOF 可以有效地识别异常值,即使在数据集中存在不同类别的对象,这些对象在训练期间可能不被视为异常。例如,假设从对象 (A) 到其第 k 个最近邻居的距离为 k-distance (A)。请注意,k 个最近邻居的集合包括所有在这个距离内的对象。我们将 k 个最近邻居的集合表示为 Nk(A)。这个距离用于确定可达距离:

如果点 A 位于点 B 的 k 个邻居之间,则 可达距离 将等于点 B 的 k-distance。否则,它将等于点 A 和 B 之间的确切距离,该距离由 dist 函数给出。对象 A 的局部可达密度定义为以下:

局部可达密度是对象 A 从其邻居的平均可达距离的倒数。请注意,这并不是从 A 到邻居的平均可达距离(根据定义,应该是 k-distance(A)),而是 A 可以从其邻居到达的距离。然后比较局部可达密度与邻居的局部可达密度:

提供的公式给出了邻居的平均局部可达密度,除以对象的局部可达密度:
-
大约为
1的值意味着对象可以与其邻居进行比较(因此它不是异常值) -
小于
1的值表示密集区域(对象有许多邻居) -
明显大于
1的值表示异常
这种方法的缺点是结果值难以解释。1或更小的值表示一个点完全是内部的,但没有明确的规则可以确定一个点是否为异常值。在一个数据集中,1.1的值可能表示异常值。然而,在另一个具有不同参数集的数据集中(例如,如果存在具有尖锐局部波动的数据),2的值也可能表示内部对象。这些差异也可能由于方法的局部性而在单个数据集中发生。
使用独立森林检测异常
独立森林(Isolation Forest)这一想法基于算法的anomaly_score值,即构建的树中叶子的深度。以下公式展示了如何计算异常分数:

这里,
是观察值的路径长度,
,
是从一组隔离树中得到的
的平均值,
是在二叉搜索树中搜索失败的平均路径长度,而
是外部节点的数量。
我们假设异常通常出现在深度较低的叶子中,这些叶子靠近根节点,但对于常规对象,树将构建更多几个层级。这种层级的数量与簇的大小成正比。因此,anomaly_score与位于其中的点的数量成正比。
这个假设意味着来自小尺寸簇(可能是异常值)的对象将比来自常规数据簇的对象具有更低的anomaly_score值:

图 5.4 – 独立森林可视化
独立森林方法被广泛使用并在各种库中得到实现。
使用单类支持向量机检测异常
支持向量法是一种基于使用超平面将对象划分为类别的二分类方法。超平面的维度总是选择小于原始空间的维度。例如,在
中,超平面是一个普通的二维平面。超平面到每个类别的距离应尽可能短。最接近分离超平面的向量被称为支持向量。在实践中,可以通过超平面划分的数据——换句话说,线性情况——相当罕见。在这种情况下,训练数据集的所有元素都嵌入到更高维的空间
中,使用特殊的映射。在这种情况下,映射的选择使得在新空间
中,数据集是线性可分的。这种映射基于核函数,通常称为核技巧;它将在第六章中更详细地讨论。
单类支持向量机(OCSVM)是对支持向量方法的一种改进,专注于异常检测。OCSVM 与标准版本的支持向量机(SVM)的不同之处在于,所得到的优化问题包括对确定一小部分预定的异常值的改进,这使得该方法可用于检测异常。这些异常值位于起点和最优分离超平面之间。属于同一类的所有其他数据都落在最优分离超平面的另一侧。
此外,还有一种 OCSVM 方法,它使用的是球形方法,而不是平面(或线性)方法。该算法在特征空间中获取数据周围的球形边界。通过最小化这个超球体的体积来减少在解中包含异常值的影响。
当数据具有球形形状时,例如数据点均匀分布在原点周围时,球形映射是合适的。当数据具有平面形状时,例如数据点位于一条线或平面上时,平面(或线性)映射更合适。此外,还可以使用其他核函数将数据映射到更高维的空间,使其线性可分。核函数的选择取决于数据的性质。
OCSVM 分配一个标签,即测试数据点到最优超平面的距离。OCSVM 输出中的正值表示正常行为(值越高表示正常性越强),而负值表示异常行为(值越低表示异常越显著)。
为了分配标签,OCSVM 首先在一个只包含正常或预期行为的数据集上训练。这个数据集被称为正类。然后 OCSVM 试图找到一个超平面,该超平面最大化正类与原点之间的距离。这个超平面被称为决策边界。
一旦找到决策边界,任何落在这个边界之外的新数据点都被认为是异常或离群值。OCSVM 将这些数据点分配一个异常标签。
密度估计方法
异常检测中最受欢迎的方法之一是密度估计,它涉及估计正常数据的概率分布,然后如果观察值落在预期范围之外,则将其标记为异常。密度估计背后的基本思想是拟合一个模型来表示正常行为的潜在分布。这个模型可以是一个简单的参数分布,如高斯,或者一个更复杂的非参数模型,如核密度估计(KDE)。一旦模型在正常数据上训练,就可以用它来估计新观察值的密度。密度低的观察值被认为是异常。
使用密度估计方法有几个优点:
-
它是灵活的,可以处理各种数据类型
-
它不需要训练标签数据,这使得它适合于无监督学习(UL)
-
它可以检测点异常(与其它观察值显著不同的观察值)和上下文异常(在特定上下文中不遵循正常模式的观察值)
然而,这种方法也存在一些挑战:
-
模型和参数的选择可能会影响算法的性能
-
离群值可能会影响估计的密度,导致假阳性
-
算法可能无法检测在训练数据中表征不佳的异常
总体而言,密度估计是异常检测的一个强大工具,可以根据应用的具体需求进行定制。通过仔细选择模型和调整参数,可以在检测异常时实现高准确性和精确度。
使用多元高斯分布进行异常检测
假设我们在数据集中有一些样本
,并且它们被标记并且呈正态分布(高斯分布)。在这种情况下,我们可以使用分布特性来检测异常。假设函数
给出了一个样本为正常的概率。高概率对应于常规样本,而低概率对应于异常。因此,我们可以选择阈值来区分常规值和异常,以下是一个异常模型公式:

如果 ![] 和
符合具有均值
和方差
的高斯分布,则表示如下:

以下公式给出了高斯分布中
的概率:

这里,
是均值,
是方差(
是标准差)。这个公式被称为参数化的概率密度函数(PDF),它描述了连续随机变量不同结果的相对可能性。它用于模拟随机变量的分布,并提供有关观察特定值或该变量的值范围的概率的信息。
接下来,我们将介绍我们用于高斯分布密度估计异常检测的一般方法的示例:
-
假设我们得到了一个新的例子,
。 -
选择特征,
,它们是规则的,意味着它们决定了异常行为。 -
调整
和
参数。 -
使用一个计算
在高斯分布中概率的方程来计算
。 -
通过将其与阈值
进行比较来确定
是否是异常;参见异常模型公式。
以下图表显示了正态分布数据的 Gaussian 分布密度估计示例:

图 5.5 – 高斯密度估计可视化
在这种方法中,我们假设所选特征是独立的,但在实际数据中,它们之间通常存在一些相关性。在这种情况下,我们应该使用多元高斯分布模型而不是单变量模型。
以下公式给出了多元高斯分布中
的概率:

这里,
是均值,
是相关矩阵,
是矩阵的行列式,
:

以下图表显示了具有相关数据的数据集的单变量和多变量高斯分布估计模型之间的差异。注意分布边界如何用较深的颜色覆盖常规数据,而异常用较浅的颜色标记:

图 5.6 – 单变量和多变量高斯分布
我们可以看到,多元高斯分布可以考虑到数据中的相关性,并适应其形状。这一特性使我们能够正确地检测出那些分布形状遵循高斯(正态)分布的数据类型的异常。此外,我们还可以看到这种方法的一个优点:结果可以很容易地在二维或三维中进行可视化,从而对数据有清晰的理解。
在下一节中,我们将探讨另一种异常检测的方法。
KDE
在 KDE 方法中,我们的目标是将围绕点样本的复杂随机分布近似为一个单一函数。因此,主要思想是在每个数据点处集中一个概率分布函数,然后取它们的平均值。这意味着我们数据集中的每个离散点都被一个扩展的概率分布所取代,称为核。在任意给定点的概率密度被估计为所有以每个离散点为中心的核函数的总和。如果点靠近许多其他点,其估计的概率密度将大于如果它远离任何样本点的情况。这种方法可以用来寻找异常点,即估计密度最小的地方。
从数学上讲,我们可以将单变量核的 KDE 函数表述如下:

在这里,Kh 是通过以下公式定义的平滑核:

在这里,h 是带宽参数,它定义了核的宽度。带宽参数控制着核函数的平滑度或粗糙度。较大的带宽会导致更平滑的函数,而较小的带宽则会导致更崎岖的函数。选择合适的带宽对于实现良好的泛化性能至关重要。
K 可以是,例如,高斯核函数,这是最常见的一种:

下面的图表显示了由单个核构成的 Gaussian 分布的 KDE 图:

图 5.7 – Gaussian 分布的 KDE
你可以从这张图中看到,密度值将在值 3 附近的点达到最大,因为大多数样本点都位于这个区域。另一个点群位于点 8 附近,但它们的密度要小得多。
在训练 KDE 模型之前,预处理数据以确保其适合 KDE 算法是非常重要的。这可能包括对数据进行缩放,以便所有特征具有相似的范围,移除异常值或异常点,以及在必要时转换数据。
由于维度诅咒(CoD),在 KDE 中有效地处理高维数据可能具有挑战性。一种方法是在应用 KDE 之前使用降维技术,如主成分分析(PCA)或t 分布随机邻域嵌入(t-SNE)来减少维度数量。另一种方法是使用稀疏 KDE 算法,在计算给定位置的密度估计时仅考虑数据点的子集。
在下一节中,我们将探讨另一种异常检测方法。
密度估计树
密度估计树(DET)算法也可以通过阈值化某些样本点的密度值来检测异常。这是一种基于决策树构建的非参数技术。该算法的主要优点是在任何给定点的密度估计的快速分析复杂度,其时间复杂度为O(log n),其中 n 是树中点的数量。树是按从上到下的方式迭代构建的。每个叶子节点t通过最大化以下定义的残差增益s被分为两个子叶子节点tl 和tr:

这里,R(t)是树损失函数:

N是t叶子节点包含的候选者数量,V是叶子节点的体积。然后,叶子节点t的实际密度可以按以下方式计算:

因此,为了估计给定点的密度值,我们必须确定它属于哪个叶子节点,然后获取该叶子节点的密度。
在本节中,我们讨论了各种异常检测方法,在接下来的几节中,我们将看到如何使用各种 C++库来处理异常检测任务。
使用不同 C++库进行异常检测的示例
在本节中,我们将探讨如何实现我们之前描述的用于异常检测的算法的一些示例。
C++实现用于异常检测的隔离森林算法
隔离森林算法可以很容易地在纯 C++中实现,因为它们的逻辑非常直接。此外,在流行的 C++库中还没有这个算法的实现。让我们假设我们的实现将仅用于二维数据。我们将检测包含相同数量特征的样本范围内的异常。
由于我们的数据集足够大,我们可以定义一个实际数据容器的包装器。这允许我们减少对实际数据执行的复制操作数量:
using DataType = double;
template <size_t Cols>
using Sample = std::array<DataType, Cols>;
template <size_t Cols>
using Dataset = std::vector<Sample<Cols>>;
...
template <size_t Cols>
struct DatasetRange {
DatasetRange(std::vector<size_t>&& indices,
const Dataset<Cols>* dataset)
: indices(std::move(indices)), dataset(dataset) {}
size_t size() const { return indices.size(); }
DataType at(size_t row, size_t col) const {
return (*dataset)[indices[row]][col];
}
std::vector<size_t> indices;
const Dataset<Cols>* dataset;
};
DatasetRange类型持有对Sample类型对象数组的引用,以及对指向数据集中样本的索引容器的引用。这些索引定义了此DatasetRange对象指向的确切数据集对象。
接下来,我们定义隔离树元素,第一个是Node类型:
struct Node {
Node() {}
Node(const Node&) = delete;
Node& operator=(const Node&) = delete;
Node(std::unique_ptr<Node> left,
std::unique_ptr<Node> right, size_t split_col,
DataType split_value)
: left(std::move(left)),
right(std::move(right)),
split_col(split_col),
split_value(split_value) {}
Node(size_t size) : size(size), is_external(true) {}
std::unique_ptr<Node> left;
std::unique_ptr<Node> right;
size_t split_col{0};
DataType split_value{0};
size_t size{0};
bool is_external{false};
};
此类型是常规树节点结构。以下成员是隔离树算法特有的:
-
split_col:这是算法导致分割的特征列的索引 -
split_value:这是算法导致分割的特征值 -
size:这是节点的底层项目数量 -
is_external:这是一个标志,表示节点是否为叶节点
以Node类型为基础,我们可以定义构建隔离树的程序。我们将此程序与辅助IsolationTree类型相结合。因为当前算法基于随机分割,辅助数据是随机引擎对象。
我们只需要初始化此对象一次,然后它将在所有树类型对象之间共享。这种方法允许我们在固定种子的情况下使算法的结果可重复。此外,它使随机化算法的调试变得更加简单:
template <size_t Cols>
class IsolationTree {
public:
using Data = DatasetRange<Cols>;
IsolationTree(const IsolationTree&) = delete;
IsolationTree& operator=(const IsolationTree&) = delete;
IsolationTree(std::mt19937* rand_engine, Data data, size_t hlim)
: rand_engine(rand_engine) {
root = MakeIsolationTree(data, 0, hlim);
}
IsolationTree(IsolationTree&& tree) {
rand_engine = std::move(tree.rand_engine);
root = td::move(tree.root);
}
double PathLength(const Sample<Cols>& sample) {
return PathLength(sample, root.get(), 0);
}
private:
std::unique_ptr<Node> MakeIsolationTree(const Data& data,
size_t height,
size_t hlim);
double PathLength(const Sample<Cols>& sample,
const Node* node, double height);
private:
std::mt19937* rand_engine;
std::unique_ptr<Node> root;
};
接下来,我们将进行MakeIsolationTree()方法中最关键的工作,该方法在构造函数中用于初始化根数据成员:
std::unique_ptr<Node> MakeIsolationTree(const Data& data,
size_t height, size_t hlim) {
auto len = data.size();
if (height >= hlim || len <= 1) {
return std::make_unique<Node>(len);
} else {
std::uniform_int_distribution<size_t> cols_dist(0, Cols - 1);
auto rand_col = cols_dist(*rand_engine);
std::unordered_set<DataType> values;
for (size_t i = 0; i < len; ++i) {
auto value = data.at(i, rand_col);
values.insert(value);
}
auto min_max = std::minmax_element(values.begin(), values.end());
std::uniform_real_distribution<DataType> value_dist(
*min_max.first, *min_max.second);
auto split_value = value_dist(*rand_engine);
std::vector<size_t> indices_left;
std::vector<size_t> indices_right;
for (size_t i = 0; i < len; ++i) {
auto value = data.at(i, rand_col);
if (value < split_value) {
indices_left.push_back(data.indices[i]);
} else {
indices_right.push_back(data.indices[i]);
}
}
return std::make_unique<Node>(
MakeIsolationTree(
Data{std::move(indices_left), data.dataset},
height + 1, hlim),
MakeIsolationTree(
Data{std::move(indices_right), data.dataset},
height + 1, hlim),
rand_col, split_value);
}
}
初始时,我们检查终止条件以停止分割过程。如果我们遇到这些条件,我们将返回一个标记为外部叶节点的新的节点。否则,我们开始分割传递的数据范围。对于分割,我们随机选择feature列并确定所选特征的唯一值。然后,我们从所有样本的特征值中随机选择一个介于max和min值之间的值。在我们做出这些随机选择之后,我们将所选分割特征值与输入数据范围中的所有样本进行比较,并将它们的索引放入两个列表中。一个列表用于高于分割值的值,而另一个列表用于低于它们的值。然后,我们返回一个新树节点,该节点初始化为对左右节点的引用,这些节点通过递归调用MakeIsolationTree()方法进行初始化。
IsolationTree类型的另一个重要方法是PathLength()方法。我们使用它进行异常分数计算。它接受样本作为输入参数,并返回从根节点到相应树叶节点的平均路径长度:
double PathLength(const Sample<Cols>& sample, const Node* node,
double height) {
assert(node != nullptr);
if (node->is_external) {
return height + CalcC(node->size);
} else {
auto col = node->split_col;
if (sample[col] < node->split_value) {
return PathLength(sample, node->left.get(), height + 1);
} else {
return PathLength(sample, node->right.get(), height + 1);
}
}
}
PathLength() 方法根据样本特征值在树遍历过程中找到叶节点。这些值用于根据当前节点分裂值选择树遍历方向。在每一步中,此方法还会增加结果的高度。此方法的结果是实际树遍历高度和从 CalcC() 函数调用返回的值的总和,该函数返回与叶节点等高的二叉搜索树中未成功搜索的平均路径长度。CalcC() 函数可以按照原始论文中的公式实现,该公式描述了隔离森林算法(你可以在 进一步 阅读 部分找到参考):
double CalcC(size_t n) {
double c = 0;
if (n > 1)
c = 2 * (log(n - 1) + 0.5772156649) - (
2 * (n - 1) / n);
return c;
}
算法实现的最后部分是森林的创建。森林是从原始数据集中随机选择的有限数量的样本构建的树数组。用于构建树的样本数量是该算法的超参数。此外,此实现使用启发式作为停止标准,即它是最大树高度 hlim 值。
让我们看看它在树构建过程中的使用。hlim 值只计算一次,以下代码展示了这一点。此外,它基于用于构建单个树的样本数量:
template <size_t Cols>
class IsolationForest {
public:
using Data = DatasetRange<Cols>;
IsolationForest(const IsolationForest&) = delete;
IsolationForest& operator=(const IsolationForest&) = delete;
IsolationForest(const Dataset<Cols>& dataset,
size_t num_trees, size_t sample_size)
: rand_engine(2325) {
std::vector<size_t> indices(dataset.size());
std::iota(indices.begin(), indices.end(), 0);
size_t hlim = static_cast<size_t>(ceil(log2(sample_size)));
for (size_t i = 0; i < num_trees; ++i) {
std::vector<size_t> sample_indices;
std::sample(indices.begin(), indices.end(),
std::back_insert_iterator(sample_indices),
sample_size, rand_engine);
trees.emplace_back(
&rand_engine,
Data(std::move(sample_indices), &dataset), hlim);
}
double n = dataset.size();
c = CalcC(n);
}
double AnomalyScore(const Sample<Cols>& sample) {
double avg_path_length = 0;
for (auto& tree : trees) {
avg_path_length += tree.PathLength(sample);
}
avg_path_length /= trees.size();
double anomaly_score = pow(2, -avg_path_length / c);
return anomaly_score;
}
private:
std::mt19937 rand_engine;
std::vector<IsolationTree<Cols>> trees;
double c{0};
};
}
树森林是在 IsolationForest 类的构造函数中构建的。我们还在构造函数中计算了所有样本在二叉搜索树中未成功搜索的平均路径长度。我们在 AnomalyScore() 方法中使用此森林来进行实际的异常检测过程。它实现了给定样本的异常分数值的公式。它返回的值可以按以下方式解释:如果返回的值接近 1,则样本具有异常特征;如果值小于 0.5,则我们可以假设该样本是正常的。
以下代码展示了我们如何使用此算法。此外,它使用 Dlib 原语来表示数据集:
void IsolationForest(const Matrix& normal, const Matrix& test) {
iforest::Dataset<2> dataset;
auto put_to_dataset = & {
for (long r = 0; r < samples.nr(); ++r) {
auto row = dlib::rowm(samples, r);
double x = row(0, 0);
double y = row(0, 1);
dataset.push_back({x, y});
}
};
put_to_dataset(normal);
put_to_dataset(test);
iforest::IsolationForest iforest(dataset, 300, 50);
double threshold = 0.6; // change this value to see isolation
//boundary
for (auto& s : dataset) {
auto anomaly_score = iforest.AnomalyScore(s);
// std::cout << anomaly_score << " " << s[0] << " " << s[1]
// << std::endl;
if (anomaly_score < threshold) {
// Do something with normal
} else {
// Do something with anomalies
}
}
}
在前面的例子中,我们将适合我们算法的容器中的给定数据集进行了转换和合并。然后,我们初始化了 IsolationForest 类的对象,它立即使用以下超参数构建隔离森林:树的数量是 100,用于构建一棵树的样本数量是 50。
最后,我们为数据集中的每个样本调用了 AnomalyScore() 方法,以使用阈值检测异常并返回它们的值。在下面的图中,我们可以看到使用隔离森林算法进行异常检测后的结果。标记为 1 cls 的点是异常点:

图 5.8 – 使用隔离森林算法进行异常检测
在本节中,我们学习了如何从头实现隔离森林算法。下一节将向您展示如何使用 Dlib 库进行异常检测。
使用 Dlib 库进行异常检测
Dlib 库提供了一些实现算法,我们可以使用它们进行异常检测:OCSVM 模型和多元高斯模型。
Dlib 中的 OCSVM
Dlib 库中直接实现的一个算法是 OCSVM。在这个库中有一个 svm_one_class_trainer 类,可以用来训练相应的算法,这应该配置一个核对象,以及 nu 参数,它控制解决方案的平滑度(换句话说,它控制泛化与过拟合之间比率的程度)。
最广泛使用的核函数基于高斯分布,被称为 radial_basis_kernel 类。通常,我们在 Dlib 库中将数据集表示为单独样本的 C++ 向量。因此,在使用此 trainer 对象之前,我们必须将矩阵数据集转换为向量:
void OneClassSvm(const Matrix& normal, const Matrix& test) {
typedef matrix<double, 0, 1> sample_type;
typedef radial_basis_kernel<sample_type> kernel_type;
svm_one_class_trainer<kernel_type> trainer;
trainer.set_nu(0.5); // control smoothness of the solution
trainer.set_kernel(kernel_type(0.5)); // kernel bandwidth
std::vector<sample_type> samples;
for (long r = 0; r < normal.nr(); ++r) {
auto row = rowm(normal, r);
samples.push_back(row);
}
decision_function<kernel_type> df = trainer.train(samples);
Clusters clusters;
double dist_threshold = -2.0;
auto detect = & {
for (long r = 0; r < samples.nr(); ++r) {
auto row = dlib::rowm(samples, r);
auto dist = df(row);
if (p > dist_threshold) {
// Do something with anomalies
} else {
// Do something with normal
}
}
};
detect(normal);
detect(test);
}
训练过程的结果是 decision_function<kernel_type> 类的决策函数对象,我们可以用它进行单样本分类。此类对象可以用作常规函数。决策函数的结果是到正常类边界的距离,因此最远的样本可以分类为异常。以下图表展示了 Dlib 库中的 OCSVM 算法的工作示例。注意,标记为 1 cls 的点对应于异常:

图 5.9 – 使用 Dlib OCSVM 实现的异常检测
我们可以看到,OCSVM 很好地解决了任务并检测到了非常可解释的异常。在下一节中,我们将看到如何使用多元高斯模型来检测异常。
Dlib 中的多元高斯模型
使用 Dlib 库的线性代数功能(或任何其他库,实际上也是如此),我们可以使用多元高斯分布方法实现异常检测。以下示例展示了如何使用 Dlib 线性代数例程实现这种方法:
void multivariateGaussianDist(const Matrix& normal,
const Matrix& test) {
// assume that rows are samples and columns are features
// calculate per feature mean
dlib::matrix<double> mu(1, normal.nc());
dlib::set_all_elements(mu, 0);
for (long c = 0; c < normal.nc(); ++c) {
auto col_mean = dlib::mean(dlib::colm(normal, c));
dlib::set_colm(mu, c) = col_mean;
}
// calculate covariance matrix
dlib::matrix<double> cov(normal.nc(), normal.nc());
dlib::set_all_elements(cov, 0);
for (long r = 0; r < normal.nr(); ++r) {
auto row = dlib::rowm(normal, r);
cov += dlib::trans(row - mu) * (row - mu);
}
cov *= 1.0 / normal.nr();
double cov_det = dlib::det(cov); // matrix determinant
dlib::matrix<double> cov_inv = dlib::inv(cov); // inverse matrix
// define probability function
auto first_part = 1\. / std::pow(2\. * M_PI, normal.nc() / 2.) /
std::sqrt(cov_det);
auto prob = & {
dlib::matrix<double> s = sample - mu;
dlib::matrix<double> exp_val_m =
s * (cov_inv * dlib::trans(s));
double exp_val = -0.5 * exp_val_m(0, 0);
double p = first_part * std::exp(exp_val);
return p;
};
// change this parameter to see the decision boundary
double prob_threshold = 0.001;
auto detect = & {
for (long r = 0; r < samples.nr(); ++r) {
auto row = dlib::rowm(samples, r);
auto p = prob(row);
if (p >= prob_threshold) {
// Do something with anomalies
} else {
// Do something with normal
}
}
};
detect(normal);
detect(test);
}
这种方法的思路是定义一个函数,该函数返回给定数据集中样本出现的概率。为了实现这样的函数,我们计算训练数据集的统计特征。在第一步中,我们计算每个特征的均值并将它们存储在一个一维矩阵中。然后,我们使用先前理论部分中给出的相关矩阵公式来计算训练样本的协方差矩阵,该部分命名为密度估计方法。接下来,我们确定相关矩阵的行列式和逆矩阵。我们定义一个名为prob的 lambda 函数,使用使用多元高斯分布部分提供的公式来计算单个样本的概率。
对于大数据集,计算协方差矩阵的计算复杂度可以成为机器学习模型整体运行时间中的一个重要因素。此外,优化大数据集的协方差矩阵计算需要结合多种技术,包括稀疏性、并行化、近似和高效算法。
我们还定义一个概率阈值来区分异常值。它决定了正常行为和异常行为之间的边界,并在将样本分类为异常或正常时起着至关重要的作用。工程师必须仔细考虑他们应用程序的要求并相应地调整阈值,以达到所需的灵敏度水平。例如,在安全应用中,误报代价高昂,可能更倾向于选择更高的阈值以最小化误报。相反,在医学诊断中,错过潜在异常可能具有严重后果,因此可能更合适选择较低的阈值以确保没有真正的异常未被检测到。
然后,我们遍历所有示例(包括训练和测试数据集),以找出算法如何将常规样本与异常值分开。在下面的图中,我们可以看到这种分离的结果。标记为1 cls的点代表异常值:

图 5.10 – 使用 Dlib 多变量高斯分布进行异常检测
我们看到这种方法找到的异常比之前少,因此你应该意识到某些方法可能不会很好地适用于你的数据,尝试不同的方法是有意义的。在下一节中,我们将看到如何使用mlpack库中的多变量高斯模型来完成相同的任务。
使用 mlpack 的多变量高斯模型
我们已经在上一章讨论了存在于mlpack库中的GMM和EMFit类。期望最大化与拟合(EMFit)算法是一种机器学习技术,用于估计高斯混合模型(GMM)的参数。它通过迭代优化参数以拟合数据来工作。我们不仅可以用它们来解决聚类任务,还可以用于异常检测。唯一的区别是:我们只需要为训练指定一个簇。因此,GMM 类的初始化将如下所示:
GMM gmm(/*gaussians*/ 1, /*dimensionality*/ 2);
KMeans和EMFit算法的初始化将与前一个示例相同:
KMeans<> kmeans;
size_t max_iterations = 250;
double tolerance = 1e-10;
EMFit<KMeans<>, NoConstraint> em(max_iterations,
tolerance,
kmeans);
gmm.Train(normal,
/*trials*/ 3,
/*use_existing_model*/ false,
em);
max_iterations和收敛容差变量的值会影响训练过程,通过确定算法运行多长时间以及何时停止。更高的试验次数可能会导致更准确的结果,但也会增加计算时间。收敛容差决定了参数在算法停止之前必须接近其先前值有多近。如果容差太低,算法可能永远不会收敛,而如果它太高,它可能收敛到一个次优解。
然后,我们可以使用gmm对象的Probability方法来测试一些新的数据。我们唯一需要采取的新行动是定义一个概率阈值,该阈值将用于检查新样本是否属于原始数据分布,或者它是否是异常。可以这样做:
double prob_threshold = 0.001;
有了这个阈值,Probability方法的用法如下:
auto detect = & {
for (size_t c = 0; c < samples.n_cols; ++c) {
auto sample = samples.col(c);
double x = sample.at(0, 0);
double y = sample.at(1, 0);
auto p = gmm.Probability(sample);
if (p >= prob_threshold) {
plot_clusters[0].first.push_back(x);
plot_clusters[0].second.push_back(y);
} else {
plot_clusters[1].first.push_back(x);
plot_clusters[1].second.push_back(y);
}
}
};
在这里,我们定义了一个可以应用于任何以矩阵定义的数据集的 lambda 函数。在这个函数中,我们使用了一个简单的循环,遍历所有样本,并对单个样本应用Probability方法,并将返回的值与阈值进行比较。如果概率值太低,我们通过将其坐标添加到plotting_cluster[1]对象中来将该样本标记为异常。为了绘制异常检测结果,我们使用了与上一章中描述的相同的方法。以下代码显示了如何使用我们定义的函数:
arma::mat normal;
arma::mat test;
Clusters plot_clusters;
detect(normal);
detect(test);
PlotClusters(plot_clusters, "Density Estimation Tree", file_name);
我们将detect函数应用于两组数据:用于训练的normal数据和新的test数据。您可以在以下图表中看到异常检测结果:

图 5.11 – 使用 mlpack 多元高斯分布进行异常检测
两个异常值被检测到。您可以尝试更改概率阈值,看看决策边界将如何改变,以及哪些对象将被分类为异常。
在下一节中,我们将看到如何使用mlpack库中的 KDE 算法实现。
使用 mlpack 的核密度估计(KDE)
mlpack库中的 KDE 算法是在KDE类中实现的。这个类可以用几个模板参数进行特殊化;其中最重要的参数是KernelType、MetricType和TreeType。让我们使用高斯核,欧几里得距离作为度量,KD-Tree 作为树类型。使用树数据结构来优化算法的计算复杂度。对于每个查询点,算法将对每个参考点应用核函数,因此,在原始实现中,对于 N 个查询点和 N 个参考点,计算复杂度可以达到O(N²)。树优化避免了许多类似的计算,因为核函数值随着距离的增加而减小,但它也引入了一定程度的近似。下面的代码片段展示了如何为我们的样本定义一个KDE对象:
using namespace mlpack;
...
KDE<GaussianKernel,
EuclideanDistance,
arma::mat,
KDTree>
kde(/*rel error*/ 0.0, /*abs error*/ 0.01, GaussianKernel());
由于算法中使用了近似,API 允许我们定义相对和绝对误差容限。相对和绝对误差容限控制 KDE 估计中的近似程度。更高的容限允许更多的近似,这可以降低计算复杂度,但也会降低精度。相反,更低的容限需要更多的计算,但可能导致更准确的估计。
相对误差容限参数指定了在任何点上真实密度与估计密度之间允许的最大相对误差。它用于确定核的最佳带宽。
绝对误差容限参数设置在整个域内真实密度与估计密度之间允许的最大绝对误差。它可以用来确保估计密度在真实密度的某个范围内。
对于我们的样本,我们只定义了绝对误差容限。下一步是用正常数据(无异常)训练我们的算法对象;可以这样做:
arma::mat normal;
...
kde.Train(normal);
你可以看到,mlpack库中的不同算法主要使用相同的 API。然后,我们可以定义一个函数来将给定的数据分类为正常或异常。下面的代码展示了其定义:
double density_threshold = 0.1;
Clusters plot_clusters;
auto detect = & {
arma::vec estimations;
kde.Evaluate(samples, estimations);
for (size_t c = 0; c < samples.n_cols; ++c) {
auto sample = samples.col(c);
double x = sample.at(0, 0);
double y = sample.at(1, 0);
auto p = estimations.at(c);
if (p >= density_threshold) {
plot_clusters[0].first.push_back(x);
plot_clusters[0].second.push_back(y);
} else {
plot_clusters[1].first.push_back(x);
plot_clusters[1].second.push_back(y);
}
}
};
我们定义了一个 lambda 函数,它接受数据矩阵并将其传递给kde对象的Evaluate方法。此方法评估并分配给给定数据矩阵中的每个样本密度值估计。然后,我们只需将这些估计与density_threshold值进行比较,以决定样本是否为正常或异常。密度值低的样本被分类为异常。
为了选择一个最优的阈值,你需要根据你的具体用例在敏感性和特异性之间进行权衡。如果你优先考虑检测所有异常,你可能想要设置一个较低的阈值以增加真阳性的数量,即使这意味着接受更多的假阳性。相反,如果你优先考虑最小化误报,你可能选择一个较高的阈值,这可能会错过一些异常,但会减少假阳性的数量。在实践中,选择最优的密度阈值通常涉及对不同值进行实验,并使用如精确度、召回率和 F1 分数等指标评估结果。此外,领域知识和专家意见可以帮助指导选择过程。
此函数还以我们之前的方式准备绘图数据。以下代码显示了如何绘制包含正常和异常数据的两个数据集:
arma::mat normal;
arma::mat test;
detect(normal);
detect(test);
PlotClusters(plot_clusters, "Density Estimation Tree", file_name);
你可以在以下图表中看到异常检测的结果:

图 5.12 – 使用 mlpack 的 KDE 进行异常检测
我们可以看到,使用 KDE 方法,我们可以找到两个异常值,正如我们在前面的章节中检测到的。标记为1 cls的点为异常值。通过改变密度阈值,你可以看到决策边界将如何改变。
在下一节中,我们将看到如何使用mlpack库中的 DET 算法实现。
DET with mlpack
mlpack库中的 DET 方法在DTree类中实现。要开始使用它,我们必须复制训练正常数据,因为DTree类的对象会改变输入数据的顺序。数据顺序的改变是因为mlpack直接在给定的数据对象上创建树数据结构。以下代码片段显示了如何定义这样的对象:
arma::mat data_copy = normal;
DTree<> det(data_copy);
由于输入数据重新排序的算法,API 需要提供一个索引映射,该映射将显示新索引与旧索引之间的关系。这种映射可以初始化如下:
arma::Col<size_t> data_indices(data_copy.n_cols);
for (size_t i = 0; i < data_copy.n_cols; i++) {
data_indices[i] = i;
}
在这里,我们只是存储了数据索引与输入数据之间的原始关系。稍后,这种映射将由算法更新。现在,可以通过调用Grow方法来构建 DET,如下所示:
size_t max_leaf_size = 5;
size_t min_leaf_size = 1;
det.Grow(data_copy, data_indices, false, max_leaf_size,
min_leaf_size);
Grow方法的两个主要参数是max_leaf_size和min_leaf_size。它们应该在一系列实验或对数据集特征的先验知识的基础上手动调整。使用如交叉验证等自动化技术估计这些参数可能会很棘手,因为在异常检测任务中,我们通常没有足够的数据被标记为异常。因此,这个示例中的值是手动选择的。
在初始化了密度估计器(DET)之后,我们可以使用ComputeValue方法来估计给定数据样本的密度。如果我们选择一个密度阈值值,只需通过比较这个值就可以检测到异常。我们在其他算法中也使用了相同的方法。以下代码片段展示了如何使用阈值来区分正常和异常数据,并构建用于结果绘制的结构:
double density_threshold = 0.01;
Clusters plot_clusters;
auto detect = & {
for (size_t c = 0; c < samples.n_cols; ++c) {
auto sample = samples.col(c);
double x = sample.at(0, 0);
double y = sample.at(1, 0);
auto p = det.ComputeValue(sample);
if (p >= density_threshold) {
plot_clusters[0].first.push_back(x);
plot_clusters[0].second.push_back(y);
} else {
plot_clusters[1].first.push_back(x);
plot_clusters[1].second.push_back(y);
}
}
};
我们定义了一个detect函数,该函数简单地遍历输入数据矩阵的列,并对每个给定的样本应用ComputeValue方法以获取密度估计。然后,该函数将一个值与density_threshold进行比较,如果密度足够大,则将样本放入第一个绘图簇。否则,样本将被放入第二个绘图簇。我们可以如下应用此函数:
detect(normal);
detect(test);
PlotClusters(plot_clusters, "Density Estimation Tree", file_name);
在这里,normal和test是包含正常和异常数据样本的矩阵。以下图表显示了检测结果的绘制:

图 5.13 – 使用 DET 算法进行异常检测
你可能会注意到,这种方法将比以前的方法更多的数据点分类为异常。要改变这种检测结果,你可以调整三个参数:密度阈值和叶子的min和max大小。这种方法在数据分布规则(即核形式)未知或难以编写代码的情况下可能很有用。同样,当你有具有不同分布的几个簇的正常数据时也是如此。
摘要
在本章中,我们探讨了数据中的异常。我们讨论了几种异常检测方法,并观察了两种类型的异常:离群值和新颖性。我们考虑了异常检测主要是一个无监督学习(UL)问题的事实,尽管如此,一些算法需要标记数据,而另一些则是半监督的。这是因为,在异常检测任务中,通常只有很少的正例(即异常样本)和大量的负例(即标准样本)。
换句话说,我们通常没有足够的正样本来训练算法。这就是为什么一些解决方案使用标记数据来提高算法的泛化能力和精确度。相反,监督学习(SL)通常需要大量的正例和负例,并且它们的分布需要平衡。
此外,请注意,检测异常的任务没有单一的公式,并且它通常根据数据的性质和具体任务的目标被不同地解释。此外,选择正确的异常检测方法主要取决于任务、数据和可用的先验信息。我们还了解到,不同的库可以为相同的算法提供略微不同的结果。
在下一章中,我们将讨论降维方法。这些方法帮助我们将高维数据降低到新的低维数据表示,同时保留原始数据中的关键信息。
进一步阅读
-
一类 SVMs 的异常检测:黑色素瘤预后应用:
www.ncbi.nlm.nih.gov/pmc/articles/PMC3041295/ -
Ram, Parikshit & Gray, Alexander. (2011). 密度估计树. ACM SIGKDD 国际知识发现和数据挖掘会议论文集. 627-635. 10.1145/2020408.2020507:
www.researchgate.net/publication/221654618_Density_estimation_trees -
KDE 教程:
faculty.washington.edu/yenchic/18W_425/Lec6_hist_KDE.pdf
第六章:维度缩减
在本章中,我们将讨论多个维度缩减任务。我们将探讨需要维度缩减的条件,并学习如何使用各种库在 C++ 中高效地使用维度缩减算法。维度缩减涉及将高维数据转换成具有较少维度的新的表示形式,同时保留原始数据中最关键的信息。这种转换可以帮助我们可视化多维空间,这在数据探索阶段或识别数据集样本中最相关的特征时可能很有用。如果我们的数据具有较少的特征,某些机器学习(ML)技术可能会表现得更好或更快,因为它们可以消耗更少的计算资源。这种转换的主要目的是保存关键特征——那些在原始数据中包含最关键信息的特征。
本章将涵盖以下主题:
-
维度缩减方法的概述
-
探索用于维度缩减的线性方法
-
探索用于维度缩减的非线性方法
-
使用各种 C++ 库理解维度缩减算法
技术要求
本章所需的技术如下:
-
Tapkee 库
-
Dlib库 -
plotcpp库 -
支持 C++20 的现代 C++ 编译器
-
CMake 构建系统,版本 >= 3.24
本章的代码文件可以在以下 GitHub 仓库中找到:github.com/PacktPublishing/Hands-on-Machine-learning-with-C-Second-Edition/tree/main/Chapter06。
维度缩减方法的概述
维度缩减方法的主要目标是使变换表示的维度与数据的内部维度相对应。换句话说,它应该类似于表达数据所有可能属性所需的最小变量数。降低维度有助于减轻维度诅咒以及其他在高维空间中出现的不可取属性的影响。因此,降低维度可以有效地解决与分类、可视化和压缩高维数据相关的问题。只有在特定数据冗余的情况下才应用维度缩减是有意义的;否则,我们可能会丢失重要信息。换句话说,如果我们能够使用具有相同效率和精度的较小维度数据来解决问题,那么我们的一些数据就是冗余的。维度缩减使我们能够降低解决问题的耗时和计算成本。它还使数据和数据分析的结果更容易解释。
当解决问题所需的信息定性上包含在特定特征子集中时,减少特征的数量是有意义的。非信息性特征是额外噪声的来源,并影响模型参数估计的准确性。此外,具有大量特征的集合可能包含相关变量的组。这种特征组的出现会导致信息重复,这可能会扭曲模型的结果并影响其对参数值的估计能力。
维度约简的方法主要是无监督的,因为我们不知道哪些特征或变量可以从原始数据集中排除而不丢失最重要的信息。
维度约简的一些实际例子包括以下内容:
-
在推荐系统中,维度约简可以用来将用户和项目表示为低维空间中的向量,这使得找到相似的用户或项目变得更容易。
-
在图像识别中,可以应用如主成分分析(PCA)之类的维度约简技术,以在保留重要特征的同时减小图像的大小。
-
在文本分析中,维度约简可以用来将大量文档转换为低维表示,这些表示捕获了文档中讨论的主要主题。
维度约简方法可以分为两组:特征选择和创建新的低维特征。这些方法可以根据数据的性质和所使用的数学工具分为线性和非线性方法。
特征选择方法
特征选择方法不会改变变量或特征的初始值;相反,它们从源数据集中移除无关的特征。我们可以使用的某些特征选择方法如下:
-
缺失值比率:这种方法基于这样的想法,即缺失许多值的特征应该从数据集中删除,因为它不包含有价值的信息,并且可能扭曲模型性能的结果。因此,如果我们有一些识别缺失值的准则,我们可以计算它们与典型值的比率,并设置一个阈值,我们可以用它来消除具有高缺失值比率的特征。
-
低方差过滤器:这种方法用于移除具有低方差的特征,因为这些特征不包含足够的信息来提高模型性能。要应用这种方法,我们需要计算每个特征的方差,按此值升序排序,然后只保留方差值最高的那些特征。
-
高相关性滤波器:这种方法基于这样的想法,如果两个特征具有高相关性,那么它们携带相似的信息。此外,高度相关的特征可以显著降低某些机器学习模型的性能,例如线性回归和逻辑回归。因此,这种方法的主要目标是仅保留与目标值高度相关且彼此之间相关性不大的特征。
-
随机森林:这种方法可以有效地用于特征选择(尽管它最初并不是为此类任务设计的)。在我们构建森林之后,我们可以通过估计树节点中的不纯度因子来估计哪些特征最重要。这个因子显示了树节点中分裂的独特性度量,并展示了当前特征(随机树仅在节点中使用一个特征来分割输入数据)如何将数据分割成两个不同的桶。然后,这个估计可以跨森林中的所有树进行平均。分割数据比其他特征更好的特征可以被选为最重要的特征。
-
逆向特征消除和正向特征选择:这些是用于特征选择的迭代方法。在逆向特征消除中,我们在使用完整特征集训练模型并估计其性能后,逐个移除其特征,并使用减少的特征集重新训练模型。然后,我们比较模型的性能,并决定通过移除特征变化而提高的性能有多少——换句话说,我们正在决定每个特征的重要性。在正向特征选择中,训练过程的方向相反。我们从单个特征开始,然后添加更多。这些方法计算成本非常高,只能用于小型数据集。
维度降低方法
维度降低方法将原始特征集转换成新的特征集,通常包含初始数据集中不存在的新特征。这些方法也可以分为两个子类——线性和非线性。非线性方法通常计算成本更高,因此如果我们对特征数据的线性有先验假设,我们可以在初始阶段选择更合适的方法类。
以下章节将描述我们可以用于降维的各种线性和非线性方法。
探索降维的线性方法
在本节中,我们将描述用于降维的最流行的线性方法,例如以下内容:
-
PCA
-
奇异值分解(SVD)
-
独立成分分析(ICA)
-
线性判别分析(LDA)
-
因子分析
-
多维尺度分析(MDS)
主成分分析
主成分分析(PCA)是将数据降维并投影到特征正交子空间的最直观简单且常用方法之一。在非常一般的形式中,它可以表示为我们所有的观测值看起来都像是原始空间子空间中的某个椭球体。在这个空间中,我们的新基与这个椭球体的轴相一致。这个假设允许我们同时去除强相关特征,因为我们投影它们的空间基向量是正交的。
这个椭球体的维度等于原始空间的维度,但我们的假设是数据位于一个较小维度的子空间中,这使得我们可以丢弃新投影中的其他子空间;即椭球体扩展最少的子空间。我们可以贪婪地这样做,根据我们的新子空间逐个选择新的元素,然后从剩余的维度中依次选择椭球体的最大分散度的轴。
为了将我们的数据维度从
降低到
,我们需要选择这样一个椭球体的前
个轴,并按轴上的分散度降序排列。首先,我们计算原始特征的方差和协方差。这是通过使用协方差矩阵来完成的。根据协方差的定义,对于两个符号,
和
,它们的协方差应该是以下这样:

在这里,
是
特征的均值。
在这种情况下,我们注意到协方差是对称的,并且向量的协方差等于其分散度。因此,协方差矩阵是一个对称矩阵,其中对应特征的分散度位于对角线上,而对应特征对的协方差位于对角线之外。在矩阵视图中,其中
是观测矩阵,我们的协方差矩阵看起来是这样的:

协方差矩阵是多维随机变量中方差的推广——它也描述了随机变量的形状(分布),就像方差一样。矩阵,如线性算子,有特征值和特征向量。它们很有趣,因为当我们对相应的线性空间或用我们的矩阵对其进行变换时,特征向量保持不变,它们只乘以相应的特征值。这意味着它们定义了一个子空间,当我们对它应用线性算子矩阵时,这个子空间保持不变或进入自身。形式上,一个特征向量
,对于矩阵有一个特征值,被简单地定义为
。
我们样本的协方差矩阵!可以表示为乘积!。从瑞利关系可以得出,我们的数据集的最大变化可以沿着这个矩阵的特征向量实现,这对应于最大的特征值。对于更高维度的投影也是一样——投影到m-维空间上的方差(协方差矩阵)在具有最大特征值的特征向量方向上最大。因此,我们想要将数据投影到的主要成分只是这个矩阵对应于前k个特征值的特征向量。
最大的向量方向与回归线相似,通过将我们的样本投影到它上面,我们丢失了信息,类似于回归的残差成员的总和。进行操作!(向量长度(大小)应等于一),进行投影。如果我们没有单个向量,而是有一个超平面,那么我们不是取向量!,而是取基向量矩阵!。得到的向量(或矩阵)是我们观察值的投影数组;也就是说,我们需要将我们的数据矩阵乘以基向量矩阵,我们得到数据的正交投影。现在,如果我们乘以数据矩阵的转置和主成分向量矩阵,我们就可以在将原始样本投影到主成分基上的空间中恢复原始样本。如果成分的数量小于原始空间的维度,我们会丢失一些信息。
单值分解
SVD 是一种重要的数据分析方法。从机器学习的角度来看,其结果矩阵分解具有有意义的解释。它还可以用于计算 PCA。SVD 相对较慢。因此,当矩阵太大时,会使用随机算法。然而,SVD 的计算效率比原始 PCA 方法中协方差矩阵及其特征值的计算要高。因此,PCA 通常通过 SVD 来实现。让我们看看。
SVD 的本质很简单——任何矩阵(实数或复数)都可以表示为三个矩阵的乘积:

在这里,
是一个阶数为
的单位矩阵,而
是一个主对角线上的大小为
的矩阵,其中包含非负数,称为奇异值(主对角线以外的元素为零——这样的矩阵有时被称为矩形对角矩阵)。
是一个阶数为
的厄米共轭
矩阵。矩阵
的
列和矩阵
的
列分别被称为矩阵
的左奇异向量和右奇异向量。为了减少维度,矩阵
很重要,其元素平方后可以解释为每个成分对联合分布的贡献的方差,并且它们按降序排列:
。因此,当我们选择 SVD(如 PCA)中的成分数量时,应该考虑它们的方差之和。
SVD 与 PCA 之间的关系可以用以下方式描述:
是
给出的协方差矩阵。它是一个对称矩阵,因此它可以被对角化成
,其中
是一个特征向量矩阵(每一列都是一个特征向量)和
是一个对角矩阵,包含特征值
,对角线上的值按降序排列。特征向量被称为数据的主轴或主方向。数据在主轴上的投影被称为主成分,也称为主成分得分。它们是新的变换变量。
主成分由
的
列给出。
数据点在新主成分空间中的坐标由
的
行给出。
通过对
执行 SVD,我们得到
,其中
是一个单位矩阵,而
是奇异值对角矩阵,
。我们可以观察到
,这意味着右奇异向量
是主方向,而奇异值与协方差矩阵的特征值通过
相关。主成分由
给出。
独立成分分析
ICA 方法被提出作为一种解决盲信号分离(BSS)问题的方法;也就是说,从混合数据中选择独立信号。让我们看看 BSS 任务的一个例子。假设同一房间里有两人在交谈并产生声波。我们在房间的不同部分放置了两个麦克风,记录声音。分析系统从两个麦克风接收两个信号,每个信号都是两个声波的数字化混合——一个来自说话的人,另一个来自一些其他噪声(例如,播放音乐)。我们的目标是选择来自混合信号的初始信号。从数学上讲,这个问题可以描述如下。我们用线性组合的形式表示进入的混合,其中
表示位移系数,
表示独立成分向量的值:

以矩阵形式,这可以表示如下:

在这里,我们必须找到以下内容:

在这个方程中,
是输入信号值的矩阵,
是位移系数或混合矩阵的矩阵,
是独立成分的矩阵。因此,问题被分为两部分。第一部分是得到原始独立成分变量,
,的估计值,
。第二部分是找到矩阵,
。这种方法的工作原理基于两个原则:
-
独立成分必须是统计独立的(
矩阵值)。粗略地说,独立成分的一个向量的值不会影响另一个成分的值。 -
独立成分必须具有非高斯分布。
ICA 的理论基础是大数定律,该定律指出,
个独立随机变量的和(平均或线性组合)的分布对于
趋近于高斯分布。特别是,如果
是彼此独立的随机变量,来自具有平均
和方差
的任意分布,那么如果我们把这些变量的均值表示为
,我们可以说
趋近于均值为 0 和方差为 1 的高斯分布。为了解决 BSS 问题,我们需要找到矩阵
,使得
。在这里,
应尽可能接近原始独立源。我们可以将这种方法视为大数定律的逆过程。所有 ICA 方法都基于相同的基本方法——找到一个矩阵 W,该矩阵最大化非高斯性,从而最小化
的独立性。
快速 ICA 算法旨在最大化函数
,其中
是
的组成部分。因此,我们可以将函数的方程重写为以下形式:

在这里,
向量是矩阵 W 的第 i 行。
ICA 算法执行以下步骤:
-
它选择 w 的初始值。
-
它计算
,其中
是函数 G(z) 的导数。 -
它标准化
。 -
它重复前两个步骤,直到 w 停止变化。
为了测量非高斯性,快速 ICA 依赖于一个非二次非线性函数 G(z),它可以采取以下形式:

线性判别分析
LDA 是一种多元分析方法,它允许我们同时估计两个或更多组对象之间的差异。判别分析的基础是假设每个 k 类对象的描述是服从正态(高斯)分布的多维随机变量的实例,其平均值为
,协方差矩阵如下:

指数
表示特征空间的维度。考虑 LDA 算法在两类情况下的简化几何解释。让判别变量
是
维欧几里得空间的轴。每个对象(样本)是这个空间中的一个点,其坐标代表每个变量的固定值。如果两个类别在可观察变量(特征)上有所不同,它们可以表示为考虑空间中不同区域的不同点簇,这些区域可能部分重叠。为了确定每个类别的位置,我们可以计算其质心,这是一个想象中的点,其坐标是类别中变量(特征)的平均值。判别分析的任务是创建一个额外的
轴,它通过点云,使得其投影提供最佳的类别分离(换句话说,它最大化了类别之间的距离)。其位置由一个线性判别(LD)函数给出,该函数具有权重
,这些权重决定了每个初始变量
的贡献:

如果我们假设类别 1 和 2 的对象协方差矩阵相等,即
,那么 LD(线性判别)的系数向量
可以使用公式
计算,其中
是协方差矩阵的逆,
是
类别的均值。得到的轴与通过两组类别对象质心的直线方程相一致。广义马氏距离,等于它们在多维特征空间中的距离,估计为
。
因此,除了关于类别数据正态(高斯)分布的假设外,这在实践中相当罕见,LDA 对组内散布和协方差矩阵的统计相等性有更强的假设。如果没有显著差异,它们将被组合成一个计算出的协方差矩阵,如下所示:

这个原理可以推广到更多的类别。最终的算法可能看起来像这样:

类间散布矩阵的计算方式如下:

在这里,
是所有对象(样本)的平均值,
是类别的数量,
是第i类中的对象数量,
是类内均值,
是第i类的散射矩阵,
是一个中心化矩阵,其中
是所有 1 的 n x n 矩阵。
基于这些矩阵,计算得到
矩阵,其中确定了特征值和相应的特征向量。在矩阵的对角元素中,我们必须选择最大特征值的s,并转换矩阵,只留下对应的s行。得到的矩阵可以用来将所有对象转换为低维空间。
此方法需要标记数据,这意味着它是一种监督方法。
因子分析
因子分析用于减少描述数据所使用的变量数量,并确定它们之间的关系。在分析过程中,相互关联的变量被组合成一个因子。结果,组件之间的分散度被重新分配,因子的结构变得更加可理解。在组合变量之后,每个因子内组件之间的相关性高于它们与其他因子组件的相关性。假设已知变量依赖于更少的未知变量,并且我们有一个可以表示为以下形式的随机误差:

在这里,
是负荷,
是因子。
因子负荷的概念至关重要。它用于描述当我们希望从一个新的基中形成特定向量时,因子(变量)所起的作用。因子分析的本质是旋转因子的过程,即根据特定方法重新分配分散度。旋转的目的是定义因子负荷的简单结构。旋转可以是正交的,也可以是斜交的。在旋转的第一种形式中,每个后续因子被确定以最大化从前一个因子中剩余的变异性。因此,因子是相互独立且不相关的。第二种类型是一种因子之间相互关联的转换。大约有 13 种旋转方法在两种形式中都被使用。对新的基的元素有相似影响作用的因子被组合成一个组。然后,从每个组中,建议留下一个代表。一些算法,而不是选择一个代表,通过一些成为该组核心的启发式方法计算一个新的因子。
在过渡到代表群体的因素系统时发生降维,其他因素被丢弃。有几个常用的标准用于确定因素的数量。其中一些标准可以一起使用以相互补充。用于确定因素数量的标准之一是凯撒准则或特征值准则:只选择特征值等于或大于 一 的因素。这意味着如果一个因素没有选择至少与一个变量的方差等效的方差,则它被省略。一般的因子分析算法遵循以下步骤:
-
它计算相关矩阵。
-
它选择要包含的因素数量,例如,使用凯撒准则。
-
它提取初始因素集。有几种不同的提取方法,包括最大似然法、主成分分析(PCA)和主轴提取。
-
它将因素旋转到最终解决方案,该解决方案等于在初始提取中获得的解决方案,但具有最直接的解释。
多维尺度分析
当除了相关矩阵外,还可以使用任意类型的对象相似性矩阵作为输入数据时,MDS 可以被视为因子分析的一种替代方法。MDS 不仅仅是一个正式的数学过程,而是一种高效放置对象的方法,从而在新的特征空间中保持它们之间适当的距离。MDS 中新的空间维度总是显著小于原始空间。用于 MDS 分析的数据通常来自对象成对比较矩阵。
主要的 MDS 算法目标是恢复分析特征空间的未知维度,
,并按这种方式为每个对象分配坐标,即尽可能使对象之间的计算成对欧几里得距离与指定的成对比较矩阵相吻合。我们谈论的是以正交变换的精度恢复新的降低特征空间的坐标,确保对象之间的成对距离不发生变化。
因此,MDS 方法的目标也可以表述为显示由成对比较矩阵给出的原始多维数据的配置信息。这以低维空间中点的配置形式提供。
经典 MDS 假设未知的坐标矩阵,
,可以通过特征值分解,
,来表示。
可以通过使用双中心化从邻近矩阵
(一个包含样本之间距离的矩阵)计算得出。一般的 MDS 算法遵循以下步骤:
-
它计算平方邻近矩阵,
。 -
它应用双重中心化,
,使用中心化矩阵,
,其中
是对象的数量。 -
它确定
最大的特征值
和相应的特征向量
,这些特征向量属于
(其中
是所需的输出维度数量)。 -
它计算
,其中
是
的特征向量矩阵,
是
的特征值对角矩阵。
MDS 方法的缺点是它在计算中使用了欧几里得距离,因此没有考虑到邻近点的分布。如果你发现多维数据位于一个弯曲的流形上,数据点之间的距离可能比欧几里得距离大得多。
既然我们已经讨论了可用于降维的线性方法,让我们来看看存在哪些非线性方法。
探索降维的非线性方法
在本节中,我们将讨论广泛使用的非线性方法和算法,这些方法和算法用于降维,例如以下内容:
-
核 PCA
-
Isomap
-
Sammon 映射
-
分布式随机邻域嵌入(SNE)
-
自动编码器
核 PCA
经典的主成分分析(PCA)是一种线性投影方法,当数据线性可分时效果良好。然而,在数据线性不可分的情况下,需要采用非线性方法。处理线性不可分数据的基本思想是将数据投影到一个具有更多维度的空间中,使其变得线性可分。我们可以选择一个非线性映射函数,
,使得样本映射,x,可以表示为
。这被称为核函数。术语核描述了一个函数,它计算映射(在更高阶空间)样本x与
的标量积。这个标量积可以解释为新空间中测量的距离。换句话说,
函数通过创建原始对象的非线性组合,将原始的d-维元素映射到更高维度的k-维特征空间。例如,一个在 3D 空间中显示 2D 样本,
的函数可能看起来像
。
在线性 PCA 方法中,我们关注的是最大化数据集方差的特征成分。我们可以通过根据数据协方差矩阵计算与最大特征值对应的特征向量(主成分)来最大化方差,并将数据投影到这些特征向量上。这种方法可以推广到使用核函数映射到更高维空间的数据。然而,在实践中,多维空间中的协方差矩阵并不是显式计算的,因为我们可以使用一种称为核技巧的方法。核技巧允许我们将数据投影到主成分上,而无需显式计算投影,这要高效得多。一般方法如下:
-
计算等于
的核矩阵。 -
使其具有零均值值
,其中
是一个大小为 N x N 的矩阵,其元素为 1/N。 -
计算特征值和特征向量
。 -
按照特征值降序排列特征向量。
-
选择与最大特征值对应的
个特征向量,其中
是新特征空间的维度数。
这些特征向量是我们数据在对应主成分上的投影。这个过程的主要困难在于选择正确的核函数以及配置其超参数。两种常用的核函数是多项式核
和高斯(径向基函数(RBF))核
。
Isomap
Isomap 算法基于流形投影技术。在数学中,流形是一个拓扑空间(通常是一组点及其邻居),在每一点附近局部类似于欧几里得空间。例如,一维流形包括直线和圆,但不包括有自交的图形。二维流形被称为曲面;例如,它们可以是球体、平面或环面,但这些曲面不能有自交。例如,圆是一个嵌入到二维空间中的一维流形。在这里,圆的每一段弧在局部上类似于直线段。如果一条三维曲线可以被分成可以嵌入到三维空间中且没有自交的直线段,那么它也可以是一个流形。三维形状如果其表面可以被分成没有自交的平面片,那么它也可以是一个流形。
应用流形投影技术的基本方法是寻找一个接近数据的流形,将数据投影到流形上,然后展开它。用于寻找流形的最流行技术是基于数据点信息构建一个图。通常,这些数据点被放置在图节点中,边模拟数据点之间的关系。
Isomap 算法依赖于两个参数:
-
用于搜索测地距离的邻居数量,
![]()
-
最终空间的维度,
![]()
简而言之,Isomap 算法遵循以下步骤:
-
首先,它构建一个表示测地距离的图。对于每个点,我们搜索
个最近邻,并从这些最近邻的距离构建一个加权无向图。边权重是到邻居的欧几里得距离。 -
使用寻找图中最短距离的算法,例如 Dijkstra 算法,我们需要找到每对顶点之间的最短距离。我们可以将这个距离视为流形上的测地距离。
-
基于我们在上一步中获得的成对测地距离矩阵,训练 MDS 算法。
-
MDS 算法将
维空间中的一组点与初始距离集相关联。
Sammon 映射
Sammon 映射是第一个非线性降维算法之一。与传统降维方法,如 PCA 不同,Sammon 映射并不直接定义数据转换函数。相反,它只确定转换结果(一个较小维度的特定数据集)与原始数据集结构对应的好坏程度。换句话说,它并不试图找到原始数据的最佳转换;相反,它寻找另一个低维数据集,其结构尽可能接近原始数据集。该算法可以描述如下。假设我们有一个
维向量,
。在这里,
向量定义在
维空间,
,用
表示。在
维空间中向量的距离将用
表示,在
维空间中,用
表示。为了确定向量之间的距离,我们可以使用任何度量;特别是欧几里得距离。非线性 Sammon 映射的目标是在选择一组向量
中搜索,以最小化误差函数
,该函数由以下公式定义:

为了最小化误差函数
,Sammon 使用了牛顿最小化方法,可以简化如下:

在这里,η是学习率。
分布式随机近邻嵌入
SNE 问题被表述如下:我们有一个数据集,其中的点由一个多维变量描述,其空间维度远高于三个。有必要获得一个存在于 2D 或 3D 空间的新变量,该变量将最大限度地保留原始数据中的结构和模式。t-SNE 与经典 SNE 之间的区别在于简化寻找全局最小值过程的修改。主要的修改是用 Student 的 t 分布替换低维数据的正态分布。SNE 开始时将点之间的多维欧几里得距离转换为反映点相似性的条件概率。从数学上看,它看起来是这样的:

这个公式显示了点
在以
为中心的正态分布中相对于点
的距离,给定偏差为
。
对于每个点都是不同的。它是这样选择的,使得密度较高的区域中的点比其他区域的点具有更小的方差。
让我们将 (
,
) 对的二维或三维映射表示为 (
,
) 对。使用相同的公式估计条件概率是必要的。标准差是
:

如果映射点
和
正确模拟了高维原始点
和
之间的相似性,那么相应的条件概率
和
将是等价的。作为一个对
如何反映
、发散或 Kullback-Leibler 距离的明显评估,使用梯度下降法最小化所有映射点之间的这种距离之和。以下公式确定了该方法的损失函数:

它具有以下梯度:

该问题的作者提出了以下物理类比来描述优化过程。让我们想象所有映射点之间都连接着弹簧。连接点
和
的弹簧的刚度取决于多维空间中两点相似性与映射空间中两点相似性之间的差异。在这个类比中,梯度是作用在映射空间中某点的合力。如果我们让系统自由发展,经过一段时间后,它会达到平衡,这就是我们想要的分布。算法上,它会在考虑以下因素的同时寻找平衡:

这里,
是学习率,
是惯性系数。经典 SNE 也允许我们获得良好的结果,但可能会在优化损失函数和拥挤问题时遇到困难。t-SNE 并没有解决这些问题,但使它们更容易管理。
t-SNE 中的损失函数与经典 SNE 的损失函数有两个主要区别。第一个区别是它在多维空间中具有对称的相似性形式和更简单的梯度版本。其次,对于映射空间中的点,不是使用高斯分布,而是使用 t 分布(学生分布)。
自编码器
自编码器代表一类特定的神经网络,其配置使得自编码器的输出尽可能接近输入信号。在其最直接的表现形式中,自编码器可以被建模为一个多层感知器,其中输出层的神经元数量等于输入的数量。以下图表显示,通过选择一个较小维度的中间隐藏层,我们可以将源数据压缩到较低维度。通常,这个中间层的值是自编码器的一个结果:

图 6.1 – 自编码器架构
现在我们已经了解了可用于降维的线性和非线性方法,并详细探讨了每种方法的组成部分,我们可以借助一些实际示例来增强我们的降维实现。
使用各种 C++ 库理解降维算法
让我们看看如何在实践中使用降维算法。所有这些示例都使用相同的数据集,该数据集包含四个经过瑞士卷映射(
)转换的通常分布的二维点集,进入三维空间。您可以在本书的 GitHub 仓库中找到数据集和相关细节:github.com/PacktPublishing/Hands-on-Machine-learning-with-C-Second-Edition。以下图表显示了这种映射的结果。

图 6.2 – 瑞士卷数据集
此数据集已标记。每个通常分布的部分都有自己的标签,我们可以在结果中看到这些标签以某种颜色表示。我们使用这些颜色来显示以下示例中我们将使用的每个算法的转换结果。这让我们对算法的工作原理有了概念。以下部分提供了如何使用 Dlib、Tapkee 和其他库的具体示例。
使用 Dlib 库
Dlib 库中有三种降维方法——两种线性方法,称为 PCA 和 LDA,以及一种非线性方法,称为 Sammon 映射。
PCA
PCA 是最受欢迎的降维算法之一,在Dlib库中有几个实现。有Dlib::vector_normalizer_pca类型,可以用于对用户数据进行 PCA 操作。这种实现也进行了数据归一化。在某些情况下,这种自动归一化是有用的,因为我们总是必须对归一化的数据进行 PCA。这种类型的对象应该用输入数据样本类型进行参数化。在我们实例化这种类型的对象之后,我们使用train()方法将模型拟合到我们的数据。train()方法接受std::vector作为样本,以及eps值作为参数。eps值控制 PCA 变换后应该保留多少维度。这可以在以下代码中看到:
void PCAReduction(const std::vector<Matrix> &data, double target_dim) {
// instantiante the PCA algorithm object.
Dlib::vector_normalizer_pca<Matrix> pca;
// train the PCA algorithm
pca.train(data, target_dim / data[0].nr());
// apply trained algorithm to the new data
std::vector<Matrix> new_data;
new_data.reserve(data.size());
for (size_t i = 0; i < data.size(); ++i) {
new_data.emplace_back(pca(data[i]));
}
// example how to get transformed values
for (size_t r = 0; r < new_data.size(); ++r) {
Matrix vec = new_data[r];
double x = vec(0, 0);
double y = vec(1, 0);
}
在算法训练完成后,我们使用该对象来转换单个样本。看看代码中的第一个循环,注意pca([data[i]])调用是如何执行这种转换的。
以下图表显示了 PCA 变换的结果:

图 6.3 – Dlib PCA 变换可视化
PCA 数据压缩
我们可以使用降维算法来完成一个稍微不同的任务——具有信息损失的数据压缩。当将 PCA 算法应用于图像时,这可以很容易地演示。让我们使用 SVD 分解从头实现 PCA,使用Dlib库。我们不能使用现有的实现,因为它以我们无法完全控制的方式进行归一化。
首先,我们需要加载一个图像并将其转换为矩阵形式:
void PCACompression(const std::string& image_file, long target_dim) {
array2d<Dlib::rgb_pixel> img;
load_image(img, image_file);
array2d<unsigned char> img_gray;
assign_image(img_gray, img);
save_png(img_gray, "original.png");
array2d<DataType> tmp;
assign_image(tmp, img_gray);
Matrix img_mat = Dlib::mat(tmp);
img_mat /= 255.; // scale
std::cout << "Original data size " << img_mat.size() <<
std::endl;
在我们加载 RGB 图像后,我们将其转换为灰度图,并将其值转换为浮点数。下一步是将图像矩阵转换为可用于 PCA 训练的样本。这可以通过使用Dlib::subm()函数将图像分割成 8 x 8 大小的矩形块,然后使用Dlib::reshape_to_column_vector()函数将它们展平来完成:
std::vector<Matrix> data;
int patch_size = 8;
for (long r = 0; r < img_mat.nr(); r += patch_size) {
for (long c = 0; c < img_mat.nc(); c += patch_size) {
auto sm =
Dlib::subm(img_mat, r, c, patch_size, patch_size);
data.emplace_back(Dlib::reshape_to_column_vector(sm));
}
}
当我们有了我们的样本后,我们可以通过减去平均值并除以它们的标准差来对它们进行归一化。我们可以通过将我们的样本向量转换为矩阵类型来使这些操作向量化。我们使用Dlib::mat()函数来完成这个操作:
// normalize data
auto data_mat = mat(data);
Matrix m = mean(data_mat);
Matrix sd = reciprocal(sqrt(variance(data_mat)));
matrix<decltype(data_mat)::type, 0, 1,
decltype(data_mat)::mem_manager_type>
x(data_mat);
for (long r = 0; r < x.size(); ++r)
x(r) = pointwise_multiply(x(r) - m, sd);
在我们准备好的数据样本之后,我们使用Dlib::covariance()函数计算协方差矩阵,并使用Dlib::svd()函数执行奇异值分解。SVD 的结果是特征值矩阵和特征向量矩阵。我们根据特征值对特征向量进行排序,并只留下对应于最大特征值的小部分特征向量(在我们的例子中,是 10 个)。我们留下的特征向量数量是新特征空间的维度数:
Matrix temp, eigen, pca;
// Compute the svd of the covariance matrix
Dlib::svd(covariance(x), temp, eigen, pca);
Matrix eigenvalues = diag(eigen);
rsort_columns(pca, eigenvalues);
// leave only required number of principal components
pca = trans(colm(pca, range(0, target_dim)));
我们的主成分分析转换矩阵被称为pca。我们使用它通过简单的矩阵乘法来降低每个样本的维度。看看以下循环并注意pca * data[i]操作:
// dimensionality reduction
std::vector<Matrix> new_data;
size_t new_size = 0;
new_data.reserve(data.size());
for (size_t i = 0; i < data.size(); ++i) {
new_data.emplace_back(pca * data[i]);
new_size += static_cast<size_t>(new_data.back().size());
}
std::cout << "New data size "
<< new_size + static_cast<size_t>(pca.size())
<< std::endl;
我们的数据已经被压缩,我们可以在控制台输出中看到其新的大小。现在,我们可以恢复数据的原始维度以便查看图像。为此,我们需要使用转置的 PCA 矩阵来乘以减少的样本。此外,我们需要对恢复的样本进行反归一化以获取实际的像素值。这可以通过乘以标准差并加上我们从上一步骤中得到的均值来完成:
auto pca_matrix_t = Dlib::trans(pca);
Matrix isd = Dlib::reciprocal(sd);
for (size_t i = 0; i < new_data.size(); ++i) {
Matrix sample = pca_matrix_t * new_data[i];
new_data[i] = Dlib::pointwise_multiply(sample, isd) + m;
}
在我们恢复像素值后,我们将它们重塑并放置在图像的原始位置:
size_t i = 0;
for (long r = 0; r < img_mat.nr(); r += patch_size) {
for (long c = 0; c < img_mat.nc(); c += patch_size)
{
auto sm = Dlib::reshape(new_data[i],
patch_size, patch_size);
Dlib::set_subm(img_mat, r, c, patch_size,
patch_size) = sm;
++i;
}
}
img_mat *= 255.0;
assign_image(img_gray, img_mat);
equalize_histogram(img_gray);
save_png(img_gray, "compressed.png");
}
让我们看看压缩在图像处理中广泛使用的标准测试图像的结果。以下是一个 512 x 512 像素的 Lena 图像:

图 6.4 – 压缩前的原始图像
其原始灰度大小为 262,144 字节。在我们仅使用 10 个主成分进行 PCA 压缩后,其大小变为 45,760 字节。我们可以在以下图表中看到结果:

图 6.5 – 压缩后的图像
在这里,我们可以看到,尽管压缩率很高,但大部分重要的视觉信息都被保留了。
LDA
Dlib库也实现了 LDA 算法,可用于降维。它是一个监督算法,因此需要标记的数据。此算法通过Dlib::compute_lda_transform()函数实现,该函数接受四个参数。第一个参数是输入/输出参数——作为输入,它用于传递输入训练数据(以矩阵形式),作为输出,它接收 LDA 转换矩阵。第二个参数是均值值的输出。第三个参数是输入数据的标签,第四个参数是期望的目标维度数。以下代码展示了如何使用Dlib库中的 LDA 进行降维的示例:
void LDAReduction(const Matrix &data,
const std::vector<unsigned long> &labels,
unsigned long target_dim) {
Dlib::matrix<DataType, 0, 1> mean;
Matrix transform = data;
// Apply LDA on input data,
// result with will be in the "transform object"
Dlib::compute_lda_transform(transform, mean, labels,
target_dim);
// Apply LDA "transform" to the input "data"
for (long r = 0; r < data.nr(); ++r) {
Matrix row =
transform * Dlib::trans(Dlib::rowm(data, r)) - mean;
double x = row(0, 0);
double y = row(1, 0);
}
}x`
在算法训练后执行实际的 LDA 转换,我们将样本与 LDA 矩阵相乘。在我们的情况下,我们还对它们进行了转置。以下代码展示了此示例的关键部分:
transform * Dlib::trans(Dlib::rowm(data, r))
以下图表显示了在两个组件上使用 LDA 降维的结果:

图 6.6 – Dlib LDA 转换可视化
在以下块中,我们将看到如何使用来自Dlib库的 Sammon 映射降维算法实现。
Sammon 映射
在 Dlib 库中,Sammon 映射是通过 Dlib::sammon_projection 类型实现的。我们需要创建此类型的实例,然后将其用作功能对象。功能对象调用参数是我们需要转换的数据和新的特征空间维度数。输入数据应以 std::vector 的形式提供,其中包含单个 Dlib::matrix 类型的样本。所有样本应具有相同数量的维度。使用此功能对象的结果是一个具有减少维度数的新样本向量:
void SammonReduction(const std::vector<Matrix> &data, long target_dim) {
Dlib::sammon_projection sp;
auto new_data = sp(data, target_dim);
for (size_t r = 0; r < new_data.size(); ++r) {
Matrix vec = new_data[r];
double x = vec(0, 0);
double y = vec(1, 0);
}
}
以下图表显示了使用此降维算法的结果:

图 6.7 – Dlib Sammon 映射变换可视化
在下一节中,我们将学习如何使用 Tapkee 库来解决降维任务。
使用 Tapkee 库
Tapkee 库包含许多降维算法,包括线性和非线性算法。这是一个仅包含头文件的 C++ 模板库,因此不需要编译,可以轻松集成到您的应用程序中。它有几个依赖项:用于格式化输出的 fmt 库和作为数学后端的 Eigen3。
在这个库中没有为算法设置特殊类,因为它提供了一个基于参数的统一 API 来构建降维对象。因此,使用这种方法,我们可以定义一个函数,该函数将接受一组参数和高度数据输入,并执行降维。结果将是一个二维图。以下代码示例展示了其实现:
void Reduction(tapkee::ParametersSet parameters,
bool with_kernel,
const tapkee::DenseMatrix& features,
const tapkee::DenseMatrix& lables,
const std::string& img_file) {
using namespace tapkee;
// define the kernel callback object,
// that will be applied to the input "features"
gaussian_kernel_callback kcb(features, 2.0);
// define distance callback object,
// that will be applied to the input "features"
eigen_distance_callback dcb(features);
// define the feature access callback object
eigen_features_callback fcb(features);
// save the initial features indices order
auto n = features.cols();
std::vector<int> indices(n);
for (int i = 0; i < n; ++i) indices[i] = i;
TapkeeOutput result;
if (with_kernel) {
// apply feature transformation with kernel function
result =
initialize()
.withParameters(parameters)
.withKernel(kcb)
.withFeatures(fcb)
.withDistance(dcb)
.embedRange(indices.begin(), indices.end());
} else {
// apply features transformation without kernel
// //function
result =
initialize()
.withParameters(parameters)
.withFeatures(fcb)
.withDistance(dcb)
.embedRange(indices.begin(), indices.end());
}
// create helper object for transformed data
// //visualization result
Clusters clusters;
for (index_t i = 0; i < result.embedding.rows(); ++i) {
// get a transformed feature
auto new_vector = result.embedding.row(i);
// populate visualization helper structure
auto label = static_cast<int>(lables(i));
clusters[label].first.push_back(new_vector[0]);
clusters[label].second.push_back(new_vector[1]);
}
// Visualize dimensionality reduction result
PlotClusters(clusters,
get_method_name(parameters[method]),
img_ file);
}
这是我们将使用的单个函数,通过它我们可以看到 Tapkee 库中的几个算法。它接受的主要参数是 tapkee::ParametersSet;这种类型的对象可以用降维方法类型、目标维度数和一些配置所选方法的特殊参数进行初始化。with_kernel 参数指定此函数是否将核变换附加到算法管道中。库 API 允许您为任何算法附加核变换,但它只有在算法实现使用它时才会被使用。在我们的情况下,我们只将其用于 KernelPCA 方法。降维函数的最后一个参数是输入数据、绘图标签和输出文件名。
让我们来看看实现。首先,我们定义了一个方法管道的回调函数。有高斯核回调、线性距离回调和特征回调。请注意,所有这些回调对象都是用输入数据初始化的。这样做是为了减少管道中的数据复制。库 API 要求回调对象能够为两个数据索引产生一些结果。这些回调对象应该实现获取访问原始数据的功能。因此,我们所有的回调都是基于库定义的类构建的,并存储对原始数据容器的引用。tapkee::DenseMatrix只是 Eigen3 密集矩阵的一个 typedef。另一个重要的事情是索引映射的使用,用于数据访问,例如indices变量;它允许您更灵活地使用数据。以下片段显示了降维对象创建和应用:
initialize().
withParameters(parameters).
withKernel(kcb).
withFeatures(fcb).
withDistance(dcb).
embedRange(indices.begin(), indices.end());
您可以看到 API 是一致的,执行配置的构建函数以with字开头。最初,我们将参数传递给构建函数,然后是三个回调函数,最后调用执行实际降维的embedRange方法。eigen_features_callback用于通过索引访问特定的数据值,而eigen_distance_callback在有些算法中用于测量项目向量的距离;在我们的例子中,它只是欧几里得距离,但您可以定义任何需要的。
在函数的最后部分,Clusters对象被填充了新的 2D 坐标和标签。然后,使用此对象来绘制降维结果。应用了之前章节中的绘图方法。
在以下小节中,我们将学习如何使用我们的通用函数来应用不同的降维方法。这些方法如下:
-
PCA
-
核心 PCA
-
MDS
-
Isomap
-
因子分析
-
t-分布 SNEs
PCA
在拥有降维的一般函数后,我们可以用它来应用不同的方法。以下代码展示了如何创建一个参数集来配置 PCA 方法:
bool with_kernel = false;
Reduction((method = PCA, target_dimension = target_dim),
with_kernel, input_data, labels_data,
"pca-tapkee.png");
第一个参数是tapkee::ParametersSet类型对象的初始化。我们使用了tapkee::PCA枚举值并指定了目标维度的数量。此外,我们没有使用核函数。input_data和labels_data是从文件中加载的输入数据,这些变量具有tapkee::DenseMatrix类型,实际上就是 Eigen3 密集矩阵。最后一个参数是用于绘图图像的输出文件名。
以下图表显示了将 Tapkee PCA 实现应用于我们的数据的结果:

图 6.8 – Tapkee PCA 变换可视化
您可以看到这种方法无法在 2D 空间中从空间上分离我们的 3D 数据。
核心 PCA
在 Tapkee 库中,PCA 的非线性版本也得到了实现。要使用此方法,我们定义核方法并将其作为回调传递给库 API 的 withKernel 构建方法。我们已经在我们的通用函数中做到了这一点,所以我们唯一要做的就是将 with_kernel 参数设置为 true。我们使用了高斯核,其定义如下:

在这里,ᵧ 是销售系数,可以估计为项目差异的中位数,或者手动配置。核回调函数定义如下:
struct gaussian_kernel_callback {
gaussian_kernel_callback(
const tapkee::DenseMatrix& matrix,
tapkee::ScalarType gamma)
: feature_ matrix(matrix), gamma(gamma){};
inline tapkee::ScalarType kernel(
tapkee::IndexType a, tapkee::IndexType b) const {
auto distance =
(feature_matrix.col(a) – feature_matrix.col(b))
.norm();
return exp(-(distance * distance) * gamma);
}
inline tapkee::ScalarType operator()(
tapkee::IndexType a, tapkee::IndexType b) const {
return kernel(a, b);
}
const tapkee::DenseMatrix& feature_matrix;
tapkee::ScalarType gamma{1};
}
Tapkee 要求您定义名为 kernel 的方法和函数操作符。我们的实现非常简单;这里的主要特性是将特定数据的引用存储为成员。这样做是因为库将仅使用索引来调用核运算符。对我们通用函数的实际调用如下:
bool with_kernel = true;
Reduction((method = KernelPCA, target_dimension = target_dim),
with_kernel, input_data, labels_data,
"kernel-pca-tapkee.png");
下面的图表显示了将 Tapkee 核 PCA 实现应用于我们的数据的结果:

图 6.9 – Tapkee 核 PCA 变换可视化
我们可以看到,这种核函数使得数据的一些部分被分离出来,但其他部分也被过度简化了。
MDS
要使用 MDS 算法,我们只需将方法名称传递给我们的通用降维函数。对于此算法,没有其他可配置的参数。以下示例显示了如何使用此方法:
Reduction((method = MultidimensionalScaling,
target_dimension = target_dim),
false, input_data, labels_data, "mds-tapkee.png";
下面的图表显示了将 Tapkee MDS 算法应用于我们的数据的结果:

图 6.10 – Tapkee MDS 变换可视化
您可以看到,结果与 PCA 算法非常相似,并且存在相同的问题,即该方法无法在 2D 空间中空间分离我们的数据。
Isomap
Isomap 方法可以按以下方式应用于我们的数据:
Reduction((method = Isomap, target_dimension = target_dim,
num_neighbors =100),
false, input_data, labels_data, "isomap-tapkee.png");
除了方法名称和目标维度外,还传递了 num_neighbors 参数。这是 Isomap 算法将使用的最近邻值的数量。
下面的图表显示了将 Tapkee Isomap 实现应用于我们的数据的结果:

图 6.11 – Tapkee Isomap 变换可视化
您可以看到,这种方法可以在 2D 空间中空间分离我们的数据,但簇彼此之间太近。此外,您还可以调整邻居参数的数量以获得另一种分离。
因子分析
因子分析方法可以按以下方式应用于我们的数据:
Reduction((method = FactorAnalysis,
target_dimension = target_dim,
fa_epsilon = 10e-5, max_iteration = 100),
false, input_data, labels_data,
"isomap-tapkee.png");
除了方法名称和目标维度外,还可以传递 fa_epsilon 和 max_iteration 参数。fa_epsilon 用于检查算法的收敛性。
下面的图表显示了将 Tapkee 因子分析实现应用于我们的数据的结果:

图 6.12 – Tapkee 因子分析变换可视化
这种方法也未能清楚地在我们数据的 2D 空间中分离我们的数据。
t-SNE
t-SNE 方法可以按以下方式应用于我们的数据:
Reduction((method = tDistributedStochasticNeighborEmbedding,
target_dimension = target_dim,
sne_perplexity = 30),
false, input_data, labels_data,
"tsne-tapkee.png");
除了方法名称和目标维度外,还指定了sne_perplexity参数。此参数调节算法收敛。此外,您还可以更改sne_theta值,这是学习率。
下面的图表显示了将 Tapkee t-SNE 实现应用于我们的数据的结果:

图 6.13 – Tapkee t-SNE 变换可视化
您可以看到,这种方法在我们的 2D 空间中给出了最合理的数据分离;所有簇之间都有明显的界限。
摘要
在本章中,我们了解到降维是将具有更高维度的数据转移到具有较低维度的数据新表示的过程。它用于减少数据集中相关特征的数量并提取最有信息量的特征。这种转换可以帮助提高其他算法的性能,减少计算复杂度,并生成人类可读的可视化。
我们了解到解决这个任务有两种不同的方法。一种方法是特征选择,它不会创建新的特征,而第二种方法是降维算法,它会创建新的特征集。我们还了解到降维算法有线性和非线性之分,我们应该根据我们的数据选择其中一种类型。我们看到了许多具有不同特性和计算复杂度的不同算法,尝试不同的算法来查看哪些是特定任务的最好解决方案是有意义的。请注意,不同的库对相同的算法有不同的实现,因此即使对于相同的数据,它们的结果也可能不同。
降维算法的领域是一个持续发展的领域。例如,有一个名为统一流形逼近与投影(UMAP)的新算法,它基于黎曼几何和代数拓扑。它在可视化质量方面与 t-SNE 算法竞争,但在变换完成后也保留了更多原始数据的全局结构。它也具有更高的计算效率,这使得它适合大规模数据集。然而,目前还没有 C++的实现。
在下一章中,我们将讨论分类任务及其解决方法。通常,当我们需要解决一个分类任务时,我们必须将一组对象划分为几个子组。这些子组中的对象具有一些共同属性,这些属性与其他子组的属性不同。
进一步阅读
-
维度降低技术综述:
arxiv.org/pdf/1403.2877.pdf -
维度降低的简短教程:
www.math.uwaterloo.ca/~aghodsib/courses/f06stat890/readings/tutorial_stat890.pdf -
12 种维度降低技术指南(附带 Python 代码):
www.analyticsvidhya.com/blog/2018/08/dimensionality-reduction-techniques-python/ -
协方差矩阵的几何和直观解释及其与线性变换的关系,这是理解和使用 PCA 和 SVD 的必要构建块:
datascienceplus.com/understanding-the-covariance-matrix
第七章:分类
在机器学习中,分类的任务是将一组观察(对象)根据其形式描述的分析划分为称为类别的组。在分类中,每个观察(对象)根据特定的定性属性被分配到一组或命名类别。分类是一个监督任务,因为它需要已知的类别来训练样本。训练集的标记通常是通过手动完成,并涉及该研究领域的专家。值得注意的是,如果类别最初没有定义,那么在聚类中将会出现问题。此外,在分类任务中,可能存在超过两个类别(多类别),并且每个对象可能属于多个类别(相交)。
在本章中,我们将讨论使用机器学习解决分类任务的多种方法。我们将查看一些最知名和最广泛使用的算法,包括逻辑回归、支持向量机(SVM)和k 最近邻(kNN)。逻辑回归是基于线性回归和特殊损失函数的最直接算法之一。SVM 基于支持向量的概念,有助于构建决策边界来分离数据。这种方法可以有效地用于高维数据。kNN 具有简单的实现算法,它使用数据紧凑性的想法。此外,我们还将展示如何使用前面提到的算法解决多类别分类问题。我们将实现程序示例,以了解如何使用这些算法通过不同的 C++库来解决分类任务。
本章涵盖了以下主题:
-
分类方法的概述
-
探索各种分类方法
-
使用 C++库处理分类任务的示例
技术要求
本章所需的技术和安装包括以下内容:
-
mlpack库 -
Dlib库 -
Flashlight 库
-
支持 C++20 的现代 C++编译器
-
CMake 构建系统版本 >= 3.10
本章的代码文件可以在以下 GitHub 仓库中找到:github.com/PacktPublishing/Hands-On-Machine-Learning-with-C-Second-Edition/tree/main/Chapter07.
分类方法的概述
分类是应用统计学、机器学习和人工智能(AI)中的一个基本任务。这是因为分类是最容易理解和解释的数据分析技术之一,分类规则可以用自然语言表述。在机器学习中,分类任务是通过监督算法解决的,因为类别是预先定义的,训练集中的对象有类别标签。解决分类任务的解析模型被称为分类器。
分类是将对象根据其形式化的特征移动到预定的类别的过程。在这个问题中,每个对象通常被表示为 N-维空间中的一个向量。该空间中的每个维度都是对象的一个特征的描述。
我们可以用数学符号来表述分类任务。让 X 表示对象的描述集合,Y 是一个有限的名字或类别标签集合。存在一个未知的客观函数——即映射
,其值只在最终训练样本的对象上已知,
。因此,我们必须构建一个
算法,能够对
任意对象进行分类。在数学统计学中,分类问题也被称为判别分析问题。
分类任务适用于许多领域,包括以下内容:
-
贸易:对客户和产品的分类使企业能够优化营销策略,刺激销售,并降低成本
-
电信:对订阅者的分类使企业能够评估客户忠诚度,因此开发忠诚度计划
-
医疗保健:通过将人群分类到风险组中来辅助疾病诊断
-
银行:对客户的分类用于信用评分程序
分类可以通过以下方法来解决:
-
逻辑回归
-
kNN 方法
-
SVM
-
判别分析
-
决策树
-
神经网络
我们在第六章,降维中探讨了判别分析,将其作为一个降维算法,但大多数库也提供了用于判别分析算法作为分类器的应用程序编程接口(API)。我们将在第九章,集成学习中讨论决策树,重点关注算法集成。我们还将讨论在接下来的章节第十章,用于图像分类的神经网络中的神经网络。
现在我们已经讨论了分类任务是什么,让我们来看看各种分类方法。
探索各种分类方法
现在,深度学习在分类任务中也变得越来越流行,尤其是在处理复杂和高维数据(如图像、音频和文本)时。深度神经网络可以学习数据的层次表示,从而能够进行准确的分类。在本章中,我们专注于更经典的分类方法,因为它们仍然适用,并且通常需要较少的计算资源。具体来说,我们将讨论一些分类方法,如 Logistic 回归、核岭回归(KRR)、kNN 方法和 SVM 方法。
Logistic 回归
Logistic 回归通过使用 Logistic 函数来确定分类因变量与一个或多个自变量之间的依赖程度。它旨在找到输入变量的系数值,就像线性回归一样。在 Logistic 回归的情况下,区别在于输出值是通过使用非线性(Logistic)函数转换的。Logistic 函数具有 S 形曲线,并将任何值转换为0到1之间的数字。这一特性很有用,因为我们可以将规则应用于 Logistic 函数的输出,将0和1绑定到类预测。以下截图显示了 Logistic 函数的图形:

图 7.1 – Logistic 函数
例如,如果函数的结果小于0.5,则输出为0。预测不仅仅是简单的答案(+1或-1),我们还可以将其解释为被分类为+1的概率。
在许多任务中,这种解释是一个基本的企业需求。例如,在信用评分任务中,Logistic 回归传统上被使用,贷款违约的概率是一个常见的预测。与线性回归的情况一样,如果移除了异常值和相关性变量,Logistic 回归的性能会更好。Logistic 回归模型可以快速训练,非常适合二元分类问题。
线性分类器的基本思想是特征空间可以通过一个超平面分为两个半空间,在每个半空间中预测目标类的两个值之一。如果我们能够无错误地划分特征空间,那么训练集被称为线性可分。Logistic 回归是一种独特的线性分类器,但它能够预测
的概率,将
的例子归为类+,如图所示:

考虑二元分类任务,目标类的标签用 +1(正例)和 -1(反例)表示。我们想要预测
的概率;因此,现在我们可以使用以下优化技术构建线性预测:
。那么,我们如何将得到的值转换为概率,其极限为 [0, 1]?这种方法需要一个特定的函数。在逻辑回归模型中,用于此的特定函数
被使用。
让我们用 P(X) 表示事件 X 发生的概率。概率优势比 OR(X) 由
确定。这是事件发生与否的概率比。我们可以看到,概率和优势比都包含相同的信息。然而,虽然 P(X) 的范围是 0 到 1,OR(X) 的范围是 0 到
。如果你计算 OR(X) 的对数(称为 优势对数 或 概率比的对数),很容易看出以下适用:
。
使用逻辑函数预测
的概率,可以从概率比(暂时假设我们也有权重)中得到如下:

因此,逻辑回归预测将样本分类到 "+" 类的概率,作为模型权重向量与样本特征向量的线性组合的 sigmoid 变换,如下所示:

从最大似然原理中,我们可以得到逻辑回归解决的优化问题——即最小化逻辑损失函数。对于 "-" 类,概率由一个类似的公式确定,如图所示:

两个类的表达式可以合并为一个,如图所示:

在这里,表达式
被称为
对象的分类边缘。分类边缘可以理解为模型对对象分类的 置信度。这个边缘的解释如下:
-
如果边缘向量的绝对值很大且为正,则类标签设置正确,对象远离分离超平面。因此,这样的对象被自信地分类。
-
如果边缘很大(通过模运算)但为负,则类标签设置错误。对象远离分离超平面。这样的对象很可能是异常值。
-
如果边缘很小(通过模运算),则对象接近分离超平面。在这种情况下,边缘符号决定了对象是否被正确分类。
在离散情况下,似然函数
可以解释为样本 X1 , . . . , Xn 等于 x1 , . . . , xn 在给定实验集中的概率。此外,这个概率依赖于 θ,如图所示:

对于未知参数
的最大似然估计
被称为
的值,此时函数
达到其最大值(θ 固定时作为 θ 的函数),如图所示:

现在,我们可以写出样本的似然函数——即在样本
中观察到给定向量
的概率。我们做出一个假设——对象独立地从单个分布中出现,如图所示:

让我们取这个表达式的对数,因为求和比求积更容易优化,如下所示:

在这种情况下,最大化似然的原则导致表达式的最小化,如图所示:

这个公式是逻辑损失函数,对所有训练样本中的对象进行求和。通常,给模型添加一些正则化来处理过拟合是一个好主意。逻辑回归的L2 正则化与岭回归(带有正则化的线性回归)的安排方式大致相同。然而,在 SVM 模型中,通常使用控制变量衰减参数 C,其中它表示软边界参数的表示。因此,对于逻辑回归,C 等于逆正则化系数
。C 和
之间的关系如下:降低 C 会增强正则化效果。因此,而不是最小化函数
,应该最小化以下函数:

.
对于这个函数最小化问题,我们可以应用不同的方法——例如,最小二乘法,或者梯度下降方法。逻辑回归的关键问题是它通常是一个线性分类器,为了处理非线性决策边界,通常使用以原始特征为基础的多项式特征。这种方法在我们讨论多项式回归时在第三章中进行了讨论。
KRR
KRR 结合了线性岭回归(线性回归和 L2 范数正则化)与核技巧,可用于分类问题。它学习在由所选核和训练数据产生的更高维空间中的线性函数。对于非线性核,它在原始空间中学习非线性函数。
KRR 学习到的模型与 SVM 模型相同,但这些方法有以下不同之处:
-
KRR 方法使用平方误差损失,而 SVM 模型在分类时使用不可分损失或铰链损失。
-
与 SVM 方法相比,KRR 的训练可以以闭式形式完成,因此对于中等大小的数据集可以更快地训练。
-
学习到的 KRR 模型是非稀疏的,在预测时间上可能比 SVM 模型慢。
尽管有这些不同之处,但两种方法通常都使用 L2 正则化。
SVM
SVM 方法是一组用于分类和回归分析任务的算法。考虑到在N维空间中,每个对象属于两个类别之一,SVM 生成一个(N-1)维超平面来将这些点分为两组。它类似于纸上描绘的两个不同类型点可以线性划分的图示。此外,SVM 选择具有最大距离于最近组元素的超平面。
输入数据可以使用各种超平面进行分离。最佳超平面是具有最大分离结果和两个类别之间最大差异的超平面。
想象平面上数据点。在以下情况下,分离器只是一条直线:

图 7.2 – 点分离线
让我们画出将点分为两集的明显直线。然后,选择尽可能远离点的直线,最大化它到每一边最近点的距离。如果存在这样的线,则称为最大间隔超平面。直观上,由于超平面本身(它具有最长距离到任何类别的训练样本的最近点),因此,一般来说,距离越大,分类器误差越小。
考虑给定的学习样本集合
,其中包含
个对象,每个对象有
个参数,其中
取值为-1 或 1,从而定义了点的类别。每个点
是一个维度为
的向量。我们的任务是找到分离观察值的最大间隔超平面。我们可以使用解析几何来定义任何超平面为满足条件的点集
,如图所示:

这里,
和
。
因此,线性分离(判别)函数由方程 g(x)=0 描述。点到分离函数 g(x)=0(点到平面的距离)等于以下:

.
位于边界
的闭集中。边界,即分隔带的宽度,需要尽可能大。考虑到边界的闭集满足条件
,那么
的距离如下所示:

.
因此,分隔带的宽度是
。为了排除分隔带中的点,我们可以写出以下条件:

让我们再介绍一个索引函数
,它显示了
属于哪个类别,如下所示:

因此,选择一个生成最大宽度走廊的分离函数的任务可以写成如下:

J(w) 函数是在假设
对所有
成立的情况下引入的。由于目标函数是二次的,这个问题有一个唯一解。
根据库恩-塔克定理,这个条件等价于以下问题:

.
这是在假设
和
,其中
,是新的变量的情况下提供的。我们可以将
以矩阵形式重写,如下所示:

矩阵的 H 系数可以按照以下方式计算:

二次规划方法可以解决
任务。
在找到每个
的最优
之后,满足以下两个条件之一:
-
(i 对应于非支持向量) -
(i 对应于支持向量)
然后,从关系
中找到
,并确定
的值,考虑到对于任何
和
,如下所示:

因此,我们可以根据提供的条件使用提供的公式来计算 w0。
最后,我们可以获得判别函数,如图所示:

注意,求和不是对所有向量进行的,而只是对集合 S 进行的,S 是支持向量
的集合。
很不幸,所描述的算法仅适用于线性可分的数据集,而这种数据集本身出现的频率相当低。处理线性不可分数据有两种方法。
其中一种被称为软边界,它选择一个尽可能纯粹(最小化错误)地分割训练样本的超平面,同时最大化到训练数据集中最近点的距离。为此,我们必须引入额外的变量,
,这些变量表征了每个对象 xi 上的错误大小。此外,我们还可以将总误差的惩罚引入目标泛函中,如下所示:

在这里,
是一个方法调整参数,允许你调整最大化分割条带宽度和最小化总误差之间的关系。对于相应的对象 xi,惩罚
的值取决于对象 xi 相对于分割线的位置。因此,如果 xi 位于判别函数的对面,则我们可以假设惩罚
的值,如果 xi 位于分割条带中,并且来自其类别。因此,相应的权重是
。在理想情况下,我们假设
。然后,可以将该问题重新表述如下:

注意,非理想情况下的元素也参与了最小化过程,如下所示:

在这里,常数 β 是考虑条带宽度的权重。如果 β 很小,那么我们可以允许算法在非理想位置(分割条带中)定位相对较多的元素。如果 β 很大,那么我们要求在非理想位置(分割条带中)的元素数量很少。不幸的是,由于
的不连续性,最小化问题相当复杂。相反,我们可以使用以下内容的优化:

这是在以下限制
下发生的,如下所示:

在类别的线性分离不可能的情况下,SVM 方法的一个想法是过渡到更高维度的空间,在那里这种分离是可能的。虽然原始问题可以在有限维空间中表述,但往往发生的情况是,用于判别的样本在这个空间中不是线性可分的。因此,建议将原始的有限维空间映射到更大的维度空间,这使得分离变得容易得多。为了保持计算负载合理,支持向量算法中使用的映射提供了在原始空间变量(特别是核函数)方面的计算便利。
首先,选择映射函数
将
的数据映射到更高维度的空间。然后,可以写出形式为
的非线性判别函数。该方法的思想是找到核函数
并最大化目标函数,如图所示:

在这里,为了最小化计算,没有使用将数据直接映射到更高维度的空间。相反,使用了一种称为核技巧的方法——即 K(x, y),这是一个核矩阵。核技巧是机器学习算法中使用的一种方法,用于将非线性数据转换到更高维度的空间,使其成为线性可分的。这允许使用线性算法来解决非线性问题,因为它们通常更简单且计算效率更高(参见第六章中对核技巧的详细解释)。
通常,方法选择的支撑向量越多,其泛化能力越好。任何不构成支撑向量的训练示例,如果出现在测试集中,都会被正确分类,因为正负示例之间的边界仍然在同一位置。因此,支持向量方法预期的错误率通常等于支撑向量示例的比例。随着测量数量的增加,这个比例也会增加,因此该方法并非不受维度的诅咒,但它比大多数算法更能抵抗这种诅咒。
值得注意的是,支持向量方法对噪声和数据标准化很敏感。
此外,SVM 方法不仅限于分类任务,还可以适应解决回归任务。因此,通常可以使用相同的 SVM 软件实现来解决分类和回归任务。
kNN 方法
kNN 是一种流行的分类方法,有时也用于回归问题。它是分类的最自然方法之一。该方法的核心是通过其邻居中最普遍的类别来对当前项进行分类。形式上,该方法的基础是紧致性假设:如果成功阐明示例之间的距离度量,则相似的示例更有可能属于同一类别。例如,如果你不知道在蓝牙耳机广告中指定什么类型的产品,你可以找到五个类似的头盔广告。如果其中四个被分类为 配件,只有一个被分类为 硬件,常识会告诉你你的广告可能应该放在 配件 类别中。
通常,为了对对象进行分类,必须按顺序执行以下操作:
-
计算对象到训练数据集中其他对象的距离。
-
选择与被分类对象距离最小的 k 个训练对象。
-
将分类对象的类别设置为在最近的 k 邻居中最常出现的类别。
如果我们将最近邻的数量 k = 1,那么算法就会失去泛化能力(即,无法对算法中未遇到过的数据进行正确的结果),因为新项目被分配到最近的类别。如果我们设置得太高,那么算法可能不会揭示许多局部特征。
计算距离的函数必须满足以下规则:
-
![]()
-
仅当 x = y 时 -
![]()
-
当 x、y 和 z 点不在一条直线上时
在这种情况下,x、y 和 z 是比较对象的特征向量。对于有序属性值,可以应用欧几里得距离,如下所示:

在这种情况下,n 是属性的数量。
对于无法排序的字符串变量,可以使用差分函数,其设置如下:

在寻找距离时,有时会考虑属性的重要性。通常,属性的相关性可以通过专家或分析师的主观判断来确定,这基于他们的经验、专业知识和问题解释。在这种情况下,总和差异的每个 i 次方乘以系数 Zi。例如,如果属性 A 比属性
(
,
) 重要三倍,那么距离的计算如下:

这种技术被称为拉伸坐标轴,这可以减少分类误差。
对于分类对象的选择也可以不同,并且有两种主要方法来做出这种选择:无权投票和加权投票。
对于无权投票,我们通过指定k的数量来确定在分类任务中有多少对象有投票权。我们通过它们到新对象的最近距离来识别这些对象。对每个对象的单独距离对于投票不再至关重要。在类定义中,所有对象都有平等的权利。每个现有对象都会为它所属的类投票。我们将获得最多投票的类分配给新对象。然而,如果几个类获得相同数量的投票,可能会出现问题。加权投票解决了这个问题。
在加权投票过程中,我们也会考虑新对象的距离。距离越小,投票的贡献就越大。对类的投票公式如下:

在这种情况下,
是已知对象
到新对象
的距离的平方,而
是计算投票的已知对象的数量。class是类的名称。新对象对应于获得最多投票的类。在这种情况下,几个类获得相同数量投票的概率要低得多。当
时,新对象被分配到最近邻的类。
kNN 方法的一个显著特点是它的惰性。惰性意味着计算仅在分类的瞬间开始。当使用 kNN 方法进行训练样本时,我们不仅构建模型,同时也会进行样本分类。请注意,最近邻方法是一个研究得很好的方法(在机器学习、计量经济学和统计学中,只有线性回归更为人所知)。对于最近邻方法,有相当多的关键定理表明,在无限样本上,kNN 是最佳分类方法。经典书籍《统计学习基础》的作者认为 kNN 是一个理论上理想的算法,其适用性仅受计算能力和维度的诅咒所限制。
kNN 是最简单的分类算法之一,因此在现实世界的任务中往往效果不佳。KNN 算法有几个缺点。除了在没有足够样本的情况下分类精度低之外,kNN 分类器的另一个问题是分类速度:如果训练集中有 N 个对象,空间维度是 K,那么对测试样本进行分类的操作次数可以估计为
。用于算法的数据集必须是具有代表性的。模型不能与数据分离:要分类一个新示例,你需要使用所有示例。
正面特征包括算法对异常离群值的抵抗力,因为这种记录落入 kNN 数量中的概率很小。如果发生这种情况,那么对投票(唯一加权)的影响
也可能是不显著的,因此对分类结果的影响也较小。算法的程序实现相对简单,算法结果易于解释。因此,应用领域的专家可以根据找到相似对象来理解算法的逻辑。通过使用最合适的函数和度量组合来修改算法的能力,你可以调整算法以适应特定任务。
多类分类
大多数现有的多类分类方法要么基于二元分类器,要么被简化为它们。这种方法的总体思路是使用一组训练有素的二元分类器,以将不同组对象彼此分离。在这种多类分类中,使用各种投票方案对一组二元分类器进行投票。
在针对 N 个类的 一对一 策略中,训练 N 个分类器,每个分类器将其类别与其他所有类别分离。在识别阶段,未知向量 X 被输入到所有 N 个分类器中。向量 X 的成员资格由给出最高估计的分类器确定。这种方法可以解决当出现类别不平衡问题时的问题。即使多类分类的任务最初是平衡的(即,每个类别的训练样本数量相同),当训练二元分类器时,每个二元问题的样本数量比例会随着类别数量的增加而增加,这因此显著影响了具有显著数量类别的任务。
每个对抗每个策略分配
个分类器。这些分类器被训练来区分所有可能的类别对。对于输入向量,每个分类器给出一个关于
的估计,反映其在
和
类别中的成员资格。结果是具有最大和
的类别,其中 g 是单调不减函数——例如,相同或逻辑函数。
射击锦标赛策略也涉及训练
个分类器,这些分类器区分所有可能的类别对。与之前的策略不同,在向量 X 的分类阶段,我们安排了类别之间的锦标赛。我们创建一个锦标赛树,其中每个类别都有一个对手,只有获胜者才能进入下一轮锦标赛。因此,在每一步,只有一个分类器确定向量 X 的类别,然后使用获胜的类别来确定下一对类别的下一个分类器。这个过程一直进行到只剩下一个获胜的类别,这应该被视为结果。
一些方法可以立即产生多类分类,无需额外的配置和组合。kNN 算法或神经网络可以被认为是此类方法的例子。
此外,逻辑回归可以通过使用 softmax 函数来推广到多类情况。softmax 函数用于确定一个样本属于特定类别的概率,其形式如下:

在这里,K 是可能的类别数量,而 theta 是一个可学习参数的向量。当 K=2 时,这个表达式简化为逻辑回归:

用单个参数向量替换向量差:

我们可以看到,对于一类,其概率将按以下方式预测:

对于第二类,它将是以下内容:

如您所见,这些表达式等同于我们之前看到的逻辑回归。
现在我们已经熟悉了一些最广泛使用的分类算法,让我们看看如何在不同的 C++ 库中使用它们。
使用 C++ 库处理分类任务的示例
现在我们来看看如何使用我们描述的方法来解决人工数据集上的分类任务,这些数据集可以在下面的屏幕截图中看到:

图 7.3 – 人工数据集
如我们所见,这些数据集包含两种和三种不同的对象类别,因此使用多类分类方法是有意义的,因为这类任务在现实生活中出现得更频繁;它们可以很容易地简化为二分类。
分类是一种监督技术,因此我们通常有一个训练数据集,以及用于分类的新数据。为了模拟这种情况,在我们的示例中我们将使用两个数据集,一个用于训练,一个用于测试。它们来自一个大型数据集中的相同分布。然而,测试集不会用于训练;因此,我们可以评估准确度指标,并查看模型的表现和泛化能力如何。
使用 mlpack 库
在本节中,我们展示如何使用mlpack库来解决分类任务。这个库为三种主要的分类算法提供了实现:逻辑回归、softmax 回归和 SVM。
使用 softmax 回归
mlpack库在SoftmaxRegression类中实现了多类逻辑回归。使用这个类非常简单。我们必须使用用于训练的样本数量和类的数量初始化一个对象。假设我们有以下对象作为训练数据和标签:
using namespace mlpack;
size_t num_classes;
arma::mat train_input;
arma::Row<size_t> train_labels;
然后,我们可以如下初始化一个SoftmaxRegression对象:
SoftmaxRegression smr(train_input.n_cols, num_classes);
在我们有了分类器对象之后,我们可以对其进行训练,并对一些新数据应用分类函数。以下代码片段显示了如何实现:
smr.Train(train_input, train_labels, num_classes);
arma::Row<size_t> predictions;
smr.Classify(test_input, predictions);
获得预测向量后,我们可以使用本书中基于plotcpp库的技术将其可视化。请注意,Train和Classify方法需要size_t类型的类标签。以下截图显示了将mlpack实现的 softmax 回归算法应用于我们的数据集的结果:

图 7.4 – 使用 mlpack 的 softmax 分类
注意到我们在Dataset 0、Dataset 1和Dataset 2数据集中存在分类错误,而其他数据集的分类几乎正确。
使用 SVMs
mlpack库在LinearSVM类中也有多类 SVM 算法的实现。该库为所有分类算法提供了几乎相同的 API,因此分类器对象的初始化与上一个示例大致相同。主要区别在于您可以使用不带参数的构造函数。因此,对象初始化如下:
mlpack::LinearSVM<> lsvm;
然后,我们使用Train方法训练分类器,并使用Classify方法对新数据样本进行应用,如下所示:
lsvm.Train(train_input, train_labels, num_classes);
arma::Row<size_t> predictions;
lsvm.Classify(test_input, predictions);
以下截图显示了将mlpack实现的 SVM 算法应用于我们的数据集的结果:

图 7.5 – 使用 mlpack 的 SVM 分类
您可以看到我们从 SVM 方法得到的结果与使用 softmax 回归得到的结果几乎相同。
使用线性回归算法
mlpack 库还在 LogisticRegression 类中实现了经典的逻辑回归算法。这个类的对象只能用于将样本分类为两个类别。使用 API 与 mlpack 库之前的示例相同。这个类的典型应用如下:
using namespace mlpack;
LogisticRegression<> lr;
lr.Train(train_input, train_labels);
arma::Row<size_t> predictions;
lr.Classify(test_input, predictions);
以下截图显示了将两类逻辑回归应用于我们的数据集的结果:

图 7.6 – 使用 mlpack 的逻辑回归分类
您可以看到,我们只为 Dataset 3 和 Dataset 4 获得了合理的分类,因为它们可以用直线分开。然而,由于两类的限制,我们无法获得正确的结果。
使用 Dlib 库
Dlib 库没有很多分类算法。其中有两个是最适用的:KRR 和 SVM。这些方法被实现为二元分类器,但对于多类分类,这个库提供了 one_vs_one_trainer 类,它实现了投票策略。请注意,这个类可以使用不同类型的分类器,这样您可以将 KRR 和 SVM 结合起来进行一个分类任务。我们还可以指定哪些分类器应该用于哪些不同的类别。
使用 KRR
以下代码示例展示了如何使用 Dlib 的 KRR 算法实现进行多类分类:
void KRRClassification(const Samples& samples,
const Labels& labels,
const Samples& test_samples,
const Labels& test_labels) {
using OVOtrainer = one_vs_one_trainer<
any_trainer<SampleType>>;
using KernelType = radial_basis_kernel<SampleType>;
krr_trainer<KernelType> krr_trainer;
krr_trainer.set_kernel(KernelType(0.1));
OVOtrainer trainer;
trainer.set_trainer(krr_trainer);
one_vs_one_decision_function<OVOtrainer> df =
trainer.train(samples, labels);
// process results and estimate accuracy
DataType accuracy = 0;
for (size_t i = 0; i != test_samples.size(); i++) {
auto vec = test_samples[i];
auto class_idx = static_cast<size_t>(df(vec));
if (static_cast<size_t>(test_labels[i]) == class_idx)
++accuracy;
// ...
}
accuracy /= test_samples.size();
}
首先,我们初始化了 krr_trainer 类的对象,然后使用核对象的实例配置它。在这个例子中,我们使用了 radial_basis_kernel 类型作为核对象,以处理无法线性分离的样本。在获得二元分类器对象后,我们初始化了 one_vs_one_trainer 类的实例,并使用 set_trainer() 方法将这个分类器添加到其堆栈中。然后,我们使用 train() 方法来训练我们的多类分类器。与 Dlib 库中的大多数算法一样,这个算法假设训练样本和标签具有 std::vector 类型,其中每个元素具有 matrix 类型。train() 方法返回一个决策函数——即作为函数对象的行为的对象,它接受一个单独的样本并返回其分类标签。这个决策函数是 one_vs_one_decision_function 类型的对象。以下代码片段展示了我们如何使用它:
auto vec = test_samples[i];
auto class_idx = static_cast<size_t>(df(vec));
Dlib 库中没有对准确度指标进行明确的实现;因此,在这个例子中,准确度直接计算为正确分类的测试样本数与总测试样本数的比率。
以下截图显示了将 Dlib 的 KRR 算法实现应用于我们的数据集的结果:

图 7.7 – 使用 Dlib 的 KRR 分类
注意到 KRR 算法在所有数据集上进行了正确的分类。
使用 SVM
以下代码示例展示了如何使用 Dlib SVM 算法实现进行多类分类:
void SVMClassification(const Samples& samples,
const Labels& labels,
const Samples& test_samples,
const Labels& test_labels) {
using OVOtrainer = one_vs_one_trainer<
any_trainer<SampleType>>;
using KernelType = radial_basis_kernel<SampleType>;
svm_nu_trainer<KernelType> svm_trainer;
svm_trainer.set_kernel(KernelType(0.1));
OVOtrainer trainer;
trainer.set_trainer(svm_trainer);
one_vs_one_decision_function<OVOtrainer> df =
trainer.train(samples, labels);
// process results and estimate accuracy
DataType accuracy = 0;
for (size_t i = 0; i != test_samples.size(); i++) {
auto vec = test_samples[i];
auto class_idx = static_cast<size_t>(df(vec));
if (static_cast<size_t>(test_labels[i]) == class_idx)
++accuracy;
// ...
}
accuracy /= test_samples.size();
}
此示例表明 Dlib 库也有一个统一的 API 用于使用不同的算法,与前一个示例的主要区别是二分类器的对象。对于 SVM 分类,我们使用了 svm_nu_trainer 类型的对象,该对象还配置了 radial_basis_kernel 类型的核对象。
以下截图展示了将 Dlib SVM 算法的实现应用于我们的数据集的结果:

图 7.8 – 使用 Dlib 的 SVM 分类
您可以看到 Dlib SVM 算法的实现也在所有数据集上进行了正确的分类,因为 mlpack 对同一算法的实现由于线性特性在某些情况下进行了错误的分类。
使用 Flashlight 库
Flashlight 库没有为分类算法提供任何特殊的类。但通过使用库的线性代数原语和自动微分功能,我们可以从头实现逻辑回归算法。此外,为了处理非线性可分的数据集,我们将实现核技巧方法。
使用逻辑回归
以下示例展示了如何使用 Flashlight 库实现二类分类。让我们定义一个用于训练线性分类器的函数;它将具有以下签名:
fl::Tensor train_linear_classifier(
const fl::Tensor& train_x,
const fl::Tensor& train_y, float learning_rate);
train_x 和 train_y 是训练样本及其相应的标签。函数的结果是学习到的参数向量——在我们的例子中,使用 fl::Tensor 类定义。我们将使用批梯度下降算法来学习参数向量。因此,我们可以使用 Flashlight 数据集类型来简化对训练数据批次的处理。以下代码片段展示了我们如何创建一个数据集对象,它将允许我们遍历批次:
std::vector<fl::Tensor> fields{train_x, train_y};
auto dataset = std::make_shared<fl::TensorDataset>(fields);
int batch_size = 8;
auto batch_dataset = std::make_shared<fl::BatchDataset>(
dataset, batch_size);
首先,我们使用 fl::TensorDataset 类型定义了常规数据集对象,然后我们使用它创建了 fl::BatchDataset 类型的对象,该对象还初始化了批次大小值。
接下来,我们需要初始化我们将使用梯度下降法学习的参数向量,如下所示:
auto weights = fl::Variable(fl::rand({
train_x.shape().dim(0), 1}), /*calcGrad=*/true);
注意我们明确地将 true 值作为最后一个参数传递,以启用 Flashlight autograd 机制进行梯度计算。现在,我们准备好使用预定义的周期数定义训练周期。在每个周期中,我们将遍历数据集中的所有批次。因此,这样的周期可以定义为以下:
int num_epochs = 100;
for (int e = 1; e <= num_epochs; ++e) {
fl::Tensor epoch_error = fl::fromScalar(0);
for (auto& batch : *batch_dataset) {
auto x = fl::Variable(batch[0], /*calcGrad=*/false);
auto y = fl::Variable(
fl::reshape(batch[1], {1, batch[1].shape().dim(0)}),
/*calcGrad=*/false);
}
}
您可以看到两个嵌套循环:外层循环用于 epoch,内层循环用于批次。在for循环中使用的batch_dataset对象与 C++基于范围的 for 循环构造兼容,因此它很容易用来访问批次。此外,请注意,我们定义了两个变量x和y,其类型与权重相同,为fl::Variable类型。使用这种类型使得将张量值传递到自动微分机制成为可能。对于这些变量,我们没有配置梯度计算,因为它们不是可训练的参数。另一个重要问题是,我们使用了fl::reshape来使所有张量形状与损失函数计算中将要应用的矩阵乘法兼容。逻辑回归损失函数如下所示:

在代码中,我们可以用以下几行来实现它:
auto z = fl::matmul(fl::transpose(weights), x);
auto loss = fl::sum(fl::log(1 + fl::exp(-1 * y * z)), /*axes=*/{1});
在我们得到损失值后,我们可以应用梯度下降算法,根据当前训练样本批次的影响来纠正权重(参数向量)。以下代码片段展示了如何实现它:
loss.backward();
weights.tensor() -= learning_rate *weights.grad().tensor();
weights.zeroGrad();
注意到梯度归零的最后一步是为了使从下一个训练样本中学习新内容成为可能,而不是混合梯度。在训练周期结束时,可以从函数中返回结果参数向量,如下所示:
return weights.tensor();
以下示例展示了如何使用我们的训练函数:
fl::Tensor train_x;
fl::Tensor train_y;
auto weights = train_linear_classifier(
train_x, train_y, /*learning_rate=*/0.1f);
在获得学习参数向量后,我们可以用它来分类新的数据样本,如下所示:
fl::Tensor sample;
constexpr float threshold = 0.5;
auto p = fl::sigmoid(fl::matmul(fl::transpose(weights), sample));
if (p.scalar<float>() > threshold)
return 1;
else
return 0;
您可以看到我们将返回结果的逻辑函数调用实现了到p变量中。这个变量的值可以解释为样本属于特定类的事件的概率。我们引入了threshold变量来检查概率。如果它大于这个阈值,那么我们将样本分类为类1;否则,它属于类0。
以下截图显示了将 Flashlight 实现的逻辑回归算法应用于我们的具有两个类别的数据集的结果:

图 7.9 – 使用 Flashlight 进行逻辑回归分类
您可以看到,它未能正确分类数据集 0和数据集 1(具有非线性类边界),但成功地将数据集 4分类为线性可分。
使用逻辑回归和核技巧
为了解决非线性类边界的问题,我们可以应用核技巧。让我们看看我们如何在 Flashlight 库中使用高斯核来实现它。想法是将我们的数据样本移动到更高维的空间中,在那里它们可以线性可分。高斯核函数如下所示:

为了使我们的计算更加高效,我们可以按以下方式重写它们:

以下代码示例展示了该公式的实现:
fl::Tensor make_kernel_matrix(const fl::Tensor& x,
const fl::Tensor& y, float gamma) {
auto x_norm = fl::sum(fl::power(x, 2), /*axes=*/{-1});
x_norm = fl::reshape(x_norm, {x_norm.dim(0), 1});
auto y_norm = fl::sum(fl::power(y, 2), /*axes=*/{-1});
y_norm = fl::reshape(y_norm, {1, y_norm.dim(0)});
auto k = fl::exp(-gamma * (x_norm + y_norm -
2 * fl::matmul(fl::transpose(x), y)));
return k;
}
make_kernel_matrix 函数接受两个矩阵,并应用高斯核,返回单个矩阵。让我们看看我们如何将其应用于我们的问题。首先,我们将其应用于我们的训练数据集,如下所示:
constexpr float rbf_gamma = 100.f;
auto kx = make_kernel_matrix(train_x, train_x, rbf_gamma);
注意到函数对两个参数使用了相同的 train_x 值。因此,我们根据这个训练数据集将我们的训练数据集移动到更高维的空间。在这个例子中,gamma 是一个手动配置的缩放超参数。有了这个转换后的数据集,我们可以使用我们在上一个例子中创建的函数来训练一个分类器,如下所示:
auto kweights = train_linear_classifier(kx, train_y, learning_rate);
然后,为了使用这些权重(参数向量),我们应该以下述方式将核函数应用于新的数据样本:
fl::Tensor sample;
auto k_sample = make_kernel_matrix(fl::reshape(sample, {
sample.dim(0), 1}), train_x, rbf_gamma);
你可以看到我们使用了重塑的新样本作为第一个参数,训练集张量作为第二个参数。因此,我们根据原始训练数据将新样本转换到更高维的空间,以保持相同的空间属性。然后,我们可以像上一个例子一样应用相同的分类程序,如下所示:
constexpr float threshold = 0.5;
auto p = fl::sigmoid(fl::matmul(fl::transpose(kweights),
fl::transpose(k_sample)));
if (p.scalar<float>() > threshold)
return 1;
else
return 0;
你可以看到我们只是使用了转换后的权重张量和转换后的样本。
以下截图显示了将带有核技巧的逻辑回归应用于我们的双类数据集的结果:

图 7.10 – 使用 Flashlight 的带有核技巧的逻辑回归分类
你可以看到,使用核技巧的逻辑回归成功地对具有非线性类别边界的数据进行分类。
摘要
在本章中,我们讨论了用于解决分类任务的监督机器学习方法。这些方法使用训练好的模型根据其特征确定对象的类别。我们考虑了两种二分类方法:逻辑回归和 SVMs。我们探讨了多类分类的实现方法。
我们看到处理非线性数据需要算法和它们的调整的额外改进。分类算法的实现因性能、所需内存量和学习所需时间而异。因此,分类算法的选择应受特定任务和业务需求指导。此外,它们在不同库中的实现可能产生不同的结果,即使是相同的算法。因此,为你的软件拥有几个库是有意义的。
在下一章中,我们将讨论推荐系统。我们将了解它们是如何工作的,有哪些算法用于实现它们,以及如何训练和评估它们。在最简单的意义上,推荐系统用于预测用户可能感兴趣的对象(商品或服务)。这样的系统在许多在线商店,如亚马逊,或流媒体网站,如 Netflix 上都可以看到,它们根据你的先前消费推荐新的内容。
进一步阅读
-
逻辑回归——详细概述:
towardsdatascience.com/logistic-regression-detailed-overview-46c4da4303bc -
通过示例理解 SVM 算法(附带代码):
www.analyticsvidhya.com/blog/2017/09/understaing-support-vector-machine-example-code/ -
理解支持向量机:入门指南:
appliedmachinelearning.wordpress.com/2017/03/09/understanding-support-vector-machines-a-primer/ -
支持向量机:核技巧;梅尔策的定理:
towardsdatascience.com/understanding-support-vector-machine-part-2-kernel-trick-mercers-theorem-e1e6848c6c4d -
支持向量机——核和核技巧:
cogsys.uni-bamberg.de/teaching/ss06/hs_svm/slides/SVM_Seminarbericht_Hofmann.pdf -
K-Nearest-Neighbors 的完整指南及其在 Python 和 R 中的应用:
kevinzakka.github.io/2016/07/13/k-nearest-neighbor
第八章:推荐系统
推荐系统是设计用来使用数据预测用户可能感兴趣的对象(商品或服务)的算法、程序和服务。主要有两种类型的推荐系统:基于内容的和协同过滤。基于内容的推荐系统基于从特定产品收集的数据。它们向用户推荐与用户之前获取或表现出兴趣的对象相似的对象。协同过滤推荐系统根据其他类似用户的反应历史过滤掉用户可能喜欢的对象。它们通常还会考虑用户的先前反应。
在本章中,我们将学习如何根据内容和协同过滤实现推荐系统算法。我们将讨论实现协同过滤算法的不同方法,使用仅包含线性代数库的系统实现系统,并学习如何使用mlpack库解决协同过滤问题。我们将使用明尼苏达大学计算机科学与工程学院一个研究实验室提供的 MovieLens 数据集:grouplens.org/datasets/movielens/。
本章将涵盖以下主题:
-
推荐系统算法概述
-
理解协同过滤方法
-
基于 C++的基于物品的协同过滤示例
技术要求
为了完成本章,你需要以下内容:
-
Eigen库 -
Armadillo库 -
mlpack库 -
支持 C++20 的现代 C++编译器
-
CMake 构建系统版本 >= 3.10
本章的代码文件可以在本书的 GitHub 仓库中找到:github.com/PacktPublishing/Hands-On-Machine-Learning-with-C-Second-Edition/tree/main/Chapter08。
推荐系统算法概述
推荐系统的任务是通知用户在特定时间可能对他们最有兴趣的对象。通常,这样的对象是产品或服务,但它也可能是信息——例如,以推荐新闻文章的形式。
在我们深入探讨推荐系统的技术细节之前,让我们看看一些实际场景,在这些场景中,推荐系统被用来改善用户体验并增加销售额。以下是最常见的应用:
-
推荐系统帮助在线零售商根据客户的过去购买、浏览历史和其他数据建议可能感兴趣的产品。这有助于客户更容易地找到相关产品,并增加转换的可能性。
-
音乐和视频流媒体服务使用推荐系统根据用户的收听或观看历史推荐音乐或视频。目标是提供个性化的内容推荐,以保持用户对平台的参与度。
-
社交媒体平台如 Meta 和 Instagram 使用推荐系统向用户展示他们可能感兴趣的朋友和页面内容。这有助于保持用户活跃并花费更多时间在平台上。
-
广告商利用推荐系统,根据他们的兴趣、人口统计和行为,针对特定受众投放广告。这提高了广告活动的有效性。
-
新闻网站、博客和搜索引擎利用推荐系统,根据用户偏好和搜索历史推荐文章、故事或搜索结果。
-
推荐系统可以根据患者数据和医学研究,建议治疗方案、药物或医疗程序。
-
旅行网站和酒店预订平台利用推荐系统,根据旅行者的偏好、预算和旅行历史,推荐旅行目的地、住宿和活动。
-
教育平台和在线课程利用推荐系统,根据学生的表现、兴趣和目标,推荐课程、材料和学习路径,以实现个性化学习体验。
-
视频游戏平台利用推荐系统,根据玩家的偏好、游戏风格和游戏历史推荐游戏。
这些只是推荐系统在实际场景中应用的一些例子。它们已成为企业寻求提高客户参与度、增加销售额和提供个性化体验的必备工具。
尽管存在许多现有算法,我们仍然可以将推荐系统分为几种基本方法。最常见的方法如下:
-
基于总结:基于平均产品评分的非个性化模型
-
基于内容:基于产品描述与用户兴趣交集的模型
-
协同过滤:基于相似用户群体兴趣的模型
-
矩阵分解:基于偏好矩阵分解的方法
任何推荐系统的基础是偏好矩阵。它将服务的所有用户放在一个轴上,推荐对象放在另一个轴上。推荐对象通常被称为项目。在行和列(用户、项目)的交叉处,这个矩阵填充了表示用户对产品的兴趣的评分,这些评分是在给定的尺度上表达的(例如,从 1 到 5),如下表所示:
| item1 | item 2 | item3 | |
|---|---|---|---|
| user1 | 1 | ||
| user2 | 2 | 4 | |
| user3 | 1 | 1 | 1 |
| user4 | 5 | ||
| user5 | 3 | 1 | |
| user6 | 4 |
表 8.1 – 用户兴趣计数
用户通常只评估目录中少量项目;推荐系统的任务是总结这些信息并预测用户可能对其他项目的态度。换句话说,你需要填写前面表格中的所有空白单元格。
人们的消费模式各不相同,不必总是推荐新产品。您可以在用户需要再次购买某物时展示重复的物品——例如,当用户购买了一些他们将来还会需要的东西时。根据这一原则,存在两类物品:
-
可重复的:例如,洗发水或剃须刀,总是需要使用的
-
不可重复的:例如,书籍或电影,很少会重复购买
如果产品不能归入这些类别之一,那么确定重复购买的产品类型就很有意义(有人通常只购买特定品牌的产品,但其他人可能会尝试目录中的所有产品)。
确定什么产品会吸引用户也是主观的。一些用户只需要他们最喜欢的类别的物品(保守型推荐),而其他人则对非标准商品(风险型推荐)反应更强烈。例如,一个视频托管服务可能只会推荐他们最喜欢的电视剧的新系列(保守型),但可能会定期推荐新的节目或新的类型。理想情况下,您应该使用客户偏好的泛化信息,为每个客户分别选择显示推荐策略。
用于构建推荐模型的必要数据集部分是用户对不同对象或物品的反应。这些反应通常被称为对象的用户评分。我们可以通过以下方式获取用户评分:
-
显式评分:用户对产品进行评分,留下评论,或点赞页面。
-
隐式评分:用户没有表达他们的态度,但可以从他们的行为中得出间接结论。例如,如果他们购买了一个产品,这意味着他们喜欢它;如果他们长时间阅读描述,这意味着他们有浓厚的兴趣。
当然,显式偏好更好。然而,在实践中,并非所有服务都允许用户清楚地表达他们的兴趣,并非所有用户都有此愿望。这两种评估类型通常同时使用,并且很好地相互补充。
区分术语预测(预测兴趣程度)和推荐本身(展示推荐)也是非常重要的。如何展示是一个独立于展示什么的任务。如何展示是一个使用预测步骤中获得的估计的任务,并且可以以不同的方式实现。
在本节中,我们讨论了推荐系统的基础知识。在接下来的章节中,我们将探讨推荐系统的基本构建块。让我们首先看看基于内容的过滤、基于用户和物品的协同过滤以及基于矩阵分解的协同过滤的主要原则。
非个性化推荐
对于非个性化推荐,用户的潜在兴趣由产品的平均评分决定:如果每个人都喜欢它,你也会喜欢它。根据这个原则,大多数服务在用户未在系统上授权时工作。
基于内容的推荐
个人推荐使用关于用户可用的最大信息——主要是他们之前购买的信息。基于内容的过滤是用于个性化推荐最早开发的方法之一。在这种方法中,产品的描述(内容)与用户的兴趣进行比较,这些兴趣是从他们之前的评估中获得的。产品越能满足这些兴趣,用户的潜在兴趣就越高。这里的一个明显要求是,目录中的所有产品都应该有描述。
从历史上看,基于内容的推荐的主题是具有无结构描述的产品:电影、书籍或文章。它们的特征可能是,例如,文本描述、评论或演员阵容。然而,没有任何东西阻止使用常规的数值或分类特征。
无结构特征以文本典型的方式进行描述——词空间中的向量(向量空间模型)。向量的每个元素都是一个特征,它可能表征了用户的兴趣。同样,一个项目(产品)是这个空间中的向量。
当用户与系统互动时(例如,他们购买电影),他们购买的商品的向量描述会合并(求和并归一化)成一个单一的向量,从而形成用户兴趣的向量。使用这个兴趣向量,我们可以找到与它最接近的产品和描述——即,我们可以解决寻找最近邻的问题。
在形成产品展示的向量空间时,你不仅可以使用单个单词,还可以使用 shingles 或 n-grams(连续的单词对、三词组或其他数量的单词)。这种方法使模型更加详细,但需要更多的数据进行训练。
在产品描述的不同位置,关键词的权重可能不同(例如,电影的描述可能包括标题、简短描述和详细描述)。来自不同用户的商品描述可以有不同的权重。例如,我们可以给评分多的活跃用户更多的权重。同样,你也可以按项目来权衡。一个对象的平均评分越高,其权重就越大(类似于 PageRank)。如果产品描述允许链接到外部来源,那么你还可以分析与产品相关的所有第三方信息。
余弦距离常用于比较产品表示向量。这个距离衡量了两个向量之间接近度的值。
在添加新的评估时,兴趣向量会增量更新(仅针对那些发生变化的元素)。在更新过程中,由于用户的偏好可能会改变,因此给予新的估计更多权重是有意义的。你会注意到基于内容的过滤几乎完全重复了搜索引擎如 Google 所使用的查询-文档匹配机制。唯一的区别在于搜索查询的形式——内容过滤系统使用描述用户兴趣的向量,而搜索引擎使用请求文档的关键词。当搜索引擎开始添加个性化时,这种区别被进一步抹去。
基于用户的协同过滤
这类系统在 90 年代开始发展。在这种方法下,推荐是基于其他相似用户的兴趣生成的。这种推荐是许多用户协作的结果,因此得名该方法。
算法的经典实现基于k 最近邻(kNN)的原则。对于每个用户,我们寻找与他们最相似的前k个(就偏好而言)。然后,我们使用已知的数据补充用户的信息。例如,如果已知你的邻居对一部电影感到非常满意,而你由于某种原因没有看过,这是一个很好的推荐这部电影的理由。
在这种情况下,相似性是兴趣相关性的同义词,可以从许多方面考虑——皮尔逊相关系数、余弦距离、Jaccard 距离、汉明距离以及其他类型的距离。
算法的经典实现有一个明显的缺点——由于计算的二次复杂性,它在实践中应用得不好。与任何最近邻方法一样,它需要计算用户之间的所有成对距离(可能有数百万用户)。很容易计算出计算距离矩阵的复杂度是
,其中
是用户的数量,而
是物品(商品)的数量。
通过购买高性能硬件可以部分缓解这个问题。但如果你明智地处理,那么最好是以下这种方式对算法进行一些修正:
-
不要在每次购买时更新距离,而是以批量(例如,每天一次)更新
-
不要完全重新计算距离矩阵,而是增量更新
-
选择一些迭代和近似算法(例如,交替最小二乘法(ALS))
满足以下假设可以使算法更加实用:
-
人们的品味不会随时间改变(或者它们会改变,但每个人都是相同的)
-
如果人们的品味相同,那么他们在所有事情上都是相同的
例如,如果两个客户喜欢相同的电影,那么他们也会喜欢相同的书籍。当推荐的产品是同质化的(例如,仅限电影)时,这种假设通常是成立的。如果情况不是这样,那么一对客户可能有相同的饮食习惯,但他们的政治观点可能正好相反;在这种情况下,算法的效率较低。
我们分析以生成新推荐的偏好空间中的用户邻域(用户的邻居),可以选择不同的方式。我们可以与系统中的所有用户一起工作;我们可以设置一个特定的接近度阈值;我们可以随机选择几个邻居;或者我们可以选择k个最相似的邻居(这是最流行的方法)。如果我们选择太多的邻居,我们得到更高的随机噪声机会,反之亦然。如果我们选择太少的邻居,我们得到更准确的推荐,但可以推荐的商品更少。
协同过滤方法中的一个有趣的发展是基于信任的推荐,它不仅考虑了人们根据兴趣的接近程度,还考虑了他们之间的社会接近程度以及他们之间的信任程度。例如,如果我们看到在 Facebook 上,一个女孩偶尔访问一个有她朋友音频录音的页面,那么她信任她的音乐品味。因此,在向这个女孩推荐时,你可以添加她朋友播放列表中的新歌曲。
基于项目的协同过滤
基于项目的这种方法是之前描述的经典基于用户方法的自然替代品,几乎重复了它,除了一个方面——它适用于转置的偏好矩阵,寻找类似的产品而不是用户。
对于每个客户,基于用户的协同过滤系统会搜索一组与该用户在以往购买方面相似的客户,然后系统平均他们的偏好。这些平均偏好作为对用户的推荐。在基于项目的协同过滤的情况下,使用偏好矩阵的列在各种产品(项目)上搜索最近邻,平均也正好根据这些最近邻进行。
如果某些产品之间在意义上相似,那么用户对这些产品的反应也将相同。因此,当我们看到某些产品在其估计之间存在强烈的关联时,这可能表明这些产品彼此之间是等效的。
与基于用户的方案相比,基于物品的方法的主要优势是计算复杂度更低。当用户数量很多(几乎总是如此)时,找到最近邻的任务变得难以计算。例如,对于 100 万用户,你需要计算和存储大约 5000 亿个距离。如果距离用 8 个字节编码,这仅距离矩阵就需要 4 太字节(TB)。如果我们采用基于物品的方法,那么计算复杂度将从
降低到
,并且距离矩阵的维度不再是每 100 万用户就有 100 万,而是根据商品(物品)的数量,是 100 乘以 100。
估计产品的邻近度比评估用户的邻近度要准确得多。这个假设是这样一个事实的直接后果:通常用户比物品多得多,因此计算物品相关性的标准误差显著较小,因为我们有更多的工作信息。
在基于用户的版本中,用户的描述通常具有非常稀疏的分布(有很多商品,但只有少数评价)。一方面,这有助于优化计算——我们只乘那些存在交集的元素。但另一方面,由于用户邻居(具有相似偏好的用户)的数量有限,系统可以向用户推荐的商品列表非常有限。此外,用户偏好可能会随时间变化,但商品的描述则要稳定得多。
算法的其余部分几乎完全重复了基于用户的版本:它使用相同的余弦距离作为距离的主要度量,并且需要相同的数据归一化。由于在更多的观察上考虑了物品的相关性,因此不需要在每次新的评估后重新计算它,这可以通过批量模式定期完成。
现在,让我们看看另一种基于矩阵分解方法来泛化用户兴趣的方法。
分解算法
很好,如果能用更广泛的特征来描述用户的兴趣那就更好了——不是用他们喜欢电影 X、Y 和 Z这样的格式,而是用他们喜欢浪漫喜剧这样的格式。除了增加模型的可泛化性之外,这还能解决数据维度大的问题——毕竟,兴趣不是由物品向量来描述的,而是由一个显著更小的偏好向量来描述的。
这种方法也被称为谱分解或高频滤波(因为我们去除了噪声,留下了有用的信号)。代数中有许多不同的矩阵分解类型,其中最常用的一种被称为奇异值分解(SVD)。
最初,奇异值分解(SVD)方法用于选择在意义上相似但在内容上不同的页面。最近,它开始被用于推荐。该方法基于将原始的 R 评分矩阵分解为三个矩阵的乘积,
,其中矩阵的大小是
,r 是分解的秩,它是描述分解详细程度的参数。
将这种分解应用于我们的偏好矩阵,我们可以得到以下两个因素矩阵(简称为描述):
-
U:对用户偏好的紧凑描述
-
S:对产品特性的紧凑描述
当使用这种方法时,我们无法知道哪些特定的特性对应于简化描述中的因素;对我们来说,它们被编码为一些数字。因此,奇异值分解(SVD)是一个未解释的模型。通过乘以因素矩阵,我们可以获得偏好矩阵的近似值。通过这样做,我们得到了所有客户-产品对的评分。
这样的算法族通常被称为非负矩阵分解(NMF)。通常,这种展开的计算非常耗时。因此,在实践中,他们经常求助于它们的近似迭代变体。交替最小二乘法(ALS)是一种流行的迭代算法,用于将偏好矩阵分解为两个矩阵的乘积:用户因素(U)和产品因素(I)。它基于最小化固定评分的均方根误差(RMSE)。优化是交替进行的——首先通过用户因素,然后通过产品因素。此外,为了避免重新训练,正则化系数被添加到 RMSE 中。
如果我们在偏好矩阵中补充一个包含有关用户或产品信息的新维度,那么我们就可以不与偏好矩阵,而与张量一起工作。因此,我们使用更多可用的信息,并可能得到一个更精确的模型。
在本节中,我们考虑了解决推荐系统任务的不同方法。现在,我们将讨论估计用户偏好相似性的方法。
相似性或偏好相关性
我们可以从不同的角度考虑两个用户偏好的相似性或相关性,但通常我们需要比较两个向量。让我们看看一些最流行的向量比较度量。
皮尔逊相关系数
这个度量是一个经典的系数,可以在比较向量时应用。它的主要缺点是,当交集被估计为低时,相关性可能会意外地很高。为了对抗意外的过高相关性,你可以乘以 50/min(50,评分交集)或任何其他阻尼因子,其效果随着估计数量的增加而降低。这里有一个例子:

斯皮尔曼相关系数
与皮尔逊相关系数相比,主要区别在于排名因素——也就是说,它不与评分的绝对值一起工作,而是与它们的序列号一起工作。一般来说,结果非常接近皮尔逊相关系数。这里有一个例子:

余弦距离
余弦距离是另一个经典的测量因素。如果你仔细观察,标准化向量之间的余弦值就是皮尔逊相关系数,相同的公式。这个距离使用余弦性质:如果两个向量是同向的(也就是说,它们之间的角度是 0),那么它们之间角度的余弦值是 1。相反,垂直向量之间角度的余弦值是 0。这里有一个例子:

通过这些,我们已经讨论了我们可以用来估计用户偏好相似性的方法。接下来我们将讨论的一个重要问题是准备数据,以便它可以在推荐系统算法中使用。
数据缩放和标准化
所有用户对物品的评估(评分)都不同。如果有人连续给出 5 分,而不是等待别人给出 4 分,那么在计算之前对数据进行归一化会更好——也就是说,将数据转换成单一尺度,以便算法可以正确比较结果。之后,预测估计需要通过逆变换转换回原始尺度(如果需要,四舍五入到最接近的整数)。
有几种方法可以归一化数据:
-
中心化(均值中心化):从用户的评分中减去他们的平均评分。这种归一化类型仅适用于非二元矩阵。
-
标准化(z 分数):除了中心化之外,这个方法还将用户的评分除以用户的标准差。但在这种情况下,在逆变换之后,评分可能会超出尺度(例如,在五点尺度上为六),但这种情况相当罕见,可以通过四舍五入到最近的可接受估计来解决。
-
双重标准化:第一次通过用户评分进行数据归一化;第二次,通过项目评分。
这些归一化技术的细节在第二章,数据处理中提供。下一节将描述推荐系统中已知的问题,称为冷启动问题,它出现在系统工作的早期阶段,当系统没有足够的数据来做出预测时。
冷启动问题
冷启动是在推荐系统还没有积累足够数据以正确运行时的典型情况(例如,当产品是新的或很少被购买时)。如果只有三个用户的评分来估计平均评分,这样的评估并不可靠,用户也明白这一点。在这种情况下,评分通常会人为调整。
实现这一点的第一种方法是不显示平均值,而是显示平滑平均值(阻尼均值)。在评分数量较少时,显示的评分更倾向于一个特定的安全平均值指标,一旦输入足够数量的新评分,平均调整就会停止。
另一种方法是计算每个评分的置信区间。从数学上讲,我们拥有的估计值越多,平均值的变异就越小,因此我们对它的准确性就越有信心。
例如,我们可以将区间的下限(低置信区间(CI)界限)显示为评分。同时,很明显,这样一个系统相当保守,倾向于低估新项目的评分。
由于估计值限制在特定的范围内(例如,从 0 到 1),由于分布尾端延伸到无穷大以及区间的对称性,因此通常用于计算置信区间的常规方法在这里适用性较差。有一种更准确的方法来计算它——威尔逊置信区间(Wilson CI)。
冷启动问题也适用于非个性化推荐。这里的一般方法是使用不同的启发式方法来替换当前无法计算的内容——例如,用平均评分替换,使用更简单的算法,或者直到收集到数据之前根本不使用该产品。
在我们开发推荐系统时,还应考虑的一个问题是推荐的相关性,这考虑了除了用户兴趣之外的因素——例如,可以是出版物的时效性或用户的评分。
推荐的相关性
在某些情况下,考虑推荐的时效性也是至关重要的。这种考虑对于论坛上的文章或帖子尤为重要。新条目应经常出现在顶部。通常使用校正因子(阻尼因子)来执行此类更新。以下公式用于计算媒体网站上文章的评分。
以下是在Hacker新闻杂志中评分计算的示例:

在这里,U表示点赞,D表示踩,P表示惩罚(对其他业务规则实施的额外调整),T表示记录时间。
以下方程展示了Reddit评分的计算方法:

在这里,U表示点赞数,D表示踩数,T表示记录时间。第一个项评估记录的质量,第二个项对时间进行校正。
没有通用的公式,每个服务都会发明一个最佳解决其问题的公式;它只能通过经验来测试。
以下部分将讨论测试推荐系统的现有方法。这不是一项简单的工作,因为通常在没有训练数据集中确切的目标值的情况下很难估计推荐的质量。
评估系统质量
测试推荐系统是一个复杂的过程,总是提出许多问题,这主要是因为“质量”概念的不确定性。
通常,在机器学习问题中,有两种主要的测试方法:
-
使用回溯测试在历史数据上进行的离线模型测试
-
使用 A/B 测试(我们运行几个选项,看看哪个给出最好的结果)来测试模型
这两种方法都在开发推荐系统中被积极使用。我们必须面对的主要限制是我们只能评估用户已经评估或评分的产品上的预测准确性。标准的方法是使用交叉验证与留一法和留出法相结合。重复测试并平均结果提供了一个更稳定的对质量的评估。
留一法使用除了一个项目之外所有项目训练的模型,并由用户进行评估。这个被排除的项目用于模型测试。这个程序对所有的n个项目都进行,并在获得的n个质量估计中计算平均值。
留出法的方法相同,但在每一步,会排除
个点。
我们可以将所有质量指标分为以下三个类别:
-
预测准确度:估计预测评分的准确性
-
决策支持:评估推荐的相关性
-
排名准确度指标:评估发布的推荐排名的质量
不幸的是,没有适用于所有场合的单一推荐指标,并且所有参与测试推荐系统的人都会选择一个适合他们目标的指标。
在以下部分,我们将形式化协同过滤方法,并展示其背后的数学。
理解协同过滤方法
在本节中,我们将形式化推荐系统问题。我们有一组用户,
,一组项目,
(电影、曲目、产品等),以及一组估计,
。每个估计由用户
,一个对象
,其结果
,以及可能的其他特征给出。
我们需要按照以下方式预测偏好:

我们需要按照以下方式预测个人推荐:

我们需要按照以下方式预测相似对象:

记住,协同过滤背后的主要思想是相似的用户通常喜欢相似的对象。让我们从最简单的方法开始:
-
根据用户对
评分的历史,选择一些条件相似度度量。 -
将用户分组(聚类)以便相似用户最终会落在同一个簇中:
。 -
预测物品的用户评分为此对象簇的平均评分:

这个算法有几个问题:
-
对于新用户或非典型用户,没有推荐的内容。对于这样的用户,没有合适的类似用户簇。
-
它忽略了每个用户的特殊性。在某种程度上,我们将所有用户划分为类别(模板)。
-
如果簇中没有人为该物品评分,则预测将不起作用。
我们可以改进这种方法,用以下公式替换硬聚类:

对于基于物品的版本,公式将是对称的,如下所示:

这些方法有以下缺点:
-
冷启动问题
-
对新用户或非典型用户或物品的糟糕预测
-
简单推荐
-
资源密集型计算
为了克服这些问题,你可以使用奇异值分解(SVD)。偏好(评分)矩阵可以被分解为三个矩阵的乘积,
。让我们用
表示前两个矩阵的乘积,其中 R 是偏好矩阵,U 是用户参数矩阵,V 是物品参数矩阵。
为了预测一个物品的用户评分 U,对于物品
,我们取一个向量
(参数集),对于一个给定的用户和一个给定物品的向量
。它们的标量积是我们需要的预测:
。使用这种方法,我们可以通过用户历史来识别物品和用户兴趣的隐藏特征。例如,向量的一阶坐标可能表示每个用户是男孩还是女孩的可能性数值,而第二坐标是一个反映用户大约年龄的数值。在物品中,第一坐标表示对男孩或女孩更有趣,第二坐标表示该物品吸引的用户年龄组。
然而,也存在一些问题。第一个问题是偏好矩阵 R 并不完全为我们所知,所以我们不能仅仅取其奇异值分解。其次,奇异值分解不是我们拥有的唯一分解,所以即使我们找到了至少一些分解,它也不太可能是我们任务的最优解。
这里我们需要机器学习。由于我们不知道矩阵本身,所以我们无法找到矩阵的奇异值分解。然而,我们可以利用这个想法,提出一个类似于奇异值分解的预测模型。我们的模型依赖于许多参数——用户和物品的向量。对于给定的参数,为了预测估计值,我们必须取用户向量、物品向量,并得到它们的标量积,
。然而,由于我们不知道向量,它们仍然需要被获得。想法是我们有用户评分,我们可以通过这些评分找到最优参数,使得我们的模型可以尽可能准确地使用以下方程预测这些估计值:
。我们希望找到这样的参数θ值,使得平方误差尽可能小。我们还想在未来犯更少的错误,但我们不知道我们需要什么估计值。因此,我们无法优化参数的θ值。我们已经知道用户给出的评分,因此我们可以尝试根据我们已有的估计值来选择参数,以最小化误差。我们还可以添加另一个项,即正则化器,如下所示:

为了对抗过拟合,需要正则化。为了找到最优参数,你需要优化以下函数:

有许多参数:对于每个用户和物品,我们都有一个想要优化的向量。优化函数最著名的方法是梯度下降(GD)。假设我们有一个许多变量的函数,我们想要优化它。我们取一个初始值,然后我们看看我们可以移动到哪个位置以最小化这个值。GD 方法是一个迭代算法——它反复取某个点的参数,查看梯度,并朝着其方向移动,如图所示:

这种方法存在各种问题:它运行得非常慢,并且找到的是局部而不是全局最小值。第二个问题对我们来说不是那么糟糕,因为在我们这个案例中,局部最小值处的函数值接近全局最优值。
然而,GD 方法并不总是必要的。例如,如果我们需要计算抛物线的最小值,就没有必要采取这种方法,因为我们确切地知道它的最小值在哪里。结果证明,我们试图优化的功能——误差平方和加上所有参数平方和——也是一个二次函数,这与抛物线非常相似。对于每个特定的参数,如果我们固定所有其他参数,它就是一个抛物线。对于这些,我们可以准确地确定至少一个坐标。ALS 方法基于这个假设。我们交替在某个坐标或另一个坐标上准确找到最小值,如图所示:

我们固定所有物品的参数,优化用户的参数,然后固定用户的参数,并优化物品的参数。我们按此方式迭代操作,如下所示:

此方法运行得相当快,并且可以并行化每个步骤。然而,由于我们没有完整的用户数据或物品数据,隐式数据仍然存在问题。因此,我们可以在更新规则中对没有评分的物品进行惩罚。通过这样做,我们只依赖于有评分的用户物品,不对未评分的物品做出任何假设。因此,让我们定义一个权重矩阵,
,如下所示:

我们试图最小化的成本函数看起来如下:

注意,我们需要正则化项来避免数据过拟合。我们可以为因子向量使用以下解决方案:

在这里,
和
是对角矩阵。
另一种处理隐式数据的方法是引入置信水平。让我们定义一组二元观测变量:

现在,我们可以为每个
值定义置信水平。当
时,我们具有低置信度。这可能是由于用户从未接触过该物品,或者它可能在当时不可用。例如,这可能是由用户为他人购买礼物来解释的。因此,我们将有 低置信度。当
更大时,我们应该有更大的置信度。例如,我们可以定义置信度如下:

在这里,
是一个超参数,应该针对给定的数据集进行调整。更新的优化函数如下:

在这里,
是一个具有
值的对角矩阵。以下解决方案用于用户和物品评分:

然而,计算
表达式是一个昂贵的计算问题。然而,它可以以下方式进行优化:

这意味着
可以在每个步骤中预先计算,而
只包含
非零时的非零条目。现在我们已经详细了解了协同过滤,让我们通过考虑一些实现协同过滤推荐系统的示例来进一步了解它。
在以下章节中,我们将学习如何使用不同的 C++ 库来开发推荐系统。
基于物品的 C++ 协同过滤示例
让我们看看我们如何实现协同过滤推荐系统。我们将使用由明尼苏达大学计算机科学与工程学院的研究实验室提供的 MovieLens 数据集:grouplens.org/datasets/movielens/。他们提供了一个包含 2000 万条电影评分的完整数据集,以及一个包含 10 万条评分的教育用较小数据集。我们建议从较小的数据集开始,因为它允许我们更早地看到结果,并更快地检测实现错误。
此数据集包含多个文件,但我们只对其中两个感兴趣:ratings.csv和movies.csv。评分文件包含以下格式的行:用户 ID、电影 ID、评分和时间戳。在此数据集中,用户在 5 星评分尺度上进行了评分,以半星递增(0.5 星到 5.0 星)。电影文件包含以下格式的行:电影 ID、标题和类型。电影 ID 在这两个文件中是相同的,这样我们就可以看到用户在评分哪些电影。
使用 Eigen 库
首先,让我们学习如何实现基于矩阵分解的协同过滤推荐系统,使用 ALS 和纯线性代数库作为后端。在以下示例中,我们使用Eigen库。实现协同过滤推荐系统的步骤如下:
-
首先,我们必须进行基本类型定义,如下所示:
using DataType = float; // using Eigen::ColMajor is Eigen restriction - todense method always returns // matrices in ColMajor order using Matrix = Eigen::Matrix<DataType, Eigen::Dynamic, Eigen::Dynamic, Eigen::ColMajor>; using SparseMatrix = Eigen::SparseMatrix<DataType, Eigen::ColMajor>; using DiagonalMatrix = Eigen::DiagonalMatrix<DataType, Eigen::Dynamic, Eigen::Dynamic>; -
这些定义使我们能够为矩阵类型编写更少的源代码,并快速更改浮点精度。接下来,我们必须定义和初始化评分(偏好)矩阵、电影标题列表和二进制评分标志矩阵,如下所示:
SparseMatrix ratings_matrix; // user-item ratings SparseMatrix p; // binary variables std::vector<std::string> movie_titles;我们有一个特定的辅助函数
LoadMovies,它将文件加载到地图容器中,如下面的代码片段所示:auto movies_file = root_path / "movies.csv"; auto movies = LoadMovies(movies_file); auto ratings_file = root_path / "ratings.csv"; auto ratings = LoadRatings(ratings_file); -
一旦数据被加载,我们可以初始化矩阵对象,使它们具有正确的大小:
ratings_matrix.resize( static_cast<Eigen::Index>(ratings.size()), static_cast<Eigen::Index>(movies.size())); ratings_matrix.setZero(); p.resize(ratings_matrix.rows(), ratings_matrix.cols()); p.setZero(); movie_titles.resize(movies.size());然而,因为我们已经将数据加载到地图中,所以我们需要将所需的评分值移动到矩阵对象中。
-
现在,我们必须初始化电影标题列表,将用户 ID 转换为我们的零基顺序,并初始化二进制评分矩阵(在算法中用于处理隐式数据),如下所示:
Eigen::Index user_idx = 0; for (auto& r : ratings) { for (auto& m : r.second) { auto mi = movies.find(m.first); Eigen::Index movie_idx = std::distance(movies.begin(), mi); movie_titles[static_cast<size_t>(movie_idx)] = mi->second; ratings_matrix.insert(user_idx, movie_idx) = static_cast<DataType>(m.second); p.insert(user_idx, movie_idx) = 1.0; } ++user_idx; } ratings_matrix.makeCompressed(); -
一旦初始化了评分矩阵,我们必须定义和初始化我们的训练变量:
auto m = ratings_matrix.rows(); auto n = ratings_matrix.cols(); Eigen::Index n_factors = 100; auto y = InitializeMatrix(n, n_factors); auto x = InitializeMatrix(m, n_factors); y matrix corresponds to user preferences, while the x matrix corresponds to the item parameters. We’ve also defined the number of factors we’ll be interested in after decomposition. These matrices are initialized with random values and normalized. Such an approach is used to speed up algorithm convergence. This can be seen in the following code snippet:Matrix InitializeMatrix(Eigen::Index rows, Eigen::Index cols) { Matrix mat = Matrix::Random(rows, cols).array().abs(); auto row_sums = mat.rowwise().sum(); mat.array().colwise() /= row_sums.array(); return mat; } -
然后,我们必须定义和初始化正则化矩阵和单位矩阵,这些矩阵在整个学习周期中是恒定的:
DataType reg_lambda = 0.1f; SparseMatrix reg = (reg_lambda * Matrix::Identity( n_factors, n_factors)).sparseView(); // Define diagonal identity terms SparseMatrix user_diag = -1 * Matrix::Identity( n, n).sparseView(); SparseMatrix item_diag = -1 * Matrix::Identity( m, m).sparseView(); -
此外,因为我们正在实现一个可以处理隐式数据的算法版本,所以我们需要将我们的评分矩阵转换为另一种格式以降低计算复杂度。我们的算法版本需要以以下形式提供用户评分!和每个用户和项目的对角矩阵。相应的矩阵对象的两个容器可以通过以下代码块查看:
std::vector<DiagonalMatrix> user_weights( static_cast<size_t>(m)); std::vector<DiagonalMatrix> item_weights( static_cast<size_t>(n)); { Matrix weights(ratings_matrix); weights.array() *= alpha; weights.array() += 1; for (Eigen::Index i = 0; i < m; ++i) { user_weights[static_cast<size_t>(i)] = weights.row(i).asDiagonal(); } for (Eigen::Index i = 0; i < n; ++i) { item_weights[static_cast<size_t>(i)] = weights.col(i).asDiagonal(); } }现在,我们已经准备好实现主要的学习循环。如前所述,ALS 算法可以很容易地并行化,所以我们使用
OpenMP编译器扩展来并行计算用户和项目参数。 -
让我们定义主要的学习周期,它运行指定次数的迭代:
size_t n_iterations = 5; for (size_t k = 0; k < n_iterations; ++k) { auto yt = y.transpose(); auto yty = yt * y; ... // update item parameters ... auto xt = x.transpose(); auto xtx = xt * x; ... // update users preferences ... auto w_mse = CalculateWeightedMse( x, y, p, ratings_matrix, alpha); } -
以下代码显示了如何更新项目参数:
#pragma omp parallel { Matrix diff; Matrix ytcuy; Matrix a, b, update_y; #pragma omp for private(diff, ytcuy, a, b, update_y) for (size_t i = 0; i < static_cast<size_t>(m); ++i) { diff = user_diag; diff += user_weights[i]; ytcuy = yty + yt * diff * y; auto p_val = p.row(static_cast<Eigen::Index>(i)).transpose(); a = ytcuy + reg; b = yt * user_weights[i] * p_val; update_y = a.colPivHouseholderQr().solve(b); x.row(static_cast<Eigen::Index>(i)) = update_y.transpose(); } } -
以下代码显示了如何更新用户的偏好:
#pragma omp parallel { Matrix diff; Matrix xtcux; Matrix a, b, update_x; #pragma omp for private(diff, xtcux, a, b, update_x) for (size_t i = 0; i < static_cast<size_t>(n); ++i) { diff = item_diag; diff += item_weights[i]; xtcux = xtx + xt * diff * x; auto p_val = p.col(static_cast<Eigen::Index>(i)); a = xtcux + reg; b = xt * item_weights[i] * p_val; update_x = a.colPivHouseholderQr().solve(b); y.row(static_cast<Eigen::Index>(i)) = update_x.transpose(); } }在这里,循环体中有两个部分几乎是相同的。首先,我们使用冻结的用户选项更新了项目参数,然后我们使用冻结的项目参数更新了用户偏好。请注意,所有矩阵对象都被移到了内部循环体外部,以减少内存分配并显著提高程序性能。另外,请注意,我们分别并行化了用户和项目参数的计算,因为当其中一个被计算时,另一个应该始终被冻结。为了计算用户偏好和项目参数的确切值,我们必须使用以下公式:

X T X 和 Y T Y 在每一步都是预先计算的。另外,请注意,这些公式是以线性方程系统的形式表达的,X = AB。我们使用来自 Eigen 库的 colPivHouseholderQr 函数来求解它,并得到用户和项目参数的确切值。这个线性方程系统也可以用其他方法求解。选择 colPivHouseholderQr 函数是因为它在 Eigen 库实现中显示了更好的计算速度和准确性的比率。
-
为了估计我们系统学习过程的进度,我们可以计算原始评分矩阵与预测矩阵之间的均方误差(MSE)。为了计算预测评分矩阵,我们必须定义另一个函数:
Matrix RatingsPredictions(const Matrix& x, const Matrix& y) { return x * y.transpose(); } -
为了计算 MSE,我们可以使用我们的优化函数中的
表达式:DataType CalculateWeightedMse(const Matrix& x, const Matrix& y, const SparseMatrix& p, const SparseMatrix& ratings_matrix, DataType alpha) { Matrix c(ratings_matrix); c.array() *= alpha; c.array() += 1.0; Matrix diff(p - RatingsPredictions(x, y)); diff = diff.array().pow(2.f); Matrix weighted_diff = c.array() * diff.array(); return weighted_diff.array().mean(); }请注意,我们必须使用权重和二进制评分来获得一个有意义的误差值,因为在学习过程中使用了类似的方法。直接计算误差会得到错误的结果,因为预测矩阵有非零预测,而原始评分矩阵有零。理解这一点很重要,这个算法并不学习原始评分的尺度(从 0 到 5);相反,它学习预测值在 0 到 1 的范围内。它从我们优化的函数开始,如下所示:

- 我们可以使用之前定义的电影列表来显示电影推荐。以下函数显示了用户偏好和系统推荐。为了确定用户喜欢什么,我们将显示用户评分超过 3 的电影标题。我们还将显示系统评分等于或高于 0.8 评分系数的电影,以确定系统通过以下代码向用户推荐的电影:
void PrintRecommendations(
const Matrix& ratings_matrix,
const Matrix& ratings_matrix_pred,
const std::vector<std::string>& movie_titles) {
// collect recommendations
auto n = ratings_matrix.cols();
std::vector<std::string> liked;
std::vector<std::string> recommended;
for (Eigen::Index u = 0; u < 5; ++u) {
for (Eigen::Index i = 0; i < n; ++i) {
DataType orig_value = ratings_matrix(u, i);
if (orig_value >= 3.f) {
liked.push_back(
movie_titles[static_cast<size_t>(i)]);
}
DataType pred_value = ratings_matrix_pred(u, i);
if (pred_value >= 0.8f && orig_value < 1.f) {
recommended.push_back(
movie_titles[static_cast<size_t>(i)]);
}
}
// print recommendations
std::cout << "\nUser " << u << " liked :";
for (auto& l : liked) {
std::cout << l << "; ";
}
std::cout << "\nUser " << u << " recommended :";
for (auto& r : recommended) {
std::cout << r << "; ";
}
std::cout << std::endl;
liked.clear();
recommended.clear();
}
}
这个函数可以这样使用:
PrintRecommendations(ratings_matrix,
RatingsPredictions(x, y),
movie_titles);
使用 mlpack 库
mlpack库是一个通用机器学习库,它提供了许多不同的算法和命令行工具来处理数据并学习这些算法,而无需显式编程。作为基础,这个库使用Armadillo线性代数库进行数学计算。我们在前面的章节中使用的一些其他库没有协同过滤算法的实现。
要加载MovieLens数据集,使用与上一节中相同的加载辅助函数。一旦数据被加载,将其转换为适合mlpack::cf::CFType类型对象的格式。这种类型实现了一个协同过滤算法,并且可以使用不同类型的矩阵分解方法进行配置。这种类型的对象可以使用密集和稀疏的评分矩阵。在密集矩阵的情况下,它应该有三行。第一行对应于用户,第二行对应于项目,第三行对应于评分。这种结构被称为来自Armadillo库的arma::SpMat<DataType>类型,如下面的代码块所示:
arma::SpMat<DataType> ratings_matrix(ratings.size(),
movies.size());
std::vector<std::string> movie_titles;
{
// fill matrix with data
movie_titles.resize(movies.size());
size_t user_idx = 0;
for (auto& r : ratings) {
for (auto& m : r.second) {
auto mi = movies.find(m.first);
auto movie_idx = std::distance(movies.begin(), mi);
movie_titles[static_cast<size_t>(movie_idx)] =
mi->second;
ratings_matrix(user_idx, movie_idx) =
static_cast<DataType>(m.second);
}
++user_idx;
}
}
现在,我们可以初始化mlpack::cf::CFType类对象。它在构造函数中接受以下参数:评分矩阵、矩阵分解策略、邻居数量、目标因子数量、迭代次数和学习误差的最小值,之后算法可以停止。
对于这个对象,只需在H矩阵上执行最近邻搜索。这意味着你避免了计算完整的评分矩阵,利用观察到的如果评分矩阵是X = W H,则以下适用:
distance(X.col(i), X.col(j)) = distance(W H.col(i), W H.col(j))
这个表达式可以看作是使用马氏距离在H矩阵上的最近邻搜索,如下面的代码块所示:
// factorization rank
size_t n_factors = 100;
size_t neighborhood = 50;
mlpack::NMFPolicy decomposition_policy;
// stopping criterions
size_t max_iterations = 20;
double min_residue = 1e-3;
mlpack::CFType cf(ratings_matrix,
decomposition_policy,
neighborhood,
n_factors,
max_iterations,
min_residue);
注意,作为一个分解策略,使用了mlpack::NMFPolicy类型的对象。这展示了如何使用 ALS 方法实现非负矩阵分解算法。mlpack库中有几个分解算法。例如,批处理 SVD 分解在mlpack::BatchSVDPolicy类型中实现。这个对象的构造函数也执行了完整的训练,因此在其调用完成后,我们可以使用这个对象来获取推荐。推荐可以通过GetRecommendations方法检索。此方法获取你想要获取的推荐数量、推荐输出矩阵以及你想要获取推荐的用户的用户 ID 列表,如下面的代码块所示:
arma::Mat<size_t> recommendations;
// Get 5 recommendations for specified users.
arma::Col<size_t> users;
users << 1 << 2 << 3;
cf.GetRecommendations(5, recommendations, users);
for (size_t u = 0; u < recommendations.n_cols; ++u) {
std::cout << "User " << users(u) <<" recommendations are: ";
for (size_t i = 0; i < recommendations.n_rows; ++i) {
std::cout << movie_titles[recommendations(i, u)] << ";";
}
std::cout << std::endl;
}
注意,GetRecommendations方法返回项目 ID 作为其输出。因此,我们可以看到,使用这个库来实现推荐系统比从头开始编写要容易得多。此外,mlpack库中还有许多更多配置选项来构建此类系统——例如,我们可以配置邻居检测策略和要使用的距离度量。这些配置可以显著提高你构建的系统质量,因为你可以根据你的特定任务来设置它们。
摘要
在本章中,我们讨论了推荐系统是什么以及目前存在的类型。我们研究了构建推荐系统的两种主要方法:基于内容的推荐和协同过滤。我们确定了两种协同过滤类型:基于用户和基于物品。然后,我们探讨了如何实现这些方法,以及它们的优缺点。我们发现,在实现推荐系统时,我们必须纠正的一个重要问题是数据量以及算法相关的大规模计算复杂性。我们考虑了克服计算复杂性问题的方法,例如部分数据更新和近似迭代算法如 ALS。我们发现矩阵分解可以帮助解决不完整数据的问题,提高模型的泛化能力,并加快计算速度。我们还实现了一个基于线性代数库的协同过滤系统,并使用了mlpack通用机器学习库。
有必要研究可以应用于推荐系统任务的新方法,例如自动编码器、变分自动编码器或深度协同方法。在最近的研究论文中,这些方法比传统的如 ALS 等经典方法显示出更令人印象深刻的结果。所有这些新方法都是非线性模型,因此它们有可能超越线性因子模型的有限建模能力。
在下一章中,我们将讨论集成学习技术。这些技术的主要思想是将不同类型的机器学习算法结合在一起,或者使用同一类算法的集合以获得更好的预测性能。将几个算法组合成一个集成,使我们能够获得每个算法的最佳特性,从而覆盖单个算法中的缺点。
进一步阅读
-
隐式反馈 数据集的协同过滤:
yifanhu.net/PUB/cf.pdf -
ALS 隐式协同 过滤:
medium.com/radon-dev/als-implicit-collaborative-filtering-5ed653ba39fe -
协同 过滤:
datasciencemadesimpler.wordpress.com/tag/alternating-least-squares/ -
mlpack库的官方网站:www.mlpack.org/ -
Armadillo库的官方网站:arma.sourceforge.net/ -
基于变分自编码器的协同过滤,作者:Dawen Liang, Rahul G. Krishnan, Matthew D. Hoffman, 和 Tony Jebara:
arxiv.org/abs/1802.05814 -
基于深度学习的推荐系统:综述与新视角,作者:Shuai Zhang, Lina Yao, Aixin Sun 和 Yi Tay:
arxiv.org/abs/1707.07435 -
基于深度自编码器的协同过滤训练,作者:Oleksii Kuchaiev 和 Boris Ginsburg:
arxiv.org/abs/1708.01715
第九章:集成学习
任何从事数据分析与机器学习工作的人都会理解没有哪种方法是理想的或通用的。这就是为什么有这么多方法。研究人员和爱好者多年来一直在寻找各种模型在准确性、简单性和可解释性之间的折衷方案。此外,我们如何在不改变模型本质的情况下提高模型的准确性?提高模型准确性的方法之一是创建和训练模型集成——即用于解决同一问题的模型集合。集成训练方法是对一组简单分类器的训练,随后将它们预测结果合并成一个聚合算法的单个预测。
本章描述了集成学习是什么,存在哪些类型的集成,以及它们如何帮助获得更好的预测性能。在本章中,我们还将使用不同的 C++ 库实现这些方法的示例。
本章将涵盖以下主题:
-
集成学习的概述
-
学习决策树和随机森林
-
使用 C++ 库创建集成示例
技术要求
本章所需的技术和安装如下:
-
Dlib库 -
mlpack库 -
支持 C++20 的现代 C++ 编译器
-
CMake 构建系统版本 >= 3.22
本章的代码文件可以在以下 GitHub 仓库中找到:github.com/PacktPublishing/Hands-on-Machine-learning-with-C-Second-Edition/tree/main/Chapter09
集成学习的概述
集成模型的训练被理解为训练一组最终的基本算法的过程,其结果随后被组合以形成聚合分类器的预测。集成模型的目的在于提高聚合分类器预测的准确性,尤其是在与每个单独的基本分类器的准确性相比时。直观上很明显,将简单的分类器组合起来可以比单独的每个简单分类器给出更准确的结果。尽管如此,简单的分类器在特定的数据集上可能足够准确,但与此同时,它们可能在不同的数据集上犯错误。
集成的一个例子是孔多塞陪审团定理(1784)。陪审团必须得出正确或错误的共识,并且每个陪审员都有独立的观点。如果每个陪审员做出正确决定的概率超过 0.5,那么陪审团作为一个整体做出正确决定的概率(趋向于 1)会随着陪审团规模的增加而增加。如果每个陪审员做出正确决定的概率小于 0.5,那么随着陪审团规模的增加,做出正确决定的概率单调递减(趋向于零)。
定理如下:
-
N: 陪审团成员的数量
-
: 陪审团成员做出正确决定的概率 -
μ: 整个陪审团做出正确决定的概率
-
m: 陪审团成员的最小多数:

: 由 N 个元素组成的 i 的组合数:

如果
则 
如果
则 
因此,基于一般推理,可以区分出为什么分类器集成可以成功的三种原因,如下:
-
统计性: 分类算法可以被视为在 H 假设空间中的搜索过程,关注数据的分布以找到最佳假设。通过从最终数据集中学习,算法可以找到许多不同的假设,这些假设可以同样好地描述训练样本。通过构建模型集成,我们可以 平均每个假设的错误,并减少新假设形成中的不稳定性和随机性的影响。
-
计算方法: 大多数学习算法使用寻找特定目标函数极值的方法。例如,神经网络使用 梯度下降(GD)方法来最小化预测误差。决策树使用最小化数据熵的贪婪算法。这些优化算法可能会陷入局部极值点,这是一个问题,因为它们的目的是找到全局最优解。结合简单分类器预测结果的模型集合,由于它们从初始假设集的不同点开始寻找最优解,因此有更高的机会找到全局最优解。
-
代表性: 组合假设可能不在简单分类器的可能假设集中。因此,通过构建组合假设,我们扩展了可能的假设集。
康多塞陪审团定理和之前提供的理由并不完全适用于现实、实际的情况,因为算法不是独立的(它们解决一个问题,在一个目标向量上学习,只能使用一个模型,或者少数几个模型)。
因此,应用集成开发的大多数技术都是为了确保集成是多样化的。这使得单个算法在单个对象中的错误可以通过其他算法的正确操作来补偿。总的来说,构建集成提高了简单算法的质量和多样性。目标是创建一个多样化的预测集,这些预测相互补充,并减少集成预测的整体方差和偏差。
最简单的集成类型是模型平均,其中集成中的每个成员对最终预报的贡献是相等的。每个模型对最终集成预报的贡献相等是这种方法的一个局限性。问题在于贡献不平衡。尽管如此,仍然要求集成中的所有成员的预测技能高于随机机会。
然而,众所周知,一些模型的表现比其他模型好得多或差得多。可以通过使用加权集成来解决此问题,其中每个成员对最终预报的贡献由该模型的性能加权。当模型的权重是一个小的正值且所有权重的总和等于 1 时,权重可以表示对每个模型的信心百分比(或预期性能)。
目前,构建集成最常见的方法如下:
-
袋装:这是一组模型,它们在来自同一训练集的不同随机样本上并行研究。最终结果由集成算法的投票决定。例如,在分类中,选择被最多分类器预测的类别。
-
提升:这是一组按顺序训练的模型,每个后续算法都在前一个算法出错的数据样本上训练。
-
堆叠:这是一种方法,其中训练集被分为N个块,并在其中的N-1个块上训练一组简单模型。然后,第N个模型在剩余的块上训练,但使用底层算法的输出(形成所谓的元属性)作为目标变量。
-
随机森林:这是一组独立构建的决策树,其答案通过平均和多数投票来决定。
以下几节将详细讨论之前描述的方法。
使用袋装方法创建集成
袋装(来自自助聚合)是最早和最直接类型的集成之一。袋装基于统计自助方法,旨在获得最准确的样本估计并将结果扩展到整个总体。自助方法如下。
假设有一个大小为M的X数据集。从数据集中均匀选择N个对象,并在选择后将其放回数据集。在选择下一个之前,我们可以生成N个子数据集。这个程序意味着N次,我们选择一个任意的样本对象(我们假设每个对象被选中的概率相同
),每次,我们都从所有原始M个对象中选择。此外,这个过程也称为带替换的抽样,这意味着数据集中的每个元素都有被选中多次的机会。
我们可以想象这是一个袋子,从中取出球。在给定步骤选中的球在选中后返回到袋子中,下一次选择仍然是从相同数量的球中按相同概率进行。请注意,由于每次都返回球,因此存在重复。
每次新的选择表示为 X1。重复该过程 k 次,我们生成 k 个子数据集。现在,我们有一个相当大的样本数量,我们可以评估原始分布的各种统计量。
主要描述性统计量是样本均值、中位数和标准差。总结统计量(例如,样本均值、中位数和相关性)可能因样本而异。自助法的想法是使用采样结果作为虚构的总体来确定统计量的样本分布。自助法分析大量被称为自助样本的虚拟样本。对于每个样本,计算目标统计量的估计值,然后对这些估计值进行平均。自助法可以看作是蒙特卡洛方法的修改。
假设有 X 训练数据集。借助自助法,我们可以生成
子数据集。现在,在每一个子数据集上,我们可以训练我们的
分类器。最终的分类器平均这些分类器的响应(在分类的情况下,这对应于投票),如下所示:
。以下图表显示了此方案:

图 9.1 – Bagging 方法方案
考虑使用简单算法
解决回归问题。假设对于所有 y(x) 对象都有一个真实的答案函数,并且在
对象上也有一个分布。在这种情况下,我们可以写出每个回归函数的误差如下:

我们也可以写出均方误差(MSE)的期望值如下:

构建的回归函数的平均误差如下:

现在,假设误差是无偏且不相关的,如图所示:

现在,我们可以编写一个新的回归函数,该函数平均我们构建的函数的响应,如下所示:

让我们找到其均方根误差(RMSE)以查看平均化的效果,如下所示:

因此,平均答案使我们能够将平均平方误差减少 n 倍。
Bagging 还可以帮助我们减少训练算法的方差,防止过拟合。Bagging 的有效性基于底层算法,这些算法在相当不同的子数据集上训练,并且在投票过程中相互补偿错误。此外,异常值可能不会落入某些训练子数据集中,这也增加了 Bagging 方法的有效性。
当排除少量训练对象会导致构建出显著不同的简单算法时,bagging 对于小型数据集是有用的。在大型数据集的情况下,通常生成比原始数据集显著小的子数据集。
注意,关于误差不相关的假设很少得到满足。如果这个假设不正确,那么误差减少的幅度不如我们可能假设的那么显著。
在实践中,与简单的单个算法相比,bagging 在提高结果准确性方面提供了良好的改进,尤其是如果简单算法足够准确但不够稳定时。通过减少单个算法预测错误的波动来提高预测的准确性。bagging 算法的优点在于其实施简单,以及可以在不同的计算节点上并行计算训练每个基本算法。
使用梯度提升方法创建集成
提升法的主要思想是基本算法不是独立构建的。我们构建每个顺序算法,使其纠正前一个算法的错误,从而提高整个集成质量。提升法第一个成功的版本是自适应提升法(AdaBoost)。现在它很少被使用,因为梯度提升已经取代了它。
假设我们有一组由属性x和目标变量y组成的对,
。在这个集合上,我们恢复形式
的依赖关系。我们通过近似
来恢复它。为了选择最佳的近似解,我们使用特定形式的损失函数
,我们应该按照以下方式优化它:

由于可用于学习的有限数据量,我们也可以用数学期望来重写表达式,如下所示:

我们的近似是不准确的。然而,提升法的理念在于,通过添加另一个模型的结果来纠正其错误,从而可以改进这种近似,如图所示:

以下方程显示了理想的误差校正模型:

我们可以将这个公式重写为以下形式,这更适合校正模型:

基于前面列出的假设,提升的目标是逼近
,使其结果尽可能接近 残差
。这种操作是顺序执行的,即
改善了先前
函数的结果。
这种方法的进一步推广使我们能够将残差视为损失函数的负梯度,具体形式为
。换句话说,梯度提升是一种使用损失函数及其梯度的 GD 方法。
现在,已知损失函数梯度的表达式,我们可以在我们的数据上计算其值。因此,我们可以训练模型,使我们的预测与这个梯度(带有负号)更好地相关。因此,我们将解决回归问题,试图纠正这些残差的预测。对于分类、回归和排序,我们总是最小化残差与我们的预测之间的平方差。
在梯度提升方法中,使用以下形式的函数逼近:

这是
类的
函数的总和;它们统称为 弱模型(算法)。这种逼近是顺序进行的,从初始逼近开始,初始逼近是一个确定的常数,如下所示:

不幸的是,对于任意损失函数,在每一步选择
最佳函数极其困难,因此采用了一种更直接的方法。想法是使用可微分的
函数和可微分的损失函数,如图所示:

然后提升算法形成如下:
-
使用常数值初始化模型,如下所示:
![]()
-
重复指定的迭代次数并执行以下操作:
- 按如下方式计算伪残差:

在这里,n 是训练样本的数量,m 是迭代次数,L 是损失函数。
-
在伪残差数据上训练基础算法(回归模型)
。 -
通过以下一维优化问题计算
系数:

- 按如下方式更新模型:

此算法的输入如下:
-
数据集 -
M 迭代次数
-
具有解析梯度(这种形式的梯度可以减少数值计算的数量)的 L( y, f) 损失函数
-
h(x)基本算法的函数族选择,以及它们的训练过程和超参数
初始近似的常数以及
-最优系数可以通过二分搜索找到,或者通过相对于初始损失函数(而不是梯度)的另一种线性搜索算法找到。
回归损失函数的例子如下:
-
:一个L2 损失,也称为高斯损失。这个公式是经典的条件均值,最常见和简单的选项。如果没有额外的信息或模型可持续性要求,应该使用它。 -
:一个L1 损失,也称为拉普拉斯损失。这个公式乍一看并不非常可微,并确定条件中位数。正如我们所知,中位数对异常值更具抵抗力。因此,在某些问题中,这个损失函数更可取,因为它不像二次函数那样对大的偏差进行惩罚。 -
:一个Lq 损失,也称为分位数损失。如果我们不想要条件中位数,但想要条件 75%分位数,我们将使用这个选项与
一起。这个函数是不对称的,对那些最终出现在所需分位数一侧的观察值进行更多惩罚。
分类损失函数的例子如下:
-
:逻辑损失,也称为伯努利损失。这个损失函数的一个有趣特性是我们甚至对正确预测的类别标签进行惩罚。通过优化这个损失函数,我们可以在所有观察都正确预测的情况下,继续使类别分离并提高分类器。这个函数是二元分类任务中最标准、最常用的损失函数。 -
:AdaBoost 损失。恰好使用这个损失函数的经典 AdaBoost 算法(AdaBoost 算法也可以使用不同的损失函数)等同于梯度提升。从概念上讲,这个损失函数与逻辑损失非常相似,但它对分类错误的指数惩罚更强,使用频率较低。
提取的思想是它可以与梯度提升方法一起使用,这被称为随机梯度提升。这样,新的算法是在训练集的子样本上训练的。这种方法可以帮助我们提高集成算法的质量,并减少构建基本算法所需的时间(每个算法都是在减少的训练样本数量上训练的)。
尽管提升本身是一种集成方法,但可以将其应用于其他集成方案,例如通过平均几种提升方法。即使我们使用相同的参数平均提升,由于实现的随机性,它们也会有所不同。这种随机性来源于每一步选择随机子数据集或在我们构建决策树时选择不同的特征(如果它们被选为基本算法)。
目前,基础的 梯度提升机 (GBM) 已针对不同的统计任务有许多扩展。具体如下:
-
GLMBoost 和 GAMBoost 作为现有 广义加性 模型 (GAM) 的增强
-
CoxBoost 用于生存曲线
-
RankBoost 和 LambdaMART 用于排序
此外,同一 GBM 在不同名称和不同平台上也有许多实现,例如以下这些:
-
随机 GBM
-
梯度提升决策 树 (GBDT)
-
梯度提升回归 树 (GBRT)
-
多重加性回归 树 (MART)
-
广义提升 机 (GBM)
此外,提升可以在搜索引擎承担的排序任务中长期应用和使用。该任务基于损失函数编写,该函数对搜索结果顺序中的错误进行惩罚;因此,将其插入 GBM 中变得方便。
使用堆叠方法创建集成
堆叠的目的是使用在相同数据上训练的不同算法作为基本模型。然后在一个元分类器上训练基本算法或源数据的结果,也补充了基本算法本身的结果。有时,元分类器使用它收到的分布参数估计(例如,分类中每个类的概率估计)进行训练,而不是基本算法的结果。
最直接的堆叠方案是混合。对于这个方案,我们将训练集分为两部分。第一部分用于教授一组基本算法。它们的结果可以被视为新特征(元特征)。然后我们使用它们作为与数据集第二部分的补充特征,并训练新的元算法。这种混合方案的问题是,既没有基本算法也没有元算法使用整个数据集进行训练。为了提高混合的质量,你可以平均在不同数据分区上训练的几个混合的结果。
实现堆叠的另一种方式是使用整个训练集。在某些资料中,这被称为 泛化。整个集被分成几部分(折),然后算法依次遍历这些折,并在除了随机选择的折之外的所有折上教授基本算法。剩下的折被用来推断基本算法。基本算法的输出值被解释为从折中计算出的新元属性(或新特征)。在这种方法中,也期望实现几个不同的折划分,然后平均相应的元属性。对于一个元算法,对元属性应用正则化或添加一些正常噪声是有意义的。这种添加的系数类似于正则化系数。我们可以总结说,描述的方法背后的基本思想是使用一组基础算法;然后,使用另一个元算法,我们将它们的预测结合起来,目的是减少泛化误差。
与提升和传统的袋装不同,在堆叠中,你可以使用不同性质的算法(例如,结合随机森林的岭回归)。然而,记住对于不同的算法,需要不同的特征空间是至关重要的。例如,如果将分类特征用作目标变量,那么可以直接使用随机森林算法,但对于回归算法,你必须首先进行 one-hot 编码。
由于元特征是已经训练好的算法的结果,它们具有很强的相关性。这个事实是 先验的,这是这种方法的一个缺点;基本算法在训练过程中往往被欠优化,以对抗相关性。有时,为了克服这个缺点,基本算法的训练不是在目标特征上,而是在特征与目标之间的差异上进行的。
使用随机森林方法创建集成
在我们转向随机森林方法之前,我们需要熟悉决策树算法,它是随机森林集成算法的基础。
决策树算法概述
决策树是一种基于人类解决预测或分类任务的监督机器学习算法。通常,这是一个具有决策规则的节点和叶节点上目标函数预测的 k 维树。决策规则是一个函数,它允许你确定哪个子节点应该被用作考虑对象的父节点。决策树的叶节点中可以存在不同类型的对象——即分配给对象的类别标签(在分类任务中)、类别的概率(在分类任务中)以及目标函数的值(在回归任务中)。
在实践中,二叉决策树比具有任意数量子节点的树使用得更频繁。
构建决策树的一般算法如下:
-
首先,检查算法停止的标准。如果执行了这个标准,就选择为节点发布的预测。否则,我们必须将训练集分割成几个不相交的小集合。
-
在一般情况下,一个
决策规则在 t 节点定义,它考虑了一定范围内的值。这个范围被划分为 Rt 个不相交的对象集合:
,其中 Rt 是节点的后代数量,每个
是落入
后代的对象集合。 -
根据所选规则将节点中的集合分割,并对每个节点递归地重复算法。
最常见的决策规则是
,即特征——也就是说,
。对于分区,我们可以使用以下规则:
-
对于选择的边界值
。 -
,其中
是一个向量的标量积。实际上,这是一个角值检查。 -
,其中距离
定义在某些度量空间中(例如,
)。 -
,其中
是一个谓词。
通常,你可以使用任何决策规则,但那些最容易解释的更好,因为它们更容易配置。没有必要采取比谓词更复杂的东西,因为你可以借助谓词创建一个在训练集上具有 100%准确性的树。
通常,选择一组决策规则来构建树。为了在每个特定节点中找到它们中的最优者,我们需要引入一个衡量最优性的标准。为此引入了
度量,用于测量特定
节点中对象如何分散(回归)或类别如何混合(分类)。这个度量称为不纯度函数。它需要根据决策规则集的所有特征和参数找到
的最大值,以便选择决策规则。通过这个选择,我们可以为当前节点中的对象集合生成最优分区。
信息增益,
,是指对于所选的分割我们可以获得多少信息,其计算方法如下:

在前面的方程中,以下适用:
-
R 是当前节点被分割成子节点的数量
-
t 是当前节点
-
是通过所选分区获得的后代节点 -
是落在当前节点子 i 的对象数量 -
是当前节点中捕获的对象数量 -
是被捕获在 t 顶点的对象
我们可以将 MSE 或 平均绝对误差(MAE)作为回归任务的
杂乱函数。对于分类任务,我们可以使用以下函数:
-
Gini 准则
作为误分类的概率,具体来说,如果我们预测给定节点中类别的发生概率 -
熵
作为随机变量的不确定性度量 -
将分类错误
作为最强类别的分类错误率
在之前描述的函数中,
是节点 t 中遇到类别 i 对象的 先验概率——即训练样本中标签为类别 i 并落入 t 的对象数量除以 t 中的对象总数(
)。
以下规则可以作为构建决策树的停止标准:
-
限制树的最大深度
-
限制叶片中的最小对象数量
-
限制树中叶子的最大数量
-
如果节点中的所有对象都属于同一类,则停止
-
要求在分裂过程中信息增益至少提高 8%
对于任何训练集,都有一个无错误的树,这导致了过拟合的问题。找到正确的停止标准来解决这个问题是具有挑战性的。一种解决方案是剪枝——在整棵树构建完成后,我们可以剪掉一些节点。这种操作可以使用测试或验证集来完成。剪枝可以减少最终分类器的复杂性,并通过减少过拟合来提高预测精度。
剪枝算法如下:
-
我们为训练集构建一个树。
-
然后,我们将验证集通过构建的树,并考虑任何内部节点 t 及其左右子节点
,
。 -
如果验证样本中没有对象达到 t,那么我们可以说这个节点(及其所有子树)是不重要的,并将 t 设为叶子(使用训练集将此节点的谓词值设置为多数类的集合)。
-
如果验证集中的对象达到了 t,那么我们必须考虑以下三个值:
-
来自 t 子树的分类错误数量
-
来自
子树的分类错误数量 -
来自
子树的分类错误数量
-
如果第一个案例的值为零,则将节点t作为具有相应类别预测的叶节点。否则,我们选择这些值中的最小值。根据哪个是最小的,我们分别执行以下操作:
-
如果第一个是最小的,则什么都不做
-
如果第二个是最小的,则用节点
的子树替换节点t。 -
如果第三个是最小的,则用节点
的子树替换节点t。
这样的过程使算法规范化,以克服过拟合并提高泛化能力。在k-维树的情况下,可以使用不同的方法来选择叶中的预测。对于分类,我们可以选择落在该叶下的训练对象中最常见的类别。或者,我们可以计算这些对象的客观函数的平均值来进行回归。
我们从树根开始应用决策规则到一个新对象,以预测或分类新数据。因此,确定对象应该进入哪个子树。我们递归地重复此过程,直到达到某个叶节点,最后返回我们找到的叶节点的值,作为分类或回归的结果。
随机森林方法概述
决策树是用于袋装算法的合适的基本算法族,因为它们相当复杂,并且最终可以在任何训练集上实现零误差。我们可以使用一种使用随机子空间(如袋装)的方法来减少树之间的相关性,避免过拟合。基本算法在不同的特征空间子集上训练,这些子集也是随机选择的。可以使用以下算法构建使用随机子空间方法的决策树模型集合。
其中训练对象的数量为N,特征的数量为
,按照以下步骤进行:
-
选择
作为集成中的单个树的数量。 -
对于每个单独的
树,选择
作为
的特征数量。通常,所有树都使用一个值。 -
对于每棵树,使用自举方法创建一个
训练子集。
现在,按照以下方式从
个样本构建决策树:
-
从源中选择
个随机特征,然后最优的训练集分割将限制其搜索范围。 -
根据给定的标准,我们选择最佳的属性,并根据它对树进行分割。
-
树的构建直到每个叶节点中剩余的对象不超过
,直到达到树的某个高度,或者直到训练集耗尽。
现在,要将集成模型应用于新对象,必须通过多数投票或通过结合后验概率来组合单个模型的结果。以下是一个最终分类器的示例:

考虑以下算法的基本参数及其属性:
-
树的数量:树越多,质量越好,但训练时间和算法的工作量也会成比例增加。通常,随着树的数量增加,训练集的质量会提高(甚至可以达到 100%的准确率),但测试集的质量是渐近的(因此可以估计所需的最小树的数量)。
-
分裂选择的特征数量:随着特征数量的增加,森林的构建时间也会增加,树比以前更均匀。在分类问题中,通常选择与
和
相等的属性数量,对于回归问题。 -
最大树深度:深度越小,算法构建和运行的速度越快。随着深度的增加,训练过程中的质量会显著提高。测试集上的质量也可能提高。建议使用最大深度(除非训练对象太多,我们得到非常深的树,构建这些树需要相当多的时间)。当使用浅层树时,改变与限制叶节点中对象数量和分裂相关的参数不会产生显著影响(叶子已经很大)。在具有大量噪声对象(异常值)的任务中推荐使用浅层树。
-
杂质函数:这是选择分支特征(决策规则)的标准。对于回归问题,通常是均方误差(MSE)/平均绝对误差(MAE)。对于分类问题,是基尼准则、熵或分类误差。树的平衡和深度可能取决于我们选择的特定杂质函数。
我们可以将随机森林视为袋装决策树,在这些树的训练过程中,我们为每个分区使用特征随机子集的特征。这种方法是一个通用算法,因为随机森林可以用于解决分类、回归、聚类、异常搜索和特征选择等任务。
在下一节中,我们将看到如何使用不同的 C++库来开发机器学习模型集成。
使用 C++库创建集成示例
以下章节将展示如何在Dlib和mlpack库中使用集成。这些库中有现成的随机森林和梯度提升算法的实现;我们将展示如何使用它们的mlpack库。
使用 Dlib 的集成
Dlib 库中只有随机森林算法的实现,在本节中,我们将展示如何在实践中使用它的具体 API。
为了展示随机森林算法的应用,我们需要为此任务创建一些数据集。让我们创建一个模拟余弦函数的人工数据集。首先,我们定义数据类型来表示样本和标签项。以下代码示例显示了如何实现:
using DataType = double;
using SampleType = dlib::matrix<DataType, 0, 1>;
using Samples = std::vector<SampleType>;
using Labels = std::vector<DataType>;
然后我们定义 GenerateData 函数:
std::pair<Samples, Labels> GenerateData(DataType start,
DataType end,
size_t n) {
Samples x;
x.resize(n);
Labels y;
y.resize(n);
auto step = (end - start) / (n - 1);
auto x_val = start;
size_t i = 0;
for (auto& x_item : x) {
x_item = SampleType({x_val});
auto y_val = std::cos(M_PI * x_val);
y[i] = y_val;
x_val += step;
++i;
}
return {x, y};
}
GenerateData 函数接受三个参数:生成范围的起始值和结束值以及要生成的点数 n。实现方式简单,在循环中计算余弦值。该函数返回一个包含 double 值的 std::vector 类型对象对。此函数的结果将用于测试。
为了证明随机森林算法确实可以近似值,我们将向原始数据添加一些噪声。以下代码片段显示了 GenerateNoiseData 函数的实现:
std::pair<Samples, Labels> GenerateNoiseData(DataType start,
DataType end,
size_t n) {
Samples x;
x.resize(n);
Labels y;
y.resize(n);
std::mt19937 re(3467);
std::uniform_real_distribution<DataType> dist(start, end);
std::normal_distribution<DataType> noise_dist;
for (size_t i = 0; i < n; ++i) {
auto x_val = dist(re);
auto y_val =
std::cos(M_PI * x_val) + (noise_dist(re) * 0.3);
x[i] = SampleType({x_val});
y[i] = y_val;
}
return {x, y};
}
GenerateNoiseData 函数也在简单的循环中计算余弦值。它接受与 GenerateData 函数相同的输入参数。然而,此函数不是按顺序生成值,而是在每次迭代中从指定的范围内采样一个随机值。对于每个样本,它计算余弦值并添加噪声样本。噪声是通过随机分布生成的。该函数还返回两个包含 double 值的 std::vector 类型对象,第一个用于训练输入,第二个用于目标值。
使用这些数据生成函数,我们可以创建训练集和测试集,如下所示:
auto [train_samples, train_lables] =
GenerateNoiseData(start, end, num_samples);
auto [test_samples, test_lables] =
GenerateData(start, end, num_samples);
现在,Dlib 随机森林实现的用法非常简单。以下代码片段展示了它:
#include <dlib/random_forest.h>
...
dlib::random_forest_regression_trainer<
dlib::dense_feature_extractor> trainer;
constexpr size_t num_trees = 1000;
trainer.set_num_trees(num_trees);
auto random_forest = trainer.train(train_samples, train_lables);
for (const auto& sample : test_samples) {
auto prediction = random_forest(sample);
…
}
在这里,我们使用名为 trainer 的 random_forest_regression_trainer 类的实例,通过 train 方法创建 random_forest 对象。trainer 对象配置了要使用的树的数量。random_forest_regression_trainer 类使用 dense_feature_extractor 类进行参数化——这是 Dlib 库现在提供的唯一特征提取类,但你可以创建一个自定义的。train 方法简单地接受两个 std::vector 类型对象,第一个用于输入数据值,第二个用于目标数据值。
训练完成后,创建了 random_forest 对象,并将其用作功能对象来对一个单一值进行预测。
下图显示了从 Dlib 库应用随机森林算法的结果。原始数据以星号表示,预测数据以线条表示:

图 9.2 – 使用 Dlib 的随机森林回归
注意,这种方法对这个数据集上的回归任务不太适用。你可以看到全局趋势被成功学习,但在细节上有很多错误。
使用 mlpack 的集成
mlpack库中有两种集成学习算法:随机森林和 AdaBoost 算法。对于这组样本,我们将使用位于archive.ics.uci.edu/dataset/17/breast+cancer+wisconsin+diagnostic的Wisconsin 乳腺癌诊断数据集。它来自D. Dua, 和 C. Graff (2019), UCI 机器学习 Repository (archive.ics.uci.edu/ml)。
这个数据集中有 569 个实例,每个实例有 32 个属性:ID、诊断和 30 个实值输入特征。诊断可以有两个值:M = 恶性,和B = 良性。其他属性为每个细胞核计算了 10 个实值特征,如下所示:
-
半径(从中心到周界的平均距离)
-
纹理(灰度值的标准差)
-
周长
-
面积
-
光滑度(半径长度的局部变化)
-
紧凑度
-
凹陷(轮廓凹部分的严重程度)
-
凹点(轮廓凹部分的数目)
-
对称性
-
分形维度(海岸线近似—1)
这个数据集可以用于二元分类任务。
mlpack 的数据准备
在mlpack中有一个DatasetInfo类用于描述数据集。这个类的实例可以与不同的算法一起使用。此外,mlpack中还有一个data::Load函数,可以自动从.csv、.tsv和.txt文件中加载数据集。然而,这个函数假设这些文件中只包含可以解释为矩阵的数值数据。在我们的情况下,数据以.csv格式存在,但Diagnosis列包含字符串值M和B。因此,我们简单地将它们转换为0和1。
当我们有了正确的.csv文件后,数据可以按照以下方式加载:
arma::mat data;
mlpack::data::DatasetInfo info;
data::Load(dataset_name, data, info, /*fail with error*/ true);
我们将数据集文件名和data矩阵对象以及info对象的引用传递给Load函数。注意,我们通过将最后一个参数传递为true来请求函数在失败情况下生成异常。
然后我们按照以下方式将数据分为输入和目标部分:
// extract the labels row
arma::Row<size_t> labels;
labels = arma::conv_to<arma::Row<size_t>>::from( data.row(0));
// remove the labels row
data.shed_row(0);
在这里,我们使用了矩阵对象的row方法来获取特定的行。然后我们使用arma::conv_to函数将其值转换为site_t类型,因为我们的数据集的第一行包含标签。最后,我们从data对象中移除了第一行,使其可以作为输入数据使用。
拥有输入数据和标签矩阵后,我们可以按照以下方式将它们分为训练和测试部分:
// split dataset into the train and test parts - make views
size_t train_num = 500;
arma::Row<size_t> train_labels = labels.head_cols( train_num); arma::mat test_input = data.tail_cols( num_samples - train_num); arma::Row<size_t> test_labels =
labels.tail_cols( num_samples - train_num);
我们使用矩阵对象的 head_cols 方法从输入数据中取出前 train_num 列并将其标记为训练值,并使用矩阵对象的 tail_cols 方法取出最后的列作为测试值。
使用 mlpack 的随机森林
mlpack 库中的随机森林算法位于 RandomForest 类中。此类有两个主要方法:Train 和 Classify。Train 方法可以使用如下方式:
using namespace mlpack;
RandomForest<> rf;
rf.Train(train_input,
train_labels,
num_classes,
/*numTrees=*/100,
/*minimumLeafSize=*/10,
/*minimumGainSplit=*/1e-7,
/*maximumDepth=*/10);
前四个参数具有自描述性。最后几个参数更具有算法特定性。minimumLeafSize 参数是每个树叶子节点的最小点数。minimumGainSplit 参数是分割决策树节点的最小增益。maximumDepth 参数是允许的最大树深度。
在使用 Train 方法处理训练数据后,可以使用 rf 对象通过 Classify 方法进行分类。此方法将单个值或行矩阵作为输入的第一个参数,第二个参数是单个预测值或由该方法填充的预测值向量。
mlpack 中有一个 Accuracy 类,可以用来估计算法的准确度。它可以与不同的算法对象一起工作,并具有统一的接口。我们可以如下使用它:
Accuracy acc;
auto acc_value = acc.Evaluate(rf, test_input, test_labels);
std::cout << "Random Forest accuracy = " << acc_value << std::endl;
我们使用 Evaluate 方法获取使用我们的数据训练的随机森林算法的准确度值。打印的值是 Random Forest accuracy = 0.971014。
使用 mlpack 的 AdaBoost
mlpack 库中另一个基于集成算法的是 AdaBoost。它基于弱学习者的集成,用于生成强学习者。以下是基于简单感知器作为弱学习者的 AdaBoost 算法对象的定义:
using namespace mlpack;
Perceptron<> p;
AdaBoost<Perceptron<>> ab;
我们使用 Perceptron 类作为模板参数来参数化 AdaBoost 类。在 AdaBoost 对象实例化后,我们可以使用 Train 方法用我们的数据集对其进行训练。以下代码片段显示了如何使用 Train 方法:
ab.Train(train_input,
train_labels,
num_classes,
p,
/*iterations*/ 1000,
/*tolerance*/ 1e-10);
前三个输入参数非常明显——输入数据、标签和分类的类别数。然后我们传递了 p 对象;它是 Perceptron 类的实例,我们的弱学习者。在弱学习者对象之后,我们将学习次数和提前停止学习的准确度容忍度传递给 Train 方法。
在强学习者 ab 训练完成后,我们可以使用 Classify 方法对新数据值进行分类。此外,我们还可以使用 Accuracy 类的实例来估计训练算法的准确度。我们已经在上一章中看到了如何使用 Accuracy。它的 API 对于 mlpack 中的所有算法都是相同的。对于 AdaBoost,我们可以如下使用:
Accuracy acc;
auto acc_value = acc.Evaluate(ab, test_input, test_labels);
std::cout << "AdaBoost accuracy = " << acc_value << std::endl;
对于与相同数据集的 AdaBoost 算法,我们得到了以下输出:AdaBoost 准确率 = 0.985507。准确率略高于我们使用随机森林算法得到的准确率。
使用 mlpack 的堆叠集成
为了展示更多集成学习技术的实现,我们可以手动开发堆叠方法。使用 mlpack 库或任何其他库都不是很难。
堆叠方法基于学习一组弱学习器。通常,k-fold 技术用于实现这一点。这意味着我们在 k-1 折上学习一个弱模型,并使用最后一折进行预测。让我们看看我们如何使用 mlpack 创建 k-fold 拆分。我们将使用与前面小节相同的相同数据集。主要思想是重复数据集,以便仅通过使用索引就能得到不同的折。以下代码片段定义了具有一个方法和构造函数的 KfoldDataSet 结构:
struct KFoldDataSet {
KFoldDataSet(const arma::mat& train_input,
const arma::Row<size_ t>& train_labels,
size_t k);
std::tuple<arma::mat, arma::Row<size_t>, arma::mat,
arma::Row<size_t>>
get_fold(const size_t i);
size_t k{0};
size_t bin_size{0};
size_t last_bin_size{0};
arma::mat inputs;
arma::Row<size_t> labels;
};
构造函数接受输入数据、标签和用于拆分的折数。get_fold 方法接受一个折的索引并返回四个值:
-
矩阵包含
k-1折的输入数据 -
行矩阵包含
k-1折的标签 -
矩阵包含输入数据的最后一折
-
行矩阵包含最后一折的标签
构造函数的实现可以如下:
KFoldDataSet(const arma::mat& train_input,
const arma::Row<size_t>& train_labels, size_t k)
: k(k) {
fold_size = train_input.n_cols / k;
last_fold_size = train_input.n_cols - ((k - 1) * fold_size);
inputs = arma::join_rows(
train_input, train_input.cols(0, train_input.n_cols -
last_fold_size - 1));
labels = arma::join_rows(
train_labels,
train_labels.cols(
0, train_labels.n_cols - last_fold_size - 1));
}
在这里,我们通过将输入数据中的样本总数除以折数来计算 fold_size 值。样本总数可能与折数不匹配,因此最后一折的大小可能不同。这就是为什么我们额外计算了 last_fold_size 值,以便能够正确地拆分。有了折的大小值,我们使用了 arma::join_rows 函数来重复训练样本。这个函数连接两个矩阵;对于第一个参数,我们使用了原始样本矩阵,对于第二个参数,我们使用了减少的矩阵。我们使用矩阵对象的 cols 方法只取第二个参数的 k-1 列。
当我们有重复的数据样本时,get_fold 方法的实现可以如下:
std::tuple<arma::mat, arma::Row<size_t>, arma::mat,
arma::Row<size_t>>
get_fold(const size_t i) {
const size_t subset_size =
(i != 0) ? last_fold_size + (k - 2) * fold_size
: (k - 1) * fold_size;
const size_t last_subset_size =
(i == k - 1) ? last_fold_size : fold_size;
// take k-1
auto input_fold =
arma::mat(inputs.colptr(fold_size * i), inputs.n_rows,
subset_size, false, true);
auto labels_fold = arma::Row<size_t>(
labels.colptr(fold_size * i), subset_size, false, true);
// take last k-th
auto last_input_fold =
arma::mat(inputs.colptr(fold_size * (i + k - 1)),
inputs.n_rows, last_subset_size, false, true);
auto last_labels_fold =
arma::Row<size_t>(labels.colptr(fold_size * (i + k - 1)),
last_subset_size, false, true);
return {input_fold, labels_fold, last_input_fold,
last_labels_fold};
}
get_fold 方法最重要的部分是获取属于 k-1 子集和最后一折子集的正确样本数量。因此,首先我们检查要拆分的所需折是否为最后一折,因为最后一折可能包含不同大小的样本。有了这个信息,我们只需将 k-1 或 k-2 的数字乘以折的大小,并根据条件添加最后一折的大小以获得样本子集。对于最后一折,我们也根据所需的折拆分索引有条件地获取折的大小。
在获得正确的子集大小后,我们使用了colptr方法来获取从重复数据中开始列样本的指针。我们使用这样的指针和子集大小来初始化指向现有数据的arma::mat对象,而不进行复制,通过将copy_aux_mem构造函数参数设置为false。使用这种方法,我们初始化了k-1个折叠样本和最后一个折叠样本的矩阵,并将它们作为元组返回。
拥有KfoldDataSet类后,我们可以进一步实现StackingClassification函数。其声明可以如下所示:
void StackingClassification(
size_t num_classes,
const arma::mat& raw_train_input,
const arma::Row<size_t>& raw_train_labels,
const arma::mat& test_input,
const arma::Row<size_t>& test_labels);
它将使用分类类别数量、训练输入和标签数据以及测试输入和标签数据来估计算法的准确性。
StackingClassification函数的实现可以分解为以下步骤:
-
准备训练数据集。
-
创建元数据集。
-
训练元模型。
-
训练弱模型。
-
评估测试数据。
让我们逐一查看它们。数据集准备可以如下实现:
using namespace mlpack;
// Shuffle data
arma::mat train_input;
arma::Row<size_t> train_labels;
ShuffleData(raw_train_input, raw_train_labels, train_input,
train_labels);
// Normalize data
data::StandardScaler sample_scaler;
sample_scaler.Fit(train_input);
arma::mat scaled_train_input(train_input.n_rows,
train_input.n_cols);
sample_scaler.Transform(train_input, scaled_train_input);
我们使用了ShuffleData函数来随机化训练和测试数据,并使用了StandardScaler类的sample_scaler对象来将我们的训练和测试数据缩放到零均值和单位方差。请注意,我们在训练数据上拟合了一个缩放器对象,然后使用Transform方法使用它。我们稍后会使用这个缩放器对象来处理测试数据。
在准备好数据集后,我们可以使用用于堆叠的弱(或基本)算法来创建元数据集。它将是三个弱算法的模型:
-
Softmax 回归
-
决策树
-
线性 SVM
元数据集生成基于在从原始数据集准备好的 k-fold 分割上对弱模型进行训练和评估。我们已创建了KFoldDataset类来完成此目的,其用法如下:
size_t k = 30;
KFoldDataSet meta_train(scaled_train_input, train_labels, k);
在这里,我们实例化了meta_train数据集对象以进行 30 个折叠分割。以下代码片段显示了如何使用三个弱模型生成元数据集:
for (size_t i = 0; i < k; ++i) {
auto [fold_train_inputs, fold_train_labels, fold_valid_inputs,
fold_valid_labels] = meta_train.get_fold(i);
arma::Row<size_t> predictions;
auto [fold_train_inputs, fold_train_labels, fold_valid_inputs,
fold_valid_labels] = meta_train.get_fold(i);
arma::Row<size_t> predictions;
arma::mat meta_feature;
LinearSVM<> local_weak0;
local_weak0.Train(fold_train_inputs, fold_train_labels,
num_classes);
local_weak0.Classify(fold_valid_inputs, predictions);
meta_feature = arma::join_cols(
meta_feature,
arma::conv_to<arma::mat>::from(predictions));
SoftmaxRegression local_weak1(fold_train_inputs.n_cols,
num_classes);
local_weak1.Train(fold_train_inputs, fold_train_labels,
num_classes);
local_weak1.Classify(fold_valid_inputs, predictions);
meta_feature = arma::join_cols(
meta_feature,
arma::conv_to<arma::mat>::from(predictions));
DecisionTree<> local_weak2;
local_weak2.Train(fold_train_inputs, fold_train_labels,
num_classes);
local_weak2.Classify(fold_valid_inputs, predictions);
meta_feature = arma::join_cols(
meta_feature,
arma::conv_to<arma::mat>::from(predictions));
meta_train_inputs =
arma::join_rows(meta_train_inputs, meta_feature);
meta_train_labels =
arma::join_rows(meta_train_labels, fold_valid_labels);
}
元数据集存储在meta_train_inputs和meta_train_labels矩阵对象中。使用循环,我们迭代了 30 个折叠索引,并对每个索引,我们调用了meta_train对象的get_fold方法。这个调用给了我们第k个折叠分割,其中包含了用于训练和评估的四个矩阵。然后我们训练了本地弱对象,在我们的例子中,这些是LinearSVM、SoftmaxRegression和DecisionTree类对象的实例。
对于它们的训练,我们使用了折的训练输入和标签。在训练了弱模型(LinearSVM、SoftmaxRegression和DecisionTree模型)之后,我们使用Classify方法在fold_valid_input对象中找到的折测试输入上对它们进行了评估。分类结果被放置在predictions对象中。使用join_cols函数将所有三个分类结果堆叠到新的meta_feature矩阵对象中。因此,我们用新的元特征替换了原始数据集的特征。这个meta_feature对象使用join_rows方法添加到meta_train_inputs对象中。位于fold_valid_labels中的折测试标签使用join_rows函数添加到元数据集中。
在创建元数据集之后,我们使用DecisionTree实例按以下方式训练元模型:
DecisionTree<> meta_model;
meta_model.Train(meta_train_inputs,
meta_train_labels,
num_classes);
为了能够使用这个元模型,我们必须再次创建一个弱模型。它将被用来为这个元模型生成元输入特征。可以按照以下方式完成:
LinearSVM<> weak0;
weak0.Train(scaled_train_input, train_labels, num_classes);
SoftmaxRegression weak1(scaled_train_input.n_cols, num_classes);
weak1.Train(scaled_train_input, train_labels, num_classes);
DecisionTree<> weak2;
weak2.Train(scaled_train_input, train_labels, num_classes);
在这里,我们使用了整个训练数据集来训练每个弱模型。
在训练了集成之后,我们可以在测试数据集上对其进行评估。由于我们使用了数据预处理,我们也应该以与转换训练数据相同的方式转换我们的测试数据。我们按以下方式缩放测试数据:
arma::mat scaled_test_input(test_input.n_rows,
test_input.n_cols);
sample_scaler.Transform(test_input, scaled_test_input);
集成评估从使用我们之前训练的弱模型预测元特征开始。我们将每个弱模型的预测存储在以下对象中:
arma::mat meta_eval_inputs;
arma::Row<size_t> meta_eval_labes;
The next code snippet shows how we get the meta-features from the weak models:
weak0.Classify(scaled_test_input, predictions);
meta_eval_inputs = arma::join_cols(meta_eval_inputs,
arma::conv_to<arma::mat>::from(predictions));
weak1.Classify(scaled_test_input, predictions);
meta_eval_inputs = arma::join_cols(meta_eval_inputs,
arma::conv_to<arma::mat>::from(predictions));
weak2.Classify(scaled_test_input, predictions);
meta_eval_inputs = arma::join_cols(meta_eval_inputs,
arma::conv_to<arma::mat>::from(predictions));
在这里,我们像创建元数据集时那样,将每个弱模型的预测堆叠到meta_eval_inputs对象中。
在我们创建了元特征之后,我们可以将它们作为输入传递给meta_model对象的Classify方法以生成真实预测。我们还可以计算准确度,如下所示:
Accuracy acc;
auto acc_value =
acc.Evaluate(meta_model, meta_eval_inputs, test_labels);
std::cout << "Stacking ensemble accuracy = " << acc_value << std::endl;
此代码的输出是Stacking ensemble accuracy = 0.985507。你可以看到,即使使用默认设置,这个集成也比随机森林实现表现得更好。在进一步调整的情况下,它可能会给出更好的结果。
摘要
在本章中,我们探讨了构建机器学习算法集成的各种方法。创建集成的目的是减少基本算法的错误,扩展可能假设的集合,并在优化过程中增加达到全局最优的概率。
我们看到,构建集成的方法主要有三种:在各个数据集上训练基本算法并平均误差(袋装),持续改进先前较弱算法的结果(提升),以及从基本算法的结果中学习元算法(堆叠)。请注意,我们已涵盖的构建集成的方法,除了堆叠外,都需要基本算法属于同一类别,这是集成的主要要求之一。人们还认为,提升比袋装给出更准确的结果,但同时也更容易过拟合。堆叠的主要缺点是它只有在相对较大的训练样本数量下才开始显著提高基本算法的结果。
在下一章中,我们将讨论人工神经网络(ANNs)的基础知识。我们将探讨其创建的历史背景,介绍 ANN 中使用的基本数学概念,实现一个多层感知器(MLP)网络和一个简单的卷积神经网络(CNN),并讨论深度学习是什么以及为什么它如此流行。
进一步阅读
-
集成方法:袋装与提升:
medium.com/@sainikhilesh/difference-between-bagging-and-boosting-f996253acd22 -
如何解释梯度提升:
explained.ai/gradient-boosting/ -
原文由杰罗姆·弗里德曼撰写,题为贪婪函数逼近:梯度提升机:
jerryfriedman.su.domains/ftp/trebst.pdf -
集成学习方法提高机器学习结果:
www.kdnuggets.com/2017/09/ensemble-learning-improve-machine-learning-results.html -
决策树简介:
www.analyticsvidhya.com/blog/2021/08/decision-tree-algorithm/ -
如何可视化决策树:
explained.ai/decision-tree-viz/ -
理解随机森林:
towardsdatascience.com/understanding-random-forest-58381e0602d2
第三部分:高级示例
在本部分中,我们将描述神经网络是什么以及它们如何应用于解决图像分类任务。我们还将描述现代大型语言模型(LLMs)是什么以及它们如何帮助解决诸如情感分析之类的神经网络处理任务。
本部分包括以下章节:
-
第十章, 用于图像分类的神经网络
-
第十一章,使用 BERT 和迁移学习进行情感分析
第十章:用于图像分类的神经网络
近年来,我们对神经网络产生了巨大的兴趣,它们在各种领域得到成功应用,如商业、医学、技术、地质和物理。神经网络在需要解决预测、分类或控制问题时发挥了作用。神经网络直观,因为它们基于人类神经系统的简化生物模型。它们源于人工智能领域的研究,即尝试通过模拟大脑的低级结构来复制生物神经系统的学习和纠错能力。神经网络是非线性建模方法,使我们能够复制极其复杂的依赖关系,因为它们是非线性的。神经网络也比其他不允许对大量变量建模依赖关系的方法更好地处理维度诅咒。
在本章中,我们将探讨人工神经网络的基本概念,并展示如何使用不同的 C++ 库实现神经网络。我们还将介绍多层感知器和简单的卷积网络的实现,并了解深度学习及其应用。
本章将涵盖以下主题:
-
神经网络概述
-
深入卷积网络
-
深度学习是什么?
-
使用 C++ 库创建神经网络的示例
-
使用 LeNet 架构理解图像分类
技术要求
-
完成本章内容你需要以下工具:
-
Dlib库 -
mlpack库 -
Flashlight库 -
PyTorch库 -
支持 C++20 的现代 C++ 编译器
-
CMake 构建系统版本 >= 3.22
本章的代码文件可以在以下 GitHub 仓库中找到:github.com/PacktPublishing/Hands-on-Machine-learning-with-C-Second-Edition/tree/main/Chapter10。
神经网络概述
在本节中,我们将讨论人工神经网络及其构建块是什么。我们将学习人工神经元的工作原理以及它们与生物类似物的关系。我们还将讨论如何使用反向传播方法训练神经网络,以及如何处理过拟合问题。
神经网络是一系列通过突触相互连接的神经元。神经网络的结构直接来自生物学。得益于这种结构,计算机可以分析和甚至记住信息。神经网络基于人脑,人脑包含数百万个神经元,它们以电脉冲的形式传递信息。
人工神经网络受到生物学的启发,因为它们由具有与生物神经元类似功能性的元素组成。这些元素可以组织成与大脑解剖相对应的方式,并且它们表现出许多大脑固有的特性。例如,它们可以从经验中学习,将先前的先例推广到新的案例,并从包含冗余信息的输入数据中识别出显著特征。
现在,让我们了解单个神经元的过程。
神经元
生物神经元由一个身体和连接它到外部世界的处理过程组成。神经元接收兴奋的处理过程称为树突。神经元传递兴奋的过程称为轴突。每个神经元只有一个轴突。树突和轴突具有相当复杂的分支结构。轴突和树突的连接点称为突触。以下图显示了生物神经元的结构:

图 10.1 – 生物神经元结构
神经元的主要功能是将来自树突的兴奋传递到轴突。然而,来自不同树突的信号可能会影响轴突中的信号。当总兴奋超过一定限值时,神经元会发出信号,这个限值在一定范围内变化。如果信号没有发送到轴突,神经元就不会对兴奋做出反应。神经元接收到的信号强度(因此是激活可能性)强烈依赖于突触活动。突触是传输这种信息的接触点。每个突触都有一个长度,并且特殊的化学物质沿着它传递信号。与生物系统相比,这个基本电路有很多简化和例外,但大多数神经网络都是基于这些简单特性来建模的。
人工神经元接收一组特定的信号作为输入,每个输入都是另一个神经元的输出。每个输入乘以相应的权重,这相当于其突触功率。然后,将所有乘积相加,这个求和的结果用于确定神经元的激活水平。以下图示演示了这个概念:

图 10.2 – 数学神经元结构
在这里,一组输入信号,表示为
,进入一个人工神经元。这些输入信号对应于到达生物神经元突触的信号。每个信号乘以相应的权重,
,然后传递到求和块。每个权重对应于一个生物突触连接的强度。求和块对应于生物神经元的身体,代数上组合加权输入。
信号,称为偏置,显示了极限值函数的作用,即所谓的和,使得f (和)的值属于特定的区间。也就是说,如果我们有一个大的输入数,通过激活函数传递它,我们就能得到所需的输出。存在许多激活函数,我们将在本章后面讨论它们。要了解更多关于神经网络的信息,我们将查看它们的一些更多组件。
感知器和神经网络
人工神经网络首次出现可以追溯到 1943 年发表的文章《神经活动内在逻辑演算》,其中提出了人工神经元的早期模型。后来,美国神经生理学家弗兰克·罗森布拉特在 1957 年发明了感知器概念,作为人类大脑信息感知的数学模型。目前,使用诸如单层感知器(SLP)或简称为感知器,以及多层感知器(MLP)这样的术语。通常,在感知器的层下面是一系列位于同一水平且未连接的神经元。以下图表显示了此模型:

图 10.3 – 感知器的一层
通常,我们可以区分以下类型的神经网络层:
-
输入层:这仅仅是作为系统(模型)输入的数据或信号源。例如,这些可以是训练集中特定向量的单个分量,
。 -
隐藏层:这是位于输入层和输出层之间的一层神经元。可能存在多个隐藏层。
-
输出层:这是最后的一层神经元,它汇总了模型的工作,其输出被用作模型工作的结果。
单层感知器这个术语通常被理解为描述一个由输入层和汇总输入数据的艺术神经元组成的模型。这个术语有时与罗森布拉特感知器这个术语一起使用,但这并不完全正确,因为罗森布拉特使用随机过程来设置输入数据和神经元之间的连接,以将数据传输到不同的维度,这使得解决分类线性不可分数据时出现的问题成为可能。在罗森布拉特的工作中,感知器由S和A神经元类型以及R加法器组成。S神经元是输入层,A神经元是隐藏层,R神经元生成模型的结果。术语的歧义产生是因为只使用了R神经元的权重,而在S和A神经元类型之间使用了常数权重。然而,请注意,这些类型神经元之间的连接是根据特定的随机过程建立的:


MLP 这个术语指的是一个由输入层、一定数量的隐藏层和输出层组成的模型。这可以在以下图中看到:

图 10.5 – 多层感知器(MLP)
应该注意的是,感知器(或神经网络)的架构包括信号传播的方向。在先前的例子中,所有通信都是从输入神经元指向输出神经元——这被称为前馈网络。其他网络架构也可能包括神经元之间的反馈。
在感知器的架构中,我们需要注意的第二点是神经元之间的连接数量。在先前的图中,我们可以看到同一层的每个神经元都与下一层的所有神经元相连——这被称为 全连接层。这并不是一个要求,但我们可以在 图 10.3 中的 罗森布拉特感知器 方案中看到一个具有不同类型连接的层的例子。
现在,让我们了解人工神经网络可以训练的一种方法。
使用反向传播法进行训练
让我们考虑用于训练前馈神经网络的常见方法:误差反向传播法。它与监督方法相关,因此需要训练示例中的目标值。
该算法使用神经网络的输出误差。在算法的每次迭代中,有两个网络传递——正向和反向。在正向传递中,一个输入向量从网络输入传播到其输出,形成一个与当前(实际)权重状态相对应的特定输出向量。然后,计算神经网络误差。在反向传递中,这个错误从网络输出传播到其输入,并纠正神经元权重。
用于计算网络误差的函数称为 损失函数。此类函数的一个例子是实际值和目标值之间差异的平方:

在这里,k 是网络中输出神经元的数量,y’ 是目标值,而 y 是实际输出值。该算法是迭代的,并使用 逐步 训练的原则;在提交一个训练示例作为输入后,网络的神经元权重会进行调整。在反向传播过程中,这个错误会从网络输出传播到其输入,以下规则会纠正神经元的权重:

这里,
是第
个神经元与第
个神经元的连接权重,
是学习率参数,它允许我们控制校正步骤
的值。为了准确调整到误差的最小值,这个值在学习过程中是实验选择的(它在 0 到 1 的范围内变化)。
选择合适的学习率可以显著影响机器学习模型的表现和收敛速度。如果学习率太小,模型可能收敛缓慢或根本不收敛。另一方面,如果学习率太大,模型可能会超过最优解并发散,导致精度差和过拟合。为了避免这些问题,根据具体问题和数据集仔细选择学习率非常重要。自适应学习率(如 Adam)可以帮助在训练过程中自动调整学习率,从而更容易获得好的结果。
是算法的层次结构编号(即步骤编号)。比如说,第i个神经元的输出总和如下:

从这个角度来看,我们可以展示以下内容:

这里,我们可以看到,网络中神经元的激活函数f(s)的微分
必须存在且在任何点上都不等于零;也就是说,激活函数必须在整个数值轴上可微分。因此,为了应用反向传播方法,通常使用 sigmoid 激活函数,如逻辑函数或双曲正切函数。
在实践中,训练不是一直持续到网络精确调整到误差函数的最小值,而是直到达到足够准确的近似值。这个过程使我们能够减少学习迭代次数,并防止网络过拟合。
目前,已经开发了许多反向传播算法的改进版本。让我们看看其中的一些。
反向传播方法模式
反向传播方法主要有三种模式:
-
随机
-
批量
-
小批量
让我们看看这些模式是什么,以及它们之间有何不同。
随机模式
在随机模式中,反向传播方法在计算一个训练样本的网络输出后立即对权重系数进行校正。
随机方法比批量方法慢。鉴于它不执行精确的梯度下降,而是通过未发展的梯度引入一些噪声,它可以跳出局部最小值并产生更好的结果。当处理大量训练数据时,它也更容易应用。
批量模式
对于梯度下降的批量模式,损失函数会立即对所有可用的训练样本进行计算,然后通过误差反向传播方法引入神经元权重系数的校正。
批量方法比随机模式更快、更稳定,但它倾向于停止并陷入局部最小值。此外,当需要训练大量数据时,它需要大量的计算资源。
小批量模式
在实践中,小批量通常被用作折衷方案。在处理几个训练样本(小批量)之后调整权重。这比随机下降做得少,但比批量模式做得多。
现在我们已经了解了主要的反向传播训练模式,让我们来讨论反向传播方法的问题。
反向传播方法问题
尽管小批量方法不是万能的,但目前它很普遍,因为它在计算可扩展性和学习有效性之间提供了一个折衷方案。它也有个别缺陷。它的大部分问题都来自无限长的学习过程。在复杂任务中,训练网络可能需要几天甚至几周的时间。此外,在训练网络时,由于校正,权重的值可能会变得非常大。这个问题可能导致所有或大多数神经元开始以巨大的值工作,在损失函数导数非常小的区域。由于学习过程中发送回的误差与这个导数成比例,学习过程实际上可能会冻结。
梯度下降法可能会陷入局部最小值而无法达到全局最小值。误差反向传播方法使用了一种梯度下降;也就是说,它是沿着误差表面下降,不断调整权重直到它们达到最小值。复杂网络的误差表面在多维空间中崎岖不平,由山丘、山谷、褶皱和峡谷组成。当附近存在一个更深的局部最小值时,网络可能会陷入局部最小值。在局部最小值点,所有方向都向上,网络无法从中逃脱。训练神经网络的主要困难在于用于退出局部最小值的方法:每次我们离开一个局部最小值,下一次局部最小值都是通过相同的方法搜索的,从而通过误差反向传播直到无法找到出路。
对收敛证明的仔细分析表明,权重校正被假定为无穷小。在实际中,这个假设是不可行的,因为它会导致无限的学习时间。步长应该取最终大小。如果步长固定且非常小,则收敛会太慢;如果固定且太大,则可能导致瘫痪或永久不稳定。今天,已经开发了许多使用可变校正步长的优化方法。它们根据学习过程调整步长(这类算法的例子包括 Adam、Adagrad、RMSProp、Adadelta 和 Nesterov 加速梯度)。
注意,网络可能存在过拟合的可能性。随着神经元数量的增加,网络泛化信息的能力可能会丧失。网络可以学习提供的整个训练样本集,但对于任何其他图像,即使是非常相似的,也可能被错误分类。为了防止这个问题,我们需要使用正则化,并在设计我们的网络架构时注意这一点。
反向传播方法 – 一个例子
要理解反向传播方法是如何工作的,让我们来看一个例子。
我们将为所有表达式元素引入以下索引:
是层的索引,
是层中神经元的索引,
是当前元素或连接(例如,权重)的索引。我们使用以下索引:
这个表达式应该被读作
层中
神经元的元素。
假设我们有一个由三层组成的网络,每层包含两个神经元:

图 10.6 – 三层神经网络
- 作为损失函数,我们选择实际值和目标值之间差异的平方:

-
在这里,
是网络输出的目标值,
是网络输出层的实际结果,
是输出层中的神经元数量。 -
这个公式计算了层中的神经元输出总和
,在
层:

-
在这里,
是特定神经元的输入数量,
是特定神经元的偏置值。 -
例如,对于第二层的第一个神经元,它等于以下内容:

-
不要忘记,第一层没有权重,因为这一层只代表输入值。
-
决定神经元输出的激活函数应该是 Sigmoid,如下所示:
![]()
-
它的性质以及其他激活函数将在本章后面讨论。因此,第 l 层的第 i 个神经元的输出(
)等于以下表达式:

- 现在,我们实现随机梯度下降;也就是说,我们在每个训练示例之后校正权重,并在权重多维空间中移动。为了达到误差的最小值,我们需要朝着梯度相反的方向移动。我们必须根据相应的输出为每个权重,
,添加误差校正。以下公式显示了我们是如何根据
输出计算误差校正值,
的:

- 现在我们有了误差校正值的公式,我们可以写出权重更新的公式:

-
这里,-
是一个学习率值。 -
误差相对于权重的偏导数,
,使用链式法则计算,该法则应用了两次。注意
只影响求和,
:

- 我们从输出层开始,推导出一个用于计算权重校正的表达式,
。为此,我们必须依次计算各个组成部分。考虑我们网络中误差的计算方式:

-
这里,我们可以看到
不依赖于
的权重。这个变量相对于它的偏导数等于
:![]()
-
然后,一般表达式变为遵循下一个公式:

- 表达式的第一部分计算如下:

- Sigmoid 导数是
,分别。对于表达式的第二部分,我们得到以下结果:

- 第三部分是求和的偏导数,其计算如下:

- 现在,我们可以将所有这些组合成一个公式:

- 我们还可以推导出一个通用公式,用于计算输出层所有权重的误差校正:

-
这里,
是网络输出层的索引。 -
现在,我们可以考虑如何对网络的内部(隐藏)层进行相应的计算。以权重,
为例。这里,方法相同,但有一个显著的区别——隐藏层神经元的输出传递到所有(或几个)输出层神经元的输入,这一点必须考虑:


- 在这里,我们可以看到
和
已经在之前的步骤中计算出来,并且我们可以使用它们的值来进行计算:

通过组合获得的结果,我们得到以下输出:

同样,我们可以使用之前步骤中计算出的值——
和
——来计算总和的第二部分:

权重校正表达式的剩余部分,
,如下获得,类似于如何获得输出层权重的表达式:

通过组合获得的结果,我们得到一个通用公式,我们可以用它来计算隐藏层权重调整的幅度:

在这里,
是隐藏层的索引,而
是其中的神经元数量。
层,
。
现在,我们拥有了描述误差反向传播算法主要步骤的所有必要公式:
-
使用小的随机值初始化所有权重,
(初始化过程将在后面讨论)。 -
依次重复此操作,对所有的训练样本或样本的小批量进行操作。
-
将一个训练样本(或样本的小批量)传递到网络输入,计算并记住所有神经元的输出。这些计算包括我们激活函数的所有总和和值。
-
计算输出层所有神经元的误差:
![]()
-
对于所有l层上的每个神经元,从倒数第二个开始,计算误差:

在这里,Lnext是l + 1层的神经元数量。
- 更新网络权重:

这里,
是学习率值。
反向传播算法有许多版本,可以改进算法的稳定性和收敛速度。最早提出的改进之一是使用动量。在每一步,值
被记住,在下一步,我们使用当前梯度值和前一步的线性组合:

是用于额外算法调整的超参数。这个算法现在比原始版本更常见,因为它允许我们在训练过程中取得更好的结果。
用于训练神经网络的下一个重要元素是损失函数。
损失函数
使用损失函数,神经网络训练被简化为选择权重矩阵系数的过程,以最小化误差。这个函数应该对应于任务,例如,对于分类问题使用类别交叉熵,对于回归使用差异的平方。如果使用反向传播方法来训练网络,可微性也是损失函数的一个基本属性。让我们看看神经网络中使用的流行损失函数:
-
均方误差(MSE)损失函数在回归和分类任务中得到了广泛的应用。分类器可以预测连续分数,这些是中间结果,只有在分类过程的最后一步才被转换为类别标签(通常通过阈值)。MSE 可以使用这些连续分数而不是类别标签来计算。这种做法的优点是避免了由于二分化而丢失信息。MSE 损失函数的标准形式定义如下:
![]()
-
均方对数误差(MSLE)损失函数是 MSE 的一种变体,其定义如下:

通过对预测值和目标值取对数,我们测量的方差已经改变。当预测值和实际值都是大数时,我们通常不希望对预测值和目标值之间的较大差异进行惩罚。此外,MSLE 对低估的惩罚比对高估的惩罚更大。
- L2损失函数是实际值与目标值之间差异的 L2 范数的平方。其定义如下:

- 平均绝对误差(MAE)损失函数用于衡量预测或预测值与最终结果之间的接近程度:

MAE 需要复杂的工具,如线性规划来计算梯度。由于它没有使用平方,MAE 比 MSE 对异常值更鲁棒。
- L1损失函数是实际值与目标值之间差异的绝对误差之和。与 MSE 和 L2 的关系类似,L1 在数学上与 MAE 相似,但它没有除以n。其定义如下:

- 交叉熵损失函数通常用于二元分类任务,其中标签被假定为取 0 或 1 的值。它被定义为如下:

交叉熵衡量两个概率分布之间的差异。如果交叉熵很大,这意味着两个分布之间的差异显著;如果交叉熵很小,这意味着两个分布彼此相似。交叉熵损失函数的优点是收敛速度更快,并且比二次损失函数更有可能达到全局优化。
- 负对数似然损失函数用于神经网络中的分类任务。当模型为每个类别输出一个概率而不是类别标签时使用。它定义如下:

- 余弦邻近损失函数计算预测值和目标值之间的余弦邻近度。它定义如下:

此函数与余弦相似度相同,它是两个非零向量之间相似度的度量。这表示为它们之间角度的余弦值。如果单位向量平行,则它们最大程度相似;如果它们正交,则最大程度不相似。
- Hinge 损失函数用于训练分类器。Hinge 损失也称为最大间隔目标,用于最大间隔分类。它使用分类器决策函数的原始输出,而不是预测的类别标签。它定义如下:

有许多其他的损失函数。复杂的网络架构通常使用多个损失函数来训练网络的各个部分。例如,用于在图像上预测对象类别和边界的Mask RCNN架构,使用不同的损失函数:一个用于回归,另一个用于分类器。在下一节中,我们将讨论神经元的激活函数。
激活函数
人工神经元的作用是什么?简单来说,它计算输入的加权和,加上偏置,并决定是否排除这个值或进一步使用它。人工神经元不知道一个阈值,可以用来判断输出值是否将神经元切换到激活状态。为此,我们添加一个激活函数。它检查神经元产生的值,以确定外部连接是否应该识别这个神经元是激活的,还是可以忽略。它根据输入加权和与阈值值的计算结果来确定神经元的输出值。
让我们考虑一些激活函数及其特性的例子。
步进激活函数
步进激活函数的工作方式是这样的——如果总和值高于特定的阈值值,我们认为神经元被激活。否则,我们说神经元是不活跃的。
此函数的图形可以在以下图中看到:

图 10.7 – 步进激活函数
当参数大于0时(零值是一个阈值),函数返回1(神经元已被激活),否则函数返回0(神经元未被激活)。这种方法很简单,但它有缺陷。想象一下我们正在创建一个二元分类器——一个应该说出是或否(激活或不)的模型。阶梯函数可以为我们做到这一点——它打印1或0。现在,想象一个需要更多神经元来分类许多类别的情形:class1、class2、class3,甚至更多。如果有多个神经元被激活会发生什么?所有激活函数的神经元都会得出1。
在这种情况下,关于给定对象最终应该获得哪个类别的疑问产生了。我们只想激活一个神经元,其他神经元的激活函数应该是零(除了在这种情况下,我们可以确信网络正确地定义了类别)。这样的网络更难以训练并达到收敛。如果激活函数不是二元的,那么可能的值是激活 50%,激活 20%,等等。如果有几个神经元被激活,我们可以找到激活函数值最高的神经元。由于神经元输出存在中间值,学习过程运行得更平滑、更快。
在阶梯激活函数中,在训练期间出现几个完全激活的神经元的可能性降低(尽管这取决于我们正在训练的内容以及数据)。此外,阶梯激活函数在点 0 处不可导,其导数在所有其他点都等于 0。这导致我们在使用梯度下降方法进行训练时遇到困难。
线性激活函数
线性激活函数,y = c x,是一条直线,并且与输入(即这个神经元的加权求和)成正比。这种激活函数的选择使我们能够得到一系列值,而不仅仅是二元答案。我们可以连接几个神经元,如果多个神经元被激活,决策将基于例如最大值的选取。
以下图表显示了线性激活函数的形状:

图 10.8 – 线性激活函数
关于y = c x相对于x的导数是c。这个结论意味着梯度与函数的参数无关。梯度是一个常数向量,而下降是根据一个常数梯度进行的。如果做出了错误的预测,那么反向传播误差的更新变化也是常数,并且不依赖于对输入所做的任何变化。
另一个问题:相关层。线性函数激活每一层。这个函数的值作为输入传递到下一层,而第二层考虑其输入的加权总和,并反过来包含神经元,这取决于另一个线性激活函数。我们有多少层无关紧要。如果它们都是线性的,那么最后一层的最终激活函数只是第一层输入的线性函数。这意味着两层(或 N 层)可以被一层替换。因此,我们失去了创建层集的能力。整个神经网络仍然类似于具有线性激活函数的一层,因为它是由线性函数的线性组合。
Sigmoid 激活函数
Sigmoid 激活函数,
,是一个平滑函数,类似于阶梯函数:

图 10.9 – Sigmoid 激活函数
Sigmoid 是一个非线性函数,sigmoid 的组合也产生一个非线性函数。这使我们能够组合神经元层。Sigmoid 激活函数不是二元的,它具有从范围 [0,1] 的值集合的激活,与阶梯函数相反。Sigmoid 也以平滑梯度为特征。在
的值从 -2 到 2 的范围内,值
变化非常快。这个梯度属性意味着在这个区域任何
值的微小变化都会导致
值的显著变化。这种函数的行为表明
倾向于附着在曲线的一侧。
Sigmoid 看起来是一个适合分类任务的函数。它试图将值带到曲线的一侧(例如,在
的上边缘和
的下边缘)。这种行为使我们能够在预测中找到清晰的边界。
Sigmoid 相对于线性函数的另一个优点如下:在第一种情况下,我们有一个固定的函数值范围,[0, 1],而线性函数在
内变化。这有利于处理激活函数上的大值时,不会导致数值计算错误。
现在,Sigmoid 函数是神经网络中最受欢迎的激活函数之一。但它也存在我们必须考虑的缺陷。当 Sigmoid 函数接近其最大值或最小值时,
的输出值往往弱地反映
的变化。这意味着这些区域的梯度取值很小,而小的值会导致梯度消失。梯度消失问题是一种梯度值变得太小或消失,神经网络拒绝进一步学习或学习速度非常慢的情况。
双曲正切
双曲正切是另一种常用的激活函数。它可以如下图形表示:

Figure 10.10 – 双曲正切激活函数
双曲正切函数与 Sigmoid 函数非常相似。这是正确的 Sigmoid 函数,
。因此,这样的函数具有与我们之前看到的 Sigmoid 函数相同的特性。它的本质是非线性的,非常适合层与层的组合,函数值的范围是
。因此,没有必要担心激活函数的值会导致计算问题。然而,值得注意的是,切向函数的梯度值比 Sigmoid 函数的梯度值高(导数比 Sigmoid 函数的导数更陡峭)。我们选择 Sigmoid 函数还是双曲正切函数取决于梯度幅度的要求。与 Sigmoid 函数一样,双曲正切函数也有固有的梯度消失问题。
修正线性单元(ReLU),
,如果
为正,则返回
,否则返回
:

Figure 10.11 – ReLU 激活函数
初看起来,ReLU 似乎与线性函数有相同的问题,因为 ReLU 在第一象限是线性的。但实际上,ReLU 是非线性的,ReLU 的组合也是非线性的。ReLU 的组合可以逼近任何函数。这一特性意味着我们可以使用层,它们不会退化成线性组合。ReLU 允许的值范围是
,这意味着它的值可以相当高,从而导致计算问题。然而,这一特性也消除了梯度消失的问题。建议使用正则化和归一化输入数据来解决大函数值问题(例如,到值范围[0,1])。
让我们来看看激活稀疏性作为神经网络的一个特性。想象一个拥有许多神经元的庞大神经网络。使用 sigmoid 或双曲正切函数意味着所有神经元都会被激活。这一动作意味着几乎所有的激活都必须被处理以计算网络输出。换句话说,激活是密集且昂贵的。
理想情况下,我们希望某些神经元不活跃,这将使激活变得稀疏且高效。ReLU 允许我们做到这一点。想象一个具有随机初始化权重(或归一化)的网络,由于 ReLU 的特性,大约 50%的激活为 0,因为![img/B19849_Formula_1112.png]的负值返回 0。在这样的网络中,包含的神经元较少(稀疏激活),网络本身也变得轻量。
由于 ReLU 的一部分是水平线(对于负值,见![img/B19849_Formula_1123.png]),这部分上的梯度为 0。这一特性导致在训练过程中无法调整权重。这种现象被称为渐逝 ReLU 问题。由于这个问题,一些神经元被关闭并且不响应,使得神经网络的大部分变得被动。然而,ReLU 的一些变体有助于解决这个问题。例如,用表达式![img/B19849_Formula_1142.png]将函数的水平部分(![img/B19849_Formula_1133.png]的区域)替换为线性部分是有意义的。还有其他避免零梯度的方法,但主要思想是使梯度非零,并在训练过程中逐渐恢复它。
此外,ReLU 在计算资源上的要求远低于双曲正切或 sigmoid,因为它执行比上述函数更简单的数学运算。
ReLU 的关键特性是其小的计算复杂度、非线性和对梯度消失问题的不敏感性。这使得它成为创建深度神经网络中最常用的激活函数之一。
现在我们已经研究了多种激活函数,我们可以突出它们的主要特性。
激活函数特性
以下是一个激活函数特性的列表,在决定选择哪种激活函数时值得考虑:
-
非线性:如果激活函数是非线性的,可以证明即使是双层神经网络也能成为函数的通用逼近器。
-
连续可微性:这一特性对于提供梯度下降优化方法是有益的。
-
值范围:如果激活函数的值集合有限,基于梯度的学习方法更稳定,更不容易出现计算错误,因为没有大值。如果值的范围是无限的,训练通常更有效,但必须小心避免梯度爆炸(这意味着梯度值可以达到极值,学习能力将丧失)。
-
单调性:如果激活函数是单调的,单层模型相关的误差表面将保证是凸的。这使我们能够更有效地学习。
-
单调导数的平滑函数:在某些情况下,这些提供了更高程度的一般性。
现在我们已经讨论了用于训练神经网络的主体组件,是时候学习如何处理在训练过程中经常出现的过度拟合问题。
神经网络中的正则化
过度拟合是机器学习模型,尤其是神经网络模型的问题之一。问题在于模型只解释了训练集中的样本,因此适应了训练样本而不是学习分类未参与训练过程的样本(失去了泛化的能力)。通常,过度拟合的主要原因在于模型的复杂性(从参数数量来看)。这种复杂性可能对于可用的训练集来说太高,最终对于要解决的问题来说也是如此。正则化器的作用是降低模型的复杂性,同时保留参数数量。让我们考虑在神经网络中常用的最常见正则化方法。
最受欢迎的正则化方法是 L2 正则化、dropout 和批量归一化。让我们逐一看看。
L2 正则化
L2 正则化(权重衰减)通过惩罚具有最高值的权重来实现。惩罚是通过最小化它们的
-范数来实现的,使用
参数——一个正则化系数,表示在需要最小化训练集上的损失时,我们倾向于最小化范数。也就是说,对于每个权重
,我们在损失函数
中添加项
(使用
因子是为了使这个项相对于
参数的梯度等于
而不是
,以便于应用误差反向传播方法)。我们必须正确选择
。如果系数太小,那么正则化的效果可以忽略不计。如果太大,模型可以重置所有权重。
Dropout 正则化
Dropout 正则化包括改变网络结构。每个神经元有概率被排除在网络结构之外,概率为
。排除一个神经元意味着无论输入数据或参数如何,它都会返回 0。
被排除的神经元在任何阶段的反向传播算法中都不会对学习过程做出贡献。因此,至少排除一个神经元相当于学习一个新的神经网络。这种稀疏网络用于训练剩余的权重。在执行梯度步骤之后,所有被驱逐的神经元都会返回到神经网络中。因此,在训练的每一步,我们设置可能的 2N 个网络架构之一。当我们评估神经网络时,神经元不再被排除。每个神经元的输出乘以(1 - p)。这意味着在神经元的输出中,我们接收所有 2N 个架构的响应期望。因此,使用 Dropout 正则化训练的神经网络可以被视为从 2*N 个网络集合中平均响应的结果。
批量归一化
批量归一化确保神经网络的有效学习过程不受阻碍。当信号通过网络内部层的传播时,均值和方差可能会显著扭曲输入信号,即使我们在网络输入处最初已经对信号进行了归一化。这种现象称为内部协方差偏移,并在不同层或级别的梯度之间产生严重差异。因此,我们必须使用更强的正则化器,这会减慢学习速度。
批量归一化为此问题提供了一个简单直接的解决方案:以获得零均值和单位方差的方式对输入数据进行归一化。归一化在每个层进入之前执行。在训练过程中,我们对批次样本进行归一化,在使用过程中,我们根据整个训练集获得的统计数据归一化,因为我们无法提前看到测试数据。我们按照以下方式计算特定批次
的均值和方差:

使用这些统计特性,我们将激活函数转换为在整个批次中具有零均值和单位方差的形式:

在这里,
是一个参数,它保护我们在批次的方差非常小或甚至等于零的情况下避免除以 0。最后,为了得到最终的激活函数
,我们需要确保在归一化过程中,我们不会失去泛化的能力。由于我们对原始数据应用了缩放和移位操作,我们可以允许对归一化值进行任意的缩放和移位,从而获得最终的激活函数:

在这里,
和
是系统可以训练的批归一化的参数(它们可以通过在训练数据上的梯度下降法进行优化)。这种泛化还意味着,当直接应用神经网络的输入时,批归一化可能是有用的。
当这种方法应用于多层网络时,几乎总是成功地达到其目标——加速学习。此外,它是一个出色的正则化器,允许我们选择学习率、
正则化器的幂和 dropout。这里的正则化是这样一个事实的结果,即网络对特定样本的结果不再是确定的(它依赖于得到这个结果的整体批次),这简化了泛化过程。
我们接下来要探讨的下一个重要主题是神经网络初始化。它影响训练过程的收敛性、训练速度和整体网络性能。
神经网络初始化
选择构成模型层的权重初始值的原则非常重要。将所有权重设置为 0 是一个严重的阻碍学习的障碍,因为没有任何权重可以最初是活跃的。从区间[0, 1]的随机值分配权重通常也不是最佳选择。实际上,模型性能和学习过程收敛可以很大程度上依赖于正确的权重初始化;然而,初始任务和模型复杂性也可能扮演着重要的角色。即使任务的解决方案不假设对初始权重值有强烈的依赖性,一个精心选择的重置权重的方法也可以显著影响模型学习的能力。这是因为它在考虑损失函数的同时预设了模型参数。让我们看看两种常用的初始化权重的流行方法。
Xavier 初始化方法
Xavier 初始化方法用于简化在正向传递和反向传递误差时通过层的信号流。这种方法对 sigmoid 函数也适用,因为其未饱和区域也具有线性特征。在计算权重时,此方法依赖于具有方差
的概率分布(如均匀分布或正态分布),其中
和
分别是前一层和后一层中的神经元数量。
他初始化方法
He 初始化方法是 Xavier 方法的变体,更适合 ReLU 激活函数,因为它补偿了该函数在定义域的一半返回零的事实。这种权重计算方法依赖于以下方差的概率分布:

还有其他权重初始化方法。你选择哪种方法通常取决于要解决的问题、网络拓扑、使用的激活函数和损失函数。例如,对于递归网络,可以使用正交初始化方法。我们将在第十二章**,导出和导入模型中提供一个具体的神经网络初始化编程示例。
在前面的章节中,我们讨论了人工神经网络的基本组件,这些组件几乎适用于所有类型的网络。在下一节中,我们将讨论常用于图像处理的卷积神经网络的特点。
深入卷积网络
MLP(多层感知器)是最强大的前馈神经网络。它由多个层组成,其中每个神经元接收来自前一层神经元的所有输出副本。这种模型适用于某些类型的任务,例如,在有限数量的更多或更少的非结构化参数上进行训练。
然而,让我们看看当使用原始数据作为输入时,这种模型中的参数(权重)数量会发生什么变化。例如,CIFAR-10 数据集包含 32 x 32 x 32 彩色图像,如果我们考虑每个像素的每个通道作为 MLP 的独立输入参数,第一隐藏层中的每个神经元都会为模型增加大约 3,000 个新参数!随着图像尺寸的增加,情况迅速失控,产生用户无法用于实际应用的图像。
一种流行的解决方案是降低图像的分辨率,以便 MLP(多层感知器)变得适用。然而,当我们降低分辨率时,我们可能会丢失大量信息。如果在应用降低质量之前处理信息,那么我们不会导致模型参数数量的爆炸性增加,这将是极好的。有一种非常有效的方法可以解决这个问题,那就是基于卷积操作。
卷积算子
这种方法最初用于处理图像的神经网络,但它已经成功地用于解决其他学科领域的问题。让我们考虑使用这种方法进行图像分类。
假设当形成我们感兴趣的特征(图像中的物体特征)时,相邻的图像像素比位于相当距离的像素相互作用更紧密。此外,如果一个小特征在图像分类过程中被认为非常重要,那么这个特征在图像的哪个部分被发现并不重要。
让我们来看看卷积算子的概念。我们有一个二维图像 I 和一个小的 K 矩阵,其尺寸为 h x w(所谓的卷积核),它以图形方式编码了一个特征。我们计算 I * K 的最小化图像,将核心以所有可能的方式叠加到图像上,并记录原始图像和核的元素之和:

精确的定义假设核矩阵是转置的,但对于机器学习任务来说,这个操作是否执行并不重要。卷积算子是 CNN(卷积神经网络)中卷积层的基础。该层由一定数量的核组成,
(每个核具有加性位移组件,
),并使用每个核计算前一层输出图像的卷积,每次添加一个位移组件。最后,可以将激活函数,
,应用于整个输出图像。通常,卷积层的输入流由 d 个通道组成——例如,对于输入层,红色/绿色/蓝色,在这种情况下,核也扩展,以便它们也由 d 个通道组成。以下公式是卷积层输出图像的一个通道,其中 K 是核,b 是步长(位移)组件:

以下图示以示意图形式展示了前面的公式:

图 10.12 – 卷积操作方案
如果加法(步长)组件不等于 1,则可以如下示意图表示:

图 10.13 – 步长等于一的卷积
请注意,由于我们在这里所做的只是添加和缩放输入像素,因此可以使用梯度下降法从现有的训练样本中获得内核,类似于在 MLP 中计算权重。MLP 可以完美地处理卷积层的功能,但它需要更长的训练时间,以及更多的训练数据。
注意到卷积算子不仅限于二维数据:大多数深度学习框架直接提供了一维或N维卷积的层。还值得注意的是,尽管与全连接层相比,卷积层减少了参数的数量,但它使用了更多的超参数——在训练之前选择的参数。
特别是,以下超参数被选中:
-
深度:一个层中涉及多少个内核和偏置系数。
-
每个内核的高度和宽度。
-
步长(步距):在计算结果图像的下一个像素时,内核在每一步中移动的距离。通常,所采用的步长值等于 1,值越大,产生的输出图像的大小就越小。
-
填充:请注意,对大于 1 x 1 维度的任何内核进行卷积都会减小输出图像的大小。由于通常希望保持原始图像的大小,因此沿边缘补充了模式。
卷积层的一次遍历通过减少特定通道的长度和宽度来影响图像,但增加其值(深度)。
另一种减少图像维度并保留其一般属性的方法是对图像进行下采样。执行此类操作的神经网络层被称为池化层。
池化操作
池化层接收图像的小片段,并将每个片段组合成一个值。有几种可能的聚合方法。最直接的方法是从像素集中取最大值。这种方法在以下示意图中显示:

图 10.14 – 池化操作
让我们考虑最大池化是如何工作的。在先前的图中,我们有一个 6 x 6 大小的数字矩阵。池化窗口的大小为 3,因此我们可以将这个矩阵分成四个 3 x 3 大小的更小的子矩阵。然后,我们可以从每个子矩阵中选择最大值,并从这些数字中构建一个 2 x 2 大小的更小的矩阵。
卷积层或池化层最重要的特性是其受感野值,这使我们能够了解用于处理的信息量。让我们详细讨论一下。
受感野
卷积神经网络架构的一个基本组成部分是在模型输入到输出的数据量减少的同时,仍然增加通道深度。如前所述,这通常是通过选择卷积步长(步长)或池化层来实现的。受信域决定了原始输入在输出中处理了多少。受信域的扩展允许卷积层将低级特征(线条、边缘)组合成高级特征(曲线、纹理)。

图 10.15 – 受信域概念
层k的受信域可以用以下公式给出:

这里,
是层k - 1的受信域,k - 1,
是滤波器大小,
是层i的步长。因此,对于前面的例子,输入层的RF = 1,隐藏层的RF = 3,最后一层的RF = 5。
现在我们已经熟悉了 CNN 的基本概念,让我们看看如何将它们结合起来创建一个具体的图像分类网络架构。
卷积网络架构
该网络从初始阶段的一小部分低级滤波器发展到大量滤波器,每个滤波器都找到特定的中级属性。从级别到级别的过渡提供了模式识别的层次结构。
首先成功应用于模式识别任务的卷积网络架构之一是 LeNet-5,由 Yann LeCun、Leon Bottou、Yosuha Bengio 和 Patrick Haffner 开发。它在 20 世纪 90 年代用于识别手写和印刷数字。以下图表显示了该架构:

图 10.16 – LeNet-5 网络架构
该架构的网络层在以下表格中解释:
| 编号 | 层 | 特征图(深度) | 大小 | 核大小 | 步长 | 激活函数 |
|---|---|---|---|---|---|---|
| 输入 | 图像 | 1 | 32 x 32 | - | - | - |
| 1 | 卷积 | 6 | 28 x 28 | 5 x 5 | 1 | tanh |
| 2 | 平均池化 | 6 | 14 x 14 | 2 x 2 | 2 | tanh |
| 3 | 卷积 | 16 | 10 x 10 | 5 x 5 | 1 | tanh |
| 4 | 平均池化 | 16 | 5 x 5 | 2 x 2 | 2 | tanh |
| 5 | 卷积 | 120 | 1 x 1 | 5 x 5 | 1 | tanh |
| 6 | FC | 84 | - | - | tanh | |
| 输出 | FC | 10 | - | - | softmax |
表 10.1 – LeNet-5 层属性
注意层深和层大小是如何向最终层变化的。我们可以看到深度在增加,而大小在减小。这意味着在最终层,网络可以学习的特征数量增加了,但它们的尺寸变小了。这种行为在不同的卷积网络架构中非常常见。
在下一节中,我们将讨论深度学习,它是机器学习的一个子集,使用人工神经网络进行学习和决策。它被称为深度学习,因为所使用的神经网络具有多层,这使得它们能够模拟数据中的复杂关系和模式。
深度学习是什么?
最常使用深度学习这个术语来描述设计用于处理大量数据并使用复杂算法来训练模型的人工神经网络。深度学习的算法可以使用监督和非监督算法(强化学习)。学习过程是深度的,因为随着时间的推移,神经网络覆盖的层级越来越多。网络越深(即,具有更多的隐藏层、滤波器和特征抽象层级),网络的性能就越高。在大数据集上,深度学习比传统的机器学习算法表现出更好的准确性。
导致当前对深度神经网络兴趣复苏的真正突破发生在 2012 年,在ACM 通讯杂志上发表的使用深度卷积神经网络进行 ImageNet 分类一文之后,由Alex Krizhevsky、Ilya Sutskever和Geoff Hinton撰写。作者们汇集了许多不同的学习加速技术。这些技术包括卷积神经网络、智能使用 GPU 以及一些创新的数学技巧:优化线性神经元(ReLU)和 dropout,表明他们可以在几周内训练一个复杂的神经网络,其结果将超越传统方法在计算机视觉中的应用。
现在,基于深度学习的系统已应用于各个领域,并成功取代了传统机器学习的传统方法。以下是一些深度学习应用的领域示例:
-
语音识别:所有主要的商业语音识别系统(如微软小娜、Xbox、Skype 翻译器、亚马逊 Alexa、谷歌 Now、苹果 Siri、百度和科大讯飞)都是基于深度学习的。
-
计算机视觉:今天,深度学习图像识别系统已经能够给出比人眼更准确的结果,例如,在分析医学研究图像(MRI、X 射线等)时。
-
新药发现:例如,AtomNet 神经网络被用来预测新的生物分子,并被提出用于治疗埃博拉病毒和多发性硬化症等疾病。
-
推荐系统:今天,深度学习被用来研究用户偏好。
-
生物信息学:它也被用来研究基因本体预测。
随着我们深入神经网络开发的领域,我们将探讨如何使用 C++库创建和训练人工神经网络模型。
使用 C++库创建神经网络的示例
许多机器学习库都有用于创建和操作神经网络的 API。我们在前几章中使用的所有库——mlpack、Dlib和Flashlight——都支持神经网络。但也有一些专门用于神经网络的框架;例如,一个流行的框架是 PyTorch 框架。专门库和通用库之间的区别在于,专门库支持更多可配置选项和不同类型的网络、层和损失函数。此外,专门库通常拥有更现代的工具,并且这些工具会更快地引入到它们的 API 中。
在本节中,我们将使用mlpack、Dlib和Flashlight库创建一个用于回归任务的简单 MLP。我们还将使用 PyTorch C++ API 创建一个更高级的网络——具有 LeNet5 架构的卷积深度神经网络,我们之前在卷积网络架构部分讨论过。我们将使用这个网络进行图像分类。
让我们学习如何使用mlpack、Dlib和Flashlight库来创建一个用于回归任务的简单 MLP。对于所有系列样本,任务都是相同的——MLP 应该学习在有限区间内的余弦函数。在这本书的代码示例中,我们可以找到数据生成和 MLP 训练的完整程序。在这里,我们将讨论用于神经网络 API 视图的程序的基本部分。请注意,我们将为这些示例使用 Tanh 和 ReLU 函数作为激活函数。我们选择它们是为了实现特定任务的更好收敛。
Dlib
Dlib库有一个用于处理神经网络的 API。它还可以通过 Nvidia CUDA 支持进行性能优化。如果我们计划处理大量数据和深度神经网络,使用 CUDA 或 OpenCL 对于 GPU 来说非常重要。
Dlib库中用于神经网络的策略与该库中其他机器学习算法的策略相同。我们应该实例化和配置所需算法类的对象,然后使用特定的训练器在数据集上对其进行训练。
在Dlib库中存在用于训练神经网络的dnn_trainer类。这个类的对象应该使用具体的网络对象和优化算法对象进行初始化。最流行的优化算法是带有动量的随机梯度下降算法,这在反向传播方法模式章节中已经讨论过。这个算法在sgd类中实现。sgd类的对象应该配置权重衰减正则化和动量参数值。dnn_trainer类有以下基本配置方法:set_learning_rate、set_mini_batch_size和set_max_num_epochs。这些分别设置学习率参数值、小批量大小和最大训练轮数。此外,这个训练类支持动态学习率变化,因此我们可以,例如,为后续轮次设置较低的学习率。学习率衰减参数可以通过set_learning_rate_shrink_factor方法进行配置。但在下面的例子中,我们将使用恒定学习率,因为对于这个特定的数据,它给出了更好的训练结果。
实例化训练对象下一个基本的项目是神经网络类型对象。Dlib库使用声明式风格来定义网络架构,为此,它使用 C++模板。因此,为了定义神经网络架构,我们应该从网络的输入开始。在我们的案例中,这是matrix<double>类型。我们需要将这个类型作为模板参数传递给下一个层类型;在我们的案例中,这是fc类型的全连接层。全连接层类型也接受神经元数量作为模板参数。为了定义整个网络,我们应该创建嵌套类型定义,直到我们达到最后一层和损失函数。在我们的案例中,这是loss_mean_squared类型,它实现了均方损失函数,这通常用于回归任务。
下面的代码片段显示了使用Dlib库 API 的网络定义:
using NetworkType = loss_mean_squared<fc<
1,
htan<fc<
8,
htan<fc<16,
htan<fc<32, input<matrix<double>>>>>>>>>>;
这个定义可以按照以下顺序阅读:
-
我们从输入层开始:
input<matrix<double> -
然后,我们添加了具有 32 个神经元的第一个隐藏层:
fc<32, input<matrix<double>> -
然后,我们在第一个隐藏层添加了双曲正切激活函数:
htan<fc<32, input<matrix<double>>> -
接下来,我们添加了具有 16 个神经元和激活函数的第二个隐藏层:
htan<fc<16, htan<fc<32, input<matrix<double>>>>>> -
然后,我们添加了具有 8 个神经元和激活函数的第三个隐藏层:
htan<fc<8, htan<fc<16, htan<fc<32, input<matrix<double>>>>>>>> -
然后,我们添加了没有激活函数的具有 1 个神经元的最后一个输出层:
fc<1, htan<fc<8, htan<fc<16, htan<fc<32, input<matrix<double>>>>>>>>> -
最后,我们完成了损失函数:
loss_mean_squared<...>
下面的片段显示了包含网络定义的完整源代码示例:
size_t n = 10000;
...
std::vector<matrix<double>> x(n);
std::vector<float> y(n);
...
using NetworkType = loss_mean_squared<
fc < 1, htan < fc < 8, htan < fc < 16, htan < fc < 32,
input < matrix < double >>>>>>>>>>;
NetworkType network;
float weight_decay = 0.0001f;
float momentum = 0.5f;
sgd solver(weight_decay, momentum);
dnn_trainer<NetworkType> trainer(network, solver);
trainer.set_learning_rate(0.01);
trainer.set_learning_rate_shrink_factor(1); // disable learning rate
//changes
trainer.set_mini_batch_size(64);
trainer.set_max_num_epochs(500);
trainer.be_verbose();
trainer.train(x, y);
network.clean();
auto predictions = network(new_x);
现在我们已经配置了训练器对象,我们可以使用train方法开始实际的训练过程。此方法接受两个 C++向量作为输入参数。第一个应该包含matrix<double>类型的训练对象,第二个应该包含目标回归值,这些值是float类型。我们还可以调用be_verbose方法来查看训练过程的输出日志。在训练网络之后,我们调用clean方法以允许网络对象清除中间训练值并因此减少内存使用。
mlpack
要使用mlpack库创建神经网络,我们必须首先定义网络的架构。我们使用mlpack库中的FFN类来这样做,它用于聚合网络层。FFN代表前馈网络。库中有用于创建层的类:
-
线性:具有输出尺寸值的全连接层 -
Sigmoid:sigmoid 激活函数层 -
卷积:二维卷积层 -
ReLU:ReLU 激活函数层 -
MaxPooling:最大池化层 -
Softmax:具有 softmax 激活函数的层
库中还有其他类型的层。所有这些都可以添加到 FFN 类型对象中,以创建神经网络。创建神经网络的第一个步骤是FFN对象的实例化,可以这样做:
MeanSquaredError loss;
ConstInitialization init(0.);
FFN<MeanSquaredError, ConstInitialization> model( loss, init);
你可以看到FFN类构造函数接受两个参数。第一个参数是一个损失函数对象,在我们的例子中是MeanSeqaredError类型对象。第二个参数是一个初始化对象;我们使用了ConstantInitialization类型并设置为0值。mlpack库中还有其他初始化类型;例如,你可以使用HeInitialization或GlorotInitialization类型。
然后,为了向网络添加一个新层,我们可以使用以下代码:
model.Add<Linear>(8);
model.Add<ReLU>();
...
新对象层是通过Add方法添加的,并且使用了模板参数来专门化层类型。此方法接受一个变量作为参数的数量,这取决于层类型。在这个例子中,我们传递了一个单一参数——全连接线性层的输出维度。FNN对象自动配置输入维度。
在我们能够训练网络之前,我们必须创建一个优化方法对象。我们可以这样做:
size_t epochs = 100;
ens::MomentumSGD optimizer(
/*stepSize=*/0.01,
/*batchSize=*/ 64,
/*maxIterations=*/ epochs * x.n_cols,
/*tolerance=*/1e-10,
/*shuffle=*/false);
我们创建了一个实现带有动量的随机梯度下降优化的对象。在mlpack库中的优化器类型不仅接受优化参数,例如学习率值,还包括配置整个训练周期的参数。我们传递了学习率、批量大小、迭代次数、早期停止的损失值容忍度以及打乱数据集的标志作为参数。请注意,我们没有直接传递训练周期数;相反,我们计算了maxIteration参数值作为周期数和训练元素数的乘积。MomentumSGD类只是SGD类与MomentumUpdate策略类的模板特化。因此,要更新默认的动量值,我们必须访问特定的字段,如下所示:
optimizer.UpdatePolicy().Momentum() = 0.5;
mlpack库中还有各种其他遵循相同初始化方案的优化器。
在有了网络和优化器对象后,我们可以如下训练一个模型:
model.Train(x, y, optimizer);
我们将训练样本的x和y矩阵以及优化器对象作为参数传递给FFN类型对象的Train方法。在mlpack库中没有专门的数据集类型,因此使用原始的arma::mat对象来完成这个目的。在一般情况下,训练是静默进行的,这在实验中并不有用。因此,Train方法中有额外的参数来增加详细程度。在优化器参数之后,Train方法接受多个回调。例如,如果我们想在控制台看到带有损失值的训练过程日志,我们可以添加ProgressBar对象回调如下:
model.Train(x, y, optimizer, ens::ProgressBar());
此外,我们还可以添加另一个类型回调。在下面的示例中,我们添加了早期停止回调和记录最佳参数值的回调:
ens::StoreBestCoordinates<arma::mat> best_params;
model.Train(scaled_x,
scaled_y,
optimizer,
ens::ProgressBar(),
ens::EarlyStopAtMinLoss(20),
best_params);
我们配置了训练,如果损失值在 20 个批次内没有变化,则停止训练,并将最佳损失值的参数保存到best_params对象中。
本示例的完整源代码如下:
MeanSquaredError loss;
ConstInitialization init(0.);
FFN<MeanSquaredError, ConstInitialization> model( loss, init);
model.Add<Linear>(8);
model.Add<ReLU>();
model.Add<Linear>(16);
model.Add<ReLU>();
model.Add<Linear>(32);
model.Add<ReLU>();
model.Add<Linear>(1);
// Define optimizer
size_t epochs = 100;
ens::MomentumSGD optimizer(
/*stepSize=*/0.01,
/*batchSize= */ 64,
/*maxIterations= */ epochs * x.n_cols,
/*tolerance=*/1e-10,
/*shuffle=*/false);
ens::StoreBestCoordinates<arma::mat> best_params;
model.Train(x, y, optimizer, ens::ProgressBar(),
ens::EarlyStopAtMinLoss(20), best_params);
在我们有一个训练好的模型后,我们可以如下使用它进行预测:
arma::mat predictions;
model.Predict(x, predictions);
在这里,我们创建了一个名为predictions的输出变量,并将其与输入变量x一起传递给Predict方法。model对象包含所有最新的训练权重,但我们可以用我们在best_weights回调中保存的最佳权重来替换它们如下:
model.Parameters() = best_params.BestCoordinates();
我们只是使用model对象的Parameters方法替换了当前的权重。
在下一小节中,我们将使用 Flashlight 框架实现相同的神经网络。
Flashlight
要使用 Flashlight 库创建神经网络,你必须遵循与我们使用mlpack库相同的步骤。主要区别在于你需要自己实现训练循环。这在你处理复杂架构和训练方法时提供了更多的灵活性。让我们从网络定义开始。我们创建了一个具有全连接线性层的前馈模型,如下所示:
fl::Sequential model;
model.add(fl::View({1, 1, 1, -1}));
model.add(fl::Linear(1, 8));
model.add(fl::ReLU());
使用Sequential类创建网络对象,然后使用add方法填充它以包含层。我们使用了与上一个示例中相同的Linear和ReLU层。主要区别在于我们添加的第一个层是View类型对象。这是为了使模型正确处理一批输入数据。Flashlight 张量数据布局{1,1,1,-1}意味着我们的输入数据是单通道、一维数据,批次大小应该自动检测,因为我们使用了-1作为最后一个维度。
接下来,我们必须定义一个损失函数对象,如下所示:
auto loss = fl::MeanSquaredError();
我们再次使用了 MSE 损失,因为我们正在解决相同的回归任务。创建优化器对象的外观与其他框架类似:
float learning_rate = 0.01;
float momentum = 0.5;
auto sgd = fl::SGDOptimizer(model.params(), learning_rate, momentum);
我们还使用了带有动量算法的随机梯度下降。请注意,优化器对象构造函数将模型参数作为第一个参数。这与Dlib和mlpack库中的方法不同,在那些库中,训练过程主要隐藏在顶级训练 API 中。
将模型参数传递给优化器的做法在可以更精确配置训练过程的框架中更为常见;你将在 PyTorch 中看到它。
在所有基础块初始化完成后,我们可以实现一个训练循环。这样的循环将包含以下重要步骤:
-
预测步骤——前向传播。
-
计算损失值。
-
梯度计算——反向传播。
-
使用梯度值的优化步骤。
-
清除梯度。
我们可以按照以下步骤实现这些步骤:
const int epochs = 500;
for (int epoch_i = 0; epoch_i < epochs; ++epoch_i) {
for (auto& batch : batch_dataset) {
// Forward propagation
auto predicted = model(fl::input(batch[0]));
// Calculate loss
auto local_batch_size = batch[0].shape().dim(0);
auto target =
fl::reshape(batch[1], {1, 1, 1, local_batch_size});
auto loss_value =
loss(predicted,
fl::noGrad(target)); // Backward propagation
loss_value.backward();
// Optimization - updating parameters
sgd.step();
// clearing graients
sgd.zeroGrad();
}
}
注意,我们在我们的训练数据集中创建了两个循环,一个是关于时代的循环,另一个是关于批次的内部循环。在内循环中,我们使用了batch_dataset变量;我们假设它具有fl::BatchDataset数据集类型,因此batch循环变量是张量的std::vector。通常,它将只有两个张量,用于输入数据和目标批次数据。
我们使用了fl::input函数将我们的输入批次张量batch[0]包装成禁用梯度计算的 Flashlight Variable类型。Variable类型用于 Flashlight 自动梯度机制。对于目标批次数据batch[1],我们使用了fl::noGrad函数来禁用梯度计算。
我们的model对象返回一个具有 WHCN 格式的 4D 形状的预测张量。如果你没有对你的数据集进行重塑以获得方便的形状,你将不得不像在这个例子中那样为每个批次使用fl::reshape函数;否则,你将在损失值计算中遇到形状不一致的错误。
在使用loss对象计算预测值和目标值得到的损失值之后,我们计算了梯度值。这是通过loss_value对象的backward方法完成的,它具有fl::Variable类型。
在计算了梯度值之后,我们使用了sgd对象的step方法来应用网络参数的优化步骤。请记住,我们使用模型参数(权重)初始化了优化sgd对象。在最后一步,我们调用优化器的zeroGrad方法来清除网络参数的梯度。
当网络(模型)训练完成后,很容易用于预测,如下所示:
auto predicted = model(fl::noGrad(x));
x应该是你的输入数据。在模型评估(预测)阶段禁用梯度计算非常重要,因为它可以节省大量的计算资源并提高整体模型吞吐量。
在下一节中,我们将使用PyTorch库实现一个更复杂的神经网络来解决图像分类任务。
使用 LeNet 架构理解图像分类
在本节中,我们将实现一个用于图像分类的卷积神经网络(CNN)。我们将使用著名的手写数字数据集,称为修改后的国家标准与技术研究院(MNIST)数据集,该数据集可在yann.lecun.com/exdb/mnist/找到。该数据集是一个标准,由美国国家标准与技术研究院提出,用于使用机器学习校准和比较图像识别方法,主要基于神经网络。
数据集的制作者使用了一组来自美国人口普查局的样本,后来又添加了一些美国大学学生的样本。所有样本都是 28 x 28 像素的归一化、抗锯齿的灰度图像。MNIST 数据集包含 60,000 个用于训练的图像和 10,000 个用于测试的图像。共有四个文件:
-
train-images-idx3-ubyte:训练集图像 -
train-labels-idx1-ubyte:训练集标签 -
t10k-images-idx3-ubyte:测试集图像 -
t10k-labels-idx1-ubyte:测试集标签
包含标签的文件格式如下:
| 偏移量 | 类型 | 值 | 描述 |
|---|---|---|---|
| 0 | 32 位整数 | 0x00000801(2049) | 魔数(最高位优先) |
| 4 | 32 位整数 | 60,000 或 10,000 | 项目数量 |
| 8 | 无符号字符 | ?? | 标签 |
| 9 | 无符号字符 | ?? | 标签 |
| ... | ... | ... | ... |
表 10.2 – MNIST 标签文件格式
标签值从0到9。包含图像的文件格式如下:
| 偏移量 | 类型 | 值 | 描述 |
|---|---|---|---|
| 0 | 32 位整数 | 0x00000803(2051) | 魔数(大端优先) |
| 0 | 32 位整数 | 60,000 或 10,000 | 图像数量 |
| 0 | 32 位整数 | 28 | 行数 |
| 0 | 32 位整数 | 28 | 列数 |
| 0 | 无符号字节 | ?? | 像素 |
| 0 | 无符号字节 | ?? | 像素 |
| ... | ... | ... | ... |
表 10.3 – MNIST 图像文件格式
像素以行优先的方式存储,值在[0, 255]范围内。0表示背景(白色),而255表示前景(黑色)。
在这个例子中,我们使用 PyTorch 深度学习框架。这个框架主要用于 Python 语言。然而,其核心部分是用 C++编写的,并且有一个良好文档和积极开发的 C++客户端 API,称为LibPyTorch。该框架基于名为ATen的线性代数库,它大量使用 Nvidia CUDA 技术来提高性能。Python 和 C++ API 几乎相同,但具有不同的语言符号,因此我们可以使用官方 Python 文档来学习如何使用该框架。该文档还包含一个说明 C++和 Python API 之间差异的部分,以及关于 C++ API 使用的特定文章。
PyTorch 框架在深度学习研究中被广泛使用。正如我们之前讨论的,该框架提供了管理大数据集的功能。它可以自动并行化从磁盘加载数据,管理预加载的数据缓冲区以减少内存使用,并限制昂贵的性能磁盘操作。它为用户自定义数据集提供了torch::data::Dataset基类实现。我们在这里只需要重写两个方法:get和size。这两个方法不是虚拟的,因为我们必须使用 C++模板的多态性来继承这个类。
读取训练数据集
考虑MNISTDataset类,它提供了对 MNIST 数据集的访问。该类的构造函数接受两个参数:一个是包含图像的文件名,另一个是包含标签的文件名。它将整个文件加载到其内存中,这不是最佳实践,但对于这个数据集,这种方法效果很好,因为数据集很小。对于更大的数据集,我们必须实现从磁盘读取数据的另一种方案,因为在实际任务中,我们通常无法将所有数据加载到计算机的内存中。
我们使用 OpenCV 库来处理图像,因此我们将所有加载的图像存储在cv::Mat类型的 C++ vector中。标签存储在unsigned char类型的 vector 中。我们编写了两个额外的辅助函数来从磁盘读取图像和标签:ReadImages和ReadLabels。以下代码片段显示了该类的头文件:
#include <torch/torch.h>
#include <opencv2/opencv.hpp>
#include <string>
class MNISTDataset
: public torch::data::Dataset<MNISTDataset> {
public:
MNISTDataset(const std::string& images_file_name,
const std::string& labels_file_name);
// torch::data::Dataset implementation
torch::data::Example<> get(size_t index) override;
torch::optional<size_t> size() const override;
private:
void ReadLabels(const std::string& labels_file_name);
void ReadImages(const std::string& images_file_name);
uint32_t rows_ = 0;
uint32_t columns_ = 0;
std::vector<unsigned char> labels_;
std::vector<cv::Mat> images_;
}
以下代码片段显示了该类的公共接口实现:
MNISTDataset::MNISTDataset(
const std::string& images_file_name,
const std::string& labels_file_name) {
ReadLabels(labels_file_name);
ReadImages(images_file_name);
}
我们可以看到构造函数将文件名传递给了相应的加载函数。size 方法返回从磁盘加载到 labels 容器中的项目数量:
torch::optional<size_t> MNISTDataset::size() const {
return labels_.size();
}
下面的代码片段显示了 get 方法的实现:
torch::data::Example<> MNISTDataset::get(size_t index) {
return {
CvImageToTensor(images_[index]),
torch::tensor(static_cast<int64_t>(labels_[index]),
torch::TensorOptions()
.dtype(torch::kLong)
.device(torch::DeviceType::CUDA))};
}
get 方法返回 torch::data::Example<> 类型的对象。一般来说,这种类型包含两个值:用 torch::Tensor 类型表示的训练样本和目标值,目标值也用 torch::Tensor 类型表示。此方法使用给定的下标从相应的容器中检索图像,使用 CvImageToTensor 函数将图像转换为 torch::Tensor 类型,并使用转换为 torch::Tensor 类型的标签值作为目标值。
存有一组 torch::tensor 函数,用于将 C++ 变量转换为 torch::Tensor 类型。它们会自动推断变量类型并创建具有相应值的张量。在我们的例子中,我们显式地将标签转换为 int64_t 类型,因为稍后我们将使用的损失函数假设目标值具有 torch::Long 类型。注意,我们将 torch::TensorOptions 作为 torch::tensor 函数的第二个参数传递。我们指定了张量值的 torch 类型,并告诉系统通过将 device 参数设置为等于 torch::DeviceType::CUDA 值并使用 torch::TensorOptions 对象来将此张量放置在 GPU 内存中。当我们手动创建 PyTorch 张量时,我们必须显式配置它们放置的位置——在 CPU 还是 GPU 上。放置在不同类型内存中的张量不能一起使用。
要将 OpenCV 图像转换为张量,编写以下函数:
torch::Tensor CvImageToTensor(const cv::Mat& image) {
assert(image.channels() == 1);
std::vector<int64_t> dims{
static_cast<int64_t>(1),
static_cast<int64_t>(image.rows),
static_cast<int64_t>(image.cols)};
torch::Tensor tensor_image =
torch::from_blob(image.data, torch::IntArrayRef(dims),
// clone is required to copy data
// from temporary object
torch::TensorOptions()
.dtype(torch::kFloat)
.requires_grad(false))
.clone();
return tensor_image.to(torch::DeviceType::CUDA);
}
此函数最重要的部分是对 torch::from_blob 函数的调用。此函数从通过第一个参数传递的指针引用的内存中的值构建张量。第二个参数应该是一个包含张量维度值的 C++ 向量;在我们的例子中,我们指定了一个具有一个通道和两个图像维度的三维张量。第三个参数是 torch::TensorOptions 对象。我们指定数据应为浮点类型,并且不需要计算梯度。
PyTorch 使用自动梯度方法进行模型训练,这意味着它不会构建一个具有预计算梯度依赖关系的静态网络图。相反,它使用一个动态网络图,这意味着模块的梯度流路径在训练过程的反向传播期间动态连接和计算。这种架构允许我们在程序运行时动态更改网络的拓扑和特性。我们之前提到的所有库都使用静态网络图。
在这里使用的第三个有趣的 PyTorch 函数是torch::Tensor::to函数,它允许我们将张量从 CPU 内存移动到 GPU 内存,反之亦然。
现在,让我们学习如何读取数据集文件。
读取数据集文件
我们使用ReadLabels函数读取标签文件:
void MNISTDataset::ReadLabels(
const std::string& labels_file_name) {
std::ifstream labels_file(
labels_file_name,
std::ios::binary | std::ios::binary);
labels_file.exceptions(std::ifstream::failbit |
std::ifstream::badbit);
if (labels_file) {
uint32_t magic_num = 0;
uint32_t num_items = 0;
if (read_header(&magic_num, labels_file) &&
read_header(&num_items, labels_file)) {
labels_.resize(static_cast<size_t>(num_items));
labels_file.read(
reinterpret_cast<char*>(labels_.data()),
num_items);
}
}
}
此函数以二进制模式打开文件并读取头记录、魔数和文件中的项目数量。它还直接将所有项目读取到 C++向量中。最重要的部分是正确读取头记录。为此,我们可以使用read_header函数:
template <class T>
bool read_header(T* out, std::istream& stream) {
auto size = static_cast<std::streamsize>(sizeof(T));
T value;
if (!stream.read(reinterpret_cast<char*>(&value), size)) {
return false;
} else {
// flip endianness
*out = (value << 24) | ((value << 8) & 0x00FF0000) |
((value >> 8) & 0X0000FF00) | (value >> 24);
return true;
}
}
此函数从输入流(在我们的情况下,是文件流)读取值,并翻转字节序。此函数还假设头记录是 32 位整数值。在不同的场景中,我们可能需要考虑其他翻转字节序的方法。
读取图像文件
读取图像文件也很直接;我们读取头记录并顺序读取图像。从头记录中,我们得到文件中的图像总数和图像大小。然后,我们定义具有相应大小和类型的 OpenCV 矩阵对象——单通道图像,其底层字节类型为CV_8UC1。我们通过传递由data对象变量返回的指针到流读取函数,直接将磁盘上的图像读取到 OpenCV 矩阵对象中。我们需要读取的数据大小是通过调用cv::Mat::size()函数,然后调用area函数确定的。然后,我们使用 OpenCV 的convertTo函数将图像从unsigned byte类型转换为 32 位浮点类型。这对于在网络层执行数学运算时保持足够的精度非常重要。我们还通过除以 255 来归一化所有数据,使其在[0, 1]范围内。
我们将所有图像的大小调整为 32 x 32,因为 LeNet5 网络架构要求我们保持卷积滤波器的原始维度:
void MNISTDataset::ReadImages(
const std::string& images_file_name) {
std::ifstream images_file(
images_file_name,
std::ios::binary | std::ios::binary);
labels_file.exceptions(std::ifstream::failbit |
std::ifstream::badbit);
if (labels_file) {
uint32_t magic_num = 0;
uint32_t num_items = 0;
rows_ = 0;
columns_ = 0;
if (read_header(&magic_num, labels_file) &&
read_header(&num_items, labels_file) &&
read_header(&rows_, labels_file) &&
read_header(&columns_, labels_file)) {
assert(num_items == labels_.size());
images_.resize(num_items);
cv::Mat img(static_cast<int>(rows_),
static_cast<int>(columns_), CV_8UC1);
for (uint32_t i = 0; i < num_items; ++i) {
images_file.read(reinterpret_cast<char*>(img.data),
static_cast<std::streamsize>(
img.size().area()));
img.convertTo(images_[i], CV_32F);
images_[i] /= 255; // normalize
cv::resize(images_[i], images_[i],
cv::Size(32, 32)); // Resize to
// 32x32 size
}
}
}
}
现在我们已经加载了训练数据,我们必须定义我们的神经网络。
神经网络定义
在这个例子中,我们选择了由 Yann LeCun、Leon Bottou、Yosuha Bengio 和 Patrick Haffner 开发的 LeNet5 架构(yann.lecun.com/exdb/lenet/)。架构的细节在之前的卷积网络架构部分中已经讨论过。在这里,我们将向您展示如何使用 PyTorch 框架实现它。
在 PyTorch 框架中,所有神经网络的结构部分都应该从torch::nn::Module类派生。以下是一个LeNet5类的头文件片段:
#include <torch/torch.h>
class LeNet5Impl : public torch::nn::Module {
public:
LeNet5Impl();
torch::Tensor forward(torch::Tensor x);
private:
torch::nn::Sequential conv_;
torch::nn::Sequential full_;
};
TORCH_MODULE(LeNet5);
注意,我们定义了一个中间实现类,称为 LeNet5Impl。这是因为 PyTorch 使用基于智能指针的内存管理模型,所有模块都应该被封装在特殊类型中。有一个特殊的类叫做 torch::nn::ModuleHolder,它是 std::shared_ptr 的封装,同时也定义了一些用于管理模块的额外方法。因此,如果我们想遵循所有 PyTorch 规范,并且无任何问题地使用我们的模块(网络)以及所有 PyTorch 的函数,我们的模块类定义应该如下所示:
class Name : public torch::nn::ModuleHolder<Impl> {}
Impl 是我们模块的实现,它从 torch::nn::Module 类派生。有一个特殊的宏可以自动完成这个定义;它被称为 TORCH_MODULE。我们需要指定我们的模块名称才能使用它。
在这个定义中最重要的函数是 forward 函数。在这个例子中,该函数接收网络的输入,并将其传递通过所有网络层,直到从该函数返回一个输出值。如果我们没有实现整个网络,而是实现 一些 定制的层或 一些 网络的结构部分,这个函数应该假设我们从前一层或其他网络部分获取值作为输入。此外,如果我们正在实现一个不是来自 PyTorch 标准模块的自定义模块,我们应该定义 backward 函数,该函数应该计算我们自定义操作的梯度。
在我们的模块定义中,下一个重要的事情是使用 torch::nn::Sequential 类。这个类用于在网络上分组顺序层,并自动化它们之间传递值的流程。我们将我们的网络分为两部分,一部分包含卷积层,另一部分包含最终的完全连接层。
PyTorch 框架包含许多用于创建层的函数。例如,torch::nn::Conv2d 函数创建了二维卷积层。在 PyTorch 中创建层的另一种方法是使用 torch::nn::Functional 函数将一些简单函数封装到层中,然后它可以与前一层的所有输出连接。请注意,激活函数不是 PyTorch 中的神经元的一部分,应该作为单独的层连接。以下代码片段显示了我们的网络组件的定义:
static std::vector<int64_t> k_size = {2, 2};
static std::vector<int64_t> p_size = {0, 0};
LeNet5Impl::LeNet5Impl() {
conv_ = torch::nn::Sequential(
torch::nn::Conv2d(torch::nn::Conv2dOptions(1, 6, 5)),
torch::nn::Functional(torch::tanh),
torch::nn::Functional(
torch::avg_pool2d,
/*kernel_size*/
/*kernel_size*/ torch::IntArrayRef(k_size),
/*stride*/ torch::IntArrayRef(k_size),
/*padding*/ torch::IntArrayRef(p_size),
/*ceil_mode*/ false,
/*count_include_pad*/ false),
torch::nn::Conv2d(torch::nn::Conv2dOptions(6, 16, 5)),
torch::nn::Functional(torch::tanh),
torch::nn::Functional(
torch::avg_pool2d,
/*kernel_size*/ torch::IntArrayRef(k_size),
/*stride*/ torch::IntArrayRef(k_size),
/*padding*/ torch::IntArrayRef(p_size),
/*ceil_mode*/ false,
/*count_include_pad*/ false),
torch::nn::Conv2d(
torch::nn::Conv2dOptions(16, 120, 5)),
torch::nn::Functional(torch::tanh));
register_module("conv", conv_);
full_ = torch::nn::Sequential(
torch::nn::Linear(torch::nn::LinearOptions(120, 84)),
torch::nn::Functional(torch::tanh),
torch::nn::Linear(torch::nn::LinearOptions(84, 10)));
register_module("full", full_);
}
在这里,我们初始化了两个 torch::nn::Sequential 模块。它们在构造函数中接受不同数量的其他模块作为参数。请注意,对于 torch::nn::Conv2d 模块的初始化,我们必须传递 torch::nn::Conv2dOptions 类的实例,该实例可以通过输入通道数、输出通道数和内核大小进行初始化。我们使用了 torch::tanh 作为激活函数;请注意,它被封装在 torch::nn::Functional 类实例中。
平均池化函数也被包装在torch::nn::Functional类实例中,因为它在 PyTorch C++ API 中不是一个层,而是一个函数。此外,池化函数需要几个参数,所以我们绑定了它们的固定值。当 PyTorch 中的函数需要维度的值时,它假设我们提供了一个torch::IntArrayRef类型的实例。这种类型的对象作为具有维度值的数组的包装器。我们应该在这里小心,因为这样的数组应该与包装器的生命周期同时存在;注意torch::nn::Functional内部存储torch::IntArrayRef对象。这就是为什么我们将k_size和p_size定义为静态全局变量的原因。
还要注意register_module函数。它将字符串名称与模块关联,并在父模块的内部注册它。如果以某种方式注册了模块,我们可以在以后使用基于字符串的参数搜索(通常在需要手动管理训练期间的权重更新时使用)和自动模块序列化。
torch::nn::Linear模块定义了全连接层,应该使用torch::nn::LinearOptions类型的实例进行初始化,该类型定义了输入和输出的数量,即层的神经元数量。注意,最后一层返回 10 个值,而不是一个标签,尽管我们只有一个目标标签。这是分类任务中的标准方法。
以下代码展示了forward函数的实现,该函数执行模型推理:
torch::Tensor LeNet5Impl::forward(at::Tensor x) {
auto output = conv_->forward(x);
output = output.view({x.size(0), -1});
output = full_->forward(output);
output = torch::log_softmax(output, -1);
return output;
}
此函数的实现如下:
-
我们将输入张量(图像)传递到序列卷积组的
forward函数。 -
然后,我们使用
view张量方法展平其输出,因为全连接层假设输入是展平的。view方法接受张量的新维度并返回一个张量视图,而不需要复制数据;-1表示我们不在乎维度的值,它可以被展平。 -
然后,将卷积组的展平输出传递到全连接组。
-
最后,我们对最终输出应用了 softmax 函数。由于多次重载,我们无法将
torch::log_softmax包装在torch::nn::Functional类实例中。
softmax 函数将维度为
的向量转换为维度相同的向量
,其中结果向量的每个坐标
由一个范围在
内的实数表示,坐标之和为 1。
坐标计算如下:

在机器学习中,当可能的类别数量超过两个时(对于两个类别,使用逻辑函数),使用 softmax 函数进行分类问题。结果向量的坐标,
,可以解释为对象属于类,
的概率。我们选择这个函数是因为其结果可以直接用于交叉熵损失函数,该函数衡量两个概率分布之间的差异。目标分布可以直接从目标标签值计算得出——我们创建一个包含 10 个值的零向量,并在标签值索引的位置放置一个值。现在,我们已经拥有了训练神经网络所需的所有组件。
网络训练
首先,我们应该为训练和测试数据集创建 PyTorch 数据加载器对象。数据加载器对象负责从数据集中采样对象并从中制作小批量。该对象可以按以下方式配置:
-
首先,我们初始化代表我们的数据集的
MNISTDataset类型对象。 -
然后,我们使用
torch::data::make_data_loader函数创建一个数据加载器对象。此函数接受一个带有数据加载器配置设置的torch::data::DataLoaderOptions类型对象。我们将小批量大小设置为 256 个项目,并设置了 8 个并行数据加载线程。我们还应该配置采样器类型,但在这个案例中,我们将保留默认的随机采样器。
以下代码片段展示了如何初始化训练和测试数据加载器:
auto train_images = root_path / "train-images-idx3-ubyte";
auto train_labels = root_path / "train-labels-idx1-ubyte";
auto test_images = root_path / "t10k-images-idx3-ubyte";
auto test_labels = root_path / "t10k-labels-idx1-ubyte";
// initialize train dataset
// ----------------------------------------------
MNISTDataset train_dataset(train_images.native(),
train_labels.native());
auto train_loader = torch::data::make_data_loader(
train_dataset.map(torch::data::transforms::Stack<>()),
torch::data::DataLoaderOptions()
.batch_size(256)
.workers(8));
// initialize test dataset
// ----------------------------------------------
MNISTDataset test_dataset(test_images.native(),
test_labels.native());
auto test_loader = torch::data::make_data_loader(
test_dataset.map(torch::data::transforms::Stack<>()),
torch::data::DataLoaderOptions()
.batch_size(1024)
.workers(8));
注意,我们没有直接将数据集对象传递给torch::data::make_data_loader函数,而是对其应用了堆叠转换映射。这种转换使我们能够以torch::Tensor对象的形式采样小批量。如果我们跳过这个转换,小批量将以张量 C++向量的形式被采样。通常,这并不很有用,因为我们不能以矢量化方式对整个批量应用线性代数运算。
下一步是初始化之前定义的LeNet5类型的神经网络对象。我们将将其移动到 GPU 上以提高训练和评估性能:
LeNet5 model;
model->to(torch::DeviceType::CUDA);
当我们的神经网络模型初始化完成后,我们可以初始化一个优化器。我们选择了带有动量优化的随机梯度下降。它在torch::optim::SGD类中实现。这个类的对象应该用模型(网络)参数和torch::optim::SGDOptions类型的对象来初始化。所有torch::nn::Module类型的对象都有parameters()方法,它返回包含网络所有参数(权重)的std::vector<Tensor>对象。还有一个named_parameters方法,它返回命名参数的字典。参数名称是用我们在register_module函数调用中使用的名称创建的。如果我们想过滤参数并排除其中的一些参数不参与训练过程,这个方法就很有用。
可以通过配置学习率、权重衰减正则化因子和动量值因子来配置torch::optim::SGDOptions对象:
double learning_rate = 0.01;
double weight_decay = 0.0001; // regularization parameter
torch::optim::SGD optimizer(
model->parameters(),
torch::optim::SGDOptions(learning_rate)
.weight_decay(weight_decay)
.momentum(0.5));
现在我们已经初始化了数据加载器、network对象和optimizer对象,我们准备开始训练周期。以下代码片段展示了训练周期的实现:
int epochs = 100;
for (int epoch = 0; epoch < epochs; ++epoch) {
model->train(); // switch to the training mode
// Iterate the data loader to get batches from the dataset
int batch_index = 0;
for (auto& batch : (*train_loader)) {
// Clear gradients
optimizer.zero_grad();
// Execute the model on the input data
torch::Tensor prediction = model->forward(batch.data);
// Compute a loss value to estimate error of our model
// target should have size of [batch_size]
torch::Tensor loss =
torch::nll_loss(prediction, batch.target);
// Compute gradients of the loss and parameters of our
// model
loss.backward();
// Update the parameters based on the calculated
// gradients.
optimizer.step();
// Output the loss every 10 batches.
if (++batch_index % 10 == 0) {
std::cout << "Epoch: " << epoch
<< " | Batch: " << batch_index
<< " | Loss: " << loss.item<float>()
<< std::endl;
}
}
我们已经创建了一个循环,重复执行 100 个 epoch 的训练周期。在训练周期的开始,我们使用model->train()将我们的网络对象切换到训练模式。对于一个 epoch,我们遍历数据加载器对象提供的所有小批量:
for (auto& batch : (*train_loader)){
...
}
对于每一个小批量,我们执行了接下来的训练步骤,通过调用优化器对象的zero_grad方法来清除之前的梯度值,然后对网络对象执行前向步骤,model->forward(batch.data),并使用nll_loss函数计算损失值。这个函数计算的是负对数似然损失。它接受两个参数:一个向量,包含一个训练样本属于由向量中位置标识的类别的概率,以及一个数字类标签(编号)。然后,我们调用了损失张量的backward方法。它递归地计算整个网络的梯度。最后,我们调用了优化器对象的step方法,该方法更新了所有参数(权重)及其对应的梯度值。step方法只更新了用于初始化的参数。
在每个 epoch 之后使用测试或验证数据来检查训练过程是一种常见的做法。我们可以这样做:
model->eval(); // switch to the training mode
unsigned long total_correct = 0;
float avg_loss = 0.0;
for (auto& batch : (*test_loader)) {
// Execute the model on the input data
torch::Tensor prediction = model->forward(batch.data);
// Compute a loss value to estimate error of our model
torch::Tensor loss =
torch::nll_loss(prediction, batch.target);
avg_loss += loss.sum().item<float>();
auto pred = std::get<1>(prediction.detach_().max(1));
total_correct += static_cast<unsigned long>(
pred.eq(batch.target.view_as(pred))
.sum()
.item<long>());
}
avg_loss /= test_dataset.size().value();
double accuracy = (static_cast<double>(total_correct) /
test_dataset.size().value());
std::cout << "Test Avg. Loss: " << avg_loss
<< " |
Accuracy : " << accuracy << std::endl;
首先,我们通过调用eval方法将模型切换到评估模式。然后,我们遍历测试数据加载器中的所有批次。对于这些批次中的每一个,我们在网络上执行正向传递,以与我们的训练过程相同的方式计算损失值。为了估计模型的总体损失(错误)值,我们平均了所有批次的损失值。为了获取批次的总体损失,我们使用了loss.sum().item<float>()。在这里,我们总结了批次中每个训练样本的损失,并使用item<float>()方法将其移动到 CPU 浮点变量中。
接下来,我们计算准确度值。这是正确答案与误分类答案的比例。让我们通过以下方法来了解这个计算过程。首先,我们通过使用张量对象的max方法来确定预测的类别标签:
auto pred = std::get<1>(prediction.detach_().max(1));
max方法返回一个元组,其中包含输入张量在给定维度上每行的最大值以及该方法找到的每个最大值的位置索引。然后,我们将预测标签与目标标签进行比较,并计算正确答案的数量:
total_correct += static_cast<unsigned long>(
pred.eq(batch.target.view_as(pred)).sum().item<long>());
我们使用了eq张量的方法来进行比较。此方法返回一个布尔向量,其大小与输入向量相等,当向量元素组件相等时值为1,不相等时值为0。为了执行比较操作,我们为预测张量创建了一个与目标标签张量相同维度的视图。我们使用view_as方法进行此比较。然后,我们计算了1值的总和,并使用item<long>()方法将值移动到 CPU 变量中。
通过这样做,我们可以看到专门的框架有更多的配置选项,对于神经网络开发来说更加灵活。它有更多的层类型,支持动态网络图。它还拥有强大的专用线性代数库,可用于创建新的层,以及新的损失和激活函数。它具有强大的抽象,使我们能够处理大量训练数据。还有一个重要的事情需要注意,那就是它有一个与 Python API 非常相似的 C++ API,因此我们可以轻松地将 Python 程序移植到 C++,反之亦然。
摘要
在本章中,我们探讨了人工神经网络是什么,回顾了它们的历史,并分析了它们出现、兴起和衰落的原因,以及为什么它们今天成为了最活跃发展的机器学习方法之一。在学习由弗兰克·罗森布拉特创造的感知器概念的基本原理之前,我们探讨了生物神经元和人工神经元之间的区别。然后,我们讨论了人工神经元和网络的内禀特征,例如激活函数及其特性、网络拓扑和卷积层概念。我们还学习了如何使用误差反向传播方法训练人工神经网络。我们看到了如何为不同类型任务选择合适的损失函数。然后,我们讨论了在训练过程中用于对抗过拟合的正则化方法。
最后,我们使用 mlpack、Dlib 和 Flashlight C++机器学习库实现了一个简单的 MLP 回归任务。然后,我们使用 PyTorch,一个专门的神经网络框架,实现了一个更高级的卷积网络用于图像分类任务。这展示了专用框架相对于通用库的优势。
在下一章中,我们将讨论如何使用预训练的大型语言模型(LLMs)并将它们适应到我们的特定任务中。我们将看到如何使用迁移学习技术和 BERT 网络来进行情感分析。
进一步阅读
-
深度神经网络在分类中的损失函数:
arxiv.org/pdf/1702.05659.pdf -
神经网络与深度学习,迈克尔·尼尔森著:
neuralnetworksanddeeplearning.com/ -
神经动力学原理,罗森布拉特,弗兰克(1962),华盛顿特区:斯巴达图书
-
感知器,明斯基 M. L.和帕佩特 S. A. 1969。剑桥,马萨诸塞州:麻省理工学院出版社
-
神经网络与学习机器,西蒙·O·海金,2008
-
深度学习,伊恩·古德费洛,约书亚·本吉奥,阿隆·库维尔,2016
-
PyTorch GitHub 页面:
github.com/pytorch/ -
PyTorch 文档网站:
pytorch.org/docs/ -
LibPyTorch(C++)文档网站:
pytorch.org/cppdocs/
第十一章:使用 BERT 和迁移学习进行情感分析
Transformer 架构是一种神经网络模型,在自然语言处理(NLP)领域获得了显著的流行。它首次在 Vaswani 等人于 2017 年发表的一篇论文中提出。Transformer 的主要优势是其处理并行处理的能力,这使得它比 RNNs 更快。Transformer 的另一个重要优势是它处理序列中长距离依赖的能力。这是通过使用注意力机制实现的,允许模型在生成输出时关注输入的特定部分。
近年来,Transformer 已被应用于广泛的 NLP 任务,包括机器翻译、问答和摘要。其成功可以归因于其简单性、可扩展性和在捕捉长期依赖方面的有效性。然而,像任何模型一样,Transformer 也有一些局限性,例如其高计算成本和对大量训练数据的依赖。尽管存在这些局限性,Transformer 仍然是 NLP 研究人员和从业者的一项强大工具。使其成为可能的一个因素是其能够使用和适应已经预训练的网络,以特定任务以更低的计算资源和训练数据量。
转移学习主要有两种方法,称为微调和迁移学习。微调是一个进一步调整预训练模型以更好地适应特定任务或数据集的过程。这涉及到解冻预训练模型中的某些或所有层,并在新数据上训练它们。迁移学习的过程通常涉及取一个预训练模型,移除针对原始任务特定的最终层,并添加新层或修改现有层以适应新任务。模型隐藏层的参数通常在训练阶段被冻结。这是与微调的主要区别之一。
本章将涵盖以下主题:
-
Transformer 架构的一般概述
-
对 Transformer 的主要组件及其协同工作方式的简要讨论
-
如何将迁移学习技术应用于构建新的情感分析模型的示例
技术要求
本章的技术要求如下:
-
PyTorch 库
-
支持 C++20 的现代 C++ 编译器
-
CMake 构建系统版本 >= 3.22
本章的代码文件可以在以下 GitHub 仓库中找到:github.com/PacktPublishing/Hands-On-Machine-Learning-with-C-second-edition/tree/master/Chapter11/pytorch。
请按照以下文档中描述的说明配置开发环境:github.com/PacktPublishing/Hands-on-Machine-learning-with-C-Second-Edition/blob/main/env_scripts/README.md。
此外,您还可以探索该文件夹中的脚本以查看配置细节。
要构建本章的示例项目,您可以使用以下脚本:github.com/PacktPublishing/Hands-on-Machine-learning-with-C-Second-Edition/blob/main/build_scripts/build_ch11.sh。
Transformer 架构概述
Transformer 是一种神经网络架构,它首次由谷歌研究人员在论文《Attention Is All You Need》中提出。由于其能够处理长距离依赖关系和注意力机制,它已经在 NLP 和其他领域得到广泛应用。以下方案展示了 Transformer 的一般架构:

图 11.1 – Transformer 架构
Transformer 架构有两个主要组件:编码器和解码器。编码器处理输入序列,而解码器生成输出序列。这些 Transformer 组件的常见元素如下:
-
自注意力机制:该模型使用自注意力机制来学习输入序列不同部分之间的关系。这使得它能够捕捉到长距离依赖关系,这对于理解自然语言中的上下文非常重要。
-
交叉注意力机制:这是一种在处理两个或更多序列时使用的注意力机制。在这种情况下,一个序列的元素关注另一个序列的元素,从而使模型能够学习两个输入之间的关系。它用于编码器和解码器部分之间的通信。
-
多头注意力:Transformer 不是使用单个注意力机制,而是使用多个注意力头,每个头都有其自己的权重。这有助于模拟输入序列的不同方面,并提高模型的表示能力。
-
位置编码:由于 Transformer 不使用循环或卷积层,它需要一种方法来保留输入序列的位置信息。位置编码用于将此信息添加到输入中。这是通过向嵌入向量添加不同频率的正弦和余弦函数来实现的。
-
前馈神经网络:在注意力层之间,存在全连接的前馈神经网络。这些网络有助于将注意力层的输出转换为更有意义的表示。
-
残差连接和归一化:与许多深度学习模型一样,Transformer 包含残差连接和批量归一化,以提高训练的稳定性和收敛性。
让我们详细看看编码器和解码器部分解决的主要差异和任务。
编码器
Transformer 的 编码器 部分负责将输入数据编码为固定长度的向量表示,称为嵌入。这个嵌入捕获了输入中的重要特征和信息,并以更抽象的形式表示它。编码器通常由多层自注意力机制和前馈网络组成。编码器的每一层都有助于细化并改进输入的表示,捕获更复杂的模式和依赖关系。
在内部表示方面,编码器产生的嵌入可以是上下文或位置的。上下文嵌入专注于从文本中捕获语义和句法信息,而位置嵌入编码关于序列中单词的顺序和位置的信息。这两种类型的嵌入都在捕捉句子中单词的上下文和关系方面发挥作用:
-
上下文嵌入允许模型根据上下文理解单词的意义,考虑到周围的单词及其关系
-
位置嵌入另一方面提供关于序列中每个单词位置的信息,帮助模型理解文本的顺序和结构
在编码器中,上下文和位置嵌入共同帮助 Transformer 更好地理解和表示输入数据,使其能够在下游任务中生成更准确和有意义的输出。
解码器
Transformer 的 解码器 部分以编码表示作为输入并生成最终输出。它由与编码器类似的多层注意力和前馈网络组成。然而,它还包括额外的层,使其能够预测和生成输出。解码器根据输入序列的编码表示及其自己的先前预测来预测序列中的下一个标记。
Transformer 解码器的输出概率代表每个可能的标记作为序列中下一个单词的可能性。这些概率使用 softmax 函数计算,该函数根据标记与上下文的相关性为其分配概率值。
在训练过程中,解码器使用编码嵌入生成预测并将其与真实输出进行比较。预测输出和真实输出之间的差异用于更新解码器的参数并提高其性能。
输出采样是解码器过程的一个重要部分。它涉及根据输出概率选择下一个标记。有许多采样输出的方法。以下列表显示了其中一些流行的方法:
-
贪婪搜索:这是最简单的采样方法,在每个步骤根据标记值的 softmax 概率分布选择最可能的标记。虽然这种方法快速且易于实现,但它可能并不总是找到最优解。
-
Top-k 采样:Top-k 采样在每个步骤从 softmax 分布中选择概率最高的前k个标记,而不是选择最可能的标记。这种方法可以帮助多样化样本,防止模型陷入局部最优。
-
核采样:核采样,也称为 top-p 采样,是 top-k 采样的一个变体,它根据概率从 softmax 分布的顶部选择一个子集的标记。通过在概率范围内选择多个标记,核采样可以提高输出的多样性和覆盖率。
您现在已经了解了主要 Transformer 组件的工作原理以及其中使用的元素。但这也留下了在解码器处理之前如何对输入进行预处理的话题。让我们看看如何将输入文本转换为 Transformer 输入。
分词
分词是将文本序列分解成更小的单元,称为标记的过程。这些标记可以是单个单词、子词,甚至字符,具体取决于特定任务和模型架构。例如,在句子“我爱吃披萨”中,标记将是“I”,“爱”,“吃”,和“披萨”。
在 Transformer 模型中使用了多种分词方法。最常用的有以下几种:
-
单词分词:这种方法将文本分割成单个单词。这是最常见的方法,适用于翻译和文本分类等任务。
-
子词分词:在这种方法中,文本被分割成更小的单元,称为子词。这可以提高在单词经常拼写错误或截断的任务上的性能,如机器翻译。
-
字符分词:这种方法将文本分解成单个字符。对于需要细粒度分析的任务,如情感分析,这可能很有用。
分词方法的选取取决于数据集的特征和任务的需求数据。
在接下来的小节中,我们将使用 BERT 模型,该模型使用[CLS](分类)和[SEP](分隔符)。这些标记在模型架构中具有特定用途。以下是 WordPiece 分词算法的逐步解释:
-
初始词汇表:从一个包含模型使用的特殊标记和初始字母表的小型词汇表开始。初始字母表包含单词开头和 WordPiece 前缀后的所有字符。
-
##(用于 BERT)将每个单词中的每个字符分开,例如将“word”分割成w ##o ##r ##d。这将从原始单词中的每个字符创建子词。 -
score = (freq_of_pair)/(freq_of_first_element×freq_of_second_element)。这优先合并那些个体部分在词汇表中出现频率较低的配对。 -
合并对:算法合并得分高的对,这意味着算法将出现频率较低的合并对合并成单个元素。
-
迭代合并:这个过程会重复进行,直到完成所需数量的合并操作或达到预定义的阈值。此时,创建最终的词汇表。
使用词汇表,我们可以以类似于我们在词汇构建过程中之前所做的方式对输入中的任何单词进行标记化。因此,首先我们在词汇表中搜索整个单词。如果我们找不到,我们就从前面带有##前缀的子词开始移除一个字符,然后再次搜索。这个过程会一直持续到我们找到一个子词。如果我们在一个词汇表中找不到任何单词的标记,我们通常会跳过这个单词。
词汇表通常表示为一个字典或查找表,将每个单词映射到唯一的整数索引。词汇表大小是 Transformer 性能的一个重要因素,因为它决定了模型的复杂性和处理不同类型文本的能力。
单词嵌入
尽管我们使用标记化将单词转换为数字,但这并不为神经网络提供任何语义意义。为了能够表示语义邻近性,我们可以使用嵌入。嵌入是将任意实体映射到特定向量的地方,例如,图中的一个节点,图片中的一个对象,或单词的定义。一组嵌入向量可以被视为向量意义空间。
创建单词嵌入有许多方法。例如,在经典 NLP 中最知名的是 Word2Vec 和 GloVe,它们基于统计分析,实际上是独立的模型。
对于 Transformer,提出了不同的方法。以标记作为输入,我们将它们传递到 MLP 层,该层为每个标记输出嵌入向量。这一层与模型的其他部分一起训练,是内部模型组件。这样,我们可以拥有针对特定训练数据和特定任务的更精细调整的嵌入。
分别使用编码器和解码器部分
原始的 Transformer 架构包含编码器和解码器两部分。但最近发现,这些部分可以分别用于解决不同的任务。基于它们的两个最著名的基架构是 BERT 和 GPT。
BERT代表来自变换器的双向编码器表示。它是一个最先进的 NLP 模型,使用双向方法来理解句子中单词的上下文。与仅考虑一个方向的单词的传统模型不同,BERT 可以查看给定单词之前和之后的单词,以更好地理解其含义。它仅基于编码器变换器部分。这使得它在需要理解上下文的任务中特别有用,例如语义相似度和文本分类。此外,它旨在理解双向上下文,这意味着它可以考虑句子中的前一个和后一个单词。
另一方面,GPT,即生成式预训练变换器,是一个仅基于解码器变换器部分的生成模型。它通过预测序列中的下一个单词来生成类似人类的文本。
在下一节中,我们将使用 PyTorch 库和 BERT 作为基础模型来开发一个情感分析模型。
基于 BERT 的情感分析示例
在本节中,我们将构建一个机器学习模型,该模型可以使用 PyTorch 检测评论情感(检测评论是正面还是负面)。作为训练集,我们将使用大型电影评论数据集,其中包含用于训练的 25,000 条电影评论和用于测试的 25,000 条评论,两者都高度两极分化。
如我们之前所说,我们将使用已经预训练的 BERT 模型。BERT 之所以被选中,是因为它能够理解单词之间的上下文和关系,这使得它在问答、情感分析和文本分类等任务中特别有效。让我们记住,迁移学习是一种机器学习方法,涉及将知识从预训练模型转移到新的或不同的问题域。当特定任务缺乏标记数据时,或者从头开始训练模型过于计算昂贵时,会使用迁移学习。
应用迁移学习算法包括以下步骤:
-
选择预训练模型:应根据任务的相关性来选择模型。
-
添加新的任务特定头:例如,这可能是全连接线性层与最终的 softmax 分类的组合。
-
冻结预训练参数:冻结参数允许模型保留其预学习的知识。
-
在新数据集上训练:模型使用预训练权重和新数据的组合进行训练,使其能够在新的层中学习与新领域相关的特定特征和模式。
我们知道 BERT 类模型用于从输入数据中提取一些语义知识;在我们的案例中,它将是文本。BERT 类模型通常以嵌入向量的形式表示提取的知识。这些向量可以用来训练新的模型头,例如,用于分类任务。我们将遵循之前描述的步骤。
导出模型和词汇表
使用 PyTorch 库在 C++中通过某些预训练模型的传统方法是将此模型作为 TorchScript 加载。获取此脚本的一种常见方法是通过追踪 Python 中可用的模型并保存它。在huggingface.co/网站上有很多预训练模型。此外,这些模型还提供了 Python API。因此,让我们编写一个简单的 Python 程序来导出基础 BERT 模型:
-
以下代码片段展示了如何导入所需的 Python 模块并加载用于追踪的预训练模型:
import torch from transformers import BertModel, BertTokenizer model_name = "bert-base-cased" tokenizer =BertTokenizer.from_pretrained(model_name, torchscript = True) bert = BertModel.from_pretrained(model_name, torchscript=True)我们从
transformers模块中导入了BertModel和BertTokenizer类,这是 Hugging Face 的库,允许我们使用不同的基于 Transformer 的模型。我们使用了bert-base-cased模型,这是在大型文本语料库上训练以理解通用语言语义的原始 BERT 模型,我们还加载了专门针对 BERT 模型的分词器模块——BertTokenizer。注意,我们还使用了torchscript=True参数来能够追踪和保存模型。此参数告诉库使用适合 Torch JIT 追踪的操作符和模块。 -
现在我们有了加载的分词器对象,我们可以按照以下方式对一些样本文本进行分词以进行追踪:
max_length = 128 tokenizer_out = tokenizer(text, padding = "max_length", max_length = max_length, truncation = True, return_tensors = "pt", ) attention_mask = tokenizer_out.attention_mask input_ids = tokenizer_out.input_ids在这里,我们定义了可以生成的最大标记数,即
128。我们加载的 BERT 模型一次可以处理最多 512 个标记,因此您应该根据您的任务配置此数字,例如,您可以使用更少的标记数量以满足嵌入式设备上的性能限制。此外,我们告诉分词器截断较长的序列并填充较短的序列到max_length。我们还通过指定return_tensors="pt"使分词器返回 PyTorch 张量。我们使用了分词器返回的两个值:
input_ids,它是标记值,以及attention_mask,它是一个二进制掩码,对于真实标记填充1,对于不应处理的填充标记填充0。 -
现在我们有了标记和掩码,我们可以按照以下方式导出模型:
model.eval() traced_script_module = torch.jit.trace(model, [ input_ids, attention_mask ]) traced_script_module.save("bert_model.pt")我们将模型切换到评估模式,因为它将被用于追踪,而不是用于训练。然后,我们使用
torch.jit.trace函数在样本输入上追踪模型,样本输入是我们生成的标记和注意力掩码的元组。我们使用追踪模块的save方法将模型脚本保存到文件中。 -
除了模型之外,我们还需要导出分词器的词汇表,如下所示:
vocab_file = open("vocab.txt", "w") for i, j in tokenizer.get_vocab().items(): vocab_file.write(f"{i} {j}\n") vocab_file.close()
在这里,我们只是遍历了分词器对象中所有可用的令牌,并将它们作为[value - id]对保存到文本文件中。
实现分词器
我们可以直接使用 PyTorch C++ API 加载保存的脚本模型,但不能对分词器做同样的事情。此外,PyTorch C++ API 中没有分词器的实现。因此,我们必须自己实现分词器:
-
最简单的分词器实际上可以很容易地实现。它可以为头文件定义以下内容:
#include <torch/torch.h> #include <string> #include <unordered_map> class Tokenizer { public: Tokenizer(const std::string& vocab_file_path, int max_len = 128); std::pair<torch::Tensor, torch::Tensor> tokenize( const std::string text); private: std::unordered_map<std::string, int> vocab_; int max_len_{0}; }我们定义了一个
Tokenizer类,它有一个构造函数,该构造函数接受词汇文件名和要生成的令牌序列的最大长度。我们还定义了一个单独的方法tokenize,它接受输入文本作为参数。 -
构造函数可以如下实现:
Tokenizer::Tokenizer(const std::string& vocab_file_path, int max_len) : max_len_{max_len} { auto file = std::ifstream(vocab_file_path); std::string line; while (std::getline(file, line)) { auto sep_pos = line.find_first_of(' '); auto token = line.substr(0, sep_pos); auto id = std::stoi(line.substr(sep_pos + 1)); vocab_.insert({token, id}); } }我们简单地打开给定的文本文件,逐行读取。我们将每一行分割成两个组件,即令牌字符串值和相应的 ID。这些组件由空格字符分隔。此外,我们将
id从string转换为integer值。所有解析的令牌 ID 对都保存到std::unordered_map容器中,以便能够有效地搜索令牌 ID。 -
tokenize方法的实现稍微复杂一些。我们定义如下:std::pair<torch::Tensor, torch::Tensor> Tokenizer::tokenize(const std::string text) { std::string pad_token = "[PAD]"; std::string start_token = "[CLS]"; std::string end_token = "[SEP]"; auto pad_token_id = vocab_[pad_token]; auto start_token_id = vocab_[start_token]; auto end_token_id = vocab_[end_token];在这里,我们从加载的词汇表中获得了特殊的令牌 ID 值。这些令牌 ID 是 BERT 模型正确处理输入所必需的。
-
当我们的输入文本太短时,PAD 令牌用于标记空令牌。可以这样做:
std::vector<int> input_ids(max_len_, pad_token_id); std::vector<int> attention_mask(max_len_, 0); input_ids[0] = start_token_id; attention_mask[0] = 1;就像在 Python 程序中一样,我们创建了两个向量,一个用于令牌 ID,另一个用于注意力掩码。我们使用
pad_token_id作为令牌 ID 的默认值,并用零填充注意力掩码。然后,我们将start_token_id作为第一个元素,并在注意力掩码中放入相应的值。 -
定义了输出容器后,我们现在定义输入文本处理的中间对象,如下所示:
std::string word; std::istringstream ss(text);我们将输入文本字符串移动到
stringstream对象中,以便能够逐词读取。我们还定义了相应的单词字符串对象。 -
顶层处理周期可以如下定义:
int input_id = 1; while (getline(ss, word, ' ')) { // search token in the vocabulary and increment input_id if (input_id == max_len_ - 1) { break; } }在这里,我们使用了
getline函数,通过空格字符作为分隔符将输入字符串流分割成单词。这是一种简单的分割输入文本的方法。通常,分词器会使用更复杂的策略进行分割。但对于我们的任务和我们的数据集来说,这已经足够了。此外,我们还添加了检查,如果我们达到最大序列长度,我们就停止文本处理,因此我们截断它。 -
然后,我们必须识别第一个单词中最长可能的、存在于词汇表中的前缀;它可能是一个完整的单词。实现开始如下:
size_t start = 0; while (start < word.size()) { size_t end = word.size(); std::string token; bool has_token = false; while (start < end) { // search the prefix in the vocabulary end--; } if (input_id == max_len_ - 1) { break; } if (!has_token) { break; } start = end; }在这里,我们定义了
start变量来跟踪单词前缀的开始位置,它被初始化为 0,即当前单词的开始位置。我们定义了end变量来跟踪前缀的结束位置,它被初始化为当前单词的长度,即单词的最后一个位置。最初,它们指向单词的开始和结束。然后,在内循环中,我们通过递减end变量来连续减小前缀的大小。每次递减后,如果我们没有在词汇表中找到前缀,我们就重复这个过程,直到单词的末尾。此外,我们还确保在成功搜索到标记后,交换start和end变量以分割单词,并继续对单词剩余部分的前缀搜索。这样做是因为一个单词可以由多个标记组成。此外,在这段代码中,我们检查了最大标记序列长度以停止整个标记搜索过程。 -
下一个步骤是我们对词汇表执行前缀搜索:
auto token = word.substr(start, end - start); if (start > 0) token = "##" + token; auto token_iter = vocab_.find(token); if (token_iter != vocab_.end()) { attention_mask[input_id] = 1; input_ids[input_id] = token_iter->second; ++input_id; has_token = true; break; }在这里,我们使用
substr函数从原始单词中提取前缀。如果start变量不是0,我们正在处理单词的内部部分,因此执行了添加##特殊前缀的操作。我们使用了无序映射容器中的find方法来查找标记(前缀)。如果搜索成功,我们将标记 ID 放置在input_ids容器的下一个位置,在attention_mask中做出相应的标记,增加input_id索引以移动当前序列位置,并中断循环以开始处理下一个单词部分。 -
在实现填充输入 ID 和注意力掩码的代码之后,我们将它们放入 PyTorch 张量对象中,并按如下方式返回输出:
attention_mask[input_id] = 1; input_ids[input_id] = end_token_id; auto input_ids_tensor = torch::tensor( input_ids).unsqueeze(0); auto attention_masks_tensor = torch::tensor( attention_mask).unsqueeze(0); return std::make_pair(input_ids_tensor, attention_masks_ tensor);在将输入 ID 和掩码转换为张量之前,我们使用
end_token_id值最终确定了标记 ID 序列。然后,我们使用torch.tensor函数创建张量对象。这个函数可以接受不同的输入,其中之一是只包含数值的std::vector。我们还使用了unsqueeze函数向张量中添加批处理维度。我们还将最终的张量作为标准对返回。
在以下小节中,我们将使用实现的标记器实现数据集加载器类。
实现数据集加载器
我们必须开发解析器和数据加载器类,以便将数据集以适合与 PyTorch 一起使用的方式移动到内存中:
-
让我们从解析器开始。我们拥有的数据集组织如下:有两个文件夹用于训练集和测试集,每个文件夹中包含两个子文件夹,分别命名为
pos和neg,正负评论文件分别放置在这些子文件夹中。数据集中的每个文件恰好包含一条评论,其情感由其所在的文件夹决定。在下面的代码示例中,我们将定义读取类的接口:#include <string> #include <vector> class ImdbReader { public: ImdbReader(const std::string& root_path); size_t get_pos_size() const; size_t get_neg_size() const; const std::string& get_pos(size_t index) const; const std::string& get_neg(size_t index) const; private: using Reviews = std::vector<std::string>; void read_ directory(const std::string& path, Reviews& reviews); private: Reviews pos_samples_; Reviews neg_samples_; size_t max_size_{0}; };我们定义了两个向量
pos_samples_和neg_samples_,它们包含从相应文件夹中读取的评论。 -
我们将假设这个类的对象应该用放置数据集之一(训练集或测试集)的根文件夹路径进行初始化。我们可以按以下方式初始化:
int main(int argc, char** argv) { if (argc > 0) { auto root_path = fs::path(argv[1]); … ImdbReader train_reader(root_path / "train"); ImdbReader test_reader(root_path / "test"); } }这个类最重要的部分是
constructor和read_directory方法。 -
构造函数是主要点,其中我们填充容器
pos_samples_和neg_samples_,以包含来自pos和neg文件夹的实际评论:namespace fs = std::filesystem; ImdbReader::ImdbReader(const std::string& root_path) { auto root = fs::path(root_path); auto neg_path = root / "neg"; auto pos_path = root / "pos"; if (fs::exists(neg_path) && fs::exists(pos_path)) { auto neg = std::async(std::launch::async, [&]() { read_directory(neg_path, neg_samples_); }); auto pos = std::async(std::launch::async, [&]() { read_directory(pos_path, pos_samples_); }); neg.get(); pos.get(); } else { throw std::invalid_argument("ImdbReader incorrect path"); } } -
read_directory方法实现了遍历给定目录中文件的逻辑,并按以下方式读取:void ImdbReader::read_directory(const std::string& path, Reviews& reviews) { for (auto& entry : fs::directory_iterator(path)) { if (fs::is_regular_file(entry)) { std::ifstream file(entry.path()); if (file) { std::stringstream buffer; buffer << file.rdbuf(); reviews.push_back(buffer.str()); } } } }我们使用了标准库目录迭代器类
fs::directory_iterator来获取文件夹中的每个文件。这个类的对象返回fs::directory_entry类的对象,这个对象可以用is_regular_file方法来确定它是否是一个常规文件。我们用path方法获取了这个条目的文件路径。我们使用std::ifstream类型对象的rdbuf方法将整个文件读取到一个字符串对象中。现在已经实现了
ImdbReader类,我们可以进一步开始数据集的实现。我们的数据集类应该返回一对项:一个表示标记化文本,另一个表示情感值。此外,我们需要开发一个自定义函数来将批次中的张量向量转换为单个张量。如果我们想使 PyTorch 与我们的自定义训练数据兼容,这个函数是必需的。 -
让我们定义一个自定义训练数据样本的
ImdbSample类型。我们将与torch::data::Dataset类型一起使用它:using ImdbData = std::pair<torch::Tensor, torch::Tensor>; using ImdbExample = torch::data::Example<ImdbData, torch::Tensor>;ImdbData代表训练数据,包含用于标记序列和注意力掩码的两个张量。ImdbSample代表整个样本,包含一个目标值。张量包含1或0,分别表示正面或负面情感。 -
以下代码片段展示了
ImdbDataset类的声明:class ImdbDataset : public torch::data::Dataset<ImdbDataset, ImdbExample> { public: ImdbDataset(const std::string& dataset_path, std::shared_ptr<Tokenizer> tokenizer); // torch::data::Dataset implementation ImdbExample get(size_t index) override; torch::optional<size_t> size() const override; private: ImdbReader reader_; std::shared_ptr<Tokenizer> tokenizer_; };我们从
torch::data::Dataset类继承了我们的数据集类,以便我们可以用它来初始化数据加载器。PyTorch 数据加载器对象负责从训练对象中采样随机对象并从中制作批次。我们的ImdbDataset类对象应该用ImdbReader和Tokenizer对象的根数据集路径进行初始化。构造函数的实现很简单;我们只是初始化了读取器并存储了分词器的指针。请注意,我们使用了分词器的指针来在训练集和测试集之间共享它。我们重写了torch::data::Dataset类中的两个方法:get和size方法。 -
以下代码展示了我们如何实现
size方法:torch::optional<size_t> ImdbDataset::size() const { return reader_.get_pos_size() + reader_.get_neg_size(); }size方法返回ImdbReader对象中的评论数量。 -
get方法的实现比之前的方法更复杂,如下面的代码所示:ImdbExample ImdbDataset::get(size_t index) { torch::Tensor target; const std::string* review{nullptr}; if (index < reader_.get_pos_size()) { review = &reader_.get_pos(index); target = torch::tensor(1, torch::dtype(torch::kLong)); } else { review = &reader_.get_neg(index - reader_.get_pos_size()); target = torch::tensor(0, torch::dtype(torch::kLong)); } // encode text auto tokenizer_out = tokenizer_->tokenize(*review); return {tokenizer_out, target.squeeze()}; }首先,我们从给定的索引(函数参数值)中获取了评论文本和情感值。在
size方法中,我们返回了总的正面和负面评论数量,所以如果输入索引大于正面评论的数量,那么这个索引指向的是一个负面的评论。然后,我们从它中减去正面评论的数量。在我们得到了正确的索引后,我们也得到了相应的文本评论,将其地址分配给
review指针,并初始化了target张量。使用torch::tensor函数初始化target张量。这个函数接受任意数值和诸如所需类型等张量选项。对于评论文本,我们仅使用分词器对象创建了两个包含标记 ID 和情感掩码的张量。它们被打包在
tokenizer_out对象对中。我们返回了训练张量对和单个目标张量。 -
为了能够有效地使用批量训练,我们创建了一个特殊的类,这样 PyTorch 就能将我们的非标准样本类型转换为批处理张量。在最简单的情况下,我们将得到训练样本的
std::vector对象而不是单个批处理张量。这是通过以下方式完成的:torch::data::transforms::Collation<ImdbExample> { ImdbExample apply_batch(std::vector<ImdbExample> examples) override { std::vector<torch::Tensor> input_ids; std::vector<torch::Tensor> attention_masks; std::vector<torch::Tensor> labels; input_ids.reserve(examples.size()); attention_masks.reserve(examples.size()); labels.reserve(examples.size()); for (auto& example : examples) { input_ids.push_back(std::move(example.data.first)); attention_masks.push_back( std::move(example.data.second)); labels.push_back(std::move(example.target)); } return {{torch::stack(input_ids), torch::stack(attention_masks)}, torch::stack(labels)}; } }
我们从特殊的 PyTorch torch::data::transforms::Collation 类型继承了我们自己的类,并通过 ImdbExample 类的模板参数对其进行特殊化。有了这样一个类,我们重写了虚拟的 apply_batch 函数,其实现在输入是包含 ImdbExample 对象的 std::vector 对象,并返回单个 ImdbExample 对象。这意味着我们将所有输入 ID、注意力掩码和目标张量合并到三个单独的张量中。这是通过为输入 ID、注意力掩码和目标张量创建三个单独的容器来完成的。它们通过简单地遍历输入样本来填充。然后,我们仅使用 torch::stack 函数将这些容器合并(堆叠)成单个张量。这个类将在后续的数据加载器类型对象构建中使用。
实现模型
下一步是创建一个 model 类。我们已经有了一个将要用作预训练部分的导出模型。我们创建了一个简单的分类头,包含两个线性全连接层和一个 dropout 层用于正则化:
-
这个类的头文件看起来如下:
#include <torch/script.h> #include <torch/torch.h> class ModelImpl : public torch::nn::Module { public: ModelImpl() = delete; ModelImpl(const std::string& bert_model_path); torch::Tensor forward(at::Tensor input_ids, at::Tensor attention_masks); private: torch::jit::script::Module bert_; torch::nn::Dropout dropout_; torch::nn::Linear fc1_; torch::nn::Linear fc2_; }; TORCH_MODULE(Model);我们包含了
torch\script.h头文件以使用torch::jit::script::Module类。这个类的实例将被用作之前导出的 BERT 模型的表示。参见bert_成员变量。我们还定义了线性层和 dropout 层的成员变量,它们是torch::jit::script::Module类的实例。我们从torch::nn::Module类继承了我们的ModelImpl类,以便将其集成到 PyTorch 自动梯度系统中。 -
构造函数的实现如下:
ModelImpl::ModelImpl(const std::string& bert_model_path) : dropout_(register_module( "dropout", torch::nn::Dropout( torch::nn::DropoutOptions().p(0.2)))), fc1_(register_module( "fc1", torch::nn::Linear( torch::nn::LinearOptions(768, 512)))), fc2_(register_module( "fc2", torch::nn::Linear( torch::nn::LinearOptions(512, 2)))) { bert_ = torch::jit::load(bert_model_path); }我们使用了
torch::jit::load来加载我们从 Python 导出的模型。这个函数接受一个参数——模型文件名。此外,我们还初始化了 dropout 和线性层,并在父torch::nn::Module对象中注册了它们。fc1_线性层是输入层;它接收 BERT 模型的 768 维输出。fc2_线性层是输出层。它将内部 512 维状态处理成两个类别。 -
主模型功能是在
forward函数中实现的,如下所示:torch::Tensor ModelImpl::forward( at::Tensor input_ids, at::Tensor attention_masks) { std::vector<torch::jit::IValue> inputs = { input_ids, attention_masks}; auto bert_output = bert_.forward(inputs); auto pooler_output = bert_output.toTuple()->elements()[1].toTensor(); auto x = fc1_(pooler_output); x = torch::nn::functional::relu(x); x = dropout_(x); x = fc2_(x); x = torch::softmax(x, /*dim=*/1); return x; }此函数接收输入标记 ID 以及相应的注意力掩码,并返回一个包含分类结果的二维张量。我们将情感分析视为一个分类任务。
forward实现有两个部分。一部分是我们使用加载的 BERT 模型对输入进行预处理;这种预处理只是使用预训练的 BERT 模型主干进行推理。第二部分是我们将 BERT 输出通过我们的分类头。这是可训练的部分。要使用 BERT 模型,即torch::jit:script::Module对象,我们将输入打包到torch::jit::Ivalue对象的std::vector容器中。从torch::Tensor的转换是自动完成的。然后,我们使用了 PyTorch 的标准
forward函数进行推理。这个函数返回一个torch::jit元组对象;返回类型实际上取决于最初被追踪的模型。因此,要从torch::jit值对象中获取 PyTorch 张量,我们显式地使用了toTuple方法来说明如何解释输出结果。然后,我们通过使用elements方法访问第二个元组元素,该方法为元组元素提供了索引操作符。最后,为了获取张量,我们使用了jit::Ivalue对象的toTensor方法,这是一个元组元素。我们使用的 BERT 模型返回两个张量。第一个表示输入标记的嵌入值,第二个是池化输出。池化输出是输入文本的
[CLS]标记的嵌入。产生此输出的线性层权重是在 BERT 预训练期间从下一个句子预测(分类)目标中训练出来的。因此,这些是在以下文本分类任务中使用的理想值。这就是为什么我们取 BERT 模型返回的元组的第二个元素。forward函数的第二部分同样简单。我们将 BERT 的输出传递给fc1_线性层,随后是relu激活函数。在此操作之后,我们得到了512个内部隐藏状态。然后,这个状态被dropout_模块处理,以向模型中引入一些正则化。最后阶段是使用fc2_输出线性模块,它返回一个二维向量,随后是softmax函数。softmax函数将 logits 转换为范围在[0,1]之间的概率值,因为来自线性层的原始 logits 可以具有任意值,需要将它们转换为目标值。
现在,我们已经描述了训练过程所需的所有组件。让我们看看模型训练是如何实现的。
训练模型
训练的第一步是创建数据集对象,可以按照以下方式完成:
auto tokenizer = std::make_shared<Tokenizer>(vocab_path);
ImdbDataset train_dataset(dataset_path / "train", tokenizer);
我们仅通过传递对应文件的路径就创建了tokenizer和train_dataset对象。测试数据集也可以用相同的方式创建,使用相同的tokenizer对象。现在我们有了数据集,我们创建数据加载器如下:
int batch_size = 8;
auto train_loader = torch::data::make_data_loader(
train_dataset.map(Stack()),
torch::data::DataLoaderOptions()
.batch_size(batch_size)
.workers(8));
我们指定了批大小,并使用make_data_loader函数创建了数据加载器对象。此对象使用数据集有效地加载和组织批量训练样本。我们使用train_dataset对象的map转换函数和一个我们的Stack类实例来允许 PyTorch 将我们的训练样本合并成张量批次。此外,我们还指定了一些数据加载器选项,即批大小和用于加载数据和预处理的线程数。
我们创建模型对象如下:
torch::DeviceType device = torch::cuda::is_available()
? torch::DeviceType::CUDA
: torch::DeviceType::CPU;
Model model(model_path);
model->to(device);
我们使用了torch::cuda::is_available函数来确定系统是否可用 CUDA 设备,并相应地初始化device变量。使用 CUDA 设备可以显著提高模型的训练和推理。模型是通过一个构造函数创建的,该构造函数接受导出 BERT 模型的路径。在模型对象初始化后,我们将此对象移动到特定的设备。
训练所需的最后一个组件是一个优化器,我们创建如下:
torch::optim::AdamW optimizer(model->parameters(),
torch::optim::AdamWOptions(1e-5));
我们使用了AdamW优化器,这是流行的 Adam 优化器的一个改进版本。为了构建优化器对象,我们将模型参数和学习率选项作为构造函数参数传递。
训练周期可以定义为以下内容:
for (int epoch = 0; epoch < epochs; ++epoch) {
model->train();
for (auto& batch : (*train_loader)) {
optimizer.zero_grad();
auto batch_label = batch.target.to(device);
auto batch_input_ids =
batch.data.first.squeeze(1).to(device);
auto batch_attention_mask =
batch.data.second.squeeze(1).to(device);
auto output =
model(batch_input_ids, batch_attention_mask);
torch::Tensor loss =
torch::cross_entropy_loss(output, batch_label);
loss.backward();
torch::nn::utils::clip_grad_norm_(model->parameters(),
1.0);
optimizer.step();
}
}
有两个嵌套循环。一个是遍历 epoch 的循环,还有一个是内部循环,遍历批次。每个内部循环的开始就是新 epoch 的开始。我们将模型切换到训练模式。这个切换可以只做一次,但通常,你会有一些测试代码将模型切换到评估模式,因此这个切换会将模型返回到所需的状态。在这里,为了简化,我们省略了测试代码。它看起来与训练代码非常相似,唯一的区别是禁用了梯度计算。在内部循环中,我们使用了基于范围的for循环 C++语法来遍历批次。对于每个批次,首先,我们通过调用优化器对象的zero_grad函数来清除梯度值。然后,我们将批次解耦成单独的张量对象。此外,如果可用,我们将这些张量移动到 GPU 设备上。这是通过.to(device)调用完成的。我们使用squeeze方法从模型输入张量中移除了一个额外的维度。这个维度是在自动批次创建过程中出现的。
一旦所有张量都准备好了,我们就用我们的模型进行了预测,得到了output张量。这个输出被用于torch::cross_entropy_loss损失函数,通常用于多类分类。它接受一个包含每个类概率的张量和一个 one-hot 编码的标签张量。然后,我们使用loss张量的backward方法来计算梯度。此外,我们通过设置一个上限值来使用clip_grad_norm_函数剪辑梯度,以防止它们爆炸。一旦梯度准备好了,我们就使用优化器的step函数根据优化器算法更新模型权重。
使用我们使用的设置,这个架构可以在 500 个训练 epoch 中实现超过 80%的电影评论情感分析准确率。
摘要
本章介绍了 Transformer 架构,这是一个在 NLP 和其他机器学习领域广泛使用的强大模型。我们讨论了 Transformer 架构的关键组件,包括分词、嵌入、位置编码、编码器、解码器、注意力机制、多头注意力、交叉注意力、残差连接、归一化层、前馈层和采样技术。
最后,在本章的最后部分,我们开发了一个应用程序,以便我们可以对电影评论进行情感分析。我们将迁移学习技术应用于使用预训练模型在新模型中学习到的特征,该模型是为我们的特定任务设计的。我们使用 BERT 模型生成输入文本的嵌入表示,并附加了一个线性层分类头以对评论情感进行分类。我们实现了简单的分词器和数据集加载器。我们还开发了分类头的完整训练周期。
我们使用迁移学习而不是微调来利用更少的计算资源,因为微调技术通常涉及在新的数据集上重新训练一个完整的预训练模型。
在下一章中,我们将讨论如何保存和加载模型参数。我们还将查看机器学习库中为此目的存在的不同 API。保存和加载模型参数可能是训练过程中的一个相当重要的部分,因为它允许我们在任意时刻停止和恢复训练。此外,保存的模型参数可以在模型训练后用于评估目的。
进一步阅读
-
PyTorch 文档:
pytorch.org/cppdocs/ -
Hugging Face BERT 模型文档:
huggingface.co/docs/transformers/model_doc/bert -
详细的 Transformer 解释:
jalammar.github.io/illustrated-transformer -
《注意力即一切》,作者:Ashish Vaswani, Noam Shazeer, Niki Parmar, Jakob Uszkoreit, Llion Jones, Aidan N. Gomez, Lukasz Kaiser, Illia Polosukhin:
arxiv.org/abs/1706.03762 -
一份预训练的 BERT 类似模型列表,用于情感分析:
huggingface.co/models?other=sentiment-analysis
第四部分:生产和部署挑战
C++的关键特性是程序能够在各种硬件平台上编译和运行。你可以在数据中心最快的 GPU 上训练你的复杂机器学习(ML)模型,并将其部署到资源有限的微型移动设备上。这部分将向你展示如何使用各种 ML 框架的 C++ API 来保存和加载训练好的模型,以及如何跟踪和可视化训练过程,这对于 ML 从业者来说至关重要,因为他们能够控制和检查模型的训练性能。此外,我们将学习如何构建在 Android 设备上使用 ML 模型的程序;特别是,我们将创建一个使用设备摄像头的目标检测系统。
本部分包括以下章节:
-
第十二章,导出和导入模型
-
第十三章,跟踪和可视化 ML 实验
-
第十四章,在移动平台上部署模型
第十二章:导出和导入模型
在本章中,我们将讨论如何在训练期间和之后保存和加载模型参数。这很重要,因为模型训练可能需要几天甚至几周。保存中间结果允许我们在以后进行评估或生产使用时加载它们。
这种常规的保存操作在随机应用程序崩溃的情况下可能有益。任何 机器学习(ML)框架的另一个重要特性是它导出模型架构的能力,这使我们能够在框架之间共享模型,并使模型部署更加容易。本章的主要内容是展示如何使用不同的 C++ 库导出和导入模型参数,如权重和偏置值。本章的第二部分全部关于 开放神经网络交换(ONNX)格式,该格式目前在不同的 ML 框架中越来越受欢迎,可以用于共享训练模型。此格式适用于共享模型架构以及模型参数。
本章将涵盖以下主题:
-
C++ 库中的 ML 模型序列化 API
-
深入了解 ONNX 格式
技术要求
以下为本章的技术要求:
-
Dlib库 -
mlpack库 -
F``lashlight库 -
pytorch库 -
onnxruntime框架 -
支持 C++20 的现代 C++ 编译器
-
CMake 构建系统版本 >= 3.8
本章的代码文件可以在本书的 GitHub 仓库中找到:github.com/PacktPublishing/Hands-on-Machine-learning-with-C-Second-Edition/tree/main/Chapter12。
C++ 库中的 ML 模型序列化 API
在本节中,我们将讨论 Dlib、F``lashlight、mlpack 和 pytorch 库中可用的 ML 模型共享 API。在不同的 C++ 库之间共享 ML 模型主要有三种类型:
-
共享模型参数(权重)
-
共享整个模型的架构
-
共享模型架构及其训练参数
在以下各节中,我们将查看每个库中可用的 API,并强调它支持哪种类型的共享。
使用 Dlib 进行模型序列化
Dlib 库使用 decision_function 和神经网络对象的序列化 API。让我们通过实现一个真实示例来学习如何使用它。
首先,我们将定义神经网络、回归核和训练样本的类型:
using namespace Dlib;
using NetworkType = loss_mean_squared<fc<1, input<matrix<double>>>>;
using SampleType = matrix<double, 1, 1>;
using KernelType = linear_kernel<SampleType>;
然后,我们将使用以下代码生成训练数据:
size_t n = 1000;
std::vector<matrix<double>> x(n);
std::vector<float> y(n);
std::random_device rd;
std::mt19937 re(rd());
std::uniform_real_distribution<float> dist(-1.5, 1.5);
// generate data
for (size_t i = 0; i < n; ++i) {
xi = i;
y[i] = func(i) + dist(re);
}
在这里,x 代表预测变量,而 y 代表目标变量。目标变量 y 被均匀随机噪声盐化,以模拟真实数据。这些变量具有线性依赖关系,该关系由以下函数定义:
double func(double x) {
return 4\. + 0.3 * x;
}
生成数据后,我们使用vector_normalizer类型的对象对其进行归一化。这种类型的对象在训练后可以重复使用,以使用学习到的均值和标准差对数据进行归一化。以下代码片段展示了其实现方式:
vector_normalizer<matrix<double>> normalizer_x;
normalizer_x.train(x);
for (size_t i = 0; i < x.size(); ++i) {
x[i] = normalizer_x(x[i]);
}
最后,我们使用krr_trainer类型的对象训练核岭回归的decision_function对象:
void TrainAndSaveKRR(const std::vector<matrix<double>>& x,
const std::vector<float>& y) {
krr_trainer<KernelType> trainer;
trainer.set_kernel(KernelType());
decision_function<KernelType> df = trainer.train(x, y);
serialize("Dlib-krr.dat") << df;
}
注意,我们使用KernelType对象的实例初始化了训练器对象。
现在我们有了训练好的decision_function对象,我们可以使用serialize函数返回的流对象将其序列化到文件中:
serialize("Dlib-krr.dat") << df;
此函数将文件存储的名称作为输入参数,并返回一个输出流对象。我们使用了<<运算符将回归模型学习到的权重放入文件。在先前的代码示例中使用的序列化方法仅保存模型参数。
同样的方法可以用来序列化Dlib库中的几乎所有机器学习模型。以下代码展示了如何使用它来序列化神经网络的参数:
void TrainAndSaveNetwork(
const std::vector<matrix<double>>& x,
const std::vector<float>& y) {
NetworkType network;
sgd solver;
dnn_trainer<NetworkType> trainer(network, solver);
trainer.set_learning_rate(0.0001);
trainer.set_mini_batch_size(50);
trainer.set_max_num_epochs(300);
trainer.be_verbose();
trainer.train(x, y);
network.clean();
serialize("Dlib-net.dat") << network;
net_to_xml(network, "net.xml");
}
对于神经网络,还有一个net_to_xml函数,它保存模型结构。然而,库 API 中没有函数可以将保存的结构加载到我们的程序中。这是用户的责任来实现加载函数。
如果我们希望在不同框架之间共享模型,可以使用net_to_xml函数,如Dlib文档中所示。
为了检查参数序列化是否按预期工作,我们可以生成新的测试数据来评估加载的模型:
std::cout << "Target values \n";
std::vector<matrix<double>> new_x(5);
for (size_t i = 0; i < 5; ++i) {
new_x[i].set_size(1, 1);
new_xi = i;
new_x[i] = normalizer_x(new_x[i]);
std::cout << func(i) << std::endl;
}
注意,我们已经重用了normalizer对象。一般来说,normalizer对象的参数也应该进行序列化和加载,因为在评估过程中,我们需要将新数据转换为我们用于训练数据的相同统计特性。
要在Dlib库中加载序列化的对象,我们可以使用deserialize函数。此函数接受文件名并返回一个输入流对象:
void LoadAndPredictKRR(
const std::vector<matrix<double>>& x) {
decision_function<KernelType> df;
deserialize("Dlib-krr.dat") >> df;
// Predict
std::cout << "KRR predictions \n";
for (auto& v : x) {
auto p = df(v);
std::cout << static_cast<double>(p) << std::endl;
}
}
如前所述,在Dlib库中,序列化仅存储模型参数。因此,要加载它们,我们需要使用在序列化之前具有相同属性的模型对象。
对于回归模型,这意味着我们应该实例化一个与相同核类型相对应的决策函数对象。
对于神经网络模型,这意味着我们应该实例化一个与序列化时使用的相同类型的网络对象,如下面的代码块所示:
void LoadAndPredictNetwork(
const std::vector<matrix<double>>& x) {
NetworkType network;
deserialize("Dlib-net.dat") >> network;
// Predict
auto predictions = network(x);
std::cout << "Net predictions \n";
for (auto p : predictions) {
std::cout << static_cast<double>(p) << std::endl;
}
}
在本节中,我们了解到Dlib序列化 API 允许我们保存和加载机器学习模型参数,但在序列化和加载模型架构方面选项有限。在下一节中,我们将探讨Shogun库模型序列化 API。
使用 Flashlight 进行模型序列化
Flashlight库可以将模型和参数保存和加载到二进制格式中。它内部使用Cereal C++库进行序列化。以下示例展示了这一功能。
如前例所示,我们首先创建一些示例训练数据:
int64_t n = 10000;
auto x = fl::randn({n});
auto y = x * 0.3f + 0.4f;
// Define dataset
std::vector<fl::Tensor> fields{x, y};
auto dataset = std::make_shared<fl::TensorDataset>(fields);
fl::BatchDataset batch_dataset(dataset, /*batch_size=*/64);
在这里,我们创建了一个包含随机数据的向量x,并通过应用线性依赖公式来创建我们的目标变量y。我们将独立和目标向量包装到一个名为batch_dataset的BatchDataset对象中,我们将使用它来训练一个示例神经网络。
以下代码展示了我们的神经网络定义:
fl::Sequential model;
model.add(fl::View({1, 1, 1, -1}));
model.add(fl::Linear(1, 8));
model.add(fl::ReLU());
model.add(fl::Linear(8, 16));
model.add(fl::ReLU());
model.add(fl::Linear(16, 32));
model.add(fl::ReLU());
model.add(fl::Linear(32, 1));
如您所见,这是我们之前示例中使用的相同的正向传播网络,但这次是为 Flashlight 设计的。
以下代码示例展示了如何训练模型:
auto loss = fl::MeanSquaredError();
float learning_rate = 0.01;
float momentum = 0.5;
auto sgd = fl::SGDOptimizer(model.params(),
learning_rate,
momentum);
const int epochs = 5;
for (int epoch_i = 0; epoch_i < epochs; ++epoch_i) {
for (auto& batch : batch_dataset) {
sgd.zeroGrad();
auto predicted = model(fl::input(batch[0]));
auto local_batch_size = batch[0].shape().dim(0);
auto target =
fl::reshape(batch[1], {1, 1, 1, local_batch_size});
auto loss_value = loss(predicted, fl::noGrad(target));
loss_value.backward();
sgd.step();
}
}
在这里,我们使用了之前使用的相同训练方法。首先,我们定义了loss对象和sgd优化器对象。然后,我们使用两个循环来训练模型:一个循环遍历 epoch,另一个循环遍历批次。在内循环中,我们将模型应用于训练批次数据以获取新的预测值。然后,我们使用loss对象使用批次目标值计算 MSE 值。我们还使用了损失值变量的backward方法来计算梯度。最后,我们使用sgd优化器对象的step方法更新模型参数。
现在我们有了训练好的模型,我们有两种方法可以在Flashlight库中保存它:
-
序列化整个模型及其架构和权重。
-
仅序列化模型权重。
对于第一种选项——即序列化整个模型及其架构——我们可以这样做:
fl::save("model.dat", model);
在这里,model.dat是我们将保存模型的文件名。要加载此类文件,我们可以使用以下代码:
fl::Sequential model_loaded;
fl::load("model.dat", model_loaded);
在这种情况下,我们创建了一个名为model_loaded的新空对象。这个新对象只是一个没有特定层的fl::Sequential容器对象。所有层和参数值都是通过fl::load函数加载的。一旦我们加载了模型,我们就可以这样使用它:
auto predicted = model_loaded(fl::noGrad(new_x));
在这里,new_x是我们用于评估目的的一些新数据。
当您存储整个模型时,这种方法对于包含不同模型但具有相同输入和输出接口的应用程序可能很有用,因为它可以帮助您轻松地在生产中更改或升级模型,例如。
第二种选项,仅保存网络的参数(权重)值,如果我们需要定期重新训练模型,或者如果我们只想共享或重用模型或其参数的某些部分,这可能是有用的。为此,我们可以使用以下代码:
fl::save("model_params.dat", model.params());
在这里,我们使用了model对象的params方法来获取所有模型参数。此方法返回所有模型子模块的参数的std::vector序列。因此,您只能管理其中的一些。要加载已保存的参数,我们可以使用以下代码:
std::vector<fl::Variable> params;
fl::load("model_params.dat", params);
for (int i = 0; i < static_cast<int>(params.size()); ++i) {
model.setParams(params[i], i);
}
首先,我们创建了空的 params 容器。然后,使用 fl::load 函数将参数值加载到其中。为了能够更新特定子模块的参数值,我们使用了 setParams 方法。'setParams' 方法接受一个值和一个整数位置,我们想要设置这个值。我们保存了所有模型参数,以便我们可以按顺序将它们放回模型中。
不幸的是,没有方法可以将其他格式的模型和权重加载到 Flashlight 库中。因此,如果您需要从其他格式加载,您必须编写一个转换器并使用 setParams 方法设置特定值。在下一节中,我们将深入了解 mlpack 库的序列化 API。
使用 mlpack 进行模型序列化
mlpack 库仅实现了模型参数序列化。这种序列化基于存在于 Armadillo 数学库中的功能,该库被用作 mlpack 的后端。这意味着我们可以使用 mlpack API 以不同的文件格式保存参数值。具体如下:
-
.csv,或者可选的.txt -
.txt -
.txt -
.pgm -
.ppm -
.bin -
.bin -
.hdf5、.hdf、.h5或.he5
让我们看看使用 mlpack 创建模型和参数管理的最小示例。首先,我们需要一个模型。以下代码展示了我们可以使用的创建模型的功能:
using ModelType = FFN<MeanSquaredError, ConstInitialization>;
ModelType make_model() {
MeanSquaredError loss;
ConstInitialization init(0.);
ModelType model(loss, init);
model.Add<Linear>(8);
model.Add<ReLU>();
model.Add<Linear>(16);
model.Add<ReLU>();
model.Add<Linear>(32);
model.Add<ReLU>();
model.Add<Linear>(1);
return model;
}
create_model 函数创建了一个具有多个线性层的前馈网络。请注意,我们使此模型使用 MSE 作为损失函数并添加了零参数初始化器。现在我们有了模型,我们需要一些数据来训练它。以下代码展示了如何创建线性相关数据:
size_t n = 10000;
arma::mat x = arma::randn(n).t();
arma::mat y = x * 0.3f + 0.4f;
在这里,我们创建了两个单维向量,类似于我们在 Flashlight 示例中所做的,但使用了 Armadillo 矩阵 API。请注意,我们使用了 t() 转置方法对 x 向量进行操作,因为 mlpack 使用列维度作为其训练特征。
现在,我们可以连接所有组件并执行模型训练:
ens::Adam optimizer;
auto model = make_model();
model.Train(x, y, optimizer);
在这里,我们创建了 Adam 算法优化器对象,并在模型的 Train 方法中使用我们之前创建的两个数据向量。现在,我们有了训练好的模型,准备保存其参数。这可以按以下方式完成:
data::Save("model.bin", model.Parameters(), true);
默认情况下,data::Save 函数会根据提供的文件名扩展名自动确定要保存的文件格式。在这里,我们使用了模型对象的 Parameters 方法来获取参数值。此方法返回一个包含所有值的矩阵。我们还传递了 true 作为第三个参数,以便在失败的情况下 save 函数抛出异常。默认情况下,它将只返回 false;这是您必须手动检查的事情。
我们可以使用 mlpack::data::Load 函数来加载参数值,如下所示:
auto new_model = make_model();
data::Load("model.bin", new_model.Parameters());
在这里,我们创建了new_model对象;这是一个与之前相同的模型,但参数初始化为零。然后,我们使用mlpack::data::Load函数从文件中加载参数值。再次使用Parameters方法获取内部参数值矩阵的引用,并将其传递给load函数。我们将load函数的第三个参数设置为true,以便在出现错误时可以抛出异常。
现在我们已经初始化了模型,我们可以用它来进行预测:
arma::mat predictions;
new_model.Predict(new_x, predictions);
在这里,我们创建了一个输出矩阵prediction,并使用new_model对象的Predict方法进行模型评估。请注意,new_x是我们希望对其获取预测的一些新数据。
注意,你不能将其他框架的文件格式加载到 mlpack 中,因此如果你需要,你必须创建转换器。在下一节中,我们将查看pytorch库的序列化 API。
使用 PyTorch 进行模型序列化
在本节中,我们将讨论在pytorch C++库中可用的两种网络参数序列化方法:
-
torch::save函数 -
一个
torch::serialize::OutputArchive类型的对象,用于将参数写入OutputArchive对象
让我们从准备神经网络开始。
初始化神经网络
让我们从生成训练数据开始。以下代码片段显示了我们可以如何做到这一点:
torch::DeviceType device = torch::cuda::is_available()
? torch::DeviceType::CUDA
: torch::DeviceType::CPU;
通常,我们希望尽可能利用硬件资源。因此,首先,我们通过使用torch::cuda::is_available()调用检查系统中是否有带有 CUDA 技术的 GPU 可用:
std::random_device rd;
std::mt19937 re(rd());
std::uniform_real_distribution<float> dist(-0.1f, 0.1f);
我们定义了dist对象,以便我们可以在-1到1的范围内生成均匀分布的实数:
size_t n = 1000;
torch::Tensor x;
torch::Tensor y;
{
std::vector<float> values(n);
std::iota(values.begin(), values.end(), 0);
std::shuffle(values.begin(), values.end(), re);
std::vector<torch::Tensor> x_vec(n);
std::vector<torch::Tensor> y_vec(n);
for (size_t i = 0; i < n; ++i) {
x_vec[i] = torch::tensor(
values[i],
torch::dtype(torch::kFloat).device(
device).requires_grad(false));
y_vec[i] = torch::tensor(
(func(values[i]) + dist(re)),
torch::dtype(torch::kFloat).device(
device).requires_grad(false));
}
x = torch::stack(x_vec);
y = torch::stack(y_vec);
}
然后,我们生成了 1,000 个预测变量值并将它们打乱。对于每个值,我们使用在之前的示例中使用的线性函数计算目标值——即func。下面是这个过程的示例:
float func(float x) {
return 4.f + 0.3f * x;
}
然后,所有值都通过torch::tensor函数调用移动到torch::Tensor对象中。请注意,我们使用了之前检测到的设备来创建张量。一旦我们将所有值移动到张量中,我们就使用torch::stack函数将预测值和目标值连接到两个不同的单张量中。这是必要的,以便我们可以使用pytorch库的线性代数例程进行数据归一化:
auto x_mean = torch::mean(x, /*dim*/ 0);
auto x_std = torch::std(x, /*dim*/ 0);
x = (x - x_mean) / x_std;
最后,我们使用了torch::mean和torch::std函数来计算预测值的平均值和标准差,并将它们进行了归一化处理。
在以下代码中,我们定义了NetImpl类,该类实现了我们的神经网络:
class NetImpl : public torch::nn::Module {
public:
NetImpl() {
l1_ = torch::nn::Linear(torch::nn::LinearOptions(
1, 8).with_bias(true));
register_module("l1", l1_);
l2_ = torch::nn::Linear(torch::nn::LinearOptions(
8, 4).with_bias(true));
register_module("l2", l2_);
l3_ = torch::nn::Linear(torch::nn::LinearOptions(
4, 1).with_bias(true));
register_module("l3", l3_);
// initialize weights
for (auto m : modules(false)) {
if (m->name().find("Linear") != std::string::npos) {
for (auto& p : m->named_parameters()) {
if (p.key().find("weight") != std::string::npos) {
torch::nn::init::normal_(p.value(), 0, 0.01);
}
if (p.key().find("bias") != std::string::npos) {
torch::nn::init::zeros_(p.value());
}
}
}
}
}
torch::Tensor forward(torch::Tensor x) {
auto y = l1_(x);
y = l2_(y);
y = l3_(y);
return y;
}
private:
torch::nn::Linear l1_{nullptr};
torch::nn::Linear l2_{nullptr};
torch::nn::Linear l3_{nullptr};
}
TORCH_MODULE(Net);
在这里,我们将我们的神经网络模型定义为一个具有三个全连接神经元层和线性激活函数的网络。每个层都是torch::nn::Linear类型。
在我们模型的构造函数中,我们使用小的随机值初始化了所有网络参数。我们通过遍历所有网络模块(参见modules方法调用)并应用torch::nn::init::normal_函数到由named_parameters()模块方法返回的参数来实现这一点。偏差使用torch::nn::init::zeros_函数初始化为零。named_parameters()方法返回由字符串名称和张量值组成的对象,因此对于初始化,我们使用了它的value方法。
现在,我们可以使用我们生成的训练数据来训练模型。以下代码展示了我们如何训练我们的模型:
Net model;
model->to(device);
// initialize optimizer -----------------------------------
double learning_rate = 0.01;
torch::optim::Adam optimizer(model->parameters(),
torch::optim::AdamOptions(learning_rate).weight_decay(0.00001));
// training
int64_t batch_size = 10;
int64_t batches_num = static_cast<int64_t>(n) / batch_size;
int epochs = 10;
for (int epoch = 0; epoch < epochs; ++epoch) {
// train the model
// -----------------------------------------------
model->train(); // switch to the training mode
// Iterate the data
double epoch_loss = 0;
for (int64_t batch_index = 0; batch_index < batches_num;
++batch_index) {
auto batch_x =
x.narrow(0, batch_index * batch_size, batch_size)
.unsqueeze(1);
auto batch_y =
y.narrow(0, batch_index * batch_size, batch_size)
.unsqueeze(1);
// Clear gradients
optimizer.zero_grad();
// Execute the model on the input data
torch::Tensor prediction = model->forward(batch_x);
torch::Tensor loss =
torch::mse_loss(prediction, batch_y);
// Compute gradients of the loss and parameters of
// our model
loss.backward();
// Update the parameters based on the calculated
// gradients.
optimizer.step();
}
}
为了利用所有我们的硬件资源,我们将模型移动到选定的计算设备。然后,我们初始化了一个优化器。在我们的例子中,优化器使用了Adam算法。之后,我们在每个 epoch 上运行了一个标准的训练循环,其中对于每个 epoch,我们取训练批次,清除优化器的梯度,执行前向传递,计算损失,执行反向传递,并使用优化器步骤更新模型权重。
从数据集中选择一批训练数据,我们使用了张量的narrow方法,该方法返回了一个维度减少的新张量。此函数接受新的维度数量作为第一个参数,起始位置作为第二个参数,以及要保留的元素数量作为第三个参数。我们还使用了unsqueeze方法来添加一个批次维度;这是 PyTorch API 进行前向传递所必需的。
如我们之前提到的,我们可以使用两种方法在 C++ API 中的pytorch序列化模型参数(Python API 提供了更多的功能)。让我们来看看它们。
使用 torch::save 和 torch::load 函数
我们可以采取的第一种保存模型参数的方法是使用torch::save函数,该函数递归地保存传递的模块的参数:
torch::save(model, "pytorch_net.pt");
为了正确地与我们的自定义模块一起使用,我们需要使用register_module模块的方法将所有子模块在父模块中注册。
要加载保存的参数,我们可以使用torch::load函数:
Net model_loaded;
torch::load(model_loaded, "pytorch_net.pt");
该函数将读取自文件的值填充到传递的模块参数中。
使用 PyTorch 存档对象
第二种方法是用torch::serialize::OutputArchive类型的对象,并将我们想要保存的参数写入其中。以下代码展示了如何实现我们模型的SaveWeights方法。此方法将我们模块中存在的所有参数和缓冲区写入archive对象,然后它使用save_to方法将它们写入文件:
void NetImpl::SaveWeights(const std::string& file_name) {
torch::serialize::OutputArchive archive;
auto parameters = named_parameters(true /*recurse*/);
auto buffers = named_buffers(true /*recurse*/);
for (const auto& param : parameters) {
if (param.value().defined()) {
archive.write(param.key(), param.value());
}
}
for (const auto& buffer : buffers) {
if (buffer.value().defined()) {
archive.write(buffer.key(), buffer.value(),
/*is_buffer*/ true);
}
}
archive.save_to(file_name);
}
保存缓冲区张量也很重要。可以使用 named_buffers 模块的 named_buffers 方法从模块中检索缓冲区。这些对象代表用于评估不同模块的中间值。例如,我们可以是批归一化模块的运行均值和标准差值。在这种情况下,如果我们使用序列化来保存中间步骤并且由于某种原因训练过程停止,我们需要它们继续训练。
要加载以这种方式保存的参数,我们可以使用 torch::serialize::InputArchive 对象。以下代码展示了如何为我们的模型实现 LoadWeights 方法:
void NetImpl::LoadWeights(const std::string& file_name) {
torch::serialize::InputArchive archive;
archive.load_from(file_name);
torch::NoGradGuard no_grad;
auto parameters = named_parameters(true /*recurse*/);
auto buffers = named_buffers(true /*recurse*/);
for (auto& param : parameters) {
archive.read(param.key(), param.value());
}
for (auto& buffer : buffers) {
archive.read(buffer.key(), buffer.value(),
/*is_buffer*/ true);
}
}
在这里,LoadWeights 方法使用 archive 对象的 load_from 方法从文件中加载参数。首先,我们使用 named_parameters 和 named_buffers 方法从我们的模块中获取参数和缓冲区,并使用 archive 对象的 read 方法逐步填充它们的值。
注意,我们使用 torch::NoGradGuard 类的实例来告诉 pytorch 库我们不会执行任何模型计算或图相关操作。这样做是必要的,因为 pytorch 库构建计算图和任何无关操作都可能导致错误。
现在,我们可以使用新的 model_loaded 模型实例,并带有 load 参数来评估一些测试数据上的模型。请注意,我们需要使用 eval 方法将模型切换到评估模式。生成的测试数据值也应使用 torch::tensor 函数转换为张量对象,并将其移动到与我们的模型使用的相同计算设备上。以下代码展示了我们如何实现这一点:
model_loaded->to(device);
model_loaded->eval();
std::cout << "Test:\n";
for (int i = 0; i < 5; ++i) {
auto x_val = static_cast<float>(i) + 0.1f;
auto tx = torch::tensor(
x_val, torch::dtype(torch::kFloat).device(device));
tx = (tx - x_mean) / x_std;
auto ty = torch::tensor(
func(x_val),
torch::dtype(torch::kFloat).device(device));
torch::Tensor prediction = model_loaded->forward(tx);
std::cout << "Target:" << ty << std::endl;
std::cout << "Prediction:" << prediction << std::endl;
}
在本节中,我们探讨了 pytorch 库中的两种序列化类型。第一种方法涉及使用 torch::save 和 torch::load 函数,分别轻松保存和加载所有模型参数。第二种方法涉及使用 torch::serialize::InputArchive 和 torch::serialize::OutputArchive 类型的对象,这样我们就可以选择我们想要保存和加载的参数。
在下一节中,我们将讨论 ONNX 文件格式,它允许我们在不同的框架之间共享我们的 ML 模型架构和模型参数。
深入探讨 ONNX 格式
ONNX 格式是一种特殊的文件格式,用于在不同框架之间共享神经网络架构和参数。它基于 Google 的 Protobuf 格式和库。这种格式存在的原因是测试和在不同的环境和设备上运行相同的神经网络模型。
通常,研究人员会使用他们熟悉的编程框架来开发模型,然后在不同环境中运行这个模型,用于生产目的或者他们想要与其他研究人员或开发者共享模型。这种格式得到了所有主流框架的支持,包括 PyTorch、TensorFlow、MXNet 以及其他。然而,这些框架的 C++ API 对这种格式的支持不足,在撰写本文时,它们只为处理 ONNX 格式提供了 Python 接口。尽管如此,微软提供了onnxruntime框架,可以直接使用不同的后端,如 CUDA、CPU 或甚至 NVIDIA TensorRT 来运行推理。
在深入探讨使用框架解决我们具体用例的细节之前,考虑某些限制因素是很重要的,这样我们可以全面地处理问题陈述。有时,由于缺少某些操作符或函数,导出为 ONNX 格式可能会出现问题,这可能会限制可以导出的模型类型。此外,对张量的动态维度和条件操作符的支持可能有限,这限制了使用具有动态计算图和实现复杂算法的模型的能力。这些限制取决于目标硬件。你会发现嵌入式设备有最多的限制,而且其中一些问题只能在推理运行时发现。然而,使用 ONNX 有一个很大的优势——通常,这样的模型可以在各种不同的张量数学加速硬件上运行。
与 ONNX 相比,TorchScript 对模型操作符和结构的限制更少。通常,可以导出具有所有所需分支的动态计算图模型。然而,在您必须推断模型的地方可能会有硬件限制。例如,通常无法使用移动 GPU 或 NPUs 进行 TorchScript 推理。ExecTorch 应该在将来解决这个问题。
为了尽可能多地利用可用硬件,我们可以使用特定供应商的不同推理引擎。通常,可以将 ONNX 格式或使用其他方法的模型转换为内部格式,以在特定的 GPU 或 NPU 上进行推理。此类引擎的例子包括 Intel 硬件的 OpenVINO、NVIDIA 的 TensorRT、基于 ARM 处理器的 ArmNN 以及 Qualcomm NPUs 的 QNN。
现在我们已经了解了如何最好地利用这个框架,接下来让我们了解如何使用 ResNet 神经网络架构进行图像分类。
使用 ResNet 架构进行图像分类
通常,作为开发者,我们不需要了解 ONNX 格式内部是如何工作的,因为我们只对保存模型的文件感兴趣。如前所述,内部上,ONNX 格式是一个 Protobuf 格式的文件。以下代码展示了 ONNX 文件的第一部分,它描述了如何使用 ResNet 神经网络架构进行图像分类:
ir_version: 3
graph {
node {
input: "data"
input: "resnetv24_batchnorm0_gamma"
input: "resnetv24_batchnorm0_beta"
input: "resnetv24_batchnorm0_running_mean"
input: "resnetv24_batchnorm0_running_var"
output: "resnetv24_batchnorm0_fwd"
name: "resnetv24_batchnorm0_fwd"
op_type: "BatchNormalization"
attribute {
name: "epsilon"
f: 1e-05
type: FLOAT
}
attribute {
name: "momentum"
f: 0.9
type: FLOAT
}
attribute {
name: "spatial"
i: 1
type: INT
}
}
node {
input: "resnetv24_batchnorm0_fwd"
input: "resnetv24_conv0_weight"
output: "resnetv24_conv0_fwd"
name: "resnetv24_conv0_fwd"
op_type: "Conv"
attribute {
name: "dilations"
ints: 1
ints: 1
type: INTS
}
attribute {
name: "group"
i: 1
type: INT
}
attribute {
name: "kernel_shape"
ints: 7
ints: 7
type: INTS
}
attribute {
name: "pads"
ints: 3
ints: 3
ints: 3
ints: 3
type: INTS
}
attribute {
name: "strides"
ints: 2
ints: 2
type: INTS
}
}
...
}
通常,ONNX 文件以二进制格式提供,以减少文件大小并提高加载速度。
现在,让我们学习如何使用onnxruntime API 加载和运行 ONNX 模型。ONNX 社区为公开可用的模型库中最流行的神经网络架构提供了预训练模型(github.com/onnx/models)。
有许多现成的模型可以用于解决不同的机器学习任务。例如,我们可以使用ResNet-50模型来进行图像分类任务(github.com/onnx/models/tree/main/validated/vision/classification/resnet/model/resnet50-v1-7.onnx)。
对于这个模型,我们必须下载相应的包含图像类别描述的synset文件,以便能够以人类可读的方式返回分类结果。您可以在github.com/onnx/models/blob/main/validated/vision/classification/synset.txt找到该文件。
为了能够使用onnxruntime C++ API,我们必须使用以下头文件:
#include <onnxruntime_cxx_api.h>
然后,我们必须创建全局共享的onnxruntime环境和模型评估会话,如下所示:
Ort::Env env;
Ort::Session session(env,
"resnet50-v1-7.onnx",
Ort::SessionOptions{nullptr});
session对象将模型的文件名作为其输入参数,并自动加载它。在这里,我们传递了下载的模型的名称。最后一个参数是SessionOptions类型的对象,它可以用来指定特定的设备执行器,例如 CUDA。env对象包含一些共享的运行时状态。最有价值的状态是日志数据和日志级别,这些可以通过构造函数参数进行配置。
一旦我们加载了一个模型,我们可以访问其参数,例如模型输入的数量、模型输出的数量和参数名称。如果您事先不知道这些信息,这些信息将非常有用,因为您需要输入参数名称来运行推理。我们可以按照以下方式发现此类模型信息:
void show_model_info(const Ort::Session& session) {
Ort::AllocatorWithDefaultOptions allocator;
在这里,我们创建了一个函数头并初始化了字符串内存分配器。现在,我们可以打印输入参数信息:
auto num_inputs = session.GetInputCount();
for (size_t i = 0; i < num_inputs; ++i) {
auto input_name = session.GetInputNameAllocated(i,
allocator);
std::cout << "Input name " << i << " : " << input_name
<< std::endl;
Ort::TypeInfo type_info = session.GetInputTypeInfo(i);
auto tensor_info =
type_info.GetTensorTypeAndShapeInfo();
auto tensor_shape = tensor_info.GetShape();
std::cout << "Input shape " << i << " : ";
for (size_t j = 0; j < tensor_shape.size(); ++j)
std::cout << tensor_shape[j] << " ";
std::cout << std::endl;
}
一旦我们发现了输入参数,我们可以按照以下方式打印输出参数信息:
auto num_outputs = session.GetOutputCount();
for (size_t i = 0; i < num_outputs; ++i) {
auto output_name = session.GetOutputNameAllocated(i,
allocator);
std::cout << "Output name " << i << " : " <<
output_name << std::endl;
Ort::TypeInfo type_info = session.GetOutputTypeInfo(i);
auto tensor_info = type_info.GetTensorTypeAndShapeInfo();
auto tensor_shape = tensor_info.GetShape();
std::cout << "Output shape " << i << " : ";
for (size_t j = 0; j < tensor_shape.size(); ++j)
std::cout << tensor_shape[j] << " ";
std::cout << std::endl;
}
}
在这里,我们使用了session对象来发现模型属性。通过使用GetInputCount和GetOutputCount方法,我们得到了相应的输入和输出参数的数量。然后,我们使用GetInputNameAllocated和GetOutputNameAllocated方法通过它们的索引来获取参数名称。请注意,这些方法需要allocator对象。在这里,我们使用了在show_model_info函数顶部初始化的默认对象。
我们可以通过使用相应的参数索引,使用GetInputTypeInfo和GetOutputTypeInfo方法获取额外的参数类型信息。然后,通过使用这些参数类型信息对象,我们可以使用GetTensorTypeAndShapeInfo方法获取张量信息。这里最重要的信息是使用tensor_onfo对象的GetShape方法获取的张量形状。它很重要,因为我们需要为模型输入和输出张量使用特定的形状。形状表示为整数向量。现在,使用show_model_info函数,我们可以获取模型输入和输出参数信息,创建相应的张量,并将数据填充到它们中。
在我们的案例中,输入是一个大小为1 x 3 x 224 x 224的张量,它代表了用于分类的 RGB 图像。onnxruntime会话对象接受Ort::Value类型对象作为输入并将它们作为输出填充。
下面的代码片段展示了如何为模型准备输入张量:
constexpr const int width = 224;
constexpr const int height = 224;
std::array<int64_t, 4> input_shape{1, 3, width, height};
std::vector<float> input_image(3 * width * height);
read_image(argv[3], width, height, input_image);
auto memory_info = Ort::MemoryInfo::CreateCpu(OrtDeviceAllocator,
OrtMemTypeCPU);
Ort::Value input_tensor =
Ort::Value::CreateTensor<float>(memory_ info,
input_image.data(),
input_image.size(),
input_shape.data(),
input_shape.size());
首先,我们定义了代表输入图像宽度和高度的常量。然后,我们创建了input_shape对象,它定义了张量的完整形状,包括其批次维度。有了形状,我们创建了input_image向量来保存确切的图像数据。这个数据容器被read_image函数填充,我们将在稍后对其进行详细探讨。最后,我们使用Ort::Value::CreateTensor函数创建了input_tensor对象,它接受memory_info对象和数据以及形状容器的引用。memory_info对象使用分配输入张量在主机 CPU 设备上的参数创建。输出张量也可以用同样的方式创建:
std::array<int64_t, 2> output_shape{1, 1000};
std::vector<float> result(1000);
Ort::Value output_tensor =
Ort::Value::CreateTensor<float>(memory_ info,
result.data(),
result.size(),
output_shape.data(),
output_shape.size());
注意到onnxruntime API 允许我们创建一个空的输出张量,它将被自动初始化。我们可以这样做:
Ort::Value output_tensor{nullptr};
现在,我们可以使用Run方法进行评估:
const char* input_names[] = {"data"};
const char* output_names[] = {"resnetv17_dense0_fwd"};
Ort::RunOptions run_options;
session.Run(run_options,
input_names,
&input_tensor,
1,
output_names,
&output_tensor,
1);
在这里,我们定义了输入和输出参数的名称和常量,并使用默认初始化创建了run_options对象。run_options对象可以用来配置日志的详细程度,而Run方法可以用来评估模型。请注意,输入和输出张量作为指针传递到数组中,并指定了相应的元素数量。在我们的案例中,我们指定了单个输入和输出元素。
该模型的输出是针对ImageNet数据集的 1,000 个类别的图像得分(概率),该数据集用于训练模型。以下代码展示了如何解码模型的输出:
std::map<size_t, std::string> classes = read_classes("synset.txt");
std::vector<std::pair<float, size_t>> pairs;
for (size_t i = 0; i < result.size(); i++) {
if (result[i] > 0.01f) { // threshold check
pairs.push_back(std::make_pair(
output[i], i + 1)); // 0 –//background
}
}
std::sort(pairs.begin(), pairs.end());
std::reverse(pairs.begin(), pairs.end());
pairs.resize(std::min(5UL, pairs.size()));
for (auto& p : pairs) {
std::cout << "Class " << p.second << " Label "
<< classes.at(p.second) << « Prob « << p.first
<< std::endl;
}
在这里,我们遍历了结果张量数据中的每个元素——即我们之前初始化的result向量对象。在模型评估期间,这个result对象被填充了实际的数据值。然后,我们将得分值和类别索引放入相应的对向量中。这个向量按得分降序排序。然后,我们打印了得分最高的五个类别。
在本节中,我们通过onnxruntime框架的示例了解了如何处理 ONNX 格式。然而,我们仍需要学习如何将输入图像加载到张量对象中,这是我们用于模型输入的部分。
将图像加载到 onnxruntime 张量中
让我们学习如何根据模型的输入要求和内存布局加载图像数据。之前,我们初始化了一个相应大小的input_image向量。模型期望输入图像是归一化的,并且是三个通道的 RGB 图像,其形状为N x 3 x H x W,其中N是批处理大小,H和W至少应为 224 像素宽。归一化假设图像被加载到[0, 1]范围内,然后使用均值[0.485, 0.456, 0.406]和标准差[0.229, 0.224, 0.225]进行归一化。
假设我们有一个以下函数定义来加载图像:
void read_image(const std::string& file_name,
int width,
int height,
std::vector<float>& image_data)
...
}
让我们编写它的实现。为了加载图像,我们将使用OpenCV库:
// load image
auto image = cv::imread(file_name, cv::IMREAD_COLOR);
if (!image.cols || !image.rows) {
return {};
}
if (image.cols != width || image.rows != height) {
// scale image to fit
cv::Size scaled(
std::max(height * image.cols / image.rows, width),
std::max(height, width * image.rows / image.cols));
cv::resize(image, image, scaled);
// crop image to fit
cv::Rect crop((image.cols - width) / 2,
(image.rows - height) / 2, width, height);
image = image(crop);
}
在这里,我们使用cv::imread函数从文件中读取图像。如果图像的尺寸不等于已指定的尺寸,我们需要使用cv::resize函数调整图像大小,然后如果图像的尺寸超过指定的尺寸,还需要裁剪图像。
然后,我们必须将图像转换为浮点类型和 RGB 格式:
image.convertTo(image, CV_32FC3);
cv::cvtColor(image, image, cv::COLOR_BGR2RGB);
格式化完成后,我们可以将图像分成三个单独的通道,分别是红色、绿色和蓝色。我们还应该对颜色值进行归一化。以下代码展示了如何进行这一操作:
std::vector<cv::Mat> channels(3);
cv::split(image, channels);
std::vector<double> mean = {0.485, 0.456, 0.406};
std::vector<double> stddev = {0.229, 0.224, 0.225};
size_t i = 0;
for (auto& c : channels) {
c = ((c / 255) - mean[i]) / stddev[i];
++i;
}
在这里,每个通道都被减去相应的均值,并除以相应的标准差,以进行归一化处理。
然后,我们应该将通道连接起来:
cv::vconcat(channels[0], channels[1], image);
cv::vconcat(image, channels[2], image);
assert(image.isContinuous());
在这种情况下,归一化后的通道被cv::vconcat函数连接成一个连续的图像。
以下代码展示了如何将 OpenCV 图像复制到image_data向量中:
std::vector<int64_t> dims = {1, 3, height, width};
std::copy_n(reinterpret_cast<float*>(image.data),
image.size().area(),
image_data.begin());
在这里,图像数据被复制到一个由指定维度初始化的浮点向量中。使用cv::Mat::data类型成员访问 OpenCV 图像数据。我们将图像数据转换为浮点类型,因为该成员变量是unsigned char *类型。使用标准的std::copy_n函数复制像素数据。这个函数被用来填充input_image向量中的实际图像数据。然后,使用input_image向量数据的引用在CreateTensor函数中初始化Ort::Value对象。
在 ONNX 格式示例中,还使用了一个可以从synset文件中读取类定义的函数。我们将在下一节中查看这个函数。
读取类定义文件
在这个例子中,我们使用了read_classes函数来加载对象映射。在这里,键是一个图像类索引,值是一个文本类描述。这个函数很简单,逐行读取synset文件。在这样的文件中,每一行包含一个数字和一个由空格分隔的类描述字符串。以下代码展示了其定义:
using Classes = std::map<size_t, std::string>;
Classes read_classes(const std::string& file_name) {
Classes classes;
std::ifstream file(file_name);
if (file) {
std::string line;
std::string id;
std::string label;
std::string token;
size_t idx = 1;
while (std::getline(file, line)) {
std::stringstream line_stream(line);
size_t i = 0;
while (std::getline(line_stream, token, ' ')) {
switch (i) {
case 0:
id = token;
break;
case 1:
label = token;
break;
}
token.clear();
++i;
}
classes.insert({idx, label});
++idx;
}
}
return classes;
注意,我们在内部while循环中使用了std::getline函数来对单行字符串进行分词。我们通过指定定义分隔符字符值的第三个参数来实现这一点。
在本节中,我们学习了如何加载synset文件,该文件表示类名与它们 ID 之间的对应关系。我们使用这些信息将作为分类结果得到的类 ID 映射到其字符串表示形式,并将其展示给用户。
摘要
在本章中,我们学习了如何在不同的机器学习框架中保存和加载模型参数。我们了解到,我们在Flashlight、mlpack、Dlib和pytorch库中使用的所有框架都有一个用于模型参数序列化的 API。通常,这些函数很简单,与模型对象和一些输入输出流一起工作。我们还讨论了可以用于保存和加载整体模型架构的序列化 API。在撰写本文时,我们使用的某些框架并不完全支持此类功能。例如,Dlib库可以以 XML 格式导出神经网络,但不能加载它们。PyTorch C++ API 缺少导出功能,但它可以加载和评估从 Python API 导出并使用 TorchScript 功能加载的模型架构。然而,pytorch库确实提供了对库 API 的访问,这允许我们从 C++中加载和评估保存为 ONNX 格式的模型。然而,请注意,您可以从之前导出为 TorchScript 并加载的 PyTorch Python API 中导出模型到 ONNX 格式。
我们还简要地了解了 ONNX 格式,并意识到它是一种在不同的机器学习框架之间共享模型非常流行的格式。它支持几乎所有用于有效地序列化复杂神经网络模型的操作和对象。在撰写本文时,它得到了所有流行的机器学习框架的支持,包括 TensorFlow、PyTorch、MXNet 和其他框架。此外,微软提供了 ONNX 运行时实现,这使得我们可以在不依赖任何其他框架的情况下运行 ONNX 模型的推理。
在本章末尾,我们开发了一个 C++应用程序,可以用来在 ResNet-50 模型上进行推理,该模型是在 ONNX 格式下训练和导出的。这个应用程序是用 onnxruntime C++ API 制作的,这样我们就可以加载模型并在加载的图像上进行分类评估。
在下一章中,我们将讨论如何将使用 C++库开发的机器学习模型部署到移动设备上。
进一步阅读
-
Dlib 文档:
Dlib.net/ -
PyTorch C++ API:
pytorch.org/cppdocs/ -
ONNX 官方页面:
onnx.ai/ -
ONNX 模型库:
github.com/onnx/models -
ONNX ResNet 模型用于图像分类:
github.com/onnx/models/blob/main/validated/vision/classification/resnet -
onnxruntimeC++示例:github.com/microsoft/onnxruntime-inference-examples/tree/main/c_cxx -
Flashlight 文档:
fl.readthedocs.io/en/stable/index.html -
mlpack 文档:
rcppmlpack.github.io/mlpack-doxygen/
第十三章:跟踪和可视化机器学习实验
在机器学习(ML)的世界里,可视化和实验跟踪系统扮演着至关重要的角色。这些工具提供了一种理解复杂数据、跟踪实验并就模型开发做出明智决策的方法。
在机器学习中,可视化数据对于理解模式、关系和趋势至关重要。数据可视化工具允许工程师创建图表、图形和图表,帮助他们探索和分析数据。有了合适的可视化工具,工程师可以快速识别模式和异常,这些可以用来提高模型性能。
实验跟踪系统旨在跟踪多个实验的进度。它们允许工程师比较结果、识别最佳实践并避免重复错误。实验跟踪工具还有助于可重复性,确保实验可以准确且高效地重复。
选择合适的可视化与实验跟踪工具至关重要。有许多开源和商业选项可供选择,每个选项都有其优点和缺点。在选择工具时,重要的是要考虑诸如易用性、与其他工具的集成以及项目具体需求等因素。
在本章中,我们将简要讨论TensorBoard,这是最广泛使用的实验可视化系统之一。我们还将了解它能够提供哪些类型的可视化以及使用 C++时面临的挑战。至于跟踪系统,我们将讨论MLflow 框架,并提供一个使用 C++的实战示例。此示例涵盖了项目设置、定义实验、记录指标以及可视化训练过程,展示了实验跟踪工具在增强机器学习开发过程中的强大功能。
到本章结束时,你应该清楚地了解为什么这些工具对机器学习工程师至关重要,以及它们如何帮助你取得更好的成果。
本章涵盖了以下主题:
-
理解可视化与实验跟踪系统
-
使用 MLflow 的 REST API 进行实验跟踪
技术要求
本章的技术要求如下:
-
Flashlight 库 0.4.0
-
MLflow 2.5.0
-
cpp-httplibv0.16.0 -
nlohmannjsonv3.11.2 -
支持 C++20 的现代 C++编译器
-
CMake 构建系统版本 >= 3.22
本章的代码文件可以在本书的 GitHub 仓库中找到:github.com/PacktPublishing/Hands-On-Machine-Learning-with-C-second-edition/tree/master/Chapter13/flashlight
理解实验的可视化和跟踪系统
ML 实验的视觉化和跟踪系统是 ML 开发过程中的关键组件。这些系统共同使工程师能够构建更稳健和有效的 ML 模型。它们还有助于确保开发过程中的可重复性和透明度,这对于科学严谨性和协作至关重要。
可视化工具提供了数据的图形表示,使工程师能够看到在原始数据中可能难以检测到的模式、趋势和关系。这可以帮助工程师深入了解其模型的行为,识别改进领域,并在模型设计和超参数调整方面做出明智的决定。
实验跟踪系统允许工程师记录和组织实验,包括模型架构、超参数和训练数据。这些系统提供了整个实验过程的概述,使得比较不同的模型并确定哪些模型表现最佳变得更加容易。
接下来,我们将探讨 TensorBoard 的一些关键特性,这是一个强大的可视化工具,并了解 MLflow 这一有效实验跟踪系统的基本组件。
TensorBoard
TensorBoard 是一个用于 ML 模型的可视化工具,它提供了对模型性能和训练进度的洞察。它还提供了一个交互式仪表板,用户可以在其中探索图表、直方图、散点图以及其他与实验相关的可视化。
这里是 TensorBoard 的一些关键特性:
-
损失、准确率、精确率、召回率和F1 分数。 -
直方图图:TensorBoard 还提供了直方图图,以更好地理解模型性能。这些图表可以帮助用户了解层权重和梯度值的分布。
-
图形:TensorBoard 中的图形提供了模型架构的视觉表示。用户可以创建图形来分析输入和输出之间的相关性,或比较不同的模型。
-
图像:TensorBoard 允许您显示图像数据,并将此类可视化连接到训练时间线。这可以帮助用户分析输入数据、中间输出或卷积滤波器结果的可视化。
-
嵌入投影仪:TensorBoard 中的嵌入投影仪允许用户使用如主成分分析(PCA)等技术,在较低维度中探索高维数据。此功能有助于可视化复杂的数据集。
-
比较:在 TensorBoard 中,比较功能使用户能够并排比较多个模型的性能,从而轻松地识别出表现最佳的模型。
不幸的是,TensorBoard 与 C++ ML 框架的集成并不容易。原生 C++支持仅存在于 TensorFlow 框架中。此外,只有一个第三方开源库允许我们使用 TensorBoard,而且它并没有得到积极维护。
TensorBoard 可以与各种基于 Python 的深度学习框架集成,包括 TensorFlow、PyTorch 等。因此,如果你在 Python 中训练模型,将其视为一个可以帮助你了解模型性能、识别潜在问题并就超参数、数据预处理和模型设计做出明智决策的工具是有意义的。
否则,为了可视化训练数据,你可以使用基于 gnuplot 的库,如 CppPlot,正如我们在前几章中所做的那样。参见 第三章 中的 2D 散点图和线图可视化示例。
MLflow
MLflow 是一个为 机器学习操作(MLOps)设计的开源框架,帮助团队管理、跟踪和扩展他们的机器学习项目。它提供了一套用于构建、训练和部署模型以及监控其性能和实验的工具和功能。
MLflow 的主要组件如下:
-
实验跟踪:MLflow 允许用户跟踪他们的实验,包括超参数、代码版本和指标。这有助于理解不同配置对模型性能的影响。
-
代码可重现性:使用 MLflow,用户可以通过跟踪代码版本和依赖关系轻松重现他们的实验。这确保了实验之间的一致性,并使得识别问题更加容易。
-
模型注册:MLflow 提供了一个模型注册组件,用户可以在其中存储、版本控制和管理工作模型。这允许团队内部轻松协作和共享模型。
-
与其他工具的集成:MLflow 与流行的数据科学和机器学习工具集成,如 Jupyter Notebook、TensorFlow、PyTorch 等。这实现了与现有工作流程的无缝集成。对于非 Python 环境,MLflow 提供了 REST API。
-
部署选项:MLflow 提供了各种部署模型的选择,包括 Docker 容器、Kubernetes 和云平台。这种灵活性使用户能够根据他们的需求选择最佳的部署策略。
内部,MLflow 使用数据库来存储关于实验、模型和参数的元数据。默认情况下,它使用 SQLite,但也支持其他数据库,如 PostgreSQL 和 MySQL。这为存储需求提供了可扩展性和灵活性。MLflow 使用唯一的标识符来跟踪平台内的对象和操作。这些标识符用于将实验的不同组件链接在一起,例如运行及其关联的参数。这使得重现实验和理解工作流程不同部分之间的关系变得容易。它还提供了一个 REST API,用于以编程方式访问模型注册、跟踪和模型生命周期管理等功能。它使用 YAML 配置文件来自定义和配置 MLflow 的行为,并使用 Python API 以便于与 MLflow 组件和工作流程集成。
因此,我们可以总结说,可视化和实验跟踪系统是数据科学家和工程师理解、分析和优化其机器学习模型的重要工具。这些系统允许用户跟踪不同模型的性能,比较结果,识别模式,并在模型开发和部署方面做出明智的决定。
为了说明如何将实验跟踪工具集成到机器学习工作流程中,我们将在下一节提供一个具体的示例。
使用 MLflow 的 REST API 进行实验跟踪
让我们考虑一个涉及回归模型的实验示例。我们将使用 MLflow 记录多个实验的性能指标和模型参数。在训练模型时,我们将使用图表可视化结果,以显示准确性和损失曲线随时间的变化。最后,我们将使用跟踪系统比较不同实验的结果,以便我们可以选择性能最佳的模型并进一步优化它。
此示例将演示如何无缝地将实验跟踪集成到 C++ 机器学习工作流程中,提供有价值的见解并提高研究的整体质量。
在您可以使用 MLflow 之前,您需要安装它。您可以使用 pip 安装 MLflow:
pip install mlflow
然后,你需要启动一个服务器,如下所示:
mlflow server --backend-store-uri file:////samples/Chapter13/mlruns
此命令在 http://localhost:5000 启动本地跟踪服务器,并将跟踪数据保存到 /samples/Chapter13/mlruns 目录。如果您需要从远程机器访问 MLflow 服务器,可以使用 --host 和 --port 参数启动命令。
启动跟踪服务器后,我们可以使用 REST API 与之通信。此 API 的访问点托管在 http://localhost:5000/api/2.0/mlflow/。MLflow 使用 JSON 作为其 REST API 的数据表示。
为了实现与跟踪服务器通信的 REST 客户端,我们将使用两个额外的库:
-
cpp-httplib:用于实现 HTTP 通信 -
nlohmann json:用于实现 REST 请求和响应
注意,基本的线性回归模型将使用 Flashlight 库实现。
接下来,我们将学习如何连接所有这些部分。我们将首先介绍实现 REST 客户端。
实现 MLflow 的 REST C++客户端
MLflow 有两个主要概念:实验和运行。它们共同提供了一个结构化的方法来管理和跟踪机器学习工作流程。它们帮助我们组织项目,确保可重复性,并促进团队成员之间的协作。
在 MLflow 中,我们可以组织和跟踪我们的机器学习实验。一个实验可以被视为与特定项目或目标相关的所有运行的一个容器。它允许您跟踪您模型的多个版本,比较它们的性能,并确定最佳版本。
以下是一个实验的关键特性:
-
名称:每个实验都有一个独特的名称来标识它。
-
标签:您可以为实验添加标签,根据不同的标准对其进行分类。
-
工件位置:工件是在实验过程中生成的文件,例如图像、日志等。MLflow 允许您存储和版本化这些工件。
运行代表实验的单次执行或实验内的特定任务。运行用于记录每次执行的详细信息,例如其开始时间、结束时间、参数和指标。
运行的关键特性如下:
-
开始时间:运行开始的时间
-
结束时间:运行完成的时间
-
参数:模型参数,如批量大小、学习率等
-
模型:运行期间执行的模型代码
-
输出:运行产生的结果,包括指标、工件等
在运行过程中,用户可以记录参数、指标和模型表示。
现在我们已经了解了 MLflow 跟踪结构的主要概念,让我们来实现 MLflow REST 客户端:
-
首先,我们将
REST客户端的所有实现细节放在一个单独的MLFlow类中。头文件应如下所示:class MLFlow { public: MLFlow(const std::string& host, size_t port); void set_experiment(const std::string& name); void start_run(); void end_run(); void log_metric(const std::string& name, float value, size_t epoch); void log_param(const std::string& name, const std::string& value); template <typename T> void log_param(const std::string& name, T value) { log_param(name, std::to_string(value)); } private: httplib::Client http_client_; std::string experiment_id_; std::string run_id_; }我们让构造函数接受主机和跟踪服务器的端口以进行通信。然后,我们定义了启动命名实验并在其中运行的方法,以及记录命名指标和参数的方法。之后,我们声明了一个
httplib::Client类的实例,该实例将用于与跟踪服务器进行 HTTP 通信。最后,我们提供了成员变量,即当前实验和运行的 ID。 -
现在,让我们学习如何实现这些方法。构造函数的实现如下:
MLFlow::MLFlow(const std::string& host, size_t port) : http_client_(host, port) { }在这里,我们使用主机和端口值初始化
httplib::Client实例,以初始化与跟踪服务器的连接。 -
以下代码显示了
set_experiment方法的实现:void MLFlow::set_experiment(const std::string& name) { auto res = http_client_.Get( "/api/2.0/mlflow/experiments/" "get-by-name?experiment_name=" + name); if (check_result(res, 404)) { // Create a new experiment nlohmann::json request; request["name"] = name; res = http_client_.Post( "/api/2.0/mlflow/experiments/create", request.dump(), "application/json"); handle_result(res); // Remember experiment ID auto json = nlohmann::json::parse(res->body); experiment_id_ = json["experiment_id"].get<std::string>(); } else if (check_result(res, 200)) { // Remember experiment ID auto json = nlohmann::json::parse(res->body); experiment_id_ = json["experiment"]["experiment_id"] .get<std::string>(); } else { handle_result(res); } }此方法为以下运行初始化实验。此方法有两个部分——一个用于新实验,另一个用于现有实验:
- 首先,我们使用以下代码检查服务器上是否存在具有给定名称的实验:
auto res = http_client_.Get( "/api/2.0/mlflow/experiments/get-byname?experiment_name=" + name)通过比较结果与
404和202代码,我们确定不存在这样的实验或它已经存在。- 由于没有现有实验,我们创建了一个基于 JSON 的请求来创建新实验,如下所示:
nlohmann::json request; request["name"] = name;- 然后,我们将它作为 HTTP 请求的正文传递给服务器,如下所示:
res = http_client_.Post("/api/2.0/mlflow/experiments/create", request.dump(), "application/json"); handle_result(res);- 使用了
nlohmann::json对象的dump方法将 JSON 转换为字符串表示。在得到结果后,我们使用handle_result函数检查错误(此函数将在稍后详细讨论)。在res变量中,我们取了experiment_id值,如下所示:
auto json = nlohmann::json::parse(res->body); experiment_id_ = json["experiment_id"].get<std::string>();在这里,我们使用
nlohmann::json::parse函数解析服务器返回的字符串,并将experiment_id值从 JSON 对象读取到我们的类成员变量中。- 在方法的第二部分,当服务器上存在实验时,我们在 JSON 对象中解析响应并获取
experiment_id值。
有两个名为
handle_result的函数用于检查响应代码,并在需要时报告错误。第一个函数用于检查响应是否包含某些特定代码,其实现如下:bool check_result(const httplib::Result& res, int code) { if (!res) { throw std::runtime_error( "REST error: " + httplib::to_ string(res.error())); } return res->status == code; }在这里,我们使用布尔转换运算符检查
httplib::Result对象是否有有效的响应。如果有通信错误,我们抛出运行时异常。否则,我们返回响应代码的比较结果。- 第二个
handle_result函数用于检查我们是否从服务器获得了成功的答案。以下代码片段显示了其实现方式:
void handle_result(const httplib::Result& res) { if (check_result(res, 200)) return; std::ostringstream oss; oss << "Request error status: " << res->status << " " << httplib::detail::status_message(res->status); oss << ", message: " << std::endl << res->body; throw std::runtime_error(oss.str()); }我们使用之前的
handle_result函数来检查响应是否有效,并获得了200响应代码。如果是真的,我们就没问题。然而,在失败的情况下,我们必须做出详细的报告并抛出运行时异常。这些函数有助于简化响应错误处理代码,并使其更容易调试通信。
-
我们接下来要讨论的两个方法是
start_run和end_run。这些方法标记了一个单独运行的界限,在此范围内我们可以记录指标、参数和工件。在生产代码中,将此类功能封装到某种 RAII 抽象中是有意义的,但我们为了简单起见创建了两个方法。start_run方法可以如下实现:void MLFlow::start_run() { nlohmann::json request; request["experiment_id"] = experiment_id_; request["start_time"] = std::chrono::duration_ cast<std::chrono::milliseconds>( std::chrono::system_ clock::now().time_since_epoch()) .count(); auto res = http_client_.Post("/api/2.0/mlflow/runs/create", request.dump(), "application/json"); handle_result(res); auto json = nlohmann::json::parse(res->body); run_id_ = json["run"]["info"]["run_id"]; }在这里,我们发送了一个基于 JSON 的请求来创建一个运行。此请求填充了当前的
experiment_id值和运行的开始时间。然后,我们向服务器发送请求并获得了响应,我们使用handle_result函数检查了它。如果我们收到答案,我们将其解析到nlohmann::json对象中,并获取run_id值。run_id值存储在对象成员中,将在后续请求中使用。在调用此方法后,跟踪服务器将所有指标和参数写入这个新的运行。 -
要完成运行,我们必须通知服务器。
end_run方法正是如此:void MLFlow::end_run() { nlohmann::json request; request["run_id"] = run_id_; request["status"] = "FINISHED"; request["end_time"] = std::chrono::duration_cast<std::chrono::milliseconds>( std::chrono::system_clock::now() .time_since_epoch()) .count(); auto res = http_client_.Post( "/api/2.0/mlflow/runs/update", request.dump(), "application/json"); handle_result(res); }
在这里,我们发送了一个基于 JSON 的请求,该请求包含run_id值、完成状态和结束时间。然后,我们将此请求发送到跟踪服务器并检查响应。请注意,我们发送了运行的开始和结束时间,此时服务器使用这些时间来计算运行持续时间。因此,您将能够看到运行持续时间如何取决于其参数。
现在我们有了设置实验和定义运行的方法,我们需要方法来记录指标值和运行参数。
记录指标值和运行参数
指标和参数之间的区别在于,指标是在运行期间的一系列值。您可以记录所需数量的单个指标值。通常,这个数字等于纪元或批次,MLflow 将为这些指标显示实时图表。然而,单个参数在每次运行中只能记录一次,通常是一个训练特征,如学习率。
指标通常是一个数值,因此我们使log_metric方法接受一个浮点数和一个值参数。请注意,此方法接受指标名称和纪元索引以生成同一指标的多个不同值。方法实现如下代码片段:
void MLFlow::log_metric(const std::string& name,
float value, size_t epoch) {
nlohmann::json request;
request["run_id"] = run_id_;
request["key"] = name;
request["value"] = value;
request["step"] = epoch;
request["timestamp"] =
std::chrono::duration_
cast<std::chrono::milliseconds>(
std::chrono::system_clock::now()
.time_ since_epoch())
.count();
auto res = http_client_.Post(
"/api/2.0/mlflow/runs/log-metric", request.dump(),
"application/json");
handle_result(res);
}
在这里,我们创建了一个基于 JSON 的请求,该请求包含run_id值,将指标名称作为key字段,指标值,将纪元索引作为step字段,以及时间戳值。然后,我们将请求发送到跟踪服务器并检查响应。
参数值可以具有任意值类型,因此我们使用了 C++模板来编写一个处理不同值类型的方法。这里有两个log_param函数——第一个是一个模板函数,它将任何合适的参数值转换为字符串,而第二个只接受参数名称和字符串值作为参数。模板可以像这样实现:
template <typename T>
void log_param(const std::string& name, T value) {
log_param(name, std::to_string(value));
}
此模板简单地将调用重定向到第二个函数,在将值转换为字符串后使用std::to_string函数。因此,如果值的类型无法转换为字符串,将发生编译错误。
第二个log_param函数的实现可以在以下代码片段中看到:
void MLFlow::log_param(const std::string& name,
const std::string& value) {
nlohmann::json request;
request["run_id"] = run_id_;
request["key"] = name;
request["value"] = value;
auto res = http_client_.Post("/api/2.0/mlflow/runs/log-parameter",
request.dump(), "application/json");
handle_result(res);
}
在这里,我们创建了一个基于 JSON 的请求,该请求包含当前的run_id值,参数名称作为key字段,以及值。然后,我们仅发送请求并检查响应。
MLflow 中的 REST API 比这更丰富;我们在这里只介绍了基本功能。例如,它还可以接受 JSON 格式的模型架构,记录输入数据集,管理实验和模型,等等。
既然我们已经了解了与 MLflow 服务器通信的基本功能,让我们学习如何实现回归任务的实验跟踪会话。
将实验跟踪集成到线性回归训练中
在本节中,我们将使用Flashlight库来实现一个线性回归模型并进行训练。我们的代码从初始化 Flashlight 并连接到 MLflow 服务器开始,如下所示:
fl::init();
MLFlow mlflow("127.0.0.1","5000");
mlflow.set_experiment("Linear regression");
在这里,我们假设跟踪服务器已经在本地主机上启动。之后,我们将实验名称设置为线性回归。现在,我们可以定义必要的参数并开始运行:
int batch_size = 64;
float learning_rate = 0.0001;
float momentum = 0.5;
int epochs = 100;
mlflow.start_run();
配置运行后,我们可以加载用于训练和测试的数据集,定义一个模型,并根据我们之前定义的参数创建一个优化器和损失函数:
// load datasets
auto train_dataset = make_dataset(/*n=*/10000, batch_size);
auto test_dataset = make_dataset(/*n=*/1000, batch_size);
// Define a model
fl::Sequential model;
model.add(fl::View({1, 1, 1, -1}));
model.add(fl::Linear(1, 1));
// define MSE loss
auto loss = fl::MeanSquaredError();
// Define optimizer
auto sgd = fl::SGDOptimizer(model.params(), learning_rate, momentum);
// Metrics meter
fl::AverageValueMeter meter;
注意,我们使用了之前定义的所有参数,除了 epoch 编号。现在,我们准备好定义训练周期,如下所示:
for (int epoch_i = 0; epoch_i < epochs; ++epoch_i) {
meter.reset();
model.train();
for (auto& batch : *train_dataset) {
sgd.zeroGrad();
// Forward propagation
auto predicted = model(fl::input(batch[0]));
// Calculate loss
auto local_batch_size = batch[0].shape().dim(0);
auto target =
fl::reshape(batch[1], {1, 1, 1, local_batch_ size});
auto loss_value = loss(predicted, fl::noGrad(target));
// Backward propagation
loss_value.backward();
// Update parameters
sgd.step();
meter.add(loss_value.scalar<float>());
}
// Train metrics logging
// ...
// Calculate and log test metrics
// ...
}
训练周期的主体看起来很正常,因为我们已经在之前章节中实现了它。请注意,我们有两个嵌套周期——一个用于 epochs,另一个用于 batches。在训练 epoch 的开始,我们清除了用于平均训练损失指标的平均仪表,并将模型置于训练模式。然后,我们清除了梯度,进行了前向传递,计算了损失值,进行了反向传递,使用优化器步骤更新了模型权重,并将损失值添加到平均值仪表对象中。在内部周期之后,训练完成。在此阶段,我们可以将平均训练损失指标值记录到跟踪服务器,如下所示:
// train metrics logging
auto avr_loss_value = meter.value()[0];
mlflow.log_metric("train loss", avr_loss_value, epoch_i);
在这里,我们记录了具有epoch_i索引的 epoch 的训练损失值,并将其命名为train loss。对于每个 epoch,这种记录将为指标添加一个新值,我们将在 MLflow UI 中看到训练损失随 epoch 变化的实时图表。此图表将在以下小节中展示。
在训练周期之后,对于每个第 10 个 epoch,我们计算测试损失指标,如下所示:
// Every 10th epoch calculate test metric
if (epoch_i % 10 == 0) {
fl::AverageValueMeter test_meter;
model.eval();
for (auto& batch : *test_dataset) {
// Forward propagation
auto predicted = model(fl::input(batch[0]));
// Calculate loss
auto local_batch_size = batch[0].shape().dim(0);
auto target =
fl::reshape(batch[1], {1, 1, 1, local_batch_ size});
auto loss_value = loss(predicted, fl::noGrad(target));
// Add loss value to test meter
test_meter.add(loss_value.scalar<float>());
}
// Logging the test metric
// ...
}
一旦我们确认当前 epoch 是第 10 个,我们为测试损失指标定义了一个额外的平均值仪表对象,并实现了评估模式。然后,我们计算每个批次的损失值,并将这些值添加到平均值仪表中。在此阶段,我们可以实现测试数据集的损失计算,并将测试指标记录到跟踪服务器:
// logging the test metric
auto avr_loss_value = test_meter.value()[0];
mlflow.log_metric("test loss", avr_loss_value, epoch_i);
在这里,我们记录了具有epoch_i索引的每个 epoch 的测试损失值,并将其命名为test loss。MLflow 也将为此指标提供图表。我们将能够将此图表与训练指标图表重叠,以检查是否存在诸如过拟合等问题。
现在我们已经完成了训练周期,我们可以结束运行并记录其参数,如下所示:
mlflow.end_run();
mlflow.log_param("epochs", epochs);
mlflow.log_param("batch_size", batch_size);
mlflow.log_param("learning_rate", learning_rate);
mlflow.log_param("momentum", momentum);
在这里,我们使用end_run调用记录了运行参数。这是使用 MLflow API 时的一个要求。请注意,参数值可以有不同的类型,并且它们只记录了一次。
现在,让我们看看 MLflow 将如何显示具有不同训练参数的程序运行。
实验跟踪过程
以下图显示了跟踪服务器启动后的 MLflow UI:

图 13.1 – 无实验和运行的 MLflow UI 概览
如我们所见,没有实验和运行信息。在执行一组参数的程序运行后,UI 将如下所示:

图 13.2 – 单个实验和一个运行下的 MLflow UI 概览
如您所见,线性回归出现在左侧面板中。同时,在右侧表格中为运行记录了新的条目。请注意,peaceful-ray-50的运行名称是自动生成的。在这里,我们可以看到开始时间和运行所需的时间。点击运行名称将打开运行详情页面,其外观如下:

图 13.3 – MLflow UI 中运行详情概览
在这里,我们可以看到开始时间和日期,与此次运行关联的实验 ID,运行 ID 和其持续时间。请注意,这里可能还会提供其他信息,例如用户名、使用的数据集、标签和模型源。这些附加属性也可以通过 REST API 进行配置。
在底部,我们可以看到参数表,其中我们可以找到从我们的代码中记录的参数。还有一个指标表,它显示了我们的训练和测试损失值指标的最终值。
如果我们点击模型指标标签,将显示以下页面:

图 13.4 – MLflow UI 中的模型指标页面
在这里,我们可以看到训练和测试损失指标图。这些图显示了损失值随时间变化的情况。通常,将训练和测试损失图重叠起来查看一些依赖关系是有用的。我们可以通过点击显示在图 13**.3中的页面上的指标名称来实现这一点。点击训练损失后,将显示以下页面:

图 13.5 – 训练损失指标图
在这里,我们可以看到单个指标的图。在这个页面上,我们可以为图表配置一些可视化参数,例如平滑度和步长。然而,在这种情况下,我们感兴趣的是Y 轴字段,它允许我们将额外的指标添加到同一图表中。如果我们添加测试损失指标,我们将看到以下页面:

图 13.6 – 指标图的叠加
现在,我们有两个重叠的训练和测试指标图。在这个可视化中,我们可以看到在前几个时间步中,测试损失大于训练损失,但在第 15 个时间步之后,损失值非常相似。这意味着没有模型过拟合。
在这种情况下,我们查看了 MLflow UI 中单个训练运行的常规区域。对于更复杂的情况,将会有包含工件和模型源的页面,但在这里我们跳过了它们。
接下来,让我们学习如何处理实验中的多个运行。我们再次运行了我们的应用程序,但动量值不同。MLflow 显示我们有两个相同实验的运行,如下所示:

图 13.7 – 具有两个运行的实验
如我们所见,实验中有两个运行。它们之间只有两个细微的差异——名称和持续时间。要比较运行,我们必须点击运行名称前方的两个复选框,如图所示:

图 13.8 – 选择两个运行
在选择两个运行后,比较按钮出现在运行表的最上方。点击此按钮将打开以下页面:


图 13.9 – 运行比较页面概述
此页面显示了并排的两个运行,并显示了各种指标和参数之间的不同可视化。请注意,运行参数的差异也将被突出显示。从左上角的面板中,您可以选择您想要比较的参数和指标。通过这样做,我们可以看到具有较低动量值的新的运行表现较差。这在上面的图中表示,其中线条连接参数和指标,并且有带有值的刻度。这也可以在底部的指标行中看到,在那里您可以比较最终的指标值。
在本节中,我们学习了如何使用 MLflow 用户界面来探索实验运行行为,以及如何查看指标可视化以及如何比较不同的运行。所有跟踪信息都由跟踪服务器保存,可以在服务器重启后使用,因此它对于机器学习从业者来说是一个非常实用的工具。
摘要
可视化和实验跟踪系统是机器学习工程师的必备工具。它们使我们能够了解模型的性能,分析结果,并改进整体流程。
TensorBoard 是一个流行的可视化系统,它提供了关于模型训练的详细信息,包括指标、损失曲线、直方图等。它支持多个框架,包括 TensorFlow,并允许我们轻松比较不同的运行。
MLflow 是一个开源框架,为模型生命周期管理提供端到端解决方案。它包括实验跟踪、模型注册、工件管理以及部署等功能。MLflow 帮助团队协作、重现实验并确保可重复性。
TensorBoard 和 MLflow 都是功能强大的工具,可以根据您的需求一起使用或单独使用。
在理解了 TensorBoard 和 MLflow 之后,我们实现了一个带有实验跟踪的线性回归训练示例。通过这样做,我们学习了如何实现 MLflow 服务器的 REST API 客户端,以及如何使用它来记录实验的指标和参数。然后,我们探索了 MLflow 用户界面,在那里我们学习了如何查看实验及其运行详情,以及如何查看指标图,并学习了如何比较不同的运行。
在下一章中,我们将学习如何在 Android 移动平台上使用 C++为计算机视觉应用机器学习模型。
进一步阅读
-
MLflow REST API:
mlflow.org/docs/latest/rest-api.html -
MLflow 文档:
mlflow.org/docs/latest/index.html -
TensorBoard 文档:
www.tensorflow.org/tensorboard/get_started -
如何使用 PyTorch 与 TensorBoard:
pytorch.org/tutorials/recipes/recipes/tensorboard_with_pytorch.html
第十四章:在移动平台上部署模型
在本章中,我们将讨论在运行 Android 操作系统的移动设备上部署机器学习(ML)模型。机器学习可以用于改善移动设备上的用户体验,尤其是我们可以创建更多自主功能,使我们的设备能够学习和适应用户行为。例如,机器学习可用于图像识别,使设备能够识别照片和视频中的对象。此功能对于增强现实或照片编辑工具等应用程序可能很有用。此外,由机器学习驱动的语音识别可以使语音助手更好地理解和响应自然语言命令。自主功能开发的另一个重要好处是它们可以在没有互联网连接的情况下工作。这在连接有限或不稳定的情况下尤其有用,例如在偏远地区旅行或自然灾害期间。
在移动设备上使用 C++可以使我们编写更快速、更紧凑的程序。由于现代编译器可以针对目标 CPU 架构优化程序,我们可以利用尽可能多的计算资源。C++不使用额外的垃圾回收器进行内存管理,这可能会对程序性能产生重大影响。程序大小可以减小,因为 C++不使用额外的虚拟机(VM)并且直接编译成机器码。此外,使用 C++可以通过更精确的资源使用和相应调整来帮助优化电池寿命。这些事实使 C++成为资源有限的移动设备的正确选择,并且可以用于解决重计算任务。
到本章结束时,你将学习如何使用 PyTorch 和 YOLOv5 在 Android 移动平台上通过相机实现实时对象检测。但本章并非 Android 开发的全面介绍;相反,它可以作为在 Android 平台上进行机器学习和计算机视觉实验的起点。它提供了一个完整的、所需的最小项目示例,你可以根据你的任务对其进行扩展。
本章涵盖了以下主题:
-
创建 Android C++开发所需的最小项目
-
实现对象检测所需的最小 Kotlin 功能
-
在项目的 C++部分初始化图像捕获会话
-
使用 OpenCV 处理原生相机图像并绘制结果
-
使用 PyTorch 脚本在 Android 平台上启动 YOLOv5 模型
技术要求
以下为本章节的技术要求:
-
Android Studio,Android 软件开发工具包(SDK),以及 Android 本地开发 工具包(NDK)
-
PyTorch 库
-
支持 C++20 的现代 C++编译器
-
CMake 构建系统版本 >= 3.22
本章的代码文件可以在以下 GitHub 仓库中找到:github.com/PacktPublishing/Hands-On-Machine-Learning-with-C-Second-edition/tree/main/Chapter14。
在 Android 上开发目标检测
关于如何将机器学习模型部署到 Android 移动设备上,有许多方法。我们可以使用 PyTorch、ExecuTorch、TensorFlow Lite、NCNN、ONNX Runtime 或其他工具。在本章中,我们将使用 PyTorch 框架,因为我们已经在之前的章节中讨论过它,并且因为它允许我们使用几乎任何 PyTorch 模型,同时功能限制最小。不幸的是,我们只能使用目标设备的 CPU 进行推理。其他框架,如 ExecuTorch、TensorFlow Lite、NCNN 和 ONNX Runtime,允许您使用其他推理后端,例如板载 GPU 或神经处理单元(NPU)。然而,这个选项也带来一个显著的限制,即缺少某些操作符或函数,这可能会限制可以在移动设备上部署的模型类型。动态形状支持通常有限,这使得处理不同维度的数据变得困难。
另一个挑战是受限的控制流,这限制了使用具有动态计算图和实现高级算法的模型的能力。这些限制可能会使得使用前面描述的框架在移动平台上部署机器学习模型变得更加困难。因此,当在移动设备上部署机器学习模型时,模型的功能和所需性能之间存在权衡。为了平衡功能和性能,开发者必须仔细评估他们的需求,并选择满足他们特定需求的框架。
PyTorch 框架的移动版本
Maven 仓库中有一个名为org.pytorch:pytorch_android_lite的 PyTorch 移动设备二进制分发版。然而,这个分发版已经过时。因此,要使用最新版本,我们需要从源代码构建它。我们可以像编译其常规版本一样做,但需要额外的 CMake 参数来启用移动模式。您还必须安装 Android NDK,它包括适当的 C/C++编译器和构建应用程序所需的 Android 原生库。
安装 Android 开发工具最简单的方法是下载 Android Studio IDE,并使用该 IDE 中的 SDK Manager 工具。您可以在cmdline-tools包下找到 SDK Manager。然而,您需要在系统中安装 Java;对于 Ubuntu,您可以按照以下方式安装 Java:
sudo apt install default-jre
以下命令行脚本展示了如何安装 CLI 开发所需的所有包:
# make the folder where to install components
mkdir android
cd android
# download command line tools
wget https://dl.google.com/android/repository/commandlinetools-linux-9477386_latest.zip
# unzip them and move to the correct folder
unzip commandlinetools-linux-9477386_latest.zip
mv cmdline-tools latest
mkdir cmdline-tools
mv latest cmdline-tools
# install SDK, NDK and build tools for Android using sdkmanager utility
yes | ./cmdline-tools/latest/bin/sdkmanager --licenses
yes | ./cmdline-tools/latest/bin/sdkmanager "platform-tools"
yes | ./cmdline-tools/latest/bin/sdkmanager "platforms;android-35"
yes | ./cmdline-tools/latest/bin/sdkmanager "build-tools;35.0.0"
yes | ./cmdline-tools/latest/bin/sdkmanager "system-images;android-35;google_apis;arm64-v8a"
yes | ./cmdline-tools/latest/bin/sdkmanager --install "ndk;26.1.10909125"
在这里,我们使用了sdkmanager管理工具来安装所有所需的组件及其适当的版本。使用此脚本,NDK 的路径将如下所示:
android/ndk/26.1.10909125/
安装了构建工具和 NDK 后,我们可以继续编译 PyTorch 移动版本。
以下代码片段展示了如何使用命令行环境检出 PyTorch 并构建它:
cd /home/[USER]
git clone https://github.com/pytorch/pytorch.git
cd pytorch/
git checkout v2.3.1
git submodule update --init --recursive
export ANDROID_NDK=[Path to the installed NDK]
export ANDROID_ABI='arm64-v8a'
export ANDROID_STL_SHARED=1
$START_DIR/android/pytorch/scripts/build_android.sh \
-DBUILD_CAFFE2_MOBILE=OFF \
-DBUILD_SHARED_LIBS=ON \
-DUSE_VULKAN=OFF \
-DCMAKE_PREFIX_PATH=$(python -c 'from distutils.sysconfig import get_python_lib; print(get_python_lib())') \
-DPYTHON_EXECUTABLE=$(python -c 'import sys; print(sys.executable)') \
在这里,我们假设 /home/[USER] 是用户的家目录。构建 PyTorch 移动版本时的主要要求是声明 ANDROID_NDK 环境变量,它应该指向 Android NDK 安装目录。可以使用 ANDROID_ABI 环境变量来指定 arm64-v8a 架构。
我们使用了 PyTorch 源代码分发中的 build_android.sh 脚本来构建移动 PyTorch 二进制文件。这个脚本内部使用 CMake 命令,这就是为什么它需要将 CMake 参数定义作为参数。请注意,我们传递了 BUILD_CAFFE2_MOBILE=OFF 参数来禁用构建 Caffe2 的移动版本,因为当前版本中该库已被弃用,难以使用。我们使用的第二个重要参数是 BUILD_SHARED_LIBS=ON,这使我们能够构建共享库。我们还通过使用 DUSE_VULKAN=OFF 禁用了 Vulkan API 支持,因为它仍然是实验性的,并且存在一些编译问题。其他配置的参数是用于中间构建代码生成的 Python 安装路径。
现在我们已经有了移动 PyTorch 库,即 libc10.so 和 libtorch.so,我们可以开始开发应用程序了。我们将基于 YOLOv5 神经网络架构构建一个目标检测应用程序。
YOLOv5 是一种基于 You Only Look Once (YOLO) 架构的目标检测模型。它是一个最先进的深度学习模型,能够以高精度和速度在图像和视频中检测物体。该模型相对较小且轻量,便于在资源受限的设备上部署。此外,它足够快速,这对于需要分析实时视频流的实时应用来说非常重要。它是一个开源软件,这意味着开发者可以自由访问代码并根据他们的需求进行修改。
使用 TorchScript 进行模型快照
在本节中,我们将讨论如何获取 YOLOv5 模型的 TorchScript 文件,以便我们可以在我们的移动应用程序中使用它。在前面的章节中,我们讨论了如何保存和加载模型参数,以及如何使用 ONNX 格式在框架之间共享模型。当我们使用 PyTorch 框架时,我们还可以使用另一种方法在 Python API 和 C++ API 之间共享模型,称为 TorchScript。
此方法使用实时模型跟踪来获取一种特殊类型的模型定义,该定义可以由 PyTorch 引擎执行,不受 API 限制。在 PyTorch 中,只有 Python API 可以创建此类定义,但我们可以使用 C++ API 来加载模型并执行它。此外,PyTorch 框架的移动版本不允许我们使用功能齐全的 C++ API 来编程神经网络。然而,正如之前所说,TorchScript 允许我们导出和运行具有复杂控制流和动态形状的模型,这在目前对于 ONNX 和其他在其他移动框架中使用的格式来说是不完全可能的。
目前,YOLOv5 PyTorch 模型可以直接导出为 TorchScript,以便在移动 CPU 上进行推理。例如,有针对 TensorFlow Lite 和 NCNN 框架优化的 YOLOv5 模型,但我们将不讨论这些情况,因为我们主要使用 PyTorch。我必须说,使用 NCNN 将允许你通过 Vulkan API 使用移动 GPU,而使用 TensorFlow Lite 或 ONNX Runtime for Android 将允许你使用某些设备的移动 NPU。然而,你需要通过减少一些功能或使用 TensorFlow 进行开发来将模型转换为另一种格式。
因此,在这个例子中,我们将使用 TorchScript 模型来进行目标检测。为了获取 YOLOv5 模型,我们必须执行以下步骤:
-
从 GitHub 克隆模型仓库并安装依赖项;在终端中运行以下命令:
git clone https://github.com/ultralytics/yolov5 cd yolov5 jit script of the model optimized for mobile:python export.py --weights yolov5s.torchscript --include torchscript --optimize
第二步的脚本会自动跟踪模型并为我们保存 TorchScript 文件。在我们完成这些步骤后,将会有yolo5s.torchscript文件,我们将能够加载并在 C++中使用它。
现在,我们已经具备了所有先决条件,可以继续创建我们的 Android Studio 项目。
Android Studio 项目
在本节中,我们将使用 Android Studio IDE 来创建我们的移动应用程序。我们可以使用默认的objectdetection并选择Kotlin作为编程语言,然后 Android Studio 将创建一个特定的项目结构;以下示例显示了其最有价值的部分:
app
|--src
| `--main
| |--cpp
| | |—CmakeLists.txt
| | `—native-lib.cpp
| |--java
| | `--com
| | `--example
| | `--objectdetection
| | `--MainActivity.kt
| |--res
| | `--layout
| | `--activity_main.xml
| |--values
| |--colors.xml
| |--strings.xml
| |--styles.xml
| `—…
|--build.gradle
`--...
cpp文件夹包含整个项目的 C++部分。在这个项目中,Android Studio IDE 将 C++部分创建为一个配置了 CMake 构建生成系统的本地共享库项目。java文件夹包含项目的 Kotlin 部分。在我们的案例中,它是一个定义主活动的单个文件——该活动用于将 UI 元素和事件处理器连接起来。res文件夹包含项目资源,例如 UI 元素和字符串定义。
我们还需要在main文件夹下创建一个名为jniLibs的文件夹,其结构如下:
app
|--src
| |--main
| |--…
| |--JniLibs
| `--arm64-v8a
| |--libc10.so
| |--libtorch_cpu.so
| |--libtorch_global_deps.so
| `—libtorch.so
`...
Android Studio 要求我们将额外的本地库放置在这样的文件夹中,以便正确地将它们打包到最终应用程序中。它还允许arm64-v8a文件夹,因为它们只编译了这种 CPU 架构。如果您有其他架构的库,您必须创建具有相应名称的文件夹。
此外,在上一小节中,我们学习了如何获取 YOLOv5 torch 脚本模型。模型文件及其对应文件,以及类 ID,应放置在assets文件夹中。此文件夹应创建在JniLibs文件夹旁边,处于同一文件夹级别,如下所示:
app
|--src
| `--main
| |--...
| |--cpp
| |--JniLibs
| |--assests
| | |--yolov5.torchscript
| | `--classes.txt
| `—...
`...
将字符串类名称映射到模型返回的数值 ID 的文件可以从github.com/ultralytics/yolov5/blob/master/data/coco.yaml下载。
在我们的示例中,我们简单地将 YAML 文件转换为文本文件,以简化其解析。
该 IDE 使用 Gradle 构建系统进行项目配置,因此有两个名为build.gradle.kts的文件,一个用于应用程序模块,另一个用于项目属性。查看我们示例中的应用程序模块的build.gradle文件。有两个变量定义了 PyTorch 源代码文件夹和 OpenCV Android SDK 文件夹的路径。如果您更改这些路径,则需要更新它们的值。预构建的 OpenCV Android SDK 可以从官方 GitHub 仓库(github.com/opencv/opencv/releases)下载,并直接解压。
项目的 Kotlin 部分
在这个项目中,我们将使用本地的 C++部分来绘制捕获的图片,并带有检测到的对象的边界框和类标签。因此,Kotlin 部分将没有 UI 代码和声明。然而,Kotlin 部分将用于请求和检查所需的相机访问权限。如果权限被授予,它还将启动相机捕获会话。所有 Kotlin 代码都将位于MainActivity.kt文件中。
保留相机方向
在我们的项目中,我们跳过了设备旋转处理的实现,以使代码更简单,并仅展示与目标检测模型一起工作的最有趣的部分。因此,为了使我们的代码稳定,我们必须禁用横屏模式,这可以在AndroidManifest.xml文件中完成,如下所示:
…
<activity
…
android:screenOrientation="portrait">
…
我们将屏幕方向指令添加到活动实体中。这不是一个好的解决方案,因为有些设备只能在横屏模式下工作,我们的应用程序将无法与它们一起工作。在实际的生产型应用程序中,您应该处理不同的方向模式;例如,对于大多数智能手机,这种脏解决方案应该可以工作。
处理相机权限请求
在 Android NDK 中没有 C++ API 来请求权限。我们只能从 Java/Kotlin 侧或通过 JNI 从 C++请求所需的权限。编写 Kotlin 代码请求相机权限比编写 JNI 调用要简单。
第一步是修改MainActivity类的声明,以便能够处理权限请求结果。如下所示完成:
class MainActivity
: NativeActivity(),
ActivityCompat.OnRequestPermissionsResultCallback {
…
}
在这里,我们继承了MainActivity类自OnRequestPermissionsResultCallback接口。这给了我们重写onRequestPermissionsResult方法的可能性,在那里我们将能够检查结果。然而,要获取结果,我们必须首先发出请求,如下所示:
override fun onResume() {
super.onResume() val cameraPermission =
android.Manifest.permission
.CAMERA if (checkSelfPermission(
cameraPermission) !=
PackageManager.PERMISSION_GRANTED) {
requestPermissions(arrayOf(cameraPermission),
CAM_PERMISSION_CODE)
}
else {
val camId =
getCameraBackCameraId() if (camId.isEmpty()){
Toast
.makeText(
this,
"Camera probably won't work on this
device !",
Toast.LENGTH_LONG)
.show() finish()} initObjectDetection(camId)
}
}
我们重写了Activity类的onResume方法。此方法在每次我们的应用程序开始工作或从后台恢复时都会被调用。我们使用所需的相机权限常量值初始化了cameraPermission变量。然后,我们使用checkSelfPermission方法检查我们是否已经授予了此权限。如果没有相机权限,我们使用requestPermissions方法请求它。
注意,我们在回调方法中使用了CAM_PERMISSION_CODE代码来识别我们的请求。如果我们被授予访问相机的权限,我们尝试获取背面相机的 ID 并为该相机初始化对象检测管道。如果我们无法访问相机,我们使用finish方法和相应的消息结束 Android 活动。在onRequestPermissionsResult方法中,我们检查是否已授予所需的权限,如下所示:
override fun onRequestPermissionsResult(requestCode
: Int, permissions
: Array<out String>,
grantResults
: IntArray) {
super.onRequestPermissionsResult(requestCode,
permissions,
grantResults)
if (requestCode == CAM_PERMISSION_CODE &&
grantResults[0] != PackageManager.PERMISSION_GRANTED)
{
Toast.makeText(this,
"This app requires camera permission",
Toast.LENGTH_SHORT).show()
finish()
}
}
首先,我们调用了父方法以保留标准应用程序行为。然后,我们检查了权限识别代码CAM_PERMISSION_CODE以及权限是否被授予。在失败情况下,我们只显示错误消息并结束 Android 活动。
如我们之前所说,在成功的情况下,我们寻找背面相机的 ID,如下所示:
private fun getCameraBackCameraId(): String {
val camManager = getSystemService(
Context.CAMERA_SERVICE)as CameraManager
for (camId in camManager.cameraIdList) {
val characteristics =
camManager.getCameraCharacteristics(camId)
val hwLevel = characteristics.get(
CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL)
val facing = characteristics.get(
CameraCharacteristics.LENS_FACING)
if (hwLevel != INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY &&
facing == LENS_FACING_BACK) {
return camId
}
}
return ""
}
我们获取了CameraManager对象的实例,并使用此对象遍历设备上的每个相机。对于每个相机对象,我们询问其特征、支持的硬件级别以及该相机面向的方向。如果一个相机是常规的遗留设备并且面向背面,我们返回其 ID。如果我们没有找到合适的设备,我们返回一个空字符串。
在授予相机访问权限和相机 ID 之后,我们调用了initObjectDetection函数来开始图像捕获和对象检测。这个函数和stopObjectDetection函数是通过 JNI 从 C++部分提供给 Kotlin 部分的函数。stopObjectDetection函数用于停止相机捕获会话,如下所示:
override fun onPause() {
super.onPause()
stopObjectDetection()
}
在重写的onPause活动方法中,我们只是停止了相机捕获会话。此方法在每次 Android 应用程序关闭或进入后台时都会被调用。
原生库加载
有两个方法,initObjectDetection和stopObjectDetection,它们是 JNI 调用原生库中用 C++实现的函数。为了将原生库与 Java 或 Kotlin 代码连接起来,我们使用 JNI。这是一个标准的机制,用于从 Kotlin 或 Java 调用 C/C++函数。
首先,我们必须使用System.LoadLibrary调用加载原生库,并将其放置在我们活动的伴随对象中。然后,我们必须通过将它们声明为external来定义原生库中实现的方法。以下代码片段展示了如何在 Kotlin 中定义这些方法:
private external fun initObjectDetection(camId: String)
private external fun stopObjectDetection()
companion object {
init {
System.loadLibrary("object-detection")
}
}
这样的声明允许 Kotlin 找到相应的原生库二进制文件,加载它,并访问函数。JNI 通过提供一组 API 来实现,允许 Java 代码调用原生代码,反之亦然。JNI API 由一组可以从 Java 或原生代码调用的函数组成。这些函数允许你执行诸如从原生代码创建和访问 Java 对象、从原生代码调用 Java 方法以及从 Java 访问原生数据结构等任务。
内部,JNI 通过将 Java 对象和类型映射到它们的对应原生版本来工作。这种映射是通过JNIEnv接口完成的,它提供了访问JNIEnv的方法,用于查找相应的原生方法并传递必要的参数。同样,当原生方法返回一个值时,使用JNIEnv将原生值转换为 Java 对象。JVM 管理 Java 和原生对象内存。然而,原生代码必须显式地分配和释放自己的内存。JNI 提供了分配和释放内存的函数,以及用于在 Java 和原生内存之间复制数据的函数。JNI 代码必须是线程安全的。这意味着任何由 JNI 访问的数据都必须正确同步,以避免竞争条件。使用 JNI 可能会有性能影响。原生代码通常比 Java 代码快,但通过 JNI 调用原生代码会有开销。
在下一节中,我们将讨论项目的 C++部分。
项目的原生 C++部分
本示例项目的主要功能是在原生 C++部分实现的。它旨在使用 OpenCV 库处理摄像头图像,并使用 PyTorch 框架进行目标检测模型推理。这种做法允许你在需要时将此解决方案移植到其他平台,并允许你使用标准桌面工具,如 OpenCV 和 PyTorch,来开发和调试将在移动平台上使用的算法。
本项目中主要有两个 C++类。Detector类是应用程序外观,它实现了与 Android 活动图像捕获管道的连接,并将对象检测委托给第二个类YOLO。YOLO类实现了对象检测模型的加载及其推理。
下面的子节将描述这些类的实现细节。
使用 JNI 初始化对象检测
我们通过讨论 JNI 函数声明结束了 Kotlin 部分的讨论。initObjectDetection 和 stopObjectDetection 的相应 C++ 实现在 native-lib.cpp 文件中。此文件由 Android Studio IDE 自动创建,用于原生活动项目。以下代码片段展示了 initObjectDetection 函数的定义:
#include <jni.h>
...
std::shared_ptr<ObjectDetector> object_detector_;
extern "C" JNIEXPORT void JNICALL
Java_com_example_objectdetection_MainActivity_initObjectDetection(
JNIEnv* env,
jobject /* this */,
jstring camId) {
auto camera_id = env->GetStringUTFChars(camId, nullptr);
LOGI("Camera ID: %s", camera_id);
if (object_detector_) {
object_detector_->allow_camera_session(camera_id);
object_detector_->configure_resources();
} else
LOGE("Object Detector object is missed!");
}
我们遵循 JNI 规则,使函数声明正确且在 Java/Kotlin 部分可见。函数名称包括完整的 Java 包名,包括命名空间,我们前两个必需的参数是 JNIEnv* 和 jobject 类型。第三个参数是字符串,对应于相机 ID;这是在函数的 Kotlin 声明中存在的参数。
在函数实现中,我们检查 ObjectDetector 对象是否已经实例化,如果是这样,我们使用相机 ID 调用 allow_camera_session 方法,然后调用 configure_resources 方法。这些调用使 ObjectDetector 对象记住要使用哪个相机以及初始化,配置输出窗口,并初始化图像捕获管道。
在 Kotlin 部分我们使用的第二个函数是 stopObjectDetection,其实现如下:
extern "C" JNIEXPORT void JNICALL
Java_com_example_objectdetection_MainActivity_stopObjectDetection(
JNIEnv*,
jobject /* this */) {
if (object_detector_) {
object_detector_->release_resources();
} else
LOGE("Object Detector object is missed!");
}
在这里,我们只是释放了用于图像捕获管道的资源,因为当应用程序挂起时,对相机设备的访问被阻止。当应用程序再次激活时,initObjectDetection 函数将被调用,图像捕获管道将重新初始化。
您可以看到我们使用了 LOGI 和 LOGE 函数,其定义如下:
#include <android/log.h>
#define LOG_TAG "OBJECT-DETECTION"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO,
LOG_TAG, __VA_ARGS__)
#define LOGW(...) __android_log_print(ANDROID_LOG_WARN,
LOG_TAG, __VA_ARGS__)
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR,
LOG_TAG, __VA_ARGS__)
#define ASSERT(cond, fmt, ...) \
if (!(cond))
{ \
__android_log_assert(#cond, LOG_TAG, fmt, ##__VA_ARGS__); \
}
我们定义了这些函数,以便更容易地将消息记录到 Android 的 logcat 子系统中。这一系列函数使用相同的标签进行记录,并且比原始的 __android_log_xxx 函数具有更少的参数。此外,日志级别被编码在函数名称中。
主应用程序循环
此项目将使用 Native App Glue 库。这是一个帮助 Android 开发者创建原生应用的库。它提供了一个抽象层,在 Java 代码和原生代码之间,使得使用这两种语言开发应用程序变得更加容易。
使用这个库,我们可以拥有一个带有循环的标准 main 函数,该循环持续运行,更新 UI,处理用户输入,并响应系统事件。以下代码片段展示了我们如何在 native-lib.cpp 文件中实现 main 函数:
extern "C" void android_main(struct android_app* app) {
LOGI("Native entry point");
object_detector_ = std::make_shared<ObjectDetector>(app);
app->onAppCmd = ProcessAndroidCmd;
while (!app->destroyRequested) {
struct android_poll_source* source = nullptr;
auto result = ALooper_pollOnce(0, nullptr, nullptr,
(void**)&source);
ASSERT(result != ALOOPER_POLL_ERROR,
"ALooper_pollOnce returned an error");
if (source != nullptr) {
source->process(app, source);
}
if (object_detector_)
object_detector_->draw_frame();
}
object_detector_.reset();
}
android_main 函数接收 android_app 类型的实例,而不是常规的 argc 和 argv 参数。android_app 是一个 C++ 类,它提供了对 Android 框架的访问权限,并允许你与系统服务进行交互。此外,你可以使用它来访问设备硬件,例如传感器和摄像头。
android_main 主函数是我们本地模块的起点。因此,我们在这里初始化了全局的 object_detector_ 对象,使其对 initObjectDetection 和 stopObjectDetection 函数可用。对于初始化,ObjectDetector 实例接收 android_app 对象的指针。
然后,我们将命令处理函数附加到 Android 应用程序对象上。最后,我们启动了主循环,它一直工作到应用程序被销毁(关闭)。在这个循环中,我们使用 ALooper_pollOnce Android NDK 函数获取到命令(事件)轮询对象的指针。
我们调用了该对象的 process 方法,通过 app 对象将当前命令派发到我们的 ProcessAndroidCmd 函数。在循环结束时,我们使用我们的目标检测器对象抓取当前摄像头图像,并在 draw_frame 方法中对其进行处理。
ProcessAndroidCmd 函数的实现如下:
static void ProcessAndroidCmd(struct android_app* /*app*/,
int32_t cmd) {
if (object_detector_) {
switch (cmd) {
case APP_CMD_INIT_WINDOW:
object_detector_->configure_resources();
break;
case APP_CMD_TERM_WINDOW:
object_detector_->release_resources();
break;
}
}
在这里,我们只处理了两个与应用程序窗口初始化和终止相对应的命令。我们使用它们在目标检测器中初始化和清除图像捕获管道。当窗口创建时,我们根据捕获分辨率配置其尺寸。窗口终止命令允许我们清除捕获资源,以防止访问已阻塞的摄像头设备。
这就是关于 native-lib.cpp 文件的所有信息。接下来的小节将探讨 ObjectDetector 类的实现细节。
ObjectDetector 类概述
这是应用程序整个目标检测管道的主界面。以下列表显示了它所实现的功能项:
-
摄像头设备访问管理
-
应用程序窗口尺寸配置
-
图像捕获管道管理
-
将摄像头图像转换为 OpenCV 矩阵对象
-
将对象检测结果绘制到应用程序窗口中
-
将对象检测委托给 YOLO 推理对象
在我们开始查看这些项目细节之前,让我们看看构造函数、析构函数和一些辅助方法的实现。构造函数的实现如下:
ObjectDetector::ObjectDetector(android_app *app) : android_app_(app) {
yolo_ = std::make_shared<YOLO>(app->activity->assetManager);
}
我们只是保存了 android_app 对象的指针,并创建了 YOLO 类推理对象。此外,我们使用 android_app 对象获取到 AssetManager 对象的指针,该对象用于加载打包到 Android 应用程序包(APK)中的文件。析构函数的实现如下:
ObjectDetector::~ObjectDetector() {
release_resources();
LOGI("Object Detector was destroyed!");
}
void ObjectDetector::release_resources() {
delete_camera();
delete_image_reader();
delete_session();
}
我们调用了release_resources方法,这是关闭已打开的相机设备和清除捕获管道对象的地方。以下代码片段显示了通过initObjectDetection函数从 Kotlin 部分使用的方法:
void ObjectDetector::allow_camera_session(std::string_view camera_id) {
camera_id_ = camera_id;
}
在allow_camera_session中,我们保存了相机 ID 字符串;具有此 ID 的设备将在configure_resources方法中打开。正如我们所知,只有当所需的权限被授予且 Android 设备上有后置摄像头时,相机 ID 才会传递给ObjectDetector。因此,我们定义了is_session_allowed如下:
bool ObjectDetector::is_session_allowed() const {
return !camera_id_.empty();
}
这里,我们只是检查了相机 ID 是否不为空。
以下小节将详细展示主要功能项。
相机设备和应用程序窗口配置
在ObjectDetection类中有一个名为create_camera的方法,它实现了创建相机管理器对象和打开相机设备,如下所示:
void ObjectDetector::create_camera() {
camera_mgr_ = ACameraManager_create();
ASSERT(camera_mgr_, "Failed to create Camera Manager");
ACameraManager_openCamera(camera_mgr_, camera_id_.c_str(),
&camera_device_callbacks,
&camera_device_);
ASSERT(camera_device_, "Failed to open camera");
}
camera_mgr_是ObjectDetector成员变量,初始化后用于打开相机设备。打开的相机设备的指针将存储在camera_device_成员变量中。注意,我们使用相机 ID 字符串打开特定设备。camera_device_callbacks变量定义如下:
namespace {
void onDisconnected(
[[maybe_unused]] void* context,
[[maybe_unused]] ACameraDevice* device) {
LOGI("Camera onDisconnected");
}
void onError([[maybe_unused]] void* context,
[[maybe_unused]] ACameraDevice* device,
int error) {
LOGE("Camera error %d", error);
}
ACameraDevice_stateCallbacks camera_device_callbacks = {
.context = nullptr,
.onDisconnected = onDisconnected,
.onError = onError,
};
} // namespace
我们定义了带有指向函数引用的ACameraDevice_stateCallbacks结构对象,这些函数简单地报告相机是否已打开或关闭。在其他应用程序中,这些处理程序可以执行一些更有用的操作,但由于 API 要求,我们无法用空值初始化它们。
create_camera方法在ObjectDetection类的configure_resources方法中被调用。每次应用程序激活时都会调用此方法,其实现如下:
void ObjectDetector::configure_resources() {
if (!is_session_allowed() || !android_app_ ||
!android_app_->window) {
LOGE("Can't configure output window!");
return;
}
if (!camera_device_)
create_camera();
// configure output window size and format
...
if (!image_reader_ && !session_output_) {
create_image_reader();
create_session();
}
}
在开始时,我们检查了所有必需的资源:相机 ID、android_app对象,以及该对象是否指向应用程序窗口。然后,我们创建了一个相机管理器对象并打开了一个相机设备。使用相机管理器,我们获取了相机传感器的方向以配置应用程序窗口的适当宽度和高度。此外,使用图像捕获宽度和高度值,我们配置了窗口尺寸,如下所示:
ACameraMetadata *metadata_obj{nullptr};
ACameraManager_getCameraCharacteristics(camera_mgr_,
camera_id_.c_str(),
&metadata_obj);
ACameraMetadata_const_entry entry;
ACameraMetadata_getConstEntry(metadata_obj,
ACAMERA_SENSOR_ ORIENTATION,
&entry);
orientation_ = entry.data.i32[0];
bool is_horizontal = orientation_ == 0 || orientation_ == 270;
auto out_width = is_horizontal ? width_ : height_;
auto out_height = is_horizontal ? height_ : width_;
ANativeWindow_setBuffersGeometry(android_app_->window,
out_width,
out_height,
WINDOW_FORMAT_RGBA_8888);
这里,我们使用了ACameraManager_getCameraCharacteristics函数来获取相机元数据特性对象。然后,我们使用ACameraMetadata_getConstEntry函数读取ACAMERA_SENSOR_ORIENTATION属性。之后,我们根据使用的方向,基于ANativeWindow_setBuffersGeometry函数选择适当的宽度和高度顺序,以设置应用程序输出窗口尺寸和渲染缓冲区格式。
我们设置的格式是竖屏模式下的32位800像素高度和600像素宽度。这种方向处理非常简单,只需正确处理输出窗口缓冲区即可。之前,我们已禁用了应用中的横屏模式,因此我们将忽略相机传感器的方向在相机图像解码中的使用。
在configure_resources方法结束时,我们创建了相机读取器对象并初始化了捕获管道。
图像捕获管道构建
之前,我们看到在捕获管道初始化之前,我们创建了图像读取器对象。这是在create_image_reader方法中完成的,如下所示:
void ObjectDetector::create_image_reader() {
constexpr int32_t MAX_BUF_COUNT = 4;
auto status = AImageReader_new(
width_, height_, AIMAGE_FORMAT_YUV_420_888,
MAX_BUF_COUNT, &image_reader_);
ASSERT(image_reader_ && status == AMEDIA_OK,
"Failed to create AImageReader");
}
我们使用AImageReader_new创建了一个具有特定宽度和高度、YUV 格式和四个图像缓冲区的AImageReader对象。我们使用的宽度和高度值与输出窗口尺寸配置中使用的相同。我们使用 YUV 格式,因为它是最多相机设备的原生图像格式。使用四个图像缓冲区是为了使图像捕获稍微独立于它们的处理。这意味着当我们在读取另一个缓冲区并处理它时,图像读取器将用相机数据填充一个图像缓冲区。
捕获会话初始化是一个复杂的过程,需要创建几个对象并将它们相互连接。create_session方法如下实现:
void ObjectDetector::create_session() {
ANativeWindow* output_ native_window;
AImageReader_getWindow(image_reader_,
&output_ native_window);
ANativeWindow_acquire(output_native_window);
ACaptureSessionOutputContainer_create(&output_container_);
ACaptureSessionOutput_create(output_native_window,
&session_ output_);
ACaptureSessionOutputContainer_add(output_container_,
session_output_);
ACameraOutputTarget_create(output_native_window,
&output_target_);
ACameraDevice_createCaptureRequest(
camera_ device_, TEMPLATE_PREVIEW, &capture_request_);
ACaptureRequest_ addTarget(capture_request_,
output_target_);
ACameraDevice_createCaptureSession(camera_device_,
output_container_,
&session_callbacks,
&capture_session_);
// Start capturing continuously
ACameraCaptureSession_setRepeatingRequest(capture_session_,
nullptr,
1,
&capture_request_,
nullptr);
}
我们从图像读取器对象中获取并获取了一个原生窗口。窗口获取意味着我们获取了窗口的引用,系统不应删除它。这个图像读取器窗口将被用作捕获管道的输出,因此相机图像将被绘制到其中。
然后,我们创建了会话输出对象和会话输出的容器。会话可以有几个输出,它们应该放入一个容器中。每个会话输出都是一个具体表面或窗口输出的连接对象;在我们的情况下,它是图像读取器窗口。
在配置了会话输出后,我们创建了捕获请求对象,并确保其输出目标是图像读取窗口。我们为打开的相机设备配置了捕获请求,并设置了预览模式。之后,我们实例化了捕获会话对象,并将其指向打开的相机设备,该设备包含我们之前创建的输出容器。
最后,我们通过设置会话的重复请求来开始捕获。会话与捕获请求之间的关系如下:我们创建了一个配置了可能输出列表的捕获会话,捕获请求指定了实际将使用的表面。可能有多个捕获请求和多个输出。在我们的情况下,我们有一个单一的捕获请求和一个单一的输出,它将连续重复。因此,总的来说,我们将像视频流一样捕获摄像头的实时图片。以下图片显示了捕获会话中图像数据流的逻辑方案:

图 14.1 – 捕获会话中的逻辑数据流
这不是实际的数据流方案,而是逻辑方案,显示了捕获会话对象是如何连接的。虚线表示请求路径,实线表示逻辑图像数据路径。
捕获图像和输出窗口缓冲区管理
当我们讨论主应用程序循环时,我们提到了draw_frame方法,该方法在处理命令后在此循环中被调用。此方法用于从图像读取对象中获取捕获的图像,然后检测其上的对象并在应用程序窗口中绘制检测结果。以下代码片段显示了draw_frame方法实现:
void ObjectDetector::draw_frame() {
if (image_reader_ == nullptr)
return;
AImage *image = nullptr;
auto status = AImageReader_acquireNextImage(image_reader_, &image);
if (status != AMEDIA_OK) {
return;
}
ANativeWindow_acquire(android_app_->window);
ANativeWindow_Buffer buf;
if (ANativeWindow_lock(android_app_->window,
&buf,
nullptr) < 0) {
AImage_delete(image);
return;
}
process_image(&buf, image);
AImage_delete(image);
ANativeWindow_unlockAndPost(android_app_->window);
ANativeWindow_release(android_app_->window);
}
我们获取了图像读取对象接收到的下一张图像。记住我们初始化了它以拥有四个图像缓冲区。因此,我们在主循环中逐个从这些缓冲区获取图像,同时我们处理一张图像时,捕获会话会填充另一个已经处理过的图像。这是以循环方式完成的。拥有来自摄像头的图像,我们获取并锁定应用程序窗口,但如果锁定失败,我们删除当前图像引用,停止处理,并进入主循环的下一个迭代。否则,如果我们成功锁定应用程序窗口,我们处理当前图像,检测其上的对象,并将检测结果绘制到应用程序窗口中——这是在process_image方法中完成的。此方法接受AImage和ANativeWindow_Buffer对象。
当我们锁定应用程序窗口时,我们获得用于绘制的内部缓冲区的指针。在处理图像并绘制结果后,我们解锁应用程序窗口以使其缓冲区可供系统使用,释放对窗口的引用,并删除对图像对象的引用。因此,这种方法主要关于资源管理,而真正的图像处理是在process_image方法中完成的,我们将在下一小节中讨论。
捕获图像处理
process_image方法实现了以下任务:
-
将 Android YUV 图像数据转换为 OpenCV 矩阵。
-
将图像矩阵调度到 YOLO 对象检测器。
-
将检测结果绘制到 OpenCV 矩阵中。
-
将 OpenCV 结果矩阵复制到 RGB(红色、蓝色、绿色)窗口缓冲区。
让我们逐一查看这些任务的实现。process_image 方法的签名如下所示:
void ObjectDetector::process_image(
ANativeWindow_Buffer* buf,
AImage* image);
此方法接受用于结果绘制的应用程序窗口缓冲区对象和用于实际处理的图像对象。为了能够处理图像,我们必须将其转换为某种适当的数据结构格式;在我们的情况下,这就是 OpenCV 矩阵。我们首先检查图像格式属性,如下所示:
int32_t src_format = -1;
AImage_getFormat(image, &src_format);
ASSERT(AIMAGE_FORMAT_YUV_420_888 == src_format,
"Unsupported image format for displaying");
int32_t num_src_planes = 0;
AImage_getNumberOfPlanes(image, &num_src_planes);
ASSERT(num_src_planes == 3,
"Image for display has unsupported number of planes");
int32_t src_height;
AImage_getHeight(image, &src_height);
int32_t src_width;
AImage_getWidth(image, &src_width);
我们检查了图像格式为 YUV(亮度(Y)、蓝色亮度(U)和红色亮度(V)),并且图像有三个平面,因此我们可以继续其转换。然后,我们获得了图像尺寸,这些尺寸将在以后使用。之后,我们验证了从 YUV 平面数据中提取的输入数据,如下所示:
int32_t y_stride{0};
AImage_getPlaneRowStride(image, 0, &y_stride);
int32_t uv_stride1{0};
AImage_getPlaneRowStride(image, 1, &uv_stride1);
int32_t uv_stride2{0};
AImage_getPlaneRowStride(image, 1, &uv_stride2);
uint8_t *y_pixel{nullptr}, *uv_pixel1{nullptr}, *uv_pixel2{nullptr};
int32_t y_len{0}, uv_len1{0}, uv_len2{0};
AImage_getPlaneData(image, 0, &y_pixel, &y_len);
AImage_getPlaneData(image, 1, &uv_pixel1, &uv_len1);
AImage_getPlaneData(image, 2, &uv_pixel2, &uv_len2);
我们获得了步长、数据大小以及实际 YUV 平面数据的指针。在此格式中,图像数据分为三个组件:亮度(y),表示亮度,以及两个色度组件(u 和 v),它们表示颜色信息。y 成分通常以全分辨率存储,而 u 和 v 成分可能被子采样。这允许更有效地存储和传输视频数据。Android YUV 图像使用 u 和 v 的半分辨率。步长将使我们能够正确访问平面缓冲区中的行数据;这些步长取决于图像分辨率和数据内存布局。
拥有 YUV 平面数据及其步长和长度后,我们将它们转换为 OpenCV 矩阵对象,如下所示:
cv::Size actual_size(src_width, src_height);
cv::Size half_size(src_width / 2, src_height / 2);
cv::Mat y(actual_size, CV_8UC1, y_pixel, y_stride);
cv::Mat uv1(half_size, CV_8UC2, uv_pixel1, uv_stride1);
cv::Mat uv2(half_size, CV_8UC2, uv_pixel2, uv_stride2);
我们创建了两个 cv::Size 对象来存储 Y 平面的原始图像大小以及 u 和 v 平面的半大小。然后,我们使用这些大小、数据指针和步长为每个平面创建一个 OpenCV 矩阵。我们没有将实际数据复制到 OpenCV 矩阵对象中;它们将使用传递给初始化的数据指针。这种视图创建方法节省了内存和计算资源。y-平面矩阵具有 8 位单通道类型,而 u 和 v 矩阵具有 8 位双通道类型。我们可以使用这些矩阵与 OpenCV 的 cvtColorTwoPlane 函数将它们转换为 RGBA 格式,如下所示:
cv::mat rgba_img_;
...
long addr_diff = uv2.data - uv1.data;
if (addr_diff > 0) {
cvtColorTwoPlane(y, uv1, rgba_img_, cv::COLOR_YUV2RGBA_NV12);
} else {
cvtColorTwoPlane(y, uv2, rgba_img_, cv::COLOR_YUV2RGBA_NV21);
}
我们使用地址差异来确定 u 和 v 平面的顺序:正差异表示 NV12 格式,而负差异表示 NV21 格式。NV12 和 NV21 是 YUV 格式的类型,它们在色度平面中 u 和 v 成分的顺序上有所不同。在 NV12 中,u 成分先于 v 成分,而在 NV21 中则相反。这种平面顺序在内存消耗和图像处理性能中发挥作用,因此选择使用哪种格式取决于实际任务和项目。此外,格式可能取决于实际的相机设备,这就是我们添加此检测的原因。
cvtColorTwoPlane函数接受y平面和uv平面矩阵作为输入参数,并将 RGBA 图像矩阵输出到rgba_img_变量中。最后一个参数是告诉函数它应该执行什么实际转换的标志。现在,这个函数只能将 YUV 格式转换为 RGB 或 RGBA 格式。
正如我们之前所说的,我们的应用程序仅在纵向模式下工作,但为了使图像看起来正常,我们需要将其旋转如下:
cv::rotate(rgba_img_, rgba_img_, cv::ROTATE_90_CLOCKWISE);
即使我们的方向已经固定,Android 相机传感器返回的图像仍然是旋转的,所以我们使用了cv::rotate函数使其看起来是垂直的。
准备好 RGBA 图像后,我们将其传递给YOLO对象检测器,并获取检测结果。对于每个结果项,我们在已经用于检测的图像矩阵上绘制矩形和标签。这些步骤的实现方式如下:
auto results = yolo_->detect(rgba_img_);
for (auto& result : results) {
int thickness = 2;
rectangle(rgba_img_, result.rect.tl(), result.rect.br(),
cv::Scalar(255, 0, 0, 255), thickness,
cv::LINE_4);
cv::putText(rgba_ img_, result.class_name,
result.rect.tl(), cv::FONT_HERSHEY_DUPLEX,
1.0, CV_RGB(0, 255, 0), 2);
}
我们调用了YOLO对象的detect方法,并获取了results容器。这个方法将在稍后讨论。然后,对于容器中的每个项,我们为检测到的对象绘制一个边界框和文本标签。我们使用了带有rgba_img_目标图像参数的 OpenCV rectangle函数。此外,文本也被渲染到rgba_img_对象中。检测结果是yolo.h头文件中定义的结构,如下所示:
struct YOLOResult {
int class_index;
std::string class_name;
float score;
cv::Rect rect;
};
因此,一个检测结果具有类别索引和名称属性、模型置信度分数以及图像坐标中的边界框。对于我们的结果可视化,我们只使用了矩形和类别名称属性。
process_image方法最后执行的任务是将生成的图像渲染到应用程序窗口缓冲区中。其实现方式如下:
cv::Mat buffer_mat(src_width,
src_height,
CV_8UC4,
buf->bits,
buf->stride * 4);
rgba_img_.copyTo(buffer_mat);
我们创建了 OpenCV 的buffer_mat矩阵来包装给定的窗口缓冲区。然后,我们简单地使用 OpenCV 的copyTo方法将带有渲染矩形和类别标签的 RGBA 图像放入buffer_mat对象中。buffer_mat是 OpenCV 对 Android 窗口缓冲区的视图。我们创建它是为了遵循在configure_resources方法中配置的窗口缓冲区格式,即WINDOW_FORMAT_RGBA_8888格式。因此,我们创建了一个 8 位 4 通道类型的 OpenCV 矩阵,并使用缓冲区步进信息来满足内存布局访问。这样的视图使我们能够编写更少的代码,并使用 OpenCV 例程进行内存管理。
我们讨论了我们的对象检测应用程序的主要外观,在接下来的小节中,我们将讨论 YOLO 模型推理的实现细节以及其结果如何解析到YOLOResult结构中。
YOLO 包装器初始化
YOLO类的公共 API 中只有构造函数和detect方法。我们已经看到YOLO对象是在ObjectDetector类的构造函数中初始化的,detect方法是在process_image方法中使用的。YOLO类的构造函数只接受资产管理器对象作为单个参数,其实现方式如下:
YOLO::YOLO(AAssetManager* asset_manager) {
const std::string model_file_name = "yolov5s.torchscript";
auto model_buf = read_asset(asset_manager,
model_file_name);
model_ = torch::jit::_load_for_mobile(
std::make_unique<ReadAdapter>(model_buf));
const std::string classes_file_name = "classes.txt";
auto classes_buf = read_asset( asset_manager,
classes_file_name);
VectorStreamBuf<char> stream_buf(classes_buf);
std::istream is(&stream_buf);
load_classes(is);
}
记住,我们已经将yolov5s.torchscript和classes.txt文件添加到我们项目的assets文件夹中。这些文件可以通过AAssetManager类对象在应用程序中访问;此对象是从android_main函数中的android_app对象中获取的。因此,在构造函数中,我们通过调用read_asset函数加载模型二进制文件和类列表文件。然后,使用torch::jit::_load_for_mobile函数使用模型二进制数据加载和初始化 PyTorch 脚本模块。
注意,脚本模型应该以针对移动设备优化的方式保存,并使用相应的函数加载。当编译 PyTorch for mobile 时,常规的torch::jit::load功能会自动禁用。让我们看看read_asset函数,该函数以std::vector<char>对象的形式从应用程序包中读取资源。以下代码展示了其实现:
std::vector<char> read_asset(AAssetManager* asset_manager,
const std::string& name) {
std::vector<char> buf;
AAsset* asset = AAssetManager_open(
asset_manager, name.c_str(), AASSET_MODE_UNKNOWN);
if (asset != nullptr) {
LOGI("Open asset %s OK", name.c_str());
off_t buf_size = AAsset_getLength(asset);
buf.resize(buf_size + 1, 0);
auto num_read =AAsset_read(
asset, buf.data(), buf_size);
LOGI("Read asset %s OK", name.c_str());
if (num_read == 0)
buf.clear();
AAsset_close(asset);
LOGI("Close asset %s OK", name.c_str());
}
return buf;
}
我们使用了四个 Android 框架函数来从应用程序包中读取资产。AAssetManager_open函数打开了资产,并返回指向AAsset对象的非空指针。此函数假设资产的路径是文件路径格式,并且此路径的根是assets文件夹。在我们打开资产后,我们使用AAsset_getLength函数获取文件大小,并使用std::vector::resize方法为std::vector<char>分配内存。然后,我们使用AAsset_read()函数将整个文件读取到buf对象中。
此函数执行以下操作:
-
它获取要读取的资产对象的指针
-
需要使用内存缓冲区的
void*指针来读取 -
它测量要读取的字节大小
因此,正如你所看到的,资源 API 基本上与标准 C 库 API 的文件操作相同。当我们完成与资源对象的协作后,我们使用AAsset_close函数通知系统我们不再需要访问此资源。如果你的资源以.zip存档格式存储,你应该检查AAsset_read函数返回的字节数,因为 Android 框架是分块读取存档的。
你可能会注意到我们没有直接将字符向量传递给torch::jit::_load_for_mobile函数。此函数不与标准 C++流和类型一起工作;相反,它接受一个指向caffe2::serialize::ReadAdapterInterface类对象的指针。以下代码展示了如何具体实现caffe2::serialize::ReadAdapterInterface类,该类封装了std::vector<char>对象:
class ReadAdapter
: public caffe2::serialize::ReadAdapterInterface {
public:
explicit ReadAdapter(const std::vector<char>& buf)
: buf_(&buf) {}
size_t size() const override { return buf_->size(); }
size_t read(uint64_t pos,
void* buf,
size_t n,
const char* what) const override {
std::copy_n(buf_->begin() + pos, n,
reinterpret_cast<char*>(buf));
return n;
}
private:
const std::vector<char>* buf_;
};
ReaderAdapter 类重写了 caffe2::serialize::ReadAdapterInterface 基类中的两个方法,size 和 read。它们的实现相当明显:size 方法返回底层向量对象的大小,而 read 方法使用标准算法函数 std::copy_n 将 n 字节(字符)从向量复制到目标缓冲区。
为了加载类信息,我们使用了 VectorStreamBuf 适配器类将 std::vector<char> 转换为 std::istream 类型对象。这样做是因为 YOLO::load_classes 方法需要一个 std::istream 类型的对象。VectorStreamBuf 的实现如下:
template<typename CharT, typename TraitsT = std::char_traits<CharT> >
struct VectorStreamBuf : public std::basic_streambuf<CharT, TraitsT> {
explicit VectorStreamBuf(std::vector<CharT>& vec) {
this->setg(vec.data(), vec.data(),
vec.data() + vec.size());
}
}
我们从 std::basic_streambuf 类继承,并在构造函数中,使用输入向量的字符值初始化 streambuf 内部数据。然后,我们使用此适配器类的对象作为常规 C++ 输入流。您可以在以下代码片段中看到它,这是 load_classes 方法实现的一部分:
void YOLO::load_classes(std::istream& stream) {
LOGI("Init classes start OK");
classes_.clear();
if (stream) {
std::string line;
std::string id;
std::string label;
size_t idx = 0;
while (std::getline(stream, line)) {
auto pos = line.find_first_of(':');
id = line.substr(0, pos);
label = line.substr(pos + 1);
classes_.insert({idx, label});
++idx;
}
}
LOGI("Init classes finish OK");
}
classes.txt 中的行格式如下:
[ID] space character [class name]
因此,我们逐行读取此文件,并在第一个空格字符的位置拆分每一行。每一行的第一部分是类标识符,而第二部分是类名。为了将模型的评估结果与正确的类名匹配,我们创建了一个字典(映射)对象,其键是 id 值,值是 label(例如,类名)。
YOLO 检测推理
YOLO 类的 detect 方法是我们进行实际对象检测的地方。此方法以表示 RGB 图像的 OpenCV 矩阵对象为参数,其实现如下:
std::vector<YOLOResult> YOLO::detect(const cv::Mat& image) {
constexpr int input_width = 640;
constexpr int input_height = 640;
cv::cvtColor(image, rgb_img_, cv::COLOR_RGBA2RGB);
cv::resize(rgb_img_, rgb_img_,
cv::Size(input_width, input_height));
auto img_scale_x =
static_cast<float>(image.cols) / input_width;
auto img_scale_y =
static_cast<float>(image.rows) / input_height;
auto input_tensor = mat2tensor(rgb_img_);
std::vector<torch::jit::IValue> inputs;
inputs.emplace_back(input_tensor);
auto output = model_.forward(inputs).toTuple() - >
elements()[0].toTensor().squeeze(0);
output2results(output, img_scale_x, img_scale_y);
return non_max_suppression();
}
我们定义了代表模型输入宽度和高度的常量;它是 640 x 640,因为 YOLO 模型是在这种大小的图像上训练的。使用这些常量,我们调整了输入图像的大小。此外,我们移除了 alpha 通道并制作了 RGB 图像。我们计算了图像尺寸的缩放因子,因为它们将被用来将检测到的对象边界重新缩放到原始图像大小。在调整了图像大小后,我们使用 mat2tensor 函数将 OpenCV 矩阵转换为 PyTorch Tensor 对象,我们将在稍后讨论其实现。我们将 PyTorch Tensor 对象添加到 torch::jit::IValue 值的容器中时,对象类型转换是自动完成的。在这个 inputs 容器中只有一个元素,因为 YOLO 模型需要一个 RGB 图像输入。
然后,我们使用 YOLO model_ 对象的 forward 函数进行推理。PyTorch API 脚本模块返回 torch::jit::Tuple 类型。因此,我们显式地将返回的 torch::jit::IValue 对象转换为元组,并取第一个元素。该元素被转换为 PyTorch Tensor 对象,并使用 squeeze 方法从其中移除批维度。因此,我们得到了大小为 25200 x 85 的 torch::Tensor 类型 output 对象。其中,25200 是检测到的对象数量,我们将应用非极大值抑制算法以获得最终减少的输出。85 表示 80 个类别分数、4 个边界框位置(x, y, 宽度,高度)和 1 个置信度分数。结果张量在 output2results 方法中解析为 YOLOResult 结构。正如我们所说的,我们使用了 non_max_suppression 方法来选择最佳检测结果。
让我们看看我们用于推理的所有中间函数的详细信息。
将 OpenCV 矩阵转换为 torch::Tensor
mat2tensor 函数将 OpenCV mat 对象转换为 torch::Tensor 对象,其实现如下:
torch::Tensor mat2tensor(const cv::Mat& image) {
ASSERT(image.channels() == 3, "Invalid image format");
torch::Tensor tensor_image = torch::from_blob(
image.data,
{1, image.rows, image.cols, image.channels()},
at::kByte);
tensor_image = tensor_image.to(at::kFloat) / 255.;
tensor_image = torch::transpose(tensor_image, 1, 2);
tensor_image = torch::transpose(tensor_image, 1, 3);
return tensor_image;
}
我们使用 torch::from_blob 函数从原始数据创建 torch Tensor 对象。数据指针是我们从 OpenCV 对象的 data 属性中获取的。我们使用的形状 [HEIGHT, WIDTH, CHANNELS] 遵循 OpenCV 内存布局,其中最后一个维度是通道号维度。然后,我们将张量转换为浮点数并归一化到 [0,1] 区间。PyTorch 和 YOLO 模型使用不同的形状布局 [CHANNELS, HEIGHT, WIDTH]。因此,我们适当地转置张量通道。
处理模型输出张量
我们使用的下一个函数是 output2results,它将输出 Tensor 对象转换为 YOLOResult 结构的向量。其实现如下:
void YOLO::output2results(const torch::Tensor &output,
float img_scale_x,
float img_scale_y) {
auto outputs = output.accessor<float, 2>();
auto output_row = output.size(0);
auto output_column = output.size(1);
results_.clear();
for (int64_t i = 0; i < output_row; i++) {
auto score = outputs[i][4];
if (score > threshold) {
// read the bounding box
// calculate the class id
results_.push_back(YOLOResult{
.class_index = cls,
.class_name = classes_[cls],
.score = score,
.rect = cv::Rect(left, top, bw, bh),
});
}
}
在开始时,我们使用 torch Tensor 对象的 accessor<float, 2> 方法来获取一个对张量非常有用的 accessor。这个 accessor 允许我们使用方括号运算符访问多维张量中的元素。数字 2 表示张量是 2D 的。然后,我们对张量行进行循环,因为每一行对应一个单独的检测结果。在循环内部,我们执行以下步骤:
-
我们从行索引
4的元素中读取置信度分数。 -
如果置信度分数大于阈值,我们继续处理结果行。
-
我们读取
0, 1, 2, 3元素,这些是边界矩形的[x, y, 宽度, 高度]坐标。 -
使用先前计算的比例因子,我们将这些坐标转换为
[左, 上, 宽度, 高度]格式。 -
我们读取元素 5-84,这些是类别概率,并选择最大值的类别。
-
我们使用计算出的值创建
YOLOResult结构并将其插入到results_容器中。
边界框计算如下:
float cx = outputs[i][0];
float cy = outputs[i][1];
float w = outputs[i][2];
float h = outputs[i][3];
int left = static_cast<int>(img_scale_x * (cx - w / 2));
int top = static_cast<int>(img_scale_y * (cy - h / 2));
int bw = static_cast<int>(img_scale_x * w);
int bh = static_cast<int>(img_scale_y * h);
YOLO 模型返回矩形的中心 X 和 Y 坐标,因此我们将它们转换为图像(屏幕)坐标系:到左上角点。
类别 ID 选择实现如下:
float max = outputs[i][5];
int cls = 0;
for (int64_t j = 0; j < output_column - 5; j++) {
if (outputs[i][5 + j] > max) {
max = outputs[i][5 + j];
cls = static_cast<int>(j);
}
}
我们使用遍历代表 79 个类别概率的最后元素来选择最大值的索引。这个索引被用作类别 ID。
NMS 和 IoU
非极大值抑制(NMS)和 交并比(IoU)是 YOLO 中用于精炼和过滤输出预测以获得最佳结果的两个关键算法。
非极大值抑制(NMS)用于抑制或消除相互重叠的重复检测。它通过比较网络预测的边界框并移除与其他边界框重叠度高的那些来实现。例如,如果有两个边界框被预测为同一对象,NMS 将只保留置信度评分最高的那个,其余的将被丢弃。以下图片展示了 NMS 的工作原理:

图 14.2 – NMS
IoU 是另一种与 NMS 结合使用的算法,用于测量边界框之间的重叠。IoU 计算两个框之间交集面积与并集面积的比率。范围在 0 到 1 之间,其中 0 表示没有重叠,1 表示完全重叠。以下图片展示了 IoU 的工作原理:

图 14.3 – IoU
我们在 non_max_suppression 方法中实现了 NMS,如下所示:
std::vector<YOLOResult> YOLO::non_max_suppression() {
// do an sort on the confidence scores, from high to low.
std::sort(results_.begin(), results_.end(), [](
auto &r1, auto &r2) {
return r1.score > r2.score;
});
std::vector<YOLOResult> selected;
std::vector<bool> active(results_.size(), true);
int num_active = static_cast<int>(active.size());
bool done = false;
for (size_t i = 0; i < results_.size() && !done; i++) {
if (active[i]) {
const auto& box_a = results_[i];
selected.push_back(box_a);
if (selected.size() >= nms_limit)
break;
for (size_t j = i + 1; j < results_.size(); j++) {
if (active[j]) {
const auto& box_b = results_[j];
if (IOU(box_a.rect, box_b.rect) > threshold) {
active[j] = false;
num_active -= 1;
if (num_active <= 0) {
done = true;
break;
}
}
}
}
}
}
return selected;
}
首先,我们按置信度分数降序对所有检测结果进行排序。我们将所有结果标记为活动状态。如果一个检测结果是活动的,那么我们可以将其与另一个结果进行比较,否则,该结果已被抑制。然后,每个活动检测结果依次与后续的活动结果进行比较;记住容器是排序的。比较是通过计算边界框的 IoU 值并与阈值进行比较来完成的。如果 IoU 值大于阈值,我们将置信度值较低的结果标记为非活动状态;我们抑制了它。因此,我们定义了嵌套比较循环。在外层循环中,我们也忽略了被抑制的结果。此外,这个嵌套循环还有检查允许的最大结果数量的检查;请参阅 nms_limit 值的使用。
两个边界框的 IoU 算法在 IOU 函数中实现如下:
float IOU(const cv::Rect& a, const cv::Rect& b) {
if (a.empty() <= 0.0)
return 0.0f;
if (b.empty() <= 0.0)
return 0.0f;
auto min_x = std::max(a.x, b.x);
auto min_y = std::max(a.y, b.y);
auto max_x = std::min(a.x + a.width, b.x + b.width);
auto max_y = std::min(a.y + a.height, b.y + b.height);
auto area = std::max(max_y - min_y, 0) *
std::max(max_x - min_x, 0);
return static_cast<float>(area) /
static_cast<float>(a.area() + b.area() - area);
}
首先,我们检查边界框是否为空;如果一个框为空,则 IoU 值为零。然后,我们计算交集面积。这是通过找到 X 和 Y 的最小和最大值,考虑两个边界框,然后取这些值之间的差异的乘积来完成的。并集面积是通过将两个边界框的面积相加减去交集面积来计算的。您可以在返回语句中看到这个计算,我们在其中计算了面积比率。
NMS 和 IoU 共同帮助通过丢弃假阳性并确保只有相关的检测被包含在最终输出中,从而提高了 YOLO 的准确性和精确度。
在本节中,我们探讨了 Android 系统对象检测应用的实现。我们学习了如何将预训练模型从 Python 程序导出为 PyTorch 脚本文件。然后,我们深入开发了一个使用 Android Studio IDE 和 PyTorch C++库移动版本的移动应用程序。在下面的图中,你可以看到一个应用程序输出窗口的示例:

图 14.4 – 对象检测应用输出
在这张图中,你可以看到我们的应用成功地在智能手机摄像头前检测到了一台笔记本电脑和一只鼠标。每一个检测结果都用边界框和相应的标签进行了标记。
摘要
在本章中,我们讨论了如何部署机器学习模型,特别是神经网络,到移动平台。我们探讨了在这些平台上,我们通常需要为我们项目使用的机器学习框架的定制构建。移动平台使用不同的 CPU,有时,它们有专门的神经网络加速器设备,因此你需要根据这些架构编译你的应用程序和机器学习框架。这些架构与开发环境不同,你通常用于两个不同的目的。第一种情况是使用配备 GPU 的强大机器配置来加速机器学习训练过程,因此你需要构建你的应用程序时考虑到一个或多个 GPU 的使用。另一种情况是仅使用设备进行推理。在这种情况下,你通常根本不需要 GPU,因为现代 CPU 在很多情况下可以满足你的性能要求。
在本章中,我们为 Android 平台开发了一个对象检测应用。我们学习了如何通过 JNI 将 Kotlin 模块与原生 C++库连接起来。然后,我们探讨了如何使用 NDK 构建 Android 平台的 PyTorch C++库,并看到了使用移动版本的限制。
这本书的最后一章;我希望你喜欢这本书,并觉得它在你掌握使用 C++进行机器学习的过程中有所帮助。我希望到现在为止,你已经对如何利用 C++的力量来构建稳健和高效的机器学习模型有了坚实的理解。在整个书中,我旨在提供复杂概念的清晰解释、实用示例和逐步指南,帮助你开始使用 C++进行机器学习。我还包括了一些提示和最佳实践,帮助你避免常见的陷阱并优化你的模型以获得更好的性能。我想提醒你,在使用 C++进行机器学习时,可能性是无限的。
不论你是初学者还是有经验的开发者,总有新的东西可以学习和探索。考虑到这一点,我鼓励你继续挑战自己的极限,尝试不同的方法和技巧。机器学习的世界不断在发展,通过跟上最新的趋势和发展,你可以保持领先,构建能够解决复杂问题的尖端模型。
再次感谢您选择我的书籍,并抽出时间学习如何使用 C++ 进行机器学习。我希望您能觉得这是一份有价值的资源,并帮助您在成为熟练且成功的机器学习开发者之路上取得进步。
进一步阅读
-
PyTorch C++ API:
pytorch.org/cppdocs/ -
应用开发者文档:
developer.android.com/develop -
Android NDK:
developer.android.com/ndk -
PyTorch 移动开发指南:
pytorch.org/mobile/android/ -
PyTorch 优化移动脚本导出指南:
pytorch.org/tutorials/recipes/script_optimized.html -
OpenCV Android SDK 教程:
docs.opencv.org/4.x/d5/df8/tutorial_dev_with_OCV_on_Android.html -
ExcuTorch – 在嵌入式设备上运行 PyTorch 的新框架:
pytorch.org/executorch/stable/index.html


,其中 I 是单位矩阵。单位矩阵是一个当我们用该矩阵乘以一个向量时不会改变该向量的矩阵。



:在
级别的子图
:距离的第 t 个阈值
:层次级别的数量
,o:当
时,一个空的图边集
:当
时,没有距离阈值的对象图
属性是围绕对象的
半径邻域区域。
包含最小非零对象数量的对象。假设这个最小数量等于一个预定义的值,称为 MinPts。
属性中,并且 q 是根对象,则 p 对象可以直接从 q 对象中密集访问。
对象的序列,其中
和
,使得
可以直接从
、
中密集访问,则对于给定的
和 MinPts,p 对象可以从 q 对象中密集访问。
和 MinPts,如果存在一个 o 对象,使得 p 和 q 都可以从 o 中密集访问,则 p 对象与 q 对象是密集连接的。
。
和
参数。
在高斯分布中概率的方程来计算
。
进行比较来确定
矩阵值)。粗略地说,独立成分的一个向量的值不会影响另一个成分的值。
,其中
是函数 G(z) 的导数。
。
。
,使用中心化矩阵,
,其中
最大的特征值
和相应的特征向量
,这些特征向量属于
(其中
是所需的输出维度数量)。
,其中
是
的特征向量矩阵,
是
的特征值对角矩阵。
的核矩阵。
,其中
是一个大小为 N x N 的矩阵,其元素为 1/N。
。
个特征向量,其中
是新特征空间的维度数。
个最近邻,并从这些最近邻的距离构建一个加权无向图。边权重是到邻居的欧几里得距离。
(i 对应于非支持向量)
(i 对应于支持向量)
仅当 x = y 时
当 x、y 和 z 点不在一条直线上时
评分的历史,选择一些条件相似度度量。
。
表达式:
: 陪审团成员做出正确决定的概率
: 由 N 个元素组成的 i 的组合数:
。
系数:
数据集
:一个L2 损失,也称为高斯损失。这个公式是经典的条件均值,最常见和简单的选项。如果没有额外的信息或模型可持续性要求,应该使用它。
:一个L1 损失,也称为拉普拉斯损失。这个公式乍一看并不非常可微,并确定条件中位数。正如我们所知,中位数对异常值更具抵抗力。因此,在某些问题中,这个损失函数更可取,因为它不像二次函数那样对大的偏差进行惩罚。
:一个Lq 损失,也称为分位数损失。如果我们不想要条件中位数,但想要条件 75%分位数,我们将使用这个选项与
一起。这个函数是不对称的,对那些最终出现在所需分位数一侧的观察值进行更多惩罚。
:逻辑损失,也称为伯努利损失。这个损失函数的一个有趣特性是我们甚至对正确预测的类别标签进行惩罚。通过优化这个损失函数,我们可以在所有观察都正确预测的情况下,继续使类别分离并提高分类器。这个函数是二元分类任务中最标准、最常用的损失函数。
:AdaBoost 损失。恰好使用这个损失函数的经典 AdaBoost 算法(AdaBoost 算法也可以使用不同的损失函数)等同于梯度提升。从概念上讲,这个损失函数与逻辑损失非常相似,但它对分类错误的指数惩罚更强,使用频率较低。
决策规则在 t 节点定义,它考虑了一定范围内的值。这个范围被划分为 Rt 个不相交的对象集合:
,其中 Rt 是节点的后代数量,每个
是落入
后代的对象集合。
对于选择的边界值
。
,其中
是一个向量的标量积。实际上,这是一个角值检查。
,其中距离
定义在某些度量空间中(例如,
)。
,其中
是一个谓词。
是通过所选分区获得的后代节点
是落在当前节点子 i 的对象数量
是当前节点中捕获的对象数量
是被捕获在 t 顶点的对象
作为误分类的概率,具体来说,如果我们预测给定节点中类别的发生概率
作为随机变量的不确定性度量
作为最强类别的分类错误率
,
。
子树的分类错误数量
子树的分类错误数量
的子树替换节点t。
的子树替换节点t。
作为集成中的单个树的数量。
树,选择
作为
的特征数量。通常,所有树都使用一个值。
个随机特征,然后最优的训练集分割将限制其搜索范围。
,直到达到树的某个高度,或者直到训练集耗尽。
和
相等的属性数量,对于回归问题。
。
是网络输出的目标值,
是网络输出层的实际结果,
是输出层中的神经元数量。
层:
是特定神经元的输入数量,
是特定神经元的偏置值。
)等于以下表达式:
,添加误差校正。以下公式显示了我们是如何根据
输出计算误差校正值,
的:
是一个学习率值。
,使用链式法则计算,该法则应用了两次。注意
只影响求和,
:
。为此,我们必须依次计算各个组成部分。考虑我们网络中误差的计算方式:
不依赖于
的权重。这个变量相对于它的偏导数等于
:
,分别。对于表达式的第二部分,我们得到以下结果:
是网络输出层的索引。
为例。这里,方法相同,但有一个显著的区别——隐藏层神经元的输出传递到所有(或几个)输出层神经元的输入,这一点必须考虑:
和
已经在之前的步骤中计算出来,并且我们可以使用它们的值来进行计算:
(初始化过程将在后面讨论)。

浙公网安备 33010602011771号