本文共 10647 字,大约阅读时间需要 35 分钟。
本节书摘来异步社区《机器学习项目开发实战》一书中的第1章,第1.5节,作者:【美】Mathias Brandewinder(马蒂亚斯·布兰德温德尔),更多章节内容可以访问云栖社区“异步社区”公众号查看
你是否注意到,运行我们的模型要花多少时间?为了了解模型的质量,在任何代码更改之后,都需要重建控制台应用并运行,重新加载数据,然后计算。步骤很多,如果数据集更大,一天当中的大多数时间就要用在等待数据加载上,这不是好的做法。
相比之下,F#自带一个非常方便的功能——Visual Studio中的F#交互执行(Interactive)。F#交互执行是一个REPL(输入——求值——打印循环),本质上是一个实时脚本编写环境,可以在其中试验代码而无须经历前面描述的整个循环。
这样,我们将用一个脚本工作,而不是控制台应用程序。进入Visual Studio并在解决方案中添加一个新的库项目(见图1-7),我们将其命名为FSharp。
■ 提示:
如果你用Visual Studio专业版或者更高版本开发,F#应该是默认安装的,其他情况请访问F#软件基金会的网站www.fsharp.org,网站上有全面的设置指南。
值得一提的是,你刚刚在包含现有C#项目的.NET解决方案中添加了一个F#项目。F#和C#完全可以互操作,可以毫无问题地相互通信——没有必要限制自己用一种语言解决所有问题。可惜,人们往往将C#和F#视为竞争语言,实际并非如此。它们可以很好地相互补充,两全其美:对于C#最擅长的功能使用C#,而在F#最专精的地方则利用F#!在新项目中,应该看到一个名为Library1.fs的文件,这是.cs文件在F#中的等价物。但是,你有没有注意到名为script.fsx的文件?.fsx文件是脚本文件,和.fs文件不同,它们不是构建的一部分。它们可以用在Visual Studio之外,作为纯粹的独立脚本,这本身非常实用。在当前的背景(机器学习和数据科学中)下,我特别感兴趣的使用方法是在Visual Studio中,.fsx文件组成了出色的“便笺本”,你不仅可以在其中试验代码,还能利用智能感知(IntelliSense)的所有好处。
进入Script.fsx,删除其中的所有内容,在任意位置键入如下语句:
let x = 42```现在选择刚刚键入的代码行并单击鼠标右键,在上下文菜单中将看到“交互执行”(Execute In Interactive)选项,如图1-8所示。继续——你应该看到标签为“F# Interactive”的窗口中出现结果(见图1-9)。■ 提示:也可以使用快捷键Alt+Enter执行脚本文件中选中的任何代码。这比使用鼠标和上下文菜单快得多。对ReSharper用户有一个小小的警告:到目前为止,ReSharper都有重置快捷键的“坏习惯”,所以如果使用8.1之前的版本,可能必须重新创建该快捷方式。F# Interactive窗口(大部分时候为了简洁我们称之为FSI)以会话的形式运行。也就是说,在交互窗口中执行的任何命令都将保持在内存中,在用鼠标右键单击F# Interactive窗口并选择“重置交互会话”(Reset Interactive Session)之前都可以访问。在本例中,我们简单地创建一个变量x,值为42。乍一看,这和C#的var x=42语句类似,但两者之间有一些微妙的差别,我们将在后面讨论。现在,x“存在于”FSI中,一直可以使用。例如,可以在FSI中直接输入:
x + 100;;
val it : int = 142
FSI“记得”x存在:不需要重新运行.fsx文件中的代码,只要运行一次,它就会留在内存中。当你想要操纵稍大的数据集时,这一特性极其方便。使用FSI,可以在最初加载数据一次,然后持续编码,不需要像在C#中那样,每次更改之后都重新加载。
你可能会注意到x+100以后神秘的“;;”。这指示FSI到目前为止输入的命令都必须执行。如果你想要执行的代码跨越多行,这就很有用。
■ 提示:
如果你尝试过在FSI中直接输入F#代码,可能会注意到其中没有智能感知功能。与完整的Visual Studio体验相比,FSI是较为粗糙的开发环境。我的建议是尽量少在FSI中输入代码,而主要在.fsx文件中工作,这样就能获得现代IDE的所有好处,例如自动完成和语法验证。这将自然地引导你编写完整的脚本,在未来重新执行。虽然脚本不是解决方案构建的一部分,但是它们是解决方案的一部分,也可以(应该)进行版本控制,这样,你就能够重复脚本中进行的任何试验。
1.5.2 创建第一个F#脚本我们已经了解了FSI的基础知识,下面就可以开始编码了。我们将从读取数据开始,转换C#示例。首先,我们将执行一个完整的F#代码块以观察它的操作,然后详细检查所有部分是否都正常工作。删除当前Script.fsx中的所有内容,并输入程序清单1-10中的F#代码。程序清单1-10 从文件中读取数据
open System.IOtype Observation = { Label:string; Pixels: int[] }let toObservation (csvData:string) = let columns = csvData.Split(',') let label = columns.[0] let pixels = columns.[1..] |> Array.map int { Label = label; Pixels = pixels }let reader path = let data = File.ReadAllLines path data.[1..] |> Array.map toObservationlet trainingPath = @"PATH-ON-YOUR-MACHINE\trainingsample.csv"let trainingData = reader trainingPath```这几行F#语句执行相当多的操作。在讨论它们的工作原理之前,我们先运行这些语句,观察结果。选中代码,单击鼠标右键并选择“交互执行”,几秒之后,应该看见F# Interactive窗口中显示如下内容:
type Observation =
{Label: string;Pixels: int [];}
val observationFactory : csvData:string -> Observation
val reader : path:string -> Observation []val trainingPath : string = "-"+[58 chars]val trainingData : Observation [] = [|{Label = "1";Pixels = [|0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; ...|];};/// Output has been cut out for brevity here ///{Label = "3"; Pixels = [|0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; ...|];};
...|]
let test = trainingData.[100].Label;;```这样就行了,因为数据已经在内存中,随时可以使用。交互执行极其方便,尤其是在数据集很大、加载很费时的情况下。这是F#在以数据为中心的工作中相对于C#的明显好处之一:C#控制台应用程序中的代码有任何更改,都需要重新构建和重新加载数据,而F# Interactive加载数据之后,你就可以使用这些数据,更改核心内容了。更改代码和试验时无须重新加载。####1.5.3 剖析第一个F#脚本现在,我们已经看到了10行代码得到的结果,下面深入它们的工作原理:open System.IO这一行很简单——等价于C#中使用System.IO的语句。F#可以访问每个.NET库,所以多年学习.NET命名空间积累的经验都不会白费——你可以重用所有库,并用F#特有的优良功能扩增它们!在C#中,我们创建一个Observation类以保存数据。在F#中,使用稍有不同的类型完成相同的工作:
type Observation = { Label:string; Pixels: int[] }`
let myObs = { Label = "3"; Pixels = [| 1; 2; 3; 4; 5 |] }```我们简单地使用花括号,填写所有属性,实例化Observation记录。F#自动推导出我们想在Observation中放入的数据,因为它是具有正确属性的唯一记录类型。我们简单地用[| |]表示数组的开始和结尾,并填写内容,创建用于Pixels属性的整数数组。现在,我们已经有了数据容器,可以从CSV文件中读取数据了。在C#示例中,我们创建一个方法ReadObservations以及包含该方法的DataReader类,但是老实说,该类对我们来说作用不大。所以我们将简单地编写一个reader函数,以路径作为参数,并使用一个辅助函数从CSV行中提取观测值:
let toObservation (csvData:string) =
let columns = csvData.Split(',')let label = columns.[0]let pixels = columns.[1..] |> Array.map int{ Label = label; Pixels = pixels }
let reader path =
let data = File.ReadAllLines pathdata.[1..]|> Array.map toFactory```
我们在这里使用了相当多的F#特性——下面将一一解说。这些功能的出现有些密集,但是一旦你仔细理解,就已经学到用F#高效地进行数据科学研究所需理解知识的80%了!
我们从大致的概况开始。下面是等价的C#代码(程序清单1-2)。
private static Observation ObservationFactory(string data){ var commaSeparated = data.Split(','); var label = commaSeparated[0]; var pixels = commaSeparated .Skip(1) .Select(x => Convert.ToInt32(x)) .ToArray(); return new Observation(label, pixels);}public static Observation[] ReadObservations(string dataPath){ var data = File.ReadAllLines(dataPath) .Skip(1) .Select(ObservationFactory) .ToArray(); return data;}```C#和F#之间有一些明显的差异。首先,F#不使用花括号;和其他语言(如Python)一样,F#使用空格表示代码块。换言之,F#代码中的空格很重要:当你看到空格缩进深度相同的代码时,它们就属于同一个代码块,仿佛它们之间围绕着不可见的花括号一样。在程序清单1-10中的reader函数中,我们可以看到函数体从let data开始,以|>Array.map observationFactory结束。另一个明显的不同之处是函数参数中没有返回类型或者类型声明。这是不是意味着,F#是一种动态语言?如果将鼠标指针放在.fsx文件中的reader之上,就会显示如下提示:val reader: path:string ->Observation []。这表示一个函数取字符串类型(string)参数path,返回一个Observation数组。和C#一样,F#是静态类型语言,但是使用一个强大的类型推理引擎,该引擎利用任何可用的提示,自行理解正确的类型。在本例中,File.ReadAllLines只有两次重载,唯一可能的匹配暗示path是一个字符串。在某种程度上,这使你两者兼得——你得到了动态语言代码较少的好处,而且还有一个稳定的类型系统,由编译器帮助你避免愚蠢的错误。■ 提示:F#的类型推理系统利用少数提示理解意图的能力绝对令人惊叹。但是,有时候它无法自行推导出结果,必须为它提供帮助。在那种情况下,你可以用预期类型加以注释,如:let reader (path:string)=。一般来说,我建议,即使没有必要,也在代码的高层组件或者关键组件中使用类型注释。这样有助于其他人更直接地理解你的代码意图,而不需要打开IDE查看推导的类型。类型注释还有助于确认在组合多个函数时,每一步都真正地传递预期的类型,以跟踪某些问题的来源。C#和F#的另一个有趣的差异是没有返回语句。和大体上是过程性语言的C#不同,F#是面向表达式的。let x=2+3*5这样的表达式将名称x与一个表达式绑定;当表达式求值(2+3*5=17)时,值与x绑定。函数也是如此:函数的值就是最后一个表达式的值。下面是说明此时发生情况的人为例子:
let demo x y =
let a = 2 * xlet b = 3 * ylet z = a + bz // this is the last expression: // therefore demo will evaluate to whatever z evaluates to.```
你可能已经发现了另一个差异——参数周围也没有括号。 我们暂时忽略这一点,本章后面再进行说明。
下面我们深入观察读取函数的主体。let data = File.ReadAllLines path一次性将位于path所指路径的文件的所有内容读入一个字符串数组,每行为一个字符串。这里没有什么魔法,只是证明我们确实可以在F#中使用.NET框架中的任何可用功能,而不用理会编写那些功能的语言。
data.[1..]说明了F#中的索引语法,myArray.[0]将返回数组的第一个元素。注意,myArray和带括号的索引之间有一个点!这里要做说明的另一个有趣的语法特征是数组切片。data.[1..]表示“给我一个新数组,从索引1处开始获取数据,直到最后一个元素。”类似地,可以采用data.[5..10](给我从索引5到索引10的所有元素)或者data.[..3](给我到索引3为止的所有元素)。这使得数据操纵极其方便,是F#成为很好的数据科学语言的原因之一。
在我们的例子中,所有元素都从索引1开始,换言之,我们丢弃数组的第一个元素——表头。
C#代码中的下一步用ObservationFactory方法从每一行中提取一个Observation值,使用的语句如下:
myData.Select(line => ObservationFactory(line));```F#中的等价语句是:
myData |> Array.map (fun line -> toObservation line)`
■ 注意:
如果你喜欢在C#中使用LINQ,我怀疑你会真的喜欢F#。在许多方面,LINQ将来自函数式编程的概念导入面向对象的C#语言。F#为你提供更深入的类LINQ功能。要对此有所感觉,只需要在.fsx中输入“Array”,看看可以使用多少种函数!
第二个重要的差异是神秘的“|>”符号。该符号称为“管道向前”操作符。简而言之,它将前一个表达式的结果传递给管道中的下一个函数,后者使用它作为最后一个参数。例如,考虑如下代码:let double x = 2 * xlet a = 5let b = double alet c = double b```上述代码可以重写为:
let double x = 2 * x
let c = 5 |> double |> double`
double函数只需要一个整数型参数,因此我们可以通过管道向前操作符直接“馈送”数值5。因为double的结果也是整数,所以我们可以继续向前传递结果。可以将这段代码重写为: let double x = 2 * xlet c = 5 |> double |> double```Array.map示例遵循相同的模式,如果我们使用原始的Array.map版本,代码为:
let transformed = Array.map (fun line -> toObservation line) data`
data |> Array.map (fun line -> toObservation line)```如果你对F#完全不熟悉,可能有些不知所措。不要担心!我们将逐步看到许多F#的示例,理解其中所使用语句的原理可能要花点时间,但是入门实际上相当容易。管道向前操作符是我最喜欢的F#功能之一,因为它使工作流变得很容易跟踪:取得某些数据,沿着各步骤或者运算组成的管道传递,直到工作完成。####1.5.5 用元组和模式匹配操纵数据我们已经有了数据,现在必须找出训练集中最接近的图像。正如C#示例中那样,我们需要图像的距离。和C#不同,我们不创建类或接口,而只使用函数:
let manhattanDistance (pixels1,pixels2) =
Array.zip pixels1 pixels2|> Array.map (fun (x,y) -> abs (x-y))|> Array.sum```
这里我们使用了F#的另一个核心功能:元组和模式匹配的组合。元组(Tuple)是一组无名称的有序值,类型可能不同。元组在C#中也存在,但是该语言中缺乏模式匹配,确实削弱了元组的实用性,这很令人遗憾,因为这两种功能是数据操纵的黄金组合。
同时使用模式匹配比起只使用元组要强大得多。一般来说,模式识别是一种机制,使你的代码能够简单地识别数据中的各种形状,并据此采取行动。下面是一个说明模式识别在元组上作用方式的小例子:
let x = "Hello", 42 // create a tuple with 2 elementslet (a, b) = x // unpack the two elements of x by pattern matchingprintfn "%s, %i" a bprintfn "%s, %i" (fst x) (snd x)```这里我们在x中“打包”了两个元素:字符串“Hello”和整数42,用逗号分隔。逗号通常表示一个元组,因此在F#代码中必须注意这一点,初看时会稍有些混乱。第二行“解包”元组,将两个元素读入a和b。对于两个元素的元组,存在一种特殊的语法:可以使用fst和snd函数访问第一个和第二个元素。■ 提示:你可能已经注意到,和C#不同,F#不使用括号定义函数参数列表。举个例子,加法函数通常这样编写:add x y= x+y。函数tupleAdd (x, y) = x + y是完全有效的F#代码,但是含义不同:它需要一个参数,该参数是完整形式的元组。因此,1 |> add 2是有效的代码,但是1 |> tupleAdd 2无法编译——而(1,2) |> tupleAdd可以正常工作。这种方法可以扩展到超过2个元素的元组,主要的不同之处是不支持fst和snd。注意wildcard _ below的使用,它的意思是“忽略第2个位置的元素”:
let y = 1,2,3,4
let (c,_,d,e) = yprintfn "%i, %i, %i" c d e`
我们来看看这在manhattanDistance函数中的效果。我们取得两个像素(图像)数组并应用Array.zip,创建一个元组数组,相同索引的元素被配对在一起。简单地举个例子可能有助于理解: let array1 = [| "A";"B";"C" |]let array2 = [| 1 .. 3 |]let zipped = Array.zip array1 array2```在FSI中运行上述代码,应该产生如下输出,无须另加说明:
val zipped : (string * int) [] = [|("A", 1); ("B", 2); ("C", 3)|]`
现在我们有了曼哈顿距离函数,就可以搜索训练集中最接近所分类图像的元素了。
let train (trainingset:Observation[]) = let classify (pixels:int[]) = trainingset |> Array.minBy (fun x -> manhattanDistance x.Pixels pixels) |> fun x -> x.Label classifylet classifier = train training```train函数以一个Observation数组为参数。在函数中,我们创建另一个函数classify,以一个图像为参数,寻找与目标距离最小的图像,并返回最接近的候选图像的标签;train返回该函数。还要注意,manhattanDistance虽然不是train函数的一部分,但是仍然可以在该函数中使用,这称作“将变量捕捉到一个闭包中”,在一个函数中使用该函数中未定义作用域的变量。minBy(不存在于C#或者LINQ中)的用法也需要注意,该函数使用很方便,我们可以将它用于任何比较数值项的函数,找出数组中最小的元素。现在,简单地调用train training就可以创建一个模型。将鼠标指针悬停在classifier上,就可以看到它的类型:
val classifier : (int [] -> string)`
我们已经完成大部分工作了,现在验证分类器:
let validationPath = @" PATH-ON-YOUR-MACHINE\validationsample.csv"let validationData = reader validationPathvalidationData|> Array.averageBy (fun x -> if model x.Pixels = x.Label then 1. else 0.)|> printfn "Correct: %.3f"```这就很简单地转换了我们的C#代码:读取验证数据,用1标记每个正确的预测,并计算平均正确率,完成了!在一个文件的30行代码中,我们就得到了所有必要的功能。
转载地址:http://gjtta.baihongyu.com/