C--机器学习项目-全-

C# 机器学习项目(全)

原文:annas-archive.org/md5/441a7eca6cdbd075d6fb97fab4a6bbb6

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

在数据时代,很难忽视机器学习(ML)和数据科学的重要性。机器学习已经在许多行业中得到广泛应用,其采用率正在以比以往任何时候都要快的速度增长。不仅像谷歌、微软和苹果这样的大科技公司,而且像彭博社和高盛这样的非科技公司也在大量投资机器学习。从在搜索引擎上搜索今晚要吃什么,到获取新信用卡的批准,机器学习的应用无处不在,渗透到我们日常生活的方方面面。作为一名数据科学家和机器学习实践者,我无法强调数据时代,尤其是大数据时代,机器学习的重要性。

如果你正在寻找学习应用机器学习(ML)的资源,你来到了正确的地点。对于许多有志于成为数据科学家和机器学习实践者的人来说,在现有的机器学习书籍中,关于 C#应用机器学习的资源相对较少。你可以轻松找到详细解释机器学习背后理论的书籍。你也能轻松找到涉及不同编程语言(如 Python)中机器学习实际应用的书籍。然而,正如你可能已经注意到的,关于如何使用 C#构建实际机器学习模型和应用的书籍并不多。

在这本书中,我们将专注于机器学习的实际应用,并直接深入到构建各种现实世界项目的机器学习模型和应用。通过分析带有现实世界数据集的机器学习真实案例,你将了解其他数据科学家和机器学习实践者实际上是如何为他们的生产系统构建机器学习模型和应用的。这本书的独特之处在于,每一章都是一个具有现实世界商业用例的独立机器学习项目。

在这本书中,我们将选择 C#作为将要工作的机器学习项目的编程语言。你可能会问,“为什么是 C#?”答案其实非常简单。正如你可能已经知道的,C#是行业中最受欢迎和最广泛使用的语言之一。特别是在金融公司中,C#是少数几种被普遍接受和用于生产应用的编程语言之一。

个人而言,当我刚开始在数据科学领域职业生涯时,我非常需要像这样一本书。当时,学校里教的内容和现实生活中真正有效的方法(以及如何有效)之间存在差异。在这本书中,我想分享我不得不艰难学习到的知识和经验。在这本书中,我们将讨论一些不常被提及的话题,例如 ML 项目通常是如何开始的,ML 模型在不同行业中是如何构建和测试的,ML 应用是如何在生产系统中部署的,以及那些在生产系统中运行的 ML 模型是如何被监控和评估的。我们将在这本书的整个过程中一起努力,帮助你为未来可能遇到的任何 ML 项目做好准备。到这本书结束时,你将能够使用 C#构建稳健且性能良好的 ML 模型和应用。

这本书面向的对象

这本书是为那些知道如何使用 C#编写代码并且对 ML 有基本了解的人而写的。即使你对 ML 算法背后的理论没有深入了解,也不要担心!这是可以的。这本书将帮助你理解如何根据不同的用例使用不同的学习算法。如果你已经学习了 ML,也许是在学校、在线课程或数据科学训练营,那么这本书对你来说将是非常好的。这本书将通过九个真实的 ML 项目和使用真实数据集,向你展示如何实际应用你学到的 ML 理论和概念。如果你已经是 ML 从业者,你仍然可以从这本书中受益良多!通过研究各种实际应用的 ML 案例,这本书将帮助你扩展将 ML 应用于各种其他商业案例的知识和经验。

这本书实际上是为任何对应用 ML 有热情的人而写的。如果你希望能够从第一天开始就能构建可以在生产系统中使用的 ML 模型和应用,那么这本书就是为你准备的!

这本书涵盖的内容

第一章,机器学习建模基础,讨论了我们周围可以轻松找到的一些 ML 应用的真实案例。它还涵盖了构建 ML 模型的基本步骤以及如何为即将到来的真实 ML 项目设置 C#开发环境。

第二章,垃圾邮件过滤,涵盖了文本数据集的特征工程技术和如何使用逻辑回归和朴素贝叶斯学习算法构建分类模型。本章还讨论了一些分类模型的基本验证方法。

第三章,推特情感分析,描述了一些常用的自然语言处理NLP)技术用于特征工程以及如何构建多类分类模型。本章还涵盖了如何在 C#中构建朴素贝叶斯和随机森林分类器,以及用于分类模型的更高级模型评估指标。

第四章,外汇汇率预测,探讨了回归问题,其中目标变量是连续变量。本章讨论了一些在外汇市场中经常使用的技术指标,以及如何将它们用作构建外汇汇率预测模型的特征。它还涵盖了如何构建用于外汇汇率预测的线性回归和支持向量机SVMs)。

第五章,房屋和财产公允价值,涉及数据集中具有混合类型特征回归问题。本章讨论了为 SVM 模型使用不同的核方法。它还描述了一些回归模型的基本模型评估指标以及如何使用它们来比较构建的模型。

第六章,客户细分,描述了一个无监督学习问题,其中没有标记的目标变量。它讨论了如何使用 k-means 聚类算法从电子商务数据集中提取客户行为的见解。本章还讨论了一个可以用来评估每个聚类或细分形成得有多好的指标。

第七章,音乐流派推荐,介绍了一个排名问题,其中机器学习模型的输出数量不止一个。本章涵盖了如何构建用于推荐音乐流派的人工智能模型以及如何评估这些模型的推荐结果。

第八章,手写数字识别,讨论了一个图像识别问题,其目标是构建机器学习模型来识别手写数字。它涵盖了一种降维技术以及它如何用于图像数据集。本章介绍了用于图像识别的神经网络模型。

第九章,网络攻击检测,深入探讨了异常检测问题。在本章中,我们将尝试构建机器学习模型来检测网络攻击。它涵盖了如何使用称为主成分分析PCA)的降维技术来构建一个可以识别网络攻击的异常检测模型。

第十章,信用卡欺诈检测,继续讨论异常检测问题。本章讨论了如何构建机器学习模型来检测信用卡欺诈。它介绍了一种新的机器学习算法,单类 SVM,用于异常检测模型。

第十一章,接下来是什么?,是本书的最后一章。它回顾了本书中讨论的所有内容。然后,它涵盖了现实生活中机器学习项目中经常出现的挑战。本章还讨论了一些用于数据科学任务的一些常用技术和工具。

为了充分利用这本书

为了充分利用这本书,我建议您彻底遵循每一章中概述的每个步骤。通过代码示例并在自己的环境中运行它们,将有助于您更好地理解并更快地熟悉构建机器学习模型。我还建议您勇于尝试,将不同章节中讨论的技术和学习算法混合起来。完成这本书后,如果您能再次从头开始浏览项目,并开始为个别项目构建自己的机器学习模型版本,那就更好了。

下载示例代码文件

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

您可以通过以下步骤下载代码文件:

  1. www.packtpub.com登录或注册。

  2. 选择“支持”标签。

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

  4. 在搜索框中输入书名,并遵循屏幕上的说明。

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

  • 适用于 Windows 的 WinRAR/7-Zip

  • 适用于 Mac 的 Zipeg/iZip/UnRarX

  • 适用于 Linux 的 7-Zip/PeaZip

本书代码包也托管在 GitHub 上,地址为github.com/PacktPublishing/CSharp-Machine-Learning-Projects。如果代码有更新,它将在现有的 GitHub 仓库中更新。

我们还有其他来自我们丰富图书和视频目录的代码包可供选择,可在github.com/PacktPublishing/找到。查看它们吧!

下载彩色图像

我们还提供了一个包含本书中使用的截图/图表彩色图像的 PDF 文件。您可以从这里下载:www.packtpub.com/sites/default/files/downloads/CSharpMachineLearningProjects_ColorImages.pdf

使用的约定

本书使用了多种文本约定。

CodeInText:表示文本中的代码词汇、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“打开您的 Visual Studio,在 Visual C#类别下创建一个新的 Console Application。使用前面的命令通过 NuGet 安装 Deedle 库,并将引用添加到您的项目中。”

代码块设置如下:

var barChart = DataBarBox.Show(
    new string[] { "Ham", "Spam" },
    new double[] {
        hamEmailCount,
        spamEmailCount
    }
);
barChart.SetTitle("Ham vs. Spam in Sample Set");

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

PM> Install-Package Deedle

粗体:表示新术语、重要词汇或屏幕上看到的词汇。例如,菜单或对话框中的文字会像这样显示。以下是一个示例:“打开包管理器(工具 | NuGet 包管理器 | 包管理器控制台),并使用以下命令安装 Deedle。”

警告或重要提示看起来像这样。

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

联系我们

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

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

勘误:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告这一错误。请访问 www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。

盗版:如果您在互联网上以任何形式发现我们作品的非法副本,我们将不胜感激,如果您能向我们提供位置地址或网站名称。请通过 copyright@packtpub.com 联系我们,并提供材料的链接。

如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问 authors.packtpub.com

评论

请留下评论。一旦您阅读并使用了这本书,为何不在购买它的网站上留下评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,Packt 可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!

如需了解 Packt 的更多信息,请访问 packtpub.com

第一章:机器学习建模基础

要看到机器学习(ML)如何影响普通人的日常生活可能很困难。实际上,机器学习无处不在!在寻找晚餐餐厅的过程中,你几乎肯定使用了机器学习。在寻找晚宴时穿的连衣裙时,你也会使用机器学习。在你前往晚餐约会的过程中,如果你使用了共享出行应用,你很可能也使用了机器学习。机器学习已经如此广泛地被使用,以至于它已经成为我们生活中不可或缺的一部分,尽管它通常不易察觉。随着数据的不断增长及其可访问性,机器学习的应用和需求在各个行业中迅速增长。然而,训练有素的科学家和机器学习工程师的增长速度尚未满足企业对机器学习增长的需求,尽管有丰富的资源和软件库使构建机器学习模型变得更加容易,这是因为数据科学家和机器学习工程师掌握这些技能集需要时间和经验。本书将通过基于真实世界数据集的实际项目来为这样的人做好准备。

在本章中,我们将了解一些机器学习的实际例子和应用、构建机器学习模型的基本步骤,以及如何为机器学习设置我们的 C# 环境。在本章简短的介绍之后,我们将立即进入使用文本数据集构建分类机器学习模型,第二章 垃圾邮件过滤 和 第三章 Twitter 情感分析。然后,我们将使用金融和房地产数据在 第四章 外汇汇率预测 和 第五章 房屋和财产的公允价值 中构建回归模型。在 第六章 客户细分 中,我们将使用聚类算法通过电子商务数据深入了解客户行为。在 第七章 音乐流派推荐 和 第八章 手写数字识别 中,我们将使用音频和图像数据构建推荐和图像识别模型。最后,我们将在 第九章 网络攻击检测 和 第十章 信用卡欺诈检测 中使用半监督学习技术来检测异常。

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

  • 关键机器学习任务和应用

  • 构建机器学习模型的步骤

  • 为机器学习设置 C# 环境

关键机器学习任务和应用

在我们的日常生活中,有许多地方使用机器学习,而我们并未意识到。媒体公司使用机器学习为您推荐最相关的内容,如新闻文章、电影或音乐,供您阅读、观看或收听。电子商务公司使用机器学习来建议您可能感兴趣且最有可能购买的商品。游戏公司使用机器学习来检测您的运动和关节运动,以用于他们的动作感应游戏。机器学习在行业中的其他一些常见用途包括相机上的面部检测以实现更好的对焦、自动问答,其中聊天机器人或虚拟助手与客户互动以回答问题和请求,以及检测和预防欺诈交易。在本节中,我们将探讨一些我们在日常生活中使用且高度依赖机器学习的应用:

  • 谷歌新闻动态:谷歌新闻动态使用机器学习根据用户的兴趣和其他个人资料数据生成个性化的文章流。协同过滤算法常用于此类推荐系统,并基于其用户群体的查看历史数据构建。媒体公司使用此类个性化推荐系统来吸引更多流量到他们的网站并增加订阅者数量。

  • 亚马逊产品推荐:亚马逊利用用户浏览和订单历史数据来训练一个机器学习模型,推荐用户最有可能购买的产品。这是电子商务行业中监督学习的良好用例。这些推荐算法帮助电子商务公司通过显示与每个用户兴趣最相关的商品来最大化其利润。

  • Netflix 电影推荐:Netflix 使用电影评分、观看历史和偏好配置文件来推荐用户可能喜欢的其他电影。他们使用数据训练协同过滤算法以做出个性化推荐。根据 Wired 杂志上的一篇文章(www.wired.co.uk/article/how-do-netflixs-algorithms-work-machine-learning-helps-to-predict-what-viewers-will-like),超过 80%的 Netflix 用户观看的电视节目是通过平台的推荐系统发现的,这是一个非常有用且有利可图的媒体公司机器学习用例。

  • 相机上的面部检测:相机通过检测面部来实现更好的对焦和曝光测量。这是计算机视觉和分类中最常用的例子。此外,一些照片管理软件使用聚类算法将图像中的相似面部分组在一起,以便您可以稍后通过图像中的特定人物搜索照片。

  • Alexa 虚拟助手:虚拟助手系统,如 Alexa,可以回答诸如纽约的天气如何? 或完成某些任务,如打开客厅的灯。这类虚拟助手系统通常使用语音识别、自然语言理解(NLU)、深度学习和各种其他机器学习技术构建。

  • 微软 Xbox Kinect:Kinect 可以感知每个物体与传感器的距离并检测关节位置。Kinect 使用随机决策森林算法进行训练,从深度图像中构建大量单个决策树。

以下截图展示了使用机器学习构建的不同推荐系统示例:

左:谷歌新闻推送,右上:亚马逊产品推荐,右下:Netflix 电影推荐

以下截图展示了几个其他机器学习应用的例子:

左:谷歌新闻推送,右上:亚马逊产品推荐,右下:Netflix 电影推荐

构建机器学习模型的步骤

现在我们已经看到了一些现有的机器学习应用的例子,问题是,我们如何着手构建这样的机器学习应用和系统? 有关机器学习的书籍和大学中教授的机器学习课程通常首先介绍机器学习算法背后的数学和理论,然后将这些算法应用于给定的数据集。这种方法对于对这个主题完全陌生且希望学习机器学习基础的人来说是很好的。然而,那些有一定先验知识和经验,并希望将他们的知识应用于实际机器学习项目的有志数据科学家往往在如何开始以及如何处理一个特定的机器学习项目上感到困惑。在本节中,我们将讨论构建机器学习应用的典型工作流程,我们将在本书中遵循这个流程。以下图总结了我们的使用机器学习开发应用的方法,我们将在接下来的小节中详细讨论:

构建机器学习模型的步骤

如前图所示,构建学习模型的步骤如下:

  • 问题定义:开始任何项目的第一步不仅是理解问题,还要定义你试图用机器学习解决的问题。问题定义不明确会导致构建的机器学习系统没有意义,因为模型已经被训练和优化用于你实际上并不试图解决的问题。这一步无疑是构建有用的机器学习模型和应用中最重要的一步。在开始构建机器学习模型之前,你应该至少回答以下四个问题:

    • 问题是?这是你描述和声明你试图用机器学习解决的问题的地方。例如,问题描述可能为需要一个系统来评估小型企业主偿还贷款的能力(针对小型企业贷款项目)。

    • 为什么这是一个问题?定义为什么这样的问题实际上是一个问题,以及为什么新的机器学习模型将会是有用的,这是非常重要的。也许你已经有一个正在工作的模型,并且你注意到它的表现不如以前;你可能已经获得了可以用于构建新预测模型的新数据源;或者你可能希望你的现有模型能够更快地产生预测结果。可能有多个原因让你认为这是一个问题,以及为什么你需要一个新的模型。定义为什么这是一个问题将帮助你保持正确的方向,在你构建新的机器学习模型时。

    • 解决这个问题的方法有哪些?这就是你构思解决给定问题方法的地方。你应该考虑这个模型将要如何被使用(你需要这是一个实时系统,还是作为批处理运行?),它是什么类型的问题(是分类问题、回归、聚类还是其他什么?),以及你需要为你的模型准备哪些类型的数据。这将为你构建机器学习模型未来的步骤提供一个良好的基础。

    • 成功的标准是什么?这是你定义检查点的地方。你应该考虑你将查看哪些指标,以及你的目标模型性能应该是什么样的。如果你正在构建一个将在实时系统中使用的模型,那么你还可以将目标执行速度和数据可用性作为运行时的成功标准。设定这些成功标准将帮助你避免在某个步骤上停滞不前。

  • 数据收集:拥有数据是构建机器学习模型最基本和关键的部分,最好是大量的数据。没有数据,就没有模型。根据你的项目,你收集数据的方法可能会有所不同。你可以从其他供应商那里购买现有的数据源,你可以抓取网站并从中提取数据,你可以使用公开可用的数据,或者你也可以收集自己的数据。你可以用多种方式收集你需要的机器学习模型数据,但当你处于数据收集过程中时,你需要记住这两个数据要素——目标变量和特征变量。目标变量是预测的答案,特征变量是模型将用来学习如何预测目标变量的因素。通常,目标变量不会以标记的形式出现。例如,当你处理 Twitter 数据以预测每条推文的情感时,你可能没有每条推文的标记情感数据。在这种情况下,你将不得不额外一步来标记你的目标变量。一旦你收集了数据,你就可以继续到数据准备步骤。

  • 数据准备:一旦你收集了所有输入数据,你需要将其准备成可用的格式。这一步比你想象的更重要。如果你有杂乱的数据,并且没有为你的学习算法清理它们,你的算法将无法从你的数据集中很好地学习,并且不会按预期表现。此外,即使你拥有高质量的数据,如果你的数据格式不适合你的算法进行训练,那么拥有高质量数据也是没有意义的。数据差,模型差。你应该至少处理以下列出的常见问题,以便为下一步做好准备:

    • 文件格式:如果你从多个数据源获取数据,你很可能会遇到每个数据源都有不同的格式。一些数据可能以 CSV 格式存储,而其他数据可能以 JSON 或 XML 格式存储。一些数据甚至可能存储在关系型数据库中。为了训练你的机器学习模型,你首先需要将这些不同格式的数据源合并成一个标准格式。

    • 数据格式:也可能存在不同数据源之间数据格式不同的情况。例如,一些数据可能将地址字段拆分为街道地址、城市、州和邮政编码,而另一些则可能没有。一些数据可能使用美国日期格式(mm/dd/yyyy)表示日期字段,而另一些则可能使用英国格式(dd/mm/yyyy)。这些数据源之间的数据格式差异在解析值时可能会引起问题。为了训练你的机器学习模型,你需要为每个字段提供一个统一的数据格式。

    • 重复记录:通常你会在数据集中看到相同的记录重复出现。这个问题可能出现在数据收集过程中,你记录了一个数据点多次,或者在你准备数据的过程中合并不同的数据集时。重复的记录可能会对你的模型产生不利影响,因此在继续下一步之前检查数据集中的重复项是很好的做法。

    • 缺失值:在数据中看到一些记录有空值或缺失值也是常见的情况。在训练你的机器学习模型时,这也可能产生不利影响。处理数据中的缺失值有多种方法,但你必须非常小心,并且非常了解你的数据,因为这将极大地改变你的模型性能。你可以处理缺失值的方法包括删除包含缺失值的记录,用平均值或中位数替换缺失值,用常数替换缺失值,或者用虚拟变量和缺失指示变量替换缺失值。在处理缺失值之前研究你的数据将是有益的。

  • 数据分析:现在数据已经准备好了,是时候真正查看数据,看看你是否能识别出任何模式,并从数据中得出一些见解。总结统计量和图表是描述和理解数据的最有效方法之一。对于连续变量,查看最小值、最大值、平均值、中位数和四分位数是一个好的开始。对于分类变量,你可以查看各个类别的计数和百分比。当你查看这些总结统计量时,你还可以开始绘制图表来可视化数据的结构。以下图示展示了数据分析中常用的一些图表。直方图常用于显示和检查变量的潜在分布、异常值和偏度。箱线图常用于可视化五数摘要、异常值和偏度。成对散点图常用于检测变量之间明显的成对相关性:

数据分析和可视化。左上角:名义房屋销售价格的直方图,右上角:使用对数刻度的房屋销售价格直方图,左下角:地下室、一楼和二楼面积分布的箱线图,右下角:一楼和二楼面积之间的散点图

    • 特征工程:特征工程是应用机器学习模型构建过程中最重要的部分。然而,这是许多教科书和机器学习课程中讨论最少的话题之一。特征工程是将原始输入数据转换为算法可以从中学习的更信息化的数据的过程。例如,对于我们在第三章中将要构建的 Twitter 情感预测模型,Twitter 情感分析,你的原始输入数据可能只包含一个列中的文本列表和另一个列中的情感目标列表。你的机器学习模型可能无法从这些原始数据中很好地学习如何预测每条推文的情感。然而,如果你将数据转换成这样,每个列代表每条推文中每个单词的出现次数,那么你的学习算法就可以更容易地学习到某些单词的存在与情感之间的关系。你还可以将每个单词与其相邻的单词(二元组)分组,并将每条推文中每个二元组的出现次数作为另一组特征。正如这个例子所示,特征工程是一种使你的原始数据更具代表性和对潜在问题更信息化的方式。特征工程不仅是一门科学,也是一种艺术。特征工程需要良好的领域知识、从原始输入数据中构建新特征的创造力,以及多次迭代以获得更好的结果。随着我们学习这本书,我们将介绍如何使用一些自然语言处理NLP)技术构建文本特征,如何构建时间序列特征,如何子选择特征以避免过拟合问题,以及如何使用降维技术将高维数据转换为更少的维度。

提出特征是困难的,耗时,需要专业知识。应用机器学习基本上是特征工程。

-安德鲁·吴

  • 训练/测试算法:一旦你创建了你的特征,就是时候训练和测试一些机器学习算法了。在你开始训练你的模型之前,考虑一下性能指标是很好的。根据你要解决的问题,你的性能度量选择会有所不同。例如,如果你正在构建一个股票价格预测模型,你可能希望最小化你的预测与实际价格之间的差异,并选择均方根误差RMSE)作为你的性能指标。如果你正在构建一个信用模型来预测一个人是否可以获得贷款,你可能会想使用精确率作为你的性能指标,因为错误的贷款批准(假阳性)比错误的贷款拒绝(假阴性)有更大的负面影响。随着我们学习这些章节,我们将讨论每个项目的更具体的性能指标。

一旦您为您的模型确定了具体的性能指标,现在您可以训练和测试各种学习算法及其性能。根据您的预测目标,您选择的学习算法也会有所不同。以下图显示了某些常见机器学习问题的说明。如果您正在解决分类问题,您将想要训练分类器,例如逻辑回归模型、朴素贝叶斯分类器或随机森林分类器。另一方面,如果您有一个连续的目标变量,那么您将想要训练回归器,例如线性回归模型、k 近邻或支持向量机SVM)。如果您想通过无监督学习从数据中得出一些见解,您将想要使用 k 均值聚类或均值漂移算法:

图片

机器学习问题的说明。左:分类,中:回归,右:聚类

最后,我们必须考虑如何测试和评估我们尝试的学习算法的性能。将数据集分为训练集和测试集以及运行交叉验证是测试和比较您的机器学习模型最常用的两种方法。将数据集分为两个子集,一个用于训练,另一个用于测试的目的,是在训练集上训练模型而不暴露给测试集,这样测试集上的预测结果就可以指示模型在不可预见数据上的总体性能。K 折交叉验证是评估模型性能的另一种方法。它首先将数据集分为大小相等的 K 个子集,并留出一个子集用于测试,其余的用于训练。例如,在 3 折交叉验证中,数据集首先分为三个大小相等的子集。在第一次迭代中,我们将使用第 1 和第 2 折来训练我们的模型并在第 3 折上测试它。在第二次迭代中,我们将使用第 1 和第 3 折来训练并在第 2 折上测试我们的模型。在第三次迭代中,我们将使用第 2 和第 3 折来训练并在第 1 折上测试我们的模型。然后,我们将平均性能指标来估计模型性能:

  • 改进结果:到目前为止,您将有一个或两个表现合理的候选模型,但可能仍有改进的空间。也许您注意到您的候选模型在一定程度上过度拟合,也许它们没有达到您的目标性能,或者也许您有更多的时间来迭代您的模型——无论您的意图如何,都有多种方法可以提高您模型的表现,它们如下:

    • 超参数调整:您可以调整模型的配置以潜在地提高性能结果。例如,对于随机森林模型,您可以调整树的最大高度或森林中的树的数量。对于支持向量机(SVMs),您可以调整核或成本值。

    • 集成方法:集成是将多个模型的输出结果结合起来以获得更好的结果。Bagging 是在数据集的不同子集上训练相同的算法,Boosting 是将训练在同一训练集上的不同模型结合起来,而 Stacking 是将模型的输出作为元模型的输入,元模型学习如何组合子模型的输出。

    • 更多特征工程:在特征工程上进行迭代是提高模型性能的另一种方法。

  • 部署:是时候将您的模型投入实际应用了!一旦您的模型准备就绪,就是让它们在生产环境中运行的时候了。在您的模型全面接管之前,请确保进行彻底的测试。在模型性能随着时间的推移和输入数据的变化而降低的情况下,计划开发模型监控工具也将是有益的。

设置 C# 环境以进行机器学习

既然我们已经讨论了本书中我们将遵循的构建机器学习模型的步骤和方法,让我们开始设置我们的 C# 机器学习环境。我们首先将安装和设置 Visual Studio,然后安装两个我们将频繁在后续章节的项目中使用的包(Accord.NET 和 Deedle)。

设置 Visual Studio 以进行 C#

假设您对 C# 有一些先前的知识,我们将简要介绍这部分内容。如果您需要安装 Visual Studio for C#,请访问 www.visualstudio.com/downloads/ 并下载 Visual Studio 的一个版本。在本书中,我们使用 Visual Studio 2017 的社区版。如果您在安装 Visual Studio 之前被提示下载 .NET Framework,请访问 www.microsoft.com/en-us/download/details.aspx?id=53344 并先安装它。

安装 Accord.NET

Accord.NET 是一个 .NET 机器学习框架。在机器学习包之上,Accord.NET 框架还包括数学、统计学、计算机视觉、计算机听觉和其他科学计算模块。我们将主要使用 Accord.NET 框架的机器学习包。

一旦您安装并设置了 Visual Studio,让我们开始安装 C# 的机器学习框架 Accord.NET。通过 NuGet 安装它是最简单的。要安装它,打开包管理器(工具 | NuGet 包管理器 | 包管理器控制台),并输入以下命令安装 Accord.MachineLearningAccord.Controls

PM> Install-Package Accord.MachineLearning
PM> Install-Package Accord.Controls

现在,让我们使用这些 Accord.NET 包构建一个示例机器学习应用程序。打开您的 Visual Studio,在 Visual C# 类别下创建一个新的 控制台应用程序。使用前面的命令通过 NuGet 安装这些 Accord.NET 包,并将它们添加到我们的项目中。您应该在 解决方案资源管理器 中看到一些 Accord.NET 包被添加到您的引用中,结果应该类似于以下截图:

图片

我们现在要构建的模型是一个非常简单的逻辑回归模型。给定二维数组和预期输出,我们将开发一个程序来训练一个逻辑回归分类器,然后绘制结果,显示该模型的预期输出和实际预测。该模型的输入和输出如下所示:

图片

这个示例逻辑回归分类器的代码如下:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

using Accord.Controls;
using Accord.Statistics;
using Accord.Statistics.Models.Regression;
using Accord.Statistics.Models.Regression.Fitting;

namespace SampleAccordNETApp
{
    class Program
    {
        static void Main(string[] args)
        {
            double[][] inputs =
            {
                new double[] { 0, 0 },
                new double[] { 0.25, 0.25 }, 
                new double[] { 0.5, 0.5 }, 
                new double[] { 1, 1 },
            };

            int[] outputs =
            { 
                0,
                0,
                1,
                1,
            };

            // Train a Logistic Regression model
            var learner = new IterativeReweightedLeastSquares<LogisticRegression>()
            {
                MaxIterations = 100
            };
            var logit = learner.Learn(inputs, outputs);

            // Predict output
            bool[] predictions = logit.Decide(inputs);

            // Plot the results
            ScatterplotBox.Show("Expected Results", inputs, outputs);
            ScatterplotBox.Show("Actual Logistic Regression Output", inputs, predictions.ToZeroOne());

            Console.ReadKey();
        }
    }
}

一旦你写完这段代码,你可以通过按F5键或点击顶部的开始按钮来运行它。如果一切顺利,它应该会生成以下图中显示的两个图表。如果失败了,请检查引用或错误。你始终可以右键单击类名或灯泡图标,让 Visual Studio 帮助你找到命名空间引用中缺少的哪些包:

图片

样本程序生成的图表。左:实际预测结果,右:预期输出

这个示例代码可以在以下链接找到:github.com/yoonhwang/c-sharp-machine-learning/blob/master/ch.1/SampleAccordNETApp.cs

安装 Deedle

Deedle 是一个开源的.NET 库,用于数据框编程。Deedle 允许你以类似于 R 数据框和 Python 中的 pandas 数据框的方式处理数据。我们将在以下章节中使用这个包来加载和操作我们的机器学习项目数据。

与我们安装 Accord.NET 的方式类似,我们可以从 NuGet 安装 Deedle 包。打开包管理器(工具 | NuGet 包管理器 | 包管理器控制台),使用以下命令安装Deedle

PM> Install-Package Deedle

让我们简要看看我们如何使用这个包从 CSV 文件加载数据并进行简单的数据操作。更多详细信息,您可以访问bluemountaincapital.github.io/Deedle/以获取 API 文档和示例代码。我们将使用 2010 年到 2013 年的 AAPL 每日股价数据来完成这个练习。您可以从以下链接下载这些数据:github.com/yoonhwang/c-sharp-machine-learning/blob/master/ch.1/table_aapl.csv

打开你的 Visual Studio,在 Visual C#类别下创建一个新的控制台应用程序。使用前面的命令通过NuGet安装Deedle库,并将引用添加到你的项目中。你应该在你的解决方案资源管理器中看到添加了Deedle包的引用。

现在,我们将把 CSV 数据加载到Deedle数据框中,然后进行一些数据处理。首先,我们将使用Date字段更新数据框的索引。然后,我们将对OpenClose列应用一些算术运算来计算从开盘价到收盘价的百分比变化。最后,我们将通过计算收盘价与前一收盘价之间的差异,将它们除以前一收盘价,然后乘以100来计算每日回报率。以下是这个示例Deedle程序的代码:

using Deedle;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace DeedleApp
{
    class Program
    {
        static void Main(string[] args)
        {
            // Read AAPL stock prices from a CSV file
            var root = Directory.GetParent(Directory.GetCurrentDirectory()).Parent.FullName;
            var aaplData = Frame.ReadCsv(Path.Combine(root, "table_aapl.csv"));
            // Print the data
            Console.WriteLine("-- Raw Data --");
            aaplData.Print();

            // Set Date field as index
            var aapl = aaplData.IndexRows<String>("Date").SortRowsByKey();
            Console.WriteLine("-- After Indexing --");
            aapl.Print();

            // Calculate percent change from open to close
            var openCloseChange = 
                ((
                    aapl.GetColumn<double>("Close") - aapl.GetColumn<double>("Open")
                ) / aapl.GetColumn<double>("Open")) * 100.0;
            aapl.AddColumn("openCloseChange", openCloseChange);
            Console.WriteLine("-- Simple Arithmetic Operations --");
            aapl.Print();

            // Shift close prices by one row and calculate daily returns
            var dailyReturn = aapl.Diff(1).GetColumn<double>("Close") / aapl.GetColumn<double>("Close") * 100.0;
            aapl.AddColumn("dailyReturn", dailyReturn);
            Console.WriteLine("-- Shift --");
            aapl.Print();

            Console.ReadKey();
        }
    }
}

当你运行这段代码时,你会看到以下输出。

原始数据集看起来如下:

图片

在使用日期字段对数据集进行索引后,你会看到以下内容:

图片

在应用简单的算术运算来计算开盘价到收盘价的变化率后,你会看到以下内容:

图片

最后,在将收盘价移动一行并计算每日回报率之后,你会看到以下内容:

图片

从这个示例Deedle项目中可以看出,我们可以用一行或两行代码运行各种数据处理操作,而使用原生 C#进行相同的操作则需要更多的代码。在这本书中,我们将频繁使用Deedle库进行数据处理和特征工程。

这个示例Deedle代码可以在以下链接找到:github.com/yoonhwang/c-sharp-machine-learning/blob/master/ch.1/DeedleApp.cs

摘要

在本章中,我们简要讨论了一些关键的机器学习任务和机器学习的实际应用案例。我们还学习了开发机器学习模型的步骤以及每个步骤中常见的挑战和任务。在接下来的章节中,我们将遵循这些步骤进行我们的项目,并将更详细地探讨某些步骤,特别是特征工程、模型选择和模型性能评估。我们将根据我们解决问题的类型讨论在每个步骤中可以应用的各项技术。最后,在本章中,我们向您介绍了如何为我们的未来机器学习项目设置 C#环境。我们使用 Accord.NET 框架构建了一个简单的逻辑回归分类器,并使用Deedle库来加载数据和处理数据。

在下一章中,我们将直接应用本章所涵盖的机器学习(ML)基础知识,来构建一个用于垃圾邮件过滤的 ML 模型。我们将遵循本章讨论的构建 ML 模型的步骤,将原始电子邮件数据转换为结构化数据集,分析电子邮件文本数据以获取一些见解,并最终构建预测电子邮件是否为垃圾邮件的分类模型。我们还将讨论下一章中一些常用的分类模型评估指标。

第二章:垃圾邮件过滤

在本章中,我们将开始使用我们在第一章“机器学习建模基础”中安装的两个包,即 Accord.NET for ML 和 Deedle for data manipulation,在 C#中构建真实的机器学习ML)模型。在本章中,我们将构建一个用于垃圾邮件过滤的分类模型。我们将使用包含垃圾邮件和正常邮件(非垃圾邮件)的原始电子邮件数据集来训练我们的 ML 模型。我们将开始遵循上一章中讨论的 ML 模型开发步骤。这将帮助我们更好地理解 ML 建模的工作流程和方法,并使它们变得自然而然。在我们努力构建垃圾邮件分类模型的同时,我们还将讨论文本数据集的特征工程技术和分类模型的初步验证方法,并比较逻辑回归分类器和朴素贝叶斯分类器在垃圾邮件过滤中的应用。熟悉这些模型构建步骤、基本的文本特征工程技术和基本的分类模型验证方法将为使用自然语言处理NLP)进行更高级的特征工程以及在第三章“Twitter 情感分析”中构建多类分类模型奠定基础。

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

  • 垃圾邮件过滤项目的定义问题

  • 数据准备

  • 邮件数据分析

  • 邮件数据特征工程

  • 逻辑回归与朴素贝叶斯在垃圾邮件过滤中的应用

  • 分类模型验证

垃圾邮件过滤项目的定义问题

让我们先定义一下本章将要解决的问题。你可能已经熟悉了垃圾邮件是什么;垃圾邮件过滤是像 Gmail、Yahoo Mail 和 Outlook 这样的电子邮件服务的基本功能。垃圾邮件可能会让用户感到烦恼,但它们带来了更多的问题和风险。例如,垃圾邮件可能被设计成索要信用卡号码或银行账户信息,这些信息可能被用于信用卡欺诈或洗钱。垃圾邮件也可能被用来获取个人信息,如社会保障号码或用户 ID 和密码,然后可以用来进行身份盗窃和其他各种犯罪。拥有垃圾邮件过滤技术是电子邮件服务保护用户免受此类犯罪侵害的关键步骤。然而,拥有正确的垃圾邮件过滤解决方案是困难的。你希望过滤掉可疑邮件,但同时,你又不希望过滤太多,以至于非垃圾邮件被放入垃圾邮件文件夹,用户永远不会查看。为了解决这个问题,我们将让我们的机器学习模型从原始电子邮件数据集中学习,并使用主题行将可疑邮件分类为垃圾邮件。我们将查看两个性能指标来衡量我们的成功:精确率和召回率。我们将在以下章节中详细讨论这些指标。

总结我们的问题定义:

  • 问题是啥?我们需要一个垃圾邮件过滤解决方案,以防止我们的用户成为欺诈活动的受害者,同时提高用户体验。

  • 为什么这是一个问题?在过滤可疑邮件和不过度过滤之间取得平衡,使得非垃圾邮件仍然进入收件箱,是困难的。我们将依赖机器学习模型来学习如何从统计上分类这类可疑邮件。

  • 解决这个问题的方法有哪些?我们将构建一个分类模型,根据邮件的主题行标记潜在的垃圾邮件。我们将使用精确率和召回率作为平衡过滤邮件数量的方式。

  • 成功的标准是什么?我们希望有高的召回率(实际垃圾邮件被检索的百分比与垃圾邮件总数的比例),同时不牺牲太多的精确率(被预测为垃圾邮件的正确分类垃圾邮件的百分比)。

数据准备

既然我们已经清楚地陈述并定义了我们打算使用机器学习解决的问题,我们就需要数据。没有数据,就没有机器学习。通常,在数据准备步骤之前,您需要额外的一步来收集和整理所需的数据,但在这本书中,我们将使用一个预先编译并标记的公开可用的数据集。在本章中,我们将使用 CSDMC2010 SPAM 语料库数据集(csmining.org/index.php/spam-email-datasets-.html)来训练和测试我们的模型。您可以点击链接并下载网页底部的压缩数据。当您下载并解压缩数据后,您将看到两个名为TESTINGTRAINING的文件夹,以及一个名为SPAMTrain.label的文本文件。SPAMTrain.label文件包含了TRAINING文件夹中每封电子邮件的编码标签——0代表垃圾邮件,1代表非垃圾邮件(非垃圾邮件)。我们将使用这个文本文件以及TRAINING文件夹中的电子邮件数据来构建垃圾邮件分类模型。

一旦您下载了数据并将其放置在可以从中加载的位置,您需要为未来的特征工程和模型构建步骤准备它。我们现在有一个包含多个包含有关单个电子邮件信息的 EML 文件和包含标记信息的文本文件的原始数据集。为了使这个原始数据集可用于使用电子邮件主题行构建垃圾邮件分类模型,我们需要执行以下任务:

  1. 从 EML 文件中提取主题行:为准备我们的数据以供未来任务使用,第一步是从单个 EML 文件中提取主题和正文。我们将使用一个名为EAGetMail的包来加载和提取 EML 文件中的信息。您可以使用 Visual Studio 中的包管理器来安装此包。请查看代码的第 4 到 6 行以了解如何安装此包。使用EAGetMail包,您可以轻松地加载和提取 EML 文件的主题和正文内容(第 24-30 行)。一旦您从电子邮件中提取了主题和正文,您需要将每行数据作为一行追加到一个 Deedle 数据框中。请查看以下代码中的ParseEmails函数(从第 18 行开始),以了解如何创建一个 Deedle 数据框,其中每行包含每封电子邮件的索引号、主题行和正文内容。

  2. 将提取的数据与标签合并:在从单个 EML 文件中提取主题和正文内容之后,我们还需要做一件事。我们需要将编码后的标签(0 表示垃圾邮件,1 表示正常邮件)映射到我们在上一步创建的 DataFrame 的每一行。如果您用任何文本编辑器打开SPAMTrain.label文件,您会看到编码的标签位于第一列,相应的电子邮件文件名位于第二列,由空格分隔。使用 Deedle 框架的ReadCsv函数,您可以通过指定空格作为分隔符轻松地将这些标签数据加载到 DataFrame 中(请参阅代码中的第 50 行)。一旦您将标记数据加载到 DataFrame 中,您只需使用 Deedle 框架的AddColumn函数将此 DataFrame 的第一列添加到我们在上一步创建的另一个 DataFrame 中。查看以下代码的第 49-52 行,了解我们如何将标签信息与提取的电子邮件数据合并。

  3. 将合并后的数据导出为 CSV 文件:现在我们有一个包含邮件和标签数据的 DataFrame,是时候将这个 DataFrame 导出为 CSV 文件以供将来使用。如以下代码的第 54 行所示,导出 DataFrame 到 CSV 文件只需要一行代码。使用 Deedle 框架的SaveCsv函数,您可以轻松地将 DataFrame 保存为 CSV 文件。

此数据准备步骤的代码如下:

// Install-Package Deedle
// Install-Package FSharp.Core
using Deedle;
// if you don't have EAGetMail package already, install it 
// via the Package Manager Console by typing in "Install-Package EAGetMail"
using EAGetMail;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace EmailParser
{
    class Program
    {
        private static Frame<int, string> ParseEmails(string[] files)
        {
            // we will parse the subject and body from each email
            // and store each record into key-value pairs
            var rows = files.AsEnumerable().Select((x, i) =>
            {
                // load each email file into a Mail object
                Mail email = new Mail("TryIt");
                email.Load(x, false);

                // extract the subject and body
                string emailSubject = email.Subject;
                string textBody = email.TextBody;

                // create key-value pairs with email id (emailNum), subject, and body
                return new { emailNum = i, subject = emailSubject, body = textBody };
            });

            // make a data frame from the rows that we just created above
            return Frame.FromRecords(rows);
        }

        static void Main(string[] args)
        {
            // Get all raw EML-format files
            // TODO: change the path to point to your data directory
            string rawDataDirPath = "<path-to-data-directory>";
            string[] emailFiles = Directory.GetFiles(rawDataDirPath, "*.eml");

            // Parse out the subject and body from the email files
            var emailDF = ParseEmails(emailFiles);
            // Get the labels (spam vs. ham) for each email
            var labelDF = Frame.ReadCsv(rawDataDirPath + "\\SPAMTrain.label", hasHeaders: false, separators: " ", schema: "int,string");
            // Add these labels to the email data frame
            emailDF.AddColumn("is_ham", labelDF.GetColumnAt<String>(0));
            // Save the parsed emails and labels as a CSV file
            emailDF.SaveCsv("transformed.csv");

            Console.WriteLine("Data Preparation Step Done!");
            Console.ReadKey();
        }
    }
}

在运行此代码之前,您需要将第 44 行中的<path-to-data-directory>替换为您存储数据的实际路径。运行此代码后,应创建一个名为transformed.csv的文件,它将包含四列(emailNumsubjectbodyis_ham)。我们将使用此输出数据作为以下步骤构建用于垃圾邮件过滤项目的机器学习模型的输入。不过,您可以自由发挥创意,尝试使用 Deedle 框架和EAGetMail包以不同的方式调整和准备这些数据。我们在这里展示的代码是准备原始邮件数据以供将来使用的一种方法,以及您可以从原始邮件数据中提取的一些信息。使用EAGetMail包,您可以提取其他特征,例如发件人的电子邮件地址和邮件中的附件,这些额外特征可能有助于提高您的垃圾邮件分类模型。

此数据准备步骤的代码也可以在以下仓库中找到:github.com/yoonhwang/c-sharp-machine-learning/blob/master/ch.2/EmailParser.cs.

邮件数据分析

在数据准备步骤中,我们将原始数据集转换成了一个更易于阅读和使用的数据集。现在我们有一个文件可以查看,以确定哪些电子邮件是垃圾邮件,哪些不是。此外,我们还可以轻松地找到垃圾邮件和非垃圾邮件的主题行。使用这个转换后的数据,让我们开始查看数据的实际样子,看看我们能否在数据中找到任何模式或问题。

由于我们处理的是文本数据,我们首先想查看的是垃圾邮件和非垃圾邮件之间单词分布的差异。为了做到这一点,我们需要将之前步骤输出的数据转换成单词出现的矩阵表示。让我们一步一步地来做,以我们数据中的前三个主题行为例。我们拥有的前三个主题行如下所示:

图片

如果我们将这些数据转换成这样,即每一列对应每个主题行中的每个单词,并将每个单元格的值编码为1,如果给定的主题行包含该单词,否则为0,那么得到的矩阵看起来可能如下所示:

图片

这种特定的编码方式被称为独热编码,我们只关心特定单词是否出现在主题行中,而不关心每个单词在主题行中实际出现的次数。在上述情况下,我们还移除了所有的标点符号,例如冒号、问号和感叹号。为了程序化地完成这项工作,我们可以使用正则表达式将每个主题行分割成只包含字母数字字符的单词,然后使用独热编码构建一个数据框。执行此编码步骤的代码如下所示:

private static Frame<int, string> CreateWordVec(Series<int, string> rows)
{
    var wordsByRows = rows.GetAllValues().Select((x, i) =>
    {
        var sb = new SeriesBuilder<string, int>();

        ISet<string> words = new HashSet<string>(
            Regex.Matches(
                // Alphanumeric characters only
                x.Value, "\\w+('(s|d|t|ve|m))?"
            ).Cast<Match>().Select(
                // Then, convert each word to lowercase
                y => y.Value.ToLower()
            ).ToArray()
        );

        // Encode words appeared in each row with 1
        foreach (string w in words)
        {
            sb.Add(w, 1);
        }

        return KeyValue.Create(i, sb.Series);
    });

    // Create a data frame from the rows we just created
    // And encode missing values with 0
    var wordVecDF = Frame.FromRows(wordsByRows).FillMissing(0);

    return wordVecDF;
}

拥有这种独热编码的单词矩阵表示形式使我们的数据分析过程变得更加容易。例如,如果我们想查看垃圾邮件中前十位频繁出现的单词,我们只需简单地对垃圾邮件独热编码单词矩阵的每一列求和,然后取求和值最高的十个单词。这正是我们在以下代码中所做的:

var hamTermFrequencies = subjectWordVecDF.Where(
    x => x.Value.GetAs<int>("is_ham") == 1
).Sum().Sort().Reversed.Where(x => x.Key != "is_ham");

var spamTermFrequencies = subjectWordVecDF.Where(
    x => x.Value.GetAs<int>("is_ham") == 0
).Sum().Sort().Reversed;

// Look at Top 10 terms that appear in Ham vs. Spam emails
var topN = 10;

var hamTermProportions = hamTermFrequencies / hamEmailCount;
var topHamTerms = hamTermProportions.Keys.Take(topN);
var topHamTermsProportions = hamTermProportions.Values.Take(topN);

System.IO.File.WriteAllLines(
    dataDirPath + "\\ham-frequencies.csv",
    hamTermFrequencies.Keys.Zip(
        hamTermFrequencies.Values, (a, b) => string.Format("{0},{1}", a, b)
    )
);

var spamTermProportions = spamTermFrequencies / spamEmailCount;
var topSpamTerms = spamTermProportions.Keys.Take(topN);
var topSpamTermsProportions = spamTermProportions.Values.Take(topN);

System.IO.File.WriteAllLines(
    dataDirPath + "\\spam-frequencies.csv",
    spamTermFrequencies.Keys.Zip(
        spamTermFrequencies.Values, (a, b) => string.Format("{0},{1}", a, b)
    )
);

如您从这段代码中可以看到,我们使用了 Deedle 数据框的Sum方法对每一列的值进行求和,并按降序排序。我们为垃圾邮件和 ham 邮件各做一次。然后,我们使用Take方法获取在垃圾邮件和 ham 邮件中出现频率最高的前十个单词。运行此代码将生成两个 CSV 文件:ham-frequencies.csvspam-frequencies.csv。这两个文件包含有关垃圾邮件和 ham 邮件中单词出现次数的信息,我们将在后续的特征工程和模型构建步骤中使用这些信息。

现在,让我们可视化一些数据以进行进一步分析。首先,看一下以下关于数据集中 ham 电子邮件中前十位频繁出现的术语的图表:

图片

一个条形图,用于展示正常邮件中频率最高的前十项术语

如从该条形图中可以看出,在数据集中,正常邮件的数量多于垃圾邮件,这与现实世界的情况相符。我们通常在我们的收件箱中收到比垃圾邮件更多的正常邮件。我们使用了以下代码来生成此条形图,以可视化数据集中正常邮件和垃圾邮件的分布:

var barChart = DataBarBox.Show(
    new string[] { "Ham", "Spam" },
    new double[] {
        hamEmailCount,
        spamEmailCount
    }
);
barChart.SetTitle("Ham vs. Spam in Sample Set");

使用 Accord.NET 框架中的DataBarBox类,我们可以轻松地将数据可视化在条形图中。现在让我们可视化正常邮件和垃圾邮件中频率最高的前十项术语。你可以使用以下代码生成正常邮件和垃圾邮件中前十项术语的条形图:

var hamBarChart = DataBarBox.Show(
    topHamTerms.ToArray(),
    new double[][] {
        topHamTermsProportions.ToArray(),
        spamTermProportions.GetItems(topHamTerms).Values.ToArray()
    }
);
hamBarChart.SetTitle("Top 10 Terms in Ham Emails (blue: HAM, red: SPAM)");

var spamBarChart = DataBarBox.Show(
    topSpamTerms.ToArray(),
    new double[][] {
        hamTermProportions.GetItems(topSpamTerms).Values.ToArray(),
        topSpamTermsProportions.ToArray()
    }
);
spamBarChart.SetTitle("Top 10 Terms in Spam Emails (blue: HAM, red: SPAM)");

类似地,我们使用了DataBarBox类来显示条形图。当你运行此代码时,你会看到以下条形图,用于展示正常邮件中频率最高的前十项术语:

一个展示正常邮件中频率最高的前十项术语的图表

垃圾邮件中频率最高的前十项术语的条形图如下所示:

一个条形图,用于展示垃圾邮件中频率最高的前十项术语

如预期的那样,垃圾邮件中的单词分布与非垃圾邮件有很大的不同。例如,如果你看右边的图表,单词垃圾邮件hibody在垃圾邮件中频繁出现,但在非垃圾邮件中并不常见。然而,有些事情并不合理。如果你仔细观察,单词试用版本出现在所有的垃圾邮件和正常邮件中,这很不可能是真的。如果你在文本编辑器中打开一些原始的 EML 文件,你可以很容易地发现并非所有的邮件都包含这两个单词在它们的主题行中。那么,发生了什么?我们的数据是否在之前的数据准备或数据分析步骤中受到了污染?

进一步的研究表明,我们使用的其中一个软件包导致了这个问题。我们使用的EAGetMail软件包,用于加载和提取电子邮件内容,当我们使用他们的试用版时,会自动将(Trial Version)附加到主题行的末尾。既然我们已经知道了这个数据问题的根本原因,我们需要回去修复它。一个解决方案是回到数据准备步骤,并更新我们的ParseEmails函数,使用以下代码,该代码简单地从主题行中删除附加的(Trial Version)标志:

private static Frame<int, string> ParseEmails(string[] files)
{
    // we will parse the subject and body from each email
    // and store each record into key-value pairs
    var rows = files.AsEnumerable().Select((x, i) =>
    {
        // load each email file into a Mail object
        Mail email = new Mail("TryIt");
        email.Load(x, false);

        // REMOVE "(Trial Version)" flags
        string EATrialVersionRemark = "(Trial Version)"; // EAGetMail appends subjects with "(Trial Version)" for trial version
        string emailSubject = email.Subject.EndsWith(EATrialVersionRemark) ? 
            email.Subject.Substring(0, email.Subject.Length - EATrialVersionRemark.Length) : email.Subject;
        string textBody = email.TextBody;

        // create key-value pairs with email id (emailNum), subject, and body
        return new { emailNum = i, subject = emailSubject, body = textBody };
    });

    // make a data frame from the rows that we just created above
    return Frame.FromRecords(rows);
}

在更新此代码并再次运行之前的数据准备和分析代码后,单词分布的条形图变得更加有意义。

以下条形图显示了修复并移除(Trial Version)标志后的正常邮件中频率最高的前十项术语:

以下条形图显示了修复并移除(Trial Version)标志后的垃圾邮件中频率最高的前十项术语:

这是在构建机器学习模型时数据分析步骤重要性的一个很好的例子。在数据准备和数据分析步骤之间迭代是非常常见的,因为我们通常在分析步骤中发现数据问题,并且我们通常可以通过更新数据准备步骤中使用的部分代码来提高数据质量。现在我们已经有了以矩阵形式表示主题行中使用的单词的干净数据,是时候开始着手构建机器学习模型所使用的实际特征了。

邮件数据的特征工程

在上一步中,我们简要地查看了一下垃圾邮件和正常邮件的单词分布,并注意到了一些事情。首先,大多数最频繁出现的单词是常用词,意义不大。例如,像 tothefora 这样的单词是常用词,我们的机器学习算法从这些单词中不会学到很多东西。这类单词被称为停用词,通常会被忽略或从特征集中删除。我们将使用 NLTK 的停用词列表来过滤掉特征集中的常用词。您可以从这里下载 NLTK 的停用词列表:github.com/yoonhwang/c-sharp-machine-learning/blob/master/ch.2/stopwords.txt。过滤掉这些停用词的一种方法如下所示:

// Read in stopwords list
ISet<string> stopWords = new HashSet<string>(
    File.ReadLines("<path-to-your-stopwords.txt>")
);
// Filter out stopwords from the term frequency series
var spamTermFrequenciesAfterStopWords = spamTermFrequencies.Where(
    x => !stopWords.Contains(x.Key)
);

在过滤掉这些停用词后,非垃圾邮件的新十大高频词如下:

图片

在过滤掉停用词后,垃圾邮件的前十大高频词如下所示:

图片

如您从这些条形图中可以看到,从特征集中过滤掉这些停用词使得更有意义的单词出现在高频出现的单词列表中。然而,我们还可以注意到另一件事。数字似乎出现在一些高频出现的单词中。例如,数字 32 成为了垃圾邮件中前十位高频出现的单词。数字 8070 成为了垃圾邮件中前十位高频出现的单词。然而,很难确定这些数字是否会在训练机器学习模型以将电子邮件分类为垃圾邮件或正常邮件时做出很大贡献。有多种方法可以从特征集中过滤掉这些数字,但在这里我们将向您展示一种方法。我们更新了之前步骤中使用的 regex,以匹配仅包含字母字符的单词,而不是字母数字字符。以下代码显示了如何更新 CreateWordVec 函数以从特征集中过滤掉数字:

private static Frame<int, string> CreateWordVec(Series<int, string> rows)
{
    var wordsByRows = rows.GetAllValues().Select((x, i) =>
    {
        var sb = new SeriesBuilder<string, int>();

        ISet<string> words = new HashSet<string>(
            Regex.Matches(
                // Alphabetical characters only
                x.Value, "[a-zA-Z]+('(s|d|t|ve|m))?"
            ).Cast<Match>().Select(
                // Then, convert each word to lowercase
                y => y.Value.ToLower()
            ).ToArray()
        );

        // Encode words appeared in each row with 1
        foreach (string w in words)
        {
            sb.Add(w, 1);
        }

        return KeyValue.Create(i, sb.Series);
    });

    // Create a data frame from the rows we just created
    // And encode missing values with 0
    var wordVecDF = Frame.FromRows(wordsByRows).FillMissing(0);

    return wordVecDF;
}

一旦我们从特征集中过滤掉这些数字,垃圾邮件的单词分布看起来如下:

图片

并且在过滤掉特征集中的数字后,垃圾邮件的单词分布如下所示:

图片

如从这些条形图中可以看出,我们列出了更有意义的词语,并且垃圾邮件和正常邮件的词语分布似乎有更大的区别。那些在垃圾邮件中频繁出现的词语似乎在正常邮件中很少出现,反之亦然。

数据分析和特征工程步骤的完整代码可以在以下仓库中找到:github.com/yoonhwang/c-sharp-machine-learning/blob/master/ch.2/DataAnalyzer.cs。运行此代码后,将生成条形图,显示垃圾邮件和正常邮件中的词语分布,以及两个 CSV 文件——一个用于包含出现次数的词语列表(正常邮件),另一个用于包含出现次数的词语列表(垃圾邮件)。在下一节构建分类模型进行垃圾邮件过滤时,我们将使用这个词频输出进行特征选择过程。

逻辑回归与朴素贝叶斯在电子邮件垃圾邮件过滤中的应用

我们已经走了很长的路,终于用 C# 构建了我们第一个机器学习模型。在本节中,我们将训练逻辑回归和朴素贝叶斯分类器,将电子邮件分类为垃圾邮件和正常邮件。我们将运行这两个学习算法的交叉验证,以估计并更好地理解我们的分类模型在实际应用中的表现。如前一章简要讨论的,在 k 折交叉验证中,训练集被分成 k 个大小相等的子集,其中一个 k 个子集被保留作为验证集,其余的 k-1 个子集用于训练模型。然后重复这个过程 k 次,其中每个迭代使用不同的子集或折作为验证集进行测试,然后将相应的 k 个验证结果平均报告为一个估计值。

首先,让我们看看如何使用 Accord.NET 框架在 C# 中通过逻辑回归实现交叉验证算法的实例化。代码如下:

var cvLogisticRegressionClassifier = CrossValidation.Create<LogisticRegression, IterativeReweightedLeastSquares<LogisticRegression>, double[], int>(
    // number of folds
    k: numFolds,
    // Learning Algorithm
    learner: (p) => new IterativeReweightedLeastSquares<LogisticRegression>()
    {
        MaxIterations = 100,
        Regularization = 1e-6
    },
    // Using Zero-One Loss Function as a Cost Function
    loss: (actual, expected, p) => new ZeroOneLoss(expected).Loss(actual),
    // Fitting a classifier
    fit: (teacher, x, y, w) => teacher.Learn(x, y, w),
    // Input with Features
    x: input,
    // Output
    y: output
);

// Run Cross-Validation
var result = cvLogisticRegressionClassifier.Learn(input, output);

让我们更深入地看看这段代码。我们可以通过提供要训练的模型类型、拟合模型的算法类型、输入数据类型和输出数据类型,使用静态 Create 函数创建一个新的 CrossValidation 算法。在这个例子中,我们创建了一个新的 CrossValidation 算法,其中 LogisticRegression 作为模型,IterativeReweightedLeastSquares 作为学习算法,双精度数组作为输入类型,整数作为输出类型(每个标签)。你可以尝试不同的学习算法来训练逻辑回归模型。在 Accord.NET 中,你可以选择随机梯度下降算法 (LogisticGradientDescent) 作为拟合逻辑回归模型的学习算法。

对于参数,你可以指定 k 折交叉验证的折数(k),具有自定义参数的学习方法(learner),你选择的损失/成本函数(loss),以及一个知道如何使用学习算法(fit)、输入(x)和输出(y)来拟合模型的功能。为了本节说明的目的,我们为 k 折交叉验证设置了一个相对较小的数字,3。此外,我们为最大迭代次数选择了相对较小的数字,100,以及相对较大的数字,1e-6 或 1/1,000,000,用于IterativeReweightedLeastSquares学习算法的正则化。对于损失函数,我们使用了一个简单的零一损失函数,其中对于正确预测分配 0,对于错误预测分配 1。这是我们学习算法试图最小化的成本函数。所有这些参数都可以进行不同的调整。你可以选择不同的损失/成本函数,k 折交叉验证中使用的折数,以及学习算法的最大迭代次数和正则化数字。你甚至可以使用不同的学习算法来拟合逻辑回归模型,例如LogisticGradientDescent,它迭代地尝试找到一个损失函数的局部最小值。

我们可以将这种方法应用于使用 k 折交叉验证训练朴素贝叶斯分类器。使用朴素贝叶斯学习算法运行 k 折交叉验证的代码如下:

var cvNaiveBayesClassifier = CrossValidation.Create<NaiveBayes<BernoulliDistribution>, NaiveBayesLearning<BernoulliDistribution>, double[], int>(
    // number of folds
    k: numFolds,
    // Naive Bayes Classifier with Binomial Distribution
    learner: (p) => new NaiveBayesLearning<BernoulliDistribution>(),
    // Using Zero-One Loss Function as a Cost Function
    loss: (actual, expected, p) => new ZeroOneLoss(expected).Loss(actual),
    // Fitting a classifier
    fit: (teacher, x, y, w) => teacher.Learn(x, y, w),
    // Input with Features
    x: input,
    // Output
    y: output
);

// Run Cross-Validation
var result = cvNaiveBayesClassifier.Learn(input, output);

之前用于逻辑回归模型的代码与这段代码之间的唯一区别是我们选择的不同模型和学习算法。我们不是使用LogisticRegressionIterativeReweightedLeastSquares,而是使用了NaiveBayes作为模型,并使用NaiveBayesLearning作为学习算法来训练我们的朴素贝叶斯分类器。由于我们的所有输入值都是二元的(要么是 0,要么是 1),我们为我们的朴素贝叶斯分类器模型使用了BernoulliDistribution

训练和验证具有 k 折交叉验证的分类模型的完整代码可以在以下仓库中找到:github.com/yoonhwang/c-sharp-machine-learning/blob/master/ch.2/Modeling.cs。当你运行此代码时,你应该会看到一个类似以下输出的结果:

图片

在下一节中,我们将更详细地探讨这些数字代表什么,其中我们将讨论模型验证方法。为了尝试不同的机器学习模型,只需修改代码中的第 68-88 行。你可以用我们之前讨论过的逻辑回归模型代码替换这些行,或者你也可以尝试拟合你选择的不同学习算法。

分类模型验证

我们在上一节中使用了 C#和 Accord.NET 框架构建了我们非常第一个机器学习模型。然而,我们还没有完成。如果我们更仔细地查看之前的控制台输出,有一件事相当令人担忧。训练错误率大约是 0.03,但验证错误率大约是 0.26。这意味着我们的分类模型在训练集中正确预测了 100 次中的 87 次,但在验证或测试集中的模型预测只有 100 次中的 74 次是正确的。这是一个典型的过拟合例子,其中模型与训练集拟合得太紧密,以至于其对未预见数据集的预测是不可靠的和不可预测的。如果我们将这个模型用于生产中的垃圾邮件过滤系统,实际过滤垃圾邮件的性能将是不可靠的,并且与我们在训练集中看到的不同。

过拟合通常是因为模型对于给定的数据集来说过于复杂,或者使用了过多的参数来拟合模型。我们在上一节中构建的朴素贝叶斯分类器模型中存在的过拟合问题很可能是由于模型的复杂性和我们用来训练模型的特征数量。如果你再次查看上一节末尾的控制台输出,你可以看到我们用来训练朴素贝叶斯模型的特征数量是 2,212。考虑到我们的样本集中只有大约 4,200 封电子邮件记录,而且其中只有大约三分之二(或者说大约 3,000 条记录)被用来训练我们的模型(这是因为我们使用了三折交叉验证,并且每次迭代中只有其中的两个折被用作训练集),这实在太多了。为了修复这个过拟合问题,我们将不得不减少我们用来训练模型的特征数量。为了做到这一点,我们可以过滤掉那些出现频率不高的术语。执行此操作的代码位于上一节完整代码的第 48-53 行,如下所示:

// Change number of features to reduce overfitting
int minNumOccurrences = 1;
string[] wordFeatures = indexedSpamTermFrequencyDF.Where(
    x => x.Value.GetAs<int>("num_occurences") >= minNumOccurrences
).RowKeys.ToArray();
Console.WriteLine("Num Features Selected: {0}", wordFeatures.Count());

如你所见,我们在上一节中构建的朴素贝叶斯分类器模型使用了在垃圾邮件中至少出现一次的所有单词。如果你查看垃圾邮件中的单词频率,大约有 1,400 个单词只出现了一次(查看在数据分析步骤中创建的spam-frequencies.csv文件)。直观上看,这些出现次数低的单词只会产生噪声,而不是为我们的模型提供很多学习信息。这立即告诉我们,当我们最初在上一节中构建我们的分类模型时,我们的模型会暴露于多少噪声。

既然我们已经知道了这个过拟合问题的原因,让我们来修复它。让我们尝试使用不同的阈值来选择特征。我们已经尝试了 5、10、15、20 和 25 作为垃圾邮件中最低出现次数(即我们将minNumOccurrences设置为 5、10、15 等等)并使用这些阈值训练了朴素贝叶斯分类器。

首先,具有至少五次出现的朴素贝叶斯分类器结果如下:

图片

具有至少 10 次出现的朴素贝叶斯分类器结果如下:

图片

具有至少 15 次出现的朴素贝叶斯分类器结果如下:

图片

最后,具有至少 20 次出现的朴素贝叶斯分类器结果如下:

图片

从这些实验结果中可以看出,随着我们增加最小单词出现次数并相应地减少用于训练模型的特征数量,训练错误验证错误之间的差距减小,训练错误开始看起来更接近验证错误。当我们解决了过拟合问题,我们可以对模型在不可预见的数据和在生产系统中的表现更有信心。我们使用逻辑回归分类模型进行了相同的实验,结果与朴素贝叶斯分类器发现的结果相似。逻辑回归模型的实验结果如下所示。

首先,具有至少五次出现的逻辑回归分类器结果如下:

图片

具有至少十次出现的逻辑回归分类器结果如下:

图片

具有至少 15 次出现的逻辑回归分类器结果如下:

图片

具有至少 20 次出现的逻辑回归分类器结果如下:

图片

现在我们已经讨论了如何处理过拟合问题,还有一些模型性能指标我们想要查看:

  • 混淆矩阵:混淆矩阵是一个表格,它告诉我们预测模型的总体性能。每一列代表每个实际类别,每一行代表每个预测类别。在二元分类问题的案例中,混淆矩阵将是一个 2 x 2 的矩阵,其中第一行代表负预测,第二行代表正预测。第一列代表实际负值,第二列代表实际正值。以下表格说明了二元分类问题的混淆矩阵中每个单元格代表的内容:

图片

  • 真负TN)是指模型正确预测了类别 0;假负FN)是指模型预测为0,但实际类别是1假正FP)是指模型预测为类别1,但实际类别是0;而真正TP)是指模型正确预测了类别1。从表中可以看出,混淆矩阵描述了整体模型性能。在我们的例子中,如果我们查看之前截图中的最后一个控制台输出,其中显示了我们的逻辑回归分类模型的控制台输出,我们可以看到 TNs 的数量为2847,FNs 的数量为606,FPs 的数量为102,TPs 的数量为772。有了这些信息,我们可以进一步计算真正正率TPR)、真正负率TNR)、假正率FPR)和假负率FNR)如下:

图片

使用前面的例子,我们例子中的真正正率(TPR)为 0.56,真正负率(TNR)为 0.97,假正率(FPR)为 0.03,假负率(FNR)为 0.44。

  • 准确率: 准确率是指正确预测的比例。使用之前例子混淆矩阵中的相同符号,准确率可以计算如下:

图片

准确率是一个常用的模型性能指标,但有时它并不能很好地代表整体模型性能。例如,如果样本集大部分不平衡,比如说在我们的样本集中有五个垃圾邮件和 95 封正常邮件,那么一个简单地将所有邮件分类为正常邮件的分类器将不得不达到 95%的准确率。然而,它永远不会捕获垃圾邮件。这就是为什么我们需要查看混淆矩阵和其他性能指标,如精确率和召回率:

  • 精确率: 精确率是指正确预测的正例数量与总预测正例数量的比例。使用与之前相同的符号,我们可以计算精确率如下:

图片

如果您查看之前截图中的最后一个控制台输出,我们的逻辑回归分类模型结果中的精确率是通过将混淆矩阵中的 TPs 数量,即 772,除以 TPs 和 FPs 的总和,即 772 和 102,得到的,结果为 0.88。

  • 召回率: 召回率是指正确预测的正例数量与实际正例总数量的比例。这是告诉我们模型检索了多少实际正例的一种方式。使用与之前相同的符号,我们可以计算召回率如下:

图片

如果你查看上一张截图中的最后一个控制台输出,即我们的逻辑回归分类模型结果,召回率是通过将混淆矩阵中 TP(真阳性)的数量,772,除以 TP(真阳性)和 FN(假阴性)的总和,772 和 606,得到的,结果是 0.56。

使用这些性能指标,数据科学家的责任是选择最优模型。精确率和召回率之间总会存在权衡。一个精确率高于其他模型的模型将具有较低的召回率。在我们的垃圾邮件过滤问题中,如果你认为正确过滤掉垃圾邮件更重要,并且你可以牺牲一些通过用户收件箱的垃圾邮件,那么你可能希望优化精确率。另一方面,如果你认为过滤掉尽可能多的垃圾邮件更重要,即使你可能会过滤掉一些非垃圾邮件,那么你可能希望优化召回率。选择正确的模型不是一个容易的决定,思考需求和成功标准对于做出正确的选择至关重要。

总结来说,以下是我们可以从交叉验证结果和混淆矩阵中计算性能指标的代码:

  • 训练与验证(测试)错误:用于识别过拟合问题(第 48-52 行):
// Run Cross-Validation
var result = cvNaiveBayesClassifier.Learn(input, output);

// Training Error vs. Test Error
double trainingError = result.Training.Mean;
double validationError = result.Validation.Mean;
  • 混淆矩阵:真阳性与假阳性,以及真阴性与假阴性(第 95-108 行):
// Confusion Matrix
GeneralConfusionMatrix gcm = result.ToConfusionMatrix(input, output);

float truePositive = (float)gcm.Matrix[1, 1];
float trueNegative = (float)gcm.Matrix[0, 0];
float falsePositive = (float)gcm.Matrix[1, 0];
float falseNegative = (float)gcm.Matrix[0, 1];
  • 准确率与精确率与召回率:用于衡量机器学习模型的正确性(第 122-130 行):
// Accuracy vs. Precision vs. Recall
float accuracy = (truePositive + trueNegative) / numberOfSamples;
float precision = truePositive / (truePositive + falsePositive);
float recall = truePositive / (truePositive + falseNegative);

摘要

在本章中,我们使用 C#构建了我们第一个机器学习模型,它可以用于垃圾邮件过滤。我们首先定义并清楚地说明了我们试图解决的问题以及成功标准。然后,我们从原始电子邮件数据中提取相关信息,并将其转换成我们可以用于数据分析、特征工程和机器学习模型构建步骤的格式。在数据分析步骤中,我们学习了如何应用独热编码,并构建了用于主题行中使用的单词的矩阵表示。我们还从我们的数据分析过程中识别出一个数据问题,并学习了我们通常如何在数据准备和分析步骤之间来回迭代。然后,我们通过过滤掉停用词和使用正则表达式来分割非字母数字或非字母词来进一步改进我们的特征集。有了这个特征集,我们使用逻辑回归和朴素贝叶斯分类器算法构建了我们第一个分类模型,简要介绍了过拟合的危险,并学习了如何通过查看准确率、精确率和召回率来评估和比较模型性能。最后,我们还学习了精确率和召回率之间的权衡,以及如何根据这些指标和业务需求来选择模型。

在下一章中,我们将进一步扩展我们在使用文本数据集构建分类模型方面的知识和技能。我们将从分析一个包含超过两个类别的数据集开始,使用 Twitter 情感数据。我们将学习二分类模型和多分类模型之间的区别。我们还将讨论一些用于特征工程的 NLP 技术,以及如何使用随机森林算法构建多分类分类模型。

第三章:Twitter 情感分析

在本章中,我们将扩展我们在 C#中构建分类模型的知识。除了我们在上一章中使用过的 Accord.NET 和 Deedle 这两个包,我们还将开始使用 Stanford CoreNLP 包来应用更高级的自然语言处理(NLP)技术,例如分词、词性标注和词元化。使用这些包,本章的目标是构建一个多类分类模型,用于预测推文的情感。我们将使用一个包含不仅只有单词,还有表情符号的原始 Twitter 数据集,并使用它来训练一个用于情感预测的机器学习(ML)模型。我们将遵循构建 ML 模型时遵循的相同步骤。我们将从问题定义开始,然后进行数据准备和分析,特征工程,以及模型开发和验证。在我们的特征工程步骤中,我们将扩展我们对 NLP 技术的知识,并探讨如何将分词、词性标注和词元化应用于构建更高级的文本特征。在模型构建步骤中,我们将探索一个新的分类算法,即随机森林分类器,并将其性能与朴素贝叶斯分类器进行比较。最后,在我们的模型验证步骤中,我们将扩展我们对混淆矩阵、精确率和召回率的了解,这些我们在上一章中已经介绍过,并讨论接收者操作特征(ROC)曲线和曲线下面积(AUC)是什么,以及这些概念如何用于评估我们的 ML 模型。

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

  • 使用 Stanford CoreNLP 包设置环境

  • Twitter 情感分析项目的定义问题

  • 使用 Stanford CoreNLP 进行数据准备

  • 使用词元作为标记的数据分析

  • 使用词元化和表情符号进行特征工程

  • 朴素贝叶斯与随机森林的比较

  • 使用 ROC 曲线和 AUC 指标进行模型验证

设置环境

在我们深入 Twitter 情感分析项目之前,让我们设置我们的开发环境,我们将使用 Stanford CoreNLP 包来完成本章的所有工作。要准备好包含 Stanford CoreNLP 包的环境,需要多个步骤,所以最好按以下步骤进行:

  1. 第一步是在 Visual Studio 中创建一个新的控制台应用程序(.NET Framework)项目。确保你使用的是 4.6.1 或更高版本的.NET Framework。如果你安装了较旧版本,请访问docs.microsoft.com/en-us/dotnet/framework/install/guide-for-developers并遵循安装指南。以下是一个项目设置页面的截图(注意:你可以在顶部栏中选择你的.NET Framework 版本):

图片

  1. 现在,让我们安装 Stanford CoreNLP 包。你可以在你的包管理控制台中输入以下命令:
Install-Package Stanford.NLP.CoreNLP

我们在本章中将使用的是 Stanford.NLP.CoreNLP 3.9.1 版本。随着时间的推移,版本可能会发生变化,你可能需要更新你的安装。

  1. 我们只需再做一些事情,我们的环境就准备好开始使用这个包了。我们需要安装 CoreNLP 模型 JAR 文件,它包含用于解析、POS 标记、命名实体识别NER)和其他一些工具的各种模型。点击此链接下载并解压 Stanford CoreNLP:stanfordnlp.github.io/CoreNLP/. 下载并解压后,你将看到那里有多个文件。我们感兴趣的特定文件是 stanford-corenlp-<版本号>-models.jar。我们需要从该 jar 文件中提取内容到一个目录中,以便我们可以在我们的 C# 项目中加载所有模型文件。你可以使用以下命令从 stanford-corenlp-<版本号>-models.jar 中提取内容:
jar xf stanford-corenlp-<version-number>-models.jar 

当你从模型 jar 文件中提取完所有模型文件后,你现在就可以开始在 C# 项目中使用 Stanford CoreNLP 包了。

现在,让我们检查我们的安装是否成功。以下代码是对本例的轻微修改 (sergey-tihon.github.io/Stanford.NLP.NET/StanfordCoreNLP.html) :

using System;
using System.IO;
using java.util;
using java.io;
using edu.stanford.nlp.pipeline;
using Console = System.Console;

namespace Tokenizer
{
    class Program
    {
        static void Main()
        {
            // Path to the folder with models extracted from Step #3
            var jarRoot = @"<path-to-your-model-files-dir>";

            // Text for processing
            var text = "We're going to test our CoreNLP installation!!";

            // Annotation pipeline configuration
            var props = new Properties();
            props.setProperty("annotators", "tokenize, ssplit, pos, lemma");
            props.setProperty("ner.useSUTime", "0");

            // We should change current directory, so StanfordCoreNLP could find all the model files automatically
            var curDir = Environment.CurrentDirectory;
            Directory.SetCurrentDirectory(jarRoot);
            var pipeline = new StanfordCoreNLP(props);
            Directory.SetCurrentDirectory(curDir);

            // Annotation
            var annotation = new Annotation(text);
            pipeline.annotate(annotation);

            // Result - Pretty Print
            using (var stream = new ByteArrayOutputStream())
            {
                pipeline.prettyPrint(annotation, new PrintWriter(stream));
                Console.WriteLine(stream.toString());
                stream.close();
            }

            Console.ReadKey();
        }
    }
}

如果你的安装成功,你应该会看到以下类似的输出:

图片

让我们更仔细地看看这个输出。标记是作为单个语义单元组合的字符序列。通常,标记是单词术语。在每一行标记输出中,我们可以看到原始文本,例如 We'regoingPartOfSpeech 标签指的是每个单词的类别,例如名词、动词和形容词。例如,我们例子中第一个标记 WePartOfSpeech 标签是 PRP,它代表人称代词。我们例子中第二个标记 'rePartOfSpeech 标签是 VBP,它代表动词,非第三人称单数现在时。完整的 POS 标签列表可以在以下位置找到 (www.ling.upenn.edu/courses/Fall_2003/ling001/penn_treebank_pos.html) 或在以下屏幕截图:

图片

POS 标签列表

最后,在我们标记化示例中的 Lemma 标签指的是给定单词的标准形式。例如,amare 的词元是 be。在我们的例子中,第三个标记中的单词 going 的词元是 go。我们将在以下章节中讨论如何使用词元化进行特征工程。

Twitter 情感分析问题定义

让我们通过明确定义我们将构建的模型及其预测内容来开始我们的 Twitter 情感分析项目。你可能已经听说过“情感分析”这个术语。情感分析本质上是一个计算过程,用于确定给定的文本表达的是积极、中性还是消极情感。社交媒体内容的情感分析可以用于多种方式。例如,营销人员可以使用它来识别营销活动有多有效,以及它如何影响消费者对某个产品或公司的看法和态度。情感分析还可以用于预测股市变化。对某个公司的正面新闻和整体正面情感往往推动其股价上涨,而对于某个公司的新闻和社交媒体中的情感分析可以用来预测股价在不久的将来会如何变动。为了实验如何构建情感分析模型,我们将使用来自 CrowdFlower 的 Data for Everyone 库的预编译和标记的航空情感 Twitter 数据集(www.figure-eight.com/data-for-everyone/)。然后,我们将应用一些 NLP 技术,特别是词素化、词性标注和词形还原,从原始推文数据中构建有意义的文本和表情符号特征。由于我们想要预测每条推文的三个不同情感(积极、中性和消极),我们将构建一个多类分类模型,并尝试不同的学习算法——朴素贝叶斯和随机森林。一旦我们构建了情感分析模型,我们将主要通过以下三个指标来评估其性能:精确度、召回率和 AUC。

让我们总结一下 Twitter 情感分析项目的需求定义:

  • 问题是什么?我们需要一个 Twitter 情感分析模型来计算识别推文中的情感。

  • 为什么这是一个问题?识别和衡量用户或消费者对某个主题(如产品、公司、广告等)的情感,通常是衡量某些任务影响力和成功的重要工具。

  • 解决这个问题的方法有哪些?我们将使用斯坦福 CoreNLP 包来应用各种 NLP 技术,如分词、词性标注和词形还原,从原始 Twitter 数据集中构建有意义的特征。有了这些特征,我们将尝试不同的学习算法来构建情感分析模型。我们将使用精确度、召回率和 AUC 指标来评估模型的性能。

  • 成功的标准是什么?我们希望有高精确率,同时不牺牲太多的召回率,因为正确地将一条推文分类到三个情感类别(正面、中立和负面)比更高的检索率更重要。此外,我们希望有高 AUC 值,我们将在本章后面的部分详细讨论。

使用斯坦福 CoreNLP 进行数据准备

既然我们已经知道了本章的目标,现在是时候深入数据了。与上一章类似,我们将使用预编译和预标记的 Twitter 情感数据。我们将使用来自 CrowdFlower 的 Data for Everyone 库的数据集(www.figure-eight.com/data-for-everyone/),你可以从这个链接下载数据:www.kaggle.com/crowdflower/twitter-airline-sentiment。这里的数据是关于大约 15,000 条关于美国航空公司的推文。这些 Twitter 数据是从 2015 年 2 月抓取的,然后被标记为三个类别——正面、负面和中立。链接提供了两种类型的数据:CSV 文件和 SQLite 数据库。我们将在这个项目中使用 CSV 文件。

一旦你下载了这些数据,我们需要为未来的分析和模型构建做准备。数据集中我们感兴趣的两大列是airline_sentimenttextairline_sentiment列包含关于情感的信息——一条推文是否有积极、消极或中性的情感——而text列包含原始的 Twitter 文本。为了使这些原始数据便于我们未来的数据分析和管理模型构建步骤,我们需要完成以下任务:

  • 清理不必要的文本:很难证明文本的某些部分提供了许多见解和信息,供我们的模型学习,例如 URL、用户 ID 和原始数字。因此,准备我们原始数据的第一个步骤是清理不包含太多信息的无用文本。在这个例子中,我们移除了 URL、Twitter 用户 ID、数字和标签符号。我们使用Regex将此类文本替换为空字符串。以下代码展示了我们用来过滤这些文本的Regex表达式:
// 1\. Remove URL's
string urlPattern = @"https?:\/\/\S+\b|www\.(\w+\.)+\S*";
Regex rgx = new Regex(urlPattern);
tweet = rgx.Replace(tweet, "");

// 2\. Remove Twitter ID's
string userIDPattern = @"@\w+";
rgx = new Regex(userIDPattern);
tweet = rgx.Replace(tweet, "");

// 3\. Remove Numbers
string numberPattern = @"[-+]?[.\d]*[\d]+[:,.\d]*";
tweet = Regex.Replace(tweet, numberPattern, "");

// 4\. Replace Hashtag
string hashtagPattern = @"#";
tweet = Regex.Replace(tweet, hashtagPattern, "");

如你所见,有两种方式可以替换匹配Regex模式的字符串。你可以实例化一个Regex对象,然后用另一个字符串替换匹配的字符串,如前两个案例所示。你也可以直接调用静态的Regex.Replace方法来达到同样的目的,如最后两个案例所示。静态方法会在每次调用Regex.Replace方法时创建一个Regex对象,所以如果你在多个地方使用相同的模式,第一种方法会更好:

  • 将相似的表情符号分组并编码:表情符号,如笑脸和悲伤脸,在推文中经常被使用,并提供了关于每条推文情感的见解。直观地,一个用户会用笑脸表情符号来推文关于积极事件,而另一个用户会用悲伤脸表情符号来推文关于负面事件。然而,不同的笑脸表现出相似的正向情感,可以分组在一起。例如,带括号的笑脸:)与带大写字母D的笑脸:D具有相同的意义。因此,我们希望将这些相似的表情符号分组在一起,并将它们编码为一个组,而不是让它们分别在不同的组中。我们将使用 Romain Paulus 和 Jeffrey Pennington 分享的 R 代码(nlp.stanford.edu/projects/glove/preprocess-twitter.rb),将其翻译成 C#,然后将其应用于我们的原始 Twitter 数据集。以下是如何将 R 中编写的表情符号Regex代码翻译成 C#,以便我们可以将相似的表情符号分组并编码:
// 1\. Replace Smiley Faces
string smileyFacePattern = String.Format(@"{0}{1}[)dD]+|[)dD]+{1}{0}", eyesPattern, nosePattern);
tweet = Regex.Replace(tweet, smileyFacePattern, " emo_smiley ");

// 2\. Replace LOL Faces
string lolFacePattern = String.Format(@"{0}{1}[pP]+", eyesPattern, nosePattern);
tweet = Regex.Replace(tweet, lolFacePattern, " emo_lol ");

// 3\. Replace Sad Faces
string sadFacePattern = String.Format(@"{0}{1}\(+|\)+{1}{0}", eyesPattern, nosePattern);
tweet = Regex.Replace(tweet, sadFacePattern, " emo_sad ");

// 4\. Replace Neutral Faces
string neutralFacePattern = String.Format(@"{0}{1}[\/|l*]", eyesPattern, nosePattern);
tweet = Regex.Replace(tweet, neutralFacePattern, " emo_neutral ");

// 5\. Replace Heart
string heartPattern = "<3";
tweet = Regex.Replace(tweet, heartPattern, " emo_heart ");
  • 将其他有用的表达式分组并编码:最后,还有一些可以帮助我们的模型检测推文情感的表达式。重复的标点符号,如!!!???,以及长单词,如wayyyysoooo,可以提供一些关于推文情感的额外信息。我们将分别将它们分组并编码,以便我们的模型可以从这些表达式中学习。以下代码展示了如何编码这样的表达式:
// 1\. Replace Punctuation Repeat
string repeatedPunctuationPattern = @"([!?.]){2,}";
tweet = Regex.Replace(tweet, repeatedPunctuationPattern, " $1_repeat ");

// 2\. Replace Elongated Words (i.e. wayyyy -> way_emphasized)
string elongatedWordsPattern = @"\b(\S*?)(.)\2{2,}\b";
tweet = Regex.Replace(tweet, elongatedWordsPattern, " $1$2_emphasized ");

如代码所示,对于重复的标点符号,我们在字符串后附加一个后缀_repeat。例如,!!!将变成!_repeat,而???将变成?_repeat。对于长单词,我们在字符串后附加一个后缀_emphasized。例如,wayyyy将变成way_emphasized,而soooo将变成so_emphasized

将原始数据集处理成单个 Twitter 文本,并导出处理后的 Twitter 文本到另一个数据文件的全代码可以在本存储库中找到:github.com/yoonhwang/c-sharp-machine-learning/blob/master/ch.3/DataProcessor.cs。让我们简要地浏览一下代码。它首先将原始Tweets.csv数据集读入一个 Deedle 数据框(第 76-82 行)。然后,它调用一个名为FormatTweets的方法,该方法包含一个包含所有原始 Twitter 文本的列序列。第 56-65 行的FormatTweets方法代码如下所示:

private static string[] FormatTweets(Series<int, string> rows)
{
    var cleanTweets = rows.GetAllValues().Select((x, i) =>
    {
        string tweet = x.Value;
        return CleanTweet(tweet);
    });

    return cleanTweets.ToArray();
}

FormatTweets方法遍历序列中的每个元素,即原始推文,并调用CleanTweet方法。在CleanTweet方法中,每条原始推文都会与之前定义的所有Regex模式进行匹配,然后按照之前讨论的方式进行处理。第 11-54 行的CleanTweet方法如下所示:

private static string CleanTweet(string rawTweet)
{
      string eyesPattern = @"[8:=;]";
      string nosePattern = @"['`\-]?";

      string tweet = rawTweet;
      // 1\. Remove URL's
      string urlPattern = @"https?:\/\/\S+\b|www\.(\w+\.)+\S*";
      Regex rgx = new Regex(urlPattern);
      tweet = rgx.Replace(tweet, "");
      // 2\. Remove Twitter ID's
      string userIDPattern = @"@\w+";
      rgx = new Regex(userIDPattern);
      tweet = rgx.Replace(tweet, "");
      // 3\. Replace Smiley Faces
      string smileyFacePattern = String.Format(@"{0}{1}[)dD]+|[)dD]+{1}{0}", eyesPattern, nosePattern);
      tweet = Regex.Replace(tweet, smileyFacePattern, " emo_smiley ");
      // 4\. Replace LOL Faces
      string lolFacePattern = String.Format(@"{0}{1}[pP]+", eyesPattern, nosePattern);
      tweet = Regex.Replace(tweet, lolFacePattern, " emo_lol ");
      // 5\. Replace Sad Faces
      string sadFacePattern = String.Format(@"{0}{1}\(+|\)+{1}{0}", eyesPattern, nosePattern);
      tweet = Regex.Replace(tweet, sadFacePattern, " emo_sad ");
      // 6\. Replace Neutral Faces
      string neutralFacePattern = String.Format(@"{0}{1}[\/|l*]", eyesPattern, nosePattern);
      tweet = Regex.Replace(tweet, neutralFacePattern, " emo_neutral ");
      // 7\. Replace Heart
      string heartPattern = "<3";
      tweet = Regex.Replace(tweet, heartPattern, " emo_heart ");
      // 8\. Replace Punctuation Repeat
      string repeatedPunctuationPattern = @"([!?.]){2,}";
      tweet = Regex.Replace(tweet, repeatedPunctuationPattern, " $1_repeat ");
      // 9\. Replace Elongated Words (i.e. wayyyy -> way_emphasized)
      string elongatedWordsPattern = @"\b(\S*?)(.)\2{2,}\b";
      tweet = Regex.Replace(tweet, elongatedWordsPattern, " $1$2_emphasized ");
      // 10\. Replace Numbers
      string numberPattern = @"[-+]?[.\d]*[\d]+[:,.\d]*";
      tweet = Regex.Replace(tweet, numberPattern, "");
      // 11\. Replace Hashtag
      string hashtagPattern = @"#";
      tweet = Regex.Replace(tweet, hashtagPattern, "");

      return tweet;
}

一旦所有原始的 Twitter 推文都被清理和加工处理,结果就会被添加到原始的 Deedle 数据框中作为一个单独的列,其列名为tweet。以下代码(第 89 行)展示了如何将字符串数组添加到数据框中:

rawDF.AddColumn("tweet", processedTweets);

当您已经走到这一步时,我们唯一需要做的额外步骤就是导出处理后的数据。使用 Deedle 数据框的SaveCsv方法,您可以轻松地将数据框导出为 CSV 文件。以下代码展示了我们如何将处理后的数据导出为 CSV 文件:

rawDF.SaveCsv(Path.Combine(dataDirPath, "processed-training.csv"));

现在我们有了干净的 Twitter 文本,让我们对其进行分词并创建推文的矩阵表示。类似于我们在第二章中做的,垃圾邮件过滤,我们将字符串分解成单词。然而,我们将使用我们在本章前一部分安装的 Stanford CoreNLP 包,并利用我们在前一部分编写的示例代码。分词推文并构建其矩阵表示的代码如下:

private static Frame<int, string> CreateWordVec(Series<int, string> rows, ISet<string> stopWords, bool useLemma=false)
        {
            // Path to the folder with models extracted from `stanford-corenlp-<version>-models.jar`
            var jarRoot = @"<path-to-model-files-dir>";

            // Annotation pipeline configuration
            var props = new Properties();
            props.setProperty("annotators", "tokenize, ssplit, pos, lemma");
            props.setProperty("ner.useSUTime", "0");

            // We should change current directory, so StanfordCoreNLP could find all the model files automatically
            var curDir = Environment.CurrentDirectory;
            Directory.SetCurrentDirectory(jarRoot);
            var pipeline = new StanfordCoreNLP(props);
            Directory.SetCurrentDirectory(curDir);

            var wordsByRows = rows.GetAllValues().Select((x, i) =>
            {
                var sb = new SeriesBuilder<string, int>();

                // Annotation
                var annotation = new Annotation(x.Value);
                pipeline.annotate(annotation);

                var tokens = annotation.get(typeof(CoreAnnotations.TokensAnnotation));
                ISet<string> terms = new HashSet<string>();

                foreach (CoreLabel token in tokens as ArrayList)
                {
                    string lemma = token.lemma().ToLower();
                    string word = token.word().ToLower();
                    string tag = token.tag();
                    //Console.WriteLine("lemma: {0}, word: {1}, tag: {2}", lemma, word, tag);

                    // Filter out stop words and single-character words
                    if (!stopWords.Contains(lemma) && word.Length > 1)
                    {
                        if (!useLemma)
                        {
                            terms.Add(word);
                        }
                        else
                        {
                            terms.Add(lemma);
                        }
                    }
                }

                foreach (string term in terms)
                {
                    sb.Add(term, 1);
                }

                return KeyValue.Create(i, sb.Series);
            });

            // Create a data frame from the rows we just created
            // And encode missing values with 0
            var wordVecDF = Frame.FromRows(wordsByRows).FillMissing(0);

            return wordVecDF;
        }

如您从代码中可以看到,这段代码与上一节中的示例代码的主要区别在于,这段代码会遍历每条推文并将标记存储到 Deedle 的数据框中。正如在第二章中,垃圾邮件过滤,我们使用独热编码来分配矩阵中每个术语的值(0 或 1)。在这里需要注意的一点是我们有创建包含词元或单词的矩阵的选项。单词是从每条推文中分解出来的原始未修改的术语。例如,字符串I am a data scientist,如果您使用单词作为标记,将会分解成Iamadatascientist。词元是每个标记中单词的标准形式。例如,相同的字符串I am a data scientist,如果您使用词元作为标记,将会分解成Ibeadatascientist。请注意beam的词元。我们将在使用词元化和表情符号进行特征工程部分讨论词元是什么以及词元化是什么。

分词和创建推文矩阵表示的完整代码可以在以下链接找到:github.com/yoonhwang/c-sharp-machine-learning/blob/master/ch.3/TwitterTokenizer.cs。在这段代码中有几点需要注意。首先,让我们看看它是如何计算每种情感样本数量的。以下代码片段(第 122-127 行)展示了我们如何计算每种情感的样本数量:

// Look at the sentiment distributions in our sample set
var sampleSetDistribution = rawDF.GetColumn<string>(
    "airline_sentiment"
).GroupBy<string>(x => x.Value).Select(x => x.Value.KeyCount);
sampleSetDistribution.Print();

如您从这段代码中可以看到,我们首先获取情感列,airline_sentiment,并按值对其进行分组,其中值可以是中立负面正面。然后,它计算出现的次数并返回计数。

TwitterTokenizer代码中需要注意的第二件事是我们如何用整数值编码情感。以下是在完整代码的第 149-154 行中看到的内容:

tweetLemmaVecDF.AddColumn(
    "tweet_polarity", 
    rawDF.GetColumn<string>("airline_sentiment").Select(
        x => x.Value == "neutral" ? 0 : x.Value == "positive" ? 1 : 2
    )
);
tweet_polarity, to the term matrix data frame. We are taking the values of the airline_sentiment column and encoding 0 for neutral, 1 for positive, and 2 for negative. We are going to use this newly added column in our future model building steps.

最后,请注意我们是如何两次调用CreateWordVec方法的——一次没有词形还原(第 135-144 行),一次有词形还原(第 147-156 行)。如果我们创建一个没有词形还原的单热编码的术语矩阵,我们实际上是将所有单词作为术语矩阵中的单个标记。正如您所想象的,这将比有词形还原的矩阵大得多,稀疏性也更高。我们留下了这两段代码供您探索两种选项。您可以尝试使用以单词为列的矩阵构建 ML 模型,并与以词元为列的模型进行比较。在本章中,我们将使用词元矩阵而不是单词矩阵。

当您运行此代码时,它将输出一个条形图,显示样本集中的情感分布。如您在以下图表中看到的,在我们的样本集中大约有 3,000 条中性推文,2,000 条积极推文和 9,000 条消极推文。图表如下:

图表

使用词元作为标记的数据分析

现在是时候查看实际数据,并寻找术语频率分布与推文不同情感之间的任何模式或差异了。我们将使用上一步的输出,并获取每个情感中最常出现的七个标记的分布。在这个例子中,我们使用了一个包含词元的术语矩阵。您可以自由地运行相同的分析,使用以单词为列的术语矩阵。分析推文中每个情感中最常使用的 N 个标记的代码可以在以下位置找到:github.com/yoonhwang/c-sharp-machine-learning/blob/master/ch.3/DataAnalyzer.cs

在这段代码中有一点需要注意。与上一章不同,我们需要为三个情感类别——中性、消极和积极——计算术语频率。以下是从完整代码中摘录的代码片段(第 54-73 行):

var neutralTermFrequencies = ColumnWiseSum(
    tweetLemmaDF.Where(
        x => x.Value.GetAs<int>("tweet_polarity") == 0
    ),
    "tweet_polarity"
).Sort().Reversed;

var positiveTermFrequencies = ColumnWiseSum(
    tweetLemmaDF.Where(
        x => x.Value.GetAs<int>("tweet_polarity") == 1
    ),
    "tweet_polarity"
).Sort().Reversed;

var negativeTermFrequencies = ColumnWiseSum(
    tweetLemmaDF.Where(
        x => x.Value.GetAs<int>("tweet_polarity") == 2
    ),
    "tweet_polarity"
).Sort().Reversed;

如您从代码中可以看到,我们为每个情感类别调用了ColumnWiseSum方法,这个方法的代码如下:

private static Series<string, double> ColumnWiseSum(Frame<int, string> frame, string exclude)
{
    var sb = new SeriesBuilder<string, double>();
    foreach(string colname in frame.ColumnKeys)
    {
        double frequency = frame[colname].Sum();
        if (!colname.Equals(exclude))
        {
            sb.Add(colname, frequency);
        }
    }

    return sb.ToSeries();
}

如您从这段代码中看到的,它遍历每一列或术语,并计算该列内的所有值。由于我们使用了单热编码,简单的列求和将给我们 Twitter 数据集中每个术语的出现次数。一旦我们计算了所有列求和,我们就将它们作为 Deedle 系列对象返回。有了这些结果,我们按频率对术语进行排名,并将这些信息存储在三个单独的文件中,分别是neutral-frequencies.csvnegative-frequencies.csvpositive-frequencies.csv。我们将在后面的章节中使用术语频率输出进行特征工程和模型构建。

当您运行代码时,它将生成以下图表:

如您从图表中可以看到,不同情感之间的分布存在一些明显的差异。例如,谢谢很好是积极推文中出现频率最高的七个词中的两个,而延误取消则是消极推文中出现频率最高的七个词中的两个。直观上看,这些是有道理的。您通常会在表达对某人或某事的积极感受时使用谢谢很好。另一方面,延误取消与飞行或航空领域的负面事件相关。也许有些用户的航班延误或取消,他们就在推特上表达了自己的挫败感。另一个值得注意的有趣现象是,emo_smiley这个术语在积极推文中被列为出现频率最高的七个词中的第七位。如果您还记得,在上一个步骤中,我们将所有笑脸表情符号(如:):D等)分组并编码为emo_smiley。这告诉我们,表情符号可能在我们的模型学习如何分类每条推文的情感方面发挥重要作用。现在我们已经对数据的外观以及每种情感出现的术语有了大致的了解,让我们来谈谈在本章中我们将采用的特征工程技术。

使用词元化和表情符号进行特征工程

在上一节中,我们简要地讨论了词元。让我们更深入地了解一下什么是词元以及什么是词元化。根据一个词在句子中的使用方式和位置,这个词会以不同的形式出现。例如,单词like可以以likesliked的形式出现,这取决于前面的内容。如果我们只是简单地将句子分词成单词,那么我们的程序将会把likelikesliked看作是三个不同的标记。然而,这可能不是我们想要的。这三个词具有相同的意义,当我们构建模型时,将它们作为特征集中的同一个标记分组会很有用。这就是词元化的作用。词元是一个词的基本形式,词元化是根据每个词在句子中的使用部分将每个词转换成词元。在上面的例子中,likelikesliked的词元,将likesliked系统地转换成like就是词元化。

下面是一个使用 Stanford CoreNLP 进行词元化的例子:

在这里,您可以看到likeslike都被词元化为like。这是因为这两个词在句子中都被用作动词,而动词形式的词元是like。让我们再看另一个例子:

在这里,第一个 likes 和第二个 likes 有不同的词干。第一个有一个 like 作为其词干,而第二个有一个 likes 作为其词干。这是因为第一个被用作动词,而第二个被用作名词。正如您可以从这些例子中看到的那样,根据句子的不同部分,相同单词的词干可能会有所不同。对您的文本数据集进行词形还原可以大大减少特征空间的稀疏性和维度,并有助于模型在没有过多噪声的情况下更好地学习。

类似于词形还原,我们也把相似的表情符号分到了同一个组。这是基于这样的假设:相似的表情符号具有相似的含义。例如,:):D 几乎具有相同的含义,如果不是完全相同。在另一种情况下,根据用户的不同,冒号和括号的顺序可能不同。一些用户可能会输入 :),但另一些用户可能会输入 (:。然而,这两者之间唯一的区别是冒号和括号的顺序,而含义是相同的。在所有这些情况下,我们都希望我们的模型能够学习到相同的情感,并且不会产生任何噪声。将相似的表情符号分组到同一个组,就像我们在上一步所做的那样,有助于减少模型的不必要噪声,并帮助它们从这些表情符号中学习到最多。

高斯贝叶斯与随机森林

现在终于到了训练我们的机器学习模型来预测推文的情感的时候了。在本节中,我们将尝试使用朴素贝叶斯和随机森林分类器。我们将要做两件与上一章不同的事情。首先,我们将把我们的样本集分成训练集和验证集,而不是运行 k 折交叉验证。这也是一种常用的技术,其中模型只从样本集的一个子集中学习,然后它们用未在训练时观察到的其余部分进行测试和验证。这样,我们可以测试模型在不可预见的数据集上的表现,并模拟它们在实际世界中的行为。我们将使用 Accord.NET 包中的 SplitSetValidation 类,将我们的样本集分成训练集和验证集,并为每个集合预先定义比例,并将学习算法拟合到训练集。

其次,我们的目标变量不再是二进制(0 或 1),与之前的第二章垃圾邮件过滤不同。相反,它可以取 0、1 或 2 的任何值,其中 0 代表中性情感推文,1 代表积极情感推文,2 代表消极情感推文。因此,我们现在处理的是一个多类分类问题,而不是二类分类问题。在评估我们的模型时,我们必须采取不同的方法。我们必须修改上一章中的准确率、精确率和召回率的计算代码,以计算本项目三个目标情感类别中的每个类别的这些数字。此外,当我们查看某些指标时,例如 ROC 曲线和 AUC,我们将在下一节讨论这些指标,我们必须使用一对一的方法。

首先,让我们看看如何在 Accord.NET 框架中使用 SplitSetValidation 类实例化我们的学习算法。以下是如何使用朴素贝叶斯分类器算法实例化一个 SplitSetValidation 对象的方法:

var nbSplitSet = new SplitSetValidation<NaiveBayes<BernoulliDistribution>, double[]>()
{
    Learner = (s) => new NaiveBayesLearning<BernoulliDistribution>(),

    Loss = (expected, actual, p) => new ZeroOneLoss(expected).Loss(actual),

    Stratify = false,

    TrainingSetProportion = 0.8,

    ValidationSetProportion = 0.2
};
var nbResult = nbSplitSet.Learn(input, output);
SplitSetValidation object—TrainingSetProportionand ValidationSetProportion. As the name suggests, you can define what percentage of your sample set is should be used for training with the TrainingSetProportionparameter and what percentage of your sample set to be used for validation with the ValidationSetProportion parameter. Here in our code snippet, we are telling our program to use 80% of our sample for training and 20% for validation. In the last line of the code snippet, we fit a Naive Bayes classification model to the train set that was split from the sample set. Also, note here that we used BernoulliDistribution for our Naive Bayes classifier, as we used one-hot encoding to encode our features and all of our features have binary values, similar to what we did in the previous chapter.

与我们使用朴素贝叶斯分类器实例化 SplitSetValidation 对象的方式类似,你还可以按照以下方式实例化另一个对象:

var rfSplitSet = new SplitSetValidation<RandomForest, double[]>()
{
    Learner = (s) => new RandomForestLearning()
    {
        NumberOfTrees = 100, // Change this hyperparameter for further tuning

        CoverageRatio = 0.5, // the proportion of variables that can be used at maximum by each tree

        SampleRatio = 0.7 // the proportion of samples used to train each of the trees

    },

    Loss = (expected, actual, p) => new ZeroOneLoss(expected).Loss(actual),

    Stratify = false,

    TrainingSetProportion = 0.7,

    ValidationSetProportion = 0.3
};
var rfResult = rfSplitSet.Learn(input, output);

我们将之前的代码替换为随机森林作为模型,以及 RandomForestLearning 作为学习算法。如果你仔细观察,会发现一些我们可以调整的 RandomForestLearning 的超参数。第一个是 NumberOfTrees。这个超参数允许你选择要进入你的随机森林中的决策树的数量。一般来说,随机森林中的树越多,性能越好,因为你实际上在森林中构建了更多的决策树。然而,性能的提升是以训练和预测时间为代价的。随着你在随机森林中增加树的数量,训练和预测将需要更多的时间。这里需要注意的其他两个参数是 CoverageRatioSampleRatioCoverageRatio 设置了每个树中使用的特征集的比例,而 SampleRatio 设置了每个树中使用的训练集的比例。较高的 CoverageRatioSampleRatio 会提高森林中单个树的表现,但也会增加树之间的相关性。树之间的低相关性有助于减少泛化误差;因此,在单个树预测能力和树之间的相关性之间找到一个良好的平衡对于构建一个好的随机森林模型至关重要。调整和实验这些超参数的各种组合可以帮助你避免过拟合问题,并在训练随机森林模型时提高你的模型性能。我们建议你构建多个具有不同超参数组合的随机森林分类器,并实验它们对模型性能的影响。

我们用来训练朴素贝叶斯和随机森林分类模型并输出验证结果的完整代码可以在以下链接找到:github.com/yoonhwang/c-sharp-machine-learning/blob/master/ch.3/TwitterSentimentModeling.cs。让我们更仔细地看看这段代码。在第 36-41 行,它首先读取我们数据准备步骤中构建的标记矩阵文件tweet-lemma.csv。然后在第 43-51 行,我们读取我们数据分析步骤中构建的词频文件positive-frequencies.csvnegative-frequencies.csv。类似于我们在上一章中做的,我们在第 64 行基于词的出现次数进行特征选择。在这个例子中,我们尝试了 5、10、50、100 和 150 作为样本推文中词出现次数的最小阈值。从第 65 行开始,我们迭代这些阈值,并开始训练和评估朴素贝叶斯和随机森林分类器。每次在训练集上训练一个模型后,它就会对在训练时间内未观察到的验证集进行运行。

以下是在训练集和验证集上运行训练好的朴素贝叶斯模型以测量样本内和样本外性能的完整代码(第 113-135 行)的一部分:

// Get in-sample & out-sample prediction results for NaiveBayes Classifier
var nbTrainedModel = nbResult.Model;

int[] nbTrainSetIDX = nbSplitSet.IndicesTrainingSet;
int[] nbTestSetIDX = nbSplitSet.IndicesValidationSet;

Console.WriteLine("* Train Set Size: {0}, Test Set Size: {1}", nbTrainSetIDX.Length, nbTestSetIDX.Length);

int[] nbTrainPreds = new int[nbTrainSetIDX.Length];
int[] nbTrainActual = new int[nbTrainSetIDX.Length];
for (int i = 0; i < nbTrainPreds.Length; i++)
{
   nbTrainActual[i] = output[nbTrainSetIDX[i]];
   nbTrainPreds[i] = nbTrainedModel.Decide(input[nbTrainSetIDX[i]]);
}

int[] nbTestPreds = new int[nbTestSetIDX.Length];
int[] nbTestActual = new int[nbTestSetIDX.Length];
for (int i = 0; i < nbTestPreds.Length; i++)
{
   nbTestActual[i] = output[nbTestSetIDX[i]];
   nbTestPreds[i] = nbTrainedModel.Decide(input[nbTestSetIDX[i]]);
}

以下是在训练集和验证集上运行训练好的随机森林模型以测量样本内和样本外性能的完整代码(第 167-189 行)的一部分:

// Get in-sample & out-sample prediction results for RandomForest Classifier
var rfTrainedModel = rfResult.Model;

int[] rfTrainSetIDX = rfSplitSet.IndicesTrainingSet;
int[] rfTestSetIDX = rfSplitSet.IndicesValidationSet;

Console.WriteLine("* Train Set Size: {0}, Test Set Size: {1}", rfTrainSetIDX.Length, rfTestSetIDX.Length);

int[] rfTrainPreds = new int[rfTrainSetIDX.Length];
int[] rfTrainActual = new int[rfTrainSetIDX.Length];
for (int i = 0; i < rfTrainPreds.Length; i++)
{
    rfTrainActual[i] = output[rfTrainSetIDX[i]];
    rfTrainPreds[i] = rfTrainedModel.Decide(input[rfTrainSetIDX[i]]);
}

int[] rfTestPreds = new int[rfTestSetIDX.Length];
int[] rfTestActual = new int[rfTestSetIDX.Length];
for (int i = 0; i < rfTestPreds.Length; i++)
{
    rfTestActual[i] = output[rfTestSetIDX[i]];
    rfTestPreds[i] = rfTrainedModel.Decide(input[rfTestSetIDX[i]]);
}

让我们更仔细地看看这些。为了简洁起见,我们只看随机森林模型的情况,因为朴素贝叶斯分类器的情况将相同。在第 168 行,我们首先从学习结果中获取训练好的模型。然后,在第 170-171 行,我们从SplitSetValidation对象中获取样本内(训练集)和样本外(测试/验证集)的索引,以便我们可以迭代每一行或记录并做出预测。我们迭代这个过程两次——一次在第 175-181 行的样本内训练集上,再次在第 183-189 行的样本外验证集上。

一旦我们在训练集和测试集上获得了预测结果,我们就将这些结果通过一些验证方法进行验证(第 138-141 行用于朴素贝叶斯分类器,第 192-196 行用于随机森林分类器)。我们为这个项目专门编写了两种方法来验证模型——PrintConfusionMatrixDrawROCCurvePrintConfusionMatrix是我们在第二章,“垃圾邮件过滤”中使用的更新版本,现在它打印的是一个 3 x 3 的混淆矩阵,而不是 2 x 2 的混淆矩阵。另一方面,DrawROCCurve方法为这个项目引入了一些新的概念和新的模型验证方法。让我们在下一节更详细地讨论这些新的评估指标,这是我们在这个项目中使用的。

模型验证——ROC 曲线和 AUC

如前所述,我们在本章中使用不同的模型验证指标:ROC 曲线和 AUC。ROC 曲线是在各种阈值下,真实正率与假正率的关系图。曲线上的每个点代表在某个概率阈值下对应的真实正率和假正率对。它通常用于从不同的模型候选者中选择最佳和最优化模型。

ROC 曲线下的面积(AUC)衡量模型区分两个类别的好坏。在二元分类的情况下,AUC 衡量模型区分正结果和负结果的好坏。由于我们在这个项目中处理的是一个多类分类问题,我们使用一对一的方法来构建 ROC 曲线并计算 AUC。例如,一条 ROC 曲线可以将正面推文作为正面结果,将中立和负面推文作为负面结果,而另一条 ROC 曲线可以将中立推文作为正面结果,将正面和负面推文作为负面结果。如图表所示,我们为每个构建的模型绘制了三个 ROC 图表——一个用于中立与剩余(正面和负面)的对比,一个用于正面与剩余(中立和负面)的对比,以及一个用于负面与剩余(中立和正面)的对比。AUC 数值越高,模型越好,因为它表明模型有更大的可能性区分正类别和负类别。

以下图表显示了具有10个最小词频的朴素贝叶斯分类器的 ROC 曲线:

图片

以下图表显示了具有50个最小词频的朴素贝叶斯分类器的 ROC 曲线:

图片

以下图表显示了具有150个最小词频的朴素贝叶斯分类器的 ROC 曲线:

图片

如您从图表中可以看到,我们也可以通过观察训练和测试结果曲线之间的差距来从 ROC 图表中检测过拟合问题。差距越大,模型过拟合的程度就越高。如果您看第一个案例,我们只过滤掉那些在推文中出现次数少于十次的术语,两个曲线之间的差距就很大。随着我们提高阈值,我们可以看到差距减小。当我们选择最终模型时,我们希望训练 ROC 曲线和测试/验证 ROC 曲线尽可能小。由于这种分辨率是以模型性能为代价的,我们需要找到这个权衡的正确截止线。

让我们现在看看我们的随机森林分类器中的一个样本。以下是从拟合随机森林分类器中得到的一个样本结果:

图片

集成方法,例如随机森林,通常在分类问题中表现良好,通过集成更多树可以提高准确率。然而,它们也有一些局限性,其中之一在前面的随机森林分类器示例结果中已经展示。对于所有基于决策树模型而言,随机森林模型倾向于过拟合,尤其是在它试图从许多分类变量中学习时。正如从随机森林分类器的 ROC 曲线中可以看到的,训练集和测试集 ROC 曲线之间的差距很大,尤其是与朴素贝叶斯分类器的 ROC 曲线相比。具有最小词频出现阈值 150 的朴素贝叶斯分类器在训练集和测试集 ROC 曲线之间几乎没有差距,而相同阈值下的随机森林分类器在两个 ROC 曲线之间显示出较大的差距。在处理存在大量分类变量的数据集时,我们需要小心选择模型,并特别注意调整超参数,例如NumberOfTreesCoverageRatioSampleRatio以优化随机森林模型。

摘要

在本章中,我们为 Twitter 情感分析构建和训练了更高级的分类模型。我们将前一章学到的知识应用于一个具有更复杂文本数据的多元分类问题。我们首先通过设置我们的环境开始,使用斯坦福 CoreNLP 包进行分词、词性标注和词形还原,在数据准备和分析步骤中。然后,我们将原始 Twitter 数据集通过分词和词形还原转换为一个独热编码矩阵。在数据准备步骤中,我们还讨论了如何使用正则表达式将相似的表情符号分组,并从推文中移除不必要的文本,例如 URL、Twitter ID 和原始数字。在数据分析步骤中,我们进一步分析了常用术语和表情符号的分布,并看到词形还原和将相似的表情符号分组如何有助于减少数据集中的噪声。在之前的步骤中获取数据和洞察后,我们尝试使用朴素贝叶斯和随机森林分类器构建多元分类模型。在构建这些模型的过程中,我们介绍了一种常用的模型验证技术,即将样本集分为两个子集,训练集和验证集,使用训练集来拟合模型,使用验证集来评估模型性能。我们还介绍了新的模型验证指标,ROC 曲线和 AUC,我们可以使用这些指标在模型候选者中选择最佳和最优化模型。

在下一章中,我们将转换方向,开始构建回归模型,其中目标变量是连续变量。我们将使用外汇汇率数据集来构建时间序列特征,并探索一些其他用于回归问题的机器学习模型。我们还将讨论评估回归模型性能与分类模型性能的不同之处。

第四章:外汇汇率预测

在本章中,我们将开始使用 C#构建回归模型。到目前为止,我们已经构建了机器学习ML)模型,目的是使用逻辑回归、朴素贝叶斯和随机森林学习算法将数据分类到二元或多个类别中。然而,我们现在将转换方向,开始构建预测连续结果的模型。在本章中,我们将探索一个金融数据集,更具体地说是一个外汇汇率市场数据集。我们将使用欧元(EUR)和美元(USD)之间的每日汇率的历史数据来构建一个预测未来汇率的回归模型。我们将从问题定义开始,然后转向数据准备和数据分析。在数据准备和分析步骤中,我们将探讨如何管理时间序列数据和分析每日回报率的分布。然后,在特征工程步骤中,我们将开始构建可以预测货币汇率的特征。我们将讨论金融市场中常用的几个技术指标,例如移动平均线、布林带和滞后变量。使用这些技术指标,我们将使用线性回归和支持向量机(SVM)学习算法构建回归 ML 模型。在构建这些模型的同时,我们还将探讨一些微调 SVM 模型超参数的方法。最后,我们将讨论几个验证指标和评估回归模型的方法。我们将讨论如何使用均方根误差RMSE)、R²以及观察值与拟合值图来评估我们模型的性能。到本章结束时,你将拥有用于预测每日 EUR/USD 汇率的工作回归模型。

在本章中,我们将涵盖以下步骤:

  • 外汇汇率(欧元对美元)预测项目的问题定义

  • 使用 Deedle 框架中的时间序列功能进行数据准备

  • 时间序列数据分析

  • 使用外汇中的各种技术指标进行特征工程

  • 线性回归与支持向量机(SVM)的比较

  • 使用 RMSE、R²以及实际与预测图进行模型验证

问题定义

让我们从定义本项目试图解决的问题开始这一章。你可能听说过术语算法交易量化金融/交易。这是金融行业中数据科学和机器学习与金融相结合的知名领域之一。算法交易或量化金融指的是一种策略,即你使用从大量历史数据构建的统计学习模型来预测未来的金融市场走势。这些策略和技术被各种交易者和投资者广泛使用,以预测各种金融资产的未来价格。外汇市场是最大的、流动性最强的金融市场之一,大量的交易者和投资者参与其中。这是一个独特的市场,每天 24 小时、每周 5 天开放,来自世界各地的交易者进入市场买卖特定的货币对。由于这一优势和独特性,外汇市场也是算法交易和量化交易者构建机器学习模型来预测未来汇率并自动化他们的交易以利用计算机做出的快速决策和执行的吸引人的金融市场。

为了了解我们如何将我们的机器学习知识应用于金融市场和回归模型,我们将使用从 1999 年 1 月 1 日到 2017 年 12 月 31 日的每日 EUR/USD 汇率的历史数据。我们将使用一个公开可用的数据集,可以从以下链接下载:www.global-view.com/forex-trading-tools/forex-history/index.html。使用这些数据,我们将通过使用常用的技术指标,如移动平均线、布林带和滞后变量来构建特征。然后,我们将使用线性回归和 SVM 学习算法构建回归模型,以预测 EUR/USD 货币对的未来每日汇率。一旦我们构建了这些模型,我们将使用 RMSE、R^([2])以及观察值与预测值对比图来评估我们的模型。

为了总结我们对外汇汇率预测项目的问题定义:

  • 问题是怎样的?我们需要一个回归模型来预测欧元和美元之间的未来汇率;更具体地说,我们希望构建一个机器学习模型来预测 EUR/USD 汇率每日的变化。

  • 为什么这是一个问题?由于外汇市场的快节奏和波动性环境,拥有一个能够预测并自主决定何时买入和何时卖出特定货币对的机器学习模型是有利的。

  • 解决这个问题的有哪些方法?我们将使用欧元与美元之间的每日汇率的历史数据。使用这个数据集,我们将使用常用的技术指标,如移动平均线、布林带和滞后变量来构建金融特征。我们将探索线性回归和 SVM 学习算法作为我们的回归模型候选。然后,我们将查看 RMSE、R²,并使用观察值与预测值图来评估我们构建的模型的表现。

  • 成功标准是什么?我们希望 RMSE 低,因为我们希望我们的预测尽可能接近实际值。我们希望 R²高,因为它表示我们模型的拟合优度。最后,我们希望看到数据点在观察值与预测值图中紧密地排列在对角线上。

数据准备

既然我们已经知道了本章试图解决的问题类型,让我们开始查看数据。与前面两章不同,那里我们预先编译并预先标记了数据,我们将从原始的 EUR/USD 汇率数据开始。点击此链接:www.global-view.com/forex-trading-tools/forex-history/index.html并选择EUR/USD 收盘价EUR/USD 最高价EUR/USD 最低价。如果您想探索不同的数据集,您也可以选择不同的货币对。一旦您选择了想要的数据点,您可以选择开始和结束日期,也可以选择您想要下载的每日、每周或每月数据。对于本章,我们选择1999 年 1 月 1 日作为开始日期2017 年 12 月 31 日作为结束日期,并下载包含 EUR/USD 货币对收盘价、最高价和最低价的每日数据集。

下载完数据后,我们需要做一些任务来为未来的数据分析、特征工程和机器学习建模做好准备。首先,我们需要定义目标变量。正如我们在问题定义步骤中讨论的,我们的目标变量将是 EUR/USD 汇率每日变化。为了计算每日回报率,我们需要从今天的收盘价中减去昨天的收盘价,然后除以昨天的收盘价。计算每日回报率的公式如下:

图片

我们可以使用 Deedle 数据框中的Diff方法来计算前一个价格和当前价格之间的差异。实际上,您可以使用Diff方法来计算任何任意时间点的数据点与当前数据点之间的差异。例如,以下代码显示了如何计算当前数据点与一步之遥、三步之遥和五步之遥的数据点之间的差异:

rawDF["DailyReturn"].Diff(1)
rawDF["DailyReturn"].Diff(3)
rawDF["DailyReturn"].Diff(5)

上述代码的输出如下:

图片

使用这个 Diff 方法,以下是如何计算 EUR/USD 汇率的每日回报的代码:

// Compute Daily Returns
rawDF.AddColumn(
    "DailyReturn", 
    rawDF["Close"].Diff(1) / rawDF["Close"] * 100.0
);

在此代码中,我们计算了前一天和当天收盘价之间的差异,然后除以前一天的收盘价。通过乘以 100,我们可以得到百分比形式的每日回报。最后,我们使用 Deedle 数据框中的 AddColumn 方法,将这个每日回报序列添加到原始数据框中,列名为 DailyReturn

然而,我们在构建目标变量方面还没有完成。由于我们正在构建一个预测模型,我们需要将下一天的回报作为目标变量。我们可以使用 Deedle 数据框中的 Shift 方法将每个记录与下一天的回报关联起来。类似于 Diff 方法,你可以使用 Shift 方法将序列向前或向后移动到任何任意的时间点。以下是如何将 DailyReturn 列通过 135 步移动的代码:

rawDF["DailyReturn"].Shift(1)
rawDF["DailyReturn"].Shift(3)
rawDF["DailyReturn"].Shift(5)

上述代码的输出如下:

如此示例所示,DailyReturn 列或序列已经根据你输入到 Shift 方法的参数向前移动了 135 步。使用这个 Shift 方法,我们将每日回报向前移动一步,以便每个记录都有下一天的回报作为目标变量。以下是如何创建目标变量列 Target 的代码:

// Encode Target Variable - Predict Next Daily Return
rawDF.AddColumn(
    "Target",
    rawDF["DailyReturn"].Shift(-1)
);

现在我们已经编码了目标变量,我们还需要进行一个额外的步骤来为未来的任务准备数据。当你处理金融数据时,你经常会听到术语 OHLC 图表OHLC 价格。OHLC 代表开盘价、最高价、最低价和收盘价,通常用于显示价格随时间的变化。如果你查看我们下载的数据,你会注意到数据集中缺少开盘价。然而,为了我们未来的特征工程步骤,我们需要开盘价。鉴于外汇市场每天 24 小时运行,并且交易量很大,非常流动,我们将假设给定一天的开盘价是前一天收盘价。为了将前一天的收盘价作为开盘价,我们将使用 Shift 方法。以下是如何创建并添加开盘价到我们的数据框中的代码:

// Assume Open prices are previous Close prices
rawDF.AddColumn(
    "Open",
    rawDF["Close"].Shift(1)
);

以下是我们用于数据准备步骤的完整代码:

using Deedle;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace DataPrep
{
    class Program
    {
        static void Main(string[] args)
        {
            Console.SetWindowSize(100, 50);

            // Read in the raw dataset
            // TODO: change the path to point to your data directory
            string dataDirPath = @"\\Mac\Home\Documents\c-sharp-machine-
learning\ch.4\input-data";

            // Load the data into a data frame
            string rawDataPath = Path.Combine(dataDirPath, "eurusd-daily.csv");
            Console.WriteLine("Loading {0}\n", rawDataPath);
            var rawDF = Frame.ReadCsv(
                rawDataPath,
                hasHeaders: true,
                schema: "Date,float,float,float",
                inferTypes: false
            );

            // Rename & Simplify Column Names
            rawDF.RenameColumns(c => c.Contains("EUR/USD ") ? c.Replace("EUR/USD ", "") : c);

            // Assume Open prices are previous Close prices
            rawDF.AddColumn(
                "Open",
                rawDF["Close"].Shift(1)
            );

            // Compute Daily Returns
            rawDF.AddColumn(
                "DailyReturn", 
                rawDF["Close"].Diff(1) / rawDF["Close"] * 100.0
            );

            // Encode Target Variable - Predict Next Daily Return
            rawDF.AddColumn(
                "Target",
                rawDF["DailyReturn"].Shift(-1)
            );

            rawDF.Print();

            // Save OHLC data
            string ohlcDataPath = Path.Combine(dataDirPath, "eurusd-daily-ohlc.csv");
            Console.WriteLine("\nSaving OHLC data to {0}\n", rawDataPath);
            rawDF.SaveCsv(ohlcDataPath);

            Console.WriteLine("DONE!!");
            Console.ReadKey();
        }
    }
}

当你运行这段代码时,它将输出结果到一个名为 eurusd-daily-ohlc.csv 的文件中,该文件包含开盘价、最高价、最低价和收盘价,以及每日回报和目标变量。我们将使用这个文件进行未来的数据分析特征工程步骤。

此代码也可以在以下仓库中找到: github.com/yoonhwang/c-sharp-machine-learning/blob/master/ch.4/DataPrep.cs

时间序列数据分析

让我们开始查看数据。我们将从之前的数据准备步骤的输出开始,查看每日收益的分布情况。与之前的章节不同,我们主要处理的是分类变量,而现在我们处理的是连续和时间序列变量。我们将以几种不同的方式查看这些数据。首先,让我们查看时间序列收盘价图表。以下代码展示了如何使用 Accord.NET 框架构建折线图:

// Time-series line chart of close prices
DataSeriesBox.Show(
    ohlcDF.RowKeys.Select(x => (double)x),
    ohlcDF.GetColumn<double>("Close").ValuesAll
);

请参考 Accord.NET 文档中的DataSeriesBox.Show方法,了解显示折线图的多种其他方式。在这个例子中,我们使用数据框的整数索引作为x轴值,收盘价作为y轴值来构建折线图。以下是在运行代码时您将看到的时序线图表:

图片

此图表显示了从 1999 年到 2017 年 EUR/USD 汇率随时间的变化情况。它从大约 1.18 开始,在 2000 年和 2001 年降至 1.0 以下。然后,它在 2008 年达到了 1.6 的高点,并在 2017 年结束时的约为 1.20。现在,让我们查看历史每日收益。以下代码展示了如何构建历史每日收益的折线图:

// Time-series line chart of daily returns
DataSeriesBox.Show(
    ohlcDF.RowKeys.Select(x => (double)x),
    ohlcDF.FillMissing(0.0)["DailyReturn"].ValuesAll
);

这里有一点需要注意,即FillMissing方法的用法。如果您还记得之前的数据准备步骤,DailyReturn序列是通过计算前一时期与当前时期的差值来构建的。因此,对于第一个数据点,我们没有前一时期的数据点,所以存在一个缺失值。FillMissing方法可以帮助您使用自定义值来编码缺失值。根据您的数据集和假设,您可以使用不同的值来编码缺失值,Deedle 数据框中的FillMissing方法将非常有用。

当您运行前面的代码时,它将显示如下图表:

图片

如您从这张图表中可以看到,每日收益在0附近波动,主要介于-2.0%和+2.0%之间。让我们更仔细地查看每日收益的分布情况。我们将查看最小值、最大值、平均值和标准差。然后,我们将查看每日收益的四分位数,我们将在查看代码后更详细地讨论这一点。计算这些数字的代码如下:

// Check the distribution of daily returns
double returnMax = ohlcDF["DailyReturn"].Max();
double returnMean = ohlcDF["DailyReturn"].Mean();
double returnMedian = ohlcDF["DailyReturn"].Median();
double returnMin = ohlcDF["DailyReturn"].Min();
double returnStdDev = ohlcDF["DailyReturn"].StdDev();

double[] quantiles = Accord.Statistics.Measures.Quantiles(
    ohlcDF.FillMissing(0.0)["DailyReturn"].ValuesAll.ToArray(),
    new double[] {0.25, 0.5, 0.75}
);

Console.WriteLine("-- DailyReturn Distribution-- ");

Console.WriteLine("Mean: \t\t\t{0:0.00}\nStdDev: \t\t{1:0.00}\n", returnMean, returnStdDev);

Console.WriteLine(
    "Min: \t\t\t{0:0.00}\nQ1 (25% Percentile): \t{1:0.00}\nQ2 (Median): \t\t{2:0.00}\nQ3 (75% Percentile): \t{3:0.00}\nMax: \t\t\t{4:0.00}", 
    returnMin, quantiles[0], quantiles[1], quantiles[2], returnMax
);

如您从这段代码中可以看到,Deedle 框架提供了许多内置方法用于计算基本统计数据。正如代码的前六行所示,您可以使用 Deedle 框架中的MaxMeanMedianMinStdDev方法来获取每日收益的相应统计数据。

为了获取四分位数,我们需要在 Accord.NET 框架的 Accord.Statistics.Measures 模块中使用 Quantiles 方法。四分位数是将有序分布划分为等长度区间的点。例如,十个四分位数将有序分布划分为十个大小相等的子集,因此第一个子集代表分布的底部 10%,最后一个子集代表分布的顶部 10%。同样,四个四分位数将有序分布划分为四个大小相等的子集,其中第一个子集代表分布的底部 25%,最后一个子集代表分布的顶部 25%。四个四分位数通常被称为四分位数,十个四分位数被称为十分位数,而一百个四分位数被称为百分位数。从这些定义中,你可以推断出第一个四分位数与 0.25 分位数和 25 分位数相同。同样,第二个和第三个四分位数与 0.50 分位数和 0.75 分位数以及 50 分位数和 75 分位数相同。由于我们对四分位数感兴趣,我们在 Quantiles 方法中使用了 25%、50% 和 75% 作为 percentiles 参数的输入。以下是在运行此代码时的输出:

与我们从日回报率时间序列线图、平均值和中位数中观察到的类似,平均值和中位数大约为 0,这表明日回报率围绕 0% 振荡。从 1999 年到 2017 年,历史上最大的负日回报率是 -2.86%,最大的正日回报率是 3.61%。第一个四分位数,即最小值和平均值之间的中间数,为 -0.36%,第三个四分位数,即平均值和最大值之间的中间数,为 0.35%。从这些汇总统计数据中,我们可以看到日回报率几乎对称地分布在 0% 附近。为了更直观地展示这一点,现在让我们看一下日回报率的直方图。绘制日回报率直方图的代码如下:

var dailyReturnHistogram = HistogramBox
.Show(
    ohlcDF.FillMissing(0.0)["DailyReturn"].ValuesAll.ToArray()
)
.SetNumberOfBins(20);

我们在 Accord.NET 框架中使用了 HistogramBox 来构建日回报率的直方图图表。在这里,我们将桶的数量设置为 20。你可以增加或减少桶的数量以显示更多或更少的粒度桶。当你运行此代码时,你将看到以下图表:

与我们在摘要统计中观察到的类似,日回报率几乎对称地分布在 0% 附近。这个日回报率的直方图显示了一个清晰的钟形曲线,这表明日回报率遵循正态分布。

我们运行此数据分析步骤的完整代码可以在以下链接中找到:github.com/yoonhwang/c-sharp-machine-learning/blob/master/ch.4/DataAnalyzer.cs

特征工程

现在我们对每日收益率的分布有了更好的理解,让我们开始为我们的机器学习建模构建特征。在这一步,我们将讨论一些在外汇市场中被交易者频繁使用的技术指标,以及我们如何使用这些技术指标为我们的机器学习模型构建特征。

移动平均线

我们将要构建的第一个特征集是移动平均线。移动平均线是预定义数量的周期内的滚动平均,是一种常用的技术指标。移动平均线有助于平滑价格波动,并显示价格行为的整体趋势。关于移动平均线在交易金融资产中的应用的深入讨论超出了本书的范围,但简而言之,查看不同时间框架的多个移动平均线有助于交易者识别趋势和交易中的支撑和阻力水平。在本章中,我们将使用四个移动平均线,其回望周期分别为 10 天、20 天、50 天和 200 天。以下代码展示了我们如何使用Window方法计算移动平均线:

// 1\. Moving Averages
ohlcDF.AddColumn("10_MA", ohlcDF.Window(10).Select(x => x.Value["Close"].Mean()));
ohlcDF.AddColumn("20_MA", ohlcDF.Window(20).Select(x => x.Value["Close"].Mean()));
ohlcDF.AddColumn("50_MA", ohlcDF.Window(50).Select(x => x.Value["Close"].Mean()));
ohlcDF.AddColumn("200_MA", ohlcDF.Window(200).Select(x => x.Value["Close"].Mean()));

Deedle 框架中的Window方法帮助我们轻松计算移动平均线。Window方法接受一个数据框,并构建一系列数据框,其中每个数据框包含一个预定义数量的记录。例如,如果你的Window方法的输入是10,那么它将构建一系列数据框,其中第一个数据框包含从 0 索引到 9 索引的记录,第二个数据框包含从 1 索引到 11 索引的记录,依此类推。使用这种方法,我们可以轻松计算不同时间窗口的移动平均线,如代码所示。现在,让我们绘制一个包含这些移动平均线的时序收盘价图表:

图片

如您从这张图表中可以看到,移动平均线平滑了价格波动。红色线表示 10 天的移动平均线,绿色线表示 20 天的移动平均线,黑色线表示 50 天,粉色线表示 200 天。从这张图表中可以看出,时间窗口越短,它就越接近价格变动,图表就越不平滑。我们用来生成这张图表的代码如下:

// Time-series line chart of close prices & moving averages
var maLineChart = DataSeriesBox.Show(
    ohlcDF.Where(x => x.Key > 4400 && x.Key < 4900).RowKeys.Select(x => (double)x),
    ohlcDF.Where(x => x.Key > 4400 && x.Key < 4900).GetColumn<double>("Close").ValuesAll,
    ohlcDF.Where(x => x.Key > 4400 && x.Key < 4900).GetColumn<double>("10_MA").ValuesAll,
    ohlcDF.Where(x => x.Key > 4400 && x.Key < 4900).GetColumn<double>("20_MA").ValuesAll,
    ohlcDF.Where(x => x.Key > 4400 && x.Key < 4900).GetColumn<double>("50_MA").ValuesAll,
    ohlcDF.Where(x => x.Key > 4400 && x.Key < 4900).GetColumn<double>("200_MA").ValuesAll
);

通过我们刚刚计算出的这些移动平均线,我们将用于我们模型的实际特征是收盘价与移动平均线之间的距离。正如简要提到的,移动平均线通常充当支撑和阻力水平,通过观察每个价格点与每个移动平均线的距离,我们可以判断我们是否正在接近支撑和阻力线。计算收盘价与移动平均线之间距离的代码如下:

// Distance from moving averages
ohlcDF.AddColumn("Close_minus_10_MA", ohlcDF["Close"] - ohlcDF["10_MA"]);
ohlcDF.AddColumn("Close_minus_20_MA", ohlcDF["Close"] - ohlcDF["20_MA"]);
ohlcDF.AddColumn("Close_minus_50_MA", ohlcDF["Close"] - ohlcDF["50_MA"]);
ohlcDF.AddColumn("Close_minus_200_MA", ohlcDF["Close"] - ohlcDF["200_MA"]);

布林带

我们将要查看的第二项技术指标是布林带。布林带由移动平均和与移动平均相同时间窗口的移动标准差组成。然后,布林带在价格时间序列图上绘制在移动平均上方和下方两个标准差的位置。我们将使用 20 日时间窗口来计算布林带。计算布林带的代码如下:

// 2\. Bollinger Band
ohlcDF.AddColumn("20_day_std", ohlcDF.Window(20).Select(x => x.Value["Close"].StdDev()));
ohlcDF.AddColumn("BollingerUpperBound", ohlcDF["20_MA"] + ohlcDF["20_day_std"] * 2);
ohlcDF.AddColumn("BollingerLowerBound", ohlcDF["20_MA"] - ohlcDF["20_day_std"] * 2);

如您从这段代码中可以看到,我们正在使用WindowStdDev方法来计算移动标准差。然后,我们通过从 20 日移动平均中加减两个标准差来计算布林带的上限和下限。当您在价格时间序列图上绘制布林带时,结果如下所示:

图片

蓝线表示价格变动,绿线表示 20 日移动平均,红线表示布林带的上限,即比移动平均高出两个标准差,黑线表示布林带的下限,即比移动平均低出两个标准差。如您从这张图表中看到,布林带在价格变动周围形成带状。显示此图表的代码如下:

// Time-series line chart of close prices & bollinger bands
var bbLineChart = DataSeriesBox.Show(
    ohlcDF.Where(x => x.Key > 4400 && x.Key < 4900).RowKeys.Select(x => (double)x),
    ohlcDF.Where(x => x.Key > 4400 && x.Key < 4900).GetColumn<double>("Close").ValuesAll,
    ohlcDF.Where(x => x.Key > 4400 && x.Key < 4900).GetColumn<double>("BollingerUpperBound").ValuesAll,
    ohlcDF.Where(x => x.Key > 4400 && x.Key < 4900).GetColumn<double>("20_MA").ValuesAll,
    ohlcDF.Where(x => x.Key > 4400 && x.Key < 4900).GetColumn<double>("BollingerLowerBound").ValuesAll
);

与之前的移动平均案例类似,我们将使用收盘价与布林带之间的距离。由于大多数交易都是在上下带之间进行的,因此价格与带之间的距离可以成为我们机器学习模型的特征。计算距离的代码如下:

// Distance from Bollinger Bands
ohlcDF.AddColumn("Close_minus_BollingerUpperBound", ohlcDF["Close"] - ohlcDF["BollingerUpperBound"]);
ohlcDF.AddColumn("Close_minus_BollingerLowerBound", ohlcDF["Close"] - ohlcDF["BollingerLowerBound"]);

滞后变量

最后,我们将使用的一组最后特征是滞后变量。滞后变量包含关于先前时期的信息。例如,如果我们使用前一天的日回报值作为我们模型的特征,那么它就是一个滞后了一个周期的滞后变量。我们还可以使用当前日期前两天的日回报作为我们模型的特征。这类变量被称为滞后变量,通常用于时间序列建模。我们将使用日回报和先前构建的特征作为滞后变量。在这个项目中,我们回顾了五个周期,但您可以尝试更长的或更短的回顾周期。创建日回报滞后变量的代码如下:

// 3\. Lagging Variables
ohlcDF.AddColumn("DailyReturn_T-1", ohlcDF["DailyReturn"].Shift(1));
ohlcDF.AddColumn("DailyReturn_T-2", ohlcDF["DailyReturn"].Shift(2));
ohlcDF.AddColumn("DailyReturn_T-3", ohlcDF["DailyReturn"].Shift(3));
ohlcDF.AddColumn("DailyReturn_T-4", ohlcDF["DailyReturn"].Shift(4));
ohlcDF.AddColumn("DailyReturn_T-5", ohlcDF["DailyReturn"].Shift(5));

类似地,我们可以使用以下代码为移动平均与收盘价之间的差异创建滞后变量:

ohlcDF.AddColumn("Close_minus_10_MA_T-1", ohlcDF["Close_minus_10_MA"].Shift(1));
ohlcDF.AddColumn("Close_minus_10_MA_T-2", ohlcDF["Close_minus_10_MA"].Shift(2));
ohlcDF.AddColumn("Close_minus_10_MA_T-3", ohlcDF["Close_minus_10_MA"].Shift(3));
ohlcDF.AddColumn("Close_minus_10_MA_T-4", ohlcDF["Close_minus_10_MA"].Shift(4));
ohlcDF.AddColumn("Close_minus_10_MA_T-5", ohlcDF["Close_minus_10_MA"].Shift(5));

ohlcDF.AddColumn("Close_minus_20_MA_T-1", ohlcDF["Close_minus_20_MA"].Shift(1));
ohlcDF.AddColumn("Close_minus_20_MA_T-2", ohlcDF["Close_minus_20_MA"].Shift(2));
ohlcDF.AddColumn("Close_minus_20_MA_T-3", ohlcDF["Close_minus_20_MA"].Shift(3));
ohlcDF.AddColumn("Close_minus_20_MA_T-4", ohlcDF["Close_minus_20_MA"].Shift(4));
ohlcDF.AddColumn("Close_minus_20_MA_T-5", ohlcDF["Close_minus_20_MA"].Shift(5));

ohlcDF.AddColumn("Close_minus_50_MA_T-1", ohlcDF["Close_minus_50_MA"].Shift(1));
ohlcDF.AddColumn("Close_minus_50_MA_T-2", ohlcDF["Close_minus_50_MA"].Shift(2));
ohlcDF.AddColumn("Close_minus_50_MA_T-3", ohlcDF["Close_minus_50_MA"].Shift(3));
ohlcDF.AddColumn("Close_minus_50_MA_T-4", ohlcDF["Close_minus_50_MA"].Shift(4));
ohlcDF.AddColumn("Close_minus_50_MA_T-5", ohlcDF["Close_minus_50_MA"].Shift(5));

ohlcDF.AddColumn("Close_minus_200_MA_T-1", ohlcDF["Close_minus_200_MA"].Shift(1));
ohlcDF.AddColumn("Close_minus_200_MA_T-2", ohlcDF["Close_minus_200_MA"].Shift(2));
ohlcDF.AddColumn("Close_minus_200_MA_T-3", ohlcDF["Close_minus_200_MA"].Shift(3));
ohlcDF.AddColumn("Close_minus_200_MA_T-4", ohlcDF["Close_minus_200_MA"].Shift(4));
ohlcDF.AddColumn("Close_minus_200_MA_T-5", ohlcDF["Close_minus_200_MA"].Shift(5));

最后,我们可以使用以下代码为布林带指标创建滞后变量:

ohlcDF.AddColumn("Close_minus_BollingerUpperBound_T-1", ohlcDF["Close_minus_BollingerUpperBound"].Shift(1));
ohlcDF.AddColumn("Close_minus_BollingerUpperBound_T-2", ohlcDF["Close_minus_BollingerUpperBound"].Shift(2));
ohlcDF.AddColumn("Close_minus_BollingerUpperBound_T-3", ohlcDF["Close_minus_BollingerUpperBound"].Shift(3));
ohlcDF.AddColumn("Close_minus_BollingerUpperBound_T-4", ohlcDF["Close_minus_BollingerUpperBound"].Shift(4));
ohlcDF.AddColumn("Close_minus_BollingerUpperBound_T-5", ohlcDF["Close_minus_BollingerUpperBound"].Shift(5));

如您从这些代码片段中可以看到,创建这样的滞后变量非常简单直接。我们只需在 Deedle 框架中使用Shift方法,并根据回顾周期更改方法输入。

在本节中,我们还将做的一件事是删除缺失值。因为我们构建了许多时间序列特征,所以我们创建了很多缺失值。例如,当我们计算 200 天的移动平均时,前 199 条记录将没有移动平均,因此将会有缺失值。当您在数据集中遇到缺失值时,有两种方法可以处理它们——您可以用某些值编码它们,或者从数据集中删除缺失值。由于我们有足够的数据,我们将删除所有包含缺失值的记录。从我们的数据框中删除缺失值的代码如下:

Console.WriteLine("\n\nDF Shape BEFORE Dropping Missing Values: ({0}, {1})", ohlcDF.RowCount, ohlcDF.ColumnCount);
ohlcDF = ohlcDF.DropSparseRows();
Console.WriteLine("\nDF Shape AFTER Dropping Missing Values: ({0}, {1})\n\n", ohlcDF.RowCount, ohlcDF.ColumnCount);

如您从这段代码中可以看到,Deedle 框架有一个方便的函数,我们可以用它来删除缺失值。我们可以使用DropSparseRows方法来删除所有缺失值。当您运行这段代码时,您的输出将如下所示:

图片

如您从输出中可以看到,它删除了 250 条因缺失值而导致的记录。运行数据分析步骤的完整代码可以在以下链接中找到:github.com/yoonhwang/c-sharp-machine-learning/blob/master/ch.4/FeatureEngineer.cs

线性回归与 SVM 对比

在本节中,我们将构建与之前章节完全不同的模型。我们将构建预测连续变量的模型,并提供 EUR/USD 汇率每日回报率,我们将使用两种新的学习算法,即线性回归和 SVM。线性回归模型试图在目标变量和特征之间找到线性关系,而 SVM 模型试图构建最大化不同类别之间距离的超平面。对于这个外汇汇率预测项目,我们将讨论如何使用 Accord.NET 框架在 C#中构建线性回归和 SVM 模型来解决回归问题。

在我们构建模型之前,我们必须将我们的样本集分成两个子集——一个用于训练,另一个用于测试。在上一章中,我们使用了 Accord.NET 框架中的SplitSetValidation来随机将样本集分成训练集和测试集,按照预定义的比例。然而,我们无法在本章中采用相同的方法。因为我们处理的是时间序列数据,我们不能随机选择并分割记录为训练集和测试集。如果我们随机分割样本集,那么我们可能会遇到用未来的事件训练我们的机器学习模型,而在过去的事件上测试模型的情况。因此,我们希望在某个时间点分割我们的样本集,并将那个时间点之前的记录放入训练集,之后的记录放入测试集。下面的代码展示了我们如何将样本集分割成训练集和测试集:

// Read in the file we created in the previous step
// TODO: change the path to point to your data directory
string dataDirPath = @"<path-to-data-dir>";

// Load the data into a data frame
Console.WriteLine("Loading data...");
var featuresDF = Frame.ReadCsv(
    Path.Combine(dataDirPath, "eurusd-features.csv"),
    hasHeaders: true,
    inferTypes: true
);

// Split the sample set into train and test sets
double trainProportion = 0.9;

int trainSetIndexMax = (int)(featuresDF.RowCount * trainProportion);

var trainSet = featuresDF.Where(x => x.Key < trainSetIndexMax);
var testSet = featuresDF.Where(x => x.Key >= trainSetIndexMax);

Console.WriteLine("\nTrain Set Shape: ({0}, {1})", trainSet.RowCount, trainSet.ColumnCount);
Console.WriteLine("Test Set Shape: ({0}, {1})", testSet.RowCount, testSet.ColumnCount);
Where method to filter records in the sample set by index. The next thing we need to do before training our ML models is select the features that we want to train our models with. Since we are only interested in using lagged variables and the distances between the prices and moving averages or Bollinger Bands, we do not want to include raw moving average or Bollinger Band numbers into our feature space. The following code snippet shows how we define the feature set for our models:
string[] features = new string[] {
    "DailyReturn", 
    "Close_minus_10_MA", "Close_minus_20_MA", "Close_minus_50_MA",
    "Close_minus_200_MA", "20_day_std", 
    "Close_minus_BollingerUpperBound", "Close_minus_BollingerLowerBound",
    "DailyReturn_T-1", "DailyReturn_T-2",
    "DailyReturn_T-3", "DailyReturn_T-4", "DailyReturn_T-5",
    "Close_minus_10_MA_T-1", "Close_minus_10_MA_T-2", 
    "Close_minus_10_MA_T-3", "Close_minus_10_MA_T-4",
    "Close_minus_10_MA_T-5", 
    "Close_minus_20_MA_T-1", "Close_minus_20_MA_T-2",
    "Close_minus_20_MA_T-3", "Close_minus_20_MA_T-4", "Close_minus_20_MA_T-5",
    "Close_minus_50_MA_T-1", "Close_minus_50_MA_T-2", "Close_minus_50_MA_T-3",
    "Close_minus_50_MA_T-4", "Close_minus_50_MA_T-5", 
    "Close_minus_200_MA_T-1", "Close_minus_200_MA_T-2", 
    "Close_minus_200_MA_T-3", "Close_minus_200_MA_T-4",
    "Close_minus_200_MA_T-5",
    "Close_minus_BollingerUpperBound_T-1",
    "Close_minus_BollingerUpperBound_T-2", "Close_minus_BollingerUpperBound_T-3",
    "Close_minus_BollingerUpperBound_T-4", "Close_minus_BollingerUpperBound_T-5"
};

现在我们已经准备好开始构建模型对象并训练我们的机器学习模型了。让我们首先看看如何实例化一个线性回归模型。我们用来训练线性回归模型的代码如下:

Console.WriteLine("\n**** Linear Regression Model ****");

// OLS learning algorithm
var ols = new OrdinaryLeastSquares()
{
    UseIntercept = true
};

// Fit a linear regression model
MultipleLinearRegression regFit = ols.Learn(trainX, trainY);

// in-sample predictions
double[] regInSamplePreds = regFit.Transform(trainX);

// out-of-sample predictions
double[] regOutSamplePreds = regFit.Transform(testX);
OrdinaryLeastSquares as a learning algorithm and MultipleLinearRegression as a model. Ordinary Least Squares (OLS) is a way of training a linear regression model by minimizing and optimizing on the sum of squares of errors. A multiple linear regression model is a model where the number of input features is larger than 1\. Lastly, in order to make predictions on data, we are using the Transform method of the MultipleLinearRegression object. We will be making predictions on both the train and test sets for our model validations in the following section.

现在我们来看一下本章将要使用的另一个学习算法和模型。以下代码展示了如何为回归问题构建和训练一个 SVM 模型:

Console.WriteLine("\n**** Linear Support Vector Machine ****");
// Linear SVM Learning Algorithm
var teacher = new LinearRegressionNewtonMethod()
{
    Epsilon = 2.1,
    Tolerance = 1e-5,
    UseComplexityHeuristic = true
};

// Train SVM
var svm = teacher.Learn(trainX, trainY);

// in-sample predictions
double[] linSVMInSamplePreds = svm.Score(trainX);

// out-of-sample predictions
double[] linSVMOutSamplePreds = svm.Score(testX);

如你所见,我们正在使用LinearRegressionNewtonMethod作为学习算法来训练一个 SVM 模型。LinearRegressionNewtonMethod是使用线性核的 SVM 学习算法。简单来说,核是一种将数据点投影到另一个空间的方法,在这个空间中,数据点比在原始空间中更容易分离。在训练 SVM 模型时,也经常使用其他核,如多项式核和高斯核。我们将在下一章中实验和进一步讨论这些其他核,但你当然可以在这个项目中尝试其他核对模型性能的影响。在使用训练好的 SVM 模型进行预测时,你可以使用代码片段中所示的Score方法。

我们用来训练和验证线性回归和 SVM 模型的完整代码可以在以下链接找到:github.com/yoonhwang/c-sharp-machine-learning/blob/master/ch.4/Modeling.cs

模型验证

现在你已经为本章的外汇汇率预测项目构建并训练了回归模型,让我们开始探讨我们的模型表现如何。在本节中,我们将讨论两个常用的基本指标,RMSE 和 R²,以及一个诊断图,实际或观察值与预测值对比。在我们深入探讨这些指标和诊断图之前,让我们首先简要讨论如何从线性回归模型中提取系数和截距值。

MultipleLinearRegressionobject:
Console.WriteLine("\n* Linear Regression Coefficients:");

for (int i = 0; i < features.Length; i++)
{
    Console.WriteLine("\t{0}: {1:0.0000}", features[i], regFit.Weights[i]);
}

Console.WriteLine("\tIntercept: {0:0.0000}", regFit.Intercept);

当你运行此代码时,你将看到以下类似的输出:

图片

观察拟合的线性回归模型的系数和截距有助于我们理解模型,并深入了解每个特征如何影响预测结果。我们能够理解和可视化特征与目标变量之间的关系是如何形成的,以及它们是如何相互作用的,这使得线性回归模型即使在其他黑盒模型(如随机森林模型或支持向量机)通常优于线性回归模型的情况下,仍然具有吸引力。正如你可以从输出中看到的那样,你可以轻松地判断哪些特征对每日回报预测有负面影响或正面影响,以及它们的影响程度。

现在我们来看看本章中用于回归模型验证的第一个指标。你可能已经熟悉 RMSE,它衡量的是预测值与实际值之间误差的平方根。RMSE 值越低,模型拟合度越好。以下代码展示了如何计算模型拟合的 RMSE:

// RMSE for in-sample 
double regInSampleRMSE = Math.Sqrt(new SquareLoss(trainX).Loss(regInSamplePreds));

// RMSE for out-sample 
double regOutSampleRMSE = Math.Sqrt(new SquareLoss(testX).Loss(regOutSamplePreds));

Console.WriteLine("RMSE: {0:0.0000} (Train) vs. {1:0.0000} (Test)", regInSampleRMSE, regOutSampleRMSE);

如此代码所示,我们正在使用 Accord.NET 框架中的SquareLoss类,该类计算预测值与实际值之间差异的平方值。为了得到 RMSE,我们需要取这个值的平方根。

我们接下来要看的下一个指标是 R²。R²经常被用作拟合优度的一个指标。值越接近 1,模型拟合度越好。以下代码展示了如何计算 R²值:

// R² for in-sample 
double regInSampleR2 = new RSquaredLoss(trainX[0].Length, trainX).Loss(regInSamplePreds);

// R² for out-sample 
double regOutSampleR2 = new RSquaredLoss(testX[0].Length, testX).Loss(regOutSamplePreds);

Console.WriteLine("R²: {0:0.0000} (Train) vs. {1:0.0000} (Test)", regInSampleR2, regOutSampleR2);

如此代码所示,我们正在使用 Accord.NET 框架中的RSquaredLoss类。我们分别对样本内预测(在训练集上的预测)和样本外预测(在测试集上的预测)进行计算。这两个值越接近,模型的过拟合程度就越低。

当你为线性回归模型运行 RMSE 和 R²的代码时,你将得到以下输出:

对于 SVM 模型,你将看到的输出如下:

从这些输出中,我们可以看到 SVM 模型在性能上远超线性回归模型。与线性回归模型相比,SVM 模型的 RMSE 要低得多。此外,SVM 模型的 R²值也远高于线性回归模型。注意线性回归模型的 R²值。当模型的拟合度不如一条简单的水平线时,就会出现这种情况,这表明我们的线性回归模型拟合度不佳。另一方面,SVM 模型的 R²值约为 0.26,这意味着 26%的目标变量方差可以通过此模型解释。

最后,我们将查看一个诊断图;实际值与预测值之间的比较。这个诊断图是观察模型拟合优度的一个很好的视觉方式。理想情况下,我们希望所有点都位于对角线上。例如,如果实际值是 1.0,那么我们希望预测值接近 1.0。点越接近对角线,模型拟合度越好。你可以使用以下代码来绘制实际值与预测值:

// Scatter Plot of expected and actual
ScatterplotBox.Show(
    String.Format("Actual vs. Prediction ({0})", modelName), testY, regOutSamplePreds
);

我们正在使用 Accord.NET 框架中的ScatterplotBox类来构建实际值与预测值之间的散点图。当你为线性回归模型运行此代码时,你会看到以下诊断图:

当你为 SVM 模型运行相同的代码时,诊断图如下所示:

如您从这些图中可以看到,线性回归模型的预测值在 0 附近更为集中,而 SVM 模型的预测值则在一个更宽的范围内分布更广。尽管线性回归和 SVM 模型结果的两个图现在都没有显示完美的对角线,但 SVM 模型的图显示了更好的结果,并且与 RMSE 和 R² 指标我们看到的结果一致。

我们编写并使用的方法来运行模型验证如下:

private static void ValidateModelResults(string modelName, double[] regInSamplePreds, double[] regOutSamplePreds, double[][] trainX, double[] trainY, double[][] testX, double[] testY)
{
    // RMSE for in-sample 
    double regInSampleRMSE = Math.Sqrt(new SquareLoss(trainX).Loss(regInSamplePreds));

    // RMSE for in-sample 
    double regOutSampleRMSE = Math.Sqrt(new SquareLoss(testX).Loss(regOutSamplePreds));

    Console.WriteLine("RMSE: {0:0.0000} (Train) vs. {1:0.0000} (Test)", regInSampleRMSE, regOutSampleRMSE);

    // R² for in-sample 
    double regInSampleR2 = new RSquaredLoss(trainX[0].Length, trainX).Loss(regInSamplePreds);

    // R² for in-sample 
    double regOutSampleR2 = new RSquaredLoss(testX[0].Length, testX).Loss(regOutSamplePreds);

    Console.WriteLine("R²: {0:0.0000} (Train) vs. {1:0.0000} (Test)", regInSampleR2, regOutSampleR2);

    // Scatter Plot of expected and actual
    ScatterplotBox.Show(
        String.Format("Actual vs. Prediction ({0})", modelName), testY, regOutSamplePreds
    );
}

摘要

在本章中,我们构建并训练了我们的第一个回归模型。我们使用了一个包含 1999 年至 2017 年间欧元和美元历史每日汇率的时间序列数据集。我们首先讨论了如何从一个未标记的原始数据集中创建目标变量,以及如何在 Deedle 框架中应用 ShiftDiff 方法来计算每日回报并创建目标变量,即一个周期前的一日回报。我们还从几个不同的角度研究了每日回报的分布,例如时间序列折线图、使用均值、标准差和分位数进行的总结统计。我们还研究了每日回报的直方图,并看到了一个绘制得很好的钟形曲线,它遵循正态分布。然后,我们介绍了外汇市场中一些常用的技术指标以及如何将它们应用于我们的特征构建过程。使用移动平均线、布林带和滞后变量等技术指标,我们构建了各种特征,帮助我们的学习算法学习如何预测未来的每日回报。在特征工程步骤中构建的这些特征,我们构建了线性回归和 SVM 模型来预测 EUR/USD 汇率。我们学习了如何从 MultipleLinearRegression 对象中提取系数和截距,以深入了解每个特征如何影响预测结果。我们还简要讨论了在构建 SVM 模型时核函数的使用。最后,我们回顾了两个常用的回归模型指标,RMSE 和 R²,以及实际值与预测值之间的诊断图。从这个模型验证步骤中,我们观察到 SVM 模型在性能上大幅优于线性回归模型。我们还讨论了与其他黑盒模型(如随机森林和 SVM 模型)相比,使用线性回归模型可以获得的可解释性比较优势。

在下一章中,我们将通过使用 Accord.NET 框架在 C# 中构建回归模型来扩展我们的知识和经验。我们将使用一个包含连续和分类变量的房价数据集,并学习如何为如此复杂的数据集构建回归模型。我们还将讨论我们可以用于 SVM 的各种核函数以及它们如何影响我们的 SVM 模型的性能。

第五章:房屋和财产的公允价值

在本章中,我们将扩展我们在 C#中构建回归机器学习(ML)模型的知识和技能。在上一章中,我们在外汇汇率数据集上构建了线性回归和线性支持向量机模型,其中所有特征都是连续变量。然而,我们将处理一个更复杂的数据集,其中一些特征是分类变量,而其他一些是连续变量。

在本章中,我们将使用一个包含房屋众多属性且变量类型混合的房价数据集。使用这些数据,我们将开始研究两种常见的分类变量类型(有序与无序)以及住房数据集中一些分类变量的分布。我们还将研究数据集中一些连续变量的分布以及使用对数变换对显示偏态分布的变量的好处。然后,我们将学习如何编码和工程化这些分类特征,以便我们可以拟合机器学习模型。与上一章我们探索支持向量机(SVM)基础知识不同,我们将为我们的 SVM 模型应用不同的核方法,并观察它如何影响模型性能。

与上一章类似,我们将使用均方根误差RMSE)、R²以及实际值与预测值的对比图来评估我们的机器学习模型的性能。在本章结束时,你将更好地理解如何处理分类变量,如何为回归模型编码和工程化这些特征,如何应用各种核方法来构建支持向量机(SVM)模型,以及如何构建预测房屋公允价值的模型。

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

  • 房屋/财产公允价值项目的问题定义

  • 分类变量与连续变量的数据分析

  • 特征工程和编码

  • 线性回归与带核的支持向量机

  • 使用 RMSE、R²和实际值与预测值对比图进行模型验证

问题定义

让我们从了解我们将要构建的机器学习模型的具体内容开始这一章。当你寻找要购买的房屋或财产时,你会考虑你看到的这些房屋或财产的众多属性。你可能正在查看卧室和浴室的数量,你能在你的车库中停放多少辆车,社区,房屋的材料或装饰,等等。所有这些房屋或财产的属性都进入了你决定为特定财产支付的价格,或者你如何与卖家协商价格的决定。然而,理解并估计财产的公允价值是非常困难的。通过拥有一个预测每个财产公允价值或最终价格的模型,你可以在与卖家协商时做出更明智的决定。

为了构建预测房屋公平价值的模型,我们将使用一个包含 79 个解释变量的数据集,这些变量涵盖了美国爱荷华州艾姆斯市几乎所有住宅的属性及其 2006 年至 2010 年的最终销售价格。这个数据集由杜鲁门州立大学的迪安·德·科克(ww2.amstat.org/publications/jse/v19n3/decock.pdf)编制,可以通过此链接下载:www.kaggle.com/c/house-prices-advanced-regression-techniques/data。利用这些数据,我们将构建包含关于房屋面积或不同部分尺寸、房屋使用的风格和材料、房屋不同部分的状况和表面处理以及描述每栋房屋信息的其他各种属性的特征。使用这些特征,我们将探索不同的回归机器学习模型,如线性回归、线性支持向量机以及具有多项式和高斯核的支持向量机(SVMs)。然后,我们将通过查看 RMSE、R²以及实际值与预测值之间的图表来评估这些模型。

为了总结我们对房屋和财产公平价值项目的定义问题:

  • 问题是什么? 我们需要一个回归模型来预测美国爱荷华州艾姆斯市的住宅公平价值,这样我们就可以在购买房屋时更好地理解和做出更明智的决策。

  • 为什么这是一个问题? 由于决定房屋或财产公平价值具有复杂性和众多变量,拥有一个能够预测并告知购房者他们所看房屋预期价值的机器学习模型是有利的。

  • 解决这个问题的方法有哪些? 我们将使用一个包含 79 个解释变量的预编译数据集,这些变量包含了美国爱荷华州艾姆斯市住宅的信息,并构建和编码混合类型(既是分类变量也是连续变量)的特征。然后,我们将探索使用不同核函数的线性回归和支持向量机来预测房屋的公平价值。我们将通过查看 RMSE、R²以及实际值与预测值之间的图表来评估模型候选者。

  • 成功的标准是什么? 我们希望我们的房价预测尽可能接近实际的房屋销售价格,因此我们希望获得尽可能低的 RMSE(均方根误差),同时不损害我们的拟合优度指标 R²,以及实际值与预测值之间的图表。

分类变量与连续变量

现在,让我们开始查看实际的数据集。您可以点击以下链接:www.kaggle.com/c/house-prices-advanced-regression-techniques/data 下载train.csvdata_description.txt文件。我们将使用train.csv文件来构建模型,而data_description.txt文件将帮助我们更好地理解数据集的结构,特别是关于我们已有的分类变量。

如果你查看训练数据文件和描述文件,你可以很容易地找到一些具有特定名称或代码的变量,它们代表每栋房屋属性的特定类型。例如,Foundation变量可以取BrkTilCBlockPConcSlabStoneWood中的任何一个值,其中这些值或代码分别代表房屋建造时所用的地基类型——砖和瓦、混凝土块、浇筑混凝土、板、石头和木材。另一方面,如果你查看数据中的TotalBsmtSF变量,你可以看到它可以取任何数值,并且这些值是连续的。如前所述,这个数据集包含混合类型的变量,我们在处理既有分类变量又有连续变量的数据集时需要谨慎处理。

非序数分类变量

让我们首先看看一些分类变量及其分布。我们将首先查看的建筑属性是建筑类型。显示建筑类型分布的柱状图的代码如下:

// Categorical Variable #1: Building Type
Console.WriteLine("\nCategorical Variable #1: Building Type");
var buildingTypeDistribution = houseDF.GetColumn&lt;string&gt;(
    "BldgType"
).GroupBy&lt;string&gt;(x =&gt; x.Value).Select(x =&gt; (double)x.Value.KeyCount);
buildingTypeDistribution.Print();

var buildingTypeBarChart = DataBarBox.Show(
    buildingTypeDistribution.Keys.ToArray(),
    buildingTypeDistribution.Values.ToArray()
);
buildingTypeBarChart.SetTitle("Building Type Distribution (Categorical)");

当你运行这段代码时,它将显示一个类似于以下的柱状图:

如您从这张柱状图中可以看出,我们数据集中大多数的建筑类型是 1Fam,这代表着独立单户住宅建筑类型。第二常见的建筑类型是 TwnhsE,代表着联排别墅端单元建筑类型。

让我们再看看一个分类变量,地块配置(数据集中的LotConfig字段)。绘制地块配置分布柱状图的代码如下:

// Categorical Variable #2: Lot Configuration
Console.WriteLine("\nCategorical Variable #1: Building Type");
var lotConfigDistribution = houseDF.GetColumn&lt;string&gt;(
    "LotConfig"
).GroupBy&lt;string&gt;(x =&gt; x.Value).Select(x =&gt; (double)x.Value.KeyCount);
lotConfigDistribution.Print();

var lotConfigBarChart = DataBarBox.Show(
    lotConfigDistribution.Keys.ToArray(),
    lotConfigDistribution.Values.ToArray()
);
lotConfigBarChart.SetTitle("Lot Configuration Distribution (Categorical)");

当你运行这段代码时,它将显示以下柱状图:

如您从这张柱状图中可以看出,内部地块是我们数据集中最常见的地块配置,其次是角地块。

序数分类变量

我们刚才查看的两个分类变量没有自然的顺序。一种类型并不在另一种类型之前,或者一种类型并不比另一种类型更重要。然而,有些分类变量具有自然顺序,我们称这样的分类变量为序数分类变量。例如,当你将材料的品质从 1 到 10 进行排名时,其中 10 代表最佳,1 代表最差,这就存在一个自然顺序。让我们看看这个数据集中的一些序数分类变量。

我们将要查看的第一个有序分类变量是OverallQual属性,它代表房屋的整体材料和装修。查看该变量分布的代码如下:

// Ordinal Categorical Variable #1: Overall material and finish of the house
Console.WriteLine("\nOrdinal Categorical #1: Overall material and finish of the house");
var overallQualDistribution = houseDF.GetColumn&lt;string&gt;(
    "OverallQual"
).GroupBy&lt;int&gt;(
    x =&gt; Convert.ToInt32(x.Value)
).Select(
    x =&gt; (double)x.Value.KeyCount
).SortByKey().Reversed;
overallQualDistribution.Print();

var overallQualBarChart = DataBarBox.Show(
    overallQualDistribution.Keys.Select(x =&gt; x.ToString()),
    overallQualDistribution.Values.ToArray()
);
overallQualBarChart.SetTitle("Overall House Quality Distribution (Ordinal)");

当你运行这段代码时,它将按从 10 到 1 的顺序显示以下条形图:

如预期的那样,在非常优秀(编码为 10)或优秀(编码为 9)类别中的房屋数量比在高于平均水平(编码为 6)或平均水平(编码为 5)类别中的房屋数量要少。

我们将要查看的另一个有序分类变量是ExterQual变量,它代表外部质量。查看该变量分布的代码如下:

// Ordinal Categorical Variable #2: Exterior Quality
Console.WriteLine("\nOrdinal Categorical #2: Exterior Quality");
var exteriorQualDistribution = houseDF.GetColumn&lt;string&gt;(
    "ExterQual"
).GroupBy&lt;string&gt;(x =&gt; x.Value).Select(
    x =&gt; (double)x.Value.KeyCount
)[new string[] { "Ex", "Gd", "TA", "Fa" }];
exteriorQualDistribution.Print();

var exteriorQualBarChart = DataBarBox.Show(
    exteriorQualDistribution.Keys.Select(x =&gt; x.ToString()),
    exteriorQualDistribution.Values.ToArray()
);
exteriorQualBarChart.SetTitle("Exterior Quality Distribution (Ordinal)");

当你运行这段代码时,它将显示以下条形图:

OverallQual变量不同,ExterQual变量没有用于排序的数值。在我们的数据集中,它有以下几种值:ExGdTAFA,分别代表优秀、良好、平均/典型和公平。尽管这个变量没有数值,但它显然有一个自然排序,其中优秀类别(Ex)代表外部材料质量的最佳,良好类别(Gd)代表外部材料质量的第二佳。在特征工程步骤中,我们将讨论如何为我们的未来模型构建步骤编码此类变量。

连续变量

我们已经查看了我们数据集中的两种类型的分类变量。然而,数据集中还有另一种类型的变量;连续变量。与分类变量不同,连续变量可以取无限多个值。例如,房屋地下室面积的平方英尺可以是任何正数。一栋房屋可以有 0 平方英尺的地下室面积(或没有地下室),或者一栋房屋可以有 1,000 平方英尺的地下室面积。我们将要查看的第一个连续变量是1stFlrSF,它代表一楼平方英尺。以下代码展示了我们如何为1stFlrSF构建直方图:

// Continuous Variable #1-1: First Floor Square Feet
var firstFloorHistogram = HistogramBox
.Show(
    houseDF.DropSparseRows()["1stFlrSF"].ValuesAll.ToArray(),
    title: "First Floor Square Feet (Continuous)"
)
.SetNumberOfBins(20);

当你运行这段代码时,将显示以下直方图:

从这张图表中明显可以看出,它在正方向上有一个长尾,换句话说,分布是右偏的。数据中的偏斜性可能会在我们构建机器学习模型时对我们产生不利影响。处理数据集中这种偏斜性的一种方法是对数据进行一些转换。一种常用的转换方法是对数转换,即取给定变量的对数值。在这个例子中,以下代码展示了我们如何对1stFlrSF变量应用对数转换并显示转换变量的直方图:

// Continuous Variable #1-2: Log of First Floor Square Feet
var logFirstFloorHistogram = HistogramBox
.Show(
    houseDF.DropSparseRows()["1stFlrSF"].Log().ValuesAll.ToArray(),
    title: "First Floor Square Feet - Log Transformed (Continuous)"
)
.SetNumberOfBins(20);

当你运行这段代码时,你将看到以下直方图:

如您从这张图表中可以看到,与之前查看的同一变量的直方图相比,分布看起来更加对称,更接近我们熟悉的钟形。对数变换通常用于处理数据集中的偏斜,并使分布更接近正态分布。让我们看看我们数据集中的另一个连续变量。以下代码用于展示 GarageArea 变量的分布,它代表车库的面积(平方英尺):

// Continuous Variable #2-1: Size of garage in square feet
var garageHistogram = HistogramBox
.Show(
    houseDF.DropSparseRows()["GarageArea"].ValuesAll.ToArray(),
    title: "Size of garage in square feet (Continuous)"
)
.SetNumberOfBins(20);

当你运行此代码时,你会看到以下直方图:

1stFlrSF 的前一个案例类似,它也是右偏斜的,尽管看起来偏斜的程度小于 1stFlrSF。我们使用了以下代码对 GarageArea 变量应用对数变换:

// Continuous Variable #2-2: Log of Value of miscellaneous feature
var logGarageHistogram = HistogramBox
.Show(
    houseDF.DropSparseRows()["GarageArea"].Log().ValuesAll.ToArray(),
    title: "Size of garage in square feet - Log Transformed (Continuous)"
)
.SetNumberOfBins(20);

当你运行此代码时,将显示以下直方图图表:

如预期的那样,当对变量应用对数变换时,分布看起来更接近正态分布。

目标变量 - 销售价格

在我们进行特征工程步骤之前,还有一个变量需要查看;即目标变量。在这个房屋公允价值项目中,我们的预测目标变量是 SalePrice,它代表美国爱荷华州艾姆斯市从 2006 年到 2010 年销售的每套住宅的最终销售价格(以美元计)。由于销售价格可以取任何正数值,因此它是一个连续变量。让我们首先看看我们是如何为销售价格变量构建直方图的:

// Target Variable: Sale Price
var salePriceHistogram = HistogramBox
.Show(
    houseDF.DropSparseRows()["SalePrice"].ValuesAll.ToArray(),
    title: "Sale Price (Continuous)"
)
.SetNumberOfBins(20);

当你运行此代码时,将显示以下直方图图表:

与之前连续变量的案例类似,SalePrice 的分布具有较长的右尾,并且严重向右偏斜。这种偏斜通常会对回归模型产生不利影响,因为一些模型,例如线性回归模型,假设变量是正态分布的。正如之前所讨论的,我们可以通过应用对数变换来解决这个问题。以下代码展示了我们如何对销售价格变量进行对数变换并构建直方图:

// Target Variable: Sale Price - Log Transformed
var logSalePriceHistogram = HistogramBox
.Show(
    houseDF.DropSparseRows()["SalePrice"].Log().ValuesAll.ToArray(),
    title: "Sale Price - Log Transformed (Continuous)"
)
.SetNumberOfBins(20);

当你运行此代码时,你会看到以下针对对数变换后的销售价格变量的直方图:

如预期的那样,SalePrice 变量的分布看起来与正态分布非常接近。我们将使用这个对数变换后的 SalePrice 变量作为我们未来模型构建步骤的目标变量。

此数据分析步骤的完整代码可以在以下链接中找到:github.com/yoonhwang/c-sharp-machine-learning/blob/master/ch.5/DataAnalyzer.cs

特征工程和编码

现在我们已经查看过我们的数据集以及分类、连续和目标变量的分布,让我们开始为我们的机器学习模型构建特征。正如我们之前讨论的,我们数据集中的分类变量有特定的字符串值来表示每种变量类型。然而,正如你可能已经清楚的那样,我们不能使用字符串类型来训练我们的机器学习模型。所有变量的值都需要是数值型的,以便能够用于拟合模型。处理具有多种类型或类别的分类变量的一种方法是通过创建虚拟变量。

虚拟变量

虚拟变量是一个变量,它取 0 或 1 的值来指示给定的类别或类型是否存在。例如,在BldgType变量的情况下,它有五个不同的类别1Fam2FmConDuplxTwnhsETwnhs,我们将创建五个虚拟变量,其中每个虚拟变量代表在给定记录中这些五个类别中的每一个的存在或不存在。以下是如何进行虚拟变量编码的一个示例:

如您从本例中可以看到,建筑类型中每个类别的存在和不存在都被编码为单独的虚拟变量,值为01。例如,对于 ID 为1的记录,建筑类型是1Fam,这在新变量BldgType_1Fam中编码为值 1,而在其他四个新变量BldgType_2fmConBldgType_DuplexBldgType_TwnhsEBldgType_Twnhs中编码为 0。另一方面,对于 ID 为10的记录,建筑类型是2fmCon,这在新变量BldgType_2fmCon中编码为值 1,而在其他四个新变量BldgType_1FamBldgType_DuplexBldgType_TwnhsEBldgType_Twnhs中编码为 0。

对于本章,我们为以下列表中的分类变量创建了虚拟变量:

string[] categoricalVars = new string[]
{
    "Alley", "BldgType", "BsmtCond", "BsmtExposure", "BsmtFinType1", "BsmtFinType2",
    "BsmtQual", "CentralAir", "Condition1", "Condition2", "Electrical", "ExterCond",
    "Exterior1st", "Exterior2nd", "ExterQual", "Fence", "FireplaceQu", "Foundation",
    "Functional", "GarageCond", "GarageFinish", "GarageQual", "GarageType",
    "Heating", "HeatingQC", "HouseStyle", "KitchenQual", "LandContour", "LandSlope", 
    "LotConfig", "LotShape", "MasVnrType", "MiscFeature", "MSSubClass", "MSZoning", 
    "Neighborhood", "PavedDrive", "PoolQC", "RoofMatl", "RoofStyle", 
    "SaleCondition", "SaleType", "Street", "Utilities"
};

以下代码显示了我们所编写的一种创建和编码虚拟变量的方法:

private static Frame&lt;int, string&gt; CreateCategories(Series&lt;int, string&gt; rows, string originalColName)
{

    var categoriesByRows = rows.GetAllValues().Select((x, i) =&gt;
    {
        // Encode the categories appeared in each row with 1
        var sb = new SeriesBuilder&lt;string, int&gt;();
        sb.Add(String.Format("{0}_{1}", originalColName, x.Value), 1);

        return KeyValue.Create(i, sb.Series);
    });

    // Create a data frame from the rows we just created
    // And encode missing values with 0
    var categoriesDF = Frame.FromRows(categoriesByRows).FillMissing(0);

    return categoriesDF;
}

如您从该方法的第 8 行中可以看到,我们在新创建的虚拟变量前加上原始分类变量的名称,并在其后加上每个类别。例如,属于1Fam类别的BldgType变量将被编码为BldgType_1Fam。然后,在第 15 行的CreateCategories方法中,我们将所有其他值编码为 0,以表示在给定的分类变量中不存在此类类别。

特征编码

现在我们知道了哪些分类变量需要编码,并为这些分类变量创建了一个虚拟变量编码方法,是时候构建一个包含特征及其值的 DataFrame 了。让我们首先看看以下代码片段中我们是如何创建特征 DataFrame 的:

var featuresDF = Frame.CreateEmpty&lt;int, string&gt;();

foreach(string col in houseDF.ColumnKeys)
{
    if (categoricalVars.Contains(col))
    {
        var categoryDF = CreateCategories(houseDF.GetColumn&lt;string&gt;(col), col);

        foreach (string newCol in categoryDF.ColumnKeys)
        {
            featuresDF.AddColumn(newCol, categoryDF.GetColumn&lt;int&gt;(newCol));
        }
    }
    else if (col.Equals("SalePrice"))
    {
        featuresDF.AddColumn(col, houseDF[col]);
        featuresDF.AddColumn("Log"+col, houseDF[col].Log());
    }
    else
    {
        featuresDF.AddColumn(col, houseDF[col].Select((x, i) =&gt; x.Value.Equals("NA")? 0.0: (double) x.Value));
    }
}
featuresDF(in line 1), and start adding in features one by one. For those categorical variables for which we are going to create dummy variables, we are calling the encoding method, CreateCategories, that we wrote previously and then adding the newly created dummy variable columns to the featuresDF data frame (in lines 5-12).  For the SalePrice variable, which is the target variable for this project, we are applying log transformation and adding it to the featuresDF data frame (in lines 13-17). Lastly, we append all the other continuous variables, after replacing the NA values with 0s, to the featuresDF data frame (in lines 18-20).

一旦我们为模型训练创建了并编码了所有特征,我们就将这个featuresDF DataFrame 导出为.csv文件。以下代码显示了如何将 DataFrame 导出为.csv文件:

string outputPath = Path.Combine(dataDirPath, "features.csv");
Console.WriteLine("Writing features DF to {0}", outputPath);
featuresDF.SaveCsv(outputPath);

现在我们有了所有必要的特征,我们可以开始构建机器学习模型来预测房屋的公允价值。特征编码和工程的全代码可以在以下链接中找到:github.com/yoonhwang/c-sharp-machine-learning/blob/master/ch.5/FeatureEngineering.cs

线性回归与核支持向量机

在我们开始训练机器学习模型之前,我们需要将我们的数据集分成训练集和测试集。在本节中,我们将通过随机子选择和按预定义比例划分索引来将样本集分成训练集和测试集。我们将用于将数据集分成训练集和测试集的代码如下:

// Split the sample set into train and test sets
double trainProportion = 0.8;

int[] shuffledIndexes = featuresDF.RowKeys.ToArray();
shuffledIndexes.Shuffle();

int trainSetIndexMax = (int)(featuresDF.RowCount * trainProportion);
int[] trainIndexes = shuffledIndexes.Where(i =&gt; i &lt; trainSetIndexMax).ToArray();
int[] testIndexes = shuffledIndexes.Where(i =&gt; i &gt;= trainSetIndexMax).ToArray();

var trainSet = featuresDF.Where(x =&gt; trainIndexes.Contains(x.Key));
var testSet = featuresDF.Where(x =&gt; testIndexes.Contains(x.Key));

Console.WriteLine("\nTrain Set Shape: ({0}, {1})", trainSet.RowCount, trainSet.ColumnCount);
Console.WriteLine("Test Set Shape: ({0}, {1})", testSet.RowCount, testSet.ColumnCount);
featuresDF data frame that we created in the previous feature engineering and encoding step into train and test sets.

一旦我们准备好了这些训练和测试数据框,我们需要从数据框中过滤掉不必要的列,因为训练和测试数据框目前有诸如SalePriceId等列的值。然后,我们将不得不将这两个数据框转换为双精度数组数组,这些数组将被输入到我们的学习算法中。过滤掉训练和测试数据框中不需要的列以及将两个数据框转换为数组数组的代码如下:

string targetVar = "LogSalePrice";
string[] features = featuresDF.ColumnKeys.Where(
    x =&gt; !x.Equals("Id") && !x.Equals(targetVar) && !x.Equals("SalePrice")
).ToArray();

double[][] trainX = BuildJaggedArray(
    trainSet.Columns[features].ToArray2D&lt;double&gt;(),
    trainSet.RowCount,
    features.Length
);
double[][] testX = BuildJaggedArray(
    testSet.Columns[features].ToArray2D&lt;double&gt;(),
    testSet.RowCount,
    features.Length
);

double[] trainY = trainSet[targetVar].ValuesAll.ToArray();
double[] testY = testSet[targetVar].ValuesAll.ToArray();

线性回归

本章将要探索的第一个机器学习模型用于房屋价格预测项目是线性回归模型。你应该已经熟悉使用 Accord.NET 框架在 C#中构建线性回归模型。我们使用以下代码构建线性回归模型:

Console.WriteLine("\n**** Linear Regression Model ****");
// OLS learning algorithm
var ols = new OrdinaryLeastSquares()
{
    UseIntercept = true,
    IsRobust = true
};

// Fit a linear regression model
MultipleLinearRegression regFit = ols.Learn(
    trainX,
    trainY
);

// in-sample predictions
double[] regInSamplePreds = regFit.Transform(trainX);
// out-of-sample predictions
double[] regOutSamplePreds = regFit.Transform(testX);

本章的线性回归模型代码与上一章代码的唯一区别是传递给OrdinaryLeastSquares学习算法的IsRobust参数。正如其名所示,它使学习算法拟合一个更稳健的线性回归模型,这意味着它对异常值不太敏感。当我们有非正态分布的变量时,就像本项目的情况一样,在拟合线性回归模型时,这通常会导致问题,因为传统的线性回归模型对非正态分布的异常值很敏感。将此参数设置为true有助于解决这个问题。

线性支持向量机

在本章中,我们将要实验的第二种学习算法是线性支持向量机。以下代码展示了我们如何构建线性支持向量机模型:

Console.WriteLine("\n**** Linear Support Vector Machine ****");
// Linear SVM Learning Algorithm
var teacher = new LinearRegressionNewtonMethod()
{
    Epsilon = 0.5,
    Tolerance = 1e-5,
    UseComplexityHeuristic = true
};

// Train SVM
var svm = teacher.Learn(trainX, trainY);

// in-sample predictions
double[] linSVMInSamplePreds = svm.Score(trainX);
// out-of-sample predictions
double[] linSVMOutSamplePreds = svm.Score(testX);

如你可能已经注意到的,并且与上一章类似,我们使用LinearRegressionNewtonMethod作为学习算法来拟合线性支持向量机。

多项式核支持向量机

我们接下来要实验的下一个模型是具有多项式核的 SVM。我们不会过多地介绍核方法,简单来说,核是输入特征变量的函数,可以将原始变量转换并投影到一个新的特征空间,这个空间更易于线性分离。多项式核考虑了原始输入特征的组合,这些输入特征变量的组合通常在回归分析中被称为交互变量。使用不同的核方法会使 SVM 模型在相同的数据集上学习并表现出不同的行为。

以下代码展示了如何构建具有多项式核的 SVM 模型:

Console.WriteLine("\n**** Support Vector Machine with a Polynomial Kernel ****");
// SVM with Polynomial Kernel
var polySVMLearner = new FanChenLinSupportVectorRegression&lt;Polynomial&gt;()
{
    Epsilon = 0.1,
    Tolerance = 1e-5,
    UseKernelEstimation = true,
    UseComplexityHeuristic = true,
    Kernel = new Polynomial(3)
};

// Train SVM with Polynomial Kernel
var polySvm = polySVMLearner.Learn(trainX, trainY);

// in-sample predictions
double[] polySVMInSamplePreds = polySvm.Score(trainX);
// out-of-sample predictions
double[] polySVMOutSamplePreds = polySvm.Score(testX);

我们使用FanChenLinSupportVectorRegression学习算法来构建具有多项式核的支持向量机。在这个例子中,我们使用了 3 次多项式,但您可以尝试不同的次数。然而,次数越高,越有可能过拟合训练数据。因此,当您使用高次多项式核时,必须谨慎行事。

具有高斯核的 SVM

另一种常用的核方法是高斯核。简单来说,高斯核考虑了输入特征变量之间的距离,对于接近或相似的特征给出较高的值,而对于距离较远的特征给出较低的值。高斯核可以帮助将线性不可分的数据集转换为一个更易于线性分离的特征空间,并可以提高模型性能。

以下代码展示了如何构建具有高斯核的 SVM 模型:

Console.WriteLine("\n**** Support Vector Machine with a Gaussian Kernel ****");
// SVM with Gaussian Kernel
var gaussianSVMLearner = new FanChenLinSupportVectorRegression&lt;Gaussian&gt;()
{
    Epsilon = 0.1,
    Tolerance = 1e-5,
    Complexity = 1e-4,
    UseKernelEstimation = true,
    Kernel = new Gaussian()
};

// Train SVM with Gaussian Kernel
var gaussianSvm = gaussianSVMLearner.Learn(trainX, trainY);

// in-sample predictions
double[] guassianSVMInSamplePreds = gaussianSvm.Score(trainX);
// out-of-sample predictions
double[] guassianSVMOutSamplePreds = gaussianSvm.Score(testX);

与多项式核的情况类似,我们使用了FanChenLinSupportVectorRegression学习算法,但将核替换为Gaussian方法。

到目前为止,我们已经讨论了如何为 SVM 使用不同的核方法。现在,我们将比较这些模型在房价数据集上的性能。您可以在以下链接找到我们构建和评估模型所使用的完整代码:github.com/yoonhwang/c-sharp-machine-learning/blob/master/ch.5/Modeling.cs

模型验证

在我们开始查看上一节中构建的线性回归和 SVM 模型的性能之前,让我们回顾一下上一章中讨论的指标和诊断图。我们将查看 RMSE、R²以及实际值与预测值对比的图表来评估我们模型的性能。本节中我们将用于模型评估的代码如下:

private static void ValidateModelResults(string modelName, double[] regInSamplePreds, double[] regOutSamplePreds, double[][] trainX, double[] trainY, double[][] testX, double[] testY)
{
    // RMSE for in-sample 
    double regInSampleRMSE = Math.Sqrt(new SquareLoss(trainX).Loss(regInSamplePreds));
    // RMSE for out-sample 
    double regOutSampleRMSE = Math.Sqrt(new SquareLoss(testX).Loss(regOutSamplePreds));

    Console.WriteLine("RMSE: {0:0.0000} (Train) vs. {1:0.0000} (Test)", regInSampleRMSE, regOutSampleRMSE);

    // R² for in-sample 
    double regInSampleR2 = new RSquaredLoss(trainX[0].Length, trainX).Loss(regInSamplePreds);
    // R² for out-sample 
    double regOutSampleR2 = new RSquaredLoss(testX[0].Length, testX).Loss(regOutSamplePreds);

    Console.WriteLine("R²: {0:0.0000} (Train) vs. {1:0.0000} (Test)", regInSampleR2, regOutSampleR2);

    // Scatter Plot of expected and actual
    var scatterplot = ScatterplotBox.Show(
        String.Format("Actual vs. Prediction ({0})", modelName), testY, regOutSamplePreds
    );

}

我们使用这种方法构建模型的方式如下:

ValidateModelResults("Linear Regression", regInSamplePreds, regOutSamplePreds, trainX, trainY, testX, testY);
ValidateModelResults("Linear SVM", linSVMInSamplePreds, linSVMOutSamplePreds, trainX, trainY, testX, testY);
ValidateModelResults("Polynomial SVM", polySVMInSamplePreds, polySVMOutSamplePreds, trainX, trainY, testX, testY);
ValidateModelResults("Guassian SVM", guassianSVMInSamplePreds, guassianSVMOutSamplePreds, trainX, trainY, testX, testY);
ValidateModelResults method. When you run this code, you will see the following output on your console:

当查看拟合优度、R²和 RMSE 值时,线性 SVM 模型似乎与数据集的拟合最佳,具有高斯核的 SVM 模型似乎与数据集的拟合次之。查看这个输出,多项式核的 SVM 模型似乎不适合预测房价公允价值。现在,让我们查看诊断图来评估我们的模型在预测房价方面的表现。

下面的图显示了线性回归模型的诊断图:

图片

这个线性回归模型的诊断图看起来很好。大多数点似乎都位于对角线上,这表明线性回归模型的预测值与实际值很好地对齐。

下面的图显示了线性 SVM 模型的诊断图:

图片

如前所述的 R²指标值所预期的那样,线性 SVM 模型的拟合效果看起来很好,尽管似乎有一个预测值与实际值相差甚远。大多数点似乎都位于对角线上,这表明线性 SVM 模型的预测值与实际值很好地对齐。

下面的图显示了具有多项式核的 SVM 模型的诊断图:

图片

这个具有多项式核的 SVM 模型的诊断图表明,该模型的拟合效果并不好。大多数预测值都位于大约 12 的直线上。这与其他指标很好地一致,我们在其中看到 RMSE 和 R²指标在我们尝试的四个模型中是最差的。

下面的图显示了具有高斯核的 SVM 模型的诊断图:

图片

这个具有高斯核的 SVM 模型的诊断图结果相当令人惊讶。从 RMSE 和 R²指标来看,我们原本预期使用高斯核的 SVM 模型拟合效果会很好。然而,这个模型的大部分预测都位于一条直线上,没有显示出任何对角线的模式。查看这个诊断图,我们不能得出结论说具有高斯核的 SVM 模型拟合效果良好,尽管 R²指标显示了模型拟合良好的强烈正信号。

通过查看指标数值和诊断图,我们可以得出结论,线性回归模型和线性 SVM 模型似乎在预测房价公允价值方面表现最佳。这个项目向我们展示了查看诊断图的重要性。仅仅关注单个指标可能很有吸引力,但始终最好使用多个验证指标来评估模型,查看诊断图,如实际值与预测值的图,对于回归模型尤其有帮助。

摘要

在本章中,我们扩展了关于构建回归模型的知识和技能。我们使用了美国爱荷华州艾姆斯市的住宅房屋的销售价格数据来构建预测模型。与其他章节不同,我们有一个更复杂的数据库,其中的变量具有混合类型,包括分类和连续变量。我们研究了分类变量,其中没有自然顺序(非序数)和有自然顺序(序数)的类别。然后我们研究了连续变量,其分布具有长的右尾。我们还讨论了如何使用对数变换来处理数据中具有高偏度的变量,以调节偏度并使这些变量的分布更接近正态分布。

我们讨论了如何处理数据集中的分类变量。我们学习了如何为每种类型的分类变量创建和编码虚拟变量。使用这些特征,我们尝试了四种不同的机器学习模型——线性回归、线性支持向量机、具有多项式核的支持向量机和具有高斯核的支持向量机。我们简要讨论了核方法的目的和用法以及它们如何用于线性不可分的数据集。使用 RMSE、R²以及实际值与预测值的图表,我们评估了我们构建的四个模型在预测美国爱荷华州艾姆斯市房屋公平价值方面的性能。在我们的模型验证步骤中,我们看到了一个案例,其中验证指标的结果与诊断图的结果相矛盾,我们学到了查看多个指标和诊断图的重要性,以确保我们模型的表现。

在下一章中,我们将再次转换方向。到目前为止,我们一直在学习如何使用和构建监督学习算法。然而,在下一章中,我们将学习无监督学习,特别是聚类算法。我们将讨论如何使用聚类算法通过在线零售数据集来深入了解客户细分。

第六章:客户细分

在本章中,我们将学习无监督学习模型及其如何从数据中提取洞察。到目前为止,我们一直专注于监督学习,其中我们的机器学习(ML)模型有已知的目标变量,它们试图预测这些变量。我们已经为垃圾邮件过滤和 Twitter 情感分析构建了分类模型。我们还为外汇汇率预测和预测房价公允价值构建了回归模型。我们迄今为止构建的所有这些机器学习模型都是监督学习算法,其中模型学习将给定的输入映射到预期的结果。然而,在某些情况下,我们更感兴趣的是从数据集中发现隐藏的洞察和推断,我们可以使用无监督学习算法来完成这些任务。

在本章中,我们将使用一个包含客户购买的商品价格和数量信息的在线零售数据集。我们将通过观察购买订单和取消订单中商品价格和数量的分布差异来探索这些数据。我们还将研究在线商店活动在不同国家之间的分布情况。然后,我们将把这一级交易数据转换和汇总成客户级数据。在我们将数据转换为以客户为中心的视角时,我们将讨论为无监督学习算法构建规模无关特征的方法。有了这个特征集,我们将使用 k-means 聚类算法来构建客户细分市场,并从每个细分市场内提取客户行为洞察。我们将介绍一个新的验证指标,即轮廓系数,以评估聚类结果。

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

  • 客户细分项目的问题定义

  • 在线零售数据集的数据分析

  • 特征工程和汇总

  • 使用 k-means 聚类算法进行无监督学习

  • 使用轮廓系数进行聚类模型验证

问题定义

让我们更详细地讨论我们将要解决的问题,并构建聚类模型。无论你是试图向客户发送营销邮件,还是仅仅想更好地了解你的客户及其在线商店的行为,你都将想要分析和识别不同类型和细分市场的客户。一些客户可能会一次性购买大量商品(批量购买者),一些可能会主要购买昂贵或奢侈品(奢侈品购买者),或者一些可能只购买了一两件商品就再也没有回来(不活跃客户)。根据这些行为模式,你的营销活动应该有所不同。例如,发送关于奢侈品促销的邮件可能会激发奢侈品购买者登录在线商店并购买某些商品,但这种营销活动可能对批量购买者效果不佳。另一方面,发送关于经常批量购买的物品(如办公用品的笔和便签本)的促销邮件可能会使批量购买者登录在线商店并下订单,但这可能对奢侈品购买者没有吸引力。通过根据客户的行为模式识别客户细分,并使用定制化的营销活动,你可以优化你的营销渠道。

为了构建客户细分模型,我们将使用一个包含 2010 年 1 月 12 日至 2011 年 9 月 12 日之间发生的所有交易的在线零售数据集,该数据集属于一家英国在线零售店。这个数据集可在 UCI 机器学习仓库中找到,并可通过以下链接下载:archive.ics.uci.edu/ml/datasets/online+retail#。利用这些数据,我们将构建包含关于净收入、平均商品价格和每位客户平均购买数量的特征。使用这些特征,我们将使用k-means 聚类算法构建一个聚类模型,将客户基础划分为不同的细分市场。我们将使用轮廓系数指标来评估聚类质量,并推断出构建客户细分的最优数量。

为了总结客户细分项目的定义问题:

  • 问题是什么?我们需要一个聚类模型,将客户划分为不同的集群,以便我们更好地理解和提取关于客户行为模式的见解。

  • 为什么这是一个问题?没有一种适合所有不同类型客户的营销活动都能奏效。我们需要为批量购买者和奢侈品购买者分别构建定制化的营销活动。此外,我们还需要将未参与活动的客户与其他客户类型区分开来,以便让他们重新参与产品。营销信息越定制化,客户参与的可能性就越大。如果我们有一个基于在线商店中客户行为模式将客户基础聚类到不同段落的机器学习模型,这将是一个巨大的优势。

  • 解决这个问题的方法有哪些?我们将使用包含 2010 年至 2011 年中期所有交易的在线零售数据集来聚合关键特征,例如每个客户的净收入、平均单价和平均购买数量。然后,我们将使用 k-means 聚类算法构建聚类模型,并使用轮廓系数来评估聚类的质量并选择最佳聚类数量。

  • 成功的标准是什么?我们不希望有太多的聚类,因为这会使解释和理解不同客户模式变得更加困难。我们将使用轮廓系数得分来告诉我们用于客户分段的最佳聚类数量。

在线零售数据集的数据分析

现在是查看数据集的时候了。您可以点击以下链接:archive.ics.uci.edu/ml/datasets/online+retail#,点击左上角的Data Folder链接,并下载Online Retail.xlsx文件。您可以将文件保存为 CSV 格式,并将其加载到 Deedle 数据框中。

处理缺失值

由于我们将对每个客户的交易数据进行聚合,我们需要检查CustomerID列中是否有任何缺失值。以下截图显示了一些没有CustomerID的记录:

我们将删除CustomerIDDescriptionQuantityUnitPriceCountry列中包含缺失值的记录。以下代码片段显示了如何删除这些列的缺失值记录:

// 1\. Missing CustomerID Values
ecommerceDF
    .Columns[new string[] { "CustomerID", "InvoiceNo", "StockCode", "Quantity", "UnitPrice", "Country" }]
    .GetRowsAt(new int[] { 1440, 1441, 1442, 1443, 1444, 1445, 1446 })
    .Print();
Console.WriteLine("\n\n* # of values in CustomerID column: {0}", ecommerceDF["CustomerID"].ValueCount);

// Drop missing values
ecommerceDF = ecommerceDF
    .Columns[new string[] { "CustomerID", "Description", "Quantity", "UnitPrice", "Country" }]
    .DropSparseRows();

// Per-Transaction Purchase Amount = Quantity * UnitPrice
ecommerceDF.AddColumn("Amount", ecommerceDF["Quantity"] * ecommerceDF["UnitPrice"]);

Console.WriteLine("\n\n* Shape (After dropping missing values): {0}, {1}\n", ecommerceDF.RowCount, ecommerceDF.ColumnCount);
Console.WriteLine("* After dropping missing values and unnecessary columns:");
ecommerceDF.GetRowsAt(new int[] { 0, 1, 2, 3, 4 }).Print();
// Export Data
ecommerceDF.SaveCsv(Path.Combine(dataDirPath, "data-clean.csv"));

我们使用 Deedle 数据框的DropSparseRows方法来删除我们感兴趣列中所有包含缺失值的记录。然后,我们添加一个额外的列Amount到数据框中,这是给定交易的总额。我们可以通过将单价乘以数量来计算这个值。

如您从前面的图像中可以看到,我们在删除缺失值之前有 541,909 条记录。在从我们感兴趣的列中删除包含缺失值的记录后,数据框中的记录数最终变为 406,829 条。现在,我们有一个包含所有交易信息的CustomerIDDescriptionQuantityUnitPriceCountry的数据框。

变量分布

让我们开始查看数据集中的分布。首先,我们将查看交易量最大的前五个国家。我们用来按国家聚合记录并计算每个国家发生的交易数量的代码如下:

// 2\. Number of transactions by country
var numTransactionsByCountry = ecommerceDF
    .AggregateRowsBy<string, int>(
        new string[] { "Country" },
        new string[] { "CustomerID" },
        x => x.ValueCount
    ).SortRows("CustomerID");

var top5 = numTransactionsByCountry
    .GetRowsAt(new int[] {
        numTransactionsByCountry.RowCount-1, numTransactionsByCountry.RowCount-2,
        numTransactionsByCountry.RowCount-3, numTransactionsByCountry.RowCount-4,
        numTransactionsByCountry.RowCount-5 });
top5.Print();

var topTransactionByCountryBarChart = DataBarBox.Show(
    top5.GetColumn<string>("Country").Values.ToArray().Select(x => x.Equals("United Kingdom") ? "UK" : x),
    top5["CustomerID"].Values.ToArray()
);
topTransactionByCountryBarChart.SetTitle(
    "Top 5 Countries with the most number of transactions"
 );
AggregateRowsBy method in the Deedle data frame to group the records by country and count the total number of transactions for each country. Then, we sort the resulting data frame using the SortRows method and take the top five countries. When you run this code, you will see the following bar chart:

图片

交易量最大的前五个国家的交易数量如下:

图片

如预期,交易数量最多的是英国。德国和法国分别排在第二和第三位,交易数量最多。

让我们开始查看我们将用于聚类模型的特征分布——购买数量、单价和净额。我们将以三种方式查看这些分布。首先,我们将获取每个特征的总体分布,无论交易是购买还是取消。其次,我们将仅查看购买订单,排除取消订单。第三,我们将查看仅取消订单的分布。

获取交易数量分布的代码如下:

// 3\. Per-Transaction Quantity Distributions
Console.WriteLine("\n\n-- Per-Transaction Order Quantity Distribution-- ");
double[] quantiles = Accord.Statistics.Measures.Quantiles(
    ecommerceDF["Quantity"].ValuesAll.ToArray(),
    new double[] { 0, 0.25, 0.5, 0.75, 1.0 }
);
Console.WriteLine(
    "Min: \t\t\t{0:0.00}\nQ1 (25% Percentile): \t{1:0.00}\nQ2 (Median): \t\t{2:0.00}\nQ3 (75% Percentile): \t{3:0.00}\nMax: \t\t\t{4:0.00}",
    quantiles[0], quantiles[1], quantiles[2], quantiles[3], quantiles[4]
);

Console.WriteLine("\n\n-- Per-Transaction Purchase-Order Quantity Distribution-- ");
quantiles = Accord.Statistics.Measures.Quantiles(
    ecommerceDF["Quantity"].Where(x => x.Value >= 0).ValuesAll.ToArray(),
    new double[] { 0, 0.25, 0.5, 0.75, 1.0 }
);
Console.WriteLine(
    "Min: \t\t\t{0:0.00}\nQ1 (25% Percentile): \t{1:0.00}\nQ2 (Median): \t\t{2:0.00}\nQ3 (75% Percentile): \t{3:0.00}\nMax: \t\t\t{4:0.00}",
    quantiles[0], quantiles[1], quantiles[2], quantiles[3], quantiles[4]
);

Console.WriteLine("\n\n-- Per-Transaction Cancel-Order Quantity Distribution-- ");
quantiles = Accord.Statistics.Measures.Quantiles(
    ecommerceDF["Quantity"].Where(x => x.Value < 0).ValuesAll.ToArray(),
    new double[] { 0, 0.25, 0.5, 0.75, 1.0 }
);
Console.WriteLine(
    "Min: \t\t\t{0:0.00}\nQ1 (25% Percentile): \t{1:0.00}\nQ2 (Median): \t\t{2:0.00}\nQ3 (75% Percentile): \t{3:0.00}\nMax: \t\t\t{4:0.00}",
    quantiles[0], quantiles[1], quantiles[2], quantiles[3], quantiles[4]
);

如前一章所述,我们使用分位数方法来计算四分位数——最小值、25%分位数、中位数、75%分位数和最大值。一旦我们得到每笔订单数量的总体分布,我们再查看购买订单和取消订单的分布。在我们的数据集中,取消订单在数量列中用负数表示。为了将取消订单与购买订单分开,我们可以简单地从我们的数据表中过滤出正数和负数,如下面的代码所示:

// Filtering out cancel orders to get purchase orders only
ecommerceDF["Quantity"].Where(x => x.Value >= 0)
// Filtering out purchase orders to get cancel orders only
ecommerceDF["Quantity"].Where(x => x.Value < 0)

为了获取每笔交易单位价格的四分位数,我们使用以下代码:

// 4\. Per-Transaction Unit Price Distributions
Console.WriteLine("\n\n-- Per-Transaction Unit Price Distribution-- ");
quantiles = Accord.Statistics.Measures.Quantiles(
    ecommerceDF["UnitPrice"].ValuesAll.ToArray(),
    new double[] { 0, 0.25, 0.5, 0.75, 1.0 }
);
Console.WriteLine(
    "Min: \t\t\t{0:0.00}\nQ1 (25% Percentile): \t{1:0.00}\nQ2 (Median): \t\t{2:0.00}\nQ3 (75% Percentile): \t{3:0.00}\nMax: \t\t\t{4:0.00}",
    quantiles[0], quantiles[1], quantiles[2], quantiles[3], quantiles[4]
);

同样,我们可以使用以下代码计算每笔交易的总额的四分位数:

// 5\. Per-Transaction Purchase Price Distributions
Console.WriteLine("\n\n-- Per-Transaction Total Amount Distribution-- ");
quantiles = Accord.Statistics.Measures.Quantiles(
    ecommerceDF["Amount"].ValuesAll.ToArray(),
    new double[] { 0, 0.25, 0.5, 0.75, 1.0 }
);
Console.WriteLine(
    "Min: \t\t\t{0:0.00}\nQ1 (25% Percentile): \t{1:0.00}\nQ2 (Median): \t\t{2:0.00}\nQ3 (75% Percentile): \t{3:0.00}\nMax: \t\t\t{4:0.00}",
    quantiles[0], quantiles[1], quantiles[2], quantiles[3], quantiles[4]
);

Console.WriteLine("\n\n-- Per-Transaction Purchase-Order Total Amount Distribution-- ");
quantiles = Accord.Statistics.Measures.Quantiles(
    ecommerceDF["Amount"].Where(x => x.Value >= 0).ValuesAll.ToArray(),
    new double[] { 0, 0.25, 0.5, 0.75, 1.0 }
);
Console.WriteLine(
    "Min: \t\t\t{0:0.00}\nQ1 (25% Percentile): \t{1:0.00}\nQ2 (Median): \t\t{2:0.00}\nQ3 (75% Percentile): \t{3:0.00}\nMax: \t\t\t{4:0.00}",
    quantiles[0], quantiles[1], quantiles[2], quantiles[3], quantiles[4]
);

Console.WriteLine("\n\n-- Per-Transaction Cancel-Order Total Amount Distribution-- ");
quantiles = Accord.Statistics.Measures.Quantiles(
    ecommerceDF["Amount"].Where(x => x.Value < 0).ValuesAll.ToArray(),
    new double[] { 0, 0.25, 0.5, 0.75, 1.0 }
);
Console.WriteLine(
    "Min: \t\t\t{0:0.00}\nQ1 (25% Percentile): \t{1:0.00}\nQ2 (Median): \t\t{2:0.00}\nQ3 (75% Percentile): \t{3:0.00}\nMax: \t\t\t{4:0.00}",
    quantiles[0], quantiles[1], quantiles[2], quantiles[3], quantiles[4]
);

当你运行代码时,你会看到以下输出,显示了每笔交易的订单数量、单价和总额的分布:

图片

如果你查看输出中的总体订单数量分布,你会注意到从第一个四分位数(25%分位数)开始,数量是正的。这表明取消订单的数量远少于购买订单,这对在线零售店来说实际上是个好事。让我们看看在我们的数据集中购买订单和取消订单是如何划分的。

使用以下代码,你可以绘制条形图来比较购买订单与取消订单的数量:

// 6\. # of Purchase vs. Cancelled Transactions
var purchaseVSCancelBarChart = DataBarBox.Show(
    new string[] { "Purchase", "Cancel" },
    new double[] {
        ecommerceDF["Quantity"].Where(x => x.Value >= 0).ValueCount ,
        ecommerceDF["Quantity"].Where(x => x.Value < 0).ValueCount
    }
);
purchaseVSCancelBarChart.SetTitle(
    "Purchase vs. Cancel"
 );

当你运行此代码时,你会看到以下条形图:

图片

如预期并在之前的分布输出中所示,取消订单的数量远少于购买订单的数量。有了这些分析结果,我们将在下一节开始构建用于客户细分聚类模型的特征。

此数据分析步骤的完整代码可以通过以下链接找到:github.com/yoonhwang/c-sharp-machine-learning/blob/master/ch.6/DataAnalyzer.cs

特征工程和数据聚合

我们现在数据集中的记录代表单个交易。然而,我们想要构建一个聚类模型,将客户聚类到不同的细分市场。为了做到这一点,我们需要按客户对数据进行转换和聚合。换句话说,我们需要按 CustomerID 对数据进行分组,并通过求和、计数或取值平均值来聚合每个客户所属的交易。让我们先看一个例子。以下代码按 CustomerID 对交易级别数据进行分组,并计算净收入、交易总数、取消订单总数、平均单价和平均订单数量:

// 1\. Net Revenue per Customer
var revPerCustomerDF = ecommerceDF.AggregateRowsBy<double, double>(
    new string[] { "CustomerID" },
    new string[] { "Amount" },
    x => x.Sum()
);
// 2\. # of Total Transactions per Customer
var numTransactionsPerCustomerDF = ecommerceDF.AggregateRowsBy<double, double>(
    new string[] { "CustomerID" },
    new string[] { "Quantity" },
    x => x.ValueCount
);
// 3\. # of Cancelled Transactions per Customer
var numCancelledPerCustomerDF = ecommerceDF.AggregateRowsBy<double, double>(
    new string[] { "CustomerID" },
    new string[] { "Quantity" },
    x => x.Select(y => y.Value >= 0 ? 0.0 : 1.0).Sum()
);
// 4\. Average UnitPrice per Customer
var avgUnitPricePerCustomerDF = ecommerceDF.AggregateRowsBy<double, double>(
    new string[] { "CustomerID" },
    new string[] { "UnitPrice" },
    x => x.Sum() / x.ValueCount
);
// 5\. Average Quantity per Customer
var avgQuantityPerCustomerDF = ecommerceDF.AggregateRowsBy<double, double>(
    new string[] { "CustomerID" },
    new string[] { "Quantity" },
    x => x.Sum() / x.ValueCount
);

如您从这段代码中看到,我们在 Deedle 数据框中使用了 AggregateRowsBy 方法,并为每个聚合传递了一个自定义的 aggFunc。在第一个例子中,我们计算每个客户的净收入时,我们汇总了每个客户的购买金额。对于第二个特征,我们计算交易数量以确定每个客户的订单总数。为了计算每个客户的平均订单数量,我们将所有订单数量相加,然后除以交易数量。如您所看到的这个案例,当您需要使用自定义 aggregation 函数转换和聚合数据框时,AggregateRowsBy 方法非常有用。

一旦我们计算了所有这些特征,我们需要将这些数据合并到一个地方。我们创建了一个新的空数据框,并将这些聚合特征作为单独的列添加到新的数据框中。以下代码展示了我们如何创建特征数据框:

// Aggregate all results
var featuresDF = Frame.CreateEmpty<int, string>();
featuresDF.AddColumn("CustomerID", revPerCustomerDF.GetColumn<double>("CustomerID"));
featuresDF.AddColumn("Description", ecommerceDF.GetColumn<string>("Description"));
featuresDF.AddColumn("NetRevenue", revPerCustomerDF.GetColumn<double>("Amount"));
featuresDF.AddColumn("NumTransactions", numTransactionsPerCustomerDF.GetColumn<double>("Quantity"));
featuresDF.AddColumn("NumCancelled", numCancelledPerCustomerDF.GetColumn<double>("Quantity"));
featuresDF.AddColumn("AvgUnitPrice", avgUnitPricePerCustomerDF.GetColumn<double>("UnitPrice"));
featuresDF.AddColumn("AvgQuantity", avgQuantityPerCustomerDF.GetColumn<double>("Quantity"));
featuresDF.AddColumn("PercentageCancelled", featuresDF["NumCancelled"] / featuresDF["NumTransactions"]);

Console.WriteLine("\n\n* Feature Set:");
featuresDF.Print();
PercentageCancelled, while we were appending those aggregated features to the new data frame. The PercentageCancelled feature simply holds information about how many of the transactions or orders were cancelled.

为了更仔细地查看这些特征的分布,我们编写了一个辅助函数,该函数计算给定特征的 四分位数 并打印出结果。此辅助函数的代码如下:

private static void PrintQuartiles(Frame<int, string> df, string colname)
{
    Console.WriteLine("\n\n-- {0} Distribution-- ", colname);
    double[] quantiles = Accord.Statistics.Measures.Quantiles(
        df[colname].ValuesAll.ToArray(),
        new double[] { 0, 0.25, 0.5, 0.75, 1.0 }
    );
    Console.WriteLine(
        "Min: \t\t\t{0:0.00}\nQ1 (25% Percentile): \t{1:0.00}\nQ2 (Median): \t\t{2:0.00}\nQ3 (75% Percentile): \t{3:0.00}\nMax: \t\t\t{4:0.00}",
        quantiles[0], quantiles[1], quantiles[2], quantiles[3], quantiles[4]
    );
}
quartiles for the features we just created:
// NetRevenue feature distribution
PrintQuartiles(featuresDF, "NetRevenue");
// NumTransactions feature distribution
PrintQuartiles(featuresDF, "NumTransactions");
// AvgUnitPrice feature distribution
PrintQuartiles(featuresDF, "AvgUnitPrice");
// AvgQuantity feature distribution
PrintQuartiles(featuresDF, "AvgQuantity");
// PercentageCancelled feature distribution
PrintQuartiles(featuresDF, "PercentageCancelled");

此代码的输出如下所示:

图片

如果您仔细观察,会发现一个问题令人担忧。有少数客户具有负净收入和负平均数量。这表明一些客户的取消订单数量可能超过购买订单数量。然而,这是奇怪的。要取消订单,首先需要有一个购买订单。这表明我们的数据集可能不完整,存在一些没有匹配先前购买订单的孤儿取消订单。由于我们无法回到过去为那些有孤儿取消订单的客户提取更多数据,处理这个问题的最简单方法就是删除那些有孤儿取消订单的客户。以下代码展示了我们可以用来删除此类客户的某些标准:

// 1\. Drop Customers with Negative NetRevenue
featuresDF = featuresDF.Rows[
    featuresDF["NetRevenue"].Where(x => x.Value >= 0.0).Keys
];
// 2\. Drop Customers with Negative AvgQuantity
featuresDF = featuresDF.Rows[
    featuresDF["AvgQuantity"].Where(x => x.Value >= 0.0).Keys
];
// 3\. Drop Customers who have more cancel orders than purchase orders
featuresDF = featuresDF.Rows[
    featuresDF["PercentageCancelled"].Where(x => x.Value < 0.5).Keys
];

如您从这段代码片段中可以看到,我们删除了任何具有负净收入、负平均数量和取消订单百分比超过 50%的客户。在删除这些客户后,结果分布看起来如下:

图片

如您从这些分布中可以看到,每个特征的尺度都非常不同。NetRevenue的范围从 0 到 279,489.02,而PercentageCancelled的范围从 0 到 0.45。我们将把这些特征转换成百分位数,这样我们就可以让所有特征都在 0 到 1 的同一尺度上。以下代码显示了如何计算每个特征的百分位数:

// Create Percentile Features
featuresDF.AddColumn(
    "NetRevenuePercentile",
    featuresDF["NetRevenue"].Select(
        x => StatsFunctions.PercentileRank(featuresDF["NetRevenue"].Values.ToArray(), x.Value)
    )
);
featuresDF.AddColumn(
    "NumTransactionsPercentile",
    featuresDF["NumTransactions"].Select(
        x => StatsFunctions.PercentileRank(featuresDF["NumTransactions"].Values.ToArray(), x.Value)
    )
);
featuresDF.AddColumn(
    "AvgUnitPricePercentile",
    featuresDF["AvgUnitPrice"].Select(
        x => StatsFunctions.PercentileRank(featuresDF["AvgUnitPrice"].Values.ToArray(), x.Value)
    )
);
featuresDF.AddColumn(
    "AvgQuantityPercentile",
    featuresDF["AvgQuantity"].Select(
        x => StatsFunctions.PercentileRank(featuresDF["AvgQuantity"].Values.ToArray(), x.Value)
    )
);
featuresDF.AddColumn(
    "PercentageCancelledPercentile",
    featuresDF["PercentageCancelled"].Select(
        x => StatsFunctions.PercentileRank(featuresDF["PercentageCancelled"].Values.ToArray(), x.Value)
    )
);
Console.WriteLine("\n\n\n* Percentile Features:");
featuresDF.Columns[
    new string[] { "NetRevenue", "NetRevenuePercentile", "NumTransactions", "NumTransactionsPercentile" }
].Print();
StatsFunctions.PercentileRank method, which is part of the CenterSpace.NMath.Stats package. You can easily install this package using the following command in the Package Manager console:
Install-Package CenterSpace.NMath.Stats

使用StatsFunctions.PercentileRank方法,我们可以计算每条记录的百分位数。以下输出显示了NetRevenueNumTransactions特征的计算结果:

图片

如您从输出中可以看到,这两个特征的值现在都在 0 到 1 之间,而不是一个广泛的范围。在下一节构建聚类模型时,我们将使用这些百分位数特征。

这个特征工程步骤的完整代码可以在以下链接中找到:github.com/yoonhwang/c-sharp-machine-learning/blob/master/ch.6/FeatureEngineering.cs

无监督学习 - k-means 聚类

现在是时候开始构建我们的聚类模型了。在这个项目中,我们将尝试根据以下三个特征将客户聚类到不同的细分市场:NetRevenuePercentileAvgUnitPricePercentileAvgQuantityPercentile,这样我们就可以根据客户的消费习惯来分析商品选择。在我们开始将 k-means 聚类算法拟合到特征集之前,有一个重要的步骤我们需要采取。我们需要对特征进行归一化,这样我们的聚类模型就不会对某些特征赋予比其他特征更多的权重。如果特征的方差不同,那么聚类算法可能会对那些方差小的特征赋予更多的权重,并倾向于将它们聚在一起。以下代码显示了如何归一化每个特征:

string[] features = new string[] { "NetRevenuePercentile", "AvgUnitPricePercentile", "AvgQuantityPercentile" };
Console.WriteLine("* Features: {0}\n\n", String.Join(", ", features));

var normalizedDf = Frame.CreateEmpty<int, string>();
var average = ecommerceDF.Columns[features].Sum() / ecommerceDF.RowCount;
foreach(string feature in features)
{
    normalizedDf.AddColumn(feature, (ecommerceDF[feature] - average[feature]) / ecommerceDF[feature].StdDev());
}

现在我们已经归一化了变量,让我们开始构建聚类模型。为了构建一个 k-means 聚类模型,我们需要提前知道我们想要的聚类数量。由于我们不知道最佳聚类数量是多少,我们将尝试几个不同的聚类数量,并依靠验证指标,即轮廓分数(Silhouette Score),来告诉我们最佳的聚类数量是多少。以下代码显示了如何构建使用 k-means 聚类算法的聚类模型:

int[] numClusters = new int[] { 4, 5, 6, 7, 8 };
List<string> clusterNames = new List<string>();
List<double> silhouetteScores = new List<double>();
for(int i = 0; i < numClusters.Length; i++)
{
    KMeans kmeans = new KMeans(numClusters[i]);
    KMeansClusterCollection clusters = kmeans.Learn(sampleSet);
    int[] labels = clusters.Decide(sampleSet);

    string colname = String.Format("Cluster-{0}", numClusters[i]);
    clusterNames.Add(colname);

    normalizedDf.AddColumn(colname, labels);
    ecommerceDF.AddColumn(colname, labels);

    Console.WriteLine("\n\n\n##################### {0} ###########################", colname);

    Console.WriteLine("\n\n* Centroids for {0} clusters:", numClusters[i]);

    PrintCentroidsInfo(clusters.Centroids, features);
    Console.WriteLine("\n");

    VisualizeClusters(normalizedDf, colname, "NetRevenuePercentile", "AvgUnitPricePercentile");
    VisualizeClusters(normalizedDf, colname, "AvgUnitPricePercentile", "AvgQuantityPercentile");
    VisualizeClusters(normalizedDf, colname, "NetRevenuePercentile", "AvgQuantityPercentile");

    for (int j = 0; j < numClusters[i]; j++)
    {
        GetTopNItemsPerCluster(ecommerceDF, j, colname);
    }

    double silhouetteScore = CalculateSilhouetteScore(normalizedDf, features, numClusters[i], colname);
    Console.WriteLine("\n\n* Silhouette Score: {0}", silhouetteScore.ToString("0.0000"));

    silhouetteScores.Add(silhouetteScore);
    Console.WriteLine("\n\n##############################################################\n\n\n");
}
4, 5, 6, 7, and 8 clusters. We can instantiate a k-means clustering algorithm object using the KMeans class in the Accord.NET framework. Using the Learn method, we can train a k-means clustering model with the feature set we have. Then, we can use the Decide method to get the cluster labels for each record.

当您运行此代码时,它将输出每个聚类的中心点。以下是一个 4 聚类聚类模型的聚类中心点输出:

图片

如您从输出结果中可以看到,标签为 3 的簇是由那些具有高净收入、中等偏高的平均单价和中等偏高的平均数量的客户组成的。因此,这些客户是高价值客户,他们带来了最多的收入,并且以高于平均的价格购买数量也高于平均水平的商品。相比之下,标签为 1 的簇是由那些具有低净收入、高平均单价和中等偏低平均数量的客户组成的。因此,这些客户以平均数量购买昂贵的商品,并且为在线商店带来的收入并不多。您可能已经注意到这个例子,您已经可以看到不同簇之间的一些模式。现在让我们看看每个细分市场中的哪些客户购买最多。以下是为 4 簇聚类模型每个细分市场购买的前 10 个商品:

图片

每个细分市场的这个前 10 个商品列表为您提供了一个大致的概念,了解每个细分市场的客户购买最多的商品类型。这超出了本章的范围,但您可以进一步分析商品描述中的单个单词,并使用词频分析,例如我们在第二章“垃圾邮件过滤”和第三章“Twitter 情感分析”中所做的那样。可视化聚类结果的另一种方法是绘制细分市场的散点图。以下图表显示了 4 簇聚类模型中NetRevenuePercentileAvgQuantityPercentile的散点图:

图片

以下图表显示了 4 簇聚类模型中AvgUnitPricePercentileAvgQuantityPercentile的散点图:

图片

以下图表显示了 4 簇聚类模型中NetRevenuePercentileAvgUnitPricePercentile的散点图:

图片

如您从这些图表中可以看到,散点图是可视化每个簇形成方式和每个簇边界的好方法。例如,如果您查看NetRevenuePercentileAvgUnitPricePercentile的散点图,簇 1 具有高平均单价和低净收入。这与我们从查看簇中心点得出的发现相对应。对于更高维度和更多簇的情况,使用散点图进行可视化变得更加困难。然而,在图表中进行可视化通常有助于更容易地从这些聚类分析中得出见解。让我们开始探讨如何使用轮廓系数评估簇的质量和选择最佳簇数量。

在这个 k-means 聚类步骤中使用的完整代码可以在以下链接中找到:github.com/yoonhwang/c-sharp-machine-learning/blob/master/ch.6/Clustering.cs

使用轮廓系数进行聚类模型验证

Silhouette 系数Silhouette 分数提供了一种简单的方法来评估簇的质量。Silhouette 系数衡量一个对象与其自身簇的紧密程度相对于其他簇。计算 Silhouette 系数的方法如下;对于每个记录,i,计算该记录与同一簇中所有其他记录的平均距离,并称这个数字为a[i]。然后,计算该记录与每个其他簇中所有其他记录的平均距离,对于所有其他簇,取最低的平均距离,并称这个数字为b[i]。一旦你有了这两个数字,从b[i]中减去a[i],然后除以a[i]b[i]之间的最大值。你迭代这个过程,为数据集中的所有记录计算平均值以获得 Silhouette 系数。以下是一个用于计算单个数据点 Silhouette 系数的公式:

图片

为了获得最终的 Silhouette 值,你需要遍历数据点,并取 Silhouette 值的平均值。Silhouette 系数介于-1 和 1 之间。越接近 1,簇的质量越好。以下代码展示了我们如何实现这个公式:

private static double CalculateSilhouetteScore(Frame<int, string> df, string[] features, int numCluster, string clusterColname)
{
    double[][] data = BuildJaggedArray(df.Columns[features].ToArray2D<double>(), df.RowCount, features.Length);

    double total = 0.0;
    for(int i = 0; i < df.RowCount; i++)
    {
        double sameClusterAverageDistance = 0.0;
        double differentClusterDistance = 1000000.0;

        double[] point = df.Columns[features].GetRowAt<double>(i).Values.ToArray();
        double cluster = df[clusterColname].GetAt(i);

        for(int j = 0; j < numCluster; j++)
        {
            double averageDistance = CalculateAverageDistance(df, features, clusterColname, j, point);

            if (cluster == j)
            {
                sameClusterAverageDistance = averageDistance;
            } else
            {
                differentClusterDistance = Math.Min(averageDistance, differentClusterDistance);
            }
        }

        total += (differentClusterDistance - sameClusterAverageDistance) / Math.Max(sameClusterAverageDistance, differentClusterDistance);
    }

    return total / df.RowCount;
}

以下是一个计算数据点与簇中所有点之间平均距离的辅助函数:

private static double CalculateAverageDistance(Frame<int, string> df, string[] features, string clusterColname, int cluster, double[] point)
{
    var clusterDF = df.Rows[
        df[clusterColname].Where(x => (int)x.Value == cluster).Keys
    ];
    double[][] clusterData = BuildJaggedArray(
        clusterDF.Columns[features].ToArray2D<double>(),
        clusterDF.RowCount,
        features.Length
    );

    double averageDistance = 0.0;
    for (int i = 0; i < clusterData.Length; i++)
    {
        averageDistance += Math.Sqrt(
            point.Select((x, j) => Math.Pow(x - clusterData[i][j], 2)).Sum()
        );
    }
    averageDistance /= (float)clusterData.Length;

    return averageDistance;
}

从代码中可以看出,我们遍历每个数据点,并开始计算给定数据点与不同簇中所有其他记录的平均距离。然后,我们取不同簇中最低平均距离与同一簇中平均距离之间的差值,并除以这两个数字中的最大值。一旦我们遍历了所有数据点,我们取这个 Silhouette 值的平均值,并将其作为聚类模型的 Silhouette 系数返回。

当你运行具有不同簇数量的聚类模型代码时,你会看到以下类似的输出:

图片

从这个输出中可以看出,随着我们将簇的数量增加到一定点,Silhouette Score 会增加,然后又下降。在我们的案例中,具有六个簇的 k-means 聚类模型表现最佳,六个簇似乎是我们数据集的最佳选择。

经常情况下,仅仅查看轮廓系数并不足以决定最佳的聚类数量。例如,一个具有大量聚类的聚类模型可以拥有很高的轮廓分数,但这并不能帮助我们从这个聚类模型中得出任何见解。由于聚类分析主要用于解释性分析,以从数据中提取见解和识别隐藏的模式,因此聚类结果的可解释性非常重要。将轮廓分数与二维或三维散点图相结合,将有助于你确定最佳聚类数量,并决定什么对你的数据集和项目最有意义。

摘要

在本章中,我们探讨了无监督学习及其如何用于从数据中提取见解和识别隐藏的模式。与迄今为止我们工作的其他项目不同,我们没有特定的目标变量,我们的机器学习模型可以从这些变量中学习。我们只有一个原始的在线零售数据集,其中包含了客户在在线商店购买的商品、数量和单价信息。使用这个给定的数据集,我们将交易级数据转换为客户级数据,并创建了大量的聚合特征。我们学习了如何利用 Deedle 的数据框中的AggregateRowsBy方法来创建聚合特征并将数据集转换为以客户为中心的视图。然后我们简要讨论了一个新的库,CenterSpace.NMath.Stats,我们可以用它来进行各种统计计算。更具体地说,我们使用了StatsFunctions.PercentileRank方法来计算给定特征的每个记录的百分位数。

我们介绍了如何使用Accord.NET框架来拟合 k-means 聚类算法。使用 k-means 聚类算法,我们能够构建具有不同聚类数量的几个聚类模型。我们以 4 聚类聚类模型为例,讨论了如何通过它来提取见解,以及如何将客户聚类到不同的客户细分市场,其中某一细分市场的客户特征是高净收入、平均单价高于平均水平以及平均数量高于平均水平,而另一细分市场的客户特征是低净收入、高平均单价以及平均数量低于平均水平,等等。然后我们查看每个客户细分市场购买频率最高的前 10 个商品,并在我们的特征空间上创建了不同细分市场的散点图。

最后,我们使用了S轮廓系数来评估聚类质量,并学习了如何将其作为选择最佳聚类数量的标准之一。

从下一章开始,我们将开始构建音频和图像数据集的模型。在下一章中,我们将讨论如何使用音乐音频数据集构建音乐流派推荐模型。我们将学习如何构建一个输出为各个类别可能性排名的排名系统。我们还将学习使用哪些类型的指标来评估这样的排名模型。

第七章:音乐流派推荐

在本章中,我们将回到监督学习。我们已经使用逻辑回归、朴素贝叶斯、随机森林和支持向量机SVM)等学习算法为分类和回归问题构建了大量的监督学习算法。然而,我们构建的这些模型输出的数量始终是单一的。在我们的 Twitter 情感分析项目中,输出只能是积极、消极或中性之一。另一方面,在我们的房价预测项目中,输出是预测的房价的对数。与我们的先前项目不同,有些情况下我们希望我们的机器学习ML)模型能够输出多个值。推荐系统就是需要能够产生排序预测的 ML 模型的一个例子。

在本章中,我们将使用一个包含各种音频特征的数据库,这些特征是从众多音乐录音中编译而来的。利用这些数据,我们将探讨音频特征值,如声音谱的峰度和偏度,在不同歌曲流派中的分布情况。然后,我们将开始构建多个 ML 模型,这些模型将输出给定歌曲属于每个音乐流派预测的概率,而不是仅输出给定歌曲最可能流派的一个预测输出。一旦我们构建了这些模型,我们将进一步将这些基础模型的预测结果进行集成,以构建一个用于最终歌曲音乐流派推荐的元模型。我们将使用不同的模型验证指标,平均倒数排名MRR),来评估我们的排序模型。

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

  • 音乐流派推荐项目的问题定义

  • 音频特征数据集的数据分析

  • 音乐流派分类的机器学习模型

  • 集成基础学习模型

  • 评估推荐/排序模型

问题定义

让我们更详细地探讨,并正确定义我们将要解决的问题以及为这个项目构建的机器学习模型。音乐流媒体服务,如 Pandora 和 Spotify,需要音乐推荐系统,这样他们就可以推荐和播放听众可能喜欢的歌曲。构建音乐推荐系统的方式不止一种。一种方式是查看其他类似用户听过的歌曲,而定义类似用户的方法是查看他们听过的歌曲历史。然而,如果用户是平台的新用户,或者我们没有足够的历史歌曲数据,这种方法可能不会很好地工作。在这种情况下,我们不能依赖于历史数据。相反,使用用户当前正在听的歌曲的属性来推荐其他音乐会更好。一首歌曲的属性在音乐推荐中可以发挥重要作用的是音乐类型。一个用户当前在平台上听音乐时,很可能喜欢继续听相同或相似的音乐。想象一下,你正在听器乐音乐,而音乐流媒体应用突然播放了摇滚音乐。这不会是一个顺畅的过渡,也不会是一个好的用户体验,因为你很可能想继续听器乐音乐。通过正确识别歌曲的类型并推荐正确的歌曲类型来播放,你可以避免打扰你的音乐流媒体服务的用户体验。

为了构建音乐类型推荐模型,我们将使用FMA:音乐分析数据集,它包含超过 10 万首歌曲的大量数据。该数据集包含关于专辑、标题、音频属性等信息,完整的数据集可以通过此链接找到并下载:github.com/mdeff/fma。有了这些数据,我们将选择感兴趣的特征,并构建多个输出每首歌曲属于不同音乐类型概率的机器学习模型。然后,我们将根据概率对音乐类型进行排序。我们将尝试各种学习算法,如逻辑回归、朴素贝叶斯和 SVM。我们将进一步使用集成技术,将这些模型的输出作为另一个机器学习模型的输入,该模型产生最终的预测和推荐输出。我们将使用 MRR 作为评估我们的音乐类型推荐模型的指标。

为了总结我们对音乐类型推荐项目的定义问题:

  • 问题是怎样的?我们需要一个推荐模型,该模型能够根据歌曲属于每个音乐类型的可能性进行排序,以便我们能够正确识别歌曲的类型并推荐下一首歌曲。

  • 这为什么是个问题?使用历史数据为音乐推荐并不适用于那些刚接触该平台的新用户,因为他们不会有足够的历史数据来进行好的音乐推荐。在这种情况下,我们将不得不使用音频和其他特征来识别接下来要播放的音乐。正确识别和推荐音乐流派是确定接下来要播放哪首歌的第一步。

  • 解决这个问题的方法有哪些?我们将使用公开可用的音乐数据,这些数据不仅包含关于专辑、标题和艺术家信息,还包含关于众多音频特征的信息。然后,我们将构建输出概率的 ML 模型,并使用这个概率输出对给定歌曲的流派进行排序。

  • 成功的标准是什么?我们希望正确的音乐流派能够作为预测流派中的前几个出现。我们将使用 MRR 作为评估排名模型的指标。

音频特征数据集的数据分析

让我们开始研究音频特征数据集。为了专注于构建音乐流派推荐模型,我们将原始数据集FMA:音乐分析数据集进行了裁剪。您可以从以下链接下载此数据:github.com/yoonhwang/c-sharp-machine-learning/blob/master/ch.7/sample.csv

目标变量分布

genre_top, and counted the number of records for each genre:
var genreCount = featuresDF.AggregateRowsBy<string, int>(
    new string[] { "genre_top" },
    new string[] { "track_id" },
    x => x.ValueCount
).SortRows("track_id");

genreCount.Print();

var barChart = DataBarBox.Show(
    genreCount.GetColumn<string>("genre_top").Values.ToArray().Select(x => x.Substring(0,3)),
    genreCount["track_id"].Values.ToArray()
).SetTitle(
    "Genre Count"
);

与前几章类似,我们在 Deedle 数据框中使用了AggregateRowsBy方法来计算每个流派记录的数量。然后,我们使用了DataBarBox类来创建一个条形图,直观地显示了目标变量的分布。如您从这段代码片段(第 10 行)中看到的那样,我们正在使用每个流派名称的前三个字母作为条形图中每个流派标签。

当您运行此代码时,您将看到以下输出,显示了目标变量的分布:

图片

下面的图表显示了目标变量的条形图分布:

图片

如您从这张图表中可以看到,在我们的样本集中,InstrumentalIns)音乐的数字最大,其次是ElectronicEle)和RockRoc),分别位列第二和第三。尽管这个样本集中某些流派的歌曲比其他流派多,但这仍然是一个相对均衡的集合,其中没有任何一个流派占据样本记录的大多数。现在,让我们来看看我们一些特征的分部情况。

音频特征 – MFCC

对于这个项目,我们将关注完整数据集具有的子集特征。我们将使用 梅尔频率倒谱系数MFCCs)及其统计分布作为我们机器学习模型的特征。简单来说,MFCC 是声音频谱的表示,我们将使用其统计分布、峰度、偏度、最小值、最大值、平均值、中位数和标准差。如果您查看从上一步下载的样本集,您将看到列名是根据相应的统计分布命名的。我们将首先查看这些特征的分布。以下代码片段显示了我们是如何计算每个特征的四分位数的:

foreach (string col in featuresDF.ColumnKeys)
{
    if (col.StartsWith("mfcc"))
    {
        int idx = int.Parse(col.Split('.')[2]);
        if(idx <= 4)
        {
            Console.WriteLine(String.Format("\n\n-- {0} Distribution -- ", col));
            double[] quantiles = Accord.Statistics.Measures.Quantiles(
                featuresDF[col].ValuesAll.ToArray(),
                new double[] { 0, 0.25, 0.5, 0.75, 1.0 }
            );
            Console.WriteLine(
                "Min: \t\t\t{0:0.00}\nQ1 (25% Percentile): \t{1:0.00}\nQ2 (Median): \t\t{2:0.00}\nQ3 (75% Percentile): \t{3:0.00}\nMax: \t\t\t{4:0.00}",
                quantiles[0], quantiles[1], quantiles[2], quantiles[3], quantiles[4]
            );
        }
    }
}

与前几章类似,我们在 Accord.Statistics.Measures 类中使用 Quantiles 方法来计算四分位数,这三个数将值分为四个子集——最小值和中位数之间的中间数(25^(th) 百分位数),中位数(50^(th) 百分位数),以及中位数和最大值之间的中间数(75^(th) 百分位数)。如您在代码片段的第 6 行中看到的那样,我们只显示了前四个系数的统计分布。对于您进一步的实验,您可以查看所有 MFCC 特征的分布,而不仅限于这四个。让我们快速看一下其中的一些分布。

前四个系数峰度的分布看起来如下:

图片

如您从输出结果中可以看到,大多数峰度值介于 -2 和 5 之间,但也有一些情况峰度可以取较大的值。现在让我们看看前四个系数的偏度分布:

图片

偏度变化范围较窄。通常,偏度值似乎介于 -15 和 5 之间。最后,让我们看看前四个系数平均值的分布:

图片

如您从输出结果中可以看到,平均值似乎有所变化,并且范围较广。它可以取介于 -1,000 和 300 之间的任何值。

现在我们对音频特征分布有了大致的了解,让我们看看是否能在不同流派的特征分布中找到任何差异。我们将绘制一个散点图,其中 x 轴是每个特征的索引,y 轴是给定特征的值。让我们先看看这些图表,因为有了视觉辅助将更容易理解。

以下图表显示了四个不同流派峰度的分布:

图片

如前所述,x 轴指的是每个特征的索引。由于我们有 20 个 MFCC 峰度的独立特征,x 值的范围从 1 到 20。另一方面,y 轴显示了给定特征的分布。如您从这张图表中看到的那样,不同流派之间特征分布存在一些差异,这将有助于我们的机器学习模型学习如何正确预测给定歌曲的流派。

以下图表显示了四种不同流派偏度的分布:

图片

最后,以下图表显示了四种不同流派的平均分布:

图片

与峰度和偏度相比,不同流派中每个特征的均值分布似乎更相似。

为了创建这些图表,我们使用了ScatterplotBox类。以下代码展示了我们如何创建之前的图表:

string[] attributes = new string[] { "kurtosis", "min", "max", "mean", "median", "skew", "std" };
foreach (string attribute in attributes)
{
    string[] featureColumns = featuresDF.ColumnKeys.Where(x => x.Contains(attribute)).ToArray();
    foreach (string genre in genreCount.GetColumn<string>("genre_top").Values)
    {
        var genreDF = featuresDF.Rows[
            featuresDF.GetColumn<string>("genre_top").Where(x => x.Value == genre).Keys
        ].Columns[featureColumns];

        ScatterplotBox.Show(
            BuildXYPairs(
                genreDF.Columns[featureColumns].ToArray2D<double>(),
                genreDF.RowCount,
                genreDF.ColumnCount
            )
        ).SetTitle(String.Format("{0}-{1}", genre, attribute));
    }
}

如您从这段代码中看到的那样,我们从第 2 行开始迭代不同的统计分布(峰度最小值最大值等),在第 7 行中从featuresDF中子选择我们感兴趣的列。然后,我们编写并使用了一个辅助函数来构建散点图的 x-y 对数组,并使用ScatterplotBox类的Show方法显示它。

构建散点图 x-y 对的辅助函数的代码如下:

private static double[][] BuildXYPairs(double[,] ary2D, int rowCount, int columnCount)
{
    double[][] ary = new double[rowCount*columnCount][];
    for (int i = 0; i < rowCount; i++)
    {
        for (int j = 0; j < columnCount; j++)
        {
            ary[i * columnCount + j] = new double[2];
            ary[i * columnCount + j][0] = j + 1;
            ary[i * columnCount + j][1] = ary2D[i, j];
        }
    }
    return ary;
}

如您从这段代码中看到的,该方法将特征的索引作为 x 值,将特征的值作为 y 值。

此数据分析步骤的完整代码可以在以下链接找到:github.com/yoonhwang/c-sharp-machine-learning/blob/master/ch.7/DataAnalyzer.cs

音乐流派分类的机器学习模型

我们现在将开始构建音乐流派分类的机器学习模型。在这个项目中,我们机器学习模型的输出将采取稍微不同的形式。与其他我们已经构建的监督学习模型不同,我们希望我们的模型为给定歌曲的每个流派输出可能性或概率。因此,我们的模型输出将不是单个值,而是八个值,其中每个值将代表给定歌曲属于八个流派(电子、实验、民谣、嘻哈、乐器、国际、流行和摇滚)之一的概率。为了实现这一点,我们将在我们迄今为止一直在使用的Decide方法之上使用每个模型类中的Probabilities方法。

逻辑回归

我们将要实验的第一个模型是逻辑回归。以下代码展示了我们如何构建一个用于训练和测试集 80/20 分割的逻辑回归分类器:

// 1\. Train a LogisticRegression Classifier
Console.WriteLine("\n---- Logistic Regression Classifier ----\n");
var logitSplitSet = new SplitSetValidation<MultinomialLogisticRegression, double[]>()
{
    Learner = (s) => new MultinomialLogisticLearning<GradientDescent>()
    {
        MiniBatchSize = 500
    },

    Loss = (expected, actual, p) => new ZeroOneLoss(expected).Loss(actual),

    Stratify = false,

    TrainingSetProportion = 0.8,

    ValidationSetProportion = 0.2,

};

var logitResult = logitSplitSet.Learn(input, output);

var logitTrainedModel = logitResult.Model;

// Store train & test set indexes to train other classifiers on the same train set
// and test on the same validation set
int[] trainSetIDX = logitSplitSet.IndicesTrainingSet;
int[] testSetIDX = logitSplitSet.IndicesValidationSet;

如您应该已经熟悉的那样,我们使用SplitSetValidation将我们的样本集分为训练集和测试集。我们使用样本集的 80%进行训练,其余的 20%用于测试和评估我们的模型。我们使用MultinomialLogisticRegression作为多类分类器的模型,使用带有GradientDescentMultinomialLogisticLearning作为学习算法。与前面的章节类似,我们使用ZeroOneLoss作为分类器的Loss函数。

如您在代码底部所看到的,我们将训练好的逻辑回归分类器模型存储到单独的变量logitTrainedModel中,同时也存储了训练和测试集的索引,以便在训练和测试其他学习算法时使用。我们这样做是为了能够在不同的 ML 模型之间进行模型性能的面对面比较。

使用此训练好的逻辑回归模型进行样本内和样本外预测的代码如下:

// Get in-sample & out-of-sample predictions and prediction probabilities for each class
double[][] trainProbabilities = new double[trainSetIDX.Length][];
int[] logitTrainPreds = new int[trainSetIDX.Length];
for (int i = 0; i < trainSetIDX.Length; i++)
{
    logitTrainPreds[i] = logitTrainedModel.Decide(input[trainSetIDX[i]]);
    trainProbabilities[i] = logitTrainedModel.Probabilities(input[trainSetIDX[i]]);
}

double[][] testProbabilities = new double[testSetIDX.Length][];
int[] logitTestPreds = new int[testSetIDX.Length];
for (int i = 0; i < testSetIDX.Length; i++)
{
    logitTestPreds[i] = logitTrainedModel.Decide(input[testSetIDX[i]]);
    testProbabilities[i] = logitTrainedModel.Probabilities(input[testSetIDX[i]]);
}

如前所述,我们使用MultinomialLogisticRegression模型的Probabilities方法,它输出一个概率数组,每个索引代表给定歌曲属于相应音乐类型的概率。以下代码展示了我们如何编码每个类型:

IDictionary<string, int> targetVarCodes = new Dictionary<string, int>
{
    { "Electronic", 0 },
    { "Experimental", 1 },
    { "Folk", 2 },
    { "Hip-Hop", 3 },
    { "Instrumental", 4 },
    { "International", 5 },
    { "Pop", 6 },
    { "Rock", 7 }
};
featuresDF.AddColumn("target", featuresDF.GetColumn<string>("genre_top").Select(x => targetVarCodes[x.Value]));

让我们尝试使用与逻辑回归模型相同的训练和测试集索引来训练另一个 ML 模型。

带高斯核的 SVM

使用以下代码,您可以训练一个多类 SVM 模型:

// 2\. Train a Gaussian SVM Classifier
Console.WriteLine("\n---- Gaussian SVM Classifier ----\n");
var teacher = new MulticlassSupportVectorLearning<Gaussian>()
{
    Learner = (param) => new SequentialMinimalOptimization<Gaussian>()
    {
        Epsilon = 2,
        Tolerance = 1e-2,
        Complexity = 1000,
        UseKernelEstimation = true
    }
};
// Train SVM model using the same train set that was used for Logistic Regression Classifier
var svmTrainedModel = teacher.Learn(
    input.Where((x,i) => trainSetIDX.Contains(i)).ToArray(),
    output.Where((x, i) => trainSetIDX.Contains(i)).ToArray()
);

如您从这段代码中可以看到,与我们之前构建的 SVM 模型相比,只有一个细微的差别。我们使用MulticlassSupportVectorLearning而不是之前在第五章“房屋和财产的公允价值”中使用的LinearRegressionNewtonMethodFanChenLinSupportVectorRegression。这是因为我们现在有一个多类分类问题,需要为这样的 SVM 模型使用不同的学习算法。正如我们在另一章中讨论过的,超参数,如EpsilonToleranceComplexity,是可以调整的,您应该尝试其他值以获得性能更好的模型。

这里需要注意的一点是,当我们训练 SVM 模型时,我们使用与构建逻辑回归模型相同的训练集。如您在代码底部所看到的,我们选择了与之前用于逻辑回归模型的训练集相同索引的记录。这是为了确保我们可以正确地进行 SVM 模型与逻辑回归模型性能的面对面比较。

与之前的逻辑回归模型类似,我们使用以下代码进行样本内和样本外预测,使用训练好的 SVM 模型:

// Get in-sample & out-of-sample predictions and prediction probabilities for each class
double[][] svmTrainProbabilities = new double[trainSetIDX.Length][];
int[] svmTrainPreds = new int[trainSetIDX.Length];
for (int i = 0; i < trainSetIDX.Length; i++)
{
    svmTrainPreds[i] = svmTrainedModel.Decide(input[trainSetIDX[i]]);
    svmTrainProbabilities[i] = svmTrainedModel.Probabilities(input[trainSetIDX[i]]);
}

double[][] svmTestProbabilities = new double[testSetIDX.Length][];
int[] svmTestPreds = new int[testSetIDX.Length];
for (int i = 0; i < testSetIDX.Length; i++)
{
    svmTestPreds[i] = svmTrainedModel.Decide(input[testSetIDX[i]]);
    svmTestProbabilities[i] = svmTrainedModel.Probabilities(input[testSetIDX[i]]);
}

MulticlassSupportVectorMachine 类还提供了 Probabilities 方法,通过这个方法我们可以得到一首歌曲属于八个流派中的每一个流派的可能性。我们将这些概率输出存储到单独的变量中,svmTrainProbabilitiessvmTestProbabilities,用于我们未来的模型评估和模型集成。

朴素贝叶斯

我们将构建一个用于音乐流派分类的更多机器学习模型。我们将训练一个朴素贝叶斯分类器。以下代码展示了如何为具有连续值的输入构建朴素贝叶斯分类器:

// 3\. Train a NaiveBayes Classifier
Console.WriteLine("\n---- NaiveBayes Classifier ----\n");
var nbTeacher = new NaiveBayesLearning<NormalDistribution>();

var nbTrainedModel = nbTeacher.Learn(
    input.Where((x, i) => trainSetIDX.Contains(i)).ToArray(),
    output.Where((x, i) => trainSetIDX.Contains(i)).ToArray()
);

从这段代码中可以看出,我们正在使用 NormalDistribution 作为 NaiveBayesLearning 的分布。与之前章节不同,那时我们的朴素贝叶斯分类器的特征是词频,我们现在有音频特征的连续值。在这种情况下,我们需要构建一个高斯朴素贝叶斯分类器。与构建 SVM 模型时类似,我们使用与逻辑回归模型相同的训练集来训练朴素贝叶斯分类器。

以下代码展示了如何使用训练好的朴素贝叶斯分类器获取样本内和样本外的预测概率输出:

// Get in-sample & out-of-sample predictions and prediction probabilities for each class
double[][] nbTrainProbabilities = new double[trainSetIDX.Length][];
int[] nbTrainPreds = new int[trainSetIDX.Length];
for (int i = 0; i < trainSetIDX.Length; i++)
{
    nbTrainProbabilities[i] = nbTrainedModel.Probabilities(input[trainSetIDX[i]]);
    nbTrainPreds[i] = nbTrainedModel.Decide(input[trainSetIDX[i]]);
}

double[][] nbTestProbabilities = new double[testSetIDX.Length][];
int[] nbTestPreds = new int[testSetIDX.Length];
for (int i = 0; i < testSetIDX.Length; i++)
{
    nbTestProbabilities[i] = nbTrainedModel.Probabilities(input[testSetIDX[i]]);
    nbTestPreds[i] = nbTrainedModel.Decide(input[testSetIDX[i]]);
}

MulticlassSupportVectorMachineMultinomialLogisticRegression 类类似,NaiveBayes 模型也提供了 Probabilities 方法。从代码中可以看出,我们将样本内和样本外的预测概率分别存储到两个单独的变量中,nbTrainProbabilitiesnbTestProbabilities

在下一节中,我们将探讨如何将我们迄今为止构建的这些模型进行组合和集成。构建机器学习模型的完整代码可以在以下链接找到:github.com/yoonhwang/c-sharp-machine-learning/blob/master/ch.7/Modeling.cs

将基础学习模型进行集成

集成学习是将训练好的模型结合起来以提高其预测能力的方法。我们在之前章节中构建的随机森林分类器就是集成学习的一个例子。它构建了一个决策树森林,其中每个树都是使用样本集的一部分样本和特征进行训练的。这种集成学习方法被称为 bagging。本章我们将使用的集成方法是 stacking。Stacking 是指使用其他模型的输出构建一个新的机器学习模型,这些模型被称为 基础学习模型

在这个项目中,我们将在之前章节中构建的逻辑回归、SVM 和 Naive Bayes 模型的预测概率输出之上构建一个新的朴素贝叶斯分类器模型。构建新模型的第一步是构建训练输入。以下代码展示了我们如何将基础模型的全部输出组合起来:

// 4\. Ensembling Base Models
Console.WriteLine("\n-- Building Meta Model --");
double[][] combinedTrainProbabilities = new double[trainSetIDX.Length][];
for (int i = 0; i < trainSetIDX.Length; i++)
{
    List<double> combined = trainProbabilities[i]
        .Concat(svmTrainProbabilities[i])
        .Concat(nbTrainProbabilities[i])
        .ToList();
    combined.Add(logitTrainPreds[i]);
    combined.Add(svmTrainPreds[i]);
    combined.Add(nbTrainPreds[i]);

    combinedTrainProbabilities[i] = combined.ToArray();
}

double[][] combinedTestProbabilities = new double[testSetIDX.Length][];
for (int i = 0; i < testSetIDX.Length; i++)
{
    List<double> combined = testProbabilities[i]
        .Concat(svmTestProbabilities[i])
        .Concat(nbTestProbabilities[i])
        .ToList();
    combined.Add(logitTestPreds[i]);
    combined.Add(svmTestPreds[i]);
    combined.Add(nbTestPreds[i]);

    combinedTestProbabilities[i] = combined.ToArray();
}
Console.WriteLine("\n* input shape: ({0}, {1})\n", combinedTestProbabilities.Length, combinedTestProbabilities[0].Length);

如此代码所示,我们正在将迄今为止构建的三个模型的预测概率进行拼接。使用这些概率输出数据作为输入,我们将构建一个新的元模型,使用朴素贝叶斯学习算法。以下是我们训练此元模型的代码:

// Build meta-model using NaiveBayes Learning Algorithm
var metaModelTeacher = new NaiveBayesLearning<NormalDistribution>();
var metamodel = metaModelTeacher.Learn(
    combinedTrainProbabilities, 
    output.Where((x, i) => trainSetIDX.Contains(i)).ToArray()
);

从此代码中,你可以看到我们仍在使用NormalDistribution,因为输入是一组连续值。然后,我们使用之前训练的基础学习模型的组合概率输出来训练这个新的朴素贝叶斯分类器。类似于之前的步骤,我们通过使用Probabilities方法从元模型获取预测输出,并将这些结果存储到单独的变量中。使用此新元模型获取训练集和测试集预测输出的代码如下:

// Get in-sample & out-of-sample predictions and prediction probabilities for each class
double[][] metaTrainProbabilities = new double[trainSetIDX.Length][];
int[] metamodelTrainPreds = new int[trainSetIDX.Length];
for (int i = 0; i < trainSetIDX.Length; i++)
{
    metaTrainProbabilities[i] = metamodel.Probabilities(combinedTrainProbabilities[i]);
    metamodelTrainPreds[i] = metamodel.Decide(combinedTrainProbabilities[i]);
}

double[][] metaTestProbabilities = new double[testSetIDX.Length][];
int[] metamodelTestPreds = new int[testSetIDX.Length];
for (int i = 0; i < testSetIDX.Length; i++)
{
    metaTestProbabilities[i] = metamodel.Probabilities(combinedTestProbabilities[i]);
    metamodelTestPreds[i] = metamodel.Decide(combinedTestProbabilities[i]);
}

现在我们已经构建了所有模型,让我们开始查看这些模型的性能。在接下来的部分,我们将评估基础模型以及我们刚刚构建的元模型的性能。

评估推荐/排序模型

评估对结果进行排序的推荐模型与评估分类模型有很大不同。除了模型预测是否正确之外,我们还关心正确结果在推荐模型中的排名。换句话说,一个预测正确结果为第二名的模型比预测为第四或第五名的模型要好。例如,当你在一个搜索引擎上搜索某物时,在第一页顶部获得最合适的文档是很好的,但即使该文档作为第一页或第二页上的第二个或第三个链接出现,也是可以接受的,只要它不会出现在第一页或第二页的底部。在接下来的几节中,我们将讨论一些评估此类推荐和排序模型的方法。

预测准确率

首先要查看的最简单指标是准确率。对于我们所构建的第一个逻辑回归模型,我们可以使用以下代码来获取准确率:

Console.WriteLine(String.Format("train accuracy: {0:0.0000}", 1-logitResult.Training.Value));
Console.WriteLine(String.Format("validation accuracy: {0:0.0000}", 1-logitResult.Validation.Value));

对于以下模型,SVM 和朴素贝叶斯分类器,我们可以使用以下代码来计算训练集和测试集预测的准确率:

Console.WriteLine(
    String.Format(
        "train accuracy: {0:0.0000}",
        1 - new ZeroOneLoss(output.Where((x, i) => trainSetIDX.Contains(i)).ToArray()).Loss(nbTrainPreds)
    )
);
Console.WriteLine(
    String.Format(
        "validation accuracy: {0:0.0000}",
        1 - new ZeroOneLoss(output.Where((x, i) => testSetIDX.Contains(i)).ToArray()).Loss(nbTestPreds)
    )
);

我们在第一个逻辑回归模型中使用了SplitSetValidation类,因此在模型拟合的同时计算准确率。然而,对于后续的模型,我们分别训练了 SVM 和朴素贝叶斯模型,因此我们需要使用ZeroOneLoss类来计算准确率。

当你运行此代码时,你会看到逻辑回归模型的准确率输出如下所示:

对于朴素贝叶斯模型,准确率结果如下所示:

对于 SVM 模型,输出如下所示:

最后,元模型的准确率结果如下所示:

从这些结果中,我们可以看出,朴素贝叶斯分类器通过预测正确流派的时间约为 42%,表现最佳。逻辑回归模型以次之的准确率排名第二,而支持向量机模型在预测准确率方面表现最差。有趣的是,我们使用其他三个模型的输出构建的元模型表现并不理想。它比支持向量机模型表现好,但比朴素贝叶斯和逻辑回归分类器表现差。

混淆矩阵

我们接下来要探讨的是混淆矩阵。在第二章“垃圾邮件过滤”的二元分类情况下,我们探讨了混淆矩阵是一个 2 x 2 矩阵的情况。然而,在这个项目中,我们的模型有8个结果,混淆矩阵的形状将是 8 x 8。让我们首先看看我们如何构建这样的混淆矩阵:

// Build confusion matrix
string[] confMatrix = BuildConfusionMatrix(
    output.Where((x, i) => testSetIDX.Contains(i)).ToArray(), logitTestPreds, 8
);

System.IO.File.WriteAllLines(Path.Combine(dataDirPath, "logit-conf-matrix.csv"), confMatrix);

辅助函数BuildConfusionMatrix的代码如下:

private static string[] BuildConfusionMatrix(int[] actual, int[] preds, int numClass)
{
    int[][] matrix = new int[numClass][];
    for(int i = 0; i < numClass; i++)
    {
        matrix[i] = new int[numClass];
    }

    for(int i = 0; i < actual.Length; i++)
    {
        matrix[actual[i]][preds[i]] += 1;
    }

    string[] lines = new string[numClass];
    for(int i = 0; i < matrix.Length; i++)
    {
        lines[i] = string.Join(",", matrix[i]);
    }

    return lines;
}

一旦运行此代码,你将得到一个 8 x 8 的矩阵,其中行是实际和观察到的流派,列是从模型预测的流派。以下是我们逻辑回归模型的混淆矩阵:

图片

粗体数字表示模型预测正确的记录数。例如,这个逻辑回归模型正确预测了79首歌曲为电子音乐,而33首歌曲被预测为电子音乐,但实际上是实验音乐。这里值得注意的是,这个逻辑回归模型在预测流行歌曲方面表现不佳。它只有一次预测流行歌曲,但这次预测是错误的,实际上这首歌是嘻哈音乐。现在让我们看看朴素贝叶斯分类器预测的混淆矩阵:

图片

如预期的那样,准确率结果显示混淆矩阵比逻辑回归的混淆矩阵要好。与逻辑回归分类器相比,每个类别的预测中正确的比例更高。朴素贝叶斯分类器在流行歌曲方面的表现似乎也更好。

下面的混淆矩阵是针对支持向量机分类器的:

图片

如预期的那样,预测结果并不理想。支持向量机模型将 100%的记录预测为电子音乐。最后,让我们看看元模型的表现:

图片

这个混淆矩阵看起来略好于支持向量机模型。然而,大多数预测要么是乐器国际流派,只有少数记录被预测为其他流派。

查看混淆矩阵是检查模型错误分类的好方法,可以找出模型的弱点和优势。这些结果与准确度结果很好地一致,其中 Naive Bayes 分类器优于所有其他模型,而元模型表现不佳,尽管它不是我们构建的四个模型中最差的。

平均倒数排名

我们接下来要查看的下一个评估指标是 MRR。MRR 可以在模型产生一系列结果的地方使用,它衡量排名的整体质量。让我们首先看看公式:

如您所见,这是对排名倒数之和的平均值。考虑以下示例:

在第一个示例中,正确的流派在排名中是第二位,因此倒数排名是1/2。第二个示例的正确流派在排名中是第一位,因此倒数排名是1/1,即1。按照这个过程,我们可以得到所有记录的倒数排名,最终的 MRR 值就是这些倒数排名的平均值。这告诉我们排名的一般质量。在这个例子中,MRR0.57,这高于 1/2。因此,这个 MRR 数值表明,平均而言,正确的流派出现在模型预测的前两个流派中。

为了计算我们模型的 MRR,我们首先需要将概率输出转换为排名,然后从转换后的模型输出中计算 MRR。以下代码片段展示了我们如何计算我们模型的 MRR:

// Calculate evaluation metrics
int[][] logitTrainPredRanks = GetPredictionRanks(trainProbabilities);
int[][] logitTestPredRanks = GetPredictionRanks(testProbabilities);

double logitTrainMRRScore = ComputeMeanReciprocalRank(
    logitTrainPredRanks,
    output.Where((x, i) => trainSetIDX.Contains(i)).ToArray()
);
double logitTestMRRScore = ComputeMeanReciprocalRank(
    logitTestPredRanks,
    output.Where((x, i) => testSetIDX.Contains(i)).ToArray()
);

Console.WriteLine("\n---- Logistic Regression Classifier ----\n");
Console.WriteLine(String.Format("train MRR score: {0:0.0000}", logitTrainMRRScore));
Console.WriteLine(String.Format("validation MRR score: {0:0.0000}", logitTestMRRScore));

此代码使用了两个辅助函数,GetPredictionRanksComputeMeanReciprocalRankGetPredictionRanks方法将模型的概率输出转换为排名,而ComputeMeanReciprocalRank方法从排名中计算 MRR。辅助函数GetPredictionRanks如下所示:

private static int[][] GetPredictionRanks(double[][] predProbabilities)
{
    int[][] rankOrdered = new int[predProbabilities.Length][];

    for(int i = 0; i< predProbabilities.Length; i++)
    {
        rankOrdered[i] = Matrix.ArgSort<double>(predProbabilities[i]).Reversed();
    }

    return rankOrdered;
}

我们正在使用Accord.Math包中的Matrix.ArgSort方法对每条记录的流派进行排序。Matrix.ArgSort返回按概率升序排序后的流派索引。然而,我们希望它们按降序排序,以便最可能的流派在排名中排在第一位。这就是为什么我们使用Reversed方法反转排序索引的顺序。

辅助函数ComputeMeanReciprocalRank如下所示:

private static double ComputeMeanReciprocalRank(int[][] rankOrderedPreds, int[] actualClasses)
{
    int num = rankOrderedPreds.Length;
    double reciprocalSum = 0.0;

    for(int i = 0; i < num; i++)
    {
        int predRank = 0;
        for(int j = 0; j < rankOrderedPreds[i].Length; j++)
        {
            if(rankOrderedPreds[i][j] == actualClasses[i])
            {
                predRank = j + 1;
            }
        }
        reciprocalSum += 1.0 / predRank;
    }

    return reciprocalSum / num;
}

这是我们之前讨论的 MRR 计算公式的实现。这种方法遍历每条记录并获取正确流派(genre)的排名。然后,它取排名的倒数,将所有倒数相加,最后将这个和除以记录数以得到 MRR 数值。

让我们开始查看我们迄今为止构建的模型的 MRR 分数。以下输出显示了Logistic Regression Classifier的 MRR 分数:

Naive Bayes 分类器的样本内和样本外 MRR 分数如下所示:

图片

SVM 分类器的结果如下所示:

图片

最后,元模型的 MRR 分数如下所示:

图片

从这些输出中,我们可以看到朴素贝叶斯分类器在大约0.61的 MRR 分数上表现最好,而 SVM 分类器在大约0.33的 MRR 分数上表现最差。元模型的 MRR 分数在大约0.4。这与我们在前一步骤中查看预测准确率和混淆矩阵所得到的结果一致。从这些 MRR 分数中,我们可以看到正确的流派通常位于朴素贝叶斯分类器的排名前两位。另一方面,正确的流派通常在 SVM 分类器中排名第三,在元模型中排名前三。从这些案例中,我们可以通过查看 MRR 指标来了解排名的整体质量。

摘要

在本章中,我们构建了我们的第一个推荐模型,以对每个结果的概率进行排序。我们本章开始时定义了我们将要解决的问题以及我们将要使用的建模和评估方法。然后,我们查看样本集中变量的分布。首先,我们查看目标变量在不同类别或流派中的分布情况,并注意到这是一个平衡良好的样本集,没有一种流派在我们的数据集中占据大多数样本。然后,我们查看音频特征的分布。在这个项目中,我们主要关注 MFCC 及其统计分布,如峰度、偏度、最小值和最大值。通过查看这些特征的四分位数和散点图,我们确认了特征分布在不同音乐流派之间是不同的。

在我们的模型构建步骤中,我们尝试了三种学习算法:逻辑回归、SVM 和朴素贝叶斯。由于我们正在构建多类分类模型,我们必须使用与之前章节不同的学习算法。我们学习了如何在 Accord.NET 框架中使用MultinomialLogisticRegressionMulticlassSupportVectorMachine类,以及何时在NaiveBayesLearning中使用NormalDistribution。然后,我们讨论了如何构建一个元模型,该模型将基础学习模型的预测结果进行集成,以提高 ML 模型的预测能力。最后,我们讨论了评估排名模型与其他分类模型的不同之处,并查看准确率、混淆矩阵和 MRR 指标来评估我们的 ML 模型。

在下一章中,我们将使用手写数字图像数据集来构建一个分类器,该分类器将每个图像分类到相应的数字。我们将讨论一些减少特征集维度的技术以及如何将这些技术应用到图像数据集中。我们还将讨论如何使用 Accord.NET 框架在 C# 中构建神经网络,该框架是深度学习的核心。

第八章:手写数字识别

我们已经探讨了如何使用多类分类模型构建推荐模型。在本章中,我们将扩展我们构建多类分类模型的知识和经验,使用图像数据集。图像识别是一个众所周知的机器学习ML)问题,也是当前积极研究的一个主题。一个与我们的生活高度相关的图像识别问题就是识别手写字母和数字。手写图像识别系统的应用实例之一就是邮局使用的地址识别系统。利用这项技术,邮局现在可以自动且更快地识别手写的地址,从而加速和提高整体邮寄服务。

在本章中,我们将构建用于手写数字识别的机器学习模型。我们将从一个包含超过 40,000 个手写数字图像的灰度像素信息的数据集开始。我们将查看每个像素中值的分布,并讨论这个灰度图像数据集的稀疏性。然后,我们将讨论何时以及如何应用降维技术,特别是主成分分析PCA),以及我们如何从这项技术中受益于我们的图像识别项目。我们将探索不同的学习算法,例如逻辑回归和朴素贝叶斯,并也将介绍如何使用 Accord.NET 框架构建人工神经网络ANN),这是深度学习技术的核心。然后,我们将通过查看各种评估指标来比较这些机器学习模型的预测性能,并讨论哪个模型在手写数字识别项目中表现最佳。

本章将涵盖以下主题:

  • 手写数字识别项目的问题定义

  • 图像数据集的数据分析

  • 特征工程与降维

  • 用于手写数字识别的机器学习模型

  • 评估多类分类模型

问题定义

图像识别技术可以应用于我们的日常生活中,并且很容易找到。在邮局,图像识别系统被用来程序化地理解手写的地址。社交网络服务,如 Facebook,使用图像识别技术进行自动的人脸标签建议,例如,当你想在照片中标记人时。此外,正如本书第一章简要提到的,微软的 Kinect 使用图像识别技术进行动作感应游戏。在这些实际应用中,我们将尝试构建一个手写数字识别系统。正如你可以想象的,这样的数字图像识别模型和系统可以用于邮局自动的手写地址识别。在我们有能力教会机器识别和理解手写数字之前,人们必须逐个查看信件以找出每个信件的目的地和起源。然而,现在我们能够训练机器理解手写地址,邮寄过程变得更加容易和快捷。

为了构建一个手写数字识别模型,我们将使用MNIST数据集,该数据集包含超过 60,000 张手写数字图像。MNIST数据集包含 28 x 28 像素的灰度图像。您可以在以下链接中找到更多信息:yann.lecun.com/exdb/mnist/。对于这个项目,我们将使用一个清洗和处理的 MNIST 数据集,您可以在以下链接中找到:www.kaggle.com/c/digit-recognizer/data。有了这些数据,我们首先将查看数字在数据集中的分布情况,以及特征集的稀疏程度。然后,我们将使用 PCA 进行降维,并可视化不同类别之间特征分布的差异。使用这个 PCA 转换后的数据,我们将训练几个机器学习模型来比较它们的预测性能。除了逻辑回归和朴素贝叶斯分类算法之外,我们还将尝试使用人工神经网络(ANN),因为它已知在图像数据集上表现良好。我们将查看准确率、精确率与召回率的比较,以及曲线下面积AUC),以比较不同机器学习模型的预测性能。

为了总结我们对手写数字识别项目的定义问题:

  • 问题是什么?我们需要一个手写数字识别模型,可以将每个手写图像分类到相应的数字类别,以便它可以用于地址识别系统等应用。

  • 为什么这是一个问题?没有这样的模型,识别和组织信件地址需要大量的人工劳动。如果我们有一种技术可以识别写在信件上的手写数字,它可以显著减少完成相同任务所需的人工劳动量。

  • 解决这个问题的方法有哪些?我们将使用包含大量手写数字图像示例的公开数据。利用这些数据,我们将构建能够将每张图像分类为 10 个数字之一的机器学习模型。

  • 成功的标准是什么?我们希望有一个机器学习模型能够准确地将每张图像与相应的数字对应起来。由于这个模型最终将用于地址识别,我们希望有高精确率,即使我们必须牺牲召回率。

图像数据集的数据分析

让我们从研究这个图像数据集开始。如前所述,我们将使用以下链接中的数据:www.kaggle.com/c/digit-recognizer/data。您可以从链接下载train.csv数据,并将其存储在一个可以从中加载到您的 C#环境中的位置。

目标变量分布

我们首先要关注的是目标变量的分布。我们的目标变量编码在label列中,可以取 0 到 9 之间的值,代表图像所属的数字。以下代码片段展示了我们如何根据目标变量聚合数据,并计算每个数字的示例数量:

var digitCount = featuresDF.AggregateRowsBy<string, int>(
    new string[] { "label" },
    new string[] { "pixel0" },
    x => x.ValueCount
).SortRows("pixel0");

digitCount.Print();

var barChart = DataBarBox.Show(
    digitCount.GetColumn<string>("label").Values.ToArray(),
    digitCount["pixel0"].Values.ToArray()
).SetTitle(
    "Digit Count"
);

与其他章节一样,我们在 Deedle 的数据框中使用了AggregateRowsBy方法,按目标变量label聚合数据,计算每个标签的记录数,并按计数排序。与之前的章节类似,我们使用DataBarBox类来显示数据集中目标变量分布的条形图。以下是在运行此代码时您将看到的条形图:

在控制台输出中,您将看到以下内容:

如从条形图和这个控制台输出中可以看出,数字1在数据集中出现得最多,而数字5出现得最少。然而,数据集中没有一类占大多数示例,目标变量在各个类别中分布得相当均衡。

手写数字图像

在我们开始研究特征集之前,让我们先看看手写数字的实际图像。在我们的数据集的每一条记录中,我们都有 28 x 28 图像中每个图像的 784 个像素的灰度值。为了从这个扁平化的数据集中构建图像,我们首先需要将每个 784 像素值的数组转换为二维数组。以下代码展示了我们编写的用于从扁平化数组创建图像的辅助函数:

private static void CreateImage(int[] rows, string digit)
{
    int width = 28;
    int height = 28;
    int stride = width * 4;
    int[,] pixelData = new int[width, height];

    for (int i = 0; i < width; ++i)
    {
        for (int j = 0; j < height; ++j)
        {
            byte[] bgra = new byte[] { (byte)rows[28 * i + j], (byte)rows[28 * i + j], (byte)rows[28 * i + j], 255 };
            pixelData[i, j] = BitConverter.ToInt32(bgra, 0);
        }
    }

    Bitmap bitmap;
    unsafe
    {
        fixed (int* ptr = &pixelData[0, 0])
        {
            bitmap = new Bitmap(width, height, stride, PixelFormat.Format32bppRgb, new IntPtr(ptr));
        }
    }
    bitmap.Save(
        String.Format(@"\\Mac\Home\Documents\c-sharp-machine-learning\ch.8\input-data\{0}.jpg", digit)
    );
}

从此代码中可以看出,它首先初始化一个二维整数数组pixelData,该数组将存储像素数据。由于我们知道每个图像是一个 28 x 28 的图像,我们将取展平数据中的前 28 个像素作为二维整数数组的第一行,第二组 28 个像素作为第二行,依此类推。在for循环内部,我们将每个像素的值转换为名为bgra蓝-绿-红-透明度BGRA)字节数组。由于我们知道图像是灰度的,我们可以使用相同的值作为蓝色、绿色和红色组件。一旦我们将展平的像素数据转换为 28 x 28 的二维整数数组,我们现在就可以构建手写数字图像了。我们使用Bitmap类来重建这些手写数字图像。以下代码展示了我们如何使用此辅助函数为每个数字构建图像:

ISet<string> exportedLabels = new HashSet<string>();
for(int i = 0; i < featuresDF.RowCount; i++)
{
    exportedLabels.Add(featuresDF.Rows[i].GetAs<string>("label"));

    CreateImage(
        featuresDF.Rows[i].ValuesAll.Select(x => (int)x).Where((x, idx) => idx > 0).ToArray(),
        featuresDF.Rows[i].GetAs<string>("label")
    );

    if(exportedLabels.Count() >= 10)
    {
        break;
    }
}

当您运行此代码时,您将在本地驱动器上看到以下图像被存储:

您可以使用相同的代码生成更多图像,这将帮助您更好地理解手写数字的原始图像是什么样的。

图像特征 - 像素

让我们现在看看图像特征。在我们的数据集中,每个图像中的每个像素都有代表灰度值的整数。了解每个像素可以取的值范围,以及我们是否可以在不同手写数字类别的像素数据分布中找到任何明显的差异,将是有帮助的。

我们首先将查看像素数据的单个分布。以下代码片段展示了您如何计算数据集中每个像素的四分位数:

List<string> featureCols = new List<string>();
foreach (string col in featuresDF.ColumnKeys)
{
    if (featureCols.Count >= 20)
    {
        break;
    }

    if (col.StartsWith("pixel"))
    {
        if (featuresDF[col].Max() > 0)
        {
            featureCols.Add(col);

            Console.WriteLine(String.Format("\n\n-- {0} Distribution -- ", col));
            double[] quantiles = Accord.Statistics.Measures.Quantiles(
                featuresDF[col].ValuesAll.ToArray(),
                new double[] { 0, 0.25, 0.5, 0.75, 1.0 }
            );
            Console.WriteLine(
                "Min: \t\t\t{0:0.00}\nQ1 (25% Percentile): \t{1:0.00}\nQ2 (Median): \t\t{2:0.00}\nQ3 (75% Percentile): \t{3:0.00}\nMax: \t\t\t{4:0.00}",
                quantiles[0], quantiles[1], quantiles[2], quantiles[3], quantiles[4]
            );
        }

    }
}

与前几章的情况类似,我们在Accord.Statistics.Measures中使用了Quantiles方法来获取每个像素的四分位数。如您从前几章中回忆起来,四分位数是将数据分为四个部分的值。换句话说,第一四分位数(Q1)代表最小值和平均值之间的中间点,即 25%的分位数。第二四分位数(Q2)代表平均值,第三四分位数(Q3)代表中位数和最大值之间的中间点,即 75%的分位数。在这个代码示例中,我们只计算了具有非零值的第一个 20 个像素的四分位数,如您在 4-7 行和第 11 行中看到的那样。当您运行此代码时,您将得到如下所示的输出:

在这里,我们只显示了前五个分布。如您从输出中看到的那样,大多数像素值都是 0。如果您查看我们在上一节中重建的图像,图像中的大多数像素是黑色的,只有一小部分像素用于显示数字。这些黑色像素在我们的像素数据中被编码为0,因此许多像素具有 0 值是预期的。

让我们构建一些散点图,以便我们能够更好地从视觉上理解这些数据。以下代码构建了每个手写数字的前 20 个非零像素特征的分布散点图:

string[] featureColumns = featureCols.ToArray();

foreach (string label in digitCount.GetColumn<string>("label").Values)
{
    var subfeaturesDF = featuresDF.Rows[
        featuresDF.GetColumn<string>("label").Where(x => x.Value == label).Keys
    ].Columns[featureColumns];

    ScatterplotBox.Show(
        BuildXYPairs(
            subfeaturesDF.Columns[featureColumns].ToArray2D<double>(),
            subfeaturesDF.RowCount,
            subfeaturesDF.ColumnCount
        )
    ).SetTitle(String.Format("Digit: {0} - 20 sample Pixels", label));
}

如果你仔细查看这段代码,我们首先从featureCols列表对象中构建了一个featureColumns字符串数组。List对象featureCols是具有非零值的前 20 个像素的列表,这是在计算四分位数时从先前代码中构建的。我们使用了与上一章中相同的辅助函数BuildXYPairs,将数据框转换为 x-y 对的数组,其中x值是每个像素的索引,y值是实际的像素值。使用这个辅助函数,我们使用ScatterplotBox类来显示一个散点图,该图显示了每个 20 个样本像素的像素分布。

以下是一个 0 位数的散点图:

在 0 位数类别中的所有图像中,前 20 个像素的大多数值都是 0。在这 20 个我们在散点图中展示的像素中,只有三个像素的值不是 0。让我们看看不同数字类别中这些像素的分布情况。

以下散点图适用于一位数类别:

与 0 位数类别的情况类似,在这 20 个我们在散点图中展示的像素中,大多数像素的值都是 0,只有三个像素的值不是 0。与 0 位数类别的先前散点图相比,像素数据的分布对于 1 位数类别略有不同。

以下内容适用于两位数类别:

这个散点图显示了这里展示的 20 个像素的不同分布。其中大多数像素的值在 0 到 255 之间,只有少数像素在所有图像中都是 0。这种特征集分布的差异将有助于我们的机器学习模型学习如何正确地分类手写数字。

最后,我们将查看一个额外的散点图,我们将看到目标变量是如何分布在两个不同的像素上的。我们使用了以下代码来生成一个示例二维散点图:

double[][] twoPixels = featuresDF.Columns[
    new string[] { featureColumns[15], featureColumns[16] }
].Rows.Select(
    x => Array.ConvertAll<object, double>(x.Value.ValuesAll.ToArray(), o => Convert.ToDouble(o))
).ValuesAll.ToArray();

ScatterplotBox.Show(
    String.Format("{0} vs. {1}", featureColumns[15], featureColumns[16]), 
    twoPixels,
    featuresDF.GetColumn<int>("label").Values.ToArray()
);

为了说明目的,我们选择了第十五和第十六个索引的特征,它们实际上是pixel43pixel44。当你运行这段代码时,你会看到以下散点图:

我们可以看到不同类别之间的一些区别,但由于pixel43pixel44的大多数像素值都是 0,因此通过查看这个散点图很难在不同目标类别之间画出清晰的界限。在下一节中,我们将探讨如何使用 PCA 及其主成分来创建这个散点图的另一个版本,这有助于我们在可视化数据时识别不同目标类别之间的更清晰的界限。

此数据分析步骤的完整代码可以在以下链接中找到:github.com/yoonhwang/c-sharp-machine-learning/blob/master/ch.8/DataAnalyzer.cs.

特征工程和降维

到目前为止,我们已经查看了解释变量和像素数据的分布。在本节中,我们将开始讨论为我们的机器学习步骤构建训练集和测试集,然后我们将讨论如何使用 PCA 进行降维以及使用主成分可视化数据。

将样本集分割为训练集和测试集

在这一步中,我们将要做的第一个任务是随机将我们的数据集分割为训练集和测试集。让我们首先看看代码:

double trainSetProportiona = 0.7;

var rnd = new Random();
var trainIdx = featuresDF.RowKeys.Where((x, i) => rnd.NextDouble() <= trainSetProportiona);
var testIdx = featuresDF.RowKeys.Where((x, i) => !trainIdx.Contains(i));

var trainset = featuresDF.Rows[trainIdx];
var testset = featuresDF.Rows[testIdx];

var trainLabels = trainset.GetColumn<int>("label").Values.ToArray();

string[] nonZeroPixelCols = trainset.ColumnKeys.Where(x => trainset[x].Max() > 0 && !x.Equals("label")).ToArray();

double[][] data = trainset.Columns[nonZeroPixelCols].Rows.Select(
    x => Array.ConvertAll<object, double>(x.Value.ValuesAll.ToArray(), o => Convert.ToDouble(o))
).ValuesAll.ToArray();

如前述代码所示,我们将大约 70%的数据用于训练,其余的用于测试。在这里,我们使用Random类生成随机数,使用记录的索引将样本集分割为训练集和测试集。一旦我们构建了训练集和测试集,我们就移除了所有图像中值为 0 的列或像素(第 12 行)。这是因为如果一个特征在不同目标类别之间没有变化,它对那些目标类别没有信息,这些信息是机器学习模型需要学习的。

现在我们有了训练集和测试集,让我们检查训练集和测试集中目标类别的分布。以下代码可用于聚合:

var digitCount = trainset.AggregateRowsBy<string, int>(
    new string[] { "label" },
    new string[] { "pixel0" },
    x => x.ValueCount
).SortRows("pixel0");

digitCount.Print();

var barChart = DataBarBox.Show(
    digitCount.GetColumn<string>("label").Values.ToArray(),
    digitCount["pixel0"].Values.ToArray()
).SetTitle(
    "Train Set - Digit Count"
);

digitCount = testset.AggregateRowsBy<string, int>(
    new string[] { "label" },
    new string[] { "pixel0" },
    x => x.ValueCount
).SortRows("pixel0");

digitCount.Print();

barChart = DataBarBox.Show(
    digitCount.GetColumn<string>("label").Values.ToArray(),
    digitCount["pixel0"].Values.ToArray()
).SetTitle(
    "Test Set - Digit Count"
);

当你运行此代码时,你将在训练集中看到目标变量分布的以下图表:

图片

然后,以下是测试集的显示结果:

图片

这些分布看起来与我们之前在数据分析步骤中分析整体数据集中的目标变量分布时看到的情况相似。现在让我们开始讨论如何将 PCA 应用于我们的训练集。

通过 PCA 进行降维

当我们分析数据时,我们注意到许多特征或像素值是 0。在这种情况下,应用 PCA 可以帮助减少数据的维度,同时最大限度地减少从减少的维度中损失的信息。简单来说,PCA 通过原始特征的线性组合来解释数据集及其结构。因此,每个主成分都是特征的线性组合。让我们开始看看如何使用 Accord.NET 框架在 C#中运行 PCA。

以下是如何初始化和训练 PCA 的方法:

var pca = new PrincipalComponentAnalysis(
    PrincipalComponentMethod.Standardize
);
pca.Learn(data);

一旦使用数据训练了PrincipalComponentAnalysis,它就包含了每个主成分的线性组合的所有信息,并且可以应用于转换其他数据。我们在应用 PCA 之前使用PrincipalComponentMethod.Standardize来标准化我们的数据。这是因为 PCA 对每个特征的规模很敏感。因此,我们在应用 PCA 之前想要标准化我们的数据集。

为了将 PCA 转换其他数据,你可以使用Transform方法,如下面的代码片段所示:

double[][] transformed = pca.Transform(data);

现在我们已经学习了如何将 PCA 应用于我们的数据集,让我们来看看前两个主成分,看看我们是否能在目标变量分布中找到任何明显的模式。以下代码展示了我们如何构建前两个成分的散点图,并使用目标类别进行颜色编码:

double[][] first2Components = transformed.Select(x => x.Where((y, i) => i < 2).ToArray()).ToArray();

ScatterplotBox.Show("Component #1 vs. Component #2", first2Components, trainLabels);

一旦运行此代码,你将看到以下散点图:

当你将此图表与我们在数据分析步骤中查看的pixel43pixel44之间的图表进行比较时,这看起来相当不同。从第一个两个主成分的散点图中,我们可以看到目标类别更加明显。尽管这些两个成分并不能完美分离,但我们可以看出,如果我们将更多的成分结合到我们的分析和建模中,将更容易将一个目标类别从另一个中分离出来。

PCA 的另一个重要方面是我们应该关注的,即每个主成分解释的方差量。让我们看看以下代码:

DataSeriesBox.Show(
    pca.Components.Select((x, i) => (double)i),
    pca.Components.Select(x => x.CumulativeProportion)
).SetTitle("Explained Variance");

System.IO.File.WriteAllLines(
    Path.Combine(dataDirPath, "explained-variance.csv"),
    pca.Components.Select((x, i) => String.Format("{0},{1:0.0000}", i, x.CumulativeProportion))
);

我们可以通过使用CumulativeProportion属性来检索由每个 PCA 成分解释的数据中方差的累积比例。为了获取每个 PCA 成分解释的个别比例,你可以使用每个 PCA 成分的Proportion属性。然后,我们将使用DataSeriesBox类来绘制折线图,以显示每个成分解释的方差累积比例。

当你运行此代码时,它将生成以下图表:

如您从该图表中可以看到,数据集中的约 90%的方差可以通过前 200 个成分来解释。使用 600 个成分,我们可以解释我们数据集中几乎 100%的方差。与原始数据集中作为特征的 784 个像素总数相比,这是数据维度的大幅减少。根据你想要为你的机器学习模型捕获多少方差,你可以使用此图表来决定最适合你的建模过程的成分数量。

最后,我们需要导出训练集和测试集,以便我们可以使用它们进行以下模型构建步骤。你可以使用以下代码来导出 PCA 转换后的训练集和测试集:

Console.WriteLine("exporting train set...");
var trainTransformed = pca.Transform(
    trainset.Columns[nonZeroPixelCols].Rows.Select(
        x => Array.ConvertAll<object, double>(x.Value.ValuesAll.ToArray(), o => Convert.ToDouble(o))
    ).ValuesAll.ToArray()
);

System.IO.File.WriteAllLines(
    Path.Combine(dataDirPath, "pca-train.csv"),
    trainTransformed.Select((x, i) => String.Format("{0},{1}", String.Join(",", x), trainset["label"].GetAt(i)))
);

Console.WriteLine("exporting test set...");
var testTransformed = pca.Transform(
    testset.Columns[nonZeroPixelCols].Rows.Select(
        x => Array.ConvertAll<object, double>(x.Value.ValuesAll.ToArray(), o => Convert.ToDouble(o))
    ).ValuesAll.ToArray()
);
System.IO.File.WriteAllLines(
    Path.Combine(dataDirPath, "pca-test.csv"),
    testTransformed.Select((x, i) => String.Format("{0},{1}", String.Join(",", x), testset["label"].GetAt(i)))
);

此特征工程和降维步骤的完整代码可以在以下链接中找到:github.com/yoonhwang/c-sharp-machine-learning/blob/master/ch.8/FeatureEngineering.cs

机器学习模型用于手写数字识别

现在我们已经为构建机器学习模型准备好了所有东西,让我们开始构建这些模型。在本节中,我们将介绍如何根据 PCA 结果子选择特征,然后讨论我们如何为手写数字识别模型构建逻辑回归和朴素贝叶斯分类器。我们将介绍一个新的学习模型——神经网络,并解释如何使用 Accord.NET 框架为这个项目构建一个神经网络。

加载数据

构建用于手写数字识别的机器学习模型的第一步是加载我们在上一节构建的数据。您可以使用以下代码来加载我们之前创建的训练集和测试集:

// Load the data into a data frame
string trainDataPath = Path.Combine(dataDirPath, "pca-train.csv");
Console.WriteLine("Loading {0}\n\n", trainDataPath);
var trainDF = Frame.ReadCsv(
    trainDataPath,
    hasHeaders: false,
    inferTypes: true
);

string testDataPath = Path.Combine(dataDirPath, "pca-test.csv");
Console.WriteLine("Loading {0}\n\n", testDataPath);
var testDF = Frame.ReadCsv(
    testDataPath,
    hasHeaders: false,
    inferTypes: true
);

string[] colnames = trainDF.ColumnKeys.Select(
    (x, i) => i < trainDF.ColumnKeys.Count() - 1 ? String.Format("component-{0}", i + 1) : "label"
).ToArray();

trainDF.RenameColumns(colnames);
testDF.RenameColumns(colnames);

在本章中,我们将使用累积解释我们数据集约 70%方差的特征进行不同模型的实验。查看以下代码,了解我们是如何筛选出感兴趣的特征成分的:

// Capturing 70% of the variance
string[] featureCols = colnames.Where((x, i) => i <= 90).ToArray();

double[][] trainInput = BuildJaggedArray(
    trainDF.Columns[featureCols].ToArray2D<double>(), trainDF.RowCount, featureCols.Length
);
int[] trainOutput = trainDF.GetColumn<int>("label").ValuesAll.ToArray();

double[][] testInput = BuildJaggedArray(
    testDF.Columns[featureCols].ToArray2D<double>(), testDF.RowCount, featureCols.Length
);
int[] testOutput = testDF.GetColumn<int>("label").ValuesAll.ToArray();

如您从代码的第一行所见,我们正在将前 91 个成分(直到第九十个索引)作为我们模型的特征。如果您还记得之前的步骤或查看成分解释的累积方差比例的图表,您会看到前 91 个成分捕捉了我们数据集约 70%的方差。然后,我们创建一个二维的 double 数组,我们将用它来训练和测试我们的机器学习模型。以下代码展示了我们编写的辅助函数BuildJaggedArray,该函数将数据框转换为二维数组:

private static double[][] BuildJaggedArray(double[,] ary2d, int rowCount, int colCount)
{
    double[][] matrix = new double[rowCount][];
    for(int i = 0; i < rowCount; i++)
    {
        matrix[i] = new double[colCount];
        for(int j = 0; j < colCount; j++)
        {
            matrix[i][j] = double.IsNaN(ary2d[i, j]) ? 0.0 : ary2d[i, j];
        }
    }
    return matrix;
}

逻辑回归分类器

我们将要实验的第一个用于手写数字识别的学习算法是逻辑回归。我们编写了一个名为BuildLogitModel的方法,它接受模型输入和输出,训练一个逻辑回归分类器,然后评估性能。以下代码展示了这个方法的编写方式:

private static void BuildLogitModel(double[][] trainInput, int[] trainOutput, double[][] testInput, int[] testOutput)
{
    var logit = new MultinomialLogisticLearning<GradientDescent>()
    {
        MiniBatchSize = 500
    };
    var logitModel = logit.Learn(trainInput, trainOutput);

    int[] inSamplePreds = logitModel.Decide(trainInput);
    int[] outSamplePreds = logitModel.Decide(testInput);

    // Accuracy
    double inSampleAccuracy = 1 - new ZeroOneLoss(trainOutput).Loss(inSamplePreds);
    double outSampleAccuracy = 1 - new ZeroOneLoss(testOutput).Loss(outSamplePreds);
    Console.WriteLine("* In-Sample Accuracy: {0:0.0000}", inSampleAccuracy);
    Console.WriteLine("* Out-of-Sample Accuracy: {0:0.0000}", outSampleAccuracy);

    // Build confusion matrix
    int[][] confMatrix = BuildConfusionMatrix(
        testOutput, outSamplePreds, 10
    );
    System.IO.File.WriteAllLines(
        Path.Combine(
            @"<path-to-dir>", 
            "logit-conf-matrix.csv"
        ),
        confMatrix.Select(x => String.Join(",", x))
    );

    // Precision Recall
    PrintPrecisionRecall(confMatrix);
    DrawROCCurve(testOutput, outSamplePreds, 10, "Logit");
}

与上一章类似,我们正在使用MultinomialLogisticLearning类来训练一个逻辑回归分类器。一旦这个模型被训练,我们就开始通过各种评估指标进行评估,我们将在下一节中更详细地讨论这些指标。

朴素贝叶斯分类器

我们接下来要实验的第二种模型是一个朴素贝叶斯分类器。类似于之前涉及逻辑回归分类器的案例,我们编写了一个辅助函数BuildNBModel,它接受输入和输出,训练一个朴素贝叶斯分类器,然后评估训练好的模型。代码如下:

private static void BuildNBModel(double[][] trainInput, int[] trainOutput, double[][] testInput, int[] testOutput)
{
    var teacher = new NaiveBayesLearning<NormalDistribution>();
    var nbModel = teacher.Learn(trainInput, trainOutput);

    int[] inSamplePreds = nbModel.Decide(trainInput);
    int[] outSamplePreds = nbModel.Decide(testInput);

    // Accuracy
    double inSampleAccuracy = 1 - new ZeroOneLoss(trainOutput).Loss(inSamplePreds);
    double outSampleAccuracy = 1 - new ZeroOneLoss(testOutput).Loss(outSamplePreds);
    Console.WriteLine("* In-Sample Accuracy: {0:0.0000}", inSampleAccuracy);
    Console.WriteLine("* Out-of-Sample Accuracy: {0:0.0000}", outSampleAccuracy);

    // Build confusion matrix
    int[][] confMatrix = BuildConfusionMatrix(
        testOutput, outSamplePreds, 10
    );
    System.IO.File.WriteAllLines(
        Path.Combine(
            @"<path-to-dir>",
            "nb-conf-matrix.csv"
        ),
        confMatrix.Select(x => String.Join(",", x))
    );

    // Precision Recall
    PrintPrecisionRecall(confMatrix);
    DrawROCCurve(testOutput, outSamplePreds, 10, "NB");
}

如您可能从上一章回忆起来,我们正在使用NaiveBayesLearning类来训练一个朴素贝叶斯分类器。我们使用NormalDistribution,因为我们的机器学习模型的所有特征都是来自之前 PCA 步骤的主成分,而这些成分的值是连续的。

神经网络分类器

我们将要实验的最后一种学习算法是人工神经网络(ANN)。正如你可能已经知道的,神经网络模型是所有深度学习技术的核心。神经网络模型在图像数据集上表现良好,因此我们将比较该模型与其他模型的性能,以查看通过使用神经网络相对于其他分类模型我们能获得多少性能提升。为了使用 Accord.NET 框架在 C# 中构建神经网络模型,你首先需要安装 Accord.Neuro 包。你可以在 NuGet 包管理器控制台中使用以下命令安装 Accord.Neuro 包:

Install-Package Accord.Neuro

现在我们来看看如何使用 Accord.NET 框架在 C# 中构建神经网络模型。代码如下:

private static void BuildNNModel(double[][] trainInput, int[] trainOutput, double[][] testInput, int[] testOutput)
{
    double[][] outputs = Accord.Math.Jagged.OneHot(trainOutput);

    var function = new BipolarSigmoidFunction(2);
    var network = new ActivationNetwork(
        new BipolarSigmoidFunction(2), 
        91, 
        20,
        10
    );

    var teacher = new LevenbergMarquardtLearning(network);

    Console.WriteLine("\n-- Training Neural Network");
    int numEpoch = 10;
    double error = Double.PositiveInfinity;
    for (int i = 0; i < numEpoch; i++)
    {
        error = teacher.RunEpoch(trainInput, outputs);
        Console.WriteLine("* Epoch {0} - error: {1:0.0000}", i + 1, error);
    }
    Console.WriteLine("");

    List<int> inSamplePredsList = new List<int>();
    for (int i = 0; i < trainInput.Length; i++)
    {
        double[] output = network.Compute(trainInput[i]);
        int pred = output.ToList().IndexOf(output.Max());
        inSamplePredsList.Add(pred);
    }

    List<int> outSamplePredsList = new List<int>();
    for (int i = 0; i < testInput.Length; i++)
    {
        double[] output = network.Compute(testInput[i]);
        int pred = output.ToList().IndexOf(output.Max());
        outSamplePredsList.Add(pred);
    }
}

让我们仔细看看这段代码。我们首先将训练标签从一维数组转换为二维数组,其中列是目标类别,如果给定的记录属于给定的目标类别,则值为 1,如果不属于,则值为 0。我们使用 Accord.Math.Jagged.OneHot 方法对训练标签进行独热编码。然后,我们使用 ActivationNetwork 类构建神经网络。ActivationNetwork 类接受三个参数:激活函数、输入计数和层的信息。对于激活函数,我们使用 sigmoid 函数,BipolarSigmoidFunction。输入计数很简单,因为它是我们将要用于训练此模型的特征数量,即 91。对于此模型,我们只使用了一个包含 20 个神经元的隐藏层。对于更深的神经网络,你可以使用多个隐藏层,并且也可以在每个隐藏层中实验不同的神经元数量。最后,ActivationNetwork 构造函数的最后一个参数代表输出计数。由于目标变量是数字类别,它可以取 0 到 9 之间的值,因此我们需要 10 个输出神经元。一旦构建了这个网络,我们就可以使用 LevenbergMarquardtLearning 学习算法来训练网络。

一旦我们设置了网络和学习算法,我们实际上就可以开始训练神经网络模型了。正如你可能已经知道的,神经网络模型在其学习阶段需要多次(epochs)通过数据集运行以获得更好的可预测性。你可以使用 RunEpoch 方法在每个 epoch 中训练和更新神经网络模型。为了节省时间,我们只运行了 10 个 epoch 来训练我们的神经网络模型。然而,我们建议你尝试增加这个值,因为它可以提高你的神经网络模型的性能。以下展示了随着我们训练和更新神经网络模型在每个 epoch 中,错误度量是如何降低的:

图片

如此输出所示,错误度量在每个 epoch 中显著降低。在此需要注意的是,错误度量减少的量在每个额外的 epoch 中减少。当你构建具有大量 epochs 的神经网络模型时,你可以监控每次运行的增益量,并在没有更多显著性能增益时决定停止。

用于模型构建步骤的完整代码可以在以下链接找到:github.com/yoonhwang/c-sharp-machine-learning/blob/master/ch.8/Modeling.cs

评估多类分类模型

在本节中,我们将评估上一节中构建的三个模型。我们将回顾之前用于分类模型的验证指标,并比较每个模型之间的性能。

混淆矩阵

首先,让我们看看混淆矩阵。以下代码展示了如何使用预测输出和实际输出构建混淆矩阵:

private static int[][] BuildConfusionMatrix(int[] actual, int[] preds, int numClass)
{
    int[][] matrix = new int[numClass][];
    for (int i = 0; i < numClass; i++)
    {
        matrix[i] = new int[numClass];
    }

    for (int i = 0; i < actual.Length; i++)
    {
        matrix[actual[i]][preds[i]] += 1;
    }

    return matrix;
}

此方法与我们之前章节中编写的方法类似,不同之处在于它返回的是一个二维数组,而不是字符串数组。我们将在下一节中使用这个二维数组输出来计算精确率和召回率。

逻辑回归分类器的混淆矩阵如下:

图片

对于朴素贝叶斯分类器,你将得到一个类似于以下表格的混淆矩阵:

图片

最后,对于神经网络模型,混淆矩阵如下:

图片

从这些混淆矩阵中,神经网络模型优于其他两个模型,逻辑回归模型似乎位居第二。

准确率和精确率/召回率

我们将要查看的第二项指标是准确度度量。我们使用ZeroOneLoss来计算损失,然后从1中减去以获得准确度数值。计算准确度度量的代码如下:

// Accuracy
double inSampleAccuracy = 1 - new ZeroOneLoss(trainOutput).Loss(inSamplePreds);
double outSampleAccuracy = 1 - new ZeroOneLoss(testOutput).Loss(outSamplePreds);
Console.WriteLine("* In-Sample Accuracy: {0:0.0000}", inSampleAccuracy);
Console.WriteLine("* Out-of-Sample Accuracy: {0:0.0000}", outSampleAccuracy);

我们将要查看的第三和第四项指标是精确率和召回率。与之前不同,目标预测有 10 个类别。因此,我们将为每个目标类别分别计算精确率和召回率。代码如下:

private static void PrintPrecisionRecall(int[][] confMatrix)
{
    for (int i = 0; i < confMatrix.Length; i++)
    {
        int totalActual = confMatrix[i].Sum();
        int correctPredCount = confMatrix[i][i];

        int totalPred = 0;
        for(int j = 0; j < confMatrix.Length; j++)
        {
            totalPred += confMatrix[j][i];
        }

        double precision = correctPredCount / (float)totalPred;
        double recall = correctPredCount / (float)totalActual;

        Console.WriteLine("- Digit {0}: precision - {1:0.0000}, recall - {2:0.0000}", i, precision, recall);
    }

}

如此代码所示,此PrintPrecisionRecall方法的输入是我们从上一节构建的混淆矩阵。在此方法中,它遍历每个目标类别并计算精确率和召回率。

以下是我们计算逻辑回归模型的准确度、精确率和召回率时的输出:

图片

对于朴素贝叶斯模型,我们得到以下指标结果:

图片

最后,对于神经网络模型,性能结果如下:

如您可能已经注意到的,从这些结果来看,神经网络模型优于其他两个模型。与逻辑回归和朴素贝叶斯模型相比,神经网络模型的整体准确率和精确率/召回率都是最高的。逻辑回归模型似乎在我们构建的三个模型中位居第二。

一对多 AUC

我们将要讨论的最后一种评估指标是接收者操作特征ROC)曲线和 AUC。在本章中,当我们构建 ROC 曲线和 AUC 时,我们需要做的一件事是,为每个目标类别构建一个。让我们先看看代码:

private static void DrawROCCurve(int[] actual, int[] preds, int numClass, string modelName)
{
    ScatterplotView spv = new ScatterplotView();
    spv.Dock = DockStyle.Fill;
    spv.LinesVisible = true;

    Color[] colors = new Color[] {
        Color.Blue, Color.Red, Color.Orange, Color.Yellow, Color.Green,
        Color.Gray, Color.LightSalmon, Color.LightSkyBlue, Color.Black, Color.Pink
    };

    for (int i = 0; i < numClass; i++)
    {
        // Build ROC for Train Set
        bool[] expected = actual.Select(x => x == i ? true : false).ToArray();
        int[] predicted = preds.Select(x => x == i ? 1 : 0).ToArray();

        var trainRoc = new ReceiverOperatingCharacteristic(expected, predicted);
        trainRoc.Compute(1000);

        // Get Train AUC
        double auc = trainRoc.Area;
        double[] xVals = trainRoc.Points.Select(x => 1 - x.Specificity).ToArray();
        double[] yVals = trainRoc.Points.Select(x => x.Sensitivity).ToArray();

        // Draw ROC Curve
        spv.Graph.GraphPane.AddCurve(
            String.Format(
                "Digit: {0} - AUC: {1:0.00}",
                i, auc
            ),
            xVals, yVals, colors[i], SymbolType.None
        );
        spv.Graph.GraphPane.AxisChange();
    }

    spv.Graph.GraphPane.Title.Text = String.Format(
        "{0} ROC - One vs. Rest",
        modelName
    );

    Form f1 = new Form();
    f1.Width = 700;
    f1.Height = 500;
    f1.Controls.Add(spv);
    f1.ShowDialog();
}

从我们编写的DrawROCCurve方法中可以看出,我们通过一个for循环遍历每个目标类别,并通过编码将每个标签与目标类别匹配时标记为1,不匹配时标记为0。完成编码后,我们可以使用ReceiverOperatingCharacteristic类来计算 AUC 并构建 ROC 曲线。

下面的图表是逻辑回归模型的 ROC 曲线:

对于朴素贝叶斯模型,ROC 曲线如下所示:

最后,神经网络模型的 ROC 曲线如下所示:

如我们所预期的,从之前我们查看的指标来看,神经网络模型的结果最好,逻辑回归模型位居第二。对于朴素贝叶斯模型,有一些数字它没有很好地计算。例如,朴素贝叶斯模型在分类数字 6 和 7 时遇到了困难。然而,对于所有目标类别,神经网络模型的 AUC 数值都接近 1,这表明模型已经很好地训练来识别手写图像中的数字。

通过查看混淆矩阵、准确率、精确率和召回率以及 ROC 曲线,我们可以得出结论,在本章训练的三个分类器中,神经网络模型表现最好。这再次证实了神经网络在图像数据集和图像识别问题上的良好表现。

摘要

在本章中,我们构建了我们第一个图像识别模型,该模型可以识别灰度图像中的手写数字。我们本章开始时讨论了这种类型的模型如何在现实生活中的广泛应用,以及我们计划如何构建手写数字识别模型。然后,我们开始研究数据集。我们首先查看目标类别的分布,以查看样本集是否是一个平衡良好的集合。当我们分析像素数据时,我们注意到大多数像素值都是 0,我们可以通过从像素数据重建图像来直观地理解这一点。在特征工程步骤中,我们讨论了如何使用 PCA 进行降维。

在对这些 PCA 转换后的特征进行处理后,我们开始构建各种机器学习模型。在已经熟悉的逻辑回归和朴素贝叶斯模型的基础上,我们引入了一个新的机器学习模型——神经网络。我们学习了如何使用BipolarSigmoidFunction作为激活函数初始化ActivationNetwork模型。然后,我们使用LevenbergMarquardtLearning学习算法在 10 个周期内开始训练神经网络。我们观察到每个额外周期中误差度量是如何减少的,并讨论了误差率增加的额外周期是如何逐渐减少的。在模型评估步骤中,我们结合了多个分类模型的验证指标。对于本章构建的机器学习模型,我们考虑了混淆矩阵、预测准确率、精确率和召回率,以及 ROC 曲线和 AUC。我们注意到神经网络模型优于其他两个模型,这再次证实了神经网络模型非常适合图像数据。

在下一章中,我们将转换方向,开始构建用于异常检测的模型。我们将使用 PCA 进行网络攻击检测项目。利用网络入侵数据集,我们将讨论如何使用 PCA 来检测网络攻击,并运行多个实验以找到通知我们潜在网络攻击的最佳阈值。

第九章:网络攻击检测

到目前为止,我们主要开发的是具有平衡样本集的机器学习ML)模型,其中目标类别在数据集中的样本记录中分布均匀或几乎均匀。然而,有些数据集存在类别分布不平衡的情况。类别不平衡在异常和欺诈检测中尤为常见。这类类别不平衡问题在训练 ML 模型时会引起问题,因为大多数 ML 算法在目标类别大致均匀分布时表现最佳。为了解决这个类别不平衡问题,我们不能像开发各种分类和回归问题的模型那样处理。我们需要采取不同的方法。

在本章中,我们将讨论如何构建异常检测模型。我们将使用一个网络入侵数据集,即KDD Cup 1999 数据,它包含大量网络连接数据,其中一些是正常网络连接,而另一些则是网络攻击。我们首先将查看数据的结构,数据集中存在的网络攻击类型,以及各种网络特征的分布。然后,我们将应用我们在前几章中讨论的一些特征工程技术,因为特征集包含分类变量和连续变量。我们还将应用我们在前一章中讨论的降维技术,即主成分分析PCA)。除了我们在前一章中关于 PCA 的讨论外,我们还将使用 PCA 来构建异常检测模型。使用 PCA 构建的模型,我们将进一步讨论评估异常检测模型的一些方法,以及哪些最适合网络攻击检测项目。

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

  • 网络攻击检测项目的定义问题

  • 互联网流量数据集的数据分析

  • 特征工程与 PCA

  • 异常检测的主成分分类器

  • 评估异常检测模型

问题定义

类别分布不平衡的数据集会给大多数机器学习算法带来问题,因为它们通常在平衡数据集上表现良好。在机器学习中处理类别不平衡问题有各种方法。对数据集进行重采样以平衡目标类别是一种方法。你可以通过随机选择并复制正样本训练样本来增加正样本的训练样本,这样大约 50%的数据集属于正类别。你也可以减少负样本的训练样本,以便负样本的数量与正样本的数量相匹配。在极端类别不平衡的情况下,你可以将其视为一个异常检测问题,其中正事件被视为异常或离群值。异常检测技术在现实世界问题中有许多应用。它们通常用于网络入侵检测、信用卡欺诈检测,甚至医疗诊断。

在本章中,我们将致力于构建一个用于网络攻击的异常检测模型。为了构建网络攻击检测模型,我们将使用KDD Cup 1999 数据,该数据集包含大量的人工和手动注入的网络攻击数据,以及正常的网络连接数据。这些数据可以在以下链接找到:kdd.ics.uci.edu/databases/kddcup99/kddcup99.html。有了这些数据,我们将首先查看网络攻击类型的分布,然后是网络特征的分布。由于这是一个模拟和人工数据集,这个数据集的大部分是由网络攻击组成的,这在现实世界中是不正常和不切实际的。为了模拟现实世界中的网络攻击实例,我们将从样本集中随机子选择网络攻击事件,并构建一个包含比恶意连接更多的正常网络连接的新训练集。使用这个子采样数据集,我们将使用 PCA 构建一个异常检测模型。然后,我们将通过查看不同目标误报率下的网络攻击检测率来评估这个模型。

为了总结我们的网络攻击检测项目的问题定义:

  • 问题是什么?我们需要一个能够从大量网络连接中识别潜在恶意连接的网络攻击检测模型,以便我们可以避免网络攻击。

  • 为什么这是一个问题?每年网络攻击的数量都在增加,如果没有为这种攻击做好准备,我们的系统将更容易受到各种网络攻击的侵害。有了网络攻击检测模型,我们可以避免成为网络攻击的受害者。

  • 解决这个问题的方法有哪些?我们将使用公开可用的数据,这些数据包含大量的人工和模拟网络攻击数据。我们将对数据进行子采样,以复制一个现实生活中正常网络连接多于异常和恶意连接的真实场景。然后,我们将使用 PCA 及其主成分来检测异常。

  • 成功的标准是什么?我们希望有高网络攻击检测率,即使这意味着我们需要牺牲更高的误报率。这是因为我们更关心允许网络攻击的发生,而不是误报警报。

对互联网流量数据进行数据分析

让我们先看看互联网流量数据。如前所述,我们将使用 KDD Cup 1999 数据,您可以从以下链接下载:kdd.ics.uci.edu/databases/kddcup99/kddcup99.html。我们将使用kddcup.data_10_percent.gz数据来进行这次网络攻击检测项目。

数据清理

我们需要做的第一件事是清理数据以供后续步骤使用。如果您打开您刚刚下载的数据,您会注意到数据集中没有标题。然而,对于未来的数据分析和模型构建,总是有益于将标题与每一列关联起来。根据可以在kdd.ics.uci.edu/databases/kddcup99/kddcup.names找到的列描述,我们将为原始数据集附加标题。将列名附加到数据框的代码如下:

// Read in the Cyber Attack dataset
// TODO: change the path to point to your data directory
string dataDirPath = @"<path-to-data-dir>";

// Load the data into a data frame
string dataPath = Path.Combine(dataDirPath, "kddcup.data_10_percent");
Console.WriteLine("Loading {0}\n\n", dataPath);
var featuresDF = Frame.ReadCsv(
    dataPath,
    hasHeaders: false,
    inferTypes: true
);

string[] colnames =
{
    "duration", "protocol_type", "service", "flag", "src_bytes",
    "dst_bytes", "land", "wrong_fragment", "urgent", "hot",
    "num_failed_logins", "logged_in", "num_compromised", "root_shell",
    "su_attempted", "num_root", "num_file_creations", "num_shells",
    "num_access_files", "num_outbound_cmds", "is_host_login", "is_guest_login",
    "count", "srv_count", "serror_rate", "srv_serror_rate", "rerror_rate",
    "srv_rerror_rate", "same_srv_rate", "diff_srv_rate", "srv_diff_host_rate",
    "dst_host_count", "dst_host_srv_count", "dst_host_same_srv_rate",
    "dst_host_diff_srv_rate", "dst_host_same_src_port_rate",
    "dst_host_srv_diff_host_rate", "dst_host_serror_rate",
    "dst_host_srv_serror_rate", "dst_host_rerror_rate", "dst_host_srv_rerror_rate",
    "attack_type"
};
featuresDF.RenameColumns(colnames);

如您从以下代码中可以看到,我们通过向 Deedle 数据框的ReadCsv方法提供hasHeaders: false标志,不带有标题地加载这个原始数据集。通过提供这个标志,我们告诉 Deedle 不要将数据集的第一行作为标题。一旦这些数据被加载到数据框中,我们就使用RenameColumns方法将列名附加到数据框上。

我们接下来的清理任务是按相应的类别将网络攻击类型分组。您可以在以下链接中找到攻击类型和类别之间的映射:kdd.ics.uci.edu/databases/kddcup99/training_attack_types。使用这个映射,我们将在数据框中创建一个新列,包含有关攻击类别的信息。让我们先看看代码:

// keeping "normal" for now for plotting purposes
IDictionary<string, string> attackCategories = new Dictionary<string, string>
{
    {"back", "dos"},
    {"land", "dos"},
    {"neptune", "dos"},
    {"pod", "dos"},
    {"smurf", "dos"},
    {"teardrop", "dos"},
    {"ipsweep", "probe"},
    {"nmap", "probe"},
    {"portsweep", "probe"},
    {"satan", "probe"},
    {"ftp_write", "r2l"},
    {"guess_passwd", "r2l"},
    {"imap", "r2l"},
    {"multihop", "r2l"},
    {"phf", "r2l"},
    {"spy", "r2l"},
    {"warezclient", "r2l"},
    {"warezmaster", "r2l"},
    {"buffer_overflow", "u2r"},
    {"loadmodule", "u2r"},
    {"perl", "u2r"},
    {"rootkit", "u2r"},
    {"normal", "normal"}
};

featuresDF.AddColumn(
    "attack_category",
    featuresDF.GetColumn<string>("attack_type")
        .Select(x => attackCategories[x.Value.Replace(".", "")])
);

如果您仔细查看此代码,我们创建了一个具有攻击类型与其类别之间映射的Dictionary对象。例如,攻击类型"back"拒绝服务DOS)攻击之一,而攻击类型"rootkit"用户到根U2R)攻击之一。使用这种映射,我们创建了一个新的列"attack_category",并将其添加到featuresDF中。现在我们已经清理了原始数据集的列名和攻击类别,我们需要将其导出并存储到我们的本地驱动器中供将来使用。您可以使用以下代码来导出这些数据:

featuresDF.SaveCsv(Path.Combine(dataDirPath, "data.csv"));

目标变量分布

现在我们有了可以工作的干净数据,我们将开始深入挖掘数据。让我们首先看看网络攻击类别的分布。获取目标变量分布的代码如下:

// 1\. Target Variable Distribution
Console.WriteLine("\n\n-- Counts by Attack Category --\n");
var attackCount = featuresDF.AggregateRowsBy<string, int>(
    new string[] { "attack_category" },
    new string[] { "duration" },
    x => x.ValueCount
).SortRows("duration");
attackCount.RenameColumns(new string[] { "attack_category", "count" });

attackCount.Print();

DataBarBox.Show(
    attackCount.GetColumn<string>("attack_category").Values.ToArray(),
    attackCount["count"].Values.ToArray()
).SetTitle(
    "Counts by Attack Category"
);

与前几章类似,我们使用 Deedle 数据框中的AggregateRowsBy方法按目标变量attack_category进行分组,并计算数据集中每个类别的发生次数。然后,我们使用DataBarBox类显示这个分布的条形图。一旦运行此代码,以下条形图将被显示:

显示每个网络攻击类别发生次数的输出如下:

这里有一个值得注意的地方。在数据集中,DOS 攻击样本的数量比正常样本多。如前所述,我们在这个项目中使用的 KDD Cup 1999 数据集是人工和模拟数据,因此它并不反映现实生活中的情况,即正常互联网连接的数量将超过所有其他网络攻击的总和。在下一节构建模型时,我们必须记住这一点。

分类变量分布

我们在这个数据集中拥有的特征是分类变量和连续变量的混合。例如,名为duration的特征表示连接的长度,是一个连续变量。然而,名为protocol_type的特征,表示协议类型,如tcpudp等,是一个分类变量。要获取完整的特征描述,您可以访问此链接:kdd.ics.uci.edu/databases/kddcup99/task.html

在本节中,我们将查看正常连接和恶意连接之间分类变量分布的差异。以下代码展示了我们如何将样本集分为两个子组,一个用于正常连接,另一个用于异常连接:

var attackSubset = featuresDF.Rows[
    featuresDF.GetColumn<string>("attack_category").Where(
        x => !x.Value.Equals("normal")
    ).Keys
];
var normalSubset = featuresDF.Rows[
    featuresDF.GetColumn<string>("attack_category").Where(
        x => x.Value.Equals("normal")
    ).Keys
];

现在我们有了这两个子集,让我们开始比较正常和恶意连接之间分类变量的分布。让我们首先看看代码:

// 2\. Categorical Variable Distribution
string[] categoricalVars =
{
    "protocol_type", "service", "flag", "land"
};
foreach (string variable in categoricalVars)
{
    Console.WriteLine("\n\n-- Counts by {0} --\n", variable);
    Console.WriteLine("* Attack:");
    var attackCountDF = attackSubset.AggregateRowsBy<string, int>(
        new string[] { variable },
        new string[] { "duration" },
        x => x.ValueCount
    );
    attackCountDF.RenameColumns(new string[] { variable, "count" });

    attackCountDF.SortRows("count").Print();

    Console.WriteLine("* Normal:");
    var countDF = normalSubset.AggregateRowsBy<string, int>(
        new string[] { variable },
        new string[] { "duration" },
        x => x.ValueCount
    );
    countDF.RenameColumns(new string[] { variable, "count" });

    countDF.SortRows("count").Print();

    DataBarBox.Show(
        countDF.GetColumn<string>(variable).Values.ToArray(),
        new double[][] 
        {
            attackCountDF["count"].Values.ToArray(),
            countDF["count"].Values.ToArray()
        }
    ).SetTitle(
        String.Format("Counts by {0} (0 - Attack, 1 - Normal)", variable)
    );
}

在此代码中,我们正在遍历一个分类变量数组:protocol_typeserviceflagland。我们将特征描述推迟到以下链接中可以找到的描述页面:kdd.ics.uci.edu/databases/kddcup99/task.html。对于每个分类变量,我们使用了AggregateRowsBy方法按变量的每种类型进行分组,并计算每种类型的出现次数。我们对正常组进行一次聚合,然后对攻击组再进行一次聚合。然后,我们使用DataBarBox类来显示条形图,以直观地展示分布的差异。让我们看看一些图表和输出。

以下条形图是针对protocol_type特征的:

两个组之间每种类型的实际计数如下所示:

从这些输出中可以看出,正常组和网络攻击组的分布之间存在一些明显的区别。例如,大多数攻击发生在icmptcp协议上,而大多数正常连接发生在tcpudp协议上。

以下条形图是针对land特征的:

该特征中每种类型的实际计数如下所示:

从这些输出中很难判断我们能否得出任何有意义的见解。数据集中的几乎所有样本在攻击组和正常组中都具有0的值。让我们再看一个特征。

以下条形图显示了攻击组和正常组中特征flag的分布情况:

实际计数如下所示:

尽管攻击组和正常组最频繁出现的标志类型都是SF,但在此特征中仍有一些明显的区别。似乎标志类型SFREJ占据了正常组的多数。另一方面,标志类型SFS0REJ占据了攻击组的多数。

连续变量分布

到目前为止,我们已经看了分类变量的分布。现在让我们看看特征集中连续变量的分布。与前面的章节类似,我们将查看每个连续变量的四分位数。计算每个连续特征四分位数的代码如下:

foreach (string variable in continuousVars)
{
    Console.WriteLine(String.Format("\n\n-- {0} Distribution (Attack) -- ", variable));
    double[] attachQuartiles = Accord.Statistics.Measures.Quantiles(
        attackSubset[variable].DropMissing().ValuesAll.ToArray(),
        new double[] { 0, 0.25, 0.5, 0.75, 1.0 }
    );
    Console.WriteLine(
        "Min: \t\t\t{0:0.00}\nQ1 (25% Percentile): \t{1:0.00}\nQ2 (Median): \t\t{2:0.00}\nQ3 (75% Percentile): \t{3:0.00}\nMax: \t\t\t{4:0.00}",
        attachQuartiles[0], attachQuartiles[1], attachQuartiles[2], attachQuartiles[3], attachQuartiles[4]
    );

    Console.WriteLine(String.Format("\n\n-- {0} Distribution (Normal) -- ", variable));
    double[] normalQuantiles = Accord.Statistics.Measures.Quantiles(
        normalSubset[variable].DropMissing().ValuesAll.ToArray(),
        new double[] { 0, 0.25, 0.5, 0.75, 1.0 }
    );
    Console.WriteLine(
        "Min: \t\t\t{0:0.00}\nQ1 (25% Percentile): \t{1:0.00}\nQ2 (Median): \t\t{2:0.00}\nQ3 (75% Percentile): \t{3:0.00}\nMax: \t\t\t{4:0.00}",
        normalQuantiles[0], normalQuantiles[1], normalQuantiles[2], normalQuantiles[3], normalQuantiles[4]
    );
}

变量continuousVars被定义为以下字符串数组:

// 3\. Continuous Variable Distribution
string[] continuousVars =
{
    "duration", "src_bytes", "dst_bytes", "wrong_fragment", "urgent", "hot",
    "num_failed_logins", "num_compromised", "root_shell", "su_attempted",
    "num_root", "num_file_creations", "num_shells", "num_access_files",
    "num_outbound_cmds", "count", "srv_count", "serror_rate", "srv_serror_rate",
    "rerror_rate", "srv_rerror_rate", "same_srv_rate", "diff_srv_rate",
    "srv_diff_host_rate", "dst_host_count", "dst_host_srv_count",
    "dst_host_same_srv_rate", "dst_host_diff_srv_rate", "dst_host_same_src_port_rate",
    "dst_host_srv_diff_host_rate", "dst_host_serror_rate", "dst_host_srv_serror_rate",
    "dst_host_rerror_rate", "dst_host_srv_rerror_rate"
};

与我们之前进行分类变量分析所做的一样,我们开始遍历前述代码中的连续变量。字符串数组continuousVars包含了我们数据集中所有连续特征的列表,我们遍历这个数组以开始计算每个分布的四分位数。正如前几章所述,我们使用Accord.Statistics.Measures.Quantiles方法来计算四分位数,这些四分位数包括最小值、25%分位数、中位数、75%分位数和最大值。我们进行了两次计算,一次针对攻击组,另一次针对正常组,这样我们可以看到分布之间是否存在任何明显的差异。让我们看看一些特征及其分布。

首先,以下输出是针对一个名为duration的特征的分布:

图片

从这个输出中,我们可以看到这个特征的攻击组和正常组的值大多数都是0。由于这个变量的方差不大,我们的模型可能不会从这个特征中学习到很多信息。让我们看看另一个特征。

以下输出是针对一个名为dst_bytes的特征的分布,它表示从目标到源的数据字节数:

图片

在这里,我们可以看到攻击组和正常组之间分布的一些明显区别。几乎所有的网络攻击都有一个值为 0,而正常网络连接的值分布在一个很宽的范围内。

最后,以下输出是针对一个名为wrong_fragment的特征:

图片

duration特征的情况类似,攻击组和正常组的值大多数都是0,这表明我们的模型可能不会从这个特征中学习到很多见解。你可以运行之前的代码来查看其他所有特征的两组之间的分布差异。

运行这个数据分析步骤的完整代码可以在以下链接找到:github.com/yoonhwang/c-sharp-machine-learning/blob/master/ch.9/DataAnalyzer.cs

特征工程和 PCA

到目前为止,我们已经分析了目标变量attack_category的分布,以及网络攻击数据集中的分类和连续变量。在本节中,我们将专注于对目标变量和分类特征进行编码,并为我们的未来模型构建步骤创建 PCA 特征。

目标和分类变量编码

首先,我们必须对目标变量attack_category中的不同类别进行编码。如果你还记得从上一个数据分析步骤,有五个不同的类别:normal、dosprober2lu2r。我们将用相应的整数表示来对这些字符串值进行编码。然后,我们将使用独热编码对每个分类变量进行编码,其中如果给定的值出现在示例中,我们用1进行编码,如果没有,则用0进行编码。让我们首先使用以下代码加载我们在上一个数据分析步骤中创建的清理后的数据:

// Read in the Cyber Attack dataset
// TODO: change the path to point to your data directory
string dataDirPath = @"<path-to-data-dir>";

// Load the data into a data frame
string dataPath = Path.Combine(dataDirPath, "data.csv");
Console.WriteLine("Loading {0}\n\n", dataPath);
var rawDF = Frame.ReadCsv(
    dataPath,
    hasHeaders: true,
    inferTypes: true
);

如此代码所示,我们设置了hasHeaders: true,因为清理后的数据现在与每个列都有正确的标题关联。以下代码显示了我们是如何对目标和分类变量进行编码的:

// Encode Categorical Variables
string[] categoricalVars =
{
    "protocol_type", "service", "flag", "land"
};
// Encode Target Variables
IDictionary<string, int> targetVarEncoding = new Dictionary<string, int>
{
    {"normal", 0},
    {"dos", 1},
    {"probe", 2},
    {"r2l", 3},
    {"u2r", 4}
};

var featuresDF = Frame.CreateEmpty<int, string>();

foreach (string col in rawDF.ColumnKeys)
{
    if(col.Equals("attack_type"))
    {
        continue;
    }
    else if (col.Equals("attack_category"))
    {
        featuresDF.AddColumn(
            col, 
            rawDF.GetColumn<string>(col).Select(x => targetVarEncoding[x.Value])
        );
    }
    else if (categoricalVars.Contains(col))
    {
        var categoryDF = EncodeOneHot(rawDF.GetColumn<string>(col), col);

        foreach (string newCol in categoryDF.ColumnKeys)
        {
            featuresDF.AddColumn(newCol, categoryDF.GetColumn<int>(newCol));
        }
    }
    else
    {
        featuresDF.AddColumn(
            col, 
            rawDF[col].Select((x, i) => double.IsNaN(x.Value) ? 0.0 : x.Value)
        );
    }
}

让我们更深入地看看这段代码。我们首先创建了一个字符串数组变量categoricalVars,它包含所有分类变量的列名,以及一个字典变量targetVarEncoding,它将每个目标类别映射到一个整数值。例如,我们将normal类别编码为0,将dos攻击类别编码为1,依此类推。然后,我们遍历rawDF数据框中的所有列,并将编码后的数据添加到新的空featuresDF中。这里需要注意的是,我们使用了一个辅助函数EncodeOneHot来对每个分类变量进行编码。让我们看看以下代码:

private static Frame<int, string> EncodeOneHot(Series<int, string> rows, string originalColName)
{

    var categoriesByRows = rows.GetAllValues().Select((x, i) =>
    {
        // Encode the categories appeared in each row with 1
        var sb = new SeriesBuilder<string, int>();
        sb.Add(String.Format("{0}_{1}", originalColName, x.Value), 1);

        return KeyValue.Create(i, sb.Series);
    });

    // Create a data frame from the rows we just created
    // And encode missing values with 0
    var categoriesDF = Frame.FromRows(categoriesByRows).FillMissing(0);

    return categoriesDF;
}

如果你还记得第二章,“垃圾邮件过滤”和第三章,“Twitter 情感分析”,这段代码应该看起来很熟悉。在这段代码中,我们遍历每一行,创建一个新的变量,它是原始列名和值的组合,最后创建一个新的 Deedle 数据框,categoriesDF。一旦完成这一步,这个数据框的输出就会被附加到前一个代码中的featuresDF

现在我们已经完成了目标和分类变量的编码,我们需要导出并存储这个新的数据框featuresDF。我们使用以下代码来存储这个数据:

Console.WriteLine("* Exporting feature set...");
featuresDF.SaveCsv(Path.Combine(dataDirPath, "features.csv"));

适配 PCA

使用我们在上一节中创建的编码数据,让我们开始构建 PCA 特征,这些特征将在接下来的模型构建步骤中用于异常检测。

我们需要做的第一件事是将我们的样本集分为两个独立的集合——一个包含正常连接数据,另一个包含恶意连接。当我们创建这些子集时,我们需要在这两组之间创建更真实的分布。如果您还记得之前的数据分析步骤,我们注意到恶意连接的数量比正常连接多,这是不现实的,因为 KDD CUP 1999 数据集是一个人工和手工注入的数据集。除了创建具有更真实正常和恶意连接数量的数据集的目的之外,我们还需要创建这两个子集,这样我们就可以仅对正常组应用 PCA,然后将其应用于异常组。

这是因为我们只想从正常连接组中学习和构建主成分,并且能够标记任何异常值作为潜在的网络安全攻击。我们将更详细地讨论如何使用主成分构建异常检测模型。

让我们看一下以下代码,用于将我们的样本集分为两组——一组为正常组,另一组为网络攻击组:

// Build PCA with only normal data
var rnd = new Random();

int[] normalIdx = featuresDF["attack_category"]
    .Where(x => x.Value == 0)
    .Keys
    .OrderBy(x => rnd.Next())
    .Take(90000).ToArray();
int[] attackIdx = featuresDF["attack_category"]
    .Where(x => x.Value > 0)
    .Keys
    .OrderBy(x => rnd.Next())
    .Take(10000).ToArray();
int[] totalIdx = normalIdx.Concat(attackIdx).ToArray();

如您从以下代码中可以看到,我们通过过滤 attack_category 是否为 0(正常)或大于 0(网络攻击)来构建正常和网络攻击组的索引数组。然后,我们从正常连接中随机选择 90,000 个样本,从恶意连接中随机选择 10,000 个样本。现在我们有了正常和异常组的索引,我们将使用以下代码来构建拟合 PCA 的实际数据:

var normalSet = featuresDF.Rows[normalIdx];

string[] nonZeroValueCols = normalSet.ColumnKeys.Where(
    x => !x.Equals("attack_category") && normalSet[x].Max() != normalSet[x].Min()
).ToArray();

double[][] normalData = BuildJaggedArray(
    normalSet.Columns[nonZeroValueCols].ToArray2D<double>(), 
    normalSet.RowCount, 
    nonZeroValueCols.Length
);
double[][] wholeData = BuildJaggedArray(
    featuresDF.Rows[totalIdx].Columns[nonZeroValueCols].ToArray2D<double>(),
    totalIdx.Length,
    nonZeroValueCols.Length
);
int[] labels = featuresDF
    .Rows[totalIdx]
    .GetColumn<int>("attack_category")
    .ValuesAll.ToArray();

如您从以下代码中可以看到,normalData 变量包含所有正常连接样本,而 wholeData 变量包含正常和网络攻击连接样本。我们将使用 normalData 来拟合 PCA,然后将学到的 PCA 应用到 wholeData 上,如下面的代码所示:

var pca = new PrincipalComponentAnalysis(
    PrincipalComponentMethod.Standardize
);
pca.Learn(normalData);

double[][] transformed = pca.Transform(wholeData);

如同 第八章 中所述的 手写数字识别,我们正在使用 Accord.NET 框架中的 PrincipalComponentAnalysis 类来拟合 PCA。一旦我们使用正常连接数据训练了 PCA,我们就通过使用 pca 对象的 Transform 方法将 PCA 应用到包含正常和网络攻击连接的 wholeData 上。

PCA 特征

我们现在已经仅使用正常连接组构建了主成分。让我们简要检查我们的目标类别在不同主成分组合上的分离情况。请看以下代码:

double[][] first2Components = transformed.Select(
    x => x.Where((y, i) => i < 2).ToArray()
).ToArray();
ScatterplotBox.Show("Component #1 vs. Component #2", first2Components, labels);

double[][] next2Components = transformed.Select(
    x => x.Where((y, i) => i < 3 && i >= 1).ToArray()
).ToArray();
ScatterplotBox.Show("Component #2 vs. Component #3", next2Components, labels);

next2Components = transformed.Select(
    x => x.Where((y, i) => i < 4 && i >= 2).ToArray()
).ToArray();
ScatterplotBox.Show("Component #3 vs. Component #4", next2Components, labels);

next2Components = transformed.Select(
    x => x.Where((y, i) => i < 5 && i >= 3).ToArray()
).ToArray();
ScatterplotBox.Show("Component #4 vs. Component #5", next2Components, labels);

next2Components = transformed.Select(
    x => x.Where((y, i) => i < 6 && i >= 4).ToArray()
).ToArray();
ScatterplotBox.Show("Component #5 vs. Component #6", next2Components, labels);

如您从以下代码中可以看到,我们一次构建两个主成分之间的散点图,对于前六个成分。当您运行此代码时,您将看到类似于以下图表的图形。

第一张图是第一和第二个主成分之间的,如下所示:

图片

蓝点代表正常连接,其他不同颜色的点代表网络攻击。我们可以看到不同类别之间的分布中存在一些区别,但模式似乎并不那么强烈。

以下图表是第二和第三成分之间的:

图片

最后,以下图表是第三和第四成分之间的:

图片

在最后一张图中,我们真的看不到不同类别之间的很多区别。尽管模式看起来不是很强,之前的散点图显示了分布中的一些差异。特别是在二维图中,视觉上看到差异尤其困难。如果我们将其扩展到更高维的空间,即我们的异常检测模型将要查看的空间,模式的差异将变得更加明显。

现在,让我们看看主成分解释的方差量。以下代码展示了我们如何获取解释方差的累积占比,并在折线图中显示它:

double[] explainedVariance = pca.Components
    .Select(x => x.CumulativeProportion)
    .Where(x => x < 1)
    .ToArray();

DataSeriesBox.Show(
    explainedVariance.Select((x, i) => (double)i),
    explainedVariance
).SetTitle("Explained Variance");
System.IO.File.WriteAllLines(
    Path.Combine(dataDirPath, "explained-variance.csv"),
    explainedVariance.Select((x, i) => String.Format("{0},{1:0.0000}", i, x))
);

如果你仔细查看这段代码,pca对象中的Components属性包含了关于解释方差的占比信息。我们可以通过使用CumulativeProportion属性遍历每个成分并获取累积占比。一旦我们提取了这些值,我们就使用DataSeriesBox类来显示一个显示解释方差累积占比的折线图。输出看起来如下:

图片

现在,我们已经成功创建了 PCA 特征,并有了完整的 PCA 转换数据。你可以使用以下代码来导出这些数据:

Console.WriteLine("* Exporting pca-transformed feature set...");
System.IO.File.WriteAllLines(
    Path.Combine(
        dataDirPath,
        "pca-transformed-features.csv"
    ),
    transformed.Select(x => String.Join(",", x))
);
System.IO.File.WriteAllLines(
    Path.Combine(
        dataDirPath,
        "pca-transformed-labels.csv"
    ),
    labels.Select(x => x.ToString())
);

特征工程步骤的完整代码可以在以下链接找到:github.com/yoonhwang/c-sharp-machine-learning/blob/master/ch.9/FeatureEngineering.cs

异常检测的主成分分类器

我们已经整理好了一切,现在准备开始构建一个用于网络攻击检测项目的异常检测模型。如前所述,我们将使用正常连接组的主成分分布数据,并将其作为主成分的正常范围。对于任何偏离这些主成分值正常范围的记录,我们将将其标记为异常,并视为潜在的网络安全攻击。

训练准备

首先,让我们加载从特征工程步骤创建的特征数据。你可以使用以下代码来加载 PCA 转换数据和标签数据:

// Read in the Cyber Attack dataset
// TODO: change the path to point to your data directory
string dataDirPath = @"<path-to-dir>";

// Load the data into a data frame
string dataPath = Path.Combine(dataDirPath, "pca-transformed-features.csv");
Console.WriteLine("Loading {0}\n\n", dataPath);
var featuresDF = Frame.ReadCsv(
    dataPath,
    hasHeaders: false,
    inferTypes: true
);
featuresDF.RenameColumns(
    featuresDF.ColumnKeys.Select((x, i) => String.Format("component-{0}", i + 1))
);

int[] labels = File.ReadLines(
    Path.Combine(dataDirPath, "pca-transformed-labels.csv")
).Select(x => int.Parse(x)).ToArray();
featuresDF.AddColumn("attack_category", labels);

让我们快速看一下目标类别的分布。按每个目标类别计数的代码如下:

var count = featuresDF.AggregateRowsBy<string, int>(
    new string[] { "attack_category" },
    new string[] { "component-1" },
    x => x.ValueCount
).SortRows("component-1");
count.RenameColumns(new string[] { "attack_category", "count" });
count.Print();

一旦运行此代码,你将看到以下输出:

图片

如预期,大多数样本属于 0 类,即正常组,其余的合并在一起占我们样本集的少数(大约 10%)。这是对网络攻击更现实的看法。网络攻击的发生频率远低于正常连接。

为了说明目的,我们将使用解释数据集总体方差约 70%的前27个主成分。您可以尝试不同的主成分数量,看看模型性能如何变化。以下代码显示了如何使用前27个主成分创建训练集:

// First 13 components explain about 50% of the variance
// First 19 components explain about 60% of the variance
// First 27 components explain about 70% of the variance
// First 34 components explain about 80% of the variance
int numComponents = 27;
string[] cols = featuresDF.ColumnKeys.Where((x, i) => i < numComponents).ToArray();

// First, compute distances from the center/mean among normal events
var normalDF = featuresDF.Rows[
    featuresDF["attack_category"].Where(x => x.Value == 0).Keys
].Columns[cols];

double[][] normalData = BuildJaggedArray(
    normalDF.ToArray2D<double>(), normalDF.RowCount, cols.Length
);

如果您仔细查看这段代码,您会注意到我们只使用正常连接样本创建了normalDFnormalData变量。如前所述,我们只想从正常数据中学习,以便我们可以标记任何异常值和主成分正常范围的极端偏差。我们将在下一节中使用这些变量来构建网络攻击检测的主成分分类器。

构建主成分分类器

为了构建一个主成分分类器,该分类器将标记那些偏离正常连接的事件,我们需要计算记录与正常连接分布之间的距离。我们将使用一个距离度量,即马氏距离,它衡量一个点与分布之间的距离。对于标准化的主成分,如这里所示,计算马氏距离的方程如下:

图片

在这个方程中,C[i]代表每个主成分的值,而var[i]代表每个主成分的方差。让我们看看以下例子:

图片

假设您有 5 个主成分,其值如图所示,并且为了简单和演示的目的,假设每个主成分的方差为 1,那么您可以计算马氏距离如下:

图片

并且,这个示例的马氏距离计算结果是 0.64。为了更详细地了解这个距离度量,建议您查阅以下维基百科页面:en.wikipedia.org/wiki/Mahalanobis_distance,或者以下研究论文:users.cs.fiu.edu/~chens/PDF/ICDM03_WS.pdf

我们将马氏距离方程实现为一个辅助函数ComputeDistances,其代码如下:

private static double[] ComputeDistances(double[][] data, double[] componentVariances)
{

    double[] distances = data.Select(
        (row, i) => Math.Sqrt(
            row.Select(
                (x, j) => Math.Pow(x, 2) / componentVariances[j]
            ).Sum()
        )
    ).ToArray();

    return distances;
}
ComputeDistances method takes in two arguments—data and componentVariances. The variable data is a two-dimensional array that we want to compute distances for, and the componentVariances variable is the variance of the principal components that are learned from the normal network connections data. In order to compute the variances of the principal components, we use the following helper function:
private static double[] ComputeVariances(double[][] data)
{
    double[] componentVariances = new double[data[0].Length];

    for (int j = 0; j < data[0].Length; j++)
    {
        componentVariances[j] = data
            .Select((x, i) => Math.Pow(data[i][j], 2))
            .Sum() / data.Length;
    }

    return componentVariances;
}
ComputeDistances, as follows:
double[] distances = ComputeDistances(normalData);

现在我们已经计算了单个记录的距离,让我们分析正常连接的范围。我们使用了以下代码来计算距离的均值和标准差,并使用直方图来可视化整体距离分布:

double meanDistance = distances.Average();
double stdDistance = Math.Sqrt(
    distances
    .Select(x => Math.Pow(x - meanDistance, 2))
    .Sum() / distances.Length
);

Console.WriteLine(
    "* Normal - mean: {0:0.0000}, std: {1:0.0000}",
    meanDistance, stdDistance
);

HistogramBox.Show(
    distances,
    title: "Distances"
)
.SetNumberOfBins(50);

当您运行此代码时,您将看到以下输出,显示正常连接距离度量的平均值和标准差:

图片 2

历史图如下所示:

图片 3

如您从这些输出中可以看到,大多数距离都非常小,这表明非攻击和正常连接通常紧密地聚集在一起。有了关于正常连接组内距离分布的信息,让我们开始寻找是否可以通过标记超出正常距离范围的某些网络连接来构建一个检测模型。

以下代码展示了我们是如何从正常网络连接的分布中计算网络攻击连接的距离的:

// Detection
var attackDF = featuresDF.Rows[
    featuresDF["attack_category"].Where(x => x.Value > 0).Keys
].Columns[cols];

double[][] attackData = BuildJaggedArray(
    attackDF.ToArray2D<double>(), attackDF.RowCount, cols.Length
);

double[] attackDistances = ComputeDistances(attackData, normalVariances);

如您从这段代码中可以看到,我们首先创建了一个名为attackData的变量,它包含了我们训练集中所有的网络攻击连接。然后,我们使用了ComputeDistances方法来计算网络攻击连接组中各个记录的距离。

现在,我们准备开始根据我们刚刚计算的距离度量来标记可疑的网络连接。让我们先看看以下代码:

// 5-10% false alarm rate
for (int i = 4; i < 10; i++)
{
    double targetFalseAlarmRate = 0.01 * (i + 1);
    double threshold = Accord.Statistics.Measures.Quantile(
        distances,
        1 - targetFalseAlarmRate
    );

    int[] detected = attackDistances.Select(x => x > threshold ? 1 : 0).ToArray();

    EvaluateResults(attackLabels, detected, targetFalseAlarmRate);
}

如您从这段代码中可以看到,我们根据正常连接组内距离的分布来确定阈值。例如,如果我们目标是保持 5%的误报率,我们将标记所有距离超过正常范围且大于正常连接组内距离分布的 95%百分位的连接。更具体地说,在我们的案例中,正常连接距离分布的 95%百分位是 5.45。因此,在这种情况下,我们将标记所有距离超过 5.45 的连接为网络攻击。我们将重复此过程,从 5%到 10%的误报率。我们将在以下模型评估步骤中更详细地讨论这个异常检测模型的表现。

构建模型步骤的完整代码可以在以下链接找到:github.com/yoonhwang/c-sharp-machine-learning/blob/master/ch.9/Modeling.cs

评估异常检测模型

在之前的模型构建步骤中,我们为网络攻击构建了一个异常检测模型。在之前的代码中,您可能已经注意到我们使用了一个名为EvaluateResults的函数。这是一个我们为评估模型性能编写的辅助函数。让我们看看以下代码:

private static void EvaluateResults(int[] attackLabels, int[] detected, double targetFalseAlarmRate)
{
    double overallRecall = (double)detected.Sum() / attackLabels.Length;

    double[] truePositives = new double[4];
    double[] actualClassCounts = new double[4];

    for (int i = 0; i < attackLabels.Length; i++)
    {
        actualClassCounts[attackLabels[i] - 1] += 1.0;

        if (detected[i] > 0)
        {
            truePositives[attackLabels[i] - 1] += 1.0;
        }
    }

    double[] recalls = truePositives.Select((x, i) => x / actualClassCounts[i]).ToArray();

    Console.WriteLine("\n\n---- {0:0.0}% False Alarm Rate ----", targetFalseAlarmRate * 100.0);
    Console.WriteLine("* Overall Attack Detection: {0:0.00}%", overallRecall * 100.0);
    Console.WriteLine(
        "* Detection by Attack Type:\n\t{0}",
        String.Join("\n\t", recalls.Select(
            (x, i) => String.Format("Class {0}: {1:0.00}%", (i + 1), x * 100.0))
        )
    );
}

如您从这段代码中可以看到,我们关注两个指标:总体网络攻击检测率和每类检测率。评估结果如下所示:

图片 1

总体结果看起来不错,检测率超过 99%。在 5%的误报率下,大约 99.1%的网络攻击被检测到。然而,如果我们更仔细地查看每类检测率,我们可以看到它们的弱点和优势。在 5%的误报率下,我们的模型在检测第 1 类和第 2 类(即dosprobe攻击)方面表现良好。另一方面,我们的模型在检测第 3 类和第 4 类(即r2lu2r攻击)方面表现不佳。正如您可以从这个输出中看到的那样,随着我们提高目标误报率,总体和每类的检测率也随之提高。在现实世界的情况下,您将不得不在更高的检测率和更高的误报率之间权衡,并做出符合您业务需求的关于目标误报率的决策。

摘要

在本章中,我们构建了我们第一个可以检测网络攻击的异常检测模型。在本章的开头,我们讨论了这种类型的异常检测模型如何被用于和应用于现实生活的情况,以及开发异常检测模型与迄今为止我们构建的其他机器学习(ML)模型的不同之处。然后,我们开始分析目标类别的分布和各种特征,以更好地理解数据集。在我们分析这个数据集的同时,我们也注意到网络攻击样本比正常连接样本多,这在现实生活中是不现实的。为了模拟现实生活中的情况,其中异常恶意连接的发生频率远低于正常连接,我们随机子选择了正常和恶意连接样本,使得训练集的 90%是正常连接,只有 10%是网络攻击示例。

使用这个子选定的训练集,我们对正常连接数据应用了主成分分析(PCA),以找出主成分的正常范围。使用马氏距离度量,我们计算了来自正常连接分布的各个记录之间的距离。在模型构建步骤中,我们根据目标误报率尝试了不同的阈值。使用 5%到 10%的误报率,我们构建了网络攻击检测模型并评估了它们的性能。在我们的模型评估步骤中,我们注意到总体检测率超过 99%,而更仔细地查看每攻击检测率暴露了模型的弱点和优势。我们还注意到,当我们牺牲并提高误报率时,总体网络攻击检测率有所提高。在应用这种异常检测技术时,了解误报率和检测率之间的权衡变得至关重要,并基于相关的业务需求做出决策。

在下一章中,我们将扩展我们在构建异常检测模型方面的知识和经验。我们将使用信用卡数据集来开展一个信用卡欺诈检测项目。在基于 PCA 的异常检测模型之上,我们将讨论如何使用一类支持向量机进行异常检测。

第十章:信用卡欺诈检测

在上一章中,我们使用主成分分析(PCA)构建了我们的第一个异常检测模型,并看到了如何使用主成分来检测网络攻击。与网络攻击或网络入侵问题类似,异常检测模型常用于欺诈检测。许多行业中的各种组织,如金融服务、保险公司和政府机构,经常遇到欺诈案例。特别是在金融领域,欺诈直接与货币损失相关,这些欺诈案例可以以许多不同的形式出现,例如被盗信用卡、会计伪造或假支票。因为这些事件相对较少发生,检测这些欺诈案例既困难又棘手。

在本章中,我们将讨论如何构建一个用于信用卡欺诈检测的异常检测模型。我们将使用一个包含大量正常信用卡交易和相对较少欺诈信用卡交易的匿名信用卡数据集。我们首先将查看数据集的结构、目标类别的分布以及各种匿名特征的分布。然后,我们将开始应用主成分分析(PCA)并构建标准化的主成分,这些主成分将被用作我们的欺诈检测模型的特征。在模型构建步骤中,我们将尝试两种不同的构建欺诈检测模型的方法——类似于我们在第九章中构建的主成分分类器(PCC),即网络攻击检测,以及从正常信用卡交易中学习并检测任何异常的单类支持向量机(SVM)。构建了这些模型后,我们将评估它们的异常检测率并比较它们在信用卡欺诈检测中的性能。

本章将涵盖以下主题:

  • 信用卡欺诈检测项目的定义问题

  • 匿名信用卡数据集的数据分析

  • 特征工程和 PCA

  • 单类 SVM 与 PCC 的比较

  • 评估异常检测模型

问题定义

信用卡欺诈在其他欺诈事件中相对较为常见,并且可能发生在我们的日常生活中。信用卡欺诈可能发生的各种方式。信用卡可能会丢失或被盗,然后被窃贼使用。另一种信用卡欺诈可能发生的方式是,你的身份可能已经被恶意人员泄露,然后他们使用你的身份开设新的信用卡账户,甚至接管你现有的信用卡账户。骗子甚至可能使用电话钓鱼进行信用卡欺诈。由于信用卡欺诈可能发生的途径很多,许多信用卡持卡人都面临着这种类型欺诈的风险,因此在我们日常生活中有一种适当的方法来预防它们的发生变得至关重要。许多信用卡公司已经采取了各种措施来预防和检测这些类型的欺诈活动,使用了各种机器学习ML)和异常检测技术。

在本章中,我们将通过运用和扩展我们关于构建异常检测模型的知识来构建信用卡欺诈检测模型。我们将使用一个可以在以下链接中找到的匿名信用卡数据集:www.kaggle.com/mlg-ulb/creditcardfraud/data。这个数据集大约有 285,000 笔信用卡交易,其中只有大约 0.17%的交易是欺诈交易,这很好地反映了现实生活中的情况。有了这些数据,我们将研究数据集的结构,然后开始研究目标和特征变量的分布。然后,我们将使用 PCA 构建特征,类似于我们在第九章中做的,即网络攻击检测。在构建信用卡欺诈检测模型时,我们将尝试使用 PCC,类似于我们在第九章中构建的,即网络攻击检测,以及单类 SVM,它从正常的信用卡交易中学习并决定一笔新交易是否为欺诈。最后,我们将查看误报率和欺诈检测率,以评估和比较这些模型的性能。

为了总结我们的信用卡欺诈检测项目的问题定义:

  • 问题是什么?我们需要一个异常检测模型来识别、预防和阻止潜在的欺诈性信用卡交易。

  • 为什么这是一个问题?每个信用卡持卡人都面临着成为信用卡欺诈受害者的风险,如果没有为这种恶意尝试做好充分准备,信用卡欺诈受害者的数量将会增加。通过信用卡欺诈检测模型,我们可以预防和阻止潜在的欺诈性信用卡交易发生。

  • 解决这个问题的方法有哪些?我们将使用公开可用的匿名信用卡数据,这些数据包含大量的正常信用卡交易和少量欺诈交易。我们将对此数据应用 PCA,并尝试使用 PCC 和单类 SVM 模型进行欺诈检测。

  • 成功的标准是什么?由于任何信用卡欺诈事件都会导致经济损失,我们希望有高欺诈检测率。即使有一些误报或误警,也最好标记任何可疑的信用卡活动,以防止任何欺诈交易通过。

匿名信用卡数据的数据分析

让我们现在开始查看信用卡数据集。如前所述,我们将使用以下链接提供的可用数据集:www.kaggle.com/mlg-ulb/creditcardfraud/data。这是一个包含大约 285,000 条信用卡交易记录的数据集,其中一些是欺诈交易,而大多数记录是正常的信用卡交易。由于保密问题,数据集中的特征名称被匿名化。我们将使用可以从中下载的creditcard.csv文件。

目标变量分布

我们将要检查的第一件事是数据集中欺诈和非欺诈信用卡交易的分发情况。在数据集中,名为Class的列是目标变量,欺诈信用卡交易用1编码,非欺诈交易用0编码。你可以使用以下代码首先将数据加载到 Deedle 数据框中:

// Read in the Credit Card Fraud dataset
// TODO: change the path to point to your data directory
string dataDirPath = @"<path-to-your-dir>";

// Load the data into a data frame
string dataPath = Path.Combine(dataDirPath, "creditcard.csv");
Console.WriteLine("Loading {0}\n\n", dataPath);
var df = Frame.ReadCsv(
    dataPath,
    hasHeaders: true,
    inferTypes: true
);

这个数据集有标题,代表每个特征和目标类,因此我们使用hasHeaders: true标志加载此数据。现在数据已经加载,你可以使用以下代码来分析目标类的分布:

// Target variable distribution
var targetVarCount = df.AggregateRowsBy<string, int>(
    new string[] { "Class" },
    new string[] { "V1" },
    x => x.ValueCount
).SortRows("V1");
targetVarCount.RenameColumns(new string[] { "is_fraud", "count" });

targetVarCount.Print();

DataBarBox.Show(
    targetVarCount.GetColumn<string>("is_fraud").Values.ToArray(),
    targetVarCount["count"].Values.ToArray()
).SetTitle(
    "Counts by Target Class"
);

由于你可能已经熟悉这个函数了,我们在 Deedle 数据框中使用AggregateRowsBy函数按Class列分组行,然后计算每个目标类中的记录数。由于列名Class并不能很好地代表我们的目标类及其含义,我们将其重命名为另一个名称,is_fraud。从这段代码中你可以看到,你可以使用RenameColumns函数和一个字符串数组来重命名特征名称。最后,我们使用了 Accord.NET 框架中的DataBarBox类来显示一个条形图,该图可以直观地展示目标类的分布。

以下输出显示了目标类的分布:

图片

从这个输出中可以看出,欺诈信用卡交易和非欺诈信用卡交易的数目之间存在很大的差距。我们只有 492 条欺诈记录,而超过 284,000 条非欺诈记录。

以下是由代码生成的用于直观显示目标类别分布的条形图:

如前述输出所预期,属于目标类别1(代表欺诈)的记录数量与属于目标类别0(代表非欺诈和正常信用卡交易)的记录数量之间存在很大的差距。由于与大量正常的日常信用卡交易相比,信用卡欺诈相对较少,这种大的类别不平衡是预期的。这种大的类别不平衡使得大多数机器学习模型难以准确学习如何从非欺诈中识别欺诈。

特征分布

由于保密性问题,我们在这份数据中除了交易金额外的特征都进行了匿名处理。因为我们不知道每个特征代表什么以及每个特征的含义,所以从特征分析中推断任何直观的见解将会很困难。然而,了解每个特征的分布情况、每个特征的分布与其他特征的区别,以及是否可以从特征集中推导出任何明显的模式,仍然是有帮助的。

让我们先看看代码。以下代码展示了我们如何计算并可视化特征的分布:

// Feature distributions
foreach (string col in df.ColumnKeys)
{
    if (col.Equals("Class") || col.Equals("Time"))
    {
        continue;
    }

    double[] values = df[col].DropMissing().ValuesAll.ToArray();

    Console.WriteLine(String.Format("\n\n-- {0} Distribution -- ", col));
    double[] quartiles = Accord.Statistics.Measures.Quantiles(
        values,
        new double[] { 0, 0.25, 0.5, 0.75, 1.0 }
    );
    Console.WriteLine(
        "Min: \t\t\t{0:0.00}\nQ1 (25% Percentile): \t{1:0.00}\nQ2 (Median): \t\t{2:0.00}\nQ3 (75% Percentile): \t{3:0.00}\nMax: \t\t\t{4:0.00}",
        quartiles[0], quartiles[1], quartiles[2], quartiles[3], quartiles[4]
    );

    HistogramBox.Show(
        values,
        title: col
    )
    .SetNumberOfBins(50);
}

如您从这段代码中看到的,我们正在计算四分位数。如您可能记得,四分位数是将数据分为四个不同部分的点。第一四分位数是最小值和中间值之间的中点,第二四分位数是中间值,第三四分位数是中间值和最大值之间的中点。您可以通过使用Accord.Statistics.Measures.Quantiles函数轻松计算四分位数。计算四分位数后,我们使用 Accord.NET 框架中的HistogramBox类为每个特征构建直方图以可视化分布。让我们看看这段代码的一些输出结果。

我们将要查看的第一个分布是针对V1特征的,而V1的特征四分位数看起来如下:

从这个代码中可以看出,V1特征的分布似乎偏向负方向。尽管中位数大约为 0,但负值范围从-56.41 到 0,而正值范围仅从 0 到 2.45。以下是由前述代码生成的直方图输出:

如预期的那样,直方图显示了特征V1分布的左偏斜,而大多数值都围绕 0。

接下来,让我们看看第二个特征V2的分布情况,输出如下:

V2的直方图看起来如下:

值似乎集中在 0 附近,尽管在负方向和正方向上都有一些极端值。与之前的特征V1相比,偏度不太明显。

最后,让我们看看amount特征的分布,这可以告诉我们交易金额的范围。以下是amount特征的四分位数:

似乎任何信用卡交易都可以取 0 到 25,691.16 之间的任何正数作为交易金额。以下是amount特征的直方图:

如预期的那样,我们可以看到右侧有一个长尾。这是可以预料的,因为每个人的消费模式都与其他人不同。有些人可能通常购买价格适中的商品,而有些人可能购买非常昂贵的商品。

最后,让我们简要看看当前特征集如何将欺诈信用卡交易与非欺诈交易区分开来。首先看看以下代码:

// Target Var Distributions on 2-dimensional feature space
double[][] data = BuildJaggedArray(
    df.ToArray2D<double>(), df.RowCount, df.ColumnCount
);
int[] labels = df.GetColumn<int>("Class").ValuesAll.ToArray();

double[][] first2Components = data.Select(
    x => x.Where((y, i) => i < 2
).ToArray()).ToArray();
ScatterplotBox.Show("Feature #1 vs. Feature #2", first2Components, labels);

double[][] next2Components = data.Select(
    x => x.Where((y, i) => i >= 1 && i <= 2).ToArray()
).ToArray();
ScatterplotBox.Show("Feature #2 vs. Feature #3", next2Components, labels);

next2Components = data.Select(
    x => x.Where((y, i) => i >= 2 && i <= 3).ToArray()
).ToArray();
ScatterplotBox.Show("Feature #3 vs. Feature #4", next2Components, labels);

如您从以下代码中可以看到,我们首先将 Deedle 数据帧变量df转换为二维数组变量data,以构建散点图。然后,我们取前两个特征并显示一个散点图,该图显示了目标类别在这两个特征上的分布。我们重复这个过程两次,分别针对第二、第三和第四个特征。

以下散点图是我们数据集中第一和第二个特征的分布:

从这个散点图中,很难(如果不是不可能的话)将欺诈(编码为 1)与非欺诈(编码为 0)区分开来。让我们看看接下来两个特征的散点图:

与前两个特征的情况类似,似乎没有一条清晰的线可以区分欺诈与非欺诈。最后,以下是在第三和第四个特征之间目标类别的散点图:

从这个散点图中可以看出,很难画出一条清晰的线来区分两个目标类别。欺诈交易似乎更多地位于散点图的右下角,但模式很弱。在下一节中,我们将尝试构建更好的特征来区分两个目标类别。

这个数据分析步骤的完整代码可以在以下链接找到:github.com/yoonhwang/c-sharp-machine-learning/blob/master/ch.10/DataAnalyzer.cs

特征工程与 PCA

到目前为止,我们已经分析了目标和特征变量的分布情况。在本章中,我们将重点关注使用 PCA 构建特征。

特征工程准备

为了拟合 PCA,我们首先需要准备我们的数据。让我们快速查看以下代码,将信用卡欺诈数据加载到 Deedle 的数据框中:

// Read in the Credit Card Fraud dataset
// TODO: change the path to point to your data directory
string dataDirPath = @"<path-to-dir>";

// Load the data into a data frame
string dataPath = Path.Combine(dataDirPath, "creditcard.csv");
Console.WriteLine("Loading {0}\n\n", dataPath);
var df = Frame.ReadCsv(
    dataPath,
    hasHeaders: true,
    inferTypes: true
);

Console.WriteLine("* Shape: {0}, {1}\n\n", df.RowCount, df.ColumnCount);

现在我们已经将数据加载到名为df的变量中,我们需要将数据分成两组,一组是正常信用卡交易数据,另一组是欺诈交易数据,这样我们就可以只使用正常交易来拟合 PCA。看看以下代码,了解我们如何从原始数据集中分离出正常交易:

string[] featureCols = df.ColumnKeys.Where(
    x => !x.Equals("Time") && !x.Equals("Class")
).ToArray();

var noFraudData = df.Rows[
    df["Class"].Where(x => x.Value == 0.0).Keys
].Columns[featureCols];
double[][] data = BuildJaggedArray(
    noFraudData.ToArray2D<double>(), noFraudData.RowCount, featureCols.Length
);

如果您还记得之前的数据分析步骤,目标变量Class被编码为 1 表示欺诈交易,0 表示非欺诈交易。从代码中您可以看到,我们创建了一个只包含正常信用卡交易记录的数据框noFraudData。然后,我们使用辅助函数BuildJaggedArray将这个数据框转换成二维双精度数组,该数组将被用于拟合 PCA。这个辅助函数的代码如下:

private static double[][] BuildJaggedArray(double[,] ary2d, int rowCount, int colCount)
{
    double[][] matrix = new double[rowCount][];
    for (int i = 0; i < rowCount; i++)
    {
        matrix[i] = new double[colCount];
        for (int j = 0; j < colCount; j++)
        {
            matrix[i][j] = double.IsNaN(ary2d[i, j]) ? 0.0 : ary2d[i, j];
        }
    }
    return matrix;
}

这段代码看起来很熟悉,因为我们已经在许多前面的章节中使用过它。

接下来我们需要做的是将整个数据框,包括非欺诈和欺诈记录,转换成二维数组。使用训练好的 PCA,我们将转换这个新创建的二维数组,该数组将用于构建信用卡欺诈检测模型。让我们看看以下代码:

double[][] wholeData = BuildJaggedArray(
    df.Columns[featureCols].ToArray2D<double>(), df.RowCount, featureCols.Length
);
int[] labels = df.GetColumn<int>("Class").ValuesAll.ToArray();
df, into a two-dimensional array, wholeData, by using the BuildJaggedArray function.

拟合 PCA

现在,我们准备使用非欺诈信用卡数据拟合 PCA。类似于我们在第九章“网络攻击检测”中所做的,我们将使用以下代码来拟合 PCA:

var pca = new PrincipalComponentAnalysis(
    PrincipalComponentMethod.Standardize
);
pca.Learn(data);

如您从这段代码中看到的,我们正在使用 Accord.NET 框架中的PrincipalComponentAnalysis类来训练 PCA。在这里要注意的另一件事是我们如何使用PrincipalComponentMethod.Standardize。由于 PCA 对特征的尺度很敏感,我们首先标准化特征值,然后拟合 PCA。使用这个训练好的 PCA,我们可以转换包含欺诈和非欺诈交易的全数据。将 PCA 转换应用于数据集的代码如下:

double[][] transformed = pca.Transform(wholeData);

现在,我们已经准备好了所有 PCA 特征,用于接下来的模型构建步骤。在我们继续之前,让我们看看是否可以通过新的 PCA 特征找到任何可以区分目标类别的明显模式。让我们首先看看以下代码:

double[][] first2Components = transformed.Select(x => x.Where((y, i) => i < 2).ToArray()).ToArray();
ScatterplotBox.Show("Component #1 vs. Component #2", first2Components, labels);

double[][] next2Components = transformed.Select(
    x => x.Where((y, i) => i >= 1 && i <= 2).ToArray()
).ToArray();
ScatterplotBox.Show("Component #2 vs. Component #3", next2Components, labels);

next2Components = transformed.Select(
    x => x.Where((y, i) => i >= 2 && i <= 3).ToArray()
).ToArray();
ScatterplotBox.Show("Component #3 vs. Component #4", next2Components, labels);

next2Components = transformed.Select(
    x => x.Where((y, i) => i >= 3 && i <= 4).ToArray()
).ToArray();
ScatterplotBox.Show("Component #4 vs. Component #5", next2Components, labels);

类似于我们在数据分析步骤中所做的,我们选取了两个特征,并创建了目标类别在所选特征上的散点图。从这些图中,我们可以看到 PCA 转换后的数据中的主成分是否更有效地将欺诈信用卡交易与非欺诈交易分开。

下面的散点图显示了第一和第二个主成分之间的关系:

图片

存在一个明显的截止点,将欺诈(散点图中的红色点)与非欺诈(散点图中的蓝色点)分开。从这个散点图来看,欺诈样本的 Y 值(第二个主成分值)通常小于-5。

以下为第二个和第三个主成分之间的散点图:

图片

与之前的散点图相比,这个图中的模式似乎更弱,但仍然似乎有一条明显的线将许多欺诈案例与非欺诈案例区分开来。

以下散点图是第三个和第四个主成分之间的:

图片

最后,以下是第四个和第五个主成分之间的散点图:

图片

在最后两个散点图中,我们找不到一个明显的模式来区分两个目标类别。鉴于我们在查看前三个主成分及其散点图时找到了一些可区分的线,我们的信用卡欺诈检测的异常检测模型将能够从这些数据在更高维度和多个主成分中学习如何分类欺诈。

最后,让我们看一下主成分解释的方差比例。首先看看以下代码:

DataSeriesBox.Show(
    pca.Components.Select((x, i) => (double)i),
    pca.Components.Select(x => x.CumulativeProportion)
).SetTitle("Explained Variance");
System.IO.File.WriteAllLines(
    Path.Combine(dataDirPath, "explained-variance.csv"),
    pca.Components.Select((x, i) => String.Format("{0},{1:0.0000}", i + 1, x.CumulativeProportion))
);

如我们在第九章“网络攻击检测”中讨论的,我们可以使用PrincipalComponentAnalysis对象内的Components属性来提取每个成分解释的累积方差比例。如代码中的第三行所示,我们遍历Components属性并提取CumulativeProportion值。然后,我们使用DataSeriesBox类显示一个折线图。当你运行此代码时,你将看到以下图表,显示了主成分解释的累积方差比例:

图片

如此图表所示,到第二十个主成分时,大约 80%的数据方差得到了解释。我们将在下一节构建异常检测模型时使用此图表来决定使用多少个主成分。

最后,我们需要导出这些数据,因为我们在这个特征工程步骤中创建了一个新的 PCA 变换数据集,我们希望使用这些新数据来构建模型。你可以使用以下代码来导出这些数据:

Console.WriteLine("exporting train set...");

System.IO.File.WriteAllLines(
    Path.Combine(dataDirPath, "pca-features.csv"),
    transformed.Select((x, i) => String.Format("{0},{1}", String.Join(",", x), labels[i]))
);
pca-features.csv. We will use this data to build anomaly detection models for credit card fraud detection in the following step.

在此特征工程步骤中使用到的完整代码可以在以下链接中找到:github.com/yoonhwang/c-sharp-machine-learning/blob/master/ch.10/FeatureEngineering.cs

单类 SVM 与 PCC 对比

现在,我们已经准备好为信用卡欺诈检测项目构建异常检测模型了。在这个步骤中,我们将尝试两种不同的方法。我们将构建一个 PCC,这与我们在第九章,“网络攻击检测”中所做的一样。此外,我们还将介绍一种新的学习算法,即单类 SVM,它从正常的信用卡交易数据中学习,并决定新的数据点是否与其训练的正常数据相似。

模型训练准备

首先,我们需要加载我们在上一个特征工程步骤中创建的数据。你可以使用以下代码来加载数据:

// Read in the Credit Card Fraud dataset
// TODO: change the path to point to your data directory
string dataDirPath = @"<path-to-dir>";

// Load the data into a data frame
string dataPath = Path.Combine(dataDirPath, "pca-features.csv");
Console.WriteLine("Loading {0}\n\n", dataPath);
var featuresDF = Frame.ReadCsv(
    dataPath,
    hasHeaders: false,
    inferTypes: true
);
featuresDF.RenameColumns(
    featuresDF.ColumnKeys
        .Select((x, i) => i == featuresDF.ColumnCount - 1 ? "is_fraud" : String.Format("component-{0}", i + 1))
);

如果你从上一个特征工程步骤中回忆起来,我们没有将带有列名的数据导出。因此,我们将数据加载到带有hasHeaders标志设置为false的 Deedle 数据框featuresDF中。然后,我们使用RenameColumns方法为每个特征赋予适当的列名。让我们快速检查这个数据集内的目标类别分布,以下代码:

Console.WriteLine("* Shape: ({0}, {1})", featuresDF.RowCount, featuresDF.ColumnCount);

var count = featuresDF.AggregateRowsBy<string, int>(
    new string[] { "is_fraud" },
    new string[] { "component-1" },
    x => x.ValueCount
).SortRows("component-1");
count.RenameColumns(new string[] { "is_fraud", "count" });
count.Print();

代码的输出如下:

图片

如前所述,在数据分析步骤中,大多数样本属于非欺诈交易,只有一小部分数据是欺诈信用卡交易。

主成分分类器

我们将首先尝试使用主成分构建一个异常检测模型,这与我们在第九章,“网络攻击检测”中所做的一样。为了训练和测试 PCC 模型,我们编写了一个名为BuildPCAClassifier的辅助函数。这个辅助函数的详细代码可以在以下仓库中找到:github.com/yoonhwang/c-sharp-machine-learning/blob/master/ch.10/Modeling.cs。让我们一步一步地看看这个辅助函数。

当你查看BuildPCAClassifier方法的代码时,你会看到以下几行代码:

// First 13 components explain about 50% of the variance
int numComponents = 13;
string[] cols = featuresDF.ColumnKeys.Where((x, i) => i < numComponents).ToArray();

// First, compute distances from the center/mean among normal events
var normalDF = featuresDF.Rows[
    featuresDF["is_fraud"].Where(x => x.Value == 0).Keys
].Columns[cols];

double[][] normalData = BuildJaggedArray(
    normalDF.ToArray2D<double>(), normalDF.RowCount, cols.Length
);

首先,我们选择前十三项主成分,这些成分解释了大约 50%的方差。然后,我们创建一个非欺诈信用卡交易组normalDFnormalData,这样我们就可以使用这个子集来构建异常检测模型。

我们接下来要做的事情是开始计算马氏距离度量,以衡量数据点与非欺诈信用卡交易分布之间的距离。如果你还记得,我们在第九章,“网络攻击检测”中使用了相同的距离度量,我们建议你回顾第九章,“网络攻击检测”中的“模型构建”部分,以了解更多关于这个距离度量的详细信息。计算距离的代码如下:

double[] normalVariances = ComputeVariances(normalData);
double[] rawDistances = ComputeDistances(normalData, normalVariances);

double[] distances = rawDistances.ToArray();

double meanDistance = distances.Average();
double stdDistance = Math.Sqrt(
    distances
    .Select(x => Math.Pow(x - meanDistance, 2))
    .Sum() / distances.Length
);

Console.WriteLine(
    "* Normal - mean: {0:0.0000}, std: {1:0.0000}",
    meanDistance, stdDistance
);
ComputeVariances and ComputeDistances, to compute the variances of feature values and the distances. The following is the code for the ComputeVariances method:
private static double[] ComputeVariances(double[][] data)
{
    double[] componentVariances = new double[data[0].Length];

    for (int j = 0; j < data[0].Length; j++)
    {
        componentVariances[j] = data
            .Select((x, i) => Math.Pow(data[i][j], 2))
            .Sum() / data.Length;
    }

    return componentVariances;
}

这段代码看起来应该很熟悉,因为这和我们在第九章,网络攻击检测中使用的代码相同,用于构建网络攻击检测的 PCC 模型。此外,以下是在ComputeDistances方法中的代码:

private static double[] ComputeDistances(double[][] data, double[] componentVariances)
{

    double[] distances = data.Select(
        (row, i) => Math.Sqrt(
            row.Select(
                (x, j) => Math.Pow(x, 2) / componentVariances[j]
            ).Sum()
        )
    ).ToArray();

    return distances;
}

这段代码也应该很熟悉,因为我们同样在第九章,网络攻击检测中使用了相同的代码。使用这两个方法,我们计算了非欺诈交易数据中距离度量的平均值和标准差。输出如下:

在计算了正常交易组内的距离度量后,我们现在计算欺诈交易与非欺诈交易分布之间的距离。以下是在BuildPCAClassifier代码中计算欺诈距离的部分:

// Detection
var fraudDF = featuresDF.Rows[
    featuresDF["is_fraud"].Where(x => x.Value > 0).Keys
].Columns[cols];

double[][] fraudData = BuildJaggedArray(
    fraudDF.ToArray2D<double>(), fraudDF.RowCount, cols.Length
);
double[] fraudDistances = ComputeDistances(fraudData, normalVariances);
fraudData, which we use for distance-measuring calculations. Then, using the ComputeDistances function that we wrote, we can compute the distances between the fraudulent credit card transactions and the distribution of non-fraudulent transactions. With these distances measures, we then start analyzing the fraud detection rates for each of the target false-alarm rates. Take a look at the following code snippet:
// 5-10% false alarm rate
for (int i = 0; i < 4; i++)
{
    double targetFalseAlarmRate = 0.05 * (i + 1);
    double threshold = Accord.Statistics.Measures.Quantile(
        distances,
        1 - targetFalseAlarmRate
    );

    int[] detected = fraudDistances.Select(x => x > threshold ? 1 : 0).ToArray();

    Console.WriteLine("\n\n---- {0:0.0}% False Alarm Rate ----", targetFalseAlarmRate * 100.0);
    double overallRecall = (double)detected.Sum() / detected.Length;
    Console.WriteLine("* Overall Fraud Detection: {0:0.00}%", overallRecall * 100.0);
}
Chapter 9, *Cyber Attack Detection*. One thing that is different here, however, is the fact that we only have two target classes (fraud versus non-fraud), whereas we had five target classes (normal versus four different types of cyber attack) in Chapter 9, *Cyber Attack Detection*. As you can see from this code, we experiment with five different target false alarm rates from 5% to 10%, and analyze the fraud detection rates for the given target false alarm rate. We will take a deeper look at this code in the following model evaluation step.

单类 SVM

我们接下来要探索的信用卡欺诈检测方法是训练一个单类 SVM。单类 SVM 是 SVM 的一个特例,其中 SVM 模型首先使用数据训练,然后,当它看到新的数据点时,SVM 模型可以确定这个新数据点是否足够接近它训练过的数据。为了训练单类 SVM 模型,我们编写了一个辅助函数BuildOneClassSVM,这个函数的完整代码可以在以下仓库中找到:github.com/yoonhwang/c-sharp-machine-learning/blob/master/ch.10/Modeling.cs。让我们一步一步地分析这个辅助函数。

首先,让我们看看代码中用于子选择用于训练单类 SVM 的非欺诈信用卡交易数据的部分。代码看起来如下:

// First 13 components explain about 50% of the variance
int numComponents = 13;
string[] cols = featuresDF.ColumnKeys.Where((x, i) => i < numComponents).ToArray();

var rnd = new Random(1);
int[] trainIdx = featuresDF["is_fraud"]
    .Where(x => x.Value == 0)
    .Keys
    .OrderBy(x => rnd.Next())
    .Take(15000)
    .ToArray();
var normalDF = featuresDF.Rows[
    trainIdx
].Columns[cols];

double[][] normalData = BuildJaggedArray(
    normalDF.ToArray2D<double>(), normalDF.RowCount, cols.Length
);

与我们之前构建的 PCC 模型类似,我们使用了前十三项主成分,这些主成分解释了大约 50%的总方差。接下来,我们将从非欺诈交易样本中子选择记录来构建训练集。如您从这段代码中可以看到,我们随机选择了 15,000 个非欺诈样本作为训练集。

现在我们有了训练单类 SVM 模型的训练集,让我们看看以下代码:

var teacher = new OneclassSupportVectorLearning<Gaussian>();
var model = teacher.Learn(normalData);

我们在 Accord.NET 框架中使用OneclassSupportVectorLearning算法来训练一个单类 SVM 模型。如您所见,我们在本章中构建了一个具有Gaussian核的 SVM 模型,但您可以尝试不同的核。现在,唯一剩下的步骤就是测试我们刚刚训练的单类 SVM 模型。以下代码展示了我们如何构建测试集来评估这个模型:

int[] testIdx = featuresDF["is_fraud"]
    .Where(x => x.Value > 0)
    .Keys
    .Concat(
        featuresDF["is_fraud"]
        .Where(x => x.Value == 0 && !trainIdx.Contains(x.Key))
        .Keys
        .OrderBy(x => rnd.Next())
        .Take(5000)
        .ToArray()
    ).ToArray();

var fraudDF = featuresDF.Rows[
    testIdx
].Columns[cols];

double[][] fraudData = BuildJaggedArray(
    fraudDF.ToArray2D<double>(), fraudDF.RowCount, cols.Length
);

int[] fraudLabels = featuresDF.Rows[
    testIdx
].GetColumn<int>("is_fraud").ValuesAll.ToArray();

如您从这段代码中可以看到,我们选取了所有欺诈样本和 5,000 个随机选择的非欺诈样本作为测试集。使用这个测试集,我们将评估这个单类 SVM 模型在检测信用卡欺诈方面的表现。

在下一节中,我们将更详细地查看评估代码,但让我们先快速看一下我们如何评估刚刚训练的单类 SVM 模型。代码如下所示:

for(int j = 0; j <= 10; j++)
{
    model.Threshold = -1 + j/10.0; 

    int[] detected = new int[fraudData.Length];
    double[] probs = new double[fraudData.Length];
    for (int i = 0; i < fraudData.Length; i++)
    {
        bool isNormal = model.Decide(fraudData[i]);
        detected[i] = isNormal ? 0 : 1;
    }

    Console.WriteLine("\n\n---- One-Class SVM Results ----");
    Console.WriteLine("* Threshold: {0:0.00000}", model.Threshold);
    double correctPreds = fraudLabels
        .Select((x, i) => detected[i] == 1 && x == 1 ? 1 : 0)
        .Sum();
    double precision = correctPreds / detected.Sum();
    double overallRecall = correctPreds / fraudLabels.Sum();
    Console.WriteLine("* Overall Fraud Detection: {0:0.00}%", overallRecall * 100.0);
    Console.WriteLine("* False Alarm Rate: {0:0.00}%", (1 - precision) * 100.0);
}

如您从这段代码中可以看到,我们遍历不同的阈值值,类似于我们为之前的 PCC 模型设置不同的阈值。在代码的第三行中,您可以使用模型的Threshold属性来获取或设置确定记录是否正常的阈值。类似于我们评估 PCC,我们将查看模型验证的欺诈检测率和误报率。

我们在模型构建步骤中使用的完整代码可以在以下链接中找到:github.com/yoonhwang/c-sharp-machine-learning/edit/master/ch.10/Modeling.cs

评估异常检测模型

我们已经训练了两个异常检测模型——一个使用主成分,另一个使用单类 SVM 算法。在本节中,我们将更详细地查看性能指标和用于评估这些模型的代码。

主成分分类器

如前节简要提到的,我们将查看每个目标误报率下的信用卡欺诈检测率。评估 PCC 模型的代码如下所示:

// 5-10% false alarm rate
for (int i = 0; i < 4; i++)
{
    double targetFalseAlarmRate = 0.05 * (i + 1);
    double threshold = Accord.Statistics.Measures.Quantile(
        distances,
        1 - targetFalseAlarmRate
    );

    int[] detected = fraudDistances.Select(x => x > threshold ? 1 : 0).ToArray();

    Console.WriteLine("\n\n---- {0:0.0}% False Alarm Rate ----", targetFalseAlarmRate * 100.0);
    double overallRecall = (double)detected.Sum() / detected.Length;
    Console.WriteLine("* Overall Fraud Detection: {0:0.00}%", overallRecall * 100.0);
}

与第九章,“网络攻击检测”类似,我们遍历从 5%到 10%的目标误报率,并检查给定误报率下的检测率。使用目标误报率变量targetFalseAlarmRate,我们使用Accord.Statistics.Measures.Quantile方法计算阈值。使用这个计算出的阈值,我们将距离大于此阈值的所有记录标记为欺诈,其他为非欺诈。让我们看看评估结果。

以下是在 5%误报率下的欺诈检测率:

图片

以下是在 10%误报率下的欺诈检测率:

图片

以下是在 15%误报率下的欺诈检测率:

图片

最后,以下是在 20%误报率下的欺诈检测率:

图片

如您从这些结果中可以看到,随着我们放宽并增加目标误报率,欺诈检测率有所提高。在 5%的目标误报率下,我们只能检测到大约 59%的欺诈交易。然而,在 20%的目标误报率下,我们可以检测到超过 80%的欺诈信用卡交易。

单类 SVM

现在我们来看看单类 SVM 模型在信用卡欺诈数据集上的表现。模型评估的代码如下所示:

for(int j = 0; j <= 10; j++)
{
    model.Threshold = -1 + j/10.0; 

    int[] detected = new int[fraudData.Length];
    double[] probs = new double[fraudData.Length];
    for (int i = 0; i < fraudData.Length; i++)
    {
        bool isNormal = model.Decide(fraudData[i]);
        detected[i] = isNormal ? 0 : 1;
    }

    Console.WriteLine("\n\n---- One-Class SVM Results ----");
    Console.WriteLine("* Threshold: {0:0.00000}", model.Threshold);
    double correctPreds = fraudLabels
        .Select((x, i) => detected[i] == 1 && x == 1 ? 1 : 0)
        .Sum();
    double precision = correctPreds / detected.Sum();
    double overallRecall = correctPreds / fraudLabels.Sum();
    Console.WriteLine("* Overall Fraud Detection: {0:0.00}%", overallRecall * 100.0);
    Console.WriteLine("* False Alarm Rate: {0:0.00}%", (1 - precision) * 100.0);
}

如您从这段代码中可以看到,我们以 0.1 的增量从-1.0 迭代到 0.0 的不同阈值。您可以通过更新单类 SVM 模型对象的Threshold属性来设置模型的阈值。这个阈值将指导模型如何确定哪些记录是欺诈的,哪些不是。在决定最终模型时,您需要尝试不同的阈值值,以确定最适合您需求的最佳阈值。让我们来看看一些性能结果。

以下展示了阈值为-0.4 时的性能指标:

图片

以下展示了阈值为-0.3 时的性能指标:

图片

以下展示了阈值为-0.2 时的性能指标:

图片

最后,以下展示了阈值为-0.1 时的性能指标:

图片

如您从这些结果中可以看到,随着阈值的增加,误报率降低,但欺诈检测率也随之降低。很明显,在更高的精确度和更高的欺诈检测率之间存在权衡。在阈值为-0.4 时,模型能够检测到大约 70%的欺诈信用卡交易,误报率大约为 40%。另一方面,在阈值为-0.1 时,模型只能检测到大约 57%的欺诈信用卡交易,但误报率仅为大约 33%。

摘要

在本章中,我们为信用卡欺诈检测构建了另一个异常检测模型。我们本章开始时查看匿名信用卡欺诈数据结构,然后开始分析目标变量和特征变量的分布。当我们分析目标类别的分布时,我们注意到欺诈和非欺诈类别之间存在很大的类别不平衡。当我们面对任何类型的异常检测项目时,这种情况是正常的,其中正常类别远远超过正类别。然后,我们开始分析匿名特征的分布。由于出于保密问题对特征进行了匿名处理,我们无法从数据集中得出任何直觉。

然而,我们能够更好地理解分布情况,以及我们如何无法轻易地使用原始特征将欺诈与非欺诈区分开来。然后,我们应用 PCA 并导出 PCA 特征用于模型构建步骤。我们尝试了两种构建信用卡欺诈检测模型的方法——主成分分类器和单类 SVM。我们通过查看不同误报率下的欺诈检测率来评估这些模型的性能。很明显,在提高误报率和提高欺诈检测率之间存在权衡。

本章是关于在 C#中构建机器学习模型的最后一章。在下一章中,我们将总结到目前为止所有章节中我们所做的一切,以及构建机器学习模型时可能遇到的额外现实挑战。此外,我们还将讨论一些其他软件包,以及一些其他可用于您未来机器学习项目的数据科学技术。

第十一章:接下来是什么?

我们已经走得很远了。从构建机器学习ML)模型的基础和步骤,到实际为各种现实世界项目开发众多 ML 模型,我们已经覆盖了很多内容。在简要的介绍章节中,我们学习了 ML 的基础知识以及构建 ML 模型所必需的步骤,然后我们开始构建 ML 模型。在第二章垃圾邮件过滤和第三章推特情感分析中,我们讨论了使用文本数据集构建分类模型。在第四章外汇汇率预测和第五章房屋和财产的公允价值中,我们使用了金融和房地产数据来构建回归模型。然后在第六章客户细分中,我们介绍了如何使用聚类算法通过电子商务数据集来直观地洞察客户行为。在第七章音乐流派推荐和第八章手写数字识别中,我们将构建 ML 模型的知识扩展到使用音乐记录和手写数字图像数据构建音乐推荐和图像识别模型。在第九章网络攻击检测和第十章信用卡欺诈检测中,我们为网络攻击检测和信用卡欺诈检测构建了异常检测模型。

在本章中,我们将回顾我们构建的 ML 模型类型、我们迄今为止参与的项目,以及使用 Accod.NET 框架训练各种 ML 模型的代码片段。我们还将讨论在现实世界项目和情况中使用和应用 ML 时的一些挑战。最后,我们将介绍一些可用于未来 ML 项目的其他软件包,以及数据科学家经常使用的其他常见技术。

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

  • 对我们迄今为止所学内容的回顾

  • 构建 ML 模型中的现实挑战

  • 数据科学家使用的其他常见技术

回顾

从第一章开始,我们已经讨论和覆盖了大量内容。从讨论 ML 的基础知识到构建分类、回归和聚类模型,在结束这本书之前回顾我们所做的一切是值得的。让我们回顾一些对您未来的 C# ML 项目有帮助的基本概念和代码。

构建 ML 模型的步骤

如第一章“机器学习建模基础”中所述,对于有志于成为数据科学家和机器学习工程师的人来说,理解用于生产系统的现实世界机器学习模型的流程和方法可能具有挑战性。我们在第一章“机器学习建模基础”中详细讨论了构建机器学习模型的步骤,并且在我们迄今为止工作的每个项目中都遵循了这些步骤。以下图表应该是对构建现实世界机器学习模型的基本步骤的良好总结:

图片

正如你所知,我们总是从问题定义开始一个机器学习项目。在这一步中,我们定义我们将用机器学习解决的问题以及为什么我们需要机器学习模型来解决这些问题。这也是我们构思想法和前提条件的步骤,例如所需数据的类型,以及我们将要实验的学习算法的类型。最后,这也是我们需要明确定义项目成功标准的步骤。我们可以定义一些评估指标,不仅用于评估机器学习模型的预测性能,还用于评估模型的执行性能,特别是如果模型需要在实时系统中运行,并在给定的时间窗口内输出预测结果。

从问题定义阶段,我们进入数据收集步骤。对于本书中我们已工作的项目,我们使用了已经编译并标记的公开数据。然而,在现实世界中,数据可能一开始就不易获得。在这种情况下,我们必须想出收集数据的方法。例如,如果我们计划为网站或应用程序上的用户行为预测构建机器学习模型,那么我们可以收集网站或应用程序上的用户活动。另一方面,如果我们正在构建一个信用模型来评估潜在借款人的信用价值,那么我们很可能无法自行收集数据。在这种情况下,我们必须求助于销售信用相关数据的第三方数据供应商。

在收集了所有数据之后,接下来我们必须要做的是准备和分析数据。在数据准备步骤中,我们需要通过查看数据字段的格式、重复记录的存在或缺失值的数量来验证数据集。在检查了这些标准后,我们就可以开始分析数据,看看数据集中是否有任何明显的模式。如果你还记得,我们通常首先分析目标变量的分布,然后我们开始分析每个目标类别的特征分布,以识别任何可能将目标类别区分开来的明显模式。在数据分析步骤中,我们专注于深入了解数据中的模式,以及数据本身的结构。

从数据分析步骤中获得对数据的洞察和理解后,我们就可以开始构建用于我们的机器学习模型的特征。正如 Andrew Ng 所说,应用机器学习基本上是特征工程。这是构建机器学习模型和确定预测模型性能的最关键步骤之一。如果你还记得,我们讨论了如何使用 one-hot 编码将文本特征转换为 1s 和 0s 的编码矩阵,以解决我们的文本分类问题。我们还讨论了在构建回归模型时构建时间序列特征,如移动平均线和布林带,以及对于高度偏斜的特征使用对数变换。这个特征工程步骤是我们需要发挥创造力的地方。

一旦我们准备好了所有特征,我们就可以继续训练和测试各种学习算法。根据目标变量是连续的还是分类的,我们可以决定是构建分类模型还是回归模型。如果你还记得以前的项目,我们通过使用 k 折交叉验证或通过将数据集分成两个子集,用一组数据进行训练,用另一组保留数据进行测试来训练和测试我们的模型。直到我们找到满意的模型,我们可能需要重复之前的步骤。如果我们没有足够的数据,我们可能需要回到数据收集阶段,尝试收集更多数据以构建更精确的模型。如果我们处理重复记录或缺失值不当,我们可能需要回到数据准备步骤来清理数据。如果我们能构建更多更好的特征,那么重复特征工程步骤可以通过提高我们的机器学习模型性能来帮助。

构建机器学习模型的最后一步是将它们部署到生产系统中。到这一步,所有模型都应该已经过全面测试和验证。在部署前设置一些监控工具将是有益的,这样就可以监控模型的性能。

我们在整本书中都非常详细地遵循了这些步骤,所以当你开始着手未来的机器学习项目时,你会意识到你对这些步骤是多么的熟悉和舒适。然而,有一些关键步骤我们在这本书中并没有完全涵盖,比如数据收集和模型部署步骤,所以你应该始终牢记这些步骤的重要性和目标。

分类模型

我们在第二章中构建的第一个两个机器学习模型,垃圾邮件过滤和第三章中的Twitter 情感分析,都是分类模型。在第二章中的垃圾邮件过滤,我们构建了一个分类模型来将邮件分类为垃圾邮件和非垃圾邮件(非垃圾邮件)。在第三章中的Twitter 情感分析,我们构建了一个用于 Twitter 情感分析的分类模型,该模型将每条推文分类为三种情感之一——正面、负面和中性。分类问题在机器学习项目中很常见。构建一个模型来预测客户是否会在在线商店购买商品是一个分类问题。构建一个模型来预测借款人是否会偿还其贷款也是一个分类问题。

如果目标变量中只有两个类别,通常是正面结果和负面结果,那么我们称之为二元分类。二元分类的一个很好的例子是我们第二章中做的垃圾邮件过滤项目。如果目标变量中有超过两个类别,那么我们称之为多类或多项式分类。在第三章中的 Twitter 情感分析项目中,我们不得不将一条记录分类为三个不同的类别;这是一个多项式分类问题的良好例子。在这本书中我们还有两个更多的分类项目。如果你还记得,在我们的音乐流派推荐项目第七章中,目标变量有八个不同的流派或类别,而在我们的手写数字识别项目第八章中,目标变量有十个不同的数字。

我们对多种学习算法进行了实验,例如逻辑回归、朴素贝叶斯、支持向量机SVM)、随机森林和神经网络,用于上述分类项目。为了提醒您如何在 C#中训练这些学习算法,我们将重述如何使用 Accord.NET 框架在 C#中初始化一些学习算法。

以下代码片段展示了我们如何训练一个二元逻辑回归分类器:

var learner = new IterativeReweightedLeastSquares<LogisticRegression>()
{
    MaxIterations = 100
};
var model = learner.Learn(inputs, outputs);

对于多项式分类问题,我们使用以下代码训练了一个逻辑回归分类器:

var learner = new MultinomialLogisticLearning<GradientDescent>()
{
    MiniBatchSize = 500
};
var model = learner.Learn(inputs, outputs);

在构建朴素贝叶斯分类器时,我们使用了以下代码:

var learner = new NaiveBayesLearning<NormalDistribution>();
var model = learner.Learn(inputs, outputs);

如果您还记得,当特征具有连续变量时,我们使用了NormalDistribution,例如在音乐流派推荐项目中,所有特征都是音频频谱特征,具有连续值。另一方面,我们使用了BernoulliDistribution,其中特征只能取二元值(0 与 1)。在第三章,“Twitter 情感分析”项目中,我们拥有的所有特征只能取 0 或 1。

以下代码展示了我们如何训练一个RandomForestLearning分类器:

var learner = new RandomForestLearning()
{
    NumberOfTrees = 100,

    CoverageRatio = 0.5,

    SampleRatio = 0.7

};
var model = learner.Learn(inputs, outputs);

如您可能已经知道,我们可以调整超参数,例如随机森林中的树的数量(NumberOfTrees)、每棵树最多可以使用的变量比例(CoverageRatio)以及训练每棵树使用的样本比例(SampleRatio),以找到性能更好的随机森林模型。

我们使用以下代码来训练一个 SVM 模型:

var learner = new SequentialMinimalOptimization<Gaussian>();
var model = learner.Learn(inputs, outputs);

如果您还记得,我们可以为 SVM 使用不同的核函数。除了高斯核之外,我们还可以使用线性多项式核。根据您拥有的数据集类型,一个核函数可能比其他核函数表现更好,因此应该尝试各种核函数以找到最佳性能的 SVM 模型。

最后,我们可以使用以下代码来训练一个神经网络:

var network = new ActivationNetwork(
    new BipolarSigmoidFunction(2), 
    91, 
    20,
    10
);

var teacher = new LevenbergMarquardtLearning(network);

Console.WriteLine("\n-- Training Neural Network");
int numEpoch = 10;
double error = Double.PositiveInfinity;
for (int i = 0; i < numEpoch; i++)
{
    error = teacher.RunEpoch(trainInput, outputs);
    Console.WriteLine("* Epoch {0} - error: {1:0.0000}", i + 1, error);
}

如您可能从第八章,“手写数字识别”中回忆起的那样,我们通过多次(多个 epoch)运行数据集来训练一个神经网络模型。在每次迭代或 epoch 之后,我们注意到错误率下降,因为神经网络从数据集中学习得越来越多。我们还注意到,在每个 epoch 中,错误率提高的速率是递减的,所以经过足够的 epochs 后,神经网络模型的性能不会有显著提升。

您可以在以下链接查看代码示例:github.com/yoonhwang/c-sharp-machine-learning/blob/master/ch.11/ClassificationModelReview.cs

回归模型

我们还开发了多个回归机器学习模型。在第四章 外汇汇率预测中,我们进行了外汇汇率预测项目,构建了可以预测欧元和美元之间未来汇率的模型。在第五章 房屋和财产公允价值中,我们训练了不同的机器学习模型,可以预测房屋和财产公允价值项目的房价。回归问题在现实世界的机器学习项目中也很常见。构建一个预测客户终身价值的模型是一个回归问题。构建一个预测潜在借款人可以借到的最大金额而不破产的模型是另一个回归问题。

在本书中,我们探讨了回归项目中多种机器学习算法。我们在第四章 外汇汇率预测中尝试了线性回归和线性 SVM 模型。我们还在第五章 房屋和财产公允价值中尝试了 SVM 模型的不同核,例如多项式核和高斯核。为了提醒您如何在 C# 中训练这些回归模型,我们将重述如何使用 C# 和 Accord.NET 框架构建这些模型。

以下代码片段展示了我们如何训练线性回归模型:

var learner = new OrdinaryLeastSquares()
{
    UseIntercept = true
};
var model = learner.Learn(inputs, outputs);

当使用线性核构建 SVM 时,我们使用了以下代码:

var learner = new LinearRegressionNewtonMethod()
{
    Epsilon = 2.1,
    Tolerance = 1e-5,
    UseComplexityHeuristic = true
};
var model = learner.Learn(inputs, outputs);

如您可能记得的,EpsilonToleranceUseComplexityHeuristic 是可以进一步调整以获得更好模型性能的超参数。在构建 SVM 模型时,我们建议您尝试各种超参数组合,以找到最适合您业务案例的最佳性能模型。

当我们想要为 SVM 使用多项式核时,可以使用以下代码:

var learner = new FanChenLinSupportVectorRegression<Polynomial>()
{
    Kernel = new Polynomial(3)
};
var model = learner.Learn(inputs, outputs);

对于多项式核,您可以调整多项式函数的次数。例如,对于二次多项式(二次)核,您可以使用 new Polynomial(2) 初始化核。同样,对于四次多项式核,您可以使用 new Polynomial(4) 初始化核。然而,增加核的复杂性可能导致过拟合,因此在使用高次数多项式核时需要小心。

当我们想要使用高斯核构建 SVM 时,可以使用以下代码:

var learner = new FanChenLinSupportVectorRegression<Gaussian>()
{
    Kernel = new Gaussian()
};
var model = learner.Learn(inputs, outputs);

您可以在以下链接找到上述回归模型的代码示例:github.com/yoonhwang/c-sharp-machine-learning/blob/master/ch.11/RegressionModelReview.cs

聚类算法

我们讨论了一种无监督学习算法,k-means 聚类,以及它是如何从未标记的数据集中提取洞察力的。在第六章“客户细分”中,我们使用 k-means 聚类算法对电子商务数据集进行了分析,并从数据集中了解了不同的客户行为。我们已经介绍了如何根据购买历史使用聚类算法构建不同的客户细分,但聚类算法还有许多其他应用。例如,聚类算法也可以用于图像分析,例如将图像划分为子区域,以及在生物信息学中,如发现紧密相关的基因组(基因聚类)。

我们使用以下代码使用 C#和 Accord.NET 框架构建了一个 k-means 聚类算法:

KMeans kmeans = new KMeans(numClusters);
KMeansClusterCollection clusters = kmeans.Learn(sampleSet);

如您所回忆的那样,我们需要向KMeans类提供我们想要构建的聚类数量。我们讨论的一种程序化决定最佳聚类数量的方法是通过查看轮廓分数,该分数衡量数据点与其自身聚类的相似程度。使用这个轮廓分数,您可以遍历不同的聚类数量,然后决定哪一个最适合给定的数据集。

您可以在以下链接找到 k-means 聚类算法的代码示例:github.com/yoonhwang/c-sharp-machine-learning/blob/master/ch.11/ClusteringAlgorithmReview.cs

现实生活中的挑战

如果我们能为所有业务问题构建机器学习模型那将非常棒。然而,通常并非如此。在到达模型开发阶段时,往往比实际构建工作模型有更多的挑战。当我们从事机器学习项目时,我们将讨论以下经常出现的数据科学挑战:

  • 数据问题

  • 基础设施问题

  • 可解释性与准确性

数据问题

拥有正确的数据和足够的数据是构建一个有效的机器学习模型最重要的先决条件。然而,这通常是开发机器学习模型中最困难的部分,原因有很多。我们将讨论许多数据科学家在数据相关问题方面面临的几个常见挑战。

首先,所需的数据可能根本不存在。例如,想象一家新成立的在线零售店想要应用机器学习来理解或预测其客户的消费模式。由于他们是一家新业务,客户基础小,历史购买数据不多,他们将没有足够的数据供数据科学家使用。在这种情况下,他们唯一能做的就是等待更好的时机开始机器学习项目,即使他们团队中有数据科学家。他们的数据科学家将无法用有限的数据构建出有意义的模型。

其次,数据集存在,但无法访问。这类问题在大公司中经常发生。由于安全问题,访问数据可能仅限于组织的某些子组。在这种情况下,数据科学家可能需要通过不同部门或商业实体的多个级别审批,或者他们可能需要构建一个独立的数据管道,通过该管道他们可以摄取所需的数据。这类问题通常意味着数据科学家在开始他们想要从事的机器学习项目之前需要花费很长时间。

最后,数据是分割的或过于杂乱。几乎所有情况下,数据科学家获得的原始数据集都包含杂乱的数据,并来自不同的数据源。数据中可能有太多的缺失值或重复记录,数据科学家将不得不花费大量时间清理原始数据集。数据可能过于非结构化。这种情况通常发生在处理大量文本数据集时。在这种情况下,您可能需要应用各种文本挖掘和自然语言处理NLP)技术来清理数据,使其可用于构建机器学习模型。

基础设施问题

在大型数据集上训练机器学习模型需要大量的内存和 CPU 资源。随着数据的规模越来越大,遇到基础设施问题几乎是不可避免的。如果您没有足够的内存资源来训练机器学习模型,您可能会在训练模型数小时或数天后遇到“内存不足”异常。如果您没有足够的处理能力,那么训练一个复杂的机器学习模型可能需要数周甚至数月。获得正确的计算资源是构建机器学习模型中的一个真正挑战。随着用于机器学习的数据增长速度比以往任何时候都快,所需的计算资源量也逐年显著增加。

随着云计算服务提供商,如 AWS、谷歌和微软 Azure 的日益流行,获取所需的计算资源变得更加容易。在任何这些云计算平台上,您都可以轻松地请求和使用所需的内存和 CPU 数量。然而,任何事物都有代价,在这些云平台上运行机器学习任务可能会花费大量金钱。根据您的预算,这些成本可能会限制您为机器学习任务可用的计算资源量,因此需要巧妙地规划。

可解释性与准确性

机器学习领域最后一个常见的现实挑战是机器学习模型的可解释性和准确率之间的权衡。更传统和线性的模型,如逻辑回归和线性回归模型,在预测输出的解释上比较容易。我们可以提取这些线性模型的截距和系数,并通过简单的算术运算得到预测输出。然而,更复杂的模型,如随机森林和 SVM,在解释预测输出方面更难使用。与逻辑回归或线性回归模型不同,我们不能通过简单的算术运算推导出预测输出。这些复杂模型更像是一个黑盒。我们知道输入和输出,但中间的过程对我们来说是一个黑盒。

当用户或审计员要求对模型行为进行解释时,这种复杂学习算法的可解释性问题成为一个问题。如果存在对可解释性的这种要求,我们可能不得不求助于更传统的线性模型,即使更复杂的模型在性能上优于这些线性模型。

其他常见技术

随着机器学习和数据科学领域的发展速度比以往任何时候都要快,正在建设的新的技术数量也在以极快的速度增长。有许多资源和工具可以帮助我们更轻松、更快速地构建机器学习解决方案和应用。我们将讨论一些我们推荐您熟悉的技术和工具,以便您在未来的机器学习项目中使用。

其他机器学习库

我们在这本书中使用的 Accord.NET 框架是机器学习中最常用且文档最完善的框架之一。然而,其他为 C#编写的机器学习库也值得提及并关注您未来的机器学习项目。

Encog是一个可以在 Java 和 C#中使用的机器学习框架。在某种程度上,它与我们所使用的 Accord.NET 框架非常相似,因为它在框架内提供了广泛的机器学习算法。这个框架有很好的文档,并且有很多示例代码可以参考,用于您的未来机器学习项目。关于Encog框架的更多信息可以在以下链接找到:www.heatonresearch.com/encog/

Weka 是另一个机器学习框架,但它在意义上与 Accord.NET 框架不同,因为 Weka 框架是专门为数据挖掘而设计的。它被许多研究人员广泛使用,并且有良好的文档,甚至有一本书解释了如何使用 Weka 进行数据挖掘。Weka 是用 Java 编写的,但它也可以用于 C#。有关 Weka 框架的更多信息可以在以下链接中找到:www.cs.waikato.ac.nz/~ml/index.html。你还可以在以下链接中找到有关如何在 C# 中使用 Weka 框架的信息:weka.wikispaces.com/Use%20WEKA%20with%20the%20Microsoft%20.NET%20Framework

最后,你可以在 NuGet 中搜索任何其他用于 C# 的机器学习框架。任何在 NuGet 上可用的库或包都可以轻松下载并在你的开发环境中引用。搜索以下链接以查找你可能需要的或可能对你的未来机器学习项目有帮助的任何包是一个好习惯:www.nuget.org/

数据可视化库和工具

我们接下来要讨论的工具和包系列是关于数据可视化的。机器学习(ML)和数据可视化是数据科学中不可分割的组合。对于你构建的任何机器学习模型,你应该能够向用户或商业伙伴展示你的发现、模型性能和模型结果。此外,为了持续监控模型性能,数据可视化技术通常被用来识别生产系统中模型的任何问题或模型性能的任何潜在下降。因此,许多数据可视化库被构建出来以简化数据可视化任务。

LiveCharts 是一个用于数据可视化的 .NET 库。在这本书中,我们一直使用 Accord.NET 框架的图表库,但对于更复杂的图表,我们推荐使用 LiveCharts。从基本的图表,如折线图和柱状图,到复杂的交互式图表,你可以在 C# 中相对容易地构建各种可视化。LiveCharts 库有详尽的文档和大量的示例以及示例代码。你可以在以下链接中找到有关如何使用 LiveCharts 进行数据可视化的更多信息:lvcharts.net/

除了用于数据可视化任务的 C#.NET 库之外,在数据科学社区中还有两个经常使用的数据可视化工具:D3.jsTableauD3.js是一个用于在网页上构建和展示图表的 JavaScript 库。通常,这个 JavaScript 库被用来创建各种数据科学和数据可视化任务的仪表板。Tableau是一个商业智能工具,您可以使用它拖放创建各种可视化。这个工具不仅被数据科学家使用,还被非数据专业人士使用来创建仪表板。有关D3.js库的更多信息,您可以点击此链接:d3js.org/。有关 Tableau 的更多信息,您可以点击此链接:www.tableau.com/

数据处理技术

最后,我们将讨论一些常用的数据处理技术和工具。在这本书中,我们主要使用 CSV 文件作为机器学习建模项目的输入。我们使用了 Deedle 框架来加载数据、操作和汇总数据。然而,机器学习项目的输入数据类型往往各不相同。对于某些项目,数据可能存储在 SQL 数据库中。对于其他项目,数据可能存储在分布式文件系统中。此外,输入数据的来源甚至可能是实时流服务。我们将简要讨论在这种情况下常用的几种技术,以及如何查找更详细的信息,以便您进行进一步的研究。

SQL 数据库,如 SQL Server 或 PostgreSQL,是数据存储和数据处理中最常用的技术。使用 SQL 语言,数据科学家可以轻松检索、操作和汇总数据,以处理和准备他们的机器学习项目所需的数据。作为一名有抱负的数据科学家,熟悉使用 SQL 语言来处理数据将是有益的。

数据科学社区中经常使用的一种技术是Spark,它是一个集群计算框架。使用Spark,您可以大规模处理大量数据。通过使用机器集群并将重计算分布到这些机器上,Spark有助于构建可扩展的大数据解决方案。这项技术在许多组织和公司中广泛使用,例如 Netflix、Yahoo 和 eBay,这些公司每天都要处理大量数据。

最后,对于实时机器学习应用,存在众多流处理技术。其中最受欢迎的一种是Kafka。当构建需要持续流式传输数据的实时应用或数据管道时,这项技术经常被使用。在构建实时机器学习应用的情况下,使用流处理技术,如Kafka,对于成功交付实时机器学习产品将是必不可少的。

摘要

在本章中,我们回顾了本书迄今为止所讨论的内容。我们简要地概述了构建机器学习模型的基本步骤。然后,我们总结并编写了使用 Accord.NET 框架在 C# 中构建各种机器学习模型的代码。我们还讨论了在本书中未能涵盖但你在开始你的未来机器学习项目时很可能会遇到的现实挑战。我们讨论了访问和编译数据以构建机器学习模型时的挑战,大数据可能出现的架构挑战,以及机器学习模型的可解释性和准确性之间的权衡。最后,我们介绍了一些我们推荐你为未来的机器学习项目熟悉的常用技术。本章中提到的代码库和工具只是可用工具的一个子集,常用的工具和技术将逐年演变。我们建议你持续研究即将到来的机器学习与数据科学技术。

在本书中,我们涵盖了各种机器学习技术、工具和概念。随着你从构建基本的分类和回归模型到复杂的推荐系统和图像识别系统,以及针对现实问题的异常检测模型的学习,我希望你在构建未来机器学习项目中的机器学习模型方面获得了更多的信心。我希望你在本书中的旅程是值得和有意义的,并且你学到了许多新的和有用的技能。

posted @ 2025-09-03 09:53  绝不原创的飞龙  阅读(2)  评论(0)    收藏  举报