在数据挖掘或一般探索中,通常需要有效地、无仪式地轻松访问数据。通常情况下,一种编程语言会专门为这种情况而设计,比如R,或者为它编写一个库,比如Python的pandas库。
在Haskell中实现这一点,我们在这一领域进行了改进,使用Haskell比Python或R有更多的好处,比如说。
- 安全性--垃圾回收内存,不需要指针。
- 性能--它很快速。如果你用Haskell编写新的算法,它们不需要被添加到语言中(如R)或用C语言编写(如Python)。
- 并发性--可以简单地使用并发的算法,并利用你的其他CPU核心的优势。
- 可维护性--静态类型在你写程序的时候保证了安全,当你以后回来改变它们的时候,就很难破坏它们。
让我们看看在Haskell中这样做的一个例子,并与Python的pandas中的做法进行比较。其步骤是
- 下载一个包含CSV文件的压缩文件。
- 解压缩该文件。
- 读取CSV文件。
- 对文件中的数据做一些操作。
在Haskell中,我们有所有需要的库(流式HTTP、CSV解析等)来实现这个目标,所以专门为这篇文章做了一个封装包,像pandas一样把它们集合在一起。我们有一些目标。
- 方便我们不希望在探索数据的同时还要写更多的代码。
- 恒定内存能够在恒定的内存空间中处理文件。如果我有一个1GB的文件,我不希望一次就把1GB的文件全部装入内存。
- 类型安全我希望一旦从CSV解析出来,我就有一个具有适当类型(整数、日期、文本等)的静态类型的数据结构。
Python例子
这个例子的代码取自Modern Pandas。在Python中,我们分块请求网络URL,然后将其写入一个文件。接下来,我们解压文件,然后数据就可以以df ,列名下移。
import zipfile
import requests
import numpy as np
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt
r = requests.get('https://chrisdone.com/ontime.csv.zip', stream=True)
with open("flights.csv", 'wb') as f:
for chunk in r.iter_content(chunk_size=1024):
if chunk:
f.write(chunk)
zf = zipfile.ZipFile("flights.csv.zip")
filename = zf.filelist[0].filename
fp = zf.extract(filename)
df = pd.read_csv(fp, parse_dates="FL_DATE").rename(columns=str.lower)
最后,我们可以看一下从第10行开始的5行,对于fl_date 和tail_num 列,像这样。
df.ix[10:14, ['fl_date', 'tail_num']]
=>
fl_date tail_num
10 2014-01-01 N002AA
11 2014-01-01 N3FXAA
12 2014-01-01 N906EV
13 2014-01-01 N903EV
14 2014-01-01 N903EV
Python:好与坏
Python代码的好部分。
- 从Zip文件中提取是相当容易的。
- 我们能够为一些字段指定将它们解析为不同的数据类型 (
parse_dates)。 - 引用数据的范围也很容易。
Python代码的不良部分。
- 读取HTTP请求是非常冗长的。我们手动将数据块流向磁盘,这似乎毫无意义。
- 它不是静态类型的。即使我们解析了
fl_date和tail_num,我们也不能确定它们是否仍然存在,或者是否是正确的类型。 - 我们把整个100MB的CSV加载到内存中。
让我们与我在Haskell中准备的解决方案进行比较。在阅读的同时,你也可以克隆我准备的仓库。
$ git clone git@github.com:chrisdone/labels.git --recursive
为这篇文章创建的包装库在labels-explore下,所有的代码样本都在labels-explore/app/Main.hs下。
Haskell例子
我准备了模块Labels.Explore ,它为我们提供了一些数据操作功能:网络请求、解压、CSV解析等。
输出。
把这个分解开来,src .| c .| c .> sink ,可以像UNIX管道src | c | c > sink 。
其步骤是
- 对压缩文件进行网络请求,产生一个字节流。
- 解压缩该字节流,得到CSV的字节流。
- 从CSV中解析为
("fl_date" := Day, "tail_num" := String)类型的记录。 - 指定
downcase选项,这样我们就可以处理小写的名字了。 - 删除前10个结果。
- 从剩余的结果中抽取5条。
- 将表格打印出来。
在这个库中,管线部分的命名惯例是。
- fooSource-- 管道开始的东西,流式输入的来源。
- fooConduit-- 将管道的两个部分连接在一起,也许做一些转换(如解析Zip、CSV或其他东西)。
- fooSink-- 所有的流式输入都被倒入其中,并产生最终结果。
Haskell:好的部分
Haskell版本好在哪里。
- 读取HTTP请求是微不足道的。
- 它是静态类型的。例如,如果我试图把
fl_date乘以一个数字,或者错误地写成fl_daet,在运行程序之前我就会得到一个编译错误。 - 它以流的方式实现了这一切,并不断地使用内存。
它是如何静态类型化的呢?在这里。
fromCsvConduit
@("fl_date" := Day, "tail_num" := String)
csv
我们已经静态地告诉fromCsvConduit 要构建的记录的确切类型:由两个字段fl_date 和tail_num 组成的记录,类型是Day 和String 。下面,我们将看看如何在算法中访问这些字段,并展示其安全方面。
调换管道部分
我们也可以很容易地切换到从文件读取。让我们把这个URL写到磁盘上,不加压缩。
main =
runResourceT
(httpSource "https://chrisdone.com/ontime.csv.zip" responseBody .|
zipEntryConduit "ontime.csv" .>
fileSink "ontime.csv")
现在我们的阅读变成了。
main =
runResourceT $
fileSource "ontime.csv" .|
fromCsvConduit
@("fl_date" := Day, "tail_num" := String)
(set #downcase True csv) .|
dropConduit 10 .|
takeConduit 5 .>
tableSink
数据压缩
要进行更详细的计算是很容易的。例如,为了显示总的航班数量和总的旅行距离,我们可以写。
main =
runResourceT $
fileSource "ontime.csv" .|
fromCsvConduit @("distance" := Double) (set #downcase True csv) .|
sinkConduit
(foldSink
(\table row ->
modify #flights (+ 1) (modify #distance (+ get #distance row) table))
(#flights := (0 :: Int), #distance := 0)) .>
tableSink
其输出结果是。
flights distance
471949 372072490.0
上面我们做了一个自己的汇,它消耗了所有的行,然后把这个结果顺势输出到表的汇,这样我们就得到了最后漂亮的表显示。
类型正确性
回到我们的安全点,想象一下上面我们犯了一些错误。
第一个错误,我不小心写了两次modify #flights 。
- modify #flights (+ 1) (modify #distance (+ get #distance row) table))
+ modify #flights (+ 1) (modify #flights (+ get #distance row) table))
在运行程序之前,Haskell类型检查器会提出以下信息。
• Couldn't match type ‘Int’ with ‘Double’
arising from a functional dependency between:
constraint ‘Has "flights" Double ("flights" := Int, "distance" := value0)’
arising from a use of ‘modify’
关于这些信息在代码中的来源,见下文。
同样地,如果我们在算法中把#distance 错写成#distant 。
- modify #flights (+ 1) (modify #distance (+ get #distance row) table))
+ modify #flights (+ 1) (modify #distance (+ get #distant row) table))
我们会得到这个错误信息。
No instance for (Has "distant" value0 ("distance" := Double))
arising from a use of ‘get’
总结。
- 从CSV中解析出的是正确的值。
- 如果我们要访问字段,字段必须存在。
- 我们不能错配类型。
所有这一切都增加了软件的可维护性,但我们没有必要说明更多的问题。
分组
如果我们想按一个字段分组,在pandas中是这样的。
first = df.groupby('airline_id')[['fl_date', 'unique_carrier']].first()
first.head()
我们只需更新代码中的类型,放入我们想要解析的额外字段。
csv :: Csv ("fl_date" := Day, "tail_num" := String
,"airline_id" := Int, "unique_carrier" := String)
然后我们的流水线就变成了。
fromCsvConduit
@("fl_date" := Day, "tail_num" := String,
"airline_id" := Int, "unique_carrier" := String)
(set #downcase True csv) .|
groupConduit #airline_id .|
explodeConduit .|
projectConduit @("fl_date" := _, "unique_carrier" := _) .|
takeConduit 5 .>
tableSink
- 我们添加了两个要解析的新字段。
- 我们通过
#airline_id字段分组到一个行的列表流中。这就把[x,y,z,a,b,c]流分组为例如[[x,y],[z,a],[b,c]]。 - 我们将这些组
[[x,y],[z,a],[b,c],...],并将每个组的部分爆发成一个流:[x,y,z,a,b,c,...]。 - 我们预测一个新的记录类型,只是为了在表格中显示包括
fl_date和unique_carrier。这些类型要保持原样,所以我们用_来表示 "你知道我的意思"。这就像SQL中的SELECT fl_date, unique_carrier。
输出。
Python的博文指出,对这个结果进行进一步查询。
first.ix[10:15, ['fl_date', 'tail_num']]
会产生一个意外的空数据框,这是由于pandas奇怪的索引行为。但是我们的结果很好,我们只是从输入流中删除了10个元素,而将tail_num 。
dropConduit 10 .|
projectConduit @("fl_date" := _, "tail_num" := _) .|
takeConduit 5 .>
tableSink
然后我们得到
结论
在这篇文章中,我们已经展示了。
- 像bash脚本一样简洁地处理一连串的问题。
- 在持续使用内存的情况下完成了上述所有工作。
- 用一个类型安全的解析器完成了上述工作,静态地指定了我们的类型,但不需要提前声明或命名任何记录类型。
这只是一个示范,而不是一个成品。Haskell需要在这个领域开展工作,这篇文章中的例子并不是高性能的(但可以是高性能的),但这样的工作将是非常有成效的。
使用Haskell的优势是你所感兴趣的吗?如果是,请联系我们FP Complete。
通过电子邮件订阅我们的博客
电子邮件订阅来自我们的Atom feed,由Blogtrottr处理。您只会收到博客文章的通知,并且可以随时取消订阅。
你喜欢这篇博文并需要DevOps、Rust或函数式编程方面的帮助吗?请联系我们。