精通--NET-机器学习-全-

精通 .NET 机器学习(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

.NET 框架是历史上最成功的应用程序框架之一。实际上,有成千上万的行代码是在 .NET 框架上编写的,还有更多的代码即将到来。尽管它取得了如此的成功,但可以争辩说,.NET 框架在数据科学领域仍然没有得到充分的体现。本书试图通过展示如何快速将机器学习注入到常见的 .NET 商业线应用程序中,来帮助解决这个问题。它还展示了如何使用 .NET 框架解决典型数据科学场景。本书快速构建在机器学习模型和技术的介绍之上,以便使用机器学习构建实际应用。虽然这并不是对预测分析的一个全面研究,但它确实解决了数据科学家在构建模型时遇到的一些更常见的问题。

许多关于机器学习的书籍都是围绕一个数据集和如何在数据集上实现模型来编写的。虽然这是一种构建心理蓝图(以及一些代码模板)的好方法,但本书将采取一种略有不同的方法。本书围绕介绍同一商业线开发的应用程序和科学程序员的一个常见公开数据集展开。然后,我们将根据业务场景介绍不同的机器学习技术。这意味着你将为每一章戴上不同的帽子。如果你是商业线软件工程师,第二章、第三章、第六章和第九章将显得很熟悉。如果你是研究分析师,第四章、第七章和第十章将对你来说非常熟悉。我鼓励你尝试所有章节,无论你的背景如何,因为你可能会获得一个新的视角,这将使你作为数据科学家更加有效。最后,值得一提的是,本书中你不会找到“简单”这个词。当我阅读基于教程的书籍,作者说“这很简单”或“只需简单地这样做”时,我会感到非常烦恼。如果它很简单,我就不需要这本书了。我希望你发现每一章都易于理解,代码示例有趣,这两个因素可以帮助你在职业生涯中立即受益。

本书涵盖内容

第一章,欢迎使用.NET 框架下的机器学习,将机器学习置于.NET 堆栈中,介绍了本书中将使用的一些库,并提供了对 F#的简要入门。

第二章, AdventureWorks 回归,介绍了本书中将使用的业务——AdventureWorks 自行车公司。然后我们将探讨一个业务问题,即客户根据产品评论取消订单。它探讨了手动创建线性回归,使用 Math.NET 和 Accord.NET 解决这个业务问题。然后,它将这个回归添加到业务应用程序中。

第三章, 更多 AdventureWorks 回归,探讨了创建多重线性回归和逻辑回归来解决 AdventureWorks 中的不同业务问题。它将探讨影响自行车销售的不同因素,并将潜在客户分类为潜在销售或潜在流失线索。然后,它将实现模型以帮助我们的网站将潜在流失线索转换为潜在销售。

第四章, 交通拦截 – 是否走错了方向?,从 AdventureWorks 的冒险中暂时休息一下。你将戴上数据科学家的帽子,使用一个开放的交通拦截数据集,看看我们是否能理解为什么有些人只得到口头警告,而另一些人则在交通拦截时得到罚单。我们将使用基本的汇总统计和决策树来帮助理解结果。

第五章, 休息时间 – 获取数据,在介绍数据集和机器学习模型后停止,专注于机器学习中最困难的部分之一——获取和清理数据。我们将探讨使用 F# 类型提供者作为一个非常强大的语言特性,它可以大大加快“数据处理”这一过程。

第六章, AdventureWorks 重塑 – k-NN 和朴素贝叶斯分类器,回到 AdventureWorks,探讨如何提高交叉销售的商业问题。我们将实现两种流行的机器学习分类模型,k-NN 和朴素贝叶斯,以查看哪个更适合解决这个问题。

第七章, 交通拦截和事故地点 – 两个数据集比一个好,回到交通拦截数据,并添加了两个其他可以用来改进预测和获得新见解的开放数据集。本章将介绍两种常见的无监督机器学习技术:k-means 和 PCA。

第八章, 特征选择和优化,在介绍新的机器学习模型方面暂时休息,转而探讨构建机器学习模型的另一个关键部分——为模型选择正确的数据,为模型准备数据,并介绍一些处理异常值和其他数据异常的常见技术。

第九章, AdventureWorks 生产 - 神经网络,回顾了 AdventureWorks,探讨了如何通过使用一种流行的机器学习技术——神经网络来提高自行车生产。

第十章, 大数据和物联网,通过探讨一个更近期的难题——如何在具有大量、可变性和速度特征的数据上构建机器学习模型来结束。然后我们将探讨物联网设备如何生成这些大数据,以及如何将这些机器学习模型部署到这些设备上,使它们能够自我学习。

你需要这本书什么

你需要在你的计算机上安装 Visual Studio 2013(任何版本)或更高版本。你也可以使用 VS Code 或 Mono Develop。本书中的示例使用 Visual Studio 2015 Update 1。

这本书面向谁

商业计算和科学计算之间的界限越来越模糊。事实上,可以争辩说,这种区别从未像过去所宣称的那样真正清晰。因此,机器学习原理和模型正在进入主流计算应用。考虑一下 Uber 应用,它显示了 Uber 司机离你有多远,以及嵌入在线零售网站如 Jet 的产品推荐。

此外,.NET 软件开发者的工作性质正在发生变化。以前,当“我们身处一个不断变化的行业”这个陈词滥调被抛出来时,它指的是语言(需要了解 JavaScript、C#和 TSql)和框架(Angular、MVC、WPF 和 EF)。现在,这个陈词滥调意味着软件开发者需要知道如何确保他们的代码是正确的(测试驱动开发),如何将他们的代码从他们的机器上传到客户的机器上(DevOps),以及如何使他们的应用程序更智能(机器学习)。

此外,推动业务开发者重新装备的相同力量也在推动研究分析师进入不熟悉的领域。以前,分析师专注于在应用程序(如 Excel、PowerBI 和 SAS)的上下文中进行数据收集、探索和可视化,以进行点时分析。分析师会从一个问题开始,抓取一些数据,构建一些模型,然后展示结果。任何类型的连续分析都是通过编写报告或重新运行模型来完成的。今天,分析师被要求筛选大量数据(物联网遥测、用户行为数据和 NoSQL 数据湖),其中问题可能事先未知。此外,一旦创建了模型,它们就会被推入生产应用程序中,并在实时中不断重新训练。研究不再仅仅是人类的决策辅助,计算机正在执行研究以立即影响用户。

新兴的数据科学家头衔正处于这些力量的交汇点。通常,没有人能在分界线的两边都是专家,因此数据科学家是一个“万事通,样样稀松”的角色,他对机器学习的了解比团队中的其他软件工程师略胜一筹,而他对软件工程的了解则比团队中的任何研究人员都要好。本书的目标是帮助从软件工程师或业务分析师过渡到数据科学家。

习惯用法

在这本书中,你会找到许多不同的文本样式,用以区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。

文本中的代码词汇、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称将如下所示:“然后,将Script1.fsx文件添加到项目中。”

代码块将如下设置:

let multipliedAndIsEven = 
    ints
    |> Array.map (fun i -> multiplyByTwo i)
    |> Array.map (fun i -> isEven i)

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

val multipliedAndIsEven : string [] =
 [|"even"; "even"; "even"; "even"; "even"; "even"|]

新术语重要词汇会以粗体显示。屏幕上显示的词汇,例如在菜单或对话框中,会在文本中这样显示:“当添加新项目对话框出现时,选择脚本文件。”

注意

警告或重要提示会以这样的框中出现。

小贴士

小贴士和技巧如下所示。

读者反馈

我们欢迎读者的反馈。请告诉我们您对这本书的看法——您喜欢或不喜欢的地方。读者反馈对我们来说很重要,因为它帮助我们开发出您真正能从中受益的标题。

要发送给我们一般性的反馈,只需发送电子邮件至<feedback@packtpub.com>,并在邮件的主题中提及本书的标题。

如果您在某个主题上具有专业知识,并且您有兴趣撰写或为书籍做出贡献,请参阅我们的作者指南www.packtpub.com/authors

客户支持

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

下载示例代码

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

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

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

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

  3. 点击代码下载 & 勘误

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

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

  6. 从下拉菜单中选择您购买此书籍的地方。

  7. 点击代码下载

一旦文件下载完成,请确保使用最新版本的以下工具解压或提取文件夹:

  • 适用于 Windows 的 WinRAR / 7-Zip

  • 适用于 Mac 的 Zipeg / iZip / UnRarX

  • 适用于 Linux 的 7-Zip / PeaZip

勘误

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

要查看之前提交的勘误,请访问www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在勘误部分下。

侵权

在互联网上,版权材料的侵权是一个持续存在的问题,涉及所有媒体。在 Packt,我们非常重视保护我们的版权和许可证。如果您在互联网上发现任何形式的非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。

请通过copyright@packtpub.com与我们联系,并提供疑似侵权材料的链接。

我们感谢您的帮助,保护我们的作者和提供有价值内容的能力。

问题

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

第一章。欢迎使用.NET 框架的机器学习

这是一本关于创建和使用机器学习ML)程序的书,这些程序使用.NET 框架。机器学习,现在是热门话题,是软件行业分析趋势的一部分,该趋势试图使机器更聪明。虽然分析并不是一个新趋势,但它的可见性可能比过去更高。本章将重点介绍一些关于使用.NET 框架进行机器学习可能存在的较大问题,即:什么是机器学习?为什么我们应该在.NET 框架中考虑它?我如何开始编码?

什么是机器学习?

如果你查看维基百科,你会找到一个相当抽象的机器学习定义:

“机器学习探索了算法的研究和构建,这些算法可以从数据中学习并做出预测。这些算法通过从示例输入中构建模型来操作,以便进行数据驱动的预测或决策,而不是严格遵循静态程序指令。”

我喜欢将机器学习想象成计算机程序,它们在接触到更多信息时会产生不同的结果,而无需更改它们的源代码(因此需要重新部署)。例如,考虑我玩的一个与计算机的游戏。

我向计算机展示这张图片 什么是机器学习? 并告诉它“蓝色圆圈”。然后我向它展示这张图片 什么是机器学习? 并告诉它“红色圆圈”。接下来,我向它展示这张图片 什么是机器学习? 并说“绿色三角形。”

最后,我向它展示这张图片 什么是机器学习? 并问它“这是什么?”理想情况下,计算机会回答,“绿色圆圈。”

这就是机器学习的一个例子。尽管我没有更改我的代码或重新编译和重新部署,但计算机程序可以准确地响应它以前从未见过的数据。此外,计算机代码不必明确编写每个可能的数据排列。相反,我们创建计算机应用于新数据的模型。有时计算机是对的,有时它是错的。然后我们将新数据喂给计算机以重新训练模型,这样计算机随着时间的推移变得越来越准确——至少,这是目标。

一旦你决定将一些机器学习集成到你的代码库中,在过程中还需要做出另一个决定。你希望计算机多久学习一次?例如,如果你手动创建一个模型,你多久更新一次?每次有新的数据行?每月?每年?根据你试图实现的目标,你可能会创建实时 ML 模型、近实时模型或周期性模型。我们将在本书的几个章节中讨论这些模型的含义和实现,因为不同的模型适合不同的重新训练策略。

为什么是.NET?

如果你是一名 Windows 开发者,使用 .NET 是你无需思考就能做的事情。事实上,在过去 15 年中编写的绝大多数 Windows 商业应用程序都使用托管代码——其中大部分是用 C# 编写的。尽管很难对数百万软件开发者进行分类,但可以说,.NET 开发者通常来自非传统背景。也许一个开发者是从 BCSC 学位转向 .NET 的,但同样有可能他/她是从 Excel 中的 VBA 脚本开始,然后发展到 Access 应用程序,最后进入 VB.NET/C# 应用程序。因此,大多数 .NET 开发者可能熟悉 C#/VB.NET,并以命令式和可能面向对象的方式编写代码。

这种相当狭窄的接触问题在于,大多数机器学习课程、书籍和代码示例都是用 R 或 Python 编写的,并且非常使用函数式编程风格来编写代码。因此,在获取机器学习技能之前,.NET 开发者需要学习新的开发环境、新的语言和新的编码风格,这使得他们在学习编写第一行机器学习代码时处于不利地位。

然而,如果同样的开发者能够使用他们熟悉的 IDE(Visual Studio)和相同的基库(.NET 框架),他们可以更早地专注于学习机器学习。此外,在 .NET 中创建机器学习模型时,他们可以立即产生影响,因为你可以直接将代码滑入现有的 C#/VB.NET 解决方案中。

另一方面,.NET 在数据科学社区中的代表性不足。关于这一点,有几个不同的原因在流传。第一个原因是,从历史上看,微软是一个专有封闭系统,而学术界则拥抱开源系统,如 Linux 和 Java。第二个原因是,许多学术研究使用特定领域的语言,如 R,而微软则将 .NET 专注于通用编程语言。转移到工业界的研究将他们的语言也带走了。然而,随着研究人员的角色从数据科学转向构建客户可以实时接触的程序,研究人员越来越多地接触到 Windows 和 Windows 开发。无论你是否喜欢,所有创建面向客户软件的公司都必须有 Windows 策略、iOS 策略和 Android 策略。

在 .NET 中编写和部署机器学习代码的一个真正优势是,你可以一站式购物获得所有东西。我知道有几家大型公司用 R 编写他们的模型,然后让另一个团队用 Python 或 C++ 重新编写它们以进行部署。他们还可能在 Python 中编写模型,然后将其重写为 C# 以在 Windows 设备上部署。显然,如果你能够在单一语言堆栈中编写和部署,那么在效率和上市速度方面将有很大的机会。

我们使用的是哪个版本的 .NET 框架?

.NET 框架自 2002 年以来就已经上市。框架的基础是公共语言运行时(CLR)。CLR 是一个虚拟机,它抽象了大部分操作系统特定的功能,如内存管理和异常处理。CLR 在很大程度上基于 Java 虚拟机 (JVM)。位于 CLR 之上的是 框架类库 (FCL),它允许不同的语言与 CLR 和彼此进行交互:FCL 是允许 VB.Net、C#、F# 和 Iron Python 代码能够相互并排工作的原因。

自从首次发布以来,.NET 框架已经包含了越来越多的功能。首个版本支持了主要的平台库,如 WinForms、ASP.NET 和 ADO.NET。随后的版本引入了诸如 Windows Communication Foundation (WCF)、Language Integrated Query (LINQ) 和 Task Parallel Library (TPL) 等功能。在撰写本文时,.NET 框架的最新版本是 4.6.2。

除了完整的 .NET 框架之外,多年来微软还发布了针对硬件和操作系统支持有限的机器的精简版 .NET 框架。其中最著名的是 可移植类库 (PCL),它针对的是运行 Windows 8 的 Windows RT 应用程序。这一最新版本是 通用 Windows 应用程序 (UWA),针对 Windows 10。

在 2015 年 11 月的 Connect() 大会上,微软宣布了 .NET 框架最新版本的 GA。这个版本引入了 .Net Core 5。到了 1 月,他们决定将其重命名为 .Net Core 1.0。.NET Core 1.0 的目的是成为一个精简版的完整 .NET 框架,可以在多个操作系统上运行(特别是针对 OS X 和 Linux)。ASP.NET 的下一个版本(ASP.NET Core 1.0)位于 .NET Core 1.0 之上。在 Windows 上运行的 ASP.NET Core 1.0 应用程序仍然可以运行完整的 .NET 框架。

我们正在使用哪个版本的 .NET 框架?

(blogs.msdn.microsoft.com/webdev/2016/01/19/asp-net-5-is-dead-introducing-asp-net-core-1-0-and-net-core-1-0/)

在本书中,我们将使用 ASP.NET 4.0、ASP.NET 5.0 和通用 Windows 应用程序的混合。正如你所猜测的,机器学习模型(以及模型背后的理论)的变化频率远低于框架的发布,因此你在 .NET 4.6 上编写的绝大部分代码都可以与 PCL 和 .NET Core 1.0 一样良好地工作。尽管如此,我们将使用的某些外部库可能需要一些时间才能跟上——因此它们可能适用于 PCL,但可能还不适用于 .NET Core 1.0。为了使事情更现实,演示项目将使用 .NET 4.6 和 ASP.NET 4.x 来构建现有的(Brownfield)应用程序。新的(Greenfield)应用程序将是一个使用 PCL 的 UWA 和 ASP.NET 5.0 应用程序的混合体。

为什么要自己编写?

看起来所有主要的软件公司都在推广机器学习服务,例如 Google Analytics、Amazon Machine Learning Services、IBM Watson、Microsoft Cortana Analytics 等。此外,主要的软件公司经常尝试销售具有机器学习组件的产品,例如 Microsoft SQL Server Analysis Service、Oracle Database Add-In、IBM SPSS 或 SAS JMP。我没有包括一些常见的分析软件包,如 PowerBI 或 Tableau,因为它们是更数据聚合和报告编写应用程序。尽管它们可以进行分析,但它们没有机器学习组件(至少目前还没有)。

在所有这些选项中,你为什么还想学习如何在你的应用程序中实现机器学习,或者说,编写一些你可以在其他地方购买的代码呢?这是经典的“建造还是购买”决策,每个部门或公司都必须做出。你可能想自己建造,因为:

  • 你真正理解你在做什么,你可以成为任何给定机器学习包的更了解的消费者和评论家。实际上,你正在构建你公司最可能珍视的内部技能集。从另一个角度来看,公司并不是通过购买一个工具就能获得竞争优势,因为如果是这样,他们的竞争对手也可以购买相同的工具并取消任何优势。然而,公司可以通过招聘或更有可能通过组建一个团队来真正有能力在市场上区分自己。

  • 你可以通过本地执行来获得更好的性能,这对于实时机器学习尤为重要,并且可以在断开连接或网络连接缓慢的情况下实现。当我们开始在具有比网络带宽大得多的 RAM 的物联网(IoT)设备场景中实施机器学习时,这一点尤为重要。考虑一下在管道上运行 Windows 10 的树莓派。网络通信可能不稳定,但机器有足够的处理能力来实施机器学习模型。

  • 你不会依赖于任何一家供应商或公司,例如,每次你使用特定供应商实施应用程序而没有考虑如何摆脱该供应商时,你都会使自己更加依赖供应商及其不可避免的重复许可费用。下次你与拥有大量 Oracle 的商店的 CTO 交谈时,问问他们是否后悔将任何业务逻辑实施在 Oracle 数据库中。答案不会让你感到惊讶。本书的大多数代码是用 F#编写的——这是一种在 Windows、Linux 和 OS X 上运行得很好的开源语言。

  • 你可以更加敏捷,并在实现方面拥有更大的灵活性。例如,我们通常会即时重新训练我们的模型,当你自己编写代码时,这样做相对容易。如果你使用第三方服务,他们可能甚至没有 API 钩子来进行模型训练和评估,因此即时模型更改是不可能的。

一旦你决定本地化,你可以选择自己编写代码或者使用一些现成的开源组件。这本书将向你介绍这两种技术,突出每种技术的优缺点,并让你决定如何实施。例如,你可以轻松地编写自己的基本分类器,这在生产中非常有效,但某些模型,如神经网络,可能需要相当多的时间和精力,而且可能无法提供开源库所能提供的结果。最后,由于我们将要查看的库是开源的,你可以自由地定制其中的某些部分——所有者甚至可能接受你的更改。然而,我们不会在这本书中定制这些库。

为什么选择开放数据?

许多关于机器学习的书籍使用随语言安装提供的数据集(如 R 或 Hadoop)或指向数据科学社区中具有相当可见性的公共存储库。最常见的是 Kaggle(特别是泰坦尼克号竞赛)和加州大学欧文分校的数据集。虽然这些数据集很棒,并且提供了一个共同的基数,但本书将向你展示来自政府实体的数据集。从政府获取数据并为了社会公益而黑客攻击通常被称为开放数据。我相信开放数据将改变政府与公民互动的方式,并使政府实体更加高效和透明。因此,我们将在这本书中使用开放数据集,并希望你能考虑帮助开放数据运动。

为什么选择 F#?

由于我们将使用.NET Framework,我们可以使用 C#、VB.NET 或 F#。这三种语言都在微软内部得到强有力的支持,并且都将在很多年内存在。F#是这本书的最佳选择,因为它在.NET Framework 中独特地适用于科学方法和机器学习模型创建。数据科学家会感到语法和 IDE(如 R 也是函数式第一语言)非常亲切。它是.NET 商业开发者的最佳选择,因为它直接集成到 Visual Studio 中,并且与现有的 C#/VB.NET 代码兼容得很好。明显的替代方案是 C#。我能否全部用 C#完成?是的,某种程度上可以。实际上,我们将使用的许多.NET 库都是用 C#编写的。

然而,在我们的代码库中使用 C#会使它变得更大,并且有更高的可能性引入代码中的错误。在某些时候,我会用 C#展示一些示例,但本书的大部分内容都是用 F#编写的。

另一种替代方案是完全放弃 .NET,并在 R 和 Python 中开发机器学习模型。你可以启动一个网络服务(如 AzureML),在某些场景下可能很好,但在断开连接或网络环境缓慢的情况下,你会陷入困境。此外,假设机器性能相当,本地执行将比通过网络执行表现更好。当我们实现模型进行实时分析时,任何可以减少性能影响的做法都是需要考虑的。

.NET 开发者将考虑的第三种替代方案是在 T-SQL 中编写模型。事实上,我们许多初始模型都是用 T-SQL 实现的,并且是 SQL Server Analysis Server 的一部分。在数据服务器上执行的优势是计算尽可能接近数据,因此你不会因为通过网络移动大量数据而遭受延迟。使用 T-SQL 的缺点是难以轻松实现单元测试,你的领域逻辑正从应用程序转移到数据服务器(这在大多数现代应用程序架构中被认为是不良的做法),你现在依赖于数据库的特定实现。F# 是开源的,可以在各种操作系统上运行,因此你可以更轻松地移植你的代码。

准备进行机器学习

在本节中,我们将安装 Visual Studio,快速浏览 F#,并安装我们将要使用的重大开源库。

设置 Visual Studio

要开始,你需要在 Microsoft Windows 机器上下载 Visual Studio。截至本文撰写时,最新的(免费)版本是 Visual Studio 2015 Community。如果你已经在你的机器上安装了更高版本,你可以跳过此步骤。如果你需要一份副本,请访问 Visual Studio 主页 www.visualstudio.com。下载 Visual Studio Community 2015 安装程序并执行它。

现在,你将看到以下屏幕:

设置 Visual Studio

选择 自定义 安装,你将被带到以下屏幕:

设置 Visual Studio

确保 Visual F#旁边有一个勾选标记。一旦安装,你应该能在你的 Windows 开始菜单中看到 Visual Studio。

学习 F#

F# 的一个伟大特性是你可以用很少的代码完成很多事情。与 C# 和 VB.NET 相比,它是一个非常简洁的语言,因此学习语法要容易一些。尽管这不是一个全面的介绍,但它将介绍我们将在这本书中使用的语言的主要特性。我鼓励你查看 www.tryfsharp.org/fsharpforfunandprofit.com/ 上的教程,如果你想要更深入地了解这门语言。考虑到这一点,让我们创建我们的第一个 F# 项目:

  1. 启动 Visual Studio。

  2. 按照以下截图导航到文件 | 新建 | 项目学习 F#

  3. 当出现新建项目对话框时,在树视图中导航到Visual F# | Windows | 控制台应用程序。查看以下截图:学习 F#

  4. 给你的项目命名,点击确定,Visual Studio 模板生成器将创建以下样板代码:学习 F#

    虽然 Visual Studio 为我们创建了一个Program.fs文件,该文件创建了一个基本的控制台.exe应用程序,但我们将以不同的方式学习 F#,所以现在我们将忽略它。

  5. 解决方案资源管理器中右键单击,导航到添加 | 新项学习 F#

  6. 当出现添加新项对话框时,选择脚本文件学习 F#

    然后,将Script1.fsx文件添加到项目中。

    学习 F#

  7. 一旦创建Script1.fsx,打开它,并将以下内容输入到文件中:

    let x = "Hello World"
    
  8. 高亮显示整行代码,右键单击并选择在交互式环境中执行(或按Alt + Enter):学习 F#

    然后,F#交互式控制台将弹出,你会看到以下内容:

    学习 F#

F#交互式是一种 REPL,代表读取-评估-打印-循环。如果你是任何在 SQL Server Management Studio 中花费过时间的.NET 开发者,F#交互式将非常熟悉查询分析器,你在顶部输入代码,在底部看到它的执行情况。此外,如果你是使用 R Studio 的数据科学家,你对 REPL 的概念非常熟悉。我在这本书中交替使用了 REPL 和 FSI 这两个词。

有几点需要注意你写的第一行 F#代码。首先,它看起来非常类似于 C#。事实上,考虑将代码更改为以下内容:

学习 F#

这将是一个完全有效的 C#代码。请注意,红色的波浪线显示 F#编译器肯定认为这不是有效的。

回到正确的代码,注意x的类型没有明确定义。F#使用推断类型的概念,这样你就不必编写你创建的值的类型。我故意使用术语,因为与 C#和 VB.NET 中的变量不同,变量可以被分配,而值是不可变的;一旦绑定,它们就永远不会改变。在这里,我们永久地将名称x绑定到其值Hello World。这种不可变性的概念一开始可能看起来有些限制,但它具有深刻和积极的影响,尤其是在编写机器学习模型时。

在我们的基本程序想法得到验证后,让我们将其移动到一个可编译的汇编中;在这种情况下,是一个针对控制台的目标.exe。高亮显示你刚刚写的行,按Ctrl + C,然后打开Program.fs。进入生成的代码,粘贴进去:

[<EntryPoint>]
let main argv = 
    printfn "%A" argv
    let x = "Hello World"
    0 // return an integer exit code

小贴士

下载示例代码

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

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

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

  • 将鼠标指针悬停在顶部的支持标签上。

  • 点击代码下载 & 错误报告

  • 搜索框中输入书籍的名称。

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

  • 从下拉菜单中选择你购买这本书的地方。

  • 点击代码下载

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

  • Windows 系统上的 WinRAR / 7-Zip

  • Mac 系统上的 Zipeg / iZip / UnRarX

  • Linux 系统上的 7-Zip / PeaZip

然后,在你刚刚添加的内容周围添加以下代码行:

// Learn more about F# at http://fsharp.org
// See the 'F# Tutorial' project for more help.
open System

[<EntryPoint>]
let main argv = 
    printfn "%A" argv
    let x = "Hello World"
    Console.WriteLine(x)
    let y = Console.ReadKey()
    0 // return an integer exit code

按下“开始”按钮(或按F5),你应该会看到你的程序运行:

学习 F#

你会注意到我不得不将Console.ReadKey()的返回值绑定到y上。在 C#或 VB.NET 中,你可以不显式处理返回值。在 F#中,不允许忽略返回值。尽管有些人可能认为这是一个限制,但实际上这是语言的一个优点。在 F#中犯错误要难得多,因为语言强制你显式处理执行路径,而不是无意中将它们扫到地毯下(或进入空值,但我们会稍后讨论)。

无论如何,让我们回到我们的脚本文件并输入另一行代码:

let ints = [|1;2;3;4;5;6|]

如果你将这一行代码发送到 REPL,你应该会看到以下内容:

val ints : int [] = [|1; 2; 3; 4; 5; 6|]

这是一个数组,就像你在 C#中这样做一样:

var ints = new[] {1,2,3,4,5,6};

注意,F#中的分隔符是分号,而不是逗号。这与许多其他语言不同,包括 C#。F#中的逗号是为元组保留的,而不是用于分隔数组中的项。我们稍后会讨论元组。

现在,让我们总结一下数组中的值:

let summedValue = ints |> Array.sum

在将这一行代码发送到 REPL 的过程中,你应该会看到以下内容:

val summedValue : int = 21

有两件事情在进行。我们有|>运算符,这是一个管道前进运算符。如果你有 Linux 或 PowerShell 的经验,这应该很熟悉。然而,如果你有 C#的背景,它可能看起来不熟悉。管道前进运算符将运算符左侧(在这种情况下,ints)的值的结果推入运算符右侧的函数(在这种情况下,sum)。

另一个新的语言结构是Array.sum。数组是 F#核心库中的一个模块,它包含一系列你可以应用于你的数据的函数。函数sum,正如你可能通过检查结果所猜测的那样,是对数组中的值求和。

所以,现在,让我们添加Array类型中的另一个不同函数:

let multiplied = ints |> Array.map (fun i -> i * 2)

如果你将其发送到 REPL,你应该会看到以下内容:

val multiplied : int [] = [|2; 4; 6; 8; 10; 12|]

Array.mapArray类型中一个高阶函数的例子。它的参数是另一个函数。实际上,我们正在将一个函数传递给另一个函数。在这种情况下,我们创建了一个匿名函数,它接受一个参数i并返回i * 2。你知道它是一个匿名函数,因为它以关键字fun开头,IDE 通过将其设置为蓝色使我们更容易理解。这个匿名函数也被称为 lambda 表达式,它自.Net 3.5 以来就存在于 C#和 VB.NET 中,所以你可能之前遇到过。如果你有使用 R 的数据科学背景,你对 lambda 表达式已经很熟悉了。

回到高阶函数Array.map,你可以看到它将 lambda 函数应用于数组的每个元素,并返回一个包含新值的新数组。

学习 F#

当我们开始实现机器学习模型时,我们会大量使用Array.map(及其更通用的兄弟Seq.map),因为这是转换数据数组最佳的方式。此外,如果你注意到了描述大数据应用(如 Hadoop)时 map/reduce 的热门词汇,那么在这个上下文中,map 一词意味着完全相同的意思。最后一点是,由于 F#中的不可变性,原始数组不会被修改,相反,乘法操作绑定到一个新数组上。

让我们继续在脚本中添加几行代码:

let multiplyByTwo x =
    x * 2

如果你将其发送到 REPL,你应该看到这个:

val multiplyByTwo : x:int -> int

这两行创建了一个名为multiplyByTwo的命名函数。该函数接受一个参数x,然后返回参数乘以2的值。这与我们之前在map函数中创建的匿名函数完全相同。由于->操作符,语法可能看起来有点奇怪。你可以把它读作,“函数multiplyByTwo接受一个名为x的参数,其类型为int,并返回一个int。”

这里要注意三点。参数x被推断为int类型,因为它在函数体中被乘以另一个int。如果函数读取x * 2.0,则x将被推断为浮点数。这与 C#和 VB.NET 有显著的不同,但对于使用 R 的人来说很熟悉。另外,该函数没有返回语句,相反,任何函数的最终表达式总是作为结果返回。最后要注意的是,空白很重要,因此需要缩进。如果代码写成这样:

let multiplyByTwo(x) =
x * 2

编译器会报错:

Script1.fsx(8,1): warning FS0058: Possible incorrect indentation: this token is offside of context started at position (7:1).

由于 F#不使用花括号和分号(或结束关键字),例如 C#或 VB.NET,它需要使用某种东西来分隔代码。这种分隔是空白。由于合理使用空白是良好的编码实践,因此对于有 C#或 VB.NET 背景的人来说,这不应该非常令人惊讶。如果你有 R 或 Python 的背景,这对你来说应该很自然。

由于multiplyByTwoArray.map (fun i -> i * 2)中创建的 lambda 的函数等价物,如果我们想这样做的话,我们可以这样做:

let multiplied' = ints |> Array.map (fun i -> multiplyByTwo i)

如果你将它发送到 REPL 中,你应该会看到这个:

val multiplied' : int [] = [|2; 4; 6; 8; 10; 12|]

通常,当我们需要在代码的多个地方使用该函数时,我们会使用命名函数;而当我们只需要在特定代码行中使用该函数时,我们会使用 lambda 表达式。

有另一件需要注意的小事。当我想要创建一个代表相同概念的新值时,我使用了 tick 符号来表示乘法值。这种符号在科学界经常被使用,但如果尝试用它来表示第三或第四(乘法''')表示,可能会变得难以控制。

接下来,让我们在 REPL 中添加另一个命名函数:

let isEven x =
    match x % 2 = 0 with
    | true -> "even"
    | false -> "odd"
isEven 2
isEven 3

如果你将它发送到 REPL 中,你应该会看到这个:

val isEven : x:int -> string

这是一个名为isEven的函数,它接受一个参数x。函数体使用模式匹配语句来确定参数是奇数还是偶数。如果是奇数,则返回字符串odd;如果是偶数,则返回字符串even

这里有一个非常有趣的现象。match 语句是模式匹配的基本示例,也是 F#中最酷的特性之一。目前,你可以将 match 语句视为你可能熟悉的 R、Python、C#或 VB.NET 中的 switch 语句,但我们在后面的章节中会看到它如何变得更加强大。我可能会像这样编写条件逻辑:

let isEven' x =
    if x % 2 = 0 then "even" else "odd"

但我更喜欢使用模式匹配来进行这种条件逻辑。实际上,我将尝试在整个书中不使用if…then语句。

isEven函数编写完成后,我现在可以像这样将我的函数链接起来:

let multipliedAndIsEven = 
    ints
    |> Array.map (fun i -> multiplyByTwo i)
    |> Array.map (fun i -> isEven i)

如果你将它发送到 REPL 中,你应该会看到这个:

val multipliedAndIsEven : string [] =
 [|"even"; "even"; "even"; "even"; "even"; "even"|]

在这种情况下,第一个管道Array.map (fun i -> multiplyByTwo i)的结果被发送到下一个函数Array.map (fun i -> isEven i)。这意味着我们可能在内存中漂浮着三个数组:传入第一个管道的 ints,第一个管道的结果传递到第二个管道,以及第二个管道的结果。从你的心理模型角度来看,你可以将每个数组视为从一个函数传递到下一个函数。在这本书中,我会频繁地使用管道链,因为它是一个非常强大的结构,并且它与我们在创建和使用机器学习模型时的思维过程完美匹配。

你现在已经掌握了足够的 F#知识,可以开始运行本书中的第一个机器学习模型。随着本书的进行,我会介绍其他 F#语言特性,但这是一个良好的开端。正如你将看到的,F#确实是一种强大的语言,简单的语法可以导致非常复杂的工作。

第三方库

以下是我们将在本书后面部分介绍的一些第三方库。

Math.NET

Math.NET 是一个开源项目,它被创建用来增强(有时替换)System.Math 中可用的函数。它的主页是 www.mathdotnet.com/。我们将使用 Math.Net 的 NumericsSymbolics 命名空间来编写一些机器学习算法。Math.Net 的一个优点是它对 F# 有很强的支持。

Accord.NET

Accord.NET 是一个开源项目,它被创建用来实现许多常见的机器学习模型。它的主页是 accord-framework.net/。尽管 Accord.NET 的重点是计算机视觉和信号处理,但我们将在这本书中广泛使用 Accord.Net,因为它使得在我们的问题域中实现算法变得非常简单。

Numl

Numl 是一个开源项目,它实现了几个常见的机器学习模型作为实验。它的主页是 numl.net/。Numl 比我们将在书中使用的其他任何第三方库都要新,所以它可能不像其他那些那么全面,但在某些情况下它可以非常强大和有帮助。我们将在本书的几个章节中使用 Numl。

摘要

在本章中,我们覆盖了大量的内容。我们讨论了什么是机器学习,为什么你想要在 .NET 堆栈中学习它,如何使用 F# 来入门,并对我们将在这本书中使用的几个主要开源库进行了简要介绍。在完成所有这些准备工作后,我们准备开始探索机器学习。

在下一章中,我们将应用我们新发现的 F# 技能来创建一个简单的线性回归,看看我们是否可以帮助 AdventureWorks 提高他们的销售额。

第二章。AdventureWorks 回归

想象一下,你是一家位于华盛顿州西雅图的自行车制造公司 AdventureWorks 的商业开发者。你负责三个在单个 SQL Server 实例上运行的应用程序。这些应用程序包括:

  • 一个客户订购网站,包括直接客户销售部分和另一个批发购买部分

  • 一个桌面库存控制管理应用程序

  • 一个使用 Power BI 作为前端报告解决方案

这三个应用程序具有相似的特征:

  • 这些是数据库优先的应用程序,它们的主要角色是为数据库构建框架。

  • 它们都是.NET 应用程序,使用标准的 Microsoft 模板和框架,例如 MVC 用于网站,Entity Frameworks 用于 Web 和桌面解决方案。

有一天,你的老板叫你到她的办公室说,“我们对网站的批发商部分感到担忧。我们通过 Power BI 的一些基本图表发现,许多批发商根据产品的平均客户评价来取消订单。”

这里是我们正在查看的图表之一:

AdventureWorks 回归

显然,如果我们能阻止人们这样做,我们就能最大化销售额。我们希望最大化我们现有的代码资产,因此你的解决方案需要与现有网站集成,我们希望我们的客户能够体验到他们目前所拥有的相同的视觉和感觉。”

这是当前网页的样貌:

AdventureWorks 回归

你告诉你的老板,你将看一下,思考几天,然后提出一些想法。在你内心深处,你感到非常兴奋,因为这将让你摆脱传统的前端开发角色,进入数据科学领域。在研究了不同的机器学习技术后,你决定使用简单的回归来帮助实现这一目标。

简单线性回归

回归试图根据一组不同的数字预测一个数字。例如,想象我们有一个盒子,我们输入一个数字,另一个数字就出来了:

简单线性回归

我在框中输入数字 1,然后数字 6 就出来了。然后,我再次在框中输入另一个 1,数字 7 就出来了。我这样做了五次,得到了以下结果:

1 -> 6
1 -> 7
1 -> 6
1 -> 5
1 -> 6

在输入另一个数字之前,你认为输出会是什么?你可能猜到是 6。然而,如果我问你是否 100%确定会出来 6,你会说,“不,但很可能会是 6。”事实上,你可能会说,根据以往的经验(在五次尝试中出现了三个 6),6 有 60%的概率出现。

你在心理上所做的是一种回归。通常,线性回归是用如下公式编写的:

y = x0 + x1 + x2 + E

这里,y 是你想要预测的数字,而 x0x1x2 是可能影响 y 的某些数字。回到 AdventureWorks,y 是零售店一个月订购的自行车数量,x0 是一年中的月份,x1 是前三个月的订单,而 x2 是直接竞争对手订购的其他自行车的数量。E 是我们公式无法解释但仍影响自行车销售的所有因素——比如某个门店失去了一位关键的销售人员。如果我们知道 x0 + x1 + x2 解释了 75% 的 y,那么我们就知道 25% 的 y 无法解释。

因此,我们的目标是找到尽可能少的 x 参数,这些参数对 y 有最大的影响,然后合理地尝试让我们的网站反映预测值并影响用户以符合我们的利益。

回归类型有很多,我们将从最基础的、虽然令人惊讶地强大的一种开始——简单回归。在简单回归中,只有一个输入变量和一个输出,因此公式是 y = x0 + E。因为只有两个变量,我们可以将它们绘制在二维图上。例如,如果我们有这些数据:

简单线性回归

我们可以像这样绘制数据:

简单线性回归

我们想要用简单回归做的,是找到一条“最适合”通过所有数据点的线:

简单线性回归

在这个例子中,你可以看到这条线穿过点 1、2 和 5。如果线没有与一个给定的点相交,我们想知道线到点的距离。在这个例子中,我们想知道点 3 和 4 到虚线红线的距离。

简单线性回归

如果我们把所有虚线红线的距离加起来,然后除以图表上总点的数量,我们就有了一个很好的想法,这条线如何代表这个图表。如果我们得到图表上的一个数字,我们可以预测它将落在何处。例如,如果我们得到另一个 2,我们可以预测我们可能会得到 2。不仅如此,我们还可以对尚未看到的输入的线(斜率)进行预测。例如,如果我们输入 6,我们可以猜测它可能接近 6。

在现实世界的例子中,我们通常不会有一个单一的输入对应一个特定的数字。所以,我们可能会得到一百个 1,90% 的时间输出将是 1,5% 的时间输出将是 1.25,还有 5% 的时间输出将是 0.75。如果我们把所有的一百个 1 放到我们的散点图上,我们会看到很多点在 1(或一个非常暗的点)上,一些在 1.25 上,一些在 0.75 上。有了这个心理模型,让我们继续从头开始创建一个简单线性回归。

设置环境

打开 Visual Studio 2015 并创建一个新的 F# 库:

设置环境

一旦 Visual Studio 为您创建完项目和相关文件,请进入解决方案资源管理器,打开 Script1.fsx 并删除文件中的所有内容。现在您应该有一个空白的脚本文件,准备好编写代码。

准备测试数据

我们首先将创建一个数据集,我们可以在回归中使用它,并给出可预测的结果。创建一个如下所示的数组:

let input = [|1,1.;2,2.;3,2.25;4,4.75;5,5.|]

在这里,input 是一个元组数组。元组是一种包含数据组的无名称数据结构——通常有两个项目。这些类型不必与元组的项相同。如果您熟悉键/值对的概念,您可以将它用作元组的心理模型。唯一真正的“陷阱”是元组可以有多个项目,因此这是一个完全有效的元组:2,true,"dog",其中第一个位置是 int 类型,第二个是布尔值,第三个是字符串。

如果您突出显示我们的一行代码,并使用 Alt + Enter 将其发送到交互式环境(REPL),您将得到以下结果:

val input : (int * float) [] =
 [|(1, 1.0); (2, 2.0); (3, 2.25); (4, 4.75); (5, 5.0)|]

F# 编译器告诉我们,我们有一个包含 intfloat 类型的元组的数组。在这个例子中,我们将使用元组的第一个值作为 X,第二个值作为简单线性回归的 Y

数据集设置好之后,让我们考虑如何计算回归。一个比之前使用的更数学的定义是 y = A + Bx,其中 A 是直线的 Y 截距,B 是直线的斜率。因此,我们需要找出如何计算直线的截距和斜率。结果是,我们需要计算 xy 值的标准差以及称为 皮尔逊相关系数 的东西。让我们分别解决这些问题。

标准差

我遇到的最佳标准差解释是在 www.mathsisfun.com/data/standard-deviation.html

标准差是方差的平方根;要计算方差:

  1. 计算平均值(数字的简单平均值)。

  2. 然后,对于每个数字,减去平均值并平方结果(平方差)。

  3. 然后,计算这些平方差的平均值。

因此,结合 MathIsFun 的解释并将其应用于 F#,我们可以写出:

let variance (source:float seq) =
    let mean = Seq.average source
    let deltas = Seq.map (fun x -> pown (x-mean) 2) source
    Seq.average deltas

将其发送到交互式环境(REPL)会得到:

val variance : source:seq<float> -> float

注意到英文解释的每一行与 F# 代码之间有一一对应的关系。这不是偶然的。F# 真的非常擅长匹配您的思维过程。事实上,当我们看到英文版本中的那些词时,我们甚至抵制了使用 for…each 代码的诱惑。

这里有一些新的 F# 代码可能让人困惑。注意,当我计算平均值时,我调用了 Seq.average 函数:

 Seq.average source

因此,source 参数位于函数之后。我也可以这样写:

 source |> Seq.average

如果你已经完成了第一章,欢迎使用.NET 框架进行机器学习,你可能会看到这个。尽管风格指南主张非管道前进的方式,但在 F#社区中,关于哪种方式更符合习惯用法并没有达成共识。由于两种方式都由语言支持并且广泛使用,我会根据代码使用它们。通常,当我有一系列想法要一起推动时,我会使用管道操作符,但如果只有一个计算,我就直接调用函数。注意,我在所有三行中使用了这种 after syntax 技术:mean,deltas,和函数的返回值。

在方差处理完毕后,我们可以计算标准差:

let standardDeviation values =
     sqrt (variance values)

将其发送到 REPL,我们得到:

val standardDeviation : values:seq<float> -> float

准备好标准差后,我们可以输入我们的数字。由于我们将独立计算 XY 的标准差,让我们将元组拆分成单独的数组,并计算它们的平均值和标准差:

let x = input |> Seq.map (fun (x,y) -> float x)
let y = input |> Seq.map (fun (x,y) -> y) 

let mX = Seq.average x
let mY = Seq.average y

let sX = standardDeviation x
let sY = standardDeviation y

将其发送到 REPL,我们得到:

val x : seq<float>
val y : seq<float>
val mX : float = 3.0
val mY : float = 3.0
val sX : float = 1.414213562
val sY : float = 1.589024858

这里有一件新的事情。注意,在计算 x 时,我使用了这种语法:

Seq.map(fun (x,y) -> float x)

返回 float xfloat 是一个将 int 转换为,嗯,浮点数的函数。如果你来自 VB.NET/C#,相应的语法将是 (float)x

皮尔逊相关系数

接下来,让我们计算皮尔逊相关系数。我找到的最好的解释可以在onlinestatbook.com/2/describing_bivariate_data/calculation.html找到。你可以把创建皮尔逊相关系数想象成在一个 Excel 电子表格中填写列,然后对列总计进行一些计算。从 xy 在不同行开始创建一个网格:

皮尔逊相关系数

然后,计算 XY 的平均值:

皮尔逊相关系数

接下来,计算 xyx 是通过从 X 中减去 X 的平均值来计算的,而 y 是通过从 Y 中减去 Y 的平均值来计算的:

皮尔逊相关系数

接下来,填写 xyx**²,和 y ²

皮尔逊相关系数

在网格填写完毕后,你可以求和 xy,和

皮尔逊相关系数

最终答案是通过对 xy 列的总和(Σxy)除以 列的总和(Σx²*)和 *y²* 列的总和(Σy**²*)的乘积的平方根来计算的。所以,在我们的例子中,它将是:

10.75/ √(10 * 12.63)

我现在想用英语重复这些步骤,而不使用那个网格:

  1. 计算 X 的平均值。

  2. 计算 Y 的平均值。

  3. 计算 x

  4. 计算 y

  5. 填写 xyx**²,和 y**²

  6. y**² 的和。

  7. x**² 的和。

  8. y**² 的和。

  9. 做最终的公式。

这是我会用 F#写的样子:

let pearsonsCorrelation(a:float seq, b:float seq) =
    let mX = Seq.average a
    let mY = Seq.average b

    let x = a |> Seq.map (fun x -> x - mX)
    let y = b |> Seq.map (fun y -> y - mY)

    let xys = Seq.zip x y
    let xy = xys |> Seq.map (fun (x, y) -> x*y, x*x, y*y)
    let sxy = xy |> Seq.sumBy (fun (xy, x2, y2) -> xy)
    let sx2 = xy |> Seq.sumBy (fun (xy, x2, y2) -> x2)
    let sy2 = xy |> Seq.sumBy (fun (xy, x2, y2) -> y2)
    sxy / sqrt (sx2*sy2)

将其发送到 REPL,我们得到:

val pearsonsCorrelation : a:seq<float> * b:seq<float> -> float

再次,你可以看到公式和代码之间几乎是一对一的对应关系。有几件事情需要注意。

Seq.zip x y 是一个函数,它接受两个长度相等的序列并将它们组合成一个单一的元组。所以对于 xy 的组合:

皮尔逊相关系数

另一件需要注意的事情是,在 Seq.SumBys 中使用了一个三项元组。元组的每个项代表我们在填写的网格中的不同列:xy。尽管我通常不喜欢创建超过两项的元组,但在这个情况下我可以例外,因为我只在这些高阶函数的上下文中使用元组。因为数据结构是包含的且短暂的,所以元组是最好的选择。如果我在高阶函数之外需要这个数据结构,记录类型可能更合适。我们将在本章的后面更多地接触到记录类型。

最后要注意的是 Seq.sumBy 高阶函数。正如你所期望的,sumBy 计算事物的总和。关键是要意识到 sumBy 期望传递一个函数,而不是数据结构。如果你只想对数组中的值求和,可以使用 Seq.sum() 函数:

Seq.sum ([1;2;3])
val it : int = 6

Seq.sumBy ([1;2;3])
Does not compile

Seq.sumBy (fun i -> i) [1;2;3]
val it : int = 6

因此,要为 xy 运行皮尔逊相关系数,请在脚本中输入以下内容:

let r = pearsonsCorrelation (x,y)

将这些发送到交互式解释器(REPL)会给我们:

val r : float = 0.9567374429

线性回归

在计算了标准差和 r 之后,我们就为线性回归做好了准备:

let b = r*(sY/sX)
let A = mY - b*mX
val b : float = 1.075
val A : float = -0.225

这两个值的含义是,我们的 y 截距是 -0.22,或者非常接近原点,我们的斜率是 1.075。将它们放在同一个网格上,你可以看到预测的数字接近实际值:

线性回归

这些在之前的图表中仍然足够不同,我们用红线直接穿过 1, 2, 3, 4, 5(实线)和回归线采取略微不同的路径(虚线):

线性回归

我们将在稍后重新审视这个回归在描述我们的数据(以及做出预测)方面的好坏。在此之前,我们可以安全地说,我们有一个回归,它似乎很好地拟合了我们的数据。

现在我们有一个库可以编译来解决我们的 AdventureWorks 问题。然而,我们可能不想自己从头开始,因为这只是一个相当有限的实现。例如,当我们计算方差和标准差时,我们使用的是整个总体的方差和标准差公式。如果我们只有总体的一小部分样本,我们将使用不同的公式来实现。此外,线性回归有几个参数我们可以输入来尝试调整我们实现中的模型。正如你所猜想的,编写自己的库需要相当多的努力,而且你可能仍然无法做到正确。如果你在之前的自己动手实现练习中想知道,“有没有更简单的方法?”答案是“有。”

Math.NET

我们在 第一章 中简要介绍了 Math.Net,欢迎使用使用 .NET 框架的机器学习。在本节中,我们将将其添加到我们的项目中,看看它如何帮助我们进行简单的线性回归。在你的打开的项目解决方案资源管理器中,添加一个新的脚本文件,并将其命名为 MathDotNet.fsx

接下来,打开 NuGet 包管理器控制台(工具 | NuGet 包管理器 | 包管理器控制台):

Math.NET

在控制台中输入以下行:

PM> install-package MathNet.Numerics

你将看到包安装成功:

Math.NET

关闭包管理器控制台以及当你安装 Math.NET 时打开的 readme.txt 文件。在未来的操作中,我将假设你知道如何打开并输入命令来安装 NuGet 包。

回归尝试 1

在脚本文件中,创建我们在手写脚本中看到的相同输入,并计算 xy 的平均值:

let input = [|1,1.;2,2.;3,2.25;4,4.75;5,5.|]

let x = input |> Array.map(fun (x,y) -> float x)
let y = input |> Array.map(fun (x,y) -> y) 
let mX = Array.average x
let mY = Array.average y 

以下是将给出的输出:

val input : (int * float) [] =
 [|(1, 1.0); (2, 2.0); (3, 2.25); (4, 4.75); (5, 5.0)|]
val x : float [] = [|1.0; 2.0; 3.0; 4.0; 5.0|]
val y : float [] = [|1.0; 2.0; 2.25; 4.75; 5.0|]
val mX : float = 3.0
val mY : float = 3.0

然后,指向与 nugget 包一起安装的 Math.NET 库,并添加对其的引用:

#r "../packages/MathNet.Numerics.3.8.0/lib/net40/MathNet.Numerics.dll"
open MathNet.Numerics.Statistics

接下来,使用 Math.Net 来计算 xy 的标准差:

let sX = ArrayStatistics.StandardDeviation x
let sY = ArrayStatistics.StandardDeviation y

前面的代码语句将给出:

val sX : float = 1.58113883
val sY : float = 1.7765838

最后,使用 Math.Net 来计算 r

let r = Correlation.Pearson (x,y)

以下将是输出:

val r : float = 0.9567374429

现在,你可以计算回归:

let b = r*(sY/sX)
let A = mY - b*mX

在输出中,你将得到以下内容:

val b : float = 1.075
val A : float = -0.225

在脚本中,我想要指出一个新事物。你不得不输入:

#r "../packages/MathNet.Numerics.3.8.0/lib/net40/MathNet.Numerics.dll"
open MathNet.Numerics.Statistics

#r 代表引用,并将 FSI 指向文件系统以定位我们想要使用的程序集。FSI 在安装了非常少的库的情况下加载,所以你通常需要添加你需要的引用。注意文件路径前缀的 ".." 简写,这是一个相对定位器,它转换成解决方案位置。

open 命令告诉 FSI 打开我们在上一行中指向的 .dll 文件。这和在 C# 中的 using、在 VB.NET 中的 Imports 以及在 R 中的 library 相同。

因此,这是一个比手工计算简单线性回归的成分要简单得多的方法。但是等等,还有更多。

回归尝试 2

Math.NET 使得在不深入组件的情况下计算回归变得更加容易。在脚本中输入以下代码:

open MathNet.Numerics
let fit = Fit.Line(x,y)
let i = fst fit
let s = snd fit

你将得到以下输出:

val fit : float * float = (-0.225, 1.075)
val i : float = -0.225
val s : float = 1.075

Math.Numerics 已经通过 Fit() 函数提供了回归功能。Fit() 接收两个数组(在我们的例子中是 xy)并返回一个元组。元组的第一个元素是截距,第二个是斜率。我在这里引入的唯一新代码是 fstsnd 操作符。这些是长度为二的元组的简写表示法。在元组上调用 fst 返回第一个元素,而 snd 返回第二个。如果你在包含多于两个元素的元组上调用 fstsnd,你将得到一个类型不匹配的编译器错误。

Accord.NET

在 Math.NET 为我们做所有重活的情况下,我们有一种更好的方法来得到简单线性回归的结果。然而,我想讨论另一种方法,Accord.NET。打开 NuGet 包管理器并安装以下三个包:

  • Accord

  • Accord.Statistics

  • FSharp.Data

注意,当你安装 FSharp.Data 时,你会得到一个弹出窗口:

Accord.NET

点击启用

回归

在脚本文件中,输入以下代码行:

#r "../packages/Accord.3.0.2/lib/net40/Accord.dll"
#r "../packages/Accord.Statistics.3.0.2/lib/net40/Accord.Statistics.dll"
#r "../packages/Accord.Math.3.0.2/lib/net40/Accord.Math.dll"

open Accord
open Accord.Statistics.Models.Regression.Linear

let input = [|1,1.;2,2.;3,2.25;4,4.75;5,5.|]
let x = input |> Array.map (fun (x,y) -> float x)
let y = input |> Array.map (fun (x,y) -> y) let regression = SimpleLinearRegression()
let sse = regression.Regress(x,y)
let intercept = regression.Intercept
let slope = regression.Slope
let mse = sse/float x.Length 
let rmse = sqrt mse
let r2 = regression.CoefficientOfDetermination(x,y)

当你将此发送到 REPL 时,你会看到以下代码:

val input : (int * float) [] =
 [|(1, 1.0); (2, 2.0); (3, 2.25); (4, 4.75); (5, 5.0)|]
val x : float [] = [|1.0; 2.0; 3.0; 4.0; 5.0|]
val y : float [] = [|1.0; 2.0; 2.25; 4.75; 5.0|]
val regression : SimpleLinearRegression = y(x) = 1.075x + -0.224999999999998
val sse : float = 1.06875
val intercept : float = -0.225
val slope : float = 1.075
val mse : float = 0.21375
val rmse : float = 0.4623310502
val r2 : float = 0.9153465347

你在这里看到的是与之前完全相同的计算,只是这次公式被友好地打印出来了(我已经将它四舍五入到小数点后三位):

y(x) = 1.075x + -0.225

使用 RMSE 进行回归评估

Accord.NET 比 Math.NET 的Fit()方法更出色,因为它返回平方误差之和以及确定系数(称为 r 平方)。在这种情况下,平方误差之和是1.06875,r 平方是0.915(四舍五入到小数点后三位)。这很好,因为我们现在有了两个关键信息:

  • 一个预测模型

  • 一些方法可以帮助我们评估模型在预测方面的好坏

在机器学习中,仅仅实现一个模型并得到一些答案是不够的。我们还需要能够说话,知道我们的答案实际上有多好。平方和误差,通常称为SSE,是评估简单线性回归的常见方法。为了开始思考 SSE,我们需要知道每个y所用的两块信息——我们猜测的是什么,实际值是什么。使用我们现有的数据集:

使用 RMSE 进行回归评估

你可以看到模型是基于所有的y数据点创建的,然后 Accord.NET 回过头来检查该模型如何接近每个数据点。这些差异被平方,然后平方值被求和。目标是使平方和尽可能低。一旦我们有了 SSE,我们就可以改变我们的模型以尝试使平方和更低。例如,如果我们把斜率从1.075x改为1.000x,这是我们之前用眼睛估计的?

使用 RMSE 进行回归评估

由于我们已经有用于初始模型计算的五个数据点,因此通过手动更改这种方式来改进模型是不可能的。原始回归是描述这五个数据点之间关系的最佳方式。重要的是要注意,SSE 是一个无上下文测度。这意味着 1.069 本身并没有任何价值。我们只知道 1.069 比 1.367 好。基本上,我们希望 SSE 尽可能低。

SSE 的一个稍微更好的变体是均方误差MSE)。MSE 是 SSE 除以回归的观测数:

使用 RMSE 进行回归评估

在这种情况下,均方误差(MSE)是0.2138。像 MSE 一样,这个数字本身并不特别有用。然而,如果我们取 MSE 的平方根,通常称为均方根误差(RMSE),结果是一个与原始数字相同单位的误差度量。

RMSE = Square Root of MSE = sqrt(.2137) = .462

在我们的案例中,RMSE 是0.462,这意味着任何给定的猜测都可能偏离 0.46。当你下次在鸡尾酒会上与其他数据科学家交谈时(你真的会与数据科学家参加鸡尾酒会,不是吗?),你通常会使用 RMSE 来评估简单线性模型的预测能力。

使用 RMSE,我们现在有一个度量,可以衡量我们的模型在预测值时的准确性。我们还有一个第二个度量,称为 r2,它计算我们的模型有多少相关性。r2 将 r(在这种情况下,皮尔逊相关)平方。r2 总是在零和一之间,零表示xy之间没有相关性,一表示回归线完美地拟合数据。在实践中,我们希望尽可能低的 RMSE 和尽可能高的 r2。

回归与现实世界

到目前为止,我们实际上并没有进行任何机器学习,因为我们无法使我们的模型变得更好。最初的回归是最好的,解释了 91.5%的数据。然而,世界并不总是以这种方式运作。

挑战在于我们将开始在一个代表人类活动(在我们的案例中,是 AdventureWorks 的销售数据)的数据集上应用简单的线性回归,而人类活动充满了不确定性。考虑一个更现实的数据框,其中包含产品、其列表价格和其客户评价:

回归与现实世界

注意到评分似乎有一些很大的差异。一些客户给自行车打了 5 分,而另一些客户只给了 1 分或 2 分。你会认为对于同一产品,平均评价应该是相当相似的。也许我们有一个制造质量的问题,或者也许价格如此之高,低端客户期望从他们认为是昂贵的自行车中获得更多,而高端客户对从他们认为是低成本自行车中获得的价值感到非常满意。现在我们可以开始构建我们的模型了吗?是的!让我们从 AdventureWorks 的数据开始,看看它如何与使用 Accord.NET 的初始模型相匹配。

对实际数据的回归

由于这是我们第一次使用 AdventureWorks,我们需要处理一些日常维护事项。我们将使用位于 msftdbprodsamples.codeplex.com/releases/view/125550 的 AdventureWorks 2014 完整数据库。如果你想将数据本地化,你可以通过从他们的网站恢复 .bak 文件来实现。如果你选择这条路,请注意,我为本章的 Production.ProductReview 表添加了一些额外的数据。在安装数据库后,你需要运行本章 GitHub 存储库中找到的 populateProductReview.sql 脚本来匹配书中的示例。此外,你将需要生成你自己的连接字符串。如果你只想在我们的服务器上使用数据,你可以使用即将到来的代码示例中的连接字符串。

你可能会想,我把连接字符串这样公开在公共领域,我一定是疯了。首先,不要告诉任何人你有它。其次,如果幸运的话,成千上万的人购买这本书,他们都会对这个服务器进行操作以执行示例,我会很高兴为微软支付更多的$$来获取计算时间。

在 Visual Studio 中,向你的项目添加一个新的脚本,并将其命名为 AccordDotNet2.fsx。然后,添加以下引用并打开脚本文件:

#r "System.Transactions.dll"
#r "../packages/Accord.3.0.2/lib/net40/Accord.dll"
#r "../packages/Accord.Statistics.3.0.2/lib/net40/Accord.Statistics.dll"
#r "../packages/Accord.Math.3.0.2/lib/net40/Accord.Math.dll"
#r "../packages/FSharp.Data.2.2.5/lib/net40/FSharp.Data.dll"

open Accord
open Accord.Statistics
open Accord.Statistics.Models.Regression.Linear

open System
open System.Data.SqlClient

接下来,添加一个记录类型、该记录类型的列表、一个连接字符串和一个查询:

type ProductReview = {ProductID:int; TotalOrders:float; AvgReviews:float}

let reviews = ResizeArray<ProductReview>()

[<Literal>]
let connectionString = "data source=nc54a9m5kk.database.windows.net;initial catalog=AdventureWorks2014;user id=chickenskills@nc54a9m5kk;password=sk1lzm@tter;"

[<Literal>]
let query = "Select 
                A.ProductID, TotalOrders, AvgReviews
                From
                (Select 
                ProductID,
                Sum(OrderQty) as TotalOrders
                from [Sales].[SalesOrderDetail] as SOD
                inner join [Sales].[SalesOrderHeader] as SOH
                on SOD.SalesOrderID = SOH.SalesOrderID
                inner join [Sales].[Customer] as C
                on SOH.CustomerID = C.CustomerID
                Where C.StoreID is not null
                Group By ProductID) as A
                Inner Join 
                (Select
                ProductID,
                (Sum(Rating) + 0.0) / (Count(ProductID) + 0.0) as AvgReviews
                from [Production].[ProductReview] as PR
                Group By ProductID) as B
                on A.ProductID = B.ProductID"

这里有三个新的语言特性。第一个是一个名为 ProductReview 的记录类型。记录类型是不可变的命名数据结构,与无名的元组形成对比。你可以将记录类型想象成在 VB.NET/C# 世界中可能遇到的不可变的 DTO/POCO。ProductReview 有三个成员:ProductIdTotalOrdersAvgReviews。你可以将这些成员想象成 C#/VB.NET 世界中 POCO 的属性。

第二个新的语言特性是添加到 connectionString 和查询值上的属性。大多数 .NET 开发者对属性都很熟悉,所以你应该会舒服地使用它们。通过将 connectionString 和查询字面量,我可以将它们传递到脚本文件中的类型提供者。

最后,我们将使用 ResizeArray 数据类型来保持我们的产品评论 seq。因为数组在 F# 中是不可变的,而我们不知道将从数据库中获取多少条评论,所以我们需要使用一个允许调整大小的特殊数组。这相当于你在 C#/VB.NET 代码中可能熟悉的 System.Collections.Generic.List<>

接下来,添加一些 ADO.Net 代码从数据库中提取数据并将其放入列表中:

let connection = new SqlConnection(connectionString)
let command = new SqlCommand(query,connection)
connection.Open()
let reader = command.ExecuteReader()
while reader.Read() do
    reviews.Add({ProductID=reader.GetInt32(0);TotalOrders=(float)(reader.GetInt32(1));AvgReviews=(float)(reader.GetDecimal(2))})

这段代码对大多数 .Net 开发者来说应该是熟悉的。将其发送到 REPL,我们可以看到:

type ProductReview =
 {ProductID: int;
 TotalOrders: float;
 AvgReviews: float;}
val reviews : System.Collections.Generic.List<ProductReview>
val connectionString : string =
 "data source=nc54a9m5kk.database.windows.net;initial catalog=A"+[72 chars]
val query : string =
 "Select 
 A.ProductID, AvgOrders, AvgReviews
 "+[814 chars]
val connection : System.Data.SqlClient.SqlConnection =
 System.Data.SqlClient.SqlConnection
val command : System.Data.SqlClient.SqlCommand =
 System.Data.SqlClient.SqlCommand
val reader : System.Data.SqlClient.SqlDataReader
val it : unit = ()

随着数据的下降,让我们看看我们的模型是否反映了经理在“power bi”图表中注意到的:

let x = reviews |> Seq.map (fun pr -> pr.AvgReviews) |> Seq.toArray
let y = reviews |> Seq.map (fun pr -> pr.TotalOrders) |> Seq.toArray
let regression = SimpleLinearRegression()
let sse = regression.Regress(x,y)
let mse = sse/float x.Length 
let rmse = sqrt mse
let r2 = regression.CoefficientOfDetermination(x,y)

你将看到以下内容:

val regression : SimpleLinearRegression =
 y(x) = 1277.89025884053x + -4092.62506538369
val sse : float = 39480886.74
val mse : float = 203509.7254
val rmse : float = 451.1205221
val r2 : float = 0.2923784167

我们现在看到 0.29 r2451 rmse,这表明客户评价和订单数量之间存在弱关系,并且存在 450 个订单的误差范围。

另一点是,简单的线性回归往往会对异常值有问题。我们将在下一章详细讨论这个话题。此外,通过一次性的分析,我们遇到了过拟合的大问题。我们将在第八章中广泛讨论过拟合问题,特征选择和优化。目前,我只是想承认,尽管我们有一个相当不错的模型,但它远非完美。然而,它仍然比仅凭肉眼观察图表要好,并且它确实具有一定的统计有效性。我们现在有一个模型,可以预测一些销售。我们如何将这个模型投入生产?

AdventureWorks 应用程序

我们将首先思考如何防止用户因为低产品评价而放弃订单。一个选择是完全删除评价。虽然这可以防止人们因为低评分而取消订单的不利影响,但它也阻止了人们基于高评分购买商品的有利影响。我们也可以隐藏低分商品的评分,但这很容易被识破。另一种可能性是降低低评分产品的价格,但降低价格对大多数公司来说都是不可接受的。也许更好的方法是让我们的网站了解低评分产品,并通过预先填写大多数人针对该评价的订单数量来激励人们订购。消费者行为学家已经证明,如果你预先填写数量,消费者放弃购买的可能性就会降低。

设置环境

在此 uri 从 GitHub 获取 AdventureWorks UI 的副本。接下来,使用 Visual Studio 2015 打开该副本。

现在,请按照以下步骤操作,这将指导您设置环境:

  1. 让我们进入我们的 解决方案资源管理器 并添加一个 F# 项目(文件 | 新建项目)。设置环境

  2. 删除脚本文件并将 Library1.fs 重命名为 OrderPrediction.fs设置环境

  3. 打开 NuGet 包管理器并将 Accord.NET 安装到 F# 项目中:

    PM> install-package Accord
    PM> install-package Accord.Statistics
    
    
  4. 确保默认项目是 AdventureWorks.MachineLearning设置环境

  5. 打开 OrderPrediction.fs 并将 Class1 重命名为 OrderPrediction

    namespace AdventureWorks.MachineLearning
    
    type OrderPrediction() = 
        member this.X = "F#"
    
  6. 然后,将 X 重命名为 PredictQuantity,它有一个整数参数 ProductId 和一个返回值 float。目前让它为 0.0。将其类型设为公共。

    namespace AdventureWorks.MachineLearning
    
    type public OrderPrediction() = 
        member this.PredictQuantity(productId:int) = 0.0
    
  7. 编译 F# 项目。

更新现有 Web 项目

接下来,转到 解决方案资源管理器 中的 C# 项目并添加对 F# 项目的引用:

更新现有 Web 项目更新现有 Web 项目

进入PurchaseOrderDetailsController.cs并在AdventureWorks.MachineLearning中添加一个using语句:

更新现有 Web 项目

接下来,创建一个可以接收productId并预测订单数量的端点:

        // GET: PurchaseOrderDetails/PredictQuantity/1
        public Int32 PredictQuantity(int id)
        {
            var orderPrediction = new OrderPrediction();
            return (Int32)orderPrediction.PredictQuantity(id);
        }

对不起,这只是一个 RPC 而不是非常 RESTful。这个练习的目的是关于机器学习而不是 Web 开发。如果你想重写,这是一个更符合 MVC 习惯的形式,请随意。

在设置好控制器后,跳转到创建视图:

更新现有 Web 项目

在页面的@section Scripts块底部添加以下 JavaScript:

@section Scripts {
    @Scripts.Render("~/bundles/jqueryval")
<script type="text/javascript">
        $(document).ready(function(){
            $("#ProductID").change(function(){
                var productID = $(this).val();
                $.get("/PurchaseOrderDetails/PredictQuantity/" + productID, function(result){
                    $("#OrderQty").val(result);
                });
            });
        });
</script>
}

在设置好这些之后,你应该能够运行项目,并在从下拉列表中选择新产品后,订单数量应该填充为0.0

更新现有 Web 项目

实现回归

在应用程序连接好之后,让我们回到 F#项目并实现真正的预测。首先,确保你有System.Data的引用。

实现回归

接下来,打开OrderPrediction.fs并在以下代码中输入:

小贴士

由于这几乎是直接从 REPL 项目复制粘贴的,如果你想避免一些输入,你可以继续这样做。

namespace AdventureWorks.MachineLearning

open Accord
open Accord.Statistics
open Accord.Statistics.Models.Regression.Linear

open System
open System.Data.SqlClient
open System.Collections.Generic

type internal ProductReview = {ProductID:int; TotalOrders:float; AvgReviews: float}

type public OrderPrediction () = 
    let reviews = List<ProductReview>()

    [<Literal>]
    let connectionString = "data source=nc54a9m5kk.database.windows.net;initial catalog=AdventureWorks2014;user id=chickenskills@nc54a9m5kk;password=sk1lzm@tter;"

    [<Literal>]
    let query = "Select 
                A.ProductID, TotalOrders, AvgReviews
                From
                (Select 
                ProductID,
                Sum(OrderQty) as TotalOrders
                from [Sales].[SalesOrderDetail] as SOD
                inner join [Sales].[SalesOrderHeader] as SOH
                on SOD.SalesOrderID = SOH.SalesOrderID
                inner join [Sales].[Customer] as C
                on SOH.CustomerID = C.CustomerID
                Where C.StoreID is not null
                Group By ProductID) as A
                Inner Join 
                (Select
                ProductID,
                (Sum(Rating) + 0.0) / (Count(ProductID) + 0.0) as AvgReviews
                from [Production].[ProductReview] as PR
                Group By ProductID) as B
                on A.ProductID = B.ProductID"

    member this.PredictQuantity(productId:int) = 
        use connection = new SqlConnection(connectionString)
        use command = new SqlCommand(query,connection)
        connection.Open()
        use reader = command.ExecuteReader()
        while reader.Read() do
            reviews.Add({ProductID=reader.GetInt32(0);TotalOrders=(float)(reader.GetInt32(1));AvgReviews=(float)(reader.GetDecimal(2))})

        let x = reviews |> Seq.map (fun pr -> pr.AvgReviews) |> Seq.toArray
        let y = reviews |> Seq.map (fun pr -> pr.TotalOrders) |> Seq.toArray
        let regression = SimpleLinearRegression()
        let sse = regression.Regress(x,y)
        let mse = sse/float x.Length 
        let rmse = sqrt mse
        let r2 = regression.CoefficientOfDetermination(x,y)

        let review = reviews |> Seq.find (fun r -> r.ProductID = productId)
        regression.Compute(review.AvgReviews)

与 REPL 代码相比,唯一的改变是现在connectioncommandreader使用use关键字而不是let进行赋值。这相当于 C#中的using语句,以便以最有效的方式清理所有资源。

在此基础上,你可以运行 UI 并看到从使用我们所有数据的回归中预测的实际值:

实现回归

恭喜!你已经成功地将一个具有简单线性回归的网站连接起来。这个预测是动态的,因为回归是在每次页面刷新时计算的。这意味着随着更多数据进入我们的数据库,网站会实时反映产品评论的变化。你应该意识到,作为软件架构师的你应该拉响警报,因为这将对性能产生严重影响;我们在每次调用时都会拉取汇总数据然后进行回归计算。我们将在本书的后面讨论更好的策略,这些策略允许我们的网站具有与机器学习算法相匹配的实时或近实时性能。

摘要

本章让我们初步涉足创建机器学习模型并在业务应用程序中实现这些模型。我们省略了许多事情,这可能会让所有数据科学爱好者感到不满,比如我简化后的回归公式、过拟合以及在使用回归时没有处理异常值。此外,房间里的 Web 开发者也有很多可以抱怨的地方,包括我基础薄弱的网站设计和在页面加载时注入数据密集型操作。不用担心。我们将在接下来的章节中解决这些问题(以及更多)。

第三章。更多 AdventureWorks 回归

在上一章中,你戴着软件开发者的帽子,你试探性地进入了机器学习的水域。你创建了一个简单的线性回归并将其应用于你的网站。这个回归试图解释顾客评论如何影响零售店的自行车销量。在这一章中,我们将继续上一章的内容,使用多元线性回归来更精确地解释自行车销量。然后我们将切换到逻辑回归,看看我们是否可以根据相同因素预测单个顾客是否会购买自行车。然后我们将考虑如何在一个有助于模型准确性和可重复性的实验中实现回归。最后,我们将总结一些回归的优缺点。

多元线性回归简介

多元线性回归与简单线性回归具有相同的概念,即我们试图找到最佳拟合。主要区别在于我们有多于一个的自变量试图解释因变量。如果你还记得上一章,我们做了如下回归:Y = x0 + E,其中Y是自行车销量,x0是平均评分。

如果我们想看看平均评分和自行车销售价格之间是否存在关系,我们可以使用公式Y = x0 + x1 + E,其中Y是自行车销量,x0是平均评分,x1是自行车的价格。

介绍示例

在深入研究实际数据之前,让我们剖析一下多元线性回归。打开 Visual Studio 并创建一个新的 F#库项目。添加一个名为AccordDotNet.fsx的脚本文件。接下来,添加对Accord.Statistics的 NuGet 引用。如果你不熟悉如何执行这些任务中的任何一项,请回顾第一章,使用.NET 框架进行机器学习欢迎,以及第二章,AdventureWorks 回归,其中每个步骤都使用截图进行了详细说明。

在脚本顶部添加以下引用:

#r "../packages/Accord.3.0.2/lib/net40/Accord.dll"
#r "../packages/Accord.Statistics.3.0.2/lib/net40/Accord.Statistics.dll"
#r "../packages/Accord.Math.3.0.2/lib/net40/Accord.Math.dll"

open Accord
open Accord.Statistics.Models.Regression.Linear

接下来,让我们创建一个虚拟数据集。在这种情况下,让我们看看学生的年龄、智商和 GPA 之间是否存在关系。由于有两个自变量(x0x1),我们将创建一个名为xs的输入值,并用五个观察值来查看它。由于每个观察值有两个值,xs是一个锯齿数组。

let xs = [| [|15.0;130.0|];[|18.0;127.0|];[|15.0;128.0|];[|17.0;120.0|];[|16.0;115.0|] |]

将其发送到 REPL,我们得到:

val xs : float [] [] =
 [|[|15.0; 130.0|]; [|18.0; 127.0|]; [|15.0; 128.0|]; [|17.0; 120.0|]; [|16.0; 115.0|]|]

在这个数据集中,第一个观察对象是一个 15 岁的 130 智商的个体,第二个是一个 18 岁的 127 智商的个体,以此类推。在处理完输入后,让我们创建y,即学生的 GPA:

let y = [|3.6;3.5;3.8;3.4;2.6|]

将其发送到 REPL,我们得到:

val y : float [] = [|3.6; 3.5; 3.8; 3.4; 2.6|]

第一个学生的 GPA 是 3.6,第二个是 3.5,以此类推。请注意,由于我们的输出是一个单一的数字,我们只需要一个简单的数组来保存这些值。处理完输入后,让我们使用我们的xsy创建一个多重线性回归:

let regression = MultipleLinearRegression(2, true)
let error = regression.Regress(xs, y)

let a = regression.Coefficients.[0]
let b = regression.Coefficients.[1]
let c = regression.Coefficients.[2]

将此发送到 REPL,我们得到:

val regression : MultipleLinearRegression =

 y(x0, x1) = 0.0221298495645295*x0 + 0.0663103721298495*x1 + -5.20098970704672
val error : float = 0.1734125099
val a : float = 0.02212984956
val b : float = 0.06631037213
val c : float = -5.200989707

有几点需要注意。首先,Accord 为我们打印了多重线性回归的公式y(x0, x1) = 0.0221298495645295*x0 + 0.0663103721298495*x1 + -5.20098970704672。需要注意的关键点是,你不能像简单回归那样解释多重回归的结果,例如,将x1x2相加来作为线的斜率是不正确的。相反,每个x是如果其他x保持不变时的线的斜率。所以,在这种情况下,如果x1保持不变,x0每增加一个单位,y就会增加.022。回到我们的例子,我们可以这样说,如果我们增加一个人的年龄一年,一个人的 GPA 会增加.022,同时保持智商不变。同样,我们可以这样说,对于一个人的智商每下降一个点,这个人的 GPA 会下降 0.066,同时保持年龄不变。我们不能像简单回归那样使用散点图来展示多重回归的所有结果,因为你需要为每个x值设置一个轴,这很快就会变得难以控制,甚至不可能。

接下来,让我们看看我们的回归效果如何,使用我们熟悉的老朋友r2rmse

let sse = regression.Regress(xs, y)
let mse = sse/float xs.Length 
let rmse = sqrt(mse)
let r2 = regression.CoefficientOfDetermination(xs,y)

将此发送到 REPL,我们得到:

val sse : float = 0.1734125099
val mse : float = 0.03468250198
val rmse : float = 0.186232387
val r2 : float = 0.7955041157

注意,sse与上面的误差相同。Accord.NET 将sse作为误差返回,所以我会以后只使用它。此外,查看我们的结果,我们可以看到我们的r2.79,这相当不错,而且我们的rmse.18,这也足够低,使得回归是可行的。

继续添加 x 变量?

如果两个x变量是好的,那么三个会更好吗?让我们看看。让我们添加另一个变量,在这种情况下,学生的前一年 GPA 作为第三个x值。回到 REPL 并添加以下内容:

let xs' = [| [|15.0;130.0;3.6|];[|18.0;127.0;3.5|];
            [|15.0;128.0;3.7|];[|17.0;120.0;3.5|];
            [|17.0;120.0;2.5|] |]

let regression' = MultipleLinearRegression(3,true)
let error' = regression'.Regress(xs',y)

let a' = regression'.Coefficients.[0]
let b' = regression'.Coefficients.[1]
let c' = regression'.Coefficients.[2]
let d' = regression'.Coefficients.[3]

let mse' = error'/float xs'.Length 
let rmse' = sqrt(mse')
let r2' = regression'.CoefficientOfDetermination(xs',y)

将此发送到 REPL,我们得到:

val xs' : float [] [] =
 [|[|15.0; 130.0; 3.6|]; [|18.0; 127.0; 3.5|]; [|15.0; 128.0; 3.7|];
 [|17.0; 120.0; 3.5|]; [|17.0; 120.0; 2.5|]|]
val regression' : MultipleLinearRegression =
 y(x0, x1, x2) = -0.0202088664499619*x0 + 0.0116951379763468*x1 + 0.834082578324918*x2 + -0.552984300435694
val error' : float = 0.01071166747
val a' : float = -0.02020886645
val b' : float = 0.01169513798
val c' : float = 0.8340825783
val d' : float = -0.5529843004
val mse' : float = 0.002142333495
val rmse' : float = 0.0462853486
val r2' : float = 0.9873683167

因此,r2现在达到了 99%,这意味着我们可以用一个人的年龄、智商和前一年 GPA 来解释 99%的 GPA 变化。此外,请注意,rmse.04,这很好,也很低。我们有一个相当好的模型。

AdventureWorks 数据

在完成演示后,让我们回到自行车公司实现多重线性回归。由于我们使用的是更真实的数据,我认为我们不会得到 99%的r2,但我们可以期待。在你的解决方案资源管理器中,添加另一个名为AccordDotNet2.fsx的 F#脚本。然后,添加对System.Transactions的引用,以便我们可以使用 ADO.NET 访问我们的数据。回到AccordDotNet2.fsx并添加以下代码:

#r "System.Transactions.dll"
#r "../packages/Accord.3.0.2/lib/net40/Accord.dll"
#r "../packages/Accord.Statistics.3.0.2/lib/net40/Accord.Statistics.dll"
#r "../packages/Accord.Math.3.0.2/lib/net40/Accord.Math.dll"

open Accord
open Accord.Statistics
open Accord.Statistics.Models.Regression.Linear

open System
open System.Data.SqlClient

type ProductInfo = {ProductID:int; AvgOrders:float; AvgReviews: float; ListPrice: float}

let productInfos =  ResizeArray<ProductInfo>()

[<Literal>]
let connectionString = "data source=nc54a9m5kk.database.windows.net;initial catalog=AdventureWorks2014;user id=chickenskills@nc54a9m5kk;password=sk1lzm@tter;"

[<Literal>]
let query = "Select 
            A.ProductID, AvgOrders, AvgReviews, ListPrice
            From
            (Select 
            ProductID,
            (Sum(OrderQty) + 0.0)/(Count(Distinct SOH.CustomerID) + 0.0) as AvgOrders
            from [Sales].[SalesOrderDetail] as SOD
            inner join [Sales].[SalesOrderHeader] as SOH
            on SOD.SalesOrderID = SOH.SalesOrderID
            inner join [Sales].[Customer] as C
            on SOH.CustomerID = C.CustomerID
            Where C.StoreID is not null
            Group By ProductID) as A
            Inner Join 
            (Select
            ProductID,
            (Sum(Rating) + 0.0) / (Count(ProductID) + 0.0) as AvgReviews
            from [Production].[ProductReview] as PR
            Group By ProductID) as B
            on A.ProductID = B.ProductID
            Inner Join
            (Select
            ProductID,
            ListPrice
            from [Production].[Product]
            ) as C
            On A.ProductID = C.ProductID"

let connection = new SqlConnection(connectionString)
let command = new SqlCommand(query,connection)
connection.Open()
let reader = command.ExecuteReader()
while reader.Read() do
    productInfos.Add({ProductID=reader.GetInt32(0);
                        AvgOrders=(float)(reader.GetDecimal(1));
                        AvgReviews=(float)(reader.GetDecimal(2));
                        ListPrice=(float)(reader.GetDecimal(3));})

注意,这与你在上一章中编写的代码非常相似。事实上,你可能想要复制并粘贴那段代码,并做出以下更改:

  1. ProductInfo记录类型中添加一个ListPrice字段。

  2. 更新查询以添加一个子句来获取自行车的零售价。

  3. 更新 productInfos。添加一个方法来包含我们正在带来的第三个值。

代码本身在 SQL 中创建了一个包含多个订单、平均评论和按 productId 计算的平均价格的 DataFrame,并将其本地化。将此代码发送到 REPL,我们得到以下结果:

type ProductInfo =
 {ProductID: int;
 AvgOrders: float;
 AvgReviews: float;
 ListPrice: float;}
val productInfos : Collections.Generic.List<ProductInfo>
val connectionString : string =
 "data source=nc54a9m5kk.database.windows.net;initial catalog=A"+[72 chars]
val query : string =
 "Select 
 A.ProductID, AvgOrders, AvgReviews, ListP"+[937 chars]
val connection : SqlConnection = System.Data.SqlClient.SqlConnection
val command : SqlCommand = System.Data.SqlClient.SqlCommand
val reader : SqlDataReader
val it : unit = ()

数据准备好后,让我们创建一个多元线性回归。将以下代码添加到脚本文件中:

let xs = 
    productInfos 
    |> Seq.map (fun pi -> [|pi.AvgReviews; pi.ListPrice|]) 
    |> Seq.toArray
let y = 
    productInfos 
    |> Seq.map (fun pi -> pi.AvgOrders) 
    |> Seq.toArray
let regression = MultipleLinearRegression(2, true)
let error = regression.Regress(xs, y)

let a = regression.Coefficients.[0]
let b = regression.Coefficients.[1]
let c = regression.Coefficients.[2]

let mse = error/float xs.Length 
let rmse = sqrt mse
let r2 = regression.CoefficientOfDetermination(xs, y)

将此代码发送到 REPL,我们得到以下结果:

val regression : MultipleLinearRegression =
 y(x0, x1) = 9.68314848116308*x0 + -0.000913619922709572*x1 + -26.1836956342657
val error : float = 682.6439378
val a : float = 9.683148481
val b : float = -0.0009136199227
val c : float = -26.18369563
val mse : float = 7.037566369
val rmse : float = 2.652841188
val r2 : float = 0.3532529168

通过添加自行车的价格,我们的 r2.29 提高到 .35。我们的 rmse 也从 2.77 降低到 2.65。这种变化意味着我们有一个更精确的模型,误差更小。因为这是更好的,所以让我们将其添加到我们的生产应用程序中。

将多元回归添加到我们的生产应用程序中

打开你在上一章开始工作的 AdventureWorks 解决方案。在 解决方案资源管理器 中,导航到 AdventureWorks.MachineLearning 项目并打开 OrderPrediction.fs

定位到 ProductReview 类型并将其替换为以下内容:

type ProductInfo = {ProductID:int; AvgOrders:float; AvgReviews: float; ListPrice: float}

接下来,进入 OrderPrediction 类型并找到分配评论值的行,将其替换为以下内容:

let productInfos = ResizeArray<ProductInfo>()

接下来,定位查询值并将其内容替换为以下内容:

[<Literal>]
let query = "Select 
            A.ProductID, AvgOrders, AvgReviews, ListPrice
            From
            (Select 
            ProductID,
            (Sum(OrderQty) + 0.0)/(Count(Distinct SOH.CustomerID) + 0.0) as AvgOrders,
            Sum(OrderQty) as TotalOrders
            from [Sales].[SalesOrderDetail] as SOD
            inner join [Sales].[SalesOrderHeader] as SOH
            on SOD.SalesOrderID = SOH.SalesOrderID
            inner join [Sales].[Customer] as C
            on SOH.CustomerID = C.CustomerID
            Where C.StoreID is not null
            Group By ProductID) as A
            Inner Join 
            (Select
            ProductID,
            (Sum(Rating) + 0.0) / (Count(ProductID) + 0.0) as AvgReviews
            from [Production].[ProductReview] as PR
            Group By ProductID) as B
            on A.ProductID = B.ProductID
            Inner Join
            (Select
            ProductID,
            ListPrice
            from [Production].[Product]
            ) as C
            On A.ProductID = C.ProductID"

接下来,向下滚动到 PredictQuantity 函数并找到 reader.Read() 代码行。将其替换为以下内容:

        while reader.Read() do
            productInfos.Add({ProductID=reader.GetInt32(0);
                                AvgOrders=(float)(reader.GetDecimal(1));
                                AvgReviews=(float)(reader.GetDecimal(2));
                                ListPrice=(float)(reader.GetDecimal(3));})

最后,从以下内容开始删除 PredictQuantity 函数中剩余的所有代码:

let x = reviews |> Seq.map(fun pr -> pr.AvgReviews) |> Seq.toArray

替换为以下内容:

        let xs = 
            productInfos 
            |> Seq.map (fun pi -> [|pi.AvgReviews; pi.ListPrice|]) 
            |> Seq.toArray
        let y = 
            productInfos 
            |> Seq.map (fun pi -> pi.AvgOrders) 
            |> Seq.toArray
        let regression = MultipleLinearRegression(2, true)
        let error = regression.Regress(xs, y)

        let a = regression.Coefficients.[0]
        let b = regression.Coefficients.[1]
        let c = regression.Coefficients.[2]

        let mse = error/float xs.Length 
        let rmse = sqrt mse
        let r2 = regression.CoefficientOfDetermination(xs, y)

        let productInfo = 
            productInfos 
            |> Seq.find (fun r -> r.ProductID = productId)
        let xs' = [|[|productInfo.AvgReviews; productInfo.ListPrice|]|]
        regression.Compute(xs') |> Seq.head

注意,即使我们只为最终的 regression.Compute() 输入一个 productInfo,我们仍然需要创建一个交错数组。此外,注意 Compute 函数返回一个数组,但由于我们只输入一个值,因此结果数组始终只有一个长度。我们使用了 Seq.head 来获取数组的第一个值。在某些时候,这个头函数非常有用,我们将在本书中再次看到它。

构建项目并打开 UI;你可以看到我们的预测已经被调整:

将多元回归添加到我们的生产应用程序中

使用多个 x 变量时的注意事项

在这一点上,你可能正在想,“这太棒了!我可以不断地添加更多的变量到我的多元线性回归中,并且我会得到更好的 r2 和更低的 rmse。”正如李·科索可能会说的,“别那么快!”在不深入细节的情况下,每次你添加一个新的线性多元回归特征,你总是会得到更好的结果,或者至少不会更差的结果。这意味着,如果你在 1999 年 6 月 29 日添加了不同城市的平均温度,模型可能会改进。此外,随着特征数量的增加,将不希望的影响引入模型的风险也会增加;我们将在稍后讨论这一点。实际上,我见过一些模型,其特征数量超过了观察数量。一般来说,这不是一个好主意。

为了对抗特征增长,你可以采取两种方法。首先,你可以将常识与奥卡姆剃刀原理结合起来。奥卡姆剃刀原理是指,在给定可能的解决方案选择时,应该总是选择最简单的一个。这种常识与简洁的结合比大多数人意识到的更为常见和强大。我们大脑中的灰色物质本身就是一台相当强大的计算机,能够很好地识别模式和建立关联。

事实上,在特定领域投入过时间的商业分析师可能知道一些对外部数据科学家来说并不明显的关联关系。这些数据科学家面对的是一系列特征列表或被应用于数据的简单机器学习模型。诚然,人类确实存在偏见,有时会错过关联关系,但总体来说,他们仍然擅长匹配模式。将奥卡姆剃刀原理应用于特征选择意味着你正在尝试找到对模型可预测性影响最大的最少特征数量。

让我们转向我们的朋友,位于 AdventureWorks 的商业分析师,询问他认为什么因素影响了我们的经销商购买自行车的数量。他说:“嗯,我认为价格和客户评价当然非常重要,但我认为自行车的重量也会影响我们的经销商。自行车越重,他们订购的可能性就越小。”

向我们的模型添加第三个 x 变量

带着商业分析师的想法,让我们在我们的模型中添加第三个独立变量,即自行车重量。回到解决方案资源管理器,添加另一个脚本文件。将以下代码添加到脚本中:

#r "System.Transactions.dll"
#r "../packages/Accord.3.0.2/lib/net40/Accord.dll"
#r "../packages/Accord.Statistics.3.0.2/lib/net40/Accord.Statistics.dll"
#r "../packages/Accord.Math.3.0.2/lib/net40/Accord.Math.dll"

open Accord
open Accord.Statistics
open Accord.Statistics.Models.Regression.Linear

open System
open System.Data.SqlClient

type ProductInfo = {ProductID:int; AvgOrders:float; AvgReviews: float; ListPrice: float; Weight: float}

let productInfos = ResizeArray<ProductInfo>()

[<Literal>]
let connectionString = "data source=nc54a9m5kk.database.windows.net;initial catalog=AdventureWorks2014;user id=chickenskills@nc54a9m5kk;password=sk1lzm@tter;"

[<Literal>]
let query = "Select 
            A.ProductID, AvgOrders, AvgReviews, ListPrice, Weight
            From
            (Select 
            ProductID,
            (Sum(OrderQty) + 0.0)/(Count(Distinct SOH.CustomerID) + 0.0) as AvgOrders
            from [Sales].[SalesOrderDetail] as SOD
            inner join [Sales].[SalesOrderHeader] as SOH
            on SOD.SalesOrderID = SOH.SalesOrderID
            inner join [Sales].[Customer] as C
            on SOH.CustomerID = C.CustomerID
            Where C.StoreID is not null
            Group By ProductID) as A
            Inner Join 
            (Select
            ProductID,
            (Sum(Rating) + 0.0) / (Count(ProductID) + 0.0) as AvgReviews
            from [Production].[ProductReview] as PR
            Group By ProductID) as B
            on A.ProductID = B.ProductID
            Inner Join
            (Select
            ProductID,
            ListPrice,
            Weight
            from [Production].[Product]
            ) as C
            On A.ProductID = C.ProductID"

let connection = new SqlConnection(connectionString)
let command = new SqlCommand(query, connection)
connection.Open()
let reader = command.ExecuteReader()
while reader.Read() do
    productInfos.Add({ProductID=reader.GetInt32(0);
                        AvgOrders=(float)(reader.GetDecimal(1));
                        AvgReviews=(float)(reader.GetDecimal(2));
                        ListPrice=(float)(reader.GetDecimal(3));
                        Weight=(float)(reader.GetDecimal(4));})

let xs = 
    productInfos 
    |> Seq.map (fun pi -> [|pi.AvgReviews; pi.ListPrice; pi.Weight|]) 
    |> Seq.toArray
let y = 
    productInfos 
    |> Seq.map (fun pi -> pi.AvgOrders) 
    |> Seq.toArray
let regression = MultipleLinearRegression(3, true)
let error = regression.Regress(xs, y)

let a = regression.Coefficients.[0]
let b = regression.Coefficients.[1]
let c = regression.Coefficients.[2]
let d = regression.Coefficients.[3]

let mse = error/float xs.Length 
let rmse = sqrt mse
let r2 = regression.CoefficientOfDetermination(xs, y)

将此发送到交互式编程环境(REPL),你会发现我们的r2值上升到.36,而rmse值下降到2.63

val regression : MultipleLinearRegression =
 y(x0, x1, x2) = 8.94836007927991*x0 + -0.00103754084861455*x1 + -0.0848953592695415*x2 + -21.2973971475571
val error : float = 671.2299241
val a : float = 8.948360079
val b : float = -0.001037540849
val c : float = -0.08489535927
val d : float = -21.29739715
val mse : float = 6.919896125
val rmse : float = 2.630569544
val r2 : float = 0.3640667242

我们分析师对价格和客户评价的直觉非常准确,而重量……则不然。使用奥卡姆剃刀原理,我们可以使用价格和客户评价来构建我们的模型,并忽略重量变量。

逻辑回归

现在我们对回归越来越熟悉了,让我们介绍另一种回归类型——逻辑回归。到目前为止,回归都有数值输出值——比如预测一个人的 GPA 或预测自行车销量。逻辑回归使用与拟合一组独立特征到直线相同的技巧,但它们并不试图预测一个数值。相反,逻辑回归试图预测一个二元值(是/否、真/假、味道好/不好),然后为该值分配一个概率。

逻辑回归简介

由于你已经对回归有了初步的了解,我们可以直接进入代码,看看一个实际的例子。打开回归项目,添加一个名为AccordDotNet7.fsx的脚本。复制以下代码行:

#r "../packages/Accord.3.0.2/lib/net40/Accord.dll"
#r "../packages/Accord.Statistics.3.0.2/lib/net40/Accord.Statistics.dll"
#r "../packages/Accord.Math.3.0.2/lib/net40/Accord.Math.dll"

open Accord
open Accord.Statistics.Analysis
open Accord.Statistics.Models.Regression
open Accord.Statistics.Models.Regression.Fitting

let xs = [| [|0.5|];[|0.75|];
            [|1.0|];[|1.25|];[|1.5|];[|1.75|];[|1.75|];
            [|2.0|];[|2.25|];[|2.5|];[|2.75|];
            [|3.0|];[|3.25|];[|3.5|];
            [|4.0|];[|4.25|];[|4.5|];[|4.75|];
            [|5.0|];[|5.5|];|]

let y = [|0.0;0.0;0.0;0.0;0.0;0.0;1.0;0.0;1.0;0.0;
          1.0;0.0;1.0;0.0;1.0;1.0;1.0;1.0;1.0;1.0|]

将此发送到交互式编程环境(REPL)后,我们得到:

val xs : float [] [] =
 [|[|0.5|]; [|0.75|]; [|1.0|]; [|1.25|]; [|1.5|]; [|1.75|]; [|1.75|]; [|2.0|];
 [|2.25|]; [|2.5|]; [|2.75|]; [|3.0|]; [|3.25|]; [|3.5|]; [|4.0|]; [|4.25|];
 [|4.5|]; [|4.75|]; [|5.0|]; [|5.5|]|]
val y : float [] =
 [|0.0; 0.0; 0.0; 0.0; 0.0; 0.0; 1.0; 0.0; 1.0; 0.0; 1.0; 0.0; 1.0; 0.0; 1.0;
 1.0; 1.0; 1.0; 1.0; 1.0|]

我从维基百科中提取了这个数据集,它代表了 20 名学生,他们在考试前一天学习的小时数,以及他们是否通过考试,用0.0表示失败,用1.0表示通过。查看xs,学生 0 学习了 0.5 小时,查看y,我们可以看到他没有通过考试。

接下来,让我们创建我们的回归分析并查看一些结果:

let analysis = new LogisticRegressionAnalysis(xs, y)
analysis.Compute() |> ignore
let pValue = analysis.ChiSquare.PValue
let coefficientOdds = analysis.Regression.GetOddsRatio(0)
let hoursOfStudyingOdds = analysis.Regression.GetOddsRatio(1)
let coefficients = analysis.CoefficientValues

将其发送到 REPL 得到以下结果:

val analysis : LogisticRegressionAnalysis
val pValue : float = 0.0006364826185
val coefficientOdds : float = 0.01694617045
val hoursOfStudyingOdds : float = 4.502556825
val coefficients : float [] = [|-4.077713403; 1.504645419|]

这里有很多新的事情在进行中,所以让我们依次查看它们。在我们创建分析后,我们计算回归。下一个项目是pValuepValue是逻辑回归中衡量准确度的常用指标。正如我们之前看到的,线性回归通常使用rmser2作为衡量模型准确度的方法。逻辑回归可以使用这些指标,但通常不使用。与使用称为最小二乘法的精确数字输出的线性回归不同,逻辑回归使用称为最大似然法的方法,其中回归会迭代并尝试不同的输入值组合以最大化结果的可能性。因此,逻辑回归需要在数据集上多次运行,我们可以配置我们希望模型有多精确。从图形上看,它看起来像这样:

逻辑回归简介

回到pValue,它是衡量我们的模型与null假设相比有多好的指标,或者说,我们的模型与一个完全随机的模型相比有多好。如果pValue小于 0.05,我们的模型是有效的。如果数字高于 0.05,模型与随机模型没有区别。你可能想知道,“0.05 有什么特别之处?”确切的答案在于一些超出本书范围的底层数学函数。粗略的答案是,这是大家都在使用的,所以 Accord 内置了这个值。如果你觉得这个解释不满意,可以查看维基百科上的这篇帖子(en.wikipedia.org/wiki/P-value)。无论如何,0.0006 是一个非常好的结果。

接下来查看下一个值,我们看到GetOddsRatio的结果:

val coefficientOdds : float = 0.01694617045
val hoursOfStudyingOdds : float = 4.502556825

这意味着如果我们完全不学习,我们通过考试的概率将是 1.6%。如果我们想通过考试,我们需要学习 4.5 小时。接下来,看一下系数:

val coefficients : float [] = [|-4.077713403; 1.504645419|]

Accord.NET 返回一个包含系数的数组,第一个值是截距。有了这些,你可以创建一个公式来预测给定任何学习小时数的学生是否能通过考试。例如,以下是我们的基础数据集的预测结果:

逻辑回归简介

如果我们想开始尝试啤酒和学习小时数的组合(例如,“如果我学习 4.5 小时,我会通过吗?”),我们可以使用Compute函数做到这一点。在脚本文件的底部输入:

let result = analysis.Regression.Compute([|3.75|])

将其发送到 REPL 进行以下操作:

val result : float = 0.8270277278

如果你学习 3.75 小时,你有 82%的通过率。

添加另一个 x 变量

接下来,让我们向我们的模型添加另一个变量——考试前一晚你喝了多少杯啤酒。回到你的脚本文件,并将其添加到底部:

let xs' = [| [|0.5;2.5|];
   [|0.75;1.5|];
            [|1.0;4.0|];
  [|1.25;1.0|];
  [|1.5;0.0|];
  [|1.75;3.0|];
  [|1.75;0.0|];
            [|2.0;3.0|];
            [|2.25;1.0|];
            [|2.5;4.5|];
            [|2.75;1.5|];
            [|3.0;1.0|];
            [|3.25;2.5|];
            [|3.5;0.0|];
            [|4.0;2.0|];
            [|4.25;1.5|];
            [|4.5;4.5|];
            [|4.75;0.0|];
            [|5.0;1.0|];
            [|5.5;0.0|];|]

let analysis' = new LogisticRegressionAnalysis(xs', y)
analysis'.Compute() |> ignore
let pValue' = analysis'.ChiSquare.PValue
let coefficientOdds' = analysis'.Regression.GetOddsRatio(0)
let hoursOfStudyingOdds' = analysis'.Regression.GetOddsRatio(1)
let numberOfBeersDrankOdds' = analysis'.Regression.GetOddsRatio(2)
let coefficients' = analysis'.CoefficientValues

将此发送到 REPL,我们看到:

val analysis' : LogisticRegressionAnalysis
val pValue' : float = 0.002336631577
val coefficientOdds' : float = 0.02748131566
val hoursOfStudyingOdds' : float = 4.595591714
val numberOfBeersDrankOdds' : float = 0.7409200941
val coefficients' : float [] = [|-3.594248936; 1.525097521; -0.2998624947|]

评估结果,我们仍然需要学习 4.59 小时才能通过,保持啤酒数量不变。此外,我们还需要喝不到 0.74 杯啤酒才能通过。请注意,即使喝更多的啤酒实际上降低了我们通过的机会,但几率比是正的。我们知道啤酒数量和通过几率之间存在反向关系,因为啤酒的系数(-0.029986)是负的。

现在,我们可以开始权衡学习时间和喝酒的机会,以增加我们通过考试的可能性。转到脚本文件,并添加学习 4.5 小时和喝一杯啤酒的内容:

let result' = analysis'.Regression.Compute([|4.50; 1.00|])

将其发送到 REPL:

val result' : float = 0.9511458187

所以如果你喝一杯啤酒并学习 4.5 小时,你有 95%的通过率。为了进一步巩固你的机会,尝试在第四题中填写“B”,以帮助你冲过顶端——这在中学时对我总是有效。

将逻辑回归应用于 AdventureWorks 数据

所以回到一个更现实的数据库集,让我们看看 AdventureWorks。请向项目中添加一个新的脚本文件。命名为AccordDotNet8.fsx。将以下代码复制并粘贴到脚本文件中:

#r "System.Transactions.dll"
#r "../packages/Accord.3.0.2/lib/net40/Accord.dll"
#r "../packages/Accord.Statistics.3.0.2/lib/net40/Accord.Statistics.dll"
#r "../packages/Accord.Math.3.0.2/lib/net40/Accord.Math.dll"

open Accord
open Accord.Statistics.Filters
open Accord.Statistics.Analysis
open Accord.Statistics.Models.Regression
open Accord.Statistics.Models.Regression.Fitting

open System
open System.Data.SqlClient

type ProductInfo = {ProductID:int; Color:string; AvgReviews: float; Markup: float}
let productInfos = ResizeArray<ProductInfo>()

[<Literal>]
let connectionString = "data source=nc54a9m5kk.database.windows.net;initial catalog=AdventureWorks2014;user id=chickenskills@nc54a9m5kk;password=sk1lzm@tter;"

[<Literal>]
let query = "Select
    A.ProductID,
    A.Color,
    B.AvgReviews,
    A.MarkUp
    From
    (Select P.ProductID,
      Color,
      ListPrice - StandardCost as Markup
      from [Sales].[SalesOrderDetail] as SOD
        inner join [Sales].[SalesOrderHeader] as SOH
        on SOD.SalesOrderID = SOH.SalesOrderID
        inner join [Sales].[Customer] as C
        on SOH.CustomerID = C.CustomerID
      inner join [Production].[Product] as P
      on SOD.ProductID = P.ProductID
      inner join [Production].[ProductSubcategory] as PS
      on P.ProductSubcategoryID = PS.ProductSubcategoryID
      Where C.StoreID is null
      and PS.ProductCategoryID = 1) as A
    Inner Join
    (Select PR.ProductID,
      (Sum(Rating) + 0.0) / (Count(ProductID) + 0.0) as AvgReviews
        from [Production].[ProductReview] as PR
        Group By ProductID) as B
    on A.ProductID = B.ProductID"

let connection = new SqlConnection(connectionString)
let command = new SqlCommand(query, connection)
connection.Open()
let reader = command.ExecuteReader()
while reader.Read() do
    productInfos.Add({ProductID=reader.GetInt32(0);
                        Color=(string)(reader.GetString(1));
                        AvgReviews=(float)(reader.GetDecimal(2));
                        Markup=(float)(reader.GetDecimal(3));})

将此发送到 REPL,你应该看到:

type ProductInfo =
 {ProductID: int;
 Color: string;
 AvgReviews: float;
 Markup: float;}
val productInfos : List<ProductInfo>
val connectionString : string =
 "data source=nc54a9m5kk.database.windows.net;initial catalog=A"+[72 chars]
val query : string =
 "Select
 A.ProductID,
 A.Color,
 B.AvgReviews,
 A."+[803 chars]
val connection : SqlConnection = System.Data.SqlClient.SqlConnection
val command : SqlCommand = System.Data.SqlClient.SqlCommand
val reader : SqlDataReader
val it : unit = ()

这里没有新的代码,所以我们可以安全地继续。然而,我想指出,这个查询可能比我们迄今为止对数据库进行的任何其他查询都要长一些。这会影响我们将代码集成到应用程序时的架构设计。我们将在第五章中详细讨论这个问题,时间到——获取数据,但现在我们只想提一下。

回到脚本文件,请将以下代码添加到底部:

type ProductInfo' = {ProductID:int; BlackInd:float; BlueInd:float; RedInd:float; SilverInd:float; OtherInd: float; AvgReviews: float; HighMargin:float}

let getProductInfo'(productInfo:ProductInfo) =
        {ProductInfo'.ProductID=productInfo.ProductID;
        BlackInd = (match productInfo.Color with | "Black" -> 1.0 | _ -> 0.0);
        BlueInd = (match productInfo.Color with | "Blue" -> 1.0 | _ -> 0.0);
        RedInd = (match productInfo.Color with | "Red" -> 1.0 | _ -> 0.0);
        SilverInd = (match productInfo.Color with | "Silver" -> 1.0 | _ -> 0.0);
        OtherInd = (match productInfo.Color with | "Silver" | "Blue" | "Red"  -> 0.0 | _ -> 1.0);
        AvgReviews = productInfo.AvgReviews;
        HighMargin = (match productInfo.Markup > 800.0 with | true -> 1.0 | false -> 0.0);}

let productInfos' = 
    productInfos 
    |> Seq.map (fun pi -> getProductInfo'(pi))
let xs = 
    productInfos' 
    |> Seq.map (fun pi -> [|pi.BlackInd; pi.BlueInd; pi.RedInd; pi.SilverInd; pi.OtherInd; pi.AvgReviews|]) 
    |> Seq.toArray
let y = 
    productInfos' 
    |> Seq.map (fun pi -> pi.HighMargin) 
    |> Seq.toArray

let analysis = new LogisticRegressionAnalysis(xs, y)
analysis.Compute() |> ignore
let pValue = analysis.ChiSquare.PValue
let coefficientOdds = analysis.Regression.GetOddsRatio(0)
let blackIndOdds = analysis.Regression.GetOddsRatio(1)
let blueIndOdds = analysis.Regression.GetOddsRatio(2)
let redIndOdds = analysis.Regression.GetOddsRatio(3)
let silverIndOdds = analysis.Regression.GetOddsRatio(4)
let otherIndOdds = analysis.Regression.GetOddsRatio(5)
let ratingsOdds = analysis.Regression.GetOddsRatio(6)
let coefficients = analysis.CoefficientValues

将此发送到 REPL,你应该得到:

val analysis : LogisticRegressionAnalysis
val pValue : float = 0.0
val coefficientOdds : float = 4.316250806e-07
val blackIndOdds : float = 6.708924364
val blueIndOdds : float = 0.03366007966
val redIndOdds : float = 0.0897074697
val silverIndOdds : float = 0.04618907808
val otherIndOdds : float = 0.003094736179
val ratingsOdds : float = 127.5863311
val coefficients : float [] =
 [|-14.65570849; 1.903438635; -3.391442724; -2.411201239; -3.075011914;
 -5.778052618; 4.848793242|]

有一些新的代码片段需要查看,以及两个新的概念。首先,注意为ProductInfo创建了一个新的记录类型,颜色从单个列(ProductType.Color)拆分出来,变成一系列 0.0/1.0 列(ProductType'.BlackIndProductType'BlueInd等等)。我没有将这些列设置为 bool 类型的原因是 Accord.NET 期望输入为浮点数,而 0.0/1.0 同样可以满足这个目的。这些列被称为“虚拟变量”,它们被逻辑回归用来适应分类数据。此时,你可能正在问,“分类数据是什么东西?”这是一个合理的问题。

分类数据

你可能没有注意到,但我们直到最后一个查询所使用的所有x变量都是数值型的——卖出的自行车数量、平均评论、喝下的啤酒数量等等。这些值被认为是连续的,因为它们可以是无限大的值。我可以喝一杯、两杯或三杯啤酒。同样,自行车的平均评论可以是 3.45、3.46 等等。因为这些值被视为数字,所以它们可以被相加、平均,并以你从一年级开始学习以来所有的方式操作。请注意,连续值可以有范围限制:平均评论只能介于 0.0 到 5.0 之间,因为这是我们限制用户输入的范围。

分类别值是不同的。通常,它们是代表非数字概念的整数。例如,0 可能代表男性,1 可能代表女性。同样,销售订单的状态可能是 1 表示开放,2 表示待定,3 表示关闭,4 表示退货。尽管这些值在数据库中以整数形式存储,但它们不能被相加、平均或以其他方式操作。类别值也可以存储为字符串,就像我们看到的自行车颜色:“黑色”、“蓝色”等等。在这种情况下,字符串的范围被限制在可以从其中选择数字的集合中。

回到我们的分析,我们有自行车颜色,这是一个类别值,正以字符串的形式存储。我们不能将这个字符串作为一个单独的x变量发送给 Accord.NET,因为LogisticRegressionAnalysis只接受数组中的浮点数。请注意,在其他统计软件包如 R 或 SAS 中,你可以传递字符串,因为背后有代码将这些字符串值转换为数字。所以,回到颜色。我们想使用它,但必须将其转换为浮点数。我们可以创建一个新的字段ColorId,并连接一个转换函数,将每种颜色转换为如下的数字表示:

let getColorId (color:string) =
    match color.ToLower() with
    | "black" -> 1.0
    | "blue" -> 2.0
    | "red" -> 3.0
    | "silver" -> 4.0
    | _ -> 5.0

我们将在本书的其他地方这样做。然而,使用这些数值在我们的逻辑回归中是没有意义的,因为没有实际的意义来比较值:2.3 的oddsRatio意味着什么?事实上,没有任何类型的回归能够理解以这种方式编码的类别数据。而不是构建无意义的值,我们创建虚拟变量,这些变量可以在回归中具有意义。对于我们的类别变量的每个可能值,我们创建一个 bool 列,表示该特定记录是否有该值。技术上,我们可以创建少于总可能值的列,但我发现为每个值创建一个列更容易推理和显示。然后我们可以将这些虚拟变量传递给回归,并得到有意义的响应。

还要注意,我像这样在一行中做了颜色分配的模式匹配:

BlackInd = (match productInfo.Color with | "Black" -> 1.0 | _ -> 0.0);

在 F#社区中,关于这一点是否被视为不良做法存在一些激烈的争议:有些人希望看到模式匹配语句的每个可能结果都在一行上,而有些人则不这样认为。我发现在这种情况下,将所有内容保持在同一行上更容易阅读,但我承认这对从类似 C#这样的花括号语言新接触 F#的人来说可能有点困难。然而,如果你使用三元运算符,你应该对语法感到舒适。

此外,请注意,我们使用这一行代码将连续变量Markup更改为High Margin

HighMargin = (match productInfo.Markup > 800.0 with | true -> 1.0 | false -> 0.0);}

附件点

由于逻辑回归需要将y变量设置为 0.0 或 1.0,我们需要一种方法将数据分割成既有商业意义又能被评估为 0.0 或 1.0 的东西。我是怎么选择 800 这个数字的呢?我在数据库中做了这个操作后,凭直觉估计的:

Select 
ProductID,
P.Name,
ProductNumber,
Color,
StandardCost,
ListPrice,
ListPrice - StandardCost as Markup
from [Production].[Product] as P
Inner Join [Production].[ProductSubcategory] as PS
on P.ProductSubcategoryID = PS.ProductSubcategoryID
Where PS.ProductCategoryID = 1
Order by ListPrice - StandardCost

这个 800 的数字通常被称为“附件点”,并且通常是任何逻辑回归模型中最常讨论的部分。在现实世界中,这个数字通常是由小公司的总裁在餐巾纸上随意设定的,或者在大公司,一个跨学科团队可能需要六周时间。要记住的关键点是,你希望这个数字出现在你的config文件中(如果你是在实时运行回归)或者脚本顶部的单独变量中(如果你是临时做这件事)。请注意,为了让我们的脚本更加智能,我们可以在其中注入另一个模型来动态确定附件点,这样就不需要人工更新它,但这将是另一天的练习。

分析逻辑回归的结果

注意,颜色是以随机顺序排列的,并且在客户选择型号之后才出现。如果我们把颜色选项移到第一个选择,让用户进入“黑色思维模式”然后再提供型号,会怎样呢?也许我们还应该把颜色选择调整一下,让黑色位于顶部?

虽然这已经相当不错了,但这本书是关于机器学习的,到目前为止这里几乎没有机器学习的内容(除非你把逻辑回归在确定答案时的方法算作机器学习,我不这么认为)。我们如何随着客户偏好的变化自动更新我们的网站?如果所有时髦的年轻人开始骑银色自行车,我们如何快速利用这一点?机器学习如何比定期运行模型的研究分析师学习得更快?

我们可以像上一章所做的那样,在每一页创建时运行模型,创建模型的评估器,然后填充选择列表。然而,如果你还记得运行所需的时间,这并不是一个最优解,因为当模型运行时,我们的大部分客户可能会放弃网站(尽管如果他们使用的是移动设备,我们总是可以责怪网络连接;开发者以前从未这样做过)。作为替代方案,如果我们创建一个在网站启动时不断运行模型并缓存结果的进程会怎样?这样,每次创建页面时,选择列表背后的数据都将是最新鲜的。让我们进入 Visual Studio 并让它实现。

向应用程序添加逻辑回归

打开 AdventureWorks 解决方案,并转到 AdventureWorks.MachineLearning 项目:

向应用程序添加逻辑回归

添加一个新的 F# 源文件,并将其命名为 ColorPrediction.fs。你会注意到它被放置在项目的底部。在 F# 项目中,文件的顺序很重要,因为这是由于类型推断系统。你可以做的操作是右键单击文件,并将其移动到 .config 文件之上:

向应用程序添加逻辑回归

移动到上方选项

进入 ColorPrediction.fs 文件,并将所有现有代码替换为以下代码:

namespace AdventureWorks.MachineLearning

open Accord
open Accord.Statistics.Filters
open Accord.Statistics.Analysis
open Accord.Statistics.Models.Regression
open Accord.Statistics.Models.Regression.Fitting

open System
open System.Data.SqlClient

接下来,让我们添加在回归项目中创建的类型以及我们需要为此编译的汇编类型。在此过程中,添加 ProductInfos 列表以及回归项目中的连接字符串和查询值:

type ProductInfo = {ProductID:int; Color:string; AvgReviews: float; Markup: float}
type ProductInfo' = {ProductID:int; BlackInd:float; BlueInd:float; RedInd:float; SilverInd:float; OtherInd: float; AvgReviews: float; HighMargin:float}

type public ColorPrediction () = 
    let productInfos = ResizeArray<ProductInfo>()

    [<Literal>]
    let connectionString = "data source=nc54a9m5kk.database.windows.net;initial catalog=AdventureWorks2014;user id=chickenskills@nc54a9m5kk;password=sk1lzm@tter;"

    [<Literal>]
    let query = "Select
        A.ProductID,
        A.Color,
        B.AvgReviews,
        A.MarkUp
        From
        (Select P.ProductID,
          Color,
          ListPrice - StandardCost as Markup
          from [Sales].[SalesOrderDetail] as SOD
            inner join [Sales].[SalesOrderHeader] as SOH
            on SOD.SalesOrderID = SOH.SalesOrderID
            inner join [Sales].[Customer] as C
            on SOH.CustomerID = C.CustomerID
            inner join [Production].[Product] as P
            on SOD.ProductID = P.ProductID
            inner join [Production].[ProductSubcategory] as PS
            on P.ProductSubcategoryID = PS.ProductSubcategoryID
            Where C.StoreID is null
            and PS.ProductCategoryID = 1) as A
        Inner Join
        (Select PR.ProductID,
            (Sum(Rating) + 0.0) / (Count(ProductID) + 0.0) as AvgReviews
            from [Production].[ProductReview] as PR
            Group By ProductID) as B
        on A.ProductID = B.ProductID"

接下来,让我们添加一个将返回按重要性排序的颜色列表的方法,最重要的颜色在顶部:

    member this.GetColors(attachmentPoint) = 
        let connection = new SqlConnection(connectionString)
        let command = new SqlCommand(query, connection)
        connection.Open()
        let reader = command.ExecuteReader()
        while reader.Read() do
            productInfos.Add({ProductID=reader.GetInt32(0);
              Color=(string)(reader.GetString(1));
              AvgReviews=(float)(reader.GetDecimal(2));
              Markup=(float)(reader.GetDecimal(3));})

        let getProductInfo'(productInfo:ProductInfo) =
                {ProductInfo'.ProductID=productInfo.ProductID;
                BlackInd = (match productInfo.Color with | "Black" -> 1.0 | _ -> 0.0);
                BlueInd = (match productInfo.Color with | "Blue" -> 1.0 | _ -> 0.0);
                RedInd = (match productInfo.Color with | "Red" -> 1.0 | _ -> 0.0);
                SilverInd = (match productInfo.Color with | "Silver" -> 1.0 | _ -> 0.0);
                OtherInd = (match productInfo.Color with | "Silver" | "Blue" | "Red" | "Silver" -> 0.0 | _ -> 1.0);
                AvgReviews = productInfo.AvgReviews;
                HighMargin = (match productInfo.Markup > attachmentPoint with | true -> 1.0 | false -> 0.0);}

        let productInfos' = 
            productInfos 
            |> Seq.map (fun pi -> getProductInfo'(pi))
        let xs = 
            productInfos' 
            |> Seq.map (fun pi -> [|pi.BlackInd; pi.BlueInd; pi.RedInd; pi.SilverInd; pi.OtherInd; pi.AvgReviews|])
            |> Seq.toArray
        let 
            y = productInfos' 
            |> Seq.map (fun pi -> pi.HighMargin) 
            |> Seq.toArray

        let colors = [|"Black";"Blue";"Red";"Silver";"Other"|]

        let analysis = new LogisticRegressionAnalysis(xs, y)
        match analysis.Compute() with 
            | true ->
                let coefficientValues = analysis.CoefficientValues |> Seq.skip 1
                let colors' = Seq.zip colors coefficientValues
                colors' |> Seq.mapi (fun i (c,cv) -> c, (abs(cv)/cv), analysis.Regression.GetOddsRatio(i))
                        |> Seq.map (fun (c, s, odr) -> c, s * odr)
                        |> Seq.sortBy (fun (c, odr) -> odr)
                        |> Seq.map (fun (c, odr) -> c)
                        |> Seq.toArray
            | false -> colors

大部分代码与我们在回归项目中做的工作相同,但有一些新的代码需要解释。现在有一个名为 colors 的字符串数组,列出了我们发送给回归的所有颜色。在调用 analysis.Compute() 之后,我们通过以下行从 analysis.CoefficientValues 中移除第一个值:

analysis.CoefficientValues |> Seq.skip 1

Skip 是一个方便的函数,允许我们跳过 Seq 的前几行。我们在这里调用它是因为 analysis.CoefficientValues 返回数组的第一值作为系数。

接下来,我们调用以下内容:

let colors' = Seq.zip colors coefficientValues

我们之前见过 Seq.zip。我们正在将颜色数组和系数值数组粘合在一起,这样每一行就是一个颜色名称及其系数的元组。有了这个数组,我们接下来实现最终的转换管道:

                colors' |> Seq.mapi (fun i (c,cv) -> c, (abs(cv)/cv), analysis.Regression.GetOddsRatio(i+1))
                        |> Seq.map (fun (c, s, odr) -> c, s * odr)
                 |> Seq.sortByDescending (fun (c,odr)-> odr)
                        |> Seq.map (fun (c, odr) -> c)
                        |> Seq.toArray

第一步如下:

|> Seq.mapi(fun i (c,cv) -> c, (abs(cv)/cv), analysis.Regression.GetOddsRatio(i+1))

这对colors应用了一个mapi函数。Seq.mapi是一个高阶函数,它就像带有额外参数的Seq.map函数,该参数是每一行的索引。所以索引i被传递进去,然后是元组(c,cv),即颜色和coefficientValue。我们返回一个包含颜色、一个根据coefficientValue符号的-1 或+1,以及 odds ratio ->的元组,我们根据索引查找它。

下一步如下:

|> Seq.map(fun (c, s, odr) -> c, s * odr)

这应用了另一个返回颜色和有符号 odds ratio 的函数。如果您还记得之前的内容,Regression.GetOddsRatio总是正的。我们应用符号以便我们可以按最可能到最不可能的顺序对比率进行排序。

下一步如下:

|> Seq.sortByDescending(fun (c,odr)-> odr)

这应用了一个根据 odds ratio 对数组进行排序的函数,使得具有最高oddsRatio的元组位于顶部。

接下来的两个步骤将元组转换为简单的字符串。颜色名称然后将我们的Seq转换为数组:

|> Seq.map(fun (c, odr) -> c)
|> Seq.toArray

代码就位后,让我们跳转到我们的 MVC 项目并实现它。找到Global.asax文件并打开它。用以下代码替换代码:

using System;
using System.Collections.Generic;
using System.Configuration;
using System.Linq;
using System.Threading;
using System.Web;
using System.Web.Mvc;
using System.Web.Optimization;
using System.Web.Routing;
using AdventureWorks.MachineLearning;

namespace AdventureWorks
{
    public class MvcApplication : System.Web.HttpApplication
    {
        static Object _lock = new Object();
        Timer _timer = null;
        static String[] _bikeColors = null;

        protected void Application_Start()
        {
            AreaRegistration.RegisterAllAreas();
            FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
            RouteConfig.RegisterRoutes(RouteTable.Routes);
            BundleConfig.RegisterBundles(BundleTable.Bundles);
            _bikeColors = new string[5] { "Red", "White", "Blue", "Black", "Silver" };
            _timer = new Timer(UpdateBikeColors,null,0,TimeSpan.FromMinutes(1).Milliseconds);
        }

        private void UpdateBikeColors(object state)
        {
            var attachmentPoint = Double.Parse(ConfigurationManager.AppSettings["attachmentPoint"]);
            var colorPrediction = new ColorPrediction();
            BikeColors = colorPrediction.GetColors(attachmentPoint);
        }

        public static String[] BikeColors
        {
            get
            {
                lock(_lock)
                {
                    return _bikeColors;
                }
            }
            set
            {
                lock(_lock)
                {
                    _bikeColors = value;
                }
            }
        }
    }
}

这部分代码可能有些新,让我们仔细看看。首先,我们创建了两个类级别变量:_lock_timer。我们使用_lock来防止在回归更新颜色数组时读取颜色数组。您可以在公开属性中看到_lock的作用,该属性公开了颜色数组:

        public static String[] BikeColors
        {
            get
            {
                lock(_lock)
                {
                    return _bikeColors;
                }
            }
            set
            {
                lock(_lock)
                {
                    _bikeColors = value;
                }
            }
        }

接下来,我们创建一个当定时器触发时将被调用的方法:

        private void UpdateBikeColors(object state)
        {
            var attachmentPoint = Double.Parse(ConfigurationManager.AppSettings["attachmentPoint"]);
            var colorPrediction = new ColorPrediction();
            BikeColors = colorPrediction.GetColors(attachmentPoint);
        }

注意我们正在创建我们的ColorPrediction类的实例,然后调用GetColors方法。我们将BikeColors属性分配给最近计算出的解决方案的返回值。

最后,在Application.Start方法中实例化_timer变量,传入当定时器倒计时时的调用方法:

_timer = new Timer(UpdateBikeColors,null,0,TimeSpan.FromMinutes(1).Milliseconds);

这意味着,每分钟我们都会调用颜色预测来运行基于最新数据的逻辑回归。同时,客户会持续调用我们的网站,并根据最新的计算结果获得一系列颜色。

接下来,转到.config文件,并将附件点添加到appSettings部分:

    <add key="attachmentPoint" value="800" />

最后,打开individualOrder控制器,并在Create方法中将颜色的硬编码值替换为我们生成的值:

var colors = MvcApplication.BikeColors;

运行网站,您会看到我们的颜色列表已更改:

将逻辑回归添加到应用中

现在,我们有一个相当不错的模型,似乎在生产环境中运行良好,没有造成重大的性能损失。然而,我们迄今为止的解决方案有一个致命的缺陷。我们犯了过度拟合的错误。正如第二章中提到的,AdventureWorks 回归,过度拟合是指我们创建的模型只适用于我们手头的数据,当我们将它引入新数据时,表现糟糕。每种机器学习技术都存在过度拟合的问题,有一些常见的方法可以减轻其影响。我们将在接下来的章节中探讨这一点。

摘要

在本章中,我们覆盖了大量的内容。我们探讨了多元线性回归、逻辑回归,然后考虑了几种对数据集进行归一化的技术。在这个过程中,我们学习了一些新的 F#代码,并了解了一种在不影响最终用户体验的情况下更新机器学习模型的方法。

在下一章中,我们将从 AdventureWorks 和业务线开发中暂时休息一下,开始作为数据科学家使用决策树处理一些公开数据。正如迪克·克拉克曾经说过,“好歌源源不断。”

第四章:交通拦截——是否走错了路?

在前两章中,你是一位将机器学习注入现有业务应用线的软件开发者。在这一章中,我们将戴上研究分析师的帽子,看看我们能否从现有数据集中发现一些隐藏的见解。

科学过程

研究分析师历史上遵循以下发现和分析模式:

科学过程

随着数据科学家的兴起,该工作流程已转变为类似以下的形式:

科学过程

注意到在报告模型结果之后,工作并没有结束。相反,数据科学家通常负责将工作模型从他们的桌面移动到生产应用中。随着这项新责任的出现,正如蜘蛛侠所说,数据科学家的技能集变得更加广泛,因为他们必须理解软件工程技术,以配合他们的传统技能集。

数据科学家牢记的一件事是以下这个工作流程。在“测试与实验”块内部,有如下内容:

科学过程

在时间花费方面,与其它块相比,“数据清洗”块非常大。这是因为大部分工作努力都花在了数据获取和准备上。历史上,大部分数据整理工作都是处理缺失、格式不正确和不合逻辑的数据。传统上,为了尽量减少这一步骤的工作量,人们会创建数据仓库,并在数据从源系统传输到仓库(有时称为提取、转换、加载或 ETL 过程)时对其进行清洗。虽然这有一些有限的成效,但这是一个相当昂贵的举措,固定的架构意味着变化变得特别困难。最近在这个领域的一些努力围绕着以原生格式收集数据,将其倒入“数据湖”中,然后在湖上构建针对数据原生格式和结构的特定作业。有时,这被称为“将数据放入矩形中”,因为您可能正在处理非结构化数据,对其进行清洗和汇总,然后以二维数据框的形式输出。这个数据框的力量在于您可以将它与其他数据框结合起来进行更有趣的分析。

开放数据

与大数据和机器学习相一致的最激动人心的公民运动之一是 开放数据。尽管围绕它的炒作并不多,但它是在数据科学领域非常激动人心且重要的变革。开放数据的前提是,如果地方政府、州政府和联邦政府将他们目前以 RESTful 格式维护的公共数据公开,他们将变得更加负责任和高效。目前,大多数政府机构可能只有纸质记录,或者为了输出一个临时的查询而收取一笔相当大的费用,或者偶尔有一个 FTP 站点,上面有一些 .xls.pdf 文件,这些文件会不时更新。开放数据运动将相同的数据(如果不是更多)放在一个可以被应用程序和/或研究分析师消费的 web 服务上。关键在于数据的安全性和隐私。历史上,一些政府机构通过保密来实施安全(我们有在线记录,但唯一获取它的方式是通过我们的定制前端),而开放数据使得这种防御变得过时。说实话,保密式的安全从未真正起作用(编写一个屏幕抓取器有多难?)而且它真正做的只是让有良好意图的人更难实现他们的目标。

开放数据的兴起也与一群为了公民利益而黑客攻击的人的形成相吻合。有时这些是围绕单一技术栈的临时聚会小组,而其他小组则更加正式。例如,Code for America 在世界各地的许多城市都有 支队。如果你对帮助当地分会感兴趣,你可以在他们的网站上找到信息 www.codeforamerica.org/

Hack-4-Good

让我们假设我们是虚构组织“Hack-4-Good”的一个地方分会的成员。在最近的会议上,领导人宣布:“通过公开记录请求,我们已经获得了我们镇上所有的交通拦截信息。有人知道如何处理这个数据集吗?”你立刻举手说:“当然,宝贝!”好吧,你可能不会用那些确切的话说,但你的热情是显而易见的。

由于你受过研究分析师的培训,你首先想要做的是将数据加载到你的集成开发环境(IDE)中,并开始探索数据。

打开 Visual Studio 并创建一个名为 Hack4Good.TrafficStop.Solution 的新解决方案:

Hack-4-Good

在解决方案中添加一个新的 F# 库项目:

Hack-4-Good

FsLab 和类型提供者

现在项目框架已经设置好了,打开 Script.fsx 文件并删除其所有内容。接下来,让我们看看一个非常棒的库 FsLab (fslab.org/))。转到 NuGet 包管理器控制台并输入以下内容:

PM> Install-Package fslab

接下来,我们将安装 SqlClient,以便我们可以访问我们的数据。转到 NuGet 包管理器并输入:

PM> Install-Package FSharp.Data.SqlClient

在完成仪式之后,让我们开始编码。首先,让我们将交通罚单数据集引入到我们的脚本中。进入Script.fsx并在顶部输入以下内容:

#load "../packages/FsLab.0.3.11/FsLab.fsx"

你应该会从 Visual Studio 得到一系列看起来像这样的对话框:

FsLab 和类型提供者

点击启用。作为一个一般性的观点,每次当你从 Visual Studio 得到这样的对话框时,点击启用。例如,根据我们机器的配置,当你运行以下open语句时,你可能会得到这些对话框。

在我们的脚本中,输入以下内容:

#r "System.Data.Entity.dll"
#r "FSharp.Data.TypeProviders.dll"
#r "System.Data.Linq.dll"

open System
open Foogle
open Deedle
open FSharp.Data
open FSharp.Charting
open System.Data.Linq
open System.Data.Entity
open Microsoft.FSharp.Data.TypeProviders

接下来,将以下内容输入到脚本中:

[<Literal>]
let connectionString = "data source=nc54a9m5kk.database.windows.net;initial catalog=Traffic;user id=chickenskills@nc54a9m5kk;password=sk1lzm@tter;"

type EntityConnection = SqlEntityConnection<connectionString,Pluralize = true>
let context = EntityConnection.GetDataContext()
context.dbo_TrafficStops |> Seq.iter(fun ts -> printfn "%s" ts.StreetAddress)

第一行应该看起来很熟悉;它是一个连接字符串,就像我们在上一章中使用的那样。唯一的区别是数据库。但下一行代码是什么意思呢?

type EntityConnection = SqlEntityConnection<connectionString,Pluralize = true>

这是一个类型提供者的例子。类型提供者是 F#的最好特性之一,并且是语言独有的。我喜欢把类型提供者想象成加强版的对象关系映射ORM)。这个类型提供者正在检查数据库,并为我生成 F#类型,以便在 REPL 中使用——我们将在下一秒看到它的实际应用。事实上,类型提供者位于Entity FrameworkEF)之上,而 EF 又位于 ADO.NET 之上。如果你对从手动编写 ADO.NET 代码到 EF 的转变感到兴奋,那么你应该同样对使用类型提供者能提高多少生产力感到兴奋;这真的是下一代数据访问。另一个酷的地方是,类型提供者不仅适用于关系数据库管理系统——还有 JSON 类型提供者、.csv类型提供者以及其他。你可以在msdn.microsoft.com/en-us/library/hh156509.aspx了解更多关于类型提供者的信息,一旦你看到它们在实际中的应用,你将发现它们在你的编码任务中是不可或缺的。

回到代码。下一行是:

let context = EntityConnection.GetDataContext()

它创建了将要使用的类型的实际实例。下一行是实际操作的地方:

context.dbo_TrafficStops |> Seq.iter(fun ts -> printfn "%s" ts.StreetAddress)

在这一行中,我们正在遍历TrafficStop表并打印出街道地址。如果你将脚本中的所有代码高亮显示并发送到 REPL,你将看到 30,000 个地址的最后部分:

128 SW MAYNARD RD/KILMAYNE DR
1 WALNUT ST TO US 1 RAMP NB/US 1 EXIT 101 RAMP NB
2333 WALNUT ST
1199 NW MAYNARD RD/HIGH HOUSE RD
3430 TEN TEN RD

val connectionString : string =
 "data source=nc54a9m5kk.database.windows.net;initial catalog=T"+[61 chars]
type EntityConnection =
 class
 static member GetDataContext : unit -> EntityConnection.ServiceTypes.SimpleDataContextTypes.EntityContainer
 + 1 overload
 nested type ServiceTypes
 end
val context :
 EntityConnection.ServiceTypes.SimpleDataContextTypes.EntityContainer
val it : unit = ()

在我们继续前进之前,我想提一下类型提供者有多么酷。只需三行代码,我就定义了一个数据库模式,连接到它,然后拉取了记录。不仅如此,数据库的结果集是IEnumerable。所以,我在前几章中用Seq所做的所有转换和数据处理,我都可以在这里完成。

数据探索

利用这种新发现的力量,让我们开始探索。将以下内容输入到脚本中:

context.dbo_TrafficStops |> Seq.head

将其发送到 REPL,我们将看到以下内容:

val it : EntityConnection.ServiceTypes.dbo_TrafficStops =
 SqlEntityConnection1.dbo_TrafficStops
 {CadCallId = 120630019.0;
 DispositionDesc = "VERBAL WARNING";
 DispositionId = 7;
 EntityKey = System.Data.EntityKey;
 EntityState = Unchanged;
 Id = 13890;
 Latitude = 35.7891;
 Longitude = -78.8289;
 StopDateTime = 6/30/2012 12:36:38 AM;
 StreetAddress = "4348 NW CARY PKWY/HIGH HOUSE RD";}
>

我们可以看到我们的数据框中有些有趣的分析元素:交通停车的日期和时间、交通停车的地理坐标以及停车的最终处理结果。我们还有一些似乎对分析没有用的数据:CadCallId可能是源系统的主键。这可能对以后的审计有用。我们还有StreetAddress,它与地理坐标相同,但形式不太适合分析。最后,我们还有一些由 Entity Framework(EntityKeyEntityStateId)添加的字段。

让我们只包含我们关心的字段创建一个数据框。将以下内容输入到脚本中:

let trafficStops = 
    context.dbo_TrafficStops 
    |> Seq.map(fun ts -> ts.StopDateTime, ts.Latitude, ts.Longitude, ts.DispositionId)

将它发送到 REPL,我们得到这个:

val trafficStops :
 seq<System.Nullable<System.DateTime> * System.Nullable<float> *
 System.Nullable<float> * System.Nullable<int>>

>

很有趣,尽管 F#非常、非常试图阻止你使用 null,但它确实支持它。实际上,我们所有的四个字段都是可空的。我会在本章稍后向你展示如何处理 null,因为它们在编码时经常是一个大麻烦。

在深入分析之前,我们还需要创建一个额外的数据框。一般来说,我们使用的机器学习模型更喜欢原始类型,如整数、浮点数和布尔值。它们在处理字符串时遇到困难,尤其是表示分类数据的字符串。你可能已经注意到,我把DispositionId放到了trafficStops数据框中,而没有放DispositionDesc。然而,我们仍然不想丢失这个描述,因为我们可能稍后需要引用它。让我们为这个查找数据创建一个单独的数据框。在脚本中输入以下内容:

let dispoistions =
    context.dbo_TrafficStops 
    |> Seq.distinctBy(fun ts -> ts.DispositionId, ts.DispositionDesc)   
    |> Seq.map (fun d -> d.DispositionId, d.DispositionDesc)
    |> Seq.toArray

然后将它发送到 REPL 以获取这个:

val dispoistions : (System.Nullable<int> * string) [] =
 [|(7, "VERBAL WARNING"); (15, "CITATION"); (12, "COMPLETED AS REQUESTED");
 (4, "WRITTEN WARNING"); (13, "INCIDENT REPORT"); (9, "ARREST");
 (14, "UNFOUNDED"); (19, "None Provided");
 (10, "NO FURTHER ACTION NECESSARY"); (5, "OTHER    SEE NOTES");
 (2, "UNABLE TO LOCATE"); (16, "FIELD CONTACT");
 (6, "REFERRED TO PROPER AGENCY"); (17, "BACK UP UNIT");
 (11, "CIVIL PROBLEM"); (1, "FURTHER ACTION NECESSARY"); (3, "FALSE ALARM");
 (18, "CITY ORDINANCE VIOLATION")|]

>

看看代码,我们有一些新东西。首先,我们使用了高阶函数Seq.distinctBy,你可能猜得到它返回具有在参数中指定的不同值的记录。有趣的是,整个交通停车记录被返回,而不仅仅是 lambda 中的值。如果你想知道 F#如何选择代表不同处理结果的记录,你必须归功于魔法。好吧,也许不是。当它遍历数据框时,F#选择了第一个具有新唯一DispositionIDDispositionDesc值的记录。无论如何,因为我们只关心DispositionIdDispositionDesc,所以我们在这个代码行中将交通停车记录映射到一个元组上:Seq.map (fun d -> d.DispositionId, d.DispositionDesc。到现在你应该已经很熟悉了。

在我们的数据框设置好之后,让我们开始深入挖掘数据。拥有DateTime值的一个好处是它代表了可能值得探索的许多不同因素。例如,按月有多少交通停车?关于一周中的哪一天呢?在停车中是否存在时间因素?是晚上发生的事情更多还是白天?让我们开始编写一些代码。转到脚本并输入以下代码块:

let months = 
    context.dbo_TrafficStops
    |> Seq.groupBy (fun ts -> ts.StopDateTime.Value.Month)
    |> Seq.map (fun (m, ts) -> m, Seq.length ts)
    |> Seq.sortBy (fun (m, ts) -> m)
    |> Seq.toArray

将它发送到 REPL,你应该看到这个:

val months : (int * int) [] =
 [|(1, 2236); (2, 2087); (3, 2630); (4, 2053); (5, 2439); (6, 2499);
 (7, 2265); (8, 2416); (9, 3365); (10, 1983); (11, 2067); (12, 1738)|]

>

只看一眼就能发现,在九月有大量的交通拦截行动,而十二月看起来是一个淡季。深入代码,我使用了一个新的高阶函数:

|> Seq.groupBy (fun ts -> ts.StopDateTime.Value.Month)

groupBy是一个非常强大的函数,但第一次使用时可能会有些困惑(至少对我来说是这样的)。我通过反向工作并查看简单数组的输出,更好地理解了groupBy。进入脚本文件并输入以下内容:

let testArray = [|1;1;2;3;4;5;3;4;5;5;2;1;5|]
testArray |> Array.groupBy (id)

将这些信息发送到 REPL 会得到以下结果:

val testArray : int [] = [|1; 1; 2; 3; 4; 5; 3; 4; 5; 5; 2; 1; 5|]
val it : (int * int []) [] =
 [|(1, [|1; 1; 1|]); (2, [|2; 2|]); (3, [|3; 3|]); (4, [|4; 4|]);
 (5, [|5; 5; 5; 5|])|]

你会注意到输出是一个元组。元组的第一个元素是groupBy对数据进行分组的值。下一个元素是一个子数组,只包含与元组第一个元素匹配的原始数组中的值。深入查看(1, [|1; 1; 1|]),我们可以看到数字 1 是groupBy的值,原始数组中有三个 1。groupBy也可以应用于记录类型。考虑以下数据框。从左到右,列是USStateGenderYearOfBirthNameGivenNumberOfInstances

USState Gender YearOfBirth NameGiven NumberOfInstances
AK F 1910 Annie 12
AK F 1910 Anna 10
AK F 1910 Margaret 8
AL F 1910 Annie 90
AL F 1910 Anna 88
AL F 1910 Margaret 86
AZ F 1910 Annie 46
AZ F 1910 Anna 34
AZ F 1910 Margaret 12

NameGiven应用groupBy到这个数据框会得到以下输出:

fst snd
Annie AK
AL
AZ
Anna AK
AL
AZ
Margaret AK
AL
AZ

使用元组的fst,即NameGiven,以及snd是一个只包含与fst匹配的记录的数据框。

让我们继续下一行代码|> Seq.map (fun (m, ts) -> m, ts |> Seq.length)

我们可以看到,我们正在将原始的月份和trafficStops元组映射到一个新的元组,该元组由月份和原始元组的snd的数组长度组成。这实际上将我们的数据减少到一个长度为 12 的序列(每个月一个)。fst是月份,snd是发生的拦截次数。接下来我们按月份排序,然后将其推送到一个数组中。

设置了这个模式后,让我们再进行几个groupBy操作。让我们做DayDayOfWeek。进入脚本并输入以下内容:

let dayOfMonth = 
    context.dbo_TrafficStops
    |> Seq.groupBy (fun ts -> ts.StopDateTime.Value.Day)
    |> Seq.map (fun (d, ts) -> d, Seq.length ts)
    |> Seq.sortBy (fun (d, ts) -> d)
    |> Seq.toArray

let weekDay = 
    context.dbo_TrafficStops
    |> Seq.groupBy (fun ts -> ts.StopDateTime.Value.DayOfWeek)
    |> Seq.map (fun (dow, ts) -> dow, Seq.length ts)
    |> Seq.sortBy (fun (dow, ts) -> dow)
    |> Seq.toArray

你会注意到与我们刚刚进行的月度分析相比有一个细微的变化——|> Seq.map (fun (dow, ts) -> dow, Seq.length ts)在获取snd的长度时语法有所不同。我写的是Seq.length ts而不是ts |> Seq.length。这两种风格在 F#中都是完全有效的,但后者被认为更符合习惯用法。我将在本书中更频繁地使用这种风格。

所以一旦我们将其发送到 REPL,我们可以看到:

val dayOfMonth : (int * int) [] =
 [|(1, 918); (2, 911); (3, 910); (4, 941); (5, 927); (6, 840); (7, 940);
 (8, 785); (9, 757); (10, 805); (11, 766); (12, 851); (13, 825); (14, 911);
 (15, 977); (16, 824); (17, 941); (18, 956); (19, 916); (20, 977);
 (21, 988); (22, 906); (23, 1003); (24, 829); (25, 1036); (26, 1031);
 (27, 890); (28, 983); (29, 897); (30, 878); (31, 659)|]

val weekDay : (System.DayOfWeek * int) [] =
 [|(Sunday, 3162); (Monday, 3277); (Tuesday, 3678); (Wednesday, 4901);
 (Thursday, 5097); (Friday, 4185); (Saturday, 3478)|]

看着结果,我们应该很清楚我们在做什么。每个月的 25 号看起来是大多数流量停止发生的日子,而星期四确实有很多停止。我想知道如果某个月的 25 号恰好是星期四会发生什么?

在我们深入数据之前,我想指出,最后三个代码块非常相似。它们都遵循这个模式:

let weekDay = 
   context.dbo_TrafficStops
    |> Seq.groupBy (fun ts -> ts.StopDateTime.Value.XXXXX)
    |> Seq.map (fun (fst, snd) -> fst, Seq.length snd)
    |> Seq.sortBy (fun (fst, snd) -> fst)
    |> Seq.toArray

而不是有三个几乎完全相同的代码块,我们能否将它们合并成一个函数?是的,我们可以。如果我们写一个这样的函数:

let transform grouper mapper =
    context.dbo_TrafficStops 
    |> Seq.groupBy grouper
             |> Seq.map mapper
                             |> Seq.sortBy fst 
                             |> Seq.toArray

然后我们这样调用它:

transform (fun ts -> ts.StopDateTime.Value.Month) (fun (m, ts) -> m, Seq.length ts)
transform (fun ts -> ts.StopDateTime.Value.Day) (fun (d, ts) -> d, Seq.length ts)
transform (fun ts -> ts.StopDateTime.Value.DayOfWeek) (fun (dow, ts) -> dow, Seq.length ts)

这会起作用吗?当然。将其发送到 REPL,我们可以看到我们得到了相同的结果:

val transform :
 grouper:(EntityConnection.ServiceTypes.dbo_TrafficStops -> 'a) ->
 mapper:('a * seq<EntityConnection.ServiceTypes.dbo_TrafficStops> ->
 'b * 'c) -> ('b * 'c) [] when 'a : equality and 'b : comparison
val it : (System.DayOfWeek * int) [] =
 [|(Sunday, 3162); (Monday, 3277); (Tuesday, 3678); (Wednesday, 4901);
 (Thursday, 5097); (Friday, 4185); (Saturday, 3478)|]

来自 C#和 VB.NET 的你们中的一些人可能会对转换的接口感到非常不舒服。你可能更习惯于这种语法:

let transform (grouper, mapper) =

()和逗号让它看起来更像是 C#和 VB.NET。尽管两者在 F#中都是完全有效的,但这又是另一个被认为更符合习惯用法的地方,即移除括号和逗号。我将在本书中更频繁地使用这种风格。

此外,请注意,我将两个函数传递给了转换函数。这与我们通常将数据传递给方法的命令式 C#/VB.NET 非常不同。我注意到函数式编程更多的是将操作带到数据上,而不是将数据带到操作上,一旦我们开始将机器学习应用于大数据,这具有深远的影响。

回到我们的转换函数,我们可以看到在三次调用中mapper函数几乎是一样的:(fun (dow, ts) -> dow, Seq.length ts)。唯一的区别是我们给元组的第一个部分取的名字。这似乎是我们可以进一步合并代码的另一个绝佳地方。让我们这样重写转换函数:

let transform grouper  =
    context.dbo_TrafficStops 
    |> Seq.groupBy grouper
    |> Seq.map (fun (fst, snd) -> fst, Seq.length snd)	
    |> Seq.sortBy fst 
    |> Seq.toArray

transform (fun ts -> ts.StopDateTime.Value.Month) 
transform (fun ts -> ts.StopDateTime.Value.Day) 
transform (fun ts -> ts.StopDateTime.Value.DayOfWeek)

然后将它发送到 REPL,我们得到这个:

val transform :
 grouper:(EntityConnection.ServiceTypes.dbo_TrafficStops -> 'a) ->
 ('a * int) [] when 'a : comparison

> 

val it : (System.DayOfWeek * int) [] =
 [|(Sunday, 3162); (Monday, 3277); (Tuesday, 3678); (Wednesday, 4901);
 (Thursday, 5097); (Friday, 4185); (Saturday, 3478)|]

真的很酷,不是吗?我们将在本书中越来越多地做这种编程。一旦你掌握了它,你将开始在代码中看到以前从未见过的模式,你又有了一个强大的工具箱中的新工具(就像我混合了隐喻)。

既然你现在对groupBy已经熟悉了,我想重写我们的转换函数,弃用它。与其使用groupBymap函数,不如用countBy高阶函数来重写它。在此过程中,让我们将我们的函数重命名为一个更具意图性的名称。将以下内容输入到脚本中:

let getCounts counter =
    context.dbo_TrafficStops 
    |> Seq.countBy counter
    |> Seq.sortBy fst 
    |> Seq.toArray

getCounts (fun ts -> ts.StopDateTime.Value.DayOfWeek)

将此发送到 REPL,我们得到相同的值:

val getCounts :
 counter:(EntityConnection.ServiceTypes.dbo_TrafficStops -> 'a) ->
 ('a * int) [] when 'a : comparison
val it : (System.DayOfWeek * int) [] =
 [|(Sunday, 3162); (Monday, 3277); (Tuesday, 3678); (Wednesday, 4901);
 (Thursday, 5097); (Friday, 4185); (Saturday, 3478)|]

可视化

在 REPL 中查看数据是一个好的开始,但图片是更强大和有效的信息传达方式。例如,交通检查站是否有某种月度季节性?让我们将数据放入图表中,以找出答案。在您的脚本中输入以下内容:

let months' = Seq.map (fun (m,c) -> string m,c) months
Chart.LineChart months'

您的 REPL 应该看起来像这样:

val months' : seq<string * int>
val it : FoogleChart = (Foogle Chart)

>

您的默认浏览器应该正在尝试打开并显示以下内容:

可视化

因此,我们看到 9 月份的峰值和 12 月份的下降,这已经引起了我们的注意。如果日期/时间有一些奇怪的规律,那么地理位置呢?将以下内容输入到脚本文件中:

let locations = 
    context.dbo_TrafficStops 
    |> Seq.filter (fun ts -> ts.Latitude.HasValue && ts.Longitude.HasValue )
    |> Seq.map (fun ts -> ts.StreetAddress, ts.Latitude.Value, ts.Longitude.Value)
    |> Seq.map (fun (sa,lat,lon) -> sa, lat.ToString(), lon.ToString())
    |> Seq.map (fun (sa,lat,lon) -> sa, lat + "," + lon)
    |> Seq.take 2
    |> Seq.toArray

Chart.GeoChart(locations,DisplayMode=GeoChart.DisplayMode.Markers,Region="US")

可视化

并没有太多帮助。问题是 FsLab geomap 覆盖了 Google 的geoMap API,而这个 API 只到国家层面。因此,我们不是使用 FsLab,而是可以自己实现。这是一个相当复杂的过程,使用了 Bing 地图、WPF 依赖属性等,所以我在这本书中不会解释它。代码可以在我们网站的下载部分供您查阅。所以,闭上眼睛,假装我花在上的最后 3 个小时在 2 秒钟内就过去了,我们有了这张地图:

可视化

那么,我们一开始能告诉什么?到处都有交通检查站,尽管它们似乎集中在主要街道上。根据初步分析,"速度陷阱"这个词可能更多关于月份、日期和时间,而不是位置。此外,我们不能从这个地图中得出太多结论,因为我们不知道交通模式——更多的检查站可能位于更繁忙的街道上,或者可能是交通检查站的关键区域的指标。为了帮助我们更深入地挖掘数据,让我们从简单的描述性统计转向应用一种常见的机器学习技术,称为决策树。

决策树

决策树的原则是这样的:你可以使用树状结构进行预测。以下是一个关于我们今天是否打网球的地方性例子:

决策树

每个决策被称为一个节点,最终结果(在我们的例子中,是/框)被称为叶子。与树的类比是相当恰当的。事实上,我会称它为一个决策分支,每个决策称为一个拐角,最终结果被称为叶子。然而,J.R. Quinlan 在 1986 年发明这种方法时并没有问我。无论如何,树的高度是指树的水平层数。在我们之前的例子中,树的最大高度为两层。对于给定点的可能节点被称为特征。在我们之前的例子中,Outlook 有三个特征(晴朗、多云和雨)和 Strong Wind 有两个特征(是和否)。

决策树的一个真正的好处是它传达信息的简单性。当节点数量较少且移动到下一个节点的计算简单时,人类经常进行心理决策树。(我应该点脱咖啡还是普通咖啡?我今晚需要学习吗?)当有大量节点且计算复杂时,计算机就派上用场了。例如,我们可以给计算机提供大量历史数据,这些数据来自决定打网球的人,它可以确定,对于晴天,实际的决策点不是 30°,而是 31.2°。不过,需要注意的是,随着特征数量的增加和深度的增大,决策树往往变得不那么有意义。我们稍后会看看如何处理这个问题。

Accord

让我们用我们的交通停车数据创建一个决策树。回到 Visual Studio,打开 解决方案资源管理器,添加一个名为 Accord.fsx 的新脚本。将以下内容输入到脚本中:

#r "System.Data.Entity.dll"
#r "FSharp.Data.TypeProviders.dll"
#r "System.Data.Linq.dll"

open System
open System.Data.Linq
open System.Data.Entity
open Microsoft.FSharp.Data.TypeProviders

[<Literal>]
let connectionString = "data source=nc54a9m5kk.database.windows.net;initial catalog=Traffic;user id=chickenskills@nc54a9m5kk;password=sk1lzm@tter;"

type EntityConnection = SqlEntityConnection<connectionString,Pluralize = true>
let context = EntityConnection.GetDataContext()

这段代码与你在 Script.fsx 中使用的代码相同。发送到 REPL 以确保你正确地复制粘贴了:

val connectionString : string =
 "data source=nc54a9m5kk.database.windows.net;initial catalog=T"+[61 chars]
type EntityConnection =
 class
 static member GetDataContext : unit -> EntityConnection.ServiceTypes.SimpleDataContextTypes.EntityContainer
 + 1 overload
 nested type ServiceTypes
 end
val context :
 EntityConnection.ServiceTypes.SimpleDataContextTypes.EntityContainer

>

接下来,打开 NuGet 包管理器并输入以下命令:

PM> Install-Package Accord.MachineLearning

回到脚本中,输入以下内容:

#r "../packages/Accord.3.0.2/lib/net40/Accord.dll"
#r "../packages/Accord.MachineLearning.3.0.2/lib/net40/Accord.MachineLearning.dll"
#r "../packages/Accord.Statistics.3.0.2/lib/net40/Accord.Statistics.dll"
open Accord
open Accord.MachineLearning
open Accord.MachineLearning.DecisionTrees
open Accord.MachineLearning.DecisionTrees.Learning

解决了这个问题之后,让我们创建一个可以传递给 Accord 的数据结构。正如我之前提到的,决策树通常会有大量特征的问题。一种常见的缓解方法是数据分组。当你分组数据时,你将原始数据放入大组中。例如,我们可以将所有交通停车的所有时间分组到上午或下午,具体取决于它们是在中午之前还是之后发生的。分组是数据科学中常用的技术——有时是合理地使用,有时只是为了使模型符合期望的输出。

回到我们的脚本中,为我们的决策树创建以下记录类型:

type TrafficStop = {Month:int; DayOfWeek:DayOfWeek; AMPM: string; ReceviedTicket: bool option }

你会看到我创建了两个数据集。第一个被称为 AMPM,它是用于停车时间的。第二个被称为 ReceviedTicket,作为一个布尔值。如果你记得,处置有 18 种不同的值。我们只关心这个人是否收到了罚单(称为传票),所以我们把传票分类为真,非传票分类为假。还有一件事你可能注意到了——ReceivedTicket 并不是一个简单的布尔值,它是一个布尔选项。你可能还记得,F# 实际上并不喜欢空值。尽管它可以支持空值,但 F# 更鼓励你使用一种称为选项类型的东西来代替。

选项类型可以有两个值:Some<T>None。如果你不熟悉 Some<T> 的语法,这意味着 Some 只限于一种类型。因此,你可以写 Some<bool>Some<int>Some<string>。使用选项类型,你可以验证一个字段是否有你关心的值:SomeNone。不仅如此,编译器强制你明确选择。这种编译器检查迫使你明确值,这是一个极其强大的结构。确实,这也是 F# 代码通常比其他语言更少出现错误的原因之一,因为它迫使开发者尽早面对问题,并防止他们将问题掩盖起来,放入一个可能被意外忽略的 null 中。

回到我们的代码,让我们编写两个函数,将我们的原始数据进行分类:

let getAMPM (stopDateTime:System.DateTime) =
    match stopDateTime.Hour < 12 with
    | true -> "AM"
    | false -> "PM"

let receviedTicket (disposition:string) =
    match disposition.ToUpper() with
    | "CITATION" -> Some true
    | "VERBAL WARNING" | "WRITTEN WARNING" -> Some false
    | _ -> None

将其发送到 REPL,我们看到:

val getAMPM : stopDateTime:DateTime -> string
val receviedTicket : disposition:string -> bool option

注意,ReceivedTicket 返回三种可能性,使用选项类型:Some trueSome falseNone。我没有将其他处理值包含在 Some falseNone 之间,是因为我们只关注交通违规,而不是警察可能停车的原因。这种过滤在数据科学中经常被用来帮助使数据集与我们要证明的内容相一致。我们在这里不深入讨论过滤,因为关于如何处理异常值和非规范数据,已经有整本书的讨论。

回到我们的代码。让我们从数据库中取出数据,并将其放入我们的 TrafficStop 记录类型中。进入脚本并输入以下内容:

let dataFrame = context.dbo_TrafficStops
                |> Seq.map (fun ts -> {Month=ts.StopDateTime.Value.Month;DayOfWeek=ts.StopDateTime.Value.DayOfWeek;
                                      AMPM=getAMPM(ts.StopDateTime.Value); ReceviedTicket= receviedTicket(ts.DispositionDesc) })
                |> Seq.filter (fun ts -> ts.ReceviedTicket.IsSome)
                |> Seq.toArray

将此发送到 REPL,我们看到数据框中所有记录的最后部分:

 {Month = 7;
 DayOfWeek = Sunday;
 AMPM = "PM";
 ReceviedTicket = Some false;}; {Month = 7;
 DayOfWeek = Sunday;
 AMPM = "PM";
 ReceviedTicket = Some false;}; ...|]

>

数据形状大致确定后,让我们为 Accord 准备它。如我之前提到的, Accord 需要决策树输入数据为 int[][],输出为 int[]。然而,它还需要对输入进行标记以使模型工作。我们通过传递属性数组来实现这一点。回到脚本文件,添加以下代码块:

let month = DecisionVariable("Month",13)
let dayOfWeek = DecisionVariable("DayOfWeek",7)
let ampm = DecisionVariable("AMPM",2)

let attributes =[|month;dayOfWeek;ampm|]
let classCount = 2 

将此发送到 REPL,我们看到:

val month : Accord.MachineLearning.DecisionTrees.DecisionVariable
val dayOfWeek : Accord.MachineLearning.DecisionTrees.DecisionVariable
val ampm : Accord.MachineLearning.DecisionTrees.DecisionVariable
val attributes : Accord.MachineLearning.DecisionTrees.DecisionVariable [] =
 [|Accord.MachineLearning.DecisionTrees.DecisionVariable;
 Accord.MachineLearning.DecisionTrees.DecisionVariable;
 Accord.MachineLearning.DecisionTrees.DecisionVariable|]
val classCount : int = 2

一些细心的读者可能会注意到,月份决策变量有 13 个范围而不是 12 个。这是因为月份的值是 1-12,Accord 需要 13 来考虑任何特征值可能高达 13 的可能性(如 12.99——我们知道这不会存在,但 Accord 不这么认为)。星期几是 0 到 6,所以它得到一个 7

因此,回到我们的脚本,添加以下代码块:

let getAMPM' (ampm: string) =
    match ampm with
    | "AM" -> 0
    | _ -> 1

let receivedTicket' value =
    match value with
    | true -> 1
    | false -> 0

let inputs = 
    dataFrame 
    |> Seq.map (fun ts -> [|(ts.Month); int ts.DayOfWeek; getAMPM'(ts.AMPM)|])
    |> Seq.toArray

let outputs = 
    dataFrame 
    |> Seq.map (fun ts -> receivedTicket'(ts.ReceviedTicket.Value))
    |> Seq.toArray

将此发送到 REPL,我们得到数据框的末尾被转换为 int 数组:

 [|7; 0; 0|]; [|7; 0; 0|]; [|7; 0; 0|]; [|7; 0; 0|]; [|7; 0; 0|];
 [|7; 0; 0|]; [|7; 0; 0|]; [|7; 0; 0|]; [|7; 0; 0|]; [|7; 0; 0|];
 [|7; 0; 1|]; [|7; 0; 1|]; [|7; 0; 1|]; [|7; 0; 1|]; [|7; 0; 1|];
 [|7; 0; 1|]; [|7; 0; 1|]; [|7; 0; 1|]; [|7; 0; 1|]; [|7; 0; 1|];
 [|7; 0; 1|]; [|7; 0; 1|]; [|7; 0; 1|]; [|7; 0; 1|]; [|7; 0; 1|];
 [|7; 0; 1|]; [|7; 0; 1|]; [|7; 0; 1|]; [|7; 0; 1|]; [|7; 0; 1|]; ...|]
val outputs : int [] =
 [|0; 1; 0; 1; 0; 0; 1; 0; 0; 0; 0; 0; 0; 1; 0; 0; 0; 0; 0; 0; 1; 1; 1; 0; 1;
 0; 0; 0; 0; 0; 1; 0; 0; 0; 0; 0; 1; 1; 1; 0; 1; 1; 0; 1; 0; 0; 1; 0; 0; 0;
 0; 0; 0; 1; 0; 1; 0; 0; 0; 0; 1; 0; 0; 0; 0; 0; 0; 0; 0; 0; 1; 1; 0; 0; 0;
 1; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 1; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0;
 ...|]

>

一切准备就绪后,让我们继续运行我们的树。将以下内容输入到脚本中:

let tree = DecisionTree(attributes, classCount)
let id3learning = ID3Learning(tree)
let error = id3learning.Run(inputs, outputs)

将此发送到 REPL 给我们:

val error : float = 0.2843236362

就像我们迄今为止看到的所有其他模型一样,我们需要模型的输出以及一些关于我们的模型基于提供的数据预测效果如何的信息。在这种情况下,模型的误差为 28%,对于一个决策树来说相当高。模型创建完成后,我们现在可以要求树预测在十月的周六晚上我们会收到罚单还是警告。

输入以下脚本:

let query = ([|10;6;1|])
let output = tree.Compute(query) 

将其发送到 REPL,我们看到:

val query : int [] = [|10; 6; 1|]
val output : int = 0

看起来我们会收到警告而不是罚单。

正如我提到的,28%对于一个决策树来说相当高。有没有办法降低这个数字?也许分类会有所帮助。回到 REPL 并输入以下内容:

dataFrame 
    |> Seq.countBy (fun ts -> ts.Month) 
    |> Seq.sort
    |> Seq.iter (fun t ->  printfn "%A" t)

dataFrame 
    |> Seq.countBy (fun ts -> ts.DayOfWeek) 
    |> Seq.sort
    |> Seq.iter (fun t ->  printfn "%A" t)

dataFrame 
    |> Seq.countBy (fun ts -> ts.AMPM) 
    |> Seq.sort
    |> Seq.iter (fun t ->  printfn "%A" t)

dataFrame 
    |> Seq.countBy (fun ts -> ts.ReceviedTicket) 
    |> Seq.sort
    |> Seq.iter (fun t ->  printfn "%A" t)

将其发送到 REPL,我们看到:

(1, 2125)
(2, 1992)
(3, 2529)
(4, 1972)
(5, 2342)
(6, 2407)
(7, 2198)
(8, 2336)
(9, 3245)
(10, 1910)
(11, 1989)
(12, 1664)
(Sunday, 3019)
(Monday, 3169)
(Tuesday, 3549)
(Wednesday, 4732)
(Thursday, 4911)
(Friday, 4012)
(Saturday, 3317)
("AM", 9282)
("PM", 17427)
(Some false, 19081)
(Some true, 7628)

val it : unit = ()

也许我们可以将一年的月份分成季度?让我们创建一个执行此操作的函数。进入脚本文件并输入以下内容:

let getQuarter(month:int) =
    match month with
    | 1 | 2 | 3 -> 1
    | 4 | 5 | 6 -> 2
    | 7 | 8 | 9 -> 3
    | _ -> 4

let inputs' = 
    dataFrame 
    |> Seq.map (fun ts -> [|getQuarter((ts.Month)); int ts.DayOfWeek; getAMPM'(ts.AMPM)|])
    |> Seq.toArray

let outputs' = 
    dataFrame 
    |> Seq.map (fun ts -> receivedTicket'(ts.ReceviedTicket.Value))
    |> Seq.toArray

let error' = id3learning.Run(inputs', outputs')

将其发送到 REPL,我们看到:

val error' : float = 0.2851473286

这并没有提高我们的模型。也许我们可以继续处理数据,或者也许我们拥有的数据中不存在罚单/警告之间的相关性。离开一个模型往往是你在数据科学中必须做的最困难的事情之一,尤其是如果你在上面投入了相当多的时间,但通常这是正确的事情。

numl

在我们离开决策树之前,我想看看另一种计算它们的方法。与其使用 Accord.Net,我想介绍另一个名为numl的.Net 机器学习库。numl 是新生事物,可以提供更低的机器学习入门门槛。尽管不如 Accord 广泛,但它确实提供了许多常见的模型,包括决策树。

前往解决方案资源管理器并添加另一个名为numl.fsx的脚本。然后进入 NuGet 包管理器并下拉 numl:

PM> Install-Package numl

回到 numl 脚本并输入以下代码:

#r "System.Data.Entity.dll"
#r "FSharp.Data.TypeProviders.dll"
#r "System.Data.Linq.dll"

open System
open System.Data.Linq
open System.Data.Entity
open Microsoft.FSharp.Data.TypeProviders

[<Literal>]
let connectionString = "data source=nc54a9m5kk.database.windows.net;initial catalog=Traffic;user id=chickenskills@nc54a9m5kk;password=sk1lzm@tter;"

type EntityConnection = SqlEntityConnection<connectionString,Pluralize = true>
let context = EntityConnection.GetDataContext()

type TrafficStop = {Month:int; DayOfWeek:DayOfWeek; AMPM: string; ReceivedTicket: option<bool>}

let getAMPM (stopDateTime:System.DateTime) =
    match stopDateTime.Hour < 12 with
    | true -> "AM"
    | false -> "PM"

let receviedTicket (disposition:string) =
    match disposition.ToUpper() with
    | "CITATION" -> Some true
    | "VERBAL WARNING" | "WRITTEN WARNING" -> Some false
    | _ -> None

let dataFrame = 
    context.dbo_TrafficStops
    |> Seq.map (fun ts -> {Month=ts.StopDateTime.Value.Month;DayOfWeek=ts.StopDateTime.Value.DayOfWeek;
       AMPM=getAMPM(ts.StopDateTime.Value); ReceivedTicket= receviedTicket(ts.DispositionDesc) })
    |> Seq.filter (fun ts -> ts.ReceivedTicket.IsSome)
    |> Seq.toArray

这与Accord.fsx脚本中的代码相同,所以你可以从那里复制粘贴。将其发送到 REPL 以确保你正确地复制粘贴了。接下来,添加以下代码块以引用 numl。

#r "../packages/numl.0.8.26.0/lib/net40/numl.dll"
open numl
open numl.Model
open numl.Supervised.DecisionTree

接下来,输入以下代码块:

type TrafficStop' = {[<Feature>] Month:int; [<Feature>] DayOfWeek:int; 
    [<Feature>] AMPM: string; [<Label>] ReceivedTicket: bool}

let dataFrame' = 
    dataFrame 
    |> Seq.map (fun ts -> {TrafficStop'.Month = ts.Month; DayOfWeek = int ts.DayOfWeek; AMPM=ts.AMPM; ReceivedTicket=ts.ReceivedTicket.Value})
    |> Seq.map box

let descriptor = Descriptor.Create<TrafficStop'>()

将其发送到 REPL,返回如下:

type TrafficStop' =
 {Month: int;
 DayOfWeek: int;
 AMPM: string;
 ReceivedTicket: bool;}
val dataFrame' : seq<obj>
val descriptor : Descriptor =
 Descriptor (TrafficStop') {
 [Month, -1, 1]
 [DayOfWeek, -1, 1]
 [AMPM, -1, 0]
 *[ReceivedTicket, -1, 1]
}

这里有两点需要注意。首先,就像 Accord 一样,numl 希望其建模引擎的输入以某种格式。在这种情况下,它不是整数的数组。而是希望是对象类型(截至写作时)。为了知道如何处理每个对象,它需要与每个元素关联的属性,因此有TrafficStop'类型,它添加了[Feature][Label]。正如你所猜到的,特征用于输入,标签用于输出。第二点要注意的是,我们调用|> Seq.map box。这将我们的类型如 int、string 和 bool 转换为对象,这是 numl 想要的。

在处理完这些之后,我们可以看看 numl 会得出什么结论。将以下内容输入到脚本窗口:

let generator = DecisionTreeGenerator(descriptor)
generator.SetHint(false)
let model = Learner.Learn(dataFrame', 0.80, 25, generator)

将其发送到 REPL,我们得到:

val generator : DecisionTreeGenerator
val model : LearningModel =
 Learning Model:
 Generator numl.Supervised.DecisionTree.DecisionTreeGenerator
 Model:
 [AM, 0.0021]
 |- 0
 |  [Month, 0.0021]
 |   |- 1 ≤ x < 6.5
 |   |  [DayOfWeek, 0.0001]
 |   |   |- 0 ≤ x < 3
 |   |   |   +(False, -1)
 |   |   |- 3 ≤ x < 6.01
 |   |   |   +(False, -1)
 |   |- 6.5 ≤ x < 12.01
 |   |   +(False, -1)
 |- 1
 |   +(False, -1)

 Accuracy: 71.98 %

>

numl 的一个优点是,ToString()重载会打印出我们树的图形表示。这是一种快速视觉检查我们有什么的好方法。你还可以看到,模型的准确度几乎与 Accord 相同。如果你运行这个脚本几次,你会因为 numl 分割数据的方式而得到略微不同的答案。再次审视这个树,让我们看看我们能否更详细地解释它。

模型引擎发现,最佳的分割特征是上午/下午。如果交通拦截发生在下午,你会收到警告而不是罚单。如果是上午,我们会移动到树上的下一个决策点。我们可以看到的是,如果交通拦截发生在 7 月至 12 月之间的上午,我们不会收到罚单。如果上午的交通拦截发生在 1 月至 6 月之间,我们就必须进入下一个级别,即星期几。在这种情况下,模型在星期日-星期二和星期三-星期六之间进行分割。你会注意到,两个终端节点也都是错误的。那么真相在哪里?模型能否预测我会收到罚单?不,这个模型不能合理地预测你何时会收到罚单。就像之前一样,我们不得不放弃这个模型。然而,这次练习并不是徒劳的,因为我们将使用这些数据以及更多数据和一个不同的模型来创建一些具有实际价值的东西。

在我们离开这一章之前,还有一个问题,“这里正在进行什么样的机器学习?”我们可以这样说,numl 正在使用机器学习,因为它对数据进行了多次迭代。但这意味着什么呢?如果你查看我们编写的最后一行代码,let model = Learner.Learn(dataFrame', 0.80, 25, generator),你可以看到第三个参数是 25。这是模型运行的次数,然后 numl 选择最佳的模型。实际上,这意味着机器“学习”了,但评估了几个可能的模型,并为我们选择了一个。我不认为这算是机器学习,因为我们没有引入新的数据来使学习变得更智能。

在下一章中,我们将探讨使用测试集和训练集来完成这些任务,但我们仍然面临这个问题:这是一个特定时间点的分析。你将如何使这个模型能够自我学习?事实上,我不会在这个模型当前状态下浪费时间,因为模型已经被证明是无用的。然而,如果模型是有用的,我可以想象一个场景,即我们不断更新数据集,并基于我们的开放数据朋友能够获取的更多数据集运行模型。有了这个,我们可以运行一个应用程序,可能会在司机早上离家前提醒他们,根据日期/时间/天气/其他因素,他们应该比平时更加小心。也许是一个简单的文本或推文给司机?无论如何,一旦我们有一个真正的模型,我们就可以看到这个应用程序的实际应用。

摘要

在本章中,我们戴上数据科学家的帽子,研究了如何使用 F# 进行数据探索和分析。我们接触到了开放数据和类型提供者的奇妙之处。然后我们实现了一个决策树,尽管最终我们得出结论,数据并没有显示出显著的关系。

在下一章中,我们将解决迄今为止我们一直略过的一些问题,并深入探讨获取、清洗和组织我们的数据的方法。

第五章. 休息时间 – 获取数据

在本章中,我们将从查看各种机器学习模型转向。相反,我们将回顾我在第二章、AdventureWorks 回归、第三章、更多 AdventureWorks 回归和第四章、交通拦截 – 是否走错了路?中略过的一些问题。我们将探讨使用 Visual Studio 和类型提供者获取数据的不同方法。然后,我们将探讨类型提供者如何帮助我们解决缺失数据的问题,我们将如何使用并行性来加速我们的数据提取,以及我们如何在受保护的 Web 服务上使用类型提供者。

概述

数据科学家必须具备的一项被低估的技能是收集和整合异构数据的能力。异构数据来自不同的来源、结构和格式。异构与同质数据相对立,同质数据假设所有导入的数据都与可能已经存在的其他数据相同。当数据科学家获得异构数据时,他们首先会做的事情之一是将数据转换到可以与其他数据结合的程度。这种转换的最常见形式是 数据帧——有时被称为 矩形,因为列是属性,行是数据。例如,这里是一个我们之前见过的数据帧:

概述

理想情况下,每个数据帧都有一个独特的键,允许它与其他数据帧结合。在这种情况下,ProductID 是主键。如果你认为这很像 RDBMS 理论——你是对的。

研究分析师和业务开发者之间的一大区别在于他们如何在其项目中使用数据。对于软件工程师来说,数据元素必须被细致地定义、创建和跟踪。而对于研究分析师来说,所有这些精神努力都是与解决问题无关的噪音。

这就是类型提供者力量的体现。我们不是花费精力去提取数据,而是花费时间对其进行转换、塑造和分析。

SQL Server 提供者

尽管围绕像 MongoDb 这样的no-sql数据库和无结构数据存储如数据湖(或根据你的观点,数据沼泽)有很多炒作,但我们行业处理的大量数据仍然存储在关系数据库中。正如我们在前面的章节中看到的,数据科学家必须能够使用 SQL 有效地与关系数据库进行通信。然而,我们也看到了 F#提供使用称为类型提供者来访问 SQL Server 的能力。

非类型提供者

让我们回到第三章中使用的 SQL,以降低单个客户的平均订单、平均评论和列表价格,并看看如何以不同的方式完成。进入 Visual Studio 并创建一个名为TypeProviders的 F# Windows 库。

注意,我正在使用.NET Framework 4.5.2。框架的次要版本并不重要,只要它是 4.x 即可。重要的是要注意,你不能在可移植类库PCLs)中使用类型提供者。

非类型提供者

一旦 Visual Studio 为你生成文件,请删除Library1.fs并移除Script1.fsx中的所有内容。将Scipt1.fsx重命名为SqlServerProviders.fsx。接下来,添加对System.Transactions的引用:

非类型提供者

进入SqlServerProviders.fsx并添加以下代码(你可以从第三章复制,更多 AdventureWorks 回归,它们是相同的):

#r "System.Transactions.dll"

open System
open System.Text
open System.Data.SqlClient

type ProductInfo = {ProductID:int; AvgOrders:float; AvgReviews: float; ListPrice: float}

let productInfos = ResizeArray<ProductInfo>()

[<Literal>]
let connectionString = "data source=nc54a9m5kk.database.windows.net;initial catalog=AdventureWorks2014;user id= PacktReader;password= P@cktM@chine1e@rning;"

[<Literal>]
let query =
    "Select 
    A.ProductID, AvgOrders, AvgReviews, ListPrice
    From
    (Select 
    ProductID,
    (Sum(OrderQty) + 0.0)/(Count(Distinct SOH.CustomerID) + 0.0) as AvgOrders
    from [Sales].[SalesOrderDetail] as SOD
    inner join [Sales].[SalesOrderHeader] as SOH
    on SOD.SalesOrderID = SOH.SalesOrderID
    inner join [Sales].[Customer] as C
    on SOH.CustomerID = C.CustomerID
    Where C.StoreID is not null
    Group By ProductID) as A
    Inner Join 
    (Select
    ProductID,
    (Sum(Rating) + 0.0) / (Count(ProductID) + 0.0) as AvgReviews
    from [Production].[ProductReview] as PR
    Group By ProductID) as B
    on A.ProductID = B.ProductID
    Inner Join
    (Select
    ProductID,
    ListPrice
    from [Production].[Product]
    ) as C
    On A.ProductID = C.ProductID"

let connection = new SqlConnection(connectionString)
let command = new SqlCommand(query,connection)
connection.Open()
let reader = command.ExecuteReader()
while reader.Read() do
    productInfos.Add({ProductID=reader.GetInt32(0);
                        AvgOrders=(float)(reader.GetDecimal(1));
                        AvgReviews=(float)(reader.GetDecimal(2));
                        ListPrice=(float)(reader.GetDecimal(3));})

productInfos

这里总共有 52 行代码,其中 26 行是字符串query中的 SQL。这似乎是做一件看似基本的事情所做的大量工作。此外,如果我们想更改我们的输出矩形,我们就必须重写这个 SQL 并希望我们做得正确。此外,尽管我们根本不在乎数据是否存储在 SQL Server 数据库中,我们现在需要了解一些相当高级的 SQL。类型提供者如何帮助我们在这里?

SqlProvider

返回 Visual Studio,打开 NuGet 包管理器,并输入以下内容:

PM> Install-Package SQLProvider -prerelease

接下来,进入脚本文件并添加以下内容:

#r "../packages/SQLProvider.0.0.11-alpha/lib/ FSharp.Data.SQLProvider.dll"

提示

警告

类型提供者不断更改它们的版本号。因此,SQLProvider.0.0.11将失败,除非你编辑它。为了确定正确的版本,进入你的解决方案中的包文件夹并查看路径。

一旦你输入了正确的提供者版本,你可能会得到一个类似这样的对话框(这是上一章的内容):

SqlProvider

点击启用。返回脚本,输入以下代码:

open System
open System.Linq
open FSharp.Data.Sql

[<Literal>]
let connectionString = "data source=nc54a9m5kk.database.windows.net;initial catalog=AdventureWorks2014;user id= PacktReader;password= P@cktM@chine1e@rning;"

type AdventureWorks = SqlDataProvider<Common.DatabaseProviderTypes.MSSQLSERVER,connectionString>
let context = AdventureWorks.GetDataContext()

Sending that to the FSI gives us this:
val connectionString : string =
  "data source=nc54a9m5kk.database.windows.net;initial catalog=A"+[72 chars]
type AdventureWorks = SqlDataProvider<...>
val context : SqlDataProvider<...>.dataContext

在脚本文件中输入以下代码:

let customers =  
    query {for c in context.Sales.Customer do
           where (c.StoreId > 0)
           select c.CustomerId}
           |> Seq.toArray 

将其发送到 FSI 后,我们得到以下结果:

val customers : int [] =
 [|1; 2; 3; 4; 5; 6; 7; 8; 9; 10; 11; 12; 13; 14; 15; 16; 17; 18; 19; 20; 21;
 22; 23; 24; 25; 26; 27; 28; 29; 30; 31; 32; 33; 34; 35; 36; 37; 38; 39; 40;
 41; 42; 43; 44; 45; 46; 47; 48; 49; 50; 51; 52; 53; 54; 55; 56; 57; 58; 59;
 60; 61; 62; 63; 64; 65; 66; 67; 68; 69; 70; 71; 72; 73; 74; 75; 76; 77; 78;
 79; 80; 81; 82; 83; 84; 85; 86; 87; 88; 89; 90; 91; 92; 93; 94; 95; 96; 97;
 98; 99; 100; ...|]

这里有几个需要注意的地方。首先,我们向类型提供者发送一个查询(有时被称为计算表达式)。在这种情况下,我们选择所有storeId大于0的客户——个人客户。表达式是{}符号之间的所有内容。注意,它是 LINQ 语法,因为它是 LINQ。如果你不熟悉,LINQ 代表语言集成查询,它是一种语言内的语言——它允许将查询功能放置在你的.NET 语言选择中。另一件需要注意的事情是,表达式的结果被管道化到我们熟悉的 F# Seq类型。这意味着我们可以从表达式中获取任何结果,并使用Seq进一步塑造或精炼数据。要看到这一点,请将以下内容输入到脚本文件中:

let products = 
    query {for soh in context.Sales.SalesOrderHeader do
           join sod in context.Sales.SalesOrderDetail on (soh.SalesOrderId = sod.SalesOrderId)
           join c in context.Sales.Customer on (soh.CustomerId = c.CustomerId)
           join p in context.Production.Product on (sod.ProductId = p.ProductId)
           where (c.CustomerId |=| customers)
           select (p.ProductId)}
           |> Seq.distinct
           |> Seq.toArray 

当你将其发送到 FSI 时,你应该会看到一个产品 ID 数组:

val products : int [] =
 [|776; 777; 778; 771; 772; 773; 774; 714; 716; 709; 712; 711; 762; 758; 745;
 743; 747; 715; 742; 775; 741; 708; 764; 770; 730; 754; 725; 765; 768; 753;
 756; 763; 732; 729; 722; 749; 760; 726; 733; 738; 766; 755; 707; 710; 761;
 748; 739; 744; 736; 767; 717; 769; 727; 718; 759; 751; 752; 750; 757; 723;
 786; 787; 788; 782; 783; 779; 780; 781; 815; 816; 808; 809; 810; 823; 824;

回到代码,我们通过外键将来自AdventureWorks数据库的三个表连接在一起:

join sod in context.Sales.SalesOrderDetail on (soh.SalesOrderId = sod.SalesOrderId)
join c in context.Sales.Customer on (soh.CustomerId = c.CustomerId)
join p in context.Production.Product on (sod.ProductId = p.ProductId)

在下一行,我们只选择那些在我们之前创建的客户表中存在的客户。注意,我们正在使用 F#的in运算符|=|

where (c.CustomerId |=| customers)

最后,我们只选择产品 ID,然后拉取所有值,然后选择唯一值:

select (p.ProductId)}
|> Seq.distinct
|> Seq.toArray

让我们继续看看我们还能做什么。将以下内容输入到脚本中:

let averageReviews = 
    query {for pr in context.Production.ProductReview do
            where (pr.ProductId |=| products)
            select pr}
            |> Seq.groupBy(fun pr -> pr.ProductId)
            |> Seq.map(fun (id,a) -> id, a |> Seq.sumBy(fun pr -> pr.Rating), a |> Seq.length)
            |> Seq.map( fun (id,r,c) -> id, float(r)/float(c))
            |> Seq.sortBy(fun (id, apr) -> id)
            |> Seq.toArray

将其发送到 REPL,我们看到:

val averageReviews : (int * float) [] =
 |(749, 3.9); (750, 3.977272727); (751, 3.93877551); (752, 4.02173913);
 (753, 3.939393939); (754, 3.965517241); (755, 3.628571429);
 (756, 3.742857143); (757, 3.9375); (758, 3.845070423); (759, 3.483870968);
 (760, 4.035874439);

在这段代码中,我们拉取所有评论。然后我们按productId对评论进行分组。从那里,我们可以汇总评分和评论数量的总和(使用Seq.length)。然后我们可以将总评分量除以评论数量,得到每个productId的平均评论。最后,我们加入一个Seq.sortBy并将其管道化到一个数组中。所有这些 F#代码都应该很熟悉,因为它与我们如何在[第二章、AdventureWorks 回归、第三章、更多 AdventureWorks 回归和第四章、交通拦截——走错了路?中处理数据非常相似。

接下来,让我们为每个产品创建一个价格数据框(如果你有几何倾向,有时也称为数据矩形):

let listPrices = 
    query {for p in context.Production.Product do
            where (p.ProductId |=| products)
            select p}
            |> Seq.map(fun p -> p.ProductId, p.ListPrice)   
            |> Seq.sortBy(fun (id, lp) -> id)
            |> Seq.toArray

将其发送到 REPL,你应该会看到以下内容:

val listPrices : (int * decimal) [] =
 [|(707, 34.9900M); (708, 34.9900M); (709, 9.5000M); (710, 9.5000M);
 (711, 34.9900M); (712, 8.9900M); (714, 49.9900M); (715, 49.9900M);
 (716, 49.9900M); (717, 1431.5000M); (718, 1431.5000M); (719, 1431.5000M);
 (722, 337.2200M); (723, 337.2200M); (725, 337.2200M); (726, 337.2200M);
 (727, 337.2200M)

这段代码没有引入任何新内容。我们拉取数组中所有的产品,获取productIdlist price,对其进行排序,然后发送到一个数组中。最后,将以下内容输入到脚本文件中:

let averageOrders = 
    query {for soh in context.Sales.SalesOrderHeader do
            join sod in context.Sales.SalesOrderDetail on (soh.SalesOrderId = sod.SalesOrderId)
            join c in context.Sales.Customer on (soh.CustomerId = c.CustomerId)
            where (c.CustomerId |=| customers)
            select (soh,sod)}
            |> Seq.map (fun (soh,sod) -> sod.ProductId, sod.OrderQty, soh.CustomerId)
            |> Seq.groupBy (fun (pid,q,cid) -> pid )
            |> Seq.map (fun (pid,a) -> pid, a |> Seq.sumBy (fun (pid,q,cid) -> q), a |> Seq.distinctBy (fun (pid,q,cid) -> cid))
            |> Seq.map (fun (pid,q,a) -> pid,q, a |> Seq.length)
            |> Seq.map (fun (pid,q,c) -> pid, float(q)/float(c))
            |> Seq.sortBy (fun (id, ao) -> id)
            |> Seq.toArray

将其发送到 REPL,我们得到以下结果:

val averageOrders : (int * float) [] =
 [|(707, 17.24786325); (708, 17.71713147); (709, 16.04347826);
 (710, 3.214285714); (711, 17.83011583); (712, 22.33941606);
 (714, 15.35576923); (715, 22.82527881); (716, 13.43979058);
 (717, 4.708737864); (718, 5.115789474); (719, 3.303030303);

这是一个相当大的代码块,看起来可能会让人感到畏惧。我们所做的是首先将所有的 SalesOrderHeadersSalesOrderDetails 作为元组选择(soh,sod)拉下来。然后我们将这个集合通过 Seq.map 转换成一个元组序列,该序列包含三个元素:ProductIdOrderQtyCustomerIdSeq.map(fun (soh,sod) -> sod.ProductId, sod.OrderQty, soh.CustomerId))。从那里,我们将这些元组通过 groupBy 分组到 ProductIdSeq.groupBy(fun (pid,q,cid) -> pid))。从那里,我们开始变得有些疯狂。看看下一行:

|> Seq.map(fun (pid,a) -> pid, a |> Seq.sumBy(fun (pid,q,cid) -> q), a |> Seq.distinctBy(fun (pid,q,cid) -> cid))

希望你记得关于 GroupBy 的讨论,这样你就会意识到输入是一个包含 ProductId 和三个项元组数组(ProductIdOrderQtyCustomerId)的元组。我们创建一个新的三项元组,包含 ProductIdOrderQty 的总和,以及另一个包含 CustomerId 和不同 customerId 项序列的元组。

当我们将这个通过到下一行时,我们取最后一个元组(CustomerId, CustomerIds 数组)的长度,因为这是订购该产品的唯一客户数量。这个三项元组是 ProductIdSumOfQuantityOrderedCountOfUniqueCustomersThatOrdered。由于这有点冗长,我使用了标准的元组表示法 (pid, q, c),其中 qSumOfQuantityOrderedcCountOfUniqueCustomersThatOrdered。这个元组随后通过到以下:

|> Seq.map(fun (pid,q,c) -> pid, float(q)/float(c))

现在我们可以得到每个产品的平均订单数量。然后我们完成排序并发送到一个数组。现在我们有三个元组数组:

averageOrders: ProductId, AverageNumberOfOrders
averageReviews: ProductId, AverageReviews
listPrices: ProductId, PriceOfProduct

理想情况下,我们可以将这些合并成一个包含 ProductIdAverageNumberOfOrdersAverageReviewsPriceOfProduct 的数组。为了做到这一点,你可能认为我们可以直接将这些三个数组连接起来。进入脚本并输入以下内容:

Seq.zip3 averageOrders  averageReviews  listPrices 

当你将其发送到 FSI 时,你会看到一些令人失望的内容:

val it : seq<(int * float) * (int * float) * (int * decimal)> =
  seq
    [((707, 17.24786325), (749, 3.9), (707, 34.9900M));
     ((708, 17.71713147),

数组没有匹配。显然,有些产品没有任何评分。我们需要一种方法将这些三个数组连接成一个数组,并且连接发生在 ProductId 上。虽然我们可以回到 LINQ 表达式中的 where 子句并尝试调整,但有一个替代方法。

Deedle

进入脚本文件并输入以下代码:

#load "../packages/FsLab.0.3.17/FsLab.fsx"
open Foogle
open Deedle
open FSharp.Data

正如我们之前所做的那样,你必须确保版本号匹配。当你将其发送到 REPL 时,你会看到以下内容:

[Loading F:\Git\MLDotNet\Book Chapters\Chapter05\TypeProviders.Solution\packages\FsLab.0.3.10\FsLab.fsx]

namespace FSI_0009.FsLab
 val server : Foogle.SimpleHttp.HttpServer option ref
 val tempDir : string
 val pid : int
 val counter : int ref
 val displayHtml : html:string -> unit
namespace FSI_0009.FSharp.Charting
 type Chart with
 static member

Line : data:Deedle.Series<'K,#FSharp.Charting.value> * ?Name:string *
 ?Title:string * ?Labels:#seq<string> * ?Color:Drawing.Color *

我们所做的是加载了 Deedle。Deedle 是一个为时间序列分析创建的 neat 库。让我们看看 Deedle 是否能帮助我们解决不平衡数组问题。我们首先想要做的是将我们的元组数组转换为数据框。将以下内容输入到脚本中:

let averageOrders' = Frame.ofRecords averageOrders
let listPrices' = Frame.ofRecords listPrices
let averageReviews' = Frame.ofRecords averageReviews

将这个发送到 FSI,你会看到如下内容:

      Item1 Item2            
0  -> 749   3.9              
1  -> 750   3.97727272727273 
2  -> 751   3.93877551020408 
3  -> 752   4.02173913043478 
4  -> 753   3.9393939393939

让我们将 Item1Item2 重命名为更有意义的东西,并将 fame 的第一个向量作为帧的主键。将以下内容输入到脚本文件中:

let orderNames = ["ProductId"; "AvgOrder"]
let priceNames = ["ProductId"; "Price"]
let reviewNames = ["ProductId"; "AvgReview"]

let adjustFrame frame headers =
    frame |> Frame.indexColsWith headers
          |> Frame.indexRowsInt "ProductId"
          |> Frame.sortRowsByKey

let averageOrders'' = adjustFrame averageOrders' orderNames
let listPrices'' = adjustFrame listPrices' priceNames
let averageReviews'' = adjustFrame averageReviews' reviewNames
Sending that to the REPL, should see something like:
val averageReviews'' : Frame<int,string> =

       AvgReview        
749 -> 3.9              
750 -> 3.97727272727273 
751 -> 3.93877551020408

这段代码应该是相当直观的。我们正在创建一个名为 adjustFrame 的函数,它接受两个参数:一个数据框和一个字符串数组,这些字符串将成为标题值。我们通过第一个管道应用标题,通过第二个管道将第一列(ProductId)设置为 primaryKey,然后通过第三个管道对数据框进行排序。然后我们将此函数应用于我们的三个数据框:订单、价格和评论。请注意,我们正在使用计时符号。

从那里,我们现在可以根据它们的键来组合数据框。转到脚本文件并添加以下内容:

averageOrders'' |> Frame.join JoinKind.Inner listPrices''
                |> Frame.join JoinKind.Inner averageReviews''

将此发送到 FSI,你应该看到以下内容:

 AvgReview        Price     AvgOrder 
749 -> 3.9              3578.2700 4.47457627118644 
750 -> 3.97727272727273 3578.2700 4.72727272727273 
751 -> 3.93877551020408 3578.2700 4.875 
752 -> 4.02173913043478

酷吧?Deedle 是一个非常强大的库,您可以在各种场景中使用它。

回到我们的原始任务,我们现在有两种不同的方式从数据库中提取数据并进行转换。当你对 ADO.NET SQL 方法和类型提供程序方法进行横向比较时,有一些相当有力的论据可以支持使用类型提供程序方法。首先,SqlDataProvider 是为大多数流行的关系数据库设计的。如果你将你的 AdventureWorks 数据库从 MS SQL Server 移动到 MySql,你只需要更改连接字符串,所有代码都会保持不变。其次,考虑到类型提供程序实现中没有 SQL。相反,我们正在使用 F# 计算表达式来选择我们想要的表和记录。这意味着我们不需要知道任何 SQL,我们甚至有更多的可移植性。如果我们将 AdventureWorks 数据库移动到类似 Mongo 或 DocumentDb 的 NoSQL 数据库,我们只需要更换类型提供程序并更改连接字符串。最后,考虑我们使用类型提供程序的方法。我们不需要提前构建任何类来将数据放入,因为类型会自动为我们生成。

此外,由于我们将小块数据传送到客户端,然后对其进行转换,因此我们可以独立运行我们的思维过程的每一步。我无法强调这一点的重要性;我们正在通过与我们思维过程一致的小步骤提取和转换数据。我们可以将我们的精神能量和时间集中在手头的问题上,而不是在可能或不熟悉的语言的语法中挣扎。类型提供程序方法的缺点是它可能比 ADO.NET 方法慢,因为调整查询优化的机会较少。在这种情况下,我们正在对小型数据集进行即席数据探索和分析,因此性能差异很小。然而,即使是一个大型数据集,我仍然会遵循软件工程的格言:“先做对,再做快。”

MicrosoftSqlProvider

在我们结束对类型提供者的讨论之前,我想展示另一个基于 Entity Framework 7 构建的类型提供者,它有很多潜力,尤其是在你想开始使用类型提供者作为当前 ORM 的替代品时。它被称为 EntityFramework.MicrosoftSqlServer 类型提供者。

返回 Visual Studio,打开包管理控制台,并输入以下内容:

PM> Install-Package FSharp.EntityFramework.MicrosoftSqlServer –Pre

接下来,转到你的脚本文件并输入以下内容:

#I @"..\packages" 
#r @"EntityFramework.Core.7.0.0-rc1-final\lib\net451\EntityFramework.Core.dll"
#r @"EntityFramework.MicrosoftSqlServer.7.0.0-rc1-final\lib\net451\EntityFramework.MicrosoftSqlServer.dll"
#r @"EntityFramework.Relational.7.0.0-rc1-final\lib\net451\EntityFramework.Relational.dll"
#r @"Inflector.1.0.0.0\lib\net45\Inflector.dll"
#r @"Microsoft.Extensions.Caching.Abstractions.1.0.0-rc1-final\lib\net451\Microsoft.Extensions.Caching.Abstractions.dll"
#r @"Microsoft.Extensions.Caching.Memory.1.0.0-rc1-final\lib\net451\Microsoft.Extensions.Caching.Memory.dll"
#r @"Microsoft.Extensions.Configuration.1.0.0-rc1-final\lib\net451\Microsoft.Extensions.Configuration.dll"
#r @"Microsoft.Extensions.Configuration.Abstractions.1.0.0-rc1-final\lib\net451\Microsoft.Extensions.Configuration.Abstractions.dll"
#r @"Microsoft.Extensions.Configuration.Binder.1.0.0-rc1-final\lib\net451\Microsoft.Extensions.Configuration.Binder.dll"
#r @"Microsoft.Extensions.DependencyInjection.1.0.0-rc1-final\lib\net451\Microsoft.Extensions.DependencyInjection.dll"
#r @"Microsoft.Extensions.Logging.1.0.0-rc1-final\lib\net451\Microsoft.Extensions.Logging.dll"
#r @"Microsoft.Extensions.Logging.Abstractions.1.0.0-rc1-final\lib\net451\Microsoft.Extensions.Logging.Abstractions.dll"
#r @"Microsoft.Extensions.OptionsModel.1.0.0-rc1-final\lib\net451\Microsoft.Extensions.OptionsModel.dll"
#r @"Microsoft.Extensions.Primitives.1.0.0-rc1-final\lib\net451\Microsoft.Extensions.Primitives.dll"
#r @"Remotion.Linq.2.0.1\lib\net45\Remotion.Linq.dll"
#r @"System.Collections.Immutable.1.1.36\lib\portable-net45+win8+wp8+wpa81\System.Collections.Immutable.dll"
#r @"System.Diagnostics.DiagnosticSource.4.0.0-beta-23516\lib\dotnet5.2\System.Diagnostics.DiagnosticSource.dll"
#r @"Ix-Async.1.2.5\lib\net45\System.Interactive.Async.dll"

#r "../packages/Microsoft.Extensions.DependencyInjection.Abstractions.1.0.0-rc1-final/lib/net451/Microsoft.Extensions.DependencyInjection.Abstractions.dll"
#r @"FSharp.EntityFramework.MicrosoftSqlServer.0.0.2.0-alpha\lib\net451\FSharp.EntityFramework.MicrosoftSqlServer.dll"

是的,我知道这有很多,但你只需要输入一次,而且你不需要将它带到你的 .fs 文件中。如果你不想将这段代码复制粘贴到你的脚本中,你只需安装所有 Entity Framework,这些包就会可用。无论如何,将以下内容输入到脚本文件中:

open System
open System.Data.SqlClient
open Microsoft.Data.Entity
open FSharp.Data.Entity

[<Literal>]
let connectionString = "data source=nc54a9m5kk.database.windows.net;initial catalog=AdventureWorks2014; user id= PacktReader;password= P@cktM@chine1e@rning;"

type AdventureWorks = SqlServer<connectionString, Pluralize = true>
let context = new AdventureWorks()
Sending this to the REPL will give you this:
    nested type Sales.SpecialOffer
    nested type Sales.SpecialOfferProduct
    nested type Sales.Store
    nested type dbo.AWBuildVersion
    nested type dbo.DatabaseLog
    nested type dbo.ErrorLog
  end
val context : AdventureWorks

返回脚本文件并输入以下内容:

let salesOrderQuery = 
    query { for soh in context.``Sales.SalesOrderHeaders`` do
            join sod in context.``Sales.SalesOrderDetails`` on (soh.SalesOrderID = sod.SalesOrderID)
            where (soh.OrderDate > DateTime(2013,5,1))
            select(soh)} |> Seq.head

当你将这个发送到 FSI 时,你会看到所有荣耀的 SalesOrderheader Entity Framework 类型:

 FK_SalesOrderHeader_Address_BillToAddressID = null;
 FK_SalesOrderHeader_CreditCard_CreditCardID = null;
 FK_SalesOrderHeader_CurrencyRate_CurrencyRateID = null;
 FK_SalesOrderHeader_Customer_CustomerID = null;
 FK_SalesOrderHeader_SalesPerson_SalesPersonID = null;
 FK_SalesOrderHeader_SalesTerritory_TerritoryID = null;
 FK_SalesOrderHeader_ShipMethod_ShipMethodID = null;
 Freight = 51.7855M;
 ModifiedDate = 5/9/2013 12:00:00 AM;
 OnlineOrderFlag = true;
 OrderDate = 5/2/2013 12:00:00 AM;
 PurchaseOrderNumber = null;
 RevisionNumber = 8uy;
 SalesOrderDetail = null;
 SalesOrderHeaderSalesReason = null;
 SalesOrderID = 50788;
 SalesOrderNumber = "SO50788";
 SalesPersonID = null;
 ShipDate = 5/9/2013 12:00:00 AM;
 ShipMethodID = 1;
 ShipToAddressID = 20927;
 Status = 5uy;
 SubTotal = 2071.4196M;
 TaxAmt = 165.7136M;
 TerritoryID = 4;
 TotalDue = 2288.9187M;
 rowguid = 74fca7f8-654b-432f-95fb-0dd42b0e3cf1;}
>

这意味着,你可以用类型提供者做任何用 Entity Framework 做的事情——无需前置代码。没有模板,没有设计器,什么都没有。

让我们继续看看类型提供者如何处理空值。进入脚本并输入以下内容:

let salesOrderQuery' = 
    query { for soh in context.``Sales.SalesOrderHeaders`` do
            join sod in context.``Sales.SalesOrderDetails`` on (soh.SalesOrderID = sod.SalesOrderID)
            join p in context.``Production.Products`` on (sod.ProductID = p.ProductID)
            where (soh.OrderDate > DateTime(2013,5,1) && p.ProductSubcategoryID =  new System.Nullable<int>(1))
            select(soh)} |> Seq.head
salesOrderQuery'

当你将这段代码发送到 FSI 时,你会看到以下类似的内容:

     SalesPersonID = null;
     ShipDate = 5/9/2013 12:00:00 AM;
     ShipMethodID = 1;
     ShipToAddressID = 20927;
     Status = 5uy;
     SubTotal = 2071.4196M;
     TaxAmt = 165.7136M;
     TerritoryID = 4;
     TotalDue = 2288.9187M;
     rowguid = 74fca7f8-654b-432f-95fb-0dd42b0e3cf1;}
>

注意,我们必须在 where 条件中使用 System.Nullable<int> 来考虑 ProductSubcategoyID 在数据库中是可空的。这导致使用类型提供者时有一个小 陷阱。你不能使用现成的 |=| 操作符来搜索值数组。例如,如果你将以下内容发送到 REPL:

let salesOrderQuery''' =
 query { for soh in context.``Sales.SalesOrderHeaders`` do
            join sod in context.``Sales.SalesOrderDetails`` on (soh.SalesOrderID = sod.SalesOrderID)
            join p in context.``Production.Products`` on (sod.ProductID = p.ProductID)
            where (soh.OrderDate > DateTime(2013,5,1) && p.ProductSubcategoryID |=| [|1;2;3|])
            select(soh)} |> Seq.head

你会得到以下结果:

SqlServerProviders.fsx(199,105): error FS0001: This expression was expected to have type
 Nullable<int> 
> but here has type
 int 

我们现在需要创建一个可空整数的数组。这会起作用吗?

let produceSubcategories = [|new System.Nullable<int>(1); new System.Nullable<int>(2); new System.Nullable<int>(3)|]

let salesOrderQuery''' = 
query { for soh in context.``Sales.SalesOrderHeaders`` do
        join sod in context.``Sales.SalesOrderDetails`` on (soh.SalesOrderID = sod.SalesOrderID)
        join p in context.``Production.Products`` on (sod.ProductID = p.ProductID)
        where (soh.OrderDate > DateTime(2013,5,1) && p.ProductSubcategoryID |=| produceSubcategories)
        select(soh)} |> Seq.head

唉,没有:

System.ArgumentException: The input sequence was empty.
Parameter name: source
 at Microsoft.FSharp.Collections.SeqModule.HeadT
 at <StartupCode$FSI_0024>.$FSI_0024.main@() in F:\Git\MLDotNet\Book Chapters\Chapter05\TypeProviders.Solution\TypeProviders\SqlServerProviders.fsx:line 206
Stopped due to error

所以,有几种方法可以解决这个问题。选项 1 是,你可以创建一个函数。将以下内容输入到你的脚本文件中:

let isBikeSubcategory id =
    let produceSubcategories = [|new System.Nullable<int>(1);
    new System.Nullable<int>(2); new System.Nullable<int>(3)|]
    Array.contains id produceSubcategories

isBikeSubcategory(new System.Nullable<int>(1))
isBikeSubcategory(new System.Nullable<int>(6))

let salesOrderQuery''' = 
    query { for soh in context.``Sales.SalesOrderHeaders`` do
            join sod in context.``Sales.SalesOrderDetails`` on (soh.SalesOrderID = sod.SalesOrderID)
            join p in context.``Production.Products`` on (sod.ProductID = p.ProductID)
            where (soh.OrderDate > DateTime(2013,5,1) && isBikeSubcategory(p.ProductSubcategoryID))
            select(soh)} |> Seq.head
salesOrderQuery'''

将这个发送到 FSI 会给你以下结果:

 Status = 5uy;
 SubTotal = 2071.4196M;
 TaxAmt = 165.7136M;
 TerritoryID = 4;
 TotalDue = 2288.9187M;
 rowguid = 74fca7f8-654b-432f-95fb-0dd42b0e3cf1;}
>

这里没有新的代码。我们创建了一个函数。

但等等!还有更多!返回脚本文件并输入以下内容:

let produceSubcategories = [|new System.Nullable<int>(1);
new System.Nullable<int>(2); new System.Nullable<int>(3)|]
let (|=|) id a = Array.contains id a

let salesOrderQuery4 = 
    query { for soh in context.``Sales.SalesOrderHeaders`` do
            join sod in context.``Sales.SalesOrderDetails`` on (soh.SalesOrderID = sod.SalesOrderID)
            join p in context.``Production.Products`` on (sod.ProductID = p.ProductID)
            where (soh.OrderDate > DateTime(2013,5,1) && p.ProductSubcategoryID |=| produceSubcategories )
            select(soh)} |> Seq.head
salesOrderQuery4

这一行代码是什么意思?

let (|=|) id a = Array.contains id a

这是一个名为 |=| 的函数,它接受两个参数:要搜索的 id 和要搜索的数组。这个函数被称为 中缀 操作符,因为我们正在将符号分配给更描述性的名称。考虑一下 + 操作符代表 加法。有了这个中缀操作符,我们可以回到这里并使我们的语法更直观:

where (soh.OrderDate > DateTime(2013,5,1) && p.ProductSubcategoryID |=| produceSubcategories )

还有一个选项可以考虑:就是放弃额外的函数,并将 Array.contains 内联。返回脚本并输入以下内容:

let produceSubcategories = [|new System.Nullable<int>(1);
new System.Nullable<int>(2); new System.Nullable<int>(3)|]

let salesOrderQuery5 = 
    query { for soh in context.``Sales.SalesOrderHeaders`` do
            join sod in context.``Sales.SalesOrderDetails`` on (soh.SalesOrderID = sod.SalesOrderID)
            join p in context.``Production.Products`` on (sod.ProductID = p.ProductID)
            where (soh.OrderDate > DateTime(2013,5,1) &&  Array.contains p.ProductSubcategoryID produceSubcategories)
            select(soh)} |> Seq.head
salesOrderQuery5

将这个发送到 REPL 给我们预期的返回结果:

     ShipDate = 5/9/2013 12:00:00 AM;
     ShipMethodID = 1;
     ShipToAddressID = 20927;
     Status = 5uy;
     SubTotal = 2071.4196M;
     TaxAmt = 165.7136M;
     TerritoryID = 4;
     TotalDue = 2288.9187M;
     rowguid = 74fca7f8-654b-432f-95fb-0dd42b0e3cf1;}
>

因此,我们有三种不同的方式来处理这个问题。我们是选择命名函数、中缀运算符,还是内联函数?在这种情况下,我会选择中缀运算符,因为我们正在替换一个应该工作并使行最易读的现有运算符。其他人可能不同意,你必须准备好作为一个数据科学家能够阅读其他人的代码,所以熟悉所有三种方式是很好的。

SQL Server 类型提供者总结

我已经在本章中突出显示了两个 SQL 类型提供者。实际上,我知道有五种不同的类型提供者,你可以在访问 SQL 数据库时使用,而且肯定还有更多。当你刚开始使用 F#时,你可能会对使用哪一个感到困惑。为了你的参考,以下是我的基本概述:

  • FSharp.Data.TypeProviders.SqlServerProvider: 这是 Visual Studio 安装的一部分,由 Microsoft 支持,目前没有新的开发工作在进行。由于这是生命周期的结束,你不会想使用这个。

  • FSharp.Data.TypeProviders.EntityFrameworkProvider: 这是 Visual Studio 安装的一部分,由 Microsoft 支持,目前没有新的开发工作在进行。它非常适合纯数据库。

  • FSharp.Data.SqlClient: 这是由社区创建的。这是一种非常稳定的方式来将 SQL 命令传递到服务器。它不支持 LINQ 风格的计算表达式。它非常适合基于 CRUD 的 F#操作。

  • FSharp.Data.SqlProvider: 这是在预发布阶段由社区创建的,所以有一些不稳定性。它非常适合进行 LINQ 风格的计算表达式。它支持不同的 RDMS,如 Oracle、MySQL 和 SQL Server。

  • FSharp.EntityFramework.MicrosoftSqlServer: 这是由社区创建的。它处于非常初级的阶段,但有很大的潜力成为传统 ORM 编码的绝佳替代品。它非常适合进行 LINQ 风格的计算表达式。

非 SQL 类型提供者

类型提供者不仅用于关系数据库管理系统。实际上,还有 JSON 类型提供者、XML 类型提供者、CSV 类型提供者,等等。让我们看看几个,看看我们如何使用它们来创建一些基于异构数据的有意思的数据框。

进入 Visual Studio,添加一个名为NonSqlTypeProviders.fsx的新脚本文件。在顶部,引入我们将使用的所有引用并打开所需的库:

#load "../packages/FsLab.0.3.17/FsLab.fsx"

#I @"..\packages" 
#r @"EntityFramework.Core.7.0.0-rc1-final\lib\net451\EntityFramework.Core.dll"
#r @"EntityFramework.MicrosoftSqlServer.7.0.0-rc1-final\lib\net451\EntityFramework.MicrosoftSqlServer.dll"
#r @"EntityFramework.Relational.7.0.0-rc1-final\lib\net451\EntityFramework.Relational.dll"
#r @"Inflector.1.0.0.0\lib\net45\Inflector.dll"
#r @"Microsoft.Extensions.Caching.Abstractions.1.0.0-rc1-final\lib\net451\Microsoft.Extensions.Caching.Abstractions.dll"
#r @"Microsoft.Extensions.Caching.Memory.1.0.0-rc1-final\lib\net451\Microsoft.Extensions.Caching.Memory.dll"
#r @"Microsoft.Extensions.Configuration.1.0.0-rc1-final\lib\net451\Microsoft.Extensions.Configuration.dll"
#r @"Microsoft.Extensions.Configuration.Abstractions.1.0.0-rc1-final\lib\net451\Microsoft.Extensions.Configuration.Abstractions.dll"
#r @"Microsoft.Extensions.Configuration.Binder.1.0.0-rc1-final\lib\net451\Microsoft.Extensions.Configuration.Binder.dll"
#r @"Microsoft.Extensions.DependencyInjection.1.0.0-rc1-final\lib\net451\Microsoft.Extensions.DependencyInjection.dll"
#r @"Microsoft.Extensions.Logging.1.0.0-rc1-final\lib\net451\Microsoft.Extensions.Logging.dll"
#r @"Microsoft.Extensions.Logging.Abstractions.1.0.0-rc1-final\lib\net451\Microsoft.Extensions.Logging.Abstractions.dll"
#r @"Microsoft.Extensions.OptionsModel.1.0.0-rc1-final\lib\net451\Microsoft.Extensions.OptionsModel.dll"
#r @"Microsoft.Extensions.Primitives.1.0.0-rc1-final\lib\net451\Microsoft.Extensions.Primitives.dll"
#r @"Remotion.Linq.2.0.1\lib\net45\Remotion.Linq.dll"
#r @"System.Collections.Immutable.1.1.36\lib\portable-net45+win8+wp8+wpa81\System.Collections.Immutable.dll"
#r @"System.Diagnostics.DiagnosticSource.4.0.0-beta-23516\lib\dotnet5.2\System.Diagnostics.DiagnosticSource.dll"
#r @"Ix-Async.1.2.5\lib\net45\System.Interactive.Async.dll"
#r "../packages/Microsoft.Extensions.DependencyInjection.Abstractions.1.0.0-rc1-final/lib/net451/Microsoft.Extensions.DependencyInjection.Abstractions.dll"
#r @"FSharp.EntityFramework.MicrosoftSqlServer.0.0.2.0-alpha\lib\net451\FSharp.EntityFramework.MicrosoftSqlServer.dll"

open System
open Foogle
open Deedle
open FSharp.Data
open System.Data.SqlClient
open Microsoft.Data.Entity

发送到 REPL 以确保你有所有需要的库。在脚本中添加以下代码以从我们的 AdventureWorks SQL Server 数据库中获取数据。你会注意到我直接将数据管道到 Deedle 的数据框中:

[<Literal>]
let connectionString = "data source=nc54a9m5kk.database.windows.net;initial catalog=AdventureWorks2014;user id=chickenskills@nc54a9m5kk;password=sk1lzm@tter;"

type AdventureWorks = SqlServer<connectionString, Pluralize = true>
let context = new AdventureWorks()

let salesNames = ["Date"; "Sales"]
let salesByDay = 
    query { for soh in context.``Sales.SalesOrderHeaders`` do
            join sod in context.``Sales.SalesOrderDetails`` on (soh.SalesOrderID = sod.SalesOrderID)
            where (soh.OrderDate > DateTime(2013,5,1))
            select(soh)}
            |> Seq.countBy(fun soh -> soh.OrderDate)
            |> Frame.ofRecords
            |> Frame.indexColsWith salesNames
            |> Frame.indexRowsDate "Date"
            |> Frame.sortRowsByKeySend it to the REPL to get this:
                         Sales 
5/2/2013 12:00:00 AM  -> 9     
5/3/2013 12:00:00 AM  -> 9     
:                        ...   
6/30/2014 12:00:00 AM -> 96    

回到脚本中,添加一些存储在 Yahoo Finance CSV 文件中的数据。在这种情况下,这是道琼斯工业平均指数的每日股价变化:

let stockNames = ["Date"; "PriceChange"]
type Stocks = CsvProvider<"http://ichart.finance.yahoo.com/table.csv?s=^DJI">
let dow = Stocks.Load("http://ichart.finance.yahoo.com/table.csv?s=^DJI")
let stockChangeByDay = 
    dow.Rows |> Seq.map(fun r -> r.Date, (r.``Adj Close`` - r.Open)/r.Open)
             |> Frame.ofRecords
             |> Frame.indexColsWith stockNames
             |> Frame.indexRowsDate "Date"
             |> Frame.sortRowsByKey

发送到 REPL 以获取以下内容:

type Stocks = CsvProvider<...>
val dow : CsvProvider<...>
val stockChangeByDay : Frame<int,string> =

 PriceChange 
1/29/1985 12:00:00 AM  -> 0.0116614159112959501515062411 
1/30/1985 12:00:00 AM  -> -0.0073147907201291486627914499 
:                         ... 
11/25/2015 12:00:00 AM -> -0.000416362767587419771025076 
11/27/2015 12:00:00 AM -> 0.0004128690819110368634773694 

回到脚本,并添加一些由 Quandl API 以 JSON 格式提供的数据。在这种情况下,是比利时皇家天文台记录的太阳黑子数量。

let sunspotNames = ["Date"; "Sunspots"]

type Sunspots = JsonProvider<"https://www.quandl.com/api/v3/datasets/SIDC/SUNSPOTS_D.json?start_date=2015-10-01&end_date=2015-10-01">
let sunspots = Sunspots.Load("https://www.quandl.com/api/v3/datasets/SIDC/SUNSPOTS_D.json?start_date=2013-05-01")
let sunspotsByDay = 
    sunspots.Dataset.Data |> Seq.map(fun r -> r.DateTime, Seq.head r.Numbers ) 
                          |> Frame.ofRecords
                          |> Frame.indexColsWith sunspotNames
                          |> Frame.indexRowsDate "Date"
                          |> Frame.sortRowsByKey

当你将其发送到 FSI 时,你应该会得到以下类似的结果:

val sunspotsByDay : Frame<DateTime,string> =

 Sunspots 
5/1/2013 12:00:00 AM   -> 142.0 
5/2/2013 12:00:00 AM   -> 104.0 
:                         ... 
10/30/2015 12:00:00 AM -> 88.0 
10/31/2015 12:00:00 AM -> 83.0

最后,回到脚本并将所有三个数据帧合并:

let dataFrame = salesByDay |> Frame.join JoinKind.Inner stockChangeByDay
                           |> Frame.join JoinKind.Inner sunspotsByDay

将其发送到 REPL 会得到:

val dataFrame : Frame<DateTime,string> =

 PriceChange                     Sales Sunspots 
5/2/2013 12:00:00 AM  -> 0.0088858122275952653140731221  9     104.0 
5/3/2013 12:00:00 AM  -> 0.0095997784626598973212920005  9     98.0 
:                        ...                             ...   ... 
6/27/2014 12:00:00 AM -> 0.0002931965456766616196704027  82    67.0 
6/30/2014 12:00:00 AM -> -0.0015363085597738848688182542 96    132.0 

我们将创建模型的过程留给读者,看看道琼斯价格变动和每日销售数量之间是否存在关系。在你过于沉迷之前,你可能想要考虑这个关于没有关系但相关联的数据元素网站(tylervigen.com/spurious-correlations)。我认为这是我最喜欢的一个:

非 SQL 类型提供者

合并数据

有时从源系统获得的数据可能是不完整的。考虑这个从州交通部办公室获得的交通事故位置数据集:

合并数据

注意到纬度和经度缺失,并且位置不使用正常的地址/城市/州模式。相反,它是OnRoadMilesFromRoadTowardRoad。不幸的是,当从公共实体获取数据时,这种情况相当普遍——系统可能是在纬/经成为主流之前建立的,系统的地址设计可能只适用于系统内部。这意味着我们需要一种方法来确定这种非典型地址的纬度和经度。

如果你从网站上拉取源代码,你会看到几个脚本文件。第一个叫做BingGeocode。这是一个脚本,它会调用必应地图 API 并为给定的地址返回地理位置。关键在于,尽管必应不识别OnRoad/FromRoad/TowardRoad,但它确实识别交叉街道。因此,我们可以从事故数据集中抽取样本,这些事故发生在或接近交叉口——只要Miles值相对较低,我们就可以从OnRoad/FromRoad中确定这一点。事实上,90%的记录都在交叉口四分之一英里范围内。

如果你检查代码,你会看到这里没有什么特别新的东西。我们使用 JSON 类型提供者调用必应,并解析结果,使用Option类型返回无或某些地理位置。如果你想在自己的机器上运行这个,我们需要在这里注册必应地图 API 开发者计划(www.bingmapsportal.com/)并将你的值放入apiKey

#r "../packages/FSharp.Data.2.2.5/lib/net40/FSharp.Data.dll"

open System.IO
open System.Text
open FSharp.Data

[<Literal>]
let sample = "..\Data\BingHttpGet.json"
type Context = JsonProvider<sample>

let getGeocode address =
    let apiKey = "yourApiKeyHere"
    let baseUri = "http://dev.virtualearth.net/REST/v1/Locations?q=" + address + "&o=json&key=" + apiKey
    let searchResult = Context.Load(baseUri)
    let resourceSets = searchResult.ResourceSets
    match resourceSets.Length with
    | 0 -> None
    | _ -> let resources = resourceSets.[0].Resources
           match resources.Length with
           | 0 -> None
           | _ -> let resource = resources.[0]
                  Some resource.GeocodePoints

let address = "1%20Microsoft%20Way%20Redmond%20WA%2098052"
let address' = "Webser st and Holtz ln Cary,NC"

getGeocode address'

在解决方案中,还有一个脚本文件负责从数据库中提取原始事故数据,更新其经纬度,并将其放回数据库。这个脚本文件名为UpdateCrashLatLon.fsx。如果你查看代码,第一部分会下载发生在与交通停止地点相同的城镇内,且距离交叉口四分之一英里以内的事故。然后它创建一个地址字符串,传递给 Bing 地理编码文件,并创建一个包含 ID 和经纬度的框架。然后我们只过滤出返回值为 some 的值。

#r "../packages/FSharp.Data.2.2.5/lib/net40/FSharp.Data.dll"
#r "System.Data.Entity.dll"
#r "FSharp.Data.TypeProviders.dll"
#r "System.Data.Linq.dll"
#load "BingGeocode.fsx"

open System
open System.Data.Linq
open System.Data.Entity
open Microsoft.FSharp.Data.TypeProviders

[<Literal>]
let connectionString = "data source=nc54a9m5kk.database.windows.net;initial catalog=Traffic;user id=chickenskills@nc54a9m5kk;password=sk1lzm@tter;"

type EntityConnection = SqlEntityConnection<connectionString,Pluralize = true>
let context = EntityConnection.GetDataContext()

type Crash = {Id: int; OnRoad:string; FromRoad:string }

let trafficCrashes = 
    context.dbo_TrafficCrashes 
    |> Seq.filter(fun tc -> tc.MunicipalityId = Nullable<int>(13))
    |> Seq.filter(fun tc -> (float)tc.Miles <= 0.25)
    |> Seq.map(fun tc -> {Id=tc.Id; OnRoad=tc.OnRoad; FromRoad=tc.FromRoad})
    |> Seq.toArray

let trafficCrashes' = 
    trafficCrashes 
    |> Array.map(fun c -> c.Id, c.OnRoad + " and " + c.FromRoad + " Cary,NC")
    |> Array.map(fun (i,l) -> i, BingGeocode.getGeocode(l))

let trafficCrashes'' = 
    trafficCrashes' 
    |> Array.filter(fun (i,p) -> p.IsSome)
    |> Array.map(fun (i,p) -> i, p.Value.[0].Coordinates.[0], p.Value.[0].Coordinates.[1])

在这个脚本中增加了一行新代码:#load "BingGeocode.fsx"。这为脚本文件添加了一个引用,因此我们可以继续调用getGeocode()函数。

在我们用数据更新数据库之前,我编写了一个脚本将数据写入本地磁盘:

//Write so we can continue to work without going to Bing again
//They throttle so you really only want to go there once
open System.IO
let baseDirectory = System.IO.DirectoryInfo(__SOURCE_DIRECTORY__)
let dataDirectory = baseDirectory.Parent.Parent.FullName + @"\Data"

use outFile = new StreamWriter(dataDirectory + @"\crashGeocode.csv")
trafficCrashes'' |> Array.map (fun (i,lt,lg) -> i.ToString() ,lt.ToString(), lg.ToString())
                 |> Array.iter (fun (i,lt,lg) -> outFile.WriteLine(sprintf "%s,%s,%s" i lt lg))
outFile.Flush
outFile.Close()

如注释所述,Bing 限制了每小时可以发送的请求数量。你最不希望的事情就是在实验数据时需要重新查询 Bing,因为你达到了限制,然后收到 401 错误。相反,最好是将数据一次性本地化,然后基于本地副本进行工作。

数据本地化后,我们就可以从数据库中拉取我们想要更新的每条记录,更新经纬度,并将其写回数据库:

type Crash' = {Id: int; Latitude: float; Longitude: float}

let updateDatabase (crash:Crash') =
    let trafficCrash = 
        context.dbo_TrafficCrashes 
        |> Seq.find(fun tc -> tc.Id = crash.Id)
    trafficCrash.Latitude <- Nullable<float>(crash.Latitude)
    trafficCrash.Longitude <- Nullable<float>(crash.Longitude)
    context.DataContext.SaveChanges() |> ignore

open FSharp.Data
type CrashProvider = CsvProvider<"../Data/crashGeocode.csv">
let crashes = 
    CrashProvider.Load("../Data/crashGeocode.csv").Rows
    |> Seq.map(fun r -> {Id=r.id; Latitude=float r.latitude; Longitude= float r.longitude})
    |> Seq.toArray
    |> Array.iter(fun c -> updateDatabase(c))

并行处理

我想向你展示一个能大大加快数据提取速度的技巧——并行处理。我的机器有四个核心,但在先前的例子中,当调用 Bing 的 API 时,只有一个核心被使用。如果我能使用所有核心并行发送请求,将会快得多。F#让这变得非常简单。作为一个演示,我重新查询了前 200 条事故记录,并将时间输出到 FSI:

let trafficCrashes = 
    context.dbo_TrafficCrashes
    |> Seq.filter (fun tc -> tc.MunicipalityId = Nullable<int>(13))
    |> Seq.filter (fun tc -> (float)tc.Miles <= 0.25)
    |> Seq.map (fun tc -> {Id=tc.Id; OnRoad=tc.OnRoad; FromRoad=tc.FromRoad})
    |> Seq.take 200
    |> Seq.toArray

open System.Diagnostics
let stopwatch = Stopwatch()
stopwatch.Start()
let trafficCrashes' = 
    trafficCrashes 
    |> Array.map (fun c -> c.Id, c.OnRoad + " and " + c.FromRoad + " Cary,NC")
    |> Array.map (fun (i,l) -> i, BingGeocode.getGeocode(l))

stopwatch.Stop()
printfn "serial - %A" stopwatch.Elapsed.Seconds 

当我运行它时,耗时 33 秒:

serial - 33

接下来,我添加了以下代码:

stopwatch.Reset()

open Microsoft.FSharp.Collections.Array.Parallel

stopwatch.Start()
let pTrafficCrashes' = 
    trafficCrashes 
    |> Array.map (fun c -> c.Id, c.OnRoad + " and " + c.FromRoad + " Cary,NC")
    |> Array.Parallel.map (fun (i,l) -> i, BingGeocode.getGeocode(l))

stopwatch.Stop()
printfn "parallel - %A" stopwatch.Elapsed.Seconds

注意,唯一的改变是添加了对Collections.Array.Parallel的引用,然后考虑以下这一行:

|> Array.map (fun (i,l) -> i, BingGeocode.getGeocode(l))

将这一行改为以下内容:

|> Array.Parallel.map (fun (i,l) -> i, BingGeocode.getGeocode(l))

当我运行它时,我在 FSI 中看到了以下内容:

parallel - 12

所以,通过改变一行代码,我实现了 3 倍的速度提升。因为 F#是从底层构建时就考虑了并行性和异步操作,所以利用这些概念非常容易。其他语言则是将这些特性附加上去,使用起来可能会非常繁琐,而且经常会导致竞态条件或更糟糕的情况。

当从网络服务中提取大量数据时,还有一点需要注意。除非你明确编码,否则你实际上没有真正的方法来监控进度。我经常打开 Fiddler(www.telerik.com/fiddler)来监控 HTTP 流量,以查看进度情况。

并行处理

JSON 类型提供者 – 认证

JSON 类型提供者是一个非常实用的工具,但它的默认实现存在一个限制——它假设网络服务没有认证或者认证令牌是查询字符串的一部分。有些数据集并不是这样——事实上,大多数网络服务使用头部进行认证。幸运的是,有一种方法可以绕过这个限制。

考虑这个公开数据集——NOAA 档案(www.ncdc.noaa.gov/cdo-web/webservices/v2)。如果你查看章节中附带解决方案,有一个名为GetWeatherData.fsx的脚本文件。在这个脚本中,我选择了一个小镇的交通停止和事故发生的单个邮政编码,并下载了每日降水量:

#r "System.Net.Http.dll"
#r "../packages/FSharp.Data.2.2.5/lib/net40/FSharp.Data.dll"

open System
open System.Net
open FSharp.Data
open System.Net.Http
open System.Net.Http.Headers
open System.Collections.Generic

[<Literal>]
let uri = "http://www.ncdc.noaa.gov/cdo-web/api/v2/data?datasetid=GHCND&locationid=ZIP:27519&startdate=2012-01-01&enddate=2012-12-31&limit=1000"
let apiToken = "yourApiTokenHere"
use client = new WebClient()
client.Headers.Add("token", apiToken)
let resultJson = client.DownloadString(uri)

[<Literal>]
let weatherSample = "..\Data\NOAAHttpGet.json"
type weatherServiceContext = JsonProvider<weatherSample>
let searchResult = weatherServiceContext.Parse(resultJson)
let results = searchResult.Results

let dailyPrecipitation = 
    results 
    |> Seq.where (fun r -> r.Value > 0)
    |> Seq.groupBy (fun r -> r.Date)
    |> Seq.map (fun (d,a) -> d, a |> Seq.sumBy (fun r -> r.Value))
    |> Seq.sortBy (fun (d,c) -> d) 

这里有一件新事物。我正在使用 JSON 类型提供者,但授权令牌需要放在请求的头部。由于 JSON 类型提供者不允许你设置头部,你需要通过System.Net.WebClient类(你可以在其中设置auth令牌在头部)下载数据,然后使用 JSON 类型提供者来解析结果。你可以看到,在下面的行中,我使用的是Parse()而不是Load()来完成这个任务:

let searchResult = weatherServiceContext.Parse(resultJson)

就像地理位置数据一样,我将数据帧推送到磁盘,因为请求数量有限:

open System.IO
let baseDirectory = System.IO.DirectoryInfo(__SOURCE_DIRECTORY__)
let dataDirectory = baseDirectory.Parent.Parent.FullName + @"\Data"

use outFile = new StreamWriter(dataDirectory + @"\dailyPrecipitation.csv")
dailyPrecipitation 
    |> Seq.map(fun (d,p) -> d.ToString(), p.ToString())
    |> Seq.iter(fun (d,p) -> outFile.WriteLine(sprintf "%s,%s" d p))

outFile.Flush
outFile.Close()

此外,就像数据地理位置数据一样,你可以在你的机器上做这件事,但你将需要一个apiToken。你可以访问 NOAA 开发者网站申请一个。我还将数据添加到了 SQL Server 上的一个表格中,这样你就不需要从源代码中拉取数据来编写章节中剩余的代码。进入活动的kmeans.fsx脚本文件,输入以下内容以从数据库中获取数据:

type DailyPercipitation = {WeatherDate: DateTime; Amount: int; }
let dailyWeather = 
    context.dbo_DailyPercipitation 
    |> Seq.map(fun dw -> {WeatherDate=dw.RecordDate; Amount=dw.Amount;})
    |> Seq.toArray

当你将其发送到 FSI 时,你会得到以下内容:

type DailyPercipitation =
 {WeatherDate: DateTime;
 Amount: int;}
val dailyWeather : DailyPercipitation [] =
 [|{WeatherDate = 1/9/2012 12:00:00 AM;
 Amount = 41;};
 {WeatherDate = 1/10/2012 12:00:00 AM;
 Amount = 30;}; {WeatherDate = 1/11/2012 12:00:00 AM;
 Amount = 5;};
 {WeatherDate = 1/12/2012 12:00:00 AM;
 Amount = 124;}; 
 {WeatherDate = 1/13/2012 12:00:00 AM;
 Amount = 5;}; 
 {WeatherDate = 1/21/2012 12:00:00 AM;
...

摘要

如果你问数据科学家他们最不喜欢他们的一天中的什么,他们会告诉你会议、制作幻灯片和按无特定顺序整理数据。尽管 F#类型提供者不能帮助你处理会议和制作幻灯片,但它可以减少获取和清理数据所需的时间。尽管不是完全无摩擦,类型提供者可以帮助你处理关系型和非关系型数据存储,并使你能够有更多时间投入到数据科学的“有趣”部分。说到这里,让我们回到 KNN 和朴素贝叶斯建模的乐趣中吧。

第六章。AdventureWorks Redux – k-NN and Naïve Bayes Classifiers

让我们回到 AdventureWorks,重新戴上我们的软件工程师帽子。在你成功实施了一个模型来提高向个人客户销售高利润自行车后的几周,CEO 来到你的办公桌前说:“你能帮助我们解决一个问题吗?如果你不知道,我们最初是一家只卖自行车的公司。然后在 2013 年 5 月,我们增加了我们的产品线。尽管一开始进展顺利,但我们似乎已经达到了顶峰。我们想在这个领域再努力一些。通过一些基本的 PowerBI 报告,我们看到购买自行车的客户中有 86%到 88%在购买时也购买了额外的商品。”

年月 交叉 独立 总计 %交叉
201305 25 295 320 7.8%
201306 429 69 498 86.1%
201307 441 56 497 88.7%
201308 525 83 608 86.3%
201309 536 68 604 88.7%
201310 649 100 749 86.6%
201311 868 136 1,004 86.5%
201312 698 99 797 87.6%
201401 800 97 897 89.2%
201402 702 96 798 88.0%
201403 891 135 1,026 86.8%
201404 965 121 1,086 88.9%
201405 1,034 152 1,186 87.2%
总计 8,563 1,507 10,070 85.0%

AdventureWorks Redux – k-NN and Naïve Bayes Classifiers

CEO 继续说:“我们非常希望能够将这个比例提高到 90%以上。我们发起了一项昂贵的营销活动,但它并没有真正推动指针的移动。你能否帮助我们更加专注,并识别那些处于交叉销售机会边缘的客户?”

你回答:“当然可以,”然后立即开始思考如何实施她的指示。也许如果你能识别出那些购买额外商品的客户与那些没有购买额外商品的客户的一些独特特征,就可以实施一个更有针对性的方法来吸引更多人购买额外商品。你立刻想到了分类模型,如K-Nearest Neighbor(k-NN)和朴素贝叶斯。由于你不确定哪一个可能有效,你决定尝试它们两个。

k-Nearest Neighbors (k-NN)

k-NN 代表 k-Nearest Neighbors,是可用的最基本分类模型之一。因为“一图胜千言”,让我们从图形的角度来看一下 k-NN。考虑一组在考试前一晚花了一些时间学习和喝酒的学生。在图表上,它看起来像这样:

k-Nearest Neighbors (k-NN)

如果我在图表中添加一个像这样的第七个学生,你会认为这个学生通过了还是失败了考试?

k-Nearest Neighbors (k-NN)

你可能会说他们是一个明星——他们通过了考试。如果我问你为什么,你可能会说他们更像是其他明星。这种心理处理方式非常类似于我们的思维方式——如果你们邻居都买日本车并认为它们质量高,那么如果你在寻找一辆高质量的车,你更有可能也会买一辆。事实上,市场营销中的很大一部分是基于 k-NN 理论的。

与大脑能够轻松建立关联不同,k-NN 实际上使用了一些数学来分类。回到我们的第七个学生,k-NN 会把他们放入通过考试的学生组,因为与其他通过考试的学生相比,他们之间的距离较短,而与未通过考试的学生相比距离较远:

k-Nearest Neighbors (k-NN)

实际上,k-NN 最简单的实现之一就是取该类别所有项目的平均值(平均五小时学习和喝一杯啤酒),然后测量这个距离到新项目。希望现在 k-NN 的名字对你来说有了一定的意义——对于一个给定的新项目 K,它的最近邻是什么?

k-NN 示例

让我们看看如何使用 Accord.NET 来实际操作 k-NN。打开 Visual Studio 并创建一个名为 Classification 的新 Visual F# Windows Library 项目:

k-NN example

进入 Script.fsx 文件并删除其所有内容。将 Scipt.fsx 重命名为 k-NNAccord.fsx。打开 NuGet 包管理器 控制台并输入以下内容:

PM> install-package Accord.MachineLearning

回到你的脚本,输入以下代码:

#r "../packages/Accord.3.0.2/lib/net40/Accord.dll"
#r "../packages/Accord.Math.3.0.2/lib/net40/Accord.Math.dll"
#r "../packages/Accord.MachineLearning.3.0.2/lib/net40/Accord.MachineLearning.dll"

open Accord
open Accord.Math
open Accord.MachineLearning

let inputs = [|[|5.0;1.0|];[|4.5;1.5|];[|5.1;0.75|];[|1.0;3.5|];[|0.5;4.0|];[|1.25;4.0|]|]
let outputs = [|1;1;1;0;0;0|]

let classes = 2
let k = 3
let knn = new KNearestNeighbors(k, classes, inputs, outputs)

将这些发送到 REPL,你会看到以下结果:

val inputs : float [] [] =
 [|[|5.0; 1.0|]; [|4.5; 1.5|]; [|5.1; 0.75|]; [|1.0; 3.5|]; [|0.5; 4.0|];
 [|1.25; 4.0|]|]
val outputs : int [] = [|1; 1; 1; 0; 0; 0|]
val classes : int = 2
val k : int = 3
val knn : KNearestNeighbors

到现在为止,这段代码应该对你来说已经很熟悉了。输入代表六个学生的两个特征:他们在考试前一晚学习了多少小时以及他们喝了多少啤酒。输出代表他们是否通过了考试:1代表通过,0代表未通过。班级的值告诉 Accord 有两种类型的值需要考虑。在这种情况下,这些值是学习时间和啤酒消费量。k 值告诉 Accord 对于每个类别我们希望使用多少个数据点进行计算。如果我们将其改为 4,那么我们就会包括一个未通过考试的学生和三个通过考试的学生(反之亦然),这会稀释我们的结果。

回到脚本中,输入代表第七个学生的以下行:

let input = [|5.0;0.5|]
let output = knn.Compute input

当你将其发送到 FSI 时,你会看到学生编号 7 很可能通过考试:

val input : float [] = [|5.0; 0.5|]
val output : int = 1

如我之前提到的,k-NN 是你可以使用的最基础的机器学习模型之一,但在某些情况下它可以非常强大。对 k-NN 更常见的一种调整是权衡邻居的距离。一个点离邻居越近,这个距离的权重就越大。k-NN 最主要的批评之一是,如果有很多观察值围绕一个点,它可能会过度权衡,因此如果可能的话,拥有一个平衡的数据集是很重要的。

Naïve Bayes

简单贝叶斯是一种分类模型,试图预测一个实体是否属于一系列预定义的集合。当你将这些集合汇总在一起时,你会有一个相当好的最终结果估计。为了说明,让我们回到我们讨论决策树时使用的网球示例。

对于两周的观察,我们有以下发现:

天气展望 温度 湿度 打网球?
0 晴朗 炎热
1 晴朗 炎热
2 阴天 炎热
3 温和
4 凉爽 正常
5 凉爽 正常
6 阴天 凉爽 正常
7 晴朗 温和
8 晴朗 凉爽 正常
9 温和 正常
10 晴朗 温和 正常
11 阴天 温和
12 阴天 炎热 正常
13 温和

对于每一类,让我们分析他们那天是否打网球,然后为每种可能性计算一个百分比:

ID 天气展望 % 是 % 否
0 晴朗 2 3 0.22 0.60
1 阴天 4 0 0.44 0.00
2 3 2 0.33 0.40
总计 9 5 1.00 1.00
ID 温度 % 是 % 否
0 2 2 0.22 0.40
1 温和 4 2 0.44 0.40
2 凉爽 3 1 0.33 0.20
总计 9 5 1.00 1.00
ID 湿度 % 是 % 否
0 3 4 0.33 0.80
1 正常 6 1 0.67 0.20
总计 9 5 1.00 1.00
ID % 是 % 否
0 6 2 0.67 0.40
1 3 3 0.33 0.60
总计 9 5 1.00 1.00
ID 最终 % 是 % 否
0 9 5 0.64 0.36

有这些网格可用时,我们就可以预测一个人在一系列条件下是否会打网球。例如,一个人会在晴朗、凉爽、高湿度和大风的日子里打网球吗?我们可以从每个网格中提取百分比:

|   |   | 是 | 否 |
| --- | --- | --- | --- | --- | --- |
| 天气展望 | 晴朗 | 0.222 | 0.600 |
| 温度 | 凉爽 | 0.333 | 0.200 |
| 湿度 | 高 | 0.333 | 0.800 |
| | 强 | 0.333 | 0.600 |
|   | 最终 | 0.643 | 0.357 |

然后,可以将每个可能性的值相乘:

  • 是的概率 = 0.222 * 0.333 * 0.333 * 0.333 * 0.643 = 0.005

  • 否的概率 = 0.600 * 0.200 * 0.800 * 0.600 * 0.357 = 0.021

你可以看到不打球的比例高于打球的比例。我们还可以将这两个百分比进行比较,如下所示:

0.005 + 0.021 = 0.026

0.005/0.026 = 0.205 和 0.021/0.026 = 0.795

打网球的可能性大约有 20%,而不打的可能性有 80%。

朴素贝叶斯在行动

让我们看看 Accord.NET 是如何计算朴素贝叶斯模型的。转到 Visual Studio 并添加一个名为NaiveBayesAccord.fsx的新脚本文件:

在那个脚本中,添加以下代码:

#r "../packages/Accord.3.0.2/lib/net40/Accord.dll"
#r "../packages/Accord.Math.3.0.2/lib/net40/Accord.Math.dll"
#r "../packages/Accord.Statistics.3.0.2/lib/net40/Accord.Statistics.dll"
#r "../packages/Accord.MachineLearning.3.0.2/lib/net40/Accord.MachineLearning.dll"

open Accord
open Accord.Math
open Accord.Statistics
open Accord.MachineLearning.Bayes

let inputs = [|[|0;0;0;0|];[|0;0;0;1|];[|1;0;0;0|];
               [|2;1;0;0|];[|2;2;1;0|];[|2;2;1;1|];
               [|1;2;1;1|];[|0;1;0;0|];[|0;2;1;0|];
               [|2;1;1;0|];[|0;2;1;1|];[|1;1;0;1|];
               [|1;0;1;0|];[|2;1;0;1|]|]

let outputs = [|0;0;1;1;1;0;1;0;1;1;1;1;1;0|]

let symbols = [|3;3;2;2|]

当你将它们发送到 FSI 时,你会看到以下内容:

val inputs : int [] [] =
 [|[|0; 0; 0; 0|]; [|0; 0; 0; 1|]; [|1; 0; 0; 0|]; [|2; 1; 0; 0|];
 [|2; 2; 1; 0|]; [|2; 2; 1; 1|]; [|1; 2; 1; 1|]; [|0; 1; 0; 0|];
 [|0; 2; 1; 0|]; [|2; 1; 1; 0|]; [|0; 2; 1; 1|]; [|1; 1; 0; 1|];
 [|1; 0; 1; 0|]; [|2; 1; 0; 1|]|]

>

val outputs : int [] = [|0; 0; 1; 1; 1; 0; 1; 0; 1; 1; 1; 1; 1; 0|]

>

val symbols : int [] = [|3; 3; 2; 2|]

输入是将值转换为整数。考虑以下示例:

展望 ID
晴朗 0
多云 1
2
温度 ID
炎热 0
温和 1
2
湿度 ID
0
正常 1
ID
0
1

每个数组中的位置是 [展望;温度;湿度;风]

输出结果是结果值转换为整数:

打球 ID
0
1

符号值是一个数组,它告诉 Accord 每个特征的可能的值的总数。例如,第一个位置是展望,有三个可能的值:(0, 1, 2)。

返回脚本并添加朴素贝叶斯计算:

let bayes = new Accord.MachineLearning.Bayes.NaiveBayes(4,symbols)
let error = bayes.Estimate(inputs, outputs)

将数据发送到 REPL 会得到以下结果:

val bayes : Bayes.NaiveBayes
val error : float = 0.1428571429

错误是通过 Accord 重新运行其估计多次并比较实际值与预期值来计算的。解释错误的一个好方法是,数字越低越好,领域决定了实际数字是否“足够好”。例如,14%的错误率对于人类能够进行随机和不可预测行为的社交实验来说是非常好的。相反,对于预测飞机引擎故障,14%的错误率是不可接受的。

最后,让我们看看对晴朗天气、温和温度、正常湿度和弱风的预测。转到脚本并添加以下内容:

let input = [|0;1;1;0|]
let output = bayes.Compute(input)

将数据发送到 REPL 会得到以下结果:

val input : int [] = [|0; 1; 1; 0|]
val output : int = 1

因此,我们将在那天打网球。

使用朴素贝叶斯时需要注意的一件事

20 世纪 50 年代创建的朴素贝叶斯是一种非常有效的分类模型,它经受了时间的考验。事实上,今天许多垃圾邮件过滤器部分仍在使用朴素贝叶斯。使用朴素贝叶斯的最大优点是其简单性和正确性的能力。最大的缺点是关键假设是每个 x 变量都是完全且完全独立的。如果 x 变量有任何可能存在共线性,朴素贝叶斯就会失效。此外,从历史上看,朴素贝叶斯被应用于高斯分布的数据集——即它遵循钟形曲线。如果你不熟悉钟形曲线,它是一种数据分布,其中大多数观测值发生在中间值,中间两侧的异常值具有大致相同数量的观测值。以下是一个例子:

使用朴素贝叶斯时需要注意的一件事

相反,偏斜分布的观测值最多在一端或另一端:

使用朴素贝叶斯时需要注意的一件事

当您使用朴素贝叶斯时,您必须确保选择的分布与您的数据匹配。现在让我们看看 k-NN 和/或朴素贝叶斯是否可以帮助我们处理 AdventureWorks。

AdventureWorks

在本节中,我们将利用我们在第五章,“时间暂停 – 获取数据”中获得的知识,提取和转换数据,并应用 k-NN 和朴素贝叶斯机器学习模型。让我们看看这三种方法中是否有任何一种可以帮助我们提高交叉销售。

准备数据

进入 Visual Studio 并添加另一个名为AdventureWorks.fsx的脚本。打开脚本,删除所有内容,并打开NuGet 包管理器控制台。在包管理器中,运行以下行:

PM> Install-Package FSharp.EntityFramework.MicrosoftSqlServer –Pre
PM> Install-Package fslab
PM> Install-Package FSharp.Data.SqlClient
PM> Install-Package Microsoft.SqlServer.Types

返回脚本文件并添加以下引用:

#I "../packages"

#r "EntityFramework.Core.7.0.0-rc1-final/lib/net451/EntityFramework.Core.dll"
#r "EntityFramework.MicrosoftSqlServer.7.0.0-rc1-final/lib/net451/EntityFramework.MicrosoftSqlServer.dll"
#r "EntityFramework.Relational.7.0.0-rc1-final/lib/net451/EntityFramework.Relational.dll"
#r "Inflector.1.0.0.0/lib/net45/Inflector.dll"
#r "Microsoft.Extensions.Caching.Abstractions.1.0.0-rc1-final/lib/net451/Microsoft.Extensions.Caching.Abstractions.dll"
#r "Microsoft.Extensions.Caching.Memory.1.0.0-rc1-final/lib/net451/Microsoft.Extensions.Caching.Memory.dll"
#r "Microsoft.Extensions.Configuration.1.0.0-rc1-final/lib/net451/Microsoft.Extensions.Configuration.dll"
#r "Microsoft.Extensions.Configuration.Abstractions.1.0.0-rc1-final/lib/net451/Microsoft.Extensions.Configuration.Abstractions.dll"
#r "Microsoft.Extensions.Configuration.Binder.1.0.0-rc1-final/lib/net451/Microsoft.Extensions.Configuration.Binder.dll"
#r "Microsoft.Extensions.DependencyInjection.1.0.0-rc1-final/lib/net451/Microsoft.Extensions.DependencyInjection.dll"
#r "Microsoft.Extensions.Logging.1.0.0-rc1-final/lib/net451/Microsoft.Extensions.Logging.dll"
#r "Microsoft.Extensions.Logging.Abstractions.1.0.0-rc1-final/lib/net451/Microsoft.Extensions.Logging.Abstractions.dll"
#r "Microsoft.Extensions.OptionsModel.1.0.0-rc1-final/lib/net451/Microsoft.Extensions.OptionsModel.dll"
#r "Microsoft.Extensions.Primitives.1.0.0-rc1-final/lib/net451/Microsoft.Extensions.Primitives.dll"
#r "Remotion.Linq.2.0.1/lib/net45/Remotion.Linq.dll"
#r "System.Collections.Immutable.1.1.36/lib/portable-net45+win8+wp8+wpa81/System.Collections.Immutable.dll"
#r "System.Diagnostics.DiagnosticSource.4.0.0-beta-23516/lib/dotnet5.2/System.Diagnostics.DiagnosticSource.dll"
#r "System.Xml.Linq.dll"
#r "Ix-Async.1.2.5/lib/net45/System.Interactive.Async.dll"
#r "FSharp.EntityFramework.MicrosoftSqlServer.0.0.2.0-alpha/lib/net451/FSharp.EntityFramework.MicrosoftSqlServer.dll"

#r "../packages/Microsoft.Extensions.DependencyInjection.Abstractions.1.0.0-rc1-final/lib/net451/Microsoft.Extensions.DependencyInjection.Abstractions.dll"
#r "../packages/FSharp.Data.SqlClient.1.7.7/lib/net40/FSharp.Data.SqlClient.dll"
#r "../packages/Microsoft.SqlServer.Types.11.0.2/lib/net20/Microsoft.SqlServer.Types.dll"
#r "../packages/FSharp.Data.2.2.5/lib/net40/FSharp.Data.dll"

#r "../packages/Accord.3.0.2/lib/net40/Accord.dll"
#r "../packages/Accord.Math.3.0.2/lib/net40/Accord.Math.dll"
#r "../packages/Accord.MachineLearning.3.0.2/lib/net40/Accord.MachineLearning.dll"
#r "../packages/Accord.Statistics.3.0.2/lib/net40/Accord.Statistics.dll"

open System
open FSharp.Data
open FSharp.Data.Entity
open Microsoft.Data.Entity

open Accord
open Accord.Math
open Accord.Statistics
open Accord.MachineLearning
open Accord.Statistics.Filters
open Accord.Statistics.Analysis
open Accord.MachineLearning.Bayes
open Accord.Statistics.Models.Regression
open Accord.Statistics.Models.Regression.Fitting

接着添加以下代码行:

[<Literal>]
let connectionString = "data source=nc54a9m5kk.database.windows.net;initial catalog=AdventureWorks2014;user id=PacktReader;password=P@cktM@chine1e@rning;"
type AdventureWorks = SqlServer<connectionString, Pluralize = true>
let context = new AdventureWorks()

如果您还记得第五章,“时间暂停 – 获取数据”,这是创建我们的类型提供者以从数据库中提取数据。将到目前为止的所有内容发送到 REPL 以查看以下结果:

 nested type Sales.SalesTerritoryHistory
 nested type Sales.ShoppingCartItem
 nested type Sales.SpecialOffer
 nested type Sales.SpecialOfferProduct
 nested type Sales.Store
 nested type dbo.AWBuildVersion
 nested type dbo.DatabaseLog
 nested type dbo.ErrorLog
 end
val context : AdventureWorks

返回脚本并添加以下内容:

let (|=|) id a = Array.contains id a
let productSubcategories = [|new System.Nullable<int>(1); new System.Nullable<int>(2); new System.Nullable<int>(3)|]

将此发送到 FSI 得到以下结果:

val ( |=| ) : id:'a -> a:'a [] -> bool when 'a : equality
val productSubcategories : Nullable<int> [] = [|1; 2; 3|]

这也是从第五章,“时间暂停 – 获取数据”中来的;我们正在重写in运算符以处理数据库中的空值。

返回脚本并添加以下代码:

let orderCustomers = 
    query { for soh in context.``Sales.SalesOrderHeaders`` do
            join sod in context.``Sales.SalesOrderDetails`` on (soh.SalesOrderID = sod.SalesOrderID)
            join p in context.``Production.Products`` on (sod.ProductID = p.ProductID)
            join c in context.``Sales.Customers`` on (soh.CustomerID = c.CustomerID)
            where (soh.OrderDate > DateTime(2013,5,1) && p.ProductSubcategoryID |=| productSubcategories && c.StoreID  = System.Nullable<int>())
            select(soh.SalesOrderID,c.CustomerID)} |> Seq.toArray

将此发送到 REPL,我们得到:

val orderCustomers : (int * int) [] =
 [|(50788, 27575); (50789, 13553); (50790, 21509); (50791, 15969);
 (50792, 15972); (50793, 14457); (50794, 27488); (50795, 27489);
 (50796, 27490); (50797, 17964); (50798, 17900); (50799, 21016);
 (50800, 11590); (50801, 15989); (50802, 14494); (50803, 15789);
 (50804, 24466); (50805, 14471); (50806, 17980); (50807, 11433);
 (50808, 115

尽管我们之前没有见过这段代码,但我们见过与之非常相似的代码。在这个块中,我们正在创建一个计算表达式。我们将SalesOrderHeaderSalesOrderDetailProductsCustomer表连接起来,以便我们只选择对这次分析感兴趣的记录。这将是:2013 年 5 月 1 日之后所有针对个人客户的自行车销售。请注意,我们正在以元组的形式返回两个整数:SalesOrderIdCustomerId

返回脚本并添加以下代码块:

let salesOrderIds = orderCustomers |> Array.distinctBy(fun (soid,coid) -> soid)
                                   |> Array.map(fun (soid,cid) -> soid)

将此发送到 FSI,我们得到以下结果:

val salesOrderIds : int [] =
 [|50788; 50789; 50790; 50791; 50792; 50793; 50794; 50795; 50796; 50797;
 50798; 50799; 50800; 50801; 50802; 50803; 50804; 50805; 50806; 50807;
 50808; 50809

如您可能已经注意到的,这创建了一个唯一的CustomerIds数组。由于一个客户可能购买了两辆自行车,他们可能有两个SalesOrderIds,因此我们需要调用distinctBy高阶函数。

返回脚本并输入以下内容:

let orderDetailCounts = 
    query { for soh in context.``Sales.SalesOrderHeaders`` do
            join sod in context.``Sales.SalesOrderDetails`` on (soh.SalesOrderID = sod.SalesOrderID)
            join p in context.``Production.Products`` on (sod.ProductID = p.ProductID)
            join c in context.``Sales.Customers`` on (soh.CustomerID = c.CustomerID)
            where (sod.SalesOrderID |=| salesOrderIds)
            select(sod.SalesOrderID, sod.SalesOrderDetailID)} 
            |> Seq.countBy(fun (soid, sodid) -> soid)
            |> Seq.toArray

将此发送到 FSI 以获取以下结果(这可能需要几秒钟):

val orderDetailCounts : (int * int) [] =
 [|(50788, 1); (50789, 1); (50790, 1); (50791, 1); (50792, 1); (50793, 1);
 (50794, 1); (50795, 1); (50796, 1); (50797, 1); (50798, 1); (50799, 1);
 (50800, 1); (50801, 1); (50802, 1); (50803, 1); (50804, 1); (50805, 1);
 (50806, 1); (50807

这是一个与第一个类似的查询。这里我们将相同的四个表连接起来,然后选择我们已识别的客户的SalesOrderIdSalesOrderDetailId。然后我们应用countBy高阶函数来计算每个订单的所有细节。如果只有一个OrderDetailId,那么只购买了自行车。如果有多个,那么客户还购买了其他物品。

我们现在必须获取特定客户的详细信息。由于数据库处于第三范式,这些细节散布在许多表中。我们不如使用数据库中已经创建的内置视图:vIndividualCustomer

但问题是,EF 类型提供程序在撰写本文时无法处理视图。这个问题的答案是另一个类型提供程序。

返回到脚本并输入以下内容:

[<Literal>]
let commandText = "Select * from [Sales].[vIndividualCustomer]"
let command = new SqlCommandProvider<commandText,connectionString>()
let output = command.Execute() 
let customers = output |> Seq.toArray

将此发送到 REPL,你可以看到以下结果:

val commandText : string = "Select * from [Sales].[vIndividualCustomer]"
val command : SqlCommandProvider<...>
val output : Collections.Generic.IEnumerable<SqlCommandProvider<...>.Record>
val customers : SqlCommandProvider<...>.Record [] =
 [|{ BusinessEntityID = 9196; Title = None; FirstName = "Calvin"; MiddleName = Some "A"; LastName = "Raji"; Suffix = None; PhoneNumber = Some "230-555-0191"; PhoneNumberType = Some "Cell"; EmailAddress = Some "calvin20@adventure-works.com"; EmailPromotion = 2; AddressType = "Shipping"; AddressLine1 = "5415 San Gabriel Dr."; AddressLine2 = None; City = "Bothell"; StateProvinceName = "Washington"; PostalCode = "98011"; CountryRegionName = "United States"; Demographics = Some
 "<IndividualSurvey ><TotalPurchaseYTD>-13.5</TotalPurchaseYTD><DateFirstPurchase>2003-02-06Z</DateFirstPurchase><BirthDate>1963-06-14Z</BirthDate><MaritalStatus>M</MaritalStatus><YearlyIncome>50001-75000</YearlyIncome><Gender>M</Gender><TotalChildren>4</TotalChildren><NumberChildrenAtHome>2</NumberChildrenAtHome><Education>Bachelors </Education><Occupation>Professional</Occupation><HomeOwnerFlag>1</HomeOwnerFlag><NumberCarsOwned>2</NumberCarsOwned><CommuteDistance>2-5 Miles</CommuteDistance></IndividualSurvey>" };
 { BusinessEntityID

每条记录都是一个怪物!看起来数据库有一个名为IndividualSurvey的字段,其中包含一些客户在调查中收集的数据。有趣的是,他们决定将其存储为 XML。我认为这证明了这样一个公理:如果给定了数据类型,开发者会使用它,无论它是否有意义。无论如何,我们如何解析这个 XML?我会给你一个提示:它与hype divider押韵。没错,就是 XML 类型提供程序。返回到脚本并添加以下代码:

[<Literal>]
let sampleXml = """<IndividualSurvey ><TotalPurchaseYTD>-13.5</TotalPurchaseYTD><DateFirstPurchase>2003-02-06Z</DateFirstPurchase><BirthDate>1963-06-14Z</BirthDate><MaritalStatus>M</MaritalStatus><YearlyIncome>50001-75000</YearlyIncome><Gender>M</Gender><TotalChildren>4</TotalChildren><NumberChildrenAtHome>2</NumberChildrenAtHome><Education>Bachelors </Education><Occupation>Professional</Occupation><HomeOwnerFlag>1</HomeOwnerFlag><NumberCarsOwned>2</NumberCarsOwned><CommuteDistance>2-5 Miles</CommuteDistance></IndividualSurvey>"""
#r "System.Xml.Linq.dll"
type IndividualSurvey = XmlProvider<sampleXml>

let getIndividualSurvey (demographic:Option<string>) =
    match demographic.IsSome with
    | true -> Some (IndividualSurvey.Parse(demographic.Value))
    | false -> None

将此发送到 REPL,我们得到以下结果:

type IndividualSurvey = XmlProvider<...>
val getIndividualSurvey :
 demographic:Option<string> -> XmlProvider<...>.IndividualSurvey option

XML 类型提供程序使用一个代表性样本来生成类型。在这种情况下,sampleXML被用来生成类型。有了这个类型提供程序为我们处理解析 XML 的重活,我们现在可以为每个CustomerId及其人口统计信息创建一个易于使用的格式的数据结构。

返回到脚本并输入以下内容:

let customerDemos = customers |> Array.map(fun c -> c.BusinessEntityID,getIndividualSurvey(c.Demographics))
                              |> Array.filter(fun (id,s) -> s.IsSome)
                              |> Array.map(fun (id,s) -> id, s.Value)
                              |> Array.distinctBy(fun (id,s) -> id)

将此发送到 FSI,我们得到以下结果:

</IndividualSurvey>);
 (2455,
 <IndividualSurvey >
 <TotalPurchaseYTD>26.24</TotalPurchaseYTD>
 <DateFirstPurchase>2004-01-24Z</DateFirstPurchase>
 <BirthDate>1953-04-10Z</BirthDate>
 <MaritalStatus>M</MaritalStatus>
 <YearlyIncome>25001-50000</YearlyIncome>
 <Gender>F</Gender>
 <TotalChildren>2</TotalChildren>
 <NumberChildrenAtHome>0</NumberChildrenAtHome>
 <Education>Bachelors </Education>
 <Occupation>Management</Occupation>
 <HomeOwnerFlag>1</HomeOwnerFlag>
 <NumberCarsOwned>1</NumberCarsOwned>
 <CommuteDistance>5-10 Miles</CommuteDistance>
</IndividualSurvey>);
 ...|]

这里没有太多新的代码。由于我们必须考虑那些没有记录人口统计信息的客户,我们正在使用Option类型。如果有人口统计信息,则返回一个包含值的Some。如果没有,则返回None。然后我们过滤这些信息,只给我们带有人口记录的客户,并调用distinct以确保每个客户只有一个记录。

在客户人口统计信息准备就绪后,我们现在可以构建一个包含我们所需所有信息的最终数据框。返回到脚本文件并输入以下内容:

let getDemoForCustomer customerId =
    let exists = Array.exists(fun (id,d) -> id = customerId) customerDemos
    match exists with
    | true -> Some (customerDemos 
                    |> Array.find(fun (id,d) -> id = customerId)
                    |> snd)
    | false -> None 

let orderCustomerDemo = 
    orderCustomers 
    |> Array.map(fun oc -> oc, getDemoForCustomer(snd oc))
                               |> Array.map(fun (oc,d) -> fst oc, snd oc, d)
                               |> Array.filter(fun (oid,cid,d) -> d.IsSome)
                               |> Array.map(fun (oid,cid,d) -> oid,cid,d.Value) 

将此发送到 FSI,你可以看到以下结果:

</IndividualSurvey>);
 (50949, 19070,
 <IndividualSurvey >
 <TotalPurchaseYTD>27.7</TotalPurchaseYTD>
 <DateFirstPurchase>2003-08-20Z</DateFirstPurchase>
 <BirthDate>1966-07-08Z</BirthDate>
 <MaritalStatus>S</MaritalStatus>
 <YearlyIncome>greater than 100000</YearlyIncome>
 <Gender>F</Gender>
 <TotalChildren>2</TotalChildren>
 <NumberChildrenAtHome>2</NumberChildrenAtHome>
 <Education>Bachelors </Education>
 <Occupation>Management</Occupation>
 <HomeOwnerFlag>0</HomeOwnerFlag>
 <NumberCarsOwned>4</NumberCarsOwned>
 <CommuteDistance>0-1 Miles</CommuteDistance>
</IndividualSurvey>);
 ...|]

我们现在有一个包含三个元素的元组:OrderIdCustomerId以及人口统计信息。请注意,输出仍然显示人口统计信息为 XML,尽管我们将在下一秒看到,这些元素实际上是人口类型的一部分。

进入脚本文件并输入以下内容:

let getMultiOrderIndForOrderId orderId =
    orderDetailCounts 
    |> Array.find(fun (oid,c) -> oid = orderId)
    |> snd > 1

let orders = 
    orderCustomerDemo 
    |> Array.map(fun (oid,cid,d) -> oid, getMultiOrderIndForOrderId(oid), d)

将此发送到 REPL,我们得到以下结果:

 (50949, false,
 <IndividualSurvey >
 <TotalPurchaseYTD>27.7</TotalPurchaseYTD>
 <DateFirstPurchase>2003-08-20Z</DateFirstPurchase>
 <BirthDate>1966-07-08Z</BirthDate>
 <MaritalStatus>S</MaritalStatus>
 <YearlyIncome>greater than 100000</YearlyIncome>
 <Gender>F</Gender>
 <TotalChildren>2</TotalChildren>
 <NumberChildrenAtHome>2</NumberChildrenAtHome>
 <Education>Bachelors </Education>
 <Occupation>Management</Occupation>
 <HomeOwnerFlag>0</HomeOwnerFlag>
 <NumberCarsOwned>4</NumberCarsOwned>
 <CommuteDistance>0-1 Miles</CommuteDistance>
</IndividualSurvey>);
 ...|]

getMultiOrderIndForOrderId 是一个函数,它接受 orderId 并在 orderDetailsCounts 数据帧中查找记录。如果有多个,则返回 true。如果只有一个订单(只有自行车),则返回 false

使用这个函数,我们可以创建一个包含 orderIdmultiOrderind 和人口统计信息的元组。我认为我们准备好开始建模了!在我们开始之前,我们需要问自己一个问题:我们想使用哪些值?y 变量很明确——multiOrderInd。但我们在模型中作为 x 变量插入哪个人口统计值呢?由于我们想要根据模型结果调整我们的网站,我们可能需要可以在网站上使用的变量。如果用户通过 Facebook 或 Google 账户登录我们的网站,那么这些账户将准确填写相关信息,并且用户同意我们的网站访问这些信息,那么这些特征(如 BirthDate)是可用的。这些都是很大的 ifs。或者,我们可能能够使用广告商放置在用户设备上的 cookie 进行推断分析,但这也是一个依赖于所使用特征的粗略度量。最好假设将输入到模型中的任何信息都将被准确自我报告,并让用户有动力准确自我报告。这意味着教育、年收入和其他敏感措施都不适用。让我们看看性别和婚姻状况,如果我们正确询问,我们应该能够从用户那里得到这些信息。因此,我们的模型将是 MultiOrder = Gender + MartialStatus + E

返回到脚本并输入以下内容:

let getValuesForMartialStatus martialStatus =
    match martialStatus with
    | "S" -> 0.0
    | _ -> 1.0

let getValuesForGender gender =
    match gender with
    | "M" -> 0.0
    | _ -> 1.0

let getValuesForMultiPurchaseInd multiPurchaseInd =
    match multiPurchaseInd with
    | true -> 1
    | false -> 0

将此发送到 REPL,我们看到以下结果:

val getValuesForMartialStatus : martialStatus:string -> float
val getValuesForGender : gender:string -> float
val getValuesForMultiPurchaseInd : multiPurchaseInd:bool -> int

由于 Accord 处理输入 float 值和输出 int 值,我们需要一个函数将我们的属性特征(目前是字符串)转换为这些类型。如果你想确保我们涵盖了所有情况,你也可以将此发送到 FSI:

orders |> Array.distinctBy(fun (oid,ind,d) -> d.Gender)
       |> Array.map(fun (oid,ind,d) -> d.Gender)
//val it : string [] = [|"M"; "F"|]

orders |> Array.distinctBy(fun (oid,ind,d) -> d.MaritalStatus)
       |> Array.map(fun (oid,ind,d) -> d.MaritalStatus)
//val it : string [] = [|"M"; "S"|]

getValues 函数的编写方式有一个风险。如果你还记得上一章,处理缺失值在进行任何类型的建模时都是一个持续关注的问题。这些函数通过避开这个问题来处理 null 问题。考虑 getValuesForGender 函数:

let getValuesForGender gender =
    match gender with
    | "M" -> 0.0
    | _ -> 1.0

如果性别代码为 UNKYOMAMA、null 或任何其他字符串,它将被分配为女性代码。这意味着我们可能会高报模型中女性的数量。由于这个数据集中每个记录都有 MF 的值,我们可以这样处理,但如果它们没有,我们就需要一种处理错误值的方法。在这种情况下,我会创建一些像这样的代码:

let mutable lastGender = "M"
let getValuesForGender gender =
    match gender, lastGender with
    | "M",_ -> 0.0
    | "F",_ -> 1.0
    | _,"M" -> lastGender = "F"
               1.0
    | _,_ -> lastGender = "M"
             0.0

这将在男性和女性之间平衡推断值。无论如何,让我们开始建模。

k-NN 和 AdventureWorks 数据

返回到脚本并输入以下内容:

let inputs = orders |> Array.map(fun (oid,ind,d) -> [|getValuesForMartialStatus(d.MaritalStatus);getValuesForGender(d.Gender)|])
let outputs = orders |> Array.map(fun (oid,ind,d) -> getValuesForMultiPurchaseInd(ind))

let classes = 2
let k = 3
let knn = new KNearestNeighbors(k, classes, inputs, outputs)

将此发送到 REPL,我们得到以下结果:

 ...|]
val classes : int = 2
val k : int = 3
val knn : KNearestNeighbors

现在我们已经设置了模型,让我们传递四个可能的场景。转到脚本并输入以下内容:

knn.Compute([|0.0;0.0|])
knn.Compute([|1.0;0.0|])
knn.Compute([|0.0;1.0|])
knn.Compute([|1.0;1.0|])

将此发送到 FSI,我们得到以下内容:

> 
val it : int = 1
> 
val it : int = 1
> 
val it : int = 0
> 
val it : int = 1

所以看起来单身女性并没有购买多个商品。

朴素贝叶斯和 AdventureWorks 数据

返回脚本并输入以下内容:

let inputs' = orders |> Array.map(fun (oid,ind,d) -> [|int(getValuesForMartialStatus(d.MaritalStatus)); 
                                                       int(getValuesForGender(d.Gender));|])
let outputs' = orders |> Array.map(fun (oid,ind,d) -> getValuesForMultiPurchaseInd(ind))

let symbols = [|2;2|]

let bayes = new Accord.MachineLearning.Bayes.NaiveBayes(2,symbols)
let error = bayes.Estimate(inputs', outputs')

将其发送到 FSI,我们得到以下内容:

 ...|]
val symbols : int [] = [|2; 2|]
val bayes : NaiveBayes
val error : float = 0.148738812

因此,我们有一个 15%错误的朴素贝叶斯模型。不太好,但让我们继续前进。在脚本文件中输入相同的四个选项用于gender/martialStatus

bayes.Compute([|0;0|])
bayes.Compute([|1;0|])
bayes.Compute([|0;1|])
bayes.Compute([|1;1|])

当你将其发送到 REPL 时,你会得到以下内容:

val it : int = 1
> 
val it : int = 1
> 
val it : int = 1
> 
val it : int = 1
>

Rut Row Raggy。看起来我们遇到了问题。事实上,我们确实遇到了。如果你还记得之前关于使用朴素贝叶斯模型的描述,它需要值沿着钟形曲线分布才能有效。90%的自行车购买有交叉销售——这意味着我们严重倾斜。无论你对模型进行何种调整,都无法改变你将multiPurchase的值乘以 0.9 的事实。

利用我们的发现

我们应该怎么做?我们有 k-NN 告诉我们单身女性不会购买额外商品,而朴素贝叶斯则完全无助于我们。我们可以尝试更多的分类模型,但让我们假设我们对分析感到足够满意,并希望使用这个模型投入生产。我们应该怎么做?一个需要考虑的关键问题是,模型基于我们数据库表中的一些静态数据,这些数据不是通过公司的正常交易进行更新的。这意味着我们实际上不需要频繁地重新训练模型。我们遇到的另一个问题是,我们需要确定订购我们自行车的客户的性别和婚姻状况。也许我们提出的问题是错误的。不是询问如何获取用户的性别和婚姻状况,如果我们已经知道了会怎样?你可能认为我们不知道,因为我们还没有询问。但我们可以——基于用户选择的自行车!

准备数据

返回脚本并输入以下代码块:

let customerProduct = 
    query { for soh in context.``Sales.SalesOrderHeaders`` do
            join sod in context.``Sales.SalesOrderDetails`` on (soh.SalesOrderID = sod.SalesOrderID)
            join p in context.``Production.Products`` on (sod.ProductID = p.ProductID)
            join c in context.``Sales.Customers`` on (soh.CustomerID = c.CustomerID)
            where (sod.SalesOrderID |=| salesOrderIds)
            select(c.CustomerID, sod.ProductID)} 
    |> Seq.toArray

将此发送到 REPL,我们看到以下内容:

val customerProduct : (int * int) [] =
 [|(27575, 780); (13553, 779); (21509, 759); (15969, 769); (15972, 760);
 (14457, 798); (27488, 763); (27489, 761); (27490, 770); (17964, 793);
 (17900,

希望现在这段代码对你来说已经显得相当无聊了。它正在从所有自行车销售中创建一个由customerIdProductId组成的元组。

返回脚本并输入以下内容:

let getProductId customerId =
    customerProduct |> Array.find(fun (cid,pid) -> cid = customerId)
                    |> snd

let getSingleFemaleInd (martialStatus:string, gender:string) =
    match martialStatus, gender with
    | "S", "F" -> 1
    | _, _ -> 0

let customerDemo = orderCustomerDemo |> Array.map(fun (oid,cid,d) -> cid, getSingleFemaleInd(d.MaritalStatus, d.Gender))
                                     |> Array.map(fun (cid,sfInd) -> cid, getProductId(cid),sfInd)

将此发送到 REPL,我们可以看到以下内容:

val getProductId : customerId:int -> int
val getSingleFemaleInd : martialStatus:string * gender:string -> int
val customerDemo : (int * int * int) [] =
 |(13553, 779, 0); (15969, 769, 0); (15972, 760, 0); (14457, 798, 0);
 (17964, 793, 0);

此代码块正在为 Accord 调整我们的数据,创建一个由customerIdproductIdsingleFemaleInd组成的元组框架。我们几乎准备好将此数据投向模型,但我们仍然需要确定我们想要使用哪个模型。我们试图确定客户购买自行车的概率,以确定客户是否为单身女性。这似乎是一个非常适合逻辑回归的问题([第三章,更多 AdventureWorks 回归)。问题是,每辆自行车都需要成为这个回归中的一个特征:

singleFemale = BikeId0 + BikeId1 + BikeId2 + BikeIdN + E

如果你将此代码放入脚本中并发送到 FSI,你会看到我们有 80 个不同的自行车 ID:

let numberOfBikeIds = customerDemo |> Array.map (fun (cid,pid,sfInd) -> pid)
 |> Array.distinct
 |> Array.length
val numberOfBikeIds : int = 80

那么,我们如何从原始数据框中创建 80 个特征作为输入呢?当然不是手动创建。让我们看看 Accord 是否能帮我们。

扩展特征

打开上一节中使用的脚本,并输入以下内容:

let inputs'' = customerDemo |> Array.map(fun (cid,pid,sfInd) -> pid)
let outputs'' = customerDemo |> Array.map(fun (cid,pid,sfInd) -> (float)sfInd)

let expandedInputs = Tools.Expand(inputs'')

将此发送到 REPL,我们看到以下内容:

val expandedInputs : float [] [] =
 |[|0.0; 0.0; 0.0; 0.0; 0.0; 0.0; 0.0; 0.0; 0.0; 0.0; 0.0; 0.0; 0.0; 0.0;
 0.0; 0.0; 0.0; 0.0; 0.0; 0.0; 0.0; 0.0; 0.0; 0.0; 0.0; 0.0; 0.0; 0.0;
 0.0; 0.0

我们所做的是从 customerDemo 数据框中选择 productId。然后我们将该数组发送到 Accord 的 Tools.Expand 方法,该方法将数组展开,使每个值都成为其自身的特征。从图形上看,它看起来像这样:

![扩展特征

如您在阅读第五章后所猜想的,“休息时间 – 获取数据”,这被认为是一个稀疏数据框。输入和输出准备就绪后,回到脚本文件并输入以下内容:

let analysis = new LogisticRegressionAnalysis(expandedInputs, outputs'')
analysis.Compute() |> ignore
let pValue = analysis.ChiSquare.PValue
let coefficients = analysis.CoefficientValues
let coefficients' = coefficients |> Array.mapi(fun i c -> i,c)
                                 |> Array.filter(fun (i,c) -> c > 5.0)

在你将此发送到 REPL 之前,让我提醒你。我们之所以识别出稀疏数据框,是因为在 80 个特征上进行回归计算需要一段时间。所以按 ALT + ENTER 并去喝杯咖啡。从星巴克。在镇上。最终,你会得到这个:

val analysis : Analysis.LogisticRegressionAnalysis
> 
val it : unit = ()
> 
val pValue : float = 1.0
val coefficients : float [] =
 [|-3.625805913; 1.845275228e-10; 7.336791927e-11; 1.184805489e-10;
 -8.762459325e-11; -2.16833771e-10; -7.952785344e-12; 1.992174635e-10;
 2.562929393e-11; -2.957572867e-11; 2.060678611e-10; -2.103176298e-11;
 -2.3

当我们在系数表中过滤时,我们可以看到有一个自行车模型受到单身女性的青睐。将此添加到您的脚本文件中,并发送到 FSI:

let coefficients' = coefficients |> Array.mapi(fun i c -> i,c)
 |> Array.filter(fun (i,c) -> c > 5.0)

val coefficients' : (int * float) [] = [|(765, 15.85774698)|]

>

因此,也许当一个人购买编号为 765 的商品时,我们试图通过优惠券或一个非常酷的网站体验来激励他们购买其他产品。这正是优秀用户体验人员和知识渊博的市场人员可以发挥作用的领域。由于我既不是用户体验人员也不是市场人员,我将把这个练习留给读者。

摘要

在本章中,我们探讨了两种常见的机器学习分类器:k-最近邻和朴素贝叶斯。我们使用 AdventureWorks 数据集对它们进行了实际操作,以查看我们是否可以增加交叉销售。我们发现 k-NN 有一些有限的成功,而朴素贝叶斯则没有用。然后我们使用我们老朋友逻辑回归来帮助我们缩小可以用于促进交叉销售的特定自行车模型。最后,我们考虑到数据是临时的,我们无法在我们的网站上实施任何实时训练。我们希望定期运行此分析,以查看我们的原始发现是否仍然成立。

在下一章中,我们将摘下软件工程师的帽子,戴上数据科学家的帽子,看看我们是否可以对那些交通拦截数据做些什么。我们将考虑使用另一个数据集来增强原始数据集,然后使用几个聚类模型:k-均值和 PCA。下一页见!

第七章。交通拦截和事故地点 – 当两个数据集比一个更好

如果你还记得第四章,交通拦截 – 是否走错了路?,我们使用决策树来帮助我们根据诸如一天中的时间、一周中的哪一天等季节性因素来确定一个人是否收到了罚单或警告。最终,我们没有找到任何关系。你的第一个想法可能是丢弃数据集,我认为这是一个错误,因为其中可能隐藏着数据宝藏,但我们只是使用了错误的模型。此外,如果一个数据集本身不盈利,我通常开始用其他数据集来增强它,看看特征组合是否能提供更令人满意的答案。在本章中,让我们回到我们的 Code-4-Good 小组,看看我们是否可以增强交通拦截数据集,并应用一些不同的模型,这些模型将帮助我们提出有趣的问题和答案。也许即使我们没有提出正确的问题,计算机也能帮助我们提出正确的问题。

无监督学习

到目前为止,本书我们已经使用了几种不同的模型来回答我们的问题:线性回归、逻辑回归和 kNN 等。尽管它们的方法不同,但它们有一个共同点;我们告诉计算机答案(称为因变量或y变量),然后提供一系列可以与该答案关联的特征(称为自变量或x变量)。以下图为例:

无监督学习

我们随后向计算机展示了一些它之前未曾见过的独立变量的组合,并要求它猜测答案:

无监督学习

我们随后通过测试将结果与已知答案进行比较,如果模型在猜测方面做得很好,我们就会在生产中使用该模型:

无监督学习

这种在事先告诉计算机答案的方法被称为监督学习。术语监督之所以被使用,是因为我们明确地提供给计算机一个答案,然后告诉它使用哪个模型。

另有一类模型不会向计算机提供答案。这类模型被称为无监督学习。如果你的无监督学习的心理模型是替代教师在暑假前一天出现在六年级班级时的混乱,你并不远。好吧,可能没有那么糟糕。在无监督学习中,我们向计算机提供一个只包含属性的数据框,并要求它告诉我们关于数据的信息。有了这些信息,我们就可以缩小可能帮助我们做出有洞察力的商业决策的数据。例如,假设你将这个数据框发送给计算机:

无监督学习

它可能会告诉你数据似乎在两个区域聚集:

无监督学习

尽管你可能在简单的 2D 数据框上通过观察发现了这种关系,但在添加更多行和特征时,这项任务会变得非常困难,甚至不可能。在本章中,我们将使用 k-means 模型进行这种聚类。

此外,我们可以使用计算机告诉我们数据框中有用的特征以及哪些特征只是噪声。例如,考虑以下数据集:

学习时间 啤酒数量 学习地点
2 4 Dorm
1 5 Dorm
6 0 Dorm
5 1 Dorm
2 8 Dorm
4 4 Dorm

学习地点包含在我们的数据框中会导致任何洞察吗?答案是不会有,因为所有值都相同。在本章中,我们将使用主成分分析(PCA)进行此类特征过滤;它将告诉我们哪些特征是重要的,哪些可以安全地删除。

k-means

如前所述,k-means 是一种无监督技术:观察值是根据每个簇的平均值进行分组的。让我们看看 k-means 的实际应用。打开 Visual Studio,创建一个新的 Visual F# Windows Library Project。将Script.fsx文件重命名为kmeans.fsx。打开NuGet 包管理器控制台,并输入以下内容:

PM> install-package Accord.MachineLearning

接下来,转到脚本并替换所有内容为以下内容:

#r "../packages/Accord.3.0.2/lib/net40/Accord.dll"
#r "../packages/Accord.Math.3.0.2/lib/net40/Accord.Math.dll"
#r "../packages/Accord.Statistics.3.0.2/lib/net40/Accord.Statistics.dll"
#r "../packages/Accord.MachineLearning.3.0.2/lib/net40/Accord.MachineLearning.dll"

open Accord.MachineLearning

接下来,让我们创建一个数组,包含我们当地餐厅提供的各种饮料:

let drinks = ["Boones Farm", 0;
                "Mad Dog", 1;
                "Night Train", 2;
                "Buckfast", 3;
                "Smirnoff", 4;
                "Bacardi", 5;
                "Johhnie Walker", 6;
                "Snow", 7;
                "Tsingtao", 8;
                "Budweiser", 9;
                "Skol", 10;
                "Yanjing", 11;
                "Heineken", 12;
                "Harbin", 13]

将此发送到 FSI,你会看到以下结果:

val drinks : (string * int) list =
 [("Boones Farm", 0); ("Mad Dog", 1); ("Night Train", 2); ("Buckfast", 3);
 ("Smirnoff", 4); ("Bacardi", 5); ("Johhnie Walker", 6); ("Snow", 7);
 ("Tsingtao", 8); ("Budweiser", 9); ("Skol", 10); ("Yanjing", 11);
 ("Heineken", 12); ("Harbin", 13)]

>

返回到脚本,并输入一些餐厅顾客的记录。我们使用浮点值,因为 Accord 期望作为输入。

let observations = [|[|1.0;2.0;3.0|];[|1.0;1.0;0.0|];
                                             [|5.0;4.0;4.0|];[|4.0;4.0;5.0|];[|4.0;5.0;5.0|];[|6.0;4.0;5.0|];
                                             [|11.0;8.0;7.0|];[|12.0;8.0;9.0|];[|10.0;8.0;9.0|]|]

将其发送到 REPL,我们得到以下结果:

val observations : float [] [] =
 [|[|1.0; 2.0; 3.0|]; [|1.0; 1.0; 0.0|]; [|5.0; 4.0; 4.0|]; [|4.0; 4.0; 5.0|];
 [|4.0; 5.0; 5.0|]; [|6.0; 4.0; 5.0|]; [|11.0; 8.0; 7.0|];
 [|12.0; 8.0; 9.0|]; [|10.0; 8.0; 9.0|]|]

你会注意到有九位不同的顾客,每位顾客都喝了三种饮料。顾客编号 1 喝了布恩农场酒、疯狗酒和夜车酒。有了这些数据,让我们对它运行 k-means 算法。将以下内容输入到脚本文件中:

let numberOfClusters = 3
let kmeans = new KMeans(numberOfClusters);
let labels = kmeans.Compute(observations)

当你将此发送到 FSI 时,你会看到以下结果:

val numberOfClusters : int = 3
val kmeans : KMeans
val labels : int [] = [|0; 0; 1; 1; 1; 1; 2; 2; 2|]

此输出将每位顾客分配到三个簇中的一个。例如,顾客编号 1 和 2 在簇编号 0 中。如果我们想每个簇有更多的观察值,我们可以像这样更改numberOfClusters

let numberOfClusters = 2
let kmeans = new KMeans(numberOfClusters);
let labels = kmeans.Compute(observations)

将其发送到 FSI,会得到以下结果:

val numberOfClusters : int = 2
val kmeans : KMeans
val labels : int [] = [|1; 1; 1; 1; 1; 1; 0; 0; 0|]

注意,计算机不会尝试为每个簇标记或分配任何值。如果可能,数据科学家需要分配一个有意义的值。返回到脚本,将numberOfClusters改回三个,并重新发送到 FSI。查看输入数组,我们可以认为分配簇0的是加强葡萄酒饮用者,簇1是烈酒饮用者,簇2是啤酒饮用者。然而,有时你可能无法仅通过观察输入数组来判断每个簇的含义。在这种情况下,你可以请求 Accord 提供一些(有限的)帮助。将以下内容输入到脚本文件中:

kmeans.Clusters.[0]

将此发送到 FSI 将得到以下结果:

val it : KMeansCluster =
 Accord.MachineLearning.KMeansCluster
 {Covariance = [[4.3; 2.6; 3.2]
 [2.6; 2.266666667; 2.733333333]
 [3.2; 2.733333333; 3.866666667]];
 Index = 0;
 Mean = [|3.5; 3.333333333; 3.666666667|];
 Proportion = 0.6666666667;}

注意均值是中间的三位数,这是一个相对较小的数字,因为我们是从 0 到 13 进行计数的。我们可以说,类别 0 的标签应该是类似巴克夫斯特的饮酒者,这通常是正确的。

主成分分析(PCA)

我们可以用无监督学习来完成另一个常见任务,即帮助我们剔除不相关的特征。如果你还记得上一章,我们在构建模型时使用逐步回归来确定最佳特征,然后使用奥卡姆剃刀法则剔除不显著的特征。PCA 的一个更常见用途是将这个无监督模型作为挑选最佳特征——即框架的主成分的一种方式。

将另一个脚本文件添加到你的项目中,并将其命名为pca.fsx。添加以下代码:

#r "../packages/Accord.3.0.2/lib/net40/Accord.dll"
#r "../packages/Accord.Math.3.0.2/lib/net40/Accord.Math.dll"
#r "../packages/Accord.Statistics.3.0.2/lib/net40/Accord.Statistics.dll"

open Accord.Statistics.Analysis

let sourceMatrix = [|[|2.5; 2.4|];[|0.5; 0.7|];[|2.2; 2.9|];
                    [|1.9; 2.2|];[|3.1; 3.0|];[|2.3; 2.7|];[|2.0; 1.6|];
                    [|1.0; 1.1|];[|1.5; 1.6|]; [|1.1; 0.9|]|] 

将此发送到 FSI 将得到以下结果:

val sourceMatrix : float [] [] =
 [|[|2.5; 2.4|]; [|0.5; 0.7|]; [|2.2; 2.9|]; [|1.9; 2.2|]; [|3.1; 3.0|];
 [|2.3; 2.7|]; [|2.0; 1.6|]; [|1.0; 1.1|]; [|1.5; 1.6|]; [|1.1; 0.9|]|]

在这种情况下,sourceMatrix是一个学生列表,这些学生在考试前学习了一定小时数,并在考试前喝了多少啤酒。例如,第一个学生学习了 2.5 小时,喝了 2.4 杯啤酒。与你在书中看到的类似例子不同,你会注意到这个框架中没有因变量(Y)。我们不知道这些学生是否通过了考试。但仅凭这些特征,我们可以确定哪些特征对分析最有用。你可能会对自己说:“这怎么可能?”不深入数学的话,PCA 将查看一系列场景下每个变量的方差。如果一个变量可以解释差异,它将得到更高的分数。如果它不能,它将得到较低的分数。

让我们看看 PCA 关于这个数据集告诉我们什么。将以下代码输入到脚本中:

let pca = new PrincipalComponentAnalysis(sourceMatrix, AnalysisMethod.Center)
pca.Compute()
pca.Transform(sourceMatrix)
pca.ComponentMatrix

将此发送到 REPL,我们将得到以下结果:

val pca : PrincipalComponentAnalysis
val it : float [,] = [[0.6778733985; -0.7351786555]
 [0.7351786555; 0.6778733985]]

你会注意到ComponentMatrix属性的输出是一个 2 x 2 的数组,互补值以交叉形式表示。在正式术语中,这个锯齿形数组被称为特征向量,数组的内容被称为特征值。如果你开始深入研究 PCA,你需要了解这些词汇的含义以及这些值的含义。对于我们这里的用途,我们可以安全地忽略这些值(除非你想要在下次家庭聚会中提及“特征值”这个词)。

在 PCA 中,我们需要特别注意的一个重要属性是成分比例。回到脚本文件,输入以下内容:

pca.ComponentProportions

将此发送到 REPL 将得到以下结果:

val it : float [] = [|0.9631813143; 0.03681868565|]

这些值对我们的分析很重要。注意,将这两个值相加等于 100 百分比?这些百分比告诉你数据框中的方差量(因此是数据的有用性量)。在这种情况下,学习时间是 96 百分比的方差,而啤酒量只有 4 百分比,所以如果我们想用这种数据进行分析,我们肯定会选择学习时间,并安全地丢弃饮酒。注意,如果我们增加了饮酒的啤酒范围,百分比会发生变化,我们可能希望使用这两个变量。这是一个有两个特征的简单示例。PCA 在你有大量特征并且需要确定它们的有用性时表现得尤为出色。

交通拦截和事故探索

在掌握 k-means 和 PCA 理论之后,让我们看看我们可以用开放数据做什么。如果你记得,我们有一个关于交通拦截的数据集。让我们再引入两个数据集:同一时间段内的汽车事故数量,以及事故/罚单当天降水量。

准备脚本和数据

在 Visual Studio 中,创建一个名为 Hack4Good.Traffic 的新 Visual F# 库项目:

准备脚本和数据

项目创建完成后,将 Script.fsx 文件重命名为 Clustering.fsx

准备脚本和数据

接下来,打开 NuGet 包管理器控制台,并输入以下内容:

PM> install-package Accord.MachineLearning

Clustering.fsx 中,将以下代码输入到脚本中:

#r "System.Data.Entity.dll"
#r "FSharp.Data.TypeProviders.dll"
#r "System.Data.Linq.dll"
#r "../packages/Accord.3.0.2/lib/net40/Accord.dll"
#r "../packages/Accord.Math.3.0.2/lib/net40/Accord.Math.dll"
#r "../packages/Accord.Statistics.3.0.2/lib/net40/Accord.Statistics.dll"
#r "../packages/Accord.MachineLearning.3.0.2/lib/net40/Accord.MachineLearning.dll"

open System
open System.Linq
open System.Data.Linq
open System.Data.Entity
open Accord.MachineLearning
open System.Collections.Generic
open Accord.Statistics.Analysis
open Microsoft.FSharp.Data.TypeProviders

[<Literal>]
let connectionString = "data source=nc54a9m5kk.database.windows.net;initial catalog=Traffic;user id=chickenskills@nc54a9m5kk;password=sk1lzm@tter;"
type Geolocation = {Latitude: float; Longitude: float} 

type EntityConnection = SqlEntityConnection<connectionString,Pluralize = true>
let context = EntityConnection.GetDataContext()

当你将此发送到 FSI 时,你会看到以下内容:

val connectionString : string =
 "data source=nc54a9m5kk.database.windows.net;initial catalog=T"+[61 chars]
type Geolocation =
 {Latitude: float;
 Longitude: float;}
type EntityConnection =
 class
 static member GetDataContext : unit -> EntityConnection.ServiceTypes.SimpleDataContextTypes.EntityContainer
 + 1 overload
 nested type ServiceTypes
 end
val context :
 EntityConnection.ServiceTypes.SimpleDataContextTypes.EntityContainer

在完成这些准备工作后,让我们从数据库中获取停止数据。将以下代码放入脚本文件中:

//Stop Data
type TrafficStop = {StopDateTime: DateTime; Geolocation: Geolocation; DispositionId: int}
let trafficStops = 
    context.dbo_TrafficStops 
    |> Seq.map(fun ts -> {StopDateTime = ts.StopDateTime.Value; 
                          Geolocation = {Latitude = Math.Round(ts.Latitude.Value,3); 
                          Longitude = Math.Round(ts.Longitude.Value,3)}; 
                          DispositionId = ts.DispositionId.Value})
    |> Seq.toArray

当你将其发送到 REPL 时,你会看到以下内容:

type TrafficStop =
 {StopDateTime: DateTime;
 Geolocation: Geolocation;
 DispositionId: int;}
val trafficStops : TrafficStop [] =
 |{StopDateTime = 6/30/2012 12:36:38 AM;
 Geolocation = {Latitude = 35.789;
 Longitude = -78.829;};
 DispositionId = 7;}; {StopDateTime = 6/30/2012 12:48:38 AM;
 Geolocation = {Latitude = 35.821;
 Longitude = -78.901;};
 DispositionId = 15;};
 {StopDateTime = 6/30/2012 1:14:29 AM;
 Geolocation = {Latitude = 35.766;

所有这些数据都应该对你熟悉,来自[第四章,交通拦截 – 是否走错了路?。唯一的真正区别是现在有一个包含纬度和经度的地理位置类型。注意,我们在这一行中首先分配数据库中的任何值:

    |> Seq.map(fun ts -> {StopDateTime = ts.StopDateTime.Value; 
                          Geolocation = {Latitude = Math.Round(ts.Latitude.Value,3); 
                          Longitude = Math.Round(ts.Longitude.Value,3)}; 
                          DispositionId = ts.DispositionId.Value})

此外,你还会注意到我们正在使用 Math.Round 将值保留到小数点后三位精度。有了这些本地数据,让我们引入事故数据。将以下代码输入到脚本中:

//Crash Data
type TrafficCrash = {CrashDateTime: DateTime;  Geolocation: Geolocation; CrashSeverityId: int; CrashTypeId: int; }
let trafficCrashes= 
    context.dbo_TrafficCrashes 
    |> Seq.filter(fun tc -> tc.MunicipalityId = Nullable<int>(13))
    |> Seq.filter(fun tc -> not (tc.Latitude = Nullable<float>()))
    |> Seq.map(fun tc -> {CrashDateTime=tc.CrashDateTime.Value; 
                          Geolocation = {Latitude =Math.Round(tc.Latitude.Value,3); 
                        Longitude=Math.Round(tc.Longitude.Value,3)};
                        CrashSeverityId=tc.CrashSeverityId.Value; 
                        CrashTypeId =tc.CrashTypeId.Value})
    |> Seq.toArray

将此发送到 FSI 给出以下结果:

type TrafficCrash =
 {CrashDateTime: DateTime;
 Geolocation: Geolocation;
 CrashSeverityId: int;
 CrashTypeId: int;}
val trafficCrashes : TrafficCrash [] =
 [|{CrashDateTime = 12/30/2011 1:00:00 AM;
 Geolocation = {Latitude = 35.79;
 Longitude = -78.781;};
 CrashSeverityId = 4;
 CrashTypeId = 3;}; {CrashDateTime = 12/30/2011 3:12:00 AM;
 Geolocation = {Latitude = 35.783;
 Longitude = -78.781;};
 CrashSeverityId = 3;
 CrashTypeId = 24;};

我们还有一个数据集想要使用:每天的交通状况。将以下内容输入到脚本中:

//Weather Data
type DailyPercipitation = {WeatherDate: DateTime; Amount: int; }
let dailyWeather = 
    context.dbo_DailyPercipitation 
    |> Seq.map(fun dw -> {WeatherDate=dw.RecordDate; Amount=dw.Amount;})
    |> Seq.toArray

将此发送到 FSI 给出以下结果:

type DailyPercipitation =
 {WeatherDate: DateTime;
 Amount: int;}
val dailyWeather : DailyPercipitation [] =
 [|{WeatherDate = 1/9/2012 12:00:00 AM;
 Amount = 41;}; {WeatherDate = 1/10/2012 12:00:00 AM;
 Amount = 30;}; {WeatherDate = 1/11/2012 12:00:00 AM;
 Amount = 5;};
 {WeatherDate = 1/12/2012 12:00:00 AM;

有这三个数据集可用,让我们将交通拦截和交通事故数据集合并成一个数据框,看看是否有关于地理位置的任何情况。

地理位置分析

前往脚本文件,并添加以下内容:

let stopData = 
    trafficStops
    |> Array.countBy(fun ts -> ts.Geolocation)

将此发送到 REPL 给出以下结果:

val stopData : (Geolocation * int) [] =
 [|({Latitude = 35.789;
 Longitude = -78.829;}, 178); ({Latitude = 35.821;
 Longitude = -78.901;}, 8);
 ({Latitude = 35.766;
 Longitu…

到现在为止,这段代码应该对你来说已经很熟悉了;我们正在按地理位置统计交通停驶的数量。对于第一条记录,地理点 35.789/-78.829 有 178 次交通停驶。

接下来,返回脚本并输入以下内容:

let crashData =
    trafficCrashes
    |> Array.countBy(fun tc -> tc.Geolocation)

将此发送到 REPL,我们得到以下结果:

val crashData : (Geolocation * int) [] =
 [|({Latitude = 35.79;
 Longitude = -78.781;}, 51); ({Latitude = 35.783;

这段代码与停驶数据相同;我们正在按地理位置统计交通事故的数量。对于第一条记录,地理点 35.790/-78.781 有 51 次交通事故。

我们接下来的步骤是将这两个数据集合并成一个单一的数据框,我们可以将其发送到 Accord。至于 F#中的大多数事情,让我们使用类型和函数来实现这一点。返回脚本文件并输入以下内容:

type GeoTraffic = {Geolocation:Geolocation; CrashCount: int; StopCount: int}

let trafficGeo = 
    Enumerable.Join(crashData, stopData, 
                (fun crashData -> fst crashData), 
                (fun stopData -> fst stopData), 
                (fun crashData stopData -> { Geolocation = fst crashData; StopCount = snd crashData ; CrashCount = snd stopData }))
                |> Seq.toArray

当你将此发送到 FSI 时,你会看到如下类似的内容:

type GeoTraffic =
 {Geolocation: Geolocation;
 CrashCount: int;
 StopCount: int;}
val trafficGeo : GeoTraffic [] =
 [|{Geolocation = {Latitude = 35.79;
 Longitude = -78.781;};
 CrashCount = 9;
 StopCount = 51;}; {Geolocation = {Latitude = 35.783;
 Longitude = -78.781;};
 CrashCount = 16;
 StopCount = 5;};
 {Geolocation = {Latitude = 35.803;
 Longitude = -78.775;};
 CrashCount = 76;
 StopCount = 2;};

这里有一些新的代码,一开始可能会让人感到有些难以理解(至少对我来说是这样的)。我们正在使用 LINQ 类EnumerableJoin方法将crashDatastopData连接起来。Join方法接受几个参数:

  • 第一个数据集(在这种情况下为crashData)。

  • 第二个数据集(在这种情况下为stopData)。

  • 一个 lambda 表达式,用于从第一个数据集中提取值,我们将使用它来进行连接。在这种情况下,元组的第一个元素,即地理位置值。

  • 一个 lambda 表达式,用于从第二个数据集中提取值,我们将使用它来进行连接。在这种情况下,元组的第一个元素,即地理位置值。

  • 一个 lambda 表达式,指定连接操作输出的样子。在这种情况下,它是我们在这段代码块的第一个语句中定义的名为GeoTraffic的记录类型。

关于使用 Join 方法的关键一点是要意识到它只保留两个数据集中都存在的记录(对于 SQL 爱好者来说,这是一个内连接)。这意味着如果一个地理位置有一个交通罚单但没有交通停驶,它将从我们的分析中删除。如果你想要进行外连接,有GroupJoin方法可以实现这一点。由于我们真正感兴趣的是高活动区域,因此内连接似乎更合适。

在创建数据框后,我们现在准备将数据发送到 Accord 的 k-means。如果你还记得,Accord 的 k-means 需要输入是一个浮点数的不规则数组。因此,我们有一个最后的转换。转到脚本文件并输入以下内容:

let kmeansInput = 
    trafficGeo 
    |> Array.map(fun cs -> [|float cs.CrashCount; float cs.StopCount |])

将其发送到 FSI,我们得到以下结果:

val kmeansInput : float [] [] =
 [|[|9.0; 51.0|]; [|16.0; 5.0|]; [|76.0; 2.0|]; [|10.0; 1.0|]; [|80.0; 7.0|];
 [|92.0; 27.0|]; [|8.0; 2.0|]; [|104.0; 11.0|]; [|47.0; 4.0|];
 [|36.0; 16.0

返回脚本文件,并输入以下内容:

let numberOfClusters = 3
let kmeans = new KMeans(numberOfClusters)
let labels = kmeans.Compute(kmeansInput.ToArray())
kmeans.Clusters.[0]
kmeans.Clusters.[1]
kmeans.Clusters.[2]

将其发送到 REPL,我们将得到以下结果:

val numberOfClusters : int = 3
val kmeans : KMeans
val labels : int [] =
 [|1; 1; 0; 1; 0; 0; 1; 0; 0; 1; 0; 0; 0; 1; 1; 0; 1; 1; 0; 0; 0; 2; 1; 0; 1;
 2; 0; 2;

哇!我们正在对交通数据进行 k-means 聚类。如果你检查每个簇,你会看到以下内容:

val it : KMeansCluster =
  Accord.MachineLearning.KMeansCluster
    {Covariance = [[533.856744; 25.86726804]
                   [25.86726804; 42.23152921]];
     Index = 0;
     Mean = [|67.50515464; 6.484536082|];
     Proportion = 0.1916996047;}
> 
val it : KMeansCluster =
  Accord.MachineLearning.KMeansCluster
    {Covariance = [[108.806009; 8.231942669]
                   [8.231942669; 16.71306776]];
     Index = 1;
     Mean = [|11.69170984; 2.624352332|];
     Proportion = 0.7628458498;}
> 
val it : KMeansCluster =
  Accord.MachineLearning.KMeansCluster
    {Covariance = [[5816.209486; -141.4980237]
                   [-141.4980237; 194.4189723]];
     Index = 2;
     Mean = [|188.8695652; 13.34782609|];
     Proportion = 0.04545454545;}

我们有三个簇。我从每个簇中提取了平均值和比例,并将它们放入如下所示的电子表格中:

事故 停驶 记录百分比
67.5 6.48 20.2%
11.69 2.62 76.3%
188.87 13.35 4.5%

观察所有三个聚类,值得注意的是交通事故比检查次数多得多。同样值得注意的是,第一和第二个聚类的事故与检查的比例大约是 10:1,但真正高事故区域的事故与检查的比例更高——大约 14:1。似乎有理由得出结论,城镇中有几个高事故区域,警察在那里非常活跃,但他们可能更加活跃。我会根据它们的活跃度给每个聚类命名:(低、中、高)。如果地理位置不在我们的数据框中(城镇中的大多数点),我们可以称之为“无活动”。

最后,将以下内容输入到脚本文件中:

let trafficGeo' = Array.zip trafficGeo labels

将这些发送到 FSI 后,我们得到以下结果:

val trafficGeo' : (GeoTraffic * int) [] =
 |({Geolocation = {Latitude = 35.79;
 Longitude = -78.781;};
 CrashCount = 9;
 StopCount = 51;}, 1); ({Geolocation = {Latitude = 35.783;
 Longitude = -78.781;};
 CrashCount = 16;
 StopCount = 5;}, 1);

我们之前已经见过.zip格式。我们现在将包含地理位置、停靠次数和事故次数的数据框与通过 k-means 得到的标签框合并。然后我们可以查找一个特定的地理位置并查看其聚类分配。例如,地理位置 35.790/-78.781 位于聚类 1——中等活跃度。

PCA

现在我们已经通过 k-means 对数据有了相当好的了解,让我们看看是否可以使用 PCA 来揭示我们交通数据中的更多洞察。而不是看位置,让我们看看日期。正如我们在[第四章中找到的,“交通检查——走错了方向?”,使用我们的决策树,我们无法从不同时间段的日期/时间与交通罚单中得出任何结论。也许通过增加事故和天气数据到检查数据中会有所帮助。

返回到Clustering.fsx脚本文件,并输入以下内容:

let crashCounts =
    trafficCrashes
    |> Array.countBy(fun tc -> tc.CrashDateTime.DayOfYear)

将这些发送到 FSI 后,我们得到以下结果:

val crashCounts : (int * int) [] =
 [|(364, 10); (365, 3); (1, 2); (2, 3); (3, 12); (4, 5); (5, 3); (6, 1);
 (7, 9); (8, 6); (9, 10); (10, 6); (11, 9);

这段代码与我们之前创建 k-means 的crashData时编写的代码非常相似。在这种情况下,我们是通过DayOfYear来统计交通事故的。DayOfYear将每年的每一天分配一个索引值。例如,1 月 1 日得到 1,1 月 2 日得到 2,12 月 31 日得到 365 或 366,这取决于是否是闰年。注意,它是基于 1 的,因为DateTime.DayOfYear是基于 1 的。

返回到脚本文件并输入以下内容:

let stopCounts = 
    trafficStops
    |> Array.countBy(fun ts -> ts.StopDateTime.DayOfYear)

将这些发送到 FSI 后,我们得到以下结果:

val stopCounts : (int * int) [] =
 [|(182, 58); (183, 96); (184, 89); (185, 65); (38, 65);

如你所能猜到的,这是按年度天数汇总的交通检查次数。继续前进,进入脚本文件并输入以下内容:

let weatherData' =
    dailyWeather
    |> Array.map(fun w -> w.WeatherDate.DayOfYear, w.Amount)

将这些发送到 REPL 后,我们得到以下结果:

val weatherData' : (int * int) [] =
 [|(9, 41); (10,` 30); (11, 5); (12, 124);

就像事故和检查数据一样,这创建了一个按年度天数汇总的降水量数据集。你会注意到数据已经处于日期级别(有时称为原子级别),所以使用了Array.map来转换日期;我们不需要使用countBy

在创建了初始数据集之后,我们现在需要一种方法来将这三个数据集合并在一起。我们在 k-means 示例中使用的 Enumerable.Join 方法在这里不适用,因此我们必须构建自己的连接函数。进入脚本文件,输入以下内容:

let getItem dataSet item  =
    let found = dataSet |> Array.tryFind(fun sd -> fst(sd) = item)
    match found with
    | Some value -> snd value
    | None -> 0

当你将此发送到 FSI 时,你会得到以下结果:

val getItem : dataSet:('a * int) [] -> item:'a -> int when 'a : equality

这是一个相当复杂的函数签名。如果我在方法中添加参数提示,可能会有所帮助,如下面的代码所示:

let getItem (dataSet:(int*int)[], item:int)  =
    let found = dataSet |> Array.tryFind(fun sd -> fst(sd) = item)
    match found with
    | Some value -> snd value
    | None -> 0

当你将其发送到 FSI 时,你会得到以下结果:

val getItem : dataSet:(int * int) [] * item:int -> int

这应该更容易访问但不太通用,这是可以接受的,因为我们的所有数据集(事故、停车和天气)都是 int*int 的数组。阅读输出,我们看到 getItem 是一个接受一个名为 dataset 的参数的函数,该参数是一个 int 元组的数组 (int * int)[],另一个参数名为 item,也是一个 int。函数随后尝试在数组中找到其 fst 值与项目相同的元组。如果找到了,它返回元组的第二个值。如果没有在数组中找到项目,它返回 0

这个函数将适用于我们所有的三个数据集(事故、停车和天气),因为这三个数据集只包含有观测记录的日期。对于交通停车来说,这不是问题,因为每年每天都有至少一次交通停车。然而,有 16 天没有记录交通事故,所以 stopData 有 350 条记录,而且有超过 250 天没有降水,所以 weatherData 只有 114 条记录。

由于第一种创建 getItem 的方法更通用且更符合 F# 的习惯用法,我将使用它来完成本章的剩余部分。这两个例子都在你可以下载的示例脚本文件中。

回到脚本中,输入以下内容:

type TrafficDay = {DayNumber:int; CrashCount: int; StopCount: int; RainAmount: int}

let trafficDates = 
    [|1..366|]
    |> Array.map(fun d -> {DayNumber=d;
                          CrashCount=getItem crashCounts d;
                          StopCount=getItem stopCounts d;
                          RainAmount=getItem weatherData' d})

当你将此发送到 REPL 时,你会看到以下内容:

type TrafficDay =
 {DayNumber: int;
 CrashCount: int;
 StopCount: int;
 RainAmount: int;}
val trafficDates : TrafficDay [] =
 [|{DayNumber = 1;
 CrashCount = 2;
 StopCount = 49;
 RainAmount = 0;}; {DayNumber = 2;
 CrashCount = 3;
 StopCount = 43;
 RainAmount = 0;};

第一行创建了一个包含当天事故、停车和降水的记录类型。我使用“rain”作为字段名,因为我们很少在北卡罗来纳州下雪,我想让任何住在北方的读者都感到这一点。当然,当我们确实下雪时,那几乎就是世界末日。

下一个代码块是我们创建最终数据帧的地方。首先,创建了一个包含全年每一天的整数数组。然后应用了一个映射函数,为数组中的每个项目调用 getItem 三次:第一次为 crashData,第二次为停车数据,最后为天气数据。结果被放入 TrafficDay 记录中。

数据帧设置完成后,我们现在可以为 Accord 准备了。转到脚本文件,输入以下内容:

let pcaInput = 
    trafficDates 
    |> Array.map(fun td -> [|float td.CrashCount; float td.StopCount; float td.RainAmount |])

当你将其发送到 REPL 时,你会得到以下结果:

val pcaInput : float [] [] =
 [|[|2.0; 49.0; 0.0|]; [|3.0; 43.0; 0.0|]; [|12.0; 52.0; 0.0|];
 [|5.0; 102.0; 0.0|];

这是一个 Accord 所需要的锯齿数组。回到脚本中,输入以下内容:

let pca = new PrincipalComponentAnalysis(pcaInput, AnalysisMethod.Center)
pca.Compute()
pca.Transform(pcaInput)
pca.ComponentMatrix
pca.ComponentProportions

当你将此发送到 REPL 时,你会得到以下结果:

val pca : PrincipalComponentAnalysis
val it : unit = ()

> 
val it : float [] [] =
 [|[|-43.72753865; 26.15506878; -4.671924583|];

val it : float [,] = [[0.00127851745; 0.01016388954; 0.999947529]
 [0.01597172498; -0.999821004; 0.01014218229]
 [0.9998716265; 0.01595791997; -0.001440623449]]
> 
val it : float [] = [|0.9379825626; 0.06122702459; 0.0007904128341|]
>

>

这表明我们数据框中 94%的方差来自事故,而不是停车或天气。这很有趣,因为常识认为,一旦在北卡罗来纳州下雨(或者下雪 ),交通事故就会激增。尽管这可能是一个好的新闻报道,但这一年的样本并没有证实这一点。

分析摘要

我们现在有几个模型指向一些有趣的想法:

  • 有几个地点占用了城镇大部分的交通事故和罚单

  • 天气并不像你想象的那么重要

拥有这些知识,我们准备好将机器学习应用于实践。

Code-4-Good 应用程序

让我们创建一个帮助人们更安全驾驶的 Windows 应用程序。此外,让我们使应用程序变得“智能”,这样它将逐渐变得更加准确。让我们从 Visual Studio 中已经创建的项目开始。

机器学习组件

进入解决方案****资源管理器,将Library1.fs重命名为TrafficML.fs。添加对System.DataSystem.Data.EntitySystem.Data.LinqFSharp.Data.TypeProviders的引用:

机器学习组件

添加引用

进入TrafficML.fs文件并输入以下代码:

namespace Hack4Good.Traffic

open System
open System.Linq
open System.Data.Linq
open System.Data.Entity
open Accord.MachineLearning
open System.Collections.Generic
open Accord.Statistics.Analysis
open Microsoft.FSharp.Data.TypeProviders

type Geolocation = {Latitude: float; Longitude: float}
type private EntityConnection = SqlEntityConnection<"data source=nc54a9m5kk.database.windows.net;initial catalog=Traffic;user id=chickenskills@nc54a9m5kk;password=sk1lzm@tter;",Pluralize = true>
type TrafficStop = {StopDateTime: DateTime; Geolocation: Geolocation; DispositionId: int}
type TrafficCrash = {CrashDateTime: DateTime;  Geolocation: Geolocation; CrashSeverityId: int; CrashTypeId: int; }
type GeoTraffic = {Geolocation:Geolocation; CrashCount: int; StopCount: int}
type GeoTraffic' = {Geolocation:Geolocation; CrashCount: int; StopCount: int; Cluster: int}

我知道不将你刚刚编写的代码发送到 FSI 感觉有点奇怪,但没有办法立即从可编译文件中获取你编写的代码的反馈。我们将在下一章讨论 TDD 时解决这个问题。在此之前,只需编译项目以确保你走在正确的轨道上。

返回到TrafficML.fs文件,输入以下代码块或从书籍的下载中复制:

type TrafficML(connectionString:string) = 
    let context = EntityConnection.GetDataContext(connectionString)

        let trafficStops = 
        context.dbo_TrafficStops 
        |> Seq.map(fun ts -> {StopDateTime = ts.StopDateTime.Value; 
                             Geolocation = {Latitude =Math.Round(ts.Latitude.Value,3); 
                             Longitude=Math.Round(ts.Longitude.Value,3)}; 
                             DispositionId = ts.DispositionId.Value})
        |> Seq.toArray

    let trafficCrashes= 
        context.dbo_TrafficCrashes 
        |> Seq.filter(fun tc -> tc.MunicipalityId = Nullable<int>(13))
        |> Seq.filter(fun tc -> not (tc.Latitude = Nullable<float>()))
        |> Seq.map(fun tc -> {CrashDateTime=tc.CrashDateTime.Value; 
                            Geolocation = {Latitude =Math.Round(tc.Latitude.Value,3); 
                            Longitude=Math.Round(tc.Longitude.Value,3)};
                            CrashSeverityId=tc.CrashSeverityId.Value; 
                            CrashTypeId =tc.CrashTypeId.Value})
        |> Seq.toArray

    let stopData = 
        trafficStops
        |> Array.countBy(fun ts -> ts.Geolocation)

    let crashData =
        trafficCrashes
        |> Array.countBy(fun tc -> tc.Geolocation)

    let trafficGeo = 
        Enumerable.Join(crashData, stopData, 
                    (fun crashData -> fst crashData), 
                    (fun stopData -> fst stopData), 
                    (fun crashData stopData -> { GeoTraffic.Geolocation = fst crashData; 
                       StopCount = snd crashData ; 
                       CrashCount = snd stopData }))
                    |> Seq.toArray

    let kmeansInput = 
        trafficGeo 
        |> Array.map(fun cs -> [|float cs.CrashCount; float cs.StopCount |])

    let numberOfClusters = 3
    let kmeans = new KMeans(numberOfClusters)
    let labels = kmeans.Compute(kmeansInput.ToArray())
    let trafficGeo' = Array.zip trafficGeo labels
                      |> Array.map(fun (tg,l) -> {Geolocation=tg.Geolocation;CrashCount=tg.CrashCount;StopCount=tg.StopCount;Cluster=l} ) 

这段代码与我们之前在Clustering.fsx脚本文件中编写的 k-means 代码非常相似。请注意,获取数据、塑造数据和在其上运行 k-means 的所有工作都在TrafficML类型的构造函数中完成。这意味着每次从另一个位置创建类的新的实例时,你都在进行数据库调用并运行模型。此外,请注意,连接字符串被硬编码到SqlEntity类型提供者中,但在调用GetDataContext()时通过构造函数参数传递。这允许你在不同的环境中移动代码(开发/测试/生产)。缺点是您需要始终暴露您的开发环境,以便生成类型。避免这种情况的一种方法是将您的 Entity Framework .edmx/架构硬编码到项目中。

返回到TrafficML.fs文件,并在TrafficML类型中输入以下函数:

    member this.GetCluster(latitude: float, longitude: float, distance: float) =
        let geolocation = {Latitude=latitude; Longitude=longitude}
        let found = trafficGeo' 
                    |> Array.map(fun gt -> gt,(haversine gt.Geolocation geolocation))
                    |> Array.filter(fun (gt,d) -> d < distance)
                    |> Array.sortByDescending(fun (gt,d) -> gt.Cluster)
        match found.Length with
        | 0 -> -1
        | _ -> let first = found |> Array.head
               let gt = fst first
               gt.Cluster

这将搜索地理位置。如果有匹配项,则返回簇。如果没有匹配项,则返回a-1,表示没有匹配项。我们现在有足够的内容来尝试创建一个实时“智能”交通应用程序。

用户界面

解决方案资源管理器中,添加一个新的 Visual C# WPF 应用程序:

用户界面

项目创建完成后,将 C# UI 项目添加到 F# 项目中,引用 System.ConfigurationSystem.Device

UIUI

作为快速预备说明,当你编写 WFP 应用程序时,应该遵循 MVVM 和命令中继模式,这在本书中不会涉及。这是一本关于机器学习的书,而不是通过令人愉悦的 UI 来宠爱人类,所以我只编写足够的 UI 代码,以便使其工作。如果你对遵循最佳实践进行 WPF 开发感兴趣,可以考虑阅读 Windows Presentation Foundation 4.5 Cookbook

在 UI 项目中,打开 MainWindow.xaml 文件,找到 Grid 元素,并在网格中输入以下 XAML:

<Button x:Name="crashbutton" Content="Crash" Click="notifyButton_Click" HorizontalAlignment="Left" Height="41" Margin="31,115,0,0" VerticalAlignment="Top" Width="123"/>
<Button x:Name="stopButton" Content="Stop" Click="notifyButton_Click" HorizontalAlignment="Left" Height="41" Margin="171,115,0,0" VerticalAlignment="Top" Width="132"/>
<TextBlock x:Name="statusTextBlock" HorizontalAlignment="Left" Height="100" Margin="31,10,0,0" TextWrapping="Wrap" Text="Current Status: No Risk" VerticalAlignment="Top" Width="272"/>

接下来,打开 MainWindow.xaml.cs 文件,并将以下 using 语句添加到文件顶部的 using 块中:

using System.Configuration;
using System.Device.Location;

你的文件应该看起来像下面这样:

UI

MainWindow 类内部,输入三个类级别变量:

        TrafficML _trafficML = null;
        GeoCoordinateWatcher _watcher = null;
        String _connectionString = null;

你的文件应该看起来像下面这样:

UI

然后,在 MainWindow() 构造函数中,在 InitializeComponent() 下方添加以下代码:

            InitializeComponent();
            _connectionString = ConfigurationManager.ConnectionStrings["trafficDatabase"].ConnectionString;
            _trafficML = new TrafficML(_connectionString);

            _watcher = new GeoCoordinateWatcher(GeoPositionAccuracy.High);
            _watcher.PositionChanged += Watcher_PositionChanged;
            bool started = this._watcher.TryStart(false, TimeSpan.FromMilliseconds(2000));
            StartUpdateLoop();

你的文件应该看起来像这样:

UI

接下来,为事件处理器创建 Watcher_PositionChanged 方法:

        private void Watcher_PositionChanged(object sender, GeoPositionChangedEventArgs<GeoCoordinate> e)
        {
            var location = e.Position.Location;
            var latitude = Double.Parse(location.Latitude.ToString("00.000"));
            var longitude = Double.Parse(location.Longitude.ToString("00.000"));

            var cluster = _trafficML.GetCluster(latitude, longitude);
            var status = "No Risk";
            switch(cluster)
            {
                case 0:
                    status = "Low Risk";
                    break;
                case 1:
                    status = "Medium Risk";
                    break;
                case 2:
                    status = "High Risk";
                    break;
                default:
                    status = "No Risk";
                    break;
            }
            this.statusTextBlock.Text = "Current Status: " + status;

        }

接下来,创建一个循环,每分钟刷新一次 MachineLearning 模型:

        private async Task StartUpdateLoop()
        {
            while (true)
            {
                await Task.Delay(TimeSpan.FromMinutes(1.0));
                _trafficML = await Task.Run(() => new TrafficML(_connectionString));
            }
        }

最后,为屏幕上的按钮点击添加事件处理器占位符:

        private void notifyButton_Click(object sender, RoutedEventArgs e)
        {
            //TODO
        }

如果你将代码折叠到定义中(CTRL + M, L),你的代码应该看起来如下:

UI

接下来,进入 解决方案资源管理器,右键单击以添加一个新的 应用程序配置 文件:

UI

添加新的应用程序配置文件

在那个 app.config 文件中,将内容替换为以下 XML(如果你使用的是本地数据库实例,请将连接字符串替换为你的连接字符串):

<?xml version="1.0" encoding="utf-8" ?>
<configuration>
    <startup> 
        <supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.5.2" />
    </startup>
  <connectionStrings>
    <add name="trafficDatabase" 
         connectionString="data source=nc54a9m5kk.database.windows.net;initial catalog=Traffic;
         user id=chickenskills@nc54a9m5kk;password=sk1lzm@tter;" />
  </connectionStrings>
</configuration>

前往 解决方案资源管理器 并将 UI 项目设置为启动项目:

UI

编译你的项目。如果一切顺利,尝试运行它。你应该会得到一个警告对话框,如下所示:

UI

然后你会看到一个类似这样的屏幕:

UI

一旦你完全领略了用户体验的奇妙,停止运行应用程序。到目前为止,这已经相当不错了。如果我们把这个应用程序放在车载的位置感知设备(如 GPS)上并四处驾驶,状态栏会警告我们是否在可能发生事故或停车风险的地理位置四分之一英里范围内。然而,如果我们想给自己更多的提醒,我们需要添加更多的代码。

添加距离计算

返回 F# 项目并打开 TrafficML.fs 文件。找到构造函数的最后一行。代码看起来如下:

    let trafficGeo' = Array.zip trafficGeo labels
                      |> Array.map(fun (tg,l) -> {Geolocation=tg.Geolocation;CrashCount=tg.CrashCount;StopCount=tg.StopCount;Cluster=l} ) 

在此行下方,输入以下内容:

    let toRadian x = (Math.PI/180.0) * x

    let haversine x y =
        let dlon = toRadian (x.Longitude - y.Longitude)
        let dLat = toRadian (x.Latitude - y.Latitude)
        let a0 = pown (Math.Sin(dLat/2.0)) 2
        let a1 = Math.Cos(toRadian(x.Latitude)) * Math.Cos(toRadian(y.Latitude))
        let a2 = pown (Math.Sin(dlon/2.0)) 2
        let a = a0 + a1 * a2
        let c = 2.0 * Math.Atan2(sqrt(a),sqrt(1.0-a))
        let R = 3956.0
        R * c

这两个函数允许我们计算地理位置之间的距离。由于地球是弯曲的,我们不能简单地从两个地理位置的纬度和经度中减去。Haversine 公式是进行这种计算最常见的方法。

前往文件末尾并添加以下内容:

    member this.GetCluster(latitude: float, longitude: float, distance: float) =
        let geolocation = {Latitude=latitude; Longitude=longitude}
        let found = trafficGeo' |> Array.map(fun gt -> gt,(haversine gt.Geolocation geolocation))
                                |> Array.filter(fun (gt,d) -> d < distance)
                                |> Array.sortByDescending(fun (gt,d) -> gt.Cluster)
        match found.Length with
        | 0 -> -1
        | _ -> let first = found |> Array.head
               let gt = fst first
               gt.Cluster 

我们正在做的是通过一个额外的参数distance来重载GetCluster函数。使用这个输入距离,我们可以计算出地理位置参数和我们的trafficGeo数组中的每个地理位置之间的距离。如果有任何匹配项,我们按簇数量最多的进行排序(sortByDescending),然后返回它。

返回我们的用户界面项目,打开MainWindow.xaml.cs文件,找到Watcher_PositionChanged方法。找到以下代码行:

var cluster = _trafficML.GetCluster(latitude, longitude);

替换为以下代码行:

var cluster = _trafficML.GetCluster(latitude, longitude, 2.0);

我们现在对道路上的任何问题区域都有了两英里的预警。

增加人工观察

我们还想对我们的用户界面做一些改动。如果你看看一些像 Waze 这样的众包道路应用,它们提供实时通知。我们的应用基于历史数据来进行分类。然而,如果我们正在一个被归类为低风险的地区街道上驾驶,并且我们看到一起交通事故,我们希望将这个位置提升到高风险。理想情况下,我们应用的所有用户都能收到这个更新,并覆盖模型对地理位置的分类(至少暂时如此),然后我们会更新我们的数据库,以便在我们重新训练模型时,信息变得更加准确。

前往notifyButton_Click事件处理程序,将//TODO替换为以下内容:

            var location = _watcher.Position.Location;
            var latitude = Double.Parse(location.Latitude.ToString("00.000"));
            var longitude = Double.Parse(location.Longitude.ToString("00.000"));
            _trafficML.AddGeolocationToClusterOverride(latitude, longitude);

编译器会向你抱怨,因为我们还没有实现AddGeolocationToClusterOverride。回到 F#项目,打开TrafficML.fs文件。在文件的底部,添加以下内容:

    member this.AddGeolocationToClusterOverride(latitude: float, longitude: float)  =
        let clusterOverride = EntityConnection.ServiceTypes.dbo_ClusterOverride()
        clusterOverride.Latitude <- latitude
        clusterOverride.Longitude <- longitude
        clusterOverride.Cluster <- 2
        clusterOverride.OverrideDateTime <- DateTime.UtcNow
        context.dbo_ClusterOverride.AddObject(clusterOverride)
        context.DataContext.SaveChanges() |> ignore

我们现在有一种方法可以更新任何覆盖的数据库。请注意,你将无法写入为这本书创建的 Azure 上的共享数据库,但你将能够写入你的本地副本。作为最后一步,回到我们创建trafficGeo的以下行:

    let trafficGeo' = Array.zip trafficGeo labels
                      |> Array.map(fun (tg,l) -> {Geolocation=tg.Geolocation;CrashCount=tg.CrashCount;StopCount=tg.StopCount;Cluster=l} ) 

将该行替换为以下代码块:

    let overrides = context.dbo_ClusterOverride
                    |> Seq.filter(fun co -> (DateTime.UtcNow - co.OverrideDateTime) > TimeSpan(0,5,0))
                    |> Seq.toArray

        let checkForOverride (geoTraffic:GeoTraffic') =
        let found = overrides
                    |> Array.tryFind(fun o -> o.Latitude = geoTraffic.Geolocation.Latitude && 
                    o.Longitude = geoTraffic.Geolocation.Longitude)
        match found.IsSome with
        | true -> {Geolocation=geoTraffic.Geolocation;
                  CrashCount=geoTraffic.CrashCount;
                  StopCount=geoTraffic.StopCount;
                  Cluster=found.Value.Cluster}
        | false -> geoTraffic

    let trafficGeo' = Array.zip trafficGeo labels
                      |> Array.map(fun (tg,l) -> {Geolocation=tg.Geolocation;
                       CrashCount=tg.CrashCount;
                       StopCount=tg.StopCount;
                       Cluster=l} ) 
                      |> Array.map(fun gt -> checkForOverride(gt))

此块将数据传输到数据库,并拉取过去 5 分钟内发生的所有覆盖项,并将它们放入覆盖数组中。然后,它创建了一个名为checkForOverride的函数,该函数接受geoTraffic值。如果纬度和经度与覆盖表匹配,则将geoTraffic值替换为数据库分配的新值,而不是来自 k-means 模型的新值。如果没有找到匹配项,则返回原始值。最后,我们将此函数管道到trafficGeo的创建中。请注意,如果您尝试在我们的共享服务器上执行此操作,它将抛出一个异常,因为您没有权限写入数据库。然而,通过这个例子,希望意图是清晰的。有了这个,我们有一个实时系统,它结合了机器学习和人类观察,为我们最终用户提供最佳的可能预测。

摘要

在本章中,我们涵盖了大量的内容。我们探讨了 k-means 和 PCA 算法,以帮助我们找到交通数据集中的隐藏关系。然后,我们构建了一个应用程序,利用我们获得的洞察力使驾驶员更加警觉,并希望他们能更安全。这个应用程序的独特之处在于它结合了实时机器学习建模和人类观察,为驾驶员提供最佳的可能结果。

在下一章中,我们将探讨我们在这本书中迄今为止的编码的一些局限性,并看看我们是否可以改进模型和特征选择。

第八章。特征选择和优化

在软件工程中,有一句古老的谚语:先让它工作,再让它变快。在这本书中,我们采用了先让它运行,再让它变得更好的策略。我们在第一章中讨论的许多模型在非常有限的意义上是正确的,并且可以通过一些优化来使它们更加正确。这一章完全是关于让它变得更好的。

清洗数据

正如我们在第五章中看到的,时间到 – 获取数据,使用 F#类型提供者获取和塑造数据(这通常是许多项目中的最大问题)非常简单。然而,一旦我们的数据本地化和塑形,我们为机器学习准备数据的工作还没有完成。每个数据帧可能仍然存在异常。像空值、空值和超出合理范围的数据值等问题需要解决。如果你来自 R 背景,你将熟悉null.omitna.omit,它们会从数据框中删除所有行。我们可以在 F#中通过应用一个过滤函数来实现功能等价。在过滤中,如果你是引用类型,可以搜索空值,如果是可选类型,则使用.isNone。虽然这很有效,但它有点像一把钝锤,因为你正在丢弃可能在其他字段中有有效值的行。

处理缺失数据的另一种方法是用一个不会扭曲分析的值来替换它。像数据科学中的大多数事情一样,关于不同技术的意见有很多,这里我不会过多详细说明。相反,我想让你意识到这个问题,并展示一种常见的补救方法:

进入 Visual Studio,创建一个名为FeatureCleaning的 Visual F# Windows 库项目:

清洗数据

解决方案资源管理器中定位Script1.fsx并将其重命名为CleanData.fsx

清洗数据

打开那个脚本文件,并用以下代码替换现有的代码:

type User = {Id: int; FirstName: string; LastName: string; Age: float}
let users = [|{Id=1; FirstName="Jim"; LastName="Jones"; Age=25.5};
              {Id=2; FirstName="Joe"; LastName="Smith"; Age=10.25};
              {Id=3; FirstName="Sally"; LastName="Price"; Age=1000.0};|]

将其发送到 FSI 后,我们得到以下结果:

type User =
 {Id: int;
 FirstName: string;
 LastName: string;
 Age: float;}
val users : User [] = [|{Id = 1;
 FirstName = "Jim";
 LastName = "Jones";
 Age = 25.5;}; {Id = 2;
 FirstName = "Joe";
 LastName = "Smith";
 Age = 10.25;}; {Id = 3;
 FirstName 
= "Sally";
 LastName = "Price";
 Age = 1000.0;}|]

User是一种记录类型,代表应用程序的用户,而users是一个包含三个用户的数组。它看起来相当普通,除了用户 3,莎莉·普莱斯,她的年龄为1000.0。我们想要做的是去掉这个年龄,但仍然保留莎莉的记录。要做到这一点,让我们将 1,000 替换为所有剩余用户年龄的平均值。回到脚本文件,输入以下内容:

let validUsers = Array.filter(fun u -> u.Age < 100.0) users
let averageAge = Array.averageBy(fun u -> u.Age) validUsers

let invalidUsers = 
    users 
    |> Array.filter(fun u -> u.Age >= 100.0) 
    |> Array.map(fun u -> {u with Age = averageAge})

let users' = Array.concat [validUsers; invalidUsers]

将其发送到 FSI 应该得到以下结果:

val averageAge : float = 17.875
val invalidUsers : User [] = [|{Id = 3;
 FirstName = "Sally";
 LastName = "Price";
 Age = 17.875;}|]
val users' : User [] = [|{Id = 1;
 FirstName = "Jim";
 LastName = "Jones";
 Age = 25.5;}; {Id = 2;
 FirstName = "Joe";
 LastName = "Smith";
 Age = 10.25;}; {Id = 3;
 FirstName = "Sally";
 LastName = "Price";
 Age = 17.875;}|]

注意,我们创建了一个有效用户的子数组,然后获取他们的平均年龄。然后我们创建了一个无效用户的子数组,并映射平均年龄。由于 F#不喜欢可变性,我们为每个无效用户创建了一个新记录,并有效地使用with语法,创建了一个具有所有相同值的新记录,除了年龄。然后我们通过将有效用户和更新的用户合并成一个单一数组来完成。尽管这是一种相当基础的技术,但它可以非常有效。当你进一步学习机器学习时,你会发展和完善自己的处理无效数据的技术——你必须记住,你使用的模型将决定你如何清理这些数据。在某些模型中,取平均值可能会使事情变得混乱。

选择数据

当我们面对大量独立变量时,我们常常会遇到选择哪些值的问题。此外,变量可能被分类、与其他变量结合或改变——这些都可能使某个模型成功或失败。

共线性

共线性是指我们有多多个彼此高度相关的x变量;它们有高度的关联度。在使用回归时,你总是要警惕共线性,因为你不能确定哪个个别变量真正影响了结果变量。这里有一个经典例子。假设你想测量大学生的幸福感。你有以下输入变量:年龄、性别、用于啤酒的可用资金、用于教科书的可用资金。在这种情况下,用于啤酒的可用资金和用于教科书的可用资金之间存在直接关系。用于教科书的资金越多,用于啤酒的资金就越少。为了解决共线性问题,你可以做几件事情:

  • 删除一个高度相关的变量。在这种情况下,可能需要删除用于教科书的可用资金。

  • 将相关的变量合并成一个单一变量。在这种情况下,可能只需要有一个检查账户中的金钱类别。

测试共线性的一个常见方法是多次运行你的多元回归,每次移除一个x变量。如果移除两个不同变量时没有显著变化,它们就是共线性的良好候选者。此外,你总是可以扫描x变量的相关矩阵,你可以使用 Accord.Net 的Tools.Corrlelation方法来做这件事。让我们看看这个。回到 Visual Studio,添加一个名为Accord.fsx的新脚本文件。打开 NuGet 包管理器控制台,并添加 Accord:

PM> install-package Accord.Statistics
Next, go into the script file and enter this:
#r "../packages/Accord.Math.3.0.2/lib/net40/Accord.Math.dll"
#r "../packages/Accord.Statistics.3.0.2/lib/net40/Accord.Statistics.dll"

open Accord.Statistics

//Age 
//Sex - 1 or 0
//money for textbooks
//money for beer

let matrix = array2D [ [ 19.0;1.0;50.0;10.0]; 
                       [18.0;0.0;40.0;15.0]; 
                       [21.0;1.0;10.0;40.0]]
let correlation = Tools.Correlation(matrix)

这代表了我们采访的三名学生。我们询问了他们的年龄、性别、用于教科书的资金和用于啤酒的资金。第一位学生是 19 岁的女性,有 50.00 美元用于教科书,10.00 美元用于啤酒。

当你将此发送到 FSI 时,你会得到以下结果:

val correlation : float [,] =
 [[1.0; 0.755928946; -0.8386278694; 0.8824975033]
 [0.755928946; 1.0; -0.2773500981; 0.3592106041]
 [-0.8386278694; -0.2773500981; 1.0; -0.9962709628]
 [0.8824975033; 0.3592106041; -0.9962709628; 1.0]]

这有点难以阅读,所以我重新格式化了它:

年龄 性别 $ 书籍 $ 啤酒
年龄 1.0 0.76 -0.84 0.88
性别 0.76 1.0 -0.28 0.35
$ 书籍 -0.84 -0.28 1.0 -0.99
$ 啤酒 0.88 0.35 -0.99 1.0

注意矩阵中的对角线值,1.0,这意味着年龄与年龄完全相关,性别与性别完全相关,等等。从这个例子中,关键的一点是书籍金额和啤酒金额之间存在几乎完美的负相关:它是 -0.99。这意味着如果你为书籍有更多的钱,那么你为啤酒的钱就会更少,这是有道理的。通过阅读相关矩阵,你可以快速了解哪些变量是相关的,可能可以被移除。

与多重共线性相关的一个话题是始终尽可能使你的 y 变量与 x 变量独立。例如,如果你做了一个回归,试图挑选出我们学生的啤酒可用金额,你不会选择任何与该学生拥有的金额相关的独立变量。为什么?因为它们测量的是同一件事。

特征选择

与多重共线性相关的一个话题是特征选择。如果你有一堆 x 变量,你如何决定哪些变量最适合你的分析?你可以开始挑选,但这很耗时,可能还会出错。与其猜测,不如有一些建模技术可以在所有数据上运行模拟,以确定最佳的 x 变量组合。其中最常见的技术之一被称为前向选择逐步回归。考虑一个包含五个自变量和一个因变量的数据框:

特征选择

使用前向选择逐步回归,该技术从单个变量开始,运行回归,并计算(在这种情况下)均方根误差(rmse):

特征选择

接下来,技术会回过头来添加另一个变量并计算 rmse:

特征选择

接下来,技术会回过头来添加另一个变量并计算 rmse:

特征选择

到现在为止,你可能已经明白了。根据实现方式,逐步回归可能会用不同的自变量组合和/或不同的测试和训练集重新运行。当逐步回归完成后,你就可以对哪些特征是重要的以及哪些可以被舍弃有一个很好的了解。

让我们来看看 Accord 中的逐步回归示例。回到你的脚本中,输入以下代码(注意,这直接来自 Accord 帮助文件中的逐步回归部分):

#r "../packages/Accord.3.0.2/lib/net40/Accord.dll"
open Accord.Statistics.Analysis

//Age/Smoking
let inputs = [|[|55.0;0.0|];[|28.0;0.0|];
               [|65.0;1.0|];[|46.0;0.0|];
               [|86.0;1.0|];[|56.0;1.0|];
               [|85.0;0.0|];[|33.0;0.0|];
               [|21.0;1.0|];[|42.0;1.0|];
               [|33.0;0.0|];[|20.0;1.0|];
               [|43.0;1.0|];[|31.0;1.0|];
               [|22.0;1.0|];[|43.0;1.0|];
               [|46.0;0.0|];[|86.0;1.0|];
               [|56.0;1.0|];[|55.0;0.0|];|]

//Have Cancer
let output = [|0.0;0.0;0.0;1.0;1.0;1.0;0.0;0.0;0.0;1.0;
               0.0;1.0;1.0;1.0;1.0;1.0;0.0;1.0;1.0;0.0|]

let regression = 
    StepwiseLogisticRegressionAnalysis(inputs, output, [|"Age";"Smoking"|],"Cancer")

将其发送到 FSI,得到以下结果:

val inputs : float [] [] =
 [|[|55.0; 0.0|]; [|28.0; 0.0|]; [|65.0; 1.0|]; [|46.0; 0.0|]; [|86.0; 1.0|];
 [|56.0; 1.0|]; [|85.0; 0.0|]; [|33.0; 0.0|]; [|21.0; 1.0|]; [|42.0; 1.0|];
 [|33.0; 0.0|]; [|20.0; 1.0|]; [|43.0; 1.0|]; [|31.0; 1.0|]; [|22.0; 1.0|];
 [|43.0; 1.0|]; [|46.0; 0.0|]; [|86.0; 1.0|]; [|56.0; 1.0|]; [|55.0; 0.0|]|]
val output : float [] =
 [|0.0; 0.0; 0.0; 1.0; 1.0; 1.0; 0.0; 0.0; 0.0; 1.0; 0.0; 1.0; 1.0; 1.0; 1.0;
 1.0; 0.0; 1.0; 1.0; 0.0|]
val regression : StepwiseLogisticRegressionAnalysis

如代码中的注释所示,输入的是 20 个最近接受癌症筛查的虚构人物。特征包括他们的年龄和是否吸烟。输出是这个人是否真的患有癌症。

回到脚本中,添加以下内容:

let results = regression.Compute()
let full = regression.Complete;
let best = regression.Current;

full.Coefficients

best.Coefficients

当你将这个数据发送到 FSI 时,你会看到一些非常有趣的东西。full.Coefficients返回所有变量,但best.Coefficients返回以下内容:

val it : NestedLogisticCoefficientCollection =
 seq
 [Accord.Statistics.Analysis.NestedLogisticCoefficient
 {Confidence = 0.0175962716285245, 1.1598020423839;
 ConfidenceLower = 0.01759627163;
 ConfidenceUpper = 1.159802042;
 LikelihoodRatio = null;
 Name = "Intercept";
 OddsRatio = 0.1428572426;
 StandardError = 1.068502877;
 Value = -1.945909451;
 Wald = 0.0685832853132018;};
 Accord.Statistics.Analysis.NestedLogisticCoefficient
 {Confidence = 2.63490696729824, 464.911388747606;
 ConfidenceLower = 2.634906967;
 ConfidenceUpper = 464.9113887;
 LikelihoodRatio = null;
 Name = "Smoking";
 OddsRatio = 34.99997511;
 StandardError = 1.319709922;
 Value = 3.55534735;
 Wald = 0.00705923290736891;}]

现在,你可以看到吸烟是预测癌症时最重要的变量。如果有两个或更多变量被认为是重要的,Accord 会告诉你第一个变量,然后是下一个,依此类推。逐步回归分析在当今社区已经转向 Lasso 和其他一些技术的情况下,有点过时了。然而,它仍然是你的工具箱中的一个重要工具,并且是你应该了解的内容。

归一化

有时候,通过调整数据,我们可以提高模型的效果。我说的不是在安然会计或美国政治家意义上的“调整数字”。我指的是使用一些标准的科学技术来调整数据,这些技术可能会提高模型的准确性。这个过程的通用术语是归一化

数据归一化的方法有很多种。我想向您展示两种与回归分析效果良好的常见方法。首先,如果你的数据是聚集在一起的,你可以对数值取对数,以帮助揭示可能被隐藏的关系。例如,看看我们来自第二章的开头的产品评论散点图,AdventureWorks 回归。注意,大多数订单数量集中在 250 到 1,000 之间。

归一化

通过对订单数量取对数并执行类似的散点图,你可以更清楚地看到关系:

归一化

注意,取对数通常不会改变因变量和自变量之间的关系,因此你可以在回归分析中安全地用它来替换自然值。

如果你回到第二章的解决方案,AdventureWorks 回归,你可以打开回归项目并添加一个名为Accord.Net4.fsx的新文件。将Accord.Net2.fsx中的内容复制并粘贴进来。接下来,用以下代码替换数据读取器的代码行:

while reader.Read() do
    productInfos.Add({ProductID=reader.GetInt32(0);
       AvgOrders=(float)(reader.GetDecimal(1));
       AvgReviews=log((float)(reader.GetDecimal(2)));
       ListPrice=(float)(reader.GetDecimal(3));})

将此发送到 REPL,我们得到以下结果:

val regression : MultipleLinearRegression =
 y(x0, x1) = 35.4805245757214*x0 + -0.000897944878777119*x1 + -36.7106228824185
val error : float = 687.122625
val a : float = 35.48052458
val b : float = -0.0008979448788
val c : float = -36.71062288
val mse : float = 7.083738402
val rmse : float = 2.661529335
val r2 : float = 0.3490097415

注意变化。我们正在对x变量取对数。同时,注意我们的r2略有下降。原因是尽管对数没有改变AvgReviews之间的关系,但它确实影响了它与其他x变量以及可能y变量的关系。你可以看到,在这种情况下,它并没有做什么。

除了使用对数,我们还可以修剪异常值。回到我们的图表,你注意到那个平均订单数量为 2.2、平均评论为 3.90 的孤独点吗?

归一化

观察所有其他数据点,我们预计平均评论为 3.90 应该至少有 2.75 的平均订单数量。尽管我们可能想深入了解以找出问题所在,但我们将其留到另一天。现在,它实际上正在破坏我们的模型。确实,回归分析的最大批评之一就是它们对异常值过于敏感。让我们看看一个简单的例子。转到第二章,AdventureWorks 回归回归项目,创建一个新的脚本,命名为Accord5.fsx。将Accord1.fsx中的代码的第一部分复制到其中:

#r "../packages/Accord.3.0.2/lib/net40/Accord.dll"
#r "../packages/Accord.Statistics.3.0.2/lib/net40/Accord.Statistics.dll"
#r "../packages/Accord.Math.3.0.2/lib/net40/Accord.Math.dll"

open Accord
open Accord.Statistics.Models.Regression.Linear

let xs = [| [|15.0;130.0|];[|18.0;127.0|];[|15.0;128.0|]; [|17.0;120.0|];[|16.0;115.0|] |]
let y = [|3.6;3.5;3.8;3.4;2.6|]

let regression = MultipleLinearRegression(2,true)
let error = regression.Regress(xs,y)

let a = regression.Coefficients.[0]
let b = regression.Coefficients.[1]

let sse = regression.Regress(xs, y)
let mse = sse/float xs.Length 
let rmse = sqrt(mse)
let r2 = regression.CoefficientOfDetermination(xs,y)

接下来,让我们加入一个对学校感到无聊的天才儿童,他的平均绩点(GPA)很低。加入一个 10 岁的学生,智商(IQ)为 150,平均绩点(GPA)为 1.0:

let xs = [| [|15.0;130.0|];[|18.0;127.0|];[|15.0;128.0|]; [|17.0;120.0|];[|16.0;115.0|];[|10.0;150.0|] |]

let y = [|3.6;3.5;3.8;3.4;2.6;1.0|]

将整个脚本发送到 REPL,我们得到以下结果:

val regression : MultipleLinearRegression =
 y(x0, x1) = 0.351124295971452*x0 + 0.0120748957392838*x1 + -3.89166344210844
val error : float = 1.882392837
val a : float = 0.351124296
val b : float = 0.01207489574
val sse : float = 1.882392837
val mse : float = 0.3137321395
val rmse : float = 0.5601179693
val r2 : float = 0.6619468116

注意我们的模型发生了什么。我们的r2从 0.79 降至 0.66,我们的均方根误差(rmse)从 0.18 升至 0.56!天哪,这是多么戏剧性的变化!正如你所猜到的,你如何处理异常值将对你的模型产生重大影响。如果模型的目的是预测大多数学生的平均绩点,我们可以安全地移除这个异常值,因为它并不典型。另一种处理异常值的方法是使用一个在处理它们方面做得更好的模型。

在掌握这些知识后,让我们用实际数据来尝试。添加一个新的脚本文件,并将其命名为AccordDotNet6.fsx。将AccordDotNet2.fsx中的所有内容复制并粘贴到其中。接下来,找到以下这些行:

        while reader.Read() do
            productInfos.Add({ProductID=reader.GetInt32(0);
                                AvgOrders=(float)(reader.GetDecimal(1));
                                AvgReviews=(float)(reader.GetDecimal(2));
                                ListPrice=(float)(reader.GetDecimal(3));})

        let xs = productInfos |> Seq.map(fun pi -> [|pi.AvgReviews; pi.ListPrice|]) |> Seq.toArray
        let y = productInfos |> Seq.map(fun pi -> pi.AvgOrders) |> Seq.toArray

And replace them with these:
        while reader.Read() do
            productInfos.Add({ProductID=reader.GetInt32(0);
                                AvgOrders=(float)(reader.GetDecimal(1));
                                AvgReviews=(float)(reader.GetDecimal(2));
                                ListPrice=(float)(reader.GetDecimal(3));})

        let productInfos' = productInfos |> Seq.filter(fun pi -> pi.ProductID <> 757)

        let xs = productInfos' |> Seq.map(fun pi -> [|pi.AvgReviews; pi.ListPrice|]) |> Seq.toArray
        let y = productInfos' |> Seq.map(fun pi -> pi.AvgOrders) |> Seq.toArray

将这些内容发送到 REPL,我们得到以下结果:

val regression : MultipleLinearRegression =
 y(x0, x1) = 9.89805316193142*x0 + -0.000944004141999501*x1 + -26.8922595356297
val error : float = 647.4688586
val a : float = 9.898053162
val b : float = -0.000944004142
val c : float = -26.89225954
val mse : float = 6.744467277
val rmse : float = 2.59701122
val r2 : float = 0.3743706412

r2从 0.35 升至 0.37,我们的 rmse 从 2.65 降至 2.59。删除一个数据点就有如此大的改进!如果你愿意,可以将这个更改应用到 AdventureWorks 项目中。我不会带你走这一步,但现在你有了独立完成它的技能。删除异常值是使回归分析更准确的一种非常强大的方法,但这也存在代价。在我们开始从模型中删除不起作用的数据元素之前,我们必须做出一些判断。事实上,有一些教科书专门讨论处理异常值和缺失数据的科学。在这本书中,我们不会深入探讨这个问题,只是承认这个问题存在,并建议你在删除元素时使用一些常识。

缩放

我想承认关于归一化和度量单位的一个常见误解。你可能会注意到,在第二章,AdventureWorks 回归和第三章中,不同的 x 变量具有显著不同的度量单位。在我们的示例中,客户评论的单位是 1-5 的评分,自行车的价格是 0-10,000 美元。你可能会认为比较这样大的数字范围会对模型产生不利影响。不深入细节,你可以放心,回归对不同的度量单位具有免疫力。

然而,其他模型(尤其是分类和聚类模型,如 k-NN、k-means 和 PCA)会受到 影响。当我们创建这些类型的模型在第六章和第七章中时,Traffic Stops and Crash Locations – When Two Datasets Are Better Than One,我们面临的风险是得到错误的结果,因为数据没有缩放。幸运的是,我们选择的功能和使用的库(Numl.net 和 Accord)帮助我们摆脱了困境。Numl.NET 自动对所有分类模型中的输入变量进行缩放。根据模型类型,Accord 可能会为你进行缩放。例如,在我们在第七章中编写的 PCA 中,我们在这行代码中传递了一个名为 AnalysisMethod.Center 的输入参数:

let pca = new PrincipalComponentAnalysis(pcaInput.ToArray(), AnalysisMethod.Center)

这将输入变量缩放到平均值,这对我们的分析来说已经足够好了。当我们使用 Accord 在第六章中执行 k-NN 时,AdventureWorks Redux – k-NN 和朴素贝叶斯分类器,我们没有缩放数据,因为我们的两个输入变量是分类变量(MartialStatusGender),只有两种可能性(已婚或未婚,男或女),并且你只需要缩放连续变量或具有两个以上值的分类变量。如果我们使用了连续变量或三个因子的分类变量在 k-NN 中,我们就必须对其进行缩放。

让我们通过一个使用 Accord 的快速缩放示例来了解一下。打开本章的 FeatureCleaning 解决方案,并添加一个名为 AccordKNN 的新脚本文件:

缩放

进入 NuGet 包管理器控制台,输入以下内容:

PM> install-package Accord.MachineLearning

进入AccordKNN.fsx文件,并添加我们在第六章中使用的代码,AdventureWorks Redux – k-NN 和朴素贝叶斯分类器,供那些学习和喝酒的学生使用:

#r "../packages/Accord.3.0.2/lib/net40/Accord.dll"
#r "../packages/Accord.Math.3.0.2/lib/net40/Accord.Math.dll"
#r "../packages/Accord.Statistics.3.0.2/lib/net40/Accord.Statistics.dll"
#r "../packages/Accord.MachineLearning.3.0.2/lib/net40/Accord.MachineLearning.dll"

open Accord
open Accord.Math
open Accord.MachineLearning
open Accord.Statistics.Analysis

let inputs = [|[|5.0;1.0|];[|4.5;1.5|];
             [|5.1;0.75|];[|1.0;3.5|];
             [|0.5;4.0|];[|1.25;4.0|]|]
let outputs = [|1;1;1;0;0;0|]

let classes = 2
let k = 3
let knn = KNearestNeighbors(k, classes, inputs, outputs)

let input = [|5.0;0.5|]
let output = knn.Compute(input)

现在,让我们将数据缩放,使得学习和喝酒相当。我们将采用最简单的缩放方法,称为均值缩放。回到脚本中,输入以下内容:

let studyingAverage = inputs |> Array.map(fun i -> i.[0]) |> Array.average
let drinkingAverage = inputs |> Array.map(fun i -> i.[1]) |> Array.average

let scaledInputs = inputs |> Array.map(fun i -> [|i.[0]/studyingAverage; i.[1]/drinkingAverage|])
let scaledKNN = KNearestNeighbors(k, classes, scaledInputs, outputs)

当你将其发送到 REPL 时,你会看到以下内容:

val studyingAverage : float = 2.891666667
val drinkingAverage : float = 2.458333333
val scaledInputs : float [] [] =
 [|[|1.729106628; 0.406779661|]; [|1.556195965; 0.6101694915|];
 [|1.763688761; 0.3050847458|]; [|0.3458213256; 1.423728814|];
 [|0.1729106628; 1.627118644|]; [|0.4322766571; 1.627118644|]|]
val scaledKNN : KNearestNeighbors

注意,输入现在相对于它们的平均值。那个学习了五小时并喝了一杯啤酒的人现在比平均水平多学习了 73%,比平均水平少喝了 41%。这个 k-NN 模型现在已缩放,当实际应用时将提供更好的“苹果对苹果”的比较。

过度拟合与交叉验证

如果你记得第 2、3 和 4 章,我们在构建模型时遇到的一个问题是过度拟合。过度拟合,预测分析的祸害,是指当我们构建一个在历史数据上做得很好的模型,但在引入新数据时却崩溃的现象。这种现象不仅限于数据科学;在我们的社会中发生得很多:职业运动员获得丰厚的合同,却未能达到先前的表现;基金经理因为去年的表现而获得大幅加薪,等等。

交叉验证 – 训练与测试

与从不学习的洋基队不同,我们的职业从错误中吸取了教训,并拥有一个伟大的、尽管不完美的工具来对抗过拟合。我们使用训练/测试/评估的方法构建多个模型,然后选择最佳模型,而不是基于它对现有数据集的表现,而是基于它对之前未见过的数据的表现。为了实现这一点,我们取我们的源数据,导入它,清理它,并将其分成两个子集:训练和测试。然后我们在训练集上构建我们的模型,如果它看起来可行,就将测试数据应用到模型上。如果模型仍然有效,我们可以考虑将其推向生产。这可以用以下图形表示:

交叉验证 – 训练与测试

但我们还可以添加一个额外的步骤。我们可以将数据分割多次,并构建新的模型进行验证。实际分割数据集本身就是一门科学,但通常每次将基本数据集分割成训练测试子集时,记录都是随机选择的。这意味着如果你将基本数据分割五次,你将拥有五个完全不同的训练和测试子集:

交叉验证 – 训练与测试

这种技术可能比实际模型选择更重要。Accord 和 Numl 在底层都进行了一些分割,在这本书中,我们将相信它们正在做好这项工作。然而,一旦你开始在野外工作模型,你将希望在每个项目上投入一定的时间进行交叉验证。

交叉验证 – 随机和平均测试

回到我们关于学生学习和喝酒的 k-NN 示例,我们如何知道我们是否预测准确?如果我们想猜测一个学生是否通过,我们只需抛硬币:正面通过,反面失败。我们分析中的假设是,学习的小时数和喝的啤酒数对考试成绩有一定的影响。如果我们的模型不如抛硬币,那么它不是一个值得使用的模型。打开 Visual Studio 并回到AccordKNN.fsx文件。在底部,输入以下代码:

let students = [|0..5|]
let random = System.Random()
let randomPrediction = 
    students 
    |> Array.map(fun s -> random.Next(0,2))

将这些发送到 FSI,我们得到以下结果(你的结果将不同):

val students : int [] = [|0; 1; 2; 3; 4; 5|]
val random : System.Random
val randomPrediction : int [] = [|0; 1; 0; 0; 1; 1|]

现在,让我们输入有关每个学生的信息:他们学习的小时数和他们喝的啤酒数,并运行未缩放的 k-NN:

let testInputs = [|[|5.0;1.0|];[|4.0;1.0|];
                 [|6.2;0.5|];[|0.0;2.0|];
                 [|0.5;4.0|];[|3.0;6.0|]|]

let knnPrediction =
    testInputs
    |> Array.map(fun ti -> knn.Compute(ti))

将这些发送到 REPL,我们得到以下结果:

val testInputs : float [] [] =
 [|[|5.0; 1.0|]; [|4.0; 1.0|]; [|6.2; 0.5|]; [|0.0; 2.0|]; [|0.5; 4.0|];
 [|3.0; 6.0|]|]
val knnPrediction : int [] = [|1; 1; 1; 0; 0; 0|]

最后,让我们看看它们在考试中的实际表现。将以下内容添加到脚本中:

let actual = [|1;1;1;0;0;0|]

将这些发送到 FSI,我们得到以下结果:

val actual : int [] = [|1; 1; 1; 0; 0; 0|]

将这些数组组合在一起在图表中,我们将得到以下结果:

交叉验证 – 随机和平均测试

如果我们评分随机测试和 k-NN 在预测实际结果方面的表现,我们可以看到随机测试正确预测结果 66%的时间,而 k-NN 正确预测结果 100%的时间:

交叉验证 – 随机和平均测试

因为我们的 k-NN 比随机抛硬币做得更好,我们可以认为这个模型是有用的。

这种是/否随机测试在我们的模型是逻辑回归或 k-NN 这样的分类模型时效果很好,但当我们依赖的(Y)变量是像线性回归中的连续值时怎么办?在这种情况下,我们不是使用随机抛硬币,而是可以插入已知值的平均值。如果结果预测比平均值好,我们可能有一个好的模型。如果它比平均值差,我们需要重新思考我们的模型。例如,考虑从 AdventureWorks 预测平均自行车评论:

交叉验证 – 随机和平均测试

当你将预测值与实际值(考虑到可能更高或更低)进行比较,然后汇总结果时,你可以看到我们的线性回归在预测评分方面做得比平均值更好:

交叉验证 – 随机和平均测试

如果你认为我们在第二章和第三章已经做过类似的事情,你是正确的——这与 RMSE 的概念相同。

交叉验证 – 混淆矩阵和 AUC

回到我们的 k-NN 示例,想象一下我们对许多学生运行了 k-NN。有时 k-NN 猜对了,有时 k-NN 没有。实际上有四种可能的结果:

  • k-NN 预测学生会通过,他们确实通过了

  • k-NN 预测学生会失败,他们确实失败了

  • k-NN 预测学生会通过,但他们失败了

  • k-NN 预测学生会失败,但他们通过了

每个这些结果都有一个特殊的名称:

  • 预测通过且实际通过:真阳性

  • 预测失败且实际失败:真阴性

  • 预测通过但失败了:假阳性

  • 预测失败但通过了:假阴性

以图表形式,它看起来是这样的:

交叉验证 – 混淆矩阵和 AUC

有时,假阳性被称为 I 型错误,而假阴性被称为 II 型错误。

如果我们对 100 名学生运行 k-NN,我们可以在图表中添加如下值:

交叉验证 – 混淆矩阵和 AUC

阅读这张图表,52 名学生通过了考试。其中,我们正确预测了 50 人会通过,但错误地预测了两个通过的学生会失败。同样,43 名学生没有通过考试(肯定是一场艰难的考试!),其中我们正确预测了 40 人会失败,而三个我们错误地预测了会通过。这个矩阵通常被称为混淆矩阵

使用这个混淆矩阵,我们可以进行一些基本的统计,例如:

准确率 = 真阳性 + 真阴性 / 总人口 = (50 + 40) / 100 = 90%

真阳性率 (TPR) = 真阳性 / 总阳性 = 50 / 52 = 96%

假阴性率 (FNR) = 假阴性 / 总阳性 = 2 / 52 = 4%

假阳性率 (FPR) = 假阳性 / 总阴性 = 3 / 43 = 7%

真阴性率 (TNR) = 真阴性 / 总阴性 = 40 / 43 = 93%

(注意,TPR 有时被称为灵敏度,FNR 有时被称为漏报率,假阳性率有时被称为逃逸率,而 TNR 有时被称为特异性。)

阳性似然比 (LR+) = TPR / FPR = 96% / (1 – 93%) = 13.8

阴性似然比 (LR-) = FNR / TNR = 4% / 93% = 0.04

诊断优势比 (DOR) = LR+ / LR- = 33.3

由于 DOR 大于 1,我们知道模型运行良好。

将这些放入代码中,我们可以手动写下这些公式,但 Accord.Net 已经为我们处理好了。回到 Visual Studio,打开AccordKNN.fsx。在底部,输入以下代码:

let positiveValue = 1
let negativeValue = 0

let confusionMatrix = ConfusionMatrix(knnPrediction,actual,positiveValue,negativeValue)

在下一行,输入confusionMatrix并按点号以查看所有可用的属性:

交叉验证 – 混淆矩阵和 AUC

这确实是一个非常实用的课程。让我们选择优势比:

confusionMatrix.OddsRatio

然后将整个代码块发送到 FSI:

val positiveValue : int = 1
val negativeValue : int = 0
val confusionMatrix : ConfusionMatrix = TP:3 FP:0, FN:0 TN:3
val it : float = infinity

由于我们的 k-NN 是 100%准确的,我们得到了一个无限大的优势比(甚至更多)。在现实世界的模型中,优势比显然会低得多。

交叉验证 – 无关变量

还有一个技术我想介绍,用于交叉验证——添加无关变量并观察对模型的影响。如果你的模型真正有用,它应该能够处理额外的“噪声”变量,而不会对模型的结果产生重大影响。正如我们在第二章中看到的,AdventureWorks 回归,任何额外的变量都会对大多数模型产生积极影响,所以这是一个程度的衡量。如果添加一个无关变量使模型看起来更加准确,那么模型本身就有问题。然而,如果额外变量只有轻微的影响,那么我们的模型可以被认为是可靠的。

让我们看看实际效果。回到 AccordKNN.fsx 并在底部添加以下代码:

let inputs' = [|[|5.0;1.0;1.0|];[|4.5;1.5;11.0|];
               [|5.1;0.75;5.0|];[|1.0;3.5;8.0|];
               [|0.5;4.0;1.0|];[|1.25;4.0;11.0|]|]

let knn' = KNearestNeighbors(k, classes, inputs', outputs)

let testInputs' = [|[|5.0;1.0;5.0|];[|4.0;1.0;8.0|];
                   [|6.2;0.5;12.0|];[|0.0;2.0;2.0|];
                   [|0.5;4.0;6.0|];[|3.0;6.0;5.0|]|]

let knnPrediction' =
    testInputs'
    |> Array.map(fun ti -> knn'.Compute(ti))

我添加了一个代表每个学生的星座符号的第三个变量(1.0 = 水瓶座,2.0 = 双鱼座,等等)。当我传入相同的测试输入(也带有随机的星座符号)时,预测结果与原始的 k-NN 相同。

val knnPrediction' : int [] = [|1; 1; 1; 0; 0; 0|]

我们可以得出结论,尽管额外变量在建模过程中某个时刻产生了影响,但它并不足以改变我们的原始模型。然后我们可以用更高的信心使用这个模型。

摘要

本章与其他你可能读过的机器学习书籍略有不同,因为它没有介绍任何新的模型,而是专注于脏活累活——收集、清理和选择你的数据。虽然不那么光鲜,但绝对有必要掌握这些概念,因为它们往往会使项目成功或失败。事实上,许多项目花费超过 90% 的时间在获取数据、清理数据、选择正确的特征和建立适当的交叉验证方法上。在本章中,我们探讨了数据清理以及如何处理缺失和不完整的数据。接下来,我们探讨了多重共线性化和归一化。最后,我们总结了常见的交叉验证技术。

我们将在接下来的章节中应用所有这些技术。接下来,让我们回到 AdventureWorks 公司,看看我们是否可以用基于人类大脑工作原理的机器学习模型帮助他们改进生产流程。

第九章. AdventureWorks 生产 - 神经网络

有一天,你坐在办公室里,沉浸在你在 AdventureWorks 新获得的摇滚明星地位的光环中,这时你的老板敲响了门。她说:“既然你在我们现有网站的面向消费者的部分做得这么好,我们想知道你是否愿意参与一个内部绿色田野项目。”你响亮地打断她,“是的!”她微笑着继续说,“好的。问题是出在我们的生产区。管理层非常感兴趣我们如何减少我们的废品数量。每个月我们都会收到一份来自 Excel 的报告,看起来像这样:”

AdventureWorks 生产 - 神经网络

“问题是,我们不知道如何处理这些数据。生产是一个复杂的流程,有许多变量可能会影响物品是否被废弃。我们正在寻找两件事:

  • 一种识别对物品是否被废弃影响最大的项目的方法

  • 一个允许我们的规划者改变关键变量以进行“如果……会怎样”的模拟并改变生产流程的工具

你告诉你的老板可以。由于这是一个绿色田野应用,而且你一直在听关于 ASP.NET Core 1.0 的炒作,这似乎是一个尝试它的绝佳地方。此外,你听说过数据科学中的一个热门模型,神经网络,并想知道现实是否与炒作相符。

神经网络

神经网络是数据科学的一个相对较晚的参与者,试图让计算机模仿大脑的工作方式。我们耳朵之间的灰质在建立联系和推理方面非常好,直到一定程度。神经网络的前景是,如果我们能构建出模仿我们大脑工作方式的模型,我们就可以结合计算机的速度和湿件的模式匹配能力,创建一个可以提供洞察力,而计算机或人类单独可能错过的学习模型。

背景

神经网络从实际大脑中汲取词汇;神经网络是一系列神经元的集合。如果你还记得生物学 101(或者《战争机器 2》),大脑有数十亿个神经元,它们看起来或多或少是这样的:

背景

一个神经元的轴突末端连接到另一个神经元的树突。由于单个神经元可以有多个树突和轴突末端,神经元可以连接,并被连接到许多其他神经元。两个神经元之间实际的连接区域被称为突触。我们的大脑使用电信号在神经元之间传递信息。

背景

由于我们正在为神经网络模拟人脑,因此我们可以合理地认为我们将使用相同的词汇。在神经网络中,我们有一系列输入和一个输出。在输入和输出之间,有一个由神经元组成的隐藏层。任何从输入到隐藏层、隐藏层内部的神经元之间,以及从隐藏层到输出的连接都被称为突触。

背景

注意,每个突触只连接到其右侧的神经元(或输出)。在神经网络中,数据总是单向流动,突触永远不会连接到自身或网络中的任何其他前一个神经元。还有一点需要注意,当隐藏层有多个神经元时,它被称为深度信念网络(或深度学习)。尽管如此,我们在这本书中不会涉及深度信念网络,尽管这确实是你下次和朋友打保龄球时可能会讨论的话题。

在神经网络中,突触只有一个任务。它们从一个神经元形成连接到下一个神经元,并应用一个权重到这个连接上。例如,神经元 1 以两个权重激活突触,因此神经元 2 接收到的输入为两个:

背景

神经元有一个更复杂的工作。它们接收来自所有输入突触的值,从称为偏差的东西那里获取输入(我稍后会解释),对输入应用激活函数,然后输出一个信号或什么都不做。激活函数可以单独处理每个输入,也可以将它们组合起来,或者两者兼而有之。存在许多种类的激活函数,从简单到令人难以置信。在这个例子中,输入被相加:

背景

一些神经网络足够智能,可以根据需要添加和删除神经元。对于这本书,我们不会做任何类似的事情——我们将固定每层的神经元数量。回到我在上一段中提到的词汇,对于神经元内的任何给定激活函数,有两种输入:通过突触传递的权重和偏差。权重是一个分配给突触的数字,它取决于突触的性质,并且在神经网络的整个生命周期中不会改变。偏差是一个分配给所有神经元(和输出)的全局值,与权重不同,它经常改变。神经网络中的机器学习组件是计算机所做的许多迭代,以创建最佳权重和偏差组合,从而给出最佳的预测分数。

神经网络演示

在建立这个心理模型之后,让我们看看神经网络的实际应用。让我们看看一系列在考试前学习和喝酒的学生,并比较他们是否通过了那次考试:

神经网络演示

由于我们有两个输入变量(x,即学习时间喝啤酒量),我们的神经网络将有两个输入。我们有一个因变量(是否通过),因此我们的神经网络将有一个输出:

神经网络演示

有一点需要注意,输入的数量取决于值的范围。所以如果我们有一个分类输入(例如男性/女性),我们将有一个与该类别值范围相对应的输入数量:

神经网络演示

  1. 进入 Visual Studio 并创建一个新的 C# ASP.NET 网络应用程序:神经网络演示

  2. 在下一个对话框中,选择 ASP.NET 5 模板并将身份验证类型更改为 无身份验证。请注意,在本书编写之后,模板可能会从 ASP.NET 5 更改为 ASP.NET Core 1。你可以将这两个术语视为同义词。神经网络演示

  3. 如果代码生成一切正常,你会得到以下项目:神经网络演示

  4. 接下来,让我们添加一个 F# Windows 库项目:神经网络演示

  5. 一旦创建了 F# 项目,打开 NuGet 包管理器控制台并安装 numl。确保你在为 NuGet 安装目标 F# 项目:

    PM> install-package numl
    
    

    神经网络演示

  6. Scipt1.fsx 重命名为 StudentNeuralNetwork.fsx

  7. 前往脚本并将其中的所有内容替换为以下代码:

    #r "../packages/numl.0.8.26.0/lib/net40/numl.dll"
    
    open numl
    open numl.Model
    open numl.Supervised.NeuralNetwork
    
    type Student = {[<Feature>]Study: float; 
                    [<Feature>]Beer: float; 
                    [<Label>] mutable Passed: bool}
    
    let data = 
        [{Study=2.0;Beer=3.0;Passed=false};
         {Study=3.0;Beer=4.0;Passed=false};
         {Study=1.0;Beer=6.0;Passed=false};
         {Study=4.0;Beer=5.0;Passed=false};
         {Study=6.0;Beer=2.0;Passed=true};
         {Study=8.0;Beer=3.0;Passed=true};
         {Study=12.0;Beer=1.0;Passed=true};
         {Study=3.0;Beer=2.0;Passed=true};]
    
    let data' = data |> Seq.map box
    let descriptor = Descriptor.Create<Student>()
    let generator = NeuralNetworkGenerator()
    generator.Descriptor <- descriptor
    let model = Learner.Learn(data', 0.80, 100, generator)
    let accuracy = model.Accuracy
    
  8. 当你将这个项目发送到 FSI 时,你会得到以下结果:

    val generator : NeuralNetworkGenerator
    val model : LearningModel =
     Learning Model:
     Generator numl.Supervised.NeuralNetwork.NeuralNetworkGenerator
     Model:
    numl.Supervised.NeuralNetwork.NeuralNetworkModel
     Accuracy: 100.00 %
    
    val accuracy : float = 1.0
    
    

如果你已经完成了第三章(更多 AdventureWorks 回归


当你将这个项目发送到 FSI 时,你会得到以下结果:

```py
val testData : Student = {Study = 7.0;
 Beer = 1.0;
 Passed = false;}

> 

val predict : obj = {Study = 7.0;
 Beer = 1.0;
 Passed = true;}

在这种情况下,我们的学生如果学习 7 小时并且喝了一杯啤酒就能通过考试。

神经网络 – 尝试 #1

理论问题解决之后,让我们看看神经网络是否可以帮助我们处理 AdventureWorks。正如第三章中所述,更多 AdventureWorks 回归,让我们看看是否可以使用业务领域专家来帮助我们制定一些可行的假设。当我们访问制造经理时,他说:“我认为有几个领域你应该关注。看看生产位置是否有影响。我们共有七个主要位置”:

神经网络 – 尝试#1

“我很想知道我们的油漆位置是否产生了比预期更多的缺陷,因为我们该区域的周转率很高。”

“此外,查看供应商和有缺陷的产品之间是否存在关系。在某些情况下,我们为单个供应商购买零件;在其他情况下,我们有两个或三个供应商为我们提供零件。在我们组装自行车时,我们没有跟踪哪个零件来自哪个供应商,但也许你可以发现某些供应商与有缺陷的采购订单相关联。”

这些看起来是两个很好的起点,因此让我们前往解决方案资源管理器,在 F#项目中创建一个名为AWNeuralNetwork.fsx的新脚本文件:

神经网络 – 尝试#1

接下来,打开 NuGet 包管理器并输入以下内容:

PM> Install-Package SQLProvider -prerelease

接下来,打开脚本文件并输入以下内容(注意,版本号可能因你而异):

#r "../packages/SQLProvider.0.0.11-alpha/lib/FSharp.Data.SQLProvider.dll"
#r "../packages/numl.0.8.26.0/lib/net40/numl.dll"
#r "../packages/FSharp.Collections.ParallelSeq.1.0.2/lib/net40/FSharp.Collections.ParallelSeq.dll"

open numl
open System
open numl.Model
open System.Linq
open FSharp.Data.Sql
open numl.Supervised.NeuralNetwork
open FSharp.Collections.ParallelSeq

[<Literal>]
let connectionString = "data source=nc54a9m5kk.database.windows.net;initial catalog=AdventureWorks2014;user id= PacktReader;password= P@cktM@chine1e@rning;"

type AdventureWorks = SqlDataProvider<ConnectionString=connectionString>
let context = AdventureWorks.GetDataContext()

将此发送到 REPL 会得到以下结果:

val connectionString : string =
 "data source=nc54a9m5kk.database.windows.net;initial catalog=A"+[70 chars]
type AdventureWorks = SqlDataProvider<...>
val context : SqlDataProvider<...>.dataContext

接下来,让我们处理位置假设。转到脚本并输入以下内容:

type WorkOrderLocation = {[<Feature>] Location10: bool; 
                          [<Feature>] Location20: bool; 
                          [<Feature>] Location30: bool; 
                          [<Feature>] Location40: bool; 
                          [<Feature>] Location45: bool; 
                          [<Feature>] Location50: bool; 
                          [<Feature>] Location60: bool; 
                          [<Label>] mutable Scrapped: bool}

let getWorkOrderLocation (workOrderId, scrappedQty:int16) =
    let workOrderRoutings = context.``[Production].[WorkOrderRouting]``.Where(fun wor -> wor.WorkOrderID = workOrderId) |> Seq.toArray
    match workOrderRoutings.Length with
    | 0 -> None
    | _ ->
        let location10 = workOrderRoutings |> Array.exists(fun wor -> wor.LocationID = int16 10)
        let location20 = workOrderRoutings |> Array.exists(fun wor -> wor.LocationID = int16 20)
        let location30 = workOrderRoutings |> Array.exists(fun wor -> wor.LocationID = int16 30)
        let location40 = workOrderRoutings |> Array.exists(fun wor -> wor.LocationID = int16 40)
        let location45 = workOrderRoutings |> Array.exists(fun wor -> wor.LocationID = int16 45)
        let location50 = workOrderRoutings |> Array.exists(fun wor -> wor.LocationID = int16 50)
        let location60 = workOrderRoutings |> Array.exists(fun wor -> wor.LocationID = int16 60)
        let scrapped = scrappedQty > int16 0
        Some {Location10=location10;Location20=location20;Location30=location30;Location40=location40;
        Location45=location45;Location50=location50;Location60=location60;Scrapped=scrapped}

将此发送到 REPL 会得到以下结果:

type WorkOrderLocation =
 {Location10: bool;
 Location20: bool;
 Location30: bool;
 Location40: bool;
 Location45: bool;
 Location50: bool;
 Location60: bool;
 mutable Scrapped: bool;}
val getWorkOrderLocation :
 workOrderId:int * scrappedQty:int16 -> WorkOrderLocation option

你可以看到我们有一个记录类型,每个位置作为一个字段,以及一个表示是否有报废的指示器。这个数据结构的自动化程度是工作订单。每个订单可能访问一个或所有这些位置,并且可能有某些报废数量。getWorkOrderFunction函数接受WorkOrderLocation表,其中每个位置是表中的一行,并将其扁平化为WorkOrderLocation记录类型。

接下来,回到脚本并输入以下内容:

let locationData =
    context.``[Production].[WorkOrder]`` 
    |> PSeq.map(fun wo -> getWorkOrderLocation(wo.WorkOrderID,wo.ScrappedQty))
    |> Seq.filter(fun wol -> wol.IsSome)
    |> Seq.map(fun wol -> wol.Value)
    |> Seq.toArray

将此发送到 REPL 会得到以下结果:

val locationData : WorkOrderLocation [] =
 |{Location10 = true;
 Location20 = true;
 Location30 = true;
 Location40 = false;
 Location45 = true;
 Location50 = true;
 Location60 = true;
 Scrapped = false;}; {Location10 = false;
 Location20 = false;
 Location30 = false;
 Location40 = false;

这段代码与你在[第五章中看到的内容非常相似,时间到 – 获取数据。我们访问数据库并拉取所有工作订单,然后将位置映射到我们的WorkOrderLocation记录。请注意,我们使用PSeq,这样我们就可以通过同时调用数据库来获取每个工作订单的位置来提高性能。

数据本地化后,让我们尝试使用神经网络。进入脚本文件并输入以下内容:

let locationData' = locationData |> Seq.map box
let descriptor = Descriptor.Create<WorkOrderLocation>()
let generator = NeuralNetworkGenerator()
generator.Descriptor <- descriptor
let model = Learner.Learn(locationData', 0.80, 5, generator)
let accuracy = model.Accuracy

在长时间等待后,将此发送到 REPL 会得到以下结果:

val generator : NeuralNetworkGenerator
val model : LearningModel =
 Learning Model:
 Generator numl.Supervised.NeuralNetwork.NeuralNetworkGenerator
 Model:
numl.Supervised.NeuralNetwork.NeuralNetworkModel
 Accuracy: 0.61 %

val accuracy : float = 0.006099706745

所以,呃,看起来位置并不能预测缺陷可能发生的地方。正如我们在 第三章 "更多 AdventureWorks 回归" 中看到的,有时你不需要一个工作模型来使实验有价值。在这种情况下,我们可以回到导演那里,告诉他报废发生在他的整个生产地点,而不仅仅是喷漆(这样就把责任推给了新来的那个人)。

神经网络 – 尝试 #2

让我们看看是否可以使用导演的第二个假设来找到一些东西,即某些供应商可能比其他供应商的缺陷率更高。回到脚本中,输入以下内容:

type  VendorProduct = {WorkOrderID: int;
                       [<Feature>]BusinessEntityID: int; 
                       [<Feature>]ProductID: int; 
                       [<Label>] mutable Scrapped: bool}

let workOrders = context.``[Production].[WorkOrder]`` |> Seq.toArray
let maxWorkOrder = workOrders.Length
let workOrderIds = Array.zeroCreate<int>(1000)
let workOrderIds' = workOrderIds |> Array.mapi(fun idx i -> workOrders.[System.Random(idx).Next(maxWorkOrder)])
                                 |> Array.map(fun wo -> wo.WorkOrderID)

当你将其发送到 FSI 后,你会得到以下内容:

type VendorProduct =
 {WorkOrderID: int;
 BusinessEntityID: int;
 ProductID: int;
 mutable Scrapped: bool;}

 …
 FSharp.Data.Sql.Common.SqlEntity; FSharp.Data.Sql.Common.SqlEntity;
 FSharp.Data.Sql.Common.SqlEntity; FSharp.Data.Sql.Common.SqlEntity; ...|]
val maxWorkOrder : int = 72591
val workOrderIds : int [] =
 [|0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0;
 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0;
 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0;
 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0;
 ...|]
val workOrderIds' : int [] =

VendorProduct 记录类型你应该很熟悉。接下来的代码块创建了一个包含 1,000 个随机工作订单 ID 的数组。正如我们从第一个实验中学到的,神经网络需要很长时间才能完成。我们将在下一章中查看一些大数据解决方案,但在此之前,我们将做数据科学家一直做的事情——从更大的数据集中抽取样本。请注意,我们正在使用 Array.Mapi 高阶函数,这样我们就可以使用索引值在工作订单数组中定位正确的值。不幸的是,我们无法将索引传递给类型提供者并在服务器上评估,因此整个工作订单表被带到本地,这样我们就可以使用索引。

接下来,将以下内容输入到脚本中:

let (|=|) id a = Array.contains id a

let vendorData = 
    query{for p in context.``[Production].[Product]`` do
          for wo in p.FK_WorkOrder_Product_ProductID do
          for bom in p.FK_BillOfMaterials_Product_ProductAssemblyID do
          join pv in context.``[Purchasing].[ProductVendor]`` on (bom.ComponentID = pv.ProductID)
          join v in context.``[Purchasing].[Vendor]`` on (pv.BusinessEntityID = v.BusinessEntityID)
          select  ({WorkOrderID = wo.WorkOrderID;BusinessEntityID = v.BusinessEntityID; ProductID = p.ProductID; Scrapped = wo.ScrappedQty > int16 0})}
          |> Seq.filter(fun vp -> vp.WorkOrderID |=| workOrderIds')
          |> Seq.toArray

当你将其发送到 FSI 后,稍作等待,你会得到以下内容:

val ( |=| ) : id:'a -> a:'a [] -> bool when 'a : equality
val vendorData : VendorProduct [] =
 |{WorkOrderID = 25;
 BusinessEntityID = 1576;
 ProductID = 764;
 Scrapped = false;}; {WorkOrderID = 25;
 BusinessEntityID = 1586;
 ProductID = 764;
 Scrapped = false;}; {WorkOrderID = 25;

第一行是我们在 [第五章 "时间到 – 获取数据" 中遇到的 in (|=|) 操作符。接下来的代码块使用从 1,000 个随机选择的工作订单中的数据填充 vendorData 数组。请注意,由于每个工作订单将使用多个部件,而每个部件可能由各种供应商(在这种情况下,称为商业实体)提供,因此存在一些重复。

数据本地化后,进入脚本并输入以下内容:

let vendorData' = vendorData |> Seq.map box
let descriptor' = Descriptor.Create<VendorProduct>()
let generator' = NeuralNetworkGenerator()
generator'.Descriptor <- descriptor'
let model' = Learner.Learn(vendorData', 0.80, 5, generator')
let accuracy' = model'.Accuracy

当你将其发送到 FSI 后,你会得到以下内容:

val generator' : NeuralNetworkGenerator
val model' : LearningModel =
 Learning Model:
 Generator numl.Supervised.NeuralNetwork.NeuralNetworkGenerator
 Model:
numl.Supervised.NeuralNetwork.NeuralNetworkModel
 Accuracy: 99.32 %

val accuracy' : float = 0.9931740614

所以,这很有趣。我们有一个非常高的准确率。人们可能会想:这是否是因为在单一供应商的产品情况下,所有报废的量都将与他们相关,因为他们是唯一的。然而,由于单个供应商可能提供多个输入产品,而这些产品可能有不同的报废率,你可以使用该模型来预测特定供应商和特定产品是否会有报废率。此外,请注意,由于为每个供应商和产品添加一个输入(这将使数据帧非常稀疏),这里有一个供应商输入和一个产品输入。虽然这些可以被认为是分类值,但我们可以为了这个练习牺牲一些精度。

你需要记住关于神经网络的关键点是,神经网络无法告诉你它是如何得到答案的(非常像人脑,不是吗?)。所以神经网络不会报告哪些供应商和产品的组合会导致缺陷。要做到这一点,你需要使用不同的模型。

构建应用程序

由于这个神经网络提供了我们所需的大部分信息,让我们继续构建我们的 ASP.NET 5.0 应用程序,并使用该模型。在撰写本文时,ASP.NET 5.0 仅支持 C#,因此我们必须将 F#转换为 C#并将代码移植到应用程序中。一旦其他语言被 ASP.NET 支持,我们将更新网站上的示例代码。

如果你不太熟悉 C#,它是.NET 堆栈中最流行的语言,并且与 Java 非常相似。C#是一种通用语言,最初结合了命令式和面向对象的语言特性。最近,函数式结构被添加到语言规范中。然而,正如老木匠的格言所说,“如果是螺丝,就用螺丝刀。如果是钉子,就用锤子。”既然如此,你最好用 F#进行.NET 函数式编程。在下一节中,我将尽力解释在将代码移植过来时 C#实现中的任何差异。

设置模型

你已经有了创建好的 MVC 网站模板。打开 NuGet 包管理器控制台,将其安装到其中:

PM > install-package numl

设置模型

接下来,在解决方案资源管理器中创建一个名为Models的文件夹:

设置模型

在那个文件夹中,添加一个名为VendorProduct的新类文件:

设置模型

在那个文件中,将所有代码替换为以下内容:

using numl.Model;

namespace AdventureWorks.ProcessAnalysisTool.Models
{
    public class VendorProduct
    {
        public int WorkOrderID { get; set; }
        [Feature]
        public int BusinessEntityID { get; set; }
        [Feature]
        public int ProductID { get; set; }
        [Label]
        public bool Scrapped { get; set; }
    }
}

如你所猜,这相当于我们在 F#中创建的记录类型。唯一的真正区别是属性默认可变(所以要小心)。转到解决方案资源管理器并找到Project.json文件。打开它,并在frameworks部分删除此条目:

:    "dnxcore50": { }

此部分现在应如下所示:

设置模型

运行网站以确保它正常工作:

设置模型

我们正在做的是移除网站对.NET Core 的依赖。虽然 numl 支持.NET Core,但我们现在不需要它。

如果网站正在运行,让我们添加我们剩余的辅助类。回到解决方案资源管理器,添加一个名为Product.cs的新类文件。进入该类,将现有代码替换为以下内容:

using System;

namespace AdventureWorks.ProcessAnalysisTool.Models
{
    public class Product
    {
        public int ProductID { get; set; }
        public string Description { get; set; }
    }
}

这是一个记录等效类,当用户选择要建模的Product时将使用它。

返回到解决方案资源管理器并添加一个名为Vendor.cs的新类文件。进入该类,将现有代码替换为以下内容:

using System;

namespace AdventureWorks.ProcessAnalysisTool.Models
{
    public class Vendor
    {
        public int VendorID { get; set; }
        public String Description { get; set; }

    }
}

就像Product类一样,这将用于填充用户的下拉列表。

返回到解决方案资源管理器并添加一个名为 Repository.cs 的新类文件。进入该类并将现有代码替换为以下内容:

using System;
using System.Collections.Generic;
using System.Data.SqlClient;
using System.Linq;

namespace AdventureWorks.ProcessAnalysisTool.Models
{
    public class Repository
    {
        public String ConnectionString { get; private set; }
        public Repository(String connectionString)
        {
            this.ConnectionString = connectionString;
        }

        public ICollection<Vendor> GetAllVendors()
        {
            var vendors = new List<Vendor>();
            using (var connection = new SqlConnection(this.ConnectionString))
            {
                var commandText =
                    "Select distinct V.BusinessEntityID, V.Name from [Purchasing].[Vendor] as V " +
                    "Inner join[Purchasing].[ProductVendor] as PV " +
                    "on V.BusinessEntityID = PV.BusinessEntityID " +
                    "order by 2 asc";

                using (var command = new SqlCommand(commandText, connection))
                {
                    connection.Open();
                    var reader = command.ExecuteReader();
                    while (reader.Read())
                    {
                        vendors.Add(new Vendor() { VendorID = (int)reader[0], Description = (string)reader[1] });
                    }
                }
            }
            return vendors;
        }

        public ICollection<Product> GetAllProducts()
        {
            var products = new List<Product>();
            using (var connection = new SqlConnection(this.ConnectionString))
            {
                var commandText =
                    "Select distinct P.ProductID, P.Name from [Production].[Product] as P " +
                    "Inner join[Purchasing].[ProductVendor] as PV " +
                    "on P.ProductID = PV.ProductID " +
                    "order by 2 asc";

                using (var command = new SqlCommand(commandText, connection))
                {
                    connection.Open();
                    var reader = command.ExecuteReader();
                    while (reader.Read())
                    {
                        products.Add(new Product() { ProductID = (int)reader[0], Description = (string)reader[1] });
                    }
                }
            }
            return products;
        }

        public ICollection<VendorProduct> GetAllVendorProducts()
        {
            var vendorProducts = new List<VendorProduct>();
            using (var connection = new SqlConnection(this.ConnectionString))
            {
                var commandText =
                    "Select WO.WorkOrderID, PV.BusinessEntityID, PV.ProductID, WO.ScrappedQty " +
                    "from[Production].[Product] as P " +
                    "inner join[Production].[WorkOrder] as WO " +
                    "on P.ProductID = WO.ProductID " +
                    "inner join[Production].[BillOfMaterials] as BOM " +
                    "on P.ProductID = BOM.ProductAssemblyID " +
                    "inner join[Purchasing].[ProductVendor] as PV " +
                    "on BOM.ComponentID = PV.ProductID ";

                using (var command = new SqlCommand(commandText, connection))
                {
                    connection.Open();
                    var reader = command.ExecuteReader();
                    while (reader.Read())
                    {
                        vendorProducts.Add(new VendorProduct()
                        {
                            WorkOrderID = (int)reader[0],
                            BusinessEntityID = (int)reader[1],
                            ProductID = (int)reader[2],
                            Scrapped = (short)reader[3] > 0
                        });
                    }
                }
            }

            return vendorProducts;
        }

        public ICollection<VendorProduct> GetRandomVendorProducts(Int32 number)
        {
            var returnValue = new List<VendorProduct>();
            var vendorProducts = this.GetAllVendorProducts();
            for (int i = 0; i < number; i++)
            {
                var random = new System.Random(i);
                var index = random.Next(vendorProducts.Count - 1);
                returnValue.Add(vendorProducts.ElementAt(index));
            }
            return returnValue;
        }
    }
}

如您可能猜到的,这是调用数据库的类。由于 C# 没有类型提供者,我们需要手动编写 ADO.NET 代码。我们需要添加对 System.Data 的引用以使此代码工作。进入解决方案资源管理器中的引用并添加它:

设置模型

您可以再次运行网站以确保我们处于正确的轨道。在解决方案资源管理器中添加一个名为 NeuralNetwork.cs 的类文件。将其所有代码替换为以下内容:

using numl;
using numl.Model;
using numl.Supervised.NeuralNetwork;
using System;
using System.Collections.Generic;

namespace AdventureWorks.ProcessAnalysisTool.Models
{
    public class NeuralNetwork
    {
        public ICollection<VendorProduct> VendorProducts { get; private set; }
        public LearningModel Model { get; private set; }

        public NeuralNetwork(ICollection<VendorProduct> vendorProducts)
        {
            if(vendorProducts ==  null)
            {
                throw new ArgumentNullException("vendorProducts");
            }
            this.VendorProducts = vendorProducts;
            this.Train();
        }

        internal void Train()
        {
            var vendorData = VendorProducts;
            var descriptor = Descriptor.Create<VendorProduct>();
            var generator = new NeuralNetworkGenerator();
            generator.Descriptor = descriptor;
            var model = Learner.Learn(vendorData, 0.80, 5, generator);
            if (model.Accuracy > .75)
            {
                this.Model = model;
            }
        }

        public bool GetScrappedInd(int vendorId, int productId)
        {
            if(this.Model == null)
            {
                return true;
            }
            else
            {
                var vendorProduct = new VendorProduct()
                {
                    BusinessEntityID = vendorId, ProductID = productId,
                    Scrapped = false
                };
                return (bool)this.Model.Model.Predict((object)vendorProduct);
            }
        }
    }
}

这个类为我们执行了神经网络计算的重活。注意,这个类是数据无关的,因此它可以轻松地移植到 .NET Core。我们需要的只是一个 VendorProducts 集合,将其传递给神经网络的构造函数进行计算。

创建了所有这些类后,您的解决方案资源管理器应该看起来像这样:

设置模型

您应该能够编译并运行网站。现在让我们为神经网络实现一个用户界面。

构建用户体验

以下步骤将指导您构建用户体验:

进入解决方案资源管理器并选择AdventureWorks.ProcessAnalysisTool。导航到添加 | 新建项

构建用户体验

在下一个对话框中,选择并将其命名为 Global.cs

构建用户体验

进入 Global 类并将所有内容替换为以下内容:

using AdventureWorks.ProcessAnalysisTool.Models;

namespace AdventureWorks.ProcessAnalysisTool
{
    public static class Global
    {
        static NeuralNetwork _neuralNetwork = null;

        public static void InitNeuralNetwork()
        {
            var connectionString = "data source=nc54a9m5kk.database.windows.net;initial catalog=AdventureWorks2014;user id= PacktReader;password= P@cktM@chine1e@rning;";
            var repository = new Repository(connectionString);
            var vendorProducts = repository.GetRandomVendorProducts(1000);
            _neuralNetwork = new NeuralNetwork(vendorProducts);
        }

        public static NeuralNetwork NeuralNetwork
        { get
            {
                return _neuralNetwork;
            }
        }
    }
}

这个类为我们创建一个新的神经网络。我们可以通过名为 Neural Network 的只读属性访问神经网络的功能。因为它被标记为静态,所以只要应用程序在运行,这个类就会保留在内存中。

接下来,在主站点中找到 Startup.cs 文件。

构建用户体验

打开文件并将构造函数(称为 Startup)替换为以下代码:

        public Startup(IHostingEnvironment env)
        {
            // Set up configuration sources.
            var builder = new ConfigurationBuilder()
                .AddJsonFile("appsettings.json")
                .AddEnvironmentVariables();
                Configuration = builder.Build();

            Global.InitNeuralNetwork();
        }

当网站启动时,它将创建一个所有请求都可以使用的全局神经网络。

接下来,在 Controllers 目录中找到 HomeController

构建用户体验

打开该文件并添加此方法以填充一些供应商和产品的下拉列表:

        [HttpGet]
        public IActionResult PredictScrap()
        {
            var connectionString = "data source=nc54a9m5kk.database.windows.net;initial catalog=AdventureWorks2014;user id= PacktReader;password= P@cktM@chine1e@rning;";
            var repository = new Repository(connectionString);
            var vendors = repository.GetAllVendors();
            var products = repository.GetAllProducts();

            ViewBag.Vendors = new SelectList(vendors, "VendorID", "Description");
            ViewBag.Products = new SelectList(products, "ProductID", "Description");

            return View();
        } 

接下来,添加此方法,在供应商和产品被发送回服务器时在全局神经网络上运行 Calculate

        [HttpPost]
        public IActionResult PredictScrap(Int32 vendorId, Int32 productId)
        {
            ViewBag.ScappedInd = Global.NeuralNetwork.GetScrappedInd(vendorId, productId);

            var connectionString = "data source=nc54a9m5kk.database.windows.net;initial catalog=AdventureWorks2014;user id= PacktReader;password= P@cktM@chine1e@rning;";
            var repository = new Repository(connectionString);
            var vendors = repository.GetAllVendors();
            var products = repository.GetAllProducts();

            ViewBag.Vendors = new SelectList(vendors, "VendorID", "Description", vendorId);
            ViewBag.Products = new SelectList(products, "ProductID", "Description", productId);

            return View();
        }

如果您折叠到定义,HomeController 将看起来像这样:

构建用户体验

接下来,进入解决方案资源管理器并导航到AdventureWorks.ProcessAnalysisTool | 视图 | 主页。右键单击文件夹并导航到添加 | 新建项

构建用户体验

在下一个对话框中,选择MVC 视图页面并将其命名为 PredictScrap.cshtml

构建用户体验

打开这个页面并将所有内容替换为以下内容:

<h2>Determine Scrap Rate</h2>

@using (Html.BeginForm())
{
    <div class="form-horizontal">
        <h4>Select Inputs</h4>
        <hr />

        <div class="form-group">
            <div class="col-md-10">
                @Html.DropDownList("VendorID", (SelectList)ViewBag.Vendors, htmlAttributes: new { @class = "form-control" })
                @Html.DropDownList("ProductID", (SelectList)ViewBag.Products, htmlAttributes: new { @class = "form-control" })
           </div>
        </div>
        <div class="form-group">
            <div class="col-md-offset-2 col-md-10">
                <input type="submit" value="Predict!" class="btn btn-default" />
            </div>
        </div>
        <h4>Will Have Scrap?</h4>
        <div class="form-group">
            <div class="col-md-offset-2 col-md-10">
                @ViewBag.ScappedInd
            </div>
        </div>
   </div>
}

这是一个输入表单,它将允许用户选择供应商和产品,并查看神经网络将预测什么——这个组合是否会有废料。当你第一次运行网站并导航到 localhost:port/home/PredictScrap 时,你会看到为你准备好的下拉列表:

构建用户体验

选择一个供应商和一个产品,然后点击 预测!:

构建用户体验

现在我们有一个完全运行的 ASP .NET Core 1.0 网站,该网站使用神经网络来预测 AdventureWorks 废料百分比。有了这个框架,我们可以将网站交给用户体验专家,使其外观和感觉更好——核心功能已经就位。

摘要

本章开辟了一些新领域。我们深入研究了 ASP.NET 5.0 用于我们的网站设计。我们使用 numl 创建了两个神经网络:一个显示公司面积与废料率之间没有关系,另一个可以根据供应商和产品预测是否会有废料。然后我们在网站上实现了第二个模型。

第十章:大数据和物联网

到目前为止,这本书遵循了一种提取数据、清洗和塑形数据,然后构建机器学习模型的模式。所有示例的共同点是,当我们提取数据时,我们已经将它从服务器(或其他外部来源)本地带到我们的机器上。这意味着我们的分析仅限于适合我们本地机器内存中的数据。虽然这对于小型和中型数据集来说很好,但还有很多数据集和问题不适合 RAM。在过去的几年里,大数据的兴起,我们可以对太大、无结构或快速移动的数据集提出问题,这些数据集无法使用我们传统的机器学习技术进行分析。与大数据很好地匹配的一个领域是小型、低成本设备的大量涌现,这些设备可以将大量数据发送到服务器进行分析。这些物联网IoT)设备有可能以典型计算机和智能手机无法实现的方式重塑我们周围的世界。在本章中,让我们在 AdventureWorks 中运行一个潜在的大数据和物联网场景。

AdventureWorks 和物联网

有一天,你坐在办公室里,你的老板走进来说:“自从你在帮助我们降低废品率方面做得如此出色,我们希望你能和我们的研发部门一起工作,做一个概念验证。上个月,管理团队参加了一个关于物联网的会议,我们认为有一个有趣的应用案例:物联网自行车IoB)。我们打算在一种可以读取自行车及其骑行模式某些诊断信息的自行车型号上安装传感器。我们认为我们的一部分客户会非常喜欢“智能自行车”。

你前往研发区域,他们已经将一辆自行车改装成这样:

  • 轮胎压力传感器

  • 速度表传感器

  • 速度传感器

  • 安装在座椅下的 Raspberry Pi 2

  • 连接到 PI 的无线以太网盾

  • 连接到 PI 的 GPS 盾AdventureWorks 和物联网

研发部门的负责人告诉你:“我们正在寻找成本效益高的无线传感器。在此之前,我们正在通过车架的管子将电线连接到 PI。我们最初考虑使用骑行者的手机作为 CPU,但最终选择了 PI,因为它体积更小,重量也比手机轻得多——骑行者非常关心重量问题。PI 从可充电电池中获取电力,当自行车在家充电时,所有车载数据都会在那个时间上传到我们的服务器。出于安全原因,我们只想在自行车在家时从 PI 向服务器传输数据,这样骑行者就不会因为使用蜂窝网络而受到数据计划限制。”

研发部门负责人继续说:“我们设想了一个仪表板,让人们可以跟踪他们的骑行路线、骑行习惯等等。您的角色在于机器学习部分。我们需要一种方法来分析我们将要收集的大量数据,以便在骑行时为用户提供增强的客户体验。”

数据考虑因素

您将来自自行车的数据(称为遥测数据)视为两个不同的问题。问题一是将数据从单个自行车传输到服务器,问题二是拥有一种格式,允许在大规模上进行机器学习。您决定通过使用 Microsoft Azure IoT 套件将自行车的数据流式传输到当前的 Northwind SQL Azure 数据库来解决这两个问题。您添加了一个名为telemetry的表,并将外键添加到PurchaseOrderHeader

您接下来将一些来自 AdventureWorks 早期采用者计划中的骑行者的数据填充到表格中。虽然表格开始时数据不多,但预计会迅速增长。表格的原子级别是每秒大约发生一次的单次读取。这意味着对于 30 分钟的骑行,我们捕获了 1,800 行数据。由于我们早期采用者计划中有大约 200 名骑行者,每次他们骑行时,我们将生成大约 360,000 行数据。一次骑行生成的数据量几乎与当前 AdventureWorks 数据库为公司维护的全部数据量相当。在一个月的时间里,这些骑行者每隔一天就出去骑行,我们将拥有 5,400,000 行数据。

我们正在捕获的数据元素之一是纬度和经度。幸运的是,我们所有的骑行者都住在北达科他州的 Enderlin,并且都在美国最直的公路上骑行,即 46 号公路(en.wikipedia.org/wiki/North_Dakota_Highway_46_(54st_SE)). 这意味着我们的经度不会改变。此外,我们正在以英尺每秒作为速度计来捕获速度,这样我们可以轻松地比较骑行者之间的表现。

数据就绪后,让我们看看如何进行大规模数据分析。

MapReduce

打开 Visual Studio 并创建一个新的 Visual F# Windows 库,命名为AdventureWorks.IOB

MapReduce

进入 NuGet 包管理器控制台并输入以下内容:

PM> install-package Accord.MachineLearning

然后,将script1.fsx重命名为MapReduce.fsx。现在,输入来自第五章的相同代码,时间到 – 获取数据,该代码创建了一个 k-NN:

#r"../packages/Accord.3.0.2/lib/net40/Accord.dll"
#r"../packages/Accord.Math.3.0.2/lib/net40/Accord.Math.dll"
#r"../packages/Accord.Statistics.3.0.2/lib/net40/Accord.Statistics.dll"
#r"../packages/Accord.MachineLearning.3.0.2/lib/net40/Accord.MachineLearning.dll"

open Accord
open System
open Accord.Math
open Accord.MachineLearning

let inputs = [|[|5.0;1.0|];[|4.5;1.5|];[|5.1;0.75|];[|1.0;3.5|];[|0.5;4.0|];[|1.25;4.0|]|]
let outputs = [|1;1;1;0;0;0|]

let classes = 2
let k = 3
let knn = new KNearestNeighbors(k, classes, inputs, outputs)

let input = [|5.0;0.5|]
let output = knn.Compute(input)

将此发送到 FSI,我们得到以下结果:

val inputs : float [] [] =
 [|[|5.0; 1.0|]; [|4.5; 1.5|]; [|5.1; 0.75|]; [|1.0; 3.5|]; [|0.5; 4.0|];
 [|1.25; 4.0|]|]
val outputs : int [] = [|1; 1; 1; 0; 0; 0|]
val classes : int = 2
val k : int = 3
val knn : Accord.MachineLearning.KNearestNeighbors
val input : float [] = [|5.0; 0.5|]
val output : int = 1

注意这一行:

let output = knn.Compute(input)

我们调用knn.Compute在创建 k-NN 模型后对单个输入进行计算。

对于单个计算来说,这已经足够好了,但如果我们要进行数千次计算呢?例如,让我们在 250,000 个随机样本上调用knn.Compute()。完成所有 250,000 次计算后,让我们将结果相加,然后将总数除以观察次数,看看数据集是否偏向特定类别。

首先,让我们创建一个函数来创建随机输入:

let createInput i =
    let random = Random(i)
    [|float(random.Next(0,6)) + Math.Round(random.NextDouble(),2);
      float(random.Next(0,6)) + Math.Round(random.NextDouble(),2);|]

将此发送到 FSI 将给我们以下结果:

val createInput : i:int -> float []

接下来,让我们创建一个包含 250,000 个项目的数组并用随机值填充它:

let observations = Array.zeroCreate<int> 250000
let inputs' = 
    observations 
    |>Array.mapi (fun idx _ -> createInput idx)

将此发送到 REPL 将给我们以下结果:

val observations : int [] =
 [|0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0;
 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0;
 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0;
 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0;
 ...|]
val inputs' : float [] [] =
 [|[|4.82; 4.56|]; [|1.11; 2.77|];

数据准备就绪后,让我们进行计算。我添加了一个计时器,以给我们一个运行 250,000 条记录的性能影响的印象:

let stopwatch = System.Diagnostics.Stopwatch()
stopwatch.Start()
let predictionTotal = 
    inputs' 
    |>Seq.map(fun i -> knn.Compute i)
    |>Seq.reduce(fun acc i -> acc + i)

let predictionBias = float predictionTotal/float 250000
stopwatch.Stop()
stopwatch.Elapsed.TotalSeconds

将此发送到 FSI 将给我们以下结果:

val stopwatch : Diagnostics.Stopwatch
val predictionTotal : int = 109826
val predictionBias : float = 0.439304
val it : float = 0.1787221

有趣的代码片段如下:

let predictionTotal = 
    inputs' 
    |>Seq.map(fun i -> knn.Compute i)
    |>Seq.reduce(fun acc i -> acc + i)

注意,我们正在进行映射和归约。映射对你来说已经是老生常谈了,但你可能对归约不太熟悉。归约是一个高阶函数,它接受两个参数:一个累加器和一个值。这两个参数是同一类型(在这种情况下,int)。归约所做的是遍历数组中的每个项目并应用一个函数。然后,它将计算结果添加到累加器中。在这种情况下,累加器acc被添加到array (i)的值中。

从视觉上看,它看起来如下:

MapReduce

你可能听说过在大数据背景下使用的 map/reduce 表达式。这是因为大数据分析的一些先驱,如 Google 和 Yahoo,基于 map/reduce 的概念创建了 Hadoop。Hadoop 是一个大数据平台,包括文件系统(HDFS)、查询语言(Hive 和 PIG)和机器学习(Mahdut)。通常,当人们谈论 Hadoop 和 map/reduce 时,他们是在谈论一个使用键/值对的特殊实现。此外,map/reduce 的map部分通常分布在一千台通用机器上。reduce可以根据传递给 reduce 的函数的性质进行分布。如果函数在数据集的某个部分上执行groupBy或其他计算,它就可以进行分布。在本章中,我们将分布 map,但不会分布 reduce。

为了说明为什么 map/reduce 在大数据中很受欢迎,让我们将映射分布到我的机器上的所有核心。这可以模拟 Hadoop 如何在成千上万的联网计算机之间分配处理。进入 Visual Studio 并打开 NuGet 包管理器,输入以下内容:

PM> Install-Package FSharp.Collections.ParallelSeq

接下来,进入MapReduce.fsx并在底部输入以下内容:

#r"../packages/FSharp.Collections.ParallelSeq.1.0.2/lib/net40/FSharp.Collections.ParallelSeq.dll"
open FSharp.Collections.ParallelSeq

let stopwatch' = new System.Diagnostics.Stopwatch()
stopwatch'.Start()
let predictionTotal' = 
    inputs' 
    |>PSeq.map(fun i -> knn.Compute i)
    |>Seq.reduce(fun acc i -> acc + i)
let predictionBias' = float predictionTotal'/float 250000
stopwatch'.Stop()
stopwatch'.Elapsed.TotalSeconds

将此发送到 FSI 将给我们以下结果:

val stopwatch' : Diagnostics.Stopwatch
val predictionTotal' : int = 109826
val predictionBias' : float = 0.439304
val it : float = 0.0700362

注意,代码与前面的代码相同,只是我们现在正在实现映射函数的PSeq,因此我们将其分布到所有核心上。你可以看到,通过实现映射函数的并行性,时间显著下降。

如果您认为我们针对大数据场景有解决方案,那么您是错误的。看看当我们尝试处理 540 万条记录时会发生什么:

System.OutOfMemoryException: Exception of type 'System.OutOfMemoryException' was thrown.
>    at Microsoft.FSharp.Collections.ArrayModule.ZeroCreateT

我们不能仅用我的机器来分析数据。为了进行映射/归约并将映射分布到多台机器上,我们可以实现 Hadoop 或其更快的兄弟产品 Spark,但那样我们就必须离开 Visual Studio 和.NET,并进入 JVM 的世界。此外,我们还需要学习 Java/Python/Scala,并且无法轻松地与现有的.NET 应用程序集成。作为替代方案,我们可以使用 Azure 的 HDInsight 实现,但那样我们就被锁定在特定的云供应商上了。相反,让我们使用MBrace来处理我们的分布式计算。

MBrace

MBrace 是一个开源项目,用于使用 F#或 C#进行可扩展的数据脚本。您可以在mbrace.io/找到网站。MBrace 支持在本地模拟分布式计算以及在 Azure 和即将推出的 AWS 上的实际实现。对于本章,我们将坚持使用本地模拟,这样您就不需要 Azure 或 AWS 订阅来运行示例。

返回 Visual Studio,打开 NuGet 包管理器,并输入以下内容:

PM> Install-Package MBrace.Thespian -pre

一旦所有包安装完成,进入MapReduce.fsx并在底部添加以下内容(注意,版本号可能因您而异):

#load"../packages/MBrace.Thespian.1.0.19/MBrace.Thespian.fsx"

open MBrace.Core.Builders
open MBrace.Thespian
open MBrace.Core
open MBrace.Library.Cloud

//Spin up your clusters
let cluster = ThespianCluster.InitOnCurrentMachine(4)

//Basic Example
let number = cloud { return 5 + 10 } |> cluster.Run

将此发送到 REPL 后,我们得到了以下结果:

namespace FSI_0007.MBrace

>

val cluster : ThespianCluster

>

val number : int = 15

但也要注意在您的机器上 Visual Studio 之外发生的事情。您可能得到了这个对话框:

MBrace

如果是这样,请点击允许访问

接下来,出现了四个对话框,代表您在这行初始化的四个机器:

let cluster = ThespianCluster.InitOnCurrentMachine(4)

MBrace

如果您在对话框之间切换,您会注意到其中一个看起来像这样:

MBrace

考虑执行以下行:

let number = cloud { return 5 + 10 } |> cluster.Run

MBrace 将作业发送到四个控制台中的一个。当使用 MBrace 时,花括号{}内的所有内容都会被执行。在这种情况下,它是 5 + 10,但很快它将包含更复杂的计算。

返回MapReduce.fsx并在底部添加以下脚本:

let mBraceTotal =
    inputs'
    |>Seq.map(fun i ->cloud { return knn.Compute i })
    |> Cloud.Parallel
    |> cluster.Run
    |>Seq.reduce(fun acc i -> acc + i)

let mBracePrediction = float mBraceTotal/float 250000

当您将此发送到 REPL 时,一段时间内不会有太多的事情发生。如果您查看四个控制台窗口,您会看到它们正在努力计算knn.map,针对那些 250,000 个值中的每一个:

MBrace

由于这是在本地机器上,并且向不同进程传递数据存在开销,所以它的速度比我们在本章前面看到的内存中的 map/reduce 要慢得多。然而,在现实世界中,当我们拥有的数据比任何一台机器都能处理的多,并且我们可以在 Azure 或 AWS 上启动多台机器时,MBrace 真的非常出色。你也会注意到,我们没有在那些其他四台机器上安装 Accord.NET。Vagabond 是 MBrace NuGet 包的一部分,它为我们处理安装缺失的程序集。这是一种永远不会打折的酷炫品牌。我们不必担心搭建和配置机器,我们可以让 MBrace 为我们处理所有这些。

我们还想使用一种最后的语法。回到MapReduce.fsx,在底部添加以下内容:

let mBraceTotal' =
    inputs' |>Balanced.map(fun i -> knn.Compute i) |> cluster.Run
            |>Seq.reduce(fun acc i -> acc + i)

let mBracePrediction' = float mBraceTotal/float 250000

将它发送到 REPL 的效果与第一个 MBrace 示例相同。考虑以下行:

|>Balanced.map(fun i -> knn.Compute i) |> cluster.Run

这行替换了第一个 MBrace 示例中的这些行:

    |>Seq.map(fun i ->cloud { return knn.Compute i })
    |> Cloud.Parallel
    |> cluster.Run

这是我们将用于 AdventureWorks 实现的语法。如果你想深入了解 MBrace,请下载 GitHub 上找到的入门包github.com/mbraceproject/MBrace.StarterKit/blo。随着我们对 MapReduce 和 MBrace 的介绍完成,让我们看看我们能用 AdventureWorks 数据做什么。

分布式逻辑回归

在 Visual Studio 解决方案资源管理器中,添加一个名为AdventureWorksLR的新 F#脚本文件。回到 Visual Studio,打开 NuGet 包管理器,输入以下内容:

PM> Install-Package SQLProvider -prerelease

在那个脚本中添加以下代码(你的版本号可能不同):

#r "../packages/SQLProvider.0.0.11-alpha/lib/net40/FSharp.Data.SQLProvider.dll"

open System
open System.Linq
open FSharp.Data.Sql

[<Literal>]
let connectionString = "data source=nc54a9m5kk.database.windows.net;initial catalog=AdventureWorks2014;user id=PacktReader;password= P@cktM@chine1e@rning;"

type AdventureWorks = SqlDataProvider<ConnectionString=connectionString>
let context = AdventureWorks.GetDataContext()

type Telemetry = {ID:int; BusinessEntityID: int; TimeStamp: System.DateTime; 
                 Longitude: float; Latitude: float; 
                 FrontTirePressure: float; BackTirePressure: float;
                 GearId: int; TireSpeed: float; RiderLevel: int}

let telemetry = query {for t in context.''[Person].[Telemetry]'' do
  join rl in context.''[Person].[RiderLevel]'' on (t.BusinessEntityID = rl.BusinessEntityID)
  select {ID=t.ID; BusinessEntityID=t.BusinessEntityID;
          TimeStamp=t.TimeStamp;
          Longitude=t.Longitude; Latitude=t.Latitude; 
          FrontTirePressure=t.FrontTirePressure;
          BackTirePressure=t.BackTirePressure;
          GearId=t.GearID;TireSpeed=t.TireSpeed;
          RiderLevel=rl.RiderLevel}}
  |>Seq.toArray

将此发送到 FSI,我们得到以下结果:

val connectionString : string =
  "data source=nc54a9m5kk.database.windows.net;initial catalog=A"+[72 chars]
type AdventureWorks = FSharp.Data.Sql.SqlDataProvider<...>
val context : FSharp.Data.Sql.SqlDataProvider<...>.dataContext
type Telemetry =
  {ID: int;
   BusinessEntityID: int;
   TimeStamp: System.DateTime;
   Longitude: float;
   Latitude: float;
   FrontTirePressure: float;
   BackTirePressure: float;
   GearId: int;
   TireSpeed: float;
   RiderLevel: int;}
val telemetry : Telemetry [] =
  [|{ID = 1;
     BusinessEntityID = 295;
     TimeStamp = 12/30/2015 3:19:02 PM;
     Longitude = 46.6297;
     Latitude = -97.6087;
     FrontTirePressure = 100.0;
     BackTirePressure = 100.0;
     GearId = 2;
     TireSpeed = 20.04;
     RiderLevel = 0;}; {ID = 2;
                        BusinessEntityID = 775;

这里没有新的代码。我们正在创建一个包含我们从物联网自行车中捕获的所有有用数据的telemetry类型。然后我们创建一个来自数据库中所有数据的遥测数组。如果你想知道,telemetry表中共有 360,000 条记录。

回到脚本,输入以下内容:

#r"../packages/Accord.3.0.2/lib/net40/Accord.dll"
#r"../packages/Accord.Math.3.0.2/lib/net40/Accord.Math.dll"
#r"../packages/Accord.Statistics.3.0.2/lib/net40/Accord.Statistics.dll"
#r"../packages/Accord.MachineLearning.3.0.2/lib/net40/Accord.MachineLearning.dll"

open System
open Accord
open Accord.Math
open Accord.Statistics
open Accord.MachineLearning
open Accord.Statistics.Models.Regression.Linear

Tools.Shuffle(telemetry)
let attachmentPoint = float telemetry.Length * 0.7 |> int
let train = telemetry.[..attachmentPoint]
let test = telemetry.[attachmentPoint+1..]

let trainInputs = train |> Array.map(fun t -> [|float t.GearId; float t.RiderLevel|])
let trainOutputs = train |> Array.map(fun t -> t.TireSpeed)
let target = new MultipleLinearRegression(2, false)
target.Regress(trainInputs, trainOutputs)

将此发送到 FSI,我们得到以下结果:

 RiderLevel = 1;}; ...|]
val trainInputs : float [] [] =
 [|[|1.0; 1.0|]; [|2.0; 2.0|]; [|2.0; 1.0|]; [|3.0; 1.0|]; [|1.0; 0.0|];
 [|3.0; 1.0|]; [|4.0; 2.0|]; [|2.0; 0.0|]; [|3.0; 1.0|]; [|1.0; 0.0|];
...|]
val trainOutputs : float [] =
 [|23.3934008; 30.5693388; 18.2111048; 19.3842; 14.007411; 21.861742;
 36.6713256; 14.5381236; 16.2; 25.451495; 25.4571174; 14.5671708;
 20.1900384; 19.3655286; 27.8646144; 21.6268866; 19.3454316; ...|]
val target : MultipleLinearRegression =
 y(x0, x1) = 5.72463678857853*x0 + 6.83607853679457*x1
val it : float = 18472679.55

这段代码创建了一个多元线性回归,用于根据骑行者的级别和他们使用的档位来预测自行车速度。不要看 r2,让我们做一个嗅探测试。回到脚本,添加以下内容:

let possible = 
    [|0..4|] 
    |>  Array.collect(fun i -> [|0..2|] 
                               |> Array.map(fun j -> [|float i; float j|]))
let predict = 
    possible
    |> Array.map(fun i -> i, target.Compute(i))

将此发送到 REPL,我们得到以下结果:

val possible : float [] [] =
 [|[|0.0; 0.0|]; [|0.0; 1.0|]; [|0.0; 2.0|]; [|1.0; 0.0|]; [|1.0; 1.0|];
 [|1.0; 2.0|]; [|2.0; 0.0|]; [|2.0; 1.0|]; [|2.0; 2.0|]; [|3.0; 0.0|];
 [|3.0; 1.0|]; [|3.0; 2.0|]; [|4.0; 0.0|]; [|4.0; 1.0|]; [|4.0; 2.0|]|]
val predict : (float [] * float) [] =
 [|([|0.0; 0.0|], 0.0); ([|0.0; 1.0|], 6.836078537);
 ([|0.0; 2.0|], 13.67215707); ([|1.0; 0.0|], 5.724636789);
 ([|1.0; 1.0|], 12.56071533); ([|1.0; 2.0|], 19.39679386);
 ([|2.0; 0.0|], 11.44927358); ([|2.0; 1.0|], 18.28535211);
 ([|2.0; 2.0|], 25.12143065); ([|3.0; 0.0|], 17.17391037);
 ([|3.0; 1.0|], 24.0099889); ([|3.0; 2.0|], 30.84606744);
 ([|4.0; 0.0|], 22.89854715); ([|4.0; 1.0|], 29.73462569);
 ([|4.0; 2.0|], 36.57070423)|]

在这个脚本中,possible是一个所有可能的档位(值 0 到 4)和骑行者级别(值 0 到 2)组合的交错数组。然后我们用Compute()方法的结果填充这个矩阵。当你将数据以更用户友好的方式呈现时,你可以看到存在一种关系——精英骑行者在所有档位上比初学者骑得快,而且看起来初学者根本不用最低档:

分布式逻辑回归

在创建了这个模型之后,我们就可以在数据上运行分类器,并得到给定档位和骑行者级别的预期速度。进入脚本文件,输入以下内容:

#load"../packages/MBrace.Thespian.1.0.19/MBrace.Thespian.fsx"

open MBrace.Core.Builders
open MBrace.Thespian
open MBrace.Core
open MBrace.Library.Cloud

let cluster = ThespianCluster.InitOnCurrentMachine(4)

let testInputs = test |> Array.map(fun t -> [|float t.GearId; float t.RiderLevel|])

let mBraceTotal =
    testInputs 
    |> Balanced.map(fun i ->
                    target.Compute(i)) |> cluster.Run

当你将此代码发送到 REPL 时,你会看到控制台窗口弹出并开始工作。几分钟之后,你会得到这个结果:

val mBraceTotal : float [] =
 |36.57070423; 25.12143065; 36.57070423; 18.28535211; 5.724636789;
 24.0099889; 5.724636789; 25.12143065; 24.0099889; 18.28535211; 24.0099889;
 5.724636789; 36.57070423; 12.56071533; 24.0099889; 11.44927358; 0.0;
 11.44927358; 25.12143065; 12.56071533; 30.84606744; 12.56071533;
 11.44927358; 18.28535211;

你可能想知道是否有方法可以分发模型的创建(即target.Regress(trainInputs, trainOutputs)这一行)。简短的回答是,你不能使用我们正在使用的框架来做这件事。然而,一些模型可能适合分布式和重新聚合,但你必须扩展 numl 和 Accord 提供的内容。

物联网

但在我们离开机器学习和物联网之前,让我们疯狂一下。PI 不仅仅是一个输入设备——天哪,它比五年前你买的笔记本电脑还要强大。让我们让我们的树莓派自行车成为三州地区的终极力量。

PCL 线性回归

进入 Visual Studio,添加一个新的 Visual F# Windows Portable Library (.NET 4.5)名为AdventureWorks.IOB.PCL

![PCL 线性回归项目创建完成后,进入 NuGet 包管理器控制台,输入以下内容:pyPM> Install-Package portable.accord.statisticsPM> Install-Package portable.accord.MachineLearning确保默认项目指向AdventureWorks.IOB.PCLPCL 线性回归

处理 PCL 时遇到的一个问题是,由于它们是.NET Framework 的精简版,因此没有数据访问支持。这意味着我们无法使用我们熟悉的环境类型提供者来获取遥测数据以训练我们的模型。相反,我们需要从不同的项目获取数据,并将这些数据推送到 PCL 中以便训练模型。另一个“陷阱”是,在 PCL 项目中创建的脚本文件在 FSI 中评估,而 FSI 是一个完整的.NET Framework。这意味着你不能假设你写在.fsx文件中的所有代码都可以复制粘贴到.fs文件中。由于我们是在已有的代码基础上构建的,所以我们将不会在这个部分使用脚本文件。我知道……深呼吸……没有 REPL 的功能性编程。

进入 PCL 项目,删除Script.fsx文件,并将PortableLibrary1.fs重命名为SpeedModel.fs

SpeedModel.fs文件中,将所有现有代码替换为以下内容:

namespace AdventureWorks.IOB.PCL

open System
open Accord
open Accord.Math
open Accord.Statistics
open Accord.MachineLearning
open Accord.Statistics.Models.Regression.Linear

typeTelemetry = {ID:int; BusinessEntityID: int; 
                 TimeStamp: System.DateTime; 
                 Longitude: float; Latitude: float; 
                 FrontTirePressure: float; 
                 BackTirePressure: float;
                 GearId: int; TireSpeed: float; RiderLevel: int}
typeSpeedModel() = 
letmutable model = newMultipleLinearRegression(2, false)

member this.CurrentModel 
with get() = model
and set (value) = model <- value

member this.Train(telemetries:Telemetry array) = 
        Tools.Shuffle(telemetries)
let inputs = telemetries |>Array.map(fun t -> [|float t.GearId; float t.RiderLevel|])
let outputs = telemetries |>Array.map(fun t -> t.TireSpeed)
        model.Regress(inputs, outputs)

member this.Classify telemetry =
let input = [|float telemetry.GearId; float telemetry.RiderLevel|]
        model.Compute(input)        

此代码创建了两个.NET 类。Telemetry类相当于在 C#或 VB.NET 中看到的只读 DTO/POCO。SpeedModel类则更为复杂。该类有一个属性和两个方法:

  • CurrentModel是一个属性,允许设置线性回归模型。请注意,模型是一个内部变量,是可变的。

  • Train是一个方法,其中传递一个遥测数组,线性回归模型将被更新。Train()的实现可以从之前工作的脚本文件中复制粘贴。

  • Classify是一个方法,其中传递一个单个遥测,线性回归计算分数。Classify()的实现可以从之前工作的脚本文件中复制粘贴。

你可以通过编译项目来检查一切是否正常。

服务层

我们的 PCL 准备好了,让我们构建一个服务层来部署模型到现场设备:

  1. 进入 Visual Studio,添加一个新的 Visual C# Web ASP.NET Web 应用程序服务层服务层

  2. 添加一个引用:服务层

  3. 接下来,进入 NuGet 包管理器控制台,添加对Accord.Statistics的引用。确保默认项目指向AdventureWorks.IOB.Services服务层

  4. 接下来,进入Web.Config文件并添加一个连接字符串条目:

    <configuration>
    <connectionStrings>
    <addname="Northwind"connectionString="data source=nc54a9m5kk.database.windows.net;initial catalog=AdventureWorks2014;user id=PacktReader;password= P@cktM@chine1e@rning;" />
    </connectionStrings>
    <appSettings>
    <addkey="webpages:Version"value="3.0.0.0" />
    
  5. 转到Global.asax.cs文件,并用以下内容替换整个内容:

    using System;
    using System.Collections.Generic;
    using System.Web.Http;
    using System.Web.Mvc;
    using System.Web.Optimization;
    using System.Web.Routing;
    using AdventureWorks.IOB.PCL;
    using System.Threading;
    using System.Configuration;
    using System.Data.SqlClient;
    
    namespace AdventureWorks.IOB.Services
    {
    publicclassWebApiApplication : System.Web.HttpApplication
        {
    staticObject _lock = newObject();
    Timer _timer = null;
    staticSpeedModel _speedModel = null;
    
    protectedvoid Application_Start()
            {
    AreaRegistration.RegisterAllAreas();
    GlobalConfiguration.Configure(WebApiConfig.Register);
    FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
    RouteConfig.RegisterRoutes(RouteTable.Routes);
    BundleConfig.RegisterBundles(BundleTable.Bundles);
    
                _speedModel = newSpeedModel();
                _timer = newTimer(TrainModel, null, 0, TimeSpan.FromMinutes(5).Milliseconds);
    
            }
    
    protectedTelemetry[] CreateTelemetries(String connectionString)
            {
    var telemetries = newList<Telemetry>();
    
    using (var connection = newSqlConnection(connectionString))
                {
    var commandText = "Select T.*,RL.RiderLevel from [Person].[Telemetry] as T " +
    "inner join[Person].[RiderLevel] as RL " +
    "on T.BusinessEntityID = rl.BusinessEntityID";
    using (var command = newSqlCommand(commandText, connection))
                    {
                        connection.Open();
    var reader = command.ExecuteReader();
    while(reader.Read())
                        {
                            telemetries.Add(newTelemetry((int)reader[0], (int)reader[1],
                            (DateTime)reader[2], 
                            (double)reader[3], 
                            (double)reader[4],
                            (double)reader[5], 
                            (double)reader[6],(int)reader[7],
                            (double)reader[8], 
                            (int)reader[9]));
                        }
                    }
                }
    
    return telemetries.ToArray();
            }
    
            private void TrainModel(object state)
            {
                var connectionString = ConfigurationManager.ConnectionStrings["Northwind"].ConnectionString;
                var telemetries = CreateTelemetries(connectionString);
                lock (_lock)
                {
                    SpeedModel.Train(telemetries);
                }
            }
    
    publicstaticSpeedModel SpeedModel
            {
    get
                {
    lock (_lock)
                    {
    return _speedModel;
                    }
                }
    set
                {
    lock (_lock)
                    {
                        _speedModel = value;
                    }
                }
            }
        }
    }
    

你现在可以编译这个项目了。这段代码与第三章,更多 AdventureWorks 回归非常相似,我们创建了一个每 5 分钟触发一次的计时器。此外,我们使用锁来防止模型在非法状态下被读取。当计时器触发时,模型将根据数据库中的数据重新创建。请注意,这是 C#应用程序负责获取传递给SpeedModel类的数据的部分。

转到Controllers并将ValuesController重命名为SpeedModelController。进入文件并将所有代码替换为以下内容:

using System.Web.Http;
using System.Net.Http;
using System.IO;
using System.Xml.Serialization;
using System.Net;

namespace AdventureWorks.IOB.Services.Controllers
{
  publicclassSpeedModelController : ApiController
    {
    // GET api/SpeedModel
      publicHttpResponseMessage Get()
        {
          HttpResponseMessage result = null;

          if (WebApiApplication.SpeedModel != null)
            {
              using (MemoryStream stream = newMemoryStream())
                {
                  var formatter = newXmlSerializer(typeof(double[]));
                  formatter.Serialize(stream, WebApiApplication.SpeedModel.CurrentModel.Coefficients);
                  var content = stream.ToArray();

                  result = Request.CreateResponse(HttpStatusCode.OK);
                  result.Content = newByteArrayContent(content);
                  return result;
                }
            }
              else
            {
              return Request.CreateResponse(HttpStatusCode.Gone);
            }
        }

    }
}

如果你编译了项目并运行了网站,当你导航到控制器时,你会得到以下内容:

服务层

我们现在有一种方法可以根据数据库中的所有数据创建模型,我们可以将其共享给单个客户端。

通用 Windows 应用程序和 Raspberry Pi 2

这个通用应用程序有几个组成部分:

  • 当应用程序连接到其家庭网络时,它将:

    • 将收集的所有遥测数据上传到 Azure 的 IoT Suite

    • 从我们的服务层下载基于 AdventureWorks 数据库中所有骑手创建的最新全局模型

  • 当应用程序运行时,它将从连接到自行车的传感器收集遥测数据。在某个时刻之后,它将开始生成自己的本地模型,并将其与全局 AdventureWorks 模型进行比较。如果本地模型开始偏离全局模型的预期速度,它将指示骑手换挡。应用程序将保留遥测数据在本地存储中,直到它连接到网络,然后上传数据。

让我们来编写这段代码:

  1. 进入解决方案资源管理器,添加一个新的 Visual C# Windows 通用空白应用程序,命名为AdventureWorks.IOB.RP2通用 Windows 应用程序和 Raspberry Pi 2

  2. 项目创建后,转到其引用部分并选择添加引用通用 Windows 应用程序和 Raspberry Pi 2

    添加引用...选项

  3. 然后导航到项目 | 解决方案并选择你的 PCL 项目的位置:通用 Windows 应用程序和 Raspberry Pi 2

  4. 现在导航到通用 Windows | 扩展 | Windows IoT 扩展用于 UWP通用 Windows 应用程序和树莓派 2

  5. 接下来,进入 NuGet 包管理器控制台,输入以下内容:

    PM> Install-Package portable.accord.statistics
    
    
  6. 确保默认项目指向AdventureWorks.IOB.RP2通用 Windows 应用程序和树莓派 2

    构建项目以确保一切正常。

  7. 接下来,转到解决方案资源管理器,添加一个名为Sensors的新文件夹:通用 Windows 应用程序和树莓派 2

    添加新文件夹

  8. 导航到Sensors文件夹:通用 Windows 应用程序和树莓派 2

  9. 添加一个名为TelemetryEventArgs.cs的新类:通用 Windows 应用程序和树莓派 2

    添加一个新类

  10. TelemetryEventArgs.cs中,用以下代码替换现有代码:

    using AdventureWorks.IOB.PCL;
    using System;
    
    namespace AdventureWorks.IOB.RP2.Sensors
    {
      Public class TelemetryEventArgs : EventArgs
        {
          Public Telemetry Telemetry { get; set; }
        }
    }
    
  11. 在传感器文件夹中,添加一个名为IBikeController的新接口。创建后,用以下代码替换所有代码:

    using System;
    
    namespace AdventureWorks.IOB.RP2.Sensors
    {
    Public interface IBikeController
        {
          Event EventHandler<TelemetryEventArgs> TelemetryCreated;
          void SwitchGear(int targetGear);
        }
    }
    

    此接口将由主应用程序用于,嗯,与树莓派进行接口交互。Pi 通过一个名为TelemetryCreated的事件将信息传回主应用程序。我们之所以使用接口(而不是直接与 PI 交谈),是因为我们想要借鉴 SOLID 原则,并为我们的应用程序提供几个实现:一个内存中的自行车控制器,我们可以用它来确保一切连接正确,以及一个实际的树莓派自行车控制器,它实际上与我们现在可用的硬件进行通信。此外,市场上有很多传感器,我们需要一种方法在不更改现有代码的情况下添加新传感器。

  12. 进入Sensors文件夹,添加一个名为InMemoryBikeController的新类。用以下代码替换现有代码:

    using AdventureWorks.IOB.PCL;
    using System;
    using System.Threading;
    
    namespace AdventureWorks.IOB.RP2.Sensors
    {
    
        public class InMemoryBikeController : IBikeController
        {
            Timer _timer = null;
    
            public InMemoryBikeController()
            {
                _timer = new Timer(GenerateTelemetry, null, 0, TimeSpan.FromSeconds(1).Milliseconds);
            }
    
            public event EventHandler<TelemetryEventArgs> TelemetryCreated;
    
            private void GenerateTelemetry(object state)
            {
                var telemetry = new Telemetry(0, 0, DateTime.UtcNow, 46.6297, -97.6087, 100.0, 100.0, 2, 10.0, 1);
                var args = new TelemetryEventArgs() { Telemetry = telemetry };
    
                if (TelemetryCreated != null)
                {
                    TelemetryCreated(this, args);
                }
            }
    
            public void SwitchGear(int targetGear)
            {
    
            }
        }
    }
    

    此代码模拟了一个实际的树莓派。每秒,它触发一个带有一些硬编码遥测数据的事件。它还有一个SwitchGears的方法占位符,什么都不做。

  13. 确保一切编译正常,跳转到MainPage.xaml文件,并用以下内容替换所有内容:

    <Page
      x:Class="AdventureWorks.IOB.RP2.MainPage"
    
      mc:Ignorable="d">
    
      <Grid Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">
      <StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
      <TextBox x:Name="StatusMessage" Text="IOB Ready!" Margin="10" IsReadOnly="True"/>
      </StackPanel>
      </Grid>
    </Page>
    

    这创建了一个状态框,你可以用它进行调试。当你将此应用程序部署到树莓派时,这就不必要了,因为没有图形用户界面。

  14. 接下来,进入MainPage.xaml.cs文件,并用以下内容替换所有内容:

    using System;
    using System.IO;
    using System.Linq;
    using Windows.UI.Xaml;
    using Windows.Web.Http;
    using AdventureWorks.IOB.PCL;
    using Windows.UI.Xaml.Controls;
    using System.Xml.Serialization;
    using System.Collections.Generic;
    using AdventureWorks.IOB.RP2.Sensors;
    using Windows.Networking.Connectivity;
    using Accord.Statistics.Models.Regression.Linear;
    
    namespace AdventureWorks.IOB.RP2
    {
      publicsealedpartialclassMainPage : Page
        {
          String _networkName = String.Empty;
          SpeedModel _globalSpeedModel = null;
          SpeedModel _localSpeedModel = null;
          List<Telemetry> _telemetries = null;
          IBikeController _bikeController = null;
          DispatcherTimer _timer = null;
    
          public MainPage()
            {
               this.InitializeComponent();
               _networkName = "MySafeNetwork";
               _globalSpeedModel = newSpeedModel();
               _localSpeedModel = newSpeedModel();
               _telemetries = newList<Telemetry>();
               _bikeController = newInMemoryBikeController();
               _timer = newDispatcherTimer();
    
               _timer.Interval = newTimeSpan(0, 0, 1);
    
               NetworkInformation.NetworkStatusChanged += NetworkInformation_NetworkStatusChanged;
               _bikeController.TelemetryCreated += _bikeController_TelemetryCreated;
               _timer.Tick += _timer_Tick;
    
            }
    
            privatevoid _timer_Tick(object sender, object e)
            {
              if(_telemetries.Count > 300)
                {
                  _localSpeedModel.Train(_telemetries.ToArray());
    
                  var targetGlobalGear = _globalSpeedModel.Classify(_telemetries.Last());
                  var targetLocalGear = _localSpeedModel.Classify(_telemetries.Last());
                  if (targetGlobalGear < targetLocalGear)
                    {
                       _bikeController.SwitchGear((int)targetGlobalGear);
                    }
                }
            }
    
            privatevoid _bikeController_TelemetryCreated(object sender, TelemetryEventArgs e)
            {
               _telemetries.Add(e.Telemetry);
            }
    
            privatevoid NetworkInformation_NetworkStatusChanged(object sender)
            {
              var connectionProfile = NetworkInformation.GetInternetConnectionProfile();
              if (connectionProfile.ProfileName == _networkName)
                {
                   GetGlobalModel();
                   UploadLocalTelemetryData();
                }
            }
    
            privateasyncvoid GetGlobalModel()
            {
              var client = newHttpClient();
              var uri = newUri("http://localhost:3899/api/SpeedModel");
              try
                {
                  var response = await client.GetAsync(uri);
                  if (response.IsSuccessStatusCode)
                    {
                      var content = await response.Content.ReadAsInputStreamAsync();
                      using (var stream = content.AsStreamForRead())
                        {
                          var formatter = newXmlSerializer(typeof(double[]));
                          var coefficients = (double[])formatter.Deserialize(stream);
                          var regression = newMultipleLinearRegression(2);
                          Array.Copy(coefficients, regression.Coefficients,coefficients.Length);
                          _globalSpeedModel.CurrentModel = regression;
                        }
                    }
                }
                catch (Exception e)
                {
                   this.StatusMessage.Text = e.ToString();
                }
    
            }
    
              privateasyncvoid UploadLocalTelemetryData()
            {
    //TODO: Send _telemetries to Azure IoT Suite
            }
    
        }
    }
    

    这就是重头戏所在。当应用程序启动时,它开始一个每秒触发一次的计时器(_timer_Tick)。如果本地集合中有超过 5 分钟的数据,它将生成一个SpeedModel。然后,它将这个速度模型与全局模型进行比较,如果全局输出小于本地输出,它将通过.SwitchGear()向骑手发出信号。实际实现取决于控制器。正如你将在一分钟内看到的那样,树莓派控制器会打开一个骑手可以看到的 LED 灯。在其他示例中,我们可以将 Pi 连接到自行车的变速机构,为骑手换挡——就像自行车的自动变速器一样。

  15. 接下来,进入解决方案资源管理器,右键点击属性,将启动项目更改为多个启动项目,并将ServicesRP2项目更改为启动Services项目必须列在RP2项目之前:通用 Windows 应用和树莓派 2

  16. 在我们运行此应用之前,你需要做的最后一件事是部署通用 Windows 应用。如果你问我为什么需要先部署它,我会告诉你,“因为微软说了。”进入解决方案资源管理器,右键点击Rp2项目,并选择部署通用 Windows 应用和树莓派 2

    部署选项

  17. 现在,你可以运行应用,浏览器将弹出服务层,通用 Windows 应用将启动:通用 Windows 应用和树莓派 2

注意屏幕上没有发生太多事情。这在物联网项目中很典型;动作发生在设备和连接的外围设备上。如果设备触发了NetworkStatus_Changed事件,设备将获取最新的全局模型并上传全局模型。如果你是那种想在屏幕上看到东西的人,你可以通过放置GetGlobalModelinMainPage()并写入状态框来模拟此操作。

让我们构建BikeController的树莓派实现。由于这是一本关于机器学习的书,而不是关于物联网的书,因此我不会涵盖设置树莓派和进行所有布线和编码的细节。作为一个参考框架,我使用了在ms-iot.github.io/content/en-US/win10/samples/Potentiometer.htm找到的示例。基本上,每个传感器都会被视为一个模拟输入设备(如电位计),它将信号转换为数字信号。对于每个输入,创建了一个SpiConnection,如下所示:

privateasyncTask<SpiDevice> InitSPI(int pin)
        {
            var settings = newSpiConnectionSettings(pin);
            settings.ClockFrequency = 500000;   
            settings.Mode = SpiMode.Mode0;    

            string spiAqs = SpiDevice.GetDeviceSelector("SPI0");
            var deviceInfo = awaitDeviceInformation.FindAllAsync(spiAqs);
            returnawaitSpiDevice.FromIdAsync(deviceInfo[0].Id, settings);
        }

每秒,读取每个设备的缓冲区:

privatevoid SensorTimer_Tick(ThreadPoolTimer timer)
        {
byte[] readBuffer = newbyte[3]; 
byte[] writeBuffer = newbyte[3] { 0x00, 0x00, 0x00 };
            writeBuffer[0] = 0x06;

//Gear
            _gear.TransferFullDuplex(writeBuffer, readBuffer);
var gear = convertToInt(readBuffer);

读取被汇总到遥测数据中,并引发事件:

var telemetry = newTelemetry(0, _businessEntityID, DateTime.UtcNow,
                latitude, longitude, frontTire, backTire, gear, tireSpeed, _riderLevel);
var args = newTelemetryEventArgs() { Telemetry = telemetry };

if (TelemetryCreated != null)
            {
                TelemetryCreated(this, args);
            }

同时,另一个计时器正在运行,每两秒关闭一次 LED。当调用SwitchGear方法时,LED 被设置:

public void SwitchGear(int targetGear)
        {
            _led.Write(GpioPinValue.Low);
        }

因此,控制器应用可以打开 LED,然后树莓派在两秒后关闭它。你可以在书中附带的代码示例中看到最终结果。

部署选项

下一步

摘要

这是一章相当雄心勃勃的内容。我们探讨了大数据的一些挑战以及如何使用 MBrace 帮助我们进行分布式机器学习。然后我们创建了一个示例物联网项目,以展示大数据是如何生成的,以及我们如何部署机器学习模型到设备上。该物联网应用使用了两个机器学习模型以获得最佳结果。然后我们简要地探讨了如何利用.NET 的力量构建多个输入设备,以便我们可以扩展到目前和未来可用的各种物联网硬件。

posted @ 2025-09-04 14:12  绝不原创的飞龙  阅读(2)  评论(0)    收藏  举报