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

74 阅读1小时+

精通 .NET 机器学习(二)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

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

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

科学过程

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

科学过程

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

科学过程

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

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

科学过程

在时间花费方面,与其它块相比,“数据清洗”块非常大。这是因为大部分工作努力都花在了数据获取和准备上。历史上,大部分数据整理工作都是处理缺失、格式不正确和不合逻辑的数据。传统上,为了尽量减少这一步骤的工作量,人们会创建数据仓库,并在数据从源系统传输到仓库(有时称为提取、转换、加载或 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

USStateGenderYearOfBirthNameGivenNumberOfInstances
AKF1910Annie12
AKF1910Anna10
AKF1910Margaret8
ALF1910Annie90
ALF1910Anna88
ALF1910Margaret86
AZF1910Annie46
AZF1910Anna34
AZF1910Margaret12

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

fstsnd
AnnieAKF1910Annie12
 ALF1910Annie90
 AZF1910Annie46
AnnaAKF1910Anna10
 ALF1910Anna88
 AZF1910Anna34
MargaretAKF1910Margaret8
 ALF1910Margaret86
 AZF1910Margaret12

使用元组的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%在购买时也购买了额外的商品。”

年月交叉独立总计%交叉
201305252953207.8%
2013064296949886.1%
2013074415649788.7%
2013085258360886.3%
2013095366860488.7%
20131064910074986.6%
2013118681361,00486.5%
2013126989979787.6%
2014018009789789.2%
2014027029679888.0%
2014038911351,02686.8%
2014049651211,08688.9%
2014051,0341521,18687.2%
总计8,5631,50710,07085.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晴朗230.220.60
1阴天400.440.00
2320.330.40
 总计951.001.00
      
ID温度% 是% 否
0220.220.40
1温和420.440.40
2凉爽310.330.20
 总计951.001.00
      
ID湿度% 是% 否
0340.330.80
1正常610.670.20
 总计951.001.00
      
ID% 是% 否
0620.670.40
1330.330.60
 总计951.001.00
      
ID最终% 是% 否
0950.640.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。下一页见!