精通 .NET 机器学习(三)
原文:
annas-archive.org/md5/ffd977b8ff1cdb3a3a6b690a4c1d47bc译者:飞龙
第七章。交通拦截和事故地点 – 当两个数据集比一个更好
如果你还记得第四章,交通拦截 – 是否走错了路?,我们使用决策树来帮助我们根据诸如一天中的时间、一周中的哪一天等季节性因素来确定一个人是否收到了罚单或警告。最终,我们没有找到任何关系。你的第一个想法可能是丢弃数据集,我认为这是一个错误,因为其中可能隐藏着数据宝藏,但我们只是使用了错误的模型。此外,如果一个数据集本身不盈利,我通常开始用其他数据集来增强它,看看特征组合是否能提供更令人满意的答案。在本章中,让我们回到我们的 Code-4-Good 小组,看看我们是否可以增强交通拦截数据集,并应用一些不同的模型,这些模型将帮助我们提出有趣的问题和答案。也许即使我们没有提出正确的问题,计算机也能帮助我们提出正确的问题。
无监督学习
到目前为止,本书我们已经使用了几种不同的模型来回答我们的问题:线性回归、逻辑回归和 kNN 等。尽管它们的方法不同,但它们有一个共同点;我们告诉计算机答案(称为因变量或y变量),然后提供一系列可以与该答案关联的特征(称为自变量或x变量)。以下图为例:
我们随后向计算机展示了一些它之前未曾见过的独立变量的组合,并要求它猜测答案:
我们随后通过测试将结果与已知答案进行比较,如果模型在猜测方面做得很好,我们就会在生产中使用该模型:
这种在事先告诉计算机答案的方法被称为监督学习。术语监督之所以被使用,是因为我们明确地提供给计算机一个答案,然后告诉它使用哪个模型。
另有一类模型不会向计算机提供答案。这类模型被称为无监督学习。如果你的无监督学习的心理模型是替代教师在暑假前一天出现在六年级班级时的混乱,你并不远。好吧,可能没有那么糟糕。在无监督学习中,我们向计算机提供一个只包含属性的数据框,并要求它告诉我们关于数据的信息。有了这些信息,我们就可以缩小可能帮助我们做出有洞察力的商业决策的数据。例如,假设你将这个数据框发送给计算机:
它可能会告诉你数据似乎在两个区域聚集:
尽管你可能在简单的 2D 数据框上通过观察发现了这种关系,但在添加更多行和特征时,这项任务会变得非常困难,甚至不可能。在本章中,我们将使用 k-means 模型进行这种聚类。
此外,我们可以使用计算机告诉我们数据框中有用的特征以及哪些特征只是噪声。例如,考虑以下数据集:
| 学习时间 | 啤酒数量 | 学习地点 |
|---|---|---|
| 2 | 4 | Dorm |
| 1 | 5 | Dorm |
| 6 | 0 | Dorm |
| 5 | 1 | Dorm |
| 2 | 8 | Dorm |
| 4 | 4 | Dorm |
将学习地点包含在我们的数据框中会导致任何洞察吗?答案是不会有,因为所有值都相同。在本章中,我们将使用主成分分析(PCA)进行此类特征过滤;它将告诉我们哪些特征是重要的,哪些可以安全地删除。
k-means
如前所述,k-means 是一种无监督技术:观察值是根据每个簇的平均值进行分组的。让我们看看 k-means 的实际应用。打开 Visual Studio,创建一个新的 Visual F# Windows Library Project。将Script.fsx文件重命名为kmeans.fsx。打开NuGet 包管理器控制台,并输入以下内容:
PM> install-package Accord.MachineLearning
接下来,转到脚本并替换所有内容为以下内容:
#r "../packages/Accord.3.0.2/lib/net40/Accord.dll"
#r "../packages/Accord.Math.3.0.2/lib/net40/Accord.Math.dll"
#r "../packages/Accord.Statistics.3.0.2/lib/net40/Accord.Statistics.dll"
#r "../packages/Accord.MachineLearning.3.0.2/lib/net40/Accord.MachineLearning.dll"
open Accord.MachineLearning
接下来,让我们创建一个数组,包含我们当地餐厅提供的各种饮料:
let drinks = ["Boones Farm", 0;
"Mad Dog", 1;
"Night Train", 2;
"Buckfast", 3;
"Smirnoff", 4;
"Bacardi", 5;
"Johhnie Walker", 6;
"Snow", 7;
"Tsingtao", 8;
"Budweiser", 9;
"Skol", 10;
"Yanjing", 11;
"Heineken", 12;
"Harbin", 13]
将此发送到 FSI,你会看到以下结果:
val drinks : (string * int) list =
[("Boones Farm", 0); ("Mad Dog", 1); ("Night Train", 2); ("Buckfast", 3);
("Smirnoff", 4); ("Bacardi", 5); ("Johhnie Walker", 6); ("Snow", 7);
("Tsingtao", 8); ("Budweiser", 9); ("Skol", 10); ("Yanjing", 11);
("Heineken", 12); ("Harbin", 13)]
>
返回到脚本,并输入一些餐厅顾客的记录。我们使用浮点值,因为 Accord 期望作为输入。
let observations = [|[|1.0;2.0;3.0|];[|1.0;1.0;0.0|];
[|5.0;4.0;4.0|];[|4.0;4.0;5.0|];[|4.0;5.0;5.0|];[|6.0;4.0;5.0|];
[|11.0;8.0;7.0|];[|12.0;8.0;9.0|];[|10.0;8.0;9.0|]|]
将其发送到 REPL,我们得到以下结果:
val observations : float [] [] =
[|[|1.0; 2.0; 3.0|]; [|1.0; 1.0; 0.0|]; [|5.0; 4.0; 4.0|]; [|4.0; 4.0; 5.0|];
[|4.0; 5.0; 5.0|]; [|6.0; 4.0; 5.0|]; [|11.0; 8.0; 7.0|];
[|12.0; 8.0; 9.0|]; [|10.0; 8.0; 9.0|]|]
你会注意到有九位不同的顾客,每位顾客都喝了三种饮料。顾客编号 1 喝了布恩农场酒、疯狗酒和夜车酒。有了这些数据,让我们对它运行 k-means 算法。将以下内容输入到脚本文件中:
let numberOfClusters = 3
let kmeans = new KMeans(numberOfClusters);
let labels = kmeans.Compute(observations)
当你将此发送到 FSI 时,你会看到以下结果:
val numberOfClusters : int = 3
val kmeans : KMeans
val labels : int [] = [|0; 0; 1; 1; 1; 1; 2; 2; 2|]
此输出将每位顾客分配到三个簇中的一个。例如,顾客编号 1 和 2 在簇编号 0 中。如果我们想每个簇有更多的观察值,我们可以像这样更改numberOfClusters:
let numberOfClusters = 2
let kmeans = new KMeans(numberOfClusters);
let labels = kmeans.Compute(observations)
将其发送到 FSI,会得到以下结果:
val numberOfClusters : int = 2
val kmeans : KMeans
val labels : int [] = [|1; 1; 1; 1; 1; 1; 0; 0; 0|]
注意,计算机不会尝试为每个簇标记或分配任何值。如果可能,数据科学家需要分配一个有意义的值。返回到脚本,将numberOfClusters改回三个,并重新发送到 FSI。查看输入数组,我们可以认为分配簇0的是加强葡萄酒饮用者,簇1是烈酒饮用者,簇2是啤酒饮用者。然而,有时你可能无法仅通过观察输入数组来判断每个簇的含义。在这种情况下,你可以请求 Accord 提供一些(有限的)帮助。将以下内容输入到脚本文件中:
kmeans.Clusters.[0]
将此发送到 FSI 将得到以下结果:
val it : KMeansCluster =
Accord.MachineLearning.KMeansCluster
{Covariance = [[4.3; 2.6; 3.2]
[2.6; 2.266666667; 2.733333333]
[3.2; 2.733333333; 3.866666667]];
Index = 0;
Mean = [|3.5; 3.333333333; 3.666666667|];
Proportion = 0.6666666667;}
注意均值是中间的三位数,这是一个相对较小的数字,因为我们是从 0 到 13 进行计数的。我们可以说,类别 0 的标签应该是类似巴克夫斯特的饮酒者,这通常是正确的。
主成分分析(PCA)
我们可以用无监督学习来完成另一个常见任务,即帮助我们剔除不相关的特征。如果你还记得上一章,我们在构建模型时使用逐步回归来确定最佳特征,然后使用奥卡姆剃刀法则剔除不显著的特征。PCA 的一个更常见用途是将这个无监督模型作为挑选最佳特征——即框架的主成分的一种方式。
将另一个脚本文件添加到你的项目中,并将其命名为pca.fsx。添加以下代码:
#r "../packages/Accord.3.0.2/lib/net40/Accord.dll"
#r "../packages/Accord.Math.3.0.2/lib/net40/Accord.Math.dll"
#r "../packages/Accord.Statistics.3.0.2/lib/net40/Accord.Statistics.dll"
open Accord.Statistics.Analysis
let sourceMatrix = [|[|2.5; 2.4|];[|0.5; 0.7|];[|2.2; 2.9|];
[|1.9; 2.2|];[|3.1; 3.0|];[|2.3; 2.7|];[|2.0; 1.6|];
[|1.0; 1.1|];[|1.5; 1.6|]; [|1.1; 0.9|]|]
将此发送到 FSI 将得到以下结果:
val sourceMatrix : float [] [] =
[|[|2.5; 2.4|]; [|0.5; 0.7|]; [|2.2; 2.9|]; [|1.9; 2.2|]; [|3.1; 3.0|];
[|2.3; 2.7|]; [|2.0; 1.6|]; [|1.0; 1.1|]; [|1.5; 1.6|]; [|1.1; 0.9|]|]
在这种情况下,sourceMatrix是一个学生列表,这些学生在考试前学习了一定小时数,并在考试前喝了多少啤酒。例如,第一个学生学习了 2.5 小时,喝了 2.4 杯啤酒。与你在书中看到的类似例子不同,你会注意到这个框架中没有因变量(Y)。我们不知道这些学生是否通过了考试。但仅凭这些特征,我们可以确定哪些特征对分析最有用。你可能会对自己说:“这怎么可能?”不深入数学的话,PCA 将查看一系列场景下每个变量的方差。如果一个变量可以解释差异,它将得到更高的分数。如果它不能,它将得到较低的分数。
让我们看看 PCA 关于这个数据集告诉我们什么。将以下代码输入到脚本中:
let pca = new PrincipalComponentAnalysis(sourceMatrix, AnalysisMethod.Center)
pca.Compute()
pca.Transform(sourceMatrix)
pca.ComponentMatrix
将此发送到 REPL,我们将得到以下结果:
val pca : PrincipalComponentAnalysis
val it : float [,] = [[0.6778733985; -0.7351786555]
[0.7351786555; 0.6778733985]]
你会注意到ComponentMatrix属性的输出是一个 2 x 2 的数组,互补值以交叉形式表示。在正式术语中,这个锯齿形数组被称为特征向量,数组的内容被称为特征值。如果你开始深入研究 PCA,你需要了解这些词汇的含义以及这些值的含义。对于我们这里的用途,我们可以安全地忽略这些值(除非你想要在下次家庭聚会中提及“特征值”这个词)。
在 PCA 中,我们需要特别注意的一个重要属性是成分比例。回到脚本文件,输入以下内容:
pca.ComponentProportions
将此发送到 REPL 将得到以下结果:
val it : float [] = [|0.9631813143; 0.03681868565|]
这些值对我们的分析很重要。注意,将这两个值相加等于 100 百分比?这些百分比告诉你数据框中的方差量(因此是数据的有用性量)。在这种情况下,学习时间是 96 百分比的方差,而啤酒量只有 4 百分比,所以如果我们想用这种数据进行分析,我们肯定会选择学习时间,并安全地丢弃饮酒。注意,如果我们增加了饮酒的啤酒范围,百分比会发生变化,我们可能希望使用这两个变量。这是一个有两个特征的简单示例。PCA 在你有大量特征并且需要确定它们的有用性时表现得尤为出色。
交通拦截和事故探索
在掌握 k-means 和 PCA 理论之后,让我们看看我们可以用开放数据做什么。如果你记得,我们有一个关于交通拦截的数据集。让我们再引入两个数据集:同一时间段内的汽车事故数量,以及事故/罚单当天降水量。
准备脚本和数据
在 Visual Studio 中,创建一个名为 Hack4Good.Traffic 的新 Visual F# 库项目:
项目创建完成后,将 Script.fsx 文件重命名为 Clustering.fsx:
接下来,打开 NuGet 包管理器控制台,并输入以下内容:
PM> install-package Accord.MachineLearning
在 Clustering.fsx 中,将以下代码输入到脚本中:
#r "System.Data.Entity.dll"
#r "FSharp.Data.TypeProviders.dll"
#r "System.Data.Linq.dll"
#r "../packages/Accord.3.0.2/lib/net40/Accord.dll"
#r "../packages/Accord.Math.3.0.2/lib/net40/Accord.Math.dll"
#r "../packages/Accord.Statistics.3.0.2/lib/net40/Accord.Statistics.dll"
#r "../packages/Accord.MachineLearning.3.0.2/lib/net40/Accord.MachineLearning.dll"
open System
open System.Linq
open System.Data.Linq
open System.Data.Entity
open Accord.MachineLearning
open System.Collections.Generic
open Accord.Statistics.Analysis
open Microsoft.FSharp.Data.TypeProviders
[<Literal>]
let connectionString = "data source=nc54a9m5kk.database.windows.net;initial catalog=Traffic;user id=chickenskills@nc54a9m5kk;password=sk1lzm@tter;"
type Geolocation = {Latitude: float; Longitude: float}
type EntityConnection = SqlEntityConnection<connectionString,Pluralize = true>
let context = EntityConnection.GetDataContext()
当你将此发送到 FSI 时,你会看到以下内容:
val connectionString : string =
"data source=nc54a9m5kk.database.windows.net;initial catalog=T"+[61 chars]
type Geolocation =
{Latitude: float;
Longitude: float;}
type EntityConnection =
class
static member GetDataContext : unit -> EntityConnection.ServiceTypes.SimpleDataContextTypes.EntityContainer
+ 1 overload
nested type ServiceTypes
end
val context :
EntityConnection.ServiceTypes.SimpleDataContextTypes.EntityContainer
在完成这些准备工作后,让我们从数据库中获取停止数据。将以下代码放入脚本文件中:
//Stop Data
type TrafficStop = {StopDateTime: DateTime; Geolocation: Geolocation; DispositionId: int}
let trafficStops =
context.dbo_TrafficStops
|> Seq.map(fun ts -> {StopDateTime = ts.StopDateTime.Value;
Geolocation = {Latitude = Math.Round(ts.Latitude.Value,3);
Longitude = Math.Round(ts.Longitude.Value,3)};
DispositionId = ts.DispositionId.Value})
|> Seq.toArray
当你将其发送到 REPL 时,你会看到以下内容:
type TrafficStop =
{StopDateTime: DateTime;
Geolocation: Geolocation;
DispositionId: int;}
val trafficStops : TrafficStop [] =
|{StopDateTime = 6/30/2012 12:36:38 AM;
Geolocation = {Latitude = 35.789;
Longitude = -78.829;};
DispositionId = 7;}; {StopDateTime = 6/30/2012 12:48:38 AM;
Geolocation = {Latitude = 35.821;
Longitude = -78.901;};
DispositionId = 15;};
{StopDateTime = 6/30/2012 1:14:29 AM;
Geolocation = {Latitude = 35.766;
所有这些数据都应该对你熟悉,来自[第四章,交通拦截 – 是否走错了路?。唯一的真正区别是现在有一个包含纬度和经度的地理位置类型。注意,我们在这一行中首先分配数据库中的任何值:
|> Seq.map(fun ts -> {StopDateTime = ts.StopDateTime.Value;
Geolocation = {Latitude = Math.Round(ts.Latitude.Value,3);
Longitude = Math.Round(ts.Longitude.Value,3)};
DispositionId = ts.DispositionId.Value})
此外,你还会注意到我们正在使用 Math.Round 将值保留到小数点后三位精度。有了这些本地数据,让我们引入事故数据。将以下代码输入到脚本中:
//Crash Data
type TrafficCrash = {CrashDateTime: DateTime; Geolocation: Geolocation; CrashSeverityId: int; CrashTypeId: int; }
let trafficCrashes=
context.dbo_TrafficCrashes
|> Seq.filter(fun tc -> tc.MunicipalityId = Nullable<int>(13))
|> Seq.filter(fun tc -> not (tc.Latitude = Nullable<float>()))
|> Seq.map(fun tc -> {CrashDateTime=tc.CrashDateTime.Value;
Geolocation = {Latitude =Math.Round(tc.Latitude.Value,3);
Longitude=Math.Round(tc.Longitude.Value,3)};
CrashSeverityId=tc.CrashSeverityId.Value;
CrashTypeId =tc.CrashTypeId.Value})
|> Seq.toArray
将此发送到 FSI 给出以下结果:
type TrafficCrash =
{CrashDateTime: DateTime;
Geolocation: Geolocation;
CrashSeverityId: int;
CrashTypeId: int;}
val trafficCrashes : TrafficCrash [] =
[|{CrashDateTime = 12/30/2011 1:00:00 AM;
Geolocation = {Latitude = 35.79;
Longitude = -78.781;};
CrashSeverityId = 4;
CrashTypeId = 3;}; {CrashDateTime = 12/30/2011 3:12:00 AM;
Geolocation = {Latitude = 35.783;
Longitude = -78.781;};
CrashSeverityId = 3;
CrashTypeId = 24;};
我们还有一个数据集想要使用:每天的交通状况。将以下内容输入到脚本中:
//Weather Data
type DailyPercipitation = {WeatherDate: DateTime; Amount: int; }
let dailyWeather =
context.dbo_DailyPercipitation
|> Seq.map(fun dw -> {WeatherDate=dw.RecordDate; Amount=dw.Amount;})
|> Seq.toArray
将此发送到 FSI 给出以下结果:
type DailyPercipitation =
{WeatherDate: DateTime;
Amount: int;}
val dailyWeather : DailyPercipitation [] =
[|{WeatherDate = 1/9/2012 12:00:00 AM;
Amount = 41;}; {WeatherDate = 1/10/2012 12:00:00 AM;
Amount = 30;}; {WeatherDate = 1/11/2012 12:00:00 AM;
Amount = 5;};
{WeatherDate = 1/12/2012 12:00:00 AM;
有这三个数据集可用,让我们将交通拦截和交通事故数据集合并成一个数据框,看看是否有关于地理位置的任何情况。
地理位置分析
前往脚本文件,并添加以下内容:
let stopData =
trafficStops
|> Array.countBy(fun ts -> ts.Geolocation)
将此发送到 REPL 给出以下结果:
val stopData : (Geolocation * int) [] =
[|({Latitude = 35.789;
Longitude = -78.829;}, 178); ({Latitude = 35.821;
Longitude = -78.901;}, 8);
({Latitude = 35.766;
Longitu…
到现在为止,这段代码应该对你来说已经很熟悉了;我们正在按地理位置统计交通停驶的数量。对于第一条记录,地理点 35.789/-78.829 有 178 次交通停驶。
接下来,返回脚本并输入以下内容:
let crashData =
trafficCrashes
|> Array.countBy(fun tc -> tc.Geolocation)
将此发送到 REPL,我们得到以下结果:
val crashData : (Geolocation * int) [] =
[|({Latitude = 35.79;
Longitude = -78.781;}, 51); ({Latitude = 35.783;
这段代码与停驶数据相同;我们正在按地理位置统计交通事故的数量。对于第一条记录,地理点 35.790/-78.781 有 51 次交通事故。
我们接下来的步骤是将这两个数据集合并成一个单一的数据框,我们可以将其发送到 Accord。至于 F#中的大多数事情,让我们使用类型和函数来实现这一点。返回脚本文件并输入以下内容:
type GeoTraffic = {Geolocation:Geolocation; CrashCount: int; StopCount: int}
let trafficGeo =
Enumerable.Join(crashData, stopData,
(fun crashData -> fst crashData),
(fun stopData -> fst stopData),
(fun crashData stopData -> { Geolocation = fst crashData; StopCount = snd crashData ; CrashCount = snd stopData }))
|> Seq.toArray
当你将此发送到 FSI 时,你会看到如下类似的内容:
type GeoTraffic =
{Geolocation: Geolocation;
CrashCount: int;
StopCount: int;}
val trafficGeo : GeoTraffic [] =
[|{Geolocation = {Latitude = 35.79;
Longitude = -78.781;};
CrashCount = 9;
StopCount = 51;}; {Geolocation = {Latitude = 35.783;
Longitude = -78.781;};
CrashCount = 16;
StopCount = 5;};
{Geolocation = {Latitude = 35.803;
Longitude = -78.775;};
CrashCount = 76;
StopCount = 2;};
这里有一些新的代码,一开始可能会让人感到有些难以理解(至少对我来说是这样的)。我们正在使用 LINQ 类Enumerable的Join方法将crashData和stopData连接起来。Join方法接受几个参数:
-
第一个数据集(在这种情况下为
crashData)。 -
第二个数据集(在这种情况下为
stopData)。 -
一个 lambda 表达式,用于从第一个数据集中提取值,我们将使用它来进行连接。在这种情况下,元组的第一个元素,即地理位置值。
-
一个 lambda 表达式,用于从第二个数据集中提取值,我们将使用它来进行连接。在这种情况下,元组的第一个元素,即地理位置值。
-
一个 lambda 表达式,指定连接操作输出的样子。在这种情况下,它是我们在这段代码块的第一个语句中定义的名为
GeoTraffic的记录类型。
关于使用 Join 方法的关键一点是要意识到它只保留两个数据集中都存在的记录(对于 SQL 爱好者来说,这是一个内连接)。这意味着如果一个地理位置有一个交通罚单但没有交通停驶,它将从我们的分析中删除。如果你想要进行外连接,有GroupJoin方法可以实现这一点。由于我们真正感兴趣的是高活动区域,因此内连接似乎更合适。
在创建数据框后,我们现在准备将数据发送到 Accord 的 k-means。如果你还记得,Accord 的 k-means 需要输入是一个浮点数的不规则数组。因此,我们有一个最后的转换。转到脚本文件并输入以下内容:
let kmeansInput =
trafficGeo
|> Array.map(fun cs -> [|float cs.CrashCount; float cs.StopCount |])
将其发送到 FSI,我们得到以下结果:
val kmeansInput : float [] [] =
[|[|9.0; 51.0|]; [|16.0; 5.0|]; [|76.0; 2.0|]; [|10.0; 1.0|]; [|80.0; 7.0|];
[|92.0; 27.0|]; [|8.0; 2.0|]; [|104.0; 11.0|]; [|47.0; 4.0|];
[|36.0; 16.0
返回脚本文件,并输入以下内容:
let numberOfClusters = 3
let kmeans = new KMeans(numberOfClusters)
let labels = kmeans.Compute(kmeansInput.ToArray())
kmeans.Clusters.[0]
kmeans.Clusters.[1]
kmeans.Clusters.[2]
将其发送到 REPL,我们将得到以下结果:
val numberOfClusters : int = 3
val kmeans : KMeans
val labels : int [] =
[|1; 1; 0; 1; 0; 0; 1; 0; 0; 1; 0; 0; 0; 1; 1; 0; 1; 1; 0; 0; 0; 2; 1; 0; 1;
2; 0; 2;
哇!我们正在对交通数据进行 k-means 聚类。如果你检查每个簇,你会看到以下内容:
val it : KMeansCluster =
Accord.MachineLearning.KMeansCluster
{Covariance = [[533.856744; 25.86726804]
[25.86726804; 42.23152921]];
Index = 0;
Mean = [|67.50515464; 6.484536082|];
Proportion = 0.1916996047;}
>
val it : KMeansCluster =
Accord.MachineLearning.KMeansCluster
{Covariance = [[108.806009; 8.231942669]
[8.231942669; 16.71306776]];
Index = 1;
Mean = [|11.69170984; 2.624352332|];
Proportion = 0.7628458498;}
>
val it : KMeansCluster =
Accord.MachineLearning.KMeansCluster
{Covariance = [[5816.209486; -141.4980237]
[-141.4980237; 194.4189723]];
Index = 2;
Mean = [|188.8695652; 13.34782609|];
Proportion = 0.04545454545;}
我们有三个簇。我从每个簇中提取了平均值和比例,并将它们放入如下所示的电子表格中:
| 事故 | 停驶 | 记录百分比 |
|---|---|---|
| 67.5 | 6.48 | 20.2% |
| 11.69 | 2.62 | 76.3% |
| 188.87 | 13.35 | 4.5% |
观察所有三个聚类,值得注意的是交通事故比检查次数多得多。同样值得注意的是,第一和第二个聚类的事故与检查的比例大约是 10:1,但真正高事故区域的事故与检查的比例更高——大约 14:1。似乎有理由得出结论,城镇中有几个高事故区域,警察在那里非常活跃,但他们可能更加活跃。我会根据它们的活跃度给每个聚类命名:(低、中、高)。如果地理位置不在我们的数据框中(城镇中的大多数点),我们可以称之为“无活动”。
最后,将以下内容输入到脚本文件中:
let trafficGeo' = Array.zip trafficGeo labels
将这些发送到 FSI 后,我们得到以下结果:
val trafficGeo' : (GeoTraffic * int) [] =
|({Geolocation = {Latitude = 35.79;
Longitude = -78.781;};
CrashCount = 9;
StopCount = 51;}, 1); ({Geolocation = {Latitude = 35.783;
Longitude = -78.781;};
CrashCount = 16;
StopCount = 5;}, 1);
我们之前已经见过.zip格式。我们现在将包含地理位置、停靠次数和事故次数的数据框与通过 k-means 得到的标签框合并。然后我们可以查找一个特定的地理位置并查看其聚类分配。例如,地理位置 35.790/-78.781 位于聚类 1——中等活跃度。
PCA
现在我们已经通过 k-means 对数据有了相当好的了解,让我们看看是否可以使用 PCA 来揭示我们交通数据中的更多洞察。而不是看位置,让我们看看日期。正如我们在[第四章中找到的,“交通检查——走错了方向?”,使用我们的决策树,我们无法从不同时间段的日期/时间与交通罚单中得出任何结论。也许通过增加事故和天气数据到检查数据中会有所帮助。
返回到Clustering.fsx脚本文件,并输入以下内容:
let crashCounts =
trafficCrashes
|> Array.countBy(fun tc -> tc.CrashDateTime.DayOfYear)
将这些发送到 FSI 后,我们得到以下结果:
val crashCounts : (int * int) [] =
[|(364, 10); (365, 3); (1, 2); (2, 3); (3, 12); (4, 5); (5, 3); (6, 1);
(7, 9); (8, 6); (9, 10); (10, 6); (11, 9);
这段代码与我们之前创建 k-means 的crashData时编写的代码非常相似。在这种情况下,我们是通过DayOfYear来统计交通事故的。DayOfYear将每年的每一天分配一个索引值。例如,1 月 1 日得到 1,1 月 2 日得到 2,12 月 31 日得到 365 或 366,这取决于是否是闰年。注意,它是基于 1 的,因为DateTime.DayOfYear是基于 1 的。
返回到脚本文件并输入以下内容:
let stopCounts =
trafficStops
|> Array.countBy(fun ts -> ts.StopDateTime.DayOfYear)
将这些发送到 FSI 后,我们得到以下结果:
val stopCounts : (int * int) [] =
[|(182, 58); (183, 96); (184, 89); (185, 65); (38, 65);
如你所能猜到的,这是按年度天数汇总的交通检查次数。继续前进,进入脚本文件并输入以下内容:
let weatherData' =
dailyWeather
|> Array.map(fun w -> w.WeatherDate.DayOfYear, w.Amount)
将这些发送到 REPL 后,我们得到以下结果:
val weatherData' : (int * int) [] =
[|(9, 41); (10,` 30); (11, 5); (12, 124);
就像事故和检查数据一样,这创建了一个按年度天数汇总的降水量数据集。你会注意到数据已经处于日期级别(有时称为原子级别),所以使用了Array.map来转换日期;我们不需要使用countBy。
在创建了初始数据集之后,我们现在需要一种方法来将这三个数据集合并在一起。我们在 k-means 示例中使用的 Enumerable.Join 方法在这里不适用,因此我们必须构建自己的连接函数。进入脚本文件,输入以下内容:
let getItem dataSet item =
let found = dataSet |> Array.tryFind(fun sd -> fst(sd) = item)
match found with
| Some value -> snd value
| None -> 0
当你将此发送到 FSI 时,你会得到以下结果:
val getItem : dataSet:('a * int) [] -> item:'a -> int when 'a : equality
这是一个相当复杂的函数签名。如果我在方法中添加参数提示,可能会有所帮助,如下面的代码所示:
let getItem (dataSet:(int*int)[], item:int) =
let found = dataSet |> Array.tryFind(fun sd -> fst(sd) = item)
match found with
| Some value -> snd value
| None -> 0
当你将其发送到 FSI 时,你会得到以下结果:
val getItem : dataSet:(int * int) [] * item:int -> int
这应该更容易访问但不太通用,这是可以接受的,因为我们的所有数据集(事故、停车和天气)都是 int*int 的数组。阅读输出,我们看到 getItem 是一个接受一个名为 dataset 的参数的函数,该参数是一个 int 元组的数组 (int * int)[],另一个参数名为 item,也是一个 int。函数随后尝试在数组中找到其 fst 值与项目相同的元组。如果找到了,它返回元组的第二个值。如果没有在数组中找到项目,它返回 0。
这个函数将适用于我们所有的三个数据集(事故、停车和天气),因为这三个数据集只包含有观测记录的日期。对于交通停车来说,这不是问题,因为每年每天都有至少一次交通停车。然而,有 16 天没有记录交通事故,所以 stopData 有 350 条记录,而且有超过 250 天没有降水,所以 weatherData 只有 114 条记录。
由于第一种创建 getItem 的方法更通用且更符合 F# 的习惯用法,我将使用它来完成本章的剩余部分。这两个例子都在你可以下载的示例脚本文件中。
回到脚本中,输入以下内容:
type TrafficDay = {DayNumber:int; CrashCount: int; StopCount: int; RainAmount: int}
let trafficDates =
[|1..366|]
|> Array.map(fun d -> {DayNumber=d;
CrashCount=getItem crashCounts d;
StopCount=getItem stopCounts d;
RainAmount=getItem weatherData' d})
当你将此发送到 REPL 时,你会看到以下内容:
type TrafficDay =
{DayNumber: int;
CrashCount: int;
StopCount: int;
RainAmount: int;}
val trafficDates : TrafficDay [] =
[|{DayNumber = 1;
CrashCount = 2;
StopCount = 49;
RainAmount = 0;}; {DayNumber = 2;
CrashCount = 3;
StopCount = 43;
RainAmount = 0;};
第一行创建了一个包含当天事故、停车和降水的记录类型。我使用“rain”作为字段名,因为我们很少在北卡罗来纳州下雪,我想让任何住在北方的读者都感到这一点。当然,当我们确实下雪时,那几乎就是世界末日。
下一个代码块是我们创建最终数据帧的地方。首先,创建了一个包含全年每一天的整数数组。然后应用了一个映射函数,为数组中的每个项目调用 getItem 三次:第一次为 crashData,第二次为停车数据,最后为天气数据。结果被放入 TrafficDay 记录中。
数据帧设置完成后,我们现在可以为 Accord 准备了。转到脚本文件,输入以下内容:
let pcaInput =
trafficDates
|> Array.map(fun td -> [|float td.CrashCount; float td.StopCount; float td.RainAmount |])
当你将其发送到 REPL 时,你会得到以下结果:
val pcaInput : float [] [] =
[|[|2.0; 49.0; 0.0|]; [|3.0; 43.0; 0.0|]; [|12.0; 52.0; 0.0|];
[|5.0; 102.0; 0.0|];
这是一个 Accord 所需要的锯齿数组。回到脚本中,输入以下内容:
let pca = new PrincipalComponentAnalysis(pcaInput, AnalysisMethod.Center)
pca.Compute()
pca.Transform(pcaInput)
pca.ComponentMatrix
pca.ComponentProportions
当你将此发送到 REPL 时,你会得到以下结果:
val pca : PrincipalComponentAnalysis
val it : unit = ()
>
val it : float [] [] =
[|[|-43.72753865; 26.15506878; -4.671924583|];
val it : float [,] = [[0.00127851745; 0.01016388954; 0.999947529]
[0.01597172498; -0.999821004; 0.01014218229]
[0.9998716265; 0.01595791997; -0.001440623449]]
>
val it : float [] = [|0.9379825626; 0.06122702459; 0.0007904128341|]
>
>
这表明我们数据框中 94%的方差来自事故,而不是停车或天气。这很有趣,因为常识认为,一旦在北卡罗来纳州下雨(或者下雪 ),交通事故就会激增。尽管这可能是一个好的新闻报道,但这一年的样本并没有证实这一点。
分析摘要
我们现在有几个模型指向一些有趣的想法:
-
有几个地点占用了城镇大部分的交通事故和罚单
-
天气并不像你想象的那么重要
拥有这些知识,我们准备好将机器学习应用于实践。
Code-4-Good 应用程序
让我们创建一个帮助人们更安全驾驶的 Windows 应用程序。此外,让我们使应用程序变得“智能”,这样它将逐渐变得更加准确。让我们从 Visual Studio 中已经创建的项目开始。
机器学习组件
进入解决方案****资源管理器,将Library1.fs重命名为TrafficML.fs。添加对System.Data、System.Data.Entity、System.Data.Linq和FSharp.Data.TypeProviders的引用:
添加引用
进入TrafficML.fs文件并输入以下代码:
namespace Hack4Good.Traffic
open System
open System.Linq
open System.Data.Linq
open System.Data.Entity
open Accord.MachineLearning
open System.Collections.Generic
open Accord.Statistics.Analysis
open Microsoft.FSharp.Data.TypeProviders
type Geolocation = {Latitude: float; Longitude: float}
type private EntityConnection = SqlEntityConnection<"data source=nc54a9m5kk.database.windows.net;initial catalog=Traffic;user id=chickenskills@nc54a9m5kk;password=sk1lzm@tter;",Pluralize = true>
type TrafficStop = {StopDateTime: DateTime; Geolocation: Geolocation; DispositionId: int}
type TrafficCrash = {CrashDateTime: DateTime; Geolocation: Geolocation; CrashSeverityId: int; CrashTypeId: int; }
type GeoTraffic = {Geolocation:Geolocation; CrashCount: int; StopCount: int}
type GeoTraffic' = {Geolocation:Geolocation; CrashCount: int; StopCount: int; Cluster: int}
我知道不将你刚刚编写的代码发送到 FSI 感觉有点奇怪,但没有办法立即从可编译文件中获取你编写的代码的反馈。我们将在下一章讨论 TDD 时解决这个问题。在此之前,只需编译项目以确保你走在正确的轨道上。
返回到TrafficML.fs文件,输入以下代码块或从书籍的下载中复制:
type TrafficML(connectionString:string) =
let context = EntityConnection.GetDataContext(connectionString)
let trafficStops =
context.dbo_TrafficStops
|> Seq.map(fun ts -> {StopDateTime = ts.StopDateTime.Value;
Geolocation = {Latitude =Math.Round(ts.Latitude.Value,3);
Longitude=Math.Round(ts.Longitude.Value,3)};
DispositionId = ts.DispositionId.Value})
|> Seq.toArray
let trafficCrashes=
context.dbo_TrafficCrashes
|> Seq.filter(fun tc -> tc.MunicipalityId = Nullable<int>(13))
|> Seq.filter(fun tc -> not (tc.Latitude = Nullable<float>()))
|> Seq.map(fun tc -> {CrashDateTime=tc.CrashDateTime.Value;
Geolocation = {Latitude =Math.Round(tc.Latitude.Value,3);
Longitude=Math.Round(tc.Longitude.Value,3)};
CrashSeverityId=tc.CrashSeverityId.Value;
CrashTypeId =tc.CrashTypeId.Value})
|> Seq.toArray
let stopData =
trafficStops
|> Array.countBy(fun ts -> ts.Geolocation)
let crashData =
trafficCrashes
|> Array.countBy(fun tc -> tc.Geolocation)
let trafficGeo =
Enumerable.Join(crashData, stopData,
(fun crashData -> fst crashData),
(fun stopData -> fst stopData),
(fun crashData stopData -> { GeoTraffic.Geolocation = fst crashData;
StopCount = snd crashData ;
CrashCount = snd stopData }))
|> Seq.toArray
let kmeansInput =
trafficGeo
|> Array.map(fun cs -> [|float cs.CrashCount; float cs.StopCount |])
let numberOfClusters = 3
let kmeans = new KMeans(numberOfClusters)
let labels = kmeans.Compute(kmeansInput.ToArray())
let trafficGeo' = Array.zip trafficGeo labels
|> Array.map(fun (tg,l) -> {Geolocation=tg.Geolocation;CrashCount=tg.CrashCount;StopCount=tg.StopCount;Cluster=l} )
这段代码与我们之前在Clustering.fsx脚本文件中编写的 k-means 代码非常相似。请注意,获取数据、塑造数据和在其上运行 k-means 的所有工作都在TrafficML类型的构造函数中完成。这意味着每次从另一个位置创建类的新的实例时,你都在进行数据库调用并运行模型。此外,请注意,连接字符串被硬编码到SqlEntity类型提供者中,但在调用GetDataContext()时通过构造函数参数传递。这允许你在不同的环境中移动代码(开发/测试/生产)。缺点是您需要始终暴露您的开发环境,以便生成类型。避免这种情况的一种方法是将您的 Entity Framework .edmx/架构硬编码到项目中。
返回到TrafficML.fs文件,并在TrafficML类型中输入以下函数:
member this.GetCluster(latitude: float, longitude: float, distance: float) =
let geolocation = {Latitude=latitude; Longitude=longitude}
let found = trafficGeo'
|> Array.map(fun gt -> gt,(haversine gt.Geolocation geolocation))
|> Array.filter(fun (gt,d) -> d < distance)
|> Array.sortByDescending(fun (gt,d) -> gt.Cluster)
match found.Length with
| 0 -> -1
| _ -> let first = found |> Array.head
let gt = fst first
gt.Cluster
这将搜索地理位置。如果有匹配项,则返回簇。如果没有匹配项,则返回a-1,表示没有匹配项。我们现在有足够的内容来尝试创建一个实时“智能”交通应用程序。
用户界面
在解决方案资源管理器中,添加一个新的 Visual C# WPF 应用程序:
项目创建完成后,将 C# UI 项目添加到 F# 项目中,引用 System.Configuration 和 System.Device:
作为快速预备说明,当你编写 WFP 应用程序时,应该遵循 MVVM 和命令中继模式,这在本书中不会涉及。这是一本关于机器学习的书,而不是通过令人愉悦的 UI 来宠爱人类,所以我只编写足够的 UI 代码,以便使其工作。如果你对遵循最佳实践进行 WPF 开发感兴趣,可以考虑阅读 Windows Presentation Foundation 4.5 Cookbook。
在 UI 项目中,打开 MainWindow.xaml 文件,找到 Grid 元素,并在网格中输入以下 XAML:
<Button x:Name="crashbutton" Content="Crash" Click="notifyButton_Click" HorizontalAlignment="Left" Height="41" Margin="31,115,0,0" VerticalAlignment="Top" Width="123"/>
<Button x:Name="stopButton" Content="Stop" Click="notifyButton_Click" HorizontalAlignment="Left" Height="41" Margin="171,115,0,0" VerticalAlignment="Top" Width="132"/>
<TextBlock x:Name="statusTextBlock" HorizontalAlignment="Left" Height="100" Margin="31,10,0,0" TextWrapping="Wrap" Text="Current Status: No Risk" VerticalAlignment="Top" Width="272"/>
接下来,打开 MainWindow.xaml.cs 文件,并将以下 using 语句添加到文件顶部的 using 块中:
using System.Configuration;
using System.Device.Location;
你的文件应该看起来像下面这样:
在 MainWindow 类内部,输入三个类级别变量:
TrafficML _trafficML = null;
GeoCoordinateWatcher _watcher = null;
String _connectionString = null;
你的文件应该看起来像下面这样:
然后,在 MainWindow() 构造函数中,在 InitializeComponent() 下方添加以下代码:
InitializeComponent();
_connectionString = ConfigurationManager.ConnectionStrings["trafficDatabase"].ConnectionString;
_trafficML = new TrafficML(_connectionString);
_watcher = new GeoCoordinateWatcher(GeoPositionAccuracy.High);
_watcher.PositionChanged += Watcher_PositionChanged;
bool started = this._watcher.TryStart(false, TimeSpan.FromMilliseconds(2000));
StartUpdateLoop();
你的文件应该看起来像这样:
接下来,为事件处理器创建 Watcher_PositionChanged 方法:
private void Watcher_PositionChanged(object sender, GeoPositionChangedEventArgs<GeoCoordinate> e)
{
var location = e.Position.Location;
var latitude = Double.Parse(location.Latitude.ToString("00.000"));
var longitude = Double.Parse(location.Longitude.ToString("00.000"));
var cluster = _trafficML.GetCluster(latitude, longitude);
var status = "No Risk";
switch(cluster)
{
case 0:
status = "Low Risk";
break;
case 1:
status = "Medium Risk";
break;
case 2:
status = "High Risk";
break;
default:
status = "No Risk";
break;
}
this.statusTextBlock.Text = "Current Status: " + status;
}
接下来,创建一个循环,每分钟刷新一次 MachineLearning 模型:
private async Task StartUpdateLoop()
{
while (true)
{
await Task.Delay(TimeSpan.FromMinutes(1.0));
_trafficML = await Task.Run(() => new TrafficML(_connectionString));
}
}
最后,为屏幕上的按钮点击添加事件处理器占位符:
private void notifyButton_Click(object sender, RoutedEventArgs e)
{
//TODO
}
如果你将代码折叠到定义中(CTRL + M, L),你的代码应该看起来如下:
接下来,进入 解决方案资源管理器,右键单击以添加一个新的 应用程序配置 文件:
添加新的应用程序配置文件
在那个 app.config 文件中,将内容替换为以下 XML(如果你使用的是本地数据库实例,请将连接字符串替换为你的连接字符串):
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
<startup>
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.5.2" />
</startup>
<connectionStrings>
<add name="trafficDatabase"
connectionString="data source=nc54a9m5kk.database.windows.net;initial catalog=Traffic;
user id=chickenskills@nc54a9m5kk;password=sk1lzm@tter;" />
</connectionStrings>
</configuration>
前往 解决方案资源管理器 并将 UI 项目设置为启动项目:
编译你的项目。如果一切顺利,尝试运行它。你应该会得到一个警告对话框,如下所示:
然后你会看到一个类似这样的屏幕:
一旦你完全领略了用户体验的奇妙,停止运行应用程序。到目前为止,这已经相当不错了。如果我们把这个应用程序放在车载的位置感知设备(如 GPS)上并四处驾驶,状态栏会警告我们是否在可能发生事故或停车风险的地理位置四分之一英里范围内。然而,如果我们想给自己更多的提醒,我们需要添加更多的代码。
添加距离计算
返回 F# 项目并打开 TrafficML.fs 文件。找到构造函数的最后一行。代码看起来如下:
let trafficGeo' = Array.zip trafficGeo labels
|> Array.map(fun (tg,l) -> {Geolocation=tg.Geolocation;CrashCount=tg.CrashCount;StopCount=tg.StopCount;Cluster=l} )
在此行下方,输入以下内容:
let toRadian x = (Math.PI/180.0) * x
let haversine x y =
let dlon = toRadian (x.Longitude - y.Longitude)
let dLat = toRadian (x.Latitude - y.Latitude)
let a0 = pown (Math.Sin(dLat/2.0)) 2
let a1 = Math.Cos(toRadian(x.Latitude)) * Math.Cos(toRadian(y.Latitude))
let a2 = pown (Math.Sin(dlon/2.0)) 2
let a = a0 + a1 * a2
let c = 2.0 * Math.Atan2(sqrt(a),sqrt(1.0-a))
let R = 3956.0
R * c
这两个函数允许我们计算地理位置之间的距离。由于地球是弯曲的,我们不能简单地从两个地理位置的纬度和经度中减去。Haversine 公式是进行这种计算最常见的方法。
前往文件末尾并添加以下内容:
member this.GetCluster(latitude: float, longitude: float, distance: float) =
let geolocation = {Latitude=latitude; Longitude=longitude}
let found = trafficGeo' |> Array.map(fun gt -> gt,(haversine gt.Geolocation geolocation))
|> Array.filter(fun (gt,d) -> d < distance)
|> Array.sortByDescending(fun (gt,d) -> gt.Cluster)
match found.Length with
| 0 -> -1
| _ -> let first = found |> Array.head
let gt = fst first
gt.Cluster
我们正在做的是通过一个额外的参数distance来重载GetCluster函数。使用这个输入距离,我们可以计算出地理位置参数和我们的trafficGeo数组中的每个地理位置之间的距离。如果有任何匹配项,我们按簇数量最多的进行排序(sortByDescending),然后返回它。
返回我们的用户界面项目,打开MainWindow.xaml.cs文件,找到Watcher_PositionChanged方法。找到以下代码行:
var cluster = _trafficML.GetCluster(latitude, longitude);
替换为以下代码行:
var cluster = _trafficML.GetCluster(latitude, longitude, 2.0);
我们现在对道路上的任何问题区域都有了两英里的预警。
增加人工观察
我们还想对我们的用户界面做一些改动。如果你看看一些像 Waze 这样的众包道路应用,它们提供实时通知。我们的应用基于历史数据来进行分类。然而,如果我们正在一个被归类为低风险的地区街道上驾驶,并且我们看到一起交通事故,我们希望将这个位置提升到高风险。理想情况下,我们应用的所有用户都能收到这个更新,并覆盖模型对地理位置的分类(至少暂时如此),然后我们会更新我们的数据库,以便在我们重新训练模型时,信息变得更加准确。
前往notifyButton_Click事件处理程序,将//TODO替换为以下内容:
var location = _watcher.Position.Location;
var latitude = Double.Parse(location.Latitude.ToString("00.000"));
var longitude = Double.Parse(location.Longitude.ToString("00.000"));
_trafficML.AddGeolocationToClusterOverride(latitude, longitude);
编译器会向你抱怨,因为我们还没有实现AddGeolocationToClusterOverride。回到 F#项目,打开TrafficML.fs文件。在文件的底部,添加以下内容:
member this.AddGeolocationToClusterOverride(latitude: float, longitude: float) =
let clusterOverride = EntityConnection.ServiceTypes.dbo_ClusterOverride()
clusterOverride.Latitude <- latitude
clusterOverride.Longitude <- longitude
clusterOverride.Cluster <- 2
clusterOverride.OverrideDateTime <- DateTime.UtcNow
context.dbo_ClusterOverride.AddObject(clusterOverride)
context.DataContext.SaveChanges() |> ignore
我们现在有一种方法可以更新任何覆盖的数据库。请注意,你将无法写入为这本书创建的 Azure 上的共享数据库,但你将能够写入你的本地副本。作为最后一步,回到我们创建trafficGeo的以下行:
let trafficGeo' = Array.zip trafficGeo labels
|> Array.map(fun (tg,l) -> {Geolocation=tg.Geolocation;CrashCount=tg.CrashCount;StopCount=tg.StopCount;Cluster=l} )
将该行替换为以下代码块:
let overrides = context.dbo_ClusterOverride
|> Seq.filter(fun co -> (DateTime.UtcNow - co.OverrideDateTime) > TimeSpan(0,5,0))
|> Seq.toArray
let checkForOverride (geoTraffic:GeoTraffic') =
let found = overrides
|> Array.tryFind(fun o -> o.Latitude = geoTraffic.Geolocation.Latitude &&
o.Longitude = geoTraffic.Geolocation.Longitude)
match found.IsSome with
| true -> {Geolocation=geoTraffic.Geolocation;
CrashCount=geoTraffic.CrashCount;
StopCount=geoTraffic.StopCount;
Cluster=found.Value.Cluster}
| false -> geoTraffic
let trafficGeo' = Array.zip trafficGeo labels
|> Array.map(fun (tg,l) -> {Geolocation=tg.Geolocation;
CrashCount=tg.CrashCount;
StopCount=tg.StopCount;
Cluster=l} )
|> Array.map(fun gt -> checkForOverride(gt))
此块将数据传输到数据库,并拉取过去 5 分钟内发生的所有覆盖项,并将它们放入覆盖数组中。然后,它创建了一个名为checkForOverride的函数,该函数接受geoTraffic值。如果纬度和经度与覆盖表匹配,则将geoTraffic值替换为数据库分配的新值,而不是来自 k-means 模型的新值。如果没有找到匹配项,则返回原始值。最后,我们将此函数管道到trafficGeo的创建中。请注意,如果您尝试在我们的共享服务器上执行此操作,它将抛出一个异常,因为您没有权限写入数据库。然而,通过这个例子,希望意图是清晰的。有了这个,我们有一个实时系统,它结合了机器学习和人类观察,为我们最终用户提供最佳的可能预测。
摘要
在本章中,我们涵盖了大量的内容。我们探讨了 k-means 和 PCA 算法,以帮助我们找到交通数据集中的隐藏关系。然后,我们构建了一个应用程序,利用我们获得的洞察力使驾驶员更加警觉,并希望他们能更安全。这个应用程序的独特之处在于它结合了实时机器学习建模和人类观察,为驾驶员提供最佳的可能结果。
在下一章中,我们将探讨我们在这本书中迄今为止的编码的一些局限性,并看看我们是否可以改进模型和特征选择。
第八章。特征选择和优化
在软件工程中,有一句古老的谚语:先让它工作,再让它变快。在这本书中,我们采用了先让它运行,再让它变得更好的策略。我们在第一章中讨论的许多模型在非常有限的意义上是正确的,并且可以通过一些优化来使它们更加正确。这一章完全是关于让它变得更好的。
清洗数据
正如我们在第五章中看到的,时间到 – 获取数据,使用 F#类型提供者获取和塑造数据(这通常是许多项目中的最大问题)非常简单。然而,一旦我们的数据本地化和塑形,我们为机器学习准备数据的工作还没有完成。每个数据帧可能仍然存在异常。像空值、空值和超出合理范围的数据值等问题需要解决。如果你来自 R 背景,你将熟悉null.omit和na.omit,它们会从数据框中删除所有行。我们可以在 F#中通过应用一个过滤函数来实现功能等价。在过滤中,如果你是引用类型,可以搜索空值,如果是可选类型,则使用.isNone。虽然这很有效,但它有点像一把钝锤,因为你正在丢弃可能在其他字段中有有效值的行。
处理缺失数据的另一种方法是用一个不会扭曲分析的值来替换它。像数据科学中的大多数事情一样,关于不同技术的意见有很多,这里我不会过多详细说明。相反,我想让你意识到这个问题,并展示一种常见的补救方法:
进入 Visual Studio,创建一个名为FeatureCleaning的 Visual F# Windows 库项目:
在解决方案资源管理器中定位Script1.fsx并将其重命名为CleanData.fsx:
打开那个脚本文件,并用以下代码替换现有的代码:
type User = {Id: int; FirstName: string; LastName: string; Age: float}
let users = [|{Id=1; FirstName="Jim"; LastName="Jones"; Age=25.5};
{Id=2; FirstName="Joe"; LastName="Smith"; Age=10.25};
{Id=3; FirstName="Sally"; LastName="Price"; Age=1000.0};|]
将其发送到 FSI 后,我们得到以下结果:
type User =
{Id: int;
FirstName: string;
LastName: string;
Age: float;}
val users : User [] = [|{Id = 1;
FirstName = "Jim";
LastName = "Jones";
Age = 25.5;}; {Id = 2;
FirstName = "Joe";
LastName = "Smith";
Age = 10.25;}; {Id = 3;
FirstName
= "Sally";
LastName = "Price";
Age = 1000.0;}|]
User是一种记录类型,代表应用程序的用户,而users是一个包含三个用户的数组。它看起来相当普通,除了用户 3,莎莉·普莱斯,她的年龄为1000.0。我们想要做的是去掉这个年龄,但仍然保留莎莉的记录。要做到这一点,让我们将 1,000 替换为所有剩余用户年龄的平均值。回到脚本文件,输入以下内容:
let validUsers = Array.filter(fun u -> u.Age < 100.0) users
let averageAge = Array.averageBy(fun u -> u.Age) validUsers
let invalidUsers =
users
|> Array.filter(fun u -> u.Age >= 100.0)
|> Array.map(fun u -> {u with Age = averageAge})
let users' = Array.concat [validUsers; invalidUsers]
将其发送到 FSI 应该得到以下结果:
val averageAge : float = 17.875
val invalidUsers : User [] = [|{Id = 3;
FirstName = "Sally";
LastName = "Price";
Age = 17.875;}|]
val users' : User [] = [|{Id = 1;
FirstName = "Jim";
LastName = "Jones";
Age = 25.5;}; {Id = 2;
FirstName = "Joe";
LastName = "Smith";
Age = 10.25;}; {Id = 3;
FirstName = "Sally";
LastName = "Price";
Age = 17.875;}|]
注意,我们创建了一个有效用户的子数组,然后获取他们的平均年龄。然后我们创建了一个无效用户的子数组,并映射平均年龄。由于 F#不喜欢可变性,我们为每个无效用户创建了一个新记录,并有效地使用with语法,创建了一个具有所有相同值的新记录,除了年龄。然后我们通过将有效用户和更新的用户合并成一个单一数组来完成。尽管这是一种相当基础的技术,但它可以非常有效。当你进一步学习机器学习时,你会发展和完善自己的处理无效数据的技术——你必须记住,你使用的模型将决定你如何清理这些数据。在某些模型中,取平均值可能会使事情变得混乱。
选择数据
当我们面对大量独立变量时,我们常常会遇到选择哪些值的问题。此外,变量可能被分类、与其他变量结合或改变——这些都可能使某个模型成功或失败。
共线性
共线性是指我们有多多个彼此高度相关的x变量;它们有高度的关联度。在使用回归时,你总是要警惕共线性,因为你不能确定哪个个别变量真正影响了结果变量。这里有一个经典例子。假设你想测量大学生的幸福感。你有以下输入变量:年龄、性别、用于啤酒的可用资金、用于教科书的可用资金。在这种情况下,用于啤酒的可用资金和用于教科书的可用资金之间存在直接关系。用于教科书的资金越多,用于啤酒的资金就越少。为了解决共线性问题,你可以做几件事情:
-
删除一个高度相关的变量。在这种情况下,可能需要删除用于教科书的可用资金。
-
将相关的变量合并成一个单一变量。在这种情况下,可能只需要有一个检查账户中的金钱类别。
测试共线性的一个常见方法是多次运行你的多元回归,每次移除一个x变量。如果移除两个不同变量时没有显著变化,它们就是共线性的良好候选者。此外,你总是可以扫描x变量的相关矩阵,你可以使用 Accord.Net 的Tools.Corrlelation方法来做这件事。让我们看看这个。回到 Visual Studio,添加一个名为Accord.fsx的新脚本文件。打开 NuGet 包管理器控制台,并添加 Accord:
PM> install-package Accord.Statistics
Next, go into the script file and enter this:
#r "../packages/Accord.Math.3.0.2/lib/net40/Accord.Math.dll"
#r "../packages/Accord.Statistics.3.0.2/lib/net40/Accord.Statistics.dll"
open Accord.Statistics
//Age
//Sex - 1 or 0
//money for textbooks
//money for beer
let matrix = array2D [ [ 19.0;1.0;50.0;10.0];
[18.0;0.0;40.0;15.0];
[21.0;1.0;10.0;40.0]]
let correlation = Tools.Correlation(matrix)
这代表了我们采访的三名学生。我们询问了他们的年龄、性别、用于教科书的资金和用于啤酒的资金。第一位学生是 19 岁的女性,有 50.00 美元用于教科书,10.00 美元用于啤酒。
当你将此发送到 FSI 时,你会得到以下结果:
val correlation : float [,] =
[[1.0; 0.755928946; -0.8386278694; 0.8824975033]
[0.755928946; 1.0; -0.2773500981; 0.3592106041]
[-0.8386278694; -0.2773500981; 1.0; -0.9962709628]
[0.8824975033; 0.3592106041; -0.9962709628; 1.0]]
这有点难以阅读,所以我重新格式化了它:
| 年龄 | 性别 | $ 书籍 | $ 啤酒 | |
|---|---|---|---|---|
| 年龄 | 1.0 | 0.76 | -0.84 | 0.88 |
| 性别 | 0.76 | 1.0 | -0.28 | 0.35 |
| $ 书籍 | -0.84 | -0.28 | 1.0 | -0.99 |
| $ 啤酒 | 0.88 | 0.35 | -0.99 | 1.0 |
注意矩阵中的对角线值,1.0,这意味着年龄与年龄完全相关,性别与性别完全相关,等等。从这个例子中,关键的一点是书籍金额和啤酒金额之间存在几乎完美的负相关:它是 -0.99。这意味着如果你为书籍有更多的钱,那么你为啤酒的钱就会更少,这是有道理的。通过阅读相关矩阵,你可以快速了解哪些变量是相关的,可能可以被移除。
与多重共线性相关的一个话题是始终尽可能使你的 y 变量与 x 变量独立。例如,如果你做了一个回归,试图挑选出我们学生的啤酒可用金额,你不会选择任何与该学生拥有的金额相关的独立变量。为什么?因为它们测量的是同一件事。
特征选择
与多重共线性相关的一个话题是特征选择。如果你有一堆 x 变量,你如何决定哪些变量最适合你的分析?你可以开始挑选,但这很耗时,可能还会出错。与其猜测,不如有一些建模技术可以在所有数据上运行模拟,以确定最佳的 x 变量组合。其中最常见的技术之一被称为前向选择逐步回归。考虑一个包含五个自变量和一个因变量的数据框:
使用前向选择逐步回归,该技术从单个变量开始,运行回归,并计算(在这种情况下)均方根误差(rmse):
接下来,技术会回过头来添加另一个变量并计算 rmse:
接下来,技术会回过头来添加另一个变量并计算 rmse:
到现在为止,你可能已经明白了。根据实现方式,逐步回归可能会用不同的自变量组合和/或不同的测试和训练集重新运行。当逐步回归完成后,你就可以对哪些特征是重要的以及哪些可以被舍弃有一个很好的了解。
让我们来看看 Accord 中的逐步回归示例。回到你的脚本中,输入以下代码(注意,这直接来自 Accord 帮助文件中的逐步回归部分):
#r "../packages/Accord.3.0.2/lib/net40/Accord.dll"
open Accord.Statistics.Analysis
//Age/Smoking
let inputs = [|[|55.0;0.0|];[|28.0;0.0|];
[|65.0;1.0|];[|46.0;0.0|];
[|86.0;1.0|];[|56.0;1.0|];
[|85.0;0.0|];[|33.0;0.0|];
[|21.0;1.0|];[|42.0;1.0|];
[|33.0;0.0|];[|20.0;1.0|];
[|43.0;1.0|];[|31.0;1.0|];
[|22.0;1.0|];[|43.0;1.0|];
[|46.0;0.0|];[|86.0;1.0|];
[|56.0;1.0|];[|55.0;0.0|];|]
//Have Cancer
let output = [|0.0;0.0;0.0;1.0;1.0;1.0;0.0;0.0;0.0;1.0;
0.0;1.0;1.0;1.0;1.0;1.0;0.0;1.0;1.0;0.0|]
let regression =
StepwiseLogisticRegressionAnalysis(inputs, output, [|"Age";"Smoking"|],"Cancer")
将其发送到 FSI,得到以下结果:
val inputs : float [] [] =
[|[|55.0; 0.0|]; [|28.0; 0.0|]; [|65.0; 1.0|]; [|46.0; 0.0|]; [|86.0; 1.0|];
[|56.0; 1.0|]; [|85.0; 0.0|]; [|33.0; 0.0|]; [|21.0; 1.0|]; [|42.0; 1.0|];
[|33.0; 0.0|]; [|20.0; 1.0|]; [|43.0; 1.0|]; [|31.0; 1.0|]; [|22.0; 1.0|];
[|43.0; 1.0|]; [|46.0; 0.0|]; [|86.0; 1.0|]; [|56.0; 1.0|]; [|55.0; 0.0|]|]
val output : float [] =
[|0.0; 0.0; 0.0; 1.0; 1.0; 1.0; 0.0; 0.0; 0.0; 1.0; 0.0; 1.0; 1.0; 1.0; 1.0;
1.0; 0.0; 1.0; 1.0; 0.0|]
val regression : StepwiseLogisticRegressionAnalysis
如代码中的注释所示,输入的是 20 个最近接受癌症筛查的虚构人物。特征包括他们的年龄和是否吸烟。输出是这个人是否真的患有癌症。
回到脚本中,添加以下内容:
let results = regression.Compute()
let full = regression.Complete;
let best = regression.Current;
full.Coefficients
best.Coefficients
当你将这个数据发送到 FSI 时,你会看到一些非常有趣的东西。full.Coefficients返回所有变量,但best.Coefficients返回以下内容:
val it : NestedLogisticCoefficientCollection =
seq
[Accord.Statistics.Analysis.NestedLogisticCoefficient
{Confidence = 0.0175962716285245, 1.1598020423839;
ConfidenceLower = 0.01759627163;
ConfidenceUpper = 1.159802042;
LikelihoodRatio = null;
Name = "Intercept";
OddsRatio = 0.1428572426;
StandardError = 1.068502877;
Value = -1.945909451;
Wald = 0.0685832853132018;};
Accord.Statistics.Analysis.NestedLogisticCoefficient
{Confidence = 2.63490696729824, 464.911388747606;
ConfidenceLower = 2.634906967;
ConfidenceUpper = 464.9113887;
LikelihoodRatio = null;
Name = "Smoking";
OddsRatio = 34.99997511;
StandardError = 1.319709922;
Value = 3.55534735;
Wald = 0.00705923290736891;}]
现在,你可以看到吸烟是预测癌症时最重要的变量。如果有两个或更多变量被认为是重要的,Accord 会告诉你第一个变量,然后是下一个,依此类推。逐步回归分析在当今社区已经转向 Lasso 和其他一些技术的情况下,有点过时了。然而,它仍然是你的工具箱中的一个重要工具,并且是你应该了解的内容。
归一化
有时候,通过调整数据,我们可以提高模型的效果。我说的不是在安然会计或美国政治家意义上的“调整数字”。我指的是使用一些标准的科学技术来调整数据,这些技术可能会提高模型的准确性。这个过程的通用术语是归一化。
数据归一化的方法有很多种。我想向您展示两种与回归分析效果良好的常见方法。首先,如果你的数据是聚集在一起的,你可以对数值取对数,以帮助揭示可能被隐藏的关系。例如,看看我们来自第二章的开头的产品评论散点图,AdventureWorks 回归。注意,大多数订单数量集中在 250 到 1,000 之间。
通过对订单数量取对数并执行类似的散点图,你可以更清楚地看到关系:
注意,取对数通常不会改变因变量和自变量之间的关系,因此你可以在回归分析中安全地用它来替换自然值。
如果你回到第二章的解决方案,AdventureWorks 回归,你可以打开回归项目并添加一个名为Accord.Net4.fsx的新文件。将Accord.Net2.fsx中的内容复制并粘贴进来。接下来,用以下代码替换数据读取器的代码行:
while reader.Read() do
productInfos.Add({ProductID=reader.GetInt32(0);
AvgOrders=(float)(reader.GetDecimal(1));
AvgReviews=log((float)(reader.GetDecimal(2)));
ListPrice=(float)(reader.GetDecimal(3));})
将此发送到 REPL,我们得到以下结果:
val regression : MultipleLinearRegression =
y(x0, x1) = 35.4805245757214*x0 + -0.000897944878777119*x1 + -36.7106228824185
val error : float = 687.122625
val a : float = 35.48052458
val b : float = -0.0008979448788
val c : float = -36.71062288
val mse : float = 7.083738402
val rmse : float = 2.661529335
val r2 : float = 0.3490097415
注意变化。我们正在对x变量取对数。同时,注意我们的r2略有下降。原因是尽管对数没有改变AvgReviews之间的关系,但它确实影响了它与其他x变量以及可能y变量的关系。你可以看到,在这种情况下,它并没有做什么。
除了使用对数,我们还可以修剪异常值。回到我们的图表,你注意到那个平均订单数量为 2.2、平均评论为 3.90 的孤独点吗?
观察所有其他数据点,我们预计平均评论为 3.90 应该至少有 2.75 的平均订单数量。尽管我们可能想深入了解以找出问题所在,但我们将其留到另一天。现在,它实际上正在破坏我们的模型。确实,回归分析的最大批评之一就是它们对异常值过于敏感。让我们看看一个简单的例子。转到第二章,AdventureWorks 回归回归项目,创建一个新的脚本,命名为Accord5.fsx。将Accord1.fsx中的代码的第一部分复制到其中:
#r "../packages/Accord.3.0.2/lib/net40/Accord.dll"
#r "../packages/Accord.Statistics.3.0.2/lib/net40/Accord.Statistics.dll"
#r "../packages/Accord.Math.3.0.2/lib/net40/Accord.Math.dll"
open Accord
open Accord.Statistics.Models.Regression.Linear
let xs = [| [|15.0;130.0|];[|18.0;127.0|];[|15.0;128.0|]; [|17.0;120.0|];[|16.0;115.0|] |]
let y = [|3.6;3.5;3.8;3.4;2.6|]
let regression = MultipleLinearRegression(2,true)
let error = regression.Regress(xs,y)
let a = regression.Coefficients.[0]
let b = regression.Coefficients.[1]
let sse = regression.Regress(xs, y)
let mse = sse/float xs.Length
let rmse = sqrt(mse)
let r2 = regression.CoefficientOfDetermination(xs,y)
接下来,让我们加入一个对学校感到无聊的天才儿童,他的平均绩点(GPA)很低。加入一个 10 岁的学生,智商(IQ)为 150,平均绩点(GPA)为 1.0:
let xs = [| [|15.0;130.0|];[|18.0;127.0|];[|15.0;128.0|]; [|17.0;120.0|];[|16.0;115.0|];[|10.0;150.0|] |]
let y = [|3.6;3.5;3.8;3.4;2.6;1.0|]
将整个脚本发送到 REPL,我们得到以下结果:
val regression : MultipleLinearRegression =
y(x0, x1) = 0.351124295971452*x0 + 0.0120748957392838*x1 + -3.89166344210844
val error : float = 1.882392837
val a : float = 0.351124296
val b : float = 0.01207489574
val sse : float = 1.882392837
val mse : float = 0.3137321395
val rmse : float = 0.5601179693
val r2 : float = 0.6619468116
注意我们的模型发生了什么。我们的r2从 0.79 降至 0.66,我们的均方根误差(rmse)从 0.18 升至 0.56!天哪,这是多么戏剧性的变化!正如你所猜到的,你如何处理异常值将对你的模型产生重大影响。如果模型的目的是预测大多数学生的平均绩点,我们可以安全地移除这个异常值,因为它并不典型。另一种处理异常值的方法是使用一个在处理它们方面做得更好的模型。
在掌握这些知识后,让我们用实际数据来尝试。添加一个新的脚本文件,并将其命名为AccordDotNet6.fsx。将AccordDotNet2.fsx中的所有内容复制并粘贴到其中。接下来,找到以下这些行:
while reader.Read() do
productInfos.Add({ProductID=reader.GetInt32(0);
AvgOrders=(float)(reader.GetDecimal(1));
AvgReviews=(float)(reader.GetDecimal(2));
ListPrice=(float)(reader.GetDecimal(3));})
let xs = productInfos |> Seq.map(fun pi -> [|pi.AvgReviews; pi.ListPrice|]) |> Seq.toArray
let y = productInfos |> Seq.map(fun pi -> pi.AvgOrders) |> Seq.toArray
And replace them with these:
while reader.Read() do
productInfos.Add({ProductID=reader.GetInt32(0);
AvgOrders=(float)(reader.GetDecimal(1));
AvgReviews=(float)(reader.GetDecimal(2));
ListPrice=(float)(reader.GetDecimal(3));})
let productInfos' = productInfos |> Seq.filter(fun pi -> pi.ProductID <> 757)
let xs = productInfos' |> Seq.map(fun pi -> [|pi.AvgReviews; pi.ListPrice|]) |> Seq.toArray
let y = productInfos' |> Seq.map(fun pi -> pi.AvgOrders) |> Seq.toArray
将这些内容发送到 REPL,我们得到以下结果:
val regression : MultipleLinearRegression =
y(x0, x1) = 9.89805316193142*x0 + -0.000944004141999501*x1 + -26.8922595356297
val error : float = 647.4688586
val a : float = 9.898053162
val b : float = -0.000944004142
val c : float = -26.89225954
val mse : float = 6.744467277
val rmse : float = 2.59701122
val r2 : float = 0.3743706412
r2从 0.35 升至 0.37,我们的 rmse 从 2.65 降至 2.59。删除一个数据点就有如此大的改进!如果你愿意,可以将这个更改应用到 AdventureWorks 项目中。我不会带你走这一步,但现在你有了独立完成它的技能。删除异常值是使回归分析更准确的一种非常强大的方法,但这也存在代价。在我们开始从模型中删除不起作用的数据元素之前,我们必须做出一些判断。事实上,有一些教科书专门讨论处理异常值和缺失数据的科学。在这本书中,我们不会深入探讨这个问题,只是承认这个问题存在,并建议你在删除元素时使用一些常识。
缩放
我想承认关于归一化和度量单位的一个常见误解。你可能会注意到,在第二章,AdventureWorks 回归和第三章中,不同的 x 变量具有显著不同的度量单位。在我们的示例中,客户评论的单位是 1-5 的评分,自行车的价格是 0-10,000 美元。你可能会认为比较这样大的数字范围会对模型产生不利影响。不深入细节,你可以放心,回归对不同的度量单位具有免疫力。
然而,其他模型(尤其是分类和聚类模型,如 k-NN、k-means 和 PCA)会受到 影响。当我们创建这些类型的模型在第六章和第七章中时,Traffic Stops and Crash Locations – When Two Datasets Are Better Than One,我们面临的风险是得到错误的结果,因为数据没有缩放。幸运的是,我们选择的功能和使用的库(Numl.net 和 Accord)帮助我们摆脱了困境。Numl.NET 自动对所有分类模型中的输入变量进行缩放。根据模型类型,Accord 可能会为你进行缩放。例如,在我们在第七章中编写的 PCA 中,我们在这行代码中传递了一个名为 AnalysisMethod.Center 的输入参数:
let pca = new PrincipalComponentAnalysis(pcaInput.ToArray(), AnalysisMethod.Center)
这将输入变量缩放到平均值,这对我们的分析来说已经足够好了。当我们使用 Accord 在第六章中执行 k-NN 时,AdventureWorks Redux – k-NN 和朴素贝叶斯分类器,我们没有缩放数据,因为我们的两个输入变量是分类变量(MartialStatus 和 Gender),只有两种可能性(已婚或未婚,男或女),并且你只需要缩放连续变量或具有两个以上值的分类变量。如果我们使用了连续变量或三个因子的分类变量在 k-NN 中,我们就必须对其进行缩放。
让我们通过一个使用 Accord 的快速缩放示例来了解一下。打开本章的 FeatureCleaning 解决方案,并添加一个名为 AccordKNN 的新脚本文件:
进入 NuGet 包管理器控制台,输入以下内容:
PM> install-package Accord.MachineLearning
进入AccordKNN.fsx文件,并添加我们在第六章中使用的代码,AdventureWorks Redux – k-NN 和朴素贝叶斯分类器,供那些学习和喝酒的学生使用:
#r "../packages/Accord.3.0.2/lib/net40/Accord.dll"
#r "../packages/Accord.Math.3.0.2/lib/net40/Accord.Math.dll"
#r "../packages/Accord.Statistics.3.0.2/lib/net40/Accord.Statistics.dll"
#r "../packages/Accord.MachineLearning.3.0.2/lib/net40/Accord.MachineLearning.dll"
open Accord
open Accord.Math
open Accord.MachineLearning
open Accord.Statistics.Analysis
let inputs = [|[|5.0;1.0|];[|4.5;1.5|];
[|5.1;0.75|];[|1.0;3.5|];
[|0.5;4.0|];[|1.25;4.0|]|]
let outputs = [|1;1;1;0;0;0|]
let classes = 2
let k = 3
let knn = KNearestNeighbors(k, classes, inputs, outputs)
let input = [|5.0;0.5|]
let output = knn.Compute(input)
现在,让我们将数据缩放,使得学习和喝酒相当。我们将采用最简单的缩放方法,称为均值缩放。回到脚本中,输入以下内容:
let studyingAverage = inputs |> Array.map(fun i -> i.[0]) |> Array.average
let drinkingAverage = inputs |> Array.map(fun i -> i.[1]) |> Array.average
let scaledInputs = inputs |> Array.map(fun i -> [|i.[0]/studyingAverage; i.[1]/drinkingAverage|])
let scaledKNN = KNearestNeighbors(k, classes, scaledInputs, outputs)
当你将其发送到 REPL 时,你会看到以下内容:
val studyingAverage : float = 2.891666667
val drinkingAverage : float = 2.458333333
val scaledInputs : float [] [] =
[|[|1.729106628; 0.406779661|]; [|1.556195965; 0.6101694915|];
[|1.763688761; 0.3050847458|]; [|0.3458213256; 1.423728814|];
[|0.1729106628; 1.627118644|]; [|0.4322766571; 1.627118644|]|]
val scaledKNN : KNearestNeighbors
注意,输入现在相对于它们的平均值。那个学习了五小时并喝了一杯啤酒的人现在比平均水平多学习了 73%,比平均水平少喝了 41%。这个 k-NN 模型现在已缩放,当实际应用时将提供更好的“苹果对苹果”的比较。
过度拟合与交叉验证
如果你记得第 2、3 和 4 章,我们在构建模型时遇到的一个问题是过度拟合。过度拟合,预测分析的祸害,是指当我们构建一个在历史数据上做得很好的模型,但在引入新数据时却崩溃的现象。这种现象不仅限于数据科学;在我们的社会中发生得很多:职业运动员获得丰厚的合同,却未能达到先前的表现;基金经理因为去年的表现而获得大幅加薪,等等。
交叉验证 – 训练与测试
与从不学习的洋基队不同,我们的职业从错误中吸取了教训,并拥有一个伟大的、尽管不完美的工具来对抗过拟合。我们使用训练/测试/评估的方法构建多个模型,然后选择最佳模型,而不是基于它对现有数据集的表现,而是基于它对之前未见过的数据的表现。为了实现这一点,我们取我们的源数据,导入它,清理它,并将其分成两个子集:训练和测试。然后我们在训练集上构建我们的模型,如果它看起来可行,就将测试数据应用到模型上。如果模型仍然有效,我们可以考虑将其推向生产。这可以用以下图形表示:
但我们还可以添加一个额外的步骤。我们可以将数据分割多次,并构建新的模型进行验证。实际分割数据集本身就是一门科学,但通常每次将基本数据集分割成训练和测试子集时,记录都是随机选择的。这意味着如果你将基本数据分割五次,你将拥有五个完全不同的训练和测试子集:
这种技术可能比实际模型选择更重要。Accord 和 Numl 在底层都进行了一些分割,在这本书中,我们将相信它们正在做好这项工作。然而,一旦你开始在野外工作模型,你将希望在每个项目上投入一定的时间进行交叉验证。
交叉验证 – 随机和平均测试
回到我们关于学生学习和喝酒的 k-NN 示例,我们如何知道我们是否预测准确?如果我们想猜测一个学生是否通过,我们只需抛硬币:正面通过,反面失败。我们分析中的假设是,学习的小时数和喝的啤酒数对考试成绩有一定的影响。如果我们的模型不如抛硬币,那么它不是一个值得使用的模型。打开 Visual Studio 并回到AccordKNN.fsx文件。在底部,输入以下代码:
let students = [|0..5|]
let random = System.Random()
let randomPrediction =
students
|> Array.map(fun s -> random.Next(0,2))
将这些发送到 FSI,我们得到以下结果(你的结果将不同):
val students : int [] = [|0; 1; 2; 3; 4; 5|]
val random : System.Random
val randomPrediction : int [] = [|0; 1; 0; 0; 1; 1|]
现在,让我们输入有关每个学生的信息:他们学习的小时数和他们喝的啤酒数,并运行未缩放的 k-NN:
let testInputs = [|[|5.0;1.0|];[|4.0;1.0|];
[|6.2;0.5|];[|0.0;2.0|];
[|0.5;4.0|];[|3.0;6.0|]|]
let knnPrediction =
testInputs
|> Array.map(fun ti -> knn.Compute(ti))
将这些发送到 REPL,我们得到以下结果:
val testInputs : float [] [] =
[|[|5.0; 1.0|]; [|4.0; 1.0|]; [|6.2; 0.5|]; [|0.0; 2.0|]; [|0.5; 4.0|];
[|3.0; 6.0|]|]
val knnPrediction : int [] = [|1; 1; 1; 0; 0; 0|]
最后,让我们看看它们在考试中的实际表现。将以下内容添加到脚本中:
let actual = [|1;1;1;0;0;0|]
将这些发送到 FSI,我们得到以下结果:
val actual : int [] = [|1; 1; 1; 0; 0; 0|]
将这些数组组合在一起在图表中,我们将得到以下结果:
如果我们评分随机测试和 k-NN 在预测实际结果方面的表现,我们可以看到随机测试正确预测结果 66%的时间,而 k-NN 正确预测结果 100%的时间:
因为我们的 k-NN 比随机抛硬币做得更好,我们可以认为这个模型是有用的。
这种是/否随机测试在我们的模型是逻辑回归或 k-NN 这样的分类模型时效果很好,但当我们依赖的(Y)变量是像线性回归中的连续值时怎么办?在这种情况下,我们不是使用随机抛硬币,而是可以插入已知值的平均值。如果结果预测比平均值好,我们可能有一个好的模型。如果它比平均值差,我们需要重新思考我们的模型。例如,考虑从 AdventureWorks 预测平均自行车评论:
当你将预测值与实际值(考虑到可能更高或更低)进行比较,然后汇总结果时,你可以看到我们的线性回归在预测评分方面做得比平均值更好:
如果你认为我们在第二章和第三章已经做过类似的事情,你是正确的——这与 RMSE 的概念相同。
交叉验证 – 混淆矩阵和 AUC
回到我们的 k-NN 示例,想象一下我们对许多学生运行了 k-NN。有时 k-NN 猜对了,有时 k-NN 没有。实际上有四种可能的结果:
-
k-NN 预测学生会通过,他们确实通过了
-
k-NN 预测学生会失败,他们确实失败了
-
k-NN 预测学生会通过,但他们失败了
-
k-NN 预测学生会失败,但他们通过了
每个这些结果都有一个特殊的名称:
-
预测通过且实际通过:真阳性
-
预测失败且实际失败:真阴性
-
预测通过但失败了:假阳性
-
预测失败但通过了:假阴性
以图表形式,它看起来是这样的:
有时,假阳性被称为 I 型错误,而假阴性被称为 II 型错误。
如果我们对 100 名学生运行 k-NN,我们可以在图表中添加如下值:
阅读这张图表,52 名学生通过了考试。其中,我们正确预测了 50 人会通过,但错误地预测了两个通过的学生会失败。同样,43 名学生没有通过考试(肯定是一场艰难的考试!),其中我们正确预测了 40 人会失败,而三个我们错误地预测了会通过。这个矩阵通常被称为混淆矩阵。
使用这个混淆矩阵,我们可以进行一些基本的统计,例如:
准确率 = 真阳性 + 真阴性 / 总人口 = (50 + 40) / 100 = 90%
真阳性率 (TPR) = 真阳性 / 总阳性 = 50 / 52 = 96%
假阴性率 (FNR) = 假阴性 / 总阳性 = 2 / 52 = 4%
假阳性率 (FPR) = 假阳性 / 总阴性 = 3 / 43 = 7%
真阴性率 (TNR) = 真阴性 / 总阴性 = 40 / 43 = 93%
(注意,TPR 有时被称为灵敏度,FNR 有时被称为漏报率,假阳性率有时被称为逃逸率,而 TNR 有时被称为特异性。)
阳性似然比 (LR+) = TPR / FPR = 96% / (1 – 93%) = 13.8
阴性似然比 (LR-) = FNR / TNR = 4% / 93% = 0.04
诊断优势比 (DOR) = LR+ / LR- = 33.3
由于 DOR 大于 1,我们知道模型运行良好。
将这些放入代码中,我们可以手动写下这些公式,但 Accord.Net 已经为我们处理好了。回到 Visual Studio,打开AccordKNN.fsx。在底部,输入以下代码:
let positiveValue = 1
let negativeValue = 0
let confusionMatrix = ConfusionMatrix(knnPrediction,actual,positiveValue,negativeValue)
在下一行,输入confusionMatrix并按点号以查看所有可用的属性:
这确实是一个非常实用的课程。让我们选择优势比:
confusionMatrix.OddsRatio
然后将整个代码块发送到 FSI:
val positiveValue : int = 1
val negativeValue : int = 0
val confusionMatrix : ConfusionMatrix = TP:3 FP:0, FN:0 TN:3
val it : float = infinity
由于我们的 k-NN 是 100%准确的,我们得到了一个无限大的优势比(甚至更多)。在现实世界的模型中,优势比显然会低得多。
交叉验证 – 无关变量
还有一个技术我想介绍,用于交叉验证——添加无关变量并观察对模型的影响。如果你的模型真正有用,它应该能够处理额外的“噪声”变量,而不会对模型的结果产生重大影响。正如我们在第二章中看到的,AdventureWorks 回归,任何额外的变量都会对大多数模型产生积极影响,所以这是一个程度的衡量。如果添加一个无关变量使模型看起来更加准确,那么模型本身就有问题。然而,如果额外变量只有轻微的影响,那么我们的模型可以被认为是可靠的。
让我们看看实际效果。回到 AccordKNN.fsx 并在底部添加以下代码:
let inputs' = [|[|5.0;1.0;1.0|];[|4.5;1.5;11.0|];
[|5.1;0.75;5.0|];[|1.0;3.5;8.0|];
[|0.5;4.0;1.0|];[|1.25;4.0;11.0|]|]
let knn' = KNearestNeighbors(k, classes, inputs', outputs)
let testInputs' = [|[|5.0;1.0;5.0|];[|4.0;1.0;8.0|];
[|6.2;0.5;12.0|];[|0.0;2.0;2.0|];
[|0.5;4.0;6.0|];[|3.0;6.0;5.0|]|]
let knnPrediction' =
testInputs'
|> Array.map(fun ti -> knn'.Compute(ti))
我添加了一个代表每个学生的星座符号的第三个变量(1.0 = 水瓶座,2.0 = 双鱼座,等等)。当我传入相同的测试输入(也带有随机的星座符号)时,预测结果与原始的 k-NN 相同。
val knnPrediction' : int [] = [|1; 1; 1; 0; 0; 0|]
我们可以得出结论,尽管额外变量在建模过程中某个时刻产生了影响,但它并不足以改变我们的原始模型。然后我们可以用更高的信心使用这个模型。
摘要
本章与其他你可能读过的机器学习书籍略有不同,因为它没有介绍任何新的模型,而是专注于脏活累活——收集、清理和选择你的数据。虽然不那么光鲜,但绝对有必要掌握这些概念,因为它们往往会使项目成功或失败。事实上,许多项目花费超过 90% 的时间在获取数据、清理数据、选择正确的特征和建立适当的交叉验证方法上。在本章中,我们探讨了数据清理以及如何处理缺失和不完整的数据。接下来,我们探讨了多重共线性化和归一化。最后,我们总结了常见的交叉验证技术。
我们将在接下来的章节中应用所有这些技术。接下来,让我们回到 AdventureWorks 公司,看看我们是否可以用基于人类大脑工作原理的机器学习模型帮助他们改进生产流程。
第九章. AdventureWorks 生产 - 神经网络
有一天,你坐在办公室里,沉浸在你在 AdventureWorks 新获得的摇滚明星地位的光环中,这时你的老板敲响了门。她说:“既然你在我们现有网站的面向消费者的部分做得这么好,我们想知道你是否愿意参与一个内部绿色田野项目。”你响亮地打断她,“是的!”她微笑着继续说,“好的。问题是出在我们的生产区。管理层非常感兴趣我们如何减少我们的废品数量。每个月我们都会收到一份来自 Excel 的报告,看起来像这样:”
“问题是,我们不知道如何处理这些数据。生产是一个复杂的流程,有许多变量可能会影响物品是否被废弃。我们正在寻找两件事:
-
一种识别对物品是否被废弃影响最大的项目的方法
-
一个允许我们的规划者改变关键变量以进行“如果……会怎样”的模拟并改变生产流程的工具
你告诉你的老板可以。由于这是一个绿色田野应用,而且你一直在听关于 ASP.NET Core 1.0 的炒作,这似乎是一个尝试它的绝佳地方。此外,你听说过数据科学中的一个热门模型,神经网络,并想知道现实是否与炒作相符。
神经网络
神经网络是数据科学的一个相对较晚的参与者,试图让计算机模仿大脑的工作方式。我们耳朵之间的灰质在建立联系和推理方面非常好,直到一定程度。神经网络的前景是,如果我们能构建出模仿我们大脑工作方式的模型,我们就可以结合计算机的速度和湿件的模式匹配能力,创建一个可以提供洞察力,而计算机或人类单独可能错过的学习模型。
背景
神经网络从实际大脑中汲取词汇;神经网络是一系列神经元的集合。如果你还记得生物学 101(或者《战争机器 2》),大脑有数十亿个神经元,它们看起来或多或少是这样的:
一个神经元的轴突末端连接到另一个神经元的树突。由于单个神经元可以有多个树突和轴突末端,神经元可以连接,并被连接到许多其他神经元。两个神经元之间实际的连接区域被称为突触。我们的大脑使用电信号在神经元之间传递信息。
由于我们正在为神经网络模拟人脑,因此我们可以合理地认为我们将使用相同的词汇。在神经网络中,我们有一系列输入和一个输出。在输入和输出之间,有一个由神经元组成的隐藏层。任何从输入到隐藏层、隐藏层内部的神经元之间,以及从隐藏层到输出的连接都被称为突触。
注意,每个突触只连接到其右侧的神经元(或输出)。在神经网络中,数据总是单向流动,突触永远不会连接到自身或网络中的任何其他前一个神经元。还有一点需要注意,当隐藏层有多个神经元时,它被称为深度信念网络(或深度学习)。尽管如此,我们在这本书中不会涉及深度信念网络,尽管这确实是你下次和朋友打保龄球时可能会讨论的话题。
在神经网络中,突触只有一个任务。它们从一个神经元形成连接到下一个神经元,并应用一个权重到这个连接上。例如,神经元 1 以两个权重激活突触,因此神经元 2 接收到的输入为两个:
神经元有一个更复杂的工作。它们接收来自所有输入突触的值,从称为偏差的东西那里获取输入(我稍后会解释),对输入应用激活函数,然后输出一个信号或什么都不做。激活函数可以单独处理每个输入,也可以将它们组合起来,或者两者兼而有之。存在许多种类的激活函数,从简单到令人难以置信。在这个例子中,输入被相加:
一些神经网络足够智能,可以根据需要添加和删除神经元。对于这本书,我们不会做任何类似的事情——我们将固定每层的神经元数量。回到我在上一段中提到的词汇,对于神经元内的任何给定激活函数,有两种输入:通过突触传递的权重和偏差。权重是一个分配给突触的数字,它取决于突触的性质,并且在神经网络的整个生命周期中不会改变。偏差是一个分配给所有神经元(和输出)的全局值,与权重不同,它经常改变。神经网络中的机器学习组件是计算机所做的许多迭代,以创建最佳权重和偏差组合,从而给出最佳的预测分数。
神经网络演示
在建立这个心理模型之后,让我们看看神经网络的实际应用。让我们看看一系列在考试前学习和喝酒的学生,并比较他们是否通过了那次考试:
由于我们有两个输入变量(x,即学习时间和喝啤酒量),我们的神经网络将有两个输入。我们有一个因变量(是否通过),因此我们的神经网络将有一个输出:
有一点需要注意,输入的数量取决于值的范围。所以如果我们有一个分类输入(例如男性/女性),我们将有一个与该类别值范围相对应的输入数量:
-
进入 Visual Studio 并创建一个新的 C# ASP.NET 网络应用程序:
-
在下一个对话框中,选择 ASP.NET 5 模板并将身份验证类型更改为 无身份验证。请注意,在本书编写之后,模板可能会从 ASP.NET 5 更改为 ASP.NET Core 1。你可以将这两个术语视为同义词。
-
如果代码生成一切正常,你会得到以下项目:
-
接下来,让我们添加一个 F# Windows 库项目:
-
一旦创建了 F# 项目,打开 NuGet 包管理器控制台并安装 numl。确保你在为 NuGet 安装目标 F# 项目:
PM> install-package numl -
将
Scipt1.fsx重命名为StudentNeuralNetwork.fsx。 -
前往脚本并将其中的所有内容替换为以下代码:
#r "../packages/numl.0.8.26.0/lib/net40/numl.dll" open numl open numl.Model open numl.Supervised.NeuralNetwork type Student = {[<Feature>]Study: float; [<Feature>]Beer: float; [<Label>] mutable Passed: bool} let data = [{Study=2.0;Beer=3.0;Passed=false}; {Study=3.0;Beer=4.0;Passed=false}; {Study=1.0;Beer=6.0;Passed=false}; {Study=4.0;Beer=5.0;Passed=false}; {Study=6.0;Beer=2.0;Passed=true}; {Study=8.0;Beer=3.0;Passed=true}; {Study=12.0;Beer=1.0;Passed=true}; {Study=3.0;Beer=2.0;Passed=true};] let data' = data |> Seq.map box let descriptor = Descriptor.Create<Student>() let generator = NeuralNetworkGenerator() generator.Descriptor <- descriptor let model = Learner.Learn(data', 0.80, 100, generator) let accuracy = model.Accuracy -
当你将这个项目发送到 FSI 时,你会得到以下结果:
val generator : NeuralNetworkGenerator val model : LearningModel = Learning Model: Generator numl.Supervised.NeuralNetwork.NeuralNetworkGenerator Model: numl.Supervised.NeuralNetwork.NeuralNetworkModel Accuracy: 100.00 % val accuracy : float = 1.0
如果你已经完成了第三章(更多 AdventureWorks 回归
当你将这个项目发送到 FSI 时,你会得到以下结果:
```py
val testData : Student = {Study = 7.0;
Beer = 1.0;
Passed = false;}
>
val predict : obj = {Study = 7.0;
Beer = 1.0;
Passed = true;}
在这种情况下,我们的学生如果学习 7 小时并且喝了一杯啤酒就能通过考试。
神经网络 – 尝试 #1
理论问题解决之后,让我们看看神经网络是否可以帮助我们处理 AdventureWorks。正如第三章中所述,更多 AdventureWorks 回归,让我们看看是否可以使用业务领域专家来帮助我们制定一些可行的假设。当我们访问制造经理时,他说:“我认为有几个领域你应该关注。看看生产位置是否有影响。我们共有七个主要位置”:
“我很想知道我们的油漆位置是否产生了比预期更多的缺陷,因为我们该区域的周转率很高。”
“此外,查看供应商和有缺陷的产品之间是否存在关系。在某些情况下,我们为单个供应商购买零件;在其他情况下,我们有两个或三个供应商为我们提供零件。在我们组装自行车时,我们没有跟踪哪个零件来自哪个供应商,但也许你可以发现某些供应商与有缺陷的采购订单相关联。”
这些看起来是两个很好的起点,因此让我们前往解决方案资源管理器,在 F#项目中创建一个名为AWNeuralNetwork.fsx的新脚本文件:
接下来,打开 NuGet 包管理器并输入以下内容:
PM> Install-Package SQLProvider -prerelease
接下来,打开脚本文件并输入以下内容(注意,版本号可能因你而异):
#r "../packages/SQLProvider.0.0.11-alpha/lib/FSharp.Data.SQLProvider.dll"
#r "../packages/numl.0.8.26.0/lib/net40/numl.dll"
#r "../packages/FSharp.Collections.ParallelSeq.1.0.2/lib/net40/FSharp.Collections.ParallelSeq.dll"
open numl
open System
open numl.Model
open System.Linq
open FSharp.Data.Sql
open numl.Supervised.NeuralNetwork
open FSharp.Collections.ParallelSeq
[<Literal>]
let connectionString = "data source=nc54a9m5kk.database.windows.net;initial catalog=AdventureWorks2014;user id= PacktReader;password= P@cktM@chine1e@rning;"
type AdventureWorks = SqlDataProvider<ConnectionString=connectionString>
let context = AdventureWorks.GetDataContext()
将此发送到 REPL 会得到以下结果:
val connectionString : string =
"data source=nc54a9m5kk.database.windows.net;initial catalog=A"+[70 chars]
type AdventureWorks = SqlDataProvider<...>
val context : SqlDataProvider<...>.dataContext
接下来,让我们处理位置假设。转到脚本并输入以下内容:
type WorkOrderLocation = {[<Feature>] Location10: bool;
[<Feature>] Location20: bool;
[<Feature>] Location30: bool;
[<Feature>] Location40: bool;
[<Feature>] Location45: bool;
[<Feature>] Location50: bool;
[<Feature>] Location60: bool;
[<Label>] mutable Scrapped: bool}
let getWorkOrderLocation (workOrderId, scrappedQty:int16) =
let workOrderRoutings = context.``[Production].[WorkOrderRouting]``.Where(fun wor -> wor.WorkOrderID = workOrderId) |> Seq.toArray
match workOrderRoutings.Length with
| 0 -> None
| _ ->
let location10 = workOrderRoutings |> Array.exists(fun wor -> wor.LocationID = int16 10)
let location20 = workOrderRoutings |> Array.exists(fun wor -> wor.LocationID = int16 20)
let location30 = workOrderRoutings |> Array.exists(fun wor -> wor.LocationID = int16 30)
let location40 = workOrderRoutings |> Array.exists(fun wor -> wor.LocationID = int16 40)
let location45 = workOrderRoutings |> Array.exists(fun wor -> wor.LocationID = int16 45)
let location50 = workOrderRoutings |> Array.exists(fun wor -> wor.LocationID = int16 50)
let location60 = workOrderRoutings |> Array.exists(fun wor -> wor.LocationID = int16 60)
let scrapped = scrappedQty > int16 0
Some {Location10=location10;Location20=location20;Location30=location30;Location40=location40;
Location45=location45;Location50=location50;Location60=location60;Scrapped=scrapped}
将此发送到 REPL 会得到以下结果:
type WorkOrderLocation =
{Location10: bool;
Location20: bool;
Location30: bool;
Location40: bool;
Location45: bool;
Location50: bool;
Location60: bool;
mutable Scrapped: bool;}
val getWorkOrderLocation :
workOrderId:int * scrappedQty:int16 -> WorkOrderLocation option
你可以看到我们有一个记录类型,每个位置作为一个字段,以及一个表示是否有报废的指示器。这个数据结构的自动化程度是工作订单。每个订单可能访问一个或所有这些位置,并且可能有某些报废数量。getWorkOrderFunction函数接受WorkOrderLocation表,其中每个位置是表中的一行,并将其扁平化为WorkOrderLocation记录类型。
接下来,回到脚本并输入以下内容:
let locationData =
context.``[Production].[WorkOrder]``
|> PSeq.map(fun wo -> getWorkOrderLocation(wo.WorkOrderID,wo.ScrappedQty))
|> Seq.filter(fun wol -> wol.IsSome)
|> Seq.map(fun wol -> wol.Value)
|> Seq.toArray
将此发送到 REPL 会得到以下结果:
val locationData : WorkOrderLocation [] =
|{Location10 = true;
Location20 = true;
Location30 = true;
Location40 = false;
Location45 = true;
Location50 = true;
Location60 = true;
Scrapped = false;}; {Location10 = false;
Location20 = false;
Location30 = false;
Location40 = false;
这段代码与你在[第五章中看到的内容非常相似,时间到 – 获取数据。我们访问数据库并拉取所有工作订单,然后将位置映射到我们的WorkOrderLocation记录。请注意,我们使用PSeq,这样我们就可以通过同时调用数据库来获取每个工作订单的位置来提高性能。
数据本地化后,让我们尝试使用神经网络。进入脚本文件并输入以下内容:
let locationData' = locationData |> Seq.map box
let descriptor = Descriptor.Create<WorkOrderLocation>()
let generator = NeuralNetworkGenerator()
generator.Descriptor <- descriptor
let model = Learner.Learn(locationData', 0.80, 5, generator)
let accuracy = model.Accuracy
在长时间等待后,将此发送到 REPL 会得到以下结果:
val generator : NeuralNetworkGenerator
val model : LearningModel =
Learning Model:
Generator numl.Supervised.NeuralNetwork.NeuralNetworkGenerator
Model:
numl.Supervised.NeuralNetwork.NeuralNetworkModel
Accuracy: 0.61 %
val accuracy : float = 0.006099706745
所以,呃,看起来位置并不能预测缺陷可能发生的地方。正如我们在 第三章 "更多 AdventureWorks 回归" 中看到的,有时你不需要一个工作模型来使实验有价值。在这种情况下,我们可以回到导演那里,告诉他报废发生在他的整个生产地点,而不仅仅是喷漆(这样就把责任推给了新来的那个人)。
神经网络 – 尝试 #2
让我们看看是否可以使用导演的第二个假设来找到一些东西,即某些供应商可能比其他供应商的缺陷率更高。回到脚本中,输入以下内容:
type VendorProduct = {WorkOrderID: int;
[<Feature>]BusinessEntityID: int;
[<Feature>]ProductID: int;
[<Label>] mutable Scrapped: bool}
let workOrders = context.``[Production].[WorkOrder]`` |> Seq.toArray
let maxWorkOrder = workOrders.Length
let workOrderIds = Array.zeroCreate<int>(1000)
let workOrderIds' = workOrderIds |> Array.mapi(fun idx i -> workOrders.[System.Random(idx).Next(maxWorkOrder)])
|> Array.map(fun wo -> wo.WorkOrderID)
当你将其发送到 FSI 后,你会得到以下内容:
type VendorProduct =
{WorkOrderID: int;
BusinessEntityID: int;
ProductID: int;
mutable Scrapped: bool;}
…
FSharp.Data.Sql.Common.SqlEntity; FSharp.Data.Sql.Common.SqlEntity;
FSharp.Data.Sql.Common.SqlEntity; FSharp.Data.Sql.Common.SqlEntity; ...|]
val maxWorkOrder : int = 72591
val workOrderIds : int [] =
[|0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0;
0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0;
0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0;
0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0;
...|]
val workOrderIds' : int [] =
VendorProduct 记录类型你应该很熟悉。接下来的代码块创建了一个包含 1,000 个随机工作订单 ID 的数组。正如我们从第一个实验中学到的,神经网络需要很长时间才能完成。我们将在下一章中查看一些大数据解决方案,但在此之前,我们将做数据科学家一直做的事情——从更大的数据集中抽取样本。请注意,我们正在使用 Array.Mapi 高阶函数,这样我们就可以使用索引值在工作订单数组中定位正确的值。不幸的是,我们无法将索引传递给类型提供者并在服务器上评估,因此整个工作订单表被带到本地,这样我们就可以使用索引。
接下来,将以下内容输入到脚本中:
let (|=|) id a = Array.contains id a
let vendorData =
query{for p in context.``[Production].[Product]`` do
for wo in p.FK_WorkOrder_Product_ProductID do
for bom in p.FK_BillOfMaterials_Product_ProductAssemblyID do
join pv in context.``[Purchasing].[ProductVendor]`` on (bom.ComponentID = pv.ProductID)
join v in context.``[Purchasing].[Vendor]`` on (pv.BusinessEntityID = v.BusinessEntityID)
select ({WorkOrderID = wo.WorkOrderID;BusinessEntityID = v.BusinessEntityID; ProductID = p.ProductID; Scrapped = wo.ScrappedQty > int16 0})}
|> Seq.filter(fun vp -> vp.WorkOrderID |=| workOrderIds')
|> Seq.toArray
当你将其发送到 FSI 后,稍作等待,你会得到以下内容:
val ( |=| ) : id:'a -> a:'a [] -> bool when 'a : equality
val vendorData : VendorProduct [] =
|{WorkOrderID = 25;
BusinessEntityID = 1576;
ProductID = 764;
Scrapped = false;}; {WorkOrderID = 25;
BusinessEntityID = 1586;
ProductID = 764;
Scrapped = false;}; {WorkOrderID = 25;
第一行是我们在 [第五章 "时间到 – 获取数据" 中遇到的 in (|=|) 操作符。接下来的代码块使用从 1,000 个随机选择的工作订单中的数据填充 vendorData 数组。请注意,由于每个工作订单将使用多个部件,而每个部件可能由各种供应商(在这种情况下,称为商业实体)提供,因此存在一些重复。
数据本地化后,进入脚本并输入以下内容:
let vendorData' = vendorData |> Seq.map box
let descriptor' = Descriptor.Create<VendorProduct>()
let generator' = NeuralNetworkGenerator()
generator'.Descriptor <- descriptor'
let model' = Learner.Learn(vendorData', 0.80, 5, generator')
let accuracy' = model'.Accuracy
当你将其发送到 FSI 后,你会得到以下内容:
val generator' : NeuralNetworkGenerator
val model' : LearningModel =
Learning Model:
Generator numl.Supervised.NeuralNetwork.NeuralNetworkGenerator
Model:
numl.Supervised.NeuralNetwork.NeuralNetworkModel
Accuracy: 99.32 %
val accuracy' : float = 0.9931740614
所以,这很有趣。我们有一个非常高的准确率。人们可能会想:这是否是因为在单一供应商的产品情况下,所有报废的量都将与他们相关,因为他们是唯一的。然而,由于单个供应商可能提供多个输入产品,而这些产品可能有不同的报废率,你可以使用该模型来预测特定供应商和特定产品是否会有报废率。此外,请注意,由于为每个供应商和产品添加一个输入(这将使数据帧非常稀疏),这里有一个供应商输入和一个产品输入。虽然这些可以被认为是分类值,但我们可以为了这个练习牺牲一些精度。
你需要记住关于神经网络的关键点是,神经网络无法告诉你它是如何得到答案的(非常像人脑,不是吗?)。所以神经网络不会报告哪些供应商和产品的组合会导致缺陷。要做到这一点,你需要使用不同的模型。
构建应用程序
由于这个神经网络提供了我们所需的大部分信息,让我们继续构建我们的 ASP.NET 5.0 应用程序,并使用该模型。在撰写本文时,ASP.NET 5.0 仅支持 C#,因此我们必须将 F#转换为 C#并将代码移植到应用程序中。一旦其他语言被 ASP.NET 支持,我们将更新网站上的示例代码。
如果你不太熟悉 C#,它是.NET 堆栈中最流行的语言,并且与 Java 非常相似。C#是一种通用语言,最初结合了命令式和面向对象的语言特性。最近,函数式结构被添加到语言规范中。然而,正如老木匠的格言所说,“如果是螺丝,就用螺丝刀。如果是钉子,就用锤子。”既然如此,你最好用 F#进行.NET 函数式编程。在下一节中,我将尽力解释在将代码移植过来时 C#实现中的任何差异。
设置模型
你已经有了创建好的 MVC 网站模板。打开 NuGet 包管理器控制台,将其安装到其中:
PM > install-package numl
接下来,在解决方案资源管理器中创建一个名为Models的文件夹:
在那个文件夹中,添加一个名为VendorProduct的新类文件:
在那个文件中,将所有代码替换为以下内容:
using numl.Model;
namespace AdventureWorks.ProcessAnalysisTool.Models
{
public class VendorProduct
{
public int WorkOrderID { get; set; }
[Feature]
public int BusinessEntityID { get; set; }
[Feature]
public int ProductID { get; set; }
[Label]
public bool Scrapped { get; set; }
}
}
如你所猜,这相当于我们在 F#中创建的记录类型。唯一的真正区别是属性默认可变(所以要小心)。转到解决方案资源管理器并找到Project.json文件。打开它,并在frameworks部分删除此条目:
: "dnxcore50": { }
此部分现在应如下所示:
运行网站以确保它正常工作:
我们正在做的是移除网站对.NET Core 的依赖。虽然 numl 支持.NET Core,但我们现在不需要它。
如果网站正在运行,让我们添加我们剩余的辅助类。回到解决方案资源管理器,添加一个名为Product.cs的新类文件。进入该类,将现有代码替换为以下内容:
using System;
namespace AdventureWorks.ProcessAnalysisTool.Models
{
public class Product
{
public int ProductID { get; set; }
public string Description { get; set; }
}
}
这是一个记录等效类,当用户选择要建模的Product时将使用它。
返回到解决方案资源管理器并添加一个名为Vendor.cs的新类文件。进入该类,将现有代码替换为以下内容:
using System;
namespace AdventureWorks.ProcessAnalysisTool.Models
{
public class Vendor
{
public int VendorID { get; set; }
public String Description { get; set; }
}
}
就像Product类一样,这将用于填充用户的下拉列表。
返回到解决方案资源管理器并添加一个名为 Repository.cs 的新类文件。进入该类并将现有代码替换为以下内容:
using System;
using System.Collections.Generic;
using System.Data.SqlClient;
using System.Linq;
namespace AdventureWorks.ProcessAnalysisTool.Models
{
public class Repository
{
public String ConnectionString { get; private set; }
public Repository(String connectionString)
{
this.ConnectionString = connectionString;
}
public ICollection<Vendor> GetAllVendors()
{
var vendors = new List<Vendor>();
using (var connection = new SqlConnection(this.ConnectionString))
{
var commandText =
"Select distinct V.BusinessEntityID, V.Name from [Purchasing].[Vendor] as V " +
"Inner join[Purchasing].[ProductVendor] as PV " +
"on V.BusinessEntityID = PV.BusinessEntityID " +
"order by 2 asc";
using (var command = new SqlCommand(commandText, connection))
{
connection.Open();
var reader = command.ExecuteReader();
while (reader.Read())
{
vendors.Add(new Vendor() { VendorID = (int)reader[0], Description = (string)reader[1] });
}
}
}
return vendors;
}
public ICollection<Product> GetAllProducts()
{
var products = new List<Product>();
using (var connection = new SqlConnection(this.ConnectionString))
{
var commandText =
"Select distinct P.ProductID, P.Name from [Production].[Product] as P " +
"Inner join[Purchasing].[ProductVendor] as PV " +
"on P.ProductID = PV.ProductID " +
"order by 2 asc";
using (var command = new SqlCommand(commandText, connection))
{
connection.Open();
var reader = command.ExecuteReader();
while (reader.Read())
{
products.Add(new Product() { ProductID = (int)reader[0], Description = (string)reader[1] });
}
}
}
return products;
}
public ICollection<VendorProduct> GetAllVendorProducts()
{
var vendorProducts = new List<VendorProduct>();
using (var connection = new SqlConnection(this.ConnectionString))
{
var commandText =
"Select WO.WorkOrderID, PV.BusinessEntityID, PV.ProductID, WO.ScrappedQty " +
"from[Production].[Product] as P " +
"inner join[Production].[WorkOrder] as WO " +
"on P.ProductID = WO.ProductID " +
"inner join[Production].[BillOfMaterials] as BOM " +
"on P.ProductID = BOM.ProductAssemblyID " +
"inner join[Purchasing].[ProductVendor] as PV " +
"on BOM.ComponentID = PV.ProductID ";
using (var command = new SqlCommand(commandText, connection))
{
connection.Open();
var reader = command.ExecuteReader();
while (reader.Read())
{
vendorProducts.Add(new VendorProduct()
{
WorkOrderID = (int)reader[0],
BusinessEntityID = (int)reader[1],
ProductID = (int)reader[2],
Scrapped = (short)reader[3] > 0
});
}
}
}
return vendorProducts;
}
public ICollection<VendorProduct> GetRandomVendorProducts(Int32 number)
{
var returnValue = new List<VendorProduct>();
var vendorProducts = this.GetAllVendorProducts();
for (int i = 0; i < number; i++)
{
var random = new System.Random(i);
var index = random.Next(vendorProducts.Count - 1);
returnValue.Add(vendorProducts.ElementAt(index));
}
return returnValue;
}
}
}
如您可能猜到的,这是调用数据库的类。由于 C# 没有类型提供者,我们需要手动编写 ADO.NET 代码。我们需要添加对 System.Data 的引用以使此代码工作。进入解决方案资源管理器中的引用并添加它:
您可以再次运行网站以确保我们处于正确的轨道。在解决方案资源管理器中添加一个名为 NeuralNetwork.cs 的类文件。将其所有代码替换为以下内容:
using numl;
using numl.Model;
using numl.Supervised.NeuralNetwork;
using System;
using System.Collections.Generic;
namespace AdventureWorks.ProcessAnalysisTool.Models
{
public class NeuralNetwork
{
public ICollection<VendorProduct> VendorProducts { get; private set; }
public LearningModel Model { get; private set; }
public NeuralNetwork(ICollection<VendorProduct> vendorProducts)
{
if(vendorProducts == null)
{
throw new ArgumentNullException("vendorProducts");
}
this.VendorProducts = vendorProducts;
this.Train();
}
internal void Train()
{
var vendorData = VendorProducts;
var descriptor = Descriptor.Create<VendorProduct>();
var generator = new NeuralNetworkGenerator();
generator.Descriptor = descriptor;
var model = Learner.Learn(vendorData, 0.80, 5, generator);
if (model.Accuracy > .75)
{
this.Model = model;
}
}
public bool GetScrappedInd(int vendorId, int productId)
{
if(this.Model == null)
{
return true;
}
else
{
var vendorProduct = new VendorProduct()
{
BusinessEntityID = vendorId, ProductID = productId,
Scrapped = false
};
return (bool)this.Model.Model.Predict((object)vendorProduct);
}
}
}
}
这个类为我们执行了神经网络计算的重活。注意,这个类是数据无关的,因此它可以轻松地移植到 .NET Core。我们需要的只是一个 VendorProducts 集合,将其传递给神经网络的构造函数进行计算。
创建了所有这些类后,您的解决方案资源管理器应该看起来像这样:
您应该能够编译并运行网站。现在让我们为神经网络实现一个用户界面。
构建用户体验
以下步骤将指导您构建用户体验:
进入解决方案资源管理器并选择AdventureWorks.ProcessAnalysisTool。导航到添加 | 新建项:
在下一个对话框中,选择类并将其命名为 Global.cs:
进入 Global 类并将所有内容替换为以下内容:
using AdventureWorks.ProcessAnalysisTool.Models;
namespace AdventureWorks.ProcessAnalysisTool
{
public static class Global
{
static NeuralNetwork _neuralNetwork = null;
public static void InitNeuralNetwork()
{
var connectionString = "data source=nc54a9m5kk.database.windows.net;initial catalog=AdventureWorks2014;user id= PacktReader;password= P@cktM@chine1e@rning;";
var repository = new Repository(connectionString);
var vendorProducts = repository.GetRandomVendorProducts(1000);
_neuralNetwork = new NeuralNetwork(vendorProducts);
}
public static NeuralNetwork NeuralNetwork
{ get
{
return _neuralNetwork;
}
}
}
}
这个类为我们创建一个新的神经网络。我们可以通过名为 Neural Network 的只读属性访问神经网络的功能。因为它被标记为静态,所以只要应用程序在运行,这个类就会保留在内存中。
接下来,在主站点中找到 Startup.cs 文件。
打开文件并将构造函数(称为 Startup)替换为以下代码:
public Startup(IHostingEnvironment env)
{
// Set up configuration sources.
var builder = new ConfigurationBuilder()
.AddJsonFile("appsettings.json")
.AddEnvironmentVariables();
Configuration = builder.Build();
Global.InitNeuralNetwork();
}
当网站启动时,它将创建一个所有请求都可以使用的全局神经网络。
接下来,在 Controllers 目录中找到 HomeController。
打开该文件并添加此方法以填充一些供应商和产品的下拉列表:
[HttpGet]
public IActionResult PredictScrap()
{
var connectionString = "data source=nc54a9m5kk.database.windows.net;initial catalog=AdventureWorks2014;user id= PacktReader;password= P@cktM@chine1e@rning;";
var repository = new Repository(connectionString);
var vendors = repository.GetAllVendors();
var products = repository.GetAllProducts();
ViewBag.Vendors = new SelectList(vendors, "VendorID", "Description");
ViewBag.Products = new SelectList(products, "ProductID", "Description");
return View();
}
接下来,添加此方法,在供应商和产品被发送回服务器时在全局神经网络上运行 Calculate:
[HttpPost]
public IActionResult PredictScrap(Int32 vendorId, Int32 productId)
{
ViewBag.ScappedInd = Global.NeuralNetwork.GetScrappedInd(vendorId, productId);
var connectionString = "data source=nc54a9m5kk.database.windows.net;initial catalog=AdventureWorks2014;user id= PacktReader;password= P@cktM@chine1e@rning;";
var repository = new Repository(connectionString);
var vendors = repository.GetAllVendors();
var products = repository.GetAllProducts();
ViewBag.Vendors = new SelectList(vendors, "VendorID", "Description", vendorId);
ViewBag.Products = new SelectList(products, "ProductID", "Description", productId);
return View();
}
如果您折叠到定义,HomeController 将看起来像这样:
接下来,进入解决方案资源管理器并导航到AdventureWorks.ProcessAnalysisTool | 视图 | 主页。右键单击文件夹并导航到添加 | 新建项:
在下一个对话框中,选择MVC 视图页面并将其命名为 PredictScrap.cshtml:
打开这个页面并将所有内容替换为以下内容:
<h2>Determine Scrap Rate</h2>
@using (Html.BeginForm())
{
<div class="form-horizontal">
<h4>Select Inputs</h4>
<hr />
<div class="form-group">
<div class="col-md-10">
@Html.DropDownList("VendorID", (SelectList)ViewBag.Vendors, htmlAttributes: new { @class = "form-control" })
@Html.DropDownList("ProductID", (SelectList)ViewBag.Products, htmlAttributes: new { @class = "form-control" })
</div>
</div>
<div class="form-group">
<div class="col-md-offset-2 col-md-10">
<input type="submit" value="Predict!" class="btn btn-default" />
</div>
</div>
<h4>Will Have Scrap?</h4>
<div class="form-group">
<div class="col-md-offset-2 col-md-10">
@ViewBag.ScappedInd
</div>
</div>
</div>
}
这是一个输入表单,它将允许用户选择供应商和产品,并查看神经网络将预测什么——这个组合是否会有废料。当你第一次运行网站并导航到 localhost:port/home/PredictScrap 时,你会看到为你准备好的下拉列表:
选择一个供应商和一个产品,然后点击 预测!:
现在我们有一个完全运行的 ASP .NET Core 1.0 网站,该网站使用神经网络来预测 AdventureWorks 废料百分比。有了这个框架,我们可以将网站交给用户体验专家,使其外观和感觉更好——核心功能已经就位。
摘要
本章开辟了一些新领域。我们深入研究了 ASP.NET 5.0 用于我们的网站设计。我们使用 numl 创建了两个神经网络:一个显示公司面积与废料率之间没有关系,另一个可以根据供应商和产品预测是否会有废料。然后我们在网站上实现了第二个模型。