在Haskell中处理数据

177 阅读6分钟

在数据挖掘或一般探索中,通常需要有效地、无仪式地轻松访问数据。通常情况下,一种编程语言会专门为这种情况而设计,比如R,或者为它编写一个库,比如Python的pandas库。

在Haskell中实现这一点,我们在这一领域进行了改进,使用Haskell比Python或R有更多的好处,比如说。

  • 安全性--垃圾回收内存,不需要指针。
  • 性能--它很快速。如果你用Haskell编写新的算法,它们不需要被添加到语言中(如R)或用C语言编写(如Python)。
  • 并发性--可以简单地使用并发的算法,并利用你的其他CPU核心的优势。
  • 可维护性--静态类型在你写程序的时候保证了安全,当你以后回来改变它们的时候,就很难破坏它们。

让我们看看在Haskell中这样做的一个例子,并与Python的pandas中的做法进行比较。其步骤是

  1. 下载一个包含CSV文件的压缩文件。
  2. 解压缩该文件。
  3. 读取CSV文件。
  4. 对文件中的数据做一些操作。

在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_datetail_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_datetail_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_datetail_num 组成的记录,类型是DayString 。下面,我们将看看如何在算法中访问这些字段,并展示其安全方面。

调换管道部分

我们也可以很容易地切换到从文件读取。让我们把这个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 ofget

总结。

  • 从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_dateunique_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

然后我们得到

结论

在这篇文章中,我们已经展示了。

  1. 像bash脚本一样简洁地处理一连串的问题。
  2. 在持续使用内存的情况下完成了上述所有工作。
  3. 用一个类型安全的解析器完成了上述工作,静态地指定了我们的类型,但不需要提前声明或命名任何记录类型。

这只是一个示范,而不是一个成品。Haskell需要在这个领域开展工作,这篇文章中的例子并不是高性能的(但可以是高性能的),但这样的工作将是非常有成效的。

使用Haskell的优势是你所感兴趣的吗?如果是,请联系我们FP Complete。

通过电子邮件订阅我们的博客
电子邮件订阅来自我们的Atom feed,由Blogtrottr处理。您只会收到博客文章的通知,并且可以随时取消订阅。

你喜欢这篇博文并需要DevOps、Rust或函数式编程方面的帮助吗?请联系我们